use std::path::Path;
use std::str::FromStr;
use std::sync::Arc;
use hmac::{Hmac, Mac};
use sha2::{Digest, Sha256};
use thiserror::Error;
pub const GENESIS_LABEL: &[u8] = b"S4-AUDIT-V1";
pub const EOF_LABEL: &[u8] = b"S4-AUDIT-EOF-V1";
pub const HMAC_HEX_LEN: usize = 64;
pub const PREV_TAIL_COMMENT_PREFIX: &str = "# prev_file_tail=";
pub const EOF_HMAC_COMMENT_PREFIX: &str = "# eof_hmac=";
type HmacSha256 = Hmac<Sha256>;
#[derive(Clone)]
pub struct AuditHmacKey(Arc<Vec<u8>>);
#[derive(Debug, Error)]
pub enum AuditKeyError {
#[error(
"audit-log HMAC key spec must start with `raw:`, `hex:`, or `base64:` (got: {0:?})"
)]
BadPrefix(String),
#[error("audit-log HMAC key hex must be even-length and all-hex; got {0}")]
BadHex(String),
#[error("audit-log HMAC key base64 decode failed: {0}")]
BadBase64(String),
#[error("audit-log HMAC key must be at least 16 bytes after decode (got {0})")]
TooShort(usize),
}
impl AuditHmacKey {
pub fn as_bytes(&self) -> &[u8] {
&self.0
}
}
impl FromStr for AuditHmacKey {
type Err = AuditKeyError;
fn from_str(spec: &str) -> Result<Self, Self::Err> {
let bytes = if let Some(s) = spec.strip_prefix("raw:") {
s.as_bytes().to_vec()
} else if let Some(s) = spec.strip_prefix("hex:") {
if !s.len().is_multiple_of(2) || !s.chars().all(|c| c.is_ascii_hexdigit()) {
return Err(AuditKeyError::BadHex(s.to_owned()));
}
let mut out = Vec::with_capacity(s.len() / 2);
for i in (0..s.len()).step_by(2) {
out.push(
u8::from_str_radix(&s[i..i + 2], 16)
.map_err(|_| AuditKeyError::BadHex(s.to_owned()))?,
);
}
out
} else if let Some(s) = spec.strip_prefix("base64:") {
base64::Engine::decode(&base64::engine::general_purpose::STANDARD, s.as_bytes())
.map_err(|e| AuditKeyError::BadBase64(e.to_string()))?
} else {
return Err(AuditKeyError::BadPrefix(spec.to_owned()));
};
if bytes.len() < 16 {
return Err(AuditKeyError::TooShort(bytes.len()));
}
Ok(Self(Arc::new(bytes)))
}
}
impl std::fmt::Debug for AuditHmacKey {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("AuditHmacKey")
.field("len", &self.0.len())
.field("key", &"<redacted>")
.finish()
}
}
pub type SharedAuditHmacKey = Arc<AuditHmacKey>;
pub fn genesis_prev() -> [u8; 32] {
let mut h = Sha256::new();
h.update(GENESIS_LABEL);
let out = h.finalize();
let mut buf = [0u8; 32];
buf.copy_from_slice(&out);
buf
}
pub fn chain_step(key: &AuditHmacKey, prev_hmac: &[u8], line_no_hmac: &[u8]) -> [u8; 32] {
let mut mac = HmacSha256::new_from_slice(key.as_bytes())
.expect("HMAC-SHA256 accepts any key length");
mac.update(prev_hmac);
mac.update(line_no_hmac);
let out = mac.finalize().into_bytes();
let mut buf = [0u8; 32];
buf.copy_from_slice(&out);
buf
}
pub fn compute_eof_hmac(key: &AuditHmacKey, prev_chain_state: &[u8; 32]) -> [u8; 32] {
let mut mac = HmacSha256::new_from_slice(key.as_bytes())
.expect("HMAC-SHA256 accepts any key length");
mac.update(EOF_LABEL);
mac.update(prev_chain_state);
let out = mac.finalize().into_bytes();
let mut buf = [0u8; 32];
buf.copy_from_slice(&out);
buf
}
pub fn hex_encode(bytes: &[u8]) -> String {
let mut out = String::with_capacity(bytes.len() * 2);
for b in bytes {
out.push_str(&format!("{b:02x}"));
}
out
}
pub fn hex_decode(s: &str) -> Option<Vec<u8>> {
if !s.len().is_multiple_of(2) {
return None;
}
let mut out = Vec::with_capacity(s.len() / 2);
for i in (0..s.len()).step_by(2) {
out.push(u8::from_str_radix(&s[i..i + 2], 16).ok()?);
}
Some(out)
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct VerifyOptions {
pub expected_prev_tail: Option<[u8; 32]>,
pub require_eof_hmac: bool,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct VerifyReport {
pub total_lines: u64,
pub ok_lines: u64,
pub first_break: Option<VerifyBreak>,
pub unsigned_eof: bool,
pub unsigned_prev_tail: bool,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct VerifyBreak {
pub line_no: u64,
pub expected_hmac: String,
pub actual_hmac: String,
}
#[derive(Debug, Error)]
pub enum VerifyError {
#[error("audit-log file {path:?}: {source}")]
Io {
path: std::path::PathBuf,
source: std::io::Error,
},
#[error("audit-log file {path:?}: prev_file_tail comment had non-hex value: {value:?}")]
BadPrevTail {
path: std::path::PathBuf,
value: String,
},
#[error(
"audit-log file {path:?}: required `# eof_hmac=` marker is absent (truncation suspected — H-2)"
)]
EofHmacMissing { path: std::path::PathBuf },
#[error(
"audit-log file {path:?}: `# eof_hmac=` marker did not authenticate (expected {expected:?}, got {actual:?})"
)]
EofHmacMismatch {
path: std::path::PathBuf,
expected: String,
actual: String,
},
}
pub fn verify_audit_log(
path: &Path,
key: &AuditHmacKey,
options: VerifyOptions,
) -> Result<VerifyReport, VerifyError> {
let raw = std::fs::read(path).map_err(|source| VerifyError::Io {
path: path.to_path_buf(),
source,
})?;
verify_audit_bytes(path, &raw, key, options)
}
pub fn verify_audit_bytes(
path: &Path,
bytes: &[u8],
key: &AuditHmacKey,
options: VerifyOptions,
) -> Result<VerifyReport, VerifyError> {
let text = std::str::from_utf8(bytes).map_err(|e| VerifyError::Io {
path: path.to_path_buf(),
source: std::io::Error::new(std::io::ErrorKind::InvalidData, e),
})?;
let operator_seed = options.expected_prev_tail;
let mut prev_hmac: [u8; 32] = operator_seed.unwrap_or_else(genesis_prev);
let mut prev_tail_came_from_file = false;
let mut total: u64 = 0;
let mut ok: u64 = 0;
let mut eof_marker: Option<[u8; 32]> = None;
let mut state_at_eof: [u8; 32] = prev_hmac;
let mut saw_eof_marker_line = false;
for (idx, raw_line) in text.split_inclusive('\n').enumerate() {
total += 1;
let line_no = (idx + 1) as u64;
let line = raw_line.trim_end_matches('\n').trim_end_matches('\r');
if line.trim().is_empty() {
continue;
}
if let Some(rest) = line.strip_prefix(PREV_TAIL_COMMENT_PREFIX) {
let hex = rest.trim();
let bytes = hex_decode(hex).ok_or_else(|| VerifyError::BadPrevTail {
path: path.to_path_buf(),
value: hex.to_owned(),
})?;
if bytes.len() != 32 {
return Err(VerifyError::BadPrevTail {
path: path.to_path_buf(),
value: hex.to_owned(),
});
}
if operator_seed.is_none() {
prev_hmac.copy_from_slice(&bytes);
state_at_eof = prev_hmac;
prev_tail_came_from_file = true;
}
continue;
}
if let Some(rest) = line.strip_prefix(EOF_HMAC_COMMENT_PREFIX) {
let hex = rest.trim();
match hex_decode(hex) {
Some(b) if b.len() == 32 => {
let mut buf = [0u8; 32];
buf.copy_from_slice(&b);
eof_marker = Some(buf);
state_at_eof = prev_hmac;
saw_eof_marker_line = true;
}
_ => {
return Err(VerifyError::EofHmacMismatch {
path: path.to_path_buf(),
expected: "<computed at end-of-file>".to_owned(),
actual: hex.to_owned(),
});
}
}
continue;
}
if line.starts_with('#') {
continue;
}
let (line_no_hmac, actual_hex) = match split_hmac_suffix(line) {
Some((body, hmac_hex)) => (body, hmac_hex),
None => {
return Ok(VerifyReport {
total_lines: total,
ok_lines: ok,
first_break: Some(VerifyBreak {
line_no,
expected_hmac: hex_encode(&chain_step(key, &prev_hmac, line.as_bytes())),
actual_hmac: "<missing>".to_owned(),
}),
unsigned_eof: !saw_eof_marker_line,
unsigned_prev_tail: prev_tail_came_from_file,
});
}
};
let expected = chain_step(key, &prev_hmac, line_no_hmac.as_bytes());
let expected_hex = hex_encode(&expected);
if expected_hex == actual_hex {
ok += 1;
prev_hmac = expected;
} else {
return Ok(VerifyReport {
total_lines: total,
ok_lines: ok,
first_break: Some(VerifyBreak {
line_no,
expected_hmac: expected_hex,
actual_hmac: actual_hex.to_owned(),
}),
unsigned_eof: !saw_eof_marker_line,
unsigned_prev_tail: prev_tail_came_from_file,
});
}
}
if let Some(marker) = eof_marker {
let expected = compute_eof_hmac(key, &state_at_eof);
if expected != marker {
return Err(VerifyError::EofHmacMismatch {
path: path.to_path_buf(),
expected: hex_encode(&expected),
actual: hex_encode(&marker),
});
}
} else if options.require_eof_hmac {
return Err(VerifyError::EofHmacMissing {
path: path.to_path_buf(),
});
}
Ok(VerifyReport {
total_lines: total,
ok_lines: ok,
first_break: None,
unsigned_eof: !saw_eof_marker_line,
unsigned_prev_tail: prev_tail_came_from_file,
})
}
fn split_hmac_suffix(line: &str) -> Option<(&str, &str)> {
if line.len() <= HMAC_HEX_LEN + 1 {
return None;
}
let cut = line.len() - HMAC_HEX_LEN;
let body = &line[..cut];
let hmac = &line[cut..];
if !body.ends_with(' ') {
return None;
}
if hmac.len() != HMAC_HEX_LEN || !hmac.chars().all(|c| c.is_ascii_hexdigit()) {
return None;
}
Some((&body[..body.len() - 1], hmac))
}
#[cfg(test)]
mod tests {
use super::*;
fn key() -> AuditHmacKey {
AuditHmacKey::from_str("raw:0123456789abcdef0123456789abcdef").unwrap()
}
#[test]
fn genesis_is_sha256_of_label() {
let g = genesis_prev();
let mut h = Sha256::new();
h.update(b"S4-AUDIT-V1");
let want = h.finalize();
assert_eq!(&g[..], &want[..]);
}
#[test]
fn key_parsing_accepts_three_prefixes() {
let r = AuditHmacKey::from_str("raw:0123456789abcdef0123456789abcdef").unwrap();
assert_eq!(r.as_bytes().len(), 32);
let h = AuditHmacKey::from_str(
"hex:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
)
.unwrap();
assert_eq!(h.as_bytes().len(), 32);
let b = AuditHmacKey::from_str("base64:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=")
.unwrap();
assert_eq!(b.as_bytes(), &[0u8; 32]);
}
#[test]
fn key_parsing_rejects_short_keys() {
let err = AuditHmacKey::from_str("raw:short").unwrap_err();
assert!(matches!(err, AuditKeyError::TooShort(5)));
}
#[test]
fn key_parsing_rejects_bad_prefix() {
let err = AuditHmacKey::from_str("plain:key").unwrap_err();
assert!(matches!(err, AuditKeyError::BadPrefix(_)));
}
#[test]
fn happy_path_chain_verifies() {
let key = key();
let lines = ["line one alpha", "line two beta", "line three gamma"];
let mut buf = String::new();
let mut prev = genesis_prev();
for ln in &lines {
let mac = chain_step(&key, &prev, ln.as_bytes());
buf.push_str(ln);
buf.push(' ');
buf.push_str(&hex_encode(&mac));
buf.push('\n');
prev = mac;
}
let report = verify_audit_bytes(
std::path::Path::new("<mem>"),
buf.as_bytes(),
&key,
VerifyOptions::default(),
)
.unwrap();
assert_eq!(report.total_lines, 3);
assert_eq!(report.ok_lines, 3);
assert!(report.first_break.is_none());
}
#[test]
fn tamper_one_byte_in_middle_breaks_at_that_line() {
let key = key();
let lines = ["line A", "line B middle", "line C tail"];
let mut buf = String::new();
let mut prev = genesis_prev();
for ln in &lines {
let mac = chain_step(&key, &prev, ln.as_bytes());
buf.push_str(ln);
buf.push(' ');
buf.push_str(&hex_encode(&mac));
buf.push('\n');
prev = mac;
}
let bad = buf.replace("middle", "MIDDLE");
let report = verify_audit_bytes(
std::path::Path::new("<mem>"),
bad.as_bytes(),
&key,
VerifyOptions::default(),
)
.unwrap();
assert!(report.first_break.is_some(), "expected a break");
let br = report.first_break.unwrap();
assert_eq!(br.line_no, 2, "break should be on line 2");
assert_eq!(report.ok_lines, 1, "line 1 OK before the break");
}
#[test]
fn tamper_hmac_field_breaks_at_that_line() {
let key = key();
let line = "lonely line";
let mac = chain_step(&key, &genesis_prev(), line.as_bytes());
let s = format!("{} {}\n", line, hex_encode(&mac));
let last = s.len() - 2;
let c = s.as_bytes()[last];
let new_c = if c == b'0' { '1' } else { '0' };
let mut bad = String::with_capacity(s.len());
bad.push_str(&s[..last]);
bad.push(new_c);
bad.push_str(&s[last + 1..]);
let report = verify_audit_bytes(
std::path::Path::new("<mem>"),
bad.as_bytes(),
&key,
VerifyOptions::default(),
)
.unwrap();
let br = report.first_break.expect("expected break");
assert_eq!(br.line_no, 1);
let _ = c;
}
#[test]
fn missing_hmac_column_reports_break_with_missing_marker() {
let key = key();
let s = "no hmac at all\n";
let report = verify_audit_bytes(
std::path::Path::new("<mem>"),
s.as_bytes(),
&key,
VerifyOptions::default(),
)
.unwrap();
let br = report.first_break.expect("expected break");
assert_eq!(br.actual_hmac, "<missing>");
}
#[test]
fn cross_file_chain_via_prev_tail_comment() {
let key = key();
let line1 = "first file lone line";
let mac1 = chain_step(&key, &genesis_prev(), line1.as_bytes());
let f1 = format!("{} {}\n", line1, hex_encode(&mac1));
let r1 = verify_audit_bytes(
std::path::Path::new("<f1>"),
f1.as_bytes(),
&key,
VerifyOptions::default(),
)
.unwrap();
assert!(r1.first_break.is_none());
let line2 = "second file lone line";
let mac2 = chain_step(&key, &mac1, line2.as_bytes());
let f2 = format!(
"# prev_file_tail={}\n{} {}\n",
hex_encode(&mac1),
line2,
hex_encode(&mac2)
);
let r2 = verify_audit_bytes(
std::path::Path::new("<f2>"),
f2.as_bytes(),
&key,
VerifyOptions::default(),
)
.unwrap();
assert!(r2.first_break.is_none(), "cross-file chain must verify");
assert_eq!(r2.ok_lines, 1);
assert_eq!(r2.total_lines, 2); assert!(r2.unsigned_prev_tail);
}
#[test]
fn cross_file_chain_with_wrong_prev_tail_breaks() {
let key = key();
let line2 = "second file lone line";
let wrong_prev = [0u8; 32];
let actual_mac = chain_step(&key, &genesis_prev(), line2.as_bytes());
let f2 = format!(
"# prev_file_tail={}\n{} {}\n",
hex_encode(&wrong_prev),
line2,
hex_encode(&actual_mac)
);
let r = verify_audit_bytes(
std::path::Path::new("<f2>"),
f2.as_bytes(),
&key,
VerifyOptions::default(),
)
.unwrap();
assert!(r.first_break.is_some());
}
#[test]
fn split_hmac_suffix_basic() {
let hmac64 = "a".repeat(64);
let s = format!("foo bar baz {hmac64}");
let (body, hmac) = split_hmac_suffix(&s).unwrap();
assert_eq!(body, "foo bar baz");
assert_eq!(hmac.len(), 64);
assert_eq!(hmac, hmac64.as_str());
}
#[test]
fn split_hmac_suffix_rejects_short_or_nonhex() {
assert!(split_hmac_suffix("short").is_none());
let bad_hmac = "g".repeat(64);
let bad = format!("x {bad_hmac}");
assert!(split_hmac_suffix(&bad).is_none());
}
#[test]
fn hex_roundtrip() {
let raw = [0u8, 1, 2, 0xff, 0x10, 0xab];
let s = hex_encode(&raw);
assert_eq!(s, "000102ff10ab");
let dec = hex_decode(&s).unwrap();
assert_eq!(dec, raw);
}
fn render_chained_file(
key: &AuditHmacKey,
prev_file_tail: Option<[u8; 32]>,
lines: &[&str],
with_eof_marker: bool,
) -> (String, [u8; 32]) {
let mut out = String::new();
let seed = if let Some(t) = prev_file_tail {
out.push_str(&format!(
"{}{}\n",
PREV_TAIL_COMMENT_PREFIX,
hex_encode(&t)
));
t
} else {
genesis_prev()
};
let mut prev = seed;
for ln in lines {
let mac = chain_step(key, &prev, ln.as_bytes());
out.push_str(ln);
out.push(' ');
out.push_str(&hex_encode(&mac));
out.push('\n');
prev = mac;
}
if with_eof_marker {
let eof = compute_eof_hmac(key, &prev);
out.push_str(EOF_HMAC_COMMENT_PREFIX);
out.push_str(&hex_encode(&eof));
out.push('\n');
}
(out, prev)
}
#[test]
fn verify_with_expected_prev_tail_overrides_in_file_hint() {
let key = key();
let real_prev_tail = [0x42u8; 32];
let (honest, _) = render_chained_file(
&key,
Some(real_prev_tail),
&["line one", "line two"],
false,
);
let attacker_seed = [0u8; 32];
let spliced = honest.replacen(
&hex_encode(&real_prev_tail),
&hex_encode(&attacker_seed),
1,
);
assert_ne!(honest, spliced);
let report = verify_audit_bytes(
std::path::Path::new("<spliced>"),
spliced.as_bytes(),
&key,
VerifyOptions {
expected_prev_tail: Some(real_prev_tail),
require_eof_hmac: false,
},
)
.unwrap();
assert!(
report.first_break.is_none(),
"operator-supplied tail must let the chain verify even when the in-file comment is a forged splice: {report:?}"
);
assert!(!report.unsigned_prev_tail);
let no_override = verify_audit_bytes(
std::path::Path::new("<spliced>"),
spliced.as_bytes(),
&key,
VerifyOptions::default(),
)
.unwrap();
assert!(
no_override.first_break.is_some(),
"without operator override the spliced comment seeds wrong, breaking the chain"
);
assert!(no_override.unsigned_prev_tail);
}
#[test]
fn verify_without_eof_hmac_when_required_fails() {
let key = key();
let (body, _) = render_chained_file(&key, None, &["a", "b", "c"], false);
let err = verify_audit_bytes(
std::path::Path::new("<no-eof>"),
body.as_bytes(),
&key,
VerifyOptions {
expected_prev_tail: None,
require_eof_hmac: true,
},
)
.unwrap_err();
assert!(matches!(err, VerifyError::EofHmacMissing { .. }));
}
#[test]
fn verify_without_eof_hmac_when_optional_succeeds_with_unsigned_eof_flag() {
let key = key();
let (body, _) = render_chained_file(&key, None, &["a", "b", "c"], false);
let report = verify_audit_bytes(
std::path::Path::new("<no-eof-relaxed>"),
body.as_bytes(),
&key,
VerifyOptions::default(),
)
.unwrap();
assert!(report.first_break.is_none());
assert!(report.unsigned_eof, "relaxed mode flags missing EOF marker");
assert_eq!(report.ok_lines, 3);
}
#[test]
fn eof_hmac_marker_round_trip() {
let key = key();
let (body, _) = render_chained_file(
&key,
None,
&["entry one", "entry two", "entry three"],
true,
);
let r1 = verify_audit_bytes(
std::path::Path::new("<eof-rt>"),
body.as_bytes(),
&key,
VerifyOptions::default(),
)
.unwrap();
assert!(r1.first_break.is_none());
assert!(!r1.unsigned_eof);
assert_eq!(r1.ok_lines, 3);
let r2 = verify_audit_bytes(
std::path::Path::new("<eof-rt>"),
body.as_bytes(),
&key,
VerifyOptions {
expected_prev_tail: None,
require_eof_hmac: true,
},
)
.unwrap();
assert!(r2.first_break.is_none());
assert!(!r2.unsigned_eof);
}
#[test]
fn truncated_log_without_expected_eof_silently_passes() {
let key = key();
let (full, _) = render_chained_file(
&key,
None,
&["alpha", "beta", "gamma", "delta"],
true,
);
let cut_at = full.find("gamma").expect("gamma in body");
let truncated = &full[..cut_at];
assert!(truncated.ends_with('\n'));
let report = verify_audit_bytes(
std::path::Path::new("<truncated>"),
truncated.as_bytes(),
&key,
VerifyOptions::default(),
)
.unwrap();
assert!(
report.first_break.is_none(),
"H-2 baseline: a valid prefix verifies clean without `require_eof_hmac` — \
this is the attack window the marker closes"
);
assert!(report.unsigned_eof);
assert_eq!(report.ok_lines, 2);
}
#[test]
fn truncated_log_with_require_eof_fails() {
let key = key();
let (full, _) = render_chained_file(
&key,
None,
&["alpha", "beta", "gamma", "delta"],
true,
);
let cut_at = full.find("gamma").expect("gamma in body");
let truncated = &full[..cut_at];
let err = verify_audit_bytes(
std::path::Path::new("<truncated-strict>"),
truncated.as_bytes(),
&key,
VerifyOptions {
expected_prev_tail: None,
require_eof_hmac: true,
},
)
.unwrap_err();
assert!(
matches!(err, VerifyError::EofHmacMissing { .. }),
"strict mode rejects truncated logs (H-2 mitigated): got {err:?}"
);
}
}