Files
pn-new-crm/tests/unit/security-encryption.test.ts

189 lines
7.9 KiB
TypeScript
Raw Normal View History

/**
* 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');
chore(cleanup): Phase 1 — gap closure across audit, alerts, soft-delete, perms Multi-area cleanup pass closing partial-implementation gaps surfaced by the post-i18n audit. No behavior changes for happy-path users; closes real correctness/security holes. PR1a Public yacht-interest endpoint i18n. /api/public/interests now accepts phoneE164/phoneCountry, nationalityIso, address.{countryIso, subdivisionIso}, and company.{incorporationCountryIso, incorporationSubdivisionIso}. Server-side parsePhone() fallback for legacy raw phone strings. PR1b Alert rule registry trim. Two rule slots ('document.expiring_soon', 'audit.suspicious_login') were registered but evaluators returned []. Both required schema/instrumentation that hadn't landed. Removed from the registry; comments record the dependencies needed to revive them. Effective rule count: 8 active. PR1c vi.mock hoist + flake fix. Hoisted vi.mock calls to top-level in 5 integration test files; webhook-delivery uses vi.hoisted for the queue-add ref. Vitest no longer warns about non-top-level mocks. Deflaked the 'short value' assertion in security-encryption.test.ts by switching plaintext from 'ab' to 'XY' (non-hex chars). 5/5 runs green. PR1d Soft-delete reference audit. listClientOptions and listYachtsForOwner now filter by isNull(archivedAt). Berths use status (no archivedAt). PR1e Permission-matrix audit script + report. scripts/audit-permissions.ts walks every src/app/api/v1/**/route.ts and reports handlers without a withPermission() wrapper. Initial run found 33 violations. - Allow-listed 17 with explicit reasons (self-data, admin, alerts, search, currency, ai, custom-fields — some marked TODO). - Wrapped 7 routes with concrete permissions: clients/options (clients:view), berths/options (berths:view), dashboard/* (reports:view_dashboard), analytics (reports:view_analytics). Audit report at docs/runbooks/permission-audit.md. Script exits non-zero on any unallow-listed violation so it can become a CI gate. Vitest: 741 -> 741 (no new tests; existing suite covers the changes). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 18:48:22 +02:00
// 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;
});
});