// Package keyring stores the sudo credential used by background Linux package // manager jobs in the operating system's native credential store. package keyring import ( "bufio" "context" "errors" "fmt" "os" "runtime" "strings" "time" systemkeyring "github.com/zalando/go-keyring" "gitgud.io/mike/mpv-manager/pkg/log" ) const ( // ServiceName is the application identifier in the native credential store. ServiceName = "mpv-manager" // PasswordKey is the account/key used to store the sudo password. PasswordKey = "sudo-password" ) // BackendType identifies the credential-store implementation. type BackendType string const ( // BackendSecretService is the freedesktop.org Secret Service provider used // on Linux. GNOME Keyring and current KDE Wallet both implement it. BackendSecretService BackendType = "secret-service" // BackendKWallet is retained for API compatibility. New Linux writes use // KWallet through its Secret Service compatibility service instead. BackendKWallet BackendType = "kwallet" // BackendPass is retained for API compatibility but is no longer selected. BackendPass BackendType = "pass" // BackendFile is retained for API compatibility but is no longer selected. // Sudo credentials are never written to an application-managed file. BackendFile BackendType = "file" // BackendKeychain is the native macOS Keychain backend. BackendKeychain BackendType = "keychain" // BackendWinCred is the native Windows Credential Manager backend. BackendWinCred BackendType = "wincred" // BackendNone indicates that the native credential store did not respond. BackendNone BackendType = "none" ) var ( // ErrNoBackend indicates that this platform has no supported native store. ErrNoBackend = errors.New("no native keyring backend available") // ErrDaemonNotRunning is retained for callers that distinguish an // unavailable Linux Secret Service session. ErrDaemonNotRunning = errors.New("keyring daemon is not running") // ErrNoPassword indicates that the credential has not been stored. ErrNoPassword = errors.New("no password stored") // ErrOperationTimeout indicates that the native keyring did not respond in // time, commonly because the desktop session is locked or unavailable. ErrOperationTimeout = errors.New("keyring operation timed out") ) // operationTimeout bounds every interaction with a native credential store. // go-keyring exposes synchronous APIs without context cancellation, so the // underlying platform call may continue after this deadline. var operationTimeout = 3 * time.Second // operationGate ensures that at most one uncancellable native call can remain // outstanding. The call keeps the gate until it actually returns; later calls // fail at their own deadline rather than accumulating blocked goroutines. var operationGate = make(chan struct{}, 1) type secretStore interface { Set(service, user, password string) error Get(service, user string) (string, error) Delete(service, user string) error } type nativeStore struct{} func (nativeStore) Set(service, user, password string) error { return systemkeyring.Set(service, user, password) } func (nativeStore) Get(service, user string) (string, error) { return systemkeyring.Get(service, user) } func (nativeStore) Delete(service, user string) error { return systemkeyring.Delete(service, user) } // defaultSecretStore is replaceable by package tests; production always uses // the operating-system implementation above. var defaultSecretStore secretStore = nativeStore{} // Status contains one bounded snapshot of native keyring availability. type Status struct { AvailableBackends []BackendType `json:"availableBackends"` ActiveBackend BackendType `json:"activeBackend"` DaemonRunning bool `json:"daemonRunning"` InstallHint string `json:"installHint"` Distro string `json:"distro"` DesktopEnv string `json:"desktopEnv"` HasPassword bool `json:"hasPassword"` Platform string `json:"platform"` Error string `json:"error,omitempty"` } // Keyring wraps the native credential store while preserving the package's // existing public API. type Keyring struct { store secretStore backend BackendType } type operationResult[T any] struct { value T err error } func operationContext(parent context.Context) (context.Context, context.CancelFunc) { if parent == nil { parent = context.Background() } return context.WithTimeout(parent, operationTimeout) } func runBounded[T any](ctx context.Context, fn func() (T, error)) (T, error) { var zero T gate := operationGate select { case gate <- struct{}{}: case <-ctx.Done(): return zero, boundedContextError(ctx.Err()) } result := make(chan operationResult[T], 1) go func() { defer func() { <-gate }() value, err := fn() result <- operationResult[T]{value: value, err: err} }() select { case result := <-result: return result.value, result.err case <-ctx.Done(): return zero, boundedContextError(ctx.Err()) } } func boundedContextError(err error) error { if errors.Is(err, context.DeadlineExceeded) { return fmt.Errorf("%w after %s", ErrOperationTimeout, operationTimeout) } return fmt.Errorf("keyring operation cancelled: %w", err) } func nativeBackendFor(goos string) BackendType { switch goos { case "linux": return BackendSecretService case "darwin": return BackendKeychain case "windows": return BackendWinCred default: return BackendNone } } func nativeBackend() BackendType { return nativeBackendFor(runtime.GOOS) } // Open prepares access to the native credential store. It deliberately does // not probe or unlock the store; all actual operations are separately bounded. func Open() (*Keyring, error) { backend := nativeBackend() if backend == BackendNone { return nil, fmt.Errorf("%w on %s", ErrNoBackend, runtime.GOOS) } return &Keyring{store: defaultSecretStore, backend: backend}, nil } // StorePassword securely stores the sudo password with the default timeout. func (k *Keyring) StorePassword(password string) error { return k.StorePasswordContext(context.Background(), password) } // StorePasswordContext stores the sudo password, respecting both the caller's // cancellation and the package's maximum native-operation timeout. func (k *Keyring) StorePasswordContext(parent context.Context, password string) error { if password == "" { return errors.New("password cannot be empty") } ctx, cancel := operationContext(parent) defer cancel() _, err := runBounded(ctx, func() (struct{}, error) { return struct{}{}, k.store.Set(ServiceName, PasswordKey, password) }) if err != nil { return fmt.Errorf("failed to store password: %w", err) } return nil } // GetPassword retrieves the stored sudo password with the default timeout. func (k *Keyring) GetPassword() (string, error) { return k.GetPasswordContext(context.Background()) } // GetPasswordContext retrieves the stored sudo password with bounded access. func (k *Keyring) GetPasswordContext(parent context.Context) (string, error) { ctx, cancel := operationContext(parent) defer cancel() password, err := runBounded(ctx, func() (string, error) { return k.store.Get(ServiceName, PasswordKey) }) if errors.Is(err, systemkeyring.ErrNotFound) { return "", ErrNoPassword } if err != nil { return "", fmt.Errorf("failed to retrieve password: %w", err) } return password, nil } // HasPassword checks whether a password exists. Errors, including a locked or // unavailable native store, conservatively report false for API compatibility. func (k *Keyring) HasPassword() bool { hasPassword, _ := k.HasPasswordContext(context.Background()) return hasPassword } // HasPasswordContext checks whether a password exists while preserving errors // so status callers can distinguish "not stored" from "store unavailable". func (k *Keyring) HasPasswordContext(parent context.Context) (bool, error) { _, err := k.GetPasswordContext(parent) if errors.Is(err, ErrNoPassword) { return false, nil } if err != nil { return false, err } return true, nil } // DeletePassword removes the stored sudo password with the default timeout. func (k *Keyring) DeletePassword() error { return k.DeletePasswordContext(context.Background()) } // DeletePasswordContext removes the stored sudo password with bounded access. func (k *Keyring) DeletePasswordContext(parent context.Context) error { ctx, cancel := operationContext(parent) defer cancel() _, err := runBounded(ctx, func() (struct{}, error) { return struct{}{}, k.store.Delete(ServiceName, PasswordKey) }) if errors.Is(err, systemkeyring.ErrNotFound) { return nil } if err != nil { return fmt.Errorf("failed to delete password: %w", err) } return nil } // Backend returns the active native backend type. func (k *Keyring) Backend() BackendType { return k.backend } // DetectStatus performs exactly one bounded credential lookup. func DetectStatus() Status { return DetectStatusContext(context.Background()) } // DetectStatusContext performs exactly one bounded credential lookup and also // respects caller cancellation. It does not inspect process names or binaries, // which are unreliable proxies for whether the user-session Secret Service can // actually answer requests. func DetectStatusContext(parent context.Context) Status { status := Status{ Platform: runtime.GOOS, ActiveBackend: BackendNone, AvailableBackends: []BackendType{}, } if runtime.GOOS == "linux" { status.Distro = detectDistro() status.DesktopEnv = detectDesktopEnvironment() status.InstallHint = generateInstallHint(status.Distro, status.DesktopEnv) } else { status.Distro = "n/a" status.DesktopEnv = "n/a" } kr, err := Open() if err == nil { status.HasPassword, err = kr.HasPasswordContext(parent) } if err != nil { status.Error = err.Error() log.Debug("Keyring: native store unavailable: " + err.Error()) return status } status.ActiveBackend = kr.Backend() status.AvailableBackends = []BackendType{kr.Backend()} status.DaemonRunning = true status.InstallHint = "" return status } // detectDistro reads /etc/os-release to determine the Linux distribution. func detectDistro() string { if runtime.GOOS != "linux" { return "n/a" } file, err := os.Open("/etc/os-release") if err != nil { return "unknown" } defer file.Close() var id, idLike string scanner := bufio.NewScanner(file) for scanner.Scan() { line := scanner.Text() if strings.HasPrefix(line, "ID=") { id = strings.Trim(strings.TrimPrefix(line, "ID="), "\"") } else if strings.HasPrefix(line, "ID_LIKE=") { idLike = strings.Trim(strings.TrimPrefix(line, "ID_LIKE="), "\"") } } switch id { case "ubuntu", "debian", "linuxmint", "pop", "elementary": return "debian" case "fedora", "rhel", "centos", "rocky", "almalinux": return "fedora" case "arch", "manjaro", "endeavouros", "garuda": return "arch" case "opensuse-tumbleweed", "opensuse-leap", "opensuse": return "suse" } for family, aliases := range map[string][]string{ "debian": {"debian"}, "fedora": {"fedora", "rhel"}, "arch": {"arch"}, "suse": {"suse"}, } { for _, alias := range aliases { if strings.Contains(idLike, alias) { return family } } } return "unknown" } func detectDesktopEnvironment() string { if runtime.GOOS != "linux" { return "n/a" } if desktop := os.Getenv("XDG_CURRENT_DESKTOP"); desktop != "" { return normalizeDesktopEnv(desktop) } if session := os.Getenv("DESKTOP_SESSION"); session != "" { return normalizeDesktopEnv(session) } return "unknown" } func normalizeDesktopEnv(env string) string { env = strings.ToLower(env) switch { case strings.Contains(env, "gnome"): return "GNOME" case strings.Contains(env, "kde") || strings.Contains(env, "plasma"): return "KDE" case strings.Contains(env, "xfce"): return "XFCE" case strings.Contains(env, "lxde") || strings.Contains(env, "lxqt"): return "LXDE" case strings.Contains(env, "mate"): return "MATE" case strings.Contains(env, "cinnamon"): return "Cinnamon" case strings.Contains(env, "budgie"): return "Budgie" case strings.Contains(env, "deepin"): return "Deepin" case strings.Contains(env, "pantheon"): return "Pantheon" case strings.Contains(env, "cosmic"): return "COSMIC" case strings.Contains(env, "hyprland"): return "Hyprland" case strings.Contains(env, "river"): return "River" case strings.Contains(env, "i3"): return "i3" case strings.Contains(env, "sway"): return "Sway" case strings.Contains(env, "awesome"): return "Awesome" case strings.Contains(env, "bspwm"): return "bspwm" case strings.Contains(env, "dwm"): return "dwm" case strings.Contains(env, "qtile"): return "qtile" default: return env } } func generateInstallHint(distro, desktop string) string { tiling := map[string]bool{ "i3": true, "Sway": true, "Hyprland": true, "River": true, "Awesome": true, "bspwm": true, "dwm": true, "qtile": true, } if tiling[desktop] { return "Start a Secret Service provider in the user session (for example: gnome-keyring-daemon --start --components=secrets)" } if desktop == "KDE" { return "Enable KDE Wallet's Secret Service integration (current KDE) or install and start GNOME Keyring" } switch distro { case "debian": return "sudo apt install gnome-keyring" case "fedora": return "sudo dnf install gnome-keyring" case "arch": return "sudo pacman -S gnome-keyring" case "suse": return "sudo zypper install gnome-keyring" default: return "Install and start a freedesktop Secret Service provider such as GNOME Keyring or current KDE Wallet" } }