#!/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 hashlib import json import os import re import subprocess import sys def secrets_name_owner_pid(): """PID currently owning the org.freedesktop.secrets D-Bus name, if any. This is the only reliable way to identify which daemon actually answers Secret Service calls right now. """ try: import gi gi.require_version("Gio", "2.0") from gi.repository import Gio, GLib bus = Gio.bus_get_sync(Gio.BusType.SESSION, None) result = bus.call_sync( "org.freedesktop.DBus", "/org/freedesktop/DBus", "org.freedesktop.DBus", "GetConnectionUnixProcessID", GLib.Variant("(s)", ("org.freedesktop.secrets",)), GLib.VariantType("(u)"), Gio.DBusCallFlags.NONE, -1, None, ) return result.unpack()[0] except Exception: # noqa: BLE001 - no name owner is a legitimate state return None def any_keyring_daemon_running(): 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" in cmdline: return True except OSError: pass return False 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. A machine can have two gnome-keyring-daemon processes at once -- a lingering PAM one alongside its D-Bus-activated replacement -- so which process this reports on matters: it must be the one that actually owns org.freedesktop.secrets right now, not merely the first one /proc happens to enumerate. """ owner_pid = secrets_name_owner_pid() if owner_pid is None: return "unknown" if any_keyring_daemon_running() else "none" try: with open(f"/proc/{owner_pid}/cmdline", "rb") as handle: cmdline = handle.read().decode("utf-8", "replace") except OSError: return "unknown" if "gnome-keyring-daemon" not in cmdline: return "unknown" try: with open(f"/proc/{owner_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" 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": "", } # Attributes worth showing. The rest are storage plumbing -- schema names, # internal ids -- and listing them turns a readable row into a debug dump. INTERESTING_ATTRIBUTES = ( "server", "host", "domain", "user", "username", "account", "protocol", "service", "object", "port", "goa-identity", "application", ) # How long a copied password stays on the clipboard. CLIPBOARD_SECONDS = 45 def item_summary(item) -> dict: """Everything about a stored secret EXCEPT the secret. Nothing in here reads the value. That is not an accident of implementation but the contract of this function: enumerating the keyring must never require the keyring to hand over what it is protecting. """ attributes = dict(item.get_attributes() or {}) shown = {key: value for key, value in attributes.items() if key in INTERESTING_ATTRIBUTES and value} return { "path": item.get_object_path(), "label": item.get_label() or "Unnamed", "schema": attributes.get("xdg:schema", ""), "attributes": shown, "created": int(item.get_created() or 0), "modified": int(item.get_modified() or 0), "locked": bool(item.get_locked()), } def items_report(service, Secret) -> dict: collections = [] for collection in service.get_collections(): entries = collection.get_items() or [] collections.append({ "label": collection.get_label() or "Unnamed keyring", "path": collection.get_object_path(), "locked": bool(collection.get_locked()), # A locked collection reports no items rather than an empty one: # "nothing stored here" and "cannot look" are different answers. "readable": not collection.get_locked(), "items": [item_summary(item) for item in entries], }) # Empty, unnamed collections are the session store and similar plumbing. collections = [entry for entry in collections if entry["items"] or entry["label"] != "Unnamed keyring"] collections.sort(key=lambda entry: (entry["label"] != "Login", entry["label"])) return {"collections": collections, "error": ""} def resolve_item(service, Secret, path: str): """An item by its D-Bus path, refusing anything that is not one. The caller is a settings page, and a settings page can be wrong or stale -- an item deleted in another window leaves a path that no longer resolves. """ if not re.fullmatch(r"/org/freedesktop/secrets/collection/[A-Za-z0-9_]+/[0-9]+", path or ""): raise ValueError("That is not a stored secret.") for collection in service.get_collections(): for item in collection.get_items() or []: if item.get_object_path() == path: return item raise ValueError("That secret no longer exists.") def copy_secret(service, Secret, path: str) -> None: """Put one stored secret on the clipboard, and nowhere else. The value is read in this process and handed to wl-copy on STDIN. It is never an argument -- argv is world-readable through /proc, so passing a password there would publish it to every process on the machine -- and it is never printed, logged, or included in an error message. """ item = resolve_item(service, Secret, path) item.load_secret_sync(None) value = item.get_secret() if value is None: raise ValueError("That secret could not be read.") secret = value.get_text() if secret is None: raise ValueError("That secret is not text.") encoded = secret.encode("utf-8") completed = subprocess.run(["wl-copy"], input=encoded, capture_output=True) if completed.returncode != 0: # Deliberately does not echo the tool's stderr: a clipboard tool that # fails while holding a password should not get to decide what lands in # a log. raise ValueError("The clipboard is not available.") # Clear it again, but only if it is still the thing we put there. The guard # compares a HASH, so the reminder process never holds the password -- and # a clipboard the user has since replaced is left alone. digest = hashlib.sha256(encoded).hexdigest() subprocess.Popen( ["sh", "-c", 'sleep "$1"; current="$(wl-paste --no-newline 2>/dev/null | sha256sum | cut -d" " -f1)";' ' [ "$current" = "$2" ] && wl-copy --clear', "sh", str(CLIPBOARD_SECONDS), digest], stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, start_new_session=True, ) def forget_secret(service, Secret, path: str) -> None: """Delete one stored secret. Irreversible, and the UI confirms first.""" item = resolve_item(service, Secret, path) item.delete_sync(None) def main(): action = sys.argv[1] if len(sys.argv) > 1 else "status" target = sys.argv[2] if len(sys.argv) > 2 else "" if action not in ("status", "unlock", "items", "copy", "forget"): print("usage: panama-keyring [status|unlock|items|copy PATH|forget PATH]", file=sys.stderr) return 2 if action in ("copy", "forget") and not target: print(f"usage: panama-keyring {action} PATH", 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 # These three answer with the item list, so a page never has to ask twice # to find out what changed. if action in ("items", "copy", "forget"): try: if action == "copy": copy_secret(service, Secret, target) elif action == "forget": forget_secret(service, Secret, target) Secret, service = load_service() except Exception as error: # noqa: BLE001 # The message is this module's own, never the underlying tool's: # an exception raised while a secret is in hand must not get to # decide what text reaches a log or a settings page. payload = items_report(service, Secret) payload["error"] = (str(error) if isinstance(error, ValueError) else "That secret could not be used.") print(json.dumps(payload)) return 0 print(json.dumps(items_report(service, Secret))) 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())