Skip to content

Add LMS signature support#2707

Open
d3zd3z wants to merge 22 commits into
mcu-tools:mainfrom
d3zd3z:add-lms
Open

Add LMS signature support#2707
d3zd3z wants to merge 22 commits into
mcu-tools:mainfrom
d3zd3z:add-lms

Conversation

@d3zd3z
Copy link
Copy Markdown
Member

@d3zd3z d3zd3z commented Apr 23, 2026

Note, this is based on #2713, and includes all of the commits from that PR. This will be simplified once that PR merges.

Add support for LMS signatures. This uses Mbed TLS 4.1's newly added support for LMS signatures. Only sha256 w8 h10 is supported as this is the only configuration supported by Mbed TLS.

This adds support for LMS to:

  • imgtool, which can now generate keys, and sign and verify images with LMS.
  • sim, which also generates keys, and signs and tests images with LMS.
  • boot/bootutil, which can verify these signatures.
    Currently, only simulator support is present, and no platforms support this yet.

LMS comes with a strong security caveat:
WARNING: LMS has stateful keys. A given private key is only able to sign a fixed number of messages (1024 with this configuration). Effectively, a generated private key has 1024 subkeys, each of which is able to sign a single message. If this is violated (for example the private key copied to another machine, or backed up and restored), and more than one different message is signed with the same subkey, the entire private key will be compromised. This generally requires special handling procedures with the signing processes.

@d3zd3z d3zd3z force-pushed the add-lms branch 9 times, most recently from a0b707b to 3ef1749 Compare April 29, 2026 18:12
@d3zd3z d3zd3z force-pushed the add-lms branch 2 times, most recently from 07a0e39 to 545b7e1 Compare May 1, 2026 04:08
@d3zd3z d3zd3z marked this pull request as ready for review May 1, 2026 04:08
@d3zd3z d3zd3z changed the title LMS support planning Add LMS signature support May 1, 2026
@d3zd3z d3zd3z requested review from de-nordic and nordicjm May 1, 2026 22:52
@OleksandrShkurchenko
Copy link
Copy Markdown
Contributor

@d3zd3z In case of Post Quantum Algorithms(PQC) (not LMS only) we should consider supporting of Hybrid Mode for signature validation (or we can call it: Double signature mode) as additional option.
Reason: many users still don`t trust existing PQC algorithms and consider ESDSA as reliable at this moment.

@d3zd3z
Copy link
Copy Markdown
Member Author

d3zd3z commented May 5, 2026

@d3zd3z In case of Post Quantum Algorithms(PQC) (not LMS only) we should consider supporting of Hybrid Mode for signature validation (or we can call it: Double signature mode) as additional option. Reason: many users still don`t trust existing PQC algorithms and consider ESDSA as reliable at this moment.

Possibly for ML-DSA as there is concerns about it's newness. LMS does not have these concerns, and other than the challenges with the stateful signing key, and the larger signatures, there is little reason to not just use LMS.

To do hybrid, probably that needs to be done with a TLV mechanism to have AND signatures instead of just OR signatures as it is now.

But, again, I don't think there is any reason to do that with LMS. LMS is arguably better understood than EC.

@kkrentz
Copy link
Copy Markdown
Contributor

kkrentz commented May 13, 2026

I agree that a hybrid mode is questionable here since LMS' security rests on fewer assumptions than ECDSA.

WARNING: LMS has stateful keys. A given private key is only able to sign a fixed number of messages (1024 with this configuration). Effectively, a generated private key has 1024 subkeys, each of which is able to sign a single message. If this is violated (for example the private key copied to another machine, or backed up and restored), and more than one different message is signed with the same subkey, the entire private key will be compromised. This generally requires special handling procedures with the signing processes.

To reduce the risk of key reuse, this paper suggests reusing the version number of the firmware image as the LMS sequence number. In that case, the key does not have to be stateful because the state is somewhat externally managed. I actually prototyped their approach some time ago.

@d3zd3z
Copy link
Copy Markdown
Member Author

d3zd3z commented May 13, 2026

To reduce the risk of key reuse, this paper suggests reusing the version number of the firmware image as the LMS sequence number. In that case, the key does not have to be stateful because the state is somewhat externally managed. I actually prototyped their approach some time ago.

