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