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:
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user