feat(web): an account's own Anthropic and OpenAI keys, sealed on the service (BYOK 1/3)

Merged
DA
8d ago
+2102-1

What

BYOK part 1 of 3: the server side of an account's own Anthropic and OpenAI API keys. Nothing uses the keys yet. Part 2 runs coding agents on them, and part 3 adds the Settings ▸ API keys page.

/api/account/provider-keys (bearer-authenticated, scoped to the owner):

MethodDoesAnswers
GETreads the account's keys{ keys: [{ provider, hint: "sk-ant-…abcd", addedAt, status, statusAt }] }
PUT { provider, key }checks the key with the provider (GET /v1/models, free), then stores it only if the provider accepts itthe keys, plus rejected: invalid | wrong_provider | unreachable | too_many_tries when nothing was kept
DELETE ?provider=removes onethe keys

No answer ever carries a key. status is verified / invalid / quota_exceeded / unreadable. unreadable means the deployment's key ring can no longer decrypt the key; the account is told to replace it, and the key is never used again.

Security design (approved by the orchestrator before building)

  • Envelope encryption (hosted/provider-key-seal.ts):
    • Each save draws a fresh 32-byte data key and seals the API key under it with AES-256-GCM.
    • The data key is wrapped with AES-256-GCM under the current key-encryption key (KEK).
    • The KEK lives only in PROVIDER_KEY_ENCRYPTION_KEYS, never in the DB.
  • AAD binding: both seals bind (user_id, provider), and the wrap also binds key_version. A sealed key copied onto another account's row, another provider's row, or another version fails its tag. Every failure to decrypt is one answer, unreadable, with no cause attached (tested).
  • Storage: account_provider_key (migration 0080) holds the version, the wrapped data key, the sealed key, the hint, the status, and timestamps. No column holds the key or its base64 in the clear (tested against the raw row). Rows cascade with the account.
  • Rate limit: saves are braked at 10 per hour per account, so the verify call can't be used as an oracle for testing stolen keys.
  • No leaks:
    • Nothing logs the key or the provider's response; the check drops the response unread.
    • A test captures every log line and asserts no key appears in any of them or in any answer.
    • Effect's HttpClient already redacts authorization and x-api-key.
    • A malformed ring logs which shape is wrong, never the value.
  • Rotation is a documented script, not a migration: scripts/rewrap-provider-keys.sh calls POST /api/maintenance/provider-keys-rewrap on the deployment under its own CRON_SECRET (constant-time compare).
    • The rewrap runs on the deployment, so the KEKs and the DB URL never leave Vercel.
    • It re-wraps data keys only and never decrypts an API key.
    • A row is moved only if it still holds the seal it was read with, so a concurrent replace is never clobbered.
    • It answers counts only.
    • It is its own route group rather than part of the sweep's, so its function doesn't bundle the voice socket library.
  • No env var: a deployment without the var, or with a malformed value, answers unavailable on the whole path.
  • The dead provider_key table from #568 is left untouched. It still holds old Conductor keys under the old secret, which PRIVACY.md already describes.

Setup for Dean: PROVIDER_KEY_ENCRYPTION_KEYS

Format: comma-separated <version>:<base64 of 32 random bytes> entries. The first entry is the current one: new saves wrap under it, and every listed version stays readable.

Use a different key for Production and for Preview.

  1. Generate two keys, one per environment:
    openssl rand -base64 32   # → production key, e.g. q3Jm…=
    openssl rand -base64 32   # → preview key (a different one)
    
    Each output is 44 characters ending in =.
  2. Set them on Vercel (project luke, the web app), marked Sensitive:
    vercel env add PROVIDER_KEY_ENCRYPTION_KEYS production --sensitive
    # paste: 1:<production key>
    vercel env add PROVIDER_KEY_ENCRYPTION_KEYS preview --sensitive
    # paste: 1:<preview key>
    
    Or in the dashboard: Settings → Environment Variables → PROVIDER_KEY_ENCRYPTION_KEYS. Add one value for Production and a different one for Preview, and tick Sensitive.
  3. Redeploy. Until the var is set, the endpoints answer 503 and agents keep running on Luke's keys.
  4. Keep a copy of each key in the team's password manager. Losing a key makes every stored key under it unreadable, and users have to re-enter their keys.

Rotating a key later:

  1. Prepend a new version, e.g. 2:<new key>,1:<old key>, then redeploy. New saves use v2, and v1 rows still decrypt.
  2. Run CRON_SECRET=<that deployment's> scripts/rewrap-provider-keys.sh https://tryluke.dev and repeat until changed is 0.
  3. Once nothing is left on v1, drop 1:<old key> from the value and redeploy.

The server README's "Accounts' own provider keys" section documents the same steps.

Tests

  • tests/provider-key-seal.test.ts:
    • round trip
    • two seals share no bytes
    • an AAD mismatch (wrong user_id or wrong provider) fails closed as unreadable
    • a wrong KEK, a missing version, or a wrong version is unreadable
    • a tampered byte is unreadable
    • a rewrap opens without the old version
    • ring parse faults
  • tests/provider-keys-app.test.ts (PGlite, provider faked at the HTTP door; no network):
    • an accepted key is stored and answered as hint/verified, never as itself
    • the raw row holds no plaintext
    • invalid, unreachable, and wrong-provider saves keep nothing, an Anthropic key is never sent to OpenAI, and no log line holds a key
    • replace and remove are owner-scoped
    • a ring change reads as unreadable
    • the brake, account-deletion cascade, unavailable without a ring, and bad requests
  • tests/provider-key-rewrap.test.ts: moves old rows, leaves current and unreadable rows as they are, is idempotent, and the endpoint's auth, method, and no-ring refusals.
  • Wire goldens recorded for the three new schemas.

./scripts/check.sh exits 0. This PR touches no Mac UI. verify.sh was not run (Linux VM), and CI cannot verify the Mac.

🤖 Generated with Claude Code


Open workspace in Conductor


Devin Review