From a7be344fe775f9153f08ee95d7dbbc5ac75c072d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C2=B7=F0=90=91=91=F0=90=91=B4=F0=90=91=95=F0=90=91=91?= =?UTF-8?q?=F0=90=91=A9=F0=90=91=A4?= Date: Fri, 24 Apr 2026 20:35:35 +0700 Subject: [PATCH] nixos/system/boot/tzpfms: init --- nixos/modules/module-list.nix | 1 + nixos/modules/system/boot/tzpfms.nix | 247 +++++++++++++++++++++++++++ 2 files changed, 248 insertions(+) create mode 100644 nixos/modules/system/boot/tzpfms.nix diff --git a/nixos/modules/module-list.nix b/nixos/modules/module-list.nix index a96e30cac837..1317f16ce286 100644 --- a/nixos/modules/module-list.nix +++ b/nixos/modules/module-list.nix @@ -1961,6 +1961,7 @@ ./system/boot/systemd/userdbd.nix ./system/boot/timesyncd.nix ./system/boot/tmp.nix + ./system/boot/tzpfms.nix ./system/boot/uki.nix ./system/boot/unl0kr.nix ./system/boot/uvesafb.nix diff --git a/nixos/modules/system/boot/tzpfms.nix b/nixos/modules/system/boot/tzpfms.nix new file mode 100644 index 000000000000..e1ca02b3a209 --- /dev/null +++ b/nixos/modules/system/boot/tzpfms.nix @@ -0,0 +1,247 @@ +{ + config, + lib, + pkgs, + utils, + ... +}: + +# NOTE: the loading of keys is done in separate tzpfms systemd services +# defined below rather than inline in the ZFS import scripts. + +let + cfgZFS = config.boot.zfs; + cfg = cfgZFS.tzpfms; + + datasetToPool = x: lib.elemAt (lib.splitString "/" x) 0; + + pools = lib.unique (map datasetToPool cfg.datasets); + + # All ZFS filesystems + zfsFilesystems = lib.filter (x: x.fsType == "zfs") config.system.build.fileSystems; + + # Pools that are already imported in initrd (have neededForBoot filesystems) + fsToPool = fs: lib.elemAt (lib.splitString "/" fs.device) 0; + rootPools = lib.unique (map fsToPool (lib.filter utils.fsNeededForBoot zfsFilesystems)); + + # Only include initrd resources if datasets belong to pools that need initrd import. + # A pool needs initrd import if it has neededForBoot filesystems. + initrdPools = lib.filter (pool: lib.elem pool rootPools) pools; + systemPools = lib.filter (pool: !(lib.elem pool rootPools)) pools; + + needsInitrd = initrdPools != [ ]; + + datasetsByPool = lib.groupBy datasetToPool cfg.datasets; + + # Goup neededForBoot filesystems by pool → initrd mount units + initrdMountsByPool = lib.foldl' ( + acc: fs: + let + p = lib.elemAt (lib.splitString "/" fs.device) 0; + mount = "${utils.escapeSystemdPath ("/sysroot" + (lib.removeSuffix "/" fs.mountPoint))}.mount"; + in + if utils.fsNeededForBoot fs then acc // { ${p} = (acc.${p} or [ ]) ++ [ mount ]; } else acc + ) { } zfsFilesystems; + + # Group all ZFS filesystems by pool → system mount units + systemMountsByPool = lib.foldl' ( + acc: fs: + let + pool = lib.elemAt (lib.splitString "/" fs.device) 0; + mount = "${utils.escapeSystemdPath (lib.removeSuffix "/" fs.mountPoint)}.mount"; + in + acc // { ${pool} = (acc.${pool} or [ ]) ++ [ mount ]; } + ) { } zfsFilesystems; + + # Generate tzpfms key-loading bash script + mkTzpfmsScript = datasets: /* bash */ '' + tzpfms_load_key() { + zfs-tpm-list -H ${backendArgs} "$@" 2>/dev/null | while IFS=$'\t' read -r name backend status _; do + case "$backend" in + ${lib.optionalString (lib.elem "TPM2" cfg.backends) /* bash */ '' + TPM2) + zfs-tpm2-load-key "$name" || true + ;; + ''} + ${lib.optionalString (lib.elem "TPM1.X" cfg.backends) /* bash */ '' + TPM1.X) + zfs-tpm1x-load-key "$name" || true + ;; + ''} + *) + echo "[WARN] boot.zfs.tzpfms: Unsupported tzpfms backend: “$backend”; “$name” not unlocked" >&2 + ;; + esac + done + } + + ${lib.concatMapStringsSep "\n" (ds: "tzpfms_load_key -u ${lib.escapeShellArg ds}") datasets} + ''; + + mkTzpfmsService = + { + pool, + mountUnits, + script, + }: + { + description = "Load TPM keys for ZFS pool “${pool}”"; + after = [ "zfs-import-${pool}.service" ]; + before = mountUnits ++ [ "zfs-import.target" ]; + requiredBy = mountUnits ++ [ "zfs-import.target" ]; + unitConfig.DefaultDependencies = "no"; + serviceConfig = { + Type = "oneshot"; + RemainAfterExit = true; + }; + inherit script; + }; + + backendArgs = lib.escapeShellArgs ( + lib.concatMap (b: [ + "-b" + b + ]) cfg.backends + ); +in +{ + meta.maintainers = with lib.maintainers; [ toastal ]; + + options = { + boot.zfs.tzpfms = { + enable = lib.mkEnableOption '' + TPM-backed ZFS encryption using tzpfms. + Supports both TPM 2.0 & TPM 1.x. + ''; + + package = lib.mkPackageOption pkgs "tzpfms" { }; + + backends = lib.mkOption { + type = + with lib.types; + nonEmptyListOf (enum [ + "TPM2" + "TPM1.X" + ]); + default = [ + "TPM2" + ]; + description = '' + TPM backends to include in for tzpfms. + ''; + }; + + datasets = lib.mkOption { + # Needs to be explicit so we can build thy systemd services + type = with lib.types; nonEmptyListOf str; + example = [ + "tank/root" + "tank/var" + ]; + description = '' + Explicit list of ZFS datasets to unlock with TPM at boot. + ''; + }; + }; + }; + + config = lib.mkIf cfg.enable { + assertions = [ + { + assertion = + config.boot.supportedFilesystems.zfs or config.boot.initrd.supportedFilesystems.zfs or false; + message = "ZFS filesystem support needs to be enabled for boot.tzpfms to work"; + } + { + assertion = initrdPools != { } -> config.boot.initrd.systemd.enable; + message = "boot.zfs.tzpfms requires boot.initrd.systemd.enable = true"; + } + { + assertion = + !(cfgZFS.requestEncryptionCredentials == true) || cfgZFS.requestEncryptionCredentials == [ ]; + message = '' + boot.zfs.requestEncryptionCredentials = true would prompt for all + encrypted dataset passphrases at boot, which conflicts with automatic + TPM unlock via tzpfms. Either set it to false, or explicitly list the + datasets that still need passphrase prompting. + ''; + } + ( + let + intersected = lib.intersectLists cfg.datasets ( + if lib.isList cfgZFS.requestEncryptionCredentials then cfgZFS.requestEncryptionCredentials else [ ] + ); + in + { + assertion = builtins.length intersected == 0; + message = '' + The following datasets are listed in both boot.zfs.tzpfms.datasets + & boot.zfs.requestEncryptionCredentials, which would cause a + passphrase prompt to block boot before tzpfms can unlock them via + TPM: + + ${lib.concatMapStringsSep "\n" (d: "• ${d}") intersected} + + Remove them from boot.zfs.requestEncryptionCredentials to allow + automatic TPM unlock. + ''; + } + ) + ]; + + environment.systemPackages = [ cfg.package ]; + + # Automatically register pools from tzpfms datasets as extraPools + boot.zfs.extraPools = pools; + + boot.initrd = lib.mkMerge [ + (lib.mkIf cfg.enable { + availableKernelModules = [ + "tpm_tis" + "tpm_crb" + ]; + }) + (lib.mkIf needsInitrd ( + lib.mkMerge [ + (lib.mkIf config.boot.initrd.systemd.enable { + systemd.extraBin = { + zfs-tpm-list = "${lib.getBin cfg.package}/bin/zfs-tpm-list"; + } + // lib.optionalAttrs (lib.elem "TPM2" cfg.backends) { + zfs-tpm2-load-key = "${lib.getBin cfg.package}/bin/zfs-tpm2-load-key"; + } + // lib.optionalAttrs (lib.elem "TPM1.X" cfg.backends) { + zfs-tpm1x-load-key = "${lib.getBin cfg.package}/bin/zfs-tpm1x-load-key"; + }; + systemd.storePaths = + lib.optional (lib.elem "TPM2" cfg.backends) pkgs.tpm2-tss + ++ lib.optional (lib.elem "TPM1.X" cfg.backends) pkgs.trousers; + systemd.services = lib.genAttrs' initrdPools (pool: { + name = "tzpfms-load-${pool}"; + value = mkTzpfmsService { + inherit pool; + mountUnits = initrdMountsByPool.${pool} or [ ]; + script = mkTzpfmsScript (datasetsByPool.${pool} or [ ]); + }; + }); + }) + ] + )) + ]; + + systemd.services = lib.genAttrs' systemPools ( + pool: + let + mnts = systemMountsByPool.${pool} or [ ]; + in + { + name = "tzpfms-load-${pool}"; + value = mkTzpfmsService { + inherit pool; + mountUnits = mnts; + script = mkTzpfmsScript (datasetsByPool.${pool} or [ ]); + }; + } + ); + }; +}