agenix-secret-sibling¶
Library
A tiny helper that derives the path of a secret's public half from the filename of its encrypted private half, for use inside agenix-rekey generators.
The problem¶
agenix-rekey lets you register generators: a named shell recipe that runs when
a secret's .age file is missing. Whatever the recipe prints to stdout becomes
the plaintext that agenix encrypts into <name>.age.
Plenty of secrets are actually key pairs:
- an SSH host/auth key (
id_ed25519+id_ed25519.pub) - a GPG signing key (secret key + armoured
.asc+.fingerprint) - a binary-cache signing key (
priv.pem+<name>.pub) - an API key pair (
sk_...secret +pk_...public)
The private half must be encrypted. The public half is not sensitive and you
almost always want it committed in the clear, so other modules can reference it
as a normal file. The awkward part is naming: the generator only knows the
absolute path of the target .age file. Where should the public file go?
The insight¶
Put the public half right beside its encrypted private counterpart, and
derive its path from the .age filename — strip .age, append a new extension:
secrets/host-ssh.age <- encrypted private key (agenix owns this)
secrets/host-ssh.pub <- public key, plaintext, committed
That is the entire helper:
file is the generator argument holding the absolute path to the .age file;
suffix is the new extension (include the leading dot). The result is a
shell-escaped string ready to drop into the generator's shell script.
Usage¶
{ lib, ... }:
let
secretSibling = import ./lib/agenix-secret-sibling/default.nix lib;
in
{
age.generators.ssh =
{ pkgs, file, ... }:
''
${pkgs.openssh}/bin/ssh-keygen -t ed25519 -f id_ed25519 -N "" -q
# public half lands next to the .age, as <name>.pub
mv id_ed25519.pub ${secretSibling file ".pub"}
cat id_ed25519 # private half -> stdout -> encrypted by agenix
rm id_ed25519
'';
}
Then a secret opts in with generator.script = "ssh"; and running
agenix-rekey generate produces both files. Multiple companions are fine — a
GPG generator can emit both ${secretSibling file ".asc"} and
${secretSibling file ".fingerprint"}.
Traps and caveats¶
-
escapeShellArgis not optional. The path is interpolated straight into a shell command; escaping keeps paths with spaces or metacharacters from breaking (or subverting) the generator. Do not drop it "because my paths are simple". -
Include the leading dot in the suffix (
".pub", not"pub"). The suffix is plain string concatenation, which also means you can build non-dotted siblings if you ever need to. -
Never leave the private half in the generator's working directory. Emit it on stdout so agenix encrypts it, and clean up any temp files. A stray plaintext private key written to the current directory can silently get committed. Prefer a
mktemp -dscratch dir with a cleanuptrapfor tools that insist on writing key files to disk:
age.generators.cache-key =
{ pkgs, file, name, ... }:
''
keydir=$(${pkgs.coreutils}/bin/mktemp -d)
trap '${pkgs.coreutils}/bin/rm -rf "$keydir"' EXIT
${pkgs.nix}/bin/nix-store --generate-binary-cache-key \
${lib.escapeShellArg name} "$keydir/priv.pem" ${secretSibling file ".pub"}
cat "$keydir/priv.pem" # only the private half reaches stdout
'';
-
Passphrase-less private keys are fine here. Generators run non-interactively, so a GPG/SSH key generated with no passphrase is normal — confidentiality comes from the age encryption layer, not a key passphrase.
-
The helper computes a path only; it does not create, move, or validate any file. The generator script is responsible for actually writing the public half to that path.
Why a whole file for one line¶
Because the one line is easy to get subtly wrong (forgetting the escape, or the dot, or re-deriving the name inconsistently across three generators), and because the pattern — public sibling next to encrypted private — is the thing worth naming and reusing across every generator in a repo.
Source¶
lib/agenix-secret-sibling/default.nix
# agenix-secret-sibling
#
# A one-line helper for agenix-rekey secret *generators*.
#
# An agenix-rekey generator produces the plaintext of a secret on stdout, which
# the framework then encrypts into `<name>.age`. Many secrets are actually key
# *pairs*: the private half is the secret (encrypted), but the public half is
# not sensitive and you usually want it committed in the clear so other modules
# can reference it (an SSH `.pub`, a GPG `.asc`, an API `public-key`, a
# binary-cache `.pub`, ...).
#
# This helper derives the sibling path for that public half from the secret's
# own `.age` filename: strip the `.age` suffix, append a new extension, and
# shell-escape the result so it is safe to interpolate into the generator's
# shell script. The public file then lands right beside its encrypted private
# counterpart in your secrets tree.
#
# Usage — import it with `lib` applied, then call `secretSibling file ".ext"`
# inside a generator, where `file` is the generator argument holding the
# absolute path to the target `.age` file:
#
# { lib, ... }:
# let
# # this file is a curried function: apply `lib` first.
# secretSibling = import ./lib/agenix-secret-sibling/default.nix lib;
# in
# {
# age.generators.ssh =
# { pkgs, file, ... }:
# ''
# ${pkgs.openssh}/bin/ssh-keygen -t ed25519 -f id_ed25519 -N "" -q
# # public half lands next to the .age as <name>.pub
# mv id_ed25519.pub ${secretSibling file ".pub"}
# cat id_ed25519 # private half -> stdout -> encrypted by agenix
# rm id_ed25519
# '';
# }
#
# Given `file = "/repo/secrets/host-ssh.age"` and `suffix = ".pub"`, the result
# is the shell-escaped string `'/repo/secrets/host-ssh.pub'`.
#
# Traps this guards against:
# * `escapeShellArg` is not optional. `file` is interpolated straight into a
# shell heredoc/command; without escaping, a path containing spaces or shell
# metacharacters would break the generator (or worse). Keep it.
# * The suffix is a plain string concatenation, so include the leading dot
# yourself (".pub", not "pub"). This lets you also produce non-dotted
# siblings if you ever need them.
# * This only computes the *public* sibling path. Never write the private half
# into the working directory of the generator — emit it on stdout so agenix
# encrypts it. A stray plaintext private key left in cwd can end up
# committed. (See the README for the full generator hygiene note.)
#
# The whole thing is deliberately tiny: it is a curried function of
# `lib -> file -> suffix -> escapedPath`.
lib: file: suffix: lib.escapeShellArg (lib.removeSuffix ".age" file + suffix)