May 18, 2026 Infrastructure

NixOS: Installation Guide with RAID 1, encryption, and TPM Unlock (part 5 - unlocking the disk with TPM)

7 reading minutes
Placeholder alt text

Content:

At last, we are going to automatically decrypt the NixOS disk using the TPM!

This is the fifth post in the series:

  1. Preparing the virtual machine and partitioning the disks
  2. Disko, LUKS, and btrfs
  3. Installing the OS
  4. Enabling Secure Boot
  5. Unlocking the disk with TPM (this post)

Up to now, every boot has required the LUKS password to decrypt the disk. But if the machine’s integrity has not been compromised, there is no problem in doing this automatically. But how?

Now it is time to orchestrate everything we have put together so far, connecting LUKS, Secure Boot, and the TPM.

Why this is safe

Assuming the machine has not been compromised, when the operating system comes up, the only safe way to access it is with valid credentials — assuming the system has no exploitable vulnerability. That means logging in through the console, SSH, the graphical interface, or a serial connection. In practice, that means that without a login password there is no access to the computer, and no privilege is gained.

Everything works based on this principle: an uncompromised and intact system protects itself, and if it is compromised (firmware modification, disk removal, etc), encryption protects the data.

Understanding the TPM

The TPM (Trusted Platform Module) is a dedicated processor that stores, in an inviolable way, measurements taken of the hardware and software running on the computer. These measurements are stored in PCRs (Platform Configuration Registers), whose values can be extended, but never set directly. Every value sent to the TPM extends the previous value and produces a new one. If we were to model this as a formula, it would be something like this:

PCR = HASH(PCR || new_measurement)

In other words, the PCR hash is used together with the new measured value to create the new hash.

You can see the hashes of each PCR on your running TPM (fictitious values):

$ systemd-analyze pcrs
NR NAME                SHA256
 0 platform-code       0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef
 1 platform-config     123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef0
 2 external-code       23456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef01
 3 external-config     3456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef012
 4 boot-loader-code    456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123
 5 boot-loader-config  56789abcdef0123456789abcdef0123456789abcdef0123456789abcdef01234
 6 host-platform       6789abcdef0123456789abcdef0123456789abcdef0123456789abcdef012345
 7 secure-boot-policy  789abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456
 8 -                   89abcdef0123456789abcdef0123456789abcdef0123456789abcdef01234567
 9 kernel-initrd       9abcdef0123456789abcdef0123456789abcdef0123456789abcdef012345678
10 ima                 abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789
11 kernel-boot         0000000000000000000000000000000000000000000000000000000000000000
12 kernel-config       0000000000000000000000000000000000000000000000000000000000000000
13 sysexts             0000000000000000000000000000000000000000000000000000000000000000
14 shim-policy         bcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789a
15 system-identity     0000000000000000000000000000000000000000000000000000000000000000
16 debug               0000000000000000000000000000000000000000000000000000000000000000
17 -                   ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff
18 -                   ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff
19 -                   ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff
20 -                   ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff
21 -                   ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff
22 -                   ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff
23 application-support 0000000000000000000000000000000000000000000000000000000000000000

Each PCR is independent and is extended freely, without depending on the others. On compatible TPMs there are 24 PCRs; the UAPI (Linux Userspace API Group) in its specifications considers 0–7 to be firmware and 8–15 to be the range available to the operating system, although the systemd ecosystem also uses specific PCRs for additional measurements, and PCRs 16–23 are also usable.

When the computer powers on, all PCRs start out with no value. The firmware then begins extending the PCRs. For example, PCR 0 depends on the software running in the firmware — what we normally call the BIOS. When you “update the BIOS”, PCR 0 changes. PCR 1 may change if you replace a hardware component. PCR 4 is tied to the operating system; it measures the boot loader and additional drivers. And so on.

PCR 7 depends on the Secure Boot state. Its values are commonly extended using the keys enrolled in Secure Boot, as well as the Secure Boot Authority. It is this PCR that we will use to unlock the disk, at this moment.

I will come back to PCRs in the next posts, but it is important to understand what they are for. If you decide to use what I am proposing in these posts, this knowledge may be useful if you run into trouble.

You can see all the events that led to PCR extensions by running sudo systemd-pcrlock log. Using jq, you can show only the events that led to PCR 7:

sudo /run/current-system/systemd/lib/systemd/systemd-pcrlock log --json=short 2>/dev/null | jq '[.log[] | select(.pcr == 7)]'

