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///
251/// Every value of this type is the output of one of the
252/// `VerifiedContext::fetch*` pipelines, each of which independently
253/// recomputes `content_hash` (RFC-ACDP-0001 §5.11) and verifies the
254/// producer signature before the value is constructed. The fields are
255/// **private** precisely so this "cryptographically verified" invariant
256/// cannot be forged: there is no way to construct a `VerifiedContext`
257/// around an unverified [`FullContext`]. Downstream code can therefore
258/// trust the accessors below without re-deriving anything.
259#[derive(Debug)]
260pub struct VerifiedContext {
261 inner: FullContext,
262 /// Whether the body verified against a currently authorized key or
263 /// a receipt-attested historical one (ACDP 0.2, WS-B).
264 key_status: KeyAuthorization,
265 /// The verified registry receipt, when one was present and the
266 /// policy verified it (RFC-ACDP-0010). `None` under
267 /// [`ReceiptPolicy::Ignore`] or when the registry minted none.
268 verified_receipt: Option<acdp_types::receipt::RegistryReceipt>,
269 /// The verified lineage-head receipt (ACDP 0.3, RFC-ACDP-0011),
270 /// when one was present and the policy verified it. Only populated
271 /// by [`Self::fetch_current`] / [`Self::fetch_current_with_policy`]
272 /// — plain retrieval preserves the raw value verbatim without
273 /// verification. Per §7 this verdict is independent of the body
274 /// verdict and the RFC-ACDP-0010 receipt verdict.
275 verified_head_receipt: Option<acdp_types::receipt::LineageHeadReceipt>,
276 /// RFC-ACDP-0011 §6 freshness verdict for the verified head
277 /// receipt, reported distinctly from verification: `Some(true)`
278 /// when the (genuine, verified) receipt's `as_of` is older than
279 /// [`LineageHeadPolicy::max_age_seconds`]; `Some(false)` when
280 /// within policy; `None` when there is no verified head receipt or
281 /// the max-age knob is disabled.
282 head_receipt_stale: Option<bool>,
283}
284
285impl VerifiedContext {
286 /// Retrieve a context and verify its signature using the strict
287 /// default [`VerificationPolicy`].
288 pub async fn fetch(
289 client: &RegistryClient,
290 resolver: &WebResolver,
291 ctx_id: &CtxId,
292 ) -> Result<Self, AcdpError> {
293 Self::fetch_with_policy(client, resolver, ctx_id, &VerificationPolicy::default()).await
294 }
295
296 /// Retrieve a context and verify its signature with caller-controlled
297 /// strictness.
298 ///
299 /// 1. Fetches `body + registry_state` from the registry.
300 /// 2. Optionally runs `validate_body` — structural schema checks
301 /// plus embedded-`DataRef` hash verification (policy-controlled).
302 /// 3. Recomputes `content_hash` over ProducerContent.
303 /// 4. Resolves the producer's DID document. `did:web` is required
304 /// unconditionally for v0.1.0 (RFC-ACDP-0001 §5.4).
305 /// 5. Verifies the Ed25519 signature (or other supported algorithm).
306 /// 6. Optionally verifies the `registry_receipt` placeholder.
307 /// 7. Optionally rejects unknown statuses.
308 pub async fn fetch_with_policy(
309 client: &RegistryClient,
310 resolver: &WebResolver,
311 ctx_id: &CtxId,
312 policy: &VerificationPolicy,
313 ) -> Result<Self, AcdpError> {
314 let ctx = client.retrieve(ctx_id).await?;
315 let (key_status, verified_receipt) =
316 Self::verify_retrieved(client, resolver, &ctx, ctx_id, policy).await?;
317 Ok(Self {
318 inner: ctx,
319 key_status,
320 verified_receipt,
321 verified_head_receipt: None,
322 head_receipt_stale: None,
323 })
324 }
325
326 /// Retrieve the current head of a lineage
327 /// (`GET /lineages/{lineage_id}/current`) and verify it with the
328 /// strict default [`VerificationPolicy`] — including the
329 /// lineage-head receipt when the registry minted one (ACDP 0.3,
330 /// RFC-ACDP-0011).
331 pub async fn fetch_current(
332 client: &RegistryClient,
333 resolver: &WebResolver,
334 lineage_id: &acdp_types::primitives::LineageId,
335 ) -> Result<Self, AcdpError> {
336 Self::fetch_current_with_policy(
337 client,
338 resolver,
339 lineage_id,
340 &VerificationPolicy::default(),
341 )
342 .await
343 }
344
345 /// Retrieve + verify the current head of a lineage with
346 /// caller-controlled strictness.
347 ///
348 /// Runs the same pipeline as [`Self::fetch_with_policy`] against
349 /// the `/current` response (the expected `ctx_id` is the served
350 /// body's own — there is no requested identifier on this endpoint;
351 /// the head receipt's §7 step 5 byte-match is what binds it), then
352 /// applies `policy.lineage_head` to the response's
353 /// `lineage_head_receipt` per RFC-ACDP-0011 §7:
354 ///
355 /// - [`ReceiptPolicy::Ignore`] — the raw value is preserved
356 /// verbatim, unverified.
357 /// - [`ReceiptPolicy::VerifyIfPresent`] — verified when present
358 /// (absence is fine: the registry may not advertise
359 /// `acdp-registry-head-receipts`).
360 /// - [`ReceiptPolicy::Require`] — fail closed with
361 /// `invalid_receipt` unless present AND verified.
362 ///
363 /// Verification fetches the registry's capabilities document for
364 /// the §7 step 3 `capabilities.registry_did` binding. Staleness
365 /// beyond `policy.lineage_head.max_age_seconds` is a *freshness*
366 /// verdict reported via [`Self::head_receipt_stale`], never a
367 /// verification failure (§6).
368 pub async fn fetch_current_with_policy(
369 client: &RegistryClient,
370 resolver: &WebResolver,
371 lineage_id: &acdp_types::primitives::LineageId,
372 policy: &VerificationPolicy,
373 ) -> Result<Self, AcdpError> {
374 let ctx = client.current(lineage_id).await?;
375 let served_ctx_id = ctx.body.ctx_id.clone();
376 let (key_status, verified_receipt) =
377 Self::verify_retrieved(client, resolver, &ctx, &served_ctx_id, policy).await?;
378
379 // ── Lineage-head receipt phase (RFC-ACDP-0011) ──────────────
380 let (verified_head_receipt, head_receipt_stale) =
381 match (policy.lineage_head.receipts, &ctx.lineage_head_receipt) {
382 (ReceiptPolicy::Ignore, _) | (ReceiptPolicy::VerifyIfPresent, None) => (None, None),
383 (ReceiptPolicy::Require, None) => {
384 return Err(AcdpError::InvalidReceipt(
385 "policy requires a lineage-head receipt but the /current response \
386 carries none (registry without the acdp-registry-head-receipts \
387 profile?)"
388 .into(),
389 ));
390 }
391 (_, Some(value)) => {
392 let serving_authority = client
393 .authority()
394 .unwrap_or_else(|| served_ctx_id.authority().to_string());
395 // §7 step 3 needs capabilities.registry_did — fetched
396 // from the same authority the context came from.
397 let caps = client.capabilities().await?;
398 let receipt = super::receipt::verify_lineage_head_receipt_value(
399 value,
400 lineage_id,
401 &served_ctx_id,
402 ctx.body.version,
403 &ctx.registry_state.status,
404 true, // /current always serves the attested head
405 &serving_authority,
406 &caps.registry_did,
407 chrono::Duration::seconds(
408 policy.lineage_head.max_clock_skew_seconds as i64,
409 ),
410 resolver,
411 )
412 .await?;
413 let stale = policy.lineage_head.max_age_seconds.map(|max| {
414 receipt.age_at(chrono::Utc::now()) > chrono::Duration::seconds(max as i64)
415 });
416 (Some(receipt), stale)
417 }
418 };
419
420 Ok(Self {
421 inner: ctx,
422 key_status,
423 verified_receipt,
424 verified_head_receipt,
425 head_receipt_stale,
426 })
427 }
428
429 /// The shared retrieve-side verification pipeline: body schema,
430 /// hash recomputation, RFC-ACDP-0010 receipt phase, signature
431 /// phase (with the receipt-gated historical-key fallback), and the
432 /// unknown-status policy check.
433 async fn verify_retrieved(
434 client: &RegistryClient,
435 resolver: &WebResolver,
436 ctx: &FullContext,
437 expected_ctx_id: &CtxId,
438 policy: &VerificationPolicy,
439 ) -> Result<
440 (
441 KeyAuthorization,
442 Option<acdp_types::receipt::RegistryReceipt>,
443 ),
444 AcdpError,
445 > {
446 if policy.validate_body_schema {
447 acdp_validation::validate_body(&ctx.body)?;
448 }
449
450 // Hash recomputation first: from here on `ctx.body.content_hash`
451 // IS the independently recomputed value, which the receipt
452 // cross-check below relies on.
453 let verifier = Verifier::new(resolver);
454 verifier.verify_body_hash(&ctx.body)?;
455
456 // ── Receipt phase (RFC-ACDP-0010) ───────────────────────────
457 // Verified BEFORE the signature phase because the historical-
458 // key path is gated on a verified receipt.
459 let serving_authority = client
460 .authority()
461 .unwrap_or_else(|| expected_ctx_id.authority().to_string());
462 let verified_receipt = match (policy.receipts, &ctx.registry_receipt) {
463 (ReceiptPolicy::Ignore, _) | (ReceiptPolicy::VerifyIfPresent, None) => None,
464 (ReceiptPolicy::Require, None) => {
465 return Err(AcdpError::InvalidReceipt(
466 "policy requires a registry receipt but the response carries none \
467 (registry without the acdp-registry-receipts profile, or a \
468 pre-receipts context)"
469 .into(),
470 ));
471 }
472 (_, Some(value)) => {
473 let fingerprint = acdp_crypto::fingerprint::fingerprint_for_key_id(
474 &ctx.body.signature.key_id,
475 &ctx.body.signature.algorithm,
476 resolver,
477 )
478 .await?;
479 Some(
480 super::receipt::verify_receipt_value(
481 value,
482 expected_ctx_id,
483 &ctx.body,
484 &ctx.body.content_hash,
485 &fingerprint,
486 &serving_authority,
487 resolver,
488 )
489 .await?,
490 )
491 }
492 };
493
494 // ── Revocation phase (RFC-ACDP-0014 §7) ─────────────────────
495 // Runs after the receipt phase because the boundary comparison
496 // accepts ONLY a receipt-attested publish time (§7 step 1 —
497 // the bare body created_at is registry-assigned and MUST NOT
498 // be used). The verified receipt's key_fingerprint was already
499 // cross-checked against the body's signing key above (§8 step
500 // 5), so `verified_receipt.created_at` genuinely places THIS
501 // key's signature in time.
502 let revocation_verdict = if policy.revocations.known.is_empty() {
503 None
504 } else {
505 let fingerprint = acdp_crypto::fingerprint::fingerprint_for_key_id(
506 &ctx.body.signature.key_id,
507 &ctx.body.signature.algorithm,
508 resolver,
509 )
510 .await?;
511 super::revocation::classify_under_revocation(
512 &policy.revocations.known,
513 &fingerprint,
514 verified_receipt.as_ref().map(|r| r.created_at),
515 )?
516 };
517
518 // ── Signature phase ──────────────────────────────────────────
519 // Standard path enforces assertionMethod membership. A
520 // KeyNotAuthorized failure falls back to the historical path
521 // only under AcceptWithReceipt AND a verified receipt — the
522 // receipt's key_fingerprint (already cross-checked against this
523 // exact key above) is what attests publish-time authorization.
524 let key_status = match revocation_verdict {
525 // Pre-compromise (§7 step 2): the signature is verified
526 // under the RFC-ACDP-0010 §10 historical rule — the key may
527 // legitimately have left assertionMethod (and SHOULD, §9),
528 // and even a key still in assertionMethod MUST NOT be
529 // reported as fully current once revoked. did:key material
530 // cannot rotate, so it takes the plain envelope path.
531 Some(pre_compromise) => {
532 if ctx.body.agent_id.as_str().starts_with("did:key:") {
533 verifier.verify_body_signature(&ctx.body).await?;
534 } else {
535 acdp_verify::verify_body_signature_historical(&ctx.body, resolver).await?;
536 }
537 pre_compromise
538 }
539 None => match verifier.verify_body_signature(&ctx.body).await {
540 Ok(()) => KeyAuthorization::CurrentlyAuthorized,
541 Err(AcdpError::KeyNotAuthorized(_))
542 if policy.historical_keys == HistoricalKeyPolicy::AcceptWithReceipt
543 && verified_receipt.is_some() =>
544 {
545 acdp_verify::verify_body_signature_historical(&ctx.body, resolver).await?;
546 KeyAuthorization::HistoricallyAuthorized
547 }
548 Err(e) => return Err(e),
549 },
550 };
551
552 if !policy.allow_unknown_status {
553 if let Some(other) = ctx.registry_state.status.as_other() {
554 return Err(AcdpError::SchemaViolation(format!(
555 "policy.allow_unknown_status=false; registry returned '{other}'"
556 )));
557 }
558 }
559
560 Ok((key_status, verified_receipt))
561 }
562
563 /// Retrieve + verify, returning a structured [`VerificationReport`]
564 /// alongside the verified context. Does NOT attempt external
565 /// `DataRef` fetches — use [`Self::fetch_report_with_fetcher`] for
566 /// that. Each `data_ref_external` slot in the returned report is
567 /// `None`.
568 ///
569 /// Unlike [`Self::fetch_with_policy`], per-`DataRef` embedded-hash
570 /// failures are recorded in the report instead of aborting the
571 /// verification. The top-level checks (schema, body hash,
572 /// signature) remain hard-fail: if any of them fails, the method
573 /// returns an `AcdpError` and produces no report.
574 ///
575 /// For diagnostic callers that want a populated report even when
576 /// a top-level check fails (e.g. an audit walker that needs to
577 /// distinguish "wrong hash" from "wrong signature"), use
578 /// [`Self::fetch_report_diagnose`] instead.
579 pub async fn fetch_report(
580 client: &RegistryClient,
581 resolver: &WebResolver,
582 ctx_id: &CtxId,
583 policy: &VerificationPolicy,
584 ) -> Result<(Self, VerificationReport), AcdpError> {
585 Self::fetch_report_inner::<NoFetcher>(client, resolver, ctx_id, policy, None).await
586 }
587
588 /// Diagnostic variant of [`Self::fetch_report`] that never
589 /// short-circuits on a top-level failure — schema, body-hash, and
590 /// signature outcomes are each recorded individually in the
591 /// returned [`VerificationReport`]. Returns `Ok((None, report))`
592 /// when any top-level stage failed (the report shows which one);
593 /// `Ok((Some(verified), report))` only when every check passed
594 /// (FEAT-05).
595 ///
596 /// Use cases:
597 /// - Audit walkers that need to classify failures by stage.
598 /// - Admin tooling that wants to distinguish "hash mismatch"
599 /// (probable tampering / encoding drift) from "signature
600 /// verification failed" (key compromise / DID resolution
601 /// problem).
602 ///
603 /// Network errors (retrieve, DID resolution) still propagate as
604 /// `Err` — there's no body to inspect when the registry is
605 /// unreachable.
606 pub async fn fetch_report_diagnose(
607 client: &RegistryClient,
608 resolver: &WebResolver,
609 ctx_id: &CtxId,
610 policy: &VerificationPolicy,
611 ) -> Result<(Option<Self>, VerificationReport), AcdpError> {
612 let ctx = client.retrieve(ctx_id).await?;
613 let mut report = VerificationReport {
614 body_hash_ok: false,
615 signature_ok: false,
616 schema_ok: false,
617 data_ref_embedded: Vec::with_capacity(ctx.body.data_refs.len()),
618 data_ref_external: Vec::with_capacity(ctx.body.data_refs.len()),
619 };
620
621 // Schema (structural) — record pass/fail.
622 if policy.validate_body_schema {
623 match acdp_validation::validate_body_structural(&ctx.body) {
624 Ok(()) => report.schema_ok = true,
625 Err(_) => { /* keep schema_ok=false; continue collecting */ }
626 }
627 } else {
628 report.schema_ok = true;
629 }
630
631 // Per-DataRef embedded hashes — same as fetch_report_inner.
632 for dr in &ctx.body.data_refs {
633 if let (Some(emb), Some(_)) = (&dr.embedded, &dr.content_hash) {
634 let outcome = acdp_validation::verify_embedded_hash(dr)
635 .and_then(|()| acdp_validation::embedded_decoded_bytes(emb).map(|b| b.len()));
636 report.data_ref_embedded.push(outcome);
637 } else {
638 report.data_ref_embedded.push(Ok(0));
639 }
640 }
641
642 // Hash + signature recorded independently (FEAT-05).
643 let verifier = Verifier::new(resolver);
644 report.body_hash_ok = verifier.verify_body_hash(&ctx.body).is_ok();
645 report.signature_ok = verifier.verify_body_signature(&ctx.body).await.is_ok();
646
647 // External fetches were not attempted (this method has no
648 // fetcher param — diagnostic callers can wire their own).
649 for _ in &ctx.body.data_refs {
650 report.data_ref_external.push(None);
651 }
652
653 // Decide whether to surface the verified handle. Report paths
654 // run the strict assertionMethod check only (no receipt /
655 // historical handling — use `fetch_with_policy` for those).
656 let all_top_level_pass = report.schema_ok && report.body_hash_ok && report.signature_ok;
657 let verified = if all_top_level_pass {
658 Some(Self {
659 inner: ctx,
660 key_status: KeyAuthorization::CurrentlyAuthorized,
661 verified_receipt: None,
662 verified_head_receipt: None,
663 head_receipt_stale: None,
664 })
665 } else {
666 None
667 };
668 Ok((verified, report))
669 }
670
671 /// Retrieve + verify like [`Self::fetch_report`], and additionally
672 /// fetch every `DataRef` whose `location` resolves through `fetcher`.
673 /// Each external fetch outcome is recorded in `report.data_ref_external`.
674 pub async fn fetch_report_with_fetcher<F: DataRefFetcher>(
675 client: &RegistryClient,
676 resolver: &WebResolver,
677 ctx_id: &CtxId,
678 policy: &VerificationPolicy,
679 fetcher: &F,
680 ) -> Result<(Self, VerificationReport), AcdpError> {
681 Self::fetch_report_inner(client, resolver, ctx_id, policy, Some(fetcher)).await
682 }
683
684 async fn fetch_report_inner<F: DataRefFetcher>(
685 client: &RegistryClient,
686 resolver: &WebResolver,
687 ctx_id: &CtxId,
688 policy: &VerificationPolicy,
689 fetcher: Option<&F>,
690 ) -> Result<(Self, VerificationReport), AcdpError> {
691 let ctx = client.retrieve(ctx_id).await?;
692 let mut report = VerificationReport {
693 body_hash_ok: false,
694 signature_ok: false,
695 schema_ok: false,
696 data_ref_embedded: Vec::with_capacity(ctx.body.data_refs.len()),
697 data_ref_external: Vec::with_capacity(ctx.body.data_refs.len()),
698 };
699
700 // Structural-only schema validation — embedded-hash checks are
701 // intentionally skipped here so per-DataRef hash failures land
702 // in the report (below) instead of short-circuiting the whole
703 // verification. That's the diagnostic shape `fetch_report`
704 // promises in its docstring.
705 if policy.validate_body_schema {
706 acdp_validation::validate_body_structural(&ctx.body)?;
707 }
708 report.schema_ok = true;
709
710 // Per-DataRef embedded-hash outcomes — recorded individually.
711 for dr in &ctx.body.data_refs {
712 if let (Some(emb), Some(_)) = (&dr.embedded, &dr.content_hash) {
713 let outcome = acdp_validation::verify_embedded_hash(dr)
714 .and_then(|()| acdp_validation::embedded_decoded_bytes(emb).map(|b| b.len()));
715 report.data_ref_embedded.push(outcome);
716 } else {
717 report.data_ref_embedded.push(Ok(0));
718 }
719 }
720
721 // `verify_body_signed` recomputes content_hash + verifies the
722 // signature WITHOUT re-running the schema validator (we already
723 // ran the structural part above, and embedded-hash failures are
724 // recorded per-DataRef rather than aborting). It still enforces
725 // `did:web` for the producer key (RFC-ACDP-0001 §5.4).
726 Verifier::new(resolver)
727 .verify_body_signed(&ctx.body)
728 .await?;
729 report.body_hash_ok = true;
730 report.signature_ok = true;
731
732 if !policy.allow_unknown_status {
733 if let Some(other) = ctx.registry_state.status.as_other() {
734 return Err(AcdpError::SchemaViolation(format!(
735 "policy.allow_unknown_status=false; registry returned '{other}'"
736 )));
737 }
738 }
739
740 // External fetches — record per-ref outcomes when a fetcher is
741 // supplied; otherwise leave each slot as `None` so callers can
742 // distinguish "skipped" from "failed".
743 for dr in &ctx.body.data_refs {
744 let slot: Option<Result<usize, AcdpError>> = match (fetcher, &dr.location) {
745 (Some(f), Some(_)) => Some(fetch_and_verify_data_ref(dr, f).await.map(|b| b.len())),
746 _ => None,
747 };
748 report.data_ref_external.push(slot);
749 }
750
751 Ok((
752 Self {
753 inner: ctx,
754 key_status: KeyAuthorization::CurrentlyAuthorized,
755 verified_receipt: None,
756 verified_head_receipt: None,
757 head_receipt_stale: None,
758 },
759 report,
760 ))
761 }
762
763 pub fn body(&self) -> &acdp_types::body::Body {
764 &self.inner.body
765 }
766
767 pub fn registry_state(&self) -> &acdp_types::body::RegistryState {
768 &self.inner.registry_state
769 }
770
771 /// The verified [`FullContext`] (body + registry state + any
772 /// receipts) in its retrieval shape. Every field was reached only
773 /// after this context's hash + signature were verified.
774 pub fn full_context(&self) -> &FullContext {
775 &self.inner
776 }
777
778 /// Whether the body verified against a currently authorized key, a
779 /// receipt-attested historical one, or a receipt-attested
780 /// pre-compromise one (ACDP 0.2 WS-B / RFC-ACDP-0014 §7).
781 pub fn key_status(&self) -> KeyAuthorization {
782 self.key_status
783 }
784
785 /// The verified registry receipt (RFC-ACDP-0010), when one was
786 /// present and the policy verified it. `None` under
787 /// [`ReceiptPolicy::Ignore`] or when the registry minted none. For
788 /// the raw on-wire value see [`Self::receipt`].
789 pub fn verified_receipt(&self) -> Option<&acdp_types::receipt::RegistryReceipt> {
790 self.verified_receipt.as_ref()
791 }
792
793 /// The verified lineage-head receipt (ACDP 0.3, RFC-ACDP-0011),
794 /// populated only by [`Self::fetch_current`] /
795 /// [`Self::fetch_current_with_policy`] when one was present and the
796 /// policy verified it. For the raw on-wire value see
797 /// [`Self::lineage_head_receipt`].
798 pub fn verified_head_receipt(&self) -> Option<&acdp_types::receipt::LineageHeadReceipt> {
799 self.verified_head_receipt.as_ref()
800 }
801
802 /// RFC-ACDP-0011 §6 freshness verdict for the verified head
803 /// receipt: `Some(true)` when the (genuine, verified) receipt's
804 /// `as_of` is older than [`LineageHeadPolicy::max_age_seconds`];
805 /// `Some(false)` when within policy; `None` when there is no
806 /// verified head receipt or the max-age knob is disabled.
807 pub fn head_receipt_stale(&self) -> Option<bool> {
808 self.head_receipt_stale
809 }
810
811 /// Raw registry receipt value as served on the wire
812 /// (RFC-ACDP-0010), preserved verbatim. For the verified, typed
813 /// form see [`Self::verified_receipt`].
814 pub fn receipt(&self) -> Option<&serde_json::Value> {
815 self.inner.registry_receipt.as_ref()
816 }
817
818 /// Raw lineage-head receipt value as served on the wire
819 /// (RFC-ACDP-0011), preserved verbatim. For the verified, typed
820 /// form see [`Self::verified_head_receipt`].
821 pub fn lineage_head_receipt(&self) -> Option<&serde_json::Value> {
822 self.inner.lineage_head_receipt.as_ref()
823 }
824
825 /// Verify the registry receipt, when one is present
826 /// (RFC-ACDP-0010).
827 ///
828 /// Standalone variant for contexts obtained via the report paths;
829 /// `fetch_with_policy` already does this under
830 /// [`ReceiptPolicy::VerifyIfPresent`]/`Require`. The serving
831 /// authority is taken from the context's own `ctx_id` — correct
832 /// when the context was fetched from its home registry, which is
833 /// the only retrieval shape v0.2 defines.
834 ///
835 /// Returns `Ok(None)` when no receipt is present, `Ok(Some(_))`
836 /// with the verified receipt otherwise.
837 ///
838 /// The receipt cross-check (RFC-ACDP-0010 §8 step 4) relies on
839 /// `body.content_hash` being the independently recomputed value.
840 /// That is guaranteed by the type invariant — every
841 /// `VerifiedContext` is built only after its constructing pipeline
842 /// verified the body hash (`Verifier::verify_body_hash` /
843 /// `verify_body_signed`), and the fields are private so no caller
844 /// can substitute an unverified body — so no re-derivation is
845 /// needed here.
846 pub async fn verify_receipt(
847 &self,
848 resolver: &WebResolver,
849 ) -> Result<Option<acdp_types::receipt::RegistryReceipt>, AcdpError> {
850 let Some(value) = &self.inner.registry_receipt else {
851 return Ok(None);
852 };
853 let fingerprint = acdp_crypto::fingerprint::fingerprint_for_key_id(
854 &self.inner.body.signature.key_id,
855 &self.inner.body.signature.algorithm,
856 resolver,
857 )
858 .await?;
859 let receipt = super::receipt::verify_receipt_value(
860 value,
861 &self.inner.body.ctx_id,
862 &self.inner.body,
863 &self.inner.body.content_hash,
864 &fingerprint,
865 self.inner.body.ctx_id.authority(),
866 resolver,
867 )
868 .await?;
869 Ok(Some(receipt))
870 }
871}
872
873/// Structured diagnostic outcome from [`VerifiedContext::fetch_report`].
874///
875/// Top-level booleans report the per-stage outcome of the verification
876/// pipeline. Per-`DataRef` slots track outcomes for each entry in
877/// `body.data_refs`, in declaration order:
878///
879/// - `data_ref_embedded[i]` — `Ok(decoded_size_bytes)` when the embedded
880/// payload's `content_hash` matched; `Err` when it didn't (or the
881/// embedded was malformed). Refs without an embedded payload or
882/// without a declared `content_hash` produce `Ok(0)`.
883/// - `data_ref_external[i]` — `None` when no external fetch was
884/// attempted (either no `location` or no `fetcher` was provided);
885/// `Some(Ok(bytes_len))` when the fetch + hash succeeded;
886/// `Some(Err(_))` on any failure (SSRF rejection, hash mismatch,
887/// timeout, …).
888///
889/// `AcdpError` doesn't implement `Clone`, so the report is move-only.
890#[derive(Debug)]
891pub struct VerificationReport {
892 /// `content_hash` recomputed from the body matches the declared one.
893 pub body_hash_ok: bool,
894 /// The producer signature verified against the resolved DID key.
895 pub signature_ok: bool,
896 /// `validate_body` passed (or was disabled by policy).
897 pub schema_ok: bool,
898 /// Per-`DataRef` embedded-hash outcome, in `body.data_refs` order.
899 pub data_ref_embedded: Vec<Result<usize, AcdpError>>,
900 /// Per-`DataRef` external-fetch outcome, in `body.data_refs` order.
901 /// `None` indicates "not attempted" (no fetcher provided or no
902 /// `location` to fetch from).
903 pub data_ref_external: Vec<Option<Result<usize, AcdpError>>>,
904}
905
906/// Sentinel `DataRefFetcher` used as the type parameter for
907/// `fetch_report_inner` when no fetcher is supplied. `fetch` is never
908/// actually called — the option is matched out before that — but
909/// providing a real impl lets the generic monomorphize cleanly without
910/// requiring `fetch_report`'s callers to name a type.
911struct NoFetcher;
912
913impl DataRefFetcher for NoFetcher {
914 async fn fetch(
915 &self,
916 _location: &acdp_types::data_ref::Location,
917 ) -> Result<Vec<u8>, AcdpError> {
918 Err(AcdpError::NotImplemented(
919 "NoFetcher should never be called — this is a fetch_report sentinel".into(),
920 ))
921 }
922}
923
924#[cfg(test)]
925mod tests {
926 use super::{HistoricalKeyPolicy, ReceiptPolicy, VerificationPolicy};
927
928 /// The RFC-ACDP-0001 §9.2 named constructor preserves exact v0.1.0
929 /// semantics: receipts inert, assertionMethod-only keys. It is
930 /// deliberately NOT the 0.2 default (which is receipt-aware).
931 #[test]
932 fn strict_v0_1_0_preserves_v0_1_0_semantics() {
933 let strict = VerificationPolicy::strict_v0_1_0();
934 assert!(strict.validate_body_schema);
935 assert!(strict.allow_unknown_status);
936 assert_eq!(strict.receipts, ReceiptPolicy::Ignore);
937 assert_eq!(strict.historical_keys, HistoricalKeyPolicy::Reject);
938 assert!(
939 strict.revocations.known.is_empty(),
940 "a 0.1.0-pinned consumer is unaffected by RFC-ACDP-0014"
941 );
942 assert_ne!(
943 strict,
944 VerificationPolicy::default(),
945 "the 0.2 default is receipt-aware; the v0.1.0 profile is not"
946 );
947 }
948}