use std::io::Write;
use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AuditEntry {
pub serial: u64,
pub event_type: String,
pub payload: serde_json::Value,
pub timestamp: String,
pub hmac: String,
pub prev_hmac: String,
}
pub struct AuditChain {
log_path: PathBuf,
hmac_key: Vec<u8>,
last_hmac: String,
next_serial: u64,
}
impl AuditChain {
pub fn open(log_path: impl Into<PathBuf>, hmac_key: &[u8]) -> crate::Result<Self> {
let log_path = log_path.into();
let hmac_key = hmac_key.to_vec();
if log_path.exists() {
let content = std::fs::read_to_string(&log_path)
.map_err(|e| crate::KavachError::ExecFailed(format!("audit chain read: {e}")))?;
let entries: Vec<AuditEntry> = content
.lines()
.filter(|l| !l.is_empty())
.map(serde_json::from_str)
.collect::<Result<_, _>>()
.map_err(|e| crate::KavachError::ExecFailed(format!("audit chain parse: {e}")))?;
verify_chain(&entries, &hmac_key)?;
let last_hmac = entries.last().map(|e| e.hmac.clone()).unwrap_or_default();
let next_serial = entries.len() as u64;
Ok(Self {
log_path,
hmac_key,
last_hmac,
next_serial,
})
} else {
let mut chain = Self {
log_path,
hmac_key,
last_hmac: String::new(),
next_serial: 0,
};
chain.record(
"genesis",
serde_json::json!({"message": "audit chain initialized"}),
)?;
Ok(chain)
}
}
pub fn record(
&mut self,
event_type: &str,
payload: serde_json::Value,
) -> crate::Result<AuditEntry> {
let timestamp = chrono::Utc::now().to_rfc3339();
let serial = self.next_serial;
let payload_str = sorted_json(&payload)?;
let content = format!(
"{}:{}:{}:{}:{}",
serial, event_type, payload_str, timestamp, self.last_hmac
);
let hmac = compute_hmac(&self.hmac_key, content.as_bytes());
let entry = AuditEntry {
serial,
event_type: event_type.to_owned(),
payload,
timestamp,
hmac: hmac.clone(),
prev_hmac: self.last_hmac.clone(),
};
let mut file = std::fs::OpenOptions::new()
.create(true)
.append(true)
.open(&self.log_path)
.map_err(|e| crate::KavachError::ExecFailed(format!("audit chain write: {e}")))?;
let line = serde_json::to_string(&entry)
.map_err(|e| crate::KavachError::ExecFailed(format!("audit chain serialize: {e}")))?;
writeln!(file, "{line}")
.map_err(|e| crate::KavachError::ExecFailed(format!("audit chain append: {e}")))?;
self.last_hmac = hmac;
self.next_serial += 1;
tracing::debug!(serial, event_type, "audit chain entry recorded");
Ok(entry)
}
#[must_use]
pub fn len(&self) -> u64 {
self.next_serial
}
#[must_use]
pub fn is_empty(&self) -> bool {
self.next_serial == 0
}
#[must_use]
pub fn path(&self) -> &Path {
&self.log_path
}
}
impl std::fmt::Debug for AuditChain {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("AuditChain")
.field("log_path", &self.log_path)
.field("entries", &self.next_serial)
.finish()
}
}
pub fn verify_chain(entries: &[AuditEntry], hmac_key: &[u8]) -> crate::Result<()> {
let mut prev_hmac = String::new();
for (i, entry) in entries.iter().enumerate() {
if entry.serial != i as u64 {
return Err(crate::KavachError::ExecFailed(format!(
"audit chain serial gap: expected {i}, got {}",
entry.serial
)));
}
if entry.prev_hmac != prev_hmac {
return Err(crate::KavachError::ExecFailed(format!(
"audit chain broken at serial {}: prev_hmac mismatch",
entry.serial
)));
}
let content = format!(
"{}:{}:{}:{}:{}",
entry.serial,
entry.event_type,
sorted_json(&entry.payload)?,
entry.timestamp,
entry.prev_hmac
);
let expected_hmac = compute_hmac(hmac_key, content.as_bytes());
if entry.hmac != expected_hmac {
return Err(crate::KavachError::ExecFailed(format!(
"audit chain HMAC mismatch at serial {}",
entry.serial
)));
}
prev_hmac = entry.hmac.clone();
}
Ok(())
}
fn sorted_json(value: &serde_json::Value) -> crate::Result<String> {
match value {
serde_json::Value::Object(map) => {
let sorted: std::collections::BTreeMap<_, _> = map.iter().collect();
serde_json::to_string(&sorted)
.map_err(|e| crate::KavachError::ExecFailed(format!("audit json serialize: {e}")))
}
other => serde_json::to_string(other)
.map_err(|e| crate::KavachError::ExecFailed(format!("audit json serialize: {e}"))),
}
}
fn compute_hmac(key: &[u8], data: &[u8]) -> String {
use hmac::{Hmac, Mac};
use sha2::Sha256;
type HmacSha256 = Hmac<Sha256>;
let Ok(mut mac) = HmacSha256::new_from_slice(key) else {
return String::new();
};
mac.update(data);
let result = mac.finalize();
let bytes = result.into_bytes();
let mut hex = String::with_capacity(bytes.len() * 2);
for b in bytes.iter() {
use std::fmt::Write;
let _ = write!(hex, "{b:02x}");
}
hex
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn create_and_record() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("audit.jsonl");
let mut chain = AuditChain::open(&path, b"test-key").unwrap();
assert_eq!(chain.len(), 1);
chain
.record("sandbox_created", serde_json::json!({"id": "sb-1"}))
.unwrap();
assert_eq!(chain.len(), 2);
chain
.record("exec", serde_json::json!({"cmd": "echo hello"}))
.unwrap();
assert_eq!(chain.len(), 3);
}
#[test]
fn chain_survives_reopen() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("audit.jsonl");
{
let mut chain = AuditChain::open(&path, b"key").unwrap();
chain
.record("event1", serde_json::json!({"data": 1}))
.unwrap();
chain
.record("event2", serde_json::json!({"data": 2}))
.unwrap();
}
let chain = AuditChain::open(&path, b"key").unwrap();
assert_eq!(chain.len(), 3); }
#[test]
fn tamper_detection() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("audit.jsonl");
{
let mut chain = AuditChain::open(&path, b"key").unwrap();
chain.record("event", serde_json::json!({})).unwrap();
}
let content = std::fs::read_to_string(&path).unwrap();
let tampered = content.replace("event", "tampered_event");
std::fs::write(&path, tampered).unwrap();
let result = AuditChain::open(&path, b"key");
assert!(result.is_err());
}
#[test]
fn wrong_key_fails() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("audit.jsonl");
{
let mut chain = AuditChain::open(&path, b"key-1").unwrap();
chain.record("event", serde_json::json!({})).unwrap();
}
let result = AuditChain::open(&path, b"key-2");
assert!(result.is_err());
}
#[test]
fn entries_linked() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("audit.jsonl");
let mut chain = AuditChain::open(&path, b"key").unwrap();
let e1 = chain.record("a", serde_json::json!({})).unwrap();
let e2 = chain.record("b", serde_json::json!({})).unwrap();
assert_eq!(e2.prev_hmac, e1.hmac);
assert_ne!(e1.hmac, e2.hmac);
}
#[test]
fn debug_format() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("audit.jsonl");
let chain = AuditChain::open(&path, b"key").unwrap();
let debug = format!("{chain:?}");
assert!(debug.contains("AuditChain"));
}
#[test]
fn verify_empty() {
assert!(verify_chain(&[], b"key").is_ok());
}
#[test]
fn audit_entry_serde() {
let entry = AuditEntry {
serial: 0,
event_type: "test".into(),
payload: serde_json::json!({}),
timestamp: "2026-03-25T00:00:00Z".into(),
hmac: "abc".into(),
prev_hmac: String::new(),
};
let json = serde_json::to_string(&entry).unwrap();
let back: AuditEntry = serde_json::from_str(&json).unwrap();
assert_eq!(back.serial, 0);
}
}