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
53impl Default for VerificationPolicy {
54    fn default() -> Self {
55        Self {
56            validate_body_schema: true,
57            allow_unknown_status: true,
58            receipts: ReceiptPolicy::VerifyIfPresent,
59            historical_keys: HistoricalKeyPolicy::AcceptWithReceipt,
60            lineage_head: LineageHeadPolicy::default(),
61        }
62    }
63}
64
65/// How to treat the optional `registry_receipt` on retrieval
66/// (RFC-ACDP-0010).
67#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
68pub enum ReceiptPolicy {
69    /// Skip receipt verification entirely (0.1.0 behavior). The
70    /// receipt value is still preserved verbatim on the context.
71    Ignore,
72    /// Verify the receipt when one is present; absence is not an
73    /// error (the registry may simply be a 0.1.0 registry). Default.
74    #[default]
75    VerifyIfPresent,
76    /// Fail closed unless a receipt is present AND verifies. Use when
77    /// the deployment requires audit-grade provenance — registry
78    /// claims (`ctx_id`, `created_at`, `origin_registry`) are
79    /// assertions, not proofs, without a receipt.
80    Require,
81}
82
83/// How to treat the optional `lineage_head_receipt` on
84/// `GET /lineages/{id}/current` responses (ACDP 0.3, RFC-ACDP-0011).
85///
86/// The presence handling reuses the [`ReceiptPolicy`] vocabulary; the
87/// two numeric knobs are the RFC's consumer-side parameters:
88///
89/// - `max_clock_skew_seconds` — §7 step 6's forward-skew allowance. A
90///   receipt whose `as_of` is further in the future **fails
91///   verification** (`invalid_receipt`, fixture `lhr-004`). RFC
92///   RECOMMENDED: 120.
93/// - `max_age_seconds` — §6's freshness policy. A receipt older than
94///   this is still *verified* (it may be perfectly genuine — merely
95///   old); it is reported distinctly via
96///   [`VerifiedContext::head_receipt_stale`], never as a verification
97///   failure. RFC RECOMMENDED default: 300. `None` disables the
98///   staleness verdict.
99#[derive(Debug, Clone, Copy, PartialEq, Eq)]
100pub struct LineageHeadPolicy {
101    /// Presence handling: `Ignore` (skip verification, preserve
102    /// verbatim), `VerifyIfPresent` (default), or `Require` (fail
103    /// closed unless present AND verified — appropriate when the
104    /// registry advertises `acdp-registry-head-receipts`, under which
105    /// a head receipt on `/current` is REQUIRED, RFC-ACDP-0011 §6).
106    pub receipts: ReceiptPolicy,
107    /// RFC-ACDP-0011 §7 step 6 clock-skew allowance (default 120 s).
108    pub max_clock_skew_seconds: u32,
109    /// RFC-ACDP-0011 §6 maximum acceptable receipt age for the
110    /// staleness verdict (default `Some(300)`).
111    pub max_age_seconds: Option<u32>,
112}
113
114impl Default for LineageHeadPolicy {
115    fn default() -> Self {
116        Self {
117            receipts: ReceiptPolicy::VerifyIfPresent,
118            max_clock_skew_seconds: 120,
119            max_age_seconds: Some(300),
120        }
121    }
122}
123
124/// How to treat a producer key that is present in the DID document's
125/// `verificationMethod` but no longer in `assertionMethod` — i.e. a
126/// key the producer rotated out but retained per the RFC-ACDP-0010
127/// key-retention rule.
128#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
129pub enum HistoricalKeyPolicy {
130    /// Strict 0.1.0 behavior: only `assertionMethod` keys verify.
131    /// Every context signed by a rotated-out key fails.
132    Reject,
133    /// Accept a retained key **only** when a verified registry receipt
134    /// attests (via `key_fingerprint`) that this exact key was the
135    /// authorized one at publish time. Without a verified receipt the
136    /// historical path never activates — fail closed. Default.
137    #[default]
138    AcceptWithReceipt,
139}
140
141/// How the producer key that verified the body relates to the
142/// producer's *current* DID document.
143#[derive(Debug, Clone, Copy, PartialEq, Eq)]
144pub enum KeyAuthorization {
145    /// The signing key is currently listed in `assertionMethod`.
146    CurrentlyAuthorized,
147    /// The signing key was rotated out of `assertionMethod` but is
148    /// retained in `verificationMethod`, and a verified registry
149    /// receipt attests it was the authorized key at publish time
150    /// (RFC-ACDP-0010). Weigh accordingly: valid history, not a
151    /// current endorsement.
152    HistoricallyAuthorized,
153}
154
155impl VerificationPolicy {
156    /// The v0.1.0 strict verification profile (RFC-ACDP-0001 §5.11, §9.2).
157    ///
158    /// Runs the full §5.11 pipeline: body schema validation, `content_hash`
159    /// recomputation, `did:web` key resolution, signature verification, and
160    /// embedded `data_ref.content_hash` checks. Returns on the first failure.
161    ///
162    /// This is the **only** mode covered by the `acdp-consumer` conformance
163    /// profile. Relaxed modes (`Diagnostic`, `UnsafeForTests`) are NOT
164    /// available in this crate in v0.1.0 — they would be separately-named
165    /// opt-ins per §9.2, and are not currently implemented.
166    ///
167    /// NOT identical to [`Default::default()`] as of 0.2: the default
168    /// policy is receipt-aware (`VerifyIfPresent` + `AcceptWithReceipt`),
169    /// while this named profile preserves the exact v0.1.0 semantics —
170    /// receipts inert ([`ReceiptPolicy::Ignore`]) and only
171    /// `assertionMethod` keys accepted
172    /// ([`HistoricalKeyPolicy::Reject`]). Callers pinned to this
173    /// constructor keep v0.1.0 behavior across the 0.2 upgrade.
174    pub fn strict_v0_1_0() -> Self {
175        Self {
176            validate_body_schema: true,
177            allow_unknown_status: true,
178            receipts: ReceiptPolicy::Ignore,
179            historical_keys: HistoricalKeyPolicy::Reject,
180            lineage_head: LineageHeadPolicy {
181                receipts: ReceiptPolicy::Ignore,
182                ..LineageHeadPolicy::default()
183            },
184        }
185    }
186}
187
188/// A retrieved context that has been cryptographically verified.
189#[derive(Debug)]
190pub struct VerifiedContext {
191    pub inner: FullContext,
192    /// Whether the body verified against a currently authorized key or
193    /// a receipt-attested historical one (ACDP 0.2, WS-B).
194    pub key_status: KeyAuthorization,
195    /// The verified registry receipt, when one was present and the
196    /// policy verified it (RFC-ACDP-0010). `None` under
197    /// [`ReceiptPolicy::Ignore`] or when the registry minted none.
198    pub verified_receipt: Option<acdp_types::receipt::RegistryReceipt>,
199    /// The verified lineage-head receipt (ACDP 0.3, RFC-ACDP-0011),
200    /// when one was present and the policy verified it. Only populated
201    /// by [`Self::fetch_current`] / [`Self::fetch_current_with_policy`]
202    /// — plain retrieval preserves the raw value verbatim without
203    /// verification. Per §7 this verdict is independent of the body
204    /// verdict and the RFC-ACDP-0010 receipt verdict.
205    pub verified_head_receipt: Option<acdp_types::receipt::LineageHeadReceipt>,
206    /// RFC-ACDP-0011 §6 freshness verdict for the verified head
207    /// receipt, reported distinctly from verification: `Some(true)`
208    /// when the (genuine, verified) receipt's `as_of` is older than
209    /// [`LineageHeadPolicy::max_age_seconds`]; `Some(false)` when
210    /// within policy; `None` when there is no verified head receipt or
211    /// the max-age knob is disabled.
212    pub head_receipt_stale: Option<bool>,
213}
214
215impl VerifiedContext {
216    /// Retrieve a context and verify its signature using the strict
217    /// default [`VerificationPolicy`].
218    pub async fn fetch(
219        client: &RegistryClient,
220        resolver: &WebResolver,
221        ctx_id: &CtxId,
222    ) -> Result<Self, AcdpError> {
223        Self::fetch_with_policy(client, resolver, ctx_id, &VerificationPolicy::default()).await
224    }
225
226    /// Retrieve a context and verify its signature with caller-controlled
227    /// strictness.
228    ///
229    /// 1. Fetches `body + registry_state` from the registry.
230    /// 2. Optionally runs `validate_body` — structural schema checks
231    ///    plus embedded-`DataRef` hash verification (policy-controlled).
232    /// 3. Recomputes `content_hash` over ProducerContent.
233    /// 4. Resolves the producer's DID document. `did:web` is required
234    ///    unconditionally for v0.1.0 (RFC-ACDP-0001 §5.4).
235    /// 5. Verifies the Ed25519 signature (or other supported algorithm).
236    /// 6. Optionally verifies the `registry_receipt` placeholder.
237    /// 7. Optionally rejects unknown statuses.
238    pub async fn fetch_with_policy(
239        client: &RegistryClient,
240        resolver: &WebResolver,
241        ctx_id: &CtxId,
242        policy: &VerificationPolicy,
243    ) -> Result<Self, AcdpError> {
244        let ctx = client.retrieve(ctx_id).await?;
245        let (key_status, verified_receipt) =
246            Self::verify_retrieved(client, resolver, &ctx, ctx_id, policy).await?;
247        Ok(Self {
248            inner: ctx,
249            key_status,
250            verified_receipt,
251            verified_head_receipt: None,
252            head_receipt_stale: None,
253        })
254    }
255
256    /// Retrieve the current head of a lineage
257    /// (`GET /lineages/{lineage_id}/current`) and verify it with the
258    /// strict default [`VerificationPolicy`] — including the
259    /// lineage-head receipt when the registry minted one (ACDP 0.3,
260    /// RFC-ACDP-0011).
261    pub async fn fetch_current(
262        client: &RegistryClient,
263        resolver: &WebResolver,
264        lineage_id: &acdp_types::primitives::LineageId,
265    ) -> Result<Self, AcdpError> {
266        Self::fetch_current_with_policy(
267            client,
268            resolver,
269            lineage_id,
270            &VerificationPolicy::default(),
271        )
272        .await
273    }
274
275    /// Retrieve + verify the current head of a lineage with
276    /// caller-controlled strictness.
277    ///
278    /// Runs the same pipeline as [`Self::fetch_with_policy`] against
279    /// the `/current` response (the expected `ctx_id` is the served
280    /// body's own — there is no requested identifier on this endpoint;
281    /// the head receipt's §7 step 5 byte-match is what binds it), then
282    /// applies `policy.lineage_head` to the response's
283    /// `lineage_head_receipt` per RFC-ACDP-0011 §7:
284    ///
285    /// - [`ReceiptPolicy::Ignore`] — the raw value is preserved
286    ///   verbatim, unverified.
287    /// - [`ReceiptPolicy::VerifyIfPresent`] — verified when present
288    ///   (absence is fine: the registry may not advertise
289    ///   `acdp-registry-head-receipts`).
290    /// - [`ReceiptPolicy::Require`] — fail closed with
291    ///   `invalid_receipt` unless present AND verified.
292    ///
293    /// Verification fetches the registry's capabilities document for
294    /// the §7 step 3 `capabilities.registry_did` binding. Staleness
295    /// beyond `policy.lineage_head.max_age_seconds` is a *freshness*
296    /// verdict reported via [`Self::head_receipt_stale`], never a
297    /// verification failure (§6).
298    pub async fn fetch_current_with_policy(
299        client: &RegistryClient,
300        resolver: &WebResolver,
301        lineage_id: &acdp_types::primitives::LineageId,
302        policy: &VerificationPolicy,
303    ) -> Result<Self, AcdpError> {
304        let ctx = client.current(lineage_id).await?;
305        let served_ctx_id = ctx.body.ctx_id.clone();
306        let (key_status, verified_receipt) =
307            Self::verify_retrieved(client, resolver, &ctx, &served_ctx_id, policy).await?;
308
309        // ── Lineage-head receipt phase (RFC-ACDP-0011) ──────────────
310        let (verified_head_receipt, head_receipt_stale) =
311            match (policy.lineage_head.receipts, &ctx.lineage_head_receipt) {
312                (ReceiptPolicy::Ignore, _) | (ReceiptPolicy::VerifyIfPresent, None) => (None, None),
313                (ReceiptPolicy::Require, None) => {
314                    return Err(AcdpError::InvalidReceipt(
315                        "policy requires a lineage-head receipt but the /current response \
316                         carries none (registry without the acdp-registry-head-receipts \
317                         profile?)"
318                            .into(),
319                    ));
320                }
321                (_, Some(value)) => {
322                    let serving_authority = client
323                        .authority()
324                        .unwrap_or_else(|| served_ctx_id.authority().to_string());
325                    // §7 step 3 needs capabilities.registry_did — fetched
326                    // from the same authority the context came from.
327                    let caps = client.capabilities().await?;
328                    let receipt = super::receipt::verify_lineage_head_receipt_value(
329                        value,
330                        lineage_id,
331                        &served_ctx_id,
332                        ctx.body.version,
333                        &ctx.registry_state.status,
334                        true, // /current always serves the attested head
335                        &serving_authority,
336                        &caps.registry_did,
337                        chrono::Duration::seconds(
338                            policy.lineage_head.max_clock_skew_seconds as i64,
339                        ),
340                        resolver,
341                    )
342                    .await?;
343                    let stale = policy.lineage_head.max_age_seconds.map(|max| {
344                        receipt.age_at(chrono::Utc::now()) > chrono::Duration::seconds(max as i64)
345                    });
346                    (Some(receipt), stale)
347                }
348            };
349
350        Ok(Self {
351            inner: ctx,
352            key_status,
353            verified_receipt,
354            verified_head_receipt,
355            head_receipt_stale,
356        })
357    }
358
359    /// The shared retrieve-side verification pipeline: body schema,
360    /// hash recomputation, RFC-ACDP-0010 receipt phase, signature
361    /// phase (with the receipt-gated historical-key fallback), and the
362    /// unknown-status policy check.
363    async fn verify_retrieved(
364        client: &RegistryClient,
365        resolver: &WebResolver,
366        ctx: &FullContext,
367        expected_ctx_id: &CtxId,
368        policy: &VerificationPolicy,
369    ) -> Result<
370        (
371            KeyAuthorization,
372            Option<acdp_types::receipt::RegistryReceipt>,
373        ),
374        AcdpError,
375    > {
376        if policy.validate_body_schema {
377            acdp_validation::validate_body(&ctx.body)?;
378        }
379
380        // Hash recomputation first: from here on `ctx.body.content_hash`
381        // IS the independently recomputed value, which the receipt
382        // cross-check below relies on.
383        let verifier = Verifier::new(resolver);
384        verifier.verify_body_hash(&ctx.body)?;
385
386        // ── Receipt phase (RFC-ACDP-0010) ───────────────────────────
387        // Verified BEFORE the signature phase because the historical-
388        // key path is gated on a verified receipt.
389        let serving_authority = client
390            .authority()
391            .unwrap_or_else(|| expected_ctx_id.authority().to_string());
392        let verified_receipt = match (policy.receipts, &ctx.registry_receipt) {
393            (ReceiptPolicy::Ignore, _) | (ReceiptPolicy::VerifyIfPresent, None) => None,
394            (ReceiptPolicy::Require, None) => {
395                return Err(AcdpError::InvalidReceipt(
396                    "policy requires a registry receipt but the response carries none \
397                     (registry without the acdp-registry-receipts profile, or a \
398                     pre-receipts context)"
399                        .into(),
400                ));
401            }
402            (_, Some(value)) => {
403                let fingerprint = acdp_crypto::fingerprint::fingerprint_for_key_id(
404                    &ctx.body.signature.key_id,
405                    &ctx.body.signature.algorithm,
406                    resolver,
407                )
408                .await?;
409                Some(
410                    super::receipt::verify_receipt_value(
411                        value,
412                        expected_ctx_id,
413                        &ctx.body,
414                        &ctx.body.content_hash,
415                        &fingerprint,
416                        &serving_authority,
417                        resolver,
418                    )
419                    .await?,
420                )
421            }
422        };
423
424        // ── Signature phase ──────────────────────────────────────────
425        // Standard path enforces assertionMethod membership. A
426        // KeyNotAuthorized failure falls back to the historical path
427        // only under AcceptWithReceipt AND a verified receipt — the
428        // receipt's key_fingerprint (already cross-checked against this
429        // exact key above) is what attests publish-time authorization.
430        let key_status = match verifier.verify_body_signature(&ctx.body).await {
431            Ok(()) => KeyAuthorization::CurrentlyAuthorized,
432            Err(AcdpError::KeyNotAuthorized(_))
433                if policy.historical_keys == HistoricalKeyPolicy::AcceptWithReceipt
434                    && verified_receipt.is_some() =>
435            {
436                acdp_verify::verify_body_signature_historical(&ctx.body, resolver).await?;
437                KeyAuthorization::HistoricallyAuthorized
438            }
439            Err(e) => return Err(e),
440        };
441
442        if !policy.allow_unknown_status {
443            if let Some(other) = ctx.registry_state.status.as_other() {
444                return Err(AcdpError::SchemaViolation(format!(
445                    "policy.allow_unknown_status=false; registry returned '{other}'"
446                )));
447            }
448        }
449
450        Ok((key_status, verified_receipt))
451    }
452
453    /// Retrieve + verify, returning a structured [`VerificationReport`]
454    /// alongside the verified context. Does NOT attempt external
455    /// `DataRef` fetches — use [`Self::fetch_report_with_fetcher`] for
456    /// that. Each `data_ref_external` slot in the returned report is
457    /// `None`.
458    ///
459    /// Unlike [`Self::fetch_with_policy`], per-`DataRef` embedded-hash
460    /// failures are recorded in the report instead of aborting the
461    /// verification. The top-level checks (schema, body hash,
462    /// signature) remain hard-fail: if any of them fails, the method
463    /// returns an `AcdpError` and produces no report.
464    ///
465    /// For diagnostic callers that want a populated report even when
466    /// a top-level check fails (e.g. an audit walker that needs to
467    /// distinguish "wrong hash" from "wrong signature"), use
468    /// [`Self::fetch_report_diagnose`] instead.
469    pub async fn fetch_report(
470        client: &RegistryClient,
471        resolver: &WebResolver,
472        ctx_id: &CtxId,
473        policy: &VerificationPolicy,
474    ) -> Result<(Self, VerificationReport), AcdpError> {
475        Self::fetch_report_inner::<NoFetcher>(client, resolver, ctx_id, policy, None).await
476    }
477
478    /// Diagnostic variant of [`Self::fetch_report`] that never
479    /// short-circuits on a top-level failure — schema, body-hash, and
480    /// signature outcomes are each recorded individually in the
481    /// returned [`VerificationReport`]. Returns `Ok((None, report))`
482    /// when any top-level stage failed (the report shows which one);
483    /// `Ok((Some(verified), report))` only when every check passed
484    /// (FEAT-05).
485    ///
486    /// Use cases:
487    /// - Audit walkers that need to classify failures by stage.
488    /// - Admin tooling that wants to distinguish "hash mismatch"
489    ///   (probable tampering / encoding drift) from "signature
490    ///   verification failed" (key compromise / DID resolution
491    ///   problem).
492    ///
493    /// Network errors (retrieve, DID resolution) still propagate as
494    /// `Err` — there's no body to inspect when the registry is
495    /// unreachable.
496    pub async fn fetch_report_diagnose(
497        client: &RegistryClient,
498        resolver: &WebResolver,
499        ctx_id: &CtxId,
500        policy: &VerificationPolicy,
501    ) -> Result<(Option<Self>, VerificationReport), AcdpError> {
502        let ctx = client.retrieve(ctx_id).await?;
503        let mut report = VerificationReport {
504            body_hash_ok: false,
505            signature_ok: false,
506            schema_ok: false,
507            data_ref_embedded: Vec::with_capacity(ctx.body.data_refs.len()),
508            data_ref_external: Vec::with_capacity(ctx.body.data_refs.len()),
509        };
510
511        // Schema (structural) — record pass/fail.
512        if policy.validate_body_schema {
513            match acdp_validation::validate_body_structural(&ctx.body) {
514                Ok(()) => report.schema_ok = true,
515                Err(_) => { /* keep schema_ok=false; continue collecting */ }
516            }
517        } else {
518            report.schema_ok = true;
519        }
520
521        // Per-DataRef embedded hashes — same as fetch_report_inner.
522        for dr in &ctx.body.data_refs {
523            if let (Some(emb), Some(_)) = (&dr.embedded, &dr.content_hash) {
524                let outcome = acdp_validation::verify_embedded_hash(dr)
525                    .and_then(|()| acdp_validation::embedded_decoded_bytes(emb).map(|b| b.len()));
526                report.data_ref_embedded.push(outcome);
527            } else {
528                report.data_ref_embedded.push(Ok(0));
529            }
530        }
531
532        // Hash + signature recorded independently (FEAT-05).
533        let verifier = Verifier::new(resolver);
534        report.body_hash_ok = verifier.verify_body_hash(&ctx.body).is_ok();
535        report.signature_ok = verifier.verify_body_signature(&ctx.body).await.is_ok();
536
537        // External fetches were not attempted (this method has no
538        // fetcher param — diagnostic callers can wire their own).
539        for _ in &ctx.body.data_refs {
540            report.data_ref_external.push(None);
541        }
542
543        // Decide whether to surface the verified handle. Report paths
544        // run the strict assertionMethod check only (no receipt /
545        // historical handling — use `fetch_with_policy` for those).
546        let all_top_level_pass = report.schema_ok && report.body_hash_ok && report.signature_ok;
547        let verified = if all_top_level_pass {
548            Some(Self {
549                inner: ctx,
550                key_status: KeyAuthorization::CurrentlyAuthorized,
551                verified_receipt: None,
552                verified_head_receipt: None,
553                head_receipt_stale: None,
554            })
555        } else {
556            None
557        };
558        Ok((verified, report))
559    }
560
561    /// Retrieve + verify like [`Self::fetch_report`], and additionally
562    /// fetch every `DataRef` whose `location` resolves through `fetcher`.
563    /// Each external fetch outcome is recorded in `report.data_ref_external`.
564    pub async fn fetch_report_with_fetcher<F: DataRefFetcher>(
565        client: &RegistryClient,
566        resolver: &WebResolver,
567        ctx_id: &CtxId,
568        policy: &VerificationPolicy,
569        fetcher: &F,
570    ) -> Result<(Self, VerificationReport), AcdpError> {
571        Self::fetch_report_inner(client, resolver, ctx_id, policy, Some(fetcher)).await
572    }
573
574    async fn fetch_report_inner<F: DataRefFetcher>(
575        client: &RegistryClient,
576        resolver: &WebResolver,
577        ctx_id: &CtxId,
578        policy: &VerificationPolicy,
579        fetcher: Option<&F>,
580    ) -> Result<(Self, VerificationReport), AcdpError> {
581        let ctx = client.retrieve(ctx_id).await?;
582        let mut report = VerificationReport {
583            body_hash_ok: false,
584            signature_ok: false,
585            schema_ok: false,
586            data_ref_embedded: Vec::with_capacity(ctx.body.data_refs.len()),
587            data_ref_external: Vec::with_capacity(ctx.body.data_refs.len()),
588        };
589
590        // Structural-only schema validation — embedded-hash checks are
591        // intentionally skipped here so per-DataRef hash failures land
592        // in the report (below) instead of short-circuiting the whole
593        // verification. That's the diagnostic shape `fetch_report`
594        // promises in its docstring.
595        if policy.validate_body_schema {
596            acdp_validation::validate_body_structural(&ctx.body)?;
597        }
598        report.schema_ok = true;
599
600        // Per-DataRef embedded-hash outcomes — recorded individually.
601        for dr in &ctx.body.data_refs {
602            if let (Some(emb), Some(_)) = (&dr.embedded, &dr.content_hash) {
603                let outcome = acdp_validation::verify_embedded_hash(dr)
604                    .and_then(|()| acdp_validation::embedded_decoded_bytes(emb).map(|b| b.len()));
605                report.data_ref_embedded.push(outcome);
606            } else {
607                report.data_ref_embedded.push(Ok(0));
608            }
609        }
610
611        // `verify_body_signed` recomputes content_hash + verifies the
612        // signature WITHOUT re-running the schema validator (we already
613        // ran the structural part above, and embedded-hash failures are
614        // recorded per-DataRef rather than aborting). It still enforces
615        // `did:web` for the producer key (RFC-ACDP-0001 §5.4).
616        Verifier::new(resolver)
617            .verify_body_signed(&ctx.body)
618            .await?;
619        report.body_hash_ok = true;
620        report.signature_ok = true;
621
622        if !policy.allow_unknown_status {
623            if let Some(other) = ctx.registry_state.status.as_other() {
624                return Err(AcdpError::SchemaViolation(format!(
625                    "policy.allow_unknown_status=false; registry returned '{other}'"
626                )));
627            }
628        }
629
630        // External fetches — record per-ref outcomes when a fetcher is
631        // supplied; otherwise leave each slot as `None` so callers can
632        // distinguish "skipped" from "failed".
633        for dr in &ctx.body.data_refs {
634            let slot: Option<Result<usize, AcdpError>> = match (fetcher, &dr.location) {
635                (Some(f), Some(_)) => Some(fetch_and_verify_data_ref(dr, f).await.map(|b| b.len())),
636                _ => None,
637            };
638            report.data_ref_external.push(slot);
639        }
640
641        Ok((
642            Self {
643                inner: ctx,
644                key_status: KeyAuthorization::CurrentlyAuthorized,
645                verified_receipt: None,
646                verified_head_receipt: None,
647                head_receipt_stale: None,
648            },
649            report,
650        ))
651    }
652
653    pub fn body(&self) -> &acdp_types::body::Body {
654        &self.inner.body
655    }
656
657    pub fn registry_state(&self) -> &acdp_types::body::RegistryState {
658        &self.inner.registry_state
659    }
660
661    /// Raw registry receipt value as served on the wire
662    /// (RFC-ACDP-0010), preserved verbatim. For the verified, typed
663    /// form see [`Self::verified_receipt`].
664    pub fn receipt(&self) -> Option<&serde_json::Value> {
665        self.inner.registry_receipt.as_ref()
666    }
667
668    /// Verify the registry receipt, when one is present
669    /// (RFC-ACDP-0010).
670    ///
671    /// Standalone variant for contexts obtained via the report paths or
672    /// constructed externally; `fetch_with_policy` already does this
673    /// under [`ReceiptPolicy::VerifyIfPresent`]/`Require`. The serving
674    /// authority is taken from the context's own `ctx_id` — correct
675    /// when the context was fetched from its home registry, which is
676    /// the only retrieval shape v0.2 defines.
677    ///
678    /// Returns `Ok(None)` when no receipt is present, `Ok(Some(_))`
679    /// with the verified receipt otherwise.
680    pub async fn verify_receipt(
681        &self,
682        resolver: &WebResolver,
683    ) -> Result<Option<acdp_types::receipt::RegistryReceipt>, AcdpError> {
684        let Some(value) = &self.inner.registry_receipt else {
685            return Ok(None);
686        };
687        // Recompute the body hash rather than trusting the echoed
688        // `body.content_hash`: all fields of this type are public, so a
689        // caller may have constructed it around an unverified
690        // FullContext, and the receipt cross-check is only meaningful
691        // against an independently recomputed hash (RFC-ACDP-0010 §8
692        // step 4).
693        let body_val = serde_json::to_value(&self.inner.body)?;
694        let recomputed = acdp_crypto::compute_content_hash(&body_val)?;
695        if recomputed != self.inner.body.content_hash {
696            return Err(AcdpError::HashMismatch {
697                stored: self.inner.body.content_hash.clone(),
698                recomputed,
699            });
700        }
701        let fingerprint = acdp_crypto::fingerprint::fingerprint_for_key_id(
702            &self.inner.body.signature.key_id,
703            &self.inner.body.signature.algorithm,
704            resolver,
705        )
706        .await?;
707        let receipt = super::receipt::verify_receipt_value(
708            value,
709            &self.inner.body.ctx_id,
710            &self.inner.body,
711            &self.inner.body.content_hash,
712            &fingerprint,
713            self.inner.body.ctx_id.authority(),
714            resolver,
715        )
716        .await?;
717        Ok(Some(receipt))
718    }
719}
720
721/// Structured diagnostic outcome from [`VerifiedContext::fetch_report`].
722///
723/// Top-level booleans report the per-stage outcome of the verification
724/// pipeline. Per-`DataRef` slots track outcomes for each entry in
725/// `body.data_refs`, in declaration order:
726///
727/// - `data_ref_embedded[i]` — `Ok(decoded_size_bytes)` when the embedded
728///   payload's `content_hash` matched; `Err` when it didn't (or the
729///   embedded was malformed). Refs without an embedded payload or
730///   without a declared `content_hash` produce `Ok(0)`.
731/// - `data_ref_external[i]` — `None` when no external fetch was
732///   attempted (either no `location` or no `fetcher` was provided);
733///   `Some(Ok(bytes_len))` when the fetch + hash succeeded;
734///   `Some(Err(_))` on any failure (SSRF rejection, hash mismatch,
735///   timeout, …).
736///
737/// `AcdpError` doesn't implement `Clone`, so the report is move-only.
738#[derive(Debug)]
739pub struct VerificationReport {
740    /// `content_hash` recomputed from the body matches the declared one.
741    pub body_hash_ok: bool,
742    /// The producer signature verified against the resolved DID key.
743    pub signature_ok: bool,
744    /// `validate_body` passed (or was disabled by policy).
745    pub schema_ok: bool,
746    /// Per-`DataRef` embedded-hash outcome, in `body.data_refs` order.
747    pub data_ref_embedded: Vec<Result<usize, AcdpError>>,
748    /// Per-`DataRef` external-fetch outcome, in `body.data_refs` order.
749    /// `None` indicates "not attempted" (no fetcher provided or no
750    /// `location` to fetch from).
751    pub data_ref_external: Vec<Option<Result<usize, AcdpError>>>,
752}
753
754/// Sentinel `DataRefFetcher` used as the type parameter for
755/// `fetch_report_inner` when no fetcher is supplied. `fetch` is never
756/// actually called — the option is matched out before that — but
757/// providing a real impl lets the generic monomorphize cleanly without
758/// requiring `fetch_report`'s callers to name a type.
759struct NoFetcher;
760
761impl DataRefFetcher for NoFetcher {
762    async fn fetch(
763        &self,
764        _location: &acdp_types::data_ref::Location,
765    ) -> Result<Vec<u8>, AcdpError> {
766        Err(AcdpError::NotImplemented(
767            "NoFetcher should never be called — this is a fetch_report sentinel".into(),
768        ))
769    }
770}
771
772#[cfg(test)]
773mod tests {
774    use super::{HistoricalKeyPolicy, ReceiptPolicy, VerificationPolicy};
775
776    /// The RFC-ACDP-0001 §9.2 named constructor preserves exact v0.1.0
777    /// semantics: receipts inert, assertionMethod-only keys. It is
778    /// deliberately NOT the 0.2 default (which is receipt-aware).
779    #[test]
780    fn strict_v0_1_0_preserves_v0_1_0_semantics() {
781        let strict = VerificationPolicy::strict_v0_1_0();
782        assert!(strict.validate_body_schema);
783        assert!(strict.allow_unknown_status);
784        assert_eq!(strict.receipts, ReceiptPolicy::Ignore);
785        assert_eq!(strict.historical_keys, HistoricalKeyPolicy::Reject);
786        assert_ne!(
787            strict,
788            VerificationPolicy::default(),
789            "the 0.2 default is receipt-aware; the v0.1.0 profile is not"
790        );
791    }
792}