From 678f96c7836922acabe1b51e6214cc388f0d7333 Mon Sep 17 00:00:00 2001 From: "lorenzo.padovani@padosoft.com" Date: Wed, 20 May 2026 12:16:04 +0200 Subject: [PATCH 1/2] fix(v1.8.1): reconcile audit-chain verifier with writer canonicalization --- docs/LESSON.md | 4 ++++ docs/PROGRESS.md | 1 + packages/compliance/src/audit-verify.ts | 13 ++++++------ packages/compliance/test/compliance.test.ts | 22 ++++++++++----------- packages/kit/test/run-cmd.test.ts | 9 +++------ 5 files changed, 26 insertions(+), 23 deletions(-) diff --git a/docs/LESSON.md b/docs/LESSON.md index ef04916..2956350 100644 --- a/docs/LESSON.md +++ b/docs/LESSON.md @@ -21,6 +21,10 @@ - Topic / context — what was learned + why it matters. Reference files/commits when useful. ``` +## 2026-05-20 + +- **Hash-chain verifier and writer must share the exact same canonical body contract.** `EventChainWriter` hashes `sha256(prev_hash || canonical(rest_without_prev_hash_and_hash))` while persisted events expose `prev_hash: null` on the first record. A verifier that re-hashes including `prev_hash` (or expects the first record to carry the all-zero seed literal in `prev_hash`) will produce false mismatches on valid logs. Keep one canonical rule across writer and verifier, and treat the all-zero seed as internal hash input only. + ## 2026-05-18 (v1.0 → v1.1 retrospective — patterns across the full 24-task roadmap) - **Bundle 2-4 tasks per minor-version PR.** Once the validation loop is diff --git a/docs/PROGRESS.md b/docs/PROGRESS.md index b97a55d..619ce8e 100644 --- a/docs/PROGRESS.md +++ b/docs/PROGRESS.md @@ -11,6 +11,7 @@ ## 2026-05-20 +- **v1.8.1 slice closed — audit-chain canonical reconciliation.** Aligned `@aqa/compliance.verifyEventChain` with `@aqa/runner.EventChainWriter`: hash recomputation now excludes `prev_hash` from canonical body (matches writer), and first-record `prev_hash: null` is now treated as canonical instead of expecting all-zero literal in the field. Updated compliance tests and removed stale divergence note in `@aqa/kit` run smoke tests. - **v1.x docs closure in progress — README/docs refresh pass started.** Removed stale preview/stub wording from README, added the new **How you use it** section after the 7-word model, updated quick-start flow to the current shipped commands (including admin panel boot), and aligned `PACK-AUTHORING.md` with the real HTTP probe runner now shipped in v1.8 (`aqa run` uses `project.sut.base_url` for `http` probes). - **v1.7 slice 4j closed — AuditChainViewer autoload from live initial chain.** Removed the manual dependency on "Load good chain" for live audit data: `AuditChainViewer` now consumes `initialChain` reactively, resets verify state safely on incoming chain changes, and both Audit pages pass normalized `/api/audit` events via `initialChain`. Added e2e coverage proving `/api/audit` data auto-loads and verifies to `CHAIN OK` without demo-button interaction. - **v1.7 slice 4f closed — Admin section pages wired to existing endpoints.** PR #40 (`4c93bb7`). PageTokens fetches `GET /api/tokens` with `x-aqa-org` (adapts `@aqa/schemas` ApiToken to the page's fixture shape: `display_name → name`, `last_used_at → last_used`, owner-prefix heuristic for `kind`). PageOrg fetches `GET /api/orgs` and joins live slugs into the subtitle. PageAdminAudit shares the slice 4e `/api/audit` wire with admin-view copy via a new `normalizeAuditEventsForViewer` helper. `fmtDate`/`fmtDateTime` made null-safe (em-dash for missing dates). Create-token modal scope chips switched from pre-schema `runs:write`/`packs:install`/`admin` to the actual `ApiTokenScope` enum. **Users/Roles/SSO deferred** — no server scaffolding exists; out of scope. 4 new e2e tests in `admin-section.e2e.ts`. 6 Copilot review iterations. diff --git a/packages/compliance/src/audit-verify.ts b/packages/compliance/src/audit-verify.ts index fc39cdd..a05182a 100644 --- a/packages/compliance/src/audit-verify.ts +++ b/packages/compliance/src/audit-verify.ts @@ -4,8 +4,8 @@ import { createHash } from 'node:crypto'; * Hash-chain verification for `events.jsonl` audit logs. * * Each event line is a JSON object containing at minimum: - * - `prev_hash`: sha256 of the previous canonical record (or all-zeros - * for the first record) + * - `prev_hash`: sha256 of the previous record (or `null` on the + * first record) * - `hash`: sha256(prev_hash || canonical(rest)) of the current record * * `verifyEventChain(lines)` re-walks the chain and returns the index of @@ -19,7 +19,7 @@ import { createHash } from 'node:crypto'; const ZERO_HASH = '0'.repeat(64); export interface AuditEvent { - prev_hash: string; + prev_hash: string | null; hash: string; [k: string]: unknown; } @@ -53,15 +53,16 @@ export function verifyEventChain(events: AuditEvent[]): ChainVerifyResult { if (!ev) { return { ok: false, bad_index: i, reason: 'empty record', count: events.length }; } - if (ev.prev_hash !== expectedPrev) { + const expectedField = i === 0 ? null : expectedPrev; + if (ev.prev_hash !== expectedField) { return { ok: false, bad_index: i, - reason: `prev_hash mismatch (expected ${expectedPrev.slice(0, 12)}…, got ${String(ev.prev_hash).slice(0, 12)}…)`, + reason: `prev_hash mismatch (expected ${String(expectedField).slice(0, 12)}…, got ${String(ev.prev_hash).slice(0, 12)}…)`, count: events.length, }; } - const { hash, ...rest } = ev; + const { hash, prev_hash: _prevHash, ...rest } = ev; const recomputed = computeHash(expectedPrev, rest); if (recomputed !== hash) { return { diff --git a/packages/compliance/test/compliance.test.ts b/packages/compliance/test/compliance.test.ts index 30647aa..c6200eb 100644 --- a/packages/compliance/test/compliance.test.ts +++ b/packages/compliance/test/compliance.test.ts @@ -20,10 +20,10 @@ function canon(value: unknown): string { .join(',')}}`; } -function makeEvent(prev: string, body: Record) { - const rest = { prev_hash: prev, ...body }; +function makeEvent(prev: string, body: Record, index: number) { + const rest = { ...body }; const hash = createHash('sha256').update(prev).update(canon(rest)).digest('hex'); - return { ...rest, hash }; + return { prev_hash: index === 0 ? null : prev, ...rest, hash }; } describe('controls catalog', () => { @@ -49,17 +49,17 @@ describe('controls catalog', () => { describe('verifyEventChain', () => { it('accepts a well-formed 3-event chain', () => { - const e1 = makeEvent(ZERO, { kind: 'run.start', t: 1 }); - const e2 = makeEvent(e1.hash, { kind: 'scenario', t: 2 }); - const e3 = makeEvent(e2.hash, { kind: 'run.end', t: 3 }); + const e1 = makeEvent(ZERO, { kind: 'run.start', t: 1 }, 0); + const e2 = makeEvent(e1.hash, { kind: 'scenario', t: 2 }, 1); + const e3 = makeEvent(e2.hash, { kind: 'run.end', t: 3 }, 2); const result = verifyEventChain([e1, e2, e3]); assert.equal(result.ok, true); assert.equal(result.count, 3); }); it('rejects a tampered body', () => { - const e1 = makeEvent(ZERO, { kind: 'run.start', t: 1 }); - const e2 = makeEvent(e1.hash, { kind: 'scenario', t: 2 }); + const e1 = makeEvent(ZERO, { kind: 'run.start', t: 1 }, 0); + const e2 = makeEvent(e1.hash, { kind: 'scenario', t: 2 }, 1); // mutate body without recomputing hash const tampered = { ...e2, kind: 'scenario-evil' }; const result = verifyEventChain([e1, tampered]); @@ -68,8 +68,8 @@ describe('verifyEventChain', () => { }); it('rejects a broken prev_hash link', () => { - const e1 = makeEvent(ZERO, { kind: 'run.start', t: 1 }); - const e2 = makeEvent('a'.repeat(64), { kind: 'scenario', t: 2 }); + const e1 = makeEvent(ZERO, { kind: 'run.start', t: 1 }, 0); + const e2 = makeEvent('a'.repeat(64), { kind: 'scenario', t: 2 }, 1); const result = verifyEventChain([e1, e2]); assert.equal(result.ok, false); assert.equal(result.bad_index, 1); @@ -78,7 +78,7 @@ describe('verifyEventChain', () => { describe('parseEventLines', () => { it('parses one event per non-empty line', () => { - const lines = `${JSON.stringify({ prev_hash: ZERO, hash: 'x', a: 1 })}\n\n${JSON.stringify({ prev_hash: 'x', hash: 'y', a: 2 })}\n`; + const lines = `${JSON.stringify({ prev_hash: null, hash: 'x', a: 1 })}\n\n${JSON.stringify({ prev_hash: 'x', hash: 'y', a: 2 })}\n`; const events = parseEventLines(lines); assert.equal(events.length, 2); }); diff --git a/packages/kit/test/run-cmd.test.ts b/packages/kit/test/run-cmd.test.ts index 0e84745..fae594b 100644 --- a/packages/kit/test/run-cmd.test.ts +++ b/packages/kit/test/run-cmd.test.ts @@ -33,12 +33,9 @@ import { runInit } from '../dist/commands/init.js'; import { runRun } from '../dist/commands/run.js'; /** - * Re-walk the writer's hash chain. We can't share `@aqa/compliance.verifyEventChain` - * directly because the two implementations differ slightly on canonical form - * (writer omits prev_hash from the canonical body, verifier includes it) and on - * seq=0 (writer emits null, verifier expects all-zero hash). Reconciling the - * two formats is tracked as a separate cleanup; this local verifier mirrors - * `packages/runner/src/events.ts` exactly. + * Re-walk the writer's hash chain. This mirrors `packages/runner/src/events.ts` + * (and now also the compliance verifier logic), but stays local to avoid + * pulling an extra cross-workspace runtime dependency into @aqa/kit tests. */ function canonicalise(value: unknown): string { return JSON.stringify(value, (_k, v) => { From 041fcca3a94ad80635d9b080f7fff73ec52a240b Mon Sep 17 00:00:00 2001 From: "lorenzo.padovani@padosoft.com" Date: Wed, 20 May 2026 12:20:00 +0200 Subject: [PATCH 2/2] docs(compliance): clarify verifier hash-chain contract and return shape --- packages/compliance/src/audit-verify.ts | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/packages/compliance/src/audit-verify.ts b/packages/compliance/src/audit-verify.ts index a05182a..ed5f9c9 100644 --- a/packages/compliance/src/audit-verify.ts +++ b/packages/compliance/src/audit-verify.ts @@ -6,10 +6,12 @@ import { createHash } from 'node:crypto'; * Each event line is a JSON object containing at minimum: * - `prev_hash`: sha256 of the previous record (or `null` on the * first record) - * - `hash`: sha256(prev_hash || canonical(rest)) of the current record + * - `hash`: sha256(seed_prev_hash || canonical(rest_without_prev_hash_and_hash)) + * of the current record. The first event uses an internal all-zero + * seed as `seed_prev_hash`. * - * `verifyEventChain(lines)` re-walks the chain and returns the index of - * the first mismatch, or -1 if the chain is intact. + * `verifyEventChain(events)` re-walks the chain and returns a structured + * result (`ok`, `bad_index`, `reason`, `count`). * * Why this exists: SOC2 CC7.1/CC7.2 and ISO A.8.15 ask for tamper-evident * logging. A hash chain is mechanically verifiable — auditors do not need