#Measured Debian Boot with TPM 2.0 and UEFI

I travel a lot. (Well, at least in the pre-COVID era, I did.) This means I drag my Thinkpad X1 Carbon gen 6 laptop to many places, and often leave it unattended in hotel rooms. While my data is all encrypted-at-rest, getting access to the data when the machine is first powered on necessarily involves running some code that is neither encrypted nor authenticated: namely, the bootloader and enough of the operating system kernel to decrypt and mount volumes.

Furthermore, this process requires me to type in a passphrase every time I boot the machine, which exposes the passphrase to anyone observing surreptitiously, and is in general an annoyance.

To increase the complexity of someone instrumenting my laptop without my knowledge and in doing so gaining access to secrets as I use the compromised machine unawares, I decided I needed to explore the use of trusted computing technologies to protect my installation from tampering.

Measured boot

There are a number of technologies that can reduce (but of course not eliminate) the attack surface of a machine. For this purpose, I am limiting my evaluation of technologies to those that protect the boot sequence from most kinds of tampering. Other measures are required for runtime protection against zero-day remote exploits, physical attacks that employ USB DMA, tire irons, etc., as well as some advanced kinds of physical tampering.

It's a wrench!

My adversary is someone who intends to tamper with my machine without my knowledge, with the purposes of either making off with some information or instrumenting the machine such that information encrypted-at-rest is revealed to the attacker out-of-band. There's not a lot of value to me in tamper prevention: once someone's screwed with my machine, I am unlikely to trust it again regardless. My main goal is tamper detection. I thus settled on measured boot, which measures the sequence of executable code launched during the boot process in a way that is highly resistant to forgery: at the end of this process, this measurement can be used to verify that the machine has not been tampered with, as well as to unlock disk encryption keys that can be used to mount volumes without requiring the user enter a passphrase.

Juniper has a short page outlining the differences between measured boot and secure boot.

Requirements

