agenix-encrypt-to-master¶
Packages
Import an existing plaintext secret into an agenix-rekey tree by encrypting it to your fleet's master recipients — the counterpart to the generators that synthesise a secret from scratch.
The problem¶
agenix-rekey encrypts every secret to a small set of master identities (the
keys the operator holds), then rekeys each secret onto the per-host age keys at
build time. To add a secret you already have in plaintext (an API token, a
downloaded credential, a key you didn't generate yourself), you need to encrypt
it to exactly that master recipient set and drop the .age file in the tree.
The obvious way to learn the master recipients is to evaluate the flake and read
age.rekey.masterIdentities (or whatever your rules module derives from it).
That's slow, and — the real trap — it can't run before any host has a usable
evaluation. During bootstrap, or on a checkout whose configs don't yet
evaluate, that path is dead.
The insight¶
Don't evaluate. Scrape.
agenix-rekey setups typically render a generated rules file that already contains the resolved master recipients as plain string literals:
# secrets/rules.nix (generated)
{
masterPubkeys = [
"age1qqq…"
"age1yubikey1…"
];
# … per-secret rules …
}
Those are just quoted strings on their own lines. A two-line sed pulls them
out with no Nix evaluation at all:
sed -n '/masterPubkeys *= *\[/,/^[[:space:]]*\]/p' "$rules" \
| sed -n 's/^[[:space:]]*"\(.*\)"[[:space:]]*$/\1/p'
So encryption is fast and works the instant the rules file exists — before, and independently of, any host evaluating.
The rules-file contract¶
The scrape assumes the block is laid out one quoted recipient per line:
If your generator emits recipients some other way (all on one line, a different
attribute name, computed at eval time), either adjust the sed in
default.nix or set the rulesPath option to point at a file that does match
this shape. The name masterPubkeys is what the scrape greps for; rename it in
both places if yours differs.
Usage¶
Wire it in with callPackage:
Then:
# from a file
agenix-encrypt-to-master my-secret ./plaintext.txt
# from stdin
printf %s "$TOKEN" | agenix-encrypt-to-master my-secret
It writes secrets/my-secret.age, encrypted to every master recipient. Re-run
your normal rekey step afterwards to fan the secret out onto the host keys.
Options¶
| Option | Default | Purpose |
|---|---|---|
rulesPath |
"secrets/rules.nix" |
Repo-relative path to the rules file with the masterPubkeys block. |
secretsDir |
"secrets" |
Repo-relative directory where <name>.age is written. |
extraRuntimeInputs |
[ ] |
Extra tools on PATH. See "YubiKey master keys" below. |
Runtime environment overrides¶
PRJ_ROOT— repo root; falls back togit rev-parse --show-toplevel.RULES_FILE— absolute path to the rules file, overridingPRJ_ROOT/rulesPath(lets you run outside a git checkout).SECRETS_DIR— absolute path to the output directory, overridingPRJ_ROOT/secretsDir.
YubiKey (or other plugin) master keys¶
Encrypting to a plugin-format recipient such as age1yubikey1… needs the
matching age plugin binary on PATH even though no touch is required to
encrypt. If any master recipient is plugin-format, pass it in:
agenix-encrypt-to-master = pkgs.callPackage ./agenix-encrypt-to-master {
extraRuntimeInputs = [ pkgs.age-plugin-yubikey ];
};
Plain age1… recipients need nothing extra, which is why the default is empty.
Caveats¶
- Flat names only. The secret name must have no slashes and no leading dot; the tool rejects anything else so you can't accidentally escape the secrets directory.
- A new name isn't wired up yet. If the name isn't already present in the rules file, the tool warns: the secret exists and is encrypted correctly, but your rekey step won't fan it onto hosts until the rules are regenerated to include it.
- The rules file must already exist. This tool only encrypts; it doesn't
generate the rules. Generate/refresh them first (or point
RULES_FILEat an existing one). .agefiles are re-encryptable, not append-only. Running again with the same name overwrites the existing ciphertext.
Source¶
packages/agenix-encrypt-to-master/default.nix
# agenix-encrypt-to-master
#
# Import an *existing* plaintext secret into an agenix-rekey tree by encrypting
# it to your fleet's master recipients — without evaluating the flake.
#
# The recipients (`masterPubkeys`) are scraped out of the generated rules file
# with `sed`, not read via a Nix evaluation. That makes encryption fast and,
# crucially, lets it work *before any host evaluates* — e.g. while you are still
# bootstrapping and no machine config has been built yet.
#
# Usage (from a `callPackage`):
#
# agenix-encrypt-to-master = pkgs.callPackage ./agenix-encrypt-to-master { };
#
# then:
#
# agenix-encrypt-to-master my-secret ./plaintext # from a file
# printf %s "$TOKEN" | agenix-encrypt-to-master my-secret # from stdin
#
# See README.md for the rules-file contract and caveats.
{
writeShellApplication,
age,
gnused,
# Extra tools placed on the script's PATH. Encrypting *to* a plugin-format
# recipient (e.g. an `age1yubikey1…` master key) requires the matching age
# plugin binary here — for a YubiKey master identity set
# `extraRuntimeInputs = [ age-plugin-yubikey ];`. Plain `age1…` recipients
# (a passphrase- or ssh-derived master key) need nothing extra, so this
# defaults to empty.
extraRuntimeInputs ? [ ],
# Relative path (from the repo root) to the agenix-rekey rules file holding
# the `masterPubkeys = [ "age1…" … ];` block. Overridable at runtime with the
# RULES_FILE environment variable (absolute path).
rulesPath ? "secrets/rules.nix",
# Relative path (from the repo root) to the directory where `<name>.age`
# files are written. Overridable at runtime with the SECRETS_DIR environment
# variable (absolute path).
secretsDir ? "secrets",
}:
writeShellApplication {
name = "agenix-encrypt-to-master";
runtimeInputs = [
age
gnused
]
++ extraRuntimeInputs;
text = ''
set -euo pipefail
# --- locate the rules file and the output directory -------------------
# Repo root: honour a caller-set PRJ_ROOT (e.g. from a devshell), else ask
# git. Both the rules file and the secrets dir can be pinned outright with
# env vars, which also makes this usable outside a git checkout.
root=''${PRJ_ROOT:-$(git rev-parse --show-toplevel)}
rules=''${RULES_FILE:-$root/${rulesPath}}
secrets_dir=''${SECRETS_DIR:-$root/${secretsDir}}
if [ ! -f "$rules" ]; then
echo "missing rules file: $rules" >&2
echo " point RULES_FILE at your agenix-rekey rules, or generate it first" >&2
exit 1
fi
# --- argument handling ------------------------------------------------
if [ "$#" -lt 1 ] || [ "$1" = "-h" ] || [ "$1" = "--help" ]; then
cat >&2 <<'EOF'
usage: agenix-encrypt-to-master <name> [plaintext-file]
<name> secret name (with or without .age suffix)
plaintext-file optional — if omitted, plaintext is read from stdin
Encrypts an existing plaintext to the masterPubkeys scraped from the rules
file. Use when you have a secret you want to import as-is rather than
(re)generate. Re-run your rekey step afterwards to fan it out to hosts.
EOF
exit 2
fi
name=''${1%.age}
case "$name" in
*/* | .* | "")
echo "invalid name '$name' — must be a flat secret name (no slashes, no leading dot)" >&2
exit 2
;;
esac
out=$secrets_dir/$name.age
# --- scrape master recipients WITHOUT evaluating the flake ------------
# Grab the lines between `masterPubkeys = [` and the closing `]`, then pull
# the quoted string out of each. This is deliberately a text scrape: it is
# fast and works before any host config has been evaluated. The trade-off
# is that it assumes the block is laid out one quoted recipient per line:
#
# masterPubkeys = [
# "age1…"
# "age1yubikey1…"
# ];
mapfile -t pubkeys < <(
sed -n '/masterPubkeys *= *\[/,/^[[:space:]]*\]/p' "$rules" \
| sed -n 's/^[[:space:]]*"\(.*\)"[[:space:]]*$/\1/p'
)
if [ "''${#pubkeys[@]}" -eq 0 ]; then
echo "no masterPubkeys found in $rules" >&2
echo " expected a 'masterPubkeys = [ \"age1…\" … ];' block, one recipient per line" >&2
exit 1
fi
recip=()
for k in "''${pubkeys[@]}"; do recip+=(-r "$k"); done
mkdir -p "$(dirname "$out")"
if [ -n "''${2-}" ]; then
age -e "''${recip[@]}" -o "$out" "$2"
else
age -e "''${recip[@]}" -o "$out"
fi
echo "wrote $out (''${#pubkeys[@]} recipients)" >&2
# A name the rules file doesn't yet list won't be rekeyed onto hosts until
# the rules are regenerated — warn so it isn't silently dropped.
if ! grep -qF "$name.age" "$rules"; then
echo "note: $name is not yet in $rules — regenerate the rules to pick it up" >&2
fi
'';
}