From a650d3615b72e611700c01fb7fe4d745e850580d Mon Sep 17 00:00:00 2001 From: Brent Date: Thu, 20 Aug 2026 14:00:32 -0400 Subject: [PATCH] Make "tamper-evident" true in all five languages, not just TypeScript MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_0152bbE1veqfG1SVJdyLCBxC --- .changeset/soft-melons-behave.md | 27 + README.md | 14 +- dotnet/src/SmooAI.Audit/HashChain.cs | 70 ++ dotnet/tests/SmooAI.Audit.Tests/AuditTests.cs | 61 ++ go/audit_test.go | 54 ++ go/hash.go | 61 ++ package.json | 2 +- python/src/smooai_audit/__init__.py | 11 +- python/src/smooai_audit/hash.py | 79 +- python/tests/test_audit.py | 34 +- rust/audit/src/hash.rs | 67 ++ rust/audit/src/lib.rs | 2 +- rust/audit/tests/parity.rs | 56 +- scripts/gen-chain-fixtures.mjs | 179 +++++ spec/parity-corpus.json | 701 ++++++++++++++++++ src/hash.ts | 44 +- src/index.ts | 8 +- src/parity-corpus.spec.ts | 34 +- 18 files changed, 1484 insertions(+), 20 deletions(-) create mode 100644 .changeset/soft-melons-behave.md create mode 100644 scripts/gen-chain-fixtures.mjs diff --git a/.changeset/soft-melons-behave.md b/.changeset/soft-melons-behave.md new file mode 100644 index 0000000..3bcb741 --- /dev/null +++ b/.changeset/soft-melons-behave.md @@ -0,0 +1,27 @@ +--- +"@smooai/audit": minor +--- + +Ship chain verification in all five languages, and prove it with corrupted-chain +corpus vectors. + +`verifyChain` — the function that actually DETECTS a broken hash chain — existed +only in TypeScript. Python carried a docstring describing how one would verify; +Rust, Go, and .NET had nothing. A service in four of five languages could seal a +chain it could never audit, which makes "tamper-evident" a claim rather than a +capability. + +- New: `verify_chain` (Python, Rust), `VerifyChain` (Go), `HashChain.Verify` + (.NET). All five return the same verdict shape: `ok`, `brokenAt`, and a shared + failure code — `hash_previous_mismatch` (the link is wrong) or + `hash_current_mismatch` (the event body was edited after sealing). +- `verifyChain` gains an optional `genesisPreviousHash`, so a slice continuing an + existing chain can be verified at all. Without it, only a first-of-day chain + was verifiable. TypeScript additionally gains a `code` field alongside the + existing human-readable `reason` (additive; `reason` is unchanged). +- `spec/parity-corpus.json` gains `chainFixtures`: 11 real chains, sealed by the + builder and then genuinely tampered with, each with the verdict every language + must return. All five suites load them. The corpus previously proved sealing + only, which is what let the asymmetry hide. +- One fixture asserts the honest limit: deleting events from the TAIL of a chain + still verifies. Replay cannot see it; catching it needs an external anchor. diff --git a/README.md b/README.md index da64eb0..fdcc5c4 100644 --- a/README.md +++ b/README.md @@ -63,6 +63,8 @@ Audit trails are only trustworthy if a hash means the same thing everywhere. The **All five test suites load this exact file** ([TS](./src/parity-corpus.spec.ts) · [Python](./python/tests/test_audit.py) · [Rust](./rust/audit/tests/parity.rs) · [Go](./go/audit_test.go) · [.NET](./dotnet/tests/SmooAI.Audit.Tests/AuditTests.cs)) and assert byte-for-byte equality — so a chain written by a Go service verifies cleanly in a Rust or TypeScript reader. A divergence is a CI failure, not a production surprise. +The corpus has a second half, `chainFixtures`: 11 whole chains, sealed by the real builder and then genuinely tampered with — a mutated field, a backdated timestamp, a reordered pair, a rewritten `hashPrevious`, a deleted middle event, a truncated head. Each carries the verdict every language must return (`ok`, `brokenAt`, and a shared failure code), so all five prove they **detect** tampering, not merely that they can hash. Sealing parity without detection parity is how a library ends up tamper-evident in one language and tamper-*oblivious* in four. + ### 🔗 Tamper-evident hash chain Each event's `hashCurrent` is `SHA-256(canonical-JSON(event minus hashCurrent))`, and every event carries `hashPrevious` — the prior event's hash in the per-org-per-day chain. Rewrite or delete any event and every subsequent hash stops verifying. @@ -80,7 +82,9 @@ flowchart LR class E1,E2,E3 teal ``` -Verification replays the chain: recompute every hash and confirm each `hashPrevious` matches the prior `hashCurrent`. A ready-made `verifyChain()` helper ships in **TypeScript today** ([`src/hash.ts`](./src/hash.ts)); in the other languages you replay with the same `computeEventHash` primitive — see the [status table](#honest-per-language-status). +Verification replays the chain: recompute every hash and confirm each `hashPrevious` matches the prior `hashCurrent`. A ready-made verifier ships in **all five languages** — `verifyChain` (TS) · `verify_chain` (Python, Rust) · `VerifyChain` (Go) · `HashChain.Verify` (.NET) — and each returns the same verdict: `ok`, the index of the first broken link, and a shared failure code (`hash_previous_mismatch` when the LINK is wrong, `hash_current_mismatch` when the event BODY was edited after sealing). Pass the chain head you already hold when verifying a slice that continues an existing chain rather than one starting at the beginning of the org's day. + +> **What replay cannot see.** Deleting events from the **tail** of a chain leaves something that still verifies — every remaining link is genuine. Catching that needs an external anchor (a stored chain head, an expected count) compared against the last event's `hashCurrent`. `ok` means *nothing here was altered*, not *nothing is missing*. The corpus pins this as an explicit fixture (`truncated_chain_tail_removed`, expected `ok: true`) so the limit stays visible instead of being mistaken for coverage. ### 🔎 Trace correlation, outside the hash @@ -103,6 +107,7 @@ Every language exposes the same four things: | `AuditEvent` | The canonical event schema — `id`, `organizationId`, `actorType`, `actorId`, `action`, `resource {type, id}`, `outcome`, `metadata`, `timestamp`, plus optional context (`actorEmail`, `reason`, `sessionId`, `conversationId`, `ipAddress`, `userAgent`, `geoCountry`, `diff`) and the chain fields `hashPrevious?` / `hashCurrent?`. | | `canonicalJson(event)` | Deterministic, byte-identical JSON serialization. | | `computeEventHash(event)` / `buildHashChain(events)` | The per-org-per-day SHA-256 hash chain (`HashChain.ComputeEventHash` / `HashChain.Build` in .NET). | +| `verifyChain(events, genesisPreviousHash?)` | Replays the chain and reports the first broken link (`HashChain.Verify` in .NET). | | `AuditClient` / `emit(event)` | Seals the event (stamps `hashCurrent`) and POSTs the canonical envelope to a configurable ingest endpoint with a bearer token. | ## Install @@ -201,10 +206,9 @@ The core contract — canonical JSON, the hash chain, the emit envelope, trace c | Envelope trace correlation (optional OTel) | ✅ | ✅ | ✅ | ✅ | ✅ | | `emit` client | ✅ async | ✅ sync | ✅ async | ✅ sync (ctx) | ✅ async | | Retry with backoff on transient emit failure | ✅ | — | — | — | — | -| Ready-made `verifyChain()` helper | ✅ | —¹ | — | — | — | +| Chain verification (corpus-verified against 11 tampered chains) | ✅ | ✅ | ✅ | ✅ | ✅ | | Chain builder name | `buildHashChain` | `build_hash_chain` | `build_hash_chain` | `BuildHashChain` | `HashChain.Build` | - -¹ Python documents the replay procedure (recompute every hash, confirm each `hash_previous` matches the prior `hash_current`) but does not yet ship the helper; the same applies to Rust, Go, and .NET — verification there means replaying with `computeEventHash`, which the parity corpus guarantees agrees with TS byte-for-byte. +| Chain verifier name | `verifyChain` | `verify_chain` | `verify_chain` | `VerifyChain` | `HashChain.Verify` | Error posture also differs by design: the TypeScript, Rust, Go, and .NET clients surface emit failures to the caller; the **Python client swallows transport errors by default** (audit emission should not take down the request path) — set `swallow_errors=False` or pass an `on_error` hook to change that. @@ -217,7 +221,7 @@ pnpm install pnpm check-all # typecheck + lint + test + build across all languages ``` -Parity is the contract: any change to `canonicalJson` or the hash chain must update [`spec/parity-corpus.json`](./spec/parity-corpus.json) and pass in **all five** languages — a canonical/hash change in one language is a breaking, cross-language change. +Parity is the contract: any change to `canonicalJson` or the hash chain must update [`spec/parity-corpus.json`](./spec/parity-corpus.json) and pass in **all five** languages — a canonical/hash change in one language is a breaking, cross-language change. The `chainFixtures` half is generated, never hand-written: `pnpm tsdown && node scripts/gen-chain-fixtures.mjs`. A hand-typed expected hash is a hash nobody computed. ## 🧩 Part of Smoo AI diff --git a/dotnet/src/SmooAI.Audit/HashChain.cs b/dotnet/src/SmooAI.Audit/HashChain.cs index 7088129..addff65 100644 --- a/dotnet/src/SmooAI.Audit/HashChain.cs +++ b/dotnet/src/SmooAI.Audit/HashChain.cs @@ -37,4 +37,74 @@ public static IReadOnlyList Build(IReadOnlyList events, } return result; } + + /// + /// Verify an ordered chain: recompute every and confirm + /// each matches the prior event's + /// . + /// + /// is the hash the FIRST event must link to — pass the + /// chain head you already have when verifying a slice that continues an existing chain. Leave it + /// null only when starts at the true beginning of the chain (first + /// event of the org's day), where HashPrevious must be null. + /// + /// + /// What replay cannot see: removing events from the TAIL leaves a chain that still + /// verifies — every remaining link is genuine. Detecting that needs an external anchor (a stored + /// chain head, an expected count) compared against the last event's HashCurrent. An + /// Ok verdict means "nothing here was altered", not "nothing is missing"; the corpus pins + /// this as an explicit fixture so the limit stays visible. + /// + /// + public static ChainVerification Verify(IReadOnlyList events, string? genesisPreviousHash = null) + { + var previous = string.IsNullOrEmpty(genesisPreviousHash) ? null : genesisPreviousHash; + for (var i = 0; i < events.Count; i++) + { + var @event = events[i]; + var stored = string.IsNullOrEmpty(@event.HashPrevious) ? null : @event.HashPrevious; + if (stored != previous) + { + return new ChainVerification(false, i, VerifyFailureCode.HashPreviousMismatch); + } + + var recomputed = ComputeEventHash(@event); + if (@event.HashCurrent != recomputed) + { + return new ChainVerification(false, i, VerifyFailureCode.HashCurrentMismatch); + } + + previous = recomputed; + } + return new ChainVerification(true, -1, null); + } } + +/// +/// Why a chain failed to verify. These codes are the cross-language contract — every SDK returns +/// the same code for the same corruption, asserted by chainFixtures in +/// spec/parity-corpus.json. +/// +public enum VerifyFailureCode +{ + /// + /// An event's HashPrevious is not the prior event's HashCurrent — the LINK is + /// wrong: a reorder, a deletion, a truncated head, a rewritten link. + /// + HashPreviousMismatch, + + /// + /// The event's own content no longer hashes to its stored HashCurrent — the event BODY + /// was edited after sealing. + /// + HashCurrentMismatch, +} + +/// +/// The verdict from . is -1 and +/// is null when is true. +/// +/// True when every link recomputed and matched. +/// Index of the first event that failed. +/// Why it failed. +public sealed record ChainVerification(bool Ok, int BrokenAt, VerifyFailureCode? Code); diff --git a/dotnet/tests/SmooAI.Audit.Tests/AuditTests.cs b/dotnet/tests/SmooAI.Audit.Tests/AuditTests.cs index 3ca0121..f9032cc 100644 --- a/dotnet/tests/SmooAI.Audit.Tests/AuditTests.cs +++ b/dotnet/tests/SmooAI.Audit.Tests/AuditTests.cs @@ -42,6 +42,67 @@ public void FixtureMatchesCanonicalAndHash(string name, string eventJson, string Assert.Equal(expectedHash, HashChain.ComputeEventHash(evt)); } + // --- Chain verification ----------------------------------------------------------- + // The fixtures above prove SEALING only — that a hash is reproducible in five languages. + // They say nothing about whether any of them can DETECT a broken chain, which is the + // property "tamper-evident" actually names. `chainFixtures` are real chains sealed by the + // TS builder and then genuinely tampered with, each carrying the verdict every language + // must return. + + public static IEnumerable ChainFixtures() + { + using var doc = JsonDocument.Parse(File.ReadAllText(CorpusPath())); + foreach (var fixture in doc.RootElement.GetProperty("chainFixtures").EnumerateArray()) + { + yield return new object[] + { + fixture.GetProperty("name").GetString()!, + fixture.GetRawText(), + }; + } + } + + [Theory] + [MemberData(nameof(ChainFixtures))] + public void ChainFixtureVerdictMatches(string name, string fixtureJson) + { + _ = name; // surfaces as the test-case display name + using var doc = JsonDocument.Parse(fixtureJson); + var root = doc.RootElement; + var description = root.GetProperty("description").GetString()!; + var genesis = root.TryGetProperty("genesisPreviousHash", out var g) ? g.GetString() : null; + var events = JsonSerializer.Deserialize>(root.GetProperty("events").GetRawText(), Relaxed)!; + var expected = root.GetProperty("expected"); + + var result = HashChain.Verify(events, genesis); + + Assert.True(result.Ok == expected.GetProperty("ok").GetBoolean(), description); + if (result.Ok) + { + return; + } + + Assert.Equal(expected.GetProperty("brokenAt").GetInt32(), result.BrokenAt); + Assert.Equal(expected.GetProperty("code").GetString(), CodeWireName(result.Code!.Value)); + } + + /// The corpus carries the shared snake_case code names, not the C# enum spelling. + private static string CodeWireName(VerifyFailureCode code) => code switch + { + VerifyFailureCode.HashPreviousMismatch => "hash_previous_mismatch", + VerifyFailureCode.HashCurrentMismatch => "hash_current_mismatch", + _ => throw new ArgumentOutOfRangeException(nameof(code), code, null), + }; + + [Fact] + public void ChainCorpusContainsTamperedChains() + { + using var doc = JsonDocument.Parse(File.ReadAllText(CorpusPath())); + var fixtures = doc.RootElement.GetProperty("chainFixtures").EnumerateArray().ToList(); + Assert.NotEmpty(fixtures); + Assert.Contains(fixtures, f => !f.GetProperty("expected").GetProperty("ok").GetBoolean()); + } + [Fact] public void BuildChainsHashPreviousAcrossEvents() { diff --git a/go/audit_test.go b/go/audit_test.go index 1f14c43..4972b5f 100644 --- a/go/audit_test.go +++ b/go/audit_test.go @@ -27,6 +27,22 @@ type corpus struct { ExpectedCanonical string `json:"expectedCanonical"` ExpectedHash string `json:"expectedHash"` } `json:"fixtures"` + // ChainFixtures are REAL chains sealed by the TS builder and then tampered + // with. `Expected` is the verdict every language must return — this is the + // half of the corpus that proves DETECTION rather than sealing. + ChainFixtures []struct { + Name string `json:"name"` + Description string `json:"description"` + GenesisPreviousHash string `json:"genesisPreviousHash"` + Events []AuditEvent `json:"events"` + Expected expectedChain `json:"expected"` + } `json:"chainFixtures"` +} + +type expectedChain struct { + OK bool `json:"ok"` + BrokenAt int `json:"brokenAt"` + Code VerifyFailureCode `json:"code"` } func loadCorpus(t *testing.T) corpus { @@ -316,3 +332,41 @@ func TestEmitHashUnchangedByTraceContext(t *testing.T) { }) } } + +// TestChainCorpus is the detection gate. The fixture corpus above proves only +// that a hash is reproducible in five languages; it says nothing about whether +// any of them can SPOT a broken chain, which is the property "tamper-evident" +// actually names. +func TestChainCorpus(t *testing.T) { + c := loadCorpus(t) + if len(c.ChainFixtures) == 0 { + t.Fatal("chainFixtures missing from the corpus") + } + sawBroken := false + for _, f := range c.ChainFixtures { + if !f.Expected.OK { + sawBroken = true + } + t.Run(f.Name, func(t *testing.T) { + got, err := VerifyChain(f.Events, f.GenesisPreviousHash) + if err != nil { + t.Fatalf("VerifyChain: %v", err) + } + if got.OK != f.Expected.OK { + t.Fatalf("ok mismatch: got %v want %v — %s", got.OK, f.Expected.OK, f.Description) + } + if f.Expected.OK { + return + } + if got.BrokenAt != f.Expected.BrokenAt { + t.Fatalf("brokenAt mismatch: got %d want %d", got.BrokenAt, f.Expected.BrokenAt) + } + if got.Code != f.Expected.Code { + t.Fatalf("code mismatch: got %q want %q", got.Code, f.Expected.Code) + } + }) + } + if !sawBroken { + t.Fatal("no tampered chain to detect — the corpus would prove nothing about verification") + } +} diff --git a/go/hash.go b/go/hash.go index 436e8e7..6070e7c 100644 --- a/go/hash.go +++ b/go/hash.go @@ -46,6 +46,67 @@ func BuildHashChain(events []AuditEvent, genesisHash string) ([]AuditEvent, erro return out, nil } +// VerifyFailureCode says why a chain failed to verify. These codes are the +// cross-language contract — every SDK returns the same code for the same +// corruption, asserted by chainFixtures in spec/parity-corpus.json. +type VerifyFailureCode string + +const ( + // HashPreviousMismatch: an event's HashPrevious is not the prior event's + // HashCurrent — the LINK is wrong: a reorder, a deletion, a truncated head, + // a rewritten link. + HashPreviousMismatch VerifyFailureCode = "hash_previous_mismatch" + // HashCurrentMismatch: the event's own content no longer hashes to its + // stored HashCurrent — the event BODY was edited after sealing. + HashCurrentMismatch VerifyFailureCode = "hash_current_mismatch" +) + +// ChainVerification is the verdict from VerifyChain. BrokenAt and Code are only +// meaningful when OK is false. +type ChainVerification struct { + OK bool + // BrokenAt is the index of the first event that failed. + BrokenAt int + // Code says why it failed. + Code VerifyFailureCode +} + +// VerifyChain verifies an ordered chain: it recomputes every HashCurrent and +// confirms each HashPrevious matches the prior event's HashCurrent. +// +// genesisPreviousHash is the hash the FIRST event must link to — pass the chain +// head you already have when verifying a slice that continues an existing chain. +// Pass "" only when events starts at the true beginning of the chain (first +// event of the org's day), where HashPrevious must be nil. +// +// What replay cannot see: removing events from the TAIL leaves a chain that +// still verifies — every remaining link is genuine. Detecting that needs an +// external anchor (a stored chain head, an expected count) compared against the +// last event's HashCurrent. OK means "nothing here was altered", not "nothing is +// missing"; the corpus pins this as an explicit fixture so the limit stays +// visible. +func VerifyChain(events []AuditEvent, genesisPreviousHash string) (ChainVerification, error) { + previous := genesisPreviousHash + for i, event := range events { + stored := "" + if event.HashPrevious != nil { + stored = *event.HashPrevious + } + if stored != previous { + return ChainVerification{BrokenAt: i, Code: HashPreviousMismatch}, nil + } + recomputed, err := ComputeEventHash(event) + if err != nil { + return ChainVerification{}, err + } + if event.HashCurrent != recomputed { + return ChainVerification{BrokenAt: i, Code: HashCurrentMismatch}, nil + } + previous = recomputed + } + return ChainVerification{OK: true}, nil +} + // hashGeneric canonicalizes a decoded JSON value and returns hex(SHA-256(bytes)). func hashGeneric(v any) (string, error) { canonical, err := CanonicalJSON(v) diff --git a/package.json b/package.json index ab9c9a4..949e58e 100644 --- a/package.json +++ b/package.json @@ -57,7 +57,7 @@ "go:fmt": "(cd go && gofmt -w .)", "go:fmt:check": "(cd go && test -z \"$(gofmt -l .)\")", "go:lint": "(cd go && go vet ./...)", - "go:test": "(cd go && go test -v ./...)", + "go:test": "(cd go && go test -count=1 -v ./...)", "lint": "oxlint . && pnpm run python:lint && pnpm run rust:lint && pnpm run go:lint", "lint:fix": "oxlint --fix . && pnpm run python:lint:fix", "pre-commit-check": "(zsh .husky/pre-commit)", diff --git a/python/src/smooai_audit/__init__.py b/python/src/smooai_audit/__init__.py index c9a7f2c..104183a 100644 --- a/python/src/smooai_audit/__init__.py +++ b/python/src/smooai_audit/__init__.py @@ -2,7 +2,13 @@ from .canonical import canonical_json from .client import AuditClient, AuditClientOptions -from .hash import build_hash_chain, compute_event_hash +from .hash import ( + ChainVerification, + VerifyFailureCode, + build_hash_chain, + compute_event_hash, + verify_chain, +) from .schema import ( AUDIT_ACTIONS, AuditActorType, @@ -22,8 +28,11 @@ "AuditEvent", "AuditOutcome", "AuditResource", + "ChainVerification", + "VerifyFailureCode", "build_hash_chain", "canonical_json", "compute_event_hash", "is_namespaced_action", + "verify_chain", ] diff --git a/python/src/smooai_audit/hash.py b/python/src/smooai_audit/hash.py index e1551b3..98f1336 100644 --- a/python/src/smooai_audit/hash.py +++ b/python/src/smooai_audit/hash.py @@ -2,14 +2,17 @@ Each event's ``hash_current`` is ``SHA-256(canonical-JSON(event minus hash_current))``; the chain links via ``hash_previous`` = the prior event's -``hash_current``. Verify by replaying: recompute every hash and confirm each -event's ``hash_previous`` equals the previous event's ``hash_current``. +``hash_current``. :func:`verify_chain` replays that: recompute every hash and +confirm each event's ``hash_previous`` equals the previous event's +``hash_current``. """ from __future__ import annotations import hashlib -from collections.abc import Mapping +from collections.abc import Mapping, Sequence +from dataclasses import dataclass +from enum import Enum from typing import Any from .canonical import canonical_json @@ -46,3 +49,73 @@ def build_hash_chain(events: list[AuditEvent], previous_hash: str | None = None) sealed.append(with_prev.model_copy(update={"hash_current": current})) prev = current return sealed + + +class VerifyFailureCode(str, Enum): + """Why a chain failed to verify. + + These codes are the cross-language contract — every SDK returns the same code + for the same corruption, asserted by ``chainFixtures`` in + ``spec/parity-corpus.json``. + """ + + HASH_PREVIOUS_MISMATCH = "hash_previous_mismatch" + """An event's ``hash_previous`` is not the prior event's ``hash_current`` — + the LINK is wrong: a reorder, a deletion, a truncated head, a rewritten link.""" + + HASH_CURRENT_MISMATCH = "hash_current_mismatch" + """The event's own content no longer hashes to its stored ``hash_current`` — + the event BODY was edited after sealing.""" + + +@dataclass(frozen=True) +class ChainVerification: + """The verdict from :func:`verify_chain`. Falsy when the chain is broken.""" + + ok: bool + broken_at: int | None = None + """Index of the first event that failed. ``None`` when ``ok``.""" + code: VerifyFailureCode | None = None + """Why it failed. ``None`` when ``ok``.""" + + def __bool__(self) -> bool: + return self.ok + + +def _chain_field(event: AuditEvent | Mapping[str, Any], attr: str, wire_key: str) -> str | None: + """Read a chain field from either a typed event or a raw wire mapping.""" + if isinstance(event, AuditEvent): + return getattr(event, attr) + value = event.get(wire_key) + return value if isinstance(value, str) else None + + +def verify_chain( + events: Sequence[AuditEvent | Mapping[str, Any]], + genesis_previous_hash: str | None = None, +) -> ChainVerification: + """Verify an ordered chain: recompute every ``hash_current`` and confirm each + ``hash_previous`` matches the prior event's ``hash_current``. + + ``genesis_previous_hash`` is the hash the FIRST event must link to — pass the + chain head you already have when verifying a slice that continues an existing + chain. Leave it ``None`` only when ``events`` starts at the true beginning of + the chain (first event of the org's day), where ``hash_previous`` must be + absent. + + **What replay cannot see:** removing events from the TAIL leaves a chain that + still verifies — every remaining link is genuine. Detecting that needs an + external anchor (a stored chain head, an expected count) compared against the + last event's ``hash_current``. An ``ok`` verdict means "nothing here was + altered", not "nothing is missing"; the corpus pins this as an explicit + fixture so the limit stays visible. + """ + previous = genesis_previous_hash + for index, event in enumerate(events): + if _chain_field(event, "hash_previous", "hashPrevious") != previous: + return ChainVerification(ok=False, broken_at=index, code=VerifyFailureCode.HASH_PREVIOUS_MISMATCH) + recomputed = compute_event_hash(event) + if _chain_field(event, "hash_current", "hashCurrent") != recomputed: + return ChainVerification(ok=False, broken_at=index, code=VerifyFailureCode.HASH_CURRENT_MISMATCH) + previous = recomputed + return ChainVerification(ok=True) diff --git a/python/tests/test_audit.py b/python/tests/test_audit.py index 0b96954..7e2ce13 100644 --- a/python/tests/test_audit.py +++ b/python/tests/test_audit.py @@ -22,11 +22,15 @@ canonical_json, compute_event_hash, is_namespaced_action, + verify_chain, ) _CORPUS_PATH = Path(__file__).resolve().parents[2] / "spec" / "parity-corpus.json" -_FIXTURES: list[dict[str, Any]] = json.loads(_CORPUS_PATH.read_text(encoding="utf-8"))["fixtures"] +_CORPUS: dict[str, Any] = json.loads(_CORPUS_PATH.read_text(encoding="utf-8")) +_FIXTURES: list[dict[str, Any]] = _CORPUS["fixtures"] _FIXTURE_IDS = [f["name"] for f in _FIXTURES] +_CHAIN_FIXTURES: list[dict[str, Any]] = _CORPUS["chainFixtures"] +_CHAIN_IDS = [f["name"] for f in _CHAIN_FIXTURES] @pytest.mark.parametrize("fixture", _FIXTURES, ids=_FIXTURE_IDS) @@ -48,6 +52,34 @@ def test_corpus_via_model_round_trip(fixture: dict[str, Any]) -> None: assert compute_event_hash(event) == fixture["expectedHash"] +# The corpus proved SEALING only — that a hash is reproducible in five languages. +# It said nothing about whether any of them can DETECT a broken chain, which is +# the property the word "tamper-evident" actually names. These are that half. +def test_chain_corpus_has_broken_fixtures() -> None: + assert _CHAIN_FIXTURES, "chainFixtures missing from the corpus" + assert any(not f["expected"]["ok"] for f in _CHAIN_FIXTURES), "no tampered chain to detect" + + +@pytest.mark.parametrize("fixture", _CHAIN_FIXTURES, ids=_CHAIN_IDS) +def test_chain_corpus_verdict_matches(fixture: dict[str, Any]) -> None: + result = verify_chain(fixture["events"], fixture.get("genesisPreviousHash")) + expected = fixture["expected"] + assert result.ok == expected["ok"], fixture["description"] + if not expected["ok"]: + assert result.broken_at == expected["brokenAt"] + assert result.code is not None + assert result.code.value == expected["code"] + + +@pytest.mark.parametrize("fixture", _CHAIN_FIXTURES, ids=_CHAIN_IDS) +def test_chain_corpus_verdict_matches_via_model(fixture: dict[str, Any]) -> None: + # Same verdict through the typed model, not just the raw wire dicts — proves + # the pydantic aliases do not lose a chain field on the way in. + events = [AuditEvent.model_validate(e) for e in fixture["events"]] + result = verify_chain(events, fixture.get("genesisPreviousHash")) + assert result.ok == fixture["expected"]["ok"], fixture["description"] + + def test_canonical_sorts_keys_and_preserves_array_order() -> None: assert canonical_json({"b": 1, "a": [3, 1, 2]}) == '{"a":[3,1,2],"b":1}' diff --git a/rust/audit/src/hash.rs b/rust/audit/src/hash.rs index b812510..cea92b8 100644 --- a/rust/audit/src/hash.rs +++ b/rust/audit/src/hash.rs @@ -43,6 +43,73 @@ pub fn build_hash_chain(events: Vec, chain_head: Option) -> .collect() } +/// Why a chain failed to verify. +/// +/// These codes are the cross-language contract — every SDK returns the same code +/// for the same corruption, asserted by `chainFixtures` in +/// `spec/parity-corpus.json`. +#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum VerifyFailureCode { + /// An event's `hashPrevious` is not the prior event's `hashCurrent` — the + /// LINK is wrong: a reorder, a deletion, a truncated head, a rewritten link. + HashPreviousMismatch, + /// The event's own content no longer hashes to its stored `hashCurrent` — + /// the event BODY was edited after sealing. + HashCurrentMismatch, +} + +/// The verdict from [`verify_chain`]. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ChainVerification { + /// Every link recomputed and matched. + Ok, + /// The first event that failed, and why. + Broken { broken_at: usize, code: VerifyFailureCode }, +} + +impl ChainVerification { + /// `true` when the chain verified intact. + pub fn is_ok(&self) -> bool { + matches!(self, ChainVerification::Ok) + } +} + +/// Verify an ordered chain: recompute every `hashCurrent` and confirm each +/// `hashPrevious` matches the prior event's `hashCurrent`. +/// +/// `genesis_previous_hash` is the hash the FIRST event must link to — pass the +/// chain head you already have when verifying a slice that continues an existing +/// chain. `None` means `events` starts at the true beginning of the chain (first +/// event of the org's day), where `hashPrevious` must be absent. +/// +/// **What replay cannot see:** removing events from the TAIL leaves a chain that +/// still verifies — every remaining link is genuine. Detecting that needs an +/// external anchor (a stored chain head, an expected count) compared against the +/// last event's `hashCurrent`. [`ChainVerification::Ok`] means "nothing here was +/// altered", not "nothing is missing"; the corpus pins this as an explicit +/// fixture so the limit stays visible. +pub fn verify_chain(events: &[AuditEvent], genesis_previous_hash: Option<&str>) -> ChainVerification { + let mut previous: Option = genesis_previous_hash.map(str::to_owned); + for (index, event) in events.iter().enumerate() { + if event.hash_previous != previous { + return ChainVerification::Broken { + broken_at: index, + code: VerifyFailureCode::HashPreviousMismatch, + }; + } + let recomputed = event.compute_hash(); + if event.hash_current.as_deref() != Some(recomputed.as_str()) { + return ChainVerification::Broken { + broken_at: index, + code: VerifyFailureCode::HashCurrentMismatch, + }; + } + previous = Some(recomputed); + } + ChainVerification::Ok +} + #[cfg(test)] mod tests { use super::*; diff --git a/rust/audit/src/lib.rs b/rust/audit/src/lib.rs index 2bbf7c6..1fea569 100644 --- a/rust/audit/src/lib.rs +++ b/rust/audit/src/lib.rs @@ -20,7 +20,7 @@ pub use crate::canonical::canonical_json; pub use crate::client::{AuditClient, AuditClientOptions}; pub use crate::envelope::{envelope_json, TraceContext}; pub use crate::error::AuditError; -pub use crate::hash::{build_hash_chain, compute_event_hash}; +pub use crate::hash::{build_hash_chain, compute_event_hash, verify_chain, ChainVerification, VerifyFailureCode}; pub use crate::schema::{actions, is_namespaced_action, ActorType, AuditDiff, AuditEvent, AuditResource, Outcome, AUDIT_ACTIONS}; #[cfg(test)] diff --git a/rust/audit/tests/parity.rs b/rust/audit/tests/parity.rs index afe2820..c992dd3 100644 --- a/rust/audit/tests/parity.rs +++ b/rust/audit/tests/parity.rs @@ -6,11 +6,31 @@ //! NEVER edit the corpus. use serde::Deserialize; -use smooai_audit::{canonical_json, compute_event_hash, AuditEvent}; +use smooai_audit::{canonical_json, compute_event_hash, verify_chain, AuditEvent, ChainVerification, VerifyFailureCode}; #[derive(Deserialize)] struct Corpus { fixtures: Vec, + #[serde(rename = "chainFixtures")] + chain_fixtures: Vec, +} + +#[derive(Deserialize)] +struct ChainFixture { + name: String, + description: String, + #[serde(rename = "genesisPreviousHash")] + genesis_previous_hash: Option, + events: Vec, + expected: ExpectedVerdict, +} + +#[derive(Deserialize)] +struct ExpectedVerdict { + ok: bool, + #[serde(rename = "brokenAt")] + broken_at: Option, + code: Option, } #[derive(Deserialize)] @@ -49,3 +69,37 @@ fn schema_roundtrip_canonical_and_hash_match() { assert_eq!(event.compute_hash(), f.expected_hash, "schema hash mismatch [{}]", f.name); } } + +// The corpus above proves SEALING only — that a hash is reproducible in five +// languages. It says nothing about whether any of them can DETECT a broken +// chain, which is the property the word "tamper-evident" actually names. The +// `chainFixtures` below are that half: real chains, really tampered with, each +// with the verdict every language must return. + +#[test] +fn chain_corpus_has_broken_fixtures() { + let corpus = corpus(); + assert!(!corpus.chain_fixtures.is_empty(), "chainFixtures missing from the corpus"); + assert!( + corpus.chain_fixtures.iter().any(|f| !f.expected.ok), + "no tampered chain to detect — the corpus would prove nothing about verification" + ); +} + +#[test] +fn chain_corpus_verdicts_match() { + for f in corpus().chain_fixtures { + let verdict = verify_chain(&f.events, f.genesis_previous_hash.as_deref()); + match (&verdict, f.expected.ok) { + (ChainVerification::Ok, true) => {} + (ChainVerification::Broken { broken_at, code }, false) => { + assert_eq!(Some(*broken_at), f.expected.broken_at, "brokenAt mismatch [{}]", f.name); + assert_eq!(Some(*code), f.expected.code, "code mismatch [{}]", f.name); + } + _ => panic!( + "verdict mismatch [{}]: got {verdict:?}, expected ok={} — {}", + f.name, f.expected.ok, f.description + ), + } + } +} diff --git a/scripts/gen-chain-fixtures.mjs b/scripts/gen-chain-fixtures.mjs new file mode 100644 index 0000000..a7c02d9 --- /dev/null +++ b/scripts/gen-chain-fixtures.mjs @@ -0,0 +1,179 @@ +#!/usr/bin/env node +/** + * Regenerate the `chainFixtures` block of `spec/parity-corpus.json`. + * + * pnpm tsdown && node scripts/gen-chain-fixtures.mjs + * + * The sealed chains are produced by the REAL TypeScript serializer (`dist/`), + * never by hand: a hand-written hash is a hash nobody computed, and the whole + * point of the corpus is that five independent implementations agree with one + * that actually ran. Corruptions are applied to the sealed output, so each + * fixture is a genuine tampered chain rather than a description of one. + * + * Only the `chainFixtures` block is rewritten — the existing `fixtures` array is + * spliced around verbatim so regenerating never reformats it. + */ +import { readFileSync, writeFileSync } from "node:fs"; +import { resolve } from "node:path"; +import process from "node:process"; +import { buildHashChain, verifyChain } from "../dist/index.mjs"; + +const root = process.cwd(); +const corpusPath = resolve(root, "spec/parity-corpus.json"); + +const base = { + organizationId: "org-1", + actorType: "user", + actorId: "user-1", + resource: { type: "crm.contact", id: "c-1" }, + outcome: "success", + metadata: {}, +}; + +const seed = [ + { + ...base, + id: "01CHAIN0000000000000000001", + action: "crm.contact_created", + timestamp: "2026-05-17T12:00:00.000Z", + }, + { + ...base, + id: "01CHAIN0000000000000000002", + action: "crm.contact_updated", + timestamp: "2026-05-17T12:00:01.000Z", + }, + { + ...base, + id: "01CHAIN0000000000000000003", + action: "crm.contact_merged", + timestamp: "2026-05-17T12:00:02.000Z", + }, + { + ...base, + id: "01CHAIN0000000000000000004", + action: "crm.contact_deleted", + timestamp: "2026-05-17T12:00:03.000Z", + }, +]; + +const GENESIS = "1111111111111111111111111111111111111111111111111111111111111111"; + +const clone = (value) => JSON.parse(JSON.stringify(value)); +const intact = buildHashChain(clone(seed)); +const continuation = buildHashChain(clone(seed), GENESIS); + +/** Each case corrupts a freshly sealed copy, so cases can never bleed into each other. */ +const cases = [ + { + name: "intact_chain", + description: + "Control: a correctly sealed four-event chain starting at the beginning of the day. Must verify.", + events: intact, + }, + { + name: "intact_continuation_chain", + description: + "A slice that continues an existing chain: the first event links to genesisPreviousHash rather than being first-of-day. Verifying it WITHOUT that seed must fail at index 0 — see unanchored_continuation_chain.", + genesisPreviousHash: GENESIS, + events: continuation, + }, + { + name: "unanchored_continuation_chain", + description: + "The same continuation slice verified with no genesisPreviousHash. The events are untouched, but a verifier that cannot be told the chain head reports a broken first link — which is why verify takes the seed.", + events: continuation, + }, + { + name: "mutated_event_field", + description: + "actorId rewritten on event 1 after sealing. The stored hashCurrent no longer covers the content.", + events: (() => { + const c = clone(intact); + c[1].actorId = "user-attacker"; + return c; + })(), + }, + { + name: "tampered_timestamp", + description: + "Event 0's timestamp backdated after sealing — the classic 'this happened earlier than it did' edit.", + events: (() => { + const c = clone(intact); + c[0].timestamp = "2026-05-17T11:59:00.000Z"; + return c; + })(), + }, + { + name: "reordered_pair", + description: + "Events 1 and 2 swapped. Both events are individually authentic; only their ORDER is wrong, so the break shows up as a link mismatch, not a content mismatch.", + events: (() => { + const c = clone(intact); + [c[1], c[2]] = [c[2], c[1]]; + return c; + })(), + }, + { + name: "broken_hash_previous_link", + description: + "Event 2's hashPrevious overwritten with a plausible-looking 64-hex value; every event body is untouched.", + events: (() => { + const c = clone(intact); + c[2].hashPrevious = "a".repeat(64); + return c; + })(), + }, + { + name: "deleted_middle_event", + description: "Event 1 removed. Event 2 still points at the hash of the event that is gone.", + events: (() => { + const c = clone(intact); + c.splice(1, 1); + return c; + })(), + }, + { + name: "truncated_chain_head_removed", + description: + "The first event dropped. The new first event carries a hashPrevious where a first-of-day event must have none.", + events: clone(intact).slice(1), + }, + { + name: "truncated_chain_tail_removed", + description: + "The LAST event dropped. This chain still verifies — every remaining link is genuine, and replay alone cannot see a deletion at the tail. Detecting it requires an external anchor (a stored chain head or expected count). This fixture exists so that limit is asserted rather than assumed: ok:true here is the honest answer, not a gap in the verifier.", + events: clone(intact).slice(0, -1), + }, + { + name: "empty_chain", + description: "No events. Vacuously intact — a verifier must not treat empty as corrupt.", + events: [], + }, +]; + +const chainFixtures = cases.map((c) => { + const verdict = verifyChain(clone(c.events), c.genesisPreviousHash); + const expected = verdict.ok + ? { ok: true } + : { ok: false, brokenAt: verdict.brokenAt, code: verdict.code }; + return { + name: c.name, + description: c.description, + ...(c.genesisPreviousHash ? { genesisPreviousHash: c.genesisPreviousHash } : {}), + events: c.events, + expected, + }; +}); + +const comment = + 'Corrupted-chain vectors for verifyChain / verify_chain / VerifyChain / HashChain.Verify. Each `events` array is a REAL chain sealed by the TS builder and then tampered with; `expected` is the verdict every language must return — `ok`, plus `brokenAt` (index of the first bad link) and `code` when it fails. `code` is the cross-language contract: "hash_previous_mismatch" (the LINK is wrong — reorder, deletion, rewritten hashPrevious, missing head) or "hash_current_mismatch" (the event BODY was edited after sealing). `genesisPreviousHash`, when present, is the chain head the first event must link to. Regenerate with `pnpm tsdown && node scripts/gen-chain-fixtures.mjs` — never hand-edit an expected value.'; + +const rendered = JSON.stringify({ $chainComment: comment, chainFixtures }, null, 4) + .replace(/^\{\n/, "") + .replace(/\n\}$/, ""); + +const raw = readFileSync(corpusPath, "utf8"); +const head = raw.replace(/,\n\s*"\$chainComment"[\s\S]*$/, "\n}").replace(/\n\}\s*$/, ""); +writeFileSync(corpusPath, `${head},\n${rendered}\n}\n`); +console.log(`Wrote ${chainFixtures.length} chain fixtures to spec/parity-corpus.json`); diff --git a/spec/parity-corpus.json b/spec/parity-corpus.json index a63e80e..57b30dd 100644 --- a/spec/parity-corpus.json +++ b/spec/parity-corpus.json @@ -202,5 +202,706 @@ "expectedCanonical": "{\"action\":\"fieldops.access_denied\",\"actorId\":\"client-3\",\"actorType\":\"api_client\",\"id\":\"01JDEN00000000000000000001\",\"metadata\":{},\"organizationId\":\"org-1\",\"outcome\":\"denied\",\"reason\":\"insufficient role\",\"resource\":{\"id\":\"site-1\",\"type\":\"fieldops_site\"},\"timestamp\":\"2026-07-02T00:00:00.000Z\"}", "expectedHash": "0f99949be0176ef0271b41c158504e6ff355dc5716626754a7631c3e72ce6d40" } + ], + "$chainComment": "Corrupted-chain vectors for verifyChain / verify_chain / VerifyChain / HashChain.Verify. Each `events` array is a REAL chain sealed by the TS builder and then tampered with; `expected` is the verdict every language must return — `ok`, plus `brokenAt` (index of the first bad link) and `code` when it fails. `code` is the cross-language contract: \"hash_previous_mismatch\" (the LINK is wrong — reorder, deletion, rewritten hashPrevious, missing head) or \"hash_current_mismatch\" (the event BODY was edited after sealing). `genesisPreviousHash`, when present, is the chain head the first event must link to. Regenerate with `pnpm tsdown && node scripts/gen-chain-fixtures.mjs` — never hand-edit an expected value.", + "chainFixtures": [ + { + "name": "intact_chain", + "description": "Control: a correctly sealed four-event chain starting at the beginning of the day. Must verify.", + "events": [ + { + "organizationId": "org-1", + "actorType": "user", + "actorId": "user-1", + "resource": { + "type": "crm.contact", + "id": "c-1" + }, + "outcome": "success", + "metadata": {}, + "id": "01CHAIN0000000000000000001", + "action": "crm.contact_created", + "timestamp": "2026-05-17T12:00:00.000Z", + "hashCurrent": "6130fff1217324c30e9cc59e88f3c91d252432119d55194620df6b1f27e758f4" + }, + { + "organizationId": "org-1", + "actorType": "user", + "actorId": "user-1", + "resource": { + "type": "crm.contact", + "id": "c-1" + }, + "outcome": "success", + "metadata": {}, + "id": "01CHAIN0000000000000000002", + "action": "crm.contact_updated", + "timestamp": "2026-05-17T12:00:01.000Z", + "hashPrevious": "6130fff1217324c30e9cc59e88f3c91d252432119d55194620df6b1f27e758f4", + "hashCurrent": "772e954fabc9910374479d3e2d70a3ac88c3316292bcf11a0c7d6743116d350a" + }, + { + "organizationId": "org-1", + "actorType": "user", + "actorId": "user-1", + "resource": { + "type": "crm.contact", + "id": "c-1" + }, + "outcome": "success", + "metadata": {}, + "id": "01CHAIN0000000000000000003", + "action": "crm.contact_merged", + "timestamp": "2026-05-17T12:00:02.000Z", + "hashPrevious": "772e954fabc9910374479d3e2d70a3ac88c3316292bcf11a0c7d6743116d350a", + "hashCurrent": "b99be950c3108de932e3db265fb4b9dec74f0c0cbda6d4ecb42939eaf9ca6a03" + }, + { + "organizationId": "org-1", + "actorType": "user", + "actorId": "user-1", + "resource": { + "type": "crm.contact", + "id": "c-1" + }, + "outcome": "success", + "metadata": {}, + "id": "01CHAIN0000000000000000004", + "action": "crm.contact_deleted", + "timestamp": "2026-05-17T12:00:03.000Z", + "hashPrevious": "b99be950c3108de932e3db265fb4b9dec74f0c0cbda6d4ecb42939eaf9ca6a03", + "hashCurrent": "323345c8f97de588b133d82c2697bd415e3b5cf670243c5fd52d9055c4728fe1" + } + ], + "expected": { + "ok": true + } + }, + { + "name": "intact_continuation_chain", + "description": "A slice that continues an existing chain: the first event links to genesisPreviousHash rather than being first-of-day. Verifying it WITHOUT that seed must fail at index 0 — see unanchored_continuation_chain.", + "genesisPreviousHash": "1111111111111111111111111111111111111111111111111111111111111111", + "events": [ + { + "organizationId": "org-1", + "actorType": "user", + "actorId": "user-1", + "resource": { + "type": "crm.contact", + "id": "c-1" + }, + "outcome": "success", + "metadata": {}, + "id": "01CHAIN0000000000000000001", + "action": "crm.contact_created", + "timestamp": "2026-05-17T12:00:00.000Z", + "hashPrevious": "1111111111111111111111111111111111111111111111111111111111111111", + "hashCurrent": "fbb2bde91cc87996ae99d977f2c071f900a5c5e0aeedfdcaab3fbfd842d48515" + }, + { + "organizationId": "org-1", + "actorType": "user", + "actorId": "user-1", + "resource": { + "type": "crm.contact", + "id": "c-1" + }, + "outcome": "success", + "metadata": {}, + "id": "01CHAIN0000000000000000002", + "action": "crm.contact_updated", + "timestamp": "2026-05-17T12:00:01.000Z", + "hashPrevious": "fbb2bde91cc87996ae99d977f2c071f900a5c5e0aeedfdcaab3fbfd842d48515", + "hashCurrent": "46f6870693e0d5aedd8fa47d8d394d7fb59d2e5d39049b3c7e1db69a4e41a927" + }, + { + "organizationId": "org-1", + "actorType": "user", + "actorId": "user-1", + "resource": { + "type": "crm.contact", + "id": "c-1" + }, + "outcome": "success", + "metadata": {}, + "id": "01CHAIN0000000000000000003", + "action": "crm.contact_merged", + "timestamp": "2026-05-17T12:00:02.000Z", + "hashPrevious": "46f6870693e0d5aedd8fa47d8d394d7fb59d2e5d39049b3c7e1db69a4e41a927", + "hashCurrent": "8e17bafb08871f7cee9a0659e81978156463cf8d67be1594b1d1ec111ef06bde" + }, + { + "organizationId": "org-1", + "actorType": "user", + "actorId": "user-1", + "resource": { + "type": "crm.contact", + "id": "c-1" + }, + "outcome": "success", + "metadata": {}, + "id": "01CHAIN0000000000000000004", + "action": "crm.contact_deleted", + "timestamp": "2026-05-17T12:00:03.000Z", + "hashPrevious": "8e17bafb08871f7cee9a0659e81978156463cf8d67be1594b1d1ec111ef06bde", + "hashCurrent": "aa5386d20241e0b7fec7b89df858c8d42b551eb51d5be2424681f358d012be2c" + } + ], + "expected": { + "ok": true + } + }, + { + "name": "unanchored_continuation_chain", + "description": "The same continuation slice verified with no genesisPreviousHash. The events are untouched, but a verifier that cannot be told the chain head reports a broken first link — which is why verify takes the seed.", + "events": [ + { + "organizationId": "org-1", + "actorType": "user", + "actorId": "user-1", + "resource": { + "type": "crm.contact", + "id": "c-1" + }, + "outcome": "success", + "metadata": {}, + "id": "01CHAIN0000000000000000001", + "action": "crm.contact_created", + "timestamp": "2026-05-17T12:00:00.000Z", + "hashPrevious": "1111111111111111111111111111111111111111111111111111111111111111", + "hashCurrent": "fbb2bde91cc87996ae99d977f2c071f900a5c5e0aeedfdcaab3fbfd842d48515" + }, + { + "organizationId": "org-1", + "actorType": "user", + "actorId": "user-1", + "resource": { + "type": "crm.contact", + "id": "c-1" + }, + "outcome": "success", + "metadata": {}, + "id": "01CHAIN0000000000000000002", + "action": "crm.contact_updated", + "timestamp": "2026-05-17T12:00:01.000Z", + "hashPrevious": "fbb2bde91cc87996ae99d977f2c071f900a5c5e0aeedfdcaab3fbfd842d48515", + "hashCurrent": "46f6870693e0d5aedd8fa47d8d394d7fb59d2e5d39049b3c7e1db69a4e41a927" + }, + { + "organizationId": "org-1", + "actorType": "user", + "actorId": "user-1", + "resource": { + "type": "crm.contact", + "id": "c-1" + }, + "outcome": "success", + "metadata": {}, + "id": "01CHAIN0000000000000000003", + "action": "crm.contact_merged", + "timestamp": "2026-05-17T12:00:02.000Z", + "hashPrevious": "46f6870693e0d5aedd8fa47d8d394d7fb59d2e5d39049b3c7e1db69a4e41a927", + "hashCurrent": "8e17bafb08871f7cee9a0659e81978156463cf8d67be1594b1d1ec111ef06bde" + }, + { + "organizationId": "org-1", + "actorType": "user", + "actorId": "user-1", + "resource": { + "type": "crm.contact", + "id": "c-1" + }, + "outcome": "success", + "metadata": {}, + "id": "01CHAIN0000000000000000004", + "action": "crm.contact_deleted", + "timestamp": "2026-05-17T12:00:03.000Z", + "hashPrevious": "8e17bafb08871f7cee9a0659e81978156463cf8d67be1594b1d1ec111ef06bde", + "hashCurrent": "aa5386d20241e0b7fec7b89df858c8d42b551eb51d5be2424681f358d012be2c" + } + ], + "expected": { + "ok": false, + "brokenAt": 0, + "code": "hash_previous_mismatch" + } + }, + { + "name": "mutated_event_field", + "description": "actorId rewritten on event 1 after sealing. The stored hashCurrent no longer covers the content.", + "events": [ + { + "organizationId": "org-1", + "actorType": "user", + "actorId": "user-1", + "resource": { + "type": "crm.contact", + "id": "c-1" + }, + "outcome": "success", + "metadata": {}, + "id": "01CHAIN0000000000000000001", + "action": "crm.contact_created", + "timestamp": "2026-05-17T12:00:00.000Z", + "hashCurrent": "6130fff1217324c30e9cc59e88f3c91d252432119d55194620df6b1f27e758f4" + }, + { + "organizationId": "org-1", + "actorType": "user", + "actorId": "user-attacker", + "resource": { + "type": "crm.contact", + "id": "c-1" + }, + "outcome": "success", + "metadata": {}, + "id": "01CHAIN0000000000000000002", + "action": "crm.contact_updated", + "timestamp": "2026-05-17T12:00:01.000Z", + "hashPrevious": "6130fff1217324c30e9cc59e88f3c91d252432119d55194620df6b1f27e758f4", + "hashCurrent": "772e954fabc9910374479d3e2d70a3ac88c3316292bcf11a0c7d6743116d350a" + }, + { + "organizationId": "org-1", + "actorType": "user", + "actorId": "user-1", + "resource": { + "type": "crm.contact", + "id": "c-1" + }, + "outcome": "success", + "metadata": {}, + "id": "01CHAIN0000000000000000003", + "action": "crm.contact_merged", + "timestamp": "2026-05-17T12:00:02.000Z", + "hashPrevious": "772e954fabc9910374479d3e2d70a3ac88c3316292bcf11a0c7d6743116d350a", + "hashCurrent": "b99be950c3108de932e3db265fb4b9dec74f0c0cbda6d4ecb42939eaf9ca6a03" + }, + { + "organizationId": "org-1", + "actorType": "user", + "actorId": "user-1", + "resource": { + "type": "crm.contact", + "id": "c-1" + }, + "outcome": "success", + "metadata": {}, + "id": "01CHAIN0000000000000000004", + "action": "crm.contact_deleted", + "timestamp": "2026-05-17T12:00:03.000Z", + "hashPrevious": "b99be950c3108de932e3db265fb4b9dec74f0c0cbda6d4ecb42939eaf9ca6a03", + "hashCurrent": "323345c8f97de588b133d82c2697bd415e3b5cf670243c5fd52d9055c4728fe1" + } + ], + "expected": { + "ok": false, + "brokenAt": 1, + "code": "hash_current_mismatch" + } + }, + { + "name": "tampered_timestamp", + "description": "Event 0's timestamp backdated after sealing — the classic 'this happened earlier than it did' edit.", + "events": [ + { + "organizationId": "org-1", + "actorType": "user", + "actorId": "user-1", + "resource": { + "type": "crm.contact", + "id": "c-1" + }, + "outcome": "success", + "metadata": {}, + "id": "01CHAIN0000000000000000001", + "action": "crm.contact_created", + "timestamp": "2026-05-17T11:59:00.000Z", + "hashCurrent": "6130fff1217324c30e9cc59e88f3c91d252432119d55194620df6b1f27e758f4" + }, + { + "organizationId": "org-1", + "actorType": "user", + "actorId": "user-1", + "resource": { + "type": "crm.contact", + "id": "c-1" + }, + "outcome": "success", + "metadata": {}, + "id": "01CHAIN0000000000000000002", + "action": "crm.contact_updated", + "timestamp": "2026-05-17T12:00:01.000Z", + "hashPrevious": "6130fff1217324c30e9cc59e88f3c91d252432119d55194620df6b1f27e758f4", + "hashCurrent": "772e954fabc9910374479d3e2d70a3ac88c3316292bcf11a0c7d6743116d350a" + }, + { + "organizationId": "org-1", + "actorType": "user", + "actorId": "user-1", + "resource": { + "type": "crm.contact", + "id": "c-1" + }, + "outcome": "success", + "metadata": {}, + "id": "01CHAIN0000000000000000003", + "action": "crm.contact_merged", + "timestamp": "2026-05-17T12:00:02.000Z", + "hashPrevious": "772e954fabc9910374479d3e2d70a3ac88c3316292bcf11a0c7d6743116d350a", + "hashCurrent": "b99be950c3108de932e3db265fb4b9dec74f0c0cbda6d4ecb42939eaf9ca6a03" + }, + { + "organizationId": "org-1", + "actorType": "user", + "actorId": "user-1", + "resource": { + "type": "crm.contact", + "id": "c-1" + }, + "outcome": "success", + "metadata": {}, + "id": "01CHAIN0000000000000000004", + "action": "crm.contact_deleted", + "timestamp": "2026-05-17T12:00:03.000Z", + "hashPrevious": "b99be950c3108de932e3db265fb4b9dec74f0c0cbda6d4ecb42939eaf9ca6a03", + "hashCurrent": "323345c8f97de588b133d82c2697bd415e3b5cf670243c5fd52d9055c4728fe1" + } + ], + "expected": { + "ok": false, + "brokenAt": 0, + "code": "hash_current_mismatch" + } + }, + { + "name": "reordered_pair", + "description": "Events 1 and 2 swapped. Both events are individually authentic; only their ORDER is wrong, so the break shows up as a link mismatch, not a content mismatch.", + "events": [ + { + "organizationId": "org-1", + "actorType": "user", + "actorId": "user-1", + "resource": { + "type": "crm.contact", + "id": "c-1" + }, + "outcome": "success", + "metadata": {}, + "id": "01CHAIN0000000000000000001", + "action": "crm.contact_created", + "timestamp": "2026-05-17T12:00:00.000Z", + "hashCurrent": "6130fff1217324c30e9cc59e88f3c91d252432119d55194620df6b1f27e758f4" + }, + { + "organizationId": "org-1", + "actorType": "user", + "actorId": "user-1", + "resource": { + "type": "crm.contact", + "id": "c-1" + }, + "outcome": "success", + "metadata": {}, + "id": "01CHAIN0000000000000000003", + "action": "crm.contact_merged", + "timestamp": "2026-05-17T12:00:02.000Z", + "hashPrevious": "772e954fabc9910374479d3e2d70a3ac88c3316292bcf11a0c7d6743116d350a", + "hashCurrent": "b99be950c3108de932e3db265fb4b9dec74f0c0cbda6d4ecb42939eaf9ca6a03" + }, + { + "organizationId": "org-1", + "actorType": "user", + "actorId": "user-1", + "resource": { + "type": "crm.contact", + "id": "c-1" + }, + "outcome": "success", + "metadata": {}, + "id": "01CHAIN0000000000000000002", + "action": "crm.contact_updated", + "timestamp": "2026-05-17T12:00:01.000Z", + "hashPrevious": "6130fff1217324c30e9cc59e88f3c91d252432119d55194620df6b1f27e758f4", + "hashCurrent": "772e954fabc9910374479d3e2d70a3ac88c3316292bcf11a0c7d6743116d350a" + }, + { + "organizationId": "org-1", + "actorType": "user", + "actorId": "user-1", + "resource": { + "type": "crm.contact", + "id": "c-1" + }, + "outcome": "success", + "metadata": {}, + "id": "01CHAIN0000000000000000004", + "action": "crm.contact_deleted", + "timestamp": "2026-05-17T12:00:03.000Z", + "hashPrevious": "b99be950c3108de932e3db265fb4b9dec74f0c0cbda6d4ecb42939eaf9ca6a03", + "hashCurrent": "323345c8f97de588b133d82c2697bd415e3b5cf670243c5fd52d9055c4728fe1" + } + ], + "expected": { + "ok": false, + "brokenAt": 1, + "code": "hash_previous_mismatch" + } + }, + { + "name": "broken_hash_previous_link", + "description": "Event 2's hashPrevious overwritten with a plausible-looking 64-hex value; every event body is untouched.", + "events": [ + { + "organizationId": "org-1", + "actorType": "user", + "actorId": "user-1", + "resource": { + "type": "crm.contact", + "id": "c-1" + }, + "outcome": "success", + "metadata": {}, + "id": "01CHAIN0000000000000000001", + "action": "crm.contact_created", + "timestamp": "2026-05-17T12:00:00.000Z", + "hashCurrent": "6130fff1217324c30e9cc59e88f3c91d252432119d55194620df6b1f27e758f4" + }, + { + "organizationId": "org-1", + "actorType": "user", + "actorId": "user-1", + "resource": { + "type": "crm.contact", + "id": "c-1" + }, + "outcome": "success", + "metadata": {}, + "id": "01CHAIN0000000000000000002", + "action": "crm.contact_updated", + "timestamp": "2026-05-17T12:00:01.000Z", + "hashPrevious": "6130fff1217324c30e9cc59e88f3c91d252432119d55194620df6b1f27e758f4", + "hashCurrent": "772e954fabc9910374479d3e2d70a3ac88c3316292bcf11a0c7d6743116d350a" + }, + { + "organizationId": "org-1", + "actorType": "user", + "actorId": "user-1", + "resource": { + "type": "crm.contact", + "id": "c-1" + }, + "outcome": "success", + "metadata": {}, + "id": "01CHAIN0000000000000000003", + "action": "crm.contact_merged", + "timestamp": "2026-05-17T12:00:02.000Z", + "hashPrevious": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "hashCurrent": "b99be950c3108de932e3db265fb4b9dec74f0c0cbda6d4ecb42939eaf9ca6a03" + }, + { + "organizationId": "org-1", + "actorType": "user", + "actorId": "user-1", + "resource": { + "type": "crm.contact", + "id": "c-1" + }, + "outcome": "success", + "metadata": {}, + "id": "01CHAIN0000000000000000004", + "action": "crm.contact_deleted", + "timestamp": "2026-05-17T12:00:03.000Z", + "hashPrevious": "b99be950c3108de932e3db265fb4b9dec74f0c0cbda6d4ecb42939eaf9ca6a03", + "hashCurrent": "323345c8f97de588b133d82c2697bd415e3b5cf670243c5fd52d9055c4728fe1" + } + ], + "expected": { + "ok": false, + "brokenAt": 2, + "code": "hash_previous_mismatch" + } + }, + { + "name": "deleted_middle_event", + "description": "Event 1 removed. Event 2 still points at the hash of the event that is gone.", + "events": [ + { + "organizationId": "org-1", + "actorType": "user", + "actorId": "user-1", + "resource": { + "type": "crm.contact", + "id": "c-1" + }, + "outcome": "success", + "metadata": {}, + "id": "01CHAIN0000000000000000001", + "action": "crm.contact_created", + "timestamp": "2026-05-17T12:00:00.000Z", + "hashCurrent": "6130fff1217324c30e9cc59e88f3c91d252432119d55194620df6b1f27e758f4" + }, + { + "organizationId": "org-1", + "actorType": "user", + "actorId": "user-1", + "resource": { + "type": "crm.contact", + "id": "c-1" + }, + "outcome": "success", + "metadata": {}, + "id": "01CHAIN0000000000000000003", + "action": "crm.contact_merged", + "timestamp": "2026-05-17T12:00:02.000Z", + "hashPrevious": "772e954fabc9910374479d3e2d70a3ac88c3316292bcf11a0c7d6743116d350a", + "hashCurrent": "b99be950c3108de932e3db265fb4b9dec74f0c0cbda6d4ecb42939eaf9ca6a03" + }, + { + "organizationId": "org-1", + "actorType": "user", + "actorId": "user-1", + "resource": { + "type": "crm.contact", + "id": "c-1" + }, + "outcome": "success", + "metadata": {}, + "id": "01CHAIN0000000000000000004", + "action": "crm.contact_deleted", + "timestamp": "2026-05-17T12:00:03.000Z", + "hashPrevious": "b99be950c3108de932e3db265fb4b9dec74f0c0cbda6d4ecb42939eaf9ca6a03", + "hashCurrent": "323345c8f97de588b133d82c2697bd415e3b5cf670243c5fd52d9055c4728fe1" + } + ], + "expected": { + "ok": false, + "brokenAt": 1, + "code": "hash_previous_mismatch" + } + }, + { + "name": "truncated_chain_head_removed", + "description": "The first event dropped. The new first event carries a hashPrevious where a first-of-day event must have none.", + "events": [ + { + "organizationId": "org-1", + "actorType": "user", + "actorId": "user-1", + "resource": { + "type": "crm.contact", + "id": "c-1" + }, + "outcome": "success", + "metadata": {}, + "id": "01CHAIN0000000000000000002", + "action": "crm.contact_updated", + "timestamp": "2026-05-17T12:00:01.000Z", + "hashPrevious": "6130fff1217324c30e9cc59e88f3c91d252432119d55194620df6b1f27e758f4", + "hashCurrent": "772e954fabc9910374479d3e2d70a3ac88c3316292bcf11a0c7d6743116d350a" + }, + { + "organizationId": "org-1", + "actorType": "user", + "actorId": "user-1", + "resource": { + "type": "crm.contact", + "id": "c-1" + }, + "outcome": "success", + "metadata": {}, + "id": "01CHAIN0000000000000000003", + "action": "crm.contact_merged", + "timestamp": "2026-05-17T12:00:02.000Z", + "hashPrevious": "772e954fabc9910374479d3e2d70a3ac88c3316292bcf11a0c7d6743116d350a", + "hashCurrent": "b99be950c3108de932e3db265fb4b9dec74f0c0cbda6d4ecb42939eaf9ca6a03" + }, + { + "organizationId": "org-1", + "actorType": "user", + "actorId": "user-1", + "resource": { + "type": "crm.contact", + "id": "c-1" + }, + "outcome": "success", + "metadata": {}, + "id": "01CHAIN0000000000000000004", + "action": "crm.contact_deleted", + "timestamp": "2026-05-17T12:00:03.000Z", + "hashPrevious": "b99be950c3108de932e3db265fb4b9dec74f0c0cbda6d4ecb42939eaf9ca6a03", + "hashCurrent": "323345c8f97de588b133d82c2697bd415e3b5cf670243c5fd52d9055c4728fe1" + } + ], + "expected": { + "ok": false, + "brokenAt": 0, + "code": "hash_previous_mismatch" + } + }, + { + "name": "truncated_chain_tail_removed", + "description": "The LAST event dropped. This chain still verifies — every remaining link is genuine, and replay alone cannot see a deletion at the tail. Detecting it requires an external anchor (a stored chain head or expected count). This fixture exists so that limit is asserted rather than assumed: ok:true here is the honest answer, not a gap in the verifier.", + "events": [ + { + "organizationId": "org-1", + "actorType": "user", + "actorId": "user-1", + "resource": { + "type": "crm.contact", + "id": "c-1" + }, + "outcome": "success", + "metadata": {}, + "id": "01CHAIN0000000000000000001", + "action": "crm.contact_created", + "timestamp": "2026-05-17T12:00:00.000Z", + "hashCurrent": "6130fff1217324c30e9cc59e88f3c91d252432119d55194620df6b1f27e758f4" + }, + { + "organizationId": "org-1", + "actorType": "user", + "actorId": "user-1", + "resource": { + "type": "crm.contact", + "id": "c-1" + }, + "outcome": "success", + "metadata": {}, + "id": "01CHAIN0000000000000000002", + "action": "crm.contact_updated", + "timestamp": "2026-05-17T12:00:01.000Z", + "hashPrevious": "6130fff1217324c30e9cc59e88f3c91d252432119d55194620df6b1f27e758f4", + "hashCurrent": "772e954fabc9910374479d3e2d70a3ac88c3316292bcf11a0c7d6743116d350a" + }, + { + "organizationId": "org-1", + "actorType": "user", + "actorId": "user-1", + "resource": { + "type": "crm.contact", + "id": "c-1" + }, + "outcome": "success", + "metadata": {}, + "id": "01CHAIN0000000000000000003", + "action": "crm.contact_merged", + "timestamp": "2026-05-17T12:00:02.000Z", + "hashPrevious": "772e954fabc9910374479d3e2d70a3ac88c3316292bcf11a0c7d6743116d350a", + "hashCurrent": "b99be950c3108de932e3db265fb4b9dec74f0c0cbda6d4ecb42939eaf9ca6a03" + } + ], + "expected": { + "ok": true + } + }, + { + "name": "empty_chain", + "description": "No events. Vacuously intact — a verifier must not treat empty as corrupt.", + "events": [], + "expected": { + "ok": true + } + } ] } diff --git a/src/hash.ts b/src/hash.ts index 47b05ea..4c6e099 100644 --- a/src/hash.ts +++ b/src/hash.ts @@ -37,25 +37,59 @@ export function buildHashChain( return chain; } +/** + * Why a chain failed to verify. These codes are the cross-language contract — + * every SDK returns the same code for the same corruption, and + * `spec/parity-corpus.json`'s `chainFixtures` assert it. + */ +export type VerifyFailureCode = + /** An event's `hashPrevious` is not the prior event's `hashCurrent` — the link + * itself is wrong: a reorder, a deletion, a truncated head, a rewritten link. */ + | "hash_previous_mismatch" + /** The event's own content no longer hashes to its stored `hashCurrent` — the + * event body was edited after sealing. */ + | "hash_current_mismatch"; + +export type ChainVerification = + | { ok: true } + | { ok: false; brokenAt: number; code: VerifyFailureCode; reason: string }; + /** * Verify an ordered chain: recompute every `hashCurrent` and confirm each * `hashPrevious` matches the prior event's `hashCurrent`. Returns the index of * the first event that fails, or `{ ok: true }` if the chain is intact. + * + * `genesisPreviousHash` is the hash the FIRST event must link to — pass the + * chain head you already have when verifying a slice that continues an existing + * chain. Omit it only when `events` starts at the true beginning of the chain + * (first event of the org's day), where `hashPrevious` must be absent. + * + * **What replay cannot see:** removing events from the TAIL leaves a chain that + * still verifies — every remaining link is genuine. Detecting that needs an + * external anchor (a stored chain head, an expected count) compared against the + * last event's `hashCurrent`. Verifying `{ ok: true }` means "nothing here was + * altered", not "nothing is missing". `spec/parity-corpus.json` pins this as an + * explicit fixture so the limit stays visible instead of being mistaken for + * coverage. */ -export function verifyChain( - events: AuditEvent[], -): { ok: true } | { ok: false; brokenAt: number; reason: string } { - let previousHash: string | undefined; +export function verifyChain(events: AuditEvent[], genesisPreviousHash?: string): ChainVerification { + let previousHash = genesisPreviousHash; for (let i = 0; i < events.length; i++) { const event = events[i]!; if (event.hashPrevious !== previousHash) { - return { ok: false, brokenAt: i, reason: "hashPrevious does not match previous event" }; + return { + ok: false, + brokenAt: i, + code: "hash_previous_mismatch", + reason: "hashPrevious does not match previous event", + }; } const recomputed = computeEventHash(event); if (recomputed !== event.hashCurrent) { return { ok: false, brokenAt: i, + code: "hash_current_mismatch", reason: "hashCurrent recomputation does not match stored value", }; } diff --git a/src/index.ts b/src/index.ts index 19d6c03..e997804 100644 --- a/src/index.ts +++ b/src/index.ts @@ -16,7 +16,13 @@ export { type AuditAction, } from "./schema"; export { canonicalJson } from "./canonical"; -export { computeEventHash, buildHashChain, verifyChain } from "./hash"; +export { + computeEventHash, + buildHashChain, + verifyChain, + type ChainVerification, + type VerifyFailureCode, +} from "./hash"; export { AuditClient, type AuditClientOptions, type SealedAuditEvent } from "./client"; export { buildEnvelope, diff --git a/src/parity-corpus.spec.ts b/src/parity-corpus.spec.ts index 476d280..5b6fbe3 100644 --- a/src/parity-corpus.spec.ts +++ b/src/parity-corpus.spec.ts @@ -2,6 +2,7 @@ import { readFileSync } from "node:fs"; import { describe, expect, it } from "vitest"; import { canonicalJson } from "./canonical"; import { buildHashChain, computeEventHash, verifyChain } from "./hash"; +import type { AuditEvent } from "./schema"; interface Fixture { name: string; @@ -11,9 +12,17 @@ interface Fixture { expectedHash: string; } +interface ChainFixture { + name: string; + description: string; + genesisPreviousHash?: string; + events: AuditEvent[]; + expected: { ok: true } | { ok: false; brokenAt: number; code: string }; +} + const corpus = JSON.parse( readFileSync(new URL("../spec/parity-corpus.json", import.meta.url), "utf8"), -) as { fixtures: Fixture[] }; +) as { fixtures: Fixture[]; chainFixtures: ChainFixture[] }; describe("parity corpus (spec/parity-corpus.json)", () => { it("has fixtures to check", () => { @@ -85,3 +94,26 @@ describe("hash chain", () => { expect(result.ok).toBe(false); }); }); + +// The corpus proved SEALING only — that a hash is reproducible in five +// languages. It said nothing about whether any of them can DETECT a broken +// chain, which is the property the word "tamper-evident" actually names. These +// fixtures are the detection half. +describe("chain corpus (spec/parity-corpus.json → chainFixtures)", () => { + it("has chain fixtures, including at least one that must be detected as broken", () => { + expect(corpus.chainFixtures.length).toBeGreaterThan(0); + expect(corpus.chainFixtures.some((f) => !f.expected.ok)).toBe(true); + }); + + for (const fixture of corpus.chainFixtures) { + it(`${fixture.name}: ${fixture.description}`, () => { + const result = verifyChain(fixture.events, fixture.genesisPreviousHash); + expect(result.ok).toBe(fixture.expected.ok); + if (!fixture.expected.ok) { + if (result.ok) throw new Error("unreachable: asserted above"); + expect(result.brokenAt).toBe(fixture.expected.brokenAt); + expect(result.code).toBe(fixture.expected.code); + } + }); + } +});