security: encrypt MCP connector secrets with per-record derived keys - #32
Open
amal66 wants to merge 1 commit into
Open
security: encrypt MCP connector secrets with per-record derived keys#32amal66 wants to merge 1 commit into
amal66 wants to merge 1 commit into
Conversation
Connector secrets (auth config, access/refresh tokens, client secrets) were all encrypted under ONE key: crypto.scryptSync(secret, "mike-user-mcp-v1"), a static salt shared across every row — extracting that one derived key exposes every stored connector secret. Derive a unique 256-bit key per secret via HKDF-SHA256 over a random 16-byte salt instead. The connector tables have no salt column and span four encrypted fields, so rather than a migration, pack the salt into the stored value: `v2.` + base64(salt || ciphertext). Decrypt resolves the key from the prefix; a wrong/forged salt derives a wrong key so GCM fails closed. Rows without the `v2.` prefix decrypt with the old static key — existing secrets keep working, new writes are per-row. Covered by crypto.test.ts (round-trip, per-row salt, legacy fallback, tamper → null). tsconfig excludes test files from the build output; the crypto unit tests run under a vitest harness added separately. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CEguyEgXa9JjCciXCcVemC
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
MCP connector secrets — OAuth access/refresh tokens, client secrets, and auth configuration for the external services a user wires in (email, DMS, etc.) — are stored encrypted, but every row was encrypted under the SAME derived key (one scrypt derivation with a fixed salt). That single key is a master skeleton key: anyone who extracts it (memory dump, side channel, a leaked debug artifact) can decrypt every connector secret for every user in the database. This PR gives each stored secret its own unique 256-bit key, derived per record via HKDF-SHA256 from the master secret plus a random 16-byte salt. Compromising one record's key now reveals exactly one secret; the blast radius of a partial compromise drops from "all connector credentials" to "one".
Changes
backend/src/lib/mcp/client.ts:deriveKey(salt)— HKDF-SHA256 (RFC 5869) over the existingMCP_CONNECTORS_ENCRYPTION_SECRET/USER_API_KEYS_ENCRYPTION_SECRETmaster secret with a fresh random 16-byte salt per encryption.v2.+ base64(salt ‖ ciphertext).unpackCiphertextresolves the right key from the prefix; a wrong or forged salt derives a wrong key, so AES-GCM authentication fails closed (decrypt returns null/empty, never garbage plaintext).v2.prefix decrypt with the previous static scrypt key (legacyEncryptionKey), so existing connectors keep working; every new write is per-record. No migration required.backend/src/lib/mcp/__tests__/crypto.test.ts: round-trip through the versioned scheme, fresh salt per encryption (same plaintext ⇒ different ciphertext), legacy static-salt ciphertext still decrypts, tampered salt/ciphertext fails closed to null.backend/tsconfig.json: exclude*.test.ts/__tests__/**from thetscbuild so shipped tests never enterdist/.Why
Connector secrets are standing credentials into systems that hold privileged client material. Encrypting them all under one static key concentrates that entire surface behind a single secret-derived value; per-record keys de-correlate the rows so no single derived key is worth stealing. Confidentiality of client-adjacent credentials improves with zero operational cost: same env vars, same tables, no migration, old rows readable. No new runtime dependencies — HKDF comes from Node's built-in
crypto.Testing
cd backend && npm install && npm run build— tsc clean on the branch as committed.upstream-pr/test-harness) merged locally:cd backend && npx vitest run— 2 test files, 16 tests passed (4 new crypto tests + 12 pre-existing harness tests).Provenance
All changes are mechanical ports of code in amal66/mike@origin/main (commit b3166dd); exceptions:
console.errorfor its structuredloggerin the decrypt catch blocks; this repo has no logger module, so those lines are left as upstream'sconsole.error(no change).userApiKeys.tswas dropped, since this repo'suserApiKeys.tsdoes not use that scheme.Credits & prior art
🤖 Generated with Claude Code
https://claude.ai/code/session_01CEguyEgXa9JjCciXCcVemC