From 3d78d9c84635679ddd676528fa6de34f35914c34 Mon Sep 17 00:00:00 2001 From: mikecarper Date: Fri, 21 Aug 2026 09:55:15 -0700 Subject: [PATCH] Add MeshTower SD bootloader updates over LoRa --- docs/cli_command_availability.md | 2 +- docs/lora_ota_automation.md | 4 +- docs/ota_easy.md | 5 +- docs/ota_meshtower_v2_sdcard.md | 68 ++++++++++- docs/ota_nrf52_bootloader_update.md | 71 +++++++++--- docs/ota_protocol.md | 19 +++- docs/ota_user_guide.md | 17 ++- src/helpers/ota/OtaApply.cpp | 145 +++++++++++++++++------- src/helpers/ota/OtaApply.h | 9 ++ src/helpers/ota/OtaBlInfo.h | 29 ++++- src/helpers/ota/OtaBootloaderUpdate.h | 71 +++++++++++- src/helpers/ota/OtaCli.cpp | 45 +++++++- src/helpers/ota/OtaContext.h | 9 +- src/helpers/ota/OtaFlashLayout_nrf52.h | 1 + src/helpers/ota/OtaSdBootToken.h | 73 ++++++++++++ src/helpers/ota/OtaStoreSdNrf52.cpp | 89 ++++++++++++++- src/helpers/ota/OtaStoreSdNrf52.h | 14 ++- src/helpers/ota/OtaVerify.cpp | 16 ++- src/helpers/ota/OtaVerify.h | 6 + test/README.md | 2 +- test/test_ota/test_ota_core.cpp | 78 ++++++++++++- tools/mota/README.md | 14 ++- tools/mota/motalib.py | 34 ++++-- tools/mota/pio_endf.py | 9 +- tools/mota/test_mota.py | 44 ++++++- variants/heltec_tower_v2/platformio.ini | 3 + 26 files changed, 763 insertions(+), 114 deletions(-) create mode 100644 src/helpers/ota/OtaSdBootToken.h diff --git a/docs/cli_command_availability.md b/docs/cli_command_availability.md index 443cdaa0..dcccc0b6 100644 --- a/docs/cli_command_availability.md +++ b/docs/cli_command_availability.md @@ -166,7 +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) | Legacy internal-flash nRF52 LoRa OTA build with failed app-side EndF validation; shared bootloader-update builds require valid EndF | No | No | Feature | -| LoRa OTA | [`ota bootloader`; `ota bootloader install `](ota_nrf52_bootloader_update.md) | Allowlisted internal-flash or XIAO-QSPI nRF52 repeater with ABI-3 exact-board OTAFIX | No | No | Feature | +| LoRa OTA | [`ota bootloader`; `ota bootloader install `](ota_nrf52_bootloader_update.md) | Allowlisted internal-flash, XIAO-QSPI, or exact MeshTower V2 microSD nRF52 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 | diff --git a/docs/lora_ota_automation.md b/docs/lora_ota_automation.md index 9063336e..92c0a630 100644 --- a/docs/lora_ota_automation.md +++ b/docs/lora_ota_automation.md @@ -17,8 +17,8 @@ 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, explicitly supported internal-flash or XIAO QSPI repeater -may later use the separate manual +An already-capable, explicitly supported internal-flash, XIAO QSPI, or exact +MeshTower V2 microSD repeater may later use the separate manual [`ota bootloader install`](ota_nrf52_bootloader_update.md) workflow. ## Required topology diff --git a/docs/ota_easy.md b/docs/ota_easy.md index a912fd1d..62884e38 100644 --- a/docs/ota_easy.md +++ b/docs/ota_easy.md @@ -335,8 +335,9 @@ or `delta` for any supported nRF52 target. Do not select `[unsupported]` (for ex 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, allowlisted internal-flash or XIAO-QSPI nRF52 target can use -the separate [explicit bootloader workflow](ota_nrf52_bootloader_update.md). +already-capable, allowlisted internal-flash, XIAO-QSPI, or exact MeshTower V2 +microSD nRF52 target can use the separate +[explicit bootloader workflow](ota_nrf52_bootloader_update.md). Use the row's stable eight-hex manifest ID rather than its changing list position: ```text diff --git a/docs/ota_meshtower_v2_sdcard.md b/docs/ota_meshtower_v2_sdcard.md index c3d6160f..f8b3c662 100644 --- a/docs/ota_meshtower_v2_sdcard.md +++ b/docs/ota_meshtower_v2_sdcard.md @@ -2,9 +2,10 @@ The `Heltec_tower_v2_sdcard_repeater_lora_ota_no_external_sensors` target uses the MeshTower V2 onboard microSD socket as persistent storage for its own LoRa OTA downloads. It accepts -both full `.mota` images and in-place delta `.mota` images. After verification, -the matching SD-aware OTAFIX bootloader reads the staged file from the card and -programs the nRF52840 application region. +full application images, in-place application deltas, and explicitly selected +signed v3 bootloader packages. After verification, the matching SD-aware OTAFIX +bootloader reads the staged file from the card and programs the selected +nRF52840 application or bootloader region. The pin assignment follows the [Heltec MeshTower V2 partial reference circuit](https://resource.heltec.cn/download/MeshTower-V2/schematic/MeshTower_V2_Partial_Reference_Circuit.pdf): @@ -70,6 +71,65 @@ bootloader capability marker does not advertise SD staging and the selected codec. Existing `Heltec_tower_v2_repeater` firmware continues to use the internal-flash delta path and is unchanged. +## Signed bootloader update + +Only this exact SD build exposes the privileged LoRa bootloader-update command. +It requires an already installed exact-board ABI-3 OTAFIX bootloader whose one +unambiguous capability marker is exactly `0x09` (`SD|BOOT_UPDATE`) and whose +codec mask is `0x0005` (`FULL|INPLACE`). Requiring both application codecs +prevents a bootloader self-update from disabling either normal SD application +path. It cannot bootstrap a stock or older bootloader; provision that +prerequisite once over USB/BLE DFU or SWD. + +The signed v3 container is exactly 41,330 bytes and carries a 40 KiB candidate +for the installed `239A0071 / TOWER_V2_OTA` identity (boot target `1150F50E`). +It uses the same contiguous `/meshcore-ota.mota` file and sector-1 handoff as an +application update. GPREGRET `0x6B` plus the distinct SD source marker `0x53` +selects the bootloader path; LoRa transport remains payload type `0x0C`. + +The application linker still ends at `0xED000`, but OTAFIX needs +`0xE0000..0xEA000` as temporary scratch while replacing itself. Before a boot +package can be downloaded or approved, MeshCore requires a hash-valid live +`EndF` proving the complete running image ends by `0xE0000`; OTAFIX repeats +that no-overlap check before its first erase. An application extending above +that boundary can still receive normal FULL or delta application updates from +SD, but its bootloader must be updated locally. + +MeshCore also checks the live boot settings before touching that scratch page. +Erased settings and a valid-app record with CRC disabled are allowed. When a +nonzero bank CRC is active, its recorded `bank_0_size` must cover the complete +EndF-inclusive running image and must end by `0xE0000`; an undersized or +oversized record refuses the operation and requires local DFU/SWD. + +Because the card is removable, approval is bound to the exact bytes that were +authenticated. MeshCore verifies the signature and signer allowlist over one +exact local manifest copy, requires the streamed manifest on SD to remain +byte-identical, and verifies the complete payload against that manifest. It +then writes `APRV` and syncs the card. Before publishing the raw-sector +handoff, it writes and readback-verifies a temporary 64-byte `MOTASDBL` token +at `0xE0000` containing the exact container length and that signed manifest's +`image_hash`. OTAFIX requires the parsed SD manifest, streamed payload, and +final scratch image to match the same token. Replacing or changing the card +after verification can therefore only make the boot update fail; it cannot +authorize different bootloader bytes. The token page becomes the first +scratch page during a successful update and is not permanently reserved. + +Bootloader packages are never autofetched or autoinstalled. Select and confirm +one exact package manually: + +```text +ota ls +ota pull flash +# wait for ota status to report the bootloader download ready +ota bootloader +ota bootloader install +``` + +Copy the MID and first 16 image-hash hex digits from `ota bootloader`. Ordinary +`ota install` rejects this package, and the bootloader command rejects an +application package. Keep the SD card inserted through the reboot. A later +`ota status` value of `blup:C8` reports a successful bootloader replacement. + ## SD card CLI The SD-backed target provides these CLI commands: @@ -100,7 +160,7 @@ reset when the device reboots. The `get sdcard` age queries report how long ago each operation completed. `get sdcard free` reports used and free filesystem space in human-readable binary units. -`get sdcard ls` and `get sdcard dir` recursively list files on the card, four +`get sdcard ls` and `get sdcard dir` recursively list files on the card, two files per page. A bare command shows page 1; append a positive page number to move through the remaining results. Each row includes the path and a compact file size. The header reports the selected page, total pages, and total files. diff --git a/docs/ota_nrf52_bootloader_update.md b/docs/ota_nrf52_bootloader_update.md index dec06b36..75385d65 100644 --- a/docs/ota_nrf52_bootloader_update.md +++ b/docs/ota_nrf52_bootloader_update.md @@ -12,6 +12,33 @@ over USB/BLE DFU or SWD. | --- | ---: | ---: | ---: | --- | | XIAO-module raw QSPI | below `0xE0000` | external QSPI offset 0 | dedicated internal `0xE0000..0xEA000` scratch | source `0x51`, flags `0x0E` | | Qualified internal-flash target | normal `0xED000` limit | shared internal slot, exact start `0xE2000` | the same eleven-page slot; no second reservation | source `0xED`, flags `0x0A` | +| MeshTower V2 microSD | normal `0xED000` limit | contiguous `/meshcore-ota.mota` | dynamic internal `0xE0000..0xEA000` scratch; live image must end by `0xE0000` | source `0x53`, flags `0x09` | + +The exact SD target is +`Heltec_tower_v2_sdcard_repeater_lora_ota_no_external_sensors`. Its normal +application FULL and delta updates continue to use the SD file without the +bootloader-update scratch restriction. For a bootloader package only, both +MeshCore and OTAFIX require a hash-valid live `EndF` proving the complete +running image ends at or below `0xE0000`. If boot settings carry a nonzero app +bank CRC, the recorded bank size must also cover that full EndF-inclusive +image and stop by `0xE0000`; erased or explicitly CRC-disabled settings remain +valid. OTAFIX then copies the verified 40 +KiB payload from SD into `0xE0000..0xEA000` and uses the MBR copy operation to +replace `0xF4000..0xFE000`. The application linker remains at `0xED000`; a +future application extending above `0xE0000` can still use application mOTA +but must update its bootloader through local DFU/SWD. + +The removable SD authorization is fail-closed. MeshCore directly authenticates +one exact manifest, requires the streamed manifest on SD to remain +byte-identical, and verifies its complete payload. After writing and syncing +`APRV`, but before publishing the raw-sector handoff, it writes and +readback-verifies a 64-byte `MOTASDBL` token at `0xE0000`. The token contains +the exact container length and the authenticated signed manifest's +`image_hash`. OTAFIX requires that same hash in the parsed manifest, the +streamed raw payload, and the final scratch image. A card swap or mutation +after application verification therefore causes refusal rather than +authorizing different bytes; the token page is consumed as scratch during a +successful update and is not a permanent reservation. The internal path does not change the application linker or permanently set aside separate app-OTA, boot-package, and scratch regions. The ordinary @@ -86,9 +113,10 @@ ordinary full-sensor target when that module is required. Boards with onboard external flash are not silently redirected to internal staging. Mesh Solar, Nano G2 Ultra, T-Impulse Plus, ThinkNode M8, T-Echo Lite/Card, MeshTracker X1, and Wio WM1110 have board-specific QSPI hardware and -need a separately matched QSPI path where available. Full Companions, +need a separately matched QSPI path where available. Full Companions, other SD/QSPI/ExtraFS roles, Ethernet roles, source-only roles, and unqualified -full-sensor roles are excluded. +full-sensor roles are excluded from the internal-flash inventory. The exact +MeshTower V2 SD role above is separately qualified for its SD path. This internal layout is limited to nRF52840 devices with 1 MiB internal flash, the exact S140 v6/v7 map, and the 40 KiB boot region at @@ -104,8 +132,9 @@ Check the installed identity and capability marker: ota bootloader ``` -The response must show a CRC-valid exact identity, ABI 3 or newer, full codec, -and exact flags `0x0A` for internal shared storage or `0x0E` for XIAO QSPI. A +The response must show a CRC-valid exact identity, ABI 3 or newer, both FULL +and INPLACE application codecs (`codecs=0x5`), and exact flags `0x09` for +MeshTower V2 SD, `0x0A` for internal shared storage, or `0x0E` for XIAO QSPI. A bootloader row is visible in `ota ls`, but it is never autofetched or autoinstalled. Fetch its exact MID, then explicitly arm it: @@ -126,25 +155,31 @@ bootloader package is not automatically resumed after an application reboot. Before writing `APRV`, the application authenticates and authorizes the package: exact v3 geometry, trusted Ed25519 signer, signed/embedded identity, one unambiguous capability marker, embedded CRC, sane vectors, complete -Merkle/payload/image hashes, safe live placement, and the typed MID/hash -confirmation. OTAFIX consumes that application-written authorization and then +Merkle/payload/image hashes, storage-specific safe live placement, and the +typed MID/hash confirmation. On SD, `APRV` and the internal signed-image-hash +token are synced and read back before the handoff becomes visible. OTAFIX consumes that application-written +authorization and then independently rechecks the safety/integrity subset: strict v3 structure, canonical identity/capabilities, vectors, full payload SHA, embedded manifest -CRC, live `EndF` no-overlap geometry, page readback, and final copy hash. It -does not re-run Ed25519, the signer allowlist, Merkle leaves/root, or the typed -operator confirmation. Success is reported as `blup:C8`. +CRC, the applicable live `EndF`/bank-settings no-overlap geometry, the SD token +binding where applicable, scratch readback, and final +copy hash. It does not re-run Ed25519, the signer allowlist, Merkle leaves/root, +or the typed operator confirmation. Success is reported as `blup:C8`. ## Failure behavior -The feature fails closed when the MCU/map is wrong, current valid `EndF` -headroom is insufficient, external/ExtraFS storage owns the target, the exact -installed capability marker is absent or ambiguous, identity cannot be derived -unambiguously, or any package check fails. Before compaction, the application -and bootloader are unchanged. OTAFIX consumes the trigger/approval before the -first in-place compaction erase, so an interrupted conversion cannot retry a -partly converted package; it boots the untouched application and old -bootloader instead. Use USB/BLE DFU or SWD for initial provisioning and local -recovery. +The feature fails closed when the MCU/map is wrong, required valid `EndF` +headroom is insufficient, an unsupported external/ExtraFS role owns the target, +the exact installed capability marker is absent or ambiguous, identity cannot be derived +unambiguously, or any package check fails. Before the storage-specific +scratch/copy step, the application and bootloader are unchanged. OTAFIX +consumes the trigger and, for internal/QSPI storage, clears approval before its +first scratch erase, so an interrupted operation cannot retry a partly +consumed package. The SD backend cannot rewrite raw card sectors: its `APRV` +and checksummed handoff may persist, but the one-shot GPREGRET trigger is +consumed before validation and they remain inert unless the running app again +authenticates and explicitly re-arms that package. Use USB/BLE DFU or SWD for +initial provisioning and local recovery. For XIAO and ordinary external-QSPI details, see [nRF52 repeater LoRa OTA with external QSPI](ota_nrf52_qspi.md). diff --git a/docs/ota_protocol.md b/docs/ota_protocol.md index 4a44d4cd..1d871706 100644 --- a/docs/ota_protocol.md +++ b/docs/ota_protocol.md @@ -271,8 +271,9 @@ A v3 bootloader package has a deliberately narrow, non-extensible profile: - signed `hw_id` exactly `XIAO_BL_28860044`/`XIAO_BL_28860045` for deployed XIAO, or the zero-padded 32-byte `NRF_BL__` for a generic target; - a sane nRF52840 vector table, exactly one CRC-valid embedded manifest v1 with the exact board/name pair, - and exactly one `MOTABLDR` marker advertising ABI >= 3, full codec, boot-update continuity, and the exact - storage flags for the application layout (`0x0E` XIAO QSPI or `0x0A` shared internal staging). + and exactly one `MOTABLDR` marker advertising ABI >= 3, both application codecs (`FULL|INPLACE`, mask + `0x0005`), boot-update continuity, and the exact storage flags for the application layout (`0x09` + MeshTower V2 SD, `0x0E` XIAO QSPI, or `0x0A` shared internal staging). 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 @@ -842,7 +843,19 @@ ota dev ... bring-up helpers (stage/recv/serve/verify) - **MeshTower V2 SD nRF52:** the application stores a contiguous `/meshcore-ota.mota` on microSD and publishes its raw sector range in a checksummed handoff record outside the MBR partition. The matching bootloader reads the card without mounting FAT, supports either a full image or an in-place delta, - verifies the staged/full result hash, and never writes through `0xED000` where InternalFS begins. + verifies the staged/full result hash, and never writes through `0xED000` where InternalFS begins. The + exact SD repeater also accepts a manually selected, signed v3 bootloader package when installed and + candidate markers are exactly `0x09` (`SD|BOOT_UPDATE`). MeshCore streams the same strict identity, + CRC, vector, signature, MID/hash-confirmation, and complete-image checks from the SD file. GPREGRET + `0x6B` plus GPREGRET2 `0x53` selects this privileged path. Both MeshCore and OTAFIX require a hash-valid + live `EndF` ending by `0xE0000`; when a nonzero boot-settings bank CRC is active, its recorded size must + also cover that complete live image and stop by `0xE0000`. MeshCore authenticates one exact manifest, + verifies the streamed SD copy is byte-identical, writes and syncs `APRV`, then writes a readback-checked + `MOTASDBL` token at `0xE0000` containing the exact total and signed `image_hash` before publishing the + raw-sector handoff. OTAFIX binds the parsed manifest, streamed payload, and final scratch image to that + token, so a removable-media change can only fail closed. OTAFIX then uses `0xE0000..0xEA000` as temporary scratch; the normal + application linker remains at `0xED000` and ordinary application updates do not inherit this scratch + headroom restriction. - **Matched external-QSPI nRF52 repeaters:** the application reserves the board's dedicated QSPI NOR as a raw store beginning at offset zero. It obtains a 1-16 MiB capacity from JEDEC, checkpoints payload before leaf metadata, and verifies each erased/programmed page. GPREGRET2 `0x51` selects QSPI only when the diff --git a/docs/ota_user_guide.md b/docs/ota_user_guide.md index f304476f..c0662058 100644 --- a/docs/ota_user_guide.md +++ b/docs/ota_user_guide.md @@ -219,7 +219,9 @@ After it reboots, run `ota status` to confirm the new version. ### Updating an nRF52 bootloader (advanced, explicit only) This is available on specially marked no-external-flash nRF52840 lean -repeater/bridge builds and on the legacy XIAO nRF52840/Sense raw-QSPI builds, +repeater/bridge builds, on the legacy XIAO nRF52840/Sense raw-QSPI builds, and +on the exact `Heltec_tower_v2_sdcard_repeater_lora_ota_no_external_sensors` +microSD build, 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: @@ -228,8 +230,8 @@ ota bootloader ``` The reply must show a CRC-valid installed identity plus ABI 3 and the exact -storage/boot-update capability bits for that build (`0x0A` for the shared -internal store or `0x0E` for XIAO raw QSPI). A bootloader package appears as +storage/boot-update capability bits for that build (`0x09` for MeshTower V2 +microSD, `0x0A` for the shared internal store, or `0x0E` for XIAO raw QSPI). 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: @@ -254,6 +256,15 @@ On an internal-flash target, the package shares the ordinary store below bank. A valid live `EndF` must prove the current image ends at or below that address before any page is erased. If `EndF` is missing/corrupt or the app is too large, the pull is refused and local DFU/SWD is required. +On the MeshTower V2 SD target, the application linker remains at `0xED000`, but +bootloader replacement needs temporary scratch beginning at `0xE0000`. A +hash-valid live `EndF` must therefore prove the complete current image ends by +`0xE0000`. A CRC-bound boot-settings bank must cover that complete image while +also ending by `0xE0000`. MeshCore binds approval to the exact authenticated +signed image hash in an internal token before handing the removable SD card to +OTAFIX, so a later card change fails instead of authorizing different bytes. +Larger applications can continue to use normal application mOTA; +only bootloader self-update is refused. See [the nRF52 bootloader-update guide](ota_nrf52_bootloader_update.md) for the complete target inventory, storage layouts, and safety contract. diff --git a/src/helpers/ota/OtaApply.cpp b/src/helpers/ota/OtaApply.cpp index 42adda60..b828549f 100644 --- a/src/helpers/ota/OtaApply.cpp +++ b/src/helpers/ota/OtaApply.cpp @@ -485,6 +485,8 @@ void ota_reboot_to_bootloader_update() { uint8_t source = GPREGRET2_OTA_STAGE_QSPI; #if defined(OTA_INTERNAL_BOOTLOADER_UPDATE) source = GPREGRET2_OTA_STAGE_EXPANDED; +#elif defined(OTA_SD_BOOTLOADER_UPDATE) + source = GPREGRET2_OTA_STAGE_SD; #endif uint8_t sd_en = 0; sd_softdevice_is_enabled(&sd_en); @@ -749,22 +751,10 @@ static bool ota_apply_mota_nrf52_external(Store& store, const SignerAllowlist& a } #endif -#if defined(OTA_SD_STORE) -bool ota_apply_mota_nrf52(OtaStoreSdNrf52& store, const SignerAllowlist& allow, - ApplyState& st, char* msg) { - return ota_apply_mota_nrf52_external(store, allow, OTA_BL_STORAGE_SD, "SD", st, msg); -} -#endif - -#if defined(OTA_QSPI_STORE) -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) { +#if defined(OTA_SD_BOOTLOADER_UPDATE) || defined(OTA_QSPI_BOOTLOADER_UPDATE) +template +static bool external_bootloader_crc_ok(Store& 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)) { @@ -781,10 +771,10 @@ static bool qspi_bootloader_crc_ok(OtaStoreQspiNrf52& store, uint32_t payload_of return ~crc == identity.crc32; } -static bool qspi_bootloader_image_metadata(OtaStoreQspiNrf52& store, uint32_t payload_off, - const OtaBootloaderIdentity& installed, - OtaBootloaderIdentity& candidate, - OtaBootloaderCapsMarker& caps) { +template +static bool external_bootloader_image_metadata(Store& store, uint32_t payload_off, + 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. @@ -804,7 +794,8 @@ static bool qspi_bootloader_image_metadata(OtaStoreQspiNrf52& store, uint32_t pa 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.codec_mask & OTA_BL_REQUIRED_APP_CODEC_MASK) == + OTA_BL_REQUIRED_APP_CODEC_MASK && (parsed.storage_flags & OTA_BL_STORAGE_BOOT_UPDATE) != 0) { if (++valid_caps != 1u) return false; if (parsed.storage_flags == ota_bootloader_update_storage_flags()) caps = parsed; @@ -812,10 +803,8 @@ static bool qspi_bootloader_image_metadata(OtaStoreQspiNrf52& store, uint32_t pa } 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)) { + if (!ota_bootloader_manifest_parse(buf + local, absolute, parsed)) continue; + if (external_bootloader_crc_ok(store, payload_off, parsed)) { parsed.crc_ok = true; if (!ota_bootloader_identity_add_unique(parsed, candidate, valid_identities)) return false; } @@ -825,13 +814,30 @@ static bool qspi_bootloader_image_metadata(OtaStoreQspiNrf52& store, uint32_t pa 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) { +#if defined(OTA_SD_BOOTLOADER_UPDATE) +static bool external_bootloader_approve(OtaStoreSdNrf52& store, + const uint8_t image_hash[32]) { + return store.approve_for_bootloader(image_hash); +} +#endif + +#if defined(OTA_QSPI_BOOTLOADER_UPDATE) +static bool external_bootloader_approve(OtaStoreQspiNrf52& store, + const uint8_t image_hash[32]) { + (void)image_hash; + return store.approve_for_bootloader(); +} +#endif + +template +static bool ota_prepare_bootloader_update_external(Store& 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], + const char* storage_name, + ApplyState& st, char* msg) { static const size_t CAP = 96; st = ApplyState(); const uint32_t total = store.staged_size(); @@ -839,12 +845,23 @@ bool ota_prepare_bootloader_update_nrf52(OtaStoreQspiNrf52& store, 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; + snprintf(msg, CAP, "%s bootloader container parse failed", storage_name); return false; } MotaManifest m; if (!mota_parse_manifest(manifest, sizeof(manifest), m)) { strcpy(msg, "not a valid v3 bootloader package"); return false; } + // Authenticate this exact local manifest copy before any removable-media + // reread. The streaming verifier below requires byte identity with it, and + // the SD approval token copies its signed image_hash. + mesh::Identity authenticated_signer(m.signer_pubkey); + if (!authenticated_signer.verify( + m.signature, m.manifest_start, (int)m.signed_len)) { + strcpy(msg, "bootloader package signature invalid"); return false; + } + if (!allow.contains(m.signer_pubkey)) { + strcpy(msg, "bootloader signer is not in the trusted allowlist"); return false; + } switch (ota_bootloader_confirmation_gate(m, installed, actual_mid, operator_mid, operator_hash8)) { case OTA_BOOT_CONFIRM_OK: break; @@ -859,7 +876,8 @@ bool ota_prepare_bootloader_update_nrf52(OtaStoreQspiNrf52& store, const OtaBlCaps current_caps = ota_bootloader_caps(); if (!ota_bootloader_self_update_caps_valid(current_caps)) { - strcpy(msg, "installed bootloader cannot safely self-update from QSPI"); return false; + snprintf(msg, CAP, "installed bootloader cannot safely self-update from %s", storage_name); + return false; } const uint64_t payload_off64 = 8u + MOTA_MFL + (uint64_t)m.block_count * 4u; @@ -868,7 +886,8 @@ bool ota_prepare_bootloader_update_nrf52(OtaStoreQspiNrf52& store, } const uint32_t payload_off = (uint32_t)payload_off64; - VerifyResult vr = ota_verify(static_cast(store), allow); + VerifyResult vr = ota_verify( + static_cast(store), allow, manifest); st.manifest_ok = vr.parsed; st.sig_ok = vr.sig_ok; st.trusted = vr.trusted; @@ -890,18 +909,68 @@ bool ota_prepare_bootloader_update_nrf52(OtaStoreQspiNrf52& store, } OtaBootloaderIdentity candidate; OtaBootloaderCapsMarker candidate_caps; - if (!qspi_bootloader_image_metadata(store, payload_off, installed, candidate, candidate_caps) || + if (!external_bootloader_image_metadata(store, payload_off, 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; + if (!external_bootloader_approve(store, m.image_hash)) { + snprintf(msg, CAP, "%s bootloader handoff failed: %s", storage_name, store.last_error()); + return false; } strcpy(msg, "trusted bootloader verified and armed; rebooting after this reply"); return true; } #endif + +#if defined(OTA_SD_STORE) +bool ota_apply_mota_nrf52(OtaStoreSdNrf52& store, const SignerAllowlist& allow, + ApplyState& st, char* msg) { + return ota_apply_mota_nrf52_external(store, allow, OTA_BL_STORAGE_SD, "SD", st, msg); +} + +#if defined(OTA_SD_BOOTLOADER_UPDATE) +bool ota_prepare_bootloader_update_nrf52(OtaStoreSdNrf52& 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) { + SelfFwInfo fi; + if (!ota_self_firmware(fi) || + !ota_bootloader_scratch_headroom_valid( + fi.valid, mota_nrf52_app_base(), fi.image_len, OTA_BOOT_SCRATCH_START) || + !ota_bootloader_live_bank_preserves_scratch( + mota_nrf52_app_base(), fi.image_len, OTA_BOOT_SCRATCH_START)) { + st = ApplyState(); + strcpy(msg, "running firmware/settings do not preserve E0000 scratch"); + return false; + } + return ota_prepare_bootloader_update_external( + store, allow, installed, actual_mid, operator_mid, operator_hash8, "SD", st, msg); +} +#endif +#endif + +#if defined(OTA_QSPI_STORE) +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) +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) { + return ota_prepare_bootloader_update_external( + store, allow, installed, actual_mid, operator_mid, operator_hash8, "QSPI", st, msg); +} +#endif #endif #if defined(OTA_INTERNAL_BOOTLOADER_UPDATE) && defined(OTA_FLASH_STORE) diff --git a/src/helpers/ota/OtaApply.h b/src/helpers/ota/OtaApply.h index cb6030da..7d40fb7a 100644 --- a/src/helpers/ota/OtaApply.h +++ b/src/helpers/ota/OtaApply.h @@ -86,6 +86,15 @@ bool ota_rescue_mota_nrf52(const uint8_t* buf, uint32_t len, class OtaStoreSdNrf52; bool ota_apply_mota_nrf52(OtaStoreSdNrf52& store, const SignerAllowlist& allow, ApplyState& st, char* msg); +#if defined(OTA_SD_BOOTLOADER_UPDATE) +bool ota_prepare_bootloader_update_nrf52(OtaStoreSdNrf52& 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 #if defined(NRF52_PLATFORM) && defined(OTA_QSPI_STORE) class OtaStoreQspiNrf52; diff --git a/src/helpers/ota/OtaBlInfo.h b/src/helpers/ota/OtaBlInfo.h index 1a0ded37..f21102d8 100644 --- a/src/helpers/ota/OtaBlInfo.h +++ b/src/helpers/ota/OtaBlInfo.h @@ -34,16 +34,29 @@ 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; static const uint8_t OTA_BL_STORAGE_KNOWN = 0x0F; +static const uint8_t OTA_BL_PROFILE_SD_BOOT_UPDATE = + OTA_BL_STORAGE_SD | OTA_BL_STORAGE_BOOT_UPDATE; +static const uint8_t OTA_BL_PROFILE_INTERNAL_BOOT_UPDATE = + OTA_BL_STORAGE_STAGE_CEILING | OTA_BL_STORAGE_BOOT_UPDATE; +static const uint8_t OTA_BL_PROFILE_QSPI_BOOT_UPDATE = + OTA_BL_STORAGE_STAGE_CEILING | OTA_BL_STORAGE_QSPI | + OTA_BL_STORAGE_BOOT_UPDATE; +// A successor must retain both application update paths used by qualified +// external stores: bit 0 CODEC_FULL and bit 2 CODEC_DETOOLS_INPLACE. +static const uint16_t OTA_BL_REQUIRED_APP_CODEC_MASK = 0x0005u; inline uint8_t ota_bootloader_update_storage_flags() { #if defined(OTA_INTERNAL_BOOTLOADER_UPDATE) // No external-storage bit means the ordinary internal flash store. The // same store holds either an app delta or a boot package; BOOT_UPDATE marks // only the privileged package capability, not a second storage backend. - return OTA_BL_STORAGE_STAGE_CEILING | OTA_BL_STORAGE_BOOT_UPDATE; + return OTA_BL_PROFILE_INTERNAL_BOOT_UPDATE; #elif defined(OTA_QSPI_BOOTLOADER_UPDATE) - return OTA_BL_STORAGE_STAGE_CEILING | OTA_BL_STORAGE_QSPI | - OTA_BL_STORAGE_BOOT_UPDATE; + return OTA_BL_PROFILE_QSPI_BOOT_UPDATE; +#elif defined(OTA_SD_BOOTLOADER_UPDATE) + // The complete container remains in the contiguous SD staging file. No + // internal stage-ceiling capability is implied by this external store. + return OTA_BL_PROFILE_SD_BOOT_UPDATE; #else return 0; #endif @@ -52,7 +65,9 @@ inline uint8_t ota_bootloader_update_storage_flags() { inline bool ota_bootloader_self_update_caps_valid(const OtaBlCaps& c) { const uint8_t required = ota_bootloader_update_storage_flags(); return required != 0 && c.present && c.apply_abi >= 3u && - (c.codec_mask & 1u) != 0 && c.storage_flags == required; + (c.codec_mask & OTA_BL_REQUIRED_APP_CODEC_MASK) == + OTA_BL_REQUIRED_APP_CODEC_MASK && + c.storage_flags == required; } // Prefer a continuity-capable marker over a numerically newer legacy-looking candidate. Bootloader @@ -88,7 +103,8 @@ inline OtaBlCaps ota_bl_caps_scan_aligned(const uint8_t* bytes, size_t len, if (abi == 0 || abi == 0xFFFFu || codecs == 0 || (storage & ~OTA_BL_STORAGE_KNOWN) != 0 || p[13] != 0 || p[14] != 0 || p[15] != 0) continue; const bool exact_profile = exact_storage_flags == 0 || storage == exact_storage_flags; - if (require_boot_update && exact_profile && abi >= 3u && (codecs & 1u) != 0 && + if (require_boot_update && exact_profile && abi >= 3u && + (codecs & OTA_BL_REQUIRED_APP_CODEC_MASK) == OTA_BL_REQUIRED_APP_CODEC_MASK && (storage & OTA_BL_STORAGE_BOOT_UPDATE) != 0) { // A self-update build must have one unambiguous privileged marker. Do // not silently choose between two otherwise valid structures (including @@ -118,7 +134,8 @@ inline OtaBlCaps ota_bootloader_caps() { 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; return ota_bl_caps_scan_aligned(lo, (size_t)(hi - lo), -#if defined(OTA_QSPI_BOOTLOADER_UPDATE) || defined(OTA_INTERNAL_BOOTLOADER_UPDATE) +#if defined(OTA_QSPI_BOOTLOADER_UPDATE) || defined(OTA_INTERNAL_BOOTLOADER_UPDATE) || \ + defined(OTA_SD_BOOTLOADER_UPDATE) true, ota_bootloader_update_storage_flags() #else false diff --git a/src/helpers/ota/OtaBootloaderUpdate.h b/src/helpers/ota/OtaBootloaderUpdate.h index f3f0e9ca..cdfb5fbf 100644 --- a/src/helpers/ota/OtaBootloaderUpdate.h +++ b/src/helpers/ota/OtaBootloaderUpdate.h @@ -31,6 +31,18 @@ #endif #endif +#if defined(OTA_SD_BOOTLOADER_UPDATE) + #if !defined(NRF52_PLATFORM) || !defined(OTA_SD_STORE) + #error "OTA_SD_BOOTLOADER_UPDATE requires nRF52 SD staging" + #endif + #if !defined(HELTEC_TOWER_V2_SDCARD) + #error "OTA_SD_BOOTLOADER_UPDATE is restricted to the qualified MeshTower V2 SD target" + #endif + #if defined(OTA_FLASH_STORE) || defined(OTA_QSPI_STORE) || defined(QSPIFLASH) + #error "SD bootloader staging cannot share another OTA/filesystem store" + #endif +#endif + namespace mesh { namespace ota { @@ -45,9 +57,65 @@ 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_BOOT_SETTINGS_ADDRESS = 0x000FF000UL; +static const uint16_t OTA_BOOT_BANK_VALID_APP = 0x0001u; +static const uint16_t OTA_BOOT_BANK_ERASED_FALLBACK = 0xFFFFu; static const uint32_t OTA_NRF52840_RAM_START = 0x20000000UL; static const uint32_t OTA_NRF52840_RAM_END = 0x20040000UL; +inline bool ota_bootloader_image_geometry_valid(uint32_t image_size, + uint32_t payload_size) { + return image_size == OTA_BOOT_IMAGE_SIZE && payload_size == OTA_BOOT_IMAGE_SIZE; +} + +// External SD keeps the normal ED000 application linker, but OTAFIX needs the +// XIAO-sized E0000..EA000 internal scratch range temporarily while replacing +// itself. A hash-valid live EndF must prove the complete running image ends +// before that scratch range; a linker maximum alone is not sufficient. +inline bool ota_bootloader_scratch_headroom_valid(bool self_valid, + uint32_t app_base, + uint32_t image_len, + uint32_t scratch_start) { + return self_valid && app_base < scratch_start && image_len != 0 && + image_len <= scratch_start - app_base; +} + +// The bootloader may validate BANK_VALID_APP over bank_0_size on every reset. +// Erasing the SD authorization-token page must not change bytes covered by a +// nonzero stored CRC. A CRC-bound bank must cover the complete hash-valid live +// image but stop by scratch_start; a smaller recorded size would disagree with +// OTAFIX even if it did not overlap scratch. Debugger/UF2 installs can have +// erased settings, and a valid-app record with CRC zero explicitly disables +// that CRC check. +inline bool ota_bootloader_scratch_bank_geometry_valid(uint16_t bank_0, + uint16_t bank_0_crc, + uint32_t bank_0_size, + uint32_t live_image_size, + uint32_t safe_span) { + if (bank_0 == OTA_BOOT_BANK_ERASED_FALLBACK) return true; + if (bank_0 != OTA_BOOT_BANK_VALID_APP) return false; + if (bank_0_crc == 0u) return true; + return live_image_size != 0u && bank_0_size >= live_image_size && + bank_0_size <= safe_span; +} + +#if defined(NRF52_PLATFORM) +inline bool ota_bootloader_live_bank_preserves_scratch(uint32_t app_base, + uint32_t live_image_size, + uint32_t scratch_start) { + if (app_base >= scratch_start) return false; + const volatile uint8_t* raw = + (const volatile uint8_t*)(uintptr_t)OTA_NRF52840_BOOT_SETTINGS_ADDRESS; + const uint16_t bank_0 = (uint16_t)(raw[0] | ((uint16_t)raw[1] << 8)); + const uint16_t bank_0_crc = (uint16_t)(raw[2] | ((uint16_t)raw[3] << 8)); + const uint32_t bank_0_size = (uint32_t)raw[8] | ((uint32_t)raw[9] << 8) | + ((uint32_t)raw[10] << 16) | ((uint32_t)raw[11] << 24); + return ota_bootloader_scratch_bank_geometry_valid( + bank_0, bank_0_crc, bank_0_size, live_image_size, + scratch_start - app_base); +} +#endif + // 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; @@ -284,7 +352,8 @@ inline bool ota_bootloader_caps_from_image(const uint8_t* image, size_t image_si OtaBootloaderCapsMarker parsed; if (!ota_bootloader_caps_marker_parse(image + off, parsed) || parsed.apply_abi < MOTA_BOOT_FORMAT_VER || - (parsed.codec_mask & (1u << CODEC_FULL)) == 0 || + (parsed.codec_mask & OTA_BL_REQUIRED_APP_CODEC_MASK) != + OTA_BL_REQUIRED_APP_CODEC_MASK || (parsed.storage_flags & OTA_BL_STORAGE_BOOT_UPDATE) == 0) continue; if (++valid_count != 1u) return false; // privileged marker identity must be unambiguous if (parsed.storage_flags != exact_storage_flags) continue; diff --git a/src/helpers/ota/OtaCli.cpp b/src/helpers/ota/OtaCli.cpp index 319755cc..510471b2 100644 --- a/src/helpers/ota/OtaCli.cpp +++ b/src/helpers/ota/OtaCli.cpp @@ -189,6 +189,16 @@ bool handle_ota_command(const char* command, char* reply, mesh::MainBoard& board "OTA: status | stats | ls | get flash | install | cancel | announce | self | qspi | " "folder | config | key"); #endif +#elif defined(NRF52_PLATFORM) && defined(OTA_SD_STORE) +#if defined(OTA_SD_BOOTLOADER_UPDATE) + strcpy(reply, + "OTA: status | stats | ls | get flash | install | bootloader | cancel | announce | self | " + "folder | cache | config | key"); +#else + strcpy(reply, + "OTA: status | stats | ls | get flash | install | cancel | announce | self | folder | " + "cache | config | key"); +#endif #else snprintf(reply, 160, "OTA: status | stats | ls | get flash | install | cancel | announce | self | folder | " @@ -328,6 +338,16 @@ bool handle_ota_command(const char* command, char* reply, mesh::MainBoard& board !defined(OTA_QSPI_STORE) SelfFwInfo list_self; bool list_has_endf = ota_self_firmware(list_self) && list_self.valid; +#endif +#if defined(NRF52_PLATFORM) && defined(OTA_SD_BOOTLOADER_UPDATE) + SelfFwInfo list_sd_self; + const bool list_sd_headroom = ota_self_firmware(list_sd_self) && + ota_bootloader_scratch_headroom_valid( + list_sd_self.valid, mota_nrf52_app_base(), list_sd_self.image_len, + OTA_BOOT_SCRATCH_START) && + ota_bootloader_live_bank_preserves_scratch( + mota_nrf52_app_base(), list_sd_self.image_len, + OTA_BOOT_SCRATCH_START); #endif uint32_t now = millis(); int shown = 0; uint16_t first = (uint16_t)(page - 1) * PAGE_SIZE; @@ -355,12 +375,16 @@ bool handle_ota_command(const char* command, char* reply, mesh::MainBoard& board if (boot_package) { bool installable = false; #if defined(NRF52_PLATFORM) && \ - (defined(OTA_QSPI_BOOTLOADER_UPDATE) || defined(OTA_INTERNAL_BOOTLOADER_UPDATE)) + (defined(OTA_QSPI_BOOTLOADER_UPDATE) || defined(OTA_INTERNAL_BOOTLOADER_UPDATE) || \ + defined(OTA_SD_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 == ota_bootloader_target_id(bid) && ota_bootloader_self_update_caps_valid(list_bl); +#if defined(OTA_SD_BOOTLOADER_UPDATE) + installable = installable && list_sd_headroom; +#endif #endif fit = installable ? "yours" : "bootloader unsupported"; } else if (myt && h->target_id == myt) { @@ -489,7 +513,8 @@ bool handle_ota_command(const char* command, char* reply, mesh::MainBoard& board #else if (selboot) { #if defined(NRF52_PLATFORM) && \ - (defined(OTA_QSPI_BOOTLOADER_UPDATE) || defined(OTA_INTERNAL_BOOTLOADER_UPDATE)) + (defined(OTA_QSPI_BOOTLOADER_UPDATE) || defined(OTA_INTERNAL_BOOTLOADER_UPDATE) || \ + defined(OTA_SD_BOOTLOADER_UPDATE)) const OtaBootloaderIdentity& bid = c.bootloaderIdentity(); const OtaBlCaps& bl = c.bootloaderCaps(); if (selflags != (MFLAG_FULL | MFLAG_SIGNED | MFLAG_BOOTLOADER) || @@ -505,6 +530,19 @@ bool handle_ota_command(const char* command, char* reply, mesh::MainBoard& board strcpy(reply, "ERR installed bootloader lacks safe LoRa bootloader-update support"); return true; } +#if defined(OTA_SD_BOOTLOADER_UPDATE) + SelfFwInfo sd_self; + if (!ota_self_firmware(sd_self) || + !ota_bootloader_scratch_headroom_valid( + sd_self.valid, mota_nrf52_app_base(), sd_self.image_len, + OTA_BOOT_SCRATCH_START) || + !ota_bootloader_live_bank_preserves_scratch( + mota_nrf52_app_base(), sd_self.image_len, + OTA_BOOT_SCRATCH_START)) { + strcpy(reply, "ERR running firmware/settings do not preserve E0000 scratch; use local DFU/SWD"); + return true; + } +#endif #else strcpy(reply, "ERR this build cannot install bootloader packages; use folder capture or USB DFU"); return true; @@ -716,7 +754,8 @@ bool handle_ota_command(const char* command, char* reply, mesh::MainBoard& board } else if (is_cmd(a, "bootloader|blupdate", &rest)) { #if defined(NRF52_PLATFORM) && !defined(OTA_SEEDER_ONLY) && \ - (defined(OTA_QSPI_BOOTLOADER_UPDATE) || defined(OTA_INTERNAL_BOOTLOADER_UPDATE)) + (defined(OTA_QSPI_BOOTLOADER_UPDATE) || defined(OTA_INTERNAL_BOOTLOADER_UPDATE) || \ + defined(OTA_SD_BOOTLOADER_UPDATE)) const OtaBootloaderIdentity& bid = c.bootloaderIdentity(); const OtaBlCaps& bl = c.bootloaderCaps(); if (*rest == 0 || strcmp(rest, "status") == 0) { diff --git a/src/helpers/ota/OtaContext.h b/src/helpers/ota/OtaContext.h index 00a64cab..f3f1bb09 100644 --- a/src/helpers/ota/OtaContext.h +++ b/src/helpers/ota/OtaContext.h @@ -35,7 +35,8 @@ // Per-device OTA singleton shared by the CLI (OtaCli) and the mesh adapter (the example's MyMesh). // Holds the session engine, a persistent staging store (fetch), a RAM serve buffer, and the signer // allowlist. nRF52 uses one of three stores selected by the exact target: internal flash for in-place -// deltas, raw microSD, or dedicated raw QSPI for full images and in-place deltas. Flash-backed stores +// deltas, raw microSD, or dedicated raw QSPI for full images and in-place deltas. One exact microSD +// target also accepts explicitly confirmed signed bootloader packages. Flash-backed stores // coalesce writes at their erase-page boundary and checkpoint payload before leaf metadata, so an // interrupted fetch can be verified and resumed without trusting stale progress markers. @@ -238,7 +239,8 @@ struct OtaContext { bool apply_fetched_bootloader(const uint8_t operator_mid[4], const uint8_t operator_hash8[8], char* msg) { #if defined(NRF52_PLATFORM) && !defined(OTA_SEEDER_ONLY) && \ - (defined(OTA_QSPI_BOOTLOADER_UPDATE) || defined(OTA_INTERNAL_BOOTLOADER_UPDATE)) + (defined(OTA_QSPI_BOOTLOADER_UPDATE) || defined(OTA_INTERNAL_BOOTLOADER_UPDATE) || \ + defined(OTA_SD_BOOTLOADER_UPDATE)) if (fetch_to_folder) { strncpy(msg, "refused: bootloader package is not in local install storage", 96); msg[95] = 0; return false; @@ -518,7 +520,8 @@ struct OtaContext { manager.set_apply_codec2(CODEC_DETOOLS_INPLACE); // also accepted -> a single in-place .mota fits both #endif #if defined(NRF52_PLATFORM) && \ - (defined(OTA_QSPI_BOOTLOADER_UPDATE) || defined(OTA_INTERNAL_BOOTLOADER_UPDATE)) + (defined(OTA_QSPI_BOOTLOADER_UPDATE) || defined(OTA_INTERNAL_BOOTLOADER_UPDATE) || \ + defined(OTA_SD_BOOTLOADER_UPDATE)) manager.set_accept_bootloader(true); #else manager.set_accept_bootloader(false); diff --git a/src/helpers/ota/OtaFlashLayout_nrf52.h b/src/helpers/ota/OtaFlashLayout_nrf52.h index 6648a62f..a87522f2 100644 --- a/src/helpers/ota/OtaFlashLayout_nrf52.h +++ b/src/helpers/ota/OtaFlashLayout_nrf52.h @@ -52,6 +52,7 @@ 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; +static const uint8_t GPREGRET2_OTA_STAGE_SD = 0x53u; inline uint8_t mota_nrf52_flash_stage_handoff(uint32_t effective_stage_ceiling) { if (effective_stage_ceiling == MOTA_NRF52_STAGE_CEILING_EXPANDED) diff --git a/src/helpers/ota/OtaSdBootToken.h b/src/helpers/ota/OtaSdBootToken.h new file mode 100644 index 00000000..d6201936 --- /dev/null +++ b/src/helpers/ota/OtaSdBootToken.h @@ -0,0 +1,73 @@ +// Internal authorization token for a bootloader package staged on removable SD. +// Keep byte-identical with OTAFIX src/ota_sd_boot_token.h. +#ifndef OTA_SD_BOOT_TOKEN_H_ +#define OTA_SD_BOOT_TOKEN_H_ + +#include +#include +#include + +#define MOTA_SD_BOOT_TOKEN_VERSION 1u +#define MOTA_SD_BOOT_TOKEN_LEN 64u +#define MOTA_SD_BOOT_TOKEN_IMAGE_HASH_OFFSET 24u +#define MOTA_SD_BOOT_TOKEN_CRC_OFFSET 56u + +static const uint8_t MOTA_SD_BOOT_TOKEN_MAGIC[8] = { + 'M', 'O', 'T', 'A', 'S', 'D', 'B', 'L' +}; + +static inline uint32_t mota_sd_boot_token_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); +} + +static inline void mota_sd_boot_token_wr32(uint8_t *p, uint32_t value) { + p[0] = (uint8_t)value; + p[1] = (uint8_t)(value >> 8); + p[2] = (uint8_t)(value >> 16); + p[3] = (uint8_t)(value >> 24); +} + +static inline uint32_t mota_sd_boot_token_crc32(const uint8_t *data, size_t len) { + uint32_t crc = 0xFFFFFFFFu; + for (size_t i = 0; i < len; i++) { + crc ^= data[i]; + for (uint8_t bit = 0; bit < 8; bit++) { + crc = (crc >> 1) ^ (0xEDB88320u & (uint32_t)-(int32_t)(crc & 1u)); + } + } + return ~crc; +} + +// image_hash is copied from the exact signed manifest buffer authenticated by +// the application. It binds the only bootloader bytes that the MBR can install. +static inline void mota_sd_boot_token_encode(uint8_t out[MOTA_SD_BOOT_TOKEN_LEN], + uint32_t total, + const uint8_t image_hash[32]) { + memset(out, 0, MOTA_SD_BOOT_TOKEN_LEN); + memcpy(out, MOTA_SD_BOOT_TOKEN_MAGIC, sizeof(MOTA_SD_BOOT_TOKEN_MAGIC)); + mota_sd_boot_token_wr32(out + 8u, MOTA_SD_BOOT_TOKEN_VERSION); + mota_sd_boot_token_wr32(out + 12u, MOTA_SD_BOOT_TOKEN_LEN); + mota_sd_boot_token_wr32(out + 16u, total); + mota_sd_boot_token_wr32(out + 20u, ~total); + memcpy(out + MOTA_SD_BOOT_TOKEN_IMAGE_HASH_OFFSET, image_hash, 32u); + const uint32_t crc = mota_sd_boot_token_crc32(out, MOTA_SD_BOOT_TOKEN_CRC_OFFSET); + mota_sd_boot_token_wr32(out + MOTA_SD_BOOT_TOKEN_CRC_OFFSET, crc); + mota_sd_boot_token_wr32(out + MOTA_SD_BOOT_TOKEN_CRC_OFFSET + 4u, ~crc); +} + +static inline int mota_sd_boot_token_valid(const uint8_t token[MOTA_SD_BOOT_TOKEN_LEN], + uint32_t total, + const uint8_t image_hash[32]) { + const uint32_t crc = mota_sd_boot_token_rd32(token + MOTA_SD_BOOT_TOKEN_CRC_OFFSET); + return memcmp(token, MOTA_SD_BOOT_TOKEN_MAGIC, sizeof(MOTA_SD_BOOT_TOKEN_MAGIC)) == 0 && + mota_sd_boot_token_rd32(token + 8u) == MOTA_SD_BOOT_TOKEN_VERSION && + mota_sd_boot_token_rd32(token + 12u) == MOTA_SD_BOOT_TOKEN_LEN && + mota_sd_boot_token_rd32(token + 16u) == total && + mota_sd_boot_token_rd32(token + 20u) == ~total && + memcmp(token + MOTA_SD_BOOT_TOKEN_IMAGE_HASH_OFFSET, image_hash, 32u) == 0 && + mota_sd_boot_token_crc32(token, MOTA_SD_BOOT_TOKEN_CRC_OFFSET) == crc && + mota_sd_boot_token_rd32(token + MOTA_SD_BOOT_TOKEN_CRC_OFFSET + 4u) == ~crc; +} + +#endif // OTA_SD_BOOT_TOKEN_H_ diff --git a/src/helpers/ota/OtaStoreSdNrf52.cpp b/src/helpers/ota/OtaStoreSdNrf52.cpp index 06b7909e..d4b27605 100644 --- a/src/helpers/ota/OtaStoreSdNrf52.cpp +++ b/src/helpers/ota/OtaStoreSdNrf52.cpp @@ -9,9 +9,13 @@ #include #include #include "MeshCore.h" +#include "OtaBootloaderUpdate.h" #include "OtaByteIO.h" #include "OtaFlashLayout_nrf52.h" +#include "OtaSdBootToken.h" #include "OtaSdHandoff.h" +#include "OtaSelf.h" +#include "flash/flash_nrf5x.h" #ifndef OTA_SD_CS_PIN #define OTA_SD_CS_PIN PIN_SPI1_NSS @@ -44,6 +48,7 @@ void OtaStoreSdNrf52::resetStoreState() { _allocated_sectors = 0; _partition_start = 0; _partition_end = 0; + _planned_bootloader = false; if (_file && *_file) _file->close(); if (_sd) _sd->end(); _mounted = false; @@ -370,7 +375,19 @@ bool OtaStoreSdNrf52::locate_file() { bool OtaStoreSdNrf52::plan_layout(bool, uint32_t image_size, uint32_t, uint32_t payload_size, bool is_bootloader) { - if (is_bootloader) return false; + _planned_bootloader = false; + if (is_bootloader) { +#if defined(OTA_SD_BOOTLOADER_UPDATE) + if (ota_bootloader_image_geometry_valid(image_size, payload_size)) { + _planned_bootloader = true; + return true; + } + fail("bootloader package geometry mismatch"); +#else + fail("bootloader update is not enabled for this SD target"); +#endif + return false; + } 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) { @@ -464,6 +481,7 @@ void OtaStoreSdNrf52::checkpoint() { bool OtaStoreSdNrf52::reopen() { _total = 0; + _planned_bootloader = false; if (!mount()) return false; if (*_file) _file->close(); if (!_file->open(PATH, O_RDWR)) return false; @@ -480,6 +498,32 @@ bool OtaStoreSdNrf52::reopen() { return false; } _total = total; + uint8_t manifest[MOTA_MFL]; + MotaManifest parsed; + if (total < 8u + sizeof(manifest) + 5u || !_file->seekSet(8u) || + _file->read(manifest, sizeof(manifest)) != (int)sizeof(manifest) || + !mota_parse_manifest(manifest, sizeof(manifest), parsed)) { + _total = 0; + _file->close(); + return false; + } + if (parsed.is_bootloader()) { +#if defined(OTA_SD_BOOTLOADER_UPDATE) + const uint64_t payload_off = 8u + sizeof(manifest) + + (uint64_t)parsed.block_count * 4u; + if (!ota_bootloader_image_geometry_valid(parsed.image_size, parsed.payload_size) || + payload_off + parsed.payload_size + 5u != total) { + _total = 0; + _file->close(); + return false; + } + _planned_bootloader = true; +#else + _total = 0; + _file->close(); + return false; +#endif + } if (!locate_file()) { _total = 0; _file->close(); @@ -490,6 +534,7 @@ bool OtaStoreSdNrf52::reopen() { void OtaStoreSdNrf52::clear() { _total = 0; + _planned_bootloader = false; _first_sector = 0; _allocated_sectors = 0; if (!mount()) return; @@ -498,14 +543,54 @@ void OtaStoreSdNrf52::clear() { _sd->remove(PATH); } -bool OtaStoreSdNrf52::approve_for_bootloader() { +bool OtaStoreSdNrf52::approve_for_bootloader( + const uint8_t expected_boot_image_hash[32]) { if (!_total || !finalize()) return false; +#if defined(OTA_SD_BOOTLOADER_UPDATE) + if (_planned_bootloader) { + SelfFwInfo fi; + if (!expected_boot_image_hash || + _total != MOTA_NRF52_BOOT_CONTAINER_SIZE || + !ota_self_firmware(fi) || + !ota_bootloader_scratch_headroom_valid( + fi.valid, mota_nrf52_app_base(), fi.image_len, + OTA_BOOT_SCRATCH_START) || + !ota_bootloader_live_bank_preserves_scratch( + mota_nrf52_app_base(), fi.image_len, OTA_BOOT_SCRATCH_START)) { + fail("running firmware/settings do not preserve boot scratch"); + return false; + } + } +#endif if (!write(8 + MOTA_OFF_APPROVAL, APPROVAL_YES, sizeof(APPROVAL_YES)) || !_file->sync() || !_sd->card()->syncDevice()) { fail("SD approval write failed"); return false; } +#if defined(OTA_SD_BOOTLOADER_UPDATE) + if (_planned_bootloader) { + uint8_t token[MOTA_SD_BOOT_TOKEN_LEN]; + mota_sd_boot_token_encode(token, _total, expected_boot_image_hash); + + // This page is outside the hash-valid live app and below InternalFS. The + // token is temporary: OTAFIX validates it against the current SD bytes, + // then consumes the page as the first bootloader scratch page. + flash_nrf5x_flush(); + if (!flash_nrf5x_erase(OTA_BOOT_SCRATCH_START) || + flash_nrf5x_write(OTA_BOOT_SCRATCH_START, token, sizeof(token)) < 0) { + fail("SD bootloader token flash write failed"); + return false; + } + flash_nrf5x_flush(); + if (memcmp((const void*)(uintptr_t)OTA_BOOT_SCRATCH_START, + token, sizeof(token)) != 0) { + fail("SD bootloader token flash verify failed"); + return false; + } + } +#endif + uint8_t sector[MOTA_SD_SECTOR_SIZE]; if (!_sd->card()->readSector(MOTA_SD_HANDOFF_SECTOR, sector)) { fail("SD handoff sector read failed"); diff --git a/src/helpers/ota/OtaStoreSdNrf52.h b/src/helpers/ota/OtaStoreSdNrf52.h index 02f2f10b..6662188b 100644 --- a/src/helpers/ota/OtaStoreSdNrf52.h +++ b/src/helpers/ota/OtaStoreSdNrf52.h @@ -18,6 +18,8 @@ class OtaCacheSdNrf52; // Persistent nRF52840 OTA store backed by the MeshTower V2 microSD socket. // The .mota remains a normal file, but is preallocated contiguously so the // bootloader can read it by raw sectors without embedding a FAT implementation. +// The exact self-update build uses this same one-file store for either an +// application package or an explicitly confirmed signed bootloader package. class OtaStoreSdNrf52 : public OtaStore { public: OtaStoreSdNrf52(); @@ -36,9 +38,14 @@ public: uint32_t payload_off, uint32_t payload_size, bool is_bootloader) override; - // Called only after the app has verified payload, base, signature and trust. - // Writes APRV into the file, then publishes the raw-sector handoff record. - bool approve_for_bootloader(); + // Called only after the app has verified package kind, payload, applicable + // base/identity, signature, trust, and storage-specific safety geometry. + // For a bootloader package it writes APRV, binds the exact authenticated + // signed image_hash to an internal-flash token, and only then publishes the + // raw-sector handoff. Application packages retain the ordinary APRV+handoff + // path and omit expected_boot_image_hash. + bool approve_for_bootloader( + const uint8_t expected_boot_image_hash[32] = nullptr); bool formatCard(MainBoard& board); bool eraseCard(MainBoard& board); bool getSpace(MainBoard& board, uint64_t& used_bytes, uint64_t& free_bytes); @@ -60,6 +67,7 @@ private: SdFs* _sd = nullptr; mutable FsFile* _file = nullptr; bool _mounted = false; + bool _planned_bootloader = false; uint32_t _total = 0; uint32_t _first_sector = 0; uint32_t _allocated_sectors = 0; diff --git a/src/helpers/ota/OtaVerify.cpp b/src/helpers/ota/OtaVerify.cpp index 73c304d7..358410e2 100644 --- a/src/helpers/ota/OtaVerify.cpp +++ b/src/helpers/ota/OtaVerify.cpp @@ -28,7 +28,9 @@ VerifyResult ota_verify(const uint8_t* buf, uint32_t len, const SignerAllowlist& return r; } -VerifyResult ota_verify(const OtaStore& store, const SignerAllowlist& allow) { +static VerifyResult ota_verify_store(const OtaStore& store, + const SignerAllowlist& allow, + const uint8_t* expected_manifest) { VerifyResult r; const uint32_t total = store.staged_size(); uint8_t hdr[8], manifest[MOTA_MFL], trailer[5]; @@ -36,6 +38,8 @@ VerifyResult ota_verify(const OtaStore& store, const SignerAllowlist& allow) { !store.read(0, hdr, sizeof(hdr)) || memcmp(hdr, MOTA_MAGIC, 4) != 0 || rd_u32le(hdr + 4) != total || !store.read(8, manifest, sizeof(manifest)) || + (expected_manifest && + memcmp(manifest, expected_manifest, sizeof(manifest)) != 0) || !store.read(total - 5, trailer, sizeof(trailer)) || memcmp(trailer, MOTA_TRAILER, sizeof(trailer)) != 0) return r; @@ -99,5 +103,15 @@ VerifyResult ota_verify(const OtaStore& store, const SignerAllowlist& allow) { return r; } +VerifyResult ota_verify(const OtaStore& store, const SignerAllowlist& allow) { + return ota_verify_store(store, allow, nullptr); +} + +VerifyResult ota_verify(const OtaStore& store, const SignerAllowlist& allow, + const uint8_t expected_manifest[MOTA_MFL]) { + if (!expected_manifest) return VerifyResult(); + return ota_verify_store(store, allow, expected_manifest); +} + } // namespace ota } // namespace mesh diff --git a/src/helpers/ota/OtaVerify.h b/src/helpers/ota/OtaVerify.h index 869728a0..397a1923 100644 --- a/src/helpers/ota/OtaVerify.h +++ b/src/helpers/ota/OtaVerify.h @@ -31,5 +31,11 @@ VerifyResult ota_verify(const uint8_t* buf, uint32_t len, const SignerAllowlist& // contiguous memory view (notably the MeshTower V2 SD-backed nRF52 store). VerifyResult ota_verify(const OtaStore& store, const SignerAllowlist& allow); +// As above, but the store's manifest must be byte-identical to an already +// authenticated local copy. This binds removable-media payload verification +// to the exact signed anchors later written into an internal approval token. +VerifyResult ota_verify(const OtaStore& store, const SignerAllowlist& allow, + const uint8_t expected_manifest[MOTA_MFL]); + } // namespace ota } // namespace mesh diff --git a/test/README.md b/test/README.md index 869d0e92..a75de6fd 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/` | v2 application/v3 bootloader parser separation; legacy XIAO and generic embedded identity, vector, capability, explicit-confirmation, codec-isolation, shared-slot placement/no-EndF erase gates, 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_ota` | `src/helpers/ota/` | v2 application/v3 bootloader parser separation; legacy XIAO, generic internal, and exact MeshTower V2 SD embedded identity, vector, capability, explicit-confirmation, codec-isolation, scratch-headroom/shared-slot no-EndF gates, 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 | app-v2 and boot-v3 traffic sharing `PAYLOAD_TYPE_OTA=0x0C` and the TempRadio suspend policy; opaque OTA relay behavior; 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 6ff0a70c..30a8b7a8 100644 --- a/test/test_ota/test_ota_core.cpp +++ b/test/test_ota/test_ota_core.cpp @@ -16,6 +16,8 @@ #include "helpers/ota/OtaManager.h" #include "helpers/ota/OtaApply.h" #include "helpers/ota/OtaBlInfo.h" +#include "helpers/ota/OtaFlashLayout_nrf52.h" +#include "helpers/ota/OtaSdBootToken.h" #include "mota_vectors.h" // auto-generated by tools/mota/gen_vectors.py extern "C" { @@ -41,12 +43,74 @@ TEST(OtaBootResult, AcceptsOnlyOtafixApplyDiagnostics) { EXPECT_EQ(ota_nrf52_boot_result_or_zero(0x00), 0x00); EXPECT_EQ(ota_nrf52_boot_result_or_zero(0x4C), 0x00); // shutdown reason EXPECT_EQ(ota_nrf52_boot_result_or_zero(0x51), 0x00); // QSPI handoff + EXPECT_EQ(ota_nrf52_boot_result_or_zero(0x53), 0x00); // SD handoff EXPECT_EQ(ota_nrf52_boot_result_or_zero(0xD4), 0x00); // legacy handoff EXPECT_EQ(ota_nrf52_boot_result_or_zero(0xED), 0x00); // expanded handoff EXPECT_EQ(ota_nrf52_boot_result_or_zero(0xE0), 0x00); // retired handoff remains non-result EXPECT_EQ(ota_nrf52_boot_result_or_zero(0xBD), 0x00); } +TEST(OtaBootPackage, StorageProfilesAndSdGeometryAreExact) { + EXPECT_EQ(OTA_BL_PROFILE_SD_BOOT_UPDATE, 0x09u); + EXPECT_EQ(OTA_BL_PROFILE_INTERNAL_BOOT_UPDATE, 0x0Au); + EXPECT_EQ(OTA_BL_PROFILE_QSPI_BOOT_UPDATE, 0x0Eu); + EXPECT_EQ(OTA_BL_REQUIRED_APP_CODEC_MASK, 0x0005u); + EXPECT_EQ(GPREGRET2_OTA_STAGE_SD, 0x53u); + EXPECT_TRUE(ota_bootloader_image_geometry_valid( + OTA_BOOT_IMAGE_SIZE, OTA_BOOT_IMAGE_SIZE)); + EXPECT_FALSE(ota_bootloader_image_geometry_valid( + OTA_BOOT_IMAGE_SIZE - 1u, OTA_BOOT_IMAGE_SIZE)); + EXPECT_FALSE(ota_bootloader_image_geometry_valid( + OTA_BOOT_IMAGE_SIZE, OTA_BOOT_IMAGE_SIZE - 1u)); + EXPECT_TRUE(ota_bootloader_scratch_headroom_valid( + true, MOTA_NRF52_APP_BASE_S140_V6, + OTA_BOOT_SCRATCH_START - MOTA_NRF52_APP_BASE_S140_V6, + OTA_BOOT_SCRATCH_START)); + EXPECT_FALSE(ota_bootloader_scratch_headroom_valid( + false, MOTA_NRF52_APP_BASE_S140_V6, 1u, OTA_BOOT_SCRATCH_START)); + EXPECT_FALSE(ota_bootloader_scratch_headroom_valid( + true, MOTA_NRF52_APP_BASE_S140_V6, + OTA_BOOT_SCRATCH_START - MOTA_NRF52_APP_BASE_S140_V6 + 1u, + OTA_BOOT_SCRATCH_START)); + + const uint32_t safe_span = OTA_BOOT_SCRATCH_START - MOTA_NRF52_APP_BASE_S140_V6; + const uint32_t live_size = safe_span - 0x1000u; + EXPECT_TRUE(ota_bootloader_scratch_bank_geometry_valid( + OTA_BOOT_BANK_ERASED_FALLBACK, 0xFFFFu, UINT32_MAX, live_size, safe_span)); + EXPECT_TRUE(ota_bootloader_scratch_bank_geometry_valid( + OTA_BOOT_BANK_VALID_APP, 0u, UINT32_MAX, live_size, safe_span)); + EXPECT_TRUE(ota_bootloader_scratch_bank_geometry_valid( + OTA_BOOT_BANK_VALID_APP, 1u, live_size, live_size, safe_span)); + EXPECT_FALSE(ota_bootloader_scratch_bank_geometry_valid( + OTA_BOOT_BANK_VALID_APP, 1u, live_size - 1u, live_size, safe_span)); + EXPECT_FALSE(ota_bootloader_scratch_bank_geometry_valid( + OTA_BOOT_BANK_VALID_APP, 1u, safe_span + 1u, live_size, safe_span)); + EXPECT_FALSE(ota_bootloader_scratch_bank_geometry_valid( + 0x00FFu, 0u, 0u, live_size, safe_span)); +} + +TEST(OtaBootPackage, SdApprovalTokenBindsTheAuthenticatedImageHash) { + uint8_t hash[32]; + for (uint8_t i = 0; i < sizeof(hash); i++) hash[i] = i; + uint8_t token[MOTA_SD_BOOT_TOKEN_LEN]; + mota_sd_boot_token_encode(token, MOTA_NRF52_BOOT_CONTAINER_SIZE, hash); + EXPECT_EQ(0, memcmp(token, "MOTASDBL", 8)); + EXPECT_EQ(mota_sd_boot_token_rd32(token + 8), 1u); + EXPECT_EQ(mota_sd_boot_token_rd32(token + 12), 64u); + EXPECT_EQ(mota_sd_boot_token_rd32(token + 16), MOTA_NRF52_BOOT_CONTAINER_SIZE); + EXPECT_EQ(mota_sd_boot_token_rd32(token + 20), ~MOTA_NRF52_BOOT_CONTAINER_SIZE); + EXPECT_EQ(0, memcmp(token + MOTA_SD_BOOT_TOKEN_IMAGE_HASH_OFFSET, hash, sizeof(hash))); + EXPECT_TRUE(mota_sd_boot_token_valid( + token, MOTA_NRF52_BOOT_CONTAINER_SIZE, hash)); + + token[MOTA_SD_BOOT_TOKEN_IMAGE_HASH_OFFSET] ^= 1u; + EXPECT_FALSE(mota_sd_boot_token_valid( + token, MOTA_NRF52_BOOT_CONTAINER_SIZE, hash)); + token[MOTA_SD_BOOT_TOKEN_IMAGE_HASH_OFFSET] ^= 1u; + EXPECT_FALSE(mota_sd_boot_token_valid( + token, MOTA_NRF52_BOOT_CONTAINER_SIZE + 1u, hash)); +} + TEST(OtaBootPackage, GenericIdentityUsesBoardAndNameForCollisionSafeTarget) { const uint32_t shared_board_id = 0x239A0029u; const uint8_t rak3401_name[16] = {'3','4','0','1','_','D','F','U',0,0,0,0,0,0,0,0}; @@ -209,7 +273,7 @@ TEST(OtaBootPackage, EmbeddedIdentityVectorCapsAndExplicitConfirmationGate) { 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 marker[16] = {'M','O','T','A','B','L','D','R', 3,0, 5,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)); @@ -218,7 +282,7 @@ TEST(OtaBootPackage, EmbeddedIdentityVectorCapsAndExplicitConfirmationGate) { std::vector internal_caps_image(64, 0xFF); const uint8_t internal_marker[16] = { - 'M','O','T','A','B','L','D','R', 3,0, 1,0, + 'M','O','T','A','B','L','D','R', 3,0, 5,0, (uint8_t)(OTA_BL_STORAGE_STAGE_CEILING | OTA_BL_STORAGE_BOOT_UPDATE), 0,0,0}; memcpy(internal_caps_image.data() + 4, internal_marker, sizeof(internal_marker)); EXPECT_TRUE(ota_bootloader_caps_from_image( @@ -237,6 +301,14 @@ TEST(OtaBootPackage, EmbeddedIdentityVectorCapsAndExplicitConfirmationGate) { OTA_BL_STORAGE_STAGE_CEILING | OTA_BL_STORAGE_BOOT_UPDATE, caps)); + std::vector full_only_caps(32, 0xFF); + memcpy(full_only_caps.data() + 4, internal_marker, sizeof(internal_marker)); + full_only_caps[4 + 10] = 1; + EXPECT_FALSE(ota_bootloader_caps_from_image( + full_only_caps.data(), full_only_caps.size(), + OTA_BL_STORAGE_STAGE_CEILING | OTA_BL_STORAGE_BOOT_UPDATE, + caps)); + OtaBlCaps selected; selected.present = true; selected.apply_abi = 3; @@ -270,7 +342,7 @@ TEST(OtaBootPackage, CapabilityScannerRejectsAnOtherwiseValidUnalignedMarker) { const uint8_t qspi_profile = OTA_BL_STORAGE_STAGE_CEILING | OTA_BL_STORAGE_QSPI | OTA_BL_STORAGE_BOOT_UPDATE; - const uint8_t marker[16] = {'M','O','T','A','B','L','D','R', 3,0, 1,0, + const uint8_t marker[16] = {'M','O','T','A','B','L','D','R', 3,0, 5,0, qspi_profile, 0,0,0}; uint8_t image[64]; memset(image, 0xFF, sizeof(image)); diff --git a/tools/mota/README.md b/tools/mota/README.md index 0c460fa4..1ef5001a 100644 --- a/tools/mota/README.md +++ b/tools/mota/README.md @@ -58,17 +58,21 @@ 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 OTAFIX region, 1024-byte blocks, full codec, zero base hash, a sane vector table, an exact embedded -manifest/CRC identity, and an ABI-3 marker retaining the selected storage plus -boot-update capabilities. Deployed XIAO packages retain their +manifest/CRC identity, and an ABI-3 marker retaining both application codecs +(`FULL|INPLACE`, mask `0x0005`), the selected storage, and boot-update +capabilities. Deployed XIAO packages retain their `XIAO_BL_28860044`/`XIAO_BL_28860045` IDs. Generic packages derive the padded `NRF_BL__` ID and collision-checked wire target from the full embedded manifest pair. Parsing and `verify()` repeat those gates; magic literals that are not complete valid structures are skipped. Generic parsing remains available for candidate inspection, but the signing -builder accepts only identities in the qualified internal inventory plus the -two deployed XIAO identities. The test suite verifies those boot target IDs are -unique and disjoint from the generated application target table. +builder accepts only identities in the qualified inventory. That inventory +currently covers the shared-internal nRF52840 targets, the two deployed XIAO +raw-QSPI identities, and the exact MeshTower V2 identity whose candidate +marker may select either its internal (`0x0A`) or microSD (`0x09`) application +layout. The test suite verifies those boot target IDs are unique and disjoint +from the generated application target table. 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 diff --git a/tools/mota/motalib.py b/tools/mota/motalib.py index 43381820..ff4b78d1 100644 --- a/tools/mota/motalib.py +++ b/tools/mota/motalib.py @@ -92,13 +92,17 @@ 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" +BOOT_STORAGE_SD = 0x01 BOOT_STORAGE_STAGE_CEILING = 0x02 XIAO_BOOT_STORAGE_QSPI = 0x04 XIAO_BOOT_STORAGE_UPDATE = 0x08 BOOT_STORAGE_KNOWN = 0x0F +BOOT_STORAGE_SD_UPDATE = BOOT_STORAGE_SD | XIAO_BOOT_STORAGE_UPDATE BOOT_STORAGE_QSPI_UPDATE = (BOOT_STORAGE_STAGE_CEILING | XIAO_BOOT_STORAGE_QSPI | XIAO_BOOT_STORAGE_UPDATE) BOOT_STORAGE_INTERNAL_UPDATE = BOOT_STORAGE_STAGE_CEILING | XIAO_BOOT_STORAGE_UPDATE +BOOT_REQUIRED_APP_CODEC_MASK = ((1 << CODEC_FULL) | + (1 << CODEC_DETOOLS_INPLACE)) # Exact embedded manifests for the qualified shared-internal nRF52840 # bootloaders. Parsing/inspection remains generic, but package creation is @@ -121,6 +125,14 @@ INTERNAL_BOOTLOADER_IDENTITIES = ( (0x239A0029, "RTAG_DFU"), ) +# MeshTower V2 has both a lean internal-store application and an exact SD-store +# application. They intentionally retain the same embedded bootloader identity +# and signed wire target; the candidate capability marker selects the matching +# storage contract and is checked again by the device before approval. +SD_BOOTLOADER_IDENTITIES = ( + (0x239A0071, "TOWER_V2_OTA"), +) + # 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. # This is deliberately target-specific: other nRF52 OTA builds still need NRF52_INPLACE_MEMORY above. @@ -659,9 +671,11 @@ def bootloader_caps_storage(image: bytes) -> Optional[int]: continue abi, codecs = struct.unpack_from("