Files
vibn-agent-runner/vibn-frontend/lib/auth/secret-box.ts

47 lines
1.7 KiB
TypeScript

/**
* 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');
}