LUKS disk encryption with FIDO2

FIDO2 security keys offer a versatile range of user authentication options. We have explored some of these possibilities during a workshop we presented at ph0wn. This post delves deeper into setting up disk encryption with LUKS safeguarded by a security key. We also explain the underlying mechanics and highlight pitfalls to avoid.

LUKS disk encryption

LUKS is a common solution to encrypt block devices like solid-state drives in Linux ecosystems. Basically it works by leaving a header unencrypted before the encrypted devices with all the necessary information to decrypt the following device. This header contains information to derive a key used to decrypt a binary key slot area. In the key slot, a master key is then used to decrypt or encrypt the whole device. A well known tool to manage encrypted devices is cryptsetup. It allows to setup and manage encryption of devices. For example, to create a LUKS device the subcommand luksFormat can be used on a file or on a block device. This command formats completely your device, thus, it has to be used carefully:

$ cryptsetup luksFormat disk.img

WARNING!
========
This will overwrite data on disk.img irrevocably.

Are you sure? (Type 'yes' in capital letters): YES
Enter passphrase for disk.img: 
Verify passphrase:

cryptsetup requests a passphrase which will be used for device encryption and decryption. Then our file disk.img is formatted and encrypted. We can simply open it with the open command:

$ sudo cryptsetup open disk.img encrypted
Enter passphrase for disk.img:
$ lsblk
...
loop23        7:23   0    30M  0 loop
โ””โ”€encrypted 254:0    0    14M  0 crypt
...

Now, let’s see now how we can replace the passphrase by a security key.

Disk encryption with FIDO2 hmac-secret extension

FIDO2 introduces a standardized communication protocol known as Client-to-Authenticator Protocol (CTAP), which defines the exchange of information between security keys, also referred as authenticators, and the operating system or web browser. The current version is 2.1 and a new version 2.2 is published as a Review Draft. This standard is often used to authenticate users and get rid of passwords as it was already explained in one of our previous blog post.

For LUKS disk encryption, a CTAP extension called “hmac-secret” is used. This extension extends the behavior of the CTAP commands authenticatorMakeCredential and authenticatorGetAssertion, allowing to obtain a symmetric key for LUKS key slot decryption. You can verify if your authenticator supports the hmac-secret extension with the get_info.py example script of the Yubico python-fido2 library. This script uses the authenticatorGetInfo CTAP command to retrieve the authenticator information. For example, this is the output of the script when using the Ledger Nano X together with the Security key application:

$ python get_info.py
CONNECT: CtapHidDevice('/dev/hidraw7')
Product name: Ledger Nano X
Serial number: 0001
CTAPHID protocol version: 2
DEVICE INFO: Info(versions=['U2F_V2', 'FIDO_2_0'], extensions=['hmac-secret', 'txAuthSimple'], aaguid=AAGUID(fcb2bcb5-f377-078c-6994-ec24d0fe3f1e), options={'rk': True, 'up': True, 'uv': True, 'clientPin': False}, max_msg_size=1024, pin_uv_protocols=[1], max_creds_in_list=None, max_cred_id_length=None, transports=[], algorithms=None, max_large_blob=None, force_pin_change=False, min_pin_length=4, firmware_version=None, max_cred_blob_length=None, max_rpids_for_min_pin=0, preferred_platform_uv_attempts=None, uv_modality=None, certifications=None, remaining_disc_creds=None, vendor_prototype_config_commands=None)
Device does not support WINK

This indicates that the device adheres to the CTAP 2.0 standard and supports the hmac-secret extension.

During the CTAP authenticatorMakeCredential command, if a hmac-secret parameter is present, the authenticator creates a credential ID and, in addition, generates a 32-bytes of random called CredRandom. To enroll a security key to a LUKS device, systemd offers a convenient tool called systemd-cryptenroll. This tool can be used to enroll the authenticator using the hmac-secret extension. Again, this following command will wipe previous slots and thus have to be used with caution:

$ systemd-cryptenroll --fido2-device=auto --wipe-slot=all test.img
๐Ÿ” Please enter current passphrase for disk /home/luks/test.img: (press TAB for no echo)********
Initializing FIDO2 credential on security token.
๐Ÿ‘† (Hint: This might require confirmation of user presence on security token.)
๐Ÿ” Please enter security token PIN: ********                
Generating secret key on FIDO2 security token.
๐Ÿ‘† In order to allow secret key generation, please confirm presence on security token.
New FIDO2 token enrolled as key slot 1.
Wiped slot 0.

