From 373b5b1a7a43234eecda519f90df78acc3d8b90c Mon Sep 17 00:00:00 2001 From: Kristian Bolino Date: Tue, 18 Sep 2018 20:25:05 -0400 Subject: [PATCH] add pageant.go, tests, and README.md --- README.md | 70 ++++++++++++++ pageant.go | 220 ++++++++++++++++++++++++++++++++++++++++++++ pageant_ssh_test.go | 44 +++++++++ pageant_test.go | 41 +++++++++ 4 files changed, 375 insertions(+) create mode 100644 README.md create mode 100644 pageant.go create mode 100644 pageant_ssh_test.go create mode 100644 pageant_test.go diff --git a/README.md b/README.md new file mode 100644 index 0000000..f1fe5df --- /dev/null +++ b/README.md @@ -0,0 +1,70 @@ +# Go Pageant client + +This repository contains a library for Go that provides a native +[PuTTY][putty] Pageant SSH agent implementation compatible with the +[golang.org/x/crypto/ssh/agent][go-ssh-agent] package. + +This page, rather unsuprisingly, only works with Windows. +See below for alternatives on Unix/Linux platforms. + +[putty]: https://www.chiark.greenend.org.uk/~sgtatham/ +[go-ssh-agent]: https://godoc.org/golang.org/x/crypto/ssh/agent + +## Usage + +```golang +import ( + "golang.org/x/crypto/ssh" + "golang.org/x/crypto/ssh/agent" + "github.com/kbolino/pageant" +) + +func main() { + agentConn, err := pageant.NewConn() + if err != nil { + // failed to connect to Pageant + } + defer agentConn.Close() + sshAgent := agent.NewClient(agentConn) + signers, err := sshAgent.Signers() + if err != nil { + // failed to get signers from Pageant + } + config := ssh.ClientConfig{ + Auth: []ssh.AuthMethod{ssh.PublicKeys(signers...)}, + HostKeyCallback: ssh.InsecureIgnoreHostKey(), + User: "somebody", + } + sshConn, err := ssh.Dial("tcp", "someserver:22", &config) + if err != nil { + // failed to connect to SSH + } + defer sshConn.Close() + // now connected to SSH with public key auth from Pageant + // ... +} +``` + +## Unix/Linux Alternatives + +The `ssh-agent` command implements the same [SSH agent protocol][ssh-agent] +as Pageant, but over a Unix domain socket instead of shared memory. +The path to this socket is exposed through the environment variable +`SSH_AUTH_SOCK`. + +Replace the connection to Pageant with one to the socket: +```golang + // instead of this: + agentConn, err := pageant.NewConn() + // do this: + agentConn, err := net.Dial("unix", os.Getenv("SSH_AUTH_SOCK")) +``` + +[ssh-agent]: https://tools.ietf.org/html/draft-miller-ssh-agent-02 + +## Testing + +The standard tests require Pageant to be running and to have at least 1 +key loaded. +To test connecting to an SSH server, set the `sshtest` build flag and +see the comments in `pageant_ssh_test.go` for how to set up the test. \ No newline at end of file diff --git a/pageant.go b/pageant.go new file mode 100644 index 0000000..66d39fb --- /dev/null +++ b/pageant.go @@ -0,0 +1,220 @@ +// Package pageant provides native Go support for using PuTTY Pageant as an +// SSH agent with the golang.org/x/crypto/ssh/agent package. +// Based loosely on the Java JNA package jsch-agent-proxy-pageant. +package pageant + +import ( + "encoding/binary" + "fmt" + "io" + "reflect" + "syscall" + "unsafe" + + "golang.org/x/sys/windows" +) + +const ( + agentCopydataID = 0x804e50ba + agentMaxMsglen = 8192 + noError = syscall.Errno(0) + wmCopyData = 0x004a +) + +var ( + pageantWindowName = utf16Ptr("Pageant") + user32 = windows.NewLazySystemDLL("user32.dll") + findWindow = user32.NewProc("FindWindowW") + sendMessage = user32.NewProc("SendMessageW") +) + +// Conn is a shared-memory connection to Pageant. +// Conn implements io.Reader, io.Writer, and io.Closer. +// It is not safe to use Conn in multiple concurrent goroutines. +type Conn struct { + window windows.Handle + sharedFile windows.Handle + sharedMem uintptr + readOffset int + readLimit int + mapName string +} + +var _ io.ReadWriteCloser = &Conn{} + +// NewConn creates a new connection to Pageant. +// Ensure Close gets called on the returned Conn when it is no longer needed. +func NewConn() (*Conn, error) { + return &Conn{}, nil +} + +// Close frees resources used by Conn. +func (c *Conn) Close() error { + if c.sharedMem == 0 { + return nil + } + errUnmap := windows.UnmapViewOfFile(c.sharedMem) + errClose := windows.CloseHandle(c.sharedFile) + if errUnmap != nil { + return errUnmap + } else if errClose != nil { + return errClose + } + c.sharedMem = 0 + c.sharedFile = windows.InvalidHandle + return nil +} + +func (c *Conn) Read(p []byte) (n int, err error) { + if c.sharedMem == 0 { + return 0, fmt.Errorf("not connected to Pageant") + } else if c.readLimit == 0 { + return 0, fmt.Errorf("must send request to Pageant before reading response") + } else if c.readOffset == c.readLimit { + return 0, io.EOF + } + bytesToRead := minInt(len(p), int(c.readLimit-c.readOffset)) + src := toSlice(c.sharedMem+uintptr(c.readOffset), bytesToRead) + copy(p, src) + c.readOffset += bytesToRead + return bytesToRead, nil +} + +func (c *Conn) Write(p []byte) (n int, err error) { + if len(p) > agentMaxMsglen { + return 0, fmt.Errorf("size of message to send (%d) exceeds max length (%d)", len(p), agentMaxMsglen) + } else if len(p) == 0 { + return 0, fmt.Errorf("message to send is empty") + } + if c.sharedMem != 0 { + err := c.Close() + if c.sharedMem != 0 { + return 0, fmt.Errorf("failed to close previous connection: %s", err) + } + } + if err := c.establishConn(); err != nil { + return 0, fmt.Errorf("failed to connect to Pageant: %s", err) + } + dst := toSlice(c.sharedMem, len(p)) + copy(dst, p) + data := make([]byte, len(c.mapName)+1) + copy(data, c.mapName) + result, err := c.sendMessage(data) + if result == 0 { + if err != nil { + return 0, fmt.Errorf("failed to send request to Pageant: %s", err) + } else { + return 0, fmt.Errorf("request refused by Pageant") + } + } + messageSize := binary.BigEndian.Uint32(toSlice(c.sharedMem, 4)) + c.readOffset = 0 + c.readLimit = 4 + int(messageSize) + return len(p), nil +} + +// establishConn creates a new connection to Pageant. +func (c *Conn) establishConn() error { + window, _, err := findWindow.Call( + uintptr(unsafe.Pointer(pageantWindowName)), + uintptr(unsafe.Pointer(pageantWindowName)), + ) + if window == 0 { + if err != nil && err != noError { + return fmt.Errorf("cannot find Pageant window: %s", err) + } else { + return fmt.Errorf("cannot find Pageant window, ensure Pageant is running") + } + } + mapName := fmt.Sprintf("PageantRequest%08x", windows.GetCurrentThreadId()) + mapNameUTF16 := utf16Ptr(mapName) + sharedFile, err := windows.CreateFileMapping( + windows.InvalidHandle, + nil, + windows.PAGE_READWRITE, + 0, + agentMaxMsglen, + mapNameUTF16, + ) + if err != nil { + return fmt.Errorf("failed to create shared file: %s", err) + } + sharedMem, err := windows.MapViewOfFile( + sharedFile, + windows.FILE_MAP_WRITE, + 0, + 0, + 0, + ) + if err != nil { + return fmt.Errorf("failed to map file into shared memory: %s", err) + } + *c = Conn{ + window: windows.Handle(window), + sharedFile: sharedFile, + sharedMem: sharedMem, + mapName: mapName, + } + return nil +} + +// sendMessage invokes user32.SendMessage to alert Pageant that data +// is available for it to read. +func (c *Conn) sendMessage(data []byte) (uintptr, error) { + cds := copyData{ + dwData: agentCopydataID, + cbData: uintptr(len(data)), + lpData: uintptr(unsafe.Pointer(&data[0])), + } + result, _, err := sendMessage.Call( + uintptr(c.window), + wmCopyData, + 0, + uintptr(unsafe.Pointer(&cds)), + ) + if err == noError { + return result, nil + } + return result, err +} + +// copyData is equivalent to COPYDATASTRUCT. +// Unlike Java, Go has a native type that matches the bit width of the +// platform, so there is no need for separate 32-bit and 64-bit versions. +// Curiously, the MSDN definition of COPYDATASTRUCT says dwData is ULONG_PTR +// and cbData is DWORD, which seems to be backwards. +type copyData struct { + dwData uint32 + cbData uintptr + lpData uintptr +} + +// minInt returns the lesser of x and y. +func minInt(x, y int) int { + if x < y { + return x + } else { + return y + } +} + +// toSlice creates a fake slice header that allows copying to/from the block +// of memory from addr to addr+size. +func toSlice(addr uintptr, size int) []byte { + header := reflect.SliceHeader{ + Len: size, + Cap: size, + Data: addr, + } + return *(*[]byte)(unsafe.Pointer(&header)) +} + +// utf16Ptr converts a static string not containing any zero bytes to a +// sequence of UTF-16 code units, represented as a pointer to the first one. +func utf16Ptr(s string) *uint16 { + result, err := windows.UTF16PtrFromString(s) + if err != nil { + panic(err) + } + return result +} diff --git a/pageant_ssh_test.go b/pageant_ssh_test.go new file mode 100644 index 0000000..2692787 --- /dev/null +++ b/pageant_ssh_test.go @@ -0,0 +1,44 @@ +// +build sshtest + +package pageant + +import ( + "os" + "testing" + + "golang.org/x/crypto/ssh" + "golang.org/x/crypto/ssh/agent" +) + +// This test requires all of the following to work: +// - build tag sshtest is active +// - environment variable PAGEANT_TEST_SSH_ADDR is set to a valid SSH +// server address (host:port) +// - environment variable PAGEANT_TEST_SSH_USER is set to a user name +// that the SSH server recognizes +// - Pageant is running on the local machine +// - Pageant has a key that is authorized for the user on the server +func TestSSHConnect(t *testing.T) { + pageantConn, err := NewConn() + if err != nil { + t.Fatalf("error on NewConn: %s", err) + } + defer pageantConn.Close() + sshAgent := agent.NewClient(pageantConn) + signers, err := sshAgent.Signers() + if err != nil { + t.Fatalf("cannot obtain signers from SSH agent: %s", err) + } + sshUser := os.Getenv("PAGEANT_TEST_SSH_USER") + config := ssh.ClientConfig{ + Auth: []ssh.AuthMethod{ssh.PublicKeys(signers...)}, + HostKeyCallback: ssh.InsecureIgnoreHostKey(), + User: sshUser, + } + sshAddr := os.Getenv("PAGEANT_TEST_SSH_ADDR") + sshConn, err := ssh.Dial("tcp", sshAddr, &config) + if err != nil { + t.Fatalf("failed to connect to %s@%s due to error: %s", sshUser, sshAddr, err) + } + sshConn.Close() +} diff --git a/pageant_test.go b/pageant_test.go new file mode 100644 index 0000000..16fbbc1 --- /dev/null +++ b/pageant_test.go @@ -0,0 +1,41 @@ +package pageant + +import ( + "testing" + + "golang.org/x/crypto/ssh/agent" +) + +// Pageant must be running for this test to work. +func TestNewConn(t *testing.T) { + conn, err := NewConn() + if err != nil { + t.Fatalf("error on NewConn: %s", err) + } else if conn == nil { + t.Fatalf("NewConn returned nil") + } + err = conn.Close() + if err != nil { + t.Fatalf("error on Conn.Close: %s", err) + } +} + +// Pageant must be running and have at least 1 key loaded for this test to work. +func TestSSHAgentList(t *testing.T) { + conn, err := NewConn() + if err != nil { + t.Fatalf("error on NewConn: %s", err) + } + defer conn.Close() + sshAgent := agent.NewClient(conn) + keys, err := sshAgent.List() + if err != nil { + t.Fatalf("error on agent.List: %s", err) + } + if len(keys) == 0 { + t.Fatalf("no keys listed by Pagent") + } + for i, key := range keys { + t.Logf("key %d: %s", i, key.Comment) + } +}