mirror of
https://github.com/flatcar/scripts.git
synced 2026-05-05 04:06:33 +02:00
build_library: add PCR precompute script
This commit is contained in:
parent
2b69b0d4a7
commit
eaeaf7562f
485
build_library/precompute_pcr.py
Executable file
485
build_library/precompute_pcr.py
Executable 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()
|
||||
Loading…
x
Reference in New Issue
Block a user