add frontend AES-GCM and base64 helpers with Vitest coverage.

This commit is contained in:
2026-10-19 03:00:03 +02:00
parent f2987da2fe
commit 0238858c8b
29 changed files with 2021 additions and 2 deletions
@@ -0,0 +1,66 @@
import { describe, expect, it } from 'vitest';
import { AES_GCM_AUTH_TAG_BYTE_LENGTH } from '@/consts/crypto/aesGcmAuthTagByteLength';
import { AES_GCM_IV_BYTE_LENGTH } from '@/consts/crypto/aesGcmIvByteLength';
import { decryptAesGcm } from '@/utils/crypto/decryptAesGcm';
import { encryptAesGcm } from '@/utils/crypto/encryptAesGcm';
import { generateDek } from '@/utils/crypto/generateDek';
import { generateIv } from '@/utils/crypto/generateIv';
import { importAesGcmKey } from '@/utils/crypto/importAesGcmKey';
describe('decryptAesGcm', () => {
it('rejects wrong-length IVs before calling Web Crypto', async () => {
const dek = generateDek();
const key = await importAesGcmKey(dek);
const ciphertext = new Uint8Array(AES_GCM_AUTH_TAG_BYTE_LENGTH);
await expect(decryptAesGcm(ciphertext, key, new Uint8Array(AES_GCM_IV_BYTE_LENGTH - 1))).rejects.toThrow(
`AES-GCM IV must be ${AES_GCM_IV_BYTE_LENGTH} bytes`
);
await expect(decryptAesGcm(ciphertext, key, new Uint8Array(AES_GCM_IV_BYTE_LENGTH + 1))).rejects.toThrow(
`AES-GCM IV must be ${AES_GCM_IV_BYTE_LENGTH} bytes`
);
});
it('rejects tampered ciphertext (auth tag failure)', async () => {
const plaintext = new TextEncoder().encode('integrity-checked');
const dek = generateDek();
const iv = generateIv();
const key = await importAesGcmKey(dek);
const ciphertext = await encryptAesGcm(plaintext, key, iv);
const tampered = new Uint8Array(ciphertext);
tampered[tampered.length - 1] ^= 0x01;
await expect(decryptAesGcm(tampered, key, iv)).rejects.toThrow();
});
it('rejects decryption with the wrong key', async () => {
const plaintext = new TextEncoder().encode('wrong-key');
const iv = generateIv();
const encryptKey = await importAesGcmKey(generateDek());
const decryptKey = await importAesGcmKey(generateDek());
const ciphertext = await encryptAesGcm(plaintext, encryptKey, iv);
await expect(decryptAesGcm(ciphertext, decryptKey, iv)).rejects.toThrow();
});
it('rejects decryption with the wrong IV', async () => {
const plaintext = new TextEncoder().encode('wrong-iv');
const dek = generateDek();
const key = await importAesGcmKey(dek);
const ciphertext = await encryptAesGcm(plaintext, key, generateIv());
await expect(decryptAesGcm(ciphertext, key, generateIv())).rejects.toThrow();
});
it('rejects truncated ciphertext', async () => {
const plaintext = new TextEncoder().encode('truncated');
const dek = generateDek();
const iv = generateIv();
const key = await importAesGcmKey(dek);
const ciphertext = await encryptAesGcm(plaintext, key, iv);
const truncated = ciphertext.slice(0, ciphertext.length - 1);
await expect(decryptAesGcm(truncated, key, iv)).rejects.toThrow();
});
});
@@ -0,0 +1,19 @@
import { AES_GCM_IV_BYTE_LENGTH } from '@/consts/crypto/aesGcmIvByteLength';
import { toArrayBuffer } from '@/utils/toArrayBuffer';
export const decryptAesGcm = async (ciphertext: Uint8Array, key: CryptoKey, iv: Uint8Array): Promise<Uint8Array> => {
if (iv.byteLength !== AES_GCM_IV_BYTE_LENGTH) {
throw new Error(`AES-GCM IV must be ${AES_GCM_IV_BYTE_LENGTH} bytes`);
}
const plaintext = await crypto.subtle.decrypt(
{
name: 'AES-GCM',
iv: toArrayBuffer(iv)
},
key,
toArrayBuffer(ciphertext)
);
return new Uint8Array(plaintext);
};
@@ -0,0 +1,109 @@
import { describe, expect, it } from 'vitest';
import { AES_GCM_AUTH_TAG_BYTE_LENGTH } from '@/consts/crypto/aesGcmAuthTagByteLength';
import { AES_GCM_IV_BYTE_LENGTH } from '@/consts/crypto/aesGcmIvByteLength';
import { bytesToBase64 } from '@/utils/encoding/bytesToBase64';
import { decryptAesGcm } from '@/utils/crypto/decryptAesGcm';
import { encryptAesGcm } from '@/utils/crypto/encryptAesGcm';
import { generateDek } from '@/utils/crypto/generateDek';
import { generateIv } from '@/utils/crypto/generateIv';
import { importAesGcmKey } from '@/utils/crypto/importAesGcmKey';
const UPLOAD_API_BASE64_PATTERN = /^[A-Za-z0-9+/]+={0,2}$/;
describe('encryptAesGcm', () => {
it('roundtrips plaintext', async () => {
const plaintext = new TextEncoder().encode('stripped-image-bytes');
const dek = generateDek();
const iv = generateIv();
const key = await importAesGcmKey(dek);
const ciphertext = await encryptAesGcm(plaintext, key, iv);
const decrypted = await decryptAesGcm(ciphertext, key, iv);
expect(decrypted).toEqual(plaintext);
});
it('roundtrips empty plaintext and large payloads', async () => {
const dek = generateDek();
const key = await importAesGcmKey(dek);
const empty = new Uint8Array(0);
const large = new Uint8Array(65_536);
for (let i = 0; i < large.length; i++) {
large[i] = i % 256;
}
for (const plaintext of [empty, large]) {
const iv = generateIv();
const ciphertext = await encryptAesGcm(plaintext, key, iv);
const decrypted = await decryptAesGcm(ciphertext, key, iv);
expect(decrypted).toEqual(plaintext);
expect(ciphertext.byteLength).toBe(plaintext.byteLength + AES_GCM_AUTH_TAG_BYTE_LENGTH);
}
});
it('accepts ArrayBuffer plaintext', async () => {
const plaintext = new TextEncoder().encode('array-buffer-plaintext').buffer;
const dek = generateDek();
const iv = generateIv();
const key = await importAesGcmKey(dek);
const ciphertext = await encryptAesGcm(plaintext, key, iv);
const decrypted = await decryptAesGcm(ciphertext, key, iv);
expect(decrypted).toEqual(new Uint8Array(plaintext));
});
it('rejects wrong-length IVs before calling Web Crypto', async () => {
const dek = generateDek();
const key = await importAesGcmKey(dek);
const plaintext = new Uint8Array([1, 2, 3]);
await expect(encryptAesGcm(plaintext, key, new Uint8Array(AES_GCM_IV_BYTE_LENGTH - 1))).rejects.toThrow(
`AES-GCM IV must be ${AES_GCM_IV_BYTE_LENGTH} bytes`
);
await expect(encryptAesGcm(plaintext, key, new Uint8Array(AES_GCM_IV_BYTE_LENGTH + 1))).rejects.toThrow(
`AES-GCM IV must be ${AES_GCM_IV_BYTE_LENGTH} bytes`
);
});
it('is deterministic for a fixed key, IV, and plaintext', async () => {
const plaintext = new TextEncoder().encode('deterministic-check');
const dek = generateDek();
const iv = generateIv();
const key = await importAesGcmKey(dek);
const first = await encryptAesGcm(plaintext, key, iv);
const second = await encryptAesGcm(plaintext, key, iv);
expect(first).toEqual(second);
});
it('changes ciphertext when the IV changes (nonce separation)', async () => {
const plaintext = new TextEncoder().encode('unique-nonce');
const dek = generateDek();
const key = await importAesGcmKey(dek);
const ciphertextA = await encryptAesGcm(plaintext, key, generateIv());
const ciphertextB = await encryptAesGcm(plaintext, key, generateIv());
expect(ciphertextA).not.toEqual(ciphertextB);
});
it('produces standard base64 iv and ciphertext suitable for the upload API', async () => {
const plaintext = new Uint8Array(64);
const dek = generateDek();
const iv = generateIv();
const key = await importAesGcmKey(dek);
const ciphertext = await encryptAesGcm(plaintext, key, iv);
const ivBase64 = bytesToBase64(iv);
const ciphertextBase64 = bytesToBase64(ciphertext);
expect(UPLOAD_API_BASE64_PATTERN.test(ivBase64)).toBe(true);
expect(UPLOAD_API_BASE64_PATTERN.test(ciphertextBase64)).toBe(true);
expect(ciphertext.byteLength).toBe(plaintext.byteLength + AES_GCM_AUTH_TAG_BYTE_LENGTH);
});
});
@@ -0,0 +1,23 @@
import { AES_GCM_IV_BYTE_LENGTH } from '@/consts/crypto/aesGcmIvByteLength';
import { toArrayBuffer } from '@/utils/toArrayBuffer';
export const encryptAesGcm = async (
plaintext: ArrayBuffer | Uint8Array,
key: CryptoKey,
iv: Uint8Array
): Promise<Uint8Array> => {
if (iv.byteLength !== AES_GCM_IV_BYTE_LENGTH) {
throw new Error(`AES-GCM IV must be ${AES_GCM_IV_BYTE_LENGTH} bytes`);
}
const ciphertext = await crypto.subtle.encrypt(
{
name: 'AES-GCM',
iv: toArrayBuffer(iv)
},
key,
toArrayBuffer(plaintext)
);
return new Uint8Array(ciphertext);
};
@@ -0,0 +1,19 @@
import { describe, expect, it } from 'vitest';
import { AES_GCM_KEY_BYTE_LENGTH } from '@/consts/crypto/aesGcmKeyByteLength';
import { generateDek } from '@/utils/crypto/generateDek';
describe('generateDek', () => {
it('returns a 32-byte raw AES-256 key', () => {
const dek = generateDek();
expect(dek).toBeInstanceOf(Uint8Array);
expect(dek.byteLength).toBe(AES_GCM_KEY_BYTE_LENGTH);
});
it('generates distinct DEKs across calls', () => {
const dekA = generateDek();
const dekB = generateDek();
expect(dekA).not.toEqual(dekB);
});
});
+4
View File
@@ -0,0 +1,4 @@
import { AES_GCM_KEY_BYTE_LENGTH } from '@/consts/crypto/aesGcmKeyByteLength';
import { generateRandomBytes } from '@/utils/crypto/generateRandomBytes';
export const generateDek = (): Uint8Array => generateRandomBytes(AES_GCM_KEY_BYTE_LENGTH);
@@ -0,0 +1,19 @@
import { describe, expect, it } from 'vitest';
import { AES_GCM_IV_BYTE_LENGTH } from '@/consts/crypto/aesGcmIvByteLength';
import { generateIv } from '@/utils/crypto/generateIv';
describe('generateIv', () => {
it('returns a 12-byte IV for AES-GCM', () => {
const iv = generateIv();
expect(iv).toBeInstanceOf(Uint8Array);
expect(iv.byteLength).toBe(AES_GCM_IV_BYTE_LENGTH);
});
it('generates distinct IVs across calls', () => {
const ivA = generateIv();
const ivB = generateIv();
expect(ivA).not.toEqual(ivB);
});
});
+4
View File
@@ -0,0 +1,4 @@
import { AES_GCM_IV_BYTE_LENGTH } from '@/consts/crypto/aesGcmIvByteLength';
import { generateRandomBytes } from '@/utils/crypto/generateRandomBytes';
export const generateIv = (): Uint8Array => generateRandomBytes(AES_GCM_IV_BYTE_LENGTH);
@@ -0,0 +1,24 @@
import { describe, expect, it } from 'vitest';
import { generateRandomBytes } from '@/utils/crypto/generateRandomBytes';
describe('generateRandomBytes', () => {
it('returns a Uint8Array of the requested length', () => {
expect(generateRandomBytes(0)).toEqual(new Uint8Array(0));
expect(generateRandomBytes(1).byteLength).toBe(1);
expect(generateRandomBytes(4096).byteLength).toBe(4096);
});
it('does not return the same buffer for consecutive calls', () => {
const first = generateRandomBytes(32);
const second = generateRandomBytes(32);
expect(first).not.toEqual(second);
});
it('is not trivially constant for non-zero length', () => {
const bytes = generateRandomBytes(64);
const allSame = bytes.every(byte => byte === bytes[0]);
expect(allSame).toBe(false);
});
});
@@ -0,0 +1,5 @@
export const generateRandomBytes = (length: number): Uint8Array => {
const bytes = new Uint8Array(length);
return crypto.getRandomValues(bytes);
};
@@ -0,0 +1,35 @@
import { describe, expect, it } from 'vitest';
import { AES_GCM_KEY_BYTE_LENGTH } from '@/consts/crypto/aesGcmKeyByteLength';
import { encryptAesGcm } from '@/utils/crypto/encryptAesGcm';
import { generateDek } from '@/utils/crypto/generateDek';
import { generateIv } from '@/utils/crypto/generateIv';
import { importAesGcmKey } from '@/utils/crypto/importAesGcmKey';
describe('importAesGcmKey', () => {
it('imports a raw 32-byte key for AES-GCM encrypt and decrypt', async () => {
const dek = generateDek();
const key = await importAesGcmKey(dek);
expect(key.algorithm.name).toBe('AES-GCM');
expect(key.type).toBe('secret');
expect(key.extractable).toBe(false);
expect(key.usages).toEqual(['encrypt', 'decrypt']);
const iv = generateIv();
const plaintext = new TextEncoder().encode('key-import-smoke');
await expect(encryptAesGcm(plaintext, key, iv)).resolves.toBeInstanceOf(Uint8Array);
});
it('rejects wrong-length raw keys', async () => {
const shortKey = new Uint8Array(AES_GCM_KEY_BYTE_LENGTH - 1);
const longKey = new Uint8Array(AES_GCM_KEY_BYTE_LENGTH + 1);
await expect(importAesGcmKey(shortKey)).rejects.toThrow(
`AES-GCM key must be ${AES_GCM_KEY_BYTE_LENGTH} bytes`
);
await expect(importAesGcmKey(longKey)).rejects.toThrow(
`AES-GCM key must be ${AES_GCM_KEY_BYTE_LENGTH} bytes`
);
});
});
@@ -0,0 +1,10 @@
import { AES_GCM_KEY_BYTE_LENGTH } from '@/consts/crypto/aesGcmKeyByteLength';
import { toArrayBuffer } from '@/utils/toArrayBuffer';
export const importAesGcmKey = async (rawKey: Uint8Array): Promise<CryptoKey> => {
if (rawKey.byteLength !== AES_GCM_KEY_BYTE_LENGTH) {
throw new Error(`AES-GCM key must be ${AES_GCM_KEY_BYTE_LENGTH} bytes`);
}
return crypto.subtle.importKey('raw', toArrayBuffer(rawKey), { name: 'AES-GCM' }, false, ['encrypt', 'decrypt']);
};