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 HMAC_HEX_LEN: usize = 64;
pub const PREV_TAIL_COMMENT_PREFIX: &str = "# prev_file_tail=";
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 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, PartialEq, Eq)]
pub struct VerifyReport {
pub total_lines: u64,
pub ok_lines: u64,
pub first_break: Option<VerifyBreak>,
}
#[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,
},
}
pub fn verify_audit_log(path: &Path, key: &AuditHmacKey) -> 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)
}
pub fn verify_audit_bytes(
path: &Path,
bytes: &[u8],
key: &AuditHmacKey,
) -> 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 mut prev_hmac: [u8; 32] = genesis_prev();
let mut have_explicit_prev = false;
let mut total: u64 = 0;
let mut ok: u64 = 0;
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(),
});
}
prev_hmac.copy_from_slice(&bytes);
have_explicit_prev = true;
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(),
}),
});
}
};
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;
have_explicit_prev = true;
} 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(),
}),
});
}
}
let _ = have_explicit_prev; Ok(VerifyReport {
total_lines: total,
ok_lines: ok,
first_break: None,
})
}
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).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).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).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).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).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).unwrap();
assert!(r2.first_break.is_none(), "cross-file chain must verify");
assert_eq!(r2.ok_lines, 1);
assert_eq!(r2.total_lines, 2); }
#[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).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);
}
}