Moving Homelab SSH and SOPS keys onto a YubiKey

Moving SSH, Git, and local SOPS access onto YubiKey backed keys.

Moving Homelab SSH and SOPS keys onto a YubiKey
Photo by Conny Schneider / Unsplash

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:

  1. install Homebrew OpenSSH, GnuPG, SOPS, and ykman
  2. pull resident SSH keys with ssh-keygen -K
  3. restore the SSH aliases
  4. import or refresh the GPG card stubs for the OpenPGP key
  5. verify sops --decrypt works 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.