acdp_did/web.rs
1//! `did:web` resolver — RFC-ACDP-0001 §5.11, step 3.
2
3use acdp_primitives::error::AcdpError;
4
5#[cfg(feature = "client")]
6use {
7 super::document::DidDocument,
8 acdp_primitives::limits::{
9 CONNECT_TIMEOUT, MAX_METADATA_BYTES, MAX_REDIRECTS, REQUEST_TIMEOUT,
10 },
11 acdp_safe_http::SsrfPolicy,
12 lru::LruCache,
13 reqwest::redirect,
14 std::num::NonZeroUsize,
15 std::sync::{Arc, Mutex},
16 std::time::{Duration, Instant},
17};
18
19#[cfg(feature = "client")]
20const CACHE_MAX: Duration = Duration::from_secs(24 * 3600); // 24 hours
21#[cfg(feature = "client")]
22const DEFAULT_CACHE_CAPACITY: usize = 1000;
23
24#[cfg(feature = "client")]
25struct CacheEntry {
26 doc: DidDocument,
27 cached_at: Instant,
28}
29
30/// Resolves `did:web:…` DIDs to DID documents via HTTPS.
31///
32/// Caches resolved documents for 5–24 hours per §5.11 guidance, evicting
33/// the least-recently-used entry once the cache reaches the configured
34/// capacity (default 1000).
35///
36/// Every resolution URL passes the [`SsrfPolicy`] gate before any socket
37/// activity: a producer-controlled `did:web` authority is an SSRF vector
38/// identical to a cross-registry reference (RFC-ACDP-0008 §4.8). The
39/// default policy refuses IP-literal authorities and non-HTTPS schemes,
40/// so `did:web:127.0.0.1` / `did:web:169.254.169.254` cannot turn a
41/// registry verifying a publish — or a consumer verifying a retrieved
42/// context — into an SSRF proxy against process-internal listeners.
43#[cfg(feature = "client")]
44pub struct WebResolver {
45 http: reqwest::Client,
46 cache: Arc<Mutex<LruCache<String, CacheEntry>>>,
47 ssrf_policy: SsrfPolicy,
48 // Stored verbatim so [`Self::with_ssrf_policy`] can rebuild the
49 // HTTP client (the DNS resolver is wired in at builder time, so a
50 // policy swap requires rebuilding).
51 root_cert_pem: Option<Vec<u8>>,
52}
53
54#[cfg(feature = "client")]
55impl WebResolver {
56 /// Build a resolver with the default LRU capacity (1000 entries).
57 ///
58 /// # Panics
59 ///
60 /// Panics if the underlying HTTP client cannot be built (e.g. the
61 /// TLS backend fails to initialize). Use [`Self::try_new`] to
62 /// handle that failure as a `Result` instead.
63 pub fn new() -> Self {
64 Self::try_new().expect("failed to build HTTP client for DID resolver")
65 }
66
67 /// Fallible variant of [`Self::new`]: builds a resolver with the
68 /// default LRU capacity (1000 entries), returning an error instead
69 /// of panicking if the underlying HTTP client cannot be built.
70 pub fn try_new() -> Result<Self, AcdpError> {
71 Self::try_with_capacity(DEFAULT_CACHE_CAPACITY)
72 }
73
74 /// Build a resolver with a custom LRU capacity.
75 ///
76 /// # Panics
77 ///
78 /// Panics if `capacity == 0` — use a positive capacity; the LRU
79 /// model has no semantically valid empty configuration — or if the
80 /// underlying HTTP client cannot be built. Use
81 /// [`Self::try_with_capacity`] to handle the HTTP-client failure
82 /// as a `Result` instead.
83 pub fn with_capacity(capacity: usize) -> Self {
84 Self::try_with_capacity(capacity).expect("failed to build HTTP client for DID resolver")
85 }
86
87 /// Fallible variant of [`Self::with_capacity`]: returns an error
88 /// instead of panicking if the underlying HTTP client cannot be
89 /// built.
90 ///
91 /// # Panics
92 ///
93 /// Still panics if `capacity == 0`; that is a programmer error, not
94 /// a runtime condition.
95 pub fn try_with_capacity(capacity: usize) -> Result<Self, AcdpError> {
96 Self::from_parts(capacity, SsrfPolicy::default(), None)
97 }
98
99 /// Build a resolver that trusts the given PEM-encoded root certificate
100 /// in addition to the system roots.
101 ///
102 /// Primary use is the in-process self-signed HTTPS server in the
103 /// crate's `tests/helpers/tls_did_server.rs` harness, so the spec
104 /// fixtures `pub-001` / `pub-006` / `fed-001..006` can drive the
105 /// resolver end-to-end without going over the network. Production
106 /// callers on corporate intranets MAY also use this to trust a
107 /// private CA.
108 pub fn with_root_cert_pem(pem: &[u8]) -> Result<Self, AcdpError> {
109 Self::from_parts(
110 DEFAULT_CACHE_CAPACITY,
111 SsrfPolicy::default(),
112 Some(pem.to_vec()),
113 )
114 }
115
116 /// Build a resolver with a custom LRU capacity AND a custom root cert.
117 pub fn with_capacity_and_root_cert_pem(capacity: usize, pem: &[u8]) -> Result<Self, AcdpError> {
118 Self::from_parts(capacity, SsrfPolicy::default(), Some(pem.to_vec()))
119 }
120
121 /// Resolver pinned to a fixed socket address for a logical hostname,
122 /// trusting the given root certificate — the resolver-side analogue
123 /// of [`crate::client::RegistryClient::with_test_endpoint`]. Lets a
124 /// test resolve `did:web:localhost` (and path DIDs under it) against
125 /// an in-process TLS server bound to `127.0.0.1:<port>` without the
126 /// port appearing in the DID. Uses the loopback-permitting SSRF
127 /// policy. Use only in tests.
128 #[doc(hidden)]
129 #[cfg(feature = "test-transport")]
130 pub fn with_test_endpoint(
131 pem: &[u8],
132 host: &str,
133 target: std::net::SocketAddr,
134 ) -> Result<Self, AcdpError> {
135 let cap = NonZeroUsize::new(DEFAULT_CACHE_CAPACITY).expect("capacity > 0");
136 let policy = SsrfPolicy::allow_test_loopback();
137 let http = build_http_client_pinned(Some(pem), &policy, Some((host, target)))?;
138 Ok(Self {
139 http,
140 cache: Arc::new(Mutex::new(LruCache::new(cap))),
141 ssrf_policy: policy,
142 root_cert_pem: Some(pem.to_vec()),
143 })
144 }
145
146 fn from_parts(
147 capacity: usize,
148 ssrf_policy: SsrfPolicy,
149 root_cert_pem: Option<Vec<u8>>,
150 ) -> Result<Self, AcdpError> {
151 let cap = NonZeroUsize::new(capacity).expect("WebResolver capacity must be > 0");
152 let http = build_http_client(root_cert_pem.as_deref(), &ssrf_policy)?;
153 Ok(Self {
154 http,
155 cache: Arc::new(Mutex::new(LruCache::new(cap))),
156 ssrf_policy,
157 root_cert_pem,
158 })
159 }
160
161 /// Override the [`SsrfPolicy`] applied to `did:web` resolution.
162 ///
163 /// The policy gates both the URL stage (refusing IP-literal
164 /// authorities and non-HTTPS schemes — fixtures did-ssrf-001/002/003)
165 /// **and** the DNS resolution stage (filtering hostnames that resolve
166 /// into forbidden ranges — RFC-ACDP-0008 §4.8 DNS-rebinding
167 /// protection). Calling this rebuilds the underlying HTTP client so
168 /// the DNS resolver hook reflects the new policy.
169 ///
170 /// Relax the policy **only** in a test harness that resolves
171 /// `did:web:localhost…` against an in-process loopback server.
172 /// Production callers MUST keep the default.
173 ///
174 /// # Panics
175 ///
176 /// Panics if the underlying HTTP client cannot be rebuilt. Use
177 /// [`Self::try_with_ssrf_policy`] to handle that failure as a
178 /// `Result` instead.
179 pub fn with_ssrf_policy(self, policy: SsrfPolicy) -> Self {
180 self.try_with_ssrf_policy(policy)
181 .expect("rebuild HTTP client for DID resolver")
182 }
183
184 /// Fallible variant of [`Self::with_ssrf_policy`]: returns an error
185 /// instead of panicking if the underlying HTTP client cannot be
186 /// rebuilt.
187 pub fn try_with_ssrf_policy(mut self, policy: SsrfPolicy) -> Result<Self, AcdpError> {
188 // Rebuild the HTTP client so the DNS resolver hook carries the
189 // new policy. `build_http_client` is fallible only on bad cert
190 // PEM input; we already validated it at construction time.
191 let http = build_http_client(self.root_cert_pem.as_deref(), &policy)?;
192 self.http = http;
193 self.ssrf_policy = policy;
194 Ok(self)
195 }
196
197 /// Resolve a `did:web:…` DID to a DID document.
198 ///
199 /// Hits the cache on repeated calls for the same DID. Refreshes
200 /// on any downstream verification failure if needed.
201 #[cfg_attr(feature = "tracing", tracing::instrument(skip(self), fields(did = did)))]
202 pub async fn resolve(&self, did: &str) -> Result<DidDocument, AcdpError> {
203 // Check cache (mutates LRU recency on hit)
204 {
205 let mut cache = self.cache.lock().unwrap();
206 if let Some(entry) = cache.get(did) {
207 if entry.cached_at.elapsed() < CACHE_MAX {
208 return Ok(entry.doc.clone());
209 }
210 }
211 }
212
213 let url = did_web_to_url(did)?;
214
215 // RFC-ACDP-0008 §4.8: a producer-controlled did:web authority is
216 // an SSRF vector. Refuse loopback / link-local / IMDS / private-
217 // range targets before issuing any request. The refusal is
218 // policy-driven and producer-caused, so it maps to
219 // `key_resolution_failed` (HTTP 400, permanent) — NOT
220 // `key_resolution_unreachable` (HTTP 502, retryable). See
221 // fixtures did-ssrf-001 / did-ssrf-002 / did-ssrf-003.
222 self.ssrf_policy.check_url(&url).map_err(|e| {
223 AcdpError::KeyResolution(format!("SSRF policy blocked did:web resolution: {e}"))
224 })?;
225
226 let mut resp = self
227 .http
228 .get(&url)
229 .header("Accept", "application/did+json, application/json")
230 .send()
231 .await
232 .map_err(|e| classify_reqwest_error(&e))?;
233
234 if !resp.status().is_success() {
235 return Err(AcdpError::KeyResolution(format!(
236 "DID document fetch returned HTTP {}",
237 resp.status()
238 )));
239 }
240
241 // Cap body size at 64 KB per RFC-ACDP-0006 §7.3.
242 if let Some(len) = resp.content_length() {
243 if len as usize > MAX_METADATA_BYTES {
244 return Err(AcdpError::KeyResolution(format!(
245 "DID document Content-Length {len} exceeds {MAX_METADATA_BYTES}-byte cap"
246 )));
247 }
248 }
249 let mut buf: Vec<u8> = Vec::with_capacity(8 * 1024);
250 while let Some(chunk) = resp
251 .chunk()
252 .await
253 .map_err(|e| AcdpError::KeyResolutionUnreachable(e.to_string()))?
254 {
255 if buf.len() + chunk.len() > MAX_METADATA_BYTES {
256 return Err(AcdpError::KeyResolution(format!(
257 "DID document body exceeded {MAX_METADATA_BYTES}-byte cap"
258 )));
259 }
260 buf.extend_from_slice(&chunk);
261 }
262 let doc: DidDocument = serde_json::from_slice(&buf)
263 .map_err(|e| AcdpError::KeyResolution(format!("DID document parse: {e}")))?;
264
265 // Store in cache (evicts LRU on overflow)
266 {
267 let mut cache = self.cache.lock().unwrap();
268 cache.put(
269 did.to_string(),
270 CacheEntry {
271 doc: doc.clone(),
272 cached_at: Instant::now(),
273 },
274 );
275 }
276
277 Ok(doc)
278 }
279
280 /// Invalidate a specific DID's cache entry, forcing a fresh fetch.
281 pub fn invalidate(&self, did: &str) {
282 self.cache.lock().unwrap().pop(did);
283 }
284}
285
286#[cfg(feature = "client")]
287impl Default for WebResolver {
288 fn default() -> Self {
289 Self::new()
290 }
291}
292
293/// Build the `reqwest::Client` used by `WebResolver`, optionally trusting
294/// an additional PEM-encoded root certificate.
295///
296/// Encapsulates the redirect policy, timeouts, and the DNS-rebinding
297/// filter so the no-cert and with-cert constructors stay byte-for-byte
298/// identical on TLS posture.
299///
300/// `ssrf_policy` is plumbed into reqwest's `dns_resolver` hook via
301/// [`acdp_safe_http::SafeDnsResolver`]: every resolved IP is filtered
302/// against the policy before reqwest connects, so a hostname whose DNS
303/// answers fall in forbidden ranges (loopback, RFC 1918, link-local,
304/// IMDS, ULA, …) is refused at connect time, defeating DNS rebinding
305/// (RFC-ACDP-0008 §4.8).
306#[cfg(feature = "client")]
307fn build_http_client(
308 extra_root_pem: Option<&[u8]>,
309 ssrf_policy: &SsrfPolicy,
310) -> Result<reqwest::Client, AcdpError> {
311 build_http_client_pinned(extra_root_pem, ssrf_policy, None)
312}
313
314/// Like [`build_http_client`] but optionally pins `host` to a fixed
315/// socket address via reqwest's `.resolve()` hook (test endpoints).
316#[cfg(feature = "client")]
317fn build_http_client_pinned(
318 extra_root_pem: Option<&[u8]>,
319 ssrf_policy: &SsrfPolicy,
320 pin: Option<(&str, std::net::SocketAddr)>,
321) -> Result<reqwest::Client, AcdpError> {
322 let policy = redirect::Policy::custom(|attempt| {
323 if attempt.previous().len() >= MAX_REDIRECTS {
324 return attempt.error(format!("DID resolver: exceeded {MAX_REDIRECTS} redirects"));
325 }
326 // Same-authority enforcement (scheme + host + port) against the
327 // original request URL. RFC-ACDP-0008 §4.8.
328 let cross = attempt
329 .previous()
330 .first()
331 .filter(|orig| !acdp_safe_http::same_fetch_authority(orig, attempt.url()))
332 .map(|orig| (orig.to_string(), attempt.url().to_string()));
333 if let Some((from, to)) = cross {
334 return attempt.error(format!(
335 "DID resolver: cross-authority redirect rejected ({from} -> {to})"
336 ));
337 }
338 attempt.follow()
339 });
340
341 let mut builder = reqwest::Client::builder()
342 .use_rustls_tls()
343 .connect_timeout(CONNECT_TIMEOUT)
344 .timeout(REQUEST_TIMEOUT)
345 .redirect(policy)
346 .dns_resolver(acdp_safe_http::SafeDnsResolver::arc(ssrf_policy.clone()));
347
348 if let Some(pem) = extra_root_pem {
349 let cert = reqwest::Certificate::from_pem(pem)
350 .map_err(|e| AcdpError::Http(format!("invalid root cert PEM: {e}")))?;
351 builder = builder.add_root_certificate(cert);
352 }
353
354 if let Some((host, target)) = pin {
355 builder = builder.resolve(host, target);
356 }
357
358 builder
359 .build()
360 .map_err(|e| AcdpError::Http(format!("DID resolver client build: {e}")))
361}
362
363/// Translate a `reqwest::Error` into the right [`AcdpError`] variant.
364///
365/// Walks the error's `source()` chain so the `SafeDnsResolver`'s refusal
366/// message — which always contains the substring `"SSRF policy"` — survives
367/// reqwest's wrapping. An SSRF-refused DNS lookup is policy-driven and
368/// permanent — it maps to `key_resolution_failed` (HTTP 400), NOT
369/// `key_resolution_unreachable` (502, retryable) that
370/// `reqwest::Error::is_connect()` would suggest by default
371/// (RFC-ACDP-0008 §4.8, fixtures did-ssrf-001/002/003).
372#[cfg(feature = "client")]
373fn classify_reqwest_error(e: &reqwest::Error) -> AcdpError {
374 let mut chain = e.to_string();
375 let mut src: Option<&dyn std::error::Error> = std::error::Error::source(e);
376 while let Some(s) = src {
377 chain = format!("{chain}: {s}");
378 src = s.source();
379 }
380 if chain.contains("SSRF policy") {
381 return AcdpError::KeyResolution(chain);
382 }
383 if e.is_timeout() || e.is_connect() {
384 AcdpError::KeyResolutionUnreachable(chain)
385 } else {
386 AcdpError::KeyResolution(chain)
387 }
388}
389
390/// Convert a `did:web:…` DID to its HTTPS URL per the `did:web` spec.
391///
392/// `did:web:example.com` → `https://example.com/.well-known/did.json`
393/// `did:web:example.com:users:alice` → `https://example.com/users/alice/did.json`
394pub fn did_web_to_url(did: &str) -> Result<String, AcdpError> {
395 let rest = did
396 .strip_prefix("did:web:")
397 .ok_or_else(|| AcdpError::KeyResolution(format!("not a did:web DID: {did}")))?;
398
399 let parts: Vec<&str> = rest.split(':').collect();
400 let authority = urlencoding::decode(parts[0])
401 .map_err(|e| AcdpError::KeyResolution(format!("authority decode: {e}")))?;
402
403 if parts.len() == 1 {
404 Ok(format!("https://{}/.well-known/did.json", authority))
405 } else {
406 let path = parts[1..].join("/");
407 Ok(format!("https://{}/{}/did.json", authority, path))
408 }
409}
410
411/// Convert a registry authority (DNS hostname, optionally with a port)
412/// to its `did:web` form per the did:web method spec.
413///
414/// The `:` between host and port is a structural delimiter in did:web
415/// — it splits the DID into colon-separated path components — so a
416/// `host:port` authority must percent-encode the colon as `%3A` to
417/// keep the port in the authority segment.
418///
419/// Examples:
420/// - `"registry.example.com"` → `"did:web:registry.example.com"`
421/// - `"localhost:8443"` → `"did:web:localhost%3A8443"`
422pub fn authority_to_did_web(authority: &str) -> String {
423 let encoded = authority.replace(':', "%3A");
424 format!("did:web:{encoded}")
425}
426
427/// Reverse of [`authority_to_did_web`]: strip the `did:web:` prefix
428/// and decode `%3A` back to `:`. Returns `None` for non-`did:web` input.
429///
430/// Used in `RegistryServer::try_new` and `CrossRegistryResolver::resolve`
431/// to compare a capabilities-advertised DID against the authority the
432/// consumer connected to. Round-trips with `authority_to_did_web`.
433pub fn did_web_to_authority(did: &str) -> Option<String> {
434 let rest = did.strip_prefix("did:web:")?;
435 // Only the first segment carries the authority; further segments
436 // are path components and don't get colon-decoded.
437 let mut parts = rest.splitn(2, ':');
438 let authority = parts.next()?;
439 Some(authority.replace("%3A", ":"))
440}
441
442#[cfg(test)]
443mod tests {
444 use super::*;
445
446 #[test]
447 fn bare_authority() {
448 let url = did_web_to_url("did:web:example.com").unwrap();
449 assert_eq!(url, "https://example.com/.well-known/did.json");
450 }
451
452 #[test]
453 fn path_authority() {
454 let url = did_web_to_url("did:web:example.com:users:alice").unwrap();
455 assert_eq!(url, "https://example.com/users/alice/did.json");
456 }
457
458 /// BUG-06: bare DNS hostname maps to the plain did:web form.
459 #[test]
460 fn authority_to_did_web_bare_hostname() {
461 assert_eq!(
462 authority_to_did_web("registry.example.com"),
463 "did:web:registry.example.com"
464 );
465 }
466
467 /// BUG-06: `host:port` authority percent-encodes the colon.
468 #[test]
469 fn authority_to_did_web_with_port() {
470 assert_eq!(
471 authority_to_did_web("localhost:8443"),
472 "did:web:localhost%3A8443"
473 );
474 }
475
476 /// BUG-06: reverse helper strips prefix and decodes the colon.
477 #[test]
478 fn did_web_to_authority_round_trips() {
479 for authority in ["registry.example.com", "localhost:8443", "127.0.0.1:9000"] {
480 let did = authority_to_did_web(authority);
481 let back = did_web_to_authority(&did)
482 .unwrap_or_else(|| panic!("did_web_to_authority returned None for '{did}'"));
483 assert_eq!(back, authority, "round-trip for '{authority}' failed");
484 }
485 }
486
487 /// `did_web_to_url` already URL-decodes the authority — confirm
488 /// that `authority_to_did_web("localhost:8443")` produces a DID
489 /// that resolves to `https://localhost:8443/.well-known/did.json`.
490 #[test]
491 fn authority_to_did_web_then_to_url_keeps_port() {
492 let did = authority_to_did_web("localhost:8443");
493 let url = did_web_to_url(&did).unwrap();
494 assert_eq!(url, "https://localhost:8443/.well-known/did.json");
495 }
496
497 // ── BUG-04 — WebResolver SSRF policy (did-ssrf-001/002/003) ─────────
498
499 /// did-ssrf-001 — a `did:web` authority that is a loopback IP literal
500 /// is refused by the default resolver before any socket activity.
501 /// The error is `key_resolution_failed` (permanent), not
502 /// `key_resolution_unreachable` (retryable).
503 #[cfg(feature = "client")]
504 #[tokio::test]
505 async fn did_resolver_rejects_loopback_did() {
506 let resolver = WebResolver::new();
507 let err = resolver.resolve("did:web:127.0.0.1").await.unwrap_err();
508 assert!(
509 matches!(err, AcdpError::KeyResolution(_)),
510 "did-ssrf-001: loopback did:web MUST be blocked by SSRF policy, got {err:?}"
511 );
512 }
513
514 /// did-ssrf-002 — a `did:web` authority pointing at the cloud-metadata
515 /// endpoint (169.254.169.254) is refused.
516 #[cfg(feature = "client")]
517 #[tokio::test]
518 async fn did_resolver_rejects_imds_did() {
519 let resolver = WebResolver::new();
520 let err = resolver
521 .resolve("did:web:169.254.169.254")
522 .await
523 .unwrap_err();
524 assert!(
525 matches!(err, AcdpError::KeyResolution(_)),
526 "did-ssrf-002: IMDS did:web MUST be blocked by SSRF policy, got {err:?}"
527 );
528 }
529
530 /// did-ssrf-003 — a `did:web` authority in an RFC 1918 private range
531 /// is refused.
532 #[cfg(feature = "client")]
533 #[tokio::test]
534 async fn did_resolver_rejects_private_range_did() {
535 let resolver = WebResolver::new();
536 for did in [
537 "did:web:192.168.1.1",
538 "did:web:10.0.0.1",
539 "did:web:172.16.0.1",
540 ] {
541 let err = resolver.resolve(did).await.unwrap_err();
542 assert!(
543 matches!(err, AcdpError::KeyResolution(_)),
544 "did-ssrf-003: private-range did:web '{did}' MUST be blocked, got {err:?}"
545 );
546 }
547 }
548
549 /// RFC-ACDP-0008 §4.8 DNS-rebinding protection — a hostname whose
550 /// DNS answers fall in forbidden ranges is refused at the DNS step,
551 /// before any TCP connect. `localhost` is a perfectly valid DNS
552 /// name (it passes `check_url`), but it resolves to `127.0.0.1` —
553 /// which the default policy MUST refuse via the `SafeDnsResolver`
554 /// hook on reqwest's `dns_resolver`. The error message MUST
555 /// identify the SSRF policy so operators can tell the refusal
556 /// apart from a generic connection failure.
557 #[cfg(feature = "client")]
558 #[tokio::test]
559 async fn did_resolver_rejects_hostname_resolving_to_loopback() {
560 let resolver = WebResolver::new();
561 let err = resolver
562 .resolve("did:web:localhost%3A12345")
563 .await
564 .expect_err("DNS-rebinding protection MUST refuse localhost under default policy");
565 let msg = format!("{err}");
566 // SSRF-refused DNS is policy-driven and permanent — it maps to
567 // `KeyResolution` (HTTP 400), NOT `KeyResolutionUnreachable`
568 // (HTTP 502, retryable). The retry-aware client MUST NOT retry.
569 assert!(
570 matches!(err, AcdpError::KeyResolution(_)),
571 "DNS-rebinding refusal MUST be permanent KeyResolution, got {err:?}"
572 );
573 assert!(
574 msg.contains("SSRF policy"),
575 "DNS-rebinding refusal MUST identify the SSRF policy in its message; got: {msg}"
576 );
577 // And `is_transient` MUST return false so retry loops don't loop.
578 assert!(!err.is_transient(), "SSRF refusal MUST NOT be transient");
579 }
580}