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