/** * AES-256-GCM encryption helpers for Fastmail app-password storage. * * Security requirements (T-03-01, T-03-03, ASVS V6): * - Key from APP_PASSWORD_ENCRYPTION_KEY env (64-char hex = 32 bytes) * - 96-bit (12-byte) random IV per encryption — never reuse IV * - GCM auth tag verifies integrity on decrypt; tampered ciphertext throws * - Stored payload: JSON { iv, authTag, ciphertext } (all hex) * - Never log plaintext or the encryption key * * Source: Node.js docs node:crypto — AES-GCM */ import { randomBytes, createCipheriv, createDecipheriv } from 'node:crypto' // Validated at module load: key must be present and 32 bytes (64 hex chars). // We deliberately do NOT throw here on missing key — that would crash the module // during tests that set the env before the first import. The KEY is only read // when encryptPassword / decryptPassword are actually called. function getKey(): Buffer { const hex = process.env.APP_PASSWORD_ENCRYPTION_KEY if (!hex || hex.length !== 64) { throw new Error( 'APP_PASSWORD_ENCRYPTION_KEY must be a 64-character hex string (32 bytes). ' + 'Generate with: node -e "console.log(require(\'crypto\').randomBytes(32).toString(\'hex\'))"', ) } return Buffer.from(hex, 'hex') } interface EncryptedPayload { iv: string authTag: string ciphertext: string } /** * Encrypts a plaintext string using AES-256-GCM. * Returns a JSON string with { iv, authTag, ciphertext } — all hex-encoded. * Each call generates a fresh random 96-bit IV. */ export function encryptPassword(plaintext: string): string { const key = getKey() const iv = randomBytes(12) // 96-bit IV for GCM const cipher = createCipheriv('aes-256-gcm', key, iv) const encrypted = Buffer.concat([cipher.update(plaintext, 'utf8'), cipher.final()]) const authTag = cipher.getAuthTag() const payload: EncryptedPayload = { iv: iv.toString('hex'), authTag: authTag.toString('hex'), ciphertext: encrypted.toString('hex'), } return JSON.stringify(payload) } /** * Decrypts an AES-256-GCM encrypted payload produced by encryptPassword. * Throws if the auth tag does not match (integrity violation). */ export function decryptPassword(stored: string): string { const key = getKey() const { iv, authTag, ciphertext } = JSON.parse(stored) as EncryptedPayload const decipher = createDecipheriv('aes-256-gcm', key, Buffer.from(iv, 'hex')) decipher.setAuthTag(Buffer.from(authTag, 'hex')) return Buffer.concat([ decipher.update(Buffer.from(ciphertext, 'hex')), decipher.final(), ]).toString('utf8') }