Lets look at the new LUKS header of our device after the security key enrollment:

$ cryptsetup luksDump test.img
LUKS header information
Version:       	2
Epoch:         	6
Metadata area: 	16384 [bytes]
Keyslots area: 	16744448 [bytes]
UUID:          	df8d4f98-2e1e-4342-befb-edf4bfa3e5a8
Label:         	(no label)
Subsystem:     	(no subsystem)
Flags:       	(no flags)

Data segments:
  0: crypt
	offset: 16777216 [bytes]
	length: (whole device)
	cipher: aes-xts-plain64
	sector: 4096 [bytes]

Keyslots:
  1: luks2
	Key:        512 bits
	Priority:   normal
	Cipher:     aes-xts-plain64
	Cipher key: 512 bits
	PBKDF:      pbkdf2
	Hash:       sha512
	Iterations: 1000
	Salt:       2e 93 59 1b 94 e1 df 30 2a 98 15 10 f1 5c b5 19 
	            89 65 d4 fd 2f 58 ac 02 68 8b cc 42 07 0b 99 12 
	AF stripes: 4000
	AF hash:    sha512
	Area offset:290816 [bytes]
	Area length:258048 [bytes]
	Digest ID:  0
Tokens:
  0: systemd-fido2
	fido2-credential:
	            db 76 b3 fc 9d d0 18 e5 1e ec f0 53 2b ed e9 8b
	            b7 70 73 85 fd 4f 16 d5 7c dc 21 1c 2c 8f 12 e7
	            29 f7 a1 22 05 5b e0 43 e4 45 23 55 33 88 6d 34
	            89 03 9b 2c 77 92 1a 87 6e e5 24 23 2f 40 05 e6
	fido2-salt: 95 11 36 b7 b1 93 54 42 0f 4f 79 95 4e e4 77 d1
	            f9 e0 d7 7a f1 37 fd 49 ab 04 6c f0 cd d9 7b 8a
	fido2-rp:   io.systemd.cryptsetup
	fido2-clientPin-required:
	            true
	fido2-up-required:
	            true
	fido2-uv-required:
	            false
	Keyslot:    1
Digests:
  0: pbkdf2
	Hash:       sha256
	Iterations: 326455
	Salt:       ac 17 58 50 09 95 09 c8 bc e5 fd d3 03 50 8f 98 
	            c9 76 55 2b e7 fc 45 09 d4 c8 4b ce b2 12 30 79 
	Digest:     ab 67 ee 89 09 45 4f ba 80 35 1a f0 a1 0b e0 ae 
	            8b e9 82 8f 72 7b 6a 54 b5 6a 43 91 aa 0a 6c fe

We can see we have a token associated with the key slot 0. This token has a fido2-credential, the credential id which allows to reconstruct everything needed at the security key side including the value of CredRandom. Nothing is stored on the security key, thus is has to be stored in the LUKS header.

Then to generate a secret, the command CTAP authenticatorGetAssertion is used. A shared secret between the security key and the host is obtain with computing a Diffie-Hellman key agreement between the host on the security key. The shared secret is used as a key to encrypt and authenticate a salt chosen by the host and embedded within the fido2-salt field of the LUKS header. The encrypted salt is sent to the security key which will verify the authenticity of the salt and decrypt it. Then the autenticator generates a secret output being the HMAC of the previously generated CredRandom and the salt:

output = HMAC-SHA-256(CredRandom, salt)

It is returned encrypted to the host. Once decrypted, cryptsetup uses this secret to decrypt the key slot associated. Practically, when you want to decrypt the LUKS image cryptsetup would need your authenticator and your PIN code to reconstruct the secret output:

$ sudo cryptsetup open --token-only image.img encrypted
Enter token PIN: 
Asking FIDO2 token for authentication.
๐Ÿ‘† Please confirm presence on security token to unlock.

Since a part of the secret, CredRandom is only recoverable by the security key, it is not possible to decrypt the device without it.

PIN management

