Age Encryption for Simple Secrets
Age Encryption for Simple Secrets
GPG keyrings and expiry theatre do not belong in a desk CI job. The boring default is: age (X25519) for files, ssh-to-age so SSH keys are recipients, and agenix only if you want NixOS decryption without SOPS’s YAML.
This book already uses sops-nix as the default for structured secrets. Age is the primitive underneath. Agenix is the alternative when you want one file per secret and less YAML.
Mental model
| Tool | Role |
|---|---|
age |
Encrypt/decrypt bytes to age1… or SSH recipients |
ssh-to-age |
Convert ssh-ed25519 pubkey → age1… |
agenix |
NixOS module: .age files in git → /run/agenix/… at boot |
No keyserver. A public key is a line of text. Lose the private key, lose the files (unless another recipient remains).
desk-token.age (cipher, git)
recipients: alice's ssh, desk-node host ssh
│ agenix at boot
▼
/run/agenix/desk-token
Private keys never enter /nix/store. Ciphertext always lives in git.
git check-ignore -v key.txt .age/key.txt 2>/dev/null || true
git ls-files '*.age' | head.age files are tracked. key.txt is not. If git ls-files shows key.txt, rotate that key now.
Worked examples
Case 1: Encrypt a file to yourself
mkdir -p "$HOME/.age"
age-keygen -o "$HOME/.age/key.txt"
# public key printed on stderr: age1…
echo 'desk-token-v1' | age -r age1youxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx > token.age
age -d -i "$HOME/.age/key.txt" token.ageOutput:
desk-token-v1
Commit token.age. Do not commit key.txt. chmod 600 the key file.
SOPS_AGE_KEY_FILE=$HOME/.age/key.txt is how sops finds the same identity. age -d -i is the explicit form. Do not export SOPS_AGE_KEY (the raw key) in a shell history. Armor (age -a) is optional; sops YAML already stores ciphertext as ASCII.
Case 2: SSH public key as recipient
ssh-to-age < ~/.ssh/id_ed25519.pub
echo 'desk-token-v1' | age -R ~/.ssh/id_ed25519.pub -o token.age
age -d -i ~/.ssh/id_ed25519 token.ageHosts:
ssh-to-age < /etc/ssh/ssh_host_ed25519_key.pubEvery NixOS machine already has a recipient if you persist host keys (impermanence: persist /etc/ssh/ssh_host_ed25519_key or decryption fails every boot).
age -R recipients.txt reads many age1… / ssh-ed25519 lines. Keep recipients.txt in git (public keys only). A passphrase (age -p) is a human backup, not a fleet identity — sops/agenix will not type it at boot.
Case 3: agenix rules file
Save as secrets.nix:
# secrets.nix
let
alice = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIExampleUserKey alice@desk";
server = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIExampleHostKey root@desk-node";
in
{
"desk-token.age".publicKeys = [ alice server ];
}nix shell nixpkgs#agenix --command agenix -e desk-token.age$EDITOR opens the plaintext. Save; the .age file is updated. Recipients are only those publicKeys. Adding a host later requires agenix -r (rekey).
Case 4: Consume on NixOS
Save as host.nix:
# host.nix
{ config, inputs, ... }:
{
imports = [ inputs.agenix.nixosModules.default ];
age.secrets.desk-token = {
file = ./desk-token.age;
owner = "desk-api";
group = "desk-api";
mode = "0400";
};
systemd.services.desk-api.serviceConfig.LoadCredential =
"token:${config.age.secrets.desk-token.path}";
}Boot decrypts to /run/agenix/desk-token (path may be the credential copy). The unit never sees a Nix string. EnvironmentFile pointing at the secret path is also fine; argv is not (ExecStart=… --token hunter2).
age.identityPaths defaults to the host ed25519 key. If you also decrypt as a user, list that user’s key — and do not put it in the store. owner = "desk-api" requires that user to exist at decrypt time; with DynamicUser, keep the file root-owned 0400 and LoadCredential.
Case 5: Rekey when the fleet grows
# add bob to secrets.nix publicKeys, then:
agenix -r
git diff desk-token.ageThe ciphertext changes; plaintext is the same. If you skip rekey, bob’s laptop cannot decrypt, and a new server fails at boot with an age error in the agenix unit.
journalctl -u agenix -n 40 --no-pagerThe trap
The trap is one recipient: your laptop. The server is not you. Always include the host SSH key.
The other trap is using both sops-nix and agenix for the same secret. Pick one tree: sops YAML for many keys, agenix for a handful of files. Two systems means two rekey rituals.
A third: putting key.txt in the ISO or in isoImage.contents. Ciphertext in git; identity keys on disk / sops.
A fourth: ssh-to-age of a private key (it wants the .pub). A fifth: RSA host keys — prefer ed25519; ssh-to-age wants the modern key type.
The boring rule
- Age, not GPG, for desk secrets.
- Recipients = people + hosts (
ssh-to-ageof .pub). Persist host keys on ephemeral roots. - Rekey when keys are added or revoked (
agenix -r/sops updatekeys). - Private keys never in git (
SOPS_AGE_KEY_FILE, notSOPS_AGE_KEYin history). Ciphertext always in git. - Prefer sops-nix if you already have YAML; agenix if you want one-file-one-secret. Not both for the same token.
Try this
ssh-to-age < ~/.ssh/id_ed25519.pub, encrypt a one-line file, decrypt with-i ~/.ssh/id_ed25519.- Encrypt to two recipients, remove your key from the list, confirm decrypt fails, put it back.
agenix -ea dummy secret in a throwaway repo andgit addonly the.agefile.- On a VM, omit the host from
publicKeys, switch, readjournalctl -u agenix. Add the host, rekey, switch again.