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