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;
9
10/// Consumer-tunable strictness for [`VerifiedContext::fetch_with_policy`],
11/// [`VerifiedContext::fetch_current_with_policy`], and the
12/// `fetch_report*` family ([`VerifiedContext::fetch_report`],
13/// [`VerifiedContext::fetch_report_with_fetcher`],
14/// [`VerifiedContext::fetch_report_diagnose`]). All three surfaces
15/// consult the same policy fields through the same `verify_retrieved`
16/// spine — but they do not always *agree*, because
17/// [`VerifiedContext::fetch_report_diagnose`] differs in more than just
18/// how a failure surfaces:
19///
20/// - [`VerifiedContext::fetch_report`] and
21/// [`VerifiedContext::fetch_report_with_fetcher`] run
22/// `verify_retrieved` directly once their own top-level probes pass,
23/// and surface a phase failure as `Err`.
24/// - [`VerifiedContext::fetch_report_diagnose`] runs its own
25/// independent, strict, assertionMethod-only signature *probe* first
26/// (recorded as `VerificationReport::signature_ok`). That probe has
27/// no historical-key fallback and runs *before* `verify_retrieved` is
28/// ever invoked. If it fails, `diagnose` withholds the
29/// [`VerifiedContext`] handle with `policy_phase_error: None` — the
30/// spine never ran, so there is no phase error to record — even in
31/// cases where `verify_retrieved` itself, as run by `fetch_report`,
32/// would have accepted the key historically under the default
33/// `historical_keys: HistoricalKeyPolicy::AcceptWithReceipt` plus a
34/// verified receipt. Concretely: for a key rotated out of
35/// `assertionMethod` with a valid receipt, `fetch_report` returns
36/// `Ok` with [`KeyAuthorization::HistoricallyAuthorized`], while
37/// `diagnose` returns no handle at all for the same input and policy.
38/// Only once `diagnose`'s own probes all pass does it fall through to
39/// `verify_retrieved` and, from that point on, withhold the handle /
40/// record [`VerificationReport::policy_phase_error`] instead of
41/// returning `Err` — that part of the behavior *is* shared with the
42/// other two.
43///
44/// For ACDP v0.1.0 the verification profile is **always strict**:
45///
46/// - `did:web` is required for every producer identity — enforced
47/// unconditionally by `verify_signature_envelope`
48/// (RFC-ACDP-0001 §5.4), regardless of any policy field.
49/// - Embedded `DataRef` hashes are verified by
50/// [`acdp_validation::validate_body`] whenever `validate_body_schema`
51/// is set.
52///
53/// Only the fields below have real effect in this version; there are no
54/// relaxed-mode `did:web` or embedded-hash knobs.
55#[derive(Debug, Clone, PartialEq, Eq)]
56pub struct VerificationPolicy {
57 /// If true, run [`acdp_validation::validate_body`] (structural
58 /// schema checks plus embedded-`DataRef` hash verification) before
59 /// any cryptographic check. Default `true`. Set `false` only in
60 /// diagnostic paths that want to attempt signature verification
61 /// despite a body known to fail structural checks.
62 ///
63 /// The `fetch_report*` family forces this field off unconditionally
64 /// on the internal policy it derives from the caller's — they run
65 /// `validate_body_structural` (schema only) themselves and record
66 /// per-`DataRef` embedded-hash outcomes in
67 /// [`VerificationReport::data_ref_embedded`] instead of treating a
68 /// mismatch as fatal. This field's value as set by the caller is
69 /// otherwise irrelevant to the report family.
70 pub validate_body_schema: bool,
71
72 /// If true, accept `Status::Other` values (degrade to active per
73 /// RFC-ACDP-0004 §4.1). When false, reject unknown statuses.
74 /// Default `true`.
75 pub allow_unknown_status: bool,
76
77 /// Registry-receipt handling (ACDP 0.2, RFC-ACDP-0010).
78 /// Default [`ReceiptPolicy::VerifyIfPresent`].
79 pub receipts: ReceiptPolicy,
80
81 /// Historical-key handling (ACDP 0.2, WS-B). Default
82 /// [`HistoricalKeyPolicy::AcceptWithReceipt`].
83 pub historical_keys: HistoricalKeyPolicy,
84
85 /// Lineage-head receipt handling on `/current` fetches (ACDP 0.3,
86 /// RFC-ACDP-0011). Only consulted by
87 /// [`VerifiedContext::fetch_current_with_policy`]; plain retrieval
88 /// preserves any `lineage_head_receipt` verbatim without verifying
89 /// it. Default [`LineageHeadPolicy::default`]. This is the ONE
90 /// field on this struct with restricted scope — `allow_unknown_status`,
91 /// `receipts`, `historical_keys`, and `revocations` above and below
92 /// are each honored by every entry point that accepts a
93 /// [`VerificationPolicy`], including the `fetch_report*` family.
94 pub lineage_head: LineageHeadPolicy,
95
96 /// Key-revocation handling (ACDP 0.3, RFC-ACDP-0014 §7). Default:
97 /// no known revocations — the phase is inert.
98 pub revocations: RevocationPolicy,
99}
100
101impl Default for VerificationPolicy {
102 fn default() -> Self {
103 Self {
104 validate_body_schema: true,
105 allow_unknown_status: true,
106 receipts: ReceiptPolicy::VerifyIfPresent,
107 historical_keys: HistoricalKeyPolicy::AcceptWithReceipt,
108 lineage_head: LineageHeadPolicy::default(),
109 revocations: RevocationPolicy::default(),
110 }
111 }
112}
113
114/// Consumer-held key revocations to enforce during verification
115/// (ACDP 0.3, RFC-ACDP-0014 §7).
116///
117/// The revocation signal is **pull-based**: the pipeline does not go
118/// looking for revocations on its own — the caller supplies the
119/// **verified** revocations it holds (from
120/// [`find_revocations`](crate::revocation::find_revocations),
121/// [`find_registry_attested_revocations`](crate::revocation::find_registry_attested_revocations),
122/// an out-of-band channel, or its own indefinite cache — the statement
123/// is permanent, cache accordingly). When `known` is empty the phase
124/// is inert and verification behaves exactly as before RFC-ACDP-0014.
125///
126/// When the body's signing key matches a supplied revocation, §7
127/// applies: a receipt-attested publish time strictly before the
128/// (earliest, §4) `compromised_since` boundary verifies as
129/// [`KeyAuthorization::HistoricallyAuthorizedPreCompromise`]; at/after
130/// the boundary, or with no verified receipt to place the context at
131/// all, verification **fails closed** with `key_not_authorized` —
132/// regardless of DID-document state and regardless of the receipt's
133/// own validity. Note the interaction with [`ReceiptPolicy::Ignore`]:
134/// an unverified receipt provides no publish time, so a revoked key's
135/// contexts all fail closed under it.
136///
137/// This applies uniformly to every entry point that accepts a
138/// [`VerificationPolicy`] — [`VerifiedContext::fetch_with_policy`],
139/// [`VerifiedContext::fetch_current_with_policy`], and the
140/// `fetch_report*` family — since they all reach this phase through the
141/// same internal pipeline. On [`VerifiedContext::fetch_report_diagnose`]
142/// specifically, "fails closed" means the returned [`VerifiedContext`]
143/// handle is withheld and the cause is recorded in
144/// `VerificationReport::policy_phase_error`, rather than the call
145/// returning `Err` — that method never short-circuits on a policy-phase
146/// failure by design.
147///
148/// Only put revocations here that you have verified (strict body
149/// pipeline + the §5 not-self-signed rule) and, per §6, that you have
150/// decided to act on: producer-signed ones unconditionally;
151/// registry-attested ones ([`RevocationTrustClass::RegistryAttested`](acdp_types::revocation::RevocationTrustClass))
152/// by default only for contexts served by or receipted by that same
153/// registry, with corroboration before global application.
154///
155/// [`find_revocations`](crate::revocation::find_revocations) itself
156/// pre-filters its output to [`RevocationTrustClass::ProducerSigned`](acdp_types::revocation::RevocationTrustClass)
157/// entries actually published by the queried producer, so §6
158/// registry-attested attestations never arrive through it — obtain
159/// those from
160/// [`find_registry_attested_revocations`](crate::revocation::find_registry_attested_revocations)
161/// instead.
162#[derive(Debug, Clone, PartialEq, Eq, Default)]
163pub struct RevocationPolicy {
164 /// Verified revocations to enforce, matched against the signing
165 /// key's RFC-ACDP-0010 §6 fingerprint. The §4 earliest-
166 /// `compromised_since` rule is applied across entries naming the
167 /// same fingerprint, so include *every* revocation of a lineage,
168 /// superseded (and retracted) ones too — a later member can only
169 /// widen the compromise window, never narrow it, and dropping an
170 /// earlier one is exactly how that window gets quietly (and
171 /// wrongly) shrunk. This is no longer an unassisted obligation:
172 /// [`find_revocations`](crate::revocation::find_revocations) and
173 /// [`find_registry_attested_revocations`](crate::revocation::find_registry_attested_revocations)
174 /// each walk the full lineage of every candidate they find
175 /// (search-visible or not, including all-retracted lineages) and
176 /// already return the complete set for their respective trust
177 /// class; [`find_revocations_in_lineage`](crate::revocation::find_revocations_in_lineage)
178 /// does the same directly from a known `lineage_id`, with no
179 /// producer/trust-class scope filter. Populate `known` from one of
180 /// these rather than hand-assembling a lineage.
181 pub known: Vec<acdp_types::revocation::KeyRevocation>,
182}
183
184/// How to treat the optional `registry_receipt` on retrieval
185/// (RFC-ACDP-0010).
186#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
187pub enum ReceiptPolicy {
188 /// Skip receipt verification entirely (0.1.0 behavior). The
189 /// receipt value is still preserved verbatim on the context.
190 Ignore,
191 /// Verify the receipt when one is present; absence is not an
192 /// error (the registry may simply be a 0.1.0 registry). Default.
193 #[default]
194 VerifyIfPresent,
195 /// Fail closed unless a receipt is present AND verifies. Use when
196 /// the deployment requires audit-grade provenance — registry
197 /// claims (`ctx_id`, `created_at`, `origin_registry`) are
198 /// assertions, not proofs, without a receipt.
199 ///
200 /// Honored identically by every entry point that accepts a
201 /// [`VerificationPolicy`] — `fetch_with_policy`,
202 /// `fetch_current_with_policy` (via [`LineageHeadPolicy::receipts`]),
203 /// and the `fetch_report*` family. On
204 /// [`VerifiedContext::fetch_report_diagnose`] the failure surfaces as
205 /// a withheld handle plus `VerificationReport::policy_phase_error`,
206 /// not an `Err` — see that method's doc.
207 Require,
208}
209
210/// How to treat the optional `lineage_head_receipt` on
211/// `GET /lineages/{id}/current` responses (ACDP 0.3, RFC-ACDP-0011).
212///
213/// The presence handling reuses the [`ReceiptPolicy`] vocabulary; the
214/// two numeric knobs are the RFC's consumer-side parameters:
215///
216/// - `max_clock_skew_seconds` — §7 step 6's forward-skew allowance. A
217/// receipt whose `as_of` is further in the future **fails
218/// verification** (`invalid_receipt`, fixture `lhr-004`). RFC
219/// RECOMMENDED: 120.
220/// - `max_age_seconds` — §6's freshness policy. A receipt older than
221/// this is still *verified* (it may be perfectly genuine — merely
222/// old); it is reported distinctly via
223/// [`VerifiedContext::head_receipt_stale`], never as a verification
224/// failure. RFC RECOMMENDED default: 300. `None` disables the
225/// staleness verdict.
226#[derive(Debug, Clone, Copy, PartialEq, Eq)]
227pub struct LineageHeadPolicy {
228 /// Presence handling: `Ignore` (skip verification, preserve
229 /// verbatim), `VerifyIfPresent` (default), or `Require` (fail
230 /// closed unless present AND verified — appropriate when the
231 /// registry advertises `acdp-registry-head-receipts`, under which
232 /// a head receipt on `/current` is REQUIRED, RFC-ACDP-0011 §6).
233 pub receipts: ReceiptPolicy,
234 /// RFC-ACDP-0011 §7 step 6 clock-skew allowance (default 120 s).
235 pub max_clock_skew_seconds: u32,
236 /// RFC-ACDP-0011 §6 maximum acceptable receipt age for the
237 /// staleness verdict (default `Some(300)`).
238 pub max_age_seconds: Option<u32>,
239}
240
241impl Default for LineageHeadPolicy {
242 fn default() -> Self {
243 Self {
244 receipts: ReceiptPolicy::VerifyIfPresent,
245 max_clock_skew_seconds: 120,
246 max_age_seconds: Some(300),
247 }
248 }
249}
250
251/// How to treat a producer key that is present in the DID document's
252/// `verificationMethod` but no longer in `assertionMethod` — i.e. a
253/// key the producer rotated out but retained per the RFC-ACDP-0010
254/// key-retention rule.
255#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
256pub enum HistoricalKeyPolicy {
257 /// Strict 0.1.0 behavior: only `assertionMethod` keys verify.
258 /// Every context signed by a rotated-out key fails.
259 Reject,
260 /// Accept a retained key **only** when a verified registry receipt
261 /// attests (via `key_fingerprint`) that this exact key was the
262 /// authorized one at publish time. Without a verified receipt the
263 /// historical path never activates — fail closed. Default.
264 #[default]
265 AcceptWithReceipt,
266}
267
268/// How the producer key that verified the body relates to the
269/// producer's *current* DID document.
270#[derive(Debug, Clone, Copy, PartialEq, Eq)]
271pub enum KeyAuthorization {
272 /// The signing key is currently listed in `assertionMethod`.
273 CurrentlyAuthorized,
274 /// The signing key was rotated out of `assertionMethod` but is
275 /// retained in `verificationMethod`, and a verified registry
276 /// receipt attests it was the authorized key at publish time
277 /// (RFC-ACDP-0010). Weigh accordingly: valid history, not a
278 /// current endorsement.
279 HistoricallyAuthorized,
280 /// The signing key is **revoked** (a verified RFC-ACDP-0014
281 /// revocation names its fingerprint), but a verified registry
282 /// receipt attests the context was published strictly *before* the
283 /// compromise boundary `compromised_since` — it was signed while
284 /// the key was still the producer's, and verified under the
285 /// RFC-ACDP-0010 §10 historical rule (RFC-ACDP-0014 §7 step 2).
286 ///
287 /// Deliberately distinguishable from BOTH
288 /// [`Self::CurrentlyAuthorized`] and the no-revocation
289 /// [`Self::HistoricallyAuthorized`]: the revocation and its
290 /// boundary MUST be visible in the verdict — even a key still
291 /// listed in `assertionMethod` MUST NOT be reported as fully
292 /// current once revoked. This holds for every entry point that
293 /// accepts a [`VerificationPolicy`], including the `fetch_report*`
294 /// family: they derive `key_status` from the same `verify_retrieved`
295 /// phase `fetch_with_policy` uses, so a revoked key cannot silently
296 /// surface as [`Self::CurrentlyAuthorized`] on any of them. Contexts
297 /// by the same key at/after the boundary — or with no verifiable
298 /// publish time — never reach a status at all: they fail closed
299 /// with `key_not_authorized` (§7 steps 3–4).
300 HistoricallyAuthorizedPreCompromise,
301}
302
303impl VerificationPolicy {
304 /// The v0.1.0 strict verification profile (RFC-ACDP-0001 §5.11, §9.2).
305 ///
306 /// Runs the full §5.11 pipeline: body schema validation, `content_hash`
307 /// recomputation, `did:web` key resolution, signature verification, and
308 /// embedded `data_ref.content_hash` checks. Returns on the first failure.
309 ///
310 /// This is the **only** mode covered by the `acdp-consumer` conformance
311 /// profile. Relaxed modes (`Diagnostic`, `UnsafeForTests`) are NOT
312 /// available in this crate in v0.1.0 — they would be separately-named
313 /// opt-ins per §9.2, and are not currently implemented.
314 ///
315 /// NOT identical to [`Default::default()`] as of 0.2: the default
316 /// policy is receipt-aware (`VerifyIfPresent` + `AcceptWithReceipt`),
317 /// while this named profile preserves the exact v0.1.0 semantics —
318 /// receipts inert ([`ReceiptPolicy::Ignore`]) and only
319 /// `assertionMethod` keys accepted
320 /// ([`HistoricalKeyPolicy::Reject`]). Callers pinned to this
321 /// constructor keep v0.1.0 behavior across the 0.2 upgrade.
322 pub fn strict_v0_1_0() -> Self {
323 Self {
324 validate_body_schema: true,
325 allow_unknown_status: true,
326 receipts: ReceiptPolicy::Ignore,
327 historical_keys: HistoricalKeyPolicy::Reject,
328 lineage_head: LineageHeadPolicy {
329 receipts: ReceiptPolicy::Ignore,
330 ..LineageHeadPolicy::default()
331 },
332 // A 0.1.0-pinned consumer predates RFC-ACDP-0014 and is
333 // unaffected by it (§10): no revocations enforced.
334 revocations: RevocationPolicy::default(),
335 }
336 }
337
338 /// The policy the report family (`fetch_report`,
339 /// `fetch_report_with_fetcher`, `fetch_report_diagnose`) passes to
340 /// [`VerifiedContext::verify_retrieved`].
341 ///
342 /// `validate_body_schema` is forced `false` unconditionally,
343 /// independent of the caller: P1 (schema) is always handled by the
344 /// report path itself — `validate_body_structural` plus per-`DataRef`
345 /// non-fatal recording of embedded-hash outcomes into
346 /// `VerificationReport::data_ref_embedded` — so the spine must always
347 /// skip its own full `validate_body` (structural + fatal embedded-hash
348 /// check) here. Every other field passes through verbatim. Do **not**
349 /// pass the caller's policy directly to `verify_retrieved` from a
350 /// report entry point; doing so reinstates the fatal embedded-hash
351 /// check the report path deliberately downgrades to non-fatal (see
352 /// `tests/tls_conformance.rs`'s
353 /// `fetch_report_records_embedded_hash_failure`).
354 fn derived_for_report(&self) -> Self {
355 Self {
356 validate_body_schema: false,
357 ..self.clone()
358 }
359 }
360}
361
362/// A retrieved context that has been cryptographically verified.
363///
364/// Every value of this type is the output of one of the
365/// `VerifiedContext::fetch*` pipelines, each of which independently
366/// recomputes `content_hash` (RFC-ACDP-0001 §5.11) and verifies the
367/// producer signature before the value is constructed. The fields are
368/// **private** precisely so this "cryptographically verified" invariant
369/// cannot be forged: there is no way to construct a `VerifiedContext`
370/// around an unverified [`FullContext`]. Downstream code can therefore
371/// trust the accessors below without re-deriving anything.
372#[derive(Debug)]
373pub struct VerifiedContext {
374 inner: FullContext,
375 /// Whether the body verified against a currently authorized key or
376 /// a receipt-attested historical one (ACDP 0.2, WS-B).
377 key_status: KeyAuthorization,
378 /// The verified registry receipt, when one was present and the
379 /// policy verified it (RFC-ACDP-0010). `None` under
380 /// [`ReceiptPolicy::Ignore`] or when the registry minted none.
381 verified_receipt: Option<acdp_types::receipt::RegistryReceipt>,
382 /// The verified lineage-head receipt (ACDP 0.3, RFC-ACDP-0011),
383 /// when one was present and the policy verified it. Only populated
384 /// by [`Self::fetch_current`] / [`Self::fetch_current_with_policy`]
385 /// — plain retrieval preserves the raw value verbatim without
386 /// verification. Per §7 this verdict is independent of the body
387 /// verdict and the RFC-ACDP-0010 receipt verdict.
388 verified_head_receipt: Option<acdp_types::receipt::LineageHeadReceipt>,
389 /// RFC-ACDP-0011 §6 freshness verdict for the verified head
390 /// receipt, reported distinctly from verification: `Some(true)`
391 /// when the (genuine, verified) receipt's `as_of` is older than
392 /// [`LineageHeadPolicy::max_age_seconds`]; `Some(false)` when
393 /// within policy; `None` when there is no verified head receipt or
394 /// the max-age knob is disabled.
395 head_receipt_stale: Option<bool>,
396}
397
398impl VerifiedContext {
399 /// Retrieve a context and verify its signature using the strict
400 /// default [`VerificationPolicy`].
401 pub async fn fetch(
402 client: &RegistryClient,
403 resolver: &WebResolver,
404 ctx_id: &CtxId,
405 ) -> Result<Self, AcdpError> {
406 Self::fetch_with_policy(client, resolver, ctx_id, &VerificationPolicy::default()).await
407 }
408
409 /// Retrieve a context and verify its signature with caller-controlled
410 /// strictness.
411 ///
412 /// 1. Fetches `body + registry_state` from the registry.
413 /// 2. Refuses a served body whose `ctx_id` differs from the one
414 /// requested (`AcdpError::ContextIdMismatch`) — this implements
415 /// RFC-ACDP-0006 §4.1 step 7 (NORMATIVE, "Bind the resolved
416 /// identity"): neither the signature check (step 5) nor the
417 /// `content_hash` recomputation (step 6) can supply this binding,
418 /// because `ctx_id` sits in the RFC-ACDP-0001 §5.7 registry-assigned
419 /// exclusion set and is therefore stripped from ProducerContent
420 /// before hashing. See RFC-ACDP-0008 §9.1 for the threat this
421 /// closes: without it, a registry can serve any other
422 /// validly-signed body by the same producer under the requested
423 /// context's URL, and both preceding checks still pass. Step 7
424 /// permits a consumer to surface "an equivalent typed error" in
425 /// place of the registry-side `cross_registry_resolution_failed`
426 /// wire code — `ContextIdMismatch` is that typed error. This
427 /// generalizes the receipt-path analogue at RFC-ACDP-0010 §8 step 3
428 /// to the receipt-less core-profile path, where it is the only
429 /// binding available. It does **not** close §9.1 in full: a
430 /// registry that genuinely republishes the same content under a
431 /// new `ctx_id` still passes; only serve-time substitution — a
432 /// different id claimed to be the one requested — is caught.
433 /// 3. Optionally runs `validate_body` — structural schema checks
434 /// plus embedded-`DataRef` hash verification (policy-controlled).
435 /// 4. Recomputes `content_hash` over ProducerContent.
436 /// 5. Resolves the producer's DID document. `did:web` is required
437 /// unconditionally for v0.1.0 (RFC-ACDP-0001 §5.4).
438 /// 6. Verifies the Ed25519 signature (or other supported algorithm).
439 /// 7. Optionally verifies the `registry_receipt` placeholder.
440 /// 8. Optionally rejects unknown statuses.
441 pub async fn fetch_with_policy(
442 client: &RegistryClient,
443 resolver: &WebResolver,
444 ctx_id: &CtxId,
445 policy: &VerificationPolicy,
446 ) -> Result<Self, AcdpError> {
447 let ctx = client.retrieve(ctx_id).await?;
448 let (key_status, verified_receipt) =
449 Self::verify_retrieved(client, resolver, &ctx, ctx_id, policy).await?;
450 Ok(Self {
451 inner: ctx,
452 key_status,
453 verified_receipt,
454 verified_head_receipt: None,
455 head_receipt_stale: None,
456 })
457 }
458
459 /// Retrieve the current head of a lineage
460 /// (`GET /lineages/{lineage_id}/current`) and verify it with the
461 /// strict default [`VerificationPolicy`] — including the
462 /// lineage-head receipt when the registry minted one (ACDP 0.3,
463 /// RFC-ACDP-0011).
464 pub async fn fetch_current(
465 client: &RegistryClient,
466 resolver: &WebResolver,
467 lineage_id: &acdp_types::primitives::LineageId,
468 ) -> Result<Self, AcdpError> {
469 Self::fetch_current_with_policy(
470 client,
471 resolver,
472 lineage_id,
473 &VerificationPolicy::default(),
474 )
475 .await
476 }
477
478 /// Retrieve + verify the current head of a lineage with
479 /// caller-controlled strictness.
480 ///
481 /// Runs the same pipeline as [`Self::fetch_with_policy`] against
482 /// the `/current` response (the expected `ctx_id` is the served
483 /// body's own — there is no requested identifier on this endpoint;
484 /// the head receipt's §7 step 5 byte-match is what binds it), then
485 /// applies `policy.lineage_head` to the response's
486 /// `lineage_head_receipt` per RFC-ACDP-0011 §7:
487 ///
488 /// - [`ReceiptPolicy::Ignore`] — the raw value is preserved
489 /// verbatim, unverified.
490 /// - [`ReceiptPolicy::VerifyIfPresent`] — verified when present
491 /// (absence is fine: the registry may not advertise
492 /// `acdp-registry-head-receipts`).
493 /// - [`ReceiptPolicy::Require`] — fail closed with
494 /// `invalid_receipt` unless present AND verified.
495 ///
496 /// Verification fetches the registry's capabilities document for
497 /// the §7 step 3 `capabilities.registry_did` binding. Staleness
498 /// beyond `policy.lineage_head.max_age_seconds` is a *freshness*
499 /// verdict reported via [`Self::head_receipt_stale`], never a
500 /// verification failure (§6).
501 ///
502 /// [`Self::fetch_with_policy`] now additionally refuses a served body
503 /// whose `ctx_id` is not the one requested (RFC-ACDP-0008 §9.1). This
504 /// endpoint has no requested identifier to compare against — the
505 /// served head's `ctx_id` is trivially "the one requested" — so on a
506 /// receipt-less registry the served head's identity rests entirely on
507 /// registry honesty (RFC-ACDP-0008 §9.1). Use [`ReceiptPolicy::Require`]
508 /// where that matters.
509 pub async fn fetch_current_with_policy(
510 client: &RegistryClient,
511 resolver: &WebResolver,
512 lineage_id: &acdp_types::primitives::LineageId,
513 policy: &VerificationPolicy,
514 ) -> Result<Self, AcdpError> {
515 let ctx = client.current(lineage_id).await?;
516 let served_ctx_id = ctx.body.ctx_id.clone();
517 let (key_status, verified_receipt) =
518 Self::verify_retrieved(client, resolver, &ctx, &served_ctx_id, policy).await?;
519
520 // ── Lineage-head receipt phase (RFC-ACDP-0011) ──────────────
521 let (verified_head_receipt, head_receipt_stale) =
522 match (policy.lineage_head.receipts, &ctx.lineage_head_receipt) {
523 (ReceiptPolicy::Ignore, _) | (ReceiptPolicy::VerifyIfPresent, None) => (None, None),
524 (ReceiptPolicy::Require, None) => {
525 return Err(AcdpError::InvalidReceipt(
526 "policy requires a lineage-head receipt but the /current response \
527 carries none (registry without the acdp-registry-head-receipts \
528 profile?)"
529 .into(),
530 ));
531 }
532 (_, Some(value)) => {
533 let serving_authority = client
534 .authority()
535 .unwrap_or_else(|| served_ctx_id.authority().to_string());
536 // §7 step 3 needs capabilities.registry_did — fetched
537 // from the same authority the context came from.
538 let caps = client.capabilities().await?;
539 let receipt = super::receipt::verify_lineage_head_receipt_value(
540 value,
541 lineage_id,
542 &served_ctx_id,
543 ctx.body.version,
544 &ctx.registry_state.status,
545 true, // /current always serves the attested head
546 &serving_authority,
547 &caps.registry_did,
548 chrono::Duration::seconds(
549 policy.lineage_head.max_clock_skew_seconds as i64,
550 ),
551 resolver,
552 )
553 .await?;
554 let stale = policy.lineage_head.max_age_seconds.map(|max| {
555 receipt.age_at(chrono::Utc::now()) > chrono::Duration::seconds(max as i64)
556 });
557 (Some(receipt), stale)
558 }
559 };
560
561 Ok(Self {
562 inner: ctx,
563 key_status,
564 verified_receipt,
565 verified_head_receipt,
566 head_receipt_stale,
567 })
568 }
569
570 /// The shared retrieve-side verification pipeline: body schema,
571 /// hash recomputation, RFC-ACDP-0010 receipt phase, signature
572 /// phase (with the receipt-gated historical-key fallback), and the
573 /// unknown-status policy check.
574 #[cfg_attr(
575 feature = "tracing",
576 tracing::instrument(
577 name = "acdp.verify_retrieved",
578 skip_all,
579 fields(ctx_id = %expected_ctx_id),
580 err(Display)
581 )
582 )]
583 async fn verify_retrieved(
584 client: &RegistryClient,
585 resolver: &WebResolver,
586 ctx: &FullContext,
587 expected_ctx_id: &CtxId,
588 policy: &VerificationPolicy,
589 ) -> Result<
590 (
591 KeyAuthorization,
592 Option<acdp_types::receipt::RegistryReceipt>,
593 ),
594 AcdpError,
595 > {
596 // Identifier binding — RFC-ACDP-0006 §4.1 step 7 (NORMATIVE, "Bind
597 // the resolved identity"): refuse a served body whose `ctx_id`
598 // differs from the one requested, before any crypto or network
599 // work. `ctx_id` is registry-assigned and outside both the
600 // `content_hash` and signature coverage (RFC-ACDP-0001 §5.7's
601 // exclusion set), so this equality check is the only binding
602 // available when no receipt is served. See RFC-ACDP-0008 §9.1 for
603 // the threat this closes; it does not close §9.1 in full (a
604 // genuine republish under a new `ctx_id` still passes — only
605 // serve-time substitution is caught). Step 7 permits a
606 // consumer-side "equivalent typed error" in place of the
607 // registry-side `cross_registry_resolution_failed` wire code —
608 // `ContextIdMismatch` is that typed error.
609 if ctx.body.ctx_id != *expected_ctx_id {
610 return Err(AcdpError::ContextIdMismatch {
611 requested: expected_ctx_id.as_str().to_string(),
612 served: ctx.body.ctx_id.as_str().to_string(),
613 });
614 }
615
616 if policy.validate_body_schema {
617 acdp_validation::validate_body(&ctx.body)?;
618 }
619
620 // Hash recomputation first: from here on `ctx.body.content_hash`
621 // IS the independently recomputed value, which the receipt
622 // cross-check below relies on.
623 let verifier = Verifier::new(resolver);
624 verifier.verify_body_hash(&ctx.body)?;
625
626 // ── Receipt phase (RFC-ACDP-0010) ───────────────────────────
627 // Verified BEFORE the signature phase because the historical-
628 // key path is gated on a verified receipt.
629 let serving_authority = client
630 .authority()
631 .unwrap_or_else(|| expected_ctx_id.authority().to_string());
632 let verified_receipt = match (policy.receipts, &ctx.registry_receipt) {
633 (ReceiptPolicy::Ignore, _) | (ReceiptPolicy::VerifyIfPresent, None) => None,
634 (ReceiptPolicy::Require, None) => {
635 return Err(AcdpError::InvalidReceipt(
636 "policy requires a registry receipt but the response carries none \
637 (registry without the acdp-registry-receipts profile, or a \
638 pre-receipts context)"
639 .into(),
640 ));
641 }
642 (_, Some(value)) => {
643 let fingerprint = acdp_crypto::fingerprint::fingerprint_for_key_id(
644 &ctx.body.signature.key_id,
645 &ctx.body.signature.algorithm,
646 resolver,
647 )
648 .await?;
649 Some(
650 super::receipt::verify_receipt_value(
651 value,
652 expected_ctx_id,
653 &ctx.body,
654 &ctx.body.content_hash,
655 &fingerprint,
656 &serving_authority,
657 resolver,
658 )
659 .await?,
660 )
661 }
662 };
663
664 // ── Revocation phase (RFC-ACDP-0014 §7) ─────────────────────
665 // Runs after the receipt phase because the boundary comparison
666 // accepts ONLY a receipt-attested publish time (§7 step 1 —
667 // the bare body created_at is registry-assigned and MUST NOT
668 // be used). The verified receipt's key_fingerprint was already
669 // cross-checked against the body's signing key above (§8 step
670 // 5), so `verified_receipt.created_at` genuinely places THIS
671 // key's signature in time.
672 let revocation_verdict = if policy.revocations.known.is_empty() {
673 None
674 } else {
675 let fingerprint = acdp_crypto::fingerprint::fingerprint_for_key_id(
676 &ctx.body.signature.key_id,
677 &ctx.body.signature.algorithm,
678 resolver,
679 )
680 .await?;
681 super::revocation::classify_under_revocation(
682 &policy.revocations.known,
683 &fingerprint,
684 verified_receipt.as_ref().map(|r| r.created_at),
685 )?
686 };
687
688 // ── Signature phase ──────────────────────────────────────────
689 // Standard path enforces assertionMethod membership. A
690 // KeyNotAuthorized failure falls back to the historical path
691 // only under AcceptWithReceipt AND a verified receipt — the
692 // receipt's key_fingerprint (already cross-checked against this
693 // exact key above) is what attests publish-time authorization.
694 let key_status = match revocation_verdict {
695 // Pre-compromise (§7 step 2): the signature is verified
696 // under the RFC-ACDP-0010 §10 historical rule — the key may
697 // legitimately have left assertionMethod (and SHOULD, §9),
698 // and even a key still in assertionMethod MUST NOT be
699 // reported as fully current once revoked. did:key material
700 // cannot rotate, so it takes the plain envelope path.
701 Some(pre_compromise) => {
702 if ctx.body.agent_id.as_str().starts_with("did:key:") {
703 verifier.verify_body_signature(&ctx.body).await?;
704 } else {
705 acdp_verify::verify_body_signature_historical(&ctx.body, resolver).await?;
706 }
707 pre_compromise
708 }
709 None => match verifier.verify_body_signature(&ctx.body).await {
710 Ok(()) => KeyAuthorization::CurrentlyAuthorized,
711 Err(AcdpError::KeyNotAuthorized(_))
712 if policy.historical_keys == HistoricalKeyPolicy::AcceptWithReceipt
713 && verified_receipt.is_some() =>
714 {
715 acdp_verify::verify_body_signature_historical(&ctx.body, resolver).await?;
716 KeyAuthorization::HistoricallyAuthorized
717 }
718 Err(e) => return Err(e),
719 },
720 };
721
722 if !policy.allow_unknown_status {
723 if let Some(other) = ctx.registry_state.status.as_other() {
724 return Err(AcdpError::SchemaViolation(format!(
725 "policy.allow_unknown_status=false; registry returned '{other}'"
726 )));
727 }
728 }
729
730 Ok((key_status, verified_receipt))
731 }
732
733 /// Retrieve + verify, returning a structured [`VerificationReport`]
734 /// alongside the verified context. Does NOT attempt external
735 /// `DataRef` fetches — use [`Self::fetch_report_with_fetcher`] for
736 /// that. Each `data_ref_external` slot in the returned report is
737 /// `None`.
738 ///
739 /// Unlike [`Self::fetch_with_policy`], per-`DataRef` embedded-hash
740 /// failures are recorded in the report instead of aborting the
741 /// verification. The top-level checks (schema, body hash,
742 /// signature) remain hard-fail: if any of them fails, the method
743 /// returns an `AcdpError` and produces no report.
744 ///
745 /// For diagnostic callers that want a populated report even when
746 /// a top-level check fails (e.g. an audit walker that needs to
747 /// distinguish "wrong hash" from "wrong signature"), use
748 /// [`Self::fetch_report_diagnose`] instead.
749 pub async fn fetch_report(
750 client: &RegistryClient,
751 resolver: &WebResolver,
752 ctx_id: &CtxId,
753 policy: &VerificationPolicy,
754 ) -> Result<(Self, VerificationReport), AcdpError> {
755 Self::fetch_report_inner::<NoFetcher>(client, resolver, ctx_id, policy, None).await
756 }
757
758 /// Diagnostic variant of [`Self::fetch_report`] that never
759 /// short-circuits on a top-level failure — schema, body-hash, and
760 /// signature outcomes are each recorded individually in the
761 /// returned [`VerificationReport`]. Returns `Ok((None, report))`
762 /// when any top-level probe failed (the report shows which one);
763 /// `Ok((Some(verified), report))` only when every check passed
764 /// (FEAT-05) — and "every check" now genuinely means every
765 /// authorization phase (receipt, revocation, signature/
766 /// historical-key, unknown-status), not just the top-level probes:
767 /// once the probes pass, this method additionally runs the same
768 /// `verify_retrieved` phase `fetch_with_policy` does, and withholds
769 /// the handle — recording the cause in
770 /// [`VerificationReport::policy_phase_error`] — if that phase fails
771 /// too. Either way the method still returns `Ok`; it never converts
772 /// a policy-phase failure into an `Err`.
773 ///
774 /// Use cases:
775 /// - Audit walkers that need to classify failures by stage.
776 /// - Admin tooling that wants to distinguish "hash mismatch"
777 /// (probable tampering / encoding drift) from "signature
778 /// verification failed" (key compromise / DID resolution
779 /// problem).
780 ///
781 /// Network errors from the initial retrieval still propagate as
782 /// `Err` — there's no body to inspect when the registry is
783 /// unreachable. But network/DID-resolution errors that occur
784 /// *inside* the `verify_retrieved` phase (e.g. resolving the
785 /// fingerprint for a receipt cross-check, or the historical-key
786 /// fallback) are caught there and land in
787 /// [`VerificationReport::policy_phase_error`] instead of `Err`,
788 /// same as any other phase failure — this method never
789 /// short-circuits once retrieval has succeeded. That means a
790 /// transient network flake at that stage can read as a policy
791 /// rejection (`Ok((None, report))`) rather than an `Err`. A caller
792 /// that needs to tell a flake from a genuine rejection should
793 /// inspect `policy_phase_error`'s [`AcdpError::is_transient`].
794 pub async fn fetch_report_diagnose(
795 client: &RegistryClient,
796 resolver: &WebResolver,
797 ctx_id: &CtxId,
798 policy: &VerificationPolicy,
799 ) -> Result<(Option<Self>, VerificationReport), AcdpError> {
800 let ctx = client.retrieve(ctx_id).await?;
801 let mut report = VerificationReport {
802 body_hash_ok: false,
803 signature_ok: false,
804 schema_ok: false,
805 data_ref_embedded: Vec::with_capacity(ctx.body.data_refs.len()),
806 data_ref_external: Vec::with_capacity(ctx.body.data_refs.len()),
807 ctx_id_ok: ctx.body.ctx_id == *ctx_id,
808 key_status: None,
809 policy_phase_error: None,
810 };
811
812 // Schema (structural) — record pass/fail.
813 if policy.validate_body_schema {
814 match acdp_validation::validate_body_structural(&ctx.body) {
815 Ok(()) => report.schema_ok = true,
816 Err(_) => { /* keep schema_ok=false; continue collecting */ }
817 }
818 } else {
819 report.schema_ok = true;
820 }
821
822 // Per-DataRef embedded hashes — same as fetch_report_inner.
823 for dr in &ctx.body.data_refs {
824 if let (Some(emb), Some(_)) = (&dr.embedded, &dr.content_hash) {
825 let outcome = acdp_validation::verify_embedded_hash(dr)
826 .and_then(|()| acdp_validation::embedded_decoded_bytes(emb).map(|b| b.len()));
827 report.data_ref_embedded.push(outcome);
828 } else {
829 report.data_ref_embedded.push(Ok(0));
830 }
831 }
832
833 // Hash + signature recorded independently (FEAT-05).
834 let verifier = Verifier::new(resolver);
835 report.body_hash_ok = verifier.verify_body_hash(&ctx.body).is_ok();
836 report.signature_ok = verifier.verify_body_signature(&ctx.body).await.is_ok();
837
838 // External fetches were not attempted (this method has no
839 // fetcher param — diagnostic callers can wire their own).
840 for _ in &ctx.body.data_refs {
841 report.data_ref_external.push(None);
842 }
843
844 // Decide whether to surface the verified handle. The probes above
845 // are diagnostic — their whole value is continuing past failure —
846 // but the handle is a trust assertion (`VerifiedContext`'s
847 // invariant: "the accessors below can be trusted without
848 // re-deriving anything"), so it is only ever issued once the real
849 // authorization phases (receipt, revocation, signature/historical,
850 // unknown-status) have actually run and passed through
851 // `verify_retrieved` — never on the probes alone.
852 let all_top_level_pass =
853 report.schema_ok && report.body_hash_ok && report.signature_ok && report.ctx_id_ok;
854 let verified = if all_top_level_pass {
855 // The call MUST be hoisted out of the `match` scrutinee: in a
856 // match, scrutinee temporaries live to the end of the match,
857 // so the awaited future would still be holding `&ctx` inside
858 // the arms and `Self { inner: ctx, .. }` below would fail
859 // borrowck (E0505).
860 let outcome = Self::verify_retrieved(
861 client,
862 resolver,
863 &ctx,
864 ctx_id,
865 &policy.derived_for_report(),
866 )
867 .await; // borrow of `ctx` ends here
868 match outcome {
869 Ok((key_status, verified_receipt)) => {
870 report.key_status = Some(key_status);
871 Some(Self {
872 inner: ctx,
873 key_status,
874 verified_receipt,
875 verified_head_receipt: None,
876 head_receipt_stale: None,
877 })
878 }
879 Err(e) => {
880 // Reports; never short-circuits — `fetch_report_diagnose`
881 // still returns `Ok` in every case it does today.
882 report.policy_phase_error = Some(e);
883 None
884 }
885 }
886 } else {
887 None
888 };
889 Ok((verified, report))
890 }
891
892 /// Retrieve + verify like [`Self::fetch_report`], and additionally
893 /// fetch every `DataRef` whose `location` resolves through `fetcher`.
894 /// Each external fetch outcome is recorded in `report.data_ref_external`.
895 pub async fn fetch_report_with_fetcher<F: DataRefFetcher>(
896 client: &RegistryClient,
897 resolver: &WebResolver,
898 ctx_id: &CtxId,
899 policy: &VerificationPolicy,
900 fetcher: &F,
901 ) -> Result<(Self, VerificationReport), AcdpError> {
902 Self::fetch_report_inner(client, resolver, ctx_id, policy, Some(fetcher)).await
903 }
904
905 async fn fetch_report_inner<F: DataRefFetcher>(
906 client: &RegistryClient,
907 resolver: &WebResolver,
908 ctx_id: &CtxId,
909 policy: &VerificationPolicy,
910 fetcher: Option<&F>,
911 ) -> Result<(Self, VerificationReport), AcdpError> {
912 let ctx = client.retrieve(ctx_id).await?;
913
914 // Identifier binding — RFC-ACDP-0006 §4.1 step 7 (NORMATIVE, "Bind
915 // the resolved identity"). Same check as `verify_retrieved`,
916 // applied here (in addition to `verify_retrieved`'s own re-check
917 // below) so this fails before schema validation too — the early
918 // copy preserves fail-before-schema ordering that
919 // `tests/receipts.rs` depends on.
920 if ctx.body.ctx_id != *ctx_id {
921 return Err(AcdpError::ContextIdMismatch {
922 requested: ctx_id.as_str().to_string(),
923 served: ctx.body.ctx_id.as_str().to_string(),
924 });
925 }
926
927 let mut report = VerificationReport {
928 body_hash_ok: false,
929 signature_ok: false,
930 schema_ok: false,
931 data_ref_embedded: Vec::with_capacity(ctx.body.data_refs.len()),
932 data_ref_external: Vec::with_capacity(ctx.body.data_refs.len()),
933 ctx_id_ok: true,
934 key_status: None,
935 policy_phase_error: None,
936 };
937
938 // Structural-only schema validation — embedded-hash checks are
939 // intentionally skipped here so per-DataRef hash failures land
940 // in the report (below) instead of short-circuiting the whole
941 // verification. That's the diagnostic shape `fetch_report`
942 // promises in its docstring.
943 if policy.validate_body_schema {
944 acdp_validation::validate_body_structural(&ctx.body)?;
945 }
946 report.schema_ok = true;
947
948 // Per-DataRef embedded-hash outcomes — recorded individually.
949 for dr in &ctx.body.data_refs {
950 if let (Some(emb), Some(_)) = (&dr.embedded, &dr.content_hash) {
951 let outcome = acdp_validation::verify_embedded_hash(dr)
952 .and_then(|()| acdp_validation::embedded_decoded_bytes(emb).map(|b| b.len()));
953 report.data_ref_embedded.push(outcome);
954 } else {
955 report.data_ref_embedded.push(Ok(0));
956 }
957 }
958
959 // Delegate the remaining phases — content_hash recomputation,
960 // RFC-ACDP-0010 receipt, RFC-ACDP-0014 revocation, signature (with
961 // the historical-key fallback), and the unknown-status check — to
962 // `verify_retrieved`, the sole reader of those policy fields. The
963 // derived policy forces `validate_body_schema` off (P1 was already
964 // handled, structurally-only, above) and passes everything else
965 // through verbatim — see `VerificationPolicy::derived_for_report`.
966 let (key_status, verified_receipt) =
967 Self::verify_retrieved(client, resolver, &ctx, ctx_id, &policy.derived_for_report())
968 .await?;
969 report.body_hash_ok = true;
970 report.signature_ok = true;
971 report.key_status = Some(key_status);
972
973 // External fetches — record per-ref outcomes when a fetcher is
974 // supplied; otherwise leave each slot as `None` so callers can
975 // distinguish "skipped" from "failed".
976 for dr in &ctx.body.data_refs {
977 let slot: Option<Result<usize, AcdpError>> = match (fetcher, &dr.location) {
978 (Some(f), Some(_)) => Some(fetch_and_verify_data_ref(dr, f).await.map(|b| b.len())),
979 _ => None,
980 };
981 report.data_ref_external.push(slot);
982 }
983
984 Ok((
985 Self {
986 inner: ctx,
987 key_status,
988 verified_receipt,
989 verified_head_receipt: None,
990 head_receipt_stale: None,
991 },
992 report,
993 ))
994 }
995
996 pub fn body(&self) -> &acdp_types::body::Body {
997 &self.inner.body
998 }
999
1000 pub fn registry_state(&self) -> &acdp_types::body::RegistryState {
1001 &self.inner.registry_state
1002 }
1003
1004 /// The verified [`FullContext`] (body + registry state + any
1005 /// receipts) in its retrieval shape. Every field was reached only
1006 /// after this context's hash + signature were verified.
1007 pub fn full_context(&self) -> &FullContext {
1008 &self.inner
1009 }
1010
1011 /// Whether the body verified against a currently authorized key, a
1012 /// receipt-attested historical one, or a receipt-attested
1013 /// pre-compromise one (ACDP 0.2 WS-B / RFC-ACDP-0014 §7). This is
1014 /// the real verdict regardless of which `fetch*`/`fetch_report*`
1015 /// entry point produced this `VerifiedContext` — every construction
1016 /// path runs the same `verify_retrieved` phase to derive it.
1017 pub fn key_status(&self) -> KeyAuthorization {
1018 self.key_status
1019 }
1020
1021 /// The verified registry receipt (RFC-ACDP-0010), when one was
1022 /// present and the policy verified it. `None` under
1023 /// [`ReceiptPolicy::Ignore`] or when the registry minted none — this
1024 /// is exhaustive; there is no additional "or you used a report path"
1025 /// carve-out, since `fetch_report`/`fetch_report_with_fetcher`/
1026 /// `fetch_report_diagnose` verify the receipt exactly like
1027 /// `fetch_with_policy` does. For the raw on-wire value see
1028 /// [`Self::receipt`].
1029 pub fn verified_receipt(&self) -> Option<&acdp_types::receipt::RegistryReceipt> {
1030 self.verified_receipt.as_ref()
1031 }
1032
1033 /// The verified lineage-head receipt (ACDP 0.3, RFC-ACDP-0011),
1034 /// populated only by [`Self::fetch_current`] /
1035 /// [`Self::fetch_current_with_policy`] when one was present and the
1036 /// policy verified it. For the raw on-wire value see
1037 /// [`Self::lineage_head_receipt`].
1038 pub fn verified_head_receipt(&self) -> Option<&acdp_types::receipt::LineageHeadReceipt> {
1039 self.verified_head_receipt.as_ref()
1040 }
1041
1042 /// RFC-ACDP-0011 §6 freshness verdict for the verified head
1043 /// receipt: `Some(true)` when the (genuine, verified) receipt's
1044 /// `as_of` is older than [`LineageHeadPolicy::max_age_seconds`];
1045 /// `Some(false)` when within policy; `None` when there is no
1046 /// verified head receipt or the max-age knob is disabled.
1047 pub fn head_receipt_stale(&self) -> Option<bool> {
1048 self.head_receipt_stale
1049 }
1050
1051 /// Raw registry receipt value as served on the wire
1052 /// (RFC-ACDP-0010), preserved verbatim. For the verified, typed
1053 /// form see [`Self::verified_receipt`].
1054 pub fn receipt(&self) -> Option<&serde_json::Value> {
1055 self.inner.registry_receipt.as_ref()
1056 }
1057
1058 /// Raw lineage-head receipt value as served on the wire
1059 /// (RFC-ACDP-0011), preserved verbatim. For the verified, typed
1060 /// form see [`Self::verified_head_receipt`].
1061 pub fn lineage_head_receipt(&self) -> Option<&serde_json::Value> {
1062 self.inner.lineage_head_receipt.as_ref()
1063 }
1064
1065 /// Verify the registry receipt, when one is present
1066 /// (RFC-ACDP-0010).
1067 ///
1068 /// Standalone variant for contexts obtained via the report paths;
1069 /// `fetch_with_policy` already does this under
1070 /// [`ReceiptPolicy::VerifyIfPresent`]/`Require`. The serving
1071 /// authority is taken from the context's own `ctx_id` — this method
1072 /// performs no requested-id binding of its own (it has no requested
1073 /// id to compare against; it only ever sees `self.inner.body.ctx_id`),
1074 /// so deriving the serving authority this way is sound only for a
1075 /// `VerifiedContext` obtained through a pipeline that already bound
1076 /// the served `ctx_id` to the one requested. Every construction path
1077 /// does: `fetch_with_policy` and `CrossRegistryResolver::resolve`
1078 /// check it directly; `fetch_current_with_policy` does too,
1079 /// tautologically, since `/current` has no requested id to diverge
1080 /// from; `fetch_report`/`fetch_report_with_fetcher` check it and
1081 /// return `ContextIdMismatch` on failure; and `fetch_report_diagnose`
1082 /// folds it into its `all_top_level_pass` gate, so it only ever
1083 /// hands back `Some(VerifiedContext)` when `ctx_id_ok` held. All of
1084 /// these implement RFC-ACDP-0006 §4.1 step 7, so the type invariant
1085 /// — every `VerifiedContext` was bound to its requested `ctx_id` —
1086 /// holds unconditionally.
1087 ///
1088 /// Returns `Ok(None)` when no receipt is present, `Ok(Some(_))`
1089 /// with the verified receipt otherwise.
1090 ///
1091 /// The receipt cross-check (RFC-ACDP-0010 §8 step 4) relies on
1092 /// `body.content_hash` being the independently recomputed value.
1093 /// That is guaranteed by the type invariant — every
1094 /// `VerifiedContext` is built only after its constructing pipeline
1095 /// verified the body hash (`Verifier::verify_body_hash` /
1096 /// `verify_body_signed`), and the fields are private so no caller
1097 /// can substitute an unverified body — so no re-derivation is
1098 /// needed here.
1099 pub async fn verify_receipt(
1100 &self,
1101 resolver: &WebResolver,
1102 ) -> Result<Option<acdp_types::receipt::RegistryReceipt>, AcdpError> {
1103 let Some(value) = &self.inner.registry_receipt else {
1104 return Ok(None);
1105 };
1106 let fingerprint = acdp_crypto::fingerprint::fingerprint_for_key_id(
1107 &self.inner.body.signature.key_id,
1108 &self.inner.body.signature.algorithm,
1109 resolver,
1110 )
1111 .await?;
1112 let receipt = super::receipt::verify_receipt_value(
1113 value,
1114 &self.inner.body.ctx_id,
1115 &self.inner.body,
1116 &self.inner.body.content_hash,
1117 &fingerprint,
1118 self.inner.body.ctx_id.authority(),
1119 resolver,
1120 )
1121 .await?;
1122 Ok(Some(receipt))
1123 }
1124}
1125
1126/// Structured diagnostic outcome from [`VerifiedContext::fetch_report`].
1127///
1128/// Top-level booleans report the per-stage outcome of the verification
1129/// pipeline. Per-`DataRef` slots track outcomes for each entry in
1130/// `body.data_refs`, in declaration order:
1131///
1132/// - `data_ref_embedded[i]` — `Ok(decoded_size_bytes)` when the embedded
1133/// payload's `content_hash` matched; `Err` when it didn't (or the
1134/// embedded was malformed). Refs without an embedded payload or
1135/// without a declared `content_hash` produce `Ok(0)`.
1136/// - `data_ref_external[i]` — `None` when no external fetch was
1137/// attempted (either no `location` or no `fetcher` was provided);
1138/// `Some(Ok(bytes_len))` when the fetch + hash succeeded;
1139/// `Some(Err(_))` on any failure (SSRF rejection, hash mismatch,
1140/// timeout, …).
1141///
1142/// `AcdpError` doesn't implement `Clone`, so the report is move-only.
1143///
1144/// `#[non_exhaustive]`: this struct has already gained a field once as a
1145/// non-optional consequence of a security fix (the RFC-ACDP-0006 §4.1
1146/// context-identity binding), and it is output-only — constructed solely
1147/// inside this crate (`verified.rs`) — so downstream loses nothing by
1148/// being unable to construct it directly. Same rationale as `SsrfReason`
1149/// in `crates/acdp-safe-http/src/lib.rs` ("future spec revisions may add
1150/// ranges"): future fields stop being breaking changes for callers that
1151/// only read this report.
1152#[derive(Debug)]
1153#[non_exhaustive]
1154pub struct VerificationReport {
1155 /// `content_hash` recomputed from the body matches the declared one.
1156 pub body_hash_ok: bool,
1157 /// The producer signature verified against the resolved DID key.
1158 pub signature_ok: bool,
1159 /// `validate_body` passed (or was disabled by policy).
1160 pub schema_ok: bool,
1161 /// Per-`DataRef` embedded-hash outcome, in `body.data_refs` order.
1162 pub data_ref_embedded: Vec<Result<usize, AcdpError>>,
1163 /// Per-`DataRef` external-fetch outcome, in `body.data_refs` order.
1164 /// `None` indicates "not attempted" (no fetcher provided or no
1165 /// `location` to fetch from).
1166 pub data_ref_external: Vec<Option<Result<usize, AcdpError>>>,
1167 /// The served body's `ctx_id` equals the one requested
1168 /// (RFC-ACDP-0006 §4.1 step 7, NORMATIVE — "Bind the resolved
1169 /// identity"). `false` means the registry served a different,
1170 /// validly-signed body under the requested id (context
1171 /// substitution); see `VerifiedContext::verify_retrieved`'s doc for
1172 /// the full rationale. This flag gates whether
1173 /// [`VerifiedContext::fetch_report_diagnose`] hands back a
1174 /// `Some(VerifiedContext)` — appended last so any positional
1175 /// construction fails loudly rather than silently binding the wrong
1176 /// field.
1177 pub ctx_id_ok: bool,
1178 /// The real P3-P6 verdict from `verify_retrieved`'s authorization
1179 /// phases (receipt, revocation, signature/historical, unknown-status),
1180 /// when they ran and all passed. `None` means either "not reached"
1181 /// (a top-level probe — schema, body hash, signature, ctx_id — failed
1182 /// first, so `verify_retrieved` was never invoked) or "the phase ran
1183 /// and failed" (see [`Self::policy_phase_error`] for which one).
1184 pub key_status: Option<KeyAuthorization>,
1185 /// Which of `verify_retrieved`'s policy-governed phases (receipt,
1186 /// revocation, signature/historical-key, unknown-status) failed, when
1187 /// one did. `None` when every phase passed, or when `verify_retrieved`
1188 /// was never invoked because a top-level probe failed first.
1189 /// `AcdpError` is not `Clone`, so — like `data_ref_embedded` above —
1190 /// this field is populated by moving the error in, and asserting on it
1191 /// requires `matches!` rather than `==`/`assert_eq!` (`AcdpError`
1192 /// derives only `Debug, Error`).
1193 pub policy_phase_error: Option<AcdpError>,
1194}
1195
1196/// Sentinel `DataRefFetcher` used as the type parameter for
1197/// `fetch_report_inner` when no fetcher is supplied. `fetch` is never
1198/// actually called — the option is matched out before that — but
1199/// providing a real impl lets the generic monomorphize cleanly without
1200/// requiring `fetch_report`'s callers to name a type.
1201struct NoFetcher;
1202
1203impl DataRefFetcher for NoFetcher {
1204 async fn fetch(
1205 &self,
1206 _location: &acdp_types::data_ref::Location,
1207 ) -> Result<Vec<u8>, AcdpError> {
1208 Err(AcdpError::NotImplemented(
1209 "NoFetcher should never be called — this is a fetch_report sentinel".into(),
1210 ))
1211 }
1212}
1213
1214#[cfg(test)]
1215mod tests {
1216 use super::{HistoricalKeyPolicy, ReceiptPolicy, VerificationPolicy};
1217
1218 /// The RFC-ACDP-0001 §9.2 named constructor preserves exact v0.1.0
1219 /// semantics: receipts inert, assertionMethod-only keys. It is
1220 /// deliberately NOT the 0.2 default (which is receipt-aware).
1221 #[test]
1222 fn strict_v0_1_0_preserves_v0_1_0_semantics() {
1223 let strict = VerificationPolicy::strict_v0_1_0();
1224 assert!(strict.validate_body_schema);
1225 assert!(strict.allow_unknown_status);
1226 assert_eq!(strict.receipts, ReceiptPolicy::Ignore);
1227 assert_eq!(strict.historical_keys, HistoricalKeyPolicy::Reject);
1228 assert!(
1229 strict.revocations.known.is_empty(),
1230 "a 0.1.0-pinned consumer is unaffected by RFC-ACDP-0014"
1231 );
1232 assert_ne!(
1233 strict,
1234 VerificationPolicy::default(),
1235 "the 0.2 default is receipt-aware; the v0.1.0 profile is not"
1236 );
1237 }
1238
1239 /// Phase 2 acceptance criterion 6 — the spine lock.
1240 ///
1241 /// `verify_retrieved` must be the SOLE reader of the four
1242 /// authorization-policy fields (`receipts`, `revocations`,
1243 /// `historical_keys`, `allow_unknown_status`) anywhere in this file.
1244 /// Every public entry point (the four `fetch*` forms plus the three
1245 /// report forms) reaches every authorization phase through that one
1246 /// function, so a future RFC phase added anywhere else — instead of
1247 /// inside `verify_retrieved` — trips this test instead of silently
1248 /// reintroducing the exact divergence this phase fixed.
1249 ///
1250 /// Implemented as a plain `str` scan (no `regex` — it is not a
1251 /// dependency of `acdp-client`) over this file's own source, read via
1252 /// `include_str!`. `verify_retrieved`'s body span is located by
1253 /// brace-counting from its own opening brace (its signature has no
1254 /// braces of its own — only angle brackets in the return type — so
1255 /// the first `{` after the `fn` keyword IS the body's opening brace),
1256 /// not by hard-coded line numbers, so the check survives any diff.
1257 /// Lines whose trimmed start is `//` (covers `///` too), and matches
1258 /// that fall inside a string literal (detected by an odd count of
1259 /// unescaped `"` before the match on its line — this file's one
1260 /// in-string occurrence, the `allow_unknown_status=false` error
1261 /// message, already lives inside `verify_retrieved` regardless), are
1262 /// excluded.
1263 ///
1264 /// The four search patterns are built by runtime concatenation
1265 /// (`policy.` + each field name) rather than written as contiguous
1266 /// `"policy.receipts"`-style literals, so this test's own source —
1267 /// included verbatim via `include_str!` — does not self-match its
1268 /// own patterns.
1269 #[test]
1270 fn verify_retrieved_is_sole_reader_of_authorization_policy_fields() {
1271 const SRC: &str = include_str!("verified.rs");
1272
1273 let policy_prefix = "policy.";
1274 let fields = [
1275 "receipts",
1276 "revocations",
1277 "historical_keys",
1278 "allow_unknown_status",
1279 ];
1280 let patterns: Vec<String> = fields
1281 .iter()
1282 .map(|f| format!("{policy_prefix}{f}"))
1283 .collect();
1284
1285 // Locate `verify_retrieved`'s body span.
1286 let fn_start = SRC
1287 .find("async fn verify_retrieved(")
1288 .expect("verify_retrieved must exist in verified.rs");
1289 let body_open = fn_start
1290 + SRC[fn_start..]
1291 .find('{')
1292 .expect("verify_retrieved must have a body");
1293 let mut depth = 0i32;
1294 let mut body_close = None;
1295 for (i, ch) in SRC[body_open..].char_indices() {
1296 match ch {
1297 '{' => depth += 1,
1298 '}' => {
1299 depth -= 1;
1300 if depth == 0 {
1301 body_close = Some(body_open + i);
1302 break;
1303 }
1304 }
1305 _ => {}
1306 }
1307 }
1308 let body_close =
1309 body_close.expect("verify_retrieved's matching closing brace must be found");
1310 assert!(
1311 body_close > body_open,
1312 "sanity: verify_retrieved's body must be non-empty"
1313 );
1314
1315 // Scan the whole file, tracking byte offsets so each match's
1316 // position can be tested against the body span.
1317 let mut offset = 0usize;
1318 let mut checked_any = false;
1319 for line in SRC.split_inclusive('\n') {
1320 let trimmed = line.trim_start();
1321 let is_comment_line = trimmed.starts_with("//");
1322 if !is_comment_line {
1323 for pattern in &patterns {
1324 let mut search_from = 0usize;
1325 while let Some(rel) = line[search_from..].find(pattern.as_str()) {
1326 let match_col = search_from + rel;
1327 let match_start = offset + match_col;
1328 let before = &line[..match_col];
1329 let in_string_literal = before.matches('"').count() % 2 == 1;
1330 if !in_string_literal {
1331 checked_any = true;
1332 assert!(
1333 match_start >= body_open && match_start < body_close,
1334 "found `{pattern}` outside verify_retrieved's body \
1335 (byte offset {match_start}, line: {line:?}) — every \
1336 authorization-policy-field read must live inside \
1337 verify_retrieved, the sole reader"
1338 );
1339 }
1340 search_from = match_col + pattern.len();
1341 }
1342 }
1343 }
1344 offset += line.len();
1345 }
1346 assert!(
1347 checked_any,
1348 "sanity: the scan must find at least one non-comment, non-string-literal \
1349 match for at least one pattern (verify_retrieved itself reads these \
1350 fields) — zero hits would mean the patterns are miscomputed, not that \
1351 the invariant holds"
1352 );
1353 }
1354}