Skip to content

Make "tamper-evident" true in all five languages, not just TypeScript - #8

Merged
brentrager merged 1 commit into
mainfrom
fix/verify-chain
Aug 20, 2026
Merged

brentrager merged 1 commit into
mainfrom
fix/verify-chain

Conversation

@brentrager

Copy link
Copy Markdown
Contributor

The gap

verifyChain — the function that actually detects a broken hash chain — existed at src/hash.ts:45 and nowhere else. Python had a docstring describing how one would verify (python/src/smooai_audit/hash.py:5); Rust, Go, and .NET had nothing at all. A service written in four of the five supported languages could seal a chain it could never audit. "Tamper-evident" was a property of the TypeScript build, not of the format.

Why the parity corpus did not catch it. Its 8 fixtures assert sealing: one event in, one canonical string and one hash out. Nothing in the corpus ever asked a chain to be verified. So a language could pass every parity gate in CI while being completely unable to notice tampering — the corpus read as a guarantee it was not making.

What ships

Verification in all five

TS Python Rust Go .NET
verifyChain verify_chain verify_chain VerifyChain HashChain.Verify

Each returns the same verdict — ok, brokenAt (index of the first bad link), and a shared failure code:

  • hash_previous_mismatch — the link is wrong: a reorder, a deletion, a rewritten hashPrevious, a missing head.
  • hash_current_mismatch — the event body was edited after sealing.

Naming follows each language's existing convention (HashChain.Build → HashChain.Verify), and the shape is idiomatic per language: a discriminated union in TS, an enum in Rust, a frozen dataclass in Python, a struct in Go, a record in C#.

genesisPreviousHash

verifyChain gained an optional seed. Without it only a first-of-day chain could be verified at all — a slice continuing an existing chain always reported a broken first link, because the verifier had no way to be told the chain head. Two corpus fixtures pin both sides of this (intact_continuation_chain vs unanchored_continuation_chain).

TypeScript additionally gains a code field beside the existing human-readable reason. Purely additive — reason is unchanged, so nothing displaying it breaks.

Corrupted-chain corpus vectors (Spec C)

spec/parity-corpus.json gains chainFixtures: 11 whole chains, sealed by the real builder and then genuinely tampered with. All five suites load them.

fixture expected
intact_chain ok
intact_continuation_chain (seeded) ok
unanchored_continuation_chain broken @0, hash_previous_mismatch
mutated_event_field broken @1, hash_current_mismatch
tampered_timestamp broken @0, hash_current_mismatch
reordered_pair broken @1, hash_previous_mismatch
broken_hash_previous_link broken @2, hash_previous_mismatch
deleted_middle_event broken @1, hash_previous_mismatch
truncated_chain_head_removed broken @0, hash_previous_mismatch
truncated_chain_tail_removed ok — see below
empty_chain ok

Fixtures are generated by scripts/gen-chain-fixtures.mjs from the real TS serializer, never hand-written. A hand-typed expected hash is a hash nobody computed. The generator splices only the chainFixtures block, so regenerating never reformats the existing fixtures array (this diff is +701 lines, 0 deletions there).

The honest limit, written down as a fixture

truncated_chain_tail_removed expects ok: true. Dropping events from the tail of a chain leaves something that still verifies — every remaining link is genuine, and replay alone cannot see it. Catching that needs an external anchor (a stored chain head, an expected count) compared against the last event's hashCurrent.

That is exactly the fail-open shape worth being loud about: a verifier returns the reassuring answer and a caller reads it as "nothing is missing" when it only means "nothing here was altered". It is now an asserted fixture, documented on every language's verify doc comment and in the README, rather than a silent assumption.

The tests actually detect things — verified

A detection corpus that nobody genuinely asserts against is worse than no corpus. So I broke it on purpose: mutating two expected verdicts (intact_chain → claimed broken, mutated_event_field → wrong brokenAt and code) turns all five suites red:

TS      2 failed | 29 passed
Python  3 failed | 60 passed
Rust    1 failed |  3 passed
Go      FAIL — intact_chain (ok mismatch), mutated_event_field (brokenAt: got 1 want 3)
.NET    Failed: 2, Passed: 29

