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, andagenix -e foorefuses 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:
- On-disk truth — every
*.agefile physically present in your secrets directory (builtins.readDir). - Config truth — every
age.secrets.<name>.rekeyFilereferenced by any host inself.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
masterIdentitiesat a plaintext private key. Each entry isreadFiled at eval time and the extracted string is written verbatim into the world-readable/nix/storecopy ofrules.nix(and into any binary cache the closure is copied to). A bare path MUST be a public recipients/.pubfile. If your master is a software age identity (a file that containsAGE-SECRET-KEY-…), do not pass its path — pass the{ identity = <path>; pubkey = "age1…"; }attrset form (thepubkeyis trusted as-is and the private file is never read), or point at the matching.pubrecipients file. The generator fails closed: if an identity file containsAGE-SECRET-KEY-…orAGE-PLUGIN-…it throws instead of leaking the key. YubiKey.pubfiles (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 undersecrets/generated/. If arekeyFilein one of those trees leaks into the master rules list you will pin derived ciphertext to master keys — overrideexcludedSubtreesto 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.
rekeyFilepaths 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
masterIdentitiesfrom 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