]> git.ipfire.org Git - thirdparty/mkosi.git/commitdiff
Move various functions to bootloader.py
authorDaan De Meyer <daan.j.demeyer@gmail.com>
Wed, 4 Sep 2024 13:44:55 +0000 (15:44 +0200)
committerDaan De Meyer <daan.j.demeyer@gmail.com>
Wed, 4 Sep 2024 14:04:41 +0000 (16:04 +0200)
Our main file is growing too large again, so let's split off a bunch
of bootloader stuff into bootloader.py

This is very rough, the kernel stuff should probably move somewhere
else as well, but I wanted to move stuff without actually changing
code.

mkosi/__init__.py
mkosi/bootloader.py [new file with mode: 0644]

index 86365c2bb8fd1be280cf9441cb9241c0e025cce3..4553bc3b9d1f16ac04413bcac8822bc23cb54a33 100644 (file)
@@ -27,12 +27,29 @@ from pathlib import Path
 from typing import Optional, Union, cast
 
 from mkosi.archive import can_extract_tar, extract_tar, make_cpio, make_tar
+from mkosi.bootloader import (
+    certificate_common_name,
+    efi_boot_binary,
+    extract_pe_section,
+    gen_kernel_images,
+    grub_bios_setup,
+    install_grub,
+    install_shim,
+    install_systemd_boot,
+    pesign_prepare,
+    prepare_grub_config,
+    python_binary,
+    shim_second_stage_binary,
+    sign_efi_binary,
+    want_efi,
+    want_grub_bios,
+    want_grub_efi,
+)
 from mkosi.burn import run_burn
 from mkosi.completion import print_completion
 from mkosi.config import (
     PACKAGE_GLOBS,
     Args,
-    BiosBootloader,
     Bootloader,
     Cacheonly,
     Compression,
@@ -948,675 +965,6 @@ def run_postoutput_scripts(context: Context) -> None:
                 )
 
 
