aph_resolver/dns.rs
1//! The §8.4.5 DNS TXT adapter, over `hickory-resolver`.
2//!
3//! This module contributes I/O and nothing else. It does not know what an
4//! APH tag-list looks like, which record wins a rotation, or what a validity
5//! window is — all of that is `aph_core::discovery::dns_txt`, reached through
6//! the composer. What lives here is the DNS query and ONE decision the parsing
7//! half cannot make on its own: whether a failed query means *nothing is
8//! published* or *the lookup broke*.
9//!
10//! That decision is load-bearing. Spec §8.4.6 advances to the next mechanism
11//! on ABSENCE and refuses outright on FAILURE, so an adapter that reports
12//! absence as failure takes every `did:web` notary offline the moment it stops
13//! publishing TXT, and one that reports failure as absence hands an attacker
14//! the downgrade §8.4.6 exists to forbid: block the DNS answer, and the
15//! verifier volunteers to trust whatever the web origin serves. The port says
16//! which is which in its return type — `aph_core::discovery::DiscoveryOutcome`
17//! — so this module's job is only to decide which DNS answers land where.
18
19/// Which side of the absence/failure line a resolver error falls on.
20///
21/// Named rather than a `bool` because the two outcomes are not degrees of
22/// the same thing: one continues the §8.4.6 sequence and the other stops it.
23///
24/// Deliberately NOT `aph_core::discovery::DiscoveryOutcome`, which is the
25/// protocol's word for the same line. This classifies an ERROR, so it can
26/// never be `Found`; reusing the port's type here would give a pure two-
27/// valued classifier a third variant it cannot produce, and would force it
28/// to construct an `AphError` it has no business owning.
29/// [`negative_answer`] is the one place the two vocabularies meet.
30#[derive(std::fmt::Debug, std::clone::Clone, std::marker::Copy, std::cmp::PartialEq, std::cmp::Eq)]
31pub(crate) enum LookupOutcome {
32 /// Nothing is published at this name. The composer may advance.
33 Absence,
34 /// The lookup could not be completed. `APH_E008`; the composer must stop.
35 Failure,
36}
37
38/// A [`aph_core::discovery::ports::TxtRecordLookup`] backed by a system-
39/// configured hickory resolver.
40///
41/// `Send + Sync` follows from the resolver's own bounds, so one instance can
42/// be shared across concurrent verifications without wrapping.
43pub struct HickoryTxtLookup {
44 resolver: hickory_resolver::TokioAsyncResolver,
45}
46
47impl HickoryTxtLookup {
48 /// Builds a lookup from the host's own resolver configuration —
49 /// `/etc/resolv.conf` on Unix, the registry on Windows.
50 ///
51 /// # Errors
52 ///
53 /// `APH_E008` if the system configuration cannot be read. The failure is
54 /// reported in the same opaque form as a failed query on purpose: a caller
55 /// that could distinguish "this verifier is misconfigured" from "that
56 /// notary is unreachable" by reading an error string would be reporting
57 /// its own posture to whoever chose the DID.
58 pub fn from_system_conf() -> std::result::Result<Self, aph_core::errors::AphError> {
59 match hickory_resolver::TokioAsyncResolver::tokio_from_system_conf() {
60 std::result::Result::Ok(resolver) => std::result::Result::Ok(Self { resolver }),
61 std::result::Result::Err(_) => {
62 std::result::Result::Err(aph_core::errors::AphError::NotaryServiceUnreachable)
63 }
64 }
65 }
66
67 /// Builds a lookup over a resolver the caller configured.
68 ///
69 /// The injection point for an adopter who already runs a resolver — a
70 /// pinned recursive server, DNS-over-TLS, a shorter timeout — without
71 /// having to reimplement the absence/failure split, which is the only part
72 /// of this adapter that is protocol knowledge rather than plumbing.
73 pub fn with_resolver(resolver: hickory_resolver::TokioAsyncResolver) -> Self {
74 Self { resolver }
75 }
76}
77
78impl aph_core::discovery::ports::TxtRecordLookup for HickoryTxtLookup {
79 /// Returns EVERY TXT string at `name`, unparsed and unfiltered.
80 ///
81 /// No pre-filtering, per the port contract: a domain legitimately holds
82 /// unrelated TXT records at one name and a rotating notary holds several
83 /// APH records side by side, so an adapter that dropped what it judged
84 /// malformed would turn one typo in DNS into a total verification outage.
85 ///
86 /// `name` arrives already derived by `DidUrl::dns_txt_name`; this adapter
87 /// does not re-derive it.
88 fn lookup_txt<'a>(
89 &'a self,
90 name: &'a str,
91 ) -> aph_core::discovery::ports::DiscoveryFuture<
92 'a,
93 aph_core::discovery::DiscoveryOutcome<std::vec::Vec<String>>,
94 > {
95 std::boxed::Box::pin(async move {
96 match self.resolver.txt_lookup(name).await {
97 std::result::Result::Ok(lookup) => {
98 let mut records: std::vec::Vec<String> = std::vec::Vec::new();
99 for txt in lookup.iter() {
100 records.push(join_character_strings(
101 txt.txt_data().iter().map(|part| &part[..]),
102 ));
103 }
104 // hickory reports the ordinary no-records answer as a
105 // `NoRecordsFound` ERROR, classified on the arm below. This guard
106 // is for the other shape — a lookup that succeeded and yielded
107 // nothing — so that the port can never answer `Found` with nothing
108 // found. Downstream it makes no difference (an empty record set
109 // selects to absence anyway); it makes the port's own answer
110 // honest, which is the whole point of the type.
111 if records.is_empty() {
112 return std::result::Result::Ok(aph_core::discovery::DiscoveryOutcome::Absent);
113 }
114 std::result::Result::Ok(aph_core::discovery::DiscoveryOutcome::Found(records))
115 }
116 std::result::Result::Err(error) => negative_answer(classify_resolve_error(&error)),
117 }
118 })
119 }
120}
121
122/// The classifier's verdict expressed as the port's own answer.
123///
124/// The one place this crate's absence/failure vocabulary meets the protocol's
125/// (see [`LookupOutcome`]), split out as a pure function so the translation
126/// is testable with no network — which matters, because getting it backwards
127/// in either direction is a live outage or a silent downgrade, and neither is
128/// visible in a unit test of the classifier alone.
129fn negative_answer(
130 outcome: LookupOutcome,
131) -> std::result::Result<
132 aph_core::discovery::DiscoveryOutcome<std::vec::Vec<String>>,
133 aph_core::errors::AphError,
134> {
135 match outcome {
136 LookupOutcome::Absence => {
137 std::result::Result::Ok(aph_core::discovery::DiscoveryOutcome::Absent)
138 }
139 LookupOutcome::Failure => {
140 std::result::Result::Err(aph_core::errors::AphError::NotaryServiceUnreachable)
141 }
142 }
143}
144
145/// Reassembles one TXT record's character-strings into the single value the
146/// publisher wrote.
147///
148/// A TXT record's RDATA is a sequence of length-prefixed strings, each at
149/// most 255 bytes, and a record longer than that arrives split. Concatenating
150/// them is the same reassembly DKIM (RFC 6376 §3.6.2.2) and SPF perform, and
151/// it is NOT the filtering the port forbids: nothing is dropped, and the
152/// record count out equals the record count in. Handing the fragments back
153/// separately would be the defect — two unparseable halves instead of one
154/// valid tag-list.
155///
156/// Non-UTF-8 bytes are replaced rather than causing the record to be skipped.
157/// Skipping would be filtering, and a garbled record must be allowed to fail
158/// `select_key` on its own without denying a valid record beside it.
159fn join_character_strings<'a>(parts: impl std::iter::Iterator<Item = &'a [u8]>) -> String {
160 let mut joined: std::vec::Vec<u8> = std::vec::Vec::new();
161 for part in parts {
162 joined.extend_from_slice(part);
163 }
164 String::from_utf8_lossy(&joined).into_owned()
165}
166
167/// The absence/failure split, as a pure function of a resolver error.
168///
169/// Pure so it is testable with no network — which is the point, because this
170/// is the rule a live run proved and a live run is exactly what a test suite
171/// must not need. hickory 0.24 folds the negative answers into a single
172/// `NoRecordsFound` kind and preserves the DNS response code beside it, so
173/// the code is what has to be read: `NXDomain` and `NoError` are the server
174/// answering "there is nothing here", while `ServFail`, `Refused` and every
175/// other kind mean the question was never answered.
176///
177/// The `_` arm is required — `ResolveErrorKind` is `#[non_exhaustive]` — and
178/// it fails CLOSED: a kind this build has never heard of is a failure, not
179/// an absence, so a hickory upgrade cannot quietly widen what advances the
180/// §8.4.6 sequence.
181pub(crate) fn classify_resolve_error(
182 error: &hickory_resolver::error::ResolveError,
183) -> LookupOutcome {
184 match error.kind() {
185 hickory_resolver::error::ResolveErrorKind::NoRecordsFound { response_code, .. } => {
186 classify_response_code(*response_code)
187 }
188 _ => LookupOutcome::Failure,
189 }
190}
191
192/// The DNS response-code half of the split.
193///
194/// Separated from [`classify_resolve_error`] so the rule can be stated —
195/// and tested — over the value that actually decides it, independent of how
196/// any resolver release chooses to wrap it.
197pub(crate) fn classify_response_code(
198 response_code: hickory_resolver::proto::op::ResponseCode,
199) -> LookupOutcome {
200 match response_code {
201 // The name does not exist. Live-proven absence: on 2026-08-14 real DNS
202 // answered NXDOMAIN for a notary's `_aph._notary` name and the §8.4.6
203 // composer correctly advanced to did:web, which then resolved.
204 hickory_resolver::proto::op::ResponseCode::NXDomain => LookupOutcome::Absence,
205 // The name exists but holds no TXT records — absence just as much as
206 // NXDOMAIN, and the more common shape for a domain that publishes A
207 // records and nothing else at that label.
208 hickory_resolver::proto::op::ResponseCode::NoError => LookupOutcome::Absence,
209 // SERVFAIL, REFUSED and the rest: the resolver did not answer the
210 // question. Treating these as absence would let anyone who can break or
211 // merely block DNS choose which anchor the verifier trusts.
212 _ => LookupOutcome::Failure,
213 }
214}
215
216#[cfg(test)]
217mod tests {
218 /// Builds the negative answer hickory reports for a given response code.
219 ///
220 /// `ResolveErrorKind` is `#[non_exhaustive]`, which forbids exhaustive
221 /// MATCHING outside the crate but not construction of an individual
222 /// variant, so the real error shape can be built here rather than
223 /// approximated.
224 fn no_records_found(
225 response_code: hickory_resolver::proto::op::ResponseCode,
226 ) -> hickory_resolver::error::ResolveError {
227 std::convert::Into::into(hickory_resolver::error::ResolveErrorKind::NoRecordsFound {
228 query: std::boxed::Box::new(hickory_resolver::proto::op::Query::new()),
229 soa: std::option::Option::None,
230 negative_ttl: std::option::Option::None,
231 response_code,
232 trusted: false,
233 })
234 }
235
236 #[test]
237 fn nxdomain_and_empty_noerror_are_absence() {
238 // THE contract this adapter exists to get right, and the one the shipped
239 // port doc got wrong until 2026-08-14: a notary that publishes only a
240 // DID Document has no `_aph._notary` name at all, so NXDOMAIN is the
241 // NORMAL answer on the §8.4.6 path — proven live, where the composer saw
242 // NXDOMAIN, advanced, and resolved the key over did:web. Report either of
243 // these as APH_E008 and every did:web-only notary becomes unverifiable.
244 std::assert_eq!(
245 super::classify_response_code(hickory_resolver::proto::op::ResponseCode::NXDomain),
246 super::LookupOutcome::Absence
247 );
248 std::assert_eq!(
249 super::classify_response_code(hickory_resolver::proto::op::ResponseCode::NoError),
250 super::LookupOutcome::Absence
251 );
252 }
253
254 #[test]
255 fn servfail_and_refused_are_failure() {
256 // The other half, and the security-critical one. SERVFAIL and REFUSED
257 // mean the question was never answered — including when an on-path
258 // attacker made sure of it. Classifying them as absence would advance
259 // the §8.4.6 sequence past the anchor the notary actually publishes to,
260 // which is precisely the downgrade that section forbids.
261 std::assert_eq!(
262 super::classify_response_code(hickory_resolver::proto::op::ResponseCode::ServFail),
263 super::LookupOutcome::Failure
264 );
265 std::assert_eq!(
266 super::classify_response_code(hickory_resolver::proto::op::ResponseCode::Refused),
267 super::LookupOutcome::Failure
268 );
269 // A response code with no bearing on APH still stops the sequence:
270 // fail-closed is the default, and only the two absence codes escape it.
271 std::assert_eq!(
272 super::classify_response_code(hickory_resolver::proto::op::ResponseCode::NotImp),
273 super::LookupOutcome::Failure
274 );
275 }
276
277 #[test]
278 fn the_split_survives_the_real_resolver_error_shape() {
279 // classify_response_code is the rule; classify_resolve_error is what the
280 // adapter actually calls. Testing only the former would leave the
281 // unwrapping untested, and an unwrapping that matched the wrong kind
282 // would send every NXDOMAIN down the failure arm while every unit test
283 // above still passed.
284 std::assert_eq!(
285 super::classify_resolve_error(&no_records_found(
286 hickory_resolver::proto::op::ResponseCode::NXDomain
287 )),
288 super::LookupOutcome::Absence
289 );
290 std::assert_eq!(
291 super::classify_resolve_error(&no_records_found(
292 hickory_resolver::proto::op::ResponseCode::ServFail
293 )),
294 super::LookupOutcome::Failure
295 );
296 }
297
298 #[test]
299 fn a_timeout_is_never_absence() {
300 // A timeout carries no response code at all, so it reaches the `_` arm.
301 // That arm must fail closed: a verifier whose resolver is being starved
302 // of answers is not a verifier that has learned a notary publishes
303 // nothing, and the difference decides whether an attacker can pick the
304 // trust anchor by dropping packets.
305 std::assert_eq!(
306 super::classify_resolve_error(&std::convert::Into::into(
307 hickory_resolver::error::ResolveErrorKind::Timeout
308 )),
309 super::LookupOutcome::Failure
310 );
311 std::assert_eq!(
312 super::classify_resolve_error(&std::convert::Into::into(
313 hickory_resolver::error::ResolveErrorKind::NoConnections
314 )),
315 super::LookupOutcome::Failure
316 );
317 }
318
319 #[test]
320 fn the_verdict_becomes_a_typed_absent_answer_or_an_opaque_refusal() {
321 // The classifier tests above pin which DNS answers are absence; this pins
322 // what the PORT then says, which is the half a caller acts on. Both
323 // directions are load-bearing and neither is observable from the
324 // classifier alone: `Absent` is what lets the §8.4.6 sequence advance to
325 // did:web, and `Err(APH_E008)` is what stops it. Swap the two arms and
326 // every classifier test still passes while every did:web-only notary
327 // becomes unverifiable — or, the other way, whoever can block DNS picks
328 // the trust anchor.
329 std::assert_eq!(
330 super::negative_answer(super::LookupOutcome::Absence).unwrap(),
331 aph_core::discovery::DiscoveryOutcome::Absent
332 );
333 std::assert_eq!(
334 super::negative_answer(super::LookupOutcome::Failure).unwrap_err(),
335 aph_core::errors::AphError::NotaryServiceUnreachable
336 );
337 }
338
339 #[test]
340 fn a_split_record_is_reassembled_not_returned_in_pieces() {
341 // A TXT record longer than 255 bytes arrives as several character-
342 // strings, and an APH tag-list with a P-256 key plus a `did` tag can
343 // cross that line. Handing the halves back separately would give
344 // `select_key` two unparseable fragments where the publisher wrote one
345 // valid record — a filtering defect wearing the costume of fidelity.
346 std::assert_eq!(
347 super::join_character_strings(
348 [&b"v=APHv1; alg=ed25519; "[..], &b"k=2Vc3Hpcg1XOoxCBT"[..]].into_iter()
349 ),
350 "v=APHv1; alg=ed25519; k=2Vc3Hpcg1XOoxCBT"
351 );
352 }
353
354 #[test]
355 fn a_non_utf8_record_survives_as_a_lossy_string() {
356 // Dropping an undecodable record would be exactly the pre-filtering the
357 // port forbids: one neighbour publishing binary junk at
358 // `_aph._notary.<domain>` must not be able to deny the valid APH record
359 // beside it. The garbled value reaches `select_key`, fails to parse
360 // there, and is skipped by the selection rules that were written for it.
361 let lossy = super::join_character_strings([&b"\xff\xfe"[..]].into_iter());
362 std::assert!(!lossy.is_empty(), "a record was dropped rather than kept");
363 }
364}