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