Add an SSH Keys page, and refuse the one control that would lie
The page shows which keys exist, what the agent is holding, and the hosts this machine has met, with a two-press forget for a host whose key has changed. Nothing here reads private key material. Fingerprints and comments come from the .pub file, and "does this key need a passphrase" is answered by asking ssh-keygen to derive the public half with an empty one -- it succeeds for an unencrypted key and fails for an encrypted one, and either way the only thing it can emit is public. The contract checks that against the payload that actually reaches the page rather than against the source, because what the code intends and what it ships are different claims. Unloading a key from the agent is refused, with its reason. On this desktop `ssh-add -d` prints "Identity removed" and the key is still offered a second later: gnome-keyring's agent lists every key it finds in ~/.ssh, so a removed one comes straight back off disk. That was measured rather than assumed -- a plain ssh-agent removes durably, this one does not -- and a button reporting success while changing nothing is worse than no button. The page says so and names the thing that does work: move the file out of ~/.ssh. SSH_AUTH_SOCK is not set in a normal shell here, so a naive check reports "no agent" while one is plainly running. The helper falls back to the keyring socket, and an agent started by hand still wins. That gap is the same one that made reaching these servers awkward in the first place. Generating a key is deliberately absent. A passphrase cannot reach ssh-keygen without going somewhere it should not -- -N puts it in argv, which every process on the machine can read -- and driving the prompt over a pty did not work. Offering to generate an unencrypted key instead would be a downgrade dressed as a feature, so the page does not offer to generate at all. Claude-Session: https://claude.ai/code/session_01BRvzt4H8XXLPVH5MyYdk9L
This commit is contained in:
Executable
+111
@@ -0,0 +1,111 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
# SSH keys, and the two 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.
|
||||
# 3. Key paths are confined to ~/.ssh, resolved and compared, so a name cannot
|
||||
# walk out of the directory.
|
||||
# 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.
|
||||
#
|
||||
# Read-only against the real configuration. Nothing here adds, removes or
|
||||
# rewrites a key, an agent entry, or a known host.
|
||||
|
||||
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"
|
||||
|
||||
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. No passphrase anywhere ───────────────────────────────────────────────
|
||||
|
||||
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'
|
||||
|
||||
# ── 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'
|
||||
|
||||
kind="$(printf '%s' "$state" | field "['agent'].get('kind','')")"
|
||||
if [[ "$kind" == "gnome-keyring" ]]; then
|
||||
reason="$(printf '%s' "$("$helper" agent-remove "$HOME/.ssh/id_ed25519")" | field "['error']")"
|
||||
[[ "$reason" == *"does not stick"* ]] \
|
||||
|| fail "removal against a keyring agent was not refused with its reason (got: $reason)"
|
||||
grep -q 'does not stick' "$page" \
|
||||
|| 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 ──────────────────────
|
||||
|
||||
grep -q 'exec wl-copy < "\$1"' "$service" \
|
||||
|| fail 'the public key copy does not pass its path as an argument'
|
||||
|
||||
printf 'ssh keys contract: ok\n'
|
||||
Reference in New Issue
Block a user