use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use crate::error::{Error, Result};
use crate::identity::{AgentIdentity, SpiffeId, DOMAIN_AUDIT};
pub const AUDIT_EVENT_VERSION: u8 = 1;
pub const MAX_SIGNED_AUDIT_EVENT_BYTES: u64 = 16 * 1024;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AuditEvent {
pub version: u8,
pub timestamp: DateTime<Utc>,
pub agent_id: SpiffeId,
pub action: String,
pub details_json: String,
pub chain_hash: String,
}
impl AuditEvent {
pub fn details(&self) -> serde_json::Value {
serde_json::from_str(&self.details_json).unwrap_or(serde_json::Value::Null)
}
}
impl AuditEvent {
pub fn new(
agent_id: SpiffeId,
action: impl Into<String>,
details: serde_json::Value,
previous_event_hash: Option<String>,
) -> Self {
let details_json = serde_json::to_string(&details).unwrap_or_else(|_| "null".to_string());
AuditEvent {
version: AUDIT_EVENT_VERSION,
timestamp: Utc::now(),
agent_id,
action: action.into(),
details_json,
chain_hash: previous_event_hash.unwrap_or_default(),
}
}
pub fn sign(self, identity: &AgentIdentity) -> Result<SignedAuditEvent> {
let event_bytes = bincode::serialize(&self)
.map_err(|e| Error::Serialization(format!("audit event serialize: {e}")))?;
let signature = identity.sign_with_domain(DOMAIN_AUDIT, &event_bytes)?;
Ok(SignedAuditEvent {
event: self,
signature,
})
}
pub fn hash_hex(&self) -> Result<String> {
let bytes = bincode::serialize(self)
.map_err(|e| Error::Serialization(format!("audit event hash serialize: {e}")))?;
Ok(hex::encode(Sha256::digest(&bytes)))
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SignedAuditEvent {
pub event: AuditEvent,
pub signature: Vec<u8>,
}
impl SignedAuditEvent {
pub fn verify(&self, verifying_key_bytes: &[u8]) -> Result<bool> {
let event_bytes = bincode::serialize(&self.event)
.map_err(|e| Error::Serialization(format!("audit event serialize: {e}")))?;
match AgentIdentity::verify_with_domain(
verifying_key_bytes,
DOMAIN_AUDIT,
&event_bytes,
&self.signature,
) {
Ok(valid) => Ok(valid),
Err(crate::error::Error::Crypto(_)) => Ok(false),
Err(e) => Err(e),
}
}
pub fn hash_hex(&self) -> Result<String> {
let bytes = bincode::serialize(self)
.map_err(|e| Error::Serialization(format!("signed event hash serialize: {e}")))?;
Ok(hex::encode(Sha256::digest(&bytes)))
}
pub fn to_bytes(&self) -> Result<Vec<u8>> {
bincode::serialize(self)
.map_err(|e| Error::Serialization(format!("signed event serialize: {e}")))
}
pub fn from_bytes(bytes: &[u8]) -> Result<Self> {
if bytes.len() as u64 > MAX_SIGNED_AUDIT_EVENT_BYTES {
return Err(Error::Serialization(format!(
"input exceeds maximum size ({} > {})",
bytes.len(),
MAX_SIGNED_AUDIT_EVENT_BYTES
)));
}
use bincode::Options as _;
bincode::DefaultOptions::new()
.with_fixint_encoding()
.allow_trailing_bytes()
.with_limit(MAX_SIGNED_AUDIT_EVENT_BYTES)
.deserialize(bytes)
.map_err(|e| Error::Serialization(format!("signed event deserialize: {e}")))
}
}
pub fn verify_audit_chain(events: &[SignedAuditEvent], verifying_keys: &[Vec<u8>]) -> Result<()> {
if events.len() != verifying_keys.len() {
return Err(Error::AuditError(
"events and verifying_keys slices must have the same length".to_string(),
));
}
let mut prev_hash: Option<String> = None;
for (i, (event, vk_bytes)) in events.iter().zip(verifying_keys.iter()).enumerate() {
let valid = event.verify(vk_bytes)?;
if !valid {
return Err(Error::AuditError(format!(
"signature invalid for event at index {i}"
)));
}
match &prev_hash {
None => {
if !event.event.chain_hash.is_empty() {
return Err(Error::AuditError(
"first event must have empty chain_hash".to_string(),
));
}
}
Some(expected) => {
if &event.event.chain_hash != expected {
return Err(Error::AuditError(format!(
"chain_hash mismatch at index {i}"
)));
}
}
}
prev_hash = Some(event.hash_hex()?);
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::identity::AgentIdentity;
use serde_json::json;
fn with_large_stack<F: FnOnce() + Send + 'static>(f: F) {
std::thread::Builder::new()
.stack_size(32 * 1024 * 1024)
.spawn(f)
.expect("thread spawn failed")
.join()
.expect("thread panicked");
}
#[test]
fn audit_event_new_fields() {
with_large_stack(|| {
let id = SpiffeId::new("example.com", "agent/test").unwrap();
let ev = AuditEvent::new(
id.clone(),
"delegation.issued",
json!({"target": "worker/1"}),
None,
);
assert_eq!(ev.version, AUDIT_EVENT_VERSION);
assert_eq!(ev.action, "delegation.issued");
assert_eq!(ev.chain_hash, "");
assert_eq!(ev.agent_id, id);
});
}
#[test]
fn audit_event_with_chain_hash() {
with_large_stack(|| {
let id = SpiffeId::new("example.com", "agent/test").unwrap();
let ev = AuditEvent::new(id, "key.rotated", json!({}), Some("deadbeef".to_string()));
assert_eq!(ev.chain_hash, "deadbeef");
});
}
#[test]
fn audit_event_sign_and_verify() {
with_large_stack(|| {
let identity = AgentIdentity::new("example.com", "agent/test").unwrap();
let vk_bytes = identity.credential().verifying_key_bytes.clone();
let ev = AuditEvent::new(
identity.spiffe_id().clone(),
"test.action",
json!({"key": "value"}),
None,
);
let signed = ev.sign(&identity).unwrap();
assert!(signed.verify(&vk_bytes).unwrap());
});
}
#[test]
fn audit_event_verify_wrong_key_fails() {
with_large_stack(|| {
let signer = AgentIdentity::new("example.com", "agent/signer").unwrap();
let other = AgentIdentity::new("example.com", "agent/other").unwrap();
let vk_bytes = other.credential().verifying_key_bytes.clone();
let ev = AuditEvent::new(signer.spiffe_id().clone(), "test.action", json!({}), None);
let signed = ev.sign(&signer).unwrap();
assert!(!signed.verify(&vk_bytes).unwrap());
});
}
#[test]
fn audit_event_verify_tampered_signature_fails() {
with_large_stack(|| {
let identity = AgentIdentity::new("example.com", "agent/test").unwrap();
let vk_bytes = identity.credential().verifying_key_bytes.clone();
let ev = AuditEvent::new(identity.spiffe_id().clone(), "test.action", json!({}), None);
let mut signed = ev.sign(&identity).unwrap();
signed.signature[0] ^= 0xFF;
assert!(!signed.verify(&vk_bytes).unwrap());
});
}
#[test]
fn signed_event_serialize_roundtrip() {
with_large_stack(|| {
let identity = AgentIdentity::new("example.com", "agent/test").unwrap();
let vk_bytes = identity.credential().verifying_key_bytes.clone();
let ev = AuditEvent::new(
identity.spiffe_id().clone(),
"test.action",
json!({"round": "trip"}),
None,
);
let signed = ev.sign(&identity).unwrap();
let bytes = signed.to_bytes().unwrap();
let signed2 = SignedAuditEvent::from_bytes(&bytes).unwrap();
assert_eq!(signed.event.action, signed2.event.action);
assert!(signed2.verify(&vk_bytes).unwrap());
});
}
#[test]
fn audit_event_hash_hex_is_64_chars() {
with_large_stack(|| {
let id = SpiffeId::new("example.com", "agent/test").unwrap();
let ev = AuditEvent::new(id, "test.action", json!({}), None);
let h = ev.hash_hex().unwrap();
assert_eq!(h.len(), 64); });
}
#[test]
fn signed_event_hash_hex_is_64_chars() {
with_large_stack(|| {
let identity = AgentIdentity::new("example.com", "agent/test").unwrap();
let ev = AuditEvent::new(identity.spiffe_id().clone(), "test.action", json!({}), None);
let signed = ev.sign(&identity).unwrap();
let h = signed.hash_hex().unwrap();
assert_eq!(h.len(), 64);
});
}
#[test]
fn audit_chain_verify_valid_chain() {
with_large_stack(|| {
let identity = AgentIdentity::new("example.com", "agent/test").unwrap();
let vk_bytes = identity.credential().verifying_key_bytes.clone();
let ev1 = AuditEvent::new(identity.spiffe_id().clone(), "action.one", json!({}), None);
let signed1 = ev1.sign(&identity).unwrap();
let hash1 = signed1.hash_hex().unwrap();
let ev2 = AuditEvent::new(
identity.spiffe_id().clone(),
"action.two",
json!({}),
Some(hash1),
);
let signed2 = ev2.sign(&identity).unwrap();
verify_audit_chain(&[signed1, signed2], &[vk_bytes.clone(), vk_bytes]).unwrap();
});
}
#[test]
fn audit_chain_verify_tampered_chain_hash_rejected() {
with_large_stack(|| {
let identity = AgentIdentity::new("example.com", "agent/test").unwrap();
let vk_bytes = identity.credential().verifying_key_bytes.clone();
let ev1 = AuditEvent::new(identity.spiffe_id().clone(), "action.one", json!({}), None);
let signed1 = ev1.sign(&identity).unwrap();
let ev2 = AuditEvent::new(
identity.spiffe_id().clone(),
"action.two",
json!({}),
Some(
"0000000000000000000000000000000000000000000000000000000000000000".to_string(),
),
);
let signed2 = ev2.sign(&identity).unwrap();
let result = verify_audit_chain(&[signed1, signed2], &[vk_bytes.clone(), vk_bytes]);
assert!(matches!(result, Err(Error::AuditError(_))));
});
}
#[test]
fn audit_chain_first_event_nonempty_hash_rejected() {
with_large_stack(|| {
let identity = AgentIdentity::new("example.com", "agent/test").unwrap();
let vk_bytes = identity.credential().verifying_key_bytes.clone();
let ev = AuditEvent::new(
identity.spiffe_id().clone(),
"action.one",
json!({}),
Some("notempty".to_string()),
);
let signed = ev.sign(&identity).unwrap();
let result = verify_audit_chain(&[signed], &[vk_bytes]);
assert!(matches!(result, Err(Error::AuditError(_))));
});
}
#[test]
fn audit_chain_mismatched_lengths_rejected() {
with_large_stack(|| {
let identity = AgentIdentity::new("example.com", "agent/test").unwrap();
let vk_bytes = identity.credential().verifying_key_bytes.clone();
let ev = AuditEvent::new(identity.spiffe_id().clone(), "action.one", json!({}), None);
let signed = ev.sign(&identity).unwrap();
let result = verify_audit_chain(&[signed], &[vk_bytes.clone(), vk_bytes]);
assert!(matches!(result, Err(Error::AuditError(_))));
});
}
#[test]
fn audit_event_details_arbitrary_json() {
with_large_stack(|| {
let identity = AgentIdentity::new("example.com", "agent/test").unwrap();
let vk_bytes = identity.credential().verifying_key_bytes.clone();
let details = json!({
"nested": {"key": "value"},
"array": [1, 2, 3],
"null_field": null
});
let ev = AuditEvent::new(
identity.spiffe_id().clone(),
"complex.event",
details.clone(),
None,
);
let signed = ev.sign(&identity).unwrap();
assert!(signed.verify(&vk_bytes).unwrap());
assert_eq!(signed.event.details(), details);
});
}
#[test]
fn signed_audit_event_from_bytes_rejects_oversized_length_prefix() {
let mut crafted = vec![0xFFu8; 8];
crafted.extend_from_slice(&[0u8; 16]);
let result = SignedAuditEvent::from_bytes(&crafted);
assert!(
result.is_err(),
"oversized length prefix must be rejected, got Ok"
);
assert!(
matches!(result, Err(Error::Serialization(_))),
"expected Serialization error, got: {:?}",
result
);
}
}