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/// Default minimum interval between two *forced* (cache-bypassing)
25/// re-fetches of the same DID document — one hour.
26///
27/// RFC-ACDP-0001 §5.11 "Caching" *(0.5.0)* requires a verifier holding a
28/// cached document to re-fetch it once before reporting "no
29/// `verificationMethod` matches" (step 4) or `key_not_authorized`
30/// (step 5), and lets it rate-limit those forced re-fetches per DID.
31/// The bound stops a stream of bodies naming unknown fragments from
32/// turning the verifier into a request amplifier against a DID host.
33/// Override with [`WebResolver::with_forced_refresh_interval`].
34#[cfg(feature = "client")]
35pub const DEFAULT_FORCED_REFRESH_INTERVAL: Duration = Duration::from_secs(3600);
36
37#[cfg(feature = "client")]
38struct CacheEntry {
39    doc: DidDocument,
40    cached_at: Instant,
41}
42
43/// A resolved DID document together with when it was fetched from the
44/// network (a cache hit reports the original fetch time).
45#[cfg(feature = "client")]
46#[derive(Debug, Clone)]
47pub struct ResolvedDidDocument {
48    /// The document.
49    pub document: DidDocument,
50    /// When this document was fetched over HTTPS.
51    pub fetched_at: Instant,
52}
53
54/// Result of [`WebResolver::resolve_until`]: the best document the
55/// resolver could obtain, and whether it is *fresh* for the operation
56/// (fetched from the network at or after the operation's start).
57#[cfg(feature = "client")]
58#[derive(Debug, Clone)]
59pub struct FreshResolution {
60    /// The document the caller should evaluate.
61    pub document: DidDocument,
62    /// `true` when `document` was fetched at or after the caller's
63    /// `since` instant — i.e. a verdict against it is final per
64    /// RFC-ACDP-0001 §5.11 "Caching". `false` only when the document is
65    /// an older cached copy that the caller's predicate rejected and a
66    /// forced re-fetch was refused by the per-DID rate limit.
67    pub fresh: bool,
68}
69
70/// Resolves `did:web:…` DIDs to DID documents via HTTPS.
71///
72/// Caches resolved documents for 5–24 hours per §5.11 guidance, evicting
73/// the least-recently-used entry once the cache reaches the configured
74/// capacity (default 1000).
75///
76/// Every resolution URL passes the [`SsrfPolicy`] gate before any socket
77/// activity: a producer-controlled `did:web` authority is an SSRF vector
78/// identical to a cross-registry reference (RFC-ACDP-0008 §4.8). The
79/// default policy refuses IP-literal authorities and non-HTTPS schemes,
80/// so `did:web:127.0.0.1` / `did:web:169.254.169.254` cannot turn a
81/// registry verifying a publish — or a consumer verifying a retrieved
82/// context — into an SSRF proxy against process-internal listeners.
83#[cfg(feature = "client")]
84pub struct WebResolver {
85    http: reqwest::Client,
86    cache: Arc<Mutex<LruCache<String, CacheEntry>>>,
87    /// Last forced (cache-bypassing) fetch per DID — the §5.11 refresh
88    /// rate limit. Bounded like the document cache.
89    forced: Arc<Mutex<LruCache<String, Instant>>>,
90    forced_refresh_interval: Duration,
91    ssrf_policy: SsrfPolicy,
92    // Stored verbatim so [`Self::with_ssrf_policy`] can rebuild the
93    // HTTP client (the DNS resolver is wired in at builder time, so a
94    // policy swap requires rebuilding).
95    root_cert_pem: Option<Vec<u8>>,
96}
97
98#[cfg(feature = "client")]
99impl WebResolver {
100    /// Build a resolver with the default LRU capacity (1000 entries).
101    ///
102    /// # Panics
103    ///
104    /// Panics if the underlying HTTP client cannot be built (e.g. the
105    /// TLS backend fails to initialize). Use [`Self::try_new`] to
106    /// handle that failure as a `Result` instead.
107    pub fn new() -> Self {
108        Self::try_new().expect("failed to build HTTP client for DID resolver")
109    }
110
111    /// Fallible variant of [`Self::new`]: builds a resolver with the
112    /// default LRU capacity (1000 entries), returning an error instead
113    /// of panicking if the underlying HTTP client cannot be built.
114    pub fn try_new() -> Result<Self, AcdpError> {
115        Self::try_with_capacity(DEFAULT_CACHE_CAPACITY)
116    }
117
118    /// Build a resolver with a custom LRU capacity.
119    ///
120    /// # Panics
121    ///
122    /// Panics if `capacity == 0` — use a positive capacity; the LRU
123    /// model has no semantically valid empty configuration — or if the
124    /// underlying HTTP client cannot be built. Use
125    /// [`Self::try_with_capacity`] to handle the HTTP-client failure
126    /// as a `Result` instead.
127    pub fn with_capacity(capacity: usize) -> Self {
128        Self::try_with_capacity(capacity).expect("failed to build HTTP client for DID resolver")
129    }
130
131    /// Fallible variant of [`Self::with_capacity`]: returns an error
132    /// instead of panicking if the underlying HTTP client cannot be
133    /// built.
134    ///
135    /// # Panics
136    ///
137    /// Still panics if `capacity == 0`; that is a programmer error, not
138    /// a runtime condition.
139    pub fn try_with_capacity(capacity: usize) -> Result<Self, AcdpError> {
140        Self::from_parts(capacity, SsrfPolicy::default(), None)
141    }
142
143    /// Build a resolver that trusts the given PEM-encoded root certificate
144    /// in addition to the system roots.
145    ///
146    /// Primary use is the in-process self-signed HTTPS server in the
147    /// crate's `tests/helpers/tls_did_server.rs` harness, so the spec
148    /// fixtures `pub-001` / `pub-006` / `fed-001..006` can drive the
149    /// resolver end-to-end without going over the network. Production
150    /// callers on corporate intranets MAY also use this to trust a
151    /// private CA.
152    pub fn with_root_cert_pem(pem: &[u8]) -> Result<Self, AcdpError> {
153        Self::from_parts(
154            DEFAULT_CACHE_CAPACITY,
155            SsrfPolicy::default(),
156            Some(pem.to_vec()),
157        )
158    }
159
160    /// Build a resolver with a custom LRU capacity AND a custom root cert.
161    pub fn with_capacity_and_root_cert_pem(capacity: usize, pem: &[u8]) -> Result<Self, AcdpError> {
162        Self::from_parts(capacity, SsrfPolicy::default(), Some(pem.to_vec()))
163    }
164
165    /// Resolver pinned to a fixed socket address for a logical hostname,
166    /// trusting the given root certificate — the resolver-side analogue
167    /// of [`crate::client::RegistryClient::with_test_endpoint`]. Lets a
168    /// test resolve `did:web:localhost` (and path DIDs under it) against
169    /// an in-process TLS server bound to `127.0.0.1:<port>` without the
170    /// port appearing in the DID. Uses the loopback-permitting SSRF
171    /// policy. Use only in tests.
172    #[doc(hidden)]
173    #[cfg(feature = "test-transport")]
174    pub fn with_test_endpoint(
175        pem: &[u8],
176        host: &str,
177        target: std::net::SocketAddr,
178    ) -> Result<Self, AcdpError> {
179        let cap = NonZeroUsize::new(DEFAULT_CACHE_CAPACITY).expect("capacity > 0");
180        let policy = SsrfPolicy::allow_test_loopback();
181        let http = build_http_client_pinned(Some(pem), &policy, Some((host, target)))?;
182        Ok(Self {
183            http,
184            cache: Arc::new(Mutex::new(LruCache::new(cap))),
185            forced: Arc::new(Mutex::new(LruCache::new(cap))),
186            forced_refresh_interval: DEFAULT_FORCED_REFRESH_INTERVAL,
187            ssrf_policy: policy,
188            root_cert_pem: Some(pem.to_vec()),
189        })
190    }
191
192    fn from_parts(
193        capacity: usize,
194        ssrf_policy: SsrfPolicy,
195        root_cert_pem: Option<Vec<u8>>,
196    ) -> Result<Self, AcdpError> {
197        let cap = NonZeroUsize::new(capacity).expect("WebResolver capacity must be > 0");
198        let http = build_http_client(root_cert_pem.as_deref(), &ssrf_policy)?;
199        Ok(Self {
200            http,
201            cache: Arc::new(Mutex::new(LruCache::new(cap))),
202            forced: Arc::new(Mutex::new(LruCache::new(cap))),
203            forced_refresh_interval: DEFAULT_FORCED_REFRESH_INTERVAL,
204            ssrf_policy,
205            root_cert_pem,
206        })
207    }
208
209    /// Override the [`SsrfPolicy`] applied to `did:web` resolution.
210    ///
211    /// The policy gates both the URL stage (refusing IP-literal
212    /// authorities and non-HTTPS schemes — fixtures did-ssrf-001/002/003)
213    /// **and** the DNS resolution stage (filtering hostnames that resolve
214    /// into forbidden ranges — RFC-ACDP-0008 §4.8 DNS-rebinding
215    /// protection). Calling this rebuilds the underlying HTTP client so
216    /// the DNS resolver hook reflects the new policy.
217    ///
218    /// Relax the policy **only** in a test harness that resolves
219    /// `did:web:localhost…` against an in-process loopback server.
220    /// Production callers MUST keep the default.
221    ///
222    /// # Panics
223    ///
224    /// Panics if the underlying HTTP client cannot be rebuilt. Use
225    /// [`Self::try_with_ssrf_policy`] to handle that failure as a
226    /// `Result` instead.
227    pub fn with_ssrf_policy(self, policy: SsrfPolicy) -> Self {
228        self.try_with_ssrf_policy(policy)
229            .expect("rebuild HTTP client for DID resolver")
230    }
231
232    /// Fallible variant of [`Self::with_ssrf_policy`]: returns an error
233    /// instead of panicking if the underlying HTTP client cannot be
234    /// rebuilt.
235    pub fn try_with_ssrf_policy(mut self, policy: SsrfPolicy) -> Result<Self, AcdpError> {
236        // Rebuild the HTTP client so the DNS resolver hook carries the
237        // new policy. `build_http_client` is fallible only on bad cert
238        // PEM input; we already validated it at construction time.
239        let http = build_http_client(self.root_cert_pem.as_deref(), &policy)?;
240        self.http = http;
241        self.ssrf_policy = policy;
242        Ok(self)
243    }
244
245    /// Set the minimum interval between two forced re-fetches of the
246    /// same DID (default [`DEFAULT_FORCED_REFRESH_INTERVAL`], one hour).
247    /// `Duration::ZERO` disables the rate limit.
248    pub fn with_forced_refresh_interval(mut self, interval: Duration) -> Self {
249        self.forced_refresh_interval = interval;
250        self
251    }
252
253    /// The configured forced-refresh interval.
254    pub fn forced_refresh_interval(&self) -> Duration {
255        self.forced_refresh_interval
256    }
257
258    /// Resolve a `did:web:…` DID to a DID document.
259    ///
260    /// Hits the cache on repeated calls for the same DID. Verifiers that
261    /// need the RFC-ACDP-0001 §5.11 "refresh on plausible rotation"
262    /// behavior use [`Self::resolve_until`].
263    #[cfg_attr(feature = "tracing", tracing::instrument(skip(self), fields(did = did)))]
264    pub async fn resolve(&self, did: &str) -> Result<DidDocument, AcdpError> {
265        Ok(self.resolve_tracked(did).await?.document)
266    }
267
268    /// [`Self::resolve`], also reporting when the document was fetched.
269    pub async fn resolve_tracked(&self, did: &str) -> Result<ResolvedDidDocument, AcdpError> {
270        // Check cache (mutates LRU recency on hit)
271        {
272            let mut cache = self.cache.lock().unwrap();
273            if let Some(entry) = cache.get(did) {
274                if entry.cached_at.elapsed() < CACHE_MAX {
275                    return Ok(ResolvedDidDocument {
276                        document: entry.doc.clone(),
277                        fetched_at: entry.cached_at,
278                    });
279                }
280            }
281        }
282        self.fetch_and_store(did).await
283    }
284
285    /// Forced re-fetch: bypass the cache, fetch the document over HTTPS
286    /// and repopulate the cache (RFC-ACDP-0001 §5.11 "Caching").
287    ///
288    /// Rate-limited per DID to one forced fetch per
289    /// [`Self::forced_refresh_interval`]. A refused forced fetch returns
290    /// [`AcdpError::KeyResolutionUnreachable`] (transient): the caller
291    /// could not obtain a fresh document, which is not evidence about
292    /// the key.
293    pub async fn resolve_fresh(&self, did: &str) -> Result<DidDocument, AcdpError> {
294        if !self.try_claim_forced(did) {
295            return Err(AcdpError::KeyResolutionUnreachable(format!(
296                "forced re-fetch of {did} refused by the per-DID rate limit ({:?})",
297                self.forced_refresh_interval
298            )));
299        }
300        Ok(self.fetch_and_store(did).await?.document)
301    }
302
303    /// Resolve `did` for an operation that started at `since`, accepting
304    /// the first document for which `accept` returns `true`.
305    ///
306    /// This is the RFC-ACDP-0001 §5.11 "Caching" *(0.5.0)* rule made
307    /// mechanical:
308    ///
309    /// 1. Resolve normally (cache allowed). If `accept` passes, done.
310    /// 2. If the document was fetched at or after `since`, it is already
311    ///    fresh for this operation: return it, `fresh = true` (the
312    ///    verdict against it is final).
313    /// 3. If another task re-fetched the document after `since`, use
314    ///    that copy (`fresh = true`) — concurrent verifications share one
315    ///    forced fetch.
316    /// 4. Otherwise force one re-fetch, subject to the per-DID rate
317    ///    limit, and return the fresh document (`fresh = true`). When the
318    ///    rate limit refuses, the cached copy is returned with
319    ///    `fresh = false`; the caller decides whether that is final
320    ///    (for example, a key present but not in `assertionMethod`) or
321    ///    must be reported as transient (a key absent from the cached
322    ///    document).
323    pub async fn resolve_until<F>(
324        &self,
325        did: &str,
326        since: Instant,
327        accept: F,
328    ) -> Result<FreshResolution, AcdpError>
329    where
330        F: Fn(&DidDocument) -> bool,
331    {
332        let first = self.resolve_tracked(did).await?;
333        if accept(&first.document) || first.fetched_at >= since {
334            return Ok(FreshResolution {
335                document: first.document,
336                fresh: first.fetched_at >= since,
337            });
338        }
339        // Someone else already refreshed after `since`?
340        {
341            let mut cache = self.cache.lock().unwrap();
342            if let Some(entry) = cache.get(did) {
343                if entry.cached_at >= since && entry.cached_at > first.fetched_at {
344                    return Ok(FreshResolution {
345                        document: entry.doc.clone(),
346                        fresh: true,
347                    });
348                }
349            }
350        }
351        if !self.try_claim_forced(did) {
352            #[cfg(feature = "tracing")]
353            tracing::debug!(
354                did,
355                "forced DID re-fetch refused by rate limit; evaluating cached copy"
356            );
357            return Ok(FreshResolution {
358                document: first.document,
359                fresh: false,
360            });
361        }
362        let fresh = self.fetch_and_store(did).await?;
363        Ok(FreshResolution {
364            document: fresh.document,
365            fresh: true,
366        })
367    }
368
369    /// Resolve `did` and return a document that contains a
370    /// `verificationMethod` with `#fragment`, re-fetching once (per
371    /// [`Self::resolve_until`]) when the cached copy lacks it.
372    ///
373    /// Errors when the key is still absent:
374    ///
375    /// - [`AcdpError::KeyResolution`] (permanent) — absent from a
376    ///   document fetched at or after `since`: the RFC-ACDP-0001 §5.11
377    ///   step 4 verdict, final per "Caching".
378    /// - [`AcdpError::KeyResolutionUnreachable`] (transient) — absent
379    ///   from an older cached copy whose forced re-fetch the rate limit
380    ///   refused. No fresh document was seen, so absence is not proven.
381    pub async fn resolve_with_fragment(
382        &self,
383        did: &str,
384        fragment: &str,
385        since: Instant,
386    ) -> Result<DidDocument, AcdpError> {
387        let res = self
388            .resolve_until(did, since, |doc| doc.find_by_fragment(fragment).is_some())
389            .await?;
390        if res.document.find_by_fragment(fragment).is_some() {
391            return Ok(res.document);
392        }
393        if res.fresh {
394            Err(AcdpError::KeyResolution(format!(
395                "no verification method with fragment '#{fragment}' in {did} \
396                 (checked against a freshly fetched DID document)"
397            )))
398        } else {
399            Err(AcdpError::KeyResolutionUnreachable(format!(
400                "no verification method with fragment '#{fragment}' in the cached DID \
401                 document for {did}, and the forced re-fetch was refused by the per-DID \
402                 rate limit — absence is not proven"
403            )))
404        }
405    }
406
407    /// Record a forced fetch for `did` if the rate limit allows one now.
408    fn try_claim_forced(&self, did: &str) -> bool {
409        let mut forced = self.forced.lock().unwrap();
410        let now = Instant::now();
411        if let Some(last) = forced.get(did) {
412            if now.duration_since(*last) < self.forced_refresh_interval {
413                return false;
414            }
415        }
416        forced.put(did.to_string(), now);
417        true
418    }
419
420    async fn fetch_and_store(&self, did: &str) -> Result<ResolvedDidDocument, AcdpError> {
421        let url = did_web_to_url(did)?;
422
423        // RFC-ACDP-0008 §4.8: a producer-controlled did:web authority is
424        // an SSRF vector. Refuse loopback / link-local / IMDS / private-
425        // range targets before issuing any request. The refusal is
426        // policy-driven and producer-caused, so it maps to
427        // `key_resolution_failed` (HTTP 400, permanent) — NOT
428        // `key_resolution_unreachable` (HTTP 502, retryable). See
429        // fixtures did-ssrf-001 / did-ssrf-002 / did-ssrf-003.
430        self.ssrf_policy.check_url(&url).map_err(|e| {
431            AcdpError::KeyResolution(format!("SSRF policy blocked did:web resolution: {e}"))
432        })?;
433
434        let mut resp = self
435            .http
436            .get(&url)
437            .header("Accept", "application/did+json, application/json")
438            .send()
439            .await
440            .map_err(|e| classify_reqwest_error(&e))?;
441
442        // RFC-ACDP-0001 §5.11 step 3 (did-001 A): ANY non-2xx DID-document
443        // response — 404 and 410 included — is `key_resolution_unreachable`
444        // (transient). A briefly-misconfigured or unavailable DID host is not
445        // evidence that the key does not exist, and a permanent verdict here
446        // is exactly what lets revocation discovery drop a real revocation
447        // candidate (RFC-ACDP-0014 §8, 0.5.0 discovery outcome classes).
448        if !resp.status().is_success() {
449            return Err(AcdpError::KeyResolutionUnreachable(format!(
450                "DID document fetch returned HTTP {}",
451                resp.status()
452            )));
453        }
454
455        // Cap body size at 64 KB per RFC-ACDP-0006 §7.3.
456        if let Some(len) = resp.content_length() {
457            if len as usize > MAX_METADATA_BYTES {
458                return Err(AcdpError::KeyResolution(format!(
459                    "DID document Content-Length {len} exceeds {MAX_METADATA_BYTES}-byte cap"
460                )));
461            }
462        }
463        let mut buf: Vec<u8> = Vec::with_capacity(8 * 1024);
464        while let Some(chunk) = resp
465            .chunk()
466            .await
467            .map_err(|e| AcdpError::KeyResolutionUnreachable(e.to_string()))?
468        {
469            if buf.len() + chunk.len() > MAX_METADATA_BYTES {
470                return Err(AcdpError::KeyResolution(format!(
471                    "DID document body exceeded {MAX_METADATA_BYTES}-byte cap"
472                )));
473            }
474            buf.extend_from_slice(&chunk);
475        }
476        let doc: DidDocument = serde_json::from_slice(&buf)
477            .map_err(|e| AcdpError::KeyResolution(format!("DID document parse: {e}")))?;
478
479        // Store in cache (evicts LRU on overflow)
480        let fetched_at = Instant::now();
481        {
482            let mut cache = self.cache.lock().unwrap();
483            cache.put(
484                did.to_string(),
485                CacheEntry {
486                    doc: doc.clone(),
487                    cached_at: fetched_at,
488                },
489            );
490        }
491
492        Ok(ResolvedDidDocument {
493            document: doc,
494            fetched_at,
495        })
496    }
497
498    /// Invalidate a specific DID's cache entry, forcing a fresh fetch.
499    pub fn invalidate(&self, did: &str) {
500        self.cache.lock().unwrap().pop(did);
501    }
502}
503
504#[cfg(feature = "client")]
505impl Default for WebResolver {
506    fn default() -> Self {
507        Self::new()
508    }
509}
510
511/// Build the `reqwest::Client` used by `WebResolver`, optionally trusting
512/// an additional PEM-encoded root certificate.
513///
514/// Encapsulates the redirect policy, timeouts, and the DNS-rebinding
515/// filter so the no-cert and with-cert constructors stay byte-for-byte
516/// identical on TLS posture.
517///
518/// `ssrf_policy` is plumbed into reqwest's `dns_resolver` hook via
519/// [`acdp_safe_http::SafeDnsResolver`]: every resolved IP is filtered
520/// against the policy before reqwest connects, so a hostname whose DNS
521/// answers fall in forbidden ranges (loopback, RFC 1918, link-local,
522/// IMDS, ULA, …) is refused at connect time, defeating DNS rebinding
523/// (RFC-ACDP-0008 §4.8).
524#[cfg(feature = "client")]
525fn build_http_client(
526    extra_root_pem: Option<&[u8]>,
527    ssrf_policy: &SsrfPolicy,
528) -> Result<reqwest::Client, AcdpError> {
529    build_http_client_pinned(extra_root_pem, ssrf_policy, None)
530}
531
532/// Like [`build_http_client`] but optionally pins `host` to a fixed
533/// socket address via reqwest's `.resolve()` hook (test endpoints).
534#[cfg(feature = "client")]
535fn build_http_client_pinned(
536    extra_root_pem: Option<&[u8]>,
537    ssrf_policy: &SsrfPolicy,
538    pin: Option<(&str, std::net::SocketAddr)>,
539) -> Result<reqwest::Client, AcdpError> {
540    let policy = redirect::Policy::custom(|attempt| {
541        if attempt.previous().len() >= MAX_REDIRECTS {
542            return attempt.error(format!("DID resolver: exceeded {MAX_REDIRECTS} redirects"));
543        }
544        // Same-authority enforcement (scheme + host + port) against the
545        // original request URL. RFC-ACDP-0008 §4.8.
546        let cross = attempt
547            .previous()
548            .first()
549            .filter(|orig| !acdp_safe_http::same_fetch_authority(orig, attempt.url()))
550            .map(|orig| (orig.to_string(), attempt.url().to_string()));
551        if let Some((from, to)) = cross {
552            return attempt.error(format!(
553                "DID resolver: cross-authority redirect rejected ({from} -> {to})"
554            ));
555        }
556        attempt.follow()
557    });
558
559    let mut builder = reqwest::Client::builder()
560        .use_rustls_tls()
561        .connect_timeout(CONNECT_TIMEOUT)
562        .timeout(REQUEST_TIMEOUT)
563        .redirect(policy)
564        .dns_resolver(acdp_safe_http::SafeDnsResolver::arc(ssrf_policy.clone()));
565
566    if let Some(pem) = extra_root_pem {
567        let cert = reqwest::Certificate::from_pem(pem)
568            .map_err(|e| AcdpError::Http(format!("invalid root cert PEM: {e}")))?;
569        builder = builder.add_root_certificate(cert);
570    }
571
572    if let Some((host, target)) = pin {
573        builder = builder.resolve(host, target);
574    }
575
576    builder
577        .build()
578        .map_err(|e| AcdpError::Http(format!("DID resolver client build: {e}")))
579}
580
581/// Translate a `reqwest::Error` into the right [`AcdpError`] variant.
582///
583/// Walks the error's `source()` chain so the `SafeDnsResolver`'s refusal
584/// (the typed `acdp_primitives::error::SsrfDnsRefusal` marker, detected by
585/// the shared `is_ssrf_refusal`) survives reqwest's wrapping. An
586/// SSRF-refused DNS lookup is policy-driven and
587/// permanent — it maps to `key_resolution_failed` (HTTP 400), NOT
588/// `key_resolution_unreachable` (502, retryable) that
589/// `reqwest::Error::is_connect()` would suggest by default
590/// (RFC-ACDP-0008 §4.8, fixtures did-ssrf-001/002/003).
591#[cfg(feature = "client")]
592fn classify_reqwest_error(e: &reqwest::Error) -> AcdpError {
593    // Shared detection with `From<reqwest::Error> for AcdpError` and
594    // `HttpsDataRefFetcher` (issue #321); only the target variants differ.
595    let chain = acdp_primitives::error::error_chain_message(e);
596    if acdp_primitives::error::is_ssrf_refusal(e) {
597        return AcdpError::KeyResolution(chain);
598    }
599    if e.is_timeout() || e.is_connect() {
600        AcdpError::KeyResolutionUnreachable(chain)
601    } else {
602        AcdpError::KeyResolution(chain)
603    }
604}
605
606/// Convert a `did:web:…` DID to its HTTPS URL per the `did:web` spec.
607///
608/// `did:web:example.com` → `https://example.com/.well-known/did.json`
609/// `did:web:example.com:users:alice` → `https://example.com/users/alice/did.json`
610pub fn did_web_to_url(did: &str) -> Result<String, AcdpError> {
611    let rest = did
612        .strip_prefix("did:web:")
613        .ok_or_else(|| AcdpError::KeyResolution(format!("not a did:web DID: {did}")))?;
614
615    let parts: Vec<&str> = rest.split(':').collect();
616    let authority = urlencoding::decode(parts[0])
617        .map_err(|e| AcdpError::KeyResolution(format!("authority decode: {e}")))?;
618
619    if parts.len() == 1 {
620        Ok(format!("https://{}/.well-known/did.json", authority))
621    } else {
622        let path = parts[1..].join("/");
623        Ok(format!("https://{}/{}/did.json", authority, path))
624    }
625}
626
627/// Convert a registry authority (DNS hostname, optionally with a port)
628/// to its `did:web` form per the did:web method spec.
629///
630/// The `:` between host and port is a structural delimiter in did:web
631/// — it splits the DID into colon-separated path components — so a
632/// `host:port` authority must percent-encode the colon as `%3A` to
633/// keep the port in the authority segment.
634///
635/// Examples:
636/// - `"registry.example.com"`  → `"did:web:registry.example.com"`
637/// - `"localhost:8443"`        → `"did:web:localhost%3A8443"`
638pub fn authority_to_did_web(authority: &str) -> String {
639    let encoded = authority.replace(':', "%3A");
640    format!("did:web:{encoded}")
641}
642
643/// Reverse of [`authority_to_did_web`]: strip the `did:web:` prefix
644/// and decode `%3A` back to `:`. Returns `None` for non-`did:web` input.
645///
646/// Used in `RegistryServer::try_new` and `CrossRegistryResolver::resolve`
647/// to compare a capabilities-advertised DID against the authority the
648/// consumer connected to. Round-trips with `authority_to_did_web`.
649pub fn did_web_to_authority(did: &str) -> Option<String> {
650    let rest = did.strip_prefix("did:web:")?;
651    // Only the first segment carries the authority; further segments
652    // are path components and don't get colon-decoded.
653    let mut parts = rest.splitn(2, ':');
654    let authority = parts.next()?;
655    Some(authority.replace("%3A", ":"))
656}
657
658#[cfg(test)]
659mod tests {
660    use super::*;
661
662    #[test]
663    fn bare_authority() {
664        let url = did_web_to_url("did:web:example.com").unwrap();
665        assert_eq!(url, "https://example.com/.well-known/did.json");
666    }
667
668    #[test]
669    fn path_authority() {
670        let url = did_web_to_url("did:web:example.com:users:alice").unwrap();
671        assert_eq!(url, "https://example.com/users/alice/did.json");
672    }
673
674    /// BUG-06: bare DNS hostname maps to the plain did:web form.
675    #[test]
676    fn authority_to_did_web_bare_hostname() {
677        assert_eq!(
678            authority_to_did_web("registry.example.com"),
679            "did:web:registry.example.com"
680        );
681    }
682
683    /// BUG-06: `host:port` authority percent-encodes the colon.
684    #[test]
685    fn authority_to_did_web_with_port() {
686        assert_eq!(
687            authority_to_did_web("localhost:8443"),
688            "did:web:localhost%3A8443"
689        );
690    }
691
692    /// BUG-06: reverse helper strips prefix and decodes the colon.
693    #[test]
694    fn did_web_to_authority_round_trips() {
695        for authority in ["registry.example.com", "localhost:8443", "127.0.0.1:9000"] {
696            let did = authority_to_did_web(authority);
697            let back = did_web_to_authority(&did)
698                .unwrap_or_else(|| panic!("did_web_to_authority returned None for '{did}'"));
699            assert_eq!(back, authority, "round-trip for '{authority}' failed");
700        }
701    }
702
703    /// `did_web_to_url` already URL-decodes the authority — confirm
704    /// that `authority_to_did_web("localhost:8443")` produces a DID
705    /// that resolves to `https://localhost:8443/.well-known/did.json`.
706    #[test]
707    fn authority_to_did_web_then_to_url_keeps_port() {
708        let did = authority_to_did_web("localhost:8443");
709        let url = did_web_to_url(&did).unwrap();
710        assert_eq!(url, "https://localhost:8443/.well-known/did.json");
711    }
712
713    // ── BUG-04 — WebResolver SSRF policy (did-ssrf-001/002/003) ─────────
714
715    /// did-ssrf-001 — a `did:web` authority that is a loopback IP literal
716    /// is refused by the default resolver before any socket activity.
717    /// The error is `key_resolution_failed` (permanent), not
718    /// `key_resolution_unreachable` (retryable).
719    #[cfg(feature = "client")]
720    #[tokio::test]
721    async fn did_resolver_rejects_loopback_did() {
722        let resolver = WebResolver::new();
723        let err = resolver.resolve("did:web:127.0.0.1").await.unwrap_err();
724        assert!(
725            matches!(err, AcdpError::KeyResolution(_)),
726            "did-ssrf-001: loopback did:web MUST be blocked by SSRF policy, got {err:?}"
727        );
728    }
729
730    /// did-ssrf-002 — a `did:web` authority pointing at the cloud-metadata
731    /// endpoint (169.254.169.254) is refused.
732    #[cfg(feature = "client")]
733    #[tokio::test]
734    async fn did_resolver_rejects_imds_did() {
735        let resolver = WebResolver::new();
736        let err = resolver
737            .resolve("did:web:169.254.169.254")
738            .await
739            .unwrap_err();
740        assert!(
741            matches!(err, AcdpError::KeyResolution(_)),
742            "did-ssrf-002: IMDS did:web MUST be blocked by SSRF policy, got {err:?}"
743        );
744    }
745
746    /// did-ssrf-003 — a `did:web` authority in an RFC 1918 private range
747    /// is refused.
748    #[cfg(feature = "client")]
749    #[tokio::test]
750    async fn did_resolver_rejects_private_range_did() {
751        let resolver = WebResolver::new();
752        for did in [
753            "did:web:192.168.1.1",
754            "did:web:10.0.0.1",
755            "did:web:172.16.0.1",
756        ] {
757            let err = resolver.resolve(did).await.unwrap_err();
758            assert!(
759                matches!(err, AcdpError::KeyResolution(_)),
760                "did-ssrf-003: private-range did:web '{did}' MUST be blocked, got {err:?}"
761            );
762        }
763    }
764
765    /// RFC-ACDP-0008 §4.8 DNS-rebinding protection — a hostname whose
766    /// DNS answers fall in forbidden ranges is refused at the DNS step,
767    /// before any TCP connect. `localhost` is a perfectly valid DNS
768    /// name (it passes `check_url`), but it resolves to `127.0.0.1` —
769    /// which the default policy MUST refuse via the `SafeDnsResolver`
770    /// hook on reqwest's `dns_resolver`. The error message MUST
771    /// identify the SSRF policy so operators can tell the refusal
772    /// apart from a generic connection failure.
773    #[cfg(feature = "client")]
774    #[tokio::test]
775    async fn did_resolver_rejects_hostname_resolving_to_loopback() {
776        let resolver = WebResolver::new();
777        let err = resolver
778            .resolve("did:web:localhost%3A12345")
779            .await
780            .expect_err("DNS-rebinding protection MUST refuse localhost under default policy");
781        let msg = format!("{err}");
782        // SSRF-refused DNS is policy-driven and permanent — it maps to
783        // `KeyResolution` (HTTP 400), NOT `KeyResolutionUnreachable`
784        // (HTTP 502, retryable). The retry-aware client MUST NOT retry.
785        assert!(
786            matches!(err, AcdpError::KeyResolution(_)),
787            "DNS-rebinding refusal MUST be permanent KeyResolution, got {err:?}"
788        );
789        assert!(
790            msg.contains("SSRF policy"),
791            "DNS-rebinding refusal MUST identify the SSRF policy in its message; got: {msg}"
792        );
793        // And `is_transient` MUST return false so retry loops don't loop.
794        assert!(!err.is_transient(), "SSRF refusal MUST NOT be transient");
795    }
796}