Skip to content

Per-Host agenix-rekey Configuration with YubiKey Masters and Encrypted Backup

Library

A curried agenix-rekey module fragment. You apply it once with your fleet-wide key settings, then call the result per host as ageWith name pubkey to produce that host's age.rekey block. It encodes four things that are easy to get subtly wrong.

The problem

With agenix-rekey, secrets are stored encrypted to a set of master identities (the keys a human uses to edit/rekey) and rekeyed per host to each host's own key. When your master identities are hardware YubiKeys, you also want:

  • an offline backup identity in case the YubiKeys are lost — but the backup identity itself should be encrypted at rest, and
  • recovery recipients so every secret can also be opened another way,

...without pinning a recipient a host can't yet use during bootstrap. Those requirements collide in a few non-obvious ways.

The key insights / traps

  1. Inline the encrypted backup identity's pubkey. A normal master identity is a plaintext .pub file, so agenix reads the recipient's public key straight from it. But if you keep the backup identity encrypted at rest (an age-encrypted .age file), agenix can't read a pubkey out of it without decrypting it first — which defeats the point of an offline, seldom-unlocked backup. The fix is to give the pubkey inline next to the encrypted identity:
{
  identity = keysDir + "/age-offline-backup-identity.age";
  pubkey   = "age1...";   # stated explicitly, no decryption needed
}

Now agenix can select the backup as a recipient at rekey time while the private identity stays sealed.

  1. Omit hostPubkey for hosts that can't decrypt yet. During install / first boot, a host has no age key of its own. If you pin a hostPubkey for it anyway, rekey encrypts secrets to a recipient that host cannot open — a footgun that "works" until you actually try to boot it. Pass pubkey = null for such hosts and the whole hostPubkey attribute is left out; the host still gets secrets via the master + recovery recipients until it has a key. Fill in the real pubkey once the host can decrypt.

  2. extraEncryptionPubkeys are recovery recipients on every secret. These are added on top of the masters, so any secret can also be opened with, e.g., a YubiKey SSH recipient or an offline root-recovery key. This is your break-glass path if the primary identities are unavailable.

  3. hostPubkey accepts an age or ssh public key. Pass the host's own key as age1..., as ssh-ed25519 AAAA..., or as a Nix path to a file holding one — agenix-rekey takes any of those. Just be consistent per host.

Usage

Partially apply once with your key settings, then call per host:

let
  ageWith = import ./agenix-rekey-yubikey-per-host {
    keysDir     = ../keys;   # dir with the .pub / .age files (a Nix path)
    secretsRoot = ../.;      # repo root containing secrets/ (a Nix path)

    yubikeyIdentities = [
      "age-yubikey-primary-identity.pub"
      "age-yubikey-backup-identity.pub"
    ];

    backupIdentity = {
      identity = "age-offline-backup-identity.age";
      pubkey   = "age1ExampleReplaceWithYourBackupPubkey00000000000000000000";
    };

    recoveryPubkeys = [
      "age-yubikey-ssh.pub"
      "root-backup.pub"
    ];
  };
in
{
  # a normal host with its own key (age or ssh form both work):
  "your-host" = ageWith "your-host" "ssh-ed25519 AAAA...";

  # a host that can't decrypt yet (installer / bootstrap image):
  "installer" = ageWith "installer" null;
}

Import each ageWith name pubkey result as a NixOS module for that host.

Options

option default meaning
keysDir Nix path to the directory holding all master/recovery key files.
secretsRoot Nix path to the repo root under which per-host ciphertext lives.
yubikeyIdentities [] Plaintext .pub master identity filenames (relative to keysDir).
backupIdentity null { identity; pubkey; } for an encrypted backup identity with its pubkey inlined.
recoveryPubkeys [] Extra recipient .pub filenames added to every secret.
perHostSubdir "/secrets/per-host" Sub-path (under secretsRoot) for per-host rekeyed ciphertext; <name> is appended.
generatedSecretsDir secretsRoot + "/secrets/generated" Where generated secrets are written.

Caveats

  • storageMode = "local" keeps rekeyed ciphertexts in the repo (secrets/per-host/<name>), not out of tree. That directory must be writable in your working tree when you rekey.
  • Everything except hostPubkey is fleet-wide and identical across hosts — the per-host application only decides whether to pin a hostPubkey.
  • The inlined backupIdentity.pubkey must actually match the encrypted identity, since nothing verifies it against the sealed file at eval time. A mismatch means secrets get encrypted to a key you can't recover with.

