Skip to content

Hash per-user API keys at rest (phase 1: additive) - #1555

Merged
helpcodeai merged 1 commit into
mainfrom
fix/hash-api-keys-at-rest
Nov 6, 2026
Merged

helpcodeai merged 1 commit into
mainfrom
fix/hash-api-keys-at-rest

Conversation

@helpcodeai

@helpcodeai helpcodeai commented Nov 6, 2026 •

Copy link
Copy Markdown
Contributor

Summary

Per-user mcp_ API keys are now stored as a keyed hash as well as in the existing key column. This is phase 1 of moving them off plaintext at rest. It only adds columns, so older containers and image rollbacks keep working. A later PR drops the plaintext column.

  • Hash: HMAC-SHA256(pepper, key), hex. The pepper is derived from ENCRYPTION_KEY with HKDF-SHA256 (info amcp:mcp-api-key:v1). No new secret to configure. ENCRYPTION_KEY already must never change.
  • Suffix: the last 8 characters are kept in key_suffix for the masked mcp_••••<suffix> display the dashboard already uses.

Changes

  • Migration 20261106130000_add_mcp_api_key_hash: nullable key_hash (unique) and key_suffix on mcp_api_keys. key is untouched.
  • common/crypto/api-key-hash.util.ts: pepper derivation, hash, suffix, and the lookup where (hash OR plaintext during the transition).
  • McpApiKeysService:
    • generate() writes hash and suffix;
    • resolveUserByKey() looks up by hash with a plaintext fallback;
    • on boot, a non-blocking, idempotent backfill hashes rows created before this change. It stops if a full batch makes no progress and never logs a key.
  • JwtOrManagementKeyGuard: same lookup.
  • Both lookup paths hash an unhashed row on first use. This covers keys an older container creates during a blue/green release after the backfill ran.
  • Corrected the key format comment (64 hex chars, 256 bits).

No API response changes. listByUser() and the dashboard still behave as before; they move to key_suffix in a later phase.

Cloud and self-hosted

Same behaviour in both modes. The migration runs at container start as usual and the backfill runs on boot. Clients keep sending the same key in X-API-Key or Authorization: Bearer, so nothing changes for users. Self-hosted instances derive the pepper from their existing ENCRYPTION_KEY and need no configuration.

Type

  • Other: security hardening (credential storage)

Testing

  • Added/updated unit tests
  • Tested manually (steps below)

Commands run in packages/backend, against an isolated local database on the dev Postgres (port 5433), after npx prisma migrate deploy and npx prisma generate:

  • npx prisma migrate diff --from-config-datasource --to-schema prisma/schema.prisma --script → empty migration (schema and migrations agree)
  • npx tsc --noEmit -p tsconfig.json → no errors
  • npm run lint → 0 errors, 11 warnings, all in files this PR does not touch; eslint --fix changed nothing
  • npx jest --maxWorkers=4 → Test Suites: 1032 passed, 7 skipped; Tests: 31785 passed, 1871 skipped; 0 failed

New tests:

  • api-key-hash.util.spec.ts: HKDF/HMAC values, stability, per-key and per-secret differences, not a plain SHA-256, weak secret refused.
  • mcp-api-keys.service.spec.ts: hash and suffix on create, hash-or-plaintext lookup, hash on first use, batched backfill, no infinite loop.
  • jwt-or-management-key.guard.spec.ts (real DB): a key matched by its hash alone authenticates; the stored hash is not accepted as a key; a plaintext-only row is hashed on first use.

Manual check, upgrade path with real builds of main (old) and this branch (new) on one local database (MCP_AUTH_MODE=oauth2). Each step probes POST /mcp/:serverId (initialize) with an MCP key, GET /api/mcp-keys with a read key, and random keys:

  1. Old code, old schema, plaintext-only keys: /mcp 200, /api 200, wrong keys 401, MCP key on /api 401.
  2. Migration applied while the old code is running: same results.
  3. New code started next to the old one (blue/green):
    • log line "Hashed 2 API key(s)"; both rows hashed with the right suffix;
    • both servers give the same results;
    • a key created through the old code's API authenticates on the new code and gets hashed on first use;
    • a key created by the new code works on the old code.
  4. Rollback (new code stopped): the old code accepts all four keys.
  5. Restore drill: deleting the rows, then restoring a data-only pg_dump of mcp_api_keys, brings everything back to 200. A full pg_restore into a scratch DB also works.

No key appears in either backend log.

Checklist

  • My code follows the existing code style
  • I have tested my changes locally
  • I have updated documentation (if applicable): no user-facing behaviour changes
  • All existing tests pass (backend npx jest)
  • This PR has a descriptive title

Related Issues

The schema change is tracked privately by the maintainers. Next phases:

  • short-lived support keys with expiresAt;
  • cutover to hash-only lookups with the UI on key_suffix;
  • dropping the plaintext column.

Store HMAC-SHA256 of each mcp_ key, keyed on a pepper derived from
ENCRYPTION_KEY with HKDF, plus the last 8 characters for the masked
display. New keys write the hash; the backend backfills existing rows
at boot and hashes any unhashed row on first use. Lookups match the
hash and still fall back to the plaintext column, which stays until a
later phase drops it, so older containers and rollbacks keep working.

Also corrects the key format comment (64 hex chars, not 32).
@helpcodeai
helpcodeai force-pushed the fix/hash-api-keys-at-rest branch from 73b1917 to 1315ccf Compare November 6, 2026 12:23
@helpcodeai
helpcodeai merged commit 4ebb3f4 into main Nov 6, 2026
16 checks passed
@helpcodeai
helpcodeai deleted the fix/hash-api-keys-at-rest branch November 6, 2026 12:39
@github-actions github-actions Bot locked and limited conversation to collaborators Nov 6, 2026
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants