feat(crypto): envelope encryption + key rotation via admin panel

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
This commit is contained in:
Marco Sadjadi 2026-05-20 22:36:08 +02:00
parent 8d47b20ae5
commit a68e882092
7 changed files with 411 additions and 11 deletions

View File

@ -4,6 +4,7 @@ import cookie from '@fastify/cookie';
import websocket from '@fastify/websocket'; import websocket from '@fastify/websocket';
import { seedAdmin } from '@bmm/auth'; import { seedAdmin } from '@bmm/auth';
import { config } from './config.js'; import { config } from './config.js';
import { ensureActiveKey } from './lib/crypto.js';
import { authRoutes } from './routes/auth.js'; import { authRoutes } from './routes/auth.js';
import { serverRoutes } from './routes/servers.js'; import { serverRoutes } from './routes/servers.js';
import { oauthRoutes } from './routes/oauth.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() })); 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(authRoutes);
await app.register(serverRoutes); await app.register(serverRoutes);
await app.register(oauthRoutes); await app.register(oauthRoutes);

View File

@ -1,30 +1,195 @@
import crypto from 'node:crypto'; import crypto from 'node:crypto';
import { count, createDb, desc, eq, encryptionKeys, secrets, sql } from '@bmm/db';
import { config } from '../config.js'; import { config } from '../config.js';
const ALGO = 'aes-256-gcm'; const ALGO = 'aes-256-gcm';
const db = createDb();
function getKey(): Buffer { /**
const hex = config.SECRETS_ENCRYPTION_KEY; * Envelope encryption.
const buf = Buffer.from(hex, 'hex'); *
* 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) { if (buf.length !== 32) {
throw new Error('SECRETS_ENCRYPTION_KEY must be 32 bytes (64 hex chars)'); throw new Error('SECRETS_ENCRYPTION_KEY must be 32 bytes (64 hex chars)');
} }
return buf; 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 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 enc = Buffer.concat([cipher.update(plaintext, 'utf8'), cipher.final()]);
const tag = cipher.getAuthTag(); const tag = cipher.getAuthTag();
return `${iv.toString('base64')}.${tag.toString('base64')}.${enc.toString('base64')}`; 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('.'); const [ivB64, tagB64, encB64] = payload.split('.');
if (!ivB64 || !tagB64 || !encB64) throw new Error('malformed_secret_payload'); if (!ivB64 || !tagB64 || !encB64) throw new Error('malformed_ciphertext');
const decipher = crypto.createDecipheriv(ALGO, getKey(), Buffer.from(ivB64, 'base64')); const decipher = crypto.createDecipheriv(ALGO, key, Buffer.from(ivB64, 'base64'));
decipher.setAuthTag(Buffer.from(tagB64, 'base64')); decipher.setAuthTag(Buffer.from(tagB64, 'base64'));
const dec = Buffer.concat([decipher.update(Buffer.from(encB64, 'base64')), decipher.final()]); return Buffer.concat([decipher.update(Buffer.from(encB64, 'base64')), decipher.final()]).toString(
return dec.toString('utf8'); 'utf8',
);
}
// In-memory DEK cache: encryption_keys.id -> raw 32-byte DEK.
const dekCache = new Map<string, Buffer>();
let activeKeyId: string | null = null;
async function loadKeys(): Promise<void> {
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<void> {
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<RotateResult> {
await loadKeys();
const newDek = crypto.randomBytes(32);
const wrapped = aesEncrypt(getKEK(), newDek.toString('base64'));
const [{ maxV } = { maxV: 0 }] = await db
.select({ maxV: sql<number>`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<EncryptionStatus> {
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,
})),
};
} }

View File

@ -24,6 +24,7 @@ import { requireAdmin } from '../plugins/session.js';
import { getRedis } from '../lib/redis.js'; import { getRedis } from '../lib/redis.js';
import { getBuildQueue } from '../lib/queue.js'; import { getBuildQueue } from '../lib/queue.js';
import { audit } from '../lib/audit.js'; import { audit } from '../lib/audit.js';
import { encryptionStatus, rotateKeys } from '../lib/crypto.js';
const db = createDb(); const db = createDb();
@ -565,5 +566,28 @@ export async function adminRoutes(app: FastifyInstance): Promise<void> {
return reply.send({ ok: true }); 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 void inArray; // referenced for future bulk operations
} }

View File

@ -164,10 +164,12 @@ export async function serverRoutes(app: FastifyInstance): Promise<void> {
for (const [key, value] of Object.entries(secretValues)) { for (const [key, value] of Object.entries(secretValues)) {
if (!value) continue; if (!value) continue;
const enc = encryptSecret(value);
await db.insert(secrets).values({ await db.insert(secrets).values({
serverId: server.id, serverId: server.id,
key, key,
encryptedValue: encryptSecret(value), encryptedValue: enc.value,
keyId: enc.keyId,
}); });
} }

View File

@ -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<Status | null>(null);
const [rotating, setRotating] = useState(false);
const [message, setMessage] = useState<string | null>(null);
async function reload() {
setStatus(await apiFetch<Status>('/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 (
<div className="px-8 py-8">
<header className="mb-6">
<h1 className="text-[22px] font-semibold tracking-tight">Encryption</h1>
<p className="mt-1 text-[13px] text-[--color-fg-muted]">
Envelope encryption for customer secrets. The KEK lives only in the environment;
Data Encryption Keys are stored wrapped and rotated here.
</p>
</header>
{!status && <div className="mono text-[12px] text-[--color-fg-muted]">Loading</div>}
{status && (
<>
<div className="grid gap-3 md:grid-cols-3">
<Card label="Active key" value={status.activeVersion ? `v${status.activeVersion}` : '—'} />
<Card label="Secrets encrypted" value={status.secretCount.toLocaleString()} />
<Card
label="Legacy (pre-envelope)"
value={status.legacySecretCount.toLocaleString()}
sub={
status.legacySecretCount > 0
? 'rotate once to migrate them onto a DEK'
: 'all on a managed DEK'
}
/>
</div>
<div className="panel mt-6 p-4">
<div className="flex items-baseline justify-between">
<div>
<h2 className="text-[14px] font-semibold tracking-tight">Rotate encryption key</h2>
<p className="mt-1 text-[12.5px] leading-relaxed text-[--color-fg-muted]">
Generates a new DEK and re-encrypts all {status.secretCount} secret(s) under it
atomically. The environment KEK is never exposed or changed.
</p>
</div>
<Button variant="primary" size="md" onClick={rotate} disabled={rotating}>
{rotating ? 'Rotating…' : 'Rotate key'}
</Button>
</div>
{message && (
<p
className={cn(
'mt-3 text-[12.5px]',
message.startsWith('Rotation failed')
? 'text-[--color-danger]'
: 'text-emerald-300',
)}
>
{message}
</p>
)}
</div>
<div className="mt-6">
<h2 className="text-[14px] font-semibold tracking-tight">Key history</h2>
<div className="panel mt-3">
<table className="w-full text-[12.5px]">
<thead className="border-b border-[--color-border] text-[--color-fg-subtle]">
<tr>
<th className="px-4 py-2 text-left font-medium">Version</th>
<th className="px-4 py-2 text-left font-medium">Status</th>
<th className="px-4 py-2 text-left font-medium">Created</th>
<th className="px-4 py-2 text-left font-medium">Retired</th>
</tr>
</thead>
<tbody>
{status.keys.map((k) => (
<tr key={k.version} className="border-b border-[--color-border] last:border-0">
<td className="px-4 py-2.5 mono">v{k.version}</td>
<td className="px-4 py-2.5">
<span
className={cn(
'mono rounded-full border px-2 py-0.5 text-[11px]',
k.active
? 'border-emerald-400/40 bg-emerald-400/10 text-emerald-300'
: 'border-[--color-border] bg-[--color-bg-subtle] text-[--color-fg-subtle]',
)}
>
{k.active ? 'active' : 'retired'}
</span>
</td>
<td className="px-4 py-2.5 mono text-[--color-fg-muted]">
{new Date(k.createdAt).toLocaleString()}
</td>
<td className="px-4 py-2.5 mono text-[--color-fg-muted]">
{k.retiredAt ? new Date(k.retiredAt).toLocaleString() : '—'}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
<p className="mt-6 text-[11.5px] leading-relaxed text-[--color-fg-subtle]">
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.
</p>
</>
)}
</div>
);
}
function Card({ label, value, sub }: { label: string; value: string; sub?: string }) {
return (
<div className="panel p-4">
<div className="text-[11px] uppercase tracking-wider text-[--color-fg-subtle]">{label}</div>
<div className="mt-1.5 text-[24px] font-semibold tabular-nums tracking-tight">{value}</div>
{sub && <div className="mt-1 text-[12px] text-[--color-fg-muted]">{sub}</div>}
</div>
);
}

View File

@ -15,6 +15,7 @@ import {
LogOut, LogOut,
ShieldAlert, ShieldAlert,
Package, Package,
KeyRound,
} from 'lucide-react'; } from 'lucide-react';
import { apiFetch } from '@/lib/api'; import { apiFetch } from '@/lib/api';
import { cn } from '@/lib/cn'; 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/builds', label: 'Builds', icon: Hammer },
{ href: '/admin/audit', label: 'Audit log', icon: FileClock }, { href: '/admin/audit', label: 'Audit log', icon: FileClock },
{ href: '/admin/system', label: 'System health', icon: Activity }, { href: '/admin/system', label: 'System health', icon: Activity },
{ href: '/admin/encryption', label: 'Encryption', icon: KeyRound },
{ href: '/admin/prompt', label: 'AI prompt', icon: Wand2 }, { href: '/admin/prompt', label: 'AI prompt', icon: Wand2 },
]; ];

View File

@ -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', { export const secrets = pgTable('secrets', {
id: uuid('id').defaultRandom().primaryKey(), id: uuid('id').defaultRandom().primaryKey(),
serverId: uuid('server_id') serverId: uuid('server_id')
@ -218,6 +232,9 @@ export const secrets = pgTable('secrets', {
.notNull(), .notNull(),
key: varchar('key', { length: 128 }).notNull(), key: varchar('key', { length: 128 }).notNull(),
encryptedValue: text('encrypted_value').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(), createdAt: timestamp('created_at').defaultNow().notNull(),
}); });
@ -302,3 +319,4 @@ export type BuildLog = typeof buildLogs.$inferSelect;
export type Secret = typeof secrets.$inferSelect; export type Secret = typeof secrets.$inferSelect;
export type OAuthClient = typeof oauthClients.$inferSelect; export type OAuthClient = typeof oauthClients.$inferSelect;
export type Template = typeof templates.$inferSelect; export type Template = typeof templates.$inferSelect;
export type EncryptionKey = typeof encryptionKeys.$inferSelect;