Restored → all green.

Second defect that control exposed

go test ./... served a CACHED pass after the corpus changed. The corpus lives at ../spec/parity-corpus.json, outside the Go package directory, and Go's test cache does not invalidate on it — the first Go run above reported ok (cached) against a corpus that had just been deliberately corrupted. -count=1 is now in go:test.

CI checks out fresh so it was not wrong in CI, but locally an edit to the single file carrying the entire cross-language guarantee could report green without being checked once. Worth its own line.

Test counts

before after
TypeScript 39 51
Python 40 63
Rust 19 21
Go 8 9 (+11 subtests)
.NET 19 31

tsc --noEmit, oxlint, clippy --all-targets --all-features -D warnings, cargo fmt --check, gofmt, go vet, basedpyright --level error all clean.

README's per-language matrix updated (the verifyChain row was the one my change made stale). Minor changeset included.

Note, not fixed here

pnpm format on main produces a large unrelated diff — oxfmt reflows markdown tables and reindents the corpus JSON from 4-space to 2-space. main is not currently oxfmt-clean and pr-checks.yml has no format-check step, while release.yml runs pnpm format and commits the result. I deliberately kept that churn out of this PR; worth a separate one-commit PR to either format the repo or add the check.

🤖 Generated with Claude Code

https://claude.ai/code/session_0152bbE1veqfG1SVJdyLCBxC

verifyChain — the function that actually DETECTS a broken hash chain — lived
only at src/hash.ts:45. Python carried a docstring describing how one would
verify; Rust, Go and .NET had nothing. Four of five languages could seal a
chain they could never audit, which makes the library's headline claim a
property of the TypeScript build rather than of the format.

The corpus is why that hid. Its 8 fixtures assert SEALING — one event in, one
canonical string and one hash out. Nothing in it ever asked a chain to be
verified, so a language could pass every parity gate while being completely
unable to notice tampering.

- verify_chain (Python, Rust), VerifyChain (Go), HashChain.Verify (.NET), each
  named to match the existing per-language conventions. All five return the
  same verdict: ok, brokenAt, and a shared failure code — hash_previous_mismatch
  (the LINK is wrong: reorder, deletion, rewritten link, missing head) or
  hash_current_mismatch (the event BODY was edited after sealing).
- verifyChain gains an optional genesisPreviousHash. Without it only a
  first-of-day chain could be verified at all: a slice continuing an existing
  chain always reported a broken first link. TS additionally gains `code`
  beside the existing prose `reason` (additive, `reason` unchanged).
- spec/parity-corpus.json gains chainFixtures: 11 real chains, sealed by the
  builder and then genuinely tampered with — mutated field, backdated
  timestamp, reordered pair, rewritten hashPrevious, deleted middle event,
  truncated head, plus intact / continuation / empty controls. Generated by
  scripts/gen-chain-fixtures.mjs from the real serializer, never by hand.
- One fixture asserts what replay CANNOT see: dropping events from the tail
  leaves a chain that still verifies (expected ok:true). Catching that needs an
  external anchor. Written down as a fixture so the limit stays visible instead
  of being mistaken for coverage.

Positive control, because a corpus nobody actually asserts against is worse
than none: mutating two expected verdicts in the corpus turns all five suites
RED (TS 2 failed, Python 3, Rust 1, Go 2 subtests, .NET 2), then green again on
restore.

That control also exposed a second defect: `go test ./...` served a CACHED pass
after the corpus changed — the corpus lives outside the package directory, so
Go's test cache does not invalidate on it. `go:test` now passes -count=1.
Without it, an edit to the one file that carries the cross-language guarantee
could report green locally without ever being checked.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0152bbE1veqfG1SVJdyLCBxC
@changeset-bot

changeset-bot Bot commented Aug 20, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: a650d36

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 1 package
Name Type
@smooai/audit Minor

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@brentrager
brentrager merged commit a6a0293 into main Aug 20, 2026
1 check passed
@brentrager
brentrager deleted the fix/verify-chain branch August 20, 2026 18:09
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant