]> git.ipfire.org Git - thirdparty/systemd.git/blame - src/ukify/ukify.py
ukify: fix parsing uname version with '+'
[thirdparty/systemd.git] / src / ukify / ukify.py
CommitLineData
f4780cbe 1#!/usr/bin/env python3
eb81a60c 2# SPDX-License-Identifier: LGPL-2.1-or-later
d9c8f075
ZJS
3#
4# This file is part of systemd.
5#
6# systemd is free software; you can redistribute it and/or modify it
7# under the terms of the GNU Lesser General Public License as published by
8# the Free Software Foundation; either version 2.1 of the License, or
9# (at your option) any later version.
10#
11# systemd is distributed in the hope that it will be useful, but
12# WITHOUT ANY WARRANTY; without even the implied warranty of
13# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
14# General Public License for more details.
15#
16# You should have received a copy of the GNU Lesser General Public License
17# along with systemd; If not, see <https://www.gnu.org/licenses/>.
f4780cbe 18
79f902eb
FS
19# pylint: disable=import-outside-toplevel,consider-using-with,unused-argument
20# pylint: disable=unnecessary-lambda-assignment
f4780cbe
ZJS
21
22import argparse
467d21a7 23import builtins
8abfd07e 24import collections
5143a47a 25import configparser
a1c80efd 26import contextlib
f4780cbe 27import dataclasses
a1c80efd 28import datetime
f4780cbe 29import fnmatch
f1b6430e 30import inspect
f4780cbe
ZJS
31import itertools
32import json
33import os
5143a47a 34import pprint
6fa79138 35import pydoc
f4780cbe
ZJS
36import re
37import shlex
c8add4c2 38import shutil
a1c80efd 39import socket
8abfd07e 40import struct
f4780cbe 41import subprocess
5143a47a 42import sys
f4780cbe 43import tempfile
df4a4673 44import textwrap
cf331f1c 45import uuid
e9519350 46from collections.abc import Iterable, Iterator, Sequence
df4a4673 47from hashlib import sha256
35d92c03 48from pathlib import Path
e9519350 49from types import ModuleType
6a28cae9 50from typing import (
8abfd07e 51 IO,
6a28cae9
JB
52 Any,
53 Callable,
f1b6430e 54 Literal,
6a28cae9 55 Optional,
e9519350 56 TypeVar,
6a28cae9 57 Union,
e9519350 58 cast,
6a28cae9 59)
f4780cbe 60
a758f95c 61import pefile # type: ignore
f4780cbe 62
6a4fcf8c 63__version__ = '{{PROJECT_VERSION}} ({{VERSION_TAG}})'
30ec2eae 64
f4780cbe 65EFI_ARCH_MAP = {
7081db29 66 # host_arch glob : [efi_arch, 32_bit_efi_arch if mixed mode is supported]
6a28cae9
JB
67 'x86_64': ['x64', 'ia32'],
68 'i[3456]86': ['ia32'],
69 'aarch64': ['aa64'],
5a945fab 70 'armv[45678]*l': ['arm'],
6a28cae9
JB
71 'loongarch32': ['loongarch32'],
72 'loongarch64': ['loongarch64'],
73 'riscv32': ['riscv32'],
74 'riscv64': ['riscv64'],
75} # fmt: skip
f4780cbe
ZJS
76EFI_ARCHES: list[str] = sum(EFI_ARCH_MAP.values(), [])
77
a05fa30f 78# Default configuration directories and file name.
2572afa4
JB
79# When the user does not specify one, the directories are searched in this order and the first file found is
80# used.
c6aadfdd 81DEFAULT_CONFIG_DIRS = ['/etc/systemd', '/run/systemd', '/usr/local/lib/systemd', '/usr/lib/systemd']
a05fa30f
AA
82DEFAULT_CONFIG_FILE = 'ukify.conf'
83
6a28cae9 84
51faf836 85class Style:
6a28cae9
JB
86 bold = '\033[0;1;39m' if sys.stderr.isatty() else ''
87 gray = '\033[0;38;5;245m' if sys.stderr.isatty() else ''
88 red = '\033[31;1m' if sys.stderr.isatty() else ''
89 yellow = '\033[33;1m' if sys.stderr.isatty() else ''
90 reset = '\033[0m' if sys.stderr.isatty() else ''
51faf836
ZJS
91
92
e9519350 93def guess_efi_arch() -> str:
f4780cbe
ZJS
94 arch = os.uname().machine
95
96 for glob, mapping in EFI_ARCH_MAP.items():
97 if fnmatch.fnmatch(arch, glob):
98 efi_arch, *fallback = mapping
99 break
100 else:
101 raise ValueError(f'Unsupported architecture {arch}')
102
103 # This makes sense only on some architectures, but it also probably doesn't
104 # hurt on others, so let's just apply the check everywhere.
105 if fallback:
35d92c03 106 fw_platform_size = Path('/sys/firmware/efi/fw_platform_size')
f4780cbe
ZJS
107 try:
108 size = fw_platform_size.read_text().strip()
109 except FileNotFoundError:
110 pass
111 else:
112 if int(size) == 32:
113 efi_arch = fallback[0]
114
df4a4673 115 # print(f'Host arch {arch!r}, EFI arch {efi_arch!r}')
f4780cbe
ZJS
116 return efi_arch
117
118
6fa79138
ZJS
119def page(text: str, enabled: Optional[bool]) -> None:
120 if enabled:
121 # Initialize less options from $SYSTEMD_LESS or provide a suitable fallback.
122 os.environ['LESS'] = os.getenv('SYSTEMD_LESS', 'FRSXMK')
123 pydoc.pager(text)
124 else:
125 print(text)
126
127
e9519350 128def shell_join(cmd: list[Union[str, Path]]) -> str:
35d92c03 129 # TODO: drop in favour of shlex.join once shlex.join supports Path.
f4780cbe
ZJS
130 return ' '.join(shlex.quote(str(x)) for x in cmd)
131
132
e9519350 133def round_up(x: int, blocksize: int = 4096) -> int:
f4780cbe
ZJS
134 return (x + blocksize - 1) // blocksize * blocksize
135
136
e9519350 137def try_import(modname: str, name: Optional[str] = None) -> ModuleType:
667578bb
ZJS
138 try:
139 return __import__(modname)
140 except ImportError as e:
141 raise ValueError(f'Kernel is compressed with {name or modname}, but module unavailable') from e
142
6a28cae9 143
b61efe65
DDM
144def read_env_file(text: str) -> dict[str, str]:
145 result = {}
146
147 for line in text.splitlines():
148 line = line.rstrip()
149 if not line or line.startswith('#'):
150 continue
151 if m := re.match(r'([A-Z][A-Z_0-9]+)=(.*)', line):
152 name, val = m.groups()
153 if val and val[0] in '"\'':
154 val = next(shlex.shlex(val, posix=True))
155
156 result[name] = val
157 else:
158 print(f'bad line {line!r}', file=sys.stderr)
159
160 return result
161
162
e9519350 163def get_zboot_kernel(f: IO[bytes]) -> bytes:
96069e57
XW
164 """Decompress zboot efistub kernel if compressed. Return contents."""
165 # See linux/drivers/firmware/efi/libstub/Makefile.zboot
166 # and linux/drivers/firmware/efi/libstub/zboot-header.S
167
168 # 4 bytes at offset 0x08 contain the starting offset of compressed data
169 f.seek(8)
170 _start = f.read(4)
171 start = struct.unpack('<i', _start)[0]
172
173 # Reading 4 bytes from address 0x0c is the size of compressed data,
174 # but it needs to be corrected according to the compressed type.
6a28cae9 175 f.seek(0xC)
96069e57
XW
176 _sizes = f.read(4)
177 size = struct.unpack('<i', _sizes)[0]
178
179 # Read 6 bytes from address 0x18, which is a nul-terminated
180 # string representing the compressed type.
181 f.seek(0x18)
182 comp_type = f.read(6)
183 f.seek(start)
184 if comp_type.startswith(b'gzip'):
185 gzip = try_import('gzip')
e9519350 186 return cast(bytes, gzip.open(f).read(size))
96069e57
XW
187 elif comp_type.startswith(b'lz4'):
188 lz4 = try_import('lz4.frame', 'lz4')
e9519350 189 return cast(bytes, lz4.frame.decompress(f.read(size)))
96069e57
XW
190 elif comp_type.startswith(b'lzma'):
191 lzma = try_import('lzma')
e9519350 192 return cast(bytes, lzma.open(f).read(size))
96069e57
XW
193 elif comp_type.startswith(b'lzo'):
194 raise NotImplementedError('lzo decompression not implemented')
195 elif comp_type.startswith(b'xzkern'):
196 raise NotImplementedError('xzkern decompression not implemented')
a6d51ae5 197 elif comp_type.startswith(b'zstd'):
fbc6fecf
LB
198 zstd = try_import('zstandard')
199 return cast(bytes, zstd.ZstdDecompressor().stream_reader(f.read(size)).read())
e9519350
JB
200
201 raise NotImplementedError(f'unknown compressed type: {comp_type!r}')
667578bb 202
6a28cae9 203
e9519350 204def maybe_decompress(filename: Union[str, Path]) -> bytes:
483c9c1b
ZJS
205 """Decompress file if compressed. Return contents."""
206 f = open(filename, 'rb')
207 start = f.read(4)
208 f.seek(0)
209
210 if start.startswith(b'\x7fELF'):
211 # not compressed
212 return f.read()
213
bf9f07a6 214 if start.startswith(b'MZ'):
96069e57
XW
215 f.seek(4)
216 img_type = f.read(4)
217 if img_type.startswith(b'zimg'):
218 # zboot efistub kernel
219 return get_zboot_kernel(f)
220 else:
221 # not compressed aarch64 and riscv64
222 return f.read()
bf9f07a6 223
483c9c1b 224 if start.startswith(b'\x1f\x8b'):
667578bb 225 gzip = try_import('gzip')
e9519350 226 return cast(bytes, gzip.open(f).read())
483c9c1b
ZJS
227
228 if start.startswith(b'\x28\xb5\x2f\xfd'):
fbc6fecf
LB
229 zstd = try_import('zstandard')
230 return cast(bytes, zstd.ZstdDecompressor().stream_reader(f.read()).read())
483c9c1b
ZJS
231
232 if start.startswith(b'\x02\x21\x4c\x18'):
667578bb 233 lz4 = try_import('lz4.frame', 'lz4')
e9519350 234 return cast(bytes, lz4.frame.decompress(f.read()))
483c9c1b
ZJS
235
236 if start.startswith(b'\x04\x22\x4d\x18'):
cf4deeaf 237 print('Newer lz4 stream format detected! This may not boot!', file=sys.stderr)
667578bb 238 lz4 = try_import('lz4.frame', 'lz4')
e9519350 239 return cast(bytes, lz4.frame.decompress(f.read()))
483c9c1b
ZJS
240
241 if start.startswith(b'\x89LZO'):
242 # python3-lzo is not packaged for Fedora
243 raise NotImplementedError('lzo decompression not implemented')
244
245 if start.startswith(b'BZh'):
667578bb 246 bz2 = try_import('bz2', 'bzip2')
e9519350 247 return cast(bytes, bz2.open(f).read())
483c9c1b
ZJS
248
249 if start.startswith(b'\x5d\x00\x00'):
667578bb 250 lzma = try_import('lzma')
e9519350 251 return cast(bytes, lzma.open(f).read())
483c9c1b 252
e9519350 253 raise NotImplementedError(f'unknown file format (starts with {start!r})')
483c9c1b
ZJS
254
255
f1b6430e
JB
256@dataclasses.dataclass
257class UkifyConfig:
258 all: bool
259 cmdline: Union[str, Path, None]
260 devicetree: Path
fa258f77 261 devicetree_auto: list[Path]
f1b6430e 262 efi_arch: str
0333b9d5 263 hwids: Path
f1b6430e 264 initrd: list[Path]
83bf58f3 265 efifw: list[Path]
f1b6430e 266 join_profiles: list[Path]
b61efe65 267 sign_profiles: list[str]
f1b6430e
JB
268 json: Union[Literal['pretty'], Literal['short'], Literal['off']]
269 linux: Optional[Path]
270 measure: bool
271 microcode: Path
272 os_release: Union[str, Path, None]
273 output: Optional[str]
274 pcr_banks: list[str]
275 pcr_private_keys: list[str]
64cc7ba5 276 pcr_public_keys: list[str]
2ac8fcf6 277 pcr_certificates: list[str]
f1b6430e 278 pcrpkey: Optional[Path]
9876e88e
LB
279 pcrsig: Union[str, Path, None]
280 join_pcrsig: Optional[Path]
f1b6430e 281 phase_path_groups: Optional[list[str]]
606c5e75 282 policy_digest: bool
7db71cd7 283 profile: Optional[str]
64cc7ba5 284 sb_cert: Union[str, Path, None]
f1b6430e
JB
285 sb_cert_name: Optional[str]
286 sb_cert_validity: int
287 sb_certdir: Path
64cc7ba5 288 sb_key: Union[str, Path, None]
f1b6430e
JB
289 sbat: Optional[list[str]]
290 sections: list['Section']
291 sections_by_name: dict[str, 'Section']
32c3e137 292 sign_kernel: Optional[bool]
f1b6430e 293 signing_engine: Optional[str]
65fbf3b1 294 signing_provider: Optional[str]
64cc7ba5 295 certificate_provider: Optional[str]
5e7e4e4d 296 signtool: Optional[str]
f1b6430e
JB
297 splash: Optional[Path]
298 stub: Path
299 summary: bool
300 tools: list[Path]
301 uname: Optional[str]
302 verb: str
303 files: list[str] = dataclasses.field(default_factory=list)
304
305 @classmethod
306 def from_namespace(cls, ns: argparse.Namespace) -> 'UkifyConfig':
307 return cls(**{k: v for k, v in vars(ns).items() if k in inspect.signature(cls).parameters})
308
309
483c9c1b
ZJS
310class Uname:
311 # This class is here purely as a namespace for the functions
312
6cc01c8c 313 VERSION_PATTERN = r'(?P<version>[a-z0-9._+-]+) \([^ )]+\) (?:#.*)'
483c9c1b
ZJS
314
315 NOTES_PATTERN = r'^\s+Linux\s+0x[0-9a-f]+\s+OPEN\n\s+description data: (?P<version>[0-9a-f ]+)\s*$'
316
317 # Linux version 6.0.8-300.fc37.ppc64le (mockbuild@buildvm-ppc64le-03.iad2.fedoraproject.org)
318 # (gcc (GCC) 12.2.1 20220819 (Red Hat 12.2.1-2), GNU ld version 2.38-24.fc37)
319 # #1 SMP Fri Nov 11 14:39:11 UTC 2022
320 TEXT_PATTERN = rb'Linux version (?P<version>\d\.\S+) \('
321
322 @classmethod
f1b6430e 323 def scrape_x86(cls, filename: Path, opts: Optional[UkifyConfig] = None) -> str:
483c9c1b 324 # Based on https://gitlab.archlinux.org/archlinux/mkinitcpio/mkinitcpio/-/blob/master/functions#L136
93428875 325 # and https://docs.kernel.org/arch/x86/boot.html#the-real-mode-kernel-header
483c9c1b
ZJS
326 with open(filename, 'rb') as f:
327 f.seek(0x202)
328 magic = f.read(4)
329 if magic != b'HdrS':
330 raise ValueError('Real-Mode Kernel Header magic not found')
331 f.seek(0x20E)
6a28cae9 332 offset = f.read(1)[0] + f.read(1)[0] * 256 # Pointer to kernel version string
483c9c1b
ZJS
333 f.seek(0x200 + offset)
334 text = f.read(128)
335 text = text.split(b'\0', maxsplit=1)[0]
e9519350 336 decoded = text.decode()
483c9c1b 337
e9519350 338 if not (m := re.match(cls.VERSION_PATTERN, decoded)):
483c9c1b
ZJS
339 raise ValueError(f'Cannot parse version-host-release uname string: {text!r}')
340 return m.group('version')
341
342 @classmethod
f1b6430e 343 def scrape_elf(cls, filename: Path, opts: Optional[UkifyConfig] = None) -> str:
483c9c1b
ZJS
344 readelf = find_tool('readelf', opts=opts)
345
346 cmd = [
347 readelf,
348 '--notes',
349 filename,
350 ]
351
7d64e2f3 352 print('+', shell_join(cmd), file=sys.stderr)
33bdec18
ZJS
353 try:
354 notes = subprocess.check_output(cmd, stderr=subprocess.PIPE, text=True)
355 except subprocess.CalledProcessError as e:
356 raise ValueError(e.stderr.strip()) from e
483c9c1b
ZJS
357
358 if not (m := re.search(cls.NOTES_PATTERN, notes, re.MULTILINE)):
359 raise ValueError('Cannot find Linux version note')
360
361 text = ''.join(chr(int(c, 16)) for c in m.group('version').split())
362 return text.rstrip('\0')
363
364 @classmethod
f1b6430e 365 def scrape_generic(cls, filename: Path, opts: Optional[UkifyConfig] = None) -> str:
483c9c1b
ZJS
366 # import libarchive
367 # libarchive-c fails with
368 # ArchiveError: Unrecognized archive format (errno=84, retcode=-30, archive_p=94705420454656)
369
370 # Based on https://gitlab.archlinux.org/archlinux/mkinitcpio/mkinitcpio/-/blob/master/functions#L209
371
372 text = maybe_decompress(filename)
373 if not (m := re.search(cls.TEXT_PATTERN, text)):
374 raise ValueError(f'Cannot find {cls.TEXT_PATTERN!r} in {filename}')
375
376 return m.group('version').decode()
377
378 @classmethod
f1b6430e 379 def scrape(cls, filename: Path, opts: Optional[UkifyConfig] = None) -> Optional[str]:
483c9c1b
ZJS
380 for func in (cls.scrape_x86, cls.scrape_elf, cls.scrape_generic):
381 try:
382 version = func(filename, opts=opts)
7d64e2f3 383 print(f'Found uname version: {version}', file=sys.stderr)
483c9c1b
ZJS
384 return version
385 except ValueError as e:
cf4deeaf 386 print(str(e), file=sys.stderr)
483c9c1b
ZJS
387 return None
388
6a28cae9 389
df4a4673 390DEFAULT_SECTIONS_TO_SHOW = {
6a28cae9
JB
391 '.linux': 'binary',
392 '.initrd': 'binary',
393 '.ucode': 'binary',
394 '.splash': 'binary',
395 '.dtb': 'binary',
fa258f77 396 '.dtbauto': 'binary',
0333b9d5 397 '.hwids': 'binary',
83bf58f3 398 '.efifw': 'binary',
6a28cae9
JB
399 '.cmdline': 'text',
400 '.osrel': 'text',
401 '.uname': 'text',
402 '.pcrpkey': 'text',
403 '.pcrsig': 'text',
404 '.sbat': 'text',
405 '.sbom': 'binary',
406 '.profile': 'text',
407} # fmt: skip
408
483c9c1b 409
f4780cbe
ZJS
410@dataclasses.dataclass
411class Section:
412 name: str
35d92c03 413 content: Optional[Path]
e9519350 414 tmpfile: Optional[IO[Any]] = None
f4780cbe 415 measure: bool = False
df4a4673 416 output_mode: Optional[str] = None
f4780cbe
ZJS
417
418 @classmethod
e9519350 419 def create(cls, name: str, contents: Union[str, bytes, Path, None], **kwargs: Any) -> 'Section':
09595fd5 420 if isinstance(contents, (str, bytes)):
54c84c8a
ZJS
421 mode = 'wt' if isinstance(contents, str) else 'wb'
422 tmp = tempfile.NamedTemporaryFile(mode=mode, prefix=f'tmp{name}')
f4780cbe
ZJS
423 tmp.write(contents)
424 tmp.flush()
35d92c03 425 contents = Path(tmp.name)
f4780cbe
ZJS
426 else:
427 tmp = None
428
789a6427 429 return cls(name, contents, tmpfile=tmp, **kwargs)
f4780cbe
ZJS
430
431 @classmethod
e9519350 432 def parse_input(cls, s: str) -> 'Section':
f4780cbe
ZJS
433 try:
434 name, contents, *rest = s.split(':')
435 except ValueError as e:
436 raise ValueError(f'Cannot parse section spec (name or contents missing): {s!r}') from e
437 if rest:
438 raise ValueError(f'Cannot parse section spec (extraneous parameters): {s!r}')
439
440 if contents.startswith('@'):
e9519350
JB
441 sec = cls.create(name, Path(contents[1:]))
442 else:
443 sec = cls.create(name, contents)
f4780cbe 444
df4a4673
EGE
445 sec.check_name()
446 return sec
447
448 @classmethod
e9519350 449 def parse_output(cls, s: str) -> 'Section':
df4a4673
EGE
450 if not (m := re.match(r'([a-zA-Z0-9_.]+):(text|binary)(?:@(.+))?', s)):
451 raise ValueError(f'Cannot parse section spec: {s!r}')
452
453 name, ttype, out = m.groups()
35d92c03 454 out = Path(out) if out else None
df4a4673
EGE
455
456 return cls.create(name, out, output_mode=ttype)
f4780cbe 457
e9519350 458 def check_name(self) -> None:
f4780cbe
ZJS
459 # PE section names with more than 8 characters are legal, but our stub does
460 # not support them.
461 if not self.name.isascii() or not self.name.isprintable():
462 raise ValueError(f'Bad section name: {self.name!r}')
463 if len(self.name) > 8:
464 raise ValueError(f'Section name too long: {self.name!r}')
465
466
467@dataclasses.dataclass
468class UKI:
929d6225 469 executable: Path
f4780cbe 470 sections: list[Section] = dataclasses.field(default_factory=list, init=False)
f4780cbe 471
e9519350 472 def add_section(self, section: Section) -> None:
16020c33
DDM
473 start = 0
474
475 # Start search at last .profile section, if there is one
476 for i, s in enumerate(self.sections):
6a28cae9 477 if s.name == '.profile':
16020c33
DDM
478 start = i + 1
479
83bf58f3
AS
480 multiple_allowed_sections = ['.dtbauto', '.efifw']
481 if any(
482 section.name == s.name for s in self.sections[start:] if s.name not in multiple_allowed_sections
483 ):
f4780cbe
ZJS
484 raise ValueError(f'Duplicate section {section.name}')
485
f4780cbe
ZJS
486 self.sections += [section]
487
488
02eabaff
JB
489class SignTool:
490 @staticmethod
f1b6430e 491 def sign(input_f: str, output_f: str, opts: UkifyConfig) -> None:
bd208a54 492 raise NotImplementedError
02eabaff
JB
493
494 @staticmethod
60bda55f 495 def verify(input_f: Path, opts: UkifyConfig) -> bool:
bd208a54 496 raise NotImplementedError
02eabaff 497
5e7e4e4d 498 @staticmethod
d6047d9f 499 def from_string(name: str) -> type['SignTool']:
5e7e4e4d
ZJS
500 if name == 'pesign':
501 return PeSign
502 elif name == 'sbsign':
503 return SbSign
504 elif name == 'systemd-sbsign':
505 return SystemdSbSign
506 else:
507 raise ValueError(f'Invalid sign tool: {name!r}')
508
02eabaff
JB
509
510class PeSign(SignTool):
511 @staticmethod
f1b6430e 512 def sign(input_f: str, output_f: str, opts: UkifyConfig) -> None:
02eabaff
JB
513 assert opts.sb_certdir is not None
514 assert opts.sb_cert_name is not None
515
516 tool = find_tool('pesign', opts=opts, msg='pesign, required for signing, is not installed')
517 cmd = [
518 tool,
519 '-s',
520 '--force',
521 '-n', opts.sb_certdir,
522 '-c', opts.sb_cert_name,
523 '-i', input_f,
524 '-o', output_f,
525 ] # fmt: skip
526
7d64e2f3 527 print('+', shell_join(cmd), file=sys.stderr)
02eabaff
JB
528 subprocess.check_call(cmd)
529
530 @staticmethod
60bda55f
LB
531 def verify(input_f: Path, opts: UkifyConfig) -> bool:
532 assert input_f is not None
02eabaff
JB
533
534 tool = find_tool('pesign', opts=opts)
60bda55f 535 cmd = [tool, '-i', input_f, '-S']
02eabaff 536
7d64e2f3 537 print('+', shell_join(cmd), file=sys.stderr)
02eabaff
JB
538 info = subprocess.check_output(cmd, text=True)
539
540 return 'No signatures found.' in info
541
542
543class SbSign(SignTool):
544 @staticmethod
f1b6430e 545 def sign(input_f: str, output_f: str, opts: UkifyConfig) -> None:
02eabaff
JB
546 assert opts.sb_key is not None
547 assert opts.sb_cert is not None
548
549 tool = find_tool('sbsign', opts=opts, msg='sbsign, required for signing, is not installed')
550 cmd = [
551 tool,
552 '--key', opts.sb_key,
553 '--cert', opts.sb_cert,
554 *(['--engine', opts.signing_engine] if opts.signing_engine is not None else []),
555 input_f,
556 '--output', output_f,
557 ] # fmt: skip
558
7d64e2f3 559 print('+', shell_join(cmd), file=sys.stderr)
02eabaff
JB
560 subprocess.check_call(cmd)
561
562 @staticmethod
60bda55f
LB
563 def verify(input_f: Path, opts: UkifyConfig) -> bool:
564 assert input_f is not None
02eabaff
JB
565
566 tool = find_tool('sbverify', opts=opts)
60bda55f 567 cmd = [tool, '--list', input_f]
02eabaff 568
7d64e2f3 569 print('+', shell_join(cmd), file=sys.stderr)
02eabaff
JB
570 info = subprocess.check_output(cmd, text=True)
571
572 return 'No signature table present' in info
573
574
d835c447
DDM
575class SystemdSbSign(SignTool):
576 @staticmethod
577 def sign(input_f: str, output_f: str, opts: UkifyConfig) -> None:
578 assert opts.sb_key is not None
579 assert opts.sb_cert is not None
580
581 tool = find_tool(
582 'systemd-sbsign',
583 '/usr/lib/systemd/systemd-sbsign',
584 opts=opts,
585 msg='systemd-sbsign, required for signing, is not installed',
586 )
587 cmd = [
588 tool,
589 "sign",
590 '--private-key', opts.sb_key,
591 '--certificate', opts.sb_cert,
592 *(
593 ['--private-key-source', f'engine:{opts.signing_engine}']
594 if opts.signing_engine is not None
595 else []
596 ),
65fbf3b1
DDM
597 *(
598 ['--private-key-source', f'provider:{opts.signing_provider}']
599 if opts.signing_provider is not None
600 else []
601 ),
64cc7ba5
DDM
602 *(
603 ['--certificate-source', f'provider:{opts.certificate_provider}']
604 if opts.certificate_provider is not None
605 else []
606 ),
d835c447
DDM
607 input_f,
608 '--output', output_f,
609 ] # fmt: skip
610
7d64e2f3 611 print('+', shell_join(cmd), file=sys.stderr)
d835c447
DDM
612 subprocess.check_call(cmd)
613
614 @staticmethod
60bda55f 615 def verify(input_f: Path, opts: UkifyConfig) -> bool:
d835c447
DDM
616 raise NotImplementedError('systemd-sbsign cannot yet verify if existing PE binaries are signed')
617
618
e9519350 619def parse_banks(s: str) -> list[str]:
f4780cbe
ZJS
620 banks = re.split(r',|\s+', s)
621 # TODO: do some sanity checking here
622 return banks
623
624
625KNOWN_PHASES = (
626 'enter-initrd',
627 'leave-initrd',
628 'sysinit',
629 'ready',
630 'shutdown',
631 'final',
632)
633
6a28cae9 634
e9519350 635def parse_phase_paths(s: str) -> list[str]:
f4780cbe
ZJS
636 # Split on commas or whitespace here. Commas might be hard to parse visually.
637 paths = re.split(r',|\s+', s)
638
639 for path in paths:
640 for phase in path.split(':'):
641 if phase not in KNOWN_PHASES:
642 raise argparse.ArgumentTypeError(f'Unknown boot phase {phase!r} ({path=})')
643
644 return paths
645
646
f1b6430e 647def check_splash(filename: Optional[Path]) -> None:
f4780cbe
ZJS
648 if filename is None:
649 return
650
651 # import is delayed, to avoid import when the splash image is not used
652 try:
653 from PIL import Image
654 except ImportError:
655 return
656
657 img = Image.open(filename, formats=['BMP'])
7d64e2f3 658 print(f'Splash image {filename} is {img.width}×{img.height} pixels', file=sys.stderr)
f4780cbe
ZJS
659
660
f1b6430e 661def check_inputs(opts: UkifyConfig) -> None:
f4780cbe
ZJS
662 for name, value in vars(opts).items():
663 if name in {'output', 'tools'}:
664 continue
665
35d92c03 666 if isinstance(value, Path):
0333b9d5 667 # Check that we can open the directory or file, or generate and exception
668 if value.is_dir():
669 value.iterdir()
670 else:
671 value.open().close()
5143a47a
ZJS
672 elif isinstance(value, list):
673 for item in value:
35d92c03 674 if isinstance(item, Path):
83bf58f3
AS
675 if item.is_dir():
676 item.iterdir()
677 else:
678 item.open().close()
f4780cbe
ZJS
679
680 check_splash(opts.splash)
681
682
f1b6430e 683def check_cert_and_keys_nonexistent(opts: UkifyConfig) -> None:
a1c80efd 684 # Raise if any of the keys and certs are found on disk
f1b6430e 685 paths: Iterator[Union[str, Path, None]] = itertools.chain(
a1c80efd 686 (opts.sb_key, opts.sb_cert),
2ac8fcf6 687 *((priv_key, pub_key, cert) for priv_key, pub_key, cert, _ in key_path_groups(opts)),
6a28cae9 688 )
a1c80efd 689 for path in paths:
35d92c03 690 if path and Path(path).exists():
a1c80efd
ZJS
691 raise ValueError(f'{path} is present')
692
693
e9519350
JB
694def find_tool(
695 name: str,
696 fallback: Optional[str] = None,
f1b6430e 697 opts: Optional[UkifyConfig] = None,
206fa93c
JB
698 msg: str = 'Tool {name} not installed!',
699) -> Union[str, Path]:
f4780cbe 700 if opts and opts.tools:
22ad038a
DDM
701 for d in opts.tools:
702 tool = d / name
703 if tool.exists():
f1b6430e 704 return tool
f4780cbe 705
c8add4c2
JJ
706 if shutil.which(name) is not None:
707 return name
708
e673c5c2 709 if fallback is None:
206fa93c 710 raise ValueError(msg.format(name=name))
f4780cbe 711
e673c5c2 712 return fallback
f4780cbe 713
6a28cae9 714
e9519350
JB
715def combine_signatures(pcrsigs: list[dict[str, str]]) -> str:
716 combined: collections.defaultdict[str, list[str]] = collections.defaultdict(list)
f4780cbe
ZJS
717 for pcrsig in pcrsigs:
718 for bank, sigs in pcrsig.items():
719 for sig in sigs:
720 if sig not in combined[bank]:
721 combined[bank] += [sig]
722 return json.dumps(combined)
723
724
2ac8fcf6 725def key_path_groups(opts: UkifyConfig) -> Iterator[tuple[str, Optional[str], Optional[str], Optional[str]]]:
9a1cb203
ZJS
726 if not opts.pcr_private_keys:
727 return
728
a1c80efd 729 n_priv = len(opts.pcr_private_keys)
f1b6430e 730 pub_keys = opts.pcr_public_keys or []
2ac8fcf6 731 certs = opts.pcr_certificates or []
f1b6430e 732 pp_groups = opts.phase_path_groups or []
9a1cb203 733
f1b6430e 734 yield from itertools.zip_longest(
6a28cae9 735 opts.pcr_private_keys,
f1b6430e 736 pub_keys[:n_priv],
2ac8fcf6 737 certs[:n_priv],
f1b6430e
JB
738 pp_groups[:n_priv],
739 fillvalue=None,
6a28cae9 740 )
9a1cb203
ZJS
741
742
e9519350 743def pe_strip_section_name(name: bytes) -> str:
6a28cae9 744 return name.rstrip(b'\x00').decode()
db0f9720
DDM
745
746
33b25fa1
DDM
747def pe_section_size(section: pefile.SectionStructure) -> int:
748 return cast(int, min(section.Misc_VirtualSize, section.SizeOfRawData))
749
750
9876e88e 751def call_systemd_measure(uki: UKI, opts: UkifyConfig, profile_start: int = 0) -> str:
6a28cae9
JB
752 measure_tool = find_tool(
753 'systemd-measure',
754 '/usr/lib/systemd/systemd-measure',
755 opts=opts,
756 )
9876e88e 757 combined = ''
f4780cbe
ZJS
758
759 banks = opts.pcr_banks or ()
760
761 # PCR measurement
762
16020c33 763 # First, pick up either the base sections or the profile specific sections we shall measure now
fa258f77 764 unique_to_measure = {
765 s.name: s for s in uki.sections[profile_start:] if s.measure and s.name != '.dtbauto'
766 }
767
768 dtbauto_to_measure: list[Optional[Section]] = [
769 s for s in uki.sections[profile_start:] if s.measure and s.name == '.dtbauto'
770 ]
771
772 if len(dtbauto_to_measure) == 0:
773 dtbauto_to_measure = [None]
16020c33
DDM
774
775 # Then, if we're measuring a profile, lookup the missing sections from the base image.
776 if profile_start != 0:
777 for section in uki.sections:
778 # If we reach the first .profile section the base is over
6a28cae9 779 if section.name == '.profile':
16020c33
DDM
780 break
781
782 # Only some sections are measured
783 if not section.measure:
784 continue
785
786 # Check if this is a section we already covered above
fa258f77 787 if section.name in unique_to_measure:
16020c33
DDM
788 continue
789
fa258f77 790 unique_to_measure[section.name] = section
16020c33 791
606c5e75 792 if opts.measure or opts.policy_digest:
9876e88e 793 pcrsigs = []
fa258f77 794 to_measure = unique_to_measure.copy()
f4780cbe 795
fa258f77 796 for dtbauto in dtbauto_to_measure:
797 if dtbauto is not None:
798 to_measure[dtbauto.name] = dtbauto
f4780cbe 799
fa258f77 800 pp_groups = opts.phase_path_groups or []
801
802 cmd = [
803 measure_tool,
606c5e75 804 'calculate' if opts.measure else 'policy-digest',
7bf31680
LB
805 '--json',
806 opts.json,
96403d51 807 *(f'--{s.name.removeprefix(".")}={s.content}' for s in to_measure.values()),
fa258f77 808 *(f'--bank={bank}' for bank in banks),
809 # For measurement, the keys are not relevant, so we can lump all the phase paths
810 # into one call to systemd-measure calculate.
811 *(f'--phase={phase_path}' for phase_path in itertools.chain.from_iterable(pp_groups)),
812 ]
813
606c5e75 814 # The JSON object will be used for offline signing, include the public key
2ac8fcf6
LB
815 # so that the fingerprint is included too. In case a certificate is passed, use the
816 # right parameter so that systemd-measure can extract the public key from it.
817 if opts.policy_digest:
818 if opts.pcr_public_keys:
819 cmd += ['--public-key', opts.pcr_public_keys[0]]
820 elif opts.pcr_certificates:
821 cmd += ['--certificate', opts.pcr_certificates[0]]
822 if opts.certificate_provider:
823 cmd += ['--certificate-source', f'provider:{opts.certificate_provider}']
606c5e75 824
7d64e2f3 825 print('+', shell_join(cmd), file=sys.stderr)
9876e88e
LB
826 output = subprocess.check_output(cmd, text=True) # type: ignore
827
828 if opts.policy_digest:
829 pcrsig = json.loads(output)
830 pcrsigs += [pcrsig]
831 else:
832 print(output)
833
834 if opts.policy_digest:
835 combined = combine_signatures(pcrsigs)
836 # We need to ensure the section has space for signatures, that will be added separately later,
837 # so add some whitespace to pad the section. At most we'll need 4kb per digest (rsa4096).
838 # We might even check the key type given we have it to know the precise length, but don't
839 # bother for now.
840 combined += ' ' * 1024 * combined.count('"pol":')
841 uki.add_section(Section.create('.pcrsig', combined))
f4780cbe
ZJS
842
843 # PCR signing
844
845 if opts.pcr_private_keys:
f4780cbe 846 pcrsigs = []
fa258f77 847 to_measure = unique_to_measure.copy()
848
849 for dtbauto in dtbauto_to_measure:
850 if dtbauto is not None:
851 to_measure[dtbauto.name] = dtbauto
852
853 cmd = [
854 measure_tool,
855 'sign',
96403d51 856 *(f'--{s.name.removeprefix(".")}={s.content}' for s in to_measure.values()),
fa258f77 857 *(f'--bank={bank}' for bank in banks),
858 ]
859
2ac8fcf6 860 for priv_key, pub_key, cert, group in key_path_groups(opts):
fa258f77 861 extra = [f'--private-key={priv_key}']
862 if opts.signing_engine is not None:
2ac8fcf6
LB
863 assert pub_key or cert
864 # Backward compatibility, we used to pass the public key as the certificate
865 # as there was no --pcr-certificate= parameter
866 extra += [
867 f'--private-key-source=engine:{opts.signing_engine}',
868 f'--certificate={pub_key or cert}',
869 ]
fa258f77 870 elif opts.signing_provider is not None:
2ac8fcf6
LB
871 assert pub_key or cert
872 extra += [
873 f'--private-key-source=provider:{opts.signing_provider}',
874 f'--certificate={pub_key or cert}',
875 ]
876 elif cert:
877 extra += [f'--certificate={cert}']
fa258f77 878 elif pub_key:
879 extra += [f'--public-key={pub_key}']
880
881 if opts.certificate_provider is not None:
882 extra += [f'--certificate-source=provider:{opts.certificate_provider}']
883
884 extra += [f'--phase={phase_path}' for phase_path in group or ()]
885
7d64e2f3 886 print('+', shell_join(cmd + extra), file=sys.stderr) # type: ignore
9876e88e
LB
887 output = subprocess.check_output(cmd + extra, text=True) # type: ignore
888 pcrsig = json.loads(output)
fa258f77 889 pcrsigs += [pcrsig]
f4780cbe
ZJS
890
891 combined = combine_signatures(pcrsigs)
892 uki.add_section(Section.create('.pcrsig', combined))
893
9876e88e
LB
894 return combined
895
f4780cbe 896
e9519350 897def join_initrds(initrds: list[Path]) -> Union[Path, bytes, None]:
a3b227d2 898 if not initrds:
09595fd5 899 return None
c4fc2546 900 if len(initrds) == 1:
09595fd5
DDM
901 return initrds[0]
902
903 seq = []
904 for file in initrds:
905 initrd = file.read_bytes()
c126c8ac
YW
906 n = len(initrd)
907 padding = b'\0' * (round_up(n, 4) - n) # pad to 32 bit alignment
09595fd5
DDM
908 seq += [initrd, padding]
909
910 return b''.join(seq)
54c84c8a
ZJS
911
912
e9519350
JB
913T = TypeVar('T')
914
915
916def pairwise(iterable: Iterable[T]) -> Iterator[tuple[T, Optional[T]]]:
c811aba0
DDM
917 a, b = itertools.tee(iterable)
918 next(b, None)
919 return zip(a, b)
920
921
7081db29 922class PEError(Exception):
3fc5eed4
JJ
923 pass
924
3fc1ae89 925
9876e88e 926def pe_add_sections(opts: UkifyConfig, uki: UKI, output: str) -> None:
3fc5eed4 927 pe = pefile.PE(uki.executable, fast_load=True)
ac3412c3 928
7f72dca7
JJ
929 # Old stubs do not have the symbol/string table stripped, even though image files should not have one.
930 if symbol_table := pe.FILE_HEADER.PointerToSymbolTable:
931 symbol_table_size = 18 * pe.FILE_HEADER.NumberOfSymbols
932 if string_table_size := pe.get_dword_from_offset(symbol_table + symbol_table_size):
933 symbol_table_size += string_table_size
934
935 # Let's be safe and only strip it if it's at the end of the file.
936 if symbol_table + symbol_table_size == len(pe.__data__):
937 pe.__data__ = pe.__data__[:symbol_table]
938 pe.FILE_HEADER.PointerToSymbolTable = 0
939 pe.FILE_HEADER.NumberOfSymbols = 0
940 pe.FILE_HEADER.IMAGE_FILE_LOCAL_SYMS_STRIPPED = True
941
ac3412c3 942 # Old stubs might have been stripped, leading to unaligned raw data values, so let's fix them up here.
c4fc2546
ZJS
943 # pylint thinks that Structure doesn't have various members that it has…
944 # pylint: disable=no-member
945
ac3412c3
DDM
946 for i, section in enumerate(pe.sections):
947 oldp = section.PointerToRawData
948 oldsz = section.SizeOfRawData
949 section.PointerToRawData = round_up(oldp, pe.OPTIONAL_HEADER.FileAlignment)
950 section.SizeOfRawData = round_up(oldsz, pe.OPTIONAL_HEADER.FileAlignment)
951 padp = section.PointerToRawData - oldp
952 padsz = section.SizeOfRawData - oldsz
953
6a28cae9 954 for later_section in pe.sections[i + 1 :]:
ac3412c3
DDM
955 later_section.PointerToRawData += padp + padsz
956
6a28cae9
JB
957 pe.__data__ = (
958 pe.__data__[:oldp]
959 + bytes(padp)
960 + pe.__data__[oldp : oldp + oldsz]
961 + bytes(padsz)
962 + pe.__data__[oldp + oldsz :]
963 )
ac3412c3
DDM
964
965 # We might not have any space to add new sections. Let's try our best to make some space by padding the
966 # SizeOfHeaders to a multiple of the file alignment. This is safe because the first section's data starts
967 # at a multiple of the file alignment, so all space before that is unused.
6a28cae9
JB
968 pe.OPTIONAL_HEADER.SizeOfHeaders = round_up(
969 pe.OPTIONAL_HEADER.SizeOfHeaders, pe.OPTIONAL_HEADER.FileAlignment
970 )
ac3412c3 971 pe = pefile.PE(data=pe.write(), fast_load=True)
3fc1ae89 972
32caed55
LB
973 # pefile has an hardcoded limit of 256MB, which is not enough when building an initrd with large firmware
974 # files and all kernel modules. See: https://github.com/erocarrera/pefile/issues/396
3fc5eed4 975 warnings = pe.get_warnings()
32caed55
LB
976 for w in warnings:
977 if 'VirtualSize is extremely large' in w:
978 continue
979 if 'VirtualAddress is beyond' in w:
980 continue
7081db29 981 raise PEError(f'pefile warnings treated as errors: {warnings}')
3fc1ae89 982
9876e88e
LB
983 # When attaching signatures we are operating on an existing UKI which might be signed
984 if not opts.pcrsig:
985 security = pe.OPTIONAL_HEADER.DATA_DIRECTORY[
986 pefile.DIRECTORY_ENTRY['IMAGE_DIRECTORY_ENTRY_SECURITY']
987 ]
988 if security.VirtualAddress != 0:
989 # We could strip the signatures, but why would anyone sign the stub?
990 raise PEError('Stub image is signed, refusing')
3fc5eed4 991
16020c33
DDM
992 # Remember how many sections originate from systemd-stub
993 n_original_sections = len(pe.sections)
994
3fc5eed4
JJ
995 for section in uki.sections:
996 new_section = pefile.SectionStructure(pe.__IMAGE_SECTION_HEADER_format__, pe=pe)
997 new_section.__unpack__(b'\0' * new_section.sizeof())
998
999 offset = pe.sections[-1].get_file_offset() + new_section.sizeof()
1000 if offset + new_section.sizeof() > pe.OPTIONAL_HEADER.SizeOfHeaders:
7081db29 1001 raise PEError(f'Not enough header space to add section {section.name}.')
3fc5eed4 1002
df4a4673 1003 assert section.content
3fc5eed4
JJ
1004 data = section.content.read_bytes()
1005
1006 new_section.set_file_offset(offset)
1007 new_section.Name = section.name.encode()
38801c91 1008 new_section.Misc_VirtualSize = len(data)
ac3412c3
DDM
1009 # Non-stripped stubs might still have an unaligned symbol table at the end, making their size
1010 # unaligned, so we make sure to explicitly pad the pointer to new sections to an aligned offset.
1011 new_section.PointerToRawData = round_up(len(pe.__data__), pe.OPTIONAL_HEADER.FileAlignment)
3fc5eed4
JJ
1012 new_section.SizeOfRawData = round_up(len(data), pe.OPTIONAL_HEADER.FileAlignment)
1013 new_section.VirtualAddress = round_up(
1014 pe.sections[-1].VirtualAddress + pe.sections[-1].Misc_VirtualSize,
1015 pe.OPTIONAL_HEADER.SectionAlignment,
1016 )
1017
1018 new_section.IMAGE_SCN_MEM_READ = True
1019 if section.name == '.linux':
1020 # Old kernels that use EFI handover protocol will be executed inline.
1021 new_section.IMAGE_SCN_CNT_CODE = True
1022 else:
1023 new_section.IMAGE_SCN_CNT_INITIALIZED_DATA = True
1024
c3f7501c
LB
1025 # Special case, mostly for .sbat: the stub will already have a .sbat section, but we want to append
1026 # the one from the kernel to it. It should be small enough to fit in the existing section, so just
1027 # swap the data.
16020c33 1028 for i, s in enumerate(pe.sections[:n_original_sections]):
fa258f77 1029 if pe_strip_section_name(s.Name) == section.name and section.name != '.dtbauto':
c3f7501c 1030 if new_section.Misc_VirtualSize > s.SizeOfRawData:
bd208a54 1031 raise PEError(f'Not enough space in existing section {section.name} to append new data')
c3f7501c
LB
1032
1033 padding = bytes(new_section.SizeOfRawData - new_section.Misc_VirtualSize)
6a28cae9
JB
1034 pe.__data__ = (
1035 pe.__data__[: s.PointerToRawData]
1036 + data
1037 + padding
1038 + pe.__data__[pe.sections[i + 1].PointerToRawData :]
1039 )
c3f7501c
LB
1040 s.SizeOfRawData = new_section.SizeOfRawData
1041 s.Misc_VirtualSize = new_section.Misc_VirtualSize
1042 break
1043 else:
6a28cae9
JB
1044 pe.__data__ = (
1045 pe.__data__[:]
1046 + bytes(new_section.PointerToRawData - len(pe.__data__))
1047 + data
1048 + bytes(new_section.SizeOfRawData - len(data))
1049 )
3fc5eed4 1050
c3f7501c
LB
1051 pe.FILE_HEADER.NumberOfSections += 1
1052 pe.OPTIONAL_HEADER.SizeOfInitializedData += new_section.Misc_VirtualSize
1053 pe.__structures__.append(new_section)
1054 pe.sections.append(new_section)
3fc5eed4 1055
9876e88e
LB
1056 # If there is a pre-signed JSON blob, we need to update the existing JSON, by appending the signature to
1057 # each corresponding digest object. We have built the unsigned UKI with enough space to fit the .sig
1058 # objects, so we can just replace the new signed JSON in the existing sections.
1059 if opts.pcrsig:
1060 signatures = json.loads(str(opts.pcrsig))
1061 for i, section in enumerate(pe.sections):
1062 if pe_strip_section_name(section.Name) == '.pcrsig':
1063 j = json.loads(
1064 bytes(
1065 pe.__data__[
1066 section.PointerToRawData : section.PointerToRawData + section.SizeOfRawData
1067 ]
1068 )
1069 .rstrip(b'\x00')
1070 .decode()
1071 )
1072 for (bank, sigs), (input_bank, input_sigs) in itertools.product(
1073 j.items(), signatures.items()
1074 ):
1075 if input_bank != bank:
1076 continue
1077 for sig, input_sig in itertools.product(sigs, input_sigs):
1078 if sig['pol'] == input_sig['pol']:
1079 sig['sig'] = input_sig['sig']
1080
1081 encoded = json.dumps(j).encode()
1082 if len(encoded) > section.SizeOfRawData:
1083 raise PEError(
bd208a54 1084 f'Not enough space in existing section .pcrsig of size {section.SizeOfRawData} to append new data of size {len(encoded)}' # noqa: E501
9876e88e 1085 )
9876e88e
LB
1086 section.Misc_VirtualSize = len(encoded)
1087 # bytes(n) results in an array of n zeroes
1088 padding = bytes(section.SizeOfRawData - len(encoded))
1089 pe.__data__ = (
1090 pe.__data__[: section.PointerToRawData]
1091 + encoded
1092 + padding
1093 + pe.__data__[section.PointerToRawData + section.SizeOfRawData :]
1094 )
1095
3fc5eed4
JJ
1096 pe.OPTIONAL_HEADER.CheckSum = 0
1097 pe.OPTIONAL_HEADER.SizeOfImage = round_up(
1098 pe.sections[-1].VirtualAddress + pe.sections[-1].Misc_VirtualSize,
1099 pe.OPTIONAL_HEADER.SectionAlignment,
1100 )
1101
1102 pe.write(output)
3fc1ae89 1103
6a28cae9 1104
e9519350 1105def merge_sbat(input_pe: list[Path], input_text: list[str]) -> str:
c3f7501c
LB
1106 sbat = []
1107
a8b645de 1108 for f in input_pe:
c3f7501c
LB
1109 try:
1110 pe = pefile.PE(f, fast_load=True)
1111 except pefile.PEFormatError:
cf4deeaf 1112 print(f'{f} is not a valid PE file, not extracting SBAT section.', file=sys.stderr)
c3f7501c
LB
1113 continue
1114
1115 for section in pe.sections:
6a28cae9
JB
1116 if pe_strip_section_name(section.Name) == '.sbat':
1117 split = section.get_data().rstrip(b'\x00').decode().splitlines()
c3f7501c 1118 if not split[0].startswith('sbat,'):
cf4deeaf 1119 print(f'{f} does not contain a valid SBAT section, skipping.', file=sys.stderr)
c3f7501c
LB
1120 continue
1121 # Filter out the sbat line, we'll add it back later, there needs to be only one and it
1122 # needs to be first.
1123 sbat += split[1:]
1124
a8b645de
LB
1125 for t in input_text:
1126 if t.startswith('@'):
35d92c03 1127 t = Path(t[1:]).read_text()
a8b645de
LB
1128 split = t.splitlines()
1129 if not split[0].startswith('sbat,'):
cf4deeaf 1130 print(f'{t} does not contain a valid SBAT section, skipping.', file=sys.stderr)
a8b645de
LB
1131 continue
1132 sbat += split[1:]
1133
6a28cae9
JB
1134 return (
1135 'sbat,1,SBAT Version,sbat,1,https://github.com/rhboot/shim/blob/main/SBAT.md\n'
1136 + '\n'.join(sbat)
1137 + '\n\x00'
1138 )
1139
c3f7501c 1140
5d0ac352 1141# Keep in sync with Device from src/boot/chid.h
61d60757 1142# uint32_t descriptor, EFI_GUID chid, uint32_t name_offset, uint32_t compatible_offset
cf331f1c 1143DEVICE_STRUCT_SIZE = 4 + 16 + 4 + 4
0333b9d5 1144NULL_DEVICE = b'\0' * DEVICE_STRUCT_SIZE
61d60757 1145DEVICE_TYPE_DEVICETREE = 1
5d0ac352 1146DEVICE_TYPE_UEFI_FW = 2
61d60757 1147
83bf58f3
AS
1148# Keep in sync with efifirmware.h
1149FWHEADERMAGIC = 'feeddead'
1150EFIFW_HEADER_SIZE = 4 + 4 + 4 + 4
1151
61d60757 1152
1153def device_make_descriptor(device_type: int, size: int) -> int:
1154 return (size) | (device_type << 28)
0333b9d5 1155
1156
5d0ac352
AS
1157def pack_device(
1158 offsets: dict[str, int], devtype: int, name: str, compatible_or_fwid: str, chids: set[uuid.UUID]
1159) -> bytes:
0333b9d5 1160 data = b''
5d0ac352 1161 descriptor = device_make_descriptor(devtype, DEVICE_STRUCT_SIZE)
cf331f1c 1162 for chid in sorted(chids):
5d0ac352 1163 data += struct.pack('<I', descriptor)
cf331f1c 1164 data += chid.bytes_le
5d0ac352 1165 data += struct.pack('<II', offsets[name], offsets[compatible_or_fwid])
0333b9d5 1166
1167 assert len(data) == DEVICE_STRUCT_SIZE * len(chids)
1168 return data
1169
1170
0333b9d5 1171def pack_strings(strings: set[str], base: int) -> tuple[bytes, dict[str, int]]:
1172 blob = b''
1173 offsets = {}
1174
1175 for string in sorted(strings):
1176 offsets[string] = base + len(blob)
1177 blob += string.encode('utf-8') + b'\00'
1178
1179 return (blob, offsets)
1180
1181
1182def parse_hwid_dir(path: Path) -> bytes:
cf331f1c 1183 hwid_files = path.rglob('*.json')
5d0ac352
AS
1184 devstr_to_type: dict[str, int] = {
1185 'devicetree': DEVICE_TYPE_DEVICETREE,
1186 'uefi-fw': DEVICE_TYPE_UEFI_FW,
1187 }
1188
1189 # all attributes in the mandatory attributes list must be present
1190 mandatory_attribute = ['type', 'name', 'hwids']
1191
1192 # at least one of the following attributes must be present
1193 one_of = ['compatible', 'fwid']
1194
1195 one_of_key_to_devtype: dict[str, int] = {
1196 'compatible': DEVICE_TYPE_DEVICETREE,
1197 'fwid': DEVICE_TYPE_UEFI_FW,
1198 }
0333b9d5 1199
1200 strings: set[str] = set()
5d0ac352 1201 devices: collections.defaultdict[tuple[int, str, str], set[uuid.UUID]] = collections.defaultdict(set)
0333b9d5 1202
1203 for hwid_file in hwid_files:
cf331f1c 1204 data = json.loads(hwid_file.read_text(encoding='UTF-8'))
0333b9d5 1205
5d0ac352 1206 for k in mandatory_attribute:
cf331f1c 1207 if k not in data:
1208 raise ValueError(f'hwid description file "{hwid_file}" does not contain "{k}"')
0333b9d5 1209
5d0ac352
AS
1210 if not any(key in data for key in one_of):
1211 required_keys = ','.join(one_of)
1212 raise ValueError(f'hwid description file "{hwid_file}" must contain one of {required_keys}')
1213
1214 # (devtype, name, compatible/fwid) pair uniquely identifies the device
1215 devtype = devstr_to_type[data['type']]
0333b9d5 1216
5d0ac352
AS
1217 for k in one_of:
1218 if k in data:
1219 if one_of_key_to_devtype[k] != devtype:
1220 raise ValueError(
1221 f'wrong attribute "{k}" for hwid description file "{hwid_file}", '
1222 'device type: "%s"' % devtype
1223 )
1224 strings |= {data['name'], data[k]}
1225 devices[(devtype, data['name'], data[k])] |= {uuid.UUID(u) for u in data['hwids']}
0333b9d5 1226
1227 total_device_structs = 1
1228 for dev, uuids in devices.items():
1229 total_device_structs += len(uuids)
1230
1231 strings_blob, offsets = pack_strings(strings, total_device_structs * DEVICE_STRUCT_SIZE)
1232
1233 devices_blob = b''
5d0ac352
AS
1234 for (devtype, name, compatible_or_fwid), uuids in devices.items():
1235 devices_blob += pack_device(offsets, devtype, name, compatible_or_fwid, uuids)
0333b9d5 1236
1237 devices_blob += NULL_DEVICE
1238
1239 return devices_blob + strings_blob
1240
1241
83bf58f3
AS
1242def parse_efifw_dir(path: Path) -> bytes:
1243 if not path.is_dir():
bd208a54 1244 raise ValueError(f'{path} is not a directory or it does not exist')
83bf58f3
AS
1245
1246 # only one firmware image must be present in the directory
1247 # to uniquely identify that firmware with its ID.
1248 if len(list(path.glob('*'))) != 1:
bd208a54 1249 raise ValueError(f'{path} must contain exactly one firmware image file')
83bf58f3
AS
1250
1251 payload_blob = b''
1252 for fw in path.iterdir():
1253 payload_blob += fw.read_bytes()
1254
1255 payload_len = len(payload_blob)
1256 if payload_len == 0:
1257 raise ValueError(f'{fw} is a zero byte file!')
1258
1259 dirname = path.parts[-1]
1260 # firmware id is the name of the directory the firmware bundle is in,
1261 # terminated by NULL.
1262 fwid = b'' + dirname.encode() + b'\0'
1263 fwid_len = len(fwid)
1264 magic = bytes.fromhex(FWHEADERMAGIC)
1265
1266 efifw_header_blob = b''
1267 efifw_header_blob += struct.pack('<p', magic)
1268 efifw_header_blob += struct.pack('<I', EFIFW_HEADER_SIZE)
1269 efifw_header_blob += struct.pack('<I', fwid_len)
1270 efifw_header_blob += struct.pack('<I', payload_len)
1271
1272 efifw_blob = b''
1273 efifw_blob += efifw_header_blob + fwid + payload_blob
1274
1275 return efifw_blob
1276
1277
180f957a
JB
1278STUB_SBAT = """\
1279sbat,1,SBAT Version,sbat,1,https://github.com/rhboot/shim/blob/main/SBAT.md
1280uki,1,UKI,uki,1,https://uapi-group.org/specifications/specs/unified_kernel_image/
1281"""
1282
1283ADDON_SBAT = """\
1284sbat,1,SBAT Version,sbat,1,https://github.com/rhboot/shim/blob/main/SBAT.md
1285uki-addon,1,UKI Addon,addon,1,https://www.freedesktop.org/software/systemd/man/latest/systemd-stub.html
1286"""
1287
1288
f1b6430e
JB
1289def make_uki(opts: UkifyConfig) -> None:
1290 assert opts.output is not None
1291
c1e8d172
EGE
1292 # kernel payload signing
1293
c1e8d172 1294 sign_args_present = opts.sb_key or opts.sb_cert_name
b708789d 1295 sign_kernel = opts.sign_kernel
b708789d 1296 linux = opts.linux
9876e88e 1297 combined_sigs = '{}'
b708789d 1298
0dd03215
LB
1299 # On some distros, on some architectures, the vmlinuz is a gzip file, so we need to decompress it
1300 # if it's not a valid PE file, as it will fail to be booted by the firmware.
1301 if linux:
1302 try:
1303 pefile.PE(linux, fast_load=True)
1304 except pefile.PEFormatError:
1305 try:
1306 decompressed = maybe_decompress(linux)
1307 except NotImplementedError:
1308 print(f'{linux} is not a valid PE file and cannot be decompressed either', file=sys.stderr)
1309 else:
1310 print(f'{linux} is compressed and cannot be loaded by UEFI, decompressing', file=sys.stderr)
1311 linux = Path(tempfile.NamedTemporaryFile(prefix='linux-decompressed').name)
1312 linux.write_bytes(decompressed)
1313
1314 if linux and sign_args_present:
02eabaff 1315 assert opts.signtool is not None
5e7e4e4d 1316 signtool = SignTool.from_string(opts.signtool)
f4780cbe 1317
32c3e137 1318 if sign_kernel is None:
b708789d 1319 # figure out if we should sign the kernel
60bda55f 1320 sign_kernel = signtool.verify(linux, opts)
b708789d
EGE
1321
1322 if sign_kernel:
1323 linux_signed = tempfile.NamedTemporaryFile(prefix='linux-signed')
0dd03215 1324 signtool.sign(os.fspath(linux), os.fspath(Path(linux_signed.name)), opts=opts)
35d92c03 1325 linux = Path(linux_signed.name)
f4780cbe 1326
0dd03215 1327 if opts.uname is None and linux is not None:
7d64e2f3 1328 print('Kernel version not specified, starting autodetection 😖.', file=sys.stderr)
0dd03215 1329 opts.uname = Uname.scrape(linux, opts=opts)
483c9c1b 1330
9876e88e 1331 uki = UKI(opts.join_pcrsig if opts.join_pcrsig else opts.stub)
54c84c8a 1332 initrd = join_initrds(opts.initrd)
f4780cbe 1333
f1b6430e 1334 pcrpkey: Union[bytes, Path, None] = opts.pcrpkey
f4780cbe 1335 if pcrpkey is None:
84624d8c
AAF
1336 keyutil_tool = find_tool('systemd-keyutil', '/usr/lib/systemd/systemd-keyutil')
1337 cmd = [keyutil_tool, 'public']
64cc7ba5 1338
f4780cbe 1339 if opts.pcr_public_keys and len(opts.pcr_public_keys) == 1:
64cc7ba5
DDM
1340 # If we're using an engine or provider, the public key will be an X.509 certificate.
1341 if opts.signing_engine or opts.signing_provider:
1342 cmd += ['--certificate', opts.pcr_public_keys[0]]
1343 if opts.certificate_provider:
1344 cmd += ['--certificate-source', f'provider:{opts.certificate_provider}']
64cc7ba5 1345
7d64e2f3 1346 print('+', shell_join(cmd), file=sys.stderr)
4b1ad039
DDM
1347 pcrpkey = subprocess.check_output(cmd)
1348 else:
1349 pcrpkey = Path(opts.pcr_public_keys[0])
2ac8fcf6
LB
1350 elif opts.pcr_certificates and len(opts.pcr_certificates) == 1:
1351 cmd += ['--certificate', opts.pcr_certificates[0]]
1352 if opts.certificate_provider:
1353 cmd += ['--certificate-source', f'provider:{opts.certificate_provider}']
1354
1355 print('+', shell_join(cmd), file=sys.stderr)
1356 pcrpkey = subprocess.check_output(cmd)
d7d36252 1357 elif opts.pcr_private_keys and len(opts.pcr_private_keys) == 1:
64cc7ba5 1358 cmd += ['--private-key', Path(opts.pcr_private_keys[0])]
6a28cae9 1359
64cc7ba5
DDM
1360 if opts.signing_engine:
1361 cmd += ['--private-key-source', f'engine:{opts.signing_engine}']
1362 if opts.signing_provider:
1363 cmd += ['--private-key-source', f'provider:{opts.signing_provider}']
1364
7d64e2f3 1365 print('+', shell_join(cmd), file=sys.stderr)
64cc7ba5 1366 pcrpkey = subprocess.check_output(cmd)
f4780cbe 1367
0333b9d5 1368 hwids = None
1369
1370 if opts.hwids is not None:
1371 hwids = parse_hwid_dir(opts.hwids)
1372
f4780cbe
ZJS
1373 sections = [
1374 # name, content, measure?
6a28cae9
JB
1375 ('.osrel', opts.os_release, True),
1376 ('.cmdline', opts.cmdline, True),
1377 ('.dtb', opts.devicetree, True),
fa258f77 1378 *(('.dtbauto', dtb, True) for dtb in opts.devicetree_auto),
0333b9d5 1379 ('.hwids', hwids, True),
6a28cae9
JB
1380 ('.uname', opts.uname, True),
1381 ('.splash', opts.splash, True),
1382 ('.pcrpkey', pcrpkey, True),
38801c91 1383 ('.linux', linux, True),
6a28cae9 1384 ('.initrd', initrd, True),
83bf58f3 1385 *(('.efifw', parse_efifw_dir(fw), False) for fw in opts.efifw),
6a28cae9
JB
1386 ('.ucode', opts.microcode, True),
1387 ] # fmt: skip
f4780cbe 1388
16020c33
DDM
1389 # If we're building a PE profile binary, the ".profile" section has to be the first one.
1390 if opts.profile and not opts.join_profiles:
6a28cae9 1391 uki.add_section(Section.create('.profile', opts.profile, measure=True))
16020c33 1392
f4780cbe
ZJS
1393 for name, content, measure in sections:
1394 if content:
1395 uki.add_section(Section.create(name, content, measure=measure))
1396
1397 # systemd-measure doesn't know about those extra sections
1398 for section in opts.sections:
1399 uki.add_section(section)
1400
16020c33 1401 # Don't add a sbat section to profile PE binaries.
9876e88e 1402 if (opts.join_profiles or not opts.profile) and not opts.pcrsig:
16020c33 1403 if linux is not None:
2572afa4
JB
1404 # Merge the .sbat sections from stub, kernel and parameter, so that revocation can be done on
1405 # either.
16020c33
DDM
1406 input_pes = [opts.stub, linux]
1407 if not opts.sbat:
180f957a 1408 opts.sbat = [STUB_SBAT]
16020c33
DDM
1409 else:
1410 # Addons don't use the stub so we add SBAT manually
1411 input_pes = []
1412 if not opts.sbat:
180f957a 1413 opts.sbat = [ADDON_SBAT]
16020c33
DDM
1414 uki.add_section(Section.create('.sbat', merge_sbat(input_pes, opts.sbat), measure=linux is not None))
1415
1416 # If we're building a UKI with additional profiles, the .profile section for the base profile has to be
1417 # the last one so that everything before it is shared between profiles. The only thing we don't share
1418 # between profiles is the .pcrsig section which is appended later and doesn't make sense to share.
1419 if opts.profile and opts.join_profiles:
6a28cae9 1420 uki.add_section(Section.create('.profile', opts.profile, measure=True))
f4780cbe 1421
d713104a
DDM
1422 # PCR measurement and signing
1423
9876e88e
LB
1424 if (
1425 not opts.pcrsig
1426 and (opts.join_profiles or not opts.profile)
7db71cd7
DDM
1427 and (
1428 not opts.sign_profiles
1429 or (opts.profile and read_env_file(opts.profile).get('ID') in opts.sign_profiles)
1430 )
b61efe65 1431 ):
9876e88e 1432 combined_sigs = call_systemd_measure(uki, opts=opts)
d713104a 1433
16020c33
DDM
1434 # UKI profiles
1435
6a28cae9
JB
1436 to_import = {
1437 '.linux',
1438 '.osrel',
1439 '.cmdline',
1440 '.initrd',
83bf58f3 1441 '.efifw',
6a28cae9
JB
1442 '.ucode',
1443 '.splash',
1444 '.dtb',
1445 '.uname',
1446 '.sbat',
1447 '.profile',
1448 }
16020c33
DDM
1449
1450 for profile in opts.join_profiles:
1451 pe = pefile.PE(profile, fast_load=True)
1452 prev_len = len(uki.sections)
1453
1454 names = [pe_strip_section_name(s.Name) for s in pe.sections]
1455 names = [n for n in names if n in to_import]
1456
1457 if len(names) == 0:
6a28cae9 1458 raise ValueError(f'Found no valid sections in PE profile binary {profile}')
16020c33 1459
6a28cae9
JB
1460 if names[0] != '.profile':
1461 raise ValueError(
2572afa4 1462 f'Expected .profile section as first valid section in PE profile binary {profile} but got {names[0]}' # noqa: E501
6a28cae9 1463 )
16020c33 1464
6a28cae9 1465 if names.count('.profile') > 1:
16020c33
DDM
1466 raise ValueError(f'Profile PE binary {profile} contains multiple .profile sections')
1467
f1b6430e
JB
1468 for pesection in pe.sections:
1469 n = pe_strip_section_name(pesection.Name)
16020c33
DDM
1470
1471 if n not in to_import:
1472 continue
1473
7d64e2f3 1474 print(
33b25fa1 1475 f"Copying section '{n}' from '{profile}': {pe_section_size(pesection)} bytes",
7d64e2f3
LB
1476 file=sys.stderr,
1477 )
6a28cae9 1478 uki.add_section(
33b25fa1 1479 Section.create(n, pesection.get_data(length=pe_section_size(pesection)), measure=True)
6a28cae9 1480 )
16020c33 1481
b61efe65
DDM
1482 if opts.sign_profiles:
1483 pesection = next(s for s in pe.sections if pe_strip_section_name(s.Name) == '.profile')
33b25fa1 1484 id = read_env_file(pesection.get_data(length=pe_section_size(pesection)).decode()).get('ID')
b61efe65 1485 if not id or id not in opts.sign_profiles:
cf4deeaf 1486 print(f'Not signing expected PCR measurements for "{id}" profile', file=sys.stderr)
b61efe65
DDM
1487 continue
1488
9876e88e
LB
1489 s = call_systemd_measure(uki, opts=opts, profile_start=prev_len)
1490 if s:
1491 combined_sigs = combine_signatures([json.loads(combined_sigs), json.loads(s)])
16020c33 1492
d713104a
DDM
1493 # UKI creation
1494
c1e8d172 1495 if sign_args_present:
f4780cbe 1496 unsigned = tempfile.NamedTemporaryFile(prefix='uki')
b708789d 1497 unsigned_output = unsigned.name
f4780cbe 1498 else:
b708789d 1499 unsigned_output = opts.output
f4780cbe 1500
9876e88e 1501 pe_add_sections(opts, uki, unsigned_output)
3fc1ae89 1502
f4780cbe
ZJS
1503 # UKI signing
1504
c1e8d172 1505 if sign_args_present:
02eabaff 1506 assert opts.signtool is not None
5e7e4e4d
ZJS
1507 signtool = SignTool.from_string(opts.signtool)
1508
1509 signtool.sign(os.fspath(unsigned_output), os.fspath(opts.output), opts)
f4780cbe
ZJS
1510
1511 # We end up with no executable bits, let's reapply them
1512 os.umask(umask := os.umask(0))
1513 os.chmod(opts.output, 0o777 & ~umask)
1514
7d64e2f3 1515 print(f'Wrote {"signed" if sign_args_present else "unsigned"} {opts.output}', file=sys.stderr)
9876e88e
LB
1516 if opts.policy_digest:
1517 print(combined_sigs)
f4780cbe
ZJS
1518
1519
a1c80efd 1520@contextlib.contextmanager
e9519350 1521def temporary_umask(mask: int) -> Iterator[None]:
a1c80efd
ZJS
1522 # Drop <mask> bits from umask
1523 old = os.umask(0)
1524 os.umask(old | mask)
1525 try:
1526 yield
1527 finally:
1528 os.umask(old)
1529
1530
1531def generate_key_cert_pair(
6a28cae9
JB
1532 common_name: str,
1533 valid_days: int,
1534 keylength: int = 2048,
e9519350 1535) -> tuple[bytes, bytes]:
a1c80efd 1536 from cryptography import x509
8abfd07e 1537 from cryptography.hazmat.primitives import hashes, serialization
750674da 1538 from cryptography.hazmat.primitives.asymmetric import rsa
a1c80efd
ZJS
1539
1540 # We use a keylength of 2048 bits. That is what Microsoft documents as
1541 # supported/expected:
1542 # https://learn.microsoft.com/en-us/windows-hardware/manufacture/desktop/windows-secure-boot-key-creation-and-management-guidance?view=windows-11#12-public-key-cryptography
1543
a1659184 1544 now = datetime.datetime.now(datetime.timezone.utc)
a1c80efd 1545
750674da 1546 key = rsa.generate_private_key(
a1c80efd
ZJS
1547 public_exponent=65537,
1548 key_size=keylength,
1549 )
6a28cae9
JB
1550 cert = (
1551 x509.CertificateBuilder()
1552 .subject_name(
1553 x509.Name([x509.NameAttribute(x509.oid.NameOID.COMMON_NAME, common_name)]),
1554 )
1555 .issuer_name(
1556 x509.Name([x509.NameAttribute(x509.oid.NameOID.COMMON_NAME, common_name)]),
1557 )
1558 .not_valid_before(
1559 now,
1560 )
1561 .not_valid_after(
1562 now + datetime.timedelta(days=valid_days),
1563 )
1564 .serial_number(
1565 x509.random_serial_number(),
1566 )
1567 .public_key(
1568 key.public_key(),
1569 )
1570 .add_extension(
1571 x509.BasicConstraints(ca=False, path_length=None),
1572 critical=True,
1573 )
1574 .sign(
1575 private_key=key,
1576 algorithm=hashes.SHA256(),
1577 )
a1c80efd
ZJS
1578 )
1579
1580 cert_pem = cert.public_bytes(
750674da 1581 encoding=serialization.Encoding.PEM,
a1c80efd
ZJS
1582 )
1583 key_pem = key.private_bytes(
750674da
VL
1584 encoding=serialization.Encoding.PEM,
1585 format=serialization.PrivateFormat.TraditionalOpenSSL,
1586 encryption_algorithm=serialization.NoEncryption(),
a1c80efd
ZJS
1587 )
1588
1589 return key_pem, cert_pem
1590
1591
e9519350 1592def generate_priv_pub_key_pair(keylength: int = 2048) -> tuple[bytes, bytes]:
750674da
VL
1593 from cryptography.hazmat.primitives import serialization
1594 from cryptography.hazmat.primitives.asymmetric import rsa
a1c80efd 1595
750674da 1596 key = rsa.generate_private_key(
a1c80efd
ZJS
1597 public_exponent=65537,
1598 key_size=keylength,
1599 )
1600 priv_key_pem = key.private_bytes(
750674da
VL
1601 encoding=serialization.Encoding.PEM,
1602 format=serialization.PrivateFormat.TraditionalOpenSSL,
1603 encryption_algorithm=serialization.NoEncryption(),
a1c80efd
ZJS
1604 )
1605 pub_key_pem = key.public_key().public_bytes(
750674da
VL
1606 encoding=serialization.Encoding.PEM,
1607 format=serialization.PublicFormat.SubjectPublicKeyInfo,
a1c80efd
ZJS
1608 )
1609
1610 return priv_key_pem, pub_key_pem
1611
1612
f1b6430e 1613def generate_keys(opts: UkifyConfig) -> None:
f3d50fb2
ZJS
1614 work = False
1615
a1c80efd
ZJS
1616 # This will generate keys and certificates and write them to the paths that
1617 # are specified as input paths.
9ba53499 1618 if opts.sb_key and opts.sb_cert:
a1c80efd 1619 fqdn = socket.getfqdn()
544df97b 1620
a1c80efd 1621 cn = f'SecureBoot signing key on host {fqdn}'
544df97b
ZJS
1622 if len(cn) > 64:
1623 # The length of CN must not exceed 64 bytes
1624 cn = cn[:61] + '...'
1625
814e4d7a
ZJS
1626 key_pem, cert_pem = generate_key_cert_pair(
1627 common_name=cn,
1628 valid_days=opts.sb_cert_validity,
1629 )
cf4deeaf 1630 print(f'Writing SecureBoot private key to {opts.sb_key}', file=sys.stderr)
a1c80efd 1631 with temporary_umask(0o077):
64cc7ba5 1632 Path(opts.sb_key).write_bytes(key_pem)
cf4deeaf 1633 print(f'Writing SecureBoot certificate to {opts.sb_cert}', file=sys.stderr)
64cc7ba5 1634 Path(opts.sb_cert).write_bytes(cert_pem)
a1c80efd 1635
f3d50fb2
ZJS
1636 work = True
1637
2ac8fcf6 1638 for priv_key, pub_key, _, _ in key_path_groups(opts):
a1c80efd
ZJS
1639 priv_key_pem, pub_key_pem = generate_priv_pub_key_pair()
1640
cf4deeaf 1641 print(f'Writing private key for PCR signing to {priv_key}', file=sys.stderr)
a1c80efd 1642 with temporary_umask(0o077):
35d92c03 1643 Path(priv_key).write_bytes(priv_key_pem)
a1c80efd 1644 if pub_key:
cf4deeaf 1645 print(f'Writing public key for PCR signing to {pub_key}', file=sys.stderr)
64cc7ba5 1646 Path(pub_key).write_bytes(pub_key_pem)
a1c80efd 1647
f3d50fb2
ZJS
1648 work = True
1649
1650 if not work:
6a28cae9 1651 raise ValueError(
2572afa4 1652 'genkey: --secureboot-private-key=/--secureboot-certificate= or --pcr-private-key/--pcr-public-key must be specified' # noqa: E501
6a28cae9 1653 )
f3d50fb2 1654
a1c80efd 1655
e9519350 1656def inspect_section(
f1b6430e 1657 opts: UkifyConfig,
e9519350
JB
1658 section: pefile.SectionStructure,
1659) -> tuple[str, Optional[dict[str, Union[int, str]]]]:
db0f9720 1660 name = pe_strip_section_name(section.Name)
df4a4673
EGE
1661
1662 # find the config for this section in opts and whether to show it
1663 config = opts.sections_by_name.get(name, None)
6a28cae9 1664 show = config or opts.all or (name in DEFAULT_SECTIONS_TO_SHOW and not opts.sections)
df4a4673
EGE
1665 if not show:
1666 return name, None
1667
1668 ttype = config.output_mode if config else DEFAULT_SECTIONS_TO_SHOW.get(name, 'binary')
1669
33b25fa1 1670 size = pe_section_size(section)
c1e70462 1671 data = section.get_data(length=size)
df4a4673
EGE
1672 digest = sha256(data).hexdigest()
1673
2d6ae431 1674 struct: dict[str, Union[int, str]] = {
6a28cae9
JB
1675 'size': size,
1676 'sha256': digest,
df4a4673
EGE
1677 }
1678
1679 if ttype == 'text':
1680 try:
1681 struct['text'] = data.decode()
1682 except UnicodeDecodeError as e:
cf4deeaf 1683 print(f'Section {name!r} is not valid text: {e}', file=sys.stderr)
df4a4673
EGE
1684 struct['text'] = '(not valid UTF-8)'
1685
1686 if config and config.content:
35d92c03 1687 assert isinstance(config.content, Path)
df4a4673
EGE
1688 config.content.write_bytes(data)
1689
1690 if opts.json == 'off':
6a28cae9 1691 print(f'{name}:\n size: {size} bytes\n sha256: {digest}')
df4a4673 1692 if ttype == 'text':
2d6ae431 1693 text = textwrap.indent(cast(str, struct['text']).rstrip(), ' ' * 4)
6a28cae9 1694 print(f' text:\n{text}')
df4a4673
EGE
1695
1696 return name, struct
1697
1698
f1b6430e 1699def inspect_sections(opts: UkifyConfig) -> None:
df4a4673
EGE
1700 indent = 4 if opts.json == 'pretty' else None
1701
1702 for file in opts.files:
1703 pe = pefile.PE(file, fast_load=True)
1704 gen = (inspect_section(opts, section) for section in pe.sections)
6a28cae9 1705 descs = {key: val for (key, val) in gen if val}
df4a4673
EGE
1706 if opts.json != 'off':
1707 json.dump(descs, sys.stdout, indent=indent)
1708
1709
5143a47a
ZJS
1710@dataclasses.dataclass(frozen=True)
1711class ConfigItem:
1712 @staticmethod
a758f95c 1713 def config_list_prepend(
6a28cae9
JB
1714 namespace: argparse.Namespace,
1715 group: Optional[str],
1716 dest: str,
1717 value: Any,
a758f95c 1718 ) -> None:
5143a47a
ZJS
1719 "Prepend value to namespace.<dest>"
1720
1721 assert not group
1722
1723 old = getattr(namespace, dest, [])
0be1de7f
FS
1724 if old is None:
1725 old = []
5143a47a
ZJS
1726 setattr(namespace, dest, value + old)
1727
1728 @staticmethod
a758f95c 1729 def config_set_if_unset(
6a28cae9
JB
1730 namespace: argparse.Namespace,
1731 group: Optional[str],
1732 dest: str,
1733 value: Any,
a758f95c 1734 ) -> None:
5143a47a
ZJS
1735 "Set namespace.<dest> to value only if it was None"
1736
1737 assert not group
1738
1739 if getattr(namespace, dest) is None:
1740 setattr(namespace, dest, value)
1741
12775754
EGE
1742 @staticmethod
1743 def config_set(
6a28cae9
JB
1744 namespace: argparse.Namespace,
1745 group: Optional[str],
1746 dest: str,
1747 value: Any,
12775754
EGE
1748 ) -> None:
1749 "Set namespace.<dest> to value only if it was None"
1750
1751 assert not group
1752
1753 setattr(namespace, dest, value)
1754
5143a47a 1755 @staticmethod
a758f95c 1756 def config_set_group(
6a28cae9
JB
1757 namespace: argparse.Namespace,
1758 group: Optional[str],
1759 dest: str,
1760 value: Any,
a758f95c 1761 ) -> None:
5143a47a
ZJS
1762 "Set namespace.<dest>[idx] to value, with idx derived from group"
1763
c4fc2546 1764 # pylint: disable=protected-access
5143a47a
ZJS
1765 if group not in namespace._groups:
1766 namespace._groups += [group]
1767 idx = namespace._groups.index(group)
1768
1769 old = getattr(namespace, dest, None)
1770 if old is None:
1771 old = []
6a28cae9
JB
1772 setattr(
1773 namespace,
1774 dest,
1775 old + ([None] * (idx - len(old))) + [value],
1776 )
5143a47a
ZJS
1777
1778 @staticmethod
1779 def parse_boolean(s: str) -> bool:
1780 "Parse 1/true/yes/y/t/on as true and 0/false/no/n/f/off/None as false"
1781 s_l = s.lower()
1782 if s_l in {'1', 'true', 'yes', 'y', 't', 'on'}:
1783 return True
1784 if s_l in {'0', 'false', 'no', 'n', 'f', 'off'}:
1785 return False
1786 raise ValueError('f"Invalid boolean literal: {s!r}')
1787
1788 # arguments for argparse.ArgumentParser.add_argument()
a758f95c
ZJS
1789 name: Union[str, tuple[str, str]]
1790 dest: Optional[str] = None
1791 metavar: Optional[str] = None
467d21a7 1792 type: Optional[Callable[[str], Any]] = None
a758f95c 1793 nargs: Optional[str] = None
467d21a7 1794 action: Optional[Union[str, Callable[[str], Any], builtins.type[argparse.Action]]] = None
a758f95c
ZJS
1795 default: Any = None
1796 version: Optional[str] = None
1797 choices: Optional[tuple[str, ...]] = None
df4a4673 1798 const: Optional[Any] = None
a758f95c 1799 help: Optional[str] = None
5143a47a
ZJS
1800
1801 # metadata for config file parsing
a758f95c 1802 config_key: Optional[str] = None
6a28cae9 1803 config_push: Callable[[argparse.Namespace, Optional[str], str, Any], None] = config_set_if_unset
5143a47a 1804
a758f95c 1805 def _names(self) -> tuple[str, ...]:
5143a47a
ZJS
1806 return self.name if isinstance(self.name, tuple) else (self.name,)
1807
1808 def argparse_dest(self) -> str:
1809 # It'd be nice if argparse exported this, but I don't see that in the API
1810 if self.dest:
1811 return self.dest
1812 return self._names()[0].lstrip('-').replace('-', '_')
1813
e9519350 1814 def add_to(self, parser: argparse.ArgumentParser) -> None:
6a28cae9
JB
1815 kwargs = {
1816 key: val
1817 for key in dataclasses.asdict(self)
1818 if (key not in ('name', 'config_key', 'config_push') and (val := getattr(self, key)) is not None)
1819 }
5143a47a
ZJS
1820 args = self._names()
1821 parser.add_argument(*args, **kwargs)
1822
e9519350
JB
1823 def apply_config(
1824 self,
1825 namespace: argparse.Namespace,
1826 section: str,
1827 group: Optional[str],
1828 key: str,
1829 value: Any,
1830 ) -> None:
5143a47a
ZJS
1831 assert f'{section}/{key}' == self.config_key
1832 dest = self.argparse_dest()
1833
a758f95c 1834 conv: Callable[[str], Any]
5143a47a
ZJS
1835 if self.action == argparse.BooleanOptionalAction:
1836 # We need to handle this case separately: the options are called
1837 # --foo and --no-foo, and no argument is parsed. But in the config
1838 # file, we have Foo=yes or Foo=no.
1839 conv = self.parse_boolean
1840 elif self.type:
1841 conv = self.type
1842 else:
2572afa4 1843 conv = lambda s: s # noqa: E731
5143a47a 1844
fa258f77 1845 # This is a bit ugly, but --initrd and --devicetree-auto are the only options
a3b227d2
ZJS
1846 # with multiple args on the command line and a space-separated list in the
1847 # config file.
fa258f77 1848 if self.name in ['--initrd', '--devicetree-auto']:
5143a47a
ZJS
1849 value = [conv(v) for v in value.split()]
1850 else:
1851 value = conv(value)
1852
1853 self.config_push(namespace, group, dest, value)
1854
a758f95c 1855 def config_example(self) -> tuple[Optional[str], Optional[str], Optional[str]]:
5143a47a
ZJS
1856 if not self.config_key:
1857 return None, None, None
1858 section_name, key = self.config_key.split('/', 1)
1859 if section_name.endswith(':'):
1860 section_name += 'NAME'
1861 if self.choices:
1862 value = '|'.join(self.choices)
1863 else:
1864 value = self.metavar or self.argparse_dest().upper()
1865 return (section_name, key, value)
1866
1867
df4a4673 1868VERBS = ('build', 'genkey', 'inspect')
a3b227d2 1869
5143a47a 1870CONFIG_ITEMS = [
a3b227d2
ZJS
1871 ConfigItem(
1872 'positional',
6a28cae9
JB
1873 metavar='VERB',
1874 nargs='*',
1875 help=argparse.SUPPRESS,
a3b227d2 1876 ),
5143a47a
ZJS
1877 ConfigItem(
1878 '--version',
6a28cae9
JB
1879 action='version',
1880 version=f'ukify {__version__}',
5143a47a 1881 ),
5143a47a
ZJS
1882 ConfigItem(
1883 '--summary',
6a28cae9
JB
1884 help='print parsed config and exit',
1885 action='store_true',
5143a47a 1886 ),
89ed3445
LP
1887 ConfigItem(
1888 ('--config', '-c'),
6a28cae9 1889 metavar='PATH',
35d92c03 1890 type=Path,
6a28cae9 1891 help='configuration file',
89ed3445 1892 ),
5143a47a 1893 ConfigItem(
a3b227d2 1894 '--linux',
35d92c03 1895 type=Path,
6a28cae9
JB
1896 help='vmlinuz file [.linux section]',
1897 config_key='UKI/Linux',
5143a47a 1898 ),
89ed3445
LP
1899 ConfigItem(
1900 '--os-release',
6a28cae9
JB
1901 metavar='TEXT|@PATH',
1902 help='path to os-release file [.osrel section]',
1903 config_key='UKI/OSRelease',
89ed3445 1904 ),
89ed3445
LP
1905 ConfigItem(
1906 '--cmdline',
6a28cae9
JB
1907 metavar='TEXT|@PATH',
1908 help='kernel command line [.cmdline section]',
1909 config_key='UKI/Cmdline',
89ed3445 1910 ),
5143a47a 1911 ConfigItem(
a3b227d2 1912 '--initrd',
6a28cae9 1913 metavar='INITRD',
35d92c03 1914 type=Path,
6a28cae9
JB
1915 action='append',
1916 help='initrd file [part of .initrd section]',
1917 config_key='UKI/Initrd',
1918 config_push=ConfigItem.config_list_prepend,
5143a47a 1919 ),
83bf58f3
AS
1920 ConfigItem(
1921 '--efifw',
1922 metavar='DIR',
1923 type=Path,
1924 action='append',
1925 default=[],
1926 help='Directory with efi firmware binary file [.efifw section]',
1927 config_key='UKI/Firmware',
1928 config_push=ConfigItem.config_list_prepend,
1929 ),
d380337d
TF
1930 ConfigItem(
1931 '--microcode',
6a28cae9 1932 metavar='UCODE',
35d92c03 1933 type=Path,
6a28cae9
JB
1934 help='microcode file [.ucode section]',
1935 config_key='UKI/Microcode',
d380337d 1936 ),
5143a47a 1937 ConfigItem(
89ed3445 1938 '--splash',
6a28cae9 1939 metavar='BMP',
35d92c03 1940 type=Path,
6a28cae9
JB
1941 help='splash image bitmap file [.splash section]',
1942 config_key='UKI/Splash',
5143a47a 1943 ),
5143a47a
ZJS
1944 ConfigItem(
1945 '--devicetree',
6a28cae9 1946 metavar='PATH',
35d92c03 1947 type=Path,
6a28cae9
JB
1948 help='Device Tree file [.dtb section]',
1949 config_key='UKI/DeviceTree',
5143a47a 1950 ),
fa258f77 1951 ConfigItem(
1952 '--devicetree-auto',
1953 metavar='PATH',
1954 type=Path,
1955 action='append',
1956 help='DeviceTree file for automatic selection [.dtbauto section]',
1957 default=[],
1958 config_key='UKI/DeviceTreeAuto',
1959 config_push=ConfigItem.config_list_prepend,
1960 ),
0333b9d5 1961 ConfigItem(
1962 '--hwids',
1963 metavar='DIR',
1964 type=Path,
1965 help='Directory with HWID text files [.hwids section]',
1966 config_key='UKI/HWIDs',
1967 ),
5143a47a 1968 ConfigItem(
89ed3445
LP
1969 '--uname',
1970 metavar='VERSION',
1971 help='"uname -r" information [.uname section]',
6a28cae9 1972 config_key='UKI/Uname',
5143a47a 1973 ),
89ed3445
LP
1974 ConfigItem(
1975 '--sbat',
6a28cae9
JB
1976 metavar='TEXT|@PATH',
1977 help='SBAT policy [.sbat section]',
1978 default=[],
1979 action='append',
1980 config_key='UKI/SBAT',
89ed3445 1981 ),
5143a47a
ZJS
1982 ConfigItem(
1983 '--pcrpkey',
6a28cae9 1984 metavar='KEY',
35d92c03 1985 type=Path,
6a28cae9
JB
1986 help='embedded public key to seal secrets to [.pcrpkey section]',
1987 config_key='UKI/PCRPKey',
5143a47a
ZJS
1988 ),
1989 ConfigItem(
89ed3445 1990 '--section',
6a28cae9
JB
1991 dest='sections',
1992 metavar='NAME:TEXT|@PATH',
1993 action='append',
1994 default=[],
1995 help='section as name and contents [NAME section] or section to print',
5143a47a 1996 ),
5143a47a 1997 ConfigItem(
22b8236f 1998 '--profile',
6a28cae9
JB
1999 metavar='TEST|@PATH',
2000 help='Profile information [.profile section]',
2001 config_key='UKI/Profile',
22b8236f 2002 ),
16020c33
DDM
2003 ConfigItem(
2004 '--join-profile',
6a28cae9
JB
2005 dest='join_profiles',
2006 metavar='PATH',
2007 action='append',
2008 default=[],
2009 help='A PE binary containing an additional profile to add to the UKI',
16020c33 2010 ),
b61efe65
DDM
2011 ConfigItem(
2012 '--sign-profile',
2013 dest='sign_profiles',
2014 metavar='ID',
2015 action='append',
2016 default=[],
2017 help='Which profiles to sign expected PCR measurements for',
2018 ),
9876e88e
LB
2019 ConfigItem(
2020 '--pcrsig',
2021 metavar='TEST|@PATH',
2022 help='Signed PCR policy JSON [.pcrsig section] to append to an existing UKI',
2023 config_key='UKI/PCRSig',
2024 ),
2025 ConfigItem(
2026 '--join-pcrsig',
2027 metavar='PATH',
2028 help='A PE binary containing a UKI without a .pcrsig to join with --pcrsig',
2029 ),
22b8236f 2030 ConfigItem(
5143a47a 2031 '--efi-arch',
6a28cae9 2032 metavar='ARCH',
d1429288 2033 choices=('ia32', 'x64', 'arm', 'aa64', 'riscv32', 'riscv64', 'loongarch32', 'loongarch64'),
6a28cae9
JB
2034 help='target EFI architecture',
2035 config_key='UKI/EFIArch',
5143a47a 2036 ),
5143a47a
ZJS
2037 ConfigItem(
2038 '--stub',
35d92c03 2039 type=Path,
6a28cae9
JB
2040 help='path to the sd-stub file [.text,.data,… sections]',
2041 config_key='UKI/Stub',
5143a47a 2042 ),
5143a47a
ZJS
2043 ConfigItem(
2044 '--pcr-banks',
6a28cae9
JB
2045 metavar='BANK…',
2046 type=parse_banks,
2047 config_key='UKI/PCRBanks',
5143a47a 2048 ),
5143a47a
ZJS
2049 ConfigItem(
2050 '--signing-engine',
6a28cae9
JB
2051 metavar='ENGINE',
2052 help='OpenSSL engine to use for signing',
2053 config_key='UKI/SigningEngine',
5143a47a 2054 ),
65fbf3b1
DDM
2055 ConfigItem(
2056 '--signing-provider',
2057 metavar='PROVIDER',
2058 help='OpenSSL provider to use for signing',
2059 config_key='UKI/SigningProvider',
2060 ),
64cc7ba5
DDM
2061 ConfigItem(
2062 '--certificate-provider',
2063 metavar='PROVIDER',
2064 help='OpenSSL provider to load certificate from',
2065 config_key='UKI/CertificateProvider',
2066 ),
c1e8d172
EGE
2067 ConfigItem(
2068 '--signtool',
d835c447 2069 choices=('sbsign', 'pesign', 'systemd-sbsign'),
6a28cae9
JB
2070 dest='signtool',
2071 help=(
2072 'whether to use sbsign or pesign. It will also be inferred by the other '
2073 'parameters given: when using --secureboot-{private-key/certificate}, sbsign '
2074 'will be used, otherwise pesign will be used'
2075 ),
2076 config_key='UKI/SecureBootSigningTool',
c1e8d172 2077 ),
5143a47a
ZJS
2078 ConfigItem(
2079 '--secureboot-private-key',
6a28cae9 2080 dest='sb_key',
65fbf3b1 2081 help='required by --signtool=sbsign|systemd-sbsign. Path to key file or engine/provider designation for SB signing', # noqa: E501
6a28cae9 2082 config_key='UKI/SecureBootPrivateKey',
5143a47a
ZJS
2083 ),
2084 ConfigItem(
2085 '--secureboot-certificate',
6a28cae9 2086 dest='sb_cert',
2572afa4
JB
2087 help=(
2088 'required by --signtool=sbsign. sbsign needs a path to certificate file or engine-specific designation for SB signing' # noqa: E501
2089 ),
6a28cae9 2090 config_key='UKI/SecureBootCertificate',
5143a47a 2091 ),
c1e8d172
EGE
2092 ConfigItem(
2093 '--secureboot-certificate-dir',
6a28cae9
JB
2094 dest='sb_certdir',
2095 default='/etc/pki/pesign',
2572afa4
JB
2096 help=(
2097 'required by --signtool=pesign. Path to nss certificate database directory for PE signing. Default is /etc/pki/pesign' # noqa: E501
2098 ),
6a28cae9
JB
2099 config_key='UKI/SecureBootCertificateDir',
2100 config_push=ConfigItem.config_set,
c1e8d172
EGE
2101 ),
2102 ConfigItem(
2103 '--secureboot-certificate-name',
6a28cae9 2104 dest='sb_cert_name',
2572afa4
JB
2105 help=(
2106 'required by --signtool=pesign. pesign needs a certificate nickname of nss certificate database entry to use for PE signing' # noqa: E501
2107 ),
6a28cae9 2108 config_key='UKI/SecureBootCertificateName',
c1e8d172 2109 ),
814e4d7a
ZJS
2110 ConfigItem(
2111 '--secureboot-certificate-validity',
6a28cae9
JB
2112 metavar='DAYS',
2113 type=int,
2114 dest='sb_cert_validity',
2115 default=365 * 10,
2116 help="period of validity (in days) for a certificate created by 'genkey'",
2117 config_key='UKI/SecureBootCertificateValidity',
2118 config_push=ConfigItem.config_set,
814e4d7a 2119 ),
5143a47a
ZJS
2120 ConfigItem(
2121 '--sign-kernel',
6a28cae9
JB
2122 action=argparse.BooleanOptionalAction,
2123 help='Sign the embedded kernel',
2124 config_key='UKI/SignKernel',
5143a47a 2125 ),
5143a47a
ZJS
2126 ConfigItem(
2127 '--pcr-private-key',
6a28cae9
JB
2128 dest='pcr_private_keys',
2129 action='append',
65fbf3b1 2130 help='private part of the keypair or engine/provider designation for signing PCR signatures',
6a28cae9
JB
2131 config_key='PCRSignature:/PCRPrivateKey',
2132 config_push=ConfigItem.config_set_group,
5143a47a
ZJS
2133 ),
2134 ConfigItem(
2135 '--pcr-public-key',
6a28cae9
JB
2136 dest='pcr_public_keys',
2137 metavar='PATH',
6a28cae9 2138 action='append',
65fbf3b1 2139 help='public part of the keypair or engine/provider designation for signing PCR signatures',
6a28cae9
JB
2140 config_key='PCRSignature:/PCRPublicKey',
2141 config_push=ConfigItem.config_set_group,
5143a47a 2142 ),
2ac8fcf6
LB
2143 ConfigItem(
2144 '--pcr-certificate',
2145 dest='pcr_certificates',
2146 metavar='PATH',
2147 action='append',
2148 help='certificate part of the keypair or engine/provider designation for signing PCR signatures',
2149 config_key='PCRSignature:/PCRCertificate',
2150 config_push=ConfigItem.config_set_group,
2151 ),
5143a47a
ZJS
2152 ConfigItem(
2153 '--phases',
6a28cae9
JB
2154 dest='phase_path_groups',
2155 metavar='PHASE-PATH…',
2156 type=parse_phase_paths,
2157 action='append',
2158 help='phase-paths to create signatures for',
2159 config_key='PCRSignature:/Phases',
2160 config_push=ConfigItem.config_set_group,
5143a47a 2161 ),
5143a47a
ZJS
2162 ConfigItem(
2163 '--tools',
35d92c03 2164 type=Path,
6a28cae9
JB
2165 action='append',
2166 help='Directories to search for tools (systemd-measure, …)',
5143a47a 2167 ),
5143a47a
ZJS
2168 ConfigItem(
2169 ('--output', '-o'),
35d92c03 2170 type=Path,
6a28cae9 2171 help='output file path',
5143a47a 2172 ),
5143a47a
ZJS
2173 ConfigItem(
2174 '--measure',
6a28cae9
JB
2175 action=argparse.BooleanOptionalAction,
2176 help='print systemd-measure output for the UKI',
5143a47a 2177 ),
606c5e75
LB
2178 ConfigItem(
2179 '--policy-digest',
2180 action=argparse.BooleanOptionalAction,
2181 help='print systemd-measure policy digests for the UKI',
2182 ),
df4a4673
EGE
2183 ConfigItem(
2184 '--json',
6a28cae9
JB
2185 choices=('pretty', 'short', 'off'),
2186 default='off',
2187 help='generate JSON output',
df4a4673
EGE
2188 ),
2189 ConfigItem(
2190 '-j',
2191 dest='json',
2192 action='store_const',
2193 const='pretty',
2194 help='equivalent to --json=pretty',
2195 ),
df4a4673
EGE
2196 ConfigItem(
2197 '--all',
6a28cae9
JB
2198 help='print all sections',
2199 action='store_true',
df4a4673 2200 ),
5143a47a
ZJS
2201]
2202
6a28cae9 2203CONFIGFILE_ITEMS = {item.config_key: item for item in CONFIG_ITEMS if item.config_key}
5143a47a
ZJS
2204
2205
e9519350 2206def apply_config(namespace: argparse.Namespace, filename: Union[str, Path, None] = None) -> None:
5143a47a 2207 if filename is None:
a05fa30f
AA
2208 if namespace.config:
2209 # Config set by the user, use that.
2210 filename = namespace.config
7d64e2f3 2211 print(f'Using config file: {filename}', file=sys.stderr)
a05fa30f
AA
2212 else:
2213 # Try to look for a config file then use the first one found.
2214 for config_dir in DEFAULT_CONFIG_DIRS:
35d92c03 2215 filename = Path(config_dir) / DEFAULT_CONFIG_FILE
a05fa30f
AA
2216 if filename.is_file():
2217 # Found a config file, use it.
7d64e2f3 2218 print(f'Using found config file: {filename}', file=sys.stderr)
a05fa30f
AA
2219 break
2220 else:
2221 # No config file specified or found, nothing to do.
2222 return
5143a47a
ZJS
2223
2224 # Fill in ._groups based on --pcr-public-key=, --pcr-private-key=, and --phases=.
2225 assert '_groups' not in namespace
2226 n_pcr_priv = len(namespace.pcr_private_keys or ())
c4fc2546 2227 namespace._groups = list(range(n_pcr_priv)) # pylint: disable=protected-access
5143a47a
ZJS
2228
2229 cp = configparser.ConfigParser(
2230 comment_prefixes='#',
2231 inline_comment_prefixes='#',
2232 delimiters='=',
2233 empty_lines_in_values=False,
2234 interpolation=None,
6a28cae9
JB
2235 strict=False,
2236 )
5143a47a 2237 # Do not make keys lowercase
e9519350 2238 cp.optionxform = lambda option: option # type: ignore
5143a47a 2239
594e27b0
ZJS
2240 # The API is not great.
2241 read = cp.read(filename)
2242 if not read:
521bc9c6 2243 raise OSError(f'Failed to read {filename}')
5143a47a
ZJS
2244
2245 for section_name, section in cp.items():
2246 idx = section_name.find(':')
2247 if idx >= 0:
6a28cae9 2248 section_name, group = section_name[: idx + 1], section_name[idx + 1 :]
5143a47a
ZJS
2249 if not section_name or not group:
2250 raise ValueError('Section name components cannot be empty')
2251 if ':' in group:
2252 raise ValueError('Section name cannot contain more than one ":"')
2253 else:
2254 group = None
2255 for key, value in section.items():
2256 if item := CONFIGFILE_ITEMS.get(f'{section_name}/{key}'):
2257 item.apply_config(namespace, section_name, group, key, value)
2258 else:
cf4deeaf 2259 print(f'Unknown config setting [{section_name}] {key}=', file=sys.stderr)
5143a47a
ZJS
2260
2261
e9519350
JB
2262def config_example() -> Iterator[str]:
2263 prev_section: Optional[str] = None
5143a47a
ZJS
2264 for item in CONFIG_ITEMS:
2265 section, key, value = item.config_example()
2266 if section:
2267 if prev_section != section:
2268 if prev_section:
2269 yield ''
2270 yield f'[{section}]'
2271 prev_section = section
2272 yield f'{key} = {value}'
2273
2274
6fa79138
ZJS
2275class PagerHelpAction(argparse._HelpAction): # pylint: disable=protected-access
2276 def __call__(
2277 self,
2278 parser: argparse.ArgumentParser,
2279 namespace: argparse.Namespace,
2280 values: Union[str, Sequence[Any], None] = None,
6a28cae9 2281 option_string: Optional[str] = None,
6fa79138
ZJS
2282 ) -> None:
2283 page(parser.format_help(), True)
2284 parser.exit()
2285
2286
e9519350 2287def create_parser() -> argparse.ArgumentParser:
f4780cbe
ZJS
2288 p = argparse.ArgumentParser(
2289 description='Build and sign Unified Kernel Images',
6a28cae9
JB
2290 usage='\n '
2291 + textwrap.dedent("""\
51faf836
ZJS
2292 ukify {b}build{e} [--linux=LINUX] [--initrd=INITRD] [options…]
2293 ukify {b}genkey{e} [options…]
2294 ukify {b}inspect{e} FILE… [options…]
6a28cae9 2295 """).format(b=Style.bold, e=Style.reset),
f4780cbe 2296 allow_abbrev=False,
6fa79138 2297 add_help=False,
5143a47a
ZJS
2298 epilog='\n '.join(('config file:', *config_example())),
2299 formatter_class=argparse.RawDescriptionHelpFormatter,
2300 )
2301
2302 for item in CONFIG_ITEMS:
2303 item.add_to(p)
f4780cbe
ZJS
2304
2305 # Suppress printing of usage synopsis on errors
e9519350 2306 p.error = lambda message: p.exit(2, f'{p.prog}: error: {message}\n') # type: ignore
f4780cbe 2307
6fa79138
ZJS
2308 # Make --help paged
2309 p.add_argument(
2310 '-h', '--help',
2311 action=PagerHelpAction,
2312 help='show this help message and exit',
6a28cae9 2313 ) # fmt: skip
6fa79138 2314
5143a47a 2315 return p
f4780cbe 2316
095ff238 2317
6e274776 2318def resolve_at_path(value: Optional[str]) -> Union[Path, str, None]:
eca003de
DDM
2319 if value and value.startswith('@'):
2320 return Path(value[1:])
2321
2322 return value
2323
2324
e9519350 2325def finalize_options(opts: argparse.Namespace) -> None:
b09a5315
ZJS
2326 # Figure out which syntax is being used, one of:
2327 # ukify verb --arg --arg --arg
2328 # ukify linux initrd…
f0876c7a 2329 if len(opts.positional) >= 1 and opts.positional[0] == 'inspect':
df4a4673
EGE
2330 opts.verb = opts.positional[0]
2331 opts.files = opts.positional[1:]
2332 if not opts.files:
2333 raise ValueError('file(s) to inspect must be specified')
2334 if len(opts.files) > 1 and opts.json != 'off':
2335 # We could allow this in the future, but we need to figure out the right structure
2336 raise ValueError('JSON output is not allowed with multiple files')
2337 elif len(opts.positional) == 1 and opts.positional[0] in VERBS:
b09a5315
ZJS
2338 opts.verb = opts.positional[0]
2339 elif opts.linux or opts.initrd:
ea22cbc3 2340 raise ValueError('--linux=/--initrd= options cannot be used with positional arguments')
b09a5315 2341 else:
7d64e2f3 2342 print("Assuming obsolete command line syntax with no verb. Please use 'build'.", file=sys.stderr)
b09a5315 2343 if opts.positional:
35d92c03 2344 opts.linux = Path(opts.positional[0])
b09a5315 2345 # If we have initrds from parsing config files, append our positional args at the end
35d92c03 2346 opts.initrd = (opts.initrd or []) + [Path(arg) for arg in opts.positional[1:]]
b09a5315
ZJS
2347 opts.verb = 'build'
2348
2349 # Check that --pcr-public-key=, --pcr-private-key=, and --phases=
c309b9e9 2350 # have either the same number of arguments or are not specified at all.
2ac8fcf6 2351 # Also check that --pcr-public-key= and --pcr-certificate= are not set at the same time.
606c5e75
LB
2352 # But allow a single public key, for offline PCR signing, to pre-populate the JSON object
2353 # with the certificate's fingerprint.
2ac8fcf6 2354 n_pcr_cert = None if opts.pcr_certificates is None else len(opts.pcr_certificates)
b09a5315
ZJS
2355 n_pcr_pub = None if opts.pcr_public_keys is None else len(opts.pcr_public_keys)
2356 n_pcr_priv = None if opts.pcr_private_keys is None else len(opts.pcr_private_keys)
2357 n_phase_path_groups = None if opts.phase_path_groups is None else len(opts.phase_path_groups)
606c5e75
LB
2358 if opts.policy_digest and n_pcr_priv is not None:
2359 raise ValueError('--pcr-private-key= cannot be specified with --policy-digest')
2ac8fcf6
LB
2360 if (
2361 opts.policy_digest
2362 and (n_pcr_pub is None or n_pcr_pub != 1)
2363 and (n_pcr_cert is None or n_pcr_cert != 1)
2364 ):
2365 raise ValueError('--policy-digest requires exactly one --pcr-public-key= or --pcr-certificate=')
606c5e75 2366 if n_pcr_pub is not None and n_pcr_priv is not None and n_pcr_pub != n_pcr_priv:
b09a5315 2367 raise ValueError('--pcr-public-key= specifications must match --pcr-private-key=')
2ac8fcf6
LB
2368 if n_pcr_cert is not None and n_pcr_priv is not None and n_pcr_cert != n_pcr_priv:
2369 raise ValueError('--pcr-certificate= specifications must match --pcr-private-key=')
2370 if n_pcr_pub is not None and n_pcr_cert is not None:
2371 raise ValueError('--pcr-public-key= and --pcr-certificate= cannot be used at the same time')
b09a5315
ZJS
2372 if n_phase_path_groups is not None and n_phase_path_groups != n_pcr_priv:
2373 raise ValueError('--phases= specifications must match --pcr-private-key=')
2374
eca003de
DDM
2375 opts.cmdline = resolve_at_path(opts.cmdline)
2376
2377 if isinstance(opts.cmdline, str):
7c52d523
JB
2378 # Drop whitespace from the command line. If we're reading from a file,
2379 # we copy the contents verbatim. But configuration specified on the command line
5143a47a
ZJS
2380 # or in the config file may contain additional whitespace that has no meaning.
2381 opts.cmdline = ' '.join(opts.cmdline.split())
2382
eca003de
DDM
2383 opts.os_release = resolve_at_path(opts.os_release)
2384
2385 if not opts.os_release and opts.linux:
35d92c03 2386 p = Path('/etc/os-release')
f4780cbe 2387 if not p.exists():
35d92c03 2388 p = Path('/usr/lib/os-release')
f4780cbe
ZJS
2389 opts.os_release = p
2390
2391 if opts.efi_arch is None:
2392 opts.efi_arch = guess_efi_arch()
2393
cdedc90c 2394 if opts.stub is None and not opts.join_pcrsig:
3891d57c 2395 if opts.linux is not None:
35d92c03 2396 opts.stub = Path(f'/usr/lib/systemd/boot/efi/linux{opts.efi_arch}.efi.stub')
f644ea3e 2397 else:
35d92c03 2398 opts.stub = Path(f'/usr/lib/systemd/boot/efi/addon{opts.efi_arch}.efi.stub')
f4780cbe 2399
65fbf3b1
DDM
2400 if opts.signing_engine and opts.signing_provider:
2401 raise ValueError('Only one of --signing-engine= and --signing-provider= may be specified')
2402
64cc7ba5
DDM
2403 if opts.signing_engine is None and opts.signing_provider is None and opts.sb_key:
2404 opts.sb_key = Path(opts.sb_key)
2405
2406 if opts.certificate_provider is None and opts.sb_cert:
2407 opts.sb_cert = Path(opts.sb_cert)
f4780cbe 2408
5c520781 2409 if bool(opts.sb_key) ^ bool(opts.sb_cert):
d19434fb 2410 # one param only given, sbsign needs both
6a28cae9
JB
2411 raise ValueError(
2412 '--secureboot-private-key= and --secureboot-certificate= must be specified together'
2413 )
5c520781
EGE
2414 elif bool(opts.sb_key) and bool(opts.sb_cert):
2415 # both param given, infer sbsign and in case it was given, ensure signtool=sbsign
5e7e4e4d 2416 if opts.signtool and opts.signtool not in ('sbsign', 'systemd-sbsign'):
6a28cae9 2417 raise ValueError(
2572afa4 2418 f'Cannot provide --signtool={opts.signtool} with --secureboot-private-key= and --secureboot-certificate=' # noqa: E501
6a28cae9 2419 )
d835c447 2420 if not opts.signtool:
5e7e4e4d 2421 opts.signtool = 'sbsign'
5c520781
EGE
2422 elif bool(opts.sb_cert_name):
2423 # sb_cert_name given, infer pesign and in case it was given, ensure signtool=pesign
5e7e4e4d 2424 if opts.signtool and opts.signtool != 'pesign':
6a28cae9
JB
2425 raise ValueError(
2426 f'Cannot provide --signtool={opts.signtool} with --secureboot-certificate-name='
2427 )
5e7e4e4d 2428 opts.signtool = 'pesign'
f4780cbe 2429
5e7e4e4d 2430 if opts.signing_provider and opts.signtool != 'systemd-sbsign':
84624d8c 2431 raise ValueError('--signing-provider= can only be used with --signtool=systemd-sbsign')
65fbf3b1 2432
5e7e4e4d 2433 if opts.certificate_provider and opts.signtool != 'systemd-sbsign':
84624d8c 2434 raise ValueError('--certificate-provider= can only be used with --signtool=systemd-sbsign')
64cc7ba5 2435
c1e8d172 2436 if opts.sign_kernel and not opts.sb_key and not opts.sb_cert_name:
6a28cae9 2437 raise ValueError(
2572afa4 2438 '--sign-kernel requires either --secureboot-private-key= and --secureboot-certificate= (for sbsign) or --secureboot-certificate-name= (for pesign) to be specified' # noqa: E501
6a28cae9 2439 )
f4780cbe 2440
224aa31f 2441 opts.profile = resolve_at_path(opts.profile)
7db71cd7
DDM
2442 if opts.profile and isinstance(opts.profile, Path):
2443 opts.profile = opts.profile.read_text()
224aa31f 2444
16020c33
DDM
2445 if opts.join_profiles and not opts.profile:
2446 # If any additional profiles are added, we need a base profile as well so add one if
2447 # one wasn't explicitly provided
2448 opts.profile = 'ID=main'
2449
9876e88e
LB
2450 if opts.pcrsig and not opts.join_pcrsig:
2451 raise ValueError('--pcrsig requires --join-pcrsig')
2452 if opts.join_pcrsig and not opts.pcrsig:
2453 raise ValueError('--join-pcrsig requires --pcrsig')
2454 if opts.pcrsig and (
2455 opts.linux
2456 or opts.initrd
2457 or opts.profile
2458 or opts.join_profiles
2459 or opts.microcode
2460 or opts.sbat
2461 or opts.uname
2462 or opts.os_release
2463 or opts.cmdline
2464 or opts.hwids
2465 or opts.splash
2466 or opts.devicetree
2467 or opts.devicetree_auto
2468 or opts.pcr_private_keys
2469 or opts.pcr_public_keys
2ac8fcf6 2470 or opts.pcr_certificates
9876e88e
LB
2471 ):
2472 raise ValueError('--pcrsig and --join-pcrsig cannot be used with other sections')
2473 if opts.pcrsig:
2474 opts.pcrsig = resolve_at_path(opts.pcrsig)
2475 if isinstance(opts.pcrsig, Path):
2476 opts.pcrsig = opts.pcrsig.read_text()
2477
a1c80efd 2478 if opts.verb == 'build' and opts.output is None:
5143a47a
ZJS
2479 if opts.linux is None:
2480 raise ValueError('--output= must be specified when building a PE addon')
c1e8d172 2481 suffix = '.efi' if opts.sb_key or opts.sb_cert_name else '.unsigned.efi'
5143a47a
ZJS
2482 opts.output = opts.linux.name + suffix
2483
df4a4673
EGE
2484 # Now that we know if we're inputting or outputting, really parse section config
2485 f = Section.parse_output if opts.verb == 'inspect' else Section.parse_input
2486 opts.sections = [f(s) for s in opts.sections]
2487 # A convenience dictionary to make it easy to look up sections
6a28cae9 2488 opts.sections_by_name = {s.name: s for s in opts.sections}
5143a47a 2489
5143a47a 2490
e9519350 2491def parse_args(args: Optional[list[str]] = None) -> argparse.Namespace:
b09a5315 2492 opts = create_parser().parse_args(args)
6446c7d9
ZJS
2493
2494 # argparse puts some unknown options in opts.positional. Make sure we don't
2495 # try to interpret something that is an option as a positional argument.
2496 if any((bad_opt := o).startswith('-') for o in opts.positional):
2497 raise ValueError(f'Unknown option: {bad_opt.partition("=")[0]}')
2498
5143a47a 2499 apply_config(opts)
5143a47a 2500 finalize_options(opts)
f4780cbe
ZJS
2501 return opts
2502
2503
e9519350 2504def main() -> None:
f1b6430e 2505 opts = UkifyConfig.from_namespace(parse_args())
3d2144a2
JB
2506 if opts.summary:
2507 # TODO: replace pprint() with some fancy formatting.
2508 pprint.pprint(vars(opts))
2509 elif opts.verb == 'build':
a1c80efd
ZJS
2510 check_inputs(opts)
2511 make_uki(opts)
2512 elif opts.verb == 'genkey':
2513 check_cert_and_keys_nonexistent(opts)
2514 generate_keys(opts)
df4a4673
EGE
2515 elif opts.verb == 'inspect':
2516 inspect_sections(opts)
a1c80efd
ZJS
2517 else:
2518 assert False
f4780cbe
ZJS
2519
2520
2521if __name__ == '__main__':
2522 main()