Jun 1, 2026 Infrastructure

NixOS: Installation Guide with RAID 1, encryption, and TPM unlock (part 7 - Mitigating the OS swap attack)

11 reading minutes
Motherboard having its NVME disk swapped from Linux for a pirate system

Content:

There is still a loophole where one could obtain the LUKS volume encryption key using another operating system. Let’s fix that.

This is the seventh 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
  6. Mitigating the volume swap attack
  7. Mitigating the OS-switch attack (this post)

If you are only using your own keys in Secure Boot, this post doesn’t apply to your scenario. If you are also using Microsoft’s keys, you need to implement this mitigation.

The Problem

So far, we have been linking disk decryption to PCR 7 (and PCR 15 for system loading, as described in the last post). As seen in previous posts, PCR 7 depends exclusively on the Secure Boot state. This means that, regardless of which operating system is loaded, PCR 7 will have the same values.

If you also trust third-party keys (such as Microsoft’s or another manufacturer’s, like Canonical’s) in Secure Boot, there is still an attack surface that allows booting another system signed by those authorities. Therefore, it’s necessary to bind the protection to something beyond just PCR 7.

With the operating system loaded and PCR 7 in the correct configuration, it’s possible to obtain the LUKS volume header data and decrypt it using the TPM to get the LUKS master key, and then decrypt the volume. This could even be done using a Live CD with a correctly signed boot loader.

Naively sealing the LUKS key with the NixOS boot loader

Sealing the volume key only with PCR 7 is risky, so let’s also use another PCR that is closely tied to our operating system. PCR 4 measures the boot loader and additional drivers — the boot loader/PE binary code executed in the boot path. This means only an operating system that loads using the NixOS .efi files we are using would be able to retrieve the LUKS key via the TPM.

Since these .efi files are generated on-demand for our specific NixOS configuration and signed with Lanzaboote using our private keys, this prevents another OS from decrypting the LUKS key. For instance, if you load Ubuntu, the boot loader will be Ubuntu’s, with different .efi files, and therefore the content extended into PCR 4 will also be different. Because PCR 4 measures the EFI/PE binary actually executed at boot, a different boot path produces a different measurement and cannot reuse the same TPM token.

This would be simple if the boot loader didn’t change every time the Kernel, initrd, or any boot configuration is updated, which ends up changing PCR 4. If that weren’t the case, we could simply run:

# remove previous token from disk 1
sudo systemd-cryptenroll --wipe-slot=tpm2 /dev/disk/by-label/NIXLUKS1
# enroll new token using PCR 4 and PCR 7 on disk 1
sudo systemd-cryptenroll --tpm2-device=auto --tpm2-pcrs=4+7 /dev/disk/by-label/NIXLUKS1
# do the same for disk 2...

Note: The commands above can be combined into one, which will wipe and enroll in a single operation.

This will only work until the next time the boot loader files (the .efi files) are changed. On the first boot after such a change, the volume will no longer decrypt automatically, it will ask for the password, and you would need to run the commands above again. If that’s acceptable to you, you don’t even need to read the rest of the post, but I can tell you there is a better and equally secure way to solve this.

Sealing the LUKS key with the NixOS boot loader using a PCR policy

Important: Before starting, verify that your virtual machine is configured to use only one of the disks as a boot disk. I noticed that if both disks are marked as boot disks, PCR 4 is not correctly evaluated and the policy is not created. In the virtual machine settings, you can see this in the “Boot Options” menu. This might be a quirk of Virtual Machine Manager (and virsh), the Firmware used, or something related to Secure Boot and TPM protocols. The problem doesn’t occur on my physical machine, only on the virtual one.

The secret to solving this problem is a system that can predict the values that will be in the PCR and create an appropriate policy, and that is exactly what systemd-pcrlock does. While the process can be done manually, Lanzaboote recently implemented TPM measurement alongside Secure Boot integration, using systemd-pcrlock. At the time of writing, this hasn’t made it into a release yet, meaning it might contain bugs. By the time you read this, the functionality may have stabilized.

