Extending agenix to derive secrets

shimun January 31, 2025 #nixos #age #openssl #kdf

Why

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 -c${toString 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 -c${toString 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