diff --git a/boards/nrf52840_s140_v7_xiao_bootloader_ota.ld b/boards/nrf52840_s140_v7_xiao_bootloader_ota.ld new file mode 100644 index 000000000..699e6e2aa --- /dev/null +++ b/boards/nrf52840_s140_v7_xiao_bootloader_ota.ld @@ -0,0 +1,53 @@ +/* XIAO nRF52840 LoRa bootloader-update layout. + * + * The application ends at 0xE0000. OTAFIX owns 0xE0000..0xEA000 as a 40 KiB internal scratch bank + * while replacing its own padded 0xF4000..0xFE000 bootloader region from the verified QSPI package. + */ + +SEARCH_DIR(.) +GROUP(-lgcc -lc -lnosys) + +MEMORY +{ + FLASH (rx) : ORIGIN = 0x27000, LENGTH = 0xE0000 - 0x27000 + + /* To keep data in RAM across resets */ + PERSISTENT_RAM (rwx) : ORIGIN = 0x20006000, LENGTH = 8 + + /* SRAM required by SoftDevice depend on + * - Attribute Table Size (Number of Services and Characteristics) + * - Vendor UUID count + * - Max ATT MTU + * - Concurrent connection peripheral + central + secure links + * - Event Len, HVN queue, Write CMD queue + */ + RAM (rwx) : ORIGIN = 0x20006000 + 8, LENGTH = 0x20040000 - 0x20006000 - 8 +} + +SECTIONS +{ + . = ALIGN(4); + .persistent (NOLOAD) : + { + KEEP(*(.persistent_magic)) + KEEP(*(.persistent_data)) + . = ALIGN(4); + } > PERSISTENT_RAM + + . = ALIGN(4); + .svc_data : + { + PROVIDE(__start_svc_data = .); + KEEP(*(.svc_data)) + PROVIDE(__stop_svc_data = .); + } > RAM + + .fs_data : + { + PROVIDE(__start_fs_data = .); + KEEP(*(.fs_data)) + PROVIDE(__stop_fs_data = .); + } > RAM +} + +INCLUDE "nrf52_common.ld" diff --git a/docs/cli_command_availability.md b/docs/cli_command_availability.md index 4bd37ea7f..94c9f5e3e 100644 --- a/docs/cli_command_availability.md +++ b/docs/cli_command_availability.md @@ -166,6 +166,7 @@ fix, no WiFi connection, an inactive bridge, or an nRF52 bootloader without | LoRa OTA | [`ota get`; `ota pull`; `ota download`](ota_protocol.md#11-cli-surface-otaclicpp) | LoRa OTA build; internal-store nRF52 installs in-place deltas, while matched SD/QSPI nRF52 also accepts full images | No | No | Yes | | LoRa OTA | [`ota install`; `ota apply`; `ota applydelta`](ota_protocol.md#11-cli-surface-otaclicpp) | Compatible bootloader and completed update | No | No | Yes | | LoRa OTA | [`ota rescue install `](ota_protocol.md#12-apply-bootloader-contract) | Internal-flash nRF52 LoRa OTA build with failed app-side EndF validation | No | No | Feature | +| LoRa OTA | [`ota bootloader`; `ota bootloader install `](ota_nrf52_qspi.md#explicit-xiao-bootloader-updates-over-lora) | Boot-update-capable XIAO-module QSPI repeater with ABI-3 exact-board OTAFIX | No | No | Feature | | LoRa OTA | [`ota cancel`; `ota drop`; `ota stop`](ota_protocol.md#11-cli-surface-otaclicpp) | LoRa OTA build | No | No | Yes | | LoRa OTA | [`ota announce`; `ota adv`](ota_protocol.md#11-cli-surface-otaclicpp) | LoRa OTA build | No | No | Yes | | LoRa OTA | [`ota self`; `ota id`](ota_protocol.md#11-cli-surface-otaclicpp) | Firmware with EndF trailer | No | No | Yes | @@ -325,6 +326,7 @@ fix, no WiFi connection, an inactive bridge, or an nRF52 bootloader without | LoRa OTA | [`ota get`; `ota pull`; `ota download`](ota_protocol.md#11-cli-surface-otaclicpp) | LoRa OTA build | No | No | Yes | Yes | Yes | | LoRa OTA | [`ota install`; `ota apply`; `ota applydelta`](ota_protocol.md#11-cli-surface-otaclicpp) | Compatible completed update | No | No | Yes | Yes | Yes | | LoRa OTA | [`ota rescue install `](ota_protocol.md#12-apply-bootloader-contract) | Internal-flash nRF52 LoRa OTA build with failed app-side EndF validation | No | No | Feature | No | No | +| LoRa OTA | [`ota bootloader`; `ota bootloader install `](ota_nrf52_qspi.md#explicit-xiao-bootloader-updates-over-lora) | nRF52 XIAO QSPI only | No | No | No | No | No | | LoRa OTA | [`ota cancel`; `ota drop`; `ota stop`](ota_protocol.md#11-cli-surface-otaclicpp) | LoRa OTA build | No | No | Yes | Yes | Yes | | LoRa OTA | [`ota announce`; `ota adv`](ota_protocol.md#11-cli-surface-otaclicpp) | LoRa OTA build | No | No | Yes | Yes | Yes | | LoRa OTA | [`ota self`; `ota id`](ota_protocol.md#11-cli-surface-otaclicpp) | Firmware with EndF trailer | No | No | Yes | Yes | Yes | diff --git a/docs/lora_ota_automation.md b/docs/lora_ota_automation.md index c056ecc72..99e388971 100644 --- a/docs/lora_ota_automation.md +++ b/docs/lora_ota_automation.md @@ -13,8 +13,13 @@ the right container, move the participating nodes to a temporary radio channel, serve and monitor the download, request installation, restore the controller, and check the rebooted node. -The script cannot install the destination's first OTA-capable firmware or its -nRF52 bootloader. Do those one-time jobs over USB before using LoRa OTA. +The script cannot install the destination's first OTA-capable firmware. It is +also deliberately application-only: it rejects both v3 bootloader containers +and a v2 container carrying the bootloader flag, and it never sends the +privileged install command. Do the first nRF52 bootloader installation over USB; +an already-capable XIAO QSPI repeater may later use the separate, manual +[`ota bootloader install`](ota_nrf52_qspi.md#explicit-xiao-bootloader-updates-over-lora) +workflow. ## Required topology @@ -425,9 +430,10 @@ the destination. 2. Authenticate to the target and query its target ID, hardware, running body hash, firmware version, bootloader version, and nRF52 bootloader capabilities. -3. Select or build one compatible mOTA and verify all block hashes, Merkle - root, full-image hash where applicable, identity fields, signature, codec, - base, and the firmware's 1024-byte maximum block size. +3. Select or build one compatible **v2 application** mOTA and verify all block + hashes, Merkle root, full-image hash where applicable, identity fields, + signature, codec, base, and the firmware's 1024-byte maximum block size. + Version-3 bootloader packages are refused before any target state changes. 4. Save the controller's normal radio tuple and show the confirmation prompt. 5. Start TempRadio on the target, then far-to-near relays, then the source; finally switch the controller to the same tuple and read it back. The runner diff --git a/docs/ota_easy.md b/docs/ota_easy.md index d4839a228..637acc762 100644 --- a/docs/ota_easy.md +++ b/docs/ota_easy.md @@ -333,6 +333,10 @@ Discovery is asynchronous. `ota ls` says `refreshing`; wait a few seconds and ru shows an older row. Select `[same target]`: it can say `full` for ESP32 and external SD/QSPI nRF52 targets, or `delta` for any supported nRF52 target. Do not select `[unsupported]` (for example, a source's self-served full image on an internal-staging nRF52). +Rows labelled `bootloader` are also outside this ordinary application flow: +they are never automatic and `ota install` refuses them. Only an already-capable +XIAO QSPI repeater can use the separate +[explicit bootloader workflow](ota_nrf52_qspi.md#explicit-xiao-bootloader-updates-over-lora). Use the row's stable eight-hex manifest ID rather than its changing list position: ```text diff --git a/docs/ota_nrf52_qspi.md b/docs/ota_nrf52_qspi.md index 93e1819f2..128243372 100644 --- a/docs/ota_nrf52_qspi.md +++ b/docs/ota_nrf52_qspi.md @@ -13,8 +13,9 @@ an update is downloaded. ## Supported repeater families -QSPI staging is enabled only for repeater-role environments on these currently -matched families: +QSPI staging is enabled only for explicitly matched repeater-role environments +(and RS232 bridge variants that directly inherit one of those repeater targets) +on these currently matched families: - Seeed XIAO nRF52840 and XIAO nRF52840 Sense modules, including the `Xiao_nrf52`, SolarXiao 30S/33S, and XIAO-module Ikoka handheld, Nano, and @@ -40,8 +41,8 @@ T-Echo target. Companion builds are intentionally excluded. Some companion targets use the same external QSPI as a LittleFS message/data store, where raw OTA staging would corrupt the filesystem; other companions simply do not assign that chip -to OTA. Room-server, sensor, KISS, and repeater-bridge roles are also unchanged. -Raw QSPI OTA is scoped to the explicitly matched repeater environments. +to OTA. Room-server, sensor, and KISS roles are unchanged. Raw QSPI OTA is +scoped to the explicitly matched repeater-derived environments. ### RAK15001 placement and module conflicts @@ -135,13 +136,21 @@ starting another pull; later capacity probes preserve that failure detail. The store reads the JEDEC capacity at runtime and accepts supported 1 MiB through 16 MiB devices using 24-bit addressing. QSPI capacity is not the final firmware limit. The reconstructed application, including its 56-byte `EndF` -trailer, must fit below InternalFS at `0xED000`: +trailer, must fit its build's linked application region: | SoftDevice layout | Application region | Maximum image | | --- | --- | --- | +| S140 v7, boot-update-capable XIAO module | `0x27000..0xE0000` | `0xB9000` (757,760 bytes) | | S140 v7, app base `0x27000` | `0x27000..0xED000` | `0xC6000` (811,008 bytes) | | S140 v6, app base `0x26000` | `0x26000..0xED000` | `0xC7000` (815,104 bytes) | +The XIAO limit is deliberately 52 KiB smaller. Its linker and post-link record +cap every ordinary application at `0xE0000`; OTAFIX reserves +`0xE0000..0xEA000` as a 40 KiB self-update scratch bank, with the remaining gap +left unused before InternalFS. Full and delta application packages for these +targets derive their effective application end from that `0xE0000` record and +are rejected if their reconstructed image or detools geometry crosses it. + A full package needs only the new raw `firmware.hex` or non-merged application image. An in-place delta still needs the exact image currently running. The automation uses a conservative `0xC6000` detools workspace for external nRF52 @@ -165,6 +174,54 @@ operator can deliberately override the routing target for a role change, so `target_id` is not an apply-time safety assertion; the destination still enforces the package's hardware identity before approval. +## Explicit XIAO bootloader updates over LoRa + +Bootloader delivery is available only when all of these are already true: + +- the application is an `OTA_QSPI_BOOTLOADER_UPDATE` XIAO-module repeater build + linked below `0xE0000`; +- `ota bootloader` shows a CRC-valid installed `XIAO_DFU` embedded identity for + board ID `28860044` (base) or `28860045` (Sense); +- the installed OTAFIX marker reports ABI 3 or newer and both QSPI (`0x04`) and + boot-update (`0x08`) capability bits; +- the package is the exact signed v3/40-KiB/1-KiB-block profile for that board, + and its signer is already in `ota key`'s trusted allowlist. + +This is not part of normal automation. Bootloader catalog rows are labelled +`bootloader`; even a capable node never autofetches or autoinstalls them, and +ordinary `ota install` rejects them in every application backend. The +`tools/lora_ota` deployment runner also refuses v3 packages. Fetch and arm one +only with the explicit flow: + +```text +ota ls +ota pull flash +# wait for ota status to report the same bootloader download as ready +ota bootloader +# copy the exact mid= and hash= values printed above +ota bootloader install +``` + +The pull command is only transport intent; it does not authorize an install. +The final command must reproduce both the complete staged manifest ID and the +first eight bytes of its signed image hash. Before approval the application +again checks package/root/payload/image hashes, Ed25519 signature and trusted +allowlist, exact signed `XIAO_BL_...` ID, installed and incoming embedded +manifest/CRC/name/board identity, vector table, and ABI-3 QSPI+boot-update +continuity. A signed older bootloader that would remove remote update support +is refused. + +After the reply drains, GPREGRET `0x6B` and GPREGRET2 `0x51` enter the special +OTAFIX path. OTAFIX independently rechecks the package before using the +reserved scratch bank to replace its own `0xF4000..0xFE000` region. The running +application remains preserved; a rejected candidate returns to it unchanged. +Post-reboot `ota status` reports bootloader-update diagnostics as `blup:C0` to +`blup:CF` (`blup:C8` is success), separately from ordinary application `blrc`. + +This feature cannot update a stock or old bootloader that lacks the capability +marker: perform the first exact-board combined bootloader+SoftDevice migration +over USB/BLE DFU or SWD. Do not attempt to bootstrap it with a v3 package. + ## Storage ownership and recovery The QSPI store is raw, not a file inside LittleFS. It owns the flash from diff --git a/docs/ota_protocol.md b/docs/ota_protocol.md index 791a71c3e..df93737ee 100644 --- a/docs/ota_protocol.md +++ b/docs/ota_protocol.md @@ -61,11 +61,13 @@ the authoritative reference for byte-level details. | Container `TRAILER` | `76 6B 34 39 36` | `vk496` | | `EndF` marker | `45 6E 64 46` | `EndF` | | `hash_algo` (sha2-256) | `0x12` | multihash code | -| `format_ver` | `0x02` | this spec | +| application `format_ver` | `0x02` | ordinary full/delta application package | +| bootloader `format_ver` | `0x03` | privileged XIAO bootloader package only | | `approval` = not approved | `FF FF FF FF` | erased NOR word | | `approval` = approved | `41 50 52 56` | `APRV` | | `MFLAG_FULL` | `0x01` | flags bit0 | | `MFLAG_SIGNED` | `0x02` | flags bit1 | +| `MFLAG_BOOTLOADER` | `0x04` | flags bit2; valid only in the exact v3 bootloader profile | | `CODEC_FULL` / `_SEQUENTIAL` / `_INPLACE` | `0` / `1` / `2` | Section 5 | | `PAYLOAD_TYPE_OTA` | `0x0C` | MeshCore packet type (`src/Packet.h`) | | `MAX_PACKET_PAYLOAD` | `184` | usable bytes per packet (`src/MeshCore.h`) | @@ -209,8 +211,8 @@ Only `leaves[]` is variable (one 4-byte hash per block). So the manifest-minus-l ``` off size field notes -0 1 format_ver = 0x02 -1 1 flags bit0 FULL (0=delta/partial, 1=full image); bit1 SIGNED; bits2-7 reserved 0 +0 1 format_ver = 0x02 application, or 0x03 privileged bootloader package +1 1 flags bit0 FULL; bit1 SIGNED; bit2 BOOTLOADER; bits3-7 reserved 0 2 1 hash_algo 0x12 = sha2-256 3 4 target_id device/arch/role discriminator (Section 9) 7 4 fw_version MAJOR<<24 | MINOR<<16 | PATCH<<8 | pre (comparable uint32) @@ -237,6 +239,11 @@ Manifest-minus-leaves size (`mfl`) is a constant **197 bytes** for every contain or unsigned). At 197 bytes the manifest exceeds one packet, so `OTA_MANIFEST` is always sent multi-fragment (Section 8.4, 2 fragments) and reassembled by the fetcher. +The two versions are deliberately disjoint. Version 2 accepts application packages only and rejects the +BOOTLOADER bit. Version 3 accepts only flags exactly `FULL|SIGNED|BOOTLOADER`; a non-bootloader v3 package +is invalid. Consequently, deployed v2-only application parsers reject a bootloader package before they can +mistake its raw 40 KiB payload for an application image. + ### 4.1 Signed region `signature` covers manifest bytes `[0, 129)` - the head + `base_hash` + `signer_pubkey`. It does **not** @@ -253,6 +260,23 @@ cover `approval` or `leaves[]`: - Bound to this image (lives in this `.mota`'s manifest, re-erased when a new `.mota` is staged). - A **consent** marker, not a security primitive. Authenticity = `signature` + `image_hash` + `hw_id`. +### 4.3 Privileged XIAO bootloader package profile + +A v3 bootloader package has a deliberately narrow, non-extensible profile: + +- flags exactly `FULL|SIGNED|BOOTLOADER`, `CODEC_FULL`, nonzero `fw_version`, zero `base_hash`; +- a raw payload and `image_size` of exactly `0xA000` (40 KiB), split into exactly forty 1024-byte blocks; +- `target_id` equal to the installed OTAFIX embedded board ID (`0x28860044` for XIAO nRF52840 or + `0x28860045` for XIAO nRF52840 Sense); +- signed `hw_id` exactly `XIAO_BL_28860044` or `XIAO_BL_28860045`, derived from that board ID; +- a sane nRF52840 vector table, a CRC-valid embedded manifest v1 with the exact padded device name + `XIAO_DFU`, and a `MOTABLDR` marker advertising ABI >= 3, full codec, QSPI, and boot-update continuity. + +The incoming embedded identity must exactly match the installed CRC-valid bootloader identity. Both scans +consider every aligned structurally valid candidate so magic bytes in a literal pool cannot shadow the real +manifest. A package must be signed by a key already in the device's trusted allowlist; unlike ordinary +application packages, there is no unsigned manual-install exception. + --- ## 5. Payload, codecs & delta base @@ -746,6 +770,9 @@ ota get | pull | download flash [rescue] | folder [validate] fetch by stable mid8 (preferred) or current page index ota install | apply | applydelta verify + approve + (ESP32) apply / (nRF52) reboot-to-bootloader ota rescue install internal-flash nRF52 only: recover from failed app-side EndF validation +ota bootloader [status] capable XIAO QSPI repeater: installed BL identity/caps + staged confirmation +ota bootloader install + explicitly verify/arm one complete trusted v3 package; never automatic ota cancel | drop | stop drop the current fetch session (frees the slot) ota announce | adv serve self + send a beacon now ota self | id print this firmware's EndF (body/image size, base_hash) @@ -817,10 +844,22 @@ ota dev ... bring-up helpers (stage/recv/serve/verify) complete internal application region as workspace. Companion builds never enable this raw store: some use QSPI as a filesystem, while others simply leave that chip outside OTA ownership. See [the nRF52 QSPI guide](ota_nrf52_qspi.md). +- **XIAO bootloader self-update (explicit only):** selected XIAO-module QSPI repeater builds link the + ordinary application below `0xE0000`, reserving `0xE0000..0xEA000` as a 40 KiB internal scratch bank. + They accept a v3 bootloader package only through an exact manual MID pull. Ordinary `ota install`, + autofetch, autoinstall, and every application apply backend reject it. The operator then copies the + staged package's exact values from `ota bootloader` into + `ota bootloader install `. The app repeats the strict v3 geometry, installed/candidate + identity, vectors, embedded CRC/capabilities, complete payload/image hashes, signature, and trusted-key + gates before writing `APRV`. GPREGRET `0x6B` plus GPREGRET2 `0x51` hands the QSPI package to OTAFIX. + OTAFIX independently repeats all privileged checks, uses the scratch bank to preserve the running + application while replacing `0xF4000..0xFE000`, and reports boot-update results in GPREGRET2 `0xC0..0xCF` + (`0xC8` success). This mechanism cannot bootstrap a stock/older bootloader; install an ABI-3, + boot-update-capable exact-board OTAFIX over USB/BLE DFU or SWD once first. -A signature, when present, proves author authenticity and must pass the device allowlist. Unsigned packages -remain installable when local policy permits them. The one-shot `approval` marker records local consent for -either form before the bootloader may apply it. +A signature, when present, proves author authenticity and must pass the device allowlist. Unsigned v2 +application packages remain installable when local policy permits them. A v3 bootloader package is always +signed and trusted. The one-shot `approval` marker records local consent before the bootloader may apply it. > **Bootloader testing note:** always test apply with a *real different* image (base != target). A same-image > (X->X) "delta" trivially reproduces the target and gives a false positive. @@ -829,7 +868,8 @@ either form before the bootloader may apply it. ## 13. Versioning of this spec -`format_ver = 2`. A parser accepts exactly this value and rejects anything else - there is one container -format, fixed-layout, and no compatibility shims to carry. If the format ever needs to change, bump -`format_ver`; the multihash `hash_algo` separately allows swapping the digest family without a format -bump. Unknown `codec_id` / `ota_msg_type` values are ignored (a node simply won't fetch what it can't apply). +The fixed byte layout has two intentionally disjoint profiles: `format_ver = 2` for ordinary application +packages and `format_ver = 3` only for the exact privileged bootloader profile in Section 4.3. A parser must +reject v2+BOOTLOADER, v3 without exact `FULL|SIGNED|BOOTLOADER`, and every other version. The multihash +`hash_algo` separately allows swapping the digest family without a format bump. Unknown `codec_id` / +`ota_msg_type` values are ignored (a node simply will not fetch what it cannot apply). diff --git a/docs/ota_user_guide.md b/docs/ota_user_guide.md index a356537b0..09c31bcd9 100644 --- a/docs/ota_user_guide.md +++ b/docs/ota_user_guide.md @@ -216,6 +216,40 @@ rejected; trusted signed images can auto-install only when that policy is enable After it reboots, run `ota status` to confirm the new version. +### Updating a XIAO bootloader (advanced, explicit only) + +This is available only on the specially marked XIAO nRF52840/Sense QSPI +repeater builds after a one-time exact-board ABI-3 OTAFIX installation over +USB/BLE DFU or SWD. It is not the normal firmware update path. Check support +first: + +```text +ota bootloader +``` + +The reply must show a valid `XIAO_DFU` board identity plus ABI 3 and the QSPI / +boot-update capability bits. A bootloader package appears as `bootloader` in +`ota ls`. It is never downloaded or installed automatically, even if both OTA +automation settings are enabled. Use its stable ID explicitly: + +```text +ota pull flash +# wait until ota status says this bootloader download is ready +ota bootloader +ota bootloader install +``` + +Copy both confirmation values exactly from the second `ota bootloader` reply. +Ordinary `ota install` deliberately refuses this package. The privileged +command requires an exact 40 KiB candidate for the installed XIAO board, a +valid embedded identity/CRC and vector table, continued boot-update support, +and a valid signature from a key already in `ota key`'s trusted allowlist. It +preserves the running application while OTAFIX replaces itself; `blup:C8` in +post-reboot `ota status` means success. Any node lacking this command or those +capabilities must update its bootloader locally instead. See +[the nRF52 QSPI guide](ota_nrf52_qspi.md#explicit-xiao-bootloader-updates-over-lora) +for the complete safety contract. + ### 5. If something goes wrong - A download that stalls or gets interrupted just **resumes** later, or you can `ota cancel` and try again. diff --git a/src/Mesh.cpp b/src/Mesh.cpp index c7ef9b010..75f57dd02 100644 --- a/src/Mesh.cpp +++ b/src/Mesh.cpp @@ -335,7 +335,8 @@ void __attribute__((noinline)) Mesh::serviceLoopMaintenance() { oc.apply_hard = futureMillis(15000); } else if (millisHasNowPassed(oc.apply_at) && (_mgr->getOutboundTotal() == 0 || millisHasNowPassed(oc.apply_hard))) { - ota::ota_reboot_to_apply(); // does not return + if (oc.bootloader_apply_pending) ota::ota_reboot_to_bootloader_update(); + else ota::ota_reboot_to_apply(); } } } @@ -410,7 +411,8 @@ void __attribute__((noinline)) Mesh::serviceLoopMaintenance() { _ota_autoinstall_tried = false; } else if (!_ota_autoinstall_tried && !oc.apply_pending && oc.autoinstall == ota::OtaContext::AUTOINSTALL_TRUSTED - && oc.manager.fetched_is_signed()) { + && oc.manager.fetched_is_signed() + && !oc.manager.fetched_is_bootloader()) { _ota_autoinstall_tried = true; char msg[100]; oc.apply_fetched(msg); // arms + sets apply_pending only if signed & allowlisted; refused otherwise diff --git a/src/helpers/ota/MotaContainer.cpp b/src/helpers/ota/MotaContainer.cpp index 4a4c5ed23..671c06e9c 100644 --- a/src/helpers/ota/MotaContainer.cpp +++ b/src/helpers/ota/MotaContainer.cpp @@ -17,8 +17,16 @@ bool MotaManifest::is_approved() const { // always covers manifest[0, MOTA_SIGNED_LEN). Returns false on any over-read or bad format_ver. static bool parse_manifest_fields(ByteReader& r, MotaManifest& out) { out.format_ver = r.u8(); - if (out.format_ver != MOTA_FORMAT_VER) return false; out.flags = r.u8(); + // v2 is application-only and v3 is bootloader-only. Requiring exact flags on v3 prevents a + // malformed or downgraded package from entering either apply path. + if (out.format_ver == MOTA_APP_FORMAT_VER) { + if (out.flags & MFLAG_BOOTLOADER || out.flags & ~MFLAG_KNOWN) return false; + } else if (out.format_ver == MOTA_BOOT_FORMAT_VER) { + if (out.flags != (MFLAG_FULL | MFLAG_SIGNED | MFLAG_BOOTLOADER)) return false; + } else { + return false; + } out.hash_algo = r.u8(); out.target_id = r.u32(); out.fw_version = r.u32(); @@ -37,6 +45,13 @@ static bool parse_manifest_fields(ByteReader& r, MotaManifest& out) { if (!r.ok) return false; if (out.block_size_log2 == 0 || out.block_size_log2 > 24 || out.payload_size == 0) return false; out.block_count = (out.payload_size + out.block_size() - 1) / out.block_size(); + if (out.is_bootloader()) { + static const uint8_t zero_base[8] = {0}; + if (out.fw_version == 0 || out.codec_id != CODEC_FULL || out.image_size != 0xA000u || + out.payload_size != 0xA000u || out.block_size_log2 != 10 || + out.block_count != 40 || memcmp(out.base_hash, zero_base, sizeof(zero_base)) != 0) + return false; + } // block_idx is uint16 on the wire; capping here also keeps block_count*4 (leaves length) from overflowing. return out.block_count != 0 && out.block_count <= 0xFFFFu; } diff --git a/src/helpers/ota/MotaContainer.h b/src/helpers/ota/MotaContainer.h index 3f6686da2..a3c1c0089 100644 --- a/src/helpers/ota/MotaContainer.h +++ b/src/helpers/ota/MotaContainer.h @@ -40,6 +40,7 @@ struct MotaManifest { bool is_full() const { return flags & MFLAG_FULL; } bool is_signed() const { return flags & MFLAG_SIGNED; } + bool is_bootloader() const { return flags & MFLAG_BOOTLOADER; } uint32_t block_size() const { return 1u << block_size_log2; } bool is_approved() const; }; diff --git a/src/helpers/ota/OtaApply.cpp b/src/helpers/ota/OtaApply.cpp index 0f12e5d2f..99e47386d 100644 --- a/src/helpers/ota/OtaApply.cpp +++ b/src/helpers/ota/OtaApply.cpp @@ -52,7 +52,7 @@ bool ota_apply_set_manifest(const uint8_t* mf, uint32_t len, const SignerAllowli ota_apply_slot_info(&st.slot_addr, &st.slot_size); MotaManifest m; if (!mota_parse_manifest(mf, len, m)) return false; - if (!m.is_full()) return false; // A/B apply takes a full image (delta would need decode) + if (m.is_bootloader() || !m.is_full()) return false; // boot packages never enter an application slot st.image_size = m.image_size; memcpy(st.image_hash, m.image_hash, 32); st.manifest_ok = true; @@ -293,6 +293,7 @@ bool ota_apply_detools_mota(OtaStoreFlashEsp32& store, const SignerAllowlist& al MotaManifest m; if (mflen < 57 || !store.read(8, mfbuf, mflen) || !mota_parse_manifest(mfbuf, mflen, m)) { strcpy(msg, "manifest parse failed"); return false; } + if (m.is_bootloader()) { strcpy(msg, "bootloader package requires explicit bootloader install"); return false; } st.image_size = m.image_size; memcpy(st.image_hash, m.image_hash, 32); st.manifest_ok = true; if (m.image_size == 0 || m.image_size > slot->size) { strcpy(msg, "image > slot"); return false; } @@ -367,6 +368,7 @@ bool ota_apply_detools_mota(const uint8_t* buf, uint32_t len, const SignerAllowl st = ApplyState(); MotaManifest m; if (!mota_parse(buf, len, m)) { strcpy(msg, "no valid .mota (parse failed)"); return false; } + if (m.is_bootloader()) { strcpy(msg, "bootloader package cannot enter an application slot"); return false; } if (m.is_full() || m.codec_id != CODEC_DETOOLS_SEQUENTIAL) { strcpy(msg, "not a detools-sequential delta"); return false; } st.image_size = m.image_size; memcpy(st.image_hash, m.image_hash, 32); @@ -416,6 +418,10 @@ bool ota_rescue_mota_nrf52(const uint8_t*, uint32_t, const SignerAllowlist&, con } void ota_reboot_to_apply() { esp_restart(); } // boots the slot armed by ota_apply_detools_mota; no return +void ota_reboot_to_bootloader_update() {} +bool ota_installed_bootloader_identity(OtaBootloaderIdentity& out) { + out = OtaBootloaderIdentity(); return false; +} #elif defined(NRF52_PLATFORM) // single-slot: verify + mark APPROVED + hand off to the bootloader @@ -473,8 +479,28 @@ void ota_reboot_to_apply() { // public: set the apply magic + NVIC_SystemReset(); // does not return } +void ota_reboot_to_bootloader_update() { + uint8_t sd_en = 0; + sd_softdevice_is_enabled(&sd_en); + if (sd_en) { + sd_power_gpregret_clr(1, 0xFFFFFFFF); + sd_power_gpregret_set(1, GPREGRET2_OTA_STAGE_QSPI); + sd_power_gpregret_clr(0, 0xFFFFFFFF); + sd_power_gpregret_set(0, GPREGRET_OTA_BOOTLOADER_UPDATE); + } else { + NRF_POWER->GPREGRET2 = GPREGRET2_OTA_STAGE_QSPI; + NRF_POWER->GPREGRET = GPREGRET_OTA_BOOTLOADER_UPDATE; + } + NVIC_SystemReset(); +} + uint8_t ota_bootloader_last_rc() { return g_bootloader_last_rc; } +bool ota_installed_bootloader_identity(OtaBootloaderIdentity& out) { + return ota_bootloader_identity_from_image( + (const uint8_t*)(uintptr_t)OTA_BOOT_IMAGE_START, OTA_BOOT_IMAGE_SIZE, out); +} + static bool ota_apply_mota_nrf52_impl(const uint8_t* buf, uint32_t len, const SignerAllowlist& allow, const uint8_t* rescue_base_hash, @@ -483,6 +509,7 @@ static bool ota_apply_mota_nrf52_impl(const uint8_t* buf, uint32_t len, st = ApplyState(); MotaManifest m; if (!mota_parse(buf, len, m)) { strcpy(msg, "parse failed"); return false; } + if (m.is_bootloader()) { strcpy(msg, "use explicit ota bootloader install"); return false; } if (m.is_full() || m.codec_id != CODEC_DETOOLS_INPLACE) { strcpy(msg, "not an in-place delta"); return false; } st.image_size = m.image_size; memcpy(st.image_hash, m.image_hash, 32); @@ -618,6 +645,10 @@ static bool ota_apply_mota_nrf52_external(Store& store, const SignerAllowlist& a snprintf(msg, NRF52_APPLY_MSG_CAP, "%s manifest parse failed", storage_name); return false; } + if (m.is_bootloader()) { + strcpy(msg, "use explicit ota bootloader install"); + return false; + } const bool full = m.is_full() && m.codec_id == CODEC_FULL && m.payload_size == m.image_size; const bool delta = !m.is_full() && m.codec_id == CODEC_DETOOLS_INPLACE; @@ -627,8 +658,9 @@ static bool ota_apply_mota_nrf52_external(Store& store, const SignerAllowlist& a return false; } const uint32_t app_base = mota_nrf52_app_base(); - if (m.image_size == 0 || app_base >= MOTA_NRF52_APP_END || - m.image_size > MOTA_NRF52_APP_END - app_base) { + const uint32_t app_ceiling = mota_nrf52_application_ceiling(); + if (m.image_size == 0 || app_base >= app_ceiling || + m.image_size > app_ceiling - app_base) { strcpy(msg, "image exceeds nRF52 application region"); return false; } @@ -687,7 +719,7 @@ static bool ota_apply_mota_nrf52_external(Store& store, const SignerAllowlist& a } if (!mota_nrf52_external_patch_geometry_valid( d.memory, d.segment, d.shift, d.from, d.to, - MOTA_NRF52_APP_END - app_base, fi.image_len, m.image_size)) { + app_ceiling - app_base, fi.image_len, m.image_size)) { strcpy(msg, "invalid in-place patch geometry"); return false; } @@ -717,6 +749,148 @@ bool ota_apply_mota_nrf52(OtaStoreQspiNrf52& store, const SignerAllowlist& allow ApplyState& st, char* msg) { return ota_apply_mota_nrf52_external(store, allow, OTA_BL_STORAGE_QSPI, "QSPI", st, msg); } + +#if defined(OTA_QSPI_BOOTLOADER_UPDATE) +static bool qspi_bootloader_crc_ok(OtaStoreQspiNrf52& store, uint32_t payload_off, + const OtaBootloaderIdentity& identity) { + uint8_t buf[512]; + uint32_t crc = UINT32_MAX; + for (uint32_t off = 0; off < OTA_BOOT_IMAGE_SIZE; off += sizeof(buf)) { + uint32_t len = OTA_BOOT_IMAGE_SIZE - off; + if (len > sizeof(buf)) len = sizeof(buf); + if (!store.read(payload_off + off, buf, len)) return false; + for (uint32_t i = 0; i < len; i++) { + const uint32_t pos = off + i; + const uint8_t value = pos >= identity.manifest_offset + 40u && + pos < identity.manifest_offset + 44u ? 0 : buf[i]; + crc = ota_boot_crc32_update(crc, value); + } + } + return ~crc == identity.crc32; +} + +static bool qspi_bootloader_image_metadata(OtaStoreQspiNrf52& store, uint32_t payload_off, + const OtaBootloaderIdentity& installed, + OtaBootloaderIdentity& candidate, + OtaBootloaderCapsMarker& caps) { + // Keep enough overlap to catch a 44-byte manifest or 16-byte capability marker split across a + // window. Every aligned manifest candidate is considered; a magic string in a literal pool is not + // a terminal failure. + static const uint32_t STEP = 512; + uint8_t buf[STEP + OTA_BOOT_MANIFEST_SIZE - 1]; + uint8_t valid_identities = 0; + for (uint32_t base = 0; base < OTA_BOOT_IMAGE_SIZE; base += STEP) { + uint32_t len = OTA_BOOT_IMAGE_SIZE - base; + if (len > sizeof(buf)) len = sizeof(buf); + if (!store.read(payload_off + base, buf, len)) return false; + const uint32_t starts = (OTA_BOOT_IMAGE_SIZE - base < STEP) + ? OTA_BOOT_IMAGE_SIZE - base : STEP; + for (uint32_t local = 0; local < starts; local++) { + const uint32_t absolute = base + local; + if (!caps.present && (absolute & 3u) == 0 && local + 16u <= len) { + OtaBootloaderCapsMarker parsed; + if (ota_bootloader_caps_marker_parse(buf + local, parsed) && + parsed.apply_abi >= MOTA_BOOT_FORMAT_VER && + (parsed.codec_mask & (1u << CODEC_FULL)) != 0 && + (parsed.storage_flags & (OTA_BL_STORAGE_QSPI | OTA_BL_STORAGE_BOOT_UPDATE)) == + (OTA_BL_STORAGE_QSPI | OTA_BL_STORAGE_BOOT_UPDATE)) { + caps = parsed; + } + } + if ((absolute & 3u) != 0 || local + OTA_BOOT_MANIFEST_SIZE > len) continue; + OtaBootloaderIdentity parsed; + if (!ota_bootloader_manifest_parse(buf + local, absolute, parsed) || + parsed.board_id != installed.board_id || + memcmp(parsed.device_name, installed.device_name, OTA_BOOT_DEVICE_NAME_SIZE) != 0) continue; + if (qspi_bootloader_crc_ok(store, payload_off, parsed)) { + parsed.crc_ok = true; + if (!ota_bootloader_identity_add_unique(parsed, candidate, valid_identities)) return false; + } + } + } + return valid_identities == 1u && candidate.present && candidate.crc_ok && caps.present; +} + +bool ota_prepare_bootloader_update_nrf52(OtaStoreQspiNrf52& store, + const SignerAllowlist& allow, + const OtaBootloaderIdentity& installed, + const uint8_t actual_mid[4], + const uint8_t operator_mid[4], + const uint8_t operator_hash8[8], + ApplyState& st, char* msg) { + static const size_t CAP = 96; + st = ApplyState(); + const uint32_t total = store.staged_size(); + uint8_t hdr[8], manifest[MOTA_MFL]; + if (total < 8u + MOTA_MFL + 5u || !store.read(0, hdr, sizeof(hdr)) || + memcmp(hdr, MOTA_MAGIC, sizeof(MOTA_MAGIC)) != 0 || rd_u32le(hdr + 4) != total || + !store.read(8, manifest, sizeof(manifest))) { + strcpy(msg, "QSPI bootloader container parse failed"); return false; + } + MotaManifest m; + if (!mota_parse_manifest(manifest, sizeof(manifest), m)) { + strcpy(msg, "not a valid v3 bootloader package"); return false; + } + switch (ota_bootloader_confirmation_gate(m, installed, actual_mid, operator_mid, + operator_hash8)) { + case OTA_BOOT_CONFIRM_OK: break; + case OTA_BOOT_CONFIRM_NOT_BOOT_PACKAGE: strcpy(msg, "not a bootloader package"); return false; + case OTA_BOOT_CONFIRM_GEOMETRY: strcpy(msg, "bootloader package geometry mismatch"); return false; + case OTA_BOOT_CONFIRM_LOCAL_IDENTITY: strcpy(msg, "installed bootloader identity is invalid"); return false; + case OTA_BOOT_CONFIRM_TARGET: strcpy(msg, "bootloader board ID mismatch"); return false; + case OTA_BOOT_CONFIRM_HW_ID: strcpy(msg, "bootloader signed hw_id mismatch"); return false; + case OTA_BOOT_CONFIRM_MID: strcpy(msg, "bootloader MID confirmation mismatch"); return false; + case OTA_BOOT_CONFIRM_IMAGE_HASH: strcpy(msg, "bootloader image-hash confirmation mismatch"); return false; + } + + const OtaBlCaps current_caps = ota_bootloader_caps(); + if (!current_caps.present || current_caps.apply_abi < MOTA_BOOT_FORMAT_VER || + (current_caps.codec_mask & (1u << CODEC_FULL)) == 0 || + (current_caps.storage_flags & (OTA_BL_STORAGE_QSPI | OTA_BL_STORAGE_BOOT_UPDATE)) != + (OTA_BL_STORAGE_QSPI | OTA_BL_STORAGE_BOOT_UPDATE)) { + strcpy(msg, "installed bootloader cannot safely self-update from QSPI"); return false; + } + + const uint64_t payload_off64 = 8u + MOTA_MFL + (uint64_t)m.block_count * 4u; + if (payload_off64 > UINT32_MAX || payload_off64 + OTA_BOOT_IMAGE_SIZE + 5u != total) { + strcpy(msg, "bootloader container layout mismatch"); return false; + } + const uint32_t payload_off = (uint32_t)payload_off64; + + VerifyResult vr = ota_verify(static_cast(store), allow); + st.manifest_ok = vr.parsed; + st.sig_ok = vr.sig_ok; + st.trusted = vr.trusted; + st.image_size = m.image_size; + memcpy(st.image_hash, m.image_hash, sizeof(st.image_hash)); + if (!vr.auto_appliable()) { + if (!vr.root_ok || !vr.payload_ok || !vr.image_ok) + strcpy(msg, "bootloader payload hash mismatch"); + else if (!vr.sig_ok) + strcpy(msg, "bootloader package signature invalid"); + else + strcpy(msg, "bootloader signer is not in the trusted allowlist"); + return false; + } + + uint8_t vectors[8]; + if (!store.read(payload_off, vectors, sizeof(vectors)) || !ota_bootloader_vector_sane(vectors)) { + strcpy(msg, "bootloader vector table is invalid"); return false; + } + OtaBootloaderIdentity candidate; + OtaBootloaderCapsMarker candidate_caps; + if (!qspi_bootloader_image_metadata(store, payload_off, installed, candidate, candidate_caps) || + !ota_bootloader_identity_matches(installed, candidate)) { + strcpy(msg, "candidate bootloader identity/capability/CRC mismatch"); return false; + } + st.slot_ok = true; + if (!store.approve_for_bootloader()) { + snprintf(msg, CAP, "QSPI bootloader handoff failed: %s", store.last_error()); return false; + } + strcpy(msg, "trusted bootloader verified and armed; rebooting after this reply"); + return true; +} +#endif #endif #else // native / other platforms @@ -732,6 +906,10 @@ bool ota_rescue_mota_nrf52(const uint8_t*, uint32_t, const SignerAllowlist&, con st = ApplyState(); strcpy(msg, "unsupported"); return false; } void ota_reboot_to_apply() {} +void ota_reboot_to_bootloader_update() {} +bool ota_installed_bootloader_identity(OtaBootloaderIdentity& out) { + out = OtaBootloaderIdentity(); return false; +} uint8_t ota_bootloader_last_rc() { return 0; } #endif diff --git a/src/helpers/ota/OtaApply.h b/src/helpers/ota/OtaApply.h index 1846bbfce..7b81cb035 100644 --- a/src/helpers/ota/OtaApply.h +++ b/src/helpers/ota/OtaApply.h @@ -4,6 +4,7 @@ #include #include #include "SignerAllowlist.h" +#include "OtaBootloaderUpdate.h" // P6 apply (full-image, ESP32 A/B). The new image is delivered into the inactive OTA slot; the device // then verifies that slot against the signed manifest's image_hash (+ Ed25519/allowlist), and commits @@ -90,6 +91,15 @@ bool ota_apply_mota_nrf52(OtaStoreSdNrf52& store, class OtaStoreQspiNrf52; bool ota_apply_mota_nrf52(OtaStoreQspiNrf52& store, const SignerAllowlist& allow, ApplyState& st, char* msg); +#if defined(OTA_QSPI_BOOTLOADER_UPDATE) +bool ota_prepare_bootloader_update_nrf52(OtaStoreQspiNrf52& store, + const SignerAllowlist& allow, + const OtaBootloaderIdentity& installed, + const uint8_t actual_mid[4], + const uint8_t operator_mid[4], + const uint8_t operator_hash8[8], + ApplyState& st, char* msg); +#endif #endif // Commit the (already approved/armed) update and reboot into it - does NOT return. Call this only after @@ -98,10 +108,17 @@ bool ota_apply_mota_nrf52(OtaStoreQspiNrf52& store, // the GPREGRET apply magic + reset (the bootloader does the in-place decode + verify). ESP32: reboot // into the slot already armed by ota_apply_detools_mota. void ota_reboot_to_apply(); +void ota_reboot_to_bootloader_update(); +bool ota_installed_bootloader_identity(OtaBootloaderIdentity& out); inline uint8_t ota_nrf52_boot_result_or_zero(uint8_t value) { return ((value >= 0x90u && value <= 0x9Fu) || - (value >= 0xB0u && value <= 0xBCu)) ? value : 0u; + (value >= 0xB0u && value <= 0xBCu) || + (value >= 0xC0u && value <= 0xCFu)) ? value : 0u; +} + +inline bool ota_nrf52_boot_update_result(uint8_t value) { + return value >= 0xC0u && value <= 0xCFu; } // DIAGNOSTIC (nRF52): the bootloader stashes its last in-place-apply bail/progress code in GPREGRET2 (see diff --git a/src/helpers/ota/OtaBlInfo.h b/src/helpers/ota/OtaBlInfo.h index 4a08dc963..e26e757ac 100644 --- a/src/helpers/ota/OtaBlInfo.h +++ b/src/helpers/ota/OtaBlInfo.h @@ -1,5 +1,6 @@ #pragma once +#include #include #include @@ -31,23 +32,63 @@ struct OtaBlCaps { static const uint8_t OTA_BL_STORAGE_SD = 0x01; static const uint8_t OTA_BL_STORAGE_STAGE_CEILING = 0x02; static const uint8_t OTA_BL_STORAGE_QSPI = 0x04; +static const uint8_t OTA_BL_STORAGE_BOOT_UPDATE = 0x08; + +// Prefer a continuity-capable marker over a numerically newer legacy-looking candidate. Bootloader +// images contain the marker magic in code/literal data as well as in the real structure, so the scan +// must not let a later plausible literal shadow the privileged marker merely because its following +// bytes decode as a larger ABI. +inline bool ota_bl_caps_prefer(const OtaBlCaps& current, uint16_t candidate_abi, + uint8_t candidate_storage, bool require_boot_update) { + if (!current.present) return true; + if (require_boot_update) { + const bool candidate_boot = (candidate_storage & OTA_BL_STORAGE_BOOT_UPDATE) != 0; + const bool current_boot = (current.storage_flags & OTA_BL_STORAGE_BOOT_UPDATE) != 0; + if (candidate_boot != current_boot) return candidate_boot; + } + return candidate_abi > current.apply_abi; +} + +// Scan a region whose offset zero is word-aligned. The marker structure contains +// uint16_t fields and OTAFIX emits it aligned(4); accepting a byte-shifted magic +// occurrence would let instruction/literal bytes masquerade as capabilities. +inline OtaBlCaps ota_bl_caps_scan_aligned(const uint8_t* bytes, size_t len, + bool require_boot_update) { + OtaBlCaps c; + if (!bytes) return c; + for (size_t off = 0; off + 16u <= len; off += 4u) { + const uint8_t* p = bytes + off; + if (p[0] != OTA_BL_MAGIC[0] || memcmp(p, OTA_BL_MAGIC, 8) != 0) continue; + const uint16_t abi = (uint16_t)(p[8] | ((uint16_t)p[9] << 8)); + const uint16_t codecs = (uint16_t)(p[10] | ((uint16_t)p[11] << 8)); + const uint8_t storage = p[12]; + if (abi == 0 || abi == 0xFFFFu || codecs == 0 || (storage & ~0x0Fu) != 0 || + p[13] != 0 || p[14] != 0 || p[15] != 0) continue; + if (ota_bl_caps_prefer(c, abi, storage, require_boot_update)) { + c.present = true; + c.apply_abi = abi; + c.codec_mask = codecs; + c.storage_flags = storage; + } + } + return c; +} // Scan the bootloader flash region for the marker. Returns {present=false} if not found / non-nRF52. inline OtaBlCaps ota_bootloader_caps() { - OtaBlCaps c; #if defined(NRF52_PLATFORM) const uint8_t* lo = (const uint8_t*)(uintptr_t)MOTA_NRF52_BL_START; const uint8_t* hi = (const uint8_t*)(uintptr_t)MOTA_NRF52_BL_END; - for (const uint8_t* p = lo; p + 16 <= hi; p++) { - if (p[0] != OTA_BL_MAGIC[0] || memcmp(p, OTA_BL_MAGIC, 8) != 0) continue; - c.present = true; - c.apply_abi = (uint16_t)(p[8] | ((uint16_t)p[9] << 8)); - c.codec_mask = (uint16_t)(p[10] | ((uint16_t)p[11] << 8)); - c.storage_flags = p[12]; - break; - } + return ota_bl_caps_scan_aligned(lo, (size_t)(hi - lo), +#if defined(OTA_QSPI_BOOTLOADER_UPDATE) + true +#else + false +#endif + ); +#else + return OtaBlCaps(); #endif - return c; } // True if this device's bootloader can apply a .mota of the given format_ver + codec_id. diff --git a/src/helpers/ota/OtaBootloaderUpdate.h b/src/helpers/ota/OtaBootloaderUpdate.h new file mode 100644 index 000000000..b016ed0bc --- /dev/null +++ b/src/helpers/ota/OtaBootloaderUpdate.h @@ -0,0 +1,242 @@ +#pragma once + +#include +#include +#include + +#include "MotaContainer.h" + +#if defined(OTA_QSPI_BOOTLOADER_UPDATE) + #if !defined(NRF52_PLATFORM) || !defined(OTA_QSPI_STORE) + #error "OTA_QSPI_BOOTLOADER_UPDATE requires nRF52 raw-QSPI staging" + #endif + #if !defined(XIAO_NRF52) && !defined(IKOKA_NRF52) + #error "OTA_QSPI_BOOTLOADER_UPDATE is restricted to XIAO nRF52840 module builds" + #endif + #if defined(QSPIFLASH) || defined(OTA_SD_STORE) + #error "bootloader-update QSPI cannot share a filesystem or SD staging configuration" + #endif +#endif + +namespace mesh { +namespace ota { + +// Mirror of OTAFIX src/usb/uf2/bootloader_image.h. This manifest is embedded in the complete, +// padded bootloader region and is covered both by its own CRC32 and the signed .mota image_hash. +static const uint32_t OTA_BOOT_MANIFEST_MAGIC0 = 0x464D4C42UL; // bytes "BLMF" +static const uint32_t OTA_BOOT_MANIFEST_MAGIC1 = 0x31435243UL; // bytes "CRC1" +static const uint16_t OTA_BOOT_MANIFEST_VERSION = 1; +static const uint16_t OTA_BOOT_MANIFEST_SIZE = 44; +static const uint8_t OTA_BOOT_DEVICE_NAME_SIZE = 16; +static const uint32_t OTA_BOOT_IMAGE_START = 0x000F4000UL; +static const uint32_t OTA_BOOT_IMAGE_SIZE = 0x0000A000UL; // F4000..FE000, padded to exactly 40 KiB +static const uint32_t OTA_BOOT_SCRATCH_START = 0x000E0000UL; +static const uint32_t OTA_BOOT_SCRATCH_END = 0x000EA000UL; +static const uint32_t OTA_NRF52840_RAM_START = 0x20000000UL; +static const uint32_t OTA_NRF52840_RAM_END = 0x20040000UL; + +// Only the two official XIAO nRF52840 OTAFIX identities are eligible. Runtime matching against the +// installed, CRC-valid manifest distinguishes the base and Sense modules even for carrier-board aliases. +static const uint32_t OTA_XIAO_BOARD_ID_BASE = 0x28860044UL; +static const uint32_t OTA_XIAO_BOARD_ID_SENSE = 0x28860045UL; +static const uint8_t OTA_XIAO_BOOT_DEVICE_NAME[OTA_BOOT_DEVICE_NAME_SIZE] = { + 'X','I','A','O','_','D','F','U', 0,0,0,0,0,0,0,0 +}; + +struct OtaBootloaderIdentity { + bool present = false; + bool crc_ok = false; + uint32_t manifest_offset = 0; + uint32_t image_start = 0; + uint32_t image_size = 0; + uint32_t board_id = 0; + char device_name[OTA_BOOT_DEVICE_NAME_SIZE + 1] = {0}; + uint32_t crc32 = 0; +}; + +// Accumulate a stream of already CRC-validated candidates without silently +// choosing between two privileged identities. Both the memory-mapped and QSPI +// scanners use this helper so their duplicate policy cannot drift. +inline bool ota_bootloader_identity_add_unique(const OtaBootloaderIdentity& candidate, + OtaBootloaderIdentity& selected, + uint8_t& valid_count) { + if (!candidate.present || !candidate.crc_ok) return true; + if (valid_count != 0u) { + selected = OtaBootloaderIdentity(); + return false; + } + selected = candidate; + valid_count = 1u; + return true; +} + +inline uint16_t ota_boot_rd16(const uint8_t* p) { + return (uint16_t)(p[0] | ((uint16_t)p[1] << 8)); +} + +inline uint32_t ota_boot_rd32(const uint8_t* p) { + return (uint32_t)p[0] | ((uint32_t)p[1] << 8) | ((uint32_t)p[2] << 16) | + ((uint32_t)p[3] << 24); +} + +inline bool ota_xiao_bootloader_board_id(uint32_t board_id) { + return board_id == OTA_XIAO_BOARD_ID_BASE || board_id == OTA_XIAO_BOARD_ID_SENSE; +} + +// Canonical signed .mota hw_id. It is intentionally distinct from application hw_id values and is +// derived solely from the exact embedded board ID. +inline bool ota_xiao_bootloader_hw_id(uint32_t board_id, uint8_t out[32]) { + if (!out || !ota_xiao_bootloader_board_id(board_id)) return false; + memset(out, 0, 32); + const char* value = board_id == OTA_XIAO_BOARD_ID_BASE + ? "XIAO_BL_28860044" : "XIAO_BL_28860045"; + memcpy(out, value, strlen(value)); + return true; +} + +inline uint32_t ota_boot_crc32_update(uint32_t crc, uint8_t value) { + crc ^= value; + for (uint8_t bit = 0; bit < 8; bit++) + crc = (crc >> 1) ^ (0xEDB88320UL & (uint32_t)-(int32_t)(crc & 1U)); + return crc; +} + +inline uint32_t ota_boot_image_crc32(const uint8_t* image, size_t image_size, + size_t crc_offset) { + if (!image || crc_offset > image_size || image_size - crc_offset < sizeof(uint32_t)) return 0; + uint32_t crc = UINT32_MAX; + for (size_t i = 0; i < image_size; i++) { + const uint8_t value = (i >= crc_offset && i < crc_offset + sizeof(uint32_t)) ? 0 : image[i]; + crc = ota_boot_crc32_update(crc, value); + } + return ~crc; +} + +// Parse one candidate header. The caller independently validates the complete-region CRC so a magic +// string in a literal pool cannot establish an identity. +inline bool ota_bootloader_manifest_parse(const uint8_t* raw, uint32_t raw_offset, + OtaBootloaderIdentity& out) { + if (!raw || ota_boot_rd32(raw) != OTA_BOOT_MANIFEST_MAGIC0 || + ota_boot_rd32(raw + 4) != OTA_BOOT_MANIFEST_MAGIC1 || + ota_boot_rd16(raw + 8) != OTA_BOOT_MANIFEST_VERSION || + ota_boot_rd16(raw + 10) != OTA_BOOT_MANIFEST_SIZE || + ota_boot_rd32(raw + 12) != OTA_BOOT_IMAGE_START || + ota_boot_rd32(raw + 16) != OTA_BOOT_IMAGE_SIZE || + !ota_xiao_bootloader_board_id(ota_boot_rd32(raw + 20)) || + memcmp(raw + 24, OTA_XIAO_BOOT_DEVICE_NAME, OTA_BOOT_DEVICE_NAME_SIZE) != 0) return false; + + out = OtaBootloaderIdentity(); + out.present = true; + out.manifest_offset = raw_offset; + out.image_start = ota_boot_rd32(raw + 12); + out.image_size = ota_boot_rd32(raw + 16); + out.board_id = ota_boot_rd32(raw + 20); + memcpy(out.device_name, raw + 24, OTA_BOOT_DEVICE_NAME_SIZE); + out.device_name[OTA_BOOT_DEVICE_NAME_SIZE] = 0; + out.crc32 = ota_boot_rd32(raw + 40); + return true; +} + +// Validate an installed memory-mapped bootloader and recover its exact identity. Scan on word +// boundaries to mirror OTAFIX's self-update validator. +inline bool ota_bootloader_identity_from_image(const uint8_t* image, size_t image_size, + OtaBootloaderIdentity& out) { + out = OtaBootloaderIdentity(); + if (!image || image_size != OTA_BOOT_IMAGE_SIZE) return false; + uint8_t valid_count = 0; + for (size_t offset = 0; offset + OTA_BOOT_MANIFEST_SIZE <= image_size; offset += 4) { + OtaBootloaderIdentity candidate; + if (!ota_bootloader_manifest_parse(image + offset, (uint32_t)offset, candidate)) continue; + candidate.crc_ok = ota_boot_image_crc32(image, image_size, offset + 40) == candidate.crc32; + if (!candidate.crc_ok) continue; + if (!ota_bootloader_identity_add_unique(candidate, out, valid_count)) return false; + } + return valid_count == 1u; +} + +inline bool ota_bootloader_identity_matches(const OtaBootloaderIdentity& a, + const OtaBootloaderIdentity& b) { + return a.present && a.crc_ok && b.present && b.crc_ok && + a.image_start == b.image_start && a.image_size == b.image_size && + a.board_id == b.board_id && + memcmp(a.device_name, b.device_name, OTA_BOOT_DEVICE_NAME_SIZE) == 0; +} + +// Cheap first-line rejection of a malformed raw bootloader region. OTAFIX repeats this check before +// touching internal flash. MSP may point one byte beyond RAM; the reset vector must be Thumb code in +// the exact bootloader image range. +inline bool ota_bootloader_vector_sane(const uint8_t vectors[8]) { + if (!vectors) return false; + const uint32_t sp = ota_boot_rd32(vectors); + const uint32_t reset = ota_boot_rd32(vectors + 4); + const uint32_t entry = reset & ~1UL; + return (sp & 7u) == 0 && sp >= OTA_NRF52840_RAM_START && sp <= OTA_NRF52840_RAM_END && + (reset & 1u) != 0 && entry >= OTA_BOOT_IMAGE_START && + entry < OTA_BOOT_IMAGE_START + OTA_BOOT_IMAGE_SIZE; +} + +struct OtaBootloaderCapsMarker { + bool present = false; + uint16_t apply_abi = 0; + uint16_t codec_mask = 0; + uint8_t storage_flags = 0; +}; + +inline bool ota_bootloader_caps_marker_parse(const uint8_t raw[16], + OtaBootloaderCapsMarker& out) { + static const uint8_t magic[8] = {'M','O','T','A','B','L','D','R'}; + if (!raw || memcmp(raw, magic, sizeof(magic)) != 0 || + raw[8] == 0 || (raw[8] == 0xFF && raw[9] == 0xFF) || + (raw[10] == 0 && raw[11] == 0) || (raw[12] & ~0x0Fu) != 0 || + raw[13] != 0 || raw[14] != 0 || raw[15] != 0) return false; + out.present = true; + out.apply_abi = ota_boot_rd16(raw + 8); + out.codec_mask = ota_boot_rd16(raw + 10); + out.storage_flags = raw[12]; + return true; +} + +enum OtaBootloaderConfirmGate : uint8_t { + OTA_BOOT_CONFIRM_OK = 0, + OTA_BOOT_CONFIRM_NOT_BOOT_PACKAGE, + OTA_BOOT_CONFIRM_GEOMETRY, + OTA_BOOT_CONFIRM_LOCAL_IDENTITY, + OTA_BOOT_CONFIRM_TARGET, + OTA_BOOT_CONFIRM_HW_ID, + OTA_BOOT_CONFIRM_MID, + OTA_BOOT_CONFIRM_IMAGE_HASH, +}; + +// Pure explicit-confirmation gate used by the device command and native tests. Cryptographic and +// candidate-image checks run immediately after this structural/typed gate. +inline OtaBootloaderConfirmGate ota_bootloader_confirmation_gate( + const MotaManifest& m, const OtaBootloaderIdentity& installed, + const uint8_t actual_mid[4], const uint8_t operator_mid[4], + const uint8_t operator_hash8[8]) { + if (m.format_ver != MOTA_BOOT_FORMAT_VER || + m.flags != (MFLAG_FULL | MFLAG_SIGNED | MFLAG_BOOTLOADER)) + return OTA_BOOT_CONFIRM_NOT_BOOT_PACKAGE; + static const uint8_t zero_base[8] = {0}; + if (m.codec_id != CODEC_FULL || m.image_size != OTA_BOOT_IMAGE_SIZE || + m.payload_size != OTA_BOOT_IMAGE_SIZE || m.block_size_log2 != 10 || + m.block_count != OTA_BOOT_IMAGE_SIZE / 1024u || !m.base_hash || + memcmp(m.base_hash, zero_base, sizeof(zero_base)) != 0) + return OTA_BOOT_CONFIRM_GEOMETRY; + if (!installed.present || !installed.crc_ok || + !ota_xiao_bootloader_board_id(installed.board_id)) + return OTA_BOOT_CONFIRM_LOCAL_IDENTITY; + if (m.target_id != installed.board_id) return OTA_BOOT_CONFIRM_TARGET; + uint8_t expected_hw[32]; + if (!ota_xiao_bootloader_hw_id(installed.board_id, expected_hw) || !m.hw_id || + memcmp(m.hw_id, expected_hw, sizeof(expected_hw)) != 0) + return OTA_BOOT_CONFIRM_HW_ID; + if (!actual_mid || !operator_mid || memcmp(actual_mid, operator_mid, 4) != 0 || + !m.merkle_root || memcmp(m.merkle_root, actual_mid, 4) != 0) + return OTA_BOOT_CONFIRM_MID; + if (!operator_hash8 || !m.image_hash || memcmp(m.image_hash, operator_hash8, 8) != 0) + return OTA_BOOT_CONFIRM_IMAGE_HASH; + return OTA_BOOT_CONFIRM_OK; +} + +} // namespace ota +} // namespace mesh diff --git a/src/helpers/ota/OtaCli.cpp b/src/helpers/ota/OtaCli.cpp index 1a18d4b26..80a82f13b 100644 --- a/src/helpers/ota/OtaCli.cpp +++ b/src/helpers/ota/OtaCli.cpp @@ -51,8 +51,30 @@ static char fstate_char(OtaManager::FetchState s) { } } -// For users, the only distinction that matters is full image vs. delta (which delta codec is internal). -static const char* codec_kind(uint8_t c) { return c == CODEC_FULL ? "full" : "delta"; } +// For users, the only distinction that matters is application full/delta vs the separately gated +// bootloader package. Which application delta codec is an implementation detail. +static const char* codec_kind(uint8_t flags, uint8_t c) { + return (flags & MFLAG_BOOTLOADER) ? "bootloader" : c == CODEC_FULL ? "full" : "delta"; +} + +static bool parse_hex_exact(const char* text, uint8_t* out, size_t bytes) { + if (!text || strlen(text) != bytes * 2u) return false; + for (size_t i = 0; i < bytes * 2u; i++) { + const char c = text[i]; + if (!((c >= '0' && c <= '9') || (c >= 'a' && c <= 'f') || (c >= 'A' && c <= 'F'))) + return false; + } + return mesh::Utils::fromHex(out, bytes, text); +} + +static bool staged_manifest(OtaContext& c, MotaManifest& m) { + uint8_t hdr[8], manifest[MOTA_MFL]; + const uint32_t total = c.fetch_store.staged_size(); + return total >= 8u + MOTA_MFL + 5u && c.fetch_store.read(0, hdr, sizeof(hdr)) && + memcmp(hdr, MOTA_MAGIC, sizeof(MOTA_MAGIC)) == 0 && rd_u32le(hdr + 4) == total && + c.fetch_store.read(8, manifest, sizeof(manifest)) && + mota_parse_manifest(manifest, sizeof(manifest), m); +} // A plain-language word for the fetch state (shown in `ota status`). static const char* state_word(OtaManager::FetchState s) { @@ -152,9 +174,15 @@ bool handle_ota_command(const char* command, char* reply, mesh::MainBoard& board "OTA: status | stats | ls | get flash [rescue] | install | rescue install | " "cancel | announce | self | folder | config | key"); #elif defined(NRF52_PLATFORM) && defined(OTA_QSPI_STORE) +#if defined(OTA_QSPI_BOOTLOADER_UPDATE) + strcpy(reply, + "OTA: status | stats | ls | get flash | install | bootloader | cancel | announce | self | " + "qspi | folder | config | key"); +#else strcpy(reply, "OTA: status | stats | ls | get flash | install | cancel | announce | self | qspi | " "folder | config | key"); +#endif #else snprintf(reply, 160, "OTA: status | stats | ls | get flash | install | cancel | announce | self | folder | " @@ -186,7 +214,8 @@ bool handle_ota_command(const char* command, char* reply, mesh::MainBoard& board snprintf(dl, sizeof dl, "download: failed (%s) %u/%u id=%s", fetch_error_word(c.manager.fetchError()), have, tot, midhx); else - snprintf(dl, sizeof dl, "download: %s %u/%u (%u%%) id=%s %us", + snprintf(dl, sizeof dl, "%sdownload: %s %u/%u (%u%%) id=%s %us", + c.manager.fetched_is_bootloader() ? "bootloader " : "", state_word(fs), have, tot, pct, midhx, age); } const char* hw = (c.hw_id[0]) ? c.hw_id : "?"; @@ -208,8 +237,13 @@ bool handle_ota_command(const char* command, char* reply, mesh::MainBoard& board #else const char* bl_state = bl.present ? "apply" : "NONE"; #endif - if (n < 146) n += snprintf(reply + n, 160 - n, " | bl:%s blrc:%02X", - bl_state, ota_bootloader_last_rc()); + const uint8_t last_rc = ota_bootloader_last_rc(); + if (n < 146) { + if (ota_nrf52_boot_update_result(last_rc)) + n += snprintf(reply + n, 160 - n, " | bl:%s blup:%02X", bl_state, last_rc); + else + n += snprintf(reply + n, 160 - n, " | bl:%s blrc:%02X", bl_state, last_rc); + } #endif #endif @@ -304,7 +338,20 @@ bool handle_ota_command(const char* command, char* reply, mesh::MainBoard& board char hwbuf[16]; const char* fit; const char* env = ota_target_env_name(h->target_id); - if (myt && h->target_id == myt) { + const bool boot_package = (h->flags & MFLAG_BOOTLOADER) != 0; + if (boot_package) { + bool installable = false; +#if defined(NRF52_PLATFORM) && defined(OTA_QSPI_STORE) && defined(OTA_QSPI_BOOTLOADER_UPDATE) + const OtaBootloaderIdentity& bid = c.bootloaderIdentity(); + installable = h->flags == (MFLAG_FULL | MFLAG_SIGNED | MFLAG_BOOTLOADER) && + h->codec == CODEC_FULL && bid.present && bid.crc_ok && + h->target_id == bid.board_id && list_bl.present && + list_bl.apply_abi >= MOTA_BOOT_FORMAT_VER && + (list_bl.storage_flags & (OTA_BL_STORAGE_QSPI | OTA_BL_STORAGE_BOOT_UPDATE)) == + (OTA_BL_STORAGE_QSPI | OTA_BL_STORAGE_BOOT_UPDATE); +#endif + fit = installable ? "yours" : "bootloader unsupported"; + } else if (myt && h->target_id == myt) { bool installable = c.manager.codecOk(h->codec); #if defined(NRF52_PLATFORM) installable = installable && list_bl.present && list_bl.apply_abi >= MOTA_FORMAT_VER @@ -326,7 +373,7 @@ bool handle_ota_command(const char* command, char* reply, mesh::MainBoard& board else { snprintf(hwbuf, sizeof hwbuf, "hw %08X", (unsigned)h->target_id); fit = hwbuf; } char midhx[9]; mesh::Utils::toHex(midhx, h->mid, 4); n += snprintf(reply + n, CAP - n, "\n %u) %s %s %s [%s] %un %us%s", - (unsigned)(i + 1), midhx, ver, codec_kind(h->codec), fit, + (unsigned)(i + 1), midhx, ver, codec_kind(h->flags, h->codec), fit, (unsigned)h->n_seeders, (unsigned)age, tag); shown++; } @@ -401,6 +448,8 @@ bool handle_ota_command(const char* command, char* reply, mesh::MainBoard& board } uint8_t selmid[4]; uint32_t seltgt = sel->target_id; uint8_t selcodec = sel->codec; + const uint8_t selflags = sel->flags; + const bool selboot = (selflags & MFLAG_BOOTLOADER) != 0; memcpy(selmid, sel->mid, 4); // sel may move when catalog traffic arrives #if defined(NRF52_PLATFORM) && defined(OTA_SD_STORE) @@ -418,10 +467,33 @@ bool handle_ota_command(const char* command, char* reply, mesh::MainBoard& board strcpy(reply, "ERR seeder-only build cannot stage or install firmware; use `folder`"); return true; #else - if (!c.manager.codecOk(selcodec)) { + if (selboot) { +#if defined(NRF52_PLATFORM) && defined(OTA_QSPI_STORE) && defined(OTA_QSPI_BOOTLOADER_UPDATE) + const OtaBootloaderIdentity& bid = c.bootloaderIdentity(); + const OtaBlCaps& bl = c.bootloaderCaps(); + if (selflags != (MFLAG_FULL | MFLAG_SIGNED | MFLAG_BOOTLOADER) || + selcodec != CODEC_FULL) { + strcpy(reply, "ERR malformed bootloader catalog row; capture it to folder for inspection"); + return true; + } + if (!bid.present || !bid.crc_ok || seltgt != bid.board_id) { + strcpy(reply, "ERR bootloader package does not match this installed XIAO bootloader"); + return true; + } + if (!bl.present || bl.apply_abi < MOTA_BOOT_FORMAT_VER || + (bl.storage_flags & (OTA_BL_STORAGE_QSPI | OTA_BL_STORAGE_BOOT_UPDATE)) != + (OTA_BL_STORAGE_QSPI | OTA_BL_STORAGE_BOOT_UPDATE)) { + strcpy(reply, "ERR installed bootloader lacks safe LoRa bootloader-update support"); + return true; + } +#else + strcpy(reply, "ERR this build cannot install bootloader packages; use folder capture or USB DFU"); + return true; +#endif + } else if (!c.manager.codecOk(selcodec)) { snprintf(reply, 160, "ERR %s codec %u cannot be installed by this build; use `ota pull %s folder` to capture it", - codec_kind(selcodec), (unsigned)selcodec, selstr); + codec_kind(selflags, selcodec), (unsigned)selcodec, selstr); return true; } #if defined(NRF52_PLATFORM) @@ -430,9 +502,10 @@ bool handle_ota_command(const char* command, char* reply, mesh::MainBoard& board strcpy(reply, "ERR bootloader has no mOTA apply support; update it over USB first"); return true; } - if (bl.apply_abi < MOTA_FORMAT_VER || selcodec >= 16 || !(bl.codec_mask & (1u << selcodec))) { + const uint8_t need_abi = selboot ? MOTA_BOOT_FORMAT_VER : MOTA_APP_FORMAT_VER; + if (bl.apply_abi < need_abi || selcodec >= 16 || !(bl.codec_mask & (1u << selcodec))) { snprintf(reply, 160, "ERR bootloader cannot apply mOTA ABI %u codec %u (has abi=%u codecs=0x%x)", - MOTA_FORMAT_VER, (unsigned)selcodec, bl.apply_abi, bl.codec_mask); + need_abi, (unsigned)selcodec, bl.apply_abi, bl.codec_mask); return true; } #if defined(OTA_SD_STORE) @@ -498,6 +571,7 @@ bool handle_ota_command(const char* command, char* reply, mesh::MainBoard& board // ---- discard the current session (e.g. a stalled old fetch) to free the slot ---- } else if (is_cmd(a, "drop|cancel|stop", &rest)) { + if (c.apply_pending) { strcpy(reply, "ERR update is armed; reboot is pending"); return true; } OtaManager::FetchState fs = c.manager.fetchState(); char midhx[9]; strcpy(midhx, "-"); if (fs != OtaManager::IDLE) mesh::Utils::toHex(midhx, c.manager.fetchManifestId(), 4); @@ -607,6 +681,54 @@ bool handle_ota_command(const char* command, char* reply, mesh::MainBoard& board strcpy(reply, "ERR rescue requires an internal-flash nRF52 LoRa OTA build"); #endif + } else if (is_cmd(a, "bootloader|blupdate", &rest)) { +#if defined(NRF52_PLATFORM) && defined(OTA_QSPI_STORE) && \ + defined(OTA_QSPI_BOOTLOADER_UPDATE) && !defined(OTA_SEEDER_ONLY) + const OtaBootloaderIdentity& bid = c.bootloaderIdentity(); + const OtaBlCaps& bl = c.bootloaderCaps(); + if (*rest == 0 || strcmp(rest, "status") == 0) { + if (!bid.present || !bid.crc_ok) { + strcpy(reply, "Bootloader update unavailable: installed embedded manifest/CRC is invalid"); + return true; + } + MotaManifest staged; + char midhx[9] = "-", hashhx[17] = "-"; + const bool ready = c.manager.fetchState() == OtaManager::COMPLETE && + staged_manifest(c, staged) && staged.is_bootloader(); + if (ready) { + mesh::Utils::toHex(midhx, c.manager.fetchManifestId(), 4); + mesh::Utils::toHex(hashhx, staged.image_hash, 8); + } + snprintf(reply, 160, "BL board=%08X name=%s crc=%08X abi=%u caps=%02X | staged:%s mid=%s hash=%s", + (unsigned)bid.board_id, bid.device_name, (unsigned)bid.crc32, + bl.apply_abi, bl.storage_flags, ready ? "ready" : "none", midhx, hashhx); + return true; + } + const char* confirm = nullptr; + if (!is_cmd(rest, "install", &confirm)) { + strcpy(reply, "ERR usage: ota bootloader install "); return true; + } + char midtxt[9] = {0}, hashtxt[17] = {0}, extra[2] = {0}; + if (sscanf(confirm, "%8s %16s %1s", midtxt, hashtxt, extra) != 2) { + strcpy(reply, "ERR usage: ota bootloader install "); return true; + } + uint8_t operator_mid[4], operator_hash8[8]; + if (!parse_hex_exact(midtxt, operator_mid, sizeof(operator_mid)) || + !parse_hex_exact(hashtxt, operator_hash8, sizeof(operator_hash8))) { + strcpy(reply, "ERR MID must be 8 hex and HASH must be 16 hex characters"); return true; + } + if (c.apply_pending) { strcpy(reply, "ERR another update is already armed"); return true; } + if (c.fetch_to_folder || c.manager.fetchState() != OtaManager::COMPLETE || + c.fetch_store.staged_size() == 0 || !c.manager.fetched_is_bootloader()) { + strcpy(reply, "ERR no complete bootloader package in local QSPI storage"); return true; + } + char m2[100]; + const bool ok = c.apply_fetched_bootloader(operator_mid, operator_hash8, m2); + snprintf(reply, 160, "%s | %s", ok ? "OK" : "ERR", m2); +#else + strcpy(reply, "ERR this build cannot update its bootloader over LoRa"); +#endif + } else if (is_cmd(a, "install|apply|applydelta", &rest)) { // Apply the fetched update. Destructive (reflashes + reboots) and GATED, not interactive (no "type // yes" round-trip - unreliable over LoRa): refuse unless the fetch is COMPLETE, then the apply path diff --git a/src/helpers/ota/OtaContext.h b/src/helpers/ota/OtaContext.h index 255ebc231..21707551e 100644 --- a/src/helpers/ota/OtaContext.h +++ b/src/helpers/ota/OtaContext.h @@ -187,7 +187,14 @@ struct OtaContext { if (total >= 13 && fetch_store.read(0, hdr, 8) && memcmp(hdr, MOTA_MAGIC, 4) == 0) { uint32_t mr = total - 8; if (mr > sizeof(mb)) mr = sizeof(mb); MotaManifest mm; - if (fetch_store.read(8, mb, mr) && mota_parse_manifest(mb, mr, mm) && !hwMatches(mm.hw_id)) { + if (!fetch_store.read(8, mb, mr) || !mota_parse_manifest(mb, mr, mm)) { + strncpy(msg, "refused: invalid staged manifest", 96); msg[95] = 0; return false; + } + if (mm.is_bootloader()) { + strncpy(msg, "refused: use ota bootloader install ", 96); + msg[95] = 0; return false; + } + if (!hwMatches(mm.hw_id)) { char want[33] = {0}; memcpy(want, mm.hw_id, 32); snprintf(msg, 96, "refused: .mota hw_id '%.32s' != this device '%s' (incompatible hardware)", want, hw_id); return false; @@ -209,16 +216,42 @@ struct OtaContext { #else ok = ota_apply_detools_mota(fetch_store.data(), fetch_store.staged_size(), allow, apply_st, msg); #endif - if (ok) apply_pending = true; + if (ok) { bootloader_apply_pending = false; apply_pending = true; } return ok; #endif } + bool apply_fetched_bootloader(const uint8_t operator_mid[4], + const uint8_t operator_hash8[8], char* msg) { +#if defined(NRF52_PLATFORM) && defined(OTA_QSPI_STORE) && \ + defined(OTA_QSPI_BOOTLOADER_UPDATE) && !defined(OTA_SEEDER_ONLY) + if (fetch_to_folder) { + strncpy(msg, "refused: bootloader package is not in local QSPI storage", 96); + msg[95] = 0; return false; + } + if (manager.fetchState() != OtaManager::COMPLETE || fetch_store.staged_size() == 0) { + strncpy(msg, "refused: no complete bootloader package fetched", 96); + msg[95] = 0; return false; + } + const OtaBootloaderIdentity& installed = bootloaderIdentity(); + bool ok = ota_prepare_bootloader_update_nrf52( + fetch_store, allow, installed, manager.fetchManifestId(), operator_mid, + operator_hash8, apply_st, msg); + if (ok) { bootloader_apply_pending = true; apply_pending = true; } + return ok; +#else + (void)operator_mid; (void)operator_hash8; + strncpy(msg, "refused: this build cannot update its bootloader over LoRa", 96); + msg[95] = 0; return false; +#endif + } + // Deferred apply-reboot: a verified `ota applydelta` approves the update but does NOT reboot inline, // so the CLI can first deliver the "verified; applying" reply (over LoRa it's the only way the // operator learns the apply started). The mesh loop then calls ota_reboot_to_apply() once that reply // has actually been transmitted. apply_at/apply_hard are mesh-clock deadlines the loop fills in. bool apply_pending = false; + bool bootloader_apply_pending = false; uint32_t apply_at = 0; // earliest reboot time (lets the reply get queued + start sending) uint32_t apply_hard = 0; // hard cap, in case the TX queue never idles on a busy node @@ -230,6 +263,15 @@ struct OtaContext { if (!_bl_caps_read) { _bl_caps = ota_bootloader_caps(); _bl_caps_read = true; } return _bl_caps; } + OtaBootloaderIdentity _bl_identity; + bool _bl_identity_read = false; + const OtaBootloaderIdentity& bootloaderIdentity() { + if (!_bl_identity_read) { + ota_installed_bootloader_identity(_bl_identity); + _bl_identity_read = true; + } + return _bl_identity; + } // --- discovery: the "what mOTAs are available around me" view ---------------------------------- // The catalog (heard mOTAs) + the heard-sources table now live in OtaManager (built from beacons + @@ -460,6 +502,11 @@ struct OtaContext { manager.set_accept_full(true); manager.set_apply_codec(CODEC_DETOOLS_SEQUENTIAL); // preferred (streams straight to the slot) manager.set_apply_codec2(CODEC_DETOOLS_INPLACE); // also accepted -> a single in-place .mota fits both +#endif +#if defined(NRF52_PLATFORM) && defined(OTA_QSPI_STORE) && defined(OTA_QSPI_BOOTLOADER_UPDATE) + manager.set_accept_bootloader(true); +#else + manager.set_accept_bootloader(false); #endif manager.set_fetch_store(&fetch_store); #if defined(NRF52_PLATFORM) && defined(OTA_SD_STORE) diff --git a/src/helpers/ota/OtaFlashLayout_nrf52.h b/src/helpers/ota/OtaFlashLayout_nrf52.h index c9d55ea7f..b4b3e5113 100644 --- a/src/helpers/ota/OtaFlashLayout_nrf52.h +++ b/src/helpers/ota/OtaFlashLayout_nrf52.h @@ -27,6 +27,11 @@ inline uint32_t mota_nrf52_app_base() { inline uint32_t mota_nrf52_app_base() { return MOTA_NRF52_APP_BASE_S140_V6; } #endif static const uint32_t MOTA_NRF52_EXTRAFS_START = 0x000D4000u; +// Bootloader-update-capable XIAO builds deliberately end the linked application here. The following +// 40 KiB is reserved as internal scratch while OTAFIX copies a verified QSPI image into its own +// 0xF4000..0xFE000 region. Ordinary nRF52 builds continue to use the 0xED000 application ceiling. +static const uint32_t MOTA_NRF52_BOOT_SCRATCH_START = 0x000E0000u; +static const uint32_t MOTA_NRF52_BOOT_SCRATCH_END = 0x000EA000u; static const uint32_t MOTA_NRF52_APP_END = 0x000ED000u; static const uint32_t MOTA_NRF52_STAGE_CEILING_LEGACY = MOTA_NRF52_EXTRAFS_START; static const uint32_t MOTA_NRF52_STAGE_CEILING_EXPANDED = MOTA_NRF52_APP_END; @@ -35,6 +40,7 @@ static const uint32_t MOTA_NRF52_STAGE_CEILING_EXPANDED = MOTA_NRF52_APP_END; static const uint32_t MOTA_NRF52_FS_START = MOTA_NRF52_EXTRAFS_START; static const uint32_t MOTA_NRF52_FLASH_PAGE = 4096u; static const uint8_t GPREGRET_OTA_APPLY = 0x6Au; // distinct from DFU magics 0x57/0x4E/0xA8 +static const uint8_t GPREGRET_OTA_BOOTLOADER_UPDATE = 0x6Bu; static const uint8_t GPREGRET2_OTA_STAGE_LEGACY = 0xD4u; static const uint8_t GPREGRET2_OTA_STAGE_EXPANDED = 0xEDu; static const uint8_t GPREGRET2_OTA_STAGE_QSPI = 0x51u; @@ -44,12 +50,20 @@ static const uint8_t GPREGRET2_OTA_STAGE_QSPI = 0x51u; // staged-container start; the app and bootloader both validate that per-patch value before writing. static const uint32_t MOTA_NRF52_FALLBACK_INPLACE_MEMORY = 0x00098000u; // 608 KB -// Bootloader flash region (nRF52840: 39 KB ending just below the CF2/MBR-params pages). The app scans +// Bootloader flash region (nRF52840: 40 KiB ending just below the CF2/MBR-params pages). The app scans // this for the bootloader capability marker (OtaBlInfo.h) to know whether THIS device's bootloader can // actually apply a .mota before staging+approving+rebooting. static const uint32_t MOTA_NRF52_BL_START = 0x000F4000u; static const uint32_t MOTA_NRF52_BL_END = 0x000FE000u; +inline uint32_t mota_nrf52_application_ceiling() { +#if defined(OTA_QSPI_BOOTLOADER_UPDATE) + return MOTA_NRF52_BOOT_SCRATCH_START; +#else + return MOTA_NRF52_APP_END; +#endif +} + // Compile-time layout-ordering invariants. If a constant above is edited inconsistently these fail the // BUILD rather than silently letting a stage/apply corrupt the filesystem (user prefs) or the app. static_assert((MOTA_NRF52_APP_BASE_S140_V6 % MOTA_NRF52_FLASH_PAGE) == 0, "S140 v6 base must be page-aligned"); @@ -60,6 +74,10 @@ static_assert((MOTA_NRF52_STAGE_CEILING_EXPANDED % MOTA_NRF52_FLASH_PAGE) == 0, "expanded staging ceiling must be page-aligned"); static_assert(MOTA_NRF52_STAGE_CEILING_LEGACY < MOTA_NRF52_STAGE_CEILING_EXPANDED, "legacy staging ceiling must precede expanded ceiling"); +static_assert(MOTA_NRF52_BOOT_SCRATCH_END - MOTA_NRF52_BOOT_SCRATCH_START == 0xA000u, + "bootloader scratch must be exactly one padded bootloader region"); +static_assert(MOTA_NRF52_BOOT_SCRATCH_END <= MOTA_NRF52_APP_END, + "bootloader scratch must remain below InternalFS"); static_assert(MOTA_NRF52_STAGE_CEILING_EXPANDED < MOTA_NRF52_BL_START, "staging and filesystems must end below the bootloader"); static_assert(MOTA_NRF52_BL_START < MOTA_NRF52_BL_END, "bootloader region must be non-empty"); @@ -74,7 +92,9 @@ static_assert(MOTA_NRF52_APP_BASE_S140_V7 + MOTA_NRF52_FALLBACK_INPLACE_MEMORY < inline uint32_t mota_nrf52_stage_ceiling_for_layout(uint32_t linked_app_end, bool uses_internal_extrafs) { if (uses_internal_extrafs) return MOTA_NRF52_STAGE_CEILING_LEGACY; - if (linked_app_end == MOTA_NRF52_APP_END || linked_app_end == MOTA_NRF52_EXTRAFS_START) + if (linked_app_end == MOTA_NRF52_APP_END || + linked_app_end == MOTA_NRF52_BOOT_SCRATCH_START || + linked_app_end == MOTA_NRF52_EXTRAFS_START) return MOTA_NRF52_STAGE_CEILING_EXPANDED; return MOTA_NRF52_STAGE_CEILING_LEGACY; } diff --git a/src/helpers/ota/OtaFormat.h b/src/helpers/ota/OtaFormat.h index d7391192f..d18de2766 100644 --- a/src/helpers/ota/OtaFormat.h +++ b/src/helpers/ota/OtaFormat.h @@ -20,7 +20,12 @@ static const uint8_t ENDF_MAGIC[4] = { 'E', 'n', 'd', 'F' }; // 45 6E 64 4 static const uint32_t ENDF_LEN = 56; // ---- manifest ------------------------------------------------------------- -static const uint8_t MOTA_FORMAT_VER = 2; // fixed-layout manifest (see offsets below) +// Application packages remain v2. Bootloader packages deliberately use v3 even though the byte +// layout is unchanged: every pre-bootloader-update parser rejects v3 before it can mistake the raw +// bootloader region for a full application image. +static const uint8_t MOTA_FORMAT_VER = 2; // application package (compatibility name) +static const uint8_t MOTA_APP_FORMAT_VER = 2; +static const uint8_t MOTA_BOOT_FORMAT_VER = 3; static const uint8_t HASH_ALGO_SHA256 = 0x12; // multihash code // Fixed manifest layout (manifest-minus-leaves) - every field is present at a constant offset, so the @@ -42,6 +47,8 @@ static const uint8_t MOTA_HW_ID_LEN = 32; static const uint8_t MFLAG_FULL = 0x01; // 0 = delta/partial, 1 = full image static const uint8_t MFLAG_SIGNED = 0x02; +static const uint8_t MFLAG_BOOTLOADER = 0x04; // v3 only; exact flags must be FULL|SIGNED|BOOTLOADER +static const uint8_t MFLAG_KNOWN = MFLAG_FULL | MFLAG_SIGNED | MFLAG_BOOTLOADER; static const uint8_t CODEC_FULL = 0; static const uint8_t CODEC_DETOOLS_SEQUENTIAL = 1; diff --git a/src/helpers/ota/OtaManager.cpp b/src/helpers/ota/OtaManager.cpp index 779ba1d9f..bfefea37e 100644 --- a/src/helpers/ota/OtaManager.cpp +++ b/src/helpers/ota/OtaManager.cpp @@ -921,6 +921,10 @@ bool OtaManager::wantRow(const uint8_t* mid, uint32_t target, uint8_t codec, uin || _fstate == WANT_LEAVES || _fstate == VERIFYING_STAGED || _fstate == PAUSED) return false; // busy if (_fstate == COMPLETE && memcmp(mid, _fid, 4) == 0) return false; // already have it + if (flags & ~MFLAG_KNOWN) return false; + const bool bootloader = (flags & MFLAG_BOOTLOADER) != 0; + if (!_archive_fetch && bootloader && + (!_accept_bootloader || !_have_desired_mid || codec != CODEC_FULL)) return false; if (!_archive_fetch && !codecOk(codec)) return false; // can't apply this codec if (_have_desired_mid) // manual pull of a specific mid return memcmp(mid, _desired_mid, 4) == 0 && (_desired_target == 0 || target == _desired_target); @@ -1169,10 +1173,16 @@ void OtaManager::handleManifest(const uint8_t* m, uint16_t n) { const uint8_t* mf = _mf_buf; // fully reassembled manifest-minus-leaves uint32_t mfl = _mf_len; if (mfl != MOTA_MFL) { failFetch(FETCH_ERROR_MANIFEST); return; } // fixed 197-byte manifest - if (mf[2] != HASH_ALGO_SHA256) { failFetch(FETCH_ERROR_HASH_ALGO); return; } - if (!_archive_fetch && !codecOk(mf[56])) { failFetch(FETCH_ERROR_CODEC); return; } - uint32_t payload_size = rd_u32le(mf + 15); - uint8_t bsl = mf[19]; + MotaManifest parsed; + if (!mota_parse_manifest(mf, mfl, parsed)) { failFetch(FETCH_ERROR_MANIFEST); return; } + if (parsed.hash_algo != HASH_ALGO_SHA256) { failFetch(FETCH_ERROR_HASH_ALGO); return; } + if (!_archive_fetch && parsed.is_bootloader() && + (!_accept_bootloader || !_have_desired_mid)) { + failFetch(FETCH_ERROR_CODEC); return; // bootloader packages are manual-pull only + } + if (!_archive_fetch && !codecOk(parsed.codec_id)) { failFetch(FETCH_ERROR_CODEC); return; } + uint32_t payload_size = parsed.payload_size; + uint8_t bsl = parsed.block_size_log2; if (bsl >= 32) { failFetch(FETCH_ERROR_GEOMETRY); return; } uint32_t bs = 1u << bsl; // a block must fit our reassembly buffer (and be non-empty) - reject an oversized block_size up front @@ -1193,8 +1203,8 @@ void OtaManager::handleManifest(const uint8_t* m, uint16_t n) { // Hand the store the parsed layout BEFORE begin(), so a partition-backed store (ESP32) can choose // placement and refuse an unfittable fetch up front: a FULL payload streams to the inactive slot, // a delta's whole container is staged together. (image_size at mf+11, is_full from flags at mf+1.) - bool is_full = (mf[1] & MFLAG_FULL) != 0; - if (!_fetch->plan_layout(is_full, rd_u32le(mf + 11), payload_off, payload_size)) { + bool is_full = parsed.is_full(); + if (!_fetch->plan_layout(is_full, parsed.image_size, payload_off, payload_size)) { failFetch(FETCH_ERROR_STORAGE); return; } if (!_fetch->begin(total)) { failFetch(FETCH_ERROR_STORAGE); return; } @@ -1316,6 +1326,7 @@ bool OtaManager::resumeStaged(const uint8_t* want_mid) { if (!_fetch->read(8, mbuf, mread) || !mota_parse_manifest(mbuf, mread, m)) return false; if (want_mid && memcmp(m.merkle_root, want_mid, 4) != 0) return false; // a different fw is staged if (m.hash_algo != HASH_ALGO_SHA256) return false; + if (!_archive_fetch && m.is_bootloader() && !_accept_bootloader) return false; if (!_archive_fetch && !codecOk(m.codec_id)) return false; uint32_t mfl = (uint32_t)(m.approval - m.manifest_start) + 4; // manifest-minus-leaves length uint32_t bs = m.block_size(); diff --git a/src/helpers/ota/OtaManager.h b/src/helpers/ota/OtaManager.h index 0ca7f367b..26f858d86 100644 --- a/src/helpers/ota/OtaManager.h +++ b/src/helpers/ota/OtaManager.h @@ -359,6 +359,8 @@ public: // in-place `.mota` can target both ESP32 and nRF52). 0xFF = unset. void set_apply_codec2(uint8_t c) { _apply_codec2 = c; } void set_accept_full(bool on) { _accept_full = on; } + void set_accept_bootloader(bool on) { _accept_bootloader = on; } + bool accepts_bootloader() const { return _accept_bootloader; } bool codecOk(uint8_t c) const { return (c == CODEC_FULL && _accept_full) || c == _apply_codec || c == _apply_codec2; } @@ -391,6 +393,7 @@ public: void set_max_hops(uint8_t h) { _max_hops = h; } uint8_t max_hops() const { return _max_hops; } bool fetched_is_signed() const { return (_fflags & MFLAG_SIGNED) != 0; } // flags of the fetched manifest + bool fetched_is_bootloader() const { return (_fflags & MFLAG_BOOTLOADER) != 0; } // This node's id (pubkey[0:4]), stamped into adverts we send so receivers can count distinct seeders. void set_seeder_id(const uint8_t* id4) { if (id4) for (int i = 0; i < 4; i++) _seeder_id[i] = id4[i]; } @@ -601,6 +604,7 @@ private: uint8_t _apply_codec = CODEC_DETOOLS_SEQUENTIAL; // platform's delta codec (OtaContext sets it) uint8_t _apply_codec2 = 0xFF; // optional 2nd accepted delta codec (ESP32: in-place) bool _accept_full = true; // false on nRF52 flash (single-slot cannot apply full) + bool _accept_bootloader = false; // explicit/manual only; never an auto-fetch signal bool _archive_fetch = false; // capture-only pull: accept any target/codec bool _archive_interest = false; // query full catalogs for the persistent archive uint8_t _seeder_id[4] = {0,0,0,0}; // our node id (pubkey[0:4]) for advert seeder counting diff --git a/src/helpers/ota/OtaStoreQspiNrf52.cpp b/src/helpers/ota/OtaStoreQspiNrf52.cpp index 6a8373491..c4491f3cb 100644 --- a/src/helpers/ota/OtaStoreQspiNrf52.cpp +++ b/src/helpers/ota/OtaStoreQspiNrf52.cpp @@ -684,8 +684,9 @@ bool OtaStoreQspiNrf52::plan_layout(bool, uint32_t image_size, uint32_t, uint32_ _io_ok = true; _stage = OtaQspiStage::IDLE; const uint32_t app_base = mota_nrf52_app_base(); - if (image_size == 0 || payload_size == 0 || app_base >= MOTA_NRF52_APP_END || - image_size > MOTA_NRF52_APP_END - app_base) { + const uint32_t app_ceiling = mota_nrf52_application_ceiling(); + if (image_size == 0 || payload_size == 0 || app_base >= app_ceiling || + image_size > app_ceiling - app_base) { fail("image exceeds nRF52 application region"); releaseFlash(); return false; diff --git a/test/README.md b/test/README.md index 8cc4dc39f..c538ae22c 100644 --- a/test/README.md +++ b/test/README.md @@ -70,7 +70,7 @@ does not reflect the GoogleTest count -- run the built binary directly | `test_user_gpio` | `src/helpers/UserGpio.cpp`, `UserGpioReplyTracker.h` | board-approved pins, get/set/reset, timed nonblocking transitions, duplicate suppression, rollover, and completion-reply routing | | `test_utf8_helpers` | `src/helpers/UTF8Helpers.h` | byte-limit truncation at complete code-point boundaries and rejection of malformed or truncated UTF-8 | | `test_wifi_ota_seeder_policy` | `src/helpers/WiFiOtaSeederPolicy.h`, `WiFiOtaSeederStatus.h` | listener state versus network availability, serial/TCP folder ownership, detach detection, and bounded status formatting | -| `test_ota` | `src/helpers/ota/` | container and EndF integrity; protocol codecs; transfer, resume, and apply safety; adaptive 2-to-4 block-request window growth and stall contraction; active-transfer priority classification | +| `test_ota` | `src/helpers/ota/` | v2 application/v3 bootloader parser separation; XIAO identity, vector, capability, explicit-confirmation and no-autofetch gates; container and EndF integrity; protocol codecs; transfer, resume, and layered apply safety; adaptive 2-to-4 block-request window growth and stall contraction; active-transfer priority classification | | `test_trace_retry` | `src/Mesh.cpp` retry and relay policy | opaque OTA relay behavior during TempRadio; background discovery priority; immediate primary transfer relay, receive-delay bypass, fast CAD retry, and no generic flood retry; trace and non-OTA flood retry timing | | `test_utils` | `src/Utils.cpp` | `Utils::toHex` (upstream) | diff --git a/test/test_ota/test_ota_core.cpp b/test/test_ota/test_ota_core.cpp index e9a9df956..bbadb9fa6 100644 --- a/test/test_ota/test_ota_core.cpp +++ b/test/test_ota/test_ota_core.cpp @@ -15,6 +15,7 @@ #include "helpers/ota/OtaProtocol.h" #include "helpers/ota/OtaManager.h" #include "helpers/ota/OtaApply.h" +#include "helpers/ota/OtaBlInfo.h" #include "mota_vectors.h" // auto-generated by tools/mota/gen_vectors.py extern "C" { @@ -29,6 +30,11 @@ TEST(OtaBootResult, AcceptsOnlyOtafixApplyDiagnostics) { EXPECT_EQ(ota_nrf52_boot_result_or_zero(0xB0), 0xB0); EXPECT_EQ(ota_nrf52_boot_result_or_zero(0xB8), 0xB8); EXPECT_EQ(ota_nrf52_boot_result_or_zero(0xBC), 0xBC); + EXPECT_EQ(ota_nrf52_boot_result_or_zero(0xC0), 0xC0); + EXPECT_EQ(ota_nrf52_boot_result_or_zero(0xC8), 0xC8); + EXPECT_EQ(ota_nrf52_boot_result_or_zero(0xCF), 0xCF); + EXPECT_TRUE(ota_nrf52_boot_update_result(0xC8)); + EXPECT_FALSE(ota_nrf52_boot_update_result(0xB8)); EXPECT_EQ(ota_nrf52_boot_result_or_zero(0x00), 0x00); EXPECT_EQ(ota_nrf52_boot_result_or_zero(0x4C), 0x00); // shutdown reason @@ -38,6 +44,154 @@ TEST(OtaBootResult, AcceptsOnlyOtafixApplyDiagnostics) { EXPECT_EQ(ota_nrf52_boot_result_or_zero(0xBD), 0x00); } +static std::vector boot_manifest_bytes() { + std::vector m(MOTA_MFL, 0); + m[0] = MOTA_BOOT_FORMAT_VER; + m[1] = MFLAG_FULL | MFLAG_SIGNED | MFLAG_BOOTLOADER; + m[2] = HASH_ALGO_SHA256; + wr_u32le(m.data() + 3, OTA_XIAO_BOARD_ID_BASE); + wr_u32le(m.data() + 7, 1); + wr_u32le(m.data() + 11, OTA_BOOT_IMAGE_SIZE); + wr_u32le(m.data() + 15, OTA_BOOT_IMAGE_SIZE); + m[19] = 10; + m[20] = 0x11; m[21] = 0x22; m[22] = 0x33; m[23] = 0x44; + for (uint8_t i = 0; i < 32; i++) m[24 + i] = (uint8_t)(0x80 + i); + m[56] = CODEC_FULL; + uint8_t hw[32]; EXPECT_TRUE(ota_xiao_bootloader_hw_id(OTA_XIAO_BOARD_ID_BASE, hw)); + memcpy(m.data() + 57, hw, sizeof(hw)); + memset(m.data() + MOTA_OFF_APPROVAL, 0xFF, 4); + return m; +} + +TEST(OtaBootPackage, ParserSeparatesV2ApplicationsFromStrictV3Bootloader) { + MotaManifest parsed; + auto m = boot_manifest_bytes(); + ASSERT_TRUE(mota_parse_manifest(m.data(), m.size(), parsed)); + EXPECT_TRUE(parsed.is_bootloader()); + EXPECT_EQ(parsed.block_count, 40u); + + m[0] = MOTA_APP_FORMAT_VER; + EXPECT_FALSE(mota_parse_manifest(m.data(), m.size(), parsed)); + m = boot_manifest_bytes(); m[1] &= ~MFLAG_BOOTLOADER; + EXPECT_FALSE(mota_parse_manifest(m.data(), m.size(), parsed)); + m = boot_manifest_bytes(); m[19] = 9; + EXPECT_FALSE(mota_parse_manifest(m.data(), m.size(), parsed)); + m = boot_manifest_bytes(); memset(m.data() + 7, 0, 4); + EXPECT_FALSE(mota_parse_manifest(m.data(), m.size(), parsed)); + m = boot_manifest_bytes(); m[MOTA_OFF_BASE_HASH] = 1; + EXPECT_FALSE(mota_parse_manifest(m.data(), m.size(), parsed)); +} + +TEST(OtaBootPackage, EmbeddedIdentityVectorCapsAndExplicitConfirmationGate) { + std::vector image(OTA_BOOT_IMAGE_SIZE, 0xFF); + wr_u32le(image.data(), OTA_NRF52840_RAM_END); + wr_u32le(image.data() + 4, OTA_BOOT_IMAGE_START + 0x101u); + ASSERT_TRUE(ota_bootloader_vector_sane(image.data())); + image[4] &= 0xFE; + EXPECT_FALSE(ota_bootloader_vector_sane(image.data())); + wr_u32le(image.data() + 4, OTA_BOOT_IMAGE_START + 0x101u); + + const uint32_t moff = 0x100; + memset(image.data() + moff, 0, OTA_BOOT_MANIFEST_SIZE); + wr_u32le(image.data() + moff, OTA_BOOT_MANIFEST_MAGIC0); + wr_u32le(image.data() + moff + 4, OTA_BOOT_MANIFEST_MAGIC1); + image[moff + 8] = OTA_BOOT_MANIFEST_VERSION; + image[moff + 10] = OTA_BOOT_MANIFEST_SIZE; + wr_u32le(image.data() + moff + 12, OTA_BOOT_IMAGE_START); + wr_u32le(image.data() + moff + 16, OTA_BOOT_IMAGE_SIZE); + wr_u32le(image.data() + moff + 20, OTA_XIAO_BOARD_ID_BASE); + memcpy(image.data() + moff + 24, OTA_XIAO_BOOT_DEVICE_NAME, + OTA_BOOT_DEVICE_NAME_SIZE); + wr_u32le(image.data() + moff + 40, + ota_boot_image_crc32(image.data(), image.size(), moff + 40)); + const std::vector canonical_image = image; + + OtaBootloaderIdentity identity; + ASSERT_TRUE(ota_bootloader_identity_from_image(image.data(), image.size(), identity)); + EXPECT_TRUE(identity.crc_ok); + EXPECT_EQ(identity.board_id, OTA_XIAO_BOARD_ID_BASE); + + // A structurally complete bad-CRC decoy before the real manifest must not + // shadow the later valid identity. + const uint32_t decoy = 0x20; + memcpy(image.data() + decoy, image.data() + moff, OTA_BOOT_MANIFEST_SIZE); + wr_u32le(image.data() + decoy + 40, 0xA5A5A5A5u); + wr_u32le(image.data() + moff + 40, 0); + wr_u32le(image.data() + moff + 40, + ota_boot_image_crc32(image.data(), image.size(), moff + 40)); + ASSERT_NE(ota_boot_image_crc32(image.data(), image.size(), decoy + 40), 0xA5A5A5A5u); + ASSERT_TRUE(ota_bootloader_identity_from_image(image.data(), image.size(), identity)); + EXPECT_EQ(identity.manifest_offset, moff); + + // These coupled CRC values make both otherwise identical manifests valid + // in this deterministic fixture. Two privileged identities are ambiguous + // and must fail closed in the helper shared with the staged-QSPI scanner. + image = canonical_image; + const uint32_t second = 0x200; + memcpy(image.data() + second, image.data() + moff, OTA_BOOT_MANIFEST_SIZE); + wr_u32le(image.data() + moff + 40, 0x07033138u); + wr_u32le(image.data() + second + 40, 0x0998508Cu); + ASSERT_EQ(ota_boot_image_crc32(image.data(), image.size(), moff + 40), 0x07033138u); + ASSERT_EQ(ota_boot_image_crc32(image.data(), image.size(), second + 40), 0x0998508Cu); + EXPECT_FALSE(ota_bootloader_identity_from_image(image.data(), image.size(), identity)); + + image = canonical_image; + image[0x300] ^= 1; + EXPECT_FALSE(ota_bootloader_identity_from_image(image.data(), image.size(), identity)); + + uint8_t marker[16] = {'M','O','T','A','B','L','D','R', 3,0, 1,0, + (uint8_t)(OTA_BL_STORAGE_QSPI | OTA_BL_STORAGE_BOOT_UPDATE), 0,0,0}; + OtaBootloaderCapsMarker caps; + EXPECT_TRUE(ota_bootloader_caps_marker_parse(marker, caps)); + marker[15] = 1; + EXPECT_FALSE(ota_bootloader_caps_marker_parse(marker, caps)); + + OtaBlCaps selected; + selected.present = true; + selected.apply_abi = 3; + selected.storage_flags = OTA_BL_STORAGE_QSPI | OTA_BL_STORAGE_BOOT_UPDATE; + EXPECT_FALSE(ota_bl_caps_prefer(selected, 99, OTA_BL_STORAGE_QSPI, true)); + EXPECT_TRUE(ota_bl_caps_prefer(selected, 4, + OTA_BL_STORAGE_QSPI | OTA_BL_STORAGE_BOOT_UPDATE, true)); + + auto mf = boot_manifest_bytes(); + MotaManifest m; + ASSERT_TRUE(mota_parse_manifest(mf.data(), mf.size(), m)); + OtaBootloaderIdentity installed; + installed.present = installed.crc_ok = true; + installed.image_start = OTA_BOOT_IMAGE_START; + installed.image_size = OTA_BOOT_IMAGE_SIZE; + installed.board_id = OTA_XIAO_BOARD_ID_BASE; + memcpy(installed.device_name, OTA_XIAO_BOOT_DEVICE_NAME, + OTA_BOOT_DEVICE_NAME_SIZE); + uint8_t mid[4] = {0x11,0x22,0x33,0x44}; + uint8_t hash8[8]; memcpy(hash8, m.image_hash, sizeof(hash8)); + EXPECT_EQ(ota_bootloader_confirmation_gate(m, installed, mid, mid, hash8), OTA_BOOT_CONFIRM_OK); + uint8_t bad_mid[4] = {0}; + EXPECT_EQ(ota_bootloader_confirmation_gate(m, installed, mid, bad_mid, hash8), + OTA_BOOT_CONFIRM_MID); + hash8[0] ^= 1; + EXPECT_EQ(ota_bootloader_confirmation_gate(m, installed, mid, mid, hash8), + OTA_BOOT_CONFIRM_IMAGE_HASH); +} + +TEST(OtaBootPackage, CapabilityScannerRejectsAnOtherwiseValidUnalignedMarker) { + const uint8_t marker[16] = {'M','O','T','A','B','L','D','R', 3,0, 1,0, + (uint8_t)(OTA_BL_STORAGE_QSPI | OTA_BL_STORAGE_BOOT_UPDATE), 0,0,0}; + uint8_t image[64]; + memset(image, 0xFF, sizeof(image)); + memcpy(image + 1, marker, sizeof(marker)); + EXPECT_FALSE(ota_bl_caps_scan_aligned(image, sizeof(image), true).present); + + memset(image, 0xFF, sizeof(image)); + memcpy(image + 4, marker, sizeof(marker)); + const OtaBlCaps caps = ota_bl_caps_scan_aligned(image, sizeof(image), true); + ASSERT_TRUE(caps.present); + EXPECT_EQ(caps.apply_abi, 3u); + EXPECT_EQ(caps.storage_flags, + (uint8_t)(OTA_BL_STORAGE_QSPI | OTA_BL_STORAGE_BOOT_UPDATE)); +} + class FakeMotaSeederStream : public Stream { public: using Stream::write; @@ -1812,6 +1966,53 @@ static uint16_t make_have1(uint8_t* buf, uint16_t cap, const uint8_t mid[4], return encode_have(buf, cap, hv); } +TEST(OtaTransfer, BootloaderPackageRequiresCapableExplicitMidPull) { + const auto manifest = boot_manifest_bytes(); + const uint8_t mid[4] = {0x11, 0x22, 0x33, 0x44}; + const uint8_t boot_flags = MFLAG_FULL | MFLAG_SIGNED | MFLAG_BOOTLOADER; + uint8_t wire[64]; + + // Even a capable XIAO must never autofetch a bootloader package. The target is intentionally + // its exact installed-bootloader board ID, so this proves the manual-MID gate rather than a + // coincidental target mismatch. + OtaManager capable; + OtaStoreRam<45000> capable_store; + capable.begin(OTA_XIAO_BOARD_ID_BASE, nullptr, nullptr); + capable.set_fetch_store(&capable_store); + capable.set_accept_full(true); + capable.set_accept_bootloader(true); + capable.set_autofetch(OtaManager::AUTOFETCH_ANY); + capable.on_message( + wire, make_have1(wire, sizeof(wire), mid, OTA_XIAO_BOARD_ID_BASE, 1, + CODEC_FULL, boot_flags)); + EXPECT_EQ(capable.fetchState(), OtaManager::IDLE); + EXPECT_EQ(capable_store.staged_size(), 0u); + + // An exact explicit pull admits the strict v3 manifest and opens the store for the 40 KiB payload. + EXPECT_EQ(capable.pull(mid, OTA_XIAO_BOARD_ID_BASE), OtaManager::PULL_STARTED); + deliver_manifest_fragment(capable, mid, 0, manifest.data(), OTA_MF_FRAG); + deliver_manifest_fragment(capable, mid, 1, manifest.data() + OTA_MF_FRAG, + (uint16_t)(MOTA_MFL - OTA_MF_FRAG)); + EXPECT_EQ(capable.fetchState(), OtaManager::FETCHING); + EXPECT_TRUE(capable.fetched_is_bootloader()); + EXPECT_GT(capable_store.staged_size(), OTA_BOOT_IMAGE_SIZE); + + // The same explicit pull is rejected before storage begin when this firmware lacks the privileged + // capability. This is the manager-layer guard; every apply backend has an independent rejection too. + OtaManager incapable; + OtaStoreRam<45000> incapable_store; + incapable.begin(OTA_XIAO_BOARD_ID_BASE, nullptr, nullptr); + incapable.set_fetch_store(&incapable_store); + incapable.set_accept_full(true); + EXPECT_EQ(incapable.pull(mid, OTA_XIAO_BOARD_ID_BASE), OtaManager::PULL_STARTED); + deliver_manifest_fragment(incapable, mid, 0, manifest.data(), OTA_MF_FRAG); + deliver_manifest_fragment(incapable, mid, 1, manifest.data() + OTA_MF_FRAG, + (uint16_t)(MOTA_MFL - OTA_MF_FRAG)); + EXPECT_EQ(incapable.fetchState(), OtaManager::FAILED); + EXPECT_EQ(incapable.fetchError(), OtaManager::FETCH_ERROR_CODEC); + EXPECT_EQ(incapable_store.staged_size(), 0u); +} + // A node must not fetch firmware it can't apply: a catalog row whose codec the platform can't decode is // not fetched. Full-image acceptance is platform-selectable (nRF52 single-slot disables it). TEST(OtaTransfer, RejectsIncompatibleCodec) { diff --git a/test/test_ota/test_ota_flashplan.cpp b/test/test_ota/test_ota_flashplan.cpp index 61f5c7cd0..747bef874 100644 --- a/test/test_ota/test_ota_flashplan.cpp +++ b/test/test_ota/test_ota_flashplan.cpp @@ -69,8 +69,10 @@ TEST(OtaFlashPlan, SelectsCeilingFromLinkedLayoutAndStorage) { // Standard or ExtraFS linker without an internal secondary filesystem reclaims the unused 100 KiB. EXPECT_EQ(mota_nrf52_stage_ceiling_for_layout(EXPANDED, false), EXPANDED); EXPECT_EQ(mota_nrf52_stage_ceiling_for_layout(LEGACY, false), EXPANDED); + // The explicit XIAO boot-update linker leaves 40 KiB scratch at 0xE0000 and still stages externally. + EXPECT_EQ(mota_nrf52_stage_ceiling_for_layout(MOTA_NRF52_BOOT_SCRATCH_START, false), EXPANDED); // An unrecognized linker region is never permission to erase a larger window. - EXPECT_EQ(mota_nrf52_stage_ceiling_for_layout(0xE0000u, false), LEGACY); + EXPECT_EQ(mota_nrf52_stage_ceiling_for_layout(0xE1000u, false), LEGACY); } TEST(OtaFlashPlan, ValidatesExternalInplacePatchGeometryBeforeHandoff) { diff --git a/tools/lora_ota/lora_ota.py b/tools/lora_ota/lora_ota.py index cac2fc9fa..4a971ffbc 100755 --- a/tools/lora_ota/lora_ota.py +++ b/tools/lora_ota/lora_ota.py @@ -43,8 +43,12 @@ import zipfile MOTA_MAGIC = b"mOTA" MOTA_TRAILER = b"vk496" MOTA_FORMAT_VERSION = 2 +MOTA_BOOT_FORMAT_VERSION = 3 MOTA_FIXED_MANIFEST_SIZE = 197 MOTA_FLAG_FULL = 0x01 +MOTA_FLAG_SIGNED = 0x02 +MOTA_FLAG_BOOTLOADER = 0x04 +MOTA_KNOWN_FLAGS = MOTA_FLAG_FULL | MOTA_FLAG_SIGNED | MOTA_FLAG_BOOTLOADER MOTA_CODEC_FULL = 0 MOTA_CODEC_SEQUENTIAL = 1 MOTA_CODEC_IN_PLACE = 2 @@ -462,12 +466,19 @@ def parse_mota(blob: bytes, path: Path | None = None) -> MotaInfo: raise OtaError( f"mOTA size field is {declared_size}, but the file is {len(blob)} bytes" ) + flags = blob[9] + if blob[8] == MOTA_BOOT_FORMAT_VERSION and flags & MOTA_FLAG_BOOTLOADER: + raise OtaError( + "bootloader mOTA packages require the device's explicit `ota bootloader install` " + "workflow; this application-update runner deliberately refuses them" + ) if blob[8] != MOTA_FORMAT_VERSION: raise OtaError(f"unsupported mOTA format version {blob[8]}") + if flags & MOTA_FLAG_BOOTLOADER or flags & ~MOTA_KNOWN_FLAGS: + raise OtaError("invalid flags in v2 application mOTA") if blob[10] != 0x12: raise OtaError(f"unsupported mOTA hash algorithm 0x{blob[10]:02x}") - flags = blob[9] target_id, fw_version, image_size, payload_size = struct.unpack_from(" 10: diff --git a/tools/lora_ota/test_lora_ota.py b/tools/lora_ota/test_lora_ota.py index 2190e5fae..86e23ff2a 100644 --- a/tools/lora_ota/test_lora_ota.py +++ b/tools/lora_ota/test_lora_ota.py @@ -137,6 +137,17 @@ class FormatTests(unittest.TestCase): with self.assertRaisesRegex(ota.OtaError, "block hashes"): ota.parse_mota(bytes(blob)) + def test_bootloader_packages_are_explicitly_refused(self) -> None: + blob = bytearray(mota_blob(firmware(b"B" * 5000, VERSION_NEW))) + blob[8] = ota.MOTA_BOOT_FORMAT_VERSION + blob[9] |= ota.MOTA_FLAG_SIGNED | ota.MOTA_FLAG_BOOTLOADER + with self.assertRaisesRegex(ota.OtaError, "bootloader mOTA packages"): + ota.parse_mota(bytes(blob)) + + blob[8] = ota.MOTA_FORMAT_VERSION + with self.assertRaisesRegex(ota.OtaError, "invalid flags in v2"): + ota.parse_mota(bytes(blob)) + def test_block_larger_than_firmware_buffer_is_rejected(self) -> None: image = firmware(b"C" * 5000, VERSION_NEW) with self.assertRaisesRegex(ota.OtaError, "at most 1024 bytes"): diff --git a/tools/mota/README.md b/tools/mota/README.md index ac51aecca..7067b24d1 100644 --- a/tools/mota/README.md +++ b/tools/mota/README.md @@ -31,8 +31,8 @@ Uses the repo's Python venv (`meshcore/`). Dependencies: `detools` (delta), `cry ## Tests ```bash -./meshcore/bin/python tools/mota/test_mota.py # EndF, merkle+proofs, full/delta, signing, - # tamper detection, approval enforcement +./meshcore/bin/python tools/mota/test_mota.py # EndF, merkle+proofs, v2 apps/v3 XIAO bootloader, + # signing, tamper detection, approval enforcement ./meshcore/bin/python tools/mota/gen_vectors.py # regenerate the native-test cross-check vectors ./meshcore/bin/python tools/mota/gen_targets.py # regenerate src/helpers/ota/OtaTargets.h (needs `pio`) ``` @@ -51,3 +51,20 @@ firmware and matches a delta's `base_hash` against its own `EndF`. Wiring (handl `target_id` = `sha2-256:4(pio_env_name)`, `hw_id` = explicit `-D MOTA_HW_ID` or a role-stripped hardware family derived from the environment name, and `fw_version` = parsed from `FIRMWARE_VERSION`. A node (and `motatool`, reading the firmware's `EndF`) therefore auto-discovers identity without relying on filenames. + +## Privileged XIAO bootloader reference builder + +Ordinary calls to `build_manifest()` continue to emit v2 application packages. +Passing `bootloader=True` is a deliberately narrow reference-only path: it +requires a nonzero version, a signed exact 40 KiB XIAO OTAFIX region, 1024-byte +blocks, full codec, zero base hash, a sane vector table, exact `XIAO_DFU` +embedded manifest/CRC/board identity, and an ABI-3 marker retaining QSPI plus +boot-update capabilities. It derives the signed `XIAO_BL_28860044` or +`XIAO_BL_28860045` hardware ID from the embedded board ID. Parsing and +`verify()` repeat those gates; magic literals that are not complete valid +structures are skipped. + +This library does not authorize a device update. A capable node will only arm +such a v3 package through the exact manual confirmation described in +[`docs/ota_nrf52_qspi.md`](../../docs/ota_nrf52_qspi.md#explicit-xiao-bootloader-updates-over-lora). +The ordinary `tools/lora_ota` runner rejects bootloader packages. diff --git a/tools/mota/motalib.py b/tools/mota/motalib.py index b6c626f3d..efcc6e990 100644 --- a/tools/mota/motalib.py +++ b/tools/mota/motalib.py @@ -1,7 +1,7 @@ """ motalib - build/parse/verify MeshCore ``.mota`` firmware-update containers. -Pure logic, no CLI. Implements docs/ota_protocol.md (format_ver=2, fixed layout). +Pure logic, no CLI. Implements docs/ota_protocol.md (v2 application / v3 bootloader, fixed layout). The wire format (all integers little-endian): @@ -37,11 +37,15 @@ ENDF_MAGIC = b"EndF" # 45 6E 64 46 # Fixed-length trailer: marker(4) + body_len(4) + body_hash8(8) + fw_version(4) + target_id(4) + hw_id(32). ENDF_LEN = 56 -FORMAT_VER = 2 # the one manifest format (fixed layout, see Manifest); other values are rejected +FORMAT_VER = 2 # application compatibility name +APP_FORMAT_VER = 2 +BOOT_FORMAT_VER = 3 HASH_ALGO_SHA256 = 0x12 # multihash code for sha2-256 FLAG_FULL = 0x01 FLAG_SIGNED = 0x02 +FLAG_BOOTLOADER = 0x04 +FLAG_KNOWN = FLAG_FULL | FLAG_SIGNED | FLAG_BOOTLOADER CODEC_FULL = 0 CODEC_DETOOLS_SEQUENTIAL = 1 # detools `sequential` patch (decoded on-device by vendored detools C) @@ -61,6 +65,8 @@ NRF52_INPLACE_MEMORY = 0x00098000 NRF52_APP_BASE_S140_V6 = 0x00026000 NRF52_APP_BASE_S140_V7 = 0x00027000 NRF52_EXTRAFS_START = 0x000D4000 +NRF52_BOOT_SCRATCH_START = 0x000E0000 +NRF52_BOOT_SCRATCH_END = 0x000EA000 NRF52_APP_END = 0x000ED000 NRF52_FLASH_PAGE = 4096 @@ -73,6 +79,19 @@ NRF52_LAYOUT_LEN = 24 NRF52_LAYOUT_FLAG_SD = 0x01 NRF52_LAYOUT_FLAG_INTERNAL_EXTRAFS = 0x02 NRF52_LAYOUT_FLAG_QSPI = 0x04 +NRF52_LAYOUT_FLAG_BOOTLOADER_SCRATCH = 0x08 + +XIAO_BOOT_BOARD_ID_BASE = 0x28860044 +XIAO_BOOT_BOARD_ID_SENSE = 0x28860045 +XIAO_BOOT_IMAGE_START = 0x000F4000 +XIAO_BOOT_IMAGE_SIZE = 0x0000A000 +XIAO_BOOT_MANIFEST_MAGIC = b"BLMFCRC1" +XIAO_BOOT_MANIFEST_VERSION = 1 +XIAO_BOOT_MANIFEST_SIZE = 44 +XIAO_BOOT_DEVICE_NAME = b"XIAO_DFU".ljust(16, b"\0") +XIAO_BOOT_CAPS_MAGIC = b"MOTABLDR" +XIAO_BOOT_STORAGE_QSPI = 0x04 +XIAO_BOOT_STORAGE_UPDATE = 0x08 # MeshTower V2's SD-backed OTA target keeps the staged .mota off-chip, so the application may use the # complete S140 v6 application region up to InternalFS instead of leaving room for internal staging. @@ -199,12 +218,16 @@ class Nrf52Layout: def external_backed(self) -> bool: return self.sd_backed or self.qspi_backed + @property + def bootloader_scratch(self) -> bool: + return bool(self.flags & NRF52_LAYOUT_FLAG_BOOTLOADER_SCRATCH) + def nrf52_stage_ceiling_for_layout(linked_app_end: int, uses_internal_extrafs: bool) -> int: """Select a safe staging ceiling from linker geometry and actual secondary-storage type.""" if uses_internal_extrafs: return NRF52_EXTRAFS_START - if linked_app_end in (NRF52_EXTRAFS_START, NRF52_APP_END): + if linked_app_end in (NRF52_EXTRAFS_START, NRF52_BOOT_SCRATCH_START, NRF52_APP_END): return NRF52_APP_END return NRF52_EXTRAFS_START @@ -212,20 +235,25 @@ def nrf52_stage_ceiling_for_layout(linked_app_end: int, uses_internal_extrafs: b def build_nrf52_layout(layout: Nrf52Layout) -> bytes: if layout.app_base not in (NRF52_APP_BASE_S140_V6, NRF52_APP_BASE_S140_V7): raise ValueError(f"unsupported nRF52 app base 0x{layout.app_base:X}") - if layout.linked_app_end not in (NRF52_EXTRAFS_START, NRF52_APP_END): + if layout.linked_app_end not in (NRF52_EXTRAFS_START, NRF52_BOOT_SCRATCH_START, NRF52_APP_END): raise ValueError(f"unsupported nRF52 linked app end 0x{layout.linked_app_end:X}") if layout.stage_ceiling not in (NRF52_EXTRAFS_START, NRF52_APP_END): raise ValueError(f"unsupported nRF52 staging ceiling 0x{layout.stage_ceiling:X}") if not (layout.app_base < layout.linked_app_end <= NRF52_APP_END): raise ValueError("invalid nRF52 app region") known_flags = (NRF52_LAYOUT_FLAG_SD | NRF52_LAYOUT_FLAG_INTERNAL_EXTRAFS | - NRF52_LAYOUT_FLAG_QSPI) + NRF52_LAYOUT_FLAG_QSPI | NRF52_LAYOUT_FLAG_BOOTLOADER_SCRATCH) if layout.flags & ~known_flags: raise ValueError(f"unsupported nRF52 layout flags 0x{layout.flags:X}") if layout.sd_backed and layout.qspi_backed: raise ValueError("nRF52 layout cannot use both SD and QSPI staging") if layout.external_backed and layout.flags & NRF52_LAYOUT_FLAG_INTERNAL_EXTRAFS: raise ValueError("nRF52 external staging cannot also reserve internal ExtraFS") + if layout.bootloader_scratch: + if not layout.qspi_backed or layout.sd_backed or layout.linked_app_end != NRF52_BOOT_SCRATCH_START: + raise ValueError("nRF52 bootloader scratch requires QSPI and linked app end 0xE0000") + elif layout.linked_app_end == NRF52_BOOT_SCRATCH_START: + raise ValueError("nRF52 linked app end 0xE0000 requires the bootloader-scratch flag") expected_ceiling = (NRF52_APP_END if layout.external_backed else nrf52_stage_ceiling_for_layout( layout.linked_app_end, @@ -417,6 +445,10 @@ class Manifest: def is_signed(self) -> bool: return bool(self.flags & FLAG_SIGNED) + @property + def is_bootloader(self) -> bool: + return bool(self.flags & FLAG_BOOTLOADER) + @property def block_size(self) -> int: return 1 << self.block_size_log2 @@ -460,6 +492,98 @@ def hw_id_bytes(s) -> bytes: return raw + b"\0" * (32 - len(raw)) +def xiao_bootloader_hw_id(board_id: int) -> str: + if board_id == XIAO_BOOT_BOARD_ID_BASE: + return "XIAO_BL_28860044" + if board_id == XIAO_BOOT_BOARD_ID_SENSE: + return "XIAO_BL_28860045" + raise ValueError(f"unsupported XIAO bootloader board ID 0x{board_id:08X}") + + +@dataclass(frozen=True) +class XiaoBootloaderIdentity: + manifest_offset: int + image_start: int + image_size: int + board_id: int + device_name: str + crc32: int + + +def _xiao_bootloader_vector_sane(image: bytes) -> bool: + if len(image) < 8: + return False + sp, reset = struct.unpack_from(" Optional[XiaoBootloaderIdentity]: + """Find exactly one CRC-valid embedded v1 manifest, continuing past invalid decoys.""" + if len(image) != XIAO_BOOT_IMAGE_SIZE: + return None + found = None + for off in range(0, len(image) - XIAO_BOOT_MANIFEST_SIZE + 1, 4): + if image[off:off + 8] != XIAO_BOOT_MANIFEST_MAGIC: + continue + version, size = struct.unpack_from(" bool: + """Find a real capability marker; a bare MOTABLDR literal is skipped.""" + # The structure is emitted with uint16_t fields, so only aligned candidates + # are valid. This also avoids accepting an incidental magic string embedded + # in instruction/literal data as the privileged continuity marker. + for off in range(0, len(image) - 16 + 1, 4): + if image[off:off + 8] != XIAO_BOOT_CAPS_MAGIC: + continue + abi, codecs = struct.unpack_from("= BOOT_FORMAT_VER and abi != 0xFFFF and + codecs & (1 << CODEC_FULL) and (storage & ~0x0F) == 0 and + storage & (XIAO_BOOT_STORAGE_QSPI | XIAO_BOOT_STORAGE_UPDATE) == + (XIAO_BOOT_STORAGE_QSPI | XIAO_BOOT_STORAGE_UPDATE) and + image[off + 13:off + 16] == b"\0\0\0"): + return True + return False + + +def validate_xiao_bootloader_image(image: bytes, board_id: Optional[int] = None) -> XiaoBootloaderIdentity: + if len(image) != XIAO_BOOT_IMAGE_SIZE: + raise ValueError(f"bootloader image must be exactly {XIAO_BOOT_IMAGE_SIZE} bytes") + if not _xiao_bootloader_vector_sane(image): + raise ValueError("bootloader vector table is invalid") + identity = parse_xiao_bootloader_identity(image) + if identity is None: + raise ValueError("bootloader embedded manifest/CRC is invalid") + if board_id is not None and identity.board_id != board_id: + raise ValueError("bootloader embedded board ID does not match package target") + if not xiao_bootloader_caps_ok(image): + raise ValueError("bootloader lacks ABI 3 QSPI boot-update continuity capabilities") + return identity + + def _validate_lengths(m: Manifest): assert len(m.merkle_root) == 4 assert len(m.image_hash) == 32 @@ -472,11 +596,26 @@ def _validate_lengths(m: Manifest): def build_manifest(*, target_id: int, fw_version: int, image_size: int, payload: bytes, block_size: int, image_hash: bytes, codec_id: int, is_full: bool, - base_hash: Optional[bytes] = None, sign_priv=None, hw_id=None) -> Manifest: + base_hash: Optional[bytes] = None, sign_priv=None, hw_id=None, + bootloader: bool = False) -> Manifest: assert (block_size & (block_size - 1)) == 0, "block_size must be a power of two" leaves = leaf_hashes(payload, block_size) + if bootloader: + identity = validate_xiao_bootloader_image(payload, target_id) + if (fw_version == 0 or not is_full or codec_id != CODEC_FULL or + image_size != XIAO_BOOT_IMAGE_SIZE or + block_size != DEFAULT_BLOCK_SIZE or base_hash not in (None, b"\0" * 8)): + raise ValueError("bootloader package needs a nonzero version and a 40 KiB CODEC_FULL image with 1 KiB blocks") + if sign_priv is None: + raise ValueError("bootloader package must be signed") + expected_hw = xiao_bootloader_hw_id(identity.board_id) + if hw_id is not None and hw_id_bytes(hw_id) != hw_id_bytes(expected_hw): + raise ValueError("bootloader package hw_id must be canonical for its embedded board ID") + hw_id = expected_hw m = Manifest( - flags=(FLAG_FULL if is_full else 0) | (FLAG_SIGNED if sign_priv is not None else 0), + format_ver=BOOT_FORMAT_VER if bootloader else APP_FORMAT_VER, + flags=((FLAG_FULL if is_full else 0) | (FLAG_SIGNED if sign_priv is not None else 0) | + (FLAG_BOOTLOADER if bootloader else 0)), target_id=target_id, fw_version=fw_version, image_size=image_size, @@ -535,9 +674,15 @@ def parse_container(blob: bytes) -> Parsed: m = Manifest() m.format_ver = take(1)[0] - if m.format_ver != FORMAT_VER: - raise ValueError(f"unsupported format_ver {m.format_ver}") m.flags = take(1)[0] + if m.format_ver == APP_FORMAT_VER: + if m.flags & FLAG_BOOTLOADER or m.flags & ~FLAG_KNOWN: + raise ValueError("v2 application manifest has invalid flags") + elif m.format_ver == BOOT_FORMAT_VER: + if m.flags != FLAG_FULL | FLAG_SIGNED | FLAG_BOOTLOADER: + raise ValueError("v3 bootloader manifest requires exact FULL|SIGNED|BOOTLOADER flags") + else: + raise ValueError(f"unsupported format_ver {m.format_ver}") m.hash_algo = take(1)[0] m.target_id, m.fw_version, m.image_size, m.payload_size = struct.unpack(" Parsed: rest = r.read() # trailer was stripped above, so nothing should remain if rest != b"": raise ValueError("trailing bytes after payload") + if m.is_bootloader: + if (m.fw_version == 0 or m.codec_id != CODEC_FULL or + m.image_size != XIAO_BOOT_IMAGE_SIZE or + m.payload_size != XIAO_BOOT_IMAGE_SIZE or + m.block_size_log2 != 10 or m.block_count != 40 or m.base_hash != b"\0" * 8 or + m.target_id not in (XIAO_BOOT_BOARD_ID_BASE, XIAO_BOOT_BOARD_ID_SENSE) or + m.hw_id != hw_id_bytes(xiao_bootloader_hw_id(m.target_id))): + raise ValueError("v3 bootloader manifest geometry/identity is invalid") return Parsed(manifest=m, payload=payload, total_size=total) @@ -571,6 +724,12 @@ def verify(parsed: Parsed, *, expect_pub: Optional[bytes] = None, problems: List[str] = [] m, payload = parsed.manifest, parsed.payload + if m.is_bootloader: + try: + validate_xiao_bootloader_image(payload, m.target_id) + except (UnicodeDecodeError, ValueError) as exc: + problems.append(f"bootloader image contract: {exc}") + # block_count / leaves if len(m.leaves) != m.block_count: problems.append(f"leaves count {len(m.leaves)} != block_count {m.block_count}") diff --git a/tools/mota/pio_endf.py b/tools/mota/pio_endf.py index 2d607abce..26650ba53 100644 --- a/tools/mota/pio_endf.py +++ b/tools/mota/pio_endf.py @@ -179,15 +179,23 @@ def _append_endf_hex(source, target, env): # Intel-HEX path (nRF52: app f and _builds_companion_radio()) sd_backed = _cppdef("OTA_SD_STORE") is not None qspi_backed = _cppdef("OTA_QSPI_STORE") is not None + bootloader_update = _cppdef("OTA_QSPI_BOOTLOADER_UPDATE") is not None if sd_backed and qspi_backed: raise RuntimeError("nRF52 build cannot enable both SD and QSPI OTA stores") if qspi_backed and _cppdef("QSPIFLASH") is not None: raise RuntimeError("raw QSPI OTA staging cannot share a chip with QSPIFLASH") + if bootloader_update: + xiao_module = _cppdef("XIAO_NRF52") is not None or _cppdef("IKOKA_NRF52") is not None + if not qspi_backed or sd_backed or not xiao_module: + raise RuntimeError("bootloader update requires a XIAO nRF52840 raw-QSPI OTA build") + if linked_app_end != ml.NRF52_BOOT_SCRATCH_START: + raise RuntimeError("bootloader-update build must link exactly below scratch at 0xE0000") stage_ceiling = (ml.NRF52_APP_END if (sd_backed or qspi_backed) else ml.nrf52_stage_ceiling_for_layout(linked_app_end, internal_extrafs)) layout_flags = ((ml.NRF52_LAYOUT_FLAG_SD if sd_backed else 0) | (ml.NRF52_LAYOUT_FLAG_QSPI if qspi_backed else 0) | - (ml.NRF52_LAYOUT_FLAG_INTERNAL_EXTRAFS if internal_extrafs else 0)) + (ml.NRF52_LAYOUT_FLAG_INTERNAL_EXTRAFS if internal_extrafs else 0) | + (ml.NRF52_LAYOUT_FLAG_BOOTLOADER_SCRATCH if bootloader_update else 0)) layout = ml.Nrf52Layout(app_start, linked_app_end, stage_ceiling, layout_flags) body = ml.ensure_nrf52_layout(raw_body, layout) ident = _firmware_ident() diff --git a/tools/mota/test_mota.py b/tools/mota/test_mota.py index 6c2099f94..de49b2d92 100644 --- a/tools/mota/test_mota.py +++ b/tools/mota/test_mota.py @@ -121,7 +121,8 @@ def test_nrf52_layout_record_roundtrip_and_policy(): == ml.NRF52_EXTRAFS_START) assert (ml.nrf52_stage_ceiling_for_layout(ml.NRF52_EXTRAFS_START, False) == ml.NRF52_APP_END) - assert ml.nrf52_stage_ceiling_for_layout(0xE0000, False) == ml.NRF52_EXTRAFS_START + assert (ml.nrf52_stage_ceiling_for_layout(ml.NRF52_BOOT_SCRATCH_START, False) + == ml.NRF52_APP_END) layout = ml.Nrf52Layout(ml.NRF52_APP_BASE_S140_V7, ml.NRF52_EXTRAFS_START, ml.NRF52_APP_END, 0) @@ -142,6 +143,13 @@ def test_nrf52_layout_record_roundtrip_and_policy(): qspi_image, _ = ml.ensure_endf(ml.ensure_nrf52_layout(_fw(7, 2048), qspi)) assert ml.parse_nrf52_layout(qspi_image) == qspi assert qspi.qspi_backed and qspi.external_backed and not qspi.sd_backed + boot_qspi = ml.Nrf52Layout( + ml.NRF52_APP_BASE_S140_V7, ml.NRF52_BOOT_SCRATCH_START, + ml.NRF52_APP_END, + ml.NRF52_LAYOUT_FLAG_QSPI | ml.NRF52_LAYOUT_FLAG_BOOTLOADER_SCRATCH) + boot_qspi_image, _ = ml.ensure_endf(ml.ensure_nrf52_layout(_fw(8, 2048), boot_qspi)) + assert ml.parse_nrf52_layout(boot_qspi_image) == boot_qspi + assert boot_qspi.bootloader_scratch try: ml.build_nrf52_layout(ml.Nrf52Layout( ml.NRF52_APP_BASE_S140_V7, ml.NRF52_EXTRAFS_START, @@ -162,6 +170,196 @@ def test_nrf52_layout_record_roundtrip_and_policy(): pass +def _xiao_bootloader_image(board_id=ml.XIAO_BOOT_BOARD_ID_BASE): + import zlib + image = bytearray(b"\xff" * ml.XIAO_BOOT_IMAGE_SIZE) + struct.pack_into("> 32) + first_error = struct.unpack_from("> output_bit) & 1) + rows.append(row | (((affine >> output_bit) & 1) << 64)) + + pivot_columns = [] + pivot_row = 0 + for column in range(64): + found = next((row for row in range(pivot_row, 64) + if (rows[row] >> column) & 1), None) + if found is None: + continue + rows[pivot_row], rows[found] = rows[found], rows[pivot_row] + for row in range(64): + if row != pivot_row and (rows[row] >> column) & 1: + rows[row] ^= rows[pivot_row] + pivot_columns.append(column) + pivot_row += 1 + + assert all((row & ((1 << 64) - 1)) or not ((row >> 64) & 1) for row in rows) + solution = 0 + for row, column in enumerate(pivot_columns): + if (rows[row] >> 64) & 1: + solution |= 1 << column + assert residual(solution) == 0 + struct.pack_into("> 32) + + +def test_xiao_bootloader_identity_skips_bad_decoy_and_rejects_two_valid_manifests(): + real_offset, decoy_offset, second_offset = 0x100, 0x20, 0x200 + image = bytearray(_xiao_bootloader_image()) + image[decoy_offset:decoy_offset + ml.XIAO_BOOT_MANIFEST_SIZE] = \ + image[real_offset:real_offset + ml.XIAO_BOOT_MANIFEST_SIZE] + struct.pack_into("