cairn_mod/auth/did.rs
1//! DID resolution (§5.4).
2//!
3//! Resolves `did:plc` via the PLC directory and `did:web` via HTTPS
4//! `.well-known/did.json` or the path-component variant. Resolution is
5//! fronted by a [`DidResolver`] trait so auth tests can inject canned
6//! documents without mocking TCP/TLS.
7//!
8//! **did:web path-component rule (§5.4):**
9//! - `did:web:example.com` → `https://example.com/.well-known/did.json`
10//! - `did:web:example.com:a:b:c` → `https://example.com/a/b/c/did.json` (NO `.well-known`)
11//!
12//! The difference is load-bearing for interop; a unit test pins exactly
13//! this distinction.
14
15use async_trait::async_trait;
16use serde::Deserialize;
17
18use super::ssrf::SafeDnsResolver;
19
20/// Categorical errors surfaced by the resolver. The auth layer maps all
21/// of these to a single external `AuthenticationRequired` response per
22/// §4 non-enumeration; variants exist here for internal logging only.
23#[derive(Debug, thiserror::Error)]
24pub enum ResolveError {
25 /// DID method not supported (only `did:plc` and `did:web` are
26 /// handled in v1).
27 #[error("unsupported DID method: {0}")]
28 UnsupportedMethod(String),
29
30 /// DID string is not a valid DID.
31 #[error("malformed DID: {0}")]
32 Malformed(String),
33
34 /// Transport-level failure reaching the resolver endpoint (DNS,
35 /// TLS, timeout, connection refused).
36 #[error("network error: {0}")]
37 Network(String),
38
39 /// Resolver endpoint returned a non-2xx status.
40 #[error("non-success HTTP status: {0}")]
41 BadStatus(u16),
42
43 /// Response body was not a parseable DID document.
44 #[error("body parse error: {0}")]
45 Parse(String),
46
47 /// SSRF filter refused to resolve the host (loopback, private
48 /// range, or other disallowed target).
49 #[error("SSRF protection rejected host: {0}")]
50 SsrfBlocked(String),
51}
52
53/// Minimal DID document shape. We deliberately deserialize only the
54/// fields Cairn uses (`id`, `verificationMethod`) — ignoring
55/// `authentication`, `assertionMethod`, `service`, etc. — so random
56/// extra fields and future additions don't break parsing.
57#[derive(Debug, Clone, Deserialize)]
58pub struct DidDocument {
59 /// DID (matches the resolved subject).
60 pub id: String,
61 /// All declared verification methods. Cairn's code selects one
62 /// by fragment via [`DidDocument::find_verification_method`].
63 #[serde(rename = "verificationMethod", default)]
64 pub verification_method: Vec<VerificationMethod>,
65}
66
67/// One entry in a DID document's `verificationMethod` array.
68#[derive(Debug, Clone, Deserialize)]
69pub struct VerificationMethod {
70 /// Full verification-method identifier, e.g.
71 /// `did:plc:3jzfcijpj2z2a4pdagfkktq6#atproto`.
72 pub id: String,
73 /// Lower-case `type` field from the DID doc. Cairn only consumes
74 /// `Multikey` in v1 but we parse the string to classify.
75 #[serde(rename = "type")]
76 pub r#type: String,
77 /// Multibase-encoded public key (typically z-prefixed base58btc).
78 #[serde(rename = "publicKeyMultibase")]
79 pub public_key_multibase: String,
80}
81
82impl DidDocument {
83 /// Find a verification method whose `id` ends with `fragment`
84 /// (e.g. `"#atproto"` for the moderator repo key, `"#atproto_label"`
85 /// for the labeler signing key).
86 pub fn find_verification_method(&self, fragment: &str) -> Option<&VerificationMethod> {
87 self.verification_method
88 .iter()
89 .find(|vm| vm.id.ends_with(fragment))
90 }
91}
92
93/// Pluggable resolver. Production uses [`HttpDidResolver`]; tests inject
94/// a canned-document resolver to avoid hitting wiremock for every JWT
95/// shape under test.
96#[async_trait]
97pub trait DidResolver: Send + Sync {
98 /// Resolve `did` to a DID document, or return the categorical
99 /// failure. Implementors must fail closed — never return a
100 /// partial / default document on error.
101 async fn resolve(&self, did: &str) -> Result<DidDocument, ResolveError>;
102}
103
104/// Production HTTP resolver. Uses reqwest with rustls, with a custom
105/// DNS resolver ([`SafeDnsResolver`]) that filters outbound targets to
106/// block SSRF.
107#[derive(Debug, Clone)]
108pub struct HttpDidResolver {
109 client: reqwest::Client,
110 plc_directory_url: String,
111}
112
113impl HttpDidResolver {
114 /// Construct a production resolver with the §F11 SSRF-filtering
115 /// DNS resolver wired in. `timeout` applies to both connect and
116 /// overall request.
117 pub fn new(plc_directory_url: String, timeout: std::time::Duration) -> Self {
118 Self::with_dns_resolver(plc_directory_url, timeout, SafeDnsResolver::arc())
119 }
120
121 /// Production alternative that lets callers plug in a DNS resolver.
122 /// The default [`SafeDnsResolver`] blocks loopback and private ranges
123 /// — correct for prod, but integration tests pointing at wiremock on
124 /// 127.0.0.1 need a pass-through resolver. Tests should live behind
125 /// `#[cfg(test)]` or a deliberate test helper; this constructor is
126 /// public only because it has to cross the test/lib boundary.
127 pub fn with_dns_resolver<R: reqwest::dns::Resolve + 'static>(
128 plc_directory_url: String,
129 timeout: std::time::Duration,
130 dns: std::sync::Arc<R>,
131 ) -> Self {
132 let client = reqwest::Client::builder()
133 .timeout(timeout)
134 .connect_timeout(timeout)
135 .dns_resolver(dns)
136 // TLS defaults to rustls per the "rustls-tls" feature we
137 // enabled in Cargo.toml — no certificate validation bypass.
138 .redirect(reqwest::redirect::Policy::limited(3))
139 .build()
140 .expect("reqwest client build");
141 Self {
142 client,
143 plc_directory_url,
144 }
145 }
146
147 /// Build the URL for a `did:web` resolution per §5.4. Public for
148 /// unit testing — the path-component rule is easy to get wrong and
149 /// the distinction is a wire-interop guarantee.
150 pub fn did_web_url(did: &str) -> Result<url::Url, ResolveError> {
151 let rest = did
152 .strip_prefix("did:web:")
153 .ok_or_else(|| ResolveError::Malformed("not a did:web".into()))?;
154 if rest.is_empty() {
155 return Err(ResolveError::Malformed("empty did:web body".into()));
156 }
157
158 // Host is everything up to the first `:` (if any). Per-spec, the
159 // colon separates host from path components; percent-encoded
160 // ports (`%3A`) stay within the host segment.
161 let mut parts = rest.splitn(2, ':');
162 let host_encoded = parts.next().unwrap_or("");
163 let path_segment = parts.next();
164
165 // Host may be percent-encoded (e.g. `example.com%3A8443`). Let
166 // `url::Url::parse` do the decoding when we build the full URL.
167 let url_str = match path_segment {
168 None => format!("https://{host_encoded}/.well-known/did.json"),
169 Some(rest) => {
170 // Remaining components are colon-separated path segments
171 // that become slash-separated URL path.
172 let path = rest.replace(':', "/");
173 format!("https://{host_encoded}/{path}/did.json")
174 }
175 };
176
177 url::Url::parse(&url_str).map_err(|e| ResolveError::Malformed(format!("bad url: {e}")))
178 }
179
180 async fn resolve_plc(&self, did: &str) -> Result<DidDocument, ResolveError> {
181 let url = format!("{}/{}", self.plc_directory_url.trim_end_matches('/'), did);
182 self.fetch_did_doc(&url).await
183 }
184
185 async fn resolve_web(&self, did: &str) -> Result<DidDocument, ResolveError> {
186 let url = Self::did_web_url(did)?;
187 self.fetch_did_doc(url.as_str()).await
188 }
189
190 async fn fetch_did_doc(&self, url: &str) -> Result<DidDocument, ResolveError> {
191 let resp = self.client.get(url).send().await.map_err(|e| {
192 // reqwest wraps SSRF rejections as client errors. We can't
193 // cleanly distinguish them at runtime without downcast, so
194 // we surface as Network — the effect (fail-closed) is the
195 // same and logs capture the full chain.
196 if e.to_string().contains("SSRF") {
197 ResolveError::SsrfBlocked(url.to_string())
198 } else {
199 ResolveError::Network(e.to_string())
200 }
201 })?;
202 if !resp.status().is_success() {
203 return Err(ResolveError::BadStatus(resp.status().as_u16()));
204 }
205 resp.json::<DidDocument>()
206 .await
207 .map_err(|e| ResolveError::Parse(e.to_string()))
208 }
209}
210
211#[async_trait]
212impl DidResolver for HttpDidResolver {
213 async fn resolve(&self, did: &str) -> Result<DidDocument, ResolveError> {
214 if did.starts_with("did:plc:") {
215 self.resolve_plc(did).await
216 } else if did.starts_with("did:web:") {
217 self.resolve_web(did).await
218 } else {
219 let method = did
220 .strip_prefix("did:")
221 .and_then(|s| s.split(':').next())
222 .unwrap_or(did);
223 Err(ResolveError::UnsupportedMethod(method.to_string()))
224 }
225 }
226}
227
228#[cfg(test)]
229mod tests {
230 use super::*;
231
232 #[test]
233 fn did_web_no_path_component_uses_well_known() {
234 let url = HttpDidResolver::did_web_url("did:web:example.com").expect("ok");
235 assert_eq!(url.as_str(), "https://example.com/.well-known/did.json");
236 }
237
238 #[test]
239 fn did_web_path_components_drops_well_known() {
240 // §5.4: colons after host become slashes; NO `.well-known` in path form.
241 let url = HttpDidResolver::did_web_url("did:web:example.com:users:alice").expect("ok");
242 assert_eq!(url.as_str(), "https://example.com/users/alice/did.json");
243 }
244
245 #[test]
246 fn did_web_single_path_component() {
247 let url = HttpDidResolver::did_web_url("did:web:example.com:user").expect("ok");
248 assert_eq!(url.as_str(), "https://example.com/user/did.json");
249 }
250
251 #[test]
252 fn did_web_malformed_without_method_prefix() {
253 let err = HttpDidResolver::did_web_url("did:plc:xyz").expect_err("must fail");
254 assert!(matches!(err, ResolveError::Malformed(_)));
255 }
256
257 #[test]
258 fn did_doc_finds_method_by_fragment() {
259 let doc = DidDocument {
260 id: "did:plc:abc".into(),
261 verification_method: vec![
262 VerificationMethod {
263 id: "did:plc:abc#atproto_label".into(),
264 r#type: "Multikey".into(),
265 public_key_multibase: "zOne".into(),
266 },
267 VerificationMethod {
268 id: "did:plc:abc#atproto".into(),
269 r#type: "Multikey".into(),
270 public_key_multibase: "zTwo".into(),
271 },
272 ],
273 };
274 let vm = doc.find_verification_method("#atproto").expect("found");
275 // ends_with("#atproto") matches the SHORT `#atproto` id, not
276 // `#atproto_label`. Important distinction — moderator JWTs use
277 // `#atproto` (repo key), labeler signing uses `#atproto_label`.
278 assert_eq!(vm.public_key_multibase, "zTwo");
279 }
280
281 #[test]
282 fn did_doc_missing_method_returns_none() {
283 let doc = DidDocument {
284 id: "did:plc:abc".into(),
285 verification_method: vec![],
286 };
287 assert!(doc.find_verification_method("#atproto").is_none());
288 }
289}