Files
pn-new-crm/tests/unit/security-encryption.test.ts
Matt Ciaccio 31fa3d08ec 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

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;
});
});