From 177eb06745dc0b5bf3ba9b72c1dbd8182ca72767 Mon Sep 17 00:00:00 2001 From: mikecarper Date: Fri, 21 Aug 2026 00:55:01 -0700 Subject: [PATCH] Add shared-slot nRF52 bootloader updates --- boards/heltec_mesh_pocket.json | 3 + build.sh | 21 ++ docs/cli_command_availability.md | 8 +- docs/index.md | 1 + docs/lora_ota_automation.md | 8 +- docs/ota_easy.md | 12 +- docs/ota_nrf52_bootloader_update.md | 150 +++++++++++++ docs/ota_nrf52_qspi.md | 11 +- docs/ota_protocol.md | 46 +++- docs/ota_user_guide.md | 49 ++-- platformio.ini | 1 + scripts/nrf52_internal_bootloader_link.py | 56 +++++ src/helpers/ota/FolderMotaStore.h | 2 +- src/helpers/ota/OtaApply.cpp | 128 +++++++++-- src/helpers/ota/OtaApply.h | 11 + src/helpers/ota/OtaBlInfo.h | 53 ++++- src/helpers/ota/OtaBootloaderUpdate.h | 115 +++++++++- src/helpers/ota/OtaCacheSdNrf52.h | 2 +- src/helpers/ota/OtaCli.cpp | 98 +++++--- src/helpers/ota/OtaContext.h | 23 +- src/helpers/ota/OtaFlashLayout_nrf52.h | 95 ++++++++ src/helpers/ota/OtaManager.cpp | 19 +- src/helpers/ota/OtaManager.h | 7 + src/helpers/ota/OtaStore.h | 6 +- src/helpers/ota/OtaStoreFlashEsp32.cpp | 5 +- src/helpers/ota/OtaStoreFlashEsp32.h | 3 +- src/helpers/ota/OtaStoreFlashNrf52.cpp | 123 +++++++++- src/helpers/ota/OtaStoreFlashNrf52.h | 15 ++ src/helpers/ota/OtaStoreQspiNrf52.cpp | 3 +- src/helpers/ota/OtaStoreQspiNrf52.h | 3 +- src/helpers/ota/OtaStoreSdNrf52.cpp | 4 +- src/helpers/ota/OtaStoreSdNrf52.h | 3 +- src/helpers/ota/OtaTargets.h | 13 +- test/README.md | 4 +- test/test_ota/test_ota_core.cpp | 130 ++++++++++- test/test_ota/test_ota_flashplan.cpp | 127 ++++++++++- test/test_trace_retry/test_trace_retry.cpp | 50 +++++ tools/mota/README.md | 28 ++- tools/mota/gen_targets.py | 30 ++- tools/mota/motalib.py | 211 ++++++++++++++---- .../nrf52_internal_bootloader_targets.txt | 27 +++ tools/mota/pio_endf.py | 12 +- tools/mota/test_mota.py | 142 +++++++++++- 43 files changed, 1647 insertions(+), 211 deletions(-) create mode 100644 docs/ota_nrf52_bootloader_update.md create mode 100644 scripts/nrf52_internal_bootloader_link.py create mode 100644 tools/mota/nrf52_internal_bootloader_targets.txt diff --git a/boards/heltec_mesh_pocket.json b/boards/heltec_mesh_pocket.json index 6fb48a46..cceab042 100644 --- a/boards/heltec_mesh_pocket.json +++ b/boards/heltec_mesh_pocket.json @@ -8,6 +8,9 @@ "extra_flags": "-DNRF52840_XXAA", "f_cpu": "64000000L", "hwids": [ + ["0x239A", "0x8071"], + ["0x239A", "0x0071"], + ["0x239A", "0x8072"], ["0x239A", "0x4405"], ["0x239A", "0x0029"], ["0x239A", "0x002A"] diff --git a/build.sh b/build.sh index 6d124983..745c2e33 100755 --- a/build.sh +++ b/build.sh @@ -2394,6 +2394,26 @@ apply_nrf52_lora_ota_build_recipe() { if ! pio_env_option_contains "$pio_env_name" extra_scripts "tools/mota/pio_endf.py"; then append_platformio_extra_script "post:tools/mota/pio_endf.py" fi + + if supports_nrf52_internal_bootloader_update "$env_name"; then + export MESHCORE_NRF52_INTERNAL_BOOTLOADER_UPDATE=1 + else + unset MESHCORE_NRF52_INTERNAL_BOOTLOADER_UPDATE + fi +} + +# Exact-board OTAFIX manifests currently emitted for shared internal, +# app-preserving bootloader staging. Keep this list narrower than generic nRF52 +# OTA support: external QSPI/SD, ExtraFS Companions, Ethernet, and +# unqualified/full-sensor roles must not advertise this privileged path. The +# same inventory is consumed by the nrf52_base pre-script so direct explicit-env +# and build.sh release builds cannot silently differ. +supports_nrf52_internal_bootloader_update() { + [ "${PIO_ENV_PLATFORM_BY_NAME[$1]:-}" = "NRF52_PLATFORM" ] || return 1 + [ "${PIO_ENV_QSPI_OTA_BY_NAME[$1]:-0}" = "0" ] || return 1 + [ "${PIO_ENV_SD_OTA_BY_NAME[$1]:-0}" = "0" ] || return 1 + is_lora_ota_build "$1" || return 1 + grep -Fqx -- "$1" tools/mota/nrf52_internal_bootloader_targets.txt } apply_lora_ota_override() { @@ -2890,6 +2910,7 @@ build_firmware() { restore_platformio_build_flags "$had_platformio_build_flags" "$original_platformio_build_flags" unset MESHCORE_ESP32_FULL_BUILD unset MESHCORE_COMPANION_RADIO_FULL + unset MESHCORE_NRF52_INTERNAL_BOOTLOADER_UPDATE if [ "$had_platformio_build_unflags" -eq 1 ]; then export PLATFORMIO_BUILD_UNFLAGS="$original_platformio_build_unflags" else diff --git a/docs/cli_command_availability.md b/docs/cli_command_availability.md index 94c9f5e3..443cdaa0 100644 --- a/docs/cli_command_availability.md +++ b/docs/cli_command_availability.md @@ -165,8 +165,8 @@ fix, no WiFi connection, an inactive bridge, or an nRF52 bootloader without | LoRa OTA | [`ota ls`; `ota neighbors`; `ota nbrs`; `ota updates`; `ota n`](ota_protocol.md#11-cli-surface-otaclicpp) | LoRa OTA build | No | No | Yes | | 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 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 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,8 +325,8 @@ fix, no WiFi connection, an inactive bridge, or an nRF52 bootloader without | LoRa OTA | [`ota ls`; `ota neighbors`; `ota nbrs`; `ota updates`; `ota n`](ota_protocol.md#11-cli-surface-otaclicpp) | LoRa OTA build | No | No | Yes | Yes | Yes | | 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 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 | No | No | +| LoRa OTA | [`ota bootloader`; `ota bootloader install `](ota_nrf52_bootloader_update.md) | Allowlisted nRF52 internal/QSPI repeater feature | 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/index.md b/docs/index.md index 764541ea..da7c255d 100644 --- a/docs/index.md +++ b/docs/index.md @@ -12,6 +12,7 @@ Below are a few quick start guides. - [Easy LoRa OTA: ESP32 and nRF52 firmware updates](./ota_easy.md) - [Scripted LoRa OTA: Bash and PowerShell](./lora_ota_automation.md) - [nRF52 repeater OTA with external QSPI](./ota_nrf52_qspi.md) +- [nRF52 bootloader updates over LoRa](./ota_nrf52_bootloader_update.md) - [MeshTower V2 microSD self-updates](./ota_meshtower_v2_sdcard.md) - [GPS Tracking](./gps_tracking.md) - [Companion Protocol](./companion_protocol.md) diff --git a/docs/lora_ota_automation.md b/docs/lora_ota_automation.md index 99e38897..9063336e 100644 --- a/docs/lora_ota_automation.md +++ b/docs/lora_ota_automation.md @@ -16,10 +16,10 @@ controller, and check the rebooted node. 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. +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 +[`ota bootloader install`](ota_nrf52_bootloader_update.md) workflow. ## Required topology diff --git a/docs/ota_easy.md b/docs/ota_easy.md index 637acc76..a912fd1d 100644 --- a/docs/ota_easy.md +++ b/docs/ota_easy.md @@ -334,18 +334,22 @@ shows an older row. Select `[same target]`: it can say `full` for ESP32 and exte 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). +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). Use the row's stable eight-hex manifest ID rather than its changing list position: ```text ota pull 838B8169 flash ``` -If an internal-flash nRF52 reports `no EndF`, only a row marked `[rescue]` is eligible. Current rescue-capable +If a legacy internal-flash nRF52 reports `no EndF`, only a row marked `[rescue]` is eligible. Current rescue-capable firmware requires `ota pull flash rescue`, followed after completion by `ota rescue install `. Older running firmware without those commands must be recovered over USB. +Shared-internal bootloader-update builds are stricter: without a valid live +`EndF`, every internal pull is refused before erase because the normal +application may extend through `0xED000`. Recover those builds over USB/BLE DFU +or SWD instead of relying on the older 608 KiB rescue estimate. Monitor the transfer: diff --git a/docs/ota_nrf52_bootloader_update.md b/docs/ota_nrf52_bootloader_update.md new file mode 100644 index 00000000..dec06b36 --- /dev/null +++ b/docs/ota_nrf52_bootloader_update.md @@ -0,0 +1,150 @@ +# nRF52 bootloader updates over LoRa + +Selected nRF52840 repeater LoRa-OTA builds can replace their matching OTAFIX +bootloader without replacing the running application. This is a privileged +maintenance path, not a normal firmware update. It cannot bootstrap a stock or +older bootloader: install the exact ABI-3 self-update-capable OTAFIX build once +over USB/BLE DFU or SWD. + +## Storage layouts + +| Layout | Application limit | Staged boot package | Work area | Handoff / capability | +| --- | ---: | ---: | ---: | --- | +| 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` | + +The internal path does not change the application linker or permanently set +aside separate app-OTA, boot-package, and scratch regions. The ordinary +bottom-aligned internal store holds one container at a time: either an +application delta or a bootloader package. + +The exact bootloader container is 41,330 bytes: 365 bytes of signed mOTA +metadata, a 40 KiB payload, and the five-byte trailer. Below the normal +`0xED000` store ceiling it bottom-aligns at `0xE2000`. Admission requires a +hash-valid live `EndF` proving the current application, including its trailer, +ends at or before `0xE2000`. OTAFIX then reads each source window before +erasing and compacts the payload forward in place to the page-aligned raw range +`0xE2000..0xEC000`; it verifies every page and the whole image before asking the +MBR to copy that image over `0xF4000..0xFE000`. + +An ordinary application delta can be smaller or larger than this eleven-page +shape. It bottom-aligns dynamically below `0xED000` and may begin below +`0xE2000`; its detools workspace must stop at its actual container start, while +the reconstructed application must stop below `0xED000`. The two package kinds +are mutually exclusive because they use the same store. + +For internal-self-update builds, an absent or corrupt live `EndF` disables +**all** internal staging before the first erase. The older 608 KiB rescue +estimate is unsafe when a normally linked application may extend to +`0xED000`. Builds without this feature retain the legacy rescue behavior. + +## Internal-flash target inventory + +The release builder consumes the allowlist in +`tools/mota/nrf52_internal_bootloader_targets.txt`. Ten names are also literal +PlatformIO environments and are enabled automatically when built directly. +The other eleven are release aliases assembled from a base environment plus +the lean OTA overlay; build them through `build.sh`, which passes the same +allowlist decision to the common pre-build guard. These lean repeater/bridge +targets have no OTA-owned SD/QSPI store and have an exact curated OTAFIX +manifest identity: + +| Build target(s) | Installed OTAFIX identity | Boot target ID | +| --- | --- | ---: | +| `Heltec_tower_v2_repeater_lora_ota_no_external_sensors` | `239A0071 / TOWER_V2_OTA` | `1150F50E` | +| T096 lean repeater and RS232 bridge | `239A0071 / T096_DFU` | `42354C85` | +| `Heltec_t1_repeater_lora_ota_no_external_sensors` | `239A0071 / T1_DFU` | `FC556FFC` | +| T114 display and without-display lean repeaters | `239A0071 / T114_DFU` | `0C3F2902` | +| `Mesh_pocket_repeater_lora_ota_no_external_sensors` | `239A0071 / MESH_POCKET_OTA` | `059277F4` | +| `KeepteenLT1_repeater_lora_ota_no_external_sensors` | `239A00B3 / KeepteenLT1_OTA` | `DB2E7B51` | +| `Minewsemi_me25ls01_repeater_lora_ota_no_external_sensors` | `239A0029 / MX25_DFU` | `026AA982` | +| `ProMicro_repeater_lora_ota_no_external_sensors` | `239A00B3 / PROM_DFU` | `AF79E8CC` | +| `t1000e_repeater_lora_ota_no_external_sensors` | `28860057 / T1KE_DFU` | `E6F5F03F` | +| `ThinkNode_M3_repeater_lora_ota_no_external_sensors` | `239A00DA / TNM3_DFU` | `0CA41DB2` | +| `RAK_3401_repeater_lora_ota_no_external_sensors` | `239A0029 / 3401_DFU` | `23818A80` | +| RAK4631 lean repeater and both lean RS232 bridges | `239A0029 / 4631_DFU` | `2D0DF000` | +| GAT562 30S/Tracker Pro/EVB Pro and R1Neo lean carrier aliases | `239A0029 / 4631_DFU` | `2D0DF000` | +| `RAK_WisMesh_Tag_repeater_lora_ota_no_external_sensors` | `239A0029 / RTAG_DFU` | `C72E9C9C` | + +Board IDs are not globally unique. For generic targets, the signed hardware ID +is the exact NUL-padded 32-byte value +`NRF_BL__`. The wire target is the little-endian first +four SHA-256 bytes of all 32 padded bytes. The installed and candidate embedded +manifest pairs must match exactly. XIAO retains its deployed +`XIAO_BL_28860044` / `XIAO_BL_28860045` identity and raw board-ID target. + +The Python reference builder and release tooling audit these boot targets for +duplicates and collisions with application target IDs. Generic image parsing +can inspect a future canonical identity, but signing/building a package fails +until that exact identity is in the qualified inventory. + +The `no_external_sensors` profiles omit optional/add-on sensor and GPS packages +to preserve flash headroom. Board-integrated GPS can be retained by a board's +recipe. The RAK3401 lean target specifically omits RAK12501/add-on GPS; use the +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, +SD/QSPI/ExtraFS roles, Ethernet roles, source-only roles, and unqualified +full-sensor roles are excluded. + +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 +`0xF4000..0xFE000`. nRF52833 and smaller nRF52 parts cannot provide that map +plus a non-overlapping 41,330-byte live staging slot, so configuration fails +closed instead of selecting smaller or overlapping geometry. + +## Explicit install workflow + +Check the installed identity and capability marker: + +```text +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 +bootloader row is visible in `ota ls`, but it is never autofetched or +autoinstalled. Fetch its exact MID, then explicitly arm it: + +```text +ota pull flash +# wait for ota status to report the download ready +ota bootloader +ota bootloader install +``` + +Copy both confirmation values from the second `ota bootloader` response. +Ordinary `ota install` rejects a bootloader package; the bootloader command +rejects an application package. The FULL-codec exception exists only for that +manual bootloader MID. Ordinary application FULL remains disabled on an +internal single-slot node, bootloader autofetch remains off, and a partial +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 +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`. + +## 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. + +For XIAO and ordinary external-QSPI details, see +[nRF52 repeater LoRa OTA with external QSPI](ota_nrf52_qspi.md). diff --git a/docs/ota_nrf52_qspi.md b/docs/ota_nrf52_qspi.md index 12824337..05ccbcdc 100644 --- a/docs/ota_nrf52_qspi.md +++ b/docs/ota_nrf52_qspi.md @@ -176,6 +176,11 @@ enforces the package's hardware identity before approval. ## Explicit XIAO bootloader updates over LoRa +This section documents the deployed XIAO raw-QSPI layout. Curated nRF52840 +targets without external staging use a separate internal-flash layout with the +same explicit operator safety model; see +[nRF52 bootloader updates over LoRa](ota_nrf52_bootloader_update.md). + Bootloader delivery is available only when all of these are already true: - the application is an `OTA_QSPI_BOOTLOADER_UPDATE` XIAO-module repeater build @@ -212,8 +217,10 @@ 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 +OTAFIX path. `APRV` carries the app's signature/allowlist and explicit operator +authorization decision. OTAFIX independently rechecks the strict structure, +identity/capabilities, vectors, payload SHA, embedded CRC, and copy hashes +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`. diff --git a/docs/ota_protocol.md b/docs/ota_protocol.md index df93737e..4a44d4cd 100644 --- a/docs/ota_protocol.md +++ b/docs/ota_protocol.md @@ -62,7 +62,7 @@ the authoritative reference for byte-level details. | `EndF` marker | `45 6E 64 46` | `EndF` | | `hash_algo` (sha2-256) | `0x12` | multihash code | | application `format_ver` | `0x02` | ordinary full/delta application package | -| bootloader `format_ver` | `0x03` | privileged XIAO bootloader package only | +| bootloader `format_ver` | `0x03` | privileged exact-identity nRF52 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 | @@ -260,17 +260,19 @@ 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 +### 4.3 Privileged nRF52 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. +- a target derived from the installed CRC-valid embedded manifest identity. Deployed XIAO identities keep + raw board IDs `0x28860044`/`0x28860045`; generic identities use LE32(SHA-256(canonical padded hw32)); +- 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). 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 @@ -770,7 +772,7 @@ 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 [status] capable allowlisted nRF52 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) @@ -805,7 +807,9 @@ ota dev ... bring-up helpers (stage/recv/serve/verify) - **nRF52 internal staging ceiling:** an internal-store application derives the ceiling from facts available in every build, not a board-name list. A companion that actually links the internal ExtraFS datastore stays below `0xD4000`; a default linker region or a role that does not mount ExtraFS can reclaim the unused - 100 KiB through `0xED000`. The application uses the larger window only when the + 100 KiB through `0xED000`. An internal bootloader-self-update target keeps that normal linker and ceiling; + it does not reserve a second boot-package or scratch region. The application uses a larger-than-legacy + window only when the installed bootloader advertises the GPREGRET2 ceiling-handoff capability. The bootloader treats every unknown/legacy handoff value as `0xD4000`, and accepts a container only at the bottom-aligned position for the selected ceiling. @@ -832,6 +836,9 @@ ota dev ... bring-up helpers (stage/recv/serve/verify) fails, so a rescue-capable bridge can fetch its exact successor before invoking the guarded command. Such a node must acknowledge the condition up front with `ota pull flash rescue`; an ordinary flash pull refuses before altering staged data. Firmware that predates both rescue commands still requires USB recovery. + Internal-bootloader-self-update builds are a stricter exception: because their ordinary linker may extend + through `0xED000`, an absent/corrupt live `EndF` disables every internal staging pull before erase instead + of trusting the legacy 608 KiB estimate. - **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, @@ -852,10 +859,27 @@ ota dev ... bring-up helpers (stage/recv/serve/verify) `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` + `APRV` carries the app's authenticated and explicitly confirmed authorization decision; OTAFIX does not + repeat Ed25519/allowlist, Merkle, or typed operator confirmation. It independently rechecks the strict v3 + structure, canonical identity/capabilities, vectors, payload SHA, embedded CRC, and scratch/copy hashes, + 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. +- **Internal-flash bootloader self-update (explicit only):** curated nRF52840 lean repeater/bridge targets + without an OTA-owned SD/QSPI store share the normal bottom-aligned internal store below `0xED000`. + It holds either an ordinary app delta or the exact 41,330-byte v3 container, never both. The boot package + bottom-aligns at `0xE2000`; a hash-valid live `EndF` must prove the complete running image ends at or below + that address before the first erase. OTAFIX reads each source window before erasing and compacts the + payload forward in the same eleven pages to raw `0xE2000..0xEC000`; no separate scratch bank or special + application linker exists. GPREGRET `0x6B` plus GPREGRET2 `0xED` selects boot update, while ordinary app + apply uses GPREGRET `0x6A` plus the same storage source. Exact installed/candidate capability flags are + `0x0A` (`STAGE_CEILING|BOOT_UPDATE`). Ordinary deltas remain dynamically sized, may start below + `0xE2000`, and reconstruct only below the normal `0xED000` app ceiling. The same signature, explicit + confirmation, exact identity, vector, CRC, and single-marker rules as the XIAO path apply. Bootloader FULL + admission is isolated from ordinary application FULL policy, and privileged partials are never resumed + automatically after an application reboot. See + [the nRF52 bootloader-update guide](ota_nrf52_bootloader_update.md) for the exact target inventory. 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 diff --git a/docs/ota_user_guide.md b/docs/ota_user_guide.md index 09c31bcd..f304476f 100644 --- a/docs/ota_user_guide.md +++ b/docs/ota_user_guide.md @@ -216,21 +216,22 @@ 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) +### Updating an nRF52 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: +This is available on specially marked no-external-flash nRF52840 lean +repeater/bridge builds and on the legacy XIAO nRF52840/Sense raw-QSPI 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: +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 +`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 @@ -241,19 +242,25 @@ 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. +command requires an exact 40 KiB candidate payload in the fixed 41,330-byte +container, a valid exact 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. + +On an internal-flash target, the package shares the ordinary store below +`0xED000` and bottom-aligns at `0xE2000`; there is no separate reserved scratch +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. +See [the nRF52 bootloader-update guide](ota_nrf52_bootloader_update.md) for the +complete target inventory, storage layouts, and 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. -- An internal-flash **nRF52** that still runs but reports `no EndF` can use the pre-provisioned rescue path +- A legacy app-only internal-flash **nRF52** that still runs but reports `no EndF` can use the pre-provisioned rescue path if its physical EndF is intact and only app-side validation is failing. Fetch the exact `[rescue]` in-place delta with an explicit acknowledgement, obtain its 16-hex-digit `base_hash` from the package metadata, then run: @@ -269,6 +276,10 @@ for the complete safety contract. the running app and rejects a wrong base before writing the app. If the physical EndF is absent or the rescue commands were not already in the running firmware, recover over USB. Release chains should put this command in their first bridge and keep it in every bridge after that. + The shared-internal bootloader-update builds are intentionally excluded: + without valid live `EndF`, they refuse every internal pull before erase + because their normal application can extend through `0xED000`. Recover one + of those builds over USB/BLE DFU or SWD. - If an **install** fails, the node won't boot a broken image - it lands in **recovery mode**: - **nRF52:** it appears as a USB drive; drag a known-good firmware `.uf2` for that exact board onto it to recover. @@ -398,7 +409,7 @@ that only contains what changed). You get them by: | Download a listed update for installation | `ota get flash` | | Cancel a download | `ota cancel` | | Install a finished download | `ota install` | -| Recover app-side `no EndF` on internal nRF52 | `ota rescue install ` | +| Recover app-side `no EndF` on a legacy app-only internal nRF52 | `ota rescue install ` | | Turn on auto-download | `ota config autofetch any` | | Turn on auto-install (trusted only) | `ota config autoinstall trusted` | | Trust a signer | `ota key add ` | diff --git a/platformio.ini b/platformio.ini index c5bf8ff5..4e92e4d6 100644 --- a/platformio.ini +++ b/platformio.ini @@ -116,6 +116,7 @@ platform_packages = framework-arduinoadafruitnrf52 @ https://github.com/meshcore-dev/Adafruit_nRF52_Arduino#d541301 platformio/toolchain-gccarmnoneeabi@^1.140201.0 extra_scripts = create-uf2.py + pre:scripts/nrf52_internal_bootloader_link.py build_flags = ${arduino_base.build_flags} -D NRF52_PLATFORM -D LFS_NO_ASSERT=1 diff --git a/scripts/nrf52_internal_bootloader_link.py b/scripts/nrf52_internal_bootloader_link.py new file mode 100644 index 00000000..87e6b0c7 --- /dev/null +++ b/scripts/nrf52_internal_bootloader_link.py @@ -0,0 +1,56 @@ +"""Enable shared-slot internal bootloader update support on qualified nRF52840 targets. + +The bootloader package uses the ordinary bottom-aligned internal OTA store +below 0xED000; no second linker reservation exists. This pre-script only binds +the feature macro to the exact release inventory and fails closed if a target +does not use the normal S140 v6/v7 1 MiB application layout. +""" + +Import("env") + +import os +from pathlib import Path + + +inventory_path = Path(env["PROJECT_DIR"]) / "tools/mota/nrf52_internal_bootloader_targets.txt" +try: + direct_targets = { + line.strip() + for line in inventory_path.read_text(encoding="utf-8").splitlines() + if line.strip() and not line.lstrip().startswith("#") + } +except OSError as exc: + raise RuntimeError(f"cannot read nRF52 internal-bootloader inventory: {exc}") + +requested = ( + os.environ.get("MESHCORE_NRF52_INTERNAL_BOOTLOADER_UPDATE") == "1" + or env["PIOENV"] in direct_targets +) + +if requested: + # The macro is injected here so direct PIO and build.sh synthetic aliases + # cannot silently differ. Package admission is runtime-capacity based. + env.AppendUnique(CPPDEFINES=["OTA_INTERNAL_BOOTLOADER_UPDATE"]) + board = env.BoardConfig() + # Most variants inherit the framework's top-level ``build.ldscript``. + # A few otherwise ordinary Adafruit nRF52840 boards (notably Keepteen LT1) + # declare it under ``build.arduino.ldscript`` instead. Resolve both forms + # before making the safety decision; an unknown map still fails closed. + current = str( + board.get("build.ldscript", "") + or board.get("build.arduino.ldscript", "") + ) + mcu = str(board.get("build.mcu", "")).lower() + if mcu != "nrf52840" or not ( + current.endswith("nrf52840_s140_v6.ld") + or current.endswith("nrf52840_s140_v7.ld") + ): + raise RuntimeError( + "shared-slot bootloader update requires an nRF52840 with the normal " + f"S140 v6/v7 non-ExtraFS linker; found mcu={mcu or 'none'} " + f"linker={current or 'none'}" + ) + print( + "nRF52 shared-slot bootloader update: normal linker %s; runtime EndF headroom required" + % current + ) diff --git a/src/helpers/ota/FolderMotaStore.h b/src/helpers/ota/FolderMotaStore.h index c597bb53..cba8319d 100644 --- a/src/helpers/ota/FolderMotaStore.h +++ b/src/helpers/ota/FolderMotaStore.h @@ -40,7 +40,7 @@ public: // A host-backed store is fully random-access, so it needs no pinned-meta RAM page and no layout planning. bool set_meta_size(uint32_t) override { return true; } - bool plan_layout(bool, uint32_t, uint32_t, uint32_t) override { return true; } + bool plan_layout(bool, uint32_t, uint32_t, uint32_t, bool) override { return true; } private: // One request/response transaction over the seeder link (mirrors SerialMotaSource). `arglen` is uint16 so diff --git a/src/helpers/ota/OtaApply.cpp b/src/helpers/ota/OtaApply.cpp index 99e47386..42adda60 100644 --- a/src/helpers/ota/OtaApply.cpp +++ b/src/helpers/ota/OtaApply.cpp @@ -32,6 +32,9 @@ #if defined(OTA_QSPI_STORE) #include "OtaStoreQspiNrf52.h" #endif + #if defined(OTA_FLASH_STORE) + #include "OtaStoreFlashNrf52.h" + #endif #endif namespace mesh { @@ -462,8 +465,7 @@ void ota_reboot_to_apply() { // public: set the apply magic + #if defined(OTA_QSPI_STORE) stage_handoff = GPREGRET2_OTA_STAGE_QSPI; #elif defined(OTA_FLASH_STORE) - if (ota_nrf52_effective_stage_ceiling() == MOTA_NRF52_STAGE_CEILING_EXPANDED) - stage_handoff = GPREGRET2_OTA_STAGE_EXPANDED; + stage_handoff = mota_nrf52_flash_stage_handoff(ota_nrf52_effective_stage_ceiling()); #endif uint8_t sd_en = 0; sd_softdevice_is_enabled(&sd_en); @@ -480,15 +482,19 @@ void ota_reboot_to_apply() { // public: set the apply magic + } void ota_reboot_to_bootloader_update() { + uint8_t source = GPREGRET2_OTA_STAGE_QSPI; +#if defined(OTA_INTERNAL_BOOTLOADER_UPDATE) + source = GPREGRET2_OTA_STAGE_EXPANDED; +#endif 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_set(1, source); 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->GPREGRET2 = source; NRF_POWER->GPREGRET = GPREGRET_OTA_BOOTLOADER_UPDATE; } NVIC_SystemReset(); @@ -584,10 +590,16 @@ static bool ota_apply_mota_nrf52_impl(const uint8_t* buf, uint32_t len, } #if defined(OTA_FLASH_STORE) const uint32_t app_base = mota_nrf52_app_base(); + const uint32_t app_ceiling = mota_nrf52_application_ceiling(); const uint32_t mota_start = (uint32_t)(uintptr_t)buf; const uint32_t stage_ceiling = ota_nrf52_effective_stage_ceiling(); - if (mota_start < app_base || mota_start >= stage_ceiling || - d.memory > mota_start - app_base) { + if (!mota_nrf52_target_image_fits(app_base, m.image_size, app_ceiling)) { + strcpy(msg, "target image exceeds linked application region"); + return false; + } + if (mota_start >= stage_ceiling || + !mota_nrf52_internal_patch_workspace_valid( + d.memory, app_base, mota_start, m.image_size, app_ceiling)) { snprintf(msg, 159, "patch memory 0x%x exceeds staging at 0x%x", (unsigned)d.memory, (unsigned)mota_start); return false; @@ -779,6 +791,7 @@ static bool qspi_bootloader_image_metadata(OtaStoreQspiNrf52& store, uint32_t pa static const uint32_t STEP = 512; uint8_t buf[STEP + OTA_BOOT_MANIFEST_SIZE - 1]; uint8_t valid_identities = 0; + uint8_t valid_caps = 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); @@ -787,14 +800,14 @@ static bool qspi_bootloader_image_metadata(OtaStoreQspiNrf52& store, uint32_t pa ? 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) { + if ((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; + (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; } } if ((absolute & 3u) != 0 || local + OTA_BOOT_MANIFEST_SIZE > len) continue; @@ -808,7 +821,8 @@ static bool qspi_bootloader_image_metadata(OtaStoreQspiNrf52& store, uint32_t pa } } } - return valid_identities == 1u && candidate.present && candidate.crc_ok && caps.present; + return valid_identities == 1u && valid_caps == 1u && + candidate.present && candidate.crc_ok && caps.present; } bool ota_prepare_bootloader_update_nrf52(OtaStoreQspiNrf52& store, @@ -844,10 +858,7 @@ bool ota_prepare_bootloader_update_nrf52(OtaStoreQspiNrf52& store, } 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)) { + if (!ota_bootloader_self_update_caps_valid(current_caps)) { strcpy(msg, "installed bootloader cannot safely self-update from QSPI"); return false; } @@ -893,6 +904,93 @@ bool ota_prepare_bootloader_update_nrf52(OtaStoreQspiNrf52& store, #endif #endif +#if defined(OTA_INTERNAL_BOOTLOADER_UPDATE) && defined(OTA_FLASH_STORE) +bool ota_prepare_bootloader_update_nrf52(OtaStoreFlashNrf52& 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(); + const uint8_t* container = store.data(); + MotaManifest m; + if (!container || total < 8u + MOTA_MFL + 5u || !mota_parse(container, total, m)) { + strcpy(msg, "internal bootloader container parse failed"); 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 target identity 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 (!ota_bootloader_self_update_caps_valid(current_caps) || + current_caps.storage_flags != (OTA_BL_STORAGE_STAGE_CEILING | + OTA_BL_STORAGE_BOOT_UPDATE)) { + strcpy(msg, "installed bootloader cannot safely self-update from internal flash"); return false; + } + + const uint64_t payload_off64 = 8u + MOTA_MFL + (uint64_t)m.block_count * 4u; + SelfFwInfo fi; + uint32_t expected_start; + const uint32_t app_base = mota_nrf52_app_base(); + if (payload_off64 > UINT32_MAX || payload_off64 + OTA_BOOT_IMAGE_SIZE + 5u != total || + !ota_self_firmware(fi) || !fi.valid || fi.image_len > UINT32_MAX - app_base || + !mota_nrf52_shared_boot_stage_plan( + total, app_base, true, app_base + fi.image_len, expected_start) || + store.write_start() != expected_start) { + strcpy(msg, "internal bootloader container placement mismatch"); return false; + } + const uint32_t payload_off = (uint32_t)payload_off64; + + VerifyResult vr = ota_verify(container, total, 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; + } + + const uint8_t* image = container + payload_off; + if (!ota_bootloader_vector_sane(image)) { + strcpy(msg, "bootloader vector table is invalid"); return false; + } + OtaBootloaderIdentity candidate; + OtaBootloaderCapsMarker candidate_caps; + if (!ota_bootloader_identity_from_image(image, OTA_BOOT_IMAGE_SIZE, candidate) || + !ota_bootloader_identity_matches(installed, candidate) || + !ota_bootloader_caps_from_image( + image, OTA_BOOT_IMAGE_SIZE, + OTA_BL_STORAGE_STAGE_CEILING | OTA_BL_STORAGE_BOOT_UPDATE, + candidate_caps)) { + strcpy(msg, "candidate bootloader identity/capability/CRC mismatch"); return false; + } + st.slot_ok = true; + if (!store.approve_for_bootloader()) { + snprintf(msg, CAP, "internal bootloader handoff failed: %s", store.last_error()); return false; + } + strcpy(msg, "trusted bootloader verified in internal flash; rebooting after this reply"); + return true; +} +#endif + #else // native / other platforms bool ota_apply_slot_info(uint32_t*, uint32_t*) { return false; } diff --git a/src/helpers/ota/OtaApply.h b/src/helpers/ota/OtaApply.h index 7b81cb03..cb6030da 100644 --- a/src/helpers/ota/OtaApply.h +++ b/src/helpers/ota/OtaApply.h @@ -101,6 +101,17 @@ bool ota_prepare_bootloader_update_nrf52(OtaStoreQspiNrf52& store, ApplyState& st, char* msg); #endif #endif +#if defined(NRF52_PLATFORM) && defined(OTA_FLASH_STORE) && \ + defined(OTA_INTERNAL_BOOTLOADER_UPDATE) +class OtaStoreFlashNrf52; +bool ota_prepare_bootloader_update_nrf52(OtaStoreFlashNrf52& 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 // Commit the (already approved/armed) update and reboot into it - does NOT return. Call this only after // a successful ota_apply_* AND after the confirmation reply has been delivered, so the operator knows diff --git a/src/helpers/ota/OtaBlInfo.h b/src/helpers/ota/OtaBlInfo.h index e26e757a..1a0ded37 100644 --- a/src/helpers/ota/OtaBlInfo.h +++ b/src/helpers/ota/OtaBlInfo.h @@ -33,6 +33,27 @@ 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; +static const uint8_t OTA_BL_STORAGE_KNOWN = 0x0F; + +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; +#elif defined(OTA_QSPI_BOOTLOADER_UPDATE) + return OTA_BL_STORAGE_STAGE_CEILING | OTA_BL_STORAGE_QSPI | + OTA_BL_STORAGE_BOOT_UPDATE; +#else + return 0; +#endif +} + +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; +} // 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 @@ -53,17 +74,33 @@ inline bool ota_bl_caps_prefer(const OtaBlCaps& current, uint16_t candidate_abi, // 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) { + bool require_boot_update, + uint8_t exact_storage_flags = 0) { OtaBlCaps c; if (!bytes) return c; + uint8_t privileged_count = 0; 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 || + 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 && + (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 + // two copies of the expected exact profile). Malformed magic/literal + // decoys were filtered above and do not count. + if (++privileged_count != 1u) return OtaBlCaps(); + c.present = true; + c.apply_abi = abi; + c.codec_mask = codecs; + c.storage_flags = storage; + continue; + } if (ota_bl_caps_prefer(c, abi, storage, require_boot_update)) { c.present = true; c.apply_abi = abi; @@ -71,6 +108,7 @@ inline OtaBlCaps ota_bl_caps_scan_aligned(const uint8_t* bytes, size_t len, c.storage_flags = storage; } } + if (require_boot_update && privileged_count != 1u) return OtaBlCaps(); return c; } @@ -80,8 +118,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) - true +#if defined(OTA_QSPI_BOOTLOADER_UPDATE) || defined(OTA_INTERNAL_BOOTLOADER_UPDATE) + true, ota_bootloader_update_storage_flags() #else false #endif @@ -103,11 +141,18 @@ inline bool ota_bootloader_can_apply(uint8_t format_ver, uint8_t codec_id) { // the legacy 0xD4000 ceiling. Packages sized for the expanded window still require the newer bootloader. inline uint32_t ota_nrf52_effective_stage_ceiling(const OtaBlCaps& c) { const uint32_t desired = mota_nrf52_layout_stage_ceiling(); +#if defined(OTA_INTERNAL_BOOTLOADER_UPDATE) + if (desired != MOTA_NRF52_STAGE_CEILING_EXPANDED || + !ota_bootloader_self_update_caps_valid(c)) + return MOTA_NRF52_STAGE_CEILING_LEGACY; + return desired; +#else if (desired == MOTA_NRF52_STAGE_CEILING_EXPANDED) { if (!c.present || !(c.storage_flags & OTA_BL_STORAGE_STAGE_CEILING)) return MOTA_NRF52_STAGE_CEILING_LEGACY; } return desired; +#endif } inline uint32_t ota_nrf52_effective_stage_ceiling() { diff --git a/src/helpers/ota/OtaBootloaderUpdate.h b/src/helpers/ota/OtaBootloaderUpdate.h index b016ed0b..f3f0e9ca 100644 --- a/src/helpers/ota/OtaBootloaderUpdate.h +++ b/src/helpers/ota/OtaBootloaderUpdate.h @@ -2,9 +2,13 @@ #include #include +#include #include #include "MotaContainer.h" +#include "Multihash.h" +#include "OtaBlInfo.h" +#include "OtaByteIO.h" #if defined(OTA_QSPI_BOOTLOADER_UPDATE) #if !defined(NRF52_PLATFORM) || !defined(OTA_QSPI_STORE) @@ -18,6 +22,15 @@ #endif #endif +#if defined(OTA_INTERNAL_BOOTLOADER_UPDATE) + #if !defined(NRF52_PLATFORM) || !defined(OTA_FLASH_STORE) + #error "OTA_INTERNAL_BOOTLOADER_UPDATE requires nRF52 internal-flash staging" + #endif + #if defined(OTA_QSPI_STORE) || defined(OTA_SD_STORE) || defined(QSPIFLASH) + #error "internal bootloader staging cannot share an external OTA/filesystem store" + #endif +#endif + namespace mesh { namespace ota { @@ -83,8 +96,30 @@ 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_bootloader_board_id_valid(uint32_t board_id) { + return board_id != 0u && board_id != UINT32_MAX; +} + +// New exact-board manifests use one to fifteen printable ASCII bytes followed by zero padding. Requiring +// a terminator keeps the canonical signed hw_id at 31 bytes or less. The two already-deployed XIAO +// identities retain their exact legacy name and signed hardware IDs. +inline bool ota_bootloader_device_name_valid(uint32_t board_id, const uint8_t name[16]) { + if (!name) return false; + if (ota_xiao_bootloader_board_id(board_id)) + return memcmp(name, OTA_XIAO_BOOT_DEVICE_NAME, OTA_BOOT_DEVICE_NAME_SIZE) == 0; + size_t end = 0; + while (end < OTA_BOOT_DEVICE_NAME_SIZE && name[end] != 0) { + if (name[end] < 0x21u || name[end] > 0x7Eu) return false; + end++; + } + if (end == 0 || end == OTA_BOOT_DEVICE_NAME_SIZE) return false; + for (size_t i = end; i < OTA_BOOT_DEVICE_NAME_SIZE; i++) + if (name[i] != 0) return false; + return true; +} + +// Legacy XIAO signed .mota hw_id. It is intentionally distinct from application hw_id values and is +// derived from the exact embedded board ID for compatibility with already-deployed packages. 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); @@ -94,6 +129,37 @@ inline bool ota_xiao_bootloader_hw_id(uint32_t board_id, uint8_t out[32]) { return true; } + +// Canonical generic bootloader identity. The board ID alone is not unique (several nRF52 families use +// the same USB VID/PID-derived ID), so the signed identity includes the complete embedded device name. +// Hashing all 32 padded bytes produces the bootloader package's collision-checked wire target ID. +inline bool ota_bootloader_hw_id(uint32_t board_id, const uint8_t device_name[16], + uint8_t out[32]) { + if (!out || !ota_bootloader_board_id_valid(board_id) || + !ota_bootloader_device_name_valid(board_id, device_name)) return false; + if (ota_xiao_bootloader_board_id(board_id)) return ota_xiao_bootloader_hw_id(board_id, out); + static const char hex[] = "0123456789ABCDEF"; + memset(out, 0, 32); + memcpy(out, "NRF_BL_", 7); + for (uint8_t i = 0; i < 8; i++) + out[7 + i] = (uint8_t)hex[(board_id >> (28u - 4u * i)) & 0x0Fu]; + out[15] = '_'; + size_t n = 0; + while (n < 15 && device_name[n]) n++; + memcpy(out + 16, device_name, n); + return true; +} + +inline bool ota_bootloader_hw_id(const OtaBootloaderIdentity& identity, uint8_t out[32]); + +inline uint32_t ota_bootloader_target_id(uint32_t board_id, const uint8_t device_name[16]) { + if (ota_xiao_bootloader_board_id(board_id)) return board_id; + uint8_t hw_id[32], digest[4]; + if (!ota_bootloader_hw_id(board_id, device_name, hw_id)) return 0; + mh4(digest, hw_id, sizeof(hw_id)); + return rd_u32le(digest); +} + inline uint32_t ota_boot_crc32_update(uint32_t crc, uint8_t value) { crc ^= value; for (uint8_t bit = 0; bit < 8; bit++) @@ -122,8 +188,8 @@ inline bool ota_bootloader_manifest_parse(const uint8_t* raw, uint32_t raw_offse 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; + !ota_bootloader_board_id_valid(ota_boot_rd32(raw + 20)) || + !ota_bootloader_device_name_valid(ota_boot_rd32(raw + 20), raw + 24)) return false; out = OtaBootloaderIdentity(); out.present = true; @@ -137,6 +203,18 @@ inline bool ota_bootloader_manifest_parse(const uint8_t* raw, uint32_t raw_offse return true; } +inline bool ota_bootloader_hw_id(const OtaBootloaderIdentity& identity, uint8_t out[32]) { + return identity.present && identity.crc_ok && + ota_bootloader_hw_id(identity.board_id, + reinterpret_cast(identity.device_name), out); +} + +inline uint32_t ota_bootloader_target_id(const OtaBootloaderIdentity& identity) { + return identity.present && identity.crc_ok + ? ota_bootloader_target_id(identity.board_id, + reinterpret_cast(identity.device_name)) : 0; +} + // 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, @@ -187,7 +265,7 @@ inline bool ota_bootloader_caps_marker_parse(const uint8_t raw[16], 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[10] == 0 && raw[11] == 0) || (raw[12] & ~OTA_BL_STORAGE_KNOWN) != 0 || raw[13] != 0 || raw[14] != 0 || raw[15] != 0) return false; out.present = true; out.apply_abi = ota_boot_rd16(raw + 8); @@ -196,6 +274,25 @@ inline bool ota_bootloader_caps_marker_parse(const uint8_t raw[16], return true; } +inline bool ota_bootloader_caps_from_image(const uint8_t* image, size_t image_size, + uint8_t exact_storage_flags, + OtaBootloaderCapsMarker& out) { + out = OtaBootloaderCapsMarker(); + if (!image || exact_storage_flags == 0) return false; + uint8_t valid_count = 0; + for (size_t off = 0; off + 16u <= image_size; off += 4u) { + 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.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; + out = parsed; + } + return valid_count == 1u && out.present; +} + enum OtaBootloaderConfirmGate : uint8_t { OTA_BOOT_CONFIRM_OK = 0, OTA_BOOT_CONFIRM_NOT_BOOT_PACKAGE, @@ -223,11 +320,13 @@ inline OtaBootloaderConfirmGate ota_bootloader_confirmation_gate( 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)) + !ota_bootloader_board_id_valid(installed.board_id) || + !ota_bootloader_device_name_valid( + installed.board_id, reinterpret_cast(installed.device_name))) return OTA_BOOT_CONFIRM_LOCAL_IDENTITY; - if (m.target_id != installed.board_id) return OTA_BOOT_CONFIRM_TARGET; + if (m.target_id != ota_bootloader_target_id(installed)) return OTA_BOOT_CONFIRM_TARGET; uint8_t expected_hw[32]; - if (!ota_xiao_bootloader_hw_id(installed.board_id, expected_hw) || !m.hw_id || + if (!ota_bootloader_hw_id(installed, 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 || diff --git a/src/helpers/ota/OtaCacheSdNrf52.h b/src/helpers/ota/OtaCacheSdNrf52.h index 9181c80a..b8595767 100644 --- a/src/helpers/ota/OtaCacheSdNrf52.h +++ b/src/helpers/ota/OtaCacheSdNrf52.h @@ -55,7 +55,7 @@ public: bool finalize() override; void checkpoint() override; bool reopen() override; - bool plan_layout(bool, uint32_t, uint32_t, uint32_t) override { return true; } + bool plan_layout(bool, uint32_t, uint32_t, uint32_t, bool) override { return true; } // MotaSource: completed files are advertised and read directly from SD on demand. uint8_t count() override { return _initialized ? _count : 0; } diff --git a/src/helpers/ota/OtaCli.cpp b/src/helpers/ota/OtaCli.cpp index 80a82f13..319755cc 100644 --- a/src/helpers/ota/OtaCli.cpp +++ b/src/helpers/ota/OtaCli.cpp @@ -170,9 +170,15 @@ bool handle_ota_command(const char* command, char* reply, mesh::MainBoard& board "announce | folder | config. LoRa install is disabled."); #elif defined(NRF52_PLATFORM) && defined(OTA_FLASH_STORE) && !defined(OTA_SD_STORE) && \ !defined(OTA_QSPI_STORE) +#if defined(OTA_INTERNAL_BOOTLOADER_UPDATE) + strcpy(reply, + "OTA: status | stats | ls | get flash | install | " + "bootloader | cancel | announce | self | folder | config | key"); +#else strcpy(reply, "OTA: status | stats | ls | get flash [rescue] | install | rescue install | " "cancel | announce | self | folder | config | key"); +#endif #elif defined(NRF52_PLATFORM) && defined(OTA_QSPI_STORE) #if defined(OTA_QSPI_BOOTLOADER_UPDATE) strcpy(reply, @@ -220,10 +226,6 @@ bool handle_ota_command(const char* command, char* reply, mesh::MainBoard& board } const char* hw = (c.hw_id[0]) ? c.hw_id : "?"; const char* tenv = ota_target_env_name(c.manager.target()); // env name, or "?" if not in the table - int n = snprintf(reply, 160, "OTA | this fw %s (%uK) hw=%s | %s | serving:%s (%u) | keys:%u | target:%08X (%s)", - selfhx, (unsigned)((s ? fi.image_len : 0) / 1024), hw, dl, - c.serving ? "on" : "off", (unsigned)c.manager.servedCount(), - (unsigned)c.allow.count(), (unsigned)c.manager.target(), tenv ? tenv : "?"); #if defined(NRF52_PLATFORM) // nRF52 applies via the bootloader - show (cached) whether it can, so `ota get`/`install` won't surprise. // blrc = the bootloader's last apply code (diagnostic; 0xB8=success, see ota_delta.c). @@ -238,12 +240,23 @@ bool handle_ota_command(const char* command, char* reply, mesh::MainBoard& board const char* bl_state = bl.present ? "apply" : "NONE"; #endif 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); - } + const char* rc_name = ota_nrf52_boot_update_result(last_rc) ? "blup" : "blrc"; + // Keep the fixed-width parser/diagnostic fields ahead of the variable download text and optional + // human target name. hw is bounded to 32 bytes, so target + the complete blup/blrc token always fit + // in the 160-byte reply; snprintf may truncate only the descriptive tail. + snprintf(reply, 160, + "OTA | this fw %s (%uK) hw=%s | target:%08X | bl:%s %s:%02X | %s | " + "serving:%s (%u) | keys:%u | env:%s", + selfhx, (unsigned)((s ? fi.image_len : 0) / 1024), hw, + (unsigned)c.manager.target(), bl_state, rc_name, last_rc, dl, + c.serving ? "on" : "off", (unsigned)c.manager.servedCount(), + (unsigned)c.allow.count(), tenv ? tenv : "?"); +#else + snprintf(reply, 160, + "OTA | this fw %s (%uK) hw=%s | %s | serving:%s (%u) | keys:%u | target:%08X (%s)", + selfhx, (unsigned)((s ? fi.image_len : 0) / 1024), hw, dl, + c.serving ? "on" : "off", (unsigned)c.manager.servedCount(), + (unsigned)c.allow.count(), (unsigned)c.manager.target(), tenv ? tenv : "?"); #endif #endif @@ -334,21 +347,20 @@ bool handle_ota_command(const char* command, char* reply, mesh::MainBoard& board uint32_t age = (now - h->last_ms) / 1000; if (age > 99999) age = 99999; char ver[20]; ver_str(ver, sizeof ver, h->fw_version); // Target equality alone is not an install-safety claim. Surface local codec/bootloader limitations, - // and flag the explicit rescue path when this internal-flash nRF52 has no valid running EndF. + // and flag the explicit legacy rescue path when an app-only internal-flash nRF52 has no valid EndF. char hwbuf[16]; const char* fit; const char* env = ota_target_env_name(h->target_id); 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) +#if defined(NRF52_PLATFORM) && \ + (defined(OTA_QSPI_BOOTLOADER_UPDATE) || defined(OTA_INTERNAL_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); + h->target_id == ota_bootloader_target_id(bid) && + ota_bootloader_self_update_caps_valid(list_bl); #endif fit = installable ? "yours" : "bootloader unsupported"; } else if (myt && h->target_id == myt) { @@ -363,7 +375,9 @@ bool handle_ota_command(const char* command, char* reply, mesh::MainBoard& board #endif #endif if (!installable) fit = "unsupported"; -#if defined(NRF52_PLATFORM) && defined(OTA_FLASH_STORE) && !defined(OTA_SD_STORE) && \ +#if defined(NRF52_PLATFORM) && defined(OTA_INTERNAL_BOOTLOADER_UPDATE) + else if (!list_has_endf) fit = "no EndF; local recovery"; +#elif defined(NRF52_PLATFORM) && defined(OTA_FLASH_STORE) && !defined(OTA_SD_STORE) && \ !defined(OTA_QSPI_STORE) else if (!list_has_endf) fit = "rescue"; #endif @@ -381,6 +395,12 @@ bool handle_ota_command(const char* command, char* reply, mesh::MainBoard& board // ---- start fetching a specific catalogued mOTA (by list index or manifest_id) ---- } else if (is_cmd(a, "pull|get|download", &rest)) { +#if defined(NRF52_PLATFORM) && defined(OTA_FLASH_STORE) && !defined(OTA_SD_STORE) && \ + !defined(OTA_QSPI_STORE) && !defined(OTA_INTERNAL_BOOTLOADER_UPDATE) + const char* pull_usage = "usage: ota pull flash [rescue] | folder [validate] (see `ota ls`)"; +#else + const char* pull_usage = "usage: ota pull flash | folder [validate] (see `ota ls`)"; +#endif const char* p = rest; while (*p == ' ') p++; // split into " [destination]": selector = #N / N (catalogue index) or mid hex; destination = // flash | folder (MANDATORY - `folder` captures the .mota onto the connected motatool folder as .mota). @@ -388,7 +408,7 @@ bool handle_ota_command(const char* command, char* reply, mesh::MainBoard& board while (p[i] && p[i] != ' ' && i < (int)sizeof(selstr) - 1) { selstr[i] = p[i]; i++; } selstr[i] = 0; const char* dst = p + i; while (*dst == ' ') dst++; - if (selstr[0] == 0) { strcpy(reply, "usage: ota pull flash [rescue] | folder [validate] (see `ota ls`)"); return true; } + if (selstr[0] == 0) { strcpy(reply, pull_usage); return true; } // resolve the catalogue row (index or explicit manifest_id) const OtaManager::CatRow* sel = nullptr; uint8_t mid[4]; // An eight-digit all-numeric manifest ID is still an ID, not a huge list index. Bare short decimal @@ -435,7 +455,7 @@ bool handle_ota_command(const char* command, char* reply, mesh::MainBoard& board char destination[8] = {0}, option[10] = {0}, extra[2] = {0}; int parts = sscanf(dst, "%7s %9s %1s", destination, option, extra); if (parts < 1 || parts > 2) { - strcpy(reply, "ERR usage: ota pull flash [rescue] | folder [validate]"); + snprintf(reply, 160, "ERR %s", pull_usage); return true; } bool to_flash = strcmp(destination, "flash") == 0; @@ -443,7 +463,7 @@ bool handle_ota_command(const char* command, char* reply, mesh::MainBoard& board bool validate = to_folder && parts == 2 && strcmp(option, "validate") == 0; bool rescue = to_flash && parts == 2 && strcmp(option, "rescue") == 0; if ((!to_flash && !to_folder) || (parts == 2 && !validate && !rescue)) { - strcpy(reply, "ERR usage: ota pull flash [rescue] | folder [validate]"); + snprintf(reply, 160, "ERR %s", pull_usage); return true; } @@ -468,7 +488,8 @@ bool handle_ota_command(const char* command, char* reply, mesh::MainBoard& board return true; #else if (selboot) { -#if defined(NRF52_PLATFORM) && defined(OTA_QSPI_STORE) && defined(OTA_QSPI_BOOTLOADER_UPDATE) +#if defined(NRF52_PLATFORM) && \ + (defined(OTA_QSPI_BOOTLOADER_UPDATE) || defined(OTA_INTERNAL_BOOTLOADER_UPDATE)) const OtaBootloaderIdentity& bid = c.bootloaderIdentity(); const OtaBlCaps& bl = c.bootloaderCaps(); if (selflags != (MFLAG_FULL | MFLAG_SIGNED | MFLAG_BOOTLOADER) || @@ -476,13 +497,11 @@ bool handle_ota_command(const char* command, char* reply, mesh::MainBoard& board 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"); + if (!bid.present || !bid.crc_ok || seltgt != ota_bootloader_target_id(bid)) { + strcpy(reply, "ERR bootloader package does not match this installed bootloader identity"); 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)) { + if (!ota_bootloader_self_update_caps_valid(bl)) { strcpy(reply, "ERR installed bootloader lacks safe LoRa bootloader-update support"); return true; } @@ -524,6 +543,16 @@ bool handle_ota_command(const char* command, char* reply, mesh::MainBoard& board !defined(OTA_QSPI_STORE) SelfFwInfo self; bool has_endf = ota_self_firmware(self) && self.valid; +#if defined(OTA_INTERNAL_BOOTLOADER_UPDATE) + if (!has_endf) { + strcpy(reply, "ERR no EndF; shared-slot builds refuse internal pulls; recover via USB/BLE DFU or SWD"); + return true; + } + if (rescue) { + strcpy(reply, "ERR rescue is disabled on shared-slot bootloader-update builds; use `flash`"); + return true; + } +#else if (!has_endf && !rescue) { snprintf(reply, 160, "ERR no EndF; retry `ota pull %s flash rescue`, then use `ota rescue install `", @@ -538,6 +567,7 @@ bool handle_ota_command(const char* command, char* reply, mesh::MainBoard& board strcpy(reply, "ERR rescue is only for a running firmware with no valid EndF; use `flash`"); return true; } +#endif #else if (rescue) { strcpy(reply, "ERR rescue is available only on internal-flash nRF52 builds"); @@ -656,7 +686,8 @@ bool handle_ota_command(const char* command, char* reply, mesh::MainBoard& board // the exact 8-byte base hash carried by the already-fetched package. The bootloader independently // hashes the running app and refuses a mismatch before writing any application flash. #if defined(NRF52_PLATFORM) && defined(OTA_FLASH_STORE) && !defined(OTA_SD_STORE) && \ - !defined(OTA_QSPI_STORE) && !defined(OTA_SEEDER_ONLY) + !defined(OTA_QSPI_STORE) && !defined(OTA_SEEDER_ONLY) && \ + !defined(OTA_INTERNAL_BOOTLOADER_UPDATE) if (c.fetch_to_folder) { strcpy(reply, "ERR the complete update was captured to a folder, not staged for install; use `ota cancel`"); return true; @@ -677,13 +708,15 @@ bool handle_ota_command(const char* command, char* reply, mesh::MainBoard& board char m2[100]; bool ok = c.apply_fetched_rescue(operator_base_hash, m2); sprintf(reply, "%s | %s", ok ? "OK" : "ERR", m2); +#elif defined(NRF52_PLATFORM) && defined(OTA_INTERNAL_BOOTLOADER_UPDATE) + strcpy(reply, "ERR rescue is disabled on shared-slot bootloader-update builds; use local DFU/SWD if EndF is invalid"); #else 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) +#if defined(NRF52_PLATFORM) && !defined(OTA_SEEDER_ONLY) && \ + (defined(OTA_QSPI_BOOTLOADER_UPDATE) || defined(OTA_INTERNAL_BOOTLOADER_UPDATE)) const OtaBootloaderIdentity& bid = c.bootloaderIdentity(); const OtaBlCaps& bl = c.bootloaderCaps(); if (*rest == 0 || strcmp(rest, "status") == 0) { @@ -699,8 +732,9 @@ bool handle_ota_command(const char* command, char* reply, mesh::MainBoard& board 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, + snprintf(reply, 160, "BL board=%08X target=%08X name=%s crc=%08X abi=%u caps=%02X | staged:%s mid=%s hash=%s", + (unsigned)bid.board_id, (unsigned)ota_bootloader_target_id(bid), + bid.device_name, (unsigned)bid.crc32, bl.apply_abi, bl.storage_flags, ready ? "ready" : "none", midhx, hashhx); return true; } @@ -720,7 +754,7 @@ bool handle_ota_command(const char* command, char* reply, mesh::MainBoard& board 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; + strcpy(reply, "ERR no complete bootloader package in local install storage"); return true; } char m2[100]; const bool ok = c.apply_fetched_bootloader(operator_mid, operator_hash8, m2); diff --git a/src/helpers/ota/OtaContext.h b/src/helpers/ota/OtaContext.h index 21707551..00a64cab 100644 --- a/src/helpers/ota/OtaContext.h +++ b/src/helpers/ota/OtaContext.h @@ -151,7 +151,14 @@ struct OtaContext { // Explicit recovery entry point for an internal-flash nRF52 whose normal EndF validation fails. This // is never called by auto-install; the CLI requires the package's exact 8-byte base hash. bool apply_fetched_rescue(const uint8_t operator_base_hash[8], char* msg) { +#if defined(OTA_INTERNAL_BOOTLOADER_UPDATE) + (void)operator_base_hash; + strncpy(msg, "rescue is disabled on shared-slot bootloader-update builds", 96); + msg[95] = 0; + return false; +#else return apply_fetched_impl(operator_base_hash, msg); +#endif } bool apply_fetched_impl(const uint8_t* rescue_base_hash, char* msg) { @@ -172,6 +179,13 @@ struct OtaContext { return false; } #endif +#if defined(OTA_INTERNAL_BOOTLOADER_UPDATE) + if (rescue_base_hash) { + strncpy(msg, "rescue is disabled on shared-slot bootloader-update builds", 96); + msg[95] = 0; + return false; + } +#endif #if defined(NRF52_PLATFORM) && defined(OTA_SD_STORE) if (sdCacheFetching()) { strncpy(msg, "refused: SD archive capture owns the OTA receive slot", 96); @@ -223,10 +237,10 @@ 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_QSPI_STORE) && \ - defined(OTA_QSPI_BOOTLOADER_UPDATE) && !defined(OTA_SEEDER_ONLY) +#if defined(NRF52_PLATFORM) && !defined(OTA_SEEDER_ONLY) && \ + (defined(OTA_QSPI_BOOTLOADER_UPDATE) || defined(OTA_INTERNAL_BOOTLOADER_UPDATE)) if (fetch_to_folder) { - strncpy(msg, "refused: bootloader package is not in local QSPI storage", 96); + strncpy(msg, "refused: bootloader package is not in local install storage", 96); msg[95] = 0; return false; } if (manager.fetchState() != OtaManager::COMPLETE || fetch_store.staged_size() == 0) { @@ -503,7 +517,8 @@ struct OtaContext { 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) +#if defined(NRF52_PLATFORM) && \ + (defined(OTA_QSPI_BOOTLOADER_UPDATE) || defined(OTA_INTERNAL_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 b4b3e511..6648a62f 100644 --- a/src/helpers/ota/OtaFlashLayout_nrf52.h +++ b/src/helpers/ota/OtaFlashLayout_nrf52.h @@ -33,6 +33,14 @@ static const uint32_t MOTA_NRF52_EXTRAFS_START = 0x000D4000u; 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; +// Internal-only targets do not reserve a second scratch bank. Their exact +// 41,330-byte v3 package shares the normal bottom-aligned internal OTA store. +// Under the 0xED000 filesystem ceiling it occupies eleven pages beginning at +// 0xE2000; OTAFIX compacts the payload forward within this same slot before +// handing that page-aligned source to the MBR. Admission requires a trustworthy +// live EndF proving the running application ends at or below this address. +static const uint32_t MOTA_NRF52_BOOT_CONTAINER_SIZE = 41330u; +static const uint32_t MOTA_NRF52_SHARED_BOOT_STAGE_START = 0x000E2000u; 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; // Compatibility name for code that refers to the fixed lower boundary. New staging code must use an @@ -45,6 +53,12 @@ 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; +inline uint8_t mota_nrf52_flash_stage_handoff(uint32_t effective_stage_ceiling) { + if (effective_stage_ceiling == MOTA_NRF52_STAGE_CEILING_EXPANDED) + return GPREGRET2_OTA_STAGE_EXPANDED; + return GPREGRET2_OTA_STAGE_LEGACY; +} + // Firmware without a valid EndF and older host tooling fall back to this conservative apply workspace. // New motatool builds read the firmware's appended layout record and derive memory_size from the actual // staged-container start; the app and bootloader both validate that per-patch value before writing. @@ -72,6 +86,13 @@ static_assert((MOTA_NRF52_STAGE_CEILING_LEGACY % MOTA_NRF52_FLASH_PAGE) == 0, "legacy staging ceiling must be page-aligned"); static_assert((MOTA_NRF52_STAGE_CEILING_EXPANDED % MOTA_NRF52_FLASH_PAGE) == 0, "expanded staging ceiling must be page-aligned"); +static_assert((MOTA_NRF52_SHARED_BOOT_STAGE_START % MOTA_NRF52_FLASH_PAGE) == 0, + "shared bootloader stage start must be page-aligned"); +static_assert(((MOTA_NRF52_APP_END - MOTA_NRF52_BOOT_CONTAINER_SIZE) & + ~(MOTA_NRF52_FLASH_PAGE - 1u)) == MOTA_NRF52_SHARED_BOOT_STAGE_START, + "exact bootloader container must bottom-align at E2000"); +static_assert(MOTA_NRF52_APP_END - MOTA_NRF52_SHARED_BOOT_STAGE_START == 0xB000u, + "shared bootloader slot must be exactly eleven pages"); 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, @@ -150,6 +171,61 @@ inline uint32_t mota_nrf52_stage_capacity(uint32_t app_base, uint32_t app_end, return first_free_page <= stage_ceiling ? stage_ceiling - first_free_page : 0; } +// Choose the erase-protection floor used while receiving/reopening an internal +// flash container. A valid EndF provides the exact live-image extent. Legacy +// app-only builds may opt into the conservative rescue fallback; a shared +// internal bootloader-update build passes refuse_without_endf=true for every +// package kind because its normal app linker may extend through 0xED000. +inline bool mota_nrf52_protected_app_end(uint32_t app_base, + uint32_t stage_ceiling, + bool firmware_info_valid, + uint32_t firmware_image_size, + bool refuse_without_endf, + uint32_t& out_app_end) { + if (!mota_nrf52_layout_valid(app_base, stage_ceiling)) return false; + if (firmware_info_valid) { + if (firmware_image_size > UINT32_MAX - app_base) return false; + out_app_end = app_base + firmware_image_size; + } else { + if (refuse_without_endf) return false; + if (MOTA_NRF52_FALLBACK_INPLACE_MEMORY > UINT32_MAX - app_base) return false; + out_app_end = app_base + MOTA_NRF52_FALLBACK_INPLACE_MEMORY; + } + return out_app_end >= app_base && out_app_end <= stage_ceiling; +} + +// Bound a staged header's untrusted total before any caller uses that value as +// a memory-mapped parse length. Subtraction is safe only after start<=ceiling. +inline bool mota_nrf52_container_span_valid(uint32_t start, + uint32_t stage_ceiling, + uint32_t total_size, + uint32_t minimum_size) { + return start <= stage_ceiling && total_size >= minimum_size && + total_size <= stage_ceiling - start; +} + +inline bool mota_nrf52_target_image_fits(uint32_t app_base, uint32_t image_size, + uint32_t application_ceiling) { + return image_size != 0 && app_base < application_ceiling && + image_size <= application_ceiling - app_base; +} + +// Internal in-place patches use whichever address the bottom-aligned container +// actually starts at as their destructive workspace ceiling. A normal delta +// may be larger than the boot package's eleven-page shared slot and start +// below E2000; that remains safe when both the encoded detools memory and the +// reconstructed application stop below their independent limits. +inline bool mota_nrf52_internal_patch_workspace_valid(uint32_t memory_size, + uint32_t app_base, + uint32_t mota_start, + uint32_t target_image_size, + uint32_t application_ceiling) { + return memory_size != 0 && mota_start >= app_base && + memory_size <= mota_start - app_base && + mota_nrf52_target_image_fits(app_base, target_image_size, + application_ceiling); +} + // Plan where to stage a received `.mota` of `total_size` bytes. It is placed bottom-aligned within the // highest flash page below stage_ceiling (the bootloader scans downward from there), and it must sit // ENTIRELY above the running image and below the filesystem region (ExtraFS/InternalFS - where user prefs @@ -170,6 +246,25 @@ inline bool mota_nrf52_stage_plan(uint32_t total_size, uint32_t app_base, uint32 return true; } +// Privileged shared-slot admission. Unlike ordinary rescue staging, this +// requires the caller to have a trustworthy live EndF (`app_end` is exact), +// accepts only the fixed v3 package size, and proves the dynamic slot is wholly +// above the running image before any erase/write begins. +inline bool mota_nrf52_shared_boot_stage_plan(uint32_t total_size, + uint32_t app_base, + bool firmware_info_valid, + uint32_t app_end, + uint32_t& out_start) { + uint32_t start; + if (!firmware_info_valid || total_size != MOTA_NRF52_BOOT_CONTAINER_SIZE || + !mota_nrf52_stage_plan(total_size, app_base, app_end, + MOTA_NRF52_APP_END, start) || + start != MOTA_NRF52_SHARED_BOOT_STAGE_START) + return false; + out_start = start; + return true; +} + inline bool mota_nrf52_stage_plan(uint32_t total_size, uint32_t app_base, uint32_t app_end, uint32_t& out_start) { return mota_nrf52_stage_plan(total_size, app_base, app_end, diff --git a/src/helpers/ota/OtaManager.cpp b/src/helpers/ota/OtaManager.cpp index bfefea37..0cd07863 100644 --- a/src/helpers/ota/OtaManager.cpp +++ b/src/helpers/ota/OtaManager.cpp @@ -923,9 +923,8 @@ bool OtaManager::wantRow(const uint8_t* mid, uint32_t target, uint8_t codec, uin 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 (!_archive_fetch && !fetchCodecOk(codec, bootloader, _have_desired_mid)) + return false; // can't or must not apply this class if (_have_desired_mid) // manual pull of a specific mid return memcmp(mid, _desired_mid, 4) == 0 && (_desired_target == 0 || target == _desired_target); if (_desired_target) return target == _desired_target; // cross-target want (role switch) @@ -1176,11 +1175,10 @@ void OtaManager::handleManifest(const uint8_t* m, uint16_t n) { 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)) { + if (!_archive_fetch && + !fetchCodecOk(parsed.codec_id, parsed.is_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; } @@ -1204,7 +1202,8 @@ void OtaManager::handleManifest(const uint8_t* m, uint16_t n) { // 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 = parsed.is_full(); - if (!_fetch->plan_layout(is_full, parsed.image_size, payload_off, payload_size)) { + if (!_fetch->plan_layout(is_full, parsed.image_size, payload_off, payload_size, + parsed.is_bootloader())) { failFetch(FETCH_ERROR_STORAGE); return; } if (!_fetch->begin(total)) { failFetch(FETCH_ERROR_STORAGE); return; } @@ -1326,8 +1325,10 @@ 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; + // A bootloader partial is resumed only as part of the same explicit MID pull. + // Boot-time resumeStaged(nullptr) must not silently re-adopt privileged data. + const bool manual = want_mid != nullptr && _have_desired_mid; + if (!_archive_fetch && !fetchCodecOk(m.codec_id, m.is_bootloader(), manual)) return false; uint32_t mfl = (uint32_t)(m.approval - m.manifest_start) + 4; // manifest-minus-leaves length uint32_t bs = m.block_size(); if (bs == 0 || bs > OTA_MAX_BLOCK) return false; diff --git a/src/helpers/ota/OtaManager.h b/src/helpers/ota/OtaManager.h index 26f858d8..d27947b6 100644 --- a/src/helpers/ota/OtaManager.h +++ b/src/helpers/ota/OtaManager.h @@ -364,6 +364,13 @@ public: bool codecOk(uint8_t c) const { return (c == CODEC_FULL && _accept_full) || c == _apply_codec || c == _apply_codec2; } + // Bootloader FULL images are a separate, privileged install class. They are + // admitted only for an explicit MID pull on a self-update-capable build; + // accepting one must never turn ordinary application FULL images on for an + // nRF52 single-slot target. + bool fetchCodecOk(uint8_t c, bool bootloader, bool manual) const { + return bootloader ? (_accept_bootloader && manual && c == CODEC_FULL) : codecOk(c); + } // Auto-fetch policy (manual `ota pull` always works regardless): 0=off (discover only), 1=any // compatible own-target advert, 2=only signed adverts. Conservative default = off. diff --git a/src/helpers/ota/OtaStore.h b/src/helpers/ota/OtaStore.h index 17d1bd94..7b186196 100644 --- a/src/helpers/ota/OtaStore.h +++ b/src/helpers/ota/OtaStore.h @@ -57,8 +57,10 @@ public: // whole container is staged together (the decoder reads the patch from it at apply). image_size is the // reconstructed image; [payload_off, payload_off+payload_size) is the payload region in the container. // Return false if it cannot fit the backing store (the transfer is then refused before any block). - virtual bool plan_layout(bool is_full, uint32_t image_size, uint32_t payload_off, uint32_t payload_size) { - (void)is_full; (void)image_size; (void)payload_off; (void)payload_size; return true; + virtual bool plan_layout(bool is_full, uint32_t image_size, uint32_t payload_off, + uint32_t payload_size, bool is_bootloader) { + (void)is_full; (void)image_size; (void)payload_off; (void)payload_size; + (void)is_bootloader; return true; } }; diff --git a/src/helpers/ota/OtaStoreFlashEsp32.cpp b/src/helpers/ota/OtaStoreFlashEsp32.cpp index 1be20563..d75f11e7 100644 --- a/src/helpers/ota/OtaStoreFlashEsp32.cpp +++ b/src/helpers/ota/OtaStoreFlashEsp32.cpp @@ -54,7 +54,10 @@ bool OtaStoreFlashEsp32::layout() { // Choose placement from the parsed manifest and refuse anything that won't fit, BEFORE any block is // staged. (See the header for the delta-vs-full layout rationale.) -bool OtaStoreFlashEsp32::plan_layout(bool is_full, uint32_t image_size, uint32_t payload_off, uint32_t payload_size) { +bool OtaStoreFlashEsp32::plan_layout(bool is_full, uint32_t image_size, + uint32_t payload_off, uint32_t payload_size, + bool is_bootloader) { + if (is_bootloader) return false; if (!acquire()) return false; _full = is_full; _image_size = image_size; _meta_bytes = payload_off; _pay_size = payload_size; bool ok = layout(); diff --git a/src/helpers/ota/OtaStoreFlashEsp32.h b/src/helpers/ota/OtaStoreFlashEsp32.h index 57bda311..36bacce8 100644 --- a/src/helpers/ota/OtaStoreFlashEsp32.h +++ b/src/helpers/ota/OtaStoreFlashEsp32.h @@ -84,7 +84,8 @@ class OtaStoreFlashEsp32 : public OtaStore { public: ~OtaStoreFlashEsp32() override; - bool plan_layout(bool is_full, uint32_t image_size, uint32_t payload_off, uint32_t payload_size) override; + bool plan_layout(bool is_full, uint32_t image_size, uint32_t payload_off, + uint32_t payload_size, bool is_bootloader) override; bool begin(uint32_t total_size) override; bool write(uint32_t offset, const uint8_t* data, uint32_t len) override; bool read(uint32_t offset, uint8_t* buf, uint32_t len) const override; diff --git a/src/helpers/ota/OtaStoreFlashNrf52.cpp b/src/helpers/ota/OtaStoreFlashNrf52.cpp index 6692a0cd..8c5665be 100644 --- a/src/helpers/ota/OtaStoreFlashNrf52.cpp +++ b/src/helpers/ota/OtaStoreFlashNrf52.cpp @@ -4,6 +4,7 @@ #include "OtaSelf.h" #include "OtaBlInfo.h" +#include "MotaContainer.h" #include "OtaDebug.h" #include "OtaByteIO.h" // align_down / rd_u32le (flash-page geometry + header read) #include @@ -12,14 +13,55 @@ namespace mesh { namespace ota { -// A valid EndF gives the exact live-image extent. Rescue/bring-up builds without one retain the old -// 608 KiB protection floor so staging cannot begin in the middle of a normally sized application. +// A valid EndF gives the exact live-image extent. Legacy app-only builds keep +// the conservative rescue fallback. Shared internal bootloader-update builds +// reject every package kind without EndF because their normal linker may place +// live application bytes anywhere through the 0xED000 stage ceiling. static bool protected_app_end(uint32_t app_base, uint32_t stage_ceiling, uint32_t& app_end) { - if (!mota_nrf52_layout_valid(app_base, stage_ceiling)) return false; - app_end = app_base + MOTA_NRF52_FALLBACK_INPLACE_MEMORY; SelfFwInfo fi; - if (ota_self_firmware(fi) && fi.valid) app_end = app_base + fi.image_len; - return app_end >= app_base && app_end <= stage_ceiling; + const bool valid = ota_self_firmware(fi) && fi.valid; +#if defined(OTA_INTERNAL_BOOTLOADER_UPDATE) + const bool refuse_without_endf = true; +#else + const bool refuse_without_endf = false; +#endif + return mota_nrf52_protected_app_end( + app_base, stage_ceiling, valid, valid ? fi.image_len : 0u, + refuse_without_endf, app_end); +} + +bool OtaStoreFlashNrf52::plan_layout(bool is_full, uint32_t image_size, + uint32_t payload_off, uint32_t payload_size, + bool is_bootloader) { + _planned_bootloader = false; + _planned_total = 0; + _planned_start = 0; + if (!is_bootloader) return true; +#if defined(OTA_INTERNAL_BOOTLOADER_UPDATE) + const uint64_t total64 = (uint64_t)payload_off + payload_size + 5u; + if (!is_full || image_size != 40u * 1024u || payload_size != 40u * 1024u || + payload_off != 365u || total64 != MOTA_NRF52_BOOT_CONTAINER_SIZE || + !ota_bootloader_self_update_caps_valid(ota_bootloader_caps())) + return false; + + // This is the release-blocking no-EndF gate: package kind is known before + // begin(), and no page may be erased until the exact live app extent proves + // the shared E2000..ED000 slot is free. + SelfFwInfo fi; + uint32_t start; + const uint32_t app_base = mota_nrf52_app_base(); + if (!ota_self_firmware(fi) || !fi.valid || fi.image_len > UINT32_MAX - app_base || + !mota_nrf52_shared_boot_stage_plan( + (uint32_t)total64, app_base, true, app_base + fi.image_len, start)) + return false; + _planned_bootloader = true; + _planned_total = (uint32_t)total64; + _planned_start = start; + return true; +#else + (void)is_full; (void)image_size; (void)payload_off; (void)payload_size; + return false; +#endif } // Write one whole 4 KB page from `buf` to flash (erase + program, ~85 ms). `buf` is PG bytes, 0xFF-padded @@ -88,18 +130,38 @@ uint8_t* OtaStoreFlashNrf52::write_slot(uint32_t pos) { } bool OtaStoreFlashNrf52::begin(uint32_t total_size) { + const bool planned_bootloader = _planned_bootloader; + const uint32_t planned_total = _planned_total; + const uint32_t planned_start = _planned_start; clear(); // never collide with the running application image (its extent comes from its EndF trailer) const uint32_t app_base = mota_nrf52_app_base(); const uint32_t stage_ceiling = ota_nrf52_effective_stage_ceiling(); uint32_t app_end; - if (!protected_app_end(app_base, stage_ceiling, app_end)) return false; + if (planned_bootloader) { +#if defined(OTA_INTERNAL_BOOTLOADER_UPDATE) + SelfFwInfo fi; + uint32_t checked_start; + if (stage_ceiling != MOTA_NRF52_APP_END || total_size != planned_total || + !ota_self_firmware(fi) || !fi.valid || fi.image_len > UINT32_MAX - app_base || + !mota_nrf52_shared_boot_stage_plan( + total_size, app_base, true, app_base + fi.image_len, checked_start) || + checked_start != planned_start) + return false; + app_end = app_base + fi.image_len; +#else + return false; +#endif + } else if (!protected_app_end(app_base, stage_ceiling, app_end)) { + return false; + } // Bottom-align below the selected ceiling and reject unless it sits above the running image. The // approval path later verifies the patch's detools workspace ends at/below this exact start. uint32_t start; if (!mota_nrf52_stage_plan(total_size, app_base, app_end, stage_ceiling, start)) return false; + if (planned_bootloader && start != planned_start) return false; _write_start = start; _stage_ceiling = stage_ceiling; @@ -109,6 +171,9 @@ bool OtaStoreFlashNrf52::begin(uint32_t total_size) { _pay_idx = 0; _flushed = false; _io_ok = true; + _planned_bootloader = planned_bootloader; + _planned_total = planned_total; + _planned_start = planned_start; OTA_DBG("OTA flash: begin total=%u start=%08x app_end=%08x ceiling=%08x\n", (unsigned)total_size, (unsigned)start, (unsigned)app_end, (unsigned)stage_ceiling); return true; // no pre-erase: each page is erased by its own (single) flush @@ -160,6 +225,26 @@ bool OtaStoreFlashNrf52::finalize() { return true; } +#if defined(OTA_INTERNAL_BOOTLOADER_UPDATE) +bool OtaStoreFlashNrf52::approve_for_bootloader() { + if (!finalize() || _total < 8u + MOTA_MFL + 5u) return false; + const uint32_t offset = 8u + MOTA_OFF_APPROVAL; + if (offset + sizeof(APPROVAL_YES) > _total) return false; + const uint32_t address = _write_start + offset; + if (flash_nrf5x_write(address, APPROVAL_YES, sizeof(APPROVAL_YES)) < 0) { + _io_ok = false; + return false; + } + flash_nrf5x_flush(); + if (!flash_matches(address, APPROVAL_YES, sizeof(APPROVAL_YES))) { + _io_ok = false; + return false; + } + memcpy(_meta_page + offset, APPROVAL_YES, sizeof(APPROVAL_YES)); + return true; +} +#endif + // Persist mid-transfer progress so a reboot can resume. Order matters for consistency: flush the open // payload page FIRST, then page 0 (the leaf-progress markers) -- so every block whose leaf is now in flash // also has its payload in flash. Infrequent (every OTA_CHECKPOINT_BLOCKS blocks), so the 2 extra page @@ -184,8 +269,27 @@ bool OtaStoreFlashNrf52::reopen() { const uint8_t* p = (const uint8_t*)(uintptr_t)start; if (memcmp(p, MOTA_MAGIC, 4) != 0) continue; uint32_t total = rd_u32le(p + 4); + if (!mota_nrf52_container_span_valid( + start, stage_ceiling, total, 8u + MOTA_MFL + 5u)) + continue; uint32_t want; // must be valid + placed exactly where begin() would have staged it (same bounds fn) - if (!mota_nrf52_stage_plan(total, app_base, app_end, stage_ceiling, want) || want != start) continue; + MotaManifest manifest; + const bool manifest_ok = mota_parse_manifest(p + 8u, MOTA_MFL, manifest); + const bool bootloader = manifest_ok && manifest.is_bootloader(); + if (bootloader) { +#if defined(OTA_INTERNAL_BOOTLOADER_UPDATE) + SelfFwInfo fi; + if (!ota_self_firmware(fi) || !fi.valid || fi.image_len > UINT32_MAX - app_base || + !mota_nrf52_shared_boot_stage_plan( + total, app_base, true, app_base + fi.image_len, want) || want != start) + continue; +#else + continue; +#endif + } else if (!mota_nrf52_stage_plan( + total, app_base, app_end, stage_ceiling, want) || want != start) { + continue; + } _write_start = start; _stage_ceiling = stage_ceiling; _total = total; @@ -194,6 +298,9 @@ bool OtaStoreFlashNrf52::reopen() { _pay_idx = 0; _flushed = false; _io_ok = true; + _planned_bootloader = bootloader; + _planned_total = bootloader ? total : 0u; + _planned_start = bootloader ? start : 0u; OTA_DBG("OTA flash: reopen total=%u start=%08x ceiling=%08x\n", (unsigned)total, (unsigned)start, (unsigned)stage_ceiling); return true; diff --git a/src/helpers/ota/OtaStoreFlashNrf52.h b/src/helpers/ota/OtaStoreFlashNrf52.h index 5ef3479c..8911b365 100644 --- a/src/helpers/ota/OtaStoreFlashNrf52.h +++ b/src/helpers/ota/OtaStoreFlashNrf52.h @@ -40,6 +40,9 @@ class OtaStoreFlashNrf52 : public OtaStore { uint32_t _total = 0; // staged container size (0 = none) bool _flushed = false; // finalize() committed everything to flash bool _io_ok = true; // cleared on a failed/out-of-bounds flash write or readback mismatch + bool _planned_bootloader = false; // manifest-kind decision made before begin()/any erase + uint32_t _planned_total = 0; + uint32_t _planned_start = 0; uint8_t _meta_page[PG]; // pinned flash page 0 (header + manifest + leaves + 1st payload) uint8_t _pay_page[PG]; // sliding buffer for one payload page (index _pay_idx) @@ -56,6 +59,8 @@ class OtaStoreFlashNrf52 : public OtaStore { bool flush_page(uint32_t page_idx, const uint8_t* buf); // write + verify a full page public: + bool plan_layout(bool is_full, uint32_t image_size, uint32_t payload_off, + uint32_t payload_size, bool is_bootloader) override; bool begin(uint32_t total_size) override; bool write(uint32_t offset, const uint8_t* data, uint32_t len) override; bool read(uint32_t offset, uint8_t* buf, uint32_t len) const override; @@ -64,12 +69,22 @@ public: void clear() override { _stage_ceiling = MOTA_NRF52_STAGE_CEILING_LEGACY; _total = 0; _pay_idx = 0; _flushed = false; _io_ok = true; + _planned_bootloader = false; _planned_total = 0; _planned_start = 0; } bool set_meta_size(uint32_t meta_bytes) override { return meta_bytes <= PG; } // leaves must fit page 0 bool finalize() override; void checkpoint() override; // persist page 0 (leaves) + the open payload page so a reboot can resume bool reopen() override; // re-attach to a container already staged in flash (scan for it) +#if defined(OTA_INTERNAL_BOOTLOADER_UPDATE) + // Clear the manifest approval word only after the privileged signed-image gates pass. OTAFIX scans + // this page-aligned shared container after the ED handoff and repeats the structural, identity, + // payload-integrity, in-place compaction, and readback checks before self-write. APRV carries the app's completed + // signature/allowlist and explicit-operator authorization decision across the reboot. + bool approve_for_bootloader(); + const char* last_error() const { return _io_ok ? "" : "internal flash write/verify failed"; } +#endif + // Contiguous view (flash is memory-mapped). VALID ONLY AFTER finalize() - before that, page 0 and the // tail are still in RAM. OtaManager/OtaCli/verify use this only once the transfer is COMPLETE. const uint8_t* data() const { return (_flushed && _io_ok) ? (const uint8_t*)(uintptr_t)_write_start : nullptr; } diff --git a/src/helpers/ota/OtaStoreQspiNrf52.cpp b/src/helpers/ota/OtaStoreQspiNrf52.cpp index c4491f3c..042ad448 100644 --- a/src/helpers/ota/OtaStoreQspiNrf52.cpp +++ b/src/helpers/ota/OtaStoreQspiNrf52.cpp @@ -676,7 +676,8 @@ uint32_t OtaStoreQspiNrf52::capacity() const { return result; } -bool OtaStoreQspiNrf52::plan_layout(bool, uint32_t image_size, uint32_t, uint32_t payload_size) { +bool OtaStoreQspiNrf52::plan_layout(bool, uint32_t image_size, uint32_t, + uint32_t payload_size, bool) { // This is the start of a new manifest admission attempt. Clear diagnostics // left by the normal empty-store reopen probe, then latch the first error // from this attempt until the operator reads it or starts another attempt. diff --git a/src/helpers/ota/OtaStoreQspiNrf52.h b/src/helpers/ota/OtaStoreQspiNrf52.h index 6a746e99..13888da1 100644 --- a/src/helpers/ota/OtaStoreQspiNrf52.h +++ b/src/helpers/ota/OtaStoreQspiNrf52.h @@ -159,7 +159,8 @@ public: bool finalize() override; void checkpoint() override; bool reopen() override; - bool plan_layout(bool is_full, uint32_t image_size, uint32_t payload_off, uint32_t payload_size) override; + bool plan_layout(bool is_full, uint32_t image_size, uint32_t payload_off, + uint32_t payload_size, bool is_bootloader) override; // Sets APRV only after all app-side verification gates have passed. bool approve_for_bootloader(); diff --git a/src/helpers/ota/OtaStoreSdNrf52.cpp b/src/helpers/ota/OtaStoreSdNrf52.cpp index 38cb9dbc..06b7909e 100644 --- a/src/helpers/ota/OtaStoreSdNrf52.cpp +++ b/src/helpers/ota/OtaStoreSdNrf52.cpp @@ -368,7 +368,9 @@ bool OtaStoreSdNrf52::locate_file() { } bool OtaStoreSdNrf52::plan_layout(bool, uint32_t image_size, - uint32_t, uint32_t payload_size) { + uint32_t, uint32_t payload_size, + bool is_bootloader) { + if (is_bootloader) 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) { diff --git a/src/helpers/ota/OtaStoreSdNrf52.h b/src/helpers/ota/OtaStoreSdNrf52.h index 71cf4ff4..02f2f10b 100644 --- a/src/helpers/ota/OtaStoreSdNrf52.h +++ b/src/helpers/ota/OtaStoreSdNrf52.h @@ -33,7 +33,8 @@ public: void checkpoint() override; bool reopen() override; bool plan_layout(bool is_full, uint32_t image_size, - uint32_t payload_off, uint32_t payload_size) override; + 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. diff --git a/src/helpers/ota/OtaTargets.h b/src/helpers/ota/OtaTargets.h index 9ca7939a..db40036b 100644 --- a/src/helpers/ota/OtaTargets.h +++ b/src/helpers/ota/OtaTargets.h @@ -2,7 +2,7 @@ #include // AUTO-GENERATED by tools/mota/gen_targets.py - do not edit by hand. -// 566 OTA-capable PlatformIO envs. Maps target_id (= sha2-256:4 of the env name, LE uint32) +// 577 OTA-capable build targets. Maps target_id (= sha2-256:4 of the target name, LE uint32) // to the human-readable env name, so a node/tool can name a target seen over the air WITHOUT // transmitting the string in the .mota / LoRa protocol. Regenerate when the OTA env set changes. // Size-constrained receivers can set OTA_TARGET_NAME_TABLE=0. They still match targets by ID; @@ -31,6 +31,7 @@ inline const char* ota_target_env_name(uint32_t target_id) { { 0xf9986af5, "GAT562_30S_Mesh_Kit_room_server" }, { 0x98519a1d, "GAT562_Mesh_EVB_Pro_kiss_modem" }, { 0x06b71719, "GAT562_Mesh_EVB_Pro_repeater" }, + { 0x6384719d, "GAT562_Mesh_EVB_Pro_repeater_lora_ota_no_external_sensors" }, { 0x4c0461c8, "GAT562_Mesh_EVB_Pro_room_server" }, { 0x4fa88992, "GAT562_Mesh_Tracker_Pro_companion_radio_ble" }, { 0xcd6604d0, "GAT562_Mesh_Tracker_Pro_companion_radio_usb" }, @@ -101,6 +102,7 @@ inline const char* ota_target_env_name(uint32_t target_id) { { 0x2f9f8e7d, "Heltec_t114_without_display_companion_radio_usb" }, { 0xc9dc5d8c, "Heltec_t114_without_display_repeater" }, { 0x34b0e5f3, "Heltec_t114_without_display_repeater_bridge_rs232" }, + { 0xb4d0ad97, "Heltec_t114_without_display_repeater_lora_ota_no_external_sensors" }, { 0xcfa0e233, "Heltec_t114_without_display_room_server" }, { 0x17d5e1ff, "Heltec_T190_companion_radio_ble_" }, { 0xc99313c6, "Heltec_T190_companion_radio_usb_" }, @@ -112,6 +114,7 @@ inline const char* ota_target_env_name(uint32_t target_id) { { 0x57172fcc, "Heltec_T190_room_server_observer_mqtt" }, { 0xa529b4f5, "Heltec_t1_repeater_lora_ota_no_external_sensors" }, { 0x85a8c944, "Heltec_tower_v2_repeater" }, + { 0x3c60586d, "Heltec_tower_v2_repeater_lora_ota_no_external_sensors" }, { 0x0a9dbbf0, "Heltec_tower_v2_sdcard_repeater_lora_ota_no_external_sensors" }, { 0xb519a89f, "heltec_tracker_v1_1_repeater_observer_mqtt" }, { 0xa3e8d8ce, "heltec_tracker_v1_1_room_server_observer_mqtt" }, @@ -271,6 +274,7 @@ inline const char* ota_target_env_name(uint32_t target_id) { { 0xe5331f02, "ikoka_stick_nrf_33dbm_repeater" }, { 0xfba7177b, "ikoka_stick_nrf_33dbm_room_server" }, { 0xde7dc472, "KeepteenLT1_repeater" }, + { 0x3625bb80, "KeepteenLT1_repeater_lora_ota_no_external_sensors" }, { 0x69df7765, "LilyGo_T-Echo-Lite_companion_radio_ble" }, { 0xe9fbc9be, "LilyGo_T-Echo-Lite_kiss_modem" }, { 0x40818863, "LilyGo_T-Echo-Lite_non_shell_companion_radio_ble" }, @@ -344,6 +348,7 @@ inline const char* ota_target_env_name(uint32_t target_id) { { 0xe6bff8f9, "M5Stack_Unit_C6L_repeater" }, { 0xb9cd26cb, "M5Stack_Unit_C6L_room_server" }, { 0x9bc34bd5, "Mesh_pocket_repeater" }, + { 0x144504b0, "Mesh_pocket_repeater_lora_ota_no_external_sensors" }, { 0xdbfe2675, "Meshadventurer_sx1262_companion_radio_ble" }, { 0xb00241d0, "Meshadventurer_sx1262_companion_radio_usb" }, { 0x88e77fee, "Meshadventurer_sx1262_kiss_modem" }, @@ -368,6 +373,7 @@ inline const char* ota_target_env_name(uint32_t target_id) { { 0x6ebf28cb, "meshnology_w12_sensor" }, { 0x2d589527, "meshnology_w12_terminal_chat" }, { 0x77d0810c, "Minewsemi_me25ls01_repeater" }, + { 0x11b131e7, "Minewsemi_me25ls01_repeater_lora_ota_no_external_sensors" }, { 0xf8259880, "Nano_G2_Ultra_repeater" }, { 0x063f1f00, "nibble_screen_connect_companion_radio_ble_" }, { 0xa8a79da5, "nibble_screen_connect_companion_radio_usb_" }, @@ -389,6 +395,7 @@ inline const char* ota_target_env_name(uint32_t target_id) { { 0xbd6a1c97, "ProMicro_kiss_modem" }, { 0xf4d98c12, "ProMicro_repeater" }, { 0xccac4283, "ProMicro_repeater_bridge_rs232_serial1" }, + { 0xa68c09e9, "ProMicro_repeater_lora_ota_no_external_sensors" }, { 0x5f606274, "ProMicro_room_server" }, { 0x84923735, "ProMicro_sensor" }, { 0x2c0b8b05, "ProMicro_terminal_chat" }, @@ -396,6 +403,7 @@ inline const char* ota_target_env_name(uint32_t target_id) { { 0xcb6555ee, "R1Neo_companion_radio_usb" }, { 0xd60daf25, "R1Neo_kiss_modem" }, { 0x410bd800, "R1Neo_repeater" }, + { 0x9aca380e, "R1Neo_repeater_lora_ota_no_external_sensors" }, { 0xd8641710, "R1Neo_room_server" }, { 0x4d0b1601, "R1Neo_sensor" }, { 0xab2ac09e, "R1Neo_terminal_chat" }, @@ -425,6 +433,7 @@ inline const char* ota_target_env_name(uint32_t target_id) { { 0x60683191, "RAK_WisMesh_Tag_companion_radio_usb" }, { 0x8a7cb79b, "RAK_WisMesh_Tag_kiss_modem" }, { 0x4fa4ae55, "RAK_WisMesh_Tag_repeater" }, + { 0xd1d61a8b, "RAK_WisMesh_Tag_repeater_lora_ota_no_external_sensors" }, { 0x9cb0b232, "RAK_WisMesh_Tag_room_server" }, { 0x51fa1a49, "RAK_WisMesh_Tag_sensor" }, { 0xdb73638b, "SenseCap_Solar_repeater" }, @@ -458,6 +467,7 @@ inline const char* ota_target_env_name(uint32_t target_id) { { 0xff4102d6, "t1000e_companion_radio_usb" }, { 0x2f9a9185, "t1000e_kiss_modem" }, { 0x000fc519, "t1000e_repeater" }, + { 0x7b071fa0, "t1000e_repeater_lora_ota_no_external_sensors" }, { 0x3e55e34d, "t1000e_room_server" }, { 0x16482630, "T_Beam_S3_Supreme_SX1262_companion_radio_ble" }, { 0xce0f07ad, "T_Beam_S3_Supreme_SX1262_companion_radio_ble_ps" }, @@ -506,6 +516,7 @@ inline const char* ota_target_env_name(uint32_t target_id) { { 0x37928233, "ThinkNode_M3_companion_radio_usb" }, { 0x88ac45da, "ThinkNode_M3_kiss_modem" }, { 0x087f99c0, "ThinkNode_M3_repeater" }, + { 0xdf41affe, "ThinkNode_M3_repeater_lora_ota_no_external_sensors" }, { 0x2afe6c63, "ThinkNode_M3_room_server" }, { 0xf2d61a1c, "ThinkNode_M5_companion_radio_ble" }, { 0x14b1b097, "ThinkNode_M5_companion_radio_ble_ps" }, diff --git a/test/README.md b/test/README.md index c538ae22..869d0e92 100644 --- a/test/README.md +++ b/test/README.md @@ -70,8 +70,8 @@ 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; 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_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_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) | ## Conventions (and how to add a suite) diff --git a/test/test_ota/test_ota_core.cpp b/test/test_ota/test_ota_core.cpp index bbadb9fa..6ff0a70c 100644 --- a/test/test_ota/test_ota_core.cpp +++ b/test/test_ota/test_ota_core.cpp @@ -24,6 +24,8 @@ extern "C" { using namespace mesh::ota; +static std::vector boot_manifest_bytes(); + TEST(OtaBootResult, AcceptsOnlyOtafixApplyDiagnostics) { EXPECT_EQ(ota_nrf52_boot_result_or_zero(0x90), 0x90); EXPECT_EQ(ota_nrf52_boot_result_or_zero(0x9F), 0x9F); @@ -41,9 +43,77 @@ TEST(OtaBootResult, AcceptsOnlyOtafixApplyDiagnostics) { EXPECT_EQ(ota_nrf52_boot_result_or_zero(0x51), 0x00); // QSPI 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, 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}; + const uint8_t rak4631_name[16] = {'4','6','3','1','_','D','F','U',0,0,0,0,0,0,0,0}; + uint8_t hw3401[32], hw4631[32]; + ASSERT_TRUE(ota_bootloader_hw_id(shared_board_id, rak3401_name, hw3401)); + ASSERT_TRUE(ota_bootloader_hw_id(shared_board_id, rak4631_name, hw4631)); + EXPECT_EQ(0, memcmp(hw3401, "NRF_BL_239A0029_3401_DFU", 24)); + EXPECT_EQ(0, memcmp(hw4631, "NRF_BL_239A0029_4631_DFU", 24)); + EXPECT_NE(0, memcmp(hw3401, hw4631, sizeof(hw3401))); + EXPECT_EQ(ota_bootloader_target_id(shared_board_id, rak3401_name), 0x23818A80u); + EXPECT_EQ(ota_bootloader_target_id(shared_board_id, rak4631_name), 0x2D0DF000u); + + uint8_t invalid[16] = {'B','A','D',0,'T','A','I','L',0,0,0,0,0,0,0,0}; + EXPECT_FALSE(ota_bootloader_device_name_valid(shared_board_id, invalid)); + memset(invalid, 'A', sizeof(invalid)); + EXPECT_FALSE(ota_bootloader_device_name_valid(shared_board_id, invalid)); + EXPECT_FALSE(ota_bootloader_board_id_valid(0)); + EXPECT_FALSE(ota_bootloader_board_id_valid(UINT32_MAX)); + + auto raw = boot_manifest_bytes(); + wr_u32le(raw.data() + 3, ota_bootloader_target_id(shared_board_id, rak3401_name)); + memcpy(raw.data() + 57, hw3401, sizeof(hw3401)); + MotaManifest manifest; + ASSERT_TRUE(mota_parse_manifest(raw.data(), raw.size(), manifest)); + 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 = shared_board_id; + memcpy(installed.device_name, rak3401_name, sizeof(rak3401_name)); + uint8_t mid[4] = {0x11,0x22,0x33,0x44}; + uint8_t hash8[8]; memcpy(hash8, manifest.image_hash, sizeof(hash8)); + EXPECT_EQ(ota_bootloader_confirmation_gate(manifest, installed, mid, mid, hash8), + OTA_BOOT_CONFIRM_OK); + wr_u32le(raw.data() + 3, shared_board_id); // raw USB ID is not the generic wire target + ASSERT_TRUE(mota_parse_manifest(raw.data(), raw.size(), manifest)); + EXPECT_EQ(ota_bootloader_confirmation_gate(manifest, installed, mid, mid, hash8), + OTA_BOOT_CONFIRM_TARGET); + + struct KnownIdentity { uint32_t board_id; const char* name; uint32_t target_id; }; + const KnownIdentity known[] = { + {0x239A0071u, "TOWER_V2_OTA", 0x1150F50Eu}, + {0x239A0071u, "T096_DFU", 0x42354C85u}, + {0x239A0071u, "T1_DFU", 0xFC556FFCu}, + {0x239A0071u, "T114_DFU", 0x0C3F2902u}, + {0x239A0071u, "MESH_POCKET_OTA",0x059277F4u}, + {0x239A00B3u, "KeepteenLT1_OTA",0xDB2E7B51u}, + {0x239A0029u, "MX25_DFU", 0x026AA982u}, + {0x239A00B3u, "PROM_DFU", 0xAF79E8CCu}, + {0x28860057u, "T1KE_DFU", 0xE6F5F03Fu}, + {0x239A00DAu, "TNM3_DFU", 0x0CA41DB2u}, + {0x239A0029u, "3401_DFU", 0x23818A80u}, + {0x239A0029u, "4631_DFU", 0x2D0DF000u}, + {0x239A0029u, "RTAG_DFU", 0xC72E9C9Cu}, + }; + uint32_t targets[sizeof(known) / sizeof(known[0])] = {0}; + for (size_t i = 0; i < sizeof(known) / sizeof(known[0]); i++) { + uint8_t name[16] = {0}; + ASSERT_LT(strlen(known[i].name), sizeof(name)); + memcpy(name, known[i].name, strlen(known[i].name)); + targets[i] = ota_bootloader_target_id(known[i].board_id, name); + EXPECT_EQ(targets[i], known[i].target_id); + for (size_t j = 0; j < i; j++) EXPECT_NE(targets[i], targets[j]); + } +} + static std::vector boot_manifest_bytes() { std::vector m(MOTA_MFL, 0); m[0] = MOTA_BOOT_FORMAT_VER; @@ -146,6 +216,27 @@ TEST(OtaBootPackage, EmbeddedIdentityVectorCapsAndExplicitConfirmationGate) { marker[15] = 1; EXPECT_FALSE(ota_bootloader_caps_marker_parse(marker, caps)); + 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, + (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( + internal_caps_image.data(), internal_caps_image.size(), + OTA_BL_STORAGE_STAGE_CEILING | OTA_BL_STORAGE_BOOT_UPDATE, + caps)); + EXPECT_FALSE(ota_bootloader_caps_from_image( + internal_caps_image.data(), internal_caps_image.size(), + OTA_BL_STORAGE_STAGE_CEILING | OTA_BL_STORAGE_QSPI | OTA_BL_STORAGE_BOOT_UPDATE, + caps)); + memcpy(internal_caps_image.data() + 24, internal_marker, sizeof(internal_marker)); + internal_caps_image[24 + 12] = + OTA_BL_STORAGE_STAGE_CEILING | OTA_BL_STORAGE_QSPI | OTA_BL_STORAGE_BOOT_UPDATE; + EXPECT_FALSE(ota_bootloader_caps_from_image( + internal_caps_image.data(), internal_caps_image.size(), + OTA_BL_STORAGE_STAGE_CEILING | OTA_BL_STORAGE_BOOT_UPDATE, + caps)); + OtaBlCaps selected; selected.present = true; selected.apply_abi = 3; @@ -176,20 +267,38 @@ TEST(OtaBootPackage, EmbeddedIdentityVectorCapsAndExplicitConfirmationGate) { } 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, - (uint8_t)(OTA_BL_STORAGE_QSPI | OTA_BL_STORAGE_BOOT_UPDATE), 0,0,0}; + qspi_profile, 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); + EXPECT_FALSE(ota_bl_caps_scan_aligned(image, sizeof(image), true, qspi_profile).present); memset(image, 0xFF, sizeof(image)); memcpy(image + 4, marker, sizeof(marker)); - const OtaBlCaps caps = ota_bl_caps_scan_aligned(image, sizeof(image), true); + const OtaBlCaps caps = ota_bl_caps_scan_aligned(image, sizeof(image), true, qspi_profile); 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)); + EXPECT_EQ(caps.storage_flags, qspi_profile); + + // A malformed aligned magic decoy is ignored, but a second fully valid + // privileged marker makes the installed capability identity ambiguous. + memcpy(image + 24, marker, sizeof(marker)); + EXPECT_FALSE(ota_bl_caps_scan_aligned(image, sizeof(image), true, qspi_profile).present); + memset(image + 24, 0xFF, sizeof(marker)); + memcpy(image + 24, marker, sizeof(marker)); + image[24 + 13] = 1; + EXPECT_TRUE(ota_bl_caps_scan_aligned(image, sizeof(image), true, qspi_profile).present); + + // A structurally valid marker for the other storage profile is not an + // installed-profile candidate and cannot shadow the exact marker. + memcpy(image + 24, marker, sizeof(marker)); + image[24 + 12] = OTA_BL_STORAGE_STAGE_CEILING | + OTA_BL_STORAGE_BOOT_UPDATE; + EXPECT_TRUE(ota_bl_caps_scan_aligned(image, sizeof(image), true, qspi_profile).present); } class FakeMotaSeederStream : public Stream { @@ -1979,7 +2088,7 @@ TEST(OtaTransfer, BootloaderPackageRequiresCapableExplicitMidPull) { 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_full(false); // internal nRF52 app FULL remains forbidden capable.set_accept_bootloader(true); capable.set_autofetch(OtaManager::AUTOFETCH_ANY); capable.on_message( @@ -1988,6 +2097,15 @@ TEST(OtaTransfer, BootloaderPackageRequiresCapableExplicitMidPull) { EXPECT_EQ(capable.fetchState(), OtaManager::IDLE); EXPECT_EQ(capable_store.staged_size(), 0u); + // Enabling the privileged bootloader path must not broaden the ordinary + // single-slot application codec policy or turn autofetch on for FULL apps. + const uint8_t app_mid[4] = {0x21, 0x22, 0x23, 0x24}; + capable.on_message( + wire, make_have1(wire, sizeof(wire), app_mid, OTA_XIAO_BOARD_ID_BASE, 2, + CODEC_FULL, MFLAG_FULL)); + 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); diff --git a/test/test_ota/test_ota_flashplan.cpp b/test/test_ota/test_ota_flashplan.cpp index 747bef87..2079c6dc 100644 --- a/test/test_ota/test_ota_flashplan.cpp +++ b/test/test_ota/test_ota_flashplan.cpp @@ -18,6 +18,7 @@ static constexpr uint32_t APP_V6 = MOTA_NRF52_APP_BASE_S140_V6; static constexpr uint32_t APP_V7 = MOTA_NRF52_APP_BASE_S140_V7; static constexpr uint32_t LEGACY = MOTA_NRF52_STAGE_CEILING_LEGACY; static constexpr uint32_t EXPANDED = MOTA_NRF52_STAGE_CEILING_EXPANDED; +static constexpr uint32_t SHARED_BOOT_START = MOTA_NRF52_SHARED_BOOT_STAGE_START; static constexpr uint32_t APP_IMAGE_SIZE = 520u * 1024u; static constexpr uint32_t APP_END_V6 = APP_V6 + APP_IMAGE_SIZE; static constexpr uint32_t APP_END_V7 = APP_V7 + APP_IMAGE_SIZE; @@ -190,10 +191,134 @@ TEST(OtaFlashPlan, RejectsAppOutsideSelectedRegion) { TEST(OtaFlashPlan, RejectsUnknownCeiling) { uint32_t start = 0x1234ABCD; - EXPECT_FALSE(mota_nrf52_stage_plan(4096, APP_V6, APP_V6, 0xE0000u, start)); + EXPECT_FALSE(mota_nrf52_stage_plan(4096, APP_V6, APP_V6, 0xE1000u, start)); EXPECT_EQ(start, 0x1234ABCDu); } +TEST(OtaFlashPlan, BootPackageUsesOneDynamicSharedInternalSlot) { + // v3 = header + fixed manifest + forty leaves + 40 KiB image + trailer. + const uint32_t total = 8u + 197u + 40u * 4u + 40u * 1024u + 5u; + ASSERT_EQ(total, MOTA_NRF52_BOOT_CONTAINER_SIZE); + uint32_t start = 0; + ASSERT_TRUE(mota_nrf52_shared_boot_stage_plan( + total, APP_V6, true, SHARED_BOOT_START, start)); + EXPECT_EQ(start, SHARED_BOOT_START); + EXPECT_EQ(start, (EXPANDED - total) & ~(MOTA_NRF52_FLASH_PAGE - 1u)); + + // OTAFIX compacts payload offset +365 forward inside these same eleven + // pages; no independent scratch bank participates in the internal path. + const uint32_t payload_offset = 8u + 197u + 40u * 4u; + EXPECT_EQ(payload_offset, 365u); + EXPECT_LE(start + payload_offset + 40u * 1024u, EXPANDED); + EXPECT_LE(start + 40u * 1024u, EXPANDED); + + // Runtime headroom, not a special linker, is authoritative. + EXPECT_FALSE(mota_nrf52_shared_boot_stage_plan( + total, APP_V6, true, SHARED_BOOT_START + 1u, start)); + EXPECT_FALSE(mota_nrf52_shared_boot_stage_plan( + total - 1u, APP_V6, true, APP_V6, start)); + EXPECT_FALSE(mota_nrf52_shared_boot_stage_plan( + total + 1u, APP_V6, true, APP_V6, start)); +} + +TEST(OtaFlashPlan, SharedInternalStoreKeepsNormalApplicationCeilingAndHandoff) { + EXPECT_TRUE(mota_nrf52_target_image_fits( + APP_V6, EXPANDED - APP_V6, EXPANDED)); + EXPECT_FALSE(mota_nrf52_target_image_fits( + APP_V6, EXPANDED - APP_V6 + 1u, EXPANDED)); + EXPECT_EQ(mota_nrf52_flash_stage_handoff(EXPANDED), + GPREGRET2_OTA_STAGE_EXPANDED); + EXPECT_EQ(mota_nrf52_flash_stage_handoff(LEGACY), + GPREGRET2_OTA_STAGE_LEGACY); +} + +TEST(OtaFlashPlan, InternalOrdinaryDeltaUsesTheSameEd000Store) { + const uint32_t running_end = APP_V6 + 500u * 1024u; + const uint32_t delta_container = 28u * 1024u; + uint32_t staged = 0; + ASSERT_TRUE(mota_nrf52_stage_plan(delta_container, APP_V6, running_end, + EXPANDED, staged)); + EXPECT_GE(staged, running_end); + EXPECT_LE(staged + delta_container, EXPANDED); + EXPECT_EQ(mota_nrf52_flash_stage_handoff(EXPANDED), 0xEDu); +} + +TEST(OtaFlashPlan, LargeInternalOrdinaryDeltaMayStageBelowSharedBootStart) { + // An ordinary delta container can exceed the boot package's eleven pages. + // It bottom-aligns below E2000 and remains valid when detools' + // encoded workspace ends before the actual container start. + const uint32_t running_end = APP_V6 + 600u * 1024u; + const uint32_t delta_container = 80u * 1024u; + uint32_t staged = 0; + ASSERT_TRUE(mota_nrf52_stage_plan(delta_container, APP_V6, running_end, + EXPANDED, staged)); + EXPECT_LT(staged, SHARED_BOOT_START); + EXPECT_GE(staged, running_end); + + const uint32_t workspace = staged - APP_V6; + const uint32_t target_size = workspace; + EXPECT_TRUE(mota_nrf52_internal_patch_workspace_valid( + workspace, APP_V6, staged, target_size, EXPANDED)); + EXPECT_FALSE(mota_nrf52_internal_patch_workspace_valid( + workspace + 1u, APP_V6, staged, target_size, EXPANDED)); + EXPECT_FALSE(mota_nrf52_internal_patch_workspace_valid( + workspace, APP_V6, staged, EXPANDED - APP_V6 + 1u, EXPANDED)); +} + +TEST(OtaFlashPlan, MissingEndfRejectsEverySharedInternalPackageBeforeErase) { + uint32_t protected_end = 0; + ASSERT_TRUE(mota_nrf52_protected_app_end( + APP_V6, EXPANDED, false, 0, false, protected_end)); + EXPECT_EQ(protected_end, APP_V6 + MOTA_NRF52_FALLBACK_INPLACE_MEMORY); + + // A shared-internal-update build is linked through ED000, so the old 608 KiB + // estimate is not a safe erase floor for either package kind. Missing EndF + // disables all internal staging before begin()/reopen can erase a page. + EXPECT_FALSE(mota_nrf52_protected_app_end( + APP_V6, EXPANDED, false, 0, true, protected_end)); + + // The old rescue estimate would also admit this ordinary 80 KiB delta at + // D9000 even though a legal ED000-linked image can have live bytes there. + // A privileged shared-internal build therefore never calls stage_plan with + // that estimate: protected_app_end() above returns false/capacity zero. + const uint32_t ordinary_container = 80u * 1024u; + uint32_t unsafe_start = 0; + const uint32_t legacy_estimate = + APP_V6 + MOTA_NRF52_FALLBACK_INPLACE_MEMORY; + ASSERT_TRUE(mota_nrf52_stage_plan( + ordinary_container, APP_V6, legacy_estimate, EXPANDED, unsafe_start)); + const uint32_t legal_live_tail = unsafe_start + MOTA_NRF52_FLASH_PAGE; + ASSERT_GT(legal_live_tail, legacy_estimate); + ASSERT_LT(legal_live_tail, EXPANDED); + EXPECT_FALSE(mota_nrf52_stage_plan( + ordinary_container, APP_V6, legal_live_tail, EXPANDED, protected_end)); + + // Model a legal linked image whose tail extends beyond the old 608 KiB + // estimate. The privileged planner rejects missing EndF even if the generic + // rescue fallback would appear to leave enough room. + const uint32_t real_tail = APP_V6 + MOTA_NRF52_FALLBACK_INPLACE_MEMORY + 0x8000u; + ASSERT_LT(real_tail, SHARED_BOOT_START); + uint32_t staged = 0; + EXPECT_FALSE(mota_nrf52_shared_boot_stage_plan( + MOTA_NRF52_BOOT_CONTAINER_SIZE, APP_V6, false, protected_end, staged)); + ASSERT_TRUE(mota_nrf52_shared_boot_stage_plan( + MOTA_NRF52_BOOT_CONTAINER_SIZE, APP_V6, true, real_tail, staged)); + EXPECT_EQ(staged, SHARED_BOOT_START); +} + +TEST(OtaFlashPlan, ReopenBoundsUntrustedTotalBeforeManifestRead) { + const uint32_t start = SHARED_BOOT_START; + EXPECT_TRUE(mota_nrf52_container_span_valid( + start, EXPANDED, MOTA_NRF52_BOOT_CONTAINER_SIZE, 8u + 197u + 5u)); + EXPECT_FALSE(mota_nrf52_container_span_valid( + start, EXPANDED, UINT32_MAX, 8u + 197u + 5u)); + EXPECT_FALSE(mota_nrf52_container_span_valid( + start, EXPANDED, 8u + 197u + 4u, 8u + 197u + 5u)); + EXPECT_FALSE(mota_nrf52_container_span_valid( + EXPANDED + 1u, EXPANDED, MOTA_NRF52_BOOT_CONTAINER_SIZE, + 8u + 197u + 5u)); +} + // out_start is only written on success - a rejected plan must not clobber the caller's variable. TEST(OtaFlashPlan, LeavesOutputUntouchedOnReject) { uint32_t start = 0x1234ABCD; diff --git a/test/test_trace_retry/test_trace_retry.cpp b/test/test_trace_retry/test_trace_retry.cpp index e5752f85..5824a1c6 100644 --- a/test/test_trace_retry/test_trace_retry.cpp +++ b/test/test_trace_retry/test_trace_retry.cpp @@ -164,6 +164,21 @@ static mesh::Packet makeFloodPacket(uint8_t payload_type) { return packet; } +static mesh::Packet makeOtaManifestFragment(uint8_t format_version) { + mesh::Packet packet = makeFloodPacket(PAYLOAD_TYPE_OTA); + packet.payload_len = 12; + packet.payload[0] = mesh::ota::OTA_MANIFEST; + packet.payload[1] = 0x11; + packet.payload[2] = 0x22; + packet.payload[3] = 0x33; + packet.payload[4] = 0x44; + packet.payload[5] = 0; // fragment index + packet.payload[6] = 2; // fragment count + memcpy(packet.payload + 7, mesh::ota::MOTA_MAGIC, sizeof(mesh::ota::MOTA_MAGIC)); + packet.payload[11] = format_version; + return packet; +} + TEST(RepeaterTransport, UnknownFloodPayloadIsRelayedWhenForwardingAllowsIt) { TraceTestClock clock; TraceTestRTC rtc; @@ -233,6 +248,41 @@ TEST(RepeaterTransport, OtaDiscoveryRelaysInBackgroundOnlyDuringTempRadio) { EXPECT_FALSE(node.canTransmit(&packet)); // queued-near-expiry packets cannot leak onto the normal channel } +TEST(RepeaterTransport, AppV2AndBootV3ShareOta0cTempRadioRelayPolicy) { + // The package format is metadata inside an OTA_MANIFEST fragment. It must + // never select a second mesh payload type or bypass the repeater default + // `flood.filter.1 0x0C all suspend=tempradio` policy. + ASSERT_EQ(0x0C, PAYLOAD_TYPE_OTA); + const uint8_t formats[] = { + mesh::ota::MOTA_APP_FORMAT_VER, + mesh::ota::MOTA_BOOT_FORMAT_VER, + }; + + for (uint8_t format : formats) { + TraceTestClock clock; + TraceTestRTC rtc; + TraceTestRNG rng; + TraceTestRadio radio; + ForwardingTestTables tables; + StaticPoolPacketManager manager(12); + TraceTestMesh node(radio, clock, rng, rtc, manager, tables); + node.forwardFloods = true; + + mesh::Packet packet = makeOtaManifestFragment(format); + ASSERT_EQ(PAYLOAD_TYPE_OTA, packet.getPayloadType()); + ASSERT_EQ(format, packet.payload[11]); + EXPECT_EQ(ACTION_RELEASE, node.receivePacket(&packet)); + EXPECT_EQ(0, tables.mark_seen_calls); + + node.tempRadioActive = true; + mesh::DispatcherAction action = node.receivePacket(&packet); + EXPECT_NE(ACTION_RELEASE, action); + EXPECT_EQ(OTA_TRANSFER_TX_PRIORITY, (action >> 24) - 1); + EXPECT_EQ(1, tables.mark_seen_calls); + EXPECT_EQ(1, packet.getPathHashCount()); + } +} + TEST(RepeaterTransport, OtaTransferRelaysAsPrimaryTrafficWithoutOtaManager) { TraceTestClock clock; TraceTestRTC rtc; diff --git a/tools/mota/README.md b/tools/mota/README.md index 7067b24d..0c460fa4 100644 --- a/tools/mota/README.md +++ b/tools/mota/README.md @@ -25,13 +25,13 @@ Uses the repo's Python venv (`meshcore/`). Dependencies: `detools` (delta), `cry | `motalib.py` | Core logic: multihash, EndF, merkle tree+proofs, manifest/container build/parse/verify. The **reference implementation** of the spec and the unit-test oracle. Imported by everything below. | | `pio_endf.py` | **PlatformIO post-build hook** (wired in `platformio.ini`) that injects the `EndF` self-identity trailer into the flashed firmware (`-D ENABLE_OTA`). | | `gen_vectors.py` | Generates `test/test_ota/mota_vectors.h` - the cross-check vectors the native C++ tests run against. | -| `gen_targets.py` | Generates `src/helpers/ota/OtaTargets.h` - the `target_id -> env-name` table (every `ENABLE_OTA` env, resolved from `pio project config`). Shared by the firmware and `motatool` so a node can name a target seen over the air without sending the string. Regenerate when the OTA env set changes. | +| `gen_targets.py` | Generates `src/helpers/ota/OtaTargets.h` - the `target_id -> env-name` table (every resolved `ENABLE_OTA` env plus qualified build.sh-only release aliases). Shared by the firmware and `motatool` so a node can name a target seen over the air without sending the string. Regenerate when the OTA env or release-alias set changes. | | `test_mota.py` | Unit tests for `motalib` (run directly or via pytest). | ## Tests ```bash -./meshcore/bin/python tools/mota/test_mota.py # EndF, merkle+proofs, v2 apps/v3 XIAO bootloader, +./meshcore/bin/python tools/mota/test_mota.py # EndF, merkle+proofs, v2 apps/v3 nRF52 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`) @@ -52,19 +52,25 @@ firmware and matches a delta's `base_hash` against its own `EndF`. Wiring (handl 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 +## Privileged nRF52 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. +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 +`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. 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). +[`docs/ota_nrf52_bootloader_update.md`](../../docs/ota_nrf52_bootloader_update.md). The ordinary `tools/lora_ota` runner rejects bootloader packages. diff --git a/tools/mota/gen_targets.py b/tools/mota/gen_targets.py index e9e3c387..3e2298cd 100644 --- a/tools/mota/gen_targets.py +++ b/tools/mota/gen_targets.py @@ -1,10 +1,12 @@ #!/usr/bin/env python3 -"""Generate src/helpers/ota/OtaTargets.h - the target_id -> env-name table for OTA-capable envs. +"""Generate src/helpers/ota/OtaTargets.h - the target_id -> env-name table for OTA-capable builds. A target_id is sha2-256:4(pio_env_name); it travels in beacons / the .mota manifest as 4 bytes. This table lets a node (and motatool) print the human-readable env name for a target seen over the air, WITHOUT -ever transmitting the string. The set of envs is resolved from PlatformIO (every env whose build_flags -include ENABLE_OTA and do not include DISABLE_LORA_OTA), so explicit size/safety exclusions stay out. +ever transmitting the string. The set is the union of PlatformIO environments whose resolved build flags +enable LoRa OTA and qualified release-only aliases composed by build.sh. Keeping those aliases in the +table prevents a valid release target from being displayed as unknown merely because it is not a literal +``[env:...]`` section. Run: ./meshcore/bin/python tools/mota/gen_targets.py # resolves via `pio project config` ./meshcore/bin/python tools/mota/gen_targets.py cfg.json # or reuse a cached config dump (faster) @@ -21,6 +23,7 @@ import motalib as ml ROOT = Path(__file__).resolve().parents[2] OUT = ROOT / "src" / "helpers" / "ota" / "OtaTargets.h" +INTERNAL_BOOTLOADER_TARGETS = ROOT / "tools" / "mota" / "nrf52_internal_bootloader_targets.txt" def resolved_config(): @@ -46,14 +49,25 @@ def ota_envs(cfg): return sorted(set(envs)) +def release_aliases(): + """Return qualified build.sh aliases that carry their own OTA target ID.""" + return sorted({ + line.strip() + for line in INTERNAL_BOOTLOADER_TARGETS.read_text(encoding="utf-8").splitlines() + if line.strip() and not line.lstrip().startswith("#") + }) + + def main(): - envs = ota_envs(resolved_config()) + envs = sorted(set(ota_envs(resolved_config())) | set(release_aliases())) rows = [(ml.target_id_for_env(e), e) for e in envs] seen = {} - for tid, e in rows: # sha2-256:4 over ~300 names - verify no collisions + for tid, e in rows: # sha2-256:4 over all names - verify no collisions if tid in seen and seen[tid] != e: - print(f"WARNING: target_id collision 0x{tid:08x}: {seen[tid]!r} vs {e!r}", file=sys.stderr) + raise RuntimeError( + f"target_id collision 0x{tid:08x}: {seen[tid]!r} vs {e!r}" + ) seen[tid] = e rows.sort(key=lambda r: r[1].lower()) # by env name (readable, deterministic) @@ -62,7 +76,7 @@ def main(): "#include ", "", "// AUTO-GENERATED by tools/mota/gen_targets.py - do not edit by hand.", - f"// {len(rows)} OTA-capable PlatformIO envs. Maps target_id (= sha2-256:4 of the env name, LE uint32)", + f"// {len(rows)} OTA-capable build targets. Maps target_id (= sha2-256:4 of the target name, LE uint32)", "// to the human-readable env name, so a node/tool can name a target seen over the air WITHOUT", "// transmitting the string in the .mota / LoRa protocol. Regenerate when the OTA env set changes.", "// Size-constrained receivers can set OTA_TARGET_NAME_TABLE=0. They still match targets by ID;", @@ -96,7 +110,7 @@ def main(): ] OUT.parent.mkdir(parents=True, exist_ok=True) OUT.write_text("\n".join(out) + "\n") - print(f"wrote {OUT} ({len(rows)} OTA envs)") + print(f"wrote {OUT} ({len(rows)} OTA targets)") if __name__ == "__main__": diff --git a/tools/mota/motalib.py b/tools/mota/motalib.py index efcc6e99..43381820 100644 --- a/tools/mota/motalib.py +++ b/tools/mota/motalib.py @@ -68,6 +68,8 @@ NRF52_EXTRAFS_START = 0x000D4000 NRF52_BOOT_SCRATCH_START = 0x000E0000 NRF52_BOOT_SCRATCH_END = 0x000EA000 NRF52_APP_END = 0x000ED000 +NRF52_BOOT_CONTAINER_SIZE = 41330 +NRF52_SHARED_BOOT_STAGE_START = 0x000E2000 NRF52_FLASH_PAGE = 4096 # A validated nRF52 firmware carries this record immediately before EndF. It lets an offline packager @@ -90,8 +92,34 @@ 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_STAGE_CEILING = 0x02 XIAO_BOOT_STORAGE_QSPI = 0x04 XIAO_BOOT_STORAGE_UPDATE = 0x08 +BOOT_STORAGE_KNOWN = 0x0F +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 + +# Exact embedded manifests for the qualified shared-internal nRF52840 +# bootloaders. Parsing/inspection remains generic, but package creation is +# deliberately limited to this collision-audited release inventory. Carrier +# aliases use the same installed bootloader identity and therefore do not add +# rows here. +INTERNAL_BOOTLOADER_IDENTITIES = ( + (0x239A0071, "TOWER_V2_OTA"), + (0x239A0071, "T096_DFU"), + (0x239A0071, "T1_DFU"), + (0x239A0071, "T114_DFU"), + (0x239A0071, "MESH_POCKET_OTA"), + (0x239A00B3, "KeepteenLT1_OTA"), + (0x239A0029, "MX25_DFU"), + (0x239A00B3, "PROM_DFU"), + (0x28860057, "T1KE_DFU"), + (0x239A00DA, "TNM3_DFU"), + (0x239A0029, "3401_DFU"), + (0x239A0029, "4631_DFU"), + (0x239A0029, "RTAG_DFU"), +) # 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. @@ -222,7 +250,6 @@ class Nrf52Layout: 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: @@ -235,7 +262,8 @@ 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_BOOT_SCRATCH_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}") @@ -250,10 +278,12 @@ def build_nrf52_layout(layout: Nrf52Layout) -> bytes: 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") + qspi_shape = (layout.qspi_backed and not layout.sd_backed and + layout.linked_app_end == NRF52_BOOT_SCRATCH_START) + if not qspi_shape: + raise ValueError("nRF52 bootloader scratch requires the XIAO QSPI layout") elif layout.linked_app_end == NRF52_BOOT_SCRATCH_START: - raise ValueError("nRF52 linked app end 0xE0000 requires the bootloader-scratch flag") + raise ValueError("reserved bootloader geometry requires the bootloader-scratch flag") expected_ceiling = (NRF52_APP_END if layout.external_backed else nrf52_stage_ceiling_for_layout( layout.linked_app_end, @@ -501,7 +531,7 @@ def xiao_bootloader_hw_id(board_id: int) -> str: @dataclass(frozen=True) -class XiaoBootloaderIdentity: +class BootloaderIdentity: manifest_offset: int image_start: int image_size: int @@ -510,7 +540,73 @@ class XiaoBootloaderIdentity: crc32: int -def _xiao_bootloader_vector_sane(image: bytes) -> bool: +XiaoBootloaderIdentity = BootloaderIdentity # compatibility for callers of the original reference API + + +def _bootloader_device_name(board_id: int, name_raw: bytes) -> Optional[str]: + if len(name_raw) != 16 or board_id in (0, 0xFFFFFFFF): + return None + if board_id in (XIAO_BOOT_BOARD_ID_BASE, XIAO_BOOT_BOARD_ID_SENSE): + return "XIAO_DFU" if name_raw == XIAO_BOOT_DEVICE_NAME else None + try: + end = name_raw.index(0) + except ValueError: + return None + if end == 0 or end > 15 or any(name_raw[end:]) or any(b < 0x21 or b > 0x7E for b in name_raw[:end]): + return None + return name_raw[:end].decode("ascii", "strict") + + +def bootloader_hw_id(board_id: int, device_name: str) -> bytes: + name_raw = device_name.encode("ascii", "strict") + if len(name_raw) > 15 or _bootloader_device_name(board_id, name_raw.ljust(16, b"\0")) is None: + raise ValueError("invalid embedded bootloader board/name identity") + if board_id in (XIAO_BOOT_BOARD_ID_BASE, XIAO_BOOT_BOARD_ID_SENSE): + return hw_id_bytes(xiao_bootloader_hw_id(board_id)) + return hw_id_bytes(f"NRF_BL_{board_id:08X}_{device_name}") + + +def bootloader_target_id(board_id: int, device_name: str) -> int: + if board_id in (XIAO_BOOT_BOARD_ID_BASE, XIAO_BOOT_BOARD_ID_SENSE): + return board_id + return int.from_bytes(sha256(bootloader_hw_id(board_id, device_name))[:4], "little") + + +def audit_bootloader_target_inventory(application_target_ids=()) -> dict: + """Return target->identity after rejecting boot/app target collisions. + + The generic image parser intentionally accepts future canonical embedded + identities for diagnostics. Only the curated identities below are allowed + to enter a newly built signed package, and release tests pass the generated + application target table here to keep the two wire namespaces disjoint. + """ + identities = ( + (XIAO_BOOT_BOARD_ID_BASE, "XIAO_DFU"), + (XIAO_BOOT_BOARD_ID_SENSE, "XIAO_DFU"), + ) + INTERNAL_BOOTLOADER_IDENTITIES + app_ids = set(application_target_ids) + targets = {} + for identity in identities: + target = bootloader_target_id(*identity) + if target in targets: + raise ValueError( + f"bootloader target collision 0x{target:08X}: " + f"{targets[target]!r} vs {identity!r}") + if target in app_ids: + raise ValueError( + f"bootloader target 0x{target:08X} collides with an application target") + targets[target] = identity + return targets + + +def bootloader_identity_is_buildable(board_id: int, device_name: str) -> bool: + return ((board_id, device_name) in INTERNAL_BOOTLOADER_IDENTITIES or + (board_id, device_name) in ( + (XIAO_BOOT_BOARD_ID_BASE, "XIAO_DFU"), + (XIAO_BOOT_BOARD_ID_SENSE, "XIAO_DFU"))) + + +def _bootloader_vector_sane(image: bytes) -> bool: if len(image) < 8: return False sp, reset = struct.unpack_from(" bool: and XIAO_BOOT_IMAGE_START <= entry < XIAO_BOOT_IMAGE_START + XIAO_BOOT_IMAGE_SIZE -def parse_xiao_bootloader_identity(image: bytes) -> Optional[XiaoBootloaderIdentity]: +def parse_bootloader_identity(image: bytes) -> Optional[BootloaderIdentity]: """Find exactly one CRC-valid embedded v1 manifest, continuing past invalid decoys.""" if len(image) != XIAO_BOOT_IMAGE_SIZE: return None @@ -531,56 +627,82 @@ def parse_xiao_bootloader_identity(image: bytes) -> Optional[XiaoBootloaderIdent start, image_size, board_id = 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. +def parse_xiao_bootloader_identity(image: bytes) -> Optional[XiaoBootloaderIdentity]: + """Compatibility wrapper restricted to the two deployed XIAO identities.""" + identity = parse_bootloader_identity(image) + return identity if identity and identity.board_id in ( + XIAO_BOOT_BOARD_ID_BASE, XIAO_BOOT_BOARD_ID_SENSE) else None + + +def bootloader_caps_storage(image: bytes) -> Optional[int]: + """Return the one exact supported self-update storage marker, or None.""" + found = None 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 + if (abi < BOOT_FORMAT_VER or abi == 0xFFFF or not codecs & (1 << CODEC_FULL) or + storage & ~BOOT_STORAGE_KNOWN or image[off + 13:off + 16] != b"\0\0\0" or + storage not in (BOOT_STORAGE_QSPI_UPDATE, BOOT_STORAGE_INTERNAL_UPDATE)): + continue + if found is not None: + return None + found = storage + return found + + +def xiao_bootloader_caps_ok(image: bytes) -> bool: + return bootloader_caps_storage(image) == BOOT_STORAGE_QSPI_UPDATE + + +def validate_bootloader_image(image: bytes, target_id: Optional[int] = None, + signed_hw_id: Optional[bytes] = None) -> BootloaderIdentity: + if len(image) != XIAO_BOOT_IMAGE_SIZE: + raise ValueError(f"bootloader image must be exactly {XIAO_BOOT_IMAGE_SIZE} bytes") + if not _bootloader_vector_sane(image): + raise ValueError("bootloader vector table is invalid") + identity = parse_bootloader_identity(image) + if identity is None: + raise ValueError("bootloader embedded manifest/CRC is invalid or ambiguous") + expected_target = bootloader_target_id(identity.board_id, identity.device_name) + if target_id is not None and target_id != expected_target: + raise ValueError("bootloader target ID does not match embedded identity") + expected_hw = bootloader_hw_id(identity.board_id, identity.device_name) + if signed_hw_id is not None and bytes(signed_hw_id) != expected_hw: + raise ValueError("bootloader signed hw_id does not match embedded identity") + expected_storage = (BOOT_STORAGE_QSPI_UPDATE + if identity.board_id in (XIAO_BOOT_BOARD_ID_BASE, + XIAO_BOOT_BOARD_ID_SENSE) + else BOOT_STORAGE_INTERNAL_UPDATE) + if bootloader_caps_storage(image) != expected_storage: + raise ValueError(f"bootloader lacks exact ABI 3 self-update capabilities 0x{expected_storage:02X}") + return identity 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") + identity = validate_bootloader_image(image) + if identity.board_id not in (XIAO_BOOT_BOARD_ID_BASE, XIAO_BOOT_BOARD_ID_SENSE): + raise ValueError("bootloader is not a deployed XIAO identity") 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 @@ -601,16 +723,19 @@ def build_manifest(*, target_id: int, fw_version: int, image_size: int, payload: 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) + identity = validate_bootloader_image(payload, target_id) + audit_bootloader_target_inventory() + if not bootloader_identity_is_buildable(identity.board_id, identity.device_name): + raise ValueError("bootloader embedded identity is not in the qualified build inventory") 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") + expected_hw = bootloader_hw_id(identity.board_id, identity.device_name) + if hw_id is not None and hw_id_bytes(hw_id) != expected_hw: + raise ValueError("bootloader package hw_id must be canonical for its embedded identity") hw_id = expected_hw m = Manifest( format_ver=BOOT_FORMAT_VER if bootloader else APP_FORMAT_VER, @@ -707,10 +832,12 @@ def parse_container(blob: bytes) -> Parsed: 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))): + m.block_size_log2 != 10 or m.block_count != 40 or m.base_hash != b"\0" * 8): raise ValueError("v3 bootloader manifest geometry/identity is invalid") + try: + validate_bootloader_image(payload, m.target_id, m.hw_id) + except (UnicodeDecodeError, ValueError) as exc: + raise ValueError(f"v3 bootloader image identity/capability is invalid: {exc}") from exc return Parsed(manifest=m, payload=payload, total_size=total) @@ -726,7 +853,7 @@ def verify(parsed: Parsed, *, expect_pub: Optional[bytes] = None, if m.is_bootloader: try: - validate_xiao_bootloader_image(payload, m.target_id) + validate_bootloader_image(payload, m.target_id, m.hw_id) except (UnicodeDecodeError, ValueError) as exc: problems.append(f"bootloader image contract: {exc}") diff --git a/tools/mota/nrf52_internal_bootloader_targets.txt b/tools/mota/nrf52_internal_bootloader_targets.txt new file mode 100644 index 00000000..62c1c9bc --- /dev/null +++ b/tools/mota/nrf52_internal_bootloader_targets.txt @@ -0,0 +1,27 @@ +# Install-capable, internal-flash nRF52840 LoRa OTA targets whose exact OTAFIX +# manifest identity and shared ED000/E2000 runtime geometry are qualified. This file is the +# shared allowlist for build.sh release aliases and the subset that are also +# literal PlatformIO envs. Synthetic aliases are activated by build.sh. +# External QSPI/SD, internal ExtraFS, seeder-only, Ethernet, and full-sensor +# roles are intentionally absent. +Heltec_tower_v2_repeater_lora_ota_no_external_sensors +Heltec_t096_repeater_lora_ota_no_external_sensors +Heltec_t096_repeater_bridge_rs232_lora_ota_no_external_sensors +Heltec_t1_repeater_lora_ota_no_external_sensors +GAT562_30S_Mesh_Kit_repeater_lora_ota_no_external_sensors +Heltec_t114_without_display_repeater_lora_ota_no_external_sensors +Heltec_t114_repeater_lora_ota_no_external_sensors +Mesh_pocket_repeater_lora_ota_no_external_sensors +KeepteenLT1_repeater_lora_ota_no_external_sensors +Minewsemi_me25ls01_repeater_lora_ota_no_external_sensors +ProMicro_repeater_lora_ota_no_external_sensors +t1000e_repeater_lora_ota_no_external_sensors +ThinkNode_M3_repeater_lora_ota_no_external_sensors +RAK_3401_repeater_lora_ota_no_external_sensors +RAK_4631_repeater_lora_ota_no_external_sensors +RAK_4631_repeater_bridge_rs232_serial1_lora_ota_no_external_sensors +RAK_4631_repeater_bridge_rs232_serial2_lora_ota_no_external_sensors +GAT562_Mesh_Tracker_Pro_repeater_lora_ota_no_external_sensors +R1Neo_repeater_lora_ota_no_external_sensors +GAT562_Mesh_EVB_Pro_repeater_lora_ota_no_external_sensors +RAK_WisMesh_Tag_repeater_lora_ota_no_external_sensors diff --git a/tools/mota/pio_endf.py b/tools/mota/pio_endf.py index 26650ba5..a42364bf 100644 --- a/tools/mota/pio_endf.py +++ b/tools/mota/pio_endf.py @@ -179,23 +179,29 @@ 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 + qspi_bootloader_update = _cppdef("OTA_QSPI_BOOTLOADER_UPDATE") is not None + internal_bootloader_update = _cppdef("OTA_INTERNAL_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: + if qspi_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") + if internal_bootloader_update: + if sd_backed or qspi_backed or _cppdef("QSPIFLASH") is not None or internal_extrafs: + raise RuntimeError("internal bootloader update cannot use SD/QSPI/ExtraFS") + if linked_app_end != ml.NRF52_APP_END: + raise RuntimeError("shared-slot bootloader update requires the normal 0xED000 linker ceiling") 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_BOOTLOADER_SCRATCH if bootloader_update else 0)) + (ml.NRF52_LAYOUT_FLAG_BOOTLOADER_SCRATCH if qspi_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 de49b2d9..ab361803 100644 --- a/tools/mota/test_mota.py +++ b/tools/mota/test_mota.py @@ -13,6 +13,7 @@ import io import os import random import struct +from pathlib import Path import motalib as ml from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey @@ -40,8 +41,34 @@ def test_target_id_for_env(): assert ml.target_id_for_env("RAK_4631_repeater") != ml.target_id_for_env("RAK_4631_companion_radio_usb") +def test_internal_bootloader_target_wiring_is_central_and_not_duplicated(): + root = Path(__file__).resolve().parents[2] + inventory = [ + line.strip() + for line in (root / "tools/mota/nrf52_internal_bootloader_targets.txt") + .read_text(encoding="utf-8").splitlines() + if line.strip() and not line.lstrip().startswith("#") + ] + assert len(inventory) == len(set(inventory)) + assert len(inventory) == 21 + + target_header = (root / "src/helpers/ota/OtaTargets.h").read_text(encoding="utf-8") + for target in inventory: + assert f'"{target}"' in target_header + + pio = (root / "platformio.ini").read_text(encoding="utf-8") + nrf52_base = pio.split("[nrf52_base]", 1)[1].split("\n[", 1)[0] + assert nrf52_base.count("pre:scripts/nrf52_internal_bootloader_link.py") == 1 + + build = (root / "build.sh").read_text(encoding="utf-8") + recipe = build.split("apply_nrf52_lora_ota_build_recipe()", 1)[1] + recipe = recipe.split("supports_nrf52_internal_bootloader_update()", 1)[0] + assert "scripts/nrf52_internal_bootloader_link.py" not in recipe + assert "export MESHCORE_NRF52_INTERNAL_BOOTLOADER_UPDATE=1" in recipe + + def test_ota_target_generation_honors_explicit_disable(): - from gen_targets import ota_envs + from gen_targets import ota_envs, release_aliases cfg = [ ["env:enabled", [["build_flags", ["-D ENABLE_OTA=1"]]]], @@ -49,6 +76,7 @@ def test_ota_target_generation_honors_explicit_disable(): ["env:unrelated", [["build_flags", ["-D NRF52_PLATFORM"]]]], ] assert ota_envs(cfg) == ["enabled"] + assert "RAK_3401_repeater_lora_ota_no_external_sensors" in release_aliases() def test_hardware_id_for_env(): @@ -150,6 +178,15 @@ def test_nrf52_layout_record_roundtrip_and_policy(): 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 + shared_internal = ml.Nrf52Layout( + ml.NRF52_APP_BASE_S140_V6, ml.NRF52_APP_END, + ml.NRF52_APP_END, 0) + shared_internal_image, _ = ml.ensure_endf( + ml.ensure_nrf52_layout(_fw(9, 2048), shared_internal)) + assert ml.parse_nrf52_layout(shared_internal_image) == shared_internal + assert not shared_internal.bootloader_scratch + assert ((ml.NRF52_APP_END - ml.NRF52_BOOT_CONTAINER_SIZE) & + ~(ml.NRF52_FLASH_PAGE - 1)) == ml.NRF52_SHARED_BOOT_STAGE_START try: ml.build_nrf52_layout(ml.Nrf52Layout( ml.NRF52_APP_BASE_S140_V7, ml.NRF52_EXTRAFS_START, @@ -160,6 +197,7 @@ def test_nrf52_layout_record_roundtrip_and_policy(): for flags in ( ml.NRF52_LAYOUT_FLAG_SD | ml.NRF52_LAYOUT_FLAG_QSPI, ml.NRF52_LAYOUT_FLAG_QSPI | ml.NRF52_LAYOUT_FLAG_INTERNAL_EXTRAFS, + 0x10, ): try: ml.build_nrf52_layout(ml.Nrf52Layout( @@ -176,7 +214,7 @@ def _xiao_bootloader_image(board_id=ml.XIAO_BOOT_BOARD_ID_BASE): struct.pack_into("