Skip to main content

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}