feat(audit): detect chain truncation and add buzz-admin audit verify#2715
feat(audit): detect chain truncation and add buzz-admin audit verify#2715SeanGearin wants to merge 1 commit into
buzz-admin audit verify#2715Conversation
|
Just saw #2715 — adopting Surfaced 113 of these on one of our own chains this week when a streaming offline verifier caught up on chain history that predated our fix. Trigger was HTTP webhook writes hitting append in parallel with active sessions. Fix on our side: |
|
Good call to check — it's covered, though not by the sequence pass (you're right that a seq-only walk would wave it through). So for the shape you described — seq N and N+1 both carrying N-1's The 113-on-a-live-chain number is great validation that it's worth catching — and nice call on |
buzz-audit's per-community SHA-256 hash chain had no operational verifier -- verify_chain was only ever called from tests, even though the conformance notes describe audit reads as "operator-internal (consumed by buzz-admin)" -- and the verifier accepted truncated chains: it seeded expected_prev = None, so a chain with its earliest entries deleted still verified; mid-range verifies never linked the first row to the row before the range; and nothing reported coverage against an expected head, so deleting the newest rows was undetectable. Harden the walk and give operators a surface: - verify_entries (pub, pure): the chain's explicit seq-contiguity pass. A single uniform walk that enumerates entries in seq order and asserts (1) seq is contiguous and (2) prev_hash == the previous entry's hash, then recomputes each stored hash. Prefix truncation, interior deletion, and (via verify_full_chain's head check) suffix truncation all fail as breaks in that one pass, the same way tampered entries do -- no per-case detection path. Unit-tested without Postgres. This seq-contiguity framing was suggested by @SaravananJaichandar on block#2620. - verify_chain: same signature, now anchored. from_seq <= 1 asserts genesis linkage (seq 1, NULL prev_hash); from_seq > 1 requires the from_seq-1 row to exist, match its own stored hash, and be linked to by the first row in range. - verify_full_chain: pages genesis-to-head and returns {entries_verified, head_seq, head_hash}; passing a previously recorded head as expected_head_seq applies the same "seq must reach where it should" rule to the tail, turning suffix truncation -- invisible to any stored-rows walk by construction -- into a hard error. - New seq-only AuditError variants (MissingGenesis, MissingAnchor, SequenceGap, TruncatedTail) name which edge of the contiguity pass broke; they are diagnostic labels over the one check, wired into the existing error-text sanitization fence so no community_id or constraint name can leak. - buzz-admin audit verify [--expect-head N]: resolves the tenant via the RELAY_URL host map like every other buzz-admin command, prints a JSON report, exits 0 verified / 1 integrity failure / 5 operational error. buzz-admin already declared the buzz-audit dependency; this is its first real use. Deliberately no new HTTP endpoint: the absence of an audit wire surface is a documented isolation property. - Wire buzz-audit into the unit-test gates: just test-unit gains cargo nextest run -p buzz-audit --lib, and the run-tests.sh fallback gains the matching cargo test step (the two crate lists mirror each other). The Postgres-backed buzz-audit tests are #[ignore]d, so the step stays infra-free -- same shape as the existing buzz-db gate. Without it the new pure chain-walk tests would compile in CI but never execute. ARCHITECTURE.md's buzz-admin subcommand table documents the new command. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Signed-off-by: Sean Gearin <sgearin@gmail.com>
dbab494 to
efeecff
Compare
|
#2638 landing fixes the The interaction is a little sharper than "the Postgres tests would have failed": @SaravananJaichandar — one correction to something I wrote on #2620: this CLI's exit codes are 0 verified / 1 integrity failure / 5 operational error, and the external anchor is |
Problem
buzz-auditimplements a real per-community SHA-256 hash chain, but the"tamper-evident" claim was a property of the schema, not of operations:
verify_chainhad no operational caller. It is invoked only fromtests. The conformance notes
(
crates/buzz-test-client/tests/conformance_multitenant.rs, audit gate)describe audit reads as "operator-internal (consumed by
buzz-admin)" —but
buzz-adminnever actually consumed them (it declares thebuzz-auditdependency and never uses it). Nothing deployed could verifya chain.
The verifier accepted truncated chains.
verify_chain(c, 1, N)seededexpected_prev = None, so the first row was never checked to be thegenesis.
DELETE ... WHERE seq <= kleft a suffix that verified clean.verify_chain(c, 5, 10)never compared row 5'sprev_hashagainst row 4's stored hash — the left edge of anymid-chain range was unchecked.
should exist. Deleting the newest rows is invisible to any walk over
stored rows, because the head is defined by what is stored.
ChainViolation(misdiagnosed as mutation rather than deletion).
Fix
buzz-audit— boundary hardening, no schema or wire changes:verify_entries(new,pub, pure): the chain's seq-contiguity pass.A single uniform walk that enumerates entries in
seqorder and asserts,per entry, that (1)
seqis contiguous and (2)prev_hashequals theprevious entry's
hash, then recomputes each stored hash. Prefixtruncation (front edge), interior deletion (mid-run), and — via
verify_full_chain's head check — suffix truncation (the sequence endingshort) all fail as breaks in that one pass, the same way tampered entries
do, with no per-case detection path. Being pure, it is unit-tested without
Postgres and usable by callers that fetched entries through another read
path. This framing — seq-contiguity as one explicit named pass, so both
truncation ends surface as seq gaps — is the cleaner one @SaravananJaichandar
named on buzz-audit: hash chain is tamper-evident in schema but not operationally verifiable; verify_chain accepts truncated chains #2620; adopting it here.
verify_chain: same signature, now anchored.from_seq <= 1assertsgenesis linkage;
from_seq > 1requires thefrom_seq - 1entry toexist (append-only chains have no legitimate holes), match its own stored
hash, and be linked to by the first entry in range.
verify_full_chain(new): the operational entry point. Pages through thewhole chain and returns
ChainVerification { entries_verified, head_seq, head_hash }. Passing a previously recorded head asexpected_head_seqapplies the same "seq must reach where it should" rule to the tail,
closing the tail-truncation hole — the one deletion class a stored-rows
walk cannot see by construction.
AuditErrorvariants —MissingGenesis,MissingAnchor,SequenceGap,TruncatedTail— name which edge of the contiguity passbroke. They are diagnostic labels over the one check (not separate
detection paths), carry per-community
seqvalues only, and are added tothe existing error-text sanitization test (no
community_id/constraintleakage, same fence as before).
buzz-admin— the operator surface that makes the claim operational:buzz-admin audit verify [--expect-head N]resolves the deployment'scommunity exactly like every other
buzz-admincommand (RELAY_URLhost→ durable host map, fail-closed) and verifies the full chain. JSON report
on stdout:
{ "ok": true, "community": "3f0f…", "entries_verified": 12873, "head_seq": 12873, "head_hash": "9a41…" }Exit codes:
0chain verified,1integrity failure (JSON verdict withthe reason),
5operational error. Recordhead_seq/head_hashexternally after each run and pass the seq back via
--expect-headonthe next run to make tail truncation detectable.
Deliberately not a new HTTP endpoint: the conformance notes treat the
absence of an audit wire surface as an isolation property ("the oracle's
surface does not exist"), and CONTRIBUTING discourages new
endpoint-specific JSON APIs. The operator command reads Postgres directly
on the same plane as the rest of
buzz-admin, constructing a smalldedicated audit pool the same way
buzz-relay'smain.rsdoes.CI and docs wiring:
just test-unitand thescripts/run-tests.shfallback gain a
buzz-audit --libstep so the new pure tests execute inCI rather than merely compiling (details under Test evidence), and
ARCHITECTURE.md'sbuzz-adminsubcommand table documentsaudit verify.Test evidence
All commands below were run this session against HEAD
(
3c14b6a), hermit toolchain, scoped to the two crates.cargo-nextestis not installed in this dev sandbox (error: no such command: nextest), so the unit gate was run with the documentedcargo testfallback. CI runs it under nextest via thejust test-unitrecipe added here.
The seven
verify_entries_*tests are the first coverage of the verifierthat runs without Postgres — each builds a real hash chain in memory and
exercises one edge of the seq-contiguity pass: a valid genesis run, prefix
truncation (front edge), a re-rooted genesis, an interior gap, content
tamper (with and without hash recomputation), anchored mid-chain segments,
and suffix truncation (back edge — a deleted tail leaves a shorter but
still-contiguous run, so it passes
verify_entriesand needs the recordedhead, which is what motivates
verify_full_chain's--expect-headcheck).Ten
#[ignore = "requires Postgres"]tests are counted asignoredabove;four of them are new and drive the same detections through the service
against real
DELETEs:verify_detects_deleted_genesis_prefix,verify_anchors_mid_chain_segments,verify_detects_interior_deletion,and
full_chain_report_and_tail_truncation(which also does thereport/
--expect-headround trip: record the head, delete the tail,re-verify against the recorded seq →
TruncatedTail). They compile underclippy --all-targetsbut were not executed here (no Docker/Postgres in thesandbox). Run them like the existing ones:
DATABASE_URL=... cargo test -p buzz-audit -- --ignored.buzz-auditwas absent from thejust test-unitcrate list, so the newpure tests would have compiled in CI (workspace clippy) but never executed.
This PR adds
cargo nextest run -p buzz-audit --libto thetest-unitrecipe (and the matching
cargo test -p buzz-audit --libstep to thescripts/run-tests.shfallback, keeping the two lists in sync) — same shapeas the existing
buzz-db --libgate: the Postgres-backed tests are#[ignore]d, so--libstays infra-free.CLI smoke (debug binary built this session, exit codes captured):
Compatibility
verify_chainsignature unchanged; all in-repo callers verifygenesis-rooted chains and keep passing.
AuditErrorgains variants; there are no exhaustive matches outsidetests.
/api/*routes, no wire-format changes, no newworkspace dependencies (
buzz-adminalready declaredbuzz-audit).entries are written, not the chain invariants this verifies.
created_athashing-precision determinism bug trackedin buzz-audit: every hash chain fails verification — created_at is hashed at nanosecond precision but stored at microsecond precision #2637 (fixes in flight: fix(audit): hash created_at at the precision Postgres stores #2638, fix(buzz-audit): truncate created_at to microseconds before hashing #2678). This PR does not touch
compute_hash; it adds the verifier and its operator surface. Note theinteraction:
audit verifyrecomputes each stored hash, so its usefulnessagainst real Postgres rows depends on
compute_hashbeing deterministicagainst the stored
created_at— the property buzz-audit: every hash chain fails verification — created_at is hashed at nanosecond precision but stored at microsecond precision #2637 reports as currentlybroken and fix(audit): hash created_at at the precision Postgres stores #2638/fix(buzz-audit): truncate created_at to microseconds before hashing #2678 fix. The pure
verify_entriestests build in-memorychains and are unaffected; the
#[ignore]d Postgres tests were not runhere, so this PR makes no claim about the round-trip until that fix lands.
Closes #2620