| // Copyright 2025 The Go Authors. All rights reserved. |
| // Use of this source code is governed by a BSD-style |
| // license that can be found in the LICENSE file. |
| |
| package test |
| |
| import ( |
| "bytes" |
| "errors" |
| "fmt" |
| "net" |
| "os" |
| "os/exec" |
| "path/filepath" |
| "strconv" |
| "strings" |
| "testing" |
| "time" |
| |
| "golang.org/x/crypto/internal/testenv" |
| "golang.org/x/crypto/ssh" |
| "golang.org/x/crypto/ssh/testdata" |
| ) |
| |
| type serverTest struct { |
| // name is a freeform string identifying the test and the file in which |
| // the expected results will be stored. |
| name string |
| // config contains the server configuration to use for this test. |
| config *ssh.ServerConfig |
| } |
| |
| // connFromCommand starts opens a listening socket and starts the reference |
| // client to connect to it. It returns a recordingConn that wraps the resulting |
| // connection. |
| func (test *serverTest) connFromCommand(t *testing.T) (conn *recordingConn, err error) { |
| sshCLI, err := exec.LookPath("ssh") |
| if err != nil { |
| t.Skipf("skipping test: %v", err) |
| } |
| l, err := net.ListenTCP("tcp", &net.TCPAddr{ |
| IP: net.IPv4(127, 0, 0, 1), |
| Port: 0, |
| }) |
| if err != nil { |
| return nil, err |
| } |
| defer l.Close() |
| |
| port := l.Addr().(*net.TCPAddr).Port |
| dir, err := os.MkdirTemp("", "sshtest") |
| if err != nil { |
| t.Fatal(err) |
| } |
| |
| filename := "id_ed25519" |
| writeFile(filepath.Join(dir, filename), testdata.PEMBytes["ed25519"]) |
| writeFile(filepath.Join(dir, filename+".pub"), ssh.MarshalAuthorizedKey(testPublicKeys["ed25519"])) |
| var args []string |
| args = append(args, "-v", "-i", filepath.Join(dir, filename), "-o", "StrictHostKeyChecking=no") |
| args = append(args, "-oKexAlgorithms=+diffie-hellman-group14-sha1") |
| args = append(args, "-p", strconv.Itoa(port)) |
| args = append(args, "testuser@127.0.0.1") |
| args = append(args, "true") |
| cmd := testenv.Command(t, sshCLI, args...) |
| cmd.Stdin = nil |
| var output bytes.Buffer |
| cmd.Stdout = &output |
| cmd.Stderr = &output |
| if err := cmd.Start(); err != nil { |
| return nil, err |
| } |
| |
| t.Cleanup(func() { |
| if err := os.RemoveAll(dir); err != nil { |
| t.Error(err) |
| } |
| // Don't check for errors; if it fails it's most |
| // likely "os: process already finished", and we don't |
| // care about that. |
| cmd.Process.Kill() |
| cmd.Wait() |
| if t.Failed() { |
| t.Logf("OpenSSH output:\n\n%s", cmd.Stdout) |
| } |
| }) |
| |
| connChan := make(chan any, 1) |
| go func() { |
| tcpConn, err := l.Accept() |
| if err != nil { |
| connChan <- err |
| return |
| } |
| connChan <- tcpConn |
| }() |
| |
| var tcpConn net.Conn |
| select { |
| case connOrError := <-connChan: |
| if err, ok := connOrError.(error); ok { |
| return nil, err |
| } |
| tcpConn = connOrError.(net.Conn) |
| case <-time.After(2 * time.Second): |
| return nil, errors.New("timed out waiting for connection from child process") |
| } |
| |
| record := &recordingConn{ |
| Conn: tcpConn, |
| clientToServer: false, |
| } |
| |
| return record, nil |
| } |
| |
| func (test *serverTest) dataPath() string { |
| return filepath.Join("..", "testdata", "Server-"+test.name) |
| } |
| |
| func (test *serverTest) loadData() (flows [][]byte, err error) { |
| in, err := os.Open(test.dataPath()) |
| if err != nil { |
| return nil, err |
| } |
| defer in.Close() |
| return parseTestData(in) |
| } |
| |
| func (test *serverTest) run(t *testing.T, write bool) { |
| var serverConn net.Conn |
| var recordingConn *recordingConn |
| |
| setDeterministicRandomSource(&test.config.Config) |
| |
| if write { |
| var err error |
| recordingConn, err = test.connFromCommand(t) |
| if err != nil { |
| t.Fatalf("Failed to start subcommand: %v", err) |
| } |
| serverConn = recordingConn |
| } else { |
| timer := time.AfterFunc(10*time.Second, func() { |
| fmt.Println("This test may be stuck, try running using -timeout 10s") |
| }) |
| t.Cleanup(func() { |
| timer.Stop() |
| }) |
| flows, err := test.loadData() |
| if err != nil { |
| t.Fatalf("Failed to load data from %s", test.dataPath()) |
| } |
| serverConn = newReplayingConn(t, flows) |
| } |
| |
| server, chans, reqs, err := ssh.NewServerConn(serverConn, test.config) |
| if err != nil { |
| t.Fatalf("Failed to create server conn: %v", err) |
| } |
| defer server.Close() |
| |
| go ssh.DiscardRequests(reqs) |
| |
| done := make(chan bool) |
| |
| for newChannel := range chans { |
| if newChannel.ChannelType() != "session" { |
| newChannel.Reject(ssh.UnknownChannelType, "unknown channel type") |
| continue |
| } |
| |
| channel, requests, err := newChannel.Accept() |
| if err != nil { |
| continue |
| } |
| |
| go func(in <-chan *ssh.Request) { |
| for req := range in { |
| switch req.Type { |
| case "exec": |
| if req.WantReply { |
| req.Reply(true, nil) |
| } |
| channel.SendRequest("exit-status", false, ssh.Marshal(&exitStatusMsg{Status: 0})) |
| channel.Close() |
| done <- true |
| default: |
| if req.WantReply { |
| req.Reply(false, nil) |
| } |
| } |
| } |
| }(requests) |
| } |
| |
| <-done |
| |
| if write { |
| path := test.dataPath() |
| out, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0644) |
| if err != nil { |
| t.Fatalf("Failed to create output file: %v", err) |
| } |
| defer out.Close() |
| recordingConn.Close() |
| |
| recordingConn.WriteTo(out) |
| t.Logf("Wrote %s\n", path) |
| } |
| } |
| |
| func recordingsServerConfig() *ssh.ServerConfig { |
| config := &ssh.ServerConfig{ |
| PublicKeyCallback: func(conn ssh.ConnMetadata, key ssh.PublicKey) (*ssh.Permissions, error) { |
| return nil, nil |
| }, |
| } |
| config.SetDefaults() |
| // Remove ML-KEM since it only works with Go 1.24. |
| config.SetDefaults() |
| if config.KeyExchanges[0] == ssh.KeyExchangeMLKEM768X25519 { |
| config.KeyExchanges = config.KeyExchanges[1:] |
| } |
| config.AddHostKey(testSigners["rsa"]) |
| return config |
| } |
| |
| func TestServerKeyExchanges(t *testing.T) { |
| config := ssh.ClientConfig{} |
| config.SetDefaults() |
| |
| var keyExchanges []string |
| for _, kex := range config.KeyExchanges { |
| // Exclude ecdh for now, to make them determistic we should use see a |
| // stream of fixed bytes as the random source. |
| // Exclude ML-KEM because server side is not deterministic. |
| if !strings.HasPrefix(kex, "ecdh-") && !strings.HasPrefix(kex, "mlkem") { |
| keyExchanges = append(keyExchanges, kex) |
| } |
| } |
| // Add diffie-hellman-group16-sha512 as it is not enabled by default. |
| keyExchanges = append(keyExchanges, "diffie-hellman-group16-sha512") |
| |
| for _, kex := range keyExchanges { |
| c := recordingsServerConfig() |
| c.KeyExchanges = []string{kex} |
| test := serverTest{ |
| name: "KEX-" + kex, |
| config: c, |
| } |
| runTestAndUpdateIfNeeded(t, test.name, test.run) |
| } |
| } |
| |
| func TestServerCiphers(t *testing.T) { |
| config := ssh.ClientConfig{} |
| config.SetDefaults() |
| |
| for _, ciph := range config.Ciphers { |
| c := recordingsServerConfig() |
| c.Ciphers = []string{ciph} |
| test := serverTest{ |
| name: "Cipher-" + ciph, |
| config: c, |
| } |
| runTestAndUpdateIfNeeded(t, test.name, test.run) |
| } |
| } |
| |
| func TestServerMACs(t *testing.T) { |
| config := ssh.ClientConfig{} |
| config.SetDefaults() |
| |
| for _, mac := range config.MACs { |
| c := recordingsServerConfig() |
| c.MACs = []string{mac} |
| test := serverTest{ |
| name: "MAC-" + mac, |
| config: c, |
| } |
| runTestAndUpdateIfNeeded(t, test.name, test.run) |
| } |
| } |