1use 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#[derive(Debug, thiserror::Error)]
46pub enum CapsuleVerifyError {
47 #[error("capsule.schema_invalid: {0}")]
48 SchemaInvalid(#[from] SchemaError),
49
50 #[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 #[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 #[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 #[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 #[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 #[error(
95 "capsule.decision_result_mismatch: decision.result={outer} but \
96 decision.receipt.decision={receipt}"
97 )]
98 DecisionResultMismatch { outer: String, receipt: String },
99
100 #[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 #[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 #[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 #[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 #[error("capsule.malformed: {detail}")]
138 Malformed { detail: String },
139}
140
141impl CapsuleVerifyError {
142 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
161pub const AUDIT_SEGMENT_BYTE_CAP: usize = 1024 * 1024;
169
170pub fn verify_capsule(value: &Value) -> std::result::Result<(), CapsuleVerifyError> {
174 crate::schema::validate_passport_capsule(value)?;
175
176 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 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 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 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 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 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 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 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 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#[derive(Default, Debug)]
360pub struct StrictVerifyOpts<'a> {
361 pub receipt_pubkey_hex: Option<&'a str>,
365
366 pub audit_bundle: Option<&'a AuditBundle>,
375
376 pub policy_json: Option<&'a Value>,
380}
381
382#[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#[derive(Debug, Clone)]
407pub struct StrictVerifyReport {
408 pub structural: CheckOutcome,
413
414 pub request_hash_recompute: CheckOutcome,
419
420 pub policy_hash_recompute: CheckOutcome,
424
425 pub receipt_signature: CheckOutcome,
429
430 pub audit_chain: CheckOutcome,
435
436 pub audit_event_link: CheckOutcome,
441}
442
443impl StrictVerifyReport {
444 pub fn is_ok(&self) -> bool {
448 self.iter().all(|c| !c.is_failed())
449 }
450
451 pub fn is_fully_ok(&self) -> bool {
454 self.iter().all(|c| c.is_passed())
455 }
456
457 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 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
483pub fn verify_capsule_strict(value: &Value, opts: &StrictVerifyOpts) -> StrictVerifyReport {
500 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 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 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 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 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 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
626fn 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 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
654pub const VERIFIER_MODE_SELF_CONTAINED: &str = "verifier-mode: self-contained";
657
658pub const VERIFIER_MODE_AUX_REQUIRED: &str = "verifier-mode: aux-required";
663
664pub 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 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 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 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 #[test]
962 fn executor_evidence_null_accepted() {
963 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 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 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 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 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 fn strict_fixture() -> (
1060 Value,
1061 DevSigner, DevSigner, AuditBundle,
1064 Value, ) {
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 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 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 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 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 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 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 #[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 #[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 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 #[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 #[test]
1341 fn strict_verify_tampered_receipt_signature_fails_receipt_signature() {
1342 let (mut capsule, receipt_signer, _audit_signer, bundle, policy) = strict_fixture();
1343 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 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 #[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 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 #[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 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 #[test]
1438 fn aux_bundle_overrides_malformed_embedded_segment() {
1439 let (mut capsule, receipt_signer, _audit_signer, bundle, policy) = strict_fixture();
1440 capsule["schema"] = serde_json::Value::String("sbo3l.passport_capsule.v2".into());
1446 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), 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 #[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 #[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 #[test]
1510 fn strict_verify_structural_failure_short_circuits_crypto_checks() {
1511 let (mut capsule, receipt_signer, _audit_signer, bundle, policy) = strict_fixture();
1512 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}