190 lines
5.9 KiB
TypeScript
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();
|
||
|
|
});
|
||
|
|
});
|