3 Ways to Protect WireGuard With YubiKey

This article will show you how to use the OpenPGP card capabilities of a YubiKey to protect your WireGuard private keys. We’ll cover how to use a YubiKey to encrypt and store your WireGuard keys in:

  1. Your Password Store

  2. A File on Disk

  3. A PIV Slot of the YubiKey

A forth option is also possible: use the PGP decryption key of the YubiKey directly as a WireGuard private key. This, however, requires a customized version of the WireGuard client; see the WireGuard Key on an OpenPGP Card guide for details.

Why?

WireGuard’s security depends entirely on the secrecy of your private keys (with one exception: for connections that use a preshared key, the connection remains secure even when the private keys are compromised, as long as the preshared key remains secret). If an adversary steals your private keys (plus any preshared keys you use), she can decrypt the contents of your WireGuard connections, and impersonate you to the rest of your WireGuard network.

You usually don’t have to worry too much about your WireGuard keys being stolen — they will be safe as long as an adversary does not get root or physical access to your computer. However, they are vulnerable to being stolen under the following circumstances:

  1. Anyone with root access to your computer can easily find your WireGuard private keys and access them.

  2. If you back up the drives or directories in which your store your WireGuard config files (usually the /etc/wireguard directory of your root drive), anyone with access to your back-ups (and can decrypt your back-ups if encrypted) can easily find your WireGuard private keys and access them.

  3. Anyone with physical access to your computer to boot it from another drive (like a USB thumb-drive or network share), and can decrypt the drive or directories in which you store your WireGuard config files (if protected by disk- or filesystem-level encryption), can easily find your WireGuard private keys and access them.

  4. Anyone with the ability to dump your computer’s memory while your WireGuard interfaces are up — which usually would require root or physical access, but could be exposed by a vulnerability like Heartbleed or Rowhammer — can laboriously sift through your memory contents and find your WireGuard private keys.

The techniques shown in this article will mitigate the first three issues. The fourth issue is also possible to mitigate, but requires a customized version of the WireGuard client to interface directly with the YubiKey, as mentioned above.

All these techniques involve encrypting your WireGuard private keys with the PGP key stored in your YubiKey. This changes the game for an adversary from stealing your WireGuard private keys to stealing your PGP private keys. And if you generate your PGP keys on the YubiKey itself, this is impossible unless the adversary steals the physical YubiKey device.

So if you generated your PGP keys on the YubiKey itself, and require a YubiKey touch for each decryption attempt, the only way an adversary can access your WireGuard private keys will be one of the following:

  1. Trick you into decrypting your WireGuard private keys when you think you’re touching your YubiKey for some other purpose.

  2. Dump your computer’s memory while your WireGuard interfaces are up.

Note

For completeness, this article will show you how to encrypt your WireGuard preshared keys in addition to your WireGuard private keys. However, there is little practical benefit to encrypting your WireGuard preshared keys in the same manner as your WireGuard private keys, since any avenue an adversary finds to steal your WireGuard private keys, she will also use to steal your WireGuard preshared keys (and if she is not able to steal your private keys, then your preshared keys will be of no value).

Password Store

A convenient way to encrypt your WireGuard keys is to use the pass password manager (which was also created by the author of WireGuard, Jason A. Donenfeld). If you configure pass to use your YubiKey’s PGP key, each access of any key in pass’s password store will require a decryption operation by your YubiKey (and therefore requires your YubiKey to be physically present, and optionally that you touch it to confirm).

Diagram of WireGuard key loaded from password store

For example, if the key ID for the master PGP key on your YubiKey is 0x1234567890ABCDEF, you would use the following command to initialize your password store with it:

$ pass init 0x1234567890ABCDEF
mkdir: created directory: '/home/justin/.password-store'
Password store initialized for 0x1234567890ABCDEF.

You can then create a new WireGuard private key and store it in your password store with the following command:

$ wg genkey | pass insert -e wireguard/private-keys/wg0

Alternately, you can copy a WireGuard key you previously generated and paste it into your password store with the following command:

$ pass insert wireguard/private-keys/wg0
Enter password for wireguard/private-keys/wg0:

