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
44//! `Ok(aph_core::discovery::DiscoveryOutcome::Absent)` while a timeout,
45//! SERVFAIL or REFUSED becomes `APH_E008`. That distinction is what keeps a
46//! notary publishing only a DID Document verifiable, without letting whoever
47//! can block DNS choose which anchor a verifier trusts.
48//!
49//! # Security posture — the five SSRF controls
50//!
51//! The host a verifier connects to is named by the envelope being verified,
52//! which means it is chosen by an untrusted party. [`ReqwestDocumentFetch`]
53//! therefore applies five controls: a deny table over EVERY resolved address
54//! before any connect with the validated set pinned so the connect cannot
55//! re-resolve; HTTPS only with no certificate-validation escape hatch;
56//! redirects refused and the response origin re-checked; a 5 s budget and a
57//! 64 KiB body cap enforced while streaming; and ONE opaque `APH_E008`
58//! outward for every failure, so a verifier cannot be used as a network
59//! probe by whoever chose the DID. The source states each control at the
60//! line that implements it.
61//!
62//! # Example
63//!
64//! ```no_run
65//! # async fn run() -> std::result::Result<(), std::boxed::Box<dyn std::error::Error>> {
66//! // The DID URL comes from the envelope's `proof.verificationMethod`, and
67//! // the instant comes from its `decisionTimestamp` — not the wall clock,
68//! // so an envelope signed before a rotation keeps verifying.
69//! let key = aph_resolver::resolve(
70//! "did:web:notary.example.com#k1",
71//! "2026-06-01T00:00:00Z",
72//! )
73//! .await?;
74//! std::println!("{} byte key", key.key_bytes.len());
75//! # std::result::Result::Ok(())
76//! # }
77//! ```
78
79mod address;
80mod dns;
81mod web;
82
83pub use crate::dns::HickoryTxtLookup;
84pub use crate::web::ReqwestDocumentFetch;
85
86/// Resolves the notary public key a DID URL names, building both adapters
87/// for you (spec §8.4.6).
88///
89/// The no-host convenience path: a system-configured resolver and a guarded
90/// HTTPS client are constructed here and handed to
91/// `aph_core::discovery::composer::resolve` under
92/// `MechanismSelection::NamedByDid`. An adopter who wants a pinned mechanism,
93/// a shared resolver, or a client they configured should build
94/// [`HickoryTxtLookup`] and [`ReqwestDocumentFetch`] themselves and call the
95/// composer directly — this function is the default, not the only door.
96///
97/// # What the §8.4.6 order does
98///
99/// A `did:key` DID is decoded in-process and touches NO network at all: the
100/// key bytes are the identifier, so this path works on an air-gapped
101/// verifier. A `did:web` DID probes DNS TXT first and advances to the
102/// document fetch only when nothing is published there — absence advances,
103/// failure never does. A mechanism that was offered and then FAILED refuses
104/// on the spot under its own code, because retrying a weaker anchor when a
105/// stronger one breaks hands an attacker a free downgrade.
106///
107/// # `at_rfc3339`
108///
109/// Pass the envelope's `decisionTimestamp`, not the wall clock. Per §8.4.7 a
110/// verifier accepts any envelope whose signing key was valid at the moment
111/// the decision was made; using "now" instead would start rejecting
112/// correctly-signed historical envelopes the day an old key's `notAfter`
113/// passes.
114///
115/// # Errors
116///
117/// Whatever the chosen mechanism returns, unflattened — `APH_E008`
118/// unreachable, `APH_E014` nothing published, `APH_E003` outside the key's
119/// validity window, `APH_E010` unsupported method or algorithm. Also
120/// `APH_E008` if the host's own resolver configuration cannot be read, which
121/// is deliberately indistinguishable from a failed lookup.
122pub async fn resolve(
123 did_url: &str,
124 at_rfc3339: &str,
125) -> std::result::Result<aph_core::discovery::NotaryPublicKey, aph_core::errors::AphError> {
126 let lookup = crate::dns::HickoryTxtLookup::from_system_conf()?;
127 let fetch = crate::web::ReqwestDocumentFetch::new();
128 aph_core::discovery::composer::resolve(
129 aph_core::discovery::composer::MechanismSelection::NamedByDid,
130 did_url,
131 &lookup,
132 &fetch,
133 at_rfc3339,
134 )
135 .await
136}
137
138#[cfg(test)]
139mod tests {
140 /// The Ed25519 key published in every spec §8.4 example.
141 const SPEC_MULTIBASE: &str = "z6MkpTHR8VNsBxYAAWHut2Geadd9jSdoVTwBaPaeT1KhFmkV";
142
143 #[tokio::test]
144 async fn a_did_key_resolves_through_this_crate_with_no_network() {
145 // The whole convenience path, on the one mechanism that needs no I/O.
146 // It pins two things a signature alone does not: that `resolve` really
147 // delegates to the §8.4.6 composer rather than reimplementing dispatch
148 // (a reimplementation would have to decide what did:key means, and this
149 // crate must never decide that), and that the did:key arm reaches an
150 // answer without either adapter performing a lookup — which is what
151 // makes an air-gapped verifier possible. Note it still constructs the
152 // system resolver, so a machine with no resolver configuration would
153 // report APH_E008 here; that is accepted, and it is the reason an
154 // air-gapped caller should use `composer::resolve_did_key` directly.
155 let did = std::format!("did:key:{}#{}", SPEC_MULTIBASE, SPEC_MULTIBASE);
156 match super::resolve(&did, "2026-06-01T00:00:00Z").await {
157 std::result::Result::Ok(key) => {
158 std::assert_eq!(key.algorithm, aph_core::discovery::KeyAlgorithm::Ed25519);
159 std::assert_eq!(key.key_bytes.len(), 32);
160 std::assert_eq!(key.kid.as_deref(), std::option::Option::Some(SPEC_MULTIBASE));
161 }
162 // The only tolerated failure is the resolver-construction one, which
163 // happens before any mechanism is chosen. Anything else means the
164 // did:key path went to the network.
165 std::result::Result::Err(error) => std::assert_eq!(
166 error,
167 aph_core::errors::AphError::NotaryServiceUnreachable,
168 "did:key resolution failed for a reason other than resolver setup"
169 ),
170 }
171 }
172
173 #[tokio::test]
174 async fn an_unknown_did_method_is_refused_before_any_adapter_runs() {
175 // APH v0.1 defines exactly two DID methods. A third must be APH_E010
176 // from `composer::named_by`, not a guess at a resolution strategy — and
177 // certainly not an outbound request whose shape a stranger chose by
178 // writing an unrecognised DID into an envelope.
179 let error = super::resolve("did:example:notary", "2026-06-01T00:00:00Z")
180 .await
181 .unwrap_err();
182 std::assert!(
183 error.code() == "APH_E010" || error == aph_core::errors::AphError::NotaryServiceUnreachable,
184 "unexpected error for an unknown DID method: {}",
185 error
186 );
187 }
188}