From various places (see references for a list of such resources) I managed to piece together a working measured boot that unseals a LUKS decryption passphrase for the root partition on a trusted Debian bullseye installation. This provides both tamper detection (machine won't boot automatically if the passphrase cannot be unsealed) and protection for data-at-rest (via the use of dm-crypt for the root partition).

I had a few requirements going into this project:

Solution overview

While it required a lot of experimentation, the final implementation is actually fairly simple. The basics are:

Prerequisites

First, you'll need an encrypted root partition. This guide assumes you have an existing LUKS-managed encrypted root partition (with no separate /boot partition) and that you use grub2 configured with GRUB_ENABLE_CRYPTODISK=y that prompts for a passphrase when the bootloader first launches. You might also be using efistub with the kernel and initrd on the EFI partition and the kernel command line parameters in the EFI boot manager configuration, but you will still have an encrypted root. Your encrypted partition might conceal an ext4-formatted volume or an LVM physical volume, and it might be atop a software RAID array: none of these variants should impact the general approach, though some of the details may vary.

To proceed, you will need to install the following Debian packages and their dependencies:

I'm sure there's a way to do this with dracut in place of initramfs-tools, but you'll need to figure it out yourself.

Preparation

Activate the TPM

Enter your machine's BIOS settings and make sure a TPM supporting the 2.0 spec is active. If you have a discrete TPM, that is probably the best choice; otherwise, a firmware TPM such as Intel's PTT (part of the dreaded Management Engine suite) will do. Reboot into Linux and verify it is working by running tpm2_pcrread as root.

Determine PCRs to include

There doesn't seem to be a whole lot of information about this available. The problem is likely that the PCRs are platform-specific, combined with the relatively sparse use of trusted computing overall among desktop Linux users. Example listings I've found are all similar to the following:

PCR Measured entity
0 BIOS
1 BIOS configuration
2 Option ROMs
3 Option ROM configuration
4 MBR (master boot record)
5 MBR configuration
6 State transitions and wake events
7 Platform manufacturer specific measurements
8–15 Static operating system
16 Debug
23 Application support

But this exact list appears in several places, and so might be cargo cult. I took an experimental approach: dumping the PCRs to a file with tpm2_pcrread, and then rebooting and otherwise screwing with the configuration several times (warm boot; hibernate and resume; boot from shutdown; boot into Windows and then warm reboot back into Linux; sleep and wake up; change display brightness) to find out which ones didn't change. For my X1C, that turned out to be all of the non-zero registers below 16 (0-7). Upon reading the TCG's definition of PCR 6, I removed that one from consideration as I explicitly want resume-from-hibernate to be treated the same as initial boot.

Keyscript

Now that you have the set of target PCRs, you'll want to create a script that the boot sequence will use to unseal the passphrase that we'll later add to the TPM. I created /etc/measuredboot and placed a script there called unseal_key_or_prompt:

#! /bin/sh

exec 3>&1 1>&2

if [ "$CRYPTTAB_TRIED" = 0 ]; then
        if ! tpm2_unseal --tcti device:/dev/tpm0 -c 0x81000000 -p pcr:sha256:0,1,2,3,4,5,7 1>&3 3>&-; then
                echo 'TPM unseal of LUKS key failed'
                exit 1
        fi
else
        echo "Decryption try $CRYPTTAB_TRIED"
        keyscript="/lib/cryptsetup/askpass"
        keyscriptarg="Please unlock disk $CRYPTTAB_NAME: "
        exec "$keyscript" "$keyscriptarg" 1>&3 3>&-
fi

If you're not an sh afficionado, the exec 3>&1 1>&2 line duplicates stdin to file descriptor 3 and then duplicates stderr over stdin. The reason for this is to avoid anything going to stdout that isn't a passphrase, such as a warning or error. You'll note that stdout is redirected back to its original destination on two subsequent lines: the tpm2_unseal command and the askpass invocation on fallback.

Other things to note:

Preparing the initrd

To get the keyscript pulled into the initrd, the only thing you need to do is to reference it from /etc/crypttab, e.g.:

mylvm UUID=6a84f43d-03ed-41b7-9dee-d5d2cdc0b474 none luks,keyscript=/etc/measuredboot/unseal_key_or_prompt

Running update-initramfs -u will pull the script in and put it at the same location in the initrd. Make sure to remove any key you might have referenced in the third field or you will soon place an initrd with a secret key onto your EFI partition in the clear!

Next, you'll need to create an initramfs-tools hook to copy the right tools, libraries, and kernel modules to the initrd. I created a script called measuredboot in /etc/initramfs-tools/hooks:

#! /bin/sh
PREREQ=""
prereqs()
{
        echo "$PREREQ"
}

case $1 in
        prereqs)
                prereqs
                exit 0
                ;;
esac

. /usr/share/initramfs-tools/hook-functions

copy_exec /usr/bin/tpm2_unseal /usr/bin
copy_exec /usr/lib/x86_64-linux-gnu/libtss2-tcti-device.so.0 /usr/lib/x86_64-linux-gnu
force_load tpm_tis
force_load tpm_crb

The top part of this script (through the line that sources hook-functions) is boilerplate. The important lines for us are the last four:

The list of modules came from examining the output of lsmod on my running system to include whichever tpm-related modules were not prerequisites for others. I didn't investigate any further than noting that modinfo has no interesting description for either.

To put the final touches on the initrd, run the update command:

update-initramfs -u

which will regenerate the initrd for the currently running kernel.

Create the unified kernel blob

The UEFI boot manager is going to measure whatever it directly loads: for installations using grub, this means the grub EFI bootloader. For an efistub installation, this means the compressed kernel. Notably, neither includes the initrd or the kernel command line. This gap is wide enough to drive a tank through. Thankfully, there is an easy solution: building your own EFI application comprising all three entities plus a stub that knows how to tease it all apart when executed. Thankfully, systemd comes with such a stub, and notably it does not require you to install systemd-boot itself to your EFI partition. Simply verify that you have a file called /usr/lib/systemd/boot/efi/linuxx64.efi.stub (which on my system comes from the systemd package) and you should be good to go.

I use the following script to generate my unified kernel blob into the existing debian subdirectory on my EFI partition:

#! /bin/bash

set -e

