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