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

@@ -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;