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
4 changes: 4 additions & 0 deletions docs/LESSON.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions docs/PROGRESS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
21 changes: 12 additions & 9 deletions packages/compliance/src/audit-verify.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,14 @@ 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)
* - `hash`: sha256(prev_hash || canonical(rest)) of the current record
* - `prev_hash`: sha256 of the previous record (or `null` on the
* first 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
Expand All @@ -19,7 +21,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;
}
Expand Down Expand Up @@ -53,15 +55,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 {
Expand Down
22 changes: 11 additions & 11 deletions packages/compliance/test/compliance.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,10 +20,10 @@ function canon(value: unknown): string {
.join(',')}}`;
}

function makeEvent(prev: string, body: Record<string, unknown>) {
const rest = { prev_hash: prev, ...body };
function makeEvent(prev: string, body: Record<string, unknown>, 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', () => {
Expand All @@ -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]);
Expand All @@ -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);
Expand All @@ -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);
});
Expand Down
9 changes: 3 additions & 6 deletions packages/kit/test/run-cmd.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
Expand Down
Loading