import crypto from "crypto" // AES-256-GCM encryption for secrets at rest (OAuth tokens). The key is derived // from ACCOUNTING_ENCRYPTION_KEY, falling back to BETTER_AUTH_SECRET, via SHA-256 // so no additional configuration is required. let warnedAboutFallbackKey = false function getKey(): Buffer { const dedicated = process.env.ACCOUNTING_ENCRYPTION_KEY const secret = dedicated || process.env.BETTER_AUTH_SECRET if (!secret) throw new Error("No encryption key configured (BETTER_AUTH_SECRET)") // Riding on BETTER_AUTH_SECRET works, but couples two independent rotation // schedules: rotating the auth secret would silently make every stored OAuth // token undecryptable, with no migration path and no error until a user's // next accounting sync fails. Warn once so this is caught before that // happens rather than after. if (!dedicated && !warnedAboutFallbackKey) { warnedAboutFallbackKey = true console.warn( "[crypto] ACCOUNTING_ENCRYPTION_KEY is not set — deriving the at-rest key " + "from BETTER_AUTH_SECRET. Rotating BETTER_AUTH_SECRET will make all " + "stored OAuth tokens undecryptable. Set a dedicated key in production." ) } return crypto.createHash("sha256").update(secret).digest() } /** Encrypt a UTF-8 string → "iv:tag:ciphertext" (all base64). */ export function encrypt(plaintext: string): string { const iv = crypto.randomBytes(12) const cipher = crypto.createCipheriv("aes-256-gcm", getKey(), iv) const enc = Buffer.concat([cipher.update(plaintext, "utf8"), cipher.final()]) const tag = cipher.getAuthTag() return [iv.toString("base64"), tag.toString("base64"), enc.toString("base64")].join(":") } /** Decrypt a value produced by encrypt(). */ export function decrypt(payload: string): string { const [ivB64, tagB64, dataB64] = payload.split(":") const decipher = crypto.createDecipheriv("aes-256-gcm", getKey(), Buffer.from(ivB64, "base64")) decipher.setAuthTag(Buffer.from(tagB64, "base64")) return Buffer.concat([decipher.update(Buffer.from(dataB64, "base64")), decipher.final()]).toString("utf8") }