]> git.ipfire.org Git - thirdparty/systemd.git/blob - src/ukify/ukify.py
Merge pull request #27030 from keszybz/bustctl-show-property-values-in-full
[thirdparty/systemd.git] / src / ukify / ukify.py
1 #!/usr/bin/env python3
2 # SPDX-License-Identifier: LGPL-2.1-or-later
3
4 # pylint: disable=missing-docstring,invalid-name,import-outside-toplevel
5 # pylint: disable=consider-using-with,unspecified-encoding,line-too-long
6 # pylint: disable=too-many-locals,too-many-statements,too-many-return-statements
7 # pylint: disable=too-many-branches
8
9 import argparse
10 import collections
11 import dataclasses
12 import fnmatch
13 import itertools
14 import json
15 import os
16 import pathlib
17 import re
18 import shlex
19 import shutil
20 import subprocess
21 import tempfile
22 import typing
23
24 import pefile
25
26 __version__ = '{{PROJECT_VERSION}} ({{GIT_VERSION}})'
27
28 EFI_ARCH_MAP = {
29 # host_arch glob : [efi_arch, 32_bit_efi_arch if mixed mode is supported]
30 'x86_64' : ['x64', 'ia32'],
31 'i[3456]86' : ['ia32'],
32 'aarch64' : ['aa64'],
33 'arm[45678]*l' : ['arm'],
34 'loongarch32' : ['loongarch32'],
35 'loongarch64' : ['loongarch64'],
36 'riscv32' : ['riscv32'],
37 'riscv64' : ['riscv64'],
38 }
39 EFI_ARCHES: list[str] = sum(EFI_ARCH_MAP.values(), [])
40
41 def guess_efi_arch():
42 arch = os.uname().machine
43
44 for glob, mapping in EFI_ARCH_MAP.items():
45 if fnmatch.fnmatch(arch, glob):
46 efi_arch, *fallback = mapping
47 break
48 else:
49 raise ValueError(f'Unsupported architecture {arch}')
50
51 # This makes sense only on some architectures, but it also probably doesn't
52 # hurt on others, so let's just apply the check everywhere.
53 if fallback:
54 fw_platform_size = pathlib.Path('/sys/firmware/efi/fw_platform_size')
55 try:
56 size = fw_platform_size.read_text().strip()
57 except FileNotFoundError:
58 pass
59 else:
60 if int(size) == 32:
61 efi_arch = fallback[0]
62
63 print(f'Host arch {arch!r}, EFI arch {efi_arch!r}')
64 return efi_arch
65
66
67 def shell_join(cmd):
68 # TODO: drop in favour of shlex.join once shlex.join supports pathlib.Path.
69 return ' '.join(shlex.quote(str(x)) for x in cmd)
70
71
72 def path_is_readable(s: typing.Optional[str]) -> typing.Optional[pathlib.Path]:
73 """Convert a filename string to a Path and verify access."""
74 if s is None:
75 return None
76 p = pathlib.Path(s)
77 try:
78 p.open().close()
79 except IsADirectoryError:
80 pass
81 return p
82
83
84 def round_up(x, blocksize=4096):
85 return (x + blocksize - 1) // blocksize * blocksize
86
87
88 def try_import(modname, name=None):
89 try:
90 return __import__(modname)
91 except ImportError as e:
92 raise ValueError(f'Kernel is compressed with {name or modname}, but module unavailable') from e
93
94
95 def maybe_decompress(filename):
96 """Decompress file if compressed. Return contents."""
97 f = open(filename, 'rb')
98 start = f.read(4)
99 f.seek(0)
100
101 if start.startswith(b'\x7fELF'):
102 # not compressed
103 return f.read()
104
105 if start.startswith(b'MZ'):
106 # not compressed aarch64 and riscv64
107 return f.read()
108
109 if start.startswith(b'\x1f\x8b'):
110 gzip = try_import('gzip')
111 return gzip.open(f).read()
112
113 if start.startswith(b'\x28\xb5\x2f\xfd'):
114 zstd = try_import('zstd')
115 return zstd.uncompress(f.read())
116
117 if start.startswith(b'\x02\x21\x4c\x18'):
118 lz4 = try_import('lz4.frame', 'lz4')
119 return lz4.frame.decompress(f.read())
120
121 if start.startswith(b'\x04\x22\x4d\x18'):
122 print('Newer lz4 stream format detected! This may not boot!')
123 lz4 = try_import('lz4.frame', 'lz4')
124 return lz4.frame.decompress(f.read())
125
126 if start.startswith(b'\x89LZO'):
127 # python3-lzo is not packaged for Fedora
128 raise NotImplementedError('lzo decompression not implemented')
129
130 if start.startswith(b'BZh'):
131 bz2 = try_import('bz2', 'bzip2')
132 return bz2.open(f).read()
133
134 if start.startswith(b'\x5d\x00\x00'):
135 lzma = try_import('lzma')
136 return lzma.open(f).read()
137
138 raise NotImplementedError(f'unknown file format (starts with {start})')
139
140
141 class Uname:
142 # This class is here purely as a namespace for the functions
143
144 VERSION_PATTERN = r'(?P<version>[a-z0-9._-]+) \([^ )]+\) (?:#.*)'
145
146 NOTES_PATTERN = r'^\s+Linux\s+0x[0-9a-f]+\s+OPEN\n\s+description data: (?P<version>[0-9a-f ]+)\s*$'
147
148 # Linux version 6.0.8-300.fc37.ppc64le (mockbuild@buildvm-ppc64le-03.iad2.fedoraproject.org)
149 # (gcc (GCC) 12.2.1 20220819 (Red Hat 12.2.1-2), GNU ld version 2.38-24.fc37)
150 # #1 SMP Fri Nov 11 14:39:11 UTC 2022
151 TEXT_PATTERN = rb'Linux version (?P<version>\d\.\S+) \('
152
153 @classmethod
154 def scrape_x86(cls, filename, opts=None):
155 # Based on https://gitlab.archlinux.org/archlinux/mkinitcpio/mkinitcpio/-/blob/master/functions#L136
156 # and https://www.kernel.org/doc/html/latest/x86/boot.html#the-real-mode-kernel-header
157 with open(filename, 'rb') as f:
158 f.seek(0x202)
159 magic = f.read(4)
160 if magic != b'HdrS':
161 raise ValueError('Real-Mode Kernel Header magic not found')
162 f.seek(0x20E)
163 offset = f.read(1)[0] + f.read(1)[0]*256 # Pointer to kernel version string
164 f.seek(0x200 + offset)
165 text = f.read(128)
166 text = text.split(b'\0', maxsplit=1)[0]
167 text = text.decode()
168
169 if not (m := re.match(cls.VERSION_PATTERN, text)):
170 raise ValueError(f'Cannot parse version-host-release uname string: {text!r}')
171 return m.group('version')
172
173 @classmethod
174 def scrape_elf(cls, filename, opts=None):
175 readelf = find_tool('readelf', opts=opts)
176
177 cmd = [
178 readelf,
179 '--notes',
180 filename,
181 ]
182
183 print('+', shell_join(cmd))
184 try:
185 notes = subprocess.check_output(cmd, stderr=subprocess.PIPE, text=True)
186 except subprocess.CalledProcessError as e:
187 raise ValueError(e.stderr.strip()) from e
188
189 if not (m := re.search(cls.NOTES_PATTERN, notes, re.MULTILINE)):
190 raise ValueError('Cannot find Linux version note')
191
192 text = ''.join(chr(int(c, 16)) for c in m.group('version').split())
193 return text.rstrip('\0')
194
195 @classmethod
196 def scrape_generic(cls, filename, opts=None):
197 # import libarchive
198 # libarchive-c fails with
199 # ArchiveError: Unrecognized archive format (errno=84, retcode=-30, archive_p=94705420454656)
200
201 # Based on https://gitlab.archlinux.org/archlinux/mkinitcpio/mkinitcpio/-/blob/master/functions#L209
202
203 text = maybe_decompress(filename)
204 if not (m := re.search(cls.TEXT_PATTERN, text)):
205 raise ValueError(f'Cannot find {cls.TEXT_PATTERN!r} in {filename}')
206
207 return m.group('version').decode()
208
209 @classmethod
210 def scrape(cls, filename, opts=None):
211 for func in (cls.scrape_x86, cls.scrape_elf, cls.scrape_generic):
212 try:
213 version = func(filename, opts=opts)
214 print(f'Found uname version: {version}')
215 return version
216 except ValueError as e:
217 print(str(e))
218 return None
219
220
221 @dataclasses.dataclass
222 class Section:
223 name: str
224 content: pathlib.Path
225 tmpfile: typing.Optional[typing.IO] = None
226 measure: bool = False
227
228 @classmethod
229 def create(cls, name, contents, **kwargs):
230 if isinstance(contents, (str, bytes)):
231 mode = 'wt' if isinstance(contents, str) else 'wb'
232 tmp = tempfile.NamedTemporaryFile(mode=mode, prefix=f'tmp{name}')
233 tmp.write(contents)
234 tmp.flush()
235 contents = pathlib.Path(tmp.name)
236 else:
237 tmp = None
238
239 return cls(name, contents, tmpfile=tmp, **kwargs)
240
241 @classmethod
242 def parse_arg(cls, s):
243 try:
244 name, contents, *rest = s.split(':')
245 except ValueError as e:
246 raise ValueError(f'Cannot parse section spec (name or contents missing): {s!r}') from e
247 if rest:
248 raise ValueError(f'Cannot parse section spec (extraneous parameters): {s!r}')
249
250 if contents.startswith('@'):
251 contents = pathlib.Path(contents[1:])
252
253 return cls.create(name, contents)
254
255 def size(self):
256 return self.content.stat().st_size
257
258 def check_name(self):
259 # PE section names with more than 8 characters are legal, but our stub does
260 # not support them.
261 if not self.name.isascii() or not self.name.isprintable():
262 raise ValueError(f'Bad section name: {self.name!r}')
263 if len(self.name) > 8:
264 raise ValueError(f'Section name too long: {self.name!r}')
265
266
267 @dataclasses.dataclass
268 class UKI:
269 executable: list[typing.Union[pathlib.Path, str]]
270 sections: list[Section] = dataclasses.field(default_factory=list, init=False)
271
272 def add_section(self, section):
273 if section.name in [s.name for s in self.sections]:
274 raise ValueError(f'Duplicate section {section.name}')
275
276 self.sections += [section]
277
278
279 def parse_banks(s):
280 banks = re.split(r',|\s+', s)
281 # TODO: do some sanity checking here
282 return banks
283
284
285 KNOWN_PHASES = (
286 'enter-initrd',
287 'leave-initrd',
288 'sysinit',
289 'ready',
290 'shutdown',
291 'final',
292 )
293
294 def parse_phase_paths(s):
295 # Split on commas or whitespace here. Commas might be hard to parse visually.
296 paths = re.split(r',|\s+', s)
297
298 for path in paths:
299 for phase in path.split(':'):
300 if phase not in KNOWN_PHASES:
301 raise argparse.ArgumentTypeError(f'Unknown boot phase {phase!r} ({path=})')
302
303 return paths
304
305
306 def check_splash(filename):
307 if filename is None:
308 return
309
310 # import is delayed, to avoid import when the splash image is not used
311 try:
312 from PIL import Image
313 except ImportError:
314 return
315
316 img = Image.open(filename, formats=['BMP'])
317 print(f'Splash image {filename} is {img.width}×{img.height} pixels')
318
319
320 def check_inputs(opts):
321 for name, value in vars(opts).items():
322 if name in {'output', 'tools'}:
323 continue
324
325 if not isinstance(value, pathlib.Path):
326 continue
327
328 # Open file to check that we can read it, or generate an exception
329 value.open().close()
330
331 check_splash(opts.splash)
332
333
334 def find_tool(name, fallback=None, opts=None):
335 if opts and opts.tools:
336 for d in opts.tools:
337 tool = d / name
338 if tool.exists():
339 return tool
340
341 if shutil.which(name) is not None:
342 return name
343
344 return fallback
345
346
347 def combine_signatures(pcrsigs):
348 combined = collections.defaultdict(list)
349 for pcrsig in pcrsigs:
350 for bank, sigs in pcrsig.items():
351 for sig in sigs:
352 if sig not in combined[bank]:
353 combined[bank] += [sig]
354 return json.dumps(combined)
355
356
357 def call_systemd_measure(uki, linux, opts):
358 measure_tool = find_tool('systemd-measure',
359 '/usr/lib/systemd/systemd-measure',
360 opts=opts)
361
362 banks = opts.pcr_banks or ()
363
364 # PCR measurement
365
366 if opts.measure:
367 pp_groups = opts.phase_path_groups or []
368
369 cmd = [
370 measure_tool,
371 'calculate',
372 f'--linux={linux}',
373 *(f"--{s.name.removeprefix('.')}={s.content}"
374 for s in uki.sections
375 if s.measure),
376 *(f'--bank={bank}'
377 for bank in banks),
378 # For measurement, the keys are not relevant, so we can lump all the phase paths
379 # into one call to systemd-measure calculate.
380 *(f'--phase={phase_path}'
381 for phase_path in itertools.chain.from_iterable(pp_groups)),
382 ]
383
384 print('+', shell_join(cmd))
385 subprocess.check_call(cmd)
386
387 # PCR signing
388
389 if opts.pcr_private_keys:
390 n_priv = len(opts.pcr_private_keys or ())
391 pp_groups = opts.phase_path_groups or [None] * n_priv
392 pub_keys = opts.pcr_public_keys or [None] * n_priv
393
394 pcrsigs = []
395
396 cmd = [
397 measure_tool,
398 'sign',
399 f'--linux={linux}',
400 *(f"--{s.name.removeprefix('.')}={s.content}"
401 for s in uki.sections
402 if s.measure),
403 *(f'--bank={bank}'
404 for bank in banks),
405 ]
406
407 for priv_key, pub_key, group in zip(opts.pcr_private_keys,
408 pub_keys,
409 pp_groups):
410 extra = [f'--private-key={priv_key}']
411 if pub_key:
412 extra += [f'--public-key={pub_key}']
413 extra += [f'--phase={phase_path}' for phase_path in group or ()]
414
415 print('+', shell_join(cmd + extra))
416 pcrsig = subprocess.check_output(cmd + extra, text=True)
417 pcrsig = json.loads(pcrsig)
418 pcrsigs += [pcrsig]
419
420 combined = combine_signatures(pcrsigs)
421 uki.add_section(Section.create('.pcrsig', combined))
422
423
424 def join_initrds(initrds):
425 if len(initrds) == 0:
426 return None
427 elif len(initrds) == 1:
428 return initrds[0]
429
430 seq = []
431 for file in initrds:
432 initrd = file.read_bytes()
433 n = len(initrd)
434 padding = b'\0' * (round_up(n, 4) - n) # pad to 32 bit alignment
435 seq += [initrd, padding]
436
437 return b''.join(seq)
438
439
440 def pairwise(iterable):
441 a, b = itertools.tee(iterable)
442 next(b, None)
443 return zip(a, b)
444
445
446 class PeError(Exception):
447 pass
448
449
450 def pe_add_sections(uki: UKI, output: str):
451 pe = pefile.PE(uki.executable, fast_load=True)
452 assert pe.FILE_HEADER.PointerToSymbolTable != 0 or len(pe.__data__) % pe.OPTIONAL_HEADER.FileAlignment == 0
453
454 warnings = pe.get_warnings()
455 if warnings:
456 raise PeError(f'pefile warnings treated as errors: {warnings}')
457
458 security = pe.OPTIONAL_HEADER.DATA_DIRECTORY[pefile.DIRECTORY_ENTRY['IMAGE_DIRECTORY_ENTRY_SECURITY']]
459 if security.VirtualAddress != 0:
460 # We could strip the signatures, but why would anyone sign the stub?
461 raise PeError(f'Stub image is signed, refusing.')
462
463 # If the executable has not been stripped, it might not be aligned to a multiple of the file alignment so
464 # let's make sure it is by padding it.
465 if pe.FILE_HEADER.PointerToSymbolTable != 0:
466 padlen = round_up(len(pe.__data__), pe.OPTIONAL_HEADER.FileAlignment) - len(pe.__data__)
467 pe.__data__ = pe.__data__[:] + padlen * b'\0'
468
469 for section in uki.sections:
470 new_section = pefile.SectionStructure(pe.__IMAGE_SECTION_HEADER_format__, pe=pe)
471 new_section.__unpack__(b'\0' * new_section.sizeof())
472
473 offset = pe.sections[-1].get_file_offset() + new_section.sizeof()
474 if offset + new_section.sizeof() > pe.OPTIONAL_HEADER.SizeOfHeaders:
475 raise PeError(f'Not enough header space to add section {section.name}.')
476
477 data = section.content.read_bytes()
478
479 new_section.set_file_offset(offset)
480 new_section.Name = section.name.encode()
481 new_section.Misc_VirtualSize = len(data)
482 new_section.PointerToRawData = len(pe.__data__)
483 new_section.SizeOfRawData = round_up(len(data), pe.OPTIONAL_HEADER.FileAlignment)
484 new_section.VirtualAddress = round_up(
485 pe.sections[-1].VirtualAddress + pe.sections[-1].Misc_VirtualSize,
486 pe.OPTIONAL_HEADER.SectionAlignment,
487 )
488
489 new_section.IMAGE_SCN_MEM_READ = True
490 if section.name == '.linux':
491 # Old kernels that use EFI handover protocol will be executed inline.
492 new_section.IMAGE_SCN_CNT_CODE = True
493 else:
494 new_section.IMAGE_SCN_CNT_INITIALIZED_DATA = True
495
496 assert len(pe.__data__) % pe.OPTIONAL_HEADER.FileAlignment == 0
497 pe.__data__ = pe.__data__[:] + data + b'\0' * (new_section.SizeOfRawData - len(data))
498
499 pe.FILE_HEADER.NumberOfSections += 1
500 pe.OPTIONAL_HEADER.SizeOfInitializedData += new_section.Misc_VirtualSize
501 pe.__structures__.append(new_section)
502 pe.sections.append(new_section)
503
504 pe.OPTIONAL_HEADER.CheckSum = 0
505 pe.OPTIONAL_HEADER.SizeOfImage = round_up(
506 pe.sections[-1].VirtualAddress + pe.sections[-1].Misc_VirtualSize,
507 pe.OPTIONAL_HEADER.SectionAlignment,
508 )
509
510 pe.write(output)
511
512
513 def make_uki(opts):
514 # kernel payload signing
515
516 sbsign_tool = find_tool('sbsign', opts=opts)
517 sbsign_invocation = [
518 sbsign_tool,
519 '--key', opts.sb_key,
520 '--cert', opts.sb_cert,
521 ]
522
523 if opts.signing_engine is not None:
524 sbsign_invocation += ['--engine', opts.signing_engine]
525
526 sign_kernel = opts.sign_kernel
527 if sign_kernel is None and opts.sb_key:
528 # figure out if we should sign the kernel
529 sbverify_tool = find_tool('sbverify', opts=opts)
530
531 cmd = [
532 sbverify_tool,
533 '--list',
534 opts.linux,
535 ]
536
537 print('+', shell_join(cmd))
538 info = subprocess.check_output(cmd, text=True)
539
540 # sbverify has wonderful API
541 if 'No signature table present' in info:
542 sign_kernel = True
543
544 if sign_kernel:
545 linux_signed = tempfile.NamedTemporaryFile(prefix='linux-signed')
546 linux = linux_signed.name
547
548 cmd = [
549 *sbsign_invocation,
550 opts.linux,
551 '--output', linux,
552 ]
553
554 print('+', shell_join(cmd))
555 subprocess.check_call(cmd)
556 else:
557 linux = opts.linux
558
559 if opts.uname is None:
560 print('Kernel version not specified, starting autodetection 😖.')
561 opts.uname = Uname.scrape(opts.linux, opts=opts)
562
563 uki = UKI(opts.stub)
564 initrd = join_initrds(opts.initrd)
565
566 # TODO: derive public key from opts.pcr_private_keys?
567 pcrpkey = opts.pcrpkey
568 if pcrpkey is None:
569 if opts.pcr_public_keys and len(opts.pcr_public_keys) == 1:
570 pcrpkey = opts.pcr_public_keys[0]
571
572 sections = [
573 # name, content, measure?
574 ('.osrel', opts.os_release, True ),
575 ('.cmdline', opts.cmdline, True ),
576 ('.dtb', opts.devicetree, True ),
577 ('.splash', opts.splash, True ),
578 ('.pcrpkey', pcrpkey, True ),
579 ('.initrd', initrd, True ),
580 ('.uname', opts.uname, False),
581
582 # linux shall be last to leave breathing room for decompression.
583 # We'll add it later.
584 ]
585
586 for name, content, measure in sections:
587 if content:
588 uki.add_section(Section.create(name, content, measure=measure))
589
590 # systemd-measure doesn't know about those extra sections
591 for section in opts.sections:
592 uki.add_section(section)
593
594 # PCR measurement and signing
595
596 call_systemd_measure(uki, linux, opts=opts)
597
598 # UKI creation
599
600 uki.add_section(Section.create('.linux', linux, measure=True))
601
602 if opts.sb_key:
603 unsigned = tempfile.NamedTemporaryFile(prefix='uki')
604 output = unsigned.name
605 else:
606 output = opts.output
607
608 pe_add_sections(uki, output)
609
610 # UKI signing
611
612 if opts.sb_key:
613 cmd = [
614 *sbsign_invocation,
615 unsigned.name,
616 '--output', opts.output,
617 ]
618 print('+', shell_join(cmd))
619 subprocess.check_call(cmd)
620
621 # We end up with no executable bits, let's reapply them
622 os.umask(umask := os.umask(0))
623 os.chmod(opts.output, 0o777 & ~umask)
624
625 print(f"Wrote {'signed' if opts.sb_key else 'unsigned'} {opts.output}")
626
627
628 def parse_args(args=None):
629 p = argparse.ArgumentParser(
630 description='Build and sign Unified Kernel Images',
631 allow_abbrev=False,
632 usage='''\
633 usage: ukify [options…] linux initrd…
634 ukify -h | --help
635 ''')
636
637 # Suppress printing of usage synopsis on errors
638 p.error = lambda message: p.exit(2, f'{p.prog}: error: {message}\n')
639
640 p.add_argument('linux',
641 type=pathlib.Path,
642 help='vmlinuz file [.linux section]')
643 p.add_argument('initrd',
644 type=pathlib.Path,
645 nargs='*',
646 help='initrd files [.initrd section]')
647
648 p.add_argument('--cmdline',
649 metavar='TEXT|@PATH',
650 help='kernel command line [.cmdline section]')
651
652 p.add_argument('--os-release',
653 metavar='TEXT|@PATH',
654 help='path to os-release file [.osrel section]')
655
656 p.add_argument('--devicetree',
657 metavar='PATH',
658 type=pathlib.Path,
659 help='Device Tree file [.dtb section]')
660 p.add_argument('--splash',
661 metavar='BMP',
662 type=pathlib.Path,
663 help='splash image bitmap file [.splash section]')
664 p.add_argument('--pcrpkey',
665 metavar='KEY',
666 type=pathlib.Path,
667 help='embedded public key to seal secrets to [.pcrpkey section]')
668 p.add_argument('--uname',
669 metavar='VERSION',
670 help='"uname -r" information [.uname section]')
671
672 p.add_argument('--efi-arch',
673 metavar='ARCH',
674 choices=('ia32', 'x64', 'arm', 'aa64', 'riscv64'),
675 help='target EFI architecture')
676
677 p.add_argument('--stub',
678 type=pathlib.Path,
679 help='path to the sd-stub file [.text,.data,… sections]')
680
681 p.add_argument('--section',
682 dest='sections',
683 metavar='NAME:TEXT|@PATH',
684 type=Section.parse_arg,
685 action='append',
686 default=[],
687 help='additional section as name and contents [NAME section]')
688
689 p.add_argument('--pcr-private-key',
690 dest='pcr_private_keys',
691 metavar='PATH',
692 type=pathlib.Path,
693 action='append',
694 help='private part of the keypair for signing PCR signatures')
695 p.add_argument('--pcr-public-key',
696 dest='pcr_public_keys',
697 metavar='PATH',
698 type=pathlib.Path,
699 action='append',
700 help='public part of the keypair for signing PCR signatures')
701 p.add_argument('--phases',
702 dest='phase_path_groups',
703 metavar='PHASE-PATH…',
704 type=parse_phase_paths,
705 action='append',
706 help='phase-paths to create signatures for')
707
708 p.add_argument('--pcr-banks',
709 metavar='BANK…',
710 type=parse_banks)
711
712 p.add_argument('--signing-engine',
713 metavar='ENGINE',
714 help='OpenSSL engine to use for signing')
715 p.add_argument('--secureboot-private-key',
716 dest='sb_key',
717 help='path to key file or engine-specific designation for SB signing')
718 p.add_argument('--secureboot-certificate',
719 dest='sb_cert',
720 help='path to certificate file or engine-specific designation for SB signing')
721
722 p.add_argument('--sign-kernel',
723 action=argparse.BooleanOptionalAction,
724 help='Sign the embedded kernel')
725
726 p.add_argument('--tools',
727 type=pathlib.Path,
728 action='append',
729 help='Directories to search for tools (systemd-measure, ...)')
730
731 p.add_argument('--output', '-o',
732 type=pathlib.Path,
733 help='output file path')
734
735 p.add_argument('--measure',
736 action=argparse.BooleanOptionalAction,
737 help='print systemd-measure output for the UKI')
738
739 p.add_argument('--version',
740 action='version',
741 version=f'ukify {__version__}')
742
743 opts = p.parse_args(args)
744
745 path_is_readable(opts.linux)
746 for initrd in opts.initrd or ():
747 path_is_readable(initrd)
748 path_is_readable(opts.devicetree)
749 path_is_readable(opts.pcrpkey)
750 for key in opts.pcr_private_keys or ():
751 path_is_readable(key)
752 for key in opts.pcr_public_keys or ():
753 path_is_readable(key)
754
755 if opts.cmdline and opts.cmdline.startswith('@'):
756 opts.cmdline = path_is_readable(opts.cmdline[1:])
757
758 if opts.os_release is not None and opts.os_release.startswith('@'):
759 opts.os_release = path_is_readable(opts.os_release[1:])
760 elif opts.os_release is None:
761 p = pathlib.Path('/etc/os-release')
762 if not p.exists():
763 p = path_is_readable('/usr/lib/os-release')
764 opts.os_release = p
765
766 if opts.efi_arch is None:
767 opts.efi_arch = guess_efi_arch()
768
769 if opts.stub is None:
770 opts.stub = path_is_readable(f'/usr/lib/systemd/boot/efi/linux{opts.efi_arch}.efi.stub')
771
772 if opts.signing_engine is None:
773 opts.sb_key = path_is_readable(opts.sb_key) if opts.sb_key else None
774 opts.sb_cert = path_is_readable(opts.sb_cert) if opts.sb_cert else None
775
776 if bool(opts.sb_key) ^ bool(opts.sb_cert):
777 raise ValueError('--secureboot-private-key= and --secureboot-certificate= must be specified together')
778
779 if opts.sign_kernel and not opts.sb_key:
780 raise ValueError('--sign-kernel requires --secureboot-private-key= and --secureboot-certificate= to be specified')
781
782 n_pcr_pub = None if opts.pcr_public_keys is None else len(opts.pcr_public_keys)
783 n_pcr_priv = None if opts.pcr_private_keys is None else len(opts.pcr_private_keys)
784 n_phase_path_groups = None if opts.phase_path_groups is None else len(opts.phase_path_groups)
785 if n_pcr_pub is not None and n_pcr_pub != n_pcr_priv:
786 raise ValueError('--pcr-public-key= specifications must match --pcr-private-key=')
787 if n_phase_path_groups is not None and n_phase_path_groups != n_pcr_priv:
788 raise ValueError('--phases= specifications must match --pcr-private-key=')
789
790 if opts.output is None:
791 suffix = '.efi' if opts.sb_key else '.unsigned.efi'
792 opts.output = opts.linux.name + suffix
793
794 for section in opts.sections:
795 section.check_name()
796
797 return opts
798
799
800 def main():
801 opts = parse_args()
802 check_inputs(opts)
803 make_uki(opts)
804
805
806 if __name__ == '__main__':
807 main()