214 lines
11 KiB
Bash
Executable File
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")"
|