Give identity its due: native enrollment, honest deletion, and sign-in that stays home

Claude-Session: https://claude.ai/code/session_01Ms2FbjQy31TVf3CEvQhGM8
This commit is contained in:
Gabriel Brown
2026-08-24 18:55:38 -04:00
parent 5a0643357f
commit 4ec8bd94d9
25 changed files with 4713 additions and 407 deletions
+434 -43
View File
@@ -6,48 +6,188 @@
# nothing, and that silence -- "I enrolled a finger and nothing happened" --
# is the failure this card exists to name.
#
# The helper is the parse surface, so it runs for real against stub fprintd
# and authselect; the page and service checks are structural.
# Two things changed, and each inverts a pin this file used to hold.
#
# Enrollment is Panama's now. It used to open GNOME's Users panel, which was
# honest while fprintd's guided capture was the only thing there; the helper
# drives EnrollStart itself and streams a touch counter, so the handoff is the
# thing that would now be wrong. The needle is inverted rather than deleted:
# the page must NOT open that panel.
#
# And the card is no longer hidden by the absence of a reader. `pamEnabled`
# used to be reported as false whenever fprintd had nothing to say, so the one
# state a person genuinely has to act on -- the PAM feature switched on, no
# reader attached, every unlock now waiting on a device that is not there --
# rendered as no card at all. The two facts are read independently, and the
# card appears when either is true.
#
# SAFETY. Enrollment claims a real device and authselect rewrites PAM, so
# nothing here may reach either. `env -i` with a stub directory first on PATH,
# both bus addresses pointed at sockets that do not exist, PANAMA_PATH pointed
# at a recording panama-sudo that runs nothing, and the enrollment stream
# replayed from PANAMA_FINGERPRINT_FIXTURE -- a canned fprintd whose calls land
# in a log file instead of on the bus. The PATH claim is asserted before the
# helper is run at all.
set -uo pipefail
repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
helper="$repo_dir/config/dot/quickshell/scripts/panama-fingerprint"
service="$repo_dir/config/dot/quickshell/services/Fingerprint.qml"
page="$repo_dir/config/dot/quickshell/modules/settings/UsersPage.qml"
settings_dir="$repo_dir/config/dot/quickshell/modules/settings"
page="$settings_dir/UsersPage.qml"
fail() {
printf 'fingerprint contract: %s\n' "$1" >&2
exit 1
}
# ── Wiring ───────────────────────────────────────────────────────────────────
for path in "$helper" "$service" "$page"; do
[[ -r "$path" ]] || fail "missing $path"
done
[[ -x "$helper" ]] || fail 'panama-fingerprint is not executable'
rg -Fq 'visible: Fingerprint.readerPresent' "$page" \
|| fail 'the card is not hidden on machines with no reader'
rg -Fq 'onToggled: value => Fingerprint.setUnlockEnabled(value)' "$page" \
file_calling() {
grep -rl --include='*.qml' -F "$1" "$settings_dir" | head -1
}
# ── Enrollment stays here ────────────────────────────────────────────────────
#
# The inverted needle. gnome-handoff-contract lost its UsersPage exception at
# the same time; this is the other half, said from the page's side.
grep -rn --include='*.qml' -E 'openGnomePanel\("(system", *"users|users")' "$settings_dir" \
&& fail 'a settings page still sends fingerprint enrollment to GNOME Users, which Panama owns'
enroll_page="$(file_calling 'Fingerprint.startEnroll(')"
[[ -n "$enroll_page" ]] || fail 'nothing starts an enrollment'
grep -q 'Fingerprint.cancelEnroll()' "$enroll_page" \
|| fail 'an enrollment in progress cannot be cancelled, so the reader stays claimed'
grep -qE 'Fingerprint\.(removeFinger|removeAll)\(' "$enroll_page" \
|| fail 'an enrolled finger cannot be removed'
# ── The card is visible in the state that needs acting on ────────────────────
#
# `readerPresent || unlockFeatureEnabled`. Either alone hides the stuck state:
# the feature on with no reader is invisible under the first, and a machine
# with a reader and nothing configured is invisible under the second.
python3 - "$page" "$service" <<'PY' || fail 'the fingerprint card is not visible in the state that needs acting on'
import re
import sys
page = open(sys.argv[1], encoding="utf-8").read()
service = open(sys.argv[2], encoding="utf-8").read()
DISJUNCTION = re.compile(
r"readerPresent\s*\|\|\s*(root\.)?unlockFeatureEnabled"
r"|unlockFeatureEnabled\s*\|\|\s*(root\.)?readerPresent")
visible = [line for line in page.splitlines()
if "visible:" in line and "Fingerprint." in line]
if not visible:
print("nothing decides whether the fingerprint card is shown", file=sys.stderr)
raise SystemExit(1)
card = visible[0]
# Either the page spells it out, or it binds to a service property that does.
# The second is better -- one expression, so the card and the state it
# describes cannot disagree -- so the indirection is followed rather than
# forbidden.
if DISJUNCTION.search(card):
raise SystemExit(0)
named = re.search(r"visible:\s*Fingerprint\.(\w+)", card)
if named:
definition = re.search(rf"property bool {named.group(1)}:([^\n]*)", service)
if definition and DISJUNCTION.search(definition.group(1)):
raise SystemExit(0)
print(f"visible: Fingerprint.{named.group(1)}, which is "
f"{definition.group(1).strip() if definition else 'not defined'}", file=sys.stderr)
raise SystemExit(1)
print(card.strip(), file=sys.stderr)
raise SystemExit(1)
PY
# And it says why it is there. A card that appears on a machine with no reader
# and offers nothing but a switch reads as a bug in the card.
stuck_page="$(file_calling 'Fingerprint.unlockFeatureEnabled')"
[[ -n "$stuck_page" ]] || fail 'no page reads the unlock feature independently of the reader'
grep -qiE 'no reader' "$stuck_page" \
|| fail 'the stuck state does not say there is no reader'
grep -qE 'Turn off' "$stuck_page" \
|| fail 'the stuck state offers no way out of it'
grep -Fq 'onToggled: value => Fingerprint.setUnlockEnabled(value)' "$page" \
|| fail 'the unlock switch does not drive authselect'
rg -Fq 'SystemSettings.openGnomePanel("system", "users")' "$page" \
|| fail 'enrollment does not hand off to the GNOME Users panel'
rg -Fq 'Fingerprint.refresh()' "$page" \
grep -Fq 'Fingerprint.refresh()' "$page" \
|| fail 'the page never reads the fingerprint state'
rg -Fq 'authselect' "$helper" && rg -Fq 'with-fingerprint' "$helper" \
|| fail 'the helper does not manage the authselect feature'
rg -Fq -- '--reason' "$helper" \
|| fail 'the privileged change carries no stated reason'
rg -Fq 'function fingerLabel' "$service" \
# ── A finger is named in exactly one place ───────────────────────────────────
#
# fprintd's vocabulary is "right-index-finger". What that READS as is a
# presentation decision, and two copies of it disagree the first time one is
# edited -- so the service presents, the helper only validates, and no page
# carries a finger name of its own.
grep -q 'function fingerLabel' "$service" \
|| fail 'finger names have no single place to be presented from'
python3 - "$service" "$settings_dir" "$repo_dir/config/dot/quickshell/services" <<'PY' \
|| fail 'fprintd vocabulary is spelled out in more than one place'
import pathlib
import re
import sys
# ── The helper against stub fprintd and authselect ───────────────────────────
FINGER = re.compile(r'"(?:left|right)-(?:thumb|(?:index|middle|ring|little)-finger)"')
owner = pathlib.Path(sys.argv[1]).resolve()
stub_dir="$(mktemp -d)"
state_dir="$(mktemp -d)"
trap 'rm -rf "$stub_dir" "$state_dir"' EXIT
# A single id elsewhere is a default choice -- "start on the right index
# finger" -- and that is allowed. A second one is a copy of the list, and the
# copy is what falls behind: the one in Fingerprint.qml grows a finger, the
# other does not, and half the interface offers nine.
for directory in sys.argv[2:]:
for path in sorted(pathlib.Path(directory).rglob("*.qml")):
if path.resolve() == owner:
continue
found = set(FINGER.findall(path.read_text(encoding="utf-8")))
if len(found) > 1:
print(f"{path}: {sorted(found)}", file=sys.stderr)
raise SystemExit(1)
raise SystemExit(0)
PY
# The service carries the enrollment state the panel counts with.
for property in unlockFeatureEnabled enrolling enrollStage enrollTotal; do
grep -qE "property (bool|int|var|string) $property" "$service" \
|| fail "the service does not expose $property, so the page cannot show the enrollment"
done
# ── The privileged change still says why ─────────────────────────────────────
grep -Fq 'authselect' "$helper" && grep -Fq 'with-fingerprint' "$helper" \
|| fail 'the helper does not manage the authselect feature'
grep -Fq -- '--reason' "$helper" \
|| fail 'the privileged change carries no stated reason'
# ── The helper cannot walk past the stubs ────────────────────────────────────
absolute="$(grep -nE '"/(usr/)?s?bin/[a-z-]+"' "$helper")"
[[ -z "$absolute" ]] \
|| fail "the helper names a binary by absolute path, so PATH stubs cannot contain it: $absolute"
command -v jq >/dev/null 2>&1 || { printf 'fingerprint contract: SKIP (no jq)\n'; exit 0; }
command -v python3 >/dev/null 2>&1 || { printf 'fingerprint contract: SKIP (no python3)\n'; exit 0; }
# ── The fake machine ─────────────────────────────────────────────────────────
work="$(mktemp -d /tmp/panama-fingerprint-contract.XXXXXX)"
stub_dir="$work/bin"
state_dir="$work/state"
home_dir="$work/home"
run_dir="$work/run"
panama_dir="$work/panama/bin"
mkdir -p "$stub_dir" "$state_dir" "$home_dir" "$run_dir" "$panama_dir"
trap 'rm -rf "$work"' EXIT
cat >"$stub_dir/fprintd-list" <<STUB
#!/usr/bin/env bash
printf 'fprintd-list %s\n' "\$*" >>"$state_dir/argv"
if [[ -e "$state_dir/no-reader" ]]; then
echo 'Impossible to enumerate devices: No devices available'
echo 'Impossible to enumerate devices: No devices available' >&2
exit 1
fi
cat <<'OUT'
@@ -62,49 +202,300 @@ STUB
cat >"$stub_dir/authselect" <<STUB
#!/usr/bin/env bash
echo "\$*" >>"$state_dir/authselect-log"
printf 'authselect %s\n' "\$*" >>"$state_dir/argv"
if [[ "\$1" == "current" ]]; then
echo 'Profile ID: local'
[[ -e "$state_dir/pam-on" ]] && echo '- with-fingerprint'
[[ -e "$state_dir/feature-on" ]] && echo '- with-fingerprint'
exit 0
fi
printf 'fingerprint contract: authselect was asked to change PAM\n' >&2
exit 1
STUB
# PANAMA_PATH pointed at an empty directory forces the plain-sudo fallback,
# which the stub records instead of escalating.
cat >"$stub_dir/sudo" <<STUB
# Records the escalation and runs NOTHING. The reason is the point: this page's
# prompt has to say what it is about to do, and the only way to see that is to
# catch the arguments before they become an authselect invocation.
cat >"$panama_dir/panama-sudo" <<STUB
#!/usr/bin/env bash
echo "\$*" >>"$state_dir/sudo-log"
exec "\$@"
printf 'panama-sudo %s\n' "\$*" >>"$state_dir/argv"
exit 0
STUB
chmod +x "$stub_dir"/fprintd-list "$stub_dir"/authselect "$stub_dir"/sudo
run() { PANAMA_PATH="$state_dir" PATH="$stub_dir:$PATH" "$helper" "$@"; }
# Anything else that could reach the machine is closed rather than left open.
for blocked in sudo pkexec gdbus busctl dbus-send fprintd-enroll fprintd-delete; do
cat >"$stub_dir/$blocked" <<STUB
#!/usr/bin/env bash
printf '$blocked %s\n' "\$*" >>"$state_dir/argv"
printf 'fingerprint contract: the helper reached for $blocked\n' >&2
exit 1
STUB
done
chmod +x "$stub_dir"/* "$panama_dir/panama-sudo"
runh() {
env -i \
PATH="$stub_dir:/usr/bin:/bin" \
HOME="$home_dir" \
USER=gib \
XDG_RUNTIME_DIR="$run_dir" \
PANAMA_PATH="$work/panama" \
DBUS_SESSION_BUS_ADDRESS="unix:path=$run_dir/absent-session-bus" \
DBUS_SYSTEM_BUS_ADDRESS="unix:path=$run_dir/absent-system-bus" \
LANG=C LC_ALL=C \
"$@"
}
run() { runh "$helper" "$@"; }
# Enrollment and removal replay a canned fprintd; nothing they do reaches a bus.
fixture="$work/fprintd.json"
log="$work/fprintd.log"
runf() {
runh env PANAMA_FINGERPRINT_FIXTURE="$fixture" PANAMA_FINGERPRINT_LOG="$log" \
"$helper" "$@"
}
# The safety claim, verified rather than assumed.
for binary in fprintd-list authselect sudo pkexec; do
resolved="$(env -i PATH="$stub_dir:/usr/bin:/bin" bash -c "command -v $binary" || true)"
[[ "$resolved" == "$stub_dir/$binary" ]] \
|| fail "$binary resolves to '$resolved', not the stub; refusing to run against the real one"
done
# ── status: two facts, read independently ────────────────────────────────────
status="$(run status)"
jq -e '.reader and .readerName == "Goodix MOC Fingerprint Sensor"' <<<"$status" >/dev/null \
|| fail "the reader name did not parse: $status"
jq -e '.enrolled == ["right-index-finger", "left-thumb"]' <<<"$status" >/dev/null \
|| fail "enrolled fingers did not parse: $status"
jq -e '.pamEnabled == false and .error == ""' <<<"$status" >/dev/null \
jq -e '.unlockFeatureEnabled == false and .error == ""' <<<"$status" >/dev/null \
|| fail "authselect state misread as enabled: $status"
# The old name is still answered, because the service and this file both read
# it and a silent rename would report "off" forever.
jq -e '.pamEnabled == .unlockFeatureEnabled' <<<"$status" >/dev/null \
|| fail "the two names for the PAM feature disagree: $status"
touch "$state_dir/pam-on"
jq -e '.pamEnabled == true' <<<"$(run status)" >/dev/null \
touch "$state_dir/feature-on"
jq -e '.unlockFeatureEnabled == true' <<<"$(run status)" >/dev/null \
|| fail 'with-fingerprint enabled was not detected'
# No reader is a normal machine, not an error.
# THE regression. No reader, feature on: the state that used to render as no
# card at all, because the feature was only read when fprintd answered.
touch "$state_dir/no-reader"
jq -e '.reader == false and .error == ""' <<<"$(run status)" >/dev/null \
|| fail "a readerless machine was reported as a problem: $(run status)"
rm -f "$state_dir/no-reader"
stuck="$(run status)"
jq -e '.reader == false and .error == ""' <<<"$stuck" >/dev/null \
|| fail "a readerless machine was reported as a problem: $stuck"
jq -e '.unlockFeatureEnabled == true' <<<"$stuck" >/dev/null \
|| fail "the PAM feature was not read on a machine with no reader, which is the one state somebody has to be able to turn off: $stuck"
jq -e '.enrolled == []' <<<"$stuck" >/dev/null \
|| fail "a machine with no reader reported enrolled fingers: $stuck"
rm -f "$state_dir/no-reader" "$state_dir/feature-on"
# The privileged change goes through, with the right feature name.
# ── The privileged change goes through, with the right feature and a reason ──
: >"$state_dir/argv"
run set-unlock on >/dev/null
grep -Fq 'authselect enable-feature with-fingerprint' "$state_dir/sudo-log" \
|| fail 'set-unlock on did not enable the authselect feature'
run set-unlock off >/dev/null
grep -Fq 'authselect disable-feature with-fingerprint' "$state_dir/sudo-log" \
|| fail 'set-unlock off did not disable the authselect feature'
grep -Fq 'authselect enable-feature with-fingerprint' "$state_dir/argv" \
|| fail "set-unlock on did not enable the authselect feature: $(cat "$state_dir/argv")"
grep -qE 'panama-sudo --reason .*fingerprint' "$state_dir/argv" \
|| fail 'the prompt does not say what it is about to do'
grep -Eq '^(sudo|pkexec) ' "$state_dir/argv" \
&& fail 'the privileged change escalated without a stated reason'
printf 'fingerprint contract: ok\n'
: >"$state_dir/argv"
run set-unlock off >/dev/null
grep -Fq 'authselect disable-feature with-fingerprint' "$state_dir/argv" \
|| fail 'set-unlock off did not disable the authselect feature'
run set-unlock sideways >/dev/null 2>&1 \
&& fail 'set-unlock accepted something that is neither on nor off'
# ── enroll: a stream, one line per touch ─────────────────────────────────────
#
# The whole point of doing this natively: the page counts touches while they
# happen. So the stream is read as a stream -- every line valid JSON on its own,
# the counter monotonic and bounded, and a terminal line that says which way it
# ended.
printf '{"enrollStages":5,"results":["enroll-stage-passed","enroll-stage-passed","enroll-retry-scan-too-short","enroll-stage-passed","enroll-stage-passed","enroll-stage-passed","enroll-completed"],"error":""}\n' \
>"$fixture"
: >"$log"
runf enroll right-index-finger >"$work/stream.jsonl" \
|| fail "a completed enrollment exited nonzero: $(cat "$work/stream.jsonl")"
python3 - "$work/stream.jsonl" <<'PY' || fail 'the enrollment stream is not a usable progress stream'
import json
import sys
lines = [line for line in open(sys.argv[1], encoding="utf-8").read().splitlines() if line.strip()]
if len(lines) < 3:
print(f"only {len(lines)} line(s) of progress", file=sys.stderr)
raise SystemExit(1)
events = []
for line in lines:
try:
events.append(json.loads(line))
except ValueError:
print(f"not a JSON object on its own line: {line}", file=sys.stderr)
raise SystemExit(1)
for event in events:
for field in ("ok", "stage", "done", "total", "result"):
if field not in event:
print(f"a progress line is missing {field}: {event}", file=sys.stderr)
raise SystemExit(1)
if event["total"] != 5:
print(f"the touch count changed mid-enrollment: {event}", file=sys.stderr)
raise SystemExit(1)
if not 0 <= event["done"] <= event["total"]:
print(f"the counter left its own range: {event}", file=sys.stderr)
raise SystemExit(1)
counts = [event["done"] for event in events]
if counts != sorted(counts):
print(f"the counter went backwards: {counts}", file=sys.stderr)
raise SystemExit(1)
# A retry is a touch that did NOT count. If it advanced the counter, the panel
# would promise a finish that never arrives.
retries = [event for event in events if "retry" in str(event["result"])]
if not retries:
print("the failed touch produced no line at all, so the panel says nothing",
file=sys.stderr)
raise SystemExit(1)
if retries[0]["done"] != 2:
print(f"a retried touch was counted as progress: {retries[0]}", file=sys.stderr)
raise SystemExit(1)
last = events[-1]
if not last["ok"] or last["done"] != last["total"]:
print(f"a completed enrollment did not finish full: {last}", file=sys.stderr)
raise SystemExit(1)
raise SystemExit(0)
PY
# The device is claimed and released around it, in that order. A reader left
# claimed by a crashed enrollment refuses the next one, and the message a
# person gets is "busy with something else" forever.
python3 - "$log" <<'PY' || fail 'the reader is not claimed and released around an enrollment'
import json
import sys
calls = [json.loads(line)["method"] for line in open(sys.argv[1], encoding="utf-8") if line.strip()]
if not calls:
print("nothing was called at all", file=sys.stderr)
raise SystemExit(1)
if calls[0] != "Claim":
print(f"the device was used before it was claimed: {calls}", file=sys.stderr)
raise SystemExit(1)
if calls[-1] != "Release":
print(f"the device was left claimed: {calls}", file=sys.stderr)
raise SystemExit(1)
if "EnrollStart" not in calls:
print(f"nothing started an enrollment: {calls}", file=sys.stderr)
raise SystemExit(1)
if calls.index("EnrollStart") < calls.index("Claim"):
print(f"enrollment started before the claim: {calls}", file=sys.stderr)
raise SystemExit(1)
raise SystemExit(0)
PY
# A finger already on the reader ends the stream badly, and says so in words
# rather than in fprintd's vocabulary.
printf '{"enrollStages":5,"results":["enroll-stage-passed","enroll-duplicate"],"error":""}\n' \
>"$fixture"
: >"$log"
duplicate="$(runf enroll left-thumb)" && fail 'a failed enrollment exited zero'
last="$(tail -1 <<<"$duplicate")"
jq -e '.ok == false and .stage == "failed"' <<<"$last" >/dev/null \
|| fail "a failed enrollment did not end in a failure line: $last"
jq -e '(.error | length) > 0 and (.error | test("[a-z] [a-z]"))' <<<"$last" >/dev/null \
|| fail "the failure is reported in fprintd's vocabulary rather than in words: $last"
grep -Fq '"Release"' "$log" \
|| fail 'a failed enrollment left the reader claimed'
# A refused claim -- the reader busy with the lock screen, most often -- is one
# line, not a hang.
printf '{"enrollStages":5,"results":[],"error":"The fingerprint reader is busy with something else."}\n' \
>"$fixture"
: >"$log"
refused="$(runf enroll right-thumb)" && fail 'a refused claim exited zero'
jq -e '.ok == false and (.error | length) > 0' <<<"$(tail -1 <<<"$refused")" >/dev/null \
|| fail "a refused claim did not report a failure: $refused"
# ── Nonsense is refused before the reader is touched ─────────────────────────
printf '{"enrollStages":5,"results":["enroll-completed"],"error":""}\n' >"$fixture"
for bad in 'third-eye' 'right-index-finger; reboot' '' 'left-thumb-finger'; do
: >"$log"
refusal="$(runf enroll "$bad" 2>/dev/null)" \
&& fail "enroll accepted ${bad@Q} as a finger"
jq -e '.ok == false and (.error | length) > 0' <<<"$(tail -1 <<<"$refusal")" >/dev/null \
|| fail "a bad finger name did not come back as a stream failure: $refusal"
[[ ! -s "$log" ]] \
|| fail "a bad finger name claimed the reader before being refused: ${bad@Q}"
: >"$log"
[[ -n "$(runf remove "$bad" 2>/dev/null | jq -r '.error // ""')" ]] \
|| fail "remove accepted ${bad@Q} as a finger"
[[ ! -s "$log" ]] \
|| fail "a bad finger name reached the reader through remove: ${bad@Q}"
done
# ── remove: one finger, or all of them, and never somebody else's ────────────
: >"$log"
removed="$(runf remove right-index-finger)" || fail 'remove failed against the canned fprintd'
jq -e 'has("reader") and has("enrolled") and has("unlockFeatureEnabled")' <<<"$removed" >/dev/null \
|| fail "remove does not answer with the fresh state: $removed"
python3 - "$log" right-index-finger <<'PY' || fail 'removing one finger did not delete that one finger'
import json
import sys
calls = [json.loads(line) for line in open(sys.argv[1], encoding="utf-8") if line.strip()]
methods = [call["method"] for call in calls]
if methods[0] != "Claim" or methods[-1] != "Release":
print(f"the device was not claimed and released around it: {methods}", file=sys.stderr)
raise SystemExit(1)
delete = next((call for call in calls if call["method"].startswith("DeleteEnrolledFinger")), None)
if delete is None:
print(f"nothing was deleted: {methods}", file=sys.stderr)
raise SystemExit(1)
if delete["method"] == "DeleteEnrolledFingers2":
print("removing one finger deleted all of them", file=sys.stderr)
raise SystemExit(1)
if sys.argv[2] not in delete["arguments"]:
print(f"a different finger was deleted: {delete}", file=sys.stderr)
raise SystemExit(1)
raise SystemExit(0)
PY
: >"$log"
runf remove-all >/dev/null || fail 'remove-all failed against the canned fprintd'
# DeleteEnrolledFingers2 works on the claimed user. Its predecessor took a user
# name, which is how a typo deletes somebody else's prints.
grep -Fq '"DeleteEnrolledFingers2"' "$log" \
|| fail 'remove-all does not use the method that works on the claimed user'
python3 - "$log" <<'PY' || fail 'remove-all names a user, which is how a typo deletes somebody else'
import json
import sys
for line in open(sys.argv[1], encoding="utf-8"):
if not line.strip():
continue
call = json.loads(line)
if call["method"] == "DeleteEnrolledFingers2" and call["arguments"]:
print(call, file=sys.stderr)
raise SystemExit(1)
raise SystemExit(0)
PY
# ── With no fixture and no bus, it still answers in its own shape ────────────
#
# The page reads this stream line by line. A traceback on stdout, or nothing at
# all, leaves an enrollment panel open forever with no way to know it failed.
absent="$(run enroll right-index-finger 2>/dev/null)"
[[ -n "$absent" ]] || fail 'enrollment against no fprintd printed nothing at all'
jq -e '.ok == false and (.error | length) > 0' <<<"$(tail -1 <<<"$absent")" >/dev/null \
|| fail "enrollment against no fprintd did not answer in the stream's own shape: $absent"
printf 'fingerprint contract: PASS (status, stuck state, enroll stream, remove, reason)\n'
+6 -2
View File
@@ -59,9 +59,13 @@ declare -A OWNED=(
# Handoffs that are correct despite naming an owned panel, with the reason.
# Anything here must be justified, not merely tolerated.
#
# The Users entry is gone: fingerprint enrollment was the only thing sending
# people to GNOME's Users panel, and Panama drives fprintd's EnrollStart itself
# now, so the door has nothing behind it. fingerprint-contract pins the
# inverse -- that the page does NOT open a GNOME panel.
declare -A ALLOWED=(
["OnlineAccountsPage.qml:online-accounts"]="adding an account requires GOA's own dialog"
["UsersPage.qml:system-users"]="fingerprint enrollment requires fprintd's guided capture flow, and GNOME's Users panel carries the only good dialog for it"
["OnlineAccountsPage.qml:online-accounts"]="OAuth sign-in (Google, Microsoft) runs inside libgoa-backend, which Fedora ships without a GIR binding, so the provider's own dialog is the only way to obtain the token; Nextcloud and IMAP are added on the page itself"
)
# The leaves: a tabless category is a page in its own right, and every tab is
+533
View File
@@ -0,0 +1,533 @@
#!/usr/bin/env bash
# Online accounts -- the other kind (see user-accounts for the local ones).
#
# Three things can go wrong here, and two of them are silent.
#
# The loud one is a leaked password. Adding a Nextcloud or an IMAP account is
# the first time Panama collects a credential for somebody else's server, and
# GOA's AddAccount takes it as a D-Bus argument -- which is fine, that is a
# direct call to a daemon. What is not fine is the same string reaching this
# helper's own argv, where /proc publishes it to every process on the machine,
# or the account listing, which the page renders.
#
# The first silent one is the page telling somebody they have no online
# accounts. `available` used to be `lastError === ""`, and `lastError` is set by
# every failed write -- so refusing to remove one account replaced the whole
# card with "Online accounts are not available on this machine", listing
# nothing, while four accounts sat there working. Availability is whether GOA
# answered the listing. An error is a row.
#
# The second is a removal that happens on the first click. GOA's Remove is
# immediate and unrecoverable: the account is gone, and re-adding it means the
# whole sign-in again. It must take two.
#
# SAFETY. This is the account store of a signed-in desktop session, so it must
# be impossible for anything here to reach the real GOA:
#
# 1. the helper is run under `env -i` with a stub directory first on PATH,
# and with both D-Bus bus addresses pointed at sockets that do not exist,
# so a client that got as far as connecting could not;
# 2. `import gi` resolves to a stand-in on PYTHONPATH whose require_version
# always raises, which is the same seam network-tools-contract uses;
# 3. the account data comes from PANAMA_ACCOUNTS_FIXTURE, a file in the
# scratch tree, so nothing is read from or written to the session;
# 4. every D-Bus client binary is stubbed with one that records and refuses.
#
# The write verbs are exercised only against that fixture. Nothing here signs
# in to anything, and nothing here removes a real account.
set -uo pipefail
repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
helper="$repo_dir/config/dot/quickshell/scripts/panama-accounts"
service="$repo_dir/config/dot/quickshell/services/OnlineAccounts.qml"
settings_dir="$repo_dir/config/dot/quickshell/modules/settings"
page="$settings_dir/OnlineAccountsPage.qml"
fail() {
printf 'online accounts contract: %s\n' "$1" >&2
exit 1
}
for path in "$helper" "$service" "$page"; do
[[ -r "$path" ]] || fail "missing $path"
done
[[ -x "$helper" ]] || fail 'panama-accounts is not executable'
file_calling() {
grep -rl --include='*.qml' -F "$1" "$settings_dir" | head -1
}
# Sentinels. Neither may come back out of anything.
readonly NEXTCLOUD_PW='nextcloud-pw-must-never-leave-6d3a'
readonly IMAP_PW='imap-pw-must-never-leave-91be'
# ── Static: the helper cannot walk past the stubs ────────────────────────────
#
# Checked before anything runs, because the safety claim above rests on it.
absolute="$(grep -nE '"/(usr/)?s?bin/[a-z-]+"' "$helper")"
[[ -z "$absolute" ]] \
|| fail "the helper names a binary by absolute path, so PATH stubs cannot contain it: $absolute"
# ── Static: a password is read, never taken as an argument ───────────────────
#
# The distinction that matters: handing the password to GOA over D-Bus is the
# supported way to add a password-based account. Handing it to THIS program as
# argv[4] would publish it through /proc for as long as the command runs.
grep -q 'sys.stdin' "$helper" \
|| fail 'the helper never reads stdin, so the password has no way in but argv'
python3 - "$helper" <<'PY' || fail 'the helper takes a password from its own command line'
import ast
import re
import sys
SECRETISH = re.compile(r"(password|passwd|secret|token|credential)", re.I)
tree = ast.parse(open(sys.argv[1], encoding="utf-8").read())
bad = []
for node in ast.walk(tree):
# password = sys.argv[n], or any assignment of an argv slice to one.
if isinstance(node, ast.Assign):
names = [t.id for t in node.targets if isinstance(t, ast.Name)]
if any(SECRETISH.search(name) for name in names):
if "argv" in ast.dump(node.value):
bad.append(f"line {node.lineno}: {names} <- argv")
# And no command list may carry one either.
if isinstance(node, (ast.List, ast.Tuple)):
literals = {e.value for e in node.elts
if isinstance(e, ast.Constant) and isinstance(e.value, str)}
if not literals & {"gdbus", "busctl", "dbus-send", "gnome-control-center", "sh", "bash"}:
continue
for element in node.elts:
name = getattr(element, "id", getattr(element, "attr", ""))
if name and SECRETISH.search(name):
bad.append(f"line {node.lineno}: {name} in a command list")
if bad:
print("; ".join(bad), file=sys.stderr)
raise SystemExit(1)
raise SystemExit(0)
PY
# ── Static: an added account is proved, not assumed ──────────────────────────
#
# GOA's AddAccount does not check what it is handed: it writes the account out
# and stores the password. A mistyped one therefore produces an account that
# exists, looks correct in the list, and never syncs -- which is the exact
# failure this page was built to be able to explain, so it must not be the
# failure the page creates. The credentials are exercised once, and an account
# that cannot sign in is taken back out.
python3 - "$helper" <<'PY' || fail 'a new account is never asked to prove its credentials'
import ast
import sys
tree = ast.parse(open(sys.argv[1], encoding="utf-8").read())
source = ast.dump(tree)
if "call_add_account_sync" not in source:
print("nothing adds an account through GOA at all", file=sys.stderr)
raise SystemExit(1)
if "call_ensure_credentials_sync" not in source:
print("an added account is never signed in with, so a mistyped password "
"produces an account that exists and never syncs", file=sys.stderr)
raise SystemExit(1)
# And the undo. The function that verifies has to be able to remove.
verify = next((node for node in ast.walk(tree)
if isinstance(node, ast.FunctionDef) and node.name == "verify"), None)
if verify is None or "call_remove_sync" not in ast.dump(verify):
print("an account whose credentials were refused is left behind", file=sys.stderr)
raise SystemExit(1)
raise SystemExit(0)
PY
# GOA's provider type for Nextcloud is "owncloud" -- the fork kept the id. A
# provider type GOA does not know is not an error: AddAccount is simply never
# offered it, and the form reports a failure nobody can act on.
grep -qE '"owncloud"' "$helper" \
|| fail 'Nextcloud is added under a provider type GOA does not have (it is "owncloud")'
grep -qE '"imap_smtp"' "$helper" \
|| fail 'mail is added under a provider type GOA does not have (it is "imap_smtp")'
# ── Static: the service hands it down the same way ───────────────────────────
grep -qE 'stdinEnabled' "$service" \
|| fail 'the service never opens a helper stdin, so the password would have to be an argument'
python3 - "$service" <<'PY' || fail 'the service puts an account password on the command line'
import re
import sys
text = open(sys.argv[1], encoding="utf-8").read()
found = False
for match in re.finditer(r"function (addNextcloud|addImap)\(([^)]*)\)", text):
found = True
body = text[match.end():text.find("\n }", match.end())]
for argv in re.findall(r"\[[^\[\]]*\]", body):
if re.search(r"(password|secret|token)", argv, re.I):
print(f"{match.group(1)}: {argv.strip()[:200]}", file=sys.stderr)
raise SystemExit(1)
if not found:
print("the service cannot add a password-based account at all", file=sys.stderr)
raise SystemExit(1)
raise SystemExit(0)
PY
# ── Static: availability is not an error ─────────────────────────────────────
#
# The regression, pinned at the definition. `available` may be computed from
# whether the listing was answered; it may not be computed from lastError,
# which every refused write sets.
python3 - "$service" <<'PY' || fail 'the page-wide availability is still derived from a write error'
import re
import sys
text = open(sys.argv[1], encoding="utf-8").read()
if re.search(r"property bool available:", text) is None:
print("the service no longer says whether online accounts are available", file=sys.stderr)
raise SystemExit(1)
# Every place `available` is decided: the declaration, and every assignment.
# A write error may not reach any of them. This is checked at each one rather
# than at the declaration alone, because the regression came back the second
# time as an assignment in the error handler of a failed toggle.
WRITE_ERRORS = re.compile(r"(lastError|writeError|setError|removeError)")
sites = [line for line in text.splitlines()
if re.search(r"property bool available:|\bavailable\s*=[^=]", line)]
if not sites:
print("nothing sets availability at all", file=sys.stderr)
raise SystemExit(1)
for line in sites:
if WRITE_ERRORS.search(line):
print(line.strip(), file=sys.stderr)
raise SystemExit(1)
# And the two must be separable at all: one string for "GOA did not answer",
# another for "that one change did not happen".
if len(set(re.findall(r"property string (\w*[Ee]rror)", text))) < 2:
print("the service keeps one error string, so a failed write is indistinguishable "
"from a daemon that is not there", file=sys.stderr)
raise SystemExit(1)
raise SystemExit(0)
PY
# ── Static: the page ─────────────────────────────────────────────────────────
#
# An error is a row on a working page, not a replacement for the page.
python3 - "$page" <<'PY' || fail 'the page still hides everything behind an error'
import re
import sys
text = open(sys.argv[1], encoding="utf-8").read()
for line in text.splitlines():
if re.search(r"visible:.*OnlineAccounts\.lastError\s*===\s*\"\"", line):
print(line.strip(), file=sys.stderr)
raise SystemExit(1)
raise SystemExit(0)
PY
remove_page="$(file_calling 'OnlineAccounts.remove(')"
[[ -n "$remove_page" ]] || fail 'nothing removes an online account'
python3 - "$remove_page" <<'PY' || fail 'removing an account is not gated on a confirmation'
import re
import sys
lines = open(sys.argv[1], encoding="utf-8").read().splitlines()
calls = [index for index, line in enumerate(lines) if "OnlineAccounts.remove(" in line]
if not calls:
raise SystemExit(1)
# The call has to sit behind a state the first click sets: a second button that
# only exists once "confirming" is true. Read from the same object block, so a
# `confirming` property declared elsewhere in the file does not count.
for index in calls:
window = "\n".join(lines[max(0, index - 25):index + 3])
if not re.search(r"confirm", window, re.I):
print(f"line {index + 1}: {lines[index].strip()}", file=sys.stderr)
raise SystemExit(1)
raise SystemExit(0)
PY
# The account is named in the confirmation, and what is lost is said out loud:
# GOA's Remove cannot be undone and the sign-in has to be done again.
grep -qE 'Sign in again' "$page" \
|| fail 'an account whose credentials expired is not offered a way back in'
# The password field must not survive the form that collected it.
add_page="$(file_calling 'addNextcloud(')"
[[ -n "$add_page" ]] || fail 'nothing adds a Nextcloud account natively'
grep -qE '\.clear\(\)|password = ""|secret = ""' "$add_page" \
|| fail 'the add form never clears the password it collected'
# ── Static: the listing itself cannot carry a credential ─────────────────────
#
# The listing is built from a fixed set of GOA properties, and the page renders
# every one of them. A password property added to that dict later is the way a
# secret would arrive on screen, so the keys are read rather than trusted.
python3 - "$helper" <<'PY' || fail 'the account listing exposes a credential-shaped field'
import ast
import re
import sys
SECRETISH = re.compile(r"(password|passwd|secret|token|credential)", re.I)
tree = ast.parse(open(sys.argv[1], encoding="utf-8").read())
describe = next((node for node in ast.walk(tree)
if isinstance(node, ast.FunctionDef) and node.name == "describe"), None)
if describe is None:
print("nothing builds the account listing", file=sys.stderr)
raise SystemExit(1)
keys = []
for node in ast.walk(describe):
if isinstance(node, ast.Dict):
keys += [key.value for key in node.keys
if isinstance(key, ast.Constant) and isinstance(key.value, str)]
if not keys:
print("the listing has no fields at all", file=sys.stderr)
raise SystemExit(1)
offenders = [key for key in keys if SECRETISH.search(key)]
if offenders:
print(f"the listing carries {offenders}", file=sys.stderr)
raise SystemExit(1)
# And the GOA properties it reads: `password` is a real property on some
# provider objects, and reading it here would put it in the dict above under
# whatever name somebody chose.
reads = [node.attr for node in ast.walk(describe) if isinstance(node, ast.Attribute)]
offenders = [name for name in reads if SECRETISH.search(name)]
if offenders:
print(f"the listing reads {offenders} off the GOA account", file=sys.stderr)
raise SystemExit(1)
raise SystemExit(0)
PY
# ── The fake GOA ─────────────────────────────────────────────────────────────
command -v jq >/dev/null 2>&1 || { printf 'online accounts contract: SKIP (no jq)\n'; exit 0; }
command -v python3 >/dev/null 2>&1 || { printf 'online accounts contract: SKIP (no python3)\n'; exit 0; }
grep -q 'PANAMA_ACCOUNTS_FIXTURE' "$helper" \
|| fail 'the helper has no fixture seam, so nothing can exercise it without the real account store'
work="$(mktemp -d /tmp/panama-accounts-contract.XXXXXX)"
stub_dir="$work/bin"
home_dir="$work/home"
config_home="$work/config"
state_home="$work/xdg-state"
run_dir="$work/run"
pystub="$work/pystub"
mkdir -p "$stub_dir" "$home_dir" "$config_home" "$state_home" "$run_dir" "$pystub/gi"
trap 'rm -rf "$work"' EXIT
# The stand-in for PyGObject. With it in place the helper cannot construct a
# Goa.Client at all, so if the fixture seam were ever removed this contract
# would stop working rather than quietly start editing the session's accounts.
cat >"$pystub/gi/__init__.py" <<'GISTUB'
"""Stand-in for PyGObject, so panama-accounts cannot reach the real GOA."""
def require_version(namespace, version):
raise ValueError(f"Namespace {namespace} not available")
GISTUB
# Two accounts, one of them needing attention. The fixture is the listing's own
# shape, so it holds no password -- that half is pinned above, from the source.
fixture="$work/accounts.json"
log="$work/accounts.log"
cat >"$fixture" <<'FIXTURE'
{
"accounts": [
{
"path": "/org/gnome/OnlineAccounts/Accounts/account_0",
"provider": "owncloud",
"providerName": "Nextcloud",
"providerIcons": ["goa-account-owncloud"],
"identity": "[email protected]",
"needsAttention": false,
"services": [
{ "key": "files", "label": "Files", "enabled": true },
{ "key": "calendar", "label": "Calendar", "enabled": false }
]
},
{
"path": "/org/gnome/OnlineAccounts/Accounts/account_1",
"provider": "imap_smtp",
"providerName": "Mail",
"providerIcons": ["goa-account-mail"],
"identity": "[email protected]",
"needsAttention": true,
"services": [
{ "key": "mail", "label": "Mail", "enabled": true }
]
}
],
"error": ""
}
FIXTURE
# Anything that could still be a way out is closed rather than left open.
for blocked in gdbus busctl dbus-send gnome-control-center goa-daemon pkexec; do
cat >"$stub_dir/$blocked" <<STUB
#!/usr/bin/env bash
printf 'online accounts contract: the helper reached for $blocked\n' >&2
exit 1
STUB
done
chmod +x "$stub_dir"/*
runh() {
env -i \
PATH="$stub_dir:/usr/bin:/bin" \
PYTHONPATH="$pystub" \
HOME="$home_dir" \
XDG_CONFIG_HOME="$config_home" \
XDG_STATE_HOME="$state_home" \
XDG_RUNTIME_DIR="$run_dir" \
DBUS_SESSION_BUS_ADDRESS="unix:path=$run_dir/absent-session-bus" \
DBUS_SYSTEM_BUS_ADDRESS="unix:path=$run_dir/absent-system-bus" \
PANAMA_ACCOUNTS_FIXTURE="$fixture" \
PANAMA_ACCOUNTS_LOG="$log" \
LANG=C LC_ALL=C \
"$helper" "$@"
}
# The safety claim, verified rather than assumed.
for binary in gdbus busctl dbus-send gnome-control-center; do
resolved="$(env -i PATH="$stub_dir:/usr/bin:/bin" bash -c "command -v $binary" || true)"
[[ "$resolved" == "$stub_dir/$binary" ]] \
|| fail "$binary resolves to '$resolved', not the stub; refusing to run"
done
leak_in_scratch() {
grep -rlF "$1" "$home_dir" "$config_home" "$state_home" "$run_dir" "$log" \
2>/dev/null | head -1
}
recorded() { cat "$log" 2>/dev/null; }
error_of() { runh "$@" 2>/dev/null | jq -r '.error // ""'; }
# ── The listing answers, under both of its names ─────────────────────────────
#
# The service asks for `snapshot`, which is what every other Panama helper
# calls this; `list` is what this one was called first and what anything older
# still passes. Both have to work, or one of them is a page with no accounts.
: >"$log"
accounts="$(runh snapshot 2>/dev/null)" || fail 'snapshot failed against the fixture'
[[ "$(runh list 2>/dev/null)" == "$accounts" ]] \
|| fail 'list and snapshot do not answer the same thing'
jq -e '.accounts | type == "array" and length == 2' <<<"$accounts" >/dev/null \
|| fail "the fixture accounts did not come back: $accounts"
jq -e '[.accounts[] | has("path") and has("provider") and has("identity")
and has("needsAttention") and (.services | type == "array")] | all' \
<<<"$accounts" >/dev/null || fail "an account is missing part of its shape: $accounts"
jq -e '[.accounts[] | select(.needsAttention)] | length == 1' <<<"$accounts" >/dev/null \
|| fail 'the account whose credentials expired is not reported as needing attention'
jq -e '.error == ""' <<<"$accounts" >/dev/null \
|| fail "reading the accounts reported an error: $accounts"
[[ -z "$(recorded)" ]] || fail 'reading the account list changed something'
# ── Adding: the password arrives on stdin and stays nowhere ──────────────────
#
# The sentinel is typed in, and then looked for everywhere it could have gone:
# back out of the helper, into the log, into the scratch home. The log records
# its LENGTH, which is what proves it was read at all rather than dropped.
: >"$log"
added="$(printf '%s\n' "$NEXTCLOUD_PW" \
| runh add-nextcloud 'https://cloud.example.org' 'gib' 2>"$work/nextcloud.err")"
jq -e '.error == ""' <<<"$added" >/dev/null \
|| fail "a well-formed Nextcloud account was refused: $added"
jq -e '.accounts | length == 2' <<<"$added" >/dev/null \
|| fail "add-nextcloud does not answer with the account list: $added"
jq -e --argjson want "${#NEXTCLOUD_PW}" '.passwordBytes == $want' <<<"$(recorded)" >/dev/null \
|| fail "the password never reached the helper on stdin: $(recorded)"
grep -Fq "$NEXTCLOUD_PW" <<<"$added" \
&& fail 'add-nextcloud echoes the password back in its own output'
grep -Fq "$NEXTCLOUD_PW" "$work/nextcloud.err" \
&& fail 'add-nextcloud wrote the password to stderr'
leaked="$(leak_in_scratch "$NEXTCLOUD_PW")"
[[ -z "$leaked" ]] || fail "the Nextcloud password was written to $leaked"
: >"$log"
mail_added="$(printf '%s\n' "$IMAP_PW" \
| runh add-imap '[email protected]' 'imap.example.com' 'smtp.example.com' 'gib' \
2>"$work/imap.err")"
jq -e '.error == ""' <<<"$mail_added" >/dev/null \
|| fail "a well-formed mail account was refused: $mail_added"
jq -e --argjson want "${#IMAP_PW}" '.passwordBytes == $want' <<<"$(recorded)" >/dev/null \
|| fail "the mail password never reached the helper on stdin: $(recorded)"
grep -Fq "$IMAP_PW" <<<"$mail_added" && fail 'add-imap echoes the password back'
grep -Fq "$IMAP_PW" "$work/imap.err" && fail 'add-imap wrote the password to stderr'
leaked="$(leak_in_scratch "$IMAP_PW")"
[[ -z "$leaked" ]] || fail "the mail password was written to $leaked"
# ── An error is a row, not an empty page ─────────────────────────────────────
#
# The regression, from the data. Whatever went wrong, the accounts that are
# there must still come back with it -- the page draws its list from the same
# answer that carries the message.
: >"$log"
refused="$(printf 'anything\n' | runh add-nextcloud '' 'gib' 2>/dev/null)"
jq -e '(.error | length) > 0' <<<"$refused" >/dev/null \
|| fail "adding an account with no server was accepted: $refused"
jq -e '.accounts | length == 2' <<<"$refused" >/dev/null \
|| fail "a refused add emptied the account list, which is how one error hides four working accounts: $refused"
# ── Adding validates what it was given ───────────────────────────────────────
#
# Each of these produces an account that exists, looks right, and never syncs
# -- the failure this page is meant to be able to explain. They must be refused
# here, with nothing recorded, rather than handed to GOA to fail slowly.
#
# A password is piped in on purpose: without one the refusal would be "no
# password was provided", which every case would pass on regardless of whether
# the field it is about is checked at all.
attempt() { printf 'unused-password\n' | runh "$@" 2>/dev/null | jq -r '.error // ""'; }
for bad_server in '' 'not a url' 'ftp://cloud.example.org' 'https://' \
'https://cloud.example.org; reboot' 'https://cloud example org'; do
: >"$log"
[[ -n "$(attempt add-nextcloud "$bad_server" 'gib')" ]] \
|| fail "add-nextcloud accepted ${bad_server@Q} as a server"
[[ -z "$(recorded)" ]] \
|| fail "a refused Nextcloud server was acted on anyway: ${bad_server@Q}"
done
[[ -n "$(attempt add-nextcloud 'https://cloud.example.org' '')" ]] \
|| fail 'add-nextcloud accepted an empty user name'
[[ -n "$(error_of add-nextcloud 'https://cloud.example.org' 'gib' </dev/null)" ]] \
|| fail 'add-nextcloud with no password at all was accepted'
for bad_address in '' 'not-an-address' 'gib@' '@example.com'; do
[[ -n "$(attempt add-imap "$bad_address" 'imap.example.com' 'smtp.example.com' 'gib')" ]] \
|| fail "add-imap accepted ${bad_address@Q} as an address"
done
for bad_host in '' 'imap example com' 'imap.example.com; reboot' '-imap.example.com'; do
: >"$log"
[[ -n "$(attempt add-imap '[email protected]' "$bad_host" 'smtp.example.com' 'gib')" ]] \
|| fail "add-imap accepted ${bad_host@Q} as an incoming server"
[[ -n "$(attempt add-imap '[email protected]' 'imap.example.com' "$bad_host" 'gib')" ]] \
|| fail "add-imap accepted ${bad_host@Q} as an outgoing server"
[[ -z "$(recorded)" ]] \
|| fail "a refused mail server was acted on anyway: ${bad_host@Q}"
done
[[ -n "$(error_of bogus-verb)" ]] || fail 'an unknown command was accepted'
# ── Removing names the account it was asked about ────────────────────────────
#
# Confirmed on the page (pinned above); here, only that the path travels intact
# -- removing the wrong account is unrecoverable and looks like a success.
: >"$log"
runh remove '/org/gnome/OnlineAccounts/Accounts/account_1' >/dev/null 2>&1
jq -e '.arguments[0] == "/org/gnome/OnlineAccounts/Accounts/account_1"' \
<<<"$(recorded)" >/dev/null || fail "remove did not carry the account path: $(recorded)"
: >"$log"
runh set '/org/gnome/OnlineAccounts/Accounts/account_0' calendar true >/dev/null 2>&1
jq -e '.arguments == ["/org/gnome/OnlineAccounts/Accounts/account_0", "calendar", "true"]' \
<<<"$(recorded)" >/dev/null || fail "a service toggle did not carry its arguments: $(recorded)"
# ── Nothing anywhere left a secret behind ────────────────────────────────────
for secret in "$NEXTCLOUD_PW" "$IMAP_PW"; do
leaked="$(leak_in_scratch "$secret")"
[[ -z "$leaked" ]] || fail "a password was left behind in $leaked"
done
printf 'online accounts contract: PASS (listing, availability, add, remove, no secret leaves)\n'
+405 -9
View File
@@ -7,14 +7,18 @@
#
# Nothing here creates, deletes, or modifies a real account. It reads the
# account state, which is safe, and exercises the refusals, which are the part
# that has to hold.
# that has to hold. Every verb that would change something is read from the
# source instead of being run -- `set-icon gib ""` really does clear the
# avatar, and `delete-user` really does delete, so those are pinned statically
# and never invoked.
set -uo pipefail
repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
helper="$repo_dir/config/dot/quickshell/scripts/panama-users"
service="$repo_dir/config/dot/quickshell/services/UserAccounts.qml"
page="$repo_dir/config/dot/quickshell/modules/settings/UsersPage.qml"
settings_dir="$repo_dir/config/dot/quickshell/modules/settings"
page="$settings_dir/UsersPage.qml"
fail() {
printf 'user accounts contract: %s\n' "$1" >&2
@@ -26,6 +30,13 @@ for path in "$helper" "$service" "$page"; do
done
[[ -x "$helper" ]] || fail 'panama-users is not executable'
# The page was rebuilt into several files, so the add-user form and the
# per-user rows may not live in UsersPage.qml any more. Everything structural
# is looked up by what it calls rather than by which file it sits in.
file_calling() {
grep -rl --include='*.qml' -F "$1" "$settings_dir" | head -1
}
# ── A new password never reaches a command line ─────────────────────────────
# argv is world-readable through /proc, so a password passed as an argument is
# published to every process on the machine. It is read from stdin, and the
@@ -50,23 +61,363 @@ grep -qE 'command:.*set-password.*password' "$service" \
grep -q 'root.pendingPassword = ""' "$service" \
|| fail 'the service never clears the password it was holding'
# ── Refusals that keep a machine administrable ──────────────────────────────
# ── Resetting a password is not the same as setting one ─────────────────────
#
# "Reset" hands the account back to its owner: accountsservice's
# SetPasswordMode(1) means "choose one at the next sign-in". The whole point is
# that an administrator resetting somebody else's password never learns, types,
# or transports a password -- so this verb must not touch stdin, must not reach
# for a hashing tool, and must not go anywhere near SetPassword.
python3 - "$helper" <<'PY' || fail 'reset-password does not set password mode 1, or it handles password material'
import ast
import sys
tree = ast.parse(open(sys.argv[1], encoding="utf-8").read())
target = next((node for node in ast.walk(tree)
if isinstance(node, ast.FunctionDef) and node.name == "reset_password"), None)
if target is None:
print("reset-password has no implementation", file=sys.stderr)
raise SystemExit(1)
# Module constants, so the mode may be a name with a meaning rather than a 1.
constants = {}
for node in tree.body:
if isinstance(node, ast.Assign) and isinstance(node.value, ast.Constant):
for name in node.targets:
if isinstance(name, ast.Name):
constants[name.id] = node.value.value
def literal(node):
if isinstance(node, ast.Constant):
return node.value
if isinstance(node, ast.Name):
return constants.get(node.id)
return None
methods = [element.value for node in ast.walk(target)
for element in ast.walk(node)
if isinstance(element, ast.Constant) and isinstance(element.value, str)]
if "SetPasswordMode" not in methods:
print("reset-password does not use accountsservice SetPasswordMode", file=sys.stderr)
raise SystemExit(1)
for forbidden in ("SetPassword", "SetPasswordHint"):
if forbidden in methods:
print(f"reset-password calls {forbidden}, which carries password material",
file=sys.stderr)
raise SystemExit(1)
# Mode 1: "no usable password, choose one at the next sign-in". 0 would mean a
# password is set, 2 would mean none is ever needed -- both are somebody else's
# account handed away.
modes = [literal(element) for node in ast.walk(target)
if isinstance(node, ast.Tuple)
for element in node.elts]
if 1 not in modes:
print(f"reset-password does not ask for mode 1: {modes}", file=sys.stderr)
raise SystemExit(1)
# Nothing that could be a password may pass through it. The docstring is
# excluded on purpose -- it is allowed to say the word.
code = ast.dump(ast.Module(body=[node for node in target.body
if not (isinstance(node, ast.Expr)
and isinstance(node.value, ast.Constant))],
type_ignores=[]))
for word in ("stdin", "openssl", "crypt", "passwd"):
if word in code:
print(f"reset-password touches {word}; it must only set the mode", file=sys.stderr)
raise SystemExit(1)
raise SystemExit(0)
PY
grep -q 'function resetPassword' "$service" \
|| fail 'the service cannot reset a password'
grep -qE 'resetPassword[^}]*stdin' "$service" \
&& fail 'the service opens stdin for a reset, which carries nothing to write'
reset_page="$(file_calling 'resetPassword(')"
[[ -n "$reset_page" ]] || fail 'no page offers to reset another account password'
grep -q 'at next sign-in\|at their next sign-in' "$reset_page" \
|| fail 'the reset row does not say the other person sets the new password themselves'
# ── Removing a picture is a real verb, not a deletion of the file ───────────
#
# accountsservice takes an empty IconFile to mean "no avatar" and cleans up
# after itself. Anything else -- unlinking the file the snapshot named, writing
# a blank image -- leaves the database pointing at something that is not there.
icon_body="$(sed -n '/^def set_icon/,/^def [a-z_]*(/p' "$helper")"
[[ -n "$icon_body" ]] || fail 'set_icon is missing'
grep -q 'SetIconFile' <<<"$icon_body" \
|| fail 'the avatar is not written through accountsservice'
python3 - "$helper" <<'PY' || fail 'set-icon with an empty path does not clear the avatar through SetIconFile("")'
import ast
import sys
tree = ast.parse(open(sys.argv[1], encoding="utf-8").read())
target = next((node for node in ast.walk(tree)
if isinstance(node, ast.FunctionDef) and node.name == "set_icon"), None)
if target is None:
raise SystemExit(1)
dump = ast.dump(target)
# An empty path is a value the function has to recognise, not a path it hands
# to GdkPixbuf -- which would fail, and the avatar would stay.
if 'Constant(value=\'\')' not in dump and 'value=""' not in dump:
print("set_icon never compares its path against the empty string", file=sys.stderr)
raise SystemExit(1)
raise SystemExit(0)
PY
grep -q 'function removeIcon' "$service" \
|| fail 'the service cannot remove a picture'
python3 - "$service" <<'PY' || fail 'removeIcon does not send an empty path to the helper'
import re
import sys
text = open(sys.argv[1], encoding="utf-8").read()
start = text.find("function removeIcon")
if start < 0:
raise SystemExit(1)
body = text[start:text.find("\n }", start)]
if not re.search(r'"set-icon"[^\]]*""', body):
print(body.strip()[:300], file=sys.stderr)
raise SystemExit(1)
raise SystemExit(0)
PY
# ── Deleting says what happens to the files, and honors the answer ──────────
#
# The choice is the whole feature: "Keep the files" and "Remove everything" are
# different, irreversible outcomes, and a dropdown whose answer is dropped on
# the way down is worse than no dropdown at all. Each link is pinned
# separately, because any one of them can invert on its own.
# 1. The helper turns its argument into accountsservice's boolean.
delete_body="$(sed -n '/^def delete_user/,/^def /p' "$helper")"
[[ -n "$delete_body" ]] || fail 'delete_user is missing'
grep -q 'You cannot delete the account you are signed in to' <<<"$delete_body" \
|| fail 'the helper would delete the account running it'
grep -q 'only administrator' <<<"$delete_body" \
|| fail 'the helper would remove the last administrator, leaving nobody able to administer the machine'
python3 - "$helper" <<'PY' || fail 'the helper does not derive the DeleteUser flag from its keep/remove argument'
import ast
import sys
# The page must not offer to change the type of the only administrator either.
grep -q 'administratorCount <= 1' "$page" \
|| fail 'the page offers to demote the only administrator'
tree = ast.parse(open(sys.argv[1], encoding="utf-8").read())
target = next((node for node in ast.walk(tree)
if isinstance(node, ast.FunctionDef) and node.name == "delete_user"), None)
if target is None:
raise SystemExit(1)
parameters = [argument.arg for argument in target.args.args]
if len(parameters) < 2:
print("delete_user takes no keep/remove argument at all", file=sys.stderr)
raise SystemExit(1)
choice = parameters[1]
# The boolean handed to DeleteUser must be computed from that argument. A
# literal True or False there is the bug this whole block exists to catch.
for node in ast.walk(target):
if not isinstance(node, ast.Call):
continue
name = getattr(node.func, "attr", getattr(node.func, "id", ""))
if name != "Variant":
continue
for element in ast.walk(node):
if isinstance(element, ast.Compare) and any(
isinstance(sub, ast.Name) and sub.id == choice
for sub in ast.walk(element)):
raise SystemExit(0)
print(f"the DeleteUser flag is not computed from {choice}", file=sys.stderr)
raise SystemExit(1)
PY
# 2. The service maps its own parameter the way its name reads. Panama has
# flipped this polarity once already (removeFiles -> keepFiles); a mapping
# that says one and sends the other reads correctly in every diff.
python3 - "$service" <<'PY' || fail 'the service maps its keep/remove parameter the wrong way round'
import re
import sys
text = open(sys.argv[1], encoding="utf-8").read()
match = re.search(r"function deleteUser\(([^)]*)\)", text)
if match is None:
print("the service has no deleteUser", file=sys.stderr)
raise SystemExit(1)
parameters = [part.split(":")[0].strip() for part in match.group(1).split(",")]
if len(parameters) < 2:
print("deleteUser takes no keep/remove argument", file=sys.stderr)
raise SystemExit(1)
choice = parameters[1]
body = text[match.end():text.find("\n }", match.end())]
ternary = re.search(re.escape(choice) + r"\s*\?\s*\"([a-z-]+)\"\s*:\s*\"([a-z-]+)\"", body)
if ternary is None:
print(f"deleteUser does not pass {choice} through to the helper: {body.strip()[:200]}",
file=sys.stderr)
raise SystemExit(1)
when_true, when_false = ternary.groups()
wanted = "keep" if "keep" in choice.lower() else "remove"
if wanted not in when_true or wanted in when_false:
print(f"{choice} true sends {when_true!r}, false sends {when_false!r}", file=sys.stderr)
raise SystemExit(1)
raise SystemExit(0)
PY
# 3. The page passes the answer, not a constant.
delete_page="$(file_calling 'deleteUser(')"
[[ -n "$delete_page" ]] || fail 'no page deletes an account'
grep -qE 'deleteUser\([^)]*,\s*(true|false)\s*\)' "$delete_page" \
&& fail 'the page hardcodes what happens to the files, so the choice it offers is decorative'
grep -q 'Keep the files' "$delete_page" \
|| fail 'the page never offers to keep the deleted account files'
grep -qE 'Remove everything|Delete the files|Remove the files' "$delete_page" \
|| fail 'the page never offers to remove the deleted account files'
# ── Deleting is confirmed, and says what it destroys ────────────────────────
grep -q 'confirmingRemoval' "$page" \
grep -q 'confirmingRemoval' "$delete_page" \
|| fail 'the page deletes an account without a confirmation step'
grep -q 'This cannot be undone' "$page" \
grep -q 'This cannot be undone' "$delete_page" \
|| fail 'the page does not say that deleting an account destroys their files'
# The page must not offer to change the type of the only administrator either.
type_page="$(file_calling 'administratorCount')"
[[ -n "$type_page" ]] || fail 'nothing on the page knows how many administrators there are'
grep -q 'administratorCount <= 1' "$type_page" \
|| fail 'the page offers to demote the only administrator'
# And the helper refuses it regardless of what the page offers. Demoting the
# only administrator is the same loss as deleting them -- a machine nobody can
# administer -- and the page is not the only thing that can call this.
python3 - "$helper" <<'PY' || fail 'the helper would demote the only administrator'
import ast
import sys
tree = ast.parse(open(sys.argv[1], encoding="utf-8").read())
target = next((node for node in ast.walk(tree)
if isinstance(node, ast.FunctionDef) and node.name == "set_account_type"), None)
if target is None:
print("set-account-type has no implementation", file=sys.stderr)
raise SystemExit(1)
dump = ast.dump(target)
if "administratorCount" not in dump:
print("set-account-type never counts the administrators", file=sys.stderr)
raise SystemExit(1)
if "BoundaryError" not in dump:
print("set-account-type counts them and refuses nothing", file=sys.stderr)
raise SystemExit(1)
raise SystemExit(0)
PY
# ── Managing somebody else ──────────────────────────────────────────────────
#
# Account type, locked accounts and the reset above are the verbs that only
# make sense for an account that is not yours, and each has to exist on both
# sides or the row is a button that does nothing.
grep -q 'function setAccountTypeFor\|function setAccountType' "$service" \
|| fail 'the service cannot change another account type'
grep -q 'function setLocked' "$service" \
|| fail 'the service cannot unlock a locked account'
grep -qE '"set-locked"' "$service" \
|| fail 'the service does not reach the helper set-locked verb'
grep -qE '\bset-locked\b' "$helper" \
|| fail 'the helper has no set-locked verb'
grep -q 'Unlock' "$(file_calling 'setLocked(')" \
|| fail 'a locked account is never offered an unlock'
# ── The add-user form validates while it is being typed ─────────────────────
#
# The regression this pins: the fields were TextFieldRow, which commits on
# blur. Typing a perfectly good user name left the Create button disabled,
# because the property behind the enabled predicate had not been told yet, and
# the form looked broken for as long as the field had focus. So the property
# the predicate reads must be updated on every keystroke, and the field it
# comes from must not be the blur-committing row.
create_page="$(file_calling 'UserAccounts.createUser(')"
[[ -n "$create_page" ]] || fail 'nothing creates a user'
# The blunt half of the same pin, and the one that cannot be argued with: the
# page that carries the add-user form has no blur-committing field anywhere.
# LiveFieldRow reports per keystroke and also on accept, so a row that only
# wants the accept behaviour has no reason to reach for the old one.
grep -nE '^\s*TextFieldRow\s*\{' "$create_page" \
&& fail 'the add-user page still has a blur-committing field, which is the bug'
python3 - "$create_page" "$helper" <<'PY' || fail 'the add-user form does not validate live'
import re
import sys
page = open(sys.argv[1], encoding="utf-8").read()
lines = page.splitlines()
# The rules the form enforces: the one regular expression it tests a user name
# against, wherever it lives -- an `enabled:` predicate, or a property that
# turns the same test into the message under the field. Found by the shape of
# the rule rather than by where it sits, because it has moved once already.
predicate = next((line for line in lines
if ".test(" in line and re.search(r"/\^\[a-z", line)), None)
if predicate is None:
print("the add-user form validates the user name nowhere at all", file=sys.stderr)
raise SystemExit(1)
held = re.search(r"\.test\(\s*(?:root\.)?([A-Za-z_][\w.]*)", predicate)
if held is None:
print(f"cannot tell what the form validates: {predicate.strip()}", file=sys.stderr)
raise SystemExit(1)
name = held.group(1).split(".")[-1]
# They must be the helper's rules. Two expressions that drift apart give a form
# that accepts a name accountsservice then refuses, with no way to tell why.
helper_rule = re.search(r'USERNAME\s*=\s*re\.compile\(r"([^"]+)"\)',
open(sys.argv[2], encoding="utf-8").read())
page_rule = re.search(r"/(\^[^/]+\$)/", predicate)
if page_rule is None:
print(f"the form's user-name test is not a regular expression: {predicate.strip()}",
file=sys.stderr)
raise SystemExit(1)
if helper_rule and helper_rule.group(1) != page_rule.group(1):
print(f"the form validates {page_rule.group(1)}, the helper enforces {helper_rule.group(1)}",
file=sys.stderr)
raise SystemExit(1)
# The cap is part of the rules and part of the sentence under the field: a
# 40-character name is accepted by the form and refused by accountsservice.
if "31" not in page_rule.group(1):
print(f"the form does not cap the user name length: {page_rule.group(1)}", file=sys.stderr)
raise SystemExit(1)
if "31" not in page:
print("the form never tells anybody about the length cap", file=sys.stderr)
raise SystemExit(1)
# Where that property is written, and by what kind of field. `edited` (or a
# text-changed handler) is per-keystroke; `accepted` is blur, which is the bug.
assignments = [index for index, line in enumerate(lines)
if re.search(rf"\b{re.escape(name)}\s*=", line)
and "property" not in line]
if not assignments:
print(f"{name} is never assigned, so the form can never become valid", file=sys.stderr)
raise SystemExit(1)
LIVE = re.compile(r"on(Edited|TextChanged|TextEdited|DisplayTextChanged)\b")
live = False
for index in assignments:
window = "\n".join(lines[max(0, index - 4):index + 1])
if LIVE.search(window):
live = True
# The enclosing component: the nearest `Type {` above, at any indentation.
for above in range(index, -1, -1):
opener = re.match(r"\s*([A-Z]\w*)\s*\{", lines[above])
if opener:
if opener.group(1) == "TextFieldRow":
print(f"{name} is committed by a TextFieldRow, which only commits on blur "
f"(line {above + 1})", file=sys.stderr)
raise SystemExit(1)
break
if not live:
print(f"{name} is only committed on accept, so the form cannot validate while it is typed",
file=sys.stderr)
raise SystemExit(1)
raise SystemExit(0)
PY
# ── The snapshot is real, and reports no secrets ────────────────────────────
command -v jq >/dev/null 2>&1 || { printf 'user accounts contract: SKIP (no jq)\n'; exit 0; }
snapshot="$("$helper" snapshot 2>/dev/null)" || fail 'snapshot failed'
@@ -77,6 +428,11 @@ jq -e '[.users[] | (.userName | length > 0)] | all' <<<"$snapshot" >/dev/null \
jq -e '.currentUser | length > 0' <<<"$snapshot" >/dev/null \
|| fail 'the snapshot does not say which account is signed in'
# The page renders these; a snapshot that drops them turns a locked account
# into a normal-looking one nobody can sign in to.
jq -e '[.users[] | has("locked") and has("loginTime")] | all' <<<"$snapshot" >/dev/null \
|| fail 'the snapshot no longer reports whether an account is locked'
offenders="$(jq -r '[paths | map(tostring) | join(".")] | map(select(test("(password|secret|hash)$";"i"))) | join(", ")' <<<"$snapshot")"
[[ -z "$offenders" ]] || fail "the snapshot carries credential-shaped fields: $offenders"
@@ -84,11 +440,51 @@ offenders="$(jq -r '[paths | map(tostring) | join(".")] | map(select(test("(pass
jq -e '[.users[] | .uid >= 1000] | all' <<<"$snapshot" >/dev/null \
|| fail 'a system account is listed as a manageable user'
# ── The stock avatars are a list, of a shape the gallery can draw ───────────
#
# Read-only: it lists files that ship with the distribution. An empty list is
# a legitimate answer on a machine without the faces package, so the shape is
# what is pinned, not the contents.
stock="$("$helper" stock-avatars 2>/dev/null)" || fail 'stock-avatars failed'
jq -e '.avatars | type == "array"' <<<"$stock" >/dev/null \
|| fail "stock-avatars does not answer with a list: $stock"
jq -e '[.avatars[] | has("name") and has("path")] | all' <<<"$stock" >/dev/null \
|| fail "a stock avatar is missing its name or its path: $stock"
jq -e '[.avatars[] | (.path | startswith("/"))] | all' <<<"$stock" >/dev/null \
|| fail 'a stock avatar path is not absolute, so nothing can load it'
grep -q 'stockAvatars' "$service" \
|| fail 'the service does not offer the stock avatars to the gallery'
# ── Input validation ────────────────────────────────────────────────────────
for bad in "root; rm -rf /" "../escape" "UPPER" ""; do
result="$("$helper" set-real-name "$bad" "Test" 2>/dev/null | jq -r '.error // ""')"
[[ -n "$result" ]] || fail "the helper accepted \"$bad\" as a user name"
done
printf 'user accounts contract: PASS (%d account(s), credentials never on a command line)\n' \
# Deleting refuses before it reaches accountsservice, not after.
[[ -n "$("$helper" delete-user "${USER:-nobody}" 2>/dev/null | jq -r '.error // ""')" ]] \
|| fail 'delete-user with no keep/remove answer was accepted'
[[ -n "$("$helper" delete-user "${USER:-nobody}" sideways 2>/dev/null | jq -r '.error // ""')" ]] \
|| fail 'delete-user accepted an answer that is neither keep nor remove'
# Both vocabularies, on purpose: `keep`/`remove` is what the page says out
# loud, `keep-files`/`remove-files` is what this helper has always taken and
# what anything older still passes. The refusal below is the self-deletion one,
# which is reached only once the keep/remove answer has been understood -- so a
# vocabulary that stopped being recognized would show up here as the wrong
# message rather than as no message.
for vocabulary in keep remove keep-files remove-files; do
refusal="$("$helper" delete-user "${USER:-nobody}" "$vocabulary" 2>/dev/null \
| jq -r '.error // ""')"
[[ -n "$refusal" ]] || fail "the helper agreed to delete the account running it"
grep -q 'signed in to' <<<"$refusal" \
|| fail "delete-user no longer understands \"$vocabulary\": $refusal"
done
for bad in "root; rm -rf /" "UPPER" ""; do
[[ -n "$("$helper" reset-password "$bad" 2>/dev/null | jq -r '.error // ""')" ]] \
|| fail "reset-password accepted \"$bad\" as a user name"
done
printf 'user accounts contract: PASS (%d account(s), credentials never on a command line, keep-files honored end to end)\n' \
"$(jq '.users | length' <<<"$snapshot")"