import { createCipheriv, createDecipheriv, createHash, randomBytes, } from 'node:crypto'; const ALGORITHM = 'aes-256-gcm'; const IV_BYTES = 12; function getKey(): Buffer { const secret = process.env.JWT_ACCESS_SECRET; if (!secret) { throw new Error('JWT_ACCESS_SECRET is not set'); } return createHash('sha256').update(secret).digest(); } /** * TOTP secrets must be reversible (unlike passwords/refresh tokens, which * are one-way hashed) so the server can compute the expected code on every * login. AES-256-GCM at rest, keyed off JWT_ACCESS_SECRET, keeps the secret * out of the DB in plaintext without introducing a new secret to manage. */ export function encryptMfaSecret(plaintext: string): string { const iv = randomBytes(IV_BYTES); const cipher = createCipheriv(ALGORITHM, getKey(), iv); const encrypted = Buffer.concat([ cipher.update(plaintext, 'utf8'), cipher.final(), ]); const authTag = cipher.getAuthTag(); return [iv, authTag, encrypted] .map((buffer) => buffer.toString('base64')) .join('.'); } export function decryptMfaSecret(ciphertext: string): string { const [ivB64, authTagB64, dataB64] = ciphertext.split('.'); if (!ivB64 || !authTagB64 || !dataB64) { throw new Error('Invalid MFA secret ciphertext'); } const decipher = createDecipheriv( ALGORITHM, getKey(), Buffer.from(ivB64, 'base64'), ); decipher.setAuthTag(Buffer.from(authTagB64, 'base64')); return Buffer.concat([ decipher.update(Buffer.from(dataB64, 'base64')), decipher.final(), ]).toString('utf8'); }