FIDO2 SSH Keys on Debian 13: Enforce ed25519-sk

11 min read

An SSH private key in ~/.ssh is just a file. Malware, a stolen backup or a careless scp can copy it, and a passphrase only slows down an offline attack. With FIDO2 SSH keys of type ed25519-sk, the private key stays on a hardware token. Every login needs the token to be plugged in, a touch and, with verify-required, the token's PIN.

Most guides stop after ssh-keygen. On its own, that only means you can log in with a token, not that you must. This tutorial makes a Debian 13 server accept only FIDO2 keys with PIN and touch verification. It rolls the change out on a test port so a typo can't lock you out, proves it works from the journal, and plans for the day a token gets lost.

What we build and prerequisites

The finished setup:

  • Two hardware tokens per admin (primary and backup), each holding an ed25519-sk key created with -O verify-required.
  • An sshd drop-in that requires touch and user verification for every FIDO signature, and accepts only sk- key algorithms.
  • Tests showing that a plain ed25519 key and a FIDO key without PIN verification are both rejected.

Versions in Debian 13 (trixie) at the time of writing, from packages.debian.org:

PackageVersion in trixieRole
openssh-server1:10.0p1-7+deb13u4sshd, enforces the policy
openssh-client1:10.0p1-7+deb13u4ssh, ssh-keygen, ssh-add; depends on libfido2-1 (>= 1.8.0)
libfido2-11.15.0-1+b1talks to the token over USB/NFC
fido2-tools1.15.0-1+b1optional, fido2-token for PIN and credential management

