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
- Why No GPG Auth Subkey?
- Why Ed25519 Instead of RSA 4096?
- Hardware
- The Air-Gap Environment
- Prepare Cold Storage USBs
- Phase 1 — Generate the GPG Master Key
- Phase 2 — Generate the age Key
- Phase 3 — Export and Bundle Key Material
- Phase 4 — Copy to Cold Storage USBs
- Phase 5 — Factory Reset Both YubiKeys
- Phase 6 — Load Subkeys onto Both YubiKeys
- Phase 7 — Generate FIDO2 Resident SSH Keys
- Phase 8 — Configure the Daily Machine
- Wipe the VM
- Yearly Maintenance
- Disaster Recovery
- What We Did Not Cover
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.