Skip to main content

sbo3l_core/
audit.rs

1//! Audit event v1 — protocol types and chain helpers.
2//!
3//! Mirrors `schemas/audit_event_v1.json`. The signature is over the canonical
4//! JSON of the inner `event` object.
5
6use chrono::{DateTime, Utc};
7use serde::{Deserialize, Serialize};
8
9use crate::error::Result;
10use crate::hashing::{canonical_json, sha256_hex};
11use crate::receipt::EmbeddedSignature as Signature;
12use crate::receipt::SignatureAlgorithm;
13use crate::signer::{verify_hex, SignerBackend, VerifyError};
14
15pub const ZERO_HASH: &str = "0000000000000000000000000000000000000000000000000000000000000000";
16
17#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
18#[serde(deny_unknown_fields)]
19pub struct AuditEvent {
20    pub version: u32,
21    pub seq: u64,
22    pub id: String,
23    pub ts: DateTime<Utc>,
24    #[serde(rename = "type")]
25    pub event_type: String,
26    pub actor: String,
27    pub subject_id: String,
28    pub payload_hash: String,
29    pub metadata: serde_json::Map<String, serde_json::Value>,
30    #[serde(default, skip_serializing_if = "Option::is_none")]
31    pub policy_version: Option<u32>,
32    #[serde(default, skip_serializing_if = "Option::is_none")]
33    pub policy_hash: Option<String>,
34    #[serde(default, skip_serializing_if = "Option::is_none")]
35    pub attestation_ref: Option<String>,
36    pub prev_event_hash: String,
37}
38
39#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
40#[serde(deny_unknown_fields)]
41pub struct SignedAuditEvent {
42    pub event: AuditEvent,
43    pub event_hash: String,
44    pub signature: Signature,
45}
46
47#[derive(Debug, thiserror::Error)]
48pub enum ChainError {
49    #[error("seq out of order at index {index}: expected {expected}, got {got}")]
50    SeqOutOfOrder {
51        index: usize,
52        expected: u64,
53        got: u64,
54    },
55    #[error("prev_event_hash mismatch at seq {seq}")]
56    PrevHashMismatch { seq: u64 },
57    #[error("event_hash mismatch at seq {seq}")]
58    EventHashMismatch { seq: u64 },
59    #[error("signature verification failed at seq {seq}")]
60    SignatureFailed { seq: u64 },
61    #[error(transparent)]
62    Core(#[from] crate::error::CoreError),
63}
64
65impl AuditEvent {
66    pub fn canonical_hash(&self) -> Result<String> {
67        let v = serde_json::to_value(self)?;
68        let bytes = canonical_json(&v)?;
69        Ok(sha256_hex(&bytes))
70    }
71}
72
73impl SignedAuditEvent {
74    /// Sign an `AuditEvent` and return the signed envelope. The signature is
75    /// computed over the canonical JSON of the inner event; the same bytes are
76    /// used to derive `event_hash`. Accepts any [`SignerBackend`] — the
77    /// envelope's `signature.key_id` records the backend's
78    /// `current_key_id()` so a verifier can route the public-key lookup
79    /// (especially after a `MockKmsSigner` rotation).
80    pub fn sign<S: SignerBackend + ?Sized>(event: AuditEvent, signer: &S) -> Result<Self> {
81        let v = serde_json::to_value(&event)?;
82        let bytes = canonical_json(&v)?;
83        let event_hash = sha256_hex(&bytes);
84        let sig_hex = signer.sign_hex(&bytes);
85        Ok(Self {
86            event,
87            event_hash,
88            signature: Signature {
89                algorithm: SignatureAlgorithm::Ed25519,
90                key_id: signer.current_key_id().to_string(),
91                signature_hex: sig_hex,
92            },
93        })
94    }
95
96    pub fn verify_signature(
97        &self,
98        verifying_key_hex: &str,
99    ) -> std::result::Result<(), VerifyError> {
100        let v = serde_json::to_value(&self.event).map_err(|_| VerifyError::Invalid)?;
101        let bytes = canonical_json(&v).map_err(|_| VerifyError::Invalid)?;
102        verify_hex(verifying_key_hex, &bytes, &self.signature.signature_hex)
103    }
104}
105
106/// Verify the integrity of a chain of signed audit events.
107///
108/// Checks performed:
109///   * `seq` starts at 1 and is monotonic.
110///   * `prev_event_hash` of each event matches the prior event's `event_hash`
111///     (or `ZERO_HASH` for the genesis event).
112///   * if `verify_hashes` is `true`, recomputes `event_hash` from canonical
113///     event bytes and compares.
114///   * if `verifying_key_hex` is provided, verifies each event's signature.
115pub fn verify_chain(
116    events: &[SignedAuditEvent],
117    verify_hashes: bool,
118    verifying_key_hex: Option<&str>,
119) -> std::result::Result<(), ChainError> {
120    let mut prev_hash = ZERO_HASH.to_string();
121    for (i, signed) in events.iter().enumerate() {
122        let expected_seq = (i as u64) + 1;
123        if signed.event.seq != expected_seq {
124            return Err(ChainError::SeqOutOfOrder {
125                index: i,
126                expected: expected_seq,
127                got: signed.event.seq,
128            });
129        }
130        if signed.event.prev_event_hash != prev_hash {
131            return Err(ChainError::PrevHashMismatch {
132                seq: signed.event.seq,
133            });
134        }
135        if verify_hashes {
136            let computed = signed.event.canonical_hash()?;
137            if computed != signed.event_hash {
138                return Err(ChainError::EventHashMismatch {
139                    seq: signed.event.seq,
140                });
141            }
142        }
143        if let Some(pk) = verifying_key_hex {
144            if signed.verify_signature(pk).is_err() {
145                return Err(ChainError::SignatureFailed {
146                    seq: signed.event.seq,
147                });
148            }
149        }
150        prev_hash = signed.event_hash.clone();
151    }
152    Ok(())
153}
154
155#[cfg(test)]
156mod tests {
157    use super::*;
158    use crate::signer::DevSigner;
159    use serde_json::json;
160
161    fn ev(seq: u64, prev: &str, ts: &str) -> AuditEvent {
162        // ULIDs are exactly 26 Crockford base32 chars after "evt-".
163        let suffix = match seq {
164            1 => "01HTAWX5K3R8YV9NQB7C6P2DGQ",
165            2 => "01HTAWX5K3R8YV9NQB7C6P2DGR",
166            3 => "01HTAWX5K3R8YV9NQB7C6P2DGS",
167            _ => "01HTAWX5K3R8YV9NQB7C6P2DGZ",
168        };
169        AuditEvent {
170            version: 1,
171            seq,
172            id: format!("evt-{suffix}"),
173            ts: chrono::DateTime::parse_from_rfc3339(ts).unwrap().into(),
174            event_type: "runtime_started".to_string(),
175            actor: "sbo3l-server".to_string(),
176            subject_id: "runtime".to_string(),
177            payload_hash: ZERO_HASH.to_string(),
178            metadata: json!({"mode":"dev"}).as_object().unwrap().clone(),
179            policy_version: None,
180            policy_hash: None,
181            attestation_ref: None,
182            prev_event_hash: prev.to_string(),
183        }
184    }
185
186    #[test]
187    fn sign_and_verify_envelope() {
188        let signer = DevSigner::from_seed("audit-signer-v1", [13u8; 32]);
189        let signed = SignedAuditEvent::sign(ev(1, ZERO_HASH, "2026-04-27T12:00:00Z"), &signer)
190            .expect("sign");
191        signed
192            .verify_signature(&signer.verifying_key_hex())
193            .unwrap();
194    }
195
196    #[test]
197    fn signed_event_validates_against_schema() {
198        let signer = DevSigner::from_seed("audit-signer-v1", [13u8; 32]);
199        let signed = SignedAuditEvent::sign(ev(1, ZERO_HASH, "2026-04-27T12:00:00Z"), &signer)
200            .expect("sign");
201        let v = serde_json::to_value(&signed).unwrap();
202        crate::schema::validate_audit_event(&v).unwrap();
203    }
204
205    #[test]
206    fn chain_verify_round_trip() {
207        let signer = DevSigner::from_seed("audit-signer-v1", [13u8; 32]);
208        let e1 = SignedAuditEvent::sign(ev(1, ZERO_HASH, "2026-04-27T12:00:00Z"), &signer).unwrap();
209        let e2 =
210            SignedAuditEvent::sign(ev(2, &e1.event_hash, "2026-04-27T12:00:01Z"), &signer).unwrap();
211        let e3 =
212            SignedAuditEvent::sign(ev(3, &e2.event_hash, "2026-04-27T12:00:02Z"), &signer).unwrap();
213        verify_chain(&[e1, e2, e3], true, Some(&signer.verifying_key_hex())).unwrap();
214    }
215
216    #[test]
217    fn chain_verify_detects_tamper_in_middle() {
218        let signer = DevSigner::from_seed("audit-signer-v1", [13u8; 32]);
219        let e1 = SignedAuditEvent::sign(ev(1, ZERO_HASH, "2026-04-27T12:00:00Z"), &signer).unwrap();
220        let mut e2 =
221            SignedAuditEvent::sign(ev(2, &e1.event_hash, "2026-04-27T12:00:01Z"), &signer).unwrap();
222        // Mutate event without re-signing.
223        e2.event.actor = "attacker".to_string();
224        let err = verify_chain(&[e1.clone(), e2.clone()], true, None).unwrap_err();
225        assert!(matches!(err, ChainError::EventHashMismatch { .. }));
226    }
227}