HOWTO
YUBIKEY
PASSWORDLESS REMOTE SERVER ADMINISTRATION WITH SSH, SUDO AND YUBIKEY

Published: 20221225

Tested on: OpenSSH 8.2p1 (Ubuntu 20.04.5 LTS stock) (both x86_64 and arm64)

* Note that YubiKey support was added in OpenSSH 8.2p1, so older versions will not work.

* In this article the words "YubiKey" (a product name from the company Yubico) and "U2F key" are used interchangeably. A YubiKey can have one or several authentication "methods" (or applications) and one of these are U2F. For all intents and purposes of this article, you can think: "a YubiKey is a U2F key".

* This article was released together with another article I wrote: Notes on using local sudo with YubiKey
While the other article is related in topic, there's nothing in it that you need in order to do the steps in this article.

-

In this HOWTO we have two machines:

client1 - Our local machine where we have access to the USB ports so we can insert a YubiKey.

server1 - Our remote server which is reachable via SSH.

Let's get started!

New functionality in OpenSSH

U2F in SSH works like this:

  1. The SSH server has several methods of authentication, one of them being public key authentication (the "authorized_keys"-file). Historically the public key types have been: RSA, DSA, ECDSA and ED25519. But starting with OpenSSH 8.2p1 it now also supports ECDSA-SK and ED25519-SK. The "-SK" stands for "Security Key" and this means U2F key.

  2. The SSH client has long been able to create a public key pair with the key type: RSA, DSA, ECDSA or ED25519. Such a key pair can be hardened by adding a passphrase. This in turn requires manual input of the passphrase while using the key pair OR requires using ssh-agent (more on this later).

The new key types: ECDSA-SK and ED25519-SK are hardened by being "built" together with your U2F key. This means that upon creating the key pair you need to authenticate your U2F key and this means that every time you use your SSH key pair you need to also "unlock it locally" by authenticating the same U2F key, thus creating a type of MFA.

Create a U2F hardened SSH key pair

Let's create our U2F hardened key pair:

client1$ ssh-keygen -t ed25519-sk -f example1.key
Generating public/private ed25519-sk key pair.
You may need to touch your authenticator to authorize key generation.

The YubiKey should now be blinking green and you need to touch the metal (gold colored) part of the physical key. If you wait to long the process will timeout, the green light will stop blinking and you can just retype the command to start over.

client1$ ssh-keygen -t ed25519-sk -f example1.key
Generating public/private ed25519-sk key pair.
You may need to touch your authenticator to authorize key generation.
Enter passphrase (empty for no passphrase):
Enter same passphrase again:
Your identification has been saved in example1.key
Your public key has been saved in example1.key.pub
The key fingerprint is:
SHA256:x/6OsfhyaWxx5QeyaoSWb8zubDlvER3Gb53DecJ9lno user1@client1
The key's randomart image is:
+[ED25519-SK 256]-+
|       o   .     |
|      + . . *    |
|     . = . B =.o+|
|        O.+ o    |
|       +S+o. o.o+|
|        =o.  . E |
|       + ooo  .  |
|        B.+=     |
|       ..B=.o    |
+----[SHA256]-----+
client1$

Then as you might've figured, all you need to do now is to export the public key to the remote server and put it in server:~/.ssh/authorized_keys

Every time you login using this ED25519-SK key, the OpenSSH client will require you to insert the right YubiKey and press "the button".

How to add another (backup) YubiKey

If you want to add another YubiKey, you do like this:

  1. Extract your YubiKey
  2. Insert another (backup) YubiKey
  3. Create a new key pair, with a new name (i.e. example2) using the other YubiKey
client1$ ssh-keygen -t ed25519-sk -f example2.key
  1. Export the public key to the remote server and put it also in server:~/.ssh/authorized_keys

When using YubiKeys it's always wise to add backup key.

Yes, I know, if we use standard OpenSSH settings there are other ways, other key types that can be used for backup access. But since the rest of this article is about setting up a server that always requires U2F, it is wise to add another key.

You can specify which specific key you want to use when you login to your SSH server by adding the "-i flag":

client1$ ssh -i ~/.ssh/example1.key user1@server1

Restricting the server to only allow YubiKey access

Before you make any changes to sshd_config, make sure you have at least one logged in session that is also root. This is because it is easy to make mistakes, and making a mistake while messing sshd_config can lock you out of the server altogether.

