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 [ ]); + }; + } + ); + }; +} diff --git a/nixos/tests/all-tests.nix b/nixos/tests/all-tests.nix index 93ec2703c3e5..db9dd1fb2561 100644 --- a/nixos/tests/all-tests.nix +++ b/nixos/tests/all-tests.nix @@ -1740,6 +1740,7 @@ in twingate = runTest ./twingate.nix; txredisapi = runTest ./txredisapi.nix; typesense = runTest ./typesense.nix; + tzpfms = runTest ./tzpfms.nix; tzupdate = runTest ./tzupdate.nix; ucarp = runTest ./ucarp.nix; udisks2 = runTest ./udisks2.nix; diff --git a/nixos/tests/tzpfms.nix b/nixos/tests/tzpfms.nix new file mode 100644 index 000000000000..8bd9131140ec --- /dev/null +++ b/nixos/tests/tzpfms.nix @@ -0,0 +1,135 @@ +{ lib, ... }: + +{ + name = "tzpfms"; + + meta = { + maintainers = with lib.maintainers; [ toastal ]; + }; + + nodes.machine = + { pkgs, ... }: + { + environment.systemPackages = [ + pkgs.jq + pkgs.parted + pkgs.tzpfms + ]; + + boot = { + initrd.systemd.enable = true; + loader = { + systemd-boot.enable = true; + timeout = 0; + efi.canTouchEfiVariables = true; + }; + supportedFilesystems = [ "zfs" ]; + zfs = { + devNodes = "/dev"; + forceImportRoot = lib.mkDefault false; + requestEncryptionCredentials = false; + }; + }; + + networking.hostId = "deadbeef"; + + virtualisation = { + emptyDiskImages = [ 1024 ]; + mountHostNixStore = true; + useBootLoader = true; + useEFIBoot = true; + tpm.enable = true; + }; + + specialisation.tzpfms-unlock.configuration = { + boot = { + kernelParams = [ + "rd.debug" + "rd.log=all" + ]; + zfs = { + devNodes = "/dev"; + requestEncryptionCredentials = false; + tzpfms = { + enable = true; + datasets = [ + "tpmpool/boot" + "tpmpool/data" + ]; + }; + }; + }; + + virtualisation.fileSystems = { + "/bootz" = { + device = "tpmpool/boot"; + fsType = "zfs"; + options = [ "zfsutil" ]; + neededForBoot = true; + }; + "/dataz" = { + device = "tpmpool/data"; + fsType = "zfs"; + options = [ "zfsutil" ]; + neededForBoot = false; + }; + }; + }; + }; + + testScript = /* python */ '' + datasets = ["boot", "data"] + + machine.start(allow_reboot=True) + + machine.wait_for_unit("multi-user.target") + + machine.succeed("test -e /dev/tpm0") + machine.succeed("test -e /dev/tpmrm0") + + machine.succeed("parted --script /dev/vdb mklabel gpt") + machine.succeed("parted --script /dev/vdb -- mkpart primary 1M 100%") + + with subtest("Create encrypted ZFS datasets"): + machine.succeed("zpool create -O mountpoint=none tpmpool /dev/vdb1") + for ds in datasets: + machine.succeed("echo aoeuhtns | zfs create -o encryption=aes-128-gcm -o keyformat=passphrase -o mountpoint=/" + ds + "z tpmpool/" + ds) + + with subtest("Wrap keys to TPM with backup"): + for ds in datasets: + machine.succeed("printf '\\n\\n' | zfs-tpm2-change-key -b /tmp/tzpfms-backup-" + ds + ".key tpmpool/" + ds) + machine.succeed("test -f /tmp/tzpfms-backup-" + ds + ".key") + list = machine.succeed("zfs-tpm-list -H") + for ds in datasets: + assert "tpmpool/" + ds in list + + with subtest("Verify backup keys work"): + for ds in datasets: + machine.succeed("zfs unmount tpmpool/" + ds + " || true") + machine.succeed("zfs unload-key tpmpool/" + ds) + machine.succeed("zfs load-key tpmpool/" + ds + "