Repository navigation
Make "tamper-evident" true in all five languages, not just TypeScript - #8
Merged
Merged
Conversation
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 detectedLatest commit: a650d36 The changes in this PR will be included in the next version bump. This PR includes changesets to release 1 package
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 |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
The gap
verifyChain— the function that actually detects a broken hash chain — existed atsrc/hash.ts:45and 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
verifyChainverify_chainverify_chainVerifyChainHashChain.VerifyEach 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 rewrittenhashPrevious, 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#.genesisPreviousHashverifyChaingained 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_chainvsunanchored_continuation_chain).TypeScript additionally gains a
codefield beside the existing human-readablereason. Purely additive —reasonis unchanged, so nothing displaying it breaks.Corrupted-chain corpus vectors (Spec C)
spec/parity-corpus.jsongainschainFixtures: 11 whole chains, sealed by the real builder and then genuinely tampered with. All five suites load them.intact_chainintact_continuation_chain(seeded)unanchored_continuation_chainhash_previous_mismatchmutated_event_fieldhash_current_mismatchtampered_timestamphash_current_mismatchreordered_pairhash_previous_mismatchbroken_hash_previous_linkhash_previous_mismatchdeleted_middle_eventhash_previous_mismatchtruncated_chain_head_removedhash_previous_mismatchtruncated_chain_tail_removedempty_chainFixtures are generated by
scripts/gen-chain-fixtures.mjsfrom the real TS serializer, never hand-written. A hand-typed expected hash is a hash nobody computed. The generator splices only thechainFixturesblock, so regenerating never reformats the existingfixturesarray (this diff is +701 lines, 0 deletions there).The honest limit, written down as a fixture
truncated_chain_tail_removedexpectsok: 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'shashCurrent.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
verifydoc 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→ wrongbrokenAtandcode) turns all five suites red: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 reportedok (cached)against a corpus that had just been deliberately corrupted.-count=1is now ingo: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
tsc --noEmit,oxlint,clippy --all-targets --all-features -D warnings,cargo fmt --check,gofmt,go vet,basedpyright --level errorall clean.README's per-language matrix updated (the
verifyChainrow was the one my change made stale). Minor changeset included.Note, not fixed here
pnpm formatonmainproduces a large unrelated diff — oxfmt reflows markdown tables and reindents the corpus JSON from 4-space to 2-space.mainis not currently oxfmt-clean andpr-checks.ymlhas no format-check step, whilerelease.ymlrunspnpm formatand 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