You can use any client with OpenSSH 8.4 or newer built with FIDO support. 8.2 introduced the -sk key types, and 8.4 added verify-required. Before you start, make sure you have console access to the server (IPMI, a hypervisor console or the provider's rescue console). That's your last-resort fallback.

Step 1: Check the client and prepare the token

On the client, check the OpenSSH version and confirm the sk key types are compiled in:

ssh -V
ssh -Q key-plain | grep '^sk-'
dpkg -l openssh-client libfido2-1
sk-ecdsa-sha2-nistp256@openssh.com
sk-ssh-ed25519@openssh.com

Install fido2-tools, list the tokens the OS can see, then set a PIN if the token doesn't have one yet:

sudo apt install fido2-tools
fido2-token -L
fido2-token -I /dev/hidraw4
fido2-token -S /dev/hidraw4

Replace /dev/hidraw4 with the path that -L printed. -I shows the token's capabilities, including its algorithms and whether a client PIN is set. Use -C to change an existing PIN. Do the same for the backup token. Don't run fido2-token -R on a token you already use: it resets the device without asking for confirmation and deletes every FIDO2 credential on it.

Step 2: Generate ed25519-sk keys with verify-required

First, decide between a resident and a non-resident key:

Non-resident (default)Resident (-O resident)
Where the key handle livesOnly in the file on diskOn the token, retrievable with ssh-keygen -K
New laptopCopy the key handle file overPlug in the token, enter the PIN, download
Token storage usedNoneOne credential slot
YubiKey requirements (per Yubico)ed25519-sk needs firmware 5.2.3+Firmware 5.2.3+ and a FIDO2 PIN

Non-resident keys are more conservative: a stolen token on its own doesn't contain the key handle. Resident keys are handier if you move between machines a lot. Either way, -O verify-required makes the token require user verification (usually the PIN) for every signature.

Primary token, non-resident:

ssh-keygen -t ed25519-sk -O verify-required -C "alice yk-primary" -f ~/.ssh/id_ed25519_sk

Or resident, with its own application string so you can keep several on one token:

ssh-keygen -t ed25519-sk -O resident -O verify-required -O application=ssh:prod -C "alice yk-primary prod" -f ~/.ssh/id_ed25519_sk_prod

Swap tokens and create the backup key:

ssh-keygen -t ed25519-sk -O verify-required -C "alice yk-backup" -f ~/.ssh/id_ed25519_sk_backup

ssh-keygen asks for the PIN and a touch. The passphrase prompt that follows encrypts the key handle file on disk, which is still worth setting. Check: each .pub file must start with sk-ssh-ed25519@openssh.com.

head -c 40 ~/.ssh/id_ed25519_sk.pub; echo
head -c 40 ~/.ssh/id_ed25519_sk_backup.pub; echo

Step 3: Add both keys to authorized_keys

Copy both public keys over your existing (still working) login:

cat ~/.ssh/id_ed25519_sk.pub ~/.ssh/id_ed25519_sk_backup.pub | ssh alice@server.example.net 'cat >> ~/.ssh/authorized_keys'

On the server, put the per-key verify-required option in front of both lines. sshd(8) documents it for ecdsa-sk and ed25519-sk keys, and it still applies if someone later loosens the global setting:

verify-required sk-ssh-ed25519@openssh.com AAAAGnNrLXNzaC1lZDI1NTE5QG9wZW5zc2guY29t... alice yk-primary
verify-required sk-ssh-ed25519@openssh.com AAAAGnNrLXNzaC1lZDI1NTE5QG9wZW5zc2guY29t... alice yk-backup

Test each token against the current, unchanged sshd. IdentitiesOnly=yes stops ssh from quietly falling back to your old key:

ssh -i ~/.ssh/id_ed25519_sk -o IdentitiesOnly=yes alice@server.example.net true
ssh -i ~/.ssh/id_ed25519_sk_backup -o IdentitiesOnly=yes alice@server.example.net true

You should be asked for the PIN and then a touch. If you get Permission denied (publickey), fix that before going further. The usual causes are covered in fixing SSH Permission denied (publickey) on Debian 13.

Step 4: Enforce FIDO2 SSH keys on a test port first

Debian's /etc/ssh/sshd_config starts with Include /etc/ssh/sshd_config.d/*.conf, and for most keywords sshd uses the first value it sees. So we put the policy in a staging file that comes before the full config, and run a second sshd from it on port 2222. The -p option makes sshd ignore the Port lines in the config files. Your production daemon on port 22 isn't touched.

PubkeyAuthentication yes
PubkeyAuthOptions touch-required verify-required
PubkeyAcceptedAlgorithms sk-ssh-ed25519@openssh.com,sk-ssh-ed25519-cert-v01@openssh.com,sk-ecdsa-sha2-nistp256@openssh.com,sk-ecdsa-sha2-nistp256-cert-v01@openssh.com
AuthenticationMethods publickey
PasswordAuthentication no
KbdInteractiveAuthentication no
Include /etc/ssh/sshd_config

Why both directives? PubkeyAuthOptions sets requirements for FIDO signatures: touch-required (user presence) and verify-required (user verification, e.g. PIN). In the OpenSSH 10.0p1 source, those checks only run when a signature carries FIDO details. A plain ssh-ed25519 key has none, so it would skip them. The sk-only PubkeyAcceptedAlgorithms list is what keeps plain keys out. The ecdsa-sk entries are there for tokens without Ed25519 support. Remove them if every token supports Ed25519.

Validate the file, then start a one-shot debug sshd. -d stays in the foreground, handles a single connection and writes debug output to the terminal:

sudo /usr/sbin/sshd -t -f /etc/ssh/sshd_fido2-test.conf
sudo /usr/sbin/sshd -d -p 2222 -f /etc/ssh/sshd_fido2-test.conf

If your firewall drops by default, temporarily allow TCP 2222 from your admin IP (see the nftables default-drop firewall post), and remove the rule when you're done. Run the debug sshd again before each test below.

Test A: primary and backup token succeed

ssh -p 2222 -i ~/.ssh/id_ed25519_sk -o IdentitiesOnly=yes alice@server.example.net true
ssh -p 2222 -i ~/.ssh/id_ed25519_sk_backup -o IdentitiesOnly=yes alice@server.example.net true

Test B: a plain key is refused

ssh -p 2222 -i ~/.ssh/id_ed25519 -o IdentitiesOnly=yes alice@server.example.net true
userauth_pubkey: signature algorithm ssh-ed25519 not in PubkeyAcceptedAlgorithms [preauth]

Test C: a FIDO key without PIN verification is refused

Generate a throwaway sk key without verify-required, temporarily add its public key to authorized_keys with no options, and try it:

ssh-keygen -t ed25519-sk -C "throwaway no-uv" -f ~/.ssh/test_no_uv
ssh -p 2222 -i ~/.ssh/test_no_uv -o IdentitiesOnly=yes alice@server.example.net true
public key ED25519-SK SHA256:... signature for alice from 198.51.100.23 port 53140 rejected: user verification requirement not met

This rejection comes from the global PubkeyAuthOptions verify-required. Delete the throwaway line from authorized_keys afterwards.

Step 5: Go live with a drop-in

Warning: the next steps change the policy of your production sshd. Keep at least one root shell open and don't close it until a new login on port 22 works. Have your out-of-band console ready.

Copy the policy into a drop-in. Drop-ins load in lexical order and the first value wins. Give this one a low number so it comes before any other file that sets the same keywords (for example the one from hardening SSH on Debian 13 with OpenSSH 10):

PubkeyAuthentication yes
PubkeyAuthOptions touch-required verify-required
PubkeyAcceptedAlgorithms sk-ssh-ed25519@openssh.com,sk-ssh-ed25519-cert-v01@openssh.com,sk-ecdsa-sha2-nistp256@openssh.com,sk-ecdsa-sha2-nistp256-cert-v01@openssh.com
AuthenticationMethods publickey
PasswordAuthentication no
KbdInteractiveAuthentication no

Check syntax and the effective values, then reload:

sudo sshd -t
sudo sshd -T | grep -E '^(pubkeyauthoptions|pubkeyacceptedalgorithms|authenticationmethods|passwordauthentication)'
sudo systemctl reload ssh.service
sudo rm /etc/ssh/sshd_fido2-test.conf

The sshd -T output must list both options and only sk- algorithms. If a value is different, another file is setting it first. Now open a new terminal and log in on port 22 with each token.

Verify in the journal that sshd accepted an ED25519-SK key

When it succeeds, sshd logs the key type, fingerprint and the token's signature counter:

sudo journalctl -u ssh.service --since "15 min ago" | grep -E 'Accepted publickey|rejected|not in PubkeyAcceptedAlgorithms'
Accepted publickey for alice from 198.51.100.23 port 53122 ssh2: ED25519-SK SHA256:7xq3hL0pQe0N9V0bFh1s5mQkzq2cR8xY3Jt6wTn4aUo, signature count = 41

(Timestamp and process prefix trimmed.) ED25519-SK is the proof you're after. A line with ED25519 and no suffix means a plain key got through and the policy isn't active. To tell which token was used, compare the fingerprint with ssh-keygen -lf ~/.ssh/id_ed25519_sk.pub on the client. A small auditd setup or your log shipping can alert on any Accepted publickey line that doesn't contain -SK.

New machines, ssh-add -K and the agent

On a new laptop with a resident key, download the key handle from the token (PIN required) into a private directory:

cd ~/.ssh
ssh-keygen -K
ls -l

If several tokens are plugged in, -K uses the first one you touch. It writes a key handle file and a .pub file to the current directory. If you'd rather not write files, load the resident key straight into the agent:

ssh-add -K
ssh-add -l

The agent holds a reference to the key handle, not the secret. According to Yubico, the PIN is never cached, so every login with a verify-required key still asks for the PIN, and a touch unless the key was made with no-touch-required. Because the agent has no terminal to prompt on, install an askpass helper (ssh-askpass is in openssh-client's Suggests), or skip the agent for these keys and use -i. This is also why sk keys reduce the forwarded-agent risk described in SSH agent forwarding hijacking on Debian 13: someone abusing your forwarded socket still can't sign without the token, your touch and your PIN.

Common pitfalls

no-touch-required

ssh-keygen -O no-touch-required creates keys that sign without a touch, and the no-touch-required option in authorized_keys tells sshd to accept them. The touch-required in our drop-in overrides that on the server side. If you ever take it out, grep every authorized_keys for no-touch-required.

Old firmware without Ed25519

FIDO tokens have to support ECDSA P-256, but Ed25519 is optional. On YubiKeys, ed25519-sk needs firmware 5.2.3 or newer. If key generation fails on an older token, use -t ecdsa-sk. The drop-in above already accepts it.

VMs, containers and USB passthrough

Authentication happens where the token is plugged in. If you run ssh inside a VM, the hypervisor has to pass the USB device through, and the VM user needs access to the hidraw device. Check with fido2-token -L inside the guest. A token plugged into the host isn't visible to a guest by default, and forwarding an agent into the VM brings back the trade-offs covered in the agent hijacking post.

A lost or broken token

This is why you enrolled two tokens. Log in with the backup and delete the lost key's line from authorized_keys on every server. The comments (yk-primary, yk-backup) are there to make that easy. Then enrol a replacement. A non-resident key handle is useless without its token. A resident credential is protected by the PIN, and the token limits PIN retries. If both tokens are gone, the out-of-band console is your only way in, so test that it works before you need it.

Takeaways

  • Check ssh -Q key-plain for sk-ssh-ed25519@openssh.com, set a FIDO2 PIN with fido2-token -S.
  • Generate keys with -O verify-required; add -O resident only if you need ssh-keygen -K on new machines.
  • Enrol two tokens per admin, and add verify-required in front of each authorized_keys line.
  • Enforce with PubkeyAuthOptions touch-required verify-required and an sk-only PubkeyAcceptedAlgorithms. The first one alone doesn't stop plain keys.
  • Test on port 2222 first: sk key passes, plain key fails, no-PIN sk key fails.
  • After the reload, look for ED25519-SK in Accepted publickey lines in the journal.
  • Write down and test the recovery path: backup token, key removal procedure and console access.

Sources

Comments