s4_server/audit_log.rs
1//! Tamper-evident audit-log HMAC chain (v0.5 #31).
2//!
3//! Extends the v0.4 #20 S3-style access log emitter with a
4//! hash-linked HMAC-SHA256 column appended to every line. Each line's
5//! HMAC is computed over the previous line's HMAC bytes concatenated
6//! with the current line's text (excluding the HMAC field itself):
7//!
8//! ```text
9//! hmac_n = HMAC-SHA256(key, hmac_{n-1} || line_n_without_hmac)
10//! ```
11//!
12//! The genesis HMAC seed is `SHA256("S4-AUDIT-V1")` — a fixed,
13//! publicly-known constant that anchors the chain at a deterministic
14//! starting point so verifiers don't need to trust the producer about
15//! "where the chain started".
16//!
17//! ## File rotation
18//!
19//! When the access-log flusher rolls over to a new file (hourly +
20//! batch-counter), the new file starts with a comment line:
21//!
22//! ```text
23//! # prev_file_tail=<hex-encoded last_hmac of the previous file>
24//! ```
25//!
26//! The first real entry in the new file uses that tail as its
27//! `prev_hmac`, so the chain extends across rotations. A verifier can
28//! optionally walk multiple files in chronological order to confirm
29//! the cross-file linkage.
30//!
31//! ## Wire format per entry
32//!
33//! ```text
34//! <existing S3-style access-log line> <hex hmac (64 chars)>\n
35//! ```
36//!
37//! A single trailing space then 64 lowercase hex chars. Existing
38//! parsers that split on whitespace see one extra column.
39//!
40//! ## Key loader
41//!
42//! `AuditHmacKey::from_str("raw:32-byte-string")`,
43//! `"hex:0123...64-char"`, or `"base64:..."` — same shape as
44//! `SseKey::from_str` (see `sse.rs`). For very small ops setups, the
45//! `raw:` prefix lets you stash the key directly in a CLI flag /
46//! systemd unit env var; production should prefer `hex:` or `base64:`
47//! delivered out-of-band.
48//!
49//! ## Verifier CLI
50//!
51//! `s4 verify-audit-log <FILE> --hmac-key <SPEC>` walks the file,
52//! recomputes each line's expected HMAC, and reports the first chain
53//! break (if any). Returns `VerifyReport { total_lines, ok_lines,
54//! first_break }`. Comment lines (`# prev_file_tail=...`) are honoured
55//! as the genesis-prev for the first real entry.
56//!
57//! ## Limitations (deliberate, v0.5 scope)
58//!
59//! - Single key, no key rotation — a follow-up issue tracks a key-id
60//! field per line.
61//! - In-memory chain state only — if the process restarts mid-hour,
62//! the new flusher loads no state and writes a fresh genesis line at
63//! the top of the next batch file. Verifier handles this by treating
64//! missing `# prev_file_tail=` as "this batch is its own chain".
65//! - Verifier only walks one file at a time; cross-file walk is the
66//! operator's responsibility (sort by name, feed one-by-one).
67
68use std::path::Path;
69use std::str::FromStr;
70use std::sync::Arc;
71
72use hmac::{Hmac, Mac};
73use sha2::{Digest, Sha256};
74use thiserror::Error;
75
76/// The fixed genesis seed: `SHA256("S4-AUDIT-V1")`. Computed once at
77/// startup; we keep it as a function (not a const) because Sha256 is
78/// not const-fn yet.
79pub const GENESIS_LABEL: &[u8] = b"S4-AUDIT-V1";
80
81/// v0.8.2 #63: domain-separation label for the EOF HMAC marker. The
82/// EOF marker is a separate HMAC over `EOF_LABEL || prev_chain_state`
83/// so it cannot collide with any chain entry (whose input is
84/// `prev_hmac || line_bytes`).
85pub const EOF_LABEL: &[u8] = b"S4-AUDIT-EOF-V1";
86
87/// Hex-encoded HMAC field length in characters (SHA-256 → 32 bytes →
88/// 64 hex chars).
89pub const HMAC_HEX_LEN: usize = 64;
90
91/// Comment prefix used to carry the previous file's last HMAC across a
92/// rotation boundary.
93pub const PREV_TAIL_COMMENT_PREFIX: &str = "# prev_file_tail=";
94
95/// v0.8.2 #63: comment prefix carrying the EOF HMAC marker. Written as
96/// the **last** line of every rotated / closed audit-log file so a
97/// verifier with `require_eof_hmac = true` can detect tail truncation
98/// (H-2). Computed via [`compute_eof_hmac`].
99pub const EOF_HMAC_COMMENT_PREFIX: &str = "# eof_hmac=";
100
101type HmacSha256 = Hmac<Sha256>;
102
103/// Fixed-length HMAC-SHA256 key. Held inside an `Arc` for cheap
104/// sharing across the access-log flusher and any verifier callers.
105#[derive(Clone)]
106pub struct AuditHmacKey(Arc<Vec<u8>>);
107
108#[derive(Debug, Error)]
109pub enum AuditKeyError {
110 #[error("audit-log HMAC key spec must start with `raw:`, `hex:`, or `base64:` (got: {0:?})")]
111 BadPrefix(String),
112 #[error("audit-log HMAC key hex must be even-length and all-hex; got {0}")]
113 BadHex(String),
114 #[error("audit-log HMAC key base64 decode failed: {0}")]
115 BadBase64(String),
116 #[error("audit-log HMAC key must be at least 16 bytes after decode (got {0})")]
117 TooShort(usize),
118}
119
120impl AuditHmacKey {
121 /// Parse a key from a CLI-style spec. Three forms:
122 ///
123 /// - `raw:<utf8 bytes>` — the bytes after the prefix are the key
124 /// verbatim. Useful for tests and small ops; production should
125 /// prefer `hex:` or `base64:`.
126 /// - `hex:<hex chars>` — even-length, all-hex.
127 /// - `base64:<base64 chars>` — standard base64, padding optional.
128 ///
129 /// Minimum decoded length: 16 bytes (128 bits). HMAC-SHA256 itself
130 /// permits any key length, but anything <16 bytes is operator
131 /// error rather than a sound choice.
132 pub fn as_bytes(&self) -> &[u8] {
133 &self.0
134 }
135}
136
137impl FromStr for AuditHmacKey {
138 type Err = AuditKeyError;
139
140 fn from_str(spec: &str) -> Result<Self, Self::Err> {
141 let bytes = if let Some(s) = spec.strip_prefix("raw:") {
142 s.as_bytes().to_vec()
143 } else if let Some(s) = spec.strip_prefix("hex:") {
144 if !s.len().is_multiple_of(2) || !s.chars().all(|c| c.is_ascii_hexdigit()) {
145 return Err(AuditKeyError::BadHex(s.to_owned()));
146 }
147 let mut out = Vec::with_capacity(s.len() / 2);
148 for i in (0..s.len()).step_by(2) {
149 out.push(
150 u8::from_str_radix(&s[i..i + 2], 16)
151 .map_err(|_| AuditKeyError::BadHex(s.to_owned()))?,
152 );
153 }
154 out
155 } else if let Some(s) = spec.strip_prefix("base64:") {
156 base64::Engine::decode(&base64::engine::general_purpose::STANDARD, s.as_bytes())
157 .map_err(|e| AuditKeyError::BadBase64(e.to_string()))?
158 } else {
159 return Err(AuditKeyError::BadPrefix(spec.to_owned()));
160 };
161 if bytes.len() < 16 {
162 return Err(AuditKeyError::TooShort(bytes.len()));
163 }
164 Ok(Self(Arc::new(bytes)))
165 }
166}
167
168impl std::fmt::Debug for AuditHmacKey {
169 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
170 f.debug_struct("AuditHmacKey")
171 .field("len", &self.0.len())
172 .field("key", &"<redacted>")
173 .finish()
174 }
175}
176
177pub type SharedAuditHmacKey = Arc<AuditHmacKey>;
178
179/// Compute the genesis seed: `SHA256("S4-AUDIT-V1")`. Used as the
180/// `prev_hmac` for the very first line in a chain (when no previous
181/// file's tail is available).
182pub fn genesis_prev() -> [u8; 32] {
183 let mut h = Sha256::new();
184 h.update(GENESIS_LABEL);
185 let out = h.finalize();
186 let mut buf = [0u8; 32];
187 buf.copy_from_slice(&out);
188 buf
189}
190
191/// Compute one chain step. Input: previous HMAC bytes + the line text
192/// without its HMAC suffix (and without the trailing newline).
193/// Output: 32-byte HMAC-SHA256.
194pub fn chain_step(key: &AuditHmacKey, prev_hmac: &[u8], line_no_hmac: &[u8]) -> [u8; 32] {
195 let mut mac =
196 HmacSha256::new_from_slice(key.as_bytes()).expect("HMAC-SHA256 accepts any key length");
197 mac.update(prev_hmac);
198 mac.update(line_no_hmac);
199 let out = mac.finalize().into_bytes();
200 let mut buf = [0u8; 32];
201 buf.copy_from_slice(&out);
202 buf
203}
204
205/// v0.8.2 #63: compute the EOF HMAC marker.
206///
207/// `eof_hmac = HMAC-SHA256(key, EOF_LABEL || prev_chain_state)`.
208///
209/// `prev_chain_state` is the last chained HMAC emitted in the file
210/// (or [`genesis_prev`] when the file contained no chained entries).
211/// The marker is written as a separate trailing comment line and is
212/// **not** itself part of the chain — verifiers honour it as a tail
213/// authenticator independent of the per-entry chain so a downstream
214/// truncation that lops off entries plus the marker is detectable
215/// (whereas truncation that preserves a valid prefix is not, without
216/// the marker — that is the H-2 attack baseline).
217pub fn compute_eof_hmac(key: &AuditHmacKey, prev_chain_state: &[u8; 32]) -> [u8; 32] {
218 let mut mac =
219 HmacSha256::new_from_slice(key.as_bytes()).expect("HMAC-SHA256 accepts any key length");
220 mac.update(EOF_LABEL);
221 mac.update(prev_chain_state);
222 let out = mac.finalize().into_bytes();
223 let mut buf = [0u8; 32];
224 buf.copy_from_slice(&out);
225 buf
226}
227
228/// Render `bytes` as lowercase hex (no separators).
229pub fn hex_encode(bytes: &[u8]) -> String {
230 let mut out = String::with_capacity(bytes.len() * 2);
231 for b in bytes {
232 out.push_str(&format!("{b:02x}"));
233 }
234 out
235}
236
237/// Decode a hex string back to bytes. `None` on any non-hex character
238/// or odd length.
239pub fn hex_decode(s: &str) -> Option<Vec<u8>> {
240 if !s.len().is_multiple_of(2) {
241 return None;
242 }
243 let mut out = Vec::with_capacity(s.len() / 2);
244 for i in (0..s.len()).step_by(2) {
245 out.push(u8::from_str_radix(&s[i..i + 2], 16).ok()?);
246 }
247 Some(out)
248}
249
250/// v0.8.2 #63: knobs that change how strictly `verify_audit_log` walks
251/// the file. Defaults preserve back-compat with v0.5 #31 callers.
252#[derive(Debug, Clone, Default, PartialEq, Eq)]
253pub struct VerifyOptions {
254 /// Operator-supplied previous-file tail HMAC. When `Some(tail)`, any
255 /// `# prev_file_tail=<hex>` comment in the file is ignored as
256 /// authentication (it is still parsed as a sanity check, but the
257 /// chain seed is the operator-supplied value). Eliminates H-3
258 /// (splice/replay): an attacker who fabricates a `# prev_file_tail=`
259 /// comment cannot forge cross-file linkage when the operator
260 /// supplies the real previous-file's tail out-of-band.
261 pub expected_prev_tail: Option<[u8; 32]>,
262 /// When `true`, the file MUST end with a recognized
263 /// `# eof_hmac=<hex>` marker that verifies against the file's
264 /// final chain state; otherwise the verifier returns
265 /// [`VerifyError::EofHmacMissing`] (or [`VerifyError::EofHmacMismatch`]
266 /// on a malformed value). Mitigates H-2 (truncation un-detection).
267 /// Off by default for back-compat with pre-v0.8.2 audit logs that
268 /// don't yet carry the marker.
269 pub require_eof_hmac: bool,
270}
271
272/// Result of `verify_audit_log`. `first_break` is `None` when the
273/// chain is intact end-to-end.
274#[derive(Debug, Clone, PartialEq, Eq)]
275pub struct VerifyReport {
276 pub total_lines: u64,
277 pub ok_lines: u64,
278 pub first_break: Option<VerifyBreak>,
279 /// v0.8.2 #63: `true` when the file does NOT end with a recognized
280 /// `# eof_hmac=<hex>` marker. With `require_eof_hmac = false` this
281 /// is informational (operator should treat as suspicious for any
282 /// post-v0.8.2 producer); with `require_eof_hmac = true` the
283 /// verifier additionally returns [`VerifyError::EofHmacMissing`].
284 pub unsigned_eof: bool,
285 /// v0.8.2 #63: `true` when the chain seed for this file came from
286 /// an in-file `# prev_file_tail=<hex>` comment that is not itself
287 /// authenticated (H-3 baseline). Cleared when the operator supplied
288 /// `VerifyOptions::expected_prev_tail` (then the chain seed is
289 /// trusted-by-construction).
290 pub unsigned_prev_tail: bool,
291}
292
293#[derive(Debug, Clone, PartialEq, Eq)]
294pub struct VerifyBreak {
295 /// 1-indexed line number within the file (counting all lines,
296 /// including comment lines).
297 pub line_no: u64,
298 /// Hex-encoded HMAC the verifier computed.
299 pub expected_hmac: String,
300 /// Hex-encoded HMAC the verifier read off the line (or "<missing>"
301 /// if the trailing column wasn't present at all).
302 pub actual_hmac: String,
303}
304
305#[derive(Debug, Error)]
306pub enum VerifyError {
307 #[error("audit-log file {path:?}: {source}")]
308 Io {
309 path: std::path::PathBuf,
310 source: std::io::Error,
311 },
312 #[error("audit-log file {path:?}: prev_file_tail comment had non-hex value: {value:?}")]
313 BadPrevTail {
314 path: std::path::PathBuf,
315 value: String,
316 },
317 /// v0.8.2 #63: `--require-eof-hmac` was set and the file did not
318 /// end with a recognized `# eof_hmac=<hex>` marker line.
319 #[error(
320 "audit-log file {path:?}: required `# eof_hmac=` marker is absent (truncation suspected — H-2)"
321 )]
322 EofHmacMissing { path: std::path::PathBuf },
323 /// v0.8.2 #63: the EOF marker was present but its value either
324 /// failed hex-decode / length check, or did not match the recomputed
325 /// `HMAC(key, EOF_LABEL || prev_chain_state)`.
326 #[error(
327 "audit-log file {path:?}: `# eof_hmac=` marker did not authenticate (expected {expected:?}, got {actual:?})"
328 )]
329 EofHmacMismatch {
330 path: std::path::PathBuf,
331 expected: String,
332 actual: String,
333 },
334}
335
336/// Walk an audit-log file, recomputing each line's HMAC and comparing
337/// against the trailing column. Stops at the first break and reports
338/// it (subsequent lines are NOT counted as `ok_lines` — they may all
339/// be valid, just not chain-linked from where the break is).
340///
341/// Comment lines (lines starting with `#`) are honoured — specifically
342/// `# prev_file_tail=<hex>` resets the running `prev_hmac` to that
343/// value before the next non-comment line, and `# eof_hmac=<hex>`
344/// (when present) is captured for the end-of-file authentication
345/// check. Other comment lines are counted but not chain-checked.
346///
347/// Empty / whitespace-only lines are skipped (counted but neither
348/// chain-checked nor flagged).
349///
350/// `options` controls the H-2 / H-3 mitigations introduced in v0.8.2
351/// #63 — see [`VerifyOptions`].
352pub fn verify_audit_log(
353 path: &Path,
354 key: &AuditHmacKey,
355 options: VerifyOptions,
356) -> Result<VerifyReport, VerifyError> {
357 let raw = std::fs::read(path).map_err(|source| VerifyError::Io {
358 path: path.to_path_buf(),
359 source,
360 })?;
361 verify_audit_bytes(path, &raw, key, options)
362}
363
364/// Same as `verify_audit_log` but takes the in-memory bytes directly.
365/// Used by the unit tests; the file-path version delegates here after
366/// reading.
367pub fn verify_audit_bytes(
368 path: &Path,
369 bytes: &[u8],
370 key: &AuditHmacKey,
371 options: VerifyOptions,
372) -> Result<VerifyReport, VerifyError> {
373 let text = std::str::from_utf8(bytes).map_err(|e| VerifyError::Io {
374 path: path.to_path_buf(),
375 source: std::io::Error::new(std::io::ErrorKind::InvalidData, e),
376 })?;
377
378 // v0.8.2 #63: when the operator supplies the previous-file tail
379 // out-of-band, seed the chain from it and ignore any in-file
380 // `# prev_file_tail=` comment as authentication. Otherwise fall
381 // back to the v0.5 behavior of trusting the in-file comment as
382 // a hint (and surface that fact via `unsigned_prev_tail`).
383 let operator_seed = options.expected_prev_tail;
384 let mut prev_hmac: [u8; 32] = operator_seed.unwrap_or_else(genesis_prev);
385 // Tracks whether the chain seed currently in `prev_hmac` came from
386 // an in-file `# prev_file_tail=<hex>` comment (used to flag
387 // `unsigned_prev_tail`). Always false when the operator supplied a
388 // seed (the operator value is trusted-by-construction).
389 let mut prev_tail_came_from_file = false;
390 let mut total: u64 = 0;
391 let mut ok: u64 = 0;
392 let mut eof_marker: Option<[u8; 32]> = None;
393 // The chain state at the moment we observed the EOF marker — used
394 // to recompute `HMAC(key, EOF_LABEL || state)` and compare. We
395 // capture this at the line *before* the marker so trailing blank
396 // lines after the marker do not change the authenticator input.
397 let mut state_at_eof: [u8; 32] = prev_hmac;
398 let mut saw_eof_marker_line = false;
399
400 for (idx, raw_line) in text.split_inclusive('\n').enumerate() {
401 total += 1;
402 let line_no = (idx + 1) as u64;
403 // Strip the trailing newline (and CR, defensively) for
404 // chain-step input. We do NOT trim leading whitespace because
405 // the access log format starts with `-` deliberately.
406 let line = raw_line.trim_end_matches('\n').trim_end_matches('\r');
407 if line.trim().is_empty() {
408 continue;
409 }
410 if let Some(rest) = line.strip_prefix(PREV_TAIL_COMMENT_PREFIX) {
411 let hex = rest.trim();
412 let bytes = hex_decode(hex).ok_or_else(|| VerifyError::BadPrevTail {
413 path: path.to_path_buf(),
414 value: hex.to_owned(),
415 })?;
416 if bytes.len() != 32 {
417 return Err(VerifyError::BadPrevTail {
418 path: path.to_path_buf(),
419 value: hex.to_owned(),
420 });
421 }
422 // Operator seed wins — H-3 mitigation. We still parse the
423 // comment (so a malformed value is loud) but do NOT let it
424 // override the operator-supplied chain seed.
425 if operator_seed.is_none() {
426 prev_hmac.copy_from_slice(&bytes);
427 state_at_eof = prev_hmac;
428 prev_tail_came_from_file = true;
429 }
430 continue;
431 }
432 if let Some(rest) = line.strip_prefix(EOF_HMAC_COMMENT_PREFIX) {
433 // v0.8.2 #63: capture the marker for end-of-file validation
434 // but do NOT chain-step it — the marker is authenticated
435 // separately via `compute_eof_hmac`. The marker uses the
436 // chain state AS OF THIS LINE, matching the producer's
437 // contract (the producer writes `compute_eof_hmac(key,
438 // last_chain_hmac)` immediately after the last entry).
439 let hex = rest.trim();
440 match hex_decode(hex) {
441 Some(b) if b.len() == 32 => {
442 let mut buf = [0u8; 32];
443 buf.copy_from_slice(&b);
444 eof_marker = Some(buf);
445 state_at_eof = prev_hmac;
446 saw_eof_marker_line = true;
447 }
448 _ => {
449 return Err(VerifyError::EofHmacMismatch {
450 path: path.to_path_buf(),
451 expected: "<computed at end-of-file>".to_owned(),
452 actual: hex.to_owned(),
453 });
454 }
455 }
456 continue;
457 }
458 if line.starts_with('#') {
459 // other comment — skip but count.
460 continue;
461 }
462 // Split off the trailing HMAC column.
463 let (line_no_hmac, actual_hex) = match split_hmac_suffix(line) {
464 Some((body, hmac_hex)) => (body, hmac_hex),
465 None => {
466 return Ok(VerifyReport {
467 total_lines: total,
468 ok_lines: ok,
469 first_break: Some(VerifyBreak {
470 line_no,
471 expected_hmac: hex_encode(&chain_step(key, &prev_hmac, line.as_bytes())),
472 actual_hmac: "<missing>".to_owned(),
473 }),
474 unsigned_eof: !saw_eof_marker_line,
475 unsigned_prev_tail: prev_tail_came_from_file,
476 });
477 }
478 };
479 let expected = chain_step(key, &prev_hmac, line_no_hmac.as_bytes());
480 let expected_hex = hex_encode(&expected);
481 if expected_hex == actual_hex {
482 ok += 1;
483 prev_hmac = expected;
484 } else {
485 return Ok(VerifyReport {
486 total_lines: total,
487 ok_lines: ok,
488 first_break: Some(VerifyBreak {
489 line_no,
490 expected_hmac: expected_hex,
491 actual_hmac: actual_hex.to_owned(),
492 }),
493 unsigned_eof: !saw_eof_marker_line,
494 unsigned_prev_tail: prev_tail_came_from_file,
495 });
496 }
497 }
498
499 // EOF marker check. If the marker appeared in the loop we captured
500 // the chain state at that point in `state_at_eof`. The producer
501 // computes `compute_eof_hmac(key, last_entry_hmac)`; the verifier
502 // recomputes the same and compares.
503 if let Some(marker) = eof_marker {
504 let expected = compute_eof_hmac(key, &state_at_eof);
505 if expected != marker {
506 return Err(VerifyError::EofHmacMismatch {
507 path: path.to_path_buf(),
508 expected: hex_encode(&expected),
509 actual: hex_encode(&marker),
510 });
511 }
512 } else if options.require_eof_hmac {
513 return Err(VerifyError::EofHmacMissing {
514 path: path.to_path_buf(),
515 });
516 }
517
518 Ok(VerifyReport {
519 total_lines: total,
520 ok_lines: ok,
521 first_break: None,
522 unsigned_eof: !saw_eof_marker_line,
523 unsigned_prev_tail: prev_tail_came_from_file,
524 })
525}
526
527/// Split a chained line into `(body_without_hmac, hmac_hex)`. The
528/// HMAC is the last whitespace-separated column and is exactly 64
529/// lowercase hex characters. Returns `None` if the line doesn't end
530/// with a valid hex column of the expected length.
531fn split_hmac_suffix(line: &str) -> Option<(&str, &str)> {
532 if line.len() <= HMAC_HEX_LEN + 1 {
533 return None;
534 }
535 let cut = line.len() - HMAC_HEX_LEN;
536 let body = &line[..cut];
537 let hmac = &line[cut..];
538 // body must end with a single space separator.
539 if !body.ends_with(' ') {
540 return None;
541 }
542 if hmac.len() != HMAC_HEX_LEN || !hmac.chars().all(|c| c.is_ascii_hexdigit()) {
543 return None;
544 }
545 // Drop the trailing space so the chain input matches the producer's
546 // (which appends ` <hex>\n` to the underlying line).
547 Some((&body[..body.len() - 1], hmac))
548}
549
550#[cfg(test)]
551mod tests {
552 use super::*;
553
554 fn key() -> AuditHmacKey {
555 AuditHmacKey::from_str("raw:0123456789abcdef0123456789abcdef").unwrap()
556 }
557
558 #[test]
559 fn genesis_is_sha256_of_label() {
560 let g = genesis_prev();
561 // SHA-256("S4-AUDIT-V1") — recomputed independently to lock
562 // the constant down. Any change to the label is a wire break.
563 let mut h = Sha256::new();
564 h.update(b"S4-AUDIT-V1");
565 let want = h.finalize();
566 assert_eq!(&g[..], &want[..]);
567 }
568
569 #[test]
570 fn key_parsing_accepts_three_prefixes() {
571 let r = AuditHmacKey::from_str("raw:0123456789abcdef0123456789abcdef").unwrap();
572 assert_eq!(r.as_bytes().len(), 32);
573 let h = AuditHmacKey::from_str(
574 "hex:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
575 )
576 .unwrap();
577 assert_eq!(h.as_bytes().len(), 32);
578 // 32 zero bytes -> base64 "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA="
579 let b =
580 AuditHmacKey::from_str("base64:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=").unwrap();
581 assert_eq!(b.as_bytes(), &[0u8; 32]);
582 }
583
584 #[test]
585 fn key_parsing_rejects_short_keys() {
586 let err = AuditHmacKey::from_str("raw:short").unwrap_err();
587 assert!(matches!(err, AuditKeyError::TooShort(5)));
588 }
589
590 #[test]
591 fn key_parsing_rejects_bad_prefix() {
592 let err = AuditHmacKey::from_str("plain:key").unwrap_err();
593 assert!(matches!(err, AuditKeyError::BadPrefix(_)));
594 }
595
596 #[test]
597 fn happy_path_chain_verifies() {
598 let key = key();
599 // Build a 3-line file by hand.
600 let lines = ["line one alpha", "line two beta", "line three gamma"];
601 let mut buf = String::new();
602 let mut prev = genesis_prev();
603 for ln in &lines {
604 let mac = chain_step(&key, &prev, ln.as_bytes());
605 buf.push_str(ln);
606 buf.push(' ');
607 buf.push_str(&hex_encode(&mac));
608 buf.push('\n');
609 prev = mac;
610 }
611 let report = verify_audit_bytes(
612 std::path::Path::new("<mem>"),
613 buf.as_bytes(),
614 &key,
615 VerifyOptions::default(),
616 )
617 .unwrap();
618 assert_eq!(report.total_lines, 3);
619 assert_eq!(report.ok_lines, 3);
620 assert!(report.first_break.is_none());
621 }
622
623 #[test]
624 fn tamper_one_byte_in_middle_breaks_at_that_line() {
625 let key = key();
626 let lines = ["line A", "line B middle", "line C tail"];
627 let mut buf = String::new();
628 let mut prev = genesis_prev();
629 for ln in &lines {
630 let mac = chain_step(&key, &prev, ln.as_bytes());
631 buf.push_str(ln);
632 buf.push(' ');
633 buf.push_str(&hex_encode(&mac));
634 buf.push('\n');
635 prev = mac;
636 }
637 // Flip one character in the middle of line 2's body.
638 let bad = buf.replace("middle", "MIDDLE");
639 let report = verify_audit_bytes(
640 std::path::Path::new("<mem>"),
641 bad.as_bytes(),
642 &key,
643 VerifyOptions::default(),
644 )
645 .unwrap();
646 assert!(report.first_break.is_some(), "expected a break");
647 let br = report.first_break.unwrap();
648 assert_eq!(br.line_no, 2, "break should be on line 2");
649 assert_eq!(report.ok_lines, 1, "line 1 OK before the break");
650 }
651
652 #[test]
653 fn tamper_hmac_field_breaks_at_that_line() {
654 let key = key();
655 let line = "lonely line";
656 let mac = chain_step(&key, &genesis_prev(), line.as_bytes());
657 let s = format!("{} {}\n", line, hex_encode(&mac));
658 // Flip a hex char in the HMAC suffix (penultimate byte; final
659 // byte is '\n').
660 let last = s.len() - 2;
661 let c = s.as_bytes()[last];
662 let new_c = if c == b'0' { '1' } else { '0' };
663 let mut bad = String::with_capacity(s.len());
664 bad.push_str(&s[..last]);
665 bad.push(new_c);
666 bad.push_str(&s[last + 1..]);
667 let report = verify_audit_bytes(
668 std::path::Path::new("<mem>"),
669 bad.as_bytes(),
670 &key,
671 VerifyOptions::default(),
672 )
673 .unwrap();
674 let br = report.first_break.expect("expected break");
675 assert_eq!(br.line_no, 1);
676 // Actual byte was flipped, so c is unchanged in `bad`.
677 let _ = c;
678 }
679
680 #[test]
681 fn missing_hmac_column_reports_break_with_missing_marker() {
682 let key = key();
683 let s = "no hmac at all\n";
684 let report = verify_audit_bytes(
685 std::path::Path::new("<mem>"),
686 s.as_bytes(),
687 &key,
688 VerifyOptions::default(),
689 )
690 .unwrap();
691 let br = report.first_break.expect("expected break");
692 assert_eq!(br.actual_hmac, "<missing>");
693 }
694
695 #[test]
696 fn cross_file_chain_via_prev_tail_comment() {
697 let key = key();
698 // First "file": one line, capture its tail.
699 let line1 = "first file lone line";
700 let mac1 = chain_step(&key, &genesis_prev(), line1.as_bytes());
701 let f1 = format!("{} {}\n", line1, hex_encode(&mac1));
702 let r1 = verify_audit_bytes(
703 std::path::Path::new("<f1>"),
704 f1.as_bytes(),
705 &key,
706 VerifyOptions::default(),
707 )
708 .unwrap();
709 assert!(r1.first_break.is_none());
710
711 // Second "file": prev_file_tail comment, then one line whose
712 // HMAC is computed from mac1 as its prev.
713 let line2 = "second file lone line";
714 let mac2 = chain_step(&key, &mac1, line2.as_bytes());
715 let f2 = format!(
716 "# prev_file_tail={}\n{} {}\n",
717 hex_encode(&mac1),
718 line2,
719 hex_encode(&mac2)
720 );
721 let r2 = verify_audit_bytes(
722 std::path::Path::new("<f2>"),
723 f2.as_bytes(),
724 &key,
725 VerifyOptions::default(),
726 )
727 .unwrap();
728 assert!(r2.first_break.is_none(), "cross-file chain must verify");
729 assert_eq!(r2.ok_lines, 1);
730 assert_eq!(r2.total_lines, 2); // comment + entry
731 // v0.8.2 #63: in-file `# prev_file_tail=` is the chain seed
732 // here (no operator override) so the report flags it.
733 assert!(r2.unsigned_prev_tail);
734 }
735
736 #[test]
737 fn cross_file_chain_with_wrong_prev_tail_breaks() {
738 let key = key();
739 let line2 = "second file lone line";
740 // Wrong prev: 32 zero bytes
741 let wrong_prev = [0u8; 32];
742 // But the producer wrote the HMAC computed from genesis (or
743 // anything other than wrong_prev), so the verifier's recompute
744 // will mismatch.
745 let actual_mac = chain_step(&key, &genesis_prev(), line2.as_bytes());
746 let f2 = format!(
747 "# prev_file_tail={}\n{} {}\n",
748 hex_encode(&wrong_prev),
749 line2,
750 hex_encode(&actual_mac)
751 );
752 let r = verify_audit_bytes(
753 std::path::Path::new("<f2>"),
754 f2.as_bytes(),
755 &key,
756 VerifyOptions::default(),
757 )
758 .unwrap();
759 assert!(r.first_break.is_some());
760 }
761
762 #[test]
763 fn split_hmac_suffix_basic() {
764 let hmac64 = "a".repeat(64);
765 let s = format!("foo bar baz {hmac64}");
766 let (body, hmac) = split_hmac_suffix(&s).unwrap();
767 assert_eq!(body, "foo bar baz");
768 assert_eq!(hmac.len(), 64);
769 assert_eq!(hmac, hmac64.as_str());
770 }
771
772 #[test]
773 fn split_hmac_suffix_rejects_short_or_nonhex() {
774 assert!(split_hmac_suffix("short").is_none());
775 // 64 chars but contains 'g' (not hex) — produce a 64-char
776 // non-hex suffix to keep the length right.
777 let bad_hmac = "g".repeat(64);
778 let bad = format!("x {bad_hmac}");
779 assert!(split_hmac_suffix(&bad).is_none());
780 }
781
782 #[test]
783 fn hex_roundtrip() {
784 let raw = [0u8, 1, 2, 0xff, 0x10, 0xab];
785 let s = hex_encode(&raw);
786 assert_eq!(s, "000102ff10ab");
787 let dec = hex_decode(&s).unwrap();
788 assert_eq!(dec, raw);
789 }
790
791 // ---------------------------------------------------------------
792 // v0.8.2 #63: H-2 (truncation) + H-3 (cross-file auth) tests.
793 // ---------------------------------------------------------------
794
795 /// Helper: render a chained file body for the given lines, optionally
796 /// seeded by a previous file's tail (mimicking what the producer
797 /// would write across rotations) and optionally appending the
798 /// v0.8.2 #63 EOF HMAC marker as the last line.
799 fn render_chained_file(
800 key: &AuditHmacKey,
801 prev_file_tail: Option<[u8; 32]>,
802 lines: &[&str],
803 with_eof_marker: bool,
804 ) -> (String, [u8; 32]) {
805 let mut out = String::new();
806 let seed = if let Some(t) = prev_file_tail {
807 out.push_str(&format!("{}{}\n", PREV_TAIL_COMMENT_PREFIX, hex_encode(&t)));
808 t
809 } else {
810 genesis_prev()
811 };
812 let mut prev = seed;
813 for ln in lines {
814 let mac = chain_step(key, &prev, ln.as_bytes());
815 out.push_str(ln);
816 out.push(' ');
817 out.push_str(&hex_encode(&mac));
818 out.push('\n');
819 prev = mac;
820 }
821 if with_eof_marker {
822 let eof = compute_eof_hmac(key, &prev);
823 out.push_str(EOF_HMAC_COMMENT_PREFIX);
824 out.push_str(&hex_encode(&eof));
825 out.push('\n');
826 }
827 (out, prev)
828 }
829
830 /// v0.8.2 #63: H-3 mitigation — when the operator supplies the
831 /// previous-file tail out-of-band, an attacker-fabricated
832 /// `# prev_file_tail=` comment inside the file is ignored as
833 /// authentication. The chain must verify against the operator's
834 /// seed even when the in-file comment is wildly wrong.
835 #[test]
836 fn verify_with_expected_prev_tail_overrides_in_file_hint() {
837 let key = key();
838 // Producer's truth: previous file ended with this tail.
839 let real_prev_tail = [0x42u8; 32];
840 // What the producer would have written (chained from
841 // real_prev_tail). The in-file comment carries `real_prev_tail`
842 // honestly here; we'll then replace it with attacker junk
843 // and assert the operator-override path still verifies.
844 let (honest, _) =
845 render_chained_file(&key, Some(real_prev_tail), &["line one", "line two"], false);
846 // Splice attack: rewrite the in-file `# prev_file_tail=` line
847 // to a fabricated value (32 zero bytes). Without operator
848 // hint a v0.5 verifier would seed from this fake; with the
849 // hint it must override and verify against the real tail.
850 let attacker_seed = [0u8; 32];
851 let spliced = honest.replacen(&hex_encode(&real_prev_tail), &hex_encode(&attacker_seed), 1);
852 // Sanity: the splice changed something.
853 assert_ne!(honest, spliced);
854 // Operator-override verify: pass the real tail; the verifier
855 // must ignore the in-file (now-attacker) comment as auth.
856 let report = verify_audit_bytes(
857 std::path::Path::new("<spliced>"),
858 spliced.as_bytes(),
859 &key,
860 VerifyOptions {
861 expected_prev_tail: Some(real_prev_tail),
862 require_eof_hmac: false,
863 },
864 )
865 .unwrap();
866 assert!(
867 report.first_break.is_none(),
868 "operator-supplied tail must let the chain verify even when the in-file comment is a forged splice: {report:?}"
869 );
870 // Operator override means the chain seed is trusted, not from
871 // the file — so `unsigned_prev_tail` must be cleared.
872 assert!(!report.unsigned_prev_tail);
873
874 // Control: same spliced bytes WITHOUT operator override break
875 // because the entries were chained against `real_prev_tail`,
876 // not against the attacker's value.
877 let no_override = verify_audit_bytes(
878 std::path::Path::new("<spliced>"),
879 spliced.as_bytes(),
880 &key,
881 VerifyOptions::default(),
882 )
883 .unwrap();
884 assert!(
885 no_override.first_break.is_some(),
886 "without operator override the spliced comment seeds wrong, breaking the chain"
887 );
888 assert!(no_override.unsigned_prev_tail);
889 }
890
891 /// v0.8.2 #63: H-2 mitigation — when `require_eof_hmac = true` and
892 /// the file does not carry a `# eof_hmac=` marker, the verifier
893 /// returns `EofHmacMissing`. This is the strict mode operators run
894 /// to detect tail truncation.
895 #[test]
896 fn verify_without_eof_hmac_when_required_fails() {
897 let key = key();
898 let (body, _) = render_chained_file(&key, None, &["a", "b", "c"], false);
899 let err = verify_audit_bytes(
900 std::path::Path::new("<no-eof>"),
901 body.as_bytes(),
902 &key,
903 VerifyOptions {
904 expected_prev_tail: None,
905 require_eof_hmac: true,
906 },
907 )
908 .unwrap_err();
909 assert!(matches!(err, VerifyError::EofHmacMissing { .. }));
910 }
911
912 /// v0.8.2 #63: relaxed mode — pre-v0.8.2 logs (no EOF marker) still
913 /// verify successfully, but `unsigned_eof = true` flags them so a
914 /// dashboard / operator can decide whether to escalate.
915 #[test]
916 fn verify_without_eof_hmac_when_optional_succeeds_with_unsigned_eof_flag() {
917 let key = key();
918 let (body, _) = render_chained_file(&key, None, &["a", "b", "c"], false);
919 let report = verify_audit_bytes(
920 std::path::Path::new("<no-eof-relaxed>"),
921 body.as_bytes(),
922 &key,
923 VerifyOptions::default(),
924 )
925 .unwrap();
926 assert!(report.first_break.is_none());
927 assert!(report.unsigned_eof, "relaxed mode flags missing EOF marker");
928 assert_eq!(report.ok_lines, 3);
929 }
930
931 /// v0.8.2 #63: a complete file with EOF marker round-trips through
932 /// the verifier with both relaxed and strict (`require_eof_hmac`)
933 /// modes returning OK, no flags raised.
934 #[test]
935 fn eof_hmac_marker_round_trip() {
936 let key = key();
937 let (body, _) =
938 render_chained_file(&key, None, &["entry one", "entry two", "entry three"], true);
939 // Relaxed mode.
940 let r1 = verify_audit_bytes(
941 std::path::Path::new("<eof-rt>"),
942 body.as_bytes(),
943 &key,
944 VerifyOptions::default(),
945 )
946 .unwrap();
947 assert!(r1.first_break.is_none());
948 assert!(!r1.unsigned_eof);
949 assert_eq!(r1.ok_lines, 3);
950 // Strict mode.
951 let r2 = verify_audit_bytes(
952 std::path::Path::new("<eof-rt>"),
953 body.as_bytes(),
954 &key,
955 VerifyOptions {
956 expected_prev_tail: None,
957 require_eof_hmac: true,
958 },
959 )
960 .unwrap();
961 assert!(r2.first_break.is_none());
962 assert!(!r2.unsigned_eof);
963 }
964
965 /// v0.8.2 #63 H-2 baseline: a truncated log without
966 /// `require_eof_hmac` silently passes verify (this is the attack
967 /// the issue documents). This regression test pins the baseline so
968 /// the next audit can reason about what `require_eof_hmac = false`
969 /// allows.
970 #[test]
971 fn truncated_log_without_expected_eof_silently_passes() {
972 let key = key();
973 // Producer wrote 4 entries + EOF marker.
974 let (full, _) = render_chained_file(&key, None, &["alpha", "beta", "gamma", "delta"], true);
975 // Attacker truncates after entry #2 (drops gamma, delta, marker).
976 let cut_at = full.find("gamma").expect("gamma in body");
977 let truncated = &full[..cut_at];
978 // Sanity: the truncated body is shorter and ends at a newline.
979 assert!(truncated.ends_with('\n'));
980 let report = verify_audit_bytes(
981 std::path::Path::new("<truncated>"),
982 truncated.as_bytes(),
983 &key,
984 VerifyOptions::default(),
985 )
986 .unwrap();
987 assert!(
988 report.first_break.is_none(),
989 "H-2 baseline: a valid prefix verifies clean without `require_eof_hmac` — \
990 this is the attack window the marker closes"
991 );
992 // The flag is the only signal in relaxed mode.
993 assert!(report.unsigned_eof);
994 assert_eq!(report.ok_lines, 2);
995 }
996
997 /// v0.8.2 #63 H-2 mitigated: the same truncated log with
998 /// `require_eof_hmac = true` returns `EofHmacMissing`.
999 #[test]
1000 fn truncated_log_with_require_eof_fails() {
1001 let key = key();
1002 let (full, _) = render_chained_file(&key, None, &["alpha", "beta", "gamma", "delta"], true);
1003 let cut_at = full.find("gamma").expect("gamma in body");
1004 let truncated = &full[..cut_at];
1005 let err = verify_audit_bytes(
1006 std::path::Path::new("<truncated-strict>"),
1007 truncated.as_bytes(),
1008 &key,
1009 VerifyOptions {
1010 expected_prev_tail: None,
1011 require_eof_hmac: true,
1012 },
1013 )
1014 .unwrap_err();
1015 assert!(
1016 matches!(err, VerifyError::EofHmacMissing { .. }),
1017 "strict mode rejects truncated logs (H-2 mitigated): got {err:?}"
1018 );
1019 }
1020}