Debian 13 - LUKS TPM2 unlocking with Secure Boot

I have a machine with an encrypted root, which means a passphrase prompt on a black screen before anything else happens. Fine the first time. Tedious the fortieth (and I reboot more than I’d like to admit for someone who cares about this stuff). It could also just get stolen, so leaving the key sat on disk unencrypted isn’t an option either.

The answer, it turns out, is to let the TPM hold the key and only hand it over when the machine has booted the way I expect. This post is that setup, done on Debian 13 with Secure Boot switched on.

First half: what actually worked. Second half: everything that didn’t, in case your paths cross mine.

Goal

  • Pulling the drive and reading it in another machine must not work
  • Booting normally must not need a passphrase
  • If the TPM refuses to unlock, there should be a passphrase fallback

Starting point: Debian 13, LUKS on LVM, a passphrase prompt at boot, UEFI, a TPM 2.0 chip, GRUB as the bootloader. Nothing exotic.

A passphrase fallback only helps if you can get to the console. Moving to dracut (below) removed dropbear-initramfs from my machine, so I lost remote unlocking. If the TPM doesn’t unlock, I need to be physically there. See Remote unlocking for the setup this replaced.

Implementation

Order matters here: enabling Secure Boot invalidates the TPM enrolment, and the crypttab needs to be in the initrd before any of this works. So: TPM unlocking first, so you can prove it works, then Secure Boot on top.

Packages

Debian’s default initramfs tooling (initramfs-tools with cryptsetup-initramfs) doesn’t understand the tpm2-device option in crypttab. It doesn’t fail loudly, which would at least be helpful - it just quietly ignores the option and asks for a passphrase anyway. You only find out if you read the output of update-initramfs:

cryptsetup: WARNING: md1_crypt: ignoring unknown option 'tpm2-device'
cryptsetup: WARNING: md1_crypt: ignoring unknown option 'headless'
cryptsetup: WARNING: md1_crypt: ignoring unknown option 'timeout'

dracut uses systemd-cryptsetup in the initrd instead, which does support it.

apt install dracut tpm2-tools

