Skip to main content

ma_core/
doc.rs

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