Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions boot/Taskfile.yml
Original file line number Diff line number Diff line change
Expand Up @@ -211,8 +211,9 @@ tasks:
desc: >-
When a workspace can first be logged into from the host over SSH, through the machine's
vsock: sshd accepting, the first login done, a second login's cost, and the console
prompt beside them. Needs KVM, /dev/vhost-vsock (task vsock), a built release, sudo
and ssh. REPS= boots.
prompt beside them, logged in with golang.org/x/crypto/ssh over ML-KEM, the one key
exchange the image's sshd accepts. Needs KVM, /dev/vhost-vsock (task vsock), a built
release and sudo. REPS= boots.
deps: [':tools']
cmds:
- SPIN_SSH_FIRST=1 go test ./boot/ -run '^TestFirstSSH$' -count=1 -v -timeout 30m
Expand Down
127 changes: 99 additions & 28 deletions boot/ssh_first_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,10 @@ package boot_test
import (
"bytes"
"cmp"
"crypto/ed25519"
"fmt"
"math/rand/v2"
"net"
"os"
"os/exec"
"path/filepath"
Expand All @@ -16,6 +18,7 @@ import (
"testing"
"time"

xssh "golang.org/x/crypto/ssh"
"golang.org/x/sys/unix"

"github.com/spin-stack/spin-machine/boot"
Expand All @@ -28,7 +31,9 @@ import (
// The machine gets a vsock device (--vsock-cid), and the image's systemd-ssh-generator binds
// sshd to AF_VSOCK port 22 when it finds one. From the moment before QEMU is exec'd the host
// tries to connect every few milliseconds; the first connection accepted is `listen`, and then
// one `ssh spin@ true` through boot/vsockproxy is `login`. The console's prompt is timed in the
// `true` run as spin over SSH is `login`. The client is golang.org/x/crypto/ssh offering only
// mlkem768x25519-sha256, the one key exchange the image's sshd accepts: the runners' OpenSSH
// (9.6 on 2026-11-13) has no ML-KEM and is refused. The console's prompt is timed in the
// same boot, with the getty an echo as boot:bench's baseline has it, so the two read side by
// side.
//
Expand All @@ -51,14 +56,22 @@ func TestFirstSSH(t *testing.T) {
}
reps := envInt(t, "REPS", 10)

dir := t.TempDir()
proxy := filepath.Join(dir, "vsockproxy")
build := exec.Command("go", "build", "-trimpath", "-o", proxy, "./vsockproxy")
if b, err := build.CombinedOutput(); err != nil {
t.Fatalf("building vsockproxy: %v\n%s", err, b)
pub, priv, err := ed25519.GenerateKey(nil)
if err != nil {
t.Fatal(err)
}
signer, err := xssh.NewSignerFromKey(priv)
if err != nil {
t.Fatal(err)
}
sshPub, err := xssh.NewPublicKey(pub)
if err != nil {
t.Fatal(err)
}
key := filepath.Join(t.TempDir(), "key")
if err := os.WriteFile(key+".pub", xssh.MarshalAuthorizedKey(sshPub), 0o644); err != nil {
t.Fatal(err)
}
key := filepath.Join(dir, "key")
mustRun(t, "ssh-keygen", "-q", "-t", "ed25519", "-N", "", "-f", key)

// Every row has the key, the echo getty, and sshd@.service ordered after the image's host
// key generation. The image carries that drop-in itself since 2026-11-12; it is written
Expand Down Expand Up @@ -91,16 +104,24 @@ install -m 600 -o "${ids%:*}" -g "${ids#*:}" ` + key + `.pub "$MNT/home/spin/.ss
test -e "$MNT/usr/lib/systemd/system/multi-user.target.wants/systemd-logind.service"
install -d "$MNT/var/lib/systemd/linger" && touch "$MNT/var/lib/systemd/linger/spin"
`
// Lab run 43463472762 (2026-11-13), 20 boots of each, p50 ms from sshd's accept to the
// first login: as built 177; the host keys generated from the boot's first dispatch rather
// than by the first connection's sshd@ 155; that and the login's files read into the page
// cache early 149, with sshd accepting 18 ms later. On a laptop, the vsock socket and sshd@
// without default dependencies accepted no sooner per login and dropped 7 of 22 first
// connections. Then lab run 43479854381, 20 of each, first login / a second: the runner's
// OpenSSH 9.6, which took sntrup761x25519, 489/104; x/crypto/ssh with mlkem768x25519
// 461/40, and with curve25519 461/39.
rows := []variant{
row("as this branch", asThisBranch),
}

type samples struct{ listen, login, again, prompt []time.Duration }
got := map[string]*samples{}
failed := map[string]int{}
failed, classical := map[string]int{}, map[string]int{}
for range reps {
for _, v := range rows {
r := firstSSH(t, out, v, proxy, key)
r := firstSSH(t, out, v, signer)
if got[v.label] == nil {
got[v.label] = &samples{}
}
Expand All @@ -109,6 +130,9 @@ install -d "$MNT/var/lib/systemd/linger" && touch "$MNT/var/lib/systemd/linger/s
t.Logf("%s: a boot with no login: listen %v, login %v; %s", v.label, r.listen, r.login, r.why)
continue
}
if r.classical {
classical[v.label]++
}
g := got[v.label]
g.listen, g.login, g.again, g.prompt = append(g.listen, r.listen), append(g.login, r.login), append(g.again, r.again), append(g.prompt, r.prompt)
}
Expand All @@ -122,26 +146,27 @@ install -d "$MNT/var/lib/systemd/linger" && touch "$MNT/var/lib/systemd/linger/s
}
var b strings.Builder
fmt.Fprintf(&b, "\nms from the moment before QEMU is exec'd, p50/p95 over %d boots of each, interleaved, %s vCPUs\n\n", reps, rows[0].cpus)
fmt.Fprintf(&b, " %-16s %15s %15s %15s %15s %9s\n", "", "SSHD ACCEPTS", "FIRST LOGIN", "A SECOND TOOK", "CONSOLE PROMPT", "NO LOGIN")
fmt.Fprintf(&b, " %-16s %15s %15s %15s %15s %9s %14s\n", "", "SSHD ACCEPTS", "FIRST LOGIN", "A SECOND TOOK", "CONSOLE PROMPT", "NO LOGIN", "CURVE25519 IN")
for _, v := range rows {
g := got[v.label]
if g == nil {
g = &samples{}
}
fmt.Fprintf(&b, " %-16s %7s/%-7s %7s/%-7s %7s/%-7s %7s/%-7s %9d\n", v.label,
fmt.Fprintf(&b, " %-16s %7s/%-7s %7s/%-7s %7s/%-7s %7s/%-7s %9d %14d\n", v.label,
ms(g.listen, .5), ms(g.listen, .95), ms(g.login, .5), ms(g.login, .95), ms(g.again, .5), ms(g.again, .95),
ms(g.prompt, .5), ms(g.prompt, .95), failed[v.label])
ms(g.prompt, .5), ms(g.prompt, .95), failed[v.label], classical[v.label])
}
t.Log(b.String())
}

type firstSSHRun struct {
listen, login, prompt time.Duration
again time.Duration // a second login, right after the first: what any login costs
classical bool // a login offering only curve25519 got in
why string
}

func firstSSH(t *testing.T, out string, v variant, proxy, key string) firstSSHRun {
func firstSSH(t *testing.T, out string, v variant, signer xssh.Signer) firstSSHRun {
t.Helper()
overlay := filepath.Join(t.TempDir(), "overlay.qcow2")
mustRun(t, filepath.Join(out, "bin", "qemu-img"), "create", "-f", "qcow2",
Expand Down Expand Up @@ -194,23 +219,21 @@ func firstSSH(t *testing.T, out string, v variant, proxy, key string) firstSSHRu
r.why = "sshd never accepted on vsock; QEMU: " + strings.TrimSpace(stderr.String())
return r
}
ssh := []string{"-F", "/dev/null", "-i", key, "-o", "BatchMode=yes",
"-o", "StrictHostKeyChecking=no", "-o", "UserKnownHostsFile=/dev/null", "-o", "LogLevel=error",
"-o", "ConnectTimeout=10", "-o", fmt.Sprintf("ProxyCommand=%s %d 22", proxy, cid), "spin@vsock", "true"}
for time.Now().Before(deadline) {
b, err := exec.Command("ssh", ssh...).CombinedOutput()
if err == nil {
r.login = time.Since(t0)
break
if _, err := sshLogin(cid, signer, xssh.KeyExchangeMLKEM768X25519); err != nil {
r.why = err.Error()
time.Sleep(10 * time.Millisecond)
continue
}
r.why = fmt.Sprintf("ssh: %v: %s", err, strings.TrimSpace(string(b)))
time.Sleep(10 * time.Millisecond)
}
if r.login != 0 {
t1 := time.Now()
if exec.Command("ssh", ssh...).Run() == nil {
r.again = time.Since(t1)
r.login = time.Since(t0)
if d, err := sshLogin(cid, signer, xssh.KeyExchangeMLKEM768X25519); err == nil {
r.again = d
}
// Whether sshd turns away a client with no ML-KEM, as the image's drop-in has it do. A
// release from before it lets one in, so this is counted rather than failed.
_, err := sshLogin(cid, signer, xssh.KeyExchangeCurve25519)
r.classical = err == nil
break
}
// The prompt is usually there by now; it is waited for so the row is not empty.
for time.Now().Before(deadline) {
Expand Down Expand Up @@ -240,3 +263,51 @@ func vsockAccepts(cid, port uint32) bool {
_ = unix.SetsockoptTimeval(fd, unix.AF_VSOCK, unix.SO_VM_SOCKETS_CONNECT_TIMEOUT, &tv)
return unix.Connect(fd, &unix.SockaddrVM{CID: cid, Port: port}) == nil
}

// sshLogin runs `true` as spin over SSH on cid's port 22, offering only the key exchange kex,
// and says how long it took from the connect.
func sshLogin(cid uint32, signer xssh.Signer, kex string) (time.Duration, error) {
t0 := time.Now()
fd, err := unix.Socket(unix.AF_VSOCK, unix.SOCK_STREAM|unix.SOCK_CLOEXEC, 0)
if err != nil {
return 0, fmt.Errorf("opening a vsock socket: %w", err)
}
if err := unix.Connect(fd, &unix.SockaddrVM{CID: cid, Port: 22}); err != nil {
_ = unix.Close(fd)
return 0, fmt.Errorf("connecting to vsock %d:22: %w", cid, err)
}
conn := vsockConn{os.NewFile(uintptr(fd), "vsock")}
defer conn.Close()
c, chans, reqs, err := xssh.NewClientConn(conn, "vsock", &xssh.ClientConfig{
User: "spin",
Auth: []xssh.AuthMethod{xssh.PublicKeys(signer)},
HostKeyCallback: xssh.InsecureIgnoreHostKey(), // a fresh VM's keys, made in its boot
Config: xssh.Config{KeyExchanges: []string{kex}},
})
if err != nil {
return 0, fmt.Errorf("ssh to vsock %d:22: %w", cid, err)
}
client := xssh.NewClient(c, chans, reqs)
defer client.Close()
s, err := client.NewSession()
if err != nil {
return 0, fmt.Errorf("opening a session on vsock %d:22: %w", cid, err)
}
defer s.Close()
if err := s.Run("true"); err != nil {
return 0, fmt.Errorf("running true on vsock %d:22: %w", cid, err)
}
return time.Since(t0), nil
}

// vsockConn is a connected vsock socket as the net.Conn x/crypto/ssh takes: net.FileConn
// knows no AF_VSOCK address, and nothing here asks for one.
type vsockConn struct{ *os.File }

type vsockAddr struct{}

func (vsockAddr) Network() string { return "vsock" }
func (vsockAddr) String() string { return "vsock" }

func (vsockConn) LocalAddr() net.Addr { return vsockAddr{} }
func (vsockConn) RemoteAddr() net.Addr { return vsockAddr{} }
51 changes: 0 additions & 51 deletions boot/vsockproxy/main.go

This file was deleted.

7 changes: 5 additions & 2 deletions boot/waits_probe_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,8 @@ import (
// TOP=<n> rows per table (default 15)
// GAP=<ms> the shortest idle interval printed (default 1)
// RAW=<dir> also write each boot's dump there, which is how testdata is made
// FLAGS=<flags> spin-machine boot flags after the rest: --vsock-cid 3 gives the boot
// systemd-ssh-generator's sshd-vsock.socket, for TARGET=sshd-vsock.socket
func TestBootWaits(t *testing.T) {
if os.Getenv("SPIN_BOOT_WAITS") == "" {
t.Skip("set SPIN_BOOT_WAITS=1: boots a VM and needs sudo to write into its overlay")
Expand Down Expand Up @@ -171,8 +173,9 @@ func bootWaits(t *testing.T, out string, v variant, target, end string) waitsBoo
"-F", "qcow2", "-b", filepath.Join(out, "image", "rootfs.qcow2"), overlay)
editOverlay(t, overlay, v)

cmd := machineCommand(t, out, v, "--disk", overlay, "--memory", v.memory, "--cpus", v.cpus,
"--console", "file:/dev/stdout", "--append", "init=/sbin/init")
// FLAGS, as the other probes take it: spin-machine boot flags after the rest.
cmd := machineCommand(t, out, v, append([]string{"--disk", overlay, "--memory", v.memory, "--cpus", v.cpus,
"--console", "file:/dev/stdout", "--append", "init=/sbin/init"}, strings.Fields(os.Getenv("FLAGS"))...)...)
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
r, w, err := os.Pipe()
if err != nil {
Expand Down
7 changes: 3 additions & 4 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,12 @@ go 1.27.0
toolchain go1.27.3

require (
github.com/godbus/dbus/v5 v5.1.0
github.com/spin-stack/go-tools v0.0.0-20261109214710-b4b5f4a78a45
golang.org/x/crypto v0.58.0
golang.org/x/sys v0.49.0
)

require (
github.com/godbus/dbus/v5 v5.1.0 // indirect
go.yaml.in/yaml/v3 v3.0.6 // indirect
)
require go.yaml.in/yaml/v3 v3.0.6 // indirect

tool github.com/spin-stack/go-tools/cmd/task
4 changes: 4 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -6,5 +6,9 @@ github.com/stretchr/testify v1.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWD
github.com/stretchr/testify v1.12.1/go.mod h1:MDEgiDPPsNp5cuIrHPPCyornHKgEVbtFUmoNlxoYthg=
go.yaml.in/yaml/v3 v3.0.6 h1:zcDfvjXEqTQmpjniUBLc91xYJMB2c4QOFxEF/LCEckM=
go.yaml.in/yaml/v3 v3.0.6/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg=
golang.org/x/crypto v0.58.0 h1:COkYLr4k7nDI4r6QtMML2AfzeIswWHAJthLS6K/M/54=
golang.org/x/crypto v0.58.0/go.mod h1:Xh+kl5A+M0gMiWDytH2Hhr5Z7WbNCpv/eB/EFgfSk+w=
golang.org/x/sys v0.49.0 h1:XbzkgYJHdqh/8m2Uu0W/dQv8nktxx4BFHp1M0gROTXA=
golang.org/x/sys v0.49.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og=
golang.org/x/term v0.47.0 h1:cpyOwQkr2Nzxe2p6KnrTG7Ei2wtzjsuSjhpXXepZ/BM=
golang.org/x/term v0.47.0/go.mod h1:efAfAuG6RKABvg1ArGebRkvX6LXul14SM67o2e4mUCY=
26 changes: 26 additions & 0 deletions image/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,32 @@ grown from the image onto a larger disk would keep 5% of the larger size.
`build.sh` asserts each of these on the finished filesystem — `/etc/machine-id` present
and *empty*, everything else absent.

## Logging in: the SSH client has to speak ML-KEM

`sshd` offers one key exchange, `mlkem768x25519-sha256`, and refuses a client that has no
ML-KEM rather than falling back to a weaker one. The client sees:

```
Unable to negotiate with … port 22: no matching key exchange method found. Their offer: mlkem768x25519-sha256
```

A client that connects is one of:

- **OpenSSH 9.9 or later**, which `ssh -V` names. Earlier ones are refused, among them the 9.6
Ubuntu 24.04 LTS ships (the bench fleet's runners have it, 2026-11-13): an operating system
whose OpenSSH is older needs a newer one installed.
- **`golang.org/x/crypto/ssh` v0.38.0 or later, built with Go 1.24 or later.** It offers
ML-KEM first by default, so a client that sets no `KeyExchanges` already connects.
- **Not** the `ssh2` npm package: no release up to 1.17.0 has ML-KEM.

Why one exchange: ML-KEM is post-quantum, and on the bench fleet a login with it cost what one
with curve25519 did (40 and 39 ms; `task boot:ssh-first`, lab run 43479854381, 2026-11-13).
sntrup761x25519, the other post-quantum exchange and the one OpenSSH 8.5-9.8 picks, took
104 ms - its key generation alone was ~39 ms of the client's. The classical exchanges are
gone with it. The drop-in is `/etc/ssh/sshd_config.d/10-spin-kex.conf`, written by
`configure-system.sh` and asserted by `build.sh`; a drop-in that sets `KexAlgorithms` and is
read before it wins, because sshd keeps the first value it reads.

## Looking inside

```
Expand Down
7 changes: 7 additions & 0 deletions image/build.sh
Original file line number Diff line number Diff line change
Expand Up @@ -357,6 +357,13 @@ in_image /etc/systemd/system/systemd-logind-varlink.socket.d/10-seats.conf || {
grep -qx 'After=spin-machine-sshd-keygen.service' || {
echo "ERROR: sshd@.service does not wait for spin-machine-sshd-keygen.service" >&2
exit 1; }
# sshd offers one key exchange, ML-KEM, and refuses a client without it (configure-system.sh).
# Read back from the finished filesystem: a drop-in that is not there leaves OpenSSH's own list,
# with three classical exchanges in it, and every login still works.
"$bin/debugfs" -R "cat /etc/ssh/sshd_config.d/10-spin-kex.conf" /work/base.raw 2>/dev/null |
grep -qx 'KexAlgorithms mlkem768x25519-sha256' || {
echo "ERROR: sshd does not offer mlkem768x25519-sha256 alone" >&2
exit 1; }
if stat_in_image /etc/systemd/system/systemd-logind.service | grep -q '/dev/null'; then
echo "ERROR: systemd-logind.service is masked - it never starts, no login gets a" \
"session, and spin's manager never starts either" >&2
Expand Down
14 changes: 14 additions & 0 deletions image/mkosi.extra/usr/local/lib/spin-base/configure-system.sh
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,20 @@ cat <<'EOF' > /etc/ssh/sshd_config.d/10-spin-user-ca.conf
TrustedUserCAKeys /etc/ssh/spin_user_ca.pub
EOF

# One key exchange, mlkem768x25519-sha256, and a client that has no ML-KEM is refused rather
# than downgraded: "no matching key exchange method found". It is post-quantum and costs what
# curve25519 does - on the bench fleet a login with each took 40 and 39 ms (task
# boot:ssh-first, lab run 43479854381, 2026-11-13) - while sntrup761x25519, the other
# post-quantum exchange and what OpenSSH 8.5-9.8 picks, took 104, its key generation alone
# ~39 ms of the client's. A client needs OpenSSH 9.9 or later, or golang.org/x/crypto/ssh
# v0.38.0 or later built with Go 1.24 or later; docs/machine.md says who that leaves out.
#
# sshd takes the first value it reads for a keyword, and spin's handoff writes drop-ins
# named 00-*, read before this one: none of them may set KexAlgorithms.
cat <<'EOF' > /etc/ssh/sshd_config.d/10-spin-kex.conf
KexAlgorithms mlkem768x25519-sha256
EOF

# The drop-in only counts if sshd_config includes the directory. Asserted rather than
# assumed, because the way this fails is silent: sshd starts, key logins keep working, and
# only certificate authentication is dead.
Expand Down
Loading