From 6739045b173cc1be1f46bdf4b6167cbf9a883089 Mon Sep 17 00:00:00 2001 From: sommerfeld Date: Wed, 30 Sep 2026 08:54:59 +0100 Subject: Require signed PCR 11 policy for TPM root unlock --- tests/test_nvpcr.py | 244 ++++++++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 238 insertions(+), 6 deletions(-) (limited to 'tests/test_nvpcr.py') 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() -- cgit v1.3.1