├── .gitignore ├── openssh ├── platform.go ├── platform_windows.go └── openssh.go ├── signer ├── platform.go ├── platform_windows.go └── vault.go ├── test ├── Dockerfile └── init-dev.sh ├── .github ├── dependabot.yml └── workflows │ ├── release.yml │ └── codeql.yml ├── LICENSE ├── go.mod ├── .goreleaser.yml ├── agent ├── external.go └── internal.go ├── main.go ├── go.sum └── README.md /.gitignore: -------------------------------------------------------------------------------- 1 | *.exe 2 | dist 3 | vault-ssh-plus 4 | -------------------------------------------------------------------------------- /openssh/platform.go: -------------------------------------------------------------------------------- 1 | // +build !windows 2 | 3 | package openssh 4 | 5 | const clientBinary string = "ssh" 6 | -------------------------------------------------------------------------------- /signer/platform.go: -------------------------------------------------------------------------------- 1 | // +build !windows 2 | 3 | package signer 4 | 5 | const clientBinary string = "vault" 6 | -------------------------------------------------------------------------------- /openssh/platform_windows.go: -------------------------------------------------------------------------------- 1 | // +build windows 2 | 3 | package openssh 4 | 5 | const clientBinary string = "ssh.exe" 6 | -------------------------------------------------------------------------------- /signer/platform_windows.go: -------------------------------------------------------------------------------- 1 | // +build windows 2 | 3 | package signer 4 | 5 | const clientBinary string = "vault.exe" 6 | -------------------------------------------------------------------------------- /test/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM debian:latest 2 | 3 | RUN apt update \ 4 | && apt install -y openssh-server \ 5 | && service ssh start \ 6 | && useradd -m testuser 7 | 8 | EXPOSE 22 9 | 10 | CMD ["-o", "TrustedUserCAKeys=/etc/ssh/trusted-user-ca-keys"] 11 | ENTRYPOINT ["/usr/sbin/sshd", "-De"] 12 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # See GitHub's documentation for more information on this file: 2 | # https://docs.github.com/en/code-security/supply-chain-security/keeping-your-dependencies-updated-automatically/configuration-options-for-dependency-updates 3 | version: 2 4 | updates: 5 | - package-ecosystem: "github-actions" 6 | directory: "/" 7 | schedule: 8 | interval: "weekly" 9 | - package-ecosystem: "gomod" 10 | directory: "/" 11 | schedule: 12 | interval: "weekly" 13 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: goreleaser 2 | 3 | on: 4 | push: 5 | tags: 6 | - "*" 7 | 8 | jobs: 9 | goreleaser: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - name: Checkout 13 | uses: actions/checkout@v6 14 | with: 15 | fetch-depth: 0 16 | - name: Set up Go 17 | uses: actions/setup-go@v6 18 | with: 19 | go-version: stable 20 | - name: Run GoReleaser 21 | uses: goreleaser/goreleaser-action@v6 22 | with: 23 | version: latest 24 | args: release --clean 25 | env: 26 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 27 | HOMEBREW_TAP_GITHUB_TOKEN: ${{ secrets.HOMEBREW_TAP_GITHUB_TOKEN }} 28 | -------------------------------------------------------------------------------- /.github/workflows/codeql.yml: -------------------------------------------------------------------------------- 1 | name: "CodeQL" 2 | 3 | on: 4 | push: 5 | branches: ["main"] 6 | pull_request: 7 | branches: ["main"] 8 | schedule: 9 | - cron: "33 23 * * 5" 10 | 11 | jobs: 12 | analyze: 13 | name: Analyze 14 | runs-on: ubuntu-latest 15 | permissions: 16 | actions: read 17 | contents: read 18 | security-events: write 19 | 20 | strategy: 21 | fail-fast: false 22 | matrix: 23 | language: ["go"] 24 | 25 | steps: 26 | - name: Checkout repository 27 | uses: actions/checkout@v6 28 | 29 | - name: Initialize CodeQL 30 | uses: github/codeql-action/init@v4 31 | with: 32 | languages: ${{ matrix.language }} 33 | 34 | - name: Autobuild 35 | uses: github/codeql-action/autobuild@v4 36 | 37 | - name: Perform CodeQL Analysis 38 | uses: github/codeql-action/analyze@v4 39 | with: 40 | category: "/language:${{matrix.language}}" 41 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright © 2020 Robin Breathe 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 13 | all 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 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/isometry/vault-ssh-plus 2 | 3 | go 1.24.0 4 | 5 | toolchain go1.24.2 6 | 7 | require ( 8 | github.com/hashicorp/vault/api v1.22.0 9 | github.com/jessevdk/go-flags v1.6.1 10 | github.com/pkg/errors v0.9.1 11 | github.com/sirupsen/logrus v1.9.3 12 | golang.org/x/crypto v0.46.0 13 | golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0 14 | ) 15 | 16 | require ( 17 | github.com/cenkalti/backoff/v4 v4.3.0 // indirect 18 | github.com/go-jose/go-jose/v4 v4.1.1 // indirect 19 | github.com/hashicorp/errwrap v1.1.0 // indirect 20 | github.com/hashicorp/go-cleanhttp v0.5.2 // indirect 21 | github.com/hashicorp/go-multierror v1.1.1 // indirect 22 | github.com/hashicorp/go-retryablehttp v0.7.8 // indirect 23 | github.com/hashicorp/go-rootcerts v1.0.2 // indirect 24 | github.com/hashicorp/go-secure-stdlib/parseutil v0.2.0 // indirect 25 | github.com/hashicorp/go-secure-stdlib/strutil v0.1.2 // indirect 26 | github.com/hashicorp/go-sockaddr v1.0.7 // indirect 27 | github.com/hashicorp/hcl v1.0.1-vault-7 // indirect 28 | github.com/mitchellh/go-homedir v1.1.0 // indirect 29 | github.com/mitchellh/mapstructure v1.5.0 // indirect 30 | github.com/ryanuber/go-glob v1.0.0 // indirect 31 | golang.org/x/net v0.47.0 // indirect 32 | golang.org/x/sys v0.39.0 // indirect 33 | golang.org/x/text v0.32.0 // indirect 34 | golang.org/x/time v0.12.0 // indirect 35 | ) 36 | -------------------------------------------------------------------------------- /.goreleaser.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | before: 3 | hooks: 4 | - go mod tidy 5 | builds: 6 | - env: 7 | - CGO_ENABLED=0 8 | mod_timestamp: "{{ .CommitTimestamp }}" 9 | flags: 10 | - -trimpath 11 | ldflags: 12 | - "-s -w -X main.version={{.Version}} -X main.commit={{.Commit}} -X main.date={{.Date}}" 13 | goos: 14 | - freebsd 15 | - windows 16 | - linux 17 | - darwin 18 | goarch: 19 | - amd64 20 | - "386" 21 | - arm 22 | - arm64 23 | binary: vssh 24 | archives: 25 | - format: zip 26 | name_template: "{{ .ProjectName }}_{{ .Version }}_{{ .Os }}_{{ .Arch }}" 27 | snapshot: 28 | name_template: "{{ .Tag }}-next" 29 | checksum: 30 | name_template: "{{ .ProjectName }}_{{ .Version }}_SHA256SUMS" 31 | algorithm: sha256 32 | release: 33 | draft: false 34 | changelog: 35 | sort: asc 36 | filters: 37 | exclude: 38 | - "^docs:" 39 | - "^test:" 40 | brews: 41 | - repository: 42 | owner: isometry 43 | name: homebrew-tap 44 | token: "{{ .Env.HOMEBREW_TAP_GITHUB_TOKEN }}" 45 | directory: Formula 46 | description: Automatically use HashiCorp Vault SSH Client Key Signing with ssh(1) 47 | homepage: https://just.breathe.io/project/vault-ssh-plus/ 48 | caveats: | 49 | vssh requires the vault binary to be in your PATH. 50 | You can install it with: 51 | brew install hashicorp/tap/vault 52 | dependencies: 53 | - name: hashicorp/tap/vault 54 | type: optional 55 | test: | 56 | system "#{bin}/vssh --version" 57 | install: | 58 | bin.install "vssh" 59 | -------------------------------------------------------------------------------- /test/init-dev.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -m 4 | 5 | function interrupt_children() { 6 | [[ -z "$CONTAINER" ]] || docker stop $CONTAINER >/dev/null 7 | wait 8 | } 9 | 10 | trap interrupt_children SIGINT ERR EXIT 11 | 12 | env VAULT_ADDR=http://127.0.0.1:8200 vault server -dev -dev-root-token-id=root & 13 | sleep 1 14 | 15 | export VAULT_ADDR=http://127.0.0.1:8200 16 | 17 | vault secrets enable ssh 18 | vault write ssh/config/ca generate_signing_key=true 19 | vault read -field=public_key ssh/config/ca >trusted-user-ca-keys 20 | vault secrets tune -max-lease-ttl=300 ssh 21 | 22 | vault write ssh/roles/default - <<-EOH 23 | { 24 | "key_type": "ca", 25 | "allow_user_certificates": true, 26 | "algorithm_signer": "rsa-sha2-512", 27 | "allowed_users": "*", 28 | "default_extensions": [], 29 | "allowed_extensions": "permit-pty,permit-port-forwarding,permit-X11-forwarding", 30 | "ttl": "300" 31 | } 32 | EOH 33 | 34 | vault write ssh/roles/root - <<-EOH 35 | { 36 | "key_type": "ca", 37 | "allow_user_certificates": true, 38 | "algorithm_signer": "rsa-sha2-512", 39 | "default_user": "root", 40 | "allowed_users": "root", 41 | "default_extensions": [], 42 | "allowed_extensions": "permit-pty,permit-port-forwarding", 43 | "ttl": "60" 44 | } 45 | EOH 46 | 47 | echo "# Building vault-ssh-target image" 48 | docker image build -t vault-ssh-target . 49 | CONTAINER=$( 50 | docker container run --detach --rm \ 51 | --publish 2222:22 \ 52 | --volume "$PWD/trusted-user-ca-keys":/etc/ssh/trusted-user-ca-keys \ 53 | vault-ssh-target 54 | ) 55 | echo "# STARTED CONTAINER $CONTAINER" 56 | docker container attach --no-stdin --sig-proxy=false $CONTAINER & 57 | 58 | fg %1 59 | -------------------------------------------------------------------------------- /agent/external.go: -------------------------------------------------------------------------------- 1 | package agent 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "net" 7 | "os" 8 | "os/user" 9 | 10 | log "github.com/sirupsen/logrus" 11 | 12 | "golang.org/x/crypto/ssh" 13 | "golang.org/x/crypto/ssh/agent" 14 | "golang.org/x/exp/slices" 15 | ) 16 | 17 | var sshKeyTypeMap = map[string][]string{ 18 | "rsa": {ssh.KeyAlgoRSA}, 19 | "ec": {ssh.KeyAlgoECDSA256, ssh.KeyAlgoSKECDSA256, ssh.KeyAlgoECDSA384, ssh.KeyAlgoECDSA521}, 20 | "ed25519": {ssh.KeyAlgoED25519, ssh.KeyAlgoSKED25519}, 21 | "sk": {ssh.KeyAlgoSKECDSA256, ssh.KeyAlgoSKED25519}, 22 | } 23 | 24 | var supportedKeyTypes = []string{ 25 | ssh.KeyAlgoRSA, 26 | ssh.KeyAlgoECDSA256, 27 | ssh.KeyAlgoSKECDSA256, 28 | ssh.KeyAlgoECDSA384, 29 | ssh.KeyAlgoECDSA521, 30 | ssh.KeyAlgoED25519, 31 | ssh.KeyAlgoSKED25519, 32 | } 33 | 34 | func GetBestPublicKey(preferredType string) (publicKey []byte, err error) { 35 | prefKeyTypes, ok := sshKeyTypeMap[preferredType] 36 | if !ok { 37 | return nil, fmt.Errorf("invalid key type: %q", preferredType) 38 | } 39 | 40 | sock := os.Getenv("SSH_AUTH_SOCK") 41 | if sock == "" { 42 | currentUser, err := user.Current() 43 | if err != nil { 44 | return nil, err 45 | } 46 | sock = fmt.Sprintf("%s/.ssh/agent.sock", currentUser.HomeDir) 47 | } 48 | conn, err := net.Dial("unix", sock) 49 | if err != nil { 50 | return 51 | } 52 | defer conn.Close() 53 | 54 | keyring := agent.NewClient(conn) 55 | 56 | keys, err := keyring.List() 57 | if err != nil { 58 | return 59 | } 60 | 61 | var bestKey *agent.Key 62 | for _, key := range keys { 63 | keyType := key.Type() 64 | if bestKey == nil && slices.Contains(supportedKeyTypes, keyType) { 65 | bestKey = key 66 | } 67 | if slices.Contains(prefKeyTypes, keyType) { 68 | bestKey = key 69 | break 70 | } 71 | } 72 | if bestKey == nil { 73 | return nil, errors.New("no viable key found in external agent") 74 | } 75 | if !slices.Contains(prefKeyTypes, bestKey.Type()) { 76 | log.Infof("no key of type %q found, falling back to first key of type %q", preferredType, bestKey.Type()) 77 | } 78 | 79 | return []byte(bestKey.String()), nil 80 | } 81 | -------------------------------------------------------------------------------- /agent/internal.go: -------------------------------------------------------------------------------- 1 | package agent 2 | 3 | import ( 4 | "io" 5 | "net" 6 | "os" 7 | "path/filepath" 8 | 9 | log "github.com/sirupsen/logrus" 10 | 11 | "github.com/pkg/errors" 12 | "golang.org/x/crypto/ssh" 13 | "golang.org/x/crypto/ssh/agent" 14 | ) 15 | 16 | type InternalAgent struct { 17 | keyring agent.Agent 18 | socketDir string 19 | socketFile string 20 | listener net.Listener 21 | stop chan bool 22 | stopped chan bool 23 | } 24 | 25 | func NewInternalAgent() (ia *InternalAgent, err error) { 26 | socketDir, err := os.MkdirTemp("", "vssh-agent.*") 27 | if err != nil { 28 | return 29 | } 30 | 31 | ia = &InternalAgent{ 32 | keyring: agent.NewKeyring(), 33 | socketDir: socketDir, 34 | socketFile: filepath.Join(socketDir, "agent.sock"), 35 | stop: make(chan bool), 36 | stopped: make(chan bool), 37 | } 38 | 39 | ia.listener, err = net.Listen("unix", ia.socketFile) 40 | if err != nil { 41 | return nil, err 42 | } 43 | 44 | go ia.run() 45 | return ia, nil 46 | } 47 | 48 | func (ia *InternalAgent) run() { 49 | defer close(ia.stopped) 50 | for { 51 | select { 52 | case <-ia.stop: 53 | return 54 | default: 55 | conn, err := ia.listener.Accept() 56 | if err != nil { 57 | select { 58 | case <-ia.stop: 59 | return 60 | default: 61 | log.Fatalf("could not accept connection to agent %v", err) 62 | continue 63 | } 64 | } 65 | defer conn.Close() 66 | go func(c io.ReadWriter) { 67 | err := agent.ServeAgent(ia.keyring, c) 68 | if err != nil && !errors.Is(err, io.EOF) { 69 | log.Printf("could not serve ssh agent %v", err) 70 | } 71 | }(conn) 72 | } 73 | } 74 | } 75 | 76 | func (ia *InternalAgent) AddSignedKeyPair(privateKeyStr string, signedKeyStr string) (err error) { 77 | privateKey, err := ssh.ParseRawPrivateKey([]byte(privateKeyStr)) 78 | if err != nil { 79 | return errors.Wrap(err, "failed to parse private key") 80 | } 81 | 82 | signedKey, _, _, _, err := ssh.ParseAuthorizedKey([]byte(signedKeyStr)) 83 | if err != nil { 84 | return errors.Wrap(err, "failed to parse signed public key") 85 | } 86 | 87 | return ia.keyring.Add(agent.AddedKey{ 88 | PrivateKey: privateKey, 89 | Certificate: signedKey.(*ssh.Certificate), 90 | }) 91 | } 92 | 93 | func (ia *InternalAgent) Stop() { 94 | close(ia.stop) 95 | ia.listener.Close() 96 | <-ia.stopped 97 | os.RemoveAll(ia.socketDir) 98 | } 99 | 100 | func (ia *InternalAgent) SocketFile() string { 101 | return ia.socketFile 102 | } 103 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "os/exec" 7 | "os/signal" 8 | "syscall" 9 | 10 | log "github.com/sirupsen/logrus" 11 | 12 | "github.com/isometry/vault-ssh-plus/agent" 13 | "github.com/isometry/vault-ssh-plus/openssh" 14 | "github.com/isometry/vault-ssh-plus/signer" 15 | "github.com/jessevdk/go-flags" 16 | ) 17 | 18 | var ( 19 | version = "dev" 20 | commit = "none" 21 | date = "unknown" 22 | options struct { 23 | Signer signer.Options 24 | OpenSSH openssh.Options `group:"OpenSSH ssh(1) Options" hidden:"yes"` 25 | Version func() `long:"version" description:"Show version"` 26 | } 27 | ) 28 | 29 | func showVersion() { 30 | fmt.Printf("vault-ssh-plus v%s (%s), %s\n", version, commit, date) 31 | os.Exit(0) 32 | } 33 | 34 | func init() { 35 | options.Version = showVersion 36 | 37 | parser := flags.NewParser(&options, flags.Default) 38 | parser.Usage = "[options] destination [command]" 39 | if _, err := parser.ParseArgs(os.Args[1:]); err != nil { 40 | if flagsErr, ok := err.(*flags.Error); ok && flagsErr.Type == flags.ErrHelp { 41 | os.Exit(0) 42 | } else { 43 | os.Exit(1) 44 | } 45 | } 46 | 47 | log.SetFormatter(&log.TextFormatter{ 48 | DisableLevelTruncation: true, 49 | // DisableTimestamp: true, 50 | PadLevelText: false, 51 | }) 52 | if len(options.OpenSSH.Verbose) > 0 { 53 | log.SetLevel(log.DebugLevel) 54 | } 55 | } 56 | 57 | func main() { 58 | os.Exit(processCommand()) 59 | } 60 | 61 | func processCommand() int { 62 | var ( 63 | vaultClient signer.Client 64 | sshClient openssh.Client 65 | err error 66 | ) 67 | 68 | sshClient.Args, err = signer.ParseArgs(&vaultClient, os.Args[1:]) 69 | if err != nil { 70 | log.Fatal(err) 71 | } 72 | 73 | if err := sshClient.ParseConfig(); err != nil { 74 | log.Fatal("failed to parse ssh configuration: ", err) 75 | } 76 | 77 | roleDefaulted := defaultRoleToUser(&vaultClient, &sshClient) 78 | if roleDefaulted { 79 | log.Debugf("defaulted vault role to ssh username: %s", sshClient.User) 80 | } 81 | 82 | userOverridden := overrideUser(&vaultClient, &sshClient) 83 | if userOverridden { 84 | log.Infof("ssh username overridden by vault role: %s", sshClient.User) 85 | } 86 | 87 | // if we have already have a Control Connection, use it 88 | controlConnection := sshClient.ControlConnection() 89 | 90 | if !controlConnection && options.OpenSSH.ControlCommand != "exit" { 91 | updateRequestExtensions(&vaultClient.Options.Extensions, &sshClient.Extensions) 92 | 93 | log.Debugf("running in %q mode\n", vaultClient.Options.Mode) 94 | switch vaultClient.Options.Mode { 95 | case "issue": 96 | agent, err := agent.NewInternalAgent() 97 | if err != nil { 98 | log.Fatal("failed to start internal agent: ", err) 99 | } 100 | defer agent.Stop() 101 | 102 | privateKey, signedKey, err := vaultClient.GenerateSignedKeypair(sshClient.User) 103 | if err != nil { 104 | log.Fatal("failed to generate signed keypair: ", err) 105 | } 106 | 107 | if err := agent.AddSignedKeyPair(privateKey, signedKey); err != nil { 108 | log.Fatal("failed to add keypair to internal agent: ", err) 109 | } 110 | 111 | // override default ssh-agent socket 112 | os.Setenv("SSH_AUTH_SOCK", agent.SocketFile()) 113 | log.Debugf("set SSH_AUTH_SOCK to %q\n", agent.SocketFile()) 114 | if sshClient.ForceIdentityAgent { 115 | sshClient.PrependArgs([]string{"-o", "IdentityAgent=SSH_AUTH_SOCK"}) 116 | } 117 | 118 | case "sign": 119 | signedKey, err := vaultClient.SignKey(sshClient.User) 120 | if err != nil { 121 | log.Fatal("failed to get signed key: ", err) 122 | } 123 | 124 | if err := sshClient.SetSignedKey(signedKey); err != nil { 125 | log.Fatal("invalid certificate: ", err) 126 | } 127 | 128 | certificateFile, err := sshClient.WriteCertificateFile() 129 | if err != nil { 130 | log.Fatal("failed to write signed key to file: ", err) 131 | } 132 | 133 | // ensure the signedKeyFile is deleted if we're killed 134 | setupExitHandler(certificateFile) 135 | defer os.Remove(certificateFile) 136 | 137 | sshClient.PrependArgs([]string{"-o", fmt.Sprintf("CertificateFile=%s", certificateFile)}) 138 | } 139 | } 140 | 141 | log.WithFields(log.Fields{ 142 | "ssh-args": sshClient.Args, 143 | "reuse-control-connection": controlConnection, 144 | }).Debug() 145 | 146 | if err := sshClient.Connect(controlConnection); err != nil { 147 | if exitError, ok := err.(*exec.ExitError); ok { 148 | return exitError.ExitCode() 149 | } else { 150 | return 999 151 | } 152 | } 153 | 154 | return 0 155 | } 156 | 157 | func setupExitHandler(fn string) { 158 | s := make(chan os.Signal, 1) 159 | signal.Notify(s, os.Interrupt, syscall.SIGTERM, syscall.SIGQUIT) 160 | go func() { 161 | <-s 162 | _ = os.Remove(fn) 163 | os.Exit(0) 164 | }() 165 | } 166 | 167 | func defaultRoleToUser(vaultClient *signer.Client, sshClient *openssh.Client) bool { 168 | // if role hasn't been set already, default to resolved SSH username 169 | if vaultClient.Options.Role == "" { 170 | vaultClient.Options.Role = sshClient.User 171 | return true 172 | } 173 | return false 174 | } 175 | 176 | func overrideUser(vaultClient *signer.Client, sshClient *openssh.Client) bool { 177 | // if the role only allows a single, fixed user, use it 178 | if user := vaultClient.GetAllowedUser(); user != "" && sshClient.User != user { 179 | sshClient.User = user 180 | sshClient.PrependArgs([]string{"-l", user}) 181 | return true 182 | } 183 | return false 184 | } 185 | 186 | func updateRequestExtensions(reqExt *signer.Extensions, sshExt *openssh.Extensions) { 187 | if !reqExt.AgentForwarding && sshExt.AgentForwarding { 188 | reqExt.AgentForwarding = true 189 | } 190 | 191 | if !reqExt.NoPTY && sshExt.NoPTY { 192 | reqExt.NoPTY = true 193 | } 194 | 195 | if !reqExt.PortForwarding && sshExt.PortForwarding { 196 | reqExt.PortForwarding = true 197 | } 198 | 199 | if !reqExt.X11Forwarding && sshExt.X11Forwarding { 200 | reqExt.X11Forwarding = true 201 | } 202 | } 203 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8= 2 | github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= 3 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 4 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 5 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 6 | github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM= 7 | github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU= 8 | github.com/go-jose/go-jose/v4 v4.1.1 h1:JYhSgy4mXXzAdF3nUx3ygx347LRXJRrpgyU3adRmkAI= 9 | github.com/go-jose/go-jose/v4 v4.1.1/go.mod h1:BdsZGqgdO3b6tTc6LSE56wcDbMMLuPsw5d4ZD5f94kA= 10 | github.com/go-test/deep v1.1.1 h1:0r/53hagsehfO4bzD2Pgr/+RgHqhmf+k1Bpse2cTu1U= 11 | github.com/go-test/deep v1.1.1/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE= 12 | github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= 13 | github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I= 14 | github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= 15 | github.com/hashicorp/go-cleanhttp v0.5.2 h1:035FKYIWjmULyFRBKPs8TBQoi0x6d9G4xc9neXJWAZQ= 16 | github.com/hashicorp/go-cleanhttp v0.5.2/go.mod h1:kO/YDlP8L1346E6Sodw+PrpBSV4/SoxCXGY6BqNFT48= 17 | github.com/hashicorp/go-hclog v1.6.3 h1:Qr2kF+eVWjTiYmU7Y31tYlP1h0q/X3Nl3tPGdaB11/k= 18 | github.com/hashicorp/go-hclog v1.6.3/go.mod h1:W4Qnvbt70Wk/zYJryRzDRU/4r0kIg0PVHBcfoyhpF5M= 19 | github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo= 20 | github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM= 21 | github.com/hashicorp/go-retryablehttp v0.7.8 h1:ylXZWnqa7Lhqpk0L1P1LzDtGcCR0rPVUrx/c8Unxc48= 22 | github.com/hashicorp/go-retryablehttp v0.7.8/go.mod h1:rjiScheydd+CxvumBsIrFKlx3iS0jrZ7LvzFGFmuKbw= 23 | github.com/hashicorp/go-rootcerts v1.0.2 h1:jzhAVGtqPKbwpyCPELlgNWhE1znq+qwJtW5Oi2viEzc= 24 | github.com/hashicorp/go-rootcerts v1.0.2/go.mod h1:pqUvnprVnM5bf7AOirdbb01K4ccR319Vf4pU3K5EGc8= 25 | github.com/hashicorp/go-secure-stdlib/parseutil v0.2.0 h1:U+kC2dOhMFQctRfhK0gRctKAPTloZdMU5ZJxaesJ/VM= 26 | github.com/hashicorp/go-secure-stdlib/parseutil v0.2.0/go.mod h1:Ll013mhdmsVDuoIXVfBtvgGJsXDYkTw1kooNcoCXuE0= 27 | github.com/hashicorp/go-secure-stdlib/strutil v0.1.2 h1:kes8mmyCpxJsI7FTwtzRqEy9CdjCtrXrXGuOpxEA7Ts= 28 | github.com/hashicorp/go-secure-stdlib/strutil v0.1.2/go.mod h1:Gou2R9+il93BqX25LAKCLuM+y9U2T4hlwvT1yprcna4= 29 | github.com/hashicorp/go-sockaddr v1.0.7 h1:G+pTkSO01HpR5qCxg7lxfsFEZaG+C0VssTy/9dbT+Fw= 30 | github.com/hashicorp/go-sockaddr v1.0.7/go.mod h1:FZQbEYa1pxkQ7WLpyXJ6cbjpT8q0YgQaK/JakXqGyWw= 31 | github.com/hashicorp/hcl v1.0.1-vault-7 h1:ag5OxFVy3QYTFTJODRzTKVZ6xvdfLLCA1cy/Y6xGI0I= 32 | github.com/hashicorp/hcl v1.0.1-vault-7/go.mod h1:XYhtn6ijBSAj6n4YqAaf7RBPS4I06AItNorpy+MoQNM= 33 | github.com/hashicorp/vault/api v1.22.0 h1:+HYFquE35/B74fHoIeXlZIP2YADVboaPjaSicHEZiH0= 34 | github.com/hashicorp/vault/api v1.22.0/go.mod h1:IUZA2cDvr4Ok3+NtK2Oq/r+lJeXkeCrHRmqdyWfpmGM= 35 | github.com/jessevdk/go-flags v1.6.1 h1:Cvu5U8UGrLay1rZfv/zP7iLpSHGUZ/Ou68T0iX1bBK4= 36 | github.com/jessevdk/go-flags v1.6.1/go.mod h1:Mk8T1hIAWpOiJiHa9rJASDK2UGWji0EuPGBnNLMooyc= 37 | github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= 38 | github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= 39 | github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= 40 | github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= 41 | github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y= 42 | github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= 43 | github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= 44 | github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= 45 | github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= 46 | github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= 47 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 48 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 49 | github.com/ryanuber/go-glob v1.0.0 h1:iQh3xXAumdQ+4Ufa5b25cRpC5TYKlno6hsv6Cb3pkBk= 50 | github.com/ryanuber/go-glob v1.0.0/go.mod h1:807d1WSdnB0XRJzKNil9Om6lcp/3a0v4qIHxIXzX/Yc= 51 | github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= 52 | github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= 53 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 54 | github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 55 | github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= 56 | github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= 57 | golang.org/x/crypto v0.46.0 h1:cKRW/pmt1pKAfetfu+RCEvjvZkA9RimPbh7bhFjGVBU= 58 | golang.org/x/crypto v0.46.0/go.mod h1:Evb/oLKmMraqjZ2iQTwDwvCtJkczlDuTmdJXoZVzqU0= 59 | golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0 h1:R84qjqJb5nVJMxqWYb3np9L5ZsaDtB+a39EqjV0JSUM= 60 | golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0/go.mod h1:S9Xr4PYopiDyqSyp5NjCrhFrqg6A5zA2E/iPHPhqnS8= 61 | golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY= 62 | golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU= 63 | golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 64 | golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk= 65 | golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= 66 | golang.org/x/term v0.38.0 h1:PQ5pkm/rLO6HnxFR7N2lJHOZX6Kez5Y1gDSJla6jo7Q= 67 | golang.org/x/term v0.38.0/go.mod h1:bSEAKrOT1W+VSu9TSCMtoGEOUcKxOKgl3LE5QEF/xVg= 68 | golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU= 69 | golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY= 70 | golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE= 71 | golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg= 72 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 73 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 74 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 75 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 76 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # vault-ssh-plus (vssh) 2 | 3 | An enhanced implementation of [`vault ssh`](https://www.vaultproject.io/docs/commands/ssh), wrapping the OpenSSH `ssh` client to eliminate the management overhead of using of short-lived SSH client keys CA-signed by [@hashicorp Vault](https://www.vaultproject.io/). 4 | 5 | ## Features 6 | 7 | * Support for all [`ssh(1)`](https://man.openbsd.org/ssh.1) capabilities, including: 8 | * non-filesystem private keys (e.g. `gpg-agent`, PKCS#11, etc.); 9 | * arbitrary [`ssh_config(5)`](https://man.openbsd.org/ssh_config.5) configuration (e.g. `Host` aliases and `Match` clauses); 10 | * `ControlMaster` connection sharing. 11 | * Automatic and transparent just-in-time delivery of short-lived, CA-signed, single-use `ssh` client keys. 12 | * Adherence to the Principal of Least Privilege: by default, signed keys only permit the specific extensions required for the `ssh` options given. 13 | * Automatic username mapping for Vault roles with a single, fixed entry in `allowed_users` (e.g. `root`, `jenkins`, `ansible`). 14 | * Significantly lower memory overhead than `vault ssh`. 15 | 16 | ## Requirements 17 | 18 | * A [HashiCorp Vault](https://www.vaultproject.io/) instance configured for [SSH Client Key Signing](https://www.vaultproject.io/docs/secrets/ssh/signed-ssh-certificates.html#client-key-signing), access to an appropriate role, and an SSH server configured to trust the Vault CA. 19 | * An active Vault token (either in the `VAULT_TOKEN` environment variable, or – if the standard `vault` binary is available within `$PATH` – available from a Vault Token Helper). The `VAULT_ADDR` environment variable must also be set. 20 | * OpenSSH 7.2 or newer `ssh` client binary. 21 | 22 | ## Usage 23 | 24 | In addition to all the options accepted by [`ssh(1)`](https://man.openbsd.org/ssh.1), `vssh` accepts the following options: 25 | 26 | ```console 27 | $ vssh --help 28 | Usage: 29 | vssh [options] destination [command] 30 | 31 | Application Options: 32 | --mode=[sign|issue] Mode (default: issue) [$VAULT_SSH_MODE] 33 | --type=[rsa|ec|ed25519] Preferred key type (default: ed25519) [$VAULT_SSH_KEY_TYPE] 34 | --bits=[0|2048|3072|4096|256|384|521] Key bits for 'issue' mode (default: 0) [$VAULT_SSH_KEY_BITS] 35 | --path= Vault SSH mountpoint (default: ssh) [$VAULT_SSH_PATH] 36 | --role= Vault SSH role (default: ) [$VAULT_SSH_ROLE] 37 | --ttl= Vault SSH certificate TTL (default: 300) [$VAULT_SSH_TTL] 38 | -P, --public-key= Path to preferred public key for 'sign' mode [$VAULT_SSH_PUBLIC_KEY] 39 | --version Show version 40 | 41 | Certificate Extensions: 42 | --default-extensions Disable automatic extension calculation and request signer-default extensions [$VAULT_SSH_DEFAULT_EXTENSIONS] 43 | --agent-forwarding Force permit-agent-forwarding extension [$VAULT_SSH_AGENT_FORWARDING] 44 | --port-forwarding Force permit-port-forwarding extension [$VAULT_SSH_PORT_FORWARDING] 45 | --no-pty Force disable permit-pty extension [$VAULT_SSH_NO_PTY] 46 | --user-rc Enable permit-user-rc extension [$VAULT_SSH_USER_RC] 47 | --x11-forwarding Force permit-X11-forwarding extension [$VAULT_SSH_X11_FORWARDING] 48 | 49 | Help Options: 50 | -h, --help Show this help message 51 | ``` 52 | 53 | If you need to override the [SSH Client Key Signing](https://www.vaultproject.io/docs/secrets/ssh/signed-ssh-certificates.html#client-key-signing) mountpoint or role, this is most easily achieved by setting the `VAULT_SSH_PATH` and `VAULT_SSH_ROLE` environment variables in your shell rc. 54 | If your Vault SSH mountpoint isn't configured with a role matching the target SSH username, you *will* need to specify the Vault SSH role to use (e.g. `export VAULT_SSH_ROLE=self` or `vssh --role=self host` if you're using a role named `self` configured with templated `allowed_users`). 55 | 56 | In `issue` mode (the default), the client will retrieve an ephemeral keypair from Vault, exposed to `ssh(1)` via an internal SSH agent. 57 | 58 | In `sign` mode, the client will sign the public key specified, defaulting to the first key added into `ssh-agent(1)` (preferring the first of type matching `VAULT_SSH_KEY_TYPE`). 59 | 60 | The certificate will be requested with only those extensions required for the current command (default `permit-pty` unless `-N` is specified). Additional extensions may be requested (e.g. to support expected future multiplexed connections) with the "Certificate Extensions" arguments, or the Vault role default extensions may be forced with `--default-extensions`. 61 | 62 | ### Examples 63 | 64 | The following will request that an existing ed25519 public key be signed by the Vault signer at `https://vault.example.com:8200/v1/ssh-client-signer/sign/default`, with (automatic) `permit-pty` and `permit-port-forwarding` extensions to support the connection to `host.example.com`: 65 | 66 | ```console 67 | $ ssh-add ~/.ssh/id_ed25519 68 | $ export VAULT_ADDR=https://vault.example.com:8200 69 | $ export VAULT_SSH_PATH=ssh-client-signer 70 | $ export VAULT_SSH_ROLE=default 71 | $ export VAULT_SSH_MODE=sign 72 | $ vault login 73 | ... 74 | $ vssh -L8080:localhost:80 host.example.com 75 | ... 76 | ``` 77 | 78 | The following will request that an ephemeral ecdsa keypair with a (default) 256-bit private key be generated by the Vault issuer at `https://vault.example.com/v1/ssh/issue/root`, and used to run the `id` command on `host2.example.com` as `root`: 79 | 80 | ```console 81 | $ export VAULT_ADDR=https://vault.example.com 82 | $ export VAULT_SSH_KEY_TYPE=ec 83 | $ vault login 84 | ... 85 | $ vssh root@host2.example.com id 86 | uid=0(root) gid=0(wheel) groups=0(wheel),5(operator) 87 | ``` 88 | 89 | ## Installation 90 | 91 | ### Manual 92 | 93 | Download and extract the [latest release](https://github.com/isometry/vault-ssh-plus/releases/latest). 94 | 95 | ### macOS 96 | 97 | ```sh 98 | brew install isometry/tap/vault-ssh-plus 99 | ``` 100 | 101 | ### Ansible 102 | 103 | If you've already installed my [release-from-github](https://github.com/isometry/ansible-role-release-from-github) role: 104 | 105 | ```sh 106 | ansible -m import_role -a name=release-from-github -e release_repo=isometry/vault-ssh-plus -e release_hashicorp_style=yes localhost 107 | ``` 108 | 109 | ### Arch Linux 110 | 111 | vault-ssh-plus has been added to the AUR repository, and can be found at `https://aur.archlinux.org/packages/vault-ssh-plus-bin`. 112 | Either install via makepkg, or your favourite AUR helper. 113 | 114 | ### Nix / NixOS 115 | 116 | `vault-ssh-plus` is available in [nixpkgs](https://github.com/NixOS/nixpkgs): 117 | 118 | ```sh 119 | nix-env -iA nixpkgs.vault-ssh-plus 120 | ``` 121 | 122 | ## Troubleshooting 123 | 124 | Refer to the [Vault Documentation](https://www.vaultproject.io/docs/secrets/ssh/signed-ssh-certificates.html#troubleshooting) 125 | -------------------------------------------------------------------------------- /openssh/openssh.go: -------------------------------------------------------------------------------- 1 | package openssh 2 | 3 | import ( 4 | "bufio" 5 | "bytes" 6 | "os" 7 | "os/exec" 8 | "strings" 9 | "syscall" 10 | 11 | log "github.com/sirupsen/logrus" 12 | 13 | "github.com/pkg/errors" 14 | "golang.org/x/crypto/ssh" 15 | ) 16 | 17 | type Client struct { 18 | Args []string 19 | HostConfig []string 20 | User string 21 | Hostname string 22 | Extensions Extensions 23 | CertificateString string 24 | CertificateFile string 25 | CertificateObject *ssh.Certificate 26 | ForceIdentityAgent bool 27 | } 28 | 29 | // Options for https://man.openbsd.org/ssh.1; parsed simply to provide accurate Destination and RemoteCommand 30 | type Options struct { 31 | IPv4Only bool `short:"4" description:"Enable IPv4 only"` 32 | IPv6Only bool `short:"6" description:"Enable IPv6 only"` 33 | ForwardAgent bool `short:"A" description:"Enable agent forwarding"` 34 | NoForwardAgent bool `short:"a" description:"Disable agent forwarding"` 35 | BindInterface string `short:"B" description:"Bind interface"` 36 | BindAddress string `short:"b" description:"Bind address"` 37 | Compression bool `short:"C" description:"Enable compression"` 38 | CipherSpec string `short:"c" description:"Cipher specification"` 39 | DynamicForward []string `short:"D" description:"Dynamic port forwarding"` 40 | LogFile string `short:"E" description:"Log file"` 41 | EscapeChar string `short:"e" description:"Escape character"` 42 | ConfigFile string `short:"F" description:"Config file"` 43 | Background bool `short:"f" description:"Background before command execution"` 44 | PrintConfig bool `short:"G" description:"Print Configuration and Exit"` 45 | AllowRemoteToLocal bool `short:"g" description:"Allow remote hosts to connect to local forwarded ports"` 46 | PKCS11 string `short:"I" description:"PKCS#11 shared library"` 47 | IdentityFile []string `short:"i" description:"Identity file"` 48 | ProxyJump string `short:"J" description:"Jump host"` 49 | GSSAPIAuthentication bool `short:"K" description:"Enable GSSAPI auth and forwarding"` 50 | NoGSSAPIDelegation bool `short:"k" description:"Disable GSSAPI forwarding"` 51 | LocalForward []string `short:"L" description:"Local port forwarding"` 52 | LoginName string `short:"l" description:"Login name"` 53 | ControlMaster []bool `short:"M" description:"Master moder for connection sharing"` 54 | MacSpec string `short:"m" description:"Mac Specification"` 55 | NoRemoteCommand bool `short:"N" description:"Do not execute a remote command"` 56 | NullStdin bool `short:"n" description:"Redirect stdin from /dev/null"` 57 | ControlCommand string `short:"O" choice:"check" choice:"forward" choice:"cancel" choice:"exit" choice:"stop" description:"Send control command"` 58 | Option []string `short:"o" description:"Override configuration option"` 59 | Port uint16 `short:"p" default:"22" description:"Port"` 60 | QueryOption string `short:"Q" description:"Query supported algorithms"` 61 | Quiet bool `short:"q" description:"Quiet mode"` 62 | RemoteForward []string `short:"R" description:"Remote port forwarding"` 63 | ControlPath string `short:"S" description:"Control socket path"` 64 | Subsystem bool `short:"s" description:"Requent remote subsystem"` 65 | NoPTY bool `short:"T" description:"Disable pseudo-terminal allocation"` 66 | ForcePTY []bool `short:"t" description:"Force pseudo-terminal allocation"` 67 | Version bool `short:"V" description:"Display version"` 68 | Verbose []bool `short:"v" description:"Verbose mode"` 69 | StdinStdoutforwarding string `short:"W" description:"Forward stdin+stdout to remote host:port"` 70 | TunnelDevice string `short:"w" description:"Request tunnel device forwarding"` 71 | ForwardX11 bool `short:"X" description:"Enable X11 forwarding"` 72 | NoForwardX11 bool `short:"x" description:"Disable X11 forwarding"` 73 | ForwardX11Trusted bool `short:"Y" description:"Enable trusted X11 forwarding"` 74 | Syslog bool `short:"y" description:"Log to syslog(3)"` 75 | Positional Positional `positional-args:"yes"` 76 | } 77 | 78 | // Positional arguments for https://man.openbsd.org/ssh.1 79 | type Positional struct { 80 | Destination string `positional-arg-name:"destination" required:"true"` 81 | RemoteCommand []string `positional-arg-name:"command"` 82 | } 83 | 84 | type Extensions struct { 85 | AgentForwarding bool 86 | PortForwarding bool 87 | NoPTY bool 88 | UserRC bool 89 | X11Forwarding bool 90 | } 91 | 92 | // ParseConfig uses `ssh -G` to obtain a fully processed ssh_config(5), parse the result and update configuration accordingly 93 | func (c *Client) ParseConfig() error { 94 | cmdArgs := append([]string{"-G"}, c.Args...) 95 | cmd := exec.Command(clientBinary, cmdArgs...) 96 | 97 | output, err := cmd.Output() 98 | if err != nil { 99 | return errors.Wrap(err, "getting ssh_config") 100 | } 101 | 102 | scanner := bufio.NewScanner(bytes.NewReader(output)) 103 | 104 | for scanner.Scan() { 105 | split := strings.SplitN(scanner.Text(), " ", 2) 106 | key := strings.ToLower(split[0]) 107 | value := "" 108 | if len(split) == 2 { 109 | value = split[1] 110 | } 111 | 112 | switch key { 113 | case "user": 114 | c.User = value 115 | case "hostname": 116 | c.Hostname = value 117 | case "dynamicforward": 118 | c.Extensions.PortForwarding = true 119 | case "forwardagent": 120 | if value == "yes" { 121 | c.Extensions.AgentForwarding = true 122 | } 123 | case "forwardx11": 124 | if value == "yes" { 125 | c.Extensions.X11Forwarding = true 126 | } 127 | case "forwardx11trusted": 128 | if value == "yes" { 129 | c.Extensions.X11Forwarding = true 130 | } 131 | case "identityagent": 132 | c.ForceIdentityAgent = true 133 | case "localforward": 134 | c.Extensions.PortForwarding = true 135 | case "remoteforward": 136 | c.Extensions.PortForwarding = true 137 | case "requesttty": 138 | if value == "false" { 139 | c.Extensions.NoPTY = true 140 | } 141 | } 142 | } 143 | 144 | return nil 145 | } 146 | 147 | // ControlConnection checks for the existence of an active control connection 148 | func (c *Client) ControlConnection() bool { 149 | cmdArgs := append([]string{"-O", "check"}, c.Args...) 150 | cmd := exec.Command(clientBinary, cmdArgs...) 151 | _, err := cmd.Output() 152 | return (err == nil) 153 | } 154 | 155 | // PrependArgs prepends the specified arguments to the list to be passed to ssh(1) 156 | func (c *Client) PrependArgs(args []string) { 157 | c.Args = append(args, c.Args...) 158 | } 159 | 160 | // SetSignedKey sets Client.SignedKey 161 | func (c *Client) SetSignedKey(key string) (err error) { 162 | c.CertificateString = key 163 | c.CertificateObject, err = ParseSignedKey(key) 164 | 165 | return 166 | } 167 | 168 | // WriteCertificateFile writes an ephemeral certificate file to disk 169 | func (c *Client) WriteCertificateFile() (string, error) { 170 | signedKeyFile, err := os.CreateTemp("", "vssh-cert.*") 171 | if err != nil { 172 | return "", errors.Wrap(err, "creating temporary certificate file") 173 | } 174 | if _, err := signedKeyFile.Write([]byte(c.CertificateString)); err != nil { 175 | return "", errors.Wrap(err, "writing certificate to temporary file") 176 | } 177 | 178 | if err := signedKeyFile.Close(); err != nil { 179 | return "", errors.Wrap(err, "closing temporary certificate file") 180 | } 181 | log.Debugf("certificate file written to %q", signedKeyFile.Name()) 182 | 183 | return signedKeyFile.Name(), nil 184 | } 185 | 186 | // Connect establishes the ssh client connection 187 | func (c *Client) Connect(connectionSharing bool) error { 188 | // save some memory if we're connection sharing 189 | if connectionSharing { 190 | sshPath, err := exec.LookPath(clientBinary) 191 | if err != nil { 192 | return err 193 | } 194 | 195 | return syscall.Exec(sshPath, append([]string{clientBinary}, c.Args...), os.Environ()) 196 | } 197 | 198 | cmd := exec.Command(clientBinary, c.Args...) 199 | cmd.Stdin, cmd.Stdout, cmd.Stderr = os.Stdin, os.Stdout, os.Stderr 200 | 201 | return cmd.Run() 202 | } 203 | 204 | func ParseSignedKey(certificateString string) (*ssh.Certificate, error) { 205 | publicKey, _, _, _, err := ssh.ParseAuthorizedKey([]byte(certificateString)) 206 | if err != nil { 207 | return nil, err 208 | } 209 | 210 | certificate, ok := publicKey.(*ssh.Certificate) 211 | if !ok { 212 | return nil, errors.New("invalid certificate") 213 | } 214 | 215 | return certificate, nil 216 | } 217 | -------------------------------------------------------------------------------- /signer/vault.go: -------------------------------------------------------------------------------- 1 | package signer 2 | 3 | import ( 4 | "fmt" 5 | "io/ioutil" 6 | "os/exec" 7 | "os/user" 8 | "path/filepath" 9 | "strings" 10 | 11 | log "github.com/sirupsen/logrus" 12 | 13 | "github.com/hashicorp/vault/api" 14 | "github.com/isometry/vault-ssh-plus/agent" 15 | "github.com/jessevdk/go-flags" 16 | "github.com/pkg/errors" 17 | "golang.org/x/crypto/ssh" 18 | ) 19 | 20 | type Client struct { 21 | API *api.Client 22 | RoleConfig map[string]interface{} 23 | Options Options 24 | PublicKey []byte 25 | SignedKey string 26 | } 27 | 28 | // Options define signer-specific flags 29 | type Options struct { 30 | Mode string `long:"mode" choice:"sign" choice:"issue" default:"issue" env:"VAULT_SSH_MODE" description:"Mode"` 31 | Type string `long:"type" choice:"rsa" choice:"ec" choice:"ed25519" choice:"sk" default:"ed25519" env:"VAULT_SSH_KEY_TYPE" description:"Key type or preference for 'sign' mode"` 32 | Bits uint `long:"bits" choice:"0" choice:"2048" choice:"3072" choice:"4096" choice:"256" choice:"384" choice:"521" default:"0" env:"VAULT_SSH_KEY_BITS" description:"Key bits for 'issue' mode"` 33 | Path string `long:"path" default:"ssh" env:"VAULT_SSH_PATH" description:"Vault SSH mountpoint"` 34 | Role string `long:"role" env:"VAULT_SSH_ROLE" description:"Vault SSH role (default: )"` 35 | TTL uint `long:"ttl" default:"300" env:"VAULT_SSH_TTL" description:"Vault SSH certificate TTL"` 36 | PublicKey string `short:"P" long:"public-key" env:"VAULT_SSH_PUBLIC_KEY" description:"Path to preferred public key for 'sign' mode"` 37 | Extensions Extensions `group:"Certificate Extensions"` 38 | } 39 | 40 | // Extensions control what certificate extensions are required for the signed key 41 | type Extensions struct { 42 | Default bool `long:"default-extensions" env:"VAULT_SSH_DEFAULT_EXTENSIONS" description:"Disable automatic extension calculation and request signer-default extensions"` 43 | AgentForwarding bool `long:"agent-forwarding" env:"VAULT_SSH_AGENT_FORWARDING" description:"Force permit-agent-forwarding extension"` 44 | PortForwarding bool `long:"port-forwarding" env:"VAULT_SSH_PORT_FORWARDING" description:"Force permit-port-forwarding extension"` 45 | NoPTY bool `long:"no-pty" env:"VAULT_SSH_NO_PTY" description:"Force disable permit-pty extension"` 46 | UserRC bool `long:"user-rc" env:"VAULT_SSH_USER_RC" description:"Enable permit-user-rc extension"` 47 | X11Forwarding bool `long:"x11-forwarding" env:"VAULT_SSH_X11_FORWARDING" description:"Force permit-X11-forwarding extension"` 48 | } 49 | 50 | func ParseArgs(client *Client, args []string) (unparsedArgs []string, err error) { 51 | var options Options 52 | 53 | parser := flags.NewParser(&options, flags.PassDoubleDash|flags.IgnoreUnknown) 54 | unparsedArgs, err = parser.ParseArgs(args) 55 | if err != nil { 56 | return nil, errors.Wrap(err, "parsing arguments") 57 | } 58 | 59 | // explicitly setting a public key forces sign mode 60 | if options.PublicKey != "" { 61 | options.Mode = "sign" 62 | } 63 | 64 | if options.Mode == "issue" && options.Type == "sk" { 65 | return nil, errors.New("key type 'sk' incompatible with 'issue' mode") 66 | } 67 | 68 | if options.Mode == "sign" { 69 | if strings.HasPrefix(options.PublicKey, "~/") { 70 | currentUser, _ := user.Current() 71 | if err != nil { 72 | return nil, errors.Wrap(err, "getting current user") 73 | } 74 | 75 | options.PublicKey = filepath.Join(currentUser.HomeDir, options.PublicKey[2:]) 76 | } 77 | 78 | var publicKey []byte 79 | if options.PublicKey != "" { 80 | log.Debug("public key option set, reading file") 81 | publicKey, err = ioutil.ReadFile(options.PublicKey) 82 | if err != nil { 83 | return nil, errors.Wrap(err, "reading public key file") 84 | } 85 | } else { 86 | log.Debug("public key option NOT set, reading agent") 87 | publicKey, err = agent.GetBestPublicKey(options.Type) 88 | if err != nil { 89 | return nil, errors.Wrap(err, "finding agent key") 90 | } 91 | } 92 | 93 | if err := client.SetPublicKey(publicKey); err != nil { 94 | return nil, errors.Wrap(err, "setting public key") 95 | } 96 | } 97 | 98 | client.API, err = GetVaultClient() 99 | if err != nil { 100 | return nil, errors.Wrap(err, "initializing Vault client") 101 | } 102 | 103 | client.Options = options 104 | 105 | return unparsedArgs, nil 106 | } 107 | 108 | func (c *Client) SetPublicKey(publicKey []byte) (err error) { 109 | // publicKey, err := ioutil.ReadFile(fn) 110 | // if err != nil { 111 | // return errors.Wrap(err, "reading public key") 112 | // } 113 | 114 | _, _, _, _, err = ssh.ParseAuthorizedKey(publicKey) 115 | if err != nil { 116 | return errors.Wrap(err, "parsing public key") 117 | } 118 | 119 | c.PublicKey = publicKey 120 | 121 | return nil 122 | } 123 | 124 | // GetTokenFromHelper uses the standard vault client binary to retrieve the "current" default token, avoiding reimplementation of token_helper, etc. 125 | func GetTokenFromHelper() (string, error) { 126 | token, err := exec.Command(clientBinary, "read", "-field=id", "auth/token/lookup-self").Output() 127 | if err != nil { 128 | return "", errors.Wrap(err, "getting token from helper") 129 | } 130 | 131 | return string(token), nil 132 | } 133 | 134 | // GetVaultClient returns a full configured Vault API Client 135 | func GetVaultClient() (*api.Client, error) { 136 | vaultConfig := api.DefaultConfig() 137 | if err := vaultConfig.ReadEnvironment(); err != nil { 138 | return nil, err 139 | } 140 | 141 | vaultClient, err := api.NewClient(vaultConfig) 142 | if err != nil { 143 | return nil, err 144 | } 145 | 146 | vaultToken := vaultClient.Token() 147 | if vaultToken == "" { 148 | vaultToken, err = GetTokenFromHelper() 149 | if err != nil { 150 | return nil, err 151 | } 152 | } 153 | 154 | vaultClient.SetToken(vaultToken) 155 | 156 | return vaultClient, nil 157 | } 158 | 159 | func (c *Client) GetRoleData() map[string]interface{} { 160 | secret, err := c.API.Logical().Read(fmt.Sprintf("%s/roles/%s", c.Options.Path, c.Options.Role)) 161 | if err != nil || secret == nil { 162 | return nil 163 | } 164 | 165 | return secret.Data 166 | } 167 | 168 | func (c *Client) GetAllowedUser() string { 169 | roleData := c.GetRoleData() 170 | if roleData == nil { 171 | return "" 172 | } 173 | 174 | allowedUsersTemplate, ok := roleData["allowed_users_template"].(bool) 175 | if !ok || allowedUsersTemplate { 176 | return "" 177 | } 178 | 179 | allowedUsersString, ok := roleData["allowed_users"].(string) 180 | if !ok || allowedUsersString == "*" { 181 | return "" 182 | } 183 | 184 | allowedUsers := strings.Split(allowedUsersString, ",") 185 | if len(allowedUsers) != 1 { 186 | return "" 187 | } 188 | 189 | return allowedUsers[0] 190 | } 191 | 192 | // SignKey signs the configured public key, sets the SignedKey property to the filename of the signed key and returns the filename 193 | func (c *Client) SignKey(principal string) (string, error) { 194 | request := make(map[string]interface{}) 195 | 196 | request["public_key"] = string(c.PublicKey) 197 | request["valid_principals"] = principal 198 | request["ttl"] = c.Options.TTL 199 | 200 | if !c.Options.Extensions.Default { 201 | request["extensions"] = c.RequiredExtensions() 202 | } 203 | 204 | signedKeySecret, err := c.API.SSHWithMountPoint(c.Options.Path).SignKey(c.Options.Role, request) 205 | if err != nil { 206 | return "", err 207 | } 208 | 209 | c.SignedKey = signedKeySecret.Data["signed_key"].(string) 210 | 211 | return c.SignedKey, nil 212 | } 213 | 214 | // GenerateSignedKeypair gets a (private, signed) key-pair 215 | func (c *Client) GenerateSignedKeypair(principal string) (privateKey string, signedKey string, err error) { 216 | request := make(map[string]interface{}) 217 | 218 | request["cert_type"] = "user" 219 | request["key_type"] = c.Options.Type 220 | request["key_bits"] = c.Options.Bits 221 | request["valid_principals"] = principal 222 | request["ttl"] = c.Options.TTL 223 | 224 | if !c.Options.Extensions.Default { 225 | request["extensions"] = c.RequiredExtensions() 226 | } 227 | 228 | secret, err := c.API.Logical().Write(fmt.Sprintf("%s/issue/%s", c.Options.Path, c.Options.Role), request) 229 | if err != nil { 230 | return 231 | } 232 | 233 | privateKey = secret.Data["private_key"].(string) 234 | signedKey = secret.Data["signed_key"].(string) 235 | 236 | return 237 | } 238 | 239 | // RequiredExtensions calculates the required set of extensions to request based on the options set on Client 240 | func (c *Client) RequiredExtensions() map[string]string { 241 | extensions := map[string]string{} 242 | 243 | if !c.Options.Extensions.NoPTY { 244 | extensions["permit-pty"] = "" 245 | } 246 | 247 | if c.Options.Extensions.AgentForwarding { 248 | extensions["permit-agent-forwarding"] = "" 249 | } 250 | 251 | if c.Options.Extensions.PortForwarding { 252 | extensions["permit-port-forwarding"] = "" 253 | } 254 | 255 | if c.Options.Extensions.UserRC { 256 | extensions["permit-user-rc"] = "" 257 | } 258 | 259 | if c.Options.Extensions.X11Forwarding { 260 | extensions["permit-X11-forwarding"] = "" 261 | } 262 | 263 | return extensions 264 | } 265 | --------------------------------------------------------------------------------