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