From 72e4df576e1457de715b32cc105051cfa2dce39d Mon Sep 17 00:00:00 2001 From: cyclopentane Date: Mon, 23 Feb 2026 17:42:14 +0100 Subject: [PATCH] nixos/initrd: add secretPaths and extraSecretsHook --- .../networking/iscsi/root-initiator.nix | 2 +- nixos/modules/system/activation/bootspec.nix | 6 +- nixos/modules/system/boot/kernel.nix | 2 +- .../system/boot/loader/grub/install-grub.pl | 2 +- .../systemd-boot/systemd-boot-builder.py | 2 +- nixos/modules/system/boot/stage-1.nix | 178 +++++++++++++++--- .../system/boot/systemd/initrd-secrets.nix | 25 ++- 7 files changed, 173 insertions(+), 44 deletions(-) diff --git a/nixos/modules/services/networking/iscsi/root-initiator.nix b/nixos/modules/services/networking/iscsi/root-initiator.nix index 86396ae1e24f..6c967d386db7 100644 --- a/nixos/modules/services/networking/iscsi/root-initiator.nix +++ b/nixos/modules/services/networking/iscsi/root-initiator.nix @@ -86,7 +86,7 @@ in description = '' Append an additional file's contents to `/etc/iscsid.conf`. Use a non-store path and store passwords in this file. Note: the file specified here must be available - in the initrd, see: `boot.initrd.secrets`. + in the initrd, see: `boot.initrd.secretPaths`. ''; default = null; type = nullOr str; diff --git a/nixos/modules/system/activation/bootspec.nix b/nixos/modules/system/activation/bootspec.nix index c625b91ec1e2..45d1d18cff2c 100644 --- a/nixos/modules/system/activation/bootspec.nix +++ b/nixos/modules/system/activation/bootspec.nix @@ -14,7 +14,9 @@ let children = lib.mapAttrs ( childName: childConfig: childConfig.configuration.system.build.toplevel ) config.specialisation; - hasAtLeastOneInitrdSecret = lib.length (lib.attrNames config.boot.initrd.secrets) > 0; + hasInitrdSecrets = + (lib.length (lib.attrNames config.boot.initrd.secretPaths) > 0) + || (config.boot.initrd.extraSecretsHook != ""); schemas = { v1 = rec { filename = "boot.json"; @@ -33,7 +35,7 @@ let // lib.optionalAttrs config.boot.initrd.enable { initrd = "${config.system.build.initialRamdisk}/${config.system.boot.loader.initrdFile}"; } - // lib.optionalAttrs hasAtLeastOneInitrdSecret { + // lib.optionalAttrs hasInitrdSecrets { initrdSecrets = "${config.system.build.initialRamdiskSecretAppender}/bin/append-initrd-secrets"; }; } diff --git a/nixos/modules/system/boot/kernel.nix b/nixos/modules/system/boot/kernel.nix index 9a3138ff37eb..b5c7acb5a877 100644 --- a/nixos/modules/system/boot/kernel.nix +++ b/nixos/modules/system/boot/kernel.nix @@ -414,7 +414,7 @@ in ln -s ${initrdPath} $out/initrd - ${optionalString (config.boot.initrd.secrets != { }) '' + ${optionalString (config.boot.initrd.secretPaths != { }) '' ln -s ${config.system.build.initialRamdiskSecretAppender}/bin/append-initrd-secrets $out ''} diff --git a/nixos/modules/system/boot/loader/grub/install-grub.pl b/nixos/modules/system/boot/loader/grub/install-grub.pl index f5ae844da21f..e9e33395a5b5 100644 --- a/nixos/modules/system/boot/loader/grub/install-grub.pl +++ b/nixos/modules/system/boot/loader/grub/install-grub.pl @@ -482,7 +482,7 @@ sub addEntry { die "failed to create initrd secrets $!\n"; } else { say STDERR "warning: failed to create initrd secrets for \"$name\", an older generation"; - say STDERR "note: this is normal after having removed or renamed a file in `boot.initrd.secrets`"; + say STDERR "note: this is normal after having modified or removed an entry in `boot.initrd.secretPaths`"; } } # Check whether any secrets were actually added diff --git a/nixos/modules/system/boot/loader/systemd-boot/systemd-boot-builder.py b/nixos/modules/system/boot/loader/systemd-boot/systemd-boot-builder.py index 90241b92cd5f..acd6536a415f 100644 --- a/nixos/modules/system/boot/loader/systemd-boot/systemd-boot-builder.py +++ b/nixos/modules/system/boot/loader/systemd-boot/systemd-boot-builder.py @@ -202,7 +202,7 @@ def write_entry(profile: str | None, generation: int, specialisation: str | None print("warning: failed to create initrd secrets " f'for "{title} - Configuration {generation}", an older generation', file=sys.stderr) print("note: this is normal after having removed " - "or renamed a file in `boot.initrd.secrets`", file=sys.stderr) + "or modified an entry in `boot.initrd.secretPaths`", file=sys.stderr) entry_file = BOOT_MOUNT_POINT / "loader/entries" / generation_conf_filename(profile, generation, specialisation) tmp_path = entry_file.with_suffix(".tmp") kernel_params = "init=%s " % bootspec.init diff --git a/nixos/modules/system/boot/stage-1.nix b/nixos/modules/system/boot/stage-1.nix index 343777d05c3a..dd6a46abf283 100644 --- a/nixos/modules/system/boot/stage-1.nix +++ b/nixos/modules/system/boot/stage-1.nix @@ -148,23 +148,22 @@ let # Copy secrets if needed. # # TODO: move out to a separate script; see #85000. - ${optionalString (!config.boot.loader.supportsInitrdSecrets) ( - concatStringsSep "\n" ( - mapAttrsToList ( - dest: source: - let - source' = if source == null then dest else source; - in - '' - mkdir -p $(dirname "$out/secrets/${dest}") - # Some programs (e.g. ssh) doesn't like secrets to be - # symlinks, so we use `cp -L` here to match the - # behaviour when secrets are natively supported. - cp -Lr ${source'} "$out/secrets/${dest}" - '' - ) config.boot.initrd.secrets - ) - )} + ${optionalString (!config.boot.loader.supportsInitrdSecrets) '' + ${concatStringsSep "\n" ( + mapAttrsToList (_: scfg: '' + mkdir -p $(dirname "$out/secrets${scfg.path}") + # Some programs (e.g. ssh) doesn't like secrets to be + # symlinks, so we use `cp -L` here to match the + # behaviour when secrets are natively supported. + # The assertion further up in this file (stage-1.nix) + # checks that all secretPaths are Nix store paths set via + # boot.initrd.secretPaths.*.source if the bootloader doesn't + # support initrd secrets. + cp -Lr ${scfg.source} "$out/secrets${scfg.path}" + '') config.boot.initrd.secretPaths + )} + ${config.boot.initrd.extraSecretsHook} + ''} ${config.boot.initrd.extraUtilsCommands} @@ -436,7 +435,9 @@ let exit 0 fi - ${lib.optionalString (config.boot.initrd.secrets == { }) "exit 0"} + ${lib.optionalString ( + config.boot.initrd.secretPaths == { } && config.boot.initrd.extraSecretsHook == "" + ) "exit 0"} export PATH=${pkgs.coreutils}/bin:${pkgs.cpio}/bin:${pkgs.gzip}/bin:${pkgs.findutils}/bin @@ -451,17 +452,25 @@ let ${lib.concatStringsSep "\n" ( mapAttrsToList ( - dest: source: + _: scfg: let - source' = if source == null then dest else toString source; + prefix = lib.optionalString scfg.intermediateSecretsDir "/.initrd-secrets"; in '' - mkdir -p $(dirname "$tmp/.initrd-secrets/${dest}") - cp -a ${source'} "$tmp/.initrd-secrets/${dest}" + mkdir -p $(dirname "$tmp${prefix}${scfg.path}") + ( + export out="$tmp${prefix}${scfg.path}" + ${scfg.generateSecretCommand} + ) '' - ) config.boot.initrd.secrets + ) config.boot.initrd.secretPaths )} + ( + cd "$tmp" + ${config.boot.initrd.extraSecretsHook} + ) + # mindepth 1 so that we don't change the mode of / (cd "$tmp" && find . -mindepth 1 | xargs touch -amt 197001010000 && find . -mindepth 1 -print0 | sort -z | cpio --quiet -o -H newc -R +0:+0 --reproducible --null) | \ ${compressorExe} ${lib.escapeShellArgs initialRamdisk.compressorArgs} >> "$1" @@ -668,6 +677,104 @@ in ''; }; + boot.initrd.secretPaths = mkOption { + default = { }; + type = types.attrsOf ( + types.submodule ( + { config, name, ... }: + { + options = { + path = mkOption { + type = types.path; + default = name; + description = '' + The path the secret should be placed at in the initrd. Defaults + to the attribute name. + ''; + }; + + intermediateSecretsDir = mkOption { + type = types.bool; + default = true; + description = '' + By default, the secrets will be copied over to the + `/.initrd-secrets` dir at initrd generation time, and then copied + over to their final location at boot time. This is because initrd secrets + that are supposed to be placed in `/run` would be overridden by + the tmpfs mount over `/run` otherwise. + + Set this option to `false` to skip this intermediate step and + place the secret at its final location straightaway. + ''; + }; + + source = mkOption { + type = types.nullOr types.path; + default = null; + description = '' + The absolute path on the filesystem to copy the secret from. + ''; + example = "/var/lib/secrets/initrd/ssh_host_ed25519_key"; + }; + + generateSecretCommand = mkOption { + type = types.path; + description = '' + The command to run to generate the secret. It should write + the secret to `$out`. + + This is useful if you have a more advanced secrets provisioning + mechanism. + ''; + example = '' + pkgs.writeShellScript "generate-secret" ''' + ''${lib.getExe pkgs.age} -d -i /etc/ssh/ssh_host_ed25519_key -o "$out" ''${./secret.age} + ''' + ''; + }; + }; + + config = { + generateSecretCommand = lib.mkIf (config.source != null) ( + pkgs.writeShellScript "copy-secret" '' + cp -Lr ${config.source} "$out" + '' + ); + }; + } + ) + ); + description = '' + Secret paths to append to the initrd. The attribute name is the + path the secret should have inside the initrd. + + Note that `nixos-rebuild switch` will generate the initrd + also for past generations, so if secrets are moved or deleted + you will also have to garbage collect the generations that + use those secrets. + ''; + example = { + "/etc/ssh/ssh_host_ed25519_key".source = "/var/lib/secrets/initrd/ssh_host_ed25519_key"; + }; + }; + + boot.initrd.extraSecretsHook = mkOption { + default = ""; + type = types.lines; + description = '' + Extra commands to be executed after the initrd secrets generation phase. + + This script should place files into the current workdir. These files + will then be copied over to the initrd to the corresponding absolute + paths, e.g. `etc/ssh/ssh_host_ed25519_key` will be copied over to + `/etc/ssh/ssh_host_ed25519_key`. + ''; + example = '' + # Generate a new SSH host key for every generation. + ssh-keygen -f etc/ssh/ssh_host_ed25519_key + ''; + }; + boot.initrd.supportedFilesystems = mkOption { default = { }; inherit (options.boot.supportedFilesystems) example type description; @@ -746,15 +853,18 @@ in assertion = !config.boot.loader.supportsInitrdSecrets -> all ( - source: builtins.isPath source || (builtins.isString source && hasPrefix builtins.storeDir source) - ) (attrValues config.boot.initrd.secrets); + scfg: + builtins.isPath scfg.source + || (builtins.isString scfg.source && hasPrefix builtins.storeDir scfg.source) + ) (attrValues config.boot.initrd.secretPaths); message = '' - boot.initrd.secrets values must be unquoted paths when - using a bootloader that doesn't natively support initrd - secrets, e.g.: + When using a bootloader that doesn't natively support initrd secrets, + all `boot.initrd.secretPaths` values must be defined via + `boot.initrd.secretsPaths.*.source`, and the `source` values must be + unquoted paths, e.g. - boot.initrd.secrets = { - "/etc/secret" = /path/to/secret; + boot.initrd.secretPaths = { + "/etc/secret".source = /path/to/secret; }; Note that this will result in all secrets being stored @@ -763,6 +873,14 @@ in } ]; + # Backwards compatibility to the legacy `boot.initrd.secrets` option. + boot.initrd.secretPaths = lib.mapAttrs' (dest: source: { + # The legacy boot.initrd.secrets option didn't type-check the attr + # names, so we need to optionally prepend a slash. + name = "${lib.optionalString (!lib.hasPrefix "/" dest) "/"}${dest}"; + value.source = if dest != null then source else dest; + }) config.boot.initrd.secrets; + system.build = mkMerge [ { inherit diff --git a/nixos/modules/system/boot/systemd/initrd-secrets.nix b/nixos/modules/system/boot/systemd/initrd-secrets.nix index 50bc45babe15..4f7bddaa6097 100644 --- a/nixos/modules/system/boot/systemd/initrd-secrets.nix +++ b/nixos/modules/system/boot/systemd/initrd-secrets.nix @@ -10,14 +10,21 @@ # Copy secrets into the initrd if they cannot be appended boot.initrd.systemd.contents = lib.mkIf (!config.boot.loader.supportsInitrdSecrets) ( lib.mapAttrs' ( - dest: source: - lib.nameValuePair "/.initrd-secrets/${dest}" { source = if source == null then dest else source; } - ) config.boot.initrd.secrets + _: scfg: + let + prefix = lib.optionalString scfg.intermediateSecretsDir "/.initrd-secrets"; + in + lib.nameValuePair "${prefix}${scfg.path}" { inherit (scfg) source; } + ) config.boot.initrd.secretPaths ); # Copy secrets to their respective locations boot.initrd.systemd.services.initrd-nixos-copy-secrets = - lib.mkIf (config.boot.initrd.secrets != { }) + lib.mkIf + ( + (builtins.any (x: x.intermediateSecretsDir) (builtins.attrValues config.boot.initrd.secretPaths)) + || config.boot.initrd.extraSecretsHook != "" + ) { description = "Copy secrets into place"; # Run as early as possible @@ -34,10 +41,12 @@ # drop this service, we'd mount the /run tmpfs over the secret, making it # invisible in stage 2. script = '' - for secret in $(cd /.initrd-secrets; find . -type f -o -type l); do - mkdir -p "$(dirname "/$secret")" - cp "/.initrd-secrets/$secret" "/$secret" - done + if [ -d /.initrd-secrets ]; then + for secret in $(cd /.initrd-secrets; find . -type f -o -type l); do + mkdir -p "$(dirname "/$secret")" + cp "/.initrd-secrets/$secret" "/$secret" + done + fi ''; serviceConfig = {