tpm2-tools needs to be installed explicitly. Without it, systemd-cryptenroll fails with an error that reads like a dying chip rather than a missing package (systemd#31925):

TPM does not support AES-128-CFB.
Failed to create TPM2 context: State not recoverable

crypttab

Edit the entry for your LUKS device in /etc/crypttab:

luks-<uuid> UUID=<uuid> none luks,tpm2-device=auto,headless=0,tries=3,timeout=30

headless=0 tells systemd-cryptsetup that someone may be sitting at the console to type a passphrase. Leave it out and a failed TPM unlock drops you at an emergency shell instead of a prompt.

timeout= is not what it sounds like, see timeout below. The UUID is the one from cryptsetup luksDump <device> | grep UUID.

Kernel command line

dracut doesn’t go looking for LUKS devices on its own the way initramfs-tools does. Without the following, my machine just hung at boot, and I had to type these in by hand at the GRUB menu to get anywhere.

Add to /etc/default/grub:

GRUB_CMDLINE_LINUX="rd.luks=1 rd.auto"

rd.luks=1 tells dracut to activate LUKS devices. rd.auto enables automatic assembly of RAID, LVM and LUKS. Skip it if you don’t have RAID, though it does no harm left in.

Rebuild

Both the crypttab and the command line have to be baked into the initrd, so do this after the two edits above:

dracut --force --kver $(uname -r)
update-grub

Dracut takes over from initramfs-tools for building the initrd from here on. /etc/crypttab doesn’t change format, reassuringly.

Enrol the TPM

Find your LUKS device with lsblk -f.

systemd-cryptenroll --tpm2-device=auto --tpm2-pcrs=0+7 /dev/nvme0n1p3

You’ll be asked for an existing passphrase. See PCRs below for why 0+7 specifically.

Reboot and check it unlocks without you touching the keyboard. Then check the fallback still works: add rd.luks.options=tpm2-device=no at the GRUB menu and make sure you get a passphrase prompt rather than a hang. Worth doing now rather than finding out later.

Secure Boot

Install the signed bootloader packages and reinstall GRUB:

apt install grub-efi-amd64-signed shim-signed sbsigntool
grub-install --target=x86_64-efi --efi-directory=/boot/efi --bootloader-id=debian
update-grub

The boot chain is now:

UEFI -> shim (Microsoft signed) -> grubx64.efi (Debian signed) -> kernel

Check that GRUB really is signed, rather than taking it on faith:

sbverify --list /boot/efi/EFI/debian/grubx64.efi

This should show a Debian Secure Boot signer. Then enable Secure Boot in the UEFI setup and boot. mokutil --sb-state should say SecureBoot enabled.

Re-enrol the TPM

Enabling Secure Boot changed PCR 7, so the key sealed earlier no longer matches anything. Wipe the TPM slot and enrol again:

systemd-cryptenroll --wipe-slot=tpm2 /dev/nvme0n1p3
systemd-cryptenroll --tpm2-device=auto --tpm2-pcrs=0+7 /dev/nvme0n1p3

--wipe-slot=tpm2 removes every TPM2 slot in one go, so there’s no need to hunt for the slot number. Your passphrase slot is untouched.

Re-enrol whenever the boot chain changes: Secure Boot toggled or its keys changed, firmware updates (PCR 0), or a new bootloader. With 0+7 you’re spared this on plain kernel updates, at least.

Firmware password

Set a UEFI firmware password. Of everything in this post, this matters most.

PCR 7 only reflects the current Secure Boot state. Without a firmware password, someone with physical access can turn Secure Boot off, boot a live USB, modify your unencrypted /boot, turn Secure Boot back on, and the TPM will happily unseal the key - as far as PCR 7 is concerned, nothing happened.

With the password set, they can’t touch Secure Boot or boot other media. How you set it depends on the vendor, but it’s normally under Security in the UEFI setup - you’re in there anyway to enable Secure Boot.

GRUB password (optional)

To stop someone editing the kernel command line at the GRUB menu, generate a hash with grub-mkpasswd-pbkdf2 and add it to /etc/grub.d/40_custom:

set superusers="root"
password_pbkdf2 root grub.pbkdf2.sha512.10000.<hash>

You’ll need to add --unrestricted to the normal boot menuentry in /etc/grub.d/10_linux, otherwise it asks for the password on every single boot, which rather defeats the point of the whole exercise. Then update-grub, and rebuild dracut if you want the two in step.

Things worth knowing

timeout

In this setup timeout= in crypttab does not mean “wait this long for the TPM, then ask for a passphrase” - which is what I assumed, and what the name suggests. What actually happens is the opposite. The passphrase prompt appears first, and the TPM unlock only kicks in once timeout= seconds have passed with no input. Kernel log timestamps put the prompt at 6 seconds and the unlock at 36 seconds, with timeout=30 set.

So timeout= is really a delay added to every boot, and timeout=0 (wait forever) means the TPM is never tried at all. I haven’t found a way to try the TPM first and only prompt on failure, which is the behaviour I actually wanted. 30 seconds was my compromise - shorten it if you’d rather not wait.

PCRs

The TPM keeps a hash of each stage of the boot in its Platform Configuration Registers (PCRs). When you enrol, the LUKS key is sealed against the current values of whichever PCRs you pick, and the TPM only releases it if they still match.

PCRMeasuresChanges when
0UEFI firmwareFirmware update
4BootloaderGRUB update
7Secure Boot stateSecure Boot toggled, keys changed
8Kernel command lineAny command line change
9Initramfs + kernelKernel update
11UKI (whole image)Any change to the UKI
14shim’s MOK certs and hashesA MOK is enrolled or removed

My first attempt was --tpm2-pcrs=7+8+9+14, which seemed thorough at the time. PCRs 8 and 9 change on every kernel update though, so the first apt upgrade that pulled in a new kernel left the machine sitting at a passphrase prompt - correct, but not what I wanted. I settled on 0+7, which stay put across kernel updates.

What it does and doesn’t protect against

With the key sealed in the TPM, the drive is useless in another machine, whichever PCRs you pick. PCR 7 adds a check on how the machine boots: the TPM only releases the key if Secure Boot is on and its keys haven’t changed, so booting with Secure Boot off, or from a differently signed USB, gets you nothing. It doesn’t cover /boot, though - someone with physical access can still swap the unsigned initramfs while leaving Secure Boot on, which is exactly why the firmware password matters. PCR 0 adds firmware tampering and motherboard swaps to the list of things that trip the seal.

For a machine where the main worry is opportunistic theft rather than a targeted attack, 0+7 with a firmware password and a passphrase fallback is enough for me.

Dead ends

UKI, systemd-boot and PCR 11

PCR 7 leaves one gap: it says nothing about the initramfs, so someone who can write to /boot can swap it while Secure Boot stays on. A Unified Kernel Image (UKI) closes that gap by bundling the kernel, initramfs and command line into one signed binary, with PCR 11 measuring the whole thing, so the TPM only unseals for exactly that image. The cost is that every kernel update changes the UKI, so you re-enrol every time.

I tried it anyway, and it needs systemd-boot rather than GRUB. That’s the awkward part on a machine installed with GRUB. Debian’s systemd-boot and Secure Boot integration assumes GRUB isn’t there, and if the GRUB packages are still installed, the postinst scripts quietly notice and do nothing - no error, nothing. I installed shim-signed and systemd-boot-efi-amd64-signed, saw no complaints, and nothing had changed. Removing the GRUB packages wasn’t enough either. I had to physically remove the files from the ESP (/boot/efi/EFI/debian) before the scripts would do anything at all.

What I remember of the setup, for whoever goes looking:

apt purge grub-common grub2-common grub-efi-amd64 grub-efi-amd64-bin grub-efi-amd64-unsigned
rm -rf /boot/efi/EFI/debian
apt install systemd-boot shim-signed systemd-boot-efi-amd64-signed systemd-ukify dracut
bootctl install

with /etc/kernel/install.conf:

layout=uki
initrd_generator=dracut
uki_generator=ukify

and /etc/kernel/uki.conf pointing Cmdline=@/etc/kernel/cmdline at your command line. Have a bootable USB ready before you purge GRUB.

In the end I went back to signed GRUB. Re-enrolling on every kernel update isn’t worth it for a machine where the main worry is opportunistic theft, and since I haven’t kept this running, I can’t vouch for every step above holding up on someone else’s machine.

Forgetting to re-enrol

Every single time I changed something in the boot chain, I forgot to re-enrol, and every single time the next boot stopped dead at a passphrase prompt. Predictable, and yet it kept catching me out. If you changed anything between the power button and the kernel, re-enrol - consider it a reflex by now.

Recovery

Force a passphrase instead of the TPM, from the GRUB menu:

rd.luks.options=tpm2-device=no

If dracut isn’t picking up your LUKS device:

rd.auto rd.luks=1 rd.luks.uuid=<uuid>

Drop to a dracut shell before the root mount:

rd.break=pre-mount

Reboot into the UEFI setup from a running system:

systemctl reboot --firmware-setup

I still haven’t found a clean way to try the TPM first and only fall back to a passphrase prompt on failure - if you know one, I’d like to hear it.