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