derek w. | @dwlfrth

Hardware-Backed Secrets — Part 1: Key Generation

Most people treat cryptographic keys the way they treat passwords — stored in a file somewhere, maybe encrypted, hopefully backed up. This works until it doesn’t. A compromised laptop, a careless git push, a misconfigured permission — any of these can silently expose years of trusted identity.

This post describes a setup where private keys never touch your laptop’s filesystem in usable form. Signing, encryption, and SSH authentication all happen inside a hardware token. The keys are generated on an air-gapped machine, bundled into an encrypted cold storage archive, and loaded directly onto two YubiKeys. Your daily machine only ever sees public keys and card stubs.

The approach draws heavily from drduh/YubiKey-Guide, with three modifications: Ed25519 instead of RSA, FIDO2 resident keys for SSH instead of the GPG auth subkey, and age for cold storage bundling.

Content Navigation


What You Will End Up With

YubiKey (primary + backup, identical)
├── OpenPGP applet
│   ├── [SIG] Ed25519      → git commits, email signing
│   └── [ENC] Curve25519   → email encryption
└── FIDO2 applet
    └── resident ed25519-sk → SSH authentication

Cold Storage USB ×2 (separate physical locations)
└── keystore.tar.gz.age    ← single encrypted bundle
    ├── master-secret.asc  (GPG passphrase protected)
    ├── subkeys-secret.asc
    ├── master-public.asc
    ├── age-backup.key.enc (age passphrase protected)
    ├── revoke.asc
    └── README-recovery.txt

Three passphrases — all machine-generated, written on paper,
stored separately from the USB drives:
├── CERTIFY_PASS   GPG master key
├── AGE_PASS       age key
└── BUNDLE_PASS    outer archive encryption

Why No GPG Auth Subkey?

drduh guide uses GPG’s auth subkey for SSH, routing it through gpg-agent with enable-ssh-support. This works, but it carries real friction: the agent needs to be running, the SSH_AUTH_SOCK needs to be exported, and macOS in particular has historically been brittle with the LaunchAgent plumbing.

FIDO2 resident keys (ed25519-sk) are a cleaner fit. They live in the YubiKey’s FIDO2 applet independently of the OpenPGP applet. ssh handles them natively without any agent configuration. And crucially, on a new machine you can recover the key handle from the device with a single command — no keyring, no agent, no config:

ssh-keygen -K

The OpenPGP AUT slot stays empty. Two subkeys, two purposes, less complexity.

There is one thing to keep in mind, though: macOS does not update OpenSSH very often. See this post to make sure everything here works as expected 🙂


Why Ed25519 Instead of RSA 4096?

Ed25519 is faster, produces smaller keys and signatures, and has no known weaknesses. RSA 4096 remains the most compatible choice if you need to interoperate with very old clients or systems that predate elliptic curve support — but for a personal setup in 2024 that’s rarely the case. The YubiKey 5 series fully supports both.


Hardware

  • 2× YubiKey 5 (any form factor — NFC, Nano, USB-C)
  • 2× USB drives for cold storage

Buy two YubiKeys. Before doing anything else, verify both are genuine by visiting yubico.com/genuine and touching each key when prompted. This confirms the device attestation and mitigates supply chain attacks.


The Air-Gap Environment

Private keys should never be generated on an internet-connected machine. The threat model isn’t just remote attackers — it’s also clipboard managers, crash reporters, browser extensions, and any other process that silently observes memory.

The gold standard is booting Tails OS from a USB on a dedicated machine kept permanently offline. A cheap used ThinkPad works well for this. Tails does not currently support Apple Silicon, so if your only machine is an M-series Mac, the practical alternative is a UTM virtual machine running Debian with networking permanently disabled.

Setting Up the UTM VM

Download Debian’s netinstall ISO on your Mac first, while you still have network access:

curl -fLO "https://cdimage.debian.org/debian-cd/current/arm64/iso-cd/debian-12-netinst-arm64.iso"
curl -fLO "https://cdimage.debian.org/debian-cd/current/arm64/iso-cd/SHA256SUMS"
shasum -a 256 --check SHA256SUMS 2>/dev/null | grep debian-12-netinst-arm64.iso

