[Security 2/9] Encrypt MCP connector secrets with per-record derived keys

⛔ closed · #244 · open-legal-products/mike ← amal66/mike · opened 3mo ago by amal66 · closed 3mo ago · +118-9 across 2 files · ↗ on GitHub

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.

⬇ Download capture-pull-244.md