a73x

d225b05c

test: a real key crosses the wire; a keyless session refuses fast

a73x   2026-08-21 08:33

Commit message
test: a real key crosses the wire; a keyless session refuses fast

Two e2e legs, no mocks. A real ssh-agent holds a real ed25519 key; a real
`ssh-add -l` typed into a real session shell prints its fingerprint, which
is echo-proof by the M12 rule — the typed line cannot spell one. Graded
against dropping -A: the expect times out with 'communication with agent
failed' on the terminal.

The twin is a session on the same daemon that nobody offered an agent to.
SSH_AUTH_SOCK is set there all the same, and the daemon accepts the dial
and closes it, so ssh-add comes back nonzero in 832-881ms measured. The
assertion is agtrc=0 absent and agtrc=[1-9] present — both echo-proof,
since the typed line spells agtrc=$? — plus a ceiling that owns the band
the fixture's own deadlines cannot see: a refusal that retried for ten
seconds would still satisfy every expect in the script.

SSH_AUTH_SOCK joins MUX_KEY_FILE in the hermetic unset at the top. A
daemon that inherited the developer's real agent would hand it to any
session whose own socket failed to bind, and the refusal leg would be
asserting against the developer's keyring.

test/e2e.sh
Old New
@@ -52,6 +52,19 @@ command -v less > /dev/null 2>&1 || {
52 echo " a real pager); install it, or lose the only check that a" 52 echo " a real pager); install it, or lose the only check that a"
53 echo " wheel notch moves anything on a TUI screen" 53 echo " wheel notch moves anything on a TUI screen"
54 exit 1; } 54 exit 1; }
55 # And the fourth, for the reason the other three are required rather than
56 # skipped: the agent-forwarding legs are the only place a REAL key crosses
57 # the wire, and a `command -v` skip would let a box print a green line
58 # having tested none of it. openssh rather than a stand-in — the daemon and
59 # the client both carry the agent protocol blind, so the only thing that can
60 # say the pump is right is the program the protocol belongs to.
61 for _agtool in ssh-agent ssh-add ssh-keygen; do
62 command -v "$_agtool" > /dev/null 2>&1 || {
63 echo "e2e FAIL: this suite needs $_agtool (the agent-forwarding legs run a"
64 echo " real ssh-agent holding a real key); install openssh, or lose"
65 echo " the only check that a signature request crosses the wire"
66 exit 1; }
67 done
55 68
56 SOCK="${TMPDIR:-/tmp}/muxd-e2e-$$.sock" 69 SOCK="${TMPDIR:-/tmp}/muxd-e2e-$$.sock"
57 OUT="${TMPDIR:-/tmp}/mux-e2e-out-$$" 70 OUT="${TMPDIR:-/tmp}/mux-e2e-out-$$"
@@ -84,6 +97,13 @@ export SHELL
84 # change which key the handoff announces — and the scenarios would still 97 # change which key the handoff announces — and the scenarios would still
85 # pass, against the wrong key, until one of them did not. 98 # pass, against the wrong key, until one of them did not.
86 unset MUX_KEY_FILE 99 unset MUX_KEY_FILE
100 # ...and SSH_AUTH_SOCK, the same family again and the sharpest case in it.
101 # A daemon that inherited the developer's real agent would hand it to every
102 # session whose own agent socket failed to bind — and the refusal leg, whose
103 # whole subject is a shell finding nobody to sign for it, would then be
104 # asserting against the developer's keyring. The legs that want an agent
105 # export one per command, at a path this file made.
106 unset SSH_AUTH_SOCK
87 # Second daemon, used only by the M7 abort scenario; declared here so the 107 # Second daemon, used only by the M7 abort scenario; declared here so the
88 # trap below can reference them under `set -u` before they are ever started. 108 # trap below can reference them under `set -u` before they are ever started.
89 SOCK2="${TMPDIR:-/tmp}/muxd-e2e-abort-$$.sock" 109 SOCK2="${TMPDIR:-/tmp}/muxd-e2e-abort-$$.sock"
@@ -403,6 +423,21 @@ SOCK47="${TMPDIR:-/tmp}/muxd-e2e-exitsem-$$.sock"
403 XESTATE="${TMPDIR:-/tmp}/mux-e2e-exitsem-state-$$" 423 XESTATE="${TMPDIR:-/tmp}/mux-e2e-exitsem-state-$$"
404 D47PID="" 424 D47PID=""
405 425
426 # Agent forwarding: one daemon serving both legs, because the negative one
427 # is ABOUT the positive one's daemon — a session on it that nobody offered
428 # an agent to. A second daemon would prove only that a daemon with no
429 # clients refuses, which is not the case anyone is in.
430 #
431 # The agent lives beside the sockets rather than under $OUT: a unix path is
432 # capped at 107 bytes by sun_path, and this one is dialled from inside a
433 # session shell, so the room it has is the room every other socket in this
434 # file was given.
435 SOCK48="${TMPDIR:-/tmp}/muxd-e2e-agentfwd-$$.sock"
436 AGENT48="${TMPDIR:-/tmp}/mux-e2e-agent-$$.sock"
437 AGENT48PID=""
438 AGENT48KEY="${TMPDIR:-/tmp}/mux-e2e-agentkey-$$"
439 D42PID=""
440
406 # One counter out of a MUX_PREDICT_STATS line. The client prints exactly one 441 # One counter out of a MUX_PREDICT_STATS line. The client prints exactly one
407 # such line on exit; every field is a key=value pair, so a rename or reorder 442 # such line on exit; every field is a key=value pair, so a rename or reorder
408 # in the client shows up here as an empty read rather than a wrong number. 443 # in the client shows up here as an empty read rather than a wrong number.
@@ -1027,6 +1062,12 @@ cleanup() {
1027 [ -n "$D39PID" ] && kill "$D39PID" 2>/dev/null || true 1062 [ -n "$D39PID" ] && kill "$D39PID" 2>/dev/null || true
1028 [ -n "$D40PID" ] && kill "$D40PID" 2>/dev/null || true 1063 [ -n "$D40PID" ] && kill "$D40PID" 2>/dev/null || true
1029 [ -n "$D41PID" ] && kill "$D41PID" 2>/dev/null || true 1064 [ -n "$D41PID" ] && kill "$D41PID" 2>/dev/null || true
1065 [ -n "$D42PID" ] && kill "$D42PID" 2>/dev/null || true
1066 # The ssh-agent the forwarding legs start. Not a mux process and so not
1067 # the leak sweep's business, but it is a daemon this file forked: left
1068 # alive it outlives the suite holding a private key, which is the one
1069 # kind of leak worth chasing even on a green run.
1070 [ -n "$AGENT48PID" ] && kill "$AGENT48PID" 2>/dev/null || true
1030 # The stops still precede the socket rm below, like SOCK14-17 above: 1071 # The stops still precede the socket rm below, like SOCK14-17 above:
1031 # unlinking a socket first would leave a live daemon nothing could reach 1072 # unlinking a socket first would leave a live daemon nothing could reach
1032 # by path. 1073 # by path.
@@ -1056,6 +1097,7 @@ cleanup() {
1056 [ -S "$SOCK45" ] && "$MUXD" stop --sock "$SOCK45" 2>/dev/null || true 1097 [ -S "$SOCK45" ] && "$MUXD" stop --sock "$SOCK45" 2>/dev/null || true
1057 [ -S "$SOCK46" ] && "$MUXD" stop --sock "$SOCK46" 2>/dev/null || true 1098 [ -S "$SOCK46" ] && "$MUXD" stop --sock "$SOCK46" 2>/dev/null || true
1058 [ -S "$SOCK47" ] && "$MUXD" stop --sock "$SOCK47" 2>/dev/null || true 1099 [ -S "$SOCK47" ] && "$MUXD" stop --sock "$SOCK47" 2>/dev/null || true
1100 [ -S "$SOCK48" ] && "$MUXD" stop --sock "$SOCK48" 2>/dev/null || true
1059 1101
1060 # ---- the leak sweep (hygiene kit, 6a) ---- 1102 # ---- the leak sweep (hygiene kit, 6a) ----
1061 # Here rather than at the bottom of the file, which `set -e` reaches only 1103 # Here rather than at the bottom of the file, which `set -e` reaches only
@@ -1070,7 +1112,7 @@ cleanup() {
1070 "$D20PID" "$D21PID" "$D22PID" "$D23PID" "$D24PID" "$D25PID" \ 1112 "$D20PID" "$D21PID" "$D22PID" "$D23PID" "$D24PID" "$D25PID" \
1071 "$D26PID" "$D27PID" "$D28PID" "$D29PID" "$D30PID" "$D31PID" \ 1113 "$D26PID" "$D27PID" "$D28PID" "$D29PID" "$D30PID" "$D31PID" \
1072 "$D32PID" "$D33PID" "$D34PID" "$D35PID" "$D36PID" "$D37PID" \ 1114 "$D32PID" "$D33PID" "$D34PID" "$D35PID" "$D36PID" "$D37PID" \
1073 "$D38PID" "$D39PID" "$D40PID" "$D41PID" 1115 "$D38PID" "$D39PID" "$D40PID" "$D41PID" "$D42PID"
1074 _leak=0 1116 _leak=0
1075 leak_sweep "$_rc" || _leak=1 1117 leak_sweep "$_rc" || _leak=1
1076 1118
@@ -1229,6 +1271,13 @@ cleanup() {
1229 "$OUT.xecap" "$OUT.xecap.err" "$OUT.xepc" "$OUT.xe2" "$OUT.xe2.err" \ 1271 "$OUT.xecap" "$OUT.xecap.err" "$OUT.xepc" "$OUT.xe2" "$OUT.xe2.err" \
1230 "$OUT.xestop" 1272 "$OUT.xestop"
1231 rm -rf "$CVSTATE" "$RGSTATE" "$RGOTHER" "$XESTATE" 1273 rm -rf "$CVSTATE" "$RGSTATE" "$RGOTHER" "$XESTATE"
1274 # The agent legs. The private key and its public half go too: this file
1275 # made them, and a keypair left in $TMPDIR is litter with a lifetime.
1276 # The agent's own socket is unlinked by the ssh-agent killed above only
1277 # when it exits cleanly, so it is named here as well.
1278 rm -f "$SOCK48" "$AGENT48" "$AGENT48KEY" "$AGENT48KEY.pub" \
1279 "$OUT.agt.d" "$OUT.agt.env" "$OUT.agt" "$OUT.agt.err" "$OUT.agt.log" \
1280 "$OUT.agtn" "$OUT.agtn.err" "$OUT.agtn.log" "$OUT.agtstop"
1232 # The convergence files a FAILING assert_converged leaves behind 1281 # The convergence files a FAILING assert_converged leaves behind
1233 # (.render/.dump/.rvt/.dvt/.diff for that capture) are deliberately not 1282 # (.render/.dump/.rvt/.dvt/.diff for that capture) are deliberately not
1234 # chased here: on a failing run they are the evidence. 1283 # chased here: on a failing run they are the evidence.
@@ -6879,6 +6928,154 @@ D47PID=""
6879 ok "a session ending under the zoom exits mux alone, and drops to the wall beside company" 6928 ok "a session ending under the zoom exits mux alone, and drops to the wall beside company"
6880 6929
6881 6930
6931 # --- agent forwarding: a real key in the client's agent answers in the session
6932 #
6933 # Everything under this heading is a real program doing its real job: a real
6934 # ssh-agent holding a real ed25519 key, a real `ssh-add -l` typed into a real
6935 # session shell on a real pty. Nothing here stands in for anything, and that
6936 # is the point — the daemon and the client both carry the agent protocol
6937 # BLIND (server.zig's serviceAgentChan copies bytes it never parses), so a
6938 # stand-in agent would be this suite grading the pump against a second guess
6939 # at a format neither end reads. openssh either recognises what came back or
6940 # it does not, and that answer is the only one worth having.
6941 #
6942 # The needle is a SHA256 fingerprint, and it is echo-proof by the M12 rule
6943 # (tp2, argued at the OSC 52 leg above): the shell echoes what is typed, so
6944 # a needle a keystroke could spell would pass on a client that forwarded
6945 # nothing at all. The typed line is `ssh-add -l`, which cannot spell a
6946 # fingerprint. What puts those 50 bytes on the terminal is the agent on this
6947 # machine answering a request that left the session, crossed the daemon,
6948 # reached the client, and came back.
6949 "$MUXD" run --sock "$SOCK48" --shell /bin/sh > "$OUT.agt.d" 2>&1 &
6950 D42PID=$!
6951 wait_sock "$SOCK48" "$OUT.agt.d" "agent daemon never bound"
6952
6953 # The agent is started AFTER the daemon and its path is never exported, so
6954 # the daemon cannot have inherited it: the only SSH_AUTH_SOCK a session on
6955 # this daemon can hold is one the daemon bound itself. That ordering is what
6956 # makes the fingerprint below evidence of a forward rather than of a leak.
6957 ssh-agent -a "$AGENT48" > "$OUT.agt.env" 2>&1
6958 AGENT48PID=$(sed -n 's/.*SSH_AGENT_PID=\([0-9]*\).*/\1/p' "$OUT.agt.env")
6959 [ -n "$AGENT48PID" ] || {
6960 echo "e2e FAIL: agent: ssh-agent printed no pid for the trap to hold it by:"
6961 cat "$OUT.agt.env"; exit 1; }
6962 # -C, so `ssh-add -l` prints a short comment instead of this key's whole
6963 # path: the answer has to fit one row of the terminal below, and a line that
6964 # wrapped could split the fingerprint across two rows the capture never
6965 # joins back up.
6966 ssh-keygen -q -t ed25519 -N '' -C mux-e2e-agent -f "$AGENT48KEY"
6967 SSH_AUTH_SOCK="$AGENT48" ssh-add "$AGENT48KEY" > /dev/null 2>&1
6968 FP48=$(ssh-keygen -lf "$AGENT48KEY" | awk '{print $2}')
6969 # An empty or malformed needle would reach ptyclient as a usage error rather
6970 # than as a failed expectation, and the suite would report the wrong thing.
6971 case "$FP48" in
6972 SHA256:?*) ;;
6973 *) echo "e2e FAIL: agent: ssh-keygen -lf gave no fingerprint to look for: [$FP48]"
6974 exit 1;;
6975 esac
6976
6977 # Unquoted heredoc, like the OSC 52 leg's and for the same reason: $FP48 has
6978 # to interpolate. Nothing else in the script needs expanding — `\n` and
6979 # `\x1b` are not escapes the shell touches.
6980 set +e
6981 SSH_AUTH_SOCK="$AGENT48" timeout 40 "$PTYCLIENT" --cols 100 --rows 30 \
6982 --out "$OUT.agt" --err "$OUT.agt.err" \
6983 -- "$MUX" -A --sock "$SOCK48" > "$OUT.agt.log" 2>&1 <<EOF
6984 expect \x1b[?1049h 15000
6985 settle 400 15000
6986 send ssh-add -l\n
6987 expect $FP48 15000
6988 settle 400 15000
6989 send exit\n
6990 waitexit 10000
6991 EOF
6992 RC=$?
6993 set -e
6994 # Only the fixture's log, not the capture beside it: ptyclient prints the
6995 # verb that failed and the last 200 bytes it saw, which is the answer. The
6996 # capture is a screenful of escapes that would bury it.
6997 [ "$RC" -eq 0 ] || {
6998 echo "e2e FAIL: agent: the client's key never answered ssh-add -l in the"
6999 echo " session (wanted $FP48):"
7000 cat "$OUT.agt.log"; exit 1; }
7001 ok "agent forwarding: ssh-add -l in the session lists the client's key"
7002
7003 # --- ...and a session nobody offered an agent to refuses, fast -------------
7004 #
7005 # The same daemon, deliberately: the interesting no-offerer case is a
7006 # session sitting beside one that HAS an answerer, not a daemon with no
7007 # clients at all. The client above has exited, so its offer is gone; this
7008 # one attaches without -A and with no agent of its own to offer.
7009 #
7010 # SSH_AUTH_SOCK is still set inside the session — every session is born with
7011 # one, whether or not anyone has offered — and it points at the daemon,
7012 # which accepts the dial and closes it. That is the designed answer, not a
7013 # gap: ssh reads a closed agent socket as "agent refused operation" and
7014 # falls straight through to its other methods, where a connection accepted
7015 # and left silent would make it wait out a timeout on every dial.
7016 AR0=$(date +%s%N)
7017 set +e
7018 timeout 40 "$PTYCLIENT" --cols 100 --rows 30 --out "$OUT.agtn" --err "$OUT.agtn.err" \
7019 -- "$MUX" --sock "$SOCK48" --session noagent > "$OUT.agtn.log" 2>&1 <<'EOF'
7020 expect \x1b[?1049h 15000
7021 settle 400 15000
7022 send ssh-add -l; echo agtrc=$?\n
7023 expect agtrc= 15000
7024 settle 400 15000
7025 send exit\n
7026 waitexit 10000
7027 EOF
7028 RC=$?
7029 set -e
7030 AR1=$(date +%s%N)
7031 AMS=$(( (AR1 - AR0) / 1000000 ))
7032 [ "$RC" -eq 0 ] || {
7033 echo "e2e FAIL: agent-refusal: the leg exited $RC:"
7034 cat "$OUT.agtn.log"; exit 1; }
7035 # Quoted heredoc, unlike the leg above: `$?` has to reach the SESSION's
7036 # shell as two characters, not be spent by this one.
7037 #
7038 # `expect agtrc=` is a wait and not an assertion — the shell echoes the line
7039 # it was typed, so that needle matches before ssh-add has run. The settle
7040 # after it is what waits for the answer; these two greps are the assertion,
7041 # and both are echo-proof, because the typed line spells `agtrc=$?` and
7042 # neither `agtrc=0` nor a digit can come from echoing it.
7043 grep -q "agtrc=0" "$OUT.agtn" && {
7044 echo "e2e FAIL: agent-refusal: ssh-add succeeded in a session nobody offered"
7045 echo " an agent to — something answered for a key that is not there:"
7046 cat -v "$OUT.agtn"; exit 1; }
7047 grep -qE "agtrc=[1-9]" "$OUT.agtn" || {
7048 echo "e2e FAIL: agent-refusal: ssh-add never reported a status at all — the"
7049 echo " session did not run it, so its failure proves nothing:"
7050 cat -v "$OUT.agtn"; exit 1; }
7051 # Ceiling only, and the floor's absence is the claim: a refusal is a close on
7052 # an already-accepted connection and should cost about nothing, so there is
7053 # no lower bound worth defending. What the ceiling owns is the band this
7054 # leg's own deadlines cannot see. A refusal that HUNG is caught by the
7055 # waitexit above and reported as an exit code; a refusal that came back
7056 # after ten seconds of retrying is not — `expect` matched the echo long
7057 # before, and the settle would sit through the whole retry and still pass.
7058 # This bound is what makes that band a failure.
7059 #
7060 # 10000 for the reason the key-mismatch leg above picked the same number: it
7061 # separates "refused" from "sat in a loop" and is not asked to do anything
7062 # finer. The whole leg — attach, two 400ms settles, a shell's startup and
7063 # the refusal itself — measured 832-881ms across the grading runs, so a
7064 # tighter ceiling would catch nothing this one misses while making a check
7065 # that is nobody's bottleneck the suite's most load-sensitive one.
7066 [ "$AMS" -lt 10000 ] || {
7067 echo "e2e FAIL: agent-refusal: the leg took ${AMS}ms — a dial with nobody to"
7068 echo " answer it must be refused, not retried"
7069 exit 1; }
7070 assert_stopped "$SOCK48" "$D42PID" "agent forwarding" "$OUT.agtstop"
7071 D42PID=""
7072 # The agent has no more work; ended here rather than in the trap so a green
7073 # run does not leave a key-holding process alive for the length of the
7074 # teardown. The trap still names it, for every run that does not reach here.
7075 kill "$AGENT48PID" 2>/dev/null || true
7076 AGENT48PID=""
7077 ok "agent forwarding: no offerer means a fast refusal, not a hang"
7078
6882 7079
6883 # The long-lived daemon has served every scenario that wanted it; stop it 7080 # The long-lived daemon has served every scenario that wanted it; stop it
6884 # NOW so its allocator verdict is written while the suite is still running 7081 # NOW so its allocator verdict is written while the suite is still running
@@ -6973,9 +7170,17 @@ DPID=""
6973 # three of them: what they assert on is a ptyclient capture, a cumulative 7170 # three of them: what they assert on is a ptyclient capture, a cumulative
6974 # attach COUNTER, the wall file and an exit CODE — a screen, a number, a 7171 # attach COUNTER, the wall file and an exit CODE — a screen, a number, a
6975 # file and a status, and the last of them names a session that no longer 7172 # file and a status, and the last of them names a session that no longer
6976 # exists to dump. 7173 # exists to dump. The 56th is agent forwarding's positive leg, and no
6977 [ "$OK_COUNT" = "55" ] || { 7174 # convergence point because its subject is one string on a terminal — a
6978 echo "e2e FAIL: $OK_COUNT scenario checkpoints ran, the pin says 55 —" 7175 # fingerprint openssh printed after a round trip through the daemon — and
7176 # converging the grid that string landed in would only say the client
7177 # painted what the daemon holds, which is a fact every leg above already
7178 # establishes and this one does not doubt. The 57th is its negative twin,
7179 # and no convergence point for the same reason plus the exit-code reason:
7180 # what it asserts on is a shell's `$?` and a wall-clock bound, and a grid
7181 # comparison speaks to neither.
7182 [ "$OK_COUNT" = "57" ] || {
7183 echo "e2e FAIL: $OK_COUNT scenario checkpoints ran, the pin says 57 —"
6979 echo " a scenario was added (update the pin) or silently lost" 7184 echo " a scenario was added (update the pin) or silently lost"
6980 exit 1 7185 exit 1
6981 } 7186 }
@@ -6983,4 +7188,4 @@ DPID=""
6983 echo "e2e FAIL: $CONV_COUNT convergence points ran, the pin says 35" 7188 echo "e2e FAIL: $CONV_COUNT convergence points ran, the pin says 35"
6984 exit 1 7189 exit 1
6985 } 7190 }
6986 echo "e2e OK (55 scenarios, 35 convergence points)" 7191 echo "e2e OK (57 scenarios, 35 convergence points)"