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}