Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions .changeset/soft-melons-behave.md
Original file line number Diff line number Diff line change
@@ -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.
14 changes: 9 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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

Expand All @@ -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
Expand Down Expand Up @@ -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.

Expand All @@ -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

Expand Down
70 changes: 70 additions & 0 deletions dotnet/src/SmooAI.Audit/HashChain.cs
Original file line number Diff line number Diff line change
Expand Up @@ -37,4 +37,74 @@ public static IReadOnlyList<AuditEvent> Build(IReadOnlyList<AuditEvent> events,
}
return result;
}

/// <summary>
/// Verify an ordered chain: recompute every <see cref="AuditEvent.HashCurrent"/> and confirm
/// each <see cref="AuditEvent.HashPrevious"/> matches the prior event's
/// <see cref="AuditEvent.HashCurrent"/>.
/// <para>
/// <paramref name="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. Leave it
/// null only when <paramref name="events"/> starts at the true beginning of the chain (first
/// event of the org's day), where <c>HashPrevious</c> must be null.
/// </para>
/// <para>
/// <b>What replay cannot see:</b> 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 <c>HashCurrent</c>. An
/// <c>Ok</c> verdict means "nothing here was altered", not "nothing is missing"; the corpus pins
/// this as an explicit fixture so the limit stays visible.
/// </para>
/// </summary>
public static ChainVerification Verify(IReadOnlyList<AuditEvent> 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);
}
}

/// <summary>
/// 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 <c>chainFixtures</c> in
/// <c>spec/parity-corpus.json</c>.
/// </summary>
public enum VerifyFailureCode
{
/// <summary>
/// An event's <c>HashPrevious</c> is not the prior event's <c>HashCurrent</c> — the LINK is
/// wrong: a reorder, a deletion, a truncated head, a rewritten link.
/// </summary>
HashPreviousMismatch,

/// <summary>
/// The event's own content no longer hashes to its stored <c>HashCurrent</c> — the event BODY
/// was edited after sealing.
/// </summary>
HashCurrentMismatch,
}

/// <summary>
/// The verdict from <see cref="HashChain.Verify"/>. <paramref name="BrokenAt"/> is -1 and
/// <paramref name="Code"/> is null when <paramref name="Ok"/> is true.
/// </summary>
/// <param name="Ok">True when every link recomputed and matched.</param>
/// <param name="BrokenAt">Index of the first event that failed.</param>
/// <param name="Code">Why it failed.</param>
public sealed record ChainVerification(bool Ok, int BrokenAt, VerifyFailureCode? Code);
61 changes: 61 additions & 0 deletions dotnet/tests/SmooAI.Audit.Tests/AuditTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<object[]> 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<List<AuditEvent>>(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));
}

/// <summary>The corpus carries the shared snake_case code names, not the C# enum spelling.</summary>
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()
{
Expand Down
54 changes: 54 additions & 0 deletions go/audit_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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")
}
}
61 changes: 61 additions & 0 deletions go/hash.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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)",
Expand Down
Loading
Loading