Files
pn-new-crm/tests/unit/security-encryption.test.ts
Matt 67d7e6e3d5
Some checks failed
Build & Push Docker Images / build-and-push (push) Has been cancelled
Build & Push Docker Images / deploy (push) Has been cancelled
Build & Push Docker Images / lint (push) Has been cancelled
Initial commit: Port Nimara CRM (Layers 0-4)
Full CRM rebuild with Next.js 15, TypeScript, Tailwind, Drizzle ORM,
PostgreSQL, Redis, BullMQ, MinIO, and Socket.io. Includes 461 source
files covering clients, berths, interests/pipeline, documents/EOI,
expenses/invoices, email, notifications, dashboard, admin, and
client portal. CI/CD via Gitea Actions with Docker builds.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 11:52:51 +01:00

186 lines
7.7 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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');
const plaintext = 'ab';
const encrypted = encrypt(plaintext);
// The JSON output contains hex-encoded bytes — plaintext chars must not appear raw
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<string, unknown>;
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;
});
});