Skip to content

zephyr: imgtool: sim: support multiple keys (closes #2700)#2701

Draft
JPHutchins wants to merge 1 commit into
mcu-tools:mainfrom
intercreate:feature-zephyr-multiple-signing-keys
Draft

zephyr: imgtool: sim: support multiple keys (closes #2700)#2701
JPHutchins wants to merge 1 commit into
mcu-tools:mainfrom
intercreate:feature-zephyr-multiple-signing-keys

Conversation

@JPHutchins
Copy link
Copy Markdown
Contributor

Closes #2700.

Summary

Extend the Zephyr port to optionally embed a second signing-verification key in the bootloader. When CONFIG_BOOT_SIGNATURE_KEY_FILE_2 is set, the bootloader accepts images signed by either the primary or the secondary key. When it is empty (the default), the compiled output is unchanged relative to current upstream — no new symbols, no new array entries, no runtime cost.

See the linked issue for motivation, user demand, and the underlying documented-but-unimplemented design intent.

Testing

Verified:

  • imgtool unit tests (scripts/tests/test_keys.py): pass, including the new --name-suffix positive and negative cases across all supported key types.
  • Simulator tests with sig-ed25519 alone and with sig-ed25519,sig-second-key: the four new multi_key scenarios pass under both configurations, with the #[cfg]-gated variants applying correctly.

Not yet verified:

  • End-to-end on real hardware. I have not run the prod-bootloader / dev-bootloader matrix on a physical target. The simulator exercises the same boot/zephyr/keys.c code path that production builds use, so I expect parity, but that's inference, not a measurement. Happy to perform hardware validation before merge if that's the expectation, or as a follow-up if simulator + imgtool-test coverage is considered sufficient for a gated, off-by-default feature. Please advise.

CI expected to exercise:

  • Sim workflow — new feature combinations.
  • Build Zephyr samples with Twister — existing coverage unaffected; the new option defaults off.
  • imgtool workflow — new unit tests.

Design questions for reviewers

Three points where I made a choice but should be considered open:

1. Symbol-renaming mechanism. I added --name-suffix to imgtool getpub / getpubhash. The alternative was a post-processing rename script inside the Zephyr CMake build. I preferred the imgtool route because it's reusable across ports (Mynewt, Espressif, sim), unit-testable, and keeps the generated sources clean. If you'd rather keep imgtool's CLI surface smaller, I can move the rename logic into the build system — let me know.

2. autogen-pubkey2.c vs autogen-pubkey2.h. The existing mechanism for the primary key emits build/zephyr/autogen-pubkey.h and #includes it from boot/zephyr/keys.c. This PR emits autogen-pubkey2.c and links it as a separate source file instead. I chose this because it keeps keys.c structurally unchanged (just an added extern block) and avoids a conditional #include of a file that may not exist. If you prefer symmetry with the primary-key mechanism (i.e. a .h #included conditionally), I'll switch — small change, called out here so it's not a surprise.

3. Scope of sig-second-key in the simulator. Currently wired for sig-ed25519 only. Extending to RSA and ECDSA in sim/mcuboot-sys/csupport/keys.c is mechanical (another root_pub_der_2 for each type). Happy to do it in this PR if you'd rather see full matrix coverage before merge, or as a follow-up if you'd rather land the Zephyr-port change first.

4. Kconfig naming. I went with BOOT_SIGNATURE_KEY_FILE_2. The alternative is a list-style BOOT_SIGNATURE_KEY_FILES taking whitespace-separated paths. In practice every multi-key fleet I've seen is "prod + dev", so two is the right starting point, and _N leaves room to grow. Push back if you'd rather pick the list form from the start.

@JPHutchins
Copy link
Copy Markdown
Contributor Author

Local test runs

Click to expand
$ cd scripts && /home/jp/repos/mcuboot/scratch/imgtool-venv/bin/pytest -v
===================================== test session starts =====================================
platform linux -- Python 3.12.3, pytest-9.0.3, pluggy-1.6.0 -- /home/jp/repos/mcuboot/scratch/imgtool-venv/bin/python3
cachedir: .pytest_cache
rootdir: /home/jp/repos/mcuboot/scripts
collected 195 items

tests/keys/test_ecdsa.py::EcKeyGeneration::test_emit PASSED                             [  0%]
tests/keys/test_ecdsa.py::EcKeyGeneration::test_emit_pub PASSED                         [  1%]
tests/keys/test_ecdsa.py::EcKeyGeneration::test_keygen PASSED                           [  1%]
tests/keys/test_ecdsa.py::EcKeyGeneration::test_sig PASSED                              [  2%]
tests/keys/test_ed25519.py::Ed25519KeyGeneration::test_emit PASSED                      [  2%]
tests/keys/test_ed25519.py::Ed25519KeyGeneration::test_emit_pub PASSED                  [  3%]
tests/keys/test_ed25519.py::Ed25519KeyGeneration::test_keygen PASSED                    [  3%]
tests/keys/test_ed25519.py::Ed25519KeyGeneration::test_sig PASSED                       [  4%]
tests/keys/test_rsa.py::KeyGeneration::test_emit PASSED                                 [  4%]
tests/keys/test_rsa.py::KeyGeneration::test_emit_pub PASSED                             [  5%]
tests/keys/test_rsa.py::KeyGeneration::test_keygen PASSED                               [  5%]
tests/keys/test_rsa.py::KeyGeneration::test_sig PASSED                                  [  6%]
tests/test_commands.py::test_new_command PASSED                                         [  6%]
tests/test_commands.py::test_help PASSED                                                [  7%]
tests/test_commands.py::test_version PASSED                                             [  7%]
tests/test_commands.py::test_unknown PASSED                                             [  8%]
tests/test_commands.py::test_cmd_help[create] PASSED                                    [  8%]
tests/test_commands.py::test_cmd_help[dumpinfo] PASSED                                  [  9%]
tests/test_commands.py::test_cmd_help[getpriv] PASSED                                   [  9%]
tests/test_commands.py::test_cmd_help[getpub] PASSED                                    [ 10%]
tests/test_commands.py::test_cmd_help[getpubhash] PASSED                                [ 10%]
tests/test_commands.py::test_cmd_help[keygen] PASSED                                    [ 11%]
tests/test_commands.py::test_cmd_help[sign] PASSED                                      [ 11%]
tests/test_commands.py::test_cmd_help[verify] PASSED                                    [ 12%]
tests/test_commands.py::test_cmd_help[version] PASSED                                   [ 12%]
tests/test_commands.py::test_cmd_dif_help[create-create] PASSED                         [ 13%]
tests/test_commands.py::test_cmd_dif_help[create-dumpinfo] PASSED                       [ 13%]
tests/test_commands.py::test_cmd_dif_help[create-getpriv] PASSED                        [ 14%]
tests/test_commands.py::test_cmd_dif_help[create-getpub] PASSED                         [ 14%]
tests/test_commands.py::test_cmd_dif_help[create-getpubhash] PASSED                     [ 15%]
tests/test_commands.py::test_cmd_dif_help[create-keygen] PASSED                         [ 15%]
tests/test_commands.py::test_cmd_dif_help[create-sign] PASSED                           [ 16%]
tests/test_commands.py::test_cmd_dif_help[create-verify] PASSED                         [ 16%]
tests/test_commands.py::test_cmd_dif_help[create-version] PASSED                        [ 17%]
tests/test_commands.py::test_cmd_dif_help[dumpinfo-create] PASSED                       [ 17%]
tests/test_commands.py::test_cmd_dif_help[dumpinfo-dumpinfo] PASSED                     [ 18%]
tests/test_commands.py::test_cmd_dif_help[dumpinfo-getpriv] PASSED                      [ 18%]
tests/test_commands.py::test_cmd_dif_help[dumpinfo-getpub] PASSED                       [ 19%]
tests/test_commands.py::test_cmd_dif_help[dumpinfo-getpubhash] PASSED                   [ 20%]
tests/test_commands.py::test_cmd_dif_help[dumpinfo-keygen] PASSED                       [ 20%]
tests/test_commands.py::test_cmd_dif_help[dumpinfo-sign] PASSED                         [ 21%]
tests/test_commands.py::test_cmd_dif_help[dumpinfo-verify] PASSED                       [ 21%]
tests/test_commands.py::test_cmd_dif_help[dumpinfo-version] PASSED                      [ 22%]
tests/test_commands.py::test_cmd_dif_help[getpriv-create] PASSED                        [ 22%]
tests/test_commands.py::test_cmd_dif_help[getpriv-dumpinfo] PASSED                      [ 23%]
tests/test_commands.py::test_cmd_dif_help[getpriv-getpriv] PASSED                       [ 23%]
tests/test_commands.py::test_cmd_dif_help[getpriv-getpub] PASSED                        [ 24%]
tests/test_commands.py::test_cmd_dif_help[getpriv-getpubhash] PASSED                    [ 24%]
tests/test_commands.py::test_cmd_dif_help[getpriv-keygen] PASSED                        [ 25%]
tests/test_commands.py::test_cmd_dif_help[getpriv-sign] PASSED                          [ 25%]
tests/test_commands.py::test_cmd_dif_help[getpriv-verify] PASSED                        [ 26%]
tests/test_commands.py::test_cmd_dif_help[getpriv-version] PASSED                       [ 26%]
tests/test_commands.py::test_cmd_dif_help[getpub-create] PASSED                         [ 27%]
tests/test_commands.py::test_cmd_dif_help[getpub-dumpinfo] PASSED                       [ 27%]
tests/test_commands.py::test_cmd_dif_help[getpub-getpriv] PASSED                        [ 28%]
tests/test_commands.py::test_cmd_dif_help[getpub-getpub] PASSED                         [ 28%]
tests/test_commands.py::test_cmd_dif_help[getpub-getpubhash] PASSED                     [ 29%]
tests/test_commands.py::test_cmd_dif_help[getpub-keygen] PASSED                         [ 29%]
tests/test_commands.py::test_cmd_dif_help[getpub-sign] PASSED                           [ 30%]
tests/test_commands.py::test_cmd_dif_help[getpub-verify] PASSED                         [ 30%]
tests/test_commands.py::test_cmd_dif_help[getpub-version] PASSED                        [ 31%]
tests/test_commands.py::test_cmd_dif_help[getpubhash-create] PASSED                     [ 31%]
tests/test_commands.py::test_cmd_dif_help[getpubhash-dumpinfo] PASSED                   [ 32%]
tests/test_commands.py::test_cmd_dif_help[getpubhash-getpriv] PASSED                    [ 32%]
tests/test_commands.py::test_cmd_dif_help[getpubhash-getpub] PASSED                     [ 33%]
tests/test_commands.py::test_cmd_dif_help[getpubhash-getpubhash] PASSED                 [ 33%]
tests/test_commands.py::test_cmd_dif_help[getpubhash-keygen] PASSED                     [ 34%]
tests/test_commands.py::test_cmd_dif_help[getpubhash-sign] PASSED                       [ 34%]
tests/test_commands.py::test_cmd_dif_help[getpubhash-verify] PASSED                     [ 35%]
tests/test_commands.py::test_cmd_dif_help[getpubhash-version] PASSED                    [ 35%]
tests/test_commands.py::test_cmd_dif_help[keygen-create] PASSED                         [ 36%]
tests/test_commands.py::test_cmd_dif_help[keygen-dumpinfo] PASSED                       [ 36%]
tests/test_commands.py::test_cmd_dif_help[keygen-getpriv] PASSED                        [ 37%]
tests/test_commands.py::test_cmd_dif_help[keygen-getpub] PASSED                         [ 37%]
tests/test_commands.py::test_cmd_dif_help[keygen-getpubhash] PASSED                     [ 38%]
tests/test_commands.py::test_cmd_dif_help[keygen-keygen] PASSED                         [ 38%]
tests/test_commands.py::test_cmd_dif_help[keygen-sign] PASSED                           [ 39%]
tests/test_commands.py::test_cmd_dif_help[keygen-verify] PASSED                         [ 40%]
tests/test_commands.py::test_cmd_dif_help[keygen-version] PASSED                        [ 40%]
tests/test_commands.py::test_cmd_dif_help[sign-create] PASSED                           [ 41%]
tests/test_commands.py::test_cmd_dif_help[sign-dumpinfo] PASSED                         [ 41%]
tests/test_commands.py::test_cmd_dif_help[sign-getpriv] PASSED                          [ 42%]
tests/test_commands.py::test_cmd_dif_help[sign-getpub] PASSED                           [ 42%]
tests/test_commands.py::test_cmd_dif_help[sign-getpubhash] PASSED                       [ 43%]
tests/test_commands.py::test_cmd_dif_help[sign-keygen] PASSED                           [ 43%]
tests/test_commands.py::test_cmd_dif_help[sign-sign] PASSED                             [ 44%]
tests/test_commands.py::test_cmd_dif_help[sign-verify] PASSED                           [ 44%]
tests/test_commands.py::test_cmd_dif_help[sign-version] PASSED                          [ 45%]
tests/test_commands.py::test_cmd_dif_help[verify-create] PASSED                         [ 45%]
tests/test_commands.py::test_cmd_dif_help[verify-dumpinfo] PASSED                       [ 46%]
tests/test_commands.py::test_cmd_dif_help[verify-getpriv] PASSED                        [ 46%]
tests/test_commands.py::test_cmd_dif_help[verify-getpub] PASSED                         [ 47%]
tests/test_commands.py::test_cmd_dif_help[verify-getpubhash] PASSED                     [ 47%]
tests/test_commands.py::test_cmd_dif_help[verify-keygen] PASSED                         [ 48%]
tests/test_commands.py::test_cmd_dif_help[verify-sign] PASSED                           [ 48%]
tests/test_commands.py::test_cmd_dif_help[verify-verify] PASSED                         [ 49%]
tests/test_commands.py::test_cmd_dif_help[verify-version] PASSED                        [ 49%]
tests/test_commands.py::test_cmd_dif_help[version-create] PASSED                        [ 50%]
tests/test_commands.py::test_cmd_dif_help[version-dumpinfo] PASSED                      [ 50%]
tests/test_commands.py::test_cmd_dif_help[version-getpriv] PASSED                       [ 51%]
tests/test_commands.py::test_cmd_dif_help[version-getpub] PASSED                        [ 51%]
tests/test_commands.py::test_cmd_dif_help[version-getpubhash] PASSED                    [ 52%]
tests/test_commands.py::test_cmd_dif_help[version-keygen] PASSED                        [ 52%]
tests/test_commands.py::test_cmd_dif_help[version-sign] PASSED                          [ 53%]
tests/test_commands.py::test_cmd_dif_help[version-verify] PASSED                        [ 53%]
tests/test_commands.py::test_cmd_dif_help[version-version] PASSED                       [ 54%]
tests/test_compression.py::test_lzma2_compression[lzma2-True] PASSED                    [ 54%]
tests/test_compression.py::test_lzma2_compression[disabled-False] PASSED                [ 55%]
tests/test_keys.py::test_keygen[rsa-2048] PASSED                                        [ 55%]
tests/test_keys.py::test_keygen[rsa-3072] PASSED                                        [ 56%]
tests/test_keys.py::test_keygen[ecdsa-p256] PASSED                                      [ 56%]
tests/test_keys.py::test_keygen[ecdsa-p384] PASSED                                      [ 57%]
tests/test_keys.py::test_keygen[ed25519] PASSED                                         [ 57%]
tests/test_keys.py::test_keygen[x25519] PASSED                                          [ 58%]
tests/test_keys.py::test_keygen_type[rsa-2048] PASSED                                   [ 58%]
tests/test_keys.py::test_keygen_type[rsa-3072] PASSED                                   [ 59%]
tests/test_keys.py::test_keygen_type[ecdsa-p256] PASSED                                 [ 60%]
tests/test_keys.py::test_keygen_type[ecdsa-p384] PASSED                                 [ 60%]
tests/test_keys.py::test_keygen_type[ed25519] PASSED                                    [ 61%]
tests/test_keys.py::test_keygen_type[x25519] PASSED                                     [ 61%]
tests/test_keys.py::test_getpriv[openssl-rsa-2048] PASSED                               [ 62%]
tests/test_keys.py::test_getpriv[openssl-rsa-3072] PASSED                               [ 62%]
tests/test_keys.py::test_getpriv[openssl-ecdsa-p256] PASSED                             [ 63%]
tests/test_keys.py::test_getpriv[openssl-ecdsa-p384] PASSED                             [ 63%]
tests/test_keys.py::test_getpriv[openssl-ed25519] XFAIL                                 [ 64%]
tests/test_keys.py::test_getpriv[openssl-x25519] XFAIL                                  [ 64%]
tests/test_keys.py::test_getpriv[pkcs8-rsa-2048] XFAIL                                  [ 65%]
tests/test_keys.py::test_getpriv[pkcs8-rsa-3072] XFAIL                                  [ 65%]
tests/test_keys.py::test_getpriv[pkcs8-ecdsa-p256] PASSED                               [ 66%]
tests/test_keys.py::test_getpriv[pkcs8-ecdsa-p384] PASSED                               [ 66%]
tests/test_keys.py::test_getpriv[pkcs8-ed25519] XFAIL                                   [ 67%]
tests/test_keys.py::test_getpriv[pkcs8-x25519] PASSED                                   [ 67%]
tests/test_keys.py::test_getpub[lang-c-rsa-2048] PASSED                                 [ 68%]
tests/test_keys.py::test_getpub[lang-c-rsa-3072] PASSED                                 [ 68%]
tests/test_keys.py::test_getpub[lang-c-ecdsa-p256] PASSED                               [ 69%]
tests/test_keys.py::test_getpub[lang-c-ecdsa-p384] PASSED                               [ 69%]
tests/test_keys.py::test_getpub[lang-c-ed25519] PASSED                                  [ 70%]
tests/test_keys.py::test_getpub[lang-c-x25519] PASSED                                   [ 70%]
tests/test_keys.py::test_getpub[lang-rust-rsa-2048] PASSED                              [ 71%]
tests/test_keys.py::test_getpub[lang-rust-rsa-3072] PASSED                              [ 71%]
tests/test_keys.py::test_getpub[lang-rust-ecdsa-p256] PASSED                            [ 72%]
tests/test_keys.py::test_getpub[lang-rust-ecdsa-p384] PASSED                            [ 72%]
tests/test_keys.py::test_getpub[lang-rust-ed25519] PASSED                               [ 73%]
tests/test_keys.py::test_getpub[lang-rust-x25519] PASSED                                [ 73%]
tests/test_keys.py::test_getpub[pem-rsa-2048] PASSED                                    [ 74%]
tests/test_keys.py::test_getpub[pem-rsa-3072] PASSED                                    [ 74%]
tests/test_keys.py::test_getpub[pem-ecdsa-p256] PASSED                                  [ 75%]
tests/test_keys.py::test_getpub[pem-ecdsa-p384] PASSED                                  [ 75%]
tests/test_keys.py::test_getpub[pem-ed25519] XFAIL                                      [ 76%]
tests/test_keys.py::test_getpub[pem-x25519] PASSED                                      [ 76%]
tests/test_keys.py::test_getpub[raw-rsa-2048] PASSED                                    [ 77%]
tests/test_keys.py::test_getpub[raw-rsa-3072] PASSED                                    [ 77%]
tests/test_keys.py::test_getpub[raw-ecdsa-p256] PASSED                                  [ 78%]
tests/test_keys.py::test_getpub[raw-ecdsa-p384] PASSED                                  [ 78%]
tests/test_keys.py::test_getpub[raw-ed25519] PASSED                                     [ 79%]
tests/test_keys.py::test_getpub[raw-x25519] PASSED                                      [ 80%]
tests/test_keys.py::test_getpubhash[lang-c-rsa-2048] PASSED                             [ 80%]
tests/test_keys.py::test_getpubhash[lang-c-rsa-3072] PASSED                             [ 81%]
tests/test_keys.py::test_getpubhash[lang-c-ecdsa-p256] PASSED                           [ 81%]
tests/test_keys.py::test_getpubhash[lang-c-ecdsa-p384] PASSED                           [ 82%]
tests/test_keys.py::test_getpubhash[lang-c-ed25519] PASSED                              [ 82%]
tests/test_keys.py::test_getpubhash[lang-c-x25519] PASSED                               [ 83%]
tests/test_keys.py::test_getpubhash[raw-rsa-2048] PASSED                                [ 83%]
tests/test_keys.py::test_getpubhash[raw-rsa-3072] PASSED                                [ 84%]
tests/test_keys.py::test_getpubhash[raw-ecdsa-p256] PASSED                              [ 84%]
tests/test_keys.py::test_getpubhash[raw-ecdsa-p384] PASSED                              [ 85%]
tests/test_keys.py::test_getpubhash[raw-ed25519] PASSED                                 [ 85%]
tests/test_keys.py::test_getpubhash[raw-x25519] PASSED                                  [ 86%]
tests/test_keys.py::test_getpub_name_suffix_c[rsa-2048] PASSED                          [ 86%]
tests/test_keys.py::test_getpub_name_suffix_c[rsa-3072] PASSED                          [ 87%]
tests/test_keys.py::test_getpub_name_suffix_c[ecdsa-p256] PASSED                        [ 87%]
tests/test_keys.py::test_getpub_name_suffix_c[ecdsa-p384] PASSED                        [ 88%]
tests/test_keys.py::test_getpub_name_suffix_c[ed25519] PASSED                           [ 88%]
tests/test_keys.py::test_getpub_name_suffix_c[x25519] PASSED                            [ 89%]
tests/test_keys.py::test_getpub_name_suffix_rust[rsa-2048] PASSED                       [ 89%]
tests/test_keys.py::test_getpub_name_suffix_rust[rsa-3072] PASSED                       [ 90%]
tests/test_keys.py::test_getpub_name_suffix_rust[ecdsa-p256] PASSED                     [ 90%]
tests/test_keys.py::test_getpub_name_suffix_rust[ecdsa-p384] PASSED                     [ 91%]
tests/test_keys.py::test_getpub_name_suffix_rust[ed25519] PASSED                        [ 91%]
tests/test_keys.py::test_getpub_name_suffix_rust[x25519] PASSED                         [ 92%]
tests/test_keys.py::test_getpub_name_suffix_rejected[pem] PASSED                        [ 92%]
tests/test_keys.py::test_getpub_name_suffix_rejected[raw] PASSED                        [ 93%]
tests/test_keys.py::test_getpubhash_name_suffix_c[rsa-2048] PASSED                      [ 93%]
tests/test_keys.py::test_getpubhash_name_suffix_c[rsa-3072] PASSED                      [ 94%]
tests/test_keys.py::test_getpubhash_name_suffix_c[ecdsa-p256] PASSED                    [ 94%]
tests/test_keys.py::test_getpubhash_name_suffix_c[ecdsa-p384] PASSED                    [ 95%]
tests/test_keys.py::test_getpubhash_name_suffix_c[ed25519] PASSED                       [ 95%]
tests/test_keys.py::test_getpubhash_name_suffix_c[x25519] PASSED                        [ 96%]
tests/test_keys.py::test_getpubhash_name_suffix_rejects_raw PASSED                      [ 96%]
tests/test_keys.py::test_sign_verify[rsa-2048] PASSED                                   [ 97%]
tests/test_keys.py::test_sign_verify[rsa-3072] PASSED                                   [ 97%]
tests/test_keys.py::test_sign_verify[ecdsa-p256] PASSED                                 [ 98%]
tests/test_keys.py::test_sign_verify[ecdsa-p384] PASSED                                 [ 98%]
tests/test_keys.py::test_sign_verify[ed25519] PASSED                                    [ 99%]
tests/test_keys.py::test_sign_verify[x25519] XFAIL                                      [100%]

=============================== 188 passed, 7 xfailed in 7.95s ================================

$ cd sim && cargo test --features sig-ed25519 --test core
warning: use of deprecated function `rand::thread_rng`: Renamed to `rng`
    --> sim/src/image.rs:1707:29
     |
1707 |         let mut rng = rand::thread_rng();
     |                             ^^^^^^^^^^
     |
     = note: `#[warn(deprecated)]` on by default

warning: use of deprecated method `rand::Rng::gen_range`: Renamed to `random_range`
    --> sim/src/image.rs:1711:37
     |
1711 |             let reset_counter = rng.gen_range(1 ..= remaining_ops / 2);
     |                                     ^^^^^^^^^

warning: `bootsim` (lib) generated 2 warnings
    Finished `test` profile [optimized + debuginfo] target(s) in 0.06s
     Running tests/core.rs (/home/jp/repos/mcuboot/target/debug/deps/core-658591a82fff9de3)

running 28 tests
test dependency_combos ... ok
test bootstrap ... ok
test hw_prot_failed_security_cnt_check ... ok
test hw_prot_missing_security_cnt ... ok
test direct_xip_first ... ok
test oversized_bootstrap ... ok
test multi_key::signed_primary_key_boots ... ok
test norevert_newimage ... ok
test oversized_secondary_slot ... ok
test ram_load_corrupt_higher_version_image ... ok
test ram_load_missing_header_flag ... ok
test multi_key::signed_unknown_key_rejected ... ok
test ram_load_failed_validation ... ok
test ram_load_from_flash ... ok
test bad_secondary_slot ... ok
test multi_key::single_key_build_rejects_secondary_key_image ... ok
test ram_load_first ... ok
test downgrade_prevention ... ok
test ram_load_split ... ok
test ram_load_out_of_bounds ... ok
test secondary_trailer_leftover ... ok
test perm_with_random_fails ... ok
test norevert ... ok
test status_write_fails_complete ... ok
test status_write_fails_with_reset ... ok
test basic_revert ... ok
test perm_with_fails has been running for over 60 seconds
test revert_with_fails has been running for over 60 seconds
test perm_with_fails ... ok
test revert_with_fails ... ok

test result: ok. 28 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 284.80s


$ cd sim && cargo test --features 'sig-ed25519 sig-second-key' --test core
   Compiling bootsim v0.1.0 (/home/jp/repos/mcuboot/sim)
warning: use of deprecated function `rand::thread_rng`: Renamed to `rng`
    --> sim/src/image.rs:1707:29
     |
1707 |         let mut rng = rand::thread_rng();
     |                             ^^^^^^^^^^
     |
     = note: `#[warn(deprecated)]` on by default

warning: use of deprecated method `rand::Rng::gen_range`: Renamed to `random_range`
    --> sim/src/image.rs:1711:37
     |
1711 |             let reset_counter = rng.gen_range(1 ..= remaining_ops / 2);
     |                                     ^^^^^^^^^

warning: `bootsim` (lib) generated 2 warnings
    Finished `test` profile [optimized + debuginfo] target(s) in 4.54s
     Running tests/core.rs (/home/jp/repos/mcuboot/target/debug/deps/core-fc4e87a8e8bb9e24)

running 28 tests
test dependency_combos ... ok
test oversized_bootstrap ... ok
test hw_prot_failed_security_cnt_check ... ok
test hw_prot_missing_security_cnt ... ok
test bootstrap ... ok
test norevert_newimage ... ok
test direct_xip_first ... ok
test multi_key::signed_secondary_key_boots ... ok
test oversized_secondary_slot ... ok
test multi_key::signed_unknown_key_rejected ... ok
test ram_load_corrupt_higher_version_image ... ok
test multi_key::signed_primary_key_boots ... ok
test ram_load_failed_validation ... ok
test bad_secondary_slot ... ok
test ram_load_from_flash ... ok
test downgrade_prevention ... ok
test ram_load_out_of_bounds ... ok
test ram_load_missing_header_flag ... ok
test ram_load_first ... ok
test norevert ... ok
test secondary_trailer_leftover ... ok
test ram_load_split ... ok
test perm_with_random_fails ... ok
test status_write_fails_complete ... ok
test status_write_fails_with_reset ... ok
test basic_revert ... ok
test perm_with_fails has been running for over 60 seconds
test revert_with_fails has been running for over 60 seconds
test perm_with_fails ... ok
test revert_with_fails ... ok

test result: ok. 28 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 293.63s

zephyr: support 2 signing keys

Add optional kconfig BOOT_SIGNATURE_KEY_FILE_2. Update
keys.c to support multiple keys of the same type.

imgtool: add --name-suffix to getpub

Update documentation and test coverage.

sim: tests for multiple ed25519 keys

Update sim test cases to cover multiple keys.

Signed-off-by: JP Hutchins <jp@intercreate.io>
@nordicjm
Copy link
Copy Markdown
Collaborator

the idea here would need @de-nordic to look at it and approve, but do not add another Kconfig for another key, how do you add a 3rd key? a 9th key? 300 keys? Just use multiple files in the existing Kconfig and iterate them as a list in cmake

@de-nordic
Copy link
Copy Markdown
Collaborator

the idea here would need @de-nordic to look at it and approve, but do not add another Kconfig for another key, how do you add a 3rd key? a 9th key? 300 keys? Just use multiple files in the existing Kconfig and iterate them as a list in cmake

No additional Kconfigs, if we are going to go with this then we should rather list keys into the existing CONFIG_BOOT_SIGNATURE_KEY_FILE, as @nordicjm has suggested.

I also do not see a reason to have multiple keys, that differ, if they basically open the same lock. You can not tell a difference between them, they do not change behaviour of an app at the MCUboot level.
There is no mechanism to disable any of the keys, so the the chances of loosing any of these basically doubles without means to mitigate the looses.
The changes of guessing the keys also doubles, but that is insignificant impact.

@JPHutchins
Copy link
Copy Markdown
Contributor Author

do not add another Kconfig for another key, how do you add a 3rd key? a 9th key? 300 keys? Just use multiple files in the existing Kconfig and iterate them as a list in cmake

Good idea, I think that's achievable. The one wrinkle that might come up during Zephyr integration is selection of the application signing key. With multiple KConfig entries, the primary key can state that it will be used to sign the image. With a single KConfig entry, that help text would instead state that the first key in the list will be used to sign the image, and therefore needs to be a private signing key.

See the draft integration here for details: intercreate/zephyr#1

@JPHutchins
Copy link
Copy Markdown
Contributor Author

JPHutchins commented Apr 29, 2026

I also do not see a reason to have multiple keys, that differ, if they basically open the same lock. You can not tell a difference between them, they do not change behaviour of an app at the MCUboot level.
There is no mechanism to disable any of the keys, so the the chances of loosing any of these basically doubles without means to mitigate the looses.
The changes of guessing the keys also doubles, but that is insignificant impact.

Let's discuss at #2700. It is an MCUBoot feature designed to improve security, not weaken it. My Zephyr integration draft demonstrates correct use of public keys. I will take note that I should warn users about use of private keys in the examples and KConfig.

I do agree that "increased chance of guessing keys" has no security impact, given their cryptographic attributes.

@nordicjm
Copy link
Copy Markdown
Collaborator

Good idea, I think that's achievable. The one wrinkle that might come up during Zephyr integration is selection of the application signing key.

If a list is provided then CMake can provide the first item in the list to the images and the full value to MCUboot, the help text can be updated to specify this

Let's discuss at #2700. It is an MCUBoot feature designed to improve security, not weaken it

I assume what Dominik is meaning is if you generate/store all the keys on the same system then one key getting compromised = all keys getting compromised, and the other that if the key is cracked through bruteforce (assuming that in the future the time required to bruteforce a key is reduced substantially) then bruteforcing one is not much difference to bruteforcing two, and the final point that if a key is cracked by luck (and this has happened in the past to some very big name company's) then having 2 keys where they are both always valid means that you do not add any extra security to the device

@JPHutchins
Copy link
Copy Markdown
Contributor Author

@nordicjm @de-nordic Thanks again for taking the time to review! I'm eager to dig into the details here and make the necessary adjustments. It seems like the main concern is that two or more keys that are always valid create more attack surface - from one or more of the keys being leaked or from a lucky or quantum crack - and that it does not have a significant security benefit.

This is a fair concern if the keys have equivalent custody and their compromise has equivalent blast radius, but this feature exists precisely to enable the workflow where they do not.

Threat Model

The threat model this feature targets is one of custody, not cryptographic strength. The keys are cryptographically equivalent; they are not operationally equivalent. Asymmetric cryptography lets public and private material live under asymmetric custody, and this feature lets the Zephyr port use that distinction.

Key Custody Distribution Blast radius if lost
Prod private HSM, signing ceremony, release team only Never leaves the HSM Catastrophic: attacker can sign images that boot on prod fleet
Prod public N/A (public material) Freely distributed; embedded in every dev bootloader so dev units can verify prod images None: public by design
Dev private Loosely held by engineers Held on dev workstations / dev signing infra Bounded: only authorizes firmware on non-deployed dev hardware

The security gain is at the custody layer, not the cryptography layer. This feature lets organizations stop exercising the prod private key for day-to-day engineering work. Every signing event is operational risk, every additional person with access to the prod private key is operational risk, and reducing both is one of the most effective ways to protect a long-lived signing key.

Design Intent

The feature is not a new direction for MCUboot, but rather an implementation of an MCUboot capability that Zephyr is unable to use.

From docs/signed_images.md:

This facility allows you to use multiple signing keys. This would
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.

From docs/readme-zephyr.md:

Currently, the Zephyr RTOS port limits its support to one keypair at the time, although MCUboot's key management infrastructure supports multiple keypairs.

#2702 is the parallel imgtool change formalizing public-only PEM support for exactly this workflow: same dev/prod custody pattern, applied to the signing tool rather than the bootloader.

Discussion

if you generate/store all the keys on the same system then one key getting compromised = all keys getting compromised

I agree, and it would represent a misuse of this feature, since it exists to enable separate custody. I'll add CMake-time validation that only the first of the list is private, KConfig help text, and docs to make sure teams don't misuse this feature. The Zephyr integration will include a sample app with standard security practice (intercreate/zephyr#1).

if the key is cracked through bruteforce (assuming that in the future the time required to bruteforce a key is reduced substantially) then bruteforcing one is not much difference to bruteforcing two

Agreed. This feature is not intended to strengthen cryptographic security; it strengthens custody.

if a key is cracked by luck (and this has happened in the past to some very big name company's) then having 2 keys where they are both always valid means that you do not add any extra security to the device

True, but as above, this doesn't apply directly since the feature is not claiming to strengthen the cryptographic security. Worth noting that only tightly held developer units would be flashed with a bootloader capable of booting from N keys. Production units should be flashed with a bootloader that trusts only the prod key. A lucky crack of the dev key gives an attacker no path onto a prod unit.

I also do not see a reason to have multiple keys, that differ, if they basically open the same lock. You can not tell a difference between them, they do not change behaviour of an app at the MCUboot level.

Correct, and they should not differ there. The differentiation is which bootloader binary embeds which public keys, not the verification logic. bootutil_find_key() already handles N keys correctly via the KEYHASH TLV match. MCUboot identifies which embedded key signed a given image, it just doesn't branch on the answer beyond accept/reject.

There is no mechanism to disable any of the keys, so the the chances of loosing any of these basically doubles without means to mitigate the looses.

The custody of the different keys is not equivalent, and adding a second key does not change the loss probability of the first. Losing a dev private signing key compromises the inventory of non-deployed hardware. Losing the production private signing key is the catastrophic event, and this feature reduces the chance of that happening. I agree that a revocation mechanism would be valuable, but that is a separate feature and not related to updating Zephyr's port to support MCUBoot's existing multi-key verification.

Summary

The feature enables a documented MCUboot capability that the Zephyr port currently can't use. I agree with the cryptographic-strength concerns. That is not the threat this PR addresses. I also agree that the multi-key capability may be misused, but with updates to validation, documentation, and samples, it will be a net gain by providing a way for teams to maintain narrow custody of their production private signing keys.

I will get started on the following improvements:

  • Rework Kconfig from BOOT_SIGNATURE_KEY_FILE_2 to a list inside the existing BOOT_SIGNATURE_KEY_FILE, per @nordicjm's suggestion.
  • Kconfig help text clarifying that the first entry is the key the application signs with, and that subsequent entries should be public-only PEMs.
  • CMake-time validation rejecting (or warning on, if reviewers prefer) private-key PEMs in any position past the first.
  • Documentation updates in signed_images.md and readme-zephyr.md making the intended multi-key custody model explicit, and pointing at the integration sample as a worked example.
  • Zephyr integration sample at intercreate/zephyr#1 demonstrating the prod/dev custody pattern with a public-only prod_pubkey.pem.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

zephyr: support embedding a second signing key (CONFIG_BOOT_SIGNATURE_KEY_FILE_2)

3 participants