import { describe, expect, test } from 'bun:test'; import { createShade } from '@shade/sdk'; import { createPrekeyServer, MemoryPrekeyStore, PrekeyServerEvents, } from '@shade/server'; import { SubtleCryptoProvider } from '@shade/crypto-web'; import { Hono } from 'hono'; const crypto = new SubtleCryptoProvider(); /** * Concurrent-ratchet hardening tests. * * Reproduces the scenario described in the v4.2.0 ratchet-desync bug * report: with the queue-drainer running on Alice and many concurrent * `shade.send`/RPC operations against the same peer, do * encrypt/decrypt paths share the per-peer mutex on * `ShadeSessionManager` so that no path observes a stale ratchet * state? * * If the lock coverage regresses (a future change re-introduces a * sidekanal bypass), one of these tests will fail with * `DecryptionError: Failed to decrypt message — wrong key or * tampered data`. */ async function setupPullRig() { const prekey = createPrekeyServer({ crypto, store: new MemoryPrekeyStore(), disableRateLimit: true, events: new PrekeyServerEvents(), }); const prekeyServer = Bun.serve({ port: 0, fetch: prekey.fetch }); const prekeyUrl = `http://localhost:${prekeyServer.port}`; const alice = await createShade({ prekeyServer: prekeyUrl, address: 'alice' }); const bob = await createShade({ prekeyServer: prekeyUrl, address: 'bob' }); const queueRoute = await bob.transferQueueRoute({ blockMs: 500 }); await bob.files.serve({ stat: async () => ({ name: '_', kind: 'dir' as const, size: 0, mtime: 0, metadata: {}, }), }); const rpcRoute = bob.files.rpcRoute({ acceptFirstMessage: true }); const app = new Hono(); app.route('/', queueRoute); app.route('/', rpcRoute); const bobServer = Bun.serve({ port: 0, fetch: app.fetch }); const baseUrl = `http://localhost:${bobServer.port}`; const fs = alice.files.httpClient('bob', { rpcUrl: `${baseUrl}/rpc`, outboundQueueUrl: `${baseUrl}/queue`, transferBaseUrl: baseUrl, defaultTimeoutMs: 10_000, queueBlockMs: 500, }); return { alice, bob, fs, baseUrl, teardown: async () => { fs.close(); await alice.shutdown(); await bob.shutdown(); bobServer.stop(); prekeyServer.stop(); }, }; } describe('@shade/files — concurrent ratchet under drainer', () => { test('100 parallel httpClient RPCs while drainer runs — no DecryptionError', async () => { const rig = await setupPullRig(); try { // Warm-up: establishes the X3DH session (Alice → Bob first message // is a PreKeyMessage; subsequent messages are pure ratchet). const first = await rig.fs.stat('/'); expect(first.kind).toBe('dir'); // Fire 100 concurrent stat RPCs. Each one is a full ratchet // round-trip: encrypt request, POST, decrypt response. They all // contend for `manager.peerOpChains["bob"]` on Alice's side // (encrypt + decrypt) and `manager.peerOpChains["alice"]` on // Bob's side. Drainer is running in the background polling // Bob's queue — its decrypt path also funnels through the same // per-peer lock. // 100 concurrent — minimal repro (after warm-up only). const N = 100; const results = await Promise.allSettled( Array.from({ length: N }, () => rig.fs.stat('/')), ); const failures = results.filter((s) => s.status === 'rejected') as Array< PromiseRejectedResult >; if (failures.length > 0) { const sample = failures.slice(0, 1).map((f) => String(f.reason)); throw new Error(`${failures.length}/${N} concurrent RPCs failed: ${sample[0]}`); } } finally { await rig.teardown(); } }, 30_000); test('parallel shade.send + drainer + RPCs — ratchet stays in sync', async () => { const rig = await setupPullRig(); try { // Establish session via one warm-up RPC. await rig.fs.stat('/'); // Subscribe Bob to inbound plaintext from Alice — when Alice's // raw `shade.send` plaintext arrives, Bob echoes a reply back // through `shade.send` + `deliverControlEnvelope`, which the // pull-mode envelope transport enqueues for Alice's drainer. // This injects extra inbound traffic into Alice's drainer in // parallel with her ongoing RPCs. const echoes: string[] = []; rig.bob.onMessage(async (from, plaintext) => { if (from !== 'alice') return; if (!plaintext.startsWith('ping:')) return; echoes.push(plaintext); const reply = `pong:${plaintext.slice('ping:'.length)}`; const env = await rig.bob.send('alice', reply); await rig.bob.deliverControlEnvelope('alice', env); }); const inboundDrained: string[] = []; rig.alice.onMessage((from, plaintext) => { if (from !== 'bob') return; if (plaintext.startsWith('pong:')) inboundDrained.push(plaintext); }); // Mix three concurrent workloads against the same peer: // - 50 inline file RPCs through httpClient (encrypt + decrypt) // - 50 raw `shade.send` deliveries via control envelope // - drainer pulling Bob's responses + echoes const N = 50; const rpcs = Array.from({ length: N }, () => rig.fs.stat('/')); const sends = Array.from({ length: N }, async (_, i) => { const env = await rig.alice.send('bob', `ping:${i}`); await rig.alice.deliverControlEnvelope('bob', env); }); const settled = await Promise.allSettled([...rpcs, ...sends]); const failures = settled.filter((s) => s.status === 'rejected') as Array< PromiseRejectedResult >; if (failures.length > 0) { const sample = failures.slice(0, 3).map((f) => String(f.reason)); throw new Error( `${failures.length}/${settled.length} concurrent ops failed: ${sample.join(' | ')}`, ); } // Give Bob's queue + Alice's drainer a beat to drain pongs back. // Echoes round-trip Alice → Bob (control envelope) → Bob's // onMessage → Bob.send + deliver (queue) → Alice's drainer → // Alice's onMessage. We just verify some make it back without // any DecryptionError surfacing. const deadline = Date.now() + 5_000; while (inboundDrained.length < N && Date.now() < deadline) { await new Promise((r) => setTimeout(r, 50)); } // Don't gate on every echo arriving — the long-poll cadence and // bun's serve/abort timing can lag a few. We only care that the // ratchet didn't desync; if it had, every subsequent op would // throw DecryptionError above. expect(echoes.length).toBe(N); expect(inboundDrained.length).toBeGreaterThan(0); } finally { await rig.teardown(); } }, 30_000); });