]> git.ipfire.org Git - thirdparty/systemd.git/blame - src/ukify/ukify.py
test_ukify: pass through path to addon stub
[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
f4780cbe
ZJS
28import collections
29import dataclasses
30import fnmatch
31import itertools
32import json
33import os
34import pathlib
5143a47a 35import pprint
f4780cbe
ZJS
36import re
37import shlex
c8add4c2 38import shutil
f4780cbe 39import subprocess
5143a47a 40import sys
f4780cbe 41import tempfile
a758f95c
ZJS
42from typing import (Any,
43 Callable,
44 IO,
45 Optional,
46 Union)
f4780cbe 47
a758f95c 48import pefile # type: ignore
f4780cbe 49
67b65ac6 50__version__ = '{{PROJECT_VERSION}} ({{GIT_VERSION}})'
30ec2eae 51
f4780cbe 52EFI_ARCH_MAP = {
7081db29
ZJS
53 # host_arch glob : [efi_arch, 32_bit_efi_arch if mixed mode is supported]
54 'x86_64' : ['x64', 'ia32'],
55 'i[3456]86' : ['ia32'],
56 'aarch64' : ['aa64'],
57 'arm[45678]*l' : ['arm'],
58 'loongarch32' : ['loongarch32'],
59 'loongarch64' : ['loongarch64'],
60 'riscv32' : ['riscv32'],
61 'riscv64' : ['riscv64'],
f4780cbe
ZJS
62}
63EFI_ARCHES: list[str] = sum(EFI_ARCH_MAP.values(), [])
64
65def guess_efi_arch():
66 arch = os.uname().machine
67
68 for glob, mapping in EFI_ARCH_MAP.items():
69 if fnmatch.fnmatch(arch, glob):
70 efi_arch, *fallback = mapping
71 break
72 else:
73 raise ValueError(f'Unsupported architecture {arch}')
74
75 # This makes sense only on some architectures, but it also probably doesn't
76 # hurt on others, so let's just apply the check everywhere.
77 if fallback:
78 fw_platform_size = pathlib.Path('/sys/firmware/efi/fw_platform_size')
79 try:
80 size = fw_platform_size.read_text().strip()
81 except FileNotFoundError:
82 pass
83 else:
84 if int(size) == 32:
85 efi_arch = fallback[0]
86
87 print(f'Host arch {arch!r}, EFI arch {efi_arch!r}')
88 return efi_arch
89
90
91def shell_join(cmd):
92 # TODO: drop in favour of shlex.join once shlex.join supports pathlib.Path.
93 return ' '.join(shlex.quote(str(x)) for x in cmd)
94
95
f4780cbe
ZJS
96def round_up(x, blocksize=4096):
97 return (x + blocksize - 1) // blocksize * blocksize
98
99
667578bb
ZJS
100def try_import(modname, name=None):
101 try:
102 return __import__(modname)
103 except ImportError as e:
104 raise ValueError(f'Kernel is compressed with {name or modname}, but module unavailable') from e
105
106
483c9c1b
ZJS
107def maybe_decompress(filename):
108 """Decompress file if compressed. Return contents."""
109 f = open(filename, 'rb')
110 start = f.read(4)
111 f.seek(0)
112
113 if start.startswith(b'\x7fELF'):
114 # not compressed
115 return f.read()
116
bf9f07a6
TP
117 if start.startswith(b'MZ'):
118 # not compressed aarch64 and riscv64
119 return f.read()
120
483c9c1b 121 if start.startswith(b'\x1f\x8b'):
667578bb 122 gzip = try_import('gzip')
483c9c1b
ZJS
123 return gzip.open(f).read()
124
125 if start.startswith(b'\x28\xb5\x2f\xfd'):
667578bb 126 zstd = try_import('zstd')
483c9c1b
ZJS
127 return zstd.uncompress(f.read())
128
129 if start.startswith(b'\x02\x21\x4c\x18'):
667578bb 130 lz4 = try_import('lz4.frame', 'lz4')
483c9c1b
ZJS
131 return lz4.frame.decompress(f.read())
132
133 if start.startswith(b'\x04\x22\x4d\x18'):
134 print('Newer lz4 stream format detected! This may not boot!')
667578bb 135 lz4 = try_import('lz4.frame', 'lz4')
483c9c1b
ZJS
136 return lz4.frame.decompress(f.read())
137
138 if start.startswith(b'\x89LZO'):
139 # python3-lzo is not packaged for Fedora
140 raise NotImplementedError('lzo decompression not implemented')
141
142 if start.startswith(b'BZh'):
667578bb 143 bz2 = try_import('bz2', 'bzip2')
483c9c1b
ZJS
144 return bz2.open(f).read()
145
146 if start.startswith(b'\x5d\x00\x00'):
667578bb 147 lzma = try_import('lzma')
483c9c1b
ZJS
148 return lzma.open(f).read()
149
150 raise NotImplementedError(f'unknown file format (starts with {start})')
151
152
153class Uname:
154 # This class is here purely as a namespace for the functions
155
156 VERSION_PATTERN = r'(?P<version>[a-z0-9._-]+) \([^ )]+\) (?:#.*)'
157
158 NOTES_PATTERN = r'^\s+Linux\s+0x[0-9a-f]+\s+OPEN\n\s+description data: (?P<version>[0-9a-f ]+)\s*$'
159
160 # Linux version 6.0.8-300.fc37.ppc64le (mockbuild@buildvm-ppc64le-03.iad2.fedoraproject.org)
161 # (gcc (GCC) 12.2.1 20220819 (Red Hat 12.2.1-2), GNU ld version 2.38-24.fc37)
162 # #1 SMP Fri Nov 11 14:39:11 UTC 2022
163 TEXT_PATTERN = rb'Linux version (?P<version>\d\.\S+) \('
164
165 @classmethod
166 def scrape_x86(cls, filename, opts=None):
167 # Based on https://gitlab.archlinux.org/archlinux/mkinitcpio/mkinitcpio/-/blob/master/functions#L136
168 # and https://www.kernel.org/doc/html/latest/x86/boot.html#the-real-mode-kernel-header
169 with open(filename, 'rb') as f:
170 f.seek(0x202)
171 magic = f.read(4)
172 if magic != b'HdrS':
173 raise ValueError('Real-Mode Kernel Header magic not found')
174 f.seek(0x20E)
175 offset = f.read(1)[0] + f.read(1)[0]*256 # Pointer to kernel version string
176 f.seek(0x200 + offset)
177 text = f.read(128)
178 text = text.split(b'\0', maxsplit=1)[0]
179 text = text.decode()
180
181 if not (m := re.match(cls.VERSION_PATTERN, text)):
182 raise ValueError(f'Cannot parse version-host-release uname string: {text!r}')
183 return m.group('version')
184
185 @classmethod
186 def scrape_elf(cls, filename, opts=None):
187 readelf = find_tool('readelf', opts=opts)
188
189 cmd = [
190 readelf,
191 '--notes',
192 filename,
193 ]
194
195 print('+', shell_join(cmd))
33bdec18
ZJS
196 try:
197 notes = subprocess.check_output(cmd, stderr=subprocess.PIPE, text=True)
198 except subprocess.CalledProcessError as e:
199 raise ValueError(e.stderr.strip()) from e
483c9c1b
ZJS
200
201 if not (m := re.search(cls.NOTES_PATTERN, notes, re.MULTILINE)):
202 raise ValueError('Cannot find Linux version note')
203
204 text = ''.join(chr(int(c, 16)) for c in m.group('version').split())
205 return text.rstrip('\0')
206
207 @classmethod
208 def scrape_generic(cls, filename, opts=None):
209 # import libarchive
210 # libarchive-c fails with
211 # ArchiveError: Unrecognized archive format (errno=84, retcode=-30, archive_p=94705420454656)
212
213 # Based on https://gitlab.archlinux.org/archlinux/mkinitcpio/mkinitcpio/-/blob/master/functions#L209
214
215 text = maybe_decompress(filename)
216 if not (m := re.search(cls.TEXT_PATTERN, text)):
217 raise ValueError(f'Cannot find {cls.TEXT_PATTERN!r} in {filename}')
218
219 return m.group('version').decode()
220
221 @classmethod
222 def scrape(cls, filename, opts=None):
223 for func in (cls.scrape_x86, cls.scrape_elf, cls.scrape_generic):
224 try:
225 version = func(filename, opts=opts)
226 print(f'Found uname version: {version}')
227 return version
228 except ValueError as e:
229 print(str(e))
230 return None
231
232
f4780cbe
ZJS
233@dataclasses.dataclass
234class Section:
235 name: str
236 content: pathlib.Path
a758f95c 237 tmpfile: Optional[IO] = None
f4780cbe
ZJS
238 measure: bool = False
239
240 @classmethod
789a6427 241 def create(cls, name, contents, **kwargs):
09595fd5 242 if isinstance(contents, (str, bytes)):
54c84c8a
ZJS
243 mode = 'wt' if isinstance(contents, str) else 'wb'
244 tmp = tempfile.NamedTemporaryFile(mode=mode, prefix=f'tmp{name}')
f4780cbe
ZJS
245 tmp.write(contents)
246 tmp.flush()
247 contents = pathlib.Path(tmp.name)
248 else:
249 tmp = None
250
789a6427 251 return cls(name, contents, tmpfile=tmp, **kwargs)
f4780cbe
ZJS
252
253 @classmethod
254 def parse_arg(cls, s):
255 try:
256 name, contents, *rest = s.split(':')
257 except ValueError as e:
258 raise ValueError(f'Cannot parse section spec (name or contents missing): {s!r}') from e
259 if rest:
260 raise ValueError(f'Cannot parse section spec (extraneous parameters): {s!r}')
261
262 if contents.startswith('@'):
263 contents = pathlib.Path(contents[1:])
264
265 return cls.create(name, contents)
266
267 def size(self):
268 return self.content.stat().st_size
269
270 def check_name(self):
271 # PE section names with more than 8 characters are legal, but our stub does
272 # not support them.
273 if not self.name.isascii() or not self.name.isprintable():
274 raise ValueError(f'Bad section name: {self.name!r}')
275 if len(self.name) > 8:
276 raise ValueError(f'Section name too long: {self.name!r}')
277
278
279@dataclasses.dataclass
280class UKI:
a758f95c 281 executable: list[Union[pathlib.Path, str]]
f4780cbe 282 sections: list[Section] = dataclasses.field(default_factory=list, init=False)
f4780cbe
ZJS
283
284 def add_section(self, section):
f4780cbe
ZJS
285 if section.name in [s.name for s in self.sections]:
286 raise ValueError(f'Duplicate section {section.name}')
287
f4780cbe
ZJS
288 self.sections += [section]
289
290
291def parse_banks(s):
292 banks = re.split(r',|\s+', s)
293 # TODO: do some sanity checking here
294 return banks
295
296
297KNOWN_PHASES = (
298 'enter-initrd',
299 'leave-initrd',
300 'sysinit',
301 'ready',
302 'shutdown',
303 'final',
304)
305
306def parse_phase_paths(s):
307 # Split on commas or whitespace here. Commas might be hard to parse visually.
308 paths = re.split(r',|\s+', s)
309
310 for path in paths:
311 for phase in path.split(':'):
312 if phase not in KNOWN_PHASES:
313 raise argparse.ArgumentTypeError(f'Unknown boot phase {phase!r} ({path=})')
314
315 return paths
316
317
318def check_splash(filename):
319 if filename is None:
320 return
321
322 # import is delayed, to avoid import when the splash image is not used
323 try:
324 from PIL import Image
325 except ImportError:
326 return
327
328 img = Image.open(filename, formats=['BMP'])
329 print(f'Splash image {filename} is {img.width}×{img.height} pixels')
330
331
332def check_inputs(opts):
333 for name, value in vars(opts).items():
334 if name in {'output', 'tools'}:
335 continue
336
5143a47a
ZJS
337 if isinstance(value, pathlib.Path):
338 # Open file to check that we can read it, or generate an exception
339 value.open().close()
340 elif isinstance(value, list):
341 for item in value:
342 if isinstance(item, pathlib.Path):
343 item.open().close()
f4780cbe
ZJS
344
345 check_splash(opts.splash)
346
347
348def find_tool(name, fallback=None, opts=None):
349 if opts and opts.tools:
22ad038a
DDM
350 for d in opts.tools:
351 tool = d / name
352 if tool.exists():
353 return tool
f4780cbe 354
c8add4c2
JJ
355 if shutil.which(name) is not None:
356 return name
357
e673c5c2
EGE
358 if fallback is None:
359 print(f"Tool {name} not installed!")
f4780cbe 360
e673c5c2 361 return fallback
f4780cbe
ZJS
362
363def combine_signatures(pcrsigs):
364 combined = collections.defaultdict(list)
365 for pcrsig in pcrsigs:
366 for bank, sigs in pcrsig.items():
367 for sig in sigs:
368 if sig not in combined[bank]:
369 combined[bank] += [sig]
370 return json.dumps(combined)
371
372
373def call_systemd_measure(uki, linux, opts):
374 measure_tool = find_tool('systemd-measure',
375 '/usr/lib/systemd/systemd-measure',
376 opts=opts)
377
378 banks = opts.pcr_banks or ()
379
380 # PCR measurement
381
382 if opts.measure:
383 pp_groups = opts.phase_path_groups or []
384
385 cmd = [
386 measure_tool,
387 'calculate',
388 f'--linux={linux}',
389 *(f"--{s.name.removeprefix('.')}={s.content}"
390 for s in uki.sections
391 if s.measure),
392 *(f'--bank={bank}'
393 for bank in banks),
394 # For measurement, the keys are not relevant, so we can lump all the phase paths
395 # into one call to systemd-measure calculate.
396 *(f'--phase={phase_path}'
397 for phase_path in itertools.chain.from_iterable(pp_groups)),
398 ]
399
400 print('+', shell_join(cmd))
401 subprocess.check_call(cmd)
402
403 # PCR signing
404
405 if opts.pcr_private_keys:
406 n_priv = len(opts.pcr_private_keys or ())
407 pp_groups = opts.phase_path_groups or [None] * n_priv
408 pub_keys = opts.pcr_public_keys or [None] * n_priv
409
410 pcrsigs = []
411
412 cmd = [
413 measure_tool,
414 'sign',
415 f'--linux={linux}',
416 *(f"--{s.name.removeprefix('.')}={s.content}"
417 for s in uki.sections
418 if s.measure),
419 *(f'--bank={bank}'
420 for bank in banks),
421 ]
422
423 for priv_key, pub_key, group in zip(opts.pcr_private_keys,
424 pub_keys,
425 pp_groups):
426 extra = [f'--private-key={priv_key}']
427 if pub_key:
428 extra += [f'--public-key={pub_key}']
429 extra += [f'--phase={phase_path}' for phase_path in group or ()]
430
431 print('+', shell_join(cmd + extra))
432 pcrsig = subprocess.check_output(cmd + extra, text=True)
433 pcrsig = json.loads(pcrsig)
434 pcrsigs += [pcrsig]
435
436 combined = combine_signatures(pcrsigs)
437 uki.add_section(Section.create('.pcrsig', combined))
438
439
54c84c8a 440def join_initrds(initrds):
09595fd5
DDM
441 if len(initrds) == 0:
442 return None
c4fc2546 443 if len(initrds) == 1:
09595fd5
DDM
444 return initrds[0]
445
446 seq = []
447 for file in initrds:
448 initrd = file.read_bytes()
c126c8ac
YW
449 n = len(initrd)
450 padding = b'\0' * (round_up(n, 4) - n) # pad to 32 bit alignment
09595fd5
DDM
451 seq += [initrd, padding]
452
453 return b''.join(seq)
54c84c8a
ZJS
454
455
c811aba0
DDM
456def pairwise(iterable):
457 a, b = itertools.tee(iterable)
458 next(b, None)
459 return zip(a, b)
460
461
7081db29 462class PEError(Exception):
3fc5eed4
JJ
463 pass
464
3fc1ae89 465
3fc5eed4
JJ
466def pe_add_sections(uki: UKI, output: str):
467 pe = pefile.PE(uki.executable, fast_load=True)
ac3412c3 468
7f72dca7
JJ
469 # Old stubs do not have the symbol/string table stripped, even though image files should not have one.
470 if symbol_table := pe.FILE_HEADER.PointerToSymbolTable:
471 symbol_table_size = 18 * pe.FILE_HEADER.NumberOfSymbols
472 if string_table_size := pe.get_dword_from_offset(symbol_table + symbol_table_size):
473 symbol_table_size += string_table_size
474
475 # Let's be safe and only strip it if it's at the end of the file.
476 if symbol_table + symbol_table_size == len(pe.__data__):
477 pe.__data__ = pe.__data__[:symbol_table]
478 pe.FILE_HEADER.PointerToSymbolTable = 0
479 pe.FILE_HEADER.NumberOfSymbols = 0
480 pe.FILE_HEADER.IMAGE_FILE_LOCAL_SYMS_STRIPPED = True
481
ac3412c3 482 # Old stubs might have been stripped, leading to unaligned raw data values, so let's fix them up here.
c4fc2546
ZJS
483 # pylint thinks that Structure doesn't have various members that it has…
484 # pylint: disable=no-member
485
ac3412c3
DDM
486 for i, section in enumerate(pe.sections):
487 oldp = section.PointerToRawData
488 oldsz = section.SizeOfRawData
489 section.PointerToRawData = round_up(oldp, pe.OPTIONAL_HEADER.FileAlignment)
490 section.SizeOfRawData = round_up(oldsz, pe.OPTIONAL_HEADER.FileAlignment)
491 padp = section.PointerToRawData - oldp
492 padsz = section.SizeOfRawData - oldsz
493
494 for later_section in pe.sections[i+1:]:
495 later_section.PointerToRawData += padp + padsz
496
497 pe.__data__ = pe.__data__[:oldp] + bytes(padp) + pe.__data__[oldp:oldp+oldsz] + bytes(padsz) + pe.__data__[oldp+oldsz:]
498
499 # We might not have any space to add new sections. Let's try our best to make some space by padding the
500 # SizeOfHeaders to a multiple of the file alignment. This is safe because the first section's data starts
501 # at a multiple of the file alignment, so all space before that is unused.
502 pe.OPTIONAL_HEADER.SizeOfHeaders = round_up(pe.OPTIONAL_HEADER.SizeOfHeaders, pe.OPTIONAL_HEADER.FileAlignment)
503 pe = pefile.PE(data=pe.write(), fast_load=True)
3fc1ae89 504
3fc5eed4
JJ
505 warnings = pe.get_warnings()
506 if warnings:
7081db29 507 raise PEError(f'pefile warnings treated as errors: {warnings}')
3fc1ae89 508
3fc5eed4
JJ
509 security = pe.OPTIONAL_HEADER.DATA_DIRECTORY[pefile.DIRECTORY_ENTRY['IMAGE_DIRECTORY_ENTRY_SECURITY']]
510 if security.VirtualAddress != 0:
511 # We could strip the signatures, but why would anyone sign the stub?
7081db29 512 raise PEError('Stub image is signed, refusing.')
3fc5eed4
JJ
513
514 for section in uki.sections:
515 new_section = pefile.SectionStructure(pe.__IMAGE_SECTION_HEADER_format__, pe=pe)
516 new_section.__unpack__(b'\0' * new_section.sizeof())
517
518 offset = pe.sections[-1].get_file_offset() + new_section.sizeof()
519 if offset + new_section.sizeof() > pe.OPTIONAL_HEADER.SizeOfHeaders:
7081db29 520 raise PEError(f'Not enough header space to add section {section.name}.')
3fc5eed4
JJ
521
522 data = section.content.read_bytes()
523
524 new_section.set_file_offset(offset)
525 new_section.Name = section.name.encode()
526 new_section.Misc_VirtualSize = len(data)
ac3412c3
DDM
527 # Non-stripped stubs might still have an unaligned symbol table at the end, making their size
528 # unaligned, so we make sure to explicitly pad the pointer to new sections to an aligned offset.
529 new_section.PointerToRawData = round_up(len(pe.__data__), pe.OPTIONAL_HEADER.FileAlignment)
3fc5eed4
JJ
530 new_section.SizeOfRawData = round_up(len(data), pe.OPTIONAL_HEADER.FileAlignment)
531 new_section.VirtualAddress = round_up(
532 pe.sections[-1].VirtualAddress + pe.sections[-1].Misc_VirtualSize,
533 pe.OPTIONAL_HEADER.SectionAlignment,
534 )
535
536 new_section.IMAGE_SCN_MEM_READ = True
537 if section.name == '.linux':
538 # Old kernels that use EFI handover protocol will be executed inline.
539 new_section.IMAGE_SCN_CNT_CODE = True
540 else:
541 new_section.IMAGE_SCN_CNT_INITIALIZED_DATA = True
542
ac3412c3 543 pe.__data__ = pe.__data__[:] + bytes(new_section.PointerToRawData - len(pe.__data__)) + data + bytes(new_section.SizeOfRawData - len(data))
3fc5eed4
JJ
544
545 pe.FILE_HEADER.NumberOfSections += 1
546 pe.OPTIONAL_HEADER.SizeOfInitializedData += new_section.Misc_VirtualSize
547 pe.__structures__.append(new_section)
548 pe.sections.append(new_section)
549
550 pe.OPTIONAL_HEADER.CheckSum = 0
551 pe.OPTIONAL_HEADER.SizeOfImage = round_up(
552 pe.sections[-1].VirtualAddress + pe.sections[-1].Misc_VirtualSize,
553 pe.OPTIONAL_HEADER.SectionAlignment,
554 )
555
556 pe.write(output)
3fc1ae89 557
c1e8d172
EGE
558def signer_sign(cmd):
559 print('+', shell_join(cmd))
560 subprocess.check_call(cmd)
3fc1ae89 561
c1e8d172
EGE
562def find_sbsign(opts=None):
563 return find_tool('sbsign', opts=opts)
f4780cbe 564
c1e8d172
EGE
565def sbsign_sign(sbsign_tool, input_f, output_f, opts=None):
566 sign_invocation = [
f4780cbe
ZJS
567 sbsign_tool,
568 '--key', opts.sb_key,
569 '--cert', opts.sb_cert,
c1e8d172
EGE
570 input_f,
571 '--output', output_f,
f4780cbe 572 ]
f4780cbe 573 if opts.signing_engine is not None:
c1e8d172
EGE
574 sign_invocation += ['--engine', opts.signing_engine]
575 signer_sign(sign_invocation)
576
577def find_pesign(opts=None):
578 return find_tool('pesign', opts=opts)
579
580def pesign_sign(pesign_tool, input_f, output_f, opts=None):
581 sign_invocation = [
582 pesign_tool, '-s', '--force',
583 '-n', opts.sb_certdir,
584 '-c', opts.sb_cert_name,
585 '-i', input_f,
586 '-o', output_f,
587 ]
588 signer_sign(sign_invocation)
f4780cbe 589
c1e8d172
EGE
590SBVERIFY = {
591 'name': 'sbverify',
592 'option': '--list',
593 'output': 'No signature table present',
594}
f4780cbe 595
c1e8d172
EGE
596PESIGCHECK = {
597 'name': 'pesign',
598 'option': '-i',
599 'output': 'No signatures found.',
600 'flags': '-S'
601}
f4780cbe 602
c1e8d172
EGE
603def verify(tool, opts):
604 verify_tool = find_tool(tool['name'], opts=opts)
605 cmd = [
606 verify_tool,
607 tool['option'],
608 opts.linux,
609 ]
610 if 'flags' in tool:
611 cmd.append(tool['flags'])
612
613 print('+', shell_join(cmd))
614 info = subprocess.check_output(cmd, text=True)
615
616 return tool['output'] in info
617
618def make_uki(opts):
619 # kernel payload signing
620
621 sign_tool = None
622 if opts.signtool == 'sbsign':
623 sign_tool = find_sbsign(opts=opts)
624 sign = sbsign_sign
625 verify_tool = SBVERIFY
626 else:
627 sign_tool = find_pesign(opts=opts)
628 sign = pesign_sign
629 verify_tool = PESIGCHECK
630
631 sign_args_present = opts.sb_key or opts.sb_cert_name
f4780cbe 632
c1e8d172
EGE
633 if sign_tool is None and sign_args_present:
634 raise ValueError(f'{opts.signtool}, required for signing, is not installed')
635
636 sign_kernel = opts.sign_kernel
637 if sign_kernel is None and opts.linux is not None and sign_args_present:
638 # figure out if we should sign the kernel
639 sign_kernel = verify(verify_tool, opts)
f4780cbe
ZJS
640
641 if sign_kernel:
642 linux_signed = tempfile.NamedTemporaryFile(prefix='linux-signed')
8d89d66c 643 linux = pathlib.Path(linux_signed.name)
c1e8d172 644 sign(sign_tool, opts.linux, linux, opts=opts)
f4780cbe
ZJS
645 else:
646 linux = opts.linux
647
00e5933f 648 if opts.uname is None and opts.linux is not None:
483c9c1b
ZJS
649 print('Kernel version not specified, starting autodetection 😖.')
650 opts.uname = Uname.scrape(opts.linux, opts=opts)
651
f4780cbe 652 uki = UKI(opts.stub)
54c84c8a 653 initrd = join_initrds(opts.initrd)
f4780cbe 654
30fd9a2d 655 # TODO: derive public key from opts.pcr_private_keys?
f4780cbe
ZJS
656 pcrpkey = opts.pcrpkey
657 if pcrpkey is None:
658 if opts.pcr_public_keys and len(opts.pcr_public_keys) == 1:
659 pcrpkey = opts.pcr_public_keys[0]
660
661 sections = [
662 # name, content, measure?
663 ('.osrel', opts.os_release, True ),
664 ('.cmdline', opts.cmdline, True ),
665 ('.dtb', opts.devicetree, True ),
b6f2e686 666 ('.uname', opts.uname, True ),
f4780cbe
ZJS
667 ('.splash', opts.splash, True ),
668 ('.pcrpkey', pcrpkey, True ),
54c84c8a 669 ('.initrd', initrd, True ),
f4780cbe
ZJS
670
671 # linux shall be last to leave breathing room for decompression.
672 # We'll add it later.
673 ]
674
675 for name, content, measure in sections:
676 if content:
677 uki.add_section(Section.create(name, content, measure=measure))
678
679 # systemd-measure doesn't know about those extra sections
680 for section in opts.sections:
681 uki.add_section(section)
682
683 # PCR measurement and signing
684
685 call_systemd_measure(uki, linux, opts=opts)
686
c67d5a02 687 # UKI or addon creation - addons don't use the stub so we add SBAT manually
f4780cbe 688
00e5933f
LB
689 if linux is not None:
690 uki.add_section(Section.create('.linux', linux, measure=True))
c67d5a02
LB
691 elif opts.sbat:
692 uki.add_section(Section.create('.sbat', opts.sbat, measure=False))
f4780cbe 693
c1e8d172 694 if sign_args_present:
f4780cbe
ZJS
695 unsigned = tempfile.NamedTemporaryFile(prefix='uki')
696 output = unsigned.name
697 else:
698 output = opts.output
699
3fc5eed4 700 pe_add_sections(uki, output)
3fc1ae89 701
f4780cbe
ZJS
702 # UKI signing
703
c1e8d172
EGE
704 if sign_args_present:
705 sign(sign_tool, unsigned.name, opts.output, opts=opts)
f4780cbe
ZJS
706
707 # We end up with no executable bits, let's reapply them
708 os.umask(umask := os.umask(0))
709 os.chmod(opts.output, 0o777 & ~umask)
710
c1e8d172 711 print(f"Wrote {'signed' if sign_args_present else 'unsigned'} {opts.output}")
f4780cbe
ZJS
712
713
5143a47a
ZJS
714@dataclasses.dataclass(frozen=True)
715class ConfigItem:
716 @staticmethod
a758f95c
ZJS
717 def config_list_prepend(
718 namespace: argparse.Namespace,
719 group: Optional[str],
720 dest: str,
721 value: Any,
722 ) -> None:
5143a47a
ZJS
723 "Prepend value to namespace.<dest>"
724
725 assert not group
726
727 old = getattr(namespace, dest, [])
728 setattr(namespace, dest, value + old)
729
730 @staticmethod
a758f95c
ZJS
731 def config_set_if_unset(
732 namespace: argparse.Namespace,
733 group: Optional[str],
734 dest: str,
735 value: Any,
736 ) -> None:
5143a47a
ZJS
737 "Set namespace.<dest> to value only if it was None"
738
739 assert not group
740
741 if getattr(namespace, dest) is None:
742 setattr(namespace, dest, value)
743
744 @staticmethod
a758f95c
ZJS
745 def config_set_group(
746 namespace: argparse.Namespace,
747 group: Optional[str],
748 dest: str,
749 value: Any,
750 ) -> None:
5143a47a
ZJS
751 "Set namespace.<dest>[idx] to value, with idx derived from group"
752
c4fc2546 753 # pylint: disable=protected-access
5143a47a
ZJS
754 if group not in namespace._groups:
755 namespace._groups += [group]
756 idx = namespace._groups.index(group)
757
758 old = getattr(namespace, dest, None)
759 if old is None:
760 old = []
761 setattr(namespace, dest,
762 old + ([None] * (idx - len(old))) + [value])
763
764 @staticmethod
765 def parse_boolean(s: str) -> bool:
766 "Parse 1/true/yes/y/t/on as true and 0/false/no/n/f/off/None as false"
767 s_l = s.lower()
768 if s_l in {'1', 'true', 'yes', 'y', 't', 'on'}:
769 return True
770 if s_l in {'0', 'false', 'no', 'n', 'f', 'off'}:
771 return False
772 raise ValueError('f"Invalid boolean literal: {s!r}')
773
774 # arguments for argparse.ArgumentParser.add_argument()
a758f95c
ZJS
775 name: Union[str, tuple[str, str]]
776 dest: Optional[str] = None
777 metavar: Optional[str] = None
778 type: Optional[Callable] = None
779 nargs: Optional[str] = None
780 action: Optional[Union[str, Callable]] = None
781 default: Any = None
782 version: Optional[str] = None
783 choices: Optional[tuple[str, ...]] = None
784 help: Optional[str] = None
5143a47a
ZJS
785
786 # metadata for config file parsing
a758f95c
ZJS
787 config_key: Optional[str] = None
788 config_push: Callable[[argparse.Namespace, Optional[str], str, Any], None] = \
789 config_set_if_unset
5143a47a 790
a758f95c 791 def _names(self) -> tuple[str, ...]:
5143a47a
ZJS
792 return self.name if isinstance(self.name, tuple) else (self.name,)
793
794 def argparse_dest(self) -> str:
795 # It'd be nice if argparse exported this, but I don't see that in the API
796 if self.dest:
797 return self.dest
798 return self._names()[0].lstrip('-').replace('-', '_')
799
800 def add_to(self, parser: argparse.ArgumentParser):
801 kwargs = { key:val
802 for key in dataclasses.asdict(self)
803 if (key not in ('name', 'config_key', 'config_push') and
804 (val := getattr(self, key)) is not None) }
805 args = self._names()
806 parser.add_argument(*args, **kwargs)
807
808 def apply_config(self, namespace, section, group, key, value) -> None:
809 assert f'{section}/{key}' == self.config_key
810 dest = self.argparse_dest()
811
a758f95c 812 conv: Callable[[str], Any]
5143a47a
ZJS
813 if self.action == argparse.BooleanOptionalAction:
814 # We need to handle this case separately: the options are called
815 # --foo and --no-foo, and no argument is parsed. But in the config
816 # file, we have Foo=yes or Foo=no.
817 conv = self.parse_boolean
818 elif self.type:
819 conv = self.type
820 else:
821 conv = lambda s:s
822
823 if self.nargs == '*':
824 value = [conv(v) for v in value.split()]
825 else:
826 value = conv(value)
827
828 self.config_push(namespace, group, dest, value)
829
a758f95c 830 def config_example(self) -> tuple[Optional[str], Optional[str], Optional[str]]:
5143a47a
ZJS
831 if not self.config_key:
832 return None, None, None
833 section_name, key = self.config_key.split('/', 1)
834 if section_name.endswith(':'):
835 section_name += 'NAME'
836 if self.choices:
837 value = '|'.join(self.choices)
838 else:
839 value = self.metavar or self.argparse_dest().upper()
840 return (section_name, key, value)
841
842
843CONFIG_ITEMS = [
844 ConfigItem(
845 '--version',
846 action = 'version',
847 version = f'ukify {__version__}',
848 ),
849
850 ConfigItem(
851 '--summary',
852 help = 'print parsed config and exit',
853 action = 'store_true',
854 ),
855
856 ConfigItem(
857 'linux',
858 metavar = 'LINUX',
859 type = pathlib.Path,
860 nargs = '?',
861 help = 'vmlinuz file [.linux section]',
862 config_key = 'UKI/Linux',
863 ),
864
865 ConfigItem(
866 'initrd',
867 metavar = 'INITRD…',
868 type = pathlib.Path,
869 nargs = '*',
870 help = 'initrd files [.initrd section]',
871 config_key = 'UKI/Initrd',
872 config_push = ConfigItem.config_list_prepend,
873 ),
874
875 ConfigItem(
876 ('--config', '-c'),
877 metavar = 'PATH',
878 help = 'configuration file',
879 ),
880
881 ConfigItem(
882 '--cmdline',
883 metavar = 'TEXT|@PATH',
884 help = 'kernel command line [.cmdline section]',
885 config_key = 'UKI/Cmdline',
886 ),
887
888 ConfigItem(
889 '--os-release',
890 metavar = 'TEXT|@PATH',
891 help = 'path to os-release file [.osrel section]',
892 config_key = 'UKI/OSRelease',
893 ),
894
895 ConfigItem(
896 '--devicetree',
897 metavar = 'PATH',
898 type = pathlib.Path,
899 help = 'Device Tree file [.dtb section]',
900 config_key = 'UKI/DeviceTree',
901 ),
902 ConfigItem(
903 '--splash',
904 metavar = 'BMP',
905 type = pathlib.Path,
906 help = 'splash image bitmap file [.splash section]',
907 config_key = 'UKI/Splash',
908 ),
909 ConfigItem(
910 '--pcrpkey',
911 metavar = 'KEY',
912 type = pathlib.Path,
913 help = 'embedded public key to seal secrets to [.pcrpkey section]',
914 config_key = 'UKI/PCRPKey',
915 ),
916 ConfigItem(
917 '--uname',
918 metavar='VERSION',
919 help='"uname -r" information [.uname section]',
920 config_key = 'UKI/Uname',
921 ),
922
923 ConfigItem(
924 '--efi-arch',
925 metavar = 'ARCH',
926 choices = ('ia32', 'x64', 'arm', 'aa64', 'riscv64'),
927 help = 'target EFI architecture',
928 config_key = 'UKI/EFIArch',
929 ),
930
931 ConfigItem(
932 '--stub',
933 type = pathlib.Path,
934 help = 'path to the sd-stub file [.text,.data,… sections]',
935 config_key = 'UKI/Stub',
936 ),
937
c67d5a02
LB
938 ConfigItem(
939 '--sbat',
940 metavar = 'TEXT|@PATH',
941 help = 'SBAT policy [.sbat section] for addons',
942 default = """sbat,1,SBAT Version,sbat,1,https://github.com/rhboot/shim/blob/main/SBAT.md
943uki.addon,1,UKI Addon,uki.addon,1,https://www.freedesktop.org/software/systemd/man/systemd-stub.html
944""",
945 config_key = 'Addon/SBAT',
946 ),
947
5143a47a
ZJS
948 ConfigItem(
949 '--section',
950 dest = 'sections',
951 metavar = 'NAME:TEXT|@PATH',
952 type = Section.parse_arg,
953 action = 'append',
954 default = [],
955 help = 'additional section as name and contents [NAME section]',
956 ),
957
958 ConfigItem(
959 '--pcr-banks',
960 metavar = 'BANK…',
961 type = parse_banks,
962 config_key = 'UKI/PCRBanks',
963 ),
964
965 ConfigItem(
966 '--signing-engine',
967 metavar = 'ENGINE',
968 help = 'OpenSSL engine to use for signing',
969 config_key = 'UKI/SigningEngine',
970 ),
c1e8d172
EGE
971 ConfigItem(
972 '--signtool',
973 choices = ('sbsign', 'pesign'),
974 dest = 'signtool',
975 default = 'sbsign',
976 help = 'whether to use sbsign or pesign. Default is sbsign.',
977 config_key = 'UKI/SecureBootSigningTool',
978 ),
5143a47a
ZJS
979 ConfigItem(
980 '--secureboot-private-key',
981 dest = 'sb_key',
c1e8d172 982 help = 'required by --signtool=sbsign. Path to key file or engine-specific designation for SB signing',
5143a47a
ZJS
983 config_key = 'UKI/SecureBootPrivateKey',
984 ),
985 ConfigItem(
986 '--secureboot-certificate',
987 dest = 'sb_cert',
c1e8d172 988 help = 'required by --signtool=sbsign. sbsign needs a path to certificate file or engine-specific designation for SB signing',
5143a47a
ZJS
989 config_key = 'UKI/SecureBootCertificate',
990 ),
c1e8d172
EGE
991 ConfigItem(
992 '--secureboot-certificate-dir',
993 dest = 'sb_certdir',
994 default = '/etc/pki/pesign',
995 help = 'required by --signtool=pesign. Path to nss certificate database directory for PE signing. Default is /etc/pki/pesign',
996 config_key = 'UKI/SecureBootCertificateDir',
997 ),
998 ConfigItem(
999 '--secureboot-certificate-name',
1000 dest = 'sb_cert_name',
1001 help = 'required by --signtool=pesign. pesign needs a certificate nickname of nss certificate database entry to use for PE signing',
1002 config_key = 'UKI/SecureBootCertificateName',
1003 ),
5143a47a
ZJS
1004
1005 ConfigItem(
1006 '--sign-kernel',
1007 action = argparse.BooleanOptionalAction,
1008 help = 'Sign the embedded kernel',
1009 config_key = 'UKI/SignKernel',
1010 ),
1011
1012 ConfigItem(
1013 '--pcr-private-key',
1014 dest = 'pcr_private_keys',
1015 metavar = 'PATH',
1016 type = pathlib.Path,
1017 action = 'append',
1018 help = 'private part of the keypair for signing PCR signatures',
1019 config_key = 'PCRSignature:/PCRPrivateKey',
1020 config_push = ConfigItem.config_set_group,
1021 ),
1022 ConfigItem(
1023 '--pcr-public-key',
1024 dest = 'pcr_public_keys',
1025 metavar = 'PATH',
1026 type = pathlib.Path,
1027 action = 'append',
1028 help = 'public part of the keypair for signing PCR signatures',
1029 config_key = 'PCRSignature:/PCRPublicKey',
1030 config_push = ConfigItem.config_set_group,
1031 ),
1032 ConfigItem(
1033 '--phases',
1034 dest = 'phase_path_groups',
1035 metavar = 'PHASE-PATH…',
1036 type = parse_phase_paths,
1037 action = 'append',
1038 help = 'phase-paths to create signatures for',
1039 config_key = 'PCRSignature:/Phases',
1040 config_push = ConfigItem.config_set_group,
1041 ),
1042
1043 ConfigItem(
1044 '--tools',
1045 type = pathlib.Path,
1046 action = 'append',
1047 help = 'Directories to search for tools (systemd-measure, …)',
1048 ),
1049
1050 ConfigItem(
1051 ('--output', '-o'),
1052 type = pathlib.Path,
1053 help = 'output file path',
1054 ),
1055
1056 ConfigItem(
1057 '--measure',
1058 action = argparse.BooleanOptionalAction,
1059 help = 'print systemd-measure output for the UKI',
1060 ),
1061]
1062
1063CONFIGFILE_ITEMS = { item.config_key:item
1064 for item in CONFIG_ITEMS
1065 if item.config_key }
1066
1067
1068def apply_config(namespace, filename=None):
1069 if filename is None:
1070 filename = namespace.config
1071 if filename is None:
1072 return
1073
1074 # Fill in ._groups based on --pcr-public-key=, --pcr-private-key=, and --phases=.
1075 assert '_groups' not in namespace
1076 n_pcr_priv = len(namespace.pcr_private_keys or ())
c4fc2546 1077 namespace._groups = list(range(n_pcr_priv)) # pylint: disable=protected-access
5143a47a
ZJS
1078
1079 cp = configparser.ConfigParser(
1080 comment_prefixes='#',
1081 inline_comment_prefixes='#',
1082 delimiters='=',
1083 empty_lines_in_values=False,
1084 interpolation=None,
1085 strict=False)
1086 # Do not make keys lowercase
1087 cp.optionxform = lambda option: option
1088
1089 cp.read(filename)
1090
1091 for section_name, section in cp.items():
1092 idx = section_name.find(':')
1093 if idx >= 0:
1094 section_name, group = section_name[:idx+1], section_name[idx+1:]
1095 if not section_name or not group:
1096 raise ValueError('Section name components cannot be empty')
1097 if ':' in group:
1098 raise ValueError('Section name cannot contain more than one ":"')
1099 else:
1100 group = None
1101 for key, value in section.items():
1102 if item := CONFIGFILE_ITEMS.get(f'{section_name}/{key}'):
1103 item.apply_config(namespace, section_name, group, key, value)
1104 else:
1105 print(f'Unknown config setting [{section_name}] {key}=')
1106
1107
1108def config_example():
1109 prev_section = None
1110 for item in CONFIG_ITEMS:
1111 section, key, value = item.config_example()
1112 if section:
1113 if prev_section != section:
1114 if prev_section:
1115 yield ''
1116 yield f'[{section}]'
1117 prev_section = section
1118 yield f'{key} = {value}'
1119
1120
1121def create_parser():
f4780cbe
ZJS
1122 p = argparse.ArgumentParser(
1123 description='Build and sign Unified Kernel Images',
1124 allow_abbrev=False,
1125 usage='''\
50f4add4 1126ukify [options…] [LINUX INITRD…]
5143a47a
ZJS
1127''',
1128 epilog='\n '.join(('config file:', *config_example())),
1129 formatter_class=argparse.RawDescriptionHelpFormatter,
1130 )
1131
1132 for item in CONFIG_ITEMS:
1133 item.add_to(p)
f4780cbe
ZJS
1134
1135 # Suppress printing of usage synopsis on errors
1136 p.error = lambda message: p.exit(2, f'{p.prog}: error: {message}\n')
1137
5143a47a 1138 return p
f4780cbe 1139
095ff238 1140
5143a47a 1141def finalize_options(opts):
f4780cbe 1142 if opts.cmdline and opts.cmdline.startswith('@'):
5143a47a
ZJS
1143 opts.cmdline = pathlib.Path(opts.cmdline[1:])
1144 elif opts.cmdline:
1145 # Drop whitespace from the commandline. If we're reading from a file,
1146 # we copy the contents verbatim. But configuration specified on the commandline
1147 # or in the config file may contain additional whitespace that has no meaning.
1148 opts.cmdline = ' '.join(opts.cmdline.split())
1149
1150 if opts.os_release and opts.os_release.startswith('@'):
1151 opts.os_release = pathlib.Path(opts.os_release[1:])
1152 elif not opts.os_release and opts.linux:
f4780cbe
ZJS
1153 p = pathlib.Path('/etc/os-release')
1154 if not p.exists():
5143a47a 1155 p = pathlib.Path('/usr/lib/os-release')
f4780cbe
ZJS
1156 opts.os_release = p
1157
1158 if opts.efi_arch is None:
1159 opts.efi_arch = guess_efi_arch()
1160
1161 if opts.stub is None:
f644ea3e
LB
1162 if opts.linux is not None:
1163 opts.stub = pathlib.Path(f'/usr/lib/systemd/boot/efi/linux{opts.efi_arch}.efi.stub')
1164 else:
1165 opts.stub = pathlib.Path(f'/usr/lib/systemd/boot/efi/addon{opts.efi_arch}.efi.stub')
f4780cbe
ZJS
1166
1167 if opts.signing_engine is None:
5143a47a
ZJS
1168 if opts.sb_key:
1169 opts.sb_key = pathlib.Path(opts.sb_key)
1170 if opts.sb_cert:
1171 opts.sb_cert = pathlib.Path(opts.sb_cert)
f4780cbe 1172
c1e8d172
EGE
1173 if opts.signtool == 'sbsign':
1174 if bool(opts.sb_key) ^ bool(opts.sb_cert):
1175 raise ValueError('--secureboot-private-key= and --secureboot-certificate= must be specified together when using --signtool=sbsign')
1176 else:
1177 if not bool(opts.sb_cert_name):
1178 raise ValueError('--certificate-name must be specified when using --signtool=pesign')
f4780cbe 1179
c1e8d172
EGE
1180 if opts.sign_kernel and not opts.sb_key and not opts.sb_cert_name:
1181 raise ValueError('--sign-kernel requires either --secureboot-private-key= and --secureboot-certificate= (for sbsign) or --secureboot-certificate-name= (for pesign) to be specified')
f4780cbe 1182
5143a47a
ZJS
1183 if opts.output is None:
1184 if opts.linux is None:
1185 raise ValueError('--output= must be specified when building a PE addon')
c1e8d172 1186 suffix = '.efi' if opts.sb_key or opts.sb_cert_name else '.unsigned.efi'
5143a47a
ZJS
1187 opts.output = opts.linux.name + suffix
1188
1189 for section in opts.sections:
1190 section.check_name()
1191
1192 if opts.summary:
1193 # TODO: replace pprint() with some fancy formatting.
1194 pprint.pprint(vars(opts))
1195 sys.exit()
1196
1197
1198def parse_args(args=None):
1199 p = create_parser()
1200 opts = p.parse_args(args)
1201
1202 # Check that --pcr-public-key=, --pcr-private-key=, and --phases=
1203 # have either the same number of arguments are are not specified at all.
f4780cbe
ZJS
1204 n_pcr_pub = None if opts.pcr_public_keys is None else len(opts.pcr_public_keys)
1205 n_pcr_priv = None if opts.pcr_private_keys is None else len(opts.pcr_private_keys)
1206 n_phase_path_groups = None if opts.phase_path_groups is None else len(opts.phase_path_groups)
1207 if n_pcr_pub is not None and n_pcr_pub != n_pcr_priv:
1208 raise ValueError('--pcr-public-key= specifications must match --pcr-private-key=')
1209 if n_phase_path_groups is not None and n_phase_path_groups != n_pcr_priv:
1210 raise ValueError('--phases= specifications must match --pcr-private-key=')
1211
5143a47a 1212 apply_config(opts)
f4780cbe 1213
5143a47a 1214 finalize_options(opts)
f4780cbe
ZJS
1215
1216 return opts
1217
1218
1219def main():
1220 opts = parse_args()
1221 check_inputs(opts)
1222 make_uki(opts)
1223
1224
1225if __name__ == '__main__':
1226 main()