To derive the public key for a private key stored in your password store, run the following command:

$ pass wireguard/private-keys/wg0 | wg pubkey
/TOE4TKtAqVsePRVR+5AA43HkAK5DSntkOCO7nYq5xU=

This is the public key you put in the configuration of the remote hosts to which you want to connect from your local computer.

In your local WireGuard config file, replace the PrivateKey line with the following PostUp script (assuming the name of the WireGuard interface is wg0, and your username is justin):

PostUp = wg set wg0 private-key <(sudo -u justin pass wireguard/private-keys/wg0)
Tip

When wg-quick runs the scripts in a config file, it automatically replaces %i with the name of the WireGuard interface being configured. For example, in a WireGuard config file named wg0.conf, %i is replaced by wg0; in a file named corp-tun.conf, %i is replaced corp-tun; etc.

So if you use the naming convention in your password store like wireguard/private-keys/xyz for the private key of a WireGuard interface named xyz, you can use %i in place of the interface name in your PostUp script, like the following:

PostUp = wg set %i private-key <(sudo -u justin pass wireguard/private-keys/%i)

This will configure the WireGuard interface to start up without a private key; and then once the interface is up, set its private key to the wireguard/private-keys/wg0 password from your password store. The scripts in WireGuard config files are run as root, so in order to access your password store and decrypt a password with your YubiKey via your gpg-agent instance, you need to switch to your user account with the sudo command (or a similar command such as su, doas, etc).

Alternately, you can omit the PrivateKey line and the addition of a PostUp script from your WireGuard configuration, and set the private key for the interface manually, as a separate step after you start up the interface. For example, the following two commands start up the WireGuard interface wg0, and then set its private key from your password store:

