Skip to main content

ma_core/
doc.rs

1use ed25519_dalek::{Signature, Verifier, VerifyingKey};
2use ipld_core::ipld::Ipld;
3use serde::{Deserialize, Serialize};
4#[cfg(not(target_arch = "wasm32"))]
5use web_time::{SystemTime, UNIX_EPOCH};
6
7use crate::{
8    did::Did,
9    error::{MaError, MaResult as Result},
10    key::{EncryptionKey, SigningKey, CODEC_ED25519_PUB, CODEC_EDDSA_SIG, CODEC_X25519_PUB},
11    multiformat::{
12        public_key_multibase_decode, signature_multibase_decode, signature_multibase_encode,
13    },
14};
15
16pub const DEFAULT_DID_CONTEXT: &[&str] = &["https://www.w3.org/ns/did/v1.1"];
17pub const DEFAULT_PROOF_TYPE: &str = "MultiformatSignature2023";
18pub const DEFAULT_PROOF_PURPOSE: &str = "assertionMethod";
19
20/// Returns the current UTC time as an ISO 8601 string with millisecond precision.
21pub fn now_iso_utc() -> String {
22    #[cfg(target_arch = "wasm32")]
23    {
24        // Bruk JS Date for ISO-format
25        return js_sys::Date::new_0()
26            .to_iso_string()
27            .as_string()
28            .unwrap_or_else(|| "1970-01-01T00:00:00.000Z".to_string());
29    }
30
31    #[cfg(not(target_arch = "wasm32"))]
32    {
33        let duration = SystemTime::now()
34            .duration_since(UNIX_EPOCH)
35            .unwrap_or_default();
36        unix_millis_to_iso(duration.as_secs(), duration.subsec_millis())
37    }
38}
39
40#[cfg(not(target_arch = "wasm32"))]
41fn unix_millis_to_iso(secs: u64, millis: u32) -> String {
42    // Howard Hinnant's civil_from_days algorithm.
43    let days = i64::try_from(secs / 86_400).unwrap_or(i64::MAX);
44    let z = days + 719_468;
45    let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
46    let doe = u64::try_from(z - era * 146_097).unwrap_or_default();
47    let yoe = (doe - doe / 1_460 + doe / 36_524 - doe / 146_096) / 365;
48    let y = i64::try_from(yoe).unwrap_or(i64::MAX) + era * 400;
49    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
50    let mp = (5 * doy + 2) / 153;
51    let d = doy - (153 * mp + 2) / 5 + 1;
52    let m = if mp < 10 { mp + 3 } else { mp - 9 };
53    let y = if m <= 2 { y + 1 } else { y };
54    let tod = secs % 86400;
55    format!(
56        "{:04}-{:02}-{:02}T{:02}:{:02}:{:02}.{:03}Z",
57        y,
58        m,
59        d,
60        tod / 3600,
61        (tod % 3600) / 60,
62        tod % 60,
63        millis,
64    )
65}
66
67#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
68pub struct VerificationMethod {
69    pub id: String,
70    #[serde(rename = "type")]
71    pub key_type: String,
72    pub controller: String,
73    #[serde(rename = "publicKeyMultibase")]
74    pub public_key_multibase: String,
75}
76
77impl VerificationMethod {
78    pub fn new(
79        id: impl AsRef<str>,
80        controller: impl Into<String>,
81        key_type: impl Into<String>,
82        fragment: impl AsRef<str>,
83        public_key_multibase: impl Into<String>,
84    ) -> Result<Self> {
85        let base_id = id
86            .as_ref()
87            .split('#')
88            .next()
89            .ok_or(MaError::MissingIdentifier)?;
90
91        let method = Self {
92            id: format!("{base_id}#{}", fragment.as_ref()),
93            key_type: key_type.into(),
94            controller: controller.into(),
95            public_key_multibase: public_key_multibase.into(),
96        };
97        method.validate()?;
98        Ok(method)
99    }
100
101    pub fn fragment(&self) -> Result<String> {
102        let did = Did::try_from(self.id.as_str())?;
103        did.fragment.ok_or(MaError::MissingFragment)
104    }
105
106    pub fn validate(&self) -> Result<()> {
107        Did::validate_url(&self.id)?;
108
109        if self.key_type.is_empty() {
110            return Err(MaError::VerificationMethodMissingType);
111        }
112
113        if self.controller.is_empty() {
114            return Err(MaError::EmptyController);
115        }
116
117        Did::validate(&self.controller)?;
118
119        if self.public_key_multibase.is_empty() {
120            return Err(MaError::EmptyPublicKeyMultibase);
121        }
122
123        public_key_multibase_decode(&self.public_key_multibase)?;
124        Ok(())
125    }
126}
127
128#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
129pub struct Proof {
130    #[serde(rename = "type")]
131    pub proof_type: String,
132    #[serde(rename = "verificationMethod")]
133    pub verification_method: String,
134    #[serde(rename = "proofPurpose")]
135    pub proof_purpose: String,
136    #[serde(rename = "proofValue")]
137    pub proof_value: String,
138}
139
140impl Proof {
141    pub fn new(proof_value: impl Into<String>, verification_method: impl Into<String>) -> Self {
142        Self {
143            proof_type: DEFAULT_PROOF_TYPE.to_string(),
144            verification_method: verification_method.into(),
145            proof_purpose: DEFAULT_PROOF_PURPOSE.to_string(),
146            proof_value: proof_value.into(),
147        }
148    }
149
150    pub fn is_empty(&self) -> bool {
151        self.proof_value.is_empty()
152    }
153}
154
155// ─── ma: extension builder ──────────────────────────────────────────────────
156
157/// Fluent builder for the opaque `ma:` IPLD extension field on a [`Document`].
158///
159/// `MaExtension` collects the node type, transport service strings, and any
160/// custom IPLD fields, then produces the [`Ipld`] value ready for
161/// [`Document::set_ma_extension`].
162///
163/// The idiomatic way to populate `ma:` is to start from the endpoint — which
164/// pre-populates services — and chain any additional fields:
165///
166/// ```ignore
167/// // Endpoint pre-populates services; add type and any extras:
168/// let ma = endpoint.ma_extension()
169///     .kind("world");
170///
171/// // Build a complete, signed document in one call:
172/// let document = bundle.build_document(ma)?;
173/// ```
174///
175/// You can also build a `MaExtension` independently and attach it to an
176/// existing document with [`Document::set_ma_extension`] before re-signing.
177#[derive(Debug, Default, Clone)]
178pub struct MaExtension {
179    map: std::collections::BTreeMap<String, Ipld>,
180}
181
182impl MaExtension {
183    /// Create an empty extension builder.
184    pub fn new() -> Self {
185        Self::default()
186    }
187
188    /// Set `ma["type"]` to identify the kind of node or service.
189    ///
190    /// The key name `"type"` follows the existing convention in the ma ecosystem.
191    #[must_use]
192    pub fn kind(mut self, kind: &str) -> Self {
193        self.map
194            .insert("type".to_string(), Ipld::String(kind.to_string()));
195        self
196    }
197
198    /// Append one transport service string to `ma["services"]`.
199    ///
200    /// Service strings have the form `/iroh/<endpoint-id>/ma/<protocol>/<version>`.
201    #[must_use]
202    pub fn add_service(mut self, service: &str) -> Self {
203        let entry = self
204            .map
205            .entry("services".to_string())
206            .or_insert_with(|| Ipld::List(Vec::new()));
207        if let Ipld::List(list) = entry {
208            list.push(Ipld::String(service.to_string()));
209        }
210        self
211    }
212
213    /// Replace `ma["services"]` with the given list.
214    ///
215    /// Use this (rather than repeated [`Self::add_service`] calls) when you
216    /// already have the full service list, e.g. from [`crate::MaEndpoint::services`].
217    #[must_use]
218    pub fn services(mut self, services: Vec<String>) -> Self {
219        self.map.insert(
220            "services".to_string(),
221            Ipld::List(services.into_iter().map(Ipld::String).collect()),
222        );
223        self
224    }
225
226    /// Set an arbitrary IPLD entry in the extension map.
227    #[must_use]
228    pub fn extra(mut self, key: &str, val: Ipld) -> Self {
229        self.map.insert(key.to_string(), val);
230        self
231    }
232
233    /// Consume the builder and return the final [`Ipld`] value.
234    ///
235    /// Returns [`Ipld::Null`] if no fields have been set (which causes
236    /// [`Document::set_ma_extension`] to clear the `ma` field).
237    pub fn build(self) -> Ipld {
238        if self.map.is_empty() {
239            Ipld::Null
240        } else {
241            Ipld::Map(self.map)
242        }
243    }
244}
245
246fn is_valid_rfc3339_utc(value: &str) -> bool {
247    let trimmed = value.trim();
248    // Strict enough for ISO-8601 UTC produced by current implementations.
249    if !trimmed.ends_with('Z') {
250        return false;
251    }
252    let bytes = trimmed.as_bytes();
253    if bytes.len() < 20 {
254        return false;
255    }
256    let expected_punct = [
257        (4usize, b'-'),
258        (7usize, b'-'),
259        (10usize, b'T'),
260        (13usize, b':'),
261        (16usize, b':'),
262    ];
263    if expected_punct
264        .iter()
265        .any(|(idx, punct)| bytes.get(*idx).copied() != Some(*punct))
266    {
267        return false;
268    }
269    let core_digits = [0usize, 1, 2, 3, 5, 6, 8, 9, 11, 12, 14, 15, 17, 18];
270    if core_digits.iter().any(|idx| {
271        !bytes
272            .get(*idx)
273            .copied()
274            .unwrap_or_default()
275            .is_ascii_digit()
276    }) {
277        return false;
278    }
279    let tail = &trimmed[19..trimmed.len() - 1];
280    if tail.is_empty() {
281        return true;
282    }
283    if let Some(frac) = tail.strip_prefix('.') {
284        return !frac.is_empty() && frac.chars().all(|ch| ch.is_ascii_digit());
285    }
286    false
287}
288
289/// A `did:ma:` DID document.
290///
291/// Contains verification methods, proof, and optional extension data.
292/// Documents are signed with Ed25519 over a BLAKE3 hash of the dag-cbor-serialized
293/// payload (all fields except `proof`).
294///
295/// # Examples
296///
297/// ```
298/// use ma_core::{generate_identity_from_secret, Document};
299///
300/// let id = generate_identity_from_secret([7u8; 32]).unwrap();
301///
302/// // Verify the signature
303/// id.document.verify().unwrap();
304///
305/// // Validate structural correctness
306/// id.document.validate().unwrap();
307///
308/// // Round-trip through the canonical wire format
309/// let bytes = id.document.encode().unwrap();
310/// let restored = Document::decode(&bytes).unwrap();
311/// assert_eq!(id.document, restored);
312/// ```
313///
314/// # Extension namespace
315///
316/// The `ma` field is an opaque IPLD value for application-defined
317/// extension data. did-ma does not interpret or validate its contents.
318/// Using [`Ipld`] gives native support for CID links and canonical DAG-CBOR
319/// round-tripping.
320///
321/// ```
322/// use std::collections::BTreeMap;
323/// use ipld_core::ipld::Ipld;
324/// use ma_core::{Did, Document};
325///
326/// let did = Did::new_url("k51qzi5uqu5dj9807pbuod1pplf0vxh8m4lfy3ewl9qbm2s8dsf9ugdf9gedhr", None::<String>).unwrap();
327/// let mut doc = Document::new(&did, &did);
328/// let ma = Ipld::Map(BTreeMap::from([
329///     ("type".into(), Ipld::String("agent".into())),
330///     ("services".into(), Ipld::Map(BTreeMap::new())),
331/// ]));
332/// doc.set_ma(ma);
333/// assert!(doc.ma.is_some());
334/// doc.clear_ma();
335/// assert!(doc.ma.is_none());
336/// ```
337#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
338pub struct Document {
339    #[serde(rename = "@context")]
340    pub context: Vec<String>,
341    pub id: String,
342    pub controller: Vec<String>,
343    #[serde(rename = "verificationMethod")]
344    pub verification_method: Vec<VerificationMethod>,
345    #[serde(rename = "assertionMethod")]
346    pub assertion_method: Vec<String>,
347    #[serde(rename = "keyAgreement")]
348    pub key_agreement: Vec<String>,
349    pub proof: Proof,
350    #[serde(rename = "createdAt")]
351    pub created_at: String,
352    #[serde(rename = "updatedAt")]
353    pub updated_at: String,
354    #[serde(skip_serializing_if = "Option::is_none")]
355    pub ma: Option<Ipld>,
356}
357
358impl Document {
359    pub fn new(identity: &Did, controller: &Did) -> Self {
360        let now = now_iso_utc();
361        Self {
362            context: DEFAULT_DID_CONTEXT
363                .iter()
364                .map(|value| (*value).to_string())
365                .collect(),
366            id: identity.base_id(),
367            controller: vec![controller.base_id()],
368            verification_method: Vec::new(),
369            assertion_method: Vec::new(),
370            key_agreement: Vec::new(),
371            proof: Proof::default(),
372            created_at: now.clone(),
373            updated_at: now,
374            ma: None,
375        }
376    }
377
378    /// Set the opaque `ma` extension namespace from a raw [`Ipld`] value.
379    ///
380    /// For the ergonomic, structured way to populate this field, prefer
381    /// [`Document::set_ma_extension`] with a [`MaExtension`] builder.
382    pub fn set_ma(&mut self, ma: Ipld) {
383        match &ma {
384            Ipld::Null => self.ma = None,
385            Ipld::Map(m) if m.is_empty() => self.ma = None,
386            _ => self.ma = Some(ma),
387        }
388    }
389
390    /// Set the `ma` extension field from a [`MaExtension`] builder.
391    ///
392    /// This is the recommended way to populate the `ma:` namespace. Build an
393    /// extension with [`MaExtension`], then call this method before signing
394    /// the document. An empty builder (or one whose [`MaExtension::build`]
395    /// returns [`Ipld::Null`]) clears the field.
396    ///
397    /// # Example
398    ///
399    /// ```ignore
400    /// let ma = endpoint.ma_extension().kind("world");
401    /// document.set_ma_extension(ma);
402    /// document.sign(&signing_key, &assertion_vm)?;
403    /// ```
404    pub fn set_ma_extension(&mut self, ext: MaExtension) {
405        self.set_ma(ext.build());
406    }
407
408    /// Clear the `ma` extension namespace.
409    pub fn clear_ma(&mut self) {
410        self.ma = None;
411    }
412
413    /// Encode the DID document to its canonical wire format.
414    ///
415    /// DID documents are always serialized as DAG-CBOR. Use this for
416    /// transport, storage, hashing, signing, and IPFS/IPNS publication.
417    pub fn encode(&self) -> Result<Vec<u8>> {
418        serde_ipld_dagcbor::to_vec(self).map_err(|error| MaError::CborEncode(error.to_string()))
419    }
420
421    /// Decode a DID document from its canonical wire format.
422    ///
423    /// DID documents are always encoded as DAG-CBOR.
424    pub fn decode(bytes: &[u8]) -> Result<Self> {
425        serde_ipld_dagcbor::from_slice(bytes)
426            .map_err(|error| MaError::CborDecode(error.to_string()))
427    }
428
429    pub fn add_controller(&mut self, controller: impl Into<String>) -> Result<()> {
430        let controller = controller.into();
431        Did::validate(&controller)?;
432        if !self.controller.contains(&controller) {
433            self.controller.push(controller);
434        }
435        Ok(())
436    }
437
438    pub fn add_verification_method(&mut self, method: VerificationMethod) -> Result<()> {
439        method.validate()?;
440        let duplicate = self.verification_method.iter().any(|existing| {
441            existing.id == method.id || existing.public_key_multibase == method.public_key_multibase
442        });
443
444        if !duplicate {
445            self.verification_method.push(method);
446        }
447
448        Ok(())
449    }
450
451    pub fn get_verification_method_by_id(&self, method_id: &str) -> Result<&VerificationMethod> {
452        self.verification_method
453            .iter()
454            .find(|method| method.id == method_id)
455            .ok_or_else(|| MaError::UnknownVerificationMethod(method_id.to_string()))
456    }
457
458    /// Update the `updatedAt` timestamp to the current time.
459    pub fn touch(&mut self) {
460        self.updated_at = now_iso_utc();
461    }
462
463    pub fn assertion_method_public_key(&self) -> Result<VerifyingKey> {
464        let assertion_id = self
465            .assertion_method
466            .first()
467            .ok_or_else(|| MaError::UnknownVerificationMethod("assertionMethod".to_string()))?;
468        let vm = self.get_verification_method_by_id(assertion_id)?;
469        let (codec, public_key_bytes) = public_key_multibase_decode(&vm.public_key_multibase)?;
470        if codec != CODEC_ED25519_PUB {
471            return Err(MaError::InvalidMulticodec {
472                expected: CODEC_ED25519_PUB,
473                actual: codec,
474            });
475        }
476
477        let key_len = public_key_bytes.len();
478        let bytes: [u8; 32] =
479            public_key_bytes
480                .try_into()
481                .map_err(|_| MaError::InvalidKeyLength {
482                    expected: 32,
483                    actual: key_len,
484                })?;
485
486        VerifyingKey::from_bytes(&bytes).map_err(|_| MaError::Crypto)
487    }
488
489    pub fn key_agreement_public_key_bytes(&self) -> Result<[u8; 32]> {
490        let agreement_id = self
491            .key_agreement
492            .first()
493            .ok_or_else(|| MaError::UnknownVerificationMethod("keyAgreement".to_string()))?;
494        let vm = self.get_verification_method_by_id(agreement_id)?;
495        let (codec, public_key_bytes) = public_key_multibase_decode(&vm.public_key_multibase)?;
496        if codec != CODEC_X25519_PUB {
497            return Err(MaError::InvalidMulticodec {
498                expected: CODEC_X25519_PUB,
499                actual: codec,
500            });
501        }
502
503        let key_len = public_key_bytes.len();
504        public_key_bytes
505            .try_into()
506            .map_err(|_| MaError::InvalidKeyLength {
507                expected: 32,
508                actual: key_len,
509            })
510    }
511
512    #[must_use]
513    pub fn payload_document(&self) -> Self {
514        let mut payload = self.clone();
515        payload.proof = Proof::default();
516        payload
517    }
518
519    pub fn payload_bytes(&self) -> Result<Vec<u8>> {
520        self.payload_document().encode()
521    }
522
523    pub fn payload_hash(&self) -> Result<[u8; 32]> {
524        Ok(blake3::hash(&self.payload_bytes()?).into())
525    }
526
527    pub fn sign(
528        &mut self,
529        signing_key: &SigningKey,
530        verification_method: &VerificationMethod,
531    ) -> Result<()> {
532        if signing_key.public_key_multibase != verification_method.public_key_multibase {
533            return Err(MaError::InvalidPublicKeyMultibase);
534        }
535
536        let signature = signing_key.sign(&self.payload_hash()?);
537        let proof_value = signature_multibase_encode(CODEC_EDDSA_SIG, &signature);
538        self.proof = Proof::new(proof_value, verification_method.id.clone());
539        Ok(())
540    }
541
542    pub fn verify(&self) -> Result<()> {
543        if self.proof.is_empty() {
544            return Err(MaError::MissingProof);
545        }
546
547        let (codec, sig_bytes) = signature_multibase_decode(&self.proof.proof_value)?;
548        if codec != CODEC_EDDSA_SIG {
549            return Err(MaError::InvalidDocumentSignature);
550        }
551        let signature =
552            Signature::from_slice(&sig_bytes).map_err(|_| MaError::InvalidDocumentSignature)?;
553        let public_key = self.assertion_method_public_key()?;
554        public_key
555            .verify(&self.payload_hash()?, &signature)
556            .map_err(|_| MaError::InvalidDocumentSignature)
557    }
558
559    pub fn validate(&self) -> Result<()> {
560        if self.context.is_empty() {
561            return Err(MaError::EmptyContext);
562        }
563
564        Did::validate(&self.id)?;
565
566        if self.controller.is_empty() {
567            return Err(MaError::EmptyController);
568        }
569
570        for controller in &self.controller {
571            Did::validate(controller)?;
572        }
573
574        if !is_valid_rfc3339_utc(&self.created_at) {
575            return Err(MaError::InvalidCreatedAt(self.created_at.clone()));
576        }
577
578        if !is_valid_rfc3339_utc(&self.updated_at) {
579            return Err(MaError::InvalidUpdatedAt(self.updated_at.clone()));
580        }
581
582        for method in &self.verification_method {
583            method.validate()?;
584        }
585
586        if self.assertion_method.is_empty() {
587            return Err(MaError::UnknownVerificationMethod(
588                "assertionMethod".to_string(),
589            ));
590        }
591
592        if self.key_agreement.is_empty() {
593            return Err(MaError::UnknownVerificationMethod(
594                "keyAgreement".to_string(),
595            ));
596        }
597
598        Ok(())
599    }
600}
601
602impl TryFrom<&[u8]> for Document {
603    type Error = MaError;
604
605    fn try_from(bytes: &[u8]) -> Result<Self> {
606        Self::decode(bytes)
607    }
608}
609
610impl TryFrom<&EncryptionKey> for VerificationMethod {
611    type Error = MaError;
612
613    fn try_from(value: &EncryptionKey) -> Result<Self> {
614        let fragment = value.did.fragment.clone().ok_or(MaError::MissingFragment)?;
615        VerificationMethod::new(
616            value.did.base_id(),
617            value.did.base_id(),
618            value.key_type.clone(),
619            fragment,
620            value.public_key_multibase.clone(),
621        )
622    }
623}
624
625impl TryFrom<&SigningKey> for VerificationMethod {
626    type Error = MaError;
627
628    fn try_from(value: &SigningKey) -> Result<Self> {
629        let fragment = value.did.fragment.clone().ok_or(MaError::MissingFragment)?;
630        VerificationMethod::new(
631            value.did.base_id(),
632            value.did.base_id(),
633            value.key_type.clone(),
634            fragment,
635            value.public_key_multibase.clone(),
636        )
637    }
638}
639
640#[cfg(test)]
641mod tests {
642    use super::*;
643    use std::collections::BTreeMap;
644
645    #[test]
646    fn encode_decode_round_trip() {
647        let identity = crate::generate_identity_from_secret([11u8; 32]).expect("identity");
648        let bytes = identity.document.encode().expect("encode");
649        let decoded = Document::decode(&bytes).expect("decode");
650        assert_eq!(decoded, identity.document);
651    }
652
653    #[test]
654    fn try_from_bytes_round_trip() {
655        let identity = crate::generate_identity_from_secret([12u8; 32]).expect("identity");
656        let bytes = identity.document.encode().expect("encode");
657        let decoded = Document::try_from(bytes.as_slice()).expect("try_from bytes");
658        assert_eq!(decoded, identity.document);
659    }
660
661    #[test]
662    fn decode_rejects_invalid_bytes() {
663        let err = Document::decode(b"not dag-cbor").expect_err("invalid bytes");
664        assert!(matches!(err, MaError::CborDecode(_)));
665    }
666
667    #[test]
668    fn payload_document_clears_proof_only() {
669        let identity = crate::generate_identity_from_secret([13u8; 32]).expect("identity");
670        let payload = identity.document.payload_document();
671
672        assert!(payload.proof.is_empty());
673        assert_eq!(payload.id, identity.document.id);
674        assert_eq!(payload.controller, identity.document.controller);
675        assert_eq!(
676            payload.verification_method,
677            identity.document.verification_method
678        );
679        assert_eq!(payload.assertion_method, identity.document.assertion_method);
680        assert_eq!(payload.key_agreement, identity.document.key_agreement);
681        assert_eq!(payload.created_at, identity.document.created_at);
682        assert_eq!(payload.updated_at, identity.document.updated_at);
683        assert_eq!(payload.ma, identity.document.ma);
684    }
685
686    #[test]
687    fn set_ma_stores_opaque_value() {
688        let root = Did::new_url(
689            "k51qzi5uqu5dj9807pbuod1pplf0vxh8m4lfy3ewl9qbm2s8dsf9ugdf9gedhr",
690            None::<String>,
691        )
692        .expect("valid test did");
693        let mut document = Document::new(&root, &root);
694
695        let ma = Ipld::Map(BTreeMap::from([(
696            "type".into(),
697            Ipld::String("agent".into()),
698        )]));
699        document.set_ma(ma.clone());
700        assert_eq!(document.ma.as_ref(), Some(&ma));
701    }
702
703    #[test]
704    fn clear_ma_removes_value() {
705        let root = Did::new_url(
706            "k51qzi5uqu5dj9807pbuod1pplf0vxh8m4lfy3ewl9qbm2s8dsf9ugdf9gedhr",
707            None::<String>,
708        )
709        .expect("valid test did");
710        let mut document = Document::new(&root, &root);
711
712        document.set_ma(Ipld::Map(BTreeMap::from([(
713            "type".into(),
714            Ipld::String("agent".into()),
715        )])));
716        assert!(document.ma.is_some());
717        document.clear_ma();
718        assert!(document.ma.is_none());
719    }
720
721    #[test]
722    fn set_ma_null_clears() {
723        let root = Did::new_url(
724            "k51qzi5uqu5dj9807pbuod1pplf0vxh8m4lfy3ewl9qbm2s8dsf9ugdf9gedhr",
725            None::<String>,
726        )
727        .expect("valid test did");
728        let mut document = Document::new(&root, &root);
729
730        document.set_ma(Ipld::Map(BTreeMap::from([(
731            "type".into(),
732            Ipld::String("agent".into()),
733        )])));
734        document.set_ma(Ipld::Null);
735        assert!(document.ma.is_none());
736    }
737
738    #[test]
739    fn validate_accepts_opaque_ma() {
740        let identity = crate::identity::generate_identity(
741            "k51qzi5uqu5dj9807pbuod1pplf0vxh8m4lfy3ewl9qbm2s8dsf9ugdf9gedhr",
742        )
743        .expect("generate identity");
744        let mut document = identity.document;
745        document.set_ma(Ipld::Map(BTreeMap::from([
746            ("type".into(), Ipld::String("bahner".into())),
747            ("custom".into(), Ipld::Integer(42)),
748        ])));
749        document
750            .validate()
751            .expect("validate should accept any ma value");
752    }
753}