├── .github ├── FUNDING.yml ├── dependabot.yml └── workflows │ ├── lint.yml │ └── build.yml ├── .gitignore ├── Makefile ├── go.mod ├── .goreleaser.yaml ├── README.md ├── go.sum ├── LICENSE.md ├── agent_test.go └── agent.go /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: [caarlos0] 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | coverage.txt 2 | bin 3 | card.png 4 | dist 5 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | SOURCE_FILES?=./... 2 | TEST_PATTERN?=. 3 | 4 | test: 5 | go test -v -failfast -race -coverpkg=./... -covermode=atomic -coverprofile=coverage.txt $(SOURCE_FILES) -run $(TEST_PATTERN) -timeout=2m 6 | .PHONY: test 7 | 8 | .DEFAULT_GOAL := test 9 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/caarlos0/go-sshagent 2 | 3 | go 1.24.0 4 | 5 | require ( 6 | github.com/caarlos0/sync v0.0.2 7 | github.com/charmbracelet/keygen v0.5.4 8 | golang.org/x/crypto v0.46.0 9 | ) 10 | 11 | require golang.org/x/sys v0.39.0 // indirect 12 | -------------------------------------------------------------------------------- /.goreleaser.yaml: -------------------------------------------------------------------------------- 1 | # The lines beneath this are called `modelines`. See `:help modeline` 2 | # Feel free to remove those if you don't want/use them. 3 | # yaml-language-server: $schema=https://goreleaser.com/static/schema-pro.json 4 | # vim: set ts=2 sw=2 tw=0 fo=cnqoj 5 | # 6 | version: 2 7 | includes: 8 | - from_url: 9 | url: caarlos0/goreleaserfiles/main/lib.yml 10 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: "gomod" 4 | directory: "/" 5 | schedule: 6 | interval: "daily" 7 | time: "08:00" 8 | labels: 9 | - "dependencies" 10 | - package-ecosystem: "github-actions" 11 | directory: "/" 12 | schedule: 13 | interval: "daily" 14 | time: "08:00" 15 | labels: 16 | - "dependencies" 17 | -------------------------------------------------------------------------------- /.github/workflows/lint.yml: -------------------------------------------------------------------------------- 1 | name: golangci-lint 2 | on: 3 | push: 4 | tags: 5 | - v* 6 | branches: 7 | - main 8 | pull_request: 9 | jobs: 10 | golangci: 11 | name: lint 12 | runs-on: ubuntu-latest 13 | steps: 14 | - uses: actions/checkout@v6 15 | - uses: actions/setup-go@v6 16 | with: 17 | go-version: stable 18 | - name: golangci-lint 19 | uses: golangci/golangci-lint-action@v9 20 | with: 21 | skip-go-installation: true 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # go-sshagent 2 | 3 | Package go-sshagent provides a simple SSH Agent implementation, written in Go, 4 | mainly to be used within tests. 5 | 6 | 7 | [![Build Status](https://img.shields.io/github/actions/workflow/status/caarlos0/go-sshagent/build.yml?style=for-the-badge)](https://github.com/caarlos0/go-sshagent/actions?workflow=build) 8 | [![Coverage Status](https://img.shields.io/codecov/c/gh/caarlos0/go-sshagent.svg?logo=codecov&style=for-the-badge)](https://codecov.io/gh/caarlos0/go-sshagent) 9 | [![](http://img.shields.io/badge/godoc-reference-5272B4.svg?style=for-the-badge)](https://pkg.go.dev/github.com/caarlos0/go-sshagent) 10 | 11 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/caarlos0/sync v0.0.2 h1:QtFo9aTmFTKI6F0l4S9JFJ6TAtJAuZpr/1w/dPTp7Bo= 2 | github.com/caarlos0/sync v0.0.2/go.mod h1:l0MiCF/ShK8f4ltZq6mZ1ynVl6QCL509vJkqqj5PyRA= 3 | github.com/charmbracelet/keygen v0.5.4 h1:XQYgf6UEaTGgQSSmiPpIQ78WfseNQp4Pz8N/c1OsrdA= 4 | github.com/charmbracelet/keygen v0.5.4/go.mod h1:t4oBRr41bvK7FaJsAaAQhhkUuHslzFXVjOBwA55CZNM= 5 | golang.org/x/crypto v0.46.0 h1:cKRW/pmt1pKAfetfu+RCEvjvZkA9RimPbh7bhFjGVBU= 6 | golang.org/x/crypto v0.46.0/go.mod h1:Evb/oLKmMraqjZ2iQTwDwvCtJkczlDuTmdJXoZVzqU0= 7 | golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk= 8 | golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= 9 | golang.org/x/term v0.38.0 h1:PQ5pkm/rLO6HnxFR7N2lJHOZX6Kez5Y1gDSJla6jo7Q= 10 | golang.org/x/term v0.38.0/go.mod h1:bSEAKrOT1W+VSu9TSCMtoGEOUcKxOKgl3LE5QEF/xVg= 11 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2022 Carlos Alexandro Becker 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: build 2 | 3 | on: 4 | push: 5 | branches: 6 | - "main" 7 | tags: 8 | - "v*" 9 | pull_request: 10 | 11 | jobs: 12 | build: 13 | strategy: 14 | matrix: 15 | os: [ubuntu-latest, macos-latest] 16 | runs-on: ${{ matrix.os }} 17 | steps: 18 | - uses: actions/checkout@v6 19 | with: 20 | fetch-depth: 0 21 | - uses: actions/setup-go@v6 22 | with: 23 | go-version: stable 24 | - run: make test 25 | - uses: codecov/codecov-action@v5 26 | if: matrix.os == 'ubuntu-latest' 27 | with: 28 | token: ${{ secrets.CODECOV_TOKEN }} 29 | file: ./coverage.txt 30 | - uses: goreleaser/goreleaser-action@v6 31 | if: success() && startsWith(github.ref, 'refs/tags/') && matrix.os == 'ubuntu-latest' 32 | with: 33 | version: latest 34 | distribution: goreleaser-pro 35 | args: release --rm-dist 36 | env: 37 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 38 | GORELEASER_KEY: ${{ secrets.GORELEASER_KEY }} 39 | dependabot: 40 | needs: [build] 41 | runs-on: ubuntu-latest 42 | permissions: 43 | pull-requests: write 44 | contents: write 45 | if: ${{ github.actor == 'dependabot[bot]' && github.event_name == 'pull_request'}} 46 | steps: 47 | - id: metadata 48 | uses: dependabot/fetch-metadata@v2 49 | with: 50 | github-token: "${{ secrets.GITHUB_TOKEN }}" 51 | - run: | 52 | gh pr review --approve "$PR_URL" 53 | gh pr merge --squash --auto "$PR_URL" 54 | env: 55 | PR_URL: ${{github.event.pull_request.html_url}} 56 | GITHUB_TOKEN: ${{secrets.GITHUB_TOKEN}} 57 | -------------------------------------------------------------------------------- /agent_test.go: -------------------------------------------------------------------------------- 1 | package sshagent_test 2 | 3 | import ( 4 | "errors" 5 | "os/exec" 6 | "path/filepath" 7 | "strings" 8 | "testing" 9 | "time" 10 | 11 | "github.com/caarlos0/go-sshagent" 12 | "github.com/charmbracelet/keygen" 13 | "golang.org/x/crypto/ssh" 14 | "golang.org/x/crypto/ssh/agent" 15 | ) 16 | 17 | func TestSSHAgent(t *testing.T) { 18 | if _, err := exec.LookPath("ssh-add"); err != nil { 19 | t.Skipf("ssh-add not present in PATH") 20 | } 21 | 22 | agt := setupAgetnt(t, makeSigner(t), makeSigner(t)) 23 | cmd := exec.Command("ssh-add", "-L") 24 | cmd.Env = append(cmd.Env, "SSH_AUTH_SOCK="+agt.Socket()) 25 | out, err := cmd.CombinedOutput() 26 | if err != nil { 27 | t.Fatal(err) 28 | } 29 | keys := strings.Split(strings.TrimSpace(string(out)), "\n") 30 | if l := len(keys); l != 2 { 31 | t.Errorf("expected 2 keys, got %d", l) 32 | } 33 | } 34 | 35 | func TestSign(t *testing.T) { 36 | signer := makeSigner(t) 37 | agt := setupAgetnt(t, signer) 38 | 39 | t.Run("signers", func(t *testing.T) { 40 | signers, err := agt.Signers() 41 | if err != nil { 42 | t.Fatal(err) 43 | } 44 | if l := len(signers); l != 1 { 45 | t.Errorf("expected 1 keys, got %d", l) 46 | } 47 | }) 48 | 49 | t.Run("sign with valid key", func(t *testing.T) { 50 | data := []byte("some data") 51 | sig, err := agt.Sign(signer.PublicKey(), data) 52 | if err != nil { 53 | t.Fatal(err) 54 | } 55 | if err := signer.PublicKey().Verify(data, sig); err != nil { 56 | t.Fatal(err) 57 | } 58 | }) 59 | 60 | t.Run("sign with invalid key", func(t *testing.T) { 61 | data := []byte("some data") 62 | _, err := agt.Sign(makeSigner(t).PublicKey(), data) 63 | if err == nil { 64 | t.Fatalf("expected an error, got nil") 65 | } 66 | }) 67 | } 68 | 69 | func TestUnsupportedOps(t *testing.T) { 70 | assertErr := func(tb testing.TB, err error) { 71 | eerr := sshagent.ErrUnsupportedOperation{} 72 | if !errors.As(err, &eerr) { 73 | t.Errorf("expected unsupported operation error, got %v", err) 74 | } 75 | } 76 | 77 | agt := sshagent.New(makeSigner(t)) 78 | 79 | t.Run("add", func(t *testing.T) { 80 | assertErr(t, agt.Add(agent.AddedKey{})) 81 | }) 82 | t.Run("remove", func(t *testing.T) { 83 | assertErr(t, agt.Remove(nil)) 84 | }) 85 | t.Run("remove all", func(t *testing.T) { 86 | assertErr(t, agt.RemoveAll()) 87 | }) 88 | t.Run("lock", func(t *testing.T) { 89 | assertErr(t, agt.Lock(nil)) 90 | }) 91 | t.Run("unlock", func(t *testing.T) { 92 | assertErr(t, agt.Unlock(nil)) 93 | }) 94 | 95 | t.Run("error", func(t *testing.T) { 96 | err := agt.RemoveAll() 97 | if err.Error() != "operation not supported: RemoveAll" { 98 | t.Errorf("unexpected error: %v", err) 99 | } 100 | }) 101 | } 102 | 103 | func makeSigner(tb testing.TB) ssh.Signer { 104 | tb.Helper() 105 | k, err := keygen.New(filepath.Join(tb.TempDir(), "key_ed25519"), keygen.WithKeyType(keygen.Ed25519)) 106 | if err != nil { 107 | tb.Fatal(err) 108 | } 109 | return k.Signer() 110 | } 111 | 112 | func setupAgetnt(tb testing.TB, signers ...ssh.Signer) *sshagent.Agent { 113 | agt := sshagent.New(signers...) 114 | go func() { 115 | _ = agt.Start() 116 | }() 117 | 118 | tb.Cleanup(func() { 119 | _ = agt.Close() 120 | }) 121 | 122 | for !agt.Ready() { 123 | time.Sleep(time.Millisecond * 100) 124 | } 125 | 126 | return agt 127 | } 128 | -------------------------------------------------------------------------------- /agent.go: -------------------------------------------------------------------------------- 1 | // Package sshagent provides an SSH agent implementation that's bootstraped with 2 | // the given signers, which cannot be changed. 3 | // 4 | // It is intended to be used on testing only. 5 | package sshagent 6 | 7 | import ( 8 | "bytes" 9 | "crypto/rand" 10 | "errors" 11 | "fmt" 12 | "io" 13 | "net" 14 | "os" 15 | "sync" 16 | 17 | "github.com/caarlos0/sync/erronce" 18 | "golang.org/x/crypto/ssh" 19 | "golang.org/x/crypto/ssh/agent" 20 | ) 21 | 22 | // New create a new agent with the given signers. 23 | func New(signers ...ssh.Signer) *Agent { 24 | return &Agent{ 25 | signers: signers, 26 | } 27 | } 28 | 29 | // Agent is the ssh agent implementation. 30 | type Agent struct { 31 | signers []ssh.Signer 32 | close func() error 33 | socket string 34 | mu sync.Mutex 35 | once erronce.ErrOnce 36 | } 37 | 38 | var _ agent.Agent = &Agent{} 39 | 40 | // Start the agent in a random socket. 41 | func (a *Agent) Start() error { 42 | return a.once.Do(func() error { 43 | f, err := os.CreateTemp(os.TempDir(), "agent.*") 44 | if err != nil { 45 | return fmt.Errorf("failed to create socket: %w", err) 46 | } 47 | if err := f.Close(); err != nil { 48 | return fmt.Errorf("failed to create socket: %w", err) 49 | } 50 | if err := os.Remove(f.Name()); err != nil { 51 | return fmt.Errorf("failed to create socket: %w", err) 52 | } 53 | 54 | sock := f.Name() 55 | l, err := net.Listen("unix", sock) 56 | if err != nil { 57 | return fmt.Errorf("failed to start listening: %w", err) 58 | } 59 | 60 | func() { 61 | a.mu.Lock() 62 | defer a.mu.Unlock() 63 | a.socket = sock 64 | a.close = l.Close 65 | }() 66 | 67 | for { 68 | c, err := l.Accept() 69 | if err != nil { 70 | if errors.Is(err, net.ErrClosed) { 71 | return nil 72 | } 73 | return fmt.Errorf("could not accept request: %w", err) 74 | } 75 | if err := agent.ServeAgent(a, c); err != nil && err != io.EOF { 76 | return fmt.Errorf("could not serve request: %w", err) 77 | } 78 | } 79 | }) 80 | } 81 | 82 | // Close the agent and cleanup. 83 | func (a *Agent) Close() error { 84 | a.mu.Lock() 85 | defer a.mu.Unlock() 86 | if a.close == nil { 87 | return nil 88 | } 89 | return a.close() 90 | } 91 | 92 | // Socket returns the unix socket address in which the agent is listening. 93 | func (a *Agent) Socket() string { 94 | a.mu.Lock() 95 | defer a.mu.Unlock() 96 | return a.socket 97 | } 98 | 99 | // Ready tells whether the agent is ready or not. 100 | func (a *Agent) Ready() bool { 101 | a.mu.Lock() 102 | defer a.mu.Unlock() 103 | return a.socket != "" 104 | } 105 | 106 | func (a *Agent) List() ([]*agent.Key, error) { 107 | result := make([]*agent.Key, 0, len(a.signers)) 108 | for _, k := range a.signers { 109 | result = append(result, &agent.Key{ 110 | Format: k.PublicKey().Type(), 111 | Blob: k.PublicKey().Marshal(), 112 | Comment: "", 113 | }) 114 | } 115 | return result, nil 116 | } 117 | 118 | func (a *Agent) Sign(key ssh.PublicKey, data []byte) (*ssh.Signature, error) { 119 | var signer ssh.Signer 120 | for _, s := range a.signers { 121 | if bytes.Equal(s.PublicKey().Marshal(), key.Marshal()) { 122 | signer = s 123 | break 124 | } 125 | } 126 | if signer == nil { 127 | return nil, fmt.Errorf("invalid key: %s", ssh.FingerprintSHA256(key)) 128 | } 129 | return signer.Sign(rand.Reader, data) 130 | } 131 | 132 | func (a *Agent) Signers() ([]ssh.Signer, error) { 133 | return a.signers, nil 134 | } 135 | 136 | // ErrUnsupportedOperation is returned on operations that are not implemented. 137 | type ErrUnsupportedOperation struct { 138 | Op string 139 | } 140 | 141 | func (e ErrUnsupportedOperation) Error() string { 142 | return fmt.Sprintf("operation not supported: %s", e.Op) 143 | } 144 | 145 | func (a *Agent) Add(key agent.AddedKey) error { return ErrUnsupportedOperation{"Add"} } 146 | func (a *Agent) Remove(key ssh.PublicKey) error { return ErrUnsupportedOperation{"Remove"} } 147 | func (a *Agent) RemoveAll() error { return ErrUnsupportedOperation{"RemoveAll"} } 148 | func (a *Agent) Lock(passphrase []byte) error { return ErrUnsupportedOperation{"Lock"} } 149 | func (a *Agent) Unlock(passphrase []byte) error { return ErrUnsupportedOperation{"Unlock"} } 150 | --------------------------------------------------------------------------------