Hardware Keys, Age, and Agenix: A Complete NixOS Secrets Stack
Every NixOS configuration needs secrets — database passwords, API tokens, private keys. The Nix store is world-readable by design, so these values can't live in your configuration files. The standard answer is agenix: encrypt secrets with age, decrypt them at activation time. But what protects the age key itself?
The Problem with Software Keys
The typical agenix setup starts with age-keygen, which creates a key file at ~/.config/age/keys.txt. This file is your master decryption key. Anyone who can read it can decrypt every secret in your repository.
Full-disk encryption helps while the machine is powered off. But on a running system — the state your workstation spends most of its time in — that key file is just a regular file. An attacker with shell access, a malicious process, or a compromised dependency can read it. One cat ~/.config/age/keys.txt and your entire secrets repo is open.
This isn't hypothetical. It's the same class of vulnerability that makes storing SSH private keys without passphrases risky: the security of everything downstream depends on a file that any process running as your user can access.
You can mitigate this with passphrases and careful permissions. But the fundamental problem remains: the secret key material exists as extractable data on your workstation's filesystem.
Hardware-Rooted Trust
A YubiKey eliminates this class of attack entirely. Cryptographic keys are generated inside the YubiKey's tamper-resistant secure element and never leave it. The operating system cannot read the private key — it can only send data to the YubiKey and get signed or decrypted results back. Every cryptographic operation requires physical touch on the device.
Two YubiKey capabilities matter for NixOS secrets management:
FIDO2 (for SSH): The YubiKey generates an ed25519-sk SSH key pair. The -sk suffix means "security key" — the private key stays on the device. The file on disk (~/.ssh/id_ed25519_sk) is just a handle; it's useless without the physical YubiKey. With -O resident, the key is stored on the YubiKey itself, so you can load it on any machine with ssh-add -K.
PIV (for age encryption): The age-plugin-yubikey tool creates an age identity backed by the YubiKey's PIV applet. Encrypting and decrypting age-encrypted data — including agenix secrets — requires the YubiKey to be plugged in and touched. Unlike GPG smartcard setups, this uses the simpler PIV standard and integrates directly with age's plugin system.
The guarantee is physical: decryption requires possession of the hardware device plus a touch. No amount of software compromise on your workstation can extract the key or perform operations without your physical presence.
The Trust Chain
YubiKey, age, agenix, and NixOS are four independent tools. Each handles one layer of the secrets stack. Together, they form a complete trust chain from hardware to running services:
YubiKey Hardware
│
├─ FIDO2: ed25519-sk SSH key
│ └─ Authenticates you to remote hosts and GitHub
│
└─ PIV: age-plugin-yubikey identity
└─ Encrypts/decrypts age-encrypted data
└─ agenix uses age to manage NixOS secrets
└─ NixOS activation decrypts to /run/agenix/ (tmpfs)
└─ Services read secrets from known pathsEach layer is independent. You can use age without agenix. You can use agenix with a software age key instead of a YubiKey. You can use a YubiKey for SSH without touching age at all. But composed together:
-
You encrypt a secret with
agenix -e secrets/db-password.age. Age encrypts it for the public keys listed insecrets.nix— your YubiKey's age public key plus the target host's SSH public key. -
You commit the
.agefile to version control. It's safe — only the listed keys can decrypt it. -
NixOS activates on the target host. The agenix module decrypts each secret using the host's SSH private key (from
/etc/ssh/ssh_host_ed25519_key). Decrypted secrets land in/run/agenix/, a tmpfs mount that never touches disk. -
Services reference the decrypted path:
{
age.secrets.db-password = {
file = ../../agenix-secrets/secrets/db-password.age;
owner = "myapp";
mode = "0400";
};
services.myapp.passwordFile = config.age.secrets.db-password.path;
# Resolves to /run/agenix/db-password at runtime
}The YubiKey secures the human side — your ability to create, edit, and re-key secrets. The host's SSH key secures the machine side — automatic decryption during activation without human interaction.
Declarative Identity Management
There's one gap in making this stack fully declarative: the age identity file. After enrolling a YubiKey with age-plugin-yubikey, you get an identity string that starts with AGE-PLUGIN-YUBIKEY-. This string is a pointer — it contains the YubiKey's serial number and slot, not any secret material. Age needs this pointer to know which plugin and device to use for decryption.
Without automation, you'd manually save this string to a file, remember the path, and either pass -i path/to/identity every time or set AGE_IDENTITIES_FILE in your shell profile. On a new machine, you'd repeat the process.
Keystone's ageYubikey module handles this declaratively:
keystone.terminal.ageYubikey = {
enable = true;
identities = [
"AGE-PLUGIN-YUBIKEY-17DDRYQ5ZFMHALWQJTKHAV" # yubi-black
];
};This writes the identity strings to ~/.age/yubikey-identity.txt via home-manager and sets AGE_IDENTITIES_FILE to point at it. After activation, age and agenix find the identity automatically. No manual file management, no -i flags.
If you have multiple YubiKeys — a primary for daily use and a backup in a safe — add both identity strings:
identities = [
"AGE-PLUGIN-YUBIKEY-17DDRYQ5ZFMHALWQJTKHAV" # yubi-black (primary)
"AGE-PLUGIN-YUBIKEY-1A2B3C4D5E6F7G8H9I0JAB" # yubi-backup
];Both appear in the identity file. Age tries each one when decrypting, using whichever YubiKey is plugged in. See the Hardware keys doc for the full options reference.
Putting It Together
The complete stack is declarative and reproducible. Set up a YubiKey once, add the identity and public keys to your NixOS configuration, and every rebuild produces a working secrets environment. Move to a new machine, plug in the YubiKey, rebuild, and everything works.
The implementation guides:
- Hardware keys — YubiKey SSH setup, age identity management, SSH agent configuration, and key inventory
- Secrets management — Set up
secrets.nix, encrypt and decrypt secrets, configure NixOS hosts
One operational note: get a backup YubiKey. Initialize it with the same steps — generate an SSH key, enroll an age identity — and add both keys' public keys to secrets.nix. Run agenix -r to re-encrypt all secrets for both keys. Store the backup somewhere secure. If your primary key is lost or damaged, the backup can still decrypt and re-key everything.