nixos/tzpfms: init (#517991)

This commit is contained in:
Adam C. Stephens
2026-06-15 18:03:18 +00:00
committed by GitHub
4 changed files with 384 additions and 0 deletions
+1
View File
@@ -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
+247
View File
@@ -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 [ ]);
};
}
);
};
}
+1
View File
@@ -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;
+135
View File
@@ -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 + " </tmp/tzpfms-backup-" + ds + ".key")
assert "available" in machine.succeed("zfs get -Ho value keystatus tpmpool/" + ds)
with subtest("Verify loading key"):
# Assertion that the key is loadable, as suggested in the manpage
for ds in datasets:
assert "OK" in machine.succeed("zfs-tpm2-load-key -n tpmpool/" + ds)
with subtest("Switch to tzpfms configuration & reboot"):
machine.succeed("zpool export tpmpool || true")
# Set the specialization as the default boot entry
entry_id = machine.succeed("bootctl list --json=short | jq -r '.[] | select(.title | test(\"tzpfms-unlock\")) | .id'")
assert id != "", "Missing boot entry"
machine.succeed("bootctl set-default " + entry_id)
machine.succeed("sync")
machine.reboot()
with subtest("Verify automatic TPM unlock at boot"):
machine.wait_for_unit("multi-user.target")
for ds in datasets:
status = machine.succeed("zfs get -Ho value keystatus tpmpool/" + ds).strip()
print("tpmpool/" + ds + ": " + status)
assert status == "available", ds + " key must be auto-loaded"
'';
}