|
| 1 | +# LMS signatures |
| 2 | + |
| 3 | +## Overview |
| 4 | + |
| 5 | +LMS (Leighton-Micali Signatures, RFC 8554) is a stateful hash-based |
| 6 | +signature scheme, recommended by NIST SP 800-208 and CNSA 2.0 for |
| 7 | +post-quantum authenticity of firmware images. Several of its |
| 8 | +properties make it a strong fit for a bootloader, even though they |
| 9 | +are limitations elsewhere: |
| 10 | + |
| 11 | +- Public keys are very small (32 bytes when expressed as a hash; 56 |
| 12 | + bytes in the RFC 8554 wire form actually carried by mcuboot — see |
| 13 | + [Image format](#image-format)). |
| 14 | +- Signatures are moderate in size — larger than ECDSA or RSA, but |
| 15 | + smaller than ML-DSA. |
| 16 | +- Verification is fast, on the order of tens of milliseconds, and is |
| 17 | + composed entirely of repeated SHA-256 invocations. |
| 18 | +- Most importantly, LMS is built around a **one-time signature** |
| 19 | + primitive (LM-OTS). Each underlying OTS leaf must sign at most |
| 20 | + once — reuse breaks security. LMS turns LM-OTS into a usable |
| 21 | + many-time signature by hanging OTS leaves off a Merkle tree, with |
| 22 | + the tree height fixed at key-generation time. |
| 23 | + |
| 24 | +The stateful-key constraint is operational, not cryptographic: it |
| 25 | +shifts a hard problem from the verifier to the signer's key-storage |
| 26 | +discipline. For a typical OTA signing pipeline, where the signing |
| 27 | +key lives in a controlled environment and is consulted serially by a |
| 28 | +release process, this is a tractable trade in exchange for PQC |
| 29 | +security. |
| 30 | + |
| 31 | +## Algorithm parameters |
| 32 | + |
| 33 | +LMS keys are parameterized at generation time. The parameters split |
| 34 | +into two groups. |
| 35 | + |
| 36 | +### LM-OTS (Winternitz) |
| 37 | + |
| 38 | +LM-OTS uses a Winternitz one-time-signature construction. The two |
| 39 | +relevant parameters are: |
| 40 | + |
| 41 | +- `n` — size of the hash function output. 32 bytes for SHA-256. |
| 42 | +- `w` — width in bits of each Winternitz coefficient. The primary |
| 43 | + size/speed trade-off: |
| 44 | + |
| 45 | +| w | p (chains) | Chain length (2^w) | Hash ops to verify (worst case) | Signature size (p·n bytes) | |
| 46 | +|---|------------|--------------------|---------------------------------|----------------------------| |
| 47 | +| 1 | 265 | 2 | 265 | 8,480 | |
| 48 | +| 2 | 133 | 4 | 399 | 4,256 | |
| 49 | +| 4 | 67 | 16 | 1,072 | 2,144 | |
| 50 | +| 8 | 34 | 256 | 8,704 | 1,088 | |
| 51 | + |
| 52 | +### LMS (Merkle tree) |
| 53 | + |
| 54 | +- `m` — bytes per Merkle node. 32, matching SHA-256. |
| 55 | +- `h` — height of the Merkle tree. Determines the total number of |
| 56 | + signatures the key can ever produce, and adds `h` hash values to |
| 57 | + each signature: |
| 58 | + |
| 59 | +| h | Total signatures | Additional signature bytes | |
| 60 | +|----|------------------|----------------------------| |
| 61 | +| 5 | 32 | 168 | |
| 62 | +| 10 | 1,024 | 328 | |
| 63 | +| 15 | 32,768 | 488 | |
| 64 | +| 20 | 1,048,576 | 648 | |
| 65 | +| 25 | 33,554,432 | 808 | |
| 66 | + |
| 67 | +## Mbed TLS 4.x constraints |
| 68 | + |
| 69 | +mcuboot's LMS verifier currently goes through Mbed TLS 4.x's |
| 70 | +implementation in `tf-psa-crypto/extras/lms.c`. That implementation |
| 71 | +imposes the parameters in practice: |
| 72 | + |
| 73 | +- **Only one parameter set is supported**: |
| 74 | + `LMS_SHA256_M32_H10` + `LMOTS_SHA256_N32_W8` — h=10 (1024 signatures |
| 75 | + over the lifetime of one key), w=8 (1088-byte LM-OTS signature |
| 76 | + proper, 1452-byte total LMS signature, ~8,700 SHA-256 ops to |
| 77 | + verify). The w/h trade-offs above are theoretical until upstream |
| 78 | + extends the implementation. |
| 79 | +- **Only verification is shipped by default** (`MBEDTLS_LMS_C`); LMS |
| 80 | + signing requires `MBEDTLS_LMS_PRIVATE`. mcuboot does not enable it |
| 81 | + — signing happens out of tree in imgtool, using a separate Python |
| 82 | + library. |
| 83 | +- The PSA Crypto API does not yet expose an LMS interface. mcuboot |
| 84 | + calls `mbedtls_lms_*` directly, which Mbed TLS calls out as an |
| 85 | + intentional public exception until PSA grows `PSA_ALG_LMS` (see |
| 86 | + `1.0-migration-guide.md`, `psa-transition.md`, and |
| 87 | + `architecture/0e-plans.md` in the Mbed TLS 4.1 source tree). This |
| 88 | + is the only interface upstream offers today; we will move to the |
| 89 | + PSA API when it lands. |
| 90 | + |
| 91 | +## Image format |
| 92 | + |
| 93 | +The wire format of `IMAGE_TLV_LMS` (0x26), the LMS public key carried |
| 94 | +in `IMAGE_TLV_PUBKEY` / `IMAGE_TLV_KEYHASH`, and the rationale for |
| 95 | +the 56-byte public key and 1452-byte signature lengths, are |
| 96 | +documented in the bootloader design under |
| 97 | +[LMS signatures](design.md#lms-signatures). This document covers the |
| 98 | +algorithm, the imgtool/sim/bootloader plumbing, and the operational |
| 99 | +implications. |
| 100 | + |
| 101 | +## Signing with imgtool |
| 102 | + |
| 103 | +LMS signing is implemented in `scripts/imgtool/keys/lms.py`, which |
| 104 | +wraps the [`pyhsslms`](https://pypi.org/project/pyhsslms/) Pure-Python |
| 105 | +LMS library (Russ Housley, RFC 8554 author; BSD-3-Clause). pyhsslms |
| 106 | +was chosen because no pip-installable Python wrapper around a C LMS |
| 107 | +library exists today: `pyca/cryptography` does not implement LMS, |
| 108 | +and `liboqs-python` requires a system `liboqs` install which would |
| 109 | +break a vanilla `pip install imgtool` from PyPI. |
| 110 | + |
| 111 | +### Key file |
| 112 | + |
| 113 | +LMS private keys are not PEM/PKCS8 (the PEM ASN.1 universe has no |
| 114 | +agreed encoding for stateful-hash keys). imgtool stores them in a |
| 115 | +custom PEM-style envelope: |
| 116 | + |
| 117 | +``` |
| 118 | +-----BEGIN MCUBOOT LMS PRIVATE KEY----- |
| 119 | +<base64 of: lms_type || lmots_type || SEED || I || q> |
| 120 | +-----END MCUBOOT LMS PRIVATE KEY----- |
| 121 | +``` |
| 122 | + |
| 123 | +Total payload is 60 bytes: 4 bytes `lms_type`, 4 bytes `lmots_type`, |
| 124 | +32 bytes seed, 16 bytes key id `I`, 4 bytes leaf counter `q`. The |
| 125 | +`keys/__init__.py` loader recognises the `BEGIN MCUBOOT LMS` marker |
| 126 | +and dispatches before falling through to the `cryptography` PEM |
| 127 | +loader. |
| 128 | + |
| 129 | +### Sign-time behaviour |
| 130 | + |
| 131 | +Each call to `sign_digest()`: |
| 132 | + |
| 133 | +1. Advances `q` in memory. |
| 134 | +2. Atomically rewrites the key file (tmpfile + fsync + rename) |
| 135 | + before returning the signature. |
| 136 | +3. If the rewrite fails, no signature is returned. |
| 137 | + |
| 138 | +The hazard the user must manage is **never re-signing from a stale |
| 139 | +backup of the key file**: doing so reuses LM-OTS leaves and breaks |
| 140 | +LMS's security argument. This is documented in the `lms.py` module |
| 141 | +docstring; a production signing-policy document is likely warranted |
| 142 | +before any release uses this feature. |
| 143 | + |
| 144 | +### Public key |
| 145 | + |
| 146 | +The 56-byte RFC 8554 public key is exported as raw bytes or |
| 147 | +PEM-wrapped. `image.py` uses it directly for both `IMAGE_TLV_PUBKEY` |
| 148 | +and `IMAGE_TLV_KEYHASH` (SHA-256 over the same 56 bytes) — the |
| 149 | +public key is small enough that there is no real difference between |
| 150 | +"carry the key" and "carry the hash and look the key up". |
| 151 | + |
| 152 | +### Cost |
| 153 | + |
| 154 | +On a contemporary development laptop (CPython 3.13, single-thread): |
| 155 | + |
| 156 | +- h=5: keygen 0.12 s, load 0.12 s, sign/verify ~2 ms. |
| 157 | +- h=10: keygen 3.9 s, load 3.9 s, sign/verify ~2 ms. |
| 158 | + |
| 159 | +Each `imgtool sign` invocation pays the full load cost because |
| 160 | +pyhsslms only persists the 60-byte state, not the 1024-leaf |
| 161 | +precomputed tree. A long-lived signing daemon would amortize this; |
| 162 | +for a CLI invocation per build, ~4 s on top of the existing build is |
| 163 | +tolerable but worth flagging. |
| 164 | + |
| 165 | +## Bootloader integration |
| 166 | + |
| 167 | +The verifier is `boot/bootutil/src/image_lms.c`. It uses |
| 168 | +`mbedtls_lms_import_public_key` + `mbedtls_lms_verify` directly on |
| 169 | +the 56-byte serialized public key from `bootutil_keys[key_id]`. |
| 170 | + |
| 171 | +### Hash-and-sign |
| 172 | + |
| 173 | +mcuboot signs `SHA-256(image || protected_TLVs)` rather than the |
| 174 | +image bytes themselves. RFC 8554 defines LMS as a sign-the-message |
| 175 | +scheme (no "ph" / pre-hash variant exists), but a bootloader cannot |
| 176 | +in general load a full image into RAM — images may live behind a |
| 177 | +controller on external QSPI flash — and the Mbed TLS LMS verify API |
| 178 | +takes a single contiguous buffer. The hash-and-sign envelope keeps |
| 179 | +imgtool, the simulator, and the bootloader in agreement on what the |
| 180 | +"message" is. |
| 181 | + |
| 182 | +The security argument is unchanged. A SHA-256 collision attack would |
| 183 | +break the hash-and-sign envelope, but LMS's own internal construction |
| 184 | +is also SHA-256-based — an effective break of SHA-256 already breaks |
| 185 | +LMS. This is the same trade-off that prevents the use of Ed25519 on |
| 186 | +this class of device. |
| 187 | + |
| 188 | +### Buffer sizing |
| 189 | + |
| 190 | +The LMS signature is 1452 bytes — much larger than ECDSA or RSA. |
| 191 | +`image_validate.c` was previously sharing one buffer for the hash, |
| 192 | +the public key, and the signature; this is now split into |
| 193 | +purpose-specific buffers so adding LMS does not push the hash and |
| 194 | +key buffers up to 1452 bytes apiece. |
| 195 | + |
| 196 | +## Simulator (the `sig-lms` feature) |
| 197 | + |
| 198 | +The `sig-lms` Cargo feature wires LMS through the simulator stack: |
| 199 | + |
| 200 | +- `TlvGen` signs test images via the |
| 201 | + [`lms-signature`](https://crates.io/crates/lms-signature) crate, |
| 202 | + matching the `LMS_SHA256_M32_H10 + LMOTS_SHA256_N32_W8` parameter |
| 203 | + set the bootloader supports. |
| 204 | +- `sig-lms` automatically builds Mbed TLS 4.x for the bootloader side |
| 205 | + via `add_mbedtls_v4_psa_lms()` in `sim/mcuboot-sys/build.rs` — |
| 206 | + there is no separate `mbedtls-v4` Cargo feature to enable. |
| 207 | +- An `lms_compat` integration test cross-checks wire-format |
| 208 | + compatibility between imgtool's pyhsslms wrapper and the |
| 209 | + `lms-signature` crate, in both directions (Python signs / Rust |
| 210 | + verifies, and vice versa). |
| 211 | + |
| 212 | +### H10 exhaustion handling |
| 213 | + |
| 214 | +The full sim test matrix easily exceeds the 1024-signature cap of a |
| 215 | +single H10 key. The simulator side-steps this by saving the 16-byte |
| 216 | +key identifier and 32-byte seed alongside the signing key and |
| 217 | +regenerating from `(id, seed)` whenever the key exhausts. RFC 8554's |
| 218 | +public-key derivation is deterministic in `(id, seed)`, so the |
| 219 | +regenerated key produces the same Merkle root and previously-signed |
| 220 | +images still verify against the same `bootutil_keys[]` entry. |
| 221 | + |
| 222 | +This deliberately re-uses LM-OTS leaves across the regen — a real |
| 223 | +security violation in production. It is acceptable in the sim |
| 224 | +because the images never leave the test harness and the goal is |
| 225 | +wire-format round-trip, not forgery resistance. The mechanism is |
| 226 | +loudly documented at the singleton's definition in |
| 227 | +`sim/src/tlv.rs`. |
| 228 | + |
| 229 | +### Process-wide singleton |
| 230 | + |
| 231 | +LMS private keys are stateful, so a fresh keypair is generated once |
| 232 | +per process and shared across all tests. The 56-byte public key is |
| 233 | +pushed into a writable buffer in `keys.c` via the |
| 234 | +`mcuboot_sim_set_lms_pubkey` FFI hook, and `bootutil_keys[].key` |
| 235 | +points into that buffer — the bootloader-side verifier sees the |
| 236 | +same key the simulator just signed with. |
| 237 | + |
| 238 | +### Threading |
| 239 | + |
| 240 | +Mbed TLS's PSA core is only thread-safe with `MBEDTLS_THREADING_C` |
| 241 | +enabled, which our v4 sim configs do not set; the LMS verifier sits |
| 242 | +on top of `psa_hash_*` and inherits the constraint. The simulator's |
| 243 | +external-RNG stub also uses libc's non-thread-safe `rand()`. CI |
| 244 | +runs sig-lms test combinations with `--test-threads=1` — see |
| 245 | +`ci/sim_run.sh`. |
| 246 | + |
| 247 | +## Operational considerations |
| 248 | + |
| 249 | +For anyone deploying LMS in a real signing pipeline: |
| 250 | + |
| 251 | +- **Never re-sign from a stale backup of the LMS private-key file.** |
| 252 | + Reusing LM-OTS leaves leaks enough hash-chain values for an |
| 253 | + adversary to forge — restoring a backup *after* signing has |
| 254 | + already advanced `q` will leak the key. |
| 255 | +- **Treat the key file as a source of truth.** imgtool's atomic |
| 256 | + rewrite (tmpfile + fsync + rename) is necessary but not sufficient: |
| 257 | + if a crash happens after the rewrite but before the caller |
| 258 | + persists the signed image, an LMS index is wasted (never reused), |
| 259 | + which is the safe outcome. Crashes that leave the key in any |
| 260 | + other state must not silently roll the file back. |
| 261 | +- **Plan for key exhaustion.** H10 caps a key at 1024 signatures. |
| 262 | + This is plenty for a single product line over many releases, but |
| 263 | + the signing infrastructure should track usage and rotate the |
| 264 | + signing key well before exhaustion, including the verifier-side |
| 265 | + key rollover required to accept a new public key. |
| 266 | +- **A signing-policy document is likely warranted** before LMS is |
| 267 | + used to sign release images. The single biggest operational risk |
| 268 | + is the stateful-private-key hazard above; it is not mitigated by |
| 269 | + the bootloader, only by procedure. |
| 270 | + |
| 271 | +## TODO |
| 272 | + |
| 273 | +The remaining work to reach a complete, mergeable LMS feature: |
| 274 | + |
| 275 | +- **Zephyr Kconfig + sample.** Add `BOOT_SIGNATURE_TYPE_LMS` to |
| 276 | + `boot/zephyr/Kconfig`, wire it through `mcuboot_config.h` and |
| 277 | + `keys.c`, add `image_lms.c` to `boot/zephyr/CMakeLists.txt`, and |
| 278 | + add a build-only sample at `boot/zephyr/sample.yaml` so the |
| 279 | + bootloader is exercised in Zephyr's CI under LMS. |
| 280 | +- **Generate the LMS test key at build time, not commit it.** |
| 281 | + Stateful keys cannot be safely committed (each test run would |
| 282 | + re-use indices). The Zephyr sample should run `imgtool keygen` as |
| 283 | + a CMake step. This is a natural pilot for the broader goal of |
| 284 | + removing checked-in test keys for *all* signature algorithms — the |
| 285 | + build-time generation pattern designed for LMS should generalize |
| 286 | + to RSA / ECDSA / Ed25519. |
| 287 | +- **Move to the PSA API when upstream lands it.** When TF-PSA-Crypto |
| 288 | + exposes LMS through `psa_verify_hash` (or whatever interface the |
| 289 | + ongoing API design produces), `image_lms.c` should be ported off |
| 290 | + `mbedtls_lms_*` — the mbedtls-side APIs are public today only as |
| 291 | + a transitional measure for mechanisms with no PSA equivalent. |
| 292 | + |
| 293 | +## Notes for future work |
| 294 | + |
| 295 | +- LMS is verification-only in the bootloader. Signing remains in |
| 296 | + imgtool. Moving signing into the bootloader is not currently |
| 297 | + envisioned and would require either deep operational care or |
| 298 | + hardware-rooted state. |
| 299 | +- ML-DSA (Dilithium) source is already sitting in the Mbed TLS 4.1 |
| 300 | + submodule at `tf-psa-crypto/drivers/pqcp/mldsa-native/`. With the |
| 301 | + CMake-driven 4.x build path in place for LMS, adding a second PQC |
| 302 | + algorithm is mostly a matter of extending the config header and |
| 303 | + wiring the TLV / bootloader plumbing. |
0 commit comments