Add MeshTower SD bootloader updates over LoRa

This commit is contained in:
mikecarper committed 2026-08-21 09:55:15 -07:00
1 parent 9e4d246ac6
commit 3d78d9c846
26 files changed
+763 -114

No files matched your search

+1 -1
View File
@@ -166,7 +166,7 @@ fix, no WiFi connection, an inactive bridge, or an nRF52 bootloader without
| LoRa OTA | [`ota get`; `ota pull`; `ota download`](ota_protocol.md#11-cli-surface-otaclicpp) | LoRa OTA build; internal-store nRF52 installs in-place deltas, while matched SD/QSPI nRF52 also accepts full images | No | No | Yes |
| LoRa OTA | [`ota install`; `ota apply`; `ota applydelta`](ota_protocol.md#11-cli-surface-otaclicpp) | Compatible bootloader and completed update | No | No | Yes |
| LoRa OTA | [`ota rescue install <base_hash16>`](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 <MID8> <HASH16>`](ota_nrf52_bootloader_update.md) | Allowlisted internal-flash or XIAO-QSPI nRF52 repeater with ABI-3 exact-board OTAFIX | No | No | Feature |
| LoRa OTA | [`ota bootloader`; `ota bootloader install <MID8> <HASH16>`](ota_nrf52_bootloader_update.md) | Allowlisted internal-flash, XIAO-QSPI, or exact MeshTower V2 microSD nRF52 repeater with ABI-3 exact-board OTAFIX | No | No | Feature |
| LoRa OTA | [`ota cancel`; `ota drop`; `ota stop`](ota_protocol.md#11-cli-surface-otaclicpp) | LoRa OTA build | No | No | Yes |
| LoRa OTA | [`ota announce`; `ota adv`](ota_protocol.md#11-cli-surface-otaclicpp) | LoRa OTA build | No | No | Yes |
| LoRa OTA | [`ota self`; `ota id`](ota_protocol.md#11-cli-surface-otaclicpp) | Firmware with EndF trailer | No | No | Yes |
+2 -2
View File
@@ -17,8 +17,8 @@ The script cannot install the destination's first OTA-capable firmware. It is
also deliberately application-only: it rejects both v3 bootloader containers
and a v2 container carrying the bootloader flag, and it never sends the
privileged install command. Do the first nRF52 bootloader installation over USB.
An already-capable, explicitly supported internal-flash or XIAO QSPI repeater
may later use the separate manual
An already-capable, explicitly supported internal-flash, XIAO QSPI, or exact
MeshTower V2 microSD repeater may later use the separate manual
[`ota bootloader install`](ota_nrf52_bootloader_update.md) workflow.
## Required topology
+3 -2
View File
@@ -335,8 +335,9 @@ or `delta` for any supported nRF52 target. Do not select `[unsupported]` (for ex
full image on an internal-staging nRF52).
Rows labelled `bootloader` are also outside this ordinary application flow:
they are never automatic and `ota install` refuses them. Only an
already-capable, allowlisted internal-flash or XIAO-QSPI nRF52 target can use
the separate [explicit bootloader workflow](ota_nrf52_bootloader_update.md).
already-capable, allowlisted internal-flash, XIAO-QSPI, or exact MeshTower V2
microSD nRF52 target can use the separate
[explicit bootloader workflow](ota_nrf52_bootloader_update.md).
Use the row's stable eight-hex manifest ID rather than its changing list position:
```text
+64 -4
View File
@@ -2,9 +2,10 @@
The `Heltec_tower_v2_sdcard_repeater_lora_ota_no_external_sensors` target uses the MeshTower V2 onboard
microSD socket as persistent storage for its own LoRa OTA downloads. It accepts
both full `.mota` images and in-place delta `.mota` images. After verification,
the matching SD-aware OTAFIX bootloader reads the staged file from the card and
programs the nRF52840 application region.
full application images, in-place application deltas, and explicitly selected
signed v3 bootloader packages. After verification, the matching SD-aware OTAFIX
bootloader reads the staged file from the card and programs the selected
nRF52840 application or bootloader region.
The pin assignment follows the
[Heltec MeshTower V2 partial reference circuit](https://resource.heltec.cn/download/MeshTower-V2/schematic/MeshTower_V2_Partial_Reference_Circuit.pdf):
@@ -70,6 +71,65 @@ bootloader capability marker does not advertise SD staging and the selected
codec. Existing `Heltec_tower_v2_repeater` firmware continues to use the
internal-flash delta path and is unchanged.
## Signed bootloader update
Only this exact SD build exposes the privileged LoRa bootloader-update command.
It requires an already installed exact-board ABI-3 OTAFIX bootloader whose one
unambiguous capability marker is exactly `0x09` (`SD|BOOT_UPDATE`) and whose
codec mask is `0x0005` (`FULL|INPLACE`). Requiring both application codecs
prevents a bootloader self-update from disabling either normal SD application
path. It cannot bootstrap a stock or older bootloader; provision that
prerequisite once over USB/BLE DFU or SWD.
The signed v3 container is exactly 41,330 bytes and carries a 40 KiB candidate
for the installed `239A0071 / TOWER_V2_OTA` identity (boot target `1150F50E`).
It uses the same contiguous `/meshcore-ota.mota` file and sector-1 handoff as an
application update. GPREGRET `0x6B` plus the distinct SD source marker `0x53`
selects the bootloader path; LoRa transport remains payload type `0x0C`.
The application linker still ends at `0xED000`, but OTAFIX needs
`0xE0000..0xEA000` as temporary scratch while replacing itself. Before a boot
package can be downloaded or approved, MeshCore requires a hash-valid live
`EndF` proving the complete running image ends by `0xE0000`; OTAFIX repeats
that no-overlap check before its first erase. An application extending above
that boundary can still receive normal FULL or delta application updates from
SD, but its bootloader must be updated locally.
MeshCore also checks the live boot settings before touching that scratch page.
Erased settings and a valid-app record with CRC disabled are allowed. When a
nonzero bank CRC is active, its recorded `bank_0_size` must cover the complete
EndF-inclusive running image and must end by `0xE0000`; an undersized or
oversized record refuses the operation and requires local DFU/SWD.
Because the card is removable, approval is bound to the exact bytes that were
authenticated. MeshCore verifies the signature and signer allowlist over one
exact local manifest copy, requires the streamed manifest on SD to remain
byte-identical, and verifies the complete payload against that manifest. It
then writes `APRV` and syncs the card. Before publishing the raw-sector
handoff, it writes and readback-verifies a temporary 64-byte `MOTASDBL` token
at `0xE0000` containing the exact container length and that signed manifest's
`image_hash`. OTAFIX requires the parsed SD manifest, streamed payload, and
final scratch image to match the same token. Replacing or changing the card
after verification can therefore only make the boot update fail; it cannot
authorize different bootloader bytes. The token page becomes the first
scratch page during a successful update and is not permanently reserved.
Bootloader packages are never autofetched or autoinstalled. Select and confirm
one exact package manually:
```text
ota ls
ota pull <MID8> flash
# wait for ota status to report the bootloader download ready
ota bootloader
ota bootloader install <MID8> <HASH16>
```
Copy the MID and first 16 image-hash hex digits from `ota bootloader`. Ordinary
`ota install` rejects this package, and the bootloader command rejects an
application package. Keep the SD card inserted through the reboot. A later
`ota status` value of `blup:C8` reports a successful bootloader replacement.
## SD card CLI
The SD-backed target provides these CLI commands:
@@ -100,7 +160,7 @@ reset when the device reboots. The `get sdcard` age queries report how long ago
each operation completed. `get sdcard free` reports used and free filesystem
space in human-readable binary units.
`get sdcard ls` and `get sdcard dir` recursively list files on the card, four
`get sdcard ls` and `get sdcard dir` recursively list files on the card, two
files per page. A bare command shows page 1; append a positive page number to
move through the remaining results. Each row includes the path and a compact
file size. The header reports the selected page, total pages, and total files.
+53 -18
View File
@@ -12,6 +12,33 @@ over USB/BLE DFU or SWD.
| --- | ---: | ---: | ---: | --- |
| XIAO-module raw QSPI | below `0xE0000` | external QSPI offset 0 | dedicated internal `0xE0000..0xEA000` scratch | source `0x51`, flags `0x0E` |
| Qualified internal-flash target | normal `0xED000` limit | shared internal slot, exact start `0xE2000` | the same eleven-page slot; no second reservation | source `0xED`, flags `0x0A` |
| MeshTower V2 microSD | normal `0xED000` limit | contiguous `/meshcore-ota.mota` | dynamic internal `0xE0000..0xEA000` scratch; live image must end by `0xE0000` | source `0x53`, flags `0x09` |
The exact SD target is
`Heltec_tower_v2_sdcard_repeater_lora_ota_no_external_sensors`. Its normal
application FULL and delta updates continue to use the SD file without the
bootloader-update scratch restriction. For a bootloader package only, both
MeshCore and OTAFIX require a hash-valid live `EndF` proving the complete
running image ends at or below `0xE0000`. If boot settings carry a nonzero app
bank CRC, the recorded bank size must also cover that full EndF-inclusive
image and stop by `0xE0000`; erased or explicitly CRC-disabled settings remain
valid. OTAFIX then copies the verified 40
KiB payload from SD into `0xE0000..0xEA000` and uses the MBR copy operation to
replace `0xF4000..0xFE000`. The application linker remains at `0xED000`; a
future application extending above `0xE0000` can still use application mOTA
but must update its bootloader through local DFU/SWD.
The removable SD authorization is fail-closed. MeshCore directly authenticates
one exact manifest, requires the streamed manifest on SD to remain
byte-identical, and verifies its complete payload. After writing and syncing
`APRV`, but before publishing the raw-sector handoff, it writes and
readback-verifies a 64-byte `MOTASDBL` token at `0xE0000`. The token contains
the exact container length and the authenticated signed manifest's
`image_hash`. OTAFIX requires that same hash in the parsed manifest, the
streamed raw payload, and the final scratch image. A card swap or mutation
after application verification therefore causes refusal rather than
authorizing different bytes; the token page is consumed as scratch during a
successful update and is not a permanent reservation.
The internal path does not change the application linker or permanently set
aside separate app-OTA, boot-package, and scratch regions. The ordinary
@@ -86,9 +113,10 @@ ordinary full-sensor target when that module is required.
Boards with onboard external flash are not silently redirected to internal
staging. Mesh Solar, Nano G2 Ultra, T-Impulse Plus, ThinkNode M8, T-Echo
Lite/Card, MeshTracker X1, and Wio WM1110 have board-specific QSPI hardware and
need a separately matched QSPI path where available. Full Companions,
need a separately matched QSPI path where available. Full Companions, other
SD/QSPI/ExtraFS roles, Ethernet roles, source-only roles, and unqualified
full-sensor roles are excluded.
full-sensor roles are excluded from the internal-flash inventory. The exact
MeshTower V2 SD role above is separately qualified for its SD path.
This internal layout is limited to nRF52840 devices with 1 MiB internal flash,
the exact S140 v6/v7 map, and the 40 KiB boot region at
@@ -104,8 +132,9 @@ Check the installed identity and capability marker:
ota bootloader
```
The response must show a CRC-valid exact identity, ABI 3 or newer, full codec,
and exact flags `0x0A` for internal shared storage or `0x0E` for XIAO QSPI. A
The response must show a CRC-valid exact identity, ABI 3 or newer, both FULL
and INPLACE application codecs (`codecs=0x5`), and exact flags `0x09` for
MeshTower V2 SD, `0x0A` for internal shared storage, or `0x0E` for XIAO QSPI. A
bootloader row is visible in `ota ls`, but it is never autofetched or
autoinstalled. Fetch its exact MID, then explicitly arm it:
@@ -126,25 +155,31 @@ bootloader package is not automatically resumed after an application reboot.
Before writing `APRV`, the application authenticates and authorizes the
package: exact v3 geometry, trusted Ed25519 signer, signed/embedded identity,
one unambiguous capability marker, embedded CRC, sane vectors, complete
Merkle/payload/image hashes, safe live placement, and the typed MID/hash
confirmation. OTAFIX consumes that application-written authorization and then
Merkle/payload/image hashes, storage-specific safe live placement, and the
typed MID/hash confirmation. On SD, `APRV` and the internal signed-image-hash
token are synced and read back before the handoff becomes visible. OTAFIX consumes that application-written
authorization and then
independently rechecks the safety/integrity subset: strict v3 structure,
canonical identity/capabilities, vectors, full payload SHA, embedded manifest
CRC, live `EndF` no-overlap geometry, page readback, and final copy hash. It
does not re-run Ed25519, the signer allowlist, Merkle leaves/root, or the typed
operator confirmation. Success is reported as `blup:C8`.
CRC, the applicable live `EndF`/bank-settings no-overlap geometry, the SD token
binding where applicable, scratch readback, and final
copy hash. It does not re-run Ed25519, the signer allowlist, Merkle leaves/root,
or the typed operator confirmation. Success is reported as `blup:C8`.
## Failure behavior
The feature fails closed when the MCU/map is wrong, current valid `EndF`
headroom is insufficient, external/ExtraFS storage owns the target, the exact
installed capability marker is absent or ambiguous, identity cannot be derived
unambiguously, or any package check fails. Before compaction, the application
and bootloader are unchanged. OTAFIX consumes the trigger/approval before the
first in-place compaction erase, so an interrupted conversion cannot retry a
partly converted package; it boots the untouched application and old
bootloader instead. Use USB/BLE DFU or SWD for initial provisioning and local
recovery.
The feature fails closed when the MCU/map is wrong, required valid `EndF`
headroom is insufficient, an unsupported external/ExtraFS role owns the target,
the exact installed capability marker is absent or ambiguous, identity cannot be derived
unambiguously, or any package check fails. Before the storage-specific
scratch/copy step, the application and bootloader are unchanged. OTAFIX
consumes the trigger and, for internal/QSPI storage, clears approval before its
first scratch erase, so an interrupted operation cannot retry a partly
consumed package. The SD backend cannot rewrite raw card sectors: its `APRV`
and checksummed handoff may persist, but the one-shot GPREGRET trigger is
consumed before validation and they remain inert unless the running app again
authenticates and explicitly re-arms that package. Use USB/BLE DFU or SWD for
initial provisioning and local recovery.
For XIAO and ordinary external-QSPI details, see
[nRF52 repeater LoRa OTA with external QSPI](ota_nrf52_qspi.md).
+16 -3
View File
@@ -271,8 +271,9 @@ A v3 bootloader package has a deliberately narrow, non-extensible profile:
- signed `hw_id` exactly `XIAO_BL_28860044`/`XIAO_BL_28860045` for deployed XIAO, or the zero-padded
32-byte `NRF_BL_<BOARD_ID>_<DEVICE_NAME>` for a generic target;
- a sane nRF52840 vector table, exactly one CRC-valid embedded manifest v1 with the exact board/name pair,
and exactly one `MOTABLDR` marker advertising ABI >= 3, full codec, boot-update continuity, and the exact
storage flags for the application layout (`0x0E` XIAO QSPI or `0x0A` shared internal staging).
and exactly one `MOTABLDR` marker advertising ABI >= 3, both application codecs (`FULL|INPLACE`, mask
`0x0005`), boot-update continuity, and the exact storage flags for the application layout (`0x09`
MeshTower V2 SD, `0x0E` XIAO QSPI, or `0x0A` shared internal staging).
The incoming embedded identity must exactly match the installed CRC-valid bootloader identity. Both scans
consider every aligned structurally valid candidate so magic bytes in a literal pool cannot shadow the real
@@ -842,7 +843,19 @@ ota dev ... bring-up helpers (stage/recv/serve/verify)
- **MeshTower V2 SD nRF52:** the application stores a contiguous `/meshcore-ota.mota` on microSD and
publishes its raw sector range in a checksummed handoff record outside the MBR partition. The matching
bootloader reads the card without mounting FAT, supports either a full image or an in-place delta,
verifies the staged/full result hash, and never writes through `0xED000` where InternalFS begins.
verifies the staged/full result hash, and never writes through `0xED000` where InternalFS begins. The
exact SD repeater also accepts a manually selected, signed v3 bootloader package when installed and
candidate markers are exactly `0x09` (`SD|BOOT_UPDATE`). MeshCore streams the same strict identity,
CRC, vector, signature, MID/hash-confirmation, and complete-image checks from the SD file. GPREGRET
`0x6B` plus GPREGRET2 `0x53` selects this privileged path. Both MeshCore and OTAFIX require a hash-valid
live `EndF` ending by `0xE0000`; when a nonzero boot-settings bank CRC is active, its recorded size must
also cover that complete live image and stop by `0xE0000`. MeshCore authenticates one exact manifest,
verifies the streamed SD copy is byte-identical, writes and syncs `APRV`, then writes a readback-checked
`MOTASDBL` token at `0xE0000` containing the exact total and signed `image_hash` before publishing the
raw-sector handoff. OTAFIX binds the parsed manifest, streamed payload, and final scratch image to that
token, so a removable-media change can only fail closed. OTAFIX then uses `0xE0000..0xEA000` as temporary scratch; the normal
application linker remains at `0xED000` and ordinary application updates do not inherit this scratch
headroom restriction.
- **Matched external-QSPI nRF52 repeaters:** the application reserves the board's dedicated QSPI NOR as a
raw store beginning at offset zero. It obtains a 1-16 MiB capacity from JEDEC, checkpoints payload before
leaf metadata, and verifies each erased/programmed page. GPREGRET2 `0x51` selects QSPI only when the
+14 -3
View File
@@ -219,7 +219,9 @@ After it reboots, run `ota status` to confirm the new version.
### Updating an nRF52 bootloader (advanced, explicit only)
This is available on specially marked no-external-flash nRF52840 lean
repeater/bridge builds and on the legacy XIAO nRF52840/Sense raw-QSPI builds,
repeater/bridge builds, on the legacy XIAO nRF52840/Sense raw-QSPI builds, and
on the exact `Heltec_tower_v2_sdcard_repeater_lora_ota_no_external_sensors`
microSD build,
after a one-time exact-board ABI-3 OTAFIX installation over USB/BLE DFU or
SWD. It is not the normal firmware update path. Check support first:
@@ -228,8 +230,8 @@ ota bootloader
```
The reply must show a CRC-valid installed identity plus ABI 3 and the exact
storage/boot-update capability bits for that build (`0x0A` for the shared
internal store or `0x0E` for XIAO raw QSPI). A bootloader package appears as
storage/boot-update capability bits for that build (`0x09` for MeshTower V2
microSD, `0x0A` for the shared internal store, or `0x0E` for XIAO raw QSPI). A bootloader package appears as
`bootloader` in `ota ls`. It is never downloaded or installed automatically,
even if both OTA automation settings are enabled. Use its stable ID explicitly:
@@ -254,6 +256,15 @@ On an internal-flash target, the package shares the ordinary store below
bank. A valid live `EndF` must prove the current image ends at or below that
address before any page is erased. If `EndF` is missing/corrupt or the app is
too large, the pull is refused and local DFU/SWD is required.
On the MeshTower V2 SD target, the application linker remains at `0xED000`, but
bootloader replacement needs temporary scratch beginning at `0xE0000`. A
hash-valid live `EndF` must therefore prove the complete current image ends by
`0xE0000`. A CRC-bound boot-settings bank must cover that complete image while
also ending by `0xE0000`. MeshCore binds approval to the exact authenticated
signed image hash in an internal token before handing the removable SD card to
OTAFIX, so a later card change fails instead of authorizing different bytes.
Larger applications can continue to use normal application mOTA;
only bootloader self-update is refused.
See [the nRF52 bootloader-update guide](ota_nrf52_bootloader_update.md) for the
complete target inventory, storage layouts, and safety contract.
+107 -38
View File
@@ -485,6 +485,8 @@ void ota_reboot_to_bootloader_update() {
uint8_t source = GPREGRET2_OTA_STAGE_QSPI;
#if defined(OTA_INTERNAL_BOOTLOADER_UPDATE)
source = GPREGRET2_OTA_STAGE_EXPANDED;
#elif defined(OTA_SD_BOOTLOADER_UPDATE)
source = GPREGRET2_OTA_STAGE_SD;
#endif
uint8_t sd_en = 0;
sd_softdevice_is_enabled(&sd_en);
@@ -749,22 +751,10 @@ static bool ota_apply_mota_nrf52_external(Store& store, const SignerAllowlist& a
}
#endif
#if defined(OTA_SD_STORE)
bool ota_apply_mota_nrf52(OtaStoreSdNrf52& store, const SignerAllowlist& allow,
ApplyState& st, char* msg) {
return ota_apply_mota_nrf52_external(store, allow, OTA_BL_STORAGE_SD, "SD", st, msg);
}
#endif
#if defined(OTA_QSPI_STORE)
bool ota_apply_mota_nrf52(OtaStoreQspiNrf52& store, const SignerAllowlist& allow,
ApplyState& st, char* msg) {
return ota_apply_mota_nrf52_external(store, allow, OTA_BL_STORAGE_QSPI, "QSPI", st, msg);
}
#if defined(OTA_QSPI_BOOTLOADER_UPDATE)
static bool qspi_bootloader_crc_ok(OtaStoreQspiNrf52& store, uint32_t payload_off,
const OtaBootloaderIdentity& identity) {
#if defined(OTA_SD_BOOTLOADER_UPDATE) || defined(OTA_QSPI_BOOTLOADER_UPDATE)
template <typename Store>
static bool external_bootloader_crc_ok(Store& store, uint32_t payload_off,
const OtaBootloaderIdentity& identity) {
uint8_t buf[512];
uint32_t crc = UINT32_MAX;
for (uint32_t off = 0; off < OTA_BOOT_IMAGE_SIZE; off += sizeof(buf)) {
@@ -781,10 +771,10 @@ static bool qspi_bootloader_crc_ok(OtaStoreQspiNrf52& store, uint32_t payload_of
return ~crc == identity.crc32;
}
static bool qspi_bootloader_image_metadata(OtaStoreQspiNrf52& store, uint32_t payload_off,
const OtaBootloaderIdentity& installed,
OtaBootloaderIdentity& candidate,
OtaBootloaderCapsMarker& caps) {
template <typename Store>
static bool external_bootloader_image_metadata(Store& store, uint32_t payload_off,
OtaBootloaderIdentity& candidate,
OtaBootloaderCapsMarker& caps) {
// Keep enough overlap to catch a 44-byte manifest or 16-byte capability marker split across a
// window. Every aligned manifest candidate is considered; a magic string in a literal pool is not
// a terminal failure.
@@ -804,7 +794,8 @@ static bool qspi_bootloader_image_metadata(OtaStoreQspiNrf52& store, uint32_t pa
OtaBootloaderCapsMarker parsed;
if (ota_bootloader_caps_marker_parse(buf + local, parsed) &&
parsed.apply_abi >= MOTA_BOOT_FORMAT_VER &&
(parsed.codec_mask & (1u << CODEC_FULL)) != 0 &&
(parsed.codec_mask & OTA_BL_REQUIRED_APP_CODEC_MASK) ==
OTA_BL_REQUIRED_APP_CODEC_MASK &&
(parsed.storage_flags & OTA_BL_STORAGE_BOOT_UPDATE) != 0) {
if (++valid_caps != 1u) return false;
if (parsed.storage_flags == ota_bootloader_update_storage_flags()) caps = parsed;
@@ -812,10 +803,8 @@ static bool qspi_bootloader_image_metadata(OtaStoreQspiNrf52& store, uint32_t pa
}
if ((absolute & 3u) != 0 || local + OTA_BOOT_MANIFEST_SIZE > len) continue;
OtaBootloaderIdentity parsed;
if (!ota_bootloader_manifest_parse(buf + local, absolute, parsed) ||
parsed.board_id != installed.board_id ||
memcmp(parsed.device_name, installed.device_name, OTA_BOOT_DEVICE_NAME_SIZE) != 0) continue;
if (qspi_bootloader_crc_ok(store, payload_off, parsed)) {
if (!ota_bootloader_manifest_parse(buf + local, absolute, parsed)) continue;
if (external_bootloader_crc_ok(store, payload_off, parsed)) {
parsed.crc_ok = true;
if (!ota_bootloader_identity_add_unique(parsed, candidate, valid_identities)) return false;
}
@@ -825,13 +814,30 @@ static bool qspi_bootloader_image_metadata(OtaStoreQspiNrf52& store, uint32_t pa
candidate.present && candidate.crc_ok && caps.present;
}
bool ota_prepare_bootloader_update_nrf52(OtaStoreQspiNrf52& store,
const SignerAllowlist& allow,
const OtaBootloaderIdentity& installed,
const uint8_t actual_mid[4],
const uint8_t operator_mid[4],
const uint8_t operator_hash8[8],
ApplyState& st, char* msg) {
#if defined(OTA_SD_BOOTLOADER_UPDATE)
static bool external_bootloader_approve(OtaStoreSdNrf52& store,
const uint8_t image_hash[32]) {
return store.approve_for_bootloader(image_hash);
}
#endif
#if defined(OTA_QSPI_BOOTLOADER_UPDATE)
static bool external_bootloader_approve(OtaStoreQspiNrf52& store,
const uint8_t image_hash[32]) {
(void)image_hash;
return store.approve_for_bootloader();
}
#endif
template <typename Store>
static bool ota_prepare_bootloader_update_external(Store& store,
const SignerAllowlist& allow,
const OtaBootloaderIdentity& installed,
const uint8_t actual_mid[4],
const uint8_t operator_mid[4],
const uint8_t operator_hash8[8],
const char* storage_name,
ApplyState& st, char* msg) {
static const size_t CAP = 96;
st = ApplyState();
const uint32_t total = store.staged_size();
@@ -839,12 +845,23 @@ bool ota_prepare_bootloader_update_nrf52(OtaStoreQspiNrf52& store,
if (total < 8u + MOTA_MFL + 5u || !store.read(0, hdr, sizeof(hdr)) ||
memcmp(hdr, MOTA_MAGIC, sizeof(MOTA_MAGIC)) != 0 || rd_u32le(hdr + 4) != total ||
!store.read(8, manifest, sizeof(manifest))) {
strcpy(msg, "QSPI bootloader container parse failed"); return false;
snprintf(msg, CAP, "%s bootloader container parse failed", storage_name); return false;
}
MotaManifest m;
if (!mota_parse_manifest(manifest, sizeof(manifest), m)) {
strcpy(msg, "not a valid v3 bootloader package"); return false;
}
// Authenticate this exact local manifest copy before any removable-media
// reread. The streaming verifier below requires byte identity with it, and
// the SD approval token copies its signed image_hash.
mesh::Identity authenticated_signer(m.signer_pubkey);
if (!authenticated_signer.verify(
m.signature, m.manifest_start, (int)m.signed_len)) {
strcpy(msg, "bootloader package signature invalid"); return false;
}
if (!allow.contains(m.signer_pubkey)) {
strcpy(msg, "bootloader signer is not in the trusted allowlist"); return false;
}
switch (ota_bootloader_confirmation_gate(m, installed, actual_mid, operator_mid,
operator_hash8)) {
case OTA_BOOT_CONFIRM_OK: break;
@@ -859,7 +876,8 @@ bool ota_prepare_bootloader_update_nrf52(OtaStoreQspiNrf52& store,
const OtaBlCaps current_caps = ota_bootloader_caps();
if (!ota_bootloader_self_update_caps_valid(current_caps)) {
strcpy(msg, "installed bootloader cannot safely self-update from QSPI"); return false;
snprintf(msg, CAP, "installed bootloader cannot safely self-update from %s", storage_name);
return false;
}
const uint64_t payload_off64 = 8u + MOTA_MFL + (uint64_t)m.block_count * 4u;
@@ -868,7 +886,8 @@ bool ota_prepare_bootloader_update_nrf52(OtaStoreQspiNrf52& store,
}
const uint32_t payload_off = (uint32_t)payload_off64;
VerifyResult vr = ota_verify(static_cast<const OtaStore&>(store), allow);
VerifyResult vr = ota_verify(
static_cast<const OtaStore&>(store), allow, manifest);
st.manifest_ok = vr.parsed;
st.sig_ok = vr.sig_ok;
st.trusted = vr.trusted;
@@ -890,18 +909,68 @@ bool ota_prepare_bootloader_update_nrf52(OtaStoreQspiNrf52& store,
}
OtaBootloaderIdentity candidate;
OtaBootloaderCapsMarker candidate_caps;
if (!qspi_bootloader_image_metadata(store, payload_off, installed, candidate, candidate_caps) ||
if (!external_bootloader_image_metadata(store, payload_off, candidate, candidate_caps) ||
!ota_bootloader_identity_matches(installed, candidate)) {
strcpy(msg, "candidate bootloader identity/capability/CRC mismatch"); return false;
}
st.slot_ok = true;
if (!store.approve_for_bootloader()) {
snprintf(msg, CAP, "QSPI bootloader handoff failed: %s", store.last_error()); return false;
if (!external_bootloader_approve(store, m.image_hash)) {
snprintf(msg, CAP, "%s bootloader handoff failed: %s", storage_name, store.last_error());
return false;
}
strcpy(msg, "trusted bootloader verified and armed; rebooting after this reply");
return true;
}
#endif
#if defined(OTA_SD_STORE)
bool ota_apply_mota_nrf52(OtaStoreSdNrf52& store, const SignerAllowlist& allow,
ApplyState& st, char* msg) {
return ota_apply_mota_nrf52_external(store, allow, OTA_BL_STORAGE_SD, "SD", st, msg);
}
#if defined(OTA_SD_BOOTLOADER_UPDATE)
bool ota_prepare_bootloader_update_nrf52(OtaStoreSdNrf52& store,
const SignerAllowlist& allow,
const OtaBootloaderIdentity& installed,
const uint8_t actual_mid[4],
const uint8_t operator_mid[4],
const uint8_t operator_hash8[8],
ApplyState& st, char* msg) {
SelfFwInfo fi;
if (!ota_self_firmware(fi) ||
!ota_bootloader_scratch_headroom_valid(
fi.valid, mota_nrf52_app_base(), fi.image_len, OTA_BOOT_SCRATCH_START) ||
!ota_bootloader_live_bank_preserves_scratch(
mota_nrf52_app_base(), fi.image_len, OTA_BOOT_SCRATCH_START)) {
st = ApplyState();
strcpy(msg, "running firmware/settings do not preserve E0000 scratch");
return false;
}
return ota_prepare_bootloader_update_external(
store, allow, installed, actual_mid, operator_mid, operator_hash8, "SD", st, msg);
}
#endif
#endif
#if defined(OTA_QSPI_STORE)
bool ota_apply_mota_nrf52(OtaStoreQspiNrf52& store, const SignerAllowlist& allow,
ApplyState& st, char* msg) {
return ota_apply_mota_nrf52_external(store, allow, OTA_BL_STORAGE_QSPI, "QSPI", st, msg);
}
#if defined(OTA_QSPI_BOOTLOADER_UPDATE)
bool ota_prepare_bootloader_update_nrf52(OtaStoreQspiNrf52& store,
const SignerAllowlist& allow,
const OtaBootloaderIdentity& installed,
const uint8_t actual_mid[4],
const uint8_t operator_mid[4],
const uint8_t operator_hash8[8],
ApplyState& st, char* msg) {
return ota_prepare_bootloader_update_external(
store, allow, installed, actual_mid, operator_mid, operator_hash8, "QSPI", st, msg);
}
#endif
#endif
#if defined(OTA_INTERNAL_BOOTLOADER_UPDATE) && defined(OTA_FLASH_STORE)
+9
View File
@@ -86,6 +86,15 @@ bool ota_rescue_mota_nrf52(const uint8_t* buf, uint32_t len,
class OtaStoreSdNrf52;
bool ota_apply_mota_nrf52(OtaStoreSdNrf52& store,
const SignerAllowlist& allow, ApplyState& st, char* msg);
#if defined(OTA_SD_BOOTLOADER_UPDATE)
bool ota_prepare_bootloader_update_nrf52(OtaStoreSdNrf52& store,
const SignerAllowlist& allow,
const OtaBootloaderIdentity& installed,
const uint8_t actual_mid[4],
const uint8_t operator_mid[4],
const uint8_t operator_hash8[8],
ApplyState& st, char* msg);
#endif
#endif
#if defined(NRF52_PLATFORM) && defined(OTA_QSPI_STORE)
class OtaStoreQspiNrf52;
+23 -6
View File
@@ -34,16 +34,29 @@ static const uint8_t OTA_BL_STORAGE_STAGE_CEILING = 0x02;
static const uint8_t OTA_BL_STORAGE_QSPI = 0x04;
static const uint8_t OTA_BL_STORAGE_BOOT_UPDATE = 0x08;
static const uint8_t OTA_BL_STORAGE_KNOWN = 0x0F;
static const uint8_t OTA_BL_PROFILE_SD_BOOT_UPDATE =
OTA_BL_STORAGE_SD | OTA_BL_STORAGE_BOOT_UPDATE;
static const uint8_t OTA_BL_PROFILE_INTERNAL_BOOT_UPDATE =
OTA_BL_STORAGE_STAGE_CEILING | OTA_BL_STORAGE_BOOT_UPDATE;
static const uint8_t OTA_BL_PROFILE_QSPI_BOOT_UPDATE =
OTA_BL_STORAGE_STAGE_CEILING | OTA_BL_STORAGE_QSPI |
OTA_BL_STORAGE_BOOT_UPDATE;
// A successor must retain both application update paths used by qualified
// external stores: bit 0 CODEC_FULL and bit 2 CODEC_DETOOLS_INPLACE.
static const uint16_t OTA_BL_REQUIRED_APP_CODEC_MASK = 0x0005u;
inline uint8_t ota_bootloader_update_storage_flags() {
#if defined(OTA_INTERNAL_BOOTLOADER_UPDATE)
// No external-storage bit means the ordinary internal flash store. The
// same store holds either an app delta or a boot package; BOOT_UPDATE marks
// only the privileged package capability, not a second storage backend.
return OTA_BL_STORAGE_STAGE_CEILING | OTA_BL_STORAGE_BOOT_UPDATE;
return OTA_BL_PROFILE_INTERNAL_BOOT_UPDATE;
#elif defined(OTA_QSPI_BOOTLOADER_UPDATE)
return OTA_BL_STORAGE_STAGE_CEILING | OTA_BL_STORAGE_QSPI |
OTA_BL_STORAGE_BOOT_UPDATE;
return OTA_BL_PROFILE_QSPI_BOOT_UPDATE;
#elif defined(OTA_SD_BOOTLOADER_UPDATE)
// The complete container remains in the contiguous SD staging file. No
// internal stage-ceiling capability is implied by this external store.
return OTA_BL_PROFILE_SD_BOOT_UPDATE;
#else
return 0;
#endif
@@ -52,7 +65,9 @@ inline uint8_t ota_bootloader_update_storage_flags() {
inline bool ota_bootloader_self_update_caps_valid(const OtaBlCaps& c) {
const uint8_t required = ota_bootloader_update_storage_flags();
return required != 0 && c.present && c.apply_abi >= 3u &&
(c.codec_mask & 1u) != 0 && c.storage_flags == required;
(c.codec_mask & OTA_BL_REQUIRED_APP_CODEC_MASK) ==
OTA_BL_REQUIRED_APP_CODEC_MASK &&
c.storage_flags == required;
}
// Prefer a continuity-capable marker over a numerically newer legacy-looking candidate. Bootloader
@@ -88,7 +103,8 @@ inline OtaBlCaps ota_bl_caps_scan_aligned(const uint8_t* bytes, size_t len,
if (abi == 0 || abi == 0xFFFFu || codecs == 0 || (storage & ~OTA_BL_STORAGE_KNOWN) != 0 ||
p[13] != 0 || p[14] != 0 || p[15] != 0) continue;
const bool exact_profile = exact_storage_flags == 0 || storage == exact_storage_flags;
if (require_boot_update && exact_profile && abi >= 3u && (codecs & 1u) != 0 &&
if (require_boot_update && exact_profile && abi >= 3u &&
(codecs & OTA_BL_REQUIRED_APP_CODEC_MASK) == OTA_BL_REQUIRED_APP_CODEC_MASK &&
(storage & OTA_BL_STORAGE_BOOT_UPDATE) != 0) {
// A self-update build must have one unambiguous privileged marker. Do
// not silently choose between two otherwise valid structures (including
@@ -118,7 +134,8 @@ inline OtaBlCaps ota_bootloader_caps() {
const uint8_t* lo = (const uint8_t*)(uintptr_t)MOTA_NRF52_BL_START;
const uint8_t* hi = (const uint8_t*)(uintptr_t)MOTA_NRF52_BL_END;
return ota_bl_caps_scan_aligned(lo, (size_t)(hi - lo),
#if defined(OTA_QSPI_BOOTLOADER_UPDATE) || defined(OTA_INTERNAL_BOOTLOADER_UPDATE)
#if defined(OTA_QSPI_BOOTLOADER_UPDATE) || defined(OTA_INTERNAL_BOOTLOADER_UPDATE) || \
defined(OTA_SD_BOOTLOADER_UPDATE)
true, ota_bootloader_update_storage_flags()
#else
false
+70 -1
View File
@@ -31,6 +31,18 @@
#endif
#endif
#if defined(OTA_SD_BOOTLOADER_UPDATE)
#if !defined(NRF52_PLATFORM) || !defined(OTA_SD_STORE)
#error "OTA_SD_BOOTLOADER_UPDATE requires nRF52 SD staging"
#endif
#if !defined(HELTEC_TOWER_V2_SDCARD)
#error "OTA_SD_BOOTLOADER_UPDATE is restricted to the qualified MeshTower V2 SD target"
#endif
#if defined(OTA_FLASH_STORE) || defined(OTA_QSPI_STORE) || defined(QSPIFLASH)
#error "SD bootloader staging cannot share another OTA/filesystem store"
#endif
#endif
namespace mesh {
namespace ota {
@@ -45,9 +57,65 @@ static const uint32_t OTA_BOOT_IMAGE_START = 0x000F4000UL;
static const uint32_t OTA_BOOT_IMAGE_SIZE = 0x0000A000UL; // F4000..FE000, padded to exactly 40 KiB
static const uint32_t OTA_BOOT_SCRATCH_START = 0x000E0000UL;
static const uint32_t OTA_BOOT_SCRATCH_END = 0x000EA000UL;
static const uint32_t OTA_NRF52840_BOOT_SETTINGS_ADDRESS = 0x000FF000UL;
static const uint16_t OTA_BOOT_BANK_VALID_APP = 0x0001u;
static const uint16_t OTA_BOOT_BANK_ERASED_FALLBACK = 0xFFFFu;
static const uint32_t OTA_NRF52840_RAM_START = 0x20000000UL;
static const uint32_t OTA_NRF52840_RAM_END = 0x20040000UL;
inline bool ota_bootloader_image_geometry_valid(uint32_t image_size,
uint32_t payload_size) {
return image_size == OTA_BOOT_IMAGE_SIZE && payload_size == OTA_BOOT_IMAGE_SIZE;
}
// External SD keeps the normal ED000 application linker, but OTAFIX needs the
// XIAO-sized E0000..EA000 internal scratch range temporarily while replacing
// itself. A hash-valid live EndF must prove the complete running image ends
// before that scratch range; a linker maximum alone is not sufficient.
inline bool ota_bootloader_scratch_headroom_valid(bool self_valid,
uint32_t app_base,
uint32_t image_len,
uint32_t scratch_start) {
return self_valid && app_base < scratch_start && image_len != 0 &&
image_len <= scratch_start - app_base;
}
// The bootloader may validate BANK_VALID_APP over bank_0_size on every reset.
// Erasing the SD authorization-token page must not change bytes covered by a
// nonzero stored CRC. A CRC-bound bank must cover the complete hash-valid live
// image but stop by scratch_start; a smaller recorded size would disagree with
// OTAFIX even if it did not overlap scratch. Debugger/UF2 installs can have
// erased settings, and a valid-app record with CRC zero explicitly disables
// that CRC check.
inline bool ota_bootloader_scratch_bank_geometry_valid(uint16_t bank_0,
uint16_t bank_0_crc,
uint32_t bank_0_size,
uint32_t live_image_size,
uint32_t safe_span) {
if (bank_0 == OTA_BOOT_BANK_ERASED_FALLBACK) return true;
if (bank_0 != OTA_BOOT_BANK_VALID_APP) return false;
if (bank_0_crc == 0u) return true;
return live_image_size != 0u && bank_0_size >= live_image_size &&
bank_0_size <= safe_span;
}
#if defined(NRF52_PLATFORM)
inline bool ota_bootloader_live_bank_preserves_scratch(uint32_t app_base,
uint32_t live_image_size,
uint32_t scratch_start) {
if (app_base >= scratch_start) return false;
const volatile uint8_t* raw =
(const volatile uint8_t*)(uintptr_t)OTA_NRF52840_BOOT_SETTINGS_ADDRESS;
const uint16_t bank_0 = (uint16_t)(raw[0] | ((uint16_t)raw[1] << 8));
const uint16_t bank_0_crc = (uint16_t)(raw[2] | ((uint16_t)raw[3] << 8));
const uint32_t bank_0_size = (uint32_t)raw[8] | ((uint32_t)raw[9] << 8) |
((uint32_t)raw[10] << 16) | ((uint32_t)raw[11] << 24);
return ota_bootloader_scratch_bank_geometry_valid(
bank_0, bank_0_crc, bank_0_size, live_image_size,
scratch_start - app_base);
}
#endif
// Only the two official XIAO nRF52840 OTAFIX identities are eligible. Runtime matching against the
// installed, CRC-valid manifest distinguishes the base and Sense modules even for carrier-board aliases.
static const uint32_t OTA_XIAO_BOARD_ID_BASE = 0x28860044UL;
@@ -284,7 +352,8 @@ inline bool ota_bootloader_caps_from_image(const uint8_t* image, size_t image_si
OtaBootloaderCapsMarker parsed;
if (!ota_bootloader_caps_marker_parse(image + off, parsed) ||
parsed.apply_abi < MOTA_BOOT_FORMAT_VER ||
(parsed.codec_mask & (1u << CODEC_FULL)) == 0 ||
(parsed.codec_mask & OTA_BL_REQUIRED_APP_CODEC_MASK) !=
OTA_BL_REQUIRED_APP_CODEC_MASK ||
(parsed.storage_flags & OTA_BL_STORAGE_BOOT_UPDATE) == 0) continue;
if (++valid_count != 1u) return false; // privileged marker identity must be unambiguous
if (parsed.storage_flags != exact_storage_flags) continue;
+42 -3
View File
@@ -189,6 +189,16 @@ bool handle_ota_command(const char* command, char* reply, mesh::MainBoard& board
"OTA: status | stats | ls | get <id> flash | install | cancel | announce | self | qspi | "
"folder | config | key");
#endif
#elif defined(NRF52_PLATFORM) && defined(OTA_SD_STORE)
#if defined(OTA_SD_BOOTLOADER_UPDATE)
strcpy(reply,
"OTA: status | stats | ls | get <id> flash | install | bootloader | cancel | announce | self | "
"folder | cache | config | key");
#else
strcpy(reply,
"OTA: status | stats | ls | get <id> flash | install | cancel | announce | self | folder | "
"cache | config | key");
#endif
#else
snprintf(reply, 160,
"OTA: status | stats | ls | get <id> flash | install | cancel | announce | self | folder | "
@@ -328,6 +338,16 @@ bool handle_ota_command(const char* command, char* reply, mesh::MainBoard& board
!defined(OTA_QSPI_STORE)
SelfFwInfo list_self;
bool list_has_endf = ota_self_firmware(list_self) && list_self.valid;
#endif
#if defined(NRF52_PLATFORM) && defined(OTA_SD_BOOTLOADER_UPDATE)
SelfFwInfo list_sd_self;
const bool list_sd_headroom = ota_self_firmware(list_sd_self) &&
ota_bootloader_scratch_headroom_valid(
list_sd_self.valid, mota_nrf52_app_base(), list_sd_self.image_len,
OTA_BOOT_SCRATCH_START) &&
ota_bootloader_live_bank_preserves_scratch(
mota_nrf52_app_base(), list_sd_self.image_len,
OTA_BOOT_SCRATCH_START);
#endif
uint32_t now = millis(); int shown = 0;
uint16_t first = (uint16_t)(page - 1) * PAGE_SIZE;
@@ -355,12 +375,16 @@ bool handle_ota_command(const char* command, char* reply, mesh::MainBoard& board
if (boot_package) {
bool installable = false;
#if defined(NRF52_PLATFORM) && \
(defined(OTA_QSPI_BOOTLOADER_UPDATE) || defined(OTA_INTERNAL_BOOTLOADER_UPDATE))
(defined(OTA_QSPI_BOOTLOADER_UPDATE) || defined(OTA_INTERNAL_BOOTLOADER_UPDATE) || \
defined(OTA_SD_BOOTLOADER_UPDATE))
const OtaBootloaderIdentity& bid = c.bootloaderIdentity();
installable = h->flags == (MFLAG_FULL | MFLAG_SIGNED | MFLAG_BOOTLOADER) &&
h->codec == CODEC_FULL && bid.present && bid.crc_ok &&
h->target_id == ota_bootloader_target_id(bid) &&
ota_bootloader_self_update_caps_valid(list_bl);
#if defined(OTA_SD_BOOTLOADER_UPDATE)
installable = installable && list_sd_headroom;
#endif
#endif
fit = installable ? "yours" : "bootloader unsupported";
} else if (myt && h->target_id == myt) {
@@ -489,7 +513,8 @@ bool handle_ota_command(const char* command, char* reply, mesh::MainBoard& board
#else
if (selboot) {
#if defined(NRF52_PLATFORM) && \
(defined(OTA_QSPI_BOOTLOADER_UPDATE) || defined(OTA_INTERNAL_BOOTLOADER_UPDATE))
(defined(OTA_QSPI_BOOTLOADER_UPDATE) || defined(OTA_INTERNAL_BOOTLOADER_UPDATE) || \
defined(OTA_SD_BOOTLOADER_UPDATE))
const OtaBootloaderIdentity& bid = c.bootloaderIdentity();
const OtaBlCaps& bl = c.bootloaderCaps();
if (selflags != (MFLAG_FULL | MFLAG_SIGNED | MFLAG_BOOTLOADER) ||
@@ -505,6 +530,19 @@ bool handle_ota_command(const char* command, char* reply, mesh::MainBoard& board
strcpy(reply, "ERR installed bootloader lacks safe LoRa bootloader-update support");
return true;
}
#if defined(OTA_SD_BOOTLOADER_UPDATE)
SelfFwInfo sd_self;
if (!ota_self_firmware(sd_self) ||
!ota_bootloader_scratch_headroom_valid(
sd_self.valid, mota_nrf52_app_base(), sd_self.image_len,
OTA_BOOT_SCRATCH_START) ||
!ota_bootloader_live_bank_preserves_scratch(
mota_nrf52_app_base(), sd_self.image_len,
OTA_BOOT_SCRATCH_START)) {
strcpy(reply, "ERR running firmware/settings do not preserve E0000 scratch; use local DFU/SWD");
return true;
}
#endif
#else
strcpy(reply, "ERR this build cannot install bootloader packages; use folder capture or USB DFU");
return true;
@@ -716,7 +754,8 @@ bool handle_ota_command(const char* command, char* reply, mesh::MainBoard& board
} else if (is_cmd(a, "bootloader|blupdate", &rest)) {
#if defined(NRF52_PLATFORM) && !defined(OTA_SEEDER_ONLY) && \
(defined(OTA_QSPI_BOOTLOADER_UPDATE) || defined(OTA_INTERNAL_BOOTLOADER_UPDATE))
(defined(OTA_QSPI_BOOTLOADER_UPDATE) || defined(OTA_INTERNAL_BOOTLOADER_UPDATE) || \
defined(OTA_SD_BOOTLOADER_UPDATE))
const OtaBootloaderIdentity& bid = c.bootloaderIdentity();
const OtaBlCaps& bl = c.bootloaderCaps();
if (*rest == 0 || strcmp(rest, "status") == 0) {
+6 -3
View File
@@ -35,7 +35,8 @@
// Per-device OTA singleton shared by the CLI (OtaCli) and the mesh adapter (the example's MyMesh).
// Holds the session engine, a persistent staging store (fetch), a RAM serve buffer, and the signer
// allowlist. nRF52 uses one of three stores selected by the exact target: internal flash for in-place
// deltas, raw microSD, or dedicated raw QSPI for full images and in-place deltas. Flash-backed stores
// deltas, raw microSD, or dedicated raw QSPI for full images and in-place deltas. One exact microSD
// target also accepts explicitly confirmed signed bootloader packages. Flash-backed stores
// coalesce writes at their erase-page boundary and checkpoint payload before leaf metadata, so an
// interrupted fetch can be verified and resumed without trusting stale progress markers.
@@ -238,7 +239,8 @@ struct OtaContext {
bool apply_fetched_bootloader(const uint8_t operator_mid[4],
const uint8_t operator_hash8[8], char* msg) {
#if defined(NRF52_PLATFORM) && !defined(OTA_SEEDER_ONLY) && \
(defined(OTA_QSPI_BOOTLOADER_UPDATE) || defined(OTA_INTERNAL_BOOTLOADER_UPDATE))
(defined(OTA_QSPI_BOOTLOADER_UPDATE) || defined(OTA_INTERNAL_BOOTLOADER_UPDATE) || \
defined(OTA_SD_BOOTLOADER_UPDATE))
if (fetch_to_folder) {
strncpy(msg, "refused: bootloader package is not in local install storage", 96);
msg[95] = 0; return false;
@@ -518,7 +520,8 @@ struct OtaContext {
manager.set_apply_codec2(CODEC_DETOOLS_INPLACE); // also accepted -> a single in-place .mota fits both
#endif
#if defined(NRF52_PLATFORM) && \
(defined(OTA_QSPI_BOOTLOADER_UPDATE) || defined(OTA_INTERNAL_BOOTLOADER_UPDATE))
(defined(OTA_QSPI_BOOTLOADER_UPDATE) || defined(OTA_INTERNAL_BOOTLOADER_UPDATE) || \
defined(OTA_SD_BOOTLOADER_UPDATE))
manager.set_accept_bootloader(true);
#else
manager.set_accept_bootloader(false);
+1
View File
@@ -52,6 +52,7 @@ static const uint8_t GPREGRET_OTA_BOOTLOADER_UPDATE = 0x6Bu;
static const uint8_t GPREGRET2_OTA_STAGE_LEGACY = 0xD4u;
static const uint8_t GPREGRET2_OTA_STAGE_EXPANDED = 0xEDu;
static const uint8_t GPREGRET2_OTA_STAGE_QSPI = 0x51u;
static const uint8_t GPREGRET2_OTA_STAGE_SD = 0x53u;
inline uint8_t mota_nrf52_flash_stage_handoff(uint32_t effective_stage_ceiling) {
if (effective_stage_ceiling == MOTA_NRF52_STAGE_CEILING_EXPANDED)
+73
View File
@@ -0,0 +1,73 @@
// Internal authorization token for a bootloader package staged on removable SD.
// Keep byte-identical with OTAFIX src/ota_sd_boot_token.h.
#ifndef OTA_SD_BOOT_TOKEN_H_
#define OTA_SD_BOOT_TOKEN_H_
#include <stddef.h>
#include <stdint.h>
#include <string.h>
#define MOTA_SD_BOOT_TOKEN_VERSION 1u
#define MOTA_SD_BOOT_TOKEN_LEN 64u
#define MOTA_SD_BOOT_TOKEN_IMAGE_HASH_OFFSET 24u
#define MOTA_SD_BOOT_TOKEN_CRC_OFFSET 56u
static const uint8_t MOTA_SD_BOOT_TOKEN_MAGIC[8] = {
'M', 'O', 'T', 'A', 'S', 'D', 'B', 'L'
};
static inline uint32_t mota_sd_boot_token_rd32(const uint8_t *p) {
return (uint32_t)p[0] | ((uint32_t)p[1] << 8) |
((uint32_t)p[2] << 16) | ((uint32_t)p[3] << 24);
}
static inline void mota_sd_boot_token_wr32(uint8_t *p, uint32_t value) {
p[0] = (uint8_t)value;
p[1] = (uint8_t)(value >> 8);
p[2] = (uint8_t)(value >> 16);
p[3] = (uint8_t)(value >> 24);
}
static inline uint32_t mota_sd_boot_token_crc32(const uint8_t *data, size_t len) {
uint32_t crc = 0xFFFFFFFFu;
for (size_t i = 0; i < len; i++) {
crc ^= data[i];
for (uint8_t bit = 0; bit < 8; bit++) {
crc = (crc >> 1) ^ (0xEDB88320u & (uint32_t)-(int32_t)(crc & 1u));
}
}
return ~crc;
}
// image_hash is copied from the exact signed manifest buffer authenticated by
// the application. It binds the only bootloader bytes that the MBR can install.
static inline void mota_sd_boot_token_encode(uint8_t out[MOTA_SD_BOOT_TOKEN_LEN],
uint32_t total,
const uint8_t image_hash[32]) {
memset(out, 0, MOTA_SD_BOOT_TOKEN_LEN);
memcpy(out, MOTA_SD_BOOT_TOKEN_MAGIC, sizeof(MOTA_SD_BOOT_TOKEN_MAGIC));
mota_sd_boot_token_wr32(out + 8u, MOTA_SD_BOOT_TOKEN_VERSION);
mota_sd_boot_token_wr32(out + 12u, MOTA_SD_BOOT_TOKEN_LEN);
mota_sd_boot_token_wr32(out + 16u, total);
mota_sd_boot_token_wr32(out + 20u, ~total);
memcpy(out + MOTA_SD_BOOT_TOKEN_IMAGE_HASH_OFFSET, image_hash, 32u);
const uint32_t crc = mota_sd_boot_token_crc32(out, MOTA_SD_BOOT_TOKEN_CRC_OFFSET);
mota_sd_boot_token_wr32(out + MOTA_SD_BOOT_TOKEN_CRC_OFFSET, crc);
mota_sd_boot_token_wr32(out + MOTA_SD_BOOT_TOKEN_CRC_OFFSET + 4u, ~crc);
}
static inline int mota_sd_boot_token_valid(const uint8_t token[MOTA_SD_BOOT_TOKEN_LEN],
uint32_t total,
const uint8_t image_hash[32]) {
const uint32_t crc = mota_sd_boot_token_rd32(token + MOTA_SD_BOOT_TOKEN_CRC_OFFSET);
return memcmp(token, MOTA_SD_BOOT_TOKEN_MAGIC, sizeof(MOTA_SD_BOOT_TOKEN_MAGIC)) == 0 &&
mota_sd_boot_token_rd32(token + 8u) == MOTA_SD_BOOT_TOKEN_VERSION &&
mota_sd_boot_token_rd32(token + 12u) == MOTA_SD_BOOT_TOKEN_LEN &&
mota_sd_boot_token_rd32(token + 16u) == total &&
mota_sd_boot_token_rd32(token + 20u) == ~total &&
memcmp(token + MOTA_SD_BOOT_TOKEN_IMAGE_HASH_OFFSET, image_hash, 32u) == 0 &&
mota_sd_boot_token_crc32(token, MOTA_SD_BOOT_TOKEN_CRC_OFFSET) == crc &&
mota_sd_boot_token_rd32(token + MOTA_SD_BOOT_TOKEN_CRC_OFFSET + 4u) == ~crc;
}
#endif // OTA_SD_BOOT_TOKEN_H_
+87 -2
View File
@@ -9,9 +9,13 @@
#include <stdlib.h>
#include <string.h>
#include "MeshCore.h"
#include "OtaBootloaderUpdate.h"
#include "OtaByteIO.h"
#include "OtaFlashLayout_nrf52.h"
#include "OtaSdBootToken.h"
#include "OtaSdHandoff.h"
#include "OtaSelf.h"
#include "flash/flash_nrf5x.h"
#ifndef OTA_SD_CS_PIN
#define OTA_SD_CS_PIN PIN_SPI1_NSS
@@ -44,6 +48,7 @@ void OtaStoreSdNrf52::resetStoreState() {
_allocated_sectors = 0;
_partition_start = 0;
_partition_end = 0;
_planned_bootloader = false;
if (_file && *_file) _file->close();
if (_sd) _sd->end();
_mounted = false;
@@ -370,7 +375,19 @@ bool OtaStoreSdNrf52::locate_file() {
bool OtaStoreSdNrf52::plan_layout(bool, uint32_t image_size,
uint32_t, uint32_t payload_size,
bool is_bootloader) {
if (is_bootloader) return false;
_planned_bootloader = false;
if (is_bootloader) {
#if defined(OTA_SD_BOOTLOADER_UPDATE)
if (ota_bootloader_image_geometry_valid(image_size, payload_size)) {
_planned_bootloader = true;
return true;
}
fail("bootloader package geometry mismatch");
#else
fail("bootloader update is not enabled for this SD target");
#endif
return false;
}
const uint32_t app_base = mota_nrf52_app_base();
if (image_size == 0 || payload_size == 0 || app_base >= MOTA_NRF52_APP_END ||
image_size > MOTA_NRF52_APP_END - app_base) {
@@ -464,6 +481,7 @@ void OtaStoreSdNrf52::checkpoint() {
bool OtaStoreSdNrf52::reopen() {
_total = 0;
_planned_bootloader = false;
if (!mount()) return false;
if (*_file) _file->close();
if (!_file->open(PATH, O_RDWR)) return false;
@@ -480,6 +498,32 @@ bool OtaStoreSdNrf52::reopen() {
return false;
}
_total = total;
uint8_t manifest[MOTA_MFL];
MotaManifest parsed;
if (total < 8u + sizeof(manifest) + 5u || !_file->seekSet(8u) ||
_file->read(manifest, sizeof(manifest)) != (int)sizeof(manifest) ||
!mota_parse_manifest(manifest, sizeof(manifest), parsed)) {
_total = 0;
_file->close();
return false;
}
if (parsed.is_bootloader()) {
#if defined(OTA_SD_BOOTLOADER_UPDATE)
const uint64_t payload_off = 8u + sizeof(manifest) +
(uint64_t)parsed.block_count * 4u;
if (!ota_bootloader_image_geometry_valid(parsed.image_size, parsed.payload_size) ||
payload_off + parsed.payload_size + 5u != total) {
_total = 0;
_file->close();
return false;
}
_planned_bootloader = true;
#else
_total = 0;
_file->close();
return false;
#endif
}
if (!locate_file()) {
_total = 0;
_file->close();
@@ -490,6 +534,7 @@ bool OtaStoreSdNrf52::reopen() {
void OtaStoreSdNrf52::clear() {
_total = 0;
_planned_bootloader = false;
_first_sector = 0;
_allocated_sectors = 0;
if (!mount()) return;
@@ -498,14 +543,54 @@ void OtaStoreSdNrf52::clear() {
_sd->remove(PATH);
}
bool OtaStoreSdNrf52::approve_for_bootloader() {
bool OtaStoreSdNrf52::approve_for_bootloader(
const uint8_t expected_boot_image_hash[32]) {
if (!_total || !finalize()) return false;
#if defined(OTA_SD_BOOTLOADER_UPDATE)
if (_planned_bootloader) {
SelfFwInfo fi;
if (!expected_boot_image_hash ||
_total != MOTA_NRF52_BOOT_CONTAINER_SIZE ||
!ota_self_firmware(fi) ||
!ota_bootloader_scratch_headroom_valid(
fi.valid, mota_nrf52_app_base(), fi.image_len,
OTA_BOOT_SCRATCH_START) ||
!ota_bootloader_live_bank_preserves_scratch(
mota_nrf52_app_base(), fi.image_len, OTA_BOOT_SCRATCH_START)) {
fail("running firmware/settings do not preserve boot scratch");
return false;
}
}
#endif
if (!write(8 + MOTA_OFF_APPROVAL, APPROVAL_YES, sizeof(APPROVAL_YES)) ||
!_file->sync() || !_sd->card()->syncDevice()) {
fail("SD approval write failed");
return false;
}
#if defined(OTA_SD_BOOTLOADER_UPDATE)
if (_planned_bootloader) {
uint8_t token[MOTA_SD_BOOT_TOKEN_LEN];
mota_sd_boot_token_encode(token, _total, expected_boot_image_hash);
// This page is outside the hash-valid live app and below InternalFS. The
// token is temporary: OTAFIX validates it against the current SD bytes,
// then consumes the page as the first bootloader scratch page.
flash_nrf5x_flush();
if (!flash_nrf5x_erase(OTA_BOOT_SCRATCH_START) ||
flash_nrf5x_write(OTA_BOOT_SCRATCH_START, token, sizeof(token)) < 0) {
fail("SD bootloader token flash write failed");
return false;
}
flash_nrf5x_flush();
if (memcmp((const void*)(uintptr_t)OTA_BOOT_SCRATCH_START,
token, sizeof(token)) != 0) {
fail("SD bootloader token flash verify failed");
return false;
}
}
#endif
uint8_t sector[MOTA_SD_SECTOR_SIZE];
if (!_sd->card()->readSector(MOTA_SD_HANDOFF_SECTOR, sector)) {
fail("SD handoff sector read failed");
+11 -3
View File
@@ -18,6 +18,8 @@ class OtaCacheSdNrf52;
// Persistent nRF52840 OTA store backed by the MeshTower V2 microSD socket.
// The .mota remains a normal file, but is preallocated contiguously so the
// bootloader can read it by raw sectors without embedding a FAT implementation.
// The exact self-update build uses this same one-file store for either an
// application package or an explicitly confirmed signed bootloader package.
class OtaStoreSdNrf52 : public OtaStore {
public:
OtaStoreSdNrf52();
@@ -36,9 +38,14 @@ public:
uint32_t payload_off, uint32_t payload_size,
bool is_bootloader) override;
// Called only after the app has verified payload, base, signature and trust.
// Writes APRV into the file, then publishes the raw-sector handoff record.
bool approve_for_bootloader();
// Called only after the app has verified package kind, payload, applicable
// base/identity, signature, trust, and storage-specific safety geometry.
// For a bootloader package it writes APRV, binds the exact authenticated
// signed image_hash to an internal-flash token, and only then publishes the
// raw-sector handoff. Application packages retain the ordinary APRV+handoff
// path and omit expected_boot_image_hash.
bool approve_for_bootloader(
const uint8_t expected_boot_image_hash[32] = nullptr);
bool formatCard(MainBoard& board);
bool eraseCard(MainBoard& board);
bool getSpace(MainBoard& board, uint64_t& used_bytes, uint64_t& free_bytes);
@@ -60,6 +67,7 @@ private:
SdFs* _sd = nullptr;
mutable FsFile* _file = nullptr;
bool _mounted = false;
bool _planned_bootloader = false;
uint32_t _total = 0;
uint32_t _first_sector = 0;
uint32_t _allocated_sectors = 0;
+15 -1
View File
@@ -28,7 +28,9 @@ VerifyResult ota_verify(const uint8_t* buf, uint32_t len, const SignerAllowlist&
return r;
}
VerifyResult ota_verify(const OtaStore& store, const SignerAllowlist& allow) {
static VerifyResult ota_verify_store(const OtaStore& store,
const SignerAllowlist& allow,
const uint8_t* expected_manifest) {
VerifyResult r;
const uint32_t total = store.staged_size();
uint8_t hdr[8], manifest[MOTA_MFL], trailer[5];
@@ -36,6 +38,8 @@ VerifyResult ota_verify(const OtaStore& store, const SignerAllowlist& allow) {
!store.read(0, hdr, sizeof(hdr)) ||
memcmp(hdr, MOTA_MAGIC, 4) != 0 || rd_u32le(hdr + 4) != total ||
!store.read(8, manifest, sizeof(manifest)) ||
(expected_manifest &&
memcmp(manifest, expected_manifest, sizeof(manifest)) != 0) ||
!store.read(total - 5, trailer, sizeof(trailer)) ||
memcmp(trailer, MOTA_TRAILER, sizeof(trailer)) != 0) return r;
@@ -99,5 +103,15 @@ VerifyResult ota_verify(const OtaStore& store, const SignerAllowlist& allow) {
return r;
}
VerifyResult ota_verify(const OtaStore& store, const SignerAllowlist& allow) {
return ota_verify_store(store, allow, nullptr);
}
VerifyResult ota_verify(const OtaStore& store, const SignerAllowlist& allow,
const uint8_t expected_manifest[MOTA_MFL]) {
if (!expected_manifest) return VerifyResult();
return ota_verify_store(store, allow, expected_manifest);
}
} // namespace ota
} // namespace mesh
+6
View File
@@ -31,5 +31,11 @@ VerifyResult ota_verify(const uint8_t* buf, uint32_t len, const SignerAllowlist&
// contiguous memory view (notably the MeshTower V2 SD-backed nRF52 store).
VerifyResult ota_verify(const OtaStore& store, const SignerAllowlist& allow);
// As above, but the store's manifest must be byte-identical to an already
// authenticated local copy. This binds removable-media payload verification
// to the exact signed anchors later written into an internal approval token.
VerifyResult ota_verify(const OtaStore& store, const SignerAllowlist& allow,
const uint8_t expected_manifest[MOTA_MFL]);
} // namespace ota
} // namespace mesh
+1 -1
View File
@@ -70,7 +70,7 @@ does not reflect the GoogleTest count -- run the built binary directly
| `test_user_gpio` | `src/helpers/UserGpio.cpp`, `UserGpioReplyTracker.h` | board-approved pins, get/set/reset, timed nonblocking transitions, duplicate suppression, rollover, and completion-reply routing |
| `test_utf8_helpers` | `src/helpers/UTF8Helpers.h` | byte-limit truncation at complete code-point boundaries and rejection of malformed or truncated UTF-8 |
| `test_wifi_ota_seeder_policy` | `src/helpers/WiFiOtaSeederPolicy.h`, `WiFiOtaSeederStatus.h` | listener state versus network availability, serial/TCP folder ownership, detach detection, and bounded status formatting |
| `test_ota` | `src/helpers/ota/` | v2 application/v3 bootloader parser separation; legacy XIAO and generic embedded identity, vector, capability, explicit-confirmation, codec-isolation, shared-slot placement/no-EndF erase gates, and no-autofetch gates; container and EndF integrity; protocol codecs; transfer, resume, and layered apply safety; adaptive 2-to-4 block-request window growth and stall contraction; active-transfer priority classification |
| `test_ota` | `src/helpers/ota/` | v2 application/v3 bootloader parser separation; legacy XIAO, generic internal, and exact MeshTower V2 SD embedded identity, vector, capability, explicit-confirmation, codec-isolation, scratch-headroom/shared-slot no-EndF gates, and no-autofetch gates; container and EndF integrity; protocol codecs; transfer, resume, and layered apply safety; adaptive 2-to-4 block-request window growth and stall contraction; active-transfer priority classification |
| `test_trace_retry` | `src/Mesh.cpp` retry and relay policy | app-v2 and boot-v3 traffic sharing `PAYLOAD_TYPE_OTA=0x0C` and the TempRadio suspend policy; opaque OTA relay behavior; background discovery priority; immediate primary transfer relay, receive-delay bypass, fast CAD retry, and no generic flood retry; trace and non-OTA flood retry timing |
| `test_utils` | `src/Utils.cpp` | `Utils::toHex` (upstream) |
+75 -3
View File
@@ -16,6 +16,8 @@
#include "helpers/ota/OtaManager.h"
#include "helpers/ota/OtaApply.h"
#include "helpers/ota/OtaBlInfo.h"
#include "helpers/ota/OtaFlashLayout_nrf52.h"
#include "helpers/ota/OtaSdBootToken.h"
#include "mota_vectors.h" // auto-generated by tools/mota/gen_vectors.py
extern "C" {
@@ -41,12 +43,74 @@ TEST(OtaBootResult, AcceptsOnlyOtafixApplyDiagnostics) {
EXPECT_EQ(ota_nrf52_boot_result_or_zero(0x00), 0x00);
EXPECT_EQ(ota_nrf52_boot_result_or_zero(0x4C), 0x00); // shutdown reason
EXPECT_EQ(ota_nrf52_boot_result_or_zero(0x51), 0x00); // QSPI handoff
EXPECT_EQ(ota_nrf52_boot_result_or_zero(0x53), 0x00); // SD handoff
EXPECT_EQ(ota_nrf52_boot_result_or_zero(0xD4), 0x00); // legacy handoff
EXPECT_EQ(ota_nrf52_boot_result_or_zero(0xED), 0x00); // expanded handoff
EXPECT_EQ(ota_nrf52_boot_result_or_zero(0xE0), 0x00); // retired handoff remains non-result
EXPECT_EQ(ota_nrf52_boot_result_or_zero(0xBD), 0x00);
}
TEST(OtaBootPackage, StorageProfilesAndSdGeometryAreExact) {
EXPECT_EQ(OTA_BL_PROFILE_SD_BOOT_UPDATE, 0x09u);
EXPECT_EQ(OTA_BL_PROFILE_INTERNAL_BOOT_UPDATE, 0x0Au);
EXPECT_EQ(OTA_BL_PROFILE_QSPI_BOOT_UPDATE, 0x0Eu);
EXPECT_EQ(OTA_BL_REQUIRED_APP_CODEC_MASK, 0x0005u);
EXPECT_EQ(GPREGRET2_OTA_STAGE_SD, 0x53u);
EXPECT_TRUE(ota_bootloader_image_geometry_valid(
OTA_BOOT_IMAGE_SIZE, OTA_BOOT_IMAGE_SIZE));
EXPECT_FALSE(ota_bootloader_image_geometry_valid(
OTA_BOOT_IMAGE_SIZE - 1u, OTA_BOOT_IMAGE_SIZE));
EXPECT_FALSE(ota_bootloader_image_geometry_valid(
OTA_BOOT_IMAGE_SIZE, OTA_BOOT_IMAGE_SIZE - 1u));
EXPECT_TRUE(ota_bootloader_scratch_headroom_valid(
true, MOTA_NRF52_APP_BASE_S140_V6,
OTA_BOOT_SCRATCH_START - MOTA_NRF52_APP_BASE_S140_V6,
OTA_BOOT_SCRATCH_START));
EXPECT_FALSE(ota_bootloader_scratch_headroom_valid(
false, MOTA_NRF52_APP_BASE_S140_V6, 1u, OTA_BOOT_SCRATCH_START));
EXPECT_FALSE(ota_bootloader_scratch_headroom_valid(
true, MOTA_NRF52_APP_BASE_S140_V6,
OTA_BOOT_SCRATCH_START - MOTA_NRF52_APP_BASE_S140_V6 + 1u,
OTA_BOOT_SCRATCH_START));
const uint32_t safe_span = OTA_BOOT_SCRATCH_START - MOTA_NRF52_APP_BASE_S140_V6;
const uint32_t live_size = safe_span - 0x1000u;
EXPECT_TRUE(ota_bootloader_scratch_bank_geometry_valid(
OTA_BOOT_BANK_ERASED_FALLBACK, 0xFFFFu, UINT32_MAX, live_size, safe_span));
EXPECT_TRUE(ota_bootloader_scratch_bank_geometry_valid(
OTA_BOOT_BANK_VALID_APP, 0u, UINT32_MAX, live_size, safe_span));
EXPECT_TRUE(ota_bootloader_scratch_bank_geometry_valid(
OTA_BOOT_BANK_VALID_APP, 1u, live_size, live_size, safe_span));
EXPECT_FALSE(ota_bootloader_scratch_bank_geometry_valid(
OTA_BOOT_BANK_VALID_APP, 1u, live_size - 1u, live_size, safe_span));
EXPECT_FALSE(ota_bootloader_scratch_bank_geometry_valid(
OTA_BOOT_BANK_VALID_APP, 1u, safe_span + 1u, live_size, safe_span));
EXPECT_FALSE(ota_bootloader_scratch_bank_geometry_valid(
0x00FFu, 0u, 0u, live_size, safe_span));
}
TEST(OtaBootPackage, SdApprovalTokenBindsTheAuthenticatedImageHash) {
uint8_t hash[32];
for (uint8_t i = 0; i < sizeof(hash); i++) hash[i] = i;
uint8_t token[MOTA_SD_BOOT_TOKEN_LEN];
mota_sd_boot_token_encode(token, MOTA_NRF52_BOOT_CONTAINER_SIZE, hash);
EXPECT_EQ(0, memcmp(token, "MOTASDBL", 8));
EXPECT_EQ(mota_sd_boot_token_rd32(token + 8), 1u);
EXPECT_EQ(mota_sd_boot_token_rd32(token + 12), 64u);
EXPECT_EQ(mota_sd_boot_token_rd32(token + 16), MOTA_NRF52_BOOT_CONTAINER_SIZE);
EXPECT_EQ(mota_sd_boot_token_rd32(token + 20), ~MOTA_NRF52_BOOT_CONTAINER_SIZE);
EXPECT_EQ(0, memcmp(token + MOTA_SD_BOOT_TOKEN_IMAGE_HASH_OFFSET, hash, sizeof(hash)));
EXPECT_TRUE(mota_sd_boot_token_valid(
token, MOTA_NRF52_BOOT_CONTAINER_SIZE, hash));
token[MOTA_SD_BOOT_TOKEN_IMAGE_HASH_OFFSET] ^= 1u;
EXPECT_FALSE(mota_sd_boot_token_valid(
token, MOTA_NRF52_BOOT_CONTAINER_SIZE, hash));
token[MOTA_SD_BOOT_TOKEN_IMAGE_HASH_OFFSET] ^= 1u;
EXPECT_FALSE(mota_sd_boot_token_valid(
token, MOTA_NRF52_BOOT_CONTAINER_SIZE + 1u, hash));
}
TEST(OtaBootPackage, GenericIdentityUsesBoardAndNameForCollisionSafeTarget) {
const uint32_t shared_board_id = 0x239A0029u;
const uint8_t rak3401_name[16] = {'3','4','0','1','_','D','F','U',0,0,0,0,0,0,0,0};
@@ -209,7 +273,7 @@ TEST(OtaBootPackage, EmbeddedIdentityVectorCapsAndExplicitConfirmationGate) {
image[0x300] ^= 1;
EXPECT_FALSE(ota_bootloader_identity_from_image(image.data(), image.size(), identity));
uint8_t marker[16] = {'M','O','T','A','B','L','D','R', 3,0, 1,0,
uint8_t marker[16] = {'M','O','T','A','B','L','D','R', 3,0, 5,0,
(uint8_t)(OTA_BL_STORAGE_QSPI | OTA_BL_STORAGE_BOOT_UPDATE), 0,0,0};
OtaBootloaderCapsMarker caps;
EXPECT_TRUE(ota_bootloader_caps_marker_parse(marker, caps));
@@ -218,7 +282,7 @@ TEST(OtaBootPackage, EmbeddedIdentityVectorCapsAndExplicitConfirmationGate) {
std::vector<uint8_t> internal_caps_image(64, 0xFF);
const uint8_t internal_marker[16] = {
'M','O','T','A','B','L','D','R', 3,0, 1,0,
'M','O','T','A','B','L','D','R', 3,0, 5,0,
(uint8_t)(OTA_BL_STORAGE_STAGE_CEILING | OTA_BL_STORAGE_BOOT_UPDATE), 0,0,0};
memcpy(internal_caps_image.data() + 4, internal_marker, sizeof(internal_marker));
EXPECT_TRUE(ota_bootloader_caps_from_image(
@@ -237,6 +301,14 @@ TEST(OtaBootPackage, EmbeddedIdentityVectorCapsAndExplicitConfirmationGate) {
OTA_BL_STORAGE_STAGE_CEILING | OTA_BL_STORAGE_BOOT_UPDATE,
caps));
std::vector<uint8_t> full_only_caps(32, 0xFF);
memcpy(full_only_caps.data() + 4, internal_marker, sizeof(internal_marker));
full_only_caps[4 + 10] = 1;
EXPECT_FALSE(ota_bootloader_caps_from_image(
full_only_caps.data(), full_only_caps.size(),
OTA_BL_STORAGE_STAGE_CEILING | OTA_BL_STORAGE_BOOT_UPDATE,
caps));
OtaBlCaps selected;
selected.present = true;
selected.apply_abi = 3;
@@ -270,7 +342,7 @@ TEST(OtaBootPackage, CapabilityScannerRejectsAnOtherwiseValidUnalignedMarker) {
const uint8_t qspi_profile =
OTA_BL_STORAGE_STAGE_CEILING | OTA_BL_STORAGE_QSPI |
OTA_BL_STORAGE_BOOT_UPDATE;
const uint8_t marker[16] = {'M','O','T','A','B','L','D','R', 3,0, 1,0,
const uint8_t marker[16] = {'M','O','T','A','B','L','D','R', 3,0, 5,0,
qspi_profile, 0,0,0};
uint8_t image[64];
memset(image, 0xFF, sizeof(image));
+9 -5
View File
@@ -58,17 +58,21 @@ Ordinary calls to `build_manifest()` continue to emit v2 application packages.
Passing `bootloader=True` is a deliberately narrow reference-only path: it
requires a nonzero version, a signed exact 40 KiB OTAFIX region, 1024-byte
blocks, full codec, zero base hash, a sane vector table, an exact embedded
manifest/CRC identity, and an ABI-3 marker retaining the selected storage plus
boot-update capabilities. Deployed XIAO packages retain their
manifest/CRC identity, and an ABI-3 marker retaining both application codecs
(`FULL|INPLACE`, mask `0x0005`), the selected storage, and boot-update
capabilities. Deployed XIAO packages retain their
`XIAO_BL_28860044`/`XIAO_BL_28860045` IDs. Generic packages derive the padded
`NRF_BL_<BOARD_ID>_<DEVICE_NAME>` ID and collision-checked wire target from the
full embedded manifest pair. Parsing and `verify()` repeat those gates; magic
literals that are not complete valid structures are skipped.
Generic parsing remains available for candidate inspection, but the signing
builder accepts only identities in the qualified internal inventory plus the
two deployed XIAO identities. The test suite verifies those boot target IDs are
unique and disjoint from the generated application target table.
builder accepts only identities in the qualified inventory. That inventory
currently covers the shared-internal nRF52840 targets, the two deployed XIAO
raw-QSPI identities, and the exact MeshTower V2 identity whose candidate
marker may select either its internal (`0x0A`) or microSD (`0x09`) application
layout. The test suite verifies those boot target IDs are unique and disjoint
from the generated application target table.
This library does not authorize a device update. A capable node will only arm
such a v3 package through the exact manual confirmation described in
+26 -8
View File
@@ -92,13 +92,17 @@ XIAO_BOOT_MANIFEST_VERSION = 1
XIAO_BOOT_MANIFEST_SIZE = 44
XIAO_BOOT_DEVICE_NAME = b"XIAO_DFU".ljust(16, b"\0")
XIAO_BOOT_CAPS_MAGIC = b"MOTABLDR"
BOOT_STORAGE_SD = 0x01
BOOT_STORAGE_STAGE_CEILING = 0x02
XIAO_BOOT_STORAGE_QSPI = 0x04
XIAO_BOOT_STORAGE_UPDATE = 0x08
BOOT_STORAGE_KNOWN = 0x0F
BOOT_STORAGE_SD_UPDATE = BOOT_STORAGE_SD | XIAO_BOOT_STORAGE_UPDATE
BOOT_STORAGE_QSPI_UPDATE = (BOOT_STORAGE_STAGE_CEILING | XIAO_BOOT_STORAGE_QSPI |
XIAO_BOOT_STORAGE_UPDATE)
BOOT_STORAGE_INTERNAL_UPDATE = BOOT_STORAGE_STAGE_CEILING | XIAO_BOOT_STORAGE_UPDATE
BOOT_REQUIRED_APP_CODEC_MASK = ((1 << CODEC_FULL) |
(1 << CODEC_DETOOLS_INPLACE))
# Exact embedded manifests for the qualified shared-internal nRF52840
# bootloaders. Parsing/inspection remains generic, but package creation is
@@ -121,6 +125,14 @@ INTERNAL_BOOTLOADER_IDENTITIES = (
(0x239A0029, "RTAG_DFU"),
)
# MeshTower V2 has both a lean internal-store application and an exact SD-store
# application. They intentionally retain the same embedded bootloader identity
# and signed wire target; the candidate capability marker selects the matching
# storage contract and is checked again by the device before approval.
SD_BOOTLOADER_IDENTITIES = (
(0x239A0071, "TOWER_V2_OTA"),
)
# MeshTower V2's SD-backed OTA target keeps the staged .mota off-chip, so the application may use the
# complete S140 v6 application region up to InternalFS instead of leaving room for internal staging.
# This is deliberately target-specific: other nRF52 OTA builds still need NRF52_INPLACE_MEMORY above.
@@ -659,9 +671,11 @@ def bootloader_caps_storage(image: bytes) -> Optional[int]:
continue
abi, codecs = struct.unpack_from("<HH", image, off + 8)
storage = image[off + 12]
if (abi < BOOT_FORMAT_VER or abi == 0xFFFF or not codecs & (1 << CODEC_FULL) or
if (abi < BOOT_FORMAT_VER or abi == 0xFFFF or
codecs & BOOT_REQUIRED_APP_CODEC_MASK != BOOT_REQUIRED_APP_CODEC_MASK 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)):
storage not in (BOOT_STORAGE_SD_UPDATE, BOOT_STORAGE_QSPI_UPDATE,
BOOT_STORAGE_INTERNAL_UPDATE)):
continue
if found is not None:
return None
@@ -688,12 +702,16 @@ def validate_bootloader_image(image: bytes, target_id: Optional[int] = None,
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}")
if identity.board_id in (XIAO_BOOT_BOARD_ID_BASE, XIAO_BOOT_BOARD_ID_SENSE):
expected_storage = (BOOT_STORAGE_QSPI_UPDATE,)
elif (identity.board_id, identity.device_name) in SD_BOOTLOADER_IDENTITIES:
expected_storage = (BOOT_STORAGE_INTERNAL_UPDATE, BOOT_STORAGE_SD_UPDATE)
else:
expected_storage = (BOOT_STORAGE_INTERNAL_UPDATE,)
actual_storage = bootloader_caps_storage(image)
if actual_storage not in expected_storage:
expected = "/".join(f"0x{value:02X}" for value in expected_storage)
raise ValueError(f"bootloader lacks exact ABI 3 self-update capabilities {expected}")
return identity
+8 -1
View File
@@ -181,6 +181,7 @@ def _append_endf_hex(source, target, env): # Intel-HEX path (nRF52: app f
qspi_backed = _cppdef("OTA_QSPI_STORE") 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
sd_bootloader_update = _cppdef("OTA_SD_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:
@@ -196,6 +197,11 @@ def _append_endf_hex(source, target, env): # Intel-HEX path (nRF52: app f
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")
if sd_bootloader_update:
if not sd_backed or qspi_backed or internal_extrafs or _cppdef("QSPIFLASH") is not None:
raise RuntimeError("SD bootloader update requires the dedicated nRF52 SD OTA store")
if linked_app_end != ml.NRF52_APP_END:
raise RuntimeError("SD 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) |
@@ -226,7 +232,8 @@ def _append_endf_hex(source, target, env): # Intel-HEX path (nRF52: app f
for i, b in enumerate(suffix):
ih[app_end + i] = b # write it right after the app's last byte
ih.write_hex_file(path)
print(f"EndF: appended to {os.path.basename(path)} at 0x{app_end:X} "
endf_address = app_start + len(body)
print(f"EndF: appended to {os.path.basename(path)} at 0x{endf_address:X} "
f"(app=0x{app_start:X}.. body_len={len(body)} body_hash={h8.hex()} "
f"stage=0x{stage_ceiling:X} "
f"target={ident.target_id:#010x} hw='{ident.hw_id}' fw={ident.fw_version:#010x})")
+38 -6
View File
@@ -213,7 +213,7 @@ def _xiao_bootloader_image(board_id=ml.XIAO_BOOT_BOARD_ID_BASE):
image = bytearray(b"\xff" * ml.XIAO_BOOT_IMAGE_SIZE)
struct.pack_into("<II", image, 0, 0x20040000, ml.XIAO_BOOT_IMAGE_START + 0x101)
struct.pack_into("<8sHHB3x", image, 0x80, ml.XIAO_BOOT_CAPS_MAGIC,
ml.BOOT_FORMAT_VER, 1 << ml.CODEC_FULL,
ml.BOOT_FORMAT_VER, ml.BOOT_REQUIRED_APP_CODEC_MASK,
ml.BOOT_STORAGE_QSPI_UPDATE)
name = ml.XIAO_BOOT_DEVICE_NAME
struct.pack_into("<8sHHIII16sI", image, 0x100, ml.XIAO_BOOT_MANIFEST_MAGIC,
@@ -224,13 +224,14 @@ def _xiao_bootloader_image(board_id=ml.XIAO_BOOT_BOARD_ID_BASE):
return bytes(image)
def _generic_bootloader_image(board_id=0x239A0029, device_name="3401_DFU"):
def _generic_bootloader_image(board_id=0x239A0029, device_name="3401_DFU",
storage=ml.BOOT_STORAGE_INTERNAL_UPDATE):
import zlib
image = bytearray(b"\xff" * ml.XIAO_BOOT_IMAGE_SIZE)
struct.pack_into("<II", image, 0, 0x20040000, ml.XIAO_BOOT_IMAGE_START + 0x101)
struct.pack_into("<8sHHB3x", image, 0x80, ml.XIAO_BOOT_CAPS_MAGIC,
ml.BOOT_FORMAT_VER, 1 << ml.CODEC_FULL,
ml.BOOT_STORAGE_INTERNAL_UPDATE)
ml.BOOT_FORMAT_VER, ml.BOOT_REQUIRED_APP_CODEC_MASK,
storage)
name = device_name.encode("ascii").ljust(16, b"\0")
struct.pack_into("<8sHHIII16sI", image, 0x100, ml.XIAO_BOOT_MANIFEST_MAGIC,
ml.XIAO_BOOT_MANIFEST_VERSION, ml.XIAO_BOOT_MANIFEST_SIZE,
@@ -321,7 +322,7 @@ def test_xiao_bootloader_identity_skips_bad_decoy_and_rejects_two_valid_manifest
def test_xiao_bootloader_caps_rejects_malformed_or_unaligned_markers():
def caps(*, offset=0, abi=ml.BOOT_FORMAT_VER,
codecs=1 << ml.CODEC_FULL,
codecs=ml.BOOT_REQUIRED_APP_CODEC_MASK,
storage=ml.BOOT_STORAGE_QSPI_UPDATE,
reserved=b"\0\0\0"):
image = bytearray(b"\xff" * 64)
@@ -333,11 +334,12 @@ def test_xiao_bootloader_caps_rejects_malformed_or_unaligned_markers():
assert not ml.xiao_bootloader_caps_ok(caps(offset=1))
assert not ml.xiao_bootloader_caps_ok(caps(abi=0xFFFF))
assert not ml.xiao_bootloader_caps_ok(caps(codecs=0))
assert not ml.xiao_bootloader_caps_ok(caps(codecs=1 << ml.CODEC_FULL))
assert not ml.xiao_bootloader_caps_ok(caps(storage=0x1C))
assert not ml.xiao_bootloader_caps_ok(caps(reserved=b"\0\x01\0"))
duplicate = bytearray(caps())
struct.pack_into("<8sHHB3s", duplicate, 24, ml.XIAO_BOOT_CAPS_MAGIC,
ml.BOOT_FORMAT_VER, 1 << ml.CODEC_FULL,
ml.BOOT_FORMAT_VER, ml.BOOT_REQUIRED_APP_CODEC_MASK,
ml.BOOT_STORAGE_INTERNAL_UPDATE, b"\0\0\0")
assert ml.bootloader_caps_storage(bytes(duplicate)) is None
@@ -412,6 +414,36 @@ def test_generic_internal_bootloader_build_parse_and_strict_contract():
pass
def test_meshtower_sd_bootloader_profile_builds_the_same_exact_identity():
priv = Ed25519PrivateKey.generate()
image = _generic_bootloader_image(
board_id=0x239A0071, device_name="TOWER_V2_OTA",
storage=ml.BOOT_STORAGE_SD_UPDATE)
identity = ml.validate_bootloader_image(image)
assert (identity.board_id, identity.device_name) in ml.SD_BOOTLOADER_IDENTITIES
assert ml.bootloader_caps_storage(image) == 0x09
target = ml.bootloader_target_id(identity.board_id, identity.device_name)
assert target == 0x1150F50E
manifest = ml.build_manifest(
target_id=target, fw_version=ml.pack_version("1.0.0"),
image_size=len(image), payload=image, block_size=1024,
image_hash=ml.mh32(image), codec_id=ml.CODEC_FULL, is_full=True,
sign_priv=priv, bootloader=True)
parsed = ml.parse_container(ml.build_container(manifest, image))
assert parsed.manifest.hw_id == b"NRF_BL_239A0071_TOWER_V2_OTA".ljust(32, b"\0")
assert ml.verify(parsed, expect_pub=priv.public_key().public_bytes_raw()) == []
def test_meshtower_sd_bootloader_build_flag_is_exact_target_only():
root = Path(__file__).resolve().parents[2]
text = (root / "variants/heltec_tower_v2/platformio.ini").read_text(encoding="utf-8")
section = text.split(
"[env:Heltec_tower_v2_sdcard_repeater_lora_ota_no_external_sensors]", 1)[1]
section = section.split("[env:", 1)[0]
assert "-D OTA_SD_STORE=1" in section
assert "-D OTA_SD_BOOTLOADER_UPDATE=1" in section
def test_bootloader_build_inventory_is_unique_and_disjoint_from_app_targets():
import re
+3
View File
@@ -49,6 +49,8 @@ build_flags =
; persistent LoRa-OTA store. Requires the matching SD-aware OTAFIX bootloader.
; Unlike the internal-flash target above, this accepts both complete images
; and in-place deltas and does not reserve application flash for the container.
; The exact SD repeater env also accepts manually confirmed signed bootloader
; packages when OTAFIX advertises the matching SD self-update profile.
[Heltec_tower_v2_sdcard]
extends = Heltec_tower_v2
build_flags =
@@ -74,6 +76,7 @@ build_flags =
-D MAX_NEIGHBOURS=254
-D ENABLE_OTA=1
-D OTA_SD_STORE=1
-D OTA_SD_BOOTLOADER_UPDATE=1
-D OTA_FOLDER_SERIAL