diff --git a/doc/build/signing/index.rst b/doc/build/signing/index.rst index 0870add5768c8..87de7a65cae09 100644 --- a/doc/build/signing/index.rst +++ b/doc/build/signing/index.rst @@ -36,9 +36,12 @@ Notes on the above commands: For more information on these and other related configuration options, see: -- ``SB_CONFIG_BOOTLOADER_MCUBOOT``: build the application for loading by MCUboot -- ``SB_CONFIG_BOOT_SIGNATURE_KEY_FILE``: the key file to use when signing images. If you have - your own key, change this appropriately +- :kconfig:option:`SB_CONFIG_BOOTLOADER_MCUBOOT`: build the application for loading by MCUboot +- :kconfig:option:`SB_CONFIG_BOOT_SIGNATURE_KEY_FILE`: the key file, or a comma-separated list of + key files, to use when signing images. If you have your own key, change this appropriately. + When a list is given, MCUboot embeds the public half of every key and accepts an image + signed with any of them; the first entry also signs the application, and every entry + past the first must be a public-only PEM of the same signature type - :kconfig:option:`CONFIG_MCUBOOT_EXTRA_IMGTOOL_ARGS`: optional additional command line arguments for ``imgtool`` - :kconfig:option:`CONFIG_MCUBOOT_GENERATE_CONFIRMED_IMAGE`: also generate a confirmed image, @@ -46,6 +49,9 @@ For more information on these and other related configuration options, see: - On Windows, if you get "Access denied" issues, the recommended fix is to run ``pip3 install imgtool``, then retry with a pristine build directory. +For a worked example of a multi-key bootloader exercised end-to-end under QEMU, see the +:zephyr_file:`tests/boot/mcuboot_multiple_keys` test. + If your ``west flash`` :ref:`runner ` uses an image format supported by imgtool, you should see something like this on your device's serial console when you run ``west flash -d build-hello-signed``: diff --git a/doc/releases/release-notes-4.5.rst b/doc/releases/release-notes-4.5.rst index baac81004c90e..07580da5a3a94 100644 --- a/doc/releases/release-notes-4.5.rst +++ b/doc/releases/release-notes-4.5.rst @@ -357,6 +357,16 @@ Other notable changes * Removed the ``samples/net/wifi/test_certs/rsa2k`` enterprise test certificates (DES-encrypted private keys). Use ``rsa2k_no_des`` instead. +* MCUboot + + * :kconfig:option:`SB_CONFIG_BOOT_SIGNATURE_KEY_FILE` now accepts a comma-separated list of + key files, embedding the public half of each in the MCUboot bootloader. When more + than one key is given, MCUboot accepts an image signed with any of them -- the + typical use is a development bootloader that boots both development- and + production-signed images, while production bootloaders embed only the production + key. The first entry is the key the application is signed with and the rest are + verification-only public keys. See :ref:`build-signing`. + .. Any more descriptive subsystem or driver changes. Do you really want to write a paragraph or is it enough to link to the api/driver/Kconfig/board page above? diff --git a/modules/Kconfig.mcuboot b/modules/Kconfig.mcuboot index 3d81f88ab6096..041222dd50ade 100644 --- a/modules/Kconfig.mcuboot +++ b/modules/Kconfig.mcuboot @@ -52,7 +52,7 @@ config MCUBOOT_SIGNATURE_KEY_FILE The existence of bin and hex files depends on CONFIG_BUILD_OUTPUT_BIN and CONFIG_BUILD_OUTPUT_HEX. - This option should contain a path to the same file as the + This option should contain a path to the same file as the (first) BOOT_SIGNATURE_KEY_FILE option in your MCUboot .config. The path may be absolute or relative to the west workspace topdir. (The MCUboot config option is used for the MCUboot bootloader image; this option is diff --git a/share/sysbuild/cmake/modules/sysbuild_extensions.cmake b/share/sysbuild/cmake/modules/sysbuild_extensions.cmake index 04ffb738f7277..84b3f39059e23 100644 --- a/share/sysbuild/cmake/modules/sysbuild_extensions.cmake +++ b/share/sysbuild/cmake/modules/sysbuild_extensions.cmake @@ -902,6 +902,50 @@ function(set_config_int image setting value) set_property(TARGET ${image} APPEND_STRING PROPERTY CONFIG "${setting}=${value}\n") endfunction() +# Usage: +# sysbuild_mcuboot_resolve_signature_key_files( ) +# +# Normalize a BOOT_SIGNATURE_KEY_FILE value -- a single path or a comma-separated +# list -- for forwarding to an image: strip surrounding whitespace from each +# entry and warn on (and skip) an empty entry (a stray comma). Entries are +# forwarded verbatim for each consumer to resolve, and stay comma-separated +# (';' would not survive set_config_string() or -D overrides). +function(sysbuild_mcuboot_resolve_signature_key_files out_var key_files) + string(REPLACE "," ";" key_list "${key_files}") + set(resolved "") + # IN LISTS keeps empty elements (unlike unquoted expansion, which drops them), + # so a stray/leading/trailing/double comma is warned about below instead of + # silently collapsing the key set. + foreach(key_path IN LISTS key_list) + string(STRIP "${key_path}" key_path) + if(key_path STREQUAL "") + message(WARNING + "Empty entry in a BOOT_SIGNATURE_KEY_FILE list (\"${key_files}\"); " + "check for a stray, leading, or trailing comma." + ) + continue() + endif() + list(APPEND resolved "${key_path}") + endforeach() + string(REPLACE ";" "," resolved "${resolved}") + set(${out_var} "${resolved}" PARENT_SCOPE) +endfunction() + +# Usage: +# sysbuild_mcuboot_application_signature_key_file( ) +# +# Set to the key the application is signed with: the first entry of +# the resolved list (the MCUboot bootloader embeds the public half +# of every entry; the application is signed with exactly one). +function(sysbuild_mcuboot_application_signature_key_file out_var key_files) + sysbuild_mcuboot_resolve_signature_key_files(resolved "${key_files}") + if(NOT resolved STREQUAL "") + string(REPLACE "," ";" resolved "${resolved}") + list(GET resolved 0 resolved) + endif() + set(${out_var} "${resolved}" PARENT_SCOPE) +endfunction() + # Usage: # sysbuild_add_subdirectory( []) # diff --git a/share/sysbuild/image_configurations/FIRMWARE_LOADER_image_default.cmake b/share/sysbuild/image_configurations/FIRMWARE_LOADER_image_default.cmake index c4edc7e3daf56..24b98c96b1594 100644 --- a/share/sysbuild/image_configurations/FIRMWARE_LOADER_image_default.cmake +++ b/share/sysbuild/image_configurations/FIRMWARE_LOADER_image_default.cmake @@ -6,8 +6,12 @@ # on a firmware updater image. set_config_bool(${ZCMAKE_APPLICATION} CONFIG_BOOTLOADER_MCUBOOT "${SB_CONFIG_BOOTLOADER_MCUBOOT}") + +sysbuild_mcuboot_application_signature_key_file( + application_signature_key_file "${SB_CONFIG_BOOT_SIGNATURE_KEY_FILE}" +) set_config_string(${ZCMAKE_APPLICATION} CONFIG_MCUBOOT_SIGNATURE_KEY_FILE - "${SB_CONFIG_BOOT_SIGNATURE_KEY_FILE}" + "${application_signature_key_file}" ) set_config_string(${ZCMAKE_APPLICATION} CONFIG_MCUBOOT_ENCRYPTION_KEY_FILE "${SB_CONFIG_BOOT_ENCRYPTION_KEY_FILE}" diff --git a/share/sysbuild/image_configurations/MAIN_image_default.cmake b/share/sysbuild/image_configurations/MAIN_image_default.cmake index b997fcbb1a7e8..3a4ce94bcf99b 100644 --- a/share/sysbuild/image_configurations/MAIN_image_default.cmake +++ b/share/sysbuild/image_configurations/MAIN_image_default.cmake @@ -6,8 +6,12 @@ # on the main Zephyr image. set_config_bool(${ZCMAKE_APPLICATION} CONFIG_BOOTLOADER_MCUBOOT "${SB_CONFIG_BOOTLOADER_MCUBOOT}") + +sysbuild_mcuboot_application_signature_key_file( + application_signature_key_file "${SB_CONFIG_BOOT_SIGNATURE_KEY_FILE}" +) set_config_string(${ZCMAKE_APPLICATION} CONFIG_MCUBOOT_SIGNATURE_KEY_FILE - "${SB_CONFIG_BOOT_SIGNATURE_KEY_FILE}" + "${application_signature_key_file}" ) set_config_string(${ZCMAKE_APPLICATION} CONFIG_MCUBOOT_ENCRYPTION_KEY_FILE "${SB_CONFIG_BOOT_ENCRYPTION_KEY_FILE}" diff --git a/share/sysbuild/images/bootloader/CMakeLists.txt b/share/sysbuild/images/bootloader/CMakeLists.txt index e7c81321f15d6..e8659c27d25fa 100644 --- a/share/sysbuild/images/bootloader/CMakeLists.txt +++ b/share/sysbuild/images/bootloader/CMakeLists.txt @@ -17,7 +17,10 @@ if(SB_CONFIG_BOOTLOADER_MCUBOOT) # Link the footer size output of this MCUboot build with the main sysbuild image set_target_properties(${image} PROPERTIES MCUBOOT_FOOTER_UPDATE_IMAGES ${DEFAULT_IMAGE}) - set_config_string(${image} CONFIG_BOOT_SIGNATURE_KEY_FILE "${SB_CONFIG_BOOT_SIGNATURE_KEY_FILE}") + sysbuild_mcuboot_resolve_signature_key_files( + resolved_key_files "${SB_CONFIG_BOOT_SIGNATURE_KEY_FILE}" + ) + set_config_string(${image} CONFIG_BOOT_SIGNATURE_KEY_FILE "${resolved_key_files}") if(SB_CONFIG_MCUBOOT_DIRECT_XIP_GENERATE_VARIANT) ExternalZephyrVariantProject_Add( diff --git a/share/sysbuild/images/bootloader/Kconfig b/share/sysbuild/images/bootloader/Kconfig index 3be46b5fe4994..d6f81d891001f 100644 --- a/share/sysbuild/images/bootloader/Kconfig +++ b/share/sysbuild/images/bootloader/Kconfig @@ -203,13 +203,17 @@ config BOOT_SIGNATURE_TYPE_ED25519 endchoice config BOOT_SIGNATURE_KEY_FILE - string "Signing PEM key file" if !BOOT_SIGNATURE_TYPE_NONE + string "Signing PEM key file(s) (or comma-separated list)" if !BOOT_SIGNATURE_TYPE_NONE default "$(ZEPHYR_MCUBOOT_MODULE_DIR)/root-ec-p256.pem" if BOOT_SIGNATURE_TYPE_ECDSA_P256 default "$(ZEPHYR_MCUBOOT_MODULE_DIR)/root-ed25519.pem" if BOOT_SIGNATURE_TYPE_ED25519 default "$(ZEPHYR_MCUBOOT_MODULE_DIR)/root-rsa-2048.pem" if BOOT_SIGNATURE_TYPE_RSA default "" help - Absolute path to signing key file to use with MCUBoot. + Path to the signing key file, or a comma-separated list of key files + to embed more than one verification key in the bootloader (an image + signed with any of them is accepted). The first entry also signs the + application; every later entry is verification-only and must be a + public-only PEM of the same signature type. config SUPPORT_BOOT_ENCRYPTION bool diff --git a/tests/boot/mcuboot_multiple_keys/CMakeLists.txt b/tests/boot/mcuboot_multiple_keys/CMakeLists.txt new file mode 100644 index 0000000000000..cde286a687414 --- /dev/null +++ b/tests/boot/mcuboot_multiple_keys/CMakeLists.txt @@ -0,0 +1,10 @@ +# Copyright (c) 2026 Intercreate, Inc. +# +# SPDX-License-Identifier: Apache-2.0 + +cmake_minimum_required(VERSION 3.20.0) + +find_package(Zephyr REQUIRED HINTS $ENV{ZEPHYR_BASE}) +project(mcuboot_multiple_keys) + +target_sources(app PRIVATE src/main.c) diff --git a/tests/boot/mcuboot_multiple_keys/README.rst b/tests/boot/mcuboot_multiple_keys/README.rst new file mode 100644 index 0000000000000..4a3bb448cd90f --- /dev/null +++ b/tests/boot/mcuboot_multiple_keys/README.rst @@ -0,0 +1,36 @@ +MCUboot Multiple Signing Keys +############################# + +Runtime acceptance test for an MCUboot bootloader built with more than one +verification key: a single bootloader embeds two keys and boots an application +signed by *either* one, exercised entirely under QEMU. + +:kconfig:option:`SB_CONFIG_BOOT_SIGNATURE_KEY_FILE` takes a comma-separated list +of keys. MCUboot embeds the public half of each; the application is signed with +the first. This test models a *development* bootloader that trusts both a +development and a production key, using keys shipped with MCUboot: + +* ``key_id 0`` -- ``root-ed25519.pem``, the development key whose private half + signs the application in the default build; +* ``key_id 1`` -- ``root-ed25519-2-pub.pem``, the production key's public half + only: the bootloader trusts production-signed images without ever holding the + production private key. + +The two scenarios differ only in how the application is signed: + +* **key0** boots the application as built -- signed with the development key. +* **key1** re-signs the built application with the production private key + (``root-ed25519-2.pem``) *after* the build and boots that, modelling a + production custodian who signs the release binary out-of-band. The re-sign + reuses the build's own imgtool invocation; see ``pytest/test_multiple_keys.py``. + +How the QEMU Test Works +******************************* + +A ``harness: pytest`` test (``pytest/test_multiple_keys.py``) starts QEMU with +two ``-device loader`` entries -- MCUboot's hex and the signed application +preloaded into slot0 -- and reads the console. With +:kconfig:option:`CONFIG_MCUBOOT_LOG_LEVEL_DBG` MCUboot prints +``bootutil_verify_sig: ED25519 key_id N``, the runtime proof of which embedded +key validated the image; the test asserts the expected ``key_id`` and that the +application banner then prints from the primary slot. diff --git a/tests/boot/mcuboot_multiple_keys/boards/mps2_an385.conf b/tests/boot/mcuboot_multiple_keys/boards/mps2_an385.conf new file mode 100644 index 0000000000000..9e268d6bbda2b --- /dev/null +++ b/tests/boot/mcuboot_multiple_keys/boards/mps2_an385.conf @@ -0,0 +1,12 @@ +# Copyright (c) 2026 Intercreate, Inc. +# +# SPDX-License-Identifier: Apache-2.0 + +CONFIG_FLASH=y +CONFIG_FLASH_SIMULATOR=y + +CONFIG_RETAINED_MEM=y +CONFIG_RETENTION=y +CONFIG_RETAINED_MEM_ZEPHYR_RAM=y +CONFIG_RETENTION_BOOTLOADER_INFO=y +CONFIG_RETENTION_BOOTLOADER_INFO_TYPE_MCUBOOT=y diff --git a/tests/boot/mcuboot_multiple_keys/boards/mps2_an385.overlay b/tests/boot/mcuboot_multiple_keys/boards/mps2_an385.overlay new file mode 100644 index 0000000000000..5f3bf3e0c5826 --- /dev/null +++ b/tests/boot/mcuboot_multiple_keys/boards/mps2_an385.overlay @@ -0,0 +1,106 @@ +/* + * Copyright (c) 2026 Intercreate, Inc. + * + * SPDX-License-Identifier: Apache-2.0 + * + * mps2/an385 memory map for the MCUboot RAM_LOAD fixture, and the single + * source of the flash-simulator layout: sysbuild/mcuboot.overlay includes + * this file so both images agree on the partition table. + * + * This is the application image. MCUboot copies slot0 into the sram0 exec + * region defined here and jumps to it. sram0 is declared before the FlashSim + * and RetainedMem regions (also mmio-sram) so it stays mmio-sram instance 0. + * The image slots use zephyr,mapped-partition, so flash_sim0's inherited + * fixed-partitions node is deleted first. + * + * Adapted from github.com/intercreate/smp-server-fixtures + */ + +/delete-node/ &sram0; +/delete-node/ &{/sim_flash_controller/flash_sim@0/partitions}; + +/ { + chosen { + zephyr,flash = &flash0; + zephyr,console = &uart0; + zephyr,bootloader-info = &boot_info0; + + /delete-property/ zephyr,code-partition; + }; + + sram0: memory@20240000 { + compatible = "mmio-sram"; + reg = <0x20240000 0x001bf000>; + }; + + flash_sim_region: memory@20040000 { + compatible = "zephyr,memory-region", "mmio-sram"; + reg = <0x20040000 0x00200000>; /* 2 MB sim-flash backing store */ + ranges = <0x0 0x20040000 0x00200000>; + zephyr,memory-region = "FlashSim"; + status = "okay"; + #address-cells = <1>; + #size-cells = <1>; + }; + + sram_retained: memory@203ffc00 { + compatible = "zephyr,memory-region", "mmio-sram"; + reg = <0x203ffc00 0x00000400>; + ranges = <0x0 0x203ffc00 0x00000400>; + zephyr,memory-region = "RetainedMem"; + status = "okay"; + #address-cells = <1>; + #size-cells = <1>; + + retainedmem { + compatible = "zephyr,retained-ram"; + status = "okay"; + ranges; + #address-cells = <1>; + #size-cells = <1>; + + boot_info0: boot_info@0 { + compatible = "zephyr,retention"; + status = "okay"; + reg = <0x0 0x100>; + }; + }; + }; +}; + +&sim_flash_controller { + memory-region = <&flash_sim_region>; +}; + +&flash_sim0 { + reg = <0x00000000 0x00200000>; + erase-block-size = <4096>; + write-block-size = <4>; + #address-cells = <1>; + #size-cells = <1>; + ranges = <0x0 0x0 0x00200000>; + + partitions { + ranges; + #address-cells = <1>; + #size-cells = <1>; + + boot_partition: partition@0 { + compatible = "zephyr,mapped-partition"; + label = "mcuboot"; + reg = <0x00000000 0x00010000>; + }; + + slot0_partition: partition@10000 { + compatible = "zephyr,mapped-partition"; + label = "image-0"; + reg = <0x00010000 0x00080000>; + }; + + slot1_partition: partition@90000 { + compatible = "zephyr,mapped-partition"; + label = "image-1"; + reg = <0x00090000 0x00080000>; + }; + }; +}; diff --git a/tests/boot/mcuboot_multiple_keys/prj.conf b/tests/boot/mcuboot_multiple_keys/prj.conf new file mode 100644 index 0000000000000..b2a4ba591044e --- /dev/null +++ b/tests/boot/mcuboot_multiple_keys/prj.conf @@ -0,0 +1 @@ +# nothing here diff --git a/tests/boot/mcuboot_multiple_keys/pytest/conftest.py b/tests/boot/mcuboot_multiple_keys/pytest/conftest.py new file mode 100644 index 0000000000000..445a4b02260bf --- /dev/null +++ b/tests/boot/mcuboot_multiple_keys/pytest/conftest.py @@ -0,0 +1,39 @@ +# Copyright (c) 2026 Intercreate, Inc. +# +# SPDX-License-Identifier: Apache-2.0 + +import os +import sys + +import pytest + +# Add the directory to PYTHONPATH +zephyr_base = os.getenv("ZEPHYR_BASE") +if zephyr_base: + sys.path.insert( + 0, os.path.join(zephyr_base, "scripts", "pylib", "pytest-twister-harness", "src") + ) +else: + raise OSError("ZEPHYR_BASE environment variable is not set") + +pytest_plugins = [ + "twister_harness.plugin", +] + + +def pytest_addoption(parser: pytest.Parser) -> None: + parser.addoption( + "--expected-key-id", + type=int, + required=True, + help="key_id MCUboot is expected to validate the application against", + ) + parser.addoption( + "--resign-key", + default="", + help=( + "filename of a key (resolved against the build's signing-key " + "directory) to re-sign the application with before loading it, " + "modelling a production custodian signing the release binary" + ), + ) diff --git a/tests/boot/mcuboot_multiple_keys/pytest/test_multiple_keys.py b/tests/boot/mcuboot_multiple_keys/pytest/test_multiple_keys.py new file mode 100644 index 0000000000000..fd525b0d2efde --- /dev/null +++ b/tests/boot/mcuboot_multiple_keys/pytest/test_multiple_keys.py @@ -0,0 +1,163 @@ +# Copyright (c) 2026 Intercreate, Inc. +# +# SPDX-License-Identifier: Apache-2.0 + +""" +Boot a two-key MCUboot bootloader on the mps2/an385 QEMU machine and assert +which key_id validates the application. + +The application is linked for MCUboot RAM_LOAD and only boots once MCUboot has +validated it and copied it out of slot0, so standard twister cannot launch it: +the bootloader and a signed slot0 image are loaded into QEMU together. + +The application is signed at build time with the bootloader's first (development) +key. To exercise the second embedded key, --resign-key re-signs the built image +with a different key before loading it -- reusing the build's own imgtool +invocation, as a production custodian would sign the release binary out-of-band. +""" + +from __future__ import annotations + +import logging +import os +import re +import selectors +import shlex +import subprocess +import sys +import time +from collections.abc import Sequence +from pathlib import Path +from typing import Final, NamedTuple + +import pytest +import yaml # type: ignore[import-untyped] +from twister_harness import DeviceAdapter # type: ignore[import-not-found] + +ZEPHYR_BASE: Final = os.environ["ZEPHYR_BASE"] +sys.path.insert(0, os.path.join(ZEPHYR_BASE, "scripts", "pylib", "twister")) +from twisterlib.cmakecache import CMakeCache # type: ignore[import-not-found] # noqa: E402 + +logger: Final = logging.getLogger(__name__) + + +class DomainDirs(NamedTuple): + """Build directories of the two sysbuild domains this test launches.""" + + mcuboot: Path + app: Path + + +def domain_dirs(build_dir: Path) -> DomainDirs: + domains = yaml.safe_load((build_dir / "domains.yaml").read_text()) + by_name: Final[dict[str, Path]] = { + domain["name"]: Path(domain["build_dir"]) for domain in domains["domains"] + } + mcuboot: Final = by_name.pop("mcuboot") + if len(by_name) != 1: + raise RuntimeError( + f"expected one application domain besides mcuboot, got {sorted(by_name)}" + ) + (app,) = by_name.values() + return DomainDirs(mcuboot=mcuboot, app=app) + + +def _imgtool_sign_argv(app_build: Path) -> list[str]: + """The imgtool 'sign' command the build used, parsed from build.ninja so a + re-sign reuses the exact header, slot, alignment and version parameters.""" + ninja: Final = (app_build / "build.ninja").read_text() + for command in ( + segment.strip() + for line in ninja.splitlines() + if "imgtool" in line + for segment in line.split("&&") + ): + if "imgtool" in command and " sign " in command: + return shlex.split(command) + raise RuntimeError(f"no imgtool sign command found in {app_build / 'build.ninja'}") + + +def resign_application(app_build: Path, key_name: str) -> Path: + """Re-sign the built application with a different key and return the new + image. Models a production custodian signing the release binary out-of-band: + the build's imgtool command is reused verbatim except for the key (resolved + against the build's signing-key directory) and the output path.""" + argv: Final = _imgtool_sign_argv(app_build) + key: Final = argv.index("--key") + signing_key: Final = Path(argv[key + 1]).parent / key_name + output: Final = app_build / "zephyr" / "zephyr.signed.resigned.bin" + subprocess.run( + [*argv[: key + 1], str(signing_key), *argv[key + 2 : -1], str(output)], + check=True, + ) + return output + + +def qemu_command(dirs: DomainDirs, app_image: Path) -> tuple[str, ...]: + qemu: Final = CMakeCache.from_file(dirs.mcuboot / "CMakeCache.txt").get("QEMU") + assert qemu, "QEMU binary not found in MCUboot CMakeCache" + return ( + qemu, + "-cpu", + "cortex-m3", + "-machine", + "mps2-an385", + "-nographic", + "-device", + f"loader,file={dirs.mcuboot / 'zephyr' / 'zephyr.hex'}", + "-device", + f"loader,file={app_image},addr=0x20050000", + ) + + +def run_qemu(command: Sequence[str], patterns: Sequence[re.Pattern[str]], timeout: float) -> str: + """Run QEMU until every pattern has matched or the timeout elapses, then + terminate it and return the console output.""" + output = "" + with subprocess.Popen(command, stdout=subprocess.PIPE, stderr=subprocess.STDOUT) as proc: + assert proc.stdout is not None + fd: Final = proc.stdout.fileno() + selector = selectors.DefaultSelector() + selector.register(fd, selectors.EVENT_READ) + deadline: Final = time.monotonic() + timeout + try: + while not all(pattern.search(output) for pattern in patterns): + remaining = deadline - time.monotonic() + if remaining <= 0 or not selector.select(remaining): + break + chunk = os.read(fd, 4096) + if not chunk: + break + output += chunk.decode(errors="replace") + finally: + selector.close() + proc.terminate() + try: + proc.wait(timeout=5) + except subprocess.TimeoutExpired: + proc.kill() + return output + + +def test_multiple_keys(unlaunched_dut: DeviceAdapter, request: pytest.FixtureRequest) -> None: + expected_key_id: Final[int] = request.config.getoption("--expected-key-id") + resign_key: Final[str] = request.config.getoption("--resign-key") + + dirs: Final = domain_dirs(Path(unlaunched_dut.device_config.build_dir)) + app_image: Final = ( + resign_application(dirs.app, resign_key) + if resign_key + else dirs.app / "zephyr" / "zephyr.signed.bin" + ) + command: Final = qemu_command(dirs, app_image) + logger.info("Launching QEMU: %s", " ".join(command)) + + key_id: Final = re.compile(rf"bootutil_verify_sig: ED25519 key_id {expected_key_id}\b") + banner: Final = re.compile(r"Hello mcuboot multiple keys!") + output: Final = run_qemu(command, (key_id, banner), timeout=60.0) + logger.info("QEMU console:\n%s", output) + + assert key_id.search(output), ( + f"MCUboot did not validate the application against key_id {expected_key_id}" + ) + assert banner.search(output), "application did not boot from the primary slot" diff --git a/tests/boot/mcuboot_multiple_keys/src/main.c b/tests/boot/mcuboot_multiple_keys/src/main.c new file mode 100644 index 0000000000000..825fffd223ee3 --- /dev/null +++ b/tests/boot/mcuboot_multiple_keys/src/main.c @@ -0,0 +1,13 @@ +/* + * Copyright (c) 2026 Intercreate, Inc. + * + * SPDX-License-Identifier: Apache-2.0 + */ + +#include + +int main(void) +{ + printk("Hello mcuboot multiple keys! %s\n", CONFIG_BOARD); + return 0; +} diff --git a/tests/boot/mcuboot_multiple_keys/sysbuild.conf b/tests/boot/mcuboot_multiple_keys/sysbuild.conf new file mode 100644 index 0000000000000..c9860c0f3ba21 --- /dev/null +++ b/tests/boot/mcuboot_multiple_keys/sysbuild.conf @@ -0,0 +1,30 @@ +# Copyright (c) 2026 Intercreate, Inc. +# +# SPDX-License-Identifier: Apache-2.0 + +SB_CONFIG_BOOTLOADER_MCUBOOT=y +SB_CONFIG_BOOT_SIGNATURE_TYPE_ED25519=y + +# A development bootloader that trusts two keys from the MCUboot repository: its +# own development key and the production key by its public half only (the +# production private key is never needed to build the bootloader). MCUboot +# embeds the public half of each and boots an image signed by either. The +# application is signed at build time with the development key (key_id 0); the +# key1 scenario re-signs it post-build with the production key (key_id 1). +# +# CAUTION: This is a test-only configuration using publicly available secrets: +SB_CONFIG_BOOT_SIGNATURE_KEY_FILE="${ZEPHYR_MCUBOOT_MODULE_DIR}/root-ed25519.pem,${ZEPHYR_MCUBOOT_MODULE_DIR}/root-ed25519-2-pub.pem" +# You must provision and manage your own keys and provide the path to them +# relative to your own application directory. For example: +# +# SB_CONFIG_BOOT_SIGNATURE_KEY_FILE="${APP_DIR}/keys/my-private-dev-key.pem,${APP_DIR}/keys/my-public-prod-key.pem" +# +# In the example above, developers hold the internally shared private key for +# development builds, but do NOT hold the private key for production builds. +# Instead, they only hold the public half of the production key, which the +# bootloader uses to verify production-signed images. + +# Test-specific configuration for QEMU. +# The image slots live in the RAM-backed flash simulator, which is not +# executable; MCUboot copies slot0 into SRAM and runs it from there. +SB_CONFIG_MCUBOOT_MODE_RAM_LOAD=y diff --git a/tests/boot/mcuboot_multiple_keys/sysbuild/mcuboot.conf b/tests/boot/mcuboot_multiple_keys/sysbuild/mcuboot.conf new file mode 100644 index 0000000000000..fa3071306f19c --- /dev/null +++ b/tests/boot/mcuboot_multiple_keys/sysbuild/mcuboot.conf @@ -0,0 +1,27 @@ +# Copyright (c) 2026 Intercreate, Inc. +# +# SPDX-License-Identifier: Apache-2.0 +# +# Appended to MCUboot's own boot/zephyr/prj.conf (a sysbuild/mcuboot/ directory +# would replace it instead, dropping MCUboot's defaults). + +# "bootutil_verify_sig: ED25519 key_id N" must reach the console ahead of the +# application banner. +CONFIG_LOG=y +CONFIG_LOG_BACKEND_UART=y +CONFIG_LOG_MODE_IMMEDIATE=y +CONFIG_MCUBOOT_LOG_LEVEL_DBG=y + +CONFIG_FLASH=y +CONFIG_FLASH_SIMULATOR=y + +# RAM_LOAD passes the load address to the application through retained RAM. +CONFIG_RETAINED_MEM=y +CONFIG_RETENTION=y +CONFIG_RETAINED_MEM_ZEPHYR_RAM=y +CONFIG_BOOT_SHARE_DATA=y +CONFIG_BOOT_SHARE_DATA_BOOTINFO=y +CONFIG_BOOT_SHARE_BACKEND_RETENTION=y + +# The pytest harness loads MCUboot into QEMU from the hex. +CONFIG_BUILD_OUTPUT_HEX=y diff --git a/tests/boot/mcuboot_multiple_keys/sysbuild/mcuboot.overlay b/tests/boot/mcuboot_multiple_keys/sysbuild/mcuboot.overlay new file mode 100644 index 0000000000000..492959ab8ae14 --- /dev/null +++ b/tests/boot/mcuboot_multiple_keys/sysbuild/mcuboot.overlay @@ -0,0 +1,31 @@ +/* + * Copyright (c) 2026 Intercreate, Inc. + * + * SPDX-License-Identifier: Apache-2.0 + * + * MCUboot image: runs XIP from flash0 @ 0x0 (QEMU loads it at the reset + * vector) using the low 256 KB of SRAM as runtime. The flash-simulator map + * and partition table are the application's; include its overlay so both + * images agree, then add the MCUboot-only exec region and code partition. + */ + +#include "../boards/mps2_an385.overlay" + +/delete-node/ &sram0; + +/ { + sram0: memory@20000000 { + compatible = "mmio-sram"; + reg = <0x20000000 0x00040000>; + }; + + image_ram: memory@20240000 { + compatible = "mmio-sram"; + reg = <0x20240000 0x001bf000>; + }; + + chosen { + zephyr,code-partition = &boot_partition; + mcuboot,image-ram = &image_ram; + }; +}; diff --git a/tests/boot/mcuboot_multiple_keys/tests.yaml b/tests/boot/mcuboot_multiple_keys/tests.yaml new file mode 100644 index 0000000000000..58e44505e01a8 --- /dev/null +++ b/tests/boot/mcuboot_multiple_keys/tests.yaml @@ -0,0 +1,32 @@ +# Copyright (c) 2026 Intercreate, Inc. +# +# SPDX-License-Identifier: Apache-2.0 + +common: + sysbuild: true + timeout: 120 + tags: + - pytest + - mcuboot + platform_allow: + - mps2/an385 + integration_platforms: + - mps2/an385 + harness: pytest +tests: + # Signed at build time with the first key in the bootloader's list (the + # development key) -> key_id 0. + bootloader.mcuboot.multiple_keys.key0: + harness_config: + pytest_root: + - "pytest/test_multiple_keys.py" + pytest_args: + - "--expected-key-id=0" + # Re-signed post-build with the production key, as a custodian would -> key_id 1. + bootloader.mcuboot.multiple_keys.key1: + harness_config: + pytest_root: + - "pytest/test_multiple_keys.py" + pytest_args: + - "--expected-key-id=1" + - "--resign-key=root-ed25519-2.pem"