Skip to content

Commit 3ef1749

Browse files
committed
plan-lms: living plan for LMS and mbedtls-v4 work
Ongoing planning document tracking LMS support and the Mbed TLS 4.x PSA porting effort. Updated in place as work progresses; will be converted into proper documentation once the implementation lands. Signed-off-by: David Brown <david.brown@linaro.org>
1 parent 4419d33 commit 3ef1749

1 file changed

Lines changed: 366 additions & 0 deletions

File tree

plan-lms.md

Lines changed: 366 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,366 @@
1+
# Plan to implement LMS in MCUboot
2+
3+
The LMS digital signature algorithm {insert RFC and FIPS references with links} is a post-quantum
4+
digital signature algorithm. It has some unusual features that make it a reasonable choice for a
5+
bootloader, even though these are limitations for other applications:
6+
7+
- Keys are very small. The public key is a single SHA, for SHA-256 (recommended by CNSA-2.0),
8+
that's 32 bytes. The private key is generally 32-bytes plus a small counter.
9+
- Signatures are moderate. Larger than ECDSA or RSA signatures, but smaller than ML-DSA signature.
10+
The algorithm is parameterizable with the main tradeoff being between signature size, and speed of
11+
verification.
12+
- Verification is fast, typically on the order of 10s of ms, and consist entirely of the repeated
13+
application of the given hash function.
14+
- Importantly: it is based on a **one time signature** algorithm, LM-OTS. If the same underlying
15+
key is ever used to sign more than one message, security is broken. LMS builds a Merkle tree
16+
around this, allowing at key generation time, this balance to be determined.
17+
18+
At key generation time, there are a small set of parameters. For the LM-OTS part:
19+
20+
- N - size of hash function, 32 for SHA-256.
21+
- W - the width (in bits) of the Winternitz coefficients, this is the primary tradeoff value
22+
23+
| w | p (chains) | Chain length (2^w) | Hash ops to verify (worst case) | Signature size (p×n bytes) |
24+
|---|---|---|---|---|
25+
| 1 | 265 | 2 | 265 | 8,480 bytes |
26+
| 2 | 133 | 4 | 399 | 4,256 bytes |
27+
| 4 | 67 | 16 | 1,072 | 2,144 bytes |
28+
| 8 | 34 | 256 | 8,704 | 1,088 bytes |
29+
30+
For LMS, there are two parameters:
31+
32+
- m - The number of bytes with each node, also 32, the size of the hash function.
33+
- h - The height of the merkel tree.
34+
35+
The height determines the number of total signatures that can be made over the lifetime of the key.
36+
Larger values allow for more signatures to be made, but also increases the size of the message.
37+
38+
| h | Total signatures | Additional signature bytes |
39+
|---|---|---|
40+
| 5 | 32 | 168 bytes |
41+
| 10 | 1,024 | 328 bytes |
42+
| 15 | 32,768 | 488 bytes |
43+
| 20 | 1,048,576 | 648 bytes |
44+
| 25 | 33,554,432 | 808 bytes |
45+
46+
## Mbed TLS 4.1 constraints on LMS
47+
48+
Mbed TLS 4.1.0 ships an LMS implementation in
49+
`ext/mbedtls-4.1.0/tf-psa-crypto/extras/lms.c` (header
50+
`tf-psa-crypto/include/mbedtls/lms.h`). The relevant facts that shape
51+
this plan:
52+
53+
- The implementation **supports only one parameter set**:
54+
`MBEDTLS_LMS_SHA256_M32_H10` + `MBEDTLS_LMOTS_SHA256_N32_W8`. The w
55+
and h tradeoff tables above become theoretical — in practice we get
56+
h=10 (1024 signatures) and w=8 (1088-byte signature, ~8,700 SHA-256
57+
ops to verify) and nothing else.
58+
- The library advertises only LMS *verification* by default
59+
(`MBEDTLS_LMS_C`). LMS *signing* requires the separate
60+
`MBEDTLS_LMS_PRIVATE`, and since mcuboot only verifies, we do not
61+
need to enable it. Signing is done externally by imgtool using a
62+
non-Mbed-TLS LMS library.
63+
- The public key is **56 bytes**, not 32: 4 bytes LMS type + 4 bytes
64+
LMOTS type + 16 bytes I (key id) + 32 bytes M-node. A future TLV
65+
design must allocate for 56 bytes.
66+
67+
## Current state
68+
69+
The work below was done on the `add-lms` branch. Commits in order:
70+
71+
- `ext/mbedtls: rename submodule to ext/mbedtls-3.6.0`
72+
- `ext/mbedtls-4.1.0: add as second Mbed TLS submodule`
73+
- `ptest: ignore local .log output files`
74+
- `ext/tinycrypt: hmac.c: avoid Clang 19+ const-VLA diagnostic`
75+
- `sim: build Mbed TLS 4.1.0 via upstream CMake for mbedtls-v4`
76+
- `plan-lms: reflect CMake-driven Mbed TLS 4.1 build`
77+
- `sim: expand CI matrix to cover mbedtls-v4 combinations`
78+
- `boot: encrypted_psa: Mbed TLS 4.x compat and -Werror fixes`
79+
- `sim: enable genuine PSA encryption for sig-ecdsa-psa+enc-ec256+mbedtls-v4`
80+
- `sim: extend mbedtls-v4 PSA encryption to enc-aes256-ec256`
81+
82+
### Dual-submodule arrangement
83+
84+
Rather than replacing Mbed TLS outright, the submodule was renamed to
85+
`ext/mbedtls-3.6.0` and a second submodule `ext/mbedtls-4.1.0` was
86+
added alongside. This preserves the LTS-backed build paths for the
87+
legacy crypto simulator features (`sig-rsa`, `sig-ecdsa-mbedtls`,
88+
`enc-rsa`, `enc-ec256-mbedtls`, `enc-x25519-mbedtls`), whose APIs are
89+
demoted to `private/` headers in 4.x and would require substantial
90+
rework to port. New code and PSA-based features migrate to 4.1
91+
incrementally via the `mbedtls-v4` Cargo feature.
92+
93+
The Zephyr port is unaffected — it consumes Zephyr's own Mbed TLS
94+
module, not either of our submodules.
95+
96+
### PSA features ported (CMake-driven)
97+
98+
The 4.x build is driven through upstream's own CMake, matching the
99+
approach Zephyr adopted when it moved to Mbed TLS 4.1: stop shadowing
100+
the upstream build and let it own the file layout, generator
101+
plumbing, and config-adjustment logic. Mechanism:
102+
103+
- `sim/mcuboot-sys/Cargo.toml`: `mbedtls-v4` feature and
104+
`cmake = "0.1"` build-dep.
105+
- `sim/mcuboot-sys/csupport/config-ec-psa-v4.h`: TF-PSA-Crypto 1.1.0
106+
config using `PSA_WANT_*` macros. The 4.x
107+
`crypto_adjust_config_enable_builtins.h` infrastructure
108+
auto-enables the corresponding `MBEDTLS_*_C` internals. Gated
109+
blocks (`#if defined(MCUBOOT_SIGN_EC384)`,
110+
`#if defined(MCUBOOT_ENCRYPT_EC256)`, etc.) layer in algorithms
111+
for each enabled Cargo feature.
112+
- `sim/mcuboot-sys/build.rs`: new `add_mbedtls_v4_psa_ecdsa()`
113+
branch invokes the `cmake` crate on
114+
`ext/mbedtls-4.1.0/tf-psa-crypto` with `TF_PSA_CRYPTO_CONFIG_FILE`,
115+
`ENABLE_{PROGRAMS,TESTING}=OFF`,
116+
`USE_STATIC_TF_PSA_CRYPTO_LIBRARY=ON`,
117+
`TF_PSA_CRYPTO_FATAL_WARNINGS=OFF`, builds the `tfpsacrypto`
118+
target, and links the resulting `libtfpsacrypto.a`. CMake's own
119+
`GEN_FILES` path invokes the upstream Python generators for
120+
`psa_crypto_driver_wrappers*` and `tf_psa_crypto_config_check_*.h`
121+
— we don't replicate that plumbing.
122+
- `sim/mcuboot-sys/csupport/psa_rng_stub_v4.c`: self-contained stub
123+
for `mbedtls_psa_external_get_random` plus empty
124+
enable/disable-insecure-RNG toggles. The equivalent upstream test
125+
shim (`framework/tests/src/fake_external_rng_for_test.c`) drags in
126+
a large transitive header tree (`test_common.h`
127+
`tf_psa_crypto_common.h``<test/build_info.h>`), which
128+
verification-only tests do not need.
129+
- `scripts/requirements.txt`: `jinja2`, `jsonschema` — required by
130+
upstream's generators. 4.x does not ship pre-generated files;
131+
CMake regenerates them on every configure.
132+
133+
Feature combinations that build and pass 25/25 tests on `mbedtls-v4`:
134+
135+
- `sig-ecdsa-psa` (base — signature verification only)
136+
- `sig-ecdsa-psa sig-p384`
137+
- `sig-ecdsa-psa` crossed with each of: `swap-move`, `swap-offset`,
138+
`bootstrap`, `max-align-16`, `validate-primary-slot`,
139+
`overwrite-only`, `multiimage`, `ram-load`, `direct-xip`,
140+
`overwrite-only downgrade-prevention`,
141+
`hw-rollback-protection multiimage`.
142+
- `sig-ecdsa-psa enc-ec256` — first real-PSA encryption path (vs.
143+
the 3.6 stub). Uses `encrypted_psa.c` for primitives + `encrypted.c`
144+
for the high-level `boot_enc_*` interface, with
145+
`CONFIG_BOOT_ECDSA_PSA` defined to skip `encrypted.c`'s duplicated
146+
legacy ASN.1/ECDH block.
147+
- `sig-ecdsa-psa enc-aes256-ec256` — same machinery, with
148+
`MCUBOOT_AES_256` defined. PSA_KEY_TYPE_AES covers all AES key
149+
sizes, so no config delta.
150+
151+
### Build-time prerequisites for `mbedtls-v4`
152+
153+
- `cmake` in PATH (most Rust developers already have it; the `cmake`
154+
crate requires it).
155+
- `python3` with `jinja2` and `jsonschema` available to the
156+
interpreter CMake finds via `find_package(Python3)`.
157+
158+
### Gotchas / lessons learned
159+
160+
- **Any `MCUBOOT_*` macro that gates `PSA_WANT_*` entries in
161+
`config-ec-psa-v4.h` must be forwarded to CMake**, not just to the
162+
`cc::Build`. Otherwise the library compiles without that algorithm
163+
enabled while the boot code uses it, yielding silent
164+
PSA_ERROR_NOT_SUPPORTED failures (or, in the P-384 case we hit,
165+
seven upgrade tests failing under `sig-p384 mbedtls-v4` because
166+
`PSA_WANT_ECC_SECP_R1_384` wasn't switched on in the library build).
167+
`add_mbedtls_v4_psa_ecdsa()` handles this via
168+
`cmake_conf.cflag("-DMCUBOOT_SIGN_EC384")` /
169+
`"-DMCUBOOT_ENCRYPT_EC256"`; any future gate macro needs the same.
170+
- **`encrypted.c` guards its legacy ASN.1/ECDH block with Zephyr
171+
Kconfig symbols** (`CONFIG_BOOT_ECDSA_PSA`,
172+
`CONFIG_BOOT_ED25519_PSA`) rather than `MCUBOOT_USE_PSA_CRYPTO`.
173+
Our sim build therefore defines `CONFIG_BOOT_ECDSA_PSA` — it's
174+
load-bearing, not cosmetic. This is a small OS-leakage that
175+
arguably ought to be cleaned up upstream (the guard should key on
176+
`MCUBOOT_USE_PSA_CRYPTO`, not a Zephyr-namespace symbol).
177+
- **`encrypted_psa.c`'s `bootutil_aes_ctr_{encrypt,decrypt}` ignore
178+
`blk_off`**. The tinycrypt and mbedtls versions both thread it
179+
through as the CTR sub-block offset. This may be fine for paths
180+
that stay block-aligned, but is almost certainly the cause of the
181+
x25519 failure below — the PSA-side comment claiming "PSA handles
182+
CTR block alignment internally" is not accurate.
183+
184+
### Local carries
185+
186+
- `ext/tinycrypt/lib/source/hmac.c` — Clang 19+ const-VLA diagnostic.
187+
See `TASKS.md`. Vendored tinycrypt, lives forever.
188+
- `boot/bootutil/src/encrypted_psa.c` — three carry patches for
189+
Mbed TLS 4.x + `-Werror -Wall -Wextra`: define
190+
`MBEDTLS_OID_EC_ALG_UNRESTRICTED` / `_EC_GRP_SECP256R1` when
191+
unavailable (they moved to a private header in 4.x), cast the
192+
`"MCUBoot_ECIES_v1"` string literal to `const uint8_t *`, mark
193+
`blk_off` as intentionally unused. Should flow back upstream as a
194+
proper 4.x compatibility fix; drop this carry when it does.
195+
196+
### Known gaps
197+
198+
- The main `~/ai/pythons` venv needs `jinja2` + `jsonschema` for
199+
`cargo test` to work without a PATH override. Update the uv-managed
200+
`pyproject.toml` alongside this plan's completion.
201+
- `sig-ecdsa-psa enc-x25519 mbedtls-v4` (and the aes256 variant) is
202+
wired but fails 7/25 upgrade-path tests. Private key parses, HKDF
203+
and MAC verification both succeed, but image-payload AES-CTR
204+
produces wrong bytes. Most likely cause is the `blk_off`-is-ignored
205+
issue in `encrypted_psa.c` noted above; full triage notes and a
206+
concrete resume step are in `TASKS.md`. The combo works on the 3.6
207+
stub path, so this blocks only the 4.x port of those two features.
208+
209+
## Plan
210+
211+
- [x] Add Mbed TLS 4.1.0 alongside the 3.6 LTS as a second
212+
submodule (`ext/mbedtls-4.1.0`).
213+
- [x] Port one PSA-only simulator feature (`sig-ecdsa-psa`) to build
214+
against 4.1 via the `mbedtls-v4` Cargo feature.
215+
- [x] Resolve hand-picked-vs-CMake question: adopted CMake via the
216+
`cmake` crate for the 4.x build path, per the Zephyr precedent.
217+
- [x] Port remaining PSA-based simulator features to `mbedtls-v4`
218+
(done except for the x25519 variants):
219+
- [x] `sig-p384` — verified; uncovered and fixed a bug where
220+
`MCUBOOT_SIGN_EC384` wasn't being forwarded to the CMake build,
221+
so the library lacked P-384 support.
222+
- [x] `sig-ecdsa-psa swap-move bootstrap max-align-16` plus a
223+
broader set of orthogonal feature crosses (see above).
224+
- [x] `sig-ecdsa-psa enc-ec256` / `enc-aes256-ec256` — moved from
225+
the `psa_crypto_init_stub.c` path to genuine PSA-based
226+
encryption via `encrypted_psa.c`.
227+
- [ ] `sig-ecdsa-psa enc-x25519` / `enc-aes256-x25519` — tests
228+
fail, probably `blk_off` handling. See Known gaps and
229+
`TASKS.md`.
230+
- [x] Add and document a new image-trailer TLV type for LMS signatures
231+
in `boot/bootutil/include/bootutil/image.h` and `docs/design.md`.
232+
Allocated `IMAGE_TLV_LMS = 0x26` (next free slot after
233+
`IMAGE_TLV_SIG_PURE`). Added to the unprotected-TLV allow list in
234+
`image_validate.c`. `docs/design.md` now has a short subsection
235+
spelling out the RFC 8554 wire formats so the 56-byte public key
236+
(`u32 lms_type | u32 lmots_type | 16-byte I | 32-byte T[1]`) and
237+
1452-byte signature (for the single `LMS_SHA256_M32_H10` +
238+
`LMOTS_SHA256_N32_W8` set Mbed TLS 4.x supports) are documented at
239+
the level a future reader will want. No new TLV is needed for the
240+
public key itself — the existing `IMAGE_TLV_PUBKEY` / `KEYHASH`
241+
pair covers it.
242+
- [x] Add LMS key generation and image signing to imgtool. Landed via
243+
the `pyhsslms` Pure-Python package (Russ Housley, RFC 8554 author;
244+
BSD-3-Clause). No pip-installable Python wrapper around a C LMS
245+
exists today — `pyca/cryptography` does not implement LMS, and
246+
`liboqs-python` requires a system `liboqs` install which would
247+
break a vanilla `pip install` of imgtool from PyPI.
248+
249+
Implementation notes:
250+
251+
- `scripts/imgtool/keys/lms.py` wraps `pyhsslms.LmsPrivateKey` /
252+
`LmsPublicKey`. Constructor takes `(lms_type, lmots_type)` so
253+
additional parameter sets are a one-line addition to
254+
`LMS_PARAM_SETS`. Initially exposes `lms-sha256-h10-w8` (the
255+
only set Mbed TLS 4.x verifies) and `lms-sha256-h5-w8` (much
256+
faster, useful for round-trip tests).
257+
- Private-key file is a custom PEM-style envelope (`-----BEGIN
258+
MCUBOOT LMS PRIVATE KEY-----`) holding the 60-byte serialized
259+
state `lms_type || lmots_type || SEED || I || q`.
260+
`keys/__init__.py:load()` sniffs the `BEGIN MCUBOOT LMS` marker
261+
before falling through to the cryptography PEM loader.
262+
- `sign_digest()` advances `q` in-memory then atomically rewrites
263+
the key file (tmpfile + fsync + rename) before returning the
264+
signature. If the disk write fails, no signature is produced; if
265+
a crash happens after the write but before the caller persists
266+
the signed image, an LMS index is wasted but never reused. The
267+
signing-policy hazard (re-signing from a stale backup) lives in
268+
the `lms.py` module docstring.
269+
- Public key is exported in the 56-byte RFC 8554 form (raw or
270+
PEM-wrapped). `image.py` uses it directly for both
271+
`IMAGE_TLV_PUBKEY` and `IMAGE_TLV_KEYHASH` (SHA-256 over the
272+
same 56 bytes).
273+
- `image.py:TLV_VALUES['LMS'] = 0x26` and a corresponding
274+
`ALLOWED_KEY_SHA[LMS] = ['256']` entry; the rest of the verify
275+
path picks up `verify_digest()` automatically since the LMS
276+
wrapper deliberately omits a `verify` method.
277+
278+
Timing on a 2026 development laptop (CPython 3.13, single-thread):
279+
280+
- h=5 keygen: 0.12 s; load (rebuilds the tree): 0.12 s.
281+
- h=10 keygen: 3.9 s; load: 3.9 s. Each `imgtool sign` invocation
282+
pays the load cost because pyhsslms only persists the 60-byte
283+
state, not the 1024-leaf precomputed tree. A long-lived signing
284+
daemon would amortize this; for a CLI invocation per build, 4 s
285+
on top of the existing build is tolerable but worth flagging.
286+
- Sign and verify (post-load) are both ~2 ms.
287+
- Signature size is 1452 bytes for h=10/w=8, matching
288+
`docs/design.md`.
289+
290+
Tests in `scripts/tests/test_keys.py` parameterize over
291+
`keygens.keys()`; the LMS variants pass `test_keygen`,
292+
`test_getpub`, `test_getpubhash`, and `test_sign_verify`.
293+
`test_keygen_type` (uses `openssl pkey`) and `test_getpriv`
294+
(PKCS8/openssl formats) skip for LMS since LMS keys are not
295+
PEM/PKCS8.
296+
- [ ] Add simulator support for LMS signatures.
297+
- [x] Wire-format compatibility tests between imgtool's pyhsslms
298+
wrapper and the `lms-signature` crate the simulator will use
299+
(`sim/tests/lms_compat.rs`). Round-trips a fresh keypair in
300+
both directions (Python→Rust and Rust→Python verification),
301+
no committed key material; uses the H5/W8 set for keygen
302+
speed. `lms-signature` is now a regular `[dependencies]` of
303+
bootsim alongside `getrandom`, `rand_core`, `hybrid-array`,
304+
`signature`.
305+
- [x] Hook `lms-signature` into `TlvGen` so the simulator can
306+
sign LMS test images and feed them through the bootloader's
307+
verifier. The tests using this path will fail until the
308+
bootloader-side LMS verification below lands.
309+
310+
Implementation notes:
311+
312+
- `sim-lms` Cargo feature added in `sim/Cargo.toml` and
313+
`sim/mcuboot-sys/Cargo.toml`. No C code is gated by it yet —
314+
the bootloader-side verifier doesn't exist.
315+
- `TlvKinds::LMS = 0x26` matches `IMAGE_TLV_LMS` from the
316+
bootutil header.
317+
- `TlvGen::new_lms()` generates a fresh `SigningKey<LmsScheme>`
318+
per call. `LmsScheme` is `LmsSha256M32H5<LmsOtsSha256N32W8>`
319+
for now: H5 keygen runs in tens of ms, vs. seconds for H10.
320+
When bootloader-side LMS verification (Mbed TLS 4.x) is
321+
wired up, the alias must move to H10 because that is the
322+
only scheme Mbed TLS 4.x supports — both the alias and
323+
`LMS_SIG_LEN` (currently 1292; H10 = 1452) need updating
324+
together.
325+
- `make_tlv()` now takes `mut self: Box<Self>` so it can
326+
`take()` the LMS signing key. It emits a `KEYHASH` TLV
327+
(SHA-256 over the 56-byte serialized public key) followed
328+
by an `LMS` TLV containing the 1292-byte signature.
329+
- `image.rs:make_tlv()` short-circuits to `TlvGen::new_lms()`
330+
when `cfg!(feature = "sig-lms")` is set, since there is no
331+
`Caps::Lms` bit yet (no bootloader code to publish it).
332+
- `TlvGen` no longer derives `Debug` because
333+
`LmsSigningKey` does not implement it. Nothing in the tree
334+
printed `TlvGen` via `{:?}`.
335+
336+
With `--features sig-lms`, all tests fail at the upgrade step
337+
("Unable to perform basic upgrade") because the bootloader
338+
rejects the image. That confirms the simulator-side path is
339+
sound and isolates the remaining work to the C side.
340+
- [ ] Generalize the signing/verification interface as needed to
341+
support LMS (follow `plan-refactor.md`). For verification the
342+
interface should look much like the existing ones — no state to
343+
maintain at verify time.
344+
- [ ] Add LMS signature verification support to the bootloader:
345+
- Config knob (`MCUBOOT_SIGN_LMS` or similar)
346+
- Zephyr Kconfig support
347+
- Integration into the boot path (`image_lms.c` alongside the
348+
existing `image_ecdsa.c`, `image_rsa.c`, `image_ed25519.c`)
349+
- [ ] Expand `.github/workflows/sim.yaml` matrix to cover LMS
350+
(probably `sig-lms` feature, combined with the usual swap/validate/
351+
enc matrix, though see constraint note below).
352+
- [ ] Retire `ext/mbedtls-3.6.0` once all features have been ported
353+
to 4.x (or explicitly dropped). 3.6 has LTS coverage until 2027, so
354+
no rush; this is a longer-term cleanup.
355+
356+
## Notes for future work
357+
358+
- LMS is verification-only in this plan; signing stays in imgtool.
359+
The stateful-private-key hazard is the single biggest operational
360+
risk — a signing-policy document may be warranted before this lands
361+
in production (not part of this branch's scope).
362+
- ML-DSA (Dilithium) source is already sitting in the 4.1 submodule
363+
at `ext/mbedtls-4.1.0/tf-psa-crypto/drivers/pqcp/mldsa-native/`.
364+
With the CMake path in place, adding a second PQC algorithm is
365+
primarily a matter of extending the config header and declaring
366+
the TLV/bootloader glue.

0 commit comments

Comments
 (0)