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:
Gabriel Brown
2026-08-24 19:26:56 -04:00
parent 4ec8bd94d9
commit 6f0ce639d9
25 changed files with 3622 additions and 408 deletions
+367 -18
View File
@@ -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'