$ sudo wg-quick up wg0
[#] ip link add wg0 type wireguard
[#] wg setconf wg0 /dev/fd/63
[#] ip -4 address add 10.0.0.1/32 dev wg0
[#] ip link set mtu 1420 up dev wg0
[#] ip -4 route add 192.168.200.0/24 dev wg0
$ pass wireguard/private-keys/wg0 | sudo wg set wg0 private-key /dev/fd/0

Doing this in two steps avoids the awkwardness of switching back to your own user account to call the pass command from within the switch to root required to start up the WireGuard interface. The /dev/fd/0 path in the wg set wg0 private-key command above tells the wg utility to read the private key from its stdin stream (into which the key is piped from the stdout stream of the pass program).

If you use preshared keys for certain WireGuard connections, you can also store them in your password store, and decrypt them for use with WireGuard the same way.

For example, you can generate and store a preshared key for the connection to another WireGuard host (“Host B”) with the following command:

$ wg genpsk | pass insert -e wireguard/preshared-keys/wg0/host-b

To retrieve the preshared key from your password store (so you can share it with Host B), you can run the following command:

$ pass wireguard/preshared-keys/wg0/host-b
/UwcSPg38hW/D9Y3tcS1FOV0K1wuURMbS0sesJEP5ak=

In your local WireGuard config file, replace the PresharedKey line for Host B with the following PostUp script (assuming the name of the WireGuard interface is wg0, the public key of the remote peer for Host B is fE/wdxzl0klVp/IR8UcaoGUMjqaWi3jAd7KzHKFS6Ds=, and your username is justin):

PostUp = wg set wg0 peer fE/wdxzl0klVp/IR8UcaoGUMjqaWi3jAd7KzHKFS6Ds= preshared-key <(sudo -u justin pass wireguard/preshared-keys/wg0/host-b)

Make sure you put the PostUp script in the [Interface] section of the config file, not the [Peer] section. Include a separate PostUp script for each preshared key.

The following example shows how you might set up a config file for a WireGuard interface wg0, where its private key is stored in your password store under wireguard/private-keys/wg0, with a preshared key for its connection to Host B stored in your password store under wireguard/preshared-keys/wg0/host-b, and a preshared key for its connection to Host C stored in your password store under wireguard/preshared-keys/wg0/host-c:

# /etc/wireguard/wg0.conf

# local settings for my computer
[Interface]
Address = 10.0.0.1/32

PostUp = wg set %i private-key <(sudo -u justin pass wireguard/private-keys/%i)
PostUp = wg set %i peer fE/wdxzl0klVp/IR8UcaoGUMjqaWi3jAd7KzHKFS6Ds= preshared-key <(sudo -u justin pass wireguard/preshared-keys/%i/host-b)
PostUp = wg set %i peer jUd41n3XYa3yXBzyBvWqlLhYgRef5RiBD7jwo70U+Rw= preshared-key <(sudo -u justin pass wireguard/preshared-keys/%i/host-c)

# remote settings for Host B
[Peer]
PublicKey = fE/wdxzl0klVp/IR8UcaoGUMjqaWi3jAd7KzHKFS6Ds=
Endpoint = 203.0.113.2:51820
AllowedIPs = 192.168.200.0/24

# remote settings for Host C
[Peer]
PublicKey = jUd41n3XYa3yXBzyBvWqlLhYgRef5RiBD7jwo70U+Rw=
Endpoint = 192.0.2.3:51820
AllowedIPs = 10.0.0.0/24

File on Disk

If you don’t want to use the pass program to manage your WireGuard keys, you can simply use regular PGP-encrypted files on disk.

Diagram of WireGuard key loaded from file on disk

For example, if the key ID for the master PGP key on your YubiKey is 0x1234567890ABCDEF, you would use the following command to create a WireGuard private key, and store it encrypted in some file on your local disk (like ~/.config/wireguard/wg0.key.gpg) with your YubiKey PGP encryption key:

$ mkdir -p ~/.config/wireguard && chmod 700 ~/.config/wireguard
$ wg genkey | gpg --encrypt --recipient 0x1234567890ABCDEF --output ~/.config/wireguard/wg0.key.gpg

Alternately, you can save a WireGuard key you previously generated, as a single line in a file, and encrypt it like the following (as a file called wg0.key.gpg):

$ cat wg0.key
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAEE=
$ gpg --encrypt --recipient 0x1234567890ABCDEF wg0.key
$ ls wg0.key*
wg0.key  wg0.key.gpg
$ rm wg0.key

To derive the public key from an encrypted private key, run the following command:

$ gpg --decrypt ~/.config/wireguard/wg0.key.gpg | wg pubkey
gpg: encrypted with 255-bit ECDH key, ID 0x1234567890ABCDEF, created 2023-01-01
      "Justin (key1) <justin@example.com>"
/TOE4TKtAqVsePRVR+5AA43HkAK5DSntkOCO7nYq5xU=

This is the public key you put in the configuration of the remote hosts to which you want to connect from your local computer.

In your local WireGuard config file, replace the PrivateKey line with the following PostUp script (assuming the name of the WireGuard interface is wg0, and your username is justin):

PostUp = wg set wg0 private-key <(sudo -u justin gpg -d /home/justin/.config/wireguard/wg0.key.gpg)

This will configure the WireGuard interface to start up without a private key; and then once the interface is up, set its private key to the decrypted contents of the /home/justin/.config/wireguard/wg0.key.gpg file.

The scripts in WireGuard config files are run as root, so in order to decrypt files with your YubiKey via your gpg-agent instance, you need to switch to your user account with the sudo command (or a similar command such as su, doas, etc). And because the decryption command is run as your user account, make sure you store the encrypted WireGuard private key in a file to which your user account has access. A directory in your home folder (like ~/.config/wireguard) is good for this.

Alternately, you can omit the PrivateKey line and the addition of a PostUp script from your WireGuard configuration, and set the private key for the interface manually, as a separate step after you start up the interface. For example, the first of the following two commands starts up the WireGuard interface wg0; and then the second command decrypts the private key for wg0, and updates the interface with it:

$ sudo wg-quick up wg0
[#] ip link add wg0 type wireguard
[#] wg setconf wg0 /dev/fd/63
[#] ip -4 address add 10.0.0.1/32 dev wg0
[#] ip link set mtu 1420 up dev wg0
[#] ip -4 route add 192.168.200.0/24 dev wg0
$ gpg --decrypt ~/.config/wireguard/wg0.key.gpg | sudo wg set wg0 private-key /dev/fd/0
gpg: encrypted with 255-bit ECDH key, ID 0x1234567890ABCDEF, created 2023-01-01
      "Justin (key1) <justin@example.com>"

Doing this in two steps avoids the awkwardness of switching back to your own user account to call the decryption command from within the switch to root required to start up the WireGuard interface. The /dev/fd/0 path in the wg set wg0 private-key command above tells the wg utility to read the private key from its stdin stream (into which the key is piped from the stdout stream of the GnuPG program).

If you use preshared keys for certain WireGuard connections, you can also encrypt them in the same way.

For example, you can generate and encrypt a preshared key for the connection to another WireGuard host (“Host B”) with the following command:

$ wg genpsk | gpg --encrypt --recipient 0x1234567890ABCDEF --output ~/.config/wireguard/wg0-psk-host-b.key.gpg

To decrypt the preshared key (so you can share it with Host B), run the following command:

$ gpg --decrypt ~/.config/wireguard/wg0-psk-host-b.key.gpg
gpg: encrypted with 255-bit ECDH key, ID 0x1234567890ABCDEF, created 2023-01-01
      "Justin (key1) <justin@example.com>"
/UwcSPg38hW/D9Y3tcS1FOV0K1wuURMbS0sesJEP5ak=

In your local WireGuard config file, replace the PresharedKey line for Host B with the following PostUp script (assuming the name of the WireGuard interface is wg0, the public key of the remote peer for Host B is fE/wdxzl0klVp/IR8UcaoGUMjqaWi3jAd7KzHKFS6Ds=, and your username is justin):

PostUp = wg set wg0 peer fE/wdxzl0klVp/IR8UcaoGUMjqaWi3jAd7KzHKFS6Ds= preshared-key <(sudo -u justin gpg -d /home/justin/.config/wireguard/wg0-psk-host-b.key.gpg)

Make sure you put the PostUp script in the [Interface] section of the config file, not the [Peer] section. Include a separate PostUp script for each preshared key.

The following example shows how you might set up a config file for a WireGuard interface wg0, with its private key and two preshared keys encrypted with your YubiKey PGP key, and stored in your home folder within the ~/.config/wireguard directory:

# /etc/wireguard/wg0.conf

# local settings for my computer
[Interface]
Address = 10.0.0.1/32

PostUp = wg set %i private-key <(sudo -u justin gpg -d /home/justin/.config/wireguard/%i.key.gpg)
PostUp = wg set %i peer fE/wdxzl0klVp/IR8UcaoGUMjqaWi3jAd7KzHKFS6Ds= preshared-key <(sudo -u justin gpg -d /home/justin/.config/wireguard/wg0-psk-host-b.key.gpg)
PostUp = wg set %i peer jUd41n3XYa3yXBzyBvWqlLhYgRef5RiBD7jwo70U+Rw= preshared-key <(sudo -u justin gpg -d /home/justin/.config/wireguard/wg0-psk-host-c.key.gpg)

# remote settings for Host B
[Peer]
PublicKey = fE/wdxzl0klVp/IR8UcaoGUMjqaWi3jAd7KzHKFS6Ds=
Endpoint = 203.0.113.2:51820
AllowedIPs = 192.168.200.0/24

# remote settings for Host C
[Peer]
PublicKey = jUd41n3XYa3yXBzyBvWqlLhYgRef5RiBD7jwo70U+Rw=
Endpoint = 192.0.2.3:51820
AllowedIPs = 10.0.0.0/24

PIV Slot

In addition to functioning as an OpenPGP card, a YubiKey also has the functionality of a US Federal PIV (Personal Identity Verification) card. A bonus feature of this functionality is that the YubiKey offers about 65,000 unused PIV slots in which you can store small blobs of data on the card itself (the PIV card standard uses only about 50 slots). These unused slots can be read without requiring a PIN or touch, and up to 3 KB of data can be stored in each slot.

This allows you to improve a bit on the security of the above Password Store and File on Disk techniques by storing your PGP-encrypted WireGuard keys as opaque blobs in unused PIV slots. This can mitigate the (fairly narrow) scenario where you did not generate your PGP key on the YubiKey device itself, and had a back-up of your PGP keys — along with the contents of your computer’s hard drive — stolen by an adversary.

Diagram of WireGuard key loaded from PIV slot

In that case, if you had stored your WireGuard keys on your hard drive, the adversary would be able to use the stolen PGP key to decrypt them; but if you store your WireGuard keys in the PIV slots of your YubiKey, the adversary would have to mount a targeted attack to gain direct access to your computer and read your WireGuard keys from your YubiKey while it was plugged in (or steal the physical YubiKey device itself).

Note

Writing to the PIV slots requires the PIV application’s management key or PIN — even though reading from the slots does not. If you have not previously overridden the default management key and PIN, you should do so, as this allows you to protect the integrity of all data stored in these slots, even without protecting their confidentiality. See the PIV PINs section of the An Opinionated YubiKey Set-Up Guide for a complete tutorial.

To create a WireGuard private key and store it in a PIV slot, run the following command, using the YubiKey Manager CLI to interact with the PIV application:

$ ykman piv objects import 5f0000 <(wg genkey | gpg --encrypt --recipient 0x1234567890ABCDEF)
Enter PIN:
Touch your YubiKey...

In the above command, change 0x1234567890ABCDEF to the key ID of the master PGP key for your YubiKey, and change 5f0000 to the hex ID of the PIV slot to use. You can use any slot from 5f0000 to 5fffff — except for slots 5fc100 through 5fc12f, which are defined for use by the PIV application itself (and if you write arbitrary data to them, you may brick the PIV application). The PIN you will be prompted for is the PIN for the PIV management key — not the PIN for your PGP key.

Note

The YubiKey Manager CLI, needed to access your YubiKey PIV slots, uses the standard pcsc-lite daemon to access your YubiKey, whereas GnuPG uses its own homegrown scdaemon — unfortunately these two daemons do not always get along.

With older versions of GnuPG, it was possible to resolve GnuPG and PC/SC conflicts by adding a disable-ccid flag to your ~/.gnupg/scdaemon.conf config file. However, even with this fix, later versions of the GnuPG 2.2.x branch (still in use by many Linux distributions, notably Ubuntu 22.04, the current Ubuntu LTS release) will intermittently fail for a few tries to access your YubiKey after the YubiKey Manager CLI is used to access the YubiKey.

Fortunately, the current GnuPG stable branch (2.4.x) seems to have solved these failures. You can upgrade to it by downloading the latest GnuPG source code and building it on your local machine. See the Installing GnuPG 2.4 on Ubuntu 22.04 guide for step-by-step instructions.

Alternately, you can encrypt and store a WireGuard key you previously generated, as a single line in a file, by encrypting and writing it to the PIV 5f0000 slot like the following:

$ cat wg0.key
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAEE=
$ gpg --encrypt --recipient 0x1234567890ABCDEF wg0.key
$ ykman piv objects import 5f0000 wg0.key.gpg
Enter PIN:
Touch your YubiKey...
$ ls wg0.key*
wg0.key  wg0.key.gpg
$ rm wg0.key*

To derive the public key from an encrypted private key stored in PIV slot 5f0000, run the following command:

$ ykman piv objects export 5f0000 - | gpg --decrypt | wg pubkey
gpg: encrypted with 255-bit ECDH key, ID 0x1234567890ABCDEF, created 2023-01-01
      "Justin (key1) <justin@example.com>"
/TOE4TKtAqVsePRVR+5AA43HkAK5DSntkOCO7nYq5xU=

This is the public key you put in the configuration of the remote hosts to which you want to connect from your local computer.

In your local WireGuard config file, replace the PrivateKey line with the following PostUp script (assuming the name of the WireGuard interface is wg0, you’ve stored the private key in PIV slot 5f0000, and your username is justin):

PostUp = wg set wg0 private-key <(ykman piv objects export 5f0000 - | sudo -u justin gpg -d)

This will configure the WireGuard interface to start up without a private key; and then once the interface is up, set its private key to the decrypted contents of PIV slot 5f0000. The scripts in WireGuard config files are run as root, so in order to decrypt files with your YubiKey via your gpg-agent instance, you need to switch to your user account with the sudo command (or a similar command such as su, doas, etc).

Alternately, you can omit the PrivateKey line and the addition of a PostUp script from your WireGuard configuration, and set the private key for the interface manually, as a separate step after you start up the interface. For example, the first of the following two commands starts up the WireGuard interface wg0; and then the second command decrypts the private key for wg0, and updates the interface with it:

$ sudo wg-quick up wg0
[#] ip link add wg0 type wireguard
[#] wg setconf wg0 /dev/fd/63
[#] ip -4 address add 10.0.0.1/32 dev wg0
[#] ip link set mtu 1420 up dev wg0
[#] ip -4 route add 192.168.200.0/24 dev wg0
$ ykman piv objects export 5f0000 - | gpg --decrypt | sudo wg set wg0 private-key /dev/fd/0
gpg: encrypted with 255-bit ECDH key, ID 0x1234567890ABCDEF, created 2023-01-01
      "Justin (key1) <justin@example.com>"

Doing this in two steps avoids the awkwardness of switching back to your own user account to call the decryption command from within the switch to root required to start up the WireGuard interface. The /dev/fd/0 path in the wg set wg0 private-key command above tells the wg utility to read the private key from its stdin stream (into which the key is piped from the stdout stream of the GnuPG program).

If you use preshared keys for certain WireGuard connections, you can also encrypt them in the same way.

For example, you can generate and encrypt a preshared key for the connection to another WireGuard host (“Host B”) with the following command, storing it in PIV slot 5f0001:

$ ykman piv objects import 5f0001 <(wg genpsk | gpg --encrypt --recipient 0x1234567890ABCDEF)
Enter PIN:
Touch your YubiKey...

To decrypt the preshared key (so you can share it with Host B), run the following command:

$ ykman piv objects export 5f0001 - | gpg --decrypt
gpg: encrypted with 255-bit ECDH key, ID 0x1234567890ABCDEF, created 2023-01-01
      "Justin (key1) <justin@example.com>"
/UwcSPg38hW/D9Y3tcS1FOV0K1wuURMbS0sesJEP5ak=

In your local WireGuard config file, replace the PresharedKey line for Host B with the following PostUp script (assuming the name of the WireGuard interface is wg0, the public key of the remote peer for Host B is fE/wdxzl0klVp/IR8UcaoGUMjqaWi3jAd7KzHKFS6Ds=, and your username is justin):

PostUp = wg set wg0 peer fE/wdxzl0klVp/IR8UcaoGUMjqaWi3jAd7KzHKFS6Ds= preshared-key <(ykman piv objects export 5f0001 - | sudo -u justin gpg -d)

Make sure you put the PostUp script in the [Interface] section of the config file, not the [Peer] section. Include a separate PostUp script for each preshared key.

The following example shows how you might set up a config file for a WireGuard interface wg0, where its private key is stored in PIV slot 5f0000, with a preshared key for its connection to Host B stored in PIV slot 5f0001, and a preshared key for its connection to Host C stored PIV slot 5f0002:

# /etc/wireguard/wg0.conf

# local settings for my computer
[Interface]
Address = 10.0.0.1/32

PostUp = wg set %i private-key <(ykman piv objects export 5f0000 - | sudo -u justin gpg -d)
PostUp = wg set %i peer fE/wdxzl0klVp/IR8UcaoGUMjqaWi3jAd7KzHKFS6Ds= preshared-key <(ykman piv objects export 5f0001 - | sudo -u justin gpg -d)
PostUp = wg set %i peer jUd41n3XYa3yXBzyBvWqlLhYgRef5RiBD7jwo70U+Rw= preshared-key <(ykman piv objects export 5f0002 - | sudo -u justin gpg -d)

# remote settings for Host B
[Peer]
PublicKey = fE/wdxzl0klVp/IR8UcaoGUMjqaWi3jAd7KzHKFS6Ds=
Endpoint = 203.0.113.2:51820
AllowedIPs = 192.168.200.0/24

# remote settings for Host C
[Peer]
PublicKey = jUd41n3XYa3yXBzyBvWqlLhYgRef5RiBD7jwo70U+Rw=
Endpoint = 192.0.2.3:51820
AllowedIPs = 10.0.0.0/24