Skip to content

Commit cc47b60

Browse files
authored
feat(api): accept Yivi condiscon in IrmaAuthRequest (#198)
Add `ConItem` to `pg-core::api`, an `#[serde(untagged)]` enum whose two variants — `Single(DisclosureAttribute)` and `Discon(Vec<Vec<...>>)` — let a top-level `con` entry be either one attribute (legacy) or a disjunction-of-conjunctions (new). `IrmaAuthRequest.con` widens to `Vec<ConItem>`. The legacy flat `{"con":[{t,v?,optional?}, ...]}` JSON body still parses, because untagged enums fall through to the matching variant. In `pg-pkg::handlers::start`, the request → Yivi `DisclosureRequest` mapping moves into `con_item_to_discon`. `Single` keeps today's behaviour (`[[ar]]` or `[[],[ar]]` when `optional`); `Discon` passes each inner conjunction through verbatim. The auth middleware already flattens whatever the user disclosed, so verification is unchanged. Motivation: postguard-website needs to accept a name from one of four credentials (gemeente fullname OR firstName+lastName from passport / idcard / drivinglicence). Expressing that requires a Yivi disjunction-of-conjunctions, which the previous flat shape could not encode. The condiscon-to-conjunction normalisation already happens inside `Derive` (Algorithm 3 in the hand-off doc), so this only widens the HTTP boundary, not the protocol. Backwards compatible: legacy callers (pg-cli, postguard-js < this PR) keep working without changes; new callers opt in to discons. Tests: - `pg-core/src/api.rs`: round-trip a JSON body containing a discon entry; round-trip a legacy flat body. Both pass. - `pg-pkg/src/handlers/start.rs`: unit tests on `con_item_to_discon` for Single (required + optional) and Discon (basic + with empty alternative). `cargo test -p pg-core --features test`: 43/43. `cargo test -p pg-pkg`: 34/34 (4 new + 30 prior).
1 parent 610e782 commit cc47b60

4 files changed

Lines changed: 206 additions & 30 deletions

File tree

pg-cli/src/decrypt.rs

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -74,10 +74,12 @@ pub async fn exec(dec_opts: DecOpts) -> Result<()> {
7474
con: reconstructed_policy
7575
.con
7676
.iter()
77-
.map(|attr| DisclosureAttribute {
78-
atype: attr.atype.clone(),
79-
value: attr.value.clone(),
80-
optional: false,
77+
.map(|attr| {
78+
ConItem::Single(DisclosureAttribute {
79+
atype: attr.atype.clone(),
80+
value: attr.value.clone(),
81+
optional: false,
82+
})
8183
})
8284
.collect(),
8385
validity: None,

pg-cli/src/encrypt.rs

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
1-
use pg_core::api::{DisclosureAttribute, IrmaAuthRequest, SigningKeyRequest, SigningKeyResponse};
1+
use pg_core::api::{
2+
ConItem, DisclosureAttribute, IrmaAuthRequest, SigningKeyRequest, SigningKeyResponse,
3+
};
24
use pg_core::client::rust::stream::SealerStreamConfig;
35
use pg_core::client::Sealer;
46
use pg_core::identity::{Attribute, Policy};
@@ -126,10 +128,12 @@ pub async fn exec(enc_opts: EncOpts) -> Result<()> {
126128
.request_start(&IrmaAuthRequest {
127129
con: total_id
128130
.into_iter()
129-
.map(|a| DisclosureAttribute {
130-
atype: a.atype,
131-
value: a.value,
132-
optional: false,
131+
.map(|a| {
132+
ConItem::Single(DisclosureAttribute {
133+
atype: a.atype,
134+
value: a.value,
135+
optional: false,
136+
})
133137
})
134138
.collect(),
135139
validity: None,

pg-core/src/api.rs

Lines changed: 89 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -39,10 +39,15 @@ pub struct DisclosureAttribute {
3939
}
4040

4141
/// An authentication request for a IRMA identity.
42+
///
43+
/// Each entry in `con` is either a single attribute ([`ConItem::Single`]) or
44+
/// a Yivi disjunction-of-conjunctions ([`ConItem::Discon`]). The legacy flat
45+
/// `[{t,v?,optional?}, ...]` JSON shape still deserialises, because
46+
/// `ConItem` is `#[serde(untagged)]`.
4247
#[derive(Debug, Serialize, Deserialize)]
4348
pub struct IrmaAuthRequest {
44-
/// The conjunction of attributes for the disclosure request.
45-
pub con: Vec<DisclosureAttribute>,
49+
/// The conjunction of attributes (or disjunctions) for the disclosure request.
50+
pub con: Vec<ConItem>,
4651
#[serde(skip_serializing_if = "Option::is_none")]
4752
/// The validity (in seconds) of the JWT response.
4853
pub validity: Option<u64>,
@@ -76,6 +81,23 @@ pub struct SigningKeyRequest {
7681
pub priv_sign_id: Option<Vec<Attribute>>,
7782
}
7883

84+
/// One entry inside [`IrmaAuthRequest::con`].
85+
///
86+
/// Backwards compatible widening: existing callers post a JSON array of
87+
/// `{t,v?,optional?}` objects, which deserialize into [`ConItem::Single`].
88+
/// New callers may post an inner JSON array-of-arrays for a Yivi
89+
/// disjunction-of-conjunctions (`OR` of `AND`), which deserializes into
90+
/// [`ConItem::Discon`]. An empty inner conjunction marks the discon as
91+
/// optional per Yivi convention.
92+
#[derive(Debug, Serialize, Deserialize, Clone)]
93+
#[serde(untagged)]
94+
pub enum ConItem {
95+
/// A single attribute, optionally marked `optional: true`.
96+
Single(DisclosureAttribute),
97+
/// A disjunction of conjunctions of attributes.
98+
Discon(Vec<Vec<DisclosureAttribute>>),
99+
}
100+
79101
/// The signing key response from the Private Key Generator (PKG).
80102
#[derive(Debug, Serialize, Deserialize)]
81103
#[serde(rename_all = "camelCase")]
@@ -96,3 +118,68 @@ pub struct SigningKeyResponse {
96118
#[serde(skip_serializing_if = "Option::is_none")]
97119
pub priv_sign_key: Option<SigningKeyExt>,
98120
}
121+
122+
#[cfg(test)]
123+
mod tests {
124+
use super::*;
125+
126+
/// New shape: a JSON `con` array containing a discon (nested array)
127+
/// must deserialize into [`ConItem::Discon`] alongside any [`ConItem::Single`] entries.
128+
#[test]
129+
fn irma_auth_request_accepts_discon_entry() {
130+
let body = r#"{
131+
"con": [
132+
{ "t": "pbdf.sidn-pbdf.email.email" },
133+
[
134+
[ { "t": "pbdf.gemeente.personalData.fullname" } ],
135+
[
136+
{ "t": "pbdf.pbdf.passport.firstName" },
137+
{ "t": "pbdf.pbdf.passport.lastName" }
138+
]
139+
]
140+
]
141+
}"#;
142+
143+
let req: IrmaAuthRequest =
144+
serde_json::from_str(body).expect("body should parse with a discon entry");
145+
146+
assert_eq!(req.con.len(), 2);
147+
match &req.con[0] {
148+
ConItem::Single(a) => assert_eq!(a.atype, "pbdf.sidn-pbdf.email.email"),
149+
other => panic!("expected Single, got {:?}", other),
150+
}
151+
match &req.con[1] {
152+
ConItem::Discon(d) => {
153+
assert_eq!(d.len(), 2, "two alternatives");
154+
assert_eq!(d[0].len(), 1, "first alt: one attr");
155+
assert_eq!(d[1].len(), 2, "second alt: firstName+lastName");
156+
assert_eq!(d[0][0].atype, "pbdf.gemeente.personalData.fullname");
157+
assert_eq!(d[1][0].atype, "pbdf.pbdf.passport.firstName");
158+
assert_eq!(d[1][1].atype, "pbdf.pbdf.passport.lastName");
159+
}
160+
other => panic!("expected Discon, got {:?}", other),
161+
}
162+
}
163+
164+
/// Backwards-compat: an old-style flat `con` of `{t,v?,optional?}` objects
165+
/// must still parse into [`ConItem::Single`] variants.
166+
#[test]
167+
fn irma_auth_request_keeps_parsing_flat_con() {
168+
let body = r#"{
169+
"con": [
170+
{ "t": "pbdf.sidn-pbdf.email.email" },
171+
{ "t": "pbdf.gemeente.personalData.fullname", "optional": true }
172+
]
173+
}"#;
174+
175+
let req: IrmaAuthRequest = serde_json::from_str(body).expect("legacy flat con must parse");
176+
177+
assert_eq!(req.con.len(), 2);
178+
for item in &req.con {
179+
assert!(matches!(item, ConItem::Single(_)), "all entries Single");
180+
}
181+
if let ConItem::Single(a) = &req.con[1] {
182+
assert!(a.optional, "optional flag preserved");
183+
}
184+
}
185+
}

pg-pkg/src/handlers/start.rs

Lines changed: 102 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ use crate::util::{IrmaToken, IrmaUrl};
22
use crate::Error;
33
use actix_web::{web::Data, web::Json, HttpResponse};
44
use irma::*;
5-
use pg_core::api::IrmaAuthRequest;
5+
use pg_core::api::{ConItem, DisclosureAttribute, IrmaAuthRequest};
66

77
/// Maximum allowed validity (in seconds) of a JWT (1 day).
88
const MAX_VALIDITY: u64 = 60 * 60 * 24;
@@ -43,6 +43,34 @@ async fn create_irma_session(
4343
Ok(HttpResponse::Ok().json(session))
4444
}
4545

46+
fn attr_to_request(attr: &DisclosureAttribute) -> AttributeRequest {
47+
AttributeRequest::Compound {
48+
attr_type: attr.atype.clone(),
49+
value: attr.value.clone().filter(|v: &String| !v.is_empty()),
50+
not_null: true,
51+
}
52+
}
53+
54+
/// Map one top-level `con` entry to the Yivi discon shape
55+
/// (`Vec<Vec<AttributeRequest>>` — OR of ANDs).
56+
fn con_item_to_discon(item: &ConItem) -> Vec<Vec<AttributeRequest>> {
57+
match item {
58+
ConItem::Single(attr) => {
59+
let ar = attr_to_request(attr);
60+
if attr.optional {
61+
// Empty first option lets the user skip this attribute.
62+
vec![vec![], vec![ar]]
63+
} else {
64+
vec![vec![ar]]
65+
}
66+
}
67+
ConItem::Discon(disjuncts) => disjuncts
68+
.iter()
69+
.map(|conj| conj.iter().map(attr_to_request).collect())
70+
.collect(),
71+
}
72+
}
73+
4674
// Starts a Yivi disclosure session.
4775
// Builds a disclosure request for every attribute in the request's policy.
4876
pub async fn start(
@@ -52,24 +80,7 @@ pub async fn start(
5280
) -> Result<HttpResponse, Error> {
5381
let kr = value.into_inner();
5482

55-
let discons: Vec<Vec<Vec<AttributeRequest>>> = kr
56-
.con
57-
.iter()
58-
.map(|attr| {
59-
let ar = AttributeRequest::Compound {
60-
attr_type: attr.atype.clone(),
61-
value: attr.value.clone().filter(|v: &String| !v.is_empty()),
62-
not_null: true,
63-
};
64-
65-
if attr.optional {
66-
// Empty first option means the user may skip this attribute
67-
vec![vec![], vec![ar]]
68-
} else {
69-
vec![vec![ar]]
70-
}
71-
})
72-
.collect();
83+
let discons: Vec<Vec<Vec<AttributeRequest>>> = kr.con.iter().map(con_item_to_discon).collect();
7384

7485
let dr = DisclosureRequestBuilder::new().add_discons(discons).build();
7586

@@ -80,3 +91,75 @@ pub async fn start(
8091

8192
create_irma_session(&url, &irma_token, dr, kr.validity).await
8293
}
94+
95+
#[cfg(test)]
96+
mod tests {
97+
use super::*;
98+
99+
fn attr(t: &str) -> DisclosureAttribute {
100+
DisclosureAttribute {
101+
atype: t.to_string(),
102+
value: None,
103+
optional: false,
104+
}
105+
}
106+
107+
fn extract_atype(ar: &AttributeRequest) -> &str {
108+
match ar {
109+
AttributeRequest::Compound { attr_type, .. } => attr_type.as_str(),
110+
AttributeRequest::Simple(s) => s.as_str(),
111+
}
112+
}
113+
114+
#[test]
115+
fn single_required_maps_to_one_inner_conjunction() {
116+
let item = ConItem::Single(attr("pbdf.sidn-pbdf.email.email"));
117+
let dis = con_item_to_discon(&item);
118+
assert_eq!(dis.len(), 1, "required: one option");
119+
assert_eq!(dis[0].len(), 1, "one attribute");
120+
assert_eq!(extract_atype(&dis[0][0]), "pbdf.sidn-pbdf.email.email");
121+
}
122+
123+
#[test]
124+
fn single_optional_prepends_empty_alternative() {
125+
let mut a = attr("pbdf.sidn-pbdf.mobilenumber.mobilenumber");
126+
a.optional = true;
127+
let item = ConItem::Single(a);
128+
let dis = con_item_to_discon(&item);
129+
assert_eq!(dis.len(), 2, "optional: empty alt + actual");
130+
assert!(dis[0].is_empty(), "empty alternative first");
131+
assert_eq!(dis[1].len(), 1);
132+
}
133+
134+
#[test]
135+
fn discon_maps_each_inner_conjunction_through() {
136+
// Models the name disjunction: gemeente fullname OR passport firstName+lastName.
137+
let item = ConItem::Discon(vec![
138+
vec![attr("pbdf.gemeente.personalData.fullname")],
139+
vec![
140+
attr("pbdf.pbdf.passport.firstName"),
141+
attr("pbdf.pbdf.passport.lastName"),
142+
],
143+
]);
144+
let dis = con_item_to_discon(&item);
145+
assert_eq!(dis.len(), 2, "two alternatives");
146+
assert_eq!(dis[0].len(), 1, "first: fullname only");
147+
assert_eq!(dis[1].len(), 2, "second: firstName + lastName");
148+
assert_eq!(
149+
extract_atype(&dis[0][0]),
150+
"pbdf.gemeente.personalData.fullname"
151+
);
152+
assert_eq!(extract_atype(&dis[1][0]), "pbdf.pbdf.passport.firstName");
153+
assert_eq!(extract_atype(&dis[1][1]), "pbdf.pbdf.passport.lastName");
154+
}
155+
156+
#[test]
157+
fn discon_with_empty_alternative_is_passed_through() {
158+
// Yivi-native way to mark a whole discon as optional.
159+
let item = ConItem::Discon(vec![vec![], vec![attr("pbdf.foo.bar.baz")]]);
160+
let dis = con_item_to_discon(&item);
161+
assert_eq!(dis.len(), 2);
162+
assert!(dis[0].is_empty(), "empty alternative preserved");
163+
assert_eq!(dis[1].len(), 1);
164+
}
165+
}

0 commit comments

Comments
 (0)