├── LICENSE ├── README.md ├── contrib └── systemd │ └── user │ └── yubikey-agent.service ├── go.mod ├── go.sum ├── main.go ├── prompt_darwin.go ├── prompt_pinentry.go ├── setup.go └── systemd.md /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright 2020 Google LLC 2 | 3 | Redistribution and use in source and binary forms, with or without 4 | modification, are permitted provided that the following conditions are 5 | met: 6 | 7 | * Redistributions of source code must retain the above copyright 8 | notice, this list of conditions and the following disclaimer. 9 | * Redistributions in binary form must reproduce the above 10 | copyright notice, this list of conditions and the following disclaimer 11 | in the documentation and/or other materials provided with the 12 | distribution. 13 | * Neither the name of Google LLC nor the names of its 14 | contributors may be used to endorse or promote products derived from 15 | this software without specific prior written permission. 16 | 17 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS 18 | "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT 19 | LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR 20 | A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT 21 | OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, 22 | SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT 23 | LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, 24 | DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY 25 | THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 26 | (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 27 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 28 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # yubikey-agent 2 | 3 | yubikey-agent is a seamless ssh-agent for YubiKeys. 4 | 5 | * **Easy to use.** A one-command setup, one environment variable, and it just runs in the background. 6 | * **Indestructible.** Tolerates unplugging, sleep, and suspend. Never needs restarting. 7 | * **Compatible.** Provides a public key that works with all services and servers. 8 | * **Secure.** The key is generated on the YubiKey and can't be extracted. Every session requires the PIN, every login requires a touch. Setup takes care of PUK and management key. 9 | 10 | Written in pure Go, it's based on [github.com/go-piv/piv-go](https://github.com/go-piv/piv-go) and [golang.org/x/crypto/ssh](https://golang.org/x/crypto/ssh). 11 | 12 | ![](https://user-images.githubusercontent.com/1225294/81489747-63a03b00-9247-11ea-923a-b7434bcf7fd1.png) 13 | 14 | ## Installation 15 | 16 | ### macOS 17 | 18 | ``` 19 | brew install yubikey-agent 20 | brew services start yubikey-agent 21 | yubikey-agent -setup # generate a new key on the YubiKey 22 | ``` 23 | 24 | Then add the following line to your `~/.zshrc` and restart the shell. 25 | 26 | ``` 27 | export SSH_AUTH_SOCK="$(brew --prefix)/var/run/yubikey-agent.sock" 28 | ``` 29 | 30 | ### Linux 31 | 32 | #### Arch 33 | 34 | On Arch, use [the `yubikey-agent` package](https://aur.archlinux.org/packages/yubikey-agent/) from the AUR. 35 | 36 | ``` 37 | git clone https://aur.archlinux.org/yubikey-agent.git 38 | cd yubikey-agent && makepkg -si 39 | 40 | systemctl daemon-reload --user 41 | sudo systemctl enable --now pcscd.socket 42 | systemctl --user enable --now yubikey-agent 43 | 44 | export SSH_AUTH_SOCK="${XDG_RUNTIME_DIR}/yubikey-agent/yubikey-agent.sock" 45 | ``` 46 | 47 | #### NixOS / nixpkgs 48 | 49 | On NixOS unstable and 20.09 (unreleased at time of writing), you can 50 | add this to your `/etc/nixos/configuration.nix`: 51 | 52 | ``` 53 | services.yubikey-agent.enable = true; 54 | ``` 55 | 56 | This installs `yubikey-agent` and sets up a systemd unit to start 57 | yubikey-agent for you. 58 | 59 | On other systems using nix, you can also install from nixpkgs: 60 | 61 | ``` 62 | nix-env -iA nixpkgs.yubikey-agent 63 | ``` 64 | 65 | This installs the software but does *not* install a systemd unit. You 66 | will have to set up service management manually (see below). 67 | 68 | #### Other systemd-based Linux systems 69 | 70 | On other systemd-based Linux systems, follow [the manual installation instructions](systemd.md). 71 | 72 | Packaging contributions are very welcome. 73 | 74 | ### FreeBSD 75 | 76 | Install the [`yubikey-agent` port](https://svnweb.freebsd.org/ports/head/security/yubikey-agent/). 77 | 78 | ### Windows 79 | 80 | Windows support is currently WIP. 81 | 82 | ## Advanced topics 83 | 84 | ### Coexisting with other `ssh-agent`s 85 | 86 | It's possible to configure `ssh-agent`s on a per-host basis. 87 | 88 | For example to only use `yubikey-agent` when connecting to `example.com`, you'd add the following lines to `~/.ssh/config` instead of setting `SSH_AUTH_SOCK`. 89 | 90 | ``` 91 | Host example.com 92 | IdentityAgent /usr/local/var/run/yubikey-agent.sock 93 | ``` 94 | 95 | To use `yubikey-agent` for all hosts but one, you'd add the following lines instead. In both cases, you can keep using `ssh-add` to interact with the main `ssh-agent`. 96 | 97 | ``` 98 | Host example.com 99 | IdentityAgent $SSH_AUTH_SOCK 100 | 101 | Host * 102 | IdentityAgent /usr/local/var/run/yubikey-agent.sock 103 | ``` 104 | 105 | ### Conflicts with `gpg-agent` and Yubikey Manager 106 | 107 | `yubikey-agent` takes a persistent transaction so the YubiKey will cache the PIN after first use. Unfortunately, this makes the YubiKey PIV and PGP applets unavailable to any other applications, like `gpg-agent` and Yubikey Manager. Our upstream [is investigating solutions to this annoyance](https://github.com/go-piv/piv-go/issues/47). 108 | 109 | If you need `yubikey-agent` to release its lock on the YubiKey, send it a hangup signal or use `ssh-add`'s "delete all identities" flag. Likewise, you might have to kill `gpg-agent` after use for it to release its own lock. 110 | 111 | ``` 112 | ssh-add -D 113 | ``` 114 | 115 | This does not affect the FIDO2 functionality. 116 | 117 | ### Changing PIN and PUK 118 | 119 | Use YubiKey Manager to change the PIN and PUK. 120 | 121 | `yubikey-agent -setup` sets the PUK to the same value as the PIN. 122 | 123 | ``` 124 | killall -HUP yubikey-agent 125 | ykman piv access change-pin 126 | ykman piv access change-puk 127 | ``` 128 | 129 | ### Unblocking the PIN with the PUK 130 | 131 | If the wrong PIN is entered incorrectly three times in a row, YubiKey Manager can be used to unlock it. 132 | 133 | `yubikey-agent -setup` sets the PUK to the same value as the PIN. 134 | 135 | ``` 136 | ykman piv access unblock-pin 137 | ``` 138 | 139 | If the PUK is also entered incorrectly three times, the key is permanently irrecoverable. The YubiKey PIV applet can be reset with `yubikey-agent --setup --really-delete-all-piv-keys`. 140 | 141 | ### Manual setup and technical details 142 | 143 | `yubikey-agent` only officially supports YubiKeys set up with `yubikey-agent -setup`. 144 | 145 | In practice, any PIV token with an RSA or ECDSA P-256 key and certificate in the Authentication slot should work, with any PIN and touch policy. Simply skip the setup step and use `ssh-add -L` to view the public key. 146 | 147 | `yubikey-agent -setup` generates a random Management Key and [stores it in PIN-protected metadata](https://pkg.go.dev/github.com/go-piv/piv-go/piv?tab=doc#YubiKey.SetMetadata). 148 | 149 | ### Alternatives 150 | 151 | #### Native FIDO2 152 | 153 | Recent versions of OpenSSH [support using FIDO2 tokens directly](https://buttondown.email/cryptography-dispatches/archive/cryptography-dispatches-openssh-82-just-works/). Since those are their own key type, they require server-side support, which has only recently reached Debian and [GitHub](https://www.yubico.com/blog/github-now-supports-ssh-security-keys/). 154 | 155 | FIDO2 SSH keys by default don't require a PIN, and require a private key file, acting more like a second factor. `yubikey-agent` keys always require PINs and can be ported to a different machine simply by plugging in the YubiKey. (With recent enough tokens such as a YubiKey 5, a similar setup can be achieved by using the `verify-required` and `resident` options, after setting a FIDO2 PIN with YubiKey Manager: the private key file will still be required, but it can be regenerated from the YubiKey.) 156 | 157 | #### `gpg-agent` 158 | 159 | `gpg-agent` can act as an `ssh-agent`, and it can use keys stored on the PGP applet of a YubiKey. 160 | 161 | This requires a finicky setup process dealing with PGP keys and the `gpg` UX, and seems to lose track of the YubiKey and require restarting all the time. Frankly, I've also had enough of PGP and GnuPG. 162 | 163 | #### `ssh-agent` and PKCS#11 164 | 165 | `ssh-agent` can load PKCS#11 applets to interact with PIV tokens directly. There are two third-party PKCS#11 providers for YubiKeys (OpenSC and ykcs11) and one that ships with macOS (`man 8 ssh-keychain`). 166 | 167 | The UX of this solution is poor: it requires calling `ssh-add` to load the PKCS#11 module and to unlock it with the PIN (as the agent has no way of requesting input from the client during use, a limitation that `yubikey-agent` handles with `pinentry`), and needs manual reloading every time the YubiKey is unplugged or the machine goes to sleep. 168 | 169 | The ssh-agent that ships with macOS (which is pretty cool, as it starts on demand and is preconfigured in the environment) also has restrictions on where the `.so` modules can be loaded from. It can see through symlinks, so a Homebrew-installed `/usr/local/lib/libykcs11.dylib` won't work, while a hard copy at `/usr/local/lib/libykcs11.copy.dylib` will. 170 | 171 | `/usr/lib/ssh-keychain.dylib` works out of the box, but only with RSA keys. Key generation is undocumented. 172 | 173 | #### Secretive and SeKey 174 | 175 | [Secretive](https://github.com/maxgoedjen/secretive) and [SeKey](https://github.com/sekey/sekey) are similar projects that use the Secure Enclave to store the private key and Touch ID for authorization. The Secure Enclave has so far a worse security track record compared to YubiKeys. 176 | 177 | #### `pivy-agent` 178 | 179 | [`pivy-agent`](https://github.com/joyent/pivy#using-pivy-agent) is part of a suite of tools to work with PIV tokens. It's similar to `yubikey-agent`, and inspired its design. 180 | 181 | The main difference is that it requires unlocking via `ssh-add -X` rather than using a graphical pinentry, and it caches the PIN in memory rather than relying on the device PIN policy. It's also written in C. 182 | 183 | `yubikey-agent` also aims to provide an even smoother setup process. 184 | -------------------------------------------------------------------------------- /contrib/systemd/user/yubikey-agent.service: -------------------------------------------------------------------------------- 1 | [Unit] 2 | Description=Seamless ssh-agent for YubiKeys 3 | Documentation=https://filippo.io/yubikey-agent 4 | 5 | [Service] 6 | ExecStart=yubikey-agent -l %t/yubikey-agent/yubikey-agent.sock 7 | ExecReload=/bin/kill -HUP $MAINPID 8 | IPAddressDeny=any 9 | RestrictAddressFamilies=AF_UNIX 10 | RestrictNamespaces=yes 11 | RestrictRealtime=yes 12 | RestrictSUIDSGID=yes 13 | LockPersonality=yes 14 | SystemCallFilter=@system-service 15 | SystemCallFilter=~@privileged @resources 16 | SystemCallErrorNumber=EPERM 17 | SystemCallArchitectures=native 18 | NoNewPrivileges=yes 19 | KeyringMode=private 20 | UMask=0177 21 | RuntimeDirectory=yubikey-agent 22 | 23 | [Install] 24 | WantedBy=default.target 25 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module filippo.io/yubikey-agent 2 | 3 | go 1.19 4 | 5 | require ( 6 | github.com/go-piv/piv-go v1.10.0 7 | github.com/twpayne/go-pinentry-minimal v0.0.0-20220113210447-2a5dc4396c2a 8 | golang.org/x/crypto v0.4.0 9 | golang.org/x/term v0.3.0 10 | ) 11 | 12 | require golang.org/x/sys v0.3.0 // indirect 13 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/go-piv/piv-go v1.10.0 h1:P1Y1VjBI5DnXW0+YkKmTuh5opWnMIrKriUaIOblee9Q= 2 | github.com/go-piv/piv-go v1.10.0/go.mod h1:NZ2zmjVkfFaL/CF8cVQ/pXdXtuj110zEKGdJM6fJZZM= 3 | github.com/twpayne/go-pinentry-minimal v0.0.0-20220113210447-2a5dc4396c2a h1:a1bRrtgkiv0tytmDVXU5Dqse/WOTws7JvsY2WxPMZ6M= 4 | github.com/twpayne/go-pinentry-minimal v0.0.0-20220113210447-2a5dc4396c2a/go.mod h1:ARJJXqNuaxVS84jX6ST52hQh0TtuQZWABhTe95a6BI4= 5 | golang.org/x/crypto v0.4.0 h1:UVQgzMY87xqpKNgb+kDsll2Igd33HszWHFLmpaRMq/8= 6 | golang.org/x/crypto v0.4.0/go.mod h1:3quD/ATkf6oY+rnes5c3ExXTbLc8mueNue5/DoinL80= 7 | golang.org/x/sys v0.3.0 h1:w8ZOecv6NaNa/zC8944JTU3vz4u6Lagfk4RPQxv92NQ= 8 | golang.org/x/sys v0.3.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 9 | golang.org/x/term v0.3.0 h1:qoo4akIqOcDME5bhc/NgxUdovd6BSS2uMsVjB56q1xI= 10 | golang.org/x/term v0.3.0/go.mod h1:q750SLmJuPmVoN1blW3UFBPREJfb1KmY3vwxfr+nFDA= 11 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | // Copyright 2020 Google LLC 2 | // 3 | // Use of this source code is governed by a BSD-style 4 | // license that can be found in the LICENSE file or at 5 | // https://developers.google.com/open-source/licenses/bsd 6 | 7 | package main 8 | 9 | import ( 10 | "bytes" 11 | "context" 12 | "crypto/ecdsa" 13 | "crypto/rand" 14 | "crypto/rsa" 15 | "errors" 16 | "flag" 17 | "fmt" 18 | "io" 19 | "log" 20 | "net" 21 | "os" 22 | "os/exec" 23 | "os/signal" 24 | "path/filepath" 25 | "runtime" 26 | "strings" 27 | "sync" 28 | "syscall" 29 | "time" 30 | 31 | "github.com/go-piv/piv-go/piv" 32 | "golang.org/x/crypto/ssh" 33 | "golang.org/x/crypto/ssh/agent" 34 | "golang.org/x/crypto/ssh/terminal" 35 | ) 36 | 37 | func main() { 38 | flag.Usage = func() { 39 | fmt.Fprintf(os.Stderr, "Usage of yubikey-agent:\n") 40 | fmt.Fprintf(os.Stderr, "\n") 41 | fmt.Fprintf(os.Stderr, "\tyubikey-agent -setup\n") 42 | fmt.Fprintf(os.Stderr, "\n") 43 | fmt.Fprintf(os.Stderr, "\t\tGenerate a new SSH key on the attached YubiKey.\n") 44 | fmt.Fprintf(os.Stderr, "\n") 45 | fmt.Fprintf(os.Stderr, "\tyubikey-agent -l PATH\n") 46 | fmt.Fprintf(os.Stderr, "\n") 47 | fmt.Fprintf(os.Stderr, "\t\tRun the agent, listening on the UNIX socket at PATH.\n") 48 | fmt.Fprintf(os.Stderr, "\n") 49 | } 50 | 51 | socketPath := flag.String("l", "", "agent: path of the UNIX socket to listen on") 52 | resetFlag := flag.Bool("really-delete-all-piv-keys", false, "setup: reset the PIV applet") 53 | setupFlag := flag.Bool("setup", false, "setup: configure a new YubiKey") 54 | flag.Parse() 55 | 56 | if flag.NArg() > 0 { 57 | flag.Usage() 58 | os.Exit(1) 59 | } 60 | 61 | if *setupFlag { 62 | log.SetFlags(0) 63 | yk := connectForSetup() 64 | if *resetFlag { 65 | runReset(yk) 66 | } 67 | runSetup(yk) 68 | } else { 69 | if *socketPath == "" { 70 | flag.Usage() 71 | os.Exit(1) 72 | } 73 | runAgent(*socketPath) 74 | } 75 | } 76 | 77 | func runAgent(socketPath string) { 78 | if terminal.IsTerminal(int(os.Stdin.Fd())) { 79 | log.Println("Warning: yubikey-agent is meant to run as a background daemon.") 80 | log.Println("Running multiple instances is likely to lead to conflicts.") 81 | log.Println("Consider using the launchd or systemd services.") 82 | } 83 | 84 | a := &Agent{} 85 | 86 | c := make(chan os.Signal) 87 | signal.Notify(c, syscall.SIGHUP) 88 | go func() { 89 | for range c { 90 | a.Close() 91 | } 92 | }() 93 | 94 | os.Remove(socketPath) 95 | if err := os.MkdirAll(filepath.Dir(socketPath), 0777); err != nil { 96 | log.Fatalln("Failed to create UNIX socket folder:", err) 97 | } 98 | l, err := net.Listen("unix", socketPath) 99 | if err != nil { 100 | log.Fatalln("Failed to listen on UNIX socket:", err) 101 | } 102 | 103 | for { 104 | c, err := l.Accept() 105 | if err != nil { 106 | type temporary interface { 107 | Temporary() bool 108 | } 109 | if err, ok := err.(temporary); ok && err.Temporary() { 110 | log.Println("Temporary Accept error, sleeping 1s:", err) 111 | time.Sleep(1 * time.Second) 112 | continue 113 | } 114 | log.Fatalln("Failed to accept connections:", err) 115 | } 116 | go a.serveConn(c) 117 | } 118 | } 119 | 120 | type Agent struct { 121 | mu sync.Mutex 122 | yk *piv.YubiKey 123 | serial uint32 124 | 125 | // touchNotification is armed by Sign to show a notification if waiting for 126 | // more than a few seconds for the touch operation. It is paused and reset 127 | // by getPIN so it won't fire while waiting for the PIN. 128 | touchNotification *time.Timer 129 | } 130 | 131 | var _ agent.ExtendedAgent = &Agent{} 132 | 133 | func (a *Agent) serveConn(c net.Conn) { 134 | if err := agent.ServeAgent(a, c); err != io.EOF { 135 | log.Println("Agent client connection ended with error:", err) 136 | } 137 | } 138 | 139 | func healthy(yk *piv.YubiKey) bool { 140 | // We can't use Serial because it locks the session on older firmwares, and 141 | // can't use Retries because it fails when the session is unlocked. 142 | _, err := yk.AttestationCertificate() 143 | return err == nil 144 | } 145 | 146 | func (a *Agent) ensureYK() error { 147 | if a.yk == nil || !healthy(a.yk) { 148 | if a.yk != nil { 149 | log.Println("Reconnecting to the YubiKey...") 150 | a.yk.Close() 151 | } else { 152 | log.Println("Connecting to the YubiKey...") 153 | } 154 | yk, err := a.connectToYK() 155 | if err != nil { 156 | return err 157 | } 158 | a.yk = yk 159 | } 160 | return nil 161 | } 162 | 163 | func (a *Agent) maybeReleaseYK() { 164 | // On macOS, YubiKey 5s persist the PIN cache even across sessions (and even 165 | // processes), so we can release the lock on the key, to let other 166 | // applications like age-plugin-yubikey use it. 167 | if runtime.GOOS != "darwin" || a.yk.Version().Major < 5 { 168 | return 169 | } 170 | if err := a.yk.Close(); err != nil { 171 | log.Println("Failed to automatically release YubiKey lock:", err) 172 | } 173 | a.yk = nil 174 | } 175 | 176 | func (a *Agent) connectToYK() (*piv.YubiKey, error) { 177 | yk, err := openYK() 178 | if err != nil { 179 | return nil, err 180 | } 181 | // Cache the serial number locally because requesting it on older firmwares 182 | // requires switching application, which drops the PIN cache. 183 | a.serial, _ = yk.Serial() 184 | return yk, nil 185 | } 186 | 187 | func openYK() (yk *piv.YubiKey, err error) { 188 | cards, err := piv.Cards() 189 | if err != nil { 190 | return nil, err 191 | } 192 | if len(cards) == 0 { 193 | return nil, errors.New("no YubiKey detected") 194 | } 195 | // TODO: support multiple YubiKeys. For now, select the first one that opens 196 | // successfully, to skip any internal unused smart card readers. 197 | for _, card := range cards { 198 | yk, err = piv.Open(card) 199 | if err == nil { 200 | return 201 | } 202 | } 203 | return 204 | } 205 | 206 | func (a *Agent) Close() error { 207 | a.mu.Lock() 208 | defer a.mu.Unlock() 209 | if a.yk != nil { 210 | log.Println("Received HUP, dropping YubiKey transaction...") 211 | err := a.yk.Close() 212 | a.yk = nil 213 | return err 214 | } 215 | return nil 216 | } 217 | 218 | func (a *Agent) getPIN() (string, error) { 219 | if a.touchNotification != nil && a.touchNotification.Stop() { 220 | defer a.touchNotification.Reset(5 * time.Second) 221 | } 222 | r, _ := a.yk.Retries() 223 | return getPIN(a.serial, r) 224 | } 225 | 226 | func (a *Agent) List() ([]*agent.Key, error) { 227 | a.mu.Lock() 228 | defer a.mu.Unlock() 229 | if err := a.ensureYK(); err != nil { 230 | return nil, fmt.Errorf("could not reach YubiKey: %w", err) 231 | } 232 | defer a.maybeReleaseYK() 233 | 234 | pk, err := getPublicKey(a.yk, piv.SlotAuthentication) 235 | if err != nil { 236 | return nil, err 237 | } 238 | return []*agent.Key{{ 239 | Format: pk.Type(), 240 | Blob: pk.Marshal(), 241 | Comment: fmt.Sprintf("YubiKey #%d PIV Slot 9a", a.serial), 242 | }}, nil 243 | } 244 | 245 | func getPublicKey(yk *piv.YubiKey, slot piv.Slot) (ssh.PublicKey, error) { 246 | cert, err := yk.Certificate(slot) 247 | if err != nil { 248 | return nil, fmt.Errorf("could not get public key: %w", err) 249 | } 250 | switch cert.PublicKey.(type) { 251 | case *ecdsa.PublicKey: 252 | case *rsa.PublicKey: 253 | default: 254 | return nil, fmt.Errorf("unexpected public key type: %T", cert.PublicKey) 255 | } 256 | pk, err := ssh.NewPublicKey(cert.PublicKey) 257 | if err != nil { 258 | return nil, fmt.Errorf("failed to process public key: %w", err) 259 | } 260 | return pk, nil 261 | } 262 | 263 | func (a *Agent) Signers() ([]ssh.Signer, error) { 264 | a.mu.Lock() 265 | defer a.mu.Unlock() 266 | if err := a.ensureYK(); err != nil { 267 | return nil, fmt.Errorf("could not reach YubiKey: %w", err) 268 | } 269 | defer a.maybeReleaseYK() 270 | 271 | return a.signers() 272 | } 273 | 274 | func (a *Agent) signers() ([]ssh.Signer, error) { 275 | pk, err := getPublicKey(a.yk, piv.SlotAuthentication) 276 | if err != nil { 277 | return nil, err 278 | } 279 | priv, err := a.yk.PrivateKey( 280 | piv.SlotAuthentication, 281 | pk.(ssh.CryptoPublicKey).CryptoPublicKey(), 282 | piv.KeyAuth{PINPrompt: a.getPIN}, 283 | ) 284 | if err != nil { 285 | return nil, fmt.Errorf("failed to prepare private key: %w", err) 286 | } 287 | s, err := ssh.NewSignerFromKey(priv) 288 | if err != nil { 289 | return nil, fmt.Errorf("failed to prepare signer: %w", err) 290 | } 291 | return []ssh.Signer{s}, nil 292 | } 293 | 294 | func (a *Agent) Sign(key ssh.PublicKey, data []byte) (*ssh.Signature, error) { 295 | return a.SignWithFlags(key, data, 0) 296 | } 297 | 298 | func (a *Agent) SignWithFlags(key ssh.PublicKey, data []byte, flags agent.SignatureFlags) (*ssh.Signature, error) { 299 | a.mu.Lock() 300 | defer a.mu.Unlock() 301 | if err := a.ensureYK(); err != nil { 302 | return nil, fmt.Errorf("could not reach YubiKey: %w", err) 303 | } 304 | defer a.maybeReleaseYK() 305 | 306 | signers, err := a.signers() 307 | if err != nil { 308 | return nil, err 309 | } 310 | for _, s := range signers { 311 | if !bytes.Equal(s.PublicKey().Marshal(), key.Marshal()) { 312 | continue 313 | } 314 | 315 | ctx, cancel := context.WithCancel(context.Background()) 316 | defer cancel() 317 | a.touchNotification = time.NewTimer(5 * time.Second) 318 | go func() { 319 | select { 320 | case <-a.touchNotification.C: 321 | case <-ctx.Done(): 322 | a.touchNotification.Stop() 323 | return 324 | } 325 | showNotification("Waiting for YubiKey touch...") 326 | }() 327 | 328 | alg := key.Type() 329 | switch { 330 | case alg == ssh.KeyAlgoRSA && flags&agent.SignatureFlagRsaSha256 != 0: 331 | alg = ssh.SigAlgoRSASHA2256 332 | case alg == ssh.KeyAlgoRSA && flags&agent.SignatureFlagRsaSha512 != 0: 333 | alg = ssh.SigAlgoRSASHA2512 334 | } 335 | // TODO: maybe retry if the PIN is not correct? 336 | return s.(ssh.AlgorithmSigner).SignWithAlgorithm(rand.Reader, data, alg) 337 | } 338 | return nil, fmt.Errorf("no private keys match the requested public key") 339 | } 340 | 341 | func showNotification(message string) { 342 | switch runtime.GOOS { 343 | case "darwin": 344 | message = strings.ReplaceAll(message, `\`, `\\`) 345 | message = strings.ReplaceAll(message, `"`, `\"`) 346 | appleScript := `display notification "%s" with title "yubikey-agent"` 347 | exec.Command("osascript", "-e", fmt.Sprintf(appleScript, message)).Run() 348 | case "linux": 349 | exec.Command("notify-send", "-i", "dialog-password", "yubikey-agent", message).Run() 350 | } 351 | } 352 | 353 | func (a *Agent) Extension(extensionType string, contents []byte) ([]byte, error) { 354 | return nil, agent.ErrExtensionUnsupported 355 | } 356 | 357 | var ErrOperationUnsupported = errors.New("operation unsupported") 358 | 359 | func (a *Agent) Add(key agent.AddedKey) error { 360 | return ErrOperationUnsupported 361 | } 362 | func (a *Agent) Remove(key ssh.PublicKey) error { 363 | return ErrOperationUnsupported 364 | } 365 | func (a *Agent) RemoveAll() error { 366 | return a.Close() 367 | } 368 | func (a *Agent) Lock(passphrase []byte) error { 369 | return ErrOperationUnsupported 370 | } 371 | func (a *Agent) Unlock(passphrase []byte) error { 372 | return ErrOperationUnsupported 373 | } 374 | -------------------------------------------------------------------------------- /prompt_darwin.go: -------------------------------------------------------------------------------- 1 | // Copyright 2020 Google LLC 2 | // 3 | // Use of this source code is governed by a BSD-style 4 | // license that can be found in the LICENSE file or at 5 | // https://developers.google.com/open-source/licenses/bsd 6 | 7 | package main 8 | 9 | import ( 10 | "bytes" 11 | "encoding/json" 12 | "fmt" 13 | "os/exec" 14 | "text/template" 15 | ) 16 | 17 | var scriptTemplate = template.Must(template.New("script").Parse(` 18 | var app = Application.currentApplication() 19 | app.includeStandardAdditions = true 20 | app.displayDialog( 21 | "YubiKey serial number: {{ .Serial }} " + 22 | "({{ .Tries }} tries remaining)\n\n" + 23 | "Please enter your PIN:", { 24 | defaultAnswer: "", 25 | withTitle: "yubikey-agent PIN prompt", 26 | buttons: ["Cancel", "OK"], 27 | defaultButton: "OK", 28 | cancelButton: "Cancel", 29 | hiddenAnswer: true, 30 | })`)) 31 | 32 | func getPIN(serial uint32, retries int) (string, error) { 33 | script := new(bytes.Buffer) 34 | if err := scriptTemplate.Execute(script, map[string]interface{}{ 35 | "Serial": serial, "Tries": retries, 36 | }); err != nil { 37 | return "", err 38 | } 39 | 40 | c := exec.Command("osascript", "-s", "se", "-l", "JavaScript") 41 | c.Stdin = script 42 | out, err := c.Output() 43 | if err != nil { 44 | return "", fmt.Errorf("failed to execute osascript: %v", err) 45 | } 46 | var x struct { 47 | PIN string `json:"textReturned"` 48 | } 49 | if err := json.Unmarshal(out, &x); err != nil { 50 | return "", fmt.Errorf("failed to parse osascript output: %v", err) 51 | } 52 | return x.PIN, nil 53 | } 54 | -------------------------------------------------------------------------------- /prompt_pinentry.go: -------------------------------------------------------------------------------- 1 | // Copyright 2020 Google LLC 2 | // 3 | // Use of this source code is governed by a BSD-style 4 | // license that can be found in the LICENSE file or at 5 | // https://developers.google.com/open-source/licenses/bsd 6 | 7 | //go:build !darwin 8 | // +build !darwin 9 | 10 | package main 11 | 12 | import ( 13 | "fmt" 14 | 15 | "github.com/twpayne/go-pinentry-minimal/pinentry" 16 | ) 17 | 18 | func getPIN(serial uint32, retries int) (string, error) { 19 | client, err := pinentry.NewClient( 20 | pinentry.WithBinaryNameFromGnuPGAgentConf(), 21 | pinentry.WithGPGTTY(), 22 | pinentry.WithTitle("yubikey-agent PIN Prompt"), 23 | pinentry.WithDesc(fmt.Sprintf("YubiKey serial number: %d (%d tries remaining)", serial, retries)), 24 | pinentry.WithPrompt("Please enter your PIN:"), 25 | // Enable opt-in external PIN caching (in the OS keychain). 26 | // https://gist.github.com/mdeguzis/05d1f284f931223624834788da045c65#file-info-pinentry-L324 27 | pinentry.WithOption(pinentry.OptionAllowExternalPasswordCache), 28 | pinentry.WithKeyInfo(fmt.Sprintf("--yubikey-id-%d", serial)), 29 | ) 30 | if err != nil { 31 | return "", err 32 | } 33 | defer client.Close() 34 | 35 | pin, _, err := client.GetPIN() 36 | return pin, err 37 | } 38 | -------------------------------------------------------------------------------- /setup.go: -------------------------------------------------------------------------------- 1 | // Copyright 2020 Google LLC 2 | // 3 | // Use of this source code is governed by a BSD-style 4 | // license that can be found in the LICENSE file or at 5 | // https://developers.google.com/open-source/licenses/bsd 6 | 7 | package main 8 | 9 | import ( 10 | "bytes" 11 | "crypto/ecdsa" 12 | "crypto/elliptic" 13 | "crypto/rand" 14 | "crypto/x509" 15 | "crypto/x509/pkix" 16 | "errors" 17 | "fmt" 18 | "log" 19 | "math/big" 20 | "os" 21 | "runtime/debug" 22 | "time" 23 | 24 | "github.com/go-piv/piv-go/piv" 25 | "golang.org/x/crypto/ssh" 26 | "golang.org/x/term" 27 | ) 28 | 29 | // Version can be set at link time to override debug.BuildInfo.Main.Version, 30 | // which is "(devel)" when building from within the module. See 31 | // golang.org/issue/29814 and golang.org/issue/29228. 32 | var Version string 33 | 34 | func init() { 35 | if Version != "" { 36 | return 37 | } 38 | if buildInfo, ok := debug.ReadBuildInfo(); ok { 39 | Version = buildInfo.Main.Version 40 | return 41 | } 42 | Version = "(unknown version)" 43 | } 44 | 45 | func connectForSetup() *piv.YubiKey { 46 | yk, err := openYK() 47 | if err != nil { 48 | log.Fatalln("Failed to connect to the YubiKey:", err) 49 | } 50 | return yk 51 | } 52 | 53 | func runReset(yk *piv.YubiKey) { 54 | fmt.Print(`Do you want to reset the PIV applet? This will delete all PIV keys. Type "delete": `) 55 | var res string 56 | if _, err := fmt.Scanln(&res); err != nil { 57 | log.Fatalln("Failed to read response:", err) 58 | } 59 | if res != "delete" { 60 | log.Fatalln("Aborting...") 61 | } 62 | 63 | fmt.Println("Resetting YubiKey PIV applet...") 64 | if err := yk.Reset(); err != nil { 65 | log.Fatalln("Failed to reset YubiKey:", err) 66 | } 67 | } 68 | 69 | func runSetup(yk *piv.YubiKey) { 70 | if _, err := yk.Certificate(piv.SlotAuthentication); err == nil { 71 | log.Println("‼️ This YubiKey looks already setup") 72 | log.Println("") 73 | log.Println("If you want to wipe all PIV keys and start fresh,") 74 | log.Fatalln("use --really-delete-all-piv-keys ⚠️") 75 | } else if !errors.Is(err, piv.ErrNotFound) { 76 | log.Fatalln("Failed to access authentication slot:", err) 77 | } 78 | 79 | fmt.Println("🔐 The PIN is up to 8 numbers, letters, or symbols. Not just numbers!") 80 | fmt.Println("❌ The key will be lost if the PIN and PUK are locked after 3 incorrect tries.") 81 | fmt.Println("") 82 | fmt.Print("Choose a new PIN/PUK: ") 83 | pin, err := term.ReadPassword(int(os.Stdin.Fd())) 84 | fmt.Print("\n") 85 | if err != nil { 86 | log.Fatalln("Failed to read PIN:", err) 87 | } 88 | if len(pin) < 6 || len(pin) > 8 { 89 | log.Fatalln("The PIN needs to be 6-8 characters.") 90 | } 91 | fmt.Print("Repeat PIN/PUK: ") 92 | repeat, err := term.ReadPassword(int(os.Stdin.Fd())) 93 | fmt.Print("\n") 94 | if err != nil { 95 | log.Fatalln("Failed to read PIN:", err) 96 | } else if !bytes.Equal(repeat, pin) { 97 | log.Fatalln("PINs don't match!") 98 | } 99 | 100 | fmt.Println("") 101 | fmt.Println("🧪 Reticulating splines...") 102 | 103 | var key [24]byte 104 | if _, err := rand.Read(key[:]); err != nil { 105 | log.Fatal(err) 106 | } 107 | if err := yk.SetManagementKey(piv.DefaultManagementKey, key); err != nil { 108 | log.Println("‼️ The default Management Key did not work") 109 | log.Println("") 110 | log.Println("If you know what you're doing, reset PIN, PUK, and") 111 | log.Println("Management Key to the defaults before retrying.") 112 | log.Println("") 113 | log.Println("If you want to wipe all PIV keys and start fresh,") 114 | log.Fatalln("use --really-delete-all-piv-keys ⚠️") 115 | } 116 | if err := yk.SetMetadata(key, &piv.Metadata{ 117 | ManagementKey: &key, 118 | }); err != nil { 119 | log.Fatalln("Failed to store the Management Key on the device:", err) 120 | } 121 | if err := yk.SetPIN(piv.DefaultPIN, string(pin)); err != nil { 122 | log.Println("‼️ The default PIN did not work") 123 | log.Println("") 124 | log.Println("If you know what you're doing, reset PIN, PUK, and") 125 | log.Println("Management Key to the defaults before retrying.") 126 | log.Println("") 127 | log.Println("If you want to wipe all PIV keys and start fresh,") 128 | log.Fatalln("use --really-delete-all-piv-keys ⚠️") 129 | } 130 | if err := yk.SetPUK(piv.DefaultPUK, string(pin)); err != nil { 131 | log.Println("‼️ The default PUK did not work") 132 | log.Println("") 133 | log.Println("If you know what you're doing, reset PIN, PUK, and") 134 | log.Println("Management Key to the defaults before retrying.") 135 | log.Println("") 136 | log.Println("If you want to wipe all PIV keys and start fresh,") 137 | log.Fatalln("use --really-delete-all-piv-keys ⚠️") 138 | } 139 | 140 | pub, err := yk.GenerateKey(key, piv.SlotAuthentication, piv.Key{ 141 | Algorithm: piv.AlgorithmEC256, 142 | PINPolicy: piv.PINPolicyOnce, 143 | TouchPolicy: piv.TouchPolicyAlways, 144 | }) 145 | if err != nil { 146 | log.Fatalln("Failed to generate key:", err) 147 | } 148 | 149 | priv, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) 150 | if err != nil { 151 | log.Fatalln("Failed to generate parent key:", err) 152 | } 153 | parent := &x509.Certificate{ 154 | Subject: pkix.Name{ 155 | Organization: []string{"yubikey-agent"}, 156 | OrganizationalUnit: []string{Version}, 157 | }, 158 | PublicKey: priv.Public(), 159 | } 160 | template := &x509.Certificate{ 161 | Subject: pkix.Name{ 162 | CommonName: "SSH key", 163 | }, 164 | NotAfter: time.Now().AddDate(42, 0, 0), 165 | NotBefore: time.Now(), 166 | SerialNumber: randomSerialNumber(), 167 | KeyUsage: x509.KeyUsageKeyAgreement | x509.KeyUsageDigitalSignature, 168 | } 169 | certBytes, err := x509.CreateCertificate(rand.Reader, template, parent, pub, priv) 170 | if err != nil { 171 | log.Fatalln("Failed to generate certificate:", err) 172 | } 173 | cert, err := x509.ParseCertificate(certBytes) 174 | if err != nil { 175 | log.Fatalln("Failed to parse certificate:", err) 176 | } 177 | if err := yk.SetCertificate(key, piv.SlotAuthentication, cert); err != nil { 178 | log.Fatalln("Failed to store certificate:", err) 179 | } 180 | 181 | sshKey, err := ssh.NewPublicKey(pub) 182 | if err != nil { 183 | log.Fatalln("Failed to generate public key:", err) 184 | } 185 | 186 | fmt.Println("") 187 | fmt.Println("✅ Done! This YubiKey is secured and ready to go.") 188 | fmt.Println("🤏 When the YubiKey blinks, touch it to authorize the login.") 189 | fmt.Println("") 190 | fmt.Println("🔑 Here's your new shiny SSH public key:") 191 | os.Stdout.Write(ssh.MarshalAuthorizedKey(sshKey)) 192 | fmt.Println("") 193 | fmt.Println("Next steps: ensure yubikey-agent is running via launchd/systemd/...,") 194 | fmt.Println(`set the SSH_AUTH_SOCK environment variable, and test with "ssh-add -L"`) 195 | fmt.Println("") 196 | fmt.Println("💭 Remember: everything breaks, have a backup plan for when this YubiKey does.") 197 | } 198 | 199 | func randomSerialNumber() *big.Int { 200 | serialNumberLimit := new(big.Int).Lsh(big.NewInt(1), 128) 201 | serialNumber, err := rand.Int(rand.Reader, serialNumberLimit) 202 | if err != nil { 203 | log.Fatalln("Failed to generate serial number:", err) 204 | } 205 | return serialNumber 206 | } 207 | -------------------------------------------------------------------------------- /systemd.md: -------------------------------------------------------------------------------- 1 | # Manual Linux setup with systemd 2 | 3 | Note: this is usually only necessary in case your distribution doesn't already 4 | provide a yubikey-agent as a package. 5 | 6 | Refer to [the README](README.md) for a list of distributions providing packages. 7 | 8 | First, install Go and the [`piv-go` dependencies](https://github.com/go-piv/piv-go#installation), build `yubikey-agent` and place it in `$PATH`. 9 | 10 | ```text 11 | $ git clone https://filippo.io/yubikey-agent && cd yubikey-agent 12 | $ go build && sudo cp yubikey-agent /usr/local/bin/ 13 | ``` 14 | 15 | Make sure you have a `pinentry` program that works for you (terminal-based or graphical) in `$PATH`. 16 | 17 | Use `yubikey-agent -setup` to create a new key on the YubiKey. 18 | 19 | ```text 20 | $ yubikey-agent -setup 21 | ``` 22 | 23 | Then, create a systemd user service at `~/.config/systemd/user/yubikey-agent.service` 24 | with the contents of [yubikey-agent.service](contrib/systemd/user/yubikey-agent.service). 25 | 26 | Depending on your distribution (`systemd <=239` or no user namespace support), 27 | you might need to edit the `ExecStart=` line and some of the sandboxing 28 | options. 29 | 30 | Refresh systemd, make sure that the PC/SC daemon is available, and start the yubikey-agent. 31 | 32 | ```text 33 | $ systemctl daemon-reload --user 34 | $ sudo systemctl enable --now pcscd.socket 35 | $ systemctl --user enable --now yubikey-agent 36 | ``` 37 | 38 | Finally, add the following line to your shell profile and restart it. 39 | 40 | ``` 41 | export SSH_AUTH_SOCK="${XDG_RUNTIME_DIR}/yubikey-agent/yubikey-agent.sock" 42 | ``` 43 | --------------------------------------------------------------------------------