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(
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}