use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use thiserror::Error;
use crate::audit::{verify_chain, ChainError, SignedAuditEvent};
use crate::receipt::{Decision, PolicyReceipt};
use crate::signer::VerifyError;
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct AuditBundle {
pub bundle_type: BundleType,
pub version: u32,
pub exported_at: DateTime<Utc>,
pub receipt: PolicyReceipt,
pub audit_event: SignedAuditEvent,
pub audit_chain_segment: Vec<SignedAuditEvent>,
pub verification_keys: VerificationKeys,
pub summary: BundleSummary,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum BundleType {
#[serde(rename = "sbo3l.audit_bundle.v1")]
AuditBundleV1,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct VerificationKeys {
pub receipt_signer_pubkey_hex: String,
pub audit_signer_pubkey_hex: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct BundleSummary {
pub decision: Decision,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub deny_code: Option<String>,
pub request_hash: String,
pub policy_hash: String,
pub audit_event_id: String,
pub audit_event_hash: String,
pub audit_chain_root: String,
pub audit_chain_latest: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct VerifySummary {
pub receipt_signature_ok: bool,
pub audit_event_signature_ok: bool,
pub audit_chain_ok: bool,
pub receipt_audit_link_ok: bool,
pub decision: Decision,
pub deny_code: Option<String>,
pub request_hash: String,
pub policy_hash: String,
pub audit_event_id: String,
pub audit_event_hash: String,
pub audit_chain_length: usize,
}
const SUPPORTED_BUNDLE_VERSION: u32 = 1;
#[derive(Debug, Error)]
pub enum BundleError {
#[error("bundle is missing a receipt's audit_event_id from the chain segment")]
AuditEventNotInChain,
#[error("receipt.audit_event_id does not match audit_event.event.id")]
ReceiptAuditMismatch,
#[error("audit_event hash in chain does not match standalone audit_event")]
AuditEventHashMismatch,
#[error("summary field '{0}' does not match the bundle body")]
SummaryMismatch(&'static str),
#[error("receipt signature does not verify under verification_keys.receipt_signer_pubkey_hex")]
ReceiptSignatureInvalid,
#[error(
"audit_event signature does not verify under verification_keys.audit_signer_pubkey_hex"
)]
AuditEventSignatureInvalid,
#[error("audit chain invalid: {0}")]
Chain(#[from] ChainError),
#[error("signer error: {0}")]
Signer(#[from] VerifyError),
#[error("serde error: {0}")]
Serde(#[from] serde_json::Error),
#[error("unsupported bundle version: {0} (this build supports v1)")]
UnsupportedVersion(u32),
#[error("unsupported bundle_type: only sbo3l.audit_bundle.v1 is accepted in this build")]
UnsupportedBundleType,
}
pub fn build(
receipt: PolicyReceipt,
audit_chain_segment: Vec<SignedAuditEvent>,
receipt_signer_pubkey_hex: String,
audit_signer_pubkey_hex: String,
exported_at: DateTime<Utc>,
) -> Result<AuditBundle, BundleError> {
let audit_event = audit_chain_segment
.iter()
.find(|e| e.event.id == receipt.audit_event_id)
.cloned()
.ok_or(BundleError::AuditEventNotInChain)?;
let chain_root = audit_chain_segment
.first()
.map(|e| e.event_hash.clone())
.ok_or(BundleError::AuditEventNotInChain)?;
let chain_latest = audit_chain_segment
.last()
.map(|e| e.event_hash.clone())
.ok_or(BundleError::AuditEventNotInChain)?;
let summary = BundleSummary {
decision: receipt.decision.clone(),
deny_code: receipt.deny_code.clone(),
request_hash: receipt.request_hash.clone(),
policy_hash: receipt.policy_hash.clone(),
audit_event_id: audit_event.event.id.clone(),
audit_event_hash: audit_event.event_hash.clone(),
audit_chain_root: chain_root,
audit_chain_latest: chain_latest,
};
Ok(AuditBundle {
bundle_type: BundleType::AuditBundleV1,
version: 1,
exported_at,
receipt,
audit_event,
audit_chain_segment,
verification_keys: VerificationKeys {
receipt_signer_pubkey_hex,
audit_signer_pubkey_hex,
},
summary,
})
}
pub fn verify(bundle: &AuditBundle) -> Result<VerifySummary, BundleError> {
if !matches!(bundle.bundle_type, BundleType::AuditBundleV1) {
return Err(BundleError::UnsupportedBundleType);
}
if bundle.version != SUPPORTED_BUNDLE_VERSION {
return Err(BundleError::UnsupportedVersion(bundle.version));
}
bundle
.receipt
.verify(&bundle.verification_keys.receipt_signer_pubkey_hex)
.map_err(|_| BundleError::ReceiptSignatureInvalid)?;
bundle
.audit_event
.verify_signature(&bundle.verification_keys.audit_signer_pubkey_hex)
.map_err(|_| BundleError::AuditEventSignatureInvalid)?;
verify_chain(
&bundle.audit_chain_segment,
true,
Some(&bundle.verification_keys.audit_signer_pubkey_hex),
)?;
if bundle.receipt.audit_event_id != bundle.audit_event.event.id {
return Err(BundleError::ReceiptAuditMismatch);
}
let chain_member = bundle
.audit_chain_segment
.iter()
.find(|e| e.event.id == bundle.audit_event.event.id)
.ok_or(BundleError::AuditEventNotInChain)?;
if chain_member != &bundle.audit_event {
return Err(BundleError::AuditEventHashMismatch);
}
let s = &bundle.summary;
if s.decision != bundle.receipt.decision {
return Err(BundleError::SummaryMismatch("decision"));
}
if s.deny_code != bundle.receipt.deny_code {
return Err(BundleError::SummaryMismatch("deny_code"));
}
if s.request_hash != bundle.receipt.request_hash {
return Err(BundleError::SummaryMismatch("request_hash"));
}
if s.policy_hash != bundle.receipt.policy_hash {
return Err(BundleError::SummaryMismatch("policy_hash"));
}
if s.audit_event_id != bundle.audit_event.event.id {
return Err(BundleError::SummaryMismatch("audit_event_id"));
}
if s.audit_event_hash != bundle.audit_event.event_hash {
return Err(BundleError::SummaryMismatch("audit_event_hash"));
}
let expected_root = &bundle
.audit_chain_segment
.first()
.ok_or(BundleError::AuditEventNotInChain)?
.event_hash;
if &s.audit_chain_root != expected_root {
return Err(BundleError::SummaryMismatch("audit_chain_root"));
}
let expected_latest = &bundle
.audit_chain_segment
.last()
.ok_or(BundleError::AuditEventNotInChain)?
.event_hash;
if &s.audit_chain_latest != expected_latest {
return Err(BundleError::SummaryMismatch("audit_chain_latest"));
}
Ok(VerifySummary {
receipt_signature_ok: true,
audit_event_signature_ok: true,
audit_chain_ok: true,
receipt_audit_link_ok: true,
decision: bundle.receipt.decision.clone(),
deny_code: bundle.receipt.deny_code.clone(),
request_hash: bundle.receipt.request_hash.clone(),
policy_hash: bundle.receipt.policy_hash.clone(),
audit_event_id: bundle.audit_event.event.id.clone(),
audit_event_hash: bundle.audit_event.event_hash.clone(),
audit_chain_length: bundle.audit_chain_segment.len(),
})
}
#[cfg(test)]
mod tests {
use super::*;
use crate::audit::{AuditEvent, ZERO_HASH};
use crate::receipt::UnsignedReceipt;
use crate::signer::DevSigner;
fn fixture() -> (AuditBundle, DevSigner, DevSigner) {
let audit_signer = DevSigner::from_seed("audit-signer-v1", [11u8; 32]);
let receipt_signer = DevSigner::from_seed("decision-signer-v1", [7u8; 32]);
let e1_event = AuditEvent {
version: 1,
seq: 1,
id: "evt-01HTAWX5K3R8YV9NQB7C6P2DGQ".to_string(),
ts: chrono::DateTime::parse_from_rfc3339("2026-04-27T12:00:00Z")
.unwrap()
.into(),
event_type: "runtime_started".to_string(),
actor: "sbo3l-server".to_string(),
subject_id: "runtime".to_string(),
payload_hash: ZERO_HASH.to_string(),
metadata: serde_json::Map::new(),
policy_version: None,
policy_hash: None,
attestation_ref: None,
prev_event_hash: ZERO_HASH.to_string(),
};
let e1 = SignedAuditEvent::sign(e1_event, &audit_signer).unwrap();
let e2_event = AuditEvent {
version: 1,
seq: 2,
id: "evt-01HTAWX5K3R8YV9NQB7C6P2DGR".to_string(),
ts: chrono::DateTime::parse_from_rfc3339("2026-04-27T12:00:01Z")
.unwrap()
.into(),
event_type: "policy_decided".to_string(),
actor: "policy_engine".to_string(),
subject_id: "pr-test-001".to_string(),
payload_hash: "1111111111111111111111111111111111111111111111111111111111111111"
.to_string(),
metadata: serde_json::Map::new(),
policy_version: Some(1),
policy_hash: Some(
"2222222222222222222222222222222222222222222222222222222222222222".to_string(),
),
attestation_ref: None,
prev_event_hash: e1.event_hash.clone(),
};
let e2 = SignedAuditEvent::sign(e2_event, &audit_signer).unwrap();
let e3_event = AuditEvent {
version: 1,
seq: 3,
id: "evt-01HTAWX5K3R8YV9NQB7C6P2DGS".to_string(),
ts: chrono::DateTime::parse_from_rfc3339("2026-04-27T12:00:02Z")
.unwrap()
.into(),
event_type: "policy_decided".to_string(),
actor: "policy_engine".to_string(),
subject_id: "pr-test-002".to_string(),
payload_hash: "3333333333333333333333333333333333333333333333333333333333333333"
.to_string(),
metadata: serde_json::Map::new(),
policy_version: Some(1),
policy_hash: Some(
"2222222222222222222222222222222222222222222222222222222222222222".to_string(),
),
attestation_ref: None,
prev_event_hash: e2.event_hash.clone(),
};
let e3 = SignedAuditEvent::sign(e3_event, &audit_signer).unwrap();
let unsigned = UnsignedReceipt {
agent_id: "research-agent-01".to_string(),
decision: Decision::Allow,
deny_code: None,
request_hash: "1111111111111111111111111111111111111111111111111111111111111111"
.to_string(),
policy_hash: "2222222222222222222222222222222222222222222222222222222222222222"
.to_string(),
policy_version: Some(1),
audit_event_id: e2.event.id.clone(),
execution_ref: None,
issued_at: chrono::DateTime::parse_from_rfc3339("2026-04-27T12:00:01.500Z")
.unwrap()
.into(),
expires_at: None,
};
let receipt = unsigned.sign(&receipt_signer).unwrap();
let exported_at: DateTime<Utc> =
chrono::DateTime::parse_from_rfc3339("2026-04-28T08:00:00Z")
.unwrap()
.into();
let bundle = build(
receipt,
vec![e1, e2, e3],
receipt_signer.verifying_key_hex(),
audit_signer.verifying_key_hex(),
exported_at,
)
.unwrap();
(bundle, receipt_signer, audit_signer)
}
#[test]
fn happy_path_round_trip_verifies() {
let (bundle, _, _) = fixture();
let summary = verify(&bundle).expect("bundle must verify");
assert!(summary.receipt_signature_ok);
assert!(summary.audit_event_signature_ok);
assert!(summary.audit_chain_ok);
assert!(summary.receipt_audit_link_ok);
assert_eq!(summary.audit_chain_length, 3);
assert_eq!(summary.decision, Decision::Allow);
}
#[test]
fn bundle_canonical_export_is_deterministic() {
let (bundle, _, _) = fixture();
let a = serde_json::to_vec(&bundle).unwrap();
let b = serde_json::to_vec(&bundle).unwrap();
assert_eq!(a, b);
}
#[test]
fn verify_fails_when_request_hash_mutated() {
let (mut bundle, _, _) = fixture();
bundle.receipt.request_hash =
"deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef".to_string();
let err = verify(&bundle).expect_err("must reject mutated request_hash");
assert!(matches!(err, BundleError::ReceiptSignatureInvalid));
}
#[test]
fn verify_fails_when_policy_hash_mutated() {
let (mut bundle, _, _) = fixture();
bundle.receipt.policy_hash =
"cafebabecafebabecafebabecafebabecafebabecafebabecafebabecafebabe".to_string();
let err = verify(&bundle).expect_err("must reject mutated policy_hash");
assert!(matches!(err, BundleError::ReceiptSignatureInvalid));
}
#[test]
fn verify_fails_when_receipt_signature_bytes_mutated() {
let (mut bundle, _, _) = fixture();
let sig = &mut bundle.receipt.signature.signature_hex;
let last = sig.pop().unwrap();
sig.push(if last == '0' { '1' } else { '0' });
let err = verify(&bundle).expect_err("must reject mutated signature");
assert!(matches!(err, BundleError::ReceiptSignatureInvalid));
}
#[test]
fn verify_fails_when_audit_event_hash_mutated() {
let (mut bundle, _, _) = fixture();
bundle.audit_event.event_hash =
"0000000000000000000000000000000000000000000000000000000000000001".to_string();
let err = verify(&bundle).expect_err("must reject mutated audit_event hash");
assert!(matches!(err, BundleError::AuditEventHashMismatch));
}
#[test]
fn verify_fails_when_audit_chain_linkage_broken() {
let (mut bundle, _, _) = fixture();
bundle.audit_chain_segment[2].event.prev_event_hash =
"0000000000000000000000000000000000000000000000000000000000000001".to_string();
let err = verify(&bundle).expect_err("must reject broken chain linkage");
assert!(matches!(err, BundleError::Chain(_)));
}
#[test]
fn verify_fails_when_audit_event_not_in_chain() {
let (mut bundle, _, _) = fixture();
bundle.audit_chain_segment.retain(|e| e.event.seq != 2);
bundle.summary.audit_chain_root = bundle.audit_chain_segment[0].event_hash.clone();
bundle.summary.audit_chain_latest = bundle
.audit_chain_segment
.last()
.unwrap()
.event_hash
.clone();
let err = verify(&bundle).expect_err("must reject missing audit_event");
assert!(matches!(err, BundleError::Chain(_)));
}
#[test]
fn verify_fails_when_summary_lies_about_decision() {
let (mut bundle, _, _) = fixture();
bundle.summary.decision = Decision::Deny;
let err = verify(&bundle).expect_err("must reject summary that lies");
assert!(matches!(err, BundleError::SummaryMismatch("decision")));
}
#[test]
fn verify_fails_when_wrong_pubkey_supplied() {
let (mut bundle, _, _) = fixture();
let other = DevSigner::from_seed("attacker", [99u8; 32]);
bundle.verification_keys.receipt_signer_pubkey_hex = other.verifying_key_hex();
let err = verify(&bundle).expect_err("must reject wrong receipt pubkey");
assert!(matches!(err, BundleError::ReceiptSignatureInvalid));
}
#[test]
fn verify_fails_when_version_field_is_not_one() {
let (mut bundle, _, _) = fixture();
bundle.version = 2;
let err = verify(&bundle).expect_err("must reject unsupported bundle version");
assert!(
matches!(err, BundleError::UnsupportedVersion(2)),
"got {err:?}"
);
let (good, _, _) = fixture();
assert_eq!(good.version, 1);
verify(&good).expect("valid v1 bundle must still verify");
}
#[test]
fn verify_fails_when_version_is_unsupported_via_json_round_trip() {
let (bundle, _, _) = fixture();
let mut value: serde_json::Value = serde_json::to_value(&bundle).unwrap();
value["version"] = serde_json::Value::Number(serde_json::Number::from(2));
let tampered: AuditBundle = serde_json::from_value(value).expect(
"serde must deserialise an arbitrary u32; the format gate runs in verify(), not parse",
);
let err = verify(&tampered).expect_err("must reject v2 on disk");
assert!(matches!(err, BundleError::UnsupportedVersion(2)));
}
#[test]
fn unknown_bundle_type_string_is_rejected_by_serde_at_parse_time() {
let (bundle, _, _) = fixture();
let mut value: serde_json::Value = serde_json::to_value(&bundle).unwrap();
value["bundle_type"] = serde_json::Value::String("sbo3l.audit_bundle.v2".to_string());
let parse_err = serde_json::from_value::<AuditBundle>(value)
.expect_err("serde must reject an unknown bundle_type string before reaching verify()");
let msg = parse_err.to_string();
assert!(
msg.contains("bundle_type") || msg.contains("variant"),
"expected a serde enum-variant error mentioning bundle_type; got {msg}"
);
}
}