To enable it, simply add this to your configuration:

boot.lanzaboote = {
  enable = true;
  pkiBundle = "/var/lib/sbctl";
  configurationLimit = 8;
  measuredBoot = {
    enable = true;
    pcrs = [ 4 7 ];
  };
};

Finally, you need to perform a nixos-rebuild, but use boot instead of switch:

sudo nixos-rebuild boot

This will prepare the configuration, but it will only be used on the next boot. This is important to ensure that the boot loader files are correctly signed, in addition to preparing the PCR 7 files.

Note: systemd-pcrlock has several subcommands that allow you to “lock” a specific PCR. “Locking,” as used here, means inspecting the events of each PCR and generating .pcrlock files in the /var/lib/pcrlock.d directory. For example, systemd-pcrlock lock-firmware-code generates the file /var/lib/pcrlock.d/250-firmware-code-early.pcrlock.d/generated.pcrlock with PCR 0 and 2 measurements. Lanzaboote uses this system when you enable PCR 7 by running systemd-pcrlock lock-secureboot-authority and systemd-pcrlock lock-secureboot-policy. This is done by creating two oneshot systemd services: systemd-pcrlock-secureboot-authority and systemd-pcrlock-secureboot-policy.

Reboot the system. The LUKS key is still tied only to PCR 7, and the disks will be decrypted automatically. After the reboot, the policy file will have been created. You can verify if both PCRs are present in the file by running:

$ jq '.pcrValues.[].pcr' /var/lib/systemd/pcrlock.json
4
7

If this is correct, you can now remove the token from the disk headers that was tied only to PCR 7 and create a new one using the policy via the generated file:

# remove previous token and enroll new token using the policy on disk 1
sudo systemd-cryptenroll --tpm2-device=auto --wipe-slot=tpm2 --tpm2-pcrlock=/var/lib/systemd/pcrlock.json /dev/disk/by-label/NIXLUKS1
# remove previous token and enroll new token using the policy on disk 2
sudo systemd-cryptenroll --tpm2-device=auto --wipe-slot=tpm2 --tpm2-pcrlock=/var/lib/systemd/pcrlock.json /dev/disk/by-label/NIXLUKS2

With this, you can reboot again. The disk should continue to unlock automatically, but this time it is using the policy file, and therefore PCRs 4 and 7.

The functionality is now complete. Whenever the boot loader files are updated, the policy will also be updated, and the disk will be decrypted automatically without requiring any manual interaction. In the following sections, I will dive deeper into what is happening.

Inspecting TPM events

You can check TPM events by running sudo systemd-pcrlock log, or just sudo systemd-pcrlock. Unfortunately, the command doesn’t allow showing events for a specific PCR, so you can do it using JSON and jq. To see only PCR 4 events, run:

sudo systemd-pcrlock log --json=short 2>/dev/null | jq '[.log[] | select(.pcr == 4)]'

To see the components registered in systemd-pcrlock, use the list-components command:

$ sudo systemd-pcrlock list-components
ID                         VARIANTS
240-secureboot-policy      /var/lib/pcrlock.d/240-secureboot-policy.pcrlock.d/generated.pcrlock
350-action-efi-application /nix/store/f4s218swc4kw35apcqg9al8d7s287x7y-pcrlock.d/350-action-efi-application.pcrlock
400-secureboot-separator   /nix/store/f4s218swc4kw35apcqg9al8d7s287x7y-pcrlock.d/400-secureboot-separator.pcrlock.d/300-0x00000000.pcrlock
500-separator              /nix/store/f4s218swc4kw35apcqg9al8d7s287x7y-pcrlock.d/500-separator.pcrlock.d/300-0x00000000.pcrlock
620-secureboot-authority   /var/lib/pcrlock.d/620-secureboot-authority.pcrlock.d/generated.pcrlock
630-bootloader             /var/lib/pcrlock.d/630-bootloader.pcrlock.d/current.pcrlock
635-lanzaboote             /var/lib/pcrlock.d/635-lanzaboote.pcrlock.d/7.pcrlock

