build_library: add PCR precompute script

This commit is contained in:
Jeremi Piotrowski 2026-02-19 11:43:38 +01:00
parent 2b69b0d4a7
commit eaeaf7562f

485
build_library/precompute_pcr.py Executable file
View File

@ -0,0 +1,485 @@
#!/usr/bin/python3
"""Precompute expected TPM PCR 4, 8, and 9 values for a Flatcar boot.
PCR 4: Boot chain EFI binaries (shim, GRUB, kernel) measured via PE
Authenticode hash, plus firmware EV_EFI_ACTION and EV_SEPARATOR.
PCR 8: GRUB commands and kernel command line measured as plain strings.
PCR 9: GRUB source'd config files and loaded kernel measured by file content.
Usage:
# Compute PCR 4 from EFI binaries:
python3 precompute_pcr.py pcr4 --shim bootx64.efi --grub grubx64.efi --kernel vmlinuz-a
# Compute PCR 9 from kernel file and optional OEM grub.cfg:
python3 precompute_pcr.py pcr9 --kernel vmlinuz-a --oem-grub-cfg /path/to/oem/grub.cfg
# Compute PCR 8 from a file listing grub commands (one per line):
python3 precompute_pcr.py pcr8 --commands-file grub_commands.txt
# Compute all PCRs at once:
python3 precompute_pcr.py all --shim bootx64.efi --grub grubx64.efi --kernel vmlinuz-a \\
--oem-grub-cfg /path/to/oem/grub.cfg --commands-file grub_commands.txt
# Parse an existing eventlog YAML and replay PCR computation:
python3 precompute_pcr.py replay --eventlog eventlog.yaml
Reference:
- build_library/generate_grub_hashes.py generates PCR hash configs for grub components
- build_library/generate_kernel_hash.py generates PCR hash config for kernel
- build_library/grub.cfg main GRUB configuration template
PCR measurement details (SHA-256):
PCR 4:
1. EV_EFI_ACTION "Calling EFI Application from Boot Option" (always)
2. EV_SEPARATOR 0x00000000 (always)
3. PE Authenticode hash of shim (bootx64.efi)
4. PE Authenticode hash of GRUB (grubx64.efi)
5. PE Authenticode hash of kernel (vmlinuz-a)
PCR 8:
Each GRUB command is measured as SHA-256(command_text) where
command_text is the raw command WITHOUT the "grub_cmd: " prefix
shown in event logs. The kernel command line is measured as
SHA-256(cmdline_text) without the "kernel_cmdline: " prefix.
PCR 9:
Each source'd config file is measured as SHA-256(file_contents).
The loaded kernel is measured as SHA-256(kernel_file_contents).
"""
import argparse
import hashlib
import json
import struct
import sys
# ---------------------------------------------------------------------------
# PE Authenticode hash (Microsoft spec: Windows Authenticode PE Signature)
# Used by UEFI firmware to measure EFI binaries into PCR 4.
# ---------------------------------------------------------------------------
def pe_authenticode_hash(filepath, hash_algo='sha256'):
"""Compute the PE Authenticode hash of an EFI binary.
This hashes the PE file contents excluding the CheckSum field,
the Certificate Table directory entry, and the Certificate Table data,
matching UEFI firmware behavior for EV_EFI_BOOT_SERVICES_APPLICATION.
"""
with open(filepath, 'rb') as f:
data = f.read()
# DOS header: e_lfanew at offset 0x3c gives PE signature offset
pe_offset = struct.unpack_from('<I', data, 0x3c)[0]
# COFF header starts after PE signature ("PE\0\0", 4 bytes)
coff_offset = pe_offset + 4
num_sections = struct.unpack_from('<H', data, coff_offset + 2)[0]
size_of_optional_header = struct.unpack_from('<H', data, coff_offset + 16)[0]
optional_offset = coff_offset + 20
optional_magic = struct.unpack_from('<H', data, optional_offset)[0]
# CheckSum is at optional_header + 64 for both PE32 and PE32+
checksum_offset = optional_offset + 64
# Certificate Table directory entry offset differs by PE format
if optional_magic == 0x20b: # PE32+ (64-bit)
cert_table_offset = optional_offset + 144
elif optional_magic == 0x10b: # PE32 (32-bit)
cert_table_offset = optional_offset + 128
else:
raise ValueError("Unknown PE optional header magic: 0x%x" % optional_magic)
# SizeOfHeaders (always at optional_header + 60)
size_of_headers = struct.unpack_from('<I', data, optional_offset + 60)[0]
# Certificate Table VA and size (8 bytes)
cert_table_va = 0
cert_table_size = 0
if cert_table_offset + 8 <= optional_offset + size_of_optional_header:
cert_table_va, cert_table_size = struct.unpack_from(
'<II', data, cert_table_offset)
# Section table follows the optional header
section_table_offset = optional_offset + size_of_optional_header
h = hashlib.new(hash_algo)
# 1. Hash from file start to CheckSum field
h.update(data[:checksum_offset])
# 2. Skip CheckSum (4 bytes)
# 3. Hash from after CheckSum to Certificate Table directory entry
h.update(data[checksum_offset + 4:cert_table_offset])
# 4. Skip Certificate Table entry (8 bytes)
# 5. Hash from after Certificate Table entry to end of headers
h.update(data[cert_table_offset + 8:size_of_headers])
# 6. Hash sections sorted by PointerToRawData
sections = []
for i in range(num_sections):
off = section_table_offset + i * 40
raw_size = struct.unpack_from('<I', data, off + 16)[0]
raw_ptr = struct.unpack_from('<I', data, off + 20)[0]
if raw_size > 0:
sections.append((raw_ptr, raw_size))
sections.sort(key=lambda s: s[0])
sum_of_bytes_hashed = size_of_headers
for ptr, size in sections:
h.update(data[ptr:ptr + size])
sum_of_bytes_hashed += size
# 7. Hash any extra data between sections and certificate table
file_size = len(data)
extra_end = file_size
if cert_table_size > 0:
extra_end = cert_table_va
if sum_of_bytes_hashed < extra_end:
h.update(data[sum_of_bytes_hashed:extra_end])
return h.hexdigest()
# ---------------------------------------------------------------------------
# PCR extension
# ---------------------------------------------------------------------------
def pcr_extend(pcr_value, digest_hex, hash_algo='sha256'):
"""Extend a PCR value: new_pcr = HASH(old_pcr || digest)."""
digest = bytes.fromhex(digest_hex)
return hashlib.new(hash_algo, pcr_value + digest).digest()
def pcr_init(hash_algo='sha256'):
"""Return initial PCR value (all zeros)."""
digest_size = hashlib.new(hash_algo).digest_size
return b'\x00' * digest_size
def hash_bytes(data, hash_algo='sha256'):
"""Hash raw bytes."""
return hashlib.new(hash_algo, data).hexdigest()
def hash_string(s, hash_algo='sha256'):
"""Hash a UTF-8 string (no null terminator — matches GRUB measurements)."""
return hashlib.new(hash_algo, s.encode('utf-8')).hexdigest()
def hash_file(filepath, hash_algo='sha256'):
"""Hash file contents."""
h = hashlib.new(hash_algo)
with open(filepath, 'rb') as f:
while True:
chunk = f.read(65536)
if not chunk:
break
h.update(chunk)
return h.hexdigest()
# ---------------------------------------------------------------------------
# PCR 4: EFI boot chain
# ---------------------------------------------------------------------------
# EV_EFI_ACTION string measured by firmware before loading the first
# boot application. This is a well-known constant.
EV_EFI_ACTION_BOOT = "Calling EFI Application from Boot Option"
# EV_SEPARATOR event data: 4 zero bytes. Measured by firmware to
# separate pre-OS and OS-present measurements.
EV_SEPARATOR_DATA = b"\x00\x00\x00\x00"
def compute_pcr4(shim_path, grub_path, kernel_path, hash_algo='sha256'):
"""Compute PCR 4 from the EFI boot chain binaries.
The UEFI firmware measures:
1. EV_EFI_ACTION "Calling EFI Application from Boot Option"
2. EV_SEPARATOR (4 zero bytes)
Then each EFI application is measured with its PE Authenticode hash:
3. shim (bootx64.efi)
4. GRUB (grubx64.efi)
5. kernel (vmlinuz-a)
"""
pcr = pcr_init(hash_algo)
# Firmware events
action_digest = hash_bytes(EV_EFI_ACTION_BOOT.encode('utf-8'), hash_algo)
pcr = pcr_extend(pcr, action_digest, hash_algo)
separator_digest = hash_bytes(EV_SEPARATOR_DATA, hash_algo)
pcr = pcr_extend(pcr, separator_digest, hash_algo)
# EFI application measurements (PE Authenticode hashes)
for label, path in [("shim", shim_path),
("grub", grub_path),
("kernel", kernel_path)]:
digest = pe_authenticode_hash(path, hash_algo)
pcr = pcr_extend(pcr, digest, hash_algo)
return pcr.hex()
# ---------------------------------------------------------------------------
# PCR 8: GRUB commands and kernel command line
# ---------------------------------------------------------------------------
def compute_pcr8(commands, hash_algo='sha256'):
"""Compute PCR 8 from a list of GRUB command strings.
Each command is measured as hash(command_text) where command_text
is the raw grub command (e.g. "set prefix=...") WITHOUT any
"grub_cmd: " prefix. The kernel command line is also measured
the same way, as the raw command line text without
"kernel_cmdline: " prefix.
Args:
commands: list of command strings, one per GRUB measurement.
Lines starting with "grub_cmd: " or "kernel_cmdline: " will
have the prefix stripped automatically for convenience.
"""
pcr = pcr_init(hash_algo)
for cmd in commands:
# Strip common prefixes if present (convenience for eventlog copy-paste)
for prefix in ("grub_cmd: ", "kernel_cmdline: "):
if cmd.startswith(prefix):
cmd = cmd[len(prefix):]
break
digest = hash_string(cmd, hash_algo)
pcr = pcr_extend(pcr, digest, hash_algo)
return pcr.hex()
def load_commands_file(filepath):
"""Load GRUB commands from a text file (one command per line).
Empty lines and lines starting with '#' are skipped.
"""
commands = []
with open(filepath, 'r') as f:
for line in f:
line = line.rstrip('\n')
if line and not line.startswith('#'):
commands.append(line)
return commands
# ---------------------------------------------------------------------------
# PCR 9: GRUB source'd files and loaded kernel
# ---------------------------------------------------------------------------
def compute_pcr9(kernel_path, oem_grub_cfg_paths=None, hash_algo='sha256'):
"""Compute PCR 9 from measured files.
GRUB measures into PCR 9:
1. Content of each source'd config file (e.g. OEM grub.cfg)
2. Content of the loaded kernel file
The measurement order matches the boot sequence: OEM configs
are sourced before the kernel is loaded.
Args:
kernel_path: path to vmlinuz-a or vmlinuz-b
oem_grub_cfg_paths: list of paths to OEM grub.cfg files that
are source'd during boot (measured before the kernel)
"""
pcr = pcr_init(hash_algo)
# Source'd config files (OEM grub.cfg etc.)
if oem_grub_cfg_paths:
for cfg_path in oem_grub_cfg_paths:
digest = hash_file(cfg_path, hash_algo)
pcr = pcr_extend(pcr, digest, hash_algo)
# Kernel file content
digest = hash_file(kernel_path, hash_algo)
pcr = pcr_extend(pcr, digest, hash_algo)
return pcr.hex()
# ---------------------------------------------------------------------------
# Eventlog replay
# ---------------------------------------------------------------------------
def replay_eventlog(eventlog_path, hash_algo='sha256'):
"""Parse a YAML eventlog and replay PCR 4, 8, 9 extensions.
Reads the event digests directly from the log and extends them
to reproduce the final PCR values. This is useful for verification.
"""
try:
import yaml
except ImportError:
# Minimal YAML-like parser for the eventlog format
return replay_eventlog_simple(eventlog_path, hash_algo)
with open(eventlog_path, 'r') as f:
data = yaml.safe_load(f)
pcrs = {}
algo_map = {'sha1': 'sha1', 'sha256': 'sha256', 'sha384': 'sha384'}
for event in data.get('events', []):
pcr_index = event.get('PCRIndex')
if pcr_index not in (4, 8, 9):
continue
digests = event.get('Digests', [])
for d in digests:
algo = algo_map.get(d.get('AlgorithmId'))
if algo != hash_algo:
continue
digest_hex = d['Digest']
key = pcr_index
if key not in pcrs:
pcrs[key] = pcr_init(hash_algo)
pcrs[key] = pcr_extend(pcrs[key], digest_hex, hash_algo)
return {k: v.hex() for k, v in sorted(pcrs.items())}
def replay_eventlog_simple(eventlog_path, hash_algo='sha256'):
"""Simple eventlog parser without PyYAML dependency.
Parses the structured eventlog YAML format to extract PCR index,
algorithm, and digest for events targeting PCR 4, 8, and 9.
"""
import re
pcrs = {}
current_pcr = None
in_digests = False
current_algo = None
with open(eventlog_path, 'r') as f:
for line in f:
line = line.rstrip()
m = re.match(r'\s+PCRIndex:\s+(\d+)', line)
if m:
current_pcr = int(m.group(1))
in_digests = False
continue
if 'Digests:' in line:
in_digests = True
continue
if in_digests:
m = re.match(r'\s+- AlgorithmId:\s+(\S+)', line)
if m:
current_algo = m.group(1)
continue
m = re.match(r'\s+Digest:\s+"([0-9a-fA-F]+)"', line)
if m and current_algo == hash_algo and current_pcr in (4, 8, 9):
digest_hex = m.group(1)
if current_pcr not in pcrs:
pcrs[current_pcr] = pcr_init(hash_algo)
pcrs[current_pcr] = pcr_extend(
pcrs[current_pcr], digest_hex, hash_algo)
continue
if re.match(r'\s+EventSize:', line) or re.match(r'\s+Event', line):
in_digests = False
return {k: v.hex() for k, v in sorted(pcrs.items())}
# ---------------------------------------------------------------------------
# CLI
# ---------------------------------------------------------------------------
def main():
parser = argparse.ArgumentParser(
description='Precompute TPM PCR 4, 8, and 9 values for Flatcar boot.')
parser.add_argument('--algo', default='sha256',
choices=['sha1', 'sha256', 'sha384'],
help='Hash algorithm (default: sha256)')
parser.add_argument('--json', action='store_true',
help='Output results as JSON')
sub = parser.add_subparsers(dest='command')
# pcr4
p4 = sub.add_parser('pcr4', help='Compute PCR 4 from EFI binaries')
p4.add_argument('--shim', required=True, help='Path to shim (bootx64.efi)')
p4.add_argument('--grub', required=True, help='Path to GRUB (grubx64.efi)')
p4.add_argument('--kernel', required=True, help='Path to kernel (vmlinuz-a)')
# pcr8
p8 = sub.add_parser('pcr8',
help='Compute PCR 8 from GRUB command list')
p8.add_argument('--commands-file', required=True,
help='File with GRUB commands, one per line')
# pcr9
p9 = sub.add_parser('pcr9',
help='Compute PCR 9 from kernel and config files')
p9.add_argument('--kernel', required=True, help='Path to kernel (vmlinuz-a)')
p9.add_argument('--oem-grub-cfg', nargs='*', default=[],
help='Path(s) to OEM grub.cfg file(s) sourced during boot')
# all
pa = sub.add_parser('all', help='Compute PCR 4, 8, and 9')
pa.add_argument('--shim', required=True, help='Path to shim (bootx64.efi)')
pa.add_argument('--grub', required=True, help='Path to GRUB (grubx64.efi)')
pa.add_argument('--kernel', required=True, help='Path to kernel (vmlinuz-a)')
pa.add_argument('--commands-file', required=True,
help='File with GRUB commands, one per line')
pa.add_argument('--oem-grub-cfg', nargs='*', default=[],
help='Path(s) to OEM grub.cfg file(s) sourced during boot')
# replay
pr = sub.add_parser('replay',
help='Replay an eventlog to verify PCR values')
pr.add_argument('--eventlog', required=True, help='Path to eventlog YAML')
args = parser.parse_args()
if not args.command:
parser.print_help()
sys.exit(1)
results = {}
if args.command == 'pcr4':
results['pcr4'] = compute_pcr4(
args.shim, args.grub, args.kernel, args.algo)
elif args.command == 'pcr8':
commands = load_commands_file(args.commands_file)
results['pcr8'] = compute_pcr8(commands, args.algo)
elif args.command == 'pcr9':
results['pcr9'] = compute_pcr9(
args.kernel,
args.oem_grub_cfg if args.oem_grub_cfg else None,
args.algo)
elif args.command == 'all':
results['pcr4'] = compute_pcr4(
args.shim, args.grub, args.kernel, args.algo)
commands = load_commands_file(args.commands_file)
results['pcr8'] = compute_pcr8(commands, args.algo)
results['pcr9'] = compute_pcr9(
args.kernel,
args.oem_grub_cfg if args.oem_grub_cfg else None,
args.algo)
elif args.command == 'replay':
results = {("pcr%d" % k): v
for k, v in replay_eventlog(
args.eventlog, args.algo).items()}
if args.json:
print(json.dumps(results, indent=2))
else:
for name, value in sorted(results.items()):
print(f"{name}: {value}")
if __name__ == '__main__':
main()