import { afterEach, describe, expect, test } from 'bun:test'; import { sha256 } from '@noble/hashes/sha2.js'; import { createDecryptStream, createEncryptStream, createWorkerCryptoProvider, SubtleCryptoProvider, WorkerCryptoProvider, } from '../src/index.js'; const WORKER_URL = new URL('../src/worker.ts', import.meta.url); const subtle = new SubtleCryptoProvider(); let provider: WorkerCryptoProvider | null = null; afterEach(async () => { if (provider) { await provider.destroy(); provider = null; } }); async function makeProvider(): Promise { provider = await createWorkerCryptoProvider({ workerUrl: WORKER_URL }); return provider; } async function readAll(rs: ReadableStream): Promise { const reader = rs.getReader(); const parts: Uint8Array[] = []; let total = 0; for (;;) { const { done, value } = await reader.read(); if (done) break; parts.push(value); total += value.byteLength; } const out = new Uint8Array(total); let off = 0; for (const p of parts) { out.set(p, off); off += p.byteLength; } return out; } function streamFromChunks(chunks: Uint8Array[]): ReadableStream { let i = 0; return new ReadableStream({ pull(controller) { if (i < chunks.length) controller.enqueue(chunks[i++]!); else controller.close(); }, }); } describe('encryptStream / decryptStream — round-trip', () => { test('round-trips small payload exactly', async () => { const p = await makeProvider(); const streamId = subtle.randomBytes(16); const streamSecret = subtle.randomBytes(32); const plaintext = new TextEncoder().encode('hello stream'); const enc = await createEncryptStream({ provider: p, streamId, streamSecret, chunkSize: 1024, }); const wireBytes = await readAll( streamFromChunks([plaintext]).pipeThrough(enc.stream), ); // Frame: each enqueue is one wire envelope. We can't trivially split // a concatenated buffer back into envelopes, but we know how many // chunks were emitted (len/chunkSize, plus the final isLast). Easier // path: collect them as separate writes through a side channel. const chunks: Uint8Array[] = []; await streamFromChunks([plaintext]) .pipeThrough( ( await createEncryptStream({ provider: p, streamId, streamSecret, chunkSize: 1024, }) ).stream, ) .pipeTo( new WritableStream({ write(c) { chunks.push(c); }, }), ); const dec = await createDecryptStream({ provider: p, streamId, streamSecret }); const recovered = await readAll(streamFromChunks(chunks).pipeThrough(dec.stream)); expect(recovered).toEqual(plaintext); expect(wireBytes.byteLength).toBeGreaterThan(plaintext.byteLength); // overhead }); test('round-trips multi-chunk payload with sha256 parity', async () => { const p = await makeProvider(); const streamId = subtle.randomBytes(16); const streamSecret = subtle.randomBytes(32); const total = 750 * 1024; // 750 KiB → forces 3+ chunks at 256 KiB const plaintext = subtle.randomBytes(total); const expectedSha = sha256(plaintext); const enc = await createEncryptStream({ provider: p, streamId, streamSecret, chunkSize: 256 * 1024, }); const wireChunks: Uint8Array[] = []; await streamFromChunks([plaintext]) .pipeThrough(enc.stream) .pipeTo( new WritableStream({ write(c) { wireChunks.push(c); }, }), ); // 750 KiB / 256 KiB = 2 full chunks + 1 final (238 KiB, isLast=true) expect(wireChunks.length).toBe(3); const senderLaneSha = await enc.laneSha256; expect(senderLaneSha).toEqual(expectedSha); const dec = await createDecryptStream({ provider: p, streamId, streamSecret, }); const recovered = await readAll(streamFromChunks(wireChunks).pipeThrough(dec.stream)); expect(recovered).toEqual(plaintext); expect(await dec.laneSha256).toEqual(expectedSha); }); test('fragmented input produces same output as single-shot', async () => { const p = await makeProvider(); const streamId = subtle.randomBytes(16); const streamSecret = subtle.randomBytes(32); const plaintext = subtle.randomBytes(50_000); async function run(parts: Uint8Array[]): Promise { const wire: Uint8Array[] = []; const e = await createEncryptStream({ provider: p!, streamId, streamSecret, chunkSize: 8 * 1024, }); await streamFromChunks(parts) .pipeThrough(e.stream) .pipeTo(new WritableStream({ write: (c) => void wire.push(c) })); return wire; } const single = await run([plaintext]); const split = await run([ plaintext.subarray(0, 17_000), plaintext.subarray(17_000, 33_000), plaintext.subarray(33_000), ]); expect(split.length).toBe(single.length); for (let i = 0; i < single.length; i++) { // Same chunk size, same lane key, same seq — wire bytes match // byte-for-byte (deterministic nonces + AEAD). expect(split[i]).toEqual(single[i]!); } }); test('100 KiB stream end-to-end completes', async () => { const p = await makeProvider(); const streamId = subtle.randomBytes(16); const streamSecret = subtle.randomBytes(32); const plaintext = subtle.randomBytes(100 * 1024); const enc = await createEncryptStream({ provider: p, streamId, streamSecret, chunkSize: 16 * 1024, }); const wire: Uint8Array[] = []; await streamFromChunks([plaintext]) .pipeThrough(enc.stream) .pipeTo(new WritableStream({ write: (c) => void wire.push(c) })); const dec = await createDecryptStream({ provider: p, streamId, streamSecret }); const out = await readAll(streamFromChunks(wire).pipeThrough(dec.stream)); expect(out).toEqual(plaintext); expect(await dec.laneSha256).toEqual(await enc.laneSha256); }); test('decryptStream rejects out-of-order chunks', async () => { const p = await makeProvider(); const streamId = subtle.randomBytes(16); const streamSecret = subtle.randomBytes(32); const plaintext = subtle.randomBytes(20_000); const enc = await createEncryptStream({ provider: p, streamId, streamSecret, chunkSize: 4 * 1024, }); const wire: Uint8Array[] = []; await streamFromChunks([plaintext]) .pipeThrough(enc.stream) .pipeTo(new WritableStream({ write: (c) => void wire.push(c) })); expect(wire.length).toBeGreaterThan(2); // Swap first and second chunk [wire[0], wire[1]] = [wire[1]!, wire[0]!]; const dec = await createDecryptStream({ provider: p, streamId, streamSecret }); await expect( streamFromChunks(wire).pipeThrough(dec.stream).pipeTo( new WritableStream({ write() {} }), ), ).rejects.toThrow(); }); });