From 4df48399dd42617cdd45d89c30a2e9fc7992f068 Mon Sep 17 00:00:00 2001 From: Daan De Meyer Date: Thu, 7 Dec 2023 23:11:19 +0100 Subject: [PATCH] Add support for installing shim Unfortunately shim is a necessary evil that we have to support. We add a new option that allows choosing either a signed version, an unsigned version or none at all (the default). We also stop redirecting /boot/efi to /efi so that /efi is our pristine directory for populating the ESP whereas /boot is unclaimed wasteland free for package managers to write all kinds of stuff to. --- mkosi.conf | 1 + mkosi.conf.d/20-arch.conf | 2 + mkosi.conf.d/20-centos-fedora.conf | 1 + mkosi.conf.d/20-debian-ubuntu.conf | 1 + mkosi.conf.d/20-opensuse.conf | 1 + mkosi/__init__.py | 205 +++++++++++++++++++++-------- mkosi/config.py | 18 +++ mkosi/resources/mkosi.md | 15 +++ tests/test_json.py | 3 + 9 files changed, 195 insertions(+), 52 deletions(-) diff --git a/mkosi.conf b/mkosi.conf index 529ab7c3c..68652b898 100644 --- a/mkosi.conf +++ b/mkosi.conf @@ -9,6 +9,7 @@ [Content] Autologin=yes BiosBootloader=grub +ShimBootloader=signed BuildSourcesEphemeral=yes Packages= diff --git a/mkosi.conf.d/20-arch.conf b/mkosi.conf.d/20-arch.conf index 6b100db26..bf2aaf7d0 100644 --- a/mkosi.conf.d/20-arch.conf +++ b/mkosi.conf.d/20-arch.conf @@ -4,6 +4,7 @@ Distribution=arch [Content] +ShimBootloader=unsigned Packages= apt archlinux-keyring @@ -32,6 +33,7 @@ Packages= qemu-base sbsigntools shadow + shim socat squashfs-tools strace diff --git a/mkosi.conf.d/20-centos-fedora.conf b/mkosi.conf.d/20-centos-fedora.conf index 21d39cb08..9d7874047 100644 --- a/mkosi.conf.d/20-centos-fedora.conf +++ b/mkosi.conf.d/20-centos-fedora.conf @@ -32,6 +32,7 @@ Packages= python3-cryptography qemu-kvm-core shadow-utils + shim socat squashfs-tools strace diff --git a/mkosi.conf.d/20-debian-ubuntu.conf b/mkosi.conf.d/20-debian-ubuntu.conf index 2aa934627..f4a519bb3 100644 --- a/mkosi.conf.d/20-debian-ubuntu.conf +++ b/mkosi.conf.d/20-debian-ubuntu.conf @@ -35,6 +35,7 @@ Packages= qemu-system sbsigntool shim-signed + shim-signed socat squashfs-tools strace diff --git a/mkosi.conf.d/20-opensuse.conf b/mkosi.conf.d/20-opensuse.conf index 968978dd8..409fc5920 100644 --- a/mkosi.conf.d/20-opensuse.conf +++ b/mkosi.conf.d/20-opensuse.conf @@ -32,6 +32,7 @@ Packages= qemu-headless sbsigntools shadow + shim socat squashfs strace diff --git a/mkosi/__init__.py b/mkosi/__init__.py index 8c4ce9b3a..e5e8e8722 100644 --- a/mkosi/__init__.py +++ b/mkosi/__init__.py @@ -36,6 +36,7 @@ from mkosi.config import ( MkosiJsonEncoder, OutputFormat, SecureBootSignTool, + ShimBootloader, Verb, format_bytes, format_tree, @@ -152,12 +153,6 @@ def install_distribution(state: MkosiState) -> None: with umask(~0o500): (state.root / "efi").mkdir(exist_ok=True) - # Some distributions install EFI binaries directly to /boot/efi. Let's redirect them to /efi - # instead. - rmtree(state.root / "boot/efi") - (state.root / "boot").mkdir(exist_ok=True) - (state.root / "boot/efi").symlink_to("../efi") - if state.config.packages: state.config.distribution.install_packages(state, state.config.packages) @@ -722,24 +717,61 @@ def pesign_prepare(state: MkosiState) -> None: "-d", state.workspace / "pesign"]) -def install_systemd_boot(state: MkosiState) -> None: - if state.config.bootable == ConfigFeature.disabled: - return +def efi_boot_binary(state: MkosiState) -> Path: + arch = state.config.architecture.to_efi() + assert arch + return Path(f"efi/EFI/BOOT/BOOT{arch.upper()}.EFI") - if state.config.bootloader != Bootloader.systemd_boot: - return + +def shim_second_stage_binary(state: MkosiState) -> Path: + arch = state.config.architecture.to_efi() + assert arch + if state.config.distribution == Distribution.opensuse: + return Path("efi/EFI/BOOT/grub.EFI") + else: + return Path(f"efi/EFI/BOOT/grub{arch}.EFI") + + +def sign_efi_binary(state: MkosiState, input: Path, output: Path) -> None: + assert state.config.secure_boot_key + assert state.config.secure_boot_certificate if ( - ( - state.config.output_format == OutputFormat.cpio or - state.config.output_format.is_extension_image() or - state.config.overlay - ) - and state.config.bootable == ConfigFeature.auto + state.config.secure_boot_sign_tool == SecureBootSignTool.sbsign or + state.config.secure_boot_sign_tool == SecureBootSignTool.auto and + shutil.which("sbsign") is not None ): + run([ + "sbsign", + "--key", state.config.secure_boot_key, + "--cert", state.config.secure_boot_certificate, + "--output", output, + input, + ]) + elif ( + state.config.secure_boot_sign_tool == SecureBootSignTool.pesign or + state.config.secure_boot_sign_tool == SecureBootSignTool.auto and + shutil.which("pesign") is not None + ): + pesign_prepare(state) + run([ + "pesign", + "--certdir", state.workspace / "pesign", + "--certificate", certificate_common_name(state.config.secure_boot_certificate), + "--sign", + "--force", + "--in", input, + "--out", output, + ]) + else: + die("One of sbsign or pesign is required to use SecureBoot=") + + +def install_systemd_boot(state: MkosiState) -> None: + if not want_efi(state.config): return - if state.config.architecture.to_efi() is None and state.config.bootable == ConfigFeature.auto: + if state.config.bootloader != Bootloader.systemd_boot: return if not any(gen_kernel_images(state)) and state.config.bootable == ConfigFeature.auto: @@ -758,38 +790,20 @@ def install_systemd_boot(state: MkosiState) -> None: return if state.config.secure_boot: - assert state.config.secure_boot_key - assert state.config.secure_boot_certificate - with complete_step("Signing systemd-boot binaries…"): for input in itertools.chain(directory.glob('*.efi'), directory.glob('*.EFI')): output = directory / f"{input}.signed" + sign_efi_binary(state, input, output) - if (state.config.secure_boot_sign_tool == SecureBootSignTool.sbsign or - state.config.secure_boot_sign_tool == SecureBootSignTool.auto and - shutil.which("sbsign") is not None): - run(["sbsign", - "--key", state.config.secure_boot_key, - "--cert", state.config.secure_boot_certificate, - "--output", output, - input]) - elif (state.config.secure_boot_sign_tool == SecureBootSignTool.pesign or - state.config.secure_boot_sign_tool == SecureBootSignTool.auto and - shutil.which("pesign") is not None): - pesign_prepare(state) - run(["pesign", - "--certdir", state.workspace / "pesign", - "--certificate", certificate_common_name(state.config.secure_boot_certificate), - "--sign", - "--force", - "--in", input, - "--out", output]) - else: - die("One of sbsign or pesign is required to use SecureBoot=") - - with complete_step("Installing boot loader…"): + with complete_step("Installing systemd-boot…"): run(["bootctl", "install", "--root", state.root, "--all-architectures", "--no-variables"], - env={"SYSTEMD_ESP_PATH": "/efi"}) + env={"SYSTEMD_ESP_PATH": "/efi", "SYSTEMD_LOG_LEVEL": "debug"}) + + if state.config.shim_bootloader != ShimBootloader.none: + shutil.copy2( + state.root / f"efi/EFI/systemd/systemd-boot{state.config.architecture.to_efi()}.efi", + state.root / shim_second_stage_binary(state), + ) if state.config.secure_boot: assert state.config.secure_boot_key @@ -823,6 +837,85 @@ def install_systemd_boot(state: MkosiState) -> None: state.workspace / "mkosi.esl"]) +def find_and_install_shim_binary( + state: MkosiState, + name: str, + signed: Sequence[str], + unsigned: Sequence[str], + output: Path, +) -> None: + if state.config.shim_bootloader == ShimBootloader.signed: + for pattern in signed: + for p in state.root.glob(pattern): + log_step(f"Installing signed {name} EFI binary from /{p.relative_to(state.root)} to /{output}") + shutil.copy2(p, state.root / output) + return + + if state.config.bootable == ConfigFeature.enabled: + die(f"Couldn't find signed {name} EFI binary installed in the image") + else: + for pattern in unsigned: + for p in state.root.glob(pattern): + if state.config.secure_boot: + log_step(f"Signing and installing unsigned {name} EFI binary from /{p.relative_to(state.root)} to /{output}") + sign_efi_binary(state, p, state.root / output) + else: + log_step(f"Installing unsigned {name} EFI binary /{p.relative_to(state.root)} to /{output}") + shutil.copy2(p, state.root / output) + + return + + if state.config.bootable == ConfigFeature.enabled: + die(f"Couldn't find unsigned {name} EFI binary installed in the image") + + +def install_shim(state: MkosiState) -> None: + if not want_efi(state.config): + return + + if state.config.shim_bootloader == ShimBootloader.none: + return + + if not any(gen_kernel_images(state)) and state.config.bootable == ConfigFeature.auto: + return + + dst = efi_boot_binary(state) + with umask(~0o700): + (state.root / dst).parent.mkdir(parents=True, exist_ok=True) + + arch = state.config.architecture.to_efi() + + signed = [ + f"usr/lib/shim/shim{arch}.efi.signed", # Debian + f"usr/lib/shim/shim{arch}.efi.signed.latest", # Ubuntu + f"boot/efi/EFI/*/shim{arch}.efi", # Fedora/CentOS + "usr/share/efi/*/shim.efi", # OpenSUSE + ] + + unsigned = [ + f"usr/lib/shim/shim{arch}.efi", # Debian/Ubuntu + f"usr/share/shim/*/*/shim{arch}.efi", # Fedora/CentOS + f"usr/share/shim/shim{arch}.efi", # Arch + ] + + find_and_install_shim_binary(state, "shim", signed, unsigned, dst) + + signed = [ + f"usr/lib/shim/mm{arch}.efi.signed", # Debian + f"usr/lib/shim/mm{arch}.efi", # Ubuntu + f"boot/efi/EFI/*/mm{arch}.efi", # Fedora/CentOS + "usr/share/efi/*/MokManager.efi", # OpenSUSE + ] + + unsigned = [ + f"usr/lib/shim/mm{arch}.efi", # Debian/Ubuntu + f"usr/share/shim/*/*/mm{arch}.efi", # Fedora/CentOS + f"usr/share/shim/mm{arch}.efi", # Arch + ] + + find_and_install_shim_binary(state, "mok", signed, unsigned, dst.parent) + + def find_grub_bios_directory(state: MkosiState) -> Optional[Path]: for d in ("usr/lib/grub/i386-pc", "usr/share/grub2/i386-pc"): if (p := state.root / d).exists() and any(p.iterdir()): @@ -1402,10 +1495,11 @@ def build_uki( run(cmd) -def want_uki(config: MkosiConfig) -> bool: - # Do we want to build an UKI according to config? +def want_efi(config: MkosiConfig) -> bool: + # Do we want to make the image bootable on EFI firmware? # Note that this returns True also in the case where autodetection might later - # cause the UKI not to be installed after the file system has been populated. + # cause the system to not be made bootable on EFI firmware after the filesystem + # has been populated. if config.output_format in (OutputFormat.uki, OutputFormat.esp): return True @@ -1422,7 +1516,10 @@ def want_uki(config: MkosiConfig) -> bool: ): return False - if config.architecture.to_efi() is None and config.bootable == ConfigFeature.auto: + if config.architecture.to_efi() is None: + if config.bootable == ConfigFeature.enabled: + die(f"Cannot make image bootable on UEFI on {config.architecture} architecture") + return False return True @@ -1435,7 +1532,7 @@ def install_uki(state: MkosiState, partitions: Sequence[Partition]) -> None: # benefit that they can be signed like normal EFI binaries, and can encode everything necessary to boot a # specific root device, including the root hash. - if not want_uki(state.config) or state.config.output_format in (OutputFormat.uki, OutputFormat.esp): + if not want_efi(state.config) or state.config.output_format in (OutputFormat.uki, OutputFormat.esp): return arch = state.config.architecture.to_efi() @@ -1454,7 +1551,10 @@ def install_uki(state: MkosiState, partitions: Sequence[Partition]) -> None: boot_count = f'+{(state.root / "etc/kernel/tries").read_text().strip()}' if state.config.bootloader == Bootloader.uki: - boot_binary = state.root / "efi/EFI/BOOT/BOOTX64.EFI" + if state.config.shim_bootloader != ShimBootloader.none: + boot_binary = state.root / shim_second_stage_binary(state) + else: + boot_binary = state.root / efi_boot_binary(state) elif state.config.image_version: boot_binary = ( state.root / f"efi/EFI/Linux/{image_id}_{state.config.image_version}-{kver}{boot_count}.efi" @@ -1767,7 +1867,7 @@ def check_systemd_tool(*tools: PathString, version: str, reason: str, hint: Opti def check_tools(args: MkosiArgs, config: MkosiConfig) -> None: - if want_uki(config): + if want_efi(config): check_systemd_tool( "ukify", "/usr/lib/systemd/ukify", version="254", @@ -2378,6 +2478,7 @@ def build_image(args: MkosiArgs, config: MkosiConfig) -> None: configure_clock(state) install_systemd_boot(state) + install_shim(state) run_sysusers(state) run_preset(state) run_depmod(state) diff --git a/mkosi/config.py b/mkosi/config.py index 92670f803..dbdd06880 100644 --- a/mkosi/config.py +++ b/mkosi/config.py @@ -209,6 +209,12 @@ class BiosBootloader(StrEnum): grub = enum.auto() +class ShimBootloader(StrEnum): + none = enum.auto() + signed = enum.auto() + unsigned = enum.auto() + + class QemuFirmware(StrEnum): auto = enum.auto() linux = enum.auto() @@ -922,6 +928,7 @@ class MkosiConfig: bootable: ConfigFeature bootloader: Bootloader bios_bootloader: BiosBootloader + shim_bootloader: ShimBootloader initrds: list[Path] initrd_packages: list[str] kernel_command_line: list[str] @@ -1663,6 +1670,15 @@ SETTINGS = ( default=BiosBootloader.none, help="Specify which BIOS bootloader to use", ), + MkosiConfigSetting( + dest="shim_bootloader", + metavar="BOOTLOADER", + section="Content", + parse=config_make_enum_parser(ShimBootloader), + choices=ShimBootloader.values(), + default=ShimBootloader.none, + help="Specify whether to use shim", + ), MkosiConfigSetting( dest="initrds", long="--initrd", @@ -2993,6 +3009,7 @@ def summary(config: MkosiConfig) -> str: Bootable: {yes_no_auto(config.bootable)} Bootloader: {config.bootloader} BIOS Bootloader: {config.bios_bootloader} + Shim Bootloader: {config.shim_bootloader} Initrds: {line_join_list(config.initrds)} Initrd Packages: {line_join_list(config.initrd_packages)} Kernel Command Line: {line_join_list(config.kernel_command_line)} @@ -3165,6 +3182,7 @@ def json_type_transformer(refcls: Union[type[MkosiArgs], type[MkosiConfig]]) -> tuple[str, ...]: str_tuple_transformer, Architecture: enum_transformer, BiosBootloader: enum_transformer, + ShimBootloader: enum_transformer, Bootloader: enum_transformer, Compression: enum_transformer, ConfigFeature: enum_transformer, diff --git a/mkosi/resources/mkosi.md b/mkosi/resources/mkosi.md index 770191bd2..6efc52750 100644 --- a/mkosi/resources/mkosi.md +++ b/mkosi/resources/mkosi.md @@ -1021,6 +1021,21 @@ boolean argument: either `1`, `yes`, or `true` to enable, or `0`, `no`, : Even if no EFI bootloader is installed, we still need an ESP for BIOS boot as that's where we store the kernel, initrd and grub modules. +`ShimBootloader=`, `--shim-bootloader=` + +: Takes one of `none`, `unsigned`, or `signed`. Defaults to `none`. If + set to `none`, shim and MokManager will not be installed to the ESP. + If set to `unsigned`, mkosi will search for unsigned shim and + MokManager EFI binaries and install them. If `SecureBoot=` is enabled, + mkosi will sign the unsigned EFI binaries before installing thel. If + set to `signed`, mkosi will search for signed EFI binaries and install + those. Even if `SecureBoot=` is enabled, mkosi won't sign these + binaries again. + +: Note that this option only takes effect when an image that is bootable + on UEFI firmware is requested using other options + (`Bootable=`, `Bootloader=`). + `Initrds=`, `--initrd` : Use user-provided initrd(s). Takes a comma separated list of paths to diff --git a/tests/test_json.py b/tests/test_json.py index 35eaed1d1..2b13e6a7b 100644 --- a/tests/test_json.py +++ b/tests/test_json.py @@ -24,6 +24,7 @@ from mkosi.config import ( QemuFirmware, QemuVsockCID, SecureBootSignTool, + ShimBootloader, Verb, ) from mkosi.distributions import Distribution @@ -243,6 +244,7 @@ def test_config() -> None: "SecureBootKey": "/path/to/keyfile", "SecureBootSignTool": "pesign", "Seed": "7496d7d8-7f08-4a2b-96c6-ec8c43791b60", + "ShimBootloader": "none", "Sign": false, "SignExpectedPcr": "disabled", "SkeletonTrees": [ @@ -367,6 +369,7 @@ def test_config() -> None: secure_boot_key = Path("/path/to/keyfile"), secure_boot_sign_tool = SecureBootSignTool.pesign, seed = uuid.UUID("7496d7d8-7f08-4a2b-96c6-ec8c43791b60"), + shim_bootloader = ShimBootloader.none, sign = False, sign_expected_pcr = ConfigFeature.disabled, skeleton_trees = [ConfigTree(Path("/foo/bar"), Path("/")), ConfigTree(Path("/bar/baz"), Path("/qux"))], -- 2.47.2