gogcli/internal/secrets/store.go
Peter Steinberger 6547ac0b04 fix(lint): satisfy fmt/lint checks (#35)
Thanks @salmonumbrella.

Co-authored-by: salmonumbrella <salmonumbrella@users.noreply.github.com>
2026-01-09 04:39:24 +01:00

451 lines
11 KiB
Go

package secrets
import (
"encoding/json"
"errors"
"fmt"
"os"
"runtime"
"strings"
"time"
"github.com/99designs/keyring"
"golang.org/x/term"
"github.com/steipete/gogcli/internal/config"
)
type Store interface {
Keys() ([]string, error)
SetToken(email string, tok Token) error
GetToken(email string) (Token, error)
DeleteToken(email string) error
ListTokens() ([]Token, error)
GetDefaultAccount() (string, error)
SetDefaultAccount(email string) error
}
type KeyringStore struct {
ring keyring.Keyring
}
type Token struct {
Email string `json:"email"`
Services []string `json:"services,omitempty"`
Scopes []string `json:"scopes,omitempty"`
CreatedAt time.Time `json:"created_at,omitempty"`
RefreshToken string `json:"-"`
}
const (
keyringPasswordEnv = "GOG_KEYRING_PASSWORD" //nolint:gosec // env var name, not a credential
keyringBackendEnv = "GOG_KEYRING_BACKEND" //nolint:gosec // env var name, not a credential
)
var (
errMissingEmail = errors.New("missing email")
errMissingRefreshToken = errors.New("missing refresh token")
errMissingSecretKey = errors.New("missing secret key")
errNoTTY = errors.New("no TTY available for keyring file backend password prompt")
errInvalidKeyringBackend = errors.New("invalid keyring backend")
errKeyringTimeout = errors.New("keyring connection timed out")
openKeyringFunc = openKeyring
keyringOpenFunc = keyring.Open
)
type KeyringBackendInfo struct {
Value string
Source string
}
const (
keyringBackendSourceEnv = "env"
keyringBackendSourceConfig = "config"
keyringBackendSourceDefault = "default"
keyringBackendAuto = "auto"
)
func ResolveKeyringBackendInfo() (KeyringBackendInfo, error) {
if v := normalizeKeyringBackend(os.Getenv(keyringBackendEnv)); v != "" {
return KeyringBackendInfo{Value: v, Source: keyringBackendSourceEnv}, nil
}
cfg, err := config.ReadConfig()
if err != nil {
return KeyringBackendInfo{}, fmt.Errorf("resolve keyring backend: %w", err)
}
if cfg.KeyringBackend != "" {
if v := normalizeKeyringBackend(cfg.KeyringBackend); v != "" {
return KeyringBackendInfo{Value: v, Source: keyringBackendSourceConfig}, nil
}
}
return KeyringBackendInfo{Value: keyringBackendAuto, Source: keyringBackendSourceDefault}, nil
}
func allowedBackends(info KeyringBackendInfo) ([]keyring.BackendType, error) {
switch info.Value {
case "", keyringBackendAuto:
return nil, nil
case "keychain":
return []keyring.BackendType{keyring.KeychainBackend}, nil
case "file":
return []keyring.BackendType{keyring.FileBackend}, nil
default:
return nil, fmt.Errorf("%w: %q (expected %s, keychain, or file)", errInvalidKeyringBackend, info.Value, keyringBackendAuto)
}
}
// wrapKeychainError wraps keychain errors with helpful guidance on macOS.
func wrapKeychainError(err error) error {
if err == nil {
return nil
}
if IsKeychainLockedError(err.Error()) {
return fmt.Errorf("%w\n\nYour macOS keychain is locked. To unlock it, run:\n security unlock-keychain ~/Library/Keychains/login.keychain-db", err)
}
return err
}
func fileKeyringPasswordFuncFrom(password string, isTTY bool) keyring.PromptFunc {
if password != "" {
return keyring.FixedStringPrompt(password)
}
if isTTY {
return keyring.TerminalPrompt
}
return func(_ string) (string, error) {
return "", fmt.Errorf("%w; set %s", errNoTTY, keyringPasswordEnv)
}
}
func fileKeyringPasswordFunc() keyring.PromptFunc {
return fileKeyringPasswordFuncFrom(os.Getenv(keyringPasswordEnv), term.IsTerminal(int(os.Stdin.Fd())))
}
func normalizeKeyringBackend(value string) string {
return strings.ToLower(strings.TrimSpace(value))
}
// keyringOpenTimeout is the maximum time to wait for keyring.Open() to complete.
// On headless Linux, D-Bus SecretService can hang indefinitely if gnome-keyring
// is installed but not running.
const keyringOpenTimeout = 5 * time.Second
func shouldForceFileBackend(goos string, backendInfo KeyringBackendInfo, dbusAddr string) bool {
return goos == "linux" && backendInfo.Value == keyringBackendAuto && dbusAddr == ""
}
func shouldUseKeyringTimeout(goos string, backendInfo KeyringBackendInfo, dbusAddr string) bool {
return goos == "linux" && backendInfo.Value == "auto" && dbusAddr != ""
}
func openKeyring() (keyring.Keyring, error) {
// On Linux/WSL/containers, OS keychains (secret-service/kwallet) may be unavailable.
// In that case github.com/99designs/keyring falls back to the "file" backend,
// which *requires* both a directory and a password prompt function.
keyringDir, err := config.EnsureKeyringDir()
if err != nil {
return nil, fmt.Errorf("ensure keyring dir: %w", err)
}
backendInfo, err := ResolveKeyringBackendInfo()
if err != nil {
return nil, err
}
backends, err := allowedBackends(backendInfo)
if err != nil {
return nil, err
}
dbusAddr := os.Getenv("DBUS_SESSION_BUS_ADDRESS")
// On Linux with "auto" backend and no D-Bus session, force file backend.
// Without DBUS_SESSION_BUS_ADDRESS, SecretService will hang indefinitely
// trying to connect (common on headless systems like Raspberry Pi).
if shouldForceFileBackend(runtime.GOOS, backendInfo, dbusAddr) {
backends = []keyring.BackendType{keyring.FileBackend}
}
cfg := keyring.Config{
ServiceName: config.AppName,
KeychainTrustApplication: runtime.GOOS == "darwin",
AllowedBackends: backends,
FileDir: keyringDir,
FilePasswordFunc: fileKeyringPasswordFunc(),
}
// On Linux with D-Bus present, keyring.Open() can still hang if SecretService
// is unresponsive (e.g., gnome-keyring installed but not running).
// Use a timeout as a safety net.
if shouldUseKeyringTimeout(runtime.GOOS, backendInfo, dbusAddr) {
return openKeyringWithTimeout(cfg, keyringOpenTimeout)
}
ring, err := keyringOpenFunc(cfg)
if err != nil {
return nil, fmt.Errorf("open keyring: %w", err)
}
return ring, nil
}
type keyringResult struct {
ring keyring.Keyring
err error
}
// openKeyringWithTimeout wraps keyring.Open with a timeout to prevent indefinite
// hangs when D-Bus SecretService is unresponsive (e.g., gnome-keyring installed
// but not running on headless Linux).
//
// Note: If timeout occurs, the spawned goroutine continues blocking on keyring.Open()
// and will leak. This is acceptable for a CLI tool since the process exits on this
// error, but would need refactoring for long-running use.
func openKeyringWithTimeout(cfg keyring.Config, timeout time.Duration) (keyring.Keyring, error) {
ch := make(chan keyringResult, 1)
go func() {
ring, err := keyringOpenFunc(cfg)
ch <- keyringResult{ring, err}
}()
select {
case res := <-ch:
if res.err != nil {
return nil, fmt.Errorf("open keyring: %w", res.err)
}
return res.ring, nil
case <-time.After(timeout):
return nil, fmt.Errorf("%w after %v (D-Bus SecretService may be unresponsive); "+
"set GOG_KEYRING_BACKEND=file and GOG_KEYRING_PASSWORD=<password> to use encrypted file storage instead",
errKeyringTimeout, timeout)
}
}
func OpenDefault() (Store, error) {
ring, err := openKeyringFunc()
if err != nil {
return nil, err
}
return &KeyringStore{ring: ring}, nil
}
func SetSecret(key string, value []byte) error {
key = strings.TrimSpace(key)
if key == "" {
return errMissingSecretKey
}
ring, err := openKeyringFunc()
if err != nil {
return err
}
if err := ring.Set(keyring.Item{
Key: key,
Data: value,
}); err != nil {
return wrapKeychainError(fmt.Errorf("store secret: %w", err))
}
return nil
}
func GetSecret(key string) ([]byte, error) {
key = strings.TrimSpace(key)
if key == "" {
return nil, errMissingSecretKey
}
ring, err := openKeyringFunc()
if err != nil {
return nil, err
}
item, err := ring.Get(key)
if err != nil {
return nil, fmt.Errorf("read secret: %w", err)
}
return item.Data, nil
}
func (s *KeyringStore) Keys() ([]string, error) {
keys, err := s.ring.Keys()
if err != nil {
return nil, fmt.Errorf("list keyring keys: %w", err)
}
return keys, nil
}
type storedToken struct {
RefreshToken string `json:"refresh_token"`
Services []string `json:"services,omitempty"`
Scopes []string `json:"scopes,omitempty"`
CreatedAt time.Time `json:"created_at,omitempty"`
}
func (s *KeyringStore) SetToken(email string, tok Token) error {
email = normalize(email)
if email == "" {
return errMissingEmail
}
if tok.RefreshToken == "" {
return errMissingRefreshToken
}
if tok.CreatedAt.IsZero() {
tok.CreatedAt = time.Now().UTC()
}
payload, err := json.Marshal(storedToken{
RefreshToken: tok.RefreshToken,
Services: tok.Services,
Scopes: tok.Scopes,
CreatedAt: tok.CreatedAt,
})
if err != nil {
return fmt.Errorf("encode token: %w", err)
}
if err := s.ring.Set(keyring.Item{
Key: tokenKey(email),
Data: payload,
}); err != nil {
return wrapKeychainError(fmt.Errorf("store token: %w", err))
}
return nil
}
func (s *KeyringStore) GetToken(email string) (Token, error) {
email = normalize(email)
if email == "" {
return Token{}, errMissingEmail
}
var it keyring.Item
if item, err := s.ring.Get(tokenKey(email)); err != nil {
return Token{}, fmt.Errorf("read token: %w", err)
} else {
it = item
}
var st storedToken
if err := json.Unmarshal(it.Data, &st); err != nil {
return Token{}, fmt.Errorf("decode token: %w", err)
}
return Token{
Email: email,
Services: st.Services,
Scopes: st.Scopes,
CreatedAt: st.CreatedAt,
RefreshToken: st.RefreshToken,
}, nil
}
func (s *KeyringStore) DeleteToken(email string) error {
email = normalize(email)
if email == "" {
return errMissingEmail
}
if err := s.ring.Remove(tokenKey(email)); err != nil {
return fmt.Errorf("delete token: %w", err)
}
return nil
}
func (s *KeyringStore) ListTokens() ([]Token, error) {
keys, err := s.Keys()
if err != nil {
return nil, fmt.Errorf("list tokens: %w", err)
}
out := make([]Token, 0)
for _, k := range keys {
email, ok := ParseTokenKey(k)
if !ok {
continue
}
var tok Token
if t, err := s.GetToken(email); err != nil {
return nil, fmt.Errorf("read token for %s: %w", email, err)
} else {
tok = t
}
out = append(out, tok)
}
return out, nil
}
func ParseTokenKey(k string) (email string, ok bool) {
const prefix = "token:"
if !strings.HasPrefix(k, prefix) {
return "", false
}
rest := strings.TrimPrefix(k, prefix)
if strings.TrimSpace(rest) == "" {
return "", false
}
return rest, true
}
func tokenKey(email string) string {
return fmt.Sprintf("token:%s", email)
}
func normalize(s string) string {
return strings.ToLower(strings.TrimSpace(s))
}
const defaultAccountKey = "default_account"
func (s *KeyringStore) GetDefaultAccount() (string, error) {
it, err := s.ring.Get(defaultAccountKey)
if err != nil {
if errors.Is(err, keyring.ErrKeyNotFound) {
return "", nil
}
return "", fmt.Errorf("read default account: %w", err)
}
return string(it.Data), nil
}
func (s *KeyringStore) SetDefaultAccount(email string) error {
email = normalize(email)
if email == "" {
return errMissingEmail
}
if err := s.ring.Set(keyring.Item{
Key: defaultAccountKey,
Data: []byte(email),
}); err != nil {
return fmt.Errorf("store default account: %w", err)
}
return nil
}