Skip to main content

sbo3l_core/
passport.rs

1//! SBO3L Passport capsule structural verification (P1.1).
2//!
3//! A *passport capsule* (`sbo3l.passport_capsule.v1`) is the portable,
4//! offline-verifiable proof artifact wrapping one SBO3L decision plus
5//! its surrounding identity, request, policy, execution, audit, and
6//! verification context. The capsule is composed from existing SBO3L
7//! primitives (APRP, PolicyReceipt, SignedAuditEvent, AuditCheckpoint,
8//! ENS records) — this module DOES NOT redefine them, it only checks
9//! how they appear together inside one capsule.
10//!
11//! `verify_capsule` is **structural only** in P1.1:
12//!
13//! 1. Schema validation via [`crate::schema::validate_passport_capsule`].
14//! 2. Internal-consistency invariants from
15//!    `docs/product/SBO3L_PASSPORT_SOURCE_OF_TRUTH.md`:
16//!    - `decision.result == "deny"` ⇒ `execution.status == "not_called"`
17//!      and `execution.execution_ref` is null.
18//!    - `execution.mode == "live"` ⇒ `execution.live_evidence` contains
19//!      at least one concrete reference (`transport`, `response_ref`, or
20//!      `block_ref`).
21//!    - `request.request_hash == decision.receipt.request_hash`.
22//!    - `policy.policy_hash == decision.receipt.policy_hash`.
23//!    - `decision.result == decision.receipt.decision`.
24//!    - `agent.agent_id == decision.receipt.agent_id`.
25//!    - `audit.audit_event_id == decision.receipt.audit_event_id`.
26//!    - When `audit.checkpoint` is present:
27//!      `audit.checkpoint.latest_event_hash == audit.event_hash` and the
28//!      schema-level `mock_anchor: true` invariant has already fired.
29//!
30//! Crypto-level checks (Ed25519 signature verification, audit-chain
31//! prev-hash linkage, full APRP re-hashing to confirm `request_hash`)
32//! are intentionally **out of scope** for P1.1. They belong in P2.1
33//! (`sbo3l passport run/verify`), where the verifier wraps the
34//! existing `audit_bundle` codec rather than reimplementing it.
35
36use serde_json::Value;
37
38use crate::audit_bundle::{self, AuditBundle, BundleError};
39use crate::error::SchemaError;
40use crate::hashing;
41use crate::receipt::PolicyReceipt;
42use crate::signer::VerifyError;
43
44/// Reasons a capsule fails structural verification.
45#[derive(Debug, thiserror::Error)]
46pub enum CapsuleVerifyError {
47    #[error("capsule.schema_invalid: {0}")]
48    SchemaInvalid(#[from] SchemaError),
49
50    /// `decision.result == "deny"` but the capsule still records an
51    /// execution call. This is the strongest truthfulness rule for the
52    /// capsule: a denied action must never have reached an executor.
53    #[error(
54        "capsule.deny_with_execution: deny capsule must have execution.status=\"not_called\" \
55         and execution.execution_ref=null; got status={status:?} execution_ref={execution_ref:?}"
56    )]
57    DenyWithExecution {
58        status: String,
59        execution_ref: Option<String>,
60    },
61
62    /// `execution.mode == "live"` requires concrete `live_evidence`.
63    /// Live without evidence is the prototypical "fake live" claim.
64    #[error(
65        "capsule.live_without_evidence: execution.mode=\"live\" requires non-null \
66         execution.live_evidence with at least one of transport/response_ref/block_ref"
67    )]
68    LiveWithoutEvidence,
69
70    /// `execution.mode == "mock"` must NOT carry live evidence.
71    #[error(
72        "capsule.mock_with_live_evidence: execution.mode=\"mock\" must have null \
73         execution.live_evidence; live_evidence on a mock execution is a mislabel"
74    )]
75    MockWithLiveEvidence,
76
77    /// `request.request_hash` and the embedded receipt's `request_hash`
78    /// disagree. The capsule is internally inconsistent.
79    #[error(
80        "capsule.request_hash_mismatch: request.request_hash={outer} but \
81         decision.receipt.request_hash={receipt}"
82    )]
83    RequestHashMismatch { outer: String, receipt: String },
84
85    /// `policy.policy_hash` and the embedded receipt's `policy_hash`
86    /// disagree.
87    #[error(
88        "capsule.policy_hash_mismatch: policy.policy_hash={outer} but \
89         decision.receipt.policy_hash={receipt}"
90    )]
91    PolicyHashMismatch { outer: String, receipt: String },
92
93    /// `decision.result` and `decision.receipt.decision` disagree.
94    #[error(
95        "capsule.decision_result_mismatch: decision.result={outer} but \
96         decision.receipt.decision={receipt}"
97    )]
98    DecisionResultMismatch { outer: String, receipt: String },
99
100    /// `agent.agent_id` and `decision.receipt.agent_id` disagree.
101    #[error(
102        "capsule.agent_id_mismatch: agent.agent_id={outer} but \
103         decision.receipt.agent_id={receipt}"
104    )]
105    AgentIdMismatch { outer: String, receipt: String },
106
107    /// `audit.audit_event_id` and `decision.receipt.audit_event_id`
108    /// disagree.
109    #[error(
110        "capsule.audit_event_id_mismatch: audit.audit_event_id={outer} but \
111         decision.receipt.audit_event_id={receipt}"
112    )]
113    AuditEventIdMismatch { outer: String, receipt: String },
114
115    /// Embedded checkpoint's `latest_event_hash` doesn't match the
116    /// outer `audit.event_hash`. The capsule is internally inconsistent.
117    #[error(
118        "capsule.checkpoint_event_hash_mismatch: audit.event_hash={outer} but \
119         audit.checkpoint.latest_event_hash={checkpoint}"
120    )]
121    CheckpointEventHashMismatch { outer: String, checkpoint: String },
122
123    /// `audit.audit_segment` (v2 self-contained verification slot)
124    /// exceeds the 1 MiB cap. The cap is an anti-DoS guard: a verifier
125    /// loading an attacker-supplied capsule should never have to allocate
126    /// arbitrary memory just to walk the chain.
127    #[error(
128        "capsule.audit_segment_too_large: capsule.audit.audit_segment is {bytes} bytes \
129         (cap is {cap_bytes} bytes / 1 MiB); verifier refuses to deserialise"
130    )]
131    AuditSegmentTooLarge { bytes: usize, cap_bytes: usize },
132
133    /// Catch-all for malformed but technically schema-valid capsules
134    /// where a required nested string is the wrong shape after schema
135    /// validation passed (e.g. an enum value snuck through). Should be
136    /// rare; helps surface internal logic bugs.
137    #[error("capsule.malformed: {detail}")]
138    Malformed { detail: String },
139}
140
141impl CapsuleVerifyError {
142    /// Stable machine-readable error code for CLI/JSON consumers.
143    pub fn code(&self) -> &'static str {
144        match self {
145            Self::SchemaInvalid(_) => "capsule.schema_invalid",
146            Self::DenyWithExecution { .. } => "capsule.deny_with_execution",
147            Self::LiveWithoutEvidence => "capsule.live_without_evidence",
148            Self::MockWithLiveEvidence => "capsule.mock_with_live_evidence",
149            Self::RequestHashMismatch { .. } => "capsule.request_hash_mismatch",
150            Self::PolicyHashMismatch { .. } => "capsule.policy_hash_mismatch",
151            Self::DecisionResultMismatch { .. } => "capsule.decision_result_mismatch",
152            Self::AgentIdMismatch { .. } => "capsule.agent_id_mismatch",
153            Self::AuditEventIdMismatch { .. } => "capsule.audit_event_id_mismatch",
154            Self::CheckpointEventHashMismatch { .. } => "capsule.checkpoint_event_hash_mismatch",
155            Self::AuditSegmentTooLarge { .. } => "capsule.audit_segment_too_large",
156            Self::Malformed { .. } => "capsule.malformed",
157        }
158    }
159}
160
161/// Maximum byte size of `audit.audit_segment` when serialised to JSON.
162/// 1 MiB (1024 * 1024). Anti-DoS guard against a capsule that bloats
163/// the verifier's memory footprint with a multi-MB chain segment. The
164/// production-shaped audit chain is genesis-through-decision-event so
165/// the legitimate size grows as O(N) in the chain length up to the
166/// capsule's own decision; 1 MiB easily covers ten-thousand-event
167/// chains.
168pub const AUDIT_SEGMENT_BYTE_CAP: usize = 1024 * 1024;
169
170/// Run schema validation **and** the cross-field truthfulness invariants
171/// against `value`. Returns `Ok(())` only if every check passes. The
172/// first violation surfaces — caller can re-check after fixing the cause.
173pub fn verify_capsule(value: &Value) -> std::result::Result<(), CapsuleVerifyError> {
174    crate::schema::validate_passport_capsule(value)?;
175
176    // Schema guarantees `decision`, `execution`, `request`, `policy`,
177    // `agent`, `audit` exist and have the right shapes. We unwrap with
178    // `Malformed` fallback purely as defense-in-depth — a passing
179    // schema validation should make these impossible.
180    let decision = value
181        .get("decision")
182        .ok_or_else(|| CapsuleVerifyError::Malformed {
183            detail: "decision missing after schema-pass".into(),
184        })?;
185    let execution = value
186        .get("execution")
187        .ok_or_else(|| CapsuleVerifyError::Malformed {
188            detail: "execution missing after schema-pass".into(),
189        })?;
190    let request = value
191        .get("request")
192        .ok_or_else(|| CapsuleVerifyError::Malformed {
193            detail: "request missing after schema-pass".into(),
194        })?;
195    let policy = value
196        .get("policy")
197        .ok_or_else(|| CapsuleVerifyError::Malformed {
198            detail: "policy missing after schema-pass".into(),
199        })?;
200    let agent = value
201        .get("agent")
202        .ok_or_else(|| CapsuleVerifyError::Malformed {
203            detail: "agent missing after schema-pass".into(),
204        })?;
205    let audit = value
206        .get("audit")
207        .ok_or_else(|| CapsuleVerifyError::Malformed {
208            detail: "audit missing after schema-pass".into(),
209        })?;
210    let receipt = decision
211        .get("receipt")
212        .ok_or_else(|| CapsuleVerifyError::Malformed {
213            detail: "decision.receipt missing after schema-pass".into(),
214        })?;
215
216    let decision_result = string_field(decision, "result")?;
217
218    // Invariant 1: deny ⇒ no executor call.
219    if decision_result == "deny" {
220        let status = string_field(execution, "status")?;
221        let execution_ref = execution
222            .get("execution_ref")
223            .and_then(|v| v.as_str())
224            .map(|s| s.to_string());
225        if status != "not_called" || execution_ref.is_some() {
226            return Err(CapsuleVerifyError::DenyWithExecution {
227                status,
228                execution_ref,
229            });
230        }
231    }
232
233    // Invariant 2: live mode ⇒ concrete live evidence; mock mode ⇒ no live evidence.
234    let mode = string_field(execution, "mode")?;
235    let live_evidence = execution.get("live_evidence");
236    let evidence_present = live_evidence.map(|v| !v.is_null()).unwrap_or(false);
237    let concrete_evidence_present = live_evidence_has_concrete_ref(live_evidence);
238    match (mode.as_str(), evidence_present, concrete_evidence_present) {
239        ("live", _, false) => return Err(CapsuleVerifyError::LiveWithoutEvidence),
240        ("mock", true, _) => return Err(CapsuleVerifyError::MockWithLiveEvidence),
241        _ => {}
242    }
243
244    // Invariant 3: request_hash agreement.
245    let outer_request_hash = string_field(request, "request_hash")?;
246    let receipt_request_hash = string_field(receipt, "request_hash")?;
247    if outer_request_hash != receipt_request_hash {
248        return Err(CapsuleVerifyError::RequestHashMismatch {
249            outer: outer_request_hash,
250            receipt: receipt_request_hash,
251        });
252    }
253
254    // Invariant 4: policy_hash agreement.
255    let outer_policy_hash = string_field(policy, "policy_hash")?;
256    let receipt_policy_hash = string_field(receipt, "policy_hash")?;
257    if outer_policy_hash != receipt_policy_hash {
258        return Err(CapsuleVerifyError::PolicyHashMismatch {
259            outer: outer_policy_hash,
260            receipt: receipt_policy_hash,
261        });
262    }
263
264    // Invariant 5: decision result agreement.
265    let receipt_decision = string_field(receipt, "decision")?;
266    if decision_result != receipt_decision {
267        return Err(CapsuleVerifyError::DecisionResultMismatch {
268            outer: decision_result,
269            receipt: receipt_decision,
270        });
271    }
272
273    // Invariant 6: agent_id agreement.
274    let outer_agent_id = string_field(agent, "agent_id")?;
275    let receipt_agent_id = string_field(receipt, "agent_id")?;
276    if outer_agent_id != receipt_agent_id {
277        return Err(CapsuleVerifyError::AgentIdMismatch {
278            outer: outer_agent_id,
279            receipt: receipt_agent_id,
280        });
281    }
282
283    // Invariant 7: audit_event_id agreement.
284    let outer_audit_event_id = string_field(audit, "audit_event_id")?;
285    let receipt_audit_event_id = string_field(receipt, "audit_event_id")?;
286    if outer_audit_event_id != receipt_audit_event_id {
287        return Err(CapsuleVerifyError::AuditEventIdMismatch {
288            outer: outer_audit_event_id,
289            receipt: receipt_audit_event_id,
290        });
291    }
292
293    // Invariant 8: when checkpoint is present, its latest_event_hash
294    // must match the outer audit.event_hash.
295    if let Some(checkpoint) = audit.get("checkpoint") {
296        if !checkpoint.is_null() {
297            let outer_event_hash = string_field(audit, "event_hash")?;
298            let cp_latest = string_field(checkpoint, "latest_event_hash")?;
299            if outer_event_hash != cp_latest {
300                return Err(CapsuleVerifyError::CheckpointEventHashMismatch {
301                    outer: outer_event_hash,
302                    checkpoint: cp_latest,
303                });
304            }
305        }
306    }
307
308    Ok(())
309}
310
311fn string_field(parent: &Value, key: &str) -> std::result::Result<String, CapsuleVerifyError> {
312    parent
313        .get(key)
314        .and_then(|v| v.as_str())
315        .map(|s| s.to_string())
316        .ok_or_else(|| CapsuleVerifyError::Malformed {
317            detail: format!("expected string field {key:?} after schema-pass"),
318        })
319}
320
321fn live_evidence_has_concrete_ref(value: Option<&Value>) -> bool {
322    let Some(object) = value.and_then(|v| v.as_object()) else {
323        return false;
324    };
325    ["transport", "response_ref", "block_ref"]
326        .iter()
327        .any(|key| {
328            object
329                .get(*key)
330                .and_then(|v| v.as_str())
331                .is_some_and(|s| !s.is_empty())
332        })
333}
334
335// =====================================================================
336// Strict (cryptographic) capsule verification.
337// =====================================================================
338//
339// `verify_capsule` (above) is intentionally structural-only — see the
340// module doc-comment + `SECURITY_NOTES.md` §"Passport verifier scope".
341// `verify_capsule_strict` extends it with the cryptographic checks that
342// belong off the structural fast path: re-hash the APRP, verify the
343// receipt's Ed25519 signature, walk the audit chain, and check the
344// audit-event-id linkage.
345//
346// Some checks are conditional on auxiliary inputs the capsule does NOT
347// itself carry (a receipt-signer pubkey, an audit bundle, a policy
348// snapshot). When the input is absent the corresponding check is
349// reported as `Skipped` rather than `Failed` — so a caller that only
350// wants the `aprp → request_hash` recompute can pass `Default::default()`
351// and still get a useful pass/fail report. This is the honest disclosure
352// pattern: never a fake-OK; always either a real PASS, an explicit
353// SKIP-with-reason, or a FAIL-with-reason.
354
355/// Auxiliary inputs for the cryptographic strict verifier. Each input is
356/// independent — passing all three runs every check; passing none runs
357/// only the structural pass + the `request_hash` recompute (the only
358/// crypto check the capsule alone is enough for).
359#[derive(Default, Debug)]
360pub struct StrictVerifyOpts<'a> {
361    /// Hex-encoded Ed25519 public key for the receipt signer. Required
362    /// to run the `receipt_signature` check. When `None` that check is
363    /// reported as `Skipped(missing_input)`.
364    pub receipt_pubkey_hex: Option<&'a str>,
365
366    /// Audit bundle (`sbo3l.audit_bundle.v1`) whose chain segment must
367    /// contain the capsule's `audit.audit_event_id`. Required to run
368    /// the `audit_chain` and `audit_event_link` checks. The bundle is
369    /// fully verified via [`crate::audit_bundle::verify`] (signatures +
370    /// chain linkage + summary consistency); if that returns `Ok`, the
371    /// link check additionally pins that `bundle.summary.audit_event_id
372    /// == capsule.audit.audit_event_id` so a capsule cannot point at a
373    /// chain prefix that doesn't include its own decision event.
374    pub audit_bundle: Option<&'a AuditBundle>,
375
376    /// Canonical policy JSON snapshot whose JCS+SHA-256 hash should
377    /// match `capsule.policy.policy_hash`. Required to run the
378    /// `policy_hash_recompute` check.
379    pub policy_json: Option<&'a Value>,
380}
381
382/// Outcome of one strict-verify check. `Skipped` carries a one-line
383/// reason for the operator (typically: missing aux input).
384#[derive(Debug, Clone, PartialEq, Eq)]
385pub enum CheckOutcome {
386    Passed,
387    Skipped(String),
388    Failed(String),
389}
390
391impl CheckOutcome {
392    pub fn is_passed(&self) -> bool {
393        matches!(self, Self::Passed)
394    }
395    pub fn is_failed(&self) -> bool {
396        matches!(self, Self::Failed(_))
397    }
398    pub fn is_skipped(&self) -> bool {
399        matches!(self, Self::Skipped(_))
400    }
401}
402
403/// Per-check report produced by [`verify_capsule_strict`]. Stable shape
404/// for CLI/JSON consumers; field order matches the documented check
405/// order in the strict-mode CLI output.
406#[derive(Debug, Clone)]
407pub struct StrictVerifyReport {
408    /// Structural verify (the same checks as [`verify_capsule`]).
409    /// If this fails, every other check is `Skipped(structural_failed)`
410    /// because running crypto on a structurally-invalid capsule
411    /// produces noise instead of signal.
412    pub structural: CheckOutcome,
413
414    /// Recompute `request_hash` from `capsule.request.aprp` via JCS +
415    /// SHA-256, then assert it matches BOTH `capsule.request.request_hash`
416    /// AND `capsule.decision.receipt.request_hash`. The capsule alone
417    /// is enough — no aux input required.
418    pub request_hash_recompute: CheckOutcome,
419
420    /// Recompute `policy_hash` from the supplied policy JSON via JCS +
421    /// SHA-256, then assert it matches `capsule.policy.policy_hash`.
422    /// `Skipped` when `opts.policy_json` is absent.
423    pub policy_hash_recompute: CheckOutcome,
424
425    /// Verify the Ed25519 signature on `capsule.decision.receipt`
426    /// against the supplied pubkey. `Skipped` when
427    /// `opts.receipt_pubkey_hex` is absent.
428    pub receipt_signature: CheckOutcome,
429
430    /// Run [`crate::audit_bundle::verify`] over the supplied bundle —
431    /// this catches every chain-level tampering (mutated event hash,
432    /// broken `prev_event_hash` linkage, signature bytes mutated, etc.).
433    /// `Skipped` when `opts.audit_bundle` is absent.
434    pub audit_chain: CheckOutcome,
435
436    /// Pin that the supplied bundle's audit event id (and the bundle's
437    /// summary) match the capsule's `audit.audit_event_id`. Catches the
438    /// "wrong bundle for this capsule" attack. `Skipped` when
439    /// `opts.audit_bundle` is absent.
440    pub audit_event_link: CheckOutcome,
441}
442
443impl StrictVerifyReport {
444    /// True iff every check that ran (i.e. was not `Skipped`) passed.
445    /// A report with all skips trivially returns `true` — callers who
446    /// want full coverage should use [`Self::is_fully_ok`].
447    pub fn is_ok(&self) -> bool {
448        self.iter().all(|c| !c.is_failed())
449    }
450
451    /// True iff every check passed (none skipped, none failed). The
452    /// strongest possible verification result.
453    pub fn is_fully_ok(&self) -> bool {
454        self.iter().all(|c| c.is_passed())
455    }
456
457    /// All six check outcomes, in declaration order.
458    pub fn iter(&self) -> impl Iterator<Item = &CheckOutcome> {
459        [
460            &self.structural,
461            &self.request_hash_recompute,
462            &self.policy_hash_recompute,
463            &self.receipt_signature,
464            &self.audit_chain,
465            &self.audit_event_link,
466        ]
467        .into_iter()
468    }
469
470    /// Stable label for each check; pairs 1:1 with `iter()`.
471    pub fn labels() -> [&'static str; 6] {
472        [
473            "structural",
474            "request_hash_recompute",
475            "policy_hash_recompute",
476            "receipt_signature",
477            "audit_chain",
478            "audit_event_link",
479        ]
480    }
481}
482
483/// Run [`verify_capsule`] plus the cryptographic checks supported by
484/// the supplied auxiliary inputs. Returns a structured report — see
485/// [`StrictVerifyReport`] — never a single boolean. The caller decides
486/// what counts as a passing run via `is_ok()` (no failures) or
487/// `is_fully_ok()` (no failures + no skips).
488///
489/// **F-6 self-contained mode.** When the capsule embeds
490/// `policy.policy_snapshot` and / or `audit.audit_segment`, those
491/// embedded fields are preferred over the corresponding `opts.*`
492/// auxiliaries. A v2 capsule that embeds both runs all 6 checks with
493/// `opts: Default::default()` — no `--policy`, no `--audit-bundle`, no
494/// `--receipt-pubkey` (the embedded bundle's
495/// `verification_keys.receipt_signer_pubkey_hex` IS the receipt pubkey).
496/// CLI callers that explicitly pass an aux input override the embedded
497/// field — the precedence is `opts.* if Some else embedded if present
498/// else Skipped(missing_input)`.
499pub fn verify_capsule_strict(value: &Value, opts: &StrictVerifyOpts) -> StrictVerifyReport {
500    // Step 1: structural verify. If this fails, the capsule is
501    // self-inconsistent — running crypto on it produces misleading
502    // results, so every downstream check is reported as Skipped with
503    // a structural-failed reason.
504    let structural = match verify_capsule(value) {
505        Ok(()) => CheckOutcome::Passed,
506        Err(e) => CheckOutcome::Failed(format!("{} ({})", e, e.code())),
507    };
508
509    if structural.is_failed() {
510        let skip = CheckOutcome::Skipped(
511            "skipped: structural verify failed; crypto checks not meaningful".into(),
512        );
513        return StrictVerifyReport {
514            structural,
515            request_hash_recompute: skip.clone(),
516            policy_hash_recompute: skip.clone(),
517            receipt_signature: skip.clone(),
518            audit_chain: skip.clone(),
519            audit_event_link: skip,
520        };
521    }
522
523    // F-6 precedence policy (Codex P1 fix on PR #118):
524    //
525    //   1. opts.audit_bundle / opts.policy_json / opts.receipt_pubkey_hex —
526    //      caller-supplied. ALWAYS authoritative when Some. Embedded
527    //      audit_segment / policy_snapshot are NOT decoded in this case;
528    //      a malformed or oversized embedded slot must NOT cause a
529    //      caller-supplied valid bundle to fail.
530    //   2. Else (no caller input), try the v2 embedded field.
531    //   3. Else, the corresponding strict check Skips with reason
532    //      "missing aux input".
533    //
534    // The pre-fix behaviour eagerly decoded `audit.audit_segment` first
535    // and bailed on decode/size errors before consulting opts.audit_bundle —
536    // so a caller that explicitly passed `--audit-bundle <good>` would
537    // see chain-level checks FAIL when the embedded slot was bad. The
538    // fix is "if opts.* is Some, skip embedded decode entirely".
539    let embedded_policy = value.pointer("/policy/policy_snapshot");
540    let policy_for_check = opts
541        .policy_json
542        .or_else(|| embedded_policy.filter(|v| !v.is_null()));
543
544    let request_hash_recompute = check_request_hash_recompute(value);
545    let policy_hash_recompute = check_policy_hash_recompute(value, policy_for_check);
546
547    if let Some(caller_bundle) = opts.audit_bundle {
548        // Caller-supplied bundle wins outright. Embedded segment is
549        // ignored — a tampered or oversized embedded slot does NOT
550        // affect a strict run that supplied a valid bundle.
551        let receipt_pubkey_owned: Option<String> = match opts.receipt_pubkey_hex {
552            Some(s) => Some(s.to_string()),
553            None => Some(
554                caller_bundle
555                    .verification_keys
556                    .receipt_signer_pubkey_hex
557                    .clone(),
558            ),
559        };
560        let receipt_pubkey_for_check = receipt_pubkey_owned.as_deref();
561        let receipt_signature = check_receipt_signature(value, receipt_pubkey_for_check);
562        let audit_chain = check_audit_chain(Some(caller_bundle));
563        let audit_event_link = check_audit_event_link(value, Some(caller_bundle));
564        return StrictVerifyReport {
565            structural,
566            request_hash_recompute,
567            policy_hash_recompute,
568            receipt_signature,
569            audit_chain,
570            audit_event_link,
571        };
572    }
573
574    // No caller-supplied bundle — try the v2 embedded segment.
575    let embedded_segment_raw = value
576        .pointer("/audit/audit_segment")
577        .filter(|v| !v.is_null());
578    let embedded_segment = match decode_embedded_segment(embedded_segment_raw) {
579        Ok(maybe) => maybe,
580        Err(e) => {
581            // Capsule self-described a self-contained verifier path,
582            // but the segment is malformed (or > 1 MiB). Fail the
583            // chain-level checks loudly — this is the verifier doing
584            // its job. The caller can recover by passing
585            // `--audit-bundle <path>` (which would skip this branch
586            // entirely per the precedence policy above).
587            let fail = CheckOutcome::Failed(format!(
588                "audit_segment invalid: {e}; provide --audit-bundle <path> to override"
589            ));
590            return StrictVerifyReport {
591                structural,
592                request_hash_recompute,
593                policy_hash_recompute,
594                receipt_signature: fail.clone(),
595                audit_chain: fail.clone(),
596                audit_event_link: fail,
597            };
598        }
599    };
600
601    // Receipt pubkey precedence: caller-supplied wins; embedded bundle's
602    // verification_keys.receipt_signer_pubkey_hex is the v2 fallback.
603    let receipt_pubkey_owned: Option<String> = match opts.receipt_pubkey_hex {
604        Some(s) => Some(s.to_string()),
605        None => embedded_segment
606            .as_ref()
607            .map(|b| b.verification_keys.receipt_signer_pubkey_hex.clone()),
608    };
609    let receipt_pubkey_for_check = receipt_pubkey_owned.as_deref();
610    let bundle_for_check: Option<&AuditBundle> = embedded_segment.as_ref();
611
612    let receipt_signature = check_receipt_signature(value, receipt_pubkey_for_check);
613    let audit_chain = check_audit_chain(bundle_for_check);
614    let audit_event_link = check_audit_event_link(value, bundle_for_check);
615
616    StrictVerifyReport {
617        structural,
618        request_hash_recompute,
619        policy_hash_recompute,
620        receipt_signature,
621        audit_chain,
622        audit_event_link,
623    }
624}
625
626/// Deserialise an embedded `audit.audit_segment` value into an
627/// [`AuditBundle`] iff it's present, fits under [`AUDIT_SEGMENT_BYTE_CAP`],
628/// and parses as a valid `sbo3l.audit_bundle.v1`. Returns `Ok(None)`
629/// when the field is absent (callers fall back to opts.audit_bundle).
630fn decode_embedded_segment(
631    embedded: Option<&Value>,
632) -> std::result::Result<Option<AuditBundle>, CapsuleVerifyError> {
633    let Some(value) = embedded else {
634        return Ok(None);
635    };
636    // Serialise to bytes ONLY for the size check. We then `from_value`
637    // on the original `Value` so we don't pay a second round-trip.
638    let serialised = serde_json::to_vec(value).map_err(|e| CapsuleVerifyError::Malformed {
639        detail: format!("audit_segment serialise: {e}"),
640    })?;
641    if serialised.len() > AUDIT_SEGMENT_BYTE_CAP {
642        return Err(CapsuleVerifyError::AuditSegmentTooLarge {
643            bytes: serialised.len(),
644            cap_bytes: AUDIT_SEGMENT_BYTE_CAP,
645        });
646    }
647    let bundle: AuditBundle =
648        serde_json::from_value(value.clone()).map_err(|e| CapsuleVerifyError::Malformed {
649            detail: format!("audit_segment is not a valid sbo3l.audit_bundle.v1: {e}"),
650        })?;
651    Ok(Some(bundle))
652}
653
654/// Stable substring printed by `passport explain` for v2 capsules whose
655/// `policy_snapshot` AND `audit_segment` are both populated. F-6 AC pin.
656pub const VERIFIER_MODE_SELF_CONTAINED: &str = "verifier-mode: self-contained";
657
658/// `passport explain` companion: stable substring when at least one
659/// embedded field is missing. The caller would then need `--policy`,
660/// `--audit-bundle`, or `--receipt-pubkey` to run the full crypto
661/// matrix.
662pub const VERIFIER_MODE_AUX_REQUIRED: &str = "verifier-mode: aux-required";
663
664/// True iff the capsule embeds BOTH `policy.policy_snapshot` AND
665/// `audit.audit_segment` (non-null). Used by `passport explain` to
666/// pick between the two `verifier-mode:` strings, and by integration
667/// tests that pin the F-6 self-contained AC.
668pub fn capsule_is_self_contained(capsule: &Value) -> bool {
669    let policy_present = capsule
670        .pointer("/policy/policy_snapshot")
671        .is_some_and(|v| !v.is_null());
672    let segment_present = capsule
673        .pointer("/audit/audit_segment")
674        .is_some_and(|v| !v.is_null());
675    policy_present && segment_present
676}
677
678fn check_request_hash_recompute(capsule: &Value) -> CheckOutcome {
679    let Some(aprp) = capsule.pointer("/request/aprp") else {
680        return CheckOutcome::Failed("capsule.request.aprp missing".into());
681    };
682    let recomputed = match hashing::request_hash(aprp) {
683        Ok(h) => h,
684        Err(e) => return CheckOutcome::Failed(format!("JCS canonicalization failed: {e}")),
685    };
686    let outer = capsule
687        .pointer("/request/request_hash")
688        .and_then(|v| v.as_str())
689        .unwrap_or("");
690    let receipt = capsule
691        .pointer("/decision/receipt/request_hash")
692        .and_then(|v| v.as_str())
693        .unwrap_or("");
694    if outer != recomputed {
695        return CheckOutcome::Failed(format!(
696            "capsule.request.request_hash={outer} but recomputed JCS+SHA-256 of \
697             capsule.request.aprp = {recomputed}"
698        ));
699    }
700    if receipt != recomputed {
701        return CheckOutcome::Failed(format!(
702            "capsule.decision.receipt.request_hash={receipt} but recomputed JCS+SHA-256 of \
703             capsule.request.aprp = {recomputed}"
704        ));
705    }
706    CheckOutcome::Passed
707}
708
709fn check_policy_hash_recompute(capsule: &Value, policy_json: Option<&Value>) -> CheckOutcome {
710    let Some(policy) = policy_json else {
711        return CheckOutcome::Skipped(
712            "skipped: --policy <path> not supplied; policy_hash recompute requires the canonical \
713             policy JSON snapshot"
714                .into(),
715        );
716    };
717    let bytes = match hashing::canonical_json(policy) {
718        Ok(b) => b,
719        Err(e) => return CheckOutcome::Failed(format!("policy JCS canonicalization failed: {e}")),
720    };
721    let recomputed = hashing::sha256_hex(&bytes);
722    let claimed = capsule
723        .pointer("/policy/policy_hash")
724        .and_then(|v| v.as_str())
725        .unwrap_or("");
726    if claimed != recomputed {
727        return CheckOutcome::Failed(format!(
728            "capsule.policy.policy_hash={claimed} but recomputed JCS+SHA-256 of supplied \
729             policy snapshot = {recomputed}"
730        ));
731    }
732    CheckOutcome::Passed
733}
734
735fn check_receipt_signature(capsule: &Value, pubkey_hex: Option<&str>) -> CheckOutcome {
736    let Some(pubkey) = pubkey_hex else {
737        return CheckOutcome::Skipped(
738            "skipped: --receipt-pubkey <hex> not supplied; Ed25519 signature verification \
739             requires the receipt signer's public key"
740                .into(),
741        );
742    };
743    let Some(receipt_value) = capsule.pointer("/decision/receipt") else {
744        return CheckOutcome::Failed("capsule.decision.receipt missing".into());
745    };
746    let receipt: PolicyReceipt = match serde_json::from_value(receipt_value.clone()) {
747        Ok(r) => r,
748        Err(e) => {
749            return CheckOutcome::Failed(format!(
750                "capsule.decision.receipt could not be deserialized as PolicyReceipt: {e}"
751            ))
752        }
753    };
754    match receipt.verify(pubkey) {
755        Ok(()) => CheckOutcome::Passed,
756        Err(VerifyError::BadPublicKey) => {
757            CheckOutcome::Failed("supplied receipt-pubkey is not a valid Ed25519 public key".into())
758        }
759        Err(VerifyError::BadSignature) => CheckOutcome::Failed(
760            "capsule.decision.receipt.signature.signature_hex is not a valid Ed25519 signature \
761             (wrong length or non-hex)"
762                .into(),
763        ),
764        Err(VerifyError::Invalid) => CheckOutcome::Failed(
765            "Ed25519 signature did not verify against supplied receipt-pubkey over the \
766             canonical receipt body"
767                .into(),
768        ),
769        Err(VerifyError::Hex(e)) => CheckOutcome::Failed(format!(
770            "capsule.decision.receipt.signature.signature_hex (or supplied receipt-pubkey) \
771             failed hex decoding: {e}"
772        )),
773    }
774}
775
776fn check_audit_chain(bundle: Option<&AuditBundle>) -> CheckOutcome {
777    let Some(b) = bundle else {
778        return CheckOutcome::Skipped(
779            "skipped: --audit-bundle <path> not supplied; chain walk requires the \
780             sbo3l.audit_bundle.v1 artefact for the capsule's audit event"
781                .into(),
782        );
783    };
784    match audit_bundle::verify(b) {
785        Ok(_) => CheckOutcome::Passed,
786        Err(BundleError::ReceiptSignatureInvalid) => CheckOutcome::Failed(
787            "audit_bundle::verify: receipt signature does not verify under the bundle's \
788             receipt-signer pubkey"
789                .into(),
790        ),
791        Err(BundleError::AuditEventSignatureInvalid) => CheckOutcome::Failed(
792            "audit_bundle::verify: audit event signature does not verify under the bundle's \
793             audit-signer pubkey"
794                .into(),
795        ),
796        Err(BundleError::Chain(e)) => {
797            CheckOutcome::Failed(format!("audit chain verify failed: {e}"))
798        }
799        Err(e) => CheckOutcome::Failed(format!("audit_bundle::verify: {e}")),
800    }
801}
802
803fn check_audit_event_link(capsule: &Value, bundle: Option<&AuditBundle>) -> CheckOutcome {
804    let Some(b) = bundle else {
805        return CheckOutcome::Skipped(
806            "skipped: --audit-bundle <path> not supplied; audit-event-id linkage requires \
807             the bundle"
808                .into(),
809        );
810    };
811    let capsule_id = capsule
812        .pointer("/audit/audit_event_id")
813        .and_then(|v| v.as_str())
814        .unwrap_or("");
815    let bundle_id = b.summary.audit_event_id.as_str();
816    if capsule_id != bundle_id {
817        return CheckOutcome::Failed(format!(
818            "capsule.audit.audit_event_id={capsule_id} but \
819             bundle.summary.audit_event_id={bundle_id} — wrong bundle for this capsule"
820        ));
821    }
822    // Defence in depth: also check the chain segment actually contains the event id.
823    let in_chain = b
824        .audit_chain_segment
825        .iter()
826        .any(|e| e.event.id == capsule_id);
827    if !in_chain {
828        return CheckOutcome::Failed(format!(
829            "capsule.audit.audit_event_id={capsule_id} not present in bundle.audit_chain_segment"
830        ));
831    }
832    CheckOutcome::Passed
833}
834
835#[cfg(test)]
836mod tests {
837    use super::*;
838
839    fn load(path: &str) -> Value {
840        let raw = std::fs::read_to_string(path).unwrap();
841        serde_json::from_str(&raw).unwrap()
842    }
843
844    fn corpus(name: &str) -> Value {
845        let path =
846            concat!(env!("CARGO_MANIFEST_DIR"), "/../../test-corpus/passport/").to_string() + name;
847        load(&path)
848    }
849
850    #[test]
851    fn golden_allow_capsule_verifies() {
852        let v = corpus("golden_001_allow_keeperhub_mock.json");
853        verify_capsule(&v).expect("golden capsule must verify");
854    }
855
856    #[test]
857    fn tampered_deny_with_execution_ref_is_rejected() {
858        let v = corpus("tampered_001_deny_with_execution_ref.json");
859        let err = verify_capsule(&v).expect_err("must fail");
860        assert_eq!(err.code(), "capsule.deny_with_execution", "{err}");
861    }
862
863    #[test]
864    fn tampered_mock_anchor_marked_live_is_rejected_by_schema() {
865        let v = corpus("tampered_002_mock_anchor_marked_live.json");
866        let err = verify_capsule(&v).expect_err("must fail");
867        // Schema enforces `mock_anchor: const true`; verifier surfaces the
868        // schema failure path, not a custom invariant code.
869        assert_eq!(err.code(), "capsule.schema_invalid", "{err}");
870    }
871
872    #[test]
873    fn tampered_live_mode_without_evidence_is_rejected() {
874        let v = corpus("tampered_003_live_mode_without_evidence.json");
875        let err = verify_capsule(&v).expect_err("must fail");
876        assert_eq!(err.code(), "capsule.live_without_evidence", "{err}");
877    }
878
879    #[test]
880    fn tampered_live_mode_empty_evidence_is_rejected() {
881        let v = corpus("tampered_008_live_mode_empty_evidence.json");
882        let err = verify_capsule(&v).expect_err("must fail");
883        assert_eq!(err.code(), "capsule.schema_invalid", "{err}");
884    }
885
886    #[test]
887    fn live_mode_with_concrete_evidence_verifies() {
888        let mut v = corpus("golden_001_allow_keeperhub_mock.json");
889        let execution = v["execution"].as_object_mut().unwrap();
890        execution.insert("mode".into(), Value::String("live".into()));
891        execution.insert(
892            "live_evidence".into(),
893            serde_json::json!({
894                "transport": "https",
895                "response_ref": "keeperhub-execution-01HTAWX5K3R8YV9NQB7C6P2DGS"
896            }),
897        );
898        verify_capsule(&v).expect("live capsule with concrete evidence must verify");
899    }
900
901    #[test]
902    fn mock_mode_with_concrete_live_evidence_is_rejected() {
903        let mut v = corpus("golden_001_allow_keeperhub_mock.json");
904        let execution = v["execution"].as_object_mut().unwrap();
905        execution.insert(
906            "live_evidence".into(),
907            serde_json::json!({
908                "response_ref": "keeperhub-execution-01HTAWX5K3R8YV9NQB7C6P2DGS"
909            }),
910        );
911        let err = verify_capsule(&v).expect_err("must fail");
912        assert_eq!(err.code(), "capsule.mock_with_live_evidence", "{err}");
913    }
914
915    #[test]
916    fn tampered_request_hash_mismatch_is_rejected() {
917        let v = corpus("tampered_004_request_hash_mismatch.json");
918        let err = verify_capsule(&v).expect_err("must fail");
919        assert_eq!(err.code(), "capsule.request_hash_mismatch", "{err}");
920    }
921
922    #[test]
923    fn tampered_policy_hash_mismatch_is_rejected() {
924        let v = corpus("tampered_005_policy_hash_mismatch.json");
925        let err = verify_capsule(&v).expect_err("must fail");
926        assert_eq!(err.code(), "capsule.policy_hash_mismatch", "{err}");
927    }
928
929    #[test]
930    fn tampered_malformed_checkpoint_is_rejected_by_schema() {
931        // tampered_006 has mock_anchor_ref="remote-onchain-eth-..." which
932        // does NOT match the `^local-mock-anchor-[0-9a-f]{16}$` pattern.
933        let v = corpus("tampered_006_malformed_checkpoint.json");
934        let err = verify_capsule(&v).expect_err("must fail");
935        assert_eq!(err.code(), "capsule.schema_invalid", "{err}");
936    }
937
938    #[test]
939    fn tampered_unknown_field_is_rejected_by_schema() {
940        let v = corpus("tampered_007_unknown_field.json");
941        let err = verify_capsule(&v).expect_err("must fail");
942        assert_eq!(err.code(), "capsule.schema_invalid", "{err}");
943    }
944
945    // -----------------------------------------------------------------
946    // P6.1 — `execution.executor_evidence` (mode-agnostic sponsor slot)
947    // -----------------------------------------------------------------
948    //
949    // The verifier adds NO new cross-field invariant for
950    // `executor_evidence`: the schema is the single source of truth for
951    // the slot's shape (`oneOf null / object minProperties:1`,
952    // `additionalProperties: true`). The two tests below pin the two
953    // behaviours the verifier MUST exhibit:
954    //
955    // 1. A capsule with `executor_evidence: null` (or omitted) verifies.
956    // 2. A capsule with arbitrary, freeform `executor_evidence` content
957    //    verifies — the schema validates the slot's shape; the
958    //    bidirectional `live_evidence` invariant continues to hold
959    //    because `executor_evidence` is a separate slot.
960
961    #[test]
962    fn executor_evidence_null_accepted() {
963        // The golden allow capsule omits `executor_evidence` entirely
964        // (the schema's `oneOf null / object` accepts a missing field
965        // when the property has no required entry). Adding `null`
966        // explicitly should also pass — both forms are equivalent on
967        // the wire and the verifier must treat them identically.
968        let v_missing = corpus("golden_001_allow_keeperhub_mock.json");
969        verify_capsule(&v_missing).expect("golden (executor_evidence missing) must verify");
970
971        let mut v_null = corpus("golden_001_allow_keeperhub_mock.json");
972        v_null["execution"]
973            .as_object_mut()
974            .unwrap()
975            .insert("executor_evidence".into(), Value::Null);
976        verify_capsule(&v_null).expect("explicit executor_evidence: null must verify");
977    }
978
979    #[test]
980    fn executor_evidence_arbitrary_object_accepted() {
981        // The schema is `additionalProperties: true` for the
982        // executor_evidence slot, so any non-empty object passes
983        // schema-level validation. The verifier (this module) adds no
984        // shape rules of its own — sponsor adapters carry their own
985        // structured payload here. We pin both a single-key shape
986        // (KeeperHub IP-1 envelope progenitor) and a Uniswap-flavoured
987        // multi-key shape so the test fails closed if a future change
988        // accidentally tightens the slot.
989        let mut v_min = corpus("golden_001_allow_keeperhub_mock.json");
990        v_min["execution"].as_object_mut().unwrap().insert(
991            "executor_evidence".into(),
992            serde_json::json!({ "quote_id": "x" }),
993        );
994        verify_capsule(&v_min).expect("single-key executor_evidence must verify");
995
996        let mut v_uni = corpus("golden_001_allow_keeperhub_mock.json");
997        v_uni["execution"].as_object_mut().unwrap().insert(
998            "executor_evidence".into(),
999            serde_json::json!({
1000                "quote_id": "mock-uniswap-quote-X",
1001                "quote_source": "mock-uniswap-v3-router",
1002                "input_token": { "symbol": "USDC", "address": "0x0" },
1003                "output_token": { "symbol": "ETH", "address": "0x1" },
1004                "route_tokens": [],
1005                "notional_in": "0.05",
1006                "slippage_cap_bps": 50,
1007                "quote_timestamp_unix": 1_700_000_000,
1008                "quote_freshness_seconds": 30,
1009                "recipient_address": "0x1111111111111111111111111111111111111111"
1010            }),
1011        );
1012        verify_capsule(&v_uni).expect("uniswap-shaped executor_evidence must verify");
1013    }
1014
1015    #[test]
1016    fn tampered_executor_evidence_empty_object_is_rejected_by_schema() {
1017        // tampered_009 sets `executor_evidence: {}` — schema's
1018        // `oneOf null / object minProperties:1` rejects this; the
1019        // verifier surfaces it as `capsule.schema_invalid`.
1020        let v = corpus("tampered_009_executor_evidence_empty_object.json");
1021        let err = verify_capsule(&v).expect_err("must fail");
1022        assert_eq!(err.code(), "capsule.schema_invalid", "{err}");
1023    }
1024
1025    #[test]
1026    fn schema_compiles() {
1027        // Pin: the embedded schema must compile at startup. Caught by
1028        // build_with_refs's expect-panic but worth a sentinel test.
1029        let _ = crate::schema::PASSPORT_CAPSULE_SCHEMA_JSON;
1030        let v: serde_json::Value =
1031            serde_json::from_str(crate::schema::PASSPORT_CAPSULE_SCHEMA_JSON).unwrap();
1032        assert_eq!(
1033            v["$id"].as_str().unwrap(),
1034            crate::schema::PASSPORT_CAPSULE_SCHEMA_ID
1035        );
1036    }
1037
1038    // =================================================================
1039    // Strict-verifier coverage (B1)
1040    // =================================================================
1041    //
1042    // The structural-only `verify_capsule` already has full coverage in
1043    // the tests above. These tests pin the cryptographic strict mode:
1044    // every check in `StrictVerifyReport` must pass on a freshly-built
1045    // capsule + matching aux inputs, and each documented tampering
1046    // class must produce a `Failed` result on the right check while
1047    // every other check stays `Passed`.
1048
1049    use crate::audit::{AuditEvent, SignedAuditEvent, ZERO_HASH};
1050    use crate::audit_bundle;
1051    use crate::receipt::{Decision, UnsignedReceipt};
1052    use crate::signer::DevSigner;
1053
1054    /// Build a real, cryptographically-valid capsule + the matching
1055    /// auxiliary inputs (receipt pubkey + audit bundle + policy
1056    /// snapshot). All inputs derived from the same `DevSigner` seeds so
1057    /// a happy-path strict verify with all aux inputs returns
1058    /// `is_fully_ok()`.
1059    fn strict_fixture() -> (
1060        Value,
1061        DevSigner, // receipt signer
1062        DevSigner, // audit signer
1063        AuditBundle,
1064        Value, // canonical policy snapshot
1065    ) {
1066        let receipt_signer = DevSigner::from_seed("decision-signer-v1", [7u8; 32]);
1067        let audit_signer = DevSigner::from_seed("audit-signer-v1", [11u8; 32]);
1068
1069        // Canonical policy snapshot — any deterministic JSON works as
1070        // long as JCS+SHA-256 over its bytes equals the capsule's
1071        // policy.policy_hash. We keep it tiny.
1072        let policy_json: Value = serde_json::json!({
1073            "policy_id": "reference_low_risk_v1",
1074            "version": 1,
1075            "rules": [
1076                { "id": "allow-low-risk-x402", "decision": "allow" }
1077            ]
1078        });
1079        let policy_bytes = hashing::canonical_json(&policy_json).unwrap();
1080        let policy_hash = hashing::sha256_hex(&policy_bytes);
1081
1082        // Real APRP body. The capsule's request_hash + the receipt's
1083        // request_hash must both equal sha256(JCS(this body)).
1084        let aprp: Value = serde_json::json!({
1085            "agent_id": "research-agent-01",
1086            "task_id": "demo-task-1",
1087            "intent": "purchase_api_call",
1088            "amount": { "value": "0.05", "currency": "USD" },
1089            "token": "USDC",
1090            "destination": {
1091                "type": "x402_endpoint",
1092                "url": "https://api.example.com/v1/inference",
1093                "method": "POST",
1094                "expected_recipient": "0x1111111111111111111111111111111111111111"
1095            },
1096            "payment_protocol": "x402",
1097            "chain": "base",
1098            "provider_url": "https://api.example.com",
1099            "x402_payload": null,
1100            "expiry": "2026-05-01T10:31:00Z",
1101            "nonce": "01HTAWX5K3R8YV9NQB7C6P2DGM",
1102            "expected_result": null,
1103            "risk_class": "low"
1104        });
1105        let request_hash_hex = hashing::request_hash(&aprp).unwrap();
1106
1107        // 3-event chain: runtime_started → policy_decided (the one the
1108        // capsule references) → policy_decided (filler).
1109        let e1_event = AuditEvent {
1110            version: 1,
1111            seq: 1,
1112            id: "evt-01HTAWX5K3R8YV9NQB7C6P2DGQ".into(),
1113            ts: chrono::DateTime::parse_from_rfc3339("2026-04-29T12:00:00Z")
1114                .unwrap()
1115                .into(),
1116            event_type: "runtime_started".into(),
1117            actor: "sbo3l-server".into(),
1118            subject_id: "runtime".into(),
1119            payload_hash: ZERO_HASH.into(),
1120            metadata: serde_json::Map::new(),
1121            policy_version: None,
1122            policy_hash: None,
1123            attestation_ref: None,
1124            prev_event_hash: ZERO_HASH.into(),
1125        };
1126        let e1 = SignedAuditEvent::sign(e1_event, &audit_signer).unwrap();
1127
1128        let e2_event = AuditEvent {
1129            version: 1,
1130            seq: 2,
1131            id: "evt-01HTAWX5K3R8YV9NQB7C6P2DGR".into(),
1132            ts: chrono::DateTime::parse_from_rfc3339("2026-04-29T12:00:01Z")
1133                .unwrap()
1134                .into(),
1135            event_type: "policy_decided".into(),
1136            actor: "policy_engine".into(),
1137            subject_id: "pr-strict-001".into(),
1138            payload_hash: request_hash_hex.clone(),
1139            metadata: serde_json::Map::new(),
1140            policy_version: Some(1),
1141            policy_hash: Some(policy_hash.clone()),
1142            attestation_ref: None,
1143            prev_event_hash: e1.event_hash.clone(),
1144        };
1145        let e2 = SignedAuditEvent::sign(e2_event, &audit_signer).unwrap();
1146
1147        let e3_event = AuditEvent {
1148            version: 1,
1149            seq: 3,
1150            id: "evt-01HTAWX5K3R8YV9NQB7C6P2DGS".into(),
1151            ts: chrono::DateTime::parse_from_rfc3339("2026-04-29T12:00:02Z")
1152                .unwrap()
1153                .into(),
1154            event_type: "policy_decided".into(),
1155            actor: "policy_engine".into(),
1156            subject_id: "pr-strict-002".into(),
1157            payload_hash: ZERO_HASH.into(),
1158            metadata: serde_json::Map::new(),
1159            policy_version: Some(1),
1160            policy_hash: Some(policy_hash.clone()),
1161            attestation_ref: None,
1162            prev_event_hash: e2.event_hash.clone(),
1163        };
1164        let e3 = SignedAuditEvent::sign(e3_event, &audit_signer).unwrap();
1165
1166        // Real signed receipt over (request_hash, policy_hash,
1167        // audit_event_id = e2.id).
1168        let unsigned = UnsignedReceipt {
1169            agent_id: "research-agent-01".into(),
1170            decision: Decision::Allow,
1171            deny_code: None,
1172            request_hash: request_hash_hex.clone(),
1173            policy_hash: policy_hash.clone(),
1174            policy_version: Some(1),
1175            audit_event_id: e2.event.id.clone(),
1176            execution_ref: None,
1177            issued_at: chrono::DateTime::parse_from_rfc3339("2026-04-29T12:00:01.500Z")
1178                .unwrap()
1179                .into(),
1180            expires_at: None,
1181        };
1182        let receipt = unsigned.sign(&receipt_signer).unwrap();
1183
1184        // Audit bundle covering the chain prefix through e2.
1185        let bundle = audit_bundle::build(
1186            receipt.clone(),
1187            vec![e1, e2.clone(), e3],
1188            receipt_signer.verifying_key_hex(),
1189            audit_signer.verifying_key_hex(),
1190            chrono::DateTime::parse_from_rfc3339("2026-04-29T13:00:00Z")
1191                .unwrap()
1192                .into(),
1193        )
1194        .unwrap();
1195
1196        // Build a structurally-valid capsule wrapping the receipt.
1197        let capsule = serde_json::json!({
1198            "schema": "sbo3l.passport_capsule.v1",
1199            "generated_at": "2026-04-29T12:30:00Z",
1200            "agent": {
1201                "agent_id": "research-agent-01",
1202                "ens_name": "research-agent.team.eth",
1203                "resolver": "offline-fixture",
1204                "records": {
1205                    "sbo3l:policy_hash": policy_hash,
1206                    "sbo3l:audit_root": "local-mock-anchor-0123456789abcdef",
1207                    "sbo3l:passport_schema": "sbo3l.passport_capsule.v1"
1208                }
1209            },
1210            "request": {
1211                "aprp": aprp,
1212                "request_hash": request_hash_hex,
1213                "idempotency_key": "strict-fixture-1",
1214                "nonce": "01HTAWX5K3R8YV9NQB7C6P2DGM"
1215            },
1216            "policy": {
1217                "policy_hash": policy_hash,
1218                "policy_version": 1,
1219                "activated_at": "2026-04-28T10:00:00Z",
1220                "source": "operator-cli"
1221            },
1222            "decision": {
1223                "result": "allow",
1224                "matched_rule": "allow-low-risk-x402",
1225                "deny_code": null,
1226                "receipt": serde_json::to_value(&receipt).unwrap(),
1227                "receipt_signature": receipt.signature.signature_hex.clone()
1228            },
1229            "execution": {
1230                "executor": "keeperhub",
1231                "mode": "mock",
1232                "execution_ref": "kh-strict-001",
1233                "status": "submitted",
1234                "sponsor_payload_hash": ZERO_HASH,
1235                "live_evidence": null
1236            },
1237            "audit": {
1238                "audit_event_id": e2.event.id,
1239                "prev_event_hash": e2.event.prev_event_hash,
1240                "event_hash": e2.event_hash,
1241                "bundle_ref": "sbo3l.audit_bundle.v1",
1242                "checkpoint": {
1243                    "schema": "sbo3l.audit_checkpoint.v1",
1244                    "sequence": 1,
1245                    "latest_event_id": e2.event.id,
1246                    "latest_event_hash": e2.event_hash,
1247                    "chain_digest": ZERO_HASH,
1248                    "mock_anchor": true,
1249                    "mock_anchor_ref": "local-mock-anchor-0123456789abcdef",
1250                    "created_at": "2026-04-29T12:00:30Z"
1251                }
1252            },
1253            "verification": {
1254                "doctor_status": "ok",
1255                "offline_verifiable": true,
1256                "live_claims": []
1257            }
1258        });
1259
1260        (capsule, receipt_signer, audit_signer, bundle, policy_json)
1261    }
1262
1263    /// B1 test 1 — happy path. Every aux input present + valid → every
1264    /// check passes, including no skips.
1265    #[test]
1266    fn strict_verify_happy_path_passes_every_check() {
1267        let (capsule, receipt_signer, _audit_signer, bundle, policy) = strict_fixture();
1268        let pk = receipt_signer.verifying_key_hex();
1269        let opts = StrictVerifyOpts {
1270            receipt_pubkey_hex: Some(&pk),
1271            audit_bundle: Some(&bundle),
1272            policy_json: Some(&policy),
1273        };
1274        let report = verify_capsule_strict(&capsule, &opts);
1275        assert!(
1276            report.is_fully_ok(),
1277            "expected fully-ok; report = {report:?}"
1278        );
1279        assert!(report.structural.is_passed());
1280        assert!(report.request_hash_recompute.is_passed());
1281        assert!(report.policy_hash_recompute.is_passed());
1282        assert!(report.receipt_signature.is_passed());
1283        assert!(report.audit_chain.is_passed());
1284        assert!(report.audit_event_link.is_passed());
1285    }
1286
1287    /// B1 test 2 — tampered request body. Mutating `capsule.request.aprp`
1288    /// must surface a `request_hash_recompute` Failed result; structural
1289    /// pass remains green because the schema is satisfied + the *claimed*
1290    /// request_hash still equals the receipt's claimed request_hash.
1291    #[test]
1292    fn strict_verify_tampered_request_body_fails_request_hash_recompute() {
1293        let (mut capsule, receipt_signer, _audit_signer, bundle, policy) = strict_fixture();
1294        // Mutate the APRP body without updating any hashes — the
1295        // recomputed JCS+SHA-256 will diverge from the claimed
1296        // request_hash.
1297        capsule["request"]["aprp"]["amount"]["value"] = serde_json::Value::String("999.00".into());
1298        let pk = receipt_signer.verifying_key_hex();
1299        let opts = StrictVerifyOpts {
1300            receipt_pubkey_hex: Some(&pk),
1301            audit_bundle: Some(&bundle),
1302            policy_json: Some(&policy),
1303        };
1304        let report = verify_capsule_strict(&capsule, &opts);
1305        assert!(
1306            report.structural.is_passed(),
1307            "structural should still pass"
1308        );
1309        assert!(
1310            report.request_hash_recompute.is_failed(),
1311            "request_hash_recompute should fail on mutated APRP body"
1312        );
1313    }
1314
1315    /// B1 test 3 — tampered policy snapshot. Supplying a different
1316    /// policy JSON than the one that produced `capsule.policy.policy_hash`
1317    /// must surface a `policy_hash_recompute` Failed result.
1318    #[test]
1319    fn strict_verify_tampered_policy_snapshot_fails_policy_hash_recompute() {
1320        let (capsule, receipt_signer, _audit_signer, bundle, _policy) = strict_fixture();
1321        let bad_policy = serde_json::json!({
1322            "policy_id": "different-policy",
1323            "rules": []
1324        });
1325        let pk = receipt_signer.verifying_key_hex();
1326        let opts = StrictVerifyOpts {
1327            receipt_pubkey_hex: Some(&pk),
1328            audit_bundle: Some(&bundle),
1329            policy_json: Some(&bad_policy),
1330        };
1331        let report = verify_capsule_strict(&capsule, &opts);
1332        assert!(
1333            report.policy_hash_recompute.is_failed(),
1334            "policy_hash_recompute should fail when supplied policy ≠ capsule.policy.policy_hash"
1335        );
1336    }
1337
1338    /// B1 test 4 — tampered receipt signature. Flipping a hex byte of
1339    /// the signature must surface a `receipt_signature` Failed result.
1340    #[test]
1341    fn strict_verify_tampered_receipt_signature_fails_receipt_signature() {
1342        let (mut capsule, receipt_signer, _audit_signer, bundle, policy) = strict_fixture();
1343        // Flip a hex character in the embedded receipt's signature_hex.
1344        // The receipt deserializes (still 128 hex chars) but the
1345        // signature won't verify under the real pubkey.
1346        let sig = capsule["decision"]["receipt"]["signature"]["signature_hex"]
1347            .as_str()
1348            .unwrap()
1349            .to_string();
1350        let mut chars: Vec<char> = sig.chars().collect();
1351        // Flip the first hex char between '0' ↔ '1' so the result stays
1352        // valid hex.
1353        chars[0] = if chars[0] == '0' { '1' } else { '0' };
1354        let mutated: String = chars.into_iter().collect();
1355        capsule["decision"]["receipt"]["signature"]["signature_hex"] =
1356            serde_json::Value::String(mutated);
1357
1358        let pk = receipt_signer.verifying_key_hex();
1359        let opts = StrictVerifyOpts {
1360            receipt_pubkey_hex: Some(&pk),
1361            audit_bundle: Some(&bundle),
1362            policy_json: Some(&policy),
1363        };
1364        let report = verify_capsule_strict(&capsule, &opts);
1365        assert!(
1366            report.receipt_signature.is_failed(),
1367            "receipt_signature must fail on a flipped signature byte"
1368        );
1369    }
1370
1371    /// B1 test 5 — tampered audit prev_event_hash inside the bundle.
1372    /// The chain walk in `audit_bundle::verify` re-hashes each event
1373    /// and checks linkage; mutating one event's prev_event_hash must
1374    /// surface an `audit_chain` Failed result.
1375    #[test]
1376    fn strict_verify_tampered_audit_prev_hash_fails_audit_chain() {
1377        let (capsule, receipt_signer, _audit_signer, mut bundle, policy) = strict_fixture();
1378        // Flip a hex byte of the second event's prev_event_hash. The
1379        // event's signature still verifies (it's signed over original
1380        // canonical bytes? actually the signature is over canonical
1381        // bytes including prev_event_hash, so this also breaks the
1382        // event signature) — either way audit_chain must fail.
1383        let original = bundle.audit_chain_segment[1].event.prev_event_hash.clone();
1384        let mut chars: Vec<char> = original.chars().collect();
1385        chars[0] = if chars[0] == '0' { '1' } else { '0' };
1386        bundle.audit_chain_segment[1].event.prev_event_hash = chars.into_iter().collect();
1387
1388        let pk = receipt_signer.verifying_key_hex();
1389        let opts = StrictVerifyOpts {
1390            receipt_pubkey_hex: Some(&pk),
1391            audit_bundle: Some(&bundle),
1392            policy_json: Some(&policy),
1393        };
1394        let report = verify_capsule_strict(&capsule, &opts);
1395        assert!(
1396            report.audit_chain.is_failed(),
1397            "audit_chain must fail when prev_event_hash linkage is broken"
1398        );
1399    }
1400
1401    /// B1 test 6 — wrong audit bundle (capsule's audit_event_id is
1402    /// not present in the supplied bundle). `audit_event_link` must
1403    /// surface a Failed result.
1404    #[test]
1405    fn strict_verify_wrong_audit_bundle_fails_audit_event_link() {
1406        let (mut capsule, receipt_signer, _audit_signer, bundle, policy) = strict_fixture();
1407        // Mutate the capsule's claimed audit_event_id to a value the
1408        // bundle doesn't contain. We have to update *both* outer and
1409        // receipt-embedded ids to keep the structural check green so
1410        // we can isolate the link failure on the strict side.
1411        let bogus = "evt-01ZZZZZZZZZZZZZZZZZZZZZZZZ";
1412        capsule["audit"]["audit_event_id"] = serde_json::Value::String(bogus.into());
1413        capsule["decision"]["receipt"]["audit_event_id"] = serde_json::Value::String(bogus.into());
1414
1415        let pk = receipt_signer.verifying_key_hex();
1416        let opts = StrictVerifyOpts {
1417            receipt_pubkey_hex: Some(&pk),
1418            audit_bundle: Some(&bundle),
1419            policy_json: Some(&policy),
1420        };
1421        let report = verify_capsule_strict(&capsule, &opts);
1422        assert!(
1423            report.audit_event_link.is_failed(),
1424            "audit_event_link must fail when capsule.audit.audit_event_id is not in the bundle's chain"
1425        );
1426    }
1427
1428    /// F-6 Codex P1 regression: when the caller supplies a valid
1429    /// `--audit-bundle` (and pubkey), a malformed or oversized
1430    /// **embedded** `audit.audit_segment` must NOT cause the chain-level
1431    /// strict checks to fail. Pre-fix the verifier eagerly decoded the
1432    /// embedded segment first and bailed on decode/size errors before
1433    /// considering the caller-supplied bundle — so `--audit-bundle
1434    /// <good>` + `audit.audit_segment = <garbled>` would FAIL. Post-fix
1435    /// the precedence is "opts.audit_bundle wins outright; embedded
1436    /// segment is only consulted when no caller bundle is provided".
1437    #[test]
1438    fn aux_bundle_overrides_malformed_embedded_segment() {
1439        let (mut capsule, receipt_signer, _audit_signer, bundle, policy) = strict_fixture();
1440        // Bump the fixture's schema id to v2 so the v2-only embedded
1441        // `audit_segment` slot is schema-allowed (v1 schema rejects
1442        // unknown fields). The fixture itself ships a structurally
1443        // valid v1 shell that's a strict superset of v2's required
1444        // fields, so toggling the schema id is enough.
1445        capsule["schema"] = serde_json::Value::String("sbo3l.passport_capsule.v2".into());
1446        // Garble the embedded audit_segment so its own decode would
1447        // fail (non-bundle-shaped object trips serde_json::from_value
1448        // in decode_embedded_segment).
1449        capsule["audit"]["audit_segment"] = serde_json::json!({
1450            "this_is_not": "a valid sbo3l.audit_bundle.v1",
1451            "garbage": [1, 2, 3]
1452        });
1453        let pk = receipt_signer.verifying_key_hex();
1454        let opts = StrictVerifyOpts {
1455            receipt_pubkey_hex: Some(&pk),
1456            audit_bundle: Some(&bundle), // caller-supplied valid bundle
1457            policy_json: Some(&policy),
1458        };
1459        let report = verify_capsule_strict(&capsule, &opts);
1460        assert!(
1461            report.is_fully_ok(),
1462            "caller-supplied --audit-bundle must override garbled embedded segment; \
1463             report = {report:?}"
1464        );
1465        assert!(report.audit_chain.is_passed());
1466        assert!(report.audit_event_link.is_passed());
1467        assert!(report.receipt_signature.is_passed());
1468    }
1469
1470    /// Companion to the regression above: confirm that when NO caller
1471    /// bundle is supplied, the same garbled embedded segment surfaces
1472    /// as Failed (not silently skipped). Pins the "caller-supplied
1473    /// wins, else embedded is authoritative" precedence — both sides
1474    /// of the branch are tested.
1475    #[test]
1476    fn embedded_malformed_segment_fails_when_no_aux_bundle_supplied() {
1477        let (mut capsule, _receipt_signer, _audit_signer, _bundle, _policy) = strict_fixture();
1478        capsule["schema"] = serde_json::Value::String("sbo3l.passport_capsule.v2".into());
1479        capsule["audit"]["audit_segment"] = serde_json::json!({
1480            "this_is_not": "a valid sbo3l.audit_bundle.v1"
1481        });
1482        let report = verify_capsule_strict(&capsule, &StrictVerifyOpts::default());
1483        assert!(report.audit_chain.is_failed());
1484        assert!(report.audit_event_link.is_failed());
1485        assert!(report.receipt_signature.is_failed());
1486    }
1487
1488    /// Bonus — minimal (no aux inputs). Runs only the structural pass
1489    /// + the request_hash recompute (the only crypto check the capsule
1490    ///   alone supports). Every other check is `Skipped` with a reason.
1491    #[test]
1492    fn strict_verify_no_aux_inputs_skips_aux_dependent_checks() {
1493        let (capsule, _receipt_signer, _audit_signer, _bundle, _policy) = strict_fixture();
1494        let report = verify_capsule_strict(&capsule, &StrictVerifyOpts::default());
1495        assert!(report.structural.is_passed());
1496        assert!(report.request_hash_recompute.is_passed());
1497        assert!(report.policy_hash_recompute.is_skipped());
1498        assert!(report.receipt_signature.is_skipped());
1499        assert!(report.audit_chain.is_skipped());
1500        assert!(report.audit_event_link.is_skipped());
1501        assert!(report.is_ok(), "no failures means is_ok() = true");
1502        assert!(!report.is_fully_ok(), "skips mean is_fully_ok() = false");
1503    }
1504
1505    /// Bonus — structural failure short-circuits crypto. A capsule that
1506    /// fails the structural pass must report every downstream check as
1507    /// Skipped (not Failed) so the operator knows the structural cause
1508    /// is what to fix first.
1509    #[test]
1510    fn strict_verify_structural_failure_short_circuits_crypto_checks() {
1511        let (mut capsule, receipt_signer, _audit_signer, bundle, policy) = strict_fixture();
1512        // Break a structural invariant: force capsule.request.request_hash
1513        // to mismatch the receipt's request_hash. The structural verifier
1514        // catches this as RequestHashMismatch.
1515        capsule["request"]["request_hash"] = serde_json::Value::String(
1516            "0000000000000000000000000000000000000000000000000000000000000000".into(),
1517        );
1518        let pk = receipt_signer.verifying_key_hex();
1519        let opts = StrictVerifyOpts {
1520            receipt_pubkey_hex: Some(&pk),
1521            audit_bundle: Some(&bundle),
1522            policy_json: Some(&policy),
1523        };
1524        let report = verify_capsule_strict(&capsule, &opts);
1525        assert!(report.structural.is_failed());
1526        assert!(report.request_hash_recompute.is_skipped());
1527        assert!(report.policy_hash_recompute.is_skipped());
1528        assert!(report.receipt_signature.is_skipped());
1529        assert!(report.audit_chain.is_skipped());
1530        assert!(report.audit_event_link.is_skipped());
1531    }
1532}