/** * Tiny AES-256-GCM wrapper for storing secrets (Gitea bot PATs, etc.) * at rest in Postgres. Layout: base64( iv(12) || ciphertext || authTag(16) ). * * The key comes from VIBN_SECRETS_KEY. It must be base64 (32 bytes) OR * any string we hash down to 32 bytes. We hash with SHA-256 so both * forms work — rotating just means generating a new env value and * re-provisioning workspaces. */ import { createCipheriv, createDecipheriv, createHash, randomBytes } from 'crypto'; const IV_BYTES = 12; function getKey(): Buffer { const raw = process.env.VIBN_SECRETS_KEY; if (!raw || raw.length < 16) { throw new Error( 'VIBN_SECRETS_KEY env var is required (>=16 chars) to encrypt workspace secrets' ); } // Normalize any input into a 32-byte key via SHA-256. return createHash('sha256').update(raw).digest(); } export function encryptSecret(plain: string): string { const key = getKey(); const iv = randomBytes(IV_BYTES); const cipher = createCipheriv('aes-256-gcm', key, iv); const enc = Buffer.concat([cipher.update(plain, 'utf8'), cipher.final()]); const tag = cipher.getAuthTag(); return Buffer.concat([iv, enc, tag]).toString('base64'); } export function decryptSecret(payload: string): string { const buf = Buffer.from(payload, 'base64'); if (buf.length < IV_BYTES + 16) throw new Error('secret-box: payload too short'); const iv = buf.subarray(0, IV_BYTES); const tag = buf.subarray(buf.length - 16); const ciphertext = buf.subarray(IV_BYTES, buf.length - 16); const key = getKey(); const decipher = createDecipheriv('aes-256-gcm', key, iv); decipher.setAuthTag(tag); const dec = Buffer.concat([decipher.update(ciphertext), decipher.final()]); return dec.toString('utf8'); }