Skip to main content

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}