/** * Acceptance test for V3.12 §"End-to-end test: split-view detection". * * Scenario: * - One legitimate STH-signing key (the operator's pinned key). * - Two divergent log views A and B, both signed by the same key * (simulating a malicious server that hands different responses to * different clients). * - Two clients (Bob, Charlie) each fetch alice's bundle, but each is * served from a different view. * - A `LightWitness` cross-pollinates the two clients' STHs. * - The witness must reject the second STH at the same tree_size with * a `KTSplitViewError`. * * Also asserts the *positive* path: when both clients see the same view, * no error is raised. */ import { describe, expect, test } from 'bun:test'; import { SubtleCryptoProvider } from '@shade/crypto-web'; import { KTLogManager, KTSplitViewError, LightWitness, MemoryKTLogStore, computeBundleHash, computeLogId, } from '@shade/key-transparency'; const crypto = new SubtleCryptoProvider(); 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('Split-view E2E', () => { test('two divergent views at the same tree_size are caught by witness', async () => { const operator = await crypto.generateEd25519KeyPair(); // View A — alice has the *real* identity (seed 0x10) const viewA = await KTLogManager.create({ crypto, store: new MemoryKTLogStore(), signingPrivateKey: operator.privateKey, signingPublicKey: operator.publicKey, }); await viewA.recordRegister('alice', computeBundleHash(fakeBundle(0x10))); const sthA = await viewA.publishSTH(); // View B — alice has a *malicious* identity (seed 0xff) const viewB = await KTLogManager.create({ crypto, store: new MemoryKTLogStore(), signingPrivateKey: operator.privateKey, signingPublicKey: operator.publicKey, }); await viewB.recordRegister('alice', computeBundleHash(fakeBundle(0xff))); const sthB = await viewB.publishSTH(); // Both STHs claim tree_size = 1 with the same logId, but with // different rootHash + indexRoot. This is what a split-view attack // looks like on the wire. expect(sthA.treeSize).toBe(sthB.treeSize); expect(Buffer.from(sthA.logId).toString('hex')).toBe( Buffer.from(sthB.logId).toString('hex'), ); expect(Buffer.from(sthA.rootHash).toString('hex')).not.toBe( Buffer.from(sthB.rootHash).toString('hex'), ); // Bob has been served STH A; Charlie has been served STH B. // They share a witness (gossip-style): const witness = new LightWitness({ crypto, logPublicKey: operator.publicKey, fetcher: { async fetchLatestSTH() { throw new Error('not used in this test'); }, async fetchConsistencyProof() { return { proof: [] }; }, }, }); await witness.observe(sthA); expect(witness.compare(sthA)).toBe('agree'); expect(witness.compare(sthB)).toBe('split-view'); await expect(witness.observe(sthB)).rejects.toBeInstanceOf(KTSplitViewError); }); test('positive path: same view → no false alarm', async () => { const operator = await crypto.generateEd25519KeyPair(); const view = await KTLogManager.create({ crypto, store: new MemoryKTLogStore(), signingPrivateKey: operator.privateKey, signingPublicKey: operator.publicKey, }); await view.recordRegister('alice', computeBundleHash(fakeBundle(0x10))); const sth1 = await view.publishSTH(); await view.recordRegister('bob', computeBundleHash(fakeBundle(0x20))); const sth2 = await view.publishSTH(); const witness = new LightWitness({ crypto, logPublicKey: operator.publicKey, fetcher: { async fetchLatestSTH() { throw new Error('not used'); }, async fetchConsistencyProof(from, to) { const result = await view.buildHistoricalConsistencyProof(from, to); return { proof: result.map((b) => Buffer.from(b).toString('base64')) }; }, }, }); await witness.observe(sth1); await witness.observe(sth2); expect(witness.compare(sth2)).toBe('agree'); }); test('rewriting history (forked log at tree_size 1) fails consistency from sth1 → sth2', async () => { const operator = await crypto.generateEd25519KeyPair(); const real = await KTLogManager.create({ crypto, store: new MemoryKTLogStore(), signingPrivateKey: operator.privateKey, signingPublicKey: operator.publicKey, }); await real.recordRegister('alice', computeBundleHash(fakeBundle(0x10))); const sth1 = await real.publishSTH(); // Build a divergent log that pretends 'mallory' was the first leaf, // not 'alice'. The forked tree's STH at size 2 must NOT pass a // consistency proof against sth1. const fork = await KTLogManager.create({ crypto, store: new MemoryKTLogStore(), signingPrivateKey: operator.privateKey, signingPublicKey: operator.publicKey, }); await fork.recordRegister('mallory', computeBundleHash(fakeBundle(0xee))); await fork.recordRegister('bob', computeBundleHash(fakeBundle(0x20))); const forkedSth2 = await fork.publishSTH(); const forkedConsistency = await fork.buildConsistencyProof(sth1.treeSize); const witness = new LightWitness({ crypto, logPublicKey: operator.publicKey, fetcher: { async fetchLatestSTH() { throw new Error('not used'); }, async fetchConsistencyProof() { return { proof: forkedConsistency.proof.map((b) => Buffer.from(b).toString('base64')) }; }, }, }); await witness.observe(sth1); await expect(witness.observe(forkedSth2)).rejects.toThrow(); // sanity: logId pinning still valid expect(Buffer.from(forkedSth2.logId).toString('hex')).toBe( Buffer.from(computeLogId(operator.publicKey)).toString('hex'), ); }); });