Show every answer the portal remembers, and give SSH keys their missing half
Claude-Session: https://claude.ai/code/session_01Ms2FbjQy31TVf3CEvQhGM8
This commit is contained in:
@@ -47,6 +47,14 @@ fail() {
|
||||
# "online-accounts" is listed because Panama has an Online Accounts page -- but
|
||||
# adding an account still has to go through GOA's own dialog, so that one
|
||||
# exception is named explicitly below.
|
||||
# "privacy" joined the list when Privacy & Security stopped being a page that
|
||||
# read the portal and pointed at GNOME for everything else. It now clears the
|
||||
# recent-files list and the thumbnail cache itself, empties the trash through
|
||||
# Storage's own cleanable, and revokes portal grants for six tables rather than
|
||||
# three devices. The card that used to carry the door -- headed "Owned by
|
||||
# Fedora", explaining that GNOME's file-history switches would not take effect
|
||||
# in a Hyprland session anyway -- is gone, because the switches it was
|
||||
# apologizing for are now buttons that work.
|
||||
declare -A OWNED=(
|
||||
[network]=connectivity
|
||||
[wifi]=connectivity
|
||||
@@ -55,6 +63,7 @@ declare -A OWNED=(
|
||||
[sharing]=sharing
|
||||
[users]=users
|
||||
[system\ users]=users
|
||||
[privacy]=privacy
|
||||
)
|
||||
|
||||
# Handoffs that are correct despite naming an owned panel, with the reason.
|
||||
@@ -109,6 +118,15 @@ done < <(grep -rno --include='*.qml' -E 'openGnomePanel\("[^"]*"(, *"[^"]*")?\)'
|
||||
|
||||
(( checked > 0 )) || fail 'no handoffs were examined, so this proves nothing'
|
||||
|
||||
# The inverse, for the page that just stopped handing anything over. The loop
|
||||
# above can only fail on a door that exists; said this way it also fails if the
|
||||
# door comes back under a panel name nobody thought to list.
|
||||
if grep -q 'openGnomePanel' "$settings_dir/PrivacyPage.qml"; then
|
||||
printf 'gnome handoff contract: PrivacyPage still opens a GNOME panel:\n' >&2
|
||||
grep -n 'openGnomePanel' "$settings_dir/PrivacyPage.qml" >&2
|
||||
violations=$((violations + 1))
|
||||
fi
|
||||
|
||||
if (( violations > 0 )); then
|
||||
printf 'Each of these sends someone to GNOME for a page this app already has.\n' >&2
|
||||
exit 1
|
||||
|
||||
@@ -46,6 +46,24 @@ for key in lockBackgroundMode lockBlurLevel lockShowClock lockShowDate lockShowU
|
||||
|| fail "$key was duplicated onto Power or Privacy"
|
||||
done
|
||||
|
||||
# ── One home for the lock timings, and a signpost where the copy used to be ──
|
||||
#
|
||||
# Privacy carried a whole second "Screen lock" card: the same idle timings, the
|
||||
# same preferences, bound twice. Two sliders writing one value is not a
|
||||
# convenience -- it is a page where the number you are looking at may not be the
|
||||
# number you last set. The card is gone, and what replaced it is a pointer,
|
||||
# because deleting a duplicate without saying where the original lives just
|
||||
# moves the confusion.
|
||||
! rg -Fq 'title: "Screen lock"' "$privacy" \
|
||||
|| fail 'the duplicated Screen lock card is back on the Privacy page'
|
||||
for key in lockMinutes lockMinutesBattery lockOnSleep; do
|
||||
rg -Fq "setting: \"$key\"" "$power" || fail "Power does not own $key"
|
||||
! rg -Fq "setting: \"$key\"" "$privacy" \
|
||||
|| fail "$key is bound on Privacy as well as Power, so one preference has two controls"
|
||||
done
|
||||
rg -Fq 'ShellState.openSettings("power")' "$privacy" \
|
||||
|| fail 'Privacy dropped the lock card without pointing anywhere, so the settings look deleted'
|
||||
|
||||
# The lock screen is the third card of the Background tab, after the still and
|
||||
# video wallpaper cards. It belongs there because it is a picture of the
|
||||
# desktop: the background is what it blurs, and the card above is what it
|
||||
|
||||
@@ -8,19 +8,29 @@
|
||||
# /dev/video0 directly, and a settings page implying otherwise is worse
|
||||
# than one that says nothing -- so the limit is stated on the page, not
|
||||
# buried in a comment.
|
||||
# 2. A device nothing has asked for is reported empty, not omitted. "No
|
||||
# 2. A table nothing has asked for is reported empty, not omitted. "No
|
||||
# application uses your microphone" and a page that quietly leaves the
|
||||
# microphone out look identical and mean very different things.
|
||||
# 3. Absence is not failure. The store answers "No entry for microphone" for a
|
||||
# device nobody has requested; treating that as an error would make the
|
||||
# table nobody has requested; treating that as an error would make the
|
||||
# whole page fail because one device is unused.
|
||||
# 4. Anything that is not an explicit "yes" is withheld. Guessing generously
|
||||
# about a camera is the wrong way to be wrong.
|
||||
# 5. A refusal states its reason.
|
||||
# 6. THE NEW ONE. screencast and remote-desktop do not hold yes/no. Their
|
||||
# permissions are structured GVariants -- which monitors, whether the
|
||||
# pointer is included, how long the grant lasts -- and there is no honest
|
||||
# way to reconstruct one from a switch. So those two tables are
|
||||
# revoke-only: DeletePermission exists for them and SetPermission does not,
|
||||
# anywhere, because a toggle that writes a plausible-looking variant would
|
||||
# silently rewrite a grant the user never described.
|
||||
#
|
||||
# The write path is exercised against an application id that does not exist, so
|
||||
# no real application's camera access is changed. What is on this machine --
|
||||
# OBS Studio and GNOME Snapshot -- is read, never written.
|
||||
# SAFETY: this file makes NO live portal writes. The earlier version set and
|
||||
# cleared a camera permission on the real permission store under a probe
|
||||
# application id and put it back afterwards; the write path is now exercised
|
||||
# against a recording `busctl` stub under `env -i`, with the session bus
|
||||
# address pointed at a socket that does not exist. Nothing here can reach the
|
||||
# store this desktop is actually using.
|
||||
|
||||
set -uo pipefail
|
||||
|
||||
@@ -28,8 +38,7 @@ repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||
helper="$repo_dir/config/dot/quickshell/scripts/panama-permissions"
|
||||
service="$repo_dir/config/dot/quickshell/services/Permissions.qml"
|
||||
page="$repo_dir/config/dot/quickshell/modules/settings/PrivacyPage.qml"
|
||||
|
||||
probe="org.panama.ContractProbe"
|
||||
qml_dir="$repo_dir/config/dot/quickshell"
|
||||
|
||||
fail() {
|
||||
printf 'permissions contract: %s\n' "$1" >&2
|
||||
@@ -40,75 +49,416 @@ for path in "$helper" "$service" "$page"; do
|
||||
[[ -r "$path" ]] || fail "missing $path"
|
||||
done
|
||||
[[ -x "$helper" ]] || fail 'panama-permissions is not executable'
|
||||
command -v jq >/dev/null 2>&1 || { printf 'permissions contract: SKIP (no jq)\n'; exit 0; }
|
||||
|
||||
field() { python3 -c "import json,sys; print(json.load(sys.stdin)$1)"; }
|
||||
|
||||
state="$("$helper" snapshot)" || fail 'snapshot failed'
|
||||
|
||||
if [[ "$(printf '%s' "$state" | field "['available']")" != "True" ]]; then
|
||||
printf 'permissions contract: skipped (the portal permission store is not running)\n'
|
||||
exit 0
|
||||
fi
|
||||
work="$(mktemp -d /tmp/panama-permissions.XXXXXX)"
|
||||
trap 'rm -rf "$work"' EXIT
|
||||
|
||||
# ── 1. The page states the limit ────────────────────────────────────────────
|
||||
|
||||
grep -q 'directly' "$page" \
|
||||
|| fail 'the page does not say that programs outside the portal reach these devices anyway'
|
||||
|
||||
# ── 2 & 3. Unused devices are present and empty, not an error ───────────────
|
||||
# ── 6a. No Set path for the structured tables, by AST ────────────────────────
|
||||
#
|
||||
# Read from the syntax tree rather than by grepping for the word, because the
|
||||
# thing being pinned is reachability: which table names can arrive at the one
|
||||
# call that writes. A second settable list, or a settable list that quietly
|
||||
# grew "screencast", is exactly the change this has to catch.
|
||||
|
||||
printf '%s' "$state" | python3 -c "
|
||||
import json, sys
|
||||
state = json.load(sys.stdin)
|
||||
if state['error']:
|
||||
raise SystemExit(f\"snapshot reported an error: {state['error']}\")
|
||||
names = [d['id'] for d in state['devices']]
|
||||
for required in ('camera', 'microphone', 'speakers'):
|
||||
if required not in names:
|
||||
raise SystemExit(f'{required} is missing from the snapshot entirely')
|
||||
" || fail 'a device with no recorded application was dropped or reported as an error'
|
||||
python3 - "$helper" <<'PY' || fail 'the helper can write a permission for a table whose values it cannot construct'
|
||||
import ast
|
||||
import importlib.machinery
|
||||
import importlib.util
|
||||
import sys
|
||||
|
||||
# ── 4 & 5. The write path, on an application that does not exist ────────────
|
||||
path = sys.argv[1]
|
||||
source = open(path, encoding="utf-8").read()
|
||||
tree = ast.parse(source)
|
||||
|
||||
before="$(printf '%s' "$state" | field "['devices']")"
|
||||
# Imported rather than pattern-matched, so the lists are read at their real
|
||||
# values. They are derived from a table registry rather than typed out, and a
|
||||
# contract that only understood one spelling of that would be pinning the
|
||||
# spelling. Importing is safe: the module does its work under __main__.
|
||||
loader = importlib.machinery.SourceFileLoader("panama_permissions", path)
|
||||
module = importlib.util.module_from_spec(importlib.util.spec_from_loader(loader.name, loader))
|
||||
# Registered before it runs: a dataclass declared inside it looks its own module
|
||||
# up by name while the decorator is running.
|
||||
sys.modules[loader.name] = module
|
||||
loader.exec_module(module)
|
||||
|
||||
denied="$("$helper" set camera "$probe" deny)" || fail 'set deny failed'
|
||||
reason="$(printf '%s' "$denied" | field "['error']")"
|
||||
[[ -z "$reason" ]] || fail "denying refused a valid write: $reason"
|
||||
printf '%s' "$denied" | python3 -c "
|
||||
import json, sys
|
||||
for device in json.load(sys.stdin)['devices']:
|
||||
for app in device['applications']:
|
||||
if app['app'] == '$probe':
|
||||
if app['allowed']:
|
||||
raise SystemExit('a denied application was reported as allowed')
|
||||
raise SystemExit(0)
|
||||
raise SystemExit('the denied application was not written at all')
|
||||
" || fail 'deny did not take effect -- the write path is not doing anything'
|
||||
structured = {"screencast", "remote-desktop"}
|
||||
|
||||
allowed="$("$helper" set camera "$probe" allow)" || fail 'set allow failed'
|
||||
printf '%s' "$allowed" | python3 -c "
|
||||
import json, sys
|
||||
for device in json.load(sys.stdin)['devices']:
|
||||
for app in device['applications']:
|
||||
if app['app'] == '$probe' and app['allowed']:
|
||||
raise SystemExit(0)
|
||||
raise SystemExit('allow did not take effect')
|
||||
" || fail 'allow did not take effect'
|
||||
|
||||
# Refusals name their reason rather than merely failing.
|
||||
reason="$(printf '%s' "$("$helper" set camera "$probe" maybe)" | field "['error']")"
|
||||
[[ "$reason" == *"allow or deny"* ]] \
|
||||
|| fail "an invalid decision was not refused with a reason (got: $reason)"
|
||||
def as_names(value):
|
||||
if isinstance(value, dict):
|
||||
value = list(value)
|
||||
if isinstance(value, (list, tuple, set, frozenset)):
|
||||
names = [item for item in value if isinstance(item, str)]
|
||||
return set(names) if len(names) == len(list(value)) else set()
|
||||
return set()
|
||||
|
||||
reason="$(printf '%s' "$("$helper" set nonsense "$probe" allow)" | field "['error']")"
|
||||
[[ -n "$reason" ]] || fail 'an unknown device was accepted'
|
||||
|
||||
# ── Put it back ────────────────────────────────────────────────────────────
|
||||
collections = {name: as_names(getattr(module, name))
|
||||
for name in dir(module) if not name.startswith("_")}
|
||||
collections = {name: value for name, value in collections.items() if value}
|
||||
|
||||
"$helper" forget camera "$probe" >/dev/null || fail 'forget failed'
|
||||
after="$("$helper" snapshot | field "['devices']")"
|
||||
[[ "$before" == "$after" ]] \
|
||||
|| fail 'the contract changed recorded permissions and did not restore them'
|
||||
known = {name for name, value in collections.items() if structured <= value}
|
||||
if not known:
|
||||
raise SystemExit("nothing in the helper names screencast and remote-desktop as tables, "
|
||||
"so it does not know they exist")
|
||||
|
||||
printf 'permissions contract: ok\n'
|
||||
# Exactly one place writes. More than one is two policies, and the second one
|
||||
# is the one that falls behind.
|
||||
writers = []
|
||||
deleters = []
|
||||
for node in ast.walk(tree):
|
||||
if isinstance(node, ast.FunctionDef):
|
||||
literals = [inner.value for inner in ast.walk(node)
|
||||
if isinstance(inner, ast.Constant) and isinstance(inner.value, str)]
|
||||
if "SetPermission" in literals:
|
||||
writers.append(node)
|
||||
if "DeletePermission" in literals:
|
||||
deleters.append(node)
|
||||
|
||||
if len(writers) != 1:
|
||||
raise SystemExit(f"expected exactly one function calling SetPermission, found "
|
||||
f"{[node.name for node in writers]}")
|
||||
if not deleters:
|
||||
raise SystemExit("nothing calls DeletePermission, so a grant can never be revoked")
|
||||
|
||||
# One call site, not merely one function. A second Set inside the same function,
|
||||
# reached by a different branch, would satisfy the count above and defeat the
|
||||
# membership check below it.
|
||||
call_sites = sum(1 for node in ast.walk(tree)
|
||||
if isinstance(node, ast.Constant) and node.value == "SetPermission")
|
||||
if call_sites != 1:
|
||||
raise SystemExit(f"SetPermission is named {call_sites} times; there is one honest "
|
||||
"place to write a permission and it is not two")
|
||||
|
||||
writer = writers[0]
|
||||
writer_names = {inner.id for inner in ast.walk(writer) if isinstance(inner, ast.Name)}
|
||||
|
||||
# The gate: a named list of table names, consulted by the one function that
|
||||
# writes. A chain of conditions would do the same job today and grow an
|
||||
# exception later; a list membership is one place to look.
|
||||
gate = {name for name in writer_names & set(collections) if "camera" in collections[name]}
|
||||
if not gate:
|
||||
raise SystemExit(f"{writer.name} writes without consulting any list of the tables that "
|
||||
"MAY be written, so refusing screencast is a runtime accident rather "
|
||||
"than a rule")
|
||||
|
||||
# The way this breaks is not by deleting the list; it is by somebody adding
|
||||
# "screencast" to it because the page wanted a switch there.
|
||||
for name in gate:
|
||||
if structured & collections[name]:
|
||||
raise SystemExit(f"{name}, which {writer.name} consults before writing, lists "
|
||||
f"{sorted(structured & collections[name])} alongside the tables "
|
||||
"that carry a plain yes/no")
|
||||
|
||||
writer_literals = {inner.value for inner in ast.walk(writer)
|
||||
if isinstance(inner, ast.Constant) and isinstance(inner.value, str)}
|
||||
leaked = structured & writer_literals
|
||||
if leaked:
|
||||
raise SystemExit(f"{writer.name} mentions {sorted(leaked)} by name, next to the one call "
|
||||
"that writes a permission")
|
||||
PY
|
||||
|
||||
# ── 6b. Nothing in the shell asks to set a structured table ─────────────────
|
||||
#
|
||||
# The rule said from the QML side: no page and no service may hand a
|
||||
# revoke-only table to the write path, whatever the helper would do with it.
|
||||
offenders="$(grep -rn --include='*.qml' -E 'setPermission\([^)]*(screencast|remote-desktop)' "$qml_dir" || true)"
|
||||
[[ -z "$offenders" ]] \
|
||||
|| fail "something offers to set a permission it cannot construct: $offenders"
|
||||
|
||||
grep -q 'revoke' "$service" \
|
||||
|| fail 'the Permissions service has no revoke path, so a structured grant is permanent'
|
||||
grep -qE 'revokeOnlyTables|revokeOnly' "$service" \
|
||||
|| fail 'the service does not name which tables are revoke-only, so the page has to guess'
|
||||
grep -qE 'simpleTables|simpleValued' "$service" \
|
||||
|| fail 'the service does not name which tables carry a plain yes/no'
|
||||
grep -q 'tables' "$service" \
|
||||
|| fail 'the service does not expose the per-table model the page is built from'
|
||||
|
||||
# The Applications page reads the camera/microphone half of this service in the
|
||||
# older `devices` shape. Generalizing the model to six tables kept that view
|
||||
# rather than rewriting a second page's bindings, and something has to notice if
|
||||
# it disappears -- the symptom there is an empty list, not an error.
|
||||
grep -q 'property var devices' "$service" \
|
||||
|| fail 'the devices view is gone; ApplicationsPage reads it and would quietly show nothing'
|
||||
consumers="$(grep -rln --include='*.qml' 'Permissions\.devices' "$qml_dir" || true)"
|
||||
[[ -n "$consumers" ]] \
|
||||
|| fail 'nothing reads Permissions.devices any more, so the compatibility view is dead weight'
|
||||
|
||||
# ── The hermetic bus ────────────────────────────────────────────────────────
|
||||
#
|
||||
# A recording `busctl` first on PATH, answering from a fixture. The proof that
|
||||
# it is the one being called is the fixture's own application ids coming back
|
||||
# out of `snapshot` -- asserted before anything that writes runs.
|
||||
|
||||
mkdir -p "$work/bin"
|
||||
export PANAMA_PERMISSIONS_CALL_LOG="$work/calls"
|
||||
: >"$PANAMA_PERMISSIONS_CALL_LOG"
|
||||
|
||||
cat >"$work/bin/busctl" <<'STUB'
|
||||
#!/usr/bin/env bash
|
||||
# A permission store that exists only inside this contract.
|
||||
#
|
||||
# The method and its arguments are read positionally rather than by pattern, so
|
||||
# `List s screencast` and `Lookup ss screencast <token>` answer with the shapes
|
||||
# the real store answers with -- an array of ids for one, a dictionary of
|
||||
# applications for the other. Answering both with the same shape would let a
|
||||
# helper that never lists a table pass anyway.
|
||||
printf '%s\n' "$*" >>"$PANAMA_PERMISSIONS_CALL_LOG"
|
||||
|
||||
method=""
|
||||
rest=()
|
||||
collecting=0
|
||||
for argument in "$@"; do
|
||||
if (( collecting )); then
|
||||
rest+=("$argument")
|
||||
continue
|
||||
fi
|
||||
case "$argument" in
|
||||
List|Lookup|SetPermission|DeletePermission|GetPermission)
|
||||
method="$argument"
|
||||
collecting=1 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
if [[ -z "$method" ]]; then
|
||||
# `busctl --user list`, which is how the helper asks whether the store is
|
||||
# running at all.
|
||||
printf 'org.freedesktop.impl.portal.PermissionStore 1234 - - - -\n'
|
||||
exit 0
|
||||
fi
|
||||
|
||||
table="${rest[1]:-}"
|
||||
entry="${rest[2]:-}"
|
||||
|
||||
ids() { printf '{"type":"as","data":[[%s]]}\n' "$1"; }
|
||||
row() { printf '{"type":"a{sas}v","data":[%s,{"type":"s","data":""}]}\n' "$1"; }
|
||||
absent() { printf 'No entry for %s\n' "$1" >&2; exit 1; }
|
||||
|
||||
case "$method" in
|
||||
SetPermission|DeletePermission)
|
||||
printf '{"type":"","data":[]}\n'
|
||||
exit 0 ;;
|
||||
List)
|
||||
case "$table" in
|
||||
# Two remembered sessions for one application, which is the normal
|
||||
# case: the page asks one question and the store holds four answers.
|
||||
# The third id is deliberately not an id -- a store that handed back
|
||||
# something path-shaped must not have it fed straight back in.
|
||||
screencast) ids '"session-a","session-b","../../etc/passwd"' ;;
|
||||
remote-desktop) ids '"session-r"' ;;
|
||||
background) ids '"background"' ;;
|
||||
location) ids '' ;;
|
||||
*) ids '' ;;
|
||||
esac
|
||||
exit 0 ;;
|
||||
Lookup)
|
||||
case "$table/$entry" in
|
||||
devices/camera)
|
||||
row '{"org.panama.FixtureCam":["yes"],"org.panama.FixtureBlocked":["no"]}' ;;
|
||||
devices/microphone)
|
||||
# Nothing has ever asked. The store says so by having no row.
|
||||
absent microphone ;;
|
||||
screencast/session-a|screencast/session-b)
|
||||
row '{"org.panama.FixtureCast":["1","screen","0"]}' ;;
|
||||
remote-desktop/session-r)
|
||||
row '{"org.panama.FixtureRemote":["1","keyboard,pointer","0"]}' ;;
|
||||
background/background)
|
||||
row '{"org.panama.FixtureBackground":["yes"],"org.panama.FixtureQuiet":["no"]}' ;;
|
||||
*)
|
||||
absent "${entry:-$table}" ;;
|
||||
esac
|
||||
exit 0 ;;
|
||||
esac
|
||||
printf '{"type":"","data":[]}\n'
|
||||
STUB
|
||||
chmod +x "$work/bin/busctl"
|
||||
|
||||
runh() {
|
||||
env -i \
|
||||
PATH="$work/bin:/usr/bin:/bin" \
|
||||
HOME="$work/home" \
|
||||
XDG_RUNTIME_DIR="$work/run" \
|
||||
DBUS_SESSION_BUS_ADDRESS="unix:path=$work/no-such-bus" \
|
||||
PANAMA_PERMISSIONS_CALL_LOG="$PANAMA_PERMISSIONS_CALL_LOG" \
|
||||
LANG=C LC_ALL=C \
|
||||
"$helper" "$@"
|
||||
}
|
||||
mkdir -p "$work/home" "$work/run"
|
||||
|
||||
# The helper has to find busctl on PATH for the stub to mean anything.
|
||||
grep -qE '/usr/bin/busctl|/bin/busctl' "$helper" \
|
||||
&& fail 'the helper names an absolute busctl, so it cannot be pointed at a fixture'
|
||||
|
||||
resolved="$(env -i PATH="$work/bin:/usr/bin:/bin" command -v busctl)"
|
||||
[[ "$resolved" == "$work/bin/busctl" ]] \
|
||||
|| fail "busctl resolves to $resolved, not the stub; refusing to run anything that writes"
|
||||
|
||||
calls() { cat "$PANAMA_PERMISSIONS_CALL_LOG"; }
|
||||
wrote() { grep -c 'SetPermission' "$PANAMA_PERMISSIONS_CALL_LOG"; }
|
||||
deleted() { grep -c 'DeletePermission' "$PANAMA_PERMISSIONS_CALL_LOG"; }
|
||||
|
||||
state="$(runh snapshot)" || fail 'snapshot failed against the fixture bus'
|
||||
|
||||
# ── 2, 3 & 4. The shape, and the fixture proving this ran against the stub ──
|
||||
|
||||
jq -e '.available == true and (.tables | type == "object") and .error == ""' <<<"$state" >/dev/null \
|
||||
|| fail "snapshot is not the tables shape the page is built from: $state"
|
||||
|
||||
for table in camera microphone screencast remote-desktop background; do
|
||||
jq -e --arg t "$table" '.tables | has($t)' <<<"$state" >/dev/null \
|
||||
|| fail "the $table table is missing from the snapshot entirely"
|
||||
jq -e --arg t "$table" '[.tables[$t][] | has("app") and has("allowed")] | all' <<<"$state" >/dev/null \
|
||||
|| fail "a $table row is not { app, allowed }"
|
||||
# `grants` is how a folded row says how many stored answers are behind it,
|
||||
# and `raw` is the store's own value -- the page explains a structured grant
|
||||
# rather than reducing it to a switch, and cannot do that from a boolean.
|
||||
jq -e --arg t "$table" '[.tables[$t][] | has("grants") and has("raw")] | all' <<<"$state" >/dev/null \
|
||||
|| fail "a $table row carries no grant count, so a folded row cannot say what it folded"
|
||||
done
|
||||
|
||||
# The tables the model dropped. "speakers" was in the devices list because the
|
||||
# portal has an entry for it, not because anything ever asks: no portal backend
|
||||
# arbitrates speaker access, so the row was a permanent "nothing has asked" for
|
||||
# a question nobody is asking. Listing it made the page look like it covered
|
||||
# more than it does.
|
||||
jq -e '.tables | has("speakers") | not' <<<"$state" >/dev/null \
|
||||
|| fail 'speakers is back in the permissions model, where nothing ever asks'
|
||||
|
||||
# The proof of the fixture: these ids exist nowhere but in this file.
|
||||
jq -e '[.tables.camera[] | select(.app == "org.panama.FixtureCam" and .allowed == true)] | length == 1' \
|
||||
<<<"$state" >/dev/null \
|
||||
|| fail "snapshot did not read the fixture's camera table; refusing to go on: $state"
|
||||
jq -e '[.tables.camera[] | select(.app == "org.panama.FixtureBlocked" and .allowed == false)] | length == 1' \
|
||||
<<<"$state" >/dev/null \
|
||||
|| fail 'a permission that is not an explicit yes was reported as allowed'
|
||||
|
||||
# Rule 3, from the store's own words: "No entry for microphone" is a table
|
||||
# nobody has asked about, not a failure of the page.
|
||||
jq -e '.tables.microphone == []' <<<"$state" >/dev/null \
|
||||
|| fail "an unused table was not reported as empty: $(jq -c .tables.microphone <<<"$state")"
|
||||
jq -e '.error == ""' <<<"$state" >/dev/null \
|
||||
|| fail 'an unused table was reported as an error, which fails the whole page over one unused device'
|
||||
|
||||
jq -e '[.tables["remote-desktop"][] | select(.app == "org.panama.FixtureRemote")] | length == 1' \
|
||||
<<<"$state" >/dev/null \
|
||||
|| fail 'a structured grant was dropped rather than listed for revoking'
|
||||
|
||||
# One application, two remembered screencast sessions, ONE row. The page asks
|
||||
# "may this application share your screen", which is one question; a store that
|
||||
# holds four answers to it must not become four rows.
|
||||
jq -e '[.tables.screencast[] | select(.app == "org.panama.FixtureCast")] | length == 1' \
|
||||
<<<"$state" >/dev/null \
|
||||
|| fail "one application's several remembered sessions became several rows: $(jq -c .tables.screencast <<<"$state")"
|
||||
|
||||
# An id the store handed back that is not an id shape is dropped, not fed
|
||||
# straight back into a Lookup.
|
||||
grep -qF '../../etc/passwd' "$PANAMA_PERMISSIONS_CALL_LOG" \
|
||||
&& fail 'a path-shaped entry id from the store was passed back into the permission store'
|
||||
|
||||
# The two lists the page builds itself from, and they do not overlap.
|
||||
jq -e '(.simpleTables | index("screencast")) == null
|
||||
and (.simpleTables | index("remote-desktop")) == null
|
||||
and (.revokeOnlyTables | index("screencast")) != null
|
||||
and (.revokeOnlyTables | index("remote-desktop")) != null' <<<"$state" >/dev/null \
|
||||
|| fail "the page is told the wrong tables are toggleable: $(jq -c '{simpleTables,revokeOnlyTables}' <<<"$state")"
|
||||
|
||||
# ── 6c. The write path, at runtime ──────────────────────────────────────────
|
||||
#
|
||||
# Which vocabulary the helper takes is read from the helper rather than
|
||||
# assumed, so this pins the rule and not the spelling.
|
||||
|
||||
probe="org.panama.ContractProbe"
|
||||
verb=""
|
||||
for candidate in true allow; do
|
||||
: >"$PANAMA_PERMISSIONS_CALL_LOG"
|
||||
if [[ "$(runh set camera "$probe" "$candidate" | jq -r '.error')" == "" ]]; then
|
||||
verb="$candidate"
|
||||
break
|
||||
fi
|
||||
done
|
||||
[[ -n "$verb" ]] || fail 'the helper accepted neither `set camera APP true` nor `set camera APP allow`'
|
||||
(( "$(wrote)" >= 1 )) \
|
||||
|| fail "setting a simple table never reached SetPermission: $(calls)"
|
||||
|
||||
# Background is the other toggleable one, and the one people actually come to
|
||||
# this page for.
|
||||
: >"$PANAMA_PERMISSIONS_CALL_LOG"
|
||||
reason="$(runh set background "$probe" "$verb" | jq -r '.error')"
|
||||
[[ -z "$reason" ]] || fail "the background table refused a plain yes/no write: $reason"
|
||||
(( "$(wrote)" >= 1 )) || fail "setting background never reached SetPermission: $(calls)"
|
||||
|
||||
# The applications the fixture store actually holds a grant for -- forgetting
|
||||
# something nobody granted is a different case, checked below.
|
||||
declare -A GRANT_HOLDER=(
|
||||
[screencast]=org.panama.FixtureCast
|
||||
["remote-desktop"]=org.panama.FixtureRemote
|
||||
)
|
||||
|
||||
for table in screencast remote-desktop; do
|
||||
holder="${GRANT_HOLDER[$table]}"
|
||||
|
||||
: >"$PANAMA_PERMISSIONS_CALL_LOG"
|
||||
reason="$(runh set "$table" "$holder" "$verb" | jq -r '.error')"
|
||||
[[ -n "$reason" ]] \
|
||||
|| fail "setting $table was accepted, but its value cannot be honestly constructed"
|
||||
(( "$(wrote)" == 0 )) \
|
||||
|| fail "setting $table was refused in words but still wrote to the store: $(calls)"
|
||||
|
||||
# Revoking, which is the one thing these tables do support.
|
||||
: >"$PANAMA_PERMISSIONS_CALL_LOG"
|
||||
reason="$(runh forget "$table" "$holder" | jq -r '.error')"
|
||||
[[ -z "$reason" ]] || fail "revoking a $table grant was refused: $reason"
|
||||
(( "$(deleted)" >= 1 )) \
|
||||
|| fail "revoking $table never reached DeletePermission: $(calls)"
|
||||
done
|
||||
|
||||
# Every remembered session, not the first one. An application with two stored
|
||||
# screencast grants whose row still says "allowed" after Revoke is the failure
|
||||
# this catches.
|
||||
: >"$PANAMA_PERMISSIONS_CALL_LOG"
|
||||
runh forget screencast org.panama.FixtureCast >/dev/null
|
||||
(( "$(deleted)" == 2 )) \
|
||||
|| fail "revoking dropped $(deleted) of the fixture's two remembered screencast sessions"
|
||||
|
||||
# Forgetting something nobody granted says so rather than reporting success.
|
||||
: >"$PANAMA_PERMISSIONS_CALL_LOG"
|
||||
reason="$(runh forget screencast "$probe" | jq -r '.error')"
|
||||
[[ -n "$reason" ]] || fail 'revoking a grant that does not exist reported success'
|
||||
(( "$(deleted)" == 0 )) || fail 'revoking a grant that does not exist still deleted something'
|
||||
|
||||
# ── 5. Refusals name their reason, and reach nothing ────────────────────────
|
||||
|
||||
: >"$PANAMA_PERMISSIONS_CALL_LOG"
|
||||
reason="$(runh set camera "$probe" maybe | jq -r '.error')"
|
||||
[[ -n "$reason" ]] || fail 'an invalid decision was accepted'
|
||||
(( "$(wrote)" == 0 )) || fail 'an invalid decision still wrote to the store'
|
||||
|
||||
for bad_table in nonsense ../devices 'devices camera' ''; do
|
||||
: >"$PANAMA_PERMISSIONS_CALL_LOG"
|
||||
reason="$(runh set "$bad_table" "$probe" "$verb" | jq -r '.error')"
|
||||
[[ -n "$reason" ]] || fail "the table id '$bad_table' was accepted"
|
||||
(( "$(wrote)" == 0 )) || fail "the table id '$bad_table' reached the store"
|
||||
reason="$(runh forget "$bad_table" "$probe" | jq -r '.error')"
|
||||
[[ -n "$reason" ]] || fail "forgetting the table id '$bad_table' was accepted"
|
||||
done
|
||||
|
||||
for bad_app in '../../etc/passwd' 'not an app' '' '-'; do
|
||||
: >"$PANAMA_PERMISSIONS_CALL_LOG"
|
||||
reason="$(runh set camera "$bad_app" "$verb" | jq -r '.error')"
|
||||
[[ -n "$reason" ]] || fail "the application id '$bad_app' was accepted"
|
||||
(( "$(wrote)" == 0 )) || fail "the application id '$bad_app' reached the store"
|
||||
done
|
||||
|
||||
# A refusal answers in the page's own shape rather than collapsing: the page
|
||||
# reads .tables off every reply, including the ones that say no.
|
||||
jq -e '.tables | type == "object"' <<<"$(runh set nonsense "$probe" "$verb")" >/dev/null \
|
||||
|| fail 'a refusal came back without the tables the page is bound to'
|
||||
|
||||
printf 'permissions contract: PASS (tables read from a fixture bus, no live portal write)\n'
|
||||
|
||||
Executable
+354
@@ -0,0 +1,354 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
# Clearing the traces this desktop keeps of you: recent files, thumbnails, and
|
||||
# the trash.
|
||||
#
|
||||
# Privacy used to hand all three to GNOME's panel, which is not running in a
|
||||
# Hyprland session, so the card that offered them changed nothing. Doing it
|
||||
# natively means writing to two paths in the user's home, which is where this
|
||||
# stops being a display concern and starts being a thing that can destroy
|
||||
# somebody's afternoon. Four rules:
|
||||
#
|
||||
# 1. Clearing recent files EMPTIES the file, it does not delete it. GTK
|
||||
# recreates a missing recently-used.xbel, but not until something writes a
|
||||
# recent entry -- so a deleted file reads as "cleared" and then repopulates
|
||||
# from whatever GTK still had in memory. An empty, valid xbel document
|
||||
# takes effect at once and stays. It also has to remain valid XML: GTK
|
||||
# given a truncated file writes nothing there again, and file history
|
||||
# silently stops working.
|
||||
# 2. Clearing thumbnails stays inside the thumbnail cache. That directory
|
||||
# collects symlinks, and an rm that follows one deletes whatever it points
|
||||
# at. The fixture plants exactly that trap.
|
||||
# 3. The trash has one implementation. Storage already itemizes it, confirms
|
||||
# it, and empties it; a second one on this page would be a second set of
|
||||
# guards to keep in step, and the one that falls behind is the one nobody
|
||||
# is looking at.
|
||||
# 4. The card does not sell anything. Every "clean my PC" product manufactures
|
||||
# urgency, and the only difference between this card and those is the copy.
|
||||
#
|
||||
# SAFETY: every run of the helper is under `env -i` with HOME, XDG_DATA_HOME and
|
||||
# XDG_CACHE_HOME inside a scratch tree this file created, and the run that
|
||||
# deletes anything happens only after the helper has reported the fixture's own
|
||||
# byte counts back. The real recently-used.xbel and the real thumbnail cache are
|
||||
# never opened.
|
||||
|
||||
set -uo pipefail
|
||||
|
||||
repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||
shell_dir="$repo_dir/config/dot/quickshell"
|
||||
helper="$shell_dir/scripts/panama-privacy"
|
||||
service="$shell_dir/services/Traces.qml"
|
||||
page="$shell_dir/modules/settings/PrivacyPage.qml"
|
||||
|
||||
fail() {
|
||||
printf 'privacy traces contract: %s\n' "$1" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
for path in "$helper" "$service" "$page"; do
|
||||
[[ -r "$path" ]] || fail "missing $path"
|
||||
done
|
||||
[[ -x "$helper" ]] || fail 'panama-privacy is not executable'
|
||||
command -v jq >/dev/null 2>&1 || { printf 'privacy traces contract: SKIP (no jq)\n'; exit 0; }
|
||||
|
||||
work="$(mktemp -d /tmp/panama-traces.XXXXXX)"
|
||||
trap 'rm -rf "$work"' EXIT
|
||||
|
||||
# ── The helper can be pointed somewhere else at all ─────────────────────────
|
||||
#
|
||||
# Everything below rests on this: a hardcoded /home/… would mean the run that
|
||||
# deletes is deleting from the real home.
|
||||
grep -n '"/home/' "$helper" \
|
||||
&& fail 'the helper hardcodes a path under /home, so it cannot be pointed at a fixture'
|
||||
|
||||
for verb in traces clear-recents clear-thumbnails; do
|
||||
grep -q -- "$verb" "$helper" || fail "the helper has no $verb command"
|
||||
done
|
||||
|
||||
# Trash is deliberately absent from this helper. Rule 3, said where it would be
|
||||
# broken first. Read past the docstrings, which are allowed to say the word --
|
||||
# and in fact should, since "the trash is Storage's" is the reason.
|
||||
python3 - "$helper" <<'PY' || fail 'panama-privacy has grown a trash implementation; Storage already has one'
|
||||
import ast
|
||||
import sys
|
||||
|
||||
tree = ast.parse(open(sys.argv[1], encoding="utf-8").read())
|
||||
|
||||
# Drop every docstring before looking at what is left.
|
||||
for node in ast.walk(tree):
|
||||
if isinstance(node, (ast.Module, ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)):
|
||||
body = getattr(node, "body", [])
|
||||
if body and isinstance(body[0], ast.Expr) and isinstance(body[0].value, ast.Constant) \
|
||||
and isinstance(body[0].value.value, str):
|
||||
node.body = body[1:]
|
||||
|
||||
offenders = sorted({inner.value for inner in ast.walk(tree)
|
||||
if isinstance(inner, ast.Constant) and isinstance(inner.value, str)
|
||||
and "trash" in inner.value.lower()})
|
||||
offenders += sorted({inner.id for inner in ast.walk(tree)
|
||||
if isinstance(inner, ast.Name) and "trash" in inner.id.lower()})
|
||||
if offenders:
|
||||
raise SystemExit(f"the helper's code mentions the trash: {offenders}")
|
||||
PY
|
||||
|
||||
# ── The fixture home ────────────────────────────────────────────────────────
|
||||
|
||||
home="$work/home"
|
||||
data="$home/.local/share"
|
||||
cache="$home/.cache"
|
||||
recents="$data/recently-used.xbel"
|
||||
thumbs="$cache/thumbnails"
|
||||
mkdir -p "$data" "$thumbs/normal" "$thumbs/large" "$work/outside"
|
||||
|
||||
# A recents file with two entries, of a size that could not be confused with a
|
||||
# real one.
|
||||
cat >"$recents" <<'XBEL'
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<xbel version="1.0"
|
||||
xmlns:bookmark="http://www.freedesktop.org/standards/desktop-bookmarks"
|
||||
xmlns:mime="http://www.freedesktop.org/standards/shared-mime-info">
|
||||
<bookmark href="file:///home/fixture/one.txt" added="2026-01-01T00:00:00Z"
|
||||
modified="2026-01-01T00:00:00Z" visited="2026-01-01T00:00:00Z">
|
||||
<info><metadata owner="http://freedesktop.org">
|
||||
<mime:mime-type type="text/plain"/>
|
||||
</metadata></info>
|
||||
</bookmark>
|
||||
<bookmark href="file:///home/fixture/two.txt" added="2026-01-02T00:00:00Z"
|
||||
modified="2026-01-02T00:00:00Z" visited="2026-01-02T00:00:00Z">
|
||||
<info><metadata owner="http://freedesktop.org">
|
||||
<mime:mime-type type="text/plain"/>
|
||||
</metadata></info>
|
||||
</bookmark>
|
||||
</xbel>
|
||||
XBEL
|
||||
|
||||
# 9 thumbnails of 33333 bytes: ~300 KB, a number that appears nowhere else.
|
||||
for index in $(seq 1 9); do
|
||||
head -c 33333 /dev/zero >"$thumbs/normal/fixture-$index.png"
|
||||
done
|
||||
|
||||
# The trap. A symlink out of the thumbnail cache to a file that must survive,
|
||||
# and a symlinked subdirectory, which has to be unlinked rather than descended.
|
||||
printf 'this file is not a thumbnail and must survive\n' >"$work/outside/precious"
|
||||
ln -s "$work/outside/precious" "$thumbs/escape-file"
|
||||
ln -s "$work/outside" "$thumbs/escape-dir"
|
||||
|
||||
runh() {
|
||||
env -i \
|
||||
PATH="/usr/bin:/bin" \
|
||||
HOME="$home" \
|
||||
XDG_DATA_HOME="$data" \
|
||||
XDG_CACHE_HOME="$cache" \
|
||||
XDG_CONFIG_HOME="$home/.config" \
|
||||
LANG=C LC_ALL=C \
|
||||
"$helper" "$@"
|
||||
}
|
||||
|
||||
# ── The measurement, and the proof it is the fixture's ──────────────────────
|
||||
|
||||
state="$(runh traces)" || fail 'traces failed against the fixture'
|
||||
jq -e '(.recents | has("bytes") and has("entries")) and (.thumbnails | has("bytes"))
|
||||
and (.error == "" or .error == null)' <<<"$state" >/dev/null \
|
||||
|| fail "traces is not the shape the card is bound to: $state"
|
||||
|
||||
# Nothing that deletes runs until these two hold. 9 x 33333 = 299997 bytes of
|
||||
# thumbnails, and two recent entries.
|
||||
jq -e '.thumbnails.bytes > 250000 and .thumbnails.bytes < 400000' <<<"$state" >/dev/null \
|
||||
|| fail "the thumbnail measurement is not the fixture's ($(jq -r .thumbnails.bytes <<<"$state") bytes); refusing to go on"
|
||||
jq -e '.recents.entries == 2' <<<"$state" >/dev/null \
|
||||
|| fail "the recents count is not the fixture's ($(jq -r .recents.entries <<<"$state")); refusing to go on"
|
||||
jq -e '.recents.bytes > 0' <<<"$state" >/dev/null \
|
||||
|| fail 'the recents file was measured as empty when it plainly is not'
|
||||
|
||||
# ── 1. Clearing recents empties the file, and leaves valid XML ──────────────
|
||||
|
||||
before_inode="$(stat -c '%i' "$recents")"
|
||||
runh clear-recents >/dev/null || fail 'clear-recents failed against the fixture'
|
||||
|
||||
[[ -f "$recents" ]] \
|
||||
|| fail 'clear-recents deleted recently-used.xbel; GTK will not recreate it until something writes a recent entry, so file history stops working until then'
|
||||
[[ ! -L "$recents" ]] || fail 'recently-used.xbel was replaced by a symlink'
|
||||
|
||||
python3 - "$recents" <<'PY' || fail 'clear-recents left something that is not a valid, empty xbel document'
|
||||
import sys
|
||||
import xml.etree.ElementTree as ET
|
||||
|
||||
path = sys.argv[1]
|
||||
raw = open(path, "rb").read()
|
||||
if not raw.strip():
|
||||
raise SystemExit("the file was truncated to nothing rather than written as an empty xbel; "
|
||||
"GTK treats a zero-length file as corrupt and stops recording history")
|
||||
try:
|
||||
root = ET.fromstring(raw)
|
||||
except ET.ParseError as error:
|
||||
raise SystemExit(f"what is left is not parseable XML: {error}")
|
||||
if root.tag != "xbel":
|
||||
raise SystemExit(f"the root element is <{root.tag}>, not <xbel>")
|
||||
bookmarks = root.findall("bookmark")
|
||||
if bookmarks:
|
||||
raise SystemExit(f"{len(bookmarks)} bookmark(s) survived clearing")
|
||||
PY
|
||||
|
||||
after="$(runh traces)"
|
||||
jq -e '.recents.entries == 0' <<<"$after" >/dev/null \
|
||||
|| fail "recents still reports $(jq -r .recents.entries <<<"$after") entries after clearing"
|
||||
|
||||
# Rewritten in place or replaced atomically -- either is fine; unlinking and
|
||||
# leaving nothing is what rule 1 forbids, and that is already covered above.
|
||||
# What is checked here is that a second clear on an already-empty file is not
|
||||
# an error, because the row stays pressable.
|
||||
runh clear-recents >/dev/null || fail 'clearing an already-empty recents file failed'
|
||||
[[ -f "$recents" ]] || fail 'the second clear removed the file'
|
||||
: "$before_inode"
|
||||
|
||||
# ── 2. Clearing thumbnails stays inside the thumbnail cache ─────────────────
|
||||
|
||||
runh clear-thumbnails >/dev/null || fail 'clear-thumbnails failed against the fixture'
|
||||
|
||||
[[ -f "$work/outside/precious" ]] \
|
||||
|| fail 'clearing thumbnails followed a symlink out of the cache and deleted a file elsewhere'
|
||||
[[ -d "$work/outside" ]] \
|
||||
|| fail 'clearing thumbnails deleted a directory outside the cache'
|
||||
[[ -d "$thumbs" ]] \
|
||||
|| fail 'the thumbnail directory itself was removed; the thumbnailer expects it to exist'
|
||||
remaining="$(find "$thumbs" -type f | wc -l)"
|
||||
[[ "$remaining" == "0" ]] \
|
||||
|| fail "clearing thumbnails left $remaining file(s) behind, so it did not do what it said"
|
||||
|
||||
# The escape hatch itself is gone -- the link is inside the cache, so removing
|
||||
# it is correct; what must not have happened is following it.
|
||||
[[ ! -e "$thumbs/escape-file" ]] \
|
||||
|| fail 'the symlink inside the cache was left behind'
|
||||
|
||||
# ── The symlinked cache root, refused rather than followed ──────────────────
|
||||
#
|
||||
# The other half of the same trap: not a link inside the cache, but a cache
|
||||
# that IS a link. Resolving to somewhere outside the home has to be refused
|
||||
# with a reason rather than emptied.
|
||||
|
||||
escaped_home="$work/escaped"
|
||||
mkdir -p "$escaped_home/.cache" "$work/victim"
|
||||
printf 'not a thumbnail\n' >"$work/victim/keepme"
|
||||
ln -s "$work/victim" "$escaped_home/.cache/thumbnails"
|
||||
|
||||
escaped_output="$(env -i PATH="/usr/bin:/bin" HOME="$escaped_home" \
|
||||
XDG_CACHE_HOME="$escaped_home/.cache" XDG_DATA_HOME="$escaped_home/.local/share" \
|
||||
LANG=C LC_ALL=C "$helper" clear-thumbnails 2>&1)"
|
||||
escaped_status=$?
|
||||
|
||||
[[ -f "$work/victim/keepme" ]] \
|
||||
|| fail 'a symlinked thumbnail directory was emptied through the link'
|
||||
[[ -d "$work/victim" ]] \
|
||||
|| fail 'the directory a symlinked thumbnail cache pointed at was removed'
|
||||
|
||||
# Refused in words. Either shape counts -- an error field on the payload the
|
||||
# card reads, or a non-zero exit -- but silence does not: a row that reports
|
||||
# success while the cache is untouched is the failure mode.
|
||||
reason="$(jq -r '.error // ""' <<<"$escaped_output" 2>/dev/null || printf '')"
|
||||
[[ -n "$reason" || "$escaped_status" -ne 0 ]] \
|
||||
|| fail 'a thumbnail cache that resolves outside the home was accepted silently'
|
||||
|
||||
# ── The seam is not a way around the guard ──────────────────────────────────
|
||||
#
|
||||
# A test seam that skips the confinement would make everything above theatre.
|
||||
# Pointed at a directory outside the home, it has to be refused exactly as a
|
||||
# symlinked one is.
|
||||
|
||||
mkdir -p "$work/elsewhere"
|
||||
printf 'also not a thumbnail\n' >"$work/elsewhere/keepme"
|
||||
seam_output="$(env -i PATH="/usr/bin:/bin" HOME="$home" \
|
||||
XDG_DATA_HOME="$data" XDG_CACHE_HOME="$cache" \
|
||||
PANAMA_PRIVACY_THUMBNAILS="$work/elsewhere" \
|
||||
LANG=C LC_ALL=C "$helper" clear-thumbnails 2>&1)"
|
||||
seam_status=$?
|
||||
[[ -f "$work/elsewhere/keepme" ]] \
|
||||
|| fail 'the thumbnail seam pointed outside the home was emptied anyway'
|
||||
reason="$(jq -r '.error // ""' <<<"$seam_output" 2>/dev/null || printf '')"
|
||||
[[ -n "$reason" || "$seam_status" -ne 0 ]] \
|
||||
|| fail 'a thumbnail path outside the home was accepted through the test seam'
|
||||
|
||||
# ── 3. One trash implementation ─────────────────────────────────────────────
|
||||
|
||||
grep -q 'Disks' "$page" \
|
||||
|| fail 'the Traces card does not reach the Storage service, so its trash row is a second implementation'
|
||||
grep -qE 'Disks\.(clean|cleanables)' "$page" \
|
||||
|| fail 'the page does not empty the trash through the cleanable Storage already has'
|
||||
|
||||
# The two ways a second one would appear: the page doing it itself, or the
|
||||
# Traces service growing the verb.
|
||||
grep -vE '^\s*//' "$page" | grep -qE 'gio +trash|"trash-empty"|rm -rf.*Trash|\.local/share/Trash' \
|
||||
&& fail 'the page empties the trash itself rather than through Storage'
|
||||
grep -vE '^\s*//' "$service" | grep -qiE 'trash' \
|
||||
&& fail 'the Traces service has grown a trash path; Storage owns that one'
|
||||
|
||||
# The row says so, rather than leaving two identical buttons in two places
|
||||
# looking like two different things.
|
||||
grep -q 'Trash' "$page" \
|
||||
|| fail 'the Traces card has no trash row at all'
|
||||
|
||||
# ── The seams, so this contract can exist ───────────────────────────────────
|
||||
#
|
||||
# Both paths are named seams AND both are re-confined against HOME, which is
|
||||
# what makes pointing HOME at a scratch tree a real test rather than a way of
|
||||
# disabling the guard.
|
||||
|
||||
for seam in PANAMA_PRIVACY_RECENTS PANAMA_PRIVACY_THUMBNAILS; do
|
||||
grep -q "$seam" "$helper" \
|
||||
|| fail "the helper has no $seam seam, so nothing can exercise it without the real home"
|
||||
done
|
||||
grep -qE 'PANAMA_PRIVACY|helperPath' "$service" \
|
||||
|| fail 'the Traces service does not name the helper it runs'
|
||||
|
||||
# ── 4. The card does not sell anything ──────────────────────────────────────
|
||||
|
||||
python3 - "$page" <<'PY' || fail 'the Traces card uses the language of a cleaner racket'
|
||||
import re
|
||||
import sys
|
||||
|
||||
source = open(sys.argv[1], encoding="utf-8").read()
|
||||
text = "\n".join(line for line in source.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 ""
|
||||
|
||||
|
||||
# Found by what it is wired to rather than by its title: the card that reads
|
||||
# the Traces service is the card under test, whatever it ends up being called.
|
||||
cards = [block for block in (block_at(match.start())
|
||||
for match in re.finditer(r"SettingsCard \{", text))
|
||||
if re.search(r"\bTraces\.", block)]
|
||||
if not cards:
|
||||
raise SystemExit("no card on the Privacy page is wired to the Traces service")
|
||||
card = min(cards, key=len)
|
||||
|
||||
# Only what a person reads. QML is full of exclamation marks and none of them
|
||||
# are shouting at anybody.
|
||||
copy = " ".join(re.findall(r'"([^"\n]*)"', card)).lower()
|
||||
|
||||
PRESSURE = [
|
||||
"running out", "running low", "act now", "recommended", "we recommend",
|
||||
"urgent", "boost", "speed up", "optimize", "optimise", "reclaim now",
|
||||
"free up now", "clean now", "junk", "safe to remove", "you should",
|
||||
"needs attention", "protect yourself", "at risk", "exposed", "!",
|
||||
]
|
||||
found = [phrase for phrase in PRESSURE if phrase in copy]
|
||||
if found:
|
||||
raise SystemExit(f"the Traces card says: {found}")
|
||||
|
||||
# Nor one button that clears the lot: each trace is a separate thing to lose,
|
||||
# and losing all three because one of them was worth clearing is not a choice
|
||||
# anybody made.
|
||||
if re.search(r'"(Clear (everything|all)|Erase everything|Wipe)"', card):
|
||||
raise SystemExit("the card offers a single button that clears every trace at once")
|
||||
PY
|
||||
|
||||
printf 'privacy traces contract: PASS (recents emptied as valid xbel, thumbnails confined, trash still Storage\047s)\n'
|
||||
@@ -69,7 +69,7 @@ grep -qiE 'property (string|var) (secret|password|value)\b' "$service" \
|
||||
&& fail 'the Keyring service declares a property that would hold a secret value'
|
||||
|
||||
# ── 4. Forgetting is confirmed ───────────────────────────────────────────────
|
||||
grep -q 'confirmingPath' "$page" \
|
||||
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'
|
||||
@@ -79,9 +79,78 @@ grep -q 'Keyring.forget(' "$page" \
|
||||
# for confirmation rather than a deletion.
|
||||
grep -q 'if (!secretRow.confirming)' "$page" \
|
||||
|| fail 'the first press on Forget is not turned into a confirmation step'
|
||||
grep -q 'root.confirmingPath = secretRow.itemPath;' "$page" \
|
||||
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'
|
||||
|
||||
@@ -40,8 +40,12 @@ expected = {
|
||||
"cursorInactiveTimeout": {"owner": "mouse", "mirrors": {"accessibility"}},
|
||||
"cursorSize": {"owner": "accessibility", "mirrors": {"mouse"}},
|
||||
"inactiveOpacity": {"owner": "appearance", "mirrors": {"accessibility"}},
|
||||
"lockMinutes": {"owner": "power", "mirrors": {"privacy"}},
|
||||
"lockOnSleep": {"owner": "power", "mirrors": {"privacy"}},
|
||||
# lockMinutes and lockOnSleep used to be mirrored onto Privacy, which was
|
||||
# the one mirror in this table that nobody had asked for: Privacy carried a
|
||||
# whole second Screen-lock card, so the same preference had two sliders and
|
||||
# the page you were looking at might not be the one you last set. The card
|
||||
# is gone and Privacy points at Power & Lock instead, so the mirror went
|
||||
# with it. Power owns them outright now.
|
||||
}
|
||||
|
||||
|
||||
@@ -103,8 +107,6 @@ for needle in \
|
||||
'`cursorInactiveTimeout`' \
|
||||
'`cursorSize`' \
|
||||
'`inactiveOpacity`' \
|
||||
'`lockMinutes`' \
|
||||
'`lockOnSleep`' \
|
||||
'scheme-relative role' \
|
||||
'mode, scale, rotation, arrangement, and primary role'; do
|
||||
rg -Fq "$needle" "$readme" || fail "README is missing $needle"
|
||||
|
||||
@@ -1,32 +1,45 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
# SSH keys, and the two things this page must never do.
|
||||
# SSH keys, and the things this page must never do.
|
||||
#
|
||||
# The rules:
|
||||
#
|
||||
# 1. A private key is never read for its contents and never leaves the
|
||||
# machine's disk. Fingerprints and comments come from the .pub file.
|
||||
# 2. No passphrase passes through this tool. Adding an encrypted key lets
|
||||
# ssh-add prompt through the system's own askpass; collecting one here and
|
||||
# handing it on would be a worse place for it to live, and putting one in
|
||||
# argv would publish it to every process on the machine.
|
||||
# 2. A passphrase never reaches argv, an environment variable, or a temporary
|
||||
# file. /proc publishes argv and the environment to every process on this
|
||||
# machine, and a file on disk outlives the moment it was needed for. The
|
||||
# page can now MAKE a key, which means it collects one -- so the rule
|
||||
# stopped being "no passphrase exists here" and became "the passphrase
|
||||
# goes down a pty to ssh-keygen and nowhere else". `ssh-keygen -N` is the
|
||||
# easy way to do this wrong and is forbidden outright. Adding an existing
|
||||
# encrypted key to the agent still collects nothing: ssh-add prompts
|
||||
# through the system's own askpass.
|
||||
# 3. Key paths are confined to ~/.ssh, resolved and compared, so a name cannot
|
||||
# walk out of the directory.
|
||||
# walk out of the directory -- and generation refuses to overwrite, because
|
||||
# the one thing worse than not making a key is replacing one whose public
|
||||
# half is already installed on servers you can no longer reach.
|
||||
# 4. A control that cannot do what it says is not offered. gnome-keyring's
|
||||
# agent lists every key it finds in ~/.ssh, so `ssh-add -d` reports
|
||||
# "Identity removed" and the key is still offered a second later. Measured
|
||||
# on this machine: a plain ssh-agent removes durably, that one does not.
|
||||
# 5. Copying a public key never splices a path into shell source.
|
||||
# The button exists now, and says so.
|
||||
# 5. Copying a public key never splices a path into shell source, and says
|
||||
# that it happened.
|
||||
#
|
||||
# Read-only against the real configuration. Nothing here adds, removes or
|
||||
# rewrites a key, an agent entry, or a known host.
|
||||
# SAFETY: the first half is read-only against the real configuration -- nothing
|
||||
# adds, removes or rewrites a key, an agent entry, or a known host. The second
|
||||
# half runs the helper under `env -i` with HOME inside a scratch tree and a
|
||||
# recording `ssh-keygen` first on PATH, so every key it creates, refuses or
|
||||
# chmods is one this file made. `~/.ssh` is never the directory under test.
|
||||
|
||||
set -uo pipefail
|
||||
|
||||
repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||
helper="$repo_dir/config/dot/quickshell/scripts/panama-ssh-keys"
|
||||
service="$repo_dir/config/dot/quickshell/services/SshKeys.qml"
|
||||
page="$repo_dir/config/dot/quickshell/modules/settings/SshKeysPage.qml"
|
||||
shell_dir="$repo_dir/config/dot/quickshell"
|
||||
helper="$shell_dir/scripts/panama-ssh-keys"
|
||||
service="$shell_dir/services/SshKeys.qml"
|
||||
page="$shell_dir/modules/settings/SshKeysPage.qml"
|
||||
|
||||
fail() {
|
||||
printf 'ssh keys contract: %s\n' "$1" >&2
|
||||
@@ -65,12 +78,98 @@ for key in state['keys']:
|
||||
grep -q 'read_text' "$helper" && ! grep -q 'KNOWN_HOSTS.read_text' "$helper" \
|
||||
&& fail 'something reads a file directly that is not known_hosts'
|
||||
|
||||
# ── 2. No passphrase anywhere ───────────────────────────────────────────────
|
||||
# ── 2. The passphrase, statically ───────────────────────────────────────────
|
||||
|
||||
grep -qE '\-N["'"'"' ]' "$helper" \
|
||||
&& fail 'ssh-keygen -N appears, which would put a passphrase in argv'
|
||||
grep -qi 'passphrase' "$service" && ! grep -qi 'never\|prompt' "$service" \
|
||||
&& fail 'the service mentions passphrases without saying it does not handle them'
|
||||
|
||||
grep -qE '\bimport pty\b|openpty' "$helper" \
|
||||
|| fail 'nothing opens a pty, so ssh-keygen has no terminal to read a passphrase from'
|
||||
|
||||
# Where it must not go, read from the syntax tree rather than by eye: the
|
||||
# parameter carrying the passphrase may not appear inside any command list, any
|
||||
# environment dict, or any call that opens a file.
|
||||
python3 - "$helper" <<'PY' || fail 'the passphrase can reach somewhere other than the pty'
|
||||
import ast
|
||||
import sys
|
||||
|
||||
tree = ast.parse(open(sys.argv[1], encoding="utf-8").read())
|
||||
|
||||
functions = [node for node in ast.walk(tree)
|
||||
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef))]
|
||||
if not any("generate" in node.name for node in functions):
|
||||
raise SystemExit("the helper has no generate function")
|
||||
|
||||
# Every function that is handed the passphrase, not only the one named
|
||||
# generate: the pty write lives in a helper of its own, and a rule that only
|
||||
# looked at the caller would miss the place the value actually goes.
|
||||
carriers = []
|
||||
for node in functions:
|
||||
arguments = node.args
|
||||
names = [argument.arg for argument in
|
||||
arguments.posonlyargs + arguments.args + arguments.kwonlyargs]
|
||||
for name in names:
|
||||
if "pass" in name.lower() or "secret" in name.lower():
|
||||
carriers.append((node, name))
|
||||
|
||||
if not carriers:
|
||||
raise SystemExit("no function in the helper takes a passphrase, so nothing collects one "
|
||||
"and the page cannot make an encrypted key")
|
||||
|
||||
|
||||
def mentions(node, secret) -> bool:
|
||||
return any(isinstance(inner, ast.Name) and inner.id == secret
|
||||
for inner in ast.walk(node))
|
||||
|
||||
|
||||
for function, secret in carriers:
|
||||
where = f"{function.name}()"
|
||||
for node in ast.walk(function):
|
||||
if isinstance(node, (ast.List, ast.Tuple)) and mentions(node, secret):
|
||||
raise SystemExit(f"{secret} appears inside a list literal in {where}, "
|
||||
"which is how it gets into argv")
|
||||
if isinstance(node, ast.Dict) and mentions(node, secret):
|
||||
raise SystemExit(f"{secret} appears inside a dict literal in {where}, "
|
||||
"which is how it gets into the environment")
|
||||
if isinstance(node, ast.Call):
|
||||
called = node.func
|
||||
label = getattr(called, "id", None) or getattr(called, "attr", None) or ""
|
||||
if label in {"open", "NamedTemporaryFile", "mkstemp", "write_text", "write_bytes"} \
|
||||
and any(mentions(argument, secret) for argument in node.args):
|
||||
raise SystemExit(f"{secret} is handed to {label}() in {where}, "
|
||||
"which puts it on disk")
|
||||
if label in {"putenv", "setenv"} and mentions(node, secret):
|
||||
raise SystemExit(f"{secret} is put into the environment in {where}, "
|
||||
"which /proc publishes")
|
||||
|
||||
# The empty-passphrase escape hatch exists, is explicit, and is not the default.
|
||||
flags = {node.value for node in ast.walk(tree)
|
||||
if isinstance(node, ast.Constant) and isinstance(node.value, str)}
|
||||
if "--no-passphrase" not in flags:
|
||||
raise SystemExit("there is no explicit --no-passphrase flag, so an empty passphrase "
|
||||
"is either impossible or silent")
|
||||
PY
|
||||
|
||||
# ...and nothing in the shell ever passes that flag. An unencrypted key is a
|
||||
# decision someone makes at a terminal, not one a settings page makes quietly.
|
||||
offenders="$(grep -rn --include='*.qml' -- '--no-passphrase' "$shell_dir" || true)"
|
||||
[[ -z "$offenders" ]] \
|
||||
|| fail "the shell passes --no-passphrase, so the page can make an unencrypted key: $offenders"
|
||||
|
||||
# The service holds one for exactly as long as it takes to write it down the
|
||||
# pipe, and never assembles it into a command.
|
||||
grep -q 'stdinEnabled' "$service" \
|
||||
|| fail 'the service has no stdin path, so a passphrase would have to travel some other way'
|
||||
python3 - "$service" <<'PY' || fail 'the service puts a passphrase into a command'
|
||||
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("//"))
|
||||
for match in re.finditer(r'command\s*[:=]\s*\[[^\]]*\]', text, re.S):
|
||||
if re.search(r'passphrase', match.group(0), re.I):
|
||||
raise SystemExit(f"a passphrase is spliced into a command: {match.group(0)!r}")
|
||||
PY
|
||||
|
||||
# ── 3. Paths are confined ───────────────────────────────────────────────────
|
||||
|
||||
@@ -94,13 +193,23 @@ reason="$(printf '%s' "$("$helper" forget-host 'not a host name')" | field "['er
|
||||
grep -q 'durableRemoval' "$helper" \
|
||||
|| fail 'the helper does not record whether removal from this agent sticks'
|
||||
|
||||
# Removal is reachable now, rather than being a verb the helper had and nothing
|
||||
# called. All three links have to exist or the button is decoration.
|
||||
grep -q 'agent-remove' "$service" \
|
||||
|| fail 'the service never invokes agent-remove, so the helper verb is unreachable'
|
||||
grep -qE 'function removeFromAgent' "$service" \
|
||||
|| fail 'the service has no removeFromAgent, so the page has nothing to call'
|
||||
grep -q 'removeFromAgent(' "$page" \
|
||||
|| fail 'the page never removes a key from the agent, so the verb is still unreachable'
|
||||
|
||||
kind="$(printf '%s' "$state" | field "['agent'].get('kind','')")"
|
||||
if [[ "$kind" == "gnome-keyring" ]]; then
|
||||
# Whichever key this machine actually has. This used to hardcode
|
||||
# id_ed25519, which asserted the author's machine: any other key name
|
||||
# earned "That key no longer exists" instead of the refusal under test.
|
||||
# No key at all means the property cannot be exercised here, not that it
|
||||
# failed.
|
||||
# failed. Nothing is removed either way -- the refusal happens before
|
||||
# ssh-add is invoked.
|
||||
real_key="$(compgen -G "$HOME/.ssh/id_*.pub" | head -1)"
|
||||
real_key="${real_key%.pub}"
|
||||
if [[ -n "$real_key" ]]; then
|
||||
@@ -112,9 +221,249 @@ if [[ "$kind" == "gnome-keyring" ]]; then
|
||||
|| fail 'the page does not say that removing a key from this agent has no effect'
|
||||
fi
|
||||
|
||||
# ── 5. Copying does not build shell source from a path ──────────────────────
|
||||
# The refusal is prose on the page, not a silent no-op: someone pressing Remove
|
||||
# and watching the key stay is owed the reason.
|
||||
grep -qE 'lastError|durableRemoval' "$page" \
|
||||
|| fail 'the page surfaces neither the refusal nor the reason for it'
|
||||
|
||||
# ── 5. Copying does not build shell source from a path, and says it happened ─
|
||||
|
||||
grep -q 'exec wl-copy < "\$1"' "$service" \
|
||||
|| fail 'the public key copy does not pass its path as an argument'
|
||||
grep -q 'copiedKey' "$service" \
|
||||
|| fail 'the service records nothing about a copy, so the button cannot confirm it happened'
|
||||
grep -qE 'Timer' "$service" \
|
||||
|| fail 'the copy confirmation is never cleared, so the page stays stuck saying Copied'
|
||||
grep -q 'copiedKey' "$page" \
|
||||
|| fail 'the page does not show that a public key was copied'
|
||||
|
||||
printf 'ssh keys contract: ok\n'
|
||||
# The page can ask for the state again rather than only at startup.
|
||||
grep -qE 'SshKeys\.refresh\(\)' "$page" \
|
||||
|| fail 'the page cannot refresh, so a key made in a terminal never appears'
|
||||
|
||||
# ══ The hermetic half ═══════════════════════════════════════════════════════
|
||||
#
|
||||
# Everything above reads. Everything below writes -- into a scratch home, with
|
||||
# a recording ssh-keygen, and never anywhere near ~/.ssh.
|
||||
|
||||
command -v jq >/dev/null 2>&1 || { printf 'ssh keys contract: SKIP hermetic half (no jq)\n'; exit 0; }
|
||||
|
||||
work="$(mktemp -d /tmp/panama-ssh-keys.XXXXXX)"
|
||||
trap 'rm -rf "$work"' EXIT
|
||||
|
||||
scratch="$work/home"
|
||||
ssh_dir="$scratch/.ssh"
|
||||
mkdir -p "$ssh_dir" "$work/bin" "$work/outside"
|
||||
chmod 700 "$ssh_dir"
|
||||
|
||||
log="$work/keygen.log"
|
||||
: >"$log"
|
||||
|
||||
# The recording ssh-keygen. It logs its own argv and its own environment,
|
||||
# straight out of /proc, so the passphrase check is made against what the
|
||||
# kernel would show any other process rather than against what the stub was
|
||||
# told. Then it plays the prompts and reads the answers off its terminal.
|
||||
cat >"$work/bin/ssh-keygen" <<STUB
|
||||
#!/usr/bin/env bash
|
||||
log="$log"
|
||||
{
|
||||
printf 'argv: %s\n' "\$(tr '\\0' ' ' </proc/\$\$/cmdline)"
|
||||
printf 'environ: %s\n' "\$(tr '\\0' ' ' </proc/\$\$/environ)"
|
||||
} >>"\$log"
|
||||
|
||||
mode=""
|
||||
keyfile=""
|
||||
comment=""
|
||||
previous=""
|
||||
for argument in "\$@"; do
|
||||
case "\$argument" in
|
||||
-t) mode=generate ;;
|
||||
-y) mode=derive ;;
|
||||
-l) mode=fingerprint ;;
|
||||
-R) mode=forget ;;
|
||||
esac
|
||||
case "\$previous" in
|
||||
-f) keyfile="\$argument" ;;
|
||||
-C) comment="\$argument" ;;
|
||||
esac
|
||||
previous="\$argument"
|
||||
done
|
||||
|
||||
case "\$mode" in
|
||||
generate)
|
||||
printf 'Enter passphrase (empty for no passphrase): '
|
||||
IFS= read -r first
|
||||
printf '\nEnter same passphrase again: '
|
||||
IFS= read -r second
|
||||
printf '\n'
|
||||
printf 'pty-read: %s\n' "\$first" >>"\$log"
|
||||
printf 'pty-read: %s\n' "\$second" >>"\$log"
|
||||
[[ -n "\$keyfile" ]] || exit 1
|
||||
printf 'fixture private key, not real material\n' >"\$keyfile"
|
||||
chmod 600 "\$keyfile"
|
||||
printf 'ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIFIXTURE %s\n' "\$comment" >"\$keyfile.pub"
|
||||
printf 'Your identification has been saved in %s\n' "\$keyfile"
|
||||
exit 0 ;;
|
||||
derive)
|
||||
# An encrypted key: deriving the public half with an empty passphrase
|
||||
# fails, which is how the helper answers "is this one encrypted".
|
||||
printf 'Load key "%s": incorrect passphrase supplied to decrypt private key\n' \
|
||||
"\$keyfile" >&2
|
||||
exit 1 ;;
|
||||
fingerprint)
|
||||
printf '256 SHA256:FIXTUREFINGERPRINTAAAAAAAAAAAAAAAAAAAAAAAAA fixture (ED25519)\n'
|
||||
exit 0 ;;
|
||||
forget)
|
||||
exit 0 ;;
|
||||
esac
|
||||
exit 0
|
||||
STUB
|
||||
chmod +x "$work/bin/ssh-keygen"
|
||||
|
||||
# ssh-add must never be reached here. If something calls it, that is the
|
||||
# failure, so the stub records and refuses rather than doing anything.
|
||||
cat >"$work/bin/ssh-add" <<STUB
|
||||
#!/usr/bin/env bash
|
||||
printf 'ssh-add: %s\n' "\$*" >>"$log"
|
||||
exit 2
|
||||
STUB
|
||||
chmod +x "$work/bin/ssh-add"
|
||||
|
||||
resolved="$(env -i PATH="$work/bin:/usr/bin:/bin" command -v ssh-keygen)"
|
||||
[[ "$resolved" == "$work/bin/ssh-keygen" ]] \
|
||||
|| fail "ssh-keygen resolves to $resolved, not the stub; refusing to generate anything"
|
||||
|
||||
runh() {
|
||||
env -i \
|
||||
PATH="$work/bin:/usr/bin:/bin" \
|
||||
HOME="$scratch" \
|
||||
XDG_RUNTIME_DIR="$work/run" \
|
||||
LANG=C LC_ALL=C \
|
||||
"$helper" "$@"
|
||||
}
|
||||
mkdir -p "$work/run"
|
||||
|
||||
# The proof that the helper is looking at the scratch home before anything is
|
||||
# written into it.
|
||||
[[ "$(runh snapshot | jq -r '.directory')" == "$ssh_dir" ]] \
|
||||
|| fail "the helper reports $(runh snapshot | jq -r '.directory') as its SSH directory, not the scratch one; refusing to go on"
|
||||
|
||||
# Deliberately does not contain the word this contract greps for in prompts:
|
||||
# the pty echoes what is typed, and a value that reads like a prompt would make
|
||||
# the transcript ambiguous.
|
||||
PASSPHRASE='Contract-Secret-9c1f-do-not-log-me'
|
||||
|
||||
generate() {
|
||||
printf '%s\n' "$PASSPHRASE" | runh generate "$@"
|
||||
}
|
||||
|
||||
# ── Generation, and where the passphrase went ───────────────────────────────
|
||||
|
||||
: >"$log"
|
||||
result="$(generate contractkey 'panama contract fixture')" \
|
||||
|| fail 'generate failed against the scratch home'
|
||||
reason="$(jq -r '.error // ""' <<<"$result")"
|
||||
[[ -z "$reason" ]] || fail "generating a key was refused: $reason"
|
||||
|
||||
[[ -f "$ssh_dir/contractkey" ]] || fail 'generate reported success and made no private key'
|
||||
[[ -f "$ssh_dir/contractkey.pub" ]] || fail 'generate made no public key'
|
||||
mode="$(stat -c '%a' "$ssh_dir/contractkey")"
|
||||
[[ "$mode" == "600" ]] || fail "a freshly made private key is mode $mode, which ssh refuses to use"
|
||||
|
||||
grep -q 'argv: .*ed25519' "$log" || fail "ssh-keygen was not asked for an ed25519 key: $(cat "$log")"
|
||||
|
||||
# The environment ssh-keygen runs in, read back out of /proc rather than out of
|
||||
# the source. Two things have to be true there, and both were found the hard
|
||||
# way:
|
||||
#
|
||||
# * SSH_ASKPASS_REQUIRE=never. This desktop sets it to "prefer", which makes
|
||||
# ssh-keygen draw a graphical passphrase dialog even with a perfectly good
|
||||
# terminal in front of it -- so the prompt appears on somebody's screen, the
|
||||
# pty sees nothing, and generation hangs until the timeout.
|
||||
# * LC_ALL=C. The prompts are matched by their words; a translated ssh-keygen
|
||||
# would never be answered.
|
||||
grep -q 'environ: .*SSH_ASKPASS_REQUIRE=never' "$log" \
|
||||
|| fail "ssh-keygen was not told to ignore askpass, so a graphical prompt can steal the passphrase question: $(grep '^environ: ' "$log" | head -1)"
|
||||
grep -q 'environ: .*LC_ALL=C' "$log" \
|
||||
|| fail 'ssh-keygen was not pinned to the C locale, so its prompts may not be the ones being matched'
|
||||
|
||||
# THE rule. Read from what /proc showed the process, both halves.
|
||||
if grep -E '^(argv|environ): ' "$log" | grep -qF "$PASSPHRASE"; then
|
||||
fail 'the passphrase appeared in ssh-keygen argv or environment, where every process on this machine can read it'
|
||||
fi
|
||||
grep -qF "pty-read: $PASSPHRASE" "$log" \
|
||||
|| fail "the passphrase never arrived down the terminal, so ssh-keygen cannot have used it: $(cat "$log")"
|
||||
[[ "$(grep -c "pty-read: $PASSPHRASE" "$log")" == "2" ]] \
|
||||
|| fail 'the passphrase was not confirmed to ssh-keygen twice, so generation would have stalled at the second prompt'
|
||||
|
||||
# It is not left lying around in the answer the page reads, either.
|
||||
grep -qF "$PASSPHRASE" <<<"$result" \
|
||||
&& fail 'the passphrase came back in the JSON the page parses'
|
||||
|
||||
# Nor on disk anywhere under the scratch tree except inside the key ssh-keygen
|
||||
# itself wrote.
|
||||
found="$(grep -rlF "$PASSPHRASE" "$scratch" "$work/run" 2>/dev/null || true)"
|
||||
[[ -z "$found" ]] || fail "the passphrase was written to disk: $found"
|
||||
|
||||
# The snapshot comes back with the new key in it, so the page does not need a
|
||||
# second round trip to see what it just made.
|
||||
jq -e '[.keys[] | select(.name == "contractkey")] | length == 1' <<<"$result" >/dev/null \
|
||||
|| fail "generate did not return a snapshot containing the new key: $result"
|
||||
|
||||
# ── Overwrite is refused ────────────────────────────────────────────────────
|
||||
|
||||
original="$(cat "$ssh_dir/contractkey")"
|
||||
: >"$log"
|
||||
reason="$(generate contractkey 'a second time' | jq -r '.error // ""')"
|
||||
[[ -n "$reason" ]] || fail 'generating over an existing key was accepted'
|
||||
[[ "$(cat "$ssh_dir/contractkey")" == "$original" ]] \
|
||||
|| fail 'the existing private key was overwritten; its public half is on servers that are now unreachable'
|
||||
grep -q 'argv: .*ed25519' "$log" \
|
||||
&& fail 'the overwrite was refused only after ssh-keygen had already run'
|
||||
|
||||
# ── Names are confined ──────────────────────────────────────────────────────
|
||||
|
||||
printf 'do not touch me\n' >"$work/outside/target"
|
||||
|
||||
for bad in '../outside/target' '../../outside/target' 'sub/dir' '.' '..' '' \
|
||||
'name with spaces' 'name;rm -rf' '/etc/hostname' \
|
||||
'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'; do
|
||||
: >"$log"
|
||||
reason="$(generate "$bad" 'confinement probe' | jq -r '.error // ""')"
|
||||
[[ -n "$reason" ]] || fail "generate accepted the key name '$bad'"
|
||||
grep -q 'argv: .*ed25519' "$log" \
|
||||
&& fail "the key name '$bad' reached ssh-keygen before being refused"
|
||||
done
|
||||
[[ "$(cat "$work/outside/target")" == "do not touch me" ]] \
|
||||
|| fail 'a key name walked out of the SSH directory and overwrote a file'
|
||||
[[ "$(find "$ssh_dir" -maxdepth 1 -type f | wc -l)" == "2" ]] \
|
||||
|| fail "the scratch SSH directory holds $(find "$ssh_dir" -maxdepth 1 -type f | wc -l) files; a refused name made one anyway"
|
||||
|
||||
# ── An empty passphrase needs the explicit flag ─────────────────────────────
|
||||
|
||||
reason="$(printf '\n' | runh generate emptypass 'no passphrase' | jq -r '.error // ""')"
|
||||
[[ -n "$reason" ]] || fail 'an empty passphrase was accepted without --no-passphrase'
|
||||
[[ ! -f "$ssh_dir/emptypass" ]] || fail 'a key was made with no passphrase and no explicit flag'
|
||||
|
||||
# ── fix-permissions, confined and effective ─────────────────────────────────
|
||||
|
||||
chmod 644 "$ssh_dir/contractkey"
|
||||
reason="$(runh fix-permissions contractkey | jq -r '.error // ""')"
|
||||
[[ -z "$reason" ]] || fail "fixing a key's permissions was refused: $reason"
|
||||
mode="$(stat -c '%a' "$ssh_dir/contractkey")"
|
||||
[[ "$mode" == "600" ]] || fail "fix-permissions left the key at $mode rather than 600"
|
||||
|
||||
chmod 644 "$work/outside/target"
|
||||
for bad in '../outside/target' '/etc/hostname' '../../outside/target' 'sub/dir' ''; do
|
||||
reason="$(runh fix-permissions "$bad" | jq -r '.error // ""')"
|
||||
[[ -n "$reason" ]] || fail "fix-permissions accepted '$bad'"
|
||||
done
|
||||
[[ "$(stat -c '%a' "$work/outside/target")" == "644" ]] \
|
||||
|| fail 'fix-permissions chmodded a file outside the SSH directory'
|
||||
|
||||
# ── Nothing reached the agent ───────────────────────────────────────────────
|
||||
|
||||
grep -q '^ssh-add: ' "$log" \
|
||||
&& fail "the hermetic half invoked ssh-add: $(grep '^ssh-add: ' "$log")"
|
||||
|
||||
printf 'ssh keys contract: PASS (passphrase over a pty only, names confined, nothing written to ~/.ssh)\n'
|
||||
|
||||
Reference in New Issue
Block a user