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))
+	}
+}