Prime the YubiKey PIN Cache Before Running agenix-rekey¶
Packages
Wrappers that let you drive agenix-rekey
with a YubiKey master identity (age-plugin-yubikey) without the decrypt
step hanging forever.
The problem¶
agenix / agenix-rekey do their crypto as an internal decrypt | encrypt
pipeline. When your master age identity lives on a YubiKey, rage/age has to
ask the card for its PIV PIN before it can decrypt. But inside that pipeline
there is no usable TTY, so instead of prompting you get:
…followed by a retry prompt that immediately hits EOF and spins forever.
agenix rekey and agenix generate never finish, and there is no obvious error
to grep for.
The insight¶
age-plugin-yubikey caches the PIN for the duration of a card session. So
the fix is not to make the pipeline interactive — it's to satisfy the PIN prompt
once, up front, in a real terminal, and let the cache carry it through the
non-interactive pipeline that follows.
Each wrapper runs a tiny priming preamble before handing off to agenix:
- Find any one committed
*.agefile to use as a throwaway "canary". - Only if stdin is a TTY (
[ -t 0 ]) — so automation is never blocked — run a single directrage -d -i <identity> -o /dev/null <canary>. - That interactive decrypt prompts for the PIN (and a touch, if your slot requires one), populating the card-session cache.
execthe realagenix generate/agenix rekey -a, which now decrypts straight from cache.
A failed priming is a warning, not a hard error: the real command still runs and surfaces the true failure, rather than being masked by the wrapper.
What you get¶
Three CLIs from one callPackage:
| command | does |
|---|---|
agenix-generate |
prime, then agenix generate (declarative generators) |
agenix-rekey |
prime, then agenix rekey -a (fan out to per-host keys) |
agenix-encrypt |
encrypt an existing payload to the master pubkeys, no regen |
agenix-encrypt is the odd one out — it does not run the agenix pipeline at
all. It scrapes the masterPubkeys = [ ... ] list straight out of your
generated rules.nix with sed (no flake evaluation, so it's fast and works
before any host has been evaluated) and calls age -e -r … -o secrets/<name>.age.
Use it when you already have a secret value and just want it encrypted to the
same recipient set, without going through a generator.
Usage¶
let
agenix-yubikey = pkgs.callPackage ./agenix-rekey-yubikey-pin-priming {
inherit (inputs.agenix-rekey.packages.${system}) agenix-rekey;
# REQUIRED: path (relative to your repo root) to the age-plugin-yubikey
# identity/recipient file the priming decrypt should use.
identityFile = "keys/age-yubikey-identity.pub";
# Optional — these are the defaults:
# secretsDir = "secrets";
# rulesFile = "secrets/rules.nix";
# rulesHint = "regenerate it with your agenix-rekey rules generator";
};
in
{
# e.g. expose them in a devShell
devShells.default = pkgs.mkShell {
packages = with agenix-yubikey; [ agenix-generate agenix-rekey agenix-encrypt ];
};
}
agenix-rekey, age, age-plugin-yubikey, rage, and gnused come from the
callPackage scope; only agenix-rekey (the package that ships the rekey
agenix binary) usually needs to be threaded in explicitly, as above.
Options¶
| argument | default | meaning |
|---|---|---|
identityFile |
required | age identity used for the priming decrypt (relative to repo root) |
secretsDir |
"secrets" |
directory of flat *.age secrets |
rulesFile |
"${secretsDir}/rules.nix" |
rules.nix carrying masterPubkeys = [ … ] (used by agenix-encrypt) |
rulesHint |
generic message | printed when rules.nix is missing — point it at your regen command |
Path resolution differs by wrapper. agenix-encrypt resolves the repo root from
$PRJ_ROOT, falling back to git rev-parse --show-toplevel, and joins
rulesFile/secretsDir onto it — so it works from any subdirectory. The
agenix-generate/agenix-rekey priming step, by contrast, uses secretsDir and
identityFile relative to the current working directory (it just finds a
canary and runs rage -i <identityFile>). Run those two from your repo root.
(A failed prime is only a warning, so a wrong CWD degrades to "no priming"
rather than a hard error.)
Caveats¶
- Interactive tool. These wrappers write into your working tree and need the YubiKey physically present. Run them by hand — don't call them from CI or unattended automation. (The TTY guard makes them safe to invoke there; they just won't prime, and the underlying command will fail loudly if it needs the card.)
- Flat
secrets/*.agelayout. The canary probe andagenix-encrypt's output path assume secrets live directly undersecretsDir. Nested layouts need the paths adjusted. agenix-encryptparses rules.nix textually. It expects the conventionalmasterPubkeys = [ "…" "…" ]block that agenix-rekey rules generators emit, one quoted key per line. If your rules.nix formats that list differently, adjust thesedextraction.- One canary is enough. Any decryptable secret primes the session; the
wrapper decrypts to
/dev/nullpurely for the PIN-cache side effect.
See also¶
- agenix-rekey-yubikey-pin-prime — a simpler single-subcommand wrapper (one binary per
callPackagecall) if you don't need the bundledagenix-encrypthelper. - agenix-yubikey-pin-prime — the lowest-level factory: accepts the agenix subcommand as a string or list and exposes an
agenixBinoverride for non-standard binary paths.
Source¶
packages/agenix-rekey-yubikey-pin-priming/default.nix
# agenix-rekey + YubiKey (age-plugin-yubikey): PIN-priming wrappers
#
# The trap this solves:
# agenix / agenix-rekey run their crypto as an internal `decrypt | encrypt`
# pipeline. When your master identity lives on a YubiKey (age-plugin-yubikey),
# `rage`/`age` needs the PIV PIN to decrypt — but inside that pipeline it has
# no usable TTY, so it prints "A PIN is required..." and then spins forever on
# EOF instead of prompting. The PIN is cached *per card session*, so the fix
# is to prime that cache with ONE direct, interactive `rage -d` decrypt in a
# real terminal BEFORE handing off to `agenix generate` / `agenix rekey`.
#
# This file is a function you `callPackage` (or apply as an overlay) to get
# three drop-in CLIs:
# * agenix-generate — prime, then `agenix generate` (declarative generators)
# * agenix-rekey — prime, then `agenix rekey -a` (fan out to per-host keys)
# * agenix-encrypt — encrypt an existing payload to the master pubkeys
# without regenerating it
#
# Everything fleet-specific has been turned into an argument. The only value you
# MUST supply is `identityFile`: the path (relative to your repo root) to the
# age-plugin-yubikey identity/recipient file the priming decrypt should use.
{
lib,
writeShellApplication,
# agenix-rekey's `agenix` binary (the rekey-flavoured one).
agenix-rekey,
# ryantm/agenix's `age` + the YubiKey plugin + rage for the priming decrypt.
age,
age-plugin-yubikey,
rage,
gnused,
# --- configuration ------------------------------------------------------
# Path to the age identity used to decrypt during priming, relative to the
# repo root at runtime — e.g. "keys/age-yubikey-identity.pub". For
# age-plugin-yubikey this is the recipient/identity stub the plugin resolves
# to a live card session. REQUIRED.
identityFile,
# Directory (relative to repo root) holding your flat `*.age` secrets and the
# generated `rules.nix`. Defaults to agenix's conventional "secrets".
secretsDir ? "secrets",
# Location of the agenix-rekey rules.nix (relative to repo root) that carries
# the `masterPubkeys = [ ... ]` list agenix-encrypt encrypts to.
rulesFile ? "${secretsDir}/rules.nix",
# Human-readable hint printed when rules.nix is missing. Point it at whatever
# regenerates rules.nix in your setup (e.g. your scan/refresh command).
rulesHint ? "regenerate it with your agenix-rekey rules generator",
}:
let
# Shared preamble: prime the YubiKey PIV PIN cache with one interactive
# decrypt so the non-interactive agenix pipeline that follows can reuse it.
#
# * Picks any one committed `*.age` file as a throwaway "canary" to decrypt.
# * Only runs when stdin is a TTY (`[ -t 0 ]`) — never blocks automation.
# * Decrypts to /dev/null; we only care about the side effect of caching
# the PIN in the card session.
# * A failed priming is a warning, not a hard error: the real command still
# runs (and will surface the true failure) rather than being masked here.
primePreamble = ''
canary=$(find ${lib.escapeShellArg secretsDir} -maxdepth 1 -name '*.age' -print -quit 2>/dev/null)
if [ -t 0 ] && [ -n "$canary" ]; then
echo "Priming YubiKey PIN via $canary (enter PIN / touch if prompted)..." >&2
rage -d -i ${lib.escapeShellArg identityFile} -o /dev/null "$canary" \
|| echo "warning: PIN priming failed; the command below may not be able to decrypt" >&2
fi
'';
# `agenix-rekey` (the argument) is the package providing the `agenix` binary.
# We keep the local wrapper names distinct so they don't shadow it — `let`
# bindings are recursive in Nix, and a local `agenix-rekey` would make
# `${agenix-rekey}/bin/agenix` refer to itself (infinite recursion).
generateBin = writeShellApplication {
name = "agenix-generate";
runtimeInputs = [
agenix-rekey
age-plugin-yubikey
rage
];
text = ''
# Prime the YubiKey PIV PIN before agenix's decrypt|encrypt pipeline runs;
# rage cannot prompt for it once inside the pipeline. See default.nix.
${primePreamble}
exec ${agenix-rekey}/bin/agenix generate "$@"
'';
};
rekeyBin = writeShellApplication {
name = "agenix-rekey";
runtimeInputs = [
agenix-rekey
age-plugin-yubikey
rage
];
text = ''
# Inside agenix-rekey's decrypt|encrypt pipeline rage cannot prompt for
# the YubiKey PIV PIN ("A PIN is required..." then a retry prompt that
# spins forever on EOF). The PIN is cached per card session, so prime it
# with one direct interactive decrypt first. See default.nix.
${primePreamble}
exec ${agenix-rekey}/bin/agenix rekey -a "$@"
'';
};
# Encrypt an existing plaintext to the master pubkeys WITHOUT regenerating it.
# Scrapes `masterPubkeys` straight out of rules.nix with sed (no flake eval),
# so it's fast and works before any host has been evaluated.
encryptBin = writeShellApplication {
name = "agenix-encrypt";
runtimeInputs = [
age
age-plugin-yubikey
gnused
];
text = ''
set -euo pipefail
root=''${PRJ_ROOT:-$(git rev-parse --show-toplevel)}
rules="$root/${rulesFile}"
if [ ! -f "$rules" ]; then
echo "missing $rules — ${rulesHint}" >&2
exit 1
fi
if [ "$#" -lt 1 ] || [ "$1" = "-h" ] || [ "$1" = "--help" ]; then
cat >&2 <<'EOF'
usage: agenix-encrypt <name> [plaintext-file]
<name> secret name (with or without .age suffix)
plaintext-file optional — if omitted, plaintext is read from stdin
Encrypts to the masterPubkeys from rules.nix. Use when you have an
existing secret you don't want to (re)generate.
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="$root/${secretsDir}/$name.age"
# Pull every quoted string between `masterPubkeys = [` and its closing `]`.
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
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
if ! grep -qF "${secretsDir}/$name.age" "$rules"; then
echo "note: $name is new — refresh rules.nix to pick up the new recipient set" >&2
fi
'';
};
in
{
agenix-generate = generateBin;
agenix-rekey = rekeyBin;
agenix-encrypt = encryptBin;
}