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:
@@ -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<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,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user