]> git.ipfire.org Git - thirdparty/systemd.git/blob - src/ukify/ukify.py
2ab9c5894ce4fd6a2d5dc413f6f43fd1d2c38c5e
[thirdparty/systemd.git] / src / ukify / ukify.py
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
22 import argparse
23 import configparser
24 import contextlib
25 import collections
26 import dataclasses
27 import datetime
28 import fnmatch
29 import itertools
30 import json
31 import os
32 import pathlib
33 import pprint
34 import pydoc
35 import re
36 import shlex
37 import shutil
38 import socket
39 import subprocess
40 import sys
41 import tempfile
42 import textwrap
43 from hashlib import sha256
44 from typing import (Any,
45 Callable,
46 IO,
47 Optional,
48 Sequence,
49 Union)
50
51 import pefile # type: ignore
52
53 __version__ = '{{PROJECT_VERSION}} ({{GIT_VERSION}})'
54
55 EFI_ARCH_MAP = {
56 # host_arch glob : [efi_arch, 32_bit_efi_arch if mixed mode is supported]
57 'x86_64' : ['x64', 'ia32'],
58 'i[3456]86' : ['ia32'],
59 'aarch64' : ['aa64'],
60 'armv[45678]*l': ['arm'],
61 'loongarch32' : ['loongarch32'],
62 'loongarch64' : ['loongarch64'],
63 'riscv32' : ['riscv32'],
64 'riscv64' : ['riscv64'],
65 }
66 EFI_ARCHES: list[str] = sum(EFI_ARCH_MAP.values(), [])
67
68 # Default configuration directories and file name.
69 # When the user does not specify one, the directories are searched in this order and the first file found is used.
70 DEFAULT_CONFIG_DIRS = ['/run/systemd', '/etc/systemd', '/usr/local/lib/systemd', '/usr/lib/systemd']
71 DEFAULT_CONFIG_FILE = 'ukify.conf'
72
73 class Style:
74 bold = "\033[0;1;39m" if sys.stderr.isatty() else ""
75 gray = "\033[0;38;5;245m" if sys.stderr.isatty() else ""
76 red = "\033[31;1m" if sys.stderr.isatty() else ""
77 yellow = "\033[33;1m" if sys.stderr.isatty() else ""
78 reset = "\033[0m" if sys.stderr.isatty() else ""
79
80
81 def guess_efi_arch():
82 arch = os.uname().machine
83
84 for glob, mapping in EFI_ARCH_MAP.items():
85 if fnmatch.fnmatch(arch, glob):
86 efi_arch, *fallback = mapping
87 break
88 else:
89 raise ValueError(f'Unsupported architecture {arch}')
90
91 # This makes sense only on some architectures, but it also probably doesn't
92 # hurt on others, so let's just apply the check everywhere.
93 if fallback:
94 fw_platform_size = pathlib.Path('/sys/firmware/efi/fw_platform_size')
95 try:
96 size = fw_platform_size.read_text().strip()
97 except FileNotFoundError:
98 pass
99 else:
100 if int(size) == 32:
101 efi_arch = fallback[0]
102
103 # print(f'Host arch {arch!r}, EFI arch {efi_arch!r}')
104 return efi_arch
105
106
107 def page(text: str, enabled: Optional[bool]) -> None:
108 if enabled:
109 # Initialize less options from $SYSTEMD_LESS or provide a suitable fallback.
110 os.environ['LESS'] = os.getenv('SYSTEMD_LESS', 'FRSXMK')
111 pydoc.pager(text)
112 else:
113 print(text)
114
115
116 def shell_join(cmd):
117 # TODO: drop in favour of shlex.join once shlex.join supports pathlib.Path.
118 return ' '.join(shlex.quote(str(x)) for x in cmd)
119
120
121 def round_up(x, blocksize=4096):
122 return (x + blocksize - 1) // blocksize * blocksize
123
124
125 def try_import(modname, name=None):
126 try:
127 return __import__(modname)
128 except ImportError as e:
129 raise ValueError(f'Kernel is compressed with {name or modname}, but module unavailable') from e
130
131
132 def maybe_decompress(filename):
133 """Decompress file if compressed. Return contents."""
134 f = open(filename, 'rb')
135 start = f.read(4)
136 f.seek(0)
137
138 if start.startswith(b'\x7fELF'):
139 # not compressed
140 return f.read()
141
142 if start.startswith(b'MZ'):
143 # not compressed aarch64 and riscv64
144 return f.read()
145
146 if start.startswith(b'\x1f\x8b'):
147 gzip = try_import('gzip')
148 return gzip.open(f).read()
149
150 if start.startswith(b'\x28\xb5\x2f\xfd'):
151 zstd = try_import('zstd')
152 return zstd.uncompress(f.read())
153
154 if start.startswith(b'\x02\x21\x4c\x18'):
155 lz4 = try_import('lz4.frame', 'lz4')
156 return lz4.frame.decompress(f.read())
157
158 if start.startswith(b'\x04\x22\x4d\x18'):
159 print('Newer lz4 stream format detected! This may not boot!')
160 lz4 = try_import('lz4.frame', 'lz4')
161 return lz4.frame.decompress(f.read())
162
163 if start.startswith(b'\x89LZO'):
164 # python3-lzo is not packaged for Fedora
165 raise NotImplementedError('lzo decompression not implemented')
166
167 if start.startswith(b'BZh'):
168 bz2 = try_import('bz2', 'bzip2')
169 return bz2.open(f).read()
170
171 if start.startswith(b'\x5d\x00\x00'):
172 lzma = try_import('lzma')
173 return lzma.open(f).read()
174
175 raise NotImplementedError(f'unknown file format (starts with {start})')
176
177
178 class Uname:
179 # This class is here purely as a namespace for the functions
180
181 VERSION_PATTERN = r'(?P<version>[a-z0-9._-]+) \([^ )]+\) (?:#.*)'
182
183 NOTES_PATTERN = r'^\s+Linux\s+0x[0-9a-f]+\s+OPEN\n\s+description data: (?P<version>[0-9a-f ]+)\s*$'
184
185 # Linux version 6.0.8-300.fc37.ppc64le (mockbuild@buildvm-ppc64le-03.iad2.fedoraproject.org)
186 # (gcc (GCC) 12.2.1 20220819 (Red Hat 12.2.1-2), GNU ld version 2.38-24.fc37)
187 # #1 SMP Fri Nov 11 14:39:11 UTC 2022
188 TEXT_PATTERN = rb'Linux version (?P<version>\d\.\S+) \('
189
190 @classmethod
191 def scrape_x86(cls, filename, opts=None):
192 # Based on https://gitlab.archlinux.org/archlinux/mkinitcpio/mkinitcpio/-/blob/master/functions#L136
193 # and https://www.kernel.org/doc/html/latest/x86/boot.html#the-real-mode-kernel-header
194 with open(filename, 'rb') as f:
195 f.seek(0x202)
196 magic = f.read(4)
197 if magic != b'HdrS':
198 raise ValueError('Real-Mode Kernel Header magic not found')
199 f.seek(0x20E)
200 offset = f.read(1)[0] + f.read(1)[0]*256 # Pointer to kernel version string
201 f.seek(0x200 + offset)
202 text = f.read(128)
203 text = text.split(b'\0', maxsplit=1)[0]
204 text = text.decode()
205
206 if not (m := re.match(cls.VERSION_PATTERN, text)):
207 raise ValueError(f'Cannot parse version-host-release uname string: {text!r}')
208 return m.group('version')
209
210 @classmethod
211 def scrape_elf(cls, filename, opts=None):
212 readelf = find_tool('readelf', opts=opts)
213
214 cmd = [
215 readelf,
216 '--notes',
217 filename,
218 ]
219
220 print('+', shell_join(cmd))
221 try:
222 notes = subprocess.check_output(cmd, stderr=subprocess.PIPE, text=True)
223 except subprocess.CalledProcessError as e:
224 raise ValueError(e.stderr.strip()) from e
225
226 if not (m := re.search(cls.NOTES_PATTERN, notes, re.MULTILINE)):
227 raise ValueError('Cannot find Linux version note')
228
229 text = ''.join(chr(int(c, 16)) for c in m.group('version').split())
230 return text.rstrip('\0')
231
232 @classmethod
233 def scrape_generic(cls, filename, opts=None):
234 # import libarchive
235 # libarchive-c fails with
236 # ArchiveError: Unrecognized archive format (errno=84, retcode=-30, archive_p=94705420454656)
237
238 # Based on https://gitlab.archlinux.org/archlinux/mkinitcpio/mkinitcpio/-/blob/master/functions#L209
239
240 text = maybe_decompress(filename)
241 if not (m := re.search(cls.TEXT_PATTERN, text)):
242 raise ValueError(f'Cannot find {cls.TEXT_PATTERN!r} in {filename}')
243
244 return m.group('version').decode()
245
246 @classmethod
247 def scrape(cls, filename, opts=None):
248 for func in (cls.scrape_x86, cls.scrape_elf, cls.scrape_generic):
249 try:
250 version = func(filename, opts=opts)
251 print(f'Found uname version: {version}')
252 return version
253 except ValueError as e:
254 print(str(e))
255 return None
256
257 DEFAULT_SECTIONS_TO_SHOW = {
258 '.linux' : 'binary',
259 '.initrd' : 'binary',
260 '.splash' : 'binary',
261 '.dtb' : 'binary',
262 '.cmdline' : 'text',
263 '.osrel' : 'text',
264 '.uname' : 'text',
265 '.pcrpkey' : 'text',
266 '.pcrsig' : 'text',
267 '.sbat' : 'text',
268 '.sbom' : 'text',
269 }
270
271 @dataclasses.dataclass
272 class Section:
273 name: str
274 content: Optional[pathlib.Path]
275 tmpfile: Optional[IO] = None
276 measure: bool = False
277 output_mode: Optional[str] = None
278
279 @classmethod
280 def create(cls, name, contents, **kwargs):
281 if isinstance(contents, (str, bytes)):
282 mode = 'wt' if isinstance(contents, str) else 'wb'
283 tmp = tempfile.NamedTemporaryFile(mode=mode, prefix=f'tmp{name}')
284 tmp.write(contents)
285 tmp.flush()
286 contents = pathlib.Path(tmp.name)
287 else:
288 tmp = None
289
290 return cls(name, contents, tmpfile=tmp, **kwargs)
291
292 @classmethod
293 def parse_input(cls, s):
294 try:
295 name, contents, *rest = s.split(':')
296 except ValueError as e:
297 raise ValueError(f'Cannot parse section spec (name or contents missing): {s!r}') from e
298 if rest:
299 raise ValueError(f'Cannot parse section spec (extraneous parameters): {s!r}')
300
301 if contents.startswith('@'):
302 contents = pathlib.Path(contents[1:])
303
304 sec = cls.create(name, contents)
305 sec.check_name()
306 return sec
307
308 @classmethod
309 def parse_output(cls, s):
310 if not (m := re.match(r'([a-zA-Z0-9_.]+):(text|binary)(?:@(.+))?', s)):
311 raise ValueError(f'Cannot parse section spec: {s!r}')
312
313 name, ttype, out = m.groups()
314 out = pathlib.Path(out) if out else None
315
316 return cls.create(name, out, output_mode=ttype)
317
318 def size(self):
319 return self.content.stat().st_size
320
321 def check_name(self):
322 # PE section names with more than 8 characters are legal, but our stub does
323 # not support them.
324 if not self.name.isascii() or not self.name.isprintable():
325 raise ValueError(f'Bad section name: {self.name!r}')
326 if len(self.name) > 8:
327 raise ValueError(f'Section name too long: {self.name!r}')
328
329
330 @dataclasses.dataclass
331 class UKI:
332 executable: list[Union[pathlib.Path, str]]
333 sections: list[Section] = dataclasses.field(default_factory=list, init=False)
334
335 def add_section(self, section):
336 if section.name in [s.name for s in self.sections]:
337 raise ValueError(f'Duplicate section {section.name}')
338
339 self.sections += [section]
340
341
342 def parse_banks(s):
343 banks = re.split(r',|\s+', s)
344 # TODO: do some sanity checking here
345 return banks
346
347
348 KNOWN_PHASES = (
349 'enter-initrd',
350 'leave-initrd',
351 'sysinit',
352 'ready',
353 'shutdown',
354 'final',
355 )
356
357 def parse_phase_paths(s):
358 # Split on commas or whitespace here. Commas might be hard to parse visually.
359 paths = re.split(r',|\s+', s)
360
361 for path in paths:
362 for phase in path.split(':'):
363 if phase not in KNOWN_PHASES:
364 raise argparse.ArgumentTypeError(f'Unknown boot phase {phase!r} ({path=})')
365
366 return paths
367
368
369 def check_splash(filename):
370 if filename is None:
371 return
372
373 # import is delayed, to avoid import when the splash image is not used
374 try:
375 from PIL import Image
376 except ImportError:
377 return
378
379 img = Image.open(filename, formats=['BMP'])
380 print(f'Splash image {filename} is {img.width}×{img.height} pixels')
381
382
383 def check_inputs(opts):
384 for name, value in vars(opts).items():
385 if name in {'output', 'tools'}:
386 continue
387
388 if isinstance(value, pathlib.Path):
389 # Open file to check that we can read it, or generate an exception
390 value.open().close()
391 elif isinstance(value, list):
392 for item in value:
393 if isinstance(item, pathlib.Path):
394 item.open().close()
395
396 check_splash(opts.splash)
397
398
399 def check_cert_and_keys_nonexistent(opts):
400 # Raise if any of the keys and certs are found on disk
401 paths = itertools.chain(
402 (opts.sb_key, opts.sb_cert),
403 *((priv_key, pub_key)
404 for priv_key, pub_key, _ in key_path_groups(opts)))
405 for path in paths:
406 if path and path.exists():
407 raise ValueError(f'{path} is present')
408
409
410 def find_tool(name, fallback=None, opts=None):
411 if opts and opts.tools:
412 for d in opts.tools:
413 tool = d / name
414 if tool.exists():
415 return tool
416
417 if shutil.which(name) is not None:
418 return name
419
420 if fallback is None:
421 print(f"Tool {name} not installed!")
422
423 return fallback
424
425 def combine_signatures(pcrsigs):
426 combined = collections.defaultdict(list)
427 for pcrsig in pcrsigs:
428 for bank, sigs in pcrsig.items():
429 for sig in sigs:
430 if sig not in combined[bank]:
431 combined[bank] += [sig]
432 return json.dumps(combined)
433
434
435 def key_path_groups(opts):
436 if not opts.pcr_private_keys:
437 return
438
439 n_priv = len(opts.pcr_private_keys)
440 pub_keys = opts.pcr_public_keys or [None] * n_priv
441 pp_groups = opts.phase_path_groups or [None] * n_priv
442
443 yield from zip(opts.pcr_private_keys,
444 pub_keys,
445 pp_groups)
446
447
448 def call_systemd_measure(uki, linux, opts):
449 measure_tool = find_tool('systemd-measure',
450 '/usr/lib/systemd/systemd-measure',
451 opts=opts)
452
453 banks = opts.pcr_banks or ()
454
455 # PCR measurement
456
457 if opts.measure:
458 pp_groups = opts.phase_path_groups or []
459
460 cmd = [
461 measure_tool,
462 'calculate',
463 f'--linux={linux}',
464 *(f"--{s.name.removeprefix('.')}={s.content}"
465 for s in uki.sections
466 if s.measure),
467 *(f'--bank={bank}'
468 for bank in banks),
469 # For measurement, the keys are not relevant, so we can lump all the phase paths
470 # into one call to systemd-measure calculate.
471 *(f'--phase={phase_path}'
472 for phase_path in itertools.chain.from_iterable(pp_groups)),
473 ]
474
475 print('+', shell_join(cmd))
476 subprocess.check_call(cmd)
477
478 # PCR signing
479
480 if opts.pcr_private_keys:
481 pcrsigs = []
482
483 cmd = [
484 measure_tool,
485 'sign',
486 f'--linux={linux}',
487 *(f"--{s.name.removeprefix('.')}={s.content}"
488 for s in uki.sections
489 if s.measure),
490 *(f'--bank={bank}'
491 for bank in banks),
492 ]
493
494 for priv_key, pub_key, group in key_path_groups(opts):
495 extra = [f'--private-key={priv_key}']
496 if pub_key:
497 extra += [f'--public-key={pub_key}']
498 extra += [f'--phase={phase_path}' for phase_path in group or ()]
499
500 print('+', shell_join(cmd + extra))
501 pcrsig = subprocess.check_output(cmd + extra, text=True)
502 pcrsig = json.loads(pcrsig)
503 pcrsigs += [pcrsig]
504
505 combined = combine_signatures(pcrsigs)
506 uki.add_section(Section.create('.pcrsig', combined))
507
508
509 def join_initrds(initrds):
510 if not initrds:
511 return None
512 if len(initrds) == 1:
513 return initrds[0]
514
515 seq = []
516 for file in initrds:
517 initrd = file.read_bytes()
518 n = len(initrd)
519 padding = b'\0' * (round_up(n, 4) - n) # pad to 32 bit alignment
520 seq += [initrd, padding]
521
522 return b''.join(seq)
523
524
525 def pairwise(iterable):
526 a, b = itertools.tee(iterable)
527 next(b, None)
528 return zip(a, b)
529
530
531 class PEError(Exception):
532 pass
533
534
535 def pe_add_sections(uki: UKI, output: str):
536 pe = pefile.PE(uki.executable, fast_load=True)
537
538 # Old stubs do not have the symbol/string table stripped, even though image files should not have one.
539 if symbol_table := pe.FILE_HEADER.PointerToSymbolTable:
540 symbol_table_size = 18 * pe.FILE_HEADER.NumberOfSymbols
541 if string_table_size := pe.get_dword_from_offset(symbol_table + symbol_table_size):
542 symbol_table_size += string_table_size
543
544 # Let's be safe and only strip it if it's at the end of the file.
545 if symbol_table + symbol_table_size == len(pe.__data__):
546 pe.__data__ = pe.__data__[:symbol_table]
547 pe.FILE_HEADER.PointerToSymbolTable = 0
548 pe.FILE_HEADER.NumberOfSymbols = 0
549 pe.FILE_HEADER.IMAGE_FILE_LOCAL_SYMS_STRIPPED = True
550
551 # Old stubs might have been stripped, leading to unaligned raw data values, so let's fix them up here.
552 # pylint thinks that Structure doesn't have various members that it has…
553 # pylint: disable=no-member
554
555 for i, section in enumerate(pe.sections):
556 oldp = section.PointerToRawData
557 oldsz = section.SizeOfRawData
558 section.PointerToRawData = round_up(oldp, pe.OPTIONAL_HEADER.FileAlignment)
559 section.SizeOfRawData = round_up(oldsz, pe.OPTIONAL_HEADER.FileAlignment)
560 padp = section.PointerToRawData - oldp
561 padsz = section.SizeOfRawData - oldsz
562
563 for later_section in pe.sections[i+1:]:
564 later_section.PointerToRawData += padp + padsz
565
566 pe.__data__ = pe.__data__[:oldp] + bytes(padp) + pe.__data__[oldp:oldp+oldsz] + bytes(padsz) + pe.__data__[oldp+oldsz:]
567
568 # We might not have any space to add new sections. Let's try our best to make some space by padding the
569 # SizeOfHeaders to a multiple of the file alignment. This is safe because the first section's data starts
570 # at a multiple of the file alignment, so all space before that is unused.
571 pe.OPTIONAL_HEADER.SizeOfHeaders = round_up(pe.OPTIONAL_HEADER.SizeOfHeaders, pe.OPTIONAL_HEADER.FileAlignment)
572 pe = pefile.PE(data=pe.write(), fast_load=True)
573
574 warnings = pe.get_warnings()
575 if warnings:
576 raise PEError(f'pefile warnings treated as errors: {warnings}')
577
578 security = pe.OPTIONAL_HEADER.DATA_DIRECTORY[pefile.DIRECTORY_ENTRY['IMAGE_DIRECTORY_ENTRY_SECURITY']]
579 if security.VirtualAddress != 0:
580 # We could strip the signatures, but why would anyone sign the stub?
581 raise PEError('Stub image is signed, refusing.')
582
583 for section in uki.sections:
584 new_section = pefile.SectionStructure(pe.__IMAGE_SECTION_HEADER_format__, pe=pe)
585 new_section.__unpack__(b'\0' * new_section.sizeof())
586
587 offset = pe.sections[-1].get_file_offset() + new_section.sizeof()
588 if offset + new_section.sizeof() > pe.OPTIONAL_HEADER.SizeOfHeaders:
589 raise PEError(f'Not enough header space to add section {section.name}.')
590
591 assert section.content
592 data = section.content.read_bytes()
593
594 new_section.set_file_offset(offset)
595 new_section.Name = section.name.encode()
596 new_section.Misc_VirtualSize = len(data)
597 # Non-stripped stubs might still have an unaligned symbol table at the end, making their size
598 # unaligned, so we make sure to explicitly pad the pointer to new sections to an aligned offset.
599 new_section.PointerToRawData = round_up(len(pe.__data__), pe.OPTIONAL_HEADER.FileAlignment)
600 new_section.SizeOfRawData = round_up(len(data), pe.OPTIONAL_HEADER.FileAlignment)
601 new_section.VirtualAddress = round_up(
602 pe.sections[-1].VirtualAddress + pe.sections[-1].Misc_VirtualSize,
603 pe.OPTIONAL_HEADER.SectionAlignment,
604 )
605
606 new_section.IMAGE_SCN_MEM_READ = True
607 if section.name == '.linux':
608 # Old kernels that use EFI handover protocol will be executed inline.
609 new_section.IMAGE_SCN_CNT_CODE = True
610 else:
611 new_section.IMAGE_SCN_CNT_INITIALIZED_DATA = True
612
613 # Special case, mostly for .sbat: the stub will already have a .sbat section, but we want to append
614 # the one from the kernel to it. It should be small enough to fit in the existing section, so just
615 # swap the data.
616 for i, s in enumerate(pe.sections):
617 if s.Name.rstrip(b"\x00").decode() == section.name:
618 if new_section.Misc_VirtualSize > s.SizeOfRawData:
619 raise PEError(f'Not enough space in existing section {section.name} to append new data.')
620
621 padding = bytes(new_section.SizeOfRawData - new_section.Misc_VirtualSize)
622 pe.__data__ = pe.__data__[:s.PointerToRawData] + data + padding + pe.__data__[pe.sections[i+1].PointerToRawData:]
623 s.SizeOfRawData = new_section.SizeOfRawData
624 s.Misc_VirtualSize = new_section.Misc_VirtualSize
625 break
626 else:
627 pe.__data__ = pe.__data__[:] + bytes(new_section.PointerToRawData - len(pe.__data__)) + data + bytes(new_section.SizeOfRawData - len(data))
628
629 pe.FILE_HEADER.NumberOfSections += 1
630 pe.OPTIONAL_HEADER.SizeOfInitializedData += new_section.Misc_VirtualSize
631 pe.__structures__.append(new_section)
632 pe.sections.append(new_section)
633
634 pe.OPTIONAL_HEADER.CheckSum = 0
635 pe.OPTIONAL_HEADER.SizeOfImage = round_up(
636 pe.sections[-1].VirtualAddress + pe.sections[-1].Misc_VirtualSize,
637 pe.OPTIONAL_HEADER.SectionAlignment,
638 )
639
640 pe.write(output)
641
642 def merge_sbat(input_pe: [pathlib.Path], input_text: [str]) -> str:
643 sbat = []
644
645 for f in input_pe:
646 try:
647 pe = pefile.PE(f, fast_load=True)
648 except pefile.PEFormatError:
649 print(f"{f} is not a valid PE file, not extracting SBAT section.")
650 continue
651
652 for section in pe.sections:
653 if section.Name.rstrip(b"\x00").decode() == ".sbat":
654 split = section.get_data().rstrip(b"\x00").decode().splitlines()
655 if not split[0].startswith('sbat,'):
656 print(f"{f} does not contain a valid SBAT section, skipping.")
657 continue
658 # Filter out the sbat line, we'll add it back later, there needs to be only one and it
659 # needs to be first.
660 sbat += split[1:]
661
662 for t in input_text:
663 if t.startswith('@'):
664 t = pathlib.Path(t[1:]).read_text()
665 split = t.splitlines()
666 if not split[0].startswith('sbat,'):
667 print(f"{t} does not contain a valid SBAT section, skipping.")
668 continue
669 sbat += split[1:]
670
671 return 'sbat,1,SBAT Version,sbat,1,https://github.com/rhboot/shim/blob/main/SBAT.md\n' + '\n'.join(sbat) + "\n\x00"
672
673 def signer_sign(cmd):
674 print('+', shell_join(cmd))
675 subprocess.check_call(cmd)
676
677 def find_sbsign(opts=None):
678 return find_tool('sbsign', opts=opts)
679
680 def sbsign_sign(sbsign_tool, input_f, output_f, opts=None):
681 sign_invocation = [
682 sbsign_tool,
683 '--key', opts.sb_key,
684 '--cert', opts.sb_cert,
685 input_f,
686 '--output', output_f,
687 ]
688 if opts.signing_engine is not None:
689 sign_invocation += ['--engine', opts.signing_engine]
690 signer_sign(sign_invocation)
691
692 def find_pesign(opts=None):
693 return find_tool('pesign', opts=opts)
694
695 def pesign_sign(pesign_tool, input_f, output_f, opts=None):
696 sign_invocation = [
697 pesign_tool, '-s', '--force',
698 '-n', opts.sb_certdir,
699 '-c', opts.sb_cert_name,
700 '-i', input_f,
701 '-o', output_f,
702 ]
703 signer_sign(sign_invocation)
704
705 SBVERIFY = {
706 'name': 'sbverify',
707 'option': '--list',
708 'output': 'No signature table present',
709 }
710
711 PESIGCHECK = {
712 'name': 'pesign',
713 'option': '-i',
714 'output': 'No signatures found.',
715 'flags': '-S'
716 }
717
718 def verify(tool, opts):
719 verify_tool = find_tool(tool['name'], opts=opts)
720 cmd = [
721 verify_tool,
722 tool['option'],
723 opts.linux,
724 ]
725 if 'flags' in tool:
726 cmd.append(tool['flags'])
727
728 print('+', shell_join(cmd))
729 info = subprocess.check_output(cmd, text=True)
730
731 return tool['output'] in info
732
733 def make_uki(opts):
734 # kernel payload signing
735
736 sign_tool = None
737 sign_args_present = opts.sb_key or opts.sb_cert_name
738 sign_kernel = opts.sign_kernel
739 sign = None
740 linux = opts.linux
741
742 if sign_args_present:
743 if opts.signtool == 'sbsign':
744 sign_tool = find_sbsign(opts=opts)
745 sign = sbsign_sign
746 verify_tool = SBVERIFY
747 else:
748 sign_tool = find_pesign(opts=opts)
749 sign = pesign_sign
750 verify_tool = PESIGCHECK
751
752 if sign_tool is None:
753 raise ValueError(f'{opts.signtool}, required for signing, is not installed')
754
755 if sign_kernel is None and opts.linux is not None:
756 # figure out if we should sign the kernel
757 sign_kernel = verify(verify_tool, opts)
758
759 if sign_kernel:
760 linux_signed = tempfile.NamedTemporaryFile(prefix='linux-signed')
761 linux = pathlib.Path(linux_signed.name)
762 sign(sign_tool, opts.linux, linux, opts=opts)
763
764 if opts.uname is None and opts.linux is not None:
765 print('Kernel version not specified, starting autodetection 😖.')
766 opts.uname = Uname.scrape(opts.linux, opts=opts)
767
768 uki = UKI(opts.stub)
769 initrd = join_initrds(opts.initrd)
770
771 pcrpkey = opts.pcrpkey
772 if pcrpkey is None:
773 if opts.pcr_public_keys and len(opts.pcr_public_keys) == 1:
774 pcrpkey = opts.pcr_public_keys[0]
775 elif opts.pcr_private_keys and len(opts.pcr_private_keys) == 1:
776 import cryptography.hazmat.primitives.serialization as serialization
777 privkey = serialization.load_pem_private_key(opts.pcr_private_keys[0].read_bytes(), password=None)
778 pcrpkey = privkey.public_key().public_bytes(
779 encoding=serialization.Encoding.PEM,
780 format=serialization.PublicFormat.SubjectPublicKeyInfo,
781 )
782
783 sections = [
784 # name, content, measure?
785 ('.osrel', opts.os_release, True ),
786 ('.cmdline', opts.cmdline, True ),
787 ('.dtb', opts.devicetree, True ),
788 ('.uname', opts.uname, True ),
789 ('.splash', opts.splash, True ),
790 ('.pcrpkey', pcrpkey, True ),
791 ('.initrd', initrd, True ),
792
793 # linux shall be last to leave breathing room for decompression.
794 # We'll add it later.
795 ]
796
797 for name, content, measure in sections:
798 if content:
799 uki.add_section(Section.create(name, content, measure=measure))
800
801 # systemd-measure doesn't know about those extra sections
802 for section in opts.sections:
803 uki.add_section(section)
804
805 if linux is not None:
806 # Merge the .sbat sections from stub, kernel and parameter, so that revocation can be done on either.
807 uki.add_section(Section.create('.sbat', merge_sbat([opts.stub, linux], opts.sbat), measure=True))
808 else:
809 # Addons don't use the stub so we add SBAT manually
810 if not opts.sbat:
811 opts.sbat = ["""sbat,1,SBAT Version,sbat,1,https://github.com/rhboot/shim/blob/main/SBAT.md
812 uki,1,UKI,uki,1,https://www.freedesktop.org/software/systemd/man/systemd-stub.html
813 """]
814 uki.add_section(Section.create('.sbat', merge_sbat([], opts.sbat), measure=False))
815
816 # PCR measurement and signing
817
818 # We pass in the contents for .linux separately because we need them to do the measurement but can't add
819 # the section yet because we want .linux to be the last section. Make sure any other sections are added
820 # before this function is called.
821 call_systemd_measure(uki, linux, opts=opts)
822
823 # UKI creation
824
825 if linux is not None:
826 uki.add_section(Section.create('.linux', linux, measure=True))
827
828 if sign_args_present:
829 unsigned = tempfile.NamedTemporaryFile(prefix='uki')
830 unsigned_output = unsigned.name
831 else:
832 unsigned_output = opts.output
833
834 pe_add_sections(uki, unsigned_output)
835
836 # UKI signing
837
838 if sign_args_present:
839 assert sign
840 sign(sign_tool, unsigned_output, opts.output, opts=opts)
841
842 # We end up with no executable bits, let's reapply them
843 os.umask(umask := os.umask(0))
844 os.chmod(opts.output, 0o777 & ~umask)
845
846 print(f"Wrote {'signed' if sign_args_present else 'unsigned'} {opts.output}")
847
848
849 ONE_DAY = datetime.timedelta(1, 0, 0)
850
851
852 @contextlib.contextmanager
853 def temporary_umask(mask: int):
854 # Drop <mask> bits from umask
855 old = os.umask(0)
856 os.umask(old | mask)
857 try:
858 yield
859 finally:
860 os.umask(old)
861
862
863 def generate_key_cert_pair(
864 common_name: str,
865 valid_days: int,
866 keylength: int = 2048,
867 ) -> tuple[bytes]:
868
869 from cryptography import x509
870 from cryptography.hazmat.primitives import serialization, hashes
871 from cryptography.hazmat.primitives.asymmetric import rsa
872
873 # We use a keylength of 2048 bits. That is what Microsoft documents as
874 # supported/expected:
875 # 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
876
877 now = datetime.datetime.utcnow()
878
879 key = rsa.generate_private_key(
880 public_exponent=65537,
881 key_size=keylength,
882 )
883 cert = x509.CertificateBuilder(
884 ).subject_name(
885 x509.Name([x509.NameAttribute(x509.oid.NameOID.COMMON_NAME, common_name)])
886 ).issuer_name(
887 x509.Name([x509.NameAttribute(x509.oid.NameOID.COMMON_NAME, common_name)])
888 ).not_valid_before(
889 now,
890 ).not_valid_after(
891 now + ONE_DAY * valid_days
892 ).serial_number(
893 x509.random_serial_number()
894 ).public_key(
895 key.public_key()
896 ).add_extension(
897 x509.BasicConstraints(ca=False, path_length=None),
898 critical=True,
899 ).sign(
900 private_key=key,
901 algorithm=hashes.SHA256(),
902 )
903
904 cert_pem = cert.public_bytes(
905 encoding=serialization.Encoding.PEM,
906 )
907 key_pem = key.private_bytes(
908 encoding=serialization.Encoding.PEM,
909 format=serialization.PrivateFormat.TraditionalOpenSSL,
910 encryption_algorithm=serialization.NoEncryption(),
911 )
912
913 return key_pem, cert_pem
914
915
916 def generate_priv_pub_key_pair(keylength : int = 2048) -> tuple[bytes]:
917 from cryptography.hazmat.primitives import serialization
918 from cryptography.hazmat.primitives.asymmetric import rsa
919
920 key = rsa.generate_private_key(
921 public_exponent=65537,
922 key_size=keylength,
923 )
924 priv_key_pem = key.private_bytes(
925 encoding=serialization.Encoding.PEM,
926 format=serialization.PrivateFormat.TraditionalOpenSSL,
927 encryption_algorithm=serialization.NoEncryption(),
928 )
929 pub_key_pem = key.public_key().public_bytes(
930 encoding=serialization.Encoding.PEM,
931 format=serialization.PublicFormat.SubjectPublicKeyInfo,
932 )
933
934 return priv_key_pem, pub_key_pem
935
936
937 def generate_keys(opts):
938 # This will generate keys and certificates and write them to the paths that
939 # are specified as input paths.
940 if opts.sb_key or opts.sb_cert:
941 fqdn = socket.getfqdn()
942 cn = f'SecureBoot signing key on host {fqdn}'
943 key_pem, cert_pem = generate_key_cert_pair(
944 common_name=cn,
945 valid_days=opts.sb_cert_validity,
946 )
947 print(f'Writing SecureBoot private key to {opts.sb_key}')
948 with temporary_umask(0o077):
949 opts.sb_key.write_bytes(key_pem)
950 print(f'Writing SecureBoot certificate to {opts.sb_cert}')
951 opts.sb_cert.write_bytes(cert_pem)
952
953 for priv_key, pub_key, _ in key_path_groups(opts):
954 priv_key_pem, pub_key_pem = generate_priv_pub_key_pair()
955
956 print(f'Writing private key for PCR signing to {priv_key}')
957 with temporary_umask(0o077):
958 priv_key.write_bytes(priv_key_pem)
959 if pub_key:
960 print(f'Writing public key for PCR signing to {pub_key}')
961 pub_key.write_bytes(pub_key_pem)
962
963
964 def inspect_section(opts, section):
965 name = section.Name.rstrip(b"\x00").decode()
966
967 # find the config for this section in opts and whether to show it
968 config = opts.sections_by_name.get(name, None)
969 show = (config or
970 opts.all or
971 (name in DEFAULT_SECTIONS_TO_SHOW and not opts.sections))
972 if not show:
973 return name, None
974
975 ttype = config.output_mode if config else DEFAULT_SECTIONS_TO_SHOW.get(name, 'binary')
976
977 size = section.Misc_VirtualSize
978 # TODO: Use ignore_padding once we can depend on a newer version of pefile
979 data = section.get_data(length=size)
980 digest = sha256(data).hexdigest()
981
982 struct = {
983 'size' : size,
984 'sha256' : digest,
985 }
986
987 if ttype == 'text':
988 try:
989 struct['text'] = data.decode()
990 except UnicodeDecodeError as e:
991 print(f"Section {name!r} is not valid text: {e}")
992 struct['text'] = '(not valid UTF-8)'
993
994 if config and config.content:
995 assert isinstance(config.content, pathlib.Path)
996 config.content.write_bytes(data)
997
998 if opts.json == 'off':
999 print(f"{name}:\n size: {size} bytes\n sha256: {digest}")
1000 if ttype == 'text':
1001 text = textwrap.indent(struct['text'].rstrip(), ' ' * 4)
1002 print(f" text:\n{text}")
1003
1004 return name, struct
1005
1006
1007 def inspect_sections(opts):
1008 indent = 4 if opts.json == 'pretty' else None
1009
1010 for file in opts.files:
1011 pe = pefile.PE(file, fast_load=True)
1012 gen = (inspect_section(opts, section) for section in pe.sections)
1013 descs = {key:val for (key, val) in gen if val}
1014 if opts.json != 'off':
1015 json.dump(descs, sys.stdout, indent=indent)
1016
1017
1018 @dataclasses.dataclass(frozen=True)
1019 class ConfigItem:
1020 @staticmethod
1021 def config_list_prepend(
1022 namespace: argparse.Namespace,
1023 group: Optional[str],
1024 dest: str,
1025 value: Any,
1026 ) -> None:
1027 "Prepend value to namespace.<dest>"
1028
1029 assert not group
1030
1031 old = getattr(namespace, dest, [])
1032 if old is None:
1033 old = []
1034 setattr(namespace, dest, value + old)
1035
1036 @staticmethod
1037 def config_set_if_unset(
1038 namespace: argparse.Namespace,
1039 group: Optional[str],
1040 dest: str,
1041 value: Any,
1042 ) -> None:
1043 "Set namespace.<dest> to value only if it was None"
1044
1045 assert not group
1046
1047 if getattr(namespace, dest) is None:
1048 setattr(namespace, dest, value)
1049
1050 @staticmethod
1051 def config_set(
1052 namespace: argparse.Namespace,
1053 group: Optional[str],
1054 dest: str,
1055 value: Any,
1056 ) -> None:
1057 "Set namespace.<dest> to value only if it was None"
1058
1059 assert not group
1060
1061 setattr(namespace, dest, value)
1062
1063 @staticmethod
1064 def config_set_group(
1065 namespace: argparse.Namespace,
1066 group: Optional[str],
1067 dest: str,
1068 value: Any,
1069 ) -> None:
1070 "Set namespace.<dest>[idx] to value, with idx derived from group"
1071
1072 # pylint: disable=protected-access
1073 if group not in namespace._groups:
1074 namespace._groups += [group]
1075 idx = namespace._groups.index(group)
1076
1077 old = getattr(namespace, dest, None)
1078 if old is None:
1079 old = []
1080 setattr(namespace, dest,
1081 old + ([None] * (idx - len(old))) + [value])
1082
1083 @staticmethod
1084 def parse_boolean(s: str) -> bool:
1085 "Parse 1/true/yes/y/t/on as true and 0/false/no/n/f/off/None as false"
1086 s_l = s.lower()
1087 if s_l in {'1', 'true', 'yes', 'y', 't', 'on'}:
1088 return True
1089 if s_l in {'0', 'false', 'no', 'n', 'f', 'off'}:
1090 return False
1091 raise ValueError('f"Invalid boolean literal: {s!r}')
1092
1093 # arguments for argparse.ArgumentParser.add_argument()
1094 name: Union[str, tuple[str, str]]
1095 dest: Optional[str] = None
1096 metavar: Optional[str] = None
1097 type: Optional[Callable] = None
1098 nargs: Optional[str] = None
1099 action: Optional[Union[str, Callable]] = None
1100 default: Any = None
1101 version: Optional[str] = None
1102 choices: Optional[tuple[str, ...]] = None
1103 const: Optional[Any] = None
1104 help: Optional[str] = None
1105
1106 # metadata for config file parsing
1107 config_key: Optional[str] = None
1108 config_push: Callable[[argparse.Namespace, Optional[str], str, Any], None] = \
1109 config_set_if_unset
1110
1111 def _names(self) -> tuple[str, ...]:
1112 return self.name if isinstance(self.name, tuple) else (self.name,)
1113
1114 def argparse_dest(self) -> str:
1115 # It'd be nice if argparse exported this, but I don't see that in the API
1116 if self.dest:
1117 return self.dest
1118 return self._names()[0].lstrip('-').replace('-', '_')
1119
1120 def add_to(self, parser: argparse.ArgumentParser):
1121 kwargs = { key:val
1122 for key in dataclasses.asdict(self)
1123 if (key not in ('name', 'config_key', 'config_push') and
1124 (val := getattr(self, key)) is not None) }
1125 args = self._names()
1126 parser.add_argument(*args, **kwargs)
1127
1128 def apply_config(self, namespace, section, group, key, value) -> None:
1129 assert f'{section}/{key}' == self.config_key
1130 dest = self.argparse_dest()
1131
1132 conv: Callable[[str], Any]
1133 if self.action == argparse.BooleanOptionalAction:
1134 # We need to handle this case separately: the options are called
1135 # --foo and --no-foo, and no argument is parsed. But in the config
1136 # file, we have Foo=yes or Foo=no.
1137 conv = self.parse_boolean
1138 elif self.type:
1139 conv = self.type
1140 else:
1141 conv = lambda s:s
1142
1143 # This is a bit ugly, but --initrd is the only option which is specified
1144 # with multiple args on the command line and a space-separated list in the
1145 # config file.
1146 if self.name == '--initrd':
1147 value = [conv(v) for v in value.split()]
1148 else:
1149 value = conv(value)
1150
1151 self.config_push(namespace, group, dest, value)
1152
1153 def config_example(self) -> tuple[Optional[str], Optional[str], Optional[str]]:
1154 if not self.config_key:
1155 return None, None, None
1156 section_name, key = self.config_key.split('/', 1)
1157 if section_name.endswith(':'):
1158 section_name += 'NAME'
1159 if self.choices:
1160 value = '|'.join(self.choices)
1161 else:
1162 value = self.metavar or self.argparse_dest().upper()
1163 return (section_name, key, value)
1164
1165
1166 VERBS = ('build', 'genkey', 'inspect')
1167
1168 CONFIG_ITEMS = [
1169 ConfigItem(
1170 'positional',
1171 metavar = 'VERB',
1172 nargs = '*',
1173 help = argparse.SUPPRESS,
1174 ),
1175
1176 ConfigItem(
1177 '--version',
1178 action = 'version',
1179 version = f'ukify {__version__}',
1180 ),
1181
1182 ConfigItem(
1183 '--summary',
1184 help = 'print parsed config and exit',
1185 action = 'store_true',
1186 ),
1187
1188 ConfigItem(
1189 '--linux',
1190 type = pathlib.Path,
1191 help = 'vmlinuz file [.linux section]',
1192 config_key = 'UKI/Linux',
1193 ),
1194
1195 ConfigItem(
1196 '--initrd',
1197 metavar = 'INITRD',
1198 type = pathlib.Path,
1199 action = 'append',
1200 help = 'initrd file [part of .initrd section]',
1201 config_key = 'UKI/Initrd',
1202 config_push = ConfigItem.config_list_prepend,
1203 ),
1204
1205 ConfigItem(
1206 ('--config', '-c'),
1207 metavar = 'PATH',
1208 type = pathlib.Path,
1209 help = 'configuration file',
1210 ),
1211
1212 ConfigItem(
1213 '--cmdline',
1214 metavar = 'TEXT|@PATH',
1215 help = 'kernel command line [.cmdline section]',
1216 config_key = 'UKI/Cmdline',
1217 ),
1218
1219 ConfigItem(
1220 '--os-release',
1221 metavar = 'TEXT|@PATH',
1222 help = 'path to os-release file [.osrel section]',
1223 config_key = 'UKI/OSRelease',
1224 ),
1225
1226 ConfigItem(
1227 '--devicetree',
1228 metavar = 'PATH',
1229 type = pathlib.Path,
1230 help = 'Device Tree file [.dtb section]',
1231 config_key = 'UKI/DeviceTree',
1232 ),
1233 ConfigItem(
1234 '--splash',
1235 metavar = 'BMP',
1236 type = pathlib.Path,
1237 help = 'splash image bitmap file [.splash section]',
1238 config_key = 'UKI/Splash',
1239 ),
1240 ConfigItem(
1241 '--pcrpkey',
1242 metavar = 'KEY',
1243 type = pathlib.Path,
1244 help = 'embedded public key to seal secrets to [.pcrpkey section]',
1245 config_key = 'UKI/PCRPKey',
1246 ),
1247 ConfigItem(
1248 '--uname',
1249 metavar='VERSION',
1250 help='"uname -r" information [.uname section]',
1251 config_key = 'UKI/Uname',
1252 ),
1253
1254 ConfigItem(
1255 '--efi-arch',
1256 metavar = 'ARCH',
1257 choices = ('ia32', 'x64', 'arm', 'aa64', 'riscv64'),
1258 help = 'target EFI architecture',
1259 config_key = 'UKI/EFIArch',
1260 ),
1261
1262 ConfigItem(
1263 '--stub',
1264 type = pathlib.Path,
1265 help = 'path to the sd-stub file [.text,.data,… sections]',
1266 config_key = 'UKI/Stub',
1267 ),
1268
1269 ConfigItem(
1270 '--sbat',
1271 metavar = 'TEXT|@PATH',
1272 help = 'SBAT policy [.sbat section]',
1273 default = [],
1274 action = 'append',
1275 config_key = 'UKI/SBAT',
1276 ),
1277
1278 ConfigItem(
1279 '--section',
1280 dest = 'sections',
1281 metavar = 'NAME:TEXT|@PATH',
1282 action = 'append',
1283 default = [],
1284 help = 'section as name and contents [NAME section] or section to print',
1285 ),
1286
1287 ConfigItem(
1288 '--pcr-banks',
1289 metavar = 'BANK…',
1290 type = parse_banks,
1291 config_key = 'UKI/PCRBanks',
1292 ),
1293
1294 ConfigItem(
1295 '--signing-engine',
1296 metavar = 'ENGINE',
1297 help = 'OpenSSL engine to use for signing',
1298 config_key = 'UKI/SigningEngine',
1299 ),
1300 ConfigItem(
1301 '--signtool',
1302 choices = ('sbsign', 'pesign'),
1303 dest = 'signtool',
1304 help = 'whether to use sbsign or pesign. It will also be inferred by the other \
1305 parameters given: when using --secureboot-{private-key/certificate}, sbsign \
1306 will be used, otherwise pesign will be used',
1307 config_key = 'UKI/SecureBootSigningTool',
1308 ),
1309 ConfigItem(
1310 '--secureboot-private-key',
1311 dest = 'sb_key',
1312 help = 'required by --signtool=sbsign. Path to key file or engine-specific designation for SB signing',
1313 config_key = 'UKI/SecureBootPrivateKey',
1314 ),
1315 ConfigItem(
1316 '--secureboot-certificate',
1317 dest = 'sb_cert',
1318 help = 'required by --signtool=sbsign. sbsign needs a path to certificate file or engine-specific designation for SB signing',
1319 config_key = 'UKI/SecureBootCertificate',
1320 ),
1321 ConfigItem(
1322 '--secureboot-certificate-dir',
1323 dest = 'sb_certdir',
1324 default = '/etc/pki/pesign',
1325 help = 'required by --signtool=pesign. Path to nss certificate database directory for PE signing. Default is /etc/pki/pesign',
1326 config_key = 'UKI/SecureBootCertificateDir',
1327 config_push = ConfigItem.config_set
1328 ),
1329 ConfigItem(
1330 '--secureboot-certificate-name',
1331 dest = 'sb_cert_name',
1332 help = 'required by --signtool=pesign. pesign needs a certificate nickname of nss certificate database entry to use for PE signing',
1333 config_key = 'UKI/SecureBootCertificateName',
1334 ),
1335 ConfigItem(
1336 '--secureboot-certificate-validity',
1337 metavar = 'DAYS',
1338 dest = 'sb_cert_validity',
1339 default = 365 * 10,
1340 help = "period of validity (in days) for a certificate created by 'genkey'",
1341 config_key = 'UKI/SecureBootCertificateValidity',
1342 config_push = ConfigItem.config_set
1343 ),
1344
1345 ConfigItem(
1346 '--sign-kernel',
1347 action = argparse.BooleanOptionalAction,
1348 help = 'Sign the embedded kernel',
1349 config_key = 'UKI/SignKernel',
1350 ),
1351
1352 ConfigItem(
1353 '--pcr-private-key',
1354 dest = 'pcr_private_keys',
1355 metavar = 'PATH',
1356 type = pathlib.Path,
1357 action = 'append',
1358 help = 'private part of the keypair for signing PCR signatures',
1359 config_key = 'PCRSignature:/PCRPrivateKey',
1360 config_push = ConfigItem.config_set_group,
1361 ),
1362 ConfigItem(
1363 '--pcr-public-key',
1364 dest = 'pcr_public_keys',
1365 metavar = 'PATH',
1366 type = pathlib.Path,
1367 action = 'append',
1368 help = 'public part of the keypair for signing PCR signatures',
1369 config_key = 'PCRSignature:/PCRPublicKey',
1370 config_push = ConfigItem.config_set_group,
1371 ),
1372 ConfigItem(
1373 '--phases',
1374 dest = 'phase_path_groups',
1375 metavar = 'PHASE-PATH…',
1376 type = parse_phase_paths,
1377 action = 'append',
1378 help = 'phase-paths to create signatures for',
1379 config_key = 'PCRSignature:/Phases',
1380 config_push = ConfigItem.config_set_group,
1381 ),
1382
1383 ConfigItem(
1384 '--tools',
1385 type = pathlib.Path,
1386 action = 'append',
1387 help = 'Directories to search for tools (systemd-measure, …)',
1388 ),
1389
1390 ConfigItem(
1391 ('--output', '-o'),
1392 type = pathlib.Path,
1393 help = 'output file path',
1394 ),
1395
1396 ConfigItem(
1397 '--measure',
1398 action = argparse.BooleanOptionalAction,
1399 help = 'print systemd-measure output for the UKI',
1400 ),
1401
1402 ConfigItem(
1403 '--json',
1404 choices = ('pretty', 'short', 'off'),
1405 default = 'off',
1406 help = 'generate JSON output',
1407 ),
1408 ConfigItem(
1409 '-j',
1410 dest='json',
1411 action='store_const',
1412 const='pretty',
1413 help='equivalent to --json=pretty',
1414 ),
1415
1416 ConfigItem(
1417 '--all',
1418 help = 'print all sections',
1419 action = 'store_true',
1420 ),
1421 ]
1422
1423 CONFIGFILE_ITEMS = { item.config_key:item
1424 for item in CONFIG_ITEMS
1425 if item.config_key }
1426
1427
1428 def apply_config(namespace, filename=None):
1429 if filename is None:
1430 if namespace.config:
1431 # Config set by the user, use that.
1432 filename = namespace.config
1433 print(f'Using config file: {filename}')
1434 else:
1435 # Try to look for a config file then use the first one found.
1436 for config_dir in DEFAULT_CONFIG_DIRS:
1437 filename = pathlib.Path(config_dir) / DEFAULT_CONFIG_FILE
1438 if filename.is_file():
1439 # Found a config file, use it.
1440 print(f'Using found config file: {filename}')
1441 break
1442 else:
1443 # No config file specified or found, nothing to do.
1444 return
1445
1446 # Fill in ._groups based on --pcr-public-key=, --pcr-private-key=, and --phases=.
1447 assert '_groups' not in namespace
1448 n_pcr_priv = len(namespace.pcr_private_keys or ())
1449 namespace._groups = list(range(n_pcr_priv)) # pylint: disable=protected-access
1450
1451 cp = configparser.ConfigParser(
1452 comment_prefixes='#',
1453 inline_comment_prefixes='#',
1454 delimiters='=',
1455 empty_lines_in_values=False,
1456 interpolation=None,
1457 strict=False)
1458 # Do not make keys lowercase
1459 cp.optionxform = lambda option: option
1460
1461 # The API is not great.
1462 read = cp.read(filename)
1463 if not read:
1464 raise IOError(f'Failed to read {filename}')
1465
1466 for section_name, section in cp.items():
1467 idx = section_name.find(':')
1468 if idx >= 0:
1469 section_name, group = section_name[:idx+1], section_name[idx+1:]
1470 if not section_name or not group:
1471 raise ValueError('Section name components cannot be empty')
1472 if ':' in group:
1473 raise ValueError('Section name cannot contain more than one ":"')
1474 else:
1475 group = None
1476 for key, value in section.items():
1477 if item := CONFIGFILE_ITEMS.get(f'{section_name}/{key}'):
1478 item.apply_config(namespace, section_name, group, key, value)
1479 else:
1480 print(f'Unknown config setting [{section_name}] {key}=')
1481
1482
1483 def config_example():
1484 prev_section = None
1485 for item in CONFIG_ITEMS:
1486 section, key, value = item.config_example()
1487 if section:
1488 if prev_section != section:
1489 if prev_section:
1490 yield ''
1491 yield f'[{section}]'
1492 prev_section = section
1493 yield f'{key} = {value}'
1494
1495
1496 class PagerHelpAction(argparse._HelpAction): # pylint: disable=protected-access
1497 def __call__(
1498 self,
1499 parser: argparse.ArgumentParser,
1500 namespace: argparse.Namespace,
1501 values: Union[str, Sequence[Any], None] = None,
1502 option_string: Optional[str] = None
1503 ) -> None:
1504 page(parser.format_help(), True)
1505 parser.exit()
1506
1507
1508 def create_parser():
1509 p = argparse.ArgumentParser(
1510 description='Build and sign Unified Kernel Images',
1511 usage='\n ' + textwrap.dedent('''\
1512 ukify {b}build{e} [--linux=LINUX] [--initrd=INITRD] [options…]
1513 ukify {b}genkey{e} [options…]
1514 ukify {b}inspect{e} FILE… [options…]
1515 ''').format(b=Style.bold, e=Style.reset),
1516 allow_abbrev=False,
1517 add_help=False,
1518 epilog='\n '.join(('config file:', *config_example())),
1519 formatter_class=argparse.RawDescriptionHelpFormatter,
1520 )
1521
1522 for item in CONFIG_ITEMS:
1523 item.add_to(p)
1524
1525 # Suppress printing of usage synopsis on errors
1526 p.error = lambda message: p.exit(2, f'{p.prog}: error: {message}\n')
1527
1528 # Make --help paged
1529 p.add_argument(
1530 '-h', '--help',
1531 action=PagerHelpAction,
1532 help='show this help message and exit',
1533 )
1534
1535 return p
1536
1537
1538 def finalize_options(opts):
1539 # Figure out which syntax is being used, one of:
1540 # ukify verb --arg --arg --arg
1541 # ukify linux initrd…
1542 if len(opts.positional) >= 1 and opts.positional[0] == 'inspect':
1543 opts.verb = opts.positional[0]
1544 opts.files = opts.positional[1:]
1545 if not opts.files:
1546 raise ValueError('file(s) to inspect must be specified')
1547 if len(opts.files) > 1 and opts.json != 'off':
1548 # We could allow this in the future, but we need to figure out the right structure
1549 raise ValueError('JSON output is not allowed with multiple files')
1550 elif len(opts.positional) == 1 and opts.positional[0] in VERBS:
1551 opts.verb = opts.positional[0]
1552 elif opts.linux or opts.initrd:
1553 raise ValueError('--linux/--initrd options cannot be used with positional arguments')
1554 else:
1555 print("Assuming obsolete command line syntax with no verb. Please use 'build'.")
1556 if opts.positional:
1557 opts.linux = pathlib.Path(opts.positional[0])
1558 # If we have initrds from parsing config files, append our positional args at the end
1559 opts.initrd = (opts.initrd or []) + [pathlib.Path(arg) for arg in opts.positional[1:]]
1560 opts.verb = 'build'
1561
1562 # Check that --pcr-public-key=, --pcr-private-key=, and --phases=
1563 # have either the same number of arguments are are not specified at all.
1564 n_pcr_pub = None if opts.pcr_public_keys is None else len(opts.pcr_public_keys)
1565 n_pcr_priv = None if opts.pcr_private_keys is None else len(opts.pcr_private_keys)
1566 n_phase_path_groups = None if opts.phase_path_groups is None else len(opts.phase_path_groups)
1567 if n_pcr_pub is not None and n_pcr_pub != n_pcr_priv:
1568 raise ValueError('--pcr-public-key= specifications must match --pcr-private-key=')
1569 if n_phase_path_groups is not None and n_phase_path_groups != n_pcr_priv:
1570 raise ValueError('--phases= specifications must match --pcr-private-key=')
1571
1572 if opts.cmdline and opts.cmdline.startswith('@'):
1573 opts.cmdline = pathlib.Path(opts.cmdline[1:])
1574 elif opts.cmdline:
1575 # Drop whitespace from the command line. If we're reading from a file,
1576 # we copy the contents verbatim. But configuration specified on the command line
1577 # or in the config file may contain additional whitespace that has no meaning.
1578 opts.cmdline = ' '.join(opts.cmdline.split())
1579
1580 if opts.os_release and opts.os_release.startswith('@'):
1581 opts.os_release = pathlib.Path(opts.os_release[1:])
1582 elif not opts.os_release and opts.linux:
1583 p = pathlib.Path('/etc/os-release')
1584 if not p.exists():
1585 p = pathlib.Path('/usr/lib/os-release')
1586 opts.os_release = p
1587
1588 if opts.efi_arch is None:
1589 opts.efi_arch = guess_efi_arch()
1590
1591 if opts.stub is None:
1592 if opts.linux is not None:
1593 opts.stub = pathlib.Path(f'/usr/lib/systemd/boot/efi/linux{opts.efi_arch}.efi.stub')
1594 else:
1595 opts.stub = pathlib.Path(f'/usr/lib/systemd/boot/efi/addon{opts.efi_arch}.efi.stub')
1596
1597 if opts.signing_engine is None:
1598 if opts.sb_key:
1599 opts.sb_key = pathlib.Path(opts.sb_key)
1600 if opts.sb_cert:
1601 opts.sb_cert = pathlib.Path(opts.sb_cert)
1602
1603 if bool(opts.sb_key) ^ bool(opts.sb_cert):
1604 # one param only given, sbsign needs both
1605 raise ValueError('--secureboot-private-key= and --secureboot-certificate= must be specified together')
1606 elif bool(opts.sb_key) and bool(opts.sb_cert):
1607 # both param given, infer sbsign and in case it was given, ensure signtool=sbsign
1608 if opts.signtool and opts.signtool != 'sbsign':
1609 raise ValueError(f'Cannot provide --signtool={opts.signtool} with --secureboot-private-key= and --secureboot-certificate=')
1610 opts.signtool = 'sbsign'
1611 elif bool(opts.sb_cert_name):
1612 # sb_cert_name given, infer pesign and in case it was given, ensure signtool=pesign
1613 if opts.signtool and opts.signtool != 'pesign':
1614 raise ValueError(f'Cannot provide --signtool={opts.signtool} with --secureboot-certificate-name=')
1615 opts.signtool = 'pesign'
1616
1617 if opts.sign_kernel and not opts.sb_key and not opts.sb_cert_name:
1618 raise ValueError('--sign-kernel requires either --secureboot-private-key= and --secureboot-certificate= (for sbsign) or --secureboot-certificate-name= (for pesign) to be specified')
1619
1620 if opts.verb == 'build' and opts.output is None:
1621 if opts.linux is None:
1622 raise ValueError('--output= must be specified when building a PE addon')
1623 suffix = '.efi' if opts.sb_key or opts.sb_cert_name else '.unsigned.efi'
1624 opts.output = opts.linux.name + suffix
1625
1626 # Now that we know if we're inputting or outputting, really parse section config
1627 f = Section.parse_output if opts.verb == 'inspect' else Section.parse_input
1628 opts.sections = [f(s) for s in opts.sections]
1629 # A convenience dictionary to make it easy to look up sections
1630 opts.sections_by_name = {s.name:s for s in opts.sections}
1631
1632 if opts.summary:
1633 # TODO: replace pprint() with some fancy formatting.
1634 pprint.pprint(vars(opts))
1635 sys.exit()
1636
1637
1638 def parse_args(args=None):
1639 opts = create_parser().parse_args(args)
1640 apply_config(opts)
1641 finalize_options(opts)
1642 return opts
1643
1644
1645 def main():
1646 opts = parse_args()
1647 if opts.verb == 'build':
1648 check_inputs(opts)
1649 make_uki(opts)
1650 elif opts.verb == 'genkey':
1651 check_cert_and_keys_nonexistent(opts)
1652 generate_keys(opts)
1653 elif opts.verb == 'inspect':
1654 inspect_sections(opts)
1655 else:
1656 assert False
1657
1658
1659 if __name__ == '__main__':
1660 main()