From 3299247446135ea4d7347f86f67062e7f1429024 Mon Sep 17 00:00:00 2001 From: JP Hutchins Date: Wed, 8 Jul 2026 15:42:18 -0700 Subject: [PATCH] mcuboot: support multiple signing keys SB_CONFIG_BOOT_SIGNATURE_KEY_FILE now accepts a comma-separated list of PEM paths in addition to a single path. When a list is given, the MCUboot bootloader embeds the public half of every key and accepts an image signed with any of them. The first entry is also the key the application is signed with; every entry past the first must be a public-only PEM of the same signature type (MCUboot enforces this at build time with 'imgtool keyinfo --require public'). A common use is a development bootloader that boots both development- and production-signed images while never holding the production private key. The keys the bootloader embeds and the single key the application is signed with are both derived from this one list through the sysbuild helpers sysbuild_mcuboot_resolve_signature_key_files() and sysbuild_mcuboot_application_signature_key_file(). Entries are forwarded verbatim for each image to resolve, so a single-key value behaves exactly as before and no previously working configuration breaks. The feature is exercised at runtime by tests/boot/mcuboot_multiple_keys, which builds a two-key bootloader on the mps2/an385 QEMU machine and boots an application signed with either embedded key, asserting MCUboot validates it against the matching key (key_id 0 and key_id 1). The two keys are MCUboot's own ed25519 test keys: a development key (key_id 0) and a production key embedded by its public half only (key_id 1). The application is signed at build time with the development key; the second scenario re-signs the built image with the production key afterwards, the way a production custodian signs a release binary out-of-band, so the production private key never enters the build configuration. Signed-off-by: JP Hutchins Co-Authored-By: Claude Opus 4.8 (1M context) --- doc/build/signing/index.rst | 12 +- doc/releases/release-notes-4.5.rst | 10 ++ modules/Kconfig.mcuboot | 2 +- .../cmake/modules/sysbuild_extensions.cmake | 44 +++++ .../FIRMWARE_LOADER_image_default.cmake | 6 +- .../MAIN_image_default.cmake | 6 +- .../sysbuild/images/bootloader/CMakeLists.txt | 5 +- share/sysbuild/images/bootloader/Kconfig | 8 +- .../boot/mcuboot_multiple_keys/CMakeLists.txt | 10 ++ tests/boot/mcuboot_multiple_keys/README.rst | 36 ++++ .../boards/mps2_an385.conf | 12 ++ .../boards/mps2_an385.overlay | 106 ++++++++++++ tests/boot/mcuboot_multiple_keys/prj.conf | 1 + .../mcuboot_multiple_keys/pytest/conftest.py | 39 +++++ .../pytest/test_multiple_keys.py | 163 ++++++++++++++++++ tests/boot/mcuboot_multiple_keys/src/main.c | 13 ++ .../boot/mcuboot_multiple_keys/sysbuild.conf | 30 ++++ .../sysbuild/mcuboot.conf | 27 +++ .../sysbuild/mcuboot.overlay | 31 ++++ tests/boot/mcuboot_multiple_keys/tests.yaml | 32 ++++ 20 files changed, 584 insertions(+), 9 deletions(-) create mode 100644 tests/boot/mcuboot_multiple_keys/CMakeLists.txt create mode 100644 tests/boot/mcuboot_multiple_keys/README.rst create mode 100644 tests/boot/mcuboot_multiple_keys/boards/mps2_an385.conf create mode 100644 tests/boot/mcuboot_multiple_keys/boards/mps2_an385.overlay create mode 100644 tests/boot/mcuboot_multiple_keys/prj.conf create mode 100644 tests/boot/mcuboot_multiple_keys/pytest/conftest.py create mode 100644 tests/boot/mcuboot_multiple_keys/pytest/test_multiple_keys.py create mode 100644 tests/boot/mcuboot_multiple_keys/src/main.c create mode 100644 tests/boot/mcuboot_multiple_keys/sysbuild.conf create mode 100644 tests/boot/mcuboot_multiple_keys/sysbuild/mcuboot.conf create mode 100644 tests/boot/mcuboot_multiple_keys/sysbuild/mcuboot.overlay create mode 100644 tests/boot/mcuboot_multiple_keys/tests.yaml 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"