Generating an `ed25519-sk` SSH Key with a YubiKey
Workflow: Generating an ed25519-sk SSH Key with a YubiKey
FIDO2-backed SSH key. The private key never leaves the token; what lands on disk is only a key handle that is useless without the YubiKey.
0. Prerequisites
| Component | Requirement | Check |
|---|---|---|
| OpenSSH client | ≥ 8.2 (-sk types), ≥ 8.4 for verify-required |
ssh -V |
| OpenSSH server | ≥ 8.2 to accept, ≥ 8.4 to enforce verify-required |
sshd -V / ssh -Q key |
| libfido2 | present, plus ssh-sk-helper |
ssh -Q key | grep sk- |
| YubiKey firmware | ≥ 5.2.3 for Ed25519 over FIDO2 (older → only ecdsa-sk) |
ykman info |
| FIDO2 application | enabled, PIN set | ykman fido info |
Debian/Ubuntu:
apt install openssh-client libfido2-1 yubikey-manager
Confirm the algorithm is actually available in your build:
ssh -Q key | grep sk-
# expect: sk-ssh-ed25519@openssh.com
# sk-ssh-ed25519-cert-v01@openssh.com
If sk-ssh-ed25519@openssh.com is missing, the client was built without FIDO support — no amount of flags will help.
1. Set a FIDO2 PIN
Required for verify-required. The FIDO2 PIN is independent of the PIV PIN and the OpenPGP PIN.
ykman fido info # shows whether a PIN is already set
ykman fido access change-pin # sets or changes it
- PIN length: 4–63 characters (UTF-8 code points on firmware ≥ 5.7).
- 3 consecutive wrong entries → token must be re-inserted.
- 8 wrong entries total → FIDO2 application locks; recovery requires
ykman fido reset, which destroys every FIDO2 credential on the token, including WebAuthn passkeys.
2. Generate the key
2a. Non-resident (default; recommended)
The key handle lives in ~/.ssh/, the secret in the token.
ssh-keygen -t ed25519-sk \
-O verify-required \
-O application=ssh:karger-oliver-yk1 \
-C "oliver@karger.lan yk1 $(date +%F)" \
-f ~/.ssh/id_ed25519_sk_yk1
Touch the YubiKey when it blinks. You will also be prompted for the FIDO2 PIN, and afterwards for a passphrase on the handle file — set one; it costs nothing and adds a layer if the handle is stolen alongside the token.
2b. Resident / discoverable
The credential is stored on the token and can be recovered onto any machine. Convenient for roaming admins, but it consumes a discoverable-credential slot and means the token alone is sufficient to reconstruct the handle (PIN still required).
ssh-keygen -t ed25519-sk \
-O resident \
-O verify-required \
-O application=ssh:karger-oliver-yk1 \
-O user=oliver \
-C "oliver@karger.lan yk1 resident" \
-f ~/.ssh/id_ed25519_sk_yk1
Slot budget: 25 discoverable credentials on firmware 5.2.3–5.6, 100 on firmware 5.7+.
-O application= must start with ssh: and is what distinguishes multiple resident keys on the same token. Pick a scheme and stick to it, e.g. ssh:karger-<user>-<token-serial>.
Option reference
| Option | Effect |
|---|---|
-O resident |
store discoverable credential on the token |
-O verify-required |
PIN and touch on every authentication |
-O no-touch-required |
touch not required — only for unattended automation, and the server must allow it |
-O application=ssh:NAME |
credential label / RP ID |
-O user=NAME |
FIDO2 user handle, shown in ykman fido credentials list |
-O challenge=FILE / -O write-attestation=FILE |
produce an attestation statement (see §7) |
Inspect the result
ssh-keygen -l -f ~/.ssh/id_ed25519_sk_yk1.pub
ykman fido credentials list # resident keys only
3. Deploy the public key
ssh-copy-id -i ~/.ssh/id_ed25519_sk_yk1.pub user@host
Or write ~/.ssh/authorized_keys directly, with per-key enforcement:
restrict,pty,verify-required sk-ssh-ed25519@openssh.com AAAAGnNr... oliver@karger.lan yk1
verify-required in authorized_keys requires sshd ≥ 8.4. Without it, the server accepts the signature even if the client skipped user verification — the client-side flag alone is not an access control.
4. Server configuration
PubkeyAuthentication yes
PubkeyAcceptedAlgorithms sk-ssh-ed25519@openssh.com,ssh-ed25519,rsa-sha2-512
PubkeyAuthOptions verify-required
PasswordAuthentication no
KbdInteractiveAuthentication no
Notes:
PubkeyAuthOptions verify-requiredenforces PIN globally; it will lock out any non-skkey, so either scope it in aMatchblock or make sure every account has migrated first.- If you already ship a hardened
PubkeyAcceptedAlgorithmslist via Ansible,sk-ssh-ed25519@openssh.commust be added explicitly — a restrictive list silently rejects the new key type. - Reload and test in a second session before dropping the first:
sshd -t && systemctl reload ssh
5. Verify
ssh -o IdentitiesOnly=yes -i ~/.ssh/id_ed25519_sk_yk1 -v user@host
Expected flow: PIN prompt → LED blinks → touch → session. In the verbose log you should see Offering public key: ... ED25519-SK and Server accepts key.
Pin it down in ~/.ssh/config:
Host *.karger.lan
IdentityFile ~/.ssh/id_ed25519_sk_yk1
IdentitiesOnly yes
AddKeysToAgent no
6. Recovery on a new machine
Only works for resident keys.
mkdir -m 700 ~/.ssh/rk && cd ~/.ssh/rk
ssh-keygen -K # writes id_ed25519_sk_rk_<application> + .pub
Or load into the agent without ever writing the handle to disk:
ssh-add -K
For non-resident keys there is no recovery path — the handle file is the only copy. Back it up (it is not secret in isolation) or accept that losing it means re-enrolling.
7. Attestation (optional, useful for NIS2 evidence)
Proves the key was generated inside genuine Yubico hardware rather than a software authenticator.
dd if=/dev/urandom of=challenge.bin bs=32 count=1
ssh-keygen -t ed25519-sk \
-O challenge=challenge.bin \
-O write-attestation=attest.bin \
-O verify-required \
-f ~/.ssh/id_ed25519_sk_yk1
attest.bin contains the attestation certificate and signature; verification means chaining it to the Yubico U2F root CA and checking the signature over challenge + credential. There is no ready-made ssh-keygen verb for this — expect a small Python/libfido2 helper. Archive challenge.bin, attest.bin, the public key and the token serial together as the enrolment record.
8. Rollout considerations
- Two tokens per user, always. The secret is non-exportable, so redundancy means a second, independent key pair on a backup token, enrolled in
authorized_keysat the same time. Generate both during provisioning; a spare token issued later requires a second visit to every target system. - Naming.
ssh:karger-<user>-<serial>inapplication=, and the token serial in the key comment. That makes revocation on token loss a grep instead of an investigation. - Inventory. Public key + token serial + issue date belong in NetBox (or wherever the token asset record lives), so that a lost-token report maps directly to the keys that need pulling.
- Distribution. Push
authorized_keysfrom a single source of truth via Ansible (ansible.posix.authorized_keywithexclusive: yes), otherwise revocation is unreliable. - Automation accounts should not use
skkeys at all — a touch requirement in a cron job just means someone will addno-touch-requiredand forget. Use a separate, non-skkey withrestrict+command=instead.
9. Known friction
| Area | Note |
|---|---|
| Windows | Microsoft's Win32-OpenSSH has historically shipped without FIDO middleware. Verify with ssh -Q key | grep sk-; if empty, use Git for Windows' OpenSSH or WSL. |
| Agent forwarding | Works, but every hop triggers PIN + touch on the origin machine. |
| Forgejo / Gitea | Accepts sk-ssh-ed25519@openssh.com, but check [ssh] minimum-key-size settings — some hardened configs reject unknown types outright. |
ykman fido reset |
Wipes all FIDO2 credentials, not just SSH ones. Never the first troubleshooting step. |
| Older YubiKeys | Firmware < 5.2.3 → ed25519-sk fails with a vague error. Fall back to -t ecdsa-sk. |
| Multiple tokens plugged in | ssh-keygen picks the first FIDO device it finds; unplug the others or pass -O device=/dev/hidrawN. |