import { describe, test, expect } from 'bun:test'; import { SubtleCryptoProvider } from '@shade/crypto-web'; import { aesGcmEncryptWithNonce, aesGcmDecryptWithNonce, buildChunkNonce, buildChunkAad, deriveStreamKey, deriveLaneKey, StreamDecryptionError, } from '../src/index.js'; const crypto = new SubtleCryptoProvider(); async function laneKey(): Promise<{ key: Uint8Array; streamId: Uint8Array }> { const secret = new Uint8Array(32).fill(0x42); const streamId = new Uint8Array(16).fill(0x99); const sk = await deriveStreamKey(crypto, secret, streamId); const lk = await deriveLaneKey(crypto, sk, streamId, 0); return { key: lk, streamId }; } describe('aesGcmEncryptWithNonce / aesGcmDecryptWithNonce', () => { test('encrypt → decrypt roundtrip', async () => { const { key, streamId } = await laneKey(); const nonce = buildChunkNonce(0, 0); const aad = buildChunkAad(streamId, 0, 0, false); const plaintext = new TextEncoder().encode('hello shade streams'); const ct = await aesGcmEncryptWithNonce(key, nonce, plaintext, aad); const pt = await aesGcmDecryptWithNonce(key, nonce, ct, aad); expect(new TextDecoder().decode(pt)).toBe('hello shade streams'); }); test('produces ciphertext length = plaintext + 16-byte tag', async () => { const { key, streamId } = await laneKey(); const plaintext = new Uint8Array(1024); const ct = await aesGcmEncryptWithNonce( key, buildChunkNonce(0, 0), plaintext, buildChunkAad(streamId, 0, 0, false), ); expect(ct.length).toBe(1024 + 16); }); test('handles empty plaintext', async () => { const { key, streamId } = await laneKey(); const nonce = buildChunkNonce(0, 0); const aad = buildChunkAad(streamId, 0, 0, true); const ct = await aesGcmEncryptWithNonce(key, nonce, new Uint8Array(0), aad); expect(ct.length).toBe(16); // tag only const pt = await aesGcmDecryptWithNonce(key, nonce, ct, aad); expect(pt.length).toBe(0); }); test('handles 1 MiB plaintext (default chunk size)', async () => { const { key, streamId } = await laneKey(); const nonce = buildChunkNonce(0, 0); const aad = buildChunkAad(streamId, 0, 0, false); const plaintext = crypto.randomBytes(1024 * 1024); const ct = await aesGcmEncryptWithNonce(key, nonce, plaintext, aad); const pt = await aesGcmDecryptWithNonce(key, nonce, ct, aad); expect(pt).toEqual(plaintext); }); test('different nonces with same key produce different ciphertexts', async () => { const { key, streamId } = await laneKey(); const aad = buildChunkAad(streamId, 0, 0, false); const plaintext = new TextEncoder().encode('same plaintext'); const ct1 = await aesGcmEncryptWithNonce(key, buildChunkNonce(0, 0), plaintext, aad); const ct2 = await aesGcmEncryptWithNonce(key, buildChunkNonce(0, 1), plaintext, aad); expect(ct1).not.toEqual(ct2); }); test('tampered ciphertext byte → StreamDecryptionError', async () => { const { key, streamId } = await laneKey(); const nonce = buildChunkNonce(0, 0); const aad = buildChunkAad(streamId, 0, 0, false); const ct = await aesGcmEncryptWithNonce(key, nonce, new TextEncoder().encode('hi'), aad); ct[0] ^= 0x01; await expect(aesGcmDecryptWithNonce(key, nonce, ct, aad)).rejects.toThrow( StreamDecryptionError, ); }); test('tampered tag byte → StreamDecryptionError', async () => { const { key, streamId } = await laneKey(); const nonce = buildChunkNonce(0, 0); const aad = buildChunkAad(streamId, 0, 0, false); const ct = await aesGcmEncryptWithNonce(key, nonce, new TextEncoder().encode('hi'), aad); ct[ct.length - 1] ^= 0x80; await expect(aesGcmDecryptWithNonce(key, nonce, ct, aad)).rejects.toThrow( StreamDecryptionError, ); }); test('wrong AAD → StreamDecryptionError', async () => { const { key, streamId } = await laneKey(); const nonce = buildChunkNonce(0, 0); const aadEnc = buildChunkAad(streamId, 0, 0, false); const aadDec = buildChunkAad(streamId, 0, 0, true); // isLast flipped const ct = await aesGcmEncryptWithNonce(key, nonce, new TextEncoder().encode('hi'), aadEnc); await expect(aesGcmDecryptWithNonce(key, nonce, ct, aadDec)).rejects.toThrow( StreamDecryptionError, ); }); test('wrong nonce → StreamDecryptionError', async () => { const { key, streamId } = await laneKey(); const aad = buildChunkAad(streamId, 0, 0, false); const ct = await aesGcmEncryptWithNonce( key, buildChunkNonce(0, 0), new TextEncoder().encode('hi'), aad, ); await expect( aesGcmDecryptWithNonce(key, buildChunkNonce(0, 1), ct, aad), ).rejects.toThrow(StreamDecryptionError); }); test('wrong key → StreamDecryptionError', async () => { const { streamId } = await laneKey(); const nonce = buildChunkNonce(0, 0); const aad = buildChunkAad(streamId, 0, 0, false); const k1 = new Uint8Array(32).fill(1); const k2 = new Uint8Array(32).fill(2); const ct = await aesGcmEncryptWithNonce(k1, nonce, new TextEncoder().encode('hi'), aad); await expect(aesGcmDecryptWithNonce(k2, nonce, ct, aad)).rejects.toThrow( StreamDecryptionError, ); }); test('rejects non-12-byte nonce', async () => { const { key, streamId } = await laneKey(); const aad = buildChunkAad(streamId, 0, 0, false); await expect( aesGcmEncryptWithNonce(key, new Uint8Array(11), new Uint8Array(0), aad), ).rejects.toThrow(); await expect( aesGcmDecryptWithNonce(key, new Uint8Array(13), new Uint8Array(16), aad), ).rejects.toThrow(); }); });