diff --git a/CMakeLists.txt b/CMakeLists.txt index a3d87e9..6083d21 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -58,7 +58,7 @@ endif() # Project version number set(PRJ_VERSION_Major "1") -set(PRJ_VERSION_Minor "1") +set(PRJ_VERSION_Minor "2") set(PRJ_VERSION_Patch "0") if (EXISTS "${PROJECT_SOURCE_DIR}/.git" AND IS_DIRECTORY "${PROJECT_SOURCE_DIR}/.git") diff --git a/README.md b/README.md index 72a38b5..c8a7137 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ Windows 10 has `ssh-agent` service (with support for persistence and Windows security) and I have been using it [successfully](https:/github.com/rupor-github/wsl-ssh-agent) for a while. However there is another set of tools entirely - [GnuPG](https://gnupg.org/). It implements `ssh-agent` functionality (with somewhat more flexibility than original), supports smart cards, attempts to handle identity aspects of security and sometimes *must* be used (for example to sign git commits on some projects). All of that works [reasonably well](https://eklitzke.org/using-gpg-agent-effectively) on Linux. -Windows usage is a bit more problematic as we have to deal with various non-cooperating pieces: GnuPG win32 binaries are somewhat deficient, OpenSSH port integrated into Windows 10 (console, terminal and all), WSL1 and WSL2 add challenges with specific binaries and different lifetime management requirements. Ideally we need to have Windows host to handle single set of secured keys (SSH and GPG) while transparently providing necessary interfaces to all other environments. This project aims to create simple set of tools to be combined with GnuPG binaries for Windows to do exactly that. +Windows usage is a bit more problematic as we have to deal with various non-cooperating pieces: GnuPG win32 binaries are somewhat deficient, OpenSSH port integrated into Windows 10 (console, terminal and all), Cygwin/MSYS2 ssh tools and WSL1 and WSL2 add challenges with specific binaries and different lifetime management requirements. Ideally we need to have Windows host to handle single set of secured keys (SSH and GPG) while transparently providing necessary interfaces to all other environments. This project aims to create simple set of tools to be combined with GnuPG binaries for Windows to do exactly that. **DISCLAIMER** When using term `GnuPG` I am **not referring** to [GPG4Win](https://gpg4win.org), but rather to basic GnuPG tools built from code base common for all platforms. GPG4Win includes this set (which could be extracted), but normally it is available from GnuPG ftp site [ftp://ftp.gnupg.org](ftp://ftp.gnupg.org/gcrypt/binary/). It also could be installed by using [chocolatey](https://chocolatey.org/) command `choco install gnupg`. So no wonderful KDE GUIs ported to Windows. @@ -41,7 +41,10 @@ Download from the [releases page](https://github.com/rupor-github/win-gpg-agent/ Set-Service -StartupType Disabled ssh-agent ``` -3. Run `agent-gui.exe` +3. If you would like to use Cygwin/MSYS2 ssh tools (as is the case by default with [Git4Windows](https://gitforwindows.org/)) you may want to consider placing `gui.openssh: cygwin` in agent-gui.conf file. **NOTE** that in any case you need to manage `SSH_AUTH_SOCK` environment variable value on Windows side. It has to point to named pipe for Windows OpenSSH to work and to Cygwin socket file for Cygwin/MSYS2 tools and __both sets are using the same variable name__. + +4. Run `agent-gui.exe` + Here is a diagram to show simplified relationship between parts: ![protocol](docs/pic1.png) @@ -68,8 +71,9 @@ Is is a simple "notification tray" applet which does `gpg-agent.exe` lifetime ma - make sure that gpg-agent will use `pinentry.exe` from the same directory where agent-gui.exe is. - make sure that it functions by communicating with it. - create AF_UNIX socket counterparts for Assuan sockets from gpg-agent (except "browser" and "ssh" ones) and handle translation. I have no use for "browser" and S.gpg-agent.ssh presently does not work on Windows. -- create and service named pipe for Windows native OpenSSH. Note, that both ssh AF_UNIX socket and named pipe are using pageant protocol to talk to gpg-agent. -- set environment variable `SSH_AUTH_SOCK` on Windows side and set it with proper pipe name so native OpenSSH tools know where to go. +- create and service named pipe for Windows native OpenSSH. Note, that OpenSSH (native and Cygwin) and AF_UNIX socket and named pipe are using pageant protocol to talk to gpg-agent. +- create and service Cygwin socket for Cygwin/MSYS2 build of OpenSSH. Note, that OpenSSH (native and Cygwin) and AF_UNIX socket and named pipe are using pageant protocol to talk to gpg-agent. +- set environment variable `SSH_AUTH_SOCK` on Windows side to point either to pipe name so native OpenSSH tools know where to go or to Cygwin socket file to be used with Cygwin/MSYS2 ssh binaries. - create `WIN_GNUPG_HOME`, `WSL_GNUPG_HOME`, `WIN_AGENT_HOME`, `WSL_AGENT_HOME` environment variables, setting them to point to directories with Assuan sockets and AF_UNIX sockets and register those environment variables with WSLENV for path translation. Basically WSL_* would be paths on the Linux side and WIN_* are Windows ones. This way every WSL environment started after will have proper "unix" and "windows" paths available for easy scripting. - serve as a backend for [gclpr](https://github.com/rupor-github/gclpr) remote clipboard tool (NOTE: starting with v1.1.0 gclpr server backend enforces protocol versioning and may require upgrade of gclpr). @@ -86,6 +90,7 @@ gpg: gui: debug: false setenv: true + openssh: native ignore_session_lock: false deadline: 1m pipe_name: \\\\.\\pipe\\openssh-ssh-agent @@ -102,6 +107,7 @@ Full list of configuration keys: * `gpg.gpg_agent_args` - array of additional arguments to be passed to gpg-agent on start. No checking is performed. * `gui.debug` - turn on debug logging. Uses `OutputDebugStringW` - use Sysinternals [debugview](https://docs.microsoft.com/en-us/sysinternals/downloads/debugview) to see * `gui.setenv` - automatically prepare environment variables +* `gui.openssh` - when value is `cygwin` set environment `SSH_AUTH_SOCK` on Windows side to point to Cygwin socket file rather then named pipe, so Cygwin and MSYS2 ssh build could be used instead of what comes with Windows 10. * `gui.ignore_session_lock` - continue to serve requests even if user session is locked * `gui.pipe_name` - full name of pipe for Windows OpenSSH * `gui.homedir` - directory to be used by agent-gui to create sockets in diff --git a/agent/agent.go b/agent/agent.go index 7cdf66c..296b48f 100644 --- a/agent/agent.go +++ b/agent/agent.go @@ -63,6 +63,7 @@ func NewAgent(cfg *config.Config) (*Agent, error) { a.conns[ConnectorSockAgentBrowser] = NewConnector(ConnectorSockAgentBrowser, a.Cfg.GPG.Home, a.Cfg.GUI.Home, util.SocketAgentBrowserName, locked, &a.wg) a.conns[ConnectorSockAgentSSH] = NewConnector(ConnectorSockAgentSSH, a.Cfg.GPG.Home, a.Cfg.GUI.Home, util.SocketAgentSSHName, locked, &a.wg) a.conns[ConnectorPipeSSH] = NewConnector(ConnectorPipeSSH, "", "", a.Cfg.GUI.PipeName, locked, &a.wg) + a.conns[ConnectorSockAgentCygwinSSH] = NewConnector(ConnectorSockAgentCygwinSSH, "", a.Cfg.GUI.Home, util.SocketAgentSSHCygwinName, locked, &a.wg) util.WaitForFileDeparture(time.Second*5, a.conns[ConnectorSockAgent].PathGPG(), @@ -194,6 +195,13 @@ func (a *Agent) Start() error { return nil } +func (a *Agent) GetConnector(ct ConnectorType) *Connector { + if a == nil || ct > maxConnector { + return nil + } + return a.conns[ct] +} + // Serve handles serving requests for a particular ConnectorType. func (a *Agent) Serve(ct ConnectorType) error { if a == nil || ct > maxConnector { diff --git a/agent/connector.go b/agent/connector.go index 2dd1664..e7ae7f5 100644 --- a/agent/connector.go +++ b/agent/connector.go @@ -34,6 +34,7 @@ const ( ConnectorSockAgentBrowser ConnectorSockAgentSSH ConnectorPipeSSH + ConnectorSockAgentCygwinSSH maxConnector ) @@ -49,6 +50,8 @@ func (ct ConnectorType) String() string { return "ssh-agent socket" case ConnectorPipeSSH: return "ssh-agent named pipe" + case ConnectorSockAgentCygwinSSH: + return "ssh-agent cygwin socket" default: } return fmt.Sprintf("unknown connector type %d", ct) @@ -84,11 +87,13 @@ func (c *Connector) Close() { } if err := c.listener.Close(); err != nil { if !util.IsNetClosing(err) && !errors.Is(err, winio.ErrPipeListenerClosed) { - log.Printf("Error closing connector for %s: %s", c.index, err) + log.Printf("Error closing listener on connector for %s: %s", c.index, err) } } if c.index != ConnectorPipeSSH && len(c.PathGUI()) != 0 { - _ = windows.Unlink(c.PathGUI()) + if err := os.Remove(c.PathGUI()); err != nil { + log.Printf("Error closing connector for %s: %s", c.index, err.Error()) + } } } @@ -118,6 +123,8 @@ func (c *Connector) Serve(deadline time.Duration) error { return c.serveSSHSocket() case ConnectorPipeSSH: return c.serveSSHPipe() + case ConnectorSockAgentCygwinSSH: + return c.serveSSHCygwinSocket() default: } log.Printf("Connector for %s is not supported", c.index) @@ -206,8 +213,7 @@ func (c *Connector) serveAssuanSocket(deadline time.Duration) error { _, err := os.Stat(socketName) if err == nil || !os.IsNotExist(err) { - err = windows.Unlink(socketName) - if err != nil { + if err = os.Remove(socketName); err != nil { return fmt.Errorf("failed to unlink socket %s: %w", socketName, err) } } @@ -284,8 +290,7 @@ func (c *Connector) serveSSHSocket() error { _, err := os.Stat(socketName) if err == nil || !os.IsNotExist(err) { - err = windows.Unlink(socketName) - if err != nil { + if err = os.Remove(socketName); err != nil { return fmt.Errorf("failed to unlink socket %s: %w", socketName, err) } } @@ -320,6 +325,62 @@ func (c *Connector) serveSSHSocket() error { return nil } +func (c *Connector) serveSSHCygwinSocket() error { + + if c == nil { + return fmt.Errorf("gpg agent has not been initialized properly") + } + socketName := c.PathGUI() + if len(socketName) > util.MaxNameLen { + return fmt.Errorf("socket name is too long: %d, max allowed: %d", len(socketName), util.MaxNameLen) + } + + _, err := os.Stat(socketName) + if err == nil || !os.IsNotExist(err) { + if err = os.Remove(socketName); err != nil { + return fmt.Errorf("failed to unlink socket %s: %w", socketName, err) + } + } + + c.listener, err = net.Listen("tcp", "localhost:0") + if err != nil { + return fmt.Errorf("could not open cygwin socket: %w", err) + } + + port := c.listener.Addr().(*net.TCPAddr).Port + nonce, err := util.CygwinCreateSocketFile(socketName, port) + if err != nil { + return err + } + + go func() { + log.Printf("Serving %s on %s:%d with nonce: %s)", c.index, socketName, port, util.CygwinNonceString(nonce)) + for { + conn, err := c.listener.Accept() + if err != nil { + if !util.IsNetClosing(err) { + log.Printf("Quiting - unable to serve on Cygwin socket: %s", err) + } + return + } + if err = util.CygwinPerformHandshake(conn, nonce); err != nil { + log.Printf("Unable to perform handshake on Cygwin socket: %s", err) + } + c.wg.Add(1) + go func() { + defer c.wg.Done() + defer conn.Close() + id := time.Now().UnixNano() // create unique id for debug tracing + log.Printf("[%d] Accepted request from %s", id, socketName) + if err := serveSSH(id, conn, c.locked); err != nil { + log.Printf("[%d] SSH handler returned error: %s", id, err.Error()) + } + }() + } + }() + return nil +} + func makeInheritSaWithSid() *windows.SecurityAttributes { var sa windows.SecurityAttributes u, err := user.Current() diff --git a/cmd/agent/main.go b/cmd/agent/main.go index 5b6a95d..5c92f00 100644 --- a/cmd/agent/main.go +++ b/cmd/agent/main.go @@ -94,18 +94,23 @@ func onSession(e systray.SessionEvent) { } } -func setVars() (func(), error) { +func setVars(native bool) (func(), error) { vars := []struct { initialized bool name, value string register, translate bool }{ + {name: envPipeName, value: gpgAgent.Cfg.GUI.PipeName, register: false, translate: false}, {name: "WSL_" + envGPGHomeName, value: gpgAgent.Cfg.GPG.Home, register: true, translate: true}, {name: "WIN_" + envGPGHomeName, value: util.PrepareWindowsPath(gpgAgent.Cfg.GPG.Home), register: true, translate: false}, {name: "WSL_" + envGUIHomeName, value: gpgAgent.Cfg.GUI.Home, register: true, translate: true}, {name: "WIN_" + envGUIHomeName, value: util.PrepareWindowsPath(gpgAgent.Cfg.GUI.Home), register: true, translate: false}, - {name: envPipeName, value: gpgAgent.Cfg.GUI.PipeName, register: false, translate: false}, + } + + if !native { + // set variable for Cygwin OpenSSH rather then for Windows OpenSSH + vars[0].value = gpgAgent.GetConnector(agent.ConnectorSockAgentCygwinSSH).PathGUI() } cleaner := func() { @@ -138,6 +143,12 @@ func run() error { // socket (WSL). NOTE: WSL2 requires additional layer of translation using socat on Linux side and either HYPER-V socket server or helper on Windows end // since AF_UNIX interop is not (yet? ever?) implemented. + // Transact on Cygwin socket for ssh Cygwin/MSYS ports + if err := gpgAgent.Serve(agent.ConnectorSockAgentCygwinSSH); err != nil { + return err + } + defer gpgAgent.Close(agent.ConnectorSockAgentCygwinSSH) + // Transact on pipe for Windows openssh if err := gpgAgent.Serve(agent.ConnectorPipeSSH); err != nil { return err @@ -163,7 +174,7 @@ func run() error { defer gpgAgent.Close(agent.ConnectorSockAgentExtra) if gpgAgent.Cfg.GUI.SetEnv { - cleaner, err := setVars() + cleaner, err := setVars(!strings.EqualFold(gpgAgent.Cfg.GUI.SSH, "cygwin")) if err != nil { return err } diff --git a/config/cfg.go b/config/cfg.go index 780d828..7da8b8b 100644 --- a/config/cfg.go +++ b/config/cfg.go @@ -38,6 +38,7 @@ type GUIConfig struct { Debug bool `yaml:"debug,omitempty"` SetEnv bool `yaml:"setenv,omitempty"` IgnoreSessionLock bool `yaml:"ignore_session_lock,omitempty"` + SSH string `yaml:"openssh,omitempty"` PipeName string `yaml:"pipe_name,omitempty"` Home string `yaml:"homedir,omitempty"` Deadline time.Duration `yaml:"deadline,omitempty"` @@ -49,6 +50,7 @@ var defaultGUIConfig = ` gui: debug: false setenv: true + openssh: windows ignore_session_lock: false deadline: 1m pipe_name: %s diff --git a/docs/pic1.png b/docs/pic1.png index 2201d37..fb3d5da 100644 Binary files a/docs/pic1.png and b/docs/pic1.png differ diff --git a/util/names.go b/util/names.go index 695634c..f07fd7c 100644 --- a/util/names.go +++ b/util/names.go @@ -1,6 +1,15 @@ package util import ( + "bytes" + "crypto/rand" + "encoding/binary" + "encoding/hex" + "errors" + "fmt" + "io" + "io/ioutil" + "log" "os" "path/filepath" "strings" @@ -19,11 +28,12 @@ const ( // Putty seems to have it at 8 + 1024. MaxAgentMsgLen = 256 * 1024 - GPGAgentName = "gpg-agent" - SocketAgentName = "S." + GPGAgentName - SocketAgentBrowserName = "S." + GPGAgentName + ".browser" - SocketAgentExtraName = "S." + GPGAgentName + ".extra" - SocketAgentSSHName = "S." + GPGAgentName + ".ssh" + GPGAgentName = "gpg-agent" + SocketAgentName = "S." + GPGAgentName + SocketAgentBrowserName = "S." + GPGAgentName + ".browser" + SocketAgentExtraName = "S." + GPGAgentName + ".extra" + SocketAgentSSHName = "S." + GPGAgentName + ".ssh" + SocketAgentSSHCygwinName = "S." + GPGAgentName + ".ssh.cyg" ) // PrepareWindowsPath prepares Windows path for use on unix shell line without quoting. @@ -105,3 +115,62 @@ func WaitForFileDeparture(period time.Duration, filenames ...string) { func IsNetClosing(err error) bool { return strings.Contains(err.Error(), "use of closed network connection") } + +// CygwinNonceString converts binary nonce to printable string in net order. +func CygwinNonceString(nonce [16]byte) string { + var buf [35]byte + dst := buf[:] + for i := 0; i < 4; i++ { + b := nonce[i*4 : i*4+4] + hex.Encode(dst[i*9:i*9+8], []byte{b[3], b[2], b[1], b[0]}) + if i != 3 { + dst[9*i+8] = '-' + } + } + return string(buf[:]) +} + +// CygwinCreateSocketFile creates CygWin socket file with proper content and attributes. +func CygwinCreateSocketFile(fname string, port int) (nonce [16]byte, err error) { + if _, err = rand.Read(nonce[:]); err != nil { + return + } + if err = ioutil.WriteFile(fname, []byte(fmt.Sprintf("!%d s %s", port, CygwinNonceString(nonce))), 0600); err != nil { + return + } + var cpath *uint16 + if cpath, err = windows.UTF16PtrFromString(fname); err != nil { + return + } + err = windows.SetFileAttributes(cpath, windows.FILE_ATTRIBUTE_SYSTEM|windows.FILE_ATTRIBUTE_READONLY) + return +} + +// CygwinPerformHandshake exchanges handshake data. +func CygwinPerformHandshake(conn io.ReadWriter, nonce [16]byte) error { + + var nonceR [16]byte + if _, err := conn.Read(nonceR[:]); err != nil { + return err + } + if !bytes.Equal(nonce[:], nonceR[:]) { + log.Printf("Wrong nonce received - expecting %x but got %x", nonce[:], nonceR[:]) + return errors.New("invalid nonce received") + } + if _, err := conn.Write(nonce[:]); err != nil { + return err + } + + // read client pid:uid:gid + buf := make([]byte, 12) + if _, err := conn.Read(buf); err != nil { + return err + } + + // Send back our info, making sure that gid:uid are the same as received + binary.LittleEndian.PutUint32(buf, uint32(os.Getpid())) + if _, err := conn.Write(buf); err != nil { + return err + } + return nil +}