use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::sync::{Mutex, OnceLock};
use rand::{rngs::OsRng, RngCore};
use zeroize::Zeroizing;
use crate::error::Error;
use crate::vault::hardware::{self, DeviceKeystore};
use crate::vault::sealed_blob;
const EVENT_DOMAIN: &[u8] = b"audit_event.v1";
const AUDIT_KEY_FILENAME: &str = "audit.key";
const AUDIT_KEY_MAGIC: [u8; 4] = *b"EAK1";
const AUDIT_KEY_MAX_BYTES: u64 = 4 * 1024;
const SEALED_HEX_MAX_BYTES: usize = 1024 * 1024;
fn cache() -> &'static Mutex<HashMap<PathBuf, Zeroizing<[u8; 32]>>> {
static CACHE: OnceLock<Mutex<HashMap<PathBuf, Zeroizing<[u8; 32]>>>> = OnceLock::new();
CACHE.get_or_init(|| Mutex::new(HashMap::new()))
}
fn generation_lock() -> &'static Mutex<()> {
static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
LOCK.get_or_init(|| Mutex::new(()))
}
fn cache_key(root: &Path) -> PathBuf {
std::fs::canonicalize(root).unwrap_or_else(|_| root.to_path_buf())
}
pub fn encrypt_event(root: &Path, plaintext: &[u8]) -> Result<String, Error> {
let key = load_or_generate_key(root)?;
let sealed = sealed_blob::seal(plaintext, &key, EVENT_DOMAIN)?;
Ok(crate::hex::encode(sealed))
}
pub fn decrypt_event(root: &Path, hex_ct: &str) -> Result<Zeroizing<Vec<u8>>, Error> {
if hex_ct.len() > SEALED_HEX_MAX_BYTES {
return Err(Error::AuditLogFailed(format!(
"audit log: sealed event hex is {} bytes, exceeds {} cap",
hex_ct.len(),
SEALED_HEX_MAX_BYTES
)));
}
let sealed = crate::hex::decode(hex_ct).ok_or_else(|| {
Error::AuditLogFailed("audit log: malformed hex in sealed event".to_string())
})?;
let key = load_or_generate_key(root)?;
sealed_blob::unseal(&sealed, &key, EVENT_DOMAIN)
}
fn load_or_generate_key(root: &Path) -> Result<Zeroizing<[u8; 32]>, Error> {
let key_id = cache_key(root);
if let Some(cached) = {
let map = cache()
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
map.get(&key_id).map(|z| **z)
} {
return Ok(Zeroizing::new(cached));
}
let path = root.join(AUDIT_KEY_FILENAME);
let _gen_guard = generation_lock()
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let key = if path.exists() {
load_key_from_disk(&path)?
} else {
match generate_and_persist_key(&path) {
Ok(k) => k,
Err(_) if path.exists() => {
load_key_from_disk(&path)?
}
Err(e) => return Err(e),
}
};
let mut map = cache()
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
map.insert(key_id, Zeroizing::new(*key));
Ok(key)
}
fn load_key_from_disk(path: &Path) -> Result<Zeroizing<[u8; 32]>, Error> {
use std::io::Read;
let file = crate::file::atomic_open::open_read_no_traverse(path)?;
let len = file
.metadata()
.map_err(|e| Error::AuditLogFailed(format!("stat audit.key: {e}")))?
.len();
if len > AUDIT_KEY_MAX_BYTES {
return Err(Error::AuditLogFailed(format!(
"audit.key is {len} bytes, exceeds {AUDIT_KEY_MAX_BYTES} cap — refusing to load",
)));
}
let mut buf = Vec::with_capacity(usize::try_from(len).unwrap_or(0));
file.take(AUDIT_KEY_MAX_BYTES)
.read_to_end(&mut buf)
.map_err(|e| Error::AuditLogFailed(format!("read audit.key: {e}")))?;
if buf.len() < 4 || buf[..4] != AUDIT_KEY_MAGIC {
return Err(Error::AuditLogFailed(
"audit.key: missing magic — file corrupted or replaced".to_string(),
));
}
let envelope = hardware::parse_v2(&buf[4..])
.map_err(|e| Error::AuditLogFailed(format!("audit.key envelope parse failed: {e}")))?;
let keystore = DeviceKeystore::select();
if envelope.backend != keystore.backend() {
return Err(Error::AuditLogFailed(format!(
"audit.key was sealed by {} but this device offers {} — \
refusing to load (probably copied between machines)",
envelope.backend.name(),
keystore.backend().name()
)));
}
let unwrapped = Zeroizing::new(
keystore
.unseal(envelope.sealed)
.map_err(|e| Error::AuditLogFailed(format!("audit.key unseal failed: {e}")))?,
);
if unwrapped.len() != 32 {
return Err(Error::AuditLogFailed(format!(
"audit.key plaintext is {} bytes, expected 32",
unwrapped.len()
)));
}
let mut key = [0u8; 32];
key.copy_from_slice(&unwrapped);
Ok(Zeroizing::new(key))
}
fn generate_and_persist_key(path: &Path) -> Result<Zeroizing<[u8; 32]>, Error> {
let mut key = [0u8; 32];
OsRng.fill_bytes(&mut key);
let keystore = DeviceKeystore::select();
let sealed = keystore
.seal(&key)
.map_err(|e| Error::AuditLogFailed(format!("audit.key seal failed: {e}")))?;
let envelope = hardware::pack_v2(keystore.backend(), &sealed);
let mut out = Vec::with_capacity(4 + envelope.len());
out.extend_from_slice(&AUDIT_KEY_MAGIC);
out.extend_from_slice(&envelope);
write_atomic_secret(path, &out)?;
Ok(Zeroizing::new(key))
}
#[cfg(unix)]
fn write_atomic_secret(path: &Path, bytes: &[u8]) -> Result<(), Error> {
use std::io::Write;
use std::os::unix::fs::OpenOptionsExt;
let tmp = path.with_extension("key.tmp");
let write_then_rename = || -> Result<(), Error> {
let mut opts = std::fs::OpenOptions::new();
opts.write(true)
.create_new(true)
.mode(0o600)
.custom_flags(libc::O_NOFOLLOW | libc::O_CLOEXEC);
let mut f = opts
.open(&tmp)
.map_err(|e| Error::AuditLogFailed(format!("create audit.key.tmp: {e}")))?;
f.write_all(bytes)
.map_err(|e| Error::AuditLogFailed(format!("write audit.key.tmp: {e}")))?;
f.sync_all().ok();
std::fs::rename(&tmp, path)
.map_err(|e| Error::AuditLogFailed(format!("rename audit.key: {e}")))?;
if let Some(parent) = path.parent() {
if let Ok(d) = std::fs::File::open(parent) {
let _ = d.sync_all();
}
}
Ok(())
};
let result = write_then_rename();
if result.is_err() {
let _ = std::fs::remove_file(&tmp);
}
result
}
#[cfg(windows)]
fn write_atomic_secret(path: &Path, bytes: &[u8]) -> Result<(), Error> {
use std::io::Write;
let tmp = path.with_extension("key.tmp");
{
let mut f = std::fs::OpenOptions::new()
.write(true)
.create_new(true)
.open(&tmp)
.map_err(|e| Error::AuditLogFailed(format!("create audit.key.tmp: {e}")))?;
f.write_all(bytes)
.map_err(|e| Error::AuditLogFailed(format!("write audit.key.tmp: {e}")))?;
f.sync_all().ok();
}
if let Err(e) = crate::policy::windows_acl::set_owner_only_dacl(&tmp) {
let _ = std::fs::remove_file(&tmp);
return Err(Error::AuditLogFailed(format!(
"lock audit.key.tmp DACL: {e}"
)));
}
std::fs::rename(&tmp, path)
.map_err(|e| Error::AuditLogFailed(format!("rename audit.key: {e}")))?;
let _ = crate::policy::windows_acl::set_owner_only_dacl(path);
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn roundtrips_event_payload() {
let dir = tempfile::tempdir().unwrap();
let payload = b"{\"event\":\"x\",\"binary\":\"/usr/bin/cat\"}";
let ct = encrypt_event(dir.path(), payload).unwrap();
let pt = decrypt_event(dir.path(), &ct).unwrap();
assert_eq!(pt.as_slice(), payload);
}
#[test]
fn ciphertext_does_not_contain_plaintext_substrings() {
let dir = tempfile::tempdir().unwrap();
let payload = b"{\"binary\":\"/usr/bin/recognizable-target\"}";
let ct = encrypt_event(dir.path(), payload).unwrap();
assert!(!ct.contains("recognizable-target"));
}
#[test]
fn second_encrypt_uses_persisted_key() {
let dir = tempfile::tempdir().unwrap();
let p1 = b"first";
let _ = encrypt_event(dir.path(), p1).unwrap();
cache()
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.clear();
let ct = encrypt_event(dir.path(), p1).unwrap();
let pt = decrypt_event(dir.path(), &ct).unwrap();
assert_eq!(pt.as_slice(), p1);
}
#[test]
fn malformed_hex_rejected() {
let dir = tempfile::tempdir().unwrap();
let _ = encrypt_event(dir.path(), b"x").unwrap();
assert!(decrypt_event(dir.path(), "not-hex-zz").is_err());
}
}