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