import { createCipheriv, createDecipheriv, createHash, randomBytes } from 'node:crypto'; const ALGORITHM = 'aes-256-gcm'; const IV_LENGTH = 12; // 96-bit IV for GCM const TAG_LENGTH = 16; // 128-bit auth tag /** * Derive a 32-byte AES-256 key from BETTER_AUTH_SECRET using SHA-256. * Throws if BETTER_AUTH_SECRET is not set. */ function deriveKey(): Buffer { const secret = process.env['BETTER_AUTH_SECRET']; if (!secret) { throw new Error('BETTER_AUTH_SECRET is not set — cannot derive encryption key'); } return createHash('sha256').update(secret).digest(); } /** * Seal a plaintext string using AES-256-GCM. * Output format: base64(IV || authTag || ciphertext) */ export function seal(plaintext: string): string { const key = deriveKey(); const iv = randomBytes(IV_LENGTH); const cipher = createCipheriv(ALGORITHM, key, iv); const encrypted = Buffer.concat([cipher.update(plaintext, 'utf8'), cipher.final()]); const authTag = cipher.getAuthTag(); const combined = Buffer.concat([iv, authTag, encrypted]); return combined.toString('base64'); } /** * Unseal a value sealed by `seal()`. * Throws on authentication failure (tampered data) or if BETTER_AUTH_SECRET is unset. */ export function unseal(encoded: string): string { const key = deriveKey(); const combined = Buffer.from(encoded, 'base64'); const iv = combined.subarray(0, IV_LENGTH); const authTag = combined.subarray(IV_LENGTH, IV_LENGTH + TAG_LENGTH); const ciphertext = combined.subarray(IV_LENGTH + TAG_LENGTH); const decipher = createDecipheriv(ALGORITHM, key, iv); decipher.setAuthTag(authTag); const decrypted = Buffer.concat([decipher.update(ciphertext), decipher.final()]); return decrypted.toString('utf8'); }