diff options
Diffstat (limited to 'scripts')
| -rw-r--r-- | scripts/tpm_nvpcr.py | 133 | ||||
| -rw-r--r-- | scripts/tpm_nvpcr_unmask.py | 53 |
2 files changed, 186 insertions, 0 deletions
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 |
