Based upon https://github.com/drduh/YubiKey-Guide
- Setup Docker
- Prepare the Yubikey
- Generate GPG-keys
- Move GPG-keys to Yubikey
- Clean-up
- Usage
- Restore from Backup
Create an ephemeral Docker container (with all required prerequisites installed
— see 📄 Dockerfile
) and start it without network access:
docker run --network none --privileged -v /dev/bus/usb:/dev/bus/usb \
--rm -it $(docker build --no-cache -q .)
Unless explicitly mentioned otherwise, all the below instructions assumes you are running commands from withint the container.
❗N.B. Stop pcscd
(and/or anything else that might have a lock on the
Yubikey) on the host machine before attempting to start pcscd
in the
container...
❗N.B. As of Ubuntu 24.04 (more specifically, GnuPG 2.4), GnuPG doesn't use
pcscd
to access the Yubikey anymore – so it's possible you don't have pcscd
on your host machine at all. See 📂 ~/.gnupg
for more
details on what this change means for using the Yubikey on Ubuntu 24.04; it
shouldn't have any impact on the below instructions though...
The Docker container is used purely out of convenience (an easy way to tool up a clean/prepared environment). From a security perspective there is little benefit in doing things this way. Also, I'm moving an unencrypted backup of the master key (protected with its passphrase though) around on my daily-use machine. If you're truly concerned about security, best to not follow the below set of instructions at all... 😇
Firstly, ensure pcscd
is running inside the container and that you can
communicate with the Yubikey:
pcscd
ykman info
Then, reset the Yubikey and prepare it for use:
ykman openpgp reset
gpg --gen-random --armor 0 32
# Take note of the Yubikey OpenPGP Admin PIN; store it in KeePass
gpg --card-edit
> admin
> kdf-setup
> passwd
>> 3
>> 1
>> q
> name
>> ...
> salutation
>> ...
> login
>> ...
> lang
>> en
> quit
ykman openpgp access set-retries 3 3 5
Due to the complexity of the Admin PIN, 5 retries seems prudent.
❗N.B. The Reset PIN isn't necessary for personal use. It's only function is to reset the regular PIN (which is also possible with the Admin PIN) — mainly intended for enterprise scenarios.
Finally, enforce "touch" for all key operations:
ykman openpgp keys set-touch aut cached-fixed
ykman openpgp keys set-touch sig cached-fixed
ykman openpgp keys set-touch enc cached-fixed
The cached-fixed
-option does the following:
Touch required, cached for 15s after use, cannot be disabled without deleting the private key.
See Yubikey Touch Policies for alternative policies.
Firstly, set up a temporary GnuPG home-directory:
export GNUPGHOME=$(mktemp -d -t gnupg_$(date +%Y%m%d%H%M)_XXX)
cp ~/gpg.conf $GNUPGHOME/gpg.conf
cp ~/gen-params-ed25519.conf $GNUPGHOME/gen-params-ed25519.conf
# Add real name and e-mail address to configuration
sed -i '/^Name-Real:/s/:/: ______/g' $GNUPGHOME/gen-params-ed25519.conf
sed -i '/^Name-Email:/s/:/: ______/g' $GNUPGHOME/gen-params-ed25519.conf
Then generate a new set of GPG-keys:
gpg --gen-random --armor 0 32
# Take note of the master key's passphrase; store it in KeePass
gpg --batch --generate-key $GNUPGHOME/gen-params-ed25519.conf
gpg -K
# Export master key fingerprint and ID for later use
export KEYFP="______"
export KEYID=0x______
gpg --quick-add-key "$KEYFP" ed25519 sign 1y
gpg --quick-add-key "$KEYFP" cv25519 encrypt 1y
gpg --quick-add-key "$KEYFP" ed25519 auth 1y
# Add as many UIDs (i.e., e-mail addresses) as required
gpg --expert --edit-key $KEYID
> adduid
> ...
> uid 2 # select UID 2
> trust
>> 5
> uid 2 # deselect UID 2
> ... # Add more UIDs
> uid 1 # select UID 1
> primary
> save
A dot (.
) behind the UID indicates it's the primary UID; an asterisk (*
)
indicates the UID is active/selected.
gpg --export $KEYID | hokey lint
hokey may warn (orange text) about cross certification for the authentication key. GPG's Signing Subkey Cross-Certification documentation has more detail on cross certification, and gpg v2.2.1 notes "subkey does not sign and so does not need to be cross-certified". hokey may also indicate a problem (red text) with Key expiration times: [] on the primary key (see Note #3 about not setting an expiry for the primary key).
mkdir ~/backup
gpg --armor --export-secret-keys $KEYID > ~/backup/mastersub.key
gpg --export-secret-keys $KEYID | paperkey --output ~/backup/master-paperkey.txt
gpg --armor --export-secret-subkeys $KEYID > ~/backup/sub.key
gpg --output ~/backup/revoke.asc --gen-revoke $KEYID
tar -cf - -C $GNUPGHOME . > ~/backup/.gnupg.tar
❗N.B. When printing the paperkey, note down the master key's passphrase on the piece of paper too.
gpg --armor --export $KEYID > ~/gpg-$KEYID-$(date +%F).asc
echo "/root/gpg-$KEYID-$(date +%F).asc"
# Take note of the public key filename
From the host machine:
docker ps
# Public key
docker cp ______:/root/gpg-0x______.asc ~/
# Private keys & revocation certificate
docker cp ______:/root/backup/mastersub.key ~/
docker cp ______:/root/backup/master-paperkey.txt ~/
docker cp ______:/root/backup/sub.key ~/
docker cp ______:/root/backup/revoke.asc ~/
# GnuPG home-directory
docker cp ______:/root/backup/.gnupg.tar ~/
Once exfiltrated, store 📄 .gnupg.tar
in KeePass and remove it from the host
machine.
gpg --edit-key $KEYID
> key 1 # select key 1
> keytocard
>> 1 # signature key
> key 1 # deselect key 1
> key 2
> keytocard
>> 2 # encryption key
> key 2
> key 3
> keytocard
>> 3 # authentication key
> save
gpg --card-status
The >
(in ssb>
) indicates they key has been moved to card...
rm -rf ~/backup
gpg --delete-secret-key $KEYID
gpg --card-status
The #
(in sec#
) Indicates master key is not available anymore...
rm -rf $GNUPGHOME
export GNUPGHOME=$(mktemp -d -t gnupg_$(date +%Y%m%d%H%M)_XXX)
gpg -K
# *empty*
gpg --card-status
# General key info..: [none]
❗N.B. gpg -k
and gpg -K
provide different outputs (the former also
shows revoked and expired keys).
Import the key to each machine where you intend to use it:
gpg --import ~/gpg-0x\*.asc
gpg --edit-key ______
> trust
>> 5
>> y
Ensure scdaemon
and pcscd
are installed – might need to restart gpg-agent
for it to pick things up properly:
sudo apt install scdaemon pcscd
gpgconf --kill gpg-agent
gpg-connect-agent reloadagent /bye
gpg --card-status
I personally only have it imported on my daily driver; using SSH agent forwarding to forward both the SSH and GPG agents to (trusted) remote machines.
❗N.B. After renewing my subkeys, I had to import the (updated) public key on a handful of additional machines for them to pick up on the updated expiry dates. Haven't had the time to properly figure this out yet; in case I never do: The simplest solution is to import the updated public key on the offending machine...
Optional: Save public key (from Yubikey) for identity file configuration.
Mainly useful to explicitly configure a connection to use the Yubikey (via
📄 ~/.ssh/config
).
ssh-add -L | grep "cardno:000______" > ~/.ssh/id_rsa_yubikey.pub
On the windows-side install the following to use the Yubikey for SSH operations:
- Microsoft OpenSSH –
winget install Microsoft.OpenSSH.Beta
- GnuPG (win32)
–
winget install GnuPG.GnuPG
- Don't install Gpg4win; the basic binaries distributed directly by GnuPG suffice...
- Use
run-hidden
in combination with Task Scheduler to run the agent at logon:run-hidden gpg-agent --daemon
wsl-ssh-pageant
- Use Task Scheduler to run the
wsl-ssh-pageant
at logon:wsl-ssh-pageant-gui.exe --winssh ssh-pageant
- Set
SSH_AUTH_SOCK=\\.\pipe\ssh-pageant
in your Windows (user) environment
- Use Task Scheduler to run the
- Yubikey mini-driver
and optionally the
Yubikey Manager –
winget install Yubico.YubikeyManager
Then, make the following configuration changes:
- Copy
📄 gpg-agent.conf
to📂 %APPDATA%/gnupg
- Copy
📄 ~/.ssh/id_rsa_yubikey.pub
from the Linux-side, and symlink the Windows SSH📄 config
from OneDrive into📂 %USERPROFILE%/.ssh
- Import the Yubikey's public GPG-key in Windows' OpenSSH (see the
Linux-instructions; use
openssh.exe
)
❗N.B. After doing all of the above, logout and log back in to ensure the environment changes properly propagated to the tasks started via Task Scheduler.
This should allow SSH connections to be authenticated via Windows' OpenSSH through the Yubikey. A GnuPG window will popup asking for the Yubikey PIN. Haven't tried/bothered to get GPG signing up-and-running; using WSL2 (see below) for that.
To allow the Yubikey to be used from within WSL2, install
usbipd-win
on the Windows-side:
winget install usbipd
Then, on the WSL2-side:
sudo apt install linux-tools-virtual hwdata
sudo update-alternatives --install /usr/local/bin/usbip usbip \
"$(ls /usr/lib/linux-tools/*/usbip | tail -n1)" 20
This processed is automated by
📄 install/software.d/40-yubikey-wsl
and
📄 install/config.d/40-yubikey-wsl
.
❗N.B. If linux-tools-virtual
gets updated, it might be necessary to
reapply the update-alternatives
for usbip
(rerun
install/install.sh 40-yubikey-wsl
).
Note the difference between usbipd
(which is not supposed to work on the
WSL2-side) and usbip
which is supposed to work...
The Yubikey needs to be bound on the Windows-side once before it can be used. From an elevated PowerShell-prompt run:
usbipd bind -b ...
Afterwards, attaching and detaching the Yubikey is done in WSL2 via two
convenience commands provided in 📂 .bashrc.d
:
yubikey-attach
yubikey-detach # Alternatively, physically detach and reattach the key...
Note that for these to work, you'll need to set an appropriate USBIP_HOST
and
USBIP_BUSID
in your 📄 ~/.env
.
The most common commands requiring the Yubikey to be attached (git
and ssh
)
are wrapped with an automatic yubikey-attach
. For maximum flexibility, ensure
WSLg is properly configured so that a GUI pinentry
dialogue can be shown:
sudo apt install pinentry-gnome3
sudo update-alternatives --config pinentry # should be "pinentry-gnome3"
The main functionality is provided by
📄 ~/.bashrc.d/40-gpg-yubikey-wsl
— see that
script for more details.
To prevent relentless sudo
-prompts, update your configuration with the
following:
📄 /etc/sudoers.d/80-yubikey
(sudo visudo -f /etc/sudoers.d/80-yubikey
).
Additional convenience for using the Yubikey via SSH on remote machines is
provided through
📄 ~/.bashrc.d/41-ssh-remote-rpi
.
❗N.B. As of
WSL 2.0.0 (in
combination with Windows 11 23H2), the below is not required anymore as
long as networkingMode
is set to mirrored
(see
📄 .wslconfig
for more details).
Using the mirrored
networking-mode, the usbipd
-service on the Windows-side
can be reached reliably (and securely) via 127.0.0.1
from within WSL –
there's no more need for firewall exceptions and such...
Should you still wish to use the below approach, take note of the fact that
(most likely) WSL's vEthernet-adapter is called
vEthernet (WSL (Hyper-V firewall))
instead of vEthernet (WSL)
. Apart from
that, the procedure below still works just fine (although using mirrored
networking-mode offers a far superior solution).
To allow for a fixed firewall exception (not relying on either the local network
or the network dynamically created by Hyper-V), a special-purpose fixed IP
address is attached to the WSL2-instance's vEthernet (WSL)
adapter:
netsh interface ip add address "vEthernet (WSL)" 192.168.___.1 255.255.255.0
netsh interface ip delete address "vEthernet (WSL)" 192.168.___.1
As these commands require elevation on the Windows-side, they are run via Task Scheduler (so as to not have to manually elevate whenever the command is executed). Create a trigger-less task named WSL2 fixed IP that runs whether logged in or not, does not store password, and runs with highest privileges:
netsh.exe interface ip add address "vEthernet (WSL)" 192.168.___.1 255.255.255.0
Test the task can be executed without requiring elevation:
schtasks.exe /run /tn "WSL2 fixed IP"
On this Linux-side, use the following for testing:
sudo ip addr add 192.168.___.2/24 broadcast 192.168.___.255 dev eth0 label eth0:1
sudo ip addr del 192.168.___.2/24 dev eth0:1
Once the interface is up you should be able to ping across it from both sides
(note that you're pinging from .1
to .2
and vice versa).
As ip addr
requires root privileges on the Linux-side too, the easiest
solution is to run the whole thing @reboot
using root's cron
. Use
sudo crontab -e
to setup the root's crontab as follows:
@reboot /usr/bin/ip addr add 192.168.___.2/24 broadcast 192.168.___.255 dev eth0 label eth0:1
@reboot /mnt/c/Windows/System32/schtasks.exe /run /tn "WSL2 fixed IP"
Concept inspired by: https://gist.github.com/wllmsash/1636b86eed45e4024fb9b7ecd25378ce.
Copy 📄 .gnupg.tar
from backup (i.e., KeePass) into an empty Docker container.
From the host machine:
docker ps
docker cp ~/.gnupg.tar ______:/root/.gnupg.tar
In the container:
export GNUPGHOME=$(mktemp -d -t gnupg_$(date +%Y%m%d%H%M)_XXX)
tar xf ~/.gnupg.tar -C $GNUPGHOME
rm ~/.gnupg.tar
gpg -K
export KEYID=0x______
gpg --expert --edit-key $KEYID
# Secret key is available
Update expiry of all public keys to a new (future) date:
gpg --edit-key $KEYID
> key 1
> key 2
> key 3 # select all keys
> expire
>> ...
> save
Export the (updated) public key and (re)import it in all places where it's used (similar to the initial procedure — see Exfiltrate (private) keys and Usage for instructions).
Update the backup. Note that the private keys and the
revocation certificate don't change, so creating up an updated copy of
📄 .gnupg.tar
suffices. Finally, do a clean-up.
Using the previously generate 📄 revoke.asc
-certificate:
gpg --output revoke.asc --gen-revoke key-ID
gpg --import revoke.asc