Files
Shade/packages/shade-key-transparency/tests/log.test.ts
Sterister e6fdf31b49
Some checks failed
Test / test (push) Has been cancelled
Cross-platform vectors / TypeScript vectors (bun) (push) Has been cancelled
Cross-platform vectors / Kotlin vectors (gradle) (push) Has been cancelled
Docker build and publish / docker (push) Has been cancelled
Publish / publish (push) Has been cancelled
release(v4.0.0): Shade GA — V3.x consolidation + audit prep
V3.1 → V3.12 consolidated and tagged for the first GA release. Wire
format unchanged from 0.4.x — 4.0 peers interoperate with 0.4.x peers
byte-for-byte. The version bump is semantic: audit-cycle complete,
opt-in surface fully exposed, threat model refreshed for every new
surface.

Highlights:
- All 24 @shade/* packages bumped to 4.0.0 in lockstep.
- CHANGELOG 4.0.0 section is the canonical manifest of what landed.
- THREAT-MODEL extended (§10 fingerprint gates, §11 WebRTC P2P, §12
  Web-Worker boundary) + residual-risks table refreshed.
- OpenAPI now covers all 27 routes: prekey, transfer, KT, inbox,
  bridge, observer, /metrics, /healthz, /ready.
- MIGRATION 0.3.x → 4.0 documented + smoke-tested against
  shade migrate-storage on a real SQLite DB.
- docs/audit/REVIEW-BUNDLE.md + SCOPE.md ready for external reviewer.
- scripts/soak.ts harness for the GA-stable 2-week soak window.
- All V*.md plans archived under docs/archive/ with Status: Done.
- Voice/Video carved out into V5.0; 4.0 audit focuses on the frozen
  non-realtime stack.

Tests: TS 1000/1000 + Kotlin 11/11 cross-platform vectors green.
Docker: gt.zyon.no/stian/shade-prekey:4.0.0 builds and reports
  version 4.0.0 on /health.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 18:35:35 +02:00

190 lines
5.9 KiB
TypeScript

import { describe, expect, test } from 'bun:test';
import fc from 'fast-check';
import {
MerkleLog,
auditPath,
consistencyProof,
emptyRootHash,
leafHash,
nodeHash,
recomputeRootFromAuditPath,
verifyConsistencyProof,
} from '../src/index.js';
import { mth } from '../src/log.js';
function buildLog(n: number): MerkleLog {
const log = new MerkleLog();
for (let i = 0; i < n; i++) {
log.appendData(new Uint8Array([i & 0xff, (i >> 8) & 0xff]));
}
return log;
}
describe('MerkleLog basics', () => {
test('empty tree root = SHA-256(empty)', () => {
const log = new MerkleLog();
expect(log.rootHash()).toEqual(emptyRootHash());
});
test('single-leaf tree root = leaf_hash(d)', () => {
const log = new MerkleLog();
const d = new Uint8Array([0xab, 0xcd]);
log.appendData(d);
expect(log.rootHash()).toEqual(leafHash(d));
});
test('two-leaf tree root = node_hash(leaf0, leaf1)', () => {
const log = new MerkleLog();
const d0 = new Uint8Array([1]);
const d1 = new Uint8Array([2]);
log.appendData(d0);
log.appendData(d1);
expect(log.rootHash()).toEqual(nodeHash(leafHash(d0), leafHash(d1)));
});
test('append-only ordering preserved', () => {
const log = new MerkleLog();
log.appendData(new Uint8Array([1]));
log.appendData(new Uint8Array([2]));
expect(log.size).toBe(2);
log.appendData(new Uint8Array([3]));
expect(log.size).toBe(3);
});
});
describe('Audit path verification', () => {
test('valid audit path reconstructs root for every leaf, every size 1..32', () => {
for (let n = 1; n <= 32; n++) {
const log = buildLog(n);
const root = log.rootHash();
for (let m = 0; m < n; m++) {
const path = log.auditPath(m);
const lh = log.leafHashAt(m);
const reconstructed = recomputeRootFromAuditPath(lh, m, n, path);
expect(Buffer.from(reconstructed).toString('hex')).toBe(
Buffer.from(root).toString('hex'),
);
}
}
});
test('property: tampered leaf fails verification', () => {
const log = buildLog(7);
const root = log.rootHash();
const path = log.auditPath(3);
const tampered = new Uint8Array(log.leafHashAt(3));
tampered[0] ^= 0xff;
const reconstructed = recomputeRootFromAuditPath(tampered, 3, 7, path);
expect(Buffer.from(reconstructed).toString('hex')).not.toBe(
Buffer.from(root).toString('hex'),
);
});
test('property: tampered audit path fails verification', () => {
const log = buildLog(11);
const root = log.rootHash();
const path = log.auditPath(5);
if (path.length === 0) return;
const tampered = path.map((p, i) => {
if (i === 0) {
const x = new Uint8Array(p);
x[0] ^= 0xff;
return x;
}
return p;
});
const reconstructed = recomputeRootFromAuditPath(log.leafHashAt(5), 5, 11, tampered);
expect(Buffer.from(reconstructed).toString('hex')).not.toBe(
Buffer.from(root).toString('hex'),
);
});
test('property-based: random N and m', () => {
fc.assert(
fc.property(fc.integer({ min: 1, max: 100 }), (n) => {
const log = buildLog(n);
const root = log.rootHash();
const m = Math.min(n - 1, Math.floor(Math.random() * n));
const path = log.auditPath(m);
const reconstructed = recomputeRootFromAuditPath(log.leafHashAt(m), m, n, path);
return Buffer.from(reconstructed).toString('hex') === Buffer.from(root).toString('hex');
}),
{ numRuns: 50 },
);
});
});
describe('Consistency proofs', () => {
test('m === 0 always consistent', () => {
const log = buildLog(5);
const newRoot = log.rootHash();
expect(verifyConsistencyProof(0, 5, emptyRootHash(), newRoot, [])).toBe(true);
});
test('m === n consistent only when both roots match and proof empty', () => {
const log = buildLog(5);
const root = log.rootHash();
expect(verifyConsistencyProof(5, 5, root, root, [])).toBe(true);
const wrong = new Uint8Array(root);
wrong[0] ^= 0xff;
expect(verifyConsistencyProof(5, 5, wrong, root, [])).toBe(false);
});
test('valid proof verifies for every (m, n) up to 16', () => {
for (let n = 1; n <= 16; n++) {
const newLog = buildLog(n);
const newRoot = newLog.rootHash();
for (let m = 0; m <= n; m++) {
const oldLog = buildLog(m);
const oldRoot = oldLog.rootHash();
const proof = newLog.consistencyProof(m);
expect(verifyConsistencyProof(m, n, oldRoot, newRoot, proof)).toBe(true);
}
}
});
test('fork detection — re-write history fails consistency', () => {
const original = buildLog(5);
const oldRoot = original.rootHash();
// Server "rewrites" leaf 2: build a new log where leaf 2 has different data.
const tampered = new MerkleLog();
for (let i = 0; i < 5; i++) {
tampered.appendData(
i === 2 ? new Uint8Array([0x99, 0x99]) : new Uint8Array([i & 0xff, (i >> 8) & 0xff]),
);
}
tampered.appendData(new Uint8Array([0x77]));
const tamperedRoot = tampered.rootHash();
const proof = tampered.consistencyProof(5);
expect(verifyConsistencyProof(5, 6, oldRoot, tamperedRoot, proof)).toBe(false);
});
});
describe('mth helper', () => {
test('mth over slice == sub-tree root', () => {
const log = buildLog(4);
const leaves = log.exportLeaves();
const left = mth(leaves, 0, 2);
const right = mth(leaves, 2, 4);
const root = mth(leaves, 0, 4);
expect(root).toEqual(nodeHash(left, right));
});
});
describe('Direct auditPath helper (tree size 1)', () => {
test('singleton tree audit path is empty', () => {
const log = buildLog(1);
const path = log.auditPath(0);
expect(path.length).toBe(0);
expect(consistencyProof([log.leafHashAt(0)], 1, 1)).toEqual([]);
});
test('auditPath out-of-range throws', () => {
expect(() => auditPath([], 0, 0)).toThrow();
const log = buildLog(3);
expect(() => log.auditPath(3)).toThrow();
});
});