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
179 lines
6.8 KiB
TypeScript
179 lines
6.8 KiB
TypeScript
'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>
|
|
);
|
|
}
|