baseid_mdl/
lifecycle.rs

1//! mdoc implementation of the unified credential lifecycle traits.
2//!
3//! `MdocLifecycle` implements `CredentialIssuer`, `CredentialVerifier`, and
4//! `CredentialPresenter` for the ISO 18013-5 mdoc format. Predicates are not
5//! supported and return `UnsupportedPredicate`.
6
7use std::collections::BTreeMap;
8
9use baseid_core::claims::{ClaimDisclosure, ClaimSet, DisclosureSelection};
10use baseid_core::error::{CredentialError, CryptoError, SerializationError};
11use baseid_core::lifecycle::{
12    CredentialIssuer, CredentialPresenter, CredentialVerifier, IssuanceOptions, IssuedCredential,
13    PresentationOptions, PresentedCredential, RevocationStatus, VerificationOutcome,
14};
15use baseid_core::types::CredentialFormat;
16use baseid_crypto::signer::{Signer, Verifier};
17use serde::{Deserialize, Serialize};
18
19use sha2::{Digest, Sha256};
20
21use crate::mdoc::{DataElement, MobileDocument};
22use crate::mso::{MobileSecurityObject, ValidityInfo};
23
24/// Wire format for an issued mdoc credential.
25///
26/// Matches the ISO 18013-5 `IssuerSigned` structure:
27/// - `doc_type`: the document type string
28/// - `namespaces`: the CBOR-encoded namespaces
29/// - `issuer_auth`: the COSE_Sign1 bytes (signed MSO)
30#[derive(Debug, Clone, Serialize, Deserialize)]
31struct IssuerSigned {
32    #[serde(rename = "docType")]
33    doc_type: String,
34    namespaces: BTreeMap<String, Vec<DataElement>>,
35    #[serde(rename = "issuerAuth")]
36    issuer_auth: Vec<u8>,
37}
38
39/// mdoc credential lifecycle implementation.
40///
41/// Wraps COSE_Sign1 signing/verification and MSO digest operations behind
42/// the unified lifecycle traits.
43pub struct MdocLifecycle<'a> {
44    signer: &'a dyn Signer,
45    verifier: &'a dyn Verifier,
46    doc_type: String,
47}
48
49impl<'a> MdocLifecycle<'a> {
50    /// Create a new mdoc lifecycle handler.
51    ///
52    /// # Arguments
53    /// * `signer` - Key for signing (issuance)
54    /// * `verifier` - Key for verification
55    /// * `doc_type` - Document type (e.g., `"org.iso.18013.5.1.mDL"`)
56    pub fn new(signer: &'a dyn Signer, verifier: &'a dyn Verifier, doc_type: &str) -> Self {
57        Self {
58            signer,
59            verifier,
60            doc_type: doc_type.to_string(),
61        }
62    }
63
64    /// Derive the default namespace from the doc_type.
65    ///
66    /// For `"org.iso.18013.5.1.mDL"`, returns `"org.iso.18013.5.1"`.
67    fn default_namespace(&self) -> String {
68        // Strip trailing ".mDL" or similar suffix
69        if let Some(pos) = self.doc_type.rfind('.') {
70            self.doc_type[..pos].to_string()
71        } else {
72            self.doc_type.clone()
73        }
74    }
75}
76
77impl CredentialIssuer for MdocLifecycle<'_> {
78    fn format(&self) -> CredentialFormat {
79        CredentialFormat::Mdl
80    }
81
82    fn issue(
83        &self,
84        issuer_did: &str,
85        subject_did: Option<&str>,
86        claims: &ClaimSet,
87        options: &IssuanceOptions,
88    ) -> baseid_core::Result<IssuedCredential> {
89        let default_ns = self.default_namespace();
90        let mut namespaces: BTreeMap<String, Vec<DataElement>> = BTreeMap::new();
91
92        // Convert ClaimSet → MobileDocument namespaces
93        for (ns_key, ns_claims) in claims.namespaces() {
94            // Map default namespace ("") to the doc_type-derived namespace
95            let target_ns = if ns_key.is_empty() {
96                default_ns.clone()
97            } else {
98                ns_key.to_string()
99            };
100
101            let elements: Vec<DataElement> = ns_claims
102                .iter()
103                .map(|(name, value)| DataElement {
104                    identifier: name.clone(),
105                    value: value.clone(),
106                })
107                .collect();
108
109            namespaces.entry(target_ns).or_default().extend(elements);
110        }
111
112        let doc = MobileDocument {
113            doc_type: self.doc_type.clone(),
114            namespaces: namespaces.clone(),
115        };
116
117        // Build validity info from options
118        let now = options
119            .valid_from
120            .clone()
121            .unwrap_or_else(|| "2024-01-01T00:00:00Z".to_string());
122        let valid_until = options
123            .valid_until
124            .clone()
125            .unwrap_or_else(|| "2099-12-31T23:59:59Z".to_string());
126
127        let validity = ValidityInfo {
128            signed: now.clone(),
129            valid_from: now,
130            valid_until,
131        };
132
133        // Create MSO and sign it
134        let mso = MobileSecurityObject::create(&doc, validity)?;
135        let issuer_auth = crate::cose::sign_mso(&mso, self.signer)?;
136
137        // Package as IssuerSigned CBOR
138        let issuer_signed = IssuerSigned {
139            doc_type: self.doc_type.clone(),
140            namespaces,
141            issuer_auth,
142        };
143
144        let mut cbor_bytes = Vec::new();
145        ciborium::into_writer(&issuer_signed, &mut cbor_bytes)
146            .map_err(|_| SerializationError::Cbor)?;
147
148        Ok(IssuedCredential {
149            data: cbor_bytes,
150            format: CredentialFormat::Mdl,
151            id: options.credential_id.clone(),
152            issuer: issuer_did.to_string(),
153            subject: subject_did.map(|s| s.to_string()),
154        })
155    }
156}
157
158impl CredentialVerifier for MdocLifecycle<'_> {
159    fn format(&self) -> CredentialFormat {
160        CredentialFormat::Mdl
161    }
162
163    fn verify(&self, credential_data: &[u8]) -> baseid_core::Result<VerificationOutcome> {
164        // 1. Deserialize the IssuerSigned CBOR structure
165        let issuer_signed: IssuerSigned =
166            ciborium::from_reader(credential_data).map_err(|_| SerializationError::Cbor)?;
167
168        // 2. Verify COSE_Sign1 signature and extract MSO
169        let mso = crate::cose::verify_signed_mso(&issuer_signed.issuer_auth, self.verifier)?;
170
171        // 3. Verify element digests (supports selective disclosure)
172        //    Each present element's CBOR hash must match some digest in the MSO
173        //    for that namespace. Missing elements are OK (selective disclosure).
174        if mso.doc_type != issuer_signed.doc_type {
175            return Err(CryptoError::VerificationFailed.into());
176        }
177
178        for (ns_key, elements) in &issuer_signed.namespaces {
179            let mso_entries = mso
180                .value_digests
181                .get(ns_key)
182                .ok_or(CryptoError::VerificationFailed)?;
183
184            // Collect MSO digests for quick lookup
185            let mso_digests: Vec<&[u8]> = mso_entries.iter().map(|e| e.digest.as_slice()).collect();
186
187            for element in elements {
188                let cbor = element.to_cbor()?;
189                let hash = Sha256::digest(&cbor);
190                if !mso_digests.iter().any(|d| *d == &hash[..]) {
191                    return Err(CryptoError::VerificationFailed.into());
192                }
193            }
194        }
195
196        // 4. Convert MobileDocument → ClaimSet
197        let default_ns = self.default_namespace();
198        let mut claim_set = ClaimSet::new();
199
200        for (ns_key, elements) in &issuer_signed.namespaces {
201            // Map doc_type-derived namespace back to default ("")
202            let claim_ns = if *ns_key == default_ns {
203                ""
204            } else {
205                ns_key.as_str()
206            };
207
208            for element in elements {
209                claim_set.insert(claim_ns, &element.identifier, element.value.clone());
210            }
211        }
212
213        // Extract validity info
214        let valid_until = Some(mso.validity_info.valid_until.clone());
215
216        Ok(VerificationOutcome {
217            valid: true,
218            format: CredentialFormat::Mdl,
219            issuer: String::new(), // mdoc doesn't carry issuer DID in the MSO
220            subject: None,
221            claims: claim_set,
222            unlinkable: false,
223            predicates_verified: vec![],
224            valid_until,
225            revocation_status: RevocationStatus::NotChecked,
226        })
227    }
228}
229
230impl CredentialPresenter for MdocLifecycle<'_> {
231    fn format(&self) -> CredentialFormat {
232        CredentialFormat::Mdl
233    }
234
235    fn present(
236        &self,
237        credential_data: &[u8],
238        disclosure: &DisclosureSelection,
239        _options: &PresentationOptions,
240    ) -> baseid_core::Result<PresentedCredential> {
241        // mdoc does not support predicates
242        if disclosure.has_predicates() {
243            return Err(CredentialError::UnsupportedPredicate.into());
244        }
245
246        // Deserialize the issued credential
247        let issuer_signed: IssuerSigned =
248            ciborium::from_reader(credential_data).map_err(|_| SerializationError::Cbor)?;
249
250        let default_ns = self.default_namespace();
251
252        // Apply selective disclosure at element level within namespaces
253        let mut filtered_namespaces: BTreeMap<String, Vec<DataElement>> = BTreeMap::new();
254
255        for (ns_key, elements) in &issuer_signed.namespaces {
256            let mut kept = Vec::new();
257
258            for element in elements {
259                // Check namespaced disclosure first, then default namespace
260                let namespaced_disc = disclosure.get(ns_key, &element.identifier);
261                let default_disc = if *ns_key == default_ns {
262                    disclosure.get("", &element.identifier)
263                } else {
264                    None
265                };
266
267                let disc = namespaced_disc.or(default_disc);
268
269                match disc {
270                    Some(ClaimDisclosure::Hide) => {
271                        // Omit this element
272                    }
273                    Some(ClaimDisclosure::Reveal) | None => {
274                        // Include: explicitly revealed or not mentioned (default include)
275                        kept.push(element.clone());
276                    }
277                    Some(ClaimDisclosure::Predicate(_)) => {
278                        // Should not reach here — checked above
279                        return Err(CredentialError::UnsupportedPredicate.into());
280                    }
281                }
282            }
283
284            if !kept.is_empty() {
285                filtered_namespaces.insert(ns_key.clone(), kept);
286            }
287        }
288
289        // Re-serialize with filtered namespaces + original issuerAuth
290        let presented = IssuerSigned {
291            doc_type: issuer_signed.doc_type,
292            namespaces: filtered_namespaces,
293            issuer_auth: issuer_signed.issuer_auth,
294        };
295
296        let mut cbor_bytes = Vec::new();
297        ciborium::into_writer(&presented, &mut cbor_bytes).map_err(|_| SerializationError::Cbor)?;
298
299        Ok(PresentedCredential {
300            data: cbor_bytes,
301            format: CredentialFormat::Mdl,
302            unlinkable: false,
303        })
304    }
305}
306
307#[cfg(test)]
308mod tests {
309    use super::*;
310    use baseid_core::claims::PredicateType;
311    use baseid_core::types::KeyType;
312    use baseid_crypto::KeyPair;
313    use serde_json::json;
314
315    fn setup() -> (KeyPair, ClaimSet) {
316        let kp = KeyPair::generate(KeyType::Ed25519).unwrap();
317        let mut claims = ClaimSet::new();
318        claims.insert("", "family_name", json!("Doe"));
319        claims.insert("", "given_name", json!("John"));
320        claims.insert("", "document_number", json!("DL-123456"));
321        claims.insert("", "birth_date", json!("1990-01-15"));
322        (kp, claims)
323    }
324
325    fn make_lifecycle(kp: &KeyPair) -> MdocLifecycle<'_> {
326        MdocLifecycle::new(kp, &kp.public, "org.iso.18013.5.1.mDL")
327    }
328
329    #[test]
330    fn issue_and_verify() {
331        let (kp, claims) = setup();
332        let lifecycle = make_lifecycle(&kp);
333
334        let issued = lifecycle
335            .issue(
336                "did:key:issuer",
337                Some("did:key:holder"),
338                &claims,
339                &IssuanceOptions::default(),
340            )
341            .unwrap();
342
343        assert_eq!(issued.format, CredentialFormat::Mdl);
344        assert_eq!(issued.issuer, "did:key:issuer");
345        assert_eq!(issued.subject, Some("did:key:holder".to_string()));
346
347        let outcome = lifecycle.verify(&issued.data).unwrap();
348        assert!(outcome.valid);
349        assert_eq!(outcome.format, CredentialFormat::Mdl);
350        assert_eq!(outcome.claims.get("", "family_name"), Some(&json!("Doe")));
351        assert_eq!(outcome.claims.get("", "given_name"), Some(&json!("John")));
352        assert_eq!(
353            outcome.claims.get("", "document_number"),
354            Some(&json!("DL-123456"))
355        );
356        assert!(!outcome.unlinkable);
357    }
358
359    #[test]
360    fn issue_with_namespaced_claims() {
361        let kp = KeyPair::generate(KeyType::Ed25519).unwrap();
362        let lifecycle = make_lifecycle(&kp);
363
364        let mut claims = ClaimSet::new();
365        claims.insert("org.iso.18013.5.1", "family_name", json!("Smith"));
366        claims.insert("org.iso.18013.5.1", "given_name", json!("Alice"));
367        claims.insert("org.iso.18013.5.1.aamva", "DHS_compliance", json!("F"));
368
369        let issued = lifecycle
370            .issue("did:key:issuer", None, &claims, &IssuanceOptions::default())
371            .unwrap();
372
373        let outcome = lifecycle.verify(&issued.data).unwrap();
374        assert!(outcome.valid);
375        // Default mdoc namespace maps back to "" in ClaimSet
376        assert_eq!(outcome.claims.get("", "family_name"), Some(&json!("Smith")));
377        assert_eq!(outcome.claims.get("", "given_name"), Some(&json!("Alice")));
378        // Non-default namespaces are preserved as-is
379        assert_eq!(
380            outcome
381                .claims
382                .get("org.iso.18013.5.1.aamva", "DHS_compliance"),
383            Some(&json!("F"))
384        );
385    }
386
387    #[test]
388    fn present_selective_disclosure() {
389        let (kp, claims) = setup();
390        let lifecycle = make_lifecycle(&kp);
391
392        let issued = lifecycle
393            .issue(
394                "did:key:issuer",
395                Some("did:key:holder"),
396                &claims,
397                &IssuanceOptions::default(),
398            )
399            .unwrap();
400
401        // Reveal only name, hide document_number and birth_date
402        let disclosure = DisclosureSelection::new()
403            .reveal("family_name")
404            .reveal("given_name")
405            .hide("document_number")
406            .hide("birth_date");
407
408        let presented = lifecycle
409            .present(&issued.data, &disclosure, &PresentationOptions::default())
410            .unwrap();
411
412        assert_eq!(presented.format, CredentialFormat::Mdl);
413        assert!(!presented.unlinkable);
414
415        // Verify the presentation — only revealed claims present
416        let outcome = lifecycle.verify(&presented.data).unwrap();
417        assert!(outcome.valid);
418        assert_eq!(outcome.claims.get("", "family_name"), Some(&json!("Doe")));
419        assert_eq!(outcome.claims.get("", "given_name"), Some(&json!("John")));
420        assert_eq!(outcome.claims.get("", "document_number"), None);
421        assert_eq!(outcome.claims.get("", "birth_date"), None);
422    }
423
424    #[test]
425    fn predicate_returns_unsupported() {
426        let (kp, claims) = setup();
427        let lifecycle = make_lifecycle(&kp);
428
429        let issued = lifecycle
430            .issue("did:key:issuer", None, &claims, &IssuanceOptions::default())
431            .unwrap();
432
433        let disclosure = DisclosureSelection::new()
434            .reveal("family_name")
435            .predicate("birth_date", PredicateType::LessThan(json!("2008-03-01")));
436
437        let result = lifecycle.present(&issued.data, &disclosure, &PresentationOptions::default());
438        assert!(result.is_err());
439
440        let err = result.unwrap_err();
441        assert!(
442            format!("{err}").contains("Predicate"),
443            "Error should mention predicate: {err}"
444        );
445    }
446
447    #[test]
448    fn issuance_with_options() {
449        let (kp, claims) = setup();
450        let lifecycle = make_lifecycle(&kp);
451
452        let opts = IssuanceOptions {
453            credential_id: Some("urn:uuid:mdl-1234".to_string()),
454            types: vec!["mDL".to_string()],
455            valid_from: Some("2024-01-01T00:00:00Z".to_string()),
456            valid_until: Some("2025-01-01T00:00:00Z".to_string()),
457            status: None,
458        };
459
460        let issued = lifecycle
461            .issue("did:key:issuer", Some("did:key:holder"), &claims, &opts)
462            .unwrap();
463
464        assert_eq!(issued.id, Some("urn:uuid:mdl-1234".to_string()));
465
466        let outcome = lifecycle.verify(&issued.data).unwrap();
467        assert_eq!(
468            outcome.valid_until,
469            Some("2025-01-01T00:00:00Z".to_string())
470        );
471    }
472
473    #[test]
474    fn format_method() {
475        let kp = KeyPair::generate(KeyType::Ed25519).unwrap();
476        let lifecycle = make_lifecycle(&kp);
477
478        assert_eq!(CredentialIssuer::format(&lifecycle), CredentialFormat::Mdl);
479        assert_eq!(
480            CredentialVerifier::format(&lifecycle),
481            CredentialFormat::Mdl
482        );
483        assert_eq!(
484            CredentialPresenter::format(&lifecycle),
485            CredentialFormat::Mdl
486        );
487    }
488}