From d6d91201b194e057594d4929e591c2d848636df0 Mon Sep 17 00:00:00 2001 From: Lucas Berger Date: Thu, 4 Jun 2026 10:27:40 -0400 Subject: [PATCH] feat(01-03): implement AES-256-GCM app-password encryption (T-03-01) - encryptPassword: randomBytes(12) IV, aes-256-gcm, returns JSON {iv,authTag,ciphertext} - decryptPassword: verifies GCM auth tag; throws on tamper - Key from APP_PASSWORD_ENCRYPTION_KEY env (64-char hex); validated on each call - No logging of plaintext or key --- apps/api/src/broker/crypto.ts | 69 +++++++++++++++++++++++++++++++++++ 1 file changed, 69 insertions(+) create mode 100644 apps/api/src/broker/crypto.ts diff --git a/apps/api/src/broker/crypto.ts b/apps/api/src/broker/crypto.ts new file mode 100644 index 0000000..11e4244 --- /dev/null +++ b/apps/api/src/broker/crypto.ts @@ -0,0 +1,69 @@ +/** + * 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') +}