]> git.ipfire.org Git - thirdparty/systemd.git/blob - src/ukify/ukify.py
d04b6dfac06398c8b5f491ce053a3fd8c0fe2c17
[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' : 'binary',
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 @contextlib.contextmanager
850 def temporary_umask(mask: int):
851 # Drop <mask> bits from umask
852 old = os.umask(0)
853 os.umask(old | mask)
854 try:
855 yield
856 finally:
857 os.umask(old)
858
859
860 def generate_key_cert_pair(
861 common_name: str,
862 valid_days: int,
863 keylength: int = 2048,
864 ) -> tuple[bytes]:
865
866 from cryptography import x509
867 from cryptography.hazmat.primitives import serialization, hashes
868 from cryptography.hazmat.primitives.asymmetric import rsa
869
870 # We use a keylength of 2048 bits. That is what Microsoft documents as
871 # supported/expected:
872 # 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
873
874 now = datetime.datetime.now(datetime.UTC)
875
876 key = rsa.generate_private_key(
877 public_exponent=65537,
878 key_size=keylength,
879 )
880 cert = x509.CertificateBuilder(
881 ).subject_name(
882 x509.Name([x509.NameAttribute(x509.oid.NameOID.COMMON_NAME, common_name)])
883 ).issuer_name(
884 x509.Name([x509.NameAttribute(x509.oid.NameOID.COMMON_NAME, common_name)])
885 ).not_valid_before(
886 now,
887 ).not_valid_after(
888 now + datetime.timedelta(days=valid_days)
889 ).serial_number(
890 x509.random_serial_number()
891 ).public_key(
892 key.public_key()
893 ).add_extension(
894 x509.BasicConstraints(ca=False, path_length=None),
895 critical=True,
896 ).sign(
897 private_key=key,
898 algorithm=hashes.SHA256(),
899 )
900
901 cert_pem = cert.public_bytes(
902 encoding=serialization.Encoding.PEM,
903 )
904 key_pem = key.private_bytes(
905 encoding=serialization.Encoding.PEM,
906 format=serialization.PrivateFormat.TraditionalOpenSSL,
907 encryption_algorithm=serialization.NoEncryption(),
908 )
909
910 return key_pem, cert_pem
911
912
913 def generate_priv_pub_key_pair(keylength : int = 2048) -> tuple[bytes]:
914 from cryptography.hazmat.primitives import serialization
915 from cryptography.hazmat.primitives.asymmetric import rsa
916
917 key = rsa.generate_private_key(
918 public_exponent=65537,
919 key_size=keylength,
920 )
921 priv_key_pem = key.private_bytes(
922 encoding=serialization.Encoding.PEM,
923 format=serialization.PrivateFormat.TraditionalOpenSSL,
924 encryption_algorithm=serialization.NoEncryption(),
925 )
926 pub_key_pem = key.public_key().public_bytes(
927 encoding=serialization.Encoding.PEM,
928 format=serialization.PublicFormat.SubjectPublicKeyInfo,
929 )
930
931 return priv_key_pem, pub_key_pem
932
933
934 def generate_keys(opts):
935 # This will generate keys and certificates and write them to the paths that
936 # are specified as input paths.
937 if opts.sb_key or opts.sb_cert:
938 fqdn = socket.getfqdn()
939 cn = f'SecureBoot signing key on host {fqdn}'
940 key_pem, cert_pem = generate_key_cert_pair(
941 common_name=cn,
942 valid_days=opts.sb_cert_validity,
943 )
944 print(f'Writing SecureBoot private key to {opts.sb_key}')
945 with temporary_umask(0o077):
946 opts.sb_key.write_bytes(key_pem)
947 print(f'Writing SecureBoot certificate to {opts.sb_cert}')
948 opts.sb_cert.write_bytes(cert_pem)
949
950 for priv_key, pub_key, _ in key_path_groups(opts):
951 priv_key_pem, pub_key_pem = generate_priv_pub_key_pair()
952
953 print(f'Writing private key for PCR signing to {priv_key}')
954 with temporary_umask(0o077):
955 priv_key.write_bytes(priv_key_pem)
956 if pub_key:
957 print(f'Writing public key for PCR signing to {pub_key}')
958 pub_key.write_bytes(pub_key_pem)
959
960
961 def inspect_section(opts, section):
962 name = section.Name.rstrip(b"\x00").decode()
963
964 # find the config for this section in opts and whether to show it
965 config = opts.sections_by_name.get(name, None)
966 show = (config or
967 opts.all or
968 (name in DEFAULT_SECTIONS_TO_SHOW and not opts.sections))
969 if not show:
970 return name, None
971
972 ttype = config.output_mode if config else DEFAULT_SECTIONS_TO_SHOW.get(name, 'binary')
973
974 size = section.Misc_VirtualSize
975 # TODO: Use ignore_padding once we can depend on a newer version of pefile
976 data = section.get_data(length=size)
977 digest = sha256(data).hexdigest()
978
979 struct = {
980 'size' : size,
981 'sha256' : digest,
982 }
983
984 if ttype == 'text':
985 try:
986 struct['text'] = data.decode()
987 except UnicodeDecodeError as e:
988 print(f"Section {name!r} is not valid text: {e}")
989 struct['text'] = '(not valid UTF-8)'
990
991 if config and config.content:
992 assert isinstance(config.content, pathlib.Path)
993 config.content.write_bytes(data)
994
995 if opts.json == 'off':
996 print(f"{name}:\n size: {size} bytes\n sha256: {digest}")
997 if ttype == 'text':
998 text = textwrap.indent(struct['text'].rstrip(), ' ' * 4)
999 print(f" text:\n{text}")
1000
1001 return name, struct
1002
1003
1004 def inspect_sections(opts):
1005 indent = 4 if opts.json == 'pretty' else None
1006
1007 for file in opts.files:
1008 pe = pefile.PE(file, fast_load=True)
1009 gen = (inspect_section(opts, section) for section in pe.sections)
1010 descs = {key:val for (key, val) in gen if val}
1011 if opts.json != 'off':
1012 json.dump(descs, sys.stdout, indent=indent)
1013
1014
1015 @dataclasses.dataclass(frozen=True)
1016 class ConfigItem:
1017 @staticmethod
1018 def config_list_prepend(
1019 namespace: argparse.Namespace,
1020 group: Optional[str],
1021 dest: str,
1022 value: Any,
1023 ) -> None:
1024 "Prepend value to namespace.<dest>"
1025
1026 assert not group
1027
1028 old = getattr(namespace, dest, [])
1029 if old is None:
1030 old = []
1031 setattr(namespace, dest, value + old)
1032
1033 @staticmethod
1034 def config_set_if_unset(
1035 namespace: argparse.Namespace,
1036 group: Optional[str],
1037 dest: str,
1038 value: Any,
1039 ) -> None:
1040 "Set namespace.<dest> to value only if it was None"
1041
1042 assert not group
1043
1044 if getattr(namespace, dest) is None:
1045 setattr(namespace, dest, value)
1046
1047 @staticmethod
1048 def config_set(
1049 namespace: argparse.Namespace,
1050 group: Optional[str],
1051 dest: str,
1052 value: Any,
1053 ) -> None:
1054 "Set namespace.<dest> to value only if it was None"
1055
1056 assert not group
1057
1058 setattr(namespace, dest, value)
1059
1060 @staticmethod
1061 def config_set_group(
1062 namespace: argparse.Namespace,
1063 group: Optional[str],
1064 dest: str,
1065 value: Any,
1066 ) -> None:
1067 "Set namespace.<dest>[idx] to value, with idx derived from group"
1068
1069 # pylint: disable=protected-access
1070 if group not in namespace._groups:
1071 namespace._groups += [group]
1072 idx = namespace._groups.index(group)
1073
1074 old = getattr(namespace, dest, None)
1075 if old is None:
1076 old = []
1077 setattr(namespace, dest,
1078 old + ([None] * (idx - len(old))) + [value])
1079
1080 @staticmethod
1081 def parse_boolean(s: str) -> bool:
1082 "Parse 1/true/yes/y/t/on as true and 0/false/no/n/f/off/None as false"
1083 s_l = s.lower()
1084 if s_l in {'1', 'true', 'yes', 'y', 't', 'on'}:
1085 return True
1086 if s_l in {'0', 'false', 'no', 'n', 'f', 'off'}:
1087 return False
1088 raise ValueError('f"Invalid boolean literal: {s!r}')
1089
1090 # arguments for argparse.ArgumentParser.add_argument()
1091 name: Union[str, tuple[str, str]]
1092 dest: Optional[str] = None
1093 metavar: Optional[str] = None
1094 type: Optional[Callable] = None
1095 nargs: Optional[str] = None
1096 action: Optional[Union[str, Callable]] = None
1097 default: Any = None
1098 version: Optional[str] = None
1099 choices: Optional[tuple[str, ...]] = None
1100 const: Optional[Any] = None
1101 help: Optional[str] = None
1102
1103 # metadata for config file parsing
1104 config_key: Optional[str] = None
1105 config_push: Callable[[argparse.Namespace, Optional[str], str, Any], None] = \
1106 config_set_if_unset
1107
1108 def _names(self) -> tuple[str, ...]:
1109 return self.name if isinstance(self.name, tuple) else (self.name,)
1110
1111 def argparse_dest(self) -> str:
1112 # It'd be nice if argparse exported this, but I don't see that in the API
1113 if self.dest:
1114 return self.dest
1115 return self._names()[0].lstrip('-').replace('-', '_')
1116
1117 def add_to(self, parser: argparse.ArgumentParser):
1118 kwargs = { key:val
1119 for key in dataclasses.asdict(self)
1120 if (key not in ('name', 'config_key', 'config_push') and
1121 (val := getattr(self, key)) is not None) }
1122 args = self._names()
1123 parser.add_argument(*args, **kwargs)
1124
1125 def apply_config(self, namespace, section, group, key, value) -> None:
1126 assert f'{section}/{key}' == self.config_key
1127 dest = self.argparse_dest()
1128
1129 conv: Callable[[str], Any]
1130 if self.action == argparse.BooleanOptionalAction:
1131 # We need to handle this case separately: the options are called
1132 # --foo and --no-foo, and no argument is parsed. But in the config
1133 # file, we have Foo=yes or Foo=no.
1134 conv = self.parse_boolean
1135 elif self.type:
1136 conv = self.type
1137 else:
1138 conv = lambda s:s
1139
1140 # This is a bit ugly, but --initrd is the only option which is specified
1141 # with multiple args on the command line and a space-separated list in the
1142 # config file.
1143 if self.name == '--initrd':
1144 value = [conv(v) for v in value.split()]
1145 else:
1146 value = conv(value)
1147
1148 self.config_push(namespace, group, dest, value)
1149
1150 def config_example(self) -> tuple[Optional[str], Optional[str], Optional[str]]:
1151 if not self.config_key:
1152 return None, None, None
1153 section_name, key = self.config_key.split('/', 1)
1154 if section_name.endswith(':'):
1155 section_name += 'NAME'
1156 if self.choices:
1157 value = '|'.join(self.choices)
1158 else:
1159 value = self.metavar or self.argparse_dest().upper()
1160 return (section_name, key, value)
1161
1162
1163 VERBS = ('build', 'genkey', 'inspect')
1164
1165 CONFIG_ITEMS = [
1166 ConfigItem(
1167 'positional',
1168 metavar = 'VERB',
1169 nargs = '*',
1170 help = argparse.SUPPRESS,
1171 ),
1172
1173 ConfigItem(
1174 '--version',
1175 action = 'version',
1176 version = f'ukify {__version__}',
1177 ),
1178
1179 ConfigItem(
1180 '--summary',
1181 help = 'print parsed config and exit',
1182 action = 'store_true',
1183 ),
1184
1185 ConfigItem(
1186 '--linux',
1187 type = pathlib.Path,
1188 help = 'vmlinuz file [.linux section]',
1189 config_key = 'UKI/Linux',
1190 ),
1191
1192 ConfigItem(
1193 '--initrd',
1194 metavar = 'INITRD',
1195 type = pathlib.Path,
1196 action = 'append',
1197 help = 'initrd file [part of .initrd section]',
1198 config_key = 'UKI/Initrd',
1199 config_push = ConfigItem.config_list_prepend,
1200 ),
1201
1202 ConfigItem(
1203 ('--config', '-c'),
1204 metavar = 'PATH',
1205 type = pathlib.Path,
1206 help = 'configuration file',
1207 ),
1208
1209 ConfigItem(
1210 '--cmdline',
1211 metavar = 'TEXT|@PATH',
1212 help = 'kernel command line [.cmdline section]',
1213 config_key = 'UKI/Cmdline',
1214 ),
1215
1216 ConfigItem(
1217 '--os-release',
1218 metavar = 'TEXT|@PATH',
1219 help = 'path to os-release file [.osrel section]',
1220 config_key = 'UKI/OSRelease',
1221 ),
1222
1223 ConfigItem(
1224 '--devicetree',
1225 metavar = 'PATH',
1226 type = pathlib.Path,
1227 help = 'Device Tree file [.dtb section]',
1228 config_key = 'UKI/DeviceTree',
1229 ),
1230 ConfigItem(
1231 '--splash',
1232 metavar = 'BMP',
1233 type = pathlib.Path,
1234 help = 'splash image bitmap file [.splash section]',
1235 config_key = 'UKI/Splash',
1236 ),
1237 ConfigItem(
1238 '--pcrpkey',
1239 metavar = 'KEY',
1240 type = pathlib.Path,
1241 help = 'embedded public key to seal secrets to [.pcrpkey section]',
1242 config_key = 'UKI/PCRPKey',
1243 ),
1244 ConfigItem(
1245 '--uname',
1246 metavar='VERSION',
1247 help='"uname -r" information [.uname section]',
1248 config_key = 'UKI/Uname',
1249 ),
1250
1251 ConfigItem(
1252 '--efi-arch',
1253 metavar = 'ARCH',
1254 choices = ('ia32', 'x64', 'arm', 'aa64', 'riscv64'),
1255 help = 'target EFI architecture',
1256 config_key = 'UKI/EFIArch',
1257 ),
1258
1259 ConfigItem(
1260 '--stub',
1261 type = pathlib.Path,
1262 help = 'path to the sd-stub file [.text,.data,… sections]',
1263 config_key = 'UKI/Stub',
1264 ),
1265
1266 ConfigItem(
1267 '--sbat',
1268 metavar = 'TEXT|@PATH',
1269 help = 'SBAT policy [.sbat section]',
1270 default = [],
1271 action = 'append',
1272 config_key = 'UKI/SBAT',
1273 ),
1274
1275 ConfigItem(
1276 '--section',
1277 dest = 'sections',
1278 metavar = 'NAME:TEXT|@PATH',
1279 action = 'append',
1280 default = [],
1281 help = 'section as name and contents [NAME section] or section to print',
1282 ),
1283
1284 ConfigItem(
1285 '--pcr-banks',
1286 metavar = 'BANK…',
1287 type = parse_banks,
1288 config_key = 'UKI/PCRBanks',
1289 ),
1290
1291 ConfigItem(
1292 '--signing-engine',
1293 metavar = 'ENGINE',
1294 help = 'OpenSSL engine to use for signing',
1295 config_key = 'UKI/SigningEngine',
1296 ),
1297 ConfigItem(
1298 '--signtool',
1299 choices = ('sbsign', 'pesign'),
1300 dest = 'signtool',
1301 help = 'whether to use sbsign or pesign. It will also be inferred by the other \
1302 parameters given: when using --secureboot-{private-key/certificate}, sbsign \
1303 will be used, otherwise pesign will be used',
1304 config_key = 'UKI/SecureBootSigningTool',
1305 ),
1306 ConfigItem(
1307 '--secureboot-private-key',
1308 dest = 'sb_key',
1309 help = 'required by --signtool=sbsign. Path to key file or engine-specific designation for SB signing',
1310 config_key = 'UKI/SecureBootPrivateKey',
1311 ),
1312 ConfigItem(
1313 '--secureboot-certificate',
1314 dest = 'sb_cert',
1315 help = 'required by --signtool=sbsign. sbsign needs a path to certificate file or engine-specific designation for SB signing',
1316 config_key = 'UKI/SecureBootCertificate',
1317 ),
1318 ConfigItem(
1319 '--secureboot-certificate-dir',
1320 dest = 'sb_certdir',
1321 default = '/etc/pki/pesign',
1322 help = 'required by --signtool=pesign. Path to nss certificate database directory for PE signing. Default is /etc/pki/pesign',
1323 config_key = 'UKI/SecureBootCertificateDir',
1324 config_push = ConfigItem.config_set
1325 ),
1326 ConfigItem(
1327 '--secureboot-certificate-name',
1328 dest = 'sb_cert_name',
1329 help = 'required by --signtool=pesign. pesign needs a certificate nickname of nss certificate database entry to use for PE signing',
1330 config_key = 'UKI/SecureBootCertificateName',
1331 ),
1332 ConfigItem(
1333 '--secureboot-certificate-validity',
1334 metavar = 'DAYS',
1335 type = int,
1336 dest = 'sb_cert_validity',
1337 default = 365 * 10,
1338 help = "period of validity (in days) for a certificate created by 'genkey'",
1339 config_key = 'UKI/SecureBootCertificateValidity',
1340 config_push = ConfigItem.config_set
1341 ),
1342
1343 ConfigItem(
1344 '--sign-kernel',
1345 action = argparse.BooleanOptionalAction,
1346 help = 'Sign the embedded kernel',
1347 config_key = 'UKI/SignKernel',
1348 ),
1349
1350 ConfigItem(
1351 '--pcr-private-key',
1352 dest = 'pcr_private_keys',
1353 metavar = 'PATH',
1354 type = pathlib.Path,
1355 action = 'append',
1356 help = 'private part of the keypair for signing PCR signatures',
1357 config_key = 'PCRSignature:/PCRPrivateKey',
1358 config_push = ConfigItem.config_set_group,
1359 ),
1360 ConfigItem(
1361 '--pcr-public-key',
1362 dest = 'pcr_public_keys',
1363 metavar = 'PATH',
1364 type = pathlib.Path,
1365 action = 'append',
1366 help = 'public part of the keypair for signing PCR signatures',
1367 config_key = 'PCRSignature:/PCRPublicKey',
1368 config_push = ConfigItem.config_set_group,
1369 ),
1370 ConfigItem(
1371 '--phases',
1372 dest = 'phase_path_groups',
1373 metavar = 'PHASE-PATH…',
1374 type = parse_phase_paths,
1375 action = 'append',
1376 help = 'phase-paths to create signatures for',
1377 config_key = 'PCRSignature:/Phases',
1378 config_push = ConfigItem.config_set_group,
1379 ),
1380
1381 ConfigItem(
1382 '--tools',
1383 type = pathlib.Path,
1384 action = 'append',
1385 help = 'Directories to search for tools (systemd-measure, …)',
1386 ),
1387
1388 ConfigItem(
1389 ('--output', '-o'),
1390 type = pathlib.Path,
1391 help = 'output file path',
1392 ),
1393
1394 ConfigItem(
1395 '--measure',
1396 action = argparse.BooleanOptionalAction,
1397 help = 'print systemd-measure output for the UKI',
1398 ),
1399
1400 ConfigItem(
1401 '--json',
1402 choices = ('pretty', 'short', 'off'),
1403 default = 'off',
1404 help = 'generate JSON output',
1405 ),
1406 ConfigItem(
1407 '-j',
1408 dest='json',
1409 action='store_const',
1410 const='pretty',
1411 help='equivalent to --json=pretty',
1412 ),
1413
1414 ConfigItem(
1415 '--all',
1416 help = 'print all sections',
1417 action = 'store_true',
1418 ),
1419 ]
1420
1421 CONFIGFILE_ITEMS = { item.config_key:item
1422 for item in CONFIG_ITEMS
1423 if item.config_key }
1424
1425
1426 def apply_config(namespace, filename=None):
1427 if filename is None:
1428 if namespace.config:
1429 # Config set by the user, use that.
1430 filename = namespace.config
1431 print(f'Using config file: {filename}')
1432 else:
1433 # Try to look for a config file then use the first one found.
1434 for config_dir in DEFAULT_CONFIG_DIRS:
1435 filename = pathlib.Path(config_dir) / DEFAULT_CONFIG_FILE
1436 if filename.is_file():
1437 # Found a config file, use it.
1438 print(f'Using found config file: {filename}')
1439 break
1440 else:
1441 # No config file specified or found, nothing to do.
1442 return
1443
1444 # Fill in ._groups based on --pcr-public-key=, --pcr-private-key=, and --phases=.
1445 assert '_groups' not in namespace
1446 n_pcr_priv = len(namespace.pcr_private_keys or ())
1447 namespace._groups = list(range(n_pcr_priv)) # pylint: disable=protected-access
1448
1449 cp = configparser.ConfigParser(
1450 comment_prefixes='#',
1451 inline_comment_prefixes='#',
1452 delimiters='=',
1453 empty_lines_in_values=False,
1454 interpolation=None,
1455 strict=False)
1456 # Do not make keys lowercase
1457 cp.optionxform = lambda option: option
1458
1459 # The API is not great.
1460 read = cp.read(filename)
1461 if not read:
1462 raise IOError(f'Failed to read {filename}')
1463
1464 for section_name, section in cp.items():
1465 idx = section_name.find(':')
1466 if idx >= 0:
1467 section_name, group = section_name[:idx+1], section_name[idx+1:]
1468 if not section_name or not group:
1469 raise ValueError('Section name components cannot be empty')
1470 if ':' in group:
1471 raise ValueError('Section name cannot contain more than one ":"')
1472 else:
1473 group = None
1474 for key, value in section.items():
1475 if item := CONFIGFILE_ITEMS.get(f'{section_name}/{key}'):
1476 item.apply_config(namespace, section_name, group, key, value)
1477 else:
1478 print(f'Unknown config setting [{section_name}] {key}=')
1479
1480
1481 def config_example():
1482 prev_section = None
1483 for item in CONFIG_ITEMS:
1484 section, key, value = item.config_example()
1485 if section:
1486 if prev_section != section:
1487 if prev_section:
1488 yield ''
1489 yield f'[{section}]'
1490 prev_section = section
1491 yield f'{key} = {value}'
1492
1493
1494 class PagerHelpAction(argparse._HelpAction): # pylint: disable=protected-access
1495 def __call__(
1496 self,
1497 parser: argparse.ArgumentParser,
1498 namespace: argparse.Namespace,
1499 values: Union[str, Sequence[Any], None] = None,
1500 option_string: Optional[str] = None
1501 ) -> None:
1502 page(parser.format_help(), True)
1503 parser.exit()
1504
1505
1506 def create_parser():
1507 p = argparse.ArgumentParser(
1508 description='Build and sign Unified Kernel Images',
1509 usage='\n ' + textwrap.dedent('''\
1510 ukify {b}build{e} [--linux=LINUX] [--initrd=INITRD] [options…]
1511 ukify {b}genkey{e} [options…]
1512 ukify {b}inspect{e} FILE… [options…]
1513 ''').format(b=Style.bold, e=Style.reset),
1514 allow_abbrev=False,
1515 add_help=False,
1516 epilog='\n '.join(('config file:', *config_example())),
1517 formatter_class=argparse.RawDescriptionHelpFormatter,
1518 )
1519
1520 for item in CONFIG_ITEMS:
1521 item.add_to(p)
1522
1523 # Suppress printing of usage synopsis on errors
1524 p.error = lambda message: p.exit(2, f'{p.prog}: error: {message}\n')
1525
1526 # Make --help paged
1527 p.add_argument(
1528 '-h', '--help',
1529 action=PagerHelpAction,
1530 help='show this help message and exit',
1531 )
1532
1533 return p
1534
1535
1536 def finalize_options(opts):
1537 # Figure out which syntax is being used, one of:
1538 # ukify verb --arg --arg --arg
1539 # ukify linux initrd…
1540 if len(opts.positional) >= 1 and opts.positional[0] == 'inspect':
1541 opts.verb = opts.positional[0]
1542 opts.files = opts.positional[1:]
1543 if not opts.files:
1544 raise ValueError('file(s) to inspect must be specified')
1545 if len(opts.files) > 1 and opts.json != 'off':
1546 # We could allow this in the future, but we need to figure out the right structure
1547 raise ValueError('JSON output is not allowed with multiple files')
1548 elif len(opts.positional) == 1 and opts.positional[0] in VERBS:
1549 opts.verb = opts.positional[0]
1550 elif opts.linux or opts.initrd:
1551 raise ValueError('--linux/--initrd options cannot be used with positional arguments')
1552 else:
1553 print("Assuming obsolete command line syntax with no verb. Please use 'build'.")
1554 if opts.positional:
1555 opts.linux = pathlib.Path(opts.positional[0])
1556 # If we have initrds from parsing config files, append our positional args at the end
1557 opts.initrd = (opts.initrd or []) + [pathlib.Path(arg) for arg in opts.positional[1:]]
1558 opts.verb = 'build'
1559
1560 # Check that --pcr-public-key=, --pcr-private-key=, and --phases=
1561 # have either the same number of arguments are are not specified at all.
1562 n_pcr_pub = None if opts.pcr_public_keys is None else len(opts.pcr_public_keys)
1563 n_pcr_priv = None if opts.pcr_private_keys is None else len(opts.pcr_private_keys)
1564 n_phase_path_groups = None if opts.phase_path_groups is None else len(opts.phase_path_groups)
1565 if n_pcr_pub is not None and n_pcr_pub != n_pcr_priv:
1566 raise ValueError('--pcr-public-key= specifications must match --pcr-private-key=')
1567 if n_phase_path_groups is not None and n_phase_path_groups != n_pcr_priv:
1568 raise ValueError('--phases= specifications must match --pcr-private-key=')
1569
1570 if opts.cmdline and opts.cmdline.startswith('@'):
1571 opts.cmdline = pathlib.Path(opts.cmdline[1:])
1572 elif opts.cmdline:
1573 # Drop whitespace from the command line. If we're reading from a file,
1574 # we copy the contents verbatim. But configuration specified on the command line
1575 # or in the config file may contain additional whitespace that has no meaning.
1576 opts.cmdline = ' '.join(opts.cmdline.split())
1577
1578 if opts.os_release and opts.os_release.startswith('@'):
1579 opts.os_release = pathlib.Path(opts.os_release[1:])
1580 elif not opts.os_release and opts.linux:
1581 p = pathlib.Path('/etc/os-release')
1582 if not p.exists():
1583 p = pathlib.Path('/usr/lib/os-release')
1584 opts.os_release = p
1585
1586 if opts.efi_arch is None:
1587 opts.efi_arch = guess_efi_arch()
1588
1589 if opts.stub is None:
1590 if opts.linux is not None:
1591 opts.stub = pathlib.Path(f'/usr/lib/systemd/boot/efi/linux{opts.efi_arch}.efi.stub')
1592 else:
1593 opts.stub = pathlib.Path(f'/usr/lib/systemd/boot/efi/addon{opts.efi_arch}.efi.stub')
1594
1595 if opts.signing_engine is None:
1596 if opts.sb_key:
1597 opts.sb_key = pathlib.Path(opts.sb_key)
1598 if opts.sb_cert:
1599 opts.sb_cert = pathlib.Path(opts.sb_cert)
1600
1601 if bool(opts.sb_key) ^ bool(opts.sb_cert):
1602 # one param only given, sbsign needs both
1603 raise ValueError('--secureboot-private-key= and --secureboot-certificate= must be specified together')
1604 elif bool(opts.sb_key) and bool(opts.sb_cert):
1605 # both param given, infer sbsign and in case it was given, ensure signtool=sbsign
1606 if opts.signtool and opts.signtool != 'sbsign':
1607 raise ValueError(f'Cannot provide --signtool={opts.signtool} with --secureboot-private-key= and --secureboot-certificate=')
1608 opts.signtool = 'sbsign'
1609 elif bool(opts.sb_cert_name):
1610 # sb_cert_name given, infer pesign and in case it was given, ensure signtool=pesign
1611 if opts.signtool and opts.signtool != 'pesign':
1612 raise ValueError(f'Cannot provide --signtool={opts.signtool} with --secureboot-certificate-name=')
1613 opts.signtool = 'pesign'
1614
1615 if opts.sign_kernel and not opts.sb_key and not opts.sb_cert_name:
1616 raise ValueError('--sign-kernel requires either --secureboot-private-key= and --secureboot-certificate= (for sbsign) or --secureboot-certificate-name= (for pesign) to be specified')
1617
1618 if opts.verb == 'build' and opts.output is None:
1619 if opts.linux is None:
1620 raise ValueError('--output= must be specified when building a PE addon')
1621 suffix = '.efi' if opts.sb_key or opts.sb_cert_name else '.unsigned.efi'
1622 opts.output = opts.linux.name + suffix
1623
1624 # Now that we know if we're inputting or outputting, really parse section config
1625 f = Section.parse_output if opts.verb == 'inspect' else Section.parse_input
1626 opts.sections = [f(s) for s in opts.sections]
1627 # A convenience dictionary to make it easy to look up sections
1628 opts.sections_by_name = {s.name:s for s in opts.sections}
1629
1630 if opts.summary:
1631 # TODO: replace pprint() with some fancy formatting.
1632 pprint.pprint(vars(opts))
1633 sys.exit()
1634
1635
1636 def parse_args(args=None):
1637 opts = create_parser().parse_args(args)
1638 apply_config(opts)
1639 finalize_options(opts)
1640 return opts
1641
1642
1643 def main():
1644 opts = parse_args()
1645 if opts.verb == 'build':
1646 check_inputs(opts)
1647 make_uki(opts)
1648 elif opts.verb == 'genkey':
1649 check_cert_and_keys_nonexistent(opts)
1650 generate_keys(opts)
1651 elif opts.verb == 'inspect':
1652 inspect_sections(opts)
1653 else:
1654 assert False
1655
1656
1657 if __name__ == '__main__':
1658 main()