Skip to content

feat(mcp): encrypt stored MCP credentials at rest - #59

Merged
IBJunior merged 3 commits into
mainfrom
feat/encrypt-mcp-credentials-at-rest
Nov 9, 2026
Merged

IBJunior merged 3 commits into
mainfrom
feat/encrypt-mcp-credentials-at-rest

Conversation

@IBJunior

@IBJunior IBJunior commented Nov 9, 2026 •

Copy link
Copy Markdown
Member

What

The MCP server credentials Cameron stores are now encrypted at rest, so a pg_dump, a file in backups/ or an exposed Postgres port no longer yields usable secrets. Covers mcp_server.env, headers, auth_tokens and client_info.

  • src/lib/crypto/secretBox.ts: AES-256-GCM via node:crypto, a random 12-byte IV per write, and a versioned envelope { enc, v: 1, iv, tag, data } (base64). The row id and column name are bound as associated data, so an envelope copied to another row or column fails to open.
  • Key: CAMERON_SECRET_KEY (32 bytes, base64: openssl rand -base64 32). Documented in .env.example and passed to the web service in compose.yaml.
  • One seam: encryption and decryption happen only in mcpServerRepository. Callers still get plain MCPServer objects. clearOAuthCredentials() (from feat(mcp): move to @langchain/mcp-adapters v2 on MCP SDK v2 #57) writes only null, so a credential reset can't write plaintext back.
  • Missing key: the app still starts, and an install with no credentials needs no key (null and {} are stored as is). Writing a real credential, or reading an envelope, without a key throws SecretKeyError. A wrong key or an altered value throws SecretDecryptError. Nothing falls back to writing plaintext. The /api/mcp-servers routes return those messages instead of a generic 500.
  • Existing servers: recreate them. There is no migration script and no Drizzle migration. Until a server is recreated, its plaintext values still read as before, and each value is encrypted the next time it's written.
  • Not covered: code_verifier (short-lived, cleared on connect).
  • Docs: docs/OAUTH.md ("Token Storage Security"), AGENTS.md, the README, docs/ARCHITECTURE.md, and the Connections form copy, which no longer says "stored unencrypted".

Upgrading

  1. Add CAMERON_SECRET_KEY to .env, and keep it out of your database backups. Losing it means re-entering those credentials.
  2. Recreate any MCP servers created before this change, so their credentials are stored encrypted.

Verification

  • pnpm test (473 passing, including new secretBox.test.ts: round trip, tampered ciphertext and tag, wrong key, moved envelope, plaintext passthrough, missing key) and tsc --noEmit.
  • Live, on a scratch copy of the dev database (dev stack, port 5544), with a throwaway key:
    • The OAuth HTTP server still listed its tools. Its token was refreshed during the call, stored encrypted, and read back intact, including SDK v2's issuer field.
    • Creating and editing a stdio server through the API delivered its env value to the process, while the column held only envelopes.
    • An envelope copied into another row was rejected with a clear error.
  • Not exercised live: a fresh OAuth authorization (saveClientInfo), which needs a browser sign-in. It uses the same encrypt path as the token save.

🤖 Generated with Claude Code

IBJunior and others added 3 commits November 8, 2026 21:08
Seal mcp_server env, headers, auth_tokens and client_info with
AES-256-GCM (key from CAMERON_SECRET_KEY) inside mcpServerRepository.
Legacy plaintext reads through; writes always seal; a missing key throws
rather than writing plaintext. `pnpm db:encrypt-secrets` seals existing
rows.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Servers created before encryption are recreated instead. Legacy
plaintext still reads through until then.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@IBJunior
IBJunior merged commit ca2a297 into main Nov 9, 2026
1 check passed
@IBJunior
IBJunior deleted the feat/encrypt-mcp-credentials-at-rest branch November 9, 2026 09:36
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant