Changeset 0.9.4 (#54)
This commit is contained in:
131
server/crypto/backfill.go
Normal file
131
server/crypto/backfill.go
Normal 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
64
server/crypto/cache.go
Normal 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
72
server/crypto/resolver.go
Normal 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
165
server/crypto/vault.go
Normal 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
241
server/crypto/vault_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user