import { describe, test, expect } from 'bun:test'; import { SubtleCryptoProvider, MemoryStorage } from '@shade/crypto-web'; import { ShadeSessionManager } from '@shade/core'; import { createPrekeyServerWithKT, MemoryPrekeyStore, } from '@shade/server'; import { KTVerificationError, LightWitness, MemoryKTLogStore, computeLogId, signSth, type SignedTreeHead, } from '@shade/key-transparency'; import { ShadeFetchTransport } from '../src/fetch-transport.js'; const crypto = new SubtleCryptoProvider(); describe('ShadeFetchTransport with KT verifier', () => { test('fetch verifies inclusion proof against pinned log key', async () => { const logKp = await crypto.generateEd25519KeyPair(); const { app, kt } = await createPrekeyServerWithKT({ crypto, store: new MemoryPrekeyStore(), disableRateLimit: true, keyTransparency: { store: new MemoryKTLogStore(), signingPrivateKey: logKp.privateKey, signingPublicKey: logKp.publicKey, }, }); const port = 20100 + Math.floor(Math.random() * 500); const handle = Bun.serve({ port, fetch: app.fetch }); try { const baseUrl = `http://localhost:${port}`; // Bob registers const bobStorage = new MemoryStorage(); const bobManager = new ShadeSessionManager(crypto, bobStorage); await bobManager.initialize(); const bobIdentity = await bobStorage.getIdentityKeyPair(); const bobTransport = new ShadeFetchTransport({ baseUrl, crypto, signingPrivateKey: bobIdentity!.signingPrivateKey, }); const bobOTPKs = await bobManager.generateOneTimePreKeys(3); const bobBundle = await bobManager.createPreKeyBundle(); await bobTransport.register('bob', bobManager.getPublicIdentity(), bobBundle.signedPreKey, bobOTPKs); // Alice fetches with KT verifier — should succeed const observed: SignedTreeHead[] = []; const aliceTransport = new ShadeFetchTransport({ baseUrl, crypto, keyTransparency: { mode: 'observe-strict', logPublicKey: logKp.publicKey, onObserveSth: async (sth) => { observed.push(sth); }, }, }); const result = await aliceTransport.fetchBundleVerified('bob'); expect(result.bundle.identityDHKey).toEqual(bobManager.getPublicIdentity().dhKey); expect(result.ktSth).toBeDefined(); expect(result.ktSth!.treeSize).toBe(1); expect(observed.length).toBe(1); // Sanity: server-side latest STH matches what client observed const serverSth = await kt.getLatestSTH(); expect(Buffer.from(serverSth.rootHash).toString('hex')).toBe( Buffer.from(result.ktSth!.rootHash).toString('hex'), ); } finally { handle.stop(); } }); test('observe-strict throws KTVerificationError on missing proof', async () => { // Plain server (no KT) const { createPrekeyServer, MemoryPrekeyStore } = await import('@shade/server'); const app = createPrekeyServer({ crypto, store: new MemoryPrekeyStore(), disableRateLimit: true, }); const port = 20800 + Math.floor(Math.random() * 200); const handle = Bun.serve({ port, fetch: app.fetch }); try { const baseUrl = `http://localhost:${port}`; const bobStorage = new MemoryStorage(); const bobManager = new ShadeSessionManager(crypto, bobStorage); await bobManager.initialize(); const bobIdentity = await bobStorage.getIdentityKeyPair(); const bobTransport = new ShadeFetchTransport({ baseUrl, crypto, signingPrivateKey: bobIdentity!.signingPrivateKey, }); const bobOTPKs = await bobManager.generateOneTimePreKeys(2); const bobBundle = await bobManager.createPreKeyBundle(); await bobTransport.register('bob', bobManager.getPublicIdentity(), bobBundle.signedPreKey, bobOTPKs); const logKp = await crypto.generateEd25519KeyPair(); const aliceTransport = new ShadeFetchTransport({ baseUrl, crypto, keyTransparency: { mode: 'observe-strict', logPublicKey: logKp.publicKey }, }); await expect(aliceTransport.fetchBundle('bob')).rejects.toBeInstanceOf(KTVerificationError); } finally { handle.stop(); } }); test('forged STH (server signed with wrong key) is rejected', async () => { const realLogKp = await crypto.generateEd25519KeyPair(); const evilLogKp = await crypto.generateEd25519KeyPair(); const { app } = await createPrekeyServerWithKT({ crypto, store: new MemoryPrekeyStore(), disableRateLimit: true, keyTransparency: { store: new MemoryKTLogStore(), // Operator signs with the EVIL key signingPrivateKey: evilLogKp.privateKey, signingPublicKey: evilLogKp.publicKey, }, }); const port = 21100 + Math.floor(Math.random() * 200); const handle = Bun.serve({ port, fetch: app.fetch }); try { const baseUrl = `http://localhost:${port}`; const bobStorage = new MemoryStorage(); const bobManager = new ShadeSessionManager(crypto, bobStorage); await bobManager.initialize(); const bobIdentity = await bobStorage.getIdentityKeyPair(); const bobTransport = new ShadeFetchTransport({ baseUrl, crypto, signingPrivateKey: bobIdentity!.signingPrivateKey, }); const bobOTPKs = await bobManager.generateOneTimePreKeys(2); const bobBundle = await bobManager.createPreKeyBundle(); await bobTransport.register('bob', bobManager.getPublicIdentity(), bobBundle.signedPreKey, bobOTPKs); // Client pinned the REAL log key — verification must fail const aliceTransport = new ShadeFetchTransport({ baseUrl, crypto, keyTransparency: { mode: 'observe-strict', logPublicKey: realLogKp.publicKey }, }); await expect(aliceTransport.fetchBundle('bob')).rejects.toThrow(); } finally { handle.stop(); } }); test('observed STH feeds LightWitness; subsequent split-view detected', async () => { const logKp = await crypto.generateEd25519KeyPair(); const { app, kt } = await createPrekeyServerWithKT({ crypto, store: new MemoryPrekeyStore(), disableRateLimit: true, keyTransparency: { store: new MemoryKTLogStore(), signingPrivateKey: logKp.privateKey, signingPublicKey: logKp.publicKey, }, }); const port = 21500 + Math.floor(Math.random() * 200); const handle = Bun.serve({ port, fetch: app.fetch }); try { const baseUrl = `http://localhost:${port}`; // Register Bob const bobStorage = new MemoryStorage(); const bobManager = new ShadeSessionManager(crypto, bobStorage); await bobManager.initialize(); const bobIdentity = await bobStorage.getIdentityKeyPair(); const bobTransport = new ShadeFetchTransport({ baseUrl, crypto, signingPrivateKey: bobIdentity!.signingPrivateKey, }); const bobOTPKs = await bobManager.generateOneTimePreKeys(2); const bobBundle = await bobManager.createPreKeyBundle(); await bobTransport.register('bob', bobManager.getPublicIdentity(), bobBundle.signedPreKey, bobOTPKs); // Witness backed by the real /v1/kt/* endpoints const witness = new LightWitness({ crypto, logPublicKey: logKp.publicKey, fetcher: { async fetchLatestSTH() { const res = await fetch(`${baseUrl}/v1/kt/sth`); return res.json(); }, async fetchConsistencyProof(from, to) { const res = await fetch(`${baseUrl}/v1/kt/consistency?from=${from}&to=${to}`); return res.json(); }, }, }); const observedSth = await witness.pollOnce(); expect(observedSth.treeSize).toBe(1); // Forge a divergent STH at the same tree_size and feed it to the // witness (this simulates a malicious second view) const realSth = await kt.getLatestSTH(); const tampered = new Uint8Array(realSth.rootHash); tampered[0] ^= 0xff; const forged = await signSth(crypto, logKp.privateKey, { treeSize: realSth.treeSize, timestampMs: realSth.timestampMs, rootHash: tampered, indexRoot: realSth.indexRoot, logId: computeLogId(logKp.publicKey), }); await expect(witness.observe(forged)).rejects.toThrow(/Split view/); } finally { handle.stop(); } }); });