Files
pn-new-crm/tests/unit/security-encryption.test.ts
Matt 221ae5784e chore(autonomous-session): consolidate uncommitted work from prior session
Bundles the prior autonomous-session output that was sitting unstaged:

- Em-dash sweep across src/ + tests/ (en-dash/em-dash to hyphen, ~2280 instances)
- country-flag-icons rollout (CountryFlag component, replaces emoji glyphs that
  never rendered on Windows; lazy-loads the 3x2 SVG index as a single chunk
  after the per-subpath dynamic-import approach silently failed in webpack)
- Admin IA Phase 1+2: 7-domain regroup, 41 to 38 pages, /admin/berths index,
  redirects (ocr to ai, reports to dashboard, invitations to users),
  docs/admin-ia-proposal.md
- Per-template email tester (registry + endpoint + UI on Email admin page)
- Cancel-document mode picker (delete-from-Documenso vs keep-for-audit)
- Dashboard PDF report: 25 widgets, SVG charts, date-range picker, 11 resolvers
- Customize-widgets per-region sortables at xl+ (charts/rails/feed); single
  flat sortable below xl when the layout stacks; per-viewport saved orders
- Audit doc updates capturing each shipped item
- Lint fixes: react-compiler immutability in DonutChart (reduce instead of
  let-reassign), set-state-in-effect disables in CountryFlag and
  UploadForSigning preview-bytes effect, unused 'confirm' destructures in
  interest contract + reservation tabs, unescaped apostrophe in test-template
  card copy
2026-05-23 00:52:59 +02:00

189 lines
7.9 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');
// 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<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;
});
});