fix(auth): repair persistence layer and stop storing plaintext keys
- Store SHA-256 digests (key_hash + key_preview) instead of raw keys, in both the in-memory store and Postgres; migrate legacy plaintext rows in place and drop the plaintext column. - Fix TEXT[] scans that failed at runtime (missing pq.Array) in GetKey/ListKeys/LoadAllKeys. - Load persisted keys at startup (InitializeFromDatabase was never called) and fall back to the database on cache miss, so issued keys survive restarts. - Make HybridKeyStore.CreateKey genuinely write-through: cache is only updated after a successful DB write. - Guard nil database backend (auth enabled without DB previously panicked on key creation) and degrade to in-memory operation. - Persist bootstrap admin keys when a database is configured. - Record real audit-log details as JSON and log audit failures instead of silently dropping them; add DB pool limits and ping timeout. - Sentinel errors matched with errors.Is; unit tests for hashing, write-through ordering, DB fallback, and nil-DB operation. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
+111
-43
@@ -3,22 +3,38 @@ package auth
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// APIKeyStore manages API keys with rate limiting
|
||||
// Sentinel errors for key validation and lookup. Match with errors.Is.
|
||||
var (
|
||||
ErrInvalidKey = errors.New("invalid API key")
|
||||
ErrKeyInactive = errors.New("API key is inactive")
|
||||
ErrKeyExpired = errors.New("API key has expired")
|
||||
ErrKeyNotFound = errors.New("API key not found")
|
||||
)
|
||||
|
||||
// APIKeyStore manages API keys with rate limiting.
|
||||
// Keys are stored indexed by SHA-256 digest; the raw key is never retained
|
||||
// after creation.
|
||||
type APIKeyStore struct {
|
||||
mu sync.RWMutex
|
||||
keys map[string]*APIKey
|
||||
keys map[string]*APIKey // keyed by KeyHash
|
||||
rateLimits map[string]*RateLimiter
|
||||
}
|
||||
|
||||
// APIKey represents an API key with metadata
|
||||
// APIKey represents an API key with metadata.
|
||||
// Key holds the raw secret only on the value returned from key creation;
|
||||
// stored and persisted records carry only KeyHash and KeyPreview.
|
||||
type APIKey struct {
|
||||
Key string `json:"key"`
|
||||
Key string `json:"key,omitempty"`
|
||||
KeyHash string `json:"-"`
|
||||
KeyPreview string `json:"key_preview,omitempty"` // e.g. "nk_…ab12"
|
||||
Name string `json:"name"`
|
||||
ClientID string `json:"client_id"`
|
||||
Roles []string `json:"roles"`
|
||||
@@ -45,20 +61,26 @@ type AuthContext struct {
|
||||
Roles []string
|
||||
}
|
||||
|
||||
// NewAPIKeyStore creates a new API key store
|
||||
func NewAPIKeyStore() *APIKeyStore {
|
||||
return &APIKeyStore{
|
||||
keys: make(map[string]*APIKey),
|
||||
rateLimits: make(map[string]*RateLimiter),
|
||||
}
|
||||
// HashKey returns the hex-encoded SHA-256 digest of a raw API key.
|
||||
// All storage and lookups are keyed by this digest so a leaked store or
|
||||
// database dump does not expose usable credentials.
|
||||
func HashKey(rawKey string) string {
|
||||
sum := sha256.Sum256([]byte(rawKey))
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
// CreateKey generates a new API key
|
||||
func (s *APIKeyStore) CreateKey(clientID string, roles []string, rateLimit int, expiresIn *time.Duration) (*APIKey, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
// keyPreview returns a non-sensitive display form of a raw key ("nk_…ab12").
|
||||
func keyPreview(rawKey string) string {
|
||||
if len(rawKey) < 4 {
|
||||
return ""
|
||||
}
|
||||
return "nk_…" + rawKey[len(rawKey)-4:]
|
||||
}
|
||||
|
||||
// Generate random key
|
||||
// generateAPIKey creates a new APIKey with a random secret without touching
|
||||
// any store. The returned value carries the raw secret in Key; callers decide
|
||||
// where (and whether) to register it.
|
||||
func generateAPIKey(clientID string, roles []string, rateLimit int, expiresIn *time.Duration) (*APIKey, error) {
|
||||
keyBytes := make([]byte, 32)
|
||||
if _, err := rand.Read(keyBytes); err != nil {
|
||||
return nil, fmt.Errorf("failed to generate key: %w", err)
|
||||
@@ -67,13 +89,15 @@ func (s *APIKeyStore) CreateKey(clientID string, roles []string, rateLimit int,
|
||||
|
||||
now := time.Now().UTC()
|
||||
apiKey := &APIKey{
|
||||
Key: key,
|
||||
ClientID: clientID,
|
||||
Roles: roles,
|
||||
CreatedAt: now,
|
||||
IsActive: true,
|
||||
RateLimit: rateLimit,
|
||||
Name: fmt.Sprintf("%s-%d", clientID, now.Unix()),
|
||||
Key: key,
|
||||
KeyHash: HashKey(key),
|
||||
KeyPreview: keyPreview(key),
|
||||
ClientID: clientID,
|
||||
Roles: roles,
|
||||
CreatedAt: now,
|
||||
IsActive: true,
|
||||
RateLimit: rateLimit,
|
||||
Name: fmt.Sprintf("%s-%d", clientID, now.Unix()),
|
||||
}
|
||||
|
||||
if expiresIn != nil {
|
||||
@@ -81,33 +105,75 @@ func (s *APIKeyStore) CreateKey(clientID string, roles []string, rateLimit int,
|
||||
apiKey.ExpiresAt = &expiresAt
|
||||
}
|
||||
|
||||
s.keys[key] = apiKey
|
||||
s.rateLimits[key] = &RateLimiter{
|
||||
maxRequests: rateLimit,
|
||||
return apiKey, nil
|
||||
}
|
||||
|
||||
// NewAPIKeyStore creates a new API key store
|
||||
func NewAPIKeyStore() *APIKeyStore {
|
||||
return &APIKeyStore{
|
||||
keys: make(map[string]*APIKey),
|
||||
rateLimits: make(map[string]*RateLimiter),
|
||||
}
|
||||
}
|
||||
|
||||
// CreateKey generates a new API key and registers it in the store.
|
||||
// The returned APIKey carries the raw secret; the stored copy does not.
|
||||
func (s *APIKeyStore) CreateKey(clientID string, roles []string, rateLimit int, expiresIn *time.Duration) (*APIKey, error) {
|
||||
apiKey, err := generateAPIKey(clientID, roles, rateLimit, expiresIn)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s.RegisterKey(apiKey)
|
||||
return apiKey, nil
|
||||
}
|
||||
|
||||
// RegisterKey adds a key to the store, indexed by digest. The stored copy has
|
||||
// the raw secret stripped. If KeyHash is unset it is computed from Key.
|
||||
func (s *APIKeyStore) RegisterKey(apiKey *APIKey) {
|
||||
stored := *apiKey
|
||||
if stored.KeyHash == "" && stored.Key != "" {
|
||||
stored.KeyHash = HashKey(stored.Key)
|
||||
}
|
||||
if stored.KeyPreview == "" && stored.Key != "" {
|
||||
stored.KeyPreview = keyPreview(stored.Key)
|
||||
}
|
||||
stored.Key = "" // never retain the raw secret
|
||||
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.keys[stored.KeyHash] = &stored
|
||||
s.rateLimits[stored.KeyHash] = &RateLimiter{
|
||||
maxRequests: stored.RateLimit,
|
||||
window: time.Minute,
|
||||
resetTime: time.Now().Add(time.Minute),
|
||||
count: 0,
|
||||
}
|
||||
|
||||
return apiKey, nil
|
||||
}
|
||||
|
||||
// ValidateKey checks if an API key is valid and returns the key metadata
|
||||
// RemoveKeyByHash deletes a key and its rate limiter from the store.
|
||||
func (s *APIKeyStore) RemoveKeyByHash(keyHash string) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
delete(s.keys, keyHash)
|
||||
delete(s.rateLimits, keyHash)
|
||||
}
|
||||
|
||||
// ValidateKey checks if a raw API key is valid and returns the key metadata
|
||||
func (s *APIKeyStore) ValidateKey(keyStr string) (*APIKey, error) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
|
||||
key, exists := s.keys[keyStr]
|
||||
key, exists := s.keys[HashKey(keyStr)]
|
||||
if !exists {
|
||||
return nil, fmt.Errorf("invalid API key")
|
||||
return nil, ErrInvalidKey
|
||||
}
|
||||
|
||||
if !key.IsActive {
|
||||
return nil, fmt.Errorf("API key is inactive")
|
||||
return nil, ErrKeyInactive
|
||||
}
|
||||
|
||||
if key.ExpiresAt != nil && time.Now().After(*key.ExpiresAt) {
|
||||
return nil, fmt.Errorf("API key has expired")
|
||||
return nil, ErrKeyExpired
|
||||
}
|
||||
|
||||
return key, nil
|
||||
@@ -115,13 +181,15 @@ func (s *APIKeyStore) ValidateKey(keyStr string) (*APIKey, error) {
|
||||
|
||||
// CheckRateLimit checks if a key has exceeded its rate limit
|
||||
func (s *APIKeyStore) CheckRateLimit(keyStr string) (bool, error) {
|
||||
keyHash := HashKey(keyStr)
|
||||
|
||||
// Look up key and limiter under the store lock, then release it
|
||||
// before acquiring the per-key limiter lock to avoid nested locking.
|
||||
s.mu.RLock()
|
||||
key, exists := s.keys[keyStr]
|
||||
key, exists := s.keys[keyHash]
|
||||
if !exists {
|
||||
s.mu.RUnlock()
|
||||
return false, fmt.Errorf("invalid API key")
|
||||
return false, ErrInvalidKey
|
||||
}
|
||||
|
||||
// Unlimited rate limit
|
||||
@@ -130,7 +198,7 @@ func (s *APIKeyStore) CheckRateLimit(keyStr string) (bool, error) {
|
||||
return true, nil
|
||||
}
|
||||
|
||||
limiter, exists := s.rateLimits[keyStr]
|
||||
limiter, exists := s.rateLimits[keyHash]
|
||||
if !exists {
|
||||
s.mu.RUnlock()
|
||||
return false, fmt.Errorf("rate limiter not found")
|
||||
@@ -160,9 +228,9 @@ func (s *APIKeyStore) UpdateLastUsed(keyStr string) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
key, exists := s.keys[keyStr]
|
||||
key, exists := s.keys[HashKey(keyStr)]
|
||||
if !exists {
|
||||
return fmt.Errorf("invalid API key")
|
||||
return ErrInvalidKey
|
||||
}
|
||||
|
||||
now := time.Now().UTC()
|
||||
@@ -170,28 +238,28 @@ func (s *APIKeyStore) UpdateLastUsed(keyStr string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeactivateKey deactivates an API key
|
||||
// DeactivateKey deactivates an API key by its raw value
|
||||
func (s *APIKeyStore) DeactivateKey(keyStr string) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
key, exists := s.keys[keyStr]
|
||||
key, exists := s.keys[HashKey(keyStr)]
|
||||
if !exists {
|
||||
return fmt.Errorf("invalid API key")
|
||||
return ErrInvalidKey
|
||||
}
|
||||
|
||||
key.IsActive = false
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetKey retrieves key metadata (for management purposes)
|
||||
// GetKey retrieves key metadata by raw key (for management purposes)
|
||||
func (s *APIKeyStore) GetKey(keyStr string) (*APIKey, error) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
|
||||
key, exists := s.keys[keyStr]
|
||||
key, exists := s.keys[HashKey(keyStr)]
|
||||
if !exists {
|
||||
return nil, fmt.Errorf("key not found")
|
||||
return nil, ErrKeyNotFound
|
||||
}
|
||||
|
||||
return key, nil
|
||||
|
||||
Reference in New Issue
Block a user