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.
This commit is contained in:
@@ -0,0 +1,68 @@
|
||||
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)}`;
|
||||
}
|
||||
Reference in New Issue
Block a user