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:
@@ -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