483 lines
23 KiB
Bash
Executable File
483 lines
23 KiB
Bash
Executable File
#!/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" <<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'
|