Skip to content

Commit fe34bd1

Browse files
committed
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 <[email protected]>
1 parent 3a9450b commit fe34bd1

File tree

4 files changed

+177
-9
lines changed

4 files changed

+177
-9
lines changed

example/luks-fido2.nix

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
{
2+
disko.devices = {
3+
disk = {
4+
main = {
5+
type = "disk";
6+
device = "/dev/vdb";
7+
content = {
8+
type = "gpt";
9+
partitions = {
10+
ESP = {
11+
size = "500M";
12+
type = "EF00";
13+
content = {
14+
type = "filesystem";
15+
format = "vfat";
16+
mountpoint = "/boot";
17+
mountOptions = [ "umask=0077" ];
18+
};
19+
};
20+
luks = {
21+
size = "100%";
22+
content = {
23+
type = "luks";
24+
name = "crypted";
25+
settings.allowDiscards = true;
26+
enrollFido2 = true;
27+
# Do not wait for recovery displaying and blocking formatting.
28+
enrollRecovery = false;
29+
content = {
30+
type = "filesystem";
31+
format = "ext4";
32+
mountpoint = "/";
33+
};
34+
};
35+
};
36+
};
37+
};
38+
};
39+
};
40+
};
41+
}

lib/tests.nix

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,7 @@ let
7979
extraInstallerConfig ? { },
8080
extraSystemConfig ? { },
8181
efi ? !pkgs.stdenv.hostPlatform.isRiscV64,
82+
enableCanokey ? false,
8283
postDisko ? "",
8384
testMode ? "module", # can be one of direct module cli
8485
testBoot ? true, # if we actually want to test booting or just create/mount
@@ -279,7 +280,13 @@ let
279280
(testConfigInstall ? networking.hostId) && (testConfigInstall.networking.hostId != null)
280281
) testConfigInstall.networking.hostId;
281282

282-
virtualisation.emptyDiskImages = builtins.genList (_: 4096) num-disks;
283+
virtualisation = {
284+
emptyDiskImages = builtins.genList (_: 4096) num-disks;
285+
qemu.options = lib.mkIf enableCanokey [
286+
"-device pci-ohci,id=usb-bus"
287+
"-device canokey,bus=usb-bus.0,file=/tmp/canokey-file"
288+
];
289+
};
283290

284291
# useful for debugging via repl
285292
system.build.systemToInstall = installed-system-eval;
@@ -319,6 +326,11 @@ let
319326
"if=pflash,format=raw,unit=1,readonly=on,file=${pkgs.OVMF.variables}"
320327
]
321328
''}
329+
${lib.optionalString enableCanokey ''
330+
start_command += ["-device", "pci-ohci,id=usb-bus",
331+
"-device", "canokey,bus=usb-bus.0,file=/tmp/canokey-file"
332+
]
333+
''}
322334
machine = create_machine(start_command=" ".join(start_command), **kwargs)
323335
driver.machines.append(machine)
324336
return machine

lib/types/luks.nix

