TL;DR One YubiKey carries a GPG key with a signing, an encryption and an authentication subkey. gpg-agent hands the authentication subkey to SSH, git signs with the signing subkey and pass decrypts with the encryption subkey, so one PIN in the morning and a touch per action replace every password I used to type. The SSH part alone is three steps and worth doing on its own.
Every morning I plug in my YubiKey, type one PIN, and that’s the last secret I type until I pull it out again. SSH to a server, no password. Sign a commit, no passphrase. Pull a secret out of pass, touch the key. One thing in a USB port, and the rest of the day my authentication happens without me.
Getting there took three evenings of swearing at gpg-agent. I’d do it again.
Why I bothered
Before the key I had four ways of proving who I was, and they all worked differently. SSH keys on disk, encrypted, sure, but still a file anyone with my home directory could copy. A GPG key for git signing, with its own passphrase. pass, which used that same GPG key and asked for that same passphrase again. And 2FA codes in an app on my phone, so grab the phone, squint, retype.
Every action started with typing a password or reaching for my phone. That’s exactly where I start cutting security corners, and I know that about myself. Give me an annoying setup and I’ll have worked around it within a week.
So I wanted one key that does all of it, with the private keys on hardware I can pull out of the laptop and put in my pocket, instead of in files spread across my disk. That’s the sovereignty part. I want to know exactly where my private keys are, and I want the answer to be “in my pocket”.
One YubiKey does all of it now.
The stack
| Tool | Function |
|---|---|
| YubiKey | Hardware security key with smartcard functionality |
| GPG | Encryption and signing |
| pass | CLI password manager (uses GPG) |
| gpg-agent | Agent that caches GPG keys and handles SSH auth |
| SSH | You know what SSH is |
The piece that makes it hang together is gpg-agent. It can act as an SSH agent, which means the authentication subkey on the YubiKey doubles as your SSH key. So the same key signs commits, decrypts passwords and logs you into servers.
SSH alone, in three steps
You don’t need the whole setup to get something out of this. Generate a GPG key with an authentication subkey, move it onto the YubiKey, and point SSH at gpg-agent. That’s three steps, and you’re logging into servers with a hardware key. pass, git signing, touch policies and backups come after.
The full setup
Put the GPG key on the YubiKey
You need a GPG key with three subkeys: signing for git commits, encryption for pass, authentication for SSH. If you already have a GPG key, add the subkeys to it. Otherwise create a new one.
# Generate new key (or use existing)
gpg --full-generate-key
# Add subkeys
gpg --edit-key <KEY_ID>
gpg> addkey # Sign
gpg> addkey # Encrypt
gpg> addkey # Authenticate (choose "set your own capabilities")
gpg> save
Then move them to the card:
gpg --edit-key <KEY_ID>
gpg> key 1
gpg> keytocard # Choose slot for signing
gpg> key 1
gpg> key 2
gpg> keytocard # Choose slot for encryption
gpg> key 2
gpg> key 3
gpg> keytocard # Choose slot for authentication
gpg> save
keytocard moves the key, it doesn’t copy it. Back up your .gnupg directory first. I didn’t, on YubiKey number one, and that’s how I learned the difference.
Configure gpg-agent
This is the step that does the heavy lifting. Edit ~/.gnupg/gpg-agent.conf:
# Cache timeout (in seconds)
default-cache-ttl 600
max-cache-ttl 7200
# Enable SSH support
enable-ssh-support
# Pinentry program (for PIN entry)
pinentry-program /usr/bin/pinentry-gnome3
# Or for terminal: /usr/bin/pinentry-curses
# Or for macOS: /usr/local/bin/pinentry-mac
And in your ~/.bashrc or ~/.zshrc:
# Use GPG agent as SSH agent
export GPG_TTY=$(tty)
export SSH_AUTH_SOCK=$(gpgconf --list-dirs agent-ssh-socket)
# Restart agent if needed
gpgconf --launch gpg-agent
Restart your shell or source the file:
source ~/.zshrc
Export the SSH key
The authentication subkey is now also your SSH key. Export the public half:
# SSH public key from your GPG key
gpg --export-ssh-key <KEY_ID>
Add it to ~/.ssh/authorized_keys on your servers, or to GitHub/GitLab. Then check that the agent sees it:
ssh-add -L
# Shows your GPG key as SSH key
pass and git on top
The SSH part is the core. What follows is a password manager, signed commits, and the touch policy that decides how paranoid you want the key to be.
Configure pass
pass is about as simple as a password manager gets. Every password is one GPG-encrypted file on disk, and since the GPG key now lives on the YubiKey, reading a password means touching the key.
# Initialize pass with your GPG key
pass init <KEY_ID>
# Or with multiple keys (handy for backup)
pass init <KEY_ID_1> <KEY_ID_2>
Adding passwords:
# New password
pass insert servers/prod-db
# Generate random password
pass generate -n 32 servers/api-key
# View password (asks for YubiKey touch)
pass servers/prod-db
Sign git commits
git config --global user.signingkey <KEY_ID>
git config --global commit.gpgsign true
git config --global tag.gpgsign true
Every commit now asks for a touch. No extra password, a tap on the key, and the commit provably came from the hardware in my USB port rather than from a process that found a key on disk.
A day with the key in
Once it runs, a day looks like this:
# Morning: plug in YubiKey, type PIN once
gpg --card-status # Triggers PIN prompt
# SSH to server - no password
ssh prod-server
# Get a password - touch the key
pass show infra/grafana | head -1
# Git commit - touch the key
git commit -m "Fix critical bug"
# Everything works, YubiKey stays in USB
After that first PIN everything keeps working until the cache expires, 600 seconds for the PIN with the config above. The touch stays required for as long as the policy is on, so the cache never makes the key silent.
Aliases I use
# Quick pass lookup with fzf
alias p='pass show $(find ~/.password-store -name "*.gpg" | sed "s|$HOME/.password-store/||;s|\.gpg$||" | fzf)'
# Copy password to clipboard
alias pc='pass show -c $(find ~/.password-store -name "*.gpg" | sed "s|$HOME/.password-store/||;s|\.gpg$||" | fzf)'
# YubiKey status
alias yk='gpg --card-status'
# Show SSH keys
alias sshkeys='ssh-add -L'
Touch policy
This is where you decide how much the key bugs you. There are a few policies:
| Policy | Meaning |
|---|---|
| Off | No touch needed |
| On | Touch for every operation |
| Fixed | Touch required, cannot be changed |
| Cached | Touch valid for X seconds |
I run “On” for signing and authentication, so every commit and every SSH login needs a physical touch. That’s deliberate. If the key blinks and I didn’t ask for anything, something is using it that shouldn’t be. That’s worth a second per commit to me.
# Set touch policy via ykman
ykman openpgp keys set-touch sig on
ykman openpgp keys set-touch aut on
ykman openpgp keys set-touch enc on
When the key is gone
A YubiKey can snap, go through the wash, or leave in a coat you donated. So I plan for the day it’s gone. That means a second YubiKey with the same keys on it, stored somewhere else. A printed copy of the master key, offline. And a revocation certificate, generated now and stored apart from both.
# Generate revocation certificate
gpg --gen-revoke <KEY_ID> > revoke.asc
# Store this safely, separate from your key backup
pass itself you can sync with git:
cd ~/.password-store
git init
git remote add origin git@github.com:user/pass-store-private.git
git push -u origin main
The passwords are encrypted files, so a leaked repo is useless without the GPG key.
Troubleshooting
“No secret key”
# Check if YubiKey is visible
gpg --card-status
# Not visible? Check USB
lsusb | grep -i yubi
# GPG doesn't know where the key is? Refresh
gpg-connect-agent "scd serialno" "learn --force" /bye
SSH doesn’t work
# Check if gpg-agent is running as SSH agent
echo $SSH_AUTH_SOCK
# Should be something like: /run/user/1000/gnupg/S.gpg-agent.ssh
# Load keys
ssh-add -L
# Should show your GPG key
# Not working? Restart agent
gpgconf --kill gpg-agent
gpgconf --launch gpg-agent
PIN blocked
After three wrong PINs the YubiKey locks. Reset it with the Admin PIN:
gpg --edit-card
gpg/card> admin
gpg/card> passwd
# Choose option 2: unblock PIN
Forgot the Admin PIN too? Then the YubiKey is a brick for those keys, and the second YubiKey from two sections up is what saves you.
Why this one stuck
The usual story is that security and convenience pull in opposite directions, and more of one costs you some of the other. For me this setup runs the other way. I type one PIN per session and touch the key after that. There’s no reusable password to phish, because the physical key has to be in the port. SSH, signing and pass are one workflow instead of three, with one mental model. And it works without internet, without a cloud account, without a vendor that has to stay online.
The part that matters most is that I use it. This one doesn’t fight me, so I’ve never worked around it. Most days I don’t think about it at all.
Where to start
You don’t have to configure all of this in one sitting. Buy a YubiKey 5, the NFC variant if you want it to work with your phone. Generate a GPG key with an authentication subkey, put it on the key, configure gpg-agent for SSH and test with ssh-add -L. pass, git signing and the touch policy go on top at whatever pace suits you.
What it cost
Three evenings, most of it on gpg-agent. You have to understand what GPG subkeys are, get gpg-agent to behave, and keep the picture of how the pieces fit in your head while you do it. That’s real time, and I swore a lot.
What I got for it is one piece of hardware at the centre of every login I do. My private keys are on something I can pocket, and using them is a tap. If you want to know whether yours still works, one line covers all three:
# Check if everything works
gpg --card-status && ssh-add -L && pass show test/dummy
If that runs without errors, you’re done. Mine took three evenings. You have this post.
