Skip to content

feat: encrypted per-user LLM key storage + settings UI (BYOK 2/4)#407

Open
edspencer wants to merge 6 commits into
byok/1-shared-ai-packagefrom
byok/2-key-storage-settings
Open

feat: encrypted per-user LLM key storage + settings UI (BYOK 2/4)#407
edspencer wants to merge 6 commits into
byok/1-shared-ai-packagefrom
byok/2-key-storage-settings

Conversation

@edspencer

Copy link
Copy Markdown
Owner

Part 2 of 4 of the BYOK stack (#405). Base: byok/1-shared-ai-package (#406) — merge that first.

Adds encrypted at-rest storage for per-user LLM API keys and the Account-page UI to manage them. Nothing consumes the stored keys yet — that's PR 3.

What's here

  • user_llm_config table (migration 0013_quiet_champions.sql): one row per (user, provider); llm_provider enum (openai / anthropic / google / deepseek / ollama / openai_compatible); AES-GCM ciphertext + IV (nullable for keyless Ollama); keyHint (last 4 chars, the only thing ever shown); model + baseURL; isDefault with a partial unique index; lastVerifiedAt. Query helpers in packages/database/src/llm-config/queries.ts (upsert clears competing defaults; delete promotes a remaining row).
  • Crypto (apps/web/lib/crypto/llm-keys.ts): WebCrypto-only (Cloudflare Workers + Node parity) AES-256-GCM with a random 12-byte IV per encryption; key derived via HKDF-SHA256 from a new dedicated BYOK_ENCRYPTION_KEY secret. Unit-tested: round-trip, unique IVs, tamper detection, missing-secret error.
  • API: GET/PUT /api/user/llm-config, DELETE /api/user/llm-config/[provider]. PUT verifies the key live via verifyLLMConfig (@bragdoc/ai) before storing — 422 with the provider's error on failure. Responses are always masked; the plaintext key never appears in any response.
  • Settings UI: new "AI Provider" section on the Account page — provider select (with signup links), password key input, model + baseURL fields, Verify & Save, configured-provider list with default badge / set-default / delete.
  • Account deletion now hard-deletes user_llm_config rows (the user row is anonymized, not deleted, so the FK cascade alone never fires).

Deploy notes (@edspencer)

  • Before this reaches an environment: openssl rand -base64 32 | wrangler secret put BYOK_ENCRYPTION_KEY --env staging (and --env production before PR 3 deploys). Also added to both .env.example files.
  • Migration 0013 is additive-only (new enum + table); safe to run.

Verification

  • pnpm build: 6/6 tasks pass
  • pnpm test: 20 suites, 352 passed (22 new: 7 crypto, 15 route)

🤖 Generated with Claude Code

Adds the storage and settings layer for BYOK (issue #405, PR B). A new
user_llm_config table (llm_provider enum, one row per user+provider,
partial unique index enforcing a single default per user) stores each
user's LLM provider configuration, with query helpers exported from
@bragdoc/database for list/get-default/upsert/delete, including default
promotion on delete and default clearing on upsert.

API keys are encrypted at rest with AES-256-GCM via WebCrypto
(crypto.subtle, Cloudflare Workers compatible), using a key derived
through HKDF-SHA256 from the new BYOK_ENCRYPTION_KEY secret with a
fresh random 12-byte IV per encryption. Only an 8-char keyHint (last 4
characters) is ever returned to clients; plaintext keys never appear in
API responses. Account deletion now explicitly hard-deletes
user_llm_config rows since the user row is anonymized rather than
deleted (FK cascade never fires).

New routes: GET /api/user/llm-config lists masked configs, PUT verifies
the config against the provider via verifyLLMConfig from @bragdoc/ai
before encrypting and upserting (422 with the provider error on
failure; stored keys are decrypted and re-verified when editing
model/baseURL only), and DELETE /api/user/llm-config/[provider] removes
a config. The Account page gains an "AI Provider" section with provider
select (signup links), password key input, model and baseURL fields,
Verify & Save flow, and a configured-provider list with set-default and
delete actions backed by a new useLLMConfigs SWR hook.

BYOK_ENCRYPTION_KEY is documented in both .env.example files and the
deployment docs (generate with `openssl rand -base64 32`). Unit tests
cover the crypto module (round-trip, unique IVs, tamper detection,
missing env) and all three routes (auth, masking, verify-fail/success,
keyless Ollama, delete paths).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@vercel

vercel Bot commented Jul 7, 2026

Copy link
Copy Markdown
Contributor

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
bragdoc-ai Ready Ready Preview, Comment Jul 7, 2026 4:12am

Request Review

@github-actions

github-actions Bot commented Jul 7, 2026

Copy link
Copy Markdown
Contributor

Braintrust eval report

No experiments to report

edspencer and others added 2 commits July 6, 2026 23:12
The web app now depends on @bragdoc/ai, which ships compiled dist/
output (unlike @bragdoc/database, which exports TS source directly).
Vercel's build script bypasses turbo's dependency graph, so the shared
package must be built explicitly first.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The App Tests CI job installs at the workspace root but never built
@bragdoc/ai, so jest could not resolve the package from the new
llm-config route tests. Also fixes a TS2532 (object possibly undefined)
in the tamper-detection crypto test under the standalone typecheck,
which unlike next build includes test files.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The PUT /api/user/llm-config route runs a live verification probe against
a user-supplied baseURL and echoed the provider error back to the caller,
so an authenticated user could point it at internal HTTP services (cloud
metadata at 169.254.169.254, loopback services, LAN hosts) and read parts
of their responses. A new validation module (apps/web/lib/llm/
validate-base-url.ts) now rejects non-http(s) schemes, embedded
credentials, and private/internal targets — localhost/*.local/*.internal
names, loopback, RFC1918, link-local/metadata, and unspecified IPv4, plus
IPv6 loopback, unique-local, link-local, unspecified, and IPv4-mapped
forms. The check runs on both freshly supplied base URLs and stored ones
reused during edits, and the 422 verification error echo is capped at
300 characters so upstream response bodies can't be exfiltrated wholesale.

Self-hosted deployments where private Ollama URLs (http://localhost:11434,
http://192.168.x.x:11434) are the primary use case can set
BYOK_ALLOW_PRIVATE_BASEURLS=true to allow private/internal base URLs.
Documented in both .env.example files and docs/SELF_HOSTING.md, with a
hint added to the base URL field in the provider settings form.

Also fixes demo reset leaving BYOK key material behind: the demo data
cleanup (apps/web/lib/demo-data-cleanup.ts) now deletes the shadow user's
userLLMConfig rows alongside the other per-user tables, matching the
account-deletion behavior — the demo user row is preserved, so the FK
cascade never fires and the encrypted keys had to be removed explicitly.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
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