ssh: add openssh controlmaster socket support Adds support for establishing SSH sessions over an existing "ControlMaster" [unix domain] socket in proxy mode. Details of the protocol can be found here: https://github.com/openssh/openssh-portable/blob/master/PROTOCOL.mux More details about ControlMaster sockets can be found here: https://linux.die.net/man/5/ssh_config Fixes golang/go#32958 Co-authored-by: Cyrus Katrak <ckatrak@slack-corp.com> Change-Id: Ia3ae8893262f5060ed3fadfcbe97619c9659145b Reviewed-on: https://go-review.googlesource.com/c/crypto/+/733040 Reviewed-by: Cherry Mui <cherryyz@google.com> Reviewed-by: Nicola Murino <nicola.murino@gmail.com> LUCI-TryBot-Result: golang-scoped@luci-project-accounts.iam.gserviceaccount.com <golang-scoped@luci-project-accounts.iam.gserviceaccount.com> Reviewed-by: Michael Pratt <mpratt@google.com>
diff --git a/ssh/client.go b/ssh/client.go index 19ff9b3..89f0def 100644 --- a/ssh/client.go +++ b/ssh/client.go
@@ -88,6 +88,32 @@ return conn, conn.mux.incomingChannels, conn.mux.incomingRequests, nil } +// NewControlClientConn establishes an SSH connection over an OpenSSH +// ControlMaster socket c in proxy mode. +// +// Note that this package only implements the client side of the multiplexing +// protocol. The provided net.Conn must be a local, secure connection (such as a +// Unix domain socket) connected to an already-running OpenSSH process acting as +// the ControlMaster. +// +// WARNING: Because proxy mode bypasses the standard cryptographic handshake +// passing a standard network connection (e.g., TCP) will result in plaintext +// data leakage. +// +// The Request and NewChannel channels must be serviced or the connection +// will hang. +func NewControlClientConn(c net.Conn) (Conn, <-chan NewChannel, <-chan *Request, error) { + conn := &connection{ + sshConn: sshConn{conn: c}, + } + var err error + if conn.transport, err = handshakeControlProxy(c); err != nil { + return nil, nil, nil, fmt.Errorf("ssh: control proxy handshake failed: %w", err) + } + conn.mux = newMux(conn.transport) + return conn, conn.mux.incomingChannels, conn.mux.incomingRequests, nil +} + // clientHandshake performs the client side key exchange. See RFC 4253 Section // 7. func (c *connection) clientHandshake(dialAddress string, config *ClientConfig) error {
diff --git a/ssh/connection.go b/ssh/connection.go index 613a71a..378f640 100644 --- a/ssh/connection.go +++ b/ssh/connection.go
@@ -91,9 +91,17 @@ } } +// A connTransport represents the transport for a connection. +type connTransport interface { + packetConn + getAlgorithms() NegotiatedAlgorithms + getSessionID() []byte + waitSession() error +} + // A connection represents an incoming connection. type connection struct { - transport *handshakeTransport + transport connTransport sshConn // The connection protocol.
diff --git a/ssh/control.go b/ssh/control.go new file mode 100644 index 0000000..9b14e4c --- /dev/null +++ b/ssh/control.go
@@ -0,0 +1,155 @@ +// Copyright 2026 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 ssh + +import ( + "encoding/binary" + "errors" + "fmt" + "io" + + "golang.org/x/crypto/cryptobyte" +) + +const ( + muxProtocolVersion = 4 + + muxMsgHello = 0x00000001 + muxCProxy = 0x1000000f + muxSProxy = 0x8000000f +) + +const controlProxyRequestID = 0 + +// handshakeControlProxy attempts to establish a transport connection with an +// OpenSSH ControlMaster socket in proxy mode. For details see: +// https://github.com/openssh/openssh-portable/blob/master/PROTOCOL.mux +func handshakeControlProxy(rw io.ReadWriteCloser) (connTransport, error) { + if err := controlProxyWritePacket(rw, func(b *cryptobyte.Builder) { + b.AddUint32(muxMsgHello) + b.AddUint32(muxProtocolVersion) + }); err != nil { + return nil, fmt.Errorf("mux hello write failed: %w", err) + } + if err := controlProxyWritePacket(rw, func(b *cryptobyte.Builder) { + b.AddUint32(muxCProxy) + b.AddUint32(controlProxyRequestID) + }); err != nil { + return nil, fmt.Errorf("mux client proxy write failed: %w", err) + } + + messageType, body, err := controlProxyReadMessage(rw) + if err != nil { + return nil, fmt.Errorf("mux hello read failed: %w", err) + } + if messageType != muxMsgHello { + return nil, fmt.Errorf("expected hello response, got %v", messageType) + } + var v uint32 + if !body.ReadUint32(&v) { + return nil, errors.New("EOF reading mux protocol version") + } + if v != muxProtocolVersion { + return nil, fmt.Errorf("mux server has unsupported version %v", v) + } + messageType, body, err = controlProxyReadMessage(rw) + if err != nil { + return nil, fmt.Errorf("mux server proxy read failed: %w", err) + } + if messageType != muxSProxy { + return nil, fmt.Errorf("expected server proxy response, got %v", messageType) + } + var reqID uint32 + if !body.ReadUint32(&reqID) { + return nil, errors.New("EOF reading request id") + } + if reqID != controlProxyRequestID { + return nil, fmt.Errorf("expected request id %v, got %v", controlProxyRequestID, reqID) + } + return &controlProxyTransport{rw}, nil +} + +// controlProxyTransport implements the connTransport interface for +// ControlMaster connections. Each controlMessage has zero length padding and +// no MAC. +type controlProxyTransport struct { + rw io.ReadWriteCloser +} + +func (p *controlProxyTransport) Close() error { + return p.rw.Close() +} + +func (p *controlProxyTransport) writePacket(controlMessage []byte) error { + return controlProxyWritePacket(p.rw, func(b *cryptobyte.Builder) { + b.AddUint8(0) // Padding length. + b.AddBytes(controlMessage) + }) +} + +func (p *controlProxyTransport) readPacket() ([]byte, error) { + buf, err := controlProxyReadPacket(p.rw) + if err != nil { + return nil, fmt.Errorf("ssh: error reading control message: %w", err) + } + // Discard the padding length. + if len(buf) < 1 { + return nil, errors.New("ssh: EOF reading padding length") + } + if buf[0] != 0 { + return nil, errors.New("ssh: unexpected non-zero padding in control message") + } + return buf[1:], nil +} + +func (p *controlProxyTransport) getAlgorithms() NegotiatedAlgorithms { + return NegotiatedAlgorithms{} +} + +func (p *controlProxyTransport) getSessionID() []byte { + return nil +} + +func (p *controlProxyTransport) waitSession() error { + return nil +} + +func controlProxyWritePacket(w io.Writer, f cryptobyte.BuilderContinuation) error { + var buf []byte + b := cryptobyte.NewBuilder(buf) + b.AddUint32LengthPrefixed(f) + out, err := b.Bytes() + if err != nil { + return err + } + _, err = w.Write(out) + return err +} + +func controlProxyReadPacket(r io.Reader) (cryptobyte.String, error) { + var l uint32 + if err := binary.Read(r, binary.BigEndian, &l); err != nil { + return nil, err + } + if l > maxPacket { + return nil, fmt.Errorf("message length %v exceeds maximum %v", l, maxPacket) + } + buf := make([]byte, l) + if _, err := io.ReadFull(r, buf); err != nil { + return nil, err + } + return buf, nil +} + +func controlProxyReadMessage(r io.Reader) (messageType uint32, body cryptobyte.String, err error) { + body, err = controlProxyReadPacket(r) + if err != nil { + return 0, nil, fmt.Errorf("error reading message body: %w", err) + } + if !body.ReadUint32(&messageType) { + return 0, nil, errors.New("EOF reading message type") + } + return messageType, body, nil +}
diff --git a/ssh/control_test.go b/ssh/control_test.go new file mode 100644 index 0000000..2c0ff95 --- /dev/null +++ b/ssh/control_test.go
@@ -0,0 +1,275 @@ +// Copyright 2026 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 ssh + +import ( + "bytes" + "io" + "strings" + "testing" +) + +func TestControlClientHandshake(t *testing.T) { + reqs := [][]byte{ + // Hello request. + {0x00, 0x00, 0x00, 0x08, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x04}, + // Client proxy request. + {0x00, 0x00, 0x00, 0x08, 0x10, 0x00, 0x00, 0x0f, 0x00, 0x00, 0x00, 0x00}, + } + respsNormal := [][]byte{ + // Hello response. + {0x00, 0x00, 0x00, 0x08, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x04}, + // Server proxy response. + {0x00, 0x00, 0x00, 0x08, 0x80, 0x00, 0x00, 0x0f, 0x00, 0x00, 0x00, 0x00}, + } + for _, tt := range []struct { + name string + resps [][]byte + expectedErr string + }{ + { + name: "normal handshake", + resps: respsNormal, + }, + { + name: "length greater than max", + resps: [][]byte{ + {0xff, 0xff, 0xff, 0xff}, + respsNormal[1], + }, + expectedErr: "message length 4294967295 exceeds maximum", + }, + { + name: "missing hello response", + resps: [][]byte{ + {}, + }, + expectedErr: "use of closed network connection", + }, + { + name: "hello response too short", + resps: [][]byte{ + {0x00, 0x00, 0x00, 0x06, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00}, + respsNormal[1], + }, + expectedErr: "EOF", + }, + { + name: "bad hello response type", + resps: [][]byte{ + {0x00, 0x00, 0x00, 0x08, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x04}, + respsNormal[1], + }, + expectedErr: "expected hello response, got 0", + }, + { + name: "bad protocol version", + resps: [][]byte{ + {0x00, 0x00, 0x00, 0x08, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00}, + respsNormal[1], + }, + expectedErr: "mux server has unsupported version 0", + }, + { + name: "missing server proxy response", + resps: [][]byte{ + respsNormal[0], + }, + expectedErr: "use of closed network connection", + }, + { + name: "server proxy response too short", + resps: [][]byte{ + respsNormal[0], + {0x00, 0x00, 0x00, 0x06, 0x80, 0x00, 0x00, 0x0f, 0x00, 0x00}, + }, + expectedErr: "EOF", + }, + { + name: "bad server proxy response type", + resps: [][]byte{ + respsNormal[0], + {0x00, 0x00, 0x00, 0x08, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}, + }, + expectedErr: "expected server proxy response, got 0", + }, + { + name: "bad request id", + resps: [][]byte{ + respsNormal[0], + {0x00, 0x00, 0x00, 0x08, 0x80, 0x00, 0x00, 0x0f, 0x00, 0x00, 0x00, 0x01}, + }, + expectedErr: "expected request id 0, got 1", + }, + } { + t.Run(tt.name, func(t *testing.T) { + done := make(chan error, 1) + + ok := func() bool { + c1, c2, err := netPipe() + if err != nil { + t.Fatalf("netPipe: %v", err) + } + defer c1.Close() + defer c2.Close() + + go func() { + defer close(done) + _, _, _, err := NewControlClientConn(c2) + c2.Write([]byte{0}) // Dummy message to unblock the final read. + done <- err + }() + + i := 0 + for ; i < len(reqs) && i < len(tt.resps); i++ { + expected := reqs[i] + buf := make([]byte, len(expected)) + if _, err := io.ReadFull(c1, buf); err != nil { + t.Errorf("error reading message %d: %v", i+1, err) + return false + } + if !bytes.Equal(buf, expected) { + t.Errorf( + "unexpected message %d: got %v, want %v", + i+1, buf, expected, + ) + return false + } + _, err = c1.Write(tt.resps[i]) + if err != nil { + t.Errorf("error writing message %d: %v", i+1, err) + return false + } + } + // Wait for the next message so that the final response can be read. + buf := make([]byte, 1) + c1.Read(buf) + return true + }() + if !ok { + return + } + + err := <-done + if tt.expectedErr != "" { + if err == nil || !strings.Contains(err.Error(), tt.expectedErr) { + t.Fatalf("got err %q; want err containing %q", err, tt.expectedErr) + } + return + } + if err != nil { + t.Fatalf("got err %q; want no err", err) + } + }) + } +} + +func TestControlClientTransport(t *testing.T) { + type response struct { + status bool + payload []byte + err error + } + + for _, tt := range []struct { + name string + resp []byte + respStatus bool + respPayload []byte + expectedErr string + }{ + { + name: "successful request", + resp: []byte{0x00, 0x00, 0x00, 0x02, 0x00, 0x51}, + respStatus: true, + }, + { + name: "failed request", + resp: []byte{0x00, 0x00, 0x00, 0x02, 0x00, 0x52}, + respStatus: false, + }, + { + name: "short response", + resp: []byte{0x00, 0x00, 0x00, 0x00}, + expectedErr: "EOF", + }, + { + name: "response with payload", + resp: []byte{0x00, 0x00, 0x00, 0x05, 0x00, 0x51, 0x01, 0x02, 0x03}, + respStatus: true, + respPayload: []byte{1, 2, 3}, + }, + } { + t.Run(tt.name, func(t *testing.T) { + c1, c2, err := netPipe() + if err != nil { + t.Fatalf("netPipe: %v", err) + } + defer c1.Close() + defer c2.Close() + + // Handshake responses. + c1.Write([]byte{ + 0x00, 0x00, 0x00, 0x08, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x04, + 0x00, 0x00, 0x00, 0x08, 0x80, 0x00, 0x00, 0x0f, 0x00, 0x00, 0x00, 0x00, + }) + + conn, chans, reqs, err := NewControlClientConn(c2) + if err != nil { + t.Fatal(err) + } + client := NewClient(conn, chans, reqs) + + done := make(chan response, 1) + go func() { + defer close(done) + status, payload, err := client.SendRequest("hello", true, nil) + if err != nil { + done <- response{err: err} + return + } + done <- response{ + status: status, + payload: payload, + } + }() + + // Discard handshake. + io.CopyN(io.Discard, c1, 24) + + expectedReq := []byte{ + 0x00, 0x00, 0x00, 0x0c, 0x00, 0x50, + 0x00, 0x00, 0x00, 0x05, 'h', 'e', 'l', 'l', 'o', + 0x01, + } + buf := make([]byte, len(expectedReq)) + if _, err := io.ReadFull(c1, buf); err != nil { + t.Fatalf("reading request: %v", err) + } + if !bytes.Equal(buf, expectedReq) { + t.Fatalf("got request %v; want %v", buf, expectedReq) + } + + c1.Write(tt.resp) + + resp := <-done + if tt.expectedErr != "" { + if resp.err == nil || !strings.Contains(resp.err.Error(), tt.expectedErr) { + t.Fatalf("got err %q; want err containing %q", resp.err, tt.expectedErr) + } + return + } + if resp.err != nil { + t.Fatalf("got err %q; want no err", resp.err) + } + if resp.status != tt.respStatus { + t.Fatalf("got status %v; want %v", resp.status, tt.respStatus) + } + if !bytes.Equal(resp.payload, tt.respPayload) { + t.Errorf("got payload %v; want %v", resp.payload, tt.respPayload) + } + }) + } +}
diff --git a/ssh/test/sshcli_test.go b/ssh/test/sshcli_test.go index 767dd6c..4537394 100644 --- a/ssh/test/sshcli_test.go +++ b/ssh/test/sshcli_test.go
@@ -7,11 +7,13 @@ import ( "bytes" "fmt" + "net" "os" "os/exec" "path/filepath" "runtime" "testing" + "time" "golang.org/x/crypto/internal/testenv" "golang.org/x/crypto/ssh" @@ -161,3 +163,87 @@ }) } } + +func TestSSHCLIControlClientConn(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skipf("always fails on Windows, see #64403") + } + sshCLI := sshClient(t) + keyFiles := map[string][]byte{ + "rsa": testdata.PEMBytes["rsa"], + "rsa.pub": ssh.MarshalAuthorizedKey(testPublicKeys["rsa"]), + } + keyPrivPath := setupSSHCLIKeys(t, keyFiles, "rsa") + + config := &ssh.ServerConfig{ + PublicKeyCallback: func(conn ssh.ConnMetadata, key ssh.PublicKey) (*ssh.Permissions, error) { + if conn.User() == "testcontrolproxy" && bytes.Equal(key.Marshal(), testPublicKeys["rsa"].Marshal()) { + return nil, nil + } + return nil, fmt.Errorf("pubkey for %q not acceptable", conn.User()) + }, + } + config.AddHostKey(testSigners["rsa"]) + + server, err := newTestServer(config) + if err != nil { + t.Fatalf("unable to start test server: %v", err) + } + defer server.Close() + + port, err := server.port() + if err != nil { + t.Fatalf("unable to get server port: %v", err) + } + + dir, err := os.MkdirTemp("", "controlSocket") + if err != nil { + t.Fatalf("unable to create temp dir for control socket: %v", err) + } + defer os.RemoveAll(dir) + csPath := filepath.Join(dir, "c") + cmd := testenv.Command(t, sshCLI, "-vvv", "-i", keyPrivPath, "-o", "StrictHostKeyChecking=no", + "-p", port, "-o", "ControlPath="+csPath, "-o", "ControlMaster=yes", "-N", "testcontrolproxy@127.0.0.1") + var output bytes.Buffer + cmd.Stdout = &output + cmd.Stderr = &output + if err := cmd.Start(); err != nil { + t.Fatalf("control socket master start failed, error: %v", err) + } + defer func() { + cmd.Process.Kill() + cmd.Wait() + if t.Failed() { + t.Logf("OpenSSH output:\n\n%s", cmd.Stdout) + } + }() + for i := range 10 { + if _, err := os.Stat(csPath); err == nil { + break + } else if !os.IsNotExist(err) { + t.Fatalf("unable to stat control socket: %v", err) + } + time.Sleep((1 << i) * 5 * time.Millisecond) + } + + conn, err := net.Dial("unix", csPath) + if err != nil { + t.Fatalf("unable to open control socket: %v", err) + } + defer conn.Close() + cc, chans, reqs, err := ssh.NewControlClientConn(conn) + if err != nil { + t.Fatalf("unable to create client: %v", err) + } + client := ssh.NewClient(cc, chans, reqs) + defer client.Close() + session, err := client.NewSession() + if err != nil { + t.Fatalf("unable to create session: %v", err) + } + defer session.Close() + out, err := session.CombinedOutput("true") + if err != nil { + t.Fatalf("command execution failed, error: %v, command output %q", err, string(out)) + } +}