Skip to content

spin: connect with this machine's OpenSSH, which negotiates ML-KEM - #8

Merged
aledbf merged 1 commit into
mainfrom
system-ssh
Nov 13, 2026
Merged

aledbf merged 1 commit into
mainfrom
system-ssh

Conversation

@aledbf

@aledbf aledbf commented Nov 13, 2026

Copy link
Copy Markdown
Member

The workspace image's sshd will accept one key exchange, mlkem768x25519-sha256 (spin-stack/spin-machine#245): post-quantum, and a login costs what curve25519's does (40 vs 39 ms on the bench fleet, where sntrup761x25519 took 104). ssh2 has no ML-KEM, so Spinbox Remote could not connect to such a workspace. This moves the connection to the machine's own OpenSSH. spin's side is https://github.com/spin-stack/spin/pull/1206.

What changes

  • The connection is ssh (src/spin/connection.ts): one ControlMaster (ssh -M -N), carried by spin tunnel <session> as its ProxyCommand. spin ssh-session hands out a session that spin tunnel carries once, so a second login would need a second session, certificate and claim check. Everything after the login goes through that one connection: the server's start runs as an exec through it, and each connection VS Code makes to the server is an ssh -W 127.0.0.1:<port> through it, behind the same loopback listener as before. Readiness is ssh -O check. Closing it kills the master, which ends the tunnel and every client.
  • Host key verification is as strict as hostVerifier was. The claim's key, parsed and re-encoded by parseHostKey, is the one line of a known_hosts that belongs to this connection alone (mkdtemp, 0600), with HostKeyAlias, StrictHostKeyChecking=yes, GlobalKnownHostsFile=/dev/null and UpdateHostKeys=no. Login uses the certificate alone: -i <identity>, CertificateFile, IdentitiesOnly, PreferredAuthentications=publickey, BatchMode. -F /dev/null keeps the person's Host * (a ProxyJump, a ControlMaster, a forwarded agent) from changing the connection. Every run passes -o KexAlgorithms=mlkem768x25519-sha256.
  • Version gate (src/spin/openssh.ts): ssh -V is asked before the CLI is run. An OpenSSH older than 9.9 is refused in VS Code as a modal SpinRefusal (ssh_outdated, not temporary). The message names the version found, the version needed and the reason, in the same words as the spin CLI. An unreadable banner is let through with a line in the log. A missing ssh is refused as ssh_not_found. A successful check is cached per PATH; a refusal is not cached, so updating OpenSSH and opening again works without reloading.
  • LogLevel is INFO: ssh reports no matching key exchange method found at INFO, and ERROR would hide exactly that refusal.
  • Windows is refused up front (platform_unsupported). There is no spin CLI for Windows (spin builds linux and darwin; vscode-spinbox carries linux and darwin only), CI is Linux-only, and OpenSSH for Windows has no ControlMaster. A per-connection design without a master (ssh -N -L) would need a fresh spin ssh-session for each login, because a session is carried once.
  • ssh2, @types/ssh2, @types/ssh2-streams, patches/ssh2@1.17.0.patch, its patchedDependencies and allowBuilds, its dependabot ignore, the esbuild externals and test/ssh2.security.test.ts are gone. keys.ts keeps only parseHostKey.

Tests

OpenSSH runs on both ends, and the runner needs OpenSSH 9.9 or newer; CI's ubuntu-latest is 26.04 with 10.2.

  • test/sshd.ts: a real sshd set up as a workspace's (certificates only, KexAlgorithms mlkem768x25519-sha256).
  • test/spin/connection.sshd.test.ts: logs in with ed25519 and ecdsa certificates; refuses the bare key, another principal's certificate, a host key that is not the pinned one, and an sshd without ML-KEM (no matching key exchange method found). Also carries a loopback port through -W. I planted StrictHostKeyChecking=no and KexAlgorithms=+…, and the host-key and ML-KEM tests failed.
  • test/spin/openssh.test.ts: the version gate (9.6, 9.8, Windows 9.5 and 8.9 refused with the message; 9.9, Windows 9.9, 10.2 and 9.10 pass; Dropbear and an empty banner warn), the PATH lookup and re-check after a refusal, and the options: KexAlgorithms, ProxyCommand, known hosts, strict checking, certificate, -F /dev/null, % escaping.
  • test/spin/resolve.test.ts: activation through to the server's answer. The real ssh runs through a wrapper that records each run and logs in as the test's user (a test sshd admits only its own user). Every run must carry the KexAlgorithms and strict host key options. A new case checks that an OpenSSH 9.6 is refused, with the exact message shown, before the CLI is asked anything.

go tool task ci passes locally (gate, install, lint, typecheck, 91 tests, package). The e2e lane (task test:e2e, against spin's KVM stack) was not run. test/e2e/spin.e2e.test.ts was only updated for forwardOut(port).

🤖 Generated with Claude Code

A workspace's sshd will accept one key exchange, mlkem768x25519-sha256 (spin-stack/spin-machine#245),
and ssh2 has no ML-KEM, so the connection is the system's ssh:

- one ControlMaster, carried by `spin tunnel <session>` as its ProxyCommand: the session is
  carried once, so everything rides on that login - the server's start as an exec through it, and
  each connection VS Code makes to the server as `ssh -W` through it;
- held to the claim's host key as the one line of a known_hosts of its own, StrictHostKeyChecking
  yes, no global file; logged in with the certificate alone (CertificateFile, IdentitiesOnly,
  BatchMode); -F /dev/null, so nothing of the person's config changes what it is; and
  -o KexAlgorithms=mlkem768x25519-sha256;
- `ssh -V` is asked first, and an OpenSSH older than 9.9 refused in VS Code before the CLI is run,
  naming the version found, the one needed and why, as the spin CLI does. An unreadable banner is
  let through with a line in the log.

Windows is refused up front: there is no spin CLI for it, and OpenSSH for Windows has no
ControlMaster. LogLevel is INFO, not ERROR: ssh says "no matching key exchange method found" at
INFO, and that refusal is the one this change is about.

ssh2, its types, its patch and the test of its internals go. The tests run OpenSSH on both ends:
a real sshd set up as a workspace's (certificates only, ML-KEM alone), which the connection logs in
to, refuses another principal's certificate and another host key, and cannot negotiate with an
sshd that has no ML-KEM; the resolve test drives the real ssh through a wrapper that records each
run and logs in as the test's user.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@aledbf
aledbf marked this pull request as ready for review November 13, 2026 18:56
@aledbf
aledbf merged commit 8be9587 into main Nov 13, 2026
1 check passed
@aledbf
aledbf deleted the system-ssh branch November 13, 2026 18:57
aledbf added a commit that referenced this pull request Nov 14, 2026
2.0.0 connects with ssh2, whose key exchange the workspace's sshd no longer offers from
spin-machine v20261113.03 on (spin-machine#245: mlkem768x25519-sha256 alone), and spin main
already pins v20261114.02: the next spin release would open no spin+ workspace with 2.0.0.
2.1.0 carries #6 (spin ssh-session and spin tunnel) and #8 (the system OpenSSH, which
negotiates ML-KEM).

test:e2e passed against spin's e2e stack at f15122137 (spin-machine v20261114.02) with VS Code
1.146.0 (69047191fc42): 8 tests, open, reconnect, stopped, key not enrolled.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant