diff options
| author | sommerfeld <sommerfeld@sommerfeld.dev> | 2026-09-30 08:54:59 +0100 |
|---|---|---|
| committer | sommerfeld <sommerfeld@sommerfeld.dev> | 2026-09-30 08:54:59 +0100 |
| commit | c1f7133be790a972400ba63ca40f92ed699d216e (patch) | |
| tree | 12e36f95ff1e87998c1028a6e2c3242cad32aa3a | |
| parent | 76db86a22a23ccfed07d6160cfdab4ef60067ef0 (diff) | |
| download | dotfiles-c1f7133be790a972400ba63ca40f92ed699d216e.tar.gz dotfiles-c1f7133be790a972400ba63ca40f92ed699d216e.tar.bz2 dotfiles-c1f7133be790a972400ba63ca40f92ed699d216e.zip | |
Enable signed initrd and NvPCR measurements
| -rw-r--r-- | docs/tpm-unlock.md | 219 | ||||
| -rw-r--r-- | etc/initcpio/install/nvpcr | 14 | ||||
| -rw-r--r-- | etc/kernel/cmdline-linux-hardened.tmpl | 2 | ||||
| -rw-r--r-- | etc/kernel/cmdline.tmpl | 2 | ||||
| -rw-r--r-- | etc/kernel/uki.conf | 9 | ||||
| -rw-r--r-- | etc/mkinitcpio.conf | 2 | ||||
| -rw-r--r-- | etc/mkinitcpio.conf.d/60-no-nvpcr.conf | 9 | ||||
| -rw-r--r-- | etc/nvpcr/cryptsetup.nvpcr | 0 | ||||
| -rw-r--r-- | etc/nvpcr/hardware.nvpcr | 0 | ||||
| -rw-r--r-- | etc/nvpcr/login.nvpcr | 0 | ||||
| -rw-r--r-- | etc/nvpcr/verity.nvpcr | 0 | ||||
| -rw-r--r-- | etc/systemd/system/systemd-pcrlogin@.service | 0 | ||||
| -rw-r--r-- | etc/systemd/system/systemd-pcrproduct.service | 0 | ||||
| -rw-r--r-- | justfile | 15 | ||||
| -rw-r--r-- | meta/base.txt | 3 | ||||
| -rwxr-xr-x | run_onchange_after_deploy-etc.sh.tmpl | 38 | ||||
| -rw-r--r-- | scripts/tpm_nvpcr.py | 133 | ||||
| -rw-r--r-- | scripts/tpm_nvpcr_unmask.py | 53 | ||||
| -rw-r--r-- | tests/test_nvpcr.py | 146 | ||||
| -rw-r--r-- | tests/test_nvpcr_masks.py | 43 |
20 files changed, 600 insertions, 88 deletions
diff --git a/docs/tpm-unlock.md b/docs/tpm-unlock.md index 0cd915b..be02edd 100644 --- a/docs/tpm-unlock.md +++ b/docs/tpm-unlock.md @@ -59,56 +59,209 @@ 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. -## Unused NvPCR Measurements +## NvPCR Measurements -This host uses PCR 7 for root unlock. It does not use the default `cryptsetup`, -`hardware`, `login`, or `verity` NvPCR measurements. Systemd 262 requires a signed -initrd PCR policy for NvPCR initialization, which this boot setup does not provide. -This is separate from the NV index that stores the `pcrlock` policy. +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. -Empty files in `etc/nvpcr/` mask the four vendor definitions. Empty unit files -mask only `systemd-pcrproduct.service` and `systemd-pcrlogin@.service`. Keep these -files at zero bytes: adding comments removes the mask. SRK setup and PCR 7 -policy maintenance remain active. No TPM index or LUKS keyslot is deleted. -The mkinitcpio drop-in includes the masks in each rebuilt initramfs. +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. -Deploy outside aibox, with the recovery passphrase available: +### First Activation -```sh -just apply -sudo mkinitcpio -P -sudo sbctl sign-all -sudo sbctl verify -``` +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. -Stop if image generation or signing fails. Confirm that the rebuilt UKIs are -signed before rebooting. No re-enrollment is required. The current boot's failed -setup status can remain until the next boot; do not restart TPM setup merely -to clear that status. +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. -After an approved reboot, confirm automatic unlock and inspect: + ```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, then + verifies each Secure Boot signature with the enrolled `sbctl` certificate. + Stop on any mismatch. The `systemd-pcrextend` socket and early setup units + must be present in both initramfs variants. + +7. Copy the checked images to the ESP with temporary names, then rename them + into place. 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" + done + for name in arch-linux-hardened.efi arch-linux-hardened-fallback.efi \ + arch-linux-lts.efi arch-linux-lts-fallback.efi; do + 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 an approved reboot into a new UKI, confirm automatic unlock and run: ```sh +just tpm-nvpcr-check sudo journalctl -b --no-pager \ -u systemd-tpm2-setup-early.service \ - -u systemd-tpm2-setup.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 systemctl --failed --no-pager -systemctl is-enabled systemd-pcrproduct.service systemd-pcrlogin@.service +systemd-analyze nvpcrs ``` -The two measurement units must report `masked`. SRK setup must have no NvPCR -initialization failures, and the policy must retain PCR 7 protection. +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. +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. + +### Rollback + +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. + +```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 +``` -To roll back, remove the four `/etc/nvpcr/*.nvpcr` masks listed above, the two -empty unit files, and `/etc/mkinitcpio.conf.d/60-no-nvpcr.conf`. Remove the same -files from the repo to prevent redeployment. Reload systemd, rebuild and sign -the UKIs, and test at the next approved reboot. This restores the vendor -behavior, including its errors if signed initrd PCR policies are still absent. +Revert this task's repo commit before another `just apply`. The rollback marker +blocks redeployment until then. Keep the key pair and recovery UKI for diagnosis. Sources: [systemd 262 release notes](https://github.com/systemd/systemd/releases/tag/v262), -[NvPCR setup](https://github.com/systemd/systemd/blob/v262/src/tpm2-setup/tpm2-setup.c), -and [empty-file mask handling](https://github.com/systemd/systemd/blob/v262/src/basic/conf-files.c). +[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). ## Recovery diff --git a/etc/initcpio/install/nvpcr b/etc/initcpio/install/nvpcr new file mode 100644 index 0000000..2fbc4ec --- /dev/null +++ b/etc/initcpio/install/nvpcr @@ -0,0 +1,14 @@ +#!/usr/bin/env bash + +build() { + # mkinitcpio 42 does not include these systemd 262 initrd services. + map add_systemd_unit \ + systemd-tpm2-setup-early.service \ + systemd-pcrnvdone.service \ + systemd-pcrextend.socket \ + systemd-pcrextend@.service +} + +help() { + echo 'Include systemd NvPCR initialization and PCR extension services.' +} diff --git a/etc/kernel/cmdline-linux-hardened.tmpl b/etc/kernel/cmdline-linux-hardened.tmpl index 674904f..a0c3b3f 100644 --- a/etc/kernel/cmdline-linux-hardened.tmpl +++ b/etc/kernel/cmdline-linux-hardened.tmpl @@ -1 +1 @@ -rd.luks.name={{ output "lsblk" "-dno" "UUID" (printf "/dev/%s" .luksRootPartition) | trim }}=root root=/dev/mapper/root rw quiet mem_sleep_default=s2idle apparmor=1 lsm=landlock,lockdown,yama,integrity,apparmor,bpf +rd.luks.name={{ output "lsblk" "-dno" "UUID" (printf "/dev/%s" .luksRootPartition) | trim }}=root rd.luks.options={{ output "lsblk" "-dno" "UUID" (printf "/dev/%s" .luksRootPartition) | trim }}=tpm2-device=auto,tpm2-measure-pcr=15,tpm2-measure-keyslot-nvpcr=cryptsetup root=/dev/mapper/root rw quiet mem_sleep_default=s2idle apparmor=1 lsm=landlock,lockdown,yama,integrity,apparmor,bpf diff --git a/etc/kernel/cmdline.tmpl b/etc/kernel/cmdline.tmpl index 555083a..860cdb0 100644 --- a/etc/kernel/cmdline.tmpl +++ b/etc/kernel/cmdline.tmpl @@ -1 +1 @@ -rd.luks.name={{ output "lsblk" "-dno" "UUID" (printf "/dev/%s" .luksRootPartition) | trim }}=root root=/dev/mapper/root rw quiet apparmor=1 lsm=landlock,lockdown,yama,integrity,apparmor,bpf +rd.luks.name={{ output "lsblk" "-dno" "UUID" (printf "/dev/%s" .luksRootPartition) | trim }}=root rd.luks.options={{ output "lsblk" "-dno" "UUID" (printf "/dev/%s" .luksRootPartition) | trim }}=tpm2-device=auto,tpm2-measure-pcr=15,tpm2-measure-keyslot-nvpcr=cryptsetup root=/dev/mapper/root rw quiet apparmor=1 lsm=landlock,lockdown,yama,integrity,apparmor,bpf diff --git a/etc/kernel/uki.conf b/etc/kernel/uki.conf new file mode 100644 index 0000000..82b7801 --- /dev/null +++ b/etc/kernel/uki.conf @@ -0,0 +1,9 @@ +[UKI] +PCRPKey=/etc/systemd/tpm2-pcr-public-key.pem +PCRBanks=sha256 + +[PCRSignature:initrd] +PCRPrivateKey=/etc/systemd/tpm2-pcr-private-key.pem +PCRPublicKey=/etc/systemd/tpm2-pcr-public-key.pem +Phases=enter-initrd +PolicyRef=initrd diff --git a/etc/mkinitcpio.conf b/etc/mkinitcpio.conf index fab5fa1..e9aca13 100644 --- a/etc/mkinitcpio.conf +++ b/etc/mkinitcpio.conf @@ -52,7 +52,7 @@ FILES=() # ## NOTE: If you have /usr on a separate partition, you MUST include the # usr and fsck hooks. -HOOKS=(base systemd autodetect microcode modconf kms keyboard keymap sd-vconsole block sd-encrypt filesystems fsck) +HOOKS=(base systemd nvpcr autodetect microcode modconf kms keyboard keymap sd-vconsole block sd-encrypt filesystems fsck) # COMPRESSION # Use this to compress the initramfs image. By default, zstd compression diff --git a/etc/mkinitcpio.conf.d/60-no-nvpcr.conf b/etc/mkinitcpio.conf.d/60-no-nvpcr.conf deleted file mode 100644 index 06b94ff..0000000 --- a/etc/mkinitcpio.conf.d/60-no-nvpcr.conf +++ /dev/null @@ -1,9 +0,0 @@ -# Keep the unused NvPCR definitions and consumers masked in the initramfs. -FILES+=( - /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 -) diff --git a/etc/nvpcr/cryptsetup.nvpcr b/etc/nvpcr/cryptsetup.nvpcr deleted file mode 100644 index e69de29..0000000 --- a/etc/nvpcr/cryptsetup.nvpcr +++ /dev/null diff --git a/etc/nvpcr/hardware.nvpcr b/etc/nvpcr/hardware.nvpcr deleted file mode 100644 index e69de29..0000000 --- a/etc/nvpcr/hardware.nvpcr +++ /dev/null diff --git a/etc/nvpcr/login.nvpcr b/etc/nvpcr/login.nvpcr deleted file mode 100644 index e69de29..0000000 --- a/etc/nvpcr/login.nvpcr +++ /dev/null diff --git a/etc/nvpcr/verity.nvpcr b/etc/nvpcr/verity.nvpcr deleted file mode 100644 index e69de29..0000000 --- a/etc/nvpcr/verity.nvpcr +++ /dev/null diff --git a/etc/systemd/system/systemd-pcrlogin@.service b/etc/systemd/system/systemd-pcrlogin@.service deleted file mode 100644 index e69de29..0000000 --- a/etc/systemd/system/systemd-pcrlogin@.service +++ /dev/null diff --git a/etc/systemd/system/systemd-pcrproduct.service b/etc/systemd/system/systemd-pcrproduct.service deleted file mode 100644 index e69de29..0000000 --- a/etc/systemd/system/systemd-pcrproduct.service +++ /dev/null @@ -149,6 +149,21 @@ nix-daemon-update: _require-canonical test: @python3 -m unittest discover -s tests -v +# Check the signed UKIs and NvPCR configuration. Pass staged UKIs to omit boot checks. +[positional-arguments] +tpm-nvpcr-check +images: _require-host + @sudo /usr/bin/python3 scripts/tpm_nvpcr.py "$@" + +# 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 + +_require-host: + #!/usr/bin/env bash + set -eu + source just-lib.sh + _require_host + # ═══════════════════════════════════════════════════════════════════ # Updates # ═══════════════════════════════════════════════════════════════════ diff --git a/meta/base.txt b/meta/base.txt index 1e7fda3..f97e7e2 100644 --- a/meta/base.txt +++ b/meta/base.txt @@ -22,6 +22,9 @@ ocl-icd pacman-contrib # paccache for etc/pacman.d/hooks/pacman-cache-cleanup.hook reflector sbctl +sbsigntools +systemd-ukify +efibootmgr smartmontools sudo-rs systemd-resolvconf diff --git a/run_onchange_after_deploy-etc.sh.tmpl b/run_onchange_after_deploy-etc.sh.tmpl index 50b0d98..021d81d 100755 --- a/run_onchange_after_deploy-etc.sh.tmpl +++ b/run_onchange_after_deploy-etc.sh.tmpl @@ -8,6 +8,41 @@ set -eu cd "$CHEZMOI_SOURCE_DIR" +if [ -f etc/kernel/uki.conf ]; then + systemd_version=$(pacman -Q systemd | awk '{ print $2 }') + ukify_version=$(pacman -Q systemd-ukify | awk '{ print $2 }') + if [ "$systemd_version" != "$ukify_version" ] || ! command -v ukify >/dev/null 2>&1; then + echo 'Install a systemd-ukify version that matches systemd before deploying NvPCR support.' >&2 + exit 1 + fi + if ! command -v sbverify >/dev/null 2>&1; then + echo 'Install sbsigntools before deploying NvPCR support.' >&2 + exit 1 + 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 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 + exit 1 + fi + if [ "$(sudo stat -c %U:%G:%a /etc/systemd/tpm2-pcr-private-key.pem)" != root:root:600 ] || + ! sudo openssl pkey -in /etc/systemd/tpm2-pcr-private-key.pem -pubout 2>/dev/null | + sudo cmp -s /etc/systemd/tpm2-pcr-public-key.pem -; then + echo 'The root-owned PCR signing key pair is invalid or has unsafe permissions.' >&2 + exit 1 + fi + if sudo test -e /var/lib/dotfiles/tpm-nvpcr/rollback; then + echo 'NvPCR rollback is active. Restore the previous repo revision before applying.' >&2 + exit 1 + fi +fi find etc -type f ! -name .ignore | while IFS= read -r src; do case "$src" in *.tmpl) @@ -23,6 +58,9 @@ find etc -type f ! -name .ignore | while IFS= read -r src; do etc/systemd/system-sleep/*) sudo install -D -m 0755 -o root -g root "$src" "/${src}" ;; + etc/initcpio/install/*) + sudo install -D -m 0755 -o root -g root "$src" "/${src}" + ;; *) sudo install -D -m 0644 -o root -g root "$src" "/${src}" ;; diff --git a/scripts/tpm_nvpcr.py b/scripts/tpm_nvpcr.py new file mode 100644 index 0000000..4f0f0a7 --- /dev/null +++ b/scripts/tpm_nvpcr.py @@ -0,0 +1,133 @@ +"""Read-only checks for Halley2's signed NvPCR UKIs.""" + +import hashlib +import json +import os +import subprocess +import sys +from pathlib import Path + +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") +IMAGES = ( + "arch-linux-hardened.efi", + "arch-linux-hardened-fallback.efi", + "arch-linux-lts.efi", + "arch-linux-lts-fallback.efi", +) +MEASUREMENT_OPTIONS = ( + "tpm2-device=auto", + "tpm2-measure-pcr=15", + "tpm2-measure-keyslot-nvpcr=cryptsetup", +) + + +def run(*command: str) -> str: + result = subprocess.run(command, capture_output=True, text=True, check=True) + return result.stdout + + +def check_sections(sections: dict, public_key: bytes) -> None: + public = sections.get(".pcrpkey") + if ( + not isinstance(public, dict) + or public.get("sha256") != hashlib.sha256(public_key).hexdigest() + ): + raise ValueError("UKI does not embed the expected PCR public key") + + signature = sections.get(".pcrsig") + 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") + + 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") + options = [ + arg.removeprefix("rd.luks.options=") + for arg in args + if arg.startswith("rd.luks.options=") + ] + if len(options) != 1 or not options[0].startswith(roots[0] + "="): + raise ValueError("Root measurement options have the wrong LUKS UUID") + 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") + + +def check_image(path: Path, public_key: bytes) -> None: + sections = json.loads( + run( + "ukify", + "--json=short", + "--section=.pcrpkey:binary", + "--section=.pcrsig:text", + "--section=.cmdline:text", + "inspect", + str(path), + ) + ) + if not isinstance(sections, dict) or "_profiles" in sections: + raise ValueError(f"Unsupported multi-profile UKI: {path}") + check_sections(sections, public_key) + run("sbverify", "--cert", str(SB_CERT), str(path)) + print(f"verified: {path}") + + +def check_boot() -> None: + active = Path("/proc/cmdline").read_text() + if not all(option in active for option in MEASUREMENT_OPTIONS): + raise ValueError("Current boot did not use the NvPCR root options") + nvpcrs = run("systemd-analyze", "nvpcrs", "--no-pager") + for name in ("cryptsetup", "hardware", "login", "verity"): + if name not in nvpcrs: + raise ValueError(f"NvPCR {name} is not available") + run( + "/usr/lib/systemd/systemd-pcrlock", + "--strict=yes", + "--pcr=7", + "--location=770", + "predict", + ) + failed = run("systemctl", "--failed", "--no-legend", "--no-pager") + if failed.strip(): + raise ValueError("Systemd has failed units; inspect systemctl --failed") + print( + "verified: current boot options, NvPCR availability, and strict PCR 7 prediction" + ) + + +def main() -> int: + if os.geteuid() != 0 or Path("/etc/hostname").read_text().strip() != "halley2": + raise ValueError("Run as root on Halley2") + public_key = PUBLIC_KEY.read_bytes() + if not public_key: + raise ValueError("Missing PCR public key") + paths = [Path(arg) for arg in sys.argv[1:]] or [BOOT / name for name in IMAGES] + for path in paths: + check_image(path, public_key) + if len(sys.argv) == 1: + check_boot() + return 0 + + +if __name__ == "__main__": + try: + sys.exit(main()) + except (OSError, TypeError, ValueError, subprocess.CalledProcessError) as exc: + print(f"NvPCR check failed: {exc}", file=sys.stderr) + sys.exit(1) diff --git a/scripts/tpm_nvpcr_unmask.py b/scripts/tpm_nvpcr_unmask.py new file mode 100644 index 0000000..0c4ea43 --- /dev/null +++ b/scripts/tpm_nvpcr_unmask.py @@ -0,0 +1,53 @@ +"""Remove only the old NvPCR masks owned by this repository.""" + +import hashlib +import os +from pathlib import Path + +MASKS = [ + *( + Path(f"/etc/nvpcr/{name}.nvpcr") + for name in ("cryptsetup", "hardware", "login", "verity") + ), + Path("/etc/systemd/system/systemd-pcrlogin@.service"), + Path("/etc/systemd/system/systemd-pcrproduct.service"), +] +DROPIN = Path("/etc/mkinitcpio.conf.d/60-no-nvpcr.conf") +DROPIN_SHA256 = "69a242f865bbd1adee85f168f19e0f4e5b94a78b3d65e5dfa976c5e0f76deea4" +RECOVERY = Path("/boot/EFI/Linux/arch-linux-hardened-nvpcr-recovery.efi") +BACKUP = Path("/var/lib/dotfiles/tpm-nvpcr/backup") + + +def validate(paths: list[Path]) -> list[Path]: + removable = [] + for path in paths: + if not path.exists() and not path.is_symlink(): + continue + if not path.is_file() or path.is_symlink(): + raise ValueError(f"Expected a regular file: {path}") + data = path.read_bytes() + if path == DROPIN: + if hashlib.sha256(data).hexdigest() != DROPIN_SHA256: + raise ValueError(f"Old mkinitcpio drop-in was changed: {path}") + elif data: + raise ValueError(f"NvPCR mask is not empty: {path}") + removable.append(path) + return removable + + +def main() -> None: + if os.geteuid() != 0 or Path("/etc/hostname").read_text().strip() != "halley2": + raise ValueError("Run as root on Halley2") + if not RECOVERY.is_file() or not BACKUP.is_dir(): + raise ValueError("Recovery image and backup must exist before removing masks") + removable = validate([*MASKS, DROPIN]) + for path in removable: + path.unlink() + print(f"removed: {path}") + + +if __name__ == "__main__": + try: + main() + except (OSError, ValueError) as exc: + raise SystemExit(f"NvPCR unmask failed: {exc}") from exc diff --git a/tests/test_nvpcr.py b/tests/test_nvpcr.py new file mode 100644 index 0000000..f5765e4 --- /dev/null +++ b/tests/test_nvpcr.py @@ -0,0 +1,146 @@ +import hashlib +import json +import subprocess +import tempfile +import unittest +from pathlib import Path +from unittest import mock + +from scripts import tpm_nvpcr, tpm_nvpcr_unmask + +ROOT = Path(__file__).resolve().parents[1] + + +class NvPCRConfigTests(unittest.TestCase): + def test_hook_adds_required_initrd_services(self): + hook = ROOT / "etc/initcpio/install/nvpcr" + output = subprocess.check_output( + [ + "bash", + "-c", + 'map() { "$@"; }; add_systemd_unit() { printf "%s\\n" "$@"; }; source "$1"; build', + "bash", + str(hook), + ], + text=True, + ).splitlines() + self.assertEqual( + set(output), + { + "systemd-tpm2-setup-early.service", + "systemd-pcrnvdone.service", + "systemd-pcrextend.socket", + "systemd-pcrextend@.service", + }, + ) + self.assertIn( + "etc/initcpio/install/*)", + (ROOT / "run_onchange_after_deploy-etc.sh.tmpl").read_text(), + ) + + def test_deploy_requires_a_signed_recovery_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) + + def test_pcr7_policy_is_unchanged(self): + policy = ( + ROOT / "etc/systemd/system/systemd-pcrlock-make-policy.service.d/pcr7.conf" + ) + self.assertIn("--pcr=7 --location=770", policy.read_text()) + 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): + config = (ROOT / "etc/kernel/uki.conf").read_text() + self.assertIn("PolicyRef=initrd", config) + self.assertIn("Phases=enter-initrd", config) + self.assertNotIn("SignInitrdPCRs=", config) + + def test_old_masks_are_not_in_source(self): + for name in ("cryptsetup", "hardware", "login", "verity"): + self.assertFalse((ROOT / f"etc/nvpcr/{name}.nvpcr").exists()) + self.assertFalse((ROOT / "etc/mkinitcpio.conf.d/60-no-nvpcr.conf").exists()) + for name in ("systemd-pcrlogin@.service", "systemd-pcrproduct.service"): + self.assertFalse((ROOT / "etc/systemd/system" / name).exists()) + + +def sections(public_key, *, policies=None, options=None): + if policies is None: + policies = {"sha256": [{"pcrs": [11], "ref": "initrd", "sig": "signature"}]} + if options is None: + options = ( + "tpm2-device=auto,tpm2-measure-pcr=15,tpm2-measure-keyslot-nvpcr=cryptsetup" + ) + return { + ".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" + }, + } + + +class NvPCRImageTests(unittest.TestCase): + def test_valid_initrd_signature_and_root_options(self): + tpm_nvpcr.check_sections(sections(b"public key"), b"public key") + + def test_rejects_wrong_public_key(self): + with self.assertRaisesRegex(ValueError, "public key"): + tpm_nvpcr.check_sections(sections(b"one"), b"two") + + def test_rejects_policy_without_initrd_reference(self): + bad = {"sha256": [{"pcrs": [11], "ref": "system", "sig": "signature"}]} + with self.assertRaisesRegex(ValueError, "initrd PCR 11"): + tpm_nvpcr.check_sections(sections(b"key", policies=bad), b"key") + + def test_rejects_unsigned_initrd_policy(self): + bad = {"sha256": [{"pcrs": [11], "ref": "initrd"}]} + with self.assertRaisesRegex(ValueError, "initrd PCR 11"): + tpm_nvpcr.check_sections(sections(b"key", policies=bad), b"key") + + def test_rejects_missing_tpm_selection(self): + with self.assertRaisesRegex(ValueError, "option is missing"): + tpm_nvpcr.check_sections( + sections( + b"key", + options="tpm2-measure-pcr=15,tpm2-measure-keyslot-nvpcr=cryptsetup", + ), + b"key", + ) + + 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=" + ) + with self.assertRaisesRegex(ValueError, "wrong LUKS UUID"): + tpm_nvpcr.check_sections(image, b"key") + + +class NvPCRUnmaskTests(unittest.TestCase): + def test_refuses_any_modified_file_before_deleting_masks(self): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + first = root / "first.nvpcr" + changed = root / "second.nvpcr" + first.touch() + changed.write_text("owned by user") + with self.assertRaisesRegex(ValueError, "not empty"): + tpm_nvpcr_unmask.validate([first, changed]) + self.assertTrue(first.exists()) + + def test_accepts_only_expected_dropin_contents(self): + with tempfile.TemporaryDirectory() as directory: + dropin = Path(directory) / "dropin.conf" + dropin.write_text("changed") + with ( + mock.patch.object(tpm_nvpcr_unmask, "DROPIN", dropin), + self.assertRaisesRegex(ValueError, "was changed"), + ): + tpm_nvpcr_unmask.validate([dropin]) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_nvpcr_masks.py b/tests/test_nvpcr_masks.py deleted file mode 100644 index b2ec429..0000000 --- a/tests/test_nvpcr_masks.py +++ /dev/null @@ -1,43 +0,0 @@ -import subprocess -import unittest -from pathlib import Path - -ROOT = Path(__file__).resolve().parents[1] -MASKS = [ - *( - f"etc/nvpcr/{name}.nvpcr" - for name in ("cryptsetup", "hardware", "login", "verity") - ), - "etc/systemd/system/systemd-pcrlogin@.service", - "etc/systemd/system/systemd-pcrproduct.service", -] - - -class NvPCRTests(unittest.TestCase): - def test_masks_are_empty_regular_files(self): - for name in MASKS: - path = ROOT / name - self.assertFalse(path.is_symlink()) - self.assertEqual(path.read_bytes(), b"") - - def test_initramfs_keeps_existing_files_and_includes_masks(self): - config = ROOT / "etc/mkinitcpio.conf.d/60-no-nvpcr.conf" - output = subprocess.check_output( - [ - "bash", - "-c", - 'FILES=(existing); source "$1"; printf "%s\\n" "${FILES[@]}"', - "bash", - str(config), - ], - text=True, - ).splitlines() - self.assertEqual(output, ["existing", *(f"/{name}" for name in MASKS)]) - - def test_srk_setup_and_pcr7_policy_remain_enabled(self): - for name in ("systemd-tpm2-setup.service", "systemd-tpm2-setup-early.service"): - self.assertFalse((ROOT / "etc/systemd/system" / name).exists()) - policy = ( - ROOT / "etc/systemd/system/systemd-pcrlock-make-policy.service.d/pcr7.conf" - ) - self.assertIn("--pcr=7 --location=770", policy.read_text()) |
