OpenSSH is on a roll. In February, OpenSSH 8.2 introduced first-class support for FIDO2 (née U2F) security keys, making hardware backed keys accessible for less than $20.

This is not some complicated PAM setup, or some janky cryptographic trick, but a proper public key type, where the private key is protected by the hardware token.[1] And it just works out of the box for USB security keys! No more tedious and unreliable gpg-agent setups, PKCS#11, or third-party agents.

I'm a big fan of hardware tokens because they allow a few things you can't do with just software cryptography: compromise recovery, because an attacker can't exfiltrate the key from the hardware to use it after losing access to it; explicit consent, where the user has to physically allow each operation by e.g. tapping the key; and short PINs that can't be bruteforced, because the retry counters or delays are enforced in hardware.

Let's cut to the chase, here's how you generate an SSH key backed by your security key:[2]

ssh-keygen -t ecdsa-sk

As long as you have OpenSSH 8.2 and a U2F/FIDO2 key plugged in, that will generate a private and public key that you can use as normal: adding the public key to authorized_keys, loading the private key into ssh-agent, etc. When you try to use the private key, it will require the security key plugged in and a touch.

Technically, OpenSSH needs a middleware to talk to the security key, but it also ships with a built-in one for USB tokens based on libfido2. Homebrew enables it, and if your distribution doesn't, you should suggest it!

The Go package supports these public keys server side since v0.0.0-20191202143827-86a70503ff7e.

How these security keys manage to be so inexpensive is that they are basically stateless. When they are asked to generate a new key, they return an opaque 64 bytes blob that the application (usually the website) has to hold on to, and pass back to the token when requesting a signature. Most tokens presumably encrypt the generated private key with a hardware master key, and return that, offloading storage to the application. If you want to learn more about U2F/FIDO2 Adam wrote about it in detail.

Indeed, if we look inside an ecdsa-sk OpenSSH private key, we find a key_handle for the blob. There's also an application which defaults to ssh:, and in a web context would be the website origin.[3]

The format of a public key is:

	string		""
	string		curve name
	ec_point	Q
	string		application (user-specified, but typically "ssh:")

The corresponding private key contains:

	string		""
	string		curve name
	ec_point	Q
	string		application (user-specified, but typically "ssh:")
	uint8		flags
	string		key_handle
	string		reserved

Something I noticed is that OpenSSH offers to encrypt these private keys with a passphrase, which would make you think the hardware token is guaranteed to be useless without the decrypted private key file. As far as I can tell, that's true when the key handle is indeed an encrypted private key, but there's nothing in the spec requiring that, and it could as well be an easy to bruteforce index into some hypothetical on-device storage. In practice, Adam tested a bunch of tokens, and although some of them squeak weird, none seem broken enough to circumvent the private key file encryption.

It is definitely advisable to acquire a security key from a reputable vendor anyway, regardless of private key encryption. The blue YubiKeys are great, as well as the open source SoloKeys. Wirecutter has a long review by the great Yael Grauer.

As long as the token does the sensible thing, FIDO2 backed keys have an added security benefit compared to PGP or PIV backed keys in that they require both the private key file and the hardware token to operate. (On the other hand, most tokens don't support hardware PINs, only touch presence checks.[4])

If that's actually the opposite of what you want, there's a feature called resident keys where the application can store the key handle and metadata back on the hardware key, so it can be moved between machines without having to move the key handle. OpenSSH has full support for this, but my YubiKey 4 doesn't.

If you want to learn more, the release notes as well as the PROTOCOL.u2f spec file are pretty readable. Now, we just wait for GitHub to start supporting these public keys, which I'm told is blocked on libssh2.

In the meantime, here's a picture of an empty platform at Milano Centrale back in January. I do miss it. Stay safe and healthy.

Milano Centrale at sunset

  1. SSH public keys are cool also because, unlike password authentication, they can't be MitM'd. A machine in the middle can make you think you authenticated successfully and serve you fake data if you don't check the host key, which is not great, but it can't use your public key to log in as you anywhere. This is why I don't care about checking SSH host keys that much. ↩︎

  2. ed25519-sk also exists, but support is limited and my YubiKey 4 doesn't support it. ↩︎

  3. The application is also in the public key because it's implicitly included in the signed data. This is what makes security keys unphishable, as a signature made for will not work on SSH already has a stronger property at the protocol level, as the message being signed is tied to the session. If there's anything neat to do by customizing this field for different keys, I can't see it. ↩︎

  4. There is support in the spec for user verification, but I can't tell if the built-in middleware supports it, and my YubiKey doesn't seem to. It does look like the SoloKeys firmware implements it. ↩︎