import { createCipheriv, createDecipheriv, randomBytes } from 'node:crypto'; import { chmodSync, existsSync, readFileSync, writeFileSync } from 'node:fs'; import { join } from 'node:path'; import { DATA_DIR, projectEnv } from './env'; /** * At-rest encryption for provider API keys. * * The keys are the one genuinely dangerous thing Atelier stores. Everything else in the database is a * prompt or a timestamp; a leaked provider key is someone else's bill. So they are encrypted in the * column rather than sitting in plaintext next to them, and the encryption key lives outside Postgres * — a database dump on its own decrypts nothing. * * AES-256-GCM: authenticated, so a tampered ciphertext fails to decrypt rather than yielding garbage * that gets sent to a provider as a credential. */ const KEY_FILE = join(DATA_DIR, 'secret.key'); /** * Generated on first use rather than demanded as configuration. * * Requiring ATELIER_SECRET_KEY would mean the server refuses to start until someone generates 32 random * bytes by hand, and the predictable outcome of that is a memorable passphrase padded to length. A * generated key in a 0600 file beside the data is stronger than the one a human would have typed. */ function loadKey(): Buffer { const configured = projectEnv('ATELIER_SECRET_KEY'); if (configured) { const key = Buffer.from(configured, 'base64'); if (key.length !== 32) throw new Error('ATELIER_SECRET_KEY must be 32 bytes, base64-encoded.'); return key; } if (existsSync(KEY_FILE)) return Buffer.from(readFileSync(KEY_FILE, 'utf-8').trim(), 'base64'); const key = randomBytes(32); writeFileSync(KEY_FILE, key.toString('base64'), { mode: 0o600 }); chmodSync(KEY_FILE, 0o600); console.log(`[crypto] generated a new encryption key at ${KEY_FILE}`); return key; } let cached: Buffer | undefined; const key = () => (cached ??= loadKey()); /** `iv:tag:ciphertext`, all base64. Self-describing, so nothing has to remember a layout. */ export function encrypt(plaintext: string): string { const iv = randomBytes(12); const cipher = createCipheriv('aes-256-gcm', key(), iv); const encrypted = Buffer.concat([cipher.update(plaintext, 'utf-8'), cipher.final()]); return [iv.toString('base64'), cipher.getAuthTag().toString('base64'), encrypted.toString('base64')].join(':'); } export function decrypt(payload: string): string { const [iv, tag, data] = payload.split(':'); if (!iv || !tag || !data) throw new Error('Malformed ciphertext'); const decipher = createDecipheriv('aes-256-gcm', key(), Buffer.from(iv, 'base64')); decipher.setAuthTag(Buffer.from(tag, 'base64')); return Buffer.concat([decipher.update(Buffer.from(data, 'base64')), decipher.final()]).toString('utf-8'); } /** `sk-abc…wxyz` — enough to recognise which key is set, not enough to use it. */ export function maskKey(plaintext: string): string { if (plaintext.length <= 11) return '•'.repeat(plaintext.length); return `${plaintext.slice(0, 6)}…${plaintext.slice(-4)}`; }