CTAP employs a parameter called clientPin during the credential creation. When set to True, this indicates that a PIN code has been configured on your authenticator, and you will be prompted for this PIN for future credential generation. If you have not established a PIN code and enroll your security key without setting clientPin to True, the PIN code will never be required for device decryption, even if you configure one later. This arrangement is suitable for devices like the Ledger wallet, which necessitates a separate PIN code for device initiation. However, for other authenticators, this poses a potential issue, as anyone who steals both the device and the authenticator could decrypt your data without requiring your PIN code.

There is another issue as well. If the clientPin was set but a authenticatorGetAssertion request is made without the PIN code, CTAP 2.0 specifies:

CTAP 2.0

This implies that if the pinAuth parameter is omitted, a ‘uv’ bit is set to 0 meaning that the user verification was not made by the authenticator. However, the CTAP specification does not explicitly state whether the hmac-secret extension should return the secret based on the ‘uv’ bit. To investigate this behavior, we conducted experiments on various authenticators. We initiated the test on a Solo key running an outdated firmware version (3.0.0):

$ python get_info.py
CONNECT: CtapHidDevice('/dev/hidraw6')
Product name: SoloKeys Solo 3.0.0
Serial number: 2060469E55B9
CTAPHID protocol version: 2
DEVICE INFO: Info(versions=['U2F_V2', 'FIDO_2_0'], extensions=['hmac-secret'], aaguid=AAGUID(8876631b-e4a0-428f-5784-0ac71c9e0279), options={'rk': True, 'up': True, 'plat': False, 'clientPin': True}, max_msg_size=1200, pin_uv_protocols=[1], max_creds_in_list=None, max_cred_id_length=None, transports=[], algorithms=None, max_large_blob=None, force_pin_change=False, min_pin_length=4, firmware_version=None, max_cred_blob_length=None, max_rpids_for_min_pin=0, preferred_platform_uv_attempts=None, uv_modality=None, certifications=None, remaining_disc_creds=None, vendor_prototype_config_commands=None)
WINK sent!

This authenticator supports CTAP 2.0 with the hmac-secret extension and it has the client PIN already set. Then we enroll our key to a LUKS device as previously and we obtain the same header as before. But then in the LUKS header we patched the value fido2-clientPin-required to False. Since the header as a checksum mechanism we created a script to handle all the operation and it is available here. Then after we patched the header we can verify everything is fine:

$ cryptsetup luksDump test.img
LUKS header information
Version:        2
Epoch:          6
Metadata area:  16384 [bytes]
Keyslots area:  16744448 [bytes]
UUID:           7a8cc432-f3ef-48de-9644-74e7d6c81d6a
Label:          (no label)
Subsystem:      (no subsystem)
Flags:          (no flags)

Data segments:
  0: crypt
        offset: 16777216 [bytes]
        length: (whole device)
        cipher: aes-xts-plain64
        sector: 4096 [bytes]

Keyslots:
  1: luks2
        Key:        512 bits
        Priority:   normal
        Cipher:     aes-xts-plain64
        Cipher key: 512 bits
        PBKDF:      pbkdf2
        Hash:       sha512
        Iterations: 1000
        Salt:       b7 ed 7b 10 20 c4 d6 65 cc 59 5c 64 16 9c 1e b4 
                    22 33 63 09 a1 1f fc f4 5b 77 79 02 81 47 7d 35 
        AF stripes: 4000
        AF hash:    sha512
        Area offset:290816 [bytes]
        Area length:258048 [bytes]
        Digest ID:  0
Tokens:
  0: systemd-fido2
        fido2-credential:
                    1e 51 5b 40 ac cb 0f d0 da e3 eb 5f 20 f9 1c aa
                    c3 16 f6 3c a4 00 ad ca 21 ab 64 ef e5 a3 03 ac
                    4b 42 3b a2 a1 21 ff 04 55 14 ab e1 b8 2a 95 99
                    df d9 be 3c 43 64 db 0d 6c d0 10 00 d7 29 10 1a
                    ba 8f 87 02 00 00
        fido2-salt: bc 34 af d7 bd 50 0b 9a 8a 7f 63 51 a6 fb d3 77
                    36 34 ce 2a c0 26 e7 bf 49 b3 1b 31 d3 3b 11 46
        fido2-rp:   io.systemd.cryptsetup
        fido2-clientPin-required:
                    false
        fido2-up-required:
                    true
        fido2-uv-required:
                    false
        Keyslot:    1

The field fido2-clientPin-required is set to False. And if we try to open our device:

