From 8bf9051c048be3058c7646186fbf5e09618aabab Mon Sep 17 00:00:00 2001 From: Manuel de Brito Fontes Date: Fri, 13 Nov 2026 15:19:00 -0300 Subject: [PATCH 1/2] image: sshd offers mlkem768x25519-sha256 alone A client with no ML-KEM is refused rather than downgraded. ML-KEM is post-quantum and on the bench fleet cost what curve25519 did - a login 40 against 39 ms (boot:ssh-first, lab run 43479854381) - while sntrup761x25519, what OpenSSH 8.5-9.8 picks, took 104. A client needs OpenSSH 9.9 or later, or x/crypto/ssh v0.38.0 or later with Go 1.24; image/README.md says so and who that leaves out. build.sh reads the drop-in back from the finished filesystem. Co-Authored-By: Claude Opus 5.5 --- image/README.md | 26 +++++++++++++++++++ image/build.sh | 7 +++++ .../local/lib/spin-base/configure-system.sh | 14 ++++++++++ 3 files changed, 47 insertions(+) diff --git a/image/README.md b/image/README.md index b5b99ab..e069029 100644 --- a/image/README.md +++ b/image/README.md @@ -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 ``` diff --git a/image/build.sh b/image/build.sh index 40e282a..2bf71f4 100755 --- a/image/build.sh +++ b/image/build.sh @@ -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 diff --git a/image/mkosi.extra/usr/local/lib/spin-base/configure-system.sh b/image/mkosi.extra/usr/local/lib/spin-base/configure-system.sh index ffb3148..f9539dd 100755 --- a/image/mkosi.extra/usr/local/lib/spin-base/configure-system.sh +++ b/image/mkosi.extra/usr/local/lib/spin-base/configure-system.sh @@ -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. From fdec6c5b34b6ba6c4a69f6eda38bb682c28e0c72 Mon Sep 17 00:00:00 2001 From: Manuel de Brito Fontes Date: Fri, 13 Nov 2026 15:19:00 -0300 Subject: [PATCH 2/2] boot: ssh-first logs in with x/crypto/ssh over ML-KEM The runners' OpenSSH is 9.6, which has no ML-KEM, and the image's sshd now refuses it. The probe dials vsock itself and logs in with x/crypto/ssh offering mlkem768x25519-sha256 only - what spin's Go clients negotiate by default - so vsockproxy, its ProxyCommand, goes. A column counts the boots where a login offering only curve25519 got in: none on this image, every one on a release from before it. The rows that answered on the bench fleet are recorded beside the one left. boot:waits takes FLAGS, so TARGET=sshd-vsock.socket can be measured with a vsock device. Co-Authored-By: Claude Opus 5.5 --- boot/Taskfile.yml | 5 +- boot/ssh_first_test.go | 127 ++++++++++++++++++++++++++++++--------- boot/vsockproxy/main.go | 51 ---------------- boot/waits_probe_test.go | 7 ++- go.mod | 7 +-- go.sum | 4 ++ 6 files changed, 114 insertions(+), 87 deletions(-) delete mode 100644 boot/vsockproxy/main.go diff --git a/boot/Taskfile.yml b/boot/Taskfile.yml index 5707cb7..625ee14 100644 --- a/boot/Taskfile.yml +++ b/boot/Taskfile.yml @@ -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 diff --git a/boot/ssh_first_test.go b/boot/ssh_first_test.go index 5c0c84b..525a192 100644 --- a/boot/ssh_first_test.go +++ b/boot/ssh_first_test.go @@ -5,8 +5,10 @@ package boot_test import ( "bytes" "cmp" + "crypto/ed25519" "fmt" "math/rand/v2" + "net" "os" "os/exec" "path/filepath" @@ -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" @@ -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. // @@ -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 @@ -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{} } @@ -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) } @@ -122,15 +146,15 @@ 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()) } @@ -138,10 +162,11 @@ install -d "$MNT/var/lib/systemd/linger" && touch "$MNT/var/lib/systemd/linger/s 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", @@ -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) { @@ -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{} } diff --git a/boot/vsockproxy/main.go b/boot/vsockproxy/main.go deleted file mode 100644 index 2b9f218..0000000 --- a/boot/vsockproxy/main.go +++ /dev/null @@ -1,51 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 - -// vsockproxy is ssh's ProxyCommand for a guest reached over vsock: it connects to a context -// id and port and copies stdin to the connection and the connection to stdout. task -// boot:ssh-first builds it, because the host that runs the probe is not guaranteed a -// systemd-ssh-proxy or a socat that speaks AF_VSOCK. -// -// Diagnostic, never part of a release. -// -// vsockproxy -package main - -import ( - "fmt" - "io" - "os" - "strconv" - - "golang.org/x/sys/unix" -) - -// main is ssh's ProxyCommand, and only ssh running against a guest calls it. -// -// mutate-lane: ./boot.TestFirstSSH, whose every login goes through it. -func main() { - if len(os.Args) != 3 { - fmt.Fprintln(os.Stderr, "usage: vsockproxy ") - os.Exit(2) - } - cid, err1 := strconv.ParseUint(os.Args[1], 10, 32) - port, err2 := strconv.ParseUint(os.Args[2], 10, 32) - if err1 != nil || err2 != nil { - fmt.Fprintf(os.Stderr, "vsockproxy: cid %q and port %q must be numbers\n", os.Args[1], os.Args[2]) - os.Exit(2) - } - fd, err := unix.Socket(unix.AF_VSOCK, unix.SOCK_STREAM|unix.SOCK_CLOEXEC, 0) - if err != nil { - fmt.Fprintf(os.Stderr, "vsockproxy: a vsock socket: %v\n", err) - os.Exit(1) - } - if err := unix.Connect(fd, &unix.SockaddrVM{CID: uint32(cid), Port: uint32(port)}); err != nil { - fmt.Fprintf(os.Stderr, "vsockproxy: connecting to vsock %d:%d: %v\n", cid, port, err) - os.Exit(1) - } - conn := os.NewFile(uintptr(fd), "vsock") - go func() { - _, _ = io.Copy(conn, os.Stdin) - _ = unix.Shutdown(fd, unix.SHUT_WR) - }() - _, _ = io.Copy(os.Stdout, conn) -} diff --git a/boot/waits_probe_test.go b/boot/waits_probe_test.go index 1c6dba8..f5d8371 100644 --- a/boot/waits_probe_test.go +++ b/boot/waits_probe_test.go @@ -44,6 +44,8 @@ import ( // TOP= rows per table (default 15) // GAP= the shortest idle interval printed (default 1) // RAW= also write each boot's dump there, which is how testdata is made +// 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") @@ -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 { diff --git a/go.mod b/go.mod index 7221772..a22f815 100644 --- a/go.mod +++ b/go.mod @@ -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 diff --git a/go.sum b/go.sum index 828a205..cd8748b 100644 --- a/go.sum +++ b/go.sum @@ -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=