baseid_mdl/
mso.rs

1//! Mobile Security Object (MSO) for mdoc integrity and authenticity.
2
3use baseid_core::error::CryptoError;
4use serde::{Deserialize, Serialize};
5use sha2::{Digest, Sha256};
6use std::collections::BTreeMap;
7
8use crate::mdoc::MobileDocument;
9
10/// Mobile Security Object — signs over digests of data elements.
11#[derive(Debug, Clone, Serialize, Deserialize)]
12pub struct MobileSecurityObject {
13    /// Version of the MSO.
14    pub version: String,
15    /// Digest algorithm used (e.g., "SHA-256").
16    pub digest_algorithm: String,
17    /// Digests of data elements, keyed by namespace.
18    pub value_digests: BTreeMap<String, Vec<DigestEntry>>,
19    /// Document type this MSO applies to.
20    pub doc_type: String,
21    /// Validity information.
22    pub validity_info: ValidityInfo,
23}
24
25/// A digest entry mapping a digest ID to its value.
26#[derive(Debug, Clone, Serialize, Deserialize)]
27pub struct DigestEntry {
28    pub digest_id: u64,
29    pub digest: Vec<u8>,
30}
31
32/// Validity period for an MSO.
33#[derive(Debug, Clone, Serialize, Deserialize)]
34pub struct ValidityInfo {
35    pub signed: String,
36    pub valid_from: String,
37    pub valid_until: String,
38}
39
40impl MobileSecurityObject {
41    /// Create an MSO from a `MobileDocument`, computing SHA-256 digests of
42    /// each CBOR-encoded data element.
43    pub fn create(doc: &MobileDocument, validity_info: ValidityInfo) -> baseid_core::Result<Self> {
44        let mut value_digests = BTreeMap::new();
45
46        for (namespace, elements) in &doc.namespaces {
47            let mut entries = Vec::new();
48            for (idx, element) in elements.iter().enumerate() {
49                let cbor = element.to_cbor()?;
50                let hash = Sha256::digest(&cbor);
51                entries.push(DigestEntry {
52                    digest_id: idx as u64,
53                    digest: hash.to_vec(),
54                });
55            }
56            value_digests.insert(namespace.clone(), entries);
57        }
58
59        Ok(Self {
60            version: "1.0".to_string(),
61            digest_algorithm: "SHA-256".to_string(),
62            value_digests,
63            doc_type: doc.doc_type.clone(),
64            validity_info,
65        })
66    }
67
68    /// Verify that the data elements in the provided document match digests
69    /// in this MSO.
70    ///
71    /// Supports selective disclosure: the document may contain a subset of the
72    /// elements that were originally signed. Each presented element's CBOR
73    /// digest must exist in the MSO's digest list for that namespace.
74    ///
75    /// Returns `Ok(true)` if all presented elements have matching digests,
76    /// `Ok(false)` if any element's digest is not found in the MSO, and
77    /// `Err` if the document structure is invalid (e.g., doc_type mismatch
78    /// or unknown namespace).
79    pub fn verify_digests(&self, doc: &MobileDocument) -> baseid_core::Result<bool> {
80        if self.doc_type != doc.doc_type {
81            return Err(CryptoError::VerificationFailed.into());
82        }
83
84        for (namespace, elements) in &doc.namespaces {
85            let mso_entries = self
86                .value_digests
87                .get(namespace)
88                .ok_or(CryptoError::VerificationFailed)?;
89
90            // Collect MSO digests for lookup
91            let mso_digests: Vec<&[u8]> = mso_entries.iter().map(|e| e.digest.as_slice()).collect();
92
93            for element in elements {
94                let cbor = element.to_cbor()?;
95                let hash = Sha256::digest(&cbor);
96
97                if !mso_digests.iter().any(|d| *d == &hash[..]) {
98                    return Ok(false);
99                }
100            }
101        }
102
103        Ok(true)
104    }
105}
106
107#[cfg(test)]
108mod tests {
109    use super::*;
110    use crate::mdoc::{DataElement, MobileDocument};
111
112    fn sample_doc() -> MobileDocument {
113        let mut namespaces = BTreeMap::new();
114        namespaces.insert(
115            "org.iso.18013.5.1".to_string(),
116            vec![
117                DataElement {
118                    identifier: "family_name".to_string(),
119                    value: serde_json::json!("Doe"),
120                },
121                DataElement {
122                    identifier: "given_name".to_string(),
123                    value: serde_json::json!("John"),
124                },
125            ],
126        );
127        MobileDocument {
128            doc_type: "org.iso.18013.5.1.mDL".to_string(),
129            namespaces,
130        }
131    }
132
133    fn sample_validity() -> ValidityInfo {
134        ValidityInfo {
135            signed: "2024-01-01T00:00:00Z".to_string(),
136            valid_from: "2024-01-01T00:00:00Z".to_string(),
137            valid_until: "2025-01-01T00:00:00Z".to_string(),
138        }
139    }
140
141    #[test]
142    fn mso_create_and_verify() {
143        let doc = sample_doc();
144        let mso = MobileSecurityObject::create(&doc, sample_validity()).unwrap();
145
146        assert_eq!(mso.doc_type, doc.doc_type);
147        assert_eq!(mso.digest_algorithm, "SHA-256");
148        assert_eq!(mso.value_digests.len(), 1);
149
150        let entries = &mso.value_digests["org.iso.18013.5.1"];
151        assert_eq!(entries.len(), 2);
152        assert_eq!(entries[0].digest.len(), 32); // SHA-256 output
153
154        assert!(mso.verify_digests(&doc).unwrap());
155    }
156
157    #[test]
158    fn tampered_element_detected() {
159        let doc = sample_doc();
160        let mso = MobileSecurityObject::create(&doc, sample_validity()).unwrap();
161
162        // Tamper with a data element
163        let mut tampered = doc.clone();
164        tampered.namespaces.get_mut("org.iso.18013.5.1").unwrap()[0].value =
165            serde_json::json!("Smith");
166
167        assert!(!mso.verify_digests(&tampered).unwrap());
168    }
169
170    #[test]
171    fn unknown_namespace_in_doc_detected() {
172        let doc = sample_doc();
173        let mso = MobileSecurityObject::create(&doc, sample_validity()).unwrap();
174
175        // Present a doc with a namespace not in the MSO
176        let mut bad_doc = MobileDocument {
177            doc_type: "org.iso.18013.5.1.mDL".to_string(),
178            namespaces: BTreeMap::new(),
179        };
180        bad_doc.namespaces.insert(
181            "org.unknown.namespace".to_string(),
182            vec![DataElement {
183                identifier: "field".to_string(),
184                value: serde_json::json!("value"),
185            }],
186        );
187
188        assert!(mso.verify_digests(&bad_doc).is_err());
189    }
190
191    #[test]
192    fn empty_doc_passes_trivially() {
193        let doc = sample_doc();
194        let mso = MobileSecurityObject::create(&doc, sample_validity()).unwrap();
195
196        // Empty doc with correct doc_type — no elements to verify
197        let empty_doc = MobileDocument {
198            doc_type: "org.iso.18013.5.1.mDL".to_string(),
199            namespaces: BTreeMap::new(),
200        };
201
202        assert!(mso.verify_digests(&empty_doc).unwrap());
203    }
204
205    #[test]
206    fn selective_disclosure_subset_passes() {
207        let doc = sample_doc(); // has family_name + given_name
208        let mso = MobileSecurityObject::create(&doc, sample_validity()).unwrap();
209
210        // Present only family_name (subset of original)
211        let mut subset = BTreeMap::new();
212        subset.insert(
213            "org.iso.18013.5.1".to_string(),
214            vec![DataElement {
215                identifier: "family_name".to_string(),
216                value: serde_json::json!("Doe"),
217            }],
218        );
219        let subset_doc = MobileDocument {
220            doc_type: "org.iso.18013.5.1.mDL".to_string(),
221            namespaces: subset,
222        };
223
224        assert!(mso.verify_digests(&subset_doc).unwrap());
225    }
226
227    #[test]
228    fn selective_disclosure_with_tampered_element_fails() {
229        let doc = sample_doc();
230        let mso = MobileSecurityObject::create(&doc, sample_validity()).unwrap();
231
232        // Present subset with tampered value
233        let mut subset = BTreeMap::new();
234        subset.insert(
235            "org.iso.18013.5.1".to_string(),
236            vec![DataElement {
237                identifier: "family_name".to_string(),
238                value: serde_json::json!("TAMPERED"),
239            }],
240        );
241        let subset_doc = MobileDocument {
242            doc_type: "org.iso.18013.5.1.mDL".to_string(),
243            namespaces: subset,
244        };
245
246        assert!(!mso.verify_digests(&subset_doc).unwrap());
247    }
248
249    #[test]
250    fn forged_element_not_in_mso_fails() {
251        let doc = sample_doc();
252        let mso = MobileSecurityObject::create(&doc, sample_validity()).unwrap();
253
254        // Present an element that was never in the original document
255        let mut forged = BTreeMap::new();
256        forged.insert(
257            "org.iso.18013.5.1".to_string(),
258            vec![DataElement {
259                identifier: "social_security_number".to_string(),
260                value: serde_json::json!("123-45-6789"),
261            }],
262        );
263        let forged_doc = MobileDocument {
264            doc_type: "org.iso.18013.5.1.mDL".to_string(),
265            namespaces: forged,
266        };
267
268        assert!(!mso.verify_digests(&forged_doc).unwrap());
269    }
270
271    #[test]
272    fn doc_type_mismatch_detected() {
273        let doc = sample_doc();
274        let mso = MobileSecurityObject::create(&doc, sample_validity()).unwrap();
275
276        let mut wrong_doc = doc.clone();
277        wrong_doc.doc_type = "wrong.type".to_string();
278
279        assert!(mso.verify_digests(&wrong_doc).is_err());
280    }
281
282    #[test]
283    fn multiple_namespaces() {
284        let mut namespaces = BTreeMap::new();
285        namespaces.insert(
286            "org.iso.18013.5.1".to_string(),
287            vec![DataElement {
288                identifier: "family_name".to_string(),
289                value: serde_json::json!("Doe"),
290            }],
291        );
292        namespaces.insert(
293            "org.iso.18013.5.1.aamva".to_string(),
294            vec![DataElement {
295                identifier: "DHS_compliance".to_string(),
296                value: serde_json::json!("F"),
297            }],
298        );
299        let doc = MobileDocument {
300            doc_type: "org.iso.18013.5.1.mDL".to_string(),
301            namespaces,
302        };
303
304        let mso = MobileSecurityObject::create(&doc, sample_validity()).unwrap();
305        assert_eq!(mso.value_digests.len(), 2);
306        assert!(mso.verify_digests(&doc).unwrap());
307    }
308}