It's not uncommon to setup an SSH server that only allows public key pair authentication. Not only does this disable login by password, it eliminates the risk of bots and worms being able to brute force their way into the server by just guessing the username and password.

That configuration is enabled by having these lines in /etc/ssh/sshd_config

PubkeyAuthentication yes
PasswordAuthentication no

Password authentication is usually set to "yes", so setting "PasswordAuthentication no" requires a restart of the SSH service:

server1$ sudo systemctl restart ssh
server1$ 

But we can take it one step further...

We can force the server to only accept public key authentication and to only allowing certain key types. In this case we set it to only accept ECDSA-SK and ED25519-SK. The effect will then be that all logins will be made via the use of YubiKey on the SSH client end. (You can read more about this in the manpage for sshd_config, under the section "PubkeyAcceptedKeyTypes".)

Make sure this line is in /etc/ssh/sshd_config

PubkeyAcceptedKeyTypes sk-ecdsa-sha2-nistp256-cert-v01@openssh.com,sk-ssh-ed25519-cert-v01@openssh.com,sk-ecdsa-sha2-nistp256@openssh.com,sk-ssh-ed25519@openssh.com

Don't forget to restart the SSH service:

server1$ sudo systemctl restart ssh
server1$ 

Let's test with an ED25519 key:

client1$ ssh -i keyfile_ed25519.key user1@server1
user1@server1: Permission denied (publickey).
client1$ 

Let's test with an ED25519-SK key:

client1$ ssh -i keyfile_ed25519_sk.ey user1@server1
Confirm user presence for key ED25519-SK SHA256:x/6OsfhyaWxx5QeyaoSWb8zubDlvER3Gb53DecJ9lno

The YubiKey is now blinking green and we press "the button".

client1$ ssh -i keyfile_ed25519_sk.ey user1@server1
Confirm user presence for key ED25519-SK SHA256:x/6OsfhyaWxx5QeyaoSWb8zubDlvER3Gb53DecJ9lno
user1@server1$ 

Success! You now have a server that only accepts login with public keys hardened with U2F keys.

The user doesn't even need to have a password set.

Translating local U2F authentication into remote sudo

But what if the user also needs to have sudo access on the remote server? How do we setup so the user can authenticate locally on the client side with a YubiKey button press instead of having to type a password on the server?

The process looks like this:

  1. The user has an ED25519-SK key pair already registered with the server

  2. The user starts by adding the ED25519-SK key to an ssh-agent session:

client1$ ssh-add ~/.ssh/keyfile_ed25519_sk.key
Identity added: .ssh/keyfile_ed25519_sk.key (user1@client1)
client1$ 
  1. The user then connects to the server and enables "agent forwarding" by using the "-A flag"
client1$ ssh server1 -A

This step will trigger the YubiKey. It will blink green, you need to press the metal (gold colored) part to continue. This is because you're using your hardened ED25519-SK key to login.

client1$ ssh server1 -A
server1$

Since this "glues" the client environment with the server environment, a lot of trust in the client is required. Consider only doing this from a secure client, i.e. a client not using a web browser or mail program.

  1. The user needs to be in the sudoers file (on the server), but the sudoers file also needs to allow "ssh-agent users".

I assume you know how to add the user, but we need to enable the special ssh-agent also.

First, edit the sudoers file:

server1$ sudo visudo

At the bottom, add this line:

Defaults env_keep += "SSH_AUTH_SOCK"
  1. By the use of a special PAM module, the server can "catch" the "ssh agent forwarding" and use it session to trigger server authentication on the client side.

There is a PAM module called "libpam-ssh-agent-auth" which allows you to use your client side SSH key pairs to authenticate on the server, via ssh-agent. This module however doesn't have support yet for ECDSA-SK or ED25519-SK, so while a good idea, we can't use it for this specific use case.

There is a PAM module on github called: "z4yx/pam_rssh" but it is unusable because the build process makes additional git calls and the author has somehow managed to make those calls dependant on git sources that only the author has access to.

However, there is a working fork on github called: "jeremie-H/pam_rssh" where the new author has fixed these errors; and this module works.

--

First, install the dependencies:

$ sudo apt install libssl-dev libpam-dev cargo

Download the repo:

