Files

214 lines
11 KiB
Bash
Executable File

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