diff --git a/README.md b/README.md index a6335b3..355e752 100644 --- a/README.md +++ b/README.md @@ -47,6 +47,18 @@ demo-seeder/ Operational tool: generates a signed demo WAL on an wasm bundle. ``` +## Verifying large WALs + +`spine-cli verify` streams the WAL one line at a time, so peak memory is +flat regardless of size: on a 709 MB, 1M-record WAL it peaks at 4.4 MB. +For routine re-checks, `--chain-only` (verify the hash chain and root, +skip per-record signatures) runs about 9x faster, and +`--sample-signatures N` spot-checks one record in every N. Full +signature verification stays the default. See +[docs/verifying-at-scale.md](docs/verifying-at-scale.md) for the +measured numbers, the threat-model trade-offs of each policy, and the +sub-linear proof-based model for the largest scenarios. + ## What this verifies, and what it does not This codebase verifies that a given Spine WAL file is internally consistent and matches a pinned diff --git a/docs/verifying-at-scale.md b/docs/verifying-at-scale.md new file mode 100644 index 0000000..d20117c --- /dev/null +++ b/docs/verifying-at-scale.md @@ -0,0 +1,193 @@ +# Verifying large WALs + +An auditor may receive months of a bank's write-ahead log on a disk: +billions of records, terabytes of JSONL. This note describes how the +open-source verifier handles that, what it guarantees, and where the +honest limits are. It separates three things: + +1. What ships in this repository today (the verifier). +2. A verifier-side optimization that is designed but not built here. +3. Server-side work that is out of scope for this repository (it + belongs to the production server, which is not open source). + +The numbers below are measured, not modelled, unless a row is labelled a +projection. The benchmark is a 1,000,000-record lenient-signed WAL +(709.5 MB on disk, ~740 bytes/record, 10 segments) verified by the +release CLI on a single core. + +## 1. What ships today + +### Streaming verification (constant memory) + +`spine-cli verify` streams the WAL one line at a time. Peak memory is +flat regardless of total size: the verifier holds one line buffer plus +the running chain state (a few hashes and counters), never the WAL +itself. + +| Run on the 709.5 MB / 1M-record WAL | Wall time | Peak RAM | Throughput | +|-------------------------------------|-----------|----------|------------| +| `verify` (full, every signature) | 35.4 s | 4.4 MB | 28,254 ev/s | +| `verify --chain-only` | 4.0 s | 4.4 MB | 249,875 ev/s | +| `verify --sample-signatures 1000` | 4.1 s | 4.5 MB | 246,609 ev/s | + +The headline is the 4.4 MB column: it does not grow with the WAL. A +buffer-the-whole-file verifier would peak above the WAL size (roughly +1.2 GB on this input); streaming removes that ceiling entirely, so the +disk a bank ships is never bounded by the auditor's RAM. + +The same state machine drives the buffered byte API used by the browser +playground and the cross-language vectors, so the streaming and buffered +paths cannot diverge. + +### Signature policy + +Verifying one Ed25519 signature per record dominates the cost: on the +benchmark it is about 93% of full-verify time. Walking the chain and +parsing JSON is roughly an order of magnitude cheaper. Three policies +let an auditor choose coverage: + +- **Full (default).** Every signed record's signature is verified. The + only policy that defends against a targeted forger. O(n) in the number + of records. +- **`--chain-only`.** Verifies chain linkage, sequence, timestamps, + hash formats and the chain root; skips per-record signatures. About + 9x faster here. It retains tamper-evidence only when paired with an + authenticated `--expected-root` (an external anchor the auditor trusts + out of band); without one it proves internal self-consistency only. + Incompatible with `--trusted-pubkey` and `--keystore`. +- **`--sample-signatures N`.** Verifies one record in every N (those + whose `sequence` is a multiple of N). A routine spot-check for + accidental corruption or a wrong-key rollout. It is NOT a defense + against a targeted forger, who can simply avoid the sampled positions; + a record left unchecked is reported in `signatures_skipped` and the + result carries a warning. A sampled signature that fails still fails + the whole run. + +Reducing signature coverage never weakens the chain's own +tamper-evidence: chain, sequence, timestamp, hash-format and root checks +always run in full. A broken hash-chain link fails even under +`--chain-only`. + +### What this means for an auditor's scenarios + +Single core, constant ~5 MB RAM in every row. The first data row is +measured; the rest are projections that scale the measured throughput +linearly (each record is independent work, so this holds until disk I/O +becomes the floor). + +| Records (approx size) | Full verify | `--chain-only` | Peak RAM | +|---------------------------|-------------|----------------|----------| +| 1M (0.7 GB, measured) | 35 s | 4 s | 4.4 MB | +| 180M (~130 GB) | ~1.8 h | ~12 min | ~5 MB | +| 1.8B (~1.3 TB) | ~17.7 h | ~2.0 h | ~5 MB | +| 18B (~13 TB) | ~7.4 days | ~20 h | ~5 MB | + +At 13 TB the `--chain-only` compute time (~20 h) and the time to read +13 TB from disk are the same order of magnitude, so that row is +I/O-bound: wall clock is about a day either way, but still at a few MB +of RAM, on a laptop. + +The takeaway: streaming removes the memory wall outright, and +`--chain-only` / `--sample-signatures` make the small and mid scenarios +a matter of minutes to a couple of hours. The largest scenario is the +one that wants a different verification model, described in section 3. + +### Honest limits + +- Full verification is O(n): there is no way to check every one of N + signatures in less than N signature verifications on one core. +- `--sample-signatures` trades completeness for speed and is not + adversarial: document the sample rate in the audit record. +- `--chain-only` is only as strong as the `--expected-root` you pin. An + unauthenticated root proves the log is self-consistent, not that it is + the log the bank committed to. + +## 2. Designed, not built here: parallel signature verification + +Signature verification of one record is independent of every other +record, so the full-verify path parallelizes across cores for a roughly +linear speed-up (for example ~8x on 8 cores), at bounded memory, by +verifying signatures for a window of records in parallel while the chain +walk stays sequential. + +This is a verifier-side change and could live in this repository. It is +deliberately not in this release: + +- The headline wins (the memory wall, and the order-of-magnitude speed-up + for routine runs) come from streaming and the signature policy, which + are simpler and provably correct. +- Doing it without forking the canonical lenient state machine means + adding threads to `spine-core`, whose value rests on being + single-source-of-truth, no-panic, and clean to compile to wasm (which + has no threads by default). That is a larger change than fits beside + the above. +- Parallelism speeds up the O(n) regime by a constant factor; it does + not change the asymptotics. For the largest scenario the right answer + is the sub-linear, proof-based model in section 3, not a faster brute + force. + +Design sketch for when it is built: read records into a bounded window +(for example 8,192 at a time); verify that window's signatures on a +thread pool calling the existing per-record signature primitive in +`spine-core`; keep the chain link, sequence, timestamp and root checks +sequential; merge signature failures back by sequence so output stays +deterministic. Memory stays O(window). No change to the verdict, only to +how fast signatures are checked. + +## 3. Out of scope here: sub-linear verification via proofs + +The production server (ingestion, batch sealing, retention) is not in +this repository and is not open source. The verification model that +actually removes the "process every record" cost requires the server to +emit additional structure. This section records the design so the +auditor-facing proof-checking can be specified against it; none of it is +implemented in this repository. + +### Tier 2: signed checkpoints + +The server periodically signs a checkpoint over `(chain_root, length, +timestamp)`: a Signed Tree Head. An auditor verifies a chain of +checkpoints and their consistency instead of every event. The receipts +already carry a `batch_id`, and the README mentions batch sealing, so +the batch structure likely exists server-side already; what is missing +is exposing it to the auditor. + +### Tier 3: Merkle transparency log + +This is the model that scales to the largest scenario. It is the design +used by Certificate Transparency (RFC 6962) and Trillian to verify +billions of certificates. + +The server maintains an append-only Merkle tree over the events and +publishes a signed tree head. With it, an auditor can verify: + +- **Inclusion.** That specific events (the ones in the audit scope) are + in the committed log, each via an inclusion proof of about log2(N) + hashes. For 18 billion events that is about 35 hashes per event, not + 18 billion. +- **Consistency.** That the current tree is an append-only extension of + an earlier tree head the auditor already trusts, via a consistency + proof, so nothing was edited or removed behind the scenes. + +The auditor downloads the signed tree head, the events in scope, and +their proofs, and verifies in seconds at constant memory on a laptop, +without reading the bulk of the log at all. That is what turns the +largest scenario from days into seconds. + +A future open-source addition to this repository would be the +auditor-side proof checker: given a signed tree head, a set of leaves, +and their inclusion and consistency proofs, verify them. That checker is +pure logic with no server dependency and would fit the same no-panic, +cross-language-vectored contract as the rest of `spine-core`. The +proof-emitting side stays in the production server. + +## Recommended workflow + +- Routine / periodic re-check of a large WAL: `--chain-only` with an + authenticated `--expected-root`, or `--sample-signatures N` to spot a + wrong-key rollout cheaply. +- One-time forensic audit where every signature must be checked: full + `verify` (streaming keeps it to a few MB of RAM); accept the O(n) + time, or parallelize once section 2 is built. +- Targeting specific events out of a very large log: the proof-based + model in section 3, once the server emits proofs. diff --git a/spine-cli/src/main.rs b/spine-cli/src/main.rs index a1b1dad..d51f377 100644 --- a/spine-cli/src/main.rs +++ b/spine-cli/src/main.rs @@ -137,6 +137,27 @@ enum Commands { #[arg(long)] strict: bool, + /// Verify chain linkage, sequence, timestamps and root only; + /// skip per-record Ed25519 signature verification. Signature + /// checks dominate the cost on a large WAL, so this is much + /// faster while still streaming at constant memory. It retains + /// tamper-evidence only when paired with an authenticated + /// `--expected-root`; without one it proves internal + /// self-consistency. Lenient profile only; incompatible with + /// `--trusted-pubkey`, `--keystore` and `--sample-signatures`. + #[arg(long)] + chain_only: bool, + + /// Spot-check signatures by verifying one record in every N + /// (those whose `sequence` is a multiple of N) instead of all + /// of them. A routine check for accidental corruption or a + /// wrong-key rollout, NOT a defense against a targeted forger + /// who can avoid the sampled positions; a sampled signature + /// that fails still fails the run. Lenient profile only; + /// incompatible with `--chain-only`. + #[arg(long, value_name = "N")] + sample_signatures: Option, + /// Manifest version echoed into the strict report so a /// consumer can pin the exact contract it expects. Strict /// profile only; ignored in lenient mode. @@ -234,6 +255,8 @@ fn main() -> ExitCode { keystore, trusted_pubkey, strict, + chain_only, + sample_signatures, manifest_version, } => verify::run( &wal, @@ -243,6 +266,8 @@ fn main() -> ExitCode { keystore.as_deref(), trusted_pubkey.as_deref(), strict, + chain_only, + sample_signatures, manifest_version, cli.format, ) diff --git a/spine-cli/src/verify.rs b/spine-cli/src/verify.rs index 8f0abe9..6fc2461 100644 --- a/spine-cli/src/verify.rs +++ b/spine-cli/src/verify.rs @@ -7,11 +7,11 @@ use std::fs; use std::path::Path; use spine_core::{ - verify_demo_wal, verify_wal_bytes_with_options, DemoRecordOutcome, DemoReport, DemoStatus, - Keystore, LenientOptions, VerificationResult, + verify_demo_wal, DemoRecordOutcome, DemoReport, DemoStatus, Keystore, LenientOptions, + LenientVerifier, SignaturePolicy, VerificationResult, }; -use crate::wal_io::{read_wal_bytes, WalIoError}; +use crate::wal_io::{for_each_wal_line, read_wal_bytes, WalIoError}; use crate::OutputFormat; #[derive(Debug, thiserror::Error)] @@ -44,12 +44,43 @@ pub fn run( keystore_path: Option<&Path>, trusted_pubkey: Option<&str>, strict: bool, + chain_only: bool, + sample_signatures: Option, manifest_version: u32, format: OutputFormat, ) -> Result { - let bytes = read_wal_bytes(wal_path)?; + // Reduced signature policies are a lenient-profile feature: the strict + // profile verifies every signature of the (capped) demo WAL by + // contract, so a request to skip or sample them there is a usage error. + if strict && (chain_only || sample_signatures.is_some()) { + return Err(VerifyCmdError::Usage( + "--chain-only and --sample-signatures apply to the lenient profile only; \ + strict verifies every signature" + .to_string(), + )); + } + if chain_only && sample_signatures.is_some() { + return Err(VerifyCmdError::Usage( + "choose either --chain-only or --sample-signatures, not both".to_string(), + )); + } + if chain_only && (trusted_pubkey.is_some() || keystore_path.is_some()) { + return Err(VerifyCmdError::Usage( + "--chain-only skips per-record signature and receipt checks; remove \ + --trusted-pubkey/--keystore (or drop --chain-only to verify them)" + .to_string(), + )); + } + if sample_signatures == Some(0) { + return Err(VerifyCmdError::Usage( + "--sample-signatures N requires N >= 1".to_string(), + )); + } if strict { + // Strict is capped at MAX_RECORDS_DEMO records, so buffering the + // whole WAL is bounded; only the lenient path needs streaming. + let bytes = read_wal_bytes(wal_path)?; return run_strict( &bytes, expected_root, @@ -77,6 +108,14 @@ pub fn run( None => None, }; + let policy = if chain_only { + SignaturePolicy::None + } else if let Some(n) = sample_signatures { + SignaturePolicy::Sample { one_in: n } + } else { + SignaturePolicy::All + }; + let opts = LenientOptions { expected_root, keystore: keystore.as_ref(), @@ -84,11 +123,13 @@ pub fn run( trusted_pubkey: trusted_pubkey.as_deref(), }; - // verify_wal_bytes_with_options no longer returns Err: the - // partial report (records up to a fail-fast halt, plus the - // failing error in result.errors) is always emitted. We just - // surface it as-is. - let mut result = verify_wal_bytes_with_options(&bytes, &opts); + // Stream the WAL one line at a time so peak memory stays flat + // regardless of total size: the verifier holds only the running + // chain state (one line buffer plus a few hashes), not the WAL. + // process_line returns true under fail-fast to stop early. + let mut verifier = LenientVerifier::new(&opts, policy); + for_each_wal_line(wal_path, |line| verifier.process_line(line))?; + let mut result = verifier.finish(); maybe_add_profile_hint(&mut result); emit_report(&result, output_path, format)?; @@ -328,6 +369,9 @@ fn print_text_report(result: &VerificationResult) { } println!("Events verified: {}", result.events_verified); println!("Signatures verified: {}", result.signatures_verified); + if result.signatures_skipped > 0 { + println!("Signatures skipped: {}", result.signatures_skipped); + } println!("Receipts verified: {}", result.receipts_verified); if let (Some(first), Some(last)) = (result.first_sequence, result.last_sequence) { println!("Sequence range: {first}..={last}"); diff --git a/spine-cli/src/wal_io.rs b/spine-cli/src/wal_io.rs index 9e7313a..28533c0 100644 --- a/spine-cli/src/wal_io.rs +++ b/spine-cli/src/wal_io.rs @@ -10,6 +10,7 @@ //! enumeration without forcing the verifier core to recompile. use std::fs; +use std::io::{BufRead, BufReader}; use std::path::{Path, PathBuf}; use thiserror::Error; @@ -146,6 +147,62 @@ pub fn read_wal_bytes(dir: &Path) -> Result, WalIoError> { Ok(buf) } +/// Stream every line of every WAL segment under `dir` to `visit`, in +/// segment-then-line order, without ever holding more than one line (plus +/// the read buffer) in memory. +/// +/// This is the constant-memory counterpart to [`read_wal_bytes`]: where +/// that helper concatenates the entire WAL into one `Vec` (peak memory +/// scales with the WAL size, which does not work for a multi-gigabyte +/// production WAL), this walks segments with a `BufReader` and a single +/// reusable line buffer, so peak memory is flat regardless of total size. +/// +/// Each line is passed WITHOUT its trailing `\n` (a trailing `\r` is left +/// in place; the verifier tolerates it). `visit` returns `true` to stop +/// early, which the lenient verifier uses for fail-fast. +/// +/// Segment boundaries match [`read_wal_bytes`]: each segment's last line is +/// kept separate from the next segment's first line, so a producer that +/// omits the final newline on a segment cannot cause two records to merge. +pub fn for_each_wal_line(dir: &Path, mut visit: F) -> Result<(), WalIoError> +where + F: FnMut(&[u8]) -> bool, +{ + let segments = collect_wal_segments(dir)?; + let mut line = Vec::new(); + for seg in &segments { + let file = fs::File::open(seg).map_err(|e| WalIoError::Io { + path: seg.display().to_string(), + source: e, + })?; + let mut reader = BufReader::new(file); + loop { + line.clear(); + let read = reader + .read_until(b'\n', &mut line) + .map_err(|e| WalIoError::Io { + path: seg.display().to_string(), + source: e, + })?; + if read == 0 { + break; // end of this segment + } + // `read_until` includes the delimiter; strip the trailing `\n` + // so the slice matches what `bytes.split(b'\n')` yields on the + // buffered path. + let end = if line.last() == Some(&b'\n') { + line.len() - 1 + } else { + line.len() + }; + if visit(&line[..end]) { + return Ok(()); + } + } + } + Ok(()) +} + /// Total byte size of every WAL segment under `dir`. Useful for the /// `inspect --stats` summary without requiring the bytes themselves /// to be held in memory. diff --git a/spine-cli/tests/cli_smoke.rs b/spine-cli/tests/cli_smoke.rs index cd16ab5..390096d 100644 --- a/spine-cli/tests/cli_smoke.rs +++ b/spine-cli/tests/cli_smoke.rs @@ -129,6 +129,41 @@ fn write_strict_wal(dir: &Path) -> (String, String) { (pubkey_hex, root) } +/// Build `n` correctly-chained, lenient-signed records (Ed25519 over the +/// un-prefixed sign hash, the contract the default lenient verifier +/// checks). Returns the serialized JSONL lines and the pubkey hex so a +/// test can split them across segments however it likes. +fn signed_lenient_lines(n: u64) -> (Vec, String) { + let signing = SigningKey::from_bytes(&[9u8; 32]); + let pubkey_hex = hex::encode(signing.verifying_key().to_bytes()); + let mut prev = GENESIS_PREV_HASH.to_string(); + let mut ts: i64 = 1_700_000_000_000_000_000; + let mut out = Vec::new(); + for seq in 1..=n { + ts += 1_000_000_000; + let payload = format!("payload-{seq}"); + let payload_hash = hex::encode(blake3::hash(payload.as_bytes()).as_bytes()); + let mut e = entry(seq, ts, &prev, &payload_hash); + // Sign hash ignores signature/public_key, so sign before setting them. + let sign_hash_hex = compute_entry_hash_for_signing(&e); + e.signature = Some(hex::encode( + signing.sign(sign_hash_hex.as_bytes()).to_bytes(), + )); + e.public_key = Some(pubkey_hex.clone()); + prev = compute_entry_hash(&e); + out.push(serde_json::to_string(&e).expect("entry should serialize")); + } + (out, pubkey_hex) +} + +/// Write a 4-entry lenient-signed WAL into `dir` as a single segment. +fn write_signed_lenient_wal(dir: &Path) { + let (lines, _pubkey) = signed_lenient_lines(4); + let mut body = lines.join("\n"); + body.push('\n'); + std::fs::write(dir.join("00000001.jsonl"), body).expect("signed wal should write"); +} + /// A non-valid strict record matching `outcome` and (optionally) /// `reason.kind`, if present in a strict JSON report. fn find_strict_record<'a>(report: &'a Value, outcome: &str, kind: &str) -> Option<&'a Value> { @@ -713,3 +748,175 @@ fn export_syslog_escapes_unicode_line_separators() { "raw U+2028 must not survive in the output" ); } + +#[test] +fn verify_chain_only_passes_and_warns_about_skipped_signatures() { + // --chain-only walks the chain but verifies no signatures. On a + // valid chain it passes (exit 0) and must warn that signatures were + // not checked, so a green run can't be mistaken for a full verify. + let dir = tempfile::tempdir().expect("tempdir"); + write_signed_lenient_wal(dir.path()); + + let out = run(&[ + "verify", + "--wal", + path_str(dir.path()), + "--chain-only", + "--format", + "json", + ]); + assert_eq!(code(&out), 0); + + let report: Value = serde_json::from_str(&stdout(&out)).expect("report should be JSON"); + assert_eq!(report["valid"], true); + assert_eq!(report["signatures_verified"], 0); + assert_eq!(report["signatures_skipped"], 4); + let warnings = report["warnings"].as_array().expect("warnings array"); + assert!( + warnings + .iter() + .any(|w| w.as_str().is_some_and(|s| s.contains("chain-only"))), + "chain-only must warn that signatures were not verified: {warnings:?}" + ); +} + +#[test] +fn verify_chain_only_still_detects_a_chain_break() { + // Skipping signatures must NOT weaken chain tamper-evidence: a broken + // prev_hash link still fails the run. + let dir = tempfile::tempdir().expect("tempdir"); + let e1 = json!({ + "sequence": 1, + "timestamp_ns": 1_700_000_000_000_000_000i64, + "prev_hash": GENESIS_PREV_HASH, + "payload_hash": "ab".repeat(32), + }); + let e2 = json!({ + "sequence": 2, + "timestamp_ns": 1_700_000_001_000_000_000i64, + "prev_hash": "ff".repeat(32), + "payload_hash": "cd".repeat(32), + }); + std::fs::write(dir.path().join("00000001.jsonl"), format!("{e1}\n{e2}\n")).expect("write wal"); + + let out = run(&[ + "verify", + "--wal", + path_str(dir.path()), + "--chain-only", + "--format", + "json", + ]); + assert_eq!( + code(&out), + 1, + "a broken chain must fail even under chain-only" + ); + let report: Value = serde_json::from_str(&stdout(&out)).expect("report should be JSON"); + assert_eq!(report["valid"], false); +} + +#[test] +fn verify_chain_only_conflicts_with_pin_and_sample() { + let dir = wal_dir(); + let pin = "ab".repeat(32); + + let with_pin = run(&[ + "verify", + "--wal", + path_str(dir.path()), + "--chain-only", + "--trusted-pubkey", + &pin, + ]); + assert_eq!( + code(&with_pin), + 2, + "--chain-only with --trusted-pubkey is a usage error" + ); + + let with_sample = run(&[ + "verify", + "--wal", + path_str(dir.path()), + "--chain-only", + "--sample-signatures", + "2", + ]); + assert_eq!( + code(&with_sample), + 2, + "--chain-only with --sample-signatures is a usage error" + ); +} + +#[test] +fn verify_sample_signatures_checks_only_the_sampled_subset() { + let dir = tempfile::tempdir().expect("tempdir"); + write_signed_lenient_wal(dir.path()); // sequences 1..=4 + + let out = run(&[ + "verify", + "--wal", + path_str(dir.path()), + "--sample-signatures", + "2", + "--format", + "json", + ]); + assert_eq!(code(&out), 0); + + let report: Value = serde_json::from_str(&stdout(&out)).expect("report should be JSON"); + assert_eq!(report["valid"], true); + // Sequences 2 and 4 are multiples of 2; 1 and 3 are skipped. + assert_eq!(report["signatures_verified"], 2); + assert_eq!(report["signatures_skipped"], 2); + let warnings = report["warnings"].as_array().expect("warnings array"); + assert!( + warnings + .iter() + .any(|w| w.as_str().is_some_and(|s| s.contains("1-in-2"))), + "sampling must disclose its partial coverage: {warnings:?}" + ); +} + +#[test] +fn verify_sample_signatures_zero_is_a_usage_error() { + let dir = wal_dir(); + let out = run(&[ + "verify", + "--wal", + path_str(dir.path()), + "--sample-signatures", + "0", + ]); + assert_eq!(code(&out), 2, "--sample-signatures 0 is a usage error"); +} + +#[test] +fn verify_streams_across_segments_without_merging_records() { + // Streaming verification must keep segment boundaries: the last line + // of segment 1 (written WITHOUT a trailing newline) must not merge + // with the first line of segment 2. A full default verify over a + // signed chain split across two segments must pass. + let dir = tempfile::tempdir().expect("tempdir"); + let (lines, _pubkey) = signed_lenient_lines(4); + std::fs::write( + dir.path().join("00000001.jsonl"), + format!("{}\n{}", lines[0], lines[1]), // no trailing newline + ) + .expect("seg1 write"); + std::fs::write( + dir.path().join("00000002.jsonl"), + format!("{}\n{}\n", lines[2], lines[3]), + ) + .expect("seg2 write"); + + let out = run(&["verify", "--wal", path_str(dir.path()), "--format", "json"]); + assert_eq!(code(&out), 0, "stdout: {}", stdout(&out)); + + let report: Value = serde_json::from_str(&stdout(&out)).expect("report should be JSON"); + assert_eq!(report["valid"], true); + assert_eq!(report["events_verified"], 4); + assert_eq!(report["signatures_verified"], 4); +} diff --git a/spine-core/src/lib.rs b/spine-core/src/lib.rs index b53bb0f..ba62e40 100644 --- a/spine-core/src/lib.rs +++ b/spine-core/src/lib.rs @@ -45,8 +45,8 @@ pub use receipt::{ Receipt, ReceiptError, RECEIPT_DOMAIN_SEP, }; pub use verify::{ - verify_wal_bytes, verify_wal_bytes_with_options, LenientOptions, VerificationError, - VerificationResult, + verify_wal_bytes, verify_wal_bytes_with_options, LenientOptions, LenientVerifier, + SignaturePolicy, VerificationError, VerificationResult, }; pub use verify_demo::{ verify_demo_wal, DemoRecordEntry, DemoRecordOutcome, DemoReport, DemoStatus, InvalidReason, diff --git a/spine-core/src/verify.rs b/spine-core/src/verify.rs index 8bfa28e..86f1c8a 100644 --- a/spine-core/src/verify.rs +++ b/spine-core/src/verify.rs @@ -72,7 +72,7 @@ //! on every signed entry. //! //! Receipt failures observed inside the loop are translated to -//! [`VerificationError`] entries (via the internal `push_or_halt`) +//! [`VerificationError`] entries (via the internal `LenientVerifier::push`) //! rather than bubbling up as a dedicated error variant. Keystore //! loading itself lives in the CLI layer that calls //! [`Keystore::load_from_file`], which surfaces failures as @@ -98,6 +98,13 @@ pub struct VerificationResult { pub valid: bool, pub events_verified: u64, pub signatures_verified: u64, + /// Records that carried signature material but were NOT individually + /// verified because a reduced [`SignaturePolicy`] was in effect + /// (chain-only or sampling). Always `0` under the default + /// [`SignaturePolicy::All`]. A non-zero value means signature + /// coverage was deliberately partial; the result carries a warning + /// spelling out what that means for the threat model. + pub signatures_skipped: u64, pub receipts_verified: u64, pub chain_root: String, pub first_sequence: Option, @@ -126,6 +133,57 @@ pub struct VerificationError { pub details: String, } +/// How aggressively the lenient verifier checks per-record Ed25519 +/// signatures. +/// +/// Signature verification dominates the cost of a large WAL: walking +/// the hash chain and parsing JSON is roughly an order of magnitude +/// cheaper than verifying one Ed25519 signature per record. An auditor +/// who only needs chain-and-root integrity, or a routine spot-check, +/// can trade signature coverage for speed with this knob. +/// +/// It governs ONLY whether the Ed25519 math runs. The chain link, +/// sequence, timestamp, hash-format and `expected_root` checks always +/// run in full regardless of the policy: reducing signature coverage +/// never weakens the chain's own tamper-evidence. +/// +/// This policy is selected through [`LenientVerifier::new`]. The +/// buffered convenience entry points ([`verify_wal_bytes`], +/// [`verify_wal_bytes_with_options`]) always use [`SignaturePolicy::All`] +/// so the WASM playground and the published cross-language vectors keep +/// verifying every signature. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub enum SignaturePolicy { + /// Verify every signed record's signature. The default, and the + /// only policy that defends against a targeted forger. + #[default] + All, + /// Verify no signatures: walk the chain, sequence, timestamps and + /// root only. Fastest. Retains tamper-evidence only when paired + /// with an authenticated `expected_root`; without one it proves + /// internal self-consistency, nothing more. + None, + /// Verify one record in every `one_in` (those whose `sequence` is a + /// multiple of `one_in`). A routine spot-check for accidental + /// corruption or a wrong-key rollout, NOT a defense against a + /// targeted forger who can simply avoid the sampled positions. A + /// sampled signature that fails still fails the whole run. `one_in` + /// of `0` checks nothing (treated as "no sampling"). + Sample { one_in: u64 }, +} + +impl SignaturePolicy { + /// Whether the record at `sequence` should have its signature + /// verified under this policy. + fn should_check(self, sequence: u64) -> bool { + match self { + SignaturePolicy::All => true, + SignaturePolicy::None => false, + SignaturePolicy::Sample { one_in } => one_in != 0 && sequence.is_multiple_of(one_in), + } + } +} + /// Knobs for the lenient verifier. `Default` gives the accumulate-all /// behaviour with no keystore, no trusted pubkey pin, and no expected /// root. @@ -149,6 +207,7 @@ fn empty_result() -> VerificationResult { valid: true, events_verified: 0, signatures_verified: 0, + signatures_skipped: 0, receipts_verified: 0, chain_root: String::new(), first_sequence: None, @@ -173,40 +232,108 @@ pub fn verify_wal_bytes_with_options(bytes: &[u8], opts: &LenientOptions) -> Ver } fn verify_internal(bytes: &[u8], opts: &LenientOptions) -> VerificationResult { - let mut result = empty_result(); - - // Used by the trusted-pubkey gate. Decoded once so we don't pay - // the hex-decode cost per record. Bad input here lands in the - // result.warnings list and degrades to "no pin" semantics: the - // alternative is failing every record with a config error, but - // the warning path is friendlier for someone who fat-fingered - // the flag. - let trusted_pubkey_bytes: Option<[u8; 32]> = match opts.trusted_pubkey { - Some(s) => match decode_hex_32(s) { - Ok(b) => Some(b), - Err(e) => { - result.warnings.push(format!( - "trusted_pubkey is not a valid 32-byte hex string ({e}); falling back to record-declared pubkeys" - )); - None - } - }, - None => None, - }; - - let mut prev_entry: Option = None; - let mut prev_sequence: Option = None; - let mut prev_timestamp: Option = None; - let mut running_hash = Hasher::new(); - let mut receipts_seen: u64 = 0; - let mut signatures_unpinned: u64 = 0; - - 'outer: for (line_idx, line) in bytes.split(|&b| b == b'\n').enumerate() { + // The buffered entry points always verify every signature: the WASM + // playground and the published cross-language vectors depend on it. + // Streaming callers that want a reduced policy build a + // `LenientVerifier` directly. + let mut verifier = LenientVerifier::new(opts, SignaturePolicy::All); + for line in bytes.split(|&b| b == b'\n') { + if verifier.process_line(line) { + break; + } + } + verifier.finish() +} + +/// Incremental lenient verifier for streaming a WAL one line at a time. +/// +/// [`verify_wal_bytes`] / [`verify_wal_bytes_with_options`] materialise +/// the whole WAL as a byte slice, which is the right tool for the WASM +/// playground and small inputs. A multi-gigabyte production WAL does +/// not fit comfortably in memory, so the CLI feeds segments line by +/// line through this type: peak memory stays flat (one line buffer plus +/// the running chain state) instead of scaling with the WAL size. +/// +/// Both surfaces drive this exact state machine, so the streaming and +/// buffered paths can never silently diverge: `verify_internal` is just +/// a `split('\n')` loop over [`process_line`]. +/// +/// Usage: [`LenientVerifier::new`], then call [`process_line`] for each +/// line (stop early when it returns `true`, the fail-fast signal), then +/// [`finish`] to obtain the [`VerificationResult`]. +/// +/// [`process_line`]: LenientVerifier::process_line +/// [`finish`]: LenientVerifier::finish +pub struct LenientVerifier<'a> { + opts: LenientOptions<'a>, + policy: SignaturePolicy, + /// Decoded once in [`new`](LenientVerifier::new) so we do not pay + /// the hex-decode cost per record. A malformed pin degrades to "no + /// pin" semantics with a warning rather than failing every record. + trusted_pubkey_bytes: Option<[u8; 32]>, + result: VerificationResult, + prev_entry: Option, + prev_sequence: Option, + prev_timestamp: Option, + running_hash: Hasher, + receipts_seen: u64, + signatures_unpinned: u64, + /// 1-based index of the line most recently passed to + /// [`process_line`](LenientVerifier::process_line), used to quote + /// the offending line in `parse_error` details. Counts every line + /// fed (including skipped whitespace lines) so the number matches + /// the buffered path's `split('\n')` enumeration exactly. + line_counter: usize, +} + +impl<'a> LenientVerifier<'a> { + /// Build a verifier for `opts` under `policy`. No bytes are + /// processed yet; feed lines with + /// [`process_line`](LenientVerifier::process_line). + pub fn new(opts: &LenientOptions<'a>, policy: SignaturePolicy) -> Self { + let mut result = empty_result(); + let trusted_pubkey_bytes: Option<[u8; 32]> = match opts.trusted_pubkey { + Some(s) => match decode_hex_32(s) { + Ok(b) => Some(b), + Err(e) => { + result.warnings.push(format!( + "trusted_pubkey is not a valid 32-byte hex string ({e}); falling back to record-declared pubkeys" + )); + None + } + }, + None => None, + }; + Self { + opts: *opts, + policy, + trusted_pubkey_bytes, + result, + prev_entry: None, + prev_sequence: None, + prev_timestamp: None, + running_hash: Hasher::new(), + receipts_seen: 0, + signatures_unpinned: 0, + line_counter: 0, + } + } + + /// Process one line of the WAL. The line must NOT include its + /// trailing newline; a trailing `\r` is tolerated (CRLF WALs). + /// Whitespace-only lines are skipped. + /// + /// Returns `true` when the caller should stop feeding lines: this + /// happens only under [`LenientOptions::fail_fast`] after the first + /// failure. Under the default accumulate-all policy it always + /// returns `false`. + pub fn process_line(&mut self, line: &[u8]) -> bool { + self.line_counter += 1; + let line_num = self.line_counter; let line_trim_end = trim_trailing_cr(line); if line_trim_end.iter().all(|b| b.is_ascii_whitespace()) { - continue; + return false; } - let line_num = line_idx + 1; let entry: WalEntry = match serde_json::from_slice(line_trim_end) { Ok(e) => e, @@ -216,24 +343,21 @@ fn verify_internal(bytes: &[u8], opts: &LenientOptions) -> VerificationResult { error_type: "parse_error".to_string(), details: format!("line {line_num}: {e}"), }; - if push_or_halt(&mut result, err, opts.fail_fast) { - break 'outer; - } - continue; + return self.push(err); } }; - if result.first_sequence.is_none() { - result.first_sequence = Some(entry.sequence); - result.first_timestamp = Some(entry.timestamp_ns); + if self.result.first_sequence.is_none() { + self.result.first_sequence = Some(entry.sequence); + self.result.first_timestamp = Some(entry.timestamp_ns); } - result.last_sequence = Some(entry.sequence); - result.last_timestamp = Some(entry.timestamp_ns); + self.result.last_sequence = Some(entry.sequence); + self.result.last_timestamp = Some(entry.timestamp_ns); // Chain-link rule reused from wal_entry::verify_chain_link so // a future refactor can't silently fork the lenient path from // the canonical contract. - match verify_chain_link(&entry, prev_entry.as_ref()) { + match verify_chain_link(&entry, self.prev_entry.as_ref()) { HashVerification::Valid => {} HashVerification::InvalidGenesis { reason } => { let err = VerificationError { @@ -241,8 +365,8 @@ fn verify_internal(bytes: &[u8], opts: &LenientOptions) -> VerificationResult { error_type: "invalid_genesis".to_string(), details: reason, }; - if push_or_halt(&mut result, err, opts.fail_fast) { - break 'outer; + if self.push(err) { + return true; } } HashVerification::Mismatch { expected, actual } => { @@ -251,13 +375,13 @@ fn verify_internal(bytes: &[u8], opts: &LenientOptions) -> VerificationResult { error_type: "chain_break".to_string(), details: format!("expected prev_hash {expected}, found {actual}"), }; - if push_or_halt(&mut result, err, opts.fail_fast) { - break 'outer; + if self.push(err) { + return true; } } } - if let Some(prev_seq) = prev_sequence { + if let Some(prev_seq) = self.prev_sequence { // saturating_add: a hostile record can carry sequence = u64::MAX // (it fails the genesis check, but in accumulate-all mode the // chain still advances and records it as prev_sequence), and a @@ -273,117 +397,121 @@ fn verify_internal(bytes: &[u8], opts: &LenientOptions) -> VerificationResult { entry.sequence ), }; - if push_or_halt(&mut result, err, opts.fail_fast) { - break 'outer; + if self.push(err) { + return true; } } } - if let Some(prev_ts) = prev_timestamp { + if let Some(prev_ts) = self.prev_timestamp { if entry.timestamp_ns < prev_ts { let err = VerificationError { sequence: Some(entry.sequence), error_type: "timestamp_regression".to_string(), details: format!("timestamp {} < previous {prev_ts}", entry.timestamp_ns), }; - if push_or_halt(&mut result, err, opts.fail_fast) { - break 'outer; + if self.push(err) { + return true; } } } - match (entry.signature.as_deref(), entry.public_key.as_deref()) { - (Some(sig_hex), Some(pk_hex)) => { - if let Some(ref pin) = trusted_pubkey_bytes { - match decode_hex_32(pk_hex) { - Ok(pk_bytes) if pk_bytes.ct_eq(pin).unwrap_u8() == 1 => {} - _ => { + // The signature policy gates ONLY the Ed25519 math (the dominant + // per-record cost). Every other check above and below runs in + // full regardless, so a reduced policy never weakens the chain's + // tamper-evidence, only the signature coverage. + if self.policy.should_check(entry.sequence) { + match (entry.signature.as_deref(), entry.public_key.as_deref()) { + (Some(sig_hex), Some(pk_hex)) => { + if let Some(ref pin) = self.trusted_pubkey_bytes { + match decode_hex_32(pk_hex) { + Ok(pk_bytes) if pk_bytes.ct_eq(pin).unwrap_u8() == 1 => {} + _ => { + let err = VerificationError { + sequence: Some(entry.sequence), + error_type: "untrusted_pubkey".to_string(), + details: "record public_key does not match trusted_pubkey pin" + .to_string(), + }; + if self.push(err) { + return true; + } + // Skip the signature math: it would either + // pass (and look "valid" under a wrong key) + // or fail (and surface as InvalidSignature, + // a misleading reason). Trusted-pubkey + // failures are reason enough on their own. + self.advance_chain(entry); + return false; + } + } + } + match verify_entry_signature(&entry, sig_hex, pk_hex) { + Ok(true) => { + self.result.signatures_verified += 1; + if self.trusted_pubkey_bytes.is_none() { + self.signatures_unpinned += 1; + } + } + Ok(false) | Err(()) => { let err = VerificationError { sequence: Some(entry.sequence), - error_type: "untrusted_pubkey".to_string(), - details: "record public_key does not match trusted_pubkey pin" - .to_string(), + error_type: "invalid_signature".to_string(), + details: "Ed25519 verification failed".to_string(), }; - if push_or_halt(&mut result, err, opts.fail_fast) { - break 'outer; + if self.push(err) { + return true; } - // Skip the signature math: it would either - // pass (and look "valid" under a wrong key) - // or fail (and surface as InvalidSignature, - // a misleading reason). Trusted-pubkey - // failures are reason enough on their own. - advance_chain( - &mut result, - &mut prev_entry, - &mut prev_sequence, - &mut prev_timestamp, - &mut running_hash, - entry, - ); - continue; } } } - match verify_entry_signature(&entry, sig_hex, pk_hex) { - Ok(true) => { - result.signatures_verified += 1; - if trusted_pubkey_bytes.is_none() { - signatures_unpinned += 1; - } - } - Ok(false) | Err(()) => { + (None, None) => { + // Lenient tolerates unsigned records by default, but when a + // trusted pubkey is pinned the operator is asserting that + // every record was signed by that key (see the + // --trusted-pubkey docs). An unsigned record violates that, + // so flag it instead of letting the gate pass. + if self.trusted_pubkey_bytes.is_some() { let err = VerificationError { sequence: Some(entry.sequence), - error_type: "invalid_signature".to_string(), - details: "Ed25519 verification failed".to_string(), + error_type: "unsigned_record".to_string(), + details: "record is unsigned but trusted_pubkey requires every record \ + to be signed by the pinned key" + .to_string(), }; - if push_or_halt(&mut result, err, opts.fail_fast) { - break 'outer; + if self.push(err) { + return true; } } } - } - (None, None) => { - // Lenient tolerates unsigned records by default, but when a - // trusted pubkey is pinned the operator is asserting that - // every record was signed by that key (see the - // --trusted-pubkey docs). An unsigned record violates that, - // so flag it instead of letting the gate pass. - if trusted_pubkey_bytes.is_some() { + _ => { + // Asymmetric: signature OR pubkey set, not both. + // Aligned with the strict verifier's terminology so + // downstream triage by error_type is uniform across + // the two profiles. The "the producer half-set the + // signature material" case is fundamentally an + // unsigned record, not a failed verification. let err = VerificationError { sequence: Some(entry.sequence), error_type: "unsigned_record".to_string(), - details: "record is unsigned but trusted_pubkey requires every record \ - to be signed by the pinned key" + details: "signature and public_key must both be present or both absent" .to_string(), }; - if push_or_halt(&mut result, err, opts.fail_fast) { - break 'outer; + if self.push(err) { + return true; } } } - _ => { - // Asymmetric: signature OR pubkey set, not both. - // Aligned with the strict verifier's terminology so - // downstream triage by error_type is uniform across - // the two profiles. The "the producer half-set the - // signature material" case is fundamentally an - // unsigned record, not a failed verification. - let err = VerificationError { - sequence: Some(entry.sequence), - error_type: "unsigned_record".to_string(), - details: "signature and public_key must both be present or both absent" - .to_string(), - }; - if push_or_halt(&mut result, err, opts.fail_fast) { - break 'outer; - } - } + } else if entry.signature.is_some() || entry.public_key.is_some() { + // A reduced policy (chain-only or sampling) skipped this + // record's signature math. Count it so finish() can be honest + // about how much coverage was actually achieved. + self.result.signatures_skipped += 1; } if let Some(alg) = entry.hash_alg.as_deref() { if alg != "blake3" { - result.warnings.push(format!( + self.result.warnings.push(format!( "sequence {}: hash_alg = {alg:?} but lenient verifier assumes blake3; \ payload integrity is unchecked", entry.sequence @@ -392,12 +520,12 @@ fn verify_internal(bytes: &[u8], opts: &LenientOptions) -> VerificationResult { } if entry.receipt.is_some() { - receipts_seen += 1; + self.receipts_seen += 1; } - if let Some(ks) = opts.keystore { + if let Some(ks) = self.opts.keystore { match verify_receipt_signature(&entry, ks) { - Ok(true) => result.receipts_verified += 1, + Ok(true) => self.result.receipts_verified += 1, Ok(false) => {} Err(err) => { let (etype, details) = match &err { @@ -429,8 +557,8 @@ fn verify_internal(bytes: &[u8], opts: &LenientOptions) -> VerificationResult { error_type: etype.to_string(), details, }; - if push_or_halt(&mut result, v_err, opts.fail_fast) { - break 'outer; + if self.push(v_err) { + return true; } } } @@ -442,102 +570,133 @@ fn verify_internal(bytes: &[u8], opts: &LenientOptions) -> VerificationResult { error_type: "invalid_hash_format".to_string(), details: msg, }; - if push_or_halt(&mut result, err, opts.fail_fast) { - break 'outer; + if self.push(err) { + return true; } } - advance_chain( - &mut result, - &mut prev_entry, - &mut prev_sequence, - &mut prev_timestamp, - &mut running_hash, - entry, - ); + self.advance_chain(entry); + false } - result.chain_root = hex::encode(running_hash.finalize().as_bytes()); + /// Finish verification and produce the report: compute the final + /// `chain_root`, run the `expected_root` gate, and append the + /// summary warnings (policy coverage, unpinned signatures, receipts + /// without a keystore). Consumes the verifier. + pub fn finish(mut self) -> VerificationResult { + self.result.chain_root = hex::encode(self.running_hash.finalize().as_bytes()); + + if self.result.events_verified == 0 { + self.result + .warnings + .push("No WAL records found".to_string()); + } - if result.events_verified == 0 { - result.warnings.push("No WAL records found".to_string()); - } + // Always check expected_root, even when no records were processed. + // An attacker who empties the segment directory must not be able + // to claim "valid: true" by virtue of producing zero records that + // also produce zero failures. + // + // A root that normalizes to empty (whitespace-only, or a bare `0x`) + // is treated as "no anchor supplied", matching the wasm facade so + // the same operator input is verified identically on the CLI and in + // the browser. + let normalized_root = self + .opts + .expected_root + .map(crate::normalize_hex_anchor) + .filter(|s| !s.is_empty()); + if let Some(normalized) = normalized_root { + if self.result.chain_root != normalized { + let computed = self.result.chain_root.clone(); + let err = VerificationError { + sequence: None, + error_type: "root_mismatch".to_string(), + details: format!("expected {normalized}, computed {computed}"), + }; + // Root mismatch always accumulates (never honors fail_fast): + // it is the single most important verdict and must surface + // even on a fail-fast run that already halted earlier. + self.result.valid = false; + self.result.errors.push(err); + } + } else if self.result.events_verified > 0 { + self.result.warnings.push( + "No expected root provided: verified internal consistency only. \ + For full tamper-detection, compare chain_root against an external anchor." + .to_string(), + ); + } - // Always check expected_root, even when no records were processed. - // An attacker who empties the segment directory must not be able - // to claim "valid: true" by virtue of producing zero records that - // also produce zero failures. - // - // A root that normalizes to empty (whitespace-only, or a bare `0x`) is - // treated as "no anchor supplied", matching the wasm facade so the same - // operator input is verified identically on the CLI and in the browser. - let normalized_root = opts - .expected_root - .map(crate::normalize_hex_anchor) - .filter(|s| !s.is_empty()); - if let Some(normalized) = normalized_root { - if result.chain_root != normalized { - let computed = result.chain_root.clone(); - let err = VerificationError { - sequence: None, - error_type: "root_mismatch".to_string(), - details: format!("expected {normalized}, computed {computed}"), - }; - push_or_halt(&mut result, err, false); + // Be explicit about reduced signature coverage so a green run + // under a reduced policy can never be mistaken for a full + // signature verification. + match self.policy { + SignaturePolicy::All => {} + SignaturePolicy::None => { + if self.result.events_verified > 0 { + self.result.warnings.push( + "Signatures were NOT verified (chain-only policy). Chain linkage, \ + sequence, timestamps and root were checked; per-record signatures were \ + not. Provide an authenticated expected_root for tamper-evidence, or run \ + full verification to check every signature." + .to_string(), + ); + } + } + SignaturePolicy::Sample { one_in } => { + self.result.warnings.push(format!( + "Sampled signature verification (1-in-{one_in}): {} signatures checked, {} \ + signed records left unchecked. Sampling is a routine spot-check for \ + accidental corruption, NOT a defense against a targeted forger who can avoid \ + the sampled positions. Use full verification or cryptographic inclusion \ + proofs for adversarial completeness.", + self.result.signatures_verified, self.result.signatures_skipped + )); + } } - } else if result.events_verified > 0 { - result.warnings.push( - "No expected root provided: verified internal consistency only. \ - For full tamper-detection, compare chain_root against an external anchor." - .to_string(), - ); - } - if opts.trusted_pubkey.is_none() && signatures_unpinned > 0 { - result.warnings.push(format!( - "{signatures_unpinned} signatures were verified against record-declared pubkeys (no external pin). \ - Pass --trusted-pubkey on the CLI to require an externally pinned key, \ - or use the strict verifier (spine_core::verify_demo_wal)." - )); - } + if self.opts.trusted_pubkey.is_none() && self.signatures_unpinned > 0 { + self.result.warnings.push(format!( + "{} signatures were verified against record-declared pubkeys (no external pin). \ + Pass --trusted-pubkey on the CLI to require an externally pinned key, \ + or use the strict verifier (spine_core::verify_demo_wal).", + self.signatures_unpinned + )); + } - if opts.keystore.is_none() && receipts_seen > 0 { - result.warnings.push(format!( - "{receipts_seen} records carry server receipts but no keystore was supplied. \ - Pass --keystore on the CLI to verify receipt signatures." - )); - } + if self.opts.keystore.is_none() && self.receipts_seen > 0 { + self.result.warnings.push(format!( + "{} records carry server receipts but no keystore was supplied. \ + Pass --keystore on the CLI to verify receipt signatures.", + self.receipts_seen + )); + } - result -} + self.result + } -fn advance_chain( - result: &mut VerificationResult, - prev_entry: &mut Option, - prev_sequence: &mut Option, - prev_timestamp: &mut Option, - running_hash: &mut Hasher, - entry: WalEntry, -) { - let entry_hash = compute_entry_hash(&entry); - running_hash.update(entry_hash.as_bytes()); - *prev_sequence = Some(entry.sequence); - *prev_timestamp = Some(entry.timestamp_ns); - *prev_entry = Some(entry); - result.events_verified += 1; -} + /// Push `err` into the result and return `true` when the caller + /// should stop feeding lines (fail-fast). Always sets + /// `result.valid = false`. + fn push(&mut self, err: VerificationError) -> bool { + self.result.valid = false; + self.result.errors.push(err); + if self.opts.fail_fast { + self.result.halted_early = true; + true + } else { + false + } + } -/// Push `err` into the result and return `true` when the caller -/// should break out of the per-record loop (fail-fast). Always sets -/// `result.valid = false`. -fn push_or_halt(result: &mut VerificationResult, err: VerificationError, fail_fast: bool) -> bool { - result.valid = false; - result.errors.push(err); - if fail_fast { - result.halted_early = true; - true - } else { - false + fn advance_chain(&mut self, entry: WalEntry) { + let entry_hash = compute_entry_hash(&entry); + self.running_hash.update(entry_hash.as_bytes()); + self.prev_sequence = Some(entry.sequence); + self.prev_timestamp = Some(entry.timestamp_ns); + self.prev_entry = Some(entry); + self.result.events_verified += 1; } } @@ -1006,4 +1165,145 @@ mod tests { // Lenient does not fail receipts without a keystore, but warns. assert!(r.warnings.iter().any(|w| w.contains("receipts"))); } + + /// Drive the streaming `LenientVerifier` one line at a time, the way + /// the CLI feeds it from disk. + fn run_streaming( + bytes: &[u8], + opts: &LenientOptions, + policy: SignaturePolicy, + ) -> VerificationResult { + let mut v = LenientVerifier::new(opts, policy); + for line in bytes.split(|&b| b == b'\n') { + if v.process_line(line) { + break; + } + } + v.finish() + } + + /// Build a correctly-chained, fully-signed WAL. The signature covers + /// `prev_hash`, so `prev_hash` is set BEFORE signing and the full + /// entry hash (which folds in the signature) is taken AFTER, to link + /// the next record. This mirrors the production signing order. + fn build_signed_chain(n: u64, key: &SigningKey) -> Vec { + let pk_hex = hex::encode(key.verifying_key().to_bytes()); + let mut entries = Vec::new(); + let mut prev = GENESIS_PREV_HASH.to_string(); + for i in 1..=n { + let mut e = make_entry(i, 1000 * i as i64, &prev, &format!("payload{i}")); + let msg = compute_entry_hash_for_signing(&e); + e.signature = Some(hex::encode(key.sign(msg.as_bytes()).to_bytes())); + e.public_key = Some(pk_hex.clone()); + prev = compute_entry_hash(&e); + entries.push(e); + } + entries + } + + #[test] + fn streaming_matches_buffered_on_signed_chain() { + // The streaming verifier and the buffered byte API share one + // state machine; feeding line by line must yield an identical + // verdict, root and counters. + let signing_key = SigningKey::from_bytes(&[0x21; 32]); + let entries = build_signed_chain(4, &signing_key); + let bytes = to_jsonl(&entries); + + let buffered = verify_wal_bytes(&bytes); + let streamed = run_streaming(&bytes, &LenientOptions::default(), SignaturePolicy::All); + + assert_eq!(buffered.valid, streamed.valid); + assert_eq!(buffered.events_verified, streamed.events_verified); + assert_eq!(buffered.signatures_verified, streamed.signatures_verified); + assert_eq!(buffered.signatures_skipped, streamed.signatures_skipped); + assert_eq!(buffered.chain_root, streamed.chain_root); + assert_eq!(buffered.errors.len(), streamed.errors.len()); + assert!(streamed.valid, "errors: {:?}", streamed.errors); + assert_eq!(streamed.signatures_verified, 4); + assert_eq!(streamed.signatures_skipped, 0); + } + + #[test] + fn chain_only_skips_signatures_but_still_checks_the_chain() { + let signing_key = SigningKey::from_bytes(&[0x22; 32]); + let entries = build_signed_chain(3, &signing_key); + let bytes = to_jsonl(&entries); + + let r = run_streaming(&bytes, &LenientOptions::default(), SignaturePolicy::None); + assert!( + r.valid, + "chain-only over a valid chain passes: {:?}", + r.errors + ); + assert_eq!( + r.signatures_verified, 0, + "chain-only verifies no signatures" + ); + assert_eq!(r.signatures_skipped, 3, "all 3 signed records were skipped"); + assert!(r + .warnings + .iter() + .any(|w| w.contains("chain-only") || w.contains("NOT verified"))); + + // Chain integrity is still enforced: break a link and chain-only + // must catch it even though signatures are off. + let mut tampered = entries.clone(); + tampered[1].prev_hash = "f".repeat(64); + let bytes2 = to_jsonl(&tampered); + let r2 = run_streaming(&bytes2, &LenientOptions::default(), SignaturePolicy::None); + assert!(!r2.valid); + assert!(r2.errors.iter().any(|e| e.error_type == "chain_break")); + } + + #[test] + fn sample_signatures_verifies_only_the_sampled_subset() { + let signing_key = SigningKey::from_bytes(&[0x23; 32]); + let entries = build_signed_chain(6, &signing_key); + let bytes = to_jsonl(&entries); + + let r = run_streaming( + &bytes, + &LenientOptions::default(), + SignaturePolicy::Sample { one_in: 3 }, + ); + assert!(r.valid, "errors: {:?}", r.errors); + // sequences 3 and 6 are multiples of 3. + assert_eq!(r.signatures_verified, 2); + assert_eq!(r.signatures_skipped, 4); + assert!(r.warnings.iter().any(|w| w.contains("1-in-3"))); + } + + #[test] + fn sample_does_not_check_an_unsampled_tampered_signature() { + // Tamper the LAST record's signature (no successor, so no chain + // break). Under full verification it is an invalid_signature; + // under sampling that skips it, the run stays valid. This is the + // honest, documented cost of sampling: partial coverage. + let signing_key = SigningKey::from_bytes(&[0x24; 32]); + let mut entries = build_signed_chain(3, &signing_key); + // Corrupt seq 3's signature to a valid-length but wrong value. + entries[2].signature = Some("0".repeat(128)); + let bytes = to_jsonl(&entries); + + let full = run_streaming(&bytes, &LenientOptions::default(), SignaturePolicy::All); + assert!(!full.valid, "full verification catches the bad signature"); + assert!(full + .errors + .iter() + .any(|e| e.error_type == "invalid_signature")); + + // one_in = 2 samples seq 2 only; seq 3 is never checked. + let sampled = run_streaming( + &bytes, + &LenientOptions::default(), + SignaturePolicy::Sample { one_in: 2 }, + ); + assert!( + sampled.valid, + "sampling skips seq 3's signature: {:?}", + sampled.errors + ); + assert_eq!(sampled.signatures_verified, 1); + } }