server1$ git clone --recurse-submodule  https://github.com/jeremie-H/pam_rssh.git
Cloning into 'pam_rssh'...
remote: Enumerating objects: 235, done.
remote: Counting objects: 100% (235/235), done.
remote: Compressing objects: 100% (148/148), done.
remote: Total 235 (delta 146), reused 146 (delta 73), pack-reused 0
Receiving objects: 100% (235/235), 46.10 KiB | 2.43 MiB/s, done.
Resolving deltas: 100% (146/146), done.
Submodule 'dep/pam-rs' (https://github.com/jeremie-H/pam-rs.git) registered for path 'dep/pam-rs'
Submodule 'dep/ssh-agent.rs' (https://github.com/jeremie-H/ssh-agent.rs.git) registered for path 'dep/ssh-agent.rs'
Cloning into '/home/user1/pam_rssh/dep/pam-rs'...
remote: Enumerating objects: 189, done.        
remote: Counting objects: 100% (5/5), done.        
remote: Compressing objects: 100% (5/5), done.        
remote: Total 189 (delta 0), reused 2 (delta 0), pack-reused 184        
Receiving objects: 100% (189/189), 39.52 KiB | 1.23 MiB/s, done.
Resolving deltas: 100% (91/91), done.
Cloning into '/home/user1/pam_rssh/dep/ssh-agent.rs'...
remote: Enumerating objects: 371, done.        
remote: Counting objects: 100% (59/59), done.        
remote: Compressing objects: 100% (35/35), done.        
remote: Total 371 (delta 29), reused 41 (delta 22), pack-reused 312        
Receiving objects: 100% (371/371), 75.06 KiB | 3.13 MiB/s, done.
Resolving deltas: 100% (211/211), done.
Submodule path 'dep/pam-rs': checked out 'd0346525b36d3bbf89e658ac3b4432cf906d78d9'
Submodule path 'dep/ssh-agent.rs': checked out '802b94ccf2e00ac33a3863300d0769f02b62d807'
server1$ 

Build the code:

server1$ cd pam_rssh
server1:~/pam_rssh$ cargo build --release
    Updating crates.io index
  Downloaded cfg-if v1.0.0
  Downloaded quote v1.0.15
  Downloaded foreign-types v0.3.2
  Downloaded unicode-xid v0.2.2
  Downloaded pwd v1.4.0
  Downloaded serde_derive v1.0.136
  Downloaded proc-macro2 v1.0.36
  Downloaded once_cell v1.9.0
  Downloaded serde v1.0.136
  Downloaded time v0.1.44
  Downloaded syn v1.0.86
  Downloaded log v0.4.14
  Downloaded byteorder v1.4.3
  Downloaded openssl v0.10.38
  Downloaded openssl-sys v0.9.72
  Downloaded libc v0.2.117
  Downloaded version_check v0.9.4
  Downloaded thiserror v1.0.38
  Downloaded pkg-config v0.3.24
  Downloaded foreign-types-shared v0.1.1
  Downloaded syslog v5.0.0
  Downloaded multisock v1.0.0
  Downloaded libc v0.1.12
  Downloaded thiserror-impl v1.0.38
  Downloaded cc v1.0.72
  Downloaded futures v0.1.31
  Downloaded autocfg v1.1.0
  Downloaded base64 v0.13.0
  Downloaded bitflags v1.3.2
  Downloaded error-chain v0.12.4
  Downloaded 30 crates (1.9 MB) in 1.05s
   Compiling proc-macro2 v1.0.36
   Compiling unicode-xid v0.2.2
   Compiling syn v1.0.86
   Compiling libc v0.2.117
   Compiling serde_derive v1.0.136
   Compiling pkg-config v0.3.24
   Compiling cc v1.0.72
   Compiling serde v1.0.136
   Compiling autocfg v1.1.0
   Compiling cfg-if v1.0.0
   Compiling version_check v0.9.4
   Compiling log v0.4.14
   Compiling thiserror v1.0.38
   Compiling openssl v0.10.38
   Compiling foreign-types-shared v0.1.1
   Compiling bitflags v1.3.2
   Compiling futures v0.1.31
   Compiling once_cell v1.9.0
   Compiling libc v0.1.12
   Compiling byteorder v1.4.3
   Compiling multisock v1.0.0
   Compiling base64 v0.13.0
   Compiling error-chain v0.12.4
   Compiling foreign-types v0.3.2
   Compiling pam v0.1.0 (/home/user1/pam_rssh/dep/pam-rs/pam)
   Compiling openssl-sys v0.9.72
warning: `extern` block uses type `Box<PamDataT>`, which is not FFI-safe
  --> dep/pam-rs/pam/src/module.rs:32:27
   |
32 |                     data: Box<PamDataT>,
   |                           ^^^^^^^^^^^^^ not FFI-safe
   |
   = note: `#[warn(improper_ctypes)]` on by default
   = help: consider adding a `#[repr(C)]` or `#[repr(transparent)]` attribute to this struct
   = note: this struct has unspecified layout
warning: `extern` block uses type `Box<PamDataT>`, which is not FFI-safe
  --> dep/pam-rs/pam/src/module.rs:33:30
   |
33 |                       cleanup: extern "C" fn(pamh: *const PamHandle,
   |  ______________________________^
34 | |                                            data: Box<PamDataT>,
35 | |                                            error_status: PamResultCode))
   | |_______________________________________________________________________^ not FFI-safe
   |
   = help: consider adding a `#[repr(C)]` or `#[repr(transparent)]` attribute to this struct
   = note: this struct has unspecified layout
warning: functions generic over types or consts must be mangled
  --> dep/pam-rs/pam/src/module.rs:55:1
   |
54 |   #[no_mangle]
   |   ------------ help: remove this attribute
55 | / pub extern "C" fn cleanup<T>(_: *const PamHandle, c_data: Box<PamDataT>, _: PamResultCode) {
56 | |     unsafe {
57 | |         let data: Box<T> = mem::transmute(c_data);
58 | |         mem::drop(data);
59 | |     }
60 | | }
   | |_^
   |
   = note: `#[warn(no_mangle_generic_items)]` on by default
warning: `pam` (lib) generated 3 warnings
   Compiling quote v1.0.15
   Compiling time v0.1.44
   Compiling thiserror-impl v1.0.38
   Compiling pwd v1.4.0
   Compiling ssh-agent v0.2.3 (/home/user1/pam_rssh/dep/ssh-agent.rs)
   Compiling syslog v5.0.0
   Compiling pam_rssh v0.2.0 (/home/user1/pam_rssh)
    Finished release [optimized] target(s) in 1m 00s
server1:~/pam_rssh$ 
   ```
Copy the binary into the system:
``` cli-linux
server1:~/pam_rssh$ sudo cp target/release/libpam_rssh.so /usr/local/lib/
server1:~/pam_rssh$ cd
server1:$

Before you alter any PAM configurations, make sure you have at least one terminal logged in as root. This is because it is easy to make mistakes when you're messing with PAM, and making a mistake means that you will lock yourself out of root privileges of your own machine.

Edit the sudo PAM configuration:

server1$ sudo vi /etc/pam.d/sudo

The file should look like this:

#%PAM-1.0
session    required   pam_env.so readenv=1 user_readenv=0
session    required   pam_env.so readenv=1 envfile=/etc/default/locale user_readenv=0
@include common-auth
@include common-account
@include common-session-noninteractive

Add one line just above "@include common-auth":

#%PAM-1.0
session    required   pam_env.so readenv=1 user_readenv=0
session    required   pam_env.so readenv=1 envfile=/etc/default/locale user_readenv=0
auth sufficient /usr/local/lib/libpam_rssh.so
@include common-auth
@include common-account
@include common-session-noninteractive

The line you added means:

When the user calls on sudo,
the PAM configuration for sudo,
will first let you authenticate with libpam_rssh.

If that succeeds, then it is sufficient and sudo is approved.
If that fails/times out, then regular authentication continues.

However, if the user doesn't have a password set on the server1, then "@include common-auth" will not really lead anywhere. Therefore, sudo via client side YubiKey / U2F key is the only way (for this user) to gain sudo on this server.

Let's test it out:

client1$ ssh-add ~/.ssh/keyfile_ed25519_sk.key
Identity added: /home/user1/.ssh/keyfile_ed25519_sk.key (user1@client1)
client1$ ssh -A server1

Now the YubiKey blinks green, touch the metal part to continue.

client1$ ssh-add ~/.ssh/keyfile_ed25519_sk.key
Identity added: /home/user1/.ssh/keyfile_ed25519_sk.key (user1@client1)
client1$ ssh -A server1
server1$ sudo id

Now again they YubiKey blinks green, touch the metal part to continue.

client1$ ssh-add ~/.ssh/keyfile_ed25519_sk.key
Identity added: /home/user1/.ssh/keyfile_ed25519_sk.key (user1@client1)
client1$ ssh -A server1
server1$ sudo id
uid=0(root) gid=0(root) groups=0(root)
server1$ 

Success!