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); }); });