fetch_ml/internal/auth/keychain.go
Jeremie Fraeys 803677be57 feat: implement Go backend with comprehensive API and internal packages
- Add API server with WebSocket support and REST endpoints
- Implement authentication system with API keys and permissions
- Add task queue system with Redis backend and error handling
- Include storage layer with database migrations and schemas
- Add comprehensive logging, metrics, and telemetry
- Implement security middleware and network utilities
- Add experiment management and container orchestration
- Include configuration management with smart defaults
2025-12-04 16:53:53 -05:00

167 lines
4.9 KiB
Go

package auth
import (
"errors"
"fmt"
"os"
"path/filepath"
"strings"
"sync"
"time"
"github.com/zalando/go-keyring"
)
// KeychainManager provides secure storage for API keys.
type KeychainManager struct {
primary systemKeyring
fallback *fileKeyStore
}
// systemKeyring abstracts go-keyring for easier testing.
type systemKeyring interface {
Set(service, account, secret string) error
Get(service, account string) (string, error)
Delete(service, account string) error
}
type goKeyring struct{}
func (goKeyring) Set(service, account, secret string) error {
return keyring.Set(service, account, secret)
}
func (goKeyring) Get(service, account string) (string, error) {
return keyring.Get(service, account)
}
func (goKeyring) Delete(service, account string) error {
return keyring.Delete(service, account)
}
// NewKeychainManager returns a manager backed by the OS keyring with a secure file fallback.
func NewKeychainManager() *KeychainManager {
return newKeychainManagerWithKeyring(goKeyring{}, defaultFallbackDir())
}
func newKeychainManagerWithKeyring(kr systemKeyring, fallbackDir string) *KeychainManager {
if fallbackDir == "" {
fallbackDir = defaultFallbackDir()
}
return &KeychainManager{
primary: kr,
fallback: newFileKeyStore(fallbackDir),
}
}
func defaultFallbackDir() string {
home, err := os.UserHomeDir()
if err != nil || home == "" {
return filepath.Join(os.TempDir(), "fetch_ml", "keys")
}
return filepath.Join(home, ".fetch_ml", "keys")
}
// StoreAPIKey stores the key in the OS keyring, falling back to a protected file when needed.
func (km *KeychainManager) StoreAPIKey(service, account, key string) error {
if err := km.primary.Set(service, account, key); err == nil {
return nil
} else if errors.Is(err, keyring.ErrUnsupportedPlatform) || errors.Is(err, keyring.ErrNotFound) {
return km.fallback.store(service, account, key)
} else if fallbackErr := km.fallback.store(service, account, key); fallbackErr == nil {
return nil
}
return fmt.Errorf("failed to store API key")
}
// GetAPIKey retrieves a key from the OS keyring or fallback store.
func (km *KeychainManager) GetAPIKey(service, account string) (string, error) {
secret, err := km.primary.Get(service, account)
if err == nil {
return secret, nil
}
if errors.Is(err, keyring.ErrUnsupportedPlatform) || errors.Is(err, keyring.ErrNotFound) {
return km.fallback.get(service, account)
}
// Unknown error - try fallback before surfacing
if fallbackSecret, ferr := km.fallback.get(service, account); ferr == nil {
return fallbackSecret, nil
}
return "", fmt.Errorf("failed to retrieve API key")
}
// DeleteAPIKey removes a key from both stores.
func (km *KeychainManager) DeleteAPIKey(service, account string) error {
if err := km.primary.Delete(service, account); err != nil && !errors.Is(err, keyring.ErrNotFound) && !errors.Is(err, keyring.ErrUnsupportedPlatform) {
return fmt.Errorf("failed to delete API key: %w", err)
}
if err := km.fallback.delete(service, account); err != nil && !errors.Is(err, os.ErrNotExist) {
return err
}
return nil
}
// IsAvailable reports whether the OS keyring backend is usable.
func (km *KeychainManager) IsAvailable() bool {
_, err := km.primary.Get("fetch_ml_probe", fmt.Sprintf("probe_%d", time.Now().UnixNano()))
return err == nil || !errors.Is(err, keyring.ErrUnsupportedPlatform)
}
// ListAvailableMethods returns backends the manager can use.
func (km *KeychainManager) ListAvailableMethods() []string {
methods := []string{}
if km.IsAvailable() {
methods = append(methods, "OS keyring")
}
methods = append(methods, fmt.Sprintf("Encrypted file (%s)", km.fallback.baseDir))
return methods
}
// fileKeyStore stores secrets with 0600 permissions as a fallback.
type fileKeyStore struct {
baseDir string
mu sync.Mutex
}
func newFileKeyStore(baseDir string) *fileKeyStore {
return &fileKeyStore{baseDir: baseDir}
}
func (f *fileKeyStore) store(service, account, secret string) error {
f.mu.Lock()
defer f.mu.Unlock()
if err := os.MkdirAll(f.baseDir, 0o700); err != nil {
return fmt.Errorf("failed to prepare key store: %w", err)
}
path := f.path(service, account)
return os.WriteFile(path, []byte(secret), 0o600)
}
func (f *fileKeyStore) get(service, account string) (string, error) {
f.mu.Lock()
defer f.mu.Unlock()
data, err := os.ReadFile(f.path(service, account))
if err != nil {
return "", err
}
return string(data), nil
}
func (f *fileKeyStore) delete(service, account string) error {
f.mu.Lock()
defer f.mu.Unlock()
path := f.path(service, account)
if err := os.Remove(path); err != nil && !errors.Is(err, os.ErrNotExist) {
return err
}
return nil
}
func (f *fileKeyStore) path(service, account string) string {
return filepath.Join(f.baseDir, fmt.Sprintf("%s_%s.key", sanitize(service), sanitize(account)))
}
func sanitize(value string) string {
replacer := strings.NewReplacer("/", "_", "\\", "_", "..", "_", " ", "_", "\t", "_")
return replacer.Replace(value)
}