Skip to content

buzz-audit: hash chain is tamper-evident in schema but not operationally verifiable; verify_chain accepts truncated chains #2620

Open

Description

@SeanGearin

The gap

buzz-audit implements a real per-community SHA-256 hash chain, but "tamper-evident" is currently a property of the schema, not of operations:

  1. verify_chain has no operational caller. It's invoked only from tests. The conformance notes (crates/buzz-test-client/tests/conformance_multitenant.rs:2686) describe audit reads as "operator-internal (consumed by buzz-admin)" — but buzz-admin declares the buzz-audit dependency and never uses it. Nothing deployed can actually verify a chain.

  2. The verifier accepts truncated chains:

    • Prefix truncation: verify_chain(c, 1, N) seeds expected_prev = None, so the first row is never checked to be genesis — DELETE ... WHERE seq <= k leaves a suffix that verifies clean.
    • Unanchored segments: verify_chain(c, 5, 10) never compares row 5's prev_hash against row 4's stored hash; the left edge of a mid-chain range is unchecked.
    • Tail truncation: nothing records what the walk covered vs. what should exist, so deleting the newest rows is invisible to a walk over stored rows (the head is defined by what's stored).
    • Interior deletions surface as a generic ChainViolation, misdiagnosed as mutation rather than deletion.

So the strongest thing the docs claim — a tamper-evident audit chain — isn't checkable by anything an operator can run, and several deletion classes verify clean.

Proposed approach (no schema or wire changes)

  • A pure verify_entries (shared chain walk with an explicit left edge — genesis or an anchor hash — plus seq contiguity, prev-hash linkage, per-entry hash recompute), unit-testable without Postgres.
  • verify_chain keeps its signature but becomes anchored (from_seq > 1 requires the from_seq-1 entry to exist and be linked).
  • A new verify_full_chain returning {entries_verified, head_seq, head_hash}; passing a previously-recorded head as --expect-head closes the tail-truncation hole.
  • New AuditError variants (MissingGenesis, MissingAnchor, SequenceGap, TruncatedTail), carrying seq values only (same error-text sanitization fence as today).
  • A buzz-admin audit verify [--expect-head N] command reading Postgres directly on the same plane as the rest of buzz-admin — deliberately not a new HTTP endpoint, since the conformance notes treat the absence of an audit wire surface as an isolation property and CONTRIBUTING discourages new endpoint-specific JSON APIs.

No migrations, no /api/* routes, no wire-format changes, no new dependencies.

Status

Implemented and tested: six new Postgres-free verify_entries_* unit tests (each builds a real chain and mutilates it — prefix deletion, re-rooted genesis, interior deletion, content tamper, bad/skipped anchors), four #[ignore] Postgres integration tests, clippy-clean on both crates, wired into just test-unit.

Per CONTRIBUTING's "for significant changes, open an issue first to discuss the approach," I'm holding the PR pending your nod rather than dropping ~600 lines of new security-surface code on you unsolicited. Two things I'd want your read on first:

  • Is the buzz-admin operator-command surface (rather than any wire endpoint) the shape you want?
  • The externally-recorded-head --expect-head approach for tail-truncation detection — acceptable, or would you prefer the head anchor persisted in-repo?

Happy to open the PR immediately if the direction sounds right.

Activity

  1. SaravananJaichandar commented on Jul 24, 2026

    @SaravananJaichandar

    Ran into the same class of gap when we shipped a standalone reference verifier for our own agent audit chain. The "verify_chain has no operational caller" bit is particularly subtle. Tests exercise the function directly, so the "nobody runs it in prod" property never fails a test.

    Two things that closed it for us:

    1. Ship the verifier as a standalone CLI a third party runs, not a library method or admin task. Reads a signed dump manifest, returns exit 0/1/2. Removes the "operator vs auditor" trust asymmetry. The operator is not the one telling you their chain is valid.

    2. Seq contiguity as an explicit named pass. Prefix and suffix truncation both surface as gaps in the seq column. The verifier enumerates entries in seq order and asserts each entry's prev_hash equals the previous entry's entry_hash AND that seq is contiguous. Missing seqs at either end fail the same way tampered entries do, no special handling required.

    The subtler thing we found: once the standalone CLI existed, it caught a real race in our own append path. Two concurrent writers computed prev_hash against the same committed snapshot and produced two entries with the same prev_hash. Detectable because entry N+1's prev_hash did not match entry N's entry_hash. Fixed in the append path with BEGIN IMMEDIATE. Would not have surfaced without the verifier running as a real process in prod.

    Point being: the reference verifier is the primitive that makes the chain trustworthy, but ONLY if it runs somewhere other than tests.

  2. SeanGearin commented on Jul 24, 2026

    @SeanGearin
    ContributorAuthor

    Exactly matches what we found. Two of your points are already in a branch we've held pending this issue: the verifier ships as a standalone buzz-admin audit verify a third party runs (exit 0/1/2 over a signed manifest, so the operator isn't the one vouching for their own chain), and truncation is caught structurally — but you've named the cleaner framing: seq-contiguity as an explicit pass, so prefix and suffix truncation fail the same way tampered entries do, no special-casing. I'll adopt that naming. The concurrent-writer race you hit (two entries sharing a prev_hash, fixed with BEGIN IMMEDIATE) is a great catch — worth a note in the issue as a second thing the verifier-in-prod surfaces. Will link the PR here once it's up.

  3. StevenMih commented on Nov 9, 2026

    @StevenMih

    The truncation case is the one worth fixing first. A chain that verifies from any prefix can't tell you it's been cut, and a DB writer that recomputes the keyless chain can rewrite it; both are the same gap.

    The usual answer is a signed checkpoint over the head: a small statement (chain head hash, entry count, time) signed by the relay or community key on a cadence, optionally registered with an outside witness. verify_chain then checks the chain against the latest checkpoint rather than against itself, and a shorter chain fails. #4173's field-boundary collision is a separate bug but the same PR would be the place for it - domain-separate the fields in compute_hash before signing anything over the result.

    This is the direction #3228 points at (verifiable audit export: the export manifest's final chain hash, signed, optionally witnessed). I'll open that PR against the export path rather than the chain itself so the chain format doesn't change. Design note and test data from our self-hosted relays will come with it. No date yet; I'd rather post it working.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions