Skip to content

Commit 47903ad

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 47903ad

File tree

4 files changed

+182
-9
lines changed

4 files changed

+182
-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: 114 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, tokenType ? null }: ''
3448
cryptsetup open "${config.device}" "${config.name}" \
49+
${lib.optionalString (tokenType != null) "--token-type ${tokenType}"} \
3550
${lib.optionalString (config.settings.allowDiscards or false) "--allow-discards"} \
3651
${
3752
lib.optionalString (config.settings.bypassWorkqueues or false
@@ -40,6 +55,17 @@ 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 {
61+
inherit keyFileArgs;
62+
tokenType = if config.enrollFido2 then "systemd-fido2" else null;
63+
};
64+
65+
# 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).
66+
formatCryptsetupOpen = createOpenCommand {
67+
keyFileArgs = formatKeyFileArgs;
68+
};
4369
in
4470
{
4571
options = {
@@ -71,10 +97,29 @@ in
7197
};
7298
askPassword = lib.mkOption {
7399
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";
100+
default = config.keyFile == null && config.passwordFile == null && (!config.settings ? "keyFile") && !autogeneratedPassword;
101+
defaultText = "true if neither keyFile nor passwordFile nor enrollFido2 are set";
76102
description = "Whether to ask for a password for initial encryption";
77103
};
104+
enrollFido2 = lib.mkOption {
105+
type = lib.types.bool;
106+
default = false;
107+
description = "Whether to enroll a FIDO2 token and use it";
108+
};
109+
extraFido2EnrollArgs = lib.mkOption {
110+
type = lib.types.listOf lib.types.str;
111+
default = [ ];
112+
example = [
113+
"--fido2-parameters-in-header=false"
114+
];
115+
description = "Extra arguments to pass to `systemd-cryptenroll` when enrolling the FIDO2 device";
116+
};
117+
enrollRecovery = lib.mkOption {
118+
type = lib.types.bool;
119+
default = config.enrollFido2;
120+
defaultText = "true if fido2 is enabled";
121+
description = "Whether to enroll an automatic (keyboard layout independent) recovery passphrase with high entropy and print a QR code on screen to take it";
122+
};
78123
settings = lib.mkOption {
79124
type = lib.types.attrsOf lib.types.anything;
80125
default = { };
@@ -159,18 +204,64 @@ in
159204
echo "Passwords did not match, please try again."
160205
done
161206
''}
162-
cryptsetup -q luksFormat "${config.device}" ${toString config.extraFormatArgs} ${keyFileArgs}
207+
${lib.optionalString autogeneratedPassword ''
208+
# Generate a random throwable key that will be removed later on.
209+
set +x
210+
password=$(openssl rand -hex 32)
211+
export password
212+
set -x
213+
214+
# We have the guarantee that slot 0 needs to be deleted later on.
215+
# If the user had set its own password, we wouldn't create this variable
216+
# and the script later will not wipe the slot zero. The user keep his password.
217+
export SLOT_ZERO_TO_DELETE=true
218+
''}
219+
cryptsetup -q luksFormat "${config.device}" ${toString config.extraFormatArgs} ${formatKeyFileArgs}
163220
fi
164221
165222
if ! cryptsetup status "${config.name}" >/dev/null; then
166-
${cryptsetupOpen} --persistent
223+
${formatCryptsetupOpen} \
224+
--persistent
167225
fi
226+
168227
${toString (
169228
lib.forEach config.additionalKeyFiles (keyFile: ''
170-
cryptsetup luksAddKey "${config.device}" ${keyFile} ${keyFileArgs}
229+
cryptsetup luksAddKey "${config.device}" ${keyFile} ${formatKeyFileArgs}
171230
'')
172231
)}
173232
233+
${lib.optionalString config.enrollRecovery ''
234+
systemd-cryptenroll \
235+
--recovery-key \
236+
--unlock-key-file=${formatKeyFile} \
237+
"${config.device}"
238+
239+
read -p "Press Enter when you scanned the QR code offscreen or that the recovery key is stored securely."
240+
''}
241+
${lib.optionalString config.enrollFido2 ''
242+
wait_for_token() {
243+
echo "Waiting for FIDO2 token insertion..."
244+
245+
# Check if any FIDO2 device is available via /dev/hidraw*
246+
while true; do
247+
if ls /dev/hidraw* &>/dev/null; then
248+
echo "FIDO2 device detected."
249+
break
250+
else
251+
echo "FIDO2 device not detected, waiting..."
252+
sleep 2
253+
fi
254+
done
255+
}
256+
257+
wait_for_token
258+
systemd-cryptenroll \
259+
--fido2-device=auto \
260+
''${SLOT_ZERO_TO_DELETE:+--wipe-slot=0} \
261+
--unlock-key-file=${formatKeyFile} \
262+
${toString config.extraFido2EnrollArgs} \
263+
"${config.device}"
264+
''}
174265
${lib.optionalString (config.content != null) config.content._create}
175266
'';
176267
};
@@ -226,7 +317,12 @@ in
226317
{
227318
boot.initrd.luks.devices.${config.name} = {
228319
inherit (config) device;
320+
crypttabExtraOpts = lib.mkIf config.enrollFido2 [ "fido2-device=auto" ];
229321
} // config.settings;
322+
323+
# If FIDO2 is used, systemd stage 1 is absolutely necessary.
324+
# Should we turn this into an assertion?
325+
boot.initrd.systemd.enable = config.enrollFido2;
230326
}
231327
])
232328
++ (lib.optional (config.content != null) config.content._config);
@@ -240,7 +336,17 @@ in
240336
pkgs:
241337
[
242338
pkgs.gnugrep
243-
pkgs.cryptsetup
339+
pkgs.openssl
340+
pkgs.systemd
341+
# We make cryptsetup aware of token libraries from systemd.
342+
# We do not have a lot of nice ways to do this...
343+
(pkgs.runCommandNoCC pkgs.cryptsetup.name {
344+
nativeBuildInputs = [ pkgs.makeWrapper ];
345+
} ''
346+
mkdir -p $out/bin/
347+
makeWrapper ${pkgs.cryptsetup.bin}/bin/cryptsetup $out/bin/cryptsetup \
348+
--prefix LD_LIBRARY_PATH : ${pkgs.systemd}/lib/cryptsetup
349+
'')
244350
]
245351
++ (lib.optionals (config.content != null) (config.content._pkgs pkgs));
246352
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)