diff --git a/config/dot/quickshell/modules/settings/PrivacyPage.qml b/config/dot/quickshell/modules/settings/PrivacyPage.qml index 59392c7..ec0c398 100644 --- a/config/dot/quickshell/modules/settings/PrivacyPage.qml +++ b/config/dot/quickshell/modules/settings/PrivacyPage.qml @@ -19,6 +19,25 @@ SettingsPage { id: root title: "Privacy & Security" + + // The stored-secret list is collapsed until asked for, and one item at a + // time can be waiting on a confirmed Forget. + property bool showingSecrets: false + property string confirmingPath: "" + + // What a stored secret is FOR, from its attributes. Never its value. + function describe(item: var): string { + const attributes = item?.attributes ?? {}; + const parts = []; + for (const key of ["user", "username", "account", "server", "host", "domain", "service", "application"]) { + if (attributes[key]) + parts.push(String(attributes[key])); + } + if (parts.length > 0) + return parts.join(" · "); + const schema = String(item?.schema ?? ""); + return schema !== "" ? schema : "No further detail stored"; + } lede: DeviceSecurity.scanned && DeviceSecurity.attentionCount === 0 ? "Screen lock, device access, and a machine whose security settings all check out." : "Screen lock, which applications can see you, and how this machine is protected." @@ -96,6 +115,140 @@ SettingsPage { } } + + // ── What is actually stored ────────────────────────────────────────────── + // Collapsed until asked. Opening the Privacy page should not enumerate + // someone's saved passwords as a side effect, and the list is long enough + // that it would bury every other setting on the page. + SettingsCard { + visible: Keyring.scanned && Keyring.available && !Keyring.locked + title: "Stored secrets" + subtitle: Keyring.listed + ? "Passwords and tokens applications have saved. The values are never shown here." + : "Passwords and tokens applications have saved, listed only when you ask." + + ActionRow { + label: "Saved items" + detail: Keyring.listed + ? Keyring.storedCount + " stored across " + + Keyring.collections.length + " keyring" + + (Keyring.collections.length === 1 ? "" : "s") + : "Read the keyring and list what is in it" + action: root.showingSecrets + ? "Hide" + : (Keyring.listing ? "Reading…" : "Show") + enabled: !Keyring.listing + divider: root.showingSecrets + onTriggered: { + if (root.showingSecrets) { + root.showingSecrets = false; + root.confirmingPath = ""; + return; + } + root.showingSecrets = true; + if (!Keyring.listed) + Keyring.list(); + } + } + + TextRow { + visible: root.showingSecrets && Keyring.copiedPath !== "" + label: "Copied to the clipboard" + detail: "It clears itself in about a minute, unless you copy something else first." + value: "" + divider: true + } + + Repeater { + model: root.showingSecrets && Keyring.listed ? Keyring.collections : [] + + delegate: Column { + id: collectionBlock + + required property var modelData + + width: parent.width + + TextRow { + width: collectionBlock.width + label: String(collectionBlock.modelData.label ?? "") + detail: collectionBlock.modelData.locked + ? "Locked, so its contents cannot be listed" + : (collectionBlock.modelData.items ?? []).length + " stored" + value: "" + divider: false + } + + Repeater { + model: collectionBlock.modelData.items ?? [] + + delegate: SettingRow { + id: secretRow + + required property var modelData + required property int index + + readonly property string itemPath: String(secretRow.modelData.path ?? "") + readonly property bool confirming: root.confirmingPath === secretRow.itemPath + + width: collectionBlock.width + label: String(secretRow.modelData.label ?? "") + // Attributes, never the value: what the secret is FOR is + // the part that identifies it. + detail: root.describe(secretRow.modelData) + controlWidth: 200 + divider: secretRow.index < (collectionBlock.modelData.items ?? []).length - 1 + + Row { + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + spacing: 8 + + SettingsButton { + text: secretRow.confirming ? "Cancel" : "Copy" + enabled: !Keyring.working + onClicked: { + if (secretRow.confirming) { + root.confirmingPath = ""; + return; + } + Keyring.copy(secretRow.itemPath); + } + } + + SettingsButton { + // Two presses, always. Forgetting a stored + // password cannot be undone, and the button + // sits next to Copy where a misclick is cheap. + text: secretRow.confirming ? "Forget it" : "Forget" + tone: secretRow.confirming ? "danger" : "normal" + enabled: !Keyring.working + onClicked: { + if (!secretRow.confirming) { + root.confirmingPath = secretRow.itemPath; + return; + } + root.confirmingPath = ""; + Keyring.forget(secretRow.itemPath); + } + } + } + } + } + + Item { width: 1; height: 6 } + } + } + + TextRow { + visible: root.showingSecrets && Keyring.listed && Keyring.storedCount === 0 + label: "Nothing stored yet" + detail: "Applications that save a password or token will appear here." + value: "" + divider: false + } + } + SettingsCard { title: "Camera & microphone" subtitle: PrivacyState.anyActive diff --git a/config/dot/quickshell/modules/settings/SettingsButton.qml b/config/dot/quickshell/modules/settings/SettingsButton.qml index f50b4b0..0ca31f3 100644 --- a/config/dot/quickshell/modules/settings/SettingsButton.qml +++ b/config/dot/quickshell/modules/settings/SettingsButton.qml @@ -5,6 +5,10 @@ Rectangle { id: root property string text: "" + // "normal", "accent", or "danger". Danger is for an action that cannot be + // undone -- it never initiates one on a single press, it marks the press + // that confirms it, so the confirming button cannot be mistaken for the + // Cancel sitting next to it. property string tone: "normal" property bool enabled: true signal clicked @@ -16,16 +20,20 @@ Rectangle { color: { if (tone === "accent") return mouse.containsMouse ? Theme.mix(Theme.accent, Theme.fg, 0.12) : Theme.accent; + if (tone === "danger") + return mouse.containsMouse ? Theme.alpha(Theme.danger, 0.22) : Theme.alpha(Theme.danger, 0.12); return mouse.containsMouse ? Theme.alpha(Theme.fg, 0.13) : Theme.alpha(Theme.fg, 0.075); } border.width: tone === "accent" ? 0 : 1 - border.color: Theme.alpha(Theme.fg, 0.08) + border.color: tone === "danger" ? Theme.alpha(Theme.danger, 0.45) : Theme.alpha(Theme.fg, 0.08) Text { id: label anchors.centerIn: parent text: root.text - color: root.tone === "accent" ? Theme.bgDark : Theme.fg + color: root.tone === "accent" + ? Theme.bgDark + : (root.tone === "danger" ? Theme.danger : Theme.fg) font.family: Theme.fontFamily font.pixelSize: Theme.fontSizeSmall font.weight: Font.Medium diff --git a/config/dot/quickshell/scripts/panama-keyring b/config/dot/quickshell/scripts/panama-keyring index db2c1f0..16c4537 100755 --- a/config/dot/quickshell/scripts/panama-keyring +++ b/config/dot/quickshell/scripts/panama-keyring @@ -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) diff --git a/config/dot/quickshell/services/Keyring.qml b/config/dot/quickshell/services/Keyring.qml index 9865976..1cd9221 100644 --- a/config/dot/quickshell/services/Keyring.qml +++ b/config/dot/quickshell/services/Keyring.qml @@ -39,6 +39,25 @@ Singleton { readonly property bool replacementDaemon: root.daemon === "dbus" + // What is stored, never what is stored IN it. No property on this service + // ever holds a password: the value is read by the helper, handed straight + // to the clipboard, and forgotten. It does not cross into QML at all. + property var collections: [] + property bool listed: false + property bool listing: false + property bool working: false + + // Set for a moment after a successful copy, so the page can say what + // happened without the page having to know how long a clipboard lasts. + property string copiedPath: "" + + readonly property int storedCount: { + let total = 0; + for (const collection of root.collections) + total += (collection.items ?? []).length; + return total; + } + function refresh(): void { if (!query.running) query.running = true; @@ -53,6 +72,54 @@ Singleton { unlockProcess.running = true; } + // The stored-secret list. Separate from status() because it is the only + // read that needs the keyring UNLOCKED, and because a settings page should + // not enumerate someone's passwords just because it was opened. + function list(): void { + if (root.listing) + return; + root.listing = true; + items.command = [root.helperPath, "items"]; + items.running = true; + } + + // Puts one stored secret on the clipboard. The value never reaches this + // process; the helper reads it and writes it to wl-copy's stdin, and clears + // it again shortly afterwards if it is still there. + function copy(path: string): void { + if (root.working) + return; + root.working = true; + root.copiedPath = path; + items.command = [root.helperPath, "copy", path]; + items.running = true; + } + + // Irreversible. The page confirms before calling this. + function forget(path: string): void { + if (root.working) + return; + root.working = true; + root.copiedPath = ""; + items.command = [root.helperPath, "forget", path]; + items.running = true; + } + + function absorbItems(text: string): void { + try { + const parsed = JSON.parse(text); + root.collections = Array.isArray(parsed.collections) ? parsed.collections : []; + root.lastError = String(parsed.error ?? ""); + if (root.lastError !== "") + root.copiedPath = ""; + } catch (error) { + root.collections = []; + root.lastError = "Could not read the stored secrets."; + console.warn("Keyring: could not parse item output:", error); + } + root.listed = true; + } + function absorb(text: string): void { try { const parsed = JSON.parse(text); @@ -74,6 +141,27 @@ Singleton { stdout: StdioCollector { onStreamFinished: root.absorb(this.text) } } + // One process for all three item operations: each of them answers with the + // same list, so a page never has to ask again to find out what changed. + Process { + id: items + stdout: StdioCollector { onStreamFinished: root.absorbItems(this.text) } + stderr: StdioCollector { + onStreamFinished: if (this.text.trim() !== "") root.lastError = this.text.trim() + } + onExited: { + root.listing = false; + root.working = false; + } + } + + // The clipboard notice is transient, and says so by disappearing. + Timer { + running: root.copiedPath !== "" + interval: 12000 + onTriggered: root.copiedPath = "" + } + Process { id: unlockProcess command: [root.helperPath, "unlock"] diff --git a/docs/superpowers/plans/2026-08-19-desktop-integration.md b/docs/superpowers/plans/2026-08-19-desktop-integration.md index 52479e4..936402f 100644 --- a/docs/superpowers/plans/2026-08-19-desktop-integration.md +++ b/docs/superpowers/plans/2026-08-19-desktop-integration.md @@ -146,6 +146,21 @@ Seahorse. **Done when** a stored password can be found, inspected and removed without leaving Settings, and the contracts prove no value leaks on the way. +**Landed 2026-08-19.** Built into Privacy & Security rather than as its own +page: that page already claimed the topic, already had the keyring rows, and +already answered the "saved passwords" search. A new page would have been a +fourth page-registry edit for a card. + +The list is collapsed until asked for -- opening Privacy should not enumerate +someone's passwords as a side effect -- and copying puts the value on the +clipboard without it ever entering Quickshell: the helper reads it, writes it to +`wl-copy` on stdin, and a detached guard clears it about a minute later only if +the clipboard still holds it, comparing a SHA-256 rather than the password. + +Seahorse is not installed, so changing a keyring's password is not offered. +Claiming a button that hands off to a missing application would be worse than +not having it. + ## Phase 6 — Backups (large, staged) The one genuinely missing safety net. `restic` is not installed. diff --git a/tests/quickshell/secrets-contract.sh b/tests/quickshell/secrets-contract.sh new file mode 100755 index 0000000..d89d63b --- /dev/null +++ b/tests/quickshell/secrets-contract.sh @@ -0,0 +1,144 @@ +#!/usr/bin/env bash + +# A stored password must never leave the keyring except onto the clipboard, +# on purpose, one at a time. +# +# Four rules, each a way this feature could leak what it exists to protect: +# +# 1. Listing secrets must not read them. Enumerating the keyring reports +# labels and attributes; it never asks the keyring for a value. +# 2. A secret must never reach a command line. /proc makes argv readable by +# every process on the machine, so a password passed as an argument is +# published to all of them. It goes on stdin or not at all. +# 3. A secret must never reach an error message, a log, or the settings page. +# An exception raised while holding a password does not get to choose what +# text is printed. +# 4. Forgetting one is irreversible, so the page confirms first. +# +# Read-only. It lists the real keyring -- which is safe, because listing is the +# thing being verified as safe -- and never copies, deletes, or unlocks. + +set -uo pipefail + +repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +helper="$repo_dir/config/dot/quickshell/scripts/panama-keyring" +service="$repo_dir/config/dot/quickshell/services/Keyring.qml" +page="$repo_dir/config/dot/quickshell/modules/settings/PrivacyPage.qml" + +fail() { + printf 'secrets contract: %s\n' "$1" >&2 + exit 1 +} + +for path in "$helper" "$service" "$page"; do + [[ -r "$path" ]] || fail "missing $path" +done +[[ -x "$helper" ]] || fail 'panama-keyring is not executable' + +# ── 1. Listing does not read values ────────────────────────────────────────── +summary_body="$(sed -n '/^def item_summary/,/^def /p' "$helper")" +[[ -n "$summary_body" ]] || fail 'item_summary is missing' +grep -qE 'load_secret|get_secret|get_text' <<<"$summary_body" \ + && fail 'the item summary reads secret values; enumerating must never require the value' + +report_body="$(sed -n '/^def items_report/,/^def /p' "$helper")" +grep -qE 'load_secret|get_secret' <<<"$report_body" \ + && fail 'the item report reads secret values' + +# ── 2. No secret on a command line ─────────────────────────────────────────── +copy_body="$(sed -n '/^def copy_secret/,/^def /p' "$helper")" +[[ -n "$copy_body" ]] || fail 'copy_secret is missing' +grep -q 'input=encoded' <<<"$copy_body" \ + || fail 'the secret does not reach the clipboard tool on stdin' +# The value must not appear inside any argument list. +grep -qE '\[.*(secret|encoded).*\]' <<<"$copy_body" \ + && fail 'the secret appears inside a command argument list, which publishes it through /proc' +grep -q 'digest = hashlib.sha256' <<<"$copy_body" \ + || fail 'the clipboard is cleared without comparing a hash, so it either holds the secret or clears the wrong thing' +grep -qE 'Popen\(.*secret|Popen\(.*encoded' <<<"$copy_body" \ + && fail 'the clipboard-clearing process is handed the secret' + +# ── 3. No secret in output ─────────────────────────────────────────────────── +grep -qE '^\s*print\((secret|value|encoded)' <<<"$copy_body" \ + && fail 'the secret is printed' +grep -q 'completed.stderr' <<<"$copy_body" \ + && fail "the clipboard tool's stderr is echoed while a secret is in hand" + +# The service must not hold one either. +grep -qiE 'property (string|var) (secret|password|value)\b' "$service" \ + && fail 'the Keyring service declares a property that would hold a secret value' + +# ── 4. Forgetting is confirmed ─────────────────────────────────────────────── +grep -q 'confirmingPath' "$page" \ + || fail 'the page deletes a stored secret without a confirmation step' +grep -q 'Keyring.forget(' "$page" \ + || fail 'the page cannot forget a secret at all' +# The guard itself, not merely the word "confirming" somewhere nearby: the +# button's own label and tone both mention it, so proximity proves nothing. +# What must exist is the early return that turns the FIRST press into a request +# for confirmation rather than a deletion. +grep -q 'if (!secretRow.confirming)' "$page" \ + || fail 'the first press on Forget is not turned into a confirmation step' +grep -q 'root.confirmingPath = secretRow.itemPath;' "$page" \ + || fail 'nothing records which item is awaiting confirmation' + +# Opening the page must not enumerate anyone's passwords as a side effect. +grep -qE 'Component.onCompleted:.*Keyring.list\(\)' "$page" \ + && fail 'the page lists stored secrets when it opens rather than when asked' + +# ── The list itself, on the real keyring ───────────────────────────────────── +command -v jq >/dev/null 2>&1 || { printf 'secrets contract: SKIP (no jq)\n'; exit 0; } +listing="$("$helper" items 2>/dev/null)" || fail 'listing stored secrets failed' +jq -e '.collections | type == "array"' <<<"$listing" >/dev/null \ + || fail 'the listing has no collections' + +# No field anywhere in the payload may be named like a value. +offenders="$(jq -r '[paths | map(tostring) | join(".")] | map(select(test("(secret|password|value|token)$";"i"))) | join(", ")' <<<"$listing")" +[[ -z "$offenders" ]] || fail "the listing carries value-shaped fields: $offenders" + +# Every item reports where it came from, so a row can be identified without it. +jq -e '[.collections[].items[] | (.path | startswith("/org/freedesktop/secrets/")) and (.label | length > 0)] | all' \ + <<<"$listing" >/dev/null || fail 'an item is missing its path or label' + +# ── Refusals ───────────────────────────────────────────────────────────────── +# A refused path must not reach the clipboard at all, which is checked by +# stubbing the clipboard tool rather than inferred from an exit code -- the +# helper answers with the item list plus an error field, so its exit status is +# deliberately 0 even when it refuses. +work="$(mktemp -d /tmp/panama-secrets.XXXXXX)" +trap 'rm -rf "$work"' EXIT +mkdir -p "$work/bin" +cat >"$work/bin/wl-copy" <<'STUB' +#!/usr/bin/env bash +printf 'called: %s\n' "$*" >>"$PANAMA_SECRETS_CALL_LOG" +cat >>"$PANAMA_SECRETS_CALL_LOG" +STUB +chmod +x "$work/bin/wl-copy" +export PANAMA_SECRETS_CALL_LOG="$work/calls" +: >"$PANAMA_SECRETS_CALL_LOG" + +refusal() { + PATH="$work/bin:$PATH" "$helper" "$@" 2>/dev/null | jq -r '.error // ""' +} + +[[ -n "$(refusal copy /etc/passwd)" ]] \ + || fail 'copy accepted a path that is not a stored secret' +[[ -n "$(refusal copy ../../etc/passwd)" ]] \ + || fail 'copy accepted a relative path' +[[ -n "$(refusal copy /org/freedesktop/secrets/collection/login)" ]] \ + || fail 'copy accepted a collection path rather than an item' +[[ -n "$(refusal forget /org/freedesktop/secrets/collection/login)" ]] \ + || fail 'forget accepted a collection path, which would delete a whole keyring' +[[ -n "$(refusal copy /org/freedesktop/secrets/collection/login/999999)" ]] \ + || fail 'copy accepted an item that does not exist' + +[[ ! -s "$PANAMA_SECRETS_CALL_LOG" ]] \ + || fail 'a refused request still reached the clipboard' + +PATH="$work/bin:$PATH" "$helper" copy >/dev/null 2>&1 \ + && fail 'copy with no argument was accepted' +[[ ! -s "$PANAMA_SECRETS_CALL_LOG" ]] \ + || fail 'a malformed request still reached the clipboard' + +printf 'secrets contract: PASS (%d items listed, none readable from the listing)\n' \ + "$(jq '[.collections[].items[]] | length' <<<"$listing")"