Over-the-Air Updates
How Hanasu firmware travels over the Thread mesh as a signed package, is applied by an admin, and confirms or rolls back on its own.
Hanasu nodes update their firmware over the mesh. A release is wrapped in a signed OTA package (.mnpkg), sent to one node as a Type 6 extended transfer, checked and staged in the node’s spare flash slot, and then applied by an allow-listed admin with a signed command. The new image boots on trial and confirms itself only once the node is healthy. Otherwise it rolls back without outside help, and the node reports either outcome to the admin who asked.
All of this is implemented on both targets, the XIAO MG24 (Zephyr + MCUboot) and the XIAO ESP32C6 (ESP-IDF), and was validated on hardware in both directions on 2026-09-30. The normative sources are docs/OTA-PACKAGE.md (package format, rev 1.4) and MAGNet_Protocol_DESIGN_PROPOSAL.md §13 (remote apply, report and revocation, rev 2.4). For the step-by-step procedure, see the OTA Operator Guide.
Three steps, kept apart
| Step | Who can do it | What it changes |
|---|---|---|
| Stage | Any node on the channel (a Type 6 transfer) | Nothing that runs. The spare slot holds a verified package, inert until armed |
| Apply | An allow-listed admin (system/ota_apply, ADMIN|SIGNED), or a privileged local ota-apply |
Arms the slot and reboots into it on trial |
| Confirm or roll back | The node itself | Keeps the new image if healthy within 10 minutes, otherwise reverts |
Staging is harmless by design. Applying reboots the node and changes what it runs, so that is the privileged step.
Why a package, not a raw image
The first MG24 implementation shipped a bare MCUboot image and relied on MCUboot’s signature check. That does not carry over to the ESP32-C6: a C6 without ESP secure boot (an irreversible eFuse decision the project is not making) boots any well-formed app image. So the node itself must decide, before anything is staged, that an image:
- was released by a trusted key (authenticity),
- arrived intact (integrity),
- is meant for this chip and board (targeting: an MG24 image in a C6 slot, or an image without the XIAO RF-switch bring-up on a XIAO, bricks the radio),
- is an acceptable version (version policy).
A small signed header answers all four the same way on every platform, and the platform’s own image format rides inside it unchanged.
Package layout
A package is a fixed 256-byte header followed by the platform image, byte for byte. There is no trailer. The header always fits whole in chunk 0 of a Type 6 transfer (336 bytes), and every payload byte lands at package offset − 256, which keeps flash writes 4-byte aligned. File extension .mnpkg, MIME application/vnd.magnet.ota-package.
All multi-byte integers are big-endian. Reserved bytes must be zero when written and are verified zero when read.
| Offset | Size | Field | Meaning |
|---|---|---|---|
| 0 | 4 | magic |
MNPK |
| 4 | 1 | format_version |
1 |
| 5 | 1 | sig_alg |
2 = Ed25519 (preferred), 1 = deterministic ECDSA P-256 / SHA-256 |
| 6 | 2 | header_len |
256 |
| 8 | 2 | chip |
Chip ID from the registry |
| 10 | 2 | board |
Board ID; 0 = any board of that chip |
| 12 | 1 | image_format |
1 ESP-IDF app image, 2 MCUboot signed image |
| 13 | 1 | flags |
bit 0 REQUIRES_REPARTITION, bit 1 CLEARS_BUNDLES |
| 16 | 8 | version |
major u8, minor u8, revision u16, build u32 |
| 24 | 8 | min_running |
The node must already run at least this version (all-zero = no floor) |
| 32 | 4 | image_len |
Payload length |
| 36 | 32 | image_sha256 |
SHA-256 over the payload |
| 68 | 8 | build_time |
Unix seconds (informational) |
| 76 | 16 | variant |
Build flavor, e.g. ble-resident (informational) |
| 92 | 32 | fw_version |
The firmware’s version string (informational) |
| 124 | 8 | key_id |
SHA-256(release public key)[0:8] |
| 132 | 60 | reserved | Zero |
| 192 | 64 | signature |
Over header bytes 0–191 |
The signature covers every header byte before it, including image_sha256, so it covers the payload too. Ed25519 signs the 192 raw header bytes; P-256 signs SHA-256 of them (r ‖ s). Everywhere else (the apply command, !OTA events, tooling) a staged package is identified by sha128, the first 16 bytes of image_sha256.
Chip and board registry
chip and board come from an append-only registry in docs/OTA-PACKAGE.md §3.1: a value is never reused or redefined, and a board ID’s high byte is its chip ID. It covers six chips (ESP32-C6 0x0001, EFR32MG24 0x0002, classic ESP32, ESP32-S3, ESP32-C3, nRF52840) and every board in use across the MagNET repos. The two that Hanasu OTA builds use today:
| Board ID | Board | mnpkg.py --board |
|---|---|---|
0x0102 |
Seeed XIAO ESP32C6 | xiao-esp32c6 |
0x0201 |
Seeed XIAO MG24 | xiao-mg24 |
tools/mnpkg.py carries the same table and refuses a board that does not belong to the given chip.
The nine checks
A receiving node runs these in order and stages nothing unless all pass. Checks 1–6 need only the header, so the node runs them the moment chunk 0 arrives and refuses a bad package before its payload crosses the air. Checks 7–9 run after the last chunk.
| # | Check | Code |
|---|---|---|
| 1 | Magic, format_version == 1, header_len == 256, known sig_alg, no unknown flag bits, reserved bytes zero |
E_PKG_FORMAT |
| 2 | key_id names a key in the node’s release-key store |
E_PKG_KEY |
| 3 | Signature verifies with that key, under the key’s own algorithm | E_PKG_SIG |
| 4 | Chip matches, board matches (or is 0), image format is the one this chip runs |
E_PKG_TARGET |
| 5 | REQUIRES_REPARTITION is clear |
E_PKG_REPARTITION |
| 6 | image_len equals the transfer length − 256 and fits the update slot |
E_PKG_SIZE |
| 7 | SHA-256 of the payload as read back from flash equals image_sha256 |
E_PKG_SHA |
| 8 | The platform’s own image check passes | E_PKG_IMAGE |
| 9 | Running version ≥ min_running |
E_PKG_MIN |
Check 7 hashes what was read back, not what arrived, so it also catches a bad flash write. The downgrade rule is deliberately not a staging check: it is enforced at apply time, so an admin can stage a known-good older build ahead of an emergency rollback.
The node answers the sender with XC_COMPLETE only if all nine passed, and XC_REFUSED otherwise, with the failing check number in the STATUS frame’s reason byte. So on the sender, !XFER_SENT means “staged and verified”, and a refusal reads !XFER_FAIL <xid> refused:<n>.
Release keys
Each image carries a compiled-in store of up to two release keys, current and next. Rotating keys means shipping an image, signed by the current key, whose store holds the next one. Nodes must support Ed25519 and should support P-256; both Hanasu targets do. The release key is separate from the admin keys (who may apply), the MCUboot key (what may boot on the MG24) and bundle author keys (what Forth may run). Holding one grants none of the others.
The repo’s DEV release keys are public. A build that trusts them says so at boot with !WARN ota-dev-keys, and mnpkg.py build refuses a DEV key unless the variant name starts with dev. Release builds use keys generated offline; see the operator guide.
Staging on each platform
The transfer is one Type 6 transfer, CON unicast, with meta mnpkg:1. A node with an OTA sink claims a transfer by that meta, keeps the header in RAM, and writes payload bytes to its update slot.
| XIAO MG24 (Zephyr) | XIAO ESP32C6 (ESP-IDF) | |
|---|---|---|
| Update slot | MCUboot slot1, 712 KB |
The inactive ota_N partition, 1792 KB |
| Usable payload | 712 KB − 16 KB (move-mode sector + trailer reserve) | The whole slot |
| Erase | 8 KB pages, erased on first touch; the slot’s last page is wiped first so a stale trailer cannot trigger a swap | 4 KB sectors, erased on first touch (esp_ota_begin would erase the whole slot up front and cannot take out-of-order chunks) |
| Check 8 | MCUboot header parses and its size fields are consistent | esp_image_verify(), the bootloader’s own image check |
| Payload format | Unpadded zephyr.signed.bin; the slot trailer is written at apply time |
The unmodified IDF firmware.bin |
| Second signature | MCUboot re-verifies its own ECDSA P-256 signature at boot | None: the package signature is the only authenticity check |
On success the node logs !OTA staged v<version> <sha128 prefix> … — FORTH: ota-apply. The Forth word ota-status prints the running image and what is staged.
The C6 partition table
OTA on the C6 needs a dual-slot table, firmware-idf/partitions_ota.csv, which fits 4 MB parts:
| Partition | Offset | Size | Note |
|---|---|---|---|
nvs |
0x9000 |
24 KB | Unchanged, so identity, name, channel and BLE bonds survive the move |
otadata |
0xF000 |
8 KB | |
phy_init |
0x11000 |
4 KB | |
ota_0 |
0x20000 |
1792 KB | |
ota_1 |
0x1E0000 |
1792 KB | |
scripts |
0x3A0000 |
384 KB | LittleFS, reserved and unused today |
The BLE+LED image is about 1.16 MB (65 % of a slot) and the Thread-only image about 852 KB. A partition table cannot change over the air, so moving a node onto this table is a one-time USB flash of the OTA env (esp32c6_xiao_ble_led_ota, which also enables CONFIG_BOOTLOADER_APP_ROLLBACK_ENABLE). A package whose build changes the partition table must set REQUIRES_REPARTITION, and nodes refuse such packages over the air.
The MG24 flash map
The MG24 build uses MCUboot in swap-using-move mode with the map in firmware-zephyr/boards/mg24_partitions.dtsi: MCUboot 48 KB, slot0 712 KB, slot1 712 KB, and storage 64 KB at 0x170000, where the pre-MCUboot build put it, so identity and bonds survive. See XIAO MG24 on Zephyr.
Trial, confirm, rollback
Both platforms apply the same health policy, with each platform’s own mechanism underneath:
| XIAO MG24 | XIAO ESP32C6 | |
|---|---|---|
| Arm | boot_request_upgrade(TEST or PERMANENT) |
esp_ota_set_boot_partition() (re-verifies the image) |
| Trial state | MCUboot TEST: the next reset swaps the old image back unless confirmed | IDF PENDING_VERIFY: the bootloader reverts an image that was booted and never marked valid |
| Confirm | boot_write_img_confirmed() |
esp_ota_mark_app_valid_cancel_rollback() |
| Power loss mid-swap | Swap-using-move is power-fail safe | Not applicable (no swap) |
A new image is healthy, and only then confirmed, when both hold:
- the node reached READY (Thread attached), and
- every persisted Forth role bundle re-applied without error at boot (or none was persisted).
The node polls every 2 seconds. If it is not healthy within 10 minutes it logs !WARN ota-unhealthy and reboots, and the bootloader reverts. The bundle half of the rule matters: a firmware update can remove an FFI word a saved bundle uses. The bundle then fails to re-apply (safely), but the node still reaches READY, so a READY-only rule would confirm an image that had silently lost the node’s role.
PERMANENT mode skips the trial. On the MG24 it is MCUboot’s permanent upgrade; on the C6 the new image marks itself valid at its first boot when the apply record says PERMANENT. TEST is the default; PERMANENT is used only when explicitly requested.
Admin-signed remote apply
system/ota_apply (namespace 0x00, command 0x06) is a Type 1 M2M command on the existing admin plane: ENCRYPTED, SIGNED and ADMIN, signed by the sender’s identity key and verified against the receiver’s admin allow-list before dispatch. It is sent CON unicast to the target, never multicast.
| Param | Bytes | Meaning |
|---|---|---|
target_id |
4 | The device ID the command is for |
sha128 |
16 | image_sha256[0:16] of the staged package |
version |
8 | The staged package’s version |
mode |
1 | 0 TEST, 1 PERMANENT |
flags |
1 | bit 0 ALLOW_DOWNGRADE; other bits must be 0 |
The receiver checks, in this order; any failure answers with the code and changes nothing. The status byte is the check number.
| # | Check | Status |
|---|---|---|
| 1 | Frame is ADMIN|SIGNED by an allow-listed key | Silently dropped |
| 2 | target_id is this node |
E_NOT_TARGET (2) |
| 7 | This build supports OTA, mode ≤ 1, no unknown flag bit |
E_UNSUPPORTED (7) |
| 3 | A package is staged and not still being received | E_NOT_STAGED (3) |
| 4 | Staged sha128 matches |
E_SHA_MISMATCH (4) |
| 5 | Staged package header version matches version |
E_VERSION_MISMATCH (5) |
| 6 | version > running, unless ALLOW_DOWNGRADE |
E_DOWNGRADE (6) |
Check 7 runs right after check 2, so a build without OTA answers 7 rather than 3. A build without an OTA sink links weak defaults that answer E_UNSUPPORTED.
On success the node records the apply in NVS (the admin’s device ID and address, the new and previous versions, the mode), notes the CLEARS_BUNDLES flag, arms the slot, and replies accepted. It reboots once the reply’s CoAP ACK arrives (+0.5 s), or at a 20-second cap if it never does. Earlier drafts used a fixed short delay, which lost the reply on the bench whenever the first transmissions were lost; the cap keeps a lost admin from stranding an armed node.
The reply is a Type 2 M2M Response, 74 bytes: status, the target’s running version, and its 65-byte identity public key. It is SIGNED (not ADMIN) with the target’s own key. The admin does not hold that key in advance, so it checks the signature and that SHA-256(pubkey)[0:4] equals the sender’s device ID. SIGNED-without-ADMIN frames are accepted only for this reply and the report; any other is dropped.
The post-reboot report
The result of an apply is only visible after the reboot, so the target reports once its OTA state settles. system/ota_report (namespace 0x00, command 0x07) carries 83 bytes:
| Field | Bytes | Meaning |
|---|---|---|
outcome |
1 | 1 CONFIRMED (new image healthy), 2 ROLLED_BACK |
| running version | 8 | What the node runs now |
| other version | 8 | CONFIRMED: the version it replaced. ROLLED_BACK: the version that failed |
bundles |
1 | 0 none persisted, 1 all re-applied, 2 a re-apply failed, 3 cleared (CLEARS_BUNDLES) |
| public key | 65 | The target’s identity key, checked as for the reply |
The outcome is decided by version, not by platform state: running equals the applied version → CONFIRMED; running equals the version before the apply → ROLLED_BACK; anything else (reflashed by hand since) → the record is dropped silently. A rollback is reported by the old image on the boot after the revert. The report waits for READY and goes CON unicast to the admin that issued the apply, falling back to channel multicast if that send fails. A local ota-apply leaves no apply record, so it produces no report.
On the admin’s host link the exchange reads:
OTA APPLY <peer-id|ipv6> <sha128-hex> <maj.min.rev+build> [PERM] [DOWNGRADE]
+OK sent
!OTA_RESULT <id> accepted v<running>
!OTA_REPORT <id> confirmed v<new> (was v<old>) bundles=<n>
!OTA_REPORT <id> rolled-back v<old> (failed v<new>) bundles=<n>
sequenceDiagram
participant H as Operator host
participant A as Admin node
participant T as Target node
H->>A: XFER BEGIN / XFER DATA (meta mnpkg:1)
A->>T: Type 6 chunk 0 (header)
Note over T: checks 1-6
A->>T: Type 6 chunks 1..n
Note over T: checks 7-9, staged in spare slot
T-->>A: XC_COMPLETE
A-->>H: !XFER_SENT
H->>A: OTA APPLY id sha128 version
A->>T: system/ota_apply (ADMIN, SIGNED)
Note over T: apply checks 2-7, record + arm slot
T-->>A: reply accepted (SIGNED)
A-->>H: !OTA_RESULT accepted
Note over T: reboot on ACK, trial boot
Note over T: READY + bundles re-applied within 10 min
T->>A: system/ota_report confirmed (or rolled-back)
A-->>H: !OTA_REPORT
CLEARS_BUNDLES
Flag bit 1 is for firmware that breaks the Forth vocabulary on purpose. While such an image is on trial the node does not re-apply its saved bundles: health reduces to READY and the report says bundles=3. Once the image is confirmed, the node forgets the saved bundles, and the operator redeploys bundles written for the new vocabulary. If the image rolls back instead, the bundles were never touched and the old image re-applies them as usual.
Nodes record the flag whenever an image is armed, by local ota-apply or remote apply, keyed by the image’s version. Package format revisions 1.0–1.3 said “forget on first boot”, which lost the bundles on a rollback; rev 1.4 holds them back during the trial instead.
Admin keys and revocation
Admin keys are named by fingerprint, SHA-256(65-byte pubkey)[0:8], which ADMIN LIST prints. Its first 4 bytes are the key owner’s device ID. A node allow-lists up to 4 admin keys.
| Command | Scope |
|---|---|
ADMIN REMOVE <n | fp> |
This node’s allow-list only |
ADMIN REVOKE <fp> |
Sends a signed system/admin_revoke to the whole channel, then removes the key locally |
system/admin_revoke (namespace 0x00, command 0x08) carries the 8-byte fingerprint, is ADMIN|SIGNED, and is verified against the receiver’s allow-list like any admin command. Unlike apply it is multicast NON: revocation must reach every node, and it is idempotent, so the operator repeats it to cover a lost frame. An admin may revoke its own key, because the frame is signed before the local removal. A node that does not hold the key logs # admin revoke: key not held.
Limits: revocation cannot stop a thief who still holds a valid admin key from revoking the others just as fast, and a node that was offline when the revoke went out keeps the key until it is revoked again. There is no revocation list.
Threats
| Threat | Mitigation |
|---|---|
| A channel member triggers apply | Needs an allow-listed admin signature |
| Replay of a captured apply | Counter high-water, and the command is bound to target_id plus the sha128 of what is staged now; a replay against a different or already-applied image fails check 3 or 4 |
| An evicted sender replays once | Same binding; at worst it re-applies the identical image |
| A rogue member overwrites the slot after staging | Apply fails E_SHA_MISMATCH; the admin restages (denial of service, not compromise) |
| Malicious image | Release-key signature checked before staging on every platform (the C6’s only check); MCUboot re-checks its own signature on the MG24 |
| Downgrade to an old signed image with a known bug | Check 6; ALLOW_DOWNGRADE is an explicit, signed admin choice |
| Bricked radio after an update | TEST mode, confirm-when-healthy, 10-minute deadline, automatic rollback |
| Power loss mid-transfer or mid-swap | Transfer: nothing staged, restage. Swap: swap-using-move is power-fail safe |
| Stolen admin key | ADMIN REMOVE locally, signed ADMIN REVOKE on the mesh; recovery is ADMIN ADD over a bonded link or serial |
| Forged apply reply or report | Signature plus SHA-256(pubkey)[0:4] == device_id. That binding is only 32 bits: a member willing to grind about 2³² P-256 keys could impersonate one node’s report (not its apply). Planned: replace with the full key once peers exchange keys |
Fleet rollout is sequential with a canary: one node first, the next only after its CONFIRMED report, never a multicast apply.
Hardware validation
Run 2026-09-30 between a XIAO MG24 (Zephyr, BLE-resident, MCUboot) and a XIAO ESP32C6 (esp32c6_xiao_ble_led_ota), each allow-listing the other’s key. Every row was observed on hardware:
| Case | Observed |
|---|---|
| Apply signed by a key the target does not allow-list | No reply, nothing logged, image untouched |
| Nothing staged / wrong SHA / wrong version | E_NOT_STAGED / E_SHA_MISMATCH / E_VERSION_MISMATCH, no reboot |
| Older version staged | E_DOWNGRADE; with DOWNGRADE, accepted and confirmed |
| Happy path, C6 admin → MG24 | accepted → swap → TEST → confirmed, report received on the C6 |
| Forced-unhealthy MG24 image | Not healthy in 20 s (test build) → MCUboot revert → rolled-back report |
| Same apply resent after success | E_NOT_STAGED |
| Captured apply frame replayed byte for byte | Immediately: dropped by the counter high-water. After the target rebooted (high-water table empty): reached dispatch and got E_NOT_STAGED, so the staged-image binding is what stops it |
CLEARS_BUNDLES, image rolls back |
Bundles held back on trial, re-applied by the old image, report bundles=1, bundle still listed |
CLEARS_BUNDLES, image confirms |
Held back, confirmed, bundle forgotten, report bundles=3 |
BLE host path: Mac → bonded BLE → MG24 OTA APPLY → C6 |
!OTA_RESULT and !OTA_REPORT both arrived as BLE notifications |
| Package rejection matrix (wrong key, bad signature, wrong chip or board, truncated or flipped payload) | Covered by the staging runs plus the host test suite tools/pkgtest, 25/25 |
| MG24 admin → C6 target | accepted → ota_1 TEST → confirmed, with the C6’s saved role bundle re-applied |
| Forced-unhealthy C6 image | Reverted by IDF app rollback |
ADMIN REVOKE (MG24 revokes its own key on the C6) |
C6 logs !WARN admin-revoked; the next apply from the MG24 was silently dropped |
Two Zephyr-port bugs surfaced during these runs. The accepted reply was lost on several MG24 applies because the compat layer allocated FreeRTOS timer control blocks from the kernel heap, which BLE exhausts at runtime, so the reboot timer could not be created. The NVS shim had the same root cause: its key-list snapshot came from the kernel heap, so at runtime every NVS enumeration silently returned nothing. Both now allocate from the libc arena. Not covered by this run: refusal of a privileged verb on an unbonded BLE link (that gate was validated earlier, in the BLE phase).
Staging speed is bound by the node and radio round trip: tools/ota_push.py moves about 90 ms per 336-byte chunk. C6 → MG24 with a 557 KB package took 137–169 s; MG24 → C6 with 1.17 MB took 385 s.
Last updated 12 Oct 2026, 20:51 -0700 .