agenix-yubikey-pin-prime¶
Packages
Wrap an agenix subcommand (generate, rekey, ...) so it runs unattended
even when your master identity lives on a YubiKey and requires a PIV PIN.
The problem¶
When your agenix master identity is a YubiKey (via age-plugin-yubikey),
decrypting a secret requires the card's PIV PIN. Agenix runs its work inside a
non-interactive decrypt | encrypt pipeline. Inside that pipeline rage
cannot prompt for the PIN: it prints
and the retry prompt then spins forever on EOF — the pipeline never had a
usable TTY to type the PIN into. So agenix rekey/agenix generate hangs the
moment it needs to touch the card.
The insight / trap¶
The YubiKey caches an entered PIN per card session. So you don't need the PIN available inside the pipeline — you only need it entered once on the card beforehand. The fix is a one-shot preamble:
Before handing control to agenix, do a single direct, interactive decrypt of any existing
.agefile using the same YubiKey identity. That decrypt is allowed to prompt (it has a real TTY), you type the PIN (and touch if your slot needs it), the card caches it, and the whole agenix pipeline then runs through unattended.
The decrypt output is thrown away (-o /dev/null) — we only want the side
effect of caching the PIN on the card.
How it works¶
This is a callPackage-able factory that produces a small
writeShellApplication. It:
- Finds a canary
.agefile (non-recursivefindin your secrets dir). - If stdin is a TTY and a canary exists, does one interactive
rage -d -i <master-identity> -o /dev/null <canary>to prime the PIN. Failure here is a warning, not fatal — the subsequent agenix run will surface the real error. execs the requestedagenixsubcommand with all passthrough args.
The TTY guard ([ -t 0 ]) means the priming step is skipped automatically in
CI or piped invocations, where there is no PIN to enter anyway.
Usage¶
Call it once per subcommand you want wrapped:
{
agenix-generate = pkgs.callPackage ./agenix-yubikey-pin-prime {
agenixSubcommand = "generate";
masterIdentity = "keys/age-yubikey-identity.txt";
};
agenix-rekey = pkgs.callPackage ./agenix-yubikey-pin-prime {
agenixSubcommand = [ "rekey" "-a" ];
masterIdentity = "keys/age-yubikey-identity.txt";
};
}
masterIdentity must be an identity, not a recipient. For
age-plugin-yubikey that is the file holding the AGE-PLUGIN-YUBIKEY-…
stanza which names the card slot (what age-plugin-yubikey --identity writes).
It is safe to commit — the private key never leaves the card. A plain age1…
recipient file (conventionally *.pub) will not work: the priming step is
rage -d -i <file>, and -i needs an identity, so a recipient makes every
prime fail with a decrypt error and leaves agenix hanging exactly as before.
Note the binary name is not the attribute name: it defaults to
agenix-<subcommand>-pinprimed, so those two calls install
agenix-generate-pinprimed and agenix-rekey-pinprimed. Pass name if you
want something else. Run it interactively — it primes the PIN, then behaves
exactly like the wrapped agenix command.
Options¶
| option | default | meaning |
|---|---|---|
agenixSubcommand |
"generate" |
Subcommand (+ flags) to exec after priming. String or list, e.g. [ "rekey" "-a" ]. |
masterIdentity |
"keys/age-yubikey-identity.txt" |
YubiKey identity file passed to rage -d -i (not a recipient). Relative to the working dir. Placeholder — set this to your own path. |
secretsDir |
"secrets" |
Directory searched (depth 1) for a canary .age file. |
name |
agenix-<first word of subcommand>-pinprimed |
Package/binary name. |
agenixBin |
agenix-rekey's agenix |
The agenix binary to exec. |
Caveats¶
masterIdentitymust be an identity file, not a recipient. Pointing it at anage1…recipient (e.g. a*.pubexport) makes the priming decrypt fail every time, which is only a warning here — so the symptom you see is the original hang, not an obvious error.- The canary is any
.agefile that your master identity can decrypt. If your first-found.agefile is not encrypted to the master identity, priming will fail (harmlessly) and the real agenix run will still hang — pointsecretsDirat a directory whose files the YubiKey can actually read. - PIN caching is a property of the card/session; if your card is configured to require a touch or PIN-per-operation, one prime may not cover the whole run. Adjust your YubiKey PIV touch/PIN policy accordingly.
- Run it interactively — the whole point is the one TTY prompt. Under a non-TTY invocation it silently skips priming (by design).
Dependencies¶
agenix-rekey, age-plugin-yubikey, rage (and lib from nixpkgs). All are
standard inputs; wire them via callPackage.
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-rekey-yubikey-pin-prime — a mid-level wrapper with a fixed
rekey/generatebinary model andsubcommand/extraArgsoptions, if the low-level factory feels like overkill.
Source¶
packages/agenix-yubikey-pin-prime/default.nix
# agenix-yubikey-pin-prime
#
# A generic factory that wraps an `agenix` subcommand (typically `generate` or
# `rekey`) with a one-shot interactive YubiKey PIV PIN "priming" step.
#
# Why: rage cannot prompt for the YubiKey PIV PIN from *inside* agenix's
# non-interactive decrypt|encrypt pipeline — it prints "A PIN is required..."
# and then spins forever on EOF. The PIN is cached per card session, so we
# perform ONE direct interactive decrypt of any existing `.age` file first
# (which is allowed to prompt), and the cached PIN then carries the whole
# unattended pipeline through.
#
# This file is a `callPackage`-able function. Call it once per agenix
# subcommand you want to wrap, e.g.:
#
# agenix-generate = pkgs.callPackage ./agenix-yubikey-pin-prime {
# agenixSubcommand = "generate";
# masterIdentity = "keys/age-yubikey-identity.txt";
# };
# agenix-rekey = pkgs.callPackage ./agenix-yubikey-pin-prime {
# agenixSubcommand = [ "rekey" "-a" ];
# masterIdentity = "keys/age-yubikey-identity.txt";
# };
#
# It has no NixOS-module dependencies — it produces a plain package you can
# drop into a devshell, a flake `packages` output, or `environment.systemPackages`.
{
lib,
writeShellApplication,
agenix-rekey,
age-plugin-yubikey,
rage,
# ---- options (all have sensible generic defaults) ----------------------
# The agenix subcommand (plus flags) this wrapper execs after priming.
# Accepts a string ("generate") or a list ([ "rekey" "-a" ]).
agenixSubcommand ? "generate",
# Path to the age-plugin-yubikey *identity* file used for the priming
# decrypt — the `AGE-PLUGIN-YUBIKEY-…` stanza that points at the card slot.
# Relative paths are resolved against the working directory (usually your
# repo/project root). It is safe to keep in the repo: the private key never
# leaves the YubiKey. A plain `age1…` RECIPIENT file (typically named
# `*.pub`) will NOT work — `rage -d -i` needs an identity, and passing a
# recipient makes every priming decrypt fail. The default is only an
# identity-shaped placeholder; point it at your own file.
masterIdentity ? "keys/age-yubikey-identity.txt",
# Directory (relative to the working dir) that is searched, non-recursively,
# for a canary `.age` file to decrypt for priming. Adjust to your layout.
secretsDir ? "secrets",
# Package name; defaults derive from the subcommand for readability.
name ? "agenix-${lib.head (lib.flatten [ agenixSubcommand ])}-pinprimed",
# The agenix binary to exec. Defaults to the one shipped by agenix-rekey.
agenixBin ? "${agenix-rekey}/bin/agenix",
}:
let
subcmd = lib.concatStringsSep " " (lib.flatten [ agenixSubcommand ]);
in
writeShellApplication {
inherit name;
runtimeInputs = [
agenix-rekey
age-plugin-yubikey
rage
];
text = ''
# --- YubiKey PIV PIN priming -----------------------------------------
# Inside agenix's decrypt|encrypt pipeline, rage cannot prompt for the
# YubiKey PIV PIN: it emits "A PIN is required..." and the retry prompt
# then spins forever on EOF. The PIN is cached per card session, so we
# prime it here with ONE direct interactive decrypt before handing over
# to agenix. We only prime when stdin is a TTY (skips CI / piped runs).
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 masterIdentity} -o /dev/null "$canary" \
|| echo "warning: PIN priming failed; ${subcmd} may not be able to decrypt" >&2
fi
exec ${agenixBin} ${subcmd} "$@"
'';
}