Skip to main content

aph_core/discovery/
did_document.rs

1//! `did:web` DID Document parsing and verification-method lookup.
2//!
3//! Spec §8.4.4 resolves a notary key by fetching
4//! `https://<domain>/.well-known/did.json` and locating the
5//! `verificationMethod` entry the proof names. The fetch belongs to an
6//! adapter; the parsing and the lookup rules live here.
7//!
8//! The trust model is the TLS certificate chain that validated the origin —
9//! the same property that backs OAuth issuer URLs and BIMI.
10
11/// A DID Document, parsed to the fields APH needs.
12///
13/// Deliberately NOT `deny_unknown_fields`: a DID Document is a general W3C
14/// artifact that legitimately carries much more than APH reads (services,
15/// authentication relationships, other proof purposes). Rejecting those
16/// would make APH unable to verify notaries with ordinary DID Documents.
17#[derive(std::fmt::Debug, std::clone::Clone, serde::Serialize, serde::Deserialize)]
18pub struct DidDocument {
19  /// The document's own DID.
20  pub id: String,
21  /// Published keys. Absent in a document that delegates all keys by
22  /// reference, which APH cannot use.
23  #[serde(default, rename = "verificationMethod")]
24  pub verification_method: std::vec::Vec<VerificationMethod>,
25  /// DID URLs approved for the `assertionMethod` proof purpose.
26  #[serde(default, rename = "assertionMethod")]
27  pub assertion_method: std::vec::Vec<serde_json::Value>,
28}
29
30/// One entry of a DID Document's `verificationMethod` array.
31#[derive(std::fmt::Debug, std::clone::Clone, serde::Serialize, serde::Deserialize)]
32pub struct VerificationMethod {
33  /// Full DID URL of this key, including its fragment.
34  pub id: String,
35  /// Key type, e.g. `Multikey`.
36  #[serde(rename = "type")]
37  pub r#type: String,
38  /// DID that controls this key.
39  #[serde(default)]
40  pub controller: std::option::Option<String>,
41  /// Multibase-encoded multicodec public key.
42  #[serde(default, rename = "publicKeyMultibase")]
43  pub public_key_multibase: std::option::Option<String>,
44  /// JWK-encoded public key. Parsed only far enough to report that APH
45  /// does not yet consume this form.
46  #[serde(default, rename = "publicKeyJwk")]
47  pub public_key_jwk: std::option::Option<serde_json::Value>,
48}
49
50/// Parses a DID Document from the bytes an adapter fetched.
51pub fn parse_did_document(
52  json: &str,
53) -> std::result::Result<DidDocument, crate::errors::AphError> {
54  match serde_json::from_str::<DidDocument>(json) {
55    std::result::Result::Ok(doc) => std::result::Result::Ok(doc),
56    // A document that will not parse is indistinguishable, from a
57    // verifier's seat, from an unreachable notary.
58    std::result::Result::Err(_) => {
59      std::result::Result::Err(crate::errors::AphError::NotaryServiceUnreachable)
60    }
61  }
62}
63
64impl DidDocument {
65  /// Returns `true` if `did_url` is listed for the `assertionMethod` proof
66  /// purpose, which is the purpose APH envelope proofs declare.
67  ///
68  /// Entries may be plain DID URL strings or embedded objects with an `id`;
69  /// both forms are recognized.
70  pub fn allows_assertion(&self, did_url: &str) -> bool {
71    self.assertion_method.iter().any(|entry| match entry {
72      serde_json::Value::String(s) => s == did_url,
73      serde_json::Value::Object(o) => {
74        o.get("id").and_then(|v| v.as_str()) == std::option::Option::Some(did_url)
75      }
76      _ => false,
77    })
78  }
79
80  /// Finds the key a proof's `verificationMethod` names (spec §8.4.4
81  /// steps 5–6).
82  ///
83  /// When the DID URL carries no fragment, the document must publish
84  /// exactly one key: guessing among several would let the document's
85  /// ordering decide which key verifies a credential.
86  pub fn resolve_key(
87    &self,
88    did_url: &str,
89  ) -> std::result::Result<super::NotaryPublicKey, crate::errors::AphError> {
90    let parsed = super::DidUrl::parse(did_url);
91    let entry = match parsed.fragment {
92      std::option::Option::Some(_) => self
93        .verification_method
94        .iter()
95        .find(|vm| vm.id == did_url)
96        // Also accept an entry recorded as a bare fragment relative to the
97        // document, which some producers emit.
98        .or_else(|| {
99          let want = std::format!("#{}", parsed.fragment.clone().unwrap_or_default());
100          self.verification_method.iter().find(|vm| vm.id == want)
101        }),
102      std::option::Option::None => {
103        if self.verification_method.len() == 1 {
104          self.verification_method.first()
105        } else {
106          std::option::Option::None
107        }
108      }
109    };
110
111    let entry = match entry {
112      std::option::Option::Some(e) => e,
113      // The document was fetched and parsed; it simply names no key
114      // matching the queried DID URL (or names several and the URL carries
115      // no fragment to choose by). That is "not published" (APH_E014), not
116      // a signature defect: the E001 this arm returned before the taxonomy
117      // had a word for absence accused the envelope of a forgery no key
118      // was ever obtained to check.
119      std::option::Option::None => {
120        return std::result::Result::Err(crate::errors::AphError::notary_key_not_published(
121          std::format!("DID Document names no verificationMethod matching `{}`", did_url),
122        ));
123      }
124    };
125
126    let multibase = match entry.public_key_multibase.as_deref() {
127      std::option::Option::Some(m) => m,
128      std::option::Option::None => {
129        // publicKeyJwk is legal in a DID Document but not yet consumed
130        // here; say so plainly instead of reporting a bad signature.
131        return std::result::Result::Err(crate::errors::AphError::unsupported_algorithm(
132          "verificationMethod without publicKeyMultibase (publicKeyJwk not supported)",
133        ));
134      }
135    };
136
137    match crate::crypto::did_key::decode_multibase_key(multibase)? {
138      crate::crypto::did_key::DecodedDidKey::Ed25519(key) => {
139        std::result::Result::Ok(super::NotaryPublicKey {
140          algorithm: super::KeyAlgorithm::Ed25519,
141          key_bytes: key.as_bytes().to_vec(),
142          kid: parsed.fragment,
143        })
144      }
145      crate::crypto::did_key::DecodedDidKey::P256(bytes) => {
146        std::result::Result::Ok(super::NotaryPublicKey {
147          algorithm: super::KeyAlgorithm::P256,
148          key_bytes: bytes,
149          kid: parsed.fragment,
150        })
151      }
152    }
153  }
154}
155
156#[cfg(test)]
157mod tests {
158  /// The DID Document printed in spec §8.4.4.
159  const SPEC_EXAMPLE: &str = r#"{
160    "@context": ["https://www.w3.org/ns/did/v1"],
161    "id": "did:web:notary.example.com",
162    "verificationMethod": [
163      {
164        "id": "did:web:notary.example.com#k1",
165        "type": "Multikey",
166        "controller": "did:web:notary.example.com",
167        "publicKeyMultibase": "z6MkpTHR8VNsBxYAAWHut2Geadd9jSdoVTwBaPaeT1KhFmkV"
168      }
169    ],
170    "assertionMethod": ["did:web:notary.example.com#k1"]
171  }"#;
172
173  #[test]
174  fn parses_the_spec_example_document() {
175    // The literal example from the specification must parse, or operators
176    // copying it would publish something this verifier cannot read.
177    let doc = super::parse_did_document(SPEC_EXAMPLE).unwrap();
178    std::assert_eq!(doc.id, "did:web:notary.example.com");
179    std::assert_eq!(doc.verification_method.len(), 1);
180  }
181
182  #[test]
183  fn resolves_the_key_named_by_the_fragment() {
184    // The proof names a specific key; resolution must return that one.
185    let doc = super::parse_did_document(SPEC_EXAMPLE).unwrap();
186    let key = doc.resolve_key("did:web:notary.example.com#k1").unwrap();
187    std::assert_eq!(key.algorithm, crate::discovery::KeyAlgorithm::Ed25519);
188    std::assert_eq!(key.key_bytes.len(), 32);
189    std::assert_eq!(key.kid.as_deref(), Some("k1"));
190  }
191
192  #[test]
193  fn unknown_fragment_does_not_fall_back_to_another_key() {
194    // Falling back to "some other key in the document" would let a
195    // document owner verify a proof under a key the proof never named.
196    let doc = super::parse_did_document(SPEC_EXAMPLE).unwrap();
197    std::assert!(doc.resolve_key("did:web:notary.example.com#k9").is_err());
198  }
199
200  #[test]
201  fn fragmentless_url_resolves_only_when_the_document_is_unambiguous() {
202    // With one key, no fragment is unambiguous. With two, silently picking
203    // the first would make document ordering security-relevant.
204    let doc = super::parse_did_document(SPEC_EXAMPLE).unwrap();
205    std::assert!(doc.resolve_key("did:web:notary.example.com").is_ok());
206
207    let multi = r#"{
208      "id": "did:web:n.example",
209      "verificationMethod": [
210        {"id":"did:web:n.example#a","type":"Multikey","publicKeyMultibase":"z6MkpTHR8VNsBxYAAWHut2Geadd9jSdoVTwBaPaeT1KhFmkV"},
211        {"id":"did:web:n.example#b","type":"Multikey","publicKeyMultibase":"z6MkpTHR8VNsBxYAAWHut2Geadd9jSdoVTwBaPaeT1KhFmkV"}
212      ]
213    }"#;
214    let doc = super::parse_did_document(multi).unwrap();
215    std::assert!(doc.resolve_key("did:web:n.example").is_err());
216  }
217
218  #[test]
219  fn unknown_document_fields_are_tolerated() {
220    // A DID Document carries far more than APH reads. Strict parsing here
221    // would make ordinary, valid documents unusable.
222    // Note the ## delimiters: this document contains `"#hub"`, and the
223    // sequence `"#` would otherwise close an r#"..."# literal early.
224    let doc = r##"{
225      "@context": ["https://www.w3.org/ns/did/v1"],
226      "id": "did:web:n.example",
227      "service": [{"id":"#hub","type":"Hub","serviceEndpoint":"https://n.example/hub"}],
228      "authentication": ["did:web:n.example#k1"],
229      "verificationMethod": [
230        {"id":"did:web:n.example#k1","type":"Multikey","publicKeyMultibase":"z6MkpTHR8VNsBxYAAWHut2Geadd9jSdoVTwBaPaeT1KhFmkV"}
231      ]
232    }"##;
233    std::assert!(super::parse_did_document(doc).unwrap().resolve_key("did:web:n.example#k1").is_ok());
234  }
235
236  #[test]
237  fn assertion_method_membership_is_reported() {
238    // APH proofs declare proofPurpose "assertionMethod"; a caller enforcing
239    // that needs to know whether the key is listed for it.
240    let doc = super::parse_did_document(SPEC_EXAMPLE).unwrap();
241    std::assert!(doc.allows_assertion("did:web:notary.example.com#k1"));
242    std::assert!(!doc.allows_assertion("did:web:notary.example.com#k2"));
243  }
244
245  #[test]
246  fn jwk_only_entry_reports_unsupported_rather_than_bad_signature() {
247    // publicKeyJwk is legal but unimplemented. The distinction matters: an
248    // operator should learn to publish multibase, not hunt a key mismatch.
249    let doc = r#"{
250      "id": "did:web:n.example",
251      "verificationMethod": [
252        {"id":"did:web:n.example#k1","type":"JsonWebKey","publicKeyJwk":{"kty":"OKP"}}
253      ]
254    }"#;
255    let parsed = super::parse_did_document(doc).unwrap();
256    std::assert_eq!(
257      parsed.resolve_key("did:web:n.example#k1").unwrap_err().code(),
258      "APH_E010"
259    );
260  }
261
262  #[test]
263  fn malformed_json_reports_the_notary_as_unreachable() {
264    // From the verifier's seat an unparseable document and an unreachable
265    // origin are the same failure: no key was obtained.
266    std::assert_eq!(
267      super::parse_did_document("{not json").unwrap_err().code(),
268      "APH_E008"
269    );
270  }
271}