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 'loongarch32' : ['loongarch32'],
35 'loongarch64' : ['loongarch64'],
36 'riscv32' : ['riscv32'],
37 'riscv64' : ['riscv64'],
39 EFI_ARCHES
: list[str] = sum(EFI_ARCH_MAP
.values(), [])
42 arch
= os
.uname().machine
44 for glob
, mapping
in EFI_ARCH_MAP
.items():
45 if fnmatch
.fnmatch(arch
, glob
):
46 efi_arch
, *fallback
= mapping
49 raise ValueError(f
'Unsupported architecture {arch}')
51 # This makes sense only on some architectures, but it also probably doesn't
52 # hurt on others, so let's just apply the check everywhere.
54 fw_platform_size
= pathlib
.Path('/sys/firmware/efi/fw_platform_size')
56 size
= fw_platform_size
.read_text().strip()
57 except FileNotFoundError
:
61 efi_arch
= fallback
[0]
63 print(f
'Host arch {arch!r}, EFI arch {efi_arch!r}')
68 # TODO: drop in favour of shlex.join once shlex.join supports pathlib.Path.
69 return ' '.join(shlex
.quote(str(x
)) for x
in cmd
)
72 def path_is_readable(s
: typing
.Optional
[str]) -> typing
.Optional
[pathlib
.Path
]:
73 """Convert a filename string to a Path and verify access."""
79 except IsADirectoryError
:
84 def round_up(x
, blocksize
=4096):
85 return (x
+ blocksize
- 1) // blocksize
* blocksize
88 def try_import(modname
, name
=None):
90 return __import__(modname
)
91 except ImportError as e
:
92 raise ValueError(f
'Kernel is compressed with {name or modname}, but module unavailable') from e
95 def maybe_decompress(filename
):
96 """Decompress file if compressed. Return contents."""
97 f
= open(filename
, 'rb')
101 if start
.startswith(b
'\x7fELF'):
105 if start
.startswith(b
'MZ'):
106 # not compressed aarch64 and riscv64
109 if start
.startswith(b
'\x1f\x8b'):
110 gzip
= try_import('gzip')
111 return gzip
.open(f
).read()
113 if start
.startswith(b
'\x28\xb5\x2f\xfd'):
114 zstd
= try_import('zstd')
115 return zstd
.uncompress(f
.read())
117 if start
.startswith(b
'\x02\x21\x4c\x18'):
118 lz4
= try_import('lz4.frame', 'lz4')
119 return lz4
.frame
.decompress(f
.read())
121 if start
.startswith(b
'\x04\x22\x4d\x18'):
122 print('Newer lz4 stream format detected! This may not boot!')
123 lz4
= try_import('lz4.frame', 'lz4')
124 return lz4
.frame
.decompress(f
.read())
126 if start
.startswith(b
'\x89LZO'):
127 # python3-lzo is not packaged for Fedora
128 raise NotImplementedError('lzo decompression not implemented')
130 if start
.startswith(b
'BZh'):
131 bz2
= try_import('bz2', 'bzip2')
132 return bz2
.open(f
).read()
134 if start
.startswith(b
'\x5d\x00\x00'):
135 lzma
= try_import('lzma')
136 return lzma
.open(f
).read()
138 raise NotImplementedError(f
'unknown file format (starts with {start})')
142 # This class is here purely as a namespace for the functions
144 VERSION_PATTERN
= r
'(?P<version>[a-z0-9._-]+) \([^ )]+\) (?:#.*)'
146 NOTES_PATTERN
= r
'^\s+Linux\s+0x[0-9a-f]+\s+OPEN\n\s+description data: (?P<version>[0-9a-f ]+)\s*$'
148 # Linux version 6.0.8-300.fc37.ppc64le (mockbuild@buildvm-ppc64le-03.iad2.fedoraproject.org)
149 # (gcc (GCC) 12.2.1 20220819 (Red Hat 12.2.1-2), GNU ld version 2.38-24.fc37)
150 # #1 SMP Fri Nov 11 14:39:11 UTC 2022
151 TEXT_PATTERN
= rb
'Linux version (?P<version>\d\.\S+) \('
154 def scrape_x86(cls
, filename
, opts
=None):
155 # Based on https://gitlab.archlinux.org/archlinux/mkinitcpio/mkinitcpio/-/blob/master/functions#L136
156 # and https://www.kernel.org/doc/html/latest/x86/boot.html#the-real-mode-kernel-header
157 with
open(filename
, 'rb') as f
:
161 raise ValueError('Real-Mode Kernel Header magic not found')
163 offset
= f
.read(1)[0] + f
.read(1)[0]*256 # Pointer to kernel version string
164 f
.seek(0x200 + offset
)
166 text
= text
.split(b
'\0', maxsplit
=1)[0]
169 if not (m
:= re
.match(cls
.VERSION_PATTERN
, text
)):
170 raise ValueError(f
'Cannot parse version-host-release uname string: {text!r}')
171 return m
.group('version')
174 def scrape_elf(cls
, filename
, opts
=None):
175 readelf
= find_tool('readelf', opts
=opts
)
183 print('+', shell_join(cmd
))
185 notes
= subprocess
.check_output(cmd
, stderr
=subprocess
.PIPE
, text
=True)
186 except subprocess
.CalledProcessError
as e
:
187 raise ValueError(e
.stderr
.strip()) from e
189 if not (m
:= re
.search(cls
.NOTES_PATTERN
, notes
, re
.MULTILINE
)):
190 raise ValueError('Cannot find Linux version note')
192 text
= ''.join(chr(int(c
, 16)) for c
in m
.group('version').split())
193 return text
.rstrip('\0')
196 def scrape_generic(cls
, filename
, opts
=None):
198 # libarchive-c fails with
199 # ArchiveError: Unrecognized archive format (errno=84, retcode=-30, archive_p=94705420454656)
201 # Based on https://gitlab.archlinux.org/archlinux/mkinitcpio/mkinitcpio/-/blob/master/functions#L209
203 text
= maybe_decompress(filename
)
204 if not (m
:= re
.search(cls
.TEXT_PATTERN
, text
)):
205 raise ValueError(f
'Cannot find {cls.TEXT_PATTERN!r} in {filename}')
207 return m
.group('version').decode()
210 def scrape(cls
, filename
, opts
=None):
211 for func
in (cls
.scrape_x86
, cls
.scrape_elf
, cls
.scrape_generic
):
213 version
= func(filename
, opts
=opts
)
214 print(f
'Found uname version: {version}')
216 except ValueError as e
:
221 @dataclasses.dataclass
224 content
: pathlib
.Path
225 tmpfile
: typing
.Optional
[typing
.IO
] = None
226 measure
: bool = False
229 def create(cls
, name
, contents
, **kwargs
):
230 if isinstance(contents
, (str, bytes
)):
231 mode
= 'wt' if isinstance(contents
, str) else 'wb'
232 tmp
= tempfile
.NamedTemporaryFile(mode
=mode
, prefix
=f
'tmp{name}')
235 contents
= pathlib
.Path(tmp
.name
)
239 return cls(name
, contents
, tmpfile
=tmp
, **kwargs
)
242 def parse_arg(cls
, s
):
244 name
, contents
, *rest
= s
.split(':')
245 except ValueError as e
:
246 raise ValueError(f
'Cannot parse section spec (name or contents missing): {s!r}') from e
248 raise ValueError(f
'Cannot parse section spec (extraneous parameters): {s!r}')
250 if contents
.startswith('@'):
251 contents
= pathlib
.Path(contents
[1:])
253 return cls
.create(name
, contents
)
256 return self
.content
.stat().st_size
258 def check_name(self
):
259 # PE section names with more than 8 characters are legal, but our stub does
261 if not self
.name
.isascii() or not self
.name
.isprintable():
262 raise ValueError(f
'Bad section name: {self.name!r}')
263 if len(self
.name
) > 8:
264 raise ValueError(f
'Section name too long: {self.name!r}')
267 @dataclasses.dataclass
269 executable
: list[typing
.Union
[pathlib
.Path
, str]]
270 sections
: list[Section
] = dataclasses
.field(default_factory
=list, init
=False)
272 def add_section(self
, section
):
273 if section
.name
in [s
.name
for s
in self
.sections
]:
274 raise ValueError(f
'Duplicate section {section.name}')
276 self
.sections
+= [section
]
280 banks
= re
.split(r
',|\s+', s
)
281 # TODO: do some sanity checking here
294 def parse_phase_paths(s
):
295 # Split on commas or whitespace here. Commas might be hard to parse visually.
296 paths
= re
.split(r
',|\s+', s
)
299 for phase
in path
.split(':'):
300 if phase
not in KNOWN_PHASES
:
301 raise argparse
.ArgumentTypeError(f
'Unknown boot phase {phase!r} ({path=})')
306 def check_splash(filename
):
310 # import is delayed, to avoid import when the splash image is not used
312 from PIL
import Image
316 img
= Image
.open(filename
, formats
=['BMP'])
317 print(f
'Splash image {filename} is {img.width}×{img.height} pixels')
320 def check_inputs(opts
):
321 for name
, value
in vars(opts
).items():
322 if name
in {'output', 'tools'}:
325 if not isinstance(value
, pathlib
.Path
):
328 # Open file to check that we can read it, or generate an exception
331 check_splash(opts
.splash
)
334 def find_tool(name
, fallback
=None, opts
=None):
335 if opts
and opts
.tools
:
341 if shutil
.which(name
) is not None:
347 def combine_signatures(pcrsigs
):
348 combined
= collections
.defaultdict(list)
349 for pcrsig
in pcrsigs
:
350 for bank
, sigs
in pcrsig
.items():
352 if sig
not in combined
[bank
]:
353 combined
[bank
] += [sig
]
354 return json
.dumps(combined
)
357 def call_systemd_measure(uki
, linux
, opts
):
358 measure_tool
= find_tool('systemd-measure',
359 '/usr/lib/systemd/systemd-measure',
362 banks
= opts
.pcr_banks
or ()
367 pp_groups
= opts
.phase_path_groups
or []
373 *(f
"--{s.name.removeprefix('.')}={s.content}"
374 for s
in uki
.sections
378 # For measurement, the keys are not relevant, so we can lump all the phase paths
379 # into one call to systemd-measure calculate.
380 *(f
'--phase={phase_path}'
381 for phase_path
in itertools
.chain
.from_iterable(pp_groups
)),
384 print('+', shell_join(cmd
))
385 subprocess
.check_call(cmd
)
389 if opts
.pcr_private_keys
:
390 n_priv
= len(opts
.pcr_private_keys
or ())
391 pp_groups
= opts
.phase_path_groups
or [None] * n_priv
392 pub_keys
= opts
.pcr_public_keys
or [None] * n_priv
400 *(f
"--{s.name.removeprefix('.')}={s.content}"
401 for s
in uki
.sections
407 for priv_key
, pub_key
, group
in zip(opts
.pcr_private_keys
,
410 extra
= [f
'--private-key={priv_key}']
412 extra
+= [f
'--public-key={pub_key}']
413 extra
+= [f
'--phase={phase_path}' for phase_path
in group
or ()]
415 print('+', shell_join(cmd
+ extra
))
416 pcrsig
= subprocess
.check_output(cmd
+ extra
, text
=True)
417 pcrsig
= json
.loads(pcrsig
)
420 combined
= combine_signatures(pcrsigs
)
421 uki
.add_section(Section
.create('.pcrsig', combined
))
424 def join_initrds(initrds
):
425 if len(initrds
) == 0:
427 elif len(initrds
) == 1:
432 initrd
= file.read_bytes()
434 padding
= b
'\0' * (round_up(n
, 4) - n
) # pad to 32 bit alignment
435 seq
+= [initrd
, padding
]
440 def pairwise(iterable
):
441 a
, b
= itertools
.tee(iterable
)
446 class PeError(Exception):
450 def pe_add_sections(uki
: UKI
, output
: str):
451 pe
= pefile
.PE(uki
.executable
, fast_load
=True)
452 assert len(pe
.__data
__) % pe
.OPTIONAL_HEADER
.FileAlignment
== 0
454 warnings
= pe
.get_warnings()
456 raise PeError(f
'pefile warnings treated as errors: {warnings}')
458 security
= pe
.OPTIONAL_HEADER
.DATA_DIRECTORY
[pefile
.DIRECTORY_ENTRY
['IMAGE_DIRECTORY_ENTRY_SECURITY']]
459 if security
.VirtualAddress
!= 0:
460 # We could strip the signatures, but why would anyone sign the stub?
461 raise PeError(f
'Stub image is signed, refusing.')
463 for section
in uki
.sections
:
464 new_section
= pefile
.SectionStructure(pe
.__IMAGE
_SECTION
_HEADER
_format
__, pe
=pe
)
465 new_section
.__unpack
__(b
'\0' * new_section
.sizeof())
467 offset
= pe
.sections
[-1].get_file_offset() + new_section
.sizeof()
468 if offset
+ new_section
.sizeof() > pe
.OPTIONAL_HEADER
.SizeOfHeaders
:
469 raise PeError(f
'Not enough header space to add section {section.name}.')
471 data
= section
.content
.read_bytes()
473 new_section
.set_file_offset(offset
)
474 new_section
.Name
= section
.name
.encode()
475 new_section
.Misc_VirtualSize
= len(data
)
476 new_section
.PointerToRawData
= len(pe
.__data
__)
477 new_section
.SizeOfRawData
= round_up(len(data
), pe
.OPTIONAL_HEADER
.FileAlignment
)
478 new_section
.VirtualAddress
= round_up(
479 pe
.sections
[-1].VirtualAddress
+ pe
.sections
[-1].Misc_VirtualSize
,
480 pe
.OPTIONAL_HEADER
.SectionAlignment
,
483 new_section
.IMAGE_SCN_MEM_READ
= True
484 if section
.name
== '.linux':
485 # Old kernels that use EFI handover protocol will be executed inline.
486 new_section
.IMAGE_SCN_CNT_CODE
= True
488 new_section
.IMAGE_SCN_CNT_INITIALIZED_DATA
= True
490 assert len(pe
.__data
__) % pe
.OPTIONAL_HEADER
.FileAlignment
== 0
491 pe
.__data
__ = pe
.__data
__[:] + data
+ b
'\0' * (new_section
.SizeOfRawData
- len(data
))
493 pe
.FILE_HEADER
.NumberOfSections
+= 1
494 pe
.OPTIONAL_HEADER
.SizeOfInitializedData
+= new_section
.Misc_VirtualSize
495 pe
.__structures
__.append(new_section
)
496 pe
.sections
.append(new_section
)
498 pe
.OPTIONAL_HEADER
.CheckSum
= 0
499 pe
.OPTIONAL_HEADER
.SizeOfImage
= round_up(
500 pe
.sections
[-1].VirtualAddress
+ pe
.sections
[-1].Misc_VirtualSize
,
501 pe
.OPTIONAL_HEADER
.SectionAlignment
,
508 # kernel payload signing
510 sbsign_tool
= find_tool('sbsign', opts
=opts
)
511 sbsign_invocation
= [
513 '--key', opts
.sb_key
,
514 '--cert', opts
.sb_cert
,
517 if opts
.signing_engine
is not None:
518 sbsign_invocation
+= ['--engine', opts
.signing_engine
]
520 sign_kernel
= opts
.sign_kernel
521 if sign_kernel
is None and opts
.sb_key
:
522 # figure out if we should sign the kernel
523 sbverify_tool
= find_tool('sbverify', opts
=opts
)
531 print('+', shell_join(cmd
))
532 info
= subprocess
.check_output(cmd
, text
=True)
534 # sbverify has wonderful API
535 if 'No signature table present' in info
:
539 linux_signed
= tempfile
.NamedTemporaryFile(prefix
='linux-signed')
540 linux
= linux_signed
.name
548 print('+', shell_join(cmd
))
549 subprocess
.check_call(cmd
)
553 if opts
.uname
is None:
554 print('Kernel version not specified, starting autodetection 😖.')
555 opts
.uname
= Uname
.scrape(opts
.linux
, opts
=opts
)
558 initrd
= join_initrds(opts
.initrd
)
560 # TODO: derive public key from opts.pcr_private_keys?
561 pcrpkey
= opts
.pcrpkey
563 if opts
.pcr_public_keys
and len(opts
.pcr_public_keys
) == 1:
564 pcrpkey
= opts
.pcr_public_keys
[0]
567 # name, content, measure?
568 ('.osrel', opts
.os_release
, True ),
569 ('.cmdline', opts
.cmdline
, True ),
570 ('.dtb', opts
.devicetree
, True ),
571 ('.splash', opts
.splash
, True ),
572 ('.pcrpkey', pcrpkey
, True ),
573 ('.initrd', initrd
, True ),
574 ('.uname', opts
.uname
, False),
576 # linux shall be last to leave breathing room for decompression.
577 # We'll add it later.
580 for name
, content
, measure
in sections
:
582 uki
.add_section(Section
.create(name
, content
, measure
=measure
))
584 # systemd-measure doesn't know about those extra sections
585 for section
in opts
.sections
:
586 uki
.add_section(section
)
588 # PCR measurement and signing
590 call_systemd_measure(uki
, linux
, opts
=opts
)
594 uki
.add_section(Section
.create('.linux', linux
, measure
=True))
597 unsigned
= tempfile
.NamedTemporaryFile(prefix
='uki')
598 output
= unsigned
.name
602 pe_add_sections(uki
, output
)
610 '--output', opts
.output
,
612 print('+', shell_join(cmd
))
613 subprocess
.check_call(cmd
)
615 # We end up with no executable bits, let's reapply them
616 os
.umask(umask
:= os
.umask(0))
617 os
.chmod(opts
.output
, 0o777 & ~umask
)
619 print(f
"Wrote {'signed' if opts.sb_key else 'unsigned'} {opts.output}")
622 def parse_args(args
=None):
623 p
= argparse
.ArgumentParser(
624 description
='Build and sign Unified Kernel Images',
627 usage: ukify [options…] linux initrd…
631 # Suppress printing of usage synopsis on errors
632 p
.error
= lambda message
: p
.exit(2, f
'{p.prog}: error: {message}\n')
634 p
.add_argument('linux',
636 help='vmlinuz file [.linux section]')
637 p
.add_argument('initrd',
640 help='initrd files [.initrd section]')
642 p
.add_argument('--cmdline',
643 metavar
='TEXT|@PATH',
644 help='kernel command line [.cmdline section]')
646 p
.add_argument('--os-release',
647 metavar
='TEXT|@PATH',
648 help='path to os-release file [.osrel section]')
650 p
.add_argument('--devicetree',
653 help='Device Tree file [.dtb section]')
654 p
.add_argument('--splash',
657 help='splash image bitmap file [.splash section]')
658 p
.add_argument('--pcrpkey',
661 help='embedded public key to seal secrets to [.pcrpkey section]')
662 p
.add_argument('--uname',
664 help='"uname -r" information [.uname section]')
666 p
.add_argument('--efi-arch',
668 choices
=('ia32', 'x64', 'arm', 'aa64', 'riscv64'),
669 help='target EFI architecture')
671 p
.add_argument('--stub',
673 help='path to the sd-stub file [.text,.data,… sections]')
675 p
.add_argument('--section',
677 metavar
='NAME:TEXT|@PATH',
678 type=Section
.parse_arg
,
681 help='additional section as name and contents [NAME section]')
683 p
.add_argument('--pcr-private-key',
684 dest
='pcr_private_keys',
688 help='private part of the keypair for signing PCR signatures')
689 p
.add_argument('--pcr-public-key',
690 dest
='pcr_public_keys',
694 help='public part of the keypair for signing PCR signatures')
695 p
.add_argument('--phases',
696 dest
='phase_path_groups',
697 metavar
='PHASE-PATH…',
698 type=parse_phase_paths
,
700 help='phase-paths to create signatures for')
702 p
.add_argument('--pcr-banks',
706 p
.add_argument('--signing-engine',
708 help='OpenSSL engine to use for signing')
709 p
.add_argument('--secureboot-private-key',
711 help='path to key file or engine-specific designation for SB signing')
712 p
.add_argument('--secureboot-certificate',
714 help='path to certificate file or engine-specific designation for SB signing')
716 p
.add_argument('--sign-kernel',
717 action
=argparse
.BooleanOptionalAction
,
718 help='Sign the embedded kernel')
720 p
.add_argument('--tools',
723 help='Directories to search for tools (systemd-measure, ...)')
725 p
.add_argument('--output', '-o',
727 help='output file path')
729 p
.add_argument('--measure',
730 action
=argparse
.BooleanOptionalAction
,
731 help='print systemd-measure output for the UKI')
733 p
.add_argument('--version',
735 version
=f
'ukify {__version__}')
737 opts
= p
.parse_args(args
)
739 path_is_readable(opts
.linux
)
740 for initrd
in opts
.initrd
or ():
741 path_is_readable(initrd
)
742 path_is_readable(opts
.devicetree
)
743 path_is_readable(opts
.pcrpkey
)
744 for key
in opts
.pcr_private_keys
or ():
745 path_is_readable(key
)
746 for key
in opts
.pcr_public_keys
or ():
747 path_is_readable(key
)
749 if opts
.cmdline
and opts
.cmdline
.startswith('@'):
750 opts
.cmdline
= path_is_readable(opts
.cmdline
[1:])
752 if opts
.os_release
is not None and opts
.os_release
.startswith('@'):
753 opts
.os_release
= path_is_readable(opts
.os_release
[1:])
754 elif opts
.os_release
is None:
755 p
= pathlib
.Path('/etc/os-release')
757 p
= path_is_readable('/usr/lib/os-release')
760 if opts
.efi_arch
is None:
761 opts
.efi_arch
= guess_efi_arch()
763 if opts
.stub
is None:
764 opts
.stub
= path_is_readable(f
'/usr/lib/systemd/boot/efi/linux{opts.efi_arch}.efi.stub')
766 if opts
.signing_engine
is None:
767 opts
.sb_key
= path_is_readable(opts
.sb_key
) if opts
.sb_key
else None
768 opts
.sb_cert
= path_is_readable(opts
.sb_cert
) if opts
.sb_cert
else None
770 if bool(opts
.sb_key
) ^
bool(opts
.sb_cert
):
771 raise ValueError('--secureboot-private-key= and --secureboot-certificate= must be specified together')
773 if opts
.sign_kernel
and not opts
.sb_key
:
774 raise ValueError('--sign-kernel requires --secureboot-private-key= and --secureboot-certificate= to be specified')
776 n_pcr_pub
= None if opts
.pcr_public_keys
is None else len(opts
.pcr_public_keys
)
777 n_pcr_priv
= None if opts
.pcr_private_keys
is None else len(opts
.pcr_private_keys
)
778 n_phase_path_groups
= None if opts
.phase_path_groups
is None else len(opts
.phase_path_groups
)
779 if n_pcr_pub
is not None and n_pcr_pub
!= n_pcr_priv
:
780 raise ValueError('--pcr-public-key= specifications must match --pcr-private-key=')
781 if n_phase_path_groups
is not None and n_phase_path_groups
!= n_pcr_priv
:
782 raise ValueError('--phases= specifications must match --pcr-private-key=')
784 if opts
.output
is None:
785 suffix
= '.efi' if opts
.sb_key
else '.unsigned.efi'
786 opts
.output
= opts
.linux
.name
+ suffix
788 for section
in opts
.sections
:
800 if __name__
== '__main__':