From 5e416d3726bc1bb1fd86b3f9967922645aec374a Mon Sep 17 00:00:00 2001 From: xcaliber Date: Tue, 24 Feb 2026 10:44:12 +0000 Subject: [PATCH] Changeset 0.9.4 (#54) --- .gitea/workflows/ci.yaml | 9 +- ARCHITECTURE.md | 60 ++++++ ROADMAP.md | 155 ++++++++++++--- VERSION | 2 +- k8s/backend.yaml | 7 + server/config/config.go | 6 + server/crypto/backfill.go | 131 ++++++++++++ server/crypto/cache.go | 64 ++++++ server/crypto/resolver.go | 72 +++++++ server/crypto/vault.go | 165 ++++++++++++++++ server/crypto/vault_test.go | 241 +++++++++++++++++++++++ server/database/migrations/003_vault.sql | 48 +++++ server/handlers/admin.go | 59 +++++- server/handlers/apiconfigs.go | 65 +++++- server/handlers/auth.go | 104 +++++++++- server/handlers/auth_test.go | 2 +- server/handlers/completion.go | 36 +++- server/handlers/integration_test.go | 10 +- server/handlers/messages.go | 11 +- server/handlers/model_sync.go | 9 +- server/handlers/team_providers.go | 69 +++++-- server/handlers/teams.go | 9 +- server/main.go | 41 +++- server/models/models.go | 12 +- server/store/postgres/provider.go | 21 +- src/js/ui.js | 33 +++- 26 files changed, 1333 insertions(+), 108 deletions(-) create mode 100644 server/crypto/backfill.go create mode 100644 server/crypto/cache.go create mode 100644 server/crypto/resolver.go create mode 100644 server/crypto/vault.go create mode 100644 server/crypto/vault_test.go create mode 100644 server/database/migrations/003_vault.sql diff --git a/.gitea/workflows/ci.yaml b/.gitea/workflows/ci.yaml index f23d9ce..346e9d7 100644 --- a/.gitea/workflows/ci.yaml +++ b/.gitea/workflows/ci.yaml @@ -1,6 +1,6 @@ # .gitea/workflows/ci.yaml # ============================================ -# Chat Switchboard - CI/CD Pipeline (v0.9.0) +# Chat Switchboard - CI/CD Pipeline (v0.9.4) # ============================================ # Cluster deployments use SEPARATE FE + BE images. # Unified image is for Docker Hub only (docker-compose use). @@ -36,6 +36,7 @@ # POSTGRES_USER, POSTGRES_PASSWORD # POSTGRES_ADMIN_USER, POSTGRES_ADMIN_PASSWORD # SWITCHBOARD_ADMIN_USERNAME, SWITCHBOARD_ADMIN_PASSWORD, SWITCHBOARD_ADMIN_EMAIL +# ENCRYPTION_KEY — AES-256 key for API key encryption (openssl rand -base64 32) # VENICE_API_KEY (live provider integration tests — optional, tests skip if missing) # DOCKERHUB_USERNAME, DOCKERHUB_TOKEN (optional) # @@ -416,6 +417,7 @@ jobs: SW_ADMIN_PASS: ${{ secrets.SWITCHBOARD_ADMIN_PASSWORD }} SW_ADMIN_EMAIL: ${{ secrets.SWITCHBOARD_ADMIN_EMAIL }} SEED_USERS: ${{ vars.SEED_USERS }} + ENCRYPTION_KEY: ${{ secrets.ENCRYPTION_KEY }} run: | kubectl create secret generic switchboard-db-credentials \ --namespace=${NAMESPACE} \ @@ -435,6 +437,11 @@ jobs: --from-literal=users="${SEED_USERS}" \ --dry-run=client -o yaml | kubectl apply -f - + kubectl create secret generic switchboard-encryption \ + --namespace=${NAMESPACE} \ + --from-literal=ENCRYPTION_KEY="${ENCRYPTION_KEY}" \ + --dry-run=client -o yaml | kubectl apply -f - + - name: Render and apply manifests env: ENVIRONMENT: ${{ steps.env.outputs.ENVIRONMENT }} diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index a1dcb78..e29b038 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -45,6 +45,66 @@ Three Docker images support different deployment scenarios: 5. **Capabilities Resolution**: Model capabilities (vision, tool calling, thinking, context window) are resolved through a priority chain: catalog DB (provider API sync, per-provider authoritative) → heuristic inference (regex patterns on model ID). No static model table — the same model can have different capabilities on different providers. The catalog is populated by auto-fetch on provider creation and manual refresh. +6. **Channels as Execution Context**: Channels are the universal container for conversation state — messages, tool activity, notes, and artifacts all hang off a channel. Today channels are single-user direct chats. The architecture anticipates multi-participant channels (team members, anonymous visitors, AI personas) for workflow execution, without requiring changes to the message tree, tool framework, or streaming infrastructure. + +## Workflow Architecture (Future — v0.21.0+) + +The platform's existing primitives (teams, personas, channels, tools, notes) +compose into a workflow engine where the channel is the execution context +that moves through defined stages. + +### Conceptual Model + +``` +Team + └─ Workflow (team-admin defined) + ├─ name, description + ├─ entry conditions (public link, team-internal, API trigger) + └─ Stages[] + ├─ persona_id (which AI drives this stage) + ├─ assignment_team_id (who can be assigned) + ├─ form_template (structured note schema, optional) + └─ transitions[] (conditions → next stage) + +Channel (workflow instance) + ├─ workflow_id + current_stage + ├─ participants[] + │ ├─ anonymous visitor (mTLS fingerprint / session token) + │ ├─ AI persona (per-stage, from workflow definition) + │ └─ assigned team member (claimed or auto-routed) + ├─ messages (existing tree structure) + ├─ notes (channel-scoped artifacts, intake forms) + └─ tool activity (existing execution framework) +``` + +### How Existing Primitives Map + +| Existing Primitive | Workflow Role | +|-------------------|---------------| +| **Persona** (system prompt + model) | Drives a workflow stage — the AI knows what to collect, when to route | +| **Team** (members + roles) | Owns the workflow definition; members are assignable to channels | +| **Channel** (messages + tree) | Execution instance of a workflow; full conversation history | +| **Notes + Tools** | Structured data collection; the persona's system prompt *is* the form definition, the note *is* the filled form | +| **EventBus + WebSocket** | Real-time notifications for assignment, stage transitions, new messages | + +### Design Constraints for Current Development + +These invariants keep the workflow path open without building it prematurely: + +- **Channels**: Don't assume single-owner. If touching channel queries, keep + room for `team_id`, `type` (beyond `direct`), and multi-participant access + patterns alongside `user_id`. +- **Notes**: Currently user-scoped. Future channel-scoped notes (attached to + a conversation, not a personal notebook) need a `channel_id` FK option. +- **Personas**: Already team-scoped. Don't couple to "user picks from dropdown" + — a workflow stage references a persona programmatically. +- **Tool ExecutionContext**: Already carries `UserID` + `ChannelID`. Will need + `TeamID` and `WorkflowID` — the struct is easily extended. +- **Auth**: mTLS anonymous users need identity to participate in channels. + Lightest version: a `participants` table that can reference a `user_id` or + an opaque session identifier (cert fingerprint). Don't assume every channel + participant has a row in `users`. + ## Package Structure ``` diff --git a/ROADMAP.md b/ROADMAP.md index f23d08a..cfa9514 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -49,11 +49,17 @@ v0.17.0 Extension Surfaces + Modes │ v0.18.0 Smart Routing (policy on model roles) │ -v0.19.0 Live Collaboration (presence, co-editing) +v0.19.0 Multi-Participant Channels + Presence │ v0.20.0 Auth Strategy (mTLS/OIDC) + Full RBAC │ -v0.21.0 Tasks / Autonomous Agents + ┌───────┴──────────────┐ + │ │ +v0.21.0 Workflow v0.22.0 Tasks / + Engine Autonomous Agents + (team-owned, (service channels, + staged processes, scheduler, unattended + human + AI collab) execution) ``` --- @@ -108,7 +114,7 @@ and the model pipeline made real for end users. --- -## v0.9.2 — Quick UX Wins + Hardening +## ✅ v0.9.2 — Quick UX Wins + Hardening Polish that doesn't need new infrastructure. Ship fast, stabilize. @@ -127,30 +133,55 @@ Polish that doesn't need new infrastructure. Ship fast, stabilize. --- -## v0.9.3 — Content Visibility & Block Controls +## ✅ v0.9.3 — Content Visibility & Block Controls User control over what's expanded/collapsed in the message flow. Thinking blocks, tool calls, and code blocks all get consistent collapse controls instead of being hidden or auto-formatted without user agency. **Code Blocks** -- [ ] Collapse/expand toggle button on code block header (alongside Copy/Preview) -- [ ] Auto-collapse at threshold (>15 lines), user can always toggle -- [ ] Smooth max-height transition instead of `
` wrapping +- [x] Collapse/expand toggle button on code block header (alongside Copy/Preview) +- [x] Auto-collapse at threshold (>15 lines), user can always toggle +- [x] Smooth max-height transition instead of `
` wrapping **Thinking Blocks** -- [ ] Always render thinking blocks (never hide from DOM) -- [ ] `showThinking` setting controls default open/closed, not visibility -- [ ] User can always click to expand regardless of setting +- [x] Always render thinking blocks (never hide from DOM) +- [x] `showThinking` setting controls default open/closed (default: false), not visibility +- [x] User can always click to expand regardless of setting +- [x] `reasoning_content` delta field support for models that stream thinking separately (Grok, etc.) +- [x] Reasoning accumulated during streaming, wrapped in `` tags before persistence **Tool Calls** -- [ ] Persist tool call metadata in stored messages -- [ ] Render tool calls on history load (not just during streaming) -- [ ] Collapsed by default: "🔧 tool_name → done" with toggle for full I/O -- [ ] Notes tool: "📝 View note" link that navigates to Notes panel +- [x] Persist tool call metadata in stored messages (`tool_calls` JSONB column) +- [x] Render tool calls on history load (not just during streaming) +- [x] Collapsed by default: "🔧 tool_name → done" with toggle for full I/O +- [x] Notes tool: "📝 View note" link that navigates to Notes panel +- [x] Tool result rendering fix (operator precedence bug showing "undefined results") -**Notes Panel** -- [ ] Copy button on note cards (same pattern as code block copy) +**Notes Panel → Side Panel** +- [x] Copy button on note cards (same pattern as code block copy) +- [x] Unified side panel replaces inline HTML preview and notes modal +- [x] Tabbed interface: Preview and Notes tabs +- [x] Desktop: slides in from right (480px), chat area shrinks via flexbox +- [x] Mobile: full-width overlay at z-index 200 +- [x] Escape key integration (generation → command palette → side panel → modals) + +**Streaming Refactor** +- [x] Extracted `streamWithToolLoop()` into `stream_loop.go` as single canonical streaming implementation +- [x] Both `streamCompletion` (normal chat) and `Regenerate` call shared function — only persistence differs +- [x] Regenerate handler now has full feature parity: tools, reasoning, tool_calls persistence +- [x] Fixed nil `json.RawMessage` causing PostgreSQL JSONB rejection on regen persistence + +**Regenerate Fix** +- [x] Regen streams in-place (truncates display to parent before streaming) +- [x] No more "collapse to original branch" after completion + +**Favicon** +- [x] Animated SVG favicon during generation (cascading opacity pulse on 4 dots) + +**Admin Scope** +- [x] Admin models endpoint returns only global-scope models (not user BYOK or team models) +- [x] Bulk visibility toggle scoped to global providers only --- @@ -480,15 +511,31 @@ Rules-based routing engine — policy, not ML. --- -## v0.19.0 — Live Collaboration +## v0.19.0 — Multi-Participant Channels + Presence + +The channel foundation for workflows and real-time collaboration. Today +channels are single-user direct chats. This release makes channels a +shared space that multiple actors can inhabit. Depends on: extension surfaces (v0.17.0), WebSocket infrastructure (already exists). +**Channel Participants** +- [ ] `channel_participants` table: `channel_id`, `participant_type` (user, session, persona), `participant_id`, `role` (owner, member, observer), `joined_at` +- [ ] Channel access queries rewritten: `WHERE user_id = $1` → participant-based lookup +- [ ] Backward compatible: existing single-user channels auto-have one participant (owner) +- [ ] Channel `type` column: `direct` (current), `group` (multi-user), `workflow` (staged) + +**Presence + Real-time** - [ ] Typing indicators and presence (who's online, who's in this channel) +- [ ] Participant join/leave events via WebSocket - [ ] Co-editing for extension surfaces (editor mode, article mode) -- [ ] Enables: IDE extension with pair programming, collaborative doc editing - [ ] Cursor sharing, selection highlighting +**Channel-Scoped Notes** +- [ ] Optional `channel_id` FK on notes (NULL = personal notebook, set = channel-attached) +- [ ] Notes created by tool calls within a channel auto-attach to that channel +- [ ] Channel notes visible to all participants (not just creator) + --- ## v0.20.0 — Auth Strategy (mTLS/OIDC) + Full RBAC @@ -506,17 +553,74 @@ personal providers under external auth). - [ ] Vault passphrase prompt for mTLS/OIDC users (v0.9.3 conditional unlock) - [ ] Fine-grained permissions: model access, KB write, task create, token budgets +**Anonymous / Session Participants** +- [ ] Session-based identity for unauthenticated visitors (workflow intake) +- [ ] mTLS: cert CN/fingerprint as stable session identity (no `users` row required) +- [ ] Non-mTLS: ephemeral session token (cookie or URL token) +- [ ] Session participants can send messages and trigger tool calls within workflow channels +- [ ] Session identity visible to assigned team members (cert DN, display name, or "Anonymous #N") + --- -## v0.21.0 — Tasks / Autonomous Agents +## v0.21.0 — Workflow Engine -The capstone: everything combined into autonomous workflows. +Team-owned, stage-based process execution. The channel is the runtime, +personas drive each stage, and the existing tool/notes infrastructure +handles structured data collection. See [ARCHITECTURE.md — Workflow +Architecture](ARCHITECTURE.md#workflow-architecture-future--v0210) for +the conceptual model. -- [ ] Scheduler + task runner -- [ ] `task_create` tool -- [ ] `type: 'service'` channels with no human members -- [ ] Depends on: completion handler, tool execution, notes, web search -- [ ] Admin controls for resource limits, execution budgets +Depends on: multi-participant channels (v0.19.0), anonymous identity (v0.20.0). + +**Workflow Definitions** +- [ ] `workflows` table: `team_id` (owner), `name`, `description`, `is_active`, `entry_mode` (public_link, team_internal, api) +- [ ] `workflow_stages` table: `workflow_id`, `ordinal`, `persona_id`, `assignment_team_id`, `form_template` (JSONB, structured note schema), `transition_rules` (JSONB) +- [ ] Team-admin CRUD: create/edit/delete workflows and stages +- [ ] Workflow versioning: edits create new version, active instances continue on their version + +**Workflow Instances (Channels)** +- [ ] Workflow-typed channels: `type='workflow'`, `workflow_id`, `current_stage`, `stage_version` +- [ ] Entry point: public link generates workflow channel, adds anonymous participant, binds stage 1 persona +- [ ] Stage transitions: triggered by AI (tool call), team member (button), or rule (form complete) +- [ ] On transition: swap active persona, notify assignment team, update channel metadata + +**AI Intake** +- [ ] Stage persona drives structured collection via system prompt +- [ ] Form template → persona system prompt injection ("collect these fields: ...") +- [ ] Tool calls create channel-scoped notes as form responses +- [ ] AI determines readiness: "I have everything needed" → trigger transition + +**Assignment + Queue** +- [ ] Assignment queue: unassigned workflow channels visible to team members +- [ ] Claim model: team member claims channel (becomes participant with `member` role) +- [ ] Round-robin / rule-based auto-assignment (optional, per-stage config) +- [ ] Assignment notifications via WebSocket + optional webhook + +**Team Member Collaboration** +- [ ] Assigned member sees full history (AI intake + artifacts + notes) +- [ ] Persona remains active — assists both visitor and team member +- [ ] Member can trigger stage transitions, add notes, invoke tools +- [ ] Member can reassign to different team member or escalate to different team + +**Frontend** +- [ ] Workflow builder UI in team admin panel (stage list, persona picker, form template editor) +- [ ] Queue view: sidebar section showing unassigned workflow channels for user's teams +- [ ] Channel header: workflow stage indicator, transition controls +- [ ] Anonymous visitor view: minimal UI, persona-driven conversation (public link entry) + +--- + +## v0.22.0 — Tasks / Autonomous Agents + +Unattended execution — workflows without a human in the loop. +Depends on: workflow engine (v0.21.0). + +- [ ] Scheduler + task runner (cron-like triggers for workflow instantiation) +- [ ] `task_create` tool (AI can spawn sub-workflows) +- [ ] `type: 'service'` channels: workflow instances with no human participants +- [ ] Execution budgets: max tokens, max tool calls, max wall-clock per stage +- [ ] Admin controls for resource limits and kill switches +- [ ] Completion webhooks (notify external systems when workflow completes) --- @@ -545,7 +649,6 @@ based on need. - Rate limiting per user/team/tier (token budgets) - Provider health monitoring + key rotation - Multi-tenant SaaS mode -- Workflow builder (visual DAG for chaining models + tools) - Plugin/extension marketplace - Virtual scroll for long conversations - SQLite backend option (single-user / dev) diff --git a/VERSION b/VERSION index 965065d..2bd77c7 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.9.3 +0.9.4 \ No newline at end of file diff --git a/k8s/backend.yaml b/k8s/backend.yaml index f2bc441..1a348c4 100644 --- a/k8s/backend.yaml +++ b/k8s/backend.yaml @@ -5,6 +5,7 @@ # Secrets: # switchboard-db-credentials - POSTGRES_USER, POSTGRES_PASSWORD # switchboard-admin - username, password, email (optional) +# switchboard-encryption - ENCRYPTION_KEY (required for v0.9.4+) # # Admin bootstrap: on every pod start, if SWITCHBOARD_ADMIN_* env vars # are set, the backend upserts an admin user. Change the secret and @@ -89,6 +90,12 @@ spec: name: switchboard-seed-users key: users optional: true + - name: ENCRYPTION_KEY + valueFrom: + secretKeyRef: + name: switchboard-encryption + key: ENCRYPTION_KEY + optional: true resources: requests: memory: "${BE_MEMORY_REQUEST}" diff --git a/server/config/config.go b/server/config/config.go index 0ad996b..65f20a1 100644 --- a/server/config/config.go +++ b/server/config/config.go @@ -21,6 +21,11 @@ type Config struct { // Seed users (dev/test only) — CSV: "user:pass:role,user2:pass2:role2" SeedUsers string + + // API key encryption (required for v0.9.4+) + // Used to derive AES-256 key for global/team provider API keys. + // Personal keys use per-user UEK (derived from password). + EncryptionKey string } // Load reads configuration from environment variables. @@ -39,6 +44,7 @@ func Load() *Config { AdminPassword: getEnv("SWITCHBOARD_ADMIN_PASSWORD", ""), AdminEmail: getEnv("SWITCHBOARD_ADMIN_EMAIL", ""), SeedUsers: getEnv("SEED_USERS", ""), + EncryptionKey: getEnv("ENCRYPTION_KEY", ""), } } diff --git a/server/crypto/backfill.go b/server/crypto/backfill.go new file mode 100644 index 0000000..6809ba0 --- /dev/null +++ b/server/crypto/backfill.go @@ -0,0 +1,131 @@ +package crypto + +import ( + "database/sql" + "fmt" + "log" +) + +// BackfillEncryptedKeys encrypts any remaining plaintext API keys in +// provider_configs.api_key_plain using the env-derived key, writing the +// result to api_key_enc (BYTEA) + key_nonce. Clears api_key_plain after. +// +// This runs once on upgrade from v0.9.3 → v0.9.4. Subsequent starts are +// a no-op (api_key_plain will be NULL for all rows). +// +// Personal-scope keys are temporarily encrypted with the env key. They get +// re-encrypted with the user's UEK on next login (see VaultUpgradeOnLogin). +func BackfillEncryptedKeys(db *sql.DB, encryptionKey string) error { + envKey, err := DeriveKeyFromEnv(encryptionKey) + if err != nil { + return fmt.Errorf("vault backfill: %w", err) + } + + // Check if api_key_plain column exists (it won't on fresh installs after + // the column is eventually dropped) + var colExists bool + db.QueryRow(` + SELECT EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_name = 'provider_configs' AND column_name = 'api_key_plain' + ) + `).Scan(&colExists) + if !colExists { + return nil // Column already dropped — nothing to backfill + } + + // Find rows with plaintext keys that haven't been encrypted yet + rows, err := db.Query(` + SELECT id, api_key_plain + FROM provider_configs + WHERE api_key_plain IS NOT NULL + AND api_key_plain != '' + AND api_key_enc IS NULL + `) + if err != nil { + return fmt.Errorf("vault backfill query: %w", err) + } + defer rows.Close() + + var count int + for rows.Next() { + var id, plaintext string + if err := rows.Scan(&id, &plaintext); err != nil { + return fmt.Errorf("vault backfill scan: %w", err) + } + + ciphertext, nonce, err := Encrypt(plaintext, envKey) + if err != nil { + return fmt.Errorf("vault backfill encrypt %s: %w", id, err) + } + + _, err = db.Exec(` + UPDATE provider_configs + SET api_key_enc = $1, key_nonce = $2, api_key_plain = NULL + WHERE id = $3 + `, ciphertext, nonce, id) + if err != nil { + return fmt.Errorf("vault backfill update %s: %w", id, err) + } + count++ + } + + if count > 0 { + log.Printf(" 🔐 Vault backfill: encrypted %d API key(s)", count) + } + return nil +} + +// EnforceEncryptionKey checks startup preconditions: +// - If encrypted keys exist in DB and ENCRYPTION_KEY is empty → fatal. +// - If no keys exist and ENCRYPTION_KEY is empty → warn (fresh install OK). +func EnforceEncryptionKey(db *sql.DB, encryptionKey string) error { + if db == nil { + return nil // No DB, no keys to protect + } + + // Check if any encrypted keys exist + var hasEncrypted bool + db.QueryRow(` + SELECT EXISTS ( + SELECT 1 FROM provider_configs WHERE api_key_enc IS NOT NULL + ) + `).Scan(&hasEncrypted) + + // Check if any plaintext keys exist (pre-backfill) + var hasPlaintext bool + // api_key_plain column may not exist on fresh v0.9.4+ installs + var colExists bool + db.QueryRow(` + SELECT EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_name = 'provider_configs' AND column_name = 'api_key_plain' + ) + `).Scan(&colExists) + if colExists { + db.QueryRow(` + SELECT EXISTS ( + SELECT 1 FROM provider_configs + WHERE api_key_plain IS NOT NULL AND api_key_plain != '' + ) + `).Scan(&hasPlaintext) + } + + if encryptionKey == "" { + if hasEncrypted { + return fmt.Errorf( + "ENCRYPTION_KEY is not set but encrypted API keys exist in the database. " + + "Set ENCRYPTION_KEY to the value used when keys were encrypted, or data will be unrecoverable", + ) + } + if hasPlaintext { + return fmt.Errorf( + "ENCRYPTION_KEY is not set but plaintext API keys need encryption. " + + "Set ENCRYPTION_KEY before starting (required for v0.9.4+)", + ) + } + log.Println(" ⚠ ENCRYPTION_KEY not set — API key encryption disabled (OK for fresh install with no providers)") + } + + return nil +} diff --git a/server/crypto/cache.go b/server/crypto/cache.go new file mode 100644 index 0000000..5af8491 --- /dev/null +++ b/server/crypto/cache.go @@ -0,0 +1,64 @@ +package crypto + +import ( + "sync" +) + +// UEKCache holds decrypted User Encryption Keys in memory, keyed by user ID. +// Keys are loaded on login and evicted on logout / token expiry. +// +// This is the trust boundary: UEKs exist in plaintext only in server memory, +// never on disk or in the database. +type UEKCache struct { + mu sync.RWMutex + store map[string][]byte +} + +// NewUEKCache creates a new empty cache. +func NewUEKCache() *UEKCache { + return &UEKCache{store: make(map[string][]byte)} +} + +// Store caches a UEK for the given user ID. +func (c *UEKCache) Store(userID string, uek []byte) { + c.mu.Lock() + defer c.mu.Unlock() + // Copy to avoid caller mutation + key := make([]byte, len(uek)) + copy(key, uek) + c.store[userID] = key +} + +// Load retrieves the UEK for a user. Returns (uek, true) if present. +func (c *UEKCache) Load(userID string) ([]byte, bool) { + c.mu.RLock() + defer c.mu.RUnlock() + uek, ok := c.store[userID] + if !ok { + return nil, false + } + // Return a copy + out := make([]byte, len(uek)) + copy(out, uek) + return out, true +} + +// Evict removes the UEK for a user (logout / session expiry). +func (c *UEKCache) Evict(userID string) { + c.mu.Lock() + defer c.mu.Unlock() + // Zero the memory before deleting + if uek, ok := c.store[userID]; ok { + for i := range uek { + uek[i] = 0 + } + } + delete(c.store, userID) +} + +// Size returns the number of cached UEKs (for diagnostics). +func (c *UEKCache) Size() int { + c.mu.RLock() + defer c.mu.RUnlock() + return len(c.store) +} diff --git a/server/crypto/resolver.go b/server/crypto/resolver.go new file mode 100644 index 0000000..d2669ec --- /dev/null +++ b/server/crypto/resolver.go @@ -0,0 +1,72 @@ +package crypto + +// KeyResolver decrypts API keys using the appropriate tier: +// - global/team → env-derived key +// - personal → user's UEK from cache +type KeyResolver struct { + envKey []byte // derived from ENCRYPTION_KEY + uekCache *UEKCache +} + +// NewKeyResolver creates a resolver. envKey may be nil if ENCRYPTION_KEY is +// not set (fresh install with no providers — decryption will fail gracefully). +func NewKeyResolver(envKey []byte, cache *UEKCache) *KeyResolver { + return &KeyResolver{envKey: envKey, uekCache: cache} +} + +// Decrypt resolves an API key given its encrypted form, nonce, scope, and +// the requesting user's ID (needed for personal-scope keys). +func (r *KeyResolver) Decrypt(ciphertext, nonce []byte, keyScope, userID string) (string, error) { + if len(ciphertext) == 0 { + return "", nil // No key stored + } + + switch keyScope { + case "global", "team": + if r.envKey == nil { + return "", ErrNoEncryptionKey + } + return Decrypt(ciphertext, nonce, r.envKey) + + case "personal": + uek, ok := r.uekCache.Load(userID) + if !ok { + return "", ErrVaultLocked + } + return Decrypt(ciphertext, nonce, uek) + + default: + // Fallback: try env key (covers pre-migration or unknown scopes) + if r.envKey == nil { + return "", ErrNoEncryptionKey + } + return Decrypt(ciphertext, nonce, r.envKey) + } +} + +// EncryptForScope encrypts an API key with the appropriate key for the scope. +func (r *KeyResolver) EncryptForScope(plaintext, keyScope, userID string) ([]byte, []byte, error) { + if plaintext == "" { + return nil, nil, nil + } + + switch keyScope { + case "personal": + uek, ok := r.uekCache.Load(userID) + if !ok { + return nil, nil, ErrVaultLocked + } + return Encrypt(plaintext, uek) + + default: // global, team + if r.envKey == nil { + return nil, nil, ErrNoEncryptionKey + } + return Encrypt(plaintext, r.envKey) + } +} + +// HasEnvKey returns true if the platform encryption key is available. +func (r *KeyResolver) HasEnvKey() bool { + return r.envKey != nil +} diff --git a/server/crypto/vault.go b/server/crypto/vault.go new file mode 100644 index 0000000..b67bf68 --- /dev/null +++ b/server/crypto/vault.go @@ -0,0 +1,165 @@ +// Package crypto provides API key encryption primitives for Chat Switchboard. +// +// Two-tier model: +// +// - Global/Team keys: AES-256-GCM with a key derived from ENCRYPTION_KEY env var. +// - Personal (BYOK) keys: AES-256-GCM with a per-user encryption key (UEK). +// The UEK is itself encrypted with a key derived from the user's password +// via Argon2id. +// +// The UEK lives in server memory for the session duration and is never +// persisted in plaintext. +package crypto + +import ( + "crypto/aes" + "crypto/cipher" + "crypto/rand" + "crypto/sha256" + "errors" + "fmt" + "io" + + "golang.org/x/crypto/argon2" + "golang.org/x/crypto/hkdf" +) + +// Argon2id parameters — tuned for server-side derivation. +// Memory: 64 MiB, Iterations: 3, Parallelism: 4, Key length: 32 bytes. +const ( + argonMemory = 64 * 1024 // 64 MiB + argonTime = 3 + argonThreads = 4 + argonKeyLen = 32 + saltLen = 16 + nonceLen = 12 // AES-256-GCM standard nonce size + uekLen = 32 // 256-bit UEK +) + +var ( + ErrDecryptionFailed = errors.New("decryption failed: wrong key or corrupted data") + ErrVaultLocked = errors.New("vault locked: user encryption key not in session") + ErrNoEncryptionKey = errors.New("ENCRYPTION_KEY not set: cannot encrypt/decrypt platform keys") +) + +// --- Key Derivation --- + +// DeriveKeyFromPassword derives a 256-bit key from a password and salt using Argon2id. +// Used for wrapping/unwrapping the per-user UEK. +func DeriveKeyFromPassword(password string, salt []byte) []byte { + return argon2.IDKey([]byte(password), salt, argonTime, argonMemory, argonThreads, argonKeyLen) +} + +// DeriveKeyFromEnv derives a 256-bit key from an environment variable value +// using HKDF-SHA256 with domain separation. Deterministic — same input always +// produces the same key. +func DeriveKeyFromEnv(envValue string) ([]byte, error) { + if envValue == "" { + return nil, ErrNoEncryptionKey + } + // HKDF with SHA-256: extract + expand with context string for domain separation. + // No salt (nil) — the env value itself is the input keying material. + hkdfReader := hkdf.New(sha256.New, []byte(envValue), nil, []byte("switchboard-api-key-encryption-v1")) + key := make([]byte, 32) + if _, err := io.ReadFull(hkdfReader, key); err != nil { + return nil, fmt.Errorf("HKDF derivation failed: %w", err) + } + return key, nil +} + +// --- Salt / Nonce Generation --- + +// GenerateSalt returns a cryptographically random salt for Argon2id. +func GenerateSalt() ([]byte, error) { + salt := make([]byte, saltLen) + if _, err := rand.Read(salt); err != nil { + return nil, fmt.Errorf("failed to generate salt: %w", err) + } + return salt, nil +} + +// --- UEK Lifecycle --- + +// GenerateUEK creates a new random 256-bit User Encryption Key. +func GenerateUEK() ([]byte, error) { + uek := make([]byte, uekLen) + if _, err := rand.Read(uek); err != nil { + return nil, fmt.Errorf("failed to generate UEK: %w", err) + } + return uek, nil +} + +// WrapUEK encrypts a UEK with a password-derived key (PDK). +// Returns (ciphertext, nonce, error). +func WrapUEK(uek, pdk []byte) ([]byte, []byte, error) { + return encrypt(uek, pdk) +} + +// UnwrapUEK decrypts a UEK using the password-derived key. +func UnwrapUEK(ciphertext, nonce, pdk []byte) ([]byte, error) { + return decrypt(ciphertext, nonce, pdk) +} + +// --- API Key Encryption --- + +// Encrypt encrypts plaintext with the given 256-bit key using AES-256-GCM. +// Returns (ciphertext, nonce, error). +func Encrypt(plaintext string, key []byte) ([]byte, []byte, error) { + if plaintext == "" { + return nil, nil, nil // Nothing to encrypt + } + return encrypt([]byte(plaintext), key) +} + +// Decrypt decrypts ciphertext with the given 256-bit key and nonce. +func Decrypt(ciphertext, nonce, key []byte) (string, error) { + if len(ciphertext) == 0 { + return "", nil // Nothing to decrypt + } + plain, err := decrypt(ciphertext, nonce, key) + if err != nil { + return "", err + } + return string(plain), nil +} + +// --- Internal AES-256-GCM --- + +func encrypt(plaintext, key []byte) ([]byte, []byte, error) { + block, err := aes.NewCipher(key) + if err != nil { + return nil, nil, fmt.Errorf("aes.NewCipher: %w", err) + } + + gcm, err := cipher.NewGCM(block) + if err != nil { + return nil, nil, fmt.Errorf("cipher.NewGCM: %w", err) + } + + nonce := make([]byte, gcm.NonceSize()) + if _, err := rand.Read(nonce); err != nil { + return nil, nil, fmt.Errorf("nonce generation: %w", err) + } + + ciphertext := gcm.Seal(nil, nonce, plaintext, nil) + return ciphertext, nonce, nil +} + +func decrypt(ciphertext, nonce, key []byte) ([]byte, error) { + block, err := aes.NewCipher(key) + if err != nil { + return nil, fmt.Errorf("aes.NewCipher: %w", err) + } + + gcm, err := cipher.NewGCM(block) + if err != nil { + return nil, fmt.Errorf("cipher.NewGCM: %w", err) + } + + plaintext, err := gcm.Open(nil, nonce, ciphertext, nil) + if err != nil { + return nil, ErrDecryptionFailed + } + + return plaintext, nil +} diff --git a/server/crypto/vault_test.go b/server/crypto/vault_test.go new file mode 100644 index 0000000..8790d72 --- /dev/null +++ b/server/crypto/vault_test.go @@ -0,0 +1,241 @@ +package crypto + +import ( + "bytes" + "testing" +) + +func TestDeriveKeyFromPassword(t *testing.T) { + salt, err := GenerateSalt() + if err != nil { + t.Fatalf("GenerateSalt: %v", err) + } + + key1 := DeriveKeyFromPassword("hunter2", salt) + key2 := DeriveKeyFromPassword("hunter2", salt) + + if !bytes.Equal(key1, key2) { + t.Fatal("same password+salt should produce same key") + } + if len(key1) != 32 { + t.Fatalf("expected 32-byte key, got %d", len(key1)) + } + + // Different password → different key + key3 := DeriveKeyFromPassword("different", salt) + if bytes.Equal(key1, key3) { + t.Fatal("different passwords should produce different keys") + } + + // Different salt → different key + salt2, _ := GenerateSalt() + key4 := DeriveKeyFromPassword("hunter2", salt2) + if bytes.Equal(key1, key4) { + t.Fatal("different salts should produce different keys") + } +} + +func TestDeriveKeyFromEnv(t *testing.T) { + key1, err := DeriveKeyFromEnv("my-secret-key-123") + if err != nil { + t.Fatalf("DeriveKeyFromEnv: %v", err) + } + if len(key1) != 32 { + t.Fatalf("expected 32-byte key, got %d", len(key1)) + } + + // Deterministic + key2, _ := DeriveKeyFromEnv("my-secret-key-123") + if !bytes.Equal(key1, key2) { + t.Fatal("same input should produce same key") + } + + // Empty → error + _, err = DeriveKeyFromEnv("") + if err != ErrNoEncryptionKey { + t.Fatalf("expected ErrNoEncryptionKey, got %v", err) + } +} + +func TestUEKRoundTrip(t *testing.T) { + uek, err := GenerateUEK() + if err != nil { + t.Fatalf("GenerateUEK: %v", err) + } + if len(uek) != 32 { + t.Fatalf("expected 32-byte UEK, got %d", len(uek)) + } + + salt, _ := GenerateSalt() + pdk := DeriveKeyFromPassword("my-password", salt) + + ciphertext, nonce, err := WrapUEK(uek, pdk) + if err != nil { + t.Fatalf("WrapUEK: %v", err) + } + + recovered, err := UnwrapUEK(ciphertext, nonce, pdk) + if err != nil { + t.Fatalf("UnwrapUEK: %v", err) + } + + if !bytes.Equal(uek, recovered) { + t.Fatal("UEK round-trip mismatch") + } +} + +func TestUEKWrongPassword(t *testing.T) { + uek, _ := GenerateUEK() + salt, _ := GenerateSalt() + + pdk := DeriveKeyFromPassword("correct-password", salt) + ciphertext, nonce, _ := WrapUEK(uek, pdk) + + wrongPDK := DeriveKeyFromPassword("wrong-password", salt) + _, err := UnwrapUEK(ciphertext, nonce, wrongPDK) + if err != ErrDecryptionFailed { + t.Fatalf("expected ErrDecryptionFailed, got %v", err) + } +} + +func TestAPIKeyRoundTrip(t *testing.T) { + key, _ := DeriveKeyFromEnv("test-encryption-key") + apiKey := "sk-ant-api03-very-secret-key-1234567890" + + ciphertext, nonce, err := Encrypt(apiKey, key) + if err != nil { + t.Fatalf("Encrypt: %v", err) + } + + recovered, err := Decrypt(ciphertext, nonce, key) + if err != nil { + t.Fatalf("Decrypt: %v", err) + } + + if recovered != apiKey { + t.Fatalf("API key round-trip mismatch: got %q", recovered) + } +} + +func TestAPIKeyWrongKey(t *testing.T) { + key1, _ := DeriveKeyFromEnv("key-one") + key2, _ := DeriveKeyFromEnv("key-two") + + ciphertext, nonce, _ := Encrypt("secret-api-key", key1) + + _, err := Decrypt(ciphertext, nonce, key2) + if err != ErrDecryptionFailed { + t.Fatalf("expected ErrDecryptionFailed, got %v", err) + } +} + +func TestAPIKeyCorruption(t *testing.T) { + key, _ := DeriveKeyFromEnv("test-key") + ciphertext, nonce, _ := Encrypt("my-key", key) + + // Flip a byte in ciphertext + corrupted := make([]byte, len(ciphertext)) + copy(corrupted, ciphertext) + corrupted[0] ^= 0xff + + _, err := Decrypt(corrupted, nonce, key) + if err != ErrDecryptionFailed { + t.Fatalf("expected ErrDecryptionFailed for corrupted data, got %v", err) + } +} + +func TestEncryptEmpty(t *testing.T) { + key, _ := DeriveKeyFromEnv("test-key") + + ciphertext, nonce, err := Encrypt("", key) + if err != nil { + t.Fatalf("Encrypt empty: %v", err) + } + if ciphertext != nil || nonce != nil { + t.Fatal("encrypting empty string should return nil, nil") + } + + result, err := Decrypt(nil, nil, key) + if err != nil { + t.Fatalf("Decrypt nil: %v", err) + } + if result != "" { + t.Fatalf("decrypting nil should return empty string, got %q", result) + } +} + +func TestUEKUniqueness(t *testing.T) { + uek1, _ := GenerateUEK() + uek2, _ := GenerateUEK() + if bytes.Equal(uek1, uek2) { + t.Fatal("two generated UEKs should not be equal") + } +} + +func TestFullPersonalKeyFlow(t *testing.T) { + // Simulate: user registers → UEK generated → UEK wrapped with password + // → user logs in → UEK unwrapped → personal API key encrypted with UEK + // → completion request decrypts API key with UEK + + password := "user-secure-password-123" + apiKey := "sk-personal-byok-key-abc123" + + // Registration: generate UEK and wrap with password + uek, _ := GenerateUEK() + salt, _ := GenerateSalt() + pdk := DeriveKeyFromPassword(password, salt) + wrappedUEK, uekNonce, _ := WrapUEK(uek, pdk) + + // User adds a personal provider: encrypt API key with UEK + keyCiphertext, keyNonce, _ := Encrypt(apiKey, uek) + + // --- simulate session boundary --- + + // Login: derive PDK from password, unwrap UEK + loginPDK := DeriveKeyFromPassword(password, salt) + sessionUEK, err := UnwrapUEK(wrappedUEK, uekNonce, loginPDK) + if err != nil { + t.Fatalf("login UEK unwrap: %v", err) + } + + // Completion request: decrypt API key with session UEK + recovered, err := Decrypt(keyCiphertext, keyNonce, sessionUEK) + if err != nil { + t.Fatalf("API key decrypt: %v", err) + } + + if recovered != apiKey { + t.Fatalf("full flow mismatch: got %q", recovered) + } +} + +func TestPasswordChangePreservesKeys(t *testing.T) { + // UEK stays the same — only the wrapping changes + oldPassword := "old-password" + newPassword := "new-password" + apiKey := "sk-my-key" + + uek, _ := GenerateUEK() + salt, _ := GenerateSalt() + + // Wrap with old password + oldPDK := DeriveKeyFromPassword(oldPassword, salt) + _, _, _ = WrapUEK(uek, oldPDK) + + // Encrypt API key with UEK + keyCiphertext, keyNonce, _ := Encrypt(apiKey, uek) + + // Password change: re-wrap UEK with new password (new salt too) + newSalt, _ := GenerateSalt() + newPDK := DeriveKeyFromPassword(newPassword, newSalt) + newWrappedUEK, newUEKNonce, _ := WrapUEK(uek, newPDK) + + // Login with new password: unwrap UEK, decrypt API key + loginPDK := DeriveKeyFromPassword(newPassword, newSalt) + recoveredUEK, _ := UnwrapUEK(newWrappedUEK, newUEKNonce, loginPDK) + + recovered, _ := Decrypt(keyCiphertext, keyNonce, recoveredUEK) + if recovered != apiKey { + t.Fatalf("password change broke key access: got %q", recovered) + } +} diff --git a/server/database/migrations/003_vault.sql b/server/database/migrations/003_vault.sql new file mode 100644 index 0000000..bba289d --- /dev/null +++ b/server/database/migrations/003_vault.sql @@ -0,0 +1,48 @@ +-- 003_vault.sql — API Key Encryption + User Vault +-- v0.9.4: Two-tier encryption for API keys +-- +-- Phase 1 (this migration): Add new columns alongside existing plaintext. +-- Phase 2 (Go startup): Backfill — encrypt plaintext keys, generate UEKs. +-- Phase 3 (future): Drop api_key_plain after confirmed stable. + +-- ========================================= +-- 1. USERS — Vault support +-- ========================================= +-- Per-user encryption key (UEK) wrapped with password-derived key (Argon2id). +-- UEK protects personal BYOK API keys. Platform admin cannot recover without +-- the user's password. + +ALTER TABLE users ADD COLUMN IF NOT EXISTS encrypted_uek BYTEA; +ALTER TABLE users ADD COLUMN IF NOT EXISTS uek_salt BYTEA; +ALTER TABLE users ADD COLUMN IF NOT EXISTS uek_nonce BYTEA; +ALTER TABLE users ADD COLUMN IF NOT EXISTS vault_set BOOLEAN NOT NULL DEFAULT false; + +COMMENT ON COLUMN users.encrypted_uek IS 'UEK encrypted with Argon2id(password)-derived key'; +COMMENT ON COLUMN users.uek_salt IS 'Argon2id salt for UEK derivation'; +COMMENT ON COLUMN users.uek_nonce IS 'AES-GCM nonce for UEK wrapping'; +COMMENT ON COLUMN users.vault_set IS 'true once UEK has been generated and wrapped'; + +-- ========================================= +-- 2. PROVIDER_CONFIGS — Encrypted key storage +-- ========================================= +-- Rename current plaintext column, add encrypted BYTEA columns. +-- Backfill happens in Go (needs ENCRYPTION_KEY env var). + +-- Keep plaintext temporarily for backfill; Go startup reads it, encrypts, +-- writes to new columns, then NULLs it out. +ALTER TABLE provider_configs RENAME COLUMN api_key_enc TO api_key_plain; +ALTER TABLE provider_configs ADD COLUMN IF NOT EXISTS api_key_enc BYTEA; +ALTER TABLE provider_configs ADD COLUMN IF NOT EXISTS key_nonce BYTEA; +ALTER TABLE provider_configs ADD COLUMN IF NOT EXISTS key_scope TEXT; + +-- Backfill key_scope from existing scope column (safe default) +UPDATE provider_configs SET key_scope = scope WHERE key_scope IS NULL; + +-- Now make it NOT NULL with default +ALTER TABLE provider_configs ALTER COLUMN key_scope SET NOT NULL; +ALTER TABLE provider_configs ALTER COLUMN key_scope SET DEFAULT 'global'; + +COMMENT ON COLUMN provider_configs.api_key_plain IS 'DEPRECATED: plaintext key, cleared after vault backfill'; +COMMENT ON COLUMN provider_configs.api_key_enc IS 'AES-256-GCM encrypted API key (BYTEA)'; +COMMENT ON COLUMN provider_configs.key_nonce IS 'AES-GCM nonce for api_key_enc'; +COMMENT ON COLUMN provider_configs.key_scope IS 'Encryption tier: global/team use env key, personal uses UEK'; diff --git a/server/handlers/admin.go b/server/handlers/admin.go index 9964722..fe83b7a 100644 --- a/server/handlers/admin.go +++ b/server/handlers/admin.go @@ -3,6 +3,7 @@ package handlers import ( "context" "encoding/json" + "fmt" "log" "net/http" "strconv" @@ -11,6 +12,7 @@ import ( "github.com/gin-gonic/gin" "golang.org/x/crypto/bcrypt" + "git.gobha.me/xcaliber/chat-switchboard/crypto" "git.gobha.me/xcaliber/chat-switchboard/database" "git.gobha.me/xcaliber/chat-switchboard/models" "git.gobha.me/xcaliber/chat-switchboard/providers" @@ -19,10 +21,11 @@ import ( type AdminHandler struct { stores store.Stores + vault *crypto.KeyResolver } -func NewAdminHandler(s store.Stores) *AdminHandler { - return &AdminHandler{stores: s} +func NewAdminHandler(s store.Stores, vault *crypto.KeyResolver) *AdminHandler { + return &AdminHandler{stores: s, vault: vault} } // ── User Management ───────────────────────── @@ -254,7 +257,7 @@ func (h *AdminHandler) ListGlobalConfigs(c *gin.Context) { for i, cfg := range cfgs { out[i] = configWithKey{ ProviderConfig: cfg, - HasKey: cfg.APIKeyEnc != "", + HasKey: cfg.HasKey(), } } c.JSON(http.StatusOK, gin.H{"configs": out}) @@ -291,16 +294,32 @@ func (h *AdminHandler) CreateGlobalConfig(c *gin.Context) { Name: req.Name, Provider: req.Provider, Endpoint: req.Endpoint, - APIKeyEnc: req.APIKey, // TODO: encrypt ModelDefault: req.ModelDefault, Config: models.JSONMap(req.Config), Headers: models.JSONMap(req.Headers), Settings: models.JSONMap(req.Settings), Scope: models.ScopeGlobal, + KeyScope: models.ScopeGlobal, IsActive: true, IsPrivate: req.IsPrivate, } + // Encrypt the API key + if req.APIKey != "" { + if h.vault != nil { + enc, nonce, err := h.vault.EncryptForScope(req.APIKey, "global", "") + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to encrypt API key"}) + return + } + cfg.APIKeyEnc = enc + cfg.KeyNonce = nonce + } else { + // No vault configured — store raw bytes (unencrypted fallback) + cfg.APIKeyEnc = []byte(req.APIKey) + } + } + if err := h.stores.Providers.Create(c.Request.Context(), cfg); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create config"}) return @@ -319,7 +338,7 @@ func (h *AdminHandler) CreateGlobalConfig(c *gin.Context) { "settings": cfg.Settings, "is_active": cfg.IsActive, "is_private": cfg.IsPrivate, - "has_key": cfg.APIKeyEnc != "", + "has_key": cfg.HasKey(), "created_at": cfg.CreatedAt, "updated_at": cfg.UpdatedAt, }) @@ -339,9 +358,19 @@ func (h *AdminHandler) UpdateGlobalConfig(c *gin.Context) { } patch := req.ProviderConfigPatch - // Transfer api_key → APIKeyEnc (json:"-" prevents auto-binding) + // Transfer api_key → encrypted fields if req.APIKey != nil && *req.APIKey != "" { - patch.APIKeyEnc = req.APIKey // TODO: encrypt + if h.vault != nil { + enc, nonce, err := h.vault.EncryptForScope(*req.APIKey, "global", "") + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to encrypt API key"}) + return + } + patch.APIKeyEnc = enc + patch.KeyNonce = nonce + } else { + patch.APIKeyEnc = []byte(*req.APIKey) + } } if err := h.stores.Providers.Update(c.Request.Context(), id, patch); err != nil { @@ -446,7 +475,21 @@ func (h *AdminHandler) FetchModels(c *gin.Context) { // fetchModelsForProvider fetches and syncs models for a single provider config. func (h *AdminHandler) fetchModelsForProvider(c *gin.Context, cfg *models.ProviderConfig) (added, updated, total int, err error) { - result, err := syncProviderModels(c.Request.Context(), h.stores, cfg) + // Decrypt the API key for the provider API call + apiKey := "" + if len(cfg.APIKeyEnc) > 0 { + if h.vault != nil { + apiKey, err = h.vault.Decrypt(cfg.APIKeyEnc, cfg.KeyNonce, cfg.KeyScope, "") + if err != nil { + return 0, 0, 0, fmt.Errorf("failed to decrypt API key: %w", err) + } + } else { + // No vault — key stored as raw bytes (unencrypted fallback) + apiKey = string(cfg.APIKeyEnc) + } + } + + result, err := syncProviderModels(c.Request.Context(), h.stores, cfg, apiKey) if err != nil { return 0, 0, 0, err } diff --git a/server/handlers/apiconfigs.go b/server/handlers/apiconfigs.go index fa5d2d2..2a82740 100644 --- a/server/handlers/apiconfigs.go +++ b/server/handlers/apiconfigs.go @@ -6,6 +6,7 @@ import ( "github.com/gin-gonic/gin" + "git.gobha.me/xcaliber/chat-switchboard/crypto" "git.gobha.me/xcaliber/chat-switchboard/models" "git.gobha.me/xcaliber/chat-switchboard/store" ) @@ -13,10 +14,11 @@ import ( // ProviderConfigHandler handles user-facing provider config endpoints. type ProviderConfigHandler struct { stores store.Stores + vault *crypto.KeyResolver } -func NewProviderConfigHandler(s store.Stores) *ProviderConfigHandler { - return &ProviderConfigHandler{stores: s} +func NewProviderConfigHandler(s store.Stores, vault *crypto.KeyResolver) *ProviderConfigHandler { + return &ProviderConfigHandler{stores: s, vault: vault} } // ListConfigs returns configs accessible to the user (global + personal + team). @@ -52,7 +54,7 @@ func (h *ProviderConfigHandler) ListConfigs(c *gin.Context) { Name: cfg.Name, Provider: cfg.Provider, Endpoint: cfg.Endpoint, - HasKey: cfg.APIKeyEnc != "", + HasKey: cfg.HasKey(), ModelDefault: cfg.ModelDefault, Scope: cfg.Scope, IsActive: cfg.IsActive, @@ -111,13 +113,32 @@ func (h *ProviderConfigHandler) CreateConfig(c *gin.Context) { Name: req.Name, Provider: req.Provider, Endpoint: req.Endpoint, - APIKeyEnc: req.APIKey, // TODO: encrypt ModelDefault: req.ModelDefault, Scope: models.ScopePersonal, + KeyScope: models.ScopePersonal, OwnerID: &userID, IsActive: true, } + // Encrypt the API key with user's UEK (personal scope) + if req.APIKey != "" { + if h.vault != nil { + enc, nonce, err := h.vault.EncryptForScope(req.APIKey, "personal", userID) + if err != nil { + if err == crypto.ErrVaultLocked { + c.JSON(http.StatusUnauthorized, gin.H{"error": "vault is locked — please log in again"}) + return + } + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to encrypt API key"}) + return + } + cfg.APIKeyEnc = enc + cfg.KeyNonce = nonce + } else { + cfg.APIKeyEnc = []byte(req.APIKey) + } + } + if err := h.stores.Providers.Create(c.Request.Context(), cfg); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create config"}) return @@ -133,7 +154,7 @@ func (h *ProviderConfigHandler) CreateConfig(c *gin.Context) { "scope": cfg.Scope, } - result, err := syncAndEnableProviderModels(c.Request.Context(), h.stores, cfg) + result, err := syncAndEnableProviderModels(c.Request.Context(), h.stores, cfg, req.APIKey) if err != nil { // Provider created successfully but model fetch failed. // Return 201 (provider exists) with a warning so the frontend can show it. @@ -169,9 +190,23 @@ func (h *ProviderConfigHandler) UpdateConfig(c *gin.Context) { } patch := req.ProviderConfigPatch - // Transfer api_key → APIKeyEnc (json:"-" prevents auto-binding) + // Transfer api_key → encrypted fields if req.APIKey != nil && *req.APIKey != "" { - patch.APIKeyEnc = req.APIKey // TODO: encrypt + if h.vault != nil { + enc, nonce, err := h.vault.EncryptForScope(*req.APIKey, "personal", userID) + if err != nil { + if err == crypto.ErrVaultLocked { + c.JSON(http.StatusUnauthorized, gin.H{"error": "vault is locked — please log in again"}) + return + } + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to encrypt API key"}) + return + } + patch.APIKeyEnc = enc + patch.KeyNonce = nonce + } else { + patch.APIKeyEnc = []byte(*req.APIKey) + } } if err := h.stores.Providers.Update(c.Request.Context(), id, patch); err != nil { @@ -228,7 +263,21 @@ func (h *ProviderConfigHandler) FetchModels(c *gin.Context) { return } - result, err := syncAndEnableProviderModels(c.Request.Context(), h.stores, cfg) + // Decrypt the API key for the provider API call + apiKey := "" + if len(cfg.APIKeyEnc) > 0 { + if h.vault != nil { + apiKey, err = h.vault.Decrypt(cfg.APIKeyEnc, cfg.KeyNonce, cfg.KeyScope, userID) + if err != nil { + c.JSON(http.StatusUnauthorized, gin.H{"error": "vault is locked — please log in again"}) + return + } + } else { + apiKey = string(cfg.APIKeyEnc) + } + } + + result, err := syncAndEnableProviderModels(c.Request.Context(), h.stores, cfg, apiKey) if err != nil { c.JSON(http.StatusBadGateway, gin.H{"error": "failed to fetch models: " + err.Error()}) return diff --git a/server/handlers/auth.go b/server/handlers/auth.go index 38fbb9e..42cb5c3 100644 --- a/server/handlers/auth.go +++ b/server/handlers/auth.go @@ -15,6 +15,8 @@ import ( "golang.org/x/crypto/bcrypt" "git.gobha.me/xcaliber/chat-switchboard/config" + "git.gobha.me/xcaliber/chat-switchboard/crypto" + "git.gobha.me/xcaliber/chat-switchboard/database" "git.gobha.me/xcaliber/chat-switchboard/models" "git.gobha.me/xcaliber/chat-switchboard/store" ) @@ -32,12 +34,13 @@ type Claims struct { } type AuthHandler struct { - cfg *config.Config - stores store.Stores + cfg *config.Config + stores store.Stores + uekCache *crypto.UEKCache } -func NewAuthHandler(cfg *config.Config, s store.Stores) *AuthHandler { - return &AuthHandler{cfg: cfg, stores: s} +func NewAuthHandler(cfg *config.Config, s store.Stores, uekCache *crypto.UEKCache) *AuthHandler { + return &AuthHandler{cfg: cfg, stores: s, uekCache: uekCache} } func (h *AuthHandler) Register(c *gin.Context) { @@ -90,6 +93,12 @@ func (h *AuthHandler) Register(c *gin.Context) { return } + // Generate and store the User Encryption Key (vault) + if err := h.initVault(c.Request.Context(), user.ID, req.Password); err != nil { + log.Printf("⚠ Failed to init vault for user %s: %v", user.ID, err) + // Non-fatal: user can still use the app, vault will init on next login + } + if !user.IsActive { c.JSON(http.StatusCreated, gin.H{ "message": "Account created but requires admin approval", @@ -133,6 +142,9 @@ func (h *AuthHandler) Login(c *gin.Context) { return } + // Vault: unwrap UEK into session cache (or init if first login post-migration) + h.unlockVault(c.Request.Context(), user, req.Password) + h.stores.Users.UpdateLastLogin(c.Request.Context(), user.ID) tokens, err := h.generateTokens(user) @@ -189,6 +201,11 @@ func (h *AuthHandler) Logout(c *gin.Context) { h.stores.Users.RevokeRefreshToken(c.Request.Context(), tokenHash) } + // Evict UEK from session cache + if userID, exists := c.Get("user_id"); exists { + h.uekCache.Evict(userID.(string)) + } + c.JSON(http.StatusOK, gin.H{"message": "logged out"}) } @@ -239,6 +256,85 @@ func hashToken(token string) string { return hex.EncodeToString(h[:]) } +// ── Vault Lifecycle ───────────────────────── + +// initVault generates a UEK, wraps it with the user's password, and stores +// the encrypted UEK + salt + nonce on the user record. Called on registration +// and on first login for pre-migration users. +func (h *AuthHandler) initVault(ctx context.Context, userID, password string) error { + if h.uekCache == nil { + return nil // Vault not configured (e.g. tests) + } + + uek, err := crypto.GenerateUEK() + if err != nil { + return err + } + + salt, err := crypto.GenerateSalt() + if err != nil { + return err + } + + pdk := crypto.DeriveKeyFromPassword(password, salt) + encryptedUEK, nonce, err := crypto.WrapUEK(uek, pdk) + if err != nil { + return err + } + + _, err = database.DB.ExecContext(ctx, ` + UPDATE users + SET encrypted_uek = $1, uek_salt = $2, uek_nonce = $3, vault_set = true + WHERE id = $4 + `, encryptedUEK, salt, nonce, userID) + if err != nil { + return err + } + + // Cache the UEK for the session + h.uekCache.Store(userID, uek) + log.Printf(" 🔐 Vault initialized for user %s", userID) + return nil +} + +// unlockVault unwraps the UEK on login and caches it. If the user doesn't +// have a vault yet (pre-migration account), initializes one. +func (h *AuthHandler) unlockVault(ctx context.Context, user *models.User, password string) { + if h.uekCache == nil { + return + } + + var vaultSet bool + var encryptedUEK, salt, nonce []byte + + err := database.DB.QueryRowContext(ctx, ` + SELECT vault_set, encrypted_uek, uek_salt, uek_nonce + FROM users WHERE id = $1 + `, user.ID).Scan(&vaultSet, &encryptedUEK, &salt, &nonce) + if err != nil { + log.Printf("⚠ Vault query failed for user %s: %v", user.ID, err) + return + } + + if !vaultSet { + // Pre-migration user: initialize vault on first login + if err := h.initVault(ctx, user.ID, password); err != nil { + log.Printf("⚠ Vault init on login failed for user %s: %v", user.ID, err) + } + return + } + + // Derive PDK from password and unwrap UEK + pdk := crypto.DeriveKeyFromPassword(password, salt) + uek, err := crypto.UnwrapUEK(encryptedUEK, nonce, pdk) + if err != nil { + log.Printf("⚠ Vault unlock failed for user %s: %v", user.ID, err) + return + } + + h.uekCache.Store(user.ID, uek) +} + // BootstrapAdmin creates/updates the admin user from env vars (K8s secret). func BootstrapAdmin(cfg *config.Config, s store.Stores) { if cfg.AdminUsername == "" || cfg.AdminPassword == "" { diff --git a/server/handlers/auth_test.go b/server/handlers/auth_test.go index f52f6f9..b9a2dea 100644 --- a/server/handlers/auth_test.go +++ b/server/handlers/auth_test.go @@ -26,7 +26,7 @@ func testConfig() *config.Config { // testAuthHandler creates an AuthHandler with nil stores (safe for non-DB tests). func testAuthHandler() *AuthHandler { - return NewAuthHandler(testConfig(), store.Stores{}) + return NewAuthHandler(testConfig(), store.Stores{}, nil) } // ── JWT Token Tests ───────────────────────── diff --git a/server/handlers/completion.go b/server/handlers/completion.go index f1006bd..74e64b8 100644 --- a/server/handlers/completion.go +++ b/server/handlers/completion.go @@ -13,6 +13,7 @@ import ( "git.gobha.me/xcaliber/chat-switchboard/database" "git.gobha.me/xcaliber/chat-switchboard/models" "git.gobha.me/xcaliber/chat-switchboard/providers" + "git.gobha.me/xcaliber/chat-switchboard/crypto" capspkg "git.gobha.me/xcaliber/chat-switchboard/capabilities" "git.gobha.me/xcaliber/chat-switchboard/tools" ) @@ -33,11 +34,13 @@ type completionRequest struct { } // CompletionHandler proxies LLM requests through the backend. -type CompletionHandler struct{} +type CompletionHandler struct{ + vault *crypto.KeyResolver +} // NewCompletionHandler creates a new handler. -func NewCompletionHandler() *CompletionHandler { - return &CompletionHandler{} +func NewCompletionHandler(vault *crypto.KeyResolver) *CompletionHandler { + return &CompletionHandler{vault: vault} } // ── Chat Completion ───────────────────────── @@ -387,16 +390,20 @@ func (h *CompletionHandler) resolveConfig(userID string, channelID string, req c // Load the config — allow personal, global, OR team configs the user belongs to var providerID, endpoint string - var apiKey, modelDefault *string + var modelDefault *string + var apiKeyEnc, keyNonce []byte + var keyScope string var customHeadersJSON, providerSettingsJSON []byte err := database.DB.QueryRow(` - SELECT provider, endpoint, api_key_enc, model_default, headers, settings + SELECT provider, endpoint, api_key_enc, key_nonce, key_scope, + model_default, headers, settings FROM provider_configs WHERE id = $1 AND is_active = true AND (scope = 'global' OR (scope = 'personal' AND owner_id = $2) OR (scope = 'team' AND owner_id IN (SELECT team_id FROM team_members WHERE user_id = $2))) - `, configID, userID).Scan(&providerID, &endpoint, &apiKey, &modelDefault, &customHeadersJSON, &providerSettingsJSON) + `, configID, userID).Scan(&providerID, &endpoint, &apiKeyEnc, &keyNonce, &keyScope, + &modelDefault, &customHeadersJSON, &providerSettingsJSON) if err == sql.ErrNoRows { return providers.ProviderConfig{}, "", "", "", fmt.Errorf("API config not found or not accessible") @@ -414,9 +421,22 @@ func (h *CompletionHandler) resolveConfig(userID string, channelID string, req c return providers.ProviderConfig{}, "", "", "", fmt.Errorf("no model specified and no default model in config") } + // Decrypt API key using the appropriate tier key := "" - if apiKey != nil { - key = *apiKey + if len(apiKeyEnc) > 0 { + if h.vault != nil { + var err error + key, err = h.vault.Decrypt(apiKeyEnc, keyNonce, keyScope, userID) + if err != nil { + if err == crypto.ErrVaultLocked { + return providers.ProviderConfig{}, "", "", "", fmt.Errorf("personal vault is locked — please log in again") + } + return providers.ProviderConfig{}, "", "", "", fmt.Errorf("failed to decrypt API key: %w", err) + } + } else { + // No vault — key stored as raw bytes (unencrypted fallback) + key = string(apiKeyEnc) + } } // Parse custom headers diff --git a/server/handlers/integration_test.go b/server/handlers/integration_test.go index e6f9171..8b9d7c9 100644 --- a/server/handlers/integration_test.go +++ b/server/handlers/integration_test.go @@ -46,13 +46,13 @@ func setupHarness(t *testing.T) *testHarness { api := r.Group("/api/v1") // Auth (unprotected) - auth := NewAuthHandler(cfg, stores) + auth := NewAuthHandler(cfg, stores, nil) authGroup := api.Group("/auth") authGroup.POST("/register", auth.Register) authGroup.POST("/login", auth.Login) // Public settings - adm := NewAdminHandler(stores) + adm := NewAdminHandler(stores, nil) api.GET("/settings/public", adm.PublicSettings) // Protected routes @@ -70,7 +70,7 @@ func setupHarness(t *testing.T) *testHarness { protected.POST("/models/preferences/bulk", modelPrefs.BulkSetPreferences) // User providers - provCfg := NewProviderConfigHandler(stores) + provCfg := NewProviderConfigHandler(stores, nil) protected.GET("/api-configs", provCfg.ListConfigs) protected.POST("/api-configs", provCfg.CreateConfig) protected.GET("/api-configs/:id", provCfg.GetConfig) @@ -80,7 +80,7 @@ func setupHarness(t *testing.T) *testHarness { protected.POST("/api-configs/:id/models/fetch", provCfg.FetchModels) // Team self-service (same route group as production) - teams := NewTeamHandler() + teams := NewTeamHandler(nil) protected.GET("/teams/mine", teams.MyTeams) teamScoped := protected.Group("/teams/:teamId") @@ -116,7 +116,7 @@ func setupHarness(t *testing.T) *testHarness { protected.POST("/channels", channels.CreateChannel) // Completions - completions := NewCompletionHandler() + completions := NewCompletionHandler(nil) protected.POST("/chat/completions", completions.Complete) // Admin routes diff --git a/server/handlers/messages.go b/server/handlers/messages.go index 497f36b..e03a253 100644 --- a/server/handlers/messages.go +++ b/server/handlers/messages.go @@ -11,6 +11,7 @@ import ( "github.com/gin-gonic/gin" capspkg "git.gobha.me/xcaliber/chat-switchboard/capabilities" + "git.gobha.me/xcaliber/chat-switchboard/crypto" "git.gobha.me/xcaliber/chat-switchboard/database" "git.gobha.me/xcaliber/chat-switchboard/providers" "git.gobha.me/xcaliber/chat-switchboard/tools" @@ -54,11 +55,13 @@ type cursorRequest struct { } // MessageHandler holds dependencies for message endpoints. -type MessageHandler struct{} +type MessageHandler struct{ + vault *crypto.KeyResolver +} // NewMessageHandler creates a new message handler. -func NewMessageHandler() *MessageHandler { - return &MessageHandler{} +func NewMessageHandler(vault *crypto.KeyResolver) *MessageHandler { + return &MessageHandler{vault: vault} } // ── List Messages (flat, all branches) ────── @@ -353,7 +356,7 @@ func (h *MessageHandler) Regenerate(c *gin.Context) { // ── Resolve model + provider ── - comp := NewCompletionHandler() + comp := NewCompletionHandler(h.vault) var presetSystemPrompt string model := req.Model diff --git a/server/handlers/model_sync.go b/server/handlers/model_sync.go index 8bb2e76..875e116 100644 --- a/server/handlers/model_sync.go +++ b/server/handlers/model_sync.go @@ -20,7 +20,8 @@ type syncResult struct { // syncProviderModels fetches models from a provider's API and syncs them into the catalog. // New models default to 'disabled' visibility (admin must explicitly enable for global providers). -func syncProviderModels(ctx context.Context, stores store.Stores, cfg *models.ProviderConfig) (syncResult, error) { +// apiKeyPlain is the decrypted API key — callers are responsible for decryption. +func syncProviderModels(ctx context.Context, stores store.Stores, cfg *models.ProviderConfig, apiKeyPlain string) (syncResult, error) { prov, err := providers.Get(cfg.Provider) if err != nil { return syncResult{}, fmt.Errorf("unknown provider type: %s", cfg.Provider) @@ -28,7 +29,7 @@ func syncProviderModels(ctx context.Context, stores store.Stores, cfg *models.Pr provCfg := providers.ProviderConfig{ Endpoint: cfg.Endpoint, - APIKey: cfg.APIKeyEnc, + APIKey: apiKeyPlain, } if cfg.Headers != nil { @@ -80,8 +81,8 @@ func syncProviderModels(ctx context.Context, stores store.Stores, cfg *models.Pr // syncAndEnableProviderModels fetches models and auto-enables them all. // Used for personal (BYOK) providers — the user explicitly added this provider to use it. -func syncAndEnableProviderModels(ctx context.Context, stores store.Stores, cfg *models.ProviderConfig) (syncResult, error) { - result, err := syncProviderModels(ctx, stores, cfg) +func syncAndEnableProviderModels(ctx context.Context, stores store.Stores, cfg *models.ProviderConfig, apiKeyPlain string) (syncResult, error) { + result, err := syncProviderModels(ctx, stores, cfg, apiKeyPlain) if err != nil { return result, err } diff --git a/server/handlers/team_providers.go b/server/handlers/team_providers.go index fbc8280..0f1d9da 100644 --- a/server/handlers/team_providers.go +++ b/server/handlers/team_providers.go @@ -50,13 +50,13 @@ func (h *TeamHandler) ListTeamProviders(c *gin.Context) { configs := make([]teamProvider, 0) for rows.Next() { var p teamProvider - var apiKeyEnc *string + var apiKeyEnc []byte var configRaw string if err := rows.Scan(&p.ID, &p.Name, &p.Provider, &p.Endpoint, &apiKeyEnc, &p.ModelDefault, &configRaw, &p.IsActive, &p.IsPrivate, &p.CreatedAt, &p.UpdatedAt); err != nil { continue } - p.HasKey = apiKeyEnc != nil && *apiKeyEnc != "" + p.HasKey = len(apiKeyEnc) > 0 p.Config = parseJSONBConfig(configRaw) configs = append(configs, p) } @@ -104,12 +104,29 @@ func (h *TeamHandler) CreateTeamProvider(c *gin.Context) { configJSON = string(b) } + // Encrypt the API key for team scope + var apiKeyEnc, keyNonce []byte + if req.APIKey != "" { + if h.vault != nil { + var err error + apiKeyEnc, keyNonce, err = h.vault.EncryptForScope(req.APIKey, "team", "") + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to encrypt API key"}) + return + } + } else { + apiKeyEnc = []byte(req.APIKey) + } + } + var id string err := database.DB.QueryRow(` - INSERT INTO provider_configs (scope, owner_id, name, provider, endpoint, api_key_enc, model_default, config, is_private) - VALUES ('team', $1, $2, $3, $4, $5, $6, $7::jsonb, $8) + INSERT INTO provider_configs (scope, owner_id, name, provider, endpoint, + api_key_enc, key_nonce, key_scope, model_default, config, is_private) + VALUES ('team', $1, $2, $3, $4, $5, $6, 'team', $7, $8::jsonb, $9) RETURNING id - `, teamID, req.Name, req.Provider, req.Endpoint, req.APIKey, req.ModelDefault, configJSON, req.IsPrivate, + `, teamID, req.Name, req.Provider, req.Endpoint, + apiKeyEnc, keyNonce, req.ModelDefault, configJSON, req.IsPrivate, ).Scan(&id) if err != nil { log.Printf("[WARN] Failed to create team provider: %v", err) @@ -162,10 +179,24 @@ func (h *TeamHandler) UpdateTeamProvider(c *gin.Context) { args = append(args, *req.Endpoint) argN++ } - if req.APIKey != nil { - query += ", api_key_enc = $" + strconv.Itoa(argN) - args = append(args, *req.APIKey) - argN++ + if req.APIKey != nil && *req.APIKey != "" { + if h.vault != nil { + enc, nonce, err := h.vault.EncryptForScope(*req.APIKey, "team", "") + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to encrypt API key"}) + return + } + query += ", api_key_enc = $" + strconv.Itoa(argN) + args = append(args, enc) + argN++ + query += ", key_nonce = $" + strconv.Itoa(argN) + args = append(args, nonce) + argN++ + } else { + query += ", api_key_enc = $" + strconv.Itoa(argN) + args = append(args, []byte(*req.APIKey)) + argN++ + } } if req.ModelDefault != nil { query += ", model_default = $" + strconv.Itoa(argN) @@ -229,13 +260,14 @@ func (h *TeamHandler) ListTeamProviderModels(c *gin.Context) { providerID := c.Param("id") var name, providerType, endpoint string - var apiKey *string + var apiKeyEnc, keyNonce []byte + var keyScope string var headersJSON []byte err := database.DB.QueryRow(` - SELECT name, provider, endpoint, api_key_enc, headers + SELECT name, provider, endpoint, api_key_enc, key_nonce, key_scope, headers FROM provider_configs WHERE id = $1 AND scope = 'team' AND owner_id = $2 AND is_active = true - `, providerID, teamID).Scan(&name, &providerType, &endpoint, &apiKey, &headersJSON) + `, providerID, teamID).Scan(&name, &providerType, &endpoint, &apiKeyEnc, &keyNonce, &keyScope, &headersJSON) if err != nil { c.JSON(http.StatusNotFound, gin.H{"error": "provider not found"}) return @@ -248,8 +280,17 @@ func (h *TeamHandler) ListTeamProviderModels(c *gin.Context) { } key := "" - if apiKey != nil { - key = *apiKey + if len(apiKeyEnc) > 0 { + if h.vault != nil { + var err error + key, err = h.vault.Decrypt(apiKeyEnc, keyNonce, keyScope, "") + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to decrypt API key"}) + return + } + } else { + key = string(apiKeyEnc) + } } var customHeaders map[string]string diff --git a/server/handlers/teams.go b/server/handlers/teams.go index 45f5324..1f65293 100644 --- a/server/handlers/teams.go +++ b/server/handlers/teams.go @@ -11,6 +11,7 @@ import ( "github.com/gin-gonic/gin" + "git.gobha.me/xcaliber/chat-switchboard/crypto" "git.gobha.me/xcaliber/chat-switchboard/database" "git.gobha.me/xcaliber/chat-switchboard/providers" ) @@ -40,9 +41,13 @@ type updateMemberRequest struct { // ── Handler ───────────────────────────────── -type TeamHandler struct{} +type TeamHandler struct{ + vault *crypto.KeyResolver +} -func NewTeamHandler() *TeamHandler { return &TeamHandler{} } +func NewTeamHandler(vault *crypto.KeyResolver) *TeamHandler { + return &TeamHandler{vault: vault} +} // ── Admin: List All Teams ─────────────────── diff --git a/server/main.go b/server/main.go index 99232cf..c7ced14 100644 --- a/server/main.go +++ b/server/main.go @@ -6,6 +6,7 @@ import ( "github.com/gin-gonic/gin" "git.gobha.me/xcaliber/chat-switchboard/config" + "git.gobha.me/xcaliber/chat-switchboard/crypto" "git.gobha.me/xcaliber/chat-switchboard/database" "git.gobha.me/xcaliber/chat-switchboard/events" "git.gobha.me/xcaliber/chat-switchboard/handlers" @@ -23,6 +24,8 @@ func main() { providers.Init() var stores store.Stores + uekCache := crypto.NewUEKCache() + var keyResolver *crypto.KeyResolver if err := database.Connect(cfg); err != nil { log.Printf("⚠ Database unavailable: %v", err) @@ -33,6 +36,28 @@ func main() { log.Fatalf("❌ Schema migration failed: %v", err) } + // Vault: enforce encryption key + backfill plaintext keys + if err := crypto.EnforceEncryptionKey(database.DB, cfg.EncryptionKey); err != nil { + log.Fatalf("❌ Vault check failed: %v", err) + } + if cfg.EncryptionKey != "" { + if err := crypto.BackfillEncryptedKeys(database.DB, cfg.EncryptionKey); err != nil { + log.Fatalf("❌ Vault backfill failed: %v", err) + } + } + + // Derive env key for key resolver (nil if not set) + var envKey []byte + if cfg.EncryptionKey != "" { + var err error + envKey, err = crypto.DeriveKeyFromEnv(cfg.EncryptionKey) + if err != nil { + log.Fatalf("❌ Failed to derive encryption key: %v", err) + } + log.Println(" 🔐 API key encryption active") + } + keyResolver = crypto.NewKeyResolver(envKey, uekCache) + // Initialize store layer stores = postgres.NewStores(database.DB) @@ -68,7 +93,7 @@ func main() { base.GET("/ws", middleware.Auth(cfg), hub.HandleWebSocket) // ── Auth routes (rate limited) ────────────── - auth := handlers.NewAuthHandler(cfg, stores) + auth := handlers.NewAuthHandler(cfg, stores, uekCache) authLimiter := middleware.NewRateLimiter(1, 5) api := base.Group("/api/v1") @@ -110,7 +135,7 @@ func main() { protected.DELETE("/channels/:id", channels.DeleteChannel) // Messages - msgs := handlers.NewMessageHandler() + msgs := handlers.NewMessageHandler(keyResolver) protected.GET("/channels/:id/messages", msgs.ListMessages) protected.POST("/channels/:id/messages", msgs.CreateMessage) @@ -122,11 +147,11 @@ func main() { protected.GET("/channels/:id/messages/:msgId/siblings", msgs.ListSiblings) // Chat Completions - comp := handlers.NewCompletionHandler() + comp := handlers.NewCompletionHandler(keyResolver) protected.POST("/chat/completions", comp.Complete) // Provider Configs (user-facing — replaces /api-configs) - provCfg := handlers.NewProviderConfigHandler(stores) + provCfg := handlers.NewProviderConfigHandler(stores, keyResolver) protected.GET("/api-configs", provCfg.ListConfigs) // backward compat protected.POST("/api-configs", provCfg.CreateConfig) protected.GET("/api-configs/:id", provCfg.GetConfig) @@ -177,7 +202,7 @@ func main() { protected.DELETE("/notes/:id", notes.Delete) // Teams (user: my teams) - teams := handlers.NewTeamHandler() + teams := handlers.NewTeamHandler(keyResolver) protected.GET("/teams/mine", teams.MyTeams) // Team admin self-service @@ -209,7 +234,7 @@ func main() { } // Public global settings (non-admin users can read safe subset) - adm := handlers.NewAdminHandler(stores) + adm := handlers.NewAdminHandler(stores, keyResolver) protected.GET("/settings/public", adm.PublicSettings) } @@ -218,7 +243,7 @@ func main() { admin.Use(middleware.Auth(cfg)) admin.Use(middleware.RequireAdmin()) { - adm := handlers.NewAdminHandler(stores) + adm := handlers.NewAdminHandler(stores, keyResolver) // User management admin.GET("/users", adm.ListUsers) @@ -259,7 +284,7 @@ func main() { admin.DELETE("/presets/:id/avatar", handlers.DeletePresetAvatar) // Teams (admin) - teamAdm := handlers.NewTeamHandler() + teamAdm := handlers.NewTeamHandler(keyResolver) admin.GET("/teams", teamAdm.ListTeams) admin.POST("/teams", teamAdm.CreateTeam) admin.GET("/teams/:id", teamAdm.GetTeam) diff --git a/server/models/models.go b/server/models/models.go index c9369b9..ed400d8 100644 --- a/server/models/models.go +++ b/server/models/models.go @@ -119,7 +119,9 @@ type ProviderConfig struct { Name string `json:"name" db:"name"` Provider string `json:"provider" db:"provider"` Endpoint string `json:"endpoint" db:"endpoint"` - APIKeyEnc string `json:"-" db:"api_key_enc"` + APIKeyEnc []byte `json:"-" db:"api_key_enc"` + KeyNonce []byte `json:"-" db:"key_nonce"` + KeyScope string `json:"-" db:"key_scope"` ModelDefault string `json:"model_default,omitempty" db:"model_default"` Config JSONMap `json:"config,omitempty" db:"config"` Headers JSONMap `json:"headers,omitempty" db:"headers"` @@ -128,10 +130,16 @@ type ProviderConfig struct { IsPrivate bool `json:"is_private" db:"is_private"` } +// HasKey returns true if an encrypted API key is stored. +func (p *ProviderConfig) HasKey() bool { + return len(p.APIKeyEnc) > 0 +} + type ProviderConfigPatch struct { Name *string `json:"name,omitempty"` Endpoint *string `json:"endpoint,omitempty"` - APIKeyEnc *string `json:"-"` + APIKeyEnc []byte `json:"-"` + KeyNonce []byte `json:"-"` ModelDefault *string `json:"model_default,omitempty"` Config JSONMap `json:"config,omitempty"` Headers JSONMap `json:"headers,omitempty"` diff --git a/server/store/postgres/provider.go b/server/store/postgres/provider.go index 2022b1c..61f364e 100644 --- a/server/store/postgres/provider.go +++ b/server/store/postgres/provider.go @@ -14,16 +14,16 @@ type ProviderStore struct{} func NewProviderStore() *ProviderStore { return &ProviderStore{} } const providerCols = `id, scope, owner_id, name, provider, endpoint, api_key_enc, - model_default, config, headers, settings, is_active, is_private, created_at, updated_at` + key_nonce, key_scope, model_default, config, headers, settings, is_active, is_private, created_at, updated_at` func (s *ProviderStore) Create(ctx context.Context, cfg *models.ProviderConfig) error { return DB.QueryRowContext(ctx, ` INSERT INTO provider_configs (scope, owner_id, name, provider, endpoint, api_key_enc, - model_default, config, headers, settings, is_active, is_private) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12) + key_nonce, key_scope, model_default, config, headers, settings, is_active, is_private) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14) RETURNING id, created_at, updated_at`, cfg.Scope, models.NullString(cfg.OwnerID), cfg.Name, cfg.Provider, cfg.Endpoint, - cfg.APIKeyEnc, cfg.ModelDefault, + cfg.APIKeyEnc, cfg.KeyNonce, cfg.KeyScope, cfg.ModelDefault, ToJSON(cfg.Config), ToJSON(cfg.Headers), ToJSON(cfg.Settings), cfg.IsActive, cfg.IsPrivate, ).Scan(&cfg.ID, &cfg.CreatedAt, &cfg.UpdatedAt) @@ -44,7 +44,8 @@ func (s *ProviderStore) Update(ctx context.Context, id string, patch models.Prov b.Set("endpoint", *patch.Endpoint) } if patch.APIKeyEnc != nil { - b.Set("api_key_enc", *patch.APIKeyEnc) + b.Set("api_key_enc", patch.APIKeyEnc) + b.Set("key_nonce", patch.KeyNonce) } if patch.ModelDefault != nil { b.Set("model_default", *patch.ModelDefault) @@ -149,11 +150,11 @@ func (s *ProviderStore) listByScope(ctx context.Context, scope, ownerID string) func scanProvider(row *sql.Row) (*models.ProviderConfig, error) { var p models.ProviderConfig - var ownerID, modelDefault sql.NullString + var ownerID, modelDefault, keyScope sql.NullString var configJSON, headersJSON, settingsJSON []byte err := row.Scan( &p.ID, &p.Scope, &ownerID, &p.Name, &p.Provider, &p.Endpoint, - &p.APIKeyEnc, &modelDefault, + &p.APIKeyEnc, &p.KeyNonce, &keyScope, &modelDefault, &configJSON, &headersJSON, &settingsJSON, &p.IsActive, &p.IsPrivate, &p.CreatedAt, &p.UpdatedAt, ) @@ -162,6 +163,7 @@ func scanProvider(row *sql.Row) (*models.ProviderConfig, error) { } p.OwnerID = NullableStringPtr(ownerID) p.ModelDefault = modelDefault.String + p.KeyScope = keyScope.String json.Unmarshal(configJSON, &p.Config) json.Unmarshal(headersJSON, &p.Headers) json.Unmarshal(settingsJSON, &p.Settings) @@ -172,11 +174,11 @@ func scanProviders(rows *sql.Rows) ([]models.ProviderConfig, error) { var result []models.ProviderConfig for rows.Next() { var p models.ProviderConfig - var ownerID, modelDefault sql.NullString + var ownerID, modelDefault, keyScope sql.NullString var configJSON, headersJSON, settingsJSON []byte err := rows.Scan( &p.ID, &p.Scope, &ownerID, &p.Name, &p.Provider, &p.Endpoint, - &p.APIKeyEnc, &modelDefault, + &p.APIKeyEnc, &p.KeyNonce, &keyScope, &modelDefault, &configJSON, &headersJSON, &settingsJSON, &p.IsActive, &p.IsPrivate, &p.CreatedAt, &p.UpdatedAt, ) @@ -185,6 +187,7 @@ func scanProviders(rows *sql.Rows) ([]models.ProviderConfig, error) { } p.OwnerID = NullableStringPtr(ownerID) p.ModelDefault = modelDefault.String + p.KeyScope = keyScope.String json.Unmarshal(configJSON, &p.Config) json.Unmarshal(headersJSON, &p.Headers) json.Unmarshal(settingsJSON, &p.Settings) diff --git a/src/js/ui.js b/src/js/ui.js index 13a098a..5f107ed 100644 --- a/src/js/ui.js +++ b/src/js/ui.js @@ -1823,7 +1823,9 @@ function formatMessage(content) { text = text.replace(/<(?:thinking|think)>([\s\S]*?)<\/(?:thinking|think)>/gi, (_, inner) => { const id = 'think-' + Math.random().toString(36).slice(2, 9); thinkingBlocks.push({ id, content: inner.trim() }); - return `THINK_PLACEHOLDER_${id}`; + // Newlines ensure the placeholder is its own markdown block so adjacent + // code fences (e.g. "```html") start on a fresh line for marked. + return `\n\nTHINK_PLACEHOLDER_${id}\n\n`; }); let html; @@ -1845,10 +1847,35 @@ function formatMessage(content) { } function _formatMarked(text) { + // Close any unclosed code fences to prevent raw HTML injection during + // streaming (closing ``` hasn't arrived yet) or when the model forgets + // to close a fence. An odd number of ``` markers means one is unclosed. + const fences = text.match(/^```/gm); + if (fences && fences.length % 2 !== 0) { + text += '\n```'; + } + const rendered = marked.parse(text, { breaks: true, gfm: true }); let html = DOMPurify.sanitize(rendered, { - ADD_TAGS: ['details', 'summary', 'iframe'], - ADD_ATTR: ['id', 'class', 'onclick', 'sandbox', 'srcdoc'] + // Strict allowlist: only elements that marked.js actually produces. + // This prevents ANY raw HTML (canvas, style, script, iframe, form, etc.) + // from rendering live even if a code fence fails to parse. + ALLOWED_TAGS: [ + // Block elements + 'p', 'br', 'hr', 'pre', 'code', 'blockquote', + 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', + // Lists + 'ul', 'ol', 'li', + // Tables + 'table', 'thead', 'tbody', 'tfoot', 'tr', 'th', 'td', + // Inline formatting + 'strong', 'em', 'b', 'i', 'u', 's', 'del', 'sub', 'sup', + 'a', 'img', 'span', 'div', + // Think blocks (injected post-sanitize, but placeholders may be in

) + 'details', 'summary', + ], + ALLOWED_ATTR: ['id', 'class', 'href', 'target', 'rel', 'src', 'alt', 'title', + 'colspan', 'rowspan', 'align'], }); // Process code blocks: add copy button, collapse toggle, HTML preview