diff --git a/lib/client/keyagent.go b/lib/client/keyagent.go index 612929ce24ac3..e603abf21e804 100644 --- a/lib/client/keyagent.go +++ b/lib/client/keyagent.go @@ -150,11 +150,17 @@ func NewLocalAgent(conf LocalAgentConfig) (a *LocalKeyAgent, err error) { if shouldAddKeysToAgent(conf.KeysOption) { a.log.DebugContext(context.Background(), "Connecting to the system agent") + // Check whether the system agent is reachable + // and close the connection right away systemAgent, err := sshagent.NewSystemAgentClient() if err != nil { a.log.WarnContext(context.Background(), "Unable to connect to system agent", "error", err) } else { - a.systemAgent = systemAgent + if err := systemAgent.Close(); err != nil { + a.log.DebugContext(context.Background(), "Failed to close connection to system agent", "error", err) + } + // Build a client that connects to the system agent for each request. + a.systemAgent = sshagent.NewSingleRequestClient(sshagent.NewSystemAgentClient) } } else { log.DebugContext(context.Background(), "Skipping connection to the local ssh-agent.") diff --git a/lib/client/keyagent_test.go b/lib/client/keyagent_test.go index 950fee3b3a56c..7345b6bb06737 100644 --- a/lib/client/keyagent_test.go +++ b/lib/client/keyagent_test.go @@ -28,6 +28,7 @@ import ( "path/filepath" "runtime" "sync" + "sync/atomic" "testing" "time" @@ -63,6 +64,10 @@ type KeyAgentTestSuite struct { clusterName string tlsca *tlsca.CertAuthority tlscaCert authclient.TrustedCerts + + // openSystemAgentConns is the number of client connections currently + // open to the agent served on $SSH_AUTH_SOCK. + openSystemAgentConns *atomic.Int32 } type keyAgentTestSuiteFunc func(opt *keyAgentTestSuiteOpt) @@ -96,14 +101,15 @@ func makeSuite(t *testing.T, opts ...keyAgentTestSuiteFunc) *KeyAgentTestSuite { o(&settings) } - err := startDebugAgent(t) + openSystemAgentConns, err := startDebugAgent(t) require.NoError(t, err) s := &KeyAgentTestSuite{ - keyDir: t.TempDir(), - username: "foo", - hostname: settings.hostname, - clusterName: settings.clusterName, + keyDir: t.TempDir(), + username: "foo", + hostname: settings.hostname, + clusterName: settings.clusterName, + openSystemAgentConns: openSystemAgentConns, } pemBytes, ok := fixtures.PEMBytes["rsa"] @@ -183,6 +189,40 @@ func TestAddKey(t *testing.T) { } +// TestSystemAgentConnections ensures that a LocalKeyAgent does not hold +// connections to the system agent open. +func TestSystemAgentConnections(t *testing.T) { + s := makeSuite(t) + + requireNoOpenConns := func(t *testing.T) { + t.Helper() + require.EventuallyWithT(t, func(t *assert.CollectT) { + assert.Zero(t, s.openSystemAgentConns.Load()) + }, 5*time.Second, 10*time.Millisecond, "connections to the system agent were leaked") + } + + // Creating many key agents must not accumulate connections to the system agent. + var keyAgents []*LocalKeyAgent + for range 10 { + keyAgent := s.newKeyAgent(t) + require.NotNil(t, keyAgent.systemAgent, "expected the key agent to use the system agent") + keyAgents = append(keyAgents, keyAgent) + } + requireNoOpenConns(t) + + for _, keyAgent := range keyAgents { + require.NoError(t, keyAgent.AddKeyRing(s.keyRing)) + requireNoOpenConns(t) + + _, err := keyAgent.Signers() + require.NoError(t, err) + requireNoOpenConns(t) + + require.NoError(t, keyAgent.UnloadKeyRing(s.keyRing.KeyRingIndex)) + requireNoOpenConns(t) + } +} + // TestLoadKey ensures correct loading of a key into an agent. This test // checks the following: // - Loading a key multiple times overwrites the same key. @@ -812,7 +852,10 @@ func (s *KeyAgentTestSuite) makeKeyRing(t *testing.T, username, proxyHost string } } -func startDebugAgent(t *testing.T) error { +// startDebugAgent serves an in-memory keyring over $SSH_AUTH_SOCK, mimicking +// the system agent. It returns an atomic integer tracking the number of client +// connections that are currently open to that agent. +func startDebugAgent(t *testing.T) (openConns *atomic.Int32, err error) { // Create own tmp dir instead of using t.TmpDir // because net.Listen("unix", path) has dir path length limitation tempDir, err := os.MkdirTemp("", "teleport-test") @@ -824,18 +867,18 @@ func startDebugAgent(t *testing.T) error { socketpath := filepath.Join(tempDir, "agent.sock") listener, err := net.Listen("unix", socketpath) if err != nil { - return trace.Wrap(err) + return nil, trace.Wrap(err) } systemAgent := agent.NewKeyring() t.Setenv(teleport.SSHAuthSock, socketpath) + openConns = new(atomic.Int32) startedC := make(chan struct{}) doneC := make(chan struct{}) + var wg sync.WaitGroup - wg.Add(2) - go func() { - defer wg.Done() + wg.Go(func() { // agent is listening and environment variable is set, unblock now close(startedC) for { @@ -846,24 +889,22 @@ func startDebugAgent(t *testing.T) error { } return } - wg.Add(2) - go func() { + openConns.Add(1) + wg.Go(func() { agent.ServeAgent(systemAgent, conn) - wg.Done() - }() - go func() { + openConns.Add(-1) + }) + wg.Go(func() { <-doneC conn.Close() - wg.Done() - }() + }) } - }() + }) - go func() { + wg.Go(func() { <-doneC listener.Close() - wg.Done() - }() + }) t.Cleanup(func() { close(doneC) @@ -872,7 +913,7 @@ func startDebugAgent(t *testing.T) error { // block until agent is started <-startedC - return nil + return openConns, nil } func (s *KeyAgentTestSuite) newKeyAgent(t *testing.T) *LocalKeyAgent { diff --git a/lib/sshagent/client.go b/lib/sshagent/client.go index 5eaa10c3527e1..808e2e4f9fcaa 100644 --- a/lib/sshagent/client.go +++ b/lib/sshagent/client.go @@ -17,6 +17,7 @@ package sshagent import ( + "bytes" "context" "errors" "io" @@ -89,6 +90,162 @@ func (c *client) Close() error { return trace.Wrap(err) } +// NewSingleRequestClient returns a client that opens a new connection for each request +// and closes it once the request completes. +// +// The returned agent is safe for concurrent use as long as getClient is. +func NewSingleRequestClient(getClient ClientGetter) agent.ExtendedAgent { + return singleRequestClient{getClient: getClient} +} + +type singleRequestClient struct { + getClient ClientGetter +} + +// withSingleRequestClient opens a new agent connection, runs fn against it, +// and closes the connection. +func withSingleRequestClient[T any](getClient ClientGetter, fn func(Client) (T, error)) (T, error) { + agentClient, err := getClient() + if err != nil { + var zero T + return zero, trace.Wrap(err) + } + defer agentClient.Close() + + out, err := fn(agentClient) + return out, trace.Wrap(err) +} + +// doWithSingleRequestClient is [withSingleRequestClient] for requests with no return value. +func doWithSingleRequestClient(getClient ClientGetter, fn func(Client) error) error { + _, err := withSingleRequestClient(getClient, func(agentClient Client) (struct{}, error) { + return struct{}{}, fn(agentClient) + }) + return trace.Wrap(err) +} + +func (s singleRequestClient) List() ([]*agent.Key, error) { + return withSingleRequestClient(s.getClient, func(agentClient Client) ([]*agent.Key, error) { + return agentClient.List() + }) +} + +func (s singleRequestClient) Sign(key ssh.PublicKey, data []byte) (*ssh.Signature, error) { + return withSingleRequestClient(s.getClient, func(agentClient Client) (*ssh.Signature, error) { + return agentClient.Sign(key, data) + }) +} + +func (s singleRequestClient) SignWithFlags(key ssh.PublicKey, data []byte, flags agent.SignatureFlags) (*ssh.Signature, error) { + return withSingleRequestClient(s.getClient, func(agentClient Client) (*ssh.Signature, error) { + return agentClient.SignWithFlags(key, data, flags) + }) +} + +func (s singleRequestClient) Add(key agent.AddedKey) error { + return doWithSingleRequestClient(s.getClient, func(agentClient Client) error { + return agentClient.Add(key) + }) +} + +func (s singleRequestClient) Remove(key ssh.PublicKey) error { + return doWithSingleRequestClient(s.getClient, func(agentClient Client) error { + return agentClient.Remove(key) + }) +} + +func (s singleRequestClient) RemoveAll() error { + return doWithSingleRequestClient(s.getClient, func(agentClient Client) error { + return agentClient.RemoveAll() + }) +} + +func (s singleRequestClient) Lock(passphrase []byte) error { + return doWithSingleRequestClient(s.getClient, func(agentClient Client) error { + return agentClient.Lock(passphrase) + }) +} + +func (s singleRequestClient) Unlock(passphrase []byte) error { + return doWithSingleRequestClient(s.getClient, func(agentClient Client) error { + return agentClient.Unlock(passphrase) + }) +} + +func (s singleRequestClient) Extension(extensionType string, contents []byte) ([]byte, error) { + return withSingleRequestClient(s.getClient, func(agentClient Client) ([]byte, error) { + return agentClient.Extension(extensionType, contents) + }) +} + +// Signers returns signers for all the keys currently known to the agent. +// The signers do not hold a connection open, they connect to the agent on demand +// for each signature. +func (s singleRequestClient) Signers() ([]ssh.Signer, error) { + keys, err := s.List() + if err != nil { + return nil, trace.Wrap(err) + } + + signers := make([]ssh.Signer, 0, len(keys)) + for _, key := range keys { + signers = append(signers, singleRequestSigner{getClient: s.getClient, pub: key}) + } + return signers, nil +} + +// singleRequestSigner is a signer for a key held by an agent that +// opens a new connection for each signature request. +type singleRequestSigner struct { + getClient ClientGetter + pub ssh.PublicKey +} + +var _ ssh.AlgorithmSigner = singleRequestSigner{} + +func (s singleRequestSigner) PublicKey() ssh.PublicKey { + return s.pub +} + +func (s singleRequestSigner) Sign(rand io.Reader, data []byte) (*ssh.Signature, error) { + // Note: the agent has its own entropy source, so the rand argument is ignored. + return withSingleRequestClient(s.getClient, func(agentClient Client) (*ssh.Signature, error) { + return agentClient.Sign(s.pub, data) + }) +} + +func (s singleRequestSigner) SignWithAlgorithm(rand io.Reader, data []byte, algorithm string) (*ssh.Signature, error) { + return withSingleRequestClient(s.getClient, func(agentClient Client) (*ssh.Signature, error) { + signer, err := agentSigner(agentClient, s.pub) + if err != nil { + return nil, trace.Wrap(err) + } + return signer.SignWithAlgorithm(rand, data, algorithm) + }) +} + +// agentSigner returns the agent's own signer for the given public key. +func agentSigner(agentClient Client, pub ssh.PublicKey) (ssh.AlgorithmSigner, error) { + signers, err := agentClient.Signers() + if err != nil { + return nil, trace.Wrap(err) + } + + pubBytes := pub.Marshal() + for _, signer := range signers { + if !bytes.Equal(signer.PublicKey().Marshal(), pubBytes) { + continue + } + algorithmSigner, ok := signer.(ssh.AlgorithmSigner) + if !ok { + return nil, trace.NotImplemented("agent signer of type %T does not support signing with a specific algorithm", signer) + } + return algorithmSigner, nil + } + + return nil, trace.NotFound("agent no longer holds the requested %v key", pub.Type()) +} + const channelType = "auth-agent@openssh.com" // ServeChannelRequests routes agent channel requests to a new agent diff --git a/lib/sshagent/client_test.go b/lib/sshagent/client_test.go index b0aa667c2e650..2cebd3000e68f 100644 --- a/lib/sshagent/client_test.go +++ b/lib/sshagent/client_test.go @@ -28,7 +28,9 @@ import ( "sync/atomic" "testing" "testing/synctest" + "time" + "github.com/gravitational/trace" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "golang.org/x/crypto/ssh" @@ -140,6 +142,106 @@ func TestSSHAgentClient(t *testing.T) { require.Error(t, err) } +// TestSingleRequestClient verifies that a single requeest agent client +// serves requests without keeping a connection to the agent open in between them. +func TestSingleRequestClient(t *testing.T) { + keyring, ok := agent.NewKeyring().(agent.ExtendedAgent) + require.True(t, ok) + + // Serve the keyring over a unix socket, keeping track of how many client + // connections are currently open. + var openConns atomic.Int32 + agentPath := filepath.Join(t.TempDir(), "agent.sock") + l, err := net.Listen("unix", agentPath) + require.NoError(t, err) + t.Cleanup(func() { l.Close() }) + + go func() { + for { + conn, err := l.Accept() + if err != nil { + return + } + openConns.Add(1) + go func() { + defer openConns.Add(-1) + defer conn.Close() + agent.ServeAgent(keyring, conn) + }() + } + }() + + requireNoOpenConns := func(t *testing.T) { + t.Helper() + // The server notices a closed connection asynchronously. + require.EventuallyWithT(t, func(t *assert.CollectT) { + assert.Zero(t, openConns.Load()) + }, time.Second*5, time.Millisecond*10, "single-request agent client leaked a connection to the agent") + } + + var dials atomic.Int32 + src := sshagent.NewSingleRequestClient(func() (sshagent.Client, error) { + dials.Add(1) + return sshagent.NewClient(func() (io.ReadWriteCloser, error) { + return net.Dial("unix", agentPath) + }) + }) + + // Creating the client should not connect to the agent. + require.Zero(t, dials.Load()) + requireNoOpenConns(t) + + pub, priv, err := ed25519.GenerateKey(rand.Reader) + require.NoError(t, err) + sshPub, err := ssh.NewPublicKey(pub) + require.NoError(t, err) + + require.NoError(t, src.Add(agent.AddedKey{PrivateKey: priv})) + requireNoOpenConns(t) + + keys, err := src.List() + require.NoError(t, err) + require.Len(t, keys, 1) + requireNoOpenConns(t) + + // Signers returned by a single-request client must remain usable + // after the request they were retrieved by has completed. + signers, err := src.Signers() + require.NoError(t, err) + require.Len(t, signers, 1) + requireNoOpenConns(t) + require.Equal(t, sshPub.Marshal(), signers[0].PublicKey().Marshal()) + + data := []byte("teleport") + sig, err := signers[0].Sign(rand.Reader, data) + require.NoError(t, err) + require.NoError(t, sshPub.Verify(data, sig)) + requireNoOpenConns(t) + + // An algorithm signer must delegate to the agent's own signer, which knows + // how to translate algorithm names into agent signature flags. + algorithmSigner, ok := signers[0].(ssh.AlgorithmSigner) + require.True(t, ok) + sig, err = algorithmSigner.SignWithAlgorithm(rand.Reader, data, ssh.KeyAlgoED25519) + require.NoError(t, err) + require.NoError(t, sshPub.Verify(data, sig)) + requireNoOpenConns(t) + + require.NoError(t, src.Remove(sshPub)) + keys, err = src.List() + require.NoError(t, err) + require.Empty(t, keys) + requireNoOpenConns(t) + + // Verify that the requests above actually opened connections. + require.Positive(t, dials.Load()) + + // The signer should detect when the key is gone from the agent. + _, err = algorithmSigner.SignWithAlgorithm(rand.Reader, data, ssh.KeyAlgoED25519) + require.True(t, trace.IsNotFound(err), "expected NotFound error, got %v", err) + requireNoOpenConns(t) +} + func TestConcurrentServeChannelRequests(t *testing.T) { synctest.Test(t, synctestConcurrentServeChannelRequests) } diff --git a/lib/sshagent/clientconn_windows.go b/lib/sshagent/clientconn_windows.go index 7c8fb6c7725f0..5310564c5f628 100644 --- a/lib/sshagent/clientconn_windows.go +++ b/lib/sshagent/clientconn_windows.go @@ -18,18 +18,8 @@ package sshagent import ( - "context" - "encoding/binary" - "encoding/hex" "io" - "log/slog" - "net" "os" - "os/exec" - "os/user" - "regexp" - "strconv" - "strings" "github.com/Microsoft/go-winio" "github.com/gravitational/trace" @@ -65,201 +55,3 @@ func DialSystemAgent() (io.ReadWriteCloser, error) { return nil, trace.Wrap(err) } - -// SIDs that are computer or domain SIDs start with this prefix. -const wellKnownSIDPrefix = "S-1-5-" - -var ( - // Format of the contents of a file created by Cygwin 'ssh-agent'. - // After '!', the listening port is specified, followed by - // an optional 's ' that is sometimes set depending on the implementation, - // ending with a GUID which is used as a shared secret when handshaking - // with the SSH agent. - // example: - // !51463 s 043B28B0-30D7E90E-027C556A-314067F9 - cygwinSocket = regexp.MustCompile(`!(\d+) (s )?([A-Fa-f0-9-]+)`) - // format of an output line from Cygwin 'ps' - // example: - // PID PPID PGID WINPID TTY UID STIME COMMAND - // 1634 1540 1634 7356 ? 197608 14:31:52 /usr/bin/ps - psLine = regexp.MustCompile(`(?m)^\s+\d+\s+\d+\s+\d+\s+\d+\s+\?\s+(\d+)`) -) - -// attempt to connect a Cygwin SSH agent socket. Some code adapted from -// https://github.com/abourget/secrets-bridge/blob/master/pkg/agentfwd/agentconn_windows.go -func dialCygwin(socket string) (net.Conn, error) { - // the "socket" is actually a file Cygwin uses to communicate what the - // actual socket is and the parameters for the handshake - contents, err := os.ReadFile(socket) - if err != nil { - return nil, trace.Wrap(err) - } - - sockMatches := cygwinSocket.FindStringSubmatch(string(contents)) - if len(sockMatches) != 4 { - return nil, trace.Errorf("could not find necessary information in Cygwin socket file") - } - port := sockMatches[1] - if sockMatches[2] != "s " { - return nil, trace.NotImplemented("dialing mysysgit ssh-agent sockets is not supported") - } - key := sockMatches[3] - - u, err := user.Current() - if err != nil { - return nil, trace.Wrap(err) - } - - var uid uint32 - var unsureOfUID bool - if !strings.HasPrefix(u.Uid, wellKnownSIDPrefix) { - // the format of the SID isn't supported, fallback to getting - // the UID from 'ps' - uid, err = getCygwinUIDFromPS() - if err != nil { - return nil, trace.Wrap(err) - } - } else { - // Attempt to get a Cygwin UID from a Windows SID. Details of - // UID -> SID mapping here: https://cygwin.com/cygwin-ug-net/ntsec.html - sidParts := strings.Split(u.Uid, "-") - if len(sidParts) == 4 { - // well-known SIDs in the NT_AUTHORITY domain of the S-1-5-RID type - u, err := strconv.ParseUint(sidParts[3], 10, 32) - if err != nil { - return nil, trace.Wrap(err) - } - uid = uint32(u) - } else if len(sidParts) == 5 { - // other well-known SIDs that aren't groups - x, err := strconv.ParseUint(sidParts[3], 10, 32) - if err != nil { - return nil, trace.Wrap(err) - } - rid, err := strconv.ParseUint(sidParts[4], 10, 32) - if err != nil { - return nil, trace.Wrap(err) - } - uid = uint32(0x1000*x + rid) - } else if len(sidParts) == 8 { - // SIDs from the local machine's SAM, the machine's primary - // domain, or a trusted domain of the machine's primary domain - u, err := strconv.ParseUint(sidParts[7], 10, 32) - if err != nil { - return nil, trace.Wrap(err) - } - uid = uint32(u) - unsureOfUID = true - } else { - // the format of the SID isn't supported, fallback to getting - // the UID from 'ps' - uid, err = getCygwinUIDFromPS() - if err != nil { - return nil, trace.Wrap(err) - } - } - } - - // dial socket and complete handshake - var conn net.Conn - if !unsureOfUID { - // we're confident in what the Cygwin UID is, only make one attempt - // at establishing a connection - conn, err = attemptCygwinHandshake(port, key, uid) - if err == nil { - return conn, nil - } - } else { - // the Cygwin UID could be built a few different ways; attempt - // with all UIDs until one succeeds - cygwinRIDNums := []uint32{0x30000, 0x100000, 0x80000000} - for _, num := range cygwinRIDNums { - conn, err = attemptCygwinHandshake(port, key, num+uid) - if err == nil { - return conn, nil - } - } - - // none of those UIDs worked, fallback to getting UID from 'ps' - uid, err = getCygwinUIDFromPS() - if err != nil { - return nil, trace.Wrap(err) - } - conn, err = attemptCygwinHandshake(port, key, uid) - if err != nil { - return nil, trace.Wrap(err) - } - } - - return conn, nil -} - -// use Cygwin 'ps' binary to get the Cygwin UID of the current user -func getCygwinUIDFromPS() (uint32, error) { - psOutput, err := exec.Command("ps").Output() - if err != nil { - return 0, trace.Wrap(err) - } - psMatches := psLine.FindStringSubmatch(string(psOutput)) - if len(psMatches) != 2 { - return 0, trace.Errorf("UID not found in Cygwin ps output") - } - uid, err := strconv.ParseUint(psMatches[1], 10, 32) - if err != nil { - return 0, trace.Wrap(err) - } - - return uint32(uid), nil -} - -// connect to a listening socket of a Cygwin SSH agent and attempt to -// preform a successful handshake with it. Handshake details here: -// https://stackoverflow.com/questions/23086038/what-mechanism-is-used-by-msys-cygwin-to-emulate-unix-domain-sockets -func attemptCygwinHandshake(port, key string, uid uint32) (net.Conn, error) { - slog.DebugContext(context.Background(), "[KEY AGENT] attempting a handshake with Cygwin ssh-agent socket", "port", port, "uid", uid) - - conn, err := net.Dial("tcp", "localhost:"+port) - if err != nil { - return nil, trace.Wrap(err) - } - - // 1. send hex-decoded GUID in little endian - keyBuf := make([]byte, 0, 16) - dst := make([]byte, 4) - // handle each part of the GUID in order - for i := 8; i <= len(key); i += 9 { - _, err := hex.Decode(dst, []byte(key)[i-8:i]) - if err != nil { - return nil, trace.Wrap(err) - } - dst[0], dst[1], dst[2], dst[3] = dst[3], dst[2], dst[1], dst[0] - keyBuf = append(keyBuf, dst...) - } - - if _, err = conn.Write(keyBuf); err != nil { - return nil, trace.Wrap(err) - } - - // 2. server echoes the same bytes, read them - if _, err = conn.Read(keyBuf); err != nil { - return nil, trace.Wrap(err) - } - - // 3. send PID, Cygwin UID and Cygwin GID of the calling process - pidsUids := make([]byte, 12) - pid := os.Getpid() - gid := pid // for cygwin's AF_UNIX -> AF_INET, pid = gid - binary.LittleEndian.PutUint32(pidsUids, uint32(pid)) - binary.LittleEndian.PutUint32(pidsUids[4:], uid) - binary.LittleEndian.PutUint32(pidsUids[8:], uint32(gid)) - if _, err = conn.Write(pidsUids); err != nil { - return nil, trace.Wrap(err) - } - - // 4. server echoes the same bytes, read them - if _, err = conn.Read(pidsUids); err != nil { - return nil, trace.Wrap(err) - } - - return conn, nil -} diff --git a/lib/sshagent/cygwin.go b/lib/sshagent/cygwin.go new file mode 100644 index 0000000000000..d1bfe2a3e06a8 --- /dev/null +++ b/lib/sshagent/cygwin.go @@ -0,0 +1,263 @@ +// Teleport +// Copyright (C) 2026 Gravitational, Inc. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package sshagent + +import ( + "context" + "encoding/binary" + "encoding/hex" + "log/slog" + "net" + "os" + "os/exec" + "os/user" + "regexp" + "strconv" + "strings" + "sync" + + "github.com/gravitational/trace" +) + +// SIDs that are computer or domain SIDs start with this prefix. +const wellKnownSIDPrefix = "S-1-5-" + +var ( + // Format of the contents of a file created by Cygwin 'ssh-agent'. + // After '!', the listening port is specified, followed by + // an optional 's ' that is sometimes set depending on the implementation, + // ending with a GUID which is used as a shared secret when handshaking + // with the SSH agent. + // example: + // !51463 s 043B28B0-30D7E90E-027C556A-314067F9 + cygwinSocket = regexp.MustCompile(`!(\d+) (s )?([A-Fa-f0-9-]+)`) + // format of an output line from Cygwin 'ps' + // example: + // PID PPID PGID WINPID TTY UID STIME COMMAND + // 1634 1540 1634 7356 ? 197608 14:31:52 /usr/bin/ps + psLine = regexp.MustCompile(`(?m)^\s+\d+\s+\d+\s+\d+\s+\d+\s+\?\s+(\d+)`) +) + +// cachedCygwinUID remembers the Cygwin UID that last completed a handshake +// successfully. Resolving the UID can take several handshake attempts and may +// shell out to Cygwin's 'ps', which is slow. +// +// Only the UID is cached. The port and shared secret are re-read from the +// socket file on every dial, so a restarted agent is picked up normally. +var cachedCygwinUID struct { + sync.Mutex + uid uint32 + known bool +} + +func loadCygwinUID() (uint32, bool) { + cachedCygwinUID.Lock() + defer cachedCygwinUID.Unlock() + return cachedCygwinUID.uid, cachedCygwinUID.known +} + +func storeCygwinUID(uid uint32, known bool) { + cachedCygwinUID.Lock() + defer cachedCygwinUID.Unlock() + cachedCygwinUID.uid, cachedCygwinUID.known = uid, known +} + +// attempt to connect a Cygwin SSH agent socket. Some code adapted from +// https://github.com/abourget/secrets-bridge/blob/master/pkg/agentfwd/agentconn_windows.go +func dialCygwin(socket string) (net.Conn, error) { + // the "socket" is actually a file Cygwin uses to communicate what the + // actual socket is and the parameters for the handshake + contents, err := os.ReadFile(socket) + if err != nil { + return nil, trace.Wrap(err) + } + + sockMatches := cygwinSocket.FindStringSubmatch(string(contents)) + if len(sockMatches) != 4 { + return nil, trace.Errorf("could not find necessary information in Cygwin socket file") + } + port := sockMatches[1] + if sockMatches[2] != "s " { + return nil, trace.NotImplemented("dialing mysysgit ssh-agent sockets is not supported") + } + key := sockMatches[3] + + // Try with a previously resolved UID before falling back to searching again, + // which may shell out to Cygwin's slow 'ps'. + if cached, ok := loadCygwinUID(); ok { + conn, err := attemptCygwinHandshake(port, key, cached) + if err == nil { + return conn, nil + } + storeCygwinUID(0, false) + } + + u, err := user.Current() + if err != nil { + return nil, trace.Wrap(err) + } + + var uid uint32 + var unsureOfUID bool + if !strings.HasPrefix(u.Uid, wellKnownSIDPrefix) { + // the format of the SID isn't supported, fallback to getting + // the UID from 'ps' + uid, err = getCygwinUIDFromPS() + if err != nil { + return nil, trace.Wrap(err) + } + } else { + // Attempt to get a Cygwin UID from a Windows SID. Details of + // UID -> SID mapping here: https://cygwin.com/cygwin-ug-net/ntsec.html + sidParts := strings.Split(u.Uid, "-") + if len(sidParts) == 4 { + // well-known SIDs in the NT_AUTHORITY domain of the S-1-5-RID type + u, err := strconv.ParseUint(sidParts[3], 10, 32) + if err != nil { + return nil, trace.Wrap(err) + } + uid = uint32(u) + } else if len(sidParts) == 5 { + // other well-known SIDs that aren't groups + x, err := strconv.ParseUint(sidParts[3], 10, 32) + if err != nil { + return nil, trace.Wrap(err) + } + rid, err := strconv.ParseUint(sidParts[4], 10, 32) + if err != nil { + return nil, trace.Wrap(err) + } + uid = uint32(0x1000*x + rid) + } else if len(sidParts) == 8 { + // SIDs from the local machine's SAM, the machine's primary + // domain, or a trusted domain of the machine's primary domain + u, err := strconv.ParseUint(sidParts[7], 10, 32) + if err != nil { + return nil, trace.Wrap(err) + } + uid = uint32(u) + unsureOfUID = true + } else { + // the format of the SID isn't supported, fallback to getting + // the UID from 'ps' + uid, err = getCygwinUIDFromPS() + if err != nil { + return nil, trace.Wrap(err) + } + } + } + + // dial socket and complete handshake + if !unsureOfUID { + // we're confident in what the Cygwin UID is, only make one attempt + // at establishing a connection + return attemptCygwinHandshake(port, key, uid) + } + + // the Cygwin UID could be built a few different ways; attempt + // with all UIDs until one succeeds + cygwinRIDNums := []uint32{0x30000, 0x100000, 0x80000000} + for _, num := range cygwinRIDNums { + conn, err := attemptCygwinHandshake(port, key, num+uid) + if err == nil { + return conn, nil + } + } + + // none of those UIDs worked, fallback to getting UID from 'ps' + uid, err = getCygwinUIDFromPS() + if err != nil { + return nil, trace.Wrap(err) + } + conn, err := attemptCygwinHandshake(port, key, uid) + return conn, trace.Wrap(err) +} + +// use Cygwin 'ps' binary to get the Cygwin UID of the current user +func getCygwinUIDFromPS() (uint32, error) { + psOutput, err := exec.Command("ps").Output() + if err != nil { + return 0, trace.Wrap(err) + } + psMatches := psLine.FindStringSubmatch(string(psOutput)) + if len(psMatches) != 2 { + return 0, trace.Errorf("UID not found in Cygwin ps output") + } + uid, err := strconv.ParseUint(psMatches[1], 10, 32) + if err != nil { + return 0, trace.Wrap(err) + } + + return uint32(uid), nil +} + +// connect to a listening socket of a Cygwin SSH agent and attempt to +// preform a successful handshake with it. Handshake details here: +// https://stackoverflow.com/questions/23086038/what-mechanism-is-used-by-msys-cygwin-to-emulate-unix-domain-sockets +// +// On success, the UID is cached in [cachedCygwinUID]. +func attemptCygwinHandshake(port, key string, uid uint32) (net.Conn, error) { + slog.DebugContext(context.Background(), "[KEY AGENT] attempting a handshake with Cygwin ssh-agent socket", "port", port, "uid", uid) + + conn, err := net.Dial("tcp", "localhost:"+port) + if err != nil { + return nil, trace.Wrap(err) + } + + // 1. send hex-decoded GUID in little endian + keyBuf := make([]byte, 0, 16) + dst := make([]byte, 4) + // handle each part of the GUID in order + for i := 8; i <= len(key); i += 9 { + _, err := hex.Decode(dst, []byte(key)[i-8:i]) + if err != nil { + return nil, trace.Wrap(err) + } + dst[0], dst[1], dst[2], dst[3] = dst[3], dst[2], dst[1], dst[0] + keyBuf = append(keyBuf, dst...) + } + + if _, err = conn.Write(keyBuf); err != nil { + return nil, trace.Wrap(err) + } + + // 2. server echoes the same bytes, read them + if _, err = conn.Read(keyBuf); err != nil { + return nil, trace.Wrap(err) + } + + // 3. send PID, Cygwin UID and Cygwin GID of the calling process + pidsUids := make([]byte, 12) + pid := os.Getpid() + gid := pid // for cygwin's AF_UNIX -> AF_INET, pid = gid + binary.LittleEndian.PutUint32(pidsUids, uint32(pid)) + binary.LittleEndian.PutUint32(pidsUids[4:], uid) + binary.LittleEndian.PutUint32(pidsUids[8:], uint32(gid)) + if _, err = conn.Write(pidsUids); err != nil { + return nil, trace.Wrap(err) + } + + // 4. server echoes the same bytes, read them + if _, err = conn.Read(pidsUids); err != nil { + return nil, trace.Wrap(err) + } + + // Remember the UID on success. + storeCygwinUID(uid, true) + + return conn, nil +} diff --git a/lib/sshagent/cygwin_test.go b/lib/sshagent/cygwin_test.go new file mode 100644 index 0000000000000..d03b3e48ce945 --- /dev/null +++ b/lib/sshagent/cygwin_test.go @@ -0,0 +1,193 @@ +// Teleport +// Copyright (C) 2026 Gravitational, Inc. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package sshagent + +import ( + "crypto/ed25519" + "crypto/rand" + "encoding/binary" + "fmt" + "io" + "net" + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/require" + "golang.org/x/crypto/ssh" + "golang.org/x/crypto/ssh/agent" +) + +// A valid Cygwin socket-file GUID: four 8-hex-digit groups separated by '-', +// which attemptCygwinHandshake decodes into the 16-byte shared secret. +const testCygwinGUID = "043B28B0-30D7E90E-027C556A-314067F9" + +// resetCygwinCache clears the process-global UID cache before and after a test +// so cases don't leak state into one another. +func resetCygwinCache(t *testing.T) { + t.Helper() + storeCygwinUID(0, false) + t.Cleanup(func() { storeCygwinUID(0, false) }) +} + +// newTestKeyring returns an in-memory agent holding a single key, along with +// that key's public half for assertions. +func newTestKeyring(t *testing.T) (agent.Agent, ssh.PublicKey) { + t.Helper() + keyring := agent.NewKeyring() + pub, priv, err := ed25519.GenerateKey(rand.Reader) + require.NoError(t, err) + require.NoError(t, keyring.Add(agent.AddedKey{PrivateKey: priv})) + sshPub, err := ssh.NewPublicKey(pub) + require.NoError(t, err) + return keyring, sshPub +} + +// startFakeCygwinAgent serves keyring behind the Cygwin AF_UNIX-over-TCP +// handshake. It returns the path to a Cygwin-style socket file describing the +// listener and the listener's port. accept decides whether a handshake +// presenting a given UID is allowed; a rejected handshake is dropped before the +// agent is served, mimicking a UID mismatch. +func startFakeCygwinAgent(t *testing.T, keyring agent.Agent, accept func(uid uint32) bool) (socketFile, port string) { + t.Helper() + + l, err := net.Listen("tcp", "127.0.0.1:0") + require.NoError(t, err) + t.Cleanup(func() { _ = l.Close() }) + + go func() { + for { + conn, err := l.Accept() + if err != nil { + return + } + go serveFakeCygwinConn(conn, keyring, accept) + } + }() + + port = fmt.Sprint(l.Addr().(*net.TCPAddr).Port) + socketFile = filepath.Join(t.TempDir(), "agent.sock") + contents := fmt.Sprintf("!%s s %s", port, testCygwinGUID) + require.NoError(t, os.WriteFile(socketFile, []byte(contents), 0o600)) + return socketFile, port +} + +// serveFakeCygwinConn performs the four-step Cygwin handshake, then hands the +// connection to the SSH agent server unless the presented UID is rejected. +func serveFakeCygwinConn(conn net.Conn, keyring agent.Agent, accept func(uid uint32) bool) { + defer conn.Close() + + // 1 + 2: read the 16-byte GUID and echo it back. + guid := make([]byte, 16) + if _, err := io.ReadFull(conn, guid); err != nil { + return + } + if _, err := conn.Write(guid); err != nil { + return + } + + // 3 + 4: read the 12-byte pid/uid/gid and echo it back, unless the UID is + // rejected, in which case the connection is dropped to fail the handshake. + ids := make([]byte, 12) + if _, err := io.ReadFull(conn, ids); err != nil { + return + } + uid := binary.LittleEndian.Uint32(ids[4:8]) + if accept != nil && !accept(uid) { + return + } + if _, err := conn.Write(ids); err != nil { + return + } + + agent.ServeAgent(keyring, conn) +} + +func acceptAnyUID(uint32) bool { return true } + +// requireLiveAgentConn asserts that conn speaks the SSH agent protocol and +// holds exactly the expected key. +func requireLiveAgentConn(t *testing.T, conn net.Conn, wantKey ssh.PublicKey) { + t.Helper() + keys, err := agent.NewClient(conn).List() + require.NoError(t, err) + require.Len(t, keys, 1) + require.Equal(t, wantKey.Marshal(), keys[0].Marshal()) +} + +// TestAttemptCygwinHandshake verifies the handshake wire protocol and that a +// successful handshake caches the UID that worked. +func TestAttemptCygwinHandshake(t *testing.T) { + resetCygwinCache(t) + keyring, key := newTestKeyring(t) + _, port := startFakeCygwinAgent(t, keyring, acceptAnyUID) + + const uid = 1234 + conn, err := attemptCygwinHandshake(port, testCygwinGUID, uid) + require.NoError(t, err) + t.Cleanup(func() { conn.Close() }) + + requireLiveAgentConn(t, conn, key) + + got, ok := loadCygwinUID() + require.True(t, ok, "a successful handshake should cache the UID") + require.Equal(t, uint32(uid), got) +} + +// TestDialCygwinUsesCachedUID verifies that dialCygwin connects using a cached +// UID and skips resolution entirely. On Unix, resolution can never succeed, so a +// successful dial proves the cache was used. +func TestDialCygwinUsesCachedUID(t *testing.T) { + resetCygwinCache(t) + keyring, key := newTestKeyring(t) + socketFile, _ := startFakeCygwinAgent(t, keyring, acceptAnyUID) + + // Without a cached UID, resolution runs and fails on Unix, so the dial fails. + _, err := dialCygwin(socketFile) + require.Error(t, err, "expected UID resolution to fail on a non-Cygwin host") + + // With a cached UID, dialCygwin skips resolution and connects. + storeCygwinUID(4321, true) + conn, err := dialCygwin(socketFile) + require.NoError(t, err) + t.Cleanup(func() { conn.Close() }) + + requireLiveAgentConn(t, conn, key) +} + +// TestDialCygwinEvictsStaleUID verifies that when a cached UID stops working, +// dialCygwin evicts it so the next dial does not retry the stale value. +func TestDialCygwinEvictsStaleUID(t *testing.T) { + resetCygwinCache(t) + keyring, _ := newTestKeyring(t) + + const staleUID = 9999 + socketFile, _ := startFakeCygwinAgent(t, keyring, func(uid uint32) bool { + return uid != staleUID + }) + + storeCygwinUID(staleUID, true) + + // The cached UID is rejected, so the cached handshake fails and dialCygwin + // falls back to resolution, which fails on Unix. + _, err := dialCygwin(socketFile) + require.Error(t, err) + + // The stale UID must have been evicted from the cache. + _, ok := loadCygwinUID() + require.False(t, ok, "a failing cached UID should be evicted") +}