/** * Security: AES-256-GCM Encryption Properties * * Verifies the security properties of @/lib/utils/encryption: * - Ciphertext never contains plaintext * - Random IVs produce different ciphertexts for identical plaintexts * - Tampered ciphertext or auth tag throws (GCM authentication) * - Decryption round-trips correctly * - Missing / malformed key is rejected at runtime * * Note: tests/unit/encryption.test.ts covers basic round-trip and IV * randomness. This file focuses on the *security boundary* properties * (plaintext non-exposure, authenticated encryption, key validation). * * SECURITY-GUIDELINES.md: credentials_enc uses AES-256-GCM. */ import { beforeAll, describe, expect, it } from 'vitest'; const VALID_KEY = 'a'.repeat(64); // 64 hex chars = 32 bytes beforeAll(() => { process.env.EMAIL_CREDENTIAL_KEY = VALID_KEY; }); // ───────────────────────────────────────────────────────────────────────────── describe('AES-256-GCM — plaintext non-exposure', () => { it('encrypted output does not contain the plaintext', async () => { const { encrypt } = await import('@/lib/utils/encryption'); const plaintext = 'my-secret-password'; const encrypted = encrypt(plaintext); expect(encrypted).not.toContain(plaintext); }); it('encrypted output does not contain plaintext even for short values', async () => { const { encrypt } = await import('@/lib/utils/encryption'); // Pick a 2-char plaintext using *non-hex* characters so the assertion can't // false-positive: random hex bytes routinely contain pairs like 'ab' or 'cd' // by chance (~1 in 256 byte positions). Using 'XY' (neither is a hex digit) // means a passing assertion actually proves the plaintext didn't leak. const plaintext = 'XY'; const encrypted = encrypt(plaintext); expect(encrypted).not.toContain(plaintext); }); it('encrypted output is a JSON object with iv, tag, data fields', async () => { const { encrypt } = await import('@/lib/utils/encryption'); const encrypted = encrypt('test-payload'); const parsed = JSON.parse(encrypted) as Record; expect(typeof parsed.iv).toBe('string'); expect(typeof parsed.tag).toBe('string'); expect(typeof parsed.data).toBe('string'); }); it('IV is 12 bytes (24 hex chars)', async () => { const { encrypt } = await import('@/lib/utils/encryption'); const parsed = JSON.parse(encrypt('hello')) as { iv: string }; expect(parsed.iv).toHaveLength(24); // 12 bytes × 2 hex chars/byte }); it('GCM auth tag is 16 bytes (32 hex chars)', async () => { const { encrypt } = await import('@/lib/utils/encryption'); const parsed = JSON.parse(encrypt('hello')) as { tag: string }; expect(parsed.tag).toHaveLength(32); // 16 bytes × 2 hex chars/byte }); }); describe('AES-256-GCM — IV randomness (semantic security)', () => { it('different plaintexts produce different ciphertexts', async () => { const { encrypt } = await import('@/lib/utils/encryption'); const enc1 = encrypt('password1'); const enc2 = encrypt('password2'); expect(enc1).not.toBe(enc2); }); it('same plaintext produces different ciphertexts (random IV)', async () => { const { encrypt } = await import('@/lib/utils/encryption'); const enc1 = encrypt('same-password'); const enc2 = encrypt('same-password'); // IVs differ, so ciphertexts differ — prevents ciphertext comparison attacks expect(enc1).not.toBe(enc2); }); it('IVs are unique across repeated encryptions of identical plaintext', async () => { const { encrypt } = await import('@/lib/utils/encryption'); const ivs = Array.from({ length: 10 }, () => { const parsed = JSON.parse(encrypt('repeated')) as { iv: string }; return parsed.iv; }); const uniqueIvs = new Set(ivs); // All 10 IVs must be unique (birthday probability is negligible for 12-byte random) expect(uniqueIvs.size).toBe(10); }); }); describe('AES-256-GCM — authenticated encryption (tamper detection)', () => { it('tampered data field throws on decrypt', async () => { const { encrypt, decrypt } = await import('@/lib/utils/encryption'); const encrypted = encrypt('test'); const parsed = JSON.parse(encrypted) as { iv: string; tag: string; data: string }; // Flip the first byte of ciphertext const flipped = parsed.data.slice(0, 2) === 'ff' ? '00' : 'ff'; parsed.data = flipped + parsed.data.slice(2); expect(() => decrypt(JSON.stringify(parsed))).toThrow(); }); it('tampered auth tag throws on decrypt', async () => { const { encrypt, decrypt } = await import('@/lib/utils/encryption'); const encrypted = encrypt('test-auth-tag'); const parsed = JSON.parse(encrypted) as { iv: string; tag: string; data: string }; // Corrupt the auth tag const flipped = parsed.tag.slice(0, 2) === 'ff' ? '00' : 'ff'; parsed.tag = flipped + parsed.tag.slice(2); expect(() => decrypt(JSON.stringify(parsed))).toThrow(); }); it('tampered IV throws on decrypt', async () => { const { encrypt, decrypt } = await import('@/lib/utils/encryption'); const encrypted = encrypt('test-iv-tamper'); const parsed = JSON.parse(encrypted) as { iv: string; tag: string; data: string }; // Replace IV with a different random 12-byte value parsed.iv = 'b'.repeat(24); expect(() => decrypt(JSON.stringify(parsed))).toThrow(); }); it('completely different ciphertext throws on decrypt', async () => { const { decrypt } = await import('@/lib/utils/encryption'); const fake = JSON.stringify({ iv: 'c'.repeat(24), tag: 'd'.repeat(32), data: 'e'.repeat(32), }); expect(() => decrypt(fake)).toThrow(); }); }); describe('AES-256-GCM — decryption correctness', () => { it('decrypt recovers original plaintext', async () => { const { encrypt, decrypt } = await import('@/lib/utils/encryption'); const plaintext = 'my-secret-credentials'; const encrypted = encrypt(plaintext); const decrypted = decrypt(encrypted); expect(decrypted).toBe(plaintext); }); it('round-trips an empty string', async () => { const { encrypt, decrypt } = await import('@/lib/utils/encryption'); expect(decrypt(encrypt(''))).toBe(''); }); it('round-trips unicode and emoji', async () => { const { encrypt, decrypt } = await import('@/lib/utils/encryption'); const unicode = 'γεια σου 🚢 日本語'; expect(decrypt(encrypt(unicode))).toBe(unicode); }); it('round-trips a long credential string', async () => { const { encrypt, decrypt } = await import('@/lib/utils/encryption'); const longCred = 'smtp_password=' + 'x'.repeat(256); expect(decrypt(encrypt(longCred))).toBe(longCred); }); }); describe('AES-256-GCM — key validation', () => { it('throws when EMAIL_CREDENTIAL_KEY is not set', async () => { const { encrypt } = await import('@/lib/utils/encryption'); const saved = process.env.EMAIL_CREDENTIAL_KEY; delete process.env.EMAIL_CREDENTIAL_KEY; expect(() => encrypt('test')).toThrow('EMAIL_CREDENTIAL_KEY'); process.env.EMAIL_CREDENTIAL_KEY = saved; }); it('throws when EMAIL_CREDENTIAL_KEY is too short', async () => { const { encrypt } = await import('@/lib/utils/encryption'); const saved = process.env.EMAIL_CREDENTIAL_KEY; process.env.EMAIL_CREDENTIAL_KEY = 'tooshort'; expect(() => encrypt('test')).toThrow('EMAIL_CREDENTIAL_KEY'); process.env.EMAIL_CREDENTIAL_KEY = saved; }); it('throws when EMAIL_CREDENTIAL_KEY is too long', async () => { const { encrypt } = await import('@/lib/utils/encryption'); const saved = process.env.EMAIL_CREDENTIAL_KEY; process.env.EMAIL_CREDENTIAL_KEY = 'a'.repeat(65); expect(() => encrypt('test')).toThrow('EMAIL_CREDENTIAL_KEY'); process.env.EMAIL_CREDENTIAL_KEY = saved; }); });