agenix-rekey-yubikey-pin-prime¶
Packages
A tiny wrapper around agenix-rekey
that stops agenix rekey / agenix generate from hanging forever when your
age master identity lives on a YubiKey.
The problem¶
agenix-rekey re-encrypts secrets by streaming each .age file through a
decrypt | encrypt pipeline. The decrypt half runs rage (or age) with the
pipe on its stdin.
When the identity is an age-plugin-yubikey
key that requires a PIV PIN, rage prints:
…and then the retry prompt spins forever. rage wants to read the PIN from a
terminal, but its stdin is the pipe, so it immediately hits EOF and loops. The
whole rekey never completes and there is no obvious error — it just sits
there.
The insight / trap¶
The YubiKey PIV PIN is cached per card session. Once you have entered it in one successful operation, subsequent operations against the same inserted card reuse the cached PIN without prompting.
So the fix is: before handing control to agenix, perform one direct,
interactive decrypt of any .age file (a "canary"). That decrypt runs with a
real TTY, so rage can prompt for the PIN (and touch, if your key is configured
touch-required). That primes the session cache. Every decrypt inside agenix's
pipeline then sails through silently.
That is the entire trick — one throwaway rage -d ... -o /dev/null <canary>
guarded on [ -t 0 ] (only when stdin is a terminal), then exec agenix ….
Usage¶
callPackage it, pointing identityFile at your own age-plugin-yubikey
identity file (the AGE-PLUGIN-YUBIKEY-… stanza — safe to commit, since the
private key never leaves the card; a plain age1… recipient will not decrypt):
let
agenix-yk = pkgs.callPackage ./default.nix {
inherit (inputs.agenix-rekey.packages.${system}) agenix-rekey;
identityFile = "keys/age-yubikey-identity.txt";
};
in
# add to a devshell, home.packages, environment.systemPackages, …
[ agenix-yk ]
Run it from the root of your secrets working tree, with the YubiKey inserted:
$ agenix-yk-rekey # enter PIN once, then agenix rekey -a runs clean
$ agenix-yk-rekey --dry-run # any extra args are forwarded to agenix verbatim
Options¶
| Argument | Default | Purpose |
|---|---|---|
identityFile |
(required) | age-plugin-yubikey identity file (relative to the working tree) used for the priming decrypt. Must be an identity (rage -d -i), not a plain age1… recipient. |
subcommand |
"rekey" |
agenix-rekey subcommand to wrap; "rekey" or "generate". |
secretsDir |
"secrets" |
Directory holding the .age canary files. |
extraArgs |
["-a"] for rekey, [] for generate |
Args appended after the subcommand, before your own $@. |
name |
"agenix-yk-<subcommand>" |
Binary name. |
Want both a rekey and a generate wrapper? callPackage it twice with different
subcommand values.
Caveats¶
- Interactive only. Priming is guarded by
[ -t 0 ]; with no TTY (CI, non-interactive shells) it is skipped and agenix runs unprimed. If agenix then needs the YubiKey it will fail — as it would anyway, since nobody can type the PIN. Do not run this from unattended automation. - Needs at least one canary. If
secretsDircontains no*.agefile, there is nothing to prime from and the step is silently skipped. On a brand-new repo,generatemay create the first secrets itself and prompt normally. - Session-scoped cache. Pull the YubiKey (or start a new card session) and you will be prompted again on the next run — which is the point.
- The priming decrypt writes plaintext to
/dev/null; the canary's contents are never exposed.
See also¶
- agenix-rekey-yubikey-pin-priming — the full-featured variant: ships three CLIs (
agenix-generate,agenix-rekey, andagenix-encrypt) from onecallPackageand can encrypt secrets directly from a parsedrules.nix. - 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-prime/default.nix
# agenix-rekey-yubikey-pin-prime
#
# A thin wrapper around `agenix rekey` / `agenix generate` (from agenix-rekey)
# that first primes the YubiKey PIV PIN cache with ONE direct interactive
# decrypt of a canary `.age` file.
#
# Why: inside agenix-rekey's `decrypt | encrypt` pipeline, rage's stdin is the
# pipe, not your terminal. When the age-plugin-yubikey identity needs a PIN,
# rage prints "A PIN is required..." and then spins forever on EOF because it
# has no TTY to read the PIN from. The PIN is cached per YubiKey card session,
# so a single *interactive* decrypt (with a real TTY) fills that cache; every
# subsequent decrypt in the pipeline reuses it and never prompts.
#
# Usage (callPackage):
#
# agenix-rekey-yubikey-pin-prime = pkgs.callPackage ./default.nix {
# inherit (agenix-rekey.packages.${system}) agenix-rekey;
# # required: the age-plugin-yubikey *identity* file used to decrypt.
# identityFile = "keys/age-yubikey-identity.txt";
# # optional overrides:
# # subcommand = "rekey"; # or "generate"
# # secretsDir = "secrets"; # where the canary *.age lives
# # name = "agenix-yk"; # binary name (default: agenix-yk-<subcommand>)
# };
#
# Run it from the root of your secrets working tree, with the YubiKey inserted:
#
# agenix-yk-rekey # -> agenix rekey -a
# agenix-yk-rekey --dry-run # extra args are forwarded verbatim
#
{
lib,
writeShellApplication,
agenix-rekey,
age-plugin-yubikey,
rage,
# The age-plugin-yubikey *identity* file (relative to the working tree) used
# for the priming decrypt — the `AGE-PLUGIN-YUBIKEY-…` stanza that points at
# the card slot. It is safe to keep in the repo since the private key never
# leaves the YubiKey; a plain `age1…` recipient will NOT work here (`rage -d
# -i` needs an identity). No fleet-specific default — point it at your key.
identityFile,
# Which agenix-rekey subcommand to wrap. "rekey" re-encrypts every secret to
# the current recipient set; "generate" creates missing generated secrets.
subcommand ? "rekey",
# Directory (relative to the working tree) holding the *.age canary files.
secretsDir ? "secrets",
# Extra args appended after the subcommand (before your own "$@").
# For "rekey", `-a` rekeys all hosts.
extraArgs ? (if subcommand == "rekey" then [ "-a" ] else [ ]),
name ? "agenix-yk-${subcommand}",
}:
assert lib.assertOneOf "subcommand" subcommand [ "rekey" "generate" ];
writeShellApplication {
inherit name;
runtimeInputs = [
agenix-rekey
age-plugin-yubikey
rage
];
text = ''
# --- YubiKey PIV PIN priming ---------------------------------------------
# Inside agenix-rekey's decrypt|encrypt pipeline rage cannot prompt for the
# YubiKey PIV PIN: it prints "A PIN is required..." and then the retry
# prompt spins forever on EOF (no TTY on the pipe). The PIN is cached per
# card session, so prime it here with one direct interactive decrypt of any
# secret before handing control to agenix.
#
# Guarded by `[ -t 0 ]`: only prime when stdin is a real terminal. In CI or
# non-interactive shells there is nothing to type a PIN into anyway, so we
# skip straight to agenix (which will fail loudly if it needs the key).
canary=$(find ${lib.escapeShellArg secretsDir} -maxdepth 1 -name '*.age' -print -quit 2>/dev/null || true)
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; ${subcommand} may not be able to decrypt" >&2
fi
exec ${agenix-rekey}/bin/agenix ${subcommand} ${lib.escapeShellArgs extraArgs} "$@"
'';
}