2 # SPDX-License-Identifier: LGPL-2.1-or-later
4 # pylint: disable=missing-docstring,invalid-name,import-outside-toplevel
5 # pylint: disable=consider-using-with,unspecified-encoding,line-too-long
6 # pylint: disable=too-many-locals,too-many-statements,too-many-return-statements
7 # pylint: disable=too-many-branches
26 __version__
= '{{PROJECT_VERSION}} ({{GIT_VERSION}})'
29 # host_arch glob : [efi_arch, 32_bit_efi_arch if mixed mode is supported]
30 'x86_64' : ['x64', 'ia32'],
31 'i[3456]86' : ['ia32'],
33 'arm[45678]*l' : ['arm'],
34 'riscv64' : ['riscv64'],
36 EFI_ARCHES
: list[str] = sum(EFI_ARCH_MAP
.values(), [])
39 arch
= os
.uname().machine
41 for glob
, mapping
in EFI_ARCH_MAP
.items():
42 if fnmatch
.fnmatch(arch
, glob
):
43 efi_arch
, *fallback
= mapping
46 raise ValueError(f
'Unsupported architecture {arch}')
48 # This makes sense only on some architectures, but it also probably doesn't
49 # hurt on others, so let's just apply the check everywhere.
51 fw_platform_size
= pathlib
.Path('/sys/firmware/efi/fw_platform_size')
53 size
= fw_platform_size
.read_text().strip()
54 except FileNotFoundError
:
58 efi_arch
= fallback
[0]
60 print(f
'Host arch {arch!r}, EFI arch {efi_arch!r}')
65 # TODO: drop in favour of shlex.join once shlex.join supports pathlib.Path.
66 return ' '.join(shlex
.quote(str(x
)) for x
in cmd
)
69 def path_is_readable(s
: typing
.Optional
[str]) -> typing
.Optional
[pathlib
.Path
]:
70 """Convert a filename string to a Path and verify access."""
76 except IsADirectoryError
:
81 def round_up(x
, blocksize
=4096):
82 return (x
+ blocksize
- 1) // blocksize
* blocksize
85 def try_import(modname
, name
=None):
87 return __import__(modname
)
88 except ImportError as e
:
89 raise ValueError(f
'Kernel is compressed with {name or modname}, but module unavailable') from e
92 def maybe_decompress(filename
):
93 """Decompress file if compressed. Return contents."""
94 f
= open(filename
, 'rb')
98 if start
.startswith(b
'\x7fELF'):
102 if start
.startswith(b
'MZ'):
103 # not compressed aarch64 and riscv64
106 if start
.startswith(b
'\x1f\x8b'):
107 gzip
= try_import('gzip')
108 return gzip
.open(f
).read()
110 if start
.startswith(b
'\x28\xb5\x2f\xfd'):
111 zstd
= try_import('zstd')
112 return zstd
.uncompress(f
.read())
114 if start
.startswith(b
'\x02\x21\x4c\x18'):
115 lz4
= try_import('lz4.frame', 'lz4')
116 return lz4
.frame
.decompress(f
.read())
118 if start
.startswith(b
'\x04\x22\x4d\x18'):
119 print('Newer lz4 stream format detected! This may not boot!')
120 lz4
= try_import('lz4.frame', 'lz4')
121 return lz4
.frame
.decompress(f
.read())
123 if start
.startswith(b
'\x89LZO'):
124 # python3-lzo is not packaged for Fedora
125 raise NotImplementedError('lzo decompression not implemented')
127 if start
.startswith(b
'BZh'):
128 bz2
= try_import('bz2', 'bzip2')
129 return bz2
.open(f
).read()
131 if start
.startswith(b
'\x5d\x00\x00'):
132 lzma
= try_import('lzma')
133 return lzma
.open(f
).read()
135 raise NotImplementedError(f
'unknown file format (starts with {start})')
139 # This class is here purely as a namespace for the functions
141 VERSION_PATTERN
= r
'(?P<version>[a-z0-9._-]+) \([^ )]+\) (?:#.*)'
143 NOTES_PATTERN
= r
'^\s+Linux\s+0x[0-9a-f]+\s+OPEN\n\s+description data: (?P<version>[0-9a-f ]+)\s*$'
145 # Linux version 6.0.8-300.fc37.ppc64le (mockbuild@buildvm-ppc64le-03.iad2.fedoraproject.org)
146 # (gcc (GCC) 12.2.1 20220819 (Red Hat 12.2.1-2), GNU ld version 2.38-24.fc37)
147 # #1 SMP Fri Nov 11 14:39:11 UTC 2022
148 TEXT_PATTERN
= rb
'Linux version (?P<version>\d\.\S+) \('
151 def scrape_x86(cls
, filename
, opts
=None):
152 # Based on https://gitlab.archlinux.org/archlinux/mkinitcpio/mkinitcpio/-/blob/master/functions#L136
153 # and https://www.kernel.org/doc/html/latest/x86/boot.html#the-real-mode-kernel-header
154 with
open(filename
, 'rb') as f
:
158 raise ValueError('Real-Mode Kernel Header magic not found')
160 offset
= f
.read(1)[0] + f
.read(1)[0]*256 # Pointer to kernel version string
161 f
.seek(0x200 + offset
)
163 text
= text
.split(b
'\0', maxsplit
=1)[0]
166 if not (m
:= re
.match(cls
.VERSION_PATTERN
, text
)):
167 raise ValueError(f
'Cannot parse version-host-release uname string: {text!r}')
168 return m
.group('version')
171 def scrape_elf(cls
, filename
, opts
=None):
172 readelf
= find_tool('readelf', opts
=opts
)
180 print('+', shell_join(cmd
))
182 notes
= subprocess
.check_output(cmd
, stderr
=subprocess
.PIPE
, text
=True)
183 except subprocess
.CalledProcessError
as e
:
184 raise ValueError(e
.stderr
.strip()) from e
186 if not (m
:= re
.search(cls
.NOTES_PATTERN
, notes
, re
.MULTILINE
)):
187 raise ValueError('Cannot find Linux version note')
189 text
= ''.join(chr(int(c
, 16)) for c
in m
.group('version').split())
190 return text
.rstrip('\0')
193 def scrape_generic(cls
, filename
, opts
=None):
195 # libarchive-c fails with
196 # ArchiveError: Unrecognized archive format (errno=84, retcode=-30, archive_p=94705420454656)
198 # Based on https://gitlab.archlinux.org/archlinux/mkinitcpio/mkinitcpio/-/blob/master/functions#L209
200 text
= maybe_decompress(filename
)
201 if not (m
:= re
.search(cls
.TEXT_PATTERN
, text
)):
202 raise ValueError(f
'Cannot find {cls.TEXT_PATTERN!r} in {filename}')
204 return m
.group('version').decode()
207 def scrape(cls
, filename
, opts
=None):
208 for func
in (cls
.scrape_x86
, cls
.scrape_elf
, cls
.scrape_generic
):
210 version
= func(filename
, opts
=opts
)
211 print(f
'Found uname version: {version}')
213 except ValueError as e
:
218 @dataclasses.dataclass
221 content
: pathlib
.Path
222 tmpfile
: typing
.Optional
[typing
.IO
] = None
223 measure
: bool = False
226 def create(cls
, name
, contents
, **kwargs
):
227 if isinstance(contents
, (str, bytes
)):
228 mode
= 'wt' if isinstance(contents
, str) else 'wb'
229 tmp
= tempfile
.NamedTemporaryFile(mode
=mode
, prefix
=f
'tmp{name}')
232 contents
= pathlib
.Path(tmp
.name
)
236 return cls(name
, contents
, tmpfile
=tmp
, **kwargs
)
239 def parse_arg(cls
, s
):
241 name
, contents
, *rest
= s
.split(':')
242 except ValueError as e
:
243 raise ValueError(f
'Cannot parse section spec (name or contents missing): {s!r}') from e
245 raise ValueError(f
'Cannot parse section spec (extraneous parameters): {s!r}')
247 if contents
.startswith('@'):
248 contents
= pathlib
.Path(contents
[1:])
250 return cls
.create(name
, contents
)
253 return self
.content
.stat().st_size
255 def check_name(self
):
256 # PE section names with more than 8 characters are legal, but our stub does
258 if not self
.name
.isascii() or not self
.name
.isprintable():
259 raise ValueError(f
'Bad section name: {self.name!r}')
260 if len(self
.name
) > 8:
261 raise ValueError(f
'Section name too long: {self.name!r}')
264 @dataclasses.dataclass
266 executable
: list[typing
.Union
[pathlib
.Path
, str]]
267 sections
: list[Section
] = dataclasses
.field(default_factory
=list, init
=False)
269 def add_section(self
, section
):
270 if section
.name
in [s
.name
for s
in self
.sections
]:
271 raise ValueError(f
'Duplicate section {section.name}')
273 self
.sections
+= [section
]
277 banks
= re
.split(r
',|\s+', s
)
278 # TODO: do some sanity checking here
291 def parse_phase_paths(s
):
292 # Split on commas or whitespace here. Commas might be hard to parse visually.
293 paths
= re
.split(r
',|\s+', s
)
296 for phase
in path
.split(':'):
297 if phase
not in KNOWN_PHASES
:
298 raise argparse
.ArgumentTypeError(f
'Unknown boot phase {phase!r} ({path=})')
303 def check_splash(filename
):
307 # import is delayed, to avoid import when the splash image is not used
309 from PIL
import Image
313 img
= Image
.open(filename
, formats
=['BMP'])
314 print(f
'Splash image {filename} is {img.width}×{img.height} pixels')
317 def check_inputs(opts
):
318 for name
, value
in vars(opts
).items():
319 if name
in {'output', 'tools'}:
322 if not isinstance(value
, pathlib
.Path
):
325 # Open file to check that we can read it, or generate an exception
328 check_splash(opts
.splash
)
331 def find_tool(name
, fallback
=None, opts
=None):
332 if opts
and opts
.tools
:
338 if shutil
.which(name
) is not None:
344 def combine_signatures(pcrsigs
):
345 combined
= collections
.defaultdict(list)
346 for pcrsig
in pcrsigs
:
347 for bank
, sigs
in pcrsig
.items():
349 if sig
not in combined
[bank
]:
350 combined
[bank
] += [sig
]
351 return json
.dumps(combined
)
354 def call_systemd_measure(uki
, linux
, opts
):
355 measure_tool
= find_tool('systemd-measure',
356 '/usr/lib/systemd/systemd-measure',
359 banks
= opts
.pcr_banks
or ()
364 pp_groups
= opts
.phase_path_groups
or []
370 *(f
"--{s.name.removeprefix('.')}={s.content}"
371 for s
in uki
.sections
375 # For measurement, the keys are not relevant, so we can lump all the phase paths
376 # into one call to systemd-measure calculate.
377 *(f
'--phase={phase_path}'
378 for phase_path
in itertools
.chain
.from_iterable(pp_groups
)),
381 print('+', shell_join(cmd
))
382 subprocess
.check_call(cmd
)
386 if opts
.pcr_private_keys
:
387 n_priv
= len(opts
.pcr_private_keys
or ())
388 pp_groups
= opts
.phase_path_groups
or [None] * n_priv
389 pub_keys
= opts
.pcr_public_keys
or [None] * n_priv
397 *(f
"--{s.name.removeprefix('.')}={s.content}"
398 for s
in uki
.sections
404 for priv_key
, pub_key
, group
in zip(opts
.pcr_private_keys
,
407 extra
= [f
'--private-key={priv_key}']
409 extra
+= [f
'--public-key={pub_key}']
410 extra
+= [f
'--phase={phase_path}' for phase_path
in group
or ()]
412 print('+', shell_join(cmd
+ extra
))
413 pcrsig
= subprocess
.check_output(cmd
+ extra
, text
=True)
414 pcrsig
= json
.loads(pcrsig
)
417 combined
= combine_signatures(pcrsigs
)
418 uki
.add_section(Section
.create('.pcrsig', combined
))
421 def join_initrds(initrds
):
422 if len(initrds
) == 0:
424 elif len(initrds
) == 1:
429 initrd
= file.read_bytes()
431 padding
= b
'\0' * (round_up(n
, 4) - n
) # pad to 32 bit alignment
432 seq
+= [initrd
, padding
]
437 def pairwise(iterable
):
438 a
, b
= itertools
.tee(iterable
)
443 class PeError(Exception):
447 def pe_add_sections(uki
: UKI
, output
: str):
448 pe
= pefile
.PE(uki
.executable
, fast_load
=True)
449 assert len(pe
.__data
__) % pe
.OPTIONAL_HEADER
.FileAlignment
== 0
451 warnings
= pe
.get_warnings()
453 raise PeError(f
'pefile warnings treated as errors: {warnings}')
455 security
= pe
.OPTIONAL_HEADER
.DATA_DIRECTORY
[pefile
.DIRECTORY_ENTRY
['IMAGE_DIRECTORY_ENTRY_SECURITY']]
456 if security
.VirtualAddress
!= 0:
457 # We could strip the signatures, but why would anyone sign the stub?
458 raise PeError(f
'Stub image is signed, refusing.')
460 for section
in uki
.sections
:
461 new_section
= pefile
.SectionStructure(pe
.__IMAGE
_SECTION
_HEADER
_format
__, pe
=pe
)
462 new_section
.__unpack
__(b
'\0' * new_section
.sizeof())
464 offset
= pe
.sections
[-1].get_file_offset() + new_section
.sizeof()
465 if offset
+ new_section
.sizeof() > pe
.OPTIONAL_HEADER
.SizeOfHeaders
:
466 raise PeError(f
'Not enough header space to add section {section.name}.')
468 data
= section
.content
.read_bytes()
470 new_section
.set_file_offset(offset
)
471 new_section
.Name
= section
.name
.encode()
472 new_section
.Misc_VirtualSize
= len(data
)
473 new_section
.PointerToRawData
= len(pe
.__data
__)
474 new_section
.SizeOfRawData
= round_up(len(data
), pe
.OPTIONAL_HEADER
.FileAlignment
)
475 new_section
.VirtualAddress
= round_up(
476 pe
.sections
[-1].VirtualAddress
+ pe
.sections
[-1].Misc_VirtualSize
,
477 pe
.OPTIONAL_HEADER
.SectionAlignment
,
480 new_section
.IMAGE_SCN_MEM_READ
= True
481 if section
.name
== '.linux':
482 # Old kernels that use EFI handover protocol will be executed inline.
483 new_section
.IMAGE_SCN_CNT_CODE
= True
485 new_section
.IMAGE_SCN_CNT_INITIALIZED_DATA
= True
487 assert len(pe
.__data
__) % pe
.OPTIONAL_HEADER
.FileAlignment
== 0
488 pe
.__data
__ = pe
.__data
__[:] + data
+ b
'\0' * (new_section
.SizeOfRawData
- len(data
))
490 pe
.FILE_HEADER
.NumberOfSections
+= 1
491 pe
.OPTIONAL_HEADER
.SizeOfInitializedData
+= new_section
.Misc_VirtualSize
492 pe
.__structures
__.append(new_section
)
493 pe
.sections
.append(new_section
)
495 pe
.OPTIONAL_HEADER
.CheckSum
= 0
496 pe
.OPTIONAL_HEADER
.SizeOfImage
= round_up(
497 pe
.sections
[-1].VirtualAddress
+ pe
.sections
[-1].Misc_VirtualSize
,
498 pe
.OPTIONAL_HEADER
.SectionAlignment
,
505 # kernel payload signing
507 sbsign_tool
= find_tool('sbsign', opts
=opts
)
508 sbsign_invocation
= [
510 '--key', opts
.sb_key
,
511 '--cert', opts
.sb_cert
,
514 if opts
.signing_engine
is not None:
515 sbsign_invocation
+= ['--engine', opts
.signing_engine
]
517 sign_kernel
= opts
.sign_kernel
518 if sign_kernel
is None and opts
.sb_key
:
519 # figure out if we should sign the kernel
520 sbverify_tool
= find_tool('sbverify', opts
=opts
)
528 print('+', shell_join(cmd
))
529 info
= subprocess
.check_output(cmd
, text
=True)
531 # sbverify has wonderful API
532 if 'No signature table present' in info
:
536 linux_signed
= tempfile
.NamedTemporaryFile(prefix
='linux-signed')
537 linux
= linux_signed
.name
545 print('+', shell_join(cmd
))
546 subprocess
.check_call(cmd
)
550 if opts
.uname
is None:
551 print('Kernel version not specified, starting autodetection 😖.')
552 opts
.uname
= Uname
.scrape(opts
.linux
, opts
=opts
)
555 initrd
= join_initrds(opts
.initrd
)
557 # TODO: derive public key from opts.pcr_private_keys?
558 pcrpkey
= opts
.pcrpkey
560 if opts
.pcr_public_keys
and len(opts
.pcr_public_keys
) == 1:
561 pcrpkey
= opts
.pcr_public_keys
[0]
564 # name, content, measure?
565 ('.osrel', opts
.os_release
, True ),
566 ('.cmdline', opts
.cmdline
, True ),
567 ('.dtb', opts
.devicetree
, True ),
568 ('.splash', opts
.splash
, True ),
569 ('.pcrpkey', pcrpkey
, True ),
570 ('.initrd', initrd
, True ),
571 ('.uname', opts
.uname
, False),
573 # linux shall be last to leave breathing room for decompression.
574 # We'll add it later.
577 for name
, content
, measure
in sections
:
579 uki
.add_section(Section
.create(name
, content
, measure
=measure
))
581 # systemd-measure doesn't know about those extra sections
582 for section
in opts
.sections
:
583 uki
.add_section(section
)
585 # PCR measurement and signing
587 call_systemd_measure(uki
, linux
, opts
=opts
)
591 uki
.add_section(Section
.create('.linux', linux
, measure
=True))
594 unsigned
= tempfile
.NamedTemporaryFile(prefix
='uki')
595 output
= unsigned
.name
599 pe_add_sections(uki
, output
)
607 '--output', opts
.output
,
609 print('+', shell_join(cmd
))
610 subprocess
.check_call(cmd
)
612 # We end up with no executable bits, let's reapply them
613 os
.umask(umask
:= os
.umask(0))
614 os
.chmod(opts
.output
, 0o777 & ~umask
)
616 print(f
"Wrote {'signed' if opts.sb_key else 'unsigned'} {opts.output}")
619 def parse_args(args
=None):
620 p
= argparse
.ArgumentParser(
621 description
='Build and sign Unified Kernel Images',
624 usage: ukify [options…] linux initrd…
628 # Suppress printing of usage synopsis on errors
629 p
.error
= lambda message
: p
.exit(2, f
'{p.prog}: error: {message}\n')
631 p
.add_argument('linux',
633 help='vmlinuz file [.linux section]')
634 p
.add_argument('initrd',
637 help='initrd files [.initrd section]')
639 p
.add_argument('--cmdline',
640 metavar
='TEXT|@PATH',
641 help='kernel command line [.cmdline section]')
643 p
.add_argument('--os-release',
644 metavar
='TEXT|@PATH',
645 help='path to os-release file [.osrel section]')
647 p
.add_argument('--devicetree',
650 help='Device Tree file [.dtb section]')
651 p
.add_argument('--splash',
654 help='splash image bitmap file [.splash section]')
655 p
.add_argument('--pcrpkey',
658 help='embedded public key to seal secrets to [.pcrpkey section]')
659 p
.add_argument('--uname',
661 help='"uname -r" information [.uname section]')
663 p
.add_argument('--efi-arch',
665 choices
=('ia32', 'x64', 'arm', 'aa64', 'riscv64'),
666 help='target EFI architecture')
668 p
.add_argument('--stub',
670 help='path to the sd-stub file [.text,.data,… sections]')
672 p
.add_argument('--section',
674 metavar
='NAME:TEXT|@PATH',
675 type=Section
.parse_arg
,
678 help='additional section as name and contents [NAME section]')
680 p
.add_argument('--pcr-private-key',
681 dest
='pcr_private_keys',
685 help='private part of the keypair for signing PCR signatures')
686 p
.add_argument('--pcr-public-key',
687 dest
='pcr_public_keys',
691 help='public part of the keypair for signing PCR signatures')
692 p
.add_argument('--phases',
693 dest
='phase_path_groups',
694 metavar
='PHASE-PATH…',
695 type=parse_phase_paths
,
697 help='phase-paths to create signatures for')
699 p
.add_argument('--pcr-banks',
703 p
.add_argument('--signing-engine',
705 help='OpenSSL engine to use for signing')
706 p
.add_argument('--secureboot-private-key',
708 help='path to key file or engine-specific designation for SB signing')
709 p
.add_argument('--secureboot-certificate',
711 help='path to certificate file or engine-specific designation for SB signing')
713 p
.add_argument('--sign-kernel',
714 action
=argparse
.BooleanOptionalAction
,
715 help='Sign the embedded kernel')
717 p
.add_argument('--tools',
720 help='Directories to search for tools (systemd-measure, ...)')
722 p
.add_argument('--output', '-o',
724 help='output file path')
726 p
.add_argument('--measure',
727 action
=argparse
.BooleanOptionalAction
,
728 help='print systemd-measure output for the UKI')
730 p
.add_argument('--version',
732 version
=f
'ukify {__version__}')
734 opts
= p
.parse_args(args
)
736 path_is_readable(opts
.linux
)
737 for initrd
in opts
.initrd
or ():
738 path_is_readable(initrd
)
739 path_is_readable(opts
.devicetree
)
740 path_is_readable(opts
.pcrpkey
)
741 for key
in opts
.pcr_private_keys
or ():
742 path_is_readable(key
)
743 for key
in opts
.pcr_public_keys
or ():
744 path_is_readable(key
)
746 if opts
.cmdline
and opts
.cmdline
.startswith('@'):
747 opts
.cmdline
= path_is_readable(opts
.cmdline
[1:])
749 if opts
.os_release
is not None and opts
.os_release
.startswith('@'):
750 opts
.os_release
= path_is_readable(opts
.os_release
[1:])
751 elif opts
.os_release
is None:
752 p
= pathlib
.Path('/etc/os-release')
754 p
= path_is_readable('/usr/lib/os-release')
757 if opts
.efi_arch
is None:
758 opts
.efi_arch
= guess_efi_arch()
760 if opts
.stub
is None:
761 opts
.stub
= path_is_readable(f
'/usr/lib/systemd/boot/efi/linux{opts.efi_arch}.efi.stub')
763 if opts
.signing_engine
is None:
764 opts
.sb_key
= path_is_readable(opts
.sb_key
) if opts
.sb_key
else None
765 opts
.sb_cert
= path_is_readable(opts
.sb_cert
) if opts
.sb_cert
else None
767 if bool(opts
.sb_key
) ^
bool(opts
.sb_cert
):
768 raise ValueError('--secureboot-private-key= and --secureboot-certificate= must be specified together')
770 if opts
.sign_kernel
and not opts
.sb_key
:
771 raise ValueError('--sign-kernel requires --secureboot-private-key= and --secureboot-certificate= to be specified')
773 n_pcr_pub
= None if opts
.pcr_public_keys
is None else len(opts
.pcr_public_keys
)
774 n_pcr_priv
= None if opts
.pcr_private_keys
is None else len(opts
.pcr_private_keys
)
775 n_phase_path_groups
= None if opts
.phase_path_groups
is None else len(opts
.phase_path_groups
)
776 if n_pcr_pub
is not None and n_pcr_pub
!= n_pcr_priv
:
777 raise ValueError('--pcr-public-key= specifications must match --pcr-private-key=')
778 if n_phase_path_groups
is not None and n_phase_path_groups
!= n_pcr_priv
:
779 raise ValueError('--phases= specifications must match --pcr-private-key=')
781 if opts
.output
is None:
782 suffix
= '.efi' if opts
.sb_key
else '.unsigned.efi'
783 opts
.output
= opts
.linux
.name
+ suffix
785 for section
in opts
.sections
:
797 if __name__
== '__main__':