]> git.ipfire.org Git - thirdparty/systemd.git/blame_incremental - src/ukify/ukify.py
man: improve Description= documentation (#38101)
[thirdparty/systemd.git] / src / ukify / ukify.py
... / ...
CommitLineData
1#!/usr/bin/env python3
2# SPDX-License-Identifier: LGPL-2.1-or-later
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/>.
18
19# pylint: disable=import-outside-toplevel,consider-using-with,unused-argument
20# pylint: disable=unnecessary-lambda-assignment
21
22import argparse
23import builtins
24import collections
25import configparser
26import contextlib
27import dataclasses
28import datetime
29import fnmatch
30import inspect
31import itertools
32import json
33import os
34import pprint
35import pydoc
36import re
37import shlex
38import shutil
39import socket
40import struct
41import subprocess
42import sys
43import tempfile
44import textwrap
45import uuid
46from collections.abc import Iterable, Iterator, Sequence
47from hashlib import sha256
48from pathlib import Path
49from types import ModuleType
50from typing import (
51 IO,
52 Any,
53 Callable,
54 Literal,
55 Optional,
56 TypeVar,
57 Union,
58 cast,
59)
60
61import pefile # type: ignore
62
63__version__ = '{{PROJECT_VERSION}} ({{VERSION_TAG}})'
64
65EFI_ARCH_MAP = {
66 # host_arch glob : [efi_arch, 32_bit_efi_arch if mixed mode is supported]
67 'x86_64': ['x64', 'ia32'],
68 'i[3456]86': ['ia32'],
69 'aarch64': ['aa64'],
70 'armv[45678]*l': ['arm'],
71 'loongarch32': ['loongarch32'],
72 'loongarch64': ['loongarch64'],
73 'riscv32': ['riscv32'],
74 'riscv64': ['riscv64'],
75} # fmt: skip
76EFI_ARCHES: list[str] = sum(EFI_ARCH_MAP.values(), [])
77
78# Default configuration directories and file name.
79# When the user does not specify one, the directories are searched in this order and the first file found is
80# used.
81DEFAULT_CONFIG_DIRS = ['/etc/systemd', '/run/systemd', '/usr/local/lib/systemd', '/usr/lib/systemd']
82DEFAULT_CONFIG_FILE = 'ukify.conf'
83
84
85class Style:
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 ''
91
92
93def guess_efi_arch() -> str:
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:
106 fw_platform_size = Path('/sys/firmware/efi/fw_platform_size')
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
115 # print(f'Host arch {arch!r}, EFI arch {efi_arch!r}')
116 return efi_arch
117
118
119def page(text: str, enabled: Optional[bool]) -> None:
120 if enabled:
121 # Initialize less options from $SYSTEMD_LESS or provide a suitable fallback.
122 os.environ['LESS'] = os.getenv('SYSTEMD_LESS', 'FRSXMK')
123 pydoc.pager(text)
124 else:
125 print(text)
126
127
128def shell_join(cmd: list[Union[str, Path]]) -> str:
129 # TODO: drop in favour of shlex.join once shlex.join supports Path.
130 return ' '.join(shlex.quote(str(x)) for x in cmd)
131
132
133def round_up(x: int, blocksize: int = 4096) -> int:
134 return (x + blocksize - 1) // blocksize * blocksize
135
136
137def try_import(modname: str, name: Optional[str] = None) -> ModuleType:
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
143
144def read_env_file(text: str) -> dict[str, str]:
145 result = {}
146
147 for line in text.splitlines():
148 line = line.rstrip()
149 if not line or line.startswith('#'):
150 continue
151 if m := re.match(r'([A-Z][A-Z_0-9]+)=(.*)', line):
152 name, val = m.groups()
153 if val and val[0] in '"\'':
154 val = next(shlex.shlex(val, posix=True))
155
156 result[name] = val
157 else:
158 print(f'bad line {line!r}', file=sys.stderr)
159
160 return result
161
162
163def get_zboot_kernel(f: IO[bytes]) -> bytes:
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.
175 f.seek(0xC)
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')
186 return cast(bytes, gzip.open(f).read(size))
187 elif comp_type.startswith(b'lz4'):
188 lz4 = try_import('lz4.frame', 'lz4')
189 return cast(bytes, lz4.frame.decompress(f.read(size)))
190 elif comp_type.startswith(b'lzma'):
191 lzma = try_import('lzma')
192 return cast(bytes, lzma.open(f).read(size))
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')
197 elif comp_type.startswith(b'zstd'):
198 zstd = try_import('zstandard')
199 return cast(bytes, zstd.ZstdDecompressor().stream_reader(f.read(size)).read())
200
201 raise NotImplementedError(f'unknown compressed type: {comp_type!r}')
202
203
204def maybe_decompress(filename: Union[str, Path]) -> bytes:
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
214 if start.startswith(b'MZ'):
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()
223
224 if start.startswith(b'\x1f\x8b'):
225 gzip = try_import('gzip')
226 return cast(bytes, gzip.open(f).read())
227
228 if start.startswith(b'\x28\xb5\x2f\xfd'):
229 zstd = try_import('zstandard')
230 return cast(bytes, zstd.ZstdDecompressor().stream_reader(f.read()).read())
231
232 if start.startswith(b'\x02\x21\x4c\x18'):
233 lz4 = try_import('lz4.frame', 'lz4')
234 return cast(bytes, lz4.frame.decompress(f.read()))
235
236 if start.startswith(b'\x04\x22\x4d\x18'):
237 print('Newer lz4 stream format detected! This may not boot!', file=sys.stderr)
238 lz4 = try_import('lz4.frame', 'lz4')
239 return cast(bytes, lz4.frame.decompress(f.read()))
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'):
246 bz2 = try_import('bz2', 'bzip2')
247 return cast(bytes, bz2.open(f).read())
248
249 if start.startswith(b'\x5d\x00\x00'):
250 lzma = try_import('lzma')
251 return cast(bytes, lzma.open(f).read())
252
253 raise NotImplementedError(f'unknown file format (starts with {start!r})')
254
255
256@dataclasses.dataclass
257class UkifyConfig:
258 all: bool
259 cmdline: Union[str, Path, None]
260 devicetree: Path
261 devicetree_auto: list[Path]
262 efi_arch: str
263 hwids: Path
264 initrd: list[Path]
265 efifw: list[Path]
266 join_profiles: list[Path]
267 sign_profiles: list[str]
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]
276 pcr_public_keys: list[str]
277 pcr_certificates: list[str]
278 pcrpkey: Optional[Path]
279 pcrsig: Union[str, Path, None]
280 join_pcrsig: Optional[Path]
281 phase_path_groups: Optional[list[str]]
282 policy_digest: bool
283 profile: Optional[str]
284 sb_cert: Union[str, Path, None]
285 sb_cert_name: Optional[str]
286 sb_cert_validity: int
287 sb_certdir: Path
288 sb_key: Union[str, Path, None]
289 sbat: Optional[list[str]]
290 sections: list['Section']
291 sections_by_name: dict[str, 'Section']
292 sign_kernel: Optional[bool]
293 signing_engine: Optional[str]
294 signing_provider: Optional[str]
295 certificate_provider: Optional[str]
296 signtool: Optional[str]
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
310class Uname:
311 # This class is here purely as a namespace for the functions
312
313 VERSION_PATTERN = r'(?P<version>[a-z0-9._+-]+) \([^ )]+\) (?:#.*)'
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
323 def scrape_x86(cls, filename: Path, opts: Optional[UkifyConfig] = None) -> str:
324 # Based on https://gitlab.archlinux.org/archlinux/mkinitcpio/mkinitcpio/-/blob/master/functions#L136
325 # and https://docs.kernel.org/arch/x86/boot.html#the-real-mode-kernel-header
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)
332 offset = f.read(1)[0] + f.read(1)[0] * 256 # Pointer to kernel version string
333 f.seek(0x200 + offset)
334 text = f.read(128)
335 text = text.split(b'\0', maxsplit=1)[0]
336 decoded = text.decode()
337
338 if not (m := re.match(cls.VERSION_PATTERN, decoded)):
339 raise ValueError(f'Cannot parse version-host-release uname string: {text!r}')
340 return m.group('version')
341
342 @classmethod
343 def scrape_elf(cls, filename: Path, opts: Optional[UkifyConfig] = None) -> str:
344 readelf = find_tool('readelf', opts=opts)
345
346 cmd = [
347 readelf,
348 '--notes',
349 filename,
350 ]
351
352 print('+', shell_join(cmd), file=sys.stderr)
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
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
365 def scrape_generic(cls, filename: Path, opts: Optional[UkifyConfig] = None) -> str:
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
379 def scrape(cls, filename: Path, opts: Optional[UkifyConfig] = None) -> Optional[str]:
380 for func in (cls.scrape_x86, cls.scrape_elf, cls.scrape_generic):
381 try:
382 version = func(filename, opts=opts)
383 print(f'Found uname version: {version}', file=sys.stderr)
384 return version
385 except ValueError as e:
386 print(str(e), file=sys.stderr)
387 return None
388
389
390DEFAULT_SECTIONS_TO_SHOW = {
391 '.linux': 'binary',
392 '.initrd': 'binary',
393 '.ucode': 'binary',
394 '.splash': 'binary',
395 '.dtb': 'binary',
396 '.dtbauto': 'binary',
397 '.hwids': 'binary',
398 '.efifw': 'binary',
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
409
410@dataclasses.dataclass
411class Section:
412 name: str
413 content: Optional[Path]
414 tmpfile: Optional[IO[Any]] = None
415 measure: bool = False
416 output_mode: Optional[str] = None
417
418 @classmethod
419 def create(cls, name: str, contents: Union[str, bytes, Path, None], **kwargs: Any) -> 'Section':
420 if isinstance(contents, (str, bytes)):
421 mode = 'wt' if isinstance(contents, str) else 'wb'
422 tmp = tempfile.NamedTemporaryFile(mode=mode, prefix=f'tmp{name}')
423 tmp.write(contents)
424 tmp.flush()
425 contents = Path(tmp.name)
426 else:
427 tmp = None
428
429 return cls(name, contents, tmpfile=tmp, **kwargs)
430
431 @classmethod
432 def parse_input(cls, s: str) -> 'Section':
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('@'):
441 sec = cls.create(name, Path(contents[1:]))
442 else:
443 sec = cls.create(name, contents)
444
445 sec.check_name()
446 return sec
447
448 @classmethod
449 def parse_output(cls, s: str) -> 'Section':
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()
454 out = Path(out) if out else None
455
456 return cls.create(name, out, output_mode=ttype)
457
458 def check_name(self) -> None:
459 # PE section names with more than 8 characters are legal, but our stub does
460 # not support them.
461 if not self.name.isascii() or not self.name.isprintable():
462 raise ValueError(f'Bad section name: {self.name!r}')
463 if len(self.name) > 8:
464 raise ValueError(f'Section name too long: {self.name!r}')
465
466
467@dataclasses.dataclass
468class UKI:
469 executable: Path
470 sections: list[Section] = dataclasses.field(default_factory=list, init=False)
471
472 def add_section(self, section: Section) -> None:
473 start = 0
474
475 # Start search at last .profile section, if there is one
476 for i, s in enumerate(self.sections):
477 if s.name == '.profile':
478 start = i + 1
479
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 ):
484 raise ValueError(f'Duplicate section {section.name}')
485
486 self.sections += [section]
487
488
489class SignTool:
490 @staticmethod
491 def sign(input_f: str, output_f: str, opts: UkifyConfig) -> None:
492 raise NotImplementedError
493
494 @staticmethod
495 def verify(input_f: Path, opts: UkifyConfig) -> bool:
496 raise NotImplementedError
497
498 @staticmethod
499 def from_string(name: str) -> type['SignTool']:
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
509
510class PeSign(SignTool):
511 @staticmethod
512 def sign(input_f: str, output_f: str, opts: UkifyConfig) -> None:
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
527 print('+', shell_join(cmd), file=sys.stderr)
528 subprocess.check_call(cmd)
529
530 @staticmethod
531 def verify(input_f: Path, opts: UkifyConfig) -> bool:
532 assert input_f is not None
533
534 tool = find_tool('pesign', opts=opts)
535 cmd = [tool, '-i', input_f, '-S']
536
537 print('+', shell_join(cmd), file=sys.stderr)
538 info = subprocess.check_output(cmd, text=True)
539
540 return 'No signatures found.' in info
541
542
543class SbSign(SignTool):
544 @staticmethod
545 def sign(input_f: str, output_f: str, opts: UkifyConfig) -> None:
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
559 print('+', shell_join(cmd), file=sys.stderr)
560 subprocess.check_call(cmd)
561
562 @staticmethod
563 def verify(input_f: Path, opts: UkifyConfig) -> bool:
564 assert input_f is not None
565
566 tool = find_tool('sbverify', opts=opts)
567 cmd = [tool, '--list', input_f]
568
569 print('+', shell_join(cmd), file=sys.stderr)
570 info = subprocess.check_output(cmd, text=True)
571
572 return 'No signature table present' in info
573
574
575class SystemdSbSign(SignTool):
576 @staticmethod
577 def sign(input_f: str, output_f: str, opts: UkifyConfig) -> None:
578 assert opts.sb_key is not None
579 assert opts.sb_cert is not None
580
581 tool = find_tool(
582 'systemd-sbsign',
583 '/usr/lib/systemd/systemd-sbsign',
584 opts=opts,
585 msg='systemd-sbsign, required for signing, is not installed',
586 )
587 cmd = [
588 tool,
589 "sign",
590 '--private-key', opts.sb_key,
591 '--certificate', opts.sb_cert,
592 *(
593 ['--private-key-source', f'engine:{opts.signing_engine}']
594 if opts.signing_engine is not None
595 else []
596 ),
597 *(
598 ['--private-key-source', f'provider:{opts.signing_provider}']
599 if opts.signing_provider is not None
600 else []
601 ),
602 *(
603 ['--certificate-source', f'provider:{opts.certificate_provider}']
604 if opts.certificate_provider is not None
605 else []
606 ),
607 input_f,
608 '--output', output_f,
609 ] # fmt: skip
610
611 print('+', shell_join(cmd), file=sys.stderr)
612 subprocess.check_call(cmd)
613
614 @staticmethod
615 def verify(input_f: Path, opts: UkifyConfig) -> bool:
616 raise NotImplementedError('systemd-sbsign cannot yet verify if existing PE binaries are signed')
617
618
619def parse_banks(s: str) -> list[str]:
620 banks = re.split(r',|\s+', s)
621 # TODO: do some sanity checking here
622 return banks
623
624
625KNOWN_PHASES = (
626 'enter-initrd',
627 'leave-initrd',
628 'sysinit',
629 'ready',
630 'shutdown',
631 'final',
632)
633
634
635def parse_phase_paths(s: str) -> list[str]:
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
647def check_splash(filename: Optional[Path]) -> None:
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'])
658 print(f'Splash image {filename} is {img.width}×{img.height} pixels', file=sys.stderr)
659
660
661def check_inputs(opts: UkifyConfig) -> None:
662 for name, value in vars(opts).items():
663 if name in {'output', 'tools'}:
664 continue
665
666 if isinstance(value, Path):
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()
672 elif isinstance(value, list):
673 for item in value:
674 if isinstance(item, Path):
675 if item.is_dir():
676 item.iterdir()
677 else:
678 item.open().close()
679
680 check_splash(opts.splash)
681
682
683def check_cert_and_keys_nonexistent(opts: UkifyConfig) -> None:
684 # Raise if any of the keys and certs are found on disk
685 paths: Iterator[Union[str, Path, None]] = itertools.chain(
686 (opts.sb_key, opts.sb_cert),
687 *((priv_key, pub_key, cert) for priv_key, pub_key, cert, _ in key_path_groups(opts)),
688 )
689 for path in paths:
690 if path and Path(path).exists():
691 raise ValueError(f'{path} is present')
692
693
694def find_tool(
695 name: str,
696 fallback: Optional[str] = None,
697 opts: Optional[UkifyConfig] = None,
698 msg: str = 'Tool {name} not installed!',
699) -> Union[str, Path]:
700 if opts and opts.tools:
701 for d in opts.tools:
702 tool = d / name
703 if tool.exists():
704 return tool
705
706 if shutil.which(name) is not None:
707 return name
708
709 if fallback is None:
710 raise ValueError(msg.format(name=name))
711
712 return fallback
713
714
715def combine_signatures(pcrsigs: list[dict[str, str]]) -> str:
716 combined: collections.defaultdict[str, list[str]] = collections.defaultdict(list)
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
725def key_path_groups(opts: UkifyConfig) -> Iterator[tuple[str, Optional[str], Optional[str], Optional[str]]]:
726 if not opts.pcr_private_keys:
727 return
728
729 n_priv = len(opts.pcr_private_keys)
730 pub_keys = opts.pcr_public_keys or []
731 certs = opts.pcr_certificates or []
732 pp_groups = opts.phase_path_groups or []
733
734 yield from itertools.zip_longest(
735 opts.pcr_private_keys,
736 pub_keys[:n_priv],
737 certs[:n_priv],
738 pp_groups[:n_priv],
739 fillvalue=None,
740 )
741
742
743def pe_strip_section_name(name: bytes) -> str:
744 return name.rstrip(b'\x00').decode()
745
746
747def pe_section_size(section: pefile.SectionStructure) -> int:
748 return cast(int, min(section.Misc_VirtualSize, section.SizeOfRawData))
749
750
751def call_systemd_measure(uki: UKI, opts: UkifyConfig, profile_start: int = 0) -> str:
752 measure_tool = find_tool(
753 'systemd-measure',
754 '/usr/lib/systemd/systemd-measure',
755 opts=opts,
756 )
757 combined = ''
758
759 banks = opts.pcr_banks or ()
760
761 # PCR measurement
762
763 # First, pick up either the base sections or the profile specific sections we shall measure now
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]
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
779 if section.name == '.profile':
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
787 if section.name in unique_to_measure:
788 continue
789
790 unique_to_measure[section.name] = section
791
792 if opts.measure or opts.policy_digest:
793 pcrsigs = []
794 to_measure = unique_to_measure.copy()
795
796 for dtbauto in dtbauto_to_measure:
797 if dtbauto is not None:
798 to_measure[dtbauto.name] = dtbauto
799
800 pp_groups = opts.phase_path_groups or []
801
802 cmd = [
803 measure_tool,
804 'calculate' if opts.measure else 'policy-digest',
805 '--json',
806 opts.json,
807 *(f'--{s.name.removeprefix(".")}={s.content}' for s in to_measure.values()),
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
814 # The JSON object will be used for offline signing, include the public key
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}']
824
825 print('+', shell_join(cmd), file=sys.stderr)
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))
842
843 # PCR signing
844
845 if opts.pcr_private_keys:
846 pcrsigs = []
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',
856 *(f'--{s.name.removeprefix(".")}={s.content}' for s in to_measure.values()),
857 *(f'--bank={bank}' for bank in banks),
858 ]
859
860 for priv_key, pub_key, cert, group in key_path_groups(opts):
861 extra = [f'--private-key={priv_key}']
862 if opts.signing_engine is not None:
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 ]
870 elif opts.signing_provider is not None:
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}']
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
886 print('+', shell_join(cmd + extra), file=sys.stderr) # type: ignore
887 output = subprocess.check_output(cmd + extra, text=True) # type: ignore
888 pcrsig = json.loads(output)
889 pcrsigs += [pcrsig]
890
891 combined = combine_signatures(pcrsigs)
892 uki.add_section(Section.create('.pcrsig', combined))
893
894 return combined
895
896
897def join_initrds(initrds: list[Path]) -> Union[Path, bytes, None]:
898 if not initrds:
899 return None
900 if len(initrds) == 1:
901 return initrds[0]
902
903 seq = []
904 for file in initrds:
905 initrd = file.read_bytes()
906 n = len(initrd)
907 padding = b'\0' * (round_up(n, 4) - n) # pad to 32 bit alignment
908 seq += [initrd, padding]
909
910 return b''.join(seq)
911
912
913T = TypeVar('T')
914
915
916def pairwise(iterable: Iterable[T]) -> Iterator[tuple[T, Optional[T]]]:
917 a, b = itertools.tee(iterable)
918 next(b, None)
919 return zip(a, b)
920
921
922class PEError(Exception):
923 pass
924
925
926def pe_add_sections(opts: UkifyConfig, uki: UKI, output: str) -> None:
927 pe = pefile.PE(uki.executable, fast_load=True)
928
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
942 # Old stubs might have been stripped, leading to unaligned raw data values, so let's fix them up here.
943 # pylint thinks that Structure doesn't have various members that it has…
944 # pylint: disable=no-member
945
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
954 for later_section in pe.sections[i + 1 :]:
955 later_section.PointerToRawData += padp + padsz
956
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 )
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.
968 pe.OPTIONAL_HEADER.SizeOfHeaders = round_up(
969 pe.OPTIONAL_HEADER.SizeOfHeaders, pe.OPTIONAL_HEADER.FileAlignment
970 )
971 pe = pefile.PE(data=pe.write(), fast_load=True)
972
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
975 warnings = pe.get_warnings()
976 for w in warnings:
977 if 'VirtualSize is extremely large' in w:
978 continue
979 if 'VirtualAddress is beyond' in w:
980 continue
981 raise PEError(f'pefile warnings treated as errors: {warnings}')
982
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')
991
992 # Remember how many sections originate from systemd-stub
993 n_original_sections = len(pe.sections)
994
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:
1001 raise PEError(f'Not enough header space to add section {section.name}.')
1002
1003 assert section.content
1004 data = section.content.read_bytes()
1005
1006 new_section.set_file_offset(offset)
1007 new_section.Name = section.name.encode()
1008 new_section.Misc_VirtualSize = len(data)
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)
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
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.
1028 for i, s in enumerate(pe.sections[:n_original_sections]):
1029 if pe_strip_section_name(s.Name) == section.name and section.name != '.dtbauto':
1030 if new_section.Misc_VirtualSize > s.SizeOfRawData:
1031 raise PEError(f'Not enough space in existing section {section.name} to append new data')
1032
1033 padding = bytes(new_section.SizeOfRawData - new_section.Misc_VirtualSize)
1034 pe.__data__ = (
1035 pe.__data__[: s.PointerToRawData]
1036 + data
1037 + padding
1038 + pe.__data__[pe.sections[i + 1].PointerToRawData :]
1039 )
1040 s.SizeOfRawData = new_section.SizeOfRawData
1041 s.Misc_VirtualSize = new_section.Misc_VirtualSize
1042 break
1043 else:
1044 pe.__data__ = (
1045 pe.__data__[:]
1046 + bytes(new_section.PointerToRawData - len(pe.__data__))
1047 + data
1048 + bytes(new_section.SizeOfRawData - len(data))
1049 )
1050
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)
1055
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(
1084 f'Not enough space in existing section .pcrsig of size {section.SizeOfRawData} to append new data of size {len(encoded)}' # noqa: E501
1085 )
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
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)
1103
1104
1105def merge_sbat(input_pe: list[Path], input_text: list[str]) -> str:
1106 sbat = []
1107
1108 for f in input_pe:
1109 try:
1110 pe = pefile.PE(f, fast_load=True)
1111 except pefile.PEFormatError:
1112 print(f'{f} is not a valid PE file, not extracting SBAT section.', file=sys.stderr)
1113 continue
1114
1115 for section in pe.sections:
1116 if pe_strip_section_name(section.Name) == '.sbat':
1117 split = section.get_data().rstrip(b'\x00').decode().splitlines()
1118 if not split[0].startswith('sbat,'):
1119 print(f'{f} does not contain a valid SBAT section, skipping.', file=sys.stderr)
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
1125 for t in input_text:
1126 if t.startswith('@'):
1127 t = Path(t[1:]).read_text()
1128 split = t.splitlines()
1129 if not split[0].startswith('sbat,'):
1130 print(f'{t} does not contain a valid SBAT section, skipping.', file=sys.stderr)
1131 continue
1132 sbat += split[1:]
1133
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
1140
1141# Keep in sync with Device from src/boot/chid.h
1142# uint32_t descriptor, EFI_GUID chid, uint32_t name_offset, uint32_t compatible_offset
1143DEVICE_STRUCT_SIZE = 4 + 16 + 4 + 4
1144NULL_DEVICE = b'\0' * DEVICE_STRUCT_SIZE
1145DEVICE_TYPE_DEVICETREE = 1
1146DEVICE_TYPE_UEFI_FW = 2
1147
1148# Keep in sync with efifirmware.h
1149FWHEADERMAGIC = 'feeddead'
1150EFIFW_HEADER_SIZE = 4 + 4 + 4 + 4
1151
1152
1153def device_make_descriptor(device_type: int, size: int) -> int:
1154 return (size) | (device_type << 28)
1155
1156
1157def pack_device(
1158 offsets: dict[str, int], devtype: int, name: str, compatible_or_fwid: str, chids: set[uuid.UUID]
1159) -> bytes:
1160 data = b''
1161 descriptor = device_make_descriptor(devtype, DEVICE_STRUCT_SIZE)
1162 for chid in sorted(chids):
1163 data += struct.pack('<I', descriptor)
1164 data += chid.bytes_le
1165 data += struct.pack('<II', offsets[name], offsets[compatible_or_fwid])
1166
1167 assert len(data) == DEVICE_STRUCT_SIZE * len(chids)
1168 return data
1169
1170
1171def pack_strings(strings: set[str], base: int) -> tuple[bytes, dict[str, int]]:
1172 blob = b''
1173 offsets = {}
1174
1175 for string in sorted(strings):
1176 offsets[string] = base + len(blob)
1177 blob += string.encode('utf-8') + b'\00'
1178
1179 return (blob, offsets)
1180
1181
1182def parse_hwid_dir(path: Path) -> bytes:
1183 hwid_files = path.rglob('*.json')
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 }
1199
1200 strings: set[str] = set()
1201 devices: collections.defaultdict[tuple[int, str, str], set[uuid.UUID]] = collections.defaultdict(set)
1202
1203 for hwid_file in hwid_files:
1204 data = json.loads(hwid_file.read_text(encoding='UTF-8'))
1205
1206 for k in mandatory_attribute:
1207 if k not in data:
1208 raise ValueError(f'hwid description file "{hwid_file}" does not contain "{k}"')
1209
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']]
1216
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']}
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''
1234 for (devtype, name, compatible_or_fwid), uuids in devices.items():
1235 devices_blob += pack_device(offsets, devtype, name, compatible_or_fwid, uuids)
1236
1237 devices_blob += NULL_DEVICE
1238
1239 return devices_blob + strings_blob
1240
1241
1242def parse_efifw_dir(path: Path) -> bytes:
1243 if not path.is_dir():
1244 raise ValueError(f'{path} is not a directory or it does not exist')
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:
1249 raise ValueError(f'{path} must contain exactly one firmware image file')
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
1278STUB_SBAT = """\
1279sbat,1,SBAT Version,sbat,1,https://github.com/rhboot/shim/blob/main/SBAT.md
1280uki,1,UKI,uki,1,https://uapi-group.org/specifications/specs/unified_kernel_image/
1281"""
1282
1283ADDON_SBAT = """\
1284sbat,1,SBAT Version,sbat,1,https://github.com/rhboot/shim/blob/main/SBAT.md
1285uki-addon,1,UKI Addon,addon,1,https://www.freedesktop.org/software/systemd/man/latest/systemd-stub.html
1286"""
1287
1288
1289def make_uki(opts: UkifyConfig) -> None:
1290 assert opts.output is not None
1291
1292 # kernel payload signing
1293
1294 sign_args_present = opts.sb_key or opts.sb_cert_name
1295 sign_kernel = opts.sign_kernel
1296 linux = opts.linux
1297 combined_sigs = '{}'
1298
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:
1315 assert opts.signtool is not None
1316 signtool = SignTool.from_string(opts.signtool)
1317
1318 if sign_kernel is None:
1319 # figure out if we should sign the kernel
1320 sign_kernel = signtool.verify(linux, opts)
1321
1322 if sign_kernel:
1323 linux_signed = tempfile.NamedTemporaryFile(prefix='linux-signed')
1324 signtool.sign(os.fspath(linux), os.fspath(Path(linux_signed.name)), opts=opts)
1325 linux = Path(linux_signed.name)
1326
1327 if opts.uname is None and linux is not None:
1328 print('Kernel version not specified, starting autodetection 😖.', file=sys.stderr)
1329 opts.uname = Uname.scrape(linux, opts=opts)
1330
1331 uki = UKI(opts.join_pcrsig if opts.join_pcrsig else opts.stub)
1332 initrd = join_initrds(opts.initrd)
1333
1334 pcrpkey: Union[bytes, Path, None] = opts.pcrpkey
1335 if pcrpkey is None:
1336 keyutil_tool = find_tool('systemd-keyutil', '/usr/lib/systemd/systemd-keyutil')
1337 cmd = [keyutil_tool, 'public']
1338
1339 if opts.pcr_public_keys and len(opts.pcr_public_keys) == 1:
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}']
1345
1346 print('+', shell_join(cmd), file=sys.stderr)
1347 pcrpkey = subprocess.check_output(cmd)
1348 else:
1349 pcrpkey = Path(opts.pcr_public_keys[0])
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)
1357 elif opts.pcr_private_keys and len(opts.pcr_private_keys) == 1:
1358 cmd += ['--private-key', Path(opts.pcr_private_keys[0])]
1359
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
1365 print('+', shell_join(cmd), file=sys.stderr)
1366 pcrpkey = subprocess.check_output(cmd)
1367
1368 hwids = None
1369
1370 if opts.hwids is not None:
1371 hwids = parse_hwid_dir(opts.hwids)
1372
1373 sections = [
1374 # name, content, measure?
1375 ('.osrel', opts.os_release, True),
1376 ('.cmdline', opts.cmdline, True),
1377 ('.dtb', opts.devicetree, True),
1378 *(('.dtbauto', dtb, True) for dtb in opts.devicetree_auto),
1379 ('.hwids', hwids, True),
1380 ('.uname', opts.uname, True),
1381 ('.splash', opts.splash, True),
1382 ('.pcrpkey', pcrpkey, True),
1383 ('.linux', linux, True),
1384 ('.initrd', initrd, True),
1385 *(('.efifw', parse_efifw_dir(fw), False) for fw in opts.efifw),
1386 ('.ucode', opts.microcode, True),
1387 ] # fmt: skip
1388
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:
1391 uki.add_section(Section.create('.profile', opts.profile, measure=True))
1392
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
1401 # Don't add a sbat section to profile PE binaries.
1402 if (opts.join_profiles or not opts.profile) and not opts.pcrsig:
1403 if linux is not None:
1404 # Merge the .sbat sections from stub, kernel and parameter, so that revocation can be done on
1405 # either.
1406 input_pes = [opts.stub, linux]
1407 if not opts.sbat:
1408 opts.sbat = [STUB_SBAT]
1409 else:
1410 # Addons don't use the stub so we add SBAT manually
1411 input_pes = []
1412 if not opts.sbat:
1413 opts.sbat = [ADDON_SBAT]
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:
1420 uki.add_section(Section.create('.profile', opts.profile, measure=True))
1421
1422 # PCR measurement and signing
1423
1424 if (
1425 not opts.pcrsig
1426 and (opts.join_profiles or not opts.profile)
1427 and (
1428 not opts.sign_profiles
1429 or (opts.profile and read_env_file(opts.profile).get('ID') in opts.sign_profiles)
1430 )
1431 ):
1432 combined_sigs = call_systemd_measure(uki, opts=opts)
1433
1434 # UKI profiles
1435
1436 to_import = {
1437 '.linux',
1438 '.osrel',
1439 '.cmdline',
1440 '.initrd',
1441 '.efifw',
1442 '.ucode',
1443 '.splash',
1444 '.dtb',
1445 '.uname',
1446 '.sbat',
1447 '.profile',
1448 }
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:
1458 raise ValueError(f'Found no valid sections in PE profile binary {profile}')
1459
1460 if names[0] != '.profile':
1461 raise ValueError(
1462 f'Expected .profile section as first valid section in PE profile binary {profile} but got {names[0]}' # noqa: E501
1463 )
1464
1465 if names.count('.profile') > 1:
1466 raise ValueError(f'Profile PE binary {profile} contains multiple .profile sections')
1467
1468 for pesection in pe.sections:
1469 n = pe_strip_section_name(pesection.Name)
1470
1471 if n not in to_import:
1472 continue
1473
1474 print(
1475 f"Copying section '{n}' from '{profile}': {pe_section_size(pesection)} bytes",
1476 file=sys.stderr,
1477 )
1478 uki.add_section(
1479 Section.create(n, pesection.get_data(length=pe_section_size(pesection)), measure=True)
1480 )
1481
1482 if opts.sign_profiles:
1483 pesection = next(s for s in pe.sections if pe_strip_section_name(s.Name) == '.profile')
1484 id = read_env_file(pesection.get_data(length=pe_section_size(pesection)).decode()).get('ID')
1485 if not id or id not in opts.sign_profiles:
1486 print(f'Not signing expected PCR measurements for "{id}" profile', file=sys.stderr)
1487 continue
1488
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)])
1492
1493 # UKI creation
1494
1495 if sign_args_present:
1496 unsigned = tempfile.NamedTemporaryFile(prefix='uki')
1497 unsigned_output = unsigned.name
1498 else:
1499 unsigned_output = opts.output
1500
1501 pe_add_sections(opts, uki, unsigned_output)
1502
1503 # UKI signing
1504
1505 if sign_args_present:
1506 assert opts.signtool is not None
1507 signtool = SignTool.from_string(opts.signtool)
1508
1509 signtool.sign(os.fspath(unsigned_output), os.fspath(opts.output), opts)
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
1515 print(f'Wrote {"signed" if sign_args_present else "unsigned"} {opts.output}', file=sys.stderr)
1516 if opts.policy_digest:
1517 print(combined_sigs)
1518
1519
1520@contextlib.contextmanager
1521def temporary_umask(mask: int) -> Iterator[None]:
1522 # Drop <mask> bits from umask
1523 old = os.umask(0)
1524 os.umask(old | mask)
1525 try:
1526 yield
1527 finally:
1528 os.umask(old)
1529
1530
1531def generate_key_cert_pair(
1532 common_name: str,
1533 valid_days: int,
1534 keylength: int = 2048,
1535) -> tuple[bytes, bytes]:
1536 from cryptography import x509
1537 from cryptography.hazmat.primitives import hashes, serialization
1538 from cryptography.hazmat.primitives.asymmetric import rsa
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
1544 now = datetime.datetime.now(datetime.timezone.utc)
1545
1546 key = rsa.generate_private_key(
1547 public_exponent=65537,
1548 key_size=keylength,
1549 )
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 )
1578 )
1579
1580 cert_pem = cert.public_bytes(
1581 encoding=serialization.Encoding.PEM,
1582 )
1583 key_pem = key.private_bytes(
1584 encoding=serialization.Encoding.PEM,
1585 format=serialization.PrivateFormat.TraditionalOpenSSL,
1586 encryption_algorithm=serialization.NoEncryption(),
1587 )
1588
1589 return key_pem, cert_pem
1590
1591
1592def generate_priv_pub_key_pair(keylength: int = 2048) -> tuple[bytes, bytes]:
1593 from cryptography.hazmat.primitives import serialization
1594 from cryptography.hazmat.primitives.asymmetric import rsa
1595
1596 key = rsa.generate_private_key(
1597 public_exponent=65537,
1598 key_size=keylength,
1599 )
1600 priv_key_pem = key.private_bytes(
1601 encoding=serialization.Encoding.PEM,
1602 format=serialization.PrivateFormat.TraditionalOpenSSL,
1603 encryption_algorithm=serialization.NoEncryption(),
1604 )
1605 pub_key_pem = key.public_key().public_bytes(
1606 encoding=serialization.Encoding.PEM,
1607 format=serialization.PublicFormat.SubjectPublicKeyInfo,
1608 )
1609
1610 return priv_key_pem, pub_key_pem
1611
1612
1613def generate_keys(opts: UkifyConfig) -> None:
1614 work = False
1615
1616 # This will generate keys and certificates and write them to the paths that
1617 # are specified as input paths.
1618 if opts.sb_key and opts.sb_cert:
1619 fqdn = socket.getfqdn()
1620
1621 cn = f'SecureBoot signing key on host {fqdn}'
1622 if len(cn) > 64:
1623 # The length of CN must not exceed 64 bytes
1624 cn = cn[:61] + '...'
1625
1626 key_pem, cert_pem = generate_key_cert_pair(
1627 common_name=cn,
1628 valid_days=opts.sb_cert_validity,
1629 )
1630 print(f'Writing SecureBoot private key to {opts.sb_key}', file=sys.stderr)
1631 with temporary_umask(0o077):
1632 Path(opts.sb_key).write_bytes(key_pem)
1633 print(f'Writing SecureBoot certificate to {opts.sb_cert}', file=sys.stderr)
1634 Path(opts.sb_cert).write_bytes(cert_pem)
1635
1636 work = True
1637
1638 for priv_key, pub_key, _, _ in key_path_groups(opts):
1639 priv_key_pem, pub_key_pem = generate_priv_pub_key_pair()
1640
1641 print(f'Writing private key for PCR signing to {priv_key}', file=sys.stderr)
1642 with temporary_umask(0o077):
1643 Path(priv_key).write_bytes(priv_key_pem)
1644 if pub_key:
1645 print(f'Writing public key for PCR signing to {pub_key}', file=sys.stderr)
1646 Path(pub_key).write_bytes(pub_key_pem)
1647
1648 work = True
1649
1650 if not work:
1651 raise ValueError(
1652 'genkey: --secureboot-private-key=/--secureboot-certificate= or --pcr-private-key/--pcr-public-key must be specified' # noqa: E501
1653 )
1654
1655
1656def inspect_section(
1657 opts: UkifyConfig,
1658 section: pefile.SectionStructure,
1659) -> tuple[str, Optional[dict[str, Union[int, str]]]]:
1660 name = pe_strip_section_name(section.Name)
1661
1662 # find the config for this section in opts and whether to show it
1663 config = opts.sections_by_name.get(name, None)
1664 show = config or opts.all or (name in DEFAULT_SECTIONS_TO_SHOW and not opts.sections)
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
1670 size = pe_section_size(section)
1671 data = section.get_data(length=size)
1672 digest = sha256(data).hexdigest()
1673
1674 struct: dict[str, Union[int, str]] = {
1675 'size': size,
1676 'sha256': digest,
1677 }
1678
1679 if ttype == 'text':
1680 try:
1681 struct['text'] = data.decode()
1682 except UnicodeDecodeError as e:
1683 print(f'Section {name!r} is not valid text: {e}', file=sys.stderr)
1684 struct['text'] = '(not valid UTF-8)'
1685
1686 if config and config.content:
1687 assert isinstance(config.content, Path)
1688 config.content.write_bytes(data)
1689
1690 if opts.json == 'off':
1691 print(f'{name}:\n size: {size} bytes\n sha256: {digest}')
1692 if ttype == 'text':
1693 text = textwrap.indent(cast(str, struct['text']).rstrip(), ' ' * 4)
1694 print(f' text:\n{text}')
1695
1696 return name, struct
1697
1698
1699def inspect_sections(opts: UkifyConfig) -> None:
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)
1705 descs = {key: val for (key, val) in gen if val}
1706 if opts.json != 'off':
1707 json.dump(descs, sys.stdout, indent=indent)
1708
1709
1710@dataclasses.dataclass(frozen=True)
1711class ConfigItem:
1712 @staticmethod
1713 def config_list_prepend(
1714 namespace: argparse.Namespace,
1715 group: Optional[str],
1716 dest: str,
1717 value: Any,
1718 ) -> None:
1719 "Prepend value to namespace.<dest>"
1720
1721 assert not group
1722
1723 old = getattr(namespace, dest, [])
1724 if old is None:
1725 old = []
1726 setattr(namespace, dest, value + old)
1727
1728 @staticmethod
1729 def config_set_if_unset(
1730 namespace: argparse.Namespace,
1731 group: Optional[str],
1732 dest: str,
1733 value: Any,
1734 ) -> None:
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
1742 @staticmethod
1743 def config_set(
1744 namespace: argparse.Namespace,
1745 group: Optional[str],
1746 dest: str,
1747 value: Any,
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
1755 @staticmethod
1756 def config_set_group(
1757 namespace: argparse.Namespace,
1758 group: Optional[str],
1759 dest: str,
1760 value: Any,
1761 ) -> None:
1762 "Set namespace.<dest>[idx] to value, with idx derived from group"
1763
1764 # pylint: disable=protected-access
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 = []
1772 setattr(
1773 namespace,
1774 dest,
1775 old + ([None] * (idx - len(old))) + [value],
1776 )
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()
1789 name: Union[str, tuple[str, str]]
1790 dest: Optional[str] = None
1791 metavar: Optional[str] = None
1792 type: Optional[Callable[[str], Any]] = None
1793 nargs: Optional[str] = None
1794 action: Optional[Union[str, Callable[[str], Any], builtins.type[argparse.Action]]] = None
1795 default: Any = None
1796 version: Optional[str] = None
1797 choices: Optional[tuple[str, ...]] = None
1798 const: Optional[Any] = None
1799 help: Optional[str] = None
1800
1801 # metadata for config file parsing
1802 config_key: Optional[str] = None
1803 config_push: Callable[[argparse.Namespace, Optional[str], str, Any], None] = config_set_if_unset
1804
1805 def _names(self) -> tuple[str, ...]:
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
1814 def add_to(self, parser: argparse.ArgumentParser) -> None:
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 }
1820 args = self._names()
1821 parser.add_argument(*args, **kwargs)
1822
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:
1831 assert f'{section}/{key}' == self.config_key
1832 dest = self.argparse_dest()
1833
1834 conv: Callable[[str], Any]
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:
1843 conv = lambda s: s # noqa: E731
1844
1845 # This is a bit ugly, but --initrd and --devicetree-auto are the only options
1846 # with multiple args on the command line and a space-separated list in the
1847 # config file.
1848 if self.name in ['--initrd', '--devicetree-auto']:
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
1855 def config_example(self) -> tuple[Optional[str], Optional[str], Optional[str]]:
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
1868VERBS = ('build', 'genkey', 'inspect')
1869
1870CONFIG_ITEMS = [
1871 ConfigItem(
1872 'positional',
1873 metavar='VERB',
1874 nargs='*',
1875 help=argparse.SUPPRESS,
1876 ),
1877 ConfigItem(
1878 '--version',
1879 action='version',
1880 version=f'ukify {__version__}',
1881 ),
1882 ConfigItem(
1883 '--summary',
1884 help='print parsed config and exit',
1885 action='store_true',
1886 ),
1887 ConfigItem(
1888 ('--config', '-c'),
1889 metavar='PATH',
1890 type=Path,
1891 help='configuration file',
1892 ),
1893 ConfigItem(
1894 '--linux',
1895 type=Path,
1896 help='vmlinuz file [.linux section]',
1897 config_key='UKI/Linux',
1898 ),
1899 ConfigItem(
1900 '--os-release',
1901 metavar='TEXT|@PATH',
1902 help='path to os-release file [.osrel section]',
1903 config_key='UKI/OSRelease',
1904 ),
1905 ConfigItem(
1906 '--cmdline',
1907 metavar='TEXT|@PATH',
1908 help='kernel command line [.cmdline section]',
1909 config_key='UKI/Cmdline',
1910 ),
1911 ConfigItem(
1912 '--initrd',
1913 metavar='INITRD',
1914 type=Path,
1915 action='append',
1916 help='initrd file [part of .initrd section]',
1917 config_key='UKI/Initrd',
1918 config_push=ConfigItem.config_list_prepend,
1919 ),
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 ),
1930 ConfigItem(
1931 '--microcode',
1932 metavar='UCODE',
1933 type=Path,
1934 help='microcode file [.ucode section]',
1935 config_key='UKI/Microcode',
1936 ),
1937 ConfigItem(
1938 '--splash',
1939 metavar='BMP',
1940 type=Path,
1941 help='splash image bitmap file [.splash section]',
1942 config_key='UKI/Splash',
1943 ),
1944 ConfigItem(
1945 '--devicetree',
1946 metavar='PATH',
1947 type=Path,
1948 help='Device Tree file [.dtb section]',
1949 config_key='UKI/DeviceTree',
1950 ),
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 ),
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 ),
1968 ConfigItem(
1969 '--uname',
1970 metavar='VERSION',
1971 help='"uname -r" information [.uname section]',
1972 config_key='UKI/Uname',
1973 ),
1974 ConfigItem(
1975 '--sbat',
1976 metavar='TEXT|@PATH',
1977 help='SBAT policy [.sbat section]',
1978 default=[],
1979 action='append',
1980 config_key='UKI/SBAT',
1981 ),
1982 ConfigItem(
1983 '--pcrpkey',
1984 metavar='KEY',
1985 type=Path,
1986 help='embedded public key to seal secrets to [.pcrpkey section]',
1987 config_key='UKI/PCRPKey',
1988 ),
1989 ConfigItem(
1990 '--section',
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',
1996 ),
1997 ConfigItem(
1998 '--profile',
1999 metavar='TEST|@PATH',
2000 help='Profile information [.profile section]',
2001 config_key='UKI/Profile',
2002 ),
2003 ConfigItem(
2004 '--join-profile',
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',
2010 ),
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 ),
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 ),
2030 ConfigItem(
2031 '--efi-arch',
2032 metavar='ARCH',
2033 choices=('ia32', 'x64', 'arm', 'aa64', 'riscv32', 'riscv64', 'loongarch32', 'loongarch64'),
2034 help='target EFI architecture',
2035 config_key='UKI/EFIArch',
2036 ),
2037 ConfigItem(
2038 '--stub',
2039 type=Path,
2040 help='path to the sd-stub file [.text,.data,… sections]',
2041 config_key='UKI/Stub',
2042 ),
2043 ConfigItem(
2044 '--pcr-banks',
2045 metavar='BANK…',
2046 type=parse_banks,
2047 config_key='UKI/PCRBanks',
2048 ),
2049 ConfigItem(
2050 '--signing-engine',
2051 metavar='ENGINE',
2052 help='OpenSSL engine to use for signing',
2053 config_key='UKI/SigningEngine',
2054 ),
2055 ConfigItem(
2056 '--signing-provider',
2057 metavar='PROVIDER',
2058 help='OpenSSL provider to use for signing',
2059 config_key='UKI/SigningProvider',
2060 ),
2061 ConfigItem(
2062 '--certificate-provider',
2063 metavar='PROVIDER',
2064 help='OpenSSL provider to load certificate from',
2065 config_key='UKI/CertificateProvider',
2066 ),
2067 ConfigItem(
2068 '--signtool',
2069 choices=('sbsign', 'pesign', 'systemd-sbsign'),
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',
2077 ),
2078 ConfigItem(
2079 '--secureboot-private-key',
2080 dest='sb_key',
2081 help='required by --signtool=sbsign|systemd-sbsign. Path to key file or engine/provider designation for SB signing', # noqa: E501
2082 config_key='UKI/SecureBootPrivateKey',
2083 ),
2084 ConfigItem(
2085 '--secureboot-certificate',
2086 dest='sb_cert',
2087 help=(
2088 'required by --signtool=sbsign. sbsign needs a path to certificate file or engine-specific designation for SB signing' # noqa: E501
2089 ),
2090 config_key='UKI/SecureBootCertificate',
2091 ),
2092 ConfigItem(
2093 '--secureboot-certificate-dir',
2094 dest='sb_certdir',
2095 default='/etc/pki/pesign',
2096 help=(
2097 'required by --signtool=pesign. Path to nss certificate database directory for PE signing. Default is /etc/pki/pesign' # noqa: E501
2098 ),
2099 config_key='UKI/SecureBootCertificateDir',
2100 config_push=ConfigItem.config_set,
2101 ),
2102 ConfigItem(
2103 '--secureboot-certificate-name',
2104 dest='sb_cert_name',
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 ),
2108 config_key='UKI/SecureBootCertificateName',
2109 ),
2110 ConfigItem(
2111 '--secureboot-certificate-validity',
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,
2119 ),
2120 ConfigItem(
2121 '--sign-kernel',
2122 action=argparse.BooleanOptionalAction,
2123 help='Sign the embedded kernel',
2124 config_key='UKI/SignKernel',
2125 ),
2126 ConfigItem(
2127 '--pcr-private-key',
2128 dest='pcr_private_keys',
2129 action='append',
2130 help='private part of the keypair or engine/provider designation for signing PCR signatures',
2131 config_key='PCRSignature:/PCRPrivateKey',
2132 config_push=ConfigItem.config_set_group,
2133 ),
2134 ConfigItem(
2135 '--pcr-public-key',
2136 dest='pcr_public_keys',
2137 metavar='PATH',
2138 action='append',
2139 help='public part of the keypair or engine/provider designation for signing PCR signatures',
2140 config_key='PCRSignature:/PCRPublicKey',
2141 config_push=ConfigItem.config_set_group,
2142 ),
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 ),
2152 ConfigItem(
2153 '--phases',
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,
2161 ),
2162 ConfigItem(
2163 '--tools',
2164 type=Path,
2165 action='append',
2166 help='Directories to search for tools (systemd-measure, …)',
2167 ),
2168 ConfigItem(
2169 ('--output', '-o'),
2170 type=Path,
2171 help='output file path',
2172 ),
2173 ConfigItem(
2174 '--measure',
2175 action=argparse.BooleanOptionalAction,
2176 help='print systemd-measure output for the UKI',
2177 ),
2178 ConfigItem(
2179 '--policy-digest',
2180 action=argparse.BooleanOptionalAction,
2181 help='print systemd-measure policy digests for the UKI',
2182 ),
2183 ConfigItem(
2184 '--json',
2185 choices=('pretty', 'short', 'off'),
2186 default='off',
2187 help='generate JSON output',
2188 ),
2189 ConfigItem(
2190 '-j',
2191 dest='json',
2192 action='store_const',
2193 const='pretty',
2194 help='equivalent to --json=pretty',
2195 ),
2196 ConfigItem(
2197 '--all',
2198 help='print all sections',
2199 action='store_true',
2200 ),
2201]
2202
2203CONFIGFILE_ITEMS = {item.config_key: item for item in CONFIG_ITEMS if item.config_key}
2204
2205
2206def apply_config(namespace: argparse.Namespace, filename: Union[str, Path, None] = None) -> None:
2207 if filename is None:
2208 if namespace.config:
2209 # Config set by the user, use that.
2210 filename = namespace.config
2211 print(f'Using config file: {filename}', file=sys.stderr)
2212 else:
2213 # Try to look for a config file then use the first one found.
2214 for config_dir in DEFAULT_CONFIG_DIRS:
2215 filename = Path(config_dir) / DEFAULT_CONFIG_FILE
2216 if filename.is_file():
2217 # Found a config file, use it.
2218 print(f'Using found config file: {filename}', file=sys.stderr)
2219 break
2220 else:
2221 # No config file specified or found, nothing to do.
2222 return
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 ())
2227 namespace._groups = list(range(n_pcr_priv)) # pylint: disable=protected-access
2228
2229 cp = configparser.ConfigParser(
2230 comment_prefixes='#',
2231 inline_comment_prefixes='#',
2232 delimiters='=',
2233 empty_lines_in_values=False,
2234 interpolation=None,
2235 strict=False,
2236 )
2237 # Do not make keys lowercase
2238 cp.optionxform = lambda option: option # type: ignore
2239
2240 # The API is not great.
2241 read = cp.read(filename)
2242 if not read:
2243 raise OSError(f'Failed to read {filename}')
2244
2245 for section_name, section in cp.items():
2246 idx = section_name.find(':')
2247 if idx >= 0:
2248 section_name, group = section_name[: idx + 1], section_name[idx + 1 :]
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:
2259 print(f'Unknown config setting [{section_name}] {key}=', file=sys.stderr)
2260
2261
2262def config_example() -> Iterator[str]:
2263 prev_section: Optional[str] = None
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
2275class PagerHelpAction(argparse._HelpAction): # pylint: disable=protected-access
2276 def __call__(
2277 self,
2278 parser: argparse.ArgumentParser,
2279 namespace: argparse.Namespace,
2280 values: Union[str, Sequence[Any], None] = None,
2281 option_string: Optional[str] = None,
2282 ) -> None:
2283 page(parser.format_help(), True)
2284 parser.exit()
2285
2286
2287def create_parser() -> argparse.ArgumentParser:
2288 p = argparse.ArgumentParser(
2289 description='Build and sign Unified Kernel Images',
2290 usage='\n '
2291 + textwrap.dedent("""\
2292 ukify {b}build{e} [--linux=LINUX] [--initrd=INITRD] [options…]
2293 ukify {b}genkey{e} [options…]
2294 ukify {b}inspect{e} FILE… [options…]
2295 """).format(b=Style.bold, e=Style.reset),
2296 allow_abbrev=False,
2297 add_help=False,
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)
2304
2305 # Suppress printing of usage synopsis on errors
2306 p.error = lambda message: p.exit(2, f'{p.prog}: error: {message}\n') # type: ignore
2307
2308 # Make --help paged
2309 p.add_argument(
2310 '-h', '--help',
2311 action=PagerHelpAction,
2312 help='show this help message and exit',
2313 ) # fmt: skip
2314
2315 return p
2316
2317
2318def resolve_at_path(value: Optional[str]) -> Union[Path, str, None]:
2319 if value and value.startswith('@'):
2320 return Path(value[1:])
2321
2322 return value
2323
2324
2325def finalize_options(opts: argparse.Namespace) -> None:
2326 # Figure out which syntax is being used, one of:
2327 # ukify verb --arg --arg --arg
2328 # ukify linux initrd…
2329 if len(opts.positional) >= 1 and opts.positional[0] == 'inspect':
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:
2338 opts.verb = opts.positional[0]
2339 elif opts.linux or opts.initrd:
2340 raise ValueError('--linux=/--initrd= options cannot be used with positional arguments')
2341 else:
2342 print("Assuming obsolete command line syntax with no verb. Please use 'build'.", file=sys.stderr)
2343 if opts.positional:
2344 opts.linux = Path(opts.positional[0])
2345 # If we have initrds from parsing config files, append our positional args at the end
2346 opts.initrd = (opts.initrd or []) + [Path(arg) for arg in opts.positional[1:]]
2347 opts.verb = 'build'
2348
2349 # Check that --pcr-public-key=, --pcr-private-key=, and --phases=
2350 # have either the same number of arguments or are not specified at all.
2351 # Also check that --pcr-public-key= and --pcr-certificate= are not set at the same time.
2352 # But allow a single public key, for offline PCR signing, to pre-populate the JSON object
2353 # with the certificate's fingerprint.
2354 n_pcr_cert = None if opts.pcr_certificates is None else len(opts.pcr_certificates)
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)
2358 if opts.policy_digest and n_pcr_priv is not None:
2359 raise ValueError('--pcr-private-key= cannot be specified with --policy-digest')
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=')
2366 if n_pcr_pub is not None and n_pcr_priv is not None and n_pcr_pub != n_pcr_priv:
2367 raise ValueError('--pcr-public-key= specifications must match --pcr-private-key=')
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')
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
2375 opts.cmdline = resolve_at_path(opts.cmdline)
2376
2377 if isinstance(opts.cmdline, str):
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
2380 # or in the config file may contain additional whitespace that has no meaning.
2381 opts.cmdline = ' '.join(opts.cmdline.split())
2382
2383 opts.os_release = resolve_at_path(opts.os_release)
2384
2385 if not opts.os_release and opts.linux:
2386 p = Path('/etc/os-release')
2387 if not p.exists():
2388 p = Path('/usr/lib/os-release')
2389 opts.os_release = p
2390
2391 if opts.efi_arch is None:
2392 opts.efi_arch = guess_efi_arch()
2393
2394 if opts.stub is None and not opts.join_pcrsig:
2395 if opts.linux is not None:
2396 opts.stub = Path(f'/usr/lib/systemd/boot/efi/linux{opts.efi_arch}.efi.stub')
2397 else:
2398 opts.stub = Path(f'/usr/lib/systemd/boot/efi/addon{opts.efi_arch}.efi.stub')
2399
2400 if opts.signing_engine and opts.signing_provider:
2401 raise ValueError('Only one of --signing-engine= and --signing-provider= may be specified')
2402
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)
2408
2409 if bool(opts.sb_key) ^ bool(opts.sb_cert):
2410 # one param only given, sbsign needs both
2411 raise ValueError(
2412 '--secureboot-private-key= and --secureboot-certificate= must be specified together'
2413 )
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
2416 if opts.signtool and opts.signtool not in ('sbsign', 'systemd-sbsign'):
2417 raise ValueError(
2418 f'Cannot provide --signtool={opts.signtool} with --secureboot-private-key= and --secureboot-certificate=' # noqa: E501
2419 )
2420 if not opts.signtool:
2421 opts.signtool = 'sbsign'
2422 elif bool(opts.sb_cert_name):
2423 # sb_cert_name given, infer pesign and in case it was given, ensure signtool=pesign
2424 if opts.signtool and opts.signtool != 'pesign':
2425 raise ValueError(
2426 f'Cannot provide --signtool={opts.signtool} with --secureboot-certificate-name='
2427 )
2428 opts.signtool = 'pesign'
2429
2430 if opts.signing_provider and opts.signtool != 'systemd-sbsign':
2431 raise ValueError('--signing-provider= can only be used with --signtool=systemd-sbsign')
2432
2433 if opts.certificate_provider and opts.signtool != 'systemd-sbsign':
2434 raise ValueError('--certificate-provider= can only be used with --signtool=systemd-sbsign')
2435
2436 if opts.sign_kernel and not opts.sb_key and not opts.sb_cert_name:
2437 raise ValueError(
2438 '--sign-kernel requires either --secureboot-private-key= and --secureboot-certificate= (for sbsign) or --secureboot-certificate-name= (for pesign) to be specified' # noqa: E501
2439 )
2440
2441 opts.profile = resolve_at_path(opts.profile)
2442 if opts.profile and isinstance(opts.profile, Path):
2443 opts.profile = opts.profile.read_text()
2444
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
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
2470 or opts.pcr_certificates
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
2478 if opts.verb == 'build' and opts.output is None:
2479 if opts.linux is None:
2480 raise ValueError('--output= must be specified when building a PE addon')
2481 suffix = '.efi' if opts.sb_key or opts.sb_cert_name else '.unsigned.efi'
2482 opts.output = opts.linux.name + suffix
2483
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
2488 opts.sections_by_name = {s.name: s for s in opts.sections}
2489
2490
2491def parse_args(args: Optional[list[str]] = None) -> argparse.Namespace:
2492 opts = create_parser().parse_args(args)
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
2499 apply_config(opts)
2500 finalize_options(opts)
2501 return opts
2502
2503
2504def main() -> None:
2505 opts = UkifyConfig.from_namespace(parse_args())
2506 if opts.summary:
2507 # TODO: replace pprint() with some fancy formatting.
2508 pprint.pprint(vars(opts))
2509 elif opts.verb == 'build':
2510 check_inputs(opts)
2511 make_uki(opts)
2512 elif opts.verb == 'genkey':
2513 check_cert_and_keys_nonexistent(opts)
2514 generate_keys(opts)
2515 elif opts.verb == 'inspect':
2516 inspect_sections(opts)
2517 else:
2518 assert False
2519
2520
2521if __name__ == '__main__':
2522 main()