Files
Panama/config/dot/quickshell/scripts/panama-keyring
T
Gabriel Brown 8e93f08977 Show what the keyring holds, without showing what it holds
Managing a stored credential meant installing Seahorse. The keyring
rows on Privacy could say whether it was locked and nothing about what
was in it.

Four rules, each pinned by a contract, because each is a way this could
leak the thing it exists to protect:

Listing never reads values. Enumerating reports labels and attributes;
it does not ask the keyring to hand over what it is protecting.

A secret never reaches a command line. /proc makes argv readable by
every process on this machine, so a password passed as an argument is
published to all of them. The helper reads the value in process and
writes it to wl-copy on stdin.

A secret never reaches an error message, a log, or a QML property. An
exception raised while holding a password does not get to choose what
text is printed, so the clipboard tool's stderr is discarded rather
than echoed.

Forgetting one is irreversible, so the first press asks and the second
does it, and the confirming button is the only one wearing danger.

The list is collapsed until asked for: opening Privacy should not
enumerate someone's passwords as a side effect. A copied value clears
itself about a minute later, but only if the clipboard still holds it --
the guard compares a SHA-256, so the waiting process never has the
password.

Claude-Session: https://claude.ai/code/session_01BRvzt4H8XXLPVH5MyYdk9L
2026-08-19 11:14:32 -04:00

329 lines
12 KiB
Python
Executable File

#!/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())