Lines changed: 109 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,9 @@
88
...
99
}:
1010
let
11+
# These options will automatically generate a temporary password and remove it later on.
12+
autogeneratedPassword = config.enrollFido2;
13+
1114
keyFile =
1215
if config.settings ? "keyFile" then
1316
config.settings.keyFile
@@ -25,13 +28,25 @@ let
2528
) config.keyFile
2629
else
2730
null;
28-
keyFileArgs = ''
31+
32+
formatKeyFile =
33+
if autogeneratedPassword then ''<(set +x; echo -n "$password"; set -x)'' else keyFile;
34+
35+
generateKeyFileArgs = keyFile: ''
2936
${lib.optionalString (keyFile != null) "--key-file ${keyFile}"} \
3037
${lib.optionalString (lib.hasAttr "keyFileSize" config.settings) "--keyfile-size ${builtins.toString config.settings.keyFileSize}"} \
3138
${lib.optionalString (lib.hasAttr "keyFileOffset" config.settings) "--keyfile-offset ${builtins.toString config.settings.keyFileOffset}"} \
3239
'';
33-
cryptsetupOpen = ''
40+
41+
# This is the one used for standard one shot formatting and mounting.
42+
keyFileArgs = generateKeyFileArgs keyFile;
43+
# This is the one used for 2-staged formatting like FIDO2 and NEVER for mounting.
44+
formatKeyFileArgs = generateKeyFileArgs formatKeyFile;
45+
46+
# --token-only forces to try FIRST the token then passphrase.
47+
createOpenCommand = keyFileArgs: ''
3448
cryptsetup open "${config.device}" "${config.name}" \
49+
${lib.optionalString (config.enrollFido2 or false) "--token-only"} \
3550
${lib.optionalString (config.settings.allowDiscards or false) "--allow-discards"} \
3651
${
3752
lib.optionalString (config.settings.bypassWorkqueues or false
@@ -40,6 +55,12 @@ let
4055
${toString config.extraOpenArgs} \
4156
${keyFileArgs} \
4257
'';
58+
59+
# Use this open command when you want to open it after full enrollment, e.g. at mount time or in standard enrollments.
60+
cryptsetupOpen = createOpenCommand keyFileArgs;
61+
62+
# 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).
63+
formatCryptsetupOpen = createOpenCommand formatKeyFileArgs;
4364
in
4465
{
4566
options = {
@@ -71,10 +92,29 @@ in
7192
};
7293
askPassword = lib.mkOption {
7394
type = lib.types.bool;
74-
default = config.keyFile == null && config.passwordFile == null && (!config.settings ? "keyFile");
75-
defaultText = "true if neither keyFile nor passwordFile are set";
95+
default = config.keyFile == null && config.passwordFile == null && (!config.settings ? "keyFile") && !autogeneratedPassword;
96+
defaultText = "true if neither keyFile nor passwordFile nor enrollFido2 are set";
7697
description = "Whether to ask for a password for initial encryption";
7798
};
99+
enrollFido2 = lib.mkOption {
100+
type = lib.types.bool;
101+
default = false;
102+
description = "Whether to enroll a FIDO2 token and use it";
103+
};
104+
extraFido2EnrollArgs = lib.mkOption {
105+
type = lib.types.listOf lib.types.str;
106+
default = [ ];
107+
example = [
108+
"--fido2-parameters-in-header=false"
109+
];
110+
description = "Extra arguments to pass to `systemd-cryptenroll` when enrolling the FIDO2 device";
111+
};
112+
enrollRecovery = lib.mkOption {
113+
type = lib.types.bool;
114+
default = config.enrollFido2;
115+
defaultText = "true if fido2 is enabled";
116+
description = "Whether to enroll an automatic (keyboard layout independent) recovery passphrase with high entropy and print a QR code on screen to take it";
117+
};
78118
settings = lib.mkOption {
79119
type = lib.types.attrsOf lib.types.anything;
80120
default = { };
@@ -159,18 +199,64 @@ in
159199
echo "Passwords did not match, please try again."
160200
done
161201
''}
162-
cryptsetup -q luksFormat "${config.device}" ${toString config.extraFormatArgs} ${keyFileArgs}
202+
${lib.optionalString autogeneratedPassword ''
203+
# Generate a random throwable key that will be removed later on.
204+
set +x
205+
password=$(openssl rand -hex 32)
206+
export password
207+
set -x
208+
209+
# We have the guarantee that slot 0 needs to be deleted later on.
210+
# If the user had set its own password, we wouldn't create this variable
211+
# and the script later will not wipe the slot zero. The user keep his password.
212+
export SLOT_ZERO_TO_DELETE=true
213+
''}
214+
cryptsetup -q luksFormat "${config.device}" ${toString config.extraFormatArgs} ${formatKeyFileArgs}
163215
fi
164216
165217
if ! cryptsetup status "${config.name}" >/dev/null; then
166-
${cryptsetupOpen} --persistent
218+
${formatCryptsetupOpen} \
219+
--persistent
167220
fi
221+
168222
${toString (
169223
lib.forEach config.additionalKeyFiles (keyFile: ''
170-
cryptsetup luksAddKey "${config.device}" ${keyFile} ${keyFileArgs}
224+
cryptsetup luksAddKey "${config.device}" ${keyFile} ${formatKeyFileArgs}
171225
'')
172226
)}
173227
228+
${lib.optionalString config.enrollRecovery ''
229+
systemd-cryptenroll \
230+
--recovery-key \
231+
--unlock-key-file=${formatKeyFile} \
232+
"${config.device}"
233+
234+
read -p "Press Enter when you scanned the QR code offscreen or that the recovery key is stored securely."
235+
''}
236+
${lib.optionalString config.enrollFido2 ''
237+
wait_for_token() {
238+
echo "Waiting for FIDO2 token insertion..."
239+
240+
# Check if any FIDO2 device is available via /dev/hidraw*
241+
while true; do
242+
if ls /dev/hidraw* &>/dev/null; then
243+
echo "FIDO2 device detected."
244+
break
245+
else
246+
echo "FIDO2 device not detected, waiting..."
247+
sleep 2
248+
fi
249+
done
250+
}
251+
252+
wait_for_token
253+
systemd-cryptenroll \
254+
--fido2-device=auto \
255+
''${SLOT_ZERO_TO_DELETE:+--wipe-slot=0} \
256+
--unlock-key-file=${formatKeyFile} \
257+
${toString config.extraFido2EnrollArgs} \
258+
"${config.device}"
259+
''}
174260
${lib.optionalString (config.content != null) config.content._create}
175261
'';
176262
};
@@ -226,7 +312,12 @@ in
226312
{
227313
boot.initrd.luks.devices.${config.name} = {
228314
inherit (config) device;
315+
crypttabExtraOpts = lib.mkIf config.enrollFido2 [ "fido2-device=auto" ];
229316
} // config.settings;
317+
318+
# If FIDO2 is used, systemd stage 1 is absolutely necessary.
319+
# Should we turn this into an assertion?
320+
boot.initrd.systemd.enable = config.enrollFido2;
230321
}
231322
])
232323
++ (lib.optional (config.content != null) config.content._config);
@@ -240,7 +331,17 @@ in
240331
pkgs:
241332
[
242333
pkgs.gnugrep
243-
pkgs.cryptsetup
334+
pkgs.openssl
335+
pkgs.systemd
336+
# We make cryptsetup aware of token libraries from systemd.
337+
# We do not have a lot of nice ways to do this...
338+
(pkgs.runCommandNoCC pkgs.cryptsetup.name {
339+
nativeBuildInputs = [ pkgs.makeWrapper ];
340+
} ''
341+
mkdir -p $out/bin/
342+
makeWrapper ${pkgs.cryptsetup.bin}/bin/cryptsetup $out/bin/cryptsetup \
343+
--prefix LD_LIBRARY_PATH : ${pkgs.systemd}/lib/cryptsetup
344+
'')
244345
]
245346
++ (lib.optionals (config.content != null) (config.content._pkgs pkgs));
246347
description = "Packages";

tests/luks-fido2.nix

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
{
2+
pkgs ? import <nixpkgs> { },
3+
diskoLib ? pkgs.callPackage ../lib { },
4+
}:
5+
diskoLib.testLib.makeDiskoTest {
6+
inherit pkgs;
7+
name = "luks-fido2";
8+
disko-config = ../example/luks-fido2.nix;
9+
# This simulates a FIDO2 stick.
10+
enableCanokey = true;
11+
extraTestScript = ''
12+
machine.succeed("cryptsetup isLuks /dev/vda2");
13+
'';
14+
}

0 commit comments

Comments
 (0)