146 lines
5.5 KiB
TypeScript
146 lines
5.5 KiB
TypeScript
|
|
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();
|
||
|
|
});
|
||
|
|
});
|