One of the challenges with that is that it places constraints on the version numbers that can be used, and has additional questions as well (these two things aren't really correlated at all), the there might be different major version bumps, whereas a simple sequence number would kind of imply just a sequence.

The issue is very real, though, and maybe there is value in encoding the key number more clearly in the image (I'm not actually sure if you can deduce it from the signature itself). But, I think we want to be careful in terms how of how much we try to impose on the signing side and infrastructure.

d3zd3z added 8 commits May 13, 2026 13:29
Rename the Mbed TLS submodule's path from ext/mbedtls to
ext/mbedtls-3.6.0 to make room for a second submodule pinned at
Mbed TLS 4.1.0, which will be added in a follow-up. This clears
the way to build and test MCUboot's PSA-based crypto paths against
the Mbed TLS 4.x release series alongside the current 3.6 LTS.

The dual-submodule arrangement lets the simulator's legacy-crypto
feature paths (sig-rsa, sig-ecdsa-mbedtls, enc-rsa, enc-ec256-mbedtls,
enc-x25519-mbedtls) continue using the 3.6 LTS series while new and
PSA-based code migrates to 4.1 incrementally. Mbed TLS 4.x relocates
the legacy crypto API to a private/ header tier and splits sources
across a new tf-psa-crypto submodule, so a straight in-place bump
would force all feature paths to be reworked at once.

No content change for the submodule itself; it stays at v3.6.0
(2ca6c285a0). Updated:
- .gitmodules path entry
- .mbedignore
- sim/mcuboot-sys/build.rs (all 148 source/include paths)
- boot/espressif/CMakeLists.txt and crypto_config/rsa.cmake
- docs/readme-espressif.md (instructions for submodule init)

No changes required in boot/zephyr/, which uses Zephyr's own mbedtls
module rather than this submodule.

Assisted-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Signed-off-by: David Brown <david.brown@linaro.org>
Add Mbed TLS v4.1.0 alongside the existing 3.6.0 submodule. Nothing
in the tree references this path yet; wiring up the simulator or
any OS port against 4.1 comes in follow-up commits as each feature
is migrated.

Mbed TLS 4.x restructures the repository into a top-level tree for
TLS/X.509/etc and a nested tf-psa-crypto submodule for crypto. A
shared framework/ submodule holds build infrastructure. After
cloning, run:

    git submodule update --init --recursive ext/mbedtls-4.1.0

to fetch both nested submodules.

Pinned commit: 0fe989b6b5 (v4.1.0).

Assisted-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Signed-off-by: David Brown <david.brown@linaro.org>
Drive the Mbed TLS 4.1 (TF-PSA-Crypto 1.1.0) build through its own
CMakeLists.txt instead of recompiling a hand-picked file list from
build.rs. This follows the same shift Zephyr made when it moved to
Mbed TLS 4.1: stop shadowing the upstream build and let upstream own
the (still-evolving) 4.x file layout, generator plumbing, and config-
adjustment logic. Our only inputs become the config header and a
handful of CMake -D knobs.

What changed:

- `sim/mcuboot-sys/Cargo.toml`: new `mbedtls-v4` feature and
  `cmake = "0.1"` build-dep.
- `sim/Cargo.toml`: forwards the feature to `mcuboot-sys`.
- `sim/mcuboot-sys/csupport/config-ec-psa-v4.h`: minimal TF-PSA-Crypto
  1.1.0 configuration (PSA_WANT_* macros); the 4.x build auto-derives
  legacy MBEDTLS_*_C internals.
- `sim/mcuboot-sys/build.rs`: new `add_mbedtls_v4_psa_ecdsa()` branch
  invokes the `cmake` crate on `ext/mbedtls-4.1.0/tf-psa-crypto` with
  `TF_PSA_CRYPTO_CONFIG_FILE` + `ENABLE_{PROGRAMS,TESTING}=OFF`,
  builds the `tfpsacrypto` target, and links the resulting static
  library. The boot-code cc::Build gets the matching include paths
  and `-DTF_PSA_CRYPTO_CONFIG_FILE` so public headers see the same
  config.
- `sim/mcuboot-sys/csupport/psa_rng_stub_v4.c`: self-contained stub
  for `mbedtls_psa_external_get_random` + the test-RNG enable/disable
  toggles. The equivalent upstream test shim drags in the whole
  test-framework header tree; verification-only tests only need a
  symbol that resolves and returns bytes.
- `scripts/requirements.txt`: `jinja2`, `jsonschema` — the 4.x
  CMake's GEN_FILES path invokes Python generators for
  `psa_crypto_driver_wrappers*` and `tf_psa_crypto_config_check_*.h`.

Tested (arm64 macOS):
- `MCUBOOT_SKIP_SLOW_TESTS=1 cargo test --features \
   sig-ecdsa-psa,mbedtls-v4 -- --test-threads=1` → 25/25.
- `cargo build --features sig-ecdsa-psa` (no mbedtls-v4) still
  builds against 3.6.0 unchanged.

Developers need `cmake` in PATH and `jinja2`+`jsonschema` available
to the Python the build invokes.

Assisted-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Signed-off-by: David Brown <david.brown@linaro.org>
Add three new matrix rows exercising the `mbedtls-v4` Cargo feature
across orthogonal feature combinations:

1. Mirror of the existing sig-ecdsa-psa row with `mbedtls-v4`
   appended (base, sig-p384, swap-move+bootstrap+max-align-16). The
   CMake-driven 4.x build is expected to match the 3.6 path here
   since the crypto config is the same shape.
2. Orthogonal features untested on the 3.6 sig-ecdsa-psa path
   (swap-offset, validate-primary-slot, overwrite-only, multiimage).
   These don't touch the crypto surface, so they ought to work on
   4.1 too; good shakedown of the CMake build surface.
3. Reset-resilience / XIP / rollback combinations (ram-load,
   direct-xip, overwrite-only+downgrade-prevention,
   hw-rollback-protection+multiimage).

Verified locally with `ptest -t 63..=73 run`: 11/11 pass.

Assisted-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Signed-off-by: David Brown <david.brown@linaro.org>
Three carry patches to make boot/bootutil/src/encrypted_psa.c compile
under Mbed TLS 4.1 with the simulator's -Werror -Wall -Wextra:

- Define MBEDTLS_OID_EC_ALG_UNRESTRICTED / _EC_GRP_SECP256R1 locally
  when unavailable. In 4.x these moved from the public
  `mbedtls/oid.h` to a private header
  (`tf-psa-crypto/utilities/crypto_oid.h`); supply the raw OID bytes
  rather than depend on a private include path.
- Cast the "MCUBoot_ECIES_v1" literal to `const uint8_t *` when
  handed to psa_key_derivation_input_bytes, silencing
  -Wpointer-sign.
- Mark `blk_off` as intentionally unused in bootutil_aes_ctr_{encrypt,
  decrypt}; the PSA cipher API handles CTR block alignment
  internally. Silences -Wunused-parameter.

Temporary local carries. File was originally written against Mbed TLS
3.x PSA mode; these should flow back upstream as a proper 4.x
compatibility fix, at which point this commit can be dropped.

Assisted-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Signed-off-by: David Brown <david.brown@linaro.org>
…s-v4

On the 3.6 path, `sig-ecdsa-psa enc-ec256` falls into the
`psa_crypto_init_stub.c` shim: PSA init is a no-op and the actual
ECIES work runs on TinyCrypt. On the mbedtls-v4 path we can do
better — the tfpsacrypto library is already being linked for the
signature side, so route image encryption through it too.

What changed:

- `sim/mcuboot-sys/csupport/config-ec-psa-v4.h`: new
  `#if defined(MCUBOOT_ENCRYPT_EC256)` block enabling PSA_WANT_*
  entries for ECDH, HKDF, CTR, HMAC, AES, HMAC keys, and
  ECC_KEY_PAIR_{DERIVE,IMPORT}. ECC curve (SECP_R1_256) is already on
  unconditionally for the signature path.
- `sim/mcuboot-sys/build.rs`:
  - `add_mbedtls_v4_psa_ecdsa()` now takes `enc_ec256` and forwards
    `-DMCUBOOT_ENCRYPT_EC256` to the CMake build so the library and
    boot code evaluate `#if defined(...)` identically.
  - New `enc_ec256 && mbedtls_v4` branch: pulls in both
    `encrypted.c` (high-level `boot_enc_*` interface) and
    `encrypted_psa.c` (PSA primitives under `MCUBOOT_USE_PSA_CRYPTO`),
    does not define `MCUBOOT_USE_TINYCRYPT` (incompatible with
    `MCUBOOT_USE_PSA_CRYPTO`), and defines `CONFIG_BOOT_ECDSA_PSA`
    to gate out `encrypted.c`'s duplicated legacy ASN.1 + ECDH
    block (which references `MBEDTLS_OID_*` macros no longer public
    in 4.x).
  - Skip `MBEDTLS_CONFIG_FILE=<config-asn1.h>` when `mbedtls-v4`
    is set — 4.x uses `TF_PSA_CRYPTO_CONFIG_FILE` instead.
- `.github/workflows/sim.yaml`: new matrix row mirroring the 3.6
  enc-ec256 entries, now exercising real PSA.

Tested: `MCUBOOT_SKIP_SLOW_TESTS=1 cargo test --features \
sig-ecdsa-psa,enc-ec256,mbedtls-v4 --test core -- \
--test-threads=1` → 25/25. Pre-existing 3.6 combinations (with and
without mbedtls-v4) unchanged.

Depends on boot/encrypted_psa local-carry patch (previous commit)
for the file to compile cleanly under 4.x + -Werror.

Assisted-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Signed-off-by: David Brown <david.brown@linaro.org>
enc-aes256-ec256 shares the ECIES-P256 machinery with enc-ec256 and
differs only in BOOT_ENC_KEY_SIZE (32 bytes vs. 16), gated by
MCUBOOT_AES_256. PSA_KEY_TYPE_AES covers all AES key sizes, so no
additional PSA_WANT_* entries are required — this is purely a
build.rs branch-extension plus a matrix row.

Changes:
- build.rs: the `enc_ec256 && mbedtls_v4` branch now also matches
  `enc_aes256_ec256 && mbedtls_v4`, defining MCUBOOT_AES_256 when
  the latter is selected. add_mbedtls_v4_psa_ecdsa() is told
  enc-ec256-equivalent encryption is active via
  `enc_ec256 || enc_aes256_ec256`, so it forwards
  `-DMCUBOOT_ENCRYPT_EC256` to CMake.
- build.rs: guard the `config-ec.h` MBEDTLS_CONFIG_FILE selection
  with `!mbedtls_v4` so enc-aes256-ec256+mbedtls-v4 doesn't pick up
  the 3.6-style config header.
- .github/workflows/sim.yaml: mirror of the 3.6 enc-aes256-ec256
  entries, routed through the PSA path.

Tested (arm64 macOS):
- `cargo test --features sig-ecdsa-psa,enc-aes256-ec256,mbedtls-v4`
  → 25/25.
- ptest -t 74..77 (enc-ec256 + enc-aes256-ec256 mbedtls-v4 entries)
  → 4/4.
- Pre-existing 3.6 entries 19, 20, 50 (enc-aes256-ec256 on 3.6)
  unchanged.

Assisted-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Signed-off-by: David Brown <david.brown@linaro.org>
The ptest harness writes per-test success/failure logs into the
ptest/ directory when run locally. Exclude them from version control
so stray logs don't clutter working trees.

Assisted-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Signed-off-by: David Brown <david.brown@linaro.org>
d3zd3z added 14 commits May 13, 2026 13:29
Clang 19 (shipping in Xcode 16) introduced
-Wdefault-const-init-var-unsafe, which with MCUboot's -Werror
simulator build rejects the const VLA in tc_hmac_set_key():

    const uint8_t dummy_key[key_size];

The buffer is a timing-equalisation device whose contents are fed
to tc_sha256_update() and then discarded. Since the enclosing branch
gates on key_size <= TC_SHA256_BLOCK_SIZE, replace the stack VLA
with a fixed-size zero-filled static const. tc_sha256_update() is
still passed key_size explicitly, so it reads the same number of
bytes as before:

    static const uint8_t dummy_key[TC_SHA256_BLOCK_SIZE] = { 0 };

This moves the dummy from stack to .rodata, which differs subtly in
cache/timing characteristics from the original stack VLA (which,
despite the const qualifier, lived on the stack). The impact on the
timing-equalisation intent is likely small but non-zero; tinycrypt
is not a timing-resistant library in general so this is mostly
academic.

Temporary local workaround. tinycrypt is vendored source with no
upstream maintenance; revisit when tinycrypt is retired from MCUboot.

Assisted-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Signed-off-by: David Brown <david.brown@linaro.org>
Reserve TLV type 0x26 for the LMS (Leighton-Micali Signatures,
RFC 8554) signature carried in the image trailer, add it to the
unprotected-TLV allow list in image_validate.c, and mirror the
definition into docs/design.md with a short subsection describing
the wire formats.

The public key (56 bytes) and signature (1452 bytes for the
LMS_SHA256_M32_H10 + LMOTS_SHA256_N32_W8 parameter set that
Mbed TLS 4.x supports) are defined by RFC 8554 rather than by
MCUboot; their sizes are a property of the chosen parameter set,
not a design choice. docs/design.md now spells out the
u32 lms_type | u32 lmots_type | 16-byte I | 32-byte T[1] layout of
the public key so a reader does not have to consult the RFC to
explain where the 56 comes from.

No new public-key TLV is needed: the existing IMAGE_TLV_PUBKEY /
IMAGE_TLV_KEYHASH pair carries the LMS public key or its SHA-256,
unchanged.

Assisted-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Signed-off-by: David Brown <david.brown@linaro.org>
Wrap pyhsslms (RFC 8554, BSD-3-Clause) so imgtool can generate LMS
keypairs, sign image digests, and verify the resulting signatures.
The bootloader-side verifier (Mbed TLS 4.x) currently supports only
LMS_SHA256_M32_H10 + LMOTS_SHA256_N32_W8, but the wrapper carries
the (lms_type, lmots_type) tuple through so a new entry in
LMS_PARAM_SETS is the only change needed to surface another variant
once the verifier supports it. Two are exposed initially:
lms-sha256-h10-w8 (matches the Mbed TLS set) and lms-sha256-h5-w8
(faster, useful for round-trip tests).

The key file is a custom PEM-style envelope ("BEGIN MCUBOOT LMS
PRIVATE KEY") holding the 60-byte serialized state
lms_type || lmots_type || SEED || I || q. keys/__init__.py:load()
sniffs the marker before falling through to the cryptography PEM
loader, so existing key types are unaffected.

LMS is built on a one-time signature scheme: the leaf index q must
never be reused. sign_digest() advances q in memory, then
atomically rewrites the key file (tmpfile + fsync + rename) before
returning the signature. If the disk write fails, no signature is
produced; if the caller crashes after the write but before
persisting the signed image, an LMS index is wasted but never
reused. The hazard is documented in the lms.py module docstring.

The public key is exported in the 56-byte RFC 8554 form
(u32 lms_type | u32 lmots_type | 16-byte I | 32-byte T[1]) and fed
unchanged into IMAGE_TLV_PUBKEY / IMAGE_TLV_KEYHASH as allocated in
the previous commit. image.py registers TLV_VALUES['LMS'] = 0x26
and ALLOWED_KEY_SHA[LMS] = ['256']; the LMS wrapper deliberately
omits a `verify` method so image.py's verify path picks up
`verify_digest` automatically.

Tests parameterize over keygens.keys(), so the new variants pass
test_keygen, test_getpub, test_getpubhash, and test_sign_verify
out of the box. test_keygen_type (uses `openssl pkey`) and
test_getpriv (PKCS8/openssl formats) skip for LMS since LMS keys
are not PEM/PKCS8.

Timing on CPython 3.13: h=10 keygen 3.9 s, load 3.9 s, sign and
verify ~2 ms each post-load; h=5 keygen and load 0.12 s each. Each
`imgtool sign` pays the load cost because pyhsslms only persists
the 60-byte state, not the 1024-leaf precomputed Merkle tree.

Assisted-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Signed-off-by: David Brown <david.brown@linaro.org>
Print human-readable information about a key file. For most key
types this is just the signature type (e.g. "PKCS1_PSS_RSA2048_SHA256"
or "ED25519"). For LMS keys it adds the parameter set name and the
maximum signature count, and for an LMS private key the count of
signatures used so far — useful given the stateful-private-key
hazard.

Implementation: a default key_info() on KeyClass returns a single
("Key type", sig_type()) row; LMS overrides to surface its
parameter set, with the private-key class extending the public's
output to include the q-vs-2^h usage. The CLI command formats the
returned (label, value) pairs with right-aligned labels.

Assisted-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Signed-off-by: David Brown <david.brown@linaro.org>
Add the lms-signature crate to bootsim's regular [dependencies] and
introduce a Rust integration test that round-trips LMS keys and
signatures with imgtool's pyhsslms wrapper.

Two test directions, neither using committed key material:

- python_signs_rust_verifies — Python (imgtool.keys.lms) generates a
  fresh keypair and signs; Rust parses the 56-byte public key and
  the RFC 8554 signature with VerifyingKey::try_from /
  Signature::try_from and verifies.
- rust_signs_python_verifies — Rust generates a SigningKey<...>::new,
  signs, and hands the pubkey + signature + message to a pyhsslms
  subprocess that calls LmsPublicKey.deserialize(...).verify(...).

The test uses LMS_SHA256_M32_H5 / LMOTS_SHA256_N32_W8 for keygen
speed (~120 ms). Both pyhsslms and lms-signature support it. When
bootloader-side verification (Mbed TLS 4.x) lands, the H10/W8 set
the boot path actually verifies can be added in parallel.

lms-signature lives in [dependencies] rather than [dev-dependencies]
because TlvGen will sign LMS test images at sim runtime once the
sig-lms path is wired up — sim does not call out to imgtool. The
new dep set (lms-signature, getrandom, rand_core, hybrid-array,
signature) will be reused there.

Assisted-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Signed-off-by: David Brown <david.brown@linaro.org>
Add a sim-only `sig-lms` Cargo feature that routes TlvGen through a
new `new_lms()` constructor. The simulator now produces LMS-signed
test images, but the bootloader-side verifier is not implemented yet,
so all upgrade tests fail at boot — that is the intended state for
this step and isolates the remaining work to the C side.

- `TlvKinds::LMS = 0x26` matches IMAGE_TLV_LMS in
  boot/bootutil/include/bootutil/image.h.
- `TlvGen::new_lms()` generates a fresh `LmsSigningKey<LmsScheme>`
  per call. One TlvGen signs once, so H5/W8's 32-leaf cap is plenty.
- `LmsScheme = LmsSha256M32H5<LmsOtsSha256N32W8>`. Mbed TLS 4.x
  verifies only the H10/W8 set, so when the C-side verifier lands,
  this alias and `LMS_SIG_LEN` (currently 1292; H10/W8 = 1452) need
  to move together. H5 keygen is ~32x faster, which matters for
  iterating during this WIP phase.
- `make_tlv` takes `mut self: Box<Self>` so it can `take()` the LMS
  signing key. It emits a KEYHASH TLV (SHA-256 over the 56-byte
  serialized public key) followed by the 1292-byte LMS TLV.
- `TlvGen` no longer derives `Debug` because `LmsSigningKey` does
  not implement it; nothing in the tree printed TlvGen via {:?}.
- `image.rs:make_tlv()` short-circuits to `TlvGen::new_lms()` when
  `cfg!(feature = "sig-lms")` is set. There is no `Caps::Lms` bit
  yet because no bootloader code publishes one.

Assisted-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Signed-off-by: David Brown <david.brown@linaro.org>
Replace the single buf[SIG_BUF_SIZE] used by bootutil_img_validate
for the hash-TLV compare, key-TLV load, and signature-TLV load with
three purpose-specific buffers: hash_buf[IMAGE_HASH_SIZE] for the
hash compare, key_buf[KEY_BUF_SIZE] (always allocated when
EXPECTED_KEY_TLV is defined, not just under MCUBOOT_HW_KEY) for the
key/keyhash, and sig_buf[SIG_BUF_SIZE] for the signature.

This decouples the three buffer sizes so signature algorithms with
much larger signatures (LMS H10/W8 = 1452 bytes) don't force the
hash and key buffers up to that size, and makes each load site's
buffer choice unambiguous.

No functional change.

Signed-off-by: David Brown <david.brown@linaro.org>
Assisted-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
RFC 8554 defines LMS as signing the message bytes, but mcuboot
cannot generally load a full image into RAM (e.g. external QSPI flash
behind a controller), and the mbedtls LMS verify API takes a single
contiguous buffer. Switch the simulator's LMS TlvGen path to sign
SHA-256(sig_payload) instead of sig_payload. imgtool already does
this — image.py computes `digest = SHA(payload)` and passes it to
keys/lms.py:sign_digest(), which hands it straight to pyhsslms — so
the wire formats remain in agreement. The lms_compat tests use
arbitrary bytes and are unaffected.

The cost is reduced robustness against a future SHA-256
collision-finding attack: with hash-and-sign, a collision on the
outer SHA-256 lets an attacker substitute a different image under
the same signature. LMS's own internal construction is also
SHA-256-based, so an effective collision attack on SHA-256 would
already break LMS itself; the practical exposure is unchanged.

Assisted-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Signed-off-by: David Brown <david.brown@linaro.org>
Mbed TLS 4.x supports only the LMS_SHA256_M32_H10 +
LMOTS_SHA256_N32_W8 parameter set, so the bootloader-side LMS
verifier (next commit) dictates this choice for the simulator's
TlvGen as well. H5 was a temporary speed optimization while no
verifier existed; with one on the way, signer and verifier must
agree on the parameter set.

Cost is keygen time, not signing or verification: H10 keygen
runs ~370ms on a release build of `lms-signature` (vs ~3.9s for
pyhsslms in the imgtool path), paid once per process when the
upcoming process-wide singleton initializes the signing key. Sign
and verify are still ~200µs each. LMS_SIG_LEN moves from 1292 to
1452 bytes accordingly (8 + 1124 LMOTS + 32*10 path).

Assisted-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Signed-off-by: David Brown <david.brown@linaro.org>
Replace TlvGen's per-instance `lms_signing_key: Option<...>`
field with a process-wide `OnceLock<Mutex<LmsSigningKey<LmsScheme>>>`
singleton in tlv.rs. The first call lazily generates the keypair
(~370ms) and pushes the 56-byte serialized public key into a
writable buffer in keys.c via a new `mcuboot_sim_set_lms_pubkey`
FFI hook. `bootutil_keys[].key` is a const pointer onto this
buffer, so the upcoming bootloader-side LMS verifier reads the
same key the simulator just signed with.

LMS private keys are stateful — committing one to the tree
would re-use signature indices on every test run, which breaks the
scheme — and at runtime, the bootloader's verification path reaches
its public key via a global symbol. So the natural shape is one
lazily-initialized global on the Rust side mirroring the singleton
on the C side. Cargo runs tests in parallel; the `Mutex` serializes
sign operations across threads. H10 caps the keypair at 1024
signatures and the test matrix uses ~150 per process — comfortable
margin, and exhaustion would fail loudly.

Knock-on changes: TlvGen no longer holds the (non-Debug) signing
key, so `#[derive(Debug)]` is restored and the `mut self: Box<Self>`
on `make_tlv` is simplified to `self: Box<Self>` (the previous
`take()` is gone). LMS-only imports and the `LmsScheme` alias are
`#[cfg(feature = "sig-lms")]`-gated so the default cargo build
emits no dead-code warnings; `LMS_SIG_LEN` stays unconditional
because it's referenced from `estimate_size()`. build.rs gains a
`sig_lms` feature read, multi-sig guard entry, and a build branch
that defines `MCUBOOT_SIGN_LMS` and links a SHA-256 backend so
`bootutil_img_hash` resolves.

Tests: lms_compat still passes; cargo test --features sig-lms
still fails at the upgrade step with the same "Unable to perform
basic upgrade" message — the FFI plumbing is inert until the
C-side verifier lands in a follow-up commit.

Assisted-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Signed-off-by: David Brown <david.brown@linaro.org>
Each #[test] in the sim iterates over 10 devices × 4 alignments
× 2 erased values = 80 configurations, signing primary + secondary
in each. A single test can chew through ~160 indices, and the full
sig-lms matrix exceeds H10's 1024-signature cap by several factors.
On exhaustion the lms-signature crate returns an error, the panic
poisons the singleton's Mutex, and every subsequent test cascades
into a PoisonError.

Save the 16-byte identifier and 32-byte seed alongside the
SigningKey. When try_sign_with_rng returns the exhaustion error,
rebuild the SigningKey via LmsSigningKey::new_from_seed using the
saved inputs. RFC 8554's public-key derivation is deterministic in
(id, seed): the regenerated key has the same Merkle-root public
key, so bootutil_keys[] never needs updating and previously-signed
images still verify. Each rebuild is the ~370ms Merkle-tree
generation; a full test run hits this a handful of times.

This deliberately re-uses OTS leaf indices, which is a real
security violation — the OTS construction's forgery resistance
relies on each leaf signing exactly once, and reuse leaks enough
hash chain values for an adversary to forge. That is fine here:
sim images never leave the test harness, and the goal is wire-
format round-trip, not forgery resistance. The lms_key() doc
comment spells this out so it doesn't escape into a non-test
context.

Until the bootloader-side LMS verifier in the next commit
lands, this regen path is dead — sig-lms tests still fail at the
upgrade step well before signing 1024 images, because the
bootloader rejects every LMS-signed image. The behaviour change
becomes visible once verification works end-to-end.

Assisted-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Signed-off-by: David Brown <david.brown@linaro.org>
Add image_lms.c implementing bootutil_verify_sig for LMS, using
mbedtls_lms_import_public_key + mbedtls_lms_verify directly on the
56-byte serialized public key from bootutil_keys[key_id]. The 4.x
verifier supports only LMS_SHA256_M32_H10 + LMOTS_SHA256_N32_W8
(1452-byte signature). PSA must be initialized first because the
LMS implementation in tf-psa-crypto/extras/lms.c reaches into
psa_hash_* internally for its hash chains; mcuboot signs the
SHA-256 image hash rather than the message bytes themselves so the
bootloader can verify without loading the full image into RAM (see
the hash-and-sign rationale in tlv.rs's LMS branch).

Wire-up:

  * image_validate.c: MCUBOOT_SIGN_LMS added to the multi-sig
    guard and the EXPECTED_SIG_TLV cascade (SIG_BUF_SIZE = 1452,
    EXPECTED_SIG_LEN(x) = ((x) == 1452)).

  * bootutil_find_key.c: MCUBOOT_SIGN_LMS added to the
    EXPECTED_SIG_TLV gate so the keyhash-lookup path is compiled.

  * sim/src/tlv.rs: new_lms() now seeds kinds with both SHA256
    and LMS — the bootloader requires the hash TLV and the LMS
    signature is over that hash.

Build:

  * config-lms-psa-v4.h: minimal TF-PSA-Crypto config —
    PSA_WANT_ALG_SHA_256, MBEDTLS_PSA_CRYPTO_C, MBEDTLS_LMS_C, plus
    the external-RNG knob the test build uses.

  * build.rs: add_mbedtls_v4_psa_lms() drives the upstream
    CMake build with the LMS config, links libtfpsacrypto.a, and
    points bootutil at mbedtls 4.x headers. The sig-lms branch
    delegates to it (replacing the 3.6-shim that only existed to
    satisfy the linker before the verifier landed). image_lms.c is
    selected alongside image_ecdsa.c / image_ed25519.c in the main
    image_*.c selection.

All 25 sig-lms tests pass on aarch64-apple-darwin.

Assisted-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Signed-off-by: David Brown <david.brown@linaro.org>
Cover sig-lms across the same swap/validate/multiimage/ram-load/
direct-xip/downgrade-prevention/hw-rollback-protection axes the
sig-ecdsa-psa mbedtls-v4 rows do, minus encryption — LMS+enc isn't
wired anywhere yet (see plan-lms.md). sig-lms auto-routes to the
mbedtls 4.x PSA build path via add_mbedtls_v4_psa_lms() in
build.rs, so no separate mbedtls-v4 feature is needed on these
rows.

Also extend sim_run.sh's --test-threads=1 guard to match lms and
mbedtls-v4. PSA's global state is only thread-safe with
MBEDTLS_THREADING_C, which our v4 configs don't enable; LMS
verification sits on psa_hash_* and inherits the constraint
regardless of feature label, and the simulator's external-RNG
stub uses libc rand() which is not thread-safe either.

Assisted-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Signed-off-by: David Brown <david.brown@linaro.org>
Convert the working plan-lms.md document into proper user-facing
documentation. The new docs/lms.md covers the algorithm and its
parameters, the Mbed TLS 4.x constraints (single supported parameter
set, verify-only, no PSA API yet), the imgtool signing path
(pyhsslms, custom MCUBOOT LMS PEM envelope, atomic key-file
rewrite), the bootloader verifier and its hash-and-sign rationale,
the simulator's sig-lms plumbing including how it works around the
1024-signature H10 cap by regenerating from (id, seed), and
operational considerations for anyone deploying LMS in a real
signing pipeline.

A TODO section captures the remaining work: Zephyr Kconfig + sample,
generating LMS test keys at build time (a pilot for removing
checked-in test keys for all sig algorithms), and porting to PSA
when TF-PSA-Crypto exposes an LMS API.

mbedtls-v4-port content from the working plan is intentionally not
carried over — it belongs to a separate PR.

docs/design.md gets a one-line pointer at the top of its existing
LMS subsection. docs/index.md picks up an LMS entry next to the
ECDSA one.

Assisted-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Signed-off-by: David Brown <david.brown@linaro.org>
Copilot AI review requested due to automatic review settings May 13, 2026 19:38
@kkrentz
Copy link
Copy Markdown
Contributor

kkrentz commented May 16, 2026

The issue is very real, though, and maybe there is value in encoding the key number more clearly in the image (I'm not actually sure if you can deduce it from the signature itself). But, I think we want to be careful in terms how of how much we try to impose on the signing side and infrastructure.

RFC 8554 calls an LMS sequence number "q". q comes at the beginning of an LMS signature. So, one could at least validate that a new version also has a new q.

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.

3 participants