These components are used to predict the values of each PCR. They are what “lock” the TPM. The idea is that systemd-pcrlock lock-* commands check TPM events and generate these components, along with Lanzaboote generating the PCR 4 (boot loader) ones at every nixos-rebuild.

Finally, you can check the measurements being used by running systemd-pcrlock predict:

$ sudo systemd-pcrlock predict --pcr=4,7
Event log record 0 (PCR 0, "Raw: 2\0008\000\000\000") not matching any component.
Event log record 9 (PCR 1, "Raw: ACPI DATA") not matching any component.
Event log record 13 (PCR 2, "Raw: \030\377\004\000") not matching any component.
Event log record 27 (PCR 5, "GPT: disk 8118a150-5781-4514-94f0-517c327f4ca7") not matching any component.
Event log record 40 (PCR 9, "Linux: kernel command line") not matching any component.
Event log record 31 (PCR 11, "String: .linux") not matching any component.
Event log record 39 (PCR 12, "String: Global credentials initrd") not matching any component.
Event log record 44 (PCR 15, "cryptsetup:crypt_disk1:5a2876f5-9220-4040-8b56-7bc226c9d649") not matching any component.
3 combinations of components.
PCR 4 (boot-loader-code) matches event log and fully consists of recognized measurements. Including in set of PCRs.
PCR 7 (secure-boot-policy) matches event log and fully consists of recognized measurements. Including in set of PCRs.
PCRs in protection mask: 4 (boot-loader-code), 7 (secure-boot-policy)
Results for PCR 4 (boot-loader-code):
  sha256: 19d6c3ffa28c30062e84d8e90a357a6319ce921dfa15ee6618a5f9c38a5e9890
  sha256: 58d68b2aa60262d9831fdd1b8b0f50fd30610162bf3ae7f19d4722e21ba24277
  sha256: 480319eadf7ad52de18d44758204505cf34a4def787c762e1da3024a69b2ec8e
Results for PCR 7 (secure-boot-policy):
  sha256: cc1dc9cbd11e3ad0a695744ab152362d388e2e97f165117e3e45b8248f4a5078

The initial lines show event records that were not recognized. Since predict only generates predictions for PCRs whose component coverage is complete, those PCRs are excluded from this specific policy. If we wanted to use them, some could have components created by running systemd-pcrlock lock-* commands, while others would need manual component creation.

Lastly, let’s compare the PCR 7 logs used in the fifth post. The only difference is the inclusion of the component field (which “locks” the PCR), highlighted below:

sudo systemd-pcrlock log --json=short 2>/dev/null | jq '[.log[] | select(.pcr == 7)]'
[
  {
    "pcr": 7,
    "pcrname": "secure-boot-policy",
    "event": "efi-variable-driver-config",
    "match": true,
    "sha256": "9f75b6823bff6af1024a4e2036719cdd548d3cbc2bf1de8e7ef4d0ed01f94bf9",
    "phase": "F",
    "component": "240-secureboot-policy",
    "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": "620-secureboot-authority",
    "description": "Authority: db-d719b2cb-3d3a-4596-a3bc-dad00e67656f"
  }
]

Evaluating the LUKS header token

We can also compare the LUKS header, which was updated when we ran systemd-cryptenroll with the --wipe-slot argument and then --tpm2-pcrlock. In the fifth post, the policy wasn’t used, so the tpm-pcrs field (a list) had the value 7, and now it’s empty. The tpm2-pcr-bank field is no longer used, and the tpm2_pcrlock_nv field now has a hash:

