add frontend AES-GCM and base64 helpers with Vitest coverage.
This commit is contained in:
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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']);
|
||||
};
|
||||
Reference in New Issue
Block a user