Source

lib/agenix-rekey-yubikey-per-host/default.nix
# agenix-rekey per-host wiring: YubiKey master identities + an encrypted
# backup identity + extra recovery recipients, with a bootstrap escape hatch.
#
# This is a *curried* module fragment. You partially apply it once with your
# fleet-wide key settings, then call the result per host as `ageWith name pubkey`
# to produce that host's `age.rekey` config. See README.md for the "why".
#
# Usage (in your flake / lib):
#
#   ageWith = import ./agenix-rekey-yubikey-per-host {
#     keysDir          = ../keys;            # dir holding the .pub / .age files
#     secretsRoot      = ../.;               # repo root that contains secrets/
#     yubikeyIdentities = [                   # master YubiKey age recipients
#       "age-yubikey-primary-identity.pub"
#       "age-yubikey-backup-identity.pub"
#     ];
#     backupIdentity = {                      # an *encrypted* offline identity
#       identity = "age-offline-backup-identity.age";
#       # its age pubkey, inlined so agenix can pick it as a recipient
#       # WITHOUT decrypting the identity first (see README):
#       pubkey   = "age1ExampleReplaceWithYourBackupPubkey00000000000000000000";
#     };
#     recoveryPubkeys = [                     # extra recipients on EVERY secret
#       "age-yubikey-ssh.pub"
#       "root-backup.pub"
#     ];
#   };
#
#   # then, per host:
#   #   (ageWith "your-host" hostAgePubkey)   -> imported as a NixOS module
#   #   (ageWith "installer" null)            -> bootstrap host, no hostPubkey
#
# All path-like inputs (keysDir, secretsRoot) should be Nix paths so the files
# are captured in the store. Filenames are strings resolved against keysDir.
{
  # Directory containing all master/recovery key material (.pub and .age).
  keysDir,

  # Repo root under which per-host ciphertext + generated secrets live.
  # `secretsRoot + "/secrets/per-host/<name>"` and
  # `secretsRoot + "/secrets/generated"` must be writable in your tree.
  secretsRoot,

  # Master YubiKey age identities (public-key files). Filenames relative to
  # keysDir. These are the hardware keys that can decrypt at edit/rekey time.
  yubikeyIdentities ? [ ],

  # An offline backup identity that is itself age-encrypted. Because agenix
  # cannot read the pubkey out of an encrypted identity, you inline it here.
  # Set to null if you don't use an encrypted backup identity.
  #   { identity = "<file under keysDir>.age"; pubkey = "age1..."; }
  backupIdentity ? null,

  # Extra recipients that EVERY secret is additionally encrypted to, so you
  # can recover if the primary/YubiKey identities are unavailable. Filenames
  # relative to keysDir.
  recoveryPubkeys ? [ ],

  # Sub-path (relative to secretsRoot) for per-host rekeyed ciphertext.
  # `<name>` is appended. Kept in-repo by default.
  perHostSubdir ? "/secrets/per-host",

  # Path to the directory where generated secrets are written.
  generatedSecretsDir ? (secretsRoot + "/secrets/generated"),
}:

# Per-host application. `name` is the host name; `pubkey` is the host's own
# age public key, or `null` for a host that cannot decrypt yet (installer /
# bootstrap image) — in which case hostPubkey is omitted entirely.
name: pubkey: {
  age.rekey =
    (
      if pubkey == null then
        { }
      else
        {
          # The host's own public key. agenix-rekey accepts an age pubkey
          # ("age1..."), an ssh pubkey ("ssh-ed25519 AAAA..."), or a Nix path
          # to a file containing one. Pass whichever form you keep for the host.
          hostPubkey = pubkey;
        }
    )
    // {
      # Keep rekeyed ciphertexts in the repo, not out of tree.
      storageMode = "local";
      localStorageDir = secretsRoot + (perHostSubdir + "/${name}");
      inherit generatedSecretsDir;

      masterIdentities =
        (map (f: keysDir + "/${f}") yubikeyIdentities)
        ++ (
          if backupIdentity == null then
            [ ]
          else
            [
              {
                identity = keysDir + "/${backupIdentity.identity}";
                # Inlined pubkey: lets agenix select this as a recipient
                # without first decrypting the (encrypted) identity file.
                pubkey = backupIdentity.pubkey;
              }
            ]
        );

      # Every secret is ALSO encrypted to these — recovery recipients.
      extraEncryptionPubkeys = map (f: keysDir + "/${f}") recoveryPubkeys;
    };
}