Skip to main content

didbot_identity/
resolve.rs

1//! Resolving a DID to its DID document, dispatched by method.
2//!
3//! Two methods are supported. `did:web` — see the next section for why this
4//! crate ships its own implementation rather than a library's. `did:plc`
5//! resolves by fetching `https://plc.directory/<did>` from the PLC
6//! directory: no hostname to build, no percent-decoding, none of the
7//! `did:web` hazards below apply. A DID of any other method is refused with
8//! [`ResolveError::UnsupportedMethod`] naming the method, rather than
9//! quietly answering "no document" for something this resolver simply does
10//! not speak.
11//!
12//! # Why this crate ships its own `did:web` resolver
13//!
14//! jacquard-identity 0.12.1 builds the fetch URL by concatenating `https://`
15//! with the method-specific identifier verbatim. Neither half of that works
16//! for a development DID:
17//!
18//! * The identifier is never percent-decoded, so `did:web:localhost%3A3000`
19//!   becomes a request for a host literally named `localhost%3A3000`, which
20//!   no resolver, DNS or otherwise, will ever answer.
21//! * The scheme is hardcoded to `https://`, and nothing is listening on TLS
22//!   on a developer's loopback interface.
23//!
24//! Both are correct for the production case that library targets. Neither is
25//! fixable from outside it, and atproto explicitly permits the shape they
26//! break: "The special `localhost` hostname is allowed, but only in testing
27//! and development environments. Port numbers (with separating colon
28//! hex-encoded) are only allowed for `localhost`, and only in testing and
29//! development." That `did:web` path is unchanged here.
30//!
31//! `did:plc` is a different code path: no hostname is built from the
32//! identifier and there is nothing to percent-decode, so jacquard-identity's
33//! objection above does not apply to it. This module still does not depend
34//! on it. A `did:plc` resolves by a direct fetch to the directory over
35//! [`DidDocumentSource`], this module's own transport trait, rather than
36//! through jacquard-identity's `IdentityResolver`, which is a much larger
37//! surface (handle resolution, DNS, Slingshot fallbacks) built around its
38//! own client trait and several dependencies (`bon`, `http`, `n0_future`,
39//! optionally `hickory_resolver`) this crate does not otherwise need.
40//!
41//! # Why the transport is a trait
42//!
43//! No HTTP client is a dependency here. The fetch is a trait so that the PDS
44//! crate can supply the real transport with whatever client and runtime it
45//! already has, and so that every test in this crate runs against
46//! [`InMemoryDocuments`] with no network access at all. A real transport is
47//! also what makes [`ResolveError::NotFound`] and [`ResolveError::Transport`]
48//! meaningfully different: the directory answering "no such DID" and the
49//! directory being unreachable are different facts, and only the transport
50//! that made the request can tell them apart.
51
52use std::collections::HashMap;
53
54use crate::did::AccountDid;
55use crate::document::DidDocument;
56
57/// The PLC directory `did:plc` resolves against.
58///
59/// Not configurable here: a mirror would be selected by the caller supplying
60/// a different [`DidDocumentSource`], which is where the transport already
61/// lives.
62pub const DEFAULT_PLC_DIRECTORY: &str = "https://plc.directory";
63
64/// Length of the identifier in a `did:plc`.
65const PLC_IDENTIFIER_LENGTH: usize = 24;
66
67/// Why resolution failed.
68#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
69pub enum ResolveError {
70    /// No document at the URL.
71    #[error("no did document at {url}")]
72    NotFound {
73        /// The URL that was requested.
74        url: String,
75    },
76    /// The transport could not complete the request.
77    #[error("transport error fetching {url}: {message}")]
78    Transport {
79        /// The URL that was requested.
80        url: String,
81        /// What the transport reported.
82        message: String,
83    },
84    /// The body was not a DID document.
85    #[error("malformed did document at {url}: {message}")]
86    Malformed {
87        /// The URL that was requested.
88        url: String,
89        /// What the deserializer reported.
90        message: String,
91    },
92    /// The document's `id` is not the DID that was asked for.
93    ///
94    /// The spec is blunt about this: "The DID declared in the document (in
95    /// the `id` field) should always be verified against what was expected."
96    /// Skipping it means accepting a document that claims to be a different
97    /// account, which for `did:web` is whatever the host chose to serve.
98    #[error("did document at {url} claims {found:?}, expected {expected:?}")]
99    IdMismatch {
100        /// The URL that was requested.
101        url: String,
102        /// The DID that was asked for.
103        expected: String,
104        /// The DID the document claimed.
105        found: String,
106    },
107    /// The DID is not a legal one of its own method — a malformed `did:web`
108    /// or a `did:plc` whose identifier is not base32-sortable. Caught before
109    /// any fetch: an unvalidated identifier joined into a URL is how a
110    /// resolver ends up requesting something other than a DID document.
111    #[error("{did:?} is not a well-formed {method}: {message}")]
112    InvalidDid {
113        /// The DID that failed to parse.
114        did: String,
115        /// The method it claimed, e.g. `"did:web"`.
116        method: String,
117        /// What was wrong with it.
118        message: String,
119    },
120    /// The DID's method is not one this resolver speaks.
121    ///
122    /// Named explicitly rather than folded into [`Self::NotFound`]: a method
123    /// this resolver has no path for and a document that genuinely does not
124    /// exist are different facts, and a caller deciding whether to keep
125    /// polling needs to tell them apart.
126    #[error("unsupported did method {method:?} in {did:?}")]
127    UnsupportedMethod {
128        /// The full DID that was requested.
129        did: String,
130        /// The method it named.
131        method: String,
132    },
133}
134
135/// Where DID document bodies come from.
136///
137/// Implementors fetch `url` and return the response body. `url` is always
138/// produced by this module's own method dispatch — [`AccountDid::did_json_url`]
139/// for `did:web`, a join onto [`DEFAULT_PLC_DIRECTORY`] for `did:plc` — so it
140/// has already been decoded, scheme-selected and checked for path injection;
141/// an implementation should request it as given and must not append
142/// anything to it.
143pub trait DidDocumentSource {
144    /// Fetches the body at `url`, or reports why it could not.
145    fn fetch(&self, url: &str) -> Result<String, ResolveError>;
146}
147
148/// Resolves `did` through `source` and verifies the result belongs to it.
149///
150/// `did` is dispatched by method: `did:web` and `did:plc` each get the URL
151/// their method defines, and any other method is refused with
152/// [`ResolveError::UnsupportedMethod`] before `source` is ever asked to
153/// fetch anything. The `id` check is here rather than in the source so that
154/// no transport can be written that forgets it.
155pub fn resolve<S>(source: &S, did: &str) -> Result<DidDocument, ResolveError>
156where
157    S: DidDocumentSource + ?Sized,
158{
159    let url = document_url(did)?;
160    let body = source.fetch(&url)?;
161    let document: DidDocument =
162        serde_json::from_str(&body).map_err(|err| ResolveError::Malformed {
163            url: url.clone(),
164            message: err.to_string(),
165        })?;
166    if document.id != did {
167        return Err(ResolveError::IdMismatch {
168            url,
169            expected: did.to_owned(),
170            found: document.id,
171        });
172    }
173    Ok(document)
174}
175
176/// The URL `did`'s document lives at, or why `did` cannot be resolved at
177/// all.
178///
179/// Public because [`resolve`] is not the only shape a caller needs. A
180/// caller holding an async HTTP client — `didbot-serve`'s operator poll —
181/// cannot implement the synchronous [`DidDocumentSource`] over it, and
182/// still needs the same method dispatch this module already performs: the
183/// `did:web` rules in [`AccountDid`] for one method, the directory join for
184/// the other, and [`ResolveError::UnsupportedMethod`] for anything else.
185/// That caller fetches the body itself and makes [`resolve`]'s `id` check on
186/// what comes back.
187pub fn document_url(did: &str) -> Result<String, ResolveError> {
188    if did.starts_with("did:web:") {
189        let parsed = AccountDid::parse(did).map_err(|err| ResolveError::InvalidDid {
190            did: did.to_owned(),
191            method: "did:web".to_owned(),
192            message: err.to_string(),
193        })?;
194        return Ok(parsed.did_json_url());
195    }
196    if let Some(identifier) = did.strip_prefix("did:plc:") {
197        if !is_plc_identifier(identifier) {
198            return Err(ResolveError::InvalidDid {
199                did: did.to_owned(),
200                method: "did:plc".to_owned(),
201                message: format!(
202                    "identifier must be {PLC_IDENTIFIER_LENGTH} lowercase base32 characters"
203                ),
204            });
205        }
206        return Ok(format!("{DEFAULT_PLC_DIRECTORY}/{did}"));
207    }
208    let method = did
209        .strip_prefix("did:")
210        .and_then(|rest| rest.split_once(':'))
211        .map(|(method, _)| method)
212        .unwrap_or(did);
213    Err(ResolveError::UnsupportedMethod {
214        did: did.to_owned(),
215        method: method.to_owned(),
216    })
217}
218
219/// Whether `identifier` is the base32-sortable body of a `did:plc`.
220///
221/// The specification fixes both the length and the alphabet, and this runs
222/// before the identifier is joined into a directory URL: an unvalidated
223/// identifier in a path join is how a resolver ends up requesting something
224/// other than a DID document.
225fn is_plc_identifier(identifier: &str) -> bool {
226    identifier.len() == PLC_IDENTIFIER_LENGTH
227        && identifier
228            .bytes()
229            .all(|b| b.is_ascii_lowercase() || (b'2'..=b'7').contains(&b))
230}
231
232/// A [`DidDocumentSource`] backed by a map from URL to response body.
233///
234/// The test double, and also a usable cache or fixture loader. Keys are the
235/// full URLs [`AccountDid::did_json_url`] produces, so a test that gets the
236/// scheme or the port wrong sees a [`ResolveError::NotFound`] rather than
237/// silently passing.
238#[derive(Debug, Clone, Default)]
239pub struct InMemoryDocuments {
240    bodies: HashMap<String, String>,
241}
242
243impl InMemoryDocuments {
244    /// An empty source.
245    pub fn new() -> Self {
246        Self::default()
247    }
248
249    /// Stores a raw body at `url`. Use this to test malformed responses.
250    pub fn insert_body(&mut self, url: &str, body: &str) -> &mut Self {
251        self.bodies.insert(url.to_owned(), body.to_owned());
252        self
253    }
254
255    /// Serializes `document` and serves it at `did`'s document URL.
256    ///
257    /// Returns the serialization error rather than panicking, so callers keep
258    /// their no-`unwrap` discipline.
259    pub fn insert(
260        &mut self,
261        did: &AccountDid,
262        document: &DidDocument,
263    ) -> Result<&mut Self, serde_json::Error> {
264        let body = serde_json::to_string(document)?;
265        self.bodies.insert(did.did_json_url(), body);
266        Ok(self)
267    }
268}
269
270impl DidDocumentSource for InMemoryDocuments {
271    fn fetch(&self, url: &str) -> Result<String, ResolveError> {
272        self.bodies
273            .get(url)
274            .cloned()
275            .ok_or_else(|| ResolveError::NotFound {
276                url: url.to_owned(),
277            })
278    }
279}
280
281#[cfg(test)]
282mod tests {
283    use super::*;
284
285    const PLC_DID: &str = "did:plc:nlzmjyfv6loqtxyzvdcznwgf";
286
287    /// A transport that always fails as if the network itself were down,
288    /// never as "no document here" — the distinction a caller polling for
289    /// a claim needs to make.
290    struct AlwaysDown;
291
292    impl DidDocumentSource for AlwaysDown {
293        fn fetch(&self, url: &str) -> Result<String, ResolveError> {
294            Err(ResolveError::Transport {
295                url: url.to_owned(),
296                message: "connection refused".to_owned(),
297            })
298        }
299    }
300
301    #[test]
302    fn a_plc_identifier_is_fixed_length_base32() {
303        assert!(is_plc_identifier("nlzmjyfv6loqtxyzvdcznwgf"));
304        // Too short, uppercase, a digit outside the alphabet, and the one
305        // that matters: anything that would climb out of the directory path.
306        assert!(!is_plc_identifier("nlzmjyfv6loqtxyzvdcznwg"));
307        assert!(!is_plc_identifier("NLZMJYFV6LOQTXYZVDCZNWGF"));
308        assert!(!is_plc_identifier("nlzmjyfv8loqtxyzvdcznwgf"));
309        assert!(!is_plc_identifier("../../../etc/passwd0000000"));
310    }
311
312    #[test]
313    fn a_did_plc_resolves_through_the_directory_url() {
314        let document = DidDocument::for_service(PLC_DID, "https://pds.example.com");
315        let mut source = InMemoryDocuments::new();
316        source.insert_body(
317            &format!("{DEFAULT_PLC_DIRECTORY}/{PLC_DID}"),
318            &serde_json::to_string(&document).expect("serializes"),
319        );
320
321        let resolved = resolve(&source, PLC_DID).expect("resolution succeeds");
322        assert_eq!(resolved, document);
323    }
324
325    #[test]
326    fn a_malformed_plc_identifier_is_rejected_before_any_fetch() {
327        // `AlwaysDown` never answers a fetch: if this got past validation and
328        // tried to fetch anyway, the test would see a `Transport` error, not
329        // `InvalidDid`.
330        match resolve(&AlwaysDown, "did:plc:not-base32") {
331            Err(ResolveError::InvalidDid { method, .. }) => assert_eq!(method, "did:plc"),
332            other => panic!("expected InvalidDid, got {other:?}"),
333        }
334    }
335
336    #[test]
337    fn an_unsupported_method_names_itself_rather_than_answering_not_found() {
338        let source = InMemoryDocuments::new();
339        match resolve(
340            &source,
341            "did:key:z6MkhaXgBZDvotDkL5257faiztiGiC2QtKLGpbnnEGta2doK",
342        ) {
343            Err(ResolveError::UnsupportedMethod { method, .. }) => assert_eq!(method, "key"),
344            other => panic!("expected UnsupportedMethod, got {other:?}"),
345        }
346    }
347
348    #[test]
349    fn a_directory_outage_is_distinguishable_from_a_missing_did() {
350        // Down: the transport itself failed.
351        assert!(matches!(
352            resolve(&AlwaysDown, PLC_DID),
353            Err(ResolveError::Transport { .. })
354        ));
355        // Reachable, but nothing there: a different fact.
356        let source = InMemoryDocuments::new();
357        assert!(matches!(
358            resolve(&source, PLC_DID),
359            Err(ResolveError::NotFound { .. })
360        ));
361    }
362}