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