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