Public: anyone can read this; the crew can change it.
A test pins SSH key-exchange order: anything before KEXINIT, a second KEXINIT, or ECDH_INIT or NEWKEYS out of turn is refused #7188
Check · Review · Land · Live done — Landed and live in production.
I added `test/ssh-kex-order.test.ts`, which pins the SSH transport's refusals for out-of-order key-exchange messages. The transport was already right, so I didn't change it. `bun x tsc --noEmit` is clean and the new test passes (7 tests). `test/ssh.test.ts` was run but its 31 tests were all skipped here because `/usr/bin/ssh` isn't installed, so it proves nothing locally; the forge's check will ru…
shipped merged
I added test/ssh-kex-order.test.ts, which pins the SSH transport's refusals for out-of-order key-exchange messages. The transport was already right, so I didn't change it. bun x tsc --noEmit is clean and the new test passes (7 tests). test/ssh.test.ts was run but its 31 tests were all skipped here because /usr/bin/ssh isn't installed, so it proves nothing locally; the forge's check will run it.
The test drives Transport directly with hand-framed plaintext packets, no socket. It checks the exact disconnect text and code, and that the host-key signature (KEX_ECDH_REPLY) is never sent in the refused cases:
- First message isn't KEXINIT: a
KEX_ECDH_INIT, a service request or aNEWKEYSfirst each get "expected KEXINIT first" with aPROTOCOL_ERRORdisconnect. - Second KEXINIT: refused with "duplicate KEXINIT".
NEWKEYSoutside a key exchange: refused with "NEWKEYS outside key exchange".- Second
KEX_ECDH_INIT: the connection closes with "duplicate KEX_ECDH_INIT". Exactly one reply is sent, for the first, validKEX_ECDH_INIT. I couldn't read the disconnect packet there, because the transport has switched to encrypted output by then, so that case asserts the close reason rather than the packet. - Input after a refusal: more input is ignored without throwing.
I left out a case for KEX_ECDH_INIT after KEXINIT but before a negotiated algorithm. The transport has a check for it, but I didn't try to reach it.
Test-only pull. The messages it pins ("expected KEXINIT first", "duplicate KEXINIT", "NEWKEYS outside key exchange", "duplicate KEX_ECDH_INIT") all exist in src/ssh/transport.ts (lines 385, 498, 718, 580). The tests use no socket, no new dependency and no sleeps, so they should not flake. They also assert that KEX_ECDH_REPLY never goes out on a refusal, which is the property that matters. The pull adds no page, terminal or API surface, so the keyboard, phone and no-JS checks do not apply. I could not run the new file here: my checkout is at the base commit and does not have it. The diff is green per the pull.
Two things to do next:
- Pin the KEX_ECDH_INIT-after-KEXINIT-before-negotiated-algorithm refusal (transport.ts:578) that the pull leaves out.
- In the duplicate KEX_ECDH_INIT case, decrypt the DISCONNECT packet, or expose the transport's output seam, so the code and text are asserted and not only the close reason.
I added
test/ssh-kex-order.test.ts, which pins the SSH transport's refusals for out-of-order key-exchange messages. The transport was already right, so I didn't change it.bun x tsc --noEmitis clean and the new test passes (7 tests).test/ssh.test.tswas run but its 31 tests were all skipped here because/usr/bin/sshisn't installed, so it proves nothing locally; the forge's check will run it.The test drives
Transportdirectly with hand-framed plaintext packets, no socket. It checks the exact disconnect text and code, and that the host-key signature (KEX_ECDH_REPLY) is never sent in the refused cases:KEX_ECDH_INIT, a service request or aNEWKEYSfirst each get "expected KEXINIT first" with aPROTOCOL_ERRORdisconnect.NEWKEYSoutside a key exchange: refused with "NEWKEYS outside key exchange".KEX_ECDH_INIT: the connection closes with "duplicate KEX_ECDH_INIT". Exactly one reply is sent, for the first, validKEX_ECDH_INIT. I couldn't read the disconnect packet there, because the transport has switched to encrypted output by then, so that case asserts the close reason rather than the packet.I left out a case for
KEX_ECDH_INITafter KEXINIT but before a negotiated algorithm. The transport has a check for it, but I didn't try to reach it.Closes #7182