release(v4.0.0): Shade GA — V3.x consolidation + audit prep
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

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>
This commit is contained in:
2026-05-03 18:35:35 +02:00
parent 8b055912b7
commit e6fdf31b49
298 changed files with 37909 additions and 256 deletions

View File

@@ -0,0 +1,127 @@
import { describe, expect, test } from 'bun:test';
import {
DOMAIN_BUNDLE,
computeBundleHash,
encodeLeafData,
leafHash,
nodeHash,
OP_REGISTER,
} from '../src/hashes.js';
import { sha256Sync } from '../src/sha256.js';
describe('RFC 6962 hash primitives', () => {
test('leafHash applies 0x00 prefix', () => {
const data = new Uint8Array([1, 2, 3]);
const expected = sha256Sync(new Uint8Array([0x00, 1, 2, 3]));
expect(leafHash(data)).toEqual(expected);
});
test('nodeHash applies 0x01 prefix', () => {
const left = new Uint8Array(32).fill(0xaa);
const right = new Uint8Array(32).fill(0xbb);
const concat = new Uint8Array(1 + 32 + 32);
concat[0] = 0x01;
concat.set(left, 1);
concat.set(right, 33);
const expected = sha256Sync(concat);
expect(nodeHash(left, right)).toEqual(expected);
});
test('leafHash and nodeHash never collide', () => {
// Same content but different domain → different hash
const x = new Uint8Array(32).fill(0x42);
const lh = leafHash(x);
const concat = new Uint8Array(64).fill(0x42);
const nh = nodeHash(concat.slice(0, 32), concat.slice(32));
expect(Buffer.from(lh).toString('hex')).not.toBe(Buffer.from(nh).toString('hex'));
});
});
describe('encodeLeafData', () => {
test('encodes timestamp + operation + address + bundleHash', () => {
const buf = encodeLeafData(
0x010203040506,
OP_REGISTER,
'alice',
new Uint8Array([0xde, 0xad, 0xbe, 0xef]),
);
// 8 + 1 + 2 + 5 + 2 + 4 = 22
expect(buf.length).toBe(22);
// last 4 bytes = bundleHash
expect(buf[buf.length - 4]).toBe(0xde);
expect(buf[buf.length - 1]).toBe(0xef);
});
test('rejects address > 65535 bytes', () => {
const huge = 'a'.repeat(0x10000);
expect(() => encodeLeafData(0, OP_REGISTER, huge, new Uint8Array(0))).toThrow();
});
});
describe('computeBundleHash', () => {
test('deterministic over equal input', () => {
const sk = new Uint8Array(32).fill(0x11);
const dk = new Uint8Array(32).fill(0x22);
const pk = new Uint8Array(32).fill(0x33);
const sig = new Uint8Array(64).fill(0x44);
const a = computeBundleHash({
identitySigningKey: sk,
identityDHKey: dk,
signedPreKey: { keyId: 7, publicKey: pk, signature: sig },
});
const b = computeBundleHash({
identitySigningKey: sk,
identityDHKey: dk,
signedPreKey: { keyId: 7, publicKey: pk, signature: sig },
});
expect(a).toEqual(b);
});
test('changing keyId changes hash', () => {
const sk = new Uint8Array(32).fill(0x11);
const dk = new Uint8Array(32).fill(0x22);
const pk = new Uint8Array(32).fill(0x33);
const sig = new Uint8Array(64).fill(0x44);
const a = computeBundleHash({
identitySigningKey: sk,
identityDHKey: dk,
signedPreKey: { keyId: 1, publicKey: pk, signature: sig },
});
const b = computeBundleHash({
identitySigningKey: sk,
identityDHKey: dk,
signedPreKey: { keyId: 2, publicKey: pk, signature: sig },
});
expect(a).not.toEqual(b);
});
test('rejects wrong-length keys', () => {
expect(() =>
computeBundleHash({
identitySigningKey: new Uint8Array(31),
identityDHKey: new Uint8Array(32),
signedPreKey: {
keyId: 0,
publicKey: new Uint8Array(32),
signature: new Uint8Array(64),
},
}),
).toThrow();
});
test('uses domain prefix 0x01', () => {
const sk = new Uint8Array(32);
const dk = new Uint8Array(32);
const pk = new Uint8Array(32);
const sig = new Uint8Array(64);
const expected = sha256Sync(
Buffer.concat([Buffer.from([DOMAIN_BUNDLE]), sk, dk, Buffer.alloc(4), pk, sig]),
);
const got = computeBundleHash({
identitySigningKey: sk,
identityDHKey: dk,
signedPreKey: { keyId: 0, publicKey: pk, signature: sig },
});
expect(Buffer.from(got).toString('hex')).toBe(Buffer.from(expected).toString('hex'));
});
});