sudo cryptsetup luksDump --dump-json-metadata /dev/disk/by-label/NIXLUKS1 | jq -r '.tokens."0"'
{
  "type": "systemd-tpm2",
  "keyslots": ["1"],
  "tpm2-blob": "<a base64 value>",
  "tpm2-pcrs": [],
  "tpm2-policy-hash": "<a hash>",
  "tpm2_pcrlock": true,
  "tpm2_srk": "<another base64 value>",
  "tpm2_pcrlock_nv": "<yet another base64 value>"
}

This last field, ending in _nv, is especially important. It points to a non-volatile area of the TPM, which is not erased when the computer turns off. The pcrlock policy data is stored there, and if the TPM is cleared, it forces the recreation of the policy (more on this below).

Removing the TPM policy

If you ever remove the NixOS TPM measurement settings (boot.lanzaboote.measuredBoot), you will need to remove the policy manually. It is registered in the aforementioned policy file, but also in the TPM’s non-volatile memory. This should be automated by Lanzaboote (I opened an issue), but from what I’ve seen, it isn’t yet. After running nixos-rebuild switch, run:

# remove the policy from the TPM's non-volatile memory and also the files:
# /var/lib/systemd/pcrlock.json and
# /boot/loader/credentials/pcrlock.nixos.cred
sudo systemd-pcrlock remove-policy

Don’t forget to remove the TPM keyslot from the LUKS volume headers:

sudo systemd-cryptenroll --wipe-slot=tpm2 /dev/disk/by-label/NIXLUKS1
sudo systemd-cryptenroll --wipe-slot=tpm2 /dev/disk/by-label/NIXLUKS2

Dealing with a cleared/wiped TPM

If the TPM’s non-volatile memory is wiped, the reference to the TPM policy registered in the LUKS volume header will become outdated, pointing to a record that no longer exists. This will force us to re-enroll the policy into the LUKS header. You can test this safely by clearing the TPM directly from Linux. Run the following commands and reboot:

nix-shell -p tpm2-tools
sudo tpm2 clear
exit

As a result, the operation to decrypt the disk using the TPM during boot will fail, and you will need to decrypt using your password. To re-enable automatic TPM decryption, you first need to remove the policy from the disk (the /var/lib/systemd/pcrlock.json and /boot/loader/credentials/pcrlock.nixos.cred files—explained in the previous section), run nixos-rebuild boot, and reboot to recreate the wiped policy. Then, redo the LUKS header with the systemd-cryptenroll --wipe-slot and systemd-cryptenroll --tpm2-pcrlock commands:

sudo systemd-pcrlock remove-policy
sudo systemd-cryptenroll --wipe-slot=tpm2 /dev/disk/by-label/NIXLUKS1
sudo systemd-cryptenroll --wipe-slot=tpm2 /dev/disk/by-label/NIXLUKS2
sudo nixos-rebuild boot
# reboot, enter LUKS password
jq '.pcrValues.[].pcr' /var/lib/systemd/pcrlock.json # validate that the result is: 4 7
sudo systemd-cryptenroll --tpm2-device=auto --tpm2-pcrlock=/var/lib/systemd/pcrlock.json /dev/disk/by-label/NIXLUKS1
sudo systemd-cryptenroll --tpm2-device=auto --tpm2-pcrlock=/var/lib/systemd/pcrlock.json /dev/disk/by-label/NIXLUKS2

Reboot once more, and you should no longer be prompted for a password.

You’ve already seen these commands in this article, so I won’t detail them further.

Conclusion

With this, your operating system is now protected, and you have the convenience of a system that starts without constantly asking for a decryption password. Better yet, since everything was done using NixOS, the configuration is ready to be reused: if it worked once, it will work forever.

The series was supposed to end here, but since I wrote this post, a few days have passed and I’ve used everything I wrote here to migrate my own machine from Ubuntu to NixOS. I learned a lot and have a few more things to share, so the series will have at least one more post.

If you’ve followed along this far, ran a proof of concept with the examples, or have ideas or criticisms, leave a comment so I know!



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

Fork me on Codeberg