echo "root=UUID=$(sudo blkid -s UUID -o value /dev/mylvm/root) rootfstype=ext4 add_efi_memmap \
ro cryptdevice=UUID=6a84f43d-03ed-41b7-9dee-d5d2cdc0b474:lvm" >/tmp/kernel-command-line.txt

objcopy \
    --add-section .osrel="/usr/lib/os-release" --change-section-vma .osrel=0x20000 \
    --add-section .cmdline="/tmp/kernel-command-line.txt" --change-section-vma .cmdline=0x30000 \
    --add-section .linux="/vmlinuz" --change-section-vma .linux=0x2000000 \
    --add-section .initrd="/initrd.img" --change-section-vma .initrd=0x3000000 \
    /usr/lib/systemd/boot/efi/linuxx64.efi.stub /boot/efi/EFI/debian/linux.efi

You will need to modify several things in the line that generates the kernel command line:

Other things to note:

Add an EFI boot entry

The final step before rebooting will be to add a new boot entry to your EFI. You should need to do this only once, since this boot entry simply chains to the self-contained blob you just created.

efibootmgr --disk /dev/nvme0n1p1 -c -L "Debian (unified)" -l '\EFI\debian\linux.efi'

The parameter to --disk is the device for your EFI partition.

Reboot!

Now you should reboot using this boot entry. This may require you to enter your machine's boot device selection menu, or you can use efibootmgr to pre-select the new entry for the next boot. If you're successful, you'll notice in the dmesg output (you do get dmesg output, right?) that the automatic unsealing of the LUKS passphrase will fail and that you'll then be prompted for the passphrase:

Decryption try 1
🔐 Please unlock disk mylvm:  (press TAB for no echo)

Enter the passphrase and complete the boot process. You are now ready to set up the TPM to automatically decrypt your drive on subsequent boots.

Create a binary LUKS passphrase

You could use your typeable passphrase for this, but it's easy enough to create a new binary passphrase for your disk. My TPM is limited to 128 octets per sealed entity, so I created a 128-octet binary passphrase in a directory on my root partition readable only by root, and then added it to the LUKS header:

dd if=/dev/urandom of=/etc/keys/luks-tpm.key bs=128 count=1
cryptsetup luksAddKey /dev/nvme0n1p5 /etc/keys/luks-tpm.key

where /dev/nvme0n1p5 is the device node for my LUKS partition. In the second step, you'll be prompted to enter an existing passphrase.

Seal the passphrase

The final step is to seal this new passphrase to the current set of PCR values for registers you identified early on as stable across boots. I have a script that does this, which I'll explain in as much detail as I understand it. If you are a trusted computing expert, I would love to hear if there is a better way to do this.

#! /bin/bash

set -e

mkdir -m 0700 /tmp/tpm2
cd /tmp/tpm2

tpm2_createpolicy --policy-pcr -l sha256:0,1,2,3,4,5,7 -L policy.digest
tpm2_createprimary -C e -g sha256 -G rsa -c primary.context
tpm2_create -g sha256 -u obj.pub -r obj.priv -C primary.context -L policy.digest \
    -a "noda|adminwithpolicy|fixedparent|fixedtpm" -i /etc/keys/luks-tpm.key
tpm2_load -C primary.context -u obj.pub -r obj.priv -c load.context
tpm2_evictcontrol -C o -c 0x81000000 || true
tpm2_evictcontrol -C o -c load.context 0x81000000

cd -
rm -rf /tmp/tpm2

tpm2_getcap handles-persistent
tpm2_readpublic -c 0x81000000
tpm2_unseal -c 0x81000000 -p pcr:sha256:0,1,2,3,4,5,7 | hd

The remaining commands delete all the ephemeral state required to load the sealed passphrase into the TPM, and then demonstrate that the key can be unsealed. You may wish to compare the output of the final command to the hex dump of the input passphrase at /etc/keys/luks-tpm.key.

All set

Reboot and make sure the root filesystem is mounted without requiring passphrase entry. If this fails, you'll need to track down the cause, which will likely wind up requiring tweaks to the set of modules or libraries that need to be installed into the initrd or to the set of PCR values required to match by the policy.

Important usage notes

Security considerations

To do

References