View File

@@ -0,0 +1,221 @@
import { describe, expect, test } from 'bun:test';
import {
AddressIndex,
compareAddresses,
computeIndexRoot,
emptyRootHash,
verifyAbsenceProof,
verifyInclusionProof,
} from '../src/index.js';
describe('AddressIndex', () => {
test('empty index has emptyRootHash root', () => {
const idx = new AddressIndex();
expect(idx.rootHash()).toEqual(emptyRootHash());
});
test('upsert keeps entries lexicographically sorted', () => {
const idx = new AddressIndex();
idx.upsert({
address: 'charlie',
latestLeafIndex: 1,
bundleHash: new Uint8Array(32).fill(3),
deleted: false,
});
idx.upsert({
address: 'alice',
latestLeafIndex: 0,
bundleHash: new Uint8Array(32).fill(1),
deleted: false,
});
idx.upsert({
address: 'bob',
latestLeafIndex: 2,
bundleHash: new Uint8Array(32).fill(2),
deleted: false,
});
const snap = idx.snapshot();
expect(snap.map((e) => e.address)).toEqual(['alice', 'bob', 'charlie']);
});
test('compareAddresses is byte-lex', () => {
expect(compareAddresses('alice', 'bob') < 0).toBe(true);
expect(compareAddresses('bob', 'alice') > 0).toBe(true);
expect(compareAddresses('alice', 'alice')).toBe(0);
expect(compareAddresses('alice', 'aliceb')).toBeLessThan(0);
});
test('inclusion proof verifies against rootHash', () => {
const idx = new AddressIndex();
for (const a of ['alice', 'bob', 'charlie', 'dave', 'eve']) {
idx.upsert({
address: a,
latestLeafIndex: 0,
bundleHash: new Uint8Array(32).fill(a.charCodeAt(0)),
deleted: false,
});
}
const proof = idx.inclusionProof('charlie');
expect(proof).not.toBeNull();
expect(verifyInclusionProof(proof!, idx.rootHash())).toBe(true);
});
test('inclusion proof fails against tampered root', () => {
const idx = new AddressIndex();
idx.upsert({
address: 'alice',
latestLeafIndex: 0,
bundleHash: new Uint8Array(32).fill(1),
deleted: false,
});
const proof = idx.inclusionProof('alice')!;
const tampered = new Uint8Array(idx.rootHash());
tampered[0] ^= 0xff;
expect(verifyInclusionProof(proof, tampered)).toBe(false);
});
test('absence proof: query between two entries', () => {
const idx = new AddressIndex();
idx.upsert({
address: 'alice',
latestLeafIndex: 0,
bundleHash: new Uint8Array(32).fill(1),
deleted: false,
});
idx.upsert({
address: 'charlie',
latestLeafIndex: 1,
bundleHash: new Uint8Array(32).fill(3),
deleted: false,
});
const absence = idx.absenceProof('bob');
expect(absence).not.toBeNull();
expect(verifyAbsenceProof(absence!, idx.rootHash())).toBe(true);
});
test('absence proof: query before first entry', () => {
const idx = new AddressIndex();
idx.upsert({
address: 'm',
latestLeafIndex: 0,
bundleHash: new Uint8Array(32).fill(1),
deleted: false,
});
idx.upsert({
address: 'z',
latestLeafIndex: 1,
bundleHash: new Uint8Array(32).fill(2),
deleted: false,
});
const absence = idx.absenceProof('a');
expect(absence!.prev).toBeNull();
expect(absence!.next).not.toBeNull();
expect(verifyAbsenceProof(absence!, idx.rootHash())).toBe(true);
});
test('absence proof: query after last entry', () => {
const idx = new AddressIndex();
idx.upsert({
address: 'a',
latestLeafIndex: 0,
bundleHash: new Uint8Array(32).fill(1),
deleted: false,
});
idx.upsert({
address: 'm',
latestLeafIndex: 1,
bundleHash: new Uint8Array(32).fill(2),
deleted: false,
});
const absence = idx.absenceProof('z');
expect(absence!.prev).not.toBeNull();
expect(absence!.next).toBeNull();
expect(verifyAbsenceProof(absence!, idx.rootHash())).toBe(true);
});
test('absence proof: empty tree', () => {
const idx = new AddressIndex();
const absence = idx.absenceProof('alice');
expect(absence).not.toBeNull();
expect(absence!.treeSize).toBe(0);
expect(verifyAbsenceProof(absence!, idx.rootHash())).toBe(true);
});
test('absence proof returns null for existing address', () => {
const idx = new AddressIndex();
idx.upsert({
address: 'alice',
latestLeafIndex: 0,
bundleHash: new Uint8Array(32).fill(1),
deleted: false,
});
expect(idx.absenceProof('alice')).toBeNull();
});
test('absence proof can be forged-detected: claim adjacent but not adjacent', () => {
const idx = new AddressIndex();
idx.upsert({
address: 'alice',
latestLeafIndex: 0,
bundleHash: new Uint8Array(32).fill(1),
deleted: false,
});
idx.upsert({
address: 'bob',
latestLeafIndex: 1,
bundleHash: new Uint8Array(32).fill(2),
deleted: false,
});
idx.upsert({
address: 'charlie',
latestLeafIndex: 2,
bundleHash: new Uint8Array(32).fill(3),
deleted: false,
});
const absence = idx.absenceProof('aaron')!;
// Tamper: replace prev with non-adjacent neighbor (charlie)
const charlieProof = idx.inclusionProof('charlie')!;
const forged = {
...absence,
prev: {
position: charlieProof.position,
entry: charlieProof.entry,
auditPath: charlieProof.auditPath,
},
};
expect(verifyAbsenceProof(forged, idx.rootHash())).toBe(false);
});
test('tombstone marks entry deleted', () => {
const idx = new AddressIndex();
idx.upsert({
address: 'alice',
latestLeafIndex: 0,
bundleHash: new Uint8Array(32).fill(1),
deleted: false,
});
idx.tombstone('alice', 5);
const e = idx.get('alice')!;
expect(e.deleted).toBe(true);
expect(e.latestLeafIndex).toBe(5);
expect(e.bundleHash.length).toBe(0);
});
test('computeIndexRoot equals AddressIndex.rootHash for the same sorted snapshot', () => {
const idx = new AddressIndex();
for (const a of ['carol', 'alice', 'bob']) {
idx.upsert({
address: a,
latestLeafIndex: 0,
bundleHash: new Uint8Array(32).fill(a.charCodeAt(0)),
deleted: false,
});
}
expect(idx.rootHash()).toEqual(computeIndexRoot(idx.snapshot()));
});
});

