Skip to main content

aph_resolver/
lib.rs

1// Every public item in a protocol reference implementation is read by
2// implementers, so undocumented surface is a defect.
3#![warn(missing_docs)]
4
5//! Ready-made I/O adapters for APH notary-key discovery (spec §8.4).
6//!
7//! `aph-core` parses and never fetches. Two of §8.4's three publication
8//! mechanisms are defined in terms of I/O — a DNS TXT query (§8.4.5) and an
9//! HTTPS GET of a DID Document (§8.4.4) — and `aph-core` declares them as two
10//! narrow one-method ports so the fetching lives outside it. This crate is one
11//! implementation of those two ports, carrying the DNS and HTTP dependencies
12//! that `aph-core` deliberately does not.
13//!
14//! # Who this is for
15//!
16//! **Adopters with no adapter layer of their own.** If you are writing a
17//! verifier — a CLI, a service, a test harness — and you have no opinion
18//! about how HTTP and DNS should be performed, [`resolve`] gets you a notary
19//! key in one call and the guards below come with it.
20//!
21//! **It is explicitly not for a host that already has a transport stack.** An
22//! OS or platform with its own HTTP client, its own DNS policy, its own
23//! timeout and retry budget and its own audit log should implement
24//! `aph_core::discovery::ports` over THAT stack, which is a few dozen lines,
25//! and keep one place where outbound requests are made. That is exactly what
26//! Squillo does, and it is why Squillo never links this crate: two HTTP
27//! clients in one process means two timeout policies, two proxy
28//! configurations and two answers to "what did this machine connect to".
29//!
30//! # What this crate does NOT decide
31//!
32//! Every parsing, selection and ordering rule stays in `aph-core`: tag-list
33//! parsing and key selection (`discovery::dns_txt`), DID Document parsing and
34//! verification-method lookup (`discovery::did_document`), and the §8.4.6
35//! mechanism order with its no-downgrade rule (`discovery::composer`). This
36//! crate contributes bytes and one classification — see below — and forms no
37//! opinion about what those bytes mean. A second parser here would be a
38//! second chance to disagree with the specification.
39//!
40//! The one judgement it does make is the ABSENCE/FAILURE split, and it is
41//! made because only the adapter can see it. §8.4.6 advances the mechanism
42//! sequence on absence and refuses on failure, so a DNS answer of NXDOMAIN
43//! (or NOERROR with no TXT records) becomes `Ok(vec![])` while a timeout,
44//! SERVFAIL or REFUSED becomes `APH_E008`. That distinction is what keeps a
45//! notary publishing only a DID Document verifiable, without letting whoever
46//! can block DNS choose which anchor a verifier trusts.
47//!
48//! # Security posture — the five SSRF controls
49//!
50//! The host a verifier connects to is named by the envelope being verified,
51//! which means it is chosen by an untrusted party. [`ReqwestDocumentFetch`]
52//! therefore applies five controls: a deny table over EVERY resolved address
53//! before any connect with the validated set pinned so the connect cannot
54//! re-resolve; HTTPS only with no certificate-validation escape hatch;
55//! redirects refused and the response origin re-checked; a 5 s budget and a
56//! 64 KiB body cap enforced while streaming; and ONE opaque `APH_E008`
57//! outward for every failure, so a verifier cannot be used as a network
58//! probe by whoever chose the DID. The source states each control at the
59//! line that implements it.
60//!
61//! # Example
62//!
63//! ```no_run
64//! # async fn run() -> std::result::Result<(), std::boxed::Box<dyn std::error::Error>> {
65//! // The DID URL comes from the envelope's `proof.verificationMethod`, and
66//! // the instant comes from its `decisionTimestamp` — not the wall clock,
67//! // so an envelope signed before a rotation keeps verifying.
68//! let key = aph_resolver::resolve(
69//!   "did:web:notary.example.com#k1",
70//!   "2026-06-01T00:00:00Z",
71//! )
72//! .await?;
73//! std::println!("{} byte key", key.key_bytes.len());
74//! # std::result::Result::Ok(())
75//! # }
76//! ```
77
78mod address;
79mod dns;
80mod web;
81
82pub use crate::dns::HickoryTxtLookup;
83pub use crate::web::ReqwestDocumentFetch;
84
85/// Resolves the notary public key a DID URL names, building both adapters
86/// for you (spec §8.4.6).
87///
88/// The no-host convenience path: a system-configured resolver and a guarded
89/// HTTPS client are constructed here and handed to
90/// `aph_core::discovery::composer::resolve` under
91/// `MechanismSelection::NamedByDid`. An adopter who wants a pinned mechanism,
92/// a shared resolver, or a client they configured should build
93/// [`HickoryTxtLookup`] and [`ReqwestDocumentFetch`] themselves and call the
94/// composer directly — this function is the default, not the only door.
95///
96/// # What the §8.4.6 order does
97///
98/// A `did:key` DID is decoded in-process and touches NO network at all: the
99/// key bytes are the identifier, so this path works on an air-gapped
100/// verifier. A `did:web` DID probes DNS TXT first and advances to the
101/// document fetch only when nothing is published there — absence advances,
102/// failure never does. A mechanism that was offered and then FAILED refuses
103/// on the spot under its own code, because retrying a weaker anchor when a
104/// stronger one breaks hands an attacker a free downgrade.
105///
106/// # `at_rfc3339`
107///
108/// Pass the envelope's `decisionTimestamp`, not the wall clock. Per §8.4.7 a
109/// verifier accepts any envelope whose signing key was valid at the moment
110/// the decision was made; using "now" instead would start rejecting
111/// correctly-signed historical envelopes the day an old key's `notAfter`
112/// passes.
113///
114/// # Errors
115///
116/// Whatever the chosen mechanism returns, unflattened — `APH_E008`
117/// unreachable, `APH_E014` nothing published, `APH_E003` outside the key's
118/// validity window, `APH_E010` unsupported method or algorithm. Also
119/// `APH_E008` if the host's own resolver configuration cannot be read, which
120/// is deliberately indistinguishable from a failed lookup.
121pub async fn resolve(
122  did_url: &str,
123  at_rfc3339: &str,
124) -> std::result::Result<aph_core::discovery::NotaryPublicKey, aph_core::errors::AphError> {
125  let lookup = crate::dns::HickoryTxtLookup::from_system_conf()?;
126  let fetch = crate::web::ReqwestDocumentFetch::new();
127  aph_core::discovery::composer::resolve(
128    aph_core::discovery::composer::MechanismSelection::NamedByDid,
129    did_url,
130    &lookup,
131    &fetch,
132    at_rfc3339,
133  )
134  .await
135}
136
137#[cfg(test)]
138mod tests {
139  /// The Ed25519 key published in every spec §8.4 example.
140  const SPEC_MULTIBASE: &str = "z6MkpTHR8VNsBxYAAWHut2Geadd9jSdoVTwBaPaeT1KhFmkV";
141
142  #[tokio::test]
143  async fn a_did_key_resolves_through_this_crate_with_no_network() {
144    // The whole convenience path, on the one mechanism that needs no I/O.
145    // It pins two things a signature alone does not: that `resolve` really
146    // delegates to the §8.4.6 composer rather than reimplementing dispatch
147    // (a reimplementation would have to decide what did:key means, and this
148    // crate must never decide that), and that the did:key arm reaches an
149    // answer without either adapter performing a lookup — which is what
150    // makes an air-gapped verifier possible. Note it still constructs the
151    // system resolver, so a machine with no resolver configuration would
152    // report APH_E008 here; that is accepted, and it is the reason an
153    // air-gapped caller should use `composer::resolve_did_key` directly.
154    let did = std::format!("did:key:{}#{}", SPEC_MULTIBASE, SPEC_MULTIBASE);
155    match super::resolve(&did, "2026-06-01T00:00:00Z").await {
156      std::result::Result::Ok(key) => {
157        std::assert_eq!(key.algorithm, aph_core::discovery::KeyAlgorithm::Ed25519);
158        std::assert_eq!(key.key_bytes.len(), 32);
159        std::assert_eq!(key.kid.as_deref(), std::option::Option::Some(SPEC_MULTIBASE));
160      }
161      // The only tolerated failure is the resolver-construction one, which
162      // happens before any mechanism is chosen. Anything else means the
163      // did:key path went to the network.
164      std::result::Result::Err(error) => std::assert_eq!(
165        error,
166        aph_core::errors::AphError::NotaryServiceUnreachable,
167        "did:key resolution failed for a reason other than resolver setup"
168      ),
169    }
170  }
171
172  #[tokio::test]
173  async fn an_unknown_did_method_is_refused_before_any_adapter_runs() {
174    // APH v0.1 defines exactly two DID methods. A third must be APH_E010
175    // from `composer::named_by`, not a guess at a resolution strategy — and
176    // certainly not an outbound request whose shape a stranger chose by
177    // writing an unrecognised DID into an envelope.
178    let error = super::resolve("did:example:notary", "2026-06-01T00:00:00Z")
179      .await
180      .unwrap_err();
181    std::assert!(
182      error.code() == "APH_E010" || error == aph_core::errors::AphError::NotaryServiceUnreachable,
183      "unexpected error for an unknown DID method: {}",
184      error
185    );
186  }
187}