Skip to main content

pubhubs/servers/
yivi.rs

1//! Tools for dealing with yivi.
2use std::cell::OnceCell;
3
4use anyhow::Context as _;
5use serde::{
6    self, Deserialize as _, Serialize as _,
7    de::{Error as _, IntoDeserializer as _},
8    ser::Error as _,
9};
10
11use crate::api;
12use crate::misc::jwt;
13use crate::misc::serde_ext::bytes_wrapper::B64UU;
14
15/// An extended session request, see:
16///  - <https://irma.app/docs/session-requests/#extra-parameters>, and
17///  -
18///  <https://github.com/privacybydesign/irmago/blob/f9718c334af76a3ad2fa23019d17957878cd2032/requests.go#L145>
19#[derive(serde::Serialize, serde::Deserialize, Debug, Clone, PartialEq, Eq)]
20pub struct ExtendedSessionRequest {
21    // TODO, maybe: validity, timeout, callbackUrl
22    request: SessionRequest,
23
24    /// Use to setup chained sessions, see
25    /// <https://docs.yivi.app/chained-sessions#the-nextsession-url>
26    #[serde(rename = "nextSession")]
27    #[serde(skip_serializing_if = "Option::is_none")]
28    pub next_session: Option<NextSessionData>,
29}
30
31/// <https://github.com/privacybydesign/irmago/blob/f9718c334af76a3ad2fa23019d17957878cd2032/requests.go#L139>
32#[derive(serde::Serialize, serde::Deserialize, Debug, Clone, PartialEq, Eq)]
33pub struct NextSessionData {
34    pub url: url::Url,
35}
36
37/// A session request sent by a requestor to a yivi server
38#[derive(serde::Serialize, serde::Deserialize, Debug, Clone, PartialEq, Eq)]
39pub struct SessionRequest {
40    #[serde(rename = "@context")]
41    context: LdContext,
42
43    /// <https://pkg.go.dev/github.com/privacybydesign/irmago#DisclosureRequest>
44    disclose: Option<AttributeConDisCon>,
45
46    /// <https://pkg.go.dev/github.com/privacybydesign/irmago#IssuanceRequest>
47    credentials: Option<Vec<CredentialToBeIssued>>,
48
49    /// <https://docs.yivi.app/session-requests/#skip-expiry-check>
50    #[serde(default)]
51    #[serde(skip_serializing_if = "Vec::is_empty")]
52    #[serde(rename = "skipExpiryCheck")]
53    skip_expiry_check: Vec<CredentialTypeIdentifier>,
54}
55
56impl ExtendedSessionRequest {
57    pub fn disclosure(cdc: AttributeConDisCon) -> Self {
58        Self {
59            request: SessionRequest {
60                context: LdContext::Disclosure,
61                disclose: Some(cdc),
62                credentials: None,
63                skip_expiry_check: vec![],
64            },
65            next_session: None,
66        }
67    }
68
69    pub fn issuance(credentials: Vec<CredentialToBeIssued>) -> Self {
70        Self {
71            request: SessionRequest {
72                context: LdContext::Issuance,
73                disclose: None,
74                credentials: Some(credentials),
75                skip_expiry_check: vec![],
76            },
77            next_session: None,
78        }
79    }
80
81    /// Adds a `next_session` field
82    pub fn next_session(self, url: url::Url) -> Self {
83        Self {
84            next_session: Some(NextSessionData { url }),
85            ..self
86        }
87    }
88
89    /// Signs this extended session request using the provided requestor credentials.
90    ///
91    /// Documentation: <https://docs.yivi.app/session-requests/#jwts-signed-session-requests>
92    /// Reference code for disclosure request:
93    ///     <https://github.com/privacybydesign/irmago/blob/d389b4559e007a0fcb4e78d1f6e073c1ad57bc13/requests.go#L957>
94    pub fn sign(self, creds: &Credentials<SigningKey>) -> anyhow::Result<jwt::JWT> {
95        creds
96            .key
97            .sign(
98                &jwt::Claims::new()
99                    .iat_now()?
100                    .claim("iss", &creds.name)? // issuer is requestor name
101                    .claim("sub", self.request.context.jwt_sub())?
102                    .claim(self.request.context.jwt_key(), self)?,
103            )
104            .context("signing session request")
105
106        // NOTE: the jwt library irmago uses adds a `"typ": "JWT"` to the header,
107        // but its presence is not checked, so we omit it.
108    }
109
110    /// Opens the given signed [`ExtendedSessionRequest`].
111    pub fn open_signed(
112        jwt: &jwt::JWT,
113        requestor_credentials: &Credentials<VerifyingKey>,
114    ) -> anyhow::Result<Self> {
115        let mut session_type_perhaps: Option<String> = None;
116
117        let mut claims = requestor_credentials
118            .key
119            .open(jwt)
120            .context("invalid jwt")?
121            .check_iss(jwt::expecting::exactly(&requestor_credentials.name))?
122            .check_sub(
123                |claim_name: &'static str, sub: Option<String>| -> Result<(), jwt::Error> {
124                    let sub = sub.ok_or_else(|| jwt::Error::MissingClaim(claim_name))?;
125
126                    assert!(
127                        session_type_perhaps.replace(sub.to_string()).is_none(),
128                        "bug: did not expect to set session_type twice"
129                    );
130
131                    Ok(())
132                },
133            )?;
134
135        let session_type = LdContext::from_jwt_sub(
136            &session_type_perhaps.expect("bug: expected session_type to be set here"),
137        )
138        .context("unknown subject in signed extended session request")?;
139
140        let session_request: Self = claims
141            .extract(session_type.jwt_key())?
142            .with_context(|| format!("missing claim {}", session_type.jwt_key()))?;
143
144        anyhow::ensure!(
145            session_request.request.context == session_type,
146            "session request jwt subject's, {}, does not align with session request context, {}",
147            session_type.jwt_sub(),
148            session_request.request.context.jwt_sub()
149        );
150
151        Ok(session_request)
152    }
153
154    /// Mocks a valid [`SessionResult`] to this [`ExtendedSessionRequest`] disclosing
155    /// the values specified by the `df` function.
156    ///
157    /// Only simple disclosure requests not involving any 'discon's are currently supported.
158    ///
159    /// # Panics
160    ///  - If `self` is not a disclosure request, or is missing the `disclosure` field.
161    ///  - If one of the 'discon's is empty, or not a singleton.
162    pub fn mock_disclosure_response(
163        &self,
164        df: impl Fn(&AttributeTypeIdentifier) -> String,
165    ) -> SessionResult {
166        assert_eq!(self.request.context, LdContext::Disclosure);
167
168        let disclosed: Vec<Vec<DisclosedAttribute>> = self
169            .request
170            .disclose
171            .as_ref()
172            .expect("missing `disclose` field in disclosure session request")
173            .iter()
174            .map(|dc: &Vec<Vec<AttributeRequest>>| {
175                assert_eq!(dc.len(), 1, "'discon's not supported by this mock function");
176
177                let con_req: &Vec<AttributeRequest> = &dc[0];
178
179                let con_resp: Vec<DisclosedAttribute> = con_req
180                    .iter()
181                    .map(|ar: &AttributeRequest| {
182                        DisclosedAttribute::mock(df(&ar.ty), ar.ty.clone())
183                    })
184                    .collect();
185
186                con_resp
187            })
188            .collect();
189
190        SessionResult::mock_disclosure(disclosed)
191    }
192}
193
194/// Some JSON linked data contexts <http://json-ld.org> used by yivi, primarily to identify a
195/// session's type.  Not to be confused with [`SessionType`].
196///
197/// <https://github.com/privacybydesign/irmago/blob/b1c38f4f2c9da3d3f39b5c21a330bcbd04143f41/requests.go#L21>
198#[derive(serde::Serialize, serde::Deserialize, Debug, Clone, PartialEq, Eq)]
199pub enum LdContext {
200    #[serde(rename = "https://irma.app/ld/request/disclosure/v2")]
201    Disclosure,
202
203    #[serde(rename = "https://irma.app/ld/request/signature/v2")]
204    Signature,
205
206    #[serde(rename = "https://irma.app/ld/request/issuance/v2")]
207    Issuance,
208}
209
210impl LdContext {
211    pub fn from_jwt_sub(jwt_sub: &str) -> Option<LdContext> {
212        match jwt_sub {
213            "verification_request" => Some(LdContext::Disclosure),
214            "signature_request" => Some(LdContext::Signature),
215            "issue_request" => Some(LdContext::Issuance),
216            _ => None,
217        }
218    }
219
220    /// The `sub` field of a signed session request JWT of this type
221    pub const fn jwt_sub(&self) -> &'static str {
222        match self {
223            LdContext::Disclosure => "verification_request",
224            LdContext::Signature => "signature_request",
225            LdContext::Issuance => "issue_request",
226        }
227    }
228
229    /// The key that holds this session request inside a JWT of a signed session request
230    pub const fn jwt_key(&self) -> &'static str {
231        match self {
232            LdContext::Disclosure => "sprequest",
233            LdContext::Signature => "absrequest",
234            LdContext::Issuance => "iprequest",
235        }
236    }
237}
238
239/// <https://pkg.go.dev/github.com/privacybydesign/irmago#AttributeConDisCon>
240pub type AttributeConDisCon = Vec<Vec<Vec<AttributeRequest>>>;
241
242/// <https://pkg.go.dev/github.com/privacybydesign/irmago#AttributeRequest>
243#[derive(serde::Serialize, serde::Deserialize, Debug, Clone, PartialEq, Eq)]
244pub struct AttributeRequest {
245    #[serde(rename = "type")] // 'type' is a keyword
246    pub ty: AttributeTypeIdentifier,
247
248    #[serde(default)]
249    #[serde(skip_serializing_if = "Option::is_none")]
250    pub value: Option<String>,
251}
252
253/// Known as
254/// [`CredentialRequest`](https://pkg.go.dev/github.com/privacybydesign/irmago#CredentialRequest) in irmago.
255#[derive(serde::Serialize, serde::Deserialize, Debug, Clone, PartialEq, Eq)]
256pub struct CredentialToBeIssued {
257    validity: Option<api::NumericDate>,
258
259    #[serde(rename = "credential")]
260    type_id: CredentialTypeIdentifier,
261
262    attributes: std::collections::HashMap<String, String>,
263}
264
265impl CredentialToBeIssued {
266    pub fn new(type_id: CredentialTypeIdentifier) -> Self {
267        Self {
268            validity: None,
269            type_id,
270            attributes: Default::default(),
271        }
272    }
273
274    pub fn valid_for(self, duration: std::time::Duration) -> Self {
275        Self {
276            validity: Some(api::NumericDate::now() + duration),
277            ..self
278        }
279    }
280
281    pub fn attribute(mut self, key: String, value: String) -> Self {
282        self.attributes.insert(key, value);
283        self
284    }
285}
286
287/// Credentials (name and key) for a requestor or yivi server.
288///
289/// To be used with `K` either [`SigningKey`] or [`VerifyingKey`].
290#[derive(serde::Serialize, serde::Deserialize, Debug, Clone, PartialEq, Eq)]
291#[serde(deny_unknown_fields)]
292pub struct Credentials<K> {
293    pub name: String,
294    pub key: K,
295}
296
297impl Credentials<SigningKey> {
298    /// Turn these signing credentials into verifying credentials
299    pub fn to_verifying_credentials(&self) -> Credentials<VerifyingKey> {
300        Credentials {
301            name: self.name.clone(),
302            key: self.key.to_verifying_key(),
303        }
304    }
305}
306
307/// Private key used by a requestor or yivi server to sign their JWTs.
308#[derive(serde::Serialize, serde::Deserialize, Debug, Clone, PartialEq, Eq)]
309#[serde(deny_unknown_fields)]
310pub enum SigningKey {
311    #[serde(rename = "hs256")]
312    HS256(B64UU<jwt::HS256>),
313
314    // We do not use the `Token`  Yivi `auth_method`s,
315    // see: https://docs.yivi.app/irma-server#requestor-authentication
316    #[serde(rename = "rs256")]
317    RS256(#[serde(with = "rs256sk_encoding")] Box<jwt::RS256Sk>),
318}
319
320/// We encode the RS256 private key using the PEM-encoded PKCS #8 format
321mod rs256sk_encoding {
322    use super::*;
323
324    pub fn deserialize<'de, D>(d: D) -> Result<Box<jwt::RS256Sk>, D::Error>
325    where
326        D: serde::Deserializer<'de>,
327    {
328        use std::borrow::Cow;
329
330        let s: Cow<'de, str> = Cow::<'de, str>::deserialize(d)?;
331
332        Ok(Box::new(
333            jwt::RS256Sk::from_pkcs8_pem(&s).map_err(D::Error::custom)?,
334        ))
335    }
336
337    // `serde(with = ...` forces the signature `&Box<...` that clippy does not like
338    #[expect(clippy::borrowed_box)]
339    pub fn serialize<S>(pk: &Box<jwt::RS256Sk>, s: S) -> Result<S::Ok, S::Error>
340    where
341        S: serde::ser::Serializer,
342    {
343        s.serialize_str(&pk.to_pkcs8_pem().map_err(S::Error::custom)?)
344    }
345}
346
347impl SigningKey {
348    /// Sign the given claims using this key.
349    ///
350    /// Note that [`SigningKey`] cannot implement [`jwt::Key`] because [`SigningKey`]
351    /// supports multiple algorithms.
352    fn sign<C: serde::Serialize>(&self, claims: &C) -> Result<jwt::JWT, jwt::Error> {
353        match self {
354            SigningKey::HS256(key) => jwt::JWT::create(claims, &**key),
355            SigningKey::RS256(key) => jwt::JWT::create(claims, &**key),
356        }
357    }
358
359    pub fn to_verifying_key(&self) -> VerifyingKey {
360        match self {
361            SigningKey::HS256(key) => VerifyingKey::HS256(key.clone()),
362            SigningKey::RS256(key) => {
363                VerifyingKey::RS256(jwt::RS256Vk::new(key.as_rsa_pub().clone()))
364            }
365        }
366    }
367}
368
369/// Public key used by a requestor or yivi server to sign their JWTs.
370#[derive(serde::Serialize, serde::Deserialize, Debug, Clone, PartialEq, Eq)]
371#[serde(deny_unknown_fields)]
372pub enum VerifyingKey {
373    #[serde(rename = "hs256")]
374    HS256(B64UU<jwt::HS256>),
375
376    #[serde(rename = "rs256")]
377    RS256(#[serde(with = "rs256vk_encoding")] jwt::RS256Vk),
378    // We do not use the `Token` Yivi `auth_method`s,
379    // see: https://docs.yivi.app/irma-server#requestor-authentication
380}
381
382/// We encode the RS256 public key using the PEM-encoded PKCS #8 format
383mod rs256vk_encoding {
384    use super::*;
385
386    pub fn deserialize<'de, D>(d: D) -> Result<jwt::RS256Vk, D::Error>
387    where
388        D: serde::Deserializer<'de>,
389    {
390        let s: &'de str = <&'de str>::deserialize(d)?;
391
392        jwt::RS256Vk::from_public_key_pem(s).map_err(D::Error::custom)
393    }
394
395    pub fn serialize<S>(pk: &jwt::RS256Vk, s: S) -> Result<S::Ok, S::Error>
396    where
397        S: serde::ser::Serializer,
398    {
399        s.serialize_str(&pk.to_public_key_pem().map_err(S::Error::custom)?)
400    }
401}
402
403impl VerifyingKey {
404    /// Open the given jwt using this key
405    fn open(&self, jwt: &jwt::JWT) -> Result<jwt::Claims, jwt::Error> {
406        match self {
407            VerifyingKey::HS256(key) => jwt::JWT::open(jwt, &**key),
408            VerifyingKey::RS256(key) => jwt::JWT::open(jwt, key),
409        }
410    }
411}
412
413/// Result of a Yivi session
414///
415/// <https://github.com/privacybydesign/irmago/blob/b1c38f4f2c9da3d3f39b5c21a330bcbd04143f41/server/api.go#L37>
416#[derive(serde::Serialize, serde::Deserialize, Debug, Clone, PartialEq, Eq)]
417pub struct SessionResult {
418    pub token: RequestorToken,
419    pub status: Status,
420
421    #[serde(rename = "type")]
422    pub session_type: SessionType,
423
424    #[serde(rename = "proofStatus")]
425    pub proof_status: Option<ProofStatus>,
426
427    pub disclosed: Option<Vec<Vec<DisclosedAttribute>>>,
428
429    // "signature" field: not used by us (yet)
430    pub error: Option<RemoteError>,
431
432    #[serde(rename = "nextSession")]
433    pub next_session: Option<RequestorToken>,
434}
435
436impl SessionResult {
437    /// Creates a mock session result containing the specified disclosed attributes
438    fn mock_disclosure(disclosed: Vec<Vec<DisclosedAttribute>>) -> SessionResult {
439        SessionResult {
440            token: "1234567890abcdefghij".parse().unwrap(),
441            status: Status::Done,
442            session_type: SessionType::Disclosing,
443            proof_status: Some(ProofStatus::Valid),
444            disclosed: Some(disclosed),
445            error: None,
446            next_session: None,
447        }
448    }
449}
450
451impl SessionResult {
452    /// Signs this session result using the provided yivi server credentials.
453    ///
454    /// <https://github.com/privacybydesign/irmago/blob/b1c38f4f2c9da3d3f39b5c21a330bcbd04143f41/server/api.go#L326>
455    pub fn sign(
456        self,
457        creds: &Credentials<SigningKey>,
458        validity: std::time::Duration,
459    ) -> anyhow::Result<jwt::JWT> {
460        creds
461            .key
462            .sign(
463                &jwt::Claims::from_custom(&self)?
464                    .iat_now()?
465                    .exp_after(validity)?
466                    .claim("iss", &creds.name)? // issuer is server name
467                    .claim("sub", self.session_type.to_result_sub())?,
468            )
469            .context("signing session result")
470    }
471
472    /// Opens the given signed [`SessionResult`].
473    pub fn open_signed(
474        jwt: &jwt::JWT,
475        server_credentials: &Credentials<VerifyingKey>,
476    ) -> anyhow::Result<Self> {
477        let mut session_type_perhaps: Option<SessionType> = None;
478
479        let session_result: Self = server_credentials
480            .key
481            .open(jwt)
482            .context("invalid jwt")?
483            .check_iss(jwt::expecting::exactly(&server_credentials.name))?
484            .check_sub(
485                |claim_name: &'static str, sub: Option<String>| -> Result<(), jwt::Error> {
486                    let sub = sub.ok_or_else(|| jwt::Error::MissingClaim(claim_name))?;
487
488                    assert!(
489                        session_type_perhaps
490                            .replace(SessionType::from_result_sub(&sub).map_err(|err| {
491                                jwt::Error::InvalidClaim {
492                                    claim_name,
493                                    source: err,
494                                }
495                            })?)
496                            .is_none(),
497                        "bug: did not expect to set session_type twice"
498                    );
499
500                    Ok(())
501                },
502            )?
503            .into_custom()?;
504
505        let session_type = session_type_perhaps.expect("bug: expected session_type to be set here");
506
507        anyhow::ensure!(
508            session_result.session_type == session_type,
509            "session result jwt subject, {}, does not align with session result type, {}",
510            session_type,
511            session_result.session_type
512        );
513
514        Ok(session_result)
515    }
516
517    /// Verifies that this [`SessionResult`] is valid ignoring the [`Self::disclosed`] field.
518    fn validate_except_disclosed(&self) -> anyhow::Result<()> {
519        anyhow::ensure!(
520            self.status == Status::Done || self.status == Status::Connected,
521            // NB: when a disclosure is POSTed by the Yivi server in a chained session to the
522            // nextSession url, the state is `connected`, not `done`.
523            "session status is not 'done' (or 'connected'), but {}",
524            self.status
525        );
526
527        if let Some(proof_status) = self.proof_status {
528            anyhow::ensure!(
529                proof_status == ProofStatus::Valid,
530                "session proof status is not 'valid', but {}",
531                proof_status
532            );
533        }
534
535        if let Some(error) = &self.error {
536            anyhow::bail!(
537                "session result error field set: {}",
538                serde_json::to_string(&error).unwrap_or_else(|_| "<ERROR SERIALIZING>".to_string()),
539            );
540        }
541
542        Ok(())
543    }
544
545    /// Verifyies that this [`SessionResult`] is valid, and for the first attribute in each inner conjunction
546    /// returns the [`AttributeTypeIdentifier`] and raw value.
547    pub fn validate_and_extract_raw_singles(
548        &self,
549    ) -> anyhow::Result<impl Iterator<Item = anyhow::Result<(&AttributeTypeIdentifier, &str)>>>
550    {
551        self.validate_except_disclosed()?;
552
553        Ok(self
554            .disclosed
555            .iter()
556            .flatten()
557            .map(|inner_con: &Vec<DisclosedAttribute>| {
558                let da: &DisclosedAttribute = inner_con
559                    .first()
560                    .context("inner conjunction has no attribute")?;
561
562                da.validate()?;
563
564                Ok((&da.id, da.raw_value.as_str()))
565            }))
566    }
567}
568
569/// Disclosure of a single attribute
570///
571/// <https://github.com/privacybydesign/irmago/blob/b1c38f4f2c9da3d3f39b5c21a330bcbd04143f41/verify.go#L36>
572#[derive(serde::Serialize, serde::Deserialize, Debug, Clone, PartialEq, Eq)]
573pub struct DisclosedAttribute {
574    #[serde(rename = "rawvalue")]
575    pub raw_value: String,
576
577    // NB: The field "value" (containing translations of the attribute) we don't use
578    /// The type of the disclosed attibute
579    pub id: AttributeTypeIdentifier,
580
581    pub status: AttributeProofStatus,
582
583    #[serde(rename = "issuancetime")]
584    pub issuance_time: api::NumericDate,
585
586    #[serde(rename = "notrevoked")]
587    pub not_revoked: Option<bool>,
588
589    #[serde(rename = "notrevokedbefore")]
590    pub not_revoked_before: Option<api::NumericDate>,
591}
592
593impl DisclosedAttribute {
594    fn mock(raw_value: String, id: AttributeTypeIdentifier) -> Self {
595        Self {
596            raw_value,
597            id,
598            status: AttributeProofStatus::Present,
599            issuance_time: api::NumericDate::now(),
600            not_revoked: None,
601            not_revoked_before: None,
602        }
603    }
604
605    /// Verifies that this [`DisclosedAttribute`] is valid.
606    fn validate(&self) -> anyhow::Result<()> {
607        anyhow::ensure!(
608            self.status == AttributeProofStatus::Present,
609            "proof status is not 'present'"
610        );
611
612        if self.not_revoked == Some(false) {
613            anyhow::bail!("attribute is revoked");
614        }
615
616        if let Some(not_revoked_before) = self.not_revoked_before
617            && api::NumericDate::now() > not_revoked_before
618        {
619            anyhow::bail!("attribute is (presumably) revoked after {not_revoked_before}");
620        }
621
622        Ok(())
623    }
624}
625
626/// Identifier for a yivi session used in requestor endpoints
627///
628/// <https://github.com/privacybydesign/irmago/blob/b1c38f4f2c9da3d3f39b5c21a330bcbd04143f41/messages.go#L179>
629#[derive(serde::Serialize, serde::Deserialize, Clone, Debug, Eq, PartialEq)]
630#[serde(transparent)]
631pub struct RequestorToken {
632    #[serde(deserialize_with = "RequestorToken::deserialize_inner")]
633    inner: String,
634}
635
636/// The regex pattern for a [`RequestorToken`]
637///
638/// <https://github.com/privacybydesign/irmago/blob/b1c38f4f2c9da3d3f39b5c21a330bcbd04143f41/internal/common/common.go#L39>
639const REQUESTOR_TOKEN_REGEX: &str = r"^[a-z0-9A-Z]{20}$";
640
641thread_local! {
642    /// Thread local compiled version of [`REQUESTOR_TOKEN_REGEX`]
643    static REQUESTOR_TOKEN_REGEX_TLK: OnceCell<regex::Regex> = const { OnceCell::new() };
644}
645
646/// Runs `f` with as argument a reference to a compiled [REQUESTOR_TOKEN_REGEX]
647/// that is cached thread locally.
648fn with_requestor_token_regex<R>(f: impl FnOnce(&regex::Regex) -> R) -> R {
649    REQUESTOR_TOKEN_REGEX_TLK.with(|oc: &OnceCell<regex::Regex>| {
650        f(oc.get_or_init(|| regex::Regex::new(REQUESTOR_TOKEN_REGEX).unwrap()))
651    })
652}
653
654impl RequestorToken {
655    fn deserialize_inner<'de, D>(d: D) -> Result<String, D::Error>
656    where
657        D: serde::de::Deserializer<'de>,
658    {
659        let inner: String = String::deserialize(d)?;
660
661        Self::validate_inner(&inner).map_err(D::Error::custom)?;
662
663        Ok(inner)
664    }
665
666    /// <https://github.com/privacybydesign/irmago/blob/b1c38f4f2c9da3d3f39b5c21a330bcbd04143f41/internal/common/common.go#L39>
667    fn validate_inner(inner: &str) -> anyhow::Result<()> {
668        if !with_requestor_token_regex(|r: &regex::Regex| r.is_match(inner)) {
669            anyhow::bail!(
670                "invalid yivi requestor token: did not match regex {}",
671                REQUESTOR_TOKEN_REGEX
672            );
673        }
674        Ok(())
675    }
676}
677
678impl std::str::FromStr for RequestorToken {
679    type Err = anyhow::Error;
680
681    fn from_str(inner: &str) -> Result<Self, Self::Err> {
682        Self::validate_inner(inner)?;
683        Ok(Self {
684            inner: inner.to_string(),
685        })
686    }
687}
688
689/// Identifier for a yivi attribute type, to us a string with three dots ('.').
690///
691/// <https://github.com/privacybydesign/irmago/blob/b1c38f4f2c9da3d3f39b5c21a330bcbd04143f41/identifiers.go#L60>
692///
693/// # Identifying credentials
694/// Yivi also permits attribute type identifiers with two dots, which refer to credentials, see
695/// for example:
696///
697/// <https://github.com/privacybydesign/irmago/blob/b1c38f4f2c9da3d3f39b5c21a330bcbd04143f41/requests.go#L382>
698///
699/// We don't, but use [`CredentialTypeIdentifier`] instead.
700#[derive(serde::Serialize, serde::Deserialize, Clone, Debug, Eq, PartialEq, Hash)]
701#[serde(transparent)]
702pub struct AttributeTypeIdentifier {
703    #[serde(deserialize_with = "AttributeTypeIdentifier::deserialize_inner")]
704    inner: String,
705}
706
707impl AttributeTypeIdentifier {
708    fn deserialize_inner<'de, D>(d: D) -> Result<String, D::Error>
709    where
710        D: serde::de::Deserializer<'de>,
711    {
712        let inner: String = String::deserialize(d)?;
713
714        Self::validate_inner(&inner).map_err(D::Error::custom)?;
715
716        Ok(inner)
717    }
718
719    /// Checks that the given string contains three dots ('.').
720    fn validate_inner(inner: &str) -> anyhow::Result<()> {
721        let dot_count: usize = inner.chars().filter(|c: &char| *c == '.').count();
722
723        if dot_count == 2 {
724            anyhow::bail!("we do not support yivi attribute identifiers with two dots");
725        }
726
727        anyhow::ensure!(
728            dot_count == 3,
729            "invalid yivi attribute type identifier: does not contain three dots"
730        );
731
732        Ok(())
733    }
734
735    /// Returns reference to underlying [`str`].
736    pub fn as_str(&self) -> &str {
737        &self.inner
738    }
739}
740
741impl std::str::FromStr for AttributeTypeIdentifier {
742    type Err = anyhow::Error;
743
744    fn from_str(inner: &str) -> Result<Self, Self::Err> {
745        Self::validate_inner(inner)?;
746        Ok(Self {
747            inner: inner.to_string(),
748        })
749    }
750}
751
752impl std::fmt::Display for AttributeTypeIdentifier {
753    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
754        self.inner.fmt(f)
755    }
756}
757
758/// Identifier for a yivi credential type, to us a string with two dots ('.').
759#[derive(serde::Serialize, serde::Deserialize, Clone, Debug, Eq, PartialEq)]
760#[serde(transparent)]
761pub struct CredentialTypeIdentifier {
762    #[serde(deserialize_with = "CredentialTypeIdentifier::deserialize_inner")]
763    inner: String,
764}
765
766impl CredentialTypeIdentifier {
767    fn deserialize_inner<'de, D>(d: D) -> Result<String, D::Error>
768    where
769        D: serde::de::Deserializer<'de>,
770    {
771        let inner: String = String::deserialize(d)?;
772
773        Self::validate_inner(&inner).map_err(D::Error::custom)?;
774
775        Ok(inner)
776    }
777
778    /// Checks that the given string contains two dots ('.').
779    fn validate_inner(inner: &str) -> anyhow::Result<()> {
780        let dot_count: usize = inner.chars().filter(|c: &char| *c == '.').count();
781
782        anyhow::ensure!(
783            dot_count == 2,
784            "invalid yivi credential type identifier: does not contain two dots"
785        );
786
787        Ok(())
788    }
789
790    /// Returns reference to underlying [`str`].
791    pub fn as_str(&self) -> &str {
792        &self.inner
793    }
794}
795
796impl std::str::FromStr for CredentialTypeIdentifier {
797    type Err = anyhow::Error;
798
799    fn from_str(inner: &str) -> Result<Self, Self::Err> {
800        Self::validate_inner(inner)?;
801        Ok(Self {
802            inner: inner.to_string(),
803        })
804    }
805}
806
807impl std::fmt::Display for CredentialTypeIdentifier {
808    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
809        self.inner.fmt(f)
810    }
811}
812
813/// Error type that may be part of a session result
814///
815/// <https://github.com/privacybydesign/irmago/blob/b1c38f4f2c9da3d3f39b5c21a330bcbd04143f41/messages.go#L119>
816#[derive(serde::Serialize, serde::Deserialize, Debug, PartialEq, Eq, Clone)]
817pub struct RemoteError {
818    pub status: Option<u64>,
819    pub error: Option<String>,
820    pub description: Option<String>,
821    pub message: Option<String>,
822    pub stacktrace: Option<String>,
823}
824
825/// Proof status of an entire session
826///
827/// <https://github.com/privacybydesign/irmago/blob/b1c38f4f2c9da3d3f39b5c21a330bcbd04143f41/verify.go#L23>
828#[derive(serde::Serialize, serde::Deserialize, Debug, PartialEq, Eq, Clone, Copy)]
829#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
830pub enum ProofStatus {
831    Valid,
832    Invalid,
833    InvalidTimestamp,
834    UnmatchedRequest,
835    MissingAttributes,
836    Expired,
837}
838
839impl std::fmt::Display for ProofStatus {
840    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
841        self.serialize(f)
842    }
843}
844
845/// Status of a yivi session
846///
847/// <https://github.com/privacybydesign/irmago/blob/b1c38f4f2c9da3d3f39b5c21a330bcbd04143f41/messages.go#L216>
848#[derive(serde::Serialize, serde::Deserialize, Debug, PartialEq, Eq, Clone, Copy)]
849#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
850pub enum Status {
851    Done,
852    Pairing,
853    Connected,
854    Cancelled,
855    Timeout,
856    Initialized,
857}
858
859impl std::fmt::Display for Status {
860    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
861        self.serialize(f)
862    }
863}
864
865/// Proof status of a single yivi attribute
866///
867/// <https://github.com/privacybydesign/irmago/blob/b1c38f4f2c9da3d3f39b5c21a330bcbd04143f41/verify.go#L30>
868#[derive(serde::Serialize, serde::Deserialize, Debug, PartialEq, Eq, Clone, Copy)]
869#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
870pub enum AttributeProofStatus {
871    Present,
872    Extra,
873    Null,
874}
875
876/// Session type (a.k.a. 'Actions')
877///
878/// <https://github.com/privacybydesign/irmago/blob/b1c38f4f2c9da3d3f39b5c21a330bcbd04143f41/messages.go#L227>
879#[derive(serde::Serialize, serde::Deserialize, Debug, PartialEq, Eq, Clone, Copy)]
880#[serde(rename_all = "lowercase")]
881pub enum SessionType {
882    Disclosing,
883    Signing,
884    Issuing,
885    Redirect,
886    Revoking,
887    Unknown,
888}
889
890impl std::fmt::Display for SessionType {
891    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
892        self.serialize(f)
893    }
894}
895
896impl std::str::FromStr for SessionType {
897    type Err = serde::de::value::Error;
898
899    fn from_str(s: &str) -> Result<Self, Self::Err> {
900        Self::deserialize(s.into_deserializer())
901    }
902}
903
904impl SessionType {
905    /// Inverse of [`SessionType::to_result_sub`].
906    fn from_result_sub(sub: &str) -> anyhow::Result<Self> {
907        let stripped_sub = sub
908            .strip_suffix("_result")
909            .ok_or_else(|| anyhow::anyhow!("subject did not end with '_result'"))?;
910
911        stripped_sub.parse().context("unknown session type")
912    }
913
914    /// Returns the `sub` value used for this session type in signed session result JWTs.
915    fn to_result_sub(self) -> String {
916        format!("{self}_result")
917    }
918}
919
920/// Represents a Yivi _epoch_ by its sequence number.  The `0`th epoch starts at 1970-01-01T00:00:00Z
921/// and each epoch lasts exactly $60 \cdot 60 \cdot 24 \cdot 7$ seconds (= 1 week).
922#[derive(
923    Debug, Clone, Copy, PartialOrd, Ord, Eq, PartialEq, serde::Serialize, serde::Deserialize,
924)]
925#[serde(transparent)]
926pub struct Epoch {
927    seqnr: u64,
928}
929
930impl Epoch {
931    /// Length of a Yivi epoch in seconds.
932    pub const fn seconds() -> u64 {
933        60 * 60 * 24 * 7
934    }
935
936    /// Returns the current Yivi epoch
937    pub fn current() -> Epoch {
938        api::NumericDate::now().into()
939    }
940
941    /// Returns the Yivi epoch with the given sequence number
942    pub fn with_seqnr(seqnr: u64) -> Self {
943        Self { seqnr }
944    }
945
946    /// Returns the second of this epoch starts
947    pub fn starts(&self) -> api::NumericDate {
948        api::NumericDate::new(self.seqnr * Self::seconds())
949    }
950
951    /// Returns the first second of the next epoch
952    pub fn ends(&self) -> api::NumericDate {
953        api::NumericDate::new((self.seqnr + 1) * Self::seconds())
954    }
955}
956
957impl From<api::NumericDate> for Epoch {
958    fn from(nd: api::NumericDate) -> Self {
959        Self {
960            seqnr: nd.timestamp() / Self::seconds(),
961        }
962    }
963}
964
965impl std::fmt::Display for Epoch {
966    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
967        write!(
968            f,
969            "Yivi epoch {} that starts {} and ends {}",
970            self.seqnr,
971            self.starts(),
972            self.ends()
973        )
974    }
975}
976
977#[cfg(test)]
978mod test {
979    use super::*;
980    use std::str::FromStr as _;
981
982    #[test]
983    fn requestor_token() {
984        let r1: RequestorToken = "1234567890abcdefghij".parse().unwrap();
985        let r2: RequestorToken = serde_json::from_str("\"1234567890abcdefghij\"").unwrap();
986        assert_eq!(r1, r2);
987
988        assert!(RequestorToken::from_str("1234567890 abcdefghij").is_err());
989    }
990
991    #[test]
992    fn attribute_type_identifier() {
993        let ati = serde_json::from_str::<AttributeTypeIdentifier>("\"a.b.c.d\"").unwrap();
994        assert_eq!(serde_json::to_string(&ati).unwrap(), "\"a.b.c.d\"");
995        assert!(serde_json::from_str::<AttributeTypeIdentifier>("\"a.b.c\"").is_err());
996    }
997
998    #[test]
999    fn test_session_result_validation() {
1000        let sr: SessionResult = serde_json::from_value(serde_json::json!({
1001          "disclosed": [
1002            [
1003              {
1004                "id": "irma-demo.sidn-pbdf.email.email",
1005                "issuancetime": 1735776000,
1006                "rawvalue": "test@test.com",
1007                "status": "PRESENT",
1008                "value": {
1009                  "": "test@test.com",
1010                  "en": "test@test.com",
1011                  "nl": "test@test.com"
1012                }
1013              }
1014            ],
1015            [
1016              {
1017                "id": "irma-demo.sidn-pbdf.mobilenumber.mobilenumber",
1018                "issuancetime": 1735776000,
1019                "rawvalue": "0612345678",
1020                "status": "PRESENT",
1021                "value": {
1022                  "": "0612345678",
1023                  "en": "0612345678",
1024                  "nl": "0612345678"
1025                }
1026              }
1027            ]
1028          ],
1029          "proofStatus": "VALID",
1030          "status": "DONE",
1031          "token": "KDRkE7LE0jIPhIBNdoBb",
1032          "type": "disclosing"
1033        }))
1034        .unwrap();
1035
1036        let mut results: Vec<(&AttributeTypeIdentifier, &str)> = sr
1037            .validate_and_extract_raw_singles()
1038            .unwrap()
1039            .map(|r| r.unwrap())
1040            .collect();
1041
1042        // Sort by second argument, just to get predictable output
1043        results.sort_by_key(|(_, v)| v.to_string());
1044
1045        assert_eq!(
1046            results,
1047            vec![
1048                (
1049                    &AttributeTypeIdentifier::from_str(
1050                        "irma-demo.sidn-pbdf.mobilenumber.mobilenumber"
1051                    )
1052                    .unwrap(),
1053                    "0612345678"
1054                ),
1055                (
1056                    &AttributeTypeIdentifier::from_str("irma-demo.sidn-pbdf.email.email").unwrap(),
1057                    "test@test.com"
1058                ),
1059            ]
1060        );
1061    }
1062}