[Security 2/9] Encrypt MCP connector secrets with per-record derived keys
From the PR description
[Security 2/9] Encrypt MCP connector secrets with per-record derived keys
Part of the split of #227 into single-topic PRs. Index: tracking comment on #227.
TL;DR
OAuth access/refresh tokens and client secrets for third-party connectors are stored in our database. This PR encrypts them at rest with AES-256-GCM, a fresh random IV per encryption, and a per-record derived key (random salt per row, key derived from a master secret via HKDF). Legacy rows still decrypt, so there is no migration flag-day.
Risk to user data
Severity: high. These secrets are live credentials to a user's other accounts - Gmail, Slack, GitHub, whatever they connected. Stored in plaintext, any single read-path leak (a stolen backup, a mis-permissioned replica, a SQL-injection read) turns into a compromise of every connected third-party account for every user. Encryption at rest means the leaked bytes are useless without the master key, which lives outside the database.
Flows affected
- MCP connector creation and auth-config storage (
encryptString,encryptJson) - Connector use / token refresh (
decryptString,decryptAuthConfig) - No API or behavior change for users; purely at-rest representation.
Attack precedent
Read-path leaks of at-rest secrets are among the most common breach mechanics: exposed backups and public database snapshots, over-permissioned read replicas, and SQL injection that exfiltrates rows. The OWASP Cryptographic Storage Cheat Sheet exists precisely because "the DB is behind a firewall" has failed so many times.
Possible fixes, and what we chose
| Option | Verdict |
|---|---|
| Store plaintext, rely on DB access controls | The status quo we're removing. One leak = full compromise. |
| Single static key for all rows | Better, but a dump can be attacked in bulk, and equal plaintexts produce equal ciphertexts (leaks structure). |
| AES-256-CBC / encryption without authentication | Rejected - no tamper detection; an attacker who can write rows can flip bits undetectably. |
| AES-256-GCM + per-record HKDF-derived key + random IV | Chosen. Authenticated (GCM tag detects tampering), semantically secure (random IV, per-row salt), and bulk-attack-resistant (one key's compromise ≠ all rows). |
The composition is the whole game - you never invent primitives, you compose standard ones correctly:
- AES-256-GCM - authenticated encryption; the auth tag detects tampering (encryption alone does not).
- Random 12-byte IV per encryption - GCM fails catastrophically on IV reuse, so this is non-negotiable.
- Per-record key via HKDF (RFC 5869) - random 16-byte salt per row → unique key. Bulk dump can't be attacked with one key, and equal plaintexts don't collide.
flowchart LR
P["secret plaintext"] --> E
MK["master secret<br/>(env, outside DB)"] --> HKDF
Salt["random 16-byte salt<br/>(per record)"] --> HKDF
HKDF["HKDF-SHA256"] --> K["per-record key"]
K --> E["AES-256-GCM<br/>+ random 12-byte IV"]
P --> E
E --> C["v2. || base64(salt ‖ ciphertext)"]
E --> Tag["auth tag"]
C --> DB[(database)]
Tag --> DB
Migration without a flag-day: the connector tables have no salt column, so the salt is packed into the stored value: v2. + base64(salt(16) ‖ ciphertext). Legacy rows have no v2. prefix and decrypt with the old static key. A wrong/forged salt derives a wrong key, so GCM auth fails closed on decrypt. crypto.test.ts covers round-trips and tamper detection.
What's in this PR
backend/src/lib/mcp/client.ts- HKDF key derivation, packed salt format, GCM encrypt/decrypt with legacy fallback.- Tests:
crypto.test.ts(4 tests: round-trip, legacy decrypt, tamper detection).
Reading
OWASP Cryptographic Storage Cheat Sheet · Galois/Counter Mode · RFC 5869 (HKDF)
Our analysis
Encrypt connector secrets at rest — read the full analysis →
Think the analysis missed something the PR description covers?
Capture this PR into my fork
Download a Markdown prompt that tells Claude how to port every
commit in this PR into your working tree. Run it via
claude -p < capture-pull-244.md from
inside the repo you want the changes in.