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>
This commit is contained in:
185
tests/unit/security-encryption.test.ts
Normal file
185
tests/unit/security-encryption.test.ts
Normal file
@@ -0,0 +1,185 @@
|
||||
/**
|
||||
* 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;
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user