use std::io::Write;
use std::path::Path;
use serde::{Deserialize, Serialize};
use crate::error::{Error, Result};
use crate::pki::{self, SigAlgKind};
use crate::utils;
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
pub struct AuditRecord {
pub ts: String,
pub host: String,
pub user: String,
pub op: String,
#[serde(default)]
pub words: Vec<String>,
#[serde(default)]
pub files: Vec<String>,
pub exit: i32,
pub duration_ms: u64,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
pub struct SignatureRecord {
pub ts: String,
pub records: usize,
pub signer: String,
pub sig: String,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
#[serde(tag = "type", rename_all = "lowercase")]
pub enum AuditLine {
Record(AuditRecord),
Signature(SignatureRecord),
}
pub fn append_line(path: &Path, line: &AuditLine) -> Result<()> {
let mut f = std::fs::OpenOptions::new()
.create(true)
.append(true)
.open(path)?;
let mut json = serde_json::to_string(line).map_err(Error::json)?;
json.push('\n');
f.write_all(json.as_bytes())?;
f.sync_data()?;
Ok(())
}
pub fn record_invocation(
log: &Path,
op: &str,
words: &[String],
files: &[String],
exit: i32,
duration_ms: u64,
signer_pem: Option<&str>,
) -> Result<()> {
let rec = AuditRecord {
ts: utils::rfc3339(None),
host: whoami::fallible::hostname().unwrap_or_else(|_| "unknown".into()),
user: whoami::fallible::username().unwrap_or_else(|_| "unknown".into()),
op: op.to_string(),
words: words.to_vec(),
files: files.to_vec(),
exit,
duration_ms,
};
let line = AuditLine::Record(rec);
let exact = serde_json::to_string(&line).map_err(Error::json)?;
append_line(log, &line)?;
if let Some(pem) = signer_pem {
let sig = sign_session(&[exact], pem)?;
append_line(log, &AuditLine::Signature(sig))?;
}
Ok(())
}
fn sign_session(lines: &[String], priv_pem: &str) -> Result<SignatureRecord> {
let mut rng = botan::RandomNumberGenerator::new_system().map_err(Error::botan)?;
let msg = lines.join("\n");
let sig = pki::sign(SigAlgKind::Ed25519, priv_pem, msg.as_bytes(), &mut rng)?;
let fp = signer_fingerprint(priv_pem)?;
Ok(SignatureRecord {
ts: utils::rfc3339(None),
records: lines.len(),
signer: format!("ed25519:{fp}"),
sig: hex::encode(sig),
})
}
fn signer_fingerprint(priv_pem: &str) -> Result<String> {
let botan_priv = botan::Privkey::load_pem(priv_pem).map_err(Error::botan)?;
let pub_pem = botan_priv
.pubkey()
.and_then(|p| p.pem_encode())
.map_err(Error::botan)?;
let fp = crate::capability::KeyFp::from_pem(&pub_pem)?;
Ok(fp.to_hex())
}
#[derive(Debug, Default, PartialEq)]
pub struct VerifyOutcome {
pub verified: usize,
pub unsigned: usize,
pub failures: Vec<String>,
}
impl VerifyOutcome {
pub fn ok(&self) -> bool {
self.failures.is_empty() && self.unsigned == 0
}
}
pub fn verify_log(log: &Path, trust_root_pem: &str) -> Result<VerifyOutcome> {
let text = std::fs::read_to_string(log)?;
let expected_fp = crate::capability::KeyFp::from_pem(trust_root_pem)?.to_hex();
let mut outcome = VerifyOutcome::default();
let mut batch: Vec<String> = Vec::new();
for (lineno, line) in text.lines().enumerate() {
if line.trim().is_empty() {
continue;
}
let parsed: AuditLine = serde_json::from_str(line)
.map_err(|e| Error::Json(format!("audit log line {}: {e}", lineno + 1)))?;
match parsed {
AuditLine::Record(_) => batch.push(line.to_string()),
AuditLine::Signature(sig) => {
if !sig.signer.ends_with(&expected_fp) {
outcome.failures.push(format!(
"line {}: signer {} is not the trust root",
lineno + 1,
sig.signer
));
}
if sig.records != batch.len() {
outcome.failures.push(format!(
"line {}: signature covers {} records but batch has {} — records tampered",
lineno + 1,
sig.records,
batch.len()
));
}
let msg = batch.join("\n");
let sig_bytes = hex::decode(&sig.sig).map_err(Error::from)?;
let ok = pki::verify(
SigAlgKind::Ed25519,
trust_root_pem,
msg.as_bytes(),
&sig_bytes,
)?;
if !ok {
outcome.failures.push(format!(
"line {}: signature does not verify — content tampered",
lineno + 1
));
}
if ok && sig.records == batch.len() && sig.signer.ends_with(&expected_fp) {
outcome.verified += batch.len();
}
batch.clear();
}
}
}
outcome.unsigned = batch.len();
Ok(outcome)
}
#[derive(Debug, Default, Clone)]
pub struct AuditQuery {
pub op: Option<String>,
pub user: Option<String>,
pub word: Option<String>,
pub since: Option<String>,
pub until: Option<String>,
}
fn normalize_bound(s: &str, end_of_day: bool) -> String {
if s.len() == 10 && s.as_bytes()[4] == b'-' {
if end_of_day {
format!("{s}T23:59:59.999999999Z")
} else {
format!("{s}T00:00:00.000000000Z")
}
} else {
s.to_string()
}
}
pub fn query(log: &Path, q: &AuditQuery) -> Result<Vec<String>> {
let text = std::fs::read_to_string(log)?;
let since = q.since.as_deref().map(|s| normalize_bound(s, false));
let until = q.until.as_deref().map(|s| normalize_bound(s, true));
let mut out = Vec::new();
for (lineno, line) in text.lines().enumerate() {
if line.trim().is_empty() {
continue;
}
let parsed: AuditLine = serde_json::from_str(line)
.map_err(|e| Error::Json(format!("audit log line {}: {e}", lineno + 1)))?;
let AuditLine::Record(r) = parsed else {
continue;
};
if let Some(op) = &q.op
&& &r.op != op
{
continue;
}
if let Some(user) = &q.user
&& &r.user != user
{
continue;
}
if let Some(word) = &q.word
&& !r.words.iter().any(|w| w == word)
{
continue;
}
if let Some(since) = &since
&& r.ts.as_str() < since.as_str()
{
continue;
}
if let Some(until) = &until
&& r.ts.as_str() > until.as_str()
{
continue;
}
out.push(line.to_string());
}
Ok(out)
}
#[cfg(test)]
mod tests {
use super::*;
fn rec(op: &str, user: &str, ts: &str, words: &[&str]) -> AuditLine {
AuditLine::Record(AuditRecord {
ts: ts.into(),
host: "h".into(),
user: user.into(),
op: op.into(),
words: words.iter().map(|s| s.to_string()).collect(),
files: vec!["f.ept".into()],
exit: 0,
duration_ms: 1,
})
}
#[test]
fn record_round_trips_with_type_tag() {
let line = rec("decrypt", "ci", "2026-08-17T00:00:00.000000000Z", &["W"]);
let json = serde_json::to_string(&line).unwrap();
assert!(json.starts_with(r#"{"type":"record""#), "{json}");
let back: AuditLine = serde_json::from_str(&json).unwrap();
assert_eq!(back, line);
}
#[test]
fn append_is_exclusive_and_appends() {
let dir = tempfile::tempdir().unwrap();
let log = dir.path().join("a.jsonl");
append_line(
&log,
&rec("encrypt", "u", "2026-08-17T00:00:00.000000000Z", &[]),
)
.unwrap();
append_line(
&log,
&rec("decrypt", "u", "2026-08-17T00:00:01.000000000Z", &[]),
)
.unwrap();
let text = std::fs::read_to_string(&log).unwrap();
assert_eq!(text.lines().count(), 2);
}
#[test]
fn concurrent_appends_do_not_interleave() {
let dir = tempfile::tempdir().unwrap();
let log = dir.path().join("c.jsonl");
std::fs::write(&log, b"").unwrap();
let n = 8;
let per = 50;
std::thread::scope(|s| {
for t in 0..n {
let log = &log;
s.spawn(move || {
for i in 0..per {
append_line(
log,
&rec(
"encrypt",
&format!("t{t}"),
&format!("2026-08-17T00:00:{i:02}.000000000Z"),
&[],
),
)
.unwrap();
}
});
}
});
let text = std::fs::read_to_string(&log).unwrap();
let lines: Vec<&str> = text.lines().collect();
assert_eq!(lines.len(), n * per, "no lines lost or merged");
assert!(lines.iter().all(|l| l.starts_with(r#"{"type":"record""#)));
}
#[test]
fn signed_session_verifies_and_tampering_is_detected() {
let mut rng = botan::RandomNumberGenerator::new_system().unwrap();
let (priv_pem, pub_pem) = pki::keygen(SigAlgKind::Ed25519, &mut rng).unwrap();
let dir = tempfile::tempdir().unwrap();
let log = dir.path().join("s.jsonl");
record_invocation(
&log,
"decrypt",
&["W".into()],
&["f.ept".into()],
0,
5,
Some(&priv_pem),
)
.unwrap();
record_invocation(&log, "encrypt", &[], &["g.ept".into()], 0, 7, None).unwrap();
let out = verify_log(&log, &pub_pem).unwrap();
assert_eq!(out.verified, 1);
assert_eq!(out.unsigned, 1);
assert!(out.failures.is_empty());
let text = std::fs::read_to_string(&log).unwrap();
let tampered = text.replace("decrypt", "DECRYPTED");
std::fs::write(&log, tampered).unwrap();
let out = verify_log(&log, &pub_pem).unwrap();
assert!(!out.failures.is_empty(), "{out:?}");
}
#[test]
fn query_filters() {
let dir = tempfile::tempdir().unwrap();
let log = dir.path().join("q.jsonl");
append_line(
&log,
&rec("decrypt", "alice", "2026-08-01T10:00:00.000000000Z", &["A"]),
)
.unwrap();
append_line(
&log,
&rec("encrypt", "bob", "2026-08-02T10:00:00.000000000Z", &["B"]),
)
.unwrap();
append_line(
&log,
&rec("decrypt", "bob", "2026-08-03T10:00:00.000000000Z", &["A"]),
)
.unwrap();
let q = |q: AuditQuery| query(&log, &q).unwrap();
assert_eq!(
q(AuditQuery {
op: Some("decrypt".into()),
..Default::default()
})
.len(),
2
);
assert_eq!(
q(AuditQuery {
user: Some("bob".into()),
..Default::default()
})
.len(),
2
);
assert_eq!(
q(AuditQuery {
word: Some("A".into()),
..Default::default()
})
.len(),
2
);
assert_eq!(
q(AuditQuery {
since: Some("2026-08-02".into()),
..Default::default()
})
.len(),
2
);
assert_eq!(
q(AuditQuery {
since: Some("2026-08-02".into()),
until: Some("2026-08-02".into()),
..Default::default()
})
.len(),
1
);
let mut rng = botan::RandomNumberGenerator::new_system().unwrap();
let (priv_pem, _) = pki::keygen(SigAlgKind::Ed25519, &mut rng).unwrap();
record_invocation(&log, "store", &[], &[], 0, 1, Some(&priv_pem)).unwrap();
assert_eq!(q(AuditQuery::default()).len(), 4);
}
#[test]
fn bound_normalization() {
assert_eq!(
normalize_bound("2026-08-01", false),
"2026-08-01T00:00:00.000000000Z"
);
assert_eq!(
normalize_bound("2026-08-01", true),
"2026-08-01T23:59:59.999999999Z"
);
assert_eq!(
normalize_bound("2026-08-01T12:00:00.000000000Z", true),
"2026-08-01T12:00:00.000000000Z"
);
}
}