From 67b03911aa43bac7c324c01bdb2bb6a31121acb5 Mon Sep 17 00:00:00 2001 From: sommerfeld Date: Wed, 30 Sep 2026 08:54:59 +0100 Subject: Bind TPM pcrlock policy to GPT measurements --- docs/tpm-unlock.md | 68 ++++++++++++++++++++++ .../pcr7.conf | 3 +- scripts/tpm_nvpcr.py | 49 +++++++++++++++- tests/test_nvpcr.py | 68 +++++++++++++++++++++- 4 files changed, 184 insertions(+), 4 deletions(-) diff --git a/docs/tpm-unlock.md b/docs/tpm-unlock.md index d60b5e7..4d17f1e 100644 --- a/docs/tpm-unlock.md +++ b/docs/tpm-unlock.md @@ -176,3 +176,71 @@ 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). + +## Add the GPT partition policy + +The current root token already requires the pcrlock policy and signed PCR 11. +This change adds PCR 5 to the existing pcrlock policy. PCR 5 covers the disk +partition table. It does not bind file contents, the whole disk, or a kernel +version. Do not enroll a new LUKS token or remove the disk passphrase. + +Run this once on Halley2, outside aibox. Keep the disk passphrase and pcrlock +recovery PIN available. Stop if a check fails. Confirm that `/dev/nvme0n1` is +the disk that contains the root volume. Do not reboot during these steps. + +```sh +sudo cryptsetup open --test-passphrase --key-slot 0 /dev/nvme0n1p2 +sudo /usr/lib/systemd/systemd-pcrlock --strict=yes --pcr=7 --location=770 predict +sudo test ! -e /var/lib/pcrlock.d/600-gpt.pcrlock.d/generated.pcrlock +sudo test ! -e /var/lib/dotfiles/tpm-gpt/pcr7-policy.json +sudo install -d -m 0700 /var/lib/dotfiles/tpm-gpt +sudo cp /var/lib/systemd/pcrlock.json /var/lib/dotfiles/tpm-gpt/pcr7-policy.json +sudo cp /etc/systemd/system/systemd-pcrlock-make-policy.service.d/pcr7.conf \ + /var/lib/dotfiles/tpm-gpt/pcr7.conf +sudo /usr/lib/systemd/systemd-pcrlock lock-gpt /dev/nvme0n1 +sudo /usr/lib/systemd/systemd-pcrlock --strict=yes --pcr=5 --pcr=7 \ + --location=770 predict +just apply +sudo /usr/lib/systemd/systemd-pcrlock make-policy \ + --strict=yes --pcr=5 --pcr=7 --location=770 +sudo jq -e --slurpfile old /var/lib/dotfiles/tpm-gpt/pcr7-policy.json \ + '.nvIndex == $old[0].nvIndex and .nvHandle == $old[0].nvHandle' \ + /var/lib/systemd/pcrlock.json >/dev/null +just tpm-nvpcr-check +just tpm-unlock-check +``` + +The GPT command writes +`/var/lib/pcrlock.d/600-gpt.pcrlock.d/generated.pcrlock`. Stop and inspect +that file if it already exists. `just apply` installs the service override. +The `jq` check confirms that the existing TPM NV index is still in use. +The new checker requires both PCRs in the stored policy, a matching EFI boot +credential, and a strict prediction. Keep the backup on encrypted root storage. +The first normal reboot is the test of automatic unlock; do it only when you +are ready to enter the disk passphrase if TPM unlock fails. After that boot, +run both checks again and check `systemctl --failed`. + +If a planned partition change makes PCR 5 differ, use the disk passphrase to +boot. Review the new layout before you approve it. Then run `lock-gpt` again, +run the strict PCR 5+7 prediction, and update the policy with `make-policy`. +The pcrlock recovery PIN can authorize the update when the old policy no +longer matches. Do not approve an unexpected partition change. + +If the new policy blocks automatic unlock, use the disk passphrase. To return +to the saved PCR 7 policy, restore the old service override and update the +TPM policy. `--force` also rewrites the EFI credential when the prediction +equals an earlier policy. Do not copy the old JSON directly over the active +policy: the TPM NV index must be updated too. + +```sh +sudo install -m 0644 /var/lib/dotfiles/tpm-gpt/pcr7.conf \ + /etc/systemd/system/systemd-pcrlock-make-policy.service.d/pcr7.conf +sudo systemctl daemon-reload +sudo /usr/lib/systemd/systemd-pcrlock make-policy --force \ + --recovery-pin=query --strict=yes --pcr=7 --location=770 +sudo /usr/lib/systemd/systemd-pcrlock --strict=yes --pcr=7 --location=770 predict +``` + +The repo checker will then fail until you restore the PCR 5 setup. Do not +delete the saved policy or recovery PIN. The EFI boot test and root token +checks remain necessary because an offline prediction cannot prove unlock. diff --git a/etc/systemd/system/systemd-pcrlock-make-policy.service.d/pcr7.conf b/etc/systemd/system/systemd-pcrlock-make-policy.service.d/pcr7.conf index 01a51a5..0bc00f2 100644 --- a/etc/systemd/system/systemd-pcrlock-make-policy.service.d/pcr7.conf +++ b/etc/systemd/system/systemd-pcrlock-make-policy.service.d/pcr7.conf @@ -2,8 +2,9 @@ ConditionPathExists=/var/lib/systemd/pcrlock.json ConditionPathExistsGlob=/var/lib/pcrlock.d/*-secureboot-policy.pcrlock.d/generated.pcrlock ConditionPathExistsGlob=/var/lib/pcrlock.d/*-secureboot-authority.pcrlock.d/generated.pcrlock +ConditionPathExists=/var/lib/pcrlock.d/600-gpt.pcrlock.d/generated.pcrlock RequiresMountsFor=/boot /var/lib/systemd /var/lib/pcrlock.d [Service] ExecStart= -ExecStart=/usr/lib/systemd/systemd-pcrlock make-policy --pcr=7 --location=770 +ExecStart=/usr/lib/systemd/systemd-pcrlock make-policy --strict=yes --pcr=5 --pcr=7 --location=770 diff --git a/scripts/tpm_nvpcr.py b/scripts/tpm_nvpcr.py index ddf9fd2..93f090e 100644 --- a/scripts/tpm_nvpcr.py +++ b/scripts/tpm_nvpcr.py @@ -15,6 +15,8 @@ from cryptography.hazmat.primitives import hashes, serialization from cryptography.hazmat.primitives.asymmetric import padding, rsa BOOT = Path("/boot/EFI/Linux") +PCRLOCK_POLICY = Path("/var/lib/systemd/pcrlock.json") +GPT_COMPONENT = Path("/var/lib/pcrlock.d/600-gpt.pcrlock.d/generated.pcrlock") 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" @@ -217,6 +219,49 @@ def check_image(path: Path, public_key: bytes) -> None: print(f"verified: {path}") +def check_pcrlock_policy(policy: dict, boot_policy: dict) -> None: + values = policy.get("pcrValues") + if ( + policy.get("pcrBank") != "sha256" + or not isinstance(values, list) + or len(values) != 2 + or any( + not isinstance(entry, dict) + or entry.get("pcr") not in (5, 7) + or not isinstance(entry.get("values"), list) + or not entry["values"] + for entry in values + ) + or {entry["pcr"] for entry in values} != {5, 7} + ): + raise ValueError("Stored pcrlock policy does not require PCR 5 and PCR 7") + if policy != boot_policy: + raise ValueError("EFI pcrlock credential differs from the stored policy") + + +def check_boot_policy() -> None: + if not GPT_COMPONENT.is_file(): + raise ValueError("Approved GPT component is missing") + policy = json.loads(PCRLOCK_POLICY.read_text()) + credential_dir = Path(run("bootctl", "-x").strip()) / "loader/credentials" + credentials = list(credential_dir.glob("pcrlock.*.cred")) + if len(credentials) != 1: + raise ValueError("Expected one EFI pcrlock credential") + credential = credentials[0] + name = credential.name.removesuffix(".cred").lower() + boot_policy = json.loads( + run( + "systemd-creds", + "--allow-null", + f"--name={name}", + "decrypt", + str(credential), + "-", + ) + ) + check_pcrlock_policy(policy, boot_policy) + + def check_boot() -> None: active = Path("/proc/cmdline").read_text() if not all(option in active for option in MEASUREMENT_OPTIONS): @@ -225,9 +270,11 @@ def check_boot() -> None: for name in ("cryptsetup", "hardware", "login", "verity"): if name not in nvpcrs: raise ValueError(f"NvPCR {name} is not available") + check_boot_policy() run( "/usr/lib/systemd/systemd-pcrlock", "--strict=yes", + "--pcr=5", "--pcr=7", "--location=770", "predict", @@ -236,7 +283,7 @@ def check_boot() -> None: if failed.strip(): raise ValueError("Systemd has failed units; inspect systemctl --failed") print( - "verified: current boot options, NvPCR availability, and strict PCR 7 prediction" + "verified: current boot options, NvPCR availability, and strict PCR 5+7 policy" ) diff --git a/tests/test_nvpcr.py b/tests/test_nvpcr.py index 48464a7..4611277 100644 --- a/tests/test_nvpcr.py +++ b/tests/test_nvpcr.py @@ -59,11 +59,15 @@ class NvPCRConfigTests(unittest.TestCase): self.assertIn("sudo sbverify --cert", script) self.assertNotIn("arch-linux-hardened-nvpcr-recovery.efi", script) - def test_pcr7_policy_is_unchanged(self): + def test_policy_requires_gpt_and_secure_boot(self): policy = ( ROOT / "etc/systemd/system/systemd-pcrlock-make-policy.service.d/pcr7.conf" ) - self.assertIn("--pcr=7 --location=770", policy.read_text()) + self.assertIn( + "make-policy --strict=yes --pcr=5 --pcr=7 --location=770", + policy.read_text(), + ) + self.assertIn(str(tpm_nvpcr.GPT_COMPONENT), policy.read_text()) for name in ("systemd-tpm2-setup.service", "systemd-tpm2-setup-early.service"): self.assertFalse((ROOT / "etc/systemd/system" / name).exists()) @@ -104,6 +108,66 @@ def sections(public_key, *, policies=None, options=None): class NvPCRImageTests(unittest.TestCase): + def test_policy_rejects_missing_gpt_and_stale_boot_credential(self): + policy = { + "pcrBank": "sha256", + "pcrValues": [ + {"pcr": 5, "values": [{"sha256": "a" * 64}]}, + {"pcr": 7, "values": [{"sha256": "b" * 64}]}, + ], + "nvIndex": 1, + } + tpm_nvpcr.check_pcrlock_policy(policy, policy.copy()) + old = {**policy, "pcrValues": policy["pcrValues"][1:]} + with self.assertRaisesRegex(ValueError, "PCR 5 and PCR 7"): + tpm_nvpcr.check_pcrlock_policy(old, old) + with self.assertRaisesRegex(ValueError, "EFI pcrlock credential differs"): + tpm_nvpcr.check_pcrlock_policy(policy, {**policy, "nvIndex": 2}) + + def test_boot_policy_checks_gpt_and_efi_credential(self): + policy = { + "pcrBank": "sha256", + "pcrValues": [ + {"pcr": 5, "values": ["a" * 64]}, + {"pcr": 7, "values": ["b" * 64]}, + ], + } + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + gpt = root / "generated.pcrlock" + gpt.touch() + policy_file = root / "pcrlock.json" + policy_file.write_text(json.dumps(policy)) + credential_dir = root / "loader/credentials" + credential_dir.mkdir(parents=True) + credential = credential_dir / "pcrlock.test-token.cred" + credential.touch() + with ( + mock.patch.object(tpm_nvpcr, "GPT_COMPONENT", gpt), + mock.patch.object(tpm_nvpcr, "PCRLOCK_POLICY", policy_file), + mock.patch.object( + tpm_nvpcr, + "run", + side_effect=[str(root), json.dumps(policy)], + ) as run, + ): + tpm_nvpcr.check_boot_policy() + run.assert_any_call( + "systemd-creds", + "--allow-null", + "--name=pcrlock.test-token", + "decrypt", + str(credential), + "-", + ) + + gpt.unlink() + with ( + mock.patch.object(tpm_nvpcr, "GPT_COMPONENT", gpt), + self.assertRaisesRegex(ValueError, "GPT component is missing"), + ): + tpm_nvpcr.check_boot_policy() + def test_command_failure_reports_stderr(self): error = subprocess.CalledProcessError(1, ["ukify"], stderr="file not found") with ( -- cgit v1.3.1