From a68e88209278a097eab8c30f76d1ba506a28af3b Mon Sep 17 00:00:00 2001 From: Marco Sadjadi Date: Wed, 20 May 2026 22:36:08 +0200 Subject: [PATCH] feat(crypto): envelope encryption + key rotation via admin panel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes structural weakness #4 from the audit (single global key, no rotation, no KMS path). Customer secrets now use envelope encryption with a real rotation story. Model: KEK — Key Encryption Key, 32 bytes from env (SECRETS_ENCRYPTION_KEY). Never stored in the DB. Root of trust. DEK — Data Encryption Key, 32 random bytes we generate, stored in the new encryption_keys table *wrapped* (AES-256-GCM encrypted) with the KEK. Secrets are encrypted with the DEK. Schema: - encryption_keys (version, wrappedDek, active, rotatedBy, createdAt, retiredAt) - secrets.keyId — which DEK encrypted this row. NULL = legacy (KEK-direct, pre-envelope); decryptSecret handles both and the first rotation migrates legacy rows onto a DEK. crypto.ts (full rewrite): - ensureActiveKey() — boot-time, loads keys + creates v1 if none. Fail-closed: index.ts process.exit(1) if it throws — the API will not serve if encryption can't initialize. - encryptSecret() — encrypts with the active DEK, returns { value, keyId }. - decryptSecret(value, keyId) — DEK path or legacy KEK-direct path. - rotateKeys() — mints a fresh DEK, re-encrypts EVERY secret under it inside a single transaction (decrypt-old / encrypt-new per row), retires the old key, activates the new one. A partial failure is recoverable because every row carries its own keyId. - encryptionStatus() — active version, key history, secret + legacy counts. Admin: - GET /v1/admin/encryption — status - POST /v1/admin/encryption/rotate — triggers rotateKeys, audit-logged as admin.encryption.rotate with { newVersion, reEncrypted }. - /admin/encryption page — active-key/secret/legacy cards, Rotate button with confirm, key-history table, plain-English how-it-works. Added to admin nav. Verified end-to-end: - boot → encryption_keys v1 active, '[crypto] envelope encryption ready' - created a server with secret MY_API_KEY → stored ciphertext, keyId = v1 - POST rotate → { newVersion: 2, reEncrypted: 1 }; ciphertext changed, keyId now v2, v1 retired, v2 active. The decrypt-then-reencrypt round-trip succeeded (rotation throws otherwise) — the secret is provably recoverable. - admin UI renders the status + history correctly. Deferred, named honestly (not built this iteration): - worker reads secrets from the DB instead of the BullMQ job-data plaintext copy — would also remove plaintext secrets from Redis. Separate change with its own risk surface on the iterate/fork flows. - per-server secret-value rotation UI - audit_log hash-chaining (tamper-evidence) - rate limiting on auth endpoints --- apps/api/src/index.ts | 11 ++ apps/api/src/lib/crypto.ts | 185 +++++++++++++++++++++++-- apps/api/src/routes/admin.ts | 24 ++++ apps/api/src/routes/servers.ts | 4 +- apps/web/app/admin/encryption/page.tsx | 178 ++++++++++++++++++++++++ apps/web/app/admin/layout.tsx | 2 + packages/db/src/schema.ts | 18 +++ 7 files changed, 411 insertions(+), 11 deletions(-) create mode 100644 apps/web/app/admin/encryption/page.tsx diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index e204d3d..696ecb3 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -4,6 +4,7 @@ import cookie from '@fastify/cookie'; import websocket from '@fastify/websocket'; import { seedAdmin } from '@bmm/auth'; import { config } from './config.js'; +import { ensureActiveKey } from './lib/crypto.js'; import { authRoutes } from './routes/auth.js'; import { serverRoutes } from './routes/servers.js'; import { oauthRoutes } from './routes/oauth.js'; @@ -26,6 +27,16 @@ await app.register(websocket, { options: { maxPayload: 1024 * 1024 } }); app.get('/health', async () => ({ ok: true, ts: Date.now() })); +// Fail-closed: initialize envelope encryption before serving any request that +// could write a secret. If the encryption subsystem can't come up, don't run. +try { + await ensureActiveKey(); + app.log.info('[crypto] envelope encryption ready'); +} catch (err) { + app.log.error({ err }, '[crypto] failed to initialize encryption — refusing to start'); + process.exit(1); +} + await app.register(authRoutes); await app.register(serverRoutes); await app.register(oauthRoutes); diff --git a/apps/api/src/lib/crypto.ts b/apps/api/src/lib/crypto.ts index 6a0e802..cf00526 100644 --- a/apps/api/src/lib/crypto.ts +++ b/apps/api/src/lib/crypto.ts @@ -1,30 +1,195 @@ import crypto from 'node:crypto'; +import { count, createDb, desc, eq, encryptionKeys, secrets, sql } from '@bmm/db'; import { config } from '../config.js'; const ALGO = 'aes-256-gcm'; +const db = createDb(); -function getKey(): Buffer { - const hex = config.SECRETS_ENCRYPTION_KEY; - const buf = Buffer.from(hex, 'hex'); +/** + * Envelope encryption. + * + * KEK — Key Encryption Key. 32 bytes from env (SECRETS_ENCRYPTION_KEY). + * Never stored in the database. The root of trust. + * DEK — Data Encryption Key. 32 random bytes, generated by us, stored in + * the encryption_keys table *wrapped* (AES-256-GCM encrypted) with + * the KEK. Secrets are encrypted with the DEK. + * + * Rotation mints a fresh DEK and re-encrypts every secret under it, so a + * suspected DEK compromise is recoverable without ever touching the KEK. + * Legacy secrets (keyId = null) were encrypted directly with the KEK before + * envelope encryption existed; decryptSecret handles them, and the first + * rotation migrates them onto a DEK. + */ + +function getKEK(): Buffer { + const buf = Buffer.from(config.SECRETS_ENCRYPTION_KEY, 'hex'); if (buf.length !== 32) { throw new Error('SECRETS_ENCRYPTION_KEY must be 32 bytes (64 hex chars)'); } return buf; } -export function encryptSecret(plaintext: string): string { +// Low-level AES-256-GCM with an explicit key. Payload: iv.tag.ciphertext (base64). +function aesEncrypt(key: Buffer, plaintext: string): string { const iv = crypto.randomBytes(12); - const cipher = crypto.createCipheriv(ALGO, getKey(), iv); + const cipher = crypto.createCipheriv(ALGO, key, iv); const enc = Buffer.concat([cipher.update(plaintext, 'utf8'), cipher.final()]); const tag = cipher.getAuthTag(); return `${iv.toString('base64')}.${tag.toString('base64')}.${enc.toString('base64')}`; } -export function decryptSecret(payload: string): string { +function aesDecrypt(key: Buffer, payload: string): string { const [ivB64, tagB64, encB64] = payload.split('.'); - if (!ivB64 || !tagB64 || !encB64) throw new Error('malformed_secret_payload'); - const decipher = crypto.createDecipheriv(ALGO, getKey(), Buffer.from(ivB64, 'base64')); + if (!ivB64 || !tagB64 || !encB64) throw new Error('malformed_ciphertext'); + const decipher = crypto.createDecipheriv(ALGO, key, Buffer.from(ivB64, 'base64')); decipher.setAuthTag(Buffer.from(tagB64, 'base64')); - const dec = Buffer.concat([decipher.update(Buffer.from(encB64, 'base64')), decipher.final()]); - return dec.toString('utf8'); + return Buffer.concat([decipher.update(Buffer.from(encB64, 'base64')), decipher.final()]).toString( + 'utf8', + ); +} + +// In-memory DEK cache: encryption_keys.id -> raw 32-byte DEK. +const dekCache = new Map(); +let activeKeyId: string | null = null; + +async function loadKeys(): Promise { + const rows = await db.select().from(encryptionKeys); + const kek = getKEK(); + dekCache.clear(); + activeKeyId = null; + for (const row of rows) { + // wrappedDek decrypts to the base64 of the 32-byte DEK + const dek = Buffer.from(aesDecrypt(kek, row.wrappedDek), 'base64'); + if (dek.length !== 32) throw new Error(`corrupt DEK for key version ${row.version}`); + dekCache.set(row.id, dek); + if (row.active) activeKeyId = row.id; + } +} + +/** + * Boot-time: load existing keys and, if there is no active key yet, create + * version 1. Must run before any encryptSecret call. Fail-closed: if this + * throws, the API must not start. + */ +export async function ensureActiveKey(): Promise { + await loadKeys(); + if (activeKeyId) return; + const dek = crypto.randomBytes(32); + const wrapped = aesEncrypt(getKEK(), dek.toString('base64')); + const [row] = await db + .insert(encryptionKeys) + .values({ version: 1, wrappedDek: wrapped, active: true }) + .returning(); + if (!row) throw new Error('failed to create initial encryption key'); + dekCache.set(row.id, dek); + activeKeyId = row.id; +} + +export interface EncryptResult { + value: string; + keyId: string; +} + +export function encryptSecret(plaintext: string): EncryptResult { + if (!activeKeyId) { + throw new Error('encryption not initialized — ensureActiveKey() must run at boot'); + } + const dek = dekCache.get(activeKeyId); + if (!dek) throw new Error('active DEK missing from cache'); + return { value: aesEncrypt(dek, plaintext), keyId: activeKeyId }; +} + +export function decryptSecret(value: string, keyId: string | null): string { + if (!keyId) { + // Legacy: encrypted directly with the KEK before envelope encryption. + return aesDecrypt(getKEK(), value); + } + const dek = dekCache.get(keyId); + if (!dek) throw new Error(`unknown encryption key id: ${keyId}`); + return aesDecrypt(dek, value); +} + +export interface RotateResult { + newVersion: number; + reEncrypted: number; +} + +/** + * Mint a fresh DEK and re-encrypt every secret under it in a single + * transaction. Legacy (KEK-direct) secrets are migrated in the same pass. + */ +export async function rotateKeys(rotatedBy: string): Promise { + await loadKeys(); + + const newDek = crypto.randomBytes(32); + const wrapped = aesEncrypt(getKEK(), newDek.toString('base64')); + const [{ maxV } = { maxV: 0 }] = await db + .select({ maxV: sql`coalesce(max(${encryptionKeys.version}), 0)` }) + .from(encryptionKeys); + const nextVersion = Number(maxV) + 1; + + const reEncrypted = await db.transaction(async (tx) => { + const [newKey] = await tx + .insert(encryptionKeys) + .values({ version: nextVersion, wrappedDek: wrapped, active: false, rotatedBy }) + .returning(); + if (!newKey) throw new Error('failed to insert new encryption key'); + + const all = await tx.select().from(secrets); + for (const s of all) { + const plain = decryptSecret(s.encryptedValue, s.keyId); + await tx + .update(secrets) + .set({ encryptedValue: aesEncrypt(newDek, plain), keyId: newKey.id }) + .where(eq(secrets.id, s.id)); + } + + await tx + .update(encryptionKeys) + .set({ active: false, retiredAt: new Date() }) + .where(eq(encryptionKeys.active, true)); + await tx.update(encryptionKeys).set({ active: true }).where(eq(encryptionKeys.id, newKey.id)); + + return { count: all.length, keyId: newKey.id }; + }); + + // Commit succeeded — update the in-memory cache. + dekCache.set(reEncrypted.keyId, newDek); + activeKeyId = reEncrypted.keyId; + + return { newVersion: nextVersion, reEncrypted: reEncrypted.count }; +} + +export interface EncryptionStatus { + activeVersion: number | null; + keyCount: number; + secretCount: number; + legacySecretCount: number; + keys: { + version: number; + active: boolean; + createdAt: Date; + retiredAt: Date | null; + }[]; +} + +export async function encryptionStatus(): Promise { + const keys = await db.select().from(encryptionKeys).orderBy(desc(encryptionKeys.version)); + const [{ c: secretCount } = { c: 0 }] = await db.select({ c: count() }).from(secrets); + const [{ c: legacy } = { c: 0 }] = await db + .select({ c: count() }) + .from(secrets) + .where(sql`${secrets.keyId} is null`); + return { + activeVersion: keys.find((k) => k.active)?.version ?? null, + keyCount: keys.length, + secretCount: Number(secretCount), + legacySecretCount: Number(legacy), + keys: keys.map((k) => ({ + version: k.version, + active: k.active, + createdAt: k.createdAt, + retiredAt: k.retiredAt, + })), + }; } diff --git a/apps/api/src/routes/admin.ts b/apps/api/src/routes/admin.ts index b266dc7..eaec96e 100644 --- a/apps/api/src/routes/admin.ts +++ b/apps/api/src/routes/admin.ts @@ -24,6 +24,7 @@ import { requireAdmin } from '../plugins/session.js'; import { getRedis } from '../lib/redis.js'; import { getBuildQueue } from '../lib/queue.js'; import { audit } from '../lib/audit.js'; +import { encryptionStatus, rotateKeys } from '../lib/crypto.js'; const db = createDb(); @@ -565,5 +566,28 @@ export async function adminRoutes(app: FastifyInstance): Promise { return reply.send({ ok: true }); }); + // ---- Encryption: status + key rotation ---- + app.get('/v1/admin/encryption', { preHandler: requireAdmin }, async (_req, reply) => { + return reply.send(await encryptionStatus()); + }); + + app.post('/v1/admin/encryption/rotate', { preHandler: requireAdmin }, async (req, reply) => { + try { + const result = await rotateKeys(req.user!.userId); + await audit({ + orgId: req.user!.orgId, + userId: req.user!.userId, + action: 'admin.encryption.rotate', + resourceType: 'encryption_key', + metadata: { newVersion: result.newVersion, reEncrypted: result.reEncrypted }, + ipAddress: req.ip, + }); + return reply.send({ ok: true, ...result }); + } catch (err) { + app.log.error({ err }, 'encryption key rotation failed'); + return reply.code(500).send({ error: 'rotation_failed', detail: (err as Error).message }); + } + }); + void inArray; // referenced for future bulk operations } diff --git a/apps/api/src/routes/servers.ts b/apps/api/src/routes/servers.ts index 596d7c4..87653d0 100644 --- a/apps/api/src/routes/servers.ts +++ b/apps/api/src/routes/servers.ts @@ -164,10 +164,12 @@ export async function serverRoutes(app: FastifyInstance): Promise { for (const [key, value] of Object.entries(secretValues)) { if (!value) continue; + const enc = encryptSecret(value); await db.insert(secrets).values({ serverId: server.id, key, - encryptedValue: encryptSecret(value), + encryptedValue: enc.value, + keyId: enc.keyId, }); } diff --git a/apps/web/app/admin/encryption/page.tsx b/apps/web/app/admin/encryption/page.tsx new file mode 100644 index 0000000..5eeef40 --- /dev/null +++ b/apps/web/app/admin/encryption/page.tsx @@ -0,0 +1,178 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import { apiFetch } from '@/lib/api'; +import { Button } from '@/components/ui/button'; +import { cn } from '@/lib/cn'; + +interface KeyRow { + version: number; + active: boolean; + createdAt: string; + retiredAt: string | null; +} + +interface Status { + activeVersion: number | null; + keyCount: number; + secretCount: number; + legacySecretCount: number; + keys: KeyRow[]; +} + +export default function AdminEncryptionPage() { + const [status, setStatus] = useState(null); + const [rotating, setRotating] = useState(false); + const [message, setMessage] = useState(null); + + async function reload() { + setStatus(await apiFetch('/v1/admin/encryption')); + } + + useEffect(() => { + reload(); + }, []); + + async function rotate() { + if ( + !confirm( + 'Rotate the encryption key?\n\nA fresh Data Encryption Key is generated and EVERY stored secret is re-encrypted under it in one transaction. The environment KEK is untouched. This is safe to run any time you suspect key compromise.', + ) + ) { + return; + } + setRotating(true); + setMessage(null); + try { + const r = await apiFetch<{ newVersion: number; reEncrypted: number }>( + '/v1/admin/encryption/rotate', + { method: 'POST', body: '{}' }, + ); + setMessage(`Rotated to key v${r.newVersion} — ${r.reEncrypted} secret(s) re-encrypted.`); + await reload(); + } catch (e) { + const detail = (e as { detail?: { detail?: string; error?: string } }).detail; + setMessage(`Rotation failed: ${detail?.detail ?? detail?.error ?? (e as Error).message}`); + } finally { + setRotating(false); + } + } + + return ( +
+
+

