A thousand fragments, one signature: updating firmware over LoRaWAN¶
CRA & Dev #6 · "CRA & Dev" series · Reading time: about 9 min · Embedded (ESP32), LoRaWAN · Tools: Zephyr, MCUboot, imgtool
What the CRA requires¶
A product must be fixable after it is placed on the market. The Cyber Resilience Act requires that vulnerabilities can be addressed through security updates, automatic where applicable (Annex I, Part I, point 2, c), and that the manufacturer has mechanisms to distribute those updates securely (Part II, point 7), without delay (point 8).
On a server, that is an HTTPS download. On a LoRaWAN sensor sitting on top of a pole for ten years, running on a battery, it is a different trade. The network carries a few dozen bytes per frame, the device almost never listens, and nobody will go and reflash it by hand. The answer is called FUOTA (Firmware Update Over The Air), and it does not exempt you from signing what you send.
The classic trap¶
Three mistakes, all common.
Believing LoRaWAN encryption authenticates the firmware. To update a fleet, you broadcast the fragments over multicast, encrypted with a group key. By construction, that key is identical in every device of the group. If an attacker extracts the multicast session keys from a single device, they can craft fragments every other device will accept. The fragmentation specification says so itself (section 4): unless every device of the group uses a secure element, those keys cannot be considered safe, an additional file integrity and authentication step is needed, and for firmware the recommended solution is a public-key signature. This is exactly the criterion from episode 4: when whoever verifies must not be able to forge, a shared secret is no longer enough.
Sending the full image without doing the maths. A study from University College Cork works it out for a 50 kB image at DR2 (SF10), meaning 51 usable bytes per frame: about 1,004 downlinks, and around 17 hours for a single device because of the 1% duty-cycle limit in Europe. Without multicast, a whole fleet takes weeks.
Not planning for failure. An incomplete image, or a complete one that does not boot, and the sensor is lost. Without automatic rollback, every update is a gamble.
The technique: separate transport from trust¶
The principle fits in one sentence: the network carries, the bootloader decides. Let LoRaWAN move bytes, and only boot what carries a valid signature.
1. Transport: three standard building blocks¶
The LoRa Alliance split FUOTA into independent application-layer packages, each on its own port.
| Building block | Specification | Port | Role |
|---|---|---|---|
| Clock synchronization | TS003 | 202 | Set the devices' clocks, so they open their receive window at the same moment |
| Multicast setup | TS005 | 200 | Hand each device the group key and the session time (class B or C) |
| Fragmented transport | TS004 | 201 | Split the image into fragments, plus redundant fragments |
A session goes like this (figure 3 of the study cited above). The server configures each device over unicast (multicast group, fragmentation session, start time). At the agreed time, all of them switch to class C and listen continuously. The server broadcasts the fragments once for the whole group. The devices rebuild the image, then return to class A.
Redundancy avoids retransmissions. The extra fragments are combinations of the original ones: according to the specification, 10% redundancy lets a device lose roughly 10% of the frames and still rebuild the file, without asking the server for anything.
2. Trust: sign the image, verify at boot¶
With MCUboot, the signature is part of the image. The bootloader holds the public key and refuses to boot an image whose signature does not match, whichever path it arrived through.
Generate the key pair once, with imgtool.
# ECDSA P-256 key pair (ed25519 and RSA are supported too)
imgtool keygen -k fuota-ecdsa-p256.pem -t ecdsa-p256
Then configure the bootloader in sysbuild.conf, and pass the key at build time, so
that its path stays out of the repository. The build signs the application and
embeds the public key in MCUboot. The commands are those of the
example firmware, here for a Heltec WiFi LoRa 32 board (ESP32 and SX1276
radio).
# sysbuild.conf
SB_CONFIG_BOOTLOADER_MCUBOOT=y
SB_CONFIG_BOOT_SIGNATURE_TYPE_ECDSA_P256=y
SB_CONFIG_MCUBOOT_MODE_SWAP_SCRATCH=y # slot swap: rollback is possible
west build -b heltec_wifi_lora32_v2/esp32/procpu --sysbuild firmware -- \
-DSB_CONFIG_BOOT_SIGNATURE_KEY_FILE='"/path/outside-the-repo/fuota-ecdsa-p256.pem"'
# Image to hand to the FUOTA server: build/firmware/zephyr/zephyr.signed.bin
That signed file is what the server fragments. The signature travels in the fragments, like everything else.
3. On the device: receive, reboot, confirm¶
The example firmware relies on the same services as Zephyr's FUOTA sample. A few options enable the three building blocks.
# prj.conf (excerpt)
CONFIG_LORAWAN_SERVICES=y
CONFIG_LORAWAN_APP_CLOCK_SYNC=y # TS003
CONFIG_LORAWAN_REMOTE_MULTICAST=y # TS005
CONFIG_LORAWAN_FRAG_TRANSPORT=y # TS004
CONFIG_IMG_MANAGER=y # writes to the secondary slot
CONFIG_REBOOT=y
The application code starts the services, reacts when the image is complete, and
decides whether a new image deserves to be kept (simplified excerpt from
firmware/src/main.c).
/* Called once the image is rebuilt in the secondary slot.
* Zephyr has already asked MCUboot for a "test" boot. */
static void fuota_finished(void)
{
k_sem_give(&image_received); /* the main loop will reboot */
}
int main(void)
{
bool confirmed = boot_is_img_confirmed();
/* ... lorawan_start() ... */
bool joined = join() == 0;
bool services = joined && start_services(); /* TS003, TS004; TS005 starts on its own */
/* The health check. A test image that fails it reboots without
* confirming itself: MCUboot puts the old one back. */
if (!confirmed) {
if (!services) {
sys_reboot(SYS_REBOOT_COLD);
}
boot_write_img_confirmed();
}
/* ... main loop: regular uplinks (in class A, they are what opens the
* receive windows the server needs), new join attempts when the network
* is missing, and a reboot as soon as image_received is given ... */
}
On reboot, MCUboot verifies the signature of the received image before swapping it with the old one. A forged, truncated or badly rebuilt image never boots.
4. Confirm, or roll back¶
Zephyr requests the upgrade in test mode (BOOT_UPGRADE_TEST).
The new image boots once. If it does not call boot_write_img_confirmed(), MCUboot
puts the old one back at the next reset. Hence the point of
confirming late, after a real health check, here a successful join and running FUOTA
services, and of letting a watchdog trigger the reset if the firmware hangs before
that. That is enough for a demo, not for a product: confirm the image at the end of an
explicit policy (watchdog fed, configuration and storage migration done, critical
peripherals initialised, possibly a first application exchange with the backend).
That leaves the malicious rollback: replaying an old image, correctly signed, but
vulnerable. MCUboot describes two protections. The first compares
version numbers (CONFIG_MCUBOOT_DOWNGRADE_PREVENTION). Its documentation restricts
it to the overwrite strategy. The second relies on
a security counter stored in hardware (CONFIG_MCUBOOT_HW_DOWNGRADE_PREVENTION) and
rejects any image whose counter is lower. An equal value passes: so bump the counter
with every security fix.
Bench observation, not a guarantee
With the MCUboot shipped by Zephyr 4.4.2, we saw version-based protection reject an old image in slot-swap mode too. This is observed behaviour, not a documented security property: do not rely on it, and check on your version.
The example's test bench drops into the secondary slot of a real board a forged image, an image with one flipped bit, an old version and an update unable to join the network. The bootloader and firmware console answers, in that order:
E: Image in the secondary slot is not valid!
I: Image 0 in slot 1 erased due to downgrade prevention
<wrn> fuota: Rebooting: test image unable to join the network
I: Image index: 0, Swap type: revert
A second board can also act as the sender, over point-to-point LoRa: on our bench, the 200 kB image went through in 33 minutes, 7 fragments lost on the way were rebuilt, then MCUboot checked the signature and booted the new version.

