Beyond GPG: Hardware-Backed File Encryption with age and YubiKey
A practical guide to modern file encryption using age's clean cryptographic design and YubiKey's PIV applet for hardware-rooted key protection

1. Introduction: The Problem with GPG
GPG has been the de facto standard for file encryption for decades, but its age shows. The OpenPGP specification (RFC 4880) carries decades of backward compatibility baggage: configurable cipher suites that invite downgrade attacks, a web-of-trust model that almost nobody uses correctly, a keyring/keyserver architecture that leaks metadata, and a UX so hostile that even security professionals routinely misconfigure it. The attack surface is enormous β not because the cryptography is weak, but because the decision space presented to the user is.
OpenSSL's enc command is another common choice, but it's even worse from a usability standpoint: no native public-key encryption, no authenticated encryption by default (CBC mode without HMAC), and easy-to-forget flags that silently produce insecure output.
age was created by Filippo Valsorda (former Go cryptography lead at Google) to solve exactly this problem: provide a file encryption tool that is secure by default, with no configuration knobs to turn wrong, explicit key management, and UNIX-style composability. When combined with age-plugin-yubikey, it delivers hardware-backed key protection with a fraction of GPG+smartcard complexity.
2. What is age
age (pronounced "ah-geh", from the Italian word) is a file encryption tool, file format, and library. Its design philosophy can be summarized in four principles:
No configuration. No config files, no keyrings, no keyservers. Keys are explicit arguments.
No algorithm negotiation. One cipher suite, chosen by the developers, not the user.
Small explicit keys. Recipients are short strings (
age1...) that fit in a chat message.UNIX composability. Reads from stdin, writes to stdout, pipes cleanly.
The format specification lives at age-encryption.org/v1 and has multiple interoperable implementations: the reference Go implementation (filippo.io/age), rage in Rust, typage in TypeScript, and others in Python, Java, Kotlin, and Swift. The plugin system (introduced in v1.1.0) enables custom recipient types β hardware tokens, cloud KMS, post-quantum hybrids β without touching the core format.
3. Cryptographic Primitives
age uses a deliberately small, modern cryptographic suite with no user-selectable options:
| Primitive | Algorithm | Purpose |
|---|---|---|
| Key agreement | X25519 (Curve25519 ECDH) | Asymmetric key wrapping for age1... recipients |
| Symmetric encryption | ChaCha20-Poly1305 | AEAD encryption of the file payload |
| Key derivation | HKDF-SHA-256 | Deriving the header MAC key and stream key from the file key |
| Passphrase KDF | scrypt | Stretching passphrases into encryption keys |
How it fits together
A random 16-byte file key is generated per encryption operation.
The file key is wrapped (encrypted) independently for each recipient β using X25519 ephemeral ECDH for public-key recipients, scrypt for passphrases, or a plugin-specific mechanism for hardware tokens.
Each wrapped copy becomes a stanza in the ASCII header.
HKDF-SHA-256 derives two keys from the file key: one for the header MAC (authenticating the header stanzas) and one for the payload stream key.
The payload is encrypted as a stream of 64 KiB chunks using ChaCha20-Poly1305, where each chunk is independently authenticated.
This design provides forward privacy (ephemeral ECDH keys mean a compromised long-term key cannot decrypt past ciphertexts) and multi-recipient support without re-encrypting the payload β only the 16-byte file key is wrapped once per recipient.
For post-quantum resistance, age now supports hybrid ML-KEM-768 + X25519 keys (recipients starting with age1pq1...), though this is still experimental.
4. Basic age Usage
Key generation
# Generate a new X25519 key pair
age-keygen -o key.txt
# Output:
# Public key: age1xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
# The file contains the secret key (AGE-SECRET-KEY-1...)
# The public key is printed to stderr
Encryption and decryption
# Encrypt to a recipient's public key
age -r age1recipient... -o secret.txt.age secret.txt
# Encrypt to multiple recipients
age -r age1alice... -r age1bob... -o shared.age document.pdf
# Encrypt with a passphrase (interactive prompt)
age -p -o backup.tar.age backup.tar
# Decrypt with a key file
age -d -i key.txt -o secret.txt secret.txt.age
# Pipe-friendly usage
tar czf - ./project | age -r age1recipient... > project.tar.gz.age
age -d -i key.txt < project.tar.gz.age | tar xzf -
SSH key compatibility
# Encrypt to an existing SSH public key
age -R ~/.ssh/id_ed25519.pub -o secret.age secret.txt
# Decrypt with the corresponding SSH private key
age -d -i ~/.ssh/id_ed25519 secret.age > secret.txt
5. YubiKey Integration
This is where age genuinely outclasses GPG. Setting up GPG with a smartcard involves generating keys on the host, moving subkeys to card slots, managing trust databases, and configuring gpg-agent. With age-plugin-yubikey, the entire flow is: install the plugin, run one command, encrypt.
How age-plugin-yubikey works
The plugin uses the YubiKey's PIV (Personal Identity Verification) applet β the same applet used for smart card authentication in enterprise environments. Specifically:
It generates an ECDSA P-256 key pair directly on the YubiKey.
The private key never leaves the hardware.
The key is stored in one of the 20 "retired" PIV slots (82β95), which avoids conflicts with standard PIV slots (9a, 9c, 9d, 9e) that may hold RSA keys for other purposes.
An
ageidentity file is produced that contains the slot reference and YubiKey serial number β enough for the plugin to locate the key at decryption time, but not the key material itself.
The plugin acts as a bridge: age clients discover it via the naming convention (age-plugin-yubikey in $PATH), delegate key operations to it, and the plugin communicates with the YubiKey via the PC/SC interface.
HMAC-SHA1 Challenge-Response vs. PIV: Two Approaches Compared
The YubiKey Shell Toolkit implements both approaches. Understanding the tradeoffs is important:
| Aspect | HMAC-SHA1 (OTP Slot 2) | PIV + age |
|---|---|---|
| Mechanism | Challenge-response to derive an AES key via PBKDF2 | ECDH key agreement using PIV-stored P-256 key |
| Encryption | AES-256-CBC via OpenSSL (+ PBKDF2, 600k iterations) | ChaCha20-Poly1305 via age (authenticated encryption) |
| Artifacts | .yk.enc file + .yk.challenge file (both required) |
Single .age file + identity file for decryption |
| Multi-recipient | Not natively supported | Native age feature β wrap file key per recipient |
| Ecosystem | Custom scripts, toolkit-specific | Standard age format, interoperable with all age clients |
| PIN policy | Touch only (configured at slot programming) | Configurable: never, once, always for both PIN and touch |
| Authentication | No AEAD β CBC without integrated MAC | AEAD (Poly1305) β tamper detection built in |
The HMAC approach is simpler and works on YubiKeys without PIV support, but it relies on OpenSSL's enc command in CBC mode (not authenticated encryption) and requires managing an auxiliary .yk.challenge file. The challenge file alone is not sensitive β without the physical YubiKey to compute the HMAC response, it's useless β but losing it means losing the file.
The PIV approach is strictly superior for most use cases: authenticated encryption, multi-recipient support, a standardized format, and configurable PIN/touch policies. Use HMAC only when PIV is unavailable (e.g., YubiKey NEO).
HMAC approach (for reference)
The yk-encrypt-file.sh script demonstrates the HMAC flow:
#!/usr/bin/env bash
set -euo pipefail
INPUT="\({1:?Usage: \)0 <file>}"
FILE="\((cd "\)(dirname "\(INPUT")" && pwd)/\)(basename "$INPUT")"
SLOT=2
# Generate random challenge
CHALLENGE=$(openssl rand -hex 32)
# YubiKey computes HMAC β secret never leaves hardware
HMAC=\((ykman otp calculate 2 "\)CHALLENGE" 2>/dev/null) || {
echo "[!] YubiKey did not respond." >&2; exit 1
}
# Derive AES-256 key from challenge+HMAC
KEY=\((echo -n "\){CHALLENGE}${HMAC}" | openssl dgst -sha256 -binary | xxd -p -c 256)
# Encrypt with AES-256-CBC + PBKDF2
openssl enc -aes-256-cbc -pbkdf2 -iter 600000 \
-k "\(KEY" -in "\)FILE" -out "${FILE}.yk.enc"
echo "\(CHALLENGE" > "\){FILE}.yk.challenge"
Installation and Setup
Prerequisites
# Debian/Ubuntu
sudo apt-get install pcscd age libpcsclite-dev
# Fedora
sudo dnf install pcsc-lite age pcsc-lite-devel
# Arch
sudo pacman -S pcsclite pcsc-tools yubikey-manager age
# Ensure pcscd is running
sudo systemctl enable --now pcscd
Install age-plugin-yubikey
# Homebrew (macOS/Linux)
brew install age-plugin-yubikey
# Cargo (Rust 1.70+)
cargo install age-plugin-yubikey
# Arch Linux
pacman -S age-plugin-yubikey
Pre-built binaries for Windows, Linux, and macOS are available on the releases page.
Generate a YubiKey identity
Interactive mode (recommended for first-time setup):
age-plugin-yubikey
This launches a text-based wizard that:
Detects connected YubiKeys and lets you select one.
Shows available PIV slots and selects a free "retired" slot (e.g., slot 82).
Prompts you to name the identity.
Asks for PIN policy (
never,once,always) and touch policy (never,always,cached).If default PINs are detected, prompts you to change them.
If the default management key is detected, generates a random one and stores it in PIN-protected metadata.
Programmatic mode:
age-plugin-yubikey --generate \
--slot 1 \
--name "backup-encryption" \
--pin-policy once \
--touch-policy always \
> ~/.config/yk-toolkit/age/yubikey-identity.txt
Extract the recipient (public key) for sharing:
# List all age recipients from connected YubiKeys
age-plugin-yubikey --list
# Or extract from a specific slot
age-plugin-yubikey --identity --slot 1 2>/dev/null \
| grep "^# recipient:" | cut -d' ' -f3 \
> ~/.config/yk-toolkit/age/yubikey-recipient.txt
Practical Examples
Single-recipient encryption with YubiKey
The yk-age-encrypt.sh script wraps age with sensible defaults:
# Encrypt using the stored YubiKey recipient
./yk-age-encrypt.sh document.pdf
# β document.pdf.age
# Encrypt with an explicit recipient
./yk-age-encrypt.sh -r age1yubikey1qfmj3... -o secrets.age secrets.env
# Decrypt (requires physical YubiKey + PIN/touch)
./yk-age-decrypt.sh document.pdf.age
The script automatically resolves the recipient by checking (in order):
The
-rflag.~/.config/yk-toolkit/age/yubikey-recipient.txt.Live query via
age-plugin-yubikey --list.
Direct age commands (no wrapper)
# Encrypt
age -r age1yubikey1qfmj3... -o report.age report.xlsx
# Decrypt β the plugin handles YubiKey interaction transparently
age -d -i yubikey-identity.txt -o report.xlsx report.age
When the touch policy is set to always, the YubiKey's LED will blink during decryption β physical touch is required to authorize the cryptographic operation.
6. Multi-Recipient Encryption
This is where age + YubiKey truly shines for team workflows. Since age wraps the file key independently for each recipient, you can encrypt a single file to multiple YubiKeys, software keys, or a mix of both.
Use cases
Team access: Encrypt shared secrets to every team member's YubiKey.
Redundancy: Encrypt to both a primary and backup YubiKey in case one is lost.
Hybrid recovery: Encrypt to your YubiKey and a passphrase-protected software key stored in a safe.
CI/CD pipelines: Encrypt to a hardware key for humans and a software key for automation.
Multi-recipient scripts
The yk-age-encrypt-multikeys.sh script supports multiple recipients via -r flags or a recipients file:
# Encrypt to multiple recipients via flags
./yk-age-encrypt-multikeys.sh \
-r age1yubikey1qfmj3... \
-r age1yubikey1q2xk7... \
-r age1ql3z7hjy54pw... \
secrets.tar.gz
# Or maintain a recipients file
cat ~/.config/yk-toolkit/age/recipients.txt
# Primary YubiKey
age1yubikey1qfmj3...
# Backup YubiKey
age1yubikey1q2xk7...
# Software fallback key
age1ql3z7hjy54pw...
# Encrypt using the recipients file (auto-loaded)
./yk-age-encrypt-multikeys.sh secrets.tar.gz
The corresponding yk-age-decrypt-multikeys.sh tries multiple identity files:
# Decrypt with multiple identity files
./yk-age-decrypt-multikeys.sh \
-i ~/yubikey-primary-identity.txt \
-i ~/yubikey-backup-identity.txt \
secrets.tar.gz.age
# Or maintain an identities file
cat ~/.config/yk-toolkit/age/identities.txt
/home/user/.config/yk-toolkit/age/yubikey-identity.txt
/home/user/.config/yk-toolkit/age/backup-identity.txt
# Decrypt using the identities file (auto-loaded)
./yk-age-decrypt-multikeys.sh secrets.tar.gz.age
age will try each identity until one succeeds β you only need one matching YubiKey inserted.
Practical redundancy pattern
# Setup: generate identities on two YubiKeys
age-plugin-yubikey --generate --slot 1 --name "primary" \
--pin-policy once --touch-policy always > primary-identity.txt
# Swap YubiKeys
age-plugin-yubikey --generate --slot 1 --name "backup" \
--pin-policy once --touch-policy always > backup-identity.txt
# Also generate a software recovery key
age-keygen -o recovery-key.txt
# Collect all recipients
age-plugin-yubikey --list > all-recipients.txt
grep "^# public" recovery-key.txt | cut -d: -f2 >> all-recipients.txt
# Encrypt backups to all three
tar czf - ~/documents | age -R all-recipients.txt -o backup.tar.gz.age
If the primary YubiKey is lost, decrypt with the backup or the software key. Then re-encrypt with a new set of recipients.
7. Security Considerations and Best Practices
Hardware-backed key advantages
Non-extractable private keys: The PIV applet does not allow exporting generated private keys. Even with physical access to the YubiKey and the PIN, the key material cannot be read.
PIN brute-force protection: After 3 failed PIN attempts, the YubiKey locks. After 3 failed PUK attempts, the PIV applet is permanently locked (requires a full reset, destroying all keys).
Touch confirmation: With
--touch-policy always, every cryptographic operation requires physical contact β malware cannot silently decrypt files even if it has access to the identity file and the YubiKey is plugged in.
Best practices
Change default PINs immediately. The default PIV PIN is
123456and PUK is12345678.age-plugin-yubikeyprompts for this, but verify.Use
--pin-policy once --touch-policy alwaysas a baseline. "Once" means the PIN is cached for the session (avoiding repeated prompts during batch decryption), while "always" touch prevents silent use.Always encrypt to at least two recipients β your primary YubiKey and a recovery mechanism (backup YubiKey or software key in secure storage).
Protect identity files. While the identity file doesn't contain the private key, it tells
agewhich YubiKey and slot to use. Treat it as sensitive metadata:chmod 600.Store software recovery keys offline β printed on paper, in a safe deposit box, or on an encrypted USB drive stored separately.
Rotate keys when a YubiKey is lost. Re-encrypt all accessible data to a new recipient set that excludes the lost key.
Pin caching caveat: On YubiKey 4 series, PIN cache preservation does not work due to how the serial number is obtained β the plugin performs a soft reset that clears the cache. YubiKey 5 series handles this correctly.
Threat model boundaries
age with YubiKey protects data at rest. It does not provide:
Signing or authentication (use SSH or FIDO2 for that).
Forward secrecy for stored files β if a file was encrypted to a recipient, compromising that recipient's key allows decryption of that specific file. Forward privacy applies to the encrypting side (ephemeral ECDH), not the recipient's long-term key.
Deniability β the header reveals the number of recipients (stanza count) and the recipient type (plugin name).
8. Ecosystem and Tooling
Core tools
| Tool | Language | Description |
|---|---|---|
| age | Go | Reference implementation β CLI + library |
| rage | Rust | Alternative implementation, supports plugins |
| age-plugin-yubikey | Rust | YubiKey PIV integration |
| age-keygen | Go | Key generation (bundled with age) |
Integration tools
SOPS β Mozilla's secret manager supports
ageas a backend. Combine withage-plugin-yubikeyfor hardware-backed secret management in GitOps workflows.YubiKey Shell Toolkit β Bash scripts for both HMAC and age-based YubiKey encryption, including multi-recipient support, setup automation, and verification.
passage β A password store (like
pass) built onageinstead of GPG.awesome-age β Curated list of
ageecosystem projects.
Relevant scripts from YubiKey Shell Toolkit
| Script | Purpose |
|---|---|
yk-encrypt-file.sh |
HMAC-based encryption (AES-256-CBC) |
yk-age-encrypt.sh |
Single-recipient age + YubiKey PIV encryption |
yk-age-encrypt-multikeys.sh |
Multi-recipient age encryption |
yk-age-decrypt-multikeys.sh |
Multi-identity age decryption |
9. Conclusion
age represents what encryption tooling should have been all along: a single correct cipher suite, explicit key management, no footguns. Adding YubiKey via age-plugin-yubikey elevates it from "good software encryption" to "hardware-rooted key protection" with remarkably little ceremony β age-plugin-yubikey --generate, then age -r as usual.
For teams, the multi-recipient model is the real differentiator. Encrypting shared secrets to every team member's YubiKey (plus a recovery key) is a one-liner, not a GPG keyring management odyssey. When someone leaves the team, re-encrypt to the updated recipient list. When a YubiKey is lost, the PIN lockout protects against brute force while you rotate.
The combination is particularly compelling for:
Encrypted backups β hardware-keyed, with software recovery fallback.
Secret management in Git β via SOPS + age + YubiKey.
Sensitive file exchange β share a short
age1yubikey1...string, not a GPG key fingerprint ceremony.Compliance environments β hardware-backed key storage with touch confirmation satisfies many audit requirements.
If you're still wrapping files with gpg -c or openssl enc, it's time to upgrade.
References
age β github.com/FiloSottile/age
age format specification β age-encryption.org/v1
age-plugin-yubikey β github.com/str4d/age-plugin-yubikey
YubiKey Shell Toolkit β github.com/Esl1h/yubikey-shell-toolkit
awesome-age ecosystem β github.com/FiloSottile/awesome-age




