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
= pathlib
.Path(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 ('.uname', opts
.uname
, True ),
662 ('.splash', opts
.splash
, True ),
663 ('.pcrpkey', pcrpkey
, True ),
664 ('.initrd', initrd
, True ),
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
)
682 # UKI or addon creation - addons don't use the stub so we add SBAT manually
684 if linux
is not None:
685 uki
.add_section(Section
.create('.linux', linux
, measure
=True))
687 uki
.add_section(Section
.create('.sbat', opts
.sbat
, measure
=False))
689 if sign_args_present
:
690 unsigned
= tempfile
.NamedTemporaryFile(prefix
='uki')
691 output
= unsigned
.name
695 pe_add_sections(uki
, output
)
699 if sign_args_present
:
700 sign(sign_tool
, unsigned
.name
, opts
.output
, opts
=opts
)
702 # We end up with no executable bits, let's reapply them
703 os
.umask(umask
:= os
.umask(0))
704 os
.chmod(opts
.output
, 0o777 & ~umask
)
706 print(f
"Wrote {'signed' if sign_args_present else 'unsigned'} {opts.output}")
709 @dataclasses.dataclass(frozen
=True)
712 def config_list_prepend(
713 namespace
: argparse
.Namespace
,
714 group
: Optional
[str],
718 "Prepend value to namespace.<dest>"
722 old
= getattr(namespace
, dest
, [])
723 setattr(namespace
, dest
, value
+ old
)
726 def config_set_if_unset(
727 namespace
: argparse
.Namespace
,
728 group
: Optional
[str],
732 "Set namespace.<dest> to value only if it was None"
736 if getattr(namespace
, dest
) is None:
737 setattr(namespace
, dest
, value
)
740 def config_set_group(
741 namespace
: argparse
.Namespace
,
742 group
: Optional
[str],
746 "Set namespace.<dest>[idx] to value, with idx derived from group"
748 if group
not in namespace
._groups
:
749 namespace
._groups
+= [group
]
750 idx
= namespace
._groups
.index(group
)
752 old
= getattr(namespace
, dest
, None)
755 setattr(namespace
, dest
,
756 old
+ ([None] * (idx
- len(old
))) + [value
])
759 def parse_boolean(s
: str) -> bool:
760 "Parse 1/true/yes/y/t/on as true and 0/false/no/n/f/off/None as false"
762 if s_l
in {'1', 'true', 'yes', 'y', 't', 'on'}:
764 if s_l
in {'0', 'false', 'no', 'n', 'f', 'off'}:
766 raise ValueError('f"Invalid boolean literal: {s!r}')
768 # arguments for argparse.ArgumentParser.add_argument()
769 name
: Union
[str, tuple[str, str]]
770 dest
: Optional
[str] = None
771 metavar
: Optional
[str] = None
772 type: Optional
[Callable
] = None
773 nargs
: Optional
[str] = None
774 action
: Optional
[Union
[str, Callable
]] = None
776 version
: Optional
[str] = None
777 choices
: Optional
[tuple[str, ...]] = None
778 help: Optional
[str] = None
780 # metadata for config file parsing
781 config_key
: Optional
[str] = None
782 config_push
: Callable
[[argparse
.Namespace
, Optional
[str], str, Any
], None] = \
785 def _names(self
) -> tuple[str, ...]:
786 return self
.name
if isinstance(self
.name
, tuple) else (self
.name
,)
788 def argparse_dest(self
) -> str:
789 # It'd be nice if argparse exported this, but I don't see that in the API
792 return self
._names
()[0].lstrip('-').replace('-', '_')
794 def add_to(self
, parser
: argparse
.ArgumentParser
):
796 for key
in dataclasses
.asdict(self
)
797 if (key
not in ('name', 'config_key', 'config_push') and
798 (val
:= getattr(self
, key
)) is not None) }
800 parser
.add_argument(*args
, **kwargs
)
802 def apply_config(self
, namespace
, section
, group
, key
, value
) -> None:
803 assert f
'{section}/{key}' == self
.config_key
804 dest
= self
.argparse_dest()
806 conv
: Callable
[[str], Any
]
807 if self
.action
== argparse
.BooleanOptionalAction
:
808 # We need to handle this case separately: the options are called
809 # --foo and --no-foo, and no argument is parsed. But in the config
810 # file, we have Foo=yes or Foo=no.
811 conv
= self
.parse_boolean
817 if self
.nargs
== '*':
818 value
= [conv(v
) for v
in value
.split()]
822 self
.config_push(namespace
, group
, dest
, value
)
824 def config_example(self
) -> tuple[Optional
[str], Optional
[str], Optional
[str]]:
825 if not self
.config_key
:
826 return None, None, None
827 section_name
, key
= self
.config_key
.split('/', 1)
828 if section_name
.endswith(':'):
829 section_name
+= 'NAME'
831 value
= '|'.join(self
.choices
)
833 value
= self
.metavar
or self
.argparse_dest().upper()
834 return (section_name
, key
, value
)
841 version
= f
'ukify {__version__}',
846 help = 'print parsed config and exit',
847 action
= 'store_true',
855 help = 'vmlinuz file [.linux section]',
856 config_key
= 'UKI/Linux',
864 help = 'initrd files [.initrd section]',
865 config_key
= 'UKI/Initrd',
866 config_push
= ConfigItem
.config_list_prepend
,
872 help = 'configuration file',
877 metavar
= 'TEXT|@PATH',
878 help = 'kernel command line [.cmdline section]',
879 config_key
= 'UKI/Cmdline',
884 metavar
= 'TEXT|@PATH',
885 help = 'path to os-release file [.osrel section]',
886 config_key
= 'UKI/OSRelease',
893 help = 'Device Tree file [.dtb section]',
894 config_key
= 'UKI/DeviceTree',
900 help = 'splash image bitmap file [.splash section]',
901 config_key
= 'UKI/Splash',
907 help = 'embedded public key to seal secrets to [.pcrpkey section]',
908 config_key
= 'UKI/PCRPKey',
913 help='"uname -r" information [.uname section]',
914 config_key
= 'UKI/Uname',
920 choices
= ('ia32', 'x64', 'arm', 'aa64', 'riscv64'),
921 help = 'target EFI architecture',
922 config_key
= 'UKI/EFIArch',
928 help = 'path to the sd-stub file [.text,.data,… sections]',
929 config_key
= 'UKI/Stub',
934 metavar
= 'TEXT|@PATH',
935 help = 'SBAT policy [.sbat section] for addons',
936 default
= """sbat,1,SBAT Version,sbat,1,https://github.com/rhboot/shim/blob/main/SBAT.md
937 uki.addon,1,UKI Addon,uki.addon,1,https://www.freedesktop.org/software/systemd/man/systemd-stub.html
939 config_key
= 'Addon/SBAT',
945 metavar
= 'NAME:TEXT|@PATH',
946 type = Section
.parse_arg
,
949 help = 'additional section as name and contents [NAME section]',
956 config_key
= 'UKI/PCRBanks',
962 help = 'OpenSSL engine to use for signing',
963 config_key
= 'UKI/SigningEngine',
967 choices
= ('sbsign', 'pesign'),
970 help = 'whether to use sbsign or pesign. Default is sbsign.',
971 config_key
= 'UKI/SecureBootSigningTool',
974 '--secureboot-private-key',
976 help = 'required by --signtool=sbsign. Path to key file or engine-specific designation for SB signing',
977 config_key
= 'UKI/SecureBootPrivateKey',
980 '--secureboot-certificate',
982 help = 'required by --signtool=sbsign. sbsign needs a path to certificate file or engine-specific designation for SB signing',
983 config_key
= 'UKI/SecureBootCertificate',
986 '--secureboot-certificate-dir',
988 default
= '/etc/pki/pesign',
989 help = 'required by --signtool=pesign. Path to nss certificate database directory for PE signing. Default is /etc/pki/pesign',
990 config_key
= 'UKI/SecureBootCertificateDir',
993 '--secureboot-certificate-name',
994 dest
= 'sb_cert_name',
995 help = 'required by --signtool=pesign. pesign needs a certificate nickname of nss certificate database entry to use for PE signing',
996 config_key
= 'UKI/SecureBootCertificateName',
1001 action
= argparse
.BooleanOptionalAction
,
1002 help = 'Sign the embedded kernel',
1003 config_key
= 'UKI/SignKernel',
1007 '--pcr-private-key',
1008 dest
= 'pcr_private_keys',
1010 type = pathlib
.Path
,
1012 help = 'private part of the keypair for signing PCR signatures',
1013 config_key
= 'PCRSignature:/PCRPrivateKey',
1014 config_push
= ConfigItem
.config_set_group
,
1018 dest
= 'pcr_public_keys',
1020 type = pathlib
.Path
,
1022 help = 'public part of the keypair for signing PCR signatures',
1023 config_key
= 'PCRSignature:/PCRPublicKey',
1024 config_push
= ConfigItem
.config_set_group
,
1028 dest
= 'phase_path_groups',
1029 metavar
= 'PHASE-PATH…',
1030 type = parse_phase_paths
,
1032 help = 'phase-paths to create signatures for',
1033 config_key
= 'PCRSignature:/Phases',
1034 config_push
= ConfigItem
.config_set_group
,
1039 type = pathlib
.Path
,
1041 help = 'Directories to search for tools (systemd-measure, …)',
1046 type = pathlib
.Path
,
1047 help = 'output file path',
1052 action
= argparse
.BooleanOptionalAction
,
1053 help = 'print systemd-measure output for the UKI',
1057 CONFIGFILE_ITEMS
= { item
.config_key
:item
1058 for item
in CONFIG_ITEMS
1059 if item
.config_key
}
1062 def apply_config(namespace
, filename
=None):
1063 if filename
is None:
1064 filename
= namespace
.config
1065 if filename
is None:
1068 # Fill in ._groups based on --pcr-public-key=, --pcr-private-key=, and --phases=.
1069 assert '_groups' not in namespace
1070 n_pcr_priv
= len(namespace
.pcr_private_keys
or ())
1071 namespace
._groups
= list(range(n_pcr_priv
))
1073 cp
= configparser
.ConfigParser(
1074 comment_prefixes
='#',
1075 inline_comment_prefixes
='#',
1077 empty_lines_in_values
=False,
1080 # Do not make keys lowercase
1081 cp
.optionxform
= lambda option
: option
1085 for section_name
, section
in cp
.items():
1086 idx
= section_name
.find(':')
1088 section_name
, group
= section_name
[:idx
+1], section_name
[idx
+1:]
1089 if not section_name
or not group
:
1090 raise ValueError('Section name components cannot be empty')
1092 raise ValueError('Section name cannot contain more than one ":"')
1095 for key
, value
in section
.items():
1096 if item
:= CONFIGFILE_ITEMS
.get(f
'{section_name}/{key}'):
1097 item
.apply_config(namespace
, section_name
, group
, key
, value
)
1099 print(f
'Unknown config setting [{section_name}] {key}=')
1102 def config_example():
1104 for item
in CONFIG_ITEMS
:
1105 section
, key
, value
= item
.config_example()
1107 if prev_section
!= section
:
1110 yield f
'[{section}]'
1111 prev_section
= section
1112 yield f
'{key} = {value}'
1115 def create_parser():
1116 p
= argparse
.ArgumentParser(
1117 description
='Build and sign Unified Kernel Images',
1120 ukify [options…] [LINUX INITRD…]
1122 epilog
='\n '.join(('config file:', *config_example())),
1123 formatter_class
=argparse
.RawDescriptionHelpFormatter
,
1126 for item
in CONFIG_ITEMS
:
1129 # Suppress printing of usage synopsis on errors
1130 p
.error
= lambda message
: p
.exit(2, f
'{p.prog}: error: {message}\n')
1135 def finalize_options(opts
):
1136 if opts
.cmdline
and opts
.cmdline
.startswith('@'):
1137 opts
.cmdline
= pathlib
.Path(opts
.cmdline
[1:])
1139 # Drop whitespace from the commandline. If we're reading from a file,
1140 # we copy the contents verbatim. But configuration specified on the commandline
1141 # or in the config file may contain additional whitespace that has no meaning.
1142 opts
.cmdline
= ' '.join(opts
.cmdline
.split())
1144 if opts
.os_release
and opts
.os_release
.startswith('@'):
1145 opts
.os_release
= pathlib
.Path(opts
.os_release
[1:])
1146 elif not opts
.os_release
and opts
.linux
:
1147 p
= pathlib
.Path('/etc/os-release')
1149 p
= pathlib
.Path('/usr/lib/os-release')
1152 if opts
.efi_arch
is None:
1153 opts
.efi_arch
= guess_efi_arch()
1155 if opts
.stub
is None:
1156 if opts
.linux
is not None:
1157 opts
.stub
= pathlib
.Path(f
'/usr/lib/systemd/boot/efi/linux{opts.efi_arch}.efi.stub')
1159 opts
.stub
= pathlib
.Path(f
'/usr/lib/systemd/boot/efi/addon{opts.efi_arch}.efi.stub')
1161 if opts
.signing_engine
is None:
1163 opts
.sb_key
= pathlib
.Path(opts
.sb_key
)
1165 opts
.sb_cert
= pathlib
.Path(opts
.sb_cert
)
1167 if opts
.signtool
== 'sbsign':
1168 if bool(opts
.sb_key
) ^
bool(opts
.sb_cert
):
1169 raise ValueError('--secureboot-private-key= and --secureboot-certificate= must be specified together when using --signtool=sbsign')
1171 if not bool(opts
.sb_cert_name
):
1172 raise ValueError('--certificate-name must be specified when using --signtool=pesign')
1174 if opts
.sign_kernel
and not opts
.sb_key
and not opts
.sb_cert_name
:
1175 raise ValueError('--sign-kernel requires either --secureboot-private-key= and --secureboot-certificate= (for sbsign) or --secureboot-certificate-name= (for pesign) to be specified')
1177 if opts
.output
is None:
1178 if opts
.linux
is None:
1179 raise ValueError('--output= must be specified when building a PE addon')
1180 suffix
= '.efi' if opts
.sb_key
or opts
.sb_cert_name
else '.unsigned.efi'
1181 opts
.output
= opts
.linux
.name
+ suffix
1183 for section
in opts
.sections
:
1184 section
.check_name()
1187 # TODO: replace pprint() with some fancy formatting.
1188 pprint
.pprint(vars(opts
))
1192 def parse_args(args
=None):
1194 opts
= p
.parse_args(args
)
1196 # Check that --pcr-public-key=, --pcr-private-key=, and --phases=
1197 # have either the same number of arguments are are not specified at all.
1198 n_pcr_pub
= None if opts
.pcr_public_keys
is None else len(opts
.pcr_public_keys
)
1199 n_pcr_priv
= None if opts
.pcr_private_keys
is None else len(opts
.pcr_private_keys
)
1200 n_phase_path_groups
= None if opts
.phase_path_groups
is None else len(opts
.phase_path_groups
)
1201 if n_pcr_pub
is not None and n_pcr_pub
!= n_pcr_priv
:
1202 raise ValueError('--pcr-public-key= specifications must match --pcr-private-key=')
1203 if n_phase_path_groups
is not None and n_phase_path_groups
!= n_pcr_priv
:
1204 raise ValueError('--phases= specifications must match --pcr-private-key=')
1208 finalize_options(opts
)
1219 if __name__
== '__main__':