2 # SPDX-License-Identifier: LGPL-2.1-or-later
4 # This file is part of systemd.
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.
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.
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/>.
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
22 # pylint: disable=too-many-branches,fixme
40 from typing
import (Any
,
46 import pefile
# type: ignore
48 __version__
= '{{PROJECT_VERSION}} ({{GIT_VERSION}})'
51 # host_arch glob : [efi_arch, 32_bit_efi_arch if mixed mode is supported]
52 'x86_64' : ['x64', 'ia32'],
53 'i[3456]86' : ['ia32'],
55 'arm[45678]*l' : ['arm'],
56 'loongarch32' : ['loongarch32'],
57 'loongarch64' : ['loongarch64'],
58 'riscv32' : ['riscv32'],
59 'riscv64' : ['riscv64'],
61 EFI_ARCHES
: list[str] = sum(EFI_ARCH_MAP
.values(), [])
64 arch
= os
.uname().machine
66 for glob
, mapping
in EFI_ARCH_MAP
.items():
67 if fnmatch
.fnmatch(arch
, glob
):
68 efi_arch
, *fallback
= mapping
71 raise ValueError(f
'Unsupported architecture {arch}')
73 # This makes sense only on some architectures, but it also probably doesn't
74 # hurt on others, so let's just apply the check everywhere.
76 fw_platform_size
= pathlib
.Path('/sys/firmware/efi/fw_platform_size')
78 size
= fw_platform_size
.read_text().strip()
79 except FileNotFoundError
:
83 efi_arch
= fallback
[0]
85 print(f
'Host arch {arch!r}, EFI arch {efi_arch!r}')
90 # TODO: drop in favour of shlex.join once shlex.join supports pathlib.Path.
91 return ' '.join(shlex
.quote(str(x
)) for x
in cmd
)
94 def round_up(x
, blocksize
=4096):
95 return (x
+ blocksize
- 1) // blocksize
* blocksize
98 def try_import(modname
, name
=None):
100 return __import__(modname
)
101 except ImportError as e
:
102 raise ValueError(f
'Kernel is compressed with {name or modname}, but module unavailable') from e
105 def maybe_decompress(filename
):
106 """Decompress file if compressed. Return contents."""
107 f
= open(filename
, 'rb')
111 if start
.startswith(b
'\x7fELF'):
115 if start
.startswith(b
'MZ'):
116 # not compressed aarch64 and riscv64
119 if start
.startswith(b
'\x1f\x8b'):
120 gzip
= try_import('gzip')
121 return gzip
.open(f
).read()
123 if start
.startswith(b
'\x28\xb5\x2f\xfd'):
124 zstd
= try_import('zstd')
125 return zstd
.uncompress(f
.read())
127 if start
.startswith(b
'\x02\x21\x4c\x18'):
128 lz4
= try_import('lz4.frame', 'lz4')
129 return lz4
.frame
.decompress(f
.read())
131 if start
.startswith(b
'\x04\x22\x4d\x18'):
132 print('Newer lz4 stream format detected! This may not boot!')
133 lz4
= try_import('lz4.frame', 'lz4')
134 return lz4
.frame
.decompress(f
.read())
136 if start
.startswith(b
'\x89LZO'):
137 # python3-lzo is not packaged for Fedora
138 raise NotImplementedError('lzo decompression not implemented')
140 if start
.startswith(b
'BZh'):
141 bz2
= try_import('bz2', 'bzip2')
142 return bz2
.open(f
).read()
144 if start
.startswith(b
'\x5d\x00\x00'):
145 lzma
= try_import('lzma')
146 return lzma
.open(f
).read()
148 raise NotImplementedError(f
'unknown file format (starts with {start})')
152 # This class is here purely as a namespace for the functions
154 VERSION_PATTERN
= r
'(?P<version>[a-z0-9._-]+) \([^ )]+\) (?:#.*)'
156 NOTES_PATTERN
= r
'^\s+Linux\s+0x[0-9a-f]+\s+OPEN\n\s+description data: (?P<version>[0-9a-f ]+)\s*$'
158 # Linux version 6.0.8-300.fc37.ppc64le (mockbuild@buildvm-ppc64le-03.iad2.fedoraproject.org)
159 # (gcc (GCC) 12.2.1 20220819 (Red Hat 12.2.1-2), GNU ld version 2.38-24.fc37)
160 # #1 SMP Fri Nov 11 14:39:11 UTC 2022
161 TEXT_PATTERN
= rb
'Linux version (?P<version>\d\.\S+) \('
164 def scrape_x86(cls
, filename
, opts
=None):
165 # Based on https://gitlab.archlinux.org/archlinux/mkinitcpio/mkinitcpio/-/blob/master/functions#L136
166 # and https://www.kernel.org/doc/html/latest/x86/boot.html#the-real-mode-kernel-header
167 with
open(filename
, 'rb') as f
:
171 raise ValueError('Real-Mode Kernel Header magic not found')
173 offset
= f
.read(1)[0] + f
.read(1)[0]*256 # Pointer to kernel version string
174 f
.seek(0x200 + offset
)
176 text
= text
.split(b
'\0', maxsplit
=1)[0]
179 if not (m
:= re
.match(cls
.VERSION_PATTERN
, text
)):
180 raise ValueError(f
'Cannot parse version-host-release uname string: {text!r}')
181 return m
.group('version')
184 def scrape_elf(cls
, filename
, opts
=None):
185 readelf
= find_tool('readelf', opts
=opts
)
193 print('+', shell_join(cmd
))
195 notes
= subprocess
.check_output(cmd
, stderr
=subprocess
.PIPE
, text
=True)
196 except subprocess
.CalledProcessError
as e
:
197 raise ValueError(e
.stderr
.strip()) from e
199 if not (m
:= re
.search(cls
.NOTES_PATTERN
, notes
, re
.MULTILINE
)):
200 raise ValueError('Cannot find Linux version note')
202 text
= ''.join(chr(int(c
, 16)) for c
in m
.group('version').split())
203 return text
.rstrip('\0')
206 def scrape_generic(cls
, filename
, opts
=None):
208 # libarchive-c fails with
209 # ArchiveError: Unrecognized archive format (errno=84, retcode=-30, archive_p=94705420454656)
211 # Based on https://gitlab.archlinux.org/archlinux/mkinitcpio/mkinitcpio/-/blob/master/functions#L209
213 text
= maybe_decompress(filename
)
214 if not (m
:= re
.search(cls
.TEXT_PATTERN
, text
)):
215 raise ValueError(f
'Cannot find {cls.TEXT_PATTERN!r} in {filename}')
217 return m
.group('version').decode()
220 def scrape(cls
, filename
, opts
=None):
221 for func
in (cls
.scrape_x86
, cls
.scrape_elf
, cls
.scrape_generic
):
223 version
= func(filename
, opts
=opts
)
224 print(f
'Found uname version: {version}')
226 except ValueError as e
:
231 @dataclasses.dataclass
234 content
: pathlib
.Path
235 tmpfile
: Optional
[IO
] = None
236 measure
: bool = False
239 def create(cls
, name
, contents
, **kwargs
):
240 if isinstance(contents
, (str, bytes
)):
241 mode
= 'wt' if isinstance(contents
, str) else 'wb'
242 tmp
= tempfile
.NamedTemporaryFile(mode
=mode
, prefix
=f
'tmp{name}')
245 contents
= pathlib
.Path(tmp
.name
)
249 return cls(name
, contents
, tmpfile
=tmp
, **kwargs
)
252 def parse_arg(cls
, s
):
254 name
, contents
, *rest
= s
.split(':')
255 except ValueError as e
:
256 raise ValueError(f
'Cannot parse section spec (name or contents missing): {s!r}') from e
258 raise ValueError(f
'Cannot parse section spec (extraneous parameters): {s!r}')
260 if contents
.startswith('@'):
261 contents
= pathlib
.Path(contents
[1:])
263 return cls
.create(name
, contents
)
266 return self
.content
.stat().st_size
268 def check_name(self
):
269 # PE section names with more than 8 characters are legal, but our stub does
271 if not self
.name
.isascii() or not self
.name
.isprintable():
272 raise ValueError(f
'Bad section name: {self.name!r}')
273 if len(self
.name
) > 8:
274 raise ValueError(f
'Section name too long: {self.name!r}')
277 @dataclasses.dataclass
279 executable
: list[Union
[pathlib
.Path
, str]]
280 sections
: list[Section
] = dataclasses
.field(default_factory
=list, init
=False)
282 def add_section(self
, section
):
283 if section
.name
in [s
.name
for s
in self
.sections
]:
284 raise ValueError(f
'Duplicate section {section.name}')
286 self
.sections
+= [section
]
290 banks
= re
.split(r
',|\s+', s
)
291 # TODO: do some sanity checking here
304 def parse_phase_paths(s
):
305 # Split on commas or whitespace here. Commas might be hard to parse visually.
306 paths
= re
.split(r
',|\s+', s
)
309 for phase
in path
.split(':'):
310 if phase
not in KNOWN_PHASES
:
311 raise argparse
.ArgumentTypeError(f
'Unknown boot phase {phase!r} ({path=})')
316 def check_splash(filename
):
320 # import is delayed, to avoid import when the splash image is not used
322 from PIL
import Image
326 img
= Image
.open(filename
, formats
=['BMP'])
327 print(f
'Splash image {filename} is {img.width}×{img.height} pixels')
330 def check_inputs(opts
):
331 for name
, value
in vars(opts
).items():
332 if name
in {'output', 'tools'}:
335 if isinstance(value
, pathlib
.Path
):
336 # Open file to check that we can read it, or generate an exception
338 elif isinstance(value
, list):
340 if isinstance(item
, pathlib
.Path
):
343 check_splash(opts
.splash
)
346 def find_tool(name
, fallback
=None, opts
=None):
347 if opts
and opts
.tools
:
353 if shutil
.which(name
) is not None:
357 print(f
"Tool {name} not installed!")
361 def combine_signatures(pcrsigs
):
362 combined
= collections
.defaultdict(list)
363 for pcrsig
in pcrsigs
:
364 for bank
, sigs
in pcrsig
.items():
366 if sig
not in combined
[bank
]:
367 combined
[bank
] += [sig
]
368 return json
.dumps(combined
)
371 def call_systemd_measure(uki
, linux
, opts
):
372 measure_tool
= find_tool('systemd-measure',
373 '/usr/lib/systemd/systemd-measure',
376 banks
= opts
.pcr_banks
or ()
381 pp_groups
= opts
.phase_path_groups
or []
387 *(f
"--{s.name.removeprefix('.')}={s.content}"
388 for s
in uki
.sections
392 # For measurement, the keys are not relevant, so we can lump all the phase paths
393 # into one call to systemd-measure calculate.
394 *(f
'--phase={phase_path}'
395 for phase_path
in itertools
.chain
.from_iterable(pp_groups
)),
398 print('+', shell_join(cmd
))
399 subprocess
.check_call(cmd
)
403 if opts
.pcr_private_keys
:
404 n_priv
= len(opts
.pcr_private_keys
or ())
405 pp_groups
= opts
.phase_path_groups
or [None] * n_priv
406 pub_keys
= opts
.pcr_public_keys
or [None] * n_priv
414 *(f
"--{s.name.removeprefix('.')}={s.content}"
415 for s
in uki
.sections
421 for priv_key
, pub_key
, group
in zip(opts
.pcr_private_keys
,
424 extra
= [f
'--private-key={priv_key}']
426 extra
+= [f
'--public-key={pub_key}']
427 extra
+= [f
'--phase={phase_path}' for phase_path
in group
or ()]
429 print('+', shell_join(cmd
+ extra
))
430 pcrsig
= subprocess
.check_output(cmd
+ extra
, text
=True)
431 pcrsig
= json
.loads(pcrsig
)
434 combined
= combine_signatures(pcrsigs
)
435 uki
.add_section(Section
.create('.pcrsig', combined
))
438 def join_initrds(initrds
):
439 if len(initrds
) == 0:
441 elif len(initrds
) == 1:
446 initrd
= file.read_bytes()
448 padding
= b
'\0' * (round_up(n
, 4) - n
) # pad to 32 bit alignment
449 seq
+= [initrd
, padding
]
454 def pairwise(iterable
):
455 a
, b
= itertools
.tee(iterable
)
460 class PEError(Exception):
464 def pe_add_sections(uki
: UKI
, output
: str):
465 pe
= pefile
.PE(uki
.executable
, fast_load
=True)
467 # Old stubs do not have the symbol/string table stripped, even though image files should not have one.
468 if symbol_table
:= pe
.FILE_HEADER
.PointerToSymbolTable
:
469 symbol_table_size
= 18 * pe
.FILE_HEADER
.NumberOfSymbols
470 if string_table_size
:= pe
.get_dword_from_offset(symbol_table
+ symbol_table_size
):
471 symbol_table_size
+= string_table_size
473 # Let's be safe and only strip it if it's at the end of the file.
474 if symbol_table
+ symbol_table_size
== len(pe
.__data
__):
475 pe
.__data
__ = pe
.__data
__[:symbol_table
]
476 pe
.FILE_HEADER
.PointerToSymbolTable
= 0
477 pe
.FILE_HEADER
.NumberOfSymbols
= 0
478 pe
.FILE_HEADER
.IMAGE_FILE_LOCAL_SYMS_STRIPPED
= True
480 # Old stubs might have been stripped, leading to unaligned raw data values, so let's fix them up here.
481 for i
, section
in enumerate(pe
.sections
):
482 oldp
= section
.PointerToRawData
483 oldsz
= section
.SizeOfRawData
484 section
.PointerToRawData
= round_up(oldp
, pe
.OPTIONAL_HEADER
.FileAlignment
)
485 section
.SizeOfRawData
= round_up(oldsz
, pe
.OPTIONAL_HEADER
.FileAlignment
)
486 padp
= section
.PointerToRawData
- oldp
487 padsz
= section
.SizeOfRawData
- oldsz
489 for later_section
in pe
.sections
[i
+1:]:
490 later_section
.PointerToRawData
+= padp
+ padsz
492 pe
.__data
__ = pe
.__data
__[:oldp
] + bytes(padp
) + pe
.__data
__[oldp
:oldp
+oldsz
] + bytes(padsz
) + pe
.__data
__[oldp
+oldsz
:]
494 # We might not have any space to add new sections. Let's try our best to make some space by padding the
495 # SizeOfHeaders to a multiple of the file alignment. This is safe because the first section's data starts
496 # at a multiple of the file alignment, so all space before that is unused.
497 pe
.OPTIONAL_HEADER
.SizeOfHeaders
= round_up(pe
.OPTIONAL_HEADER
.SizeOfHeaders
, pe
.OPTIONAL_HEADER
.FileAlignment
)
498 pe
= pefile
.PE(data
=pe
.write(), fast_load
=True)
500 warnings
= pe
.get_warnings()
502 raise PEError(f
'pefile warnings treated as errors: {warnings}')
504 security
= pe
.OPTIONAL_HEADER
.DATA_DIRECTORY
[pefile
.DIRECTORY_ENTRY
['IMAGE_DIRECTORY_ENTRY_SECURITY']]
505 if security
.VirtualAddress
!= 0:
506 # We could strip the signatures, but why would anyone sign the stub?
507 raise PEError('Stub image is signed, refusing.')
509 for section
in uki
.sections
:
510 new_section
= pefile
.SectionStructure(pe
.__IMAGE
_SECTION
_HEADER
_format
__, pe
=pe
)
511 new_section
.__unpack
__(b
'\0' * new_section
.sizeof())
513 offset
= pe
.sections
[-1].get_file_offset() + new_section
.sizeof()
514 if offset
+ new_section
.sizeof() > pe
.OPTIONAL_HEADER
.SizeOfHeaders
:
515 raise PEError(f
'Not enough header space to add section {section.name}.')
517 data
= section
.content
.read_bytes()
519 new_section
.set_file_offset(offset
)
520 new_section
.Name
= section
.name
.encode()
521 new_section
.Misc_VirtualSize
= len(data
)
522 # Non-stripped stubs might still have an unaligned symbol table at the end, making their size
523 # unaligned, so we make sure to explicitly pad the pointer to new sections to an aligned offset.
524 new_section
.PointerToRawData
= round_up(len(pe
.__data
__), pe
.OPTIONAL_HEADER
.FileAlignment
)
525 new_section
.SizeOfRawData
= round_up(len(data
), pe
.OPTIONAL_HEADER
.FileAlignment
)
526 new_section
.VirtualAddress
= round_up(
527 pe
.sections
[-1].VirtualAddress
+ pe
.sections
[-1].Misc_VirtualSize
,
528 pe
.OPTIONAL_HEADER
.SectionAlignment
,
531 new_section
.IMAGE_SCN_MEM_READ
= True
532 if section
.name
== '.linux':
533 # Old kernels that use EFI handover protocol will be executed inline.
534 new_section
.IMAGE_SCN_CNT_CODE
= True
536 new_section
.IMAGE_SCN_CNT_INITIALIZED_DATA
= True
538 pe
.__data
__ = pe
.__data
__[:] + bytes(new_section
.PointerToRawData
- len(pe
.__data
__)) + data
+ bytes(new_section
.SizeOfRawData
- len(data
))
540 pe
.FILE_HEADER
.NumberOfSections
+= 1
541 pe
.OPTIONAL_HEADER
.SizeOfInitializedData
+= new_section
.Misc_VirtualSize
542 pe
.__structures
__.append(new_section
)
543 pe
.sections
.append(new_section
)
545 pe
.OPTIONAL_HEADER
.CheckSum
= 0
546 pe
.OPTIONAL_HEADER
.SizeOfImage
= round_up(
547 pe
.sections
[-1].VirtualAddress
+ pe
.sections
[-1].Misc_VirtualSize
,
548 pe
.OPTIONAL_HEADER
.SectionAlignment
,
553 def signer_sign(cmd
):
554 print('+', shell_join(cmd
))
555 subprocess
.check_call(cmd
)
557 def find_sbsign(opts
=None):
558 return find_tool('sbsign', opts
=opts
)
560 def sbsign_sign(sbsign_tool
, input_f
, output_f
, opts
=None):
563 '--key', opts
.sb_key
,
564 '--cert', opts
.sb_cert
,
566 '--output', output_f
,
568 if opts
.signing_engine
is not None:
569 sign_invocation
+= ['--engine', opts
.signing_engine
]
570 signer_sign(sign_invocation
)
572 def find_pesign(opts
=None):
573 return find_tool('pesign', opts
=opts
)
575 def pesign_sign(pesign_tool
, input_f
, output_f
, opts
=None):
577 pesign_tool
, '-s', '--force',
578 '-n', opts
.sb_certdir
,
579 '-c', opts
.sb_cert_name
,
583 signer_sign(sign_invocation
)
588 'output': 'No signature table present',
594 'output': 'No signatures found.',
598 def verify(tool
, opts
):
599 verify_tool
= find_tool(tool
['name'], opts
=opts
)
606 cmd
.append(tool
['flags'])
608 print('+', shell_join(cmd
))
609 info
= subprocess
.check_output(cmd
, text
=True)
611 return tool
['output'] in info
614 # kernel payload signing
617 if opts
.signtool
== 'sbsign':
618 sign_tool
= find_sbsign(opts
=opts
)
620 verify_tool
= SBVERIFY
622 sign_tool
= find_pesign(opts
=opts
)
624 verify_tool
= PESIGCHECK
626 sign_args_present
= opts
.sb_key
or opts
.sb_cert_name
628 if sign_tool
is None and sign_args_present
:
629 raise ValueError(f
'{opts.signtool}, required for signing, is not installed')
631 sign_kernel
= opts
.sign_kernel
632 if sign_kernel
is None and opts
.linux
is not None and sign_args_present
:
633 # figure out if we should sign the kernel
634 sign_kernel
= verify(verify_tool
, opts
)
637 linux_signed
= tempfile
.NamedTemporaryFile(prefix
='linux-signed')
638 linux
= linux_signed
.name
639 sign(sign_tool
, opts
.linux
, linux
, opts
=opts
)
643 if opts
.uname
is None and opts
.linux
is not None:
644 print('Kernel version not specified, starting autodetection 😖.')
645 opts
.uname
= Uname
.scrape(opts
.linux
, opts
=opts
)
648 initrd
= join_initrds(opts
.initrd
)
650 # TODO: derive public key from opts.pcr_private_keys?
651 pcrpkey
= opts
.pcrpkey
653 if opts
.pcr_public_keys
and len(opts
.pcr_public_keys
) == 1:
654 pcrpkey
= opts
.pcr_public_keys
[0]
657 # name, content, measure?
658 ('.osrel', opts
.os_release
, True ),
659 ('.cmdline', opts
.cmdline
, True ),
660 ('.dtb', opts
.devicetree
, True ),
661 ('.splash', opts
.splash
, True ),
662 ('.pcrpkey', pcrpkey
, True ),
663 ('.initrd', initrd
, True ),
664 ('.uname', opts
.uname
, False),
666 # linux shall be last to leave breathing room for decompression.
667 # We'll add it later.
670 for name
, content
, measure
in sections
:
672 uki
.add_section(Section
.create(name
, content
, measure
=measure
))
674 # systemd-measure doesn't know about those extra sections
675 for section
in opts
.sections
:
676 uki
.add_section(section
)
678 # PCR measurement and signing
680 call_systemd_measure(uki
, linux
, opts
=opts
)
684 if linux
is not None:
685 uki
.add_section(Section
.create('.linux', linux
, measure
=True))
687 if sign_args_present
:
688 unsigned
= tempfile
.NamedTemporaryFile(prefix
='uki')
689 output
= unsigned
.name
693 pe_add_sections(uki
, output
)
697 if sign_args_present
:
698 sign(sign_tool
, unsigned
.name
, opts
.output
, opts
=opts
)
700 # We end up with no executable bits, let's reapply them
701 os
.umask(umask
:= os
.umask(0))
702 os
.chmod(opts
.output
, 0o777 & ~umask
)
704 print(f
"Wrote {'signed' if sign_args_present else 'unsigned'} {opts.output}")
707 @dataclasses.dataclass(frozen
=True)
710 def config_list_prepend(
711 namespace
: argparse
.Namespace
,
712 group
: Optional
[str],
716 "Prepend value to namespace.<dest>"
720 old
= getattr(namespace
, dest
, [])
721 setattr(namespace
, dest
, value
+ old
)
724 def config_set_if_unset(
725 namespace
: argparse
.Namespace
,
726 group
: Optional
[str],
730 "Set namespace.<dest> to value only if it was None"
734 if getattr(namespace
, dest
) is None:
735 setattr(namespace
, dest
, value
)
738 def config_set_group(
739 namespace
: argparse
.Namespace
,
740 group
: Optional
[str],
744 "Set namespace.<dest>[idx] to value, with idx derived from group"
746 if group
not in namespace
._groups
:
747 namespace
._groups
+= [group
]
748 idx
= namespace
._groups
.index(group
)
750 old
= getattr(namespace
, dest
, None)
753 setattr(namespace
, dest
,
754 old
+ ([None] * (idx
- len(old
))) + [value
])
757 def parse_boolean(s
: str) -> bool:
758 "Parse 1/true/yes/y/t/on as true and 0/false/no/n/f/off/None as false"
760 if s_l
in {'1', 'true', 'yes', 'y', 't', 'on'}:
762 if s_l
in {'0', 'false', 'no', 'n', 'f', 'off'}:
764 raise ValueError('f"Invalid boolean literal: {s!r}')
766 # arguments for argparse.ArgumentParser.add_argument()
767 name
: Union
[str, tuple[str, str]]
768 dest
: Optional
[str] = None
769 metavar
: Optional
[str] = None
770 type: Optional
[Callable
] = None
771 nargs
: Optional
[str] = None
772 action
: Optional
[Union
[str, Callable
]] = None
774 version
: Optional
[str] = None
775 choices
: Optional
[tuple[str, ...]] = None
776 help: Optional
[str] = None
778 # metadata for config file parsing
779 config_key
: Optional
[str] = None
780 config_push
: Callable
[[argparse
.Namespace
, Optional
[str], str, Any
], None] = \
783 def _names(self
) -> tuple[str, ...]:
784 return self
.name
if isinstance(self
.name
, tuple) else (self
.name
,)
786 def argparse_dest(self
) -> str:
787 # It'd be nice if argparse exported this, but I don't see that in the API
790 return self
._names
()[0].lstrip('-').replace('-', '_')
792 def add_to(self
, parser
: argparse
.ArgumentParser
):
794 for key
in dataclasses
.asdict(self
)
795 if (key
not in ('name', 'config_key', 'config_push') and
796 (val
:= getattr(self
, key
)) is not None) }
798 parser
.add_argument(*args
, **kwargs
)
800 def apply_config(self
, namespace
, section
, group
, key
, value
) -> None:
801 assert f
'{section}/{key}' == self
.config_key
802 dest
= self
.argparse_dest()
804 conv
: Callable
[[str], Any
]
805 if self
.action
== argparse
.BooleanOptionalAction
:
806 # We need to handle this case separately: the options are called
807 # --foo and --no-foo, and no argument is parsed. But in the config
808 # file, we have Foo=yes or Foo=no.
809 conv
= self
.parse_boolean
815 if self
.nargs
== '*':
816 value
= [conv(v
) for v
in value
.split()]
820 self
.config_push(namespace
, group
, dest
, value
)
822 def config_example(self
) -> tuple[Optional
[str], Optional
[str], Optional
[str]]:
823 if not self
.config_key
:
824 return None, None, None
825 section_name
, key
= self
.config_key
.split('/', 1)
826 if section_name
.endswith(':'):
827 section_name
+= 'NAME'
829 value
= '|'.join(self
.choices
)
831 value
= self
.metavar
or self
.argparse_dest().upper()
832 return (section_name
, key
, value
)
839 version
= f
'ukify {__version__}',
844 help = 'print parsed config and exit',
845 action
= 'store_true',
853 help = 'vmlinuz file [.linux section]',
854 config_key
= 'UKI/Linux',
862 help = 'initrd files [.initrd section]',
863 config_key
= 'UKI/Initrd',
864 config_push
= ConfigItem
.config_list_prepend
,
870 help = 'configuration file',
875 metavar
= 'TEXT|@PATH',
876 help = 'kernel command line [.cmdline section]',
877 config_key
= 'UKI/Cmdline',
882 metavar
= 'TEXT|@PATH',
883 help = 'path to os-release file [.osrel section]',
884 config_key
= 'UKI/OSRelease',
891 help = 'Device Tree file [.dtb section]',
892 config_key
= 'UKI/DeviceTree',
898 help = 'splash image bitmap file [.splash section]',
899 config_key
= 'UKI/Splash',
905 help = 'embedded public key to seal secrets to [.pcrpkey section]',
906 config_key
= 'UKI/PCRPKey',
911 help='"uname -r" information [.uname section]',
912 config_key
= 'UKI/Uname',
918 choices
= ('ia32', 'x64', 'arm', 'aa64', 'riscv64'),
919 help = 'target EFI architecture',
920 config_key
= 'UKI/EFIArch',
926 help = 'path to the sd-stub file [.text,.data,… sections]',
927 config_key
= 'UKI/Stub',
933 metavar
= 'NAME:TEXT|@PATH',
934 type = Section
.parse_arg
,
937 help = 'additional section as name and contents [NAME section]',
944 config_key
= 'UKI/PCRBanks',
950 help = 'OpenSSL engine to use for signing',
951 config_key
= 'UKI/SigningEngine',
955 choices
= ('sbsign', 'pesign'),
958 help = 'whether to use sbsign or pesign. Default is sbsign.',
959 config_key
= 'UKI/SecureBootSigningTool',
962 '--secureboot-private-key',
964 help = 'required by --signtool=sbsign. Path to key file or engine-specific designation for SB signing',
965 config_key
= 'UKI/SecureBootPrivateKey',
968 '--secureboot-certificate',
970 help = 'required by --signtool=sbsign. sbsign needs a path to certificate file or engine-specific designation for SB signing',
971 config_key
= 'UKI/SecureBootCertificate',
974 '--secureboot-certificate-dir',
976 default
= '/etc/pki/pesign',
977 help = 'required by --signtool=pesign. Path to nss certificate database directory for PE signing. Default is /etc/pki/pesign',
978 config_key
= 'UKI/SecureBootCertificateDir',
981 '--secureboot-certificate-name',
982 dest
= 'sb_cert_name',
983 help = 'required by --signtool=pesign. pesign needs a certificate nickname of nss certificate database entry to use for PE signing',
984 config_key
= 'UKI/SecureBootCertificateName',
989 action
= argparse
.BooleanOptionalAction
,
990 help = 'Sign the embedded kernel',
991 config_key
= 'UKI/SignKernel',
996 dest
= 'pcr_private_keys',
1000 help = 'private part of the keypair for signing PCR signatures',
1001 config_key
= 'PCRSignature:/PCRPrivateKey',
1002 config_push
= ConfigItem
.config_set_group
,
1006 dest
= 'pcr_public_keys',
1008 type = pathlib
.Path
,
1010 help = 'public part of the keypair for signing PCR signatures',
1011 config_key
= 'PCRSignature:/PCRPublicKey',
1012 config_push
= ConfigItem
.config_set_group
,
1016 dest
= 'phase_path_groups',
1017 metavar
= 'PHASE-PATH…',
1018 type = parse_phase_paths
,
1020 help = 'phase-paths to create signatures for',
1021 config_key
= 'PCRSignature:/Phases',
1022 config_push
= ConfigItem
.config_set_group
,
1027 type = pathlib
.Path
,
1029 help = 'Directories to search for tools (systemd-measure, …)',
1034 type = pathlib
.Path
,
1035 help = 'output file path',
1040 action
= argparse
.BooleanOptionalAction
,
1041 help = 'print systemd-measure output for the UKI',
1045 CONFIGFILE_ITEMS
= { item
.config_key
:item
1046 for item
in CONFIG_ITEMS
1047 if item
.config_key
}
1050 def apply_config(namespace
, filename
=None):
1051 if filename
is None:
1052 filename
= namespace
.config
1053 if filename
is None:
1056 # Fill in ._groups based on --pcr-public-key=, --pcr-private-key=, and --phases=.
1057 assert '_groups' not in namespace
1058 n_pcr_priv
= len(namespace
.pcr_private_keys
or ())
1059 namespace
._groups
= list(range(n_pcr_priv
))
1061 cp
= configparser
.ConfigParser(
1062 comment_prefixes
='#',
1063 inline_comment_prefixes
='#',
1065 empty_lines_in_values
=False,
1068 # Do not make keys lowercase
1069 cp
.optionxform
= lambda option
: option
1073 for section_name
, section
in cp
.items():
1074 idx
= section_name
.find(':')
1076 section_name
, group
= section_name
[:idx
+1], section_name
[idx
+1:]
1077 if not section_name
or not group
:
1078 raise ValueError('Section name components cannot be empty')
1080 raise ValueError('Section name cannot contain more than one ":"')
1083 for key
, value
in section
.items():
1084 if item
:= CONFIGFILE_ITEMS
.get(f
'{section_name}/{key}'):
1085 item
.apply_config(namespace
, section_name
, group
, key
, value
)
1087 print(f
'Unknown config setting [{section_name}] {key}=')
1090 def config_example():
1092 for item
in CONFIG_ITEMS
:
1093 section
, key
, value
= item
.config_example()
1095 if prev_section
!= section
:
1098 yield f
'[{section}]'
1099 prev_section
= section
1100 yield f
'{key} = {value}'
1103 def create_parser():
1104 p
= argparse
.ArgumentParser(
1105 description
='Build and sign Unified Kernel Images',
1108 ukify [options…] [LINUX INITRD…]
1110 epilog
='\n '.join(('config file:', *config_example())),
1111 formatter_class
=argparse
.RawDescriptionHelpFormatter
,
1114 for item
in CONFIG_ITEMS
:
1117 # Suppress printing of usage synopsis on errors
1118 p
.error
= lambda message
: p
.exit(2, f
'{p.prog}: error: {message}\n')
1123 def finalize_options(opts
):
1124 if opts
.cmdline
and opts
.cmdline
.startswith('@'):
1125 opts
.cmdline
= pathlib
.Path(opts
.cmdline
[1:])
1127 # Drop whitespace from the commandline. If we're reading from a file,
1128 # we copy the contents verbatim. But configuration specified on the commandline
1129 # or in the config file may contain additional whitespace that has no meaning.
1130 opts
.cmdline
= ' '.join(opts
.cmdline
.split())
1132 if opts
.os_release
and opts
.os_release
.startswith('@'):
1133 opts
.os_release
= pathlib
.Path(opts
.os_release
[1:])
1134 elif not opts
.os_release
and opts
.linux
:
1135 p
= pathlib
.Path('/etc/os-release')
1137 p
= pathlib
.Path('/usr/lib/os-release')
1140 if opts
.efi_arch
is None:
1141 opts
.efi_arch
= guess_efi_arch()
1143 if opts
.stub
is None:
1144 opts
.stub
= pathlib
.Path(f
'/usr/lib/systemd/boot/efi/linux{opts.efi_arch}.efi.stub')
1146 if opts
.signing_engine
is None:
1148 opts
.sb_key
= pathlib
.Path(opts
.sb_key
)
1150 opts
.sb_cert
= pathlib
.Path(opts
.sb_cert
)
1152 if opts
.signtool
== 'sbsign':
1153 if bool(opts
.sb_key
) ^
bool(opts
.sb_cert
):
1154 raise ValueError('--secureboot-private-key= and --secureboot-certificate= must be specified together when using --signtool=sbsign')
1156 if not bool(opts
.sb_cert_name
):
1157 raise ValueError('--certificate-name must be specified when using --signtool=pesign')
1159 if opts
.sign_kernel
and not opts
.sb_key
and not opts
.sb_cert_name
:
1160 raise ValueError('--sign-kernel requires either --secureboot-private-key= and --secureboot-certificate= (for sbsign) or --secureboot-certificate-name= (for pesign) to be specified')
1162 if opts
.output
is None:
1163 if opts
.linux
is None:
1164 raise ValueError('--output= must be specified when building a PE addon')
1165 suffix
= '.efi' if opts
.sb_key
or opts
.sb_cert_name
else '.unsigned.efi'
1166 opts
.output
= opts
.linux
.name
+ suffix
1168 for section
in opts
.sections
:
1169 section
.check_name()
1172 # TODO: replace pprint() with some fancy formatting.
1173 pprint
.pprint(vars(opts
))
1177 def parse_args(args
=None):
1179 opts
= p
.parse_args(args
)
1181 # Check that --pcr-public-key=, --pcr-private-key=, and --phases=
1182 # have either the same number of arguments are are not specified at all.
1183 n_pcr_pub
= None if opts
.pcr_public_keys
is None else len(opts
.pcr_public_keys
)
1184 n_pcr_priv
= None if opts
.pcr_private_keys
is None else len(opts
.pcr_private_keys
)
1185 n_phase_path_groups
= None if opts
.phase_path_groups
is None else len(opts
.phase_path_groups
)
1186 if n_pcr_pub
is not None and n_pcr_pub
!= n_pcr_priv
:
1187 raise ValueError('--pcr-public-key= specifications must match --pcr-private-key=')
1188 if n_phase_path_groups
is not None and n_phase_path_groups
!= n_pcr_priv
:
1189 raise ValueError('--phases= specifications must match --pcr-private-key=')
1193 finalize_options(opts
)
1204 if __name__
== '__main__':