Arch Linux Full-Disk Encryption with TPM2 + LUKS Passphrase
Getting the benefits of TPM2 without fully trusting it.
Maria Nicolae,
In a full-disk encryption setup, Secure Boot and TPM2 unlocking can help mitigate certain types of attacks, like evil maid attacks and rootkits. In principle, this is not mutually exclusive with password-based authentication either, since TPM provides a mechanism to prompt the user for a PIN when unlocking the disk. That is to say, TPM unlocking is not necessarily unattended unlocking.
Such a setup, however, still rests all security on the TPM, which is undesirable if the user does not entirely trust TPM. Why might this be the case? First of all, it is known that some implementations of TPM unlocking are vulnerable to key sniffing attacks. Besides that, there is the fact that TPM's root of trust, the Storage Root Key, cannot be replaced by the user with a key that they generated themselves, meaning that TPM is treacherous and not fully under the user's control.
To address this on my own computers, I created a non-standard setup in LUKS in which unlocking the disk involves both the TPM and an LUKS passphrase prompt. That way, a compromise of the TPM would not compromise the entire full-disk encryption; there would still be the LUKS passphrase to crack, as in a "vanilla" LUKS setup without Secure Boot and TPM. Here, I take you through what I did to set this up while (re-)installing Arch Linux.
Designing the Setup
In a typical LUKS full-disk encryption setup on a UEFI system, the layout of the disk partitions is something like this:
- EFI system partition (1 GiB)
- LUKS volume (rest of the disk)
- Root volume, mounted at
/
- Root volume, mounted at
When unlocking an LUKS volume using a passphrase, a key is generated by running the passphrase through some key-derivation function. This key is not what is used to encrypt and decrypt the volume, however. Instead, it is merely used to access the volume key, an encrypted copy of which is stored in the LUKS volume's header. Similarly, in a TPM unlocking setup, the volume key is encrypted with a key stored in the TPM. Thus, to achieve the non-standard setup that I want, I needed to have the volume key be doubly encrypted, first with a passphrase, and then with the TPM's key.
LUKS does not provide any mechanism to encrypt the volume key in multiple layers like this. It does allow for multiple "keyslots", differently-encrypted copies of the volume key stored side-by-side, but this creates an OR relationship between the various factors of authentication, in which any one is sufficient on its own, rather than the AND relationship that I was after.
One way to achieve this, the first that I thought of, would be to nest two LUKS volumes:
- EFI system partition (1 GiB)
- Outer LUKS volume (rest of the disk)
- Inner LUKS volume
- Root volume, mounted at
/
- Root volume, mounted at
- Inner LUKS volume
Here, the outer volume would unlock via TPM, while the inner layer unlocks via a passphrase. This approach, however, would mean that any disk I/O with the root volume would have to go through two passes of LUKS encryption, which is bad for performance. Ideally, only the volume key would be doubly encrypted, not the contents of the volume itself.
Fortunately, LUKS allows us to "detach" a volume's header, and have separate files for the header and the bare encrypted volume itself. What I then came up with was to have the root volume be bare, with its header being stored in a separate LUKS volume:
- EFI system partition (1 GiB)
- Outer LUKS volume (64 MiB)
- Header for inner LUKS volume
- Bare inner LUKS volume (rest of the disk)
- Root volume, mounted at
/
- Root volume, mounted at
An LUKS header is 16 MiB in size, so in theory I only needed this first LUKS volume to be 32 MiB in size, but I allocated some extra space to be on the safe side.
Finally, I wanted to have swap space, and to be able to hibernate/suspend-to-disk, so I replace the root volume here with an LVM volume containing the root and swap volumes within it:
- EFI system partition (1 GiB)
- Outer LUKS volume (64 MiB)
- Header for inner LUKS volume
- Bare inner LUKS volume (rest of the disk)
- LVM volume
- Swap volume
- Root volume, mounted at
/
- LVM volume
Implementing the Setup
Implementing this setup involved three main steps. First, while installing Arch Linux, I created and formatted the disk partitions. Next, at the end of the installation process, before exiting the installation medium, I set up the two-layer unlocking process, to be done on each boot of the installed system. Finally, after I finished installing Arch Linux and booted this new installation, I set up Secure Boot and TPM2 unlocking.
(Full disclosure: I did this months ago, and did not journal the process live when I did it. Thus, to refresh my memory, I did this setup again in a virtual machine as I was writing this blog post.)
Formatting the Disk
After booting into the Arch Linux installation medium, the disk I wanted to boot from was mounted as /dev/sda
. First of all, I filled the disk with secure random bytes:
cat /dev/random > /dev/sda
I did this to minimise the amount of information that the encrypted state of the disk would give away to an attacker, since encrypted data looks like random data. If the disk had instead been filled with zeroes when I started, for example, it would be obvious which blocks had been written to. This could give away how full the disk was, and possibly even some information about file sizes and directory structure.
After this, to begin formatting the disks, I ran
fdisk /dev/sda
to enter an interactive disk partitioning dialog. From here, I created a GPT (rather than MBR) partition table and three partitions, of sizes 1 GiB, 64 MiB, and the rest of the disk respectively. For the first partition only, I changed its partition type (command t
in fdisk
) to EFI System
, from the default of Linux filesystem
. I then saved this partition table and exited the fdisk
dialog using the w
command.
Next, I formatted the partitions, including setting up LUKS and LVM. First of all, I formatted the EFI system partition as FAT32:
mkfs.fat -F 32 /dev/sda1
I then formatted /dev/sda2
as an LUKS volume, which is to be the "outer" layer of this two-layer encryption setup:
cryptsetup luksFormat /dev/sda2
Here, I was prompted to set a passphrase for the volume. Since this volume is to be unlocked by TPM in the finished setup, I set a temporary weak passphrase, confident that no attacker would gain access in the brief timespan between now and when I finished this process. In the finished setup, I did also include a second keyslot in this volume, unlocked by a strong "recovery" passphrase, for me to use in the event that the Secure Boot state broke, so that such an event would not permanently lock me out. If I had been more paranoid about security, I could have set this recovery passphrase from the very beginning, rather than a temporary weak passphrase; you can choose either option if you're setting this up yourself.
Anyways, I then unlocked this volume, using the passphrase I just set:
cryptsetup open /dev/sda2 outer
Finally, I formatted /dev/sda3
as the "inner" LUKS volume, with a detached header stored encrypted inside the outer volume:
cryptsetup luksFormat /dev/sda --header /dev/mapper/outer
This volume will be unlocked with a passphrase in the finished setup, so when this command prompted me to set a passphrase, I used the one that I wanted to have to enter on boot. I chose this passphrase to be six random words from the Electronic Frontier Foundation's list of 7776 words, giving me bits of entropy. While I simply used a random number generator on a computer to select the words, one could use six-sided dice for this, since the length of the list is a power of six () by design.
The last step in formatting the disk was to set up the LVM volumes in the inner LUKS volume. First of all, I unlocked this inner volume:
cryptsetup open /dev/sda3 --header /dev/mapper/outer cryptlvm
Next, I created the LVM volume
pvcreate /dev/mapper/cryptlvm
vgcreate RootGroup /dev/mapper/cryptlvm
and the root and swap subvolumes within:
lvcreate -L swap-size -n swap RootGroup
lvcreate -l 100%FREE -n root RootGroup
Right after this, I shrunk the root volume by 256 MiB, to allow e2scrub
to be used on the ext4 filesystem that this volume will be formatted to:
lvreduce -L -256M RootGroup/root
Finally, I formatted the volumes:
mkfs.ext4 /dev/RootGroup/root
mkswap /dev/RootGroup/swap
After formatting the volumes, I mounted them
mount /dev/RootGroup/root /mnt
mount --mkdir /dev/sda1 /mnt/boot/efi
swapon /dev/RootGroup/swap
and proceeded with the normal Arch Linux installation process.
Setting up Unlock on Boot
The Linux boot process happens inside an inital ramdisk. On Arch Linux, this initial ramdisk is generated by the mkinitcpio
script, which runs after boot-relevant system packages, such as the Linux kernel itself, are installed or upgraded. Thus, setting up unlock on boot was a matter of configuring this script.
After finishing the normal installation process, mkinitcpio
had already been run once automatically, with a default configuration, so I started by cleaning up the resulting initial ramdisks:
rm /boot/initramfs*.img
(Note that I am now chroot
ed into the newly installed system.) To begin configuring mkinitcpio
, I edited the existing /etc/mkinitcpio.d/linux.preset
file to
ALL_config="/etc/mkinitcpio.conf"
ALL_kver="/boot/vmlinuz-linux"
PRESETS=('default')
default_uki="/boot/efi/arch-linux.efi"
This configuration tells mkinitcpio
to generate a Unified Kernel Image (UKI), arch-linux.efi
, in the EFI system partition. This is a single EFI executable that contains the initial ramdisk and all of its resources. Thus, only this one file will need to be signed once Secure Boot is set up. Next, inside /etc/mkinitcpio.conf
, I set the variable HOOKS
to
HOOKS=(base systemd autodetect microcode modconf kms keyboard sd-vconsole block sd-encrypt lvm2 filesystems fsck)
The systemd
hook specifies that a systemd-based initial ramdisk is to be used, which is the only one that supports TPM unlock on boot. At boot time, the sd-encrypt
hook performs unlocking, and then the lvm2
hook opens the root and swap LVM subvolumes.
Next, I set the kernel command line by creating /etc/cmdline.d/cmd.conf
with contents
root=/dev/RootGroup/root rw
specifying the root volume to be mounted at /
. Note that there are no command-line arguments here for decryption, or for restoring from hibernation. The systemd-based initial ramdisk handles the latter automatically, and for the former, I instead specify the (two-step) unlocking process by creating the file /etc/crypttab.initramfs
with contents
outer UUID=uuid-of-/dev/sda2 none luks,tries=2147483647
cryptlvm PARTUUID=uuid-of-/dev/sda3 none luks,tries=2147483647,header=/dev/mapper/outer
This file is a table listing, in order, which unlocks of encrypted volumes are to take place during the boot process. The first column is the name that the unlocked volume will take. The second column is the block device to be unlocked; to obtain their UUIDs, I used the blkid
command. The third column is a path to the passphrase file to be used for the unlock, if specified; I left this blank, of course. The fourth and last column is options for the unlock process; I set luks
so that LUKS would be used, set tries
to the maximum value () for "unlimited" passphrase retries, and set header
to specify the detached header for /dev/sda3
.
At this point, the mkinitcpio
configuration was complete. However, I wasn't quite ready to generate the UKI just yet. There is a configuration file /etc/vconsole.conf
for the virtual console that is not present by default, but that mkinitcpio
expects to exist when generating a systemd-based initial ramdisk, even if it's empty. Thus, I created this as an empty file:
touch /etc/vconsole.conf
At last, I was ready to generate the UKI:
mkinitcpio -P
Finally, I used efibootmgr
to create a UEFI boot entry for this UKI:
efibootmgr --create --disk /dev/sda --part 1 --label "Arch Linux" --loader '\arch-linux.efi' --unicode
I then exited the chroot
environment, shut down the computer, and booted into the newly installed system.
Secure Boot
Setting up Secure Boot consisted of three main steps. First, I generated some keys, then I set up UKI signing in mkinitcpio
, and finally I enrolled the keys into the UEFI firmware for Secure Boot.
To start, I created a dedicated directory for key generation and navigated to it:
mkdir /etc/secureboot
cd /etc/secureboot
Next, I created a directory structure within this to mirror the EFI variable structure:
mkdir -p keys/PK keys/KEK keys/db keys/dbx
After key generation, this will be synchronised with the Secure Boot state to enroll the keys into the firmware. To clarify what's going on here, there are four stores of cryptographic tokens (keys or hashes) in the Secure Boot state:
- the Platform Key (PK), a single key which is the root of trust for Secure Boot,
- the Key Exchange Keys (KEK), one or more keys which are signed by the PK,
- the Signatures Database (db), containing one or more keys (signed by keys in the KEK) or hashes for EFI executables that are allowed to execute, and
- the Forbidden Signatures Database (dbx), containing one or more keys or hashes corresponding to EFI executables that are forbidden to execute.
My setup consisted of the simplest possible version of this, with only one key in the KEK and db each, and an empty/unused dbx. Next, I generated a UUID to be the "identity" for these keys of mine:
uuidgen --random > GUID
To generate the keys themselves, I wrote a Bash script genkeys
with contents
#!/bin/bash
DAYS=365
KEYS=(PK KEK db)
SIGNERS=(PK PK KEK)
GUID=$(cat GUID)
for ((i=0; i<${#KEYS[@]}; i++)); do
KEY="${KEYS[$i]}"
SIGNER="${SIGNERS[$i]}"
openssl req -newkey rsa:4096 -nodes -keyout "$KEY.key" -new -x509 -sha256 \
-days "$DAYS" -subj "/CN=Maria $KEY Key/" -out "$KEY.crt"
openssl x509 -outform DER -in "$KEY.crt" -out "$KEY.cer"
cert-to-efi-sig-list -g "$GUID" "$KEY.crt" "$KEY.esl"
sign-efi-sig-list -g "GUID" -k "$SIGNER.key" -c "$SIGNER.crt" \
"$KEY" "$KEY.esl" "keys/$KEY/$KEY.auth"
done
Here, the DAYS
variable specifies how long the keys last before expiring; I chose one year. This script uses the cert-to-efi-sig-list
and sign-efi-sig-list
commands from efitools
.
To set up UKI signing, I created a post hook in mkinitcpio
. To do this, I created the file /etc/initcpio/post/uki-sbsign
with contents
#!/usr/bin/env bash
uki="$3"
[[ -n "$uki" ]] || exit 0
keypairs=(/etc/secureboot/db.key /etc/secureboot/db.crt)
for (( i=0; i<${#keypairs[@]}; i+=2 )); do
key="${keypairs[$i]}" cert="${keypairs[(( i + 1 ))]}"
if ! sbverify --cert "$cert" "$uki" &>/dev/null; then
sbsign --key "$key" --cert "$cert" --output "$uki" "$uki"
fi
done
copied from the Arch Linux wiki. This uses the sbverify
and sbsign
commands from sbsigntools
. I then made this executable
chmod +x /etc/initcpio/post/uki-sbsign
and regenerated the UKI:
mkinitcpio -P
Finally, I enrolled these keys into Secure Boot; I've found that the exact procedure for this varies from machine to machine. In the aforementioned sbsigntools
package, the command
sbkeysync --keystore /etc/secureboot/keys --verbose
enrolls all but the Platform Key. On some machines, getting this to work requires entering Secure Boot "setup" mode by enabling Secure Boot and deleting the existing PK via the UEFI boot menu. Additionally, the command
sbkeysync --keystore /etc/secureboot/keys --verbose --pk
is meant to enroll the Platform Key, but on some machines, this didn't work, and I instead needed to copy the PK certificate into the EFI system partition
cp /etc/secureboot/PK.cer /boot/efi/
so that I could enroll it via the boot menu. After doing this, I deleted this certificate copy:
rm /boot/efi/PK.cer
After all this, I rebooted, and ran bootctl
to ensure that Secure Boot was enabled and enforcing (i.e. in "user" mode rather than "setup" mode).
TPM Unlocking
Finally, with Secure Boot up and running, I was ready to set up TPM unlocking for the "outer" layer of encryption on /dev/sda2
. After ensuring in the boot menu that a suitable TPM2 device was enabled, I ran
systemd-cryptenroll /dev/sda2 --wipe-slot=empty --tpm2-device=auto --tpm2-pcrs=7
to add TPM unlocking as an additional keyslot. Here, the --wipe-slot=empty
option ensures that no existing keyslots are wiped, and the --tpm2-pcrs=7
option makes TPM unlock if and only if the Secure Boot state matches this current state. Next, to make sure that the boot process attempts TPM unlocking, I added an option to the corresponding entry in /etc/crypttab.initramfs
, changing it to
outer UUID=uuid-of-/dev/sda2 none luks,tries=2147483647,tpm2-device=auto
and regenerated the UKI for the third and final time:
mkinitcpio -P
Finally, I added a "recovery" passphrase as another keyslot
cryptsetup luksAddKey /dev/sda2
and removed the weak temporary passphrase that I had originally set for this outer volume:
cryptsetup luksRemoveKey /dev/sda2
The recovery passphrase will be used if TPM unlocking does not occur, for example if the Secure Boot state has changed. Since I will only rarely need to use this passphrase, I did not memorise it, merely recording it in my password manager. I set this recovery passphrase to be 25 random digits and lowercase letters (giving bits of entropy), with groups of 5 characters separated by hyphens for easy reading (for example, 01234-56789-abcde-fghij-klmno
).
At this point, the setup was finished. I rebooted my computer one last time, confirming that I was only prompted for one passphrase, for the inner layer of encryption.