]>
Commit | Line | Data |
---|---|---|
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 | |
22 | import argparse | |
467d21a7 | 23 | import builtins |
8abfd07e | 24 | import collections |
5143a47a | 25 | import configparser |
a1c80efd | 26 | import contextlib |
f4780cbe | 27 | import dataclasses |
a1c80efd | 28 | import datetime |
f4780cbe | 29 | import fnmatch |
f1b6430e | 30 | import inspect |
f4780cbe ZJS |
31 | import itertools |
32 | import json | |
33 | import os | |
5143a47a | 34 | import pprint |
6fa79138 | 35 | import pydoc |
f4780cbe ZJS |
36 | import re |
37 | import shlex | |
c8add4c2 | 38 | import shutil |
a1c80efd | 39 | import socket |
8abfd07e | 40 | import struct |
f4780cbe | 41 | import subprocess |
5143a47a | 42 | import sys |
f4780cbe | 43 | import tempfile |
df4a4673 | 44 | import textwrap |
cf331f1c | 45 | import uuid |
e9519350 | 46 | from collections.abc import Iterable, Iterator, Sequence |
df4a4673 | 47 | from hashlib import sha256 |
35d92c03 | 48 | from pathlib import Path |
e9519350 | 49 | from types import ModuleType |
6a28cae9 | 50 | from 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 | 61 | import pefile # type: ignore |
f4780cbe | 62 | |
6a4fcf8c | 63 | __version__ = '{{PROJECT_VERSION}} ({{VERSION_TAG}})' |
30ec2eae | 64 | |
f4780cbe | 65 | EFI_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 |
76 | EFI_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 | 81 | DEFAULT_CONFIG_DIRS = ['/etc/systemd', '/run/systemd', '/usr/local/lib/systemd', '/usr/lib/systemd'] |
a05fa30f AA |
82 | DEFAULT_CONFIG_FILE = 'ukify.conf' |
83 | ||
6a28cae9 | 84 | |
51faf836 | 85 | class 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 | 93 | def 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 |
119 | def 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 | 128 | def 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 | 133 | def round_up(x: int, blocksize: int = 4096) -> int: |
f4780cbe ZJS |
134 | return (x + blocksize - 1) // blocksize * blocksize |
135 | ||
136 | ||
e9519350 | 137 | def 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 |
144 | def 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 | 163 | def 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 | 204 | def 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 |
257 | class 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 |
310 | class 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 | 390 | DEFAULT_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 |
411 | class 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 | |
468 | class 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 |
489 | class 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 | |
510 | class 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 | ||
543 | class 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 |
575 | class 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 | 619 | def 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 | ||
625 | KNOWN_PHASES = ( | |
626 | 'enter-initrd', | |
627 | 'leave-initrd', | |
628 | 'sysinit', | |
629 | 'ready', | |
630 | 'shutdown', | |
631 | 'final', | |
632 | ) | |
633 | ||
6a28cae9 | 634 | |
e9519350 | 635 | def 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 | 647 | def 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 | 661 | def 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 | 683 | def 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 |
694 | def 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 |
715 | def 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 | 725 | def 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 | 743 | def pe_strip_section_name(name: bytes) -> str: |
6a28cae9 | 744 | return name.rstrip(b'\x00').decode() |
db0f9720 DDM |
745 | |
746 | ||
33b25fa1 DDM |
747 | def pe_section_size(section: pefile.SectionStructure) -> int: |
748 | return cast(int, min(section.Misc_VirtualSize, section.SizeOfRawData)) | |
749 | ||
750 | ||
9876e88e | 751 | def 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 | 897 | def 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 |
913 | T = TypeVar('T') |
914 | ||
915 | ||
916 | def 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 | 922 | class PEError(Exception): |
3fc5eed4 JJ |
923 | pass |
924 | ||
3fc1ae89 | 925 | |
9876e88e | 926 | def 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 | 1105 | def 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 | 1143 | DEVICE_STRUCT_SIZE = 4 + 16 + 4 + 4 |
0333b9d5 | 1144 | NULL_DEVICE = b'\0' * DEVICE_STRUCT_SIZE |
61d60757 | 1145 | DEVICE_TYPE_DEVICETREE = 1 |
5d0ac352 | 1146 | DEVICE_TYPE_UEFI_FW = 2 |
61d60757 | 1147 | |
83bf58f3 AS |
1148 | # Keep in sync with efifirmware.h |
1149 | FWHEADERMAGIC = 'feeddead' | |
1150 | EFIFW_HEADER_SIZE = 4 + 4 + 4 + 4 | |
1151 | ||
61d60757 | 1152 | |
1153 | def device_make_descriptor(device_type: int, size: int) -> int: | |
1154 | return (size) | (device_type << 28) | |
0333b9d5 | 1155 | |
1156 | ||
5d0ac352 AS |
1157 | def 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 | 1171 | def 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 | ||
1182 | def 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 |
1242 | def 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 |
1278 | STUB_SBAT = """\ |
1279 | sbat,1,SBAT Version,sbat,1,https://github.com/rhboot/shim/blob/main/SBAT.md | |
1280 | uki,1,UKI,uki,1,https://uapi-group.org/specifications/specs/unified_kernel_image/ | |
1281 | """ | |
1282 | ||
1283 | ADDON_SBAT = """\ | |
1284 | sbat,1,SBAT Version,sbat,1,https://github.com/rhboot/shim/blob/main/SBAT.md | |
1285 | uki-addon,1,UKI Addon,addon,1,https://www.freedesktop.org/software/systemd/man/latest/systemd-stub.html | |
1286 | """ | |
1287 | ||
1288 | ||
f1b6430e JB |
1289 | def 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 | 1521 | def 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 | ||
1531 | def 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 | 1592 | def 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 | 1613 | def 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 | 1656 | def 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 | 1699 | def 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) |
1711 | class 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 | 1868 | VERBS = ('build', 'genkey', 'inspect') |
a3b227d2 | 1869 | |
5143a47a | 1870 | CONFIG_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 | 2203 | CONFIGFILE_ITEMS = {item.config_key: item for item in CONFIG_ITEMS if item.config_key} |
5143a47a ZJS |
2204 | |
2205 | ||
e9519350 | 2206 | def 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 |
2262 | def 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 |
2275 | class 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 | 2287 | def 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 | 2318 | def 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 | 2325 | def 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 | 2491 | def 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 | 2504 | def 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 | ||
2521 | if __name__ == '__main__': | |
2522 | main() |