Skip to main content

acdp_did/
document.rs

1//! DID document types and key extraction.
2
3use acdp_primitives::error::AcdpError;
4use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine};
5use serde::{Deserialize, Serialize};
6
7/// A minimal DID document (subset sufficient for ACDP §5.11).
8#[derive(Debug, Clone, Serialize, Deserialize)]
9pub struct DidDocument {
10    pub id: String,
11
12    #[serde(rename = "verificationMethod", default)]
13    pub verification_methods: Vec<VerificationMethod>,
14
15    #[serde(rename = "assertionMethod", default)]
16    pub assertion_method: Vec<AssertionMethodRef>,
17}
18
19impl DidDocument {
20    /// The fragment (substring after the final `#`) of a DID-URL, or
21    /// `None` if it carries no fragment.
22    fn fragment_of(id: &str) -> Option<&str> {
23        id.rsplit_once('#').map(|(_, frag)| frag)
24    }
25
26    /// Resolve a verification-method reference to an absolute DID URL. A
27    /// relative `#fragment` ref is resolved against this document's `id`;
28    /// an already-absolute ref is returned unchanged.
29    fn absolutize(&self, vm_ref: &str) -> String {
30        match vm_ref.strip_prefix('#') {
31            Some(frag) => format!("{}#{frag}", self.id),
32            None => vm_ref.to_string(),
33        }
34    }
35
36    /// Find a verification method whose `#fragment` is **exactly** `fragment`.
37    ///
38    /// Compares the fragment for equality rather than `ends_with`: a
39    /// loose suffix match would let an unlisted `…#evil-key-1` satisfy a
40    /// lookup for `key-1` (authorization-scope escalation within a DID).
41    pub fn find_by_fragment(&self, fragment: &str) -> Option<&VerificationMethod> {
42        self.verification_methods
43            .iter()
44            .find(|m| Self::fragment_of(&m.id) == Some(fragment))
45    }
46
47    /// Returns `true` if `vm_id` is listed in `assertionMethod`.
48    ///
49    /// Both `vm_id` and each `assertionMethod` entry are normalized to
50    /// absolute DID URLs and compared for **exact** equality. The prior
51    /// `vm_id.ends_with(id)` form authorized any `…#evil-key-1` against a
52    /// relative `#key-1` entry.
53    pub fn is_assertion_method(&self, vm_id: &str) -> bool {
54        let target = self.absolutize(vm_id);
55        self.assertion_method.iter().any(|r| match r {
56            AssertionMethodRef::Id(id) => self.absolutize(id) == target,
57            AssertionMethodRef::Embedded(m) => self.absolutize(&m.id) == target,
58        })
59    }
60}
61
62/// A DID document verification method entry.
63#[derive(Debug, Clone, Serialize, Deserialize)]
64pub struct VerificationMethod {
65    pub id: String,
66    #[serde(rename = "type")]
67    pub method_type: String,
68    pub controller: String,
69
70    /// JWK public key representation.
71    #[serde(rename = "publicKeyJwk", skip_serializing_if = "Option::is_none")]
72    pub public_key_jwk: Option<serde_json::Value>,
73
74    /// Multibase-encoded public key (`z` prefix = base58btc + multicodec).
75    #[serde(rename = "publicKeyMultibase", skip_serializing_if = "Option::is_none")]
76    pub public_key_multibase: Option<String>,
77}
78
79impl VerificationMethod {
80    /// Extract the raw 32-byte Ed25519 public key.
81    ///
82    /// Supports both `publicKeyJwk` (OKP / Ed25519) and
83    /// `publicKeyMultibase` (base58btc, multicodec 0xed01 prefix).
84    pub fn ed25519_public_key_bytes(&self) -> Result<[u8; 32], AcdpError> {
85        if let Some(jwk) = &self.public_key_jwk {
86            return extract_from_jwk(jwk);
87        }
88        if let Some(mb) = &self.public_key_multibase {
89            return extract_from_multibase(mb);
90        }
91        Err(AcdpError::KeyResolution(
92            "verification method has neither publicKeyJwk nor publicKeyMultibase".into(),
93        ))
94    }
95
96    /// Extract the SEC1-uncompressed P-256 public key (65 bytes
97    /// starting with `0x04`).
98    pub fn ecdsa_p256_public_key_sec1(&self) -> Result<Vec<u8>, AcdpError> {
99        if let Some(jwk) = &self.public_key_jwk {
100            return extract_p256_from_jwk(jwk);
101        }
102        Err(AcdpError::KeyResolution(
103            "ecdsa-p256 verification method requires publicKeyJwk \
104             (publicKeyMultibase not yet supported for P-256)"
105                .into(),
106        ))
107    }
108
109    /// Best-effort algorithm declaration derived from the verification
110    /// method's `type` and (when relevant) `publicKeyJwk` parameters.
111    ///
112    /// Returns the canonical lowercase algorithm string compatible with
113    /// `signature.algorithm` (e.g. `"ed25519"`, `"ecdsa-p256"`), or
114    /// `None` for verification methods whose declared algorithm cannot
115    /// be inferred from `type` alone.
116    ///
117    /// Used by the high-level `Verifier::verify_body` to
118    /// detect algorithm-downgrade attacks per RFC-ACDP-0008 §3.9.
119    pub fn declared_algorithm(&self) -> Option<&'static str> {
120        match self.method_type.as_str() {
121            "Ed25519VerificationKey2020" | "Ed25519VerificationKey2018" => Some("ed25519"),
122            "EcdsaSecp256r1VerificationKey2019" => Some("ecdsa-p256"),
123            "JsonWebKey2020" => {
124                let jwk = self.public_key_jwk.as_ref()?;
125                let kty = jwk.get("kty").and_then(|v| v.as_str())?;
126                let crv = jwk.get("crv").and_then(|v| v.as_str())?;
127                match (kty, crv) {
128                    ("OKP", "Ed25519") => Some("ed25519"),
129                    ("EC", "P-256") => Some("ecdsa-p256"),
130                    _ => None,
131                }
132            }
133            // #21: for a `type` this version doesn't special-case (e.g. the
134            // W3C 2023 `Multikey`), still derive the algorithm from the
135            // `publicKeyMultibase` multicodec prefix when present, so the
136            // algorithm-downgrade guard (RFC-ACDP-0008 §3.9) is not silently
137            // skipped for multibase keys. `None` only when nothing carries an
138            // algorithm signal.
139            _ => self
140                .public_key_multibase
141                .as_deref()
142                .and_then(multicodec_algorithm),
143        }
144    }
145}
146
147/// Map a `publicKeyMultibase` value to its canonical algorithm string via the
148/// multicodec prefix of the decoded key. The prefix is the multicodec code in
149/// unsigned-varint encoding: `ed25519-pub` (code `0xed`) → bytes `0xed 0x01`,
150/// `p256-pub` (code `0x1200`) → bytes `0x80 0x24`. Returns `None` if the value
151/// isn't base58btc or the codec is unrecognized.
152fn multicodec_algorithm(mb: &str) -> Option<&'static str> {
153    let rest = mb.strip_prefix('z')?;
154    let decoded = bs58::decode(rest).into_vec().ok()?;
155    match decoded.get(0..2) {
156        Some([0xed, 0x01]) => Some("ed25519"),
157        Some([0x80, 0x24]) => Some("ecdsa-p256"),
158        _ => None,
159    }
160}
161
162/// `assertionMethod` entries can be either an ID string or an embedded object.
163#[derive(Debug, Clone, Serialize, Deserialize)]
164#[serde(untagged)]
165pub enum AssertionMethodRef {
166    Id(String),
167    Embedded(Box<VerificationMethod>),
168}
169
170// ── Key extraction helpers ────────────────────────────────────────────────────
171
172fn extract_from_jwk(jwk: &serde_json::Value) -> Result<[u8; 32], AcdpError> {
173    let kty = jwk["kty"].as_str().unwrap_or("");
174    let crv = jwk["crv"].as_str().unwrap_or("");
175
176    if kty != "OKP" || crv != "Ed25519" {
177        return Err(AcdpError::KeyResolution(format!(
178            "expected OKP/Ed25519 JWK, got kty={kty} crv={crv}"
179        )));
180    }
181
182    let x = jwk["x"]
183        .as_str()
184        .ok_or_else(|| AcdpError::KeyResolution("JWK missing 'x' parameter".into()))?;
185
186    let bytes = URL_SAFE_NO_PAD
187        .decode(x)
188        .map_err(|e| AcdpError::KeyResolution(format!("JWK 'x' base64url decode: {e}")))?;
189
190    bytes
191        .try_into()
192        .map_err(|_| AcdpError::KeyResolution("JWK 'x' is not 32 bytes (not Ed25519)".into()))
193}
194
195fn extract_p256_from_jwk(jwk: &serde_json::Value) -> Result<Vec<u8>, AcdpError> {
196    let kty = jwk["kty"].as_str().unwrap_or("");
197    let crv = jwk["crv"].as_str().unwrap_or("");
198    if kty != "EC" || crv != "P-256" {
199        return Err(AcdpError::KeyResolution(format!(
200            "expected EC/P-256 JWK, got kty={kty} crv={crv}"
201        )));
202    }
203    let x = jwk["x"]
204        .as_str()
205        .ok_or_else(|| AcdpError::KeyResolution("JWK missing 'x'".into()))?;
206    let y = jwk["y"]
207        .as_str()
208        .ok_or_else(|| AcdpError::KeyResolution("JWK missing 'y'".into()))?;
209    let x_bytes = URL_SAFE_NO_PAD
210        .decode(x)
211        .map_err(|e| AcdpError::KeyResolution(format!("JWK 'x' base64url: {e}")))?;
212    let y_bytes = URL_SAFE_NO_PAD
213        .decode(y)
214        .map_err(|e| AcdpError::KeyResolution(format!("JWK 'y' base64url: {e}")))?;
215    if x_bytes.len() != 32 || y_bytes.len() != 32 {
216        return Err(AcdpError::KeyResolution(format!(
217            "P-256 JWK x/y must be 32 bytes each, got x={} y={}",
218            x_bytes.len(),
219            y_bytes.len()
220        )));
221    }
222    let mut sec1 = Vec::with_capacity(65);
223    sec1.push(0x04);
224    sec1.extend_from_slice(&x_bytes);
225    sec1.extend_from_slice(&y_bytes);
226    Ok(sec1)
227}
228
229fn extract_from_multibase(mb: &str) -> Result<[u8; 32], AcdpError> {
230    if !mb.starts_with('z') {
231        return Err(AcdpError::KeyResolution(
232            "only 'z' (base58btc) multibase prefix is supported".into(),
233        ));
234    }
235
236    let decoded = bs58::decode(&mb[1..])
237        .into_vec()
238        .map_err(|e| AcdpError::KeyResolution(format!("base58 decode: {e}")))?;
239
240    // Multicodec prefix for Ed25519 is 0xed 0x01
241    if decoded.len() < 2 || decoded[0] != 0xed || decoded[1] != 0x01 {
242        return Err(AcdpError::KeyResolution(
243            "multibase key does not have Ed25519 multicodec prefix (0xed 0x01)".into(),
244        ));
245    }
246
247    decoded[2..].try_into().map_err(|_| {
248        AcdpError::KeyResolution("Ed25519 key must be 32 bytes after multicodec prefix".into())
249    })
250}
251
252#[cfg(test)]
253mod tests {
254    use super::*;
255    use serde_json::json;
256
257    const TEST_PUB_HEX: &str = "3b6a27bcceb6a42d62a3a8d02a6f0d73653215771de243a63ac048a18b59da29";
258
259    fn test_pub_bytes() -> [u8; 32] {
260        hex::decode(TEST_PUB_HEX).unwrap().try_into().unwrap()
261    }
262
263    #[test]
264    fn extracts_from_jwk() {
265        let raw = test_pub_bytes();
266        let x = URL_SAFE_NO_PAD.encode(raw);
267        let jwk = json!({ "kty": "OKP", "crv": "Ed25519", "x": x });
268        let vm = VerificationMethod {
269            id: "did:web:example.com#key-1".into(),
270            method_type: "JsonWebKey2020".into(),
271            controller: "did:web:example.com".into(),
272            public_key_jwk: Some(jwk),
273            public_key_multibase: None,
274        };
275        assert_eq!(vm.ed25519_public_key_bytes().unwrap(), raw);
276    }
277
278    #[test]
279    fn rejects_wrong_kty() {
280        let jwk = json!({ "kty": "EC", "crv": "P-256", "x": "abc" });
281        let vm = VerificationMethod {
282            id: "did:web:example.com#key-1".into(),
283            method_type: "JsonWebKey2020".into(),
284            controller: "did:web:example.com".into(),
285            public_key_jwk: Some(jwk),
286            public_key_multibase: None,
287        };
288        assert!(matches!(
289            vm.ed25519_public_key_bytes(),
290            Err(AcdpError::KeyResolution(_))
291        ));
292    }
293
294    #[test]
295    fn extracts_from_multibase() {
296        let raw = test_pub_bytes();
297        let mut prefixed = vec![0xed, 0x01];
298        prefixed.extend_from_slice(&raw);
299        let mb = format!("z{}", bs58::encode(&prefixed).into_string());
300        let vm = VerificationMethod {
301            id: "did:web:example.com#key-1".into(),
302            method_type: "Ed25519VerificationKey2020".into(),
303            controller: "did:web:example.com".into(),
304            public_key_jwk: None,
305            public_key_multibase: Some(mb),
306        };
307        assert_eq!(vm.ed25519_public_key_bytes().unwrap(), raw);
308    }
309
310    #[test]
311    fn rejects_non_z_multibase() {
312        let vm = VerificationMethod {
313            id: "did:web:example.com#key-1".into(),
314            method_type: "Ed25519VerificationKey2020".into(),
315            controller: "did:web:example.com".into(),
316            public_key_jwk: None,
317            public_key_multibase: Some("uAAAA".into()),
318        };
319        assert!(matches!(
320            vm.ed25519_public_key_bytes(),
321            Err(AcdpError::KeyResolution(_))
322        ));
323    }
324
325    #[test]
326    fn rejects_non_ed25519_multicodec() {
327        // 0xe7 = secp256k1 multicodec, not Ed25519 (0xed 0x01)
328        let mut prefixed = vec![0xe7, 0x01];
329        prefixed.extend_from_slice(&[0u8; 32]);
330        let mb = format!("z{}", bs58::encode(&prefixed).into_string());
331        let vm = VerificationMethod {
332            id: "did:web:example.com#key-1".into(),
333            method_type: "X".into(),
334            controller: "did:web:example.com".into(),
335            public_key_jwk: None,
336            public_key_multibase: Some(mb),
337        };
338        assert!(matches!(
339            vm.ed25519_public_key_bytes(),
340            Err(AcdpError::KeyResolution(_))
341        ));
342    }
343
344    #[test]
345    fn assertion_method_authorization_by_full_id() {
346        let doc = DidDocument {
347            id: "did:web:example.com".into(),
348            verification_methods: vec![VerificationMethod {
349                id: "did:web:example.com#key-1".into(),
350                method_type: "Ed25519VerificationKey2020".into(),
351                controller: "did:web:example.com".into(),
352                public_key_jwk: None,
353                public_key_multibase: None,
354            }],
355            assertion_method: vec![AssertionMethodRef::Id("did:web:example.com#key-1".into())],
356        };
357        assert!(doc.is_assertion_method("did:web:example.com#key-1"));
358        assert!(!doc.is_assertion_method("did:web:example.com#key-2"));
359    }
360
361    #[test]
362    fn assertion_method_authorization_by_relative_fragment() {
363        let doc = DidDocument {
364            id: "did:web:example.com".into(),
365            verification_methods: vec![VerificationMethod {
366                id: "did:web:example.com#key-1".into(),
367                method_type: "Ed25519VerificationKey2020".into(),
368                controller: "did:web:example.com".into(),
369                public_key_jwk: None,
370                public_key_multibase: None,
371            }],
372            assertion_method: vec![AssertionMethodRef::Id("#key-1".into())],
373        };
374        assert!(doc.is_assertion_method("did:web:example.com#key-1"));
375    }
376
377    #[test]
378    fn find_by_fragment() {
379        let doc = DidDocument {
380            id: "did:web:example.com".into(),
381            verification_methods: vec![VerificationMethod {
382                id: "did:web:example.com#key-1".into(),
383                method_type: "Ed25519VerificationKey2020".into(),
384                controller: "did:web:example.com".into(),
385                public_key_jwk: None,
386                public_key_multibase: None,
387            }],
388            assertion_method: vec![],
389        };
390        assert!(doc.find_by_fragment("key-1").is_some());
391        assert!(doc.find_by_fragment("key-2").is_none());
392    }
393
394    #[test]
395    fn find_by_fragment_no_loose_suffix_match() {
396        // P1-2: an unlisted `…#evil-key-1` MUST NOT satisfy a lookup for
397        // `key-1` via `ends_with`.
398        let doc = DidDocument {
399            id: "did:web:example.com".into(),
400            verification_methods: vec![VerificationMethod {
401                id: "did:web:example.com#evil-key-1".into(),
402                method_type: "Ed25519VerificationKey2020".into(),
403                controller: "did:web:example.com".into(),
404                public_key_jwk: None,
405                public_key_multibase: None,
406            }],
407            assertion_method: vec![],
408        };
409        assert!(doc.find_by_fragment("key-1").is_none());
410        assert!(doc.find_by_fragment("evil-key-1").is_some());
411    }
412
413    #[test]
414    fn assertion_method_no_loose_suffix_match() {
415        // P1-2: assertionMethod `#key-1` MUST NOT authorize `…#evil-key-1`.
416        let doc = DidDocument {
417            id: "did:web:example.com".into(),
418            verification_methods: vec![],
419            assertion_method: vec![AssertionMethodRef::Id("#key-1".into())],
420        };
421        assert!(doc.is_assertion_method("did:web:example.com#key-1"));
422        assert!(!doc.is_assertion_method("did:web:example.com#evil-key-1"));
423        // A different DID sharing the fragment must not match either.
424        assert!(!doc.is_assertion_method("did:web:attacker.com#key-1"));
425    }
426
427    /// #21: the algorithm-downgrade guard must derive an algorithm from a
428    /// `Multikey`/multibase verification method whose `type` isn't otherwise
429    /// recognized. The prefix is the multicodec code in *unsigned-varint*
430    /// encoding, so `p256-pub` (code `0x1200`) decodes to bytes `0x80 0x24`
431    /// — a literal `0x12 0x00` would never match a real key, silently
432    /// skipping the guard.
433    #[test]
434    fn declared_algorithm_from_multibase_multicodec() {
435        let mk = |prefix: &[u8], body_len: usize| {
436            let mut prefixed = prefix.to_vec();
437            prefixed.resize(prefix.len() + body_len, 0u8);
438            let mb = format!("z{}", bs58::encode(&prefixed).into_string());
439            VerificationMethod {
440                id: "did:web:example.com#key-1".into(),
441                method_type: "Multikey".into(),
442                controller: "did:web:example.com".into(),
443                public_key_jwk: None,
444                public_key_multibase: Some(mb),
445            }
446        };
447        // ed25519-pub: varint(0xed) = [0xed, 0x01], 32-byte key.
448        assert_eq!(mk(&[0xed, 0x01], 32).declared_algorithm(), Some("ed25519"));
449        // p256-pub: varint(0x1200) = [0x80, 0x24], 33-byte compressed key.
450        assert_eq!(
451            mk(&[0x80, 0x24], 33).declared_algorithm(),
452            Some("ecdsa-p256")
453        );
454        // The incorrect big-endian-code prefix must NOT be treated as p256.
455        assert_eq!(mk(&[0x12, 0x00], 33).declared_algorithm(), None);
456        // Unknown codec → no algorithm signal.
457        assert_eq!(mk(&[0xe7, 0x01], 33).declared_algorithm(), None);
458    }
459}