ssh: add integration tests against a reference implementation
As in crypto/tls, we record the connection against a reference implementation, OpenSSH in our case, and run part of our integration tests by replaying these recorded SSH connections. Change-Id: If042b5f650b267bd3ede34a05ec3a6fa6d1a86b3 Reviewed-on: https://go-review.googlesource.com/c/crypto/+/644436 LUCI-TryBot-Result: Go LUCI <golang-scoped@luci-project-accounts.iam.gserviceaccount.com> Reviewed-by: Carlos Amedee <carlos@golang.org> Auto-Submit: Filippo Valsorda <filippo@golang.org> Reviewed-by: Dmitri Shuralyov <dmitshur@google.com> Reviewed-by: Daniel McCarney <daniel@binaryparadox.net>
This commit is contained in:
committed by
Gopher Robot
parent
959f8f3db0
commit
6b13eef40b
@@ -0,0 +1,279 @@
|
||||
// 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/sha3"
|
||||
"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
|
||||
|
||||
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{
|
||||
Config: ssh.Config{
|
||||
Rand: sha3.NewShake128(),
|
||||
},
|
||||
PublicKeyCallback: func(conn ssh.ConnMetadata, key ssh.PublicKey) (*ssh.Permissions, error) {
|
||||
return nil, nil
|
||||
},
|
||||
}
|
||||
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.
|
||||
if !strings.HasPrefix(kex, "ecdh-") {
|
||||
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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user