Skip to content

Commit

Permalink
add pageant.go, tests, and README.md
Browse files Browse the repository at this point in the history
  • Loading branch information
kbolino committed Sep 19, 2018
1 parent 2a62d4c commit 373b5b1
Show file tree
Hide file tree
Showing 4 changed files with 375 additions and 0 deletions.
70 changes: 70 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -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.
220 changes: 220 additions & 0 deletions pageant.go
Original file line number Diff line number Diff line change
@@ -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
}
44 changes: 44 additions & 0 deletions pageant_ssh_test.go
Original file line number Diff line number Diff line change
@@ -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()
}
41 changes: 41 additions & 0 deletions pageant_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}

0 comments on commit 373b5b1

Please sign in to comment.