Skip to main content

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}