-def certificate_common_name(context: Context, certificate: Path) -> str:
-    output = run(
-        [
-            "openssl",
-            "x509",
-            "-noout",
-            "-subject",
-            "-nameopt", "multiline",
-            "-in", certificate,
-        ],
-        stdout=subprocess.PIPE,
-        sandbox=context.sandbox(binary="openssl", options=["--ro-bind", certificate, certificate]),
-    ).stdout
-
-    for line in output.splitlines():
-        if not line.strip().startswith("commonName"):
-            continue
-
-        _, sep, value = line.partition("=")
-        if not sep:
-            die("Missing '=' delimiter in openssl output")
-
-        return value.strip()
-
-    die(f"Certificate {certificate} is missing Common Name")
-
-
-def pesign_prepare(context: Context) -> None:
-    assert context.config.secure_boot_key
-    assert context.config.secure_boot_certificate
-
-    if (context.workspace / "pesign").exists():
-        return
-
-    (context.workspace / "pesign").mkdir()
-
-    # pesign takes a certificate directory and a certificate common name as input arguments, so we have
-    # to transform our input key and cert into that format. Adapted from
-    # https://www.mankier.com/1/pesign#Examples-Signing_with_the_certificate_and_private_key_in_individual_files
-    with open(context.workspace / "secure-boot.p12", "wb") as f:
-        run(
-            [
-                "openssl",
-                "pkcs12",
-                "-export",
-                # Arcane incantation to create a pkcs12 certificate without a password.
-                "-keypbe", "NONE",
-                "-certpbe", "NONE",
-                "-nomaciter",
-                "-passout", "pass:",
-                "-inkey", context.config.secure_boot_key,
-                "-in", context.config.secure_boot_certificate,
-            ],
-            stdout=f,
-            sandbox=context.sandbox(
-                binary="openssl",
-                options=[
-                    "--ro-bind", context.config.secure_boot_key, context.config.secure_boot_key,
-                    "--ro-bind", context.config.secure_boot_certificate, context.config.secure_boot_certificate,
-                ],
-            ),
-        )
-
-    (context.workspace / "pesign").mkdir(exist_ok=True)
-
-    run(
-        [
-            "pk12util",
-            "-K", "",
-            "-W", "",
-            "-i", context.workspace / "secure-boot.p12",
-            "-d", context.workspace / "pesign",
-        ],
-        sandbox=context.sandbox(
-            binary="pk12util",
-            options=[
-                "--ro-bind", context.workspace / "secure-boot.p12", context.workspace / "secure-boot.p12",
-                "--ro-bind", context.workspace / "pesign", context.workspace / "pesign",
-            ],
-        ),
-    )
-
-
-def efi_boot_binary(context: Context) -> Path:
-    arch = context.config.architecture.to_efi()
-    assert arch
-    return Path(f"efi/EFI/BOOT/BOOT{arch.upper()}.EFI")
-
-
-def shim_second_stage_binary(context: Context) -> Path:
-    arch = context.config.architecture.to_efi()
-    assert arch
-    if context.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(context: Context, input: Path, output: Path) -> Path:
-    assert context.config.secure_boot_key
-    assert context.config.secure_boot_certificate
-
-    if (
-        context.config.secure_boot_sign_tool == SecureBootSignTool.sbsign or
-        context.config.secure_boot_sign_tool == SecureBootSignTool.auto and
-        context.config.find_binary("sbsign") is not None
-    ):
-        with tempfile.NamedTemporaryFile(dir=output.parent, prefix=output.name) as f:
-            os.chmod(f.name, stat.S_IMODE(input.stat().st_mode))
-            cmd: list[PathString] = [
-                "sbsign",
-                "--key", context.config.secure_boot_key,
-                "--cert", context.config.secure_boot_certificate,
-                "--output", "/dev/stdout",
-            ]
-            options: list[PathString] = [
-                "--ro-bind", context.config.secure_boot_certificate, context.config.secure_boot_certificate,
-                "--ro-bind", input, input,
-            ]
-            if context.config.secure_boot_key_source.type == KeySource.Type.engine:
-                cmd += ["--engine", context.config.secure_boot_key_source.source]
-            if context.config.secure_boot_key.exists():
-                options += ["--ro-bind", context.config.secure_boot_key, context.config.secure_boot_key]
-            cmd += [input]
-            run(
-                cmd,
-                stdout=f,
-                sandbox=context.sandbox(
-                    binary="sbsign",
-                    options=options,
-                    devices=context.config.secure_boot_key_source.type != KeySource.Type.file,
-                )
-            )
-            output.unlink(missing_ok=True)
-            os.link(f.name, output)
-    elif (
-        context.config.secure_boot_sign_tool == SecureBootSignTool.pesign or
-        context.config.secure_boot_sign_tool == SecureBootSignTool.auto and
-        context.config.find_binary("pesign") is not None
-    ):
-        pesign_prepare(context)
-        with tempfile.NamedTemporaryFile(dir=output.parent, prefix=output.name) as f:
-            os.chmod(f.name, stat.S_IMODE(input.stat().st_mode))
-            run(
-                [
-                    "pesign",
-                    "--certdir", context.workspace / "pesign",
-                    "--certificate", certificate_common_name(context, context.config.secure_boot_certificate),
-                    "--sign",
-                    "--force",
-                    "--in", input,
-                    "--out", "/dev/stdout",
-                ],
-                stdout=f,
-                sandbox=context.sandbox(
-                    binary="pesign",
-                    options=[
-                        "--ro-bind", context.workspace / "pesign", context.workspace / "pesign",
-                        "--ro-bind", input, input,
-                    ]
-                ),
-            )
-            output.unlink(missing_ok=True)
-            os.link(f.name, output)
-    else:
-        die("One of sbsign or pesign is required to use SecureBoot=")
-
-    return output
-
-
-def install_systemd_boot(context: Context) -> None:
-    if not want_efi(context.config):
-        return
-
-    if context.config.bootloader != Bootloader.systemd_boot:
-        return
-
-    if not any(gen_kernel_images(context)) and context.config.bootable == ConfigFeature.auto:
-        return
-
-    if not context.config.find_binary("bootctl"):
-        if context.config.bootable == ConfigFeature.enabled:
-            die("An EFI bootable image with systemd-boot was requested but bootctl was not found")
-        return
-
-    directory = context.root / "usr/lib/systemd/boot/efi"
-    signed = context.config.shim_bootloader == ShimBootloader.signed
-    if not directory.glob("*.efi.signed" if signed else "*.efi"):
-        if context.config.bootable == ConfigFeature.enabled:
-            die(f"An EFI bootable image with systemd-boot was requested but a {'signed ' if signed else ''}"
-                f"systemd-boot binary was not found at {directory.relative_to(context.root)}")
-        return
-
-    if context.config.secure_boot and not signed:
-        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(context, input, output)
-
-    with complete_step("Installing systemd-boot…"):
-        run(
-            ["bootctl", "install", "--root=/buildroot", "--all-architectures", "--no-variables"],
-            env={"SYSTEMD_ESP_PATH": "/efi", "SYSTEMD_XBOOTLDR_PATH": "/boot"},
-            sandbox=context.sandbox(binary="bootctl", options=["--bind", context.root, "/buildroot"]),
-        )
-        # TODO: Use --random-seed=no when we can depend on systemd 256.
-        Path(context.root / "efi/loader/random-seed").unlink(missing_ok=True)
-
-        if context.config.shim_bootloader != ShimBootloader.none:
-            shutil.copy2(
-                context.root / f"efi/EFI/systemd/systemd-boot{context.config.architecture.to_efi()}.efi",
-                context.root / shim_second_stage_binary(context),
-            )
-
-    if context.config.secure_boot and context.config.secure_boot_auto_enroll:
-        assert context.config.secure_boot_key
-        assert context.config.secure_boot_certificate
-
-        with complete_step("Setting up secure boot auto-enrollment…"):
-            keys = context.root / "efi/loader/keys/auto"
-            with umask(~0o700):
-                keys.mkdir(parents=True, exist_ok=True)
-
-            # sbsiglist expects a DER certificate.
-            with umask(~0o600), open(context.workspace / "mkosi.der", "wb") as f:
-                run(
-                    [
-                        "openssl",
-                        "x509",
-                        "-outform", "DER",
-                        "-in", context.config.secure_boot_certificate,
-                    ],
-                    stdout=f,
-                    sandbox=context.sandbox(
-                        binary="openssl",
-                        options=[
-                            "--ro-bind",
-                            context.config.secure_boot_certificate,
-                            context.config.secure_boot_certificate,
-                        ],
-                    ),
-                )
-
-            with umask(~0o600), open(context.workspace / "mkosi.esl", "wb") as f:
-                run(
-                    [
-                        "sbsiglist",
-                        "--owner", str(uuid.uuid4()),
-                        "--type", "x509",
-                        "--output", "/dev/stdout",
-                        context.workspace / "mkosi.der",
-                    ],
-                    stdout=f,
-                    sandbox=context.sandbox(
-                        binary="sbsiglist",
-                        options=["--ro-bind", context.workspace / "mkosi.der", context.workspace / "mkosi.der"]
-                    ),
-                )
-
-            # We reuse the key for all secure boot databases to keep things simple.
-            for db in ["PK", "KEK", "db"]:
-                with umask(~0o600), open(keys / f"{db}.auth", "wb") as f:
-                    cmd: list[PathString] = [
-                        "sbvarsign",
-                        "--attr",
-                            "NON_VOLATILE,BOOTSERVICE_ACCESS,RUNTIME_ACCESS,TIME_BASED_AUTHENTICATED_WRITE_ACCESS",
-                        "--key", context.config.secure_boot_key,
-                        "--cert", context.config.secure_boot_certificate,
-                        "--output", "/dev/stdout",
-                    ]
-                    options: list[PathString] = [
-                        "--ro-bind", context.config.secure_boot_certificate, context.config.secure_boot_certificate,
-                        "--ro-bind", context.workspace / "mkosi.esl", context.workspace / "mkosi.esl",
-                    ]
-                    if context.config.secure_boot_key_source.type == KeySource.Type.engine:
-                        cmd += ["--engine", context.config.secure_boot_key_source.source]
-                    if context.config.secure_boot_key.exists():
-                        options += ["--ro-bind", context.config.secure_boot_key, context.config.secure_boot_key]
-                    cmd += [db, context.workspace / "mkosi.esl"]
-                    run(
-                        cmd,
-                        stdout=f,
-                        sandbox=context.sandbox(
-                            binary="sbvarsign",
-                            options=options,
-                            devices=context.config.secure_boot_key_source.type != KeySource.Type.file,
-                        ),
-                    )
-
-
-def find_and_install_shim_binary(
-    context: Context,
-    name: str,
-    signed: Sequence[str],
-    unsigned: Sequence[str],
-    output: Path,
-) -> None:
-    if context.config.shim_bootloader == ShimBootloader.signed:
-        for pattern in signed:
-            for p in context.root.glob(pattern):
-                if p.is_symlink() and p.readlink().is_absolute():
-                    logging.warning(f"Ignoring signed {name} EFI binary which is an absolute path to {p.readlink()}")
-                    continue
-
-                rel = p.relative_to(context.root)
-                if (context.root / output).is_dir():
-                    output /= rel.name
-
-                log_step(f"Installing signed {name} EFI binary from /{rel} to /{output}")
-                shutil.copy2(p, context.root / output)
-                return
-
-        if context.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 context.root.glob(pattern):
-                if p.is_symlink() and p.readlink().is_absolute():
-                    logging.warning(f"Ignoring unsigned {name} EFI binary which is an absolute path to {p.readlink()}")
-                    continue
-
-                rel = p.relative_to(context.root)
-                if (context.root / output).is_dir():
-                    output /= rel.name
-
-                if context.config.secure_boot:
-                    log_step(f"Signing and installing unsigned {name} EFI binary from /{rel} to /{output}")
-                    sign_efi_binary(context, p, context.root / output)
-                else:
-                    log_step(f"Installing unsigned {name} EFI binary /{rel} to /{output}")
-                    shutil.copy2(p, context.root / output)
-
-                return
-
-        if context.config.bootable == ConfigFeature.enabled:
-            die(f"Couldn't find unsigned {name} EFI binary installed in the image")
-
-
-def install_shim(context: Context) -> None:
-    if not want_efi(context.config):
-        return
-
-    if context.config.shim_bootloader == ShimBootloader.none:
-        return
-
-    if not any(gen_kernel_images(context)) and context.config.bootable == ConfigFeature.auto:
-        return
-
-    dst = efi_boot_binary(context)
-    with umask(~0o700):
-        (context.root / dst).parent.mkdir(parents=True, exist_ok=True)
-
-    arch = context.config.architecture.to_efi()
-
-    signed = [
-        f"usr/lib/shim/shim{arch}.efi.signed.latest", # Ubuntu
-        f"usr/lib/shim/shim{arch}.efi.signed", # Debian
-        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(context, "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(context, "mok", signed, unsigned, dst.parent)
-
-
-def find_grub_directory(context: Context, *, target: str) -> Optional[Path]:
-    for d in ("usr/lib/grub", "usr/share/grub2"):
-        if (p := context.root / d / target).exists() and any(p.iterdir()):
-            return p
-
-    return None
-
-
-def find_grub_binary(config: Config, binary: str) -> Optional[Path]:
-    assert "grub" not in binary
-
-    # Debian has a bespoke setup where if only grub-pc-bin is installed, grub-bios-setup is installed in
-    # /usr/lib/i386-pc instead of in /usr/bin. Let's take that into account and look for binaries in
-    # /usr/lib/grub/i386-pc as well.
-    return config.find_binary(f"grub-{binary}", f"grub2-{binary}", f"/usr/lib/grub/i386-pc/grub-{binary}")
-
-
-def want_grub_efi(context: Context) -> bool:
-    if not want_efi(context.config):
-        return False
-
-    if context.config.bootloader != Bootloader.grub:
-        return False
-
-    if context.config.shim_bootloader != ShimBootloader.signed:
-        have = find_grub_directory(context, target="x86_64-efi") is not None
-        if not have and context.config.bootable == ConfigFeature.enabled:
-            die("An EFI bootable image with grub was requested but grub for EFI is not installed")
-
-    return True
-
-
-def want_grub_bios(context: Context, partitions: Sequence[Partition] = ()) -> bool:
-    if context.config.bootable == ConfigFeature.disabled:
-        return False
-
-    if context.config.output_format != OutputFormat.disk:
-        return False
-
-    if context.config.bios_bootloader != BiosBootloader.grub:
-        return False
-
-    if context.config.overlay:
-        return False
-
-    have = find_grub_directory(context, target="i386-pc") is not None
-    if not have and context.config.bootable == ConfigFeature.enabled:
-        die("A BIOS bootable image with grub was requested but grub for BIOS is not installed")
-
-    bios = any(p.type == Partition.GRUB_BOOT_PARTITION_UUID for p in partitions)
-    if partitions and not bios and context.config.bootable == ConfigFeature.enabled:
-        die("A BIOS bootable image with grub was requested but no BIOS Boot Partition was configured")
-
-    esp = any(p.type == "esp" for p in partitions)
-    if partitions and not esp and context.config.bootable == ConfigFeature.enabled:
-        die("A BIOS bootable image with grub was requested but no ESP partition was configured")
-
-    root = any(p.type.startswith("root") or p.type.startswith("usr") for p in partitions)
-    if partitions and not root and context.config.bootable == ConfigFeature.enabled:
-        die("A BIOS bootable image with grub was requested but no root or usr partition was configured")
-
-    installed = True
-
-    for binary in ("mkimage", "bios-setup"):
-        if find_grub_binary(context.config, binary):
-            continue
-
-        if context.config.bootable == ConfigFeature.enabled:
-            die(f"A BIOS bootable image with grub was requested but {binary} was not found")
-
-        installed = False
-
-    return (have and bios and esp and root and installed) if partitions else have
-
-
-def prepare_grub_config(context: Context) -> Optional[Path]:
-    config = context.root / "efi" / context.config.distribution.grub_prefix() / "grub.cfg"
-    with umask(~0o700):
-        config.parent.mkdir(exist_ok=True)
-
-    # For some unknown reason, if we don't set the timeout to zero, grub never leaves its menu, so we default
-    # to a zero timeout, but only if the config file hasn't been provided by the user.
-    if not config.exists():
-        with umask(~0o600), config.open("w") as f:
-            f.write("set timeout=0\n")
-
-    if want_grub_efi(context):
-        # Signed EFI grub shipped by distributions reads its configuration from /EFI/<distribution>/grub.cfg (except
-        # in OpenSUSE) in the ESP so let's put a shim there to redirect to the actual configuration file.
-        if context.config.distribution == Distribution.opensuse:
-            earlyconfig = context.root / "efi/EFI/BOOT/grub.cfg"
-        else:
-            earlyconfig = context.root / "efi/EFI" / context.config.distribution.name / "grub.cfg"
-
-        with umask(~0o700):
-            earlyconfig.parent.mkdir(parents=True, exist_ok=True)
-
-        # Read the actual config file from the root of the ESP.
-        earlyconfig.write_text(f"configfile /{context.config.distribution.grub_prefix()}/grub.cfg\n")
-
-    return config
-
-
-def grub_mkimage(
-    context: Context,
-    *,
-    target: str,
-    modules: Sequence[str] = (),
-    output: Optional[Path] = None,
-    sbat: Optional[Path] = None,
-) -> None:
-    mkimage = find_grub_binary(context.config, "mkimage")
-    assert mkimage
-
-    directory = find_grub_directory(context, target=target)
-    assert directory
-
-    with (
-        complete_step(f"Generating grub image for {target}"),
-        tempfile.NamedTemporaryFile("w", prefix="grub-early-config") as earlyconfig
-    ):
-        earlyconfig.write(
-            textwrap.dedent(
-                f"""\
-                search --no-floppy --set=root --file /{context.config.distribution.grub_prefix()}/grub.cfg
-                set prefix=($root)/{context.config.distribution.grub_prefix()}
-                """
-            )
-        )
-
-        earlyconfig.flush()
-
-        run(
-            [
-                mkimage,
-                "--directory", "/grub",
-                "--config", earlyconfig.name,
-                "--prefix", f"/{context.config.distribution.grub_prefix()}",
-                "--output", output or ("/grub/core.img"),
-                "--format", target,
-                *(["--sbat", str(sbat)] if sbat else []),
-                *(["--disable-shim-lock"] if context.config.shim_bootloader == ShimBootloader.none else []),
-                "cat",
-                "cmp",
-                "div",
-                "echo",
-                "fat",
-                "hello",
-                "help",
-                "keylayouts",
-                "linux",
-                "loadenv",
-                "ls",
-                "normal",
-                "part_gpt",
-                "read",
-                "reboot",
-                "search_fs_file",
-                "search",
-                "sleep",
-                "test",
-                "tr",
-                "true",
-                *modules,
-            ],
-            sandbox=context.sandbox(
-                binary=mkimage,
-                options=[
-                    "--bind", directory, "/grub",
-                    "--ro-bind", earlyconfig.name, earlyconfig.name,
-                    *(["--bind", str(output.parent), str(output.parent)] if output else []),
-                    *(["--ro-bind", str(sbat), str(sbat)] if sbat else []),
-                ],
-            ),
-        )
-
-
-def find_signed_grub_image(context: Context) -> Optional[Path]:
-    arch = context.config.architecture.to_efi()
-
-    patterns = [
-        f"usr/lib/grub/*-signed/grub{arch}.efi.signed", # Debian/Ubuntu
-        f"boot/efi/EFI/*/grub{arch}.efi", # Fedora/CentOS
-        "usr/share/efi/*/grub.efi", # OpenSUSE
-    ]
-
-    for p in flatten(context.root.glob(pattern) for pattern in patterns):
-        if p.is_symlink() and p.readlink().is_absolute():
-            logging.warning(f"Ignoring signed grub EFI binary which is an absolute path to {p.readlink()}")
-            continue
-
-        return p
-
-    return None
-
-
-def install_grub(context: Context) -> None:
-    if not want_grub_bios(context) and not want_grub_efi(context):
-        return
-
-    if want_grub_bios(context):
-        grub_mkimage(context, target="i386-pc", modules=("biosdisk",))
-
-    if want_grub_efi(context):
-        if context.config.shim_bootloader != ShimBootloader.none:
-            output = context.root / shim_second_stage_binary(context)
-        else:
-            output = context.root / efi_boot_binary(context)
-
-        with umask(~0o700):
-            output.parent.mkdir(parents=True, exist_ok=True)
-
-        if context.config.shim_bootloader == ShimBootloader.signed:
-            if not (signed := find_signed_grub_image(context)):
-                if context.config.bootable == ConfigFeature.enabled:
-                    die("Couldn't find a signed grub EFI binary installed in the image")
-
-                return
-
-            rel = output.relative_to(context.root)
-            log_step(f"Installing signed grub EFI binary from /{signed.relative_to(context.root)} to /{rel}")
-            shutil.copy2(signed, output)
-        else:
-            if context.config.secure_boot and context.config.shim_bootloader != ShimBootloader.none:
-                if not (signed := find_signed_grub_image(context)):
-                    die("Couldn't find a signed grub EFI binary installed in the image to extract SBAT from")
-
-                sbat = extract_pe_section(context, signed, ".sbat", context.workspace / "sbat")
-            else:
-                sbat = None
-
-            grub_mkimage(context, target="x86_64-efi", output=output, modules=("chain",), sbat=sbat)
-            if context.config.secure_boot:
-                sign_efi_binary(context, output, output)
-
-    dst = context.root / "efi" / context.config.distribution.grub_prefix() / "fonts"
-    with umask(~0o700):
-        dst.mkdir(parents=True, exist_ok=True)
-
-    for d in ("grub", "grub2"):
-        unicode = context.root / "usr/share" / d / "unicode.pf2"
-        if unicode.exists():
-            shutil.copy2(unicode, dst)
-
-
-def grub_bios_setup(context: Context, partitions: Sequence[Partition]) -> None:
-    if not want_grub_bios(context, partitions):
-        return
-
-    setup = find_grub_binary(context.config, "bios-setup")
-    assert setup
-
-    directory = find_grub_directory(context, target="i386-pc")
-    assert directory
-
-    with (
-        complete_step("Installing grub boot loader for BIOS…"),
-        tempfile.NamedTemporaryFile(mode="w") as mountinfo,
-    ):
-        # grub-bios-setup insists on being able to open the root device that --directory is located on, which
-        # needs root privileges. However, it only uses the root device when it is unable to embed itself in the
-        # bios boot partition. To make installation work unprivileged, we trick grub to think that the root
-        # device is our image by mounting over its /proc/self/mountinfo file (where it gets its information from)
-        # with our own file correlating the root directory to our image file.
-        mountinfo.write(f"1 0 1:1 / / - fat {context.staging / context.config.output_with_format}\n")
-        mountinfo.flush()
-
-        run(
-            [
-                setup,
-                "--directory", "/grub",
-                context.staging / context.config.output_with_format,
-            ],
-            sandbox=context.sandbox(
-                binary=setup,
-                options=[
-                    "--bind", directory, "/grub",
-                    "--bind", context.staging, context.staging,
-                    "--bind", mountinfo.name, "/proc/self/mountinfo",
-                ],
-            ),
-        )
-
-
 def install_tree(
     config: Config,
     src: Path,
@@ -1766,32 +1114,6 @@ def fixup_vmlinuz_location(context: Context) -> None:
                 shutil.copy2(d, vmlinuz)
 
 
-def gen_kernel_images(context: Context) -> Iterator[tuple[str, Path]]:
-    if not (context.root / "usr/lib/modules").exists():
-        return
-
-    for kver in sorted(
-        (k for k in (context.root / "usr/lib/modules").iterdir() if k.is_dir()),
-        key=lambda k: GenericVersion(k.name),
-        reverse=True
-    ):
-        # Make sure we look for anything that remotely resembles vmlinuz, as
-        # the arch specific install scripts in the kernel source tree sometimes
-        # do weird stuff. But let's make sure we're not returning UKIs as the
-        # UKI on Fedora is named vmlinuz-virt.efi. Also look for uncompressed
-        # images (vmlinux) as some architectures ship those. Prefer vmlinuz if
-        # both are present.
-        for kimg in kver.glob("vmlinuz*"):
-            if KernelType.identify(context.config, kimg) != KernelType.uki:
-                yield kver.name, kimg
-                break
-        else:
-            for kimg in kver.glob("vmlinux*"):
-                if KernelType.identify(context.config, kimg) != KernelType.uki:
-                    yield kver.name, kimg
-                    break
-
-
 def want_initrd(context: Context) -> bool:
     if context.config.bootable == ConfigFeature.disabled:
         return False
@@ -2077,56 +1399,6 @@ def join_initrds(initrds: Sequence[Path], output: Path) -> Path:
     return output
 
 
-def python_binary(config: Config, *, binary: Optional[PathString]) -> PathString:
-    tools = (
-        not binary or
-        not (path := config.find_binary(binary)) or
-        not any(path.is_relative_to(d) for d in config.extra_search_paths)
-    )
-
-    # If there's no tools tree, prefer the interpreter from MKOSI_INTERPRETER. If there is a tools
-    # tree, just use the default python3 interpreter.
-    exe = Path(sys.executable)
-    return "python3" if (tools and config.tools_tree) or not exe.is_relative_to("/usr") else exe
-
-
-def extract_pe_section(context: Context, binary: Path, section: str, output: Path) -> Path:
-    # When using a tools tree, we want to use the pefile module from the tools tree instead of requiring that
-    # python-pefile is installed on the host. So we execute python as a subprocess to make sure we load
-    # pefile from the tools tree if one is used.
-
-    # TODO: Use ignore_padding=True instead of length once we can depend on a newer pefile.
-    # TODO: Drop KeyError logic once we drop support for Ubuntu Jammy and sdmagic will always be available.
-    pefile = textwrap.dedent(
-        f"""\
-        import pefile
-        import sys
-        from pathlib import Path
-        pe = pefile.PE("{binary}", fast_load=True)
-        section = {{s.Name.decode().strip("\\0"): s for s in pe.sections}}.get("{section}")
-        if not section:
-            sys.exit(67)
-        sys.stdout.buffer.write(section.get_data(length=section.Misc_VirtualSize))
-        """
-    )
-
-    with open(output, "wb") as f:
-        result = run(
-            [python_binary(context.config, binary=None)],
-            input=pefile,
-            stdout=f,
-            sandbox=context.sandbox(
-                binary=python_binary(context.config, binary=None),
-                options=["--ro-bind", binary, binary],
-            ),
-            success_exit_status=(0, 67),
-        )
-        if result.returncode == 67:
-            raise KeyError(f"{section} section not found in {binary}")
-
-    return output
-
-
 def want_signed_pcrs(config: Config) -> bool:
     return (
         config.sign_expected_pcr == ConfigFeature.enabled or
@@ -2260,36 +1532,6 @@ def build_uki(
         )
 
 
-def want_efi(config: Config) -> 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 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
-
-    if config.bootable == ConfigFeature.disabled:
-        return False
-
-    if config.bootloader == Bootloader.none:
-        return False
-
-    if (
-        (config.output_format == OutputFormat.cpio or config.output_format.is_extension_image() or config.overlay)
-        and config.bootable == ConfigFeature.auto
-    ):
-        return False
-
-    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
-
-
 def systemd_stub_binary(context: Context) -> Path:
     arch = context.config.architecture.to_efi()
     stub = context.root / f"usr/lib/systemd/boot/efi/linux{arch}.efi.stub"
diff --git a/mkosi/bootloader.py b/mkosi/bootloader.py
new file mode 100644 (file)
index 0000000..77cb972
--- /dev/null
@@ -0,0 +1,810 @@
+import itertools
+import logging
+import os
+import shutil
+import stat
+import subprocess
+import sys
+import tempfile
+import textwrap
+import uuid
+from collections.abc import Iterator, Sequence
+from pathlib import Path
+from typing import Optional
+
+from mkosi.config import (
+    BiosBootloader,
+    Bootloader,
+    Config,
+    ConfigFeature,
+    KeySource,
+    OutputFormat,
+    SecureBootSignTool,
+    ShimBootloader,
+)
+from mkosi.context import Context
+from mkosi.distributions import Distribution
+from mkosi.log import complete_step, die, log_step
+from mkosi.partition import Partition
+from mkosi.qemu import KernelType
+from mkosi.run import run
+from mkosi.sandbox import umask
+from mkosi.types import PathString
+from mkosi.util import flatten
+from mkosi.versioncomp import GenericVersion
+
+
+def want_efi(config: Config) -> 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 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
+
+    if config.bootable == ConfigFeature.disabled:
+        return False
+
+    if config.bootloader == Bootloader.none:
+        return False
+
+    if (
+        (config.output_format == OutputFormat.cpio or config.output_format.is_extension_image() or config.overlay)
+        and config.bootable == ConfigFeature.auto
+    ):
+        return False
+
+    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
+
+
+def want_grub_efi(context: Context) -> bool:
+    if not want_efi(context.config):
+        return False
+
+    if context.config.bootloader != Bootloader.grub:
+        return False
+
+    if context.config.shim_bootloader != ShimBootloader.signed:
+        have = find_grub_directory(context, target="x86_64-efi") is not None
+        if not have and context.config.bootable == ConfigFeature.enabled:
+            die("An EFI bootable image with grub was requested but grub for EFI is not installed")
+
+    return True
+
+
+def want_grub_bios(context: Context, partitions: Sequence[Partition] = ()) -> bool:
+    if context.config.bootable == ConfigFeature.disabled:
+        return False
+
+    if context.config.output_format != OutputFormat.disk:
+        return False
+
+    if context.config.bios_bootloader != BiosBootloader.grub:
+        return False
+
+    if context.config.overlay:
+        return False
+
+    have = find_grub_directory(context, target="i386-pc") is not None
+    if not have and context.config.bootable == ConfigFeature.enabled:
+        die("A BIOS bootable image with grub was requested but grub for BIOS is not installed")
+
+    bios = any(p.type == Partition.GRUB_BOOT_PARTITION_UUID for p in partitions)
+    if partitions and not bios and context.config.bootable == ConfigFeature.enabled:
+        die("A BIOS bootable image with grub was requested but no BIOS Boot Partition was configured")
+
+    esp = any(p.type == "esp" for p in partitions)
+    if partitions and not esp and context.config.bootable == ConfigFeature.enabled:
+        die("A BIOS bootable image with grub was requested but no ESP partition was configured")
+
+    root = any(p.type.startswith("root") or p.type.startswith("usr") for p in partitions)
+    if partitions and not root and context.config.bootable == ConfigFeature.enabled:
+        die("A BIOS bootable image with grub was requested but no root or usr partition was configured")
+
+    installed = True
+
+    for binary in ("mkimage", "bios-setup"):
+        if find_grub_binary(context.config, binary):
+            continue
+
+        if context.config.bootable == ConfigFeature.enabled:
+            die(f"A BIOS bootable image with grub was requested but {binary} was not found")
+
+        installed = False
+
+    return (have and bios and esp and root and installed) if partitions else have
+
+
+def find_grub_directory(context: Context, *, target: str) -> Optional[Path]:
+    for d in ("usr/lib/grub", "usr/share/grub2"):
+        if (p := context.root / d / target).exists() and any(p.iterdir()):
+            return p
+
+    return None
+
+
+def find_grub_binary(config: Config, binary: str) -> Optional[Path]:
+    assert "grub" not in binary
+
+    # Debian has a bespoke setup where if only grub-pc-bin is installed, grub-bios-setup is installed in
+    # /usr/lib/i386-pc instead of in /usr/bin. Let's take that into account and look for binaries in
+    # /usr/lib/grub/i386-pc as well.
+    return config.find_binary(f"grub-{binary}", f"grub2-{binary}", f"/usr/lib/grub/i386-pc/grub-{binary}")
+
+
+def prepare_grub_config(context: Context) -> Optional[Path]:
+    config = context.root / "efi" / context.config.distribution.grub_prefix() / "grub.cfg"
+    with umask(~0o700):
+        config.parent.mkdir(exist_ok=True)
+
+    # For some unknown reason, if we don't set the timeout to zero, grub never leaves its menu, so we default
+    # to a zero timeout, but only if the config file hasn't been provided by the user.
+    if not config.exists():
+        with umask(~0o600), config.open("w") as f:
+            f.write("set timeout=0\n")
+
+    if want_grub_efi(context):
+        # Signed EFI grub shipped by distributions reads its configuration from /EFI/<distribution>/grub.cfg (except
+        # in OpenSUSE) in the ESP so let's put a shim there to redirect to the actual configuration file.
+        if context.config.distribution == Distribution.opensuse:
+            earlyconfig = context.root / "efi/EFI/BOOT/grub.cfg"
+        else:
+            earlyconfig = context.root / "efi/EFI" / context.config.distribution.name / "grub.cfg"
+
+        with umask(~0o700):
+            earlyconfig.parent.mkdir(parents=True, exist_ok=True)
+
+        # Read the actual config file from the root of the ESP.
+        earlyconfig.write_text(f"configfile /{context.config.distribution.grub_prefix()}/grub.cfg\n")
+
+    return config
+
+
+def grub_mkimage(
+    context: Context,
+    *,
+    target: str,
+    modules: Sequence[str] = (),
+    output: Optional[Path] = None,
+    sbat: Optional[Path] = None,
+) -> None:
+    mkimage = find_grub_binary(context.config, "mkimage")
+    assert mkimage
+
+    directory = find_grub_directory(context, target=target)
+    assert directory
+
+    with (
+        complete_step(f"Generating grub image for {target}"),
+        tempfile.NamedTemporaryFile("w", prefix="grub-early-config") as earlyconfig
+    ):
+        earlyconfig.write(
+            textwrap.dedent(
+                f"""\
+                search --no-floppy --set=root --file /{context.config.distribution.grub_prefix()}/grub.cfg
+                set prefix=($root)/{context.config.distribution.grub_prefix()}
+                """
+            )
+        )
+
+        earlyconfig.flush()
+
+        run(
+            [
+                mkimage,
+                "--directory", "/grub",
+                "--config", earlyconfig.name,
+                "--prefix", f"/{context.config.distribution.grub_prefix()}",
+                "--output", output or ("/grub/core.img"),
+                "--format", target,
+                *(["--sbat", str(sbat)] if sbat else []),
+                *(["--disable-shim-lock"] if context.config.shim_bootloader == ShimBootloader.none else []),
+                "cat",
+                "cmp",
+                "div",
+                "echo",
+                "fat",
+                "hello",
+                "help",
+                "keylayouts",
+                "linux",
+                "loadenv",
+                "ls",
+                "normal",
+                "part_gpt",
+                "read",
+                "reboot",
+                "search_fs_file",
+                "search",
+                "sleep",
+                "test",
+                "tr",
+                "true",
+                *modules,
+            ],
+            sandbox=context.sandbox(
+                binary=mkimage,
+                options=[
+                    "--bind", directory, "/grub",
+                    "--ro-bind", earlyconfig.name, earlyconfig.name,
+                    *(["--bind", str(output.parent), str(output.parent)] if output else []),
+                    *(["--ro-bind", str(sbat), str(sbat)] if sbat else []),
+                ],
+            ),
+        )
+
+
+def find_signed_grub_image(context: Context) -> Optional[Path]:
+    arch = context.config.architecture.to_efi()
+
+    patterns = [
+        f"usr/lib/grub/*-signed/grub{arch}.efi.signed", # Debian/Ubuntu
+        f"boot/efi/EFI/*/grub{arch}.efi", # Fedora/CentOS
+        "usr/share/efi/*/grub.efi", # OpenSUSE
+    ]
+
+    for p in flatten(context.root.glob(pattern) for pattern in patterns):
+        if p.is_symlink() and p.readlink().is_absolute():
+            logging.warning(f"Ignoring signed grub EFI binary which is an absolute path to {p.readlink()}")
+            continue
+
+        return p
+
+    return None
+
+
+def python_binary(config: Config, *, binary: Optional[PathString]) -> PathString:
+    tools = (
+        not binary or
+        not (path := config.find_binary(binary)) or
+        not any(path.is_relative_to(d) for d in config.extra_search_paths)
+    )
+
+    # If there's no tools tree, prefer the interpreter from MKOSI_INTERPRETER. If there is a tools
+    # tree, just use the default python3 interpreter.
+    exe = Path(sys.executable)
+    return "python3" if (tools and config.tools_tree) or not exe.is_relative_to("/usr") else exe
+
+
+def extract_pe_section(context: Context, binary: Path, section: str, output: Path) -> Path:
+    # When using a tools tree, we want to use the pefile module from the tools tree instead of requiring that
+    # python-pefile is installed on the host. So we execute python as a subprocess to make sure we load
+    # pefile from the tools tree if one is used.
+
+    # TODO: Use ignore_padding=True instead of length once we can depend on a newer pefile.
+    # TODO: Drop KeyError logic once we drop support for Ubuntu Jammy and sdmagic will always be available.
+    pefile = textwrap.dedent(
+        f"""\
+        import pefile
+        import sys
+        from pathlib import Path
+        pe = pefile.PE("{binary}", fast_load=True)
+        section = {{s.Name.decode().strip("\\0"): s for s in pe.sections}}.get("{section}")
+        if not section:
+            sys.exit(67)
+        sys.stdout.buffer.write(section.get_data(length=section.Misc_VirtualSize))
+        """
+    )
+
+    with open(output, "wb") as f:
+        result = run(
+            [python_binary(context.config, binary=None)],
+            input=pefile,
+            stdout=f,
+            sandbox=context.sandbox(
+                binary=python_binary(context.config, binary=None),
+                options=["--ro-bind", binary, binary],
+            ),
+            success_exit_status=(0, 67),
+        )
+        if result.returncode == 67:
+            raise KeyError(f"{section} section not found in {binary}")
+
+    return output
+
+
+def install_grub(context: Context) -> None:
+    if not want_grub_bios(context) and not want_grub_efi(context):
+        return
+
+    if want_grub_bios(context):
+        grub_mkimage(context, target="i386-pc", modules=("biosdisk",))
+
+    if want_grub_efi(context):
+        if context.config.shim_bootloader != ShimBootloader.none:
+            output = context.root / shim_second_stage_binary(context)
+        else:
+            output = context.root / efi_boot_binary(context)
+
+        with umask(~0o700):
+            output.parent.mkdir(parents=True, exist_ok=True)
+
+        if context.config.shim_bootloader == ShimBootloader.signed:
+            if not (signed := find_signed_grub_image(context)):
+                if context.config.bootable == ConfigFeature.enabled:
+                    die("Couldn't find a signed grub EFI binary installed in the image")
+
+                return
+
+            rel = output.relative_to(context.root)
+            log_step(f"Installing signed grub EFI binary from /{signed.relative_to(context.root)} to /{rel}")
+            shutil.copy2(signed, output)
+        else:
+            if context.config.secure_boot and context.config.shim_bootloader != ShimBootloader.none:
+                if not (signed := find_signed_grub_image(context)):
+                    die("Couldn't find a signed grub EFI binary installed in the image to extract SBAT from")
+
+                sbat = extract_pe_section(context, signed, ".sbat", context.workspace / "sbat")
+            else:
+                sbat = None
+
+            grub_mkimage(context, target="x86_64-efi", output=output, modules=("chain",), sbat=sbat)
+            if context.config.secure_boot:
+                sign_efi_binary(context, output, output)
+
+    dst = context.root / "efi" / context.config.distribution.grub_prefix() / "fonts"
+    with umask(~0o700):
+        dst.mkdir(parents=True, exist_ok=True)
+
+    for d in ("grub", "grub2"):
+        unicode = context.root / "usr/share" / d / "unicode.pf2"
+        if unicode.exists():
+            shutil.copy2(unicode, dst)
+
+
+def grub_bios_setup(context: Context, partitions: Sequence[Partition]) -> None:
+    if not want_grub_bios(context, partitions):
+        return
+
+    setup = find_grub_binary(context.config, "bios-setup")
+    assert setup
+
+    directory = find_grub_directory(context, target="i386-pc")
+    assert directory
+
+    with (
+        complete_step("Installing grub boot loader for BIOS…"),
+        tempfile.NamedTemporaryFile(mode="w") as mountinfo,
+    ):
+        # grub-bios-setup insists on being able to open the root device that --directory is located on, which
+        # needs root privileges. However, it only uses the root device when it is unable to embed itself in the
+        # bios boot partition. To make installation work unprivileged, we trick grub to think that the root
+        # device is our image by mounting over its /proc/self/mountinfo file (where it gets its information from)
+        # with our own file correlating the root directory to our image file.
+        mountinfo.write(f"1 0 1:1 / / - fat {context.staging / context.config.output_with_format}\n")
+        mountinfo.flush()
+
+        run(
+            [
+                setup,
+                "--directory", "/grub",
+                context.staging / context.config.output_with_format,
+            ],
+            sandbox=context.sandbox(
+                binary=setup,
+                options=[
+                    "--bind", directory, "/grub",
+                    "--bind", context.staging, context.staging,
+                    "--bind", mountinfo.name, "/proc/self/mountinfo",
+                ],
+            ),
+        )
+
+
+def efi_boot_binary(context: Context) -> Path:
+    arch = context.config.architecture.to_efi()
+    assert arch
+    return Path(f"efi/EFI/BOOT/BOOT{arch.upper()}.EFI")
+
+
+def shim_second_stage_binary(context: Context) -> Path:
+    arch = context.config.architecture.to_efi()
+    assert arch
+    if context.config.distribution == Distribution.opensuse:
+        return Path("efi/EFI/BOOT/grub.EFI")
+    else:
+        return Path(f"efi/EFI/BOOT/grub{arch}.EFI")
+
+
+def certificate_common_name(context: Context, certificate: Path) -> str:
+    output = run(
+        [
+            "openssl",
+            "x509",
+            "-noout",
+            "-subject",
+            "-nameopt", "multiline",
+            "-in", certificate,
+        ],
+        stdout=subprocess.PIPE,
+        sandbox=context.sandbox(binary="openssl", options=["--ro-bind", certificate, certificate]),
+    ).stdout
+
+    for line in output.splitlines():
+        if not line.strip().startswith("commonName"):
+            continue
+
+        _, sep, value = line.partition("=")
+        if not sep:
+            die("Missing '=' delimiter in openssl output")
+
+        return value.strip()
+
+    die(f"Certificate {certificate} is missing Common Name")
+
+
+
+def pesign_prepare(context: Context) -> None:
+    assert context.config.secure_boot_key
+    assert context.config.secure_boot_certificate
+
+    if (context.workspace / "pesign").exists():
+        return
+
+    (context.workspace / "pesign").mkdir()
+
+    # pesign takes a certificate directory and a certificate common name as input arguments, so we have
+    # to transform our input key and cert into that format. Adapted from
+    # https://www.mankier.com/1/pesign#Examples-Signing_with_the_certificate_and_private_key_in_individual_files
+    with open(context.workspace / "secure-boot.p12", "wb") as f:
+        run(
+            [
+                "openssl",
+                "pkcs12",
+                "-export",
+                # Arcane incantation to create a pkcs12 certificate without a password.
+                "-keypbe", "NONE",
+                "-certpbe", "NONE",
+                "-nomaciter",
+                "-passout", "pass:",
+                "-inkey", context.config.secure_boot_key,
+                "-in", context.config.secure_boot_certificate,
+            ],
+            stdout=f,
+            sandbox=context.sandbox(
+                binary="openssl",
+                options=[
+                    "--ro-bind", context.config.secure_boot_key, context.config.secure_boot_key,
+                    "--ro-bind", context.config.secure_boot_certificate, context.config.secure_boot_certificate,
+                ],
+            ),
+        )
+
+    (context.workspace / "pesign").mkdir(exist_ok=True)
+
+    run(
+        [
+            "pk12util",
+            "-K", "",
+            "-W", "",
+            "-i", context.workspace / "secure-boot.p12",
+            "-d", context.workspace / "pesign",
+        ],
+        sandbox=context.sandbox(
+            binary="pk12util",
+            options=[
+                "--ro-bind", context.workspace / "secure-boot.p12", context.workspace / "secure-boot.p12",
+                "--ro-bind", context.workspace / "pesign", context.workspace / "pesign",
+            ],
+        ),
+    )
+
+
+def sign_efi_binary(context: Context, input: Path, output: Path) -> Path:
+    assert context.config.secure_boot_key
+    assert context.config.secure_boot_certificate
+
+    if (
+        context.config.secure_boot_sign_tool == SecureBootSignTool.sbsign or
+        context.config.secure_boot_sign_tool == SecureBootSignTool.auto and
+        context.config.find_binary("sbsign") is not None
+    ):
+        with tempfile.NamedTemporaryFile(dir=output.parent, prefix=output.name) as f:
+            os.chmod(f.name, stat.S_IMODE(input.stat().st_mode))
+            cmd: list[PathString] = [
+                "sbsign",
+                "--key", context.config.secure_boot_key,
+                "--cert", context.config.secure_boot_certificate,
+                "--output", "/dev/stdout",
+            ]
+            options: list[PathString] = [
+                "--ro-bind", context.config.secure_boot_certificate, context.config.secure_boot_certificate,
+                "--ro-bind", input, input,
+            ]
+            if context.config.secure_boot_key_source.type == KeySource.Type.engine:
+                cmd += ["--engine", context.config.secure_boot_key_source.source]
+            if context.config.secure_boot_key.exists():
+                options += ["--ro-bind", context.config.secure_boot_key, context.config.secure_boot_key]
+            cmd += [input]
+            run(
+                cmd,
+                stdout=f,
+                sandbox=context.sandbox(
+                    binary="sbsign",
+                    options=options,
+                    devices=context.config.secure_boot_key_source.type != KeySource.Type.file,
+                )
+            )
+            output.unlink(missing_ok=True)
+            os.link(f.name, output)
+    elif (
+        context.config.secure_boot_sign_tool == SecureBootSignTool.pesign or
+        context.config.secure_boot_sign_tool == SecureBootSignTool.auto and
+        context.config.find_binary("pesign") is not None
+    ):
+        pesign_prepare(context)
+        with tempfile.NamedTemporaryFile(dir=output.parent, prefix=output.name) as f:
+            os.chmod(f.name, stat.S_IMODE(input.stat().st_mode))
+            run(
+                [
+                    "pesign",
+                    "--certdir", context.workspace / "pesign",
+                    "--certificate", certificate_common_name(context, context.config.secure_boot_certificate),
+                    "--sign",
+                    "--force",
+                    "--in", input,
+                    "--out", "/dev/stdout",
+                ],
+                stdout=f,
+                sandbox=context.sandbox(
+                    binary="pesign",
+                    options=[
+                        "--ro-bind", context.workspace / "pesign", context.workspace / "pesign",
+                        "--ro-bind", input, input,
+                    ]
+                ),
+            )
+            output.unlink(missing_ok=True)
+            os.link(f.name, output)
+    else:
+        die("One of sbsign or pesign is required to use SecureBoot=")
+
+    return output
+
+
+def find_and_install_shim_binary(
+    context: Context,
+    name: str,
+    signed: Sequence[str],
+    unsigned: Sequence[str],
+    output: Path,
+) -> None:
+    if context.config.shim_bootloader == ShimBootloader.signed:
+        for pattern in signed:
+            for p in context.root.glob(pattern):
+                if p.is_symlink() and p.readlink().is_absolute():
+                    logging.warning(f"Ignoring signed {name} EFI binary which is an absolute path to {p.readlink()}")
+                    continue
+
+                rel = p.relative_to(context.root)
+                if (context.root / output).is_dir():
+                    output /= rel.name
+
+                log_step(f"Installing signed {name} EFI binary from /{rel} to /{output}")
+                shutil.copy2(p, context.root / output)
+                return
+
+        if context.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 context.root.glob(pattern):
+                if p.is_symlink() and p.readlink().is_absolute():
+                    logging.warning(f"Ignoring unsigned {name} EFI binary which is an absolute path to {p.readlink()}")
+                    continue
+
+                rel = p.relative_to(context.root)
+                if (context.root / output).is_dir():
+                    output /= rel.name
+
+                if context.config.secure_boot:
+                    log_step(f"Signing and installing unsigned {name} EFI binary from /{rel} to /{output}")
+                    sign_efi_binary(context, p, context.root / output)
+                else:
+                    log_step(f"Installing unsigned {name} EFI binary /{rel} to /{output}")
+                    shutil.copy2(p, context.root / output)
+
+                return
+
+        if context.config.bootable == ConfigFeature.enabled:
+            die(f"Couldn't find unsigned {name} EFI binary installed in the image")
+
+
+def gen_kernel_images(context: Context) -> Iterator[tuple[str, Path]]:
+    if not (context.root / "usr/lib/modules").exists():
+        return
+
+    for kver in sorted(
+        (k for k in (context.root / "usr/lib/modules").iterdir() if k.is_dir()),
+        key=lambda k: GenericVersion(k.name),
+        reverse=True
+    ):
+        # Make sure we look for anything that remotely resembles vmlinuz, as
+        # the arch specific install scripts in the kernel source tree sometimes
+        # do weird stuff. But let's make sure we're not returning UKIs as the
+        # UKI on Fedora is named vmlinuz-virt.efi. Also look for uncompressed
+        # images (vmlinux) as some architectures ship those. Prefer vmlinuz if
+        # both are present.
+        for kimg in kver.glob("vmlinuz*"):
+            if KernelType.identify(context.config, kimg) != KernelType.uki:
+                yield kver.name, kimg
+                break
+        else:
+            for kimg in kver.glob("vmlinux*"):
+                if KernelType.identify(context.config, kimg) != KernelType.uki:
+                    yield kver.name, kimg
+                    break
+
+
+def install_systemd_boot(context: Context) -> None:
+    if not want_efi(context.config):
+        return
+
+    if context.config.bootloader != Bootloader.systemd_boot:
+        return
+
+    if not any(gen_kernel_images(context)) and context.config.bootable == ConfigFeature.auto:
+        return
+
+    if not context.config.find_binary("bootctl"):
+        if context.config.bootable == ConfigFeature.enabled:
+            die("An EFI bootable image with systemd-boot was requested but bootctl was not found")
+        return
+
+    directory = context.root / "usr/lib/systemd/boot/efi"
+    signed = context.config.shim_bootloader == ShimBootloader.signed
+    if not directory.glob("*.efi.signed" if signed else "*.efi"):
+        if context.config.bootable == ConfigFeature.enabled:
+            die(f"An EFI bootable image with systemd-boot was requested but a {'signed ' if signed else ''}"
+                f"systemd-boot binary was not found at {directory.relative_to(context.root)}")
+        return
+
+    if context.config.secure_boot and not signed:
+        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(context, input, output)
+
+    with complete_step("Installing systemd-boot…"):
+        run(
+            ["bootctl", "install", "--root=/buildroot", "--all-architectures", "--no-variables"],
+            env={"SYSTEMD_ESP_PATH": "/efi", "SYSTEMD_XBOOTLDR_PATH": "/boot"},
+            sandbox=context.sandbox(binary="bootctl", options=["--bind", context.root, "/buildroot"]),
+        )
+        # TODO: Use --random-seed=no when we can depend on systemd 256.
+        Path(context.root / "efi/loader/random-seed").unlink(missing_ok=True)
+
+        if context.config.shim_bootloader != ShimBootloader.none:
+            shutil.copy2(
+                context.root / f"efi/EFI/systemd/systemd-boot{context.config.architecture.to_efi()}.efi",
+                context.root / shim_second_stage_binary(context),
+            )
+
+    if context.config.secure_boot and context.config.secure_boot_auto_enroll:
+        assert context.config.secure_boot_key
+        assert context.config.secure_boot_certificate
+
+        with complete_step("Setting up secure boot auto-enrollment…"):
+            keys = context.root / "efi/loader/keys/auto"
+            with umask(~0o700):
+                keys.mkdir(parents=True, exist_ok=True)
+
+            # sbsiglist expects a DER certificate.
+            with umask(~0o600), open(context.workspace / "mkosi.der", "wb") as f:
+                run(
+                    [
+                        "openssl",
+                        "x509",
+                        "-outform", "DER",
+                        "-in", context.config.secure_boot_certificate,
+                    ],
+                    stdout=f,
+                    sandbox=context.sandbox(
+                        binary="openssl",
+                        options=[
+                            "--ro-bind",
+                            context.config.secure_boot_certificate,
+                            context.config.secure_boot_certificate,
+                        ],
+                    ),
+                )
+
+            with umask(~0o600), open(context.workspace / "mkosi.esl", "wb") as f:
+                run(
+                    [
+                        "sbsiglist",
+                        "--owner", str(uuid.uuid4()),
+                        "--type", "x509",
+                        "--output", "/dev/stdout",
+                        context.workspace / "mkosi.der",
+                    ],
+                    stdout=f,
+                    sandbox=context.sandbox(
+                        binary="sbsiglist",
+                        options=["--ro-bind", context.workspace / "mkosi.der", context.workspace / "mkosi.der"]
+                    ),
+                )
+
+            # We reuse the key for all secure boot databases to keep things simple.
+            for db in ["PK", "KEK", "db"]:
+                with umask(~0o600), open(keys / f"{db}.auth", "wb") as f:
+                    cmd: list[PathString] = [
+                        "sbvarsign",
+                        "--attr",
+                            "NON_VOLATILE,BOOTSERVICE_ACCESS,RUNTIME_ACCESS,TIME_BASED_AUTHENTICATED_WRITE_ACCESS",
+                        "--key", context.config.secure_boot_key,
+                        "--cert", context.config.secure_boot_certificate,
+                        "--output", "/dev/stdout",
+                    ]
+                    options: list[PathString] = [
+                        "--ro-bind", context.config.secure_boot_certificate, context.config.secure_boot_certificate,
+                        "--ro-bind", context.workspace / "mkosi.esl", context.workspace / "mkosi.esl",
+                    ]
+                    if context.config.secure_boot_key_source.type == KeySource.Type.engine:
+                        cmd += ["--engine", context.config.secure_boot_key_source.source]
+                    if context.config.secure_boot_key.exists():
+                        options += ["--ro-bind", context.config.secure_boot_key, context.config.secure_boot_key]
+                    cmd += [db, context.workspace / "mkosi.esl"]
+                    run(
+                        cmd,
+                        stdout=f,
+                        sandbox=context.sandbox(
+                            binary="sbvarsign",
+                            options=options,
+                            devices=context.config.secure_boot_key_source.type != KeySource.Type.file,
+                        ),
+                    )
+
+
+def install_shim(context: Context) -> None:
+    if not want_efi(context.config):
+        return
+
+    if context.config.shim_bootloader == ShimBootloader.none:
+        return
+
+    if not any(gen_kernel_images(context)) and context.config.bootable == ConfigFeature.auto:
+        return
+
+    dst = efi_boot_binary(context)
+    with umask(~0o700):
+        (context.root / dst).parent.mkdir(parents=True, exist_ok=True)
+
+    arch = context.config.architecture.to_efi()
+
+    signed = [
+        f"usr/lib/shim/shim{arch}.efi.signed.latest", # Ubuntu
+        f"usr/lib/shim/shim{arch}.efi.signed", # Debian
+        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(context, "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(context, "mok", signed, unsigned, dst.parent)