import { describe, test, expect } from 'bun:test'; import { SubtleCryptoProvider } from '@shade/crypto-web'; import { StreamSender, StreamReceiver, StreamFinishedError, generateStreamId, generateStreamSecret, } from '../src/index.js'; const crypto = new SubtleCryptoProvider(); function hex(b: Uint8Array): string { return Array.from(b, (x) => x.toString(16).padStart(2, '0')).join(''); } async function makePair(opts?: { laneId?: number; startSeq?: number }) { const streamId = generateStreamId(crypto); const streamSecret = generateStreamSecret(crypto); const laneId = opts?.laneId ?? 0; const sender = await StreamSender.create({ crypto, streamId, streamSecret, laneId, ...(opts?.startSeq !== undefined ? { startSeq: opts.startSeq } : {}), }); const receiver = await StreamReceiver.create({ crypto, streamId, streamSecret, laneId, ...(opts?.startSeq !== undefined ? { startSeq: opts.startSeq } : {}), }); return { sender, receiver, streamId, streamSecret }; } describe('Single-lane sender/receiver roundtrip', () => { test('basic single-chunk transfer', async () => { const { sender, receiver } = await makePair(); const plaintext = new TextEncoder().encode('hello shade'); const { bytes } = await sender.encryptChunk(plaintext, true); const decrypted = await receiver.decryptChunk(bytes); expect(new TextDecoder().decode(decrypted.plaintext)).toBe('hello shade'); expect(decrypted.seq).toBe(0); expect(decrypted.isLast).toBe(true); }); test('multi-chunk transfer with monotonic seq', async () => { const { sender, receiver } = await makePair(); const chunks = ['alpha', 'beta', 'gamma', 'delta']; for (let i = 0; i < chunks.length; i++) { const isLast = i === chunks.length - 1; const { bytes, seq } = await sender.encryptChunk( new TextEncoder().encode(chunks[i]!), isLast, ); expect(seq).toBe(i); const dec = await receiver.decryptChunk(bytes); expect(new TextDecoder().decode(dec.plaintext)).toBe(chunks[i]); expect(dec.seq).toBe(i); expect(dec.isLast).toBe(isLast); } }); test('lane sha256 matches between sender and receiver', async () => { const { sender, receiver } = await makePair(); const data = [ crypto.randomBytes(1024), crypto.randomBytes(2048), crypto.randomBytes(512), ]; for (let i = 0; i < data.length; i++) { const { bytes } = await sender.encryptChunk(data[i]!, i === data.length - 1); await receiver.decryptChunk(bytes); } expect(hex(sender.getLaneSha256Digest())).toBe(hex(receiver.getLaneSha256Digest())); }); test('handles empty chunks', async () => { const { sender, receiver } = await makePair(); const { bytes } = await sender.encryptChunk(new Uint8Array(0), true); const dec = await receiver.decryptChunk(bytes); expect(dec.plaintext.length).toBe(0); expect(dec.isLast).toBe(true); }); test('ship-gate: ~10 MiB roundtrip preserves byte-for-byte content', async () => { const { sender, receiver } = await makePair(); const total = 10 * 1024 * 1024; const chunkSize = 256 * 1024; const allBytes = crypto.randomBytes(total); const reconstructed: Uint8Array[] = []; for (let off = 0; off < total; off += chunkSize) { const slice = allBytes.subarray(off, Math.min(off + chunkSize, total)); const isLast = off + chunkSize >= total; const { bytes } = await sender.encryptChunk(slice, isLast); const dec = await receiver.decryptChunk(bytes); reconstructed.push(dec.plaintext); } let off = 0; for (const piece of reconstructed) { for (let i = 0; i < piece.length; i++) { if (piece[i] !== allBytes[off + i]) { throw new Error(`mismatch at byte ${off + i}`); } } off += piece.length; } expect(off).toBe(total); expect(hex(sender.getLaneSha256Digest())).toBe(hex(receiver.getLaneSha256Digest())); }); test('byte counters track encrypted/decrypted plaintext', async () => { const { sender, receiver } = await makePair(); const a = crypto.randomBytes(100); const b = crypto.randomBytes(250); const { bytes: ab } = await sender.encryptChunk(a, false); const { bytes: bb } = await sender.encryptChunk(b, true); await receiver.decryptChunk(ab); await receiver.decryptChunk(bb); expect(sender.bytesSent).toBe(350n); expect(receiver.bytesReceived).toBe(350n); }); test('finished flag set after isLast', async () => { const { sender, receiver } = await makePair(); const { bytes } = await sender.encryptChunk(new Uint8Array(8), true); expect(sender.isFinished).toBe(true); await receiver.decryptChunk(bytes); expect(receiver.isFinished).toBe(true); }); test('encryptChunk after finish throws StreamFinishedError', async () => { const { sender } = await makePair(); await sender.encryptChunk(new Uint8Array(0), true); await expect(sender.encryptChunk(new Uint8Array(0), false)).rejects.toThrow( StreamFinishedError, ); }); test('decryptChunk after finish throws StreamFinishedError', async () => { const { sender, receiver } = await makePair(); const { bytes: a } = await sender.encryptChunk(new Uint8Array(8), true); await receiver.decryptChunk(a); // Try to feed another chunk — sender wouldn't normally produce one, but // simulate an attacker sending bytes after the legitimate isLast. const sender2 = await StreamSender.create({ crypto, streamId: (sender as unknown as { streamId: Uint8Array }).streamId, streamSecret: new Uint8Array(32), laneId: 0, }); const { bytes: extra } = await sender2.encryptChunk(new Uint8Array(8), false); await expect(receiver.decryptChunk(extra)).rejects.toThrow(StreamFinishedError); }); test('destroy zeroes the lane key (subsequent calls throw)', async () => { const { sender, receiver } = await makePair(); sender.destroy(); receiver.destroy(); await expect(sender.encryptChunk(new Uint8Array(0), false)).rejects.toThrow( StreamFinishedError, ); }); test('startSeq enables resume from arbitrary offset', async () => { const { sender, receiver } = await makePair({ startSeq: 100 }); const { bytes, seq } = await sender.encryptChunk(new TextEncoder().encode('mid'), false); expect(seq).toBe(100); const dec = await receiver.decryptChunk(bytes); expect(dec.seq).toBe(100); }); });