summaryrefslogtreecommitdiffstatshomepage
diff options
context:
space:
mode:
-rw-r--r--docs/tpm-unlock.md219
-rw-r--r--etc/initcpio/install/nvpcr14
-rw-r--r--etc/kernel/cmdline-linux-hardened.tmpl2
-rw-r--r--etc/kernel/cmdline.tmpl2
-rw-r--r--etc/kernel/uki.conf9
-rw-r--r--etc/mkinitcpio.conf2
-rw-r--r--etc/mkinitcpio.conf.d/60-no-nvpcr.conf9
-rw-r--r--etc/nvpcr/cryptsetup.nvpcr0
-rw-r--r--etc/nvpcr/hardware.nvpcr0
-rw-r--r--etc/nvpcr/login.nvpcr0
-rw-r--r--etc/nvpcr/verity.nvpcr0
-rw-r--r--etc/systemd/system/systemd-pcrlogin@.service0
-rw-r--r--etc/systemd/system/systemd-pcrproduct.service0
-rw-r--r--justfile15
-rw-r--r--meta/base.txt3
-rwxr-xr-xrun_onchange_after_deploy-etc.sh.tmpl38
-rw-r--r--scripts/tpm_nvpcr.py133
-rw-r--r--scripts/tpm_nvpcr_unmask.py53
-rw-r--r--tests/test_nvpcr.py146
-rw-r--r--tests/test_nvpcr_masks.py43
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
diff --git a/justfile b/justfile
index daeb4f0..1299506 100644
--- a/justfile
+++ b/justfile
@@ -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())