The bench: two Heltec boards (ESP32, SX1276). On the right, the radio test in both directions: −124 dBm one way, −88 dBm the other.
Three things to know¶
- Count your airtime before writing code. The image size sets the duration and the energy cost. In the study cited above, an update at DR0 (SF12) takes almost 30 times longer than at DR5 (SF7), but DR5 only reaches 45% of the devices in the simulated deployment. The levers are well known: a small image, an incremental update rather than a full one (recommended by The Things Stack), a fragment size matched to the lowest data rate in the group. With a delta, the signature must cover the rebuilt image, not the patch. Count RAM too: Zephyr's decoder reserves its memory according to image size, fragment size and redundancy. With the defaults, it asked for more than 5 MB on our ESP32, which offers 192 KB.
- Replace the default key. Without
SB_CONFIG_BOOT_SIGNATURE_KEY_FILE, the build uses the example key shipped in the public MCUboot repository, whose documentation stresses that the private key is available to all. And as with Authenticode (episode 2), yours lives neither in the repository nor in plaintext in CI: for a product, it stays offline or in an HSM, and only the public key is used to build the bootloader (MCUboot's custody model). Its leak is the worst case of this architecture: whoever holds it signs images the whole fleet will boot. The Zephyr port accepts several verification keys, which lets you switch to a backup key; but as long as the old one stays in the bootloader, an image signed with it still passes. So decide before production how you will withdraw it. Check your board's defaults too: for the example's Heltec board, Zephyr disables the signature, and MCUboot on ESP32 does not validate the primary slot and overwrites without rollback. - The signature does not protect everything. A compromised device of the group can still read the broadcast firmware and inject fragments to make the session fail, since it holds the group keys (TS004, section 4). It cannot get its own code to boot. If the firmware is confidential, MCUboot can also handle encrypted images. And the code that receives the fragments is itself an attack surface: CVE-2026-13480 is an out-of-bounds read in Zephyr's TS004 decoder, fixed in 4.4.2. Tracking the vulnerabilities of your radio stack is the subject of episode 1.
Takeaway¶
On LoRaWAN, updating is a matter of radio budget and trust. The FUOTA specifications provide the building blocks for the first: clock, multicast, redundant fragments; data rate, image size, coverage and RAM remain engineering choices. They leave the second to the manufacturer. Sign the image, have the bootloader verify it, confirm it only once it has proven itself, and forbid going back to a vulnerable version. That is what turns a channel of a few bytes into a secure update mechanism in the CRA's sense.
Previous episode: Generating an SBOM is not enough, monitor it with Dependency-Track.
Companion code, in examples/06-fuota-lorawan: a full simulated FUOTA
session with its tests, and the complete Zephyr firmware, with its test bench on a
Heltec ESP32 board.
Going further: the IETF's firmware update architecture for IoT (RFC 9019), and version 2.0.0 of TS004 published in 2022 (the Zephyr sample and AWS IoT Core for LoRaWAN implement 1.0.0).