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
This commit is contained in:
Gabriel Brown
2026-08-19 11:14:32 -04:00
parent 99433c0e8e
commit 8e93f08977
6 changed files with 552 additions and 4 deletions
+142 -2
View File
@@ -33,9 +33,11 @@ Usage:
panama-keyring unlock -> raises the password prompt; prints the new state
"""
import hashlib
import json
import os
import re
import subprocess
import sys
@@ -147,10 +149,127 @@ def report(service, Secret):
}
# 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"
if action not in ("status", "unlock"):
print("usage: panama-keyring [status|unlock]", file=sys.stderr)
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:
@@ -165,6 +284,27 @@ def main():
}))
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)