Moving Homelab SSH and SOPS keys onto a YubiKey
Moving SSH, Git, and local SOPS access onto YubiKey backed keys.
I wanted my homelab access to depend less on normal private keys sitting around on a laptop.
SSH keys, SOPS age keys, GPG keys, bootstrap keys. It is very easy to grow a pile of local secrets that quietly become part of the machine. That works until I reinstall the OS, move to another laptop or they get potentially leaked.
I wanted to keep the signing and decrypting material on the YubiKey where possible, and leave the laptop with handles, config, and public keys. A new laptop is one case where this matters, but it is not the point of the setup. The point is that the laptop should not be the place where all the long-lived access keys live.
This ended up being two separate tracks.
- OpenSSH resident FIDO keys for host access
- SOPS access through a YubiKey backed OpenPGP key
I originally wanted the SOPS part to use age-plugin-yubikey, but my YubiKey setup got in the way.
SSH keys on the yubikey
For SSH, I used OpenSSH security key keys.
ssh-keygen -t ed25519-sk \
-O resident \
-O verify-required \
-O application=ssh:realm-nano \
-f ~/.ssh/id_ed25519_sk_realm_nano \
-C "lab@realm"
The resident option stores the key handle on the YubiKey, so the local machine does not need a normal exportable SSH private key for this access path. If the local files disappear, I can pull the key-handle files back down from the token. verify-required makes OpenSSH ask for the FIDO PIN before the YubiKey signs.
On macOS I had to use Homebrew OpenSSH. The system OpenSSH could list the sk-* key types, but failed before it reached the YubiKey because the FIDO provider path was not available. Homebrew OpenSSH worked for this.
brew install openssh
which ssh
which ssh-keygen
ssh -V
The commands should resolve to /opt/homebrew/bin/ssh and /opt/homebrew/bin/ssh-keygen, not /usr/bin.
Once the public key was copied to a host, the SSH config only needed a normal alias. This is the shape, using one host as the example.
Host avalon-yk
HostName <ip of the machine>
User root
IdentityFile ~/.ssh/id_ed25519_sk_realm_nano
IdentityFile ~/.ssh/id_ed25519_sk_realm_nfc
IdentitiesOnly yes
PreferredAuthentications publickey
ControlMaster auto
ControlPath ~/.ssh/control-%C
ControlPersist 30m
Two identity files, one Yubikey is used as a backup of the other.
I used aliases like this for the Proxmox hosts, OPNsense, TrueNAS, and the DNS host. The hostnames and users change, but the alias shape stays the same.
The first login needs a real local terminal because of the PIN and touch flow.
ssh avalon-yk
After that, multiplexing keeps the next few commands on the same connection.
ssh -O check avalon-yk
ssh avalon-yk 'hostname && whoami'
scp some-file avalon-yk:/tmp/
With ControlPersist 30m, I can unlock one SSH connection with the YubiKey and then run follow-up commands through the same master connection for half an hour.
Pulling resident ssh handles back down
If the local key-handle files are missing, they can be pulled from the YubiKey with ssh-keygen -K.
mkdir -p ~/.ssh
chmod 700 ~/.ssh
cd ~/.ssh
ssh-keygen -K
This works because the keys were created with -O resident. OpenSSH knows how to talk to FIDO authenticators directly, either through its built-in USB HID support or an SSH_SK_PROVIDER / -w provider override. The YubiKey is just the FIDO2 authenticator in this case.
ssh-keygen -K writes public and private key-handle files into the current directory. It is different from ssh-add -K, which loads resident keys into the agent. For this setup I want files back in ~/.ssh, so ssh-keygen -K is the one I care about.
OpenSSH downloads resident keys from the first FIDO authenticator that gets touched. If both YubiKeys are plugged in, I would do one at a time. Plug in the nano, run ssh-keygen -K, rename the files, then repeat with the other key.
After downloading, check the comments and fingerprints.
ls -l id_*_sk*
ssh-keygen -lf ./*.pub
Then rename the local key-handle files to match the SSH config.
mv <downloaded-nano-private-key> ~/.ssh/id_ed25519_sk_realm_nano
mv <downloaded-nano-public-key> ~/.ssh/id_ed25519_sk_realm_nano.pub
chmod 600 ~/.ssh/id_ed25519_sk_realm_nano
chmod 644 ~/.ssh/id_ed25519_sk_realm_nano.pub
The local ed25519-sk private file is not a normal private key in the old SSH sense. It is a key handle, and the YubiKey still has to sign. I would not commit it or pass it around, but having that file by itself is not enough to log in.
Installing a public key on a host
If a host still only has an old bootstrap key, I do not need anything fancy. Show the public key locally.
cat ~/.ssh/id_ed25519_sk_realm_nano.pub
Then SSH in with whatever still works and paste that line into ~/.ssh/authorized_keys on the host.
ssh -i ~/.ssh/<bootstrap-key> root@<host>
mkdir -p ~/.ssh
chmod 700 ~/.ssh
vi ~/.ssh/authorized_keys
chmod 600 ~/.ssh/authorized_keys
After that, test the YubiKey alias from the laptop.
ssh avalon-yk 'hostname && whoami'
Once the YubiKey alias works, the bootstrap key can stop being the normal path.
Git over ssh
I used the same idea for GitHub and my internal GitLab, but with a separate pair of FIDO SSH keys.
~/.ssh/id_ed25519_sk_git
~/.ssh/id_ed25519_sk_git.pub
~/.ssh/id_ed25519_sk_git_nfc
~/.ssh/id_ed25519_sk_git_nfc.pub
The SSH config for GitHub and GitLab points at those keys instead of the homelab host key.
Host github.com
HostName github.com
User git
IdentityFile ~/.ssh/id_ed25519_sk_git
IdentityFile ~/.ssh/id_ed25519_sk_git_nfc
IdentitiesOnly yes
PreferredAuthentications publickey
Git signing also moved to SSH signatures, using the Git FIDO public key.
git config --global gpg.format ssh
git config --global user.signingkey ~/.ssh/id_ed25519_sk_git.pub
git config --global commit.gpgsign true
For local verification, I added both Git signing public keys to ~/.ssh/allowed_signers and pointed Git at it.
git config --global gpg.ssh.allowedSignersFile ~/.ssh/allowed_signers
That keeps Git auth and Git signing on the YubiKey path too, without reusing the same SSH key I use for homelab hosts.
SOPS was the awkward part
The homelab repo already used SOPS with an age recipient. That still makes sense for GitOps, because Argo CD can use the in-cluster age key when rendering Helmfile.
For my local machine, I wanted a hardware-backed route too. The first attempt was age-plugin-yubikey.
age-plugin-yubikey --list
age-plugin-yubikey --identity
age-plugin-yubikey --generate \
--serial <serial> \
--name "sops temp" \
--pin-policy once \
--touch-policy cached
That failed on my nano because of the PIV management-key setup.
AES protected management key is unsupported by age-plugin-yubikey
Checking the PIV metadata showed why.
ykman --device <serial> piv info
The management key was AES192, stored on the YubiKey and protected by PIN. I could probably have gone deeper into changing the PIV setup, but that was not what I wanted to do while wiring up repo secrets. The goal was to avoid local secret material lying around, not to turn the PIV management key into its own side quest.
So I switched the SOPS hardware-backed path to OpenPGP/GPG.
PGP on the yubikey
The working SOPS key ended up as a YubiKey backed OpenPGP key.
gpg --list-secret-keys --keyid-format LONG --with-keygrip
gpg --card-status
ykman openpgp info
The key fingerprint is available from GPG
gpg --list-secret-keys --keyid-format LONG
After moving the key to the YubiKey, GPG showed it as card-backed (sec> / ssb>), and a disposable SOPS round trip worked.
sops --encrypt \
--pgp <yubikey-backed-gpg-fingerprint> \
/tmp/sops-ykgpg-test.yaml > /tmp/sops-ykgpg-test.enc.yaml
sops --decrypt /tmp/sops-ykgpg-test.enc.yaml
The decrypt prompts for the OpenPGP user PIN. YubiKey PIN retries are limited FYI.
Keeping both age and pgp recipients
I did not replace age with PGP. I added PGP next to age.
The repo-level .sops.yaml now has both recipients.
creation_rules:
- path_regex: .*\.yaml$
age: <cluster-age-recipient>
pgp: <yubikey-backed-gpg-fingerprint>
The age recipient keeps the existing GitOps path working. The PGP recipient gives me a local YubiKey-backed decrypt path. After changing .sops.yaml, the encrypted file metadata has to be updated.
sops updatekeys secrets/secrets.enc.yaml
GPG agent cache
The YubiKey OpenPGP path works, but the PIN prompts get old quickly when doing several SOPS operations. I set the GPG agent cache to eight hours.
cat > ~/.gnupg/gpg-agent.conf <<'EOF'
default-cache-ttl 28800
max-cache-ttl 28800
default-cache-ttl-ssh 28800
max-cache-ttl-ssh 28800
EOF
chmod 600 ~/.gnupg/gpg-agent.conf
gpgconf --reload gpg-agent
This only controls how long GPG remembers the PIN locally.
Current shape
SSH access is now mostly a YubiKey plus SSH config problem. SOPS access is still age for the cluster, with a YubiKey backed PGP recipient for my local decrypt path.
If I have to set this up again, I would do it in the order of:
- install Homebrew OpenSSH, GnuPG, SOPS, and
ykman - pull resident SSH keys with
ssh-keygen -K - restore the SSH aliases
- import or refresh the GPG card stubs for the OpenPGP key
- verify
sops --decryptworks with the YubiKey
I did not end up with one YubiKey mechanism for everything. SSH uses resident OpenSSH FIDO keys. SOPS still uses age for the cluster, and PGP for my local YubiKey decrypt path.