Skip to content

agenix-rules-autogen

Packages

Stop hand-maintaining agenix's recipient list. Derive secrets/rules.nix by scanning your flake, and gate drift with a CI freshness check.

Problem

agenix keeps a secrets/rules.nix (a.k.a. secrets.nix) that maps each canonical secrets/*.age file to the set of recipients it is encrypted to. agenix -e <file> reads it to know who may decrypt and re-encrypt a secret when you edit its plaintext.

Maintained by hand, this file rots the moment your fleet has more than a handful of secrets:

  • you add a secrets/foo.age, forget to list it, and agenix -e foo refuses to edit it;
  • you reference age.secrets.bar.rekeyFile = ./secrets/bar.age; from a host but the ciphertext (and its rules entry) does not exist yet;
  • you rotate a master key and now have to retype it on every single line.

The recipient set is already stated declaratively elsewhere in your config (age.rekey.masterIdentities), and the path set is discoverable from the filesystem plus the host configs. So don't type it twice — generate it.

Key insight

rules.nix is a projection of information you already have. This recipe computes it from two sources and unions them:

  1. On-disk truth — every *.age file physically present in your secrets directory (builtins.readDir).
  2. Config truth — every age.secrets.<name>.rekeyFile referenced by any host in self.nixosConfigurations / self.darwinConfigurations.

The union is what makes the workflow ergonomic: a host reference alone adds a secret to the list, before its ciphertext exists. That is deliberate — it lets a later "generate" or "encrypt" step create the file for a secret that is already declared and already scoped to the right recipients.

The recipients are derived too, not typed: the generator reads the first host's age.rekey.masterIdentities (taking each key's # Recipient: comment line, else the first non-comment line that is itself a valid age1… / ssh-* public recipient) plus age.rekey.extraEncryptionPubkeys. Every canonical secret is pinned to this same fleet-wide master set — so losing one master key (e.g. a hardware token) costs convenience, not access: another master decrypts and you rekey onto a replacement.

⚠️ Never point masterIdentities at a plaintext private key. Each entry is readFiled at eval time and the extracted string is written verbatim into the world-readable /nix/store copy of rules.nix (and into any binary cache the closure is copied to). A bare path MUST be a public recipients/.pub file. If your master is a software age identity (a file that contains AGE-SECRET-KEY-…), do not pass its path — pass the { identity = <path>; pubkey = "age1…"; } attrset form (the pubkey is trusted as-is and the private file is never read), or point at the matching .pub recipients file. The generator fails closed: if an identity file contains AGE-SECRET-KEY-… or AGE-PLUGIN-… it throws instead of leaking the key. YubiKey .pub files (with a # Recipient: line) are fine.

The trap it closes

Even a generator is worthless if people keep editing the output by hand and it silently diverges from reality. So don't just generate — enforce. Add a CI check that regenerates the file and diffs it against the committed copy, failing the build on any difference with a message telling the contributor which command to run. Hand edits then survive only until the next check.

Hard dependency

This reads config.age.rekey.*, an option tree provided by agenix-rekey, not stock agenix. You must import its overlay into the pkgs that build your host configurations so that age.rekey.masterIdentities and age.rekey.extraEncryptionPubkeys exist. This is a dependency, not a coupling you can strip — the whole "derive the recipients" idea comes from agenix-rekey's model.

Usage

default.nix is a callPackage-style function returning a writeText derivation whose content is your rules.nix.

# in flake.nix, in your per-system outputs
let
  agenix-rules-nix = pkgs.callPackage ./packages/agenix-rules-autogen {
    inherit self system;
    secretsDir = ./secrets;
    # optional overrides shown with their defaults:
    # pathPrefix       = "secrets/";
    # excludedSubtrees = [ "secrets/generated/" "secrets/per-host/" ];
  };
in { ... }

A one-command refresher for your devShell:

agenix-auto-scan = pkgs.writeShellApplication {
  name = "agenix-auto-scan";
  text = ''
    set -euo pipefail
    root=''${PRJ_ROOT:-$(git rev-parse --show-toplevel)}
    install -m 644 ${agenix-rules-nix} "$root/secrets/rules.nix"
    echo "wrote $root/secrets/rules.nix"
  '';
};

And the drift-gating check (a flake checks.<system> entry):

checks.${system}.agenix-rules-fresh =
  pkgs.runCommand "agenix-rules-fresh"
    { expected = agenix-rules-nix; cached = ./secrets/rules.nix; }
    ''
      if ! ${pkgs.diffutils}/bin/diff -u "$cached" "$expected"; then
        echo ""
        echo "secrets/rules.nix is stale — run 'agenix-auto-scan'."
        exit 1
      fi
      touch $out
    '';

Options

Argument Default Meaning
self The flake self; source of nixosConfigurations / darwinConfigurations.
system System double being evaluated; darwin hosts not matching it are skipped so identity files aren't cross-read.
secretsDir Path to the directory holding the canonical *.age files.
pathPrefix "secrets/" Prefix each emitted entry carries and that rekeyFile paths are matched against.
excludedSubtrees [ "secrets/generated/" "secrets/per-host/" ] rekeyFile references under these prefixes are dropped — they are agenix-rekey-managed derived trees (per-host re-encrypted copies, generator scratch), not source secrets. Set to match your own layout.

Caveats

  • The excluded subtrees are your convention, not a universal one. The defaults reflect a common agenix-rekey layout where per-host re-encrypted copies live under secrets/per-host/ and generated material under secrets/generated/. If a rekeyFile in one of those trees leaks into the master rules list you will pin derived ciphertext to master keys — override excludedSubtrees to whatever your tree uses.
  • All secrets get the same recipient set by design. This recipe is for the source side (the masters that open canonical secrets). Per-host recipient scoping is a separate concern handled by agenix-rekey.
  • rekeyFile paths are normalised by regex back to <pathPrefix>….age. Keep your secrets under a single, consistently-named directory or the match will drop references it cannot normalise.
  • Reading masterIdentities from the first host assumes the master set is uniform across hosts. That is the intended model here; if your hosts genuinely differ, this generator is the wrong tool.

Source

packages/agenix-rules-autogen/default.nix
# agenix-rules-autogen
#
# Generate agenix's recipient list (the classic `secrets/rules.nix`) instead of
# hand-maintaining it. The generated file maps every canonical `secrets/*.age`
# to the set of "master" recipients allowed to decrypt and re-encrypt it when
# editing plaintext with `agenix -e` / an `agenix-edit` wrapper.
#
# The path list is the UNION of:
#   1. every `*.age` file physically present in the secrets directory, and
#   2. every `age.secrets.<name>.rekeyFile` referenced by ANY host config,
# minus a configurable set of excluded subtrees (see `excludedSubtrees`).
# The recipient list is DERIVED from `age.rekey.masterIdentities` +
# `age.rekey.extraEncryptionPubkeys` of the first host, so it is never typed by
# hand either.
#
# Pair this with a CI freshness check (regenerate-and-diff) so drift fails the
# build — an example check is shown at the bottom of the README.
#
# HARD DEPENDENCY: this reads `config.age.rekey.*`, which is provided by the
# agenix-rekey NixOS/nix-darwin module + overlay
# (https://github.com/oddlama/agenix-rekey). Import its overlay into the
# `pkgs` that instantiate your host configurations, or the `age.rekey` option
# tree will not exist and evaluation will fail.
#
# callPackage-style. Typical wiring:
#
#   agenix-rules-nix = pkgs.callPackage ./agenix-rules-autogen {
#     inherit self system;
#     secretsDir = ./secrets;
#     # excludedSubtrees = [ "secrets/generated/" "secrets/per-host/" ];
#   };
#
# Then install the built file into your tree:
#
#   install -m 644 ${agenix-rules-nix} "$root/secrets/rules.nix"

{
  lib,
  writeText,

  # The flake `self`, so we can read `self.nixosConfigurations` /
  # `self.darwinConfigurations`.
  self,

  # The system double being evaluated (e.g. "x86_64-linux"). Used to keep only
  # the darwin hosts that match, so a linux evaluation does not try to read
  # darwin-only identity files and vice versa.
  system,

  # Path to the directory that holds the canonical `*.age` ciphertext files
  # (typically `./secrets`).
  secretsDir,

  # Path prefix each emitted entry carries, and the prefix `rekeyFile`
  # references are matched against. Almost always "secrets/".
  pathPrefix ? "secrets/",

  # rekeyFile references whose path starts with any of these prefixes are
  # dropped from the union. These are the agenix-rekey-managed derived trees:
  # the per-host re-encrypted copies and any generator scratch output. They are
  # NOT source secrets and must not appear in the master recipient list. Adjust
  # to match your own directory conventions.
  excludedSubtrees ? [
    "${pathPrefix}generated/"
    "${pathPrefix}per-host/"
  ],
}:

let
  # ---- Collect every host config across both NixOS and nix-darwin ----------
  nixosHosts = builtins.attrValues (self.nixosConfigurations or { });
  darwinHosts = builtins.filter (h: h.config.nixpkgs.hostPlatform.system == system) (
    builtins.attrValues (self.darwinConfigurations or { })
  );
  allHosts = nixosHosts ++ darwinHosts;

  # The recipient set is fleet-wide: every canonical secret is pinned to the
  # same masters, so reading it from the first host is sufficient. (Per-host
  # recipient scoping is agenix-rekey's job, in `secrets/per-host/`, not here.)
  cfg0 = (builtins.head allHosts).config.age.rekey;

  # ---- Turn a master identity into an age recipient pubkey -----------------
  # An identity may be given as a bare path, or as { identity; pubkey; }. If a
  # pubkey is supplied we trust it; otherwise we parse the referenced file:
  #   * prefer a `# Recipient: <pubkey>` comment line (age-plugin-yubikey style
  #     `.pub` files carry this), else
  #   * fall back to the first non-comment line that is itself a valid RECIPIENT
  #     (an `age1…` / `age1yubikey1…` recipient or an `ssh-ed25519`/`ssh-rsa`
  #     public key), stripping a trailing CR for CRLF safety.
  #
  # SECURITY: a bare `masterIdentities` path is `readFile`d at eval time and the
  # extracted string is written verbatim into the world-readable `/nix/store`
  # `rules.nix`. It MUST therefore be a PUBLIC recipients/`.pub` file, never a
  # plaintext age private identity. We fail closed on any private-key material
  # (`AGE-SECRET-KEY-…`, `AGE-PLUGIN-…`) rather than silently baking your fleet's
  # master decryption key into the store. If your identity file is a private
  # key, supply the `{ identity; pubkey = "age1…"; }` attrset form (honored
  # above) or point at the matching `.pub` recipients file instead.
  isRecipientLine = l: (builtins.match "(age1[0-9a-z]+|ssh-(ed25519|rsa) [^[:space:]]+.*)" l) != null;
  containsPrivateKey =
    text:
    (builtins.match ".*(AGE-SECRET-KEY-|AGE-PLUGIN-).*" (builtins.replaceStrings [ "\n" ] [ " " ] text))
    != null;
  idPubkey =
    idIn:
    let
      rec' =
        if builtins.isAttrs idIn then
          idIn
        else
          {
            identity = idIn;
            pubkey = null;
          };
    in
    if rec'.pubkey != null then
      rec'.pubkey
    else
      let
        text = builtins.readFile rec'.identity;
        lines = lib.splitString "\n" text;
        recipientLine = lib.findFirst (
          l: (builtins.match "#[[:space:]]*[Rr]ecipient:.*" l) != null
        ) null lines;
      in
      if containsPrivateKey text then
        throw ''
          agenix-rules-autogen: identity file ${toString rec'.identity} contains
          private key material (AGE-SECRET-KEY-… / AGE-PLUGIN-…). Reading it at
          eval time would leak the private key into the world-readable Nix store
          via the generated rules.nix. Supply the public recipient instead —
          either the `{ identity = <path>; pubkey = "age1…"; }` attrset form, or
          a path to the matching `.pub` / recipients file.''
      else if recipientLine != null then
        lib.removeSuffix "\r" (
          builtins.head (
            builtins.match "#[[:space:]]*[Rr]ecipient:[[:space:]]*([^[:space:]]+).*" recipientLine
          )
        )
      else
        let
          nonComment = builtins.filter (l: l != "" && !(lib.hasPrefix "#" l)) lines;
          recipients = builtins.filter (l: isRecipientLine (lib.removeSuffix "\r" l)) nonComment;
        in
        if recipients != [ ] then
          lib.removeSuffix "\r" (builtins.head recipients)
        else
          throw ''
            agenix-rules-autogen: cannot parse a public recipient from
            ${toString rec'.identity}. Expected a `# Recipient: age1…` comment
            line or a line containing an `age1…` / `ssh-ed25519` / `ssh-rsa`
            public recipient. If this is a private identity file, supply the
            `{ identity; pubkey = "age1…"; }` attrset form or a `.pub` file.'';

  fromFile = p: lib.removeSuffix "\n" (builtins.readFile p);

  masterPubkeys = (map idPubkey cfg0.masterIdentities) ++ (map fromFile cfg0.extraEncryptionPubkeys);

  # ---- Half 1 of the union: every *.age file physically on disk ------------
  fsSecrets = map (n: pathPrefix + n) (
    builtins.filter (n: lib.hasSuffix ".age" n) (builtins.attrNames (builtins.readDir secretsDir))
  );

  # ---- Half 2 of the union: every rekeyFile any host references ------------
  # A `rekeyFile` is an absolute store-ish path; normalise it back to a
  # tree-relative `<pathPrefix>....age`. This is what lets a secret appear in
  # rules.nix BEFORE its ciphertext exists: a host reference alone adds it, and
  # a later generate/encrypt step can then create the file.
  extractPath =
    rf:
    let
      s = toString rf;
      m = builtins.match ".*/(${pathPrefix}.*\\.age)" s;
    in
    if m != null then builtins.head m else null;

  configSecretRefs = lib.unique (
    lib.concatMap (
      h:
      builtins.filter (p: p != null) (
        map (s: extractPath s.rekeyFile) (builtins.attrValues (h.config.age.secrets or { }))
      )
    ) allHosts
  );

  isExcluded = p: lib.any (pre: lib.hasPrefix pre p) excludedSubtrees;

  configSecrets = builtins.filter (p: lib.hasPrefix pathPrefix p && !(isExcluded p)) configSecretRefs;

  allSecrets = lib.sort (a: b: a < b) (lib.unique (fsSecrets ++ configSecrets));

  # ---- Render the agenix rules.nix ----------------------------------------
  renderList = xs: lib.concatMapStrings (x: "    \"${x}\"\n") xs;

  content = ''
    let
      masterPubkeys = [
    ${renderList masterPubkeys}  ];
      paths = [
    ${renderList allSecrets}  ];
      mkSecret = p: { name = p; value = { publicKeys = masterPubkeys; }; };
    in builtins.listToAttrs (map mkSecret paths)
  '';
in
writeText "rules.nix" content