Changeset 0.9.4 (#54)

This commit is contained in:
2026-02-24 10:44:12 +00:00
parent 90021157e6
commit 5e416d3726
26 changed files with 1333 additions and 108 deletions

View File

@@ -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", ""),
}
}

131
server/crypto/backfill.go Normal file
View File

@@ -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
}

64
server/crypto/cache.go Normal file
View File

@@ -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)
}

72
server/crypto/resolver.go Normal file
View File

@@ -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
}

165
server/crypto/vault.go Normal file
View File

@@ -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
}

241
server/crypto/vault_test.go Normal file
View File

@@ -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)
}
}

View File

@@ -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';

View File

@@ -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
}

View File

@@ -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

View File

@@ -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 == "" {

View File

@@ -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 ─────────────────────────

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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
}

View File

@@ -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

View File

@@ -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 ───────────────────

View File

@@ -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)

View File

@@ -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"`

View File

@@ -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)