Files
familysync/apps/api/tests/broker/crypto.test.ts
T
Lucas Berger 04d7c23e94 test(01-03): add failing tests for AES-256-GCM crypto helpers
- 5 tests covering roundtrip, IV uniqueness, tamper detection, payload shape
- Dynamic import with env key set in beforeAll for module-level KEY eval
- RED gate: all fail (src/broker/crypto.ts does not exist yet)
2026-06-04 10:26:59 -04:00

81 lines
3.1 KiB
TypeScript

/**
* 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)
})
})