import { describe, test, expect } from 'bun:test'; import { SubtleCryptoProvider } from '@shade/crypto-web'; import { decodeStreamChunk, encodeStreamChunk } from '@shade/proto'; import { StreamSender, StreamReceiver, StreamDecryptionError, StreamProtocolError, generateStreamId, generateStreamSecret, } from '../src/index.js'; const crypto = new SubtleCryptoProvider(); async function pair() { const streamId = generateStreamId(crypto); const streamSecret = generateStreamSecret(crypto); const sender = await StreamSender.create({ crypto, streamId, streamSecret, laneId: 0 }); const receiver = await StreamReceiver.create({ crypto, streamId, streamSecret, laneId: 0 }); return { sender, receiver }; } describe('Tamper detection', () => { test('flipping a ciphertext byte → StreamDecryptionError', async () => { const { sender, receiver } = await pair(); const { bytes } = await sender.encryptChunk(new TextEncoder().encode('payload'), false); const env = decodeStreamChunk(bytes); env.ciphertext[0] ^= 0x01; const reencoded = encodeStreamChunk(env); await expect(receiver.decryptChunk(reencoded)).rejects.toThrow(StreamDecryptionError); }); test('flipping the AEAD tag → StreamDecryptionError', async () => { const { sender, receiver } = await pair(); const { bytes } = await sender.encryptChunk(new TextEncoder().encode('payload'), false); const env = decodeStreamChunk(bytes); env.ciphertext[env.ciphertext.length - 1] ^= 0x80; await expect(receiver.decryptChunk(encodeStreamChunk(env))).rejects.toThrow( StreamDecryptionError, ); }); test('tampering with isLast flag → StreamDecryptionError (AAD mismatch)', async () => { const { sender, receiver } = await pair(); const { bytes } = await sender.encryptChunk(new TextEncoder().encode('payload'), false); const env = decodeStreamChunk(bytes); env.isLast = true; await expect(receiver.decryptChunk(encodeStreamChunk(env))).rejects.toThrow( StreamDecryptionError, ); }); test('tampering with the wire nonce → StreamProtocolError (deterministic check)', async () => { const { sender, receiver } = await pair(); const { bytes } = await sender.encryptChunk(new TextEncoder().encode('payload'), false); const env = decodeStreamChunk(bytes); env.nonce[0] ^= 0x01; await expect(receiver.decryptChunk(encodeStreamChunk(env))).rejects.toThrow( StreamProtocolError, ); }); test('tampering with streamId → StreamProtocolError', async () => { const { sender, receiver } = await pair(); const { bytes } = await sender.encryptChunk(new TextEncoder().encode('payload'), false); const env = decodeStreamChunk(bytes); env.streamId[0] ^= 0xff; await expect(receiver.decryptChunk(encodeStreamChunk(env))).rejects.toThrow( StreamProtocolError, ); }); test('routing a chunk to wrong-lane receiver → StreamProtocolError', async () => { const streamId = generateStreamId(crypto); const streamSecret = generateStreamSecret(crypto); const sender0 = await StreamSender.create({ crypto, streamId, streamSecret, laneId: 0 }); const receiver1 = await StreamReceiver.create({ crypto, streamId, streamSecret, laneId: 1 }); const { bytes } = await sender0.encryptChunk(new TextEncoder().encode('payload'), false); await expect(receiver1.decryptChunk(bytes)).rejects.toThrow(StreamProtocolError); }); test('non-empty AAD on wire → StreamProtocolError (reserved in v0.2.0)', async () => { const { sender, receiver } = await pair(); const { bytes } = await sender.encryptChunk(new TextEncoder().encode('payload'), false); const env = decodeStreamChunk(bytes); env.aad = new Uint8Array([1, 2, 3]); await expect(receiver.decryptChunk(encodeStreamChunk(env))).rejects.toThrow( StreamProtocolError, ); }); test('different streamSecret → StreamDecryptionError', async () => { const streamId = generateStreamId(crypto); const sender = await StreamSender.create({ crypto, streamId, streamSecret: new Uint8Array(32).fill(1), laneId: 0, }); const receiver = await StreamReceiver.create({ crypto, streamId, streamSecret: new Uint8Array(32).fill(2), laneId: 0, }); const { bytes } = await sender.encryptChunk(new TextEncoder().encode('hi'), false); await expect(receiver.decryptChunk(bytes)).rejects.toThrow(StreamDecryptionError); }); });