summaryrefslogtreecommitdiffstatshomepage
diff options
context:
space:
mode:
-rw-r--r--docs/tpm-unlock.md429
-rw-r--r--etc/kernel/uki.conf6
-rw-r--r--justfile5
-rw-r--r--nix/flake.nix5
-rwxr-xr-xrun_onchange_after_deploy-etc.sh.tmpl6
-rw-r--r--scripts/tpm_negative_uki.py69
-rw-r--r--scripts/tpm_nvpcr.py133
-rw-r--r--scripts/tpm_unlock.py108
-rw-r--r--tests/test_nvpcr.py244
9 files changed, 683 insertions, 322 deletions
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.
-
-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.
-
-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
+`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.
-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.
-
-After a systemd update, check the prediction before rebooting:
+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.
```sh
-sudo /usr/lib/systemd/systemd-pcrlock --pcr=7 --location=770 predict
+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
```
-If PCR 7 is included and the prediction completes without errors, refresh the
-policy and check the journal:
+## Enroll
+
+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 systemctl restart systemd-pcrlock-make-policy.service
-sudo journalctl -b -u systemd-pcrlock-make-policy.service --no-pager
+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}))}'
```
-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.
+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.
-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.
+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
- 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
- ```
+```sh
+sudo cryptsetup token remove --token-id 0 /dev/nvme0n1p2
+just tpm-unlock-check --trial
+```
-### Boot Checks
+## Test
-After the one-time test boot, confirm automatic unlock and run:
+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()