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