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

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