View File

@@ -0,0 +1,189 @@
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();
});
});

View File

@@ -0,0 +1,223 @@
import { describe, expect, test } from 'bun:test';
import { SubtleCryptoProvider } from '@shade/crypto-web';
import {
KTLogManager,
MemoryKTLogStore,
computeBundleHash,
ktProofFromWire,
ktProofToWire,
verifyBundleAbsence,
verifyBundleInclusion,
verifyBundleTombstone,
} from '../src/index.js';
import { KTSplitViewError, KTVerificationError } from '../src/errors.js';
const crypto = new SubtleCryptoProvider();
async function makeManager() {
const kp = await crypto.generateEd25519KeyPair();
const store = new MemoryKTLogStore();
const mgr = await KTLogManager.create({
crypto,
store,
signingPrivateKey: kp.privateKey,
signingPublicKey: kp.publicKey,
});
return { mgr, kp, store };
}
function fakeBundle(seed: number) {
return {
identitySigningKey: new Uint8Array(32).fill(seed),
identityDHKey: new Uint8Array(32).fill(seed + 1),
signedPreKey: {
keyId: 1,
publicKey: new Uint8Array(32).fill(seed + 2),
signature: new Uint8Array(64).fill(seed + 3),
},
};
}
describe('KTLogManager — happy paths', () => {
test('register + buildBundleInclusionProof + verify', async () => {
const { mgr, kp } = await makeManager();
const bundle = fakeBundle(0x10);
const bundleHash = computeBundleHash(bundle);
await mgr.recordRegister('alice', bundleHash);
const sth = await mgr.publishSTH();
const proof = await mgr.buildBundleInclusionProof('alice', sth);
expect(proof).not.toBeNull();
expect(proof!.body.kind).toBe('inclusion');
const verified = await verifyBundleInclusion(
{ crypto, logPublicKey: kp.publicKey },
'alice',
bundle,
proof!,
);
expect(verified.treeSize).toBe(1);
});
test('absence proof for unknown address verifies', async () => {
const { mgr, kp } = await makeManager();
await mgr.recordRegister('alice', computeBundleHash(fakeBundle(0x10)));
const sth = await mgr.publishSTH();
const proof = mgr.buildBundleAbsenceProof('zeta', sth);
expect(proof).not.toBeNull();
expect(proof!.body.kind).toBe('absence');
await verifyBundleAbsence({ crypto, logPublicKey: kp.publicKey }, 'zeta', proof!);
});
test('tombstone proof verifies after delete', async () => {
const { mgr, kp } = await makeManager();
await mgr.recordRegister('alice', computeBundleHash(fakeBundle(0x10)));
await mgr.recordDelete('alice');
const sth = await mgr.publishSTH();
const proof = await mgr.buildBundleInclusionProof('alice', sth);
expect(proof!.body.kind).toBe('tombstone');
const verified = await verifyBundleTombstone(
{ crypto, logPublicKey: kp.publicKey },
'alice',
proof!,
);
expect(verified.treeSize).toBe(2);
});
test('multiple addresses + STH at increasing tree sizes', async () => {
const { mgr, kp } = await makeManager();
const aliceBundle = fakeBundle(0x10);
const bobBundle = fakeBundle(0x20);
await mgr.recordRegister('alice', computeBundleHash(aliceBundle));
await mgr.recordRegister('bob', computeBundleHash(bobBundle));
const sth = await mgr.publishSTH();
expect(sth.treeSize).toBe(2);
const proofAlice = await mgr.buildBundleInclusionProof('alice', sth);
const proofBob = await mgr.buildBundleInclusionProof('bob', sth);
await verifyBundleInclusion(
{ crypto, logPublicKey: kp.publicKey },
'alice',
aliceBundle,
proofAlice!,
);
await verifyBundleInclusion(
{ crypto, logPublicKey: kp.publicKey },
'bob',
bobBundle,
proofBob!,
);
});
test('rotation: new register replaces old', async () => {
const { mgr, kp } = await makeManager();
const v1 = fakeBundle(0x10);
const v2 = fakeBundle(0x55);
await mgr.recordRegister('alice', computeBundleHash(v1));
await mgr.recordRegister('alice', computeBundleHash(v2));
const sth = await mgr.publishSTH();
const proof = await mgr.buildBundleInclusionProof('alice', sth);
// Latest is v2; verifying with v1's bundle should fail.
await expect(
verifyBundleInclusion({ crypto, logPublicKey: kp.publicKey }, 'alice', v1, proof!),
).rejects.toBeInstanceOf(KTVerificationError);
await verifyBundleInclusion(
{ crypto, logPublicKey: kp.publicKey },
'alice',
v2,
proof!,
);
});
});
describe('KTLogManager — wire encoding roundtrip', () => {
test('inclusion proof survives wire roundtrip', async () => {
const { mgr, kp } = await makeManager();
const bundle = fakeBundle(0x42);
await mgr.recordRegister('alice', computeBundleHash(bundle));
const sth = await mgr.publishSTH();
const proof = await mgr.buildBundleInclusionProof('alice', sth);
const wire = ktProofToWire(proof!);
const json = JSON.stringify(wire);
const back = ktProofFromWire(JSON.parse(json));
await verifyBundleInclusion(
{ crypto, logPublicKey: kp.publicKey },
'alice',
bundle,
back,
);
});
});
describe('Tampering detection', () => {
test('forged bundle (different signing key) is rejected', async () => {
const { mgr, kp } = await makeManager();
const real = fakeBundle(0x10);
await mgr.recordRegister('alice', computeBundleHash(real));
const sth = await mgr.publishSTH();
const proof = await mgr.buildBundleInclusionProof('alice', sth);
const forged = { ...real, identitySigningKey: new Uint8Array(32).fill(0xff) };
await expect(
verifyBundleInclusion({ crypto, logPublicKey: kp.publicKey }, 'alice', forged, proof!),
).rejects.toBeInstanceOf(KTVerificationError);
});
test('proof for alice cannot be re-used for bob (address mismatch)', async () => {
const { mgr, kp } = await makeManager();
const aliceBundle = fakeBundle(0x10);
await mgr.recordRegister('alice', computeBundleHash(aliceBundle));
const sth = await mgr.publishSTH();
const proof = await mgr.buildBundleInclusionProof('alice', sth);
await expect(
verifyBundleInclusion(
{ crypto, logPublicKey: kp.publicKey },
'bob',
aliceBundle,
proof!,
),
).rejects.toBeInstanceOf(KTVerificationError);
});
test('split-view: same tree_size with different roots is detected by witness', async () => {
const { mgr, kp } = await makeManager();
const bundle = fakeBundle(0x10);
await mgr.recordRegister('alice', computeBundleHash(bundle));
const sth = await mgr.publishSTH();
// Forge a *different* STH at the same tree_size — pretend the server
// signed two divergent versions.
const sth2 = await mgr.publishSTH();
expect(sth2.treeSize).toBe(sth.treeSize);
// To simulate a conflicting STH, sign one with a tampered root_hash.
const tampered = { ...sth, rootHash: new Uint8Array(sth.rootHash) };
tampered.rootHash[0] ^= 0xff;
// Re-sign with the same key so it would individually verify…
const forged = await (await import('../src/sth.js')).signSth(crypto, kp.privateKey, {
treeSize: tampered.treeSize,
timestampMs: tampered.timestampMs,
rootHash: tampered.rootHash,
indexRoot: tampered.indexRoot,
logId: tampered.logId,
});
const { LightWitness } = await import('../src/witness.js');
const witness = new LightWitness({
crypto,
logPublicKey: kp.publicKey,
fetcher: {
async fetchLatestSTH() {
return (await import('../src/sth.js')).sthToWire(sth, (b) =>
Buffer.from(b).toString('base64'),
);
},
async fetchConsistencyProof() {
return { proof: [] };
},
},
});
await witness.observe(sth);
await expect(witness.observe(forged)).rejects.toBeInstanceOf(KTSplitViewError);
});
});

