Some checks failed
Security Scan / Security Analysis (push) Waiting to run
Security Scan / Native Library Security (push) Waiting to run
Checkout test / test (push) Successful in 4s
CI/CD Pipeline / Test (push) Has been cancelled
CI/CD Pipeline / Dev Compose Smoke Test (push) Has been cancelled
CI/CD Pipeline / Build (push) Has been cancelled
CI/CD Pipeline / Test Scripts (push) Has been cancelled
CI/CD Pipeline / Test Native Libraries (push) Has been cancelled
CI/CD Pipeline / Docker Build (push) Has been cancelled
Documentation / build-and-publish (push) Has been cancelled
DeleteAPIKey now ignores primary keyring errors (e.g., dbus unavailable) and always cleans up the fallback store
169 lines
4.9 KiB
Go
169 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 {
|
|
// Try to delete from primary keyring, but don't fail on keyring errors
|
|
// (e.g., dbus unavailable, permission denied) - just clean up fallback
|
|
_ = km.primary.Delete(service, account)
|
|
|
|
// Always clean up fallback
|
|
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)
|
|
}
|