feat: add FIDO2 & recovery support for LUKS

This introduces what has been described in
https://0pointer.net/blog/unlocking-luks2-volumes-with-tpm2-fido2-pkcs11-security-hardware-on-systemd-248.html.

That is: support for FIDO2 and recovery passphrases and their boot
mechanism.

Testing is hard right now because of canokey-qemu being broken and U2F
is not a valid replacement for FIDO2…

I tried to keep as much as possible the previous behavior and make it
possible to mix FIDO2 and normal passphrases or key files without any
problem.

PIV support is out of scope for this change but can easily be added.

Signed-off-by: Raito Bezarius <masterancpp@gmail.com>
This commit is contained in:
Raito Bezarius
2026-04-19 15:26:35 +02:00
parent 5ad85c82cc
commit b02541f2d0
3 changed files with 192 additions and 18 deletions
+41
View File
@@ -0,0 +1,41 @@
{
disko.devices = {
disk = {
main = {
type = "disk";
device = "/dev/vdb";
content = {
type = "gpt";
partitions = {
ESP = {
size = "500M";
type = "EF00";
content = {
type = "filesystem";
format = "vfat";
mountpoint = "/boot";
mountOptions = [ "umask=0077" ];
};
};
luks = {
size = "100%";
content = {
type = "luks";
name = "crypted";
settings.allowDiscards = true;
enrollFido2 = true;
# Do not wait for recovery displaying and blocking formatting.
enrollRecovery = false;
content = {
type = "filesystem";
format = "ext4";
mountpoint = "/";
};
};
};
};
};
};
};
};
}
+13 -1
View File
@@ -80,6 +80,7 @@ let
extraInstallerConfig ? { }, extraInstallerConfig ? { },
extraSystemConfig ? { }, extraSystemConfig ? { },
efi ? !pkgs.stdenv.hostPlatform.isRiscV64, efi ? !pkgs.stdenv.hostPlatform.isRiscV64,
enableCanokey ? false,
postDisko ? "", postDisko ? "",
testMode ? "module", # can be one of direct module cli testMode ? "module", # can be one of direct module cli
testBoot ? true, # if we actually want to test booting or just create/mount testBoot ? true, # if we actually want to test booting or just create/mount
@@ -288,7 +289,13 @@ let
(testConfigInstall ? networking.hostId) && (testConfigInstall.networking.hostId != null) (testConfigInstall ? networking.hostId) && (testConfigInstall.networking.hostId != null)
) testConfigInstall.networking.hostId; ) testConfigInstall.networking.hostId;
virtualisation.emptyDiskImages = builtins.genList (_: 4096) num-disks; virtualisation = {
emptyDiskImages = builtins.genList (_: 4096) num-disks;
qemu.options = lib.mkIf enableCanokey [
"-device pci-ohci,id=usb-bus"
"-device canokey,bus=usb-bus.0,file=/tmp/canokey-file"
];
};
# useful for debugging via repl # useful for debugging via repl
system.build.systemToInstall = installed-system-eval; system.build.systemToInstall = installed-system-eval;
@@ -329,6 +336,11 @@ let
"if=pflash,format=raw,unit=1,readonly=on,file=${pkgs.OVMF.variables}" "if=pflash,format=raw,unit=1,readonly=on,file=${pkgs.OVMF.variables}"
] ]
''} ''}
${lib.optionalString enableCanokey ''
start_command += ["-device", "pci-ohci,id=usb-bus",
"-device", "canokey,bus=usb-bus.0,file=/tmp/canokey-file"
]
''}
machine = create_machine(start_command=" ".join(start_command), **kwargs) machine = create_machine(start_command=" ".join(start_command), **kwargs)
driver.machines.append(machine) driver.machines.append(machine)
return machine return machine
+138 -17
View File
@@ -8,6 +8,9 @@
... ...
}: }:
let let
# These options will automatically generate a temporary password and remove it later on.
autogeneratedPassword = config.enrollFido2;
keyFile = keyFile =
if config.settings ? "keyFile" then if config.settings ? "keyFile" then
config.settings.keyFile config.settings.keyFile
@@ -25,21 +28,49 @@ let
) config.keyFile ) config.keyFile
else else
null; null;
keyFileArgs = ''
formatKeyFile =
if autogeneratedPassword then ''<(set +x; echo -n "$password"; set -x)'' else keyFile;
generateKeyFileArgs = keyFile: ''
${lib.optionalString (keyFile != null) "--key-file ${keyFile}"} \ ${lib.optionalString (keyFile != null) "--key-file ${keyFile}"} \
${lib.optionalString (lib.hasAttr "keyFileSize" config.settings) "--keyfile-size ${builtins.toString config.settings.keyFileSize}"} \ ${lib.optionalString (lib.hasAttr "keyFileSize" config.settings) "--keyfile-size ${builtins.toString config.settings.keyFileSize}"} \
${lib.optionalString (lib.hasAttr "keyFileOffset" config.settings) "--keyfile-offset ${builtins.toString config.settings.keyFileOffset}"} \ ${lib.optionalString (lib.hasAttr "keyFileOffset" config.settings) "--keyfile-offset ${builtins.toString config.settings.keyFileOffset}"} \
''; '';
cryptsetupOpen = ''
cryptsetup open "${config.device}" "${config.name}" \ # This is the one used for standard one shot formatting and mounting.
${lib.optionalString (config.settings.allowDiscards or false) "--allow-discards"} \ keyFileArgs = generateKeyFileArgs keyFile;
${ # This is the one used for 2-staged formatting like FIDO2 and NEVER for mounting.
lib.optionalString (config.settings.bypassWorkqueues or false formatKeyFileArgs = generateKeyFileArgs formatKeyFile;
) "--perf-no_read_workqueue --perf-no_write_workqueue"
} \ # --token-only forces to try FIRST the token then passphrase.
${toString config.extraOpenArgs} \ createOpenCommand =
${keyFileArgs} \ {
''; keyFileArgs,
tokenType ? null,
}:
''
cryptsetup open "${config.device}" "${config.name}" \
${lib.optionalString (tokenType != null) "--token-type ${tokenType}"} \
${lib.optionalString (config.settings.allowDiscards or false) "--allow-discards"} \
${
lib.optionalString (config.settings.bypassWorkqueues or false
) "--perf-no_read_workqueue --perf-no_write_workqueue"
} \
${toString config.extraOpenArgs} \
${keyFileArgs} \
'';
# Use this open command when you want to open it after full enrollment, e.g. at mount time or in standard enrollments.
cryptsetupOpen = createOpenCommand {
inherit keyFileArgs;
tokenType = if config.enrollFido2 then "systemd-fido2" else null;
};
# Use this open command when you want to open it immediately after the formatting and before the stage 2 process is finished (i.e. the wipe slot).
formatCryptsetupOpen = createOpenCommand {
keyFileArgs = formatKeyFileArgs;
};
in in
{ {
options = { options = {
@@ -71,10 +102,33 @@ in
}; };
askPassword = lib.mkOption { askPassword = lib.mkOption {
type = lib.types.bool; type = lib.types.bool;
default = config.keyFile == null && config.passwordFile == null && (!config.settings ? "keyFile"); default =
defaultText = "true if neither keyFile nor passwordFile are set"; config.keyFile == null
&& config.passwordFile == null
&& (!config.settings ? "keyFile")
&& !autogeneratedPassword;
defaultText = "true if neither keyFile nor passwordFile nor enrollFido2 are set";
description = "Whether to ask for a password for initial encryption"; description = "Whether to ask for a password for initial encryption";
}; };
enrollFido2 = lib.mkOption {
type = lib.types.bool;
default = false;
description = "Whether to enroll a FIDO2 token and use it";
};
extraFido2EnrollArgs = lib.mkOption {
type = lib.types.listOf lib.types.str;
default = [ ];
example = [
"--fido2-parameters-in-header=false"
];
description = "Extra arguments to pass to `systemd-cryptenroll` when enrolling the FIDO2 device";
};
enrollRecovery = lib.mkOption {
type = lib.types.bool;
default = config.enrollFido2;
defaultText = "true if fido2 is enabled";
description = "Whether to enroll an automatic (keyboard layout independent) recovery passphrase with high entropy and print a QR code on screen to take it";
};
settings = lib.mkOption { settings = lib.mkOption {
type = lib.types.attrsOf lib.types.anything; type = lib.types.attrsOf lib.types.anything;
default = { }; default = { };
@@ -159,18 +213,67 @@ in
echo "Passwords did not match, please try again." echo "Passwords did not match, please try again."
done done
''} ''}
cryptsetup -q luksFormat "${config.device}" ${toString config.extraFormatArgs} ${keyFileArgs} ${lib.optionalString autogeneratedPassword ''
# Generate a random throwable key that will be removed later on.
set +x
password=$(openssl rand -hex 32)
export password
set -x
# We have the guarantee that slot 0 needs to be deleted later on.
# If the user had set its own password, we wouldn't create this variable
# and the script later will not wipe the slot zero. The user keep his password.
export SLOT_ZERO_TO_DELETE=true
''}
cryptsetup -q luksFormat "${config.device}" ${toString config.extraFormatArgs} ${formatKeyFileArgs}
fi fi
if ! cryptsetup status "${config.name}" >/dev/null; then if ! cryptsetup status "${config.name}" >/dev/null; then
${cryptsetupOpen} --persistent ${formatCryptsetupOpen} \
--persistent
fi fi
${toString ( ${toString (
lib.forEach config.additionalKeyFiles (keyFile: '' lib.forEach config.additionalKeyFiles (keyFile: ''
cryptsetup luksAddKey "${config.device}" ${keyFile} ${keyFileArgs} cryptsetup luksAddKey "${config.device}" ${keyFile} ${formatKeyFileArgs}
'') '')
)} )}
${lib.optionalString config.enrollRecovery ''
systemd-cryptenroll \
--recovery-key \
--unlock-key-file=${formatKeyFile} \
"${config.device}"
if [ -z ''${IN_DISKO_TEST+x} ]; then
set +x; read -p "Press Enter when you scanned the QR code offscreen or that the recovery key is stored securely."; set -x
fi
''}
${lib.optionalString config.enrollFido2 ''
wait_for_token() {
set +f
echo "Waiting for FIDO2 token insertion..."
# Check if any FIDO2 device is available via /dev/hidraw*
while true; do
if ls /dev/hidraw* &>/dev/null; then
echo "FIDO2 device detected."
break
else
echo "FIDO2 device not detected, waiting..."
sleep 2
fi
done
set -f
}
wait_for_token
systemd-cryptenroll \
--fido2-device=auto \
''${SLOT_ZERO_TO_DELETE:+--wipe-slot=0} \
--unlock-key-file=${formatKeyFile} \
${toString config.extraFido2EnrollArgs} \
"${config.device}"
''}
${lib.optionalString (config.content != null) config.content._create} ${lib.optionalString (config.content != null) config.content._create}
''; '';
}; };
@@ -227,8 +330,13 @@ in
{ {
boot.initrd.luks.devices.${config.name} = { boot.initrd.luks.devices.${config.name} = {
inherit (config) device; inherit (config) device;
crypttabExtraOpts = lib.mkIf config.enrollFido2 [ "fido2-device=auto" ];
} }
// config.settings; // config.settings;
# If FIDO2 is used, systemd stage 1 is absolutely necessary.
# Should we turn this into an assertion?
boot.initrd.systemd.enable = lib.mkIf config.enrollFido2 true;
} }
]) ])
++ (lib.optional (config.content != null) config.content._config); ++ (lib.optional (config.content != null) config.content._config);
@@ -242,7 +350,20 @@ in
pkgs: pkgs:
[ [
pkgs.gnugrep pkgs.gnugrep
pkgs.cryptsetup pkgs.openssl
pkgs.systemd
# We make cryptsetup aware of token libraries from systemd.
# We do not have a lot of nice ways to do this...
(pkgs.runCommandNoCC pkgs.cryptsetup.name
{
nativeBuildInputs = [ pkgs.makeWrapper ];
}
''
mkdir -p $out/bin/
makeWrapper ${pkgs.cryptsetup.bin}/bin/cryptsetup $out/bin/cryptsetup \
--prefix LD_LIBRARY_PATH : ${pkgs.systemd}/lib/cryptsetup
''
)
] ]
++ (lib.optionals (config.content != null) (config.content._pkgs pkgs)); ++ (lib.optionals (config.content != null) (config.content._pkgs pkgs));
description = "Packages"; description = "Packages";