1use chrono::{DateTime, Utc};
24use serde::{Deserialize, Serialize};
25use thiserror::Error;
26
27use crate::audit::{verify_chain, ChainError, SignedAuditEvent};
28use crate::receipt::{Decision, PolicyReceipt};
29use crate::signer::VerifyError;
30
31#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
37#[serde(deny_unknown_fields)]
38pub struct AuditBundle {
39 pub bundle_type: BundleType,
40 pub version: u32,
41 pub exported_at: DateTime<Utc>,
42 pub receipt: PolicyReceipt,
43 pub audit_event: SignedAuditEvent,
44 pub audit_chain_segment: Vec<SignedAuditEvent>,
45 pub verification_keys: VerificationKeys,
46 pub summary: BundleSummary,
47}
48
49#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
50pub enum BundleType {
51 #[serde(rename = "sbo3l.audit_bundle.v1")]
52 AuditBundleV1,
53}
54
55#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
56#[serde(deny_unknown_fields)]
57pub struct VerificationKeys {
58 pub receipt_signer_pubkey_hex: String,
59 pub audit_signer_pubkey_hex: String,
60}
61
62#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
66#[serde(deny_unknown_fields)]
67pub struct BundleSummary {
68 pub decision: Decision,
69 #[serde(default, skip_serializing_if = "Option::is_none")]
70 pub deny_code: Option<String>,
71 pub request_hash: String,
72 pub policy_hash: String,
73 pub audit_event_id: String,
74 pub audit_event_hash: String,
75 pub audit_chain_root: String,
76 pub audit_chain_latest: String,
77}
78
79#[derive(Debug, Clone, PartialEq, Eq)]
82pub struct VerifySummary {
83 pub receipt_signature_ok: bool,
84 pub audit_event_signature_ok: bool,
85 pub audit_chain_ok: bool,
86 pub receipt_audit_link_ok: bool,
87 pub decision: Decision,
88 pub deny_code: Option<String>,
89 pub request_hash: String,
90 pub policy_hash: String,
91 pub audit_event_id: String,
92 pub audit_event_hash: String,
93 pub audit_chain_length: usize,
94}
95
96const SUPPORTED_BUNDLE_VERSION: u32 = 1;
99
100#[derive(Debug, Error)]
101pub enum BundleError {
102 #[error("bundle is missing a receipt's audit_event_id from the chain segment")]
103 AuditEventNotInChain,
104 #[error("receipt.audit_event_id does not match audit_event.event.id")]
105 ReceiptAuditMismatch,
106 #[error("audit_event hash in chain does not match standalone audit_event")]
107 AuditEventHashMismatch,
108 #[error("summary field '{0}' does not match the bundle body")]
109 SummaryMismatch(&'static str),
110 #[error("receipt signature does not verify under verification_keys.receipt_signer_pubkey_hex")]
111 ReceiptSignatureInvalid,
112 #[error(
113 "audit_event signature does not verify under verification_keys.audit_signer_pubkey_hex"
114 )]
115 AuditEventSignatureInvalid,
116 #[error("audit chain invalid: {0}")]
117 Chain(#[from] ChainError),
118 #[error("signer error: {0}")]
119 Signer(#[from] VerifyError),
120 #[error("serde error: {0}")]
121 Serde(#[from] serde_json::Error),
122 #[error("unsupported bundle version: {0} (this build supports v1)")]
123 UnsupportedVersion(u32),
124 #[error("unsupported bundle_type: only sbo3l.audit_bundle.v1 is accepted in this build")]
125 UnsupportedBundleType,
126}
127
128pub fn build(
135 receipt: PolicyReceipt,
136 audit_chain_segment: Vec<SignedAuditEvent>,
137 receipt_signer_pubkey_hex: String,
138 audit_signer_pubkey_hex: String,
139 exported_at: DateTime<Utc>,
140) -> Result<AuditBundle, BundleError> {
141 let audit_event = audit_chain_segment
142 .iter()
143 .find(|e| e.event.id == receipt.audit_event_id)
144 .cloned()
145 .ok_or(BundleError::AuditEventNotInChain)?;
146 let chain_root = audit_chain_segment
147 .first()
148 .map(|e| e.event_hash.clone())
149 .ok_or(BundleError::AuditEventNotInChain)?;
150 let chain_latest = audit_chain_segment
151 .last()
152 .map(|e| e.event_hash.clone())
153 .ok_or(BundleError::AuditEventNotInChain)?;
154 let summary = BundleSummary {
155 decision: receipt.decision.clone(),
156 deny_code: receipt.deny_code.clone(),
157 request_hash: receipt.request_hash.clone(),
158 policy_hash: receipt.policy_hash.clone(),
159 audit_event_id: audit_event.event.id.clone(),
160 audit_event_hash: audit_event.event_hash.clone(),
161 audit_chain_root: chain_root,
162 audit_chain_latest: chain_latest,
163 };
164 Ok(AuditBundle {
165 bundle_type: BundleType::AuditBundleV1,
166 version: 1,
167 exported_at,
168 receipt,
169 audit_event,
170 audit_chain_segment,
171 verification_keys: VerificationKeys {
172 receipt_signer_pubkey_hex,
173 audit_signer_pubkey_hex,
174 },
175 summary,
176 })
177}
178
179pub fn verify(bundle: &AuditBundle) -> Result<VerifySummary, BundleError> {
189 if !matches!(bundle.bundle_type, BundleType::AuditBundleV1) {
200 return Err(BundleError::UnsupportedBundleType);
201 }
202 if bundle.version != SUPPORTED_BUNDLE_VERSION {
203 return Err(BundleError::UnsupportedVersion(bundle.version));
204 }
205
206 bundle
209 .receipt
210 .verify(&bundle.verification_keys.receipt_signer_pubkey_hex)
211 .map_err(|_| BundleError::ReceiptSignatureInvalid)?;
212
213 bundle
215 .audit_event
216 .verify_signature(&bundle.verification_keys.audit_signer_pubkey_hex)
217 .map_err(|_| BundleError::AuditEventSignatureInvalid)?;
218
219 verify_chain(
222 &bundle.audit_chain_segment,
223 true,
224 Some(&bundle.verification_keys.audit_signer_pubkey_hex),
225 )?;
226
227 if bundle.receipt.audit_event_id != bundle.audit_event.event.id {
232 return Err(BundleError::ReceiptAuditMismatch);
233 }
234 let chain_member = bundle
235 .audit_chain_segment
236 .iter()
237 .find(|e| e.event.id == bundle.audit_event.event.id)
238 .ok_or(BundleError::AuditEventNotInChain)?;
239 if chain_member != &bundle.audit_event {
240 return Err(BundleError::AuditEventHashMismatch);
243 }
244
245 let s = &bundle.summary;
248 if s.decision != bundle.receipt.decision {
249 return Err(BundleError::SummaryMismatch("decision"));
250 }
251 if s.deny_code != bundle.receipt.deny_code {
252 return Err(BundleError::SummaryMismatch("deny_code"));
253 }
254 if s.request_hash != bundle.receipt.request_hash {
255 return Err(BundleError::SummaryMismatch("request_hash"));
256 }
257 if s.policy_hash != bundle.receipt.policy_hash {
258 return Err(BundleError::SummaryMismatch("policy_hash"));
259 }
260 if s.audit_event_id != bundle.audit_event.event.id {
261 return Err(BundleError::SummaryMismatch("audit_event_id"));
262 }
263 if s.audit_event_hash != bundle.audit_event.event_hash {
264 return Err(BundleError::SummaryMismatch("audit_event_hash"));
265 }
266 let expected_root = &bundle
267 .audit_chain_segment
268 .first()
269 .ok_or(BundleError::AuditEventNotInChain)?
270 .event_hash;
271 if &s.audit_chain_root != expected_root {
272 return Err(BundleError::SummaryMismatch("audit_chain_root"));
273 }
274 let expected_latest = &bundle
275 .audit_chain_segment
276 .last()
277 .ok_or(BundleError::AuditEventNotInChain)?
278 .event_hash;
279 if &s.audit_chain_latest != expected_latest {
280 return Err(BundleError::SummaryMismatch("audit_chain_latest"));
281 }
282
283 Ok(VerifySummary {
284 receipt_signature_ok: true,
285 audit_event_signature_ok: true,
286 audit_chain_ok: true,
287 receipt_audit_link_ok: true,
288 decision: bundle.receipt.decision.clone(),
289 deny_code: bundle.receipt.deny_code.clone(),
290 request_hash: bundle.receipt.request_hash.clone(),
291 policy_hash: bundle.receipt.policy_hash.clone(),
292 audit_event_id: bundle.audit_event.event.id.clone(),
293 audit_event_hash: bundle.audit_event.event_hash.clone(),
294 audit_chain_length: bundle.audit_chain_segment.len(),
295 })
296}
297
298#[cfg(test)]
299mod tests {
300 use super::*;
301 use crate::audit::{AuditEvent, ZERO_HASH};
302 use crate::receipt::UnsignedReceipt;
303 use crate::signer::DevSigner;
304
305 fn fixture() -> (AuditBundle, DevSigner, DevSigner) {
309 let audit_signer = DevSigner::from_seed("audit-signer-v1", [11u8; 32]);
310 let receipt_signer = DevSigner::from_seed("decision-signer-v1", [7u8; 32]);
311
312 let e1_event = AuditEvent {
313 version: 1,
314 seq: 1,
315 id: "evt-01HTAWX5K3R8YV9NQB7C6P2DGQ".to_string(),
316 ts: chrono::DateTime::parse_from_rfc3339("2026-04-27T12:00:00Z")
317 .unwrap()
318 .into(),
319 event_type: "runtime_started".to_string(),
320 actor: "sbo3l-server".to_string(),
321 subject_id: "runtime".to_string(),
322 payload_hash: ZERO_HASH.to_string(),
323 metadata: serde_json::Map::new(),
324 policy_version: None,
325 policy_hash: None,
326 attestation_ref: None,
327 prev_event_hash: ZERO_HASH.to_string(),
328 };
329 let e1 = SignedAuditEvent::sign(e1_event, &audit_signer).unwrap();
330
331 let e2_event = AuditEvent {
332 version: 1,
333 seq: 2,
334 id: "evt-01HTAWX5K3R8YV9NQB7C6P2DGR".to_string(),
335 ts: chrono::DateTime::parse_from_rfc3339("2026-04-27T12:00:01Z")
336 .unwrap()
337 .into(),
338 event_type: "policy_decided".to_string(),
339 actor: "policy_engine".to_string(),
340 subject_id: "pr-test-001".to_string(),
341 payload_hash: "1111111111111111111111111111111111111111111111111111111111111111"
342 .to_string(),
343 metadata: serde_json::Map::new(),
344 policy_version: Some(1),
345 policy_hash: Some(
346 "2222222222222222222222222222222222222222222222222222222222222222".to_string(),
347 ),
348 attestation_ref: None,
349 prev_event_hash: e1.event_hash.clone(),
350 };
351 let e2 = SignedAuditEvent::sign(e2_event, &audit_signer).unwrap();
352
353 let e3_event = AuditEvent {
354 version: 1,
355 seq: 3,
356 id: "evt-01HTAWX5K3R8YV9NQB7C6P2DGS".to_string(),
357 ts: chrono::DateTime::parse_from_rfc3339("2026-04-27T12:00:02Z")
358 .unwrap()
359 .into(),
360 event_type: "policy_decided".to_string(),
361 actor: "policy_engine".to_string(),
362 subject_id: "pr-test-002".to_string(),
363 payload_hash: "3333333333333333333333333333333333333333333333333333333333333333"
364 .to_string(),
365 metadata: serde_json::Map::new(),
366 policy_version: Some(1),
367 policy_hash: Some(
368 "2222222222222222222222222222222222222222222222222222222222222222".to_string(),
369 ),
370 attestation_ref: None,
371 prev_event_hash: e2.event_hash.clone(),
372 };
373 let e3 = SignedAuditEvent::sign(e3_event, &audit_signer).unwrap();
374
375 let unsigned = UnsignedReceipt {
376 agent_id: "research-agent-01".to_string(),
377 decision: Decision::Allow,
378 deny_code: None,
379 request_hash: "1111111111111111111111111111111111111111111111111111111111111111"
380 .to_string(),
381 policy_hash: "2222222222222222222222222222222222222222222222222222222222222222"
382 .to_string(),
383 policy_version: Some(1),
384 audit_event_id: e2.event.id.clone(),
385 execution_ref: None,
386 issued_at: chrono::DateTime::parse_from_rfc3339("2026-04-27T12:00:01.500Z")
387 .unwrap()
388 .into(),
389 expires_at: None,
390 };
391 let receipt = unsigned.sign(&receipt_signer).unwrap();
392 let exported_at: DateTime<Utc> =
393 chrono::DateTime::parse_from_rfc3339("2026-04-28T08:00:00Z")
394 .unwrap()
395 .into();
396 let bundle = build(
397 receipt,
398 vec![e1, e2, e3],
399 receipt_signer.verifying_key_hex(),
400 audit_signer.verifying_key_hex(),
401 exported_at,
402 )
403 .unwrap();
404 (bundle, receipt_signer, audit_signer)
405 }
406
407 #[test]
408 fn happy_path_round_trip_verifies() {
409 let (bundle, _, _) = fixture();
410 let summary = verify(&bundle).expect("bundle must verify");
411 assert!(summary.receipt_signature_ok);
412 assert!(summary.audit_event_signature_ok);
413 assert!(summary.audit_chain_ok);
414 assert!(summary.receipt_audit_link_ok);
415 assert_eq!(summary.audit_chain_length, 3);
416 assert_eq!(summary.decision, Decision::Allow);
417 }
418
419 #[test]
420 fn bundle_canonical_export_is_deterministic() {
421 let (bundle, _, _) = fixture();
425 let a = serde_json::to_vec(&bundle).unwrap();
426 let b = serde_json::to_vec(&bundle).unwrap();
427 assert_eq!(a, b);
428 }
429
430 #[test]
431 fn verify_fails_when_request_hash_mutated() {
432 let (mut bundle, _, _) = fixture();
437 bundle.receipt.request_hash =
438 "deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef".to_string();
439 let err = verify(&bundle).expect_err("must reject mutated request_hash");
442 assert!(matches!(err, BundleError::ReceiptSignatureInvalid));
443 }
444
445 #[test]
446 fn verify_fails_when_policy_hash_mutated() {
447 let (mut bundle, _, _) = fixture();
448 bundle.receipt.policy_hash =
449 "cafebabecafebabecafebabecafebabecafebabecafebabecafebabecafebabe".to_string();
450 let err = verify(&bundle).expect_err("must reject mutated policy_hash");
451 assert!(matches!(err, BundleError::ReceiptSignatureInvalid));
452 }
453
454 #[test]
455 fn verify_fails_when_receipt_signature_bytes_mutated() {
456 let (mut bundle, _, _) = fixture();
457 let sig = &mut bundle.receipt.signature.signature_hex;
460 let last = sig.pop().unwrap();
461 sig.push(if last == '0' { '1' } else { '0' });
462 let err = verify(&bundle).expect_err("must reject mutated signature");
463 assert!(matches!(err, BundleError::ReceiptSignatureInvalid));
464 }
465
466 #[test]
467 fn verify_fails_when_audit_event_hash_mutated() {
468 let (mut bundle, _, _) = fixture();
473 bundle.audit_event.event_hash =
474 "0000000000000000000000000000000000000000000000000000000000000001".to_string();
475 let err = verify(&bundle).expect_err("must reject mutated audit_event hash");
476 assert!(matches!(err, BundleError::AuditEventHashMismatch));
480 }
481
482 #[test]
483 fn verify_fails_when_audit_chain_linkage_broken() {
484 let (mut bundle, _, _) = fixture();
485 bundle.audit_chain_segment[2].event.prev_event_hash =
487 "0000000000000000000000000000000000000000000000000000000000000001".to_string();
488 let err = verify(&bundle).expect_err("must reject broken chain linkage");
489 assert!(matches!(err, BundleError::Chain(_)));
490 }
491
492 #[test]
493 fn verify_fails_when_audit_event_not_in_chain() {
494 let (mut bundle, _, _) = fixture();
495 bundle.audit_chain_segment.retain(|e| e.event.seq != 2);
497 bundle.summary.audit_chain_root = bundle.audit_chain_segment[0].event_hash.clone();
500 bundle.summary.audit_chain_latest = bundle
501 .audit_chain_segment
502 .last()
503 .unwrap()
504 .event_hash
505 .clone();
506 let err = verify(&bundle).expect_err("must reject missing audit_event");
507 assert!(matches!(err, BundleError::Chain(_)));
513 }
514
515 #[test]
516 fn verify_fails_when_summary_lies_about_decision() {
517 let (mut bundle, _, _) = fixture();
519 bundle.summary.decision = Decision::Deny;
520 let err = verify(&bundle).expect_err("must reject summary that lies");
521 assert!(matches!(err, BundleError::SummaryMismatch("decision")));
522 }
523
524 #[test]
525 fn verify_fails_when_wrong_pubkey_supplied() {
526 let (mut bundle, _, _) = fixture();
529 let other = DevSigner::from_seed("attacker", [99u8; 32]);
530 bundle.verification_keys.receipt_signer_pubkey_hex = other.verifying_key_hex();
531 let err = verify(&bundle).expect_err("must reject wrong receipt pubkey");
532 assert!(matches!(err, BundleError::ReceiptSignatureInvalid));
533 }
534
535 #[test]
536 fn verify_fails_when_version_field_is_not_one() {
537 let (mut bundle, _, _) = fixture();
543 bundle.version = 2;
544 let err = verify(&bundle).expect_err("must reject unsupported bundle version");
545 assert!(
546 matches!(err, BundleError::UnsupportedVersion(2)),
547 "got {err:?}"
548 );
549
550 let (good, _, _) = fixture();
553 assert_eq!(good.version, 1);
554 verify(&good).expect("valid v1 bundle must still verify");
555 }
556
557 #[test]
558 fn verify_fails_when_version_is_unsupported_via_json_round_trip() {
559 let (bundle, _, _) = fixture();
564 let mut value: serde_json::Value = serde_json::to_value(&bundle).unwrap();
565 value["version"] = serde_json::Value::Number(serde_json::Number::from(2));
566 let tampered: AuditBundle = serde_json::from_value(value).expect(
567 "serde must deserialise an arbitrary u32; the format gate runs in verify(), not parse",
568 );
569 let err = verify(&tampered).expect_err("must reject v2 on disk");
570 assert!(matches!(err, BundleError::UnsupportedVersion(2)));
571 }
572
573 #[test]
574 fn unknown_bundle_type_string_is_rejected_by_serde_at_parse_time() {
575 let (bundle, _, _) = fixture();
586 let mut value: serde_json::Value = serde_json::to_value(&bundle).unwrap();
587 value["bundle_type"] = serde_json::Value::String("sbo3l.audit_bundle.v2".to_string());
588 let parse_err = serde_json::from_value::<AuditBundle>(value)
589 .expect_err("serde must reject an unknown bundle_type string before reaching verify()");
590 let msg = parse_err.to_string();
594 assert!(
595 msg.contains("bundle_type") || msg.contains("variant"),
596 "expected a serde enum-variant error mentioning bundle_type; got {msg}"
597 );
598 }
599}