Files
atelier/sidecar/crypto.ts
T
pastilhas f23ec1283b manifest, thin proxy router, and the sidecar backend
ported from the standalone officer-atelier service: providers (adapter-shaped, openai the only
adapter today), the generation queue, collections, and the database layer — kept on its own
Postgres database rather than folded into officer_db, since both collections and provider_settings
had rows on the machine this was extracted from.

what changed rather than just moved:
- images are served from an authenticated GET /files/:filename instead of an unauthenticated
  PUBLIC_URL-prefixed static route
- the OpenAI-key bootstrap fallback reads the secret store (getSecret) before .env, per-adapter
- provider_settings (the one-row-per-adapter shape providers replaced) did not come along — it has
  no callers left anywhere in the server code, only a migration script that also did not move

ping/echo (smoke-test routes with no caller) did not move either; the sidecar's own /_health covers
liveness.

not yet wired into the platform: sidecar/index.ts exists but nothing mounts it, and web/ has not
been written, so this is not yet a discoverable plugin.
2026-08-20 03:53:53 +00:00

69 lines
3.0 KiB
TypeScript

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)}`;
}