Skip to content
Merged
Show file tree
Hide file tree
Changes from 14 commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
284fec9
feat(audit-2026-05-27): NDIS category dropdown on policy editor (Phas…
ejay-dev May 27, 2026
f9655a7
feat(audit-2026-05-27): behaviour support plan CRUD surface (Phase 3 UI)
ejay-dev May 27, 2026
80592fa
feat(audit-2026-05-27): typed register-entry sheet on /app/registers …
ejay-dev May 27, 2026
4b547f0
feat(audit-2026-05-27): per-control pass/partial/fail tally on framew…
ejay-dev May 27, 2026
3d7282a
test(audit-2026-05-27): Playwright smoke for NDIS Phase 3 UI surface
ejay-dev May 27, 2026
1bc6d1c
feat(audit-2026-05-27): compliance-health aggregation lib (Tier 2.C s…
ejay-dev May 27, 2026
b73b7a1
feat(audit-2026-05-27): /app/compliance/health unified dashboard (Tie…
ejay-dev May 27, 2026
738ce62
feat(audit-2026-05-27): compliance-health snapshot table + weekly cro…
ejay-dev May 27, 2026
a240c14
feat(audit-2026-05-27): NDIS-3.4 per-participant cadence (Tier 3.5)
ejay-dev May 27, 2026
d6678a0
chore(audit-2026-05-27): SECDEF allowlist trim batch (Tier 3.3)
ejay-dev May 27, 2026
821381e
feat(audit-2026-05-27): auto-open CAPA from evaluator fails (Tier 2.A)
ejay-dev May 27, 2026
fb6adc4
feat(audit-2026-05-27): public /verify page for Merkle bundles + Reko…
ejay-dev May 27, 2026
37322ac
docs(audit-2026-05-27): summary doc — prepend follow-up cycle (15 new…
ejay-dev May 27, 2026
7392cda
chore(audit-2026-05-27): move vector + pg_trgm out of public schema (…
ejay-dev May 27, 2026
30a0380
feat(audit-2026-05-27): industry-gated framework picker (Tier 4.1)
ejay-dev May 27, 2026
bfe0e4d
feat(audit-2026-05-27): Tier 4.4 hybrid retention (36mo hard-delete) …
ejay-dev May 27, 2026
3bc4af4
fix(audit-2026-05-27): WebSocket polyfill for Supabase JS on Node 20 CI
ejay-dev May 27, 2026
a9b54e5
fix(audit-2026-05-27): drop dead public.orgs mirror call in db:test:v…
ejay-dev May 27, 2026
dd01d5d
fix(audit-2026-05-27): wire Node 20 WS shim into test-supabase-health.js
ejay-dev May 27, 2026
4555aaf
fix(audit-2026-05-27): WebSocket polyfill for Playwright e2e helpers …
ejay-dev May 27, 2026
5b36068
fix(audit-2026-05-27): refactor e2e WebSocket polyfill to a clean TS …
ejay-dev May 27, 2026
214812f
fix(audit-2026-05-27): remove dead public.orgs mirror calls in e2e/te…
ejay-dev May 27, 2026
72a117d
fix(audit-2026-05-27): restore nowIso declaration after legacy-mirror…
ejay-dev May 27, 2026
bd6c497
fix(audit-2026-05-27): register org_progress_notes + Phase 3 tables i…
ejay-dev May 27, 2026
ee27bc5
fix(audit-2026-05-27): set file_hash on seeded org_evidence rows
ejay-dev May 27, 2026
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
182 changes: 182 additions & 0 deletions __tests__/lib/audit/verify-export-merkle-client.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,182 @@
/** @jest-environment node */
/**
* Audit 2026-05-27 (Tier 2.B) — client Merkle verifier round-trip tests.
*
* Builds a small bundle the same way scripts/verify-export-merkle.mjs +
* the audit-engine emit them, then drives verifyMerkleBundle through the
* happy path AND the three tamper variants (entry mutated, leaf hash
* mutated, proof mutated). Catches the canonicalisation / proof
* regressions that would silently let a tampered bundle pass.
*/

import { createHash } from 'node:crypto';
import {
verifyMerkleBundle,
type MerkleBundle,
} from '@/lib/audit/verify-export-merkle-client';

function sha256Hex(buf: Buffer): string {
return createHash('sha256').update(buf).digest('hex');
}

function pad(n: number, len = 2) {
return String(n).padStart(len, '0');
}
function formatCreatedAtV2(input: string) {
const d = new Date(input);
return (
`${pad(d.getUTCFullYear(), 4)}-${pad(d.getUTCMonth() + 1)}-${pad(d.getUTCDate())}` +
`T${pad(d.getUTCHours())}:${pad(d.getUTCMinutes())}:${pad(d.getUTCSeconds())}` +
`.${pad(d.getUTCMilliseconds(), 3)}Z`
);
}

function canonical(entry: Record<string, unknown>, orgId: string): string {
return JSON.stringify({
id: entry.id,
org_id: orgId,
user_id: entry.user_id ?? null,
action: entry.action,
resource_type: entry.resource_type,
resource_id: entry.resource_id ?? null,
details: entry.details ?? {},
created_at: formatCreatedAtV2(entry.created_at as string),
prev_hash: entry.prev_hash || '',
});
}

function leafHash(canonicalUtf8: string): string {
return sha256Hex(Buffer.concat([Buffer.from([0x00]), Buffer.from(canonicalUtf8, 'utf8')]));
}

function nodeHash(leftHex: string, rightHex: string): string {
return sha256Hex(
Buffer.concat([
Buffer.from([0x01]),
Buffer.from(leftHex, 'hex'),
Buffer.from(rightHex, 'hex'),
]),
);
}

/**
* Build a balanced Merkle tree with explicit per-leaf proofs. The
* audit-engine uses an RFC-6962-style construction; for the test we
* use 4 leaves (one round of pairing produces 2 intermediate nodes,
* then a single root).
*/
function buildBundle(): MerkleBundle {
const orgId = '00000000-0000-0000-0000-000000000001';
const baseAt = '2026-05-26T12:00:00.000Z';
const entries = Array.from({ length: 4 }, (_, i) => ({
id: `evt-${i}`,
user_id: null,
action: 'TEST',
resource_type: 'unit',
resource_id: null,
details: { i },
created_at: baseAt,
prev_hash: '',
}));

const leaves = entries.map((e) => leafHash(canonical(e, orgId)));

// Build per-entry inclusion proofs for a 4-leaf balanced tree.
// Tree:
// root = node(L01, L23)
// L01 = node(leaf0, leaf1)
// L23 = node(leaf2, leaf3)
const L01 = nodeHash(leaves[0], leaves[1]);
const L23 = nodeHash(leaves[2], leaves[3]);
const root = nodeHash(L01, L23);

const proofs: MerkleBundle['merkle'] extends infer M
? M extends { proofs?: infer P }
? P
: never
: never = {
'evt-0': [
{ position: 'right', hash: leaves[1] },
{ position: 'right', hash: L23 },
],
'evt-1': [
{ position: 'left', hash: leaves[0] },
{ position: 'right', hash: L23 },
],
'evt-2': [
{ position: 'right', hash: leaves[3] },
{ position: 'left', hash: L01 },
],
'evt-3': [
{ position: 'left', hash: leaves[2] },
{ position: 'left', hash: L01 },
],
};

return {
manifest: { org_id: orgId, generated_at: '2026-05-27T00:00:00.000Z' },
merkle: { algorithm: 'sha256', tree_size: 4, root, proofs },
entries: entries.map((e, i) => ({ ...e, leaf_hash: leaves[i] })),
};
}

describe('verifyMerkleBundle()', () => {
it('verifies a well-formed 4-leaf bundle', async () => {
const out = await verifyMerkleBundle(buildBundle());
expect(out.ok).toBe(true);
expect(out.steps.every((s) => s.status === 'pass')).toBe(true);
expect(out.summary.tree_size).toBe(4);
});

it('reports an empty tree as verified (no entries to check)', async () => {
const out = await verifyMerkleBundle({
manifest: { org_id: 'org-x' },
merkle: { algorithm: 'sha256', tree_size: 0, empty_tree: true, root: '' },
entries: [],
});
expect(out.ok).toBe(true);
expect(out.steps.some((s) => s.label === 'Empty tree')).toBe(true);
});

it('fails when the manifest / merkle / entries are missing', async () => {
const out = await verifyMerkleBundle({} as MerkleBundle);
expect(out.ok).toBe(false);
expect(out.steps[0].label).toBe('Bundle shape');
});

it('rejects unsupported algorithms', async () => {
const bundle = buildBundle();
bundle.merkle!.algorithm = 'sha512';
const out = await verifyMerkleBundle(bundle);
expect(out.ok).toBe(false);
expect(out.steps.find((s) => s.label === 'Algorithm')?.status).toBe('fail');
});

it('catches a tampered entry (leaf-hash mismatch)', async () => {
const bundle = buildBundle();
// Mutate the details payload without updating leaf_hash.
bundle.entries![1].details = { i: 1, tampered: true };
const out = await verifyMerkleBundle(bundle);
expect(out.ok).toBe(false);
expect(out.steps.find((s) => s.label === 'Leaf hashes')?.status).toBe('fail');
});

it('catches a tampered proof (does not reconstruct the root)', async () => {
const bundle = buildBundle();
// Replace one sibling hash with a different value.
bundle.merkle!.proofs!['evt-0'][0].hash = 'f'.repeat(64);
const out = await verifyMerkleBundle(bundle);
expect(out.ok).toBe(false);
expect(out.steps.find((s) => s.label === 'Inclusion proofs')?.status).toBe('fail');
});

it('catches tree_size / entries.length mismatch', async () => {
const bundle = buildBundle();
bundle.merkle!.tree_size = 3;
const out = await verifyMerkleBundle(bundle);
expect(out.ok).toBe(false);
expect(out.steps.find((s) => s.label === 'tree_size matches entries.length')?.status).toBe(
'fail',
);
});
});
178 changes: 178 additions & 0 deletions __tests__/lib/audit/verify-rekor-anchor-client.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,178 @@
/** @jest-environment node */
/**
* Audit 2026-05-27 (Tier 2.B) — client Rekor anchor verifier tests.
*
* Drives the verifier with a fake fetcher so we don't hit the real
* rekor.sigstore.dev during CI. Round-trips a real ECDSA P-256 signature
* via node:crypto to confirm the DER → raw conversion + SubtleCrypto
* verify path actually validates a genuine signature.
*/

import {
generateKeyPairSync,
createSign,
randomBytes,
createHash,
} from 'node:crypto';
import {
verifyRekorAnchor,
derEcdsaToRaw,
} from '@/lib/audit/verify-rekor-anchor-client';

function sha256Hex(s: string): string {
return createHash('sha256').update(s, 'utf8').digest('hex');
}

function makeRekorEntryBody(args: {
expectedHash: string;
signatureDerB64: string;
publicKeyPemB64: string;
}) {
return Buffer.from(
JSON.stringify({
kind: 'hashedrekord',
apiVersion: '0.0.1',
spec: {
data: { hash: { algorithm: 'sha256', value: args.expectedHash } },
signature: {
content: args.signatureDerB64,
publicKey: { content: args.publicKeyPemB64 },
},
},
}),
'utf8',
).toString('base64');
}

describe('derEcdsaToRaw()', () => {
it('parses a real DER ECDSA P-256 signature into 64 raw bytes', () => {
const { publicKey: _pub, privateKey } = generateKeyPairSync('ec', {
namedCurve: 'P-256',
});
const signer = createSign('SHA256');
signer.update('test message');
signer.end();
const der = signer.sign({ key: privateKey, dsaEncoding: 'der' });
const raw = derEcdsaToRaw(new Uint8Array(der), 32);
expect(raw.length).toBe(64);
});

it('throws on a non-SEQUENCE DER blob', () => {
expect(() => derEcdsaToRaw(new Uint8Array([0x04, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00]))).toThrow(
/SEQUENCE/,
);
});
});

describe('verifyRekorAnchor()', () => {
function makeFetcher(body: string, opts: Partial<{ status: number; ok: boolean }> = {}) {
return async (_url: string): Promise<Response> =>
({
ok: opts.ok ?? true,
status: opts.status ?? 200,
json: async () => ({
'uuid-1': {
body,
integratedTime: 1716800000,
logIndex: 42,
logID: 'mock-log-id',
},
}),
} as unknown as Response);
}

it('rejects a non-hex expected_top_hash', async () => {
const out = await verifyRekorAnchor({
uuid: 'uuid-1',
expectedTopHash: 'not-hex',
fetcher: makeFetcher('e30=' /* {} */),
});
expect(out.ok).toBe(false);
expect(out.steps[0].status).toBe('fail');
});

it('fails when Rekor returns HTTP 404', async () => {
const out = await verifyRekorAnchor({
uuid: 'uuid-1',
expectedTopHash: sha256Hex('whatever'),
fetcher: makeFetcher('', { ok: false, status: 404 }),
});
expect(out.ok).toBe(false);
expect(out.steps.find((s) => s.label === 'Rekor lookup')?.status).toBe('fail');
});

it('fails when the recorded hash does not match the expected hash', async () => {
const expected = sha256Hex('top-of-chain');
const wrong = sha256Hex('something-else');
const body = makeRekorEntryBody({
expectedHash: wrong,
signatureDerB64: 'AA==',
publicKeyPemB64: Buffer.from('-----BEGIN PUBLIC KEY-----\nAA==\n-----END PUBLIC KEY-----').toString(
'base64',
),
});
const out = await verifyRekorAnchor({
uuid: 'uuid-1',
expectedTopHash: expected,
fetcher: makeFetcher(body),
});
expect(out.ok).toBe(false);
expect(out.steps.find((s) => s.label === 'Hash match')?.status).toBe('fail');
});

it('end-to-end: verifies a genuine ECDSA P-256 signature round-tripped through DER', async () => {
const { publicKey, privateKey } = generateKeyPairSync('ec', {
namedCurve: 'P-256',
});
const pubPem = publicKey.export({ type: 'spki', format: 'pem' }).toString();
const expected = randomBytes(32).toString('hex');

const signer = createSign('SHA256');
signer.update(expected, 'utf8');
signer.end();
const sigDer = signer.sign({ key: privateKey, dsaEncoding: 'der' });

const body = makeRekorEntryBody({
expectedHash: expected,
signatureDerB64: Buffer.from(sigDer).toString('base64'),
publicKeyPemB64: Buffer.from(pubPem, 'utf8').toString('base64'),
});

const out = await verifyRekorAnchor({
uuid: 'uuid-1',
expectedTopHash: expected,
fetcher: makeFetcher(body),
});
expect(out.ok).toBe(true);
expect(out.summary.recorded_hash).toBe(expected);
expect(out.steps.every((s) => s.status === 'pass')).toBe(true);
});

it('fails verification when the signature was made over a different message', async () => {
const { publicKey, privateKey } = generateKeyPairSync('ec', {
namedCurve: 'P-256',
});
const pubPem = publicKey.export({ type: 'spki', format: 'pem' }).toString();
const expected = sha256Hex('claimed-top');

// Sign a DIFFERENT message — verifier should reject.
const signer = createSign('SHA256');
signer.update('not-the-claimed-top', 'utf8');
signer.end();
const sigDer = signer.sign({ key: privateKey, dsaEncoding: 'der' });

const body = makeRekorEntryBody({
expectedHash: expected,
signatureDerB64: Buffer.from(sigDer).toString('base64'),
publicKeyPemB64: Buffer.from(pubPem, 'utf8').toString('base64'),
});

const out = await verifyRekorAnchor({
uuid: 'uuid-1',
expectedTopHash: expected,
fetcher: makeFetcher(body),
});
expect(out.ok).toBe(false);
expect(out.steps.find((s) => s.label === 'Signature verify')?.status).toBe('fail');
});
});
Loading
Loading