View File

@@ -0,0 +1,83 @@
import { describe, expect, test } from 'bun:test';
import { SubtleCryptoProvider } from '@shade/crypto-web';
import {
canonicalSthBytes,
computeLogId,
signSth,
sthFromWire,
sthToWire,
verifySthSignature,
} from '../src/index.js';
import { fromBase64, toBase64 } from '../src/util.js';
const crypto = new SubtleCryptoProvider();
describe('STH signing & verification', () => {
test('signSth + verifySthSignature roundtrip', async () => {
const kp = await crypto.generateEd25519KeyPair();
const sth = await signSth(crypto, kp.privateKey, {
treeSize: 42,
timestampMs: 1700000000000,
rootHash: new Uint8Array(32).fill(0xaa),
indexRoot: new Uint8Array(32).fill(0xbb),
logId: computeLogId(kp.publicKey),
});
expect(await verifySthSignature(crypto, sth, kp.publicKey)).toBe(true);
});
test('verify fails with wrong public key', async () => {
const kp = await crypto.generateEd25519KeyPair();
const other = await crypto.generateEd25519KeyPair();
const sth = await signSth(crypto, kp.privateKey, {
treeSize: 1,
timestampMs: 1700000000000,
rootHash: new Uint8Array(32),
indexRoot: new Uint8Array(32),
logId: computeLogId(kp.publicKey),
});
expect(await verifySthSignature(crypto, sth, other.publicKey)).toBe(false);
});
test('verify fails when log_id is forged', async () => {
const kp = await crypto.generateEd25519KeyPair();
const other = await crypto.generateEd25519KeyPair();
const sth = await signSth(crypto, kp.privateKey, {
treeSize: 1,
timestampMs: 1700000000000,
rootHash: new Uint8Array(32),
indexRoot: new Uint8Array(32),
logId: computeLogId(other.publicKey), // mismatched
});
// The signature was made over a log_id that doesn't match the supplied
// public key — verifySthSignature should refuse.
expect(await verifySthSignature(crypto, sth, kp.publicKey)).toBe(false);
});
test('canonical bytes layout is stable', () => {
const bytes = canonicalSthBytes({
treeSize: 0x0102030405,
timestampMs: 0x06070809,
rootHash: new Uint8Array(32).fill(0x11),
indexRoot: new Uint8Array(32).fill(0x22),
logId: new Uint8Array(32).fill(0x33),
});
// 1 prefix + 8 treeSize + 8 timestamp + 32 + 32 + 32
expect(bytes.length).toBe(113);
expect(bytes[0]).toBe(0x02);
});
test('wire roundtrip', async () => {
const kp = await crypto.generateEd25519KeyPair();
const sth = await signSth(crypto, kp.privateKey, {
treeSize: 7,
timestampMs: 1700000000000,
rootHash: new Uint8Array(32).fill(0x77),
indexRoot: new Uint8Array(32).fill(0x88),
logId: computeLogId(kp.publicKey),
});
const wire = sthToWire(sth, toBase64);
const back = sthFromWire(wire, fromBase64);
expect(back).toEqual(sth);
expect(await verifySthSignature(crypto, back, kp.publicKey)).toBe(true);
});
});

