acdp_client/verified.rs
1//! VerifiedContext: retrieve + verify in one call.
2
3use super::data_ref::{fetch_and_verify_data_ref, DataRefFetcher};
4use super::registry::{DiscoveryBudget, RegistryClient};
5use acdp_did::WebResolver;
6use acdp_primitives::error::AcdpError;
7use acdp_types::{body::FullContext, primitives::CtxId};
8use acdp_verify::Verifier;
9use std::time::Duration;
10
11/// Consumer-tunable strictness for [`VerifiedContext::fetch_with_policy`],
12/// [`VerifiedContext::fetch_current_with_policy`], and the
13/// `fetch_report*` family ([`VerifiedContext::fetch_report`],
14/// [`VerifiedContext::fetch_report_with_fetcher`],
15/// [`VerifiedContext::fetch_report_diagnose`]). All three surfaces
16/// consult the same policy fields through the same `verify_retrieved`
17/// spine — but they do not always *agree*, because
18/// [`VerifiedContext::fetch_report_diagnose`] differs in more than just
19/// how a failure surfaces:
20///
21/// - [`VerifiedContext::fetch_report`] and
22/// [`VerifiedContext::fetch_report_with_fetcher`] run
23/// `verify_retrieved` directly once their own top-level probes pass,
24/// and surface a phase failure as `Err`.
25/// - [`VerifiedContext::fetch_report_diagnose`] runs its own
26/// independent, strict, assertionMethod-only signature *probe* first
27/// (recorded as `VerificationReport::signature_ok`). That probe has
28/// no historical-key fallback and runs *before* `verify_retrieved` is
29/// ever invoked. If it fails, `diagnose` withholds the
30/// [`VerifiedContext`] handle with `policy_phase_error: None` — the
31/// spine never ran, so there is no phase error to record — even in
32/// cases where `verify_retrieved` itself, as run by `fetch_report`,
33/// would have accepted the key historically under the default
34/// `historical_keys: HistoricalKeyPolicy::AcceptWithReceipt` plus a
35/// verified receipt. Concretely: for a key rotated out of
36/// `assertionMethod` with a valid receipt, `fetch_report` returns
37/// `Ok` with [`KeyAuthorization::HistoricallyAuthorized`], while
38/// `diagnose` returns no handle at all for the same input and policy.
39/// Only once `diagnose`'s own probes all pass does it fall through to
40/// `verify_retrieved` and, from that point on, withhold the handle /
41/// record [`VerificationReport::policy_phase_error`] instead of
42/// returning `Err` — that part of the behavior *is* shared with the
43/// other two.
44///
45/// For ACDP v0.1.0 the verification profile is **always strict**:
46///
47/// - `did:web` is required for every producer identity — enforced
48/// unconditionally by `verify_signature_envelope`
49/// (RFC-ACDP-0001 §5.4), regardless of any policy field.
50/// - Embedded `DataRef` hashes are verified by
51/// [`acdp_validation::validate_body`] whenever `validate_body_schema`
52/// is set.
53///
54/// Only the fields below have real effect in this version; there are no
55/// relaxed-mode `did:web` or embedded-hash knobs.
56#[derive(Debug, Clone, PartialEq, Eq)]
57pub struct VerificationPolicy {
58 /// If true, run [`acdp_validation::validate_body`] (structural
59 /// schema checks plus embedded-`DataRef` hash verification) before
60 /// any cryptographic check. Default `true`. Set `false` only in
61 /// diagnostic paths that want to attempt signature verification
62 /// despite a body known to fail structural checks.
63 ///
64 /// The `fetch_report*` family forces this field off unconditionally
65 /// on the internal policy it derives from the caller's — they run
66 /// `validate_body_structural` (schema only) themselves and record
67 /// per-`DataRef` embedded-hash outcomes in
68 /// [`VerificationReport::data_ref_embedded`] instead of treating a
69 /// mismatch as fatal. This field's value as set by the caller is
70 /// otherwise irrelevant to the report family.
71 pub validate_body_schema: bool,
72
73 /// If true, accept `Status::Other` values (degrade to active per
74 /// RFC-ACDP-0004 §4.1). When false, reject unknown statuses.
75 /// Default `true`.
76 pub allow_unknown_status: bool,
77
78 /// Registry-receipt handling (ACDP 0.2, RFC-ACDP-0010).
79 /// Default [`ReceiptPolicy::VerifyIfPresent`].
80 pub receipts: ReceiptPolicy,
81
82 /// Historical-key handling (ACDP 0.2, WS-B). Default
83 /// [`HistoricalKeyPolicy::AcceptWithReceipt`].
84 pub historical_keys: HistoricalKeyPolicy,
85
86 /// Lineage-head receipt handling on `/current` fetches (ACDP 0.3,
87 /// RFC-ACDP-0011). Only consulted by
88 /// [`VerifiedContext::fetch_current_with_policy`]; plain retrieval
89 /// preserves any `lineage_head_receipt` verbatim without verifying
90 /// it. Default [`LineageHeadPolicy::default`]. This is the ONE
91 /// field on this struct with restricted scope — `allow_unknown_status`,
92 /// `receipts`, `historical_keys`, and `revocations` above and below
93 /// are each honored by every entry point that accepts a
94 /// [`VerificationPolicy`], including the `fetch_report*` family.
95 pub lineage_head: LineageHeadPolicy,
96
97 /// Key-revocation handling (ACDP 0.3, RFC-ACDP-0014 §7). Default:
98 /// no known revocations — the phase is inert.
99 pub revocations: RevocationPolicy,
100}
101
102impl Default for VerificationPolicy {
103 fn default() -> Self {
104 Self {
105 validate_body_schema: true,
106 allow_unknown_status: true,
107 receipts: ReceiptPolicy::VerifyIfPresent,
108 historical_keys: HistoricalKeyPolicy::AcceptWithReceipt,
109 lineage_head: LineageHeadPolicy::default(),
110 revocations: RevocationPolicy::default(),
111 }
112 }
113}
114
115/// Consumer-held key revocations to enforce during verification
116/// (ACDP 0.3, RFC-ACDP-0014 §7).
117///
118/// [`Self::known`] is **pull-based**: the pipeline does not go looking
119/// for those revocations on its own — the caller supplies the
120/// **verified** revocations it holds (from
121/// [`find_revocations`](crate::revocation::find_revocations),
122/// [`find_registry_attested_revocations`](crate::revocation::find_registry_attested_revocations),
123/// an out-of-band channel, or its own indefinite cache — the statement
124/// is permanent, cache accordingly). [`Self::discover`] (RFC-ACDP-0014
125/// §8) is the opt-in complement: when set, `verify_retrieved` itself
126/// runs those same two lookups and unions their result with `known`
127/// (see [`RevocationDiscovery`]). When `known` is empty, `discover` is
128/// `None`, AND no [`crate::RevocationCache`] is attached to the client,
129/// the phase is inert and verification behaves exactly as before
130/// RFC-ACDP-0014.
131///
132/// Issue #257 gives `discover`'s own caching a first-class home:
133/// [`crate::RevocationCache`], attached to the [`crate::RegistryClient`]
134/// passed to `verify_retrieved` via
135/// [`crate::RegistryClient::with_revocation_cache`], persists exactly the
136/// "own indefinite cache" a caller would otherwise have to hand-roll
137/// around `known` — verified revocations discovered on one call are
138/// unioned into every later call against the same client, unconditionally
139/// and indefinitely, independent of [`RevocationDiscovery::freshness`], and
140/// **regardless of whether that later call itself sets `discover`** —
141/// attaching a cache is itself the opt-in (MATERIAL-3, fresh-Opus review
142/// of Phase 2): a call made with `discover: None` still seeds
143/// producer-signed facts from the cache (never registry-attested ones —
144/// see below), which is what extends this anti-rollback protection to
145/// [`VerifiedContext::fetch`]/[`VerifiedContext::fetch_current`] (LIM-2),
146/// the two entry points that can never set `discover` at all. A
147/// registry-attested fact, by contrast, is additionally scoped to the
148/// vantage that minted it (BLOCKER-1, RFC-ACDP-0014 §6): reading it back
149/// through a client talking to a different authority never applies it,
150/// even under [`RevocationDiscovery::include_registry_attested`]. See
151/// that field's doc and `crate::revocation_cache` for the full model,
152/// including the separate, opt-in, TTL-bounded marker that can additionally
153/// skip a repeat lookup.
154///
155/// When the body's signing key matches a supplied revocation, §7
156/// applies: a receipt-attested publish time strictly before the
157/// (earliest, §4) `compromised_since` boundary verifies as
158/// [`KeyAuthorization::HistoricallyAuthorizedPreCompromise`]; at/after
159/// the boundary, or with no verified receipt to place the context at
160/// all, verification **fails closed** with `key_not_authorized` —
161/// regardless of DID-document state and regardless of the receipt's
162/// own validity. Note the interaction with [`ReceiptPolicy::Ignore`]:
163/// an unverified receipt provides no publish time, so a revoked key's
164/// contexts all fail closed under it.
165///
166/// This applies uniformly to every entry point that *accepts* a
167/// [`VerificationPolicy`] — [`VerifiedContext::fetch_with_policy`],
168/// [`VerifiedContext::fetch_current_with_policy`], and the
169/// `fetch_report*` family (five entry points in total) — since they all
170/// reach this phase through the same internal pipeline. On
171/// [`VerifiedContext::fetch_report_diagnose`] specifically, "fails
172/// closed" means the returned [`VerifiedContext`] handle is withheld
173/// and the cause is recorded in `VerificationReport::policy_phase_error`,
174/// rather than the call returning `Err` — that method never
175/// short-circuits on a policy-phase failure by design.
176///
177/// "Uniformly" has one remaining carve-out, structural rather than a
178/// policy choice: [`VerifiedContext::fetch`] and
179/// [`VerifiedContext::fetch_current`] hardcode
180/// [`VerificationPolicy::default`] and so can never carry a non-empty
181/// `known` or a `discover`; callers wanting either use the
182/// `_with_policy` forms instead. This is recorded as a known limitation
183/// (issue #248 LIM-2) rather than silently true. Phase 2's cache still
184/// extends anti-rollback protection to these two entry points — see
185/// [`Self::discover`]'s doc — but they can never *configure* discovery
186/// themselves.
187///
188/// **Issue #260 closed the sibling limitation, LIM-1.**
189/// [`crate::CrossRegistryResolver::with_revocation_policy`] now injects a
190/// [`RevocationPolicy`] into every node a cross-registry walk verifies,
191/// and [`crate::CrossRegistryResolver::with_revocation_cache`] shares one
192/// [`crate::RevocationCache`] across the walk (walk-scoped by default —
193/// see that method's doc). The resolver derives `receipts` itself, per
194/// node, from that node's advertised capabilities, so it takes a
195/// [`RevocationPolicy`], never a caller-supplied [`VerificationPolicy`] —
196/// see `CrossRegistryResolver::with_revocation_policy`'s doc for why.
197///
198/// Only put revocations here that you have verified (strict body
199/// pipeline + the §5 not-self-signed rule) and, per §6, that you have
200/// decided to act on: producer-signed ones unconditionally;
201/// registry-attested ones ([`RevocationTrustClass::RegistryAttested`](acdp_types::revocation::RevocationTrustClass))
202/// by default only for contexts served by or receipted by that same
203/// registry, with corroboration before global application.
204///
205/// [`find_revocations`](crate::revocation::find_revocations) itself
206/// pre-filters its output to [`RevocationTrustClass::ProducerSigned`](acdp_types::revocation::RevocationTrustClass)
207/// entries actually published by the queried producer, so §6
208/// registry-attested attestations never arrive through it — obtain
209/// those from
210/// [`find_registry_attested_revocations`](crate::revocation::find_registry_attested_revocations)
211/// instead.
212///
213/// `#[non_exhaustive]`: this struct grew a second field
214/// ([`Self::discover`], RFC-ACDP-0014 §8 auto-discovery) after having
215/// shipped with exactly one, and a caller constructing this with a
216/// bare struct literal would otherwise break on every future field the
217/// same way. Construct with [`Self::new`] (equivalent to today's
218/// `RevocationPolicy { known }`) and, when opting into discovery,
219/// [`Self::with_discovery`].
220#[derive(Debug, Clone, PartialEq, Eq, Default)]
221#[non_exhaustive]
222pub struct RevocationPolicy {
223 /// Verified revocations to enforce, matched against the signing
224 /// key's RFC-ACDP-0010 §6 fingerprint. The §4 earliest-
225 /// `compromised_since` rule is applied across entries naming the
226 /// same fingerprint, so include *every* revocation of a lineage,
227 /// superseded (and retracted) ones too — a later member can only
228 /// widen the compromise window, never narrow it, and dropping an
229 /// earlier one is exactly how that window gets quietly (and
230 /// wrongly) shrunk. This is no longer an unassisted obligation:
231 /// [`find_revocations`](crate::revocation::find_revocations) and
232 /// [`find_registry_attested_revocations`](crate::revocation::find_registry_attested_revocations)
233 /// each walk the full lineage of every candidate they find
234 /// (search-visible or not, including all-retracted lineages) and
235 /// already return the complete set for their respective trust
236 /// class; [`find_revocations_in_lineage`](crate::revocation::find_revocations_in_lineage)
237 /// does the same directly from a known `lineage_id`, with no
238 /// producer/trust-class scope filter. Populate `known` from one of
239 /// these rather than hand-assembling a lineage.
240 pub known: Vec<acdp_types::revocation::KeyRevocation>,
241
242 /// RFC-ACDP-0014 §8 auto-discovery configuration. `None` (the
243 /// default) is exactly today's behavior: the caller supplies
244 /// everything via [`Self::known`] and this phase does no network
245 /// I/O of its own. `Some` opts into running
246 /// [`find_revocations`](crate::revocation::find_revocations) and,
247 /// depending on [`RevocationDiscovery::include_registry_attested`],
248 /// [`find_registry_attested_revocations`](crate::revocation::find_registry_attested_revocations)
249 /// as part of verification, merging their results with
250 /// [`Self::known`].
251 pub discover: Option<RevocationDiscovery>,
252}
253
254impl RevocationPolicy {
255 /// Construct a policy from a caller-supplied revocation set with
256 /// discovery left off (`discover: None`) — the same shape as the
257 /// bare `RevocationPolicy { known }` literal this struct's
258 /// `#[non_exhaustive]` retires.
259 #[must_use]
260 pub fn new(known: Vec<acdp_types::revocation::KeyRevocation>) -> Self {
261 Self {
262 known,
263 discover: None,
264 }
265 }
266
267 /// Opt into RFC-ACDP-0014 §8 auto-discovery on top of any
268 /// caller-supplied [`Self::known`] revocations. See
269 /// [`RevocationDiscovery`] for cost and the required explicit
270 /// trust-class choice.
271 #[must_use]
272 pub fn with_discovery(mut self, discovery: RevocationDiscovery) -> Self {
273 self.discover = Some(discovery);
274 self
275 }
276}
277
278/// RFC-ACDP-0014 §8 revocation auto-discovery configuration.
279///
280/// When set on [`RevocationPolicy::discover`], this instructs
281/// verification to look up revocations itself — via
282/// [`find_revocations`](crate::revocation::find_revocations) and,
283/// when [`Self::include_registry_attested`] is `true`, additionally
284/// [`find_registry_attested_revocations`](crate::revocation::find_registry_attested_revocations)
285/// — instead of relying solely on [`RevocationPolicy::known`].
286///
287/// # Cost
288///
289/// Discovery is expensive, and every request it issues is **serial**.
290/// `MAX_SEARCH_PAGES = 10` (`crate::revocation`) bounds search
291/// round-trips *per `(type_form, status)` pair*, and there are **6**
292/// such pairs (2 type forms × 3 statuses) — so up to 60 search
293/// requests, each of which can name up to 100 per-candidate context
294/// retrieves (`GET /contexts/{id}`, capped at 1 MB apiece), for up to
295/// **6,000 + 60 + 100 = 6,160 requests / ~6.1 GB** in the worst case
296/// for *one* of the two discovery functions. The retrieve fan-out is
297/// **not** bounded by `MAX_LINEAGE_WALKS = 100` — that cap is only
298/// checked *after* the retrieves have already gone out. With
299/// [`Self::include_registry_attested`] set, both functions run:
300/// **≈12,321 requests / ~12.2 GB** worst case for the pair (the extra
301/// 1 is the unconditional `client.capabilities()` fetch
302/// `find_registry_attested_revocations` makes).
303///
304/// [`Self::total_timeout`] is an **availability bound, not a bytes or
305/// memory bound**: it stops verification from hanging forever against
306/// a slow or hostile registry, but a hostile registry on a fast link
307/// can still serve gigabytes of legitimate-looking traffic inside the
308/// window — the 1 MB cap applies per request, not in aggregate, and
309/// verified revocations accumulate in a `Vec` for the call's duration.
310/// [`Self::max_requests`] and [`Self::max_bytes`] (issue #258) close
311/// that gap: set either (or both) to bound the two lookups **combined**
312/// — enabling [`Self::include_registry_attested`] does not double the
313/// ceiling — checked **before** each request is issued, so a would-be
314/// request that would exceed the budget is never sent. Exhaustion
315/// raises `AcdpError::RevocationDiscoveryBudgetExceeded` through the
316/// same [`Self::on_failure`] path as `AcdpError::SearchTruncated` — it
317/// is permanent for the same request shape and is never transient.
318/// Both knobs bound **registry** traffic only: DID-document fetches
319/// issued via `WebResolver` are not counted. [`Self::max_bytes`] counts
320/// only **successfully-parsed response bodies** — an error-envelope
321/// read on a non-success response is not charged. [`Self::max_requests`]
322/// has no such exemption: the request slot is reserved *before* the
323/// request is issued, so a 503, a parse failure, or a `PayloadTooLarge`
324/// still consumes it. Leaving both `None` (as both
325/// [`Self::producer_signed_only`] and [`Self::all_trust_classes`] do)
326/// preserves pre-#258 behavior exactly: unbounded requests and bytes,
327/// bounded only by [`Self::total_timeout`].
328///
329/// Issue #257 adds an opt-in cache ([`crate::RevocationCache`], attached
330/// to the [`crate::RegistryClient`] passed in via
331/// [`crate::RegistryClient::with_revocation_cache`]) — but attaching one
332/// does NOT, by itself, turn "re-discovers from scratch" into "sometimes
333/// skips discovery." It is two independent things: verified revocations
334/// ("facts") are always unioned into classification, indefinitely,
335/// regardless of [`Self::freshness`] — a caller verifying many contexts
336/// against the same producer benefits from this immediately, with zero
337/// extra configuration, simply by attaching a cache and reusing the
338/// client. Whether a repeat lookup is skipped entirely (saving requests)
339/// is governed separately by [`Self::freshness`], which defaults to
340/// `Duration::ZERO` — i.e. off. See [`Self::freshness`]'s own doc and
341/// `crate::revocation_cache` for the full model. Without a cache attached
342/// at all, this crate behaves exactly as before #257: every call
343/// re-discovers from scratch, budgeted or not. A caller that does not
344/// want to manage a `RevocationCache` can still discover once itself and
345/// pass the results via [`RevocationPolicy::known`] instead of setting
346/// `discover` on every call — the same hoisting guidance
347/// `crate::revocation`'s `find_registry_attested_revocations` doc already
348/// gives callers of that function directly (see its "Cost note for
349/// callers verifying many contexts").
350///
351/// # Reentrancy
352///
353/// Discovery calls back into verification, and that reentrancy has two
354/// consequences worth stating explicitly rather than leaving implicit:
355///
356/// 1. Each candidate body [`find_revocations`](crate::revocation::find_revocations)
357/// turns up is verified via `Verifier::new(resolver).verify_body` —
358/// **not** `verify_retrieved` — so discovered revocation bodies are
359/// themselves checked *without* revocation checking of their own.
360/// That is defensible under RFC-ACDP-0014 §5 step 1's "currently
361/// authorized key," but it is an assumption this type is making on
362/// the caller's behalf, not an accident.
363/// 2. Verifying a `key-revocation` context now *also* triggers
364/// discovery against the same producer, so the revocation-fetch path
365/// itself becomes fragile under [`DiscoveryFailurePolicy::FailClosed`]:
366/// a producer whose revocation search is briefly unreachable can no
367/// longer be verified as revoked, either.
368///
369/// # No `Default`
370///
371/// This type deliberately has **no [`Default`] impl** — construct it
372/// via [`Self::producer_signed_only`] or [`Self::all_trust_classes`].
373/// RFC-ACDP-0014 §6's "lost-everything" fallback means a producer that
374/// has lost every key it could sign a revocation with can *only* be
375/// revoked registry-attested — so the catastrophic case is exactly the
376/// one a silently-defaulted-off trust class would skip. A quiet
377/// `Default::default()` that leaves `include_registry_attested: false`
378/// would make that skip invisible at every call site; forcing a named
379/// constructor puts the choice at the type level instead, where a
380/// reviewer (and `git grep`) can see it. Callers protecting against
381/// key loss, or otherwise unwilling to assume a producer always
382/// retains signing capacity, MUST use [`Self::all_trust_classes`].
383#[derive(Debug, Clone, Copy, PartialEq, Eq)]
384#[non_exhaustive]
385pub struct RevocationDiscovery {
386 /// Whether to also run
387 /// [`find_registry_attested_revocations`](crate::revocation::find_registry_attested_revocations)
388 /// (the §6 registry-attested trust class), in addition to the
389 /// producer-signed search every discovery configuration runs.
390 /// Requires the registry to serve `/.well-known/acdp.json`; under
391 /// [`DiscoveryFailurePolicy::FailClosed`] a registry that serves no
392 /// capabilities document fails verification when this is `true`.
393 pub include_registry_attested: bool,
394 /// What to do when discovery itself fails (a transient transport
395 /// error from either search, or the search-safety-cap error
396 /// `AcdpError::SearchTruncated`). Default [`DiscoveryFailurePolicy::FailClosed`].
397 ///
398 /// **`SearchTruncated` and a transport error (e.g. a 503) are NOT
399 /// equivalent, even though both take this same `on_failure` path.**
400 /// `SearchTruncated` means "this producer has more revocations than
401 /// we will page through" (`MAX_SEARCH_PAGES`) — an
402 /// **attacker-inducible security downgrade**, since a hostile
403 /// producer or registry can pad the result set specifically to
404 /// exhaust the page cap and hide a real revocation from discovery.
405 /// A 503 is an ordinary availability blip. **A third case is
406 /// attacker-inducible the same way `SearchTruncated` is (issue
407 /// #258):** `AcdpError::RevocationDiscoveryBudgetExceeded`, raised
408 /// when [`Self::max_requests`] or [`Self::max_bytes`] is exhausted —
409 /// a hostile registry that learns a caller's budget can pad
410 /// harmless-looking traffic specifically to exhaust it before a
411 /// real revocation is found, just as padding the result set exhausts
412 /// `MAX_SEARCH_PAGES`. Under [`DiscoveryFailurePolicy::ProceedWithKnown`]
413 /// all three are treated the same way (proceed on
414 /// [`RevocationPolicy::known`] alone, record the failure) — choose
415 /// `ProceedWithKnown` knowing it also waives truncation and budget
416 /// exhaustion, not just transient unavailability.
417 pub on_failure: DiscoveryFailurePolicy,
418 /// Wall-clock budget for the whole discovery step (both searches,
419 /// if [`Self::include_registry_attested`] is set). An **availability**
420 /// bound only — see the type-level cost section above. Matches this
421 /// crate's existing `ResolverOptions::total_timeout` precedent
422 /// (`crate::cross_registry`), defaulting to the same 30 s rather
423 /// than exceeding it on the core verify path.
424 ///
425 /// Enforced via [`tokio::time::timeout`], which requires the
426 /// executing Tokio runtime to have its **time driver enabled**
427 /// (`#[tokio::main]` and `#[tokio::test]` enable it by default;
428 /// a hand-built `Builder::new_current_thread()` runtime does
429 /// **not** unless `.enable_time()` or `.enable_all()` is called).
430 /// Calling `verify_retrieved` with `discover: Some(..)` from a
431 /// runtime without the time driver **panics** — it does not
432 /// return `Err` — the same requirement `ResolverOptions::total_timeout`
433 /// (`crate::cross_registry`) already carries on its opt-in walk,
434 /// but here it sits on the core verify path whenever discovery is
435 /// configured, not just on an explicit cross-registry walk.
436 pub total_timeout: Duration,
437 /// Issue #258: cap on the total number of registry requests the
438 /// discovery step may issue, **combined across both lookups** (the
439 /// producer-signed search always, plus the registry-attested search
440 /// when [`Self::include_registry_attested`] is set) — not a ceiling
441 /// per lookup, so turning on the second trust class does not double
442 /// the allowance. `None` (the default from both named constructors)
443 /// is unbounded, matching every version before #258. Checked
444 /// **before** each request is issued (`RegistryClient::capabilities`,
445 /// `::retrieve`, `::lineage`, `::search`); exceeding it raises
446 /// `AcdpError::RevocationDiscoveryBudgetExceeded` through
447 /// [`Self::on_failure`], the same path `AcdpError::SearchTruncated`
448 /// already takes. Counts registry requests only — DID-document
449 /// fetches via `WebResolver` are not counted.
450 pub max_requests: Option<std::num::NonZeroUsize>,
451 /// Issue #258: cap on the cumulative bytes of *successfully-parsed*
452 /// response bodies the discovery step may read, **combined across
453 /// both lookups**, same combination rule as [`Self::max_requests`].
454 /// `None` (the default from both named constructors) is unbounded,
455 /// matching every version before #258. Checked **before** each
456 /// request is issued, using the running total from requests that
457 /// already completed — the size of an in-flight request cannot be
458 /// known (and therefore reserved) in advance, so **up to two**
459 /// requests can push the total past `max_bytes` before the next
460 /// check observes the overrun: the two trust-class lookups run
461 /// concurrently under `tokio::try_join!`, and both can pass a
462 /// not-yet-updated check before either's response is recorded — see
463 /// [`Self::max_requests`]'s doc for the same race on the request
464 /// count, where it is closed by an atomic reservation; there is no
465 /// equivalent reservation for bytes, since a response's size is not
466 /// known until after it is read. Counts a non-success response's
467 /// error-envelope read *not at all* — only bytes read on the
468 /// success path are charged.
469 ///
470 /// `Some(0)` is representable (unlike [`Self::max_requests`], which
471 /// is guarded by `NonZeroUsize`) and is not special-cased: it trips
472 /// the `>=` check on the very first request of either lookup,
473 /// before that request is ever issued, so discovery fails
474 /// immediately with zero registry traffic. This is a deliberate
475 /// consequence of keeping this field a plain `u64` (matching the
476 /// wave plan's chosen types) rather than a reason to reach for
477 /// `NonZeroU64`.
478 pub max_bytes: Option<u64>,
479 /// Issue #257: how long a discovery-freshness marker stays valid on
480 /// a [`crate::RevocationCache`] attached to the [`crate::RegistryClient`]
481 /// passed to `verify_retrieved` (via
482 /// [`crate::RegistryClient::with_revocation_cache`]). A marker records
483 /// "vantage V completed a full, untruncated discovery for this
484 /// producer/trust-class at time T"; while one is within `freshness` of
485 /// T, that lookup is skipped entirely (zero registry requests) rather
486 /// than merely supplemented.
487 ///
488 /// Default `Duration::ZERO` from both named constructors — markers
489 /// never suppress a lookup unless a caller explicitly raises this
490 /// above zero. This is the safe default per RFC-ACDP-0014 §8's own
491 /// warning ("absence of search results is not evidence of absence"): a
492 /// cached *absence* is not licensed the way a cached, verified
493 /// revocation is (§7:114 licenses only the latter, indefinitely). No
494 /// cache attached makes this field inert regardless of its value.
495 ///
496 /// Distinct from — and orthogonal to — caching verified revocations
497 /// themselves ("facts"), which a [`crate::RevocationCache`] does
498 /// **unconditionally** and **indefinitely** whenever one is attached,
499 /// independent of this field: facts are always unioned into
500 /// classification (never gated behind `freshness`), because a
501 /// revocation is monotone (more revocations ⇒ an earlier effective
502 /// boundary ⇒ strictly more fail-closed verdicts), so seeding from
503 /// them can only tighten a verdict, never loosen one. `freshness`
504 /// governs only whether a lookup that would otherwise re-confirm "no
505 /// NEW revocation" is skipped. See `crate::revocation_cache` for the
506 /// full two-object model.
507 ///
508 /// That "unconditionally" is exact for a producer-signed fact (§8:
509 /// self-contained, applies at any vantage) but is scoped for a
510 /// registry-attested one (§6): the cache additionally filters those to
511 /// the vantage that minted them, so attaching one cache to clients for
512 /// two different registries does not let registry A's attestation
513 /// apply to a context served by registry B.
514 ///
515 /// A recommended ceiling, not enforced: RFC-ACDP-0006 §4.2 caps
516 /// `WebResolver`'s own DID-document cache TTL at 3600 s, and that is a
517 /// reasonable order-of-magnitude anchor for this field too — this
518 /// crate already accepts bounded key-material staleness at that
519 /// order. Left unenforced deliberately: a hard cap on a knob whose
520 /// safe default is `ZERO` would add a failure mode without adding
521 /// safety.
522 pub freshness: Duration,
523}
524
525impl RevocationDiscovery {
526 /// Discover producer-signed revocations only
527 /// (`include_registry_attested: false`). Cheapest of the two
528 /// constructors, and the default choice for callers that are not
529 /// specifically defending against a producer that has lost every
530 /// signing key — see the "No `Default`" section above for who must
531 /// NOT stop here.
532 #[must_use]
533 pub fn producer_signed_only() -> Self {
534 Self {
535 include_registry_attested: false,
536 on_failure: DiscoveryFailurePolicy::FailClosed,
537 total_timeout: Duration::from_secs(30),
538 max_requests: None,
539 max_bytes: None,
540 freshness: Duration::ZERO,
541 }
542 }
543
544 /// Discover both trust classes: producer-signed AND registry-attested
545 /// (`include_registry_attested: true`). Required to catch RFC-ACDP-0014
546 /// §6's "lost-everything" fallback, where a producer with no signing
547 /// key left can only be revoked registry-attested. Requires the
548 /// registry to serve a capabilities document.
549 #[must_use]
550 pub fn all_trust_classes() -> Self {
551 Self {
552 include_registry_attested: true,
553 on_failure: DiscoveryFailurePolicy::FailClosed,
554 total_timeout: Duration::from_secs(30),
555 max_requests: None,
556 max_bytes: None,
557 freshness: Duration::ZERO,
558 }
559 }
560}
561
562/// What to do when RFC-ACDP-0014 §8 auto-discovery itself fails (a
563/// transient search/lookup error, or the discovery search functions'
564/// own safety-cap error).
565#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
566#[non_exhaustive]
567pub enum DiscoveryFailurePolicy {
568 /// Treat a discovery failure as a verification failure
569 /// (`AcdpError::RevocationDiscoveryFailed`). Default — matches this
570 /// crate's existing bias (§7's own lineage-walk and
571 /// verification-failure paths already fail closed; see
572 /// `crate::revocation`'s doc for the parallel reasoning) that an
573 /// authorization phase which could not run is not the same thing
574 /// as one that ran and found nothing.
575 #[default]
576 FailClosed,
577 /// Proceed using only [`RevocationPolicy::known`] when discovery
578 /// fails, silently dropping whatever discovery could not complete.
579 /// Use only when availability matters more than catching a
580 /// revocation that discovery would otherwise have found.
581 ProceedWithKnown,
582}
583
584/// What RFC-ACDP-0014 §8 auto-discovery actually did, distinguishing
585/// "this trust class was not queried" from "it was queried and found
586/// nothing" — the same distinction
587/// [`RevocationDiscovery`]'s no-`Default` design protects at the
588/// config level, carried through to the outcome.
589#[derive(Debug, Clone, Copy, PartialEq, Eq)]
590#[non_exhaustive]
591pub struct DiscoveryOutcome {
592 /// Count of producer-signed revocations discovery found.
593 pub producer_signed: usize,
594 /// Count of registry-attested revocations discovery found, or
595 /// `None` if [`RevocationDiscovery::include_registry_attested`]
596 /// was `false` and that trust class was never queried.
597 pub registry_attested: Option<usize>,
598}
599
600/// How to treat the optional `registry_receipt` on retrieval
601/// (RFC-ACDP-0010).
602#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
603pub enum ReceiptPolicy {
604 /// Skip receipt verification entirely (0.1.0 behavior). The
605 /// receipt value is still preserved verbatim on the context.
606 Ignore,
607 /// Verify the receipt when one is present; absence is not an
608 /// error (the registry may simply be a 0.1.0 registry). Default.
609 #[default]
610 VerifyIfPresent,
611 /// Fail closed unless a receipt is present AND verifies. Use when
612 /// the deployment requires audit-grade provenance — registry
613 /// claims (`ctx_id`, `created_at`, `origin_registry`) are
614 /// assertions, not proofs, without a receipt.
615 ///
616 /// Honored identically by every entry point that accepts a
617 /// [`VerificationPolicy`] — `fetch_with_policy`,
618 /// `fetch_current_with_policy` (via [`LineageHeadPolicy::receipts`]),
619 /// and the `fetch_report*` family. On
620 /// [`VerifiedContext::fetch_report_diagnose`] the failure surfaces as
621 /// a withheld handle plus `VerificationReport::policy_phase_error`,
622 /// not an `Err` — see that method's doc.
623 Require,
624}
625
626/// How to treat the optional `lineage_head_receipt` on
627/// `GET /lineages/{id}/current` responses (ACDP 0.3, RFC-ACDP-0011).
628///
629/// The presence handling reuses the [`ReceiptPolicy`] vocabulary; the
630/// two numeric knobs are the RFC's consumer-side parameters:
631///
632/// - `max_clock_skew_seconds` — §7 step 6's forward-skew allowance. A
633/// receipt whose `as_of` is further in the future **fails
634/// verification** (`invalid_receipt`, fixture `lhr-004`). RFC
635/// RECOMMENDED: 120.
636/// - `max_age_seconds` — §6's freshness policy. A receipt older than
637/// this is still *verified* (it may be perfectly genuine — merely
638/// old); it is reported distinctly via
639/// [`VerifiedContext::head_receipt_stale`], never as a verification
640/// failure. RFC RECOMMENDED default: 300. `None` disables the
641/// staleness verdict.
642#[derive(Debug, Clone, Copy, PartialEq, Eq)]
643pub struct LineageHeadPolicy {
644 /// Presence handling: `Ignore` (skip verification, preserve
645 /// verbatim), `VerifyIfPresent` (default), or `Require` (fail
646 /// closed unless present AND verified — appropriate when the
647 /// registry advertises `acdp-registry-head-receipts`, under which
648 /// a head receipt on `/current` is REQUIRED, RFC-ACDP-0011 §6).
649 pub receipts: ReceiptPolicy,
650 /// RFC-ACDP-0011 §7 step 6 clock-skew allowance (default 120 s).
651 pub max_clock_skew_seconds: u32,
652 /// RFC-ACDP-0011 §6 maximum acceptable receipt age for the
653 /// staleness verdict (default `Some(300)`).
654 pub max_age_seconds: Option<u32>,
655}
656
657impl Default for LineageHeadPolicy {
658 fn default() -> Self {
659 Self {
660 receipts: ReceiptPolicy::VerifyIfPresent,
661 max_clock_skew_seconds: 120,
662 max_age_seconds: Some(300),
663 }
664 }
665}
666
667/// How to treat a producer key that is present in the DID document's
668/// `verificationMethod` but no longer in `assertionMethod` — i.e. a
669/// key the producer rotated out but retained per the RFC-ACDP-0010
670/// key-retention rule.
671#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
672pub enum HistoricalKeyPolicy {
673 /// Strict 0.1.0 behavior: only `assertionMethod` keys verify.
674 /// Every context signed by a rotated-out key fails.
675 Reject,
676 /// Accept a retained key **only** when a verified registry receipt
677 /// attests (via `key_fingerprint`) that this exact key was the
678 /// authorized one at publish time. Without a verified receipt the
679 /// historical path never activates — fail closed. Default.
680 #[default]
681 AcceptWithReceipt,
682}
683
684/// How the producer key that verified the body relates to the
685/// producer's *current* DID document.
686#[derive(Debug, Clone, Copy, PartialEq, Eq)]
687pub enum KeyAuthorization {
688 /// The signing key is currently listed in `assertionMethod`.
689 CurrentlyAuthorized,
690 /// The signing key was rotated out of `assertionMethod` but is
691 /// retained in `verificationMethod`, and a verified registry
692 /// receipt attests it was the authorized key at publish time
693 /// (RFC-ACDP-0010). Weigh accordingly: valid history, not a
694 /// current endorsement.
695 HistoricallyAuthorized,
696 /// The signing key is **revoked** (a verified RFC-ACDP-0014
697 /// revocation names its fingerprint), but a verified registry
698 /// receipt attests the context was published strictly *before* the
699 /// compromise boundary `compromised_since` — it was signed while
700 /// the key was still the producer's, and verified under the
701 /// RFC-ACDP-0010 §10 historical rule (RFC-ACDP-0014 §7 step 2).
702 ///
703 /// Deliberately distinguishable from BOTH
704 /// [`Self::CurrentlyAuthorized`] and the no-revocation
705 /// [`Self::HistoricallyAuthorized`]: the revocation and its
706 /// boundary MUST be visible in the verdict — even a key still
707 /// listed in `assertionMethod` MUST NOT be reported as fully
708 /// current once revoked. This holds for every entry point that
709 /// accepts a [`VerificationPolicy`], including the `fetch_report*`
710 /// family: they derive `key_status` from the same `verify_retrieved`
711 /// phase `fetch_with_policy` uses, so a revoked key cannot silently
712 /// surface as [`Self::CurrentlyAuthorized`] on any of them. Contexts
713 /// by the same key at/after the boundary — or with no verifiable
714 /// publish time — never reach a status at all: they fail closed
715 /// with `key_not_authorized` (§7 steps 3–4).
716 HistoricallyAuthorizedPreCompromise,
717}
718
719impl VerificationPolicy {
720 /// The v0.1.0 strict verification profile (RFC-ACDP-0001 §5.11, §9.2).
721 ///
722 /// Runs the full §5.11 pipeline: body schema validation, `content_hash`
723 /// recomputation, `did:web` key resolution, signature verification, and
724 /// embedded `data_ref.content_hash` checks. Returns on the first failure.
725 ///
726 /// This is the **only** mode covered by the `acdp-consumer` conformance
727 /// profile. Relaxed modes (`Diagnostic`, `UnsafeForTests`) are NOT
728 /// available in this crate in v0.1.0 — they would be separately-named
729 /// opt-ins per §9.2, and are not currently implemented.
730 ///
731 /// NOT identical to [`Default::default()`] as of 0.2: the default
732 /// policy is receipt-aware (`VerifyIfPresent` + `AcceptWithReceipt`),
733 /// while this named profile preserves the exact v0.1.0 semantics —
734 /// receipts inert ([`ReceiptPolicy::Ignore`]) and only
735 /// `assertionMethod` keys accepted
736 /// ([`HistoricalKeyPolicy::Reject`]). Callers pinned to this
737 /// constructor keep v0.1.0 behavior across the 0.2 upgrade.
738 pub fn strict_v0_1_0() -> Self {
739 Self {
740 validate_body_schema: true,
741 allow_unknown_status: true,
742 receipts: ReceiptPolicy::Ignore,
743 historical_keys: HistoricalKeyPolicy::Reject,
744 lineage_head: LineageHeadPolicy {
745 receipts: ReceiptPolicy::Ignore,
746 ..LineageHeadPolicy::default()
747 },
748 // A 0.1.0-pinned consumer predates RFC-ACDP-0014 and is
749 // unaffected by it (§10): no revocations enforced.
750 revocations: RevocationPolicy::default(),
751 }
752 }
753
754 /// The policy the report family (`fetch_report`,
755 /// `fetch_report_with_fetcher`, `fetch_report_diagnose`) passes to
756 /// [`VerifiedContext::verify_retrieved`].
757 ///
758 /// `validate_body_schema` is forced `false` unconditionally,
759 /// independent of the caller: P1 (schema) is always handled by the
760 /// report path itself — `validate_body_structural` plus per-`DataRef`
761 /// non-fatal recording of embedded-hash outcomes into
762 /// `VerificationReport::data_ref_embedded` — so the spine must always
763 /// skip its own full `validate_body` (structural + fatal embedded-hash
764 /// check) here. Every other field passes through verbatim. Do **not**
765 /// pass the caller's policy directly to `verify_retrieved` from a
766 /// report entry point; doing so reinstates the fatal embedded-hash
767 /// check the report path deliberately downgrades to non-fatal (see
768 /// `tests/tls_conformance.rs`'s
769 /// `fetch_report_records_embedded_hash_failure`).
770 fn derived_for_report(&self) -> Self {
771 Self {
772 validate_body_schema: false,
773 ..self.clone()
774 }
775 }
776}
777
778/// A retrieved context that has been cryptographically verified.
779///
780/// Every value of this type is the output of one of the
781/// `VerifiedContext::fetch*` pipelines, each of which independently
782/// recomputes `content_hash` (RFC-ACDP-0001 §5.11) and verifies the
783/// producer signature before the value is constructed. The fields are
784/// **private** precisely so this "cryptographically verified" invariant
785/// cannot be forged: there is no way to construct a `VerifiedContext`
786/// around an unverified [`FullContext`]. Downstream code can therefore
787/// trust the accessors below without re-deriving anything.
788#[derive(Debug)]
789pub struct VerifiedContext {
790 inner: FullContext,
791 /// Whether the body verified against a currently authorized key or
792 /// a receipt-attested historical one (ACDP 0.2, WS-B).
793 key_status: KeyAuthorization,
794 /// The verified registry receipt, when one was present and the
795 /// policy verified it (RFC-ACDP-0010). `None` under
796 /// [`ReceiptPolicy::Ignore`] or when the registry minted none.
797 verified_receipt: Option<acdp_types::receipt::RegistryReceipt>,
798 /// The verified lineage-head receipt (ACDP 0.3, RFC-ACDP-0011),
799 /// when one was present and the policy verified it. Only populated
800 /// by [`Self::fetch_current`] / [`Self::fetch_current_with_policy`]
801 /// — plain retrieval preserves the raw value verbatim without
802 /// verification. Per §7 this verdict is independent of the body
803 /// verdict and the RFC-ACDP-0010 receipt verdict.
804 verified_head_receipt: Option<acdp_types::receipt::LineageHeadReceipt>,
805 /// RFC-ACDP-0011 §6 freshness verdict for the verified head
806 /// receipt, reported distinctly from verification: `Some(true)`
807 /// when the (genuine, verified) receipt's `as_of` is older than
808 /// [`LineageHeadPolicy::max_age_seconds`]; `Some(false)` when
809 /// within policy; `None` when there is no verified head receipt or
810 /// the max-age knob is disabled.
811 head_receipt_stale: Option<bool>,
812 /// RFC-ACDP-0014 §8 auto-discovery failure, when
813 /// `policy.revocations.discover` was `Some`, discovery failed, and
814 /// [`DiscoveryFailurePolicy::ProceedWithKnown`] let verification
815 /// proceed on [`RevocationPolicy::known`] alone anyway. `None` when
816 /// discovery was off, succeeded, or was never attempted because
817 /// [`DiscoveryFailurePolicy::FailClosed`] turned the failure into
818 /// this call's `Err` instead (so no `VerifiedContext` was ever
819 /// constructed to carry it). Exposed so `ProceedWithKnown` is never
820 /// silent on the plain `fetch*` paths — see
821 /// [`Self::revocation_discovery_failure`].
822 revocation_discovery_failure: Option<AcdpError>,
823}
824
825/// `verify_retrieved`'s return type — private and free to shape (issue
826/// #248 Phase 4 grew a third tuple element for the discovery outcome).
827/// Named only to keep clippy's `type_complexity` lint quiet; every
828/// caller destructures it positionally exactly as before.
829type VerifyRetrievedResult = Result<
830 (
831 KeyAuthorization,
832 Option<acdp_types::receipt::RegistryReceipt>,
833 Option<Result<DiscoveryOutcome, AcdpError>>,
834 ),
835 AcdpError,
836>;
837
838impl VerifiedContext {
839 /// Retrieve a context and verify its signature using the strict
840 /// default [`VerificationPolicy`].
841 pub async fn fetch(
842 client: &RegistryClient,
843 resolver: &WebResolver,
844 ctx_id: &CtxId,
845 ) -> Result<Self, AcdpError> {
846 Self::fetch_with_policy(client, resolver, ctx_id, &VerificationPolicy::default()).await
847 }
848
849 /// Retrieve a context and verify its signature with caller-controlled
850 /// strictness.
851 ///
852 /// 1. Fetches `body + registry_state` from the registry.
853 /// 2. Refuses a served body whose `ctx_id` differs from the one
854 /// requested (`AcdpError::ContextIdMismatch`) — this implements
855 /// RFC-ACDP-0006 §4.1 step 7 (NORMATIVE, "Bind the resolved
856 /// identity"): neither the signature check (step 5) nor the
857 /// `content_hash` recomputation (step 6) can supply this binding,
858 /// because `ctx_id` sits in the RFC-ACDP-0001 §5.7 registry-assigned
859 /// exclusion set and is therefore stripped from ProducerContent
860 /// before hashing. See RFC-ACDP-0008 §9.1 for the threat this
861 /// closes: without it, a registry can serve any other
862 /// validly-signed body by the same producer under the requested
863 /// context's URL, and both preceding checks still pass. Step 7
864 /// permits a consumer to surface "an equivalent typed error" in
865 /// place of the registry-side `cross_registry_resolution_failed`
866 /// wire code — `ContextIdMismatch` is that typed error. This
867 /// generalizes the receipt-path analogue at RFC-ACDP-0010 §8 step 3
868 /// to the receipt-less core-profile path, where it is the only
869 /// binding available. It does **not** close §9.1 in full: a
870 /// registry that genuinely republishes the same content under a
871 /// new `ctx_id` still passes; only serve-time substitution — a
872 /// different id claimed to be the one requested — is caught.
873 /// 3. Optionally runs `validate_body` — structural schema checks
874 /// plus embedded-`DataRef` hash verification (policy-controlled).
875 /// 4. Recomputes `content_hash` over ProducerContent.
876 /// 5. Resolves the producer's DID document. `did:web` is required
877 /// unconditionally for v0.1.0 (RFC-ACDP-0001 §5.4).
878 /// 6. Verifies the Ed25519 signature (or other supported algorithm).
879 /// 7. Optionally verifies the `registry_receipt` placeholder.
880 /// 8. Optionally rejects unknown statuses.
881 pub async fn fetch_with_policy(
882 client: &RegistryClient,
883 resolver: &WebResolver,
884 ctx_id: &CtxId,
885 policy: &VerificationPolicy,
886 ) -> Result<Self, AcdpError> {
887 let ctx = client.retrieve(ctx_id).await?;
888 let (key_status, verified_receipt, revocation_discovery) =
889 Self::verify_retrieved(client, resolver, &ctx, ctx_id, policy).await?;
890 let revocation_discovery_failure = match revocation_discovery {
891 Some(Err(e)) => Some(e),
892 _ => None,
893 };
894 Ok(Self {
895 inner: ctx,
896 key_status,
897 verified_receipt,
898 verified_head_receipt: None,
899 head_receipt_stale: None,
900 revocation_discovery_failure,
901 })
902 }
903
904 /// Retrieve the current head of a lineage
905 /// (`GET /lineages/{lineage_id}/current`) and verify it with the
906 /// strict default [`VerificationPolicy`] — including the
907 /// lineage-head receipt when the registry minted one (ACDP 0.3,
908 /// RFC-ACDP-0011).
909 pub async fn fetch_current(
910 client: &RegistryClient,
911 resolver: &WebResolver,
912 lineage_id: &acdp_types::primitives::LineageId,
913 ) -> Result<Self, AcdpError> {
914 Self::fetch_current_with_policy(
915 client,
916 resolver,
917 lineage_id,
918 &VerificationPolicy::default(),
919 )
920 .await
921 }
922
923 /// Retrieve + verify the current head of a lineage with
924 /// caller-controlled strictness.
925 ///
926 /// Runs the same pipeline as [`Self::fetch_with_policy`] against
927 /// the `/current` response (the expected `ctx_id` is the served
928 /// body's own — there is no requested identifier on this endpoint;
929 /// the head receipt's §7 step 5 byte-match is what binds it), then
930 /// applies `policy.lineage_head` to the response's
931 /// `lineage_head_receipt` per RFC-ACDP-0011 §7:
932 ///
933 /// - [`ReceiptPolicy::Ignore`] — the raw value is preserved
934 /// verbatim, unverified.
935 /// - [`ReceiptPolicy::VerifyIfPresent`] — verified when present
936 /// (absence is fine: the registry may not advertise
937 /// `acdp-registry-head-receipts`).
938 /// - [`ReceiptPolicy::Require`] — fail closed with
939 /// `invalid_receipt` unless present AND verified.
940 ///
941 /// Verification fetches the registry's capabilities document for
942 /// the §7 step 3 `capabilities.registry_did` binding. Staleness
943 /// beyond `policy.lineage_head.max_age_seconds` is a *freshness*
944 /// verdict reported via [`Self::head_receipt_stale`], never a
945 /// verification failure (§6).
946 ///
947 /// [`Self::fetch_with_policy`] now additionally refuses a served body
948 /// whose `ctx_id` is not the one requested (RFC-ACDP-0008 §9.1). This
949 /// endpoint has no requested identifier to compare against — the
950 /// served head's `ctx_id` is trivially "the one requested" — so on a
951 /// receipt-less registry the served head's identity rests entirely on
952 /// registry honesty (RFC-ACDP-0008 §9.1). Use [`ReceiptPolicy::Require`]
953 /// where that matters.
954 pub async fn fetch_current_with_policy(
955 client: &RegistryClient,
956 resolver: &WebResolver,
957 lineage_id: &acdp_types::primitives::LineageId,
958 policy: &VerificationPolicy,
959 ) -> Result<Self, AcdpError> {
960 let ctx = client.current(lineage_id).await?;
961 let served_ctx_id = ctx.body.ctx_id.clone();
962 let (key_status, verified_receipt, revocation_discovery) =
963 Self::verify_retrieved(client, resolver, &ctx, &served_ctx_id, policy).await?;
964 let revocation_discovery_failure = match revocation_discovery {
965 Some(Err(e)) => Some(e),
966 _ => None,
967 };
968
969 // ── Lineage-head receipt phase (RFC-ACDP-0011) ──────────────
970 let (verified_head_receipt, head_receipt_stale) =
971 match (policy.lineage_head.receipts, &ctx.lineage_head_receipt) {
972 (ReceiptPolicy::Ignore, _) | (ReceiptPolicy::VerifyIfPresent, None) => (None, None),
973 (ReceiptPolicy::Require, None) => {
974 return Err(AcdpError::InvalidReceipt(
975 "policy requires a lineage-head receipt but the /current response \
976 carries none (registry without the acdp-registry-head-receipts \
977 profile?)"
978 .into(),
979 ));
980 }
981 (_, Some(value)) => {
982 let serving_authority = client
983 .authority()
984 .unwrap_or_else(|| served_ctx_id.authority().to_string());
985 // §7 step 3 needs capabilities.registry_did — fetched
986 // from the same authority the context came from.
987 let caps = client.capabilities().await?;
988 let receipt = super::receipt::verify_lineage_head_receipt_value(
989 value,
990 lineage_id,
991 &served_ctx_id,
992 ctx.body.version,
993 &ctx.registry_state.status,
994 true, // /current always serves the attested head
995 &serving_authority,
996 &caps.registry_did,
997 chrono::Duration::seconds(
998 policy.lineage_head.max_clock_skew_seconds as i64,
999 ),
1000 resolver,
1001 )
1002 .await?;
1003 let stale = policy.lineage_head.max_age_seconds.map(|max| {
1004 receipt.age_at(chrono::Utc::now()) > chrono::Duration::seconds(max as i64)
1005 });
1006 (Some(receipt), stale)
1007 }
1008 };
1009
1010 Ok(Self {
1011 inner: ctx,
1012 key_status,
1013 verified_receipt,
1014 verified_head_receipt,
1015 head_receipt_stale,
1016 revocation_discovery_failure,
1017 })
1018 }
1019
1020 /// The shared retrieve-side verification pipeline: body schema,
1021 /// hash recomputation, RFC-ACDP-0010 receipt phase, signature
1022 /// phase (with the receipt-gated historical-key fallback), and the
1023 /// unknown-status policy check.
1024 #[cfg_attr(
1025 feature = "tracing",
1026 tracing::instrument(
1027 name = "acdp.verify_retrieved",
1028 skip_all,
1029 fields(ctx_id = %expected_ctx_id),
1030 err(Display)
1031 )
1032 )]
1033 async fn verify_retrieved(
1034 client: &RegistryClient,
1035 resolver: &WebResolver,
1036 ctx: &FullContext,
1037 expected_ctx_id: &CtxId,
1038 policy: &VerificationPolicy,
1039 ) -> VerifyRetrievedResult {
1040 // Identifier binding — RFC-ACDP-0006 §4.1 step 7 (NORMATIVE, "Bind
1041 // the resolved identity"): refuse a served body whose `ctx_id`
1042 // differs from the one requested, before any crypto or network
1043 // work. `ctx_id` is registry-assigned and outside both the
1044 // `content_hash` and signature coverage (RFC-ACDP-0001 §5.7's
1045 // exclusion set), so this equality check is the only binding
1046 // available when no receipt is served. See RFC-ACDP-0008 §9.1 for
1047 // the threat this closes; it does not close §9.1 in full (a
1048 // genuine republish under a new `ctx_id` still passes — only
1049 // serve-time substitution is caught). Step 7 permits a
1050 // consumer-side "equivalent typed error" in place of the
1051 // registry-side `cross_registry_resolution_failed` wire code —
1052 // `ContextIdMismatch` is that typed error.
1053 if ctx.body.ctx_id != *expected_ctx_id {
1054 return Err(AcdpError::ContextIdMismatch {
1055 requested: expected_ctx_id.as_str().to_string(),
1056 served: ctx.body.ctx_id.as_str().to_string(),
1057 });
1058 }
1059
1060 if policy.validate_body_schema {
1061 acdp_validation::validate_body(&ctx.body)?;
1062 }
1063
1064 // Hash recomputation first: from here on `ctx.body.content_hash`
1065 // IS the independently recomputed value, which the receipt
1066 // cross-check below relies on.
1067 let verifier = Verifier::new(resolver);
1068 verifier.verify_body_hash(&ctx.body)?;
1069
1070 // ── Receipt phase (RFC-ACDP-0010) ───────────────────────────
1071 // Verified BEFORE the signature phase because the historical-
1072 // key path is gated on a verified receipt.
1073 let serving_authority = client
1074 .authority()
1075 .unwrap_or_else(|| expected_ctx_id.authority().to_string());
1076 let verified_receipt = match (policy.receipts, &ctx.registry_receipt) {
1077 (ReceiptPolicy::Ignore, _) | (ReceiptPolicy::VerifyIfPresent, None) => None,
1078 (ReceiptPolicy::Require, None) => {
1079 return Err(AcdpError::InvalidReceipt(
1080 "policy requires a registry receipt but the response carries none \
1081 (registry without the acdp-registry-receipts profile, or a \
1082 pre-receipts context)"
1083 .into(),
1084 ));
1085 }
1086 (_, Some(value)) => {
1087 let fingerprint = acdp_crypto::fingerprint::fingerprint_for_key_id(
1088 &ctx.body.signature.key_id,
1089 &ctx.body.signature.algorithm,
1090 resolver,
1091 )
1092 .await?;
1093 Some(
1094 super::receipt::verify_receipt_value(
1095 value,
1096 expected_ctx_id,
1097 &ctx.body,
1098 &ctx.body.content_hash,
1099 &fingerprint,
1100 &serving_authority,
1101 resolver,
1102 )
1103 .await?,
1104 )
1105 }
1106 };
1107
1108 // ── Revocation discovery phase (RFC-ACDP-0014 §8) ────────────
1109 // Runs `policy.revocations.discover`'s two independent lookups
1110 // — producer-signed always, registry-attested only when
1111 // `include_registry_attested` — CONCURRENTLY under
1112 // `tokio::try_join!`, with the pair wrapped in a single
1113 // `tokio::time::timeout(discover.total_timeout, ..)` (D4). This
1114 // puts a Tokio **time-driver requirement** (`enable_time`) on
1115 // the core verify path, gated behind `discover: Some` — the
1116 // same requirement `CrossRegistryResolver::walk_derived_from`
1117 // already carries on its opt-in walk (`cross_registry.rs`).
1118 //
1119 // Reentrancy: `find_revocations` verifies each candidate body
1120 // via `Verifier::new(resolver).verify_body` — never
1121 // `verify_retrieved` — so discovered revocation bodies are
1122 // themselves checked WITHOUT revocation checking. That is
1123 // defensible under §5 step 1's "currently authorized key," but
1124 // it is an assumption, not an accident: verifying a
1125 // `key-revocation` context now *also* triggers discovery
1126 // against the same producer, so the revocation-fetch path
1127 // itself becomes fragile under `FailClosed` (a producer whose
1128 // revocation search is briefly unreachable can no longer be
1129 // verified as revoked, either).
1130 //
1131 // A `SearchTruncated` failure and a 503 both take the
1132 // `on_failure` path, but they are NOT equivalent: truncation
1133 // means "this producer has more revocations than we will page
1134 // through" — an attacker-inducible security downgrade, since a
1135 // hostile producer/registry can pad the result set specifically
1136 // to exhaust `MAX_SEARCH_PAGES` — whereas a 503 is an ordinary
1137 // availability blip. `RevocationDiscoveryBudgetExceeded` (issue
1138 // #258, `max_requests`/`max_bytes`) is a THIRD case in the same
1139 // attacker-inducible bucket as truncation: a hostile registry
1140 // that learns a caller's budget can pad harmless-looking traffic
1141 // to exhaust it before a real revocation is found.
1142 // `ProceedWithKnown` treats all three the same way (proceed on
1143 // `known` alone, record the failure); naming the asymmetry here
1144 // is so that choice is made with open eyes.
1145 // Issue #257 (D-A / B1): `seeded_facts` is read from whatever
1146 // `RevocationCache` is attached to `client` — BEFORE any of the
1147 // discovery attempt below runs — and threaded through every arm
1148 // below UNCHANGED, including the failure arms that reset
1149 // `discovered` to `Vec::new()`. This is the load-bearing placement:
1150 // facts are a verified, permanent record (RFC-ACDP-0014 §7:114)
1151 // and MUST survive a 503, a `SearchTruncated`, a budget
1152 // exhaustion, or a `total_timeout` trip on THIS call — exactly the
1153 // failure modes an attacker can induce to try to make a client
1154 // "forget" a revocation it already saw. Computing this once, up
1155 // front, and never touching it again inside the match is what
1156 // makes that survival structural rather than a discipline: there
1157 // is no code path below that can zero it out the way `discovered`
1158 // legitimately is on failure.
1159 let (discovered, revocation_discovery, seeded_facts) = match &policy.revocations.discover {
1160 // MATERIAL-3 (fresh-Opus review of Phase 2): attaching a
1161 // `RevocationCache` IS the opt-in for anti-rollback, independent
1162 // of whether `discover` itself is configured — seed producer-
1163 // signed facts here too, not only in the `Some` arm below. This
1164 // is what extends anti-rollback protection to
1165 // `VerifiedContext::fetch`/`fetch_current` (LIM-2), which
1166 // hardcode `VerificationPolicy::default()` and so can NEVER
1167 // set `discover` at all — without this, those two entry points
1168 // would get zero benefit from an attached cache. Seeding is
1169 // monotone (facts can only tighten a verdict, never loosen
1170 // one), so doing it unconditionally is safe. With `discover:
1171 // None` there is no `include_registry_attested` to consult, so
1172 // seed producer-signed facts ONLY: registry-attested requires
1173 // the explicit opt-in `RevocationDiscovery::include_registry_attested`
1174 // carries (D6), and BLOCKER-1's origin filter would need this
1175 // call's serving vantage regardless, so staying conservative
1176 // here costs nothing.
1177 None => {
1178 let agent_id = &ctx.body.agent_id;
1179 let vantage = client.authority();
1180 let seeded_facts = client
1181 .revocation_cache()
1182 .map(|(cache, _freshness)| {
1183 cache.facts_for(agent_id.as_str(), false, vantage.as_deref())
1184 })
1185 .unwrap_or_default();
1186 (Vec::new(), None, seeded_facts)
1187 }
1188 Some(discovery) => {
1189 let agent_id = &ctx.body.agent_id;
1190 let include_attested = discovery.include_registry_attested;
1191 // BLOCKER-1: pass this call's own vantage through so
1192 // `facts_for` can filter registry-attested facts to
1193 // `origin == current vantage` (RFC-ACDP-0014 §6) —
1194 // producer-signed facts are unaffected either way (§8).
1195 let vantage = client.authority();
1196 let seeded_facts = client
1197 .revocation_cache()
1198 .map(|(cache, _freshness)| {
1199 cache.facts_for(agent_id.as_str(), include_attested, vantage.as_deref())
1200 })
1201 .unwrap_or_default();
1202 // Issue #258 (D-B): a combined request/byte budget is
1203 // enforced inside `RegistryClient`'s request methods, on
1204 // a client clone created HERE and handed to BOTH lookups
1205 // below — never on `client` itself, so a caller sharing
1206 // that original client across concurrent work never has
1207 // unrelated traffic charged to this discovery's budget.
1208 // One `DiscoveryBudget` per call means one combined
1209 // ceiling: enabling `include_registry_attested` cannot
1210 // silently double it, since both `try_join!` arms below
1211 // draw down the same counters. Issue #257: the SAME clone
1212 // additionally carries `client`'s attached
1213 // `RevocationCache` (if any, unchanged by
1214 // `with_discovery_budget`) with `freshness` overridden from
1215 // this call's `discovery.freshness` — never a
1216 // caller-facing knob, only ever set here from the
1217 // extracted `discovery` value (spine-lock safe).
1218 let budget = DiscoveryBudget::new(discovery.max_requests, discovery.max_bytes);
1219 let client = client
1220 .with_discovery_budget(budget)
1221 .with_revocation_freshness(discovery.freshness);
1222 let discovery_fut = async {
1223 tokio::try_join!(
1224 super::revocation::find_revocations(&client, resolver, agent_id),
1225 async {
1226 if include_attested {
1227 super::revocation::find_registry_attested_revocations(
1228 &client, resolver, agent_id,
1229 )
1230 .await
1231 } else {
1232 Ok(Vec::new())
1233 }
1234 },
1235 )
1236 };
1237 match tokio::time::timeout(discovery.total_timeout, discovery_fut).await {
1238 Ok(Ok((producer_signed, registry_attested))) => {
1239 let outcome = DiscoveryOutcome {
1240 producer_signed: producer_signed.len(),
1241 registry_attested: if include_attested {
1242 Some(registry_attested.len())
1243 } else {
1244 None
1245 },
1246 };
1247 let mut merged = producer_signed;
1248 merged.extend(registry_attested);
1249 (merged, Some(Ok(outcome)), seeded_facts)
1250 }
1251 Ok(Err(e)) => {
1252 let wrapped = AcdpError::RevocationDiscoveryFailed {
1253 source: Box::new(e),
1254 };
1255 match discovery.on_failure {
1256 DiscoveryFailurePolicy::FailClosed => return Err(wrapped),
1257 DiscoveryFailurePolicy::ProceedWithKnown => {
1258 (Vec::new(), Some(Err(wrapped)), seeded_facts)
1259 }
1260 }
1261 }
1262 Err(_elapsed) => {
1263 let wrapped = AcdpError::RevocationDiscoveryFailed {
1264 source: Box::new(AcdpError::CrossRegistryResolutionFailed(format!(
1265 "revocation auto-discovery exceeded total_timeout={:?}",
1266 discovery.total_timeout
1267 ))),
1268 };
1269 match discovery.on_failure {
1270 DiscoveryFailurePolicy::FailClosed => return Err(wrapped),
1271 DiscoveryFailurePolicy::ProceedWithKnown => {
1272 (Vec::new(), Some(Err(wrapped)), seeded_facts)
1273 }
1274 }
1275 }
1276 }
1277 }
1278 };
1279
1280 // ── Revocation phase (RFC-ACDP-0014 §7) ─────────────────────
1281 // Runs after the receipt phase because the boundary comparison
1282 // accepts ONLY a receipt-attested publish time (§7 step 1 —
1283 // the bare body created_at is registry-assigned and MUST NOT
1284 // be used). The verified receipt's key_fingerprint was already
1285 // cross-checked against the body's signing key above (§8 step
1286 // 5), so `verified_receipt.created_at` genuinely places THIS
1287 // key's signature in time.
1288 //
1289 // `effective` is the union of `policy.revocations.known`,
1290 // `seeded_facts` (issue #257 — the attached `RevocationCache`'s
1291 // permanent record for this producer, ALWAYS unioned in
1292 // regardless of whether this call's discovery attempt above
1293 // succeeded, failed, or was never configured at all), and
1294 // whatever `discovered` above (empty when `discover` is `None`,
1295 // or when discovery failed under `ProceedWithKnown`) —
1296 // deliberately WITHOUT deduplication: `effective_boundary` is a
1297 // `filter().map().min()` fold, so duplicate entries are inert
1298 // and two sources disagreeing resolves to the earliest
1299 // boundary, the fail-closed direction §4 mandates. Dedup is
1300 // unavailable anyway — `KeyRevocation` is not `Hash`. (The cache
1301 // itself still dedups on insert — see
1302 // `RevocationCache::record_success` — so `seeded_facts` alone
1303 // does not grow unboundedly across repeated calls; this `chain`
1304 // is simply not where that bound lives.)
1305 let effective: Vec<acdp_types::revocation::KeyRevocation> = policy
1306 .revocations
1307 .known
1308 .iter()
1309 .cloned()
1310 .chain(seeded_facts)
1311 .chain(discovered)
1312 .collect();
1313 let revocation_verdict = if effective.is_empty() {
1314 None
1315 } else {
1316 let fingerprint = acdp_crypto::fingerprint::fingerprint_for_key_id(
1317 &ctx.body.signature.key_id,
1318 &ctx.body.signature.algorithm,
1319 resolver,
1320 )
1321 .await?;
1322 super::revocation::classify_under_revocation(
1323 &effective,
1324 &fingerprint,
1325 verified_receipt.as_ref().map(|r| r.created_at),
1326 )?
1327 };
1328
1329 // ── Signature phase ──────────────────────────────────────────
1330 // Standard path enforces assertionMethod membership. A
1331 // KeyNotAuthorized failure falls back to the historical path
1332 // only under AcceptWithReceipt AND a verified receipt — the
1333 // receipt's key_fingerprint (already cross-checked against this
1334 // exact key above) is what attests publish-time authorization.
1335 let key_status = match revocation_verdict {
1336 // Pre-compromise (§7 step 2): the signature is verified
1337 // under the RFC-ACDP-0010 §10 historical rule — the key may
1338 // legitimately have left assertionMethod (and SHOULD, §9),
1339 // and even a key still in assertionMethod MUST NOT be
1340 // reported as fully current once revoked. did:key material
1341 // cannot rotate, so it takes the plain envelope path.
1342 Some(pre_compromise) => {
1343 if ctx.body.agent_id.as_str().starts_with("did:key:") {
1344 verifier.verify_body_signature(&ctx.body).await?;
1345 } else {
1346 acdp_verify::verify_body_signature_historical(&ctx.body, resolver).await?;
1347 }
1348 pre_compromise
1349 }
1350 None => match verifier.verify_body_signature(&ctx.body).await {
1351 Ok(()) => KeyAuthorization::CurrentlyAuthorized,
1352 Err(AcdpError::KeyNotAuthorized(_))
1353 if policy.historical_keys == HistoricalKeyPolicy::AcceptWithReceipt
1354 && verified_receipt.is_some() =>
1355 {
1356 acdp_verify::verify_body_signature_historical(&ctx.body, resolver).await?;
1357 KeyAuthorization::HistoricallyAuthorized
1358 }
1359 Err(e) => return Err(e),
1360 },
1361 };
1362
1363 if !policy.allow_unknown_status {
1364 if let Some(other) = ctx.registry_state.status.as_other() {
1365 return Err(AcdpError::SchemaViolation(format!(
1366 "policy.allow_unknown_status=false; registry returned '{other}'"
1367 )));
1368 }
1369 }
1370
1371 Ok((key_status, verified_receipt, revocation_discovery))
1372 }
1373
1374 /// Retrieve + verify, returning a structured [`VerificationReport`]
1375 /// alongside the verified context. Does NOT attempt external
1376 /// `DataRef` fetches — use [`Self::fetch_report_with_fetcher`] for
1377 /// that. Each `data_ref_external` slot in the returned report is
1378 /// `None`.
1379 ///
1380 /// Unlike [`Self::fetch_with_policy`], per-`DataRef` embedded-hash
1381 /// failures are recorded in the report instead of aborting the
1382 /// verification. The top-level checks (schema, body hash,
1383 /// signature) remain hard-fail: if any of them fails, the method
1384 /// returns an `AcdpError` and produces no report.
1385 ///
1386 /// For diagnostic callers that want a populated report even when
1387 /// a top-level check fails (e.g. an audit walker that needs to
1388 /// distinguish "wrong hash" from "wrong signature"), use
1389 /// [`Self::fetch_report_diagnose`] instead.
1390 pub async fn fetch_report(
1391 client: &RegistryClient,
1392 resolver: &WebResolver,
1393 ctx_id: &CtxId,
1394 policy: &VerificationPolicy,
1395 ) -> Result<(Self, VerificationReport), AcdpError> {
1396 Self::fetch_report_inner::<NoFetcher>(client, resolver, ctx_id, policy, None).await
1397 }
1398
1399 /// Diagnostic variant of [`Self::fetch_report`] that never
1400 /// short-circuits on a top-level failure — schema, body-hash, and
1401 /// signature outcomes are each recorded individually in the
1402 /// returned [`VerificationReport`]. Returns `Ok((None, report))`
1403 /// when any top-level probe failed (the report shows which one);
1404 /// `Ok((Some(verified), report))` only when every check passed
1405 /// (FEAT-05) — and "every check" now genuinely means every
1406 /// authorization phase (receipt, revocation, signature/
1407 /// historical-key, unknown-status), not just the top-level probes:
1408 /// once the probes pass, this method additionally runs the same
1409 /// `verify_retrieved` phase `fetch_with_policy` does, and withholds
1410 /// the handle — recording the cause in
1411 /// [`VerificationReport::policy_phase_error`] — if that phase fails
1412 /// too. Either way the method still returns `Ok`; it never converts
1413 /// a policy-phase failure into an `Err`.
1414 ///
1415 /// Use cases:
1416 /// - Audit walkers that need to classify failures by stage.
1417 /// - Admin tooling that wants to distinguish "hash mismatch"
1418 /// (probable tampering / encoding drift) from "signature
1419 /// verification failed" (key compromise / DID resolution
1420 /// problem).
1421 ///
1422 /// Network errors from the initial retrieval still propagate as
1423 /// `Err` — there's no body to inspect when the registry is
1424 /// unreachable. But network/DID-resolution errors that occur
1425 /// *inside* the `verify_retrieved` phase (e.g. resolving the
1426 /// fingerprint for a receipt cross-check, or the historical-key
1427 /// fallback) are caught there and land in
1428 /// [`VerificationReport::policy_phase_error`] instead of `Err`,
1429 /// same as any other phase failure — this method never
1430 /// short-circuits once retrieval has succeeded. That means a
1431 /// transient network flake at that stage can read as a policy
1432 /// rejection (`Ok((None, report))`) rather than an `Err`. A caller
1433 /// that needs to tell a flake from a genuine rejection should
1434 /// inspect `policy_phase_error`'s [`AcdpError::is_transient`].
1435 pub async fn fetch_report_diagnose(
1436 client: &RegistryClient,
1437 resolver: &WebResolver,
1438 ctx_id: &CtxId,
1439 policy: &VerificationPolicy,
1440 ) -> Result<(Option<Self>, VerificationReport), AcdpError> {
1441 let ctx = client.retrieve(ctx_id).await?;
1442 let mut report = VerificationReport {
1443 body_hash_ok: false,
1444 signature_ok: false,
1445 schema_ok: false,
1446 data_ref_embedded: Vec::with_capacity(ctx.body.data_refs.len()),
1447 data_ref_external: Vec::with_capacity(ctx.body.data_refs.len()),
1448 ctx_id_ok: ctx.body.ctx_id == *ctx_id,
1449 key_status: None,
1450 policy_phase_error: None,
1451 revocation_discovery: None,
1452 };
1453
1454 // Schema (structural) — record pass/fail.
1455 if policy.validate_body_schema {
1456 match acdp_validation::validate_body_structural(&ctx.body) {
1457 Ok(()) => report.schema_ok = true,
1458 Err(_) => { /* keep schema_ok=false; continue collecting */ }
1459 }
1460 } else {
1461 report.schema_ok = true;
1462 }
1463
1464 // Per-DataRef embedded hashes — same as fetch_report_inner.
1465 for dr in &ctx.body.data_refs {
1466 if let (Some(emb), Some(_)) = (&dr.embedded, &dr.content_hash) {
1467 let outcome = acdp_validation::verify_embedded_hash(dr)
1468 .and_then(|()| acdp_validation::embedded_decoded_bytes(emb).map(|b| b.len()));
1469 report.data_ref_embedded.push(outcome);
1470 } else {
1471 report.data_ref_embedded.push(Ok(0));
1472 }
1473 }
1474
1475 // Hash + signature recorded independently (FEAT-05).
1476 let verifier = Verifier::new(resolver);
1477 report.body_hash_ok = verifier.verify_body_hash(&ctx.body).is_ok();
1478 report.signature_ok = verifier.verify_body_signature(&ctx.body).await.is_ok();
1479
1480 // External fetches were not attempted (this method has no
1481 // fetcher param — diagnostic callers can wire their own).
1482 for _ in &ctx.body.data_refs {
1483 report.data_ref_external.push(None);
1484 }
1485
1486 // Decide whether to surface the verified handle. The probes above
1487 // are diagnostic — their whole value is continuing past failure —
1488 // but the handle is a trust assertion (`VerifiedContext`'s
1489 // invariant: "the accessors below can be trusted without
1490 // re-deriving anything"), so it is only ever issued once the real
1491 // authorization phases (receipt, revocation, signature/historical,
1492 // unknown-status) have actually run and passed through
1493 // `verify_retrieved` — never on the probes alone.
1494 let all_top_level_pass =
1495 report.schema_ok && report.body_hash_ok && report.signature_ok && report.ctx_id_ok;
1496 let verified = if all_top_level_pass {
1497 // The call MUST be hoisted out of the `match` scrutinee: in a
1498 // match, scrutinee temporaries live to the end of the match,
1499 // so the awaited future would still be holding `&ctx` inside
1500 // the arms and `Self { inner: ctx, .. }` below would fail
1501 // borrowck (E0505).
1502 let outcome = Self::verify_retrieved(
1503 client,
1504 resolver,
1505 &ctx,
1506 ctx_id,
1507 &policy.derived_for_report(),
1508 )
1509 .await; // borrow of `ctx` ends here
1510 match outcome {
1511 Ok((key_status, verified_receipt, revocation_discovery)) => {
1512 report.key_status = Some(key_status);
1513 let revocation_discovery_failure = match &revocation_discovery {
1514 Some(Err(e)) => Some(e.clone()),
1515 _ => None,
1516 };
1517 report.revocation_discovery = revocation_discovery;
1518 Some(Self {
1519 inner: ctx,
1520 key_status,
1521 verified_receipt,
1522 verified_head_receipt: None,
1523 head_receipt_stale: None,
1524 revocation_discovery_failure,
1525 })
1526 }
1527 Err(e) => {
1528 // Reports; never short-circuits — `fetch_report_diagnose`
1529 // still returns `Ok` in every case it does today.
1530 report.policy_phase_error = Some(e);
1531 None
1532 }
1533 }
1534 } else {
1535 None
1536 };
1537 Ok((verified, report))
1538 }
1539
1540 /// Retrieve + verify like [`Self::fetch_report`], and additionally
1541 /// fetch every `DataRef` whose `location` resolves through `fetcher`.
1542 /// Each external fetch outcome is recorded in `report.data_ref_external`.
1543 pub async fn fetch_report_with_fetcher<F: DataRefFetcher>(
1544 client: &RegistryClient,
1545 resolver: &WebResolver,
1546 ctx_id: &CtxId,
1547 policy: &VerificationPolicy,
1548 fetcher: &F,
1549 ) -> Result<(Self, VerificationReport), AcdpError> {
1550 Self::fetch_report_inner(client, resolver, ctx_id, policy, Some(fetcher)).await
1551 }
1552
1553 async fn fetch_report_inner<F: DataRefFetcher>(
1554 client: &RegistryClient,
1555 resolver: &WebResolver,
1556 ctx_id: &CtxId,
1557 policy: &VerificationPolicy,
1558 fetcher: Option<&F>,
1559 ) -> Result<(Self, VerificationReport), AcdpError> {
1560 let ctx = client.retrieve(ctx_id).await?;
1561
1562 // Identifier binding — RFC-ACDP-0006 §4.1 step 7 (NORMATIVE, "Bind
1563 // the resolved identity"). Same check as `verify_retrieved`,
1564 // applied here (in addition to `verify_retrieved`'s own re-check
1565 // below) so this fails before schema validation too — the early
1566 // copy preserves fail-before-schema ordering that
1567 // `tests/receipts.rs` depends on.
1568 if ctx.body.ctx_id != *ctx_id {
1569 return Err(AcdpError::ContextIdMismatch {
1570 requested: ctx_id.as_str().to_string(),
1571 served: ctx.body.ctx_id.as_str().to_string(),
1572 });
1573 }
1574
1575 let mut report = VerificationReport {
1576 body_hash_ok: false,
1577 signature_ok: false,
1578 schema_ok: false,
1579 data_ref_embedded: Vec::with_capacity(ctx.body.data_refs.len()),
1580 data_ref_external: Vec::with_capacity(ctx.body.data_refs.len()),
1581 ctx_id_ok: true,
1582 key_status: None,
1583 policy_phase_error: None,
1584 revocation_discovery: None,
1585 };
1586
1587 // Structural-only schema validation — embedded-hash checks are
1588 // intentionally skipped here so per-DataRef hash failures land
1589 // in the report (below) instead of short-circuiting the whole
1590 // verification. That's the diagnostic shape `fetch_report`
1591 // promises in its docstring.
1592 if policy.validate_body_schema {
1593 acdp_validation::validate_body_structural(&ctx.body)?;
1594 }
1595 report.schema_ok = true;
1596
1597 // Per-DataRef embedded-hash outcomes — recorded individually.
1598 for dr in &ctx.body.data_refs {
1599 if let (Some(emb), Some(_)) = (&dr.embedded, &dr.content_hash) {
1600 let outcome = acdp_validation::verify_embedded_hash(dr)
1601 .and_then(|()| acdp_validation::embedded_decoded_bytes(emb).map(|b| b.len()));
1602 report.data_ref_embedded.push(outcome);
1603 } else {
1604 report.data_ref_embedded.push(Ok(0));
1605 }
1606 }
1607
1608 // Delegate the remaining phases — content_hash recomputation,
1609 // RFC-ACDP-0010 receipt, RFC-ACDP-0014 revocation, signature (with
1610 // the historical-key fallback), and the unknown-status check — to
1611 // `verify_retrieved`, the sole reader of those policy fields. The
1612 // derived policy forces `validate_body_schema` off (P1 was already
1613 // handled, structurally-only, above) and passes everything else
1614 // through verbatim — see `VerificationPolicy::derived_for_report`.
1615 let (key_status, verified_receipt, revocation_discovery) =
1616 Self::verify_retrieved(client, resolver, &ctx, ctx_id, &policy.derived_for_report())
1617 .await?;
1618 report.body_hash_ok = true;
1619 report.signature_ok = true;
1620 report.key_status = Some(key_status);
1621 let revocation_discovery_failure = match &revocation_discovery {
1622 Some(Err(e)) => Some(e.clone()),
1623 _ => None,
1624 };
1625 report.revocation_discovery = revocation_discovery;
1626
1627 // External fetches — record per-ref outcomes when a fetcher is
1628 // supplied; otherwise leave each slot as `None` so callers can
1629 // distinguish "skipped" from "failed".
1630 for dr in &ctx.body.data_refs {
1631 let slot: Option<Result<usize, AcdpError>> = match (fetcher, &dr.location) {
1632 (Some(f), Some(_)) => Some(fetch_and_verify_data_ref(dr, f).await.map(|b| b.len())),
1633 _ => None,
1634 };
1635 report.data_ref_external.push(slot);
1636 }
1637
1638 Ok((
1639 Self {
1640 inner: ctx,
1641 key_status,
1642 verified_receipt,
1643 verified_head_receipt: None,
1644 head_receipt_stale: None,
1645 revocation_discovery_failure,
1646 },
1647 report,
1648 ))
1649 }
1650
1651 pub fn body(&self) -> &acdp_types::body::Body {
1652 &self.inner.body
1653 }
1654
1655 pub fn registry_state(&self) -> &acdp_types::body::RegistryState {
1656 &self.inner.registry_state
1657 }
1658
1659 /// The verified [`FullContext`] (body + registry state + any
1660 /// receipts) in its retrieval shape. Every field was reached only
1661 /// after this context's hash + signature were verified.
1662 pub fn full_context(&self) -> &FullContext {
1663 &self.inner
1664 }
1665
1666 /// Whether the body verified against a currently authorized key, a
1667 /// receipt-attested historical one, or a receipt-attested
1668 /// pre-compromise one (ACDP 0.2 WS-B / RFC-ACDP-0014 §7). This is
1669 /// the real verdict regardless of which `fetch*`/`fetch_report*`
1670 /// entry point produced this `VerifiedContext` — every construction
1671 /// path runs the same `verify_retrieved` phase to derive it.
1672 pub fn key_status(&self) -> KeyAuthorization {
1673 self.key_status
1674 }
1675
1676 /// The verified registry receipt (RFC-ACDP-0010), when one was
1677 /// present and the policy verified it. `None` under
1678 /// [`ReceiptPolicy::Ignore`] or when the registry minted none — this
1679 /// is exhaustive; there is no additional "or you used a report path"
1680 /// carve-out, since `fetch_report`/`fetch_report_with_fetcher`/
1681 /// `fetch_report_diagnose` verify the receipt exactly like
1682 /// `fetch_with_policy` does. For the raw on-wire value see
1683 /// [`Self::receipt`].
1684 pub fn verified_receipt(&self) -> Option<&acdp_types::receipt::RegistryReceipt> {
1685 self.verified_receipt.as_ref()
1686 }
1687
1688 /// The verified lineage-head receipt (ACDP 0.3, RFC-ACDP-0011),
1689 /// populated only by [`Self::fetch_current`] /
1690 /// [`Self::fetch_current_with_policy`] when one was present and the
1691 /// policy verified it. For the raw on-wire value see
1692 /// [`Self::lineage_head_receipt`].
1693 pub fn verified_head_receipt(&self) -> Option<&acdp_types::receipt::LineageHeadReceipt> {
1694 self.verified_head_receipt.as_ref()
1695 }
1696
1697 /// RFC-ACDP-0011 §6 freshness verdict for the verified head
1698 /// receipt: `Some(true)` when the (genuine, verified) receipt's
1699 /// `as_of` is older than [`LineageHeadPolicy::max_age_seconds`];
1700 /// `Some(false)` when within policy; `None` when there is no
1701 /// verified head receipt or the max-age knob is disabled.
1702 pub fn head_receipt_stale(&self) -> Option<bool> {
1703 self.head_receipt_stale
1704 }
1705
1706 /// RFC-ACDP-0014 §8 auto-discovery failure that
1707 /// [`DiscoveryFailurePolicy::ProceedWithKnown`] swallowed to let
1708 /// this `VerifiedContext` exist at all. `None` when discovery was
1709 /// off ([`RevocationPolicy::discover`] is `None`), succeeded, or
1710 /// was never attempted — a [`DiscoveryFailurePolicy::FailClosed`]
1711 /// failure turns into this call's `Err` instead, so there is no
1712 /// `VerifiedContext` to carry it in that case. See
1713 /// [`VerificationReport::revocation_discovery`] for the twin
1714 /// surface on the report family, populated from the same event on
1715 /// the `fetch_report*` paths.
1716 pub fn revocation_discovery_failure(&self) -> Option<&AcdpError> {
1717 self.revocation_discovery_failure.as_ref()
1718 }
1719
1720 /// Raw registry receipt value as served on the wire
1721 /// (RFC-ACDP-0010), preserved verbatim. For the verified, typed
1722 /// form see [`Self::verified_receipt`].
1723 pub fn receipt(&self) -> Option<&serde_json::Value> {
1724 self.inner.registry_receipt.as_ref()
1725 }
1726
1727 /// Raw lineage-head receipt value as served on the wire
1728 /// (RFC-ACDP-0011), preserved verbatim. For the verified, typed
1729 /// form see [`Self::verified_head_receipt`].
1730 pub fn lineage_head_receipt(&self) -> Option<&serde_json::Value> {
1731 self.inner.lineage_head_receipt.as_ref()
1732 }
1733
1734 /// Verify the registry receipt, when one is present
1735 /// (RFC-ACDP-0010).
1736 ///
1737 /// Standalone variant for contexts obtained via the report paths;
1738 /// `fetch_with_policy` already does this under
1739 /// [`ReceiptPolicy::VerifyIfPresent`]/`Require`. The serving
1740 /// authority is taken from the context's own `ctx_id` — this method
1741 /// performs no requested-id binding of its own (it has no requested
1742 /// id to compare against; it only ever sees `self.inner.body.ctx_id`),
1743 /// so deriving the serving authority this way is sound only for a
1744 /// `VerifiedContext` obtained through a pipeline that already bound
1745 /// the served `ctx_id` to the one requested. Every construction path
1746 /// does: `fetch_with_policy` and `CrossRegistryResolver::resolve`
1747 /// check it directly; `fetch_current_with_policy` does too,
1748 /// tautologically, since `/current` has no requested id to diverge
1749 /// from; `fetch_report`/`fetch_report_with_fetcher` check it and
1750 /// return `ContextIdMismatch` on failure; and `fetch_report_diagnose`
1751 /// folds it into its `all_top_level_pass` gate, so it only ever
1752 /// hands back `Some(VerifiedContext)` when `ctx_id_ok` held. All of
1753 /// these implement RFC-ACDP-0006 §4.1 step 7, so the type invariant
1754 /// — every `VerifiedContext` was bound to its requested `ctx_id` —
1755 /// holds unconditionally.
1756 ///
1757 /// Returns `Ok(None)` when no receipt is present, `Ok(Some(_))`
1758 /// with the verified receipt otherwise.
1759 ///
1760 /// The receipt cross-check (RFC-ACDP-0010 §8 step 4) relies on
1761 /// `body.content_hash` being the independently recomputed value.
1762 /// That is guaranteed by the type invariant — every
1763 /// `VerifiedContext` is built only after its constructing pipeline
1764 /// verified the body hash (`Verifier::verify_body_hash` /
1765 /// `verify_body_signed`), and the fields are private so no caller
1766 /// can substitute an unverified body — so no re-derivation is
1767 /// needed here.
1768 pub async fn verify_receipt(
1769 &self,
1770 resolver: &WebResolver,
1771 ) -> Result<Option<acdp_types::receipt::RegistryReceipt>, AcdpError> {
1772 let Some(value) = &self.inner.registry_receipt else {
1773 return Ok(None);
1774 };
1775 let fingerprint = acdp_crypto::fingerprint::fingerprint_for_key_id(
1776 &self.inner.body.signature.key_id,
1777 &self.inner.body.signature.algorithm,
1778 resolver,
1779 )
1780 .await?;
1781 let receipt = super::receipt::verify_receipt_value(
1782 value,
1783 &self.inner.body.ctx_id,
1784 &self.inner.body,
1785 &self.inner.body.content_hash,
1786 &fingerprint,
1787 self.inner.body.ctx_id.authority(),
1788 resolver,
1789 )
1790 .await?;
1791 Ok(Some(receipt))
1792 }
1793}
1794
1795/// Structured diagnostic outcome from [`VerifiedContext::fetch_report`].
1796///
1797/// Top-level booleans report the per-stage outcome of the verification
1798/// pipeline. Per-`DataRef` slots track outcomes for each entry in
1799/// `body.data_refs`, in declaration order:
1800///
1801/// - `data_ref_embedded[i]` — `Ok(decoded_size_bytes)` when the embedded
1802/// payload's `content_hash` matched; `Err` when it didn't (or the
1803/// embedded was malformed). Refs without an embedded payload or
1804/// without a declared `content_hash` produce `Ok(0)`.
1805/// - `data_ref_external[i]` — `None` when no external fetch was
1806/// attempted (either no `location` or no `fetcher` was provided);
1807/// `Some(Ok(bytes_len))` when the fetch + hash succeeded;
1808/// `Some(Err(_))` on any failure (SSRF rejection, hash mismatch,
1809/// timeout, …).
1810///
1811/// `AcdpError` implements `Clone` (see its doc), which is what lets
1812/// [`Self::revocation_discovery`]'s failure also be independently owned
1813/// by [`VerifiedContext::revocation_discovery_failure`] from the same
1814/// `fetch_report*` call; `AcdpError` still has no `PartialEq`, so
1815/// asserting on any `AcdpError`-carrying field here wants `matches!`.
1816///
1817/// `#[non_exhaustive]`: this struct has already gained a field once as a
1818/// non-optional consequence of a security fix (the RFC-ACDP-0006 §4.1
1819/// context-identity binding), and it is output-only — constructed solely
1820/// inside this crate (`verified.rs`) — so downstream loses nothing by
1821/// being unable to construct it directly. Same rationale as `SsrfReason`
1822/// in `crates/acdp-safe-http/src/lib.rs` ("future spec revisions may add
1823/// ranges"): future fields stop being breaking changes for callers that
1824/// only read this report.
1825#[derive(Debug)]
1826#[non_exhaustive]
1827pub struct VerificationReport {
1828 /// `content_hash` recomputed from the body matches the declared one.
1829 pub body_hash_ok: bool,
1830 /// The producer signature verified against the resolved DID key.
1831 pub signature_ok: bool,
1832 /// `validate_body` passed (or was disabled by policy).
1833 pub schema_ok: bool,
1834 /// Per-`DataRef` embedded-hash outcome, in `body.data_refs` order.
1835 pub data_ref_embedded: Vec<Result<usize, AcdpError>>,
1836 /// Per-`DataRef` external-fetch outcome, in `body.data_refs` order.
1837 /// `None` indicates "not attempted" (no fetcher provided or no
1838 /// `location` to fetch from).
1839 pub data_ref_external: Vec<Option<Result<usize, AcdpError>>>,
1840 /// The served body's `ctx_id` equals the one requested
1841 /// (RFC-ACDP-0006 §4.1 step 7, NORMATIVE — "Bind the resolved
1842 /// identity"). `false` means the registry served a different,
1843 /// validly-signed body under the requested id (context
1844 /// substitution); see `VerifiedContext::verify_retrieved`'s doc for
1845 /// the full rationale. This flag gates whether
1846 /// [`VerifiedContext::fetch_report_diagnose`] hands back a
1847 /// `Some(VerifiedContext)` — appended last so any positional
1848 /// construction fails loudly rather than silently binding the wrong
1849 /// field.
1850 pub ctx_id_ok: bool,
1851 /// The real P3-P6 verdict from `verify_retrieved`'s authorization
1852 /// phases (receipt, revocation, signature/historical, unknown-status),
1853 /// when they ran and all passed. `None` means either "not reached"
1854 /// (a top-level probe — schema, body hash, signature, ctx_id — failed
1855 /// first, so `verify_retrieved` was never invoked) or "the phase ran
1856 /// and failed" (see [`Self::policy_phase_error`] for which one).
1857 pub key_status: Option<KeyAuthorization>,
1858 /// Which of `verify_retrieved`'s policy-governed phases (receipt,
1859 /// revocation, signature/historical-key, unknown-status) failed, when
1860 /// one did. `None` when every phase passed, or when `verify_retrieved`
1861 /// was never invoked because a top-level probe failed first.
1862 /// `AcdpError` derives `Clone` (see its doc — added for this field's
1863 /// and [`VerifiedContext::revocation_discovery_failure`]'s sake), but
1864 /// asserting on it still wants `matches!` over `==`/`assert_eq!`:
1865 /// `AcdpError` has no `PartialEq`.
1866 pub policy_phase_error: Option<AcdpError>,
1867 /// What RFC-ACDP-0014 §8 auto-discovery did, when
1868 /// `policy.revocations.discover` was `Some` and `verify_retrieved`
1869 /// was reached (a top-level probe failure or a
1870 /// [`DiscoveryFailurePolicy::FailClosed`] discovery failure both
1871 /// leave this `None` — the latter surfaces via
1872 /// [`Self::policy_phase_error`] instead, since `verify_retrieved`
1873 /// returned `Err` before there was any outcome to record). `Some(Ok(_))`
1874 /// on success; `Some(Err(_))` when
1875 /// [`DiscoveryFailurePolicy::ProceedWithKnown`] swallowed a
1876 /// discovery failure and verification proceeded on
1877 /// [`RevocationPolicy::known`] alone — see
1878 /// [`VerifiedContext::revocation_discovery_failure`] for the twin
1879 /// surface on the verified handle itself, populated from the same
1880 /// event. The counts inside [`DiscoveryOutcome`] are discovery
1881 /// output only, never `known` — appended last, after
1882 /// `policy_phase_error`, for the same "fail loudly on stale
1883 /// positional construction" reason that field was.
1884 pub revocation_discovery: Option<Result<DiscoveryOutcome, AcdpError>>,
1885}
1886
1887/// Sentinel `DataRefFetcher` used as the type parameter for
1888/// `fetch_report_inner` when no fetcher is supplied. `fetch` is never
1889/// actually called — the option is matched out before that — but
1890/// providing a real impl lets the generic monomorphize cleanly without
1891/// requiring `fetch_report`'s callers to name a type.
1892struct NoFetcher;
1893
1894impl DataRefFetcher for NoFetcher {
1895 async fn fetch(
1896 &self,
1897 _location: &acdp_types::data_ref::Location,
1898 ) -> Result<Vec<u8>, AcdpError> {
1899 Err(AcdpError::NotImplemented(
1900 "NoFetcher should never be called — this is a fetch_report sentinel".into(),
1901 ))
1902 }
1903}
1904
1905#[cfg(test)]
1906mod tests {
1907 use super::{
1908 DiscoveryFailurePolicy, HistoricalKeyPolicy, ReceiptPolicy, RevocationDiscovery,
1909 RevocationPolicy, VerificationPolicy,
1910 };
1911 use acdp_primitives::error::AcdpError;
1912 use std::time::Duration;
1913
1914 /// The RFC-ACDP-0001 §9.2 named constructor preserves exact v0.1.0
1915 /// semantics: receipts inert, assertionMethod-only keys. It is
1916 /// deliberately NOT the 0.2 default (which is receipt-aware).
1917 #[test]
1918 fn strict_v0_1_0_preserves_v0_1_0_semantics() {
1919 let strict = VerificationPolicy::strict_v0_1_0();
1920 assert!(strict.validate_body_schema);
1921 assert!(strict.allow_unknown_status);
1922 assert_eq!(strict.receipts, ReceiptPolicy::Ignore);
1923 assert_eq!(strict.historical_keys, HistoricalKeyPolicy::Reject);
1924 assert!(
1925 strict.revocations.known.is_empty(),
1926 "a 0.1.0-pinned consumer is unaffected by RFC-ACDP-0014"
1927 );
1928 assert_ne!(
1929 strict,
1930 VerificationPolicy::default(),
1931 "the 0.2 default is receipt-aware; the v0.1.0 profile is not"
1932 );
1933 }
1934
1935 /// Phase 2 acceptance criterion 6 — the spine lock.
1936 ///
1937 /// `verify_retrieved` must be the SOLE reader of the four
1938 /// authorization-policy fields (`receipts`, `revocations`,
1939 /// `historical_keys`, `allow_unknown_status`) anywhere in this file.
1940 /// Every public entry point (the four `fetch*` forms plus the three
1941 /// report forms) reaches every authorization phase through that one
1942 /// function, so a future RFC phase added anywhere else — instead of
1943 /// inside `verify_retrieved` — trips this test instead of silently
1944 /// reintroducing the exact divergence this phase fixed.
1945 ///
1946 /// Implemented as a plain `str` scan (no `regex` — it is not a
1947 /// dependency of `acdp-client`) over this file's own source, read via
1948 /// `include_str!`. `verify_retrieved`'s body span is located by
1949 /// brace-counting from its own opening brace (its signature has no
1950 /// braces of its own — only angle brackets in the return type — so
1951 /// the first `{` after the `fn` keyword IS the body's opening brace),
1952 /// not by hard-coded line numbers, so the check survives any diff.
1953 /// Lines whose trimmed start is `//` (covers `///` too), and matches
1954 /// that fall inside a string literal (detected by an odd count of
1955 /// unescaped `"` before the match on its line — this file's one
1956 /// in-string occurrence, the `allow_unknown_status=false` error
1957 /// message, already lives inside `verify_retrieved` regardless), are
1958 /// excluded.
1959 ///
1960 /// The four search patterns are built by runtime concatenation
1961 /// (`policy.` + each field name) rather than written as contiguous
1962 /// `"policy.receipts"`-style literals, so this test's own source —
1963 /// included verbatim via `include_str!` — does not self-match its
1964 /// own patterns.
1965 #[test]
1966 fn verify_retrieved_is_sole_reader_of_authorization_policy_fields() {
1967 const SRC: &str = include_str!("verified.rs");
1968
1969 let policy_prefix = "policy.";
1970 let fields = [
1971 "receipts",
1972 "revocations",
1973 "historical_keys",
1974 "allow_unknown_status",
1975 ];
1976 let patterns: Vec<String> = fields
1977 .iter()
1978 .map(|f| format!("{policy_prefix}{f}"))
1979 .collect();
1980
1981 // Locate `verify_retrieved`'s body span.
1982 let fn_start = SRC
1983 .find("async fn verify_retrieved(")
1984 .expect("verify_retrieved must exist in verified.rs");
1985 let body_open = fn_start
1986 + SRC[fn_start..]
1987 .find('{')
1988 .expect("verify_retrieved must have a body");
1989 let mut depth = 0i32;
1990 let mut body_close = None;
1991 for (i, ch) in SRC[body_open..].char_indices() {
1992 match ch {
1993 '{' => depth += 1,
1994 '}' => {
1995 depth -= 1;
1996 if depth == 0 {
1997 body_close = Some(body_open + i);
1998 break;
1999 }
2000 }
2001 _ => {}
2002 }
2003 }
2004 let body_close =
2005 body_close.expect("verify_retrieved's matching closing brace must be found");
2006 assert!(
2007 body_close > body_open,
2008 "sanity: verify_retrieved's body must be non-empty"
2009 );
2010
2011 // Scan the whole file, tracking byte offsets so each match's
2012 // position can be tested against the body span.
2013 let mut offset = 0usize;
2014 let mut checked_any = false;
2015 for line in SRC.split_inclusive('\n') {
2016 let trimmed = line.trim_start();
2017 let is_comment_line = trimmed.starts_with("//");
2018 if !is_comment_line {
2019 for pattern in &patterns {
2020 let mut search_from = 0usize;
2021 while let Some(rel) = line[search_from..].find(pattern.as_str()) {
2022 let match_col = search_from + rel;
2023 let match_start = offset + match_col;
2024 let before = &line[..match_col];
2025 let in_string_literal = before.matches('"').count() % 2 == 1;
2026 if !in_string_literal {
2027 checked_any = true;
2028 assert!(
2029 match_start >= body_open && match_start < body_close,
2030 "found `{pattern}` outside verify_retrieved's body \
2031 (byte offset {match_start}, line: {line:?}) — every \
2032 authorization-policy-field read must live inside \
2033 verify_retrieved, the sole reader"
2034 );
2035 }
2036 search_from = match_col + pattern.len();
2037 }
2038 }
2039 }
2040 offset += line.len();
2041 }
2042
2043 // Second pass — whitespace-normalized, to catch a read rustfmt
2044 // has wrapped across lines (e.g. a `policy` / `.revocations` /
2045 // `.known` chain on three separate lines), which the
2046 // line-by-line pass above cannot see since the pattern never
2047 // sits contiguously on any single line. Build a copy of `SRC`
2048 // with whitespace immediately touching a `.` removed — folding
2049 // any such wrapped chain back to its unwrapped spelling — while
2050 // recording, for every byte kept, the byte offset it came from
2051 // in the original `SRC`. Every match here is re-validated
2052 // against its ORIGINAL line for the same comment / string
2053 // exclusions the first pass applies, so this pass only adds
2054 // coverage; it does not relax anything the first pass enforces.
2055 let mut normalized = String::with_capacity(SRC.len());
2056 let mut orig_offsets: Vec<usize> = Vec::with_capacity(SRC.len());
2057 let mut after_dot = false;
2058 for (i, ch) in SRC.char_indices() {
2059 if ch == '.' {
2060 while let Some(last) = normalized.chars().last() {
2061 if !last.is_whitespace() {
2062 break;
2063 }
2064 normalized.pop();
2065 let new_len = orig_offsets.len() - last.len_utf8();
2066 orig_offsets.truncate(new_len);
2067 }
2068 normalized.push(ch);
2069 orig_offsets.push(i);
2070 after_dot = true;
2071 continue;
2072 }
2073 if after_dot && ch.is_whitespace() {
2074 continue; // swallow whitespace immediately after a dot
2075 }
2076 after_dot = false;
2077 normalized.push(ch);
2078 for _ in 0..ch.len_utf8() {
2079 orig_offsets.push(i);
2080 }
2081 }
2082 debug_assert_eq!(normalized.len(), orig_offsets.len());
2083
2084 let is_comment_line_at = |pos: usize| -> bool {
2085 let line_start = SRC[..pos].rfind('\n').map_or(0, |i| i + 1);
2086 let line_end = SRC[pos..].find('\n').map_or(SRC.len(), |i| pos + i);
2087 SRC[line_start..line_end].trim_start().starts_with("//")
2088 };
2089 let in_string_literal_at = |pos: usize| -> bool {
2090 let line_start = SRC[..pos].rfind('\n').map_or(0, |i| i + 1);
2091 SRC[line_start..pos].matches('"').count() % 2 == 1
2092 };
2093
2094 for pattern in &patterns {
2095 let mut search_from = 0usize;
2096 while let Some(rel) = normalized[search_from..].find(pattern.as_str()) {
2097 let match_col = search_from + rel;
2098 let orig_start = orig_offsets[match_col];
2099 if !is_comment_line_at(orig_start) && !in_string_literal_at(orig_start) {
2100 checked_any = true;
2101 assert!(
2102 orig_start >= body_open && orig_start < body_close,
2103 "found `{pattern}` (whitespace-normalized) outside \
2104 verify_retrieved's body (original byte offset {orig_start}) \
2105 — every authorization-policy-field read must live inside \
2106 verify_retrieved, the sole reader, even when rustfmt has \
2107 wrapped the field-access chain across multiple lines"
2108 );
2109 }
2110 search_from = match_col + pattern.len();
2111 }
2112 }
2113
2114 assert!(
2115 checked_any,
2116 "sanity: the scan must find at least one non-comment, non-string-literal \
2117 match for at least one pattern (verify_retrieved itself reads these \
2118 fields) — zero hits would mean the patterns are miscomputed, not that \
2119 the invariant holds"
2120 );
2121 }
2122
2123 /// issue #248 Phase 2, acceptance criterion 1 — `RevocationPolicy::default()`
2124 /// stays behaviorally identical to the pre-Phase-2 shape: no known
2125 /// revocations, discovery off.
2126 #[test]
2127 fn revocation_policy_default_is_unchanged() {
2128 let policy = RevocationPolicy::default();
2129 assert!(policy.known.is_empty());
2130 assert!(policy.discover.is_none());
2131 }
2132
2133 /// issue #248 Phase 2, acceptance criterion 1 (continued) — `::new`
2134 /// produces the same shape as the old bare-literal `RevocationPolicy { known }`
2135 /// this struct's `#[non_exhaustive]` retires.
2136 #[test]
2137 fn revocation_policy_new_leaves_discovery_off() {
2138 let policy = RevocationPolicy::new(vec![]);
2139 assert!(policy.known.is_empty());
2140 assert!(policy.discover.is_none());
2141 }
2142
2143 /// issue #248 Phase 2, acceptance criterion 3.
2144 #[test]
2145 fn discovery_failure_policy_defaults_to_fail_closed() {
2146 assert_eq!(
2147 DiscoveryFailurePolicy::default(),
2148 DiscoveryFailurePolicy::FailClosed
2149 );
2150 }
2151
2152 /// issue #248 Phase 2, acceptance criterion 3.
2153 #[test]
2154 fn revocation_discovery_constructors_set_the_right_trust_classes() {
2155 let producer_only = RevocationDiscovery::producer_signed_only();
2156 assert!(!producer_only.include_registry_attested);
2157 assert_eq!(producer_only.on_failure, DiscoveryFailurePolicy::FailClosed);
2158 assert_eq!(producer_only.total_timeout, Duration::from_secs(30));
2159 assert_eq!(
2160 producer_only.freshness,
2161 Duration::ZERO,
2162 "issue #257: freshness defaults to ZERO (off) from both named constructors"
2163 );
2164
2165 let all = RevocationDiscovery::all_trust_classes();
2166 assert!(all.include_registry_attested);
2167 assert_eq!(all.on_failure, DiscoveryFailurePolicy::FailClosed);
2168 assert_eq!(all.total_timeout, Duration::from_secs(30));
2169 assert_eq!(all.freshness, Duration::ZERO);
2170 }
2171
2172 /// issue #248 Phase 2, acceptance criterion 3 (continued) — D6:
2173 /// `RevocationDiscovery` has no `Default` impl. This is a
2174 /// compile-time property, not something a runtime assertion can
2175 /// check; the doc comment on `RevocationDiscovery` records why.
2176 /// This test exists to make that guarantee discoverable from the
2177 /// test suite: if a future change adds `Default`, this comment is
2178 /// the tripwire a reviewer reads, since nothing here would fail.
2179 /// (An attempted `RevocationDiscovery::default()` call would be a
2180 /// compile error today — that IS the enforcement.)
2181 #[test]
2182 fn revocation_discovery_has_no_default_by_design() {
2183 // Deliberately empty: the guarantee is enforced by the type
2184 // system (no `Default` impl exists), not by this test body.
2185 }
2186
2187 /// issue #248 Phase 2, acceptance criterion 4 — `RevocationDiscoveryFailed`
2188 /// delegates `is_transient` to its `source`, both ways.
2189 #[test]
2190 fn revocation_discovery_failed_delegates_is_transient_to_source() {
2191 let transient = AcdpError::RevocationDiscoveryFailed {
2192 source: Box::new(AcdpError::KeyResolutionUnreachable(
2193 "did:web host unreachable".into(),
2194 )),
2195 };
2196 assert!(
2197 transient.is_transient(),
2198 "a transient source must make the wrapper transient too"
2199 );
2200
2201 let permanent = AcdpError::RevocationDiscoveryFailed {
2202 source: Box::new(AcdpError::InvalidSignature("bad signature".into())),
2203 };
2204 assert!(
2205 !permanent.is_transient(),
2206 "a permanent source must make the wrapper permanent too"
2207 );
2208 }
2209}