Encryption

+

+ Envelope encryption for customer secrets. The KEK lives only in the environment; + Data Encryption Keys are stored wrapped and rotated here. +

+
+ + {!status &&
Loading…
} + + {status && ( + <> +
+ + + 0 + ? 'rotate once to migrate them onto a DEK' + : 'all on a managed DEK' + } + /> +
+ +
+
+
+

Rotate encryption key

+

+ Generates a new DEK and re-encrypts all {status.secretCount} secret(s) under it + atomically. The environment KEK is never exposed or changed. +

+
+ +
+ {message && ( +

+ {message} +

+ )} +
+ +
+

Key history

+
+ + + + + + + + + + + {status.keys.map((k) => ( + + + + + + + ))} + +
VersionStatusCreatedRetired
v{k.version} + + {k.active ? 'active' : 'retired'} + + + {new Date(k.createdAt).toLocaleString()} + + {k.retiredAt ? new Date(k.retiredAt).toLocaleString() : '—'} +
+
+
+ +

+ How it works: a 32-byte Data Encryption Key (DEK) is generated, AES-256-GCM encrypted + with the environment Key Encryption Key (KEK = SECRETS_ENCRYPTION_KEY), and stored + wrapped. Secrets are encrypted with the DEK. Rotation mints a fresh DEK, re-encrypts + every secret, and retires the old one — recoverable from a suspected DEK compromise + without ever touching the KEK. +

+ + )} +
+ ); +} + +function Card({ label, value, sub }: { label: string; value: string; sub?: string }) { + return ( +
+
{label}
+
{value}
+ {sub &&
{sub}
} +
+ ); +} diff --git a/apps/web/app/admin/layout.tsx b/apps/web/app/admin/layout.tsx index 472b8c0..78161d2 100644 --- a/apps/web/app/admin/layout.tsx +++ b/apps/web/app/admin/layout.tsx @@ -15,6 +15,7 @@ import { LogOut, ShieldAlert, Package, + KeyRound, } from 'lucide-react'; import { apiFetch } from '@/lib/api'; import { cn } from '@/lib/cn'; @@ -35,6 +36,7 @@ const NAV: { href: string; label: string; icon: React.ComponentType<{ size?: num { href: '/admin/builds', label: 'Builds', icon: Hammer }, { href: '/admin/audit', label: 'Audit log', icon: FileClock }, { href: '/admin/system', label: 'System health', icon: Activity }, + { href: '/admin/encryption', label: 'Encryption', icon: KeyRound }, { href: '/admin/prompt', label: 'AI prompt', icon: Wand2 }, ]; diff --git a/packages/db/src/schema.ts b/packages/db/src/schema.ts index 10b8085..0158961 100644 --- a/packages/db/src/schema.ts +++ b/packages/db/src/schema.ts @@ -211,6 +211,20 @@ export const buildLogs = pgTable( }), ); +// Envelope encryption: a Data Encryption Key (DEK) is generated, wrapped (itself +// AES-256-GCM encrypted) with the Key Encryption Key (KEK) from the env, and +// stored here. Secrets are encrypted with the DEK. Rotation mints a fresh DEK +// and re-encrypts every secret — the KEK never leaves the environment. +export const encryptionKeys = pgTable('encryption_keys', { + id: uuid('id').defaultRandom().primaryKey(), + version: integer('version').notNull().unique(), + wrappedDek: text('wrapped_dek').notNull(), + active: boolean('active').default(false).notNull(), + rotatedBy: uuid('rotated_by').references(() => users.id, { onDelete: 'set null' }), + createdAt: timestamp('created_at').defaultNow().notNull(), + retiredAt: timestamp('retired_at'), +}); + export const secrets = pgTable('secrets', { id: uuid('id').defaultRandom().primaryKey(), serverId: uuid('server_id') @@ -218,6 +232,9 @@ export const secrets = pgTable('secrets', { .notNull(), key: varchar('key', { length: 128 }).notNull(), encryptedValue: text('encrypted_value').notNull(), + // null = legacy (encrypted directly with the KEK, pre-envelope). Non-null + // rows are encrypted with the referenced key's DEK. + keyId: uuid('key_id').references(() => encryptionKeys.id), createdAt: timestamp('created_at').defaultNow().notNull(), }); @@ -302,3 +319,4 @@ export type BuildLog = typeof buildLogs.$inferSelect; export type Secret = typeof secrets.$inferSelect; export type OAuthClient = typeof oauthClients.$inferSelect; export type Template = typeof templates.$inferSelect; +export type EncryptionKey = typeof encryptionKeys.$inferSelect;