From 6739045b173cc1be1f46bdf4b6167cbf9a883089 Mon Sep 17 00:00:00 2001 From: sommerfeld Date: Wed, 30 Sep 2026 08:54:59 +0100 Subject: Require signed PCR 11 policy for TPM root unlock --- docs/tpm-unlock.md | 431 ++++++++++------------------------ etc/kernel/uki.conf | 6 + justfile | 5 + nix/flake.nix | 5 +- run_onchange_after_deploy-etc.sh.tmpl | 6 +- scripts/tpm_negative_uki.py | 69 ++++++ scripts/tpm_nvpcr.py | 133 ++++++++++- scripts/tpm_unlock.py | 108 +++++++++ tests/test_nvpcr.py | 244 ++++++++++++++++++- 9 files changed, 684 insertions(+), 323 deletions(-) create mode 100644 scripts/tpm_negative_uki.py create mode 100644 scripts/tpm_unlock.py diff --git a/docs/tpm-unlock.md b/docs/tpm-unlock.md index 8815639..d60b5e7 100644 --- a/docs/tpm-unlock.md +++ b/docs/tpm-unlock.md @@ -1,351 +1,178 @@ -# TPM Unlock on Arch +# TPM Root Unlock on Halley2 -The root volume uses a systemd TPM enrollment with a `pcrlock` policy that -protects PCR 7. Keep a working disk passphrase and the policy recovery PIN in -a secure location accessible without unlocking this laptop. +Before this migration, the root token requires the approved PCR 7 pcrlock +policy. The new enrollment also requires an RSA signed PCR 11 policy at +`enter-initrd`. The `luks-root` policy reference is for root unlock; the +existing `initrd` reference remains for NvPCR setup. The root volume is +`/dev/nvme0n1p2`. Keep the tested disk passphrase and pcrlock recovery PIN +available away from this laptop. -The policy, enrollment, and generated component files are host state. Do not -copy them into this repo. Deploy this configuration only after policy creation, -enrollment, and an automatic unlock test have succeeded. +The signing key, pcrlock policy, and LUKS metadata are host state. Do not put +them in this repo. Each kernel update must build and Secure Boot sign a UKI +with both PCR 11 signatures. A signed policy approves older UKIs that use the +same signing key and `luks-root` reference; it does not prevent rollback to +an older approved version. -## Deploy +## Prepare -Run on the Arch host, outside aibox: +Run these steps on Halley2, outside aibox. Stop if a check fails. No step in +this section changes the LUKS header. ```sh +pacman -Q systemd systemd-ukify mkinitcpio +sudo cryptsetup open --test-passphrase --key-slot 0 /dev/nvme0n1p2 +sudo /usr/lib/systemd/systemd-pcrlock --strict=yes --pcr=7 --location=770 predict +df -h /boot just apply -sudo systemctl daemon-reload -sudo systemctl enable systemd-pcrlock-make-policy.service -sudo systemctl restart systemd-pcrlock-make-policy.service -sudo journalctl -b -u systemd-pcrlock-make-policy.service --no-pager +sudo mkinitcpio -P +sudo sbctl verify +just tpm-nvpcr-check ``` -The journal must show PCR 7 in the protection mask, or report that the policy -has not changed. Check for errors and unmet conditions. A successful command -exit alone does not prove that a conditional service ran. +`systemd` and `systemd-ukify` must have the same version. The normal +`mkinitcpio -P` build replaces all four maintained UKIs. The checker verifies +their Secure Boot signatures, initrd services, and both PCR 11 signatures +against the measured contents of each image. It does not claim that the LUKS +token has changed. Before enrollment, check that the initrd has no separate +root key file or credential that could bypass the TPM test. -The service runs after root is unlocked. It updates the existing TPM policy -and boot credential from the approved component files. It selects only PCR 7 -and requires the policy files and the mounted EFI System Partition at `/boot`. -On a host without an existing policy, the service skips execution. +Back up the header and old TPM token in the encrypted root file system. These +files must stay root owned and must not go to the ESP or the repo. Check the +current LUKS metadata before using the token and slot numbers below. On this +host the existing TPM token is 0, its keyslot is 1, and the passphrase uses +slot 0. Stop if these facts have changed. -Leave `systemd-pcrlock-secureboot-policy.service` and -`systemd-pcrlock-secureboot-authority.service` disabled. These services generate -component files from the current system. This setup retains the approved -components instead of automatically accepting changes to Secure Boot settings. - -## Updates +```sh +sudo cryptsetup luksDump --dump-json-metadata /dev/nvme0n1p2 | jq '{keyslots: (.keyslots|keys), tokens: (.tokens|with_entries(.value |= {type, keyslots, "tpm2-pcrs", tpm2_pcrlock, tpm2_pubkey_pcrs, tpm2_pubkey_ref}))}' +sudo install -d -m 0700 /var/lib/dotfiles/tpm-root +sudo cryptsetup luksHeaderBackup /dev/nvme0n1p2 \ + --header-backup-file /var/lib/dotfiles/tpm-root/before.header +sudo install -m 0600 /dev/null /var/lib/dotfiles/tpm-root/old-token.json +sudo cryptsetup token export --token-id 0 \ + --json-file /var/lib/dotfiles/tpm-root/old-token.json /dev/nvme0n1p2 +``` -Normal kernel and initramfs updates do not require LUKS re-enrollment. Keep the -existing mkinitcpio and Secure Boot signing hooks. Policy maintenance does not -build or sign UKIs. +## Enroll -After a systemd update, check the prediction before rebooting: +Enroll a new keyslot with two required TPM policy shards. Do not request a +direct PCR value or a TPM PIN. Enrollment on the running system cannot test +the `enter-initrd` signature because PCR 11 has changed since that phase. ```sh -sudo /usr/lib/systemd/systemd-pcrlock --pcr=7 --location=770 predict +sudo systemd-cryptenroll /dev/nvme0n1p2 \ + --unlock-tpm2-device=auto --tpm2-device=auto --tpm2-pcrs= \ + --tpm2-pcrlock=/var/lib/systemd/pcrlock.json \ + --tpm2-public-key=/etc/systemd/tpm2-pcr-public-key.pem \ + --tpm2-public-key-pcrs=11 \ + --tpm2-public-key-policyref=luks-root --tpm2-with-pin=no +sudo cryptsetup luksDump --dump-json-metadata /dev/nvme0n1p2 | jq '{keyslots: (.keyslots|keys), tokens: (.tokens|with_entries(.value |= {type, keyslots, "tpm2-pcrs", "tpm2-pcr-bank", tpm2_pcrlock, tpm2_pubkey_pcrs, tpm2_pubkey_ref}))}' ``` -If PCR 7 is included and the prediction completes without errors, refresh the -policy and check the journal: +Confirm that the new TPM token has a different token ID and keyslot, and +requires `tpm2_pcrlock`, `tpm2_pubkey_pcrs: [11]`, and +`tpm2_pubkey_ref: "luks-root"`. Keep slot 0. If there is no new token, stop. + +Remove only the old token metadata. Its keyslot remains for reversible +rollback until the boot tests pass. The new token is now the only automatic +unlock path. This command assumes the old token is still ID 0. ```sh -sudo systemctl restart systemd-pcrlock-make-policy.service -sudo journalctl -b -u systemd-pcrlock-make-policy.service --no-pager +sudo cryptsetup token remove --token-id 0 /dev/nvme0n1p2 +just tpm-unlock-check --trial ``` -Prediction uses the current boot log. It cannot guarantee that changed firmware -or boot software will produce the same measurements on the next boot. Keep the -disk passphrase available. No unattended hook relaxes protection or replaces -LUKS enrollments. - -## NvPCR Measurements - -This setup initializes the four systemd NvPCRs. It records hardware identity, -the logged-in user, and the LUKS root unlock method and keyslot. It also records -the root volume key in ordinary PCR 15. The verity NvPCR has no event on this -host because it does not use dm-verity. Root unlock still uses only the existing -PCR 7 `pcrlock` policy. The NvPCR indices are separate from that policy's index. - -Ukify signs an initrd PCR 11 policy with reference `initrd` and embeds its public -key in each UKI. The `sbctl` mkinitcpio post hook signs the complete UKI for -Secure Boot. Keep the PCR private key outside the repo and EFI System Partition -(ESP). Do not regenerate it during updates. - -### First Activation - -Run these steps on Halley2. Keep the LUKS passphrase and pcrlock recovery PIN -available without unlocking this laptop. Do not reboot until the requested test -is agreed. Stop if any command fails. - -1. Install the declared Arch packages. A full Arch update may rebuild UKIs. If - it does, verify Secure Boot signing and automatic unlock on a separate, - approved reboot before taking the recovery copy below. - - ```sh - sudo pacman -Syu --needed systemd-ukify efibootmgr sbsigntools - pacman -Q systemd systemd-ukify mkinitcpio - sudo sbctl verify - sudo /usr/lib/systemd/systemd-pcrlock --strict=yes --pcr=7 --location=770 predict - df -h /boot - ``` - - `systemd` and `systemd-ukify` must have the same package version. Check that - `/boot` can hold one more complete hardened UKI and temporary replacement - files. Leave enough free space for normal kernel updates. - -2. Save the current boot images and affected `/etc` files in a root-only backup. - Do this once, before `just apply`. Do not overwrite this backup on a retry. - - ```sh - sudo bash -euo pipefail -c ' - backup=/var/lib/dotfiles/tpm-nvpcr/backup - test ! -e "$backup" - install -d -m 0700 "$backup" - cp -a /boot/EFI/Linux/arch-linux-hardened*.efi \ - /boot/EFI/Linux/arch-linux-lts*.efi "$backup/" - tar -C / -cpf "$backup/etc.tar" \ - etc/nvpcr/cryptsetup.nvpcr etc/nvpcr/hardware.nvpcr \ - etc/nvpcr/login.nvpcr etc/nvpcr/verity.nvpcr \ - etc/systemd/system/systemd-pcrlogin@.service \ - etc/systemd/system/systemd-pcrproduct.service \ - etc/mkinitcpio.conf.d/60-no-nvpcr.conf \ - etc/mkinitcpio.conf etc/kernel/cmdline \ - etc/kernel/cmdline-linux-hardened - ' - ``` - -3. Copy and check a working recovery UKI. Use `--create-only` so this entry does - not change the normal EFI boot order. Read its entry number from `efibootmgr -v`. - - ```sh - sudo cp -a /boot/EFI/Linux/arch-linux-hardened.efi \ - /boot/EFI/Linux/arch-linux-hardened-nvpcr-recovery.efi - sudo sbverify --cert /var/lib/sbctl/keys/db/db.pem \ - /boot/EFI/Linux/arch-linux-hardened-nvpcr-recovery.efi - sudo efibootmgr --create-only --disk /dev/nvme0n1 --part 1 \ - --label 'Arch NvPCR Recovery' \ - --loader '\EFI\Linux\arch-linux-hardened-nvpcr-recovery.efi' - sudo efibootmgr -v - ``` - - On an approved reboot, select this entry once with `efibootmgr --bootnext` - or the firmware menu. Confirm automatic unlock. Return to the normal entry. - The recovery copy is outside all mkinitcpio presets. - -4. Create a dedicated PCR signing key pair if neither file exists. Ukify refuses - to replace existing key files. Check permissions and that the keys match. - - ```sh - sudo ukify --pcr-private-key=/etc/systemd/tpm2-pcr-private-key.pem \ - --pcr-public-key=/etc/systemd/tpm2-pcr-public-key.pem genkey - sudo stat -c '%U:%G:%a %n' /etc/systemd/tpm2-pcr-private-key.pem - sudo openssl pkey -in /etc/systemd/tpm2-pcr-private-key.pem -pubout | - sudo cmp - /etc/systemd/tpm2-pcr-public-key.pem - ``` - - The private file must be `root:root:600`. If either file already exists, - verify the pair and do not run `genkey` again. Never copy the private key - into the repo, an initramfs, or the ESP. - -5. Apply the repo files and remove only the prior masks. The deployment checks - the key pair, ukify version, and recovery image before it changes `/etc`. - Chezmoi does not delete files that were removed from its source directory; - `tpm-nvpcr-unmask` checks their exact old contents before removing them. - - ```sh - just apply - just tpm-nvpcr-unmask - ``` - -6. Build and sign all four candidate UKIs on the root filesystem. These - commands do not replace the active boot images. - - ```sh - sudo install -d -m 0700 /var/lib/dotfiles/tpm-nvpcr/stage - sudo mkinitcpio -k /boot/vmlinuz-linux-hardened \ - -U /var/lib/dotfiles/tpm-nvpcr/stage/arch-linux-hardened.efi \ - --cmdline /etc/kernel/cmdline-linux-hardened \ - --ukiconfig /etc/kernel/uki.conf - sudo mkinitcpio -k /boot/vmlinuz-linux-hardened \ - -U /var/lib/dotfiles/tpm-nvpcr/stage/arch-linux-hardened-fallback.efi \ - --cmdline /etc/kernel/cmdline-linux-hardened \ - --ukiconfig /etc/kernel/uki.conf -S autodetect - sudo mkinitcpio -k /boot/vmlinuz-linux-lts \ - -U /var/lib/dotfiles/tpm-nvpcr/stage/arch-linux-lts.efi \ - --cmdline /etc/kernel/cmdline --ukiconfig /etc/kernel/uki.conf - sudo mkinitcpio -k /boot/vmlinuz-linux-lts \ - -U /var/lib/dotfiles/tpm-nvpcr/stage/arch-linux-lts-fallback.efi \ - --cmdline /etc/kernel/cmdline --ukiconfig /etc/kernel/uki.conf \ - -S autodetect - just tpm-nvpcr-check \ - /var/lib/dotfiles/tpm-nvpcr/stage/arch-linux-hardened.efi \ - /var/lib/dotfiles/tpm-nvpcr/stage/arch-linux-hardened-fallback.efi \ - /var/lib/dotfiles/tpm-nvpcr/stage/arch-linux-lts.efi \ - /var/lib/dotfiles/tpm-nvpcr/stage/arch-linux-lts-fallback.efi - ``` - - The check reads `.pcrpkey`, `.pcrsig`, and `.cmdline` using ukify. It uses - `lsinitcpio` to check the four required initrd units and verifies each - Secure Boot signature with the enrolled `sbctl` certificate. Stop on any - mismatch. - -7. Copy only the checked hardened default image to a separate ESP path and - create a one-time test entry. Leave the normal and recovery images intact. - Read the new entry number from `efibootmgr -v`; do not assume it is `0002`. - - ```sh - sudo install -m 0644 \ - /var/lib/dotfiles/tpm-nvpcr/stage/arch-linux-hardened.efi \ - /boot/EFI/Linux/arch-linux-hardened-nvpcr-test.efi - just tpm-nvpcr-check /boot/EFI/Linux/arch-linux-hardened-nvpcr-test.efi - sudo efibootmgr --create-only --disk /dev/nvme0n1 --part 1 \ - --label 'Arch NvPCR Test' \ - --loader '\EFI\Linux\arch-linux-hardened-nvpcr-test.efi' - sudo efibootmgr -v - ``` - - Only after these commands succeed, set `BootNext` to that entry and perform - one approved reboot. Keep the disk passphrase available. Confirm automatic - unlock, `BootCurrent` equal to the test entry, and the checks below. The - normal boot order remains unchanged. Do not publish the other images until - the test passes. If the test fails, boot the normal or recovery entry and - inspect the journal before trying again. - -8. After the one-time test passes, copy each checked image to the ESP under a - temporary name, then rename it - into place before copying the next one. This needs space for only one extra - image at a time. Do not reboot after a partial copy. Restore from the backup - if a copy or verification fails. - - ```sh - sudo bash -euo pipefail -c ' - for name in arch-linux-hardened.efi arch-linux-hardened-fallback.efi \ - arch-linux-lts.efi arch-linux-lts-fallback.efi; do - install -m 0644 "/var/lib/dotfiles/tpm-nvpcr/stage/$name" \ - "/boot/EFI/Linux/$name.next" - mv -f "/boot/EFI/Linux/$name.next" "/boot/EFI/Linux/$name" - done - ' - sudo sbctl verify - just tpm-nvpcr-check /boot/EFI/Linux/arch-linux-hardened.efi \ - /boot/EFI/Linux/arch-linux-hardened-fallback.efi \ - /boot/EFI/Linux/arch-linux-lts.efi \ - /boot/EFI/Linux/arch-linux-lts-fallback.efi - ``` - -### Boot Checks - -After the one-time test boot, confirm automatic unlock and run: +## Test + +Boot the normal Hardened entry once. Confirm automatic unlock, then run: ```sh -just tpm-nvpcr-check /boot/EFI/Linux/arch-linux-hardened-nvpcr-test.efi -sudo env SYSTEMD_PAGER=cat /usr/lib/systemd/systemd-pcrlock \ - --strict=yes --pcr=7 --location=770 predict -sudo journalctl -b --no-pager \ - -u systemd-tpm2-setup-early.service \ - -u systemd-pcrnvdone.service \ - -u systemd-pcrproduct.service \ - -u systemd-pcrlogin@1000.service \ - -u systemd-cryptsetup@root.service \ - -u systemd-pcrlock-make-policy.service +efibootmgr | rg 'BootCurrent|BootOrder' +just tpm-nvpcr-check +just tpm-unlock-check --trial systemctl --failed --no-pager -systemd-analyze nvpcrs ``` -The early setup and NvPCR separator must succeed. The `hardware`, `login`, and -`cryptsetup` records must appear after their events; `verity` can stay at its -initial value. Check that PCR 7 is still the only disk-unlock policy PCR. After -publishing all four images, reboot into the normal hardened entry and run -`just tpm-nvpcr-check` with no image arguments. Boot-test hardened and LTS -default and fallback UKIs with explicit approval. -Then rebuild with `sudo mkinitcpio -P` and repeat a normal boot test. Keep the -recovery UKI until these checks pass. +The image check verifies all four maintained UKIs without booting each one. +The token check verifies header metadata. The main boot proves that the +combined token can unlock the root volume. -After the final boot test, verify the disk passphrase without activating a new -mapping. Keyslot 0 is the passphrase slot on Halley2; check the LUKS header if -this test fails. Do not type the passphrase on the command line. +For a refusal test, make a copy of the main UKI without the `luks-root` +approval. The helper keeps the `initrd` approval and the measured sections. +Re-sign the modified image for Secure Boot, then check the new signature. The +unsigned intermediate file remains on the encrypted root file system. ```sh -sudo cryptsetup open --test-passphrase --key-slot 0 /dev/nvme0n1p2 +sudo python3 -m scripts.tpm_negative_uki \ + /boot/EFI/Linux/arch-linux-hardened.efi \ + /var/lib/dotfiles/tpm-root/no-root-approval.unsigned.efi +sudo sbsign --key /var/lib/sbctl/keys/db/db.key \ + --cert /var/lib/sbctl/keys/db/db.pem \ + --output /boot/EFI/Linux/arch-linux-hardened-no-root-approval.efi \ + /var/lib/dotfiles/tpm-root/no-root-approval.unsigned.efi +sudo sbverify --cert /var/lib/sbctl/keys/db/db.pem \ + /boot/EFI/Linux/arch-linux-hardened-no-root-approval.efi +sudo efibootmgr --create-only --disk /dev/nvme0n1 --part 1 \ + --label 'Arch Root Policy Refusal Test' \ + --loader '\EFI\Linux\arch-linux-hardened-no-root-approval.efi' +sudo efibootmgr ``` -If the test succeeds, remove the temporary recovery and test boot entries and -UKIs. Check that `BootCurrent` is 0000 and that entries 0001 and 0002 still -refer to these exact files before running the removal commands. The normal -hardened and LTS entries must stay in `BootOrder`. +Read the new EFI entry number. Set it for one boot with +`sudo efibootmgr --bootnext NNNN`. The test must request the disk passphrase. +Enter it and check the boot journal for the TPM policy failure. If it unlocks +automatically, stop: another automatic path still exists. The normal boot +order must stay unchanged. + +After the refusal test, remove only that test entry and UKI, using its actual +entry number. Boot the normal Hardened entry again if needed. Keep the +passphrase available. ```sh -efibootmgr -v -sudo efibootmgr -b 0001 -B -sudo efibootmgr -b 0002 -B -sudo rm -- /boot/EFI/Linux/arch-linux-hardened-nvpcr-recovery.efi \ - /boot/EFI/Linux/arch-linux-hardened-nvpcr-test.efi -efibootmgr -sudo sbctl verify +sudo efibootmgr -b NNNN -B +sudo rm -- /boot/EFI/Linux/arch-linux-hardened-no-root-approval.efi +sudo rm -- /var/lib/dotfiles/tpm-root/no-root-approval.unsigned.efi just tpm-nvpcr-check +just tpm-unlock-check --trial ``` -The recovery copy is not updated by `mkinitcpio`. Keeping it on the ESP leaves -an old signed boot path that can unlock under the current PCR 7 policy. The -NvPCRs record boot events, but the root TPM enrollment does not require their -values. Removal of these two copies does not prevent booting another old UKI -signed by a trusted Secure Boot key. Keep the disk passphrase and the root-only -backup for recovery from external media. Audit the LUKS TPM tokens separately -before claiming that boot-image measurements are required for unlock. - -### Rollback +## Finish -If a new image does not boot or automatic unlock fails, select the recovery -entry and use the disk passphrase if needed. On the running host, restore the -saved images and exact `/etc` files. Do not remove any TPM indices or keyslots. +When both tests pass, remove only the old, unbound TPM keyslot. This host used +slot 1. Confirm the slot number again before running the command. Do not use a +type-wide `--wipe-slot=tpm2` operation. ```sh -sudo install -d -m 0700 /var/lib/dotfiles/tpm-nvpcr -sudo touch /var/lib/dotfiles/tpm-nvpcr/rollback -sudo bash -euo pipefail -c ' - cp -a /var/lib/dotfiles/tpm-nvpcr/backup/arch-linux-hardened*.efi \ - /var/lib/dotfiles/tpm-nvpcr/backup/arch-linux-lts*.efi /boot/EFI/Linux/ -' -sudo tar -C / -xpf /var/lib/dotfiles/tpm-nvpcr/backup/etc.tar -sudo rm -f /etc/kernel/uki.conf /etc/initcpio/install/nvpcr -sudo systemctl daemon-reload -sudo sbctl verify +sudo cryptsetup luksDump /dev/nvme0n1p2 +sudo cryptsetup luksKillSlot /dev/nvme0n1p2 1 +just tpm-unlock-check +sudo cryptsetup open --test-passphrase --key-slot 0 /dev/nvme0n1p2 +sudo cryptsetup luksHeaderBackup /dev/nvme0n1p2 \ + --header-backup-file /var/lib/dotfiles/tpm-root/final.header +sudo mkinitcpio -P +just tpm-nvpcr-check ``` -Revert this task's repo commit before another `just apply`. The rollback marker -blocks redeployment until then. Keep the key pair and root-only backup for -diagnosis; the temporary recovery UKI is only for first activation. - -Sources: [systemd 262 release notes](https://github.com/systemd/systemd/releases/tag/v262), -[ukify](https://github.com/systemd/systemd/blob/v262/man/ukify.xml), and -[systemd TPM measurement guide](https://github.com/systemd/systemd/blob/v262/docs/TPM2_PCR_MEASUREMENTS.md). +On a later approved reboot, confirm normal automatic unlock. Keep the final +header backup and the passphrase. Remove the pre-migration backup and token +export only after that boot succeeds. ## Recovery -If automatic unlock fails, use the disk passphrase. Inspect the boot journal -and `systemd-pcrlock log` before changing policy. Do not clear the TPM, remove -the policy, or remove password slots. - -After confirming that a Secure Boot change was intended, regenerate its -component files: - -```sh -sudo /usr/lib/systemd/systemd-pcrlock lock-secureboot-policy -sudo /usr/lib/systemd/systemd-pcrlock lock-secureboot-authority -sudo /usr/lib/systemd/systemd-pcrlock --pcr=7 --location=770 predict -``` - -Proceed only if PCR 7 is included. Update the existing policy with its recovery -PIN when the old policy no longer permits updates: +If automatic unlock fails, use the disk passphrase. During the trial, restore +the old token if needed, after checking that token ID 0 is free. This restores +the previous, weaker automatic unlock policy. The old keyslot must still exist. ```sh -sudo /usr/lib/systemd/systemd-pcrlock \ - --pcr=7 --location=770 --recovery-pin=query make-policy +sudo cryptsetup token import --token-id 0 \ + --json-file /var/lib/dotfiles/tpm-root/old-token.json /dev/nvme0n1p2 ``` -Enter the policy recovery PIN, not the disk passphrase. Do not record this -session or share the PIN. Reboot to test automatic unlock. An update to the -existing policy normally needs neither re-enrollment nor a UKI rebuild. +If the LUKS header is damaged, use the saved header and passphrase from an +external recovery system. Header restore replaces all current token and +keyslot changes; do not use it for an ordinary policy failure. Do not clear +the TPM or its NV indices. -Reference: [systemd-pcrlock manual](https://man.archlinux.org/man/systemd-pcrlock.8.en). +For an intended Secure Boot change, review the PCR 7 prediction before +updating its policy. See [systemd-pcrlock](https://man.archlinux.org/man/systemd-pcrlock.8.en), +[systemd-cryptenroll](https://man.archlinux.org/man/systemd-cryptenroll.1.en), +and [cryptsetup-token](https://man.archlinux.org/man/cryptsetup-token.8.en). diff --git a/etc/kernel/uki.conf b/etc/kernel/uki.conf index 82b7801..5417a7a 100644 --- a/etc/kernel/uki.conf +++ b/etc/kernel/uki.conf @@ -7,3 +7,9 @@ PCRPrivateKey=/etc/systemd/tpm2-pcr-private-key.pem PCRPublicKey=/etc/systemd/tpm2-pcr-public-key.pem Phases=enter-initrd PolicyRef=initrd + +[PCRSignature:luks-root] +PCRPrivateKey=/etc/systemd/tpm2-pcr-private-key.pem +PCRPublicKey=/etc/systemd/tpm2-pcr-public-key.pem +Phases=enter-initrd +PolicyRef=luks-root diff --git a/justfile b/justfile index 61c495e..a980ac5 100644 --- a/justfile +++ b/justfile @@ -154,6 +154,11 @@ test: tpm-nvpcr-check *images: _require-host @sudo /usr/bin/python3 scripts/tpm_nvpcr.py "$@" +# Audit the combined LUKS TPM token. Use --trial until the old slot is removed. +[positional-arguments] +tpm-unlock-check *args: _require-host + @sudo /usr/bin/python3 -m scripts.tpm_unlock "$@" + # Remove only this repo's obsolete zero-byte NvPCR masks after recovery is ready. tpm-nvpcr-unmask: _require-host @sudo /usr/bin/python3 scripts/tpm_nvpcr_unmask.py diff --git a/nix/flake.nix b/nix/flake.nix index 84b4d51..7c19abb 100644 --- a/nix/flake.nix +++ b/nix/flake.nix @@ -282,7 +282,10 @@ chezmoi git jq - python3 + (python3.withPackages (ps: [ + ps.cryptography + ps.pefile + ])) just nixfmt nodejs diff --git a/run_onchange_after_deploy-etc.sh.tmpl b/run_onchange_after_deploy-etc.sh.tmpl index 021d81d..03dc64e 100755 --- a/run_onchange_after_deploy-etc.sh.tmpl +++ b/run_onchange_after_deploy-etc.sh.tmpl @@ -21,15 +21,15 @@ if [ -f etc/kernel/uki.conf ]; then fi if ! sudo test -s /etc/systemd/tpm2-pcr-private-key.pem || ! sudo test -s /etc/systemd/tpm2-pcr-public-key.pem || - ! sudo test -s /boot/EFI/Linux/arch-linux-hardened-nvpcr-recovery.efi || ! sudo test -s /var/lib/dotfiles/tpm-nvpcr/backup/etc.tar || ! sudo test -s /var/lib/dotfiles/tpm-nvpcr/backup/arch-linux-hardened.efi || ! sudo test -s /var/lib/dotfiles/tpm-nvpcr/backup/arch-linux-hardened-fallback.efi || ! sudo test -s /var/lib/dotfiles/tpm-nvpcr/backup/arch-linux-lts.efi || ! sudo test -s /var/lib/dotfiles/tpm-nvpcr/backup/arch-linux-lts-fallback.efi || + ! sudo test -s /boot/EFI/Linux/arch-linux-hardened.efi || ! sudo sbverify --cert /var/lib/sbctl/keys/db/db.pem \ - /boot/EFI/Linux/arch-linux-hardened-nvpcr-recovery.efi >/dev/null 2>&1; then - echo 'Create the PCR key pair, backup, and signed recovery UKI first. See docs/tpm-unlock.md.' >&2 + /boot/EFI/Linux/arch-linux-hardened.efi >/dev/null 2>&1; then + echo 'Check the PCR key pair, root-only backup, and signed main UKI. See docs/tpm-unlock.md.' >&2 exit 1 fi if [ "$(sudo stat -c %U:%G:%a /etc/systemd/tpm2-pcr-private-key.pem)" != root:root:600 ] || diff --git a/scripts/tpm_negative_uki.py b/scripts/tpm_negative_uki.py new file mode 100644 index 0000000..b9ce8b2 --- /dev/null +++ b/scripts/tpm_negative_uki.py @@ -0,0 +1,69 @@ +"""Prepare a UKI that retains NvPCR approval but lacks root-unlock approval.""" + +import json +import os +import subprocess +import sys +from pathlib import Path + +import pefile + +from scripts.tpm_nvpcr import PUBLIC_KEY, read_pe_sections, verify_pcr_signatures + + +def remove_root_approval(source: Path, destination: Path, public_key: bytes) -> None: + if destination.exists(): + raise ValueError("Refusing to replace an existing test image") + original = read_pe_sections(source) + verify_pcr_signatures(original, public_key) + with destination.open("xb") as output: + output.write(source.read_bytes()) + try: + subprocess.run( + ["sbattach", "--remove", str(destination)], check=True, capture_output=True + ) + pe = pefile.PE(str(destination), fast_load=True) + sections = [s for s in pe.sections if s.Name.rstrip(b"\0") == b".pcrsig"] + if len(sections) != 1: + raise ValueError("Expected one .pcrsig section") + section = sections[0] + length = min(section.Misc_VirtualSize, section.SizeOfRawData) + offset = section.PointerToRawData + image = bytearray(destination.read_bytes()) + old = bytes(image[offset : offset + length]) + if old.count(b"luks-root") != 1: + raise ValueError("Expected exactly one luks-root approval") + new = old.replace(b"luks-root", b"deny-root") + policies = json.loads(new.decode().rstrip("\0")) + if any(item.get("ref") == "luks-root" for item in policies.get("sha256", [])): + raise ValueError("Root approval remains in test image") + image[offset : offset + length] = new + destination.write_bytes(image) + changed = read_pe_sections(destination) + if any( + original.get(name) != changed.get(name) + for name in original + if name != ".pcrsig" + ): + raise ValueError("Test image changed a measured section") + verify_pcr_signatures(changed, public_key, refs=("initrd",)) + except Exception: + destination.unlink() + raise + + +def main() -> None: + if len(sys.argv) != 3: + raise ValueError("Usage: tpm_negative_uki.py SOURCE DESTINATION") + if os.geteuid() != 0 or Path("/etc/hostname").read_text().strip() != "halley2": + raise ValueError("Run as root on Halley2") + remove_root_approval(Path(sys.argv[1]), Path(sys.argv[2]), PUBLIC_KEY.read_bytes()) + print(f"prepared: {sys.argv[2]} (sign with Secure Boot key before testing)") + + +if __name__ == "__main__": + try: + main() + except (OSError, TypeError, ValueError, subprocess.CalledProcessError) as exc: + print(f"Negative UKI preparation failed: {exc}", file=sys.stderr) + sys.exit(1) diff --git a/scripts/tpm_nvpcr.py b/scripts/tpm_nvpcr.py index 4f110fa..ddf9fd2 100644 --- a/scripts/tpm_nvpcr.py +++ b/scripts/tpm_nvpcr.py @@ -1,15 +1,23 @@ """Read-only checks for Halley2's signed NvPCR UKIs.""" +import base64 import hashlib import json import os import subprocess import sys +import tempfile from pathlib import Path +import pefile +from cryptography.exceptions import InvalidSignature +from cryptography.hazmat.primitives import hashes, serialization +from cryptography.hazmat.primitives.asymmetric import padding, rsa + BOOT = Path("/boot/EFI/Linux") PUBLIC_KEY = Path("/etc/systemd/tpm2-pcr-public-key.pem") SB_CERT = Path("/var/lib/sbctl/keys/db/db.pem") +ROOT_UUID = "81520bbc-1e7a-45e6-9465-cfc2e8b18945" IMAGES = ( "arch-linux-hardened.efi", "arch-linux-hardened-fallback.efi", @@ -27,6 +35,23 @@ INITRD_UNITS = ( "systemd-pcrextend.socket", "systemd-pcrextend@.service", ) +POLICY_REFS = ("initrd", "luks-root") +MEASURED_SECTIONS = frozenset( + ( + ".linux", + ".osrel", + ".cmdline", + ".initrd", + ".ucode", + ".splash", + ".dtb", + ".uname", + ".sbat", + ".pcrpkey", + ".profile", + ".hwids", + ) +) def run(*command: str) -> str: @@ -50,23 +75,24 @@ def check_sections(sections: dict, public_key: bytes) -> None: if not isinstance(signature, dict) or not isinstance(signature.get("text"), str): raise TypeError("UKI has no readable PCR signatures") policies = json.loads(signature["text"]) - if not isinstance(policies, dict) or not any( - isinstance(item, dict) - and item.get("pcrs") == [11] - and item.get("ref") == "initrd" - and isinstance(item.get("sig"), str) - and bool(item["sig"]) - for item in policies.get("sha256", []) - ): - raise ValueError("UKI has no signed initrd PCR 11 policy") + for ref in POLICY_REFS: + if not isinstance(policies, dict) or not any( + isinstance(item, dict) + and item.get("pcrs") == [11] + and item.get("ref") == ref + and isinstance(item.get("sig"), str) + and bool(item["sig"]) + for item in policies.get("sha256", []) + ): + raise ValueError(f"UKI has no signed {ref} PCR 11 policy") cmdline = sections.get(".cmdline") if not isinstance(cmdline, dict) or not isinstance(cmdline.get("text"), str): raise TypeError("UKI has no kernel command line") args = cmdline["text"].split() roots = [arg.split("=", 2)[1] for arg in args if arg.startswith("rd.luks.name=")] - if len(roots) != 1: - raise ValueError("UKI must specify exactly one LUKS root device") + if roots != [ROOT_UUID]: + raise ValueError("UKI has the wrong LUKS root UUID") options = [ arg.removeprefix("rd.luks.options=") for arg in args @@ -74,6 +100,8 @@ def check_sections(sections: dict, public_key: bytes) -> None: ] if len(options) != 1 or not options[0].startswith(roots[0] + "="): raise ValueError("Root measurement options have the wrong LUKS UUID") + if "root=/dev/mapper/root" not in args: + raise ValueError("UKI does not boot the expected root mapping") selected = options[0].split("=", 1)[1].split(",") if not all(option in selected for option in MEASUREMENT_OPTIONS): raise ValueError("Root measurement or TPM unlock option is missing") @@ -86,6 +114,88 @@ def check_initrd_listing(listing: str) -> None: raise ValueError(f"UKI initrd lacks {unit}") +def read_pe_sections(path: Path) -> dict[str, bytes]: + pe = pefile.PE(str(path), fast_load=True) + sections = {} + for section in pe.sections: + name = section.Name.rstrip(b"\0").decode("ascii") + if name in sections: + raise ValueError(f"Duplicate UKI section: {name}") + size = min(section.Misc_VirtualSize, section.SizeOfRawData) + sections[name] = section.get_data(length=size) + if ".dtbauto" in sections: + raise ValueError( + "Automatic device tree selection is not supported by this checker" + ) + return sections + + +def verify_pcr_signatures( + sections: dict[str, bytes], public_key: bytes, refs: tuple[str, ...] = POLICY_REFS +) -> None: + if sections.get(".pcrpkey") != public_key: + raise ValueError("UKI PCR public key does not match the installed key") + try: + signed = json.loads(sections[".pcrsig"].decode().rstrip("\0")) + key = serialization.load_pem_public_key(public_key) + except (KeyError, ValueError, TypeError) as exc: + raise ValueError("Invalid UKI PCR signature or public key") from exc + if not isinstance(key, rsa.RSAPublicKey): + raise TypeError("PCR public key must be RSA") + + measured = { + name: data for name, data in sections.items() if name in MEASURED_SECTIONS + } + if ".linux" not in measured or ".initrd" not in measured: + raise ValueError("UKI lacks a kernel or initrd") + + with tempfile.TemporaryDirectory() as directory: + options = [] + for name, data in measured.items(): + section_path = Path(directory) / name.removeprefix(".") + section_path.write_bytes(data) + options.append(f"--{name.removeprefix('.')}={section_path}") + for ref in refs: + expected = json.loads( + run( + "/usr/lib/systemd/systemd-measure", + "policy-digest", + "--json=short", + "--bank=sha256", + "--phase=enter-initrd", + f"--policyref={ref}", + f"--public-key={PUBLIC_KEY}", + *options, + ) + )["sha256"] + if len(expected) != 1: + raise ValueError(f"Unexpected PCR policy count for {ref}") + matching = [ + item + for item in signed.get("sha256", []) + if item.get("pcrs") == [11] and item.get("ref") == ref + ] + if len(matching) != 1: + raise ValueError(f"Expected one {ref} PCR 11 signature") + actual = matching[0] + policy = expected[0] + if any( + actual.get(field) != policy.get(field) + for field in ("pcrs", "pkfp", "ref", "pol") + ): + raise ValueError(f"{ref} PCR 11 signature does not match UKI contents") + try: + signature = base64.b64decode(actual["sig"], validate=True) + key.verify( + signature, + bytes.fromhex(policy["tbs"]), + padding.PKCS1v15(), + hashes.SHA256(), + ) + except (KeyError, ValueError, InvalidSignature) as exc: + raise ValueError(f"Invalid {ref} PCR 11 signature") from exc + + def check_image(path: Path, public_key: bytes) -> None: sections = json.loads( run( @@ -101,6 +211,7 @@ def check_image(path: Path, public_key: bytes) -> None: if not isinstance(sections, dict) or "_profiles" in sections: raise ValueError(f"Unsupported multi-profile UKI: {path}") check_sections(sections, public_key) + verify_pcr_signatures(read_pe_sections(path), public_key) check_initrd_listing(run("lsinitcpio", "--list", str(path))) run("sbverify", "--cert", str(SB_CERT), str(path)) print(f"verified: {path}") diff --git a/scripts/tpm_unlock.py b/scripts/tpm_unlock.py new file mode 100644 index 0000000..0082d2d --- /dev/null +++ b/scripts/tpm_unlock.py @@ -0,0 +1,108 @@ +"""Audit the LUKS token used for automatic root unlock on Halley2.""" + +import base64 +import binascii +import json +import os +import subprocess +import sys +from pathlib import Path + +from cryptography.hazmat.primitives import serialization + +from scripts.tpm_nvpcr import PUBLIC_KEY + +DEVICE = "/dev/nvme0n1p2" + + +def decoded(value: object, field: str) -> bytes: + if not isinstance(value, str): + raise TypeError(f"Missing {field}") + try: + result = base64.b64decode(value, validate=True) + except (ValueError, binascii.Error) as exc: + raise ValueError(f"Invalid {field}") from exc + if not result: + raise ValueError(f"Empty {field}") + return result + + +def check_header( + header: dict, public_key: bytes, *, trial: bool = False +) -> tuple[str, str]: + tokens = header.get("tokens", {}) + slots = header.get("keyslots", {}) + if not isinstance(tokens, dict) or len(tokens) != 1: + raise ValueError("Expected exactly one automatic-unlock token") + if not isinstance(slots, dict) or "0" not in slots: + raise ValueError("Passphrase keyslot 0 is missing") + token_id, token = next(iter(tokens.items())) + if not isinstance(token, dict) or token.get("type") != "systemd-tpm2": + raise ValueError("Expected one systemd TPM2 token") + assigned = token.get("keyslots") + if not isinstance(assigned, list) or len(assigned) != 1: + raise ValueError("TPM token must name exactly one keyslot") + slot = assigned[0] + if slot == "0" or slot not in slots: + raise ValueError("TPM token has no separate live keyslot") + if not trial and set(slots) != {"0", slot}: + raise ValueError("Unexpected extra keyslot after migration") + + if token.get("tpm2-pcrs") != [] or token.get("tpm2-pcr-bank") != "sha256": + raise ValueError("Unexpected direct PCR binding or PCR bank") + if token.get("tpm2_pcrlock") is not True: + raise ValueError("TPM token does not require pcrlock") + decoded(token.get("tpm2_pcrlock_nv"), "pcrlock NV reference") + if ( + token.get("tpm2_pubkey_pcrs") != [11] + or token.get("tpm2_pubkey_ref") != "luks-root" + ): + raise ValueError( + "TPM token does not require the signed luks-root PCR 11 policy" + ) + embedded = serialization.load_pem_public_key( + decoded(token.get("tpm2_pubkey"), "PCR public key") + ) + installed = serialization.load_pem_public_key(public_key) + encoding = serialization.Encoding.DER + fmt = serialization.PublicFormat.SubjectPublicKeyInfo + if embedded.public_bytes(encoding, fmt) != installed.public_bytes(encoding, fmt): + raise ValueError("TPM token has the wrong PCR public key") + blobs = token.get("tpm2-blob") + hashes = token.get("tpm2-policy-hash") + if not isinstance(blobs, list) or len(blobs) != 2: + raise ValueError("Combined TPM policy must have two sealed blobs") + if not isinstance(hashes, list) or len(hashes) != 2: + raise ValueError("Combined TPM policy must have two policy hashes") + for blob in blobs: + decoded(blob, "sealed blob") + for value in hashes: + if not isinstance(value, str) or len(bytes.fromhex(value)) != 32: + raise ValueError("Invalid TPM policy hash") + return token_id, slot + + +def main() -> None: + if len(sys.argv) > 2 or (len(sys.argv) == 2 and sys.argv[1] != "--trial"): + raise ValueError("Usage: tpm_unlock.py [--trial]") + if os.geteuid() != 0 or Path("/etc/hostname").read_text().strip() != "halley2": + raise ValueError("Run as root on Halley2") + header = json.loads( + subprocess.check_output( + ["cryptsetup", "luksDump", "--dump-json-metadata", DEVICE], text=True + ) + ) + token_id, slot = check_header( + header, PUBLIC_KEY.read_bytes(), trial="--trial" in sys.argv + ) + print( + f"verified: token {token_id}, keyslot {slot}, pcrlock and signed PCR 11 metadata" + ) + + +if __name__ == "__main__": + try: + main() + except (OSError, TypeError, ValueError, subprocess.CalledProcessError) as exc: + print(f"TPM unlock check failed: {exc}", file=sys.stderr) + sys.exit(1) diff --git a/tests/test_nvpcr.py b/tests/test_nvpcr.py index 57b3c81..48464a7 100644 --- a/tests/test_nvpcr.py +++ b/tests/test_nvpcr.py @@ -1,3 +1,4 @@ +import base64 import hashlib import json import subprocess @@ -6,7 +7,10 @@ import unittest from pathlib import Path from unittest import mock -from scripts import tpm_nvpcr, tpm_nvpcr_unmask +from cryptography.hazmat.primitives import serialization +from cryptography.hazmat.primitives.asymmetric import rsa + +from scripts import tpm_negative_uki, tpm_nvpcr, tpm_nvpcr_unmask, tpm_unlock ROOT = Path(__file__).resolve().parents[1] @@ -48,11 +52,12 @@ class NvPCRConfigTests(unittest.TestCase): (ROOT / "run_onchange_after_deploy-etc.sh.tmpl").read_text(), ) - def test_deploy_requires_a_signed_recovery_image_and_backup(self): + def test_deploy_requires_a_signed_main_image_and_backup(self): script = (ROOT / "run_onchange_after_deploy-etc.sh.tmpl").read_text() self.assertIn("backup/etc.tar", script) self.assertIn("backup/arch-linux-lts-fallback.efi", script) self.assertIn("sudo sbverify --cert", script) + self.assertNotIn("arch-linux-hardened-nvpcr-recovery.efi", script) def test_pcr7_policy_is_unchanged(self): policy = ( @@ -62,9 +67,10 @@ class NvPCRConfigTests(unittest.TestCase): for name in ("systemd-tpm2-setup.service", "systemd-tpm2-setup-early.service"): self.assertFalse((ROOT / "etc/systemd/system" / name).exists()) - def test_uki_signs_only_the_initrd_referenced_policy(self): + def test_uki_signs_initrd_and_root_policies(self): config = (ROOT / "etc/kernel/uki.conf").read_text() self.assertIn("PolicyRef=initrd", config) + self.assertIn("PolicyRef=luks-root", config) self.assertIn("Phases=enter-initrd", config) self.assertNotIn("SignInitrdPCRs=", config) @@ -78,7 +84,12 @@ class NvPCRConfigTests(unittest.TestCase): def sections(public_key, *, policies=None, options=None): if policies is None: - policies = {"sha256": [{"pcrs": [11], "ref": "initrd", "sig": "signature"}]} + policies = { + "sha256": [ + {"pcrs": [11], "ref": ref, "sig": "signature"} + for ref in tpm_nvpcr.POLICY_REFS + ] + } if options is None: options = ( "tpm2-device=auto,tpm2-measure-pcr=15,tpm2-measure-keyslot-nvpcr=cryptsetup" @@ -87,7 +98,7 @@ def sections(public_key, *, policies=None, options=None): ".pcrpkey": {"sha256": hashlib.sha256(public_key).hexdigest()}, ".pcrsig": {"text": json.dumps(policies)}, ".cmdline": { - "text": f"rd.luks.name=uuid=root rd.luks.options=uuid={options} root=/dev/mapper/root" + "text": f"rd.luks.name={tpm_nvpcr.ROOT_UUID}=root rd.luks.options={tpm_nvpcr.ROOT_UUID}={options} root=/dev/mapper/root" }, } @@ -133,6 +144,186 @@ class NvPCRImageTests(unittest.TestCase): with self.assertRaisesRegex(ValueError, "initrd PCR 11"): tpm_nvpcr.check_sections(sections(b"key", policies=bad), b"key") + def test_rejects_missing_root_policy(self): + bad = {"sha256": [{"pcrs": [11], "ref": "initrd", "sig": "signature"}]} + with self.assertRaisesRegex(ValueError, "luks-root PCR 11"): + tpm_nvpcr.check_sections(sections(b"key", policies=bad), b"key") + + def test_pcr_signatures_match_measured_sections(self): + key = rsa.generate_private_key(public_exponent=65537, key_size=2048) + private = key.private_bytes( + serialization.Encoding.PEM, + serialization.PrivateFormat.PKCS8, + serialization.NoEncryption(), + ) + public = key.public_key().public_bytes( + serialization.Encoding.PEM, + serialization.PublicFormat.SubjectPublicKeyInfo, + ) + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + private_path = root / "private.pem" + public_path = root / "public.pem" + private_path.write_bytes(private) + private_path.chmod(0o600) + public_path.write_bytes(public) + image = { + ".linux": b"kernel", + ".initrd": b"initrd", + ".pcrpkey": public, + ".cmdline": b"root=/dev/mapper/root", + } + args = [] + for name, content in image.items(): + path = root / name.removeprefix(".") + path.write_bytes(content) + args.append(f"--{name.removeprefix('.')}={path}") + signatures = [] + for ref in tpm_nvpcr.POLICY_REFS: + result = subprocess.check_output( + [ + "/usr/lib/systemd/systemd-measure", + "sign", + "--bank=sha256", + "--phase=enter-initrd", + f"--policyref={ref}", + f"--private-key={private_path}", + f"--public-key={public_path}", + *args, + ], + text=True, + ) + signatures.extend(json.loads(result)["sha256"]) + image[".pcrsig"] = json.dumps({"sha256": signatures}).encode() + with mock.patch.object(tpm_nvpcr, "PUBLIC_KEY", public_path): + tpm_nvpcr.verify_pcr_signatures(image, public) + changed = dict(image, **{".initrd": b"changed"}) + with self.assertRaisesRegex(ValueError, "does not match UKI contents"): + tpm_nvpcr.verify_pcr_signatures(changed, public) + changed = json.loads(image[".pcrsig"]) + changed["sha256"][1]["sig"] = base64.b64encode(b"wrong").decode() + image[".pcrsig"] = json.dumps(changed).encode() + with self.assertRaisesRegex( + ValueError, "Invalid luks-root PCR 11 signature" + ): + tpm_nvpcr.verify_pcr_signatures(image, public) + + def test_ukify_image_signatures_match_its_sections(self): + key = rsa.generate_private_key(public_exponent=65537, key_size=2048) + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + private = root / "private.pem" + public = root / "public.pem" + private.write_bytes( + key.private_bytes( + serialization.Encoding.PEM, + serialization.PrivateFormat.PKCS8, + serialization.NoEncryption(), + ) + ) + private.chmod(0o600) + public.write_bytes( + key.public_key().public_bytes( + serialization.Encoding.PEM, + serialization.PublicFormat.SubjectPublicKeyInfo, + ) + ) + initrd = root / "initrd" + initrd.write_bytes(b"test initrd") + config = root / "uki.conf" + config.write_text( + f"[UKI]\nPCRPKey={public}\nPCRBanks=sha256\n\n" + + "".join( + f"[PCRSignature:{ref}]\nPCRPrivateKey={private}\n" + f"PCRPublicKey={public}\nPhases=enter-initrd\nPolicyRef={ref}\n\n" + for ref in tpm_nvpcr.POLICY_REFS + ) + ) + output = root / "test.efi" + subprocess.run( + [ + "ukify", + "build", + "--config", + str(config), + "--linux=/usr/bin/true", + f"--initrd={initrd}", + f"--output={output}", + ], + check=True, + capture_output=True, + ) + sb_key = root / "secureboot.key" + sb_cert = root / "secureboot.pem" + subprocess.run( + [ + "openssl", + "req", + "-x509", + "-newkey", + "rsa:2048", + "-nodes", + "-keyout", + str(sb_key), + "-out", + str(sb_cert), + "-days", + "1", + "-subj", + "/CN=UKI test", + ], + check=True, + capture_output=True, + ) + signed = root / "signed.efi" + subprocess.run( + [ + "sbsign", + "--key", + str(sb_key), + "--cert", + str(sb_cert), + "--output", + str(signed), + str(output), + ], + check=True, + capture_output=True, + ) + with mock.patch.object(tpm_nvpcr, "PUBLIC_KEY", public): + tpm_nvpcr.verify_pcr_signatures( + tpm_nvpcr.read_pe_sections(signed), public.read_bytes() + ) + negative = root / "negative.efi" + tpm_negative_uki.remove_root_approval( + signed, negative, public.read_bytes() + ) + changed = tpm_nvpcr.read_pe_sections(negative) + tpm_nvpcr.verify_pcr_signatures( + changed, public.read_bytes(), refs=("initrd",) + ) + with self.assertRaisesRegex(ValueError, "luks-root"): + tpm_nvpcr.verify_pcr_signatures(changed, public.read_bytes()) + resigned = root / "resigned.efi" + subprocess.run( + [ + "sbsign", + "--key", + str(sb_key), + "--cert", + str(sb_cert), + "--output", + str(resigned), + str(negative), + ], + check=True, + capture_output=True, + ) + subprocess.run( + ["sbverify", "--cert", str(sb_cert), str(resigned)], + check=True, + ) + def test_rejects_missing_tpm_selection(self): with self.assertRaisesRegex(ValueError, "option is missing"): tpm_nvpcr.check_sections( @@ -146,7 +337,7 @@ class NvPCRImageTests(unittest.TestCase): def test_rejects_wrong_root_uuid(self): image = sections(b"key") image[".cmdline"]["text"] = image[".cmdline"]["text"].replace( - "rd.luks.options=uuid=", "rd.luks.options=other=" + f"rd.luks.options={tpm_nvpcr.ROOT_UUID}=", "rd.luks.options=other=" ) with self.assertRaisesRegex(ValueError, "wrong LUKS UUID"): tpm_nvpcr.check_sections(image, b"key") @@ -175,5 +366,46 @@ class NvPCRUnmaskTests(unittest.TestCase): tpm_nvpcr_unmask.validate([dropin]) +class TPMUnlockTests(unittest.TestCase): + def test_combined_token_and_trial_slot(self): + key = rsa.generate_private_key(public_exponent=65537, key_size=2048) + public = key.public_key().public_bytes( + serialization.Encoding.PEM, + serialization.PublicFormat.SubjectPublicKeyInfo, + ) + token = { + "type": "systemd-tpm2", + "keyslots": ["2"], + "tpm2-pcrs": [], + "tpm2-pcr-bank": "sha256", + "tpm2_pcrlock": True, + "tpm2_pcrlock_nv": base64.b64encode(b"nv").decode(), + "tpm2_pubkey_pcrs": [11], + "tpm2_pubkey_ref": "luks-root", + "tpm2_pubkey": base64.b64encode(public).decode(), + "tpm2-blob": [base64.b64encode(b"blob").decode()] * 2, + "tpm2-policy-hash": ["a0" * 32] * 2, + } + header = {"tokens": {"1": token}, "keyslots": {"0": {}, "1": {}, "2": {}}} + self.assertEqual( + tpm_unlock.check_header(header, public, trial=True), ("1", "2") + ) + with self.assertRaisesRegex(ValueError, "extra keyslot"): + tpm_unlock.check_header(header, public) + del header["keyslots"]["1"] + self.assertEqual(tpm_unlock.check_header(header, public), ("1", "2")) + token["tpm2_pubkey_ref"] = "initrd" + with self.assertRaisesRegex(ValueError, "luks-root"): + tpm_unlock.check_header(header, public) + token["tpm2_pubkey_ref"] = "luks-root" + token["tpm2_pcrlock"] = False + with self.assertRaisesRegex(ValueError, "pcrlock"): + tpm_unlock.check_header(header, public) + token["tpm2_pcrlock"] = True + token["tpm2-blob"] = token["tpm2-blob"][:1] + with self.assertRaisesRegex(ValueError, "two sealed blobs"): + tpm_unlock.check_header(header, public) + + if __name__ == "__main__": unittest.main() -- cgit v1.3.1