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

@@ -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<void> {
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
}

View File

@@ -164,10 +164,12 @@ export async function serverRoutes(app: FastifyInstance): Promise<void> {
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,
});
}