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}