Skip to main content

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}