Skip to main content

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}