View File

@@ -0,0 +1,223 @@
import { describe, expect, test } from 'bun:test';
import { SubtleCryptoProvider } from '@shade/crypto-web';
import {
KTLogManager,
LightWitness,
MemoryKTLogStore,
computeBundleHash,
computeLogId,
signSth,
sthToWire,
} from '../src/index.js';
import {
KTLogIdMismatchError,
KTSplitViewError,
KTStaleSTHError,
KTVerificationError,
} from '../src/errors.js';
import { toBase64, fromBase64 } from '../src/util.js';
const crypto = new SubtleCryptoProvider();
async function setup() {
const kp = await crypto.generateEd25519KeyPair();
const store = new MemoryKTLogStore();
const mgr = await KTLogManager.create({
crypto,
store,
signingPrivateKey: kp.privateKey,
signingPublicKey: kp.publicKey,
});
return { kp, mgr };
}
function fakeBundle(seed: number) {
return {
identitySigningKey: new Uint8Array(32).fill(seed),
identityDHKey: new Uint8Array(32).fill(seed + 1),
signedPreKey: {
keyId: 1,
publicKey: new Uint8Array(32).fill(seed + 2),
signature: new Uint8Array(64).fill(seed + 3),
},
};
}
describe('LightWitness', () => {
test('observes valid STH and stores it', async () => {
const { kp, mgr } = await setup();
await mgr.recordRegister('alice', computeBundleHash(fakeBundle(0x10)));
const sth = await mgr.publishSTH();
const witness = new LightWitness({
crypto,
logPublicKey: kp.publicKey,
fetcher: {
async fetchLatestSTH() {
return sthToWire(sth, toBase64);
},
async fetchConsistencyProof() {
return { proof: [] };
},
},
});
const polled = await witness.pollOnce();
expect(polled.treeSize).toBe(1);
expect(witness.compare(sth)).toBe('agree');
});
test('rejects STH whose log_id does not match pinned key', async () => {
const { mgr } = await setup();
const wrong = await crypto.generateEd25519KeyPair();
await mgr.recordRegister('alice', computeBundleHash(fakeBundle(0x10)));
const sth = await mgr.publishSTH();
const witness = new LightWitness({
crypto,
logPublicKey: wrong.publicKey, // pinned to wrong key
fetcher: {
async fetchLatestSTH() {
return sthToWire(sth, toBase64);
},
async fetchConsistencyProof() {
return { proof: [] };
},
},
});
await expect(witness.pollOnce()).rejects.toBeInstanceOf(KTLogIdMismatchError);
});
test('rejects STH older than maxStaleMs', async () => {
const { kp, mgr } = await setup();
await mgr.recordRegister('alice', computeBundleHash(fakeBundle(0x10)));
const sth = await mgr.publishSTH(1000); // far in the past
const witness = new LightWitness({
crypto,
logPublicKey: kp.publicKey,
fetcher: {
async fetchLatestSTH() {
return sthToWire(sth, toBase64);
},
async fetchConsistencyProof() {
return { proof: [] };
},
},
maxStaleMs: 1000,
now: () => 10_000_000,
});
await expect(witness.pollOnce()).rejects.toBeInstanceOf(KTStaleSTHError);
});
test('detects split-view at same tree_size', async () => {
const { kp, mgr } = await setup();
await mgr.recordRegister('alice', computeBundleHash(fakeBundle(0x10)));
const sth1 = await mgr.publishSTH();
// Forge another signed STH with same tree_size but different rootHash
const tamperedRoot = new Uint8Array(sth1.rootHash);
tamperedRoot[0] ^= 0xff;
const sth2 = await signSth(crypto, kp.privateKey, {
treeSize: sth1.treeSize,
timestampMs: sth1.timestampMs,
rootHash: tamperedRoot,
indexRoot: sth1.indexRoot,
logId: computeLogId(kp.publicKey),
});
const witness = new LightWitness({
crypto,
logPublicKey: kp.publicKey,
fetcher: {
async fetchLatestSTH() {
return sthToWire(sth1, toBase64);
},
async fetchConsistencyProof() {
return { proof: [] };
},
},
});
await witness.observe(sth1);
await expect(witness.observe(sth2)).rejects.toBeInstanceOf(KTSplitViewError);
});
test('verifies consistency between two successive STHs', async () => {
const { kp, mgr } = await setup();
await mgr.recordRegister('alice', computeBundleHash(fakeBundle(0x10)));
const sth1 = await mgr.publishSTH();
await mgr.recordRegister('bob', computeBundleHash(fakeBundle(0x20)));
const sth2 = await mgr.publishSTH();
const consistency = await mgr.buildConsistencyProof(sth1.treeSize);
const witness = new LightWitness({
crypto,
logPublicKey: kp.publicKey,
fetcher: {
async fetchLatestSTH() {
return sthToWire(sth2, toBase64);
},
async fetchConsistencyProof() {
return { proof: consistency.proof.map(toBase64) };
},
},
});
await witness.observe(sth1);
await witness.observe(sth2);
expect(witness.compare(sth2)).toBe('agree');
});
test('rejects STH where log re-wrote history (consistency proof fails)', async () => {
const { kp, mgr } = await setup();
await mgr.recordRegister('alice', computeBundleHash(fakeBundle(0x10)));
const sth1 = await mgr.publishSTH();
// Build a forked log where leaf 0 is different.
const forkStore = new MemoryKTLogStore();
const forkMgr = await KTLogManager.create({
crypto,
store: forkStore,
signingPrivateKey: kp.privateKey,
signingPublicKey: kp.publicKey,
});
await forkMgr.recordRegister('mallory', computeBundleHash(fakeBundle(0xee)));
await forkMgr.recordRegister('bob', computeBundleHash(fakeBundle(0x20)));
const forkedSth2 = await forkMgr.publishSTH();
const forkedConsistency = await forkMgr.buildConsistencyProof(sth1.treeSize);
const witness = new LightWitness({
crypto,
logPublicKey: kp.publicKey,
fetcher: {
async fetchLatestSTH() {
return sthToWire(forkedSth2, toBase64);
},
async fetchConsistencyProof() {
return { proof: forkedConsistency.proof.map(toBase64) };
},
},
});
await witness.observe(sth1);
await expect(witness.observe(forkedSth2)).rejects.toBeInstanceOf(KTVerificationError);
});
test('compare returns "unknown" for tree_size we have not seen', async () => {
const { kp, mgr } = await setup();
await mgr.recordRegister('alice', computeBundleHash(fakeBundle(0x10)));
const sth = await mgr.publishSTH();
const witness = new LightWitness({
crypto,
logPublicKey: kp.publicKey,
fetcher: {
async fetchLatestSTH() {
return sthToWire(sth, toBase64);
},
async fetchConsistencyProof() {
return { proof: [] };
},
},
});
expect(witness.compare(sth)).toBe('unknown');
});
});
// Make TS happy about unused fromBase64
void fromBase64;