From e2801701c9256d3868df072ddf60d4c82c78dc17 Mon Sep 17 00:00:00 2001 From: sommerfeld Date: Wed, 30 Sep 2026 08:54:59 +0100 Subject: Check NvPCR services in staged UKIs --- docs/tpm-unlock.md | 8 ++++---- scripts/tpm_nvpcr.py | 14 ++++++++++++++ tests/test_nvpcr.py | 15 +++++++++++++++ 3 files changed, 33 insertions(+), 4 deletions(-) diff --git a/docs/tpm-unlock.md b/docs/tpm-unlock.md index 6a95ac7..653b099 100644 --- a/docs/tpm-unlock.md +++ b/docs/tpm-unlock.md @@ -185,10 +185,10 @@ is agreed. Stop if any command fails. /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. + 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 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 diff --git a/scripts/tpm_nvpcr.py b/scripts/tpm_nvpcr.py index 4f0f0a7..14a250c 100644 --- a/scripts/tpm_nvpcr.py +++ b/scripts/tpm_nvpcr.py @@ -21,6 +21,12 @@ MEASUREMENT_OPTIONS = ( "tpm2-measure-pcr=15", "tpm2-measure-keyslot-nvpcr=cryptsetup", ) +INITRD_UNITS = ( + "systemd-tpm2-setup-early.service", + "systemd-pcrnvdone.service", + "systemd-pcrextend.socket", + "systemd-pcrextend@.service", +) def run(*command: str) -> str: @@ -69,6 +75,13 @@ def check_sections(sections: dict, public_key: bytes) -> None: raise ValueError("Root measurement or TPM unlock option is missing") +def check_initrd_listing(listing: str) -> None: + members = {line.strip().lstrip("./") for line in listing.splitlines()} + for unit in INITRD_UNITS: + if f"usr/lib/systemd/system/{unit}" not in members: + raise ValueError(f"UKI initrd lacks {unit}") + + def check_image(path: Path, public_key: bytes) -> None: sections = json.loads( run( @@ -84,6 +97,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) + check_initrd_listing(run("lsinitcpio", "--list", str(path))) run("sbverify", "--cert", str(SB_CERT), str(path)) print(f"verified: {path}") diff --git a/tests/test_nvpcr.py b/tests/test_nvpcr.py index f5765e4..5f36bb7 100644 --- a/tests/test_nvpcr.py +++ b/tests/test_nvpcr.py @@ -83,6 +83,21 @@ def sections(public_key, *, policies=None, options=None): class NvPCRImageTests(unittest.TestCase): + def test_initrd_has_required_units(self): + listing = "\n".join( + f"usr/lib/systemd/system/{unit}" for unit in tpm_nvpcr.INITRD_UNITS + ) + tpm_nvpcr.check_initrd_listing(listing) + + def test_rejects_missing_initrd_service(self): + listing = "\n".join( + f"usr/lib/systemd/system/{unit}" + for unit in tpm_nvpcr.INITRD_UNITS + if unit != "systemd-pcrnvdone.service" + ) + with self.assertRaisesRegex(ValueError, "systemd-pcrnvdone.service"): + tpm_nvpcr.check_initrd_listing(listing) + def test_valid_initrd_signature_and_root_options(self): tpm_nvpcr.check_sections(sections(b"public key"), b"public key") -- cgit v1.3.1