summaryrefslogtreecommitdiffstatshomepage
path: root/scripts/tpm_unlock.py
diff options
context:
space:
mode:
authorsommerfeld <sommerfeld@sommerfeld.dev>2026-09-30 08:54:59 +0100
committersommerfeld <sommerfeld@sommerfeld.dev>2026-09-30 08:54:59 +0100
commit6739045b173cc1be1f46bdf4b6167cbf9a883089 (patch)
tree955161ee64eba8d06cf1492b8c903bae76b967b6 /scripts/tpm_unlock.py
parentc6cf3bc7e20e43c61b48d417b9291e5d7d115b1d (diff)
downloaddotfiles-6739045b173cc1be1f46bdf4b6167cbf9a883089.tar.gz
dotfiles-6739045b173cc1be1f46bdf4b6167cbf9a883089.tar.bz2
dotfiles-6739045b173cc1be1f46bdf4b6167cbf9a883089.zip
Require signed PCR 11 policy for TPM root unlock
Diffstat (limited to 'scripts/tpm_unlock.py')
-rw-r--r--scripts/tpm_unlock.py108
1 files changed, 108 insertions, 0 deletions
diff --git a/scripts/tpm_unlock.py b/scripts/tpm_unlock.py
new file mode 100644
index 0000000..0082d2d
--- /dev/null
+++ b/scripts/tpm_unlock.py
@@ -0,0 +1,108 @@
+"""Audit the LUKS token used for automatic root unlock on Halley2."""
+
+import base64
+import binascii
+import json
+import os
+import subprocess
+import sys
+from pathlib import Path
+
+from cryptography.hazmat.primitives import serialization
+
+from scripts.tpm_nvpcr import PUBLIC_KEY
+
+DEVICE = "/dev/nvme0n1p2"
+
+
+def decoded(value: object, field: str) -> bytes:
+ if not isinstance(value, str):
+ raise TypeError(f"Missing {field}")
+ try:
+ result = base64.b64decode(value, validate=True)
+ except (ValueError, binascii.Error) as exc:
+ raise ValueError(f"Invalid {field}") from exc
+ if not result:
+ raise ValueError(f"Empty {field}")
+ return result
+
+
+def check_header(
+ header: dict, public_key: bytes, *, trial: bool = False
+) -> tuple[str, str]:
+ tokens = header.get("tokens", {})
+ slots = header.get("keyslots", {})
+ if not isinstance(tokens, dict) or len(tokens) != 1:
+ raise ValueError("Expected exactly one automatic-unlock token")
+ if not isinstance(slots, dict) or "0" not in slots:
+ raise ValueError("Passphrase keyslot 0 is missing")
+ token_id, token = next(iter(tokens.items()))
+ if not isinstance(token, dict) or token.get("type") != "systemd-tpm2":
+ raise ValueError("Expected one systemd TPM2 token")
+ assigned = token.get("keyslots")
+ if not isinstance(assigned, list) or len(assigned) != 1:
+ raise ValueError("TPM token must name exactly one keyslot")
+ slot = assigned[0]
+ if slot == "0" or slot not in slots:
+ raise ValueError("TPM token has no separate live keyslot")
+ if not trial and set(slots) != {"0", slot}:
+ raise ValueError("Unexpected extra keyslot after migration")
+
+ if token.get("tpm2-pcrs") != [] or token.get("tpm2-pcr-bank") != "sha256":
+ raise ValueError("Unexpected direct PCR binding or PCR bank")
+ if token.get("tpm2_pcrlock") is not True:
+ raise ValueError("TPM token does not require pcrlock")
+ decoded(token.get("tpm2_pcrlock_nv"), "pcrlock NV reference")
+ if (
+ token.get("tpm2_pubkey_pcrs") != [11]
+ or token.get("tpm2_pubkey_ref") != "luks-root"
+ ):
+ raise ValueError(
+ "TPM token does not require the signed luks-root PCR 11 policy"
+ )
+ embedded = serialization.load_pem_public_key(
+ decoded(token.get("tpm2_pubkey"), "PCR public key")
+ )
+ installed = serialization.load_pem_public_key(public_key)
+ encoding = serialization.Encoding.DER
+ fmt = serialization.PublicFormat.SubjectPublicKeyInfo
+ if embedded.public_bytes(encoding, fmt) != installed.public_bytes(encoding, fmt):
+ raise ValueError("TPM token has the wrong PCR public key")
+ blobs = token.get("tpm2-blob")
+ hashes = token.get("tpm2-policy-hash")
+ if not isinstance(blobs, list) or len(blobs) != 2:
+ raise ValueError("Combined TPM policy must have two sealed blobs")
+ if not isinstance(hashes, list) or len(hashes) != 2:
+ raise ValueError("Combined TPM policy must have two policy hashes")
+ for blob in blobs:
+ decoded(blob, "sealed blob")
+ for value in hashes:
+ if not isinstance(value, str) or len(bytes.fromhex(value)) != 32:
+ raise ValueError("Invalid TPM policy hash")
+ return token_id, slot
+
+
+def main() -> None:
+ if len(sys.argv) > 2 or (len(sys.argv) == 2 and sys.argv[1] != "--trial"):
+ raise ValueError("Usage: tpm_unlock.py [--trial]")
+ if os.geteuid() != 0 or Path("/etc/hostname").read_text().strip() != "halley2":
+ raise ValueError("Run as root on Halley2")
+ header = json.loads(
+ subprocess.check_output(
+ ["cryptsetup", "luksDump", "--dump-json-metadata", DEVICE], text=True
+ )
+ )
+ token_id, slot = check_header(
+ header, PUBLIC_KEY.read_bytes(), trial="--trial" in sys.argv
+ )
+ print(
+ f"verified: token {token_id}, keyslot {slot}, pcrlock and signed PCR 11 metadata"
+ )
+
+
+if __name__ == "__main__":
+ try:
+ main()
+ except (OSError, TypeError, ValueError, subprocess.CalledProcessError) as exc:
+ print(f"TPM unlock check failed: {exc}", file=sys.stderr)
+ sys.exit(1)