$ sudo cryptsetup open --token-only test.img encrypted        
Asking FIDO2 token for authentication.
๐Ÿ‘† Please confirm presence on security token to unlock.

Surprise! in this case, no PIN code is request but the device is decrypted correctly. This is a security issue as explained before if someone is able to steal you disk and you security key. This behavior is not generalized to all auhtenticators. We have tested a Yubikey having the firmware version 5.1.1 and it turned out that the key would not allow to answer the secret even if the fido2-clientPin-required is set to False.

CTAP update

The previous flaw was rectified in later CTAP versions, starting from CTAP 2.1. These updates introduced a change where the security key generates two distinct secrets during the authenticatorMakeCredential command: CredRandomWithUV and CredRandomWithoutUV. During the command execution, the authenticator determines whether to utilize CredRandomWithUV or CredRandomWithoutUV as the CredRandom for secret generation based on whether user verification was performed in the preceding steps. Let’s observe how a newer security key behaves in practice. We’ve selected a newer YubiKey 5 NFC as our example.

$ python get_info.py
CONNECT: CtapHidDevice('/dev/hidraw5')
Product name: Yubico YubiKey OTP+FIDO+CCID
Serial number: None
CTAPHID protocol version: 2
DEVICE INFO: Info(versions=['U2F_V2', 'FIDO_2_0', 'FIDO_2_1_PRE'], extensions=['credProtect', 'hmac-secret'], aaguid=AAGUID(2fc0578f-8553-48ea-b11f-ba5a8fb9213a), options={'rk': True, 'up': True, 'plat': False, 'clientPin': True, 'credentialMgmtPreview': True}, max_msg_size=1200, pin_uv_protocols=[2, 1], max_creds_in_list=8, max_cred_id_length=128, transports=['nfc', 'usb'], algorithms=[{'alg': -7, 'type': 'public-key'}, {'alg': -8, 'type': 'public-key'}], max_large_blob=None, force_pin_change=False, min_pin_length=4, firmware_version=328707, max_cred_blob_length=None, max_rpids_for_min_pin=0, preferred_platform_uv_attempts=None, uv_modality=None, certifications=None, remaining_disc_creds=None, vendor_prototype_config_commands=None)
WINK sent!

The field 'FIDO_2_1_PRE indicates that the authenticator supports partially the CTAP 2.1 protocol. Then, as previously, we enrolled our key to the disk image and we patched the LUKS header with our script and finally, we tried to open the image with cryptsetup:

$ sudo cryptsetup open --token-only test.img encrypted
Asking FIDO2 token for authentication.
๐Ÿ‘† Please confirm presence on security token to unlock.

It seems we have the same problem as before. However, the device is not open properly and if we ask cryptsetup to be more verbose we can understand the problem:

$ sudo cryptsetup open --token-only test.img encrypted --debug
Asking FIDO2 token for authentication.
๐Ÿ‘† Please confirm presence on security token to unlock.
# Trying to open keyslot 1 with token 0 (type systemd-fido2).
# Trying to open LUKS2 keyslot 1.
# Running keyslot key derivation.
# Reading keyslot area [0x47000].
# Acquiring read lock for device test.img.
# Verifying lock handle for test.img.
# Device test.img READ lock taken.
# Reusing open ro fd on device test.img
# Device test.img READ lock released.
# Verifying key from keyslot 1, digest 0.
# Digest 0 (pbkdf2) verify failed with -1.
# Releasing crypt device test.img context.
# Releasing device-mapper backend.
# Closing read only fd for test.img.
Command failed with code -2 (no permission or bad passphrase).
# Unloading systemd-fido2 token handler.

The key slot verification failed and that’s coherent with the usage of the value CredRandomWithoutUV which differs from CredRandomWithUV in CTAP 2.1 and would lead to a different secret generation.

This update is now implemented in the latest solo key firmware but some authenticators may not be upgradable and thus it is better to check the version of CTAP supported by your authenticator before setting up disk encryption.

Conclusion

FIDO2 security keys offer a convenient and secure method for unlocking LUKS encrypted disks. However, it’s crucial to understand the underlying mechanisms and potential pitfalls to ensure optimal protection. To safeguard your data, it’s essential to utilize FIDO2 security keys with the latest CTAP version and ensure proper credential creation procedures, including user verification when applicable.

Leave a Reply