feather_reader/oauth/resolve.rs
1//! Turning what a user typed into a DID, a DID document, and a PDS.
2//!
3//! The I/O half of [`super::identity`], which holds the decisions. Split that
4//! way because the decisions are where the security properties live and they
5//! are testable; this is sequencing and network calls, which are not.
6//!
7//! **DNS takes precedence over HTTP.** The handle spec: *"When both methods
8//! return results, the DNS TXT result should be preferred."* It is not a
9//! tie-break rule in practice — a real handle was found during the live spike
10//! whose `/.well-known/atproto-did` returns 404 and which resolves by TXT
11//! alone, so an HTTP-first implementation simply fails on it.
12
13use anyhow::{Context as _, Result};
14use hickory_resolver::TokioResolver;
15use reqwest::Client;
16
17use super::{fetch, identity};
18use crate::net;
19
20/// A resolved account: who they are and where their repo lives.
21#[derive(Debug, Clone, PartialEq, Eq)]
22pub struct ResolvedAccount {
23 pub did: String,
24 pub pds_url: String,
25 /// The handle, only when it was verified bidirectionally. `None` for a
26 /// DID-first login whose handle did not round-trip — it must not be
27 /// displayed as though it were confirmed.
28 pub handle: Option<String>,
29}
30
31/// Build the DNS resolver from the host's own configuration.
32pub fn resolver() -> Result<TokioResolver> {
33 let builder = TokioResolver::builder_tokio().context("reading the system DNS configuration")?;
34 builder.build().context("building the DNS resolver")
35}
36
37/// Look up `_atproto.<handle>` and return the DID it claims.
38///
39/// `Ok(None)` means no usable record, which falls through to the well-known
40/// lookup. An error means records exist but are unusable — two different `did=`
41/// values, say — which must NOT fall through, because resolving a handle two
42/// ways and taking whichever answers is how you resolve to the wrong account.
43/// Whether a resolver error means "this name has no such record", as opposed to
44/// a failure to find out which.
45fn is_no_records(err: &hickory_resolver::net::NetError) -> bool {
46 matches!(
47 err,
48 hickory_resolver::net::NetError::Dns(hickory_resolver::net::DnsError::NoRecordsFound(_))
49 )
50}
51
52/// The name to query, **fully qualified**.
53///
54/// The trailing dot is load-bearing. hickory's `build_names` short-circuits only
55/// on `is_fqdn()`; without it, the host's `search` domains are appended and
56/// `_atproto.victim.example` is also queried as
57/// `_atproto.victim.example.<search-domain>`. Whoever controls that domain can
58/// then answer for any handle that has no TXT record of its own — and because
59/// DNS is tried first, the well-known lookup never runs, so the substitution is
60/// total. Kubernetes always sets a search domain; so do most LANs.
61///
62/// Node's `dns.resolveTxt` issues the name as given (c-ares does not apply the
63/// search list), which is why the reference implementation never needed this.
64fn txt_query_name(handle: &str) -> String {
65 format!("_atproto.{}.", handle.trim_end_matches('.'))
66}
67
68pub async fn did_from_dns(resolver: &TokioResolver, handle: &str) -> Result<Option<String>> {
69 let name = txt_query_name(handle);
70 let lookup = match resolver.txt_lookup(&name).await {
71 Ok(lookup) => lookup,
72 // ONLY "no records" is absence. Everything else — SERVFAIL, timeout,
73 // refused, a name too long to construct — is a failure and must NOT
74 // fall through to the well-known path: an attacker who can induce a
75 // resolver error would otherwise choose which of the two mechanisms
76 // answers, and the HTTP one is the weaker.
77 Err(err) if is_no_records(&err) => return Ok(None),
78 Err(err) => {
79 return Err(anyhow::Error::new(err))
80 .with_context(|| format!("resolving the {name} TXT record"))
81 }
82 };
83
84 // One joined value per RECORD. A record's character-strings concatenate;
85 // flattening them into separate records would split a long DID in half.
86 let records: Vec<String> = lookup
87 .answers()
88 .iter()
89 .filter_map(|record| match &record.data {
90 hickory_resolver::proto::rr::RData::TXT(txt) => Some(txt),
91 _ => None,
92 })
93 .map(|txt| {
94 let chunks: Vec<&[u8]> = txt.txt_data.iter().map(|c| c.as_ref()).collect();
95 identity::join_txt_chunks(&chunks)
96 })
97 .collect();
98
99 identity::did_from_txt_records(&records)
100 .with_context(|| format!("reading the {name} TXT record"))
101}
102
103/// Look up `/.well-known/atproto-did`.
104///
105/// The one fetch in this module where redirects ARE permitted: the handle spec
106/// allows them explicitly, unlike the metadata and DID documents.
107async fn did_from_well_known(http: &Client, handle: &str) -> Result<String> {
108 let url = format!("https://{handle}/.well-known/atproto-did");
109 let response = net::guarded_get_no_privacy(http, &url, &[])
110 .await
111 .with_context(|| format!("fetching {url}"))?;
112 let status = response.status().as_u16();
113 if status != 200 {
114 anyhow::bail!("{url} returned status {status}");
115 }
116 let body = net::read_capped(response).await?;
117 identity::did_from_well_known(&String::from_utf8_lossy(&body))
118 .with_context(|| format!("reading {url}"))
119}
120
121/// Resolve a handle to a DID: DNS first, then well-known.
122pub async fn did_for_handle(
123 resolver: &TokioResolver,
124 http: &Client,
125 handle: &str,
126) -> Result<String> {
127 let dns = did_from_dns(resolver, handle).await?;
128 prefer_dns(dns, || did_from_well_known(http, handle)).await
129}
130
131/// Apply the precedence rule: DNS wins, and the well-known lookup runs **only**
132/// when DNS returned no record at all.
133///
134/// `well_known` is a closure rather than a value so the property that matters is
135/// observable: that it is never CALLED when DNS answered. With the two lookups
136/// inline, swapping their order passed the whole suite — the rule was documented
137/// in the module header and pinned by nothing.
138///
139/// Note what the caller has already done: `did_from_dns` returns `Err` for a
140/// resolver FAILURE and `Ok(None)` only for a genuine absence, so the `?` above
141/// means a SERVFAIL never reaches this fallback. An attacker who can induce a
142/// resolver error must not get to choose the weaker mechanism.
143pub async fn prefer_dns<F, Fut>(dns: Option<String>, well_known: F) -> Result<String>
144where
145 F: FnOnce() -> Fut,
146 Fut: std::future::Future<Output = Result<String>>,
147{
148 match dns {
149 Some(did) => Ok(did),
150 None => well_known().await,
151 }
152}
153
154/// Fetch and validate a DID document.
155pub async fn did_document(
156 http: &Client,
157 did: &str,
158 plc_directory: &str,
159) -> Result<serde_json::Value> {
160 let url = identity::did_document_url(did, plc_directory)?;
161 let document = fetch::get_json(http, &url, fetch::DID_JSON).await?;
162 identity::validate_did_document(&document, did)?;
163 Ok(document)
164}
165
166/// Resolve whatever the user typed into an account.
167///
168/// From a HANDLE, the DID document must claim that handle back — the spec calls
169/// this mandatory, and without it whoever controls a DNS name can point it at
170/// any DID at all.
171///
172/// From a DID, the document's claimed handle is only trustworthy if re-resolving
173/// it returns the same DID; when it does not, the handle is reported as `None`
174/// rather than displayed beside an account it may not belong to.
175pub async fn resolve(
176 resolver: &TokioResolver,
177 http: &Client,
178 subject: &str,
179 plc_directory: &str,
180) -> Result<ResolvedAccount> {
181 if identity::is_atproto_did(subject) {
182 let document = did_document(http, subject, plc_directory).await?;
183 // The reverse round trip is I/O; deciding what it MEANS is not.
184 let reverse = match identity::declared_handle(&document) {
185 Some(handle) => did_for_handle(resolver, http, &handle).await.ok(),
186 None => None,
187 };
188 return account_from_did(&document, subject, reverse.as_deref());
189 }
190
191 let handle = identity::normalize_handle(subject)?;
192 let did = did_for_handle(resolver, http, &handle).await?;
193 let document = did_document(http, &did, plc_directory).await?;
194 account_from_handle(&document, &handle, &did)
195}
196
197/// The decision half of a HANDLE-first resolution.
198///
199/// Split out from the I/O so it can be tested: with the fetching inline, a
200/// mutation that dropped the `?` from `verify_handle_claim` — deleting the
201/// verification the spec calls mandatory — passed the entire suite, because
202/// every test of that rule sat one layer below on `verify_handle_claim` itself
203/// and nothing proved `resolve` called it.
204pub fn account_from_handle(
205 document: &serde_json::Value,
206 handle: &str,
207 did: &str,
208) -> Result<ResolvedAccount> {
209 // MANDATORY. Without it, whoever controls a DNS name can point it at any
210 // DID at all and we would serve that account under this handle.
211 identity::verify_handle_claim(document, handle)?;
212 Ok(ResolvedAccount {
213 pds_url: identity::pds_endpoint(document, did)?,
214 did: did.to_string(),
215 handle: Some(handle.to_string()),
216 })
217}
218
219/// The decision half of a DID-first resolution.
220///
221/// `reverse` is the DID that re-resolving the document's claimed handle
222/// returned, or `None` if there was no claim or the lookup failed. The handle is
223/// reported ONLY when that round trip came back to the same DID — a document can
224/// claim any handle it likes, and only the handle's own DNS or well-known record
225/// can confirm it.
226pub fn account_from_did(
227 document: &serde_json::Value,
228 did: &str,
229 reverse: Option<&str>,
230) -> Result<ResolvedAccount> {
231 let claimed = identity::declared_handle(document);
232 let handle = match (&claimed, reverse) {
233 (Some(_), Some(back)) if back == did => claimed.clone(),
234 // Claimed but unconfirmed, or never claimed: absent, not "probably
235 // right". An unverified handle displayed beside an account is a lie
236 // with a UI around it.
237 _ => None,
238 };
239 Ok(ResolvedAccount {
240 pds_url: identity::pds_endpoint(document, did)?,
241 did: did.to_string(),
242 handle,
243 })
244}
245
246#[cfg(test)]
247mod tests {
248 use super::*;
249
250 /// **The query name must be FULLY QUALIFIED.**
251 ///
252 /// Without the trailing dot, hickory appends the host's `search` domains
253 /// (`build_names` short-circuits only on `is_fqdn()`), so
254 /// `_atproto.victim.example` is ALSO queried as
255 /// `_atproto.victim.example.<search-domain>`. Whoever controls that domain
256 /// then answers for any handle lacking a TXT record — and since DNS is tried
257 /// first, the well-known lookup never runs. Kubernetes always has a search
258 /// domain; so do most corporate and home LANs.
259 ///
260 /// Node's `dns.resolveTxt` goes through c-ares, which issues the name as
261 /// given, so the reference implementation never had this and the port
262 /// acquired it silently.
263 #[test]
264 fn the_txt_query_name_is_fully_qualified() {
265 let name = txt_query_name("alice.example.com");
266 assert!(
267 name.ends_with('.'),
268 "not an FQDN, so the DNS search list applies: {name}"
269 );
270 assert_eq!(name, "_atproto.alice.example.com.");
271 assert!(!name.contains(".."), "double dot in {name}");
272 }
273
274 /// A handle that does not exist must be reported as absent, not as an
275 /// error — the well-known route is the documented fallback and has to be
276 /// reachable.
277 #[tokio::test]
278 async fn a_missing_txt_record_is_absent_rather_than_an_error() {
279 let resolver = resolver().unwrap();
280 let result = did_from_dns(&resolver, "nonexistent-handle.invalid").await;
281 assert!(matches!(result, Ok(None)), "got {result:?}");
282 }
283
284 /// **A resolver FAILURE is not "no record".** Treating every error as absent
285 /// silently downgrades resolution to the HTTP path, which is the weaker of
286 /// the two — and an off-path attacker who can force SERVFAIL or a timeout
287 /// gets to choose that downgrade.
288 ///
289 /// This case needs no network manipulation: a 251-character handle passes
290 /// `normalize_handle` (every label is within 63 bytes) but `_atproto.` + 251
291 /// exceeds the 255-byte DNS name limit, so name construction fails before a
292 /// query is ever issued.
293 #[tokio::test]
294 async fn a_resolver_failure_is_an_error_rather_than_absence() {
295 let label = "a".repeat(60);
296 let handle = format!("{label}.{label}.{label}.{label}.com");
297 assert!(handle.len() > 240 && handle.len() <= 253);
298 assert!(
299 identity::normalize_handle(&handle).is_ok(),
300 "the handle itself must be valid, or the test proves nothing"
301 );
302
303 let resolver = resolver().unwrap();
304 let result = did_from_dns(&resolver, &handle).await;
305 assert!(
306 result.is_err(),
307 "a name-construction failure was reported as 'no record': {result:?}"
308 );
309 }
310
311 /// The well-known fallback still goes through the SSRF guard, asserted on
312 /// the guard's own error rather than merely `is_err()`.
313 #[tokio::test]
314 async fn the_well_known_fallback_fails_closed_on_an_internal_host() {
315 let err = did_from_well_known(&Client::new(), "127.0.0.1")
316 .await
317 .expect_err("must refuse a loopback handle host");
318 let rendered = format!("{err:#}");
319 assert!(
320 rendered.contains("forbidden (internal) address"),
321 "failed for the wrong reason: {rendered}"
322 );
323 }
324
325 /// Reserved TLDs are rejected before any lookup happens, so a `.internal`
326 /// handle never becomes a DNS query at all.
327 #[tokio::test]
328 async fn a_reserved_tld_handle_is_refused_before_any_lookup() {
329 let resolver = resolver().unwrap();
330 let err = resolve(
331 &resolver,
332 &Client::new(),
333 "alice.internal",
334 "https://plc.directory",
335 )
336 .await
337 .expect_err("must refuse a reserved TLD");
338 assert!(format!("{err:#}").contains("reserved TLD"));
339 }
340
341 // ── the decisions `resolve` makes, now that they are reachable ───────────
342
343 const SUBJECT_DID: &str = "did:plc:ewvi7nxzyoun6zhxrhs64oiz";
344
345 fn doc_claiming(handle: &str) -> serde_json::Value {
346 serde_json::json!({
347 "id": SUBJECT_DID,
348 "alsoKnownAs": [format!("at://{handle}")],
349 "service": [{
350 "id": "#atproto_pds",
351 "type": "AtprotoPersonalDataServer",
352 "serviceEndpoint": "https://pds.example.com"
353 }]
354 })
355 }
356
357 /// **The document must claim the handle we started from.**
358 ///
359 /// Dropping the `?` from `verify_handle_claim` inside `resolve` passed the
360 /// entire suite: every test of that rule sat on `verify_handle_claim`
361 /// itself, and nothing proved the resolution path called it. Without it,
362 /// whoever controls a DNS name can point it at any DID at all.
363 #[test]
364 fn a_handle_the_document_does_not_claim_is_refused() {
365 let document = doc_claiming("someone-else.com");
366 let err = account_from_handle(&document, "victim.com", SUBJECT_DID)
367 .expect_err("a document that claims a different handle must be refused");
368 assert!(
369 format!("{err:#}").contains("victim.com"),
370 "failed for the wrong reason: {err:#}"
371 );
372
373 // The matching claim still resolves.
374 let ok = account_from_handle(&doc_claiming("alice.com"), "alice.com", SUBJECT_DID)
375 .expect("a matching claim must resolve");
376 assert_eq!(ok.handle.as_deref(), Some("alice.com"));
377 assert_eq!(ok.pds_url, "https://pds.example.com");
378 }
379
380 /// **A DID-first handle is reported only when it round-trips back.**
381 ///
382 /// Relaxing the comparison to accept ANY reverse result passed the suite. A
383 /// handle shown beside an account it does not belong to is a lie with a UI
384 /// around it.
385 #[test]
386 fn a_did_first_handle_must_round_trip_to_the_same_did() {
387 let document = doc_claiming("alice.com");
388
389 // Came back to us: reported.
390 let ok = account_from_did(&document, SUBJECT_DID, Some(SUBJECT_DID)).unwrap();
391 assert_eq!(ok.handle.as_deref(), Some("alice.com"));
392
393 // Came back to someone ELSE: withheld.
394 let other = account_from_did(
395 &document,
396 SUBJECT_DID,
397 Some("did:plc:aaaaaaaaaaaaaaaaaaaaaaaa"),
398 )
399 .unwrap();
400 assert_eq!(
401 other.handle, None,
402 "a handle that resolves to a DIFFERENT did was reported as verified"
403 );
404
405 // Did not come back at all: withheld.
406 let none = account_from_did(&document, SUBJECT_DID, None).unwrap();
407 assert_eq!(none.handle, None);
408
409 // Every case still yields the PDS — withholding the handle must not
410 // break the login.
411 assert_eq!(none.pds_url, "https://pds.example.com");
412 }
413
414 /// **DNS wins, and the well-known lookup is not even attempted.**
415 ///
416 /// Swapping the order passed the whole suite. The rule is not a tie-break:
417 /// a real handle was found during the live spike whose
418 /// `/.well-known/atproto-did` 404s and which resolves by TXT alone, so an
419 /// HTTP-first implementation simply fails on it — and the weaker mechanism
420 /// must never be reachable while the stronger one has answered.
421 #[tokio::test]
422 async fn dns_wins_and_the_well_known_lookup_is_never_called() {
423 let called = std::sync::atomic::AtomicUsize::new(0);
424
425 let did = prefer_dns(Some(SUBJECT_DID.to_string()), || async {
426 called.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
427 Ok("did:plc:aaaaaaaaaaaaaaaaaaaaaaaa".to_string())
428 })
429 .await
430 .unwrap();
431
432 assert_eq!(did, SUBJECT_DID, "the DNS answer must win");
433 assert_eq!(
434 called.load(std::sync::atomic::Ordering::SeqCst),
435 0,
436 "the well-known lookup ran even though DNS had answered"
437 );
438 }
439
440 /// And it IS called when DNS found nothing — the fallback has to work.
441 #[tokio::test]
442 async fn the_well_known_lookup_runs_when_dns_has_no_record() {
443 let did = prefer_dns(None, || async { Ok(SUBJECT_DID.to_string()) })
444 .await
445 .unwrap();
446 assert_eq!(did, SUBJECT_DID);
447 }
448}