#!/usr/bin/env python3 """The login keyring's lock state, and a way to unlock it. Why this exists --------------- GNOME unlocks the login keyring at sign-in through pam_gnome_keyring, and so does this desktop -- the PAM stack is GDM's and it works. What GNOME also has, and a bare Hyprland session does not, is anywhere to SEE that it failed. It does fail, rarely. gnome-keyring-daemon can crash (an upstream abort in service_method_open_session, seen once here), and when it does, D-Bus activates a replacement. That replacement never received the login password, so the login keyring comes back LOCKED in the middle of a session that unlocked it correctly at login. Everything that stores a secret then starts failing in ways that do not mention keyrings at all: a mail client that will not authenticate, a git push that cannot find its key, an integration that reports "not configured". So this reports the state plainly and offers the one action that fixes it. Unlocking prompts ----------------- `unlock` asks the Secret Service to unlock, which raises the gcr password dialog. That is deliberate: the password is not ours to store or handle, and it never passes through this script. The dialog is the same one GNOME shows. Note that a locked keyring makes a NON-INTERACTIVE caller appear to hang -- it is not hung, it is waiting for a dialog nobody is looking at. That is worth knowing before debugging one for an hour. Usage: panama-keyring status -> {"available", "locked", "collections", "daemon"} panama-keyring unlock -> raises the password prompt; prints the new state """ import json import os import re import sys def daemon_origin(): """Whether the running secrets daemon came from PAM or from D-Bus activation. A D-Bus-activated daemon is the signature of the crash-and-replace case above: it is the one that cannot have the login password. PAM's daemon lives outside the app slice, so the cgroup tells the two apart. """ try: for pid in os.listdir("/proc"): if not pid.isdigit(): continue try: with open(f"/proc/{pid}/cmdline", "rb") as handle: cmdline = handle.read().decode("utf-8", "replace") except OSError: continue if "gnome-keyring-daemon" not in cmdline: continue try: with open(f"/proc/{pid}/cgroup", "r") as handle: cgroup = handle.read() except OSError: return "unknown" if re.search(r"dbus-.*org\.freedesktop\.secrets", cgroup): return "dbus" return "pam" except OSError: pass return "none" def load_service(): import gi gi.require_version("Secret", "1") from gi.repository import Secret return Secret, Secret.Service.get_sync(Secret.ServiceFlags.LOAD_COLLECTIONS, None) def report(service, Secret): collections = [ {"label": c.get_label(), "locked": c.get_locked()} for c in service.get_collections() ] # The login keyring is the one that matters; the others are per-application # stores that manage their own unlocking. login = next((c for c in collections if c["label"] == "Login"), None) return { "available": True, "locked": bool(login["locked"]) if login else False, "hasLogin": login is not None, "collections": collections, "daemon": daemon_origin(), "error": "", } def main(): action = sys.argv[1] if len(sys.argv) > 1 else "status" if action not in ("status", "unlock"): print("usage: panama-keyring [status|unlock]", file=sys.stderr) return 2 try: Secret, service = load_service() except Exception as error: # noqa: BLE001 - any failure here is "no keyring" # No Secret Service at all is a legitimate state, not a crash: report it # so the UI can say so instead of showing an empty card. print(json.dumps({ "available": False, "locked": False, "hasLogin": False, "collections": [], "daemon": daemon_origin(), "error": f"The secret service is not answering: {error}", })) return 0 if action == "unlock": login = next( (c for c in service.get_collections() if c.get_label() == "Login"), None) if login is not None and login.get_locked(): try: # Blocks until the dialog is answered or dismissed. service.unlock_sync([login], None) except Exception as error: # noqa: BLE001 state = report(service, Secret) state["error"] = f"The keyring was not unlocked: {error}" print(json.dumps(state)) return 0 # The collection object caches its state; re-read it. Secret, service = load_service() print(json.dumps(report(service, Secret))) return 0 if __name__ == "__main__": sys.exit(main())