// Package keyring provides secure password storage for sudo authentication // in Web UI mode. It supports multiple backends including: // - Linux: SecretService (GNOME Keyring), KWallet, pass, file-based // - macOS: Keychain (native) // - Windows: Credential Manager (native) package keyring import ( "bufio" "errors" "fmt" "os" "os/exec" "runtime" "strconv" "strings" "github.com/99designs/keyring" "gitgud.io/mike/mpv-manager/pkg/log" ) const ( // ServiceName is the application identifier for the keyring ServiceName = "mpv-manager" // PasswordKey is the key used to store the sudo password PasswordKey = "sudo-password" ) // BackendType represents a keyring backend type BackendType string const ( // BackendSecretService is the GNOME Keyring/libsecret backend BackendSecretService BackendType = "secret-service" // BackendKWallet is the KDE Wallet backend BackendKWallet BackendType = "kwallet" // BackendPass is the pass (password-store) backend BackendPass BackendType = "pass" // BackendFile is the encrypted file-based backend BackendFile BackendType = "file" // BackendNone indicates no backend is available BackendNone BackendType = "none" ) // Status contains information about keyring availability and configuration type Status struct { // AvailableBackends lists all backends detected on the system AvailableBackends []BackendType `json:"availableBackends"` // ActiveBackend is the backend currently being used ActiveBackend BackendType `json:"activeBackend"` // DaemonRunning indicates if a keyring daemon is running DaemonRunning bool `json:"daemonRunning"` // InstallHint contains installation command for missing dependencies InstallHint string `json:"installHint"` // Distro is the detected Linux distribution Distro string `json:"distro"` // DesktopEnv is the detected desktop environment DesktopEnv string `json:"desktopEnv"` // HasPassword indicates if a password is currently stored HasPassword bool `json:"hasPassword"` // Platform is the current operating system Platform string `json:"platform"` } // Keyring wraps the keyring.Keyring interface with additional functionality type Keyring struct { ring keyring.Keyring backend BackendType } // DetectStatus detects available keyring backends and system environment func DetectStatus() Status { log.Debug("Keyring: Detecting status...") status := Status{ Platform: runtime.GOOS, DaemonRunning: true, // Assume true for non-Linux ActiveBackend: BackendNone, AvailableBackends: []BackendType{}, } // Non-Linux platforms have native keyring support if runtime.GOOS != "linux" { log.Debug("Keyring: Non-Linux platform, using native keyring") status.AvailableBackends = []BackendType{BackendFile} status.ActiveBackend = BackendFile status.Distro = "n/a" status.DesktopEnv = "n/a" return status } // Linux-specific detection status.Distro = detectDistro() status.DesktopEnv = detectDesktopEnvironment() log.Debug(fmt.Sprintf("Keyring: Detected distro=%s, desktopEnv=%s", status.Distro, status.DesktopEnv)) // Detect available backends backends := detectAvailableBackends() status.AvailableBackends = backends log.Debug(fmt.Sprintf("Keyring: Available backends: %v", backends)) // Determine the active backend - prefer one with a running daemon if len(backends) > 0 { // First, try to find a backend with a running daemon for _, backend := range backends { if checkDaemonRunning(backend) { status.ActiveBackend = backend status.DaemonRunning = true break } } // If no running daemon found, use the first available backend if status.ActiveBackend == BackendNone { status.ActiveBackend = backends[0] status.DaemonRunning = checkDaemonRunning(backends[0]) } } log.Debug(fmt.Sprintf("Keyring: Active backend: %s, daemon running: %v", status.ActiveBackend, status.DaemonRunning)) // Generate install hint if no suitable backend found // Don't show hint if file backend is available (it's always usable) if status.ActiveBackend == BackendNone { status.InstallHint = generateInstallHint(status.Distro, status.DesktopEnv) } else if !status.DaemonRunning && status.ActiveBackend != BackendFile && status.ActiveBackend != BackendPass { // Only show hint for daemon-based backends that aren't running status.InstallHint = generateInstallHint(status.Distro, status.DesktopEnv) } // Check if password is stored kr, err := Open() if err == nil { status.HasPassword = kr.HasPassword() } log.Debug(fmt.Sprintf("Keyring: Status complete - hasPassword: %v", status.HasPassword)) return status } // Open opens the keyring with backends in priority order func Open() (*Keyring, error) { // Non-Linux platforms use native keyring if runtime.GOOS != "linux" { ring, err := keyring.Open(keyring.Config{ ServiceName: ServiceName, }) if err != nil { return nil, fmt.Errorf("failed to open keyring: %w", err) } return &Keyring{ring: ring, backend: BackendFile}, nil } // Linux: try backends in priority order backends := []keyring.BackendType{ keyring.SecretServiceBackend, keyring.KWalletBackend, keyring.PassBackend, keyring.FileBackend, } var lastErr error for _, backend := range backends { config := keyring.Config{ KeychainTrustApplication: true, FilePasswordFunc: func(prompt string) (string, error) { // For file backend, use a fixed password based on machine ID // This is less secure but allows non-interactive use return "mpv-manager-file-keyring", nil }, } // Configure backend-specific settings switch backend { case keyring.KWalletBackend: // Use default KDE wallet with our own folder config.ServiceName = "kdewallet" config.KWalletFolder = "mpv-manager" config.KWalletAppID = "mpv-manager" log.Debug("Keyring: Trying KWallet backend with kdewallet/mpv-manager folder") case keyring.SecretServiceBackend: // Use our own collection for SecretService config.ServiceName = ServiceName log.Debug("Keyring: Trying SecretService backend") default: config.ServiceName = ServiceName log.Debug(fmt.Sprintf("Keyring: Trying %s backend", backend)) } config.AllowedBackends = []keyring.BackendType{backend} ring, err := keyring.Open(config) if err != nil { log.Debug(fmt.Sprintf("Keyring: Backend %s failed: %v", backend, err)) lastErr = err continue } // Test if backend is functional backendType := backendTypeFromKeyring(backend) log.Info(fmt.Sprintf("Keyring: Successfully opened backend: %s", backendType)) return &Keyring{ring: ring, backend: backendType}, nil } log.Error(fmt.Sprintf("Keyring: No working backend available: %v", lastErr)) return nil, fmt.Errorf("no working keyring backend available: %w", lastErr) } // StorePassword securely stores the sudo password func (k *Keyring) StorePassword(password string) error { if password == "" { return errors.New("password cannot be empty") } log.Debug(fmt.Sprintf("Keyring: Storing password in backend: %s", k.backend)) err := k.ring.Set(keyring.Item{ Key: PasswordKey, Data: []byte(password), }) if err != nil { log.Error(fmt.Sprintf("Keyring: Failed to store password: %v", err)) return fmt.Errorf("failed to store password: %w", err) } log.Info("Keyring: Password stored successfully") return nil } // GetPassword retrieves the stored sudo password func (k *Keyring) GetPassword() (string, error) { log.Debug(fmt.Sprintf("Keyring: Retrieving password from backend: %s", k.backend)) item, err := k.ring.Get(PasswordKey) if err != nil { if errors.Is(err, keyring.ErrKeyNotFound) { log.Debug("Keyring: No password stored") return "", errors.New("no password stored") } log.Error(fmt.Sprintf("Keyring: Failed to retrieve password: %v", err)) return "", fmt.Errorf("failed to retrieve password: %w", err) } log.Debug("Keyring: Password retrieved successfully") return string(item.Data), nil } // HasPassword checks if a password is currently stored func (k *Keyring) HasPassword() bool { _, err := k.ring.Get(PasswordKey) hasPassword := err == nil log.Debug(fmt.Sprintf("Keyring: HasPassword check: %v", hasPassword)) return hasPassword } // DeletePassword removes the stored sudo password func (k *Keyring) DeletePassword() error { err := k.ring.Remove(PasswordKey) if err != nil && !errors.Is(err, keyring.ErrKeyNotFound) { return fmt.Errorf("failed to delete password: %w", err) } return nil } // Backend returns the currently active backend type func (k *Keyring) Backend() BackendType { return k.backend } // 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 string var 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="), "\"") } } // Map to known distro families 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" } // Check ID_LIKE for derivative distros if strings.Contains(idLike, "debian") { return "debian" } if strings.Contains(idLike, "fedora") || strings.Contains(idLike, "rhel") { return "fedora" } if strings.Contains(idLike, "arch") { return "arch" } if strings.Contains(idLike, "suse") { return "suse" } return "unknown" } // detectDesktopEnvironment determines the current desktop environment func detectDesktopEnvironment() string { if runtime.GOOS != "linux" { return "n/a" } // Check XDG_CURRENT_DESKTOP first if de := os.Getenv("XDG_CURRENT_DESKTOP"); de != "" { return normalizeDesktopEnv(de) } // Check DESKTOP_SESSION as fallback if session := os.Getenv("DESKTOP_SESSION"); session != "" { return normalizeDesktopEnv(session) } return "unknown" } // normalizeDesktopEnv normalizes desktop environment names 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 } } // generateInstallHint creates installation instructions based on distro and DE func generateInstallHint(distro, de string) string { // Tiling WMs and compositors need special setup tilingWMs := map[string]bool{ "i3": true, "Sway": true, "Hyprland": true, "River": true, "Awesome": true, "bspwm": true, "dwm": true, "qtile": true, } if tilingWMs[de] { return "Install gnome-keyring and add 'exec gnome-keyring-daemon --start --components=secrets' to your WM config" } switch distro { case "debian": return "sudo apt install gnome-keyring libsecret-1-dev" case "fedora": return "sudo dnf install gnome-keyring libsecret-devel" case "arch": return "sudo pacman -S gnome-keyring libsecret" case "suse": return "sudo zypper install gnome-keyring libsecret-devel" default: // Generic hint based on desktop environment switch de { case "KDE": return "Install kwallet from your package manager" case "GNOME", "Cinnamon", "Pantheon", "XFCE", "MATE", "Budgie", "COSMIC": return "Install gnome-keyring and libsecret from your package manager" default: return "Install a keyring service (gnome-keyring, kwallet, or pass) from your package manager" } } } // detectAvailableBackends checks which backends are available on the system func detectAvailableBackends() []BackendType { backends := []BackendType{} // Check for KWallet (KDE5 and KDE6) - check this first for KDE systems if checkCommandExists("kwalletd") || checkCommandExists("kwalletd5") || checkCommandExists("kwalletd6") { backends = append(backends, BackendKWallet) } // Check for SecretService (GNOME Keyring / libsecret) // Multiple paths for different distros secretServicePaths := []string{ "/usr/bin/gnome-keyring-daemon", "/usr/libexec/gnome-keyring-daemon", "/usr/lib/gnome-keyring-daemon", "/usr/lib64/gnome-keyring-daemon", } hasSecretService := checkCommandExists("gnome-keyring-daemon") if !hasSecretService { for _, path := range secretServicePaths { if checkFileExists(path) { hasSecretService = true break } } } // Also check for libsecret which is what we actually need if !hasSecretService { hasSecretService = checkFileExists("/usr/lib/libsecret-1.so") || checkFileExists("/usr/lib64/libsecret-1.so") || checkFileExists("/usr/lib/x86_64-linux-gnu/libsecret-1.so") } if hasSecretService { backends = append(backends, BackendSecretService) } // Check for pass (must be initialized with a GPG key) if checkCommandExists("pass") { // Check if pass has been initialized (has .password-store directory) homeDir, _ := os.UserHomeDir() if homeDir != "" && checkFileExists(homeDir+"/.password-store") { backends = append(backends, BackendPass) } } // File backend is always available as fallback backends = append(backends, BackendFile) return backends } // checkDaemonRunning checks if the keyring daemon is running func checkDaemonRunning(backend BackendType) bool { var running bool switch backend { case BackendSecretService: // Check if gnome-keyring-daemon is running running = checkProcessRunning("gnome-keyring-d") // truncated name case BackendKWallet: // Check if kwalletd is running (handles kwalletd, kwalletd5, kwalletd6) running = checkProcessRunning("kwalletd") case BackendPass: // pass doesn't need a daemon running = true case BackendFile: // File backend doesn't need a daemon running = true default: running = false } log.Debug(fmt.Sprintf("Keyring: checkDaemonRunning(%s) = %v", backend, running)) return running } // checkCommandExists checks if a command exists in PATH func checkCommandExists(cmd string) bool { _, err := exec.LookPath(cmd) return err == nil } // checkFileExists checks if a file exists func checkFileExists(path string) bool { _, err := os.Stat(path) return err == nil } // checkProcessRunning checks if a process is running (by name pattern) func checkProcessRunning(name string) bool { // Read /proc to find running processes entries, err := os.ReadDir("/proc") if err != nil { log.Debug(fmt.Sprintf("Keyring: Failed to read /proc: %v", err)) return false } for _, entry := range entries { if !entry.IsDir() { continue } // Check if directory name is a PID pid := entry.Name() if _, err := strconv.Atoi(pid); err != nil { continue } // Read the comm file to get process name commPath := fmt.Sprintf("/proc/%s/comm", pid) data, err := os.ReadFile(commPath) if err != nil { continue } procName := strings.TrimSpace(string(data)) if strings.Contains(procName, name) { log.Debug(fmt.Sprintf("Keyring: Found running process '%s' (looking for '%s')", procName, name)) return true } } log.Debug(fmt.Sprintf("Keyring: No running process found matching '%s'", name)) return false } // backendTypeFromKeyring converts keyring.BackendType to our BackendType func backendTypeFromKeyring(backend keyring.BackendType) BackendType { switch backend { case keyring.SecretServiceBackend: return BackendSecretService case keyring.KWalletBackend: return BackendKWallet case keyring.PassBackend: return BackendPass case keyring.FileBackend: return BackendFile default: return BackendNone } }