#!/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 'confirmingItem' "$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.confirmingItem = secretRow.itemPath;' "$page" \ || fail 'nothing records which item is awaiting confirmation' # ── 5. Confirming a Forget does not move the Copy button ───────────────────── # # The page used to run one confirmation state for the whole card, and the row's # other button read `confirming ? "Cancel" : "Copy"`. So arming Forget on a row # replaced the word "Copy" -- in the exact place the user had just learnt to # find it -- with "Cancel", and the way out of a confirmation was to press the # button that copies. Two states that happen to be about the same row are still # two states. python3 - "$page" <<'PY' || fail 'the copy button doubles as something else' import re import sys text = "\n".join(line for line in open(sys.argv[1], encoding="utf-8").read().splitlines() if not line.strip().startswith("//")) def block_at(start: int) -> str: depth = 0 for index in range(text.find("{", start), len(text)): if text[index] == "{": depth += 1 elif text[index] == "}": depth -= 1 if depth == 0: return text[start:index + 1] return "" buttons = [block_at(match.start()) for match in re.finditer(r"SettingsButton \{", text)] copiers = [block for block in buttons if "Keyring.copy(" in block] if not copiers: raise SystemExit("nothing on the page copies a stored secret") for block in copiers: label = re.search(r'text:\s*(.+)', block) if label is None: raise SystemExit("the copy button has no label") if label.group(1).strip().rstrip(";") != '"Copy"': raise SystemExit(f"the copy button's label is conditional: {label.group(1).strip()}") # ...and its press does one thing. The old Cancel behaviour lived in the # handler as well as the label: pressing Copy while a Forget was armed # cleared the confirmation instead of copying. handler = re.search(r'onClicked:\s*(\{.*?\n\s*\}|[^\n]+)', block, re.S) if handler is None: raise SystemExit("the copy button does nothing when pressed") if re.search(r'confirming', handler.group(1)): raise SystemExit(f"pressing Copy reads the confirmation state: {handler.group(1).strip()}") forgetters = [block for block in buttons if "Keyring.forget(" in block] if not forgetters: raise SystemExit("nothing on the page forgets a stored secret") for block in forgetters: if "Keyring.copy(" in block: raise SystemExit("one button both copies and forgets") PY # ── 6. A copy says it happened, on its own row ─────────────────────────────── # # Putting something on the clipboard is invisible. The confirmation has to be # tied to the row it happened on, or a card of six identical Copy buttons says # only that A copy happened. grep -qE 'Keyring\.copiedPath === [A-Za-z_][A-Za-z0-9_]*\.itemPath' "$page" \ || fail 'the copy confirmation is not tied to the row that was copied' grep -q 'copiedPath' "$service" \ || fail 'the service records nothing about a copy, so no row can confirm one' # ...and the confirmation is not the same state as the Forget confirmation. grep -qE 'confirmingItem[^=]*=[^=].*copiedPath|copiedPath.*=.*confirmingItem' "$page" \ && fail 'the confirm state and the copy state are the same value' # 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")"