Skip to main content

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-required enforces PIN globally; it will lock out any non-sk key, so either scope it in a Match block or make sure every account has migrated first.
  • If you already ship a hardened PubkeyAcceptedAlgorithms list via Ansible, sk-ssh-ed25519@openssh.com must 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_keys at 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> in application=, 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_keys from a single source of truth via Ansible (ansible.posix.authorized_key with exclusive: yes), otherwise revocation is unreliable.
  • Automation accounts should not use sk keys at all — a touch requirement in a cron job just means someone will add no-touch-required and forget. Use a separate, non-sk key with restrict + 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.