Create the VM in UTM — ARM64, 2 cores, 2GB RAM, 8GB disk — with networking temporarily enabled. Install Debian in text mode with no desktop environment. Then install the tools you need while network is still up:

sudo apt install --no-install-recommends \
    gnupg2 gpg-agent pcscd scdaemon \
    age yubikey-manager pinentry-curses \
    hopenpgp-tools secure-delete

Once packages are installed, go to UTM → VM Settings → Network → None. Verify inside the VM:

ip link show   # only loopback should appear
ping 1.1.1.1   # must fail

Take a snapshot called clean-state. You will restore this snapshot before every key ceremony, ensuring the VM always starts from a known-clean state.

Note on VMs vs physical air-gap: A VM on your own encrypted daily machine is acceptable for personal key generation. The hypervisor shares memory with your host OS, so it does not provide the same isolation as a physically separate machine — but for a personal setup the practical risk is low. The real requirement is that networking is disabled before you generate anything.


Prepare Cold Storage USBs

Format both drives as FAT32 on your Mac. FAT32 is readable on every OS without drivers — critical for emergency recovery from an unfamiliar machine years from now.

diskutil list                                 # identify USB, e.g. /dev/disk4
diskutil eraseDisk FAT32 COLDSTORE MBR /dev/disk4

Repeat for the second USB.


Phase 1 — Generate the GPG Master Key

Restore the clean-state snapshot, verify no network, then begin.

Isolated GPG homedir

Always use a temporary isolated homedir during key generation. This prevents interference from any existing keyring state.

export GNUPGHOME=$(mktemp -d -t $(date +%Y%m%d)-XXXX)
chmod 700 $GNUPGHOME

Hardened gpg.conf

Before generating anything, write a hardened configuration. This sets strong cipher preferences, disables weak algorithms, and importantly sets throw-keyids — which omits the recipient key ID from encrypted messages, preventing passive observers from knowing who can decrypt them.

cat > $GNUPGHOME/gpg.conf << 'EOF'
personal-cipher-preferences AES256 AES192 AES
personal-digest-preferences SHA512 SHA384 SHA256
personal-compress-preferences ZLIB BZIP2 ZIP Uncompressed
default-preference-list SHA512 SHA384 SHA256 AES256 AES192 AES ZLIB BZIP2 ZIP Uncompressed
cert-digest-algo SHA512
s2k-digest-algo SHA512
s2k-cipher-algo AES256
charset utf-8
no-comments
no-emit-version
no-greeting
keyid-format 0xlong
list-options show-uid-validity
verify-options show-uid-validity
with-fingerprint
require-cross-certification
require-secmem
no-symkey-cache
armor
use-agent
throw-keyids
EOF
chmod 600 $GNUPGHOME/gpg.conf

Machine-generated passphrases

Do not choose your passphrases manually. Use the machine to generate them — this avoids predictable patterns and memorable-but-weak phrases. The format below uses uppercase letters and digits, avoids visually ambiguous characters, and produces groups separated by dashes for easier transcription.

export CERTIFY_PASS=$(LC_ALL=C tr -dc "A-Z2-9" < /dev/urandom | \
    tr -d "IOUS5" | fold -w4 | paste -sd- | head -c29)

export AGE_PASS=$(LC_ALL=C tr -dc "A-Z2-9" < /dev/urandom | \
    tr -d "IOUS5" | fold -w4 | paste -sd- | head -c29)

export BUNDLE_PASS=$(LC_ALL=C tr -dc "A-Z2-9" < /dev/urandom | \
    tr -d "IOUS5" | fold -w4 | paste -sd- | head -c29)

printf "\nCERTIFY_PASS: %s\n" "$CERTIFY_PASS"
printf "AGE_PASS:     %s\n" "$AGE_PASS"
printf "BUNDLE_PASS:  %s\n\n" "$BUNDLE_PASS"

Write all three on paper immediately. This paper stays separate from the USB drives — never in the same bag, drawer, or location. These three passphrases, together with the USB, are the only path to recovering your identity if both YubiKeys are lost.

Set identity

export IDENTITY="Your Name <your@email.com>"

Generate the master key

The master key is a Certify-only key — it cannot sign messages or encrypt data directly. Its sole purpose is to issue and revoke subkeys. This separation means that if a subkey is ever compromised, you revoke it using the offline master key and issue a new one, without losing your identity.

