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