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:
2026-07-17 20:27:49 -07:00
parent d38c700949
commit 172c240d1b
7 changed files with 822 additions and 399 deletions
+111 -43
View File
@@ -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