Skip to main content

pg_core/
api.rs

1//! Definitions of the PostGuard protocol REST API.
2
3use crate::{artifacts::SigningKeyExt, identity::Attribute};
4use alloc::string::String;
5use alloc::vec::Vec;
6use irma::{ProofStatus, SessionStatus};
7use serde::{Deserialize, Serialize};
8
9/// The public parameters of the Private Key Generator (PKG).
10#[derive(Debug, Serialize, Deserialize)]
11#[serde(rename_all = "camelCase")]
12pub struct Parameters<T> {
13    /// The formatting version of the Master Public Key.
14    pub format_version: u8,
15
16    /// The Master Public Key.
17    pub public_key: T,
18}
19
20/// An attribute in a disclosure request, extending [`Attribute`] with an `optional` flag.
21///
22/// When `optional` is true, the PKG wraps this attribute in a disjunction with an empty
23/// first option, allowing the user to skip disclosing it in the Yivi app.
24///
25/// This type is only used in API requests (JSON), not in the binary wire format.
26///
27/// Unknown fields are rejected: in a key-issuance request a silently dropped
28/// misspelled field (`vaule` for `v`, `optioanl` for `optional`) would *widen*
29/// the disclosure the caller intended, so a typo must be a 400, not a
30/// reinterpretation.
31#[derive(Debug, Serialize, Deserialize, Clone)]
32#[serde(deny_unknown_fields)]
33pub struct DisclosureAttribute {
34    /// Attribute type.
35    #[serde(rename = "t")]
36    pub atype: String,
37
38    /// Attribute value.
39    #[serde(rename = "v")]
40    pub value: Option<String>,
41
42    /// Whether this attribute is optional in the disclosure session.
43    #[serde(default, skip_serializing_if = "crate::util::is_false")]
44    pub optional: bool,
45}
46
47/// An authentication request for a IRMA identity.
48///
49/// Each entry in `con` is either a single attribute ([`ConItem::Single`]) or
50/// a Yivi disjunction-of-conjunctions ([`ConItem::Discon`]). The legacy flat
51/// `[{t,v?,optional?}, ...]` JSON shape still deserialises, because
52/// `ConItem` is `#[serde(untagged)]`.
53#[derive(Debug, Serialize, Deserialize)]
54#[serde(deny_unknown_fields)]
55pub struct IrmaAuthRequest {
56    /// The conjunction of attributes (or disjunctions) for the disclosure request.
57    pub con: Vec<ConItem>,
58    #[serde(skip_serializing_if = "Option::is_none")]
59    /// The validity (in seconds) of the JWT response.
60    pub validity: Option<u64>,
61}
62
63/// The key response from the Private Key Generator (PKG).
64#[derive(Debug, Serialize, Deserialize)]
65#[serde(rename_all = "camelCase")]
66pub struct KeyResponse<T> {
67    /// The status of the session.
68    pub status: SessionStatus,
69
70    /// The status of the IRMA proof.
71    #[serde(skip_serializing_if = "Option::is_none")]
72    pub proof_status: Option<ProofStatus>,
73
74    /// The key will remain `None` until the status is `Done` and the proof is `Valid`.
75    #[serde(skip_serializing_if = "Option::is_none")]
76    pub key: Option<T>,
77}
78
79/// The request Signing key request body.
80#[derive(Debug, Serialize, Deserialize)]
81#[serde(rename_all = "camelCase", deny_unknown_fields)]
82pub struct SigningKeyRequest {
83    /// The public signing identity.
84    pub pub_sign_id: Vec<Attribute>,
85
86    /// The private signing identity.
87    #[serde(skip_serializing_if = "Option::is_none")]
88    pub priv_sign_id: Option<Vec<Attribute>>,
89}
90
91/// One entry inside [`IrmaAuthRequest::con`].
92///
93/// Backwards compatible widening: existing callers post a JSON array of
94/// `{t,v?,optional?}` objects, which deserialize into [`ConItem::Single`].
95/// New callers may post an inner JSON array-of-arrays for a Yivi
96/// disjunction-of-conjunctions (`OR` of `AND`), which deserializes into
97/// [`ConItem::Discon`]. An empty inner conjunction marks the discon as
98/// optional per Yivi convention.
99#[derive(Debug, Serialize, Clone)]
100#[serde(untagged)]
101pub enum ConItem {
102    /// A single attribute, optionally marked `optional: true`.
103    Single(DisclosureAttribute),
104    /// A disjunction of conjunctions of attributes.
105    Discon(Vec<Vec<DisclosureAttribute>>),
106}
107
108/// Manual [`Deserialize`] instead of `#[serde(untagged)]`: untagged enums
109/// swallow the inner error ("data did not match any variant"), which is
110/// useless to a client debugging a 400. The JSON shape already discriminates
111/// the variants (object vs array), so dispatch on it and let the real error —
112/// e.g. ``unknown field `vaule`, expected one of `t`, `v`, `optional``` —
113/// propagate.
114impl<'de> Deserialize<'de> for ConItem {
115    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
116    where
117        D: serde::Deserializer<'de>,
118    {
119        struct ConItemVisitor;
120
121        impl<'de> serde::de::Visitor<'de> for ConItemVisitor {
122            type Value = ConItem;
123
124            fn expecting(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
125                f.write_str(
126                    "an attribute object like {\"t\": …} or a disjunction \
127                        (array of conjunctions, i.e. array of arrays of attributes)",
128                )
129            }
130
131            fn visit_map<A>(self, map: A) -> Result<ConItem, A::Error>
132            where
133                A: serde::de::MapAccess<'de>,
134            {
135                DisclosureAttribute::deserialize(serde::de::value::MapAccessDeserializer::new(map))
136                    .map(ConItem::Single)
137            }
138
139            fn visit_seq<A>(self, seq: A) -> Result<ConItem, A::Error>
140            where
141                A: serde::de::SeqAccess<'de>,
142            {
143                Vec::<Vec<DisclosureAttribute>>::deserialize(
144                    serde::de::value::SeqAccessDeserializer::new(seq),
145                )
146                .map(ConItem::Discon)
147            }
148        }
149
150        deserializer.deserialize_any(ConItemVisitor)
151    }
152}
153
154/// The signing key response from the Private Key Generator (PKG).
155#[derive(Debug, Serialize, Deserialize)]
156#[serde(rename_all = "camelCase")]
157pub struct SigningKeyResponse {
158    /// The status of the session.
159    pub status: SessionStatus,
160
161    /// The status of the IRMA proof.
162    #[serde(skip_serializing_if = "Option::is_none")]
163    pub proof_status: Option<ProofStatus>,
164
165    /// The public signing key.
166    /// The key will remain `None` until the status is `Done` and the proof is `Valid`.
167    #[serde(skip_serializing_if = "Option::is_none")]
168    pub pub_sign_key: Option<SigningKeyExt>,
169
170    /// This private signing key.
171    #[serde(skip_serializing_if = "Option::is_none")]
172    pub priv_sign_key: Option<SigningKeyExt>,
173}
174
175#[cfg(test)]
176mod tests {
177    use super::*;
178
179    /// New shape: a JSON `con` array containing a discon (nested array)
180    /// must deserialize into [`ConItem::Discon`] alongside any [`ConItem::Single`] entries.
181    #[test]
182    fn irma_auth_request_accepts_discon_entry() {
183        let body = r#"{
184            "con": [
185                { "t": "pbdf.sidn-pbdf.email.email" },
186                [
187                    [ { "t": "pbdf.gemeente.personalData.fullname" } ],
188                    [
189                        { "t": "pbdf.pbdf.passport.firstName" },
190                        { "t": "pbdf.pbdf.passport.lastName" }
191                    ]
192                ]
193            ]
194        }"#;
195
196        let req: IrmaAuthRequest =
197            serde_json::from_str(body).expect("body should parse with a discon entry");
198
199        assert_eq!(req.con.len(), 2);
200        match &req.con[0] {
201            ConItem::Single(a) => assert_eq!(a.atype, "pbdf.sidn-pbdf.email.email"),
202            other => panic!("expected Single, got {:?}", other),
203        }
204        match &req.con[1] {
205            ConItem::Discon(d) => {
206                assert_eq!(d.len(), 2, "two alternatives");
207                assert_eq!(d[0].len(), 1, "first alt: one attr");
208                assert_eq!(d[1].len(), 2, "second alt: firstName+lastName");
209                assert_eq!(d[0][0].atype, "pbdf.gemeente.personalData.fullname");
210                assert_eq!(d[1][0].atype, "pbdf.pbdf.passport.firstName");
211                assert_eq!(d[1][1].atype, "pbdf.pbdf.passport.lastName");
212            }
213            other => panic!("expected Discon, got {:?}", other),
214        }
215    }
216
217    /// Unknown fields in a request are rejected, not silently ignored: a
218    /// misspelled `v` would otherwise WIDEN the disclosure the caller
219    /// intended. The error must name the offending field so a client
220    /// developer can act on the 400.
221    #[test]
222    fn irma_auth_request_rejects_unknown_attribute_field() {
223        let body =
224            r#"{ "con": [ { "t": "pbdf.sidn-pbdf.email.email", "vaule": "alice@example.com" } ] }"#;
225
226        let err = serde_json::from_str::<IrmaAuthRequest>(body)
227            .expect_err("a misspelled attribute field must be rejected");
228        let msg = alloc::string::ToString::to_string(&err);
229        assert!(
230            msg.contains("vaule"),
231            "error must name the unknown field, got: {msg}"
232        );
233    }
234
235    /// Same at the top level: extra request fields are rejected.
236    #[test]
237    fn irma_auth_request_rejects_unknown_top_level_field() {
238        let body = r#"{ "con": [ { "t": "pbdf.sidn-pbdf.email.email" } ], "validty": 300 }"#;
239
240        let err = serde_json::from_str::<IrmaAuthRequest>(body)
241            .expect_err("a misspelled top-level field must be rejected");
242        let msg = alloc::string::ToString::to_string(&err);
243        assert!(
244            msg.contains("validty"),
245            "error must name the unknown field, got: {msg}"
246        );
247    }
248
249    /// The nesting mistake (a one-level array of attributes where a
250    /// disjunction needs arrays-of-arrays) yields the visitor's shape hint,
251    /// not an inscrutable untagged-enum error.
252    #[test]
253    fn con_item_nesting_mistake_gets_a_useful_error() {
254        let body = r#"{ "con": [ [ { "t": "pbdf.gemeente.personalData.fullname" } ] ] }"#;
255
256        let err = serde_json::from_str::<IrmaAuthRequest>(body)
257            .expect_err("attributes directly inside a disjunction must be rejected");
258        let msg = alloc::string::ToString::to_string(&err);
259        assert!(
260            !msg.contains("did not match any variant"),
261            "must not surface the untagged-enum catch-all, got: {msg}"
262        );
263    }
264
265    /// Backwards-compat: an old-style flat `con` of `{t,v?,optional?}` objects
266    /// must still parse into [`ConItem::Single`] variants.
267    #[test]
268    fn irma_auth_request_keeps_parsing_flat_con() {
269        let body = r#"{
270            "con": [
271                { "t": "pbdf.sidn-pbdf.email.email" },
272                { "t": "pbdf.gemeente.personalData.fullname", "optional": true }
273            ]
274        }"#;
275
276        let req: IrmaAuthRequest = serde_json::from_str(body).expect("legacy flat con must parse");
277
278        assert_eq!(req.con.len(), 2);
279        for item in &req.con {
280            assert!(matches!(item, ConItem::Single(_)), "all entries Single");
281        }
282        if let ConItem::Single(a) = &req.con[1] {
283            assert!(a.optional, "optional flag preserved");
284        }
285    }
286}