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