/** * Broker: AES-GCM app-password encryption * * Tests the encryptPassword / decryptPassword helpers in src/broker/crypto.ts. * Key behaviors verified (T-03-01, T-03-03): * - Lossless roundtrip * - Unique IVs per encryption (never reuse IV) * - GCM auth tag tamper detection * - Stored payload shape */ import { describe, it, expect, beforeAll } from 'vitest' // Set a fixed 32-byte (64-char hex) key before importing the module const TEST_KEY = 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2' beforeAll(() => { process.env.APP_PASSWORD_ENCRYPTION_KEY = TEST_KEY }) // Dynamic import so env is set before module-level KEY evaluation async function getCrypto() { return import('../../src/broker/crypto.js') } describe('encryptPassword / decryptPassword', () => { it('roundtrip: decrypt(encrypt(plaintext)) === plaintext', async () => { const { encryptPassword, decryptPassword } = await getCrypto() const plaintext = 'my-fastmail-app-password-abc123' const encrypted = encryptPassword(plaintext) expect(decryptPassword(encrypted)).toBe(plaintext) }) it('different IVs produce different ciphertext for the same plaintext', async () => { const { encryptPassword } = await getCrypto() const plaintext = 'same-password' const enc1 = encryptPassword(plaintext) const enc2 = encryptPassword(plaintext) // The JSON payloads must differ (different IVs → different ciphertext) expect(enc1).not.toBe(enc2) // And the IVs themselves must differ const p1 = JSON.parse(enc1) const p2 = JSON.parse(enc2) expect(p1.iv).not.toBe(p2.iv) }) it('decrypting with a tampered authTag throws', async () => { const { encryptPassword, decryptPassword } = await getCrypto() const encrypted = encryptPassword('secret') const payload = JSON.parse(encrypted) // Flip first byte of authTag payload.authTag = ('ff' + payload.authTag.slice(2)) expect(() => decryptPassword(JSON.stringify(payload))).toThrow() }) it('decrypting with a tampered ciphertext throws', async () => { const { encryptPassword, decryptPassword } = await getCrypto() const encrypted = encryptPassword('secret') const payload = JSON.parse(encrypted) // Flip first byte of ciphertext payload.ciphertext = ('ff' + payload.ciphertext.slice(2)) expect(() => decryptPassword(JSON.stringify(payload))).toThrow() }) it('stored payload is valid JSON with iv, authTag, ciphertext fields', async () => { const { encryptPassword } = await getCrypto() const encrypted = encryptPassword('test-password') const payload = JSON.parse(encrypted) expect(payload).toHaveProperty('iv') expect(payload).toHaveProperty('authTag') expect(payload).toHaveProperty('ciphertext') // All values are non-empty hex strings expect(typeof payload.iv).toBe('string') expect(payload.iv.length).toBeGreaterThan(0) expect(typeof payload.authTag).toBe('string') expect(payload.authTag.length).toBeGreaterThan(0) expect(typeof payload.ciphertext).toBe('string') expect(payload.ciphertext.length).toBeGreaterThan(0) }) })