Extending agenix to derive secrets
shimun January 31, 2025 #nixos #age #openssl #kdfWhy
Agenix is a fine soultion for managing distinct secrets, e.g. the kind you can't generate since the secret has to conform to a very specific format or is generated by an external party like an API key. For a lot of other uses like JWT secrets or initial admin passwords it'd be a lot more convenient to just an add a like of nix config defining the secret rather than generating and encrypting one.
How
The easiest way to derive an secret from an master secret would be an HMAC:
secret = hmac(master, salt)
Where salt is an non secret value used to the describe the secret e.g. "admin password" its value doesn't really matter as long as it does not collide with other secrets being derived. The is an dowsinde to this apporch however, the generated secret will be of fixed size depending on the hash algorithm. This poses an problem if we desire to apply an filter to the secret in order to generate an alpha numeric string for instance.
But there is an solution. Instead of an hash function we use our secret to seed an cryptographic random number generator:
seed = hmac(master, salt)
rng = cipher(seed)
Putting it all together
Most of it is just boiler plate to ensure nix typechecks everthing for use:
options = let
derived = self: let
inherit (self.config._module.args) name;
in {
options = {
source = mkOption {
type = with types; either (submodule (_: {options.age = mkOption {type = enum (attrNames config.age.secrets);};})) path;
};
sourcePath = mkOption {
type = types.path;
readOnly = true;
};
name = mkOption {
type = types.str;
default = "name-substring 0 12 (builtins.hashString "sha256" "lib.escapeShellArg self.config.sourcePath-lib.escapeShellArg self.config.filter-toString self.config.len")";
};
filter = mkOption {
type = types.str;
default = "A-Za-z0-9";
};
len = mkOption {
type = types.ints.positive;
default = 32;
};
path = mkOption {
type = types.path;
readOnly = true;
default = "config.age.secretsDir/self.config.name";
};
mode = mkOption {
type = types.str;
default = "0400";
};
owner = mkOption {
type = with types; nullOr (enum (attrNames config.users.users));
default = null;
};
group = mkOption {
type = with types; nullOr (enum (attrNames config.users.groups));
default = null;
};
};
config = {
sourcePath = let
inherit (self.config) source;
in
if source ? age
then config.age.secrets."self.config.source.age".path
else source;
};
};
in {
age.derived = mkOption {
type = with types; attrsOf (submodule derived);
default = {};
};
};
config = let
mkDerived = {
sourcePath,
filter,
len,
name,
path,
mode,
owner,
group,
...
}:
pkgs.writeShellScript "derive-name" ''
set -e
umask u=r,g=,o=
path="$(realpath escapeShellArg path)"
lib.getExe pkgs.openssl aes-256-cbc -e -in /dev/zero \
-nosalt -nopad -iter 1 -kfile lib.escapeShellArg sourcePath 2> /dev/null \
| lib.getExe pkgs.openssl aes-256-cbc -e \
-nosalt -nopad -iter 1 -k lib.escapeShellArg name 2> /dev/null \
| tr -dc lib.escapeShellArg filter \
| head -ctoString len > "$path"
chmod mode "$path"
optionalString (owner != null) "chown owner \"$path\""
optionalString (group != null) "chgrp group \"$path\""
'';
in
mkIf (cfg != {}) {
assertions = flatten (attrValues (mapAttrs (name: args @ {
len,
filter,
...
}: [
{
assertion = len > 0 && builtins.stringLength filter > 0;
message = "age.derived.name: len must be > 0, filter must be non empty";
}
{
message = "age.derived.name: does not yield distinct secrets for distinct inputs";
assertion = let
inputs = map (i: pkgs.writeText "input" (toString (i * 1000))) [0 1 2 3];
secrets = map (input:
builtins.readFile (pkgs.runCommandLocal "assert-name" {} ''
set -xe
bash -x mkDerived (args
// {
sourcePath = input;
path = "./secret";
len = 64;
owner = null;
group = null;
})
cp $PWD/secret $out
''))
inputs;
in
allUnique secrets;
}
])
cfg));
system.activationScripts.age-derive = {
deps = ["agenix"];
text = ''
lib.concatStringsSep " &\n" (map toString (map mkDerived (attrValues cfg)))
wait
'';
};
};
}
Using types.enum
to ensure source.age
contains only valid secrets is an nice trick though :)
The actual derivation using aes-256-cbc
as crng:
mkDerived = {
sourcePath,
filter,
len,
name,
path,
mode,
owner,
group,
...
}:
pkgs.writeShellScript "derive-name" ''
set -e
umask u=r,g=,o=
path="$(realpath escapeShellArg path)"
lib.getExe pkgs.openssl aes-256-cbc -e -in /dev/zero \
-nosalt -nopad -iter 1 -kfile lib.escapeShellArg sourcePath 2> /dev/null \
| lib.getExe pkgs.openssl aes-256-cbc -e \
-nosalt -nopad -iter 1 -k lib.escapeShellArg name 2> /dev/null \
| tr -dc lib.escapeShellArg filter \
| head -ctoString len > "$path"
chmod mode "$path"
optionalString (owner != null) "chown owner \"$path\""
optionalString (group != null) "chgrp group \"$path\""
'';
Assertions to make sure the configured filter yields an passable secret:
assertions = flatten (attrValues (mapAttrs (name: args @ {
len,
filter,
...
}: [
{
assertion = len > 0 && builtins.stringLength filter > 0;
message = "age.derived.name: len must be > 0, filter must be non empty";
}
{
message = "age.derived.name: does not yield distinct secrets for distinct inputs";
assertion = let
inputs = map (i: pkgs.writeText "input" (toString (i * 1000))) [0 1 2 3];
secrets = map (input:
builtins.readFile (pkgs.runCommandLocal "assert-name" {} ''
set -xe
bash -x mkDerived (args
// {
sourcePath = input;
path = "./secret";
len = 64;
owner = null;
group = null;
})
cp $PWD/secret $out
''))
inputs;
in
allUnique secrets;
}
])
cfg));
And finally the activation script:
system.activationScripts.age-derive = {
deps = ["agenix"];
text = ''
lib.concatStringsSep " &\n" (map toString (map mkDerived (attrValues cfg)))
wait
'';
};
The complete file can be found here