#!/usr/bin/env bash # 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. 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 -- 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. # The button exists now, and says so. # 5. Copying a public key never splices a path into shell source, and says # that it happened. # # 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)" 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 exit 1 } for path in "$helper" "$service" "$page"; do [[ -r "$path" ]] || fail "missing $path" done [[ -x "$helper" ]] || fail 'panama-ssh-keys is not executable' field() { python3 -c "import json,sys; print(json.load(sys.stdin)$1)"; } state="$("$helper" snapshot)" || fail 'snapshot failed' # ── 1. Private key material never surfaces ────────────────────────────────── # # Checked against the payload rather than the source: whatever the code intends, # what actually reaches the page must not contain key material. printf '%s' "$state" | python3 -c " import json, sys raw = sys.stdin.read() for marker in ('BEGIN OPENSSH PRIVATE KEY', 'BEGIN RSA PRIVATE KEY', 'BEGIN EC PRIVATE KEY'): if marker in raw: raise SystemExit(f'the snapshot contains {marker}') state = json.loads(raw) for key in state['keys']: for name, value in key.items(): if isinstance(value, str) and len(value) > 200: raise SystemExit(f'{name} is long enough to be key material') " || fail 'private key material reached the snapshot' # The helper must never read a private key for its bytes. The one place it opens # one is ssh-keygen -y, which can only ever emit the public half. grep -q 'read_text' "$helper" && ! grep -q 'KNOWN_HOSTS.read_text' "$helper" \ && fail 'something reads a file directly that is not known_hosts' # ── 2. The passphrase, statically ─────────────────────────────────────────── grep -qE '\-N["'"'"' ]' "$helper" \ && fail 'ssh-keygen -N appears, which would put a passphrase in argv' 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 ─────────────────────────────────────────────────── grep -q 'def resolve_key' "$helper" || fail 'key paths are not resolved before use' grep -q 'resolved.parent != SSH_DIR' "$helper" \ || fail 'a key path is not compared against the SSH directory, so it could escape' reason="$(printf '%s' "$("$helper" agent-add /etc/hostname)" | field "['error']")" [[ "$reason" == *"not in the SSH directory"* ]] \ || fail "a path outside ~/.ssh was not refused with a reason (got: $reason)" reason="$(printf '%s' "$("$helper" agent-add /home/nonexistent/.ssh/nope)" | field "['error']")" [[ -n "$reason" ]] || fail 'a missing key was accepted' reason="$(printf '%s' "$("$helper" forget-host 'not a host name')" | field "['error']")" [[ "$reason" == *"not a host name"* ]] \ || fail "an invalid host was not refused with a reason (got: $reason)" # ── 4. A control that cannot deliver is not offered ───────────────────────── 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. 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 reason="$(printf '%s' "$("$helper" agent-remove "$real_key")" | field "['error']")" [[ "$reason" == *"does not stick"* ]] \ || fail "removal against a keyring agent was not refused with its reason (got: $reason)" fi grep -q 'does not stick' "$page" \ || fail 'the page does not say that removing a key from this agent has no effect' fi # 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' # 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' # Known hosts are folded at the house cap. A machine in daily use accumulates # dozens of these, and an unbounded Repeater makes the last card taller than the # rest of the page put together -- the keys and the agent, which are what # somebody came here for, end up above the fold of a list nobody reads. grep -q 'shownHosts' "$page" \ || fail 'the known-hosts list is not capped, so it grows without limit' grep -qE 'hostCap: 6' "$page" \ || fail 'the known-hosts cap is not the house cap of 6' grep -q 'hiddenHostCount' "$page" \ || fail 'nothing counts the folded hosts, so the fold row cannot say how many' grep -qE 'model: root\.shownHosts' "$page" \ || fail 'the hosts Repeater still walks the whole list, so the cap is decorative' # ══ 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" <>"\$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" <>"$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'