echo "$CERTIFY_PASS" | \
    gpg --batch --passphrase-fd 0 \
        --quick-generate-key "$IDENTITY" ed25519 cert never

export KEYID=$(gpg -k --with-colons "$IDENTITY" | \
    awk -F: '/^pub:/ { print $5; exit }')
export KEYFP=$(gpg -k --with-colons "$IDENTITY" | \
    awk -F: '/^fpr:/ { print $10; exit }')

printf "\nKey ID:          %s\nFingerprint:     %s\n\n" "$KEYID" "$KEYFP"

Generate subkeys

Two subkeys — one for signing, one for encryption. Both set to expire after one year. Expiring subkeys force regular rotation, which limits the window of exposure if a key is ever silently compromised.

# Signing subkey — Ed25519
echo "$CERTIFY_PASS" | \
    gpg --batch --pinentry-mode=loopback --passphrase-fd 0 \
        --quick-add-key "$KEYFP" ed25519 sign 1y

# Encryption subkey — Curve25519
echo "$CERTIFY_PASS" | \
    gpg --batch --pinentry-mode=loopback --passphrase-fd 0 \
        --quick-add-key "$KEYFP" cv25519 encrypt 1y

Verify the key structure:

gpg -K

# Expected:
# sec   ed25519 [C]             ← master, certify only, no expiry
# ssb   ed25519 [S] [expires: ] ← signing subkey
# ssb   cv25519 [E] [expires: ] ← encryption subkey

Phase 2 — Generate the age Key

age is used for backup encryption and secrets management in part two. It is simpler than GPG for automated workflows, has no agent or keyring requirements, and is trivially recoverable from a single key file.

age-keygen -o /tmp/age-backup.key
# Write down the public key shown in the output:
# Public key: age1xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
export AGE_PUBKEY="age1xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"

# Encrypt with passphrase before storing
echo "$AGE_PASS" | age -p -o age-backup.key.enc /tmp/age-backup.key

# Destroy plaintext key
shred -u /tmp/age-backup.key

# Verify encrypted key decrypts
echo "$AGE_PASS" | age -d age-backup.key.enc > /dev/null && echo "OK"

Write the age public key on paper alongside your passphrases. You will need it later when encrypting secrets on your daily machine.


Phase 3 — Export and Bundle Key Material

Export GPG keys and revocation certificate

mkdir -p /tmp/keyexport

echo "$CERTIFY_PASS" | \
    gpg --batch --pinentry-mode=loopback --passphrase-fd 0 \
        --output /tmp/keyexport/master-secret.asc \
        --armor --export-secret-keys $KEYID

echo "$CERTIFY_PASS" | \
    gpg --batch --pinentry-mode=loopback --passphrase-fd 0 \
        --output /tmp/keyexport/subkeys-secret.asc \
        --armor --export-secret-subkeys $KEYID

gpg --output /tmp/keyexport/master-public.asc \
    --armor --export $KEYID

# GPG requires interactive confirmation for revocation certificates
# Enter CERTIFY_PASS when prompted by pinentry
gpg --pinentry-mode=loopback \
    --output /tmp/keyexport/revoke.asc \
    --gen-revoke $KEYID << 'EOF'
y
0

y
EOF

cp age-backup.key.enc /tmp/keyexport/

Write a recovery document

This file lives unencrypted in the bundle alongside the keys. It contains no secrets — only instructions for a future version of you, or a trusted person, who needs to recover access under stress.

cat > /tmp/keyexport/README-recovery.txt << EOF
COLD STORAGE RECOVERY INSTRUCTIONS
====================================
Created: $(date -u +%Y-%m-%d)
Key ID:  $KEYID

