]> git.ipfire.org Git - thirdparty/systemd.git/blame - src/ukify/ukify.py
Merge pull request #30284 from YHNdnzj/fstab-wantedby-defaultdeps
[thirdparty/systemd.git] / src / ukify / ukify.py
CommitLineData
f4780cbe 1#!/usr/bin/env python3
eb81a60c 2# SPDX-License-Identifier: LGPL-2.1-or-later
d9c8f075
ZJS
3#
4# This file is part of systemd.
5#
6# systemd is free software; you can redistribute it and/or modify it
7# under the terms of the GNU Lesser General Public License as published by
8# the Free Software Foundation; either version 2.1 of the License, or
9# (at your option) any later version.
10#
11# systemd is distributed in the hope that it will be useful, but
12# WITHOUT ANY WARRANTY; without even the implied warranty of
13# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
14# General Public License for more details.
15#
16# You should have received a copy of the GNU Lesser General Public License
17# along with systemd; If not, see <https://www.gnu.org/licenses/>.
f4780cbe 18
79f902eb
FS
19# pylint: disable=import-outside-toplevel,consider-using-with,unused-argument
20# pylint: disable=unnecessary-lambda-assignment
f4780cbe
ZJS
21
22import argparse
5143a47a 23import configparser
a1c80efd 24import contextlib
f4780cbe
ZJS
25import collections
26import dataclasses
a1c80efd 27import datetime
f4780cbe
ZJS
28import fnmatch
29import itertools
30import json
31import os
32import pathlib
5143a47a 33import pprint
6fa79138 34import pydoc
f4780cbe
ZJS
35import re
36import shlex
c8add4c2 37import shutil
a1c80efd 38import socket
f4780cbe 39import subprocess
5143a47a 40import sys
f4780cbe 41import tempfile
df4a4673
EGE
42import textwrap
43from hashlib import sha256
a758f95c
ZJS
44from typing import (Any,
45 Callable,
46 IO,
47 Optional,
6fa79138 48 Sequence,
a758f95c 49 Union)
f4780cbe 50
a758f95c 51import pefile # type: ignore
f4780cbe 52
67b65ac6 53__version__ = '{{PROJECT_VERSION}} ({{GIT_VERSION}})'
30ec2eae 54
f4780cbe 55EFI_ARCH_MAP = {
7081db29
ZJS
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'],
5a945fab 60 'armv[45678]*l': ['arm'],
7081db29
ZJS
61 'loongarch32' : ['loongarch32'],
62 'loongarch64' : ['loongarch64'],
63 'riscv32' : ['riscv32'],
64 'riscv64' : ['riscv64'],
f4780cbe
ZJS
65}
66EFI_ARCHES: list[str] = sum(EFI_ARCH_MAP.values(), [])
67
a05fa30f
AA
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.
70DEFAULT_CONFIG_DIRS = ['/run/systemd', '/etc/systemd', '/usr/local/lib/systemd', '/usr/lib/systemd']
71DEFAULT_CONFIG_FILE = 'ukify.conf'
72
51faf836
ZJS
73class 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
f4780cbe
ZJS
81def 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
df4a4673 103 # print(f'Host arch {arch!r}, EFI arch {efi_arch!r}')
f4780cbe
ZJS
104 return efi_arch
105
106
6fa79138
ZJS
107def 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
f4780cbe
ZJS
116def 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
f4780cbe
ZJS
121def round_up(x, blocksize=4096):
122 return (x + blocksize - 1) // blocksize * blocksize
123
124
667578bb
ZJS
125def 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
483c9c1b
ZJS
132def 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
bf9f07a6
TP
142 if start.startswith(b'MZ'):
143 # not compressed aarch64 and riscv64
144 return f.read()
145
483c9c1b 146 if start.startswith(b'\x1f\x8b'):
667578bb 147 gzip = try_import('gzip')
483c9c1b
ZJS
148 return gzip.open(f).read()
149
150 if start.startswith(b'\x28\xb5\x2f\xfd'):
667578bb 151 zstd = try_import('zstd')
483c9c1b
ZJS
152 return zstd.uncompress(f.read())
153
154 if start.startswith(b'\x02\x21\x4c\x18'):
667578bb 155 lz4 = try_import('lz4.frame', 'lz4')
483c9c1b
ZJS
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!')
667578bb 160 lz4 = try_import('lz4.frame', 'lz4')
483c9c1b
ZJS
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'):
667578bb 168 bz2 = try_import('bz2', 'bzip2')
483c9c1b
ZJS
169 return bz2.open(f).read()
170
171 if start.startswith(b'\x5d\x00\x00'):
667578bb 172 lzma = try_import('lzma')
483c9c1b
ZJS
173 return lzma.open(f).read()
174
175 raise NotImplementedError(f'unknown file format (starts with {start})')
176
177
178class 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))
33bdec18
ZJS
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
483c9c1b
ZJS
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
df4a4673
EGE
257DEFAULT_SECTIONS_TO_SHOW = {
258 '.linux' : 'binary',
259 '.initrd' : 'binary',
260 '.splash' : 'binary',
e3696e6f 261 '.dtb' : 'binary',
df4a4673
EGE
262 '.cmdline' : 'text',
263 '.osrel' : 'text',
264 '.uname' : 'text',
265 '.pcrpkey' : 'text',
266 '.pcrsig' : 'text',
267 '.sbat' : 'text',
f99304fe 268 '.sbom' : 'binary',
df4a4673 269}
483c9c1b 270
f4780cbe
ZJS
271@dataclasses.dataclass
272class Section:
273 name: str
df4a4673 274 content: Optional[pathlib.Path]
a758f95c 275 tmpfile: Optional[IO] = None
f4780cbe 276 measure: bool = False
df4a4673 277 output_mode: Optional[str] = None
f4780cbe
ZJS
278
279 @classmethod
789a6427 280 def create(cls, name, contents, **kwargs):
09595fd5 281 if isinstance(contents, (str, bytes)):
54c84c8a
ZJS
282 mode = 'wt' if isinstance(contents, str) else 'wb'
283 tmp = tempfile.NamedTemporaryFile(mode=mode, prefix=f'tmp{name}')
f4780cbe
ZJS
284 tmp.write(contents)
285 tmp.flush()
286 contents = pathlib.Path(tmp.name)
287 else:
288 tmp = None
289
789a6427 290 return cls(name, contents, tmpfile=tmp, **kwargs)
f4780cbe
ZJS
291
292 @classmethod
df4a4673 293 def parse_input(cls, s):
f4780cbe
ZJS
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
df4a4673
EGE
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)
f4780cbe
ZJS
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
331class UKI:
a758f95c 332 executable: list[Union[pathlib.Path, str]]
f4780cbe 333 sections: list[Section] = dataclasses.field(default_factory=list, init=False)
f4780cbe
ZJS
334
335 def add_section(self, section):
f4780cbe
ZJS
336 if section.name in [s.name for s in self.sections]:
337 raise ValueError(f'Duplicate section {section.name}')
338
f4780cbe
ZJS
339 self.sections += [section]
340
341
342def parse_banks(s):
343 banks = re.split(r',|\s+', s)
344 # TODO: do some sanity checking here
345 return banks
346
347
348KNOWN_PHASES = (
349 'enter-initrd',
350 'leave-initrd',
351 'sysinit',
352 'ready',
353 'shutdown',
354 'final',
355)
356
357def 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
369def 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
383def check_inputs(opts):
384 for name, value in vars(opts).items():
385 if name in {'output', 'tools'}:
386 continue
387
5143a47a
ZJS
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()
f4780cbe
ZJS
395
396 check_splash(opts.splash)
397
398
a1c80efd
ZJS
399def 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
f4780cbe
ZJS
410def find_tool(name, fallback=None, opts=None):
411 if opts and opts.tools:
22ad038a
DDM
412 for d in opts.tools:
413 tool = d / name
414 if tool.exists():
415 return tool
f4780cbe 416
c8add4c2
JJ
417 if shutil.which(name) is not None:
418 return name
419
e673c5c2
EGE
420 if fallback is None:
421 print(f"Tool {name} not installed!")
f4780cbe 422
e673c5c2 423 return fallback
f4780cbe
ZJS
424
425def 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
9a1cb203
ZJS
435def key_path_groups(opts):
436 if not opts.pcr_private_keys:
437 return
438
a1c80efd 439 n_priv = len(opts.pcr_private_keys)
9a1cb203
ZJS
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
f4780cbe
ZJS
448def 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:
f4780cbe
ZJS
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
9a1cb203 494 for priv_key, pub_key, group in key_path_groups(opts):
f4780cbe
ZJS
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
54c84c8a 509def join_initrds(initrds):
a3b227d2 510 if not initrds:
09595fd5 511 return None
c4fc2546 512 if len(initrds) == 1:
09595fd5
DDM
513 return initrds[0]
514
515 seq = []
516 for file in initrds:
517 initrd = file.read_bytes()
c126c8ac
YW
518 n = len(initrd)
519 padding = b'\0' * (round_up(n, 4) - n) # pad to 32 bit alignment
09595fd5
DDM
520 seq += [initrd, padding]
521
522 return b''.join(seq)
54c84c8a
ZJS
523
524
c811aba0
DDM
525def pairwise(iterable):
526 a, b = itertools.tee(iterable)
527 next(b, None)
528 return zip(a, b)
529
530
7081db29 531class PEError(Exception):
3fc5eed4
JJ
532 pass
533
3fc1ae89 534
3fc5eed4
JJ
535def pe_add_sections(uki: UKI, output: str):
536 pe = pefile.PE(uki.executable, fast_load=True)
ac3412c3 537
7f72dca7
JJ
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
ac3412c3 551 # Old stubs might have been stripped, leading to unaligned raw data values, so let's fix them up here.
c4fc2546
ZJS
552 # pylint thinks that Structure doesn't have various members that it has…
553 # pylint: disable=no-member
554
ac3412c3
DDM
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)
3fc1ae89 573
3fc5eed4
JJ
574 warnings = pe.get_warnings()
575 if warnings:
7081db29 576 raise PEError(f'pefile warnings treated as errors: {warnings}')
3fc1ae89 577
3fc5eed4
JJ
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?
7081db29 581 raise PEError('Stub image is signed, refusing.')
3fc5eed4
JJ
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:
7081db29 589 raise PEError(f'Not enough header space to add section {section.name}.')
3fc5eed4 590
df4a4673 591 assert section.content
3fc5eed4
JJ
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)
ac3412c3
DDM
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)
3fc5eed4
JJ
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
c3f7501c
LB
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))
3fc5eed4 628
c3f7501c
LB
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)
3fc5eed4
JJ
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)
3fc1ae89 641
a8b645de 642def merge_sbat(input_pe: [pathlib.Path], input_text: [str]) -> str:
c3f7501c
LB
643 sbat = []
644
a8b645de 645 for f in input_pe:
c3f7501c
LB
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
a8b645de
LB
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
c3f7501c
LB
671 return 'sbat,1,SBAT Version,sbat,1,https://github.com/rhboot/shim/blob/main/SBAT.md\n' + '\n'.join(sbat) + "\n\x00"
672
c1e8d172
EGE
673def signer_sign(cmd):
674 print('+', shell_join(cmd))
675 subprocess.check_call(cmd)
3fc1ae89 676
c1e8d172
EGE
677def find_sbsign(opts=None):
678 return find_tool('sbsign', opts=opts)
f4780cbe 679
c1e8d172
EGE
680def sbsign_sign(sbsign_tool, input_f, output_f, opts=None):
681 sign_invocation = [
f4780cbe
ZJS
682 sbsign_tool,
683 '--key', opts.sb_key,
684 '--cert', opts.sb_cert,
c1e8d172
EGE
685 input_f,
686 '--output', output_f,
f4780cbe 687 ]
f4780cbe 688 if opts.signing_engine is not None:
c1e8d172
EGE
689 sign_invocation += ['--engine', opts.signing_engine]
690 signer_sign(sign_invocation)
691
692def find_pesign(opts=None):
693 return find_tool('pesign', opts=opts)
694
695def 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)
f4780cbe 704
c1e8d172
EGE
705SBVERIFY = {
706 'name': 'sbverify',
707 'option': '--list',
708 'output': 'No signature table present',
709}
f4780cbe 710
c1e8d172
EGE
711PESIGCHECK = {
712 'name': 'pesign',
713 'option': '-i',
714 'output': 'No signatures found.',
715 'flags': '-S'
716}
f4780cbe 717
c1e8d172
EGE
718def 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
733def make_uki(opts):
734 # kernel payload signing
735
736 sign_tool = None
c1e8d172 737 sign_args_present = opts.sb_key or opts.sb_cert_name
b708789d
EGE
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
f4780cbe 751
b708789d
EGE
752 if sign_tool is None:
753 raise ValueError(f'{opts.signtool}, required for signing, is not installed')
c1e8d172 754
b708789d
EGE
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)
f4780cbe 763
00e5933f 764 if opts.uname is None and opts.linux is not None:
483c9c1b
ZJS
765 print('Kernel version not specified, starting autodetection 😖.')
766 opts.uname = Uname.scrape(opts.linux, opts=opts)
767
f4780cbe 768 uki = UKI(opts.stub)
54c84c8a 769 initrd = join_initrds(opts.initrd)
f4780cbe 770
f4780cbe
ZJS
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]
d7d36252 775 elif opts.pcr_private_keys and len(opts.pcr_private_keys) == 1:
34c2fc85 776 from cryptography.hazmat.primitives import serialization
d7d36252
DDM
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 )
f4780cbe
ZJS
782
783 sections = [
784 # name, content, measure?
785 ('.osrel', opts.os_release, True ),
786 ('.cmdline', opts.cmdline, True ),
787 ('.dtb', opts.devicetree, True ),
b6f2e686 788 ('.uname', opts.uname, True ),
f4780cbe
ZJS
789 ('.splash', opts.splash, True ),
790 ('.pcrpkey', pcrpkey, True ),
54c84c8a 791 ('.initrd', initrd, True ),
f4780cbe
ZJS
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
00e5933f 805 if linux is not None:
a8b645de 806 # Merge the .sbat sections from stub, kernel and parameter, so that revocation can be done on either.
635c6ea6 807 uki.add_section(Section.create('.sbat', merge_sbat([opts.stub, linux], opts.sbat), measure=True))
a8b645de 808 else:
d713104a 809 # Addons don't use the stub so we add SBAT manually
a8b645de
LB
810 if not opts.sbat:
811 opts.sbat = ["""sbat,1,SBAT Version,sbat,1,https://github.com/rhboot/shim/blob/main/SBAT.md
812uki,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))
f4780cbe 815
d713104a
DDM
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
c1e8d172 828 if sign_args_present:
f4780cbe 829 unsigned = tempfile.NamedTemporaryFile(prefix='uki')
b708789d 830 unsigned_output = unsigned.name
f4780cbe 831 else:
b708789d 832 unsigned_output = opts.output
f4780cbe 833
b708789d 834 pe_add_sections(uki, unsigned_output)
3fc1ae89 835
f4780cbe
ZJS
836 # UKI signing
837
c1e8d172 838 if sign_args_present:
b708789d
EGE
839 assert sign
840 sign(sign_tool, unsigned_output, opts.output, opts=opts)
f4780cbe
ZJS
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
c1e8d172 846 print(f"Wrote {'signed' if sign_args_present else 'unsigned'} {opts.output}")
f4780cbe
ZJS
847
848
a1c80efd
ZJS
849@contextlib.contextmanager
850def 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
860def generate_key_cert_pair(
861 common_name: str,
814e4d7a 862 valid_days: int,
a1c80efd 863 keylength: int = 2048,
a1c80efd
ZJS
864) -> tuple[bytes]:
865
866 from cryptography import x509
750674da
VL
867 from cryptography.hazmat.primitives import serialization, hashes
868 from cryptography.hazmat.primitives.asymmetric import rsa
a1c80efd
ZJS
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
24baee33 874 now = datetime.datetime.now(datetime.UTC)
a1c80efd 875
750674da 876 key = rsa.generate_private_key(
a1c80efd
ZJS
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(
bf35f9c8 888 now + datetime.timedelta(days=valid_days)
a1c80efd
ZJS
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,
750674da 898 algorithm=hashes.SHA256(),
a1c80efd
ZJS
899 )
900
901 cert_pem = cert.public_bytes(
750674da 902 encoding=serialization.Encoding.PEM,
a1c80efd
ZJS
903 )
904 key_pem = key.private_bytes(
750674da
VL
905 encoding=serialization.Encoding.PEM,
906 format=serialization.PrivateFormat.TraditionalOpenSSL,
907 encryption_algorithm=serialization.NoEncryption(),
a1c80efd
ZJS
908 )
909
910 return key_pem, cert_pem
911
912
913def generate_priv_pub_key_pair(keylength : int = 2048) -> tuple[bytes]:
750674da
VL
914 from cryptography.hazmat.primitives import serialization
915 from cryptography.hazmat.primitives.asymmetric import rsa
a1c80efd 916
750674da 917 key = rsa.generate_private_key(
a1c80efd
ZJS
918 public_exponent=65537,
919 key_size=keylength,
920 )
921 priv_key_pem = key.private_bytes(
750674da
VL
922 encoding=serialization.Encoding.PEM,
923 format=serialization.PrivateFormat.TraditionalOpenSSL,
924 encryption_algorithm=serialization.NoEncryption(),
a1c80efd
ZJS
925 )
926 pub_key_pem = key.public_key().public_bytes(
750674da
VL
927 encoding=serialization.Encoding.PEM,
928 format=serialization.PublicFormat.SubjectPublicKeyInfo,
a1c80efd
ZJS
929 )
930
931 return priv_key_pem, pub_key_pem
932
933
934def generate_keys(opts):
f3d50fb2
ZJS
935 work = False
936
a1c80efd
ZJS
937 # This will generate keys and certificates and write them to the paths that
938 # are specified as input paths.
939 if opts.sb_key or opts.sb_cert:
940 fqdn = socket.getfqdn()
941 cn = f'SecureBoot signing key on host {fqdn}'
814e4d7a
ZJS
942 key_pem, cert_pem = generate_key_cert_pair(
943 common_name=cn,
944 valid_days=opts.sb_cert_validity,
945 )
a1c80efd
ZJS
946 print(f'Writing SecureBoot private key to {opts.sb_key}')
947 with temporary_umask(0o077):
948 opts.sb_key.write_bytes(key_pem)
9a27ef09 949 print(f'Writing SecureBoot certificate to {opts.sb_cert}')
a1c80efd
ZJS
950 opts.sb_cert.write_bytes(cert_pem)
951
f3d50fb2
ZJS
952 work = True
953
a1c80efd
ZJS
954 for priv_key, pub_key, _ in key_path_groups(opts):
955 priv_key_pem, pub_key_pem = generate_priv_pub_key_pair()
956
957 print(f'Writing private key for PCR signing to {priv_key}')
958 with temporary_umask(0o077):
959 priv_key.write_bytes(priv_key_pem)
960 if pub_key:
961 print(f'Writing public key for PCR signing to {pub_key}')
962 pub_key.write_bytes(pub_key_pem)
963
f3d50fb2
ZJS
964 work = True
965
966 if not work:
967 raise ValueError('genkey: --secureboot-private-key=/--secureboot-certificate= or --pcr-private-key/--pcr-public-key must be specified')
968
a1c80efd 969
df4a4673
EGE
970def inspect_section(opts, section):
971 name = section.Name.rstrip(b"\x00").decode()
972
973 # find the config for this section in opts and whether to show it
974 config = opts.sections_by_name.get(name, None)
975 show = (config or
976 opts.all or
977 (name in DEFAULT_SECTIONS_TO_SHOW and not opts.sections))
978 if not show:
979 return name, None
980
981 ttype = config.output_mode if config else DEFAULT_SECTIONS_TO_SHOW.get(name, 'binary')
982
df4a4673 983 size = section.Misc_VirtualSize
c1e70462
DDM
984 # TODO: Use ignore_padding once we can depend on a newer version of pefile
985 data = section.get_data(length=size)
df4a4673
EGE
986 digest = sha256(data).hexdigest()
987
988 struct = {
989 'size' : size,
990 'sha256' : digest,
991 }
992
993 if ttype == 'text':
994 try:
995 struct['text'] = data.decode()
996 except UnicodeDecodeError as e:
997 print(f"Section {name!r} is not valid text: {e}")
998 struct['text'] = '(not valid UTF-8)'
999
1000 if config and config.content:
1001 assert isinstance(config.content, pathlib.Path)
1002 config.content.write_bytes(data)
1003
1004 if opts.json == 'off':
1005 print(f"{name}:\n size: {size} bytes\n sha256: {digest}")
1006 if ttype == 'text':
1007 text = textwrap.indent(struct['text'].rstrip(), ' ' * 4)
1008 print(f" text:\n{text}")
1009
1010 return name, struct
1011
1012
1013def inspect_sections(opts):
1014 indent = 4 if opts.json == 'pretty' else None
1015
1016 for file in opts.files:
1017 pe = pefile.PE(file, fast_load=True)
1018 gen = (inspect_section(opts, section) for section in pe.sections)
1019 descs = {key:val for (key, val) in gen if val}
1020 if opts.json != 'off':
1021 json.dump(descs, sys.stdout, indent=indent)
1022
1023
5143a47a
ZJS
1024@dataclasses.dataclass(frozen=True)
1025class ConfigItem:
1026 @staticmethod
a758f95c
ZJS
1027 def config_list_prepend(
1028 namespace: argparse.Namespace,
1029 group: Optional[str],
1030 dest: str,
1031 value: Any,
1032 ) -> None:
5143a47a
ZJS
1033 "Prepend value to namespace.<dest>"
1034
1035 assert not group
1036
1037 old = getattr(namespace, dest, [])
0be1de7f
FS
1038 if old is None:
1039 old = []
5143a47a
ZJS
1040 setattr(namespace, dest, value + old)
1041
1042 @staticmethod
a758f95c
ZJS
1043 def config_set_if_unset(
1044 namespace: argparse.Namespace,
1045 group: Optional[str],
1046 dest: str,
1047 value: Any,
1048 ) -> None:
5143a47a
ZJS
1049 "Set namespace.<dest> to value only if it was None"
1050
1051 assert not group
1052
1053 if getattr(namespace, dest) is None:
1054 setattr(namespace, dest, value)
1055
12775754
EGE
1056 @staticmethod
1057 def config_set(
1058 namespace: argparse.Namespace,
1059 group: Optional[str],
1060 dest: str,
1061 value: Any,
1062 ) -> None:
1063 "Set namespace.<dest> to value only if it was None"
1064
1065 assert not group
1066
1067 setattr(namespace, dest, value)
1068
5143a47a 1069 @staticmethod
a758f95c
ZJS
1070 def config_set_group(
1071 namespace: argparse.Namespace,
1072 group: Optional[str],
1073 dest: str,
1074 value: Any,
1075 ) -> None:
5143a47a
ZJS
1076 "Set namespace.<dest>[idx] to value, with idx derived from group"
1077
c4fc2546 1078 # pylint: disable=protected-access
5143a47a
ZJS
1079 if group not in namespace._groups:
1080 namespace._groups += [group]
1081 idx = namespace._groups.index(group)
1082
1083 old = getattr(namespace, dest, None)
1084 if old is None:
1085 old = []
1086 setattr(namespace, dest,
1087 old + ([None] * (idx - len(old))) + [value])
1088
1089 @staticmethod
1090 def parse_boolean(s: str) -> bool:
1091 "Parse 1/true/yes/y/t/on as true and 0/false/no/n/f/off/None as false"
1092 s_l = s.lower()
1093 if s_l in {'1', 'true', 'yes', 'y', 't', 'on'}:
1094 return True
1095 if s_l in {'0', 'false', 'no', 'n', 'f', 'off'}:
1096 return False
1097 raise ValueError('f"Invalid boolean literal: {s!r}')
1098
1099 # arguments for argparse.ArgumentParser.add_argument()
a758f95c
ZJS
1100 name: Union[str, tuple[str, str]]
1101 dest: Optional[str] = None
1102 metavar: Optional[str] = None
1103 type: Optional[Callable] = None
1104 nargs: Optional[str] = None
1105 action: Optional[Union[str, Callable]] = None
1106 default: Any = None
1107 version: Optional[str] = None
1108 choices: Optional[tuple[str, ...]] = None
df4a4673 1109 const: Optional[Any] = None
a758f95c 1110 help: Optional[str] = None
5143a47a
ZJS
1111
1112 # metadata for config file parsing
a758f95c
ZJS
1113 config_key: Optional[str] = None
1114 config_push: Callable[[argparse.Namespace, Optional[str], str, Any], None] = \
1115 config_set_if_unset
5143a47a 1116
a758f95c 1117 def _names(self) -> tuple[str, ...]:
5143a47a
ZJS
1118 return self.name if isinstance(self.name, tuple) else (self.name,)
1119
1120 def argparse_dest(self) -> str:
1121 # It'd be nice if argparse exported this, but I don't see that in the API
1122 if self.dest:
1123 return self.dest
1124 return self._names()[0].lstrip('-').replace('-', '_')
1125
1126 def add_to(self, parser: argparse.ArgumentParser):
1127 kwargs = { key:val
1128 for key in dataclasses.asdict(self)
1129 if (key not in ('name', 'config_key', 'config_push') and
1130 (val := getattr(self, key)) is not None) }
1131 args = self._names()
1132 parser.add_argument(*args, **kwargs)
1133
1134 def apply_config(self, namespace, section, group, key, value) -> None:
1135 assert f'{section}/{key}' == self.config_key
1136 dest = self.argparse_dest()
1137
a758f95c 1138 conv: Callable[[str], Any]
5143a47a
ZJS
1139 if self.action == argparse.BooleanOptionalAction:
1140 # We need to handle this case separately: the options are called
1141 # --foo and --no-foo, and no argument is parsed. But in the config
1142 # file, we have Foo=yes or Foo=no.
1143 conv = self.parse_boolean
1144 elif self.type:
1145 conv = self.type
1146 else:
1147 conv = lambda s:s
1148
a3b227d2
ZJS
1149 # This is a bit ugly, but --initrd is the only option which is specified
1150 # with multiple args on the command line and a space-separated list in the
1151 # config file.
1152 if self.name == '--initrd':
5143a47a
ZJS
1153 value = [conv(v) for v in value.split()]
1154 else:
1155 value = conv(value)
1156
1157 self.config_push(namespace, group, dest, value)
1158
a758f95c 1159 def config_example(self) -> tuple[Optional[str], Optional[str], Optional[str]]:
5143a47a
ZJS
1160 if not self.config_key:
1161 return None, None, None
1162 section_name, key = self.config_key.split('/', 1)
1163 if section_name.endswith(':'):
1164 section_name += 'NAME'
1165 if self.choices:
1166 value = '|'.join(self.choices)
1167 else:
1168 value = self.metavar or self.argparse_dest().upper()
1169 return (section_name, key, value)
1170
1171
df4a4673 1172VERBS = ('build', 'genkey', 'inspect')
a3b227d2 1173
5143a47a 1174CONFIG_ITEMS = [
a3b227d2
ZJS
1175 ConfigItem(
1176 'positional',
1177 metavar = 'VERB',
1178 nargs = '*',
51faf836 1179 help = argparse.SUPPRESS,
a3b227d2
ZJS
1180 ),
1181
5143a47a
ZJS
1182 ConfigItem(
1183 '--version',
1184 action = 'version',
1185 version = f'ukify {__version__}',
1186 ),
1187
1188 ConfigItem(
1189 '--summary',
1190 help = 'print parsed config and exit',
1191 action = 'store_true',
1192 ),
1193
1194 ConfigItem(
a3b227d2 1195 '--linux',
5143a47a 1196 type = pathlib.Path,
5143a47a
ZJS
1197 help = 'vmlinuz file [.linux section]',
1198 config_key = 'UKI/Linux',
1199 ),
1200
1201 ConfigItem(
a3b227d2
ZJS
1202 '--initrd',
1203 metavar = 'INITRD',
5143a47a 1204 type = pathlib.Path,
a3b227d2
ZJS
1205 action = 'append',
1206 help = 'initrd file [part of .initrd section]',
5143a47a
ZJS
1207 config_key = 'UKI/Initrd',
1208 config_push = ConfigItem.config_list_prepend,
1209 ),
1210
1211 ConfigItem(
1212 ('--config', '-c'),
1213 metavar = 'PATH',
a05fa30f 1214 type = pathlib.Path,
5143a47a
ZJS
1215 help = 'configuration file',
1216 ),
1217
1218 ConfigItem(
1219 '--cmdline',
1220 metavar = 'TEXT|@PATH',
1221 help = 'kernel command line [.cmdline section]',
1222 config_key = 'UKI/Cmdline',
1223 ),
1224
1225 ConfigItem(
1226 '--os-release',
1227 metavar = 'TEXT|@PATH',
1228 help = 'path to os-release file [.osrel section]',
1229 config_key = 'UKI/OSRelease',
1230 ),
1231
1232 ConfigItem(
1233 '--devicetree',
1234 metavar = 'PATH',
1235 type = pathlib.Path,
1236 help = 'Device Tree file [.dtb section]',
1237 config_key = 'UKI/DeviceTree',
1238 ),
1239 ConfigItem(
1240 '--splash',
1241 metavar = 'BMP',
1242 type = pathlib.Path,
1243 help = 'splash image bitmap file [.splash section]',
1244 config_key = 'UKI/Splash',
1245 ),
1246 ConfigItem(
1247 '--pcrpkey',
1248 metavar = 'KEY',
1249 type = pathlib.Path,
1250 help = 'embedded public key to seal secrets to [.pcrpkey section]',
1251 config_key = 'UKI/PCRPKey',
1252 ),
1253 ConfigItem(
1254 '--uname',
1255 metavar='VERSION',
1256 help='"uname -r" information [.uname section]',
1257 config_key = 'UKI/Uname',
1258 ),
1259
1260 ConfigItem(
1261 '--efi-arch',
1262 metavar = 'ARCH',
1263 choices = ('ia32', 'x64', 'arm', 'aa64', 'riscv64'),
1264 help = 'target EFI architecture',
1265 config_key = 'UKI/EFIArch',
1266 ),
1267
1268 ConfigItem(
1269 '--stub',
1270 type = pathlib.Path,
1271 help = 'path to the sd-stub file [.text,.data,… sections]',
1272 config_key = 'UKI/Stub',
1273 ),
1274
c67d5a02
LB
1275 ConfigItem(
1276 '--sbat',
1277 metavar = 'TEXT|@PATH',
a8b645de
LB
1278 help = 'SBAT policy [.sbat section]',
1279 default = [],
1280 action = 'append',
1281 config_key = 'UKI/SBAT',
c67d5a02
LB
1282 ),
1283
5143a47a
ZJS
1284 ConfigItem(
1285 '--section',
1286 dest = 'sections',
1287 metavar = 'NAME:TEXT|@PATH',
5143a47a
ZJS
1288 action = 'append',
1289 default = [],
df4a4673 1290 help = 'section as name and contents [NAME section] or section to print',
5143a47a
ZJS
1291 ),
1292
1293 ConfigItem(
1294 '--pcr-banks',
1295 metavar = 'BANK…',
1296 type = parse_banks,
1297 config_key = 'UKI/PCRBanks',
1298 ),
1299
1300 ConfigItem(
1301 '--signing-engine',
1302 metavar = 'ENGINE',
1303 help = 'OpenSSL engine to use for signing',
1304 config_key = 'UKI/SigningEngine',
1305 ),
c1e8d172
EGE
1306 ConfigItem(
1307 '--signtool',
1308 choices = ('sbsign', 'pesign'),
1309 dest = 'signtool',
5c520781
EGE
1310 help = 'whether to use sbsign or pesign. It will also be inferred by the other \
1311 parameters given: when using --secureboot-{private-key/certificate}, sbsign \
1312 will be used, otherwise pesign will be used',
c1e8d172
EGE
1313 config_key = 'UKI/SecureBootSigningTool',
1314 ),
5143a47a
ZJS
1315 ConfigItem(
1316 '--secureboot-private-key',
1317 dest = 'sb_key',
c1e8d172 1318 help = 'required by --signtool=sbsign. Path to key file or engine-specific designation for SB signing',
5143a47a
ZJS
1319 config_key = 'UKI/SecureBootPrivateKey',
1320 ),
1321 ConfigItem(
1322 '--secureboot-certificate',
1323 dest = 'sb_cert',
c1e8d172 1324 help = 'required by --signtool=sbsign. sbsign needs a path to certificate file or engine-specific designation for SB signing',
5143a47a
ZJS
1325 config_key = 'UKI/SecureBootCertificate',
1326 ),
c1e8d172
EGE
1327 ConfigItem(
1328 '--secureboot-certificate-dir',
1329 dest = 'sb_certdir',
1330 default = '/etc/pki/pesign',
1331 help = 'required by --signtool=pesign. Path to nss certificate database directory for PE signing. Default is /etc/pki/pesign',
1332 config_key = 'UKI/SecureBootCertificateDir',
12775754 1333 config_push = ConfigItem.config_set
c1e8d172
EGE
1334 ),
1335 ConfigItem(
1336 '--secureboot-certificate-name',
1337 dest = 'sb_cert_name',
1338 help = 'required by --signtool=pesign. pesign needs a certificate nickname of nss certificate database entry to use for PE signing',
1339 config_key = 'UKI/SecureBootCertificateName',
1340 ),
814e4d7a
ZJS
1341 ConfigItem(
1342 '--secureboot-certificate-validity',
1343 metavar = 'DAYS',
bf35f9c8 1344 type = int,
814e4d7a
ZJS
1345 dest = 'sb_cert_validity',
1346 default = 365 * 10,
1347 help = "period of validity (in days) for a certificate created by 'genkey'",
1348 config_key = 'UKI/SecureBootCertificateValidity',
12775754 1349 config_push = ConfigItem.config_set
814e4d7a 1350 ),
5143a47a
ZJS
1351
1352 ConfigItem(
1353 '--sign-kernel',
1354 action = argparse.BooleanOptionalAction,
1355 help = 'Sign the embedded kernel',
1356 config_key = 'UKI/SignKernel',
1357 ),
1358
1359 ConfigItem(
1360 '--pcr-private-key',
1361 dest = 'pcr_private_keys',
1362 metavar = 'PATH',
1363 type = pathlib.Path,
1364 action = 'append',
1365 help = 'private part of the keypair for signing PCR signatures',
1366 config_key = 'PCRSignature:/PCRPrivateKey',
1367 config_push = ConfigItem.config_set_group,
1368 ),
1369 ConfigItem(
1370 '--pcr-public-key',
1371 dest = 'pcr_public_keys',
1372 metavar = 'PATH',
1373 type = pathlib.Path,
1374 action = 'append',
1375 help = 'public part of the keypair for signing PCR signatures',
1376 config_key = 'PCRSignature:/PCRPublicKey',
1377 config_push = ConfigItem.config_set_group,
1378 ),
1379 ConfigItem(
1380 '--phases',
1381 dest = 'phase_path_groups',
1382 metavar = 'PHASE-PATH…',
1383 type = parse_phase_paths,
1384 action = 'append',
1385 help = 'phase-paths to create signatures for',
1386 config_key = 'PCRSignature:/Phases',
1387 config_push = ConfigItem.config_set_group,
1388 ),
1389
1390 ConfigItem(
1391 '--tools',
1392 type = pathlib.Path,
1393 action = 'append',
1394 help = 'Directories to search for tools (systemd-measure, …)',
1395 ),
1396
1397 ConfigItem(
1398 ('--output', '-o'),
1399 type = pathlib.Path,
1400 help = 'output file path',
1401 ),
1402
1403 ConfigItem(
1404 '--measure',
1405 action = argparse.BooleanOptionalAction,
1406 help = 'print systemd-measure output for the UKI',
1407 ),
df4a4673
EGE
1408
1409 ConfigItem(
1410 '--json',
1411 choices = ('pretty', 'short', 'off'),
1412 default = 'off',
1413 help = 'generate JSON output',
1414 ),
1415 ConfigItem(
1416 '-j',
1417 dest='json',
1418 action='store_const',
1419 const='pretty',
1420 help='equivalent to --json=pretty',
1421 ),
1422
1423 ConfigItem(
1424 '--all',
1425 help = 'print all sections',
1426 action = 'store_true',
1427 ),
5143a47a
ZJS
1428]
1429
1430CONFIGFILE_ITEMS = { item.config_key:item
1431 for item in CONFIG_ITEMS
1432 if item.config_key }
1433
1434
1435def apply_config(namespace, filename=None):
1436 if filename is None:
a05fa30f
AA
1437 if namespace.config:
1438 # Config set by the user, use that.
1439 filename = namespace.config
1440 print(f'Using config file: {filename}')
1441 else:
1442 # Try to look for a config file then use the first one found.
1443 for config_dir in DEFAULT_CONFIG_DIRS:
1444 filename = pathlib.Path(config_dir) / DEFAULT_CONFIG_FILE
1445 if filename.is_file():
1446 # Found a config file, use it.
1447 print(f'Using found config file: {filename}')
1448 break
1449 else:
1450 # No config file specified or found, nothing to do.
1451 return
5143a47a
ZJS
1452
1453 # Fill in ._groups based on --pcr-public-key=, --pcr-private-key=, and --phases=.
1454 assert '_groups' not in namespace
1455 n_pcr_priv = len(namespace.pcr_private_keys or ())
c4fc2546 1456 namespace._groups = list(range(n_pcr_priv)) # pylint: disable=protected-access
5143a47a
ZJS
1457
1458 cp = configparser.ConfigParser(
1459 comment_prefixes='#',
1460 inline_comment_prefixes='#',
1461 delimiters='=',
1462 empty_lines_in_values=False,
1463 interpolation=None,
1464 strict=False)
1465 # Do not make keys lowercase
1466 cp.optionxform = lambda option: option
1467
594e27b0
ZJS
1468 # The API is not great.
1469 read = cp.read(filename)
1470 if not read:
1471 raise IOError(f'Failed to read {filename}')
5143a47a
ZJS
1472
1473 for section_name, section in cp.items():
1474 idx = section_name.find(':')
1475 if idx >= 0:
1476 section_name, group = section_name[:idx+1], section_name[idx+1:]
1477 if not section_name or not group:
1478 raise ValueError('Section name components cannot be empty')
1479 if ':' in group:
1480 raise ValueError('Section name cannot contain more than one ":"')
1481 else:
1482 group = None
1483 for key, value in section.items():
1484 if item := CONFIGFILE_ITEMS.get(f'{section_name}/{key}'):
1485 item.apply_config(namespace, section_name, group, key, value)
1486 else:
1487 print(f'Unknown config setting [{section_name}] {key}=')
1488
1489
1490def config_example():
1491 prev_section = None
1492 for item in CONFIG_ITEMS:
1493 section, key, value = item.config_example()
1494 if section:
1495 if prev_section != section:
1496 if prev_section:
1497 yield ''
1498 yield f'[{section}]'
1499 prev_section = section
1500 yield f'{key} = {value}'
1501
1502
6fa79138
ZJS
1503class PagerHelpAction(argparse._HelpAction): # pylint: disable=protected-access
1504 def __call__(
1505 self,
1506 parser: argparse.ArgumentParser,
1507 namespace: argparse.Namespace,
1508 values: Union[str, Sequence[Any], None] = None,
1509 option_string: Optional[str] = None
1510 ) -> None:
1511 page(parser.format_help(), True)
1512 parser.exit()
1513
1514
5143a47a 1515def create_parser():
f4780cbe
ZJS
1516 p = argparse.ArgumentParser(
1517 description='Build and sign Unified Kernel Images',
51faf836
ZJS
1518 usage='\n ' + textwrap.dedent('''\
1519 ukify {b}build{e} [--linux=LINUX] [--initrd=INITRD] [options…]
1520 ukify {b}genkey{e} [options…]
1521 ukify {b}inspect{e} FILE… [options…]
1522 ''').format(b=Style.bold, e=Style.reset),
f4780cbe 1523 allow_abbrev=False,
6fa79138 1524 add_help=False,
5143a47a
ZJS
1525 epilog='\n '.join(('config file:', *config_example())),
1526 formatter_class=argparse.RawDescriptionHelpFormatter,
1527 )
1528
1529 for item in CONFIG_ITEMS:
1530 item.add_to(p)
f4780cbe
ZJS
1531
1532 # Suppress printing of usage synopsis on errors
1533 p.error = lambda message: p.exit(2, f'{p.prog}: error: {message}\n')
1534
6fa79138
ZJS
1535 # Make --help paged
1536 p.add_argument(
1537 '-h', '--help',
1538 action=PagerHelpAction,
1539 help='show this help message and exit',
1540 )
1541
5143a47a 1542 return p
f4780cbe 1543
095ff238 1544
5143a47a 1545def finalize_options(opts):
b09a5315
ZJS
1546 # Figure out which syntax is being used, one of:
1547 # ukify verb --arg --arg --arg
1548 # ukify linux initrd…
f0876c7a 1549 if len(opts.positional) >= 1 and opts.positional[0] == 'inspect':
df4a4673
EGE
1550 opts.verb = opts.positional[0]
1551 opts.files = opts.positional[1:]
1552 if not opts.files:
1553 raise ValueError('file(s) to inspect must be specified')
1554 if len(opts.files) > 1 and opts.json != 'off':
1555 # We could allow this in the future, but we need to figure out the right structure
1556 raise ValueError('JSON output is not allowed with multiple files')
1557 elif len(opts.positional) == 1 and opts.positional[0] in VERBS:
b09a5315
ZJS
1558 opts.verb = opts.positional[0]
1559 elif opts.linux or opts.initrd:
1560 raise ValueError('--linux/--initrd options cannot be used with positional arguments')
1561 else:
7c52d523 1562 print("Assuming obsolete command line syntax with no verb. Please use 'build'.")
b09a5315
ZJS
1563 if opts.positional:
1564 opts.linux = pathlib.Path(opts.positional[0])
1565 # If we have initrds from parsing config files, append our positional args at the end
1566 opts.initrd = (opts.initrd or []) + [pathlib.Path(arg) for arg in opts.positional[1:]]
1567 opts.verb = 'build'
1568
1569 # Check that --pcr-public-key=, --pcr-private-key=, and --phases=
1570 # have either the same number of arguments are are not specified at all.
1571 n_pcr_pub = None if opts.pcr_public_keys is None else len(opts.pcr_public_keys)
1572 n_pcr_priv = None if opts.pcr_private_keys is None else len(opts.pcr_private_keys)
1573 n_phase_path_groups = None if opts.phase_path_groups is None else len(opts.phase_path_groups)
1574 if n_pcr_pub is not None and n_pcr_pub != n_pcr_priv:
1575 raise ValueError('--pcr-public-key= specifications must match --pcr-private-key=')
1576 if n_phase_path_groups is not None and n_phase_path_groups != n_pcr_priv:
1577 raise ValueError('--phases= specifications must match --pcr-private-key=')
1578
f4780cbe 1579 if opts.cmdline and opts.cmdline.startswith('@'):
5143a47a
ZJS
1580 opts.cmdline = pathlib.Path(opts.cmdline[1:])
1581 elif opts.cmdline:
7c52d523
JB
1582 # Drop whitespace from the command line. If we're reading from a file,
1583 # we copy the contents verbatim. But configuration specified on the command line
5143a47a
ZJS
1584 # or in the config file may contain additional whitespace that has no meaning.
1585 opts.cmdline = ' '.join(opts.cmdline.split())
1586
1587 if opts.os_release and opts.os_release.startswith('@'):
1588 opts.os_release = pathlib.Path(opts.os_release[1:])
1589 elif not opts.os_release and opts.linux:
f4780cbe
ZJS
1590 p = pathlib.Path('/etc/os-release')
1591 if not p.exists():
5143a47a 1592 p = pathlib.Path('/usr/lib/os-release')
f4780cbe
ZJS
1593 opts.os_release = p
1594
1595 if opts.efi_arch is None:
1596 opts.efi_arch = guess_efi_arch()
1597
1598 if opts.stub is None:
f644ea3e
LB
1599 if opts.linux is not None:
1600 opts.stub = pathlib.Path(f'/usr/lib/systemd/boot/efi/linux{opts.efi_arch}.efi.stub')
1601 else:
1602 opts.stub = pathlib.Path(f'/usr/lib/systemd/boot/efi/addon{opts.efi_arch}.efi.stub')
f4780cbe
ZJS
1603
1604 if opts.signing_engine is None:
5143a47a
ZJS
1605 if opts.sb_key:
1606 opts.sb_key = pathlib.Path(opts.sb_key)
1607 if opts.sb_cert:
1608 opts.sb_cert = pathlib.Path(opts.sb_cert)
f4780cbe 1609
5c520781 1610 if bool(opts.sb_key) ^ bool(opts.sb_cert):
d19434fb 1611 # one param only given, sbsign needs both
5c520781
EGE
1612 raise ValueError('--secureboot-private-key= and --secureboot-certificate= must be specified together')
1613 elif bool(opts.sb_key) and bool(opts.sb_cert):
1614 # both param given, infer sbsign and in case it was given, ensure signtool=sbsign
1615 if opts.signtool and opts.signtool != 'sbsign':
1616 raise ValueError(f'Cannot provide --signtool={opts.signtool} with --secureboot-private-key= and --secureboot-certificate=')
1617 opts.signtool = 'sbsign'
1618 elif bool(opts.sb_cert_name):
1619 # sb_cert_name given, infer pesign and in case it was given, ensure signtool=pesign
1620 if opts.signtool and opts.signtool != 'pesign':
1621 raise ValueError(f'Cannot provide --signtool={opts.signtool} with --secureboot-certificate-name=')
1622 opts.signtool = 'pesign'
f4780cbe 1623
c1e8d172
EGE
1624 if opts.sign_kernel and not opts.sb_key and not opts.sb_cert_name:
1625 raise ValueError('--sign-kernel requires either --secureboot-private-key= and --secureboot-certificate= (for sbsign) or --secureboot-certificate-name= (for pesign) to be specified')
f4780cbe 1626
a1c80efd 1627 if opts.verb == 'build' and opts.output is None:
5143a47a
ZJS
1628 if opts.linux is None:
1629 raise ValueError('--output= must be specified when building a PE addon')
c1e8d172 1630 suffix = '.efi' if opts.sb_key or opts.sb_cert_name else '.unsigned.efi'
5143a47a
ZJS
1631 opts.output = opts.linux.name + suffix
1632
df4a4673
EGE
1633 # Now that we know if we're inputting or outputting, really parse section config
1634 f = Section.parse_output if opts.verb == 'inspect' else Section.parse_input
1635 opts.sections = [f(s) for s in opts.sections]
1636 # A convenience dictionary to make it easy to look up sections
1637 opts.sections_by_name = {s.name:s for s in opts.sections}
5143a47a
ZJS
1638
1639 if opts.summary:
1640 # TODO: replace pprint() with some fancy formatting.
1641 pprint.pprint(vars(opts))
1642 sys.exit()
1643
1644
1645def parse_args(args=None):
b09a5315 1646 opts = create_parser().parse_args(args)
5143a47a 1647 apply_config(opts)
5143a47a 1648 finalize_options(opts)
f4780cbe
ZJS
1649 return opts
1650
1651
1652def main():
1653 opts = parse_args()
a1c80efd
ZJS
1654 if opts.verb == 'build':
1655 check_inputs(opts)
1656 make_uki(opts)
1657 elif opts.verb == 'genkey':
1658 check_cert_and_keys_nonexistent(opts)
1659 generate_keys(opts)
df4a4673
EGE
1660 elif opts.verb == 'inspect':
1661 inspect_sections(opts)
a1c80efd
ZJS
1662 else:
1663 assert False
f4780cbe
ZJS
1664
1665
1666if __name__ == '__main__':
1667 main()