The result, simplified so it does not get too long, is this:

[
  {
    "pcr": 7,
    "pcrname": "secure-boot-policy",
    "event": "efi-variable-driver-config",
    "match": true,
    "sha256": "9f75b6823bff6af1024a4e2036719cdd548d3cbc2bf1de8e7ef4d0ed01f94bf9",
    "phase": "F",
    "component": null,
    "description": "Variable: dbx-d719b2cb-3d3a-4596-a3bc-dad00e67656f"
  },
  {
    "pcr": 7,
    "pcrname": "secure-boot-policy",
    "event": "efi-variable-authority",
    "match": true,
    "sha256": "f35567246a92b2fcdd2901e4ad2febdfd80173f25527c5a73033bf100a8eb980",
    "phase": "F",
    "component": null,
    "description": "Authority: db-d719b2cb-3d3a-4596-a3bc-dad00e67656f"
  }
]

Note that the hash value, stored in the sha256 field, changes with each event. When the TPM receives a request to decrypt a value, its state must match exactly what was used when the value was encrypted (this is done through TPM policies). That means a value encrypted early in the boot process may no longer be decryptable at the end of the boot process, if the PCR has been extended again. This is especially useful for this very reason: allowing a value to be accessible only at a specific point in the boot sequence, such as during initrd startup (PCR 11 has measurements that make this possible).

Systemd documents how it uses PCRs, and since nowadays there is practically no Linux without systemd, it is worth understanding. This document will be useful later, when we look more closely at PCR 15 (system identity).

Unlocking the disk during boot

Only one command is needed to make everything happen. In our case, since we have two disks and two partitions, we run the command once for each of them (you will need to enter your LUKS password for the process to complete):

sudo systemd-cryptenroll --tpm2-device=auto --tpm2-pcrs=7 /dev/disk/by-label/NIXLUKS1
sudo systemd-cryptenroll --tpm2-device=auto --tpm2-pcrs=7 /dev/disk/by-label/NIXLUKS2

When this command runs, the volume master key is obtained using your password. It is then encrypted (sealed) using the TPM, and this sealed value is stored in the LUKS volume header. In this case, only PCR 7 is being used to seal the value, meaning only the Secure Boot state. That means if the Secure Boot state changes (if it is reset, or if a new key is removed or added), the TPM’s PCR 7 state will also change, and the disk will no longer be decrypted during boot. In this post, the goal is to tie automatic disk decryption to the Secure Boot state, and that is what we have done.

You can see the details stored in the LUKS volume header by running the following command after systemd-cryptenroll has been executed:

sudo cryptsetup luksDump /dev/disk/by-label/NIXLUKS1

Notice the Tokens field, with the first one being systemd-tpm2, with a Keyslot:

Tokens:
  0: systemd-tpm2
        Keyslot:    1

To see more details about the sealed value, run:

sudo cryptsetup luksDump --dump-json-metadata /dev/disk/by-label/NIXLUKS1 | jq -r '.tokens."0"'

The result will look something like this:

{
  "type": "systemd-tpm2",
  "keyslots": ["1"],
  "tpm2-blob": "<a base64 value>",
  "tpm2-pcrs": [7],
  "tpm2-pcr-bank": "sha256",
  "tpm2-policy-hash": "<a hash>",
  "tpm2_srk": "<another base64 value>",
}

The tpm2-blob contains the value encrypted by the TPM (plus metadata). Using the tools from the tpm2-tools package and root access, you could recover the master password from this value. That is not a security problem: the system is already running and the disk is already decrypted. The purpose of TPM protection is not to protect a healthy running system from its administrator, but to protect against integrity violations that happen before, during, and after boot.

With just these two commands, the TPM will start unlocking both LUKS volumes. Reboot to confirm that it no longer asks for the password. If it does, inspect the boot logs with sudo journalctl --boot.

Is it secure?

Not yet.

There are two vulnerabilities, and I will address them in the next posts. Neither is trivial, but someone who understands how all of this works could gain access to the data in a short amount of time. None of the work done so far will be lost; we will build on the current solution. The good news is that both issues are easy to fix.

That said, it is fair assume that most people, including technicians who are not systems penetration experts, would no longer be able to read the disk of a computer encrypted this way. In other words, sending a laptop for repair with this configuration would probably not present a risk. But that is still not good enough.

In the next post we will start closing the gaps.



Did you find a problem in the text? Send me an improvement suggestion. This code is free.

Fork me on Codeberg