From 39cf12b537dce3a0c73f4e78b6fd50d1ef4664c4 Mon Sep 17 00:00:00 2001 From: JP Hutchins Date: Thu, 6 Aug 2026 07:45:20 +0300 Subject: [PATCH 1/2] [nrf noup] zephyr: imgtool: sim: support multiple keys MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit zephyr: support multiple signing keys CONFIG_BOOT_SIGNATURE_KEY_FILE accepts a comma-separated list of PEMs. The first entry may be a keypair or public-only PEM — only the public bytes are ever embedded in the bootloader binary; subsequent entries must be public-only and are validated at CMake configure time. This enables the prod/dev custody model: a development bootloader can boot both production-signed and development-signed images, while production bootloaders embed only the production public key. imgtool: add --name-suffix to getpub and getpubhash Emits suffixed C/Rust symbol names so more than one key of the same signature type can be embedded without collisions. imgtool: add keyinfo subcommand Reports whether a PEM is a keypair (private) or public-only. Used by the Zephyr build to enforce that verification-only entries past the first are public-only PEMs. sim: tests for multiple ed25519 keys Add the sig-second-key feature and multi_key test scenarios covering single-key and dual-key builds. Documentation updated in docs/readme-zephyr.md, docs/signed_images.md, and docs/imgtool.md. Closes #2700. Signed-off-by: JP Hutchins NCS adaptation notes: - Simulator changes excluded (not applicable to sdk-mcuboot). - CMakeLists.txt: NCS-specific key resolution (CONF_FILE scanning and KEY_FILE global property) preserved for the primary key; additional keys use CONF_DIR (same source as primary key) rather than APPLICATION_CONFIG_DIR for relative path resolution. zephyr_library_sources() used instead of target_sources() to match existing NCS conventions. MCUBOOT_SIGN_KEY_COUNT=1 set in else() branch to cover KMU, ITS, builtin-key, and any other no-key-file configurations. - Kconfig: BOOT_BUILTIN_KEY replaced with BOOT_SIGNATURE_USING_KMU and NCS_BOOT_SIGNATURE_USING_ITS in the mutually-exclusive list; help text updated to reflect NCS CONF_FILE-based path resolution behaviour. - imgtool: keyinfo uses hasattr(key_obj, "private_bytes") on the underlying cryptography key object to detect private keys, replacing keys.PayloadSigner/DigestSigner (absent in NCS) and keys.PrivateBytesMixin (which Ed25519 does not inherit in this fork). (cherry picked from commit 5497c95f1c8cde55a2d0da0db96e53fad77b8c8d) Signed-off-by: Markus Lassila (cherry picked from commit 918212398e0563dd4e40cf7bd922b1054bd24c9c) --- boot/zephyr/CMakeLists.txt | 59 +++++- boot/zephyr/Kconfig | 40 +++- boot/zephyr/keys.c | 47 +++-- boot/zephyr/sample.yaml | 8 + docs/imgtool.md | 24 +++ docs/readme-zephyr.md | 48 ++++- docs/signed_images.md | 12 ++ root-ed25519-2-pub.pem | 3 + scripts/imgtool/keys/general.py | 18 +- scripts/imgtool/main.py | 66 ++++++- scripts/tests/test_commands.py | 1 + scripts/tests/test_keys.py | 330 ++++++++++++++++++++++++++++++++ 12 files changed, 608 insertions(+), 48 deletions(-) create mode 100644 root-ed25519-2-pub.pem diff --git a/boot/zephyr/CMakeLists.txt b/boot/zephyr/CMakeLists.txt index 63d22e218c..5bda8cd7eb 100644 --- a/boot/zephyr/CMakeLists.txt +++ b/boot/zephyr/CMakeLists.txt @@ -405,10 +405,14 @@ if(CONFIG_MCUBOOT_SERIAL) endif() if(NOT CONFIG_BOOT_SIGNATURE_USING_KMU AND NOT CONFIG_BOOT_SIGNATURE_KEY_FILE STREQUAL "") + # Extract primary key from a potentially comma-separated list. + string(REPLACE "," ";" _mcuboot_key_files "${CONFIG_BOOT_SIGNATURE_KEY_FILE}") + list(GET _mcuboot_key_files 0 _primary_key_path) + # CONF_FILE points to the KConfig configuration files of the bootloader. foreach (filepath ${CONF_FILE}) file(READ ${filepath} temp_text) - string(FIND "${temp_text}" ${CONFIG_BOOT_SIGNATURE_KEY_FILE} match) + string(FIND "${temp_text}" ${_primary_key_path} match) if (${match} GREATER_EQUAL 0) if (NOT DEFINED CONF_DIR) get_filename_component(CONF_DIR ${filepath} DIRECTORY) @@ -418,13 +422,17 @@ if(NOT CONFIG_BOOT_SIGNATURE_USING_KMU AND NOT CONFIG_BOOT_SIGNATURE_KEY_FILE ST endif() endforeach() - if(IS_ABSOLUTE ${CONFIG_BOOT_SIGNATURE_KEY_FILE}) - set(KEY_FILE ${CONFIG_BOOT_SIGNATURE_KEY_FILE}) + # Expand CMake variables (e.g. ${CMAKE_CURRENT_LIST_DIR}) after scanning + # CONF_FILE with the raw text, so the scan still matches the .conf content. + string(CONFIGURE "${_primary_key_path}" _primary_key_path) + + if(IS_ABSOLUTE ${_primary_key_path}) + set(KEY_FILE ${_primary_key_path}) elseif((DEFINED CONF_DIR) AND - (EXISTS ${CONF_DIR}/${CONFIG_BOOT_SIGNATURE_KEY_FILE})) - set(KEY_FILE ${CONF_DIR}/${CONFIG_BOOT_SIGNATURE_KEY_FILE}) + (EXISTS ${CONF_DIR}/${_primary_key_path})) + set(KEY_FILE ${CONF_DIR}/${_primary_key_path}) else() - set(KEY_FILE ${MCUBOOT_DIR}/${CONFIG_BOOT_SIGNATURE_KEY_FILE}) + set(KEY_FILE ${MCUBOOT_DIR}/${_primary_key_path}) endif() message("MCUBoot bootloader key file: ${KEY_FILE}") @@ -463,6 +471,45 @@ if(NOT CONFIG_BOOT_SIGNATURE_USING_KMU AND NOT CONFIG_BOOT_SIGNATURE_KEY_FILE ST DEPENDS ${KEY_FILE} ) zephyr_library_sources(${GENERATED_PUBKEY}) + + list(LENGTH _mcuboot_key_files _mcuboot_key_count) + target_compile_definitions(app PRIVATE MCUBOOT_SIGN_KEY_COUNT=${_mcuboot_key_count}) + + if(_mcuboot_key_count GREATER 1) + # Additional verification keys (index >= 1) from comma-separated list. + # Keys are comma-separated (semicolons do not survive sysbuild). + list(SUBLIST _mcuboot_key_files 1 -1 _extra_keys) + set(_key_index 1) + foreach(_key_path IN LISTS _extra_keys) + string(CONFIGURE "${_key_path}" _key_path) + if(IS_ABSOLUTE ${_key_path}) + set(_resolved_key_path ${_key_path}) + elseif((DEFINED CONF_DIR) AND (EXISTS ${CONF_DIR}/${_key_path})) + set(_resolved_key_path ${CONF_DIR}/${_key_path}) + else() + set(_resolved_key_path ${MCUBOOT_DIR}/${_key_path}) + endif() + set(_generated_pubkey ${ZEPHYR_BINARY_DIR}/autogen-pubkey-${_key_index}.c) + add_custom_command( + OUTPUT ${_generated_pubkey} + COMMAND ${PYTHON_EXECUTABLE} ${MCUBOOT_DIR}/scripts/imgtool.py + keyinfo --key ${_resolved_key_path} --require public + COMMAND + ${PYTHON_EXECUTABLE} + ${MCUBOOT_DIR}/scripts/imgtool.py + getpub + -k + ${_resolved_key_path} + --name-suffix _${_key_index} + > ${_generated_pubkey} + DEPENDS ${_resolved_key_path} + ) + zephyr_library_sources(${_generated_pubkey}) + math(EXPR _key_index "${_key_index} + 1") + endforeach() + endif() +else() + target_compile_definitions(app PRIVATE MCUBOOT_SIGN_KEY_COUNT=1) endif() if(CONFIG_BOOT_ENCRYPTION_KEY_FILE AND NOT CONFIG_BOOT_ENCRYPTION_KEY_FILE STREQUAL "") diff --git a/boot/zephyr/Kconfig b/boot/zephyr/Kconfig index 898dec28dd..add7e84d57 100644 --- a/boot/zephyr/Kconfig +++ b/boot/zephyr/Kconfig @@ -503,21 +503,43 @@ config NCS_BOOT_SIGNATURE_USING_ITS if !BOOT_SIGNATURE_USING_KMU && !NCS_BOOT_SIGNATURE_USING_ITS config BOOT_SIGNATURE_KEY_FILE - string "PEM key file" + string "PEM key file (or comma-separated list)" + depends on !BOOT_SIGNATURE_TYPE_NONE default "root-ec-p256.pem" if BOOT_SIGNATURE_TYPE_ECDSA_P256 default "root-ed25519.pem" if BOOT_SIGNATURE_TYPE_ED25519 default "root-rsa-3072.pem" if BOOT_SIGNATURE_TYPE_RSA && BOOT_SIGNATURE_TYPE_RSA_LEN=3072 default "root-rsa-2048.pem" if BOOT_SIGNATURE_TYPE_RSA && BOOT_SIGNATURE_TYPE_RSA_LEN=2048 default "" help - You can use either absolute or relative path. - In case relative path is used, the build system assumes that it starts - from the directory where the MCUBoot KConfig configuration file is - located. If the key file is not there, the build system uses relative - path that starts from the MCUBoot repository root directory. - The key file will be parsed by imgtool's getpub command and a .c source - with the public key information will be written in a format expected by - MCUboot. + Path to a signing/verification key PEM, or a comma-separated + list of PEMs (e.g. "prod_pub.pem,dev_pub.pem") to embed multiple + verification keys in the bootloader. Only the public-key bytes are + ever embedded in the bootloader image regardless of which form is + passed in. + + The first entry may be either a keypair PEM or a public-only PEM. + A keypair is required only if the same file is also used with + `imgtool sign`; a public-only PEM is sufficient (and preferred) + when image signing is performed elsewhere with the private half + held under separate custody. When a private key is used, only + the public half is embedded in the bootloader. + + Subsequent entries (positions past the first) must be public-only + PEMs; the build will fail at CMake time otherwise. This guards the + intended workflow: a development bootloader that accepts both + production-signed images (verified against the prod public key, + whose private half stays under release-team custody) and + development-signed images (verified against the dev public key). + All entries must use the same BOOT_SIGNATURE_TYPE. Multi-key mode + is mutually exclusive with BOOT_HW_KEY, BOOT_SIGNATURE_USING_KMU, + NCS_BOOT_SIGNATURE_USING_ITS, and BOOT_BYPASS_KEY_MATCH. + + Each entry can be an absolute or relative path. Relative paths are + resolved first against the directory of the Kconfig config file that + references the key (as found by scanning CONF_FILE), then against the + MCUboot repository root. Each file is parsed by imgtool's getpub + command and a .c source with the public key information is written + in a format expected by MCUboot. endif diff --git a/boot/zephyr/keys.c b/boot/zephyr/keys.c index ab403ddc38..3f84da1c37 100644 --- a/boot/zephyr/keys.c +++ b/boot/zephyr/keys.c @@ -27,20 +27,40 @@ * provides via the compiler command line). */ #include +#include #if !defined(MCUBOOT_HW_KEY) #if defined(MCUBOOT_SIGN_RSA) || defined(MCUBOOT_SIGN_EC256) || defined(MCUBOOT_SIGN_ED25519) #define HAVE_KEYS + +#ifndef MCUBOOT_SIGN_KEY_COUNT +#error "MCUBOOT_SIGN_KEY_COUNT must be defined by the build system" +#endif + +#define _BOOT_KEY_CAT(a, b) a##b +#define BOOT_KEY_CAT(a, b) _BOOT_KEY_CAT(a, b) + #if defined(MCUBOOT_SIGN_RSA) -extern const unsigned char rsa_pub_key[]; -extern unsigned int rsa_pub_key_len; +# define BOOT_KEY_PRIMARY rsa_pub_key #elif defined(MCUBOOT_SIGN_EC256) -extern const unsigned char ecdsa_pub_key[]; -extern unsigned int ecdsa_pub_key_len; +# define BOOT_KEY_PRIMARY ecdsa_pub_key #elif defined(MCUBOOT_SIGN_ED25519) -extern const unsigned char ed25519_pub_key[]; -extern unsigned int ed25519_pub_key_len; +# define BOOT_KEY_PRIMARY ed25519_pub_key #endif + +#define BOOT_KEY_NAME(N) BOOT_KEY_CAT(BOOT_KEY_PRIMARY, BOOT_KEY_CAT(_, N)) + +#define BOOT_KEY_DECL_AT(i, _) \ + extern const unsigned char BOOT_KEY_NAME(UTIL_INC(i))[]; \ + extern unsigned int BOOT_KEY_CAT(BOOT_KEY_NAME(UTIL_INC(i)), _len); + +#define BOOT_KEY_ENTRY_AT(i, _) \ + { .key = BOOT_KEY_NAME(UTIL_INC(i)), \ + .len = &BOOT_KEY_CAT(BOOT_KEY_NAME(UTIL_INC(i)), _len) }, + +extern const unsigned char BOOT_KEY_PRIMARY[]; +extern unsigned int BOOT_KEY_CAT(BOOT_KEY_PRIMARY, _len); +LISTIFY(UTIL_DEC(MCUBOOT_SIGN_KEY_COUNT), BOOT_KEY_DECL_AT, ()) #endif /* @@ -51,19 +71,12 @@ extern unsigned int ed25519_pub_key_len; #if defined(HAVE_KEYS) const struct bootutil_key bootutil_keys[] = { { -#if defined(MCUBOOT_SIGN_RSA) - .key = rsa_pub_key, - .len = &rsa_pub_key_len, -#elif defined(MCUBOOT_SIGN_EC256) - .key = ecdsa_pub_key, - .len = &ecdsa_pub_key_len, -#elif defined(MCUBOOT_SIGN_ED25519) - .key = ed25519_pub_key, - .len = &ed25519_pub_key_len, -#endif + .key = BOOT_KEY_PRIMARY, + .len = &BOOT_KEY_CAT(BOOT_KEY_PRIMARY, _len), }, + LISTIFY(UTIL_DEC(MCUBOOT_SIGN_KEY_COUNT), BOOT_KEY_ENTRY_AT, ()) }; -const int bootutil_key_cnt = 1; +const int bootutil_key_cnt = sizeof(bootutil_keys) / sizeof(bootutil_keys[0]); #endif /* HAVE_KEYS */ #else unsigned int pub_key_len; diff --git a/boot/zephyr/sample.yaml b/boot/zephyr/sample.yaml index f66f2e693d..cbd566171e 100644 --- a/boot/zephyr/sample.yaml +++ b/boot/zephyr/sample.yaml @@ -97,6 +97,14 @@ tests: integration_platforms: - nrf52840dk/nrf52840 tags: bootloader_mcuboot + sample.bootloader.mcuboot.two_signing_keys: + extra_configs: + - CONFIG_BOOT_SIGNATURE_TYPE_ED25519=y + - CONFIG_BOOT_SIGNATURE_KEY_FILE="root-ed25519.pem,root-ed25519-2-pub.pem" + platform_allow: nrf52840dk/nrf52840 + integration_platforms: + - nrf52840dk/nrf52840 + tags: bootloader_mcuboot sample.bootloader.mcuboot.runtime_source.hooks: extra_args: EXTRA_CONF_FILE=../../samples/runtime-source/zephyr/sample.conf TEST_RUNTIME_SOURCE_HOOKS=y diff --git a/docs/imgtool.md b/docs/imgtool.md index 958e1af154..9a7f5a5608 100644 --- a/docs/imgtool.md +++ b/docs/imgtool.md @@ -46,6 +46,30 @@ output it as a C data structure. You can replace or insert this code into the key file. However, when the `MCUBOOT_HW_KEY` config option is enabled, this last step is unnecessary and can be skipped. +When embedding more than one signing-verification key in the same image +(for example, a Zephyr build with a multi-key +`CONFIG_BOOT_SIGNATURE_KEY_FILE` list), pass `--name-suffix` to +distinguish the emitted symbol names: + + ./scripts/imgtool.py getpub -k dev-key.pem --name-suffix _2 + +emits `_pub_key_2[]` and `_pub_key_2_len` (the +same suffix is applied by `getpubhash` for the lang-c encoding). The +option is accepted only for the `lang-c` / `lang-rust` encodings; using +it with `--encoding pem` or `--encoding raw` is rejected. + +## [Inspecting key kind](#inspecting-key-kind) + +For build-system use, `imgtool keyinfo` reports whether a PEM contains +private material (`private`) or only public material (`public`): + + ./scripts/imgtool.py keyinfo -k some-key.pem + +Pair with `--require private` or `--require public` to exit non-zero +when the kind does not match. The Zephyr port uses this to enforce that +every verification-only key passed via `CONFIG_BOOT_SIGNATURE_KEY_FILE` +past the first entry is a public-only PEM. + ## [Signing images](#signing-images) Image signing takes an image in binary or Intel Hex format intended for the diff --git a/docs/readme-zephyr.md b/docs/readme-zephyr.md index 6b12b5b066..22f86aaa85 100644 --- a/docs/readme-zephyr.md +++ b/docs/readme-zephyr.md @@ -156,8 +156,52 @@ the public key in a format usable by the C compiler. The generated public key is saved in `build/zephyr/autogen-pubkey.h`, which is included by the `boot/zephyr/keys.c`. -Currently, the Zephyr RTOS port limits its support to one keypair at the time, -although MCUboot's key management infrastructure supports multiple keypairs. +``CONFIG_BOOT_SIGNATURE_KEY_FILE`` accepts either a keypair PEM or a +public-key-only PEM: only the public key is consumed during the build. +This enables production flows in which the signing private key is held +by a release team and only an exported public key is provided to +bootloader builders. Signing images (`imgtool sign`) requires the +private key. + +The Zephyr port supports embedding multiple verification keys in the +bootloader. `CONFIG_BOOT_SIGNATURE_KEY_FILE` accepts a single PEM path or +a comma-separated list, e.g. +`"prod_pubkey.pem,dev_pubkey.pem"` or +`"\${CMAKE_CURRENT_LIST_DIR}/prod_pubkey.pem,\${CMAKE_CURRENT_LIST_DIR}/dev_pubkey.pem"`. +Only the public-key bytes are ever embedded in the bootloader image, +regardless of which form is passed in. All entries must use the same +`BOOT_SIGNATURE_TYPE`, and multi-key mode is mutually exclusive with +`BOOT_HW_KEY`, `BOOT_SIGNATURE_USING_KMU`, `NCS_BOOT_SIGNATURE_USING_ITS`, +and `BOOT_BYPASS_KEY_MATCH`. +The first entry **may** be a keypair PEM or a public-only PEM. A +keypair is required only if the same file is also used with +`imgtool sign`; otherwise a public-only PEM is sufficient — and +preferred, since private material embedded in the bootloader image is +recoverable from flash. Subsequent entries (positions past the first) +**must** be public-only PEMs; the build is rejected at CMake time +otherwise (via `imgtool keyinfo --require public`). + +### Custody model + +The feature is for separating signing custody from verification custody. + +| Key | Custody | Distribution | Blast radius if lost | +| ------------------------- | ------------------------------------------ | ----------------------------------------------------------------- | --------------------------------------------------------------------- | +| Production private | HSM, signing ceremony, release team only | Never leaves the HSM | Catastrophic: attacker can sign images that boot on the prod fleet | +| Production public | N/A (public material) | Embedded in dev bootloaders so dev units can verify prod images | None: public by design | +| Development private | Loosely held by engineers | On dev workstations / dev signing infra | Bounded: only authorizes firmware on non-deployed dev hardware | + +Production bootloaders should embed only the production public key, so +that production units boot only production-signed images. Development +bootloaders embed both the production public key and the development +public key, so a dev unit can boot a production-signed image (verified +against the prod public key) without re-signing, while still allowing +engineers to flash development-signed images. + +The bootloader's existing key-matching logic (`bootutil_find_key()`) +hashes the image's KEYHASH TLV against every embedded key and accepts +the first match. There is no per-key behaviour: multi-key mode is purely +about accepting more than one valid signer. Once MCUboot is built, this new keypair file (`mykey.pem` in this example) can be used to sign images. diff --git a/docs/signed_images.md b/docs/signed_images.md index bcc201b855..54b7f6b83e 100644 --- a/docs/signed_images.md +++ b/docs/signed_images.md @@ -33,6 +33,18 @@ be useful when you want to prevent production units from booting development images, but want development units to be able to boot both production images and development images. +On Zephyr, `CONFIG_BOOT_SIGNATURE_KEY_FILE` accepts a comma-separated +list of PEMs. Only the public-key bytes are embedded regardless of which +form is passed in. The first entry may be a keypair PEM (needed only if +the same file is also fed to `imgtool sign`) or a public-only PEM +(preferred when signing happens elsewhere); subsequent entries must be +public-only. The intended use is to separate signing custody (a +production private key, held only by a release team) from verification +custody (the production public key, embedded in development bootloaders +so dev units can boot prod-signed images). See +[readme-zephyr.md](readme-zephyr.md) for the threat-model table and a +worked example. + For an alternative solution when the public key(s) doesn't need to be included in the bootloader, see the [design](design.md) document. diff --git a/root-ed25519-2-pub.pem b/root-ed25519-2-pub.pem new file mode 100644 index 0000000000..683505fe90 --- /dev/null +++ b/root-ed25519-2-pub.pem @@ -0,0 +1,3 @@ +-----BEGIN PUBLIC KEY----- +MCowBQYDK2VwAyEAw7zfMHZOH120DYkuDQ6rBJwwBk55qO2293kuRpom2nc= +-----END PUBLIC KEY----- diff --git a/scripts/imgtool/keys/general.py b/scripts/imgtool/keys/general.py index fdaab147fc..957a647339 100644 --- a/scripts/imgtool/keys/general.py +++ b/scripts/imgtool/keys/general.py @@ -59,28 +59,28 @@ def _emit_raw(self, encoded_bytes, file): # raw binary data, can be for example io.BytesIO file.write(encoded_bytes) - def emit_c_public(self, file=sys.stdout): + def emit_c_public(self, file=sys.stdout, name_suffix: str = ""): self._emit( - header=f"const unsigned char {self.shortname()}_pub_key[] = {{" + header=f"const unsigned char {self.shortname()}_pub_key{name_suffix}[] = {{" , trailer="};", encoded_bytes=self.get_public_bytes(), indent=" ", - len_format=f"const unsigned int {self.shortname()}_pub_key_len = {{}};" + len_format=f"const unsigned int {self.shortname()}_pub_key{name_suffix}_len = {{}};" , file=file) - def emit_c_public_hash(self, file=sys.stdout): + def emit_c_public_hash(self, file=sys.stdout, name_suffix: str = ""): digest = Hash(SHA256()) digest.update(self.get_public_bytes()) self._emit( - header=f"const unsigned char {self.shortname()}_pub_key_hash[] = {{" + header=f"const unsigned char {self.shortname()}_pub_key_hash{name_suffix}[] = {{" , trailer="};", encoded_bytes=digest.finalize(), indent=" ", - len_format=f"const unsigned int {self.shortname()}_pub_key_hash_len = {{}};" - , + len_format=("const unsigned int " + f"{self.shortname()}_pub_key_hash{name_suffix}_len = {{}};"), file=file) def emit_raw_public(self, file=sys.stdout): @@ -91,9 +91,9 @@ def emit_raw_public_hash(self, file=sys.stdout): digest.update(self.get_public_bytes()) self._emit_raw(digest.finalize(), file=file) - def emit_rust_public(self, file=sys.stdout): + def emit_rust_public(self, file=sys.stdout, name_suffix: str = ""): self._emit( - header=f"static {self.shortname().upper()}_PUB_KEY: &[u8] = &[" + header=f"static {self.shortname().upper()}_PUB_KEY{name_suffix.upper()}: &[u8] = &[" , trailer="];", encoded_bytes=self.get_public_bytes(), diff --git a/scripts/imgtool/main.py b/scripts/imgtool/main.py index 5560a4612e..92e56a8186 100755 --- a/scripts/imgtool/main.py +++ b/scripts/imgtool/main.py @@ -67,6 +67,17 @@ def gen_x25519(keyfile, passwd): valid_langs = ['c', 'rust'] valid_hash_encodings = ['lang-c', 'raw'] valid_encodings = ['lang-c', 'lang-rust', 'pem', 'raw'] + + +def _validate_name_suffix(ctx: click.Context, param: click.Parameter, value: str) -> str: + if value and not re.match(r"^[A-Za-z0-9_]*$", value): + raise click.BadParameter( + f"{value!r} must contain only [A-Za-z0-9_]; it is appended " + f"directly to a C/Rust identifier." + ) + return value + + keygens = { 'rsa-2048': gen_rsa2048, 'rsa-3072': gen_rsa3072, @@ -134,12 +145,19 @@ def keygen(type, key, password): @click.option('-e', '--encoding', metavar='encoding', type=click.Choice(valid_encodings), help='Valid encodings: {}'.format(', '.join(valid_encodings))) +@click.option('--name-suffix', 'name_suffix', metavar='SUFFIX', default='', + callback=_validate_name_suffix, + help='Append SUFFIX to the emitted C/Rust symbol names ' + '(e.g. `--name-suffix _2` emits `rsa_pub_key_2` / ' + '`rsa_pub_key_2_len`). Useful when embedding multiple ' + 'signing keys in the same image. Rejected for PEM/raw ' + 'encodings (those emit no identifiers).') @click.option('-k', '--key', metavar='filename', required=True) @click.option('-o', '--output', metavar='output', required=False, help='Specify the output file\'s name. \ The stdout is used if it is not provided.') @click.command(help='Dump public key from keypair') -def getpub(key, encoding, lang, output): +def getpub(key, encoding, lang, output, name_suffix): if encoding and lang: raise click.UsageError('Please use only one of `--encoding/-e` ' 'or `--lang/-l`') @@ -147,6 +165,9 @@ def getpub(key, encoding, lang, output): # Preserve old behavior defaulting to `c`. If `lang` is removed, # `default=valid_encodings[0]` should be added to `-e` param. lang = valid_langs[0] + if name_suffix and (encoding in ('pem', 'raw')): + raise click.UsageError( + '`--name-suffix` is only meaningful for lang-c / lang-rust encodings') key = load_key(key) if not output: @@ -154,9 +175,9 @@ def getpub(key, encoding, lang, output): if key is None: print("Invalid passphrase") elif lang == 'c' or encoding == 'lang-c': - key.emit_c_public(file=output) + key.emit_c_public(file=output, name_suffix=name_suffix) elif lang == 'rust' or encoding == 'lang-rust': - key.emit_rust_public(file=output) + key.emit_rust_public(file=output, name_suffix=name_suffix) elif encoding == 'pem': key.emit_public_pem(file=output) elif encoding == 'raw': @@ -171,14 +192,21 @@ def getpub(key, encoding, lang, output): 'Default value is {}.' .format(', '.join(valid_hash_encodings), valid_hash_encodings[0])) +@click.option('--name-suffix', 'name_suffix', metavar='SUFFIX', default='', + callback=_validate_name_suffix, + help='Append SUFFIX to the emitted C symbol names (lang-c ' + 'encoding only). Rejected for raw encoding.') @click.option('-k', '--key', metavar='filename', required=True) @click.option('-o', '--output', metavar='output', required=False, help='Specify the output file\'s name. \ The stdout is used if it is not provided.') @click.command(help='Dump the SHA256 hash of the public key') -def getpubhash(key, output, encoding): +def getpubhash(key, output, encoding, name_suffix): if not encoding: encoding = valid_hash_encodings[0] + if name_suffix and encoding == 'raw': + raise click.UsageError( + '`--name-suffix` is only meaningful for the lang-c encoding') key = load_key(key) if not output: @@ -186,13 +214,40 @@ def getpubhash(key, output, encoding): if key is None: print("Invalid passphrase") elif encoding == 'lang-c': - key.emit_c_public_hash(file=output) + key.emit_c_public_hash(file=output, name_suffix=name_suffix) elif encoding == 'raw': key.emit_raw_public_hash(file=output) else: raise click.UsageError() +@click.option('--require', 'require', type=click.Choice(['private', 'public']), + default=None, + help='Exit non-zero if the key kind does not match REQUIRE. ' + 'Without this option, keyinfo always exits 0 and prints ' + 'the detected kind on stdout.') +@click.option('-k', '--key', metavar='filename', required=True) +@click.command(help='Print whether KEY is a keypair PEM (`private`) or a ' + 'public-only PEM (`public`). Intended for build-system ' + 'use: pair with `--require` to gate the build on the ' + 'expected key kind.') +def keyinfo(key, require): + loaded = keys.load(key) + if loaded is None: + raise click.UsageError( + f"Cannot inspect {key}: key is password-protected or unreadable. " + f"keyinfo runs non-interactively and does not prompt for a " + f"passphrase." + ) + key_obj = getattr(loaded, "key", None) + kind = "private" if (key_obj is not None and hasattr(key_obj, "private_bytes")) else "public" + click.echo(kind) + if require is not None and kind != require: + raise click.UsageError( + f"Key {key} is {kind}, but {require} was required." + ) + + @click.option('--minimal', default=False, is_flag=True, help='Reduce the size of the dumped private key to include only ' 'the minimum amount of data required to decrypt. This ' @@ -644,6 +699,7 @@ def imgtool(): imgtool.add_command(getpub) imgtool.add_command(getpubhash) imgtool.add_command(getpriv) +imgtool.add_command(keyinfo) imgtool.add_command(verify) imgtool.add_command(sign) imgtool.add_command(version) diff --git a/scripts/tests/test_commands.py b/scripts/tests/test_commands.py index 4ef794e256..d3a1609db4 100644 --- a/scripts/tests/test_commands.py +++ b/scripts/tests/test_commands.py @@ -28,6 +28,7 @@ "getpub", "getpubhash", "keygen", + "keyinfo", "sign", "verify", "version", diff --git a/scripts/tests/test_keys.py b/scripts/tests/test_keys.py index 92a50a45f7..32f2613f95 100644 --- a/scripts/tests/test_keys.py +++ b/scripts/tests/test_keys.py @@ -177,6 +177,336 @@ def test_getpubhash(key_type, encoding, tmp_path_persistent): assert pub_key_hash.stat().st_size > 0 +def _ensure_pub_only_pem( + runner: CliRunner, gen_key: Path, pub_only_pem: Path +) -> None: + if pub_only_pem.exists(): + return + assert gen_key.exists(), ( + f"Expected generated key to exist before extracting public key: " + f"{gen_key}" + ) + result = runner.invoke( + imgtool, + ( + "getpub", + "--key", + str(gen_key), + "--output", + str(pub_only_pem), + "--encoding", + "pem", + ), + ) + assert result.exit_code == 0 + assert pub_only_pem.read_bytes().startswith(b"-----BEGIN PUBLIC KEY-----") + + +@pytest.mark.parametrize("key_type", KEY_TYPES) +@pytest.mark.parametrize("encoding", KEY_ENCODINGS) +def test_getpub_from_pub_only_pem( + key_type: str, encoding: str, tmp_path_persistent: Path +) -> None: + """Get public key when input is itself a public-only PEM""" + runner = CliRunner() + + gen_key = tmp_name(tmp_path_persistent, key_type, GEN_KEY_EXT) + pub_only_pem = tmp_name(tmp_path_persistent, key_type, PUB_ONLY_PEM_EXT) + _ensure_pub_only_pem(runner, gen_key, pub_only_pem) + + from_priv = tmp_name(tmp_path_persistent, key_type, ".from_priv_pub." + encoding) + from_pub = tmp_name(tmp_path_persistent, key_type, ".from_pub_pub." + encoding) + + for src, out in ((gen_key, from_priv), (pub_only_pem, from_pub)): + result = runner.invoke( + imgtool, + ( + "getpub", + "--key", + str(src), + "--output", + str(out), + "--encoding", + encoding, + ), + ) + assert result.exit_code == 0 + assert out.exists() + assert out.stat().st_size > 0 + + assert from_priv.read_bytes() == from_pub.read_bytes() + + +@pytest.mark.parametrize("key_type", KEY_TYPES) +@pytest.mark.parametrize("encoding", PUB_HASH_ENCODINGS) +def test_getpubhash_from_pub_only_pem( + key_type: str, encoding: str, tmp_path_persistent: Path +) -> None: + """Get public-key hash when input is itself a public-only PEM""" + runner = CliRunner() + + gen_key = tmp_name(tmp_path_persistent, key_type, GEN_KEY_EXT) + pub_only_pem = tmp_name(tmp_path_persistent, key_type, PUB_ONLY_PEM_EXT) + _ensure_pub_only_pem(runner, gen_key, pub_only_pem) + + from_priv = tmp_name(tmp_path_persistent, key_type, ".from_priv_hash." + encoding) + from_pub = tmp_name(tmp_path_persistent, key_type, ".from_pub_hash." + encoding) + + for src, out in ((gen_key, from_priv), (pub_only_pem, from_pub)): + result = runner.invoke( + imgtool, + ( + "getpubhash", + "--key", + str(src), + "--output", + str(out), + "--encoding", + encoding, + ), + ) + assert result.exit_code == 0 + assert out.exists() + assert out.stat().st_size > 0 + + assert from_priv.read_bytes() == from_pub.read_bytes() + +KEY_SHORTNAMES = { + "rsa-2048": "rsa", + "rsa-3072": "rsa", + "ecdsa-p256": "ecdsa", + "ecdsa-p384": "ecdsap384", + "ed25519": "ed25519", + "x25519": "x25519", +} +"""Map from keygen key_type names to the shortname() each key class emits, +which is the prefix used in the autogenerated C/Rust symbol names.""" + + +@pytest.mark.parametrize("key_type", KEY_TYPES) +def test_getpub_name_suffix_c(key_type, tmp_path_persistent): + """`--name-suffix` appends to lang-c symbol names.""" + runner = CliRunner() + + gen_key = tmp_name(tmp_path_persistent, key_type, GEN_KEY_EXT) + pub_key = tmp_name(tmp_path_persistent, key_type, + PUB_KEY_EXT + ".suffix.c") + suffix = "_dev" + + result = runner.invoke( + imgtool, + [ + "getpub", "--key", str(gen_key), + "--output", str(pub_key), + "--encoding", "lang-c", + "--name-suffix", suffix, + ], + ) + assert result.exit_code == 0 + content = pub_key.read_text() + short = KEY_SHORTNAMES[key_type] + assert f"{short}_pub_key{suffix}[]" in content + assert f"{short}_pub_key{suffix}_len" in content + # The un-suffixed names must not appear — otherwise linking two keys of + # the same type in the same image would fail. + assert f"{short}_pub_key[]" not in content + assert f"{short}_pub_key_len" not in content + + +@pytest.mark.parametrize("key_type", KEY_TYPES) +def test_getpub_name_suffix_rust(key_type, tmp_path_persistent): + """`--name-suffix` appends to lang-rust symbol names (uppercased).""" + runner = CliRunner() + + gen_key = tmp_name(tmp_path_persistent, key_type, GEN_KEY_EXT) + pub_key = tmp_name(tmp_path_persistent, key_type, + PUB_KEY_EXT + ".suffix.rs") + suffix = "_dev" + + result = runner.invoke( + imgtool, + [ + "getpub", "--key", str(gen_key), + "--output", str(pub_key), + "--encoding", "lang-rust", + "--name-suffix", suffix, + ], + ) + assert result.exit_code == 0 + content = pub_key.read_text() + short = KEY_SHORTNAMES[key_type].upper() + assert f"{short}_PUB_KEY{suffix.upper()}" in content + + +@pytest.mark.parametrize("encoding", ["pem", "raw"]) +def test_getpub_name_suffix_rejected(encoding, tmp_path_persistent): + """`--name-suffix` must be rejected for encodings without symbol names.""" + runner = CliRunner() + + gen_key = tmp_name(tmp_path_persistent, "ed25519", GEN_KEY_EXT) + out = tmp_name(tmp_path_persistent, "ed25519", + PUB_KEY_EXT + ".reject." + encoding) + + result = runner.invoke( + imgtool, + [ + "getpub", "--key", str(gen_key), + "--output", str(out), + "--encoding", encoding, + "--name-suffix", "_2", + ], + ) + assert result.exit_code != 0 + assert "name-suffix" in result.output + + +@pytest.mark.parametrize( + "bad_suffix", + ["-dev", "foo/bar", "a b", "@2", "key+", " ", "_2-x"], +) +def test_getpub_name_suffix_invalid_chars_rejected( + bad_suffix: str, tmp_path_persistent: Path, +) -> None: + """Suffixes that would produce invalid C/Rust identifiers are rejected.""" + runner = CliRunner() + gen_key = tmp_name(tmp_path_persistent, "ed25519", GEN_KEY_EXT) + + result = runner.invoke( + imgtool, + [ + "getpub", "--key", str(gen_key), + "--encoding", "lang-c", + "--name-suffix", bad_suffix, + ], + ) + assert result.exit_code != 0 + assert "A-Za-z0-9_" in result.output + + +@pytest.mark.parametrize( + "bad_suffix", + ["-dev", "foo/bar", "a b", "@2", "key+", " "], +) +def test_getpubhash_name_suffix_invalid_chars_rejected( + bad_suffix: str, tmp_path_persistent: Path, +) -> None: + """getpubhash rejects suffixes with non-identifier chars too.""" + runner = CliRunner() + gen_key = tmp_name(tmp_path_persistent, "ed25519", GEN_KEY_EXT) + + result = runner.invoke( + imgtool, + [ + "getpubhash", "--key", str(gen_key), + "--encoding", "lang-c", + "--name-suffix", bad_suffix, + ], + ) + assert result.exit_code != 0 + assert "A-Za-z0-9_" in result.output + + +@pytest.mark.parametrize("key_type", KEY_TYPES) +def test_getpubhash_name_suffix_c(key_type, tmp_path_persistent): + """`--name-suffix` appends to getpubhash lang-c symbol names.""" + runner = CliRunner() + + gen_key = tmp_name(tmp_path_persistent, key_type, GEN_KEY_EXT) + pub_hash = tmp_name(tmp_path_persistent, key_type, + PUB_KEY_HASH_EXT + ".suffix.c") + suffix = "_dev" + + result = runner.invoke( + imgtool, + [ + "getpubhash", "--key", str(gen_key), + "--output", str(pub_hash), + "--encoding", "lang-c", + "--name-suffix", suffix, + ], + ) + assert result.exit_code == 0 + content = pub_hash.read_text() + short = KEY_SHORTNAMES[key_type] + assert f"{short}_pub_key_hash{suffix}[]" in content + assert f"{short}_pub_key_hash{suffix}_len" in content + + +def test_getpubhash_name_suffix_rejects_raw(tmp_path_persistent): + """`--name-suffix` must be rejected for getpubhash raw encoding.""" + runner = CliRunner() + + gen_key = tmp_name(tmp_path_persistent, "ed25519", GEN_KEY_EXT) + out = tmp_name(tmp_path_persistent, "ed25519", + PUB_KEY_HASH_EXT + ".reject.raw") + + result = runner.invoke( + imgtool, + [ + "getpubhash", "--key", str(gen_key), + "--output", str(out), + "--encoding", "raw", + "--name-suffix", "_2", + ], + ) + assert result.exit_code != 0 + assert "name-suffix" in result.output + + +@pytest.mark.parametrize("key_type", KEY_TYPES) +def test_keyinfo_reports_private(key_type, tmp_path_persistent): + """keyinfo prints `private` for a keypair PEM.""" + runner = CliRunner() + gen_key = tmp_name(tmp_path_persistent, key_type, GEN_KEY_EXT) + + result = runner.invoke(imgtool, ["keyinfo", "--key", str(gen_key)]) + assert result.exit_code == 0 + assert result.output.strip() == "private" + + +@pytest.mark.parametrize("key_type", KEY_TYPES) +def test_keyinfo_reports_public(key_type, tmp_path_persistent): + """keyinfo prints `public` for a public-only PEM.""" + runner = CliRunner() + gen_key = tmp_name(tmp_path_persistent, key_type, GEN_KEY_EXT) + pub_only_pem = tmp_name(tmp_path_persistent, key_type, PUB_ONLY_PEM_EXT) + _ensure_pub_only_pem(runner, gen_key, pub_only_pem) + + result = runner.invoke(imgtool, ["keyinfo", "--key", str(pub_only_pem)]) + assert result.exit_code == 0 + assert result.output.strip() == "public" + + +def test_keyinfo_require_public_rejects_private(tmp_path_persistent): + """keyinfo --require=public fails on a keypair PEM.""" + runner = CliRunner() + gen_key = tmp_name(tmp_path_persistent, "ed25519", GEN_KEY_EXT) + + result = runner.invoke( + imgtool, + ["keyinfo", "--key", str(gen_key), "--require", "public"], + ) + assert result.exit_code != 0 + assert "private" in result.output + assert "public was required" in result.output + + +def test_keyinfo_require_private_rejects_public(tmp_path_persistent): + """keyinfo --require=private fails on a public-only PEM.""" + runner = CliRunner() + gen_key = tmp_name(tmp_path_persistent, "ed25519", GEN_KEY_EXT) + pub_only_pem = tmp_name(tmp_path_persistent, "ed25519", PUB_ONLY_PEM_EXT) + _ensure_pub_only_pem(runner, gen_key, pub_only_pem) + + result = runner.invoke( + imgtool, + ["keyinfo", "--key", str(pub_only_pem), "--require", "private"], + ) + assert result.exit_code != 0 + assert "public" in result.output + assert "private was required" in result.output + + @pytest.mark.parametrize("key_type", KEY_TYPES) def test_sign_verify(key_type, tmp_path_persistent): """Test basic sign and verify""" From 9a297e8d6a8d88f6659b948b1955e4fe44125045 Mon Sep 17 00:00:00 2001 From: Markus Lassila Date: Thu, 20 Aug 2026 14:25:38 +0300 Subject: [PATCH 2/2] [nrf noup] boot: zephyr: Comma separated BOOT_SIGNATURE_KEY_FILE for 91 only Scope the comma separated BOOT_SIGNATURE_KEY_FILE to only 91 series devices in NCS 3.4. Signed-off-by: Markus Lassila --- boot/zephyr/CMakeLists.txt | 8 ++++++-- boot/zephyr/Kconfig | 13 +++++++++++-- boot/zephyr/sample.yaml | 8 -------- 3 files changed, 17 insertions(+), 12 deletions(-) diff --git a/boot/zephyr/CMakeLists.txt b/boot/zephyr/CMakeLists.txt index 5bda8cd7eb..993b8c4269 100644 --- a/boot/zephyr/CMakeLists.txt +++ b/boot/zephyr/CMakeLists.txt @@ -405,8 +405,12 @@ if(CONFIG_MCUBOOT_SERIAL) endif() if(NOT CONFIG_BOOT_SIGNATURE_USING_KMU AND NOT CONFIG_BOOT_SIGNATURE_KEY_FILE STREQUAL "") - # Extract primary key from a potentially comma-separated list. - string(REPLACE "," ";" _mcuboot_key_files "${CONFIG_BOOT_SIGNATURE_KEY_FILE}") + # Extract primary key; split on comma only when BOOT_SIGNATURE_KEY_FILE_MULTI is enabled. + if(CONFIG_BOOT_SIGNATURE_KEY_FILE_MULTI) + string(REPLACE "," ";" _mcuboot_key_files "${CONFIG_BOOT_SIGNATURE_KEY_FILE}") + else() + set(_mcuboot_key_files "${CONFIG_BOOT_SIGNATURE_KEY_FILE}") + endif() list(GET _mcuboot_key_files 0 _primary_key_path) # CONF_FILE points to the KConfig configuration files of the bootloader. diff --git a/boot/zephyr/Kconfig b/boot/zephyr/Kconfig index add7e84d57..08752613a2 100644 --- a/boot/zephyr/Kconfig +++ b/boot/zephyr/Kconfig @@ -503,7 +503,7 @@ config NCS_BOOT_SIGNATURE_USING_ITS if !BOOT_SIGNATURE_USING_KMU && !NCS_BOOT_SIGNATURE_USING_ITS config BOOT_SIGNATURE_KEY_FILE - string "PEM key file (or comma-separated list)" + string "PEM key file (or comma-separated list on nrf91)" depends on !BOOT_SIGNATURE_TYPE_NONE default "root-ec-p256.pem" if BOOT_SIGNATURE_TYPE_ECDSA_P256 default "root-ed25519.pem" if BOOT_SIGNATURE_TYPE_ED25519 @@ -515,7 +515,8 @@ config BOOT_SIGNATURE_KEY_FILE list of PEMs (e.g. "prod_pub.pem,dev_pub.pem") to embed multiple verification keys in the bootloader. Only the public-key bytes are ever embedded in the bootloader image regardless of which form is - passed in. + passed in. Comma-separated lists are only supported on nrf91 series + (requires BOOT_SIGNATURE_KEY_FILE_MULTI). The first entry may be either a keypair PEM or a public-only PEM. A keypair is required only if the same file is also used with @@ -541,6 +542,14 @@ config BOOT_SIGNATURE_KEY_FILE command and a .c source with the public key information is written in a format expected by MCUboot. +config BOOT_SIGNATURE_KEY_FILE_MULTI + bool + default y + depends on SOC_SERIES_NRF91 + help + Enable comma-separated multi-key support in BOOT_SIGNATURE_KEY_FILE. + Restricted to nrf91 series; cannot be enabled for other SoC families. + endif config SOC_NRF54LX_SKIP_GLITCHDETECTOR_DISABLE diff --git a/boot/zephyr/sample.yaml b/boot/zephyr/sample.yaml index cbd566171e..f66f2e693d 100644 --- a/boot/zephyr/sample.yaml +++ b/boot/zephyr/sample.yaml @@ -97,14 +97,6 @@ tests: integration_platforms: - nrf52840dk/nrf52840 tags: bootloader_mcuboot - sample.bootloader.mcuboot.two_signing_keys: - extra_configs: - - CONFIG_BOOT_SIGNATURE_TYPE_ED25519=y - - CONFIG_BOOT_SIGNATURE_KEY_FILE="root-ed25519.pem,root-ed25519-2-pub.pem" - platform_allow: nrf52840dk/nrf52840 - integration_platforms: - - nrf52840dk/nrf52840 - tags: bootloader_mcuboot sample.bootloader.mcuboot.runtime_source.hooks: extra_args: EXTRA_CONF_FILE=../../samples/runtime-source/zephyr/sample.conf TEST_RUNTIME_SOURCE_HOOKS=y