// ── Secret-at-rest encryption (AES-256-GCM) ──────────────────────────────── // // Used to encrypt OAuth client secrets before they are written to the DB, so a // database read alone does not yield usable provider credentials. Output format // is `iv:tag:ciphertext`, each part base64. GCM provides authenticated // encryption, so tampering is detected on decrypt. // // The key comes from SECRET_ENC_KEY (any string — it is hashed to 32 bytes). In // development, if unset, we derive a key from JWT_SECRET with a loud warning // (mirrors token.resolveJwtSecret) so local dev works; production must set a // dedicated key so rotating JWT_SECRET does not silently orphan stored secrets. const crypto = require('crypto') require('dotenv').config() const log = require('../utils/logger')('secretbox') const ALGO = 'aes-256-gcm' function resolveKey() { const explicit = process.env.SECRET_ENC_KEY if (explicit) return crypto.createHash('sha256').update(explicit).digest() if (process.env.NODE_ENV === 'production') { throw new Error('SECRET_ENC_KEY must be set in production') } const jwt = process.env.JWT_SECRET || 'dev-insecure-jwt-secret-do-not-use-in-production' log.warn('SECRET_ENC_KEY is not set — deriving an insecure key from JWT_SECRET for development. Set SECRET_ENC_KEY before deploying.') return crypto.createHash('sha256').update(`secretbox:${jwt}`).digest() } const KEY = resolveKey() // Encrypt a UTF-8 string → "iv:tag:ct" (base64 parts). Returns null for empty input. function encrypt(plaintext) { if (plaintext == null || plaintext === '') return null const iv = crypto.randomBytes(12) const cipher = crypto.createCipheriv(ALGO, KEY, iv) const ct = Buffer.concat([cipher.update(String(plaintext), 'utf8'), cipher.final()]) const tag = cipher.getAuthTag() return `${iv.toString('base64')}:${tag.toString('base64')}:${ct.toString('base64')}` } // Decrypt a value produced by encrypt(). Returns null for null/blank input; // throws if the payload is malformed or fails authentication (tampered/wrong key). function decrypt(payload) { if (payload == null || payload === '') return null const parts = String(payload).split(':') if (parts.length !== 3) throw new Error('secretBox: malformed ciphertext') const [iv, tag, ct] = parts.map((p) => Buffer.from(p, 'base64')) const decipher = crypto.createDecipheriv(ALGO, KEY, iv) decipher.setAuthTag(tag) return Buffer.concat([decipher.update(ct), decipher.final()]).toString('utf8') } module.exports = { encrypt, decrypt }