summaryrefslogtreecommitdiff
path: root/src/ukify
diff options
context:
space:
mode:
Diffstat (limited to 'src/ukify')
-rwxr-xr-xsrc/ukify/ukify.py570
1 files changed, 417 insertions, 153 deletions
diff --git a/src/ukify/ukify.py b/src/ukify/ukify.py
index 599f872bc1..033acba3d7 100755
--- a/src/ukify/ukify.py
+++ b/src/ukify/ukify.py
@@ -22,6 +22,7 @@
# pylint: disable=too-many-branches,fixme
import argparse
+import configparser
import collections
import dataclasses
import fnmatch
@@ -29,10 +30,12 @@ import itertools
import json
import os
import pathlib
+import pprint
import re
import shlex
import shutil
import subprocess
+import sys
import tempfile
import typing
@@ -84,18 +87,6 @@ def shell_join(cmd):
return ' '.join(shlex.quote(str(x)) for x in cmd)
-def path_is_readable(s: typing.Optional[str]) -> typing.Optional[pathlib.Path]:
- """Convert a filename string to a Path and verify access."""
- if s is None:
- return None
- p = pathlib.Path(s)
- try:
- p.open().close()
- except IsADirectoryError:
- pass
- return p
-
-
def round_up(x, blocksize=4096):
return (x + blocksize - 1) // blocksize * blocksize
@@ -337,11 +328,13 @@ def check_inputs(opts):
if name in {'output', 'tools'}:
continue
- if not isinstance(value, pathlib.Path):
- continue
-
- # Open file to check that we can read it, or generate an exception
- value.open().close()
+ if isinstance(value, pathlib.Path):
+ # Open file to check that we can read it, or generate an exception
+ value.open().close()
+ elif isinstance(value, list):
+ for item in value:
+ if isinstance(item, pathlib.Path):
+ item.open().close()
check_splash(opts.splash)
@@ -668,157 +661,412 @@ def make_uki(opts):
print(f"Wrote {'signed' if opts.sb_key else 'unsigned'} {opts.output}")
-def parse_args(args=None):
+@dataclasses.dataclass(frozen=True)
+class ConfigItem:
+ @staticmethod
+ def config_list_prepend(namespace, group, dest, value):
+ "Prepend value to namespace.<dest>"
+
+ assert not group
+
+ old = getattr(namespace, dest, [])
+ setattr(namespace, dest, value + old)
+
+ @staticmethod
+ def config_set_if_unset(namespace, group, dest, value):
+ "Set namespace.<dest> to value only if it was None"
+
+ assert not group
+
+ if getattr(namespace, dest) is None:
+ setattr(namespace, dest, value)
+
+ @staticmethod
+ def config_set_group(namespace, group, dest, value):
+ "Set namespace.<dest>[idx] to value, with idx derived from group"
+
+ if group not in namespace._groups:
+ namespace._groups += [group]
+ idx = namespace._groups.index(group)
+
+ old = getattr(namespace, dest, None)
+ if old is None:
+ old = []
+ setattr(namespace, dest,
+ old + ([None] * (idx - len(old))) + [value])
+
+ @staticmethod
+ def parse_boolean(s: str) -> bool:
+ "Parse 1/true/yes/y/t/on as true and 0/false/no/n/f/off/None as false"
+ s_l = s.lower()
+ if s_l in {'1', 'true', 'yes', 'y', 't', 'on'}:
+ return True
+ if s_l in {'0', 'false', 'no', 'n', 'f', 'off'}:
+ return False
+ raise ValueError('f"Invalid boolean literal: {s!r}')
+
+ # arguments for argparse.ArgumentParser.add_argument()
+ name: typing.Union[str, typing.List[str]]
+ dest: str = None
+ metavar: str = None
+ type: typing.Callable = None
+ nargs: str = None
+ action: typing.Callable = None
+ default: typing.Any = None
+ version: str = None
+ choices: typing.List[str] = None
+ help: str = None
+
+ # metadata for config file parsing
+ config_key: str = None
+ config_push: typing.Callable[..., ...] = config_set_if_unset
+
+ def _names(self) -> typing.Tuple[str]:
+ return self.name if isinstance(self.name, tuple) else (self.name,)
+
+ def argparse_dest(self) -> str:
+ # It'd be nice if argparse exported this, but I don't see that in the API
+ if self.dest:
+ return self.dest
+ return self._names()[0].lstrip('-').replace('-', '_')
+
+ def add_to(self, parser: argparse.ArgumentParser):
+ kwargs = { key:val
+ for key in dataclasses.asdict(self)
+ if (key not in ('name', 'config_key', 'config_push') and
+ (val := getattr(self, key)) is not None) }
+ args = self._names()
+ parser.add_argument(*args, **kwargs)
+
+ def apply_config(self, namespace, section, group, key, value) -> None:
+ assert f'{section}/{key}' == self.config_key
+ dest = self.argparse_dest()
+
+ if self.action == argparse.BooleanOptionalAction:
+ # We need to handle this case separately: the options are called
+ # --foo and --no-foo, and no argument is parsed. But in the config
+ # file, we have Foo=yes or Foo=no.
+ conv = self.parse_boolean
+ elif self.type:
+ conv = self.type
+ else:
+ conv = lambda s:s
+
+ if self.nargs == '*':
+ value = [conv(v) for v in value.split()]
+ else:
+ value = conv(value)
+
+ self.config_push(namespace, group, dest, value)
+
+ def config_example(self) -> typing.Tuple[typing.Optional[str]]:
+ if not self.config_key:
+ return None, None, None
+ section_name, key = self.config_key.split('/', 1)
+ if section_name.endswith(':'):
+ section_name += 'NAME'
+ if self.choices:
+ value = '|'.join(self.choices)
+ else:
+ value = self.metavar or self.argparse_dest().upper()
+ return (section_name, key, value)
+
+
+CONFIG_ITEMS = [
+ ConfigItem(
+ '--version',
+ action = 'version',
+ version = f'ukify {__version__}',
+ ),
+
+ ConfigItem(
+ '--summary',
+ help = 'print parsed config and exit',
+ action = 'store_true',
+ ),
+
+ ConfigItem(
+ 'linux',
+ metavar = 'LINUX',
+ type = pathlib.Path,
+ nargs = '?',
+ help = 'vmlinuz file [.linux section]',
+ config_key = 'UKI/Linux',
+ ),
+
+ ConfigItem(
+ 'initrd',
+ metavar = 'INITRD…',
+ type = pathlib.Path,
+ nargs = '*',
+ help = 'initrd files [.initrd section]',
+ config_key = 'UKI/Initrd',
+ config_push = ConfigItem.config_list_prepend,
+ ),
+
+ ConfigItem(
+ ('--config', '-c'),
+ metavar = 'PATH',
+ help = 'configuration file',
+ ),
+
+ ConfigItem(
+ '--cmdline',
+ metavar = 'TEXT|@PATH',
+ help = 'kernel command line [.cmdline section]',
+ config_key = 'UKI/Cmdline',
+ ),
+
+ ConfigItem(
+ '--os-release',
+ metavar = 'TEXT|@PATH',
+ help = 'path to os-release file [.osrel section]',
+ config_key = 'UKI/OSRelease',
+ ),
+
+ ConfigItem(
+ '--devicetree',
+ metavar = 'PATH',
+ type = pathlib.Path,
+ help = 'Device Tree file [.dtb section]',
+ config_key = 'UKI/DeviceTree',
+ ),
+ ConfigItem(
+ '--splash',
+ metavar = 'BMP',
+ type = pathlib.Path,
+ help = 'splash image bitmap file [.splash section]',
+ config_key = 'UKI/Splash',
+ ),
+ ConfigItem(
+ '--pcrpkey',
+ metavar = 'KEY',
+ type = pathlib.Path,
+ help = 'embedded public key to seal secrets to [.pcrpkey section]',
+ config_key = 'UKI/PCRPKey',
+ ),
+ ConfigItem(
+ '--uname',
+ metavar='VERSION',
+ help='"uname -r" information [.uname section]',
+ config_key = 'UKI/Uname',
+ ),
+
+ ConfigItem(
+ '--efi-arch',
+ metavar = 'ARCH',
+ choices = ('ia32', 'x64', 'arm', 'aa64', 'riscv64'),
+ help = 'target EFI architecture',
+ config_key = 'UKI/EFIArch',
+ ),
+
+ ConfigItem(
+ '--stub',
+ type = pathlib.Path,
+ help = 'path to the sd-stub file [.text,.data,… sections]',
+ config_key = 'UKI/Stub',
+ ),
+
+ ConfigItem(
+ '--section',
+ dest = 'sections',
+ metavar = 'NAME:TEXT|@PATH',
+ type = Section.parse_arg,
+ action = 'append',
+ default = [],
+ help = 'additional section as name and contents [NAME section]',
+ ),
+
+ ConfigItem(
+ '--pcr-banks',
+ metavar = 'BANK…',
+ type = parse_banks,
+ config_key = 'UKI/PCRBanks',
+ ),
+
+ ConfigItem(
+ '--signing-engine',
+ metavar = 'ENGINE',
+ help = 'OpenSSL engine to use for signing',
+ config_key = 'UKI/SigningEngine',
+ ),
+ ConfigItem(
+ '--secureboot-private-key',
+ dest = 'sb_key',
+ help = 'path to key file or engine-specific designation for SB signing',
+ config_key = 'UKI/SecureBootPrivateKey',
+ ),
+ ConfigItem(
+ '--secureboot-certificate',
+ dest = 'sb_cert',
+ help = 'path to certificate file or engine-specific designation for SB signing',
+ config_key = 'UKI/SecureBootCertificate',
+ ),
+
+ ConfigItem(
+ '--sign-kernel',
+ action = argparse.BooleanOptionalAction,
+ help = 'Sign the embedded kernel',
+ config_key = 'UKI/SignKernel',
+ ),
+
+ ConfigItem(
+ '--pcr-private-key',
+ dest = 'pcr_private_keys',
+ metavar = 'PATH',
+ type = pathlib.Path,
+ action = 'append',
+ help = 'private part of the keypair for signing PCR signatures',
+ config_key = 'PCRSignature:/PCRPrivateKey',
+ config_push = ConfigItem.config_set_group,
+ ),
+ ConfigItem(
+ '--pcr-public-key',
+ dest = 'pcr_public_keys',
+ metavar = 'PATH',
+ type = pathlib.Path,
+ action = 'append',
+ help = 'public part of the keypair for signing PCR signatures',
+ config_key = 'PCRSignature:/PCRPublicKey',
+ config_push = ConfigItem.config_set_group,
+ ),
+ ConfigItem(
+ '--phases',
+ dest = 'phase_path_groups',
+ metavar = 'PHASE-PATH…',
+ type = parse_phase_paths,
+ action = 'append',
+ help = 'phase-paths to create signatures for',
+ config_key = 'PCRSignature:/Phases',
+ config_push = ConfigItem.config_set_group,
+ ),
+
+ ConfigItem(
+ '--tools',
+ type = pathlib.Path,
+ action = 'append',
+ help = 'Directories to search for tools (systemd-measure, …)',
+ ),
+
+ ConfigItem(
+ ('--output', '-o'),
+ type = pathlib.Path,
+ help = 'output file path',
+ ),
+
+ ConfigItem(
+ '--measure',
+ action = argparse.BooleanOptionalAction,
+ help = 'print systemd-measure output for the UKI',
+ ),
+]
+
+CONFIGFILE_ITEMS = { item.config_key:item
+ for item in CONFIG_ITEMS
+ if item.config_key }
+
+
+def apply_config(namespace, filename=None):
+ if filename is None:
+ filename = namespace.config
+ if filename is None:
+ return
+
+ # Fill in ._groups based on --pcr-public-key=, --pcr-private-key=, and --phases=.
+ assert '_groups' not in namespace
+ n_pcr_priv = len(namespace.pcr_private_keys or ())
+ namespace._groups = list(range(n_pcr_priv))
+
+ cp = configparser.ConfigParser(
+ comment_prefixes='#',
+ inline_comment_prefixes='#',
+ delimiters='=',
+ empty_lines_in_values=False,
+ interpolation=None,
+ strict=False)
+ # Do not make keys lowercase
+ cp.optionxform = lambda option: option
+
+ cp.read(filename)
+
+ for section_name, section in cp.items():
+ idx = section_name.find(':')
+ if idx >= 0:
+ section_name, group = section_name[:idx+1], section_name[idx+1:]
+ if not section_name or not group:
+ raise ValueError('Section name components cannot be empty')
+ if ':' in group:
+ raise ValueError('Section name cannot contain more than one ":"')
+ else:
+ group = None
+ for key, value in section.items():
+ if item := CONFIGFILE_ITEMS.get(f'{section_name}/{key}'):
+ item.apply_config(namespace, section_name, group, key, value)
+ else:
+ print(f'Unknown config setting [{section_name}] {key}=')
+
+
+def config_example():
+ prev_section = None
+ for item in CONFIG_ITEMS:
+ section, key, value = item.config_example()
+ if section:
+ if prev_section != section:
+ if prev_section:
+ yield ''
+ yield f'[{section}]'
+ prev_section = section
+ yield f'{key} = {value}'
+
+
+def create_parser():
p = argparse.ArgumentParser(
description='Build and sign Unified Kernel Images',
allow_abbrev=False,
usage='''\
ukify [options…] [LINUX INITRD…]
- ukify -h | --help
-''')
+''',
+ epilog='\n '.join(('config file:', *config_example())),
+ formatter_class=argparse.RawDescriptionHelpFormatter,
+ )
+
+ for item in CONFIG_ITEMS:
+ item.add_to(p)
# Suppress printing of usage synopsis on errors
p.error = lambda message: p.exit(2, f'{p.prog}: error: {message}\n')
- p.add_argument('linux',
- metavar='LINUX',
- type=pathlib.Path,
- nargs="?",
- help='vmlinuz file [.linux section]')
- p.add_argument('initrd',
- metavar='INITRD…',
- type=pathlib.Path,
- nargs='*',
- help='initrd files [.initrd section]')
-
- p.add_argument('--cmdline',
- metavar='TEXT|@PATH',
- help='kernel command line [.cmdline section]')
-
- p.add_argument('--os-release',
- metavar='TEXT|@PATH',
- help='path to os-release file [.osrel section]')
-
- p.add_argument('--devicetree',
- metavar='PATH',
- type=pathlib.Path,
- help='Device Tree file [.dtb section]')
- p.add_argument('--splash',
- metavar='BMP',
- type=pathlib.Path,
- help='splash image bitmap file [.splash section]')
- p.add_argument('--pcrpkey',
- metavar='KEY',
- type=pathlib.Path,
- help='embedded public key to seal secrets to [.pcrpkey section]')
- p.add_argument('--uname',
- metavar='VERSION',
- help='"uname -r" information [.uname section]')
-
- p.add_argument('--efi-arch',
- metavar='ARCH',
- choices=('ia32', 'x64', 'arm', 'aa64', 'riscv64'),
- help='target EFI architecture')
-
- p.add_argument('--stub',
- type=pathlib.Path,
- help='path to the sd-stub file [.text,.data,… sections]')
-
- p.add_argument('--section',
- dest='sections',
- metavar='NAME:TEXT|@PATH',
- type=Section.parse_arg,
- action='append',
- default=[],
- help='additional section as name and contents [NAME section]')
-
- p.add_argument('--pcr-private-key',
- dest='pcr_private_keys',
- metavar='PATH',
- type=pathlib.Path,
- action='append',
- help='private part of the keypair for signing PCR signatures')
- p.add_argument('--pcr-public-key',
- dest='pcr_public_keys',
- metavar='PATH',
- type=pathlib.Path,
- action='append',
- help='public part of the keypair for signing PCR signatures')
- p.add_argument('--phases',
- dest='phase_path_groups',
- metavar='PHASE-PATH…',
- type=parse_phase_paths,
- action='append',
- help='phase-paths to create signatures for')
-
- p.add_argument('--pcr-banks',
- metavar='BANK…',
- type=parse_banks)
-
- p.add_argument('--signing-engine',
- metavar='ENGINE',
- help='OpenSSL engine to use for signing')
- p.add_argument('--secureboot-private-key',
- dest='sb_key',
- help='path to key file or engine-specific designation for SB signing')
- p.add_argument('--secureboot-certificate',
- dest='sb_cert',
- help='path to certificate file or engine-specific designation for SB signing')
-
- p.add_argument('--sign-kernel',
- action=argparse.BooleanOptionalAction,
- help='Sign the embedded kernel')
-
- p.add_argument('--tools',
- type=pathlib.Path,
- action='append',
- help='Directories to search for tools (systemd-measure, ...)')
-
- p.add_argument('--output', '-o',
- type=pathlib.Path,
- help='output file path')
-
- p.add_argument('--measure',
- action=argparse.BooleanOptionalAction,
- help='print systemd-measure output for the UKI')
-
- p.add_argument('--version',
- action='version',
- version=f'ukify {__version__}')
-
- opts = p.parse_args(args)
+ return p
- if opts.linux is not None:
- path_is_readable(opts.linux)
- for initrd in opts.initrd or ():
- path_is_readable(initrd)
- path_is_readable(opts.devicetree)
- path_is_readable(opts.pcrpkey)
- for key in opts.pcr_private_keys or ():
- path_is_readable(key)
- for key in opts.pcr_public_keys or ():
- path_is_readable(key)
+def finalize_options(opts):
if opts.cmdline and opts.cmdline.startswith('@'):
- opts.cmdline = path_is_readable(opts.cmdline[1:])
-
- if opts.os_release is not None and opts.os_release.startswith('@'):
- opts.os_release = path_is_readable(opts.os_release[1:])
- elif opts.os_release is None and opts.linux is not None:
+ opts.cmdline = pathlib.Path(opts.cmdline[1:])
+ elif opts.cmdline:
+ # Drop whitespace from the commandline. If we're reading from a file,
+ # we copy the contents verbatim. But configuration specified on the commandline
+ # or in the config file may contain additional whitespace that has no meaning.
+ opts.cmdline = ' '.join(opts.cmdline.split())
+
+ if opts.os_release and opts.os_release.startswith('@'):
+ opts.os_release = pathlib.Path(opts.os_release[1:])
+ elif not opts.os_release and opts.linux:
p = pathlib.Path('/etc/os-release')
if not p.exists():
- p = path_is_readable('/usr/lib/os-release')
+ p = pathlib.Path('/usr/lib/os-release')
opts.os_release = p
if opts.efi_arch is None:
opts.efi_arch = guess_efi_arch()
if opts.stub is None:
- opts.stub = path_is_readable(f'/usr/lib/systemd/boot/efi/linux{opts.efi_arch}.efi.stub')
+ opts.stub = pathlib.Path(f'/usr/lib/systemd/boot/efi/linux{opts.efi_arch}.efi.stub')
if opts.signing_engine is None:
- opts.sb_key = path_is_readable(opts.sb_key) if opts.sb_key else None
- opts.sb_cert = path_is_readable(opts.sb_cert) if opts.sb_cert else None
+ if opts.sb_key:
+ opts.sb_key = pathlib.Path(opts.sb_key)
+ if opts.sb_cert:
+ opts.sb_cert = pathlib.Path(opts.sb_cert)
if bool(opts.sb_key) ^ bool(opts.sb_cert):
raise ValueError('--secureboot-private-key= and --secureboot-certificate= must be specified together')
@@ -826,6 +1074,27 @@ ukify [options…] [LINUX INITRD…]
if opts.sign_kernel and not opts.sb_key:
raise ValueError('--sign-kernel requires --secureboot-private-key= and --secureboot-certificate= to be specified')
+ if opts.output is None:
+ if opts.linux is None:
+ raise ValueError('--output= must be specified when building a PE addon')
+ suffix = '.efi' if opts.sb_key else '.unsigned.efi'
+ opts.output = opts.linux.name + suffix
+
+ for section in opts.sections:
+ section.check_name()
+
+ if opts.summary:
+ # TODO: replace pprint() with some fancy formatting.
+ pprint.pprint(vars(opts))
+ sys.exit()
+
+
+def parse_args(args=None):
+ p = create_parser()
+ opts = p.parse_args(args)
+
+ # Check that --pcr-public-key=, --pcr-private-key=, and --phases=
+ # have either the same number of arguments are are not specified at all.
n_pcr_pub = None if opts.pcr_public_keys is None else len(opts.pcr_public_keys)
n_pcr_priv = None if opts.pcr_private_keys is None else len(opts.pcr_private_keys)
n_phase_path_groups = None if opts.phase_path_groups is None else len(opts.phase_path_groups)
@@ -834,14 +1103,9 @@ ukify [options…] [LINUX INITRD…]
if n_phase_path_groups is not None and n_phase_path_groups != n_pcr_priv:
raise ValueError('--phases= specifications must match --pcr-private-key=')
- if opts.output is None:
- if opts.linux is None:
- raise ValueError('--output= must be specified when building a PE addon')
- suffix = '.efi' if opts.sb_key else '.unsigned.efi'
- opts.output = opts.linux.name + suffix
+ apply_config(opts)
- for section in opts.sections:
- section.check_name()
+ finalize_options(opts)
return opts