Skip to main content

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///
12/// For ACDP v0.1.0 the verification profile is **always strict**:
13///
14/// - `did:web` is required for every producer identity — enforced
15///   unconditionally by `verify_signature_envelope`
16///   (RFC-ACDP-0001 §5.4), regardless of any policy field.
17/// - Embedded `DataRef` hashes are verified by
18///   [`acdp_validation::validate_body`] whenever `validate_body_schema`
19///   is set.
20///
21/// Only the fields below have real effect in this version; there are no
22/// relaxed-mode `did:web` or embedded-hash knobs.
23#[derive(Debug, Clone, PartialEq, Eq)]
24pub struct VerificationPolicy {
25    /// If true, run [`acdp_validation::validate_body`] (structural
26    /// schema checks plus embedded-`DataRef` hash verification) before
27    /// any cryptographic check. Default `true`. Set `false` only in
28    /// diagnostic paths that want to attempt signature verification
29    /// despite a body known to fail structural checks.
30    pub validate_body_schema: bool,
31
32    /// If true, accept `Status::Other` values (degrade to active per
33    /// RFC-ACDP-0004 §4.1). When false, reject unknown statuses.
34    /// Default `true`.
35    pub allow_unknown_status: bool,
36
37    /// Registry-receipt handling (ACDP 0.2, RFC-ACDP-0010).
38    /// Default [`ReceiptPolicy::VerifyIfPresent`].
39    pub receipts: ReceiptPolicy,
40
41    /// Historical-key handling (ACDP 0.2, WS-B). Default
42    /// [`HistoricalKeyPolicy::AcceptWithReceipt`].
43    pub historical_keys: HistoricalKeyPolicy,
44
45    /// Lineage-head receipt handling on `/current` fetches (ACDP 0.3,
46    /// RFC-ACDP-0011). Only consulted by
47    /// [`VerifiedContext::fetch_current_with_policy`]; plain retrieval
48    /// preserves any `lineage_head_receipt` verbatim without verifying
49    /// it. Default [`LineageHeadPolicy::default`].
50    pub lineage_head: LineageHeadPolicy,
51
52    /// Key-revocation handling (ACDP 0.3, RFC-ACDP-0014 §7). Default:
53    /// no known revocations — the phase is inert.
54    pub revocations: RevocationPolicy,
55}
56
57impl Default for VerificationPolicy {
58    fn default() -> Self {
59        Self {
60            validate_body_schema: true,
61            allow_unknown_status: true,
62            receipts: ReceiptPolicy::VerifyIfPresent,
63            historical_keys: HistoricalKeyPolicy::AcceptWithReceipt,
64            lineage_head: LineageHeadPolicy::default(),
65            revocations: RevocationPolicy::default(),
66        }
67    }
68}
69
70/// Consumer-held key revocations to enforce during verification
71/// (ACDP 0.3, RFC-ACDP-0014 §7).
72///
73/// The revocation signal is **pull-based**: the pipeline does not go
74/// looking for revocations on its own — the caller supplies the
75/// **verified** revocations it holds (from
76/// [`find_revocations`](crate::revocation::find_revocations),
77/// [`find_registry_attested_revocations`](crate::revocation::find_registry_attested_revocations),
78/// an out-of-band channel, or its own indefinite cache — the statement
79/// is permanent, cache accordingly). When `known` is empty the phase
80/// is inert and verification behaves exactly as before RFC-ACDP-0014.
81///
82/// When the body's signing key matches a supplied revocation, §7
83/// applies: a receipt-attested publish time strictly before the
84/// (earliest, §4) `compromised_since` boundary verifies as
85/// [`KeyAuthorization::HistoricallyAuthorizedPreCompromise`]; at/after
86/// the boundary, or with no verified receipt to place the context at
87/// all, verification **fails closed** with `key_not_authorized` —
88/// regardless of DID-document state and regardless of the receipt's
89/// own validity. Note the interaction with [`ReceiptPolicy::Ignore`]:
90/// an unverified receipt provides no publish time, so a revoked key's
91/// contexts all fail closed under it.
92///
93/// Only put revocations here that you have verified (strict body
94/// pipeline + the §5 not-self-signed rule) and, per §6, that you have
95/// decided to act on: producer-signed ones unconditionally;
96/// registry-attested ones ([`RevocationTrustClass::RegistryAttested`](acdp_types::revocation::RevocationTrustClass))
97/// by default only for contexts served by or receipted by that same
98/// registry, with corroboration before global application.
99///
100/// [`find_revocations`](crate::revocation::find_revocations) itself
101/// pre-filters its output to [`RevocationTrustClass::ProducerSigned`](acdp_types::revocation::RevocationTrustClass)
102/// entries actually published by the queried producer, so §6
103/// registry-attested attestations never arrive through it — obtain
104/// those from
105/// [`find_registry_attested_revocations`](crate::revocation::find_registry_attested_revocations)
106/// instead.
107#[derive(Debug, Clone, PartialEq, Eq, Default)]
108pub struct RevocationPolicy {
109    /// Verified revocations to enforce, matched against the signing
110    /// key's RFC-ACDP-0010 §6 fingerprint. The §4 earliest-
111    /// `compromised_since` rule is applied across entries naming the
112    /// same fingerprint, so include *every* revocation of a lineage,
113    /// superseded ones too.
114    pub known: Vec<acdp_types::revocation::KeyRevocation>,
115}
116
117/// How to treat the optional `registry_receipt` on retrieval
118/// (RFC-ACDP-0010).
119#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
120pub enum ReceiptPolicy {
121    /// Skip receipt verification entirely (0.1.0 behavior). The
122    /// receipt value is still preserved verbatim on the context.
123    Ignore,
124    /// Verify the receipt when one is present; absence is not an
125    /// error (the registry may simply be a 0.1.0 registry). Default.
126    #[default]
127    VerifyIfPresent,
128    /// Fail closed unless a receipt is present AND verifies. Use when
129    /// the deployment requires audit-grade provenance — registry
130    /// claims (`ctx_id`, `created_at`, `origin_registry`) are
131    /// assertions, not proofs, without a receipt.
132    Require,
133}
134
135/// How to treat the optional `lineage_head_receipt` on
136/// `GET /lineages/{id}/current` responses (ACDP 0.3, RFC-ACDP-0011).
137///
138/// The presence handling reuses the [`ReceiptPolicy`] vocabulary; the
139/// two numeric knobs are the RFC's consumer-side parameters:
140///
141/// - `max_clock_skew_seconds` — §7 step 6's forward-skew allowance. A
142///   receipt whose `as_of` is further in the future **fails
143///   verification** (`invalid_receipt`, fixture `lhr-004`). RFC
144///   RECOMMENDED: 120.
145/// - `max_age_seconds` — §6's freshness policy. A receipt older than
146///   this is still *verified* (it may be perfectly genuine — merely
147///   old); it is reported distinctly via
148///   [`VerifiedContext::head_receipt_stale`], never as a verification
149///   failure. RFC RECOMMENDED default: 300. `None` disables the
150///   staleness verdict.
151#[derive(Debug, Clone, Copy, PartialEq, Eq)]
152pub struct LineageHeadPolicy {
153    /// Presence handling: `Ignore` (skip verification, preserve
154    /// verbatim), `VerifyIfPresent` (default), or `Require` (fail
155    /// closed unless present AND verified — appropriate when the
156    /// registry advertises `acdp-registry-head-receipts`, under which
157    /// a head receipt on `/current` is REQUIRED, RFC-ACDP-0011 §6).
158    pub receipts: ReceiptPolicy,
159    /// RFC-ACDP-0011 §7 step 6 clock-skew allowance (default 120 s).
160    pub max_clock_skew_seconds: u32,
161    /// RFC-ACDP-0011 §6 maximum acceptable receipt age for the
162    /// staleness verdict (default `Some(300)`).
163    pub max_age_seconds: Option<u32>,
164}
165
166impl Default for LineageHeadPolicy {
167    fn default() -> Self {
168        Self {
169            receipts: ReceiptPolicy::VerifyIfPresent,
170            max_clock_skew_seconds: 120,
171            max_age_seconds: Some(300),
172        }
173    }
174}
175
176/// How to treat a producer key that is present in the DID document's
177/// `verificationMethod` but no longer in `assertionMethod` — i.e. a
178/// key the producer rotated out but retained per the RFC-ACDP-0010
179/// key-retention rule.
180#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
181pub enum HistoricalKeyPolicy {
182    /// Strict 0.1.0 behavior: only `assertionMethod` keys verify.
183    /// Every context signed by a rotated-out key fails.
184    Reject,
185    /// Accept a retained key **only** when a verified registry receipt
186    /// attests (via `key_fingerprint`) that this exact key was the
187    /// authorized one at publish time. Without a verified receipt the
188    /// historical path never activates — fail closed. Default.
189    #[default]
190    AcceptWithReceipt,
191}
192
193/// How the producer key that verified the body relates to the
194/// producer's *current* DID document.
195#[derive(Debug, Clone, Copy, PartialEq, Eq)]
196pub enum KeyAuthorization {
197    /// The signing key is currently listed in `assertionMethod`.
198    CurrentlyAuthorized,
199    /// The signing key was rotated out of `assertionMethod` but is
200    /// retained in `verificationMethod`, and a verified registry
201    /// receipt attests it was the authorized key at publish time
202    /// (RFC-ACDP-0010). Weigh accordingly: valid history, not a
203    /// current endorsement.
204    HistoricallyAuthorized,
205    /// The signing key is **revoked** (a verified RFC-ACDP-0014
206    /// revocation names its fingerprint), but a verified registry
207    /// receipt attests the context was published strictly *before* the
208    /// compromise boundary `compromised_since` — it was signed while
209    /// the key was still the producer's, and verified under the
210    /// RFC-ACDP-0010 §10 historical rule (RFC-ACDP-0014 §7 step 2).
211    ///
212    /// Deliberately distinguishable from BOTH
213    /// [`Self::CurrentlyAuthorized`] and the no-revocation
214    /// [`Self::HistoricallyAuthorized`]: the revocation and its
215    /// boundary MUST be visible in the verdict. Contexts by the same
216    /// key at/after the boundary — or with no verifiable publish time
217    /// — never reach a status at all: they fail closed with
218    /// `key_not_authorized` (§7 steps 3–4).
219    HistoricallyAuthorizedPreCompromise,
220}
221
222impl VerificationPolicy {
223    /// The v0.1.0 strict verification profile (RFC-ACDP-0001 §5.11, §9.2).
224    ///
225    /// Runs the full §5.11 pipeline: body schema validation, `content_hash`
226    /// recomputation, `did:web` key resolution, signature verification, and
227    /// embedded `data_ref.content_hash` checks. Returns on the first failure.
228    ///
229    /// This is the **only** mode covered by the `acdp-consumer` conformance
230    /// profile. Relaxed modes (`Diagnostic`, `UnsafeForTests`) are NOT
231    /// available in this crate in v0.1.0 — they would be separately-named
232    /// opt-ins per §9.2, and are not currently implemented.
233    ///
234    /// NOT identical to [`Default::default()`] as of 0.2: the default
235    /// policy is receipt-aware (`VerifyIfPresent` + `AcceptWithReceipt`),
236    /// while this named profile preserves the exact v0.1.0 semantics —
237    /// receipts inert ([`ReceiptPolicy::Ignore`]) and only
238    /// `assertionMethod` keys accepted
239    /// ([`HistoricalKeyPolicy::Reject`]). Callers pinned to this
240    /// constructor keep v0.1.0 behavior across the 0.2 upgrade.
241    pub fn strict_v0_1_0() -> Self {
242        Self {
243            validate_body_schema: true,
244            allow_unknown_status: true,
245            receipts: ReceiptPolicy::Ignore,
246            historical_keys: HistoricalKeyPolicy::Reject,
247            lineage_head: LineageHeadPolicy {
248                receipts: ReceiptPolicy::Ignore,
249                ..LineageHeadPolicy::default()
250            },
251            // A 0.1.0-pinned consumer predates RFC-ACDP-0014 and is
252            // unaffected by it (§10): no revocations enforced.
253            revocations: RevocationPolicy::default(),
254        }
255    }
256}
257
258/// A retrieved context that has been cryptographically verified.
259///
260/// Every value of this type is the output of one of the
261/// `VerifiedContext::fetch*` pipelines, each of which independently
262/// recomputes `content_hash` (RFC-ACDP-0001 §5.11) and verifies the
263/// producer signature before the value is constructed. The fields are
264/// **private** precisely so this "cryptographically verified" invariant
265/// cannot be forged: there is no way to construct a `VerifiedContext`
266/// around an unverified [`FullContext`]. Downstream code can therefore
267/// trust the accessors below without re-deriving anything.
268#[derive(Debug)]
269pub struct VerifiedContext {
270    inner: FullContext,
271    /// Whether the body verified against a currently authorized key or
272    /// a receipt-attested historical one (ACDP 0.2, WS-B).
273    key_status: KeyAuthorization,
274    /// The verified registry receipt, when one was present and the
275    /// policy verified it (RFC-ACDP-0010). `None` under
276    /// [`ReceiptPolicy::Ignore`] or when the registry minted none.
277    verified_receipt: Option<acdp_types::receipt::RegistryReceipt>,
278    /// The verified lineage-head receipt (ACDP 0.3, RFC-ACDP-0011),
279    /// when one was present and the policy verified it. Only populated
280    /// by [`Self::fetch_current`] / [`Self::fetch_current_with_policy`]
281    /// — plain retrieval preserves the raw value verbatim without
282    /// verification. Per §7 this verdict is independent of the body
283    /// verdict and the RFC-ACDP-0010 receipt verdict.
284    verified_head_receipt: Option<acdp_types::receipt::LineageHeadReceipt>,
285    /// RFC-ACDP-0011 §6 freshness verdict for the verified head
286    /// receipt, reported distinctly from verification: `Some(true)`
287    /// when the (genuine, verified) receipt's `as_of` is older than
288    /// [`LineageHeadPolicy::max_age_seconds`]; `Some(false)` when
289    /// within policy; `None` when there is no verified head receipt or
290    /// the max-age knob is disabled.
291    head_receipt_stale: Option<bool>,
292}
293
294impl VerifiedContext {
295    /// Retrieve a context and verify its signature using the strict
296    /// default [`VerificationPolicy`].
297    pub async fn fetch(
298        client: &RegistryClient,
299        resolver: &WebResolver,
300        ctx_id: &CtxId,
301    ) -> Result<Self, AcdpError> {
302        Self::fetch_with_policy(client, resolver, ctx_id, &VerificationPolicy::default()).await
303    }
304
305    /// Retrieve a context and verify its signature with caller-controlled
306    /// strictness.
307    ///
308    /// 1. Fetches `body + registry_state` from the registry.
309    /// 2. Refuses a served body whose `ctx_id` differs from the one
310    ///    requested (`AcdpError::ContextIdMismatch`) — this implements
311    ///    RFC-ACDP-0006 §4.1 step 7 (NORMATIVE, "Bind the resolved
312    ///    identity"): neither the signature check (step 5) nor the
313    ///    `content_hash` recomputation (step 6) can supply this binding,
314    ///    because `ctx_id` sits in the RFC-ACDP-0001 §5.7 registry-assigned
315    ///    exclusion set and is therefore stripped from ProducerContent
316    ///    before hashing. See RFC-ACDP-0008 §9.1 for the threat this
317    ///    closes: without it, a registry can serve any other
318    ///    validly-signed body by the same producer under the requested
319    ///    context's URL, and both preceding checks still pass. Step 7
320    ///    permits a consumer to surface "an equivalent typed error" in
321    ///    place of the registry-side `cross_registry_resolution_failed`
322    ///    wire code — `ContextIdMismatch` is that typed error. This
323    ///    generalizes the receipt-path analogue at RFC-ACDP-0010 §8 step 3
324    ///    to the receipt-less core-profile path, where it is the only
325    ///    binding available. It does **not** close §9.1 in full: a
326    ///    registry that genuinely republishes the same content under a
327    ///    new `ctx_id` still passes; only serve-time substitution — a
328    ///    different id claimed to be the one requested — is caught.
329    /// 3. Optionally runs `validate_body` — structural schema checks
330    ///    plus embedded-`DataRef` hash verification (policy-controlled).
331    /// 4. Recomputes `content_hash` over ProducerContent.
332    /// 5. Resolves the producer's DID document. `did:web` is required
333    ///    unconditionally for v0.1.0 (RFC-ACDP-0001 §5.4).
334    /// 6. Verifies the Ed25519 signature (or other supported algorithm).
335    /// 7. Optionally verifies the `registry_receipt` placeholder.
336    /// 8. Optionally rejects unknown statuses.
337    pub async fn fetch_with_policy(
338        client: &RegistryClient,
339        resolver: &WebResolver,
340        ctx_id: &CtxId,
341        policy: &VerificationPolicy,
342    ) -> Result<Self, AcdpError> {
343        let ctx = client.retrieve(ctx_id).await?;
344        let (key_status, verified_receipt) =
345            Self::verify_retrieved(client, resolver, &ctx, ctx_id, policy).await?;
346        Ok(Self {
347            inner: ctx,
348            key_status,
349            verified_receipt,
350            verified_head_receipt: None,
351            head_receipt_stale: None,
352        })
353    }
354
355    /// Retrieve the current head of a lineage
356    /// (`GET /lineages/{lineage_id}/current`) and verify it with the
357    /// strict default [`VerificationPolicy`] — including the
358    /// lineage-head receipt when the registry minted one (ACDP 0.3,
359    /// RFC-ACDP-0011).
360    pub async fn fetch_current(
361        client: &RegistryClient,
362        resolver: &WebResolver,
363        lineage_id: &acdp_types::primitives::LineageId,
364    ) -> Result<Self, AcdpError> {
365        Self::fetch_current_with_policy(
366            client,
367            resolver,
368            lineage_id,
369            &VerificationPolicy::default(),
370        )
371        .await
372    }
373
374    /// Retrieve + verify the current head of a lineage with
375    /// caller-controlled strictness.
376    ///
377    /// Runs the same pipeline as [`Self::fetch_with_policy`] against
378    /// the `/current` response (the expected `ctx_id` is the served
379    /// body's own — there is no requested identifier on this endpoint;
380    /// the head receipt's §7 step 5 byte-match is what binds it), then
381    /// applies `policy.lineage_head` to the response's
382    /// `lineage_head_receipt` per RFC-ACDP-0011 §7:
383    ///
384    /// - [`ReceiptPolicy::Ignore`] — the raw value is preserved
385    ///   verbatim, unverified.
386    /// - [`ReceiptPolicy::VerifyIfPresent`] — verified when present
387    ///   (absence is fine: the registry may not advertise
388    ///   `acdp-registry-head-receipts`).
389    /// - [`ReceiptPolicy::Require`] — fail closed with
390    ///   `invalid_receipt` unless present AND verified.
391    ///
392    /// Verification fetches the registry's capabilities document for
393    /// the §7 step 3 `capabilities.registry_did` binding. Staleness
394    /// beyond `policy.lineage_head.max_age_seconds` is a *freshness*
395    /// verdict reported via [`Self::head_receipt_stale`], never a
396    /// verification failure (§6).
397    ///
398    /// [`Self::fetch_with_policy`] now additionally refuses a served body
399    /// whose `ctx_id` is not the one requested (RFC-ACDP-0008 §9.1). This
400    /// endpoint has no requested identifier to compare against — the
401    /// served head's `ctx_id` is trivially "the one requested" — so on a
402    /// receipt-less registry the served head's identity rests entirely on
403    /// registry honesty (RFC-ACDP-0008 §9.1). Use [`ReceiptPolicy::Require`]
404    /// where that matters.
405    pub async fn fetch_current_with_policy(
406        client: &RegistryClient,
407        resolver: &WebResolver,
408        lineage_id: &acdp_types::primitives::LineageId,
409        policy: &VerificationPolicy,
410    ) -> Result<Self, AcdpError> {
411        let ctx = client.current(lineage_id).await?;
412        let served_ctx_id = ctx.body.ctx_id.clone();
413        let (key_status, verified_receipt) =
414            Self::verify_retrieved(client, resolver, &ctx, &served_ctx_id, policy).await?;
415
416        // ── Lineage-head receipt phase (RFC-ACDP-0011) ──────────────
417        let (verified_head_receipt, head_receipt_stale) =
418            match (policy.lineage_head.receipts, &ctx.lineage_head_receipt) {
419                (ReceiptPolicy::Ignore, _) | (ReceiptPolicy::VerifyIfPresent, None) => (None, None),
420                (ReceiptPolicy::Require, None) => {
421                    return Err(AcdpError::InvalidReceipt(
422                        "policy requires a lineage-head receipt but the /current response \
423                         carries none (registry without the acdp-registry-head-receipts \
424                         profile?)"
425                            .into(),
426                    ));
427                }
428                (_, Some(value)) => {
429                    let serving_authority = client
430                        .authority()
431                        .unwrap_or_else(|| served_ctx_id.authority().to_string());
432                    // §7 step 3 needs capabilities.registry_did — fetched
433                    // from the same authority the context came from.
434                    let caps = client.capabilities().await?;
435                    let receipt = super::receipt::verify_lineage_head_receipt_value(
436                        value,
437                        lineage_id,
438                        &served_ctx_id,
439                        ctx.body.version,
440                        &ctx.registry_state.status,
441                        true, // /current always serves the attested head
442                        &serving_authority,
443                        &caps.registry_did,
444                        chrono::Duration::seconds(
445                            policy.lineage_head.max_clock_skew_seconds as i64,
446                        ),
447                        resolver,
448                    )
449                    .await?;
450                    let stale = policy.lineage_head.max_age_seconds.map(|max| {
451                        receipt.age_at(chrono::Utc::now()) > chrono::Duration::seconds(max as i64)
452                    });
453                    (Some(receipt), stale)
454                }
455            };
456
457        Ok(Self {
458            inner: ctx,
459            key_status,
460            verified_receipt,
461            verified_head_receipt,
462            head_receipt_stale,
463        })
464    }
465
466    /// The shared retrieve-side verification pipeline: body schema,
467    /// hash recomputation, RFC-ACDP-0010 receipt phase, signature
468    /// phase (with the receipt-gated historical-key fallback), and the
469    /// unknown-status policy check.
470    async fn verify_retrieved(
471        client: &RegistryClient,
472        resolver: &WebResolver,
473        ctx: &FullContext,
474        expected_ctx_id: &CtxId,
475        policy: &VerificationPolicy,
476    ) -> Result<
477        (
478            KeyAuthorization,
479            Option<acdp_types::receipt::RegistryReceipt>,
480        ),
481        AcdpError,
482    > {
483        // Identifier binding — RFC-ACDP-0006 §4.1 step 7 (NORMATIVE, "Bind
484        // the resolved identity"): refuse a served body whose `ctx_id`
485        // differs from the one requested, before any crypto or network
486        // work. `ctx_id` is registry-assigned and outside both the
487        // `content_hash` and signature coverage (RFC-ACDP-0001 §5.7's
488        // exclusion set), so this equality check is the only binding
489        // available when no receipt is served. See RFC-ACDP-0008 §9.1 for
490        // the threat this closes; it does not close §9.1 in full (a
491        // genuine republish under a new `ctx_id` still passes — only
492        // serve-time substitution is caught). Step 7 permits a
493        // consumer-side "equivalent typed error" in place of the
494        // registry-side `cross_registry_resolution_failed` wire code —
495        // `ContextIdMismatch` is that typed error.
496        if ctx.body.ctx_id != *expected_ctx_id {
497            return Err(AcdpError::ContextIdMismatch {
498                requested: expected_ctx_id.as_str().to_string(),
499                served: ctx.body.ctx_id.as_str().to_string(),
500            });
501        }
502
503        if policy.validate_body_schema {
504            acdp_validation::validate_body(&ctx.body)?;
505        }
506
507        // Hash recomputation first: from here on `ctx.body.content_hash`
508        // IS the independently recomputed value, which the receipt
509        // cross-check below relies on.
510        let verifier = Verifier::new(resolver);
511        verifier.verify_body_hash(&ctx.body)?;
512
513        // ── Receipt phase (RFC-ACDP-0010) ───────────────────────────
514        // Verified BEFORE the signature phase because the historical-
515        // key path is gated on a verified receipt.
516        let serving_authority = client
517            .authority()
518            .unwrap_or_else(|| expected_ctx_id.authority().to_string());
519        let verified_receipt = match (policy.receipts, &ctx.registry_receipt) {
520            (ReceiptPolicy::Ignore, _) | (ReceiptPolicy::VerifyIfPresent, None) => None,
521            (ReceiptPolicy::Require, None) => {
522                return Err(AcdpError::InvalidReceipt(
523                    "policy requires a registry receipt but the response carries none \
524                     (registry without the acdp-registry-receipts profile, or a \
525                     pre-receipts context)"
526                        .into(),
527                ));
528            }
529            (_, Some(value)) => {
530                let fingerprint = acdp_crypto::fingerprint::fingerprint_for_key_id(
531                    &ctx.body.signature.key_id,
532                    &ctx.body.signature.algorithm,
533                    resolver,
534                )
535                .await?;
536                Some(
537                    super::receipt::verify_receipt_value(
538                        value,
539                        expected_ctx_id,
540                        &ctx.body,
541                        &ctx.body.content_hash,
542                        &fingerprint,
543                        &serving_authority,
544                        resolver,
545                    )
546                    .await?,
547                )
548            }
549        };
550
551        // ── Revocation phase (RFC-ACDP-0014 §7) ─────────────────────
552        // Runs after the receipt phase because the boundary comparison
553        // accepts ONLY a receipt-attested publish time (§7 step 1 —
554        // the bare body created_at is registry-assigned and MUST NOT
555        // be used). The verified receipt's key_fingerprint was already
556        // cross-checked against the body's signing key above (§8 step
557        // 5), so `verified_receipt.created_at` genuinely places THIS
558        // key's signature in time.
559        let revocation_verdict = if policy.revocations.known.is_empty() {
560            None
561        } else {
562            let fingerprint = acdp_crypto::fingerprint::fingerprint_for_key_id(
563                &ctx.body.signature.key_id,
564                &ctx.body.signature.algorithm,
565                resolver,
566            )
567            .await?;
568            super::revocation::classify_under_revocation(
569                &policy.revocations.known,
570                &fingerprint,
571                verified_receipt.as_ref().map(|r| r.created_at),
572            )?
573        };
574
575        // ── Signature phase ──────────────────────────────────────────
576        // Standard path enforces assertionMethod membership. A
577        // KeyNotAuthorized failure falls back to the historical path
578        // only under AcceptWithReceipt AND a verified receipt — the
579        // receipt's key_fingerprint (already cross-checked against this
580        // exact key above) is what attests publish-time authorization.
581        let key_status = match revocation_verdict {
582            // Pre-compromise (§7 step 2): the signature is verified
583            // under the RFC-ACDP-0010 §10 historical rule — the key may
584            // legitimately have left assertionMethod (and SHOULD, §9),
585            // and even a key still in assertionMethod MUST NOT be
586            // reported as fully current once revoked. did:key material
587            // cannot rotate, so it takes the plain envelope path.
588            Some(pre_compromise) => {
589                if ctx.body.agent_id.as_str().starts_with("did:key:") {
590                    verifier.verify_body_signature(&ctx.body).await?;
591                } else {
592                    acdp_verify::verify_body_signature_historical(&ctx.body, resolver).await?;
593                }
594                pre_compromise
595            }
596            None => match verifier.verify_body_signature(&ctx.body).await {
597                Ok(()) => KeyAuthorization::CurrentlyAuthorized,
598                Err(AcdpError::KeyNotAuthorized(_))
599                    if policy.historical_keys == HistoricalKeyPolicy::AcceptWithReceipt
600                        && verified_receipt.is_some() =>
601                {
602                    acdp_verify::verify_body_signature_historical(&ctx.body, resolver).await?;
603                    KeyAuthorization::HistoricallyAuthorized
604                }
605                Err(e) => return Err(e),
606            },
607        };
608
609        if !policy.allow_unknown_status {
610            if let Some(other) = ctx.registry_state.status.as_other() {
611                return Err(AcdpError::SchemaViolation(format!(
612                    "policy.allow_unknown_status=false; registry returned '{other}'"
613                )));
614            }
615        }
616
617        Ok((key_status, verified_receipt))
618    }
619
620    /// Retrieve + verify, returning a structured [`VerificationReport`]
621    /// alongside the verified context. Does NOT attempt external
622    /// `DataRef` fetches — use [`Self::fetch_report_with_fetcher`] for
623    /// that. Each `data_ref_external` slot in the returned report is
624    /// `None`.
625    ///
626    /// Unlike [`Self::fetch_with_policy`], per-`DataRef` embedded-hash
627    /// failures are recorded in the report instead of aborting the
628    /// verification. The top-level checks (schema, body hash,
629    /// signature) remain hard-fail: if any of them fails, the method
630    /// returns an `AcdpError` and produces no report.
631    ///
632    /// For diagnostic callers that want a populated report even when
633    /// a top-level check fails (e.g. an audit walker that needs to
634    /// distinguish "wrong hash" from "wrong signature"), use
635    /// [`Self::fetch_report_diagnose`] instead.
636    pub async fn fetch_report(
637        client: &RegistryClient,
638        resolver: &WebResolver,
639        ctx_id: &CtxId,
640        policy: &VerificationPolicy,
641    ) -> Result<(Self, VerificationReport), AcdpError> {
642        Self::fetch_report_inner::<NoFetcher>(client, resolver, ctx_id, policy, None).await
643    }
644
645    /// Diagnostic variant of [`Self::fetch_report`] that never
646    /// short-circuits on a top-level failure — schema, body-hash, and
647    /// signature outcomes are each recorded individually in the
648    /// returned [`VerificationReport`]. Returns `Ok((None, report))`
649    /// when any top-level stage failed (the report shows which one);
650    /// `Ok((Some(verified), report))` only when every check passed
651    /// (FEAT-05).
652    ///
653    /// Use cases:
654    /// - Audit walkers that need to classify failures by stage.
655    /// - Admin tooling that wants to distinguish "hash mismatch"
656    ///   (probable tampering / encoding drift) from "signature
657    ///   verification failed" (key compromise / DID resolution
658    ///   problem).
659    ///
660    /// Network errors (retrieve, DID resolution) still propagate as
661    /// `Err` — there's no body to inspect when the registry is
662    /// unreachable.
663    pub async fn fetch_report_diagnose(
664        client: &RegistryClient,
665        resolver: &WebResolver,
666        ctx_id: &CtxId,
667        policy: &VerificationPolicy,
668    ) -> Result<(Option<Self>, VerificationReport), AcdpError> {
669        let ctx = client.retrieve(ctx_id).await?;
670        let mut report = VerificationReport {
671            body_hash_ok: false,
672            signature_ok: false,
673            schema_ok: false,
674            data_ref_embedded: Vec::with_capacity(ctx.body.data_refs.len()),
675            data_ref_external: Vec::with_capacity(ctx.body.data_refs.len()),
676            ctx_id_ok: ctx.body.ctx_id == *ctx_id,
677        };
678
679        // Schema (structural) — record pass/fail.
680        if policy.validate_body_schema {
681            match acdp_validation::validate_body_structural(&ctx.body) {
682                Ok(()) => report.schema_ok = true,
683                Err(_) => { /* keep schema_ok=false; continue collecting */ }
684            }
685        } else {
686            report.schema_ok = true;
687        }
688
689        // Per-DataRef embedded hashes — same as fetch_report_inner.
690        for dr in &ctx.body.data_refs {
691            if let (Some(emb), Some(_)) = (&dr.embedded, &dr.content_hash) {
692                let outcome = acdp_validation::verify_embedded_hash(dr)
693                    .and_then(|()| acdp_validation::embedded_decoded_bytes(emb).map(|b| b.len()));
694                report.data_ref_embedded.push(outcome);
695            } else {
696                report.data_ref_embedded.push(Ok(0));
697            }
698        }
699
700        // Hash + signature recorded independently (FEAT-05).
701        let verifier = Verifier::new(resolver);
702        report.body_hash_ok = verifier.verify_body_hash(&ctx.body).is_ok();
703        report.signature_ok = verifier.verify_body_signature(&ctx.body).await.is_ok();
704
705        // External fetches were not attempted (this method has no
706        // fetcher param — diagnostic callers can wire their own).
707        for _ in &ctx.body.data_refs {
708            report.data_ref_external.push(None);
709        }
710
711        // Decide whether to surface the verified handle. Report paths
712        // run the strict assertionMethod check only (no receipt /
713        // historical handling — use `fetch_with_policy` for those).
714        let all_top_level_pass =
715            report.schema_ok && report.body_hash_ok && report.signature_ok && report.ctx_id_ok;
716        let verified = if all_top_level_pass {
717            Some(Self {
718                inner: ctx,
719                key_status: KeyAuthorization::CurrentlyAuthorized,
720                verified_receipt: None,
721                verified_head_receipt: None,
722                head_receipt_stale: None,
723            })
724        } else {
725            None
726        };
727        Ok((verified, report))
728    }
729
730    /// Retrieve + verify like [`Self::fetch_report`], and additionally
731    /// fetch every `DataRef` whose `location` resolves through `fetcher`.
732    /// Each external fetch outcome is recorded in `report.data_ref_external`.
733    pub async fn fetch_report_with_fetcher<F: DataRefFetcher>(
734        client: &RegistryClient,
735        resolver: &WebResolver,
736        ctx_id: &CtxId,
737        policy: &VerificationPolicy,
738        fetcher: &F,
739    ) -> Result<(Self, VerificationReport), AcdpError> {
740        Self::fetch_report_inner(client, resolver, ctx_id, policy, Some(fetcher)).await
741    }
742
743    async fn fetch_report_inner<F: DataRefFetcher>(
744        client: &RegistryClient,
745        resolver: &WebResolver,
746        ctx_id: &CtxId,
747        policy: &VerificationPolicy,
748        fetcher: Option<&F>,
749    ) -> Result<(Self, VerificationReport), AcdpError> {
750        let ctx = client.retrieve(ctx_id).await?;
751
752        // Identifier binding — RFC-ACDP-0006 §4.1 step 7 (NORMATIVE, "Bind
753        // the resolved identity"). Same check as `verify_retrieved`,
754        // applied here because this path (backing both `fetch_report` and
755        // `fetch_report_with_fetcher`) never calls that function. See its
756        // doc comment for the full rationale.
757        if ctx.body.ctx_id != *ctx_id {
758            return Err(AcdpError::ContextIdMismatch {
759                requested: ctx_id.as_str().to_string(),
760                served: ctx.body.ctx_id.as_str().to_string(),
761            });
762        }
763
764        let mut report = VerificationReport {
765            body_hash_ok: false,
766            signature_ok: false,
767            schema_ok: false,
768            data_ref_embedded: Vec::with_capacity(ctx.body.data_refs.len()),
769            data_ref_external: Vec::with_capacity(ctx.body.data_refs.len()),
770            ctx_id_ok: true,
771        };
772
773        // Structural-only schema validation — embedded-hash checks are
774        // intentionally skipped here so per-DataRef hash failures land
775        // in the report (below) instead of short-circuiting the whole
776        // verification. That's the diagnostic shape `fetch_report`
777        // promises in its docstring.
778        if policy.validate_body_schema {
779            acdp_validation::validate_body_structural(&ctx.body)?;
780        }
781        report.schema_ok = true;
782
783        // Per-DataRef embedded-hash outcomes — recorded individually.
784        for dr in &ctx.body.data_refs {
785            if let (Some(emb), Some(_)) = (&dr.embedded, &dr.content_hash) {
786                let outcome = acdp_validation::verify_embedded_hash(dr)
787                    .and_then(|()| acdp_validation::embedded_decoded_bytes(emb).map(|b| b.len()));
788                report.data_ref_embedded.push(outcome);
789            } else {
790                report.data_ref_embedded.push(Ok(0));
791            }
792        }
793
794        // `verify_body_signed` recomputes content_hash + verifies the
795        // signature WITHOUT re-running the schema validator (we already
796        // ran the structural part above, and embedded-hash failures are
797        // recorded per-DataRef rather than aborting). It still enforces
798        // `did:web` for the producer key (RFC-ACDP-0001 §5.4).
799        Verifier::new(resolver)
800            .verify_body_signed(&ctx.body)
801            .await?;
802        report.body_hash_ok = true;
803        report.signature_ok = true;
804
805        if !policy.allow_unknown_status {
806            if let Some(other) = ctx.registry_state.status.as_other() {
807                return Err(AcdpError::SchemaViolation(format!(
808                    "policy.allow_unknown_status=false; registry returned '{other}'"
809                )));
810            }
811        }
812
813        // External fetches — record per-ref outcomes when a fetcher is
814        // supplied; otherwise leave each slot as `None` so callers can
815        // distinguish "skipped" from "failed".
816        for dr in &ctx.body.data_refs {
817            let slot: Option<Result<usize, AcdpError>> = match (fetcher, &dr.location) {
818                (Some(f), Some(_)) => Some(fetch_and_verify_data_ref(dr, f).await.map(|b| b.len())),
819                _ => None,
820            };
821            report.data_ref_external.push(slot);
822        }
823
824        Ok((
825            Self {
826                inner: ctx,
827                key_status: KeyAuthorization::CurrentlyAuthorized,
828                verified_receipt: None,
829                verified_head_receipt: None,
830                head_receipt_stale: None,
831            },
832            report,
833        ))
834    }
835
836    pub fn body(&self) -> &acdp_types::body::Body {
837        &self.inner.body
838    }
839
840    pub fn registry_state(&self) -> &acdp_types::body::RegistryState {
841        &self.inner.registry_state
842    }
843
844    /// The verified [`FullContext`] (body + registry state + any
845    /// receipts) in its retrieval shape. Every field was reached only
846    /// after this context's hash + signature were verified.
847    pub fn full_context(&self) -> &FullContext {
848        &self.inner
849    }
850
851    /// Whether the body verified against a currently authorized key, a
852    /// receipt-attested historical one, or a receipt-attested
853    /// pre-compromise one (ACDP 0.2 WS-B / RFC-ACDP-0014 §7).
854    pub fn key_status(&self) -> KeyAuthorization {
855        self.key_status
856    }
857
858    /// The verified registry receipt (RFC-ACDP-0010), when one was
859    /// present and the policy verified it. `None` under
860    /// [`ReceiptPolicy::Ignore`] or when the registry minted none. For
861    /// the raw on-wire value see [`Self::receipt`].
862    pub fn verified_receipt(&self) -> Option<&acdp_types::receipt::RegistryReceipt> {
863        self.verified_receipt.as_ref()
864    }
865
866    /// The verified lineage-head receipt (ACDP 0.3, RFC-ACDP-0011),
867    /// populated only by [`Self::fetch_current`] /
868    /// [`Self::fetch_current_with_policy`] when one was present and the
869    /// policy verified it. For the raw on-wire value see
870    /// [`Self::lineage_head_receipt`].
871    pub fn verified_head_receipt(&self) -> Option<&acdp_types::receipt::LineageHeadReceipt> {
872        self.verified_head_receipt.as_ref()
873    }
874
875    /// RFC-ACDP-0011 §6 freshness verdict for the verified head
876    /// receipt: `Some(true)` when the (genuine, verified) receipt's
877    /// `as_of` is older than [`LineageHeadPolicy::max_age_seconds`];
878    /// `Some(false)` when within policy; `None` when there is no
879    /// verified head receipt or the max-age knob is disabled.
880    pub fn head_receipt_stale(&self) -> Option<bool> {
881        self.head_receipt_stale
882    }
883
884    /// Raw registry receipt value as served on the wire
885    /// (RFC-ACDP-0010), preserved verbatim. For the verified, typed
886    /// form see [`Self::verified_receipt`].
887    pub fn receipt(&self) -> Option<&serde_json::Value> {
888        self.inner.registry_receipt.as_ref()
889    }
890
891    /// Raw lineage-head receipt value as served on the wire
892    /// (RFC-ACDP-0011), preserved verbatim. For the verified, typed
893    /// form see [`Self::verified_head_receipt`].
894    pub fn lineage_head_receipt(&self) -> Option<&serde_json::Value> {
895        self.inner.lineage_head_receipt.as_ref()
896    }
897
898    /// Verify the registry receipt, when one is present
899    /// (RFC-ACDP-0010).
900    ///
901    /// Standalone variant for contexts obtained via the report paths;
902    /// `fetch_with_policy` already does this under
903    /// [`ReceiptPolicy::VerifyIfPresent`]/`Require`. The serving
904    /// authority is taken from the context's own `ctx_id` — this method
905    /// performs no requested-id binding of its own (it has no requested
906    /// id to compare against; it only ever sees `self.inner.body.ctx_id`),
907    /// so deriving the serving authority this way is sound only for a
908    /// `VerifiedContext` obtained through a pipeline that already bound
909    /// the served `ctx_id` to the one requested. Every construction path
910    /// does: `fetch_with_policy` and `CrossRegistryResolver::resolve`
911    /// check it directly; `fetch_current_with_policy` does too,
912    /// tautologically, since `/current` has no requested id to diverge
913    /// from; `fetch_report`/`fetch_report_with_fetcher` check it and
914    /// return `ContextIdMismatch` on failure; and `fetch_report_diagnose`
915    /// folds it into its `all_top_level_pass` gate, so it only ever
916    /// hands back `Some(VerifiedContext)` when `ctx_id_ok` held. All of
917    /// these implement RFC-ACDP-0006 §4.1 step 7, so the type invariant
918    /// — every `VerifiedContext` was bound to its requested `ctx_id` —
919    /// holds unconditionally.
920    ///
921    /// Returns `Ok(None)` when no receipt is present, `Ok(Some(_))`
922    /// with the verified receipt otherwise.
923    ///
924    /// The receipt cross-check (RFC-ACDP-0010 §8 step 4) relies on
925    /// `body.content_hash` being the independently recomputed value.
926    /// That is guaranteed by the type invariant — every
927    /// `VerifiedContext` is built only after its constructing pipeline
928    /// verified the body hash (`Verifier::verify_body_hash` /
929    /// `verify_body_signed`), and the fields are private so no caller
930    /// can substitute an unverified body — so no re-derivation is
931    /// needed here.
932    pub async fn verify_receipt(
933        &self,
934        resolver: &WebResolver,
935    ) -> Result<Option<acdp_types::receipt::RegistryReceipt>, AcdpError> {
936        let Some(value) = &self.inner.registry_receipt else {
937            return Ok(None);
938        };
939        let fingerprint = acdp_crypto::fingerprint::fingerprint_for_key_id(
940            &self.inner.body.signature.key_id,
941            &self.inner.body.signature.algorithm,
942            resolver,
943        )
944        .await?;
945        let receipt = super::receipt::verify_receipt_value(
946            value,
947            &self.inner.body.ctx_id,
948            &self.inner.body,
949            &self.inner.body.content_hash,
950            &fingerprint,
951            self.inner.body.ctx_id.authority(),
952            resolver,
953        )
954        .await?;
955        Ok(Some(receipt))
956    }
957}
958
959/// Structured diagnostic outcome from [`VerifiedContext::fetch_report`].
960///
961/// Top-level booleans report the per-stage outcome of the verification
962/// pipeline. Per-`DataRef` slots track outcomes for each entry in
963/// `body.data_refs`, in declaration order:
964///
965/// - `data_ref_embedded[i]` — `Ok(decoded_size_bytes)` when the embedded
966///   payload's `content_hash` matched; `Err` when it didn't (or the
967///   embedded was malformed). Refs without an embedded payload or
968///   without a declared `content_hash` produce `Ok(0)`.
969/// - `data_ref_external[i]` — `None` when no external fetch was
970///   attempted (either no `location` or no `fetcher` was provided);
971///   `Some(Ok(bytes_len))` when the fetch + hash succeeded;
972///   `Some(Err(_))` on any failure (SSRF rejection, hash mismatch,
973///   timeout, …).
974///
975/// `AcdpError` doesn't implement `Clone`, so the report is move-only.
976///
977/// `#[non_exhaustive]`: this struct has already gained a field once as a
978/// non-optional consequence of a security fix (the RFC-ACDP-0006 §4.1
979/// context-identity binding), and it is output-only — constructed solely
980/// inside this crate (`verified.rs`) — so downstream loses nothing by
981/// being unable to construct it directly. Same rationale as `SsrfReason`
982/// in `crates/acdp-safe-http/src/lib.rs` ("future spec revisions may add
983/// ranges"): future fields stop being breaking changes for callers that
984/// only read this report.
985#[derive(Debug)]
986#[non_exhaustive]
987pub struct VerificationReport {
988    /// `content_hash` recomputed from the body matches the declared one.
989    pub body_hash_ok: bool,
990    /// The producer signature verified against the resolved DID key.
991    pub signature_ok: bool,
992    /// `validate_body` passed (or was disabled by policy).
993    pub schema_ok: bool,
994    /// Per-`DataRef` embedded-hash outcome, in `body.data_refs` order.
995    pub data_ref_embedded: Vec<Result<usize, AcdpError>>,
996    /// Per-`DataRef` external-fetch outcome, in `body.data_refs` order.
997    /// `None` indicates "not attempted" (no fetcher provided or no
998    /// `location` to fetch from).
999    pub data_ref_external: Vec<Option<Result<usize, AcdpError>>>,
1000    /// The served body's `ctx_id` equals the one requested
1001    /// (RFC-ACDP-0006 §4.1 step 7, NORMATIVE — "Bind the resolved
1002    /// identity"). `false` means the registry served a different,
1003    /// validly-signed body under the requested id (context
1004    /// substitution); see `VerifiedContext::verify_retrieved`'s doc for
1005    /// the full rationale. This flag gates whether
1006    /// [`VerifiedContext::fetch_report_diagnose`] hands back a
1007    /// `Some(VerifiedContext)` — appended last so any positional
1008    /// construction fails loudly rather than silently binding the wrong
1009    /// field.
1010    pub ctx_id_ok: bool,
1011}
1012
1013/// Sentinel `DataRefFetcher` used as the type parameter for
1014/// `fetch_report_inner` when no fetcher is supplied. `fetch` is never
1015/// actually called — the option is matched out before that — but
1016/// providing a real impl lets the generic monomorphize cleanly without
1017/// requiring `fetch_report`'s callers to name a type.
1018struct NoFetcher;
1019
1020impl DataRefFetcher for NoFetcher {
1021    async fn fetch(
1022        &self,
1023        _location: &acdp_types::data_ref::Location,
1024    ) -> Result<Vec<u8>, AcdpError> {
1025        Err(AcdpError::NotImplemented(
1026            "NoFetcher should never be called — this is a fetch_report sentinel".into(),
1027        ))
1028    }
1029}
1030
1031#[cfg(test)]
1032mod tests {
1033    use super::{HistoricalKeyPolicy, ReceiptPolicy, VerificationPolicy};
1034
1035    /// The RFC-ACDP-0001 §9.2 named constructor preserves exact v0.1.0
1036    /// semantics: receipts inert, assertionMethod-only keys. It is
1037    /// deliberately NOT the 0.2 default (which is receipt-aware).
1038    #[test]
1039    fn strict_v0_1_0_preserves_v0_1_0_semantics() {
1040        let strict = VerificationPolicy::strict_v0_1_0();
1041        assert!(strict.validate_body_schema);
1042        assert!(strict.allow_unknown_status);
1043        assert_eq!(strict.receipts, ReceiptPolicy::Ignore);
1044        assert_eq!(strict.historical_keys, HistoricalKeyPolicy::Reject);
1045        assert!(
1046            strict.revocations.known.is_empty(),
1047            "a 0.1.0-pinned consumer is unaffected by RFC-ACDP-0014"
1048        );
1049        assert_ne!(
1050            strict,
1051            VerificationPolicy::default(),
1052            "the 0.2 default is receipt-aware; the v0.1.0 profile is not"
1053        );
1054    }
1055}