TO DECRYPT THIS BUNDLE
-----------------------
1. Install age (https://age-encryption.org)
2. age -d -o keystore.tar.gz keystore.tar.gz.age
3. tar xzf keystore.tar.gz

CONTENTS
--------
master-secret.asc     GPG master key    — requires CERTIFY passphrase
subkeys-secret.asc    GPG subkeys only  — requires CERTIFY passphrase
master-public.asc     GPG public key    — not sensitive
age-backup.key.enc    age private key   — requires AGE passphrase
revoke.asc            GPG revocation certificate

TO RESTORE GPG
--------------
gpg --import master-secret.asc
gpg --import master-public.asc
gpg --edit-key $KEYID → trust → 5 → save

TO RESTORE age KEY
------------------
age -d -o age-backup.key age-backup.key.enc

TO REVOKE GPG KEY IF COMPROMISED
----------------------------------
gpg --import revoke.asc
gpg --keyserver keys.openpgp.org --send-keys $KEYID

PASSPHRASES are on paper, stored separately from this USB.
EOF

Bundle and encrypt

Everything goes into a single encrypted archive. The outer layer uses a third passphrase — BUNDLE_PASS — separate from the GPG and age passphrases. Even if one layer is broken, the others remain.

cd /tmp/keyexport

tar czf keystore.tar.gz \
    master-secret.asc \
    subkeys-secret.asc \
    master-public.asc \
    age-backup.key.enc \
    revoke.asc \
    README-recovery.txt

echo "$BUNDLE_PASS" | age -p -o keystore.tar.gz.age keystore.tar.gz

# Destroy plaintext
shred -u keystore.tar.gz master-secret.asc \
    subkeys-secret.asc age-backup.key.enc

# Verify before proceeding
echo "$BUNDLE_PASS" | age -d keystore.tar.gz.age | tar tz
# Must list all files cleanly

Phase 4 — Copy to Cold Storage USBs

Mount via UTM shared folder:

UTM → VM Settings → Sharing → Shared Directory → /Volumes/COLDSTORE
sudo mkdir -p /mnt/usb
sudo mount -t 9p -o trans=virtio share /mnt/usb

cp /tmp/keyexport/keystore.tar.gz.age /mnt/usb/
cp /tmp/keyexport/README-recovery.txt /mnt/usb/

# Verify
echo "$BUNDLE_PASS" | age -d /mnt/usb/keystore.tar.gz.age | tar tz

sudo umount /mnt/usb

Repeat for the second USB, then verify the second copy too. Store the two USBs in separate physical locations.


Phase 5 — Factory Reset Both YubiKeys

Before loading anything onto a YubiKey, reset it to factory defaults. This is not optional — it ensures you are starting from a known-clean state regardless of the device’s history. A YubiKey you just unboxed may have been configured during manufacturing testing. A previously used key will carry old keys, PINs, and touch policies that will interfere.

Each applet on the YubiKey is independent and must be reset separately. The three relevant ones for this setup are OpenPGP, PIV, and FIDO2.

Repeat this entire phase for each YubiKey before proceeding.

Connect via USB passthrough: UTM → VM Settings → USB → Add Device → YubiKey.

ykman info   # confirm device is detected and show firmware version

Reset the OpenPGP applet

The OpenPGP applet holds GPG subkeys, PINs, and touch policy. Resetting it wipes all of this and returns PINs to factory defaults (123456 / 12345678).

ykman openpgp reset
# Confirm with 'y' when prompted

Verify the applet is clean:

gpg --card-status
# PIN retry counter should show: 3 3 3 (factory defaults)
# Signature key, Encryption key, Authentication key: [none]

Reset the PIV applet

The PIV applet holds TLS client certificates and keys. Resetting it wipes all slots and returns the PIN and PUK to factory defaults (123456 / 12345678).

ykman piv reset
# Confirm with 'y' when prompted

Verify:

ykman piv info
# All certificate slots should show: Empty

Reset the FIDO2 applet

The FIDO2 applet holds resident keys including any previously generated SSH keys. Resetting it wipes all resident credentials and removes any FIDO2 PIN.

Note: This also resets FIDO/U2F registrations stored on the device. If the YubiKey was previously registered as a second factor with any accounts (GitHub, Google, etc.), those registrations will stop working after a FIDO2 reset. Remove the key from those accounts first, or be prepared to use a backup factor to re-register.

ykman fido reset
# Confirm with 'y' when prompted

Verify:

ykman fido info
# FIDO2 PIN: Not set
# Resident credentials: 0

Confirm all applets are clean

ykman info
# All applets should reflect factory state

gpg --card-status
# No keys loaded, default PINs

ykman piv info
# No certificates in any slot

ykman fido info
# No PIN, no resident credentials

The YubiKey is now in a verified clean state. Proceed to Phase 6 to configure and load keys.


Phase 6 — Load Subkeys onto Both YubiKeys

Repeat this entire phase for each YubiKey.

Connect via USB passthrough: UTM → VM Settings → USB → Add Device → YubiKey.

ykman info          # confirm device detected
gpg --card-status   # confirm OpenPGP applet accessible

Change PINs

Generate random PINs and write them on the same paper as your passphrases.

export USER_PIN=$(LC_ALL=C tr -dc '0-9' < /dev/urandom | fold -w6 | head -1)
export ADMIN_PIN=$(LC_ALL=C tr -dc '0-9' < /dev/urandom | fold -w8 | head -1)
printf "User PIN:  %s\nAdmin PIN: %s\n" "$USER_PIN" "$ADMIN_PIN"

# Change Admin PIN (default: 12345678)
gpg --command-fd=0 --pinentry-mode=loopback --change-pin << EOF
3
12345678
$ADMIN_PIN
$ADMIN_PIN
q
EOF

# Change User PIN (default: 123456)
gpg --command-fd=0 --pinentry-mode=loopback --change-pin << EOF
1
123456
$USER_PIN
$USER_PIN
q
EOF

Set PIN retry count and enable KDF

Three wrong PINs locks the card by default — raise it to five to reduce accidental lockouts. KDF (Key Derived Function) hashes the PIN before sending it to the card, protecting against brute force attacks even if someone extracts the card’s communication.

ykman openpgp access set-retries 5 5 5 -f -a $ADMIN_PIN

gpg --command-fd=0 --pinentry-mode=loopback --edit-card << EOF
admin
kdf-setup
$ADMIN_PIN
quit
EOF

Set touch policy

fixed means touch is always required and the policy cannot be changed without the Admin PIN. Every signing and decryption operation will require a physical touch on the device.

ykman openpgp keys set-touch sig fixed -a $ADMIN_PIN
ykman openpgp keys set-touch enc fixed -a $ADMIN_PIN

ykman openpgp info   # verify touch policies

Set card identity

gpg --command-fd=0 --pinentry-mode=loopback --edit-card << EOF
admin
login
$IDENTITY
$ADMIN_PIN
quit
EOF

Transfer subkeys

# Signing subkey → slot 1
gpg --command-fd=0 --pinentry-mode=loopback --edit-key $KEYID << EOF
key 1
keytocard
1
$CERTIFY_PASS
$ADMIN_PIN
save
EOF

# Encryption subkey → slot 2
gpg --command-fd=0 --pinentry-mode=loopback --edit-key $KEYID << EOF
key 2
keytocard
2
$CERTIFY_PASS
$ADMIN_PIN
save
EOF

Verify and wipe local copies

gpg -K
# sec#  ed25519 [C]   ← # means master key is offline
# ssb>  ed25519 [S]   ← > means key is on card
# ssb>  cv25519 [E]   ← > means key is on card

# Remove local subkey copies — they now live on the card only
gpg --delete-secret-keys $KEYID

# Re-import public key — recreates stubs automatically
gpg --import /tmp/keyexport/master-public.asc
gpg --card-status

Remove the first YubiKey, insert the second, and repeat Phases 5 and 6 from the beginning. Both cards must end up with identical subkeys.


Phase 7 — Generate FIDO2 Resident SSH Keys

Back on your daily Mac — not in the VM. Insert your primary YubiKey.

ssh-keygen -t ed25519-sk \
    -O resident \
    -O verify-required \
    -O application=ssh:yourname \
    -C "yubikey-primary-$(date +%Y)" \
    -f ~/.ssh/id_ed25519_sk_primary

Swap to the backup YubiKey:

ssh-keygen -t ed25519-sk \
    -O resident \
    -O verify-required \
    -O application=ssh:yourname \
    -C "yubikey-backup-$(date +%Y)" \
    -f ~/.ssh/id_ed25519_sk_backup

-O verify-required enforces PIN verification plus physical touch on every use. Drop it if you prefer touch-only.

Deploy both public keys to every server you authenticate to:

ssh-copy-id -i ~/.ssh/id_ed25519_sk_primary.pub user@server
ssh-copy-id -i ~/.ssh/id_ed25519_sk_backup.pub  user@server

Add both to ~/.ssh/config:

Host *
    IdentityFile ~/.ssh/id_ed25519_sk_primary
    IdentityFile ~/.ssh/id_ed25519_sk_backup
    AddKeysToAgent yes

Phase 8 — Configure the Daily Machine

Import GPG public key and configure agent

# From USB
gpg --import /Volumes/COLDSTORE/master-public.asc

# Or decrypt from bundle
age -d keystore.tar.gz.age | tar xzO master-public.asc | gpg --import

# Set ultimate trust
gpg --edit-key $KEYID  # → trust → 5 → save

# Insert YubiKey — auto-recreates stubs
gpg --card-status

Apply the same hardened gpg.conf from Phase 1 to ~/.gnupg/gpg.conf.

# Prevent double-detection issues on macOS
echo "disable-ccid" >> ~/.gnupg/scdaemon.conf

# gpg-agent — no SSH support needed
cat > ~/.gnupg/gpg-agent.conf << EOF
default-cache-ttl 600
max-cache-ttl 7200
pinentry-program /opt/homebrew/bin/pinentry-mac
EOF

gpgconf --kill gpg-agent
gpgconf --launch gpg-agent

In your shell config:

export GPG_TTY=$(tty)
gpgconf --launch gpg-agent

Git signing

git config --global user.signingkey $KEYID
git config --global commit.gpgsign true
git config --global tag.gpgsign true

Test: echo "test" | gpg --clearsign — the YubiKey should blink, touch to sign.

Auto-lock on YubiKey removal (macOS)

# ~/.hammerspoon/init.lua
hs.usb.watcher.new(function(data)
    if data["productName"] and data["productName"]:find("YubiKey") then
        if data["eventType"] == "removed" then
            hs.caffeinate.lockScreen()
        end
    end
end):start()

Wipe the VM

shred -u /tmp/keyexport/* 2>/dev/null
rm -rf /tmp/keyexport
unset GNUPGHOME KEYID KEYFP IDENTITY
unset CERTIFY_PASS AGE_PASS BUNDLE_PASS
unset USER_PIN ADMIN_PIN AGE_PUBKEY

Restore the clean-state snapshot in UTM. The VM now contains no key material.


Yearly Maintenance

Once a year, retrieve the cold storage USB and renew the subkeys:

# 1. Decrypt bundle, import master key
age -d keystore.tar.gz.age | tar xz
gpg --import master-secret.asc

# 2. Extend subkey expiry
gpg --command-fd=0 --pinentry-mode=loopback --edit-key $KEYID << EOF
key 1
expire
1y
key 1
key 2
expire
1y
save
EOF

# 3. Load updated subkeys onto both YubiKeys (repeat Phases 5 and 6)

# 4. Re-bundle and update both cold storage USBs (repeat Phases 3 and 4)

# 5. Publish updated public key
gpg --export --armor $KEYID | curl -T - https://keys.openpgp.org

# 6. Wipe local master key
shred -u master-secret.asc subkeys-secret.asc \
    age-backup.key.enc revoke.asc keystore.tar.gz

Disaster Recovery

Lost one YubiKey, backup still available: Insert backup YubiKey. Run ssh-keygen -K to restore SSH key handles, gpg --card-status to restore GPG stubs. Remove the lost key’s SSH public key from all authorized_keys files. Order a new YubiKey and load it using the cold storage USB.

Lost both YubiKeys: Retrieve cold storage USB. Decrypt bundle, import master key, generate new subkeys, load onto new hardware. Revoke old subkeys and publish the updated key. Regenerate FIDO2 SSH keys and deploy new public keys to all servers.

Need to restore backups, no YubiKey available: The age key on the cold storage USB is independent of the YubiKey. Decrypt the bundle, decrypt the age key, use it to decrypt your secrets file and run rclone. This is covered in detail in part two.


What We Did Not Cover

This post focused entirely on key generation and initial device configuration. Part two covers the operational layer: using age to manage secrets at rest, encrypting backups with rclone crypt, and running the whole thing inside a Docker container on a scheduled cron. The YubiKey does not appear at all in that workflow — which is the point.