aph_core/discovery/composer.rs
1//! Mechanism dispatch for notary key discovery (spec §8.4.6).
2//!
3//! Spec §8.4 gives a verifier three ways to recover a notary's public key,
4//! and §8.4.6 states the order they are preferred in — `did:key` offline,
5//! then DNS TXT, then `did:web` over HTTPS — followed by the rule that makes
6//! the order safe:
7//!
8//! > A verifier MUST NOT silently fall back from a stronger anchor to a
9//! > weaker one mid-resolution; failures escalate to envelope rejection.
10//!
11//! **That rule is the whole design of this module.** The SELECTION is fixed
12//! before any I/O happens, and failure is always the answer — what advances
13//! the §8.4.6 sequence is ABSENCE, never failure. There is no `or_else` on
14//! an error path, because a verifier that retries a weaker anchor when a
15//! stronger one FAILS hands an attacker a free downgrade: break (or merely
16//! block) the mechanism the notary actually anchors its identity to, and the
17//! verifier volunteers to trust whatever is published under the one it can
18//! reach. A mechanism that was never published offered nothing to break, so
19//! advancing past it concedes nothing — that distinction is the whole §8.4.6
20//! order.
21//!
22//! Which mechanism a DID *names* is [`DiscoveryMechanism::named_by`]: a
23//! `did:key` DID names the offline decode, a `did:web` DID names its DID
24//! Document. DNS TXT is never named by a DID — §8.4.5 anchors it to the
25//! domain of a `did:web` identifier — but a `did:web` DID still reaches it:
26//! [`resolve`]'s `DidWeb` arm probes DNS TXT FIRST per §8.4.6's order,
27//! advancing to the document fetch only when nothing is published there
28//! (absence), and rejecting outright when TXT is published-and-broken.
29//! [`MechanismSelection::Pinned`] narrows to exactly one mechanism — §8.4.6's
30//! "A verifier MAY pin a preferred mechanism per notary (typically via
31//! configuration or via prior successful resolution)" — for the operator who
32//! wants no sequence at all.
33//!
34//! Nothing here does I/O. The two ports in [`super::ports`] supply it, the
35//! `did:key` path needs none, and every parsing rule is delegated:
36//! [`crate::crypto::did_key`] decodes multicodec key material,
37//! [`super::dns_txt`] parses and selects TXT records, and
38//! [`super::did_document`] parses DID Documents. This module adds no second
39//! decoder and — importantly — no `map_err`: each mechanism's failure
40//! distinctions (`APH_E008` unreachable, `APH_E003` outside its validity
41//! window, `APH_E010` unsupported, `APH_E014` not published) reach the caller
42//! unflattened, because "the notary is down" and "the notary rotated last
43//! week" have different next steps for an operator.
44
45/// One of the three publication mechanisms of spec §8.4.2.
46#[derive(std::fmt::Debug, std::clone::Clone, std::marker::Copy, std::cmp::PartialEq, std::cmp::Eq)]
47pub enum DiscoveryMechanism {
48 /// §8.4.3 — the key bytes are embedded in the DID itself; decoded
49 /// in-process with no network access of any kind.
50 DidKey,
51 /// §8.4.5 — a DKIM-style tag-list in TXT records at
52 /// `_aph._notary.<domain>`, anchored in DNS.
53 DnsTxt,
54 /// §8.4.4 — a DID Document fetched over TLS from the notary's web
55 /// origin, anchored in the certificate chain that validated it.
56 DidWeb,
57}
58
59impl DiscoveryMechanism {
60 /// Returns the mechanism a DID URL itself names (spec §8.4.6 steps 1
61 /// and 3).
62 ///
63 /// `did:key` names [`Self::DidKey`]; `did:web` names [`Self::DidWeb`].
64 /// [`Self::DnsTxt`] is never returned by THIS function because no DID
65 /// method spells it — but a `did:web` DID still reaches it: [`resolve`]'s
66 /// `DidWeb` arm probes DNS TXT first per §8.4.6's order, advancing to the
67 /// document fetch only on ABSENCE (nothing published) and never on
68 /// FAILURE. [`MechanismSelection::Pinned`] narrows to one mechanism when
69 /// an operator wants no sequence at all.
70 ///
71 /// # Errors
72 ///
73 /// `APH_E010` for any other DID method. APH v0.1 defines exactly two, and
74 /// guessing a resolution strategy for a third would mean verifying against
75 /// key material obtained by an unspecified route.
76 pub fn named_by(did_url: &str) -> std::result::Result<Self, crate::errors::AphError> {
77 let did = super::DidUrl::parse(did_url).did;
78 if did.starts_with("did:key:") {
79 return std::result::Result::Ok(Self::DidKey);
80 }
81 if did.starts_with("did:web:") {
82 return std::result::Result::Ok(Self::DidWeb);
83 }
84 std::result::Result::Err(crate::errors::AphError::unsupported_algorithm(std::format!(
85 "DID method of `{}` is not an APH v0.1 key discovery mechanism (§8.4.2)",
86 did
87 )))
88 }
89}
90
91/// How [`resolve`] picks the single mechanism it will use.
92#[derive(std::fmt::Debug, std::clone::Clone, std::marker::Copy, std::cmp::PartialEq, std::cmp::Eq)]
93pub enum MechanismSelection {
94 /// Use whatever the DID names ([`DiscoveryMechanism::named_by`]). The
95 /// default for a verifier with no configuration for this notary.
96 NamedByDid,
97 /// Use exactly this mechanism and no other — §8.4.6's per-notary pin.
98 ///
99 /// A pin is a narrowing, never a widening: pinning a mechanism the DID
100 /// cannot support fails rather than quietly resolving by another route,
101 /// so an operator learns their configuration is wrong instead of getting
102 /// a key from somewhere they did not choose.
103 Pinned(DiscoveryMechanism),
104}
105
106/// Resolves the notary public key that `did_url` names, through §8.4.6's
107/// order for the selected mechanism — and a FAILURE in one mechanism never
108/// reaches another (spec §8.4.6).
109///
110/// Both ports are taken because the `DidWeb` sequence and the pinned
111/// selections route between them; the `did:key` path uses neither. The
112/// `match` below is the no-downgrade guarantee: every error propagates with
113/// `?` or is returned, so [`super::DiscoveryOutcome::Absent`] is the only
114/// thing that advances.
115///
116/// `at_rfc3339` is the instant the key must be valid at. Per §8.4.7 a
117/// verifier "accept[s] any envelope where the signing key was valid at the
118/// envelope's `decisionTimestamp`", so callers pass THAT timestamp, not the
119/// wall clock — otherwise every envelope signed before a rotation would
120/// start failing the day the old key's `notAfter` passes. It is consulted
121/// only by the DNS TXT mechanism, which is the only one publishing a
122/// validity window in v0.1.
123///
124/// # Errors
125///
126/// Whatever the chosen mechanism returns, unmodified — see the module note
127/// on preserving failure distinctions.
128pub async fn resolve(
129 selection: MechanismSelection,
130 did_url: &str,
131 lookup: &dyn super::ports::TxtRecordLookup,
132 fetch: &dyn super::ports::DidDocumentFetch,
133 at_rfc3339: &str,
134) -> std::result::Result<super::NotaryPublicKey, crate::errors::AphError> {
135 let mechanism = match selection {
136 MechanismSelection::NamedByDid => DiscoveryMechanism::named_by(did_url)?,
137 MechanismSelection::Pinned(pinned) => pinned,
138 };
139 match mechanism {
140 DiscoveryMechanism::DidKey => resolve_did_key(did_url),
141 DiscoveryMechanism::DnsTxt => resolve_dns_txt(did_url, lookup, at_rfc3339).await,
142 // §8.4.6 orders the network mechanisms DNS TXT then did:web, and the
143 // sequence below is NOT the downgrade that section forbids: the `?` is
144 // what makes it safe. Only `DiscoveryOutcome::Absent` can reach the
145 // second arm, because every failure has already left through the `?` —
146 // so a broken TXT record can never advance to the web origin, and no
147 // future edit can make it without deleting that `?`.
148 //
149 // Forged absence (an on-path attacker answering with an empty record
150 // set) buys nothing: it advances the verifier to did:web, which is
151 // anchored in a TLS certificate for the same domain — a bar DNS forgery
152 // does not clear.
153 DiscoveryMechanism::DidWeb => {
154 match probe_dns_txt(did_url, lookup, at_rfc3339).await? {
155 super::DiscoveryOutcome::Found(key) => std::result::Result::Ok(key),
156 super::DiscoveryOutcome::Absent => resolve_did_web(did_url, fetch).await,
157 }
158 }
159 }
160}
161
162/// Resolves a `did:key` DID URL offline (spec §8.4.3).
163///
164/// Synchronous and port-free by design: the key bytes ARE the identifier, so
165/// this path is usable on an air-gapped verifier that has no adapters at all.
166/// The multicodec decode is pure delegation to
167/// [`crate::crypto::did_key::decode`] — the same decoder `did:web`'s
168/// `publicKeyMultibase` goes through — because two decoders for one encoding
169/// is two chances to disagree about what a key is.
170///
171/// The DID URL's fragment becomes the returned
172/// [`super::NotaryPublicKey::kid`], and is stripped before decoding: a
173/// `did:key` URL conventionally repeats its multibase suffix after the `#`,
174/// and feeding that whole string to the decoder would fail.
175///
176/// # Errors
177///
178/// `APH_E001` if the identifier is not a decodable `did:key`, `APH_E010` if
179/// it carries a multicodec APH does not define.
180pub fn resolve_did_key(
181 did_url: &str,
182) -> std::result::Result<super::NotaryPublicKey, crate::errors::AphError> {
183 let parsed = super::DidUrl::parse(did_url);
184 // Decoded into a binding first so the borrow of `parsed.did` is plainly
185 // over before the arms move `parsed.fragment` out.
186 let decoded = crate::crypto::did_key::decode(&parsed.did)?;
187 match decoded {
188 crate::crypto::did_key::DecodedDidKey::Ed25519(key) => {
189 std::result::Result::Ok(super::NotaryPublicKey {
190 algorithm: super::KeyAlgorithm::Ed25519,
191 key_bytes: key.as_bytes().to_vec(),
192 kid: parsed.fragment,
193 })
194 }
195 crate::crypto::did_key::DecodedDidKey::P256(bytes) => {
196 std::result::Result::Ok(super::NotaryPublicKey {
197 algorithm: super::KeyAlgorithm::P256,
198 key_bytes: bytes,
199 kid: parsed.fragment,
200 })
201 }
202 }
203}
204
205/// Resolves through the DNS TXT mechanism (spec §8.4.5).
206///
207/// The queried name comes from [`super::DidUrl::dns_txt_name`] and is never
208/// assembled here: that function strips any port number (a port belongs in
209/// an HTTPS URL but never in a DNS name), percent-decodes in the one order
210/// that keeps a `%3A`-written port attached to its host, and prefixes
211/// `_aph._notary.` — a re-derivation gets at least one of those wrong, and
212/// this crate has already paid for that bug once.
213///
214/// Every record the port returns is handed to
215/// [`super::dns_txt::select_key`] untouched, so a malformed or unrelated TXT
216/// record sitting beside a valid one does not deny the valid one. The DID
217/// URL's fragment is passed through as the requested `kid`; note the
218/// consequence, which is an operator footgun worth stating: if the proof
219/// names a fragment, only a TXT record carrying a matching `kid` tag will be
220/// accepted, because an unlabelled record is not a substitute for a named
221/// key (§8.4.5 step 3b, and rotation would be ambiguous otherwise).
222///
223/// # Errors
224///
225/// `APH_E010` if the DID has no DNS anchor — notably a `did:key` DID, for
226/// which §8.4.5 step 1 says "this discovery path is not applicable". It
227/// fails rather than decoding the `did:key` offline: a caller that pinned
228/// DNS TXT asked for DNS TXT, and quietly answering from a different
229/// mechanism is the substitution this module exists to prevent.
230/// Otherwise whatever the port or `select_key` returns: `APH_E014` when
231/// nothing resolvable is published at the TXT name, `APH_E003` when a
232/// matching key's `notBefore`/`notAfter` window excludes `at_rfc3339`,
233/// `APH_E008` when the lookup itself failed.
234pub async fn resolve_dns_txt(
235 did_url: &str,
236 lookup: &dyn super::ports::TxtRecordLookup,
237 at_rfc3339: &str,
238) -> std::result::Result<super::NotaryPublicKey, crate::errors::AphError> {
239 let parsed = super::DidUrl::parse(did_url);
240 let name = match parsed.dns_txt_name() {
241 std::option::Option::Some(name) => name,
242 std::option::Option::None => {
243 return std::result::Result::Err(crate::errors::AphError::unsupported_algorithm(
244 std::format!(
245 "DNS TXT discovery is not applicable to `{}` (§8.4.5 step 1)",
246 parsed.did
247 ),
248 ));
249 }
250 };
251 match dns_txt_step(&name, parsed.fragment.as_deref(), lookup, at_rfc3339).await? {
252 super::DiscoveryOutcome::Found(key) => std::result::Result::Ok(key),
253 // Terminal absence: this mechanism was explicitly chosen (named or
254 // pinned), so there is no later mechanism to advance to — but absence
255 // is still not failure, and E014 is where the boundary says which one
256 // happened. A caller holding E014 knows nothing is published here; a
257 // caller holding E008 knows the lookup broke. §8.4.6's no-downgrade
258 // rule is only writable downstream because these are different codes.
259 super::DiscoveryOutcome::Absent => std::result::Result::Err(
260 crate::errors::AphError::notary_key_not_published(std::format!(
261 "DNS TXT `{}` (terminal: this mechanism was explicitly selected)",
262 name
263 )),
264 ),
265 }
266}
267
268/// The DNS TXT mechanism as one step: query the name, then select.
269///
270/// ONE translation point from the port's answer to the mechanism's, shared by
271/// the terminal [`resolve_dns_txt`] and the non-terminal [`probe_dns_txt`],
272/// because the two differ only in what they do with absence and a second copy
273/// is how they would come to differ in more than that. The two ways of
274/// publishing nothing collapse here: an adapter answering `Absent` and an
275/// adapter answering `Found` with a record set holding no APH record are the
276/// same fact, and `select_key` reports the second as absence too.
277async fn dns_txt_step(
278 name: &str,
279 kid: std::option::Option<&str>,
280 lookup: &dyn super::ports::TxtRecordLookup,
281 at_rfc3339: &str,
282) -> std::result::Result<super::DiscoveryOutcome<super::NotaryPublicKey>, crate::errors::AphError> {
283 match lookup.lookup_txt(name).await? {
284 super::DiscoveryOutcome::Found(records) => {
285 super::dns_txt::select_key(&records, kid, at_rfc3339)
286 }
287 super::DiscoveryOutcome::Absent => {
288 std::result::Result::Ok(super::DiscoveryOutcome::Absent)
289 }
290 }
291}
292
293/// The non-terminal DNS TXT probe the §8.4.6 ordered sequence uses, where
294/// `Absent` lets the caller advance and an error must stop it.
295async fn probe_dns_txt(
296 did_url: &str,
297 lookup: &dyn super::ports::TxtRecordLookup,
298 at_rfc3339: &str,
299) -> std::result::Result<super::DiscoveryOutcome<super::NotaryPublicKey>, crate::errors::AphError> {
300 let parsed = super::DidUrl::parse(did_url);
301 let name = match parsed.dns_txt_name() {
302 std::option::Option::Some(name) => name,
303 // A DID with no derivable TXT name does not offer the mechanism at all.
304 std::option::Option::None => {
305 return std::result::Result::Ok(super::DiscoveryOutcome::Absent);
306 }
307 };
308 dns_txt_step(&name, parsed.fragment.as_deref(), lookup, at_rfc3339).await
309}
310
311/// Resolves through the `did:web` DID Document mechanism (spec §8.4.4).
312///
313/// The document URL comes from [`super::DidUrl::web_document_url`] and is
314/// never assembled here, for the same reason the DNS name is not: colon-to-
315/// path-segment mapping, the `/.well-known/` special case, and percent-decode
316/// ordering are all already encoded there.
317///
318/// The fetched body goes to [`super::did_document::parse_did_document`] and
319/// then to [`super::did_document::DidDocument::resolve_key`] with the FULL
320/// DID URL, fragment included, so the document cannot answer with a key the
321/// proof did not name.
322///
323/// Note what is deliberately NOT enforced: membership in the document's
324/// `assertionMethod` list. The correct proof purpose depends on the proof's
325/// position in an envelope's chain — §7.1.11 gives a principal proof
326/// `assertionMethod` but a notary countersignature `authentication` — so a
327/// key-discovery function cannot know which list to demand without seeing
328/// the proof. A caller enforcing proof purpose must parse the document
329/// itself and consult
330/// [`super::did_document::DidDocument::allows_assertion`].
331///
332/// # Errors
333///
334/// `APH_E010` if the identifier yields no document URL (e.g. an empty host)
335/// or the named key is published only as `publicKeyJwk`; `APH_E008` if the
336/// port could not fetch or the body will not parse; `APH_E014` if the
337/// document was fetched but publishes no key under the named fragment —
338/// the document exists, this key is simply not published in it, and the
339/// requester already knows the host is reachable so the code leaks nothing
340/// a transport-opaque `APH_E008` was protecting.
341pub async fn resolve_did_web(
342 did_url: &str,
343 fetch: &dyn super::ports::DidDocumentFetch,
344) -> std::result::Result<super::NotaryPublicKey, crate::errors::AphError> {
345 let parsed = super::DidUrl::parse(did_url);
346 let url = match parsed.web_document_url() {
347 std::option::Option::Some(url) => url,
348 std::option::Option::None => {
349 return std::result::Result::Err(crate::errors::AphError::unsupported_algorithm(
350 std::format!(
351 "`{}` yields no did:web document URL (§8.4.4 step 2)",
352 parsed.did
353 ),
354 ));
355 }
356 };
357 let body = fetch.fetch_did_document(&url).await?;
358 super::did_document::parse_did_document(&body)?.resolve_key(did_url)
359}
360
361#[cfg(test)]
362mod tests {
363 /// The Ed25519 key published in every spec §8.4 example.
364 const SPEC_MULTIBASE: &str = "z6MkpTHR8VNsBxYAAWHut2Geadd9jSdoVTwBaPaeT1KhFmkV";
365 /// The single-key TXT record printed in spec §8.4.5.
366 const SPEC_TXT: &str =
367 "v=APHv1; alg=ed25519; k=2Vc3Hpcg1XOoxCBT0qZQYR8WlAlBpvW0nVwRyJI5Ouw";
368 /// The DID Document printed in spec §8.4.4.
369 const SPEC_DOC: &str = r#"{
370 "@context": ["https://www.w3.org/ns/did/v1"],
371 "id": "did:web:notary.example.com",
372 "verificationMethod": [
373 {
374 "id": "did:web:notary.example.com#k1",
375 "type": "Multikey",
376 "controller": "did:web:notary.example.com",
377 "publicKeyMultibase": "z6MkpTHR8VNsBxYAAWHut2Geadd9jSdoVTwBaPaeT1KhFmkV"
378 }
379 ],
380 "assertionMethod": ["did:web:notary.example.com#k1"]
381 }"#;
382 /// An instant inside the spec's rotation-example window.
383 const NOW: &str = "2026-06-01T00:00:00Z";
384
385 /// Drives a future to completion on the calling thread.
386 ///
387 /// The poller itself now lives in
388 /// [`crate::discovery::test_support::block_on`], because the §6.3.3 status
389 /// arm needs the same twelve lines and two hand-written wakers in one
390 /// crate is two sets of soundness assumptions. This local name is kept so
391 /// the fifteen call sites below stay about §8.4.6 rather than about test
392 /// scaffolding.
393 fn block_on<F: std::future::Future>(future: F) -> F::Output {
394 crate::discovery::test_support::block_on(future)
395 }
396
397 /// A DNS port with a canned answer that records the names it was asked for.
398 ///
399 /// The recorded names are what let a test assert a mechanism was NOT
400 /// consulted, which is the only observable form of "did not downgrade".
401 ///
402 /// The canned answer is the port's whole return type rather than a bare
403 /// record list, so a test can distinguish the three cases the §8.4.6 rule
404 /// turns on — published, definitively-not-published, and the lookup broke —
405 /// instead of spelling two of them with the same empty `Vec`.
406 struct FakeTxt {
407 answer: std::result::Result<
408 crate::discovery::DiscoveryOutcome<std::vec::Vec<String>>,
409 crate::errors::AphError,
410 >,
411 asked: std::sync::Mutex<std::vec::Vec<String>>,
412 }
413
414 impl FakeTxt {
415 fn with(records: &[&str]) -> Self {
416 Self::answering(std::result::Result::Ok(
417 crate::discovery::DiscoveryOutcome::Found(
418 records.iter().map(|r| String::from(*r)).collect(),
419 ),
420 ))
421 }
422
423 /// The name answered, and nothing is published there.
424 fn absent() -> Self {
425 Self::answering(std::result::Result::Ok(
426 crate::discovery::DiscoveryOutcome::Absent,
427 ))
428 }
429
430 fn failing(error: crate::errors::AphError) -> Self {
431 Self::answering(std::result::Result::Err(error))
432 }
433
434 fn answering(
435 answer: std::result::Result<
436 crate::discovery::DiscoveryOutcome<std::vec::Vec<String>>,
437 crate::errors::AphError,
438 >,
439 ) -> Self {
440 Self {
441 answer,
442 asked: std::sync::Mutex::new(std::vec::Vec::new()),
443 }
444 }
445
446 fn asked(&self) -> std::vec::Vec<String> {
447 self.asked.lock().expect("test mutex poisoned").clone()
448 }
449 }
450
451 impl crate::discovery::ports::TxtRecordLookup for FakeTxt {
452 fn lookup_txt<'a>(
453 &'a self,
454 name: &'a str,
455 ) -> crate::discovery::ports::DiscoveryFuture<
456 'a,
457 crate::discovery::DiscoveryOutcome<std::vec::Vec<String>>,
458 > {
459 std::boxed::Box::pin(async move {
460 self
461 .asked
462 .lock()
463 .expect("test mutex poisoned")
464 .push(String::from(name));
465 self.answer.clone()
466 })
467 }
468 }
469
470 /// A `did:web` port with a canned body that records the URLs it was given.
471 struct FakeFetch {
472 body: String,
473 error: std::option::Option<crate::errors::AphError>,
474 asked: std::sync::Mutex<std::vec::Vec<String>>,
475 }
476
477 impl FakeFetch {
478 fn with(body: &str) -> Self {
479 Self {
480 body: String::from(body),
481 error: None,
482 asked: std::sync::Mutex::new(std::vec::Vec::new()),
483 }
484 }
485
486 fn failing(error: crate::errors::AphError) -> Self {
487 Self {
488 body: String::new(),
489 error: Some(error),
490 asked: std::sync::Mutex::new(std::vec::Vec::new()),
491 }
492 }
493
494 fn asked(&self) -> std::vec::Vec<String> {
495 self.asked.lock().expect("test mutex poisoned").clone()
496 }
497 }
498
499 impl crate::discovery::ports::DidDocumentFetch for FakeFetch {
500 fn fetch_did_document<'a>(
501 &'a self,
502 url: &'a str,
503 ) -> crate::discovery::ports::DiscoveryFuture<'a, String> {
504 std::boxed::Box::pin(async move {
505 self
506 .asked
507 .lock()
508 .expect("test mutex poisoned")
509 .push(String::from(url));
510 let out: std::result::Result<String, crate::errors::AphError> = match &self.error {
511 Some(error) => Err(error.clone()),
512 None => Ok(self.body.clone()),
513 };
514 out
515 })
516 }
517 }
518
519 /// A port pair that panics on contact.
520 ///
521 /// Counting calls proves a port was not used *in this run*; panicking
522 /// proves it cannot be used at all on the path under test, which is the
523 /// stronger statement wanted for the offline `did:key` mechanism.
524 struct Exploding;
525
526 impl crate::discovery::ports::TxtRecordLookup for Exploding {
527 fn lookup_txt<'a>(
528 &'a self,
529 _name: &'a str,
530 ) -> crate::discovery::ports::DiscoveryFuture<
531 'a,
532 crate::discovery::DiscoveryOutcome<std::vec::Vec<String>>,
533 > {
534 std::panic!("did:key resolution must perform no DNS lookup");
535 }
536 }
537
538 impl crate::discovery::ports::DidDocumentFetch for Exploding {
539 fn fetch_did_document<'a>(
540 &'a self,
541 _url: &'a str,
542 ) -> crate::discovery::ports::DiscoveryFuture<'a, String> {
543 std::panic!("this path must perform no HTTPS fetch");
544 }
545 }
546
547 #[test]
548 fn did_key_resolves_with_both_ports_wired_to_explode() {
549 // §8.4.3's entire value is that verification works offline — on an
550 // air-gapped recipient, or when the notary's origin and DNS are both
551 // unreachable. A port that panics on contact is the only way to PROVE
552 // zero I/O rather than merely observe none: if a future refactor made
553 // the did:key path "confirm" the key against DNS or the DID Document,
554 // this test aborts instead of silently becoming a network dependency.
555 let did = std::format!("did:key:{}#{}", SPEC_MULTIBASE, SPEC_MULTIBASE);
556 let key = block_on(super::resolve(
557 super::MechanismSelection::NamedByDid,
558 &did,
559 &Exploding,
560 &Exploding,
561 NOW,
562 ))
563 .unwrap();
564 std::assert_eq!(key.algorithm, crate::discovery::KeyAlgorithm::Ed25519);
565 }
566
567 #[test]
568 fn did_key_fragment_becomes_the_kid() {
569 // A did:key URL repeats its multibase suffix after the '#'. The fragment
570 // must survive as the kid (callers match it against
571 // proof.verificationMethod) AND must be stripped before decoding — pass
572 // the whole URL to the multicodec decoder and it fails.
573 let did = std::format!("did:key:{}#{}", SPEC_MULTIBASE, SPEC_MULTIBASE);
574 let key = super::resolve_did_key(&did).unwrap();
575 std::assert_eq!(key.kid.as_deref(), Some(SPEC_MULTIBASE));
576 }
577
578 #[test]
579 fn dns_txt_returns_the_valid_key_beside_a_malformed_one() {
580 // A notary's domain holds TXT records this protocol knows nothing about
581 // (SPF, site verification) and, mid-rotation, records that are simply
582 // wrong. If one bad neighbour denied the valid key, a single typo in DNS
583 // would take a notary offline for every verifier. The port therefore
584 // hands back EVERY record and selection does the filtering.
585 let lookup = FakeTxt::with(&["v=spf1 include:example.com ~all", "garbage", SPEC_TXT]);
586 let key = block_on(super::resolve(
587 super::MechanismSelection::Pinned(super::DiscoveryMechanism::DnsTxt),
588 "did:web:notary.example.com",
589 &lookup,
590 &Exploding,
591 NOW,
592 ))
593 .unwrap();
594 std::assert_eq!(key.key_bytes.len(), 32);
595 }
596
597 #[test]
598 fn the_queried_dns_name_comes_from_did_url_not_from_string_building() {
599 // DidUrl::dns_txt_name strips any port number, percent-decodes in the one
600 // order that keeps a %3A-written port attached to its host, prefixes
601 // _aph._notary., and — as here — ignores the DID's path segments.
602 // Re-deriving the name instead of calling it gets at least one of those
603 // wrong; pinning the exact queried name is what stops that recurring.
604 let lookup = FakeTxt::with(&[SPEC_TXT]);
605 let _ = block_on(super::resolve_dns_txt(
606 "did:web:example.com:notaries:alice",
607 &lookup,
608 NOW,
609 ));
610 std::assert_eq!(lookup.asked(), std::vec![String::from("_aph._notary.example.com")]);
611 }
612
613 #[test]
614 fn did_web_resolves_through_the_fetch_port() {
615 // The §8.4.4 path end to end: probe DNS TXT first per §8.4.6's order,
616 // find NOTHING PUBLISHED (absence — the one outcome that may advance),
617 // then derive the well-known URL, fetch through the port, parse, and
618 // return the key the proof's fragment names. Asserting the kid pins
619 // that the document answered for k1 rather than whatever it listed
620 // first; the absent TXT fake pins that advancing required absence, not
621 // an ignored error.
622 let lookup = FakeTxt::absent();
623 let fetch = FakeFetch::with(SPEC_DOC);
624 let key = block_on(super::resolve(
625 super::MechanismSelection::NamedByDid,
626 "did:web:notary.example.com#k1",
627 &lookup,
628 &fetch,
629 NOW,
630 ))
631 .unwrap();
632 std::assert_eq!(key.kid.as_deref(), Some("k1"));
633 }
634
635 #[test]
636 fn the_fetched_url_comes_from_did_url_not_from_string_building() {
637 // Same reason as the DNS name: web_document_url encodes the colon-to-
638 // path-segment mapping and the /.well-known special case. A composer
639 // that rebuilt the URL would fetch the wrong document for any pathful
640 // did:web — and a wrong document that happens to parse yields a wrong
641 // key, not an error.
642 let fetch = FakeFetch::with(SPEC_DOC);
643 let _ = block_on(super::resolve_did_web("did:web:example.com:notaries:alice#k1", &fetch));
644 std::assert_eq!(
645 fetch.asked(),
646 std::vec![String::from("https://example.com/notaries/alice/did.json")]
647 );
648 }
649
650 #[test]
651 fn a_broken_txt_record_never_advances_to_did_web() {
652 // THE no-downgrade test, in the direction §8.4.6 makes sharp: DNS TXT
653 // is PUBLISHED AND BROKEN (a key exists, its window has closed), and a
654 // perfectly good did:web document sits one step further down the
655 // chain. A composer that advanced would succeed here and look correct.
656 // That success is the attack: whoever can expire or corrupt the TXT
657 // record — the preferred anchor — gets to choose that the verifier
658 // trusts the next one instead, and choosing the anchor is an identity
659 // decision. The two assertions are one claim: the TXT failure was
660 // reported AS the outcome, and the web port was never consulted.
661 let expired = "v=APHv1; alg=ed25519; kid=k1; \
662 k=2Vc3Hpcg1XOoxCBT0qZQYR8WlAlBpvW0nVwRyJI5Ouw; \
663 notAfter=2026-01-01T00:00:00Z";
664 let lookup = FakeTxt::with(&[expired]);
665 let fetch = FakeFetch::with(SPEC_DOC);
666 let error = block_on(super::resolve(
667 super::MechanismSelection::NamedByDid,
668 "did:web:notary.example.com#k1",
669 &lookup,
670 &fetch,
671 NOW,
672 ))
673 .unwrap_err();
674 std::assert_eq!(error.code(), "APH_E003");
675 std::assert!(
676 fetch.asked().is_empty(),
677 "did:web was consulted after a TXT failure: {:?}",
678 fetch.asked()
679 );
680 }
681
682 #[test]
683 fn a_published_txt_key_preempts_the_did_web_fetch() {
684 // The complementary pin: when TXT publishes a valid key, §8.4.6's
685 // order means it IS the answer and the fetch never happens. This is
686 // the resilience §8.4.6 ranks TXT above HTTPS for — an origin outage
687 // does not take out verification for a notary that publishes both.
688 // The record carries kid=k1 because the probe selects by the proof's
689 // fragment; an unlabelled record beside a kid-bearing proof is ABSENCE
690 // (see dns_txt::select_key), which would advance instead of preempting.
691 let labelled =
692 "v=APHv1; alg=ed25519; kid=k1; k=2Vc3Hpcg1XOoxCBT0qZQYR8WlAlBpvW0nVwRyJI5Ouw";
693 let lookup = FakeTxt::with(&[labelled]);
694 let fetch = FakeFetch::failing(crate::errors::AphError::NotaryServiceUnreachable);
695 let key = block_on(super::resolve(
696 super::MechanismSelection::NamedByDid,
697 "did:web:notary.example.com#k1",
698 &lookup,
699 &fetch,
700 NOW,
701 ))
702 .unwrap();
703 std::assert_eq!(key.kid.as_deref(), Some("k1"));
704 std::assert!(fetch.asked().is_empty(), "fetch ran despite a valid TXT key");
705 }
706
707 #[test]
708 fn a_failed_dns_lookup_never_falls_back_to_did_web() {
709 // The downgrade guard has to hold in BOTH directions. A verifier that
710 // pinned DNS TXT for this notary (§8.4.6 per-notary pin) has decided the
711 // DNS anchor is the one it trusts; reaching for the DID Document when
712 // DNS fails would silently overrule that decision, and the operator who
713 // set the pin would never know it stopped being honoured.
714 let lookup = FakeTxt::failing(crate::errors::AphError::NotaryServiceUnreachable);
715 let fetch = FakeFetch::with(SPEC_DOC);
716 let error = block_on(super::resolve(
717 super::MechanismSelection::Pinned(super::DiscoveryMechanism::DnsTxt),
718 "did:web:notary.example.com#k1",
719 &lookup,
720 &fetch,
721 NOW,
722 ))
723 .unwrap_err();
724 std::assert_eq!(error.code(), "APH_E008");
725 std::assert!(fetch.asked().is_empty(), "HTTPS was consulted: {:?}", fetch.asked());
726 }
727
728 #[test]
729 fn pinning_dns_txt_on_a_did_key_refuses_rather_than_decoding_offline() {
730 // §8.4.5 step 1: for did:key "this discovery path is not applicable".
731 // The tempting shortcut — notice the DID is a did:key and just decode it
732 // — is mechanism substitution: the caller asked for the DNS anchor and
733 // would be handed a key from the self-describing identifier instead,
734 // which is precisely the anchor a pin was set to avoid relying on.
735 let did = std::format!("did:key:{}", SPEC_MULTIBASE);
736 let error = block_on(super::resolve_dns_txt(&did, &Exploding, NOW)).unwrap_err();
737 std::assert_eq!(error.code(), "APH_E010");
738 }
739
740 #[test]
741 fn dns_txt_is_never_the_mechanism_a_did_names() {
742 // If `named_by` returned DnsTxt for a did:web DID, DNS would become an
743 // implicit extra attempt on the default path for every did:web notary,
744 // and `resolve`'s single-mechanism guarantee would be worthless. DNS TXT
745 // is reachable only through an explicit pin.
746 std::assert_eq!(
747 super::DiscoveryMechanism::named_by("did:web:notary.example.com#k1").unwrap(),
748 super::DiscoveryMechanism::DidWeb
749 );
750 std::assert_eq!(
751 super::DiscoveryMechanism::named_by(&std::format!("did:key:{}", SPEC_MULTIBASE)).unwrap(),
752 super::DiscoveryMechanism::DidKey
753 );
754 }
755
756 #[test]
757 fn failure_classes_do_not_collapse_into_one_code() {
758 // A verifier's error is what an operator acts on, and
759 // these four demand four different actions — restore the service, wait
760 // for or re-issue against the rotated key, publish a supported key form,
761 // fix the DID method. A composer that map_err'd everything to one code
762 // (an easy accident when threading errors through three mechanisms)
763 // would leave every one of those questions unanswerable.
764 let expired = "v=APHv1; alg=ed25519; k=2Vc3Hpcg1XOoxCBT0qZQYR8WlAlBpvW0nVwRyJI5Ouw; \
765 notAfter=2026-01-01T00:00:00Z";
766
767 // APH_E010 — the DID method is not one APH v0.1 can resolve.
768 std::assert_eq!(
769 block_on(super::resolve(
770 super::MechanismSelection::NamedByDid,
771 "did:example:notary",
772 &Exploding,
773 &Exploding,
774 NOW,
775 ))
776 .unwrap_err()
777 .code(),
778 "APH_E010"
779 );
780
781 // APH_E014 — a PIN is a narrowing (there is no later mechanism), so
782 // absence at a pinned mechanism is terminal — but terminal absence is
783 // still absence, and since E014 joined the taxonomy the code says so.
784 // Pinning E014 (not E008) here is the point of the widening: a caller
785 // can now tell "nothing is published" from "the lookup broke" without
786 // parsing a message string.
787 std::assert_eq!(
788 block_on(super::resolve(
789 super::MechanismSelection::Pinned(super::DiscoveryMechanism::DnsTxt),
790 "did:web:notary.example.com",
791 &FakeTxt::absent(),
792 &Exploding,
793 NOW,
794 ))
795 .unwrap_err()
796 .code(),
797 "APH_E014"
798 );
799
800 // APH_E003 — the key was found, but not valid at this instant.
801 std::assert_eq!(
802 block_on(super::resolve(
803 super::MechanismSelection::Pinned(super::DiscoveryMechanism::DnsTxt),
804 "did:web:notary.example.com",
805 &FakeTxt::with(&[expired]),
806 &Exploding,
807 NOW,
808 ))
809 .unwrap_err()
810 .code(),
811 "APH_E003"
812 );
813
814 // APH_E014 — the anchor answered, but publishes no such key. The TXT
815 // probe that precedes the fetch sees absence (nothing published) and
816 // advances, per §8.4.6 — hence an absent fake here rather than the
817 // exploding one used on the did:key row. The fetched document exists
818 // and simply lacks `#k9`, which is "not published" (E014), not "the
819 // envelope's signature is invalid" (the E001 this arm reported before
820 // the taxonomy had a word for absence).
821 std::assert_eq!(
822 block_on(super::resolve(
823 super::MechanismSelection::NamedByDid,
824 "did:web:notary.example.com#k9",
825 &FakeTxt::absent(),
826 &FakeFetch::with(SPEC_DOC),
827 NOW,
828 ))
829 .unwrap_err()
830 .code(),
831 "APH_E014"
832 );
833 }
834
835 #[test]
836 fn the_txt_ports_three_answers_advance_advance_and_refuse() {
837 // The absence/failure rule now lives in a TYPE, so this pins that the
838 // type is wired to the behaviour it names — all three answers the DNS
839 // port can give, side by side, in one place. It also proves the change
840 // that introduced the type moved NOTHING: before it, absence was spelled
841 // `Ok(vec![])`, and an adapter that still answers with an empty record
842 // set (row 2) must behave exactly as one that answers `Absent` (row 1).
843 // A future edit that made either row refuse, or made row 3 advance, is
844 // the downgrade §8.4.6 forbids and fails here.
845 //
846 // Ok(Absent) nothing published at the name -> advance to did:web
847 // Ok(Found(&[])) the same fact, spelled by an -> advance to did:web
848 // adapter that returns records
849 // Err(APH_E008) the lookup never answered -> REFUSE, and the
850 // web port is never
851 // consulted
852 for lookup in [FakeTxt::absent(), FakeTxt::with(&[])] {
853 let fetch = FakeFetch::with(SPEC_DOC);
854 let key = block_on(super::resolve(
855 super::MechanismSelection::NamedByDid,
856 "did:web:notary.example.com#k1",
857 &lookup,
858 &fetch,
859 NOW,
860 ))
861 .unwrap();
862 std::assert_eq!(key.kid.as_deref(), Some("k1"));
863 std::assert_eq!(fetch.asked().len(), 1, "absence did not advance to did:web");
864 }
865
866 let failing = FakeTxt::failing(crate::errors::AphError::NotaryServiceUnreachable);
867 let fetch = FakeFetch::with(SPEC_DOC);
868 let error = block_on(super::resolve(
869 super::MechanismSelection::NamedByDid,
870 "did:web:notary.example.com#k1",
871 &failing,
872 &fetch,
873 NOW,
874 ))
875 .unwrap_err();
876 std::assert_eq!(error.code(), "APH_E008");
877 std::assert!(
878 fetch.asked().is_empty(),
879 "a failed lookup advanced to did:web: {:?}",
880 fetch.asked()
881 );
882 }
883
884 #[test]
885 fn an_unparseable_did_document_is_unreachable_not_a_bad_signature() {
886 // From a verifier's seat "the origin served junk" and "the origin was
887 // down" are the same outcome — no key was obtained — and neither is
888 // evidence about the envelope's signature. Reporting APH_E001 here would
889 // accuse the notary of forging a proof it never got the chance to prove.
890 let fetch = FakeFetch::with("{not json");
891 let error = block_on(super::resolve_did_web("did:web:notary.example.com#k1", &fetch))
892 .unwrap_err();
893 std::assert_eq!(error.code(), "APH_E008");
894 }
895
896 #[test]
897 fn a_did_web_with_no_host_yields_no_fetch_at_all() {
898 // `did:web:` has no origin to trust, so there is nothing to request. The
899 // guard belongs before the port call: without it the composer would ask
900 // an adapter to fetch a URL built from an empty host, turning a
901 // malformed DID into an outbound request an attacker chose the shape of.
902 let fetch = FakeFetch::with(SPEC_DOC);
903 let error = block_on(super::resolve_did_web("did:web:", &fetch)).unwrap_err();
904 std::assert_eq!(error.code(), "APH_E010");
905 std::assert!(fetch.asked().is_empty());
906 }
907}