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  /// Key-agreement (encryption) keys — the surface RFC 0008's sealed
29  /// payloads resolve readers from. APH's profile carries EMBEDDED entries
30  /// (the same `Multikey` shape as `verificationMethod`), never bare
31  /// string references: a reference points at a key the resolver must
32  /// fetch AGAIN, and the §8.4 surfaces publish whole documents.
33  #[serde(default, rename = "keyAgreement")]
34  pub key_agreement: std::vec::Vec<VerificationMethod>,
35}
36
37/// The X25519 multicodec prefix (0xEC as a varint), the encryption sibling
38/// of the Ed25519 prefix `verificationMethod` keys carry.
39const X25519_MULTICODEC: [u8; 2] = [0xec, 0x01];
40
41/// One entry of a DID Document's `verificationMethod` array.
42#[derive(std::fmt::Debug, std::clone::Clone, serde::Serialize, serde::Deserialize)]
43pub struct VerificationMethod {
44  /// Full DID URL of this key, including its fragment.
45  pub id: String,
46  /// Key type, e.g. `Multikey`.
47  #[serde(rename = "type")]
48  pub r#type: String,
49  /// DID that controls this key.
50  #[serde(default)]
51  pub controller: std::option::Option<String>,
52  /// Multibase-encoded multicodec public key.
53  #[serde(default, rename = "publicKeyMultibase")]
54  pub public_key_multibase: std::option::Option<String>,
55  /// JWK-encoded public key. Parsed only far enough to report that APH
56  /// does not yet consume this form.
57  #[serde(default, rename = "publicKeyJwk")]
58  pub public_key_jwk: std::option::Option<serde_json::Value>,
59}
60
61/// Parses a DID Document from the bytes an adapter fetched.
62pub fn parse_did_document(
63  json: &str,
64) -> std::result::Result<DidDocument, crate::errors::AphError> {
65  match serde_json::from_str::<DidDocument>(json) {
66    std::result::Result::Ok(doc) => std::result::Result::Ok(doc),
67    // A document that will not parse is indistinguishable, from a
68    // verifier's seat, from an unreachable notary.
69    std::result::Result::Err(_) => {
70      std::result::Result::Err(crate::errors::AphError::NotaryServiceUnreachable)
71    }
72  }
73}
74
75impl DidDocument {
76  /// Resolves the X25519 `keyAgreement` key named by `kid` — the reader
77  /// key a sealed payload (RFC 0008) points at. Refuses with `APH_E023`
78  /// when the document publishes no matching entry, when the entry carries
79  /// no `publicKeyMultibase`, or when the bytes are not an X25519 multikey
80  /// — all three are "the key you were told to use is not published", and
81  /// which document surface came up empty is exactly what the code's
82  /// distinctness from `APH_E014` exists to say.
83  pub fn key_agreement_x25519(
84    &self,
85    kid: &str,
86  ) -> std::result::Result<[u8; 32], crate::errors::AphError> {
87    let refuse = || crate::errors::AphError::seal_reader_key_unpublished(&self.id, kid);
88    let want = std::format!("{}#{}", self.id, kid);
89    let entry = self
90      .key_agreement
91      .iter()
92      .find(|entry| entry.id == want || entry.id == kid)
93      .ok_or_else(refuse)?;
94    let multibase = entry.public_key_multibase.as_deref().ok_or_else(refuse)?;
95    let bytes =
96      crate::crypto::multibase::base58btc_decode(multibase).map_err(|_| refuse())?;
97    if bytes.len() != 34 || bytes[..2] != X25519_MULTICODEC {
98      return std::result::Result::Err(refuse());
99    }
100    let mut key = [0u8; 32];
101    key.copy_from_slice(&bytes[2..]);
102    std::result::Result::Ok(key)
103  }
104
105  /// Returns `true` if `did_url` is listed for the `assertionMethod` proof
106  /// purpose, which is the purpose APH envelope proofs declare.
107  ///
108  /// Entries may be plain DID URL strings or embedded objects with an `id`;
109  /// both forms are recognized.
110  pub fn allows_assertion(&self, did_url: &str) -> bool {
111    self.assertion_method.iter().any(|entry| match entry {
112      serde_json::Value::String(s) => s == did_url,
113      serde_json::Value::Object(o) => {
114        o.get("id").and_then(|v| v.as_str()) == std::option::Option::Some(did_url)
115      }
116      _ => false,
117    })
118  }
119
120  /// Finds the key a proof's `verificationMethod` names (spec §8.4.4
121  /// steps 5–6).
122  ///
123  /// When the DID URL carries no fragment, the document must publish
124  /// exactly one key: guessing among several would let the document's
125  /// ordering decide which key verifies a credential.
126  pub fn resolve_key(
127    &self,
128    did_url: &str,
129  ) -> std::result::Result<super::NotaryPublicKey, crate::errors::AphError> {
130    let parsed = super::DidUrl::parse(did_url);
131    let entry = match parsed.fragment {
132      std::option::Option::Some(_) => self
133        .verification_method
134        .iter()
135        .find(|vm| vm.id == did_url)
136        // Also accept an entry recorded as a bare fragment relative to the
137        // document, which some producers emit.
138        .or_else(|| {
139          let want = std::format!("#{}", parsed.fragment.clone().unwrap_or_default());
140          self.verification_method.iter().find(|vm| vm.id == want)
141        }),
142      std::option::Option::None => {
143        if self.verification_method.len() == 1 {
144          self.verification_method.first()
145        } else {
146          std::option::Option::None
147        }
148      }
149    };
150
151    let entry = match entry {
152      std::option::Option::Some(e) => e,
153      // The document was fetched and parsed; it simply names no key
154      // matching the queried DID URL (or names several and the URL carries
155      // no fragment to choose by). That is "not published" (APH_E014), not
156      // a signature defect: the E001 this arm returned before the taxonomy
157      // had a word for absence accused the envelope of a forgery no key
158      // was ever obtained to check.
159      std::option::Option::None => {
160        return std::result::Result::Err(crate::errors::AphError::notary_key_not_published(
161          std::format!("DID Document names no verificationMethod matching `{}`", did_url),
162        ));
163      }
164    };
165
166    let multibase = match entry.public_key_multibase.as_deref() {
167      std::option::Option::Some(m) => m,
168      std::option::Option::None => {
169        // publicKeyJwk is legal in a DID Document but not yet consumed
170        // here; say so plainly instead of reporting a bad signature.
171        return std::result::Result::Err(crate::errors::AphError::unsupported_algorithm(
172          "verificationMethod without publicKeyMultibase (publicKeyJwk not supported)",
173        ));
174      }
175    };
176
177    match crate::crypto::did_key::decode_multibase_key(multibase)? {
178      crate::crypto::did_key::DecodedDidKey::Ed25519(key) => {
179        std::result::Result::Ok(super::NotaryPublicKey {
180          algorithm: super::KeyAlgorithm::Ed25519,
181          key_bytes: key.as_bytes().to_vec(),
182          kid: parsed.fragment,
183        })
184      }
185      crate::crypto::did_key::DecodedDidKey::P256(bytes) => {
186        std::result::Result::Ok(super::NotaryPublicKey {
187          algorithm: super::KeyAlgorithm::P256,
188          key_bytes: bytes,
189          kid: parsed.fragment,
190        })
191      }
192    }
193  }
194}
195
196#[cfg(test)]
197mod tests {
198  /// The DID Document printed in spec §8.4.4.
199  const SPEC_EXAMPLE: &str = r#"{
200    "@context": ["https://www.w3.org/ns/did/v1"],
201    "id": "did:web:notary.example.com",
202    "verificationMethod": [
203      {
204        "id": "did:web:notary.example.com#k1",
205        "type": "Multikey",
206        "controller": "did:web:notary.example.com",
207        "publicKeyMultibase": "z6MkpTHR8VNsBxYAAWHut2Geadd9jSdoVTwBaPaeT1KhFmkV"
208      }
209    ],
210    "assertionMethod": ["did:web:notary.example.com#k1"]
211  }"#;
212
213  #[test]
214  fn parses_the_spec_example_document() {
215    // The literal example from the specification must parse, or operators
216    // copying it would publish something this verifier cannot read.
217    let doc = super::parse_did_document(SPEC_EXAMPLE).unwrap();
218    std::assert_eq!(doc.id, "did:web:notary.example.com");
219    std::assert_eq!(doc.verification_method.len(), 1);
220  }
221
222  #[test]
223  fn resolves_the_key_named_by_the_fragment() {
224    // The proof names a specific key; resolution must return that one.
225    let doc = super::parse_did_document(SPEC_EXAMPLE).unwrap();
226    let key = doc.resolve_key("did:web:notary.example.com#k1").unwrap();
227    std::assert_eq!(key.algorithm, crate::discovery::KeyAlgorithm::Ed25519);
228    std::assert_eq!(key.key_bytes.len(), 32);
229    std::assert_eq!(key.kid.as_deref(), Some("k1"));
230  }
231
232  #[test]
233  fn unknown_fragment_does_not_fall_back_to_another_key() {
234    // Falling back to "some other key in the document" would let a
235    // document owner verify a proof under a key the proof never named.
236    let doc = super::parse_did_document(SPEC_EXAMPLE).unwrap();
237    std::assert!(doc.resolve_key("did:web:notary.example.com#k9").is_err());
238  }
239
240  #[test]
241  fn fragmentless_url_resolves_only_when_the_document_is_unambiguous() {
242    // With one key, no fragment is unambiguous. With two, silently picking
243    // the first would make document ordering security-relevant.
244    let doc = super::parse_did_document(SPEC_EXAMPLE).unwrap();
245    std::assert!(doc.resolve_key("did:web:notary.example.com").is_ok());
246
247    let multi = r#"{
248      "id": "did:web:n.example",
249      "verificationMethod": [
250        {"id":"did:web:n.example#a","type":"Multikey","publicKeyMultibase":"z6MkpTHR8VNsBxYAAWHut2Geadd9jSdoVTwBaPaeT1KhFmkV"},
251        {"id":"did:web:n.example#b","type":"Multikey","publicKeyMultibase":"z6MkpTHR8VNsBxYAAWHut2Geadd9jSdoVTwBaPaeT1KhFmkV"}
252      ]
253    }"#;
254    let doc = super::parse_did_document(multi).unwrap();
255    std::assert!(doc.resolve_key("did:web:n.example").is_err());
256  }
257
258  #[test]
259  fn unknown_document_fields_are_tolerated() {
260    // A DID Document carries far more than APH reads. Strict parsing here
261    // would make ordinary, valid documents unusable.
262    // Note the ## delimiters: this document contains `"#hub"`, and the
263    // sequence `"#` would otherwise close an r#"..."# literal early.
264    let doc = r##"{
265      "@context": ["https://www.w3.org/ns/did/v1"],
266      "id": "did:web:n.example",
267      "service": [{"id":"#hub","type":"Hub","serviceEndpoint":"https://n.example/hub"}],
268      "authentication": ["did:web:n.example#k1"],
269      "verificationMethod": [
270        {"id":"did:web:n.example#k1","type":"Multikey","publicKeyMultibase":"z6MkpTHR8VNsBxYAAWHut2Geadd9jSdoVTwBaPaeT1KhFmkV"}
271      ]
272    }"##;
273    std::assert!(super::parse_did_document(doc).unwrap().resolve_key("did:web:n.example#k1").is_ok());
274  }
275
276  #[test]
277  fn assertion_method_membership_is_reported() {
278    // APH proofs declare proofPurpose "assertionMethod"; a caller enforcing
279    // that needs to know whether the key is listed for it.
280    let doc = super::parse_did_document(SPEC_EXAMPLE).unwrap();
281    std::assert!(doc.allows_assertion("did:web:notary.example.com#k1"));
282    std::assert!(!doc.allows_assertion("did:web:notary.example.com#k2"));
283  }
284
285  #[test]
286  fn jwk_only_entry_reports_unsupported_rather_than_bad_signature() {
287    // publicKeyJwk is legal but unimplemented. The distinction matters: an
288    // operator should learn to publish multibase, not hunt a key mismatch.
289    let doc = r#"{
290      "id": "did:web:n.example",
291      "verificationMethod": [
292        {"id":"did:web:n.example#k1","type":"JsonWebKey","publicKeyJwk":{"kty":"OKP"}}
293      ]
294    }"#;
295    let parsed = super::parse_did_document(doc).unwrap();
296    std::assert_eq!(
297      parsed.resolve_key("did:web:n.example#k1").unwrap_err().code(),
298      "APH_E010"
299    );
300  }
301
302  #[test]
303  fn malformed_json_reports_the_notary_as_unreachable() {
304    // From the verifier's seat an unparseable document and an unreachable
305    // origin are the same failure: no key was obtained.
306    std::assert_eq!(
307      super::parse_did_document("{not json").unwrap_err().code(),
308      "APH_E008"
309    );
310  }
311
312  #[test]
313  fn key_agreement_lookup_resolves_x25519_and_refuses_with_e023_by_name() {
314    // The surface RFC 0008 readers are resolved from, end to end: a
315    // published X25519 multikey resolves to its raw bytes; a missing kid,
316    // and an entry of the WRONG codec (a signing key in the encryption
317    // slot — the conversion this protocol refuses to do), both refuse
318    // with APH_E023 naming reader and kid.
319    let x25519_raw = [7u8; 32];
320    let mut encoded_bytes = std::vec![0xecu8, 0x01];
321    encoded_bytes.extend_from_slice(&x25519_raw);
322    let multibase = crate::crypto::multibase::base58btc_encode(&encoded_bytes);
323    let doc: super::DidDocument = serde_json::from_value(serde_json::json!({
324      "id": "did:web:reader.example.com",
325      "verificationMethod": [],
326      "keyAgreement": [
327        {
328          "id": "did:web:reader.example.com#enc-1",
329          "type": "Multikey",
330          "controller": "did:web:reader.example.com",
331          "publicKeyMultibase": multibase
332        },
333        {
334          "id": "did:web:reader.example.com#sign-as-enc",
335          "type": "Multikey",
336          "controller": "did:web:reader.example.com",
337          "publicKeyMultibase": "z6MkpTHR8VNsBxYAAWHut2Geadd9jSdoVTwBaPaeT1KhFmkV"
338        }
339      ]
340    }))
341    .expect("the fixture document parses");
342
343    std::assert_eq!(
344      doc.key_agreement_x25519("enc-1").expect("the published key resolves"),
345      x25519_raw
346    );
347
348    let missing = doc.key_agreement_x25519("enc-9").expect_err("an unpublished kid refuses");
349    std::assert_eq!(missing.code(), "APH_E023");
350    std::assert!(std::format!("{missing}").contains("enc-9"));
351
352    let wrong_codec = doc
353      .key_agreement_x25519("sign-as-enc")
354      .expect_err("an Ed25519 key in the keyAgreement slot is not an encryption key");
355    std::assert_eq!(wrong_codec.code(), "APH_E023");
356  }
357}