diff --git a/docs/ota_nrf52_bootloader_update.md b/docs/ota_nrf52_bootloader_update.md index 9a66c9f1..a6f3865d 100644 --- a/docs/ota_nrf52_bootloader_update.md +++ b/docs/ota_nrf52_bootloader_update.md @@ -16,6 +16,15 @@ be replaced with the exact ABI-3 self-update-capable OTAFIX build over USB/BLE DFU or SWD. MeshTower SD also requires the BLM2 retained-auth version to be provisioned locally before either application or bootloader OTA. +The adaptive RAK3401 and RAK4631 loaders are an exception: their 2.4.8 +application updater has ABI 2 and capability flags `0x16`, with no bootloader +self-update code. The two `*_repeater_unified_lora_ota` MeshCore builds now +include an application-owned recovery installer. Install that application +through its normal exact-base application mOTA path first. It can then fetch +an exact-board signed format-3 bootloader package from internal flash or a +matched external NOR and invoke Nordic's MBR itself. A node that cannot run +MeshCore and receive LoRa cannot use this path; use local UF2/DFU or SWD. + ## Storage layouts | Layout | Application limit | Staged boot package | Work area | Handoff / capability | @@ -23,6 +32,36 @@ provisioned locally before either application or bootloader OTA. | 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` flash limit; top 64 KiB SRAM reserved for application-delta staging | shared internal slot, exact start `0xE2000` | the same eleven-page slot; no second flash 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` | +| Adaptive RAK3401/RAK4631 recovery application | normal `0xED000` flash limit; live EndF and bank CRC must stop by `0xE2000` | internal `0xE2000` slot or exact matched NOR | internal `0xE2000..0xEC000` raw copy | application calls MBR; installed loader flags `0x16` | + +For adaptive RAK recovery, `ota bootloader status` reports the installed +`3401_AUTO_DFU` or `4631_AUTO_DFU` identity. The host must build a signed +format-3 package from a *newer* exact-board OTAFIX image with the same `0x16` +capability marker and BLM2 continuity metadata. The package is full, so it +does not depend on the installed bootloader bytes. Its target IDs are +`D04AB3AB` and `FEEAFD1B`, respectively. The application requires a trusted +signer, matching board name/ID and SoftDevice layout, complete Merkle and +SHA-256 verification, explicit MID and image-hash confirmation, and a +hash-valid live EndF plus safe bank settings before writing scratch. It +copies each page with readback, hashes the final source again after the LoRa +reply drains, then calls `SD_MBR_COMMAND_COPY_BL`. The old bootloader remains +untouched until the MBR call. An installed 2.4.8 BLM2 loader rejects an equal +or older version; a 2.4.8 image is therefore not a recovery upgrade for it. +If scratch verification or the MBR call fails, the application resets without +the bootloader-update trigger and reports `blup:CE` on its next OTA status. +Build a candidate only from the matching **newer** OTAFIX bootloader HEX: + +```bash +motatool build-bootloader --fw \ + --board wiscore_rak3401_auto --sign \ + --out +# For RAK4631 use --board wiscore_rak4631_auto and its own exact-board HEX. +``` + +Verify the signed file with the corresponding public key, serve it over LoRa, +pull its MID manually, and run the same `ota bootloader install ` +confirmation shown below. A temporary or untrusted test signing key cannot +install on a node whose allowlist does not contain its public key. The exact SD target is `Heltec_tower_v2_sdcard_repeater_lora_ota_no_external_sensors`. Its normal diff --git a/platformio.ini b/platformio.ini index 6f6ceca4..d1e35253 100644 --- a/platformio.ini +++ b/platformio.ini @@ -271,6 +271,7 @@ build_flags = [nrf52_wisblock_auto_ota] build_flags = -D OTA_RAK_AUTO_STORE=1 + -D OTA_RAK_BOOTLOADER_RECOVERY=1 -D OTA_QSPI_STORE=1 -D OTA_FLASH_STORE=1 -D OTA_QSPI_SHARED_WISBLOCK_SPI=1 diff --git a/src/helpers/ota/OtaApply.cpp b/src/helpers/ota/OtaApply.cpp index abf73f9f..fba5b500 100644 --- a/src/helpers/ota/OtaApply.cpp +++ b/src/helpers/ota/OtaApply.cpp @@ -18,6 +18,7 @@ #include "OtaSelf.h" // SelfFwInfo / ota_self_firmware (running-image base_hash gate) #endif #elif defined(NRF52_PLATFORM) + #include #include "OtaVerify.h" #include "OtaSelf.h" #include "OtaFlashLayout_nrf52.h" @@ -26,7 +27,9 @@ #include "nrf.h" #include "nrf_soc.h" #include "nrf_sdm.h" + #include "nrf_mbr.h" #include "../nrf52/SoftDeviceState.h" + #include #if defined(OTA_SD_STORE) #include "OtaStoreSdNrf52.h" #endif @@ -468,6 +471,31 @@ static OtaStoreFlashNrf52* g_nrf52_apply_store = nullptr; #if defined(OTA_QSPI_STORE) static uint8_t g_nrf52_qspi_handoff = GPREGRET2_OTA_STAGE_QSPI; #endif +#if defined(OTA_RAK_BOOTLOADER_RECOVERY) +// Set only after the exact-board signed image has been copied to the internal +// source and independently hashed there. Mesh drains the LoRa reply first. +static bool g_rak_recovery_ready = false; +static uint8_t g_rak_recovery_hash[32] = {0}; + +static bool ota_rak_recovery_source_hash_matches() { + SHA256 sha; + const uint8_t* image = (const uint8_t*)(uintptr_t)MOTA_NRF52_SHARED_BOOT_STAGE_START; + for (uint32_t off = 0; off < OTA_BOOT_IMAGE_SIZE; off += 512u) + sha.update(image + off, 512u); + uint8_t hash[32]; + sha.finalize(hash, sizeof(hash)); + return memcmp(hash, g_rak_recovery_hash, sizeof(hash)) == 0; +} + +static bool ota_rak_mbr_addresses_ready() { + const uint32_t mbr_boot = *(const volatile uint32_t*)(uintptr_t)MBR_BOOTLOADER_ADDR; + const uint32_t mbr_params = *(const volatile uint32_t*)(uintptr_t)MBR_PARAM_PAGE_ADDR; + const uint32_t boot = mbr_boot == UINT32_MAX ? NRF_UICR->NRFFW[0] : mbr_boot; + const uint32_t params = mbr_params == UINT32_MAX ? NRF_UICR->NRFFW[1] : mbr_params; + return boot == OTA_BOOT_IMAGE_START && + params == OTA_BOOT_IMAGE_START + OTA_BOOT_IMAGE_SIZE; +} +#endif static void ota_nrf52_set_reset_handoff(uint8_t request, uint8_t source) { uint8_t sd_en = 0; @@ -540,6 +568,40 @@ void ota_reboot_to_apply() { // public: set the apply magic + } void ota_reboot_to_bootloader_update() { +#if defined(OTA_RAK_BOOTLOADER_RECOVERY) + if (g_rak_recovery_ready) { + // COPY_BL succeeds by resetting directly into the new bootloader. Never + // hand 0x6B to an older adaptive loader: it cannot consume this package. + if (!ota_rak_mbr_addresses_ready() || + !ota_rak_recovery_source_hash_matches()) { + g_rak_recovery_ready = false; + ota_nrf52_set_reset_handoff(0u, GPREGRET2_RAK_RECOVERY_FAILED); + NVIC_SystemReset(); + return; + } + uint8_t sd_enabled = 0; + if (mesh_nrf52::softdeviceIsEnabled(sd_enabled) != NRF_SUCCESS || + (sd_enabled && sd_softdevice_disable() != NRF_SUCCESS)) { + g_rak_recovery_ready = false; + ota_nrf52_set_reset_handoff(0u, GPREGRET2_RAK_RECOVERY_FAILED); + NVIC_SystemReset(); + return; + } + sd_mbr_command_t command = {}; + command.command = SD_MBR_COMMAND_COPY_BL; + command.params.copy_bl.bl_src = + (uint32_t*)(uintptr_t)MOTA_NRF52_SHARED_BOOT_STAGE_START; + command.params.copy_bl.bl_len = OTA_BOOT_IMAGE_SIZE / sizeof(uint32_t); + NRF_POWER->GPREGRET = 0u; + NRF_POWER->GPREGRET2 = 0u; + (void)sd_mbr_command(&command); // success never returns + g_rak_recovery_ready = false; + NRF_POWER->GPREGRET = 0u; + NRF_POWER->GPREGRET2 = GPREGRET2_RAK_RECOVERY_FAILED; + NVIC_SystemReset(); + return; + } +#endif uint8_t source = GPREGRET2_OTA_STAGE_QSPI; #if defined(OTA_INTERNAL_BOOTLOADER_UPDATE) source = GPREGRET2_OTA_STAGE_EXPANDED; @@ -1014,6 +1076,127 @@ static const char* ota_bootloader_continuity_error(OtaBootloaderContinuityGate g } } +#if defined(OTA_RAK_BOOTLOADER_RECOVERY) +bool ota_prepare_rak_bootloader_recovery(OtaStore& store, bool internal, + 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) { + st = ApplyState(); + g_rak_recovery_ready = false; + const uint32_t total = store.staged_size(); + uint8_t header[8], manifest[MOTA_MFL]; + MotaManifest m; + if (total != MOTA_NRF52_BOOT_CONTAINER_SIZE || + !store.read(0, header, sizeof(header)) || + memcmp(header, MOTA_MAGIC, sizeof(MOTA_MAGIC)) != 0 || + rd_u32le(header + 4) != total || + !store.read(8, manifest, sizeof(manifest)) || + !mota_parse_manifest(manifest, sizeof(manifest), m)) { + strcpy(msg, "recovery bootloader container is invalid"); return false; + } + if (ota_bootloader_confirmation_gate(m, installed, actual_mid, operator_mid, + operator_hash8) != OTA_BOOT_CONFIRM_OK) { + strcpy(msg, "recovery bootloader identity/MID/hash confirmation failed"); return false; + } + const OtaBlCaps current = ota_bootloader_app_caps(); + const uint8_t rak_flags = OTA_BL_PROFILE_RAK_AUTO; + if (!current.present || current.storage_flags != rak_flags || + current.apply_abi < MOTA_APP_FORMAT_VER || + (current.codec_mask & OTA_BL_REQUIRED_APP_CODEC_MASK) != + OTA_BL_REQUIRED_APP_CODEC_MASK) { + strcpy(msg, "installed bootloader is not the adaptive RAK OTAFIX profile"); return false; + } + SelfFwInfo fi; + const uint32_t app_base = mota_nrf52_app_base(); + if (!ota_self_firmware(fi) || + !ota_bootloader_scratch_headroom_valid( + fi.valid, app_base, fi.image_len, MOTA_NRF52_SHARED_BOOT_STAGE_START) || + !ota_bootloader_live_bank_preserves_scratch( + app_base, fi.image_len, MOTA_NRF52_SHARED_BOOT_STAGE_START) || + ota_nrf52_effective_stage_ceiling() != MOTA_NRF52_APP_END || + !ota_rak_mbr_addresses_ready()) { + strcpy(msg, "running app/settings or MBR do not preserve bootloader scratch"); return false; + } + if (internal) { + OtaStoreFlashNrf52& flash = static_cast(store); + if (flash.write_start() != MOTA_NRF52_SHARED_BOOT_STAGE_START || + !flash.data() || flash.is_hybrid()) { + strcpy(msg, "internal recovery container placement is invalid"); return false; + } + } + const uint32_t payload_off = 8u + MOTA_MFL + (uint32_t)m.block_count * 4u; + if (payload_off != 365u || payload_off + OTA_BOOT_IMAGE_SIZE + 5u != total) { + strcpy(msg, "recovery payload layout is invalid"); return false; + } + + mesh::Identity signer(m.signer_pubkey); + if (!signer.verify(m.signature, m.manifest_start, (int)m.signed_len) || + !allow.contains(m.signer_pubkey)) { + strcpy(msg, "recovery bootloader signer is not trusted"); return false; + } + const VerifyResult verified = ota_verify(store, allow, manifest); + st.manifest_ok = verified.parsed; + st.sig_ok = verified.sig_ok; + st.trusted = verified.trusted; + st.image_size = m.image_size; + memcpy(st.image_hash, m.image_hash, sizeof(st.image_hash)); + if (!verified.auto_appliable() || !verified.container_hash_ok) { + strcpy(msg, "recovery bootloader package failed full verification"); return false; + } + uint8_t vectors[8]; + OtaBootloaderIdentity candidate; + OtaBootloaderCapsMarker candidate_caps; + if (!store.read(payload_off, vectors, sizeof(vectors)) || + !ota_bootloader_vector_sane(vectors) || + !ota_bootloader_external_image_metadata( + store, payload_off, rak_flags, candidate, candidate_caps, false) || + !ota_bootloader_identity_matches(installed, candidate) || + candidate_caps.apply_abi < current.apply_abi || + (candidate_caps.codec_mask & current.codec_mask) != current.codec_mask) { + strcpy(msg, "recovery candidate identity/capabilities/CRC mismatch"); return false; + } + const OtaBootloaderContinuityGate continuity = ota_bootloader_continuity_gate( + installed, candidate, m.fw_version, OTA_BOOT_CONTINUITY_FAMILY_S140, + ota_runtime_softdevice_fwid(), app_base, OTA_BOOT_CONTINUITY_LAYOUT_ABI); + if (continuity != OTA_BOOT_CONTINUITY_OK) { + strncpy(msg, ota_bootloader_continuity_error(continuity), 96); + msg[95] = 0; return false; + } + + uint8_t* page = static_cast(malloc(MOTA_NRF52_FLASH_PAGE)); + if (!page) { strcpy(msg, "not enough RAM for recovery copy"); return false; } + bool copied = true; + for (uint32_t off = 0; off < OTA_BOOT_IMAGE_SIZE; off += MOTA_NRF52_FLASH_PAGE) { + const uint32_t dest = MOTA_NRF52_SHARED_BOOT_STAGE_START + off; + // Internal packages overlap the destination by 365 bytes. Read the next + // complete source page before erasing the lower destination page. + if (!store.read(payload_off + off, page, MOTA_NRF52_FLASH_PAGE) || + flash_nrf5x_write(dest, page, MOTA_NRF52_FLASH_PAGE) < 0) { + copied = false; break; + } + flash_nrf5x_flush(); + if (memcmp((const void*)(uintptr_t)dest, page, MOTA_NRF52_FLASH_PAGE) != 0) { + copied = false; break; + } + } + free(page); + if (!copied) { + strcpy(msg, "recovery scratch copy/readback failed; bootloader unchanged"); return false; + } + memcpy(g_rak_recovery_hash, m.image_hash, sizeof(g_rak_recovery_hash)); + if (!ota_rak_recovery_source_hash_matches()) { + strcpy(msg, "recovery scratch hash mismatch; bootloader unchanged"); return false; + } + st.slot_ok = true; + g_rak_recovery_ready = true; + strcpy(msg, "signed bootloader copied and verified; MBR install follows this reply"); + return true; +} +#endif + #if defined(OTA_SD_BOOTLOADER_UPDATE) static bool external_bootloader_approve(OtaStoreSdNrf52& store, const uint8_t image_hash[32], diff --git a/src/helpers/ota/OtaApply.h b/src/helpers/ota/OtaApply.h index 6da29137..37bb8308 100644 --- a/src/helpers/ota/OtaApply.h +++ b/src/helpers/ota/OtaApply.h @@ -15,6 +15,8 @@ namespace mesh { namespace ota { +class OtaStore; + struct ApplyState { bool manifest_ok = false; bool sig_ok = false; @@ -131,6 +133,19 @@ bool ota_prepare_bootloader_update_nrf52(OtaStoreFlashNrf52& store, ApplyState& st, char* msg); #endif +#if defined(OTA_RAK_BOOTLOADER_RECOVERY) +// Application-owned recovery for adaptive RAK loaders lacking BOOT_UPDATE. +// Copies a verified full bootloader from either active store into internal +// scratch; deferred commit invokes Nordic's MBR after the LoRa reply drains. +bool ota_prepare_rak_bootloader_recovery(OtaStore& store, bool internal, + 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 // the apply started (over LoRa the device then goes silent while the bootloader applies). nRF52: set diff --git a/src/helpers/ota/OtaBlInfo.h b/src/helpers/ota/OtaBlInfo.h index 80199cdf..c3192595 100644 --- a/src/helpers/ota/OtaBlInfo.h +++ b/src/helpers/ota/OtaBlInfo.h @@ -93,6 +93,9 @@ static const uint8_t OTA_BL_PROFILE_INTERNAL_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; +static const uint8_t OTA_BL_PROFILE_RAK_AUTO = + OTA_BL_STORAGE_STAGE_CEILING | OTA_BL_STORAGE_QSPI | + OTA_BL_STORAGE_HEADER_W25; // 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; diff --git a/src/helpers/ota/OtaBootloaderUpdate.h b/src/helpers/ota/OtaBootloaderUpdate.h index a5adfccb..b0d26a0d 100644 --- a/src/helpers/ota/OtaBootloaderUpdate.h +++ b/src/helpers/ota/OtaBootloaderUpdate.h @@ -32,6 +32,17 @@ #endif #endif +#if defined(OTA_RAK_BOOTLOADER_RECOVERY) + #if !defined(NRF52_PLATFORM) || !defined(OTA_RAK_AUTO_STORE) || \ + !defined(OTA_INTERNAL_BOOTLOADER_UPDATE) || !defined(OTA_FLASH_STORE) || \ + !defined(OTA_QSPI_STORE) + #error "RAK application recovery requires the adaptive nRF52 shared-slot profile" + #endif + #if !defined(RAK_3401) && !defined(RAK_4631) + #error "RAK application recovery is restricted to the two qualified RAK profiles" + #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" @@ -512,17 +523,19 @@ inline bool ota_bootloader_caps_marker_parse(const uint8_t raw[16], inline bool ota_bootloader_caps_from_image(const uint8_t* image, size_t image_size, uint8_t exact_storage_flags, - OtaBootloaderCapsMarker& out) { + OtaBootloaderCapsMarker& out, + bool require_boot_update = true) { 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.apply_abi < (require_boot_update ? MOTA_BOOT_FORMAT_VER : MOTA_APP_FORMAT_VER) || (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; + ((parsed.storage_flags & OTA_BL_STORAGE_BOOT_UPDATE) != 0u) != + require_boot_update) continue; if (++valid_count != 1u) return false; // privileged marker identity must be unambiguous if (parsed.storage_flags != exact_storage_flags) continue; out = parsed; @@ -556,7 +569,8 @@ inline bool ota_bootloader_external_crc_ok( template inline bool ota_bootloader_external_image_metadata( Store& store, uint32_t payload_off, uint8_t exact_storage_flags, - OtaBootloaderIdentity& candidate, OtaBootloaderCapsMarker& caps) { + OtaBootloaderIdentity& candidate, OtaBootloaderCapsMarker& caps, + bool require_boot_update = true) { candidate = OtaBootloaderIdentity(); caps = OtaBootloaderCapsMarker(); if (exact_storage_flags == 0u) return false; @@ -576,10 +590,11 @@ inline bool ota_bootloader_external_image_metadata( if ((absolute & 3u) == 0u && local + 16u <= len) { OtaBootloaderCapsMarker parsed; if (ota_bootloader_caps_marker_parse(buf + local, parsed) && - parsed.apply_abi >= MOTA_BOOT_FORMAT_VER && + parsed.apply_abi >= (require_boot_update ? MOTA_BOOT_FORMAT_VER : MOTA_APP_FORMAT_VER) && (parsed.codec_mask & OTA_BL_REQUIRED_APP_CODEC_MASK) == OTA_BL_REQUIRED_APP_CODEC_MASK && - (parsed.storage_flags & OTA_BL_STORAGE_BOOT_UPDATE) != 0u) { + ((parsed.storage_flags & OTA_BL_STORAGE_BOOT_UPDATE) != 0u) == + require_boot_update) { if (++valid_caps != 1u) return false; if (parsed.storage_flags == exact_storage_flags) caps = parsed; } diff --git a/src/helpers/ota/OtaCli.cpp b/src/helpers/ota/OtaCli.cpp index 9d4d5f61..61676ced 100644 --- a/src/helpers/ota/OtaCli.cpp +++ b/src/helpers/ota/OtaCli.cpp @@ -400,11 +400,17 @@ bool handle_ota_command(const char* command, char* reply, mesh::MainBoard& board 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_update); + h->target_id == ota_bootloader_target_id(bid); +#if defined(OTA_RAK_BOOTLOADER_RECOVERY) + installable = installable && + list_bl.storage_flags == OTA_BL_PROFILE_RAK_AUTO && + (c.fetch_store.usesExternal() || c.fetch_store.usesInternal()); +#else + installable = installable && ota_bootloader_self_update_caps_valid(list_bl_update); #if defined(OTA_SD_BOOTLOADER_UPDATE) installable = installable && list_sd_headroom; #endif +#endif #endif fit = installable ? "yours" : "bootloader unsupported"; } else if (myt && h->target_id == myt) { @@ -541,7 +547,11 @@ bool handle_ota_command(const char* command, char* reply, mesh::MainBoard& board (defined(OTA_QSPI_BOOTLOADER_UPDATE) || defined(OTA_INTERNAL_BOOTLOADER_UPDATE) || \ defined(OTA_SD_BOOTLOADER_UPDATE)) const OtaBootloaderIdentity& bid = c.bootloaderIdentity(); +#if defined(OTA_RAK_BOOTLOADER_RECOVERY) + const OtaBlCaps& bl = c.bootloaderAppCaps(); +#else const OtaBlCaps& bl = c.bootloaderUpdateCaps(); +#endif if (selflags != (MFLAG_FULL | MFLAG_SIGNED | MFLAG_BOOTLOADER) || selcodec != CODEC_FULL) { strcpy(reply, "ERR malformed bootloader catalog row; capture it to folder for inspection"); @@ -551,10 +561,17 @@ bool handle_ota_command(const char* command, char* reply, mesh::MainBoard& board strcpy(reply, "ERR bootloader package does not match this installed bootloader identity"); return true; } +#if defined(OTA_RAK_BOOTLOADER_RECOVERY) + if (bl.storage_flags != OTA_BL_PROFILE_RAK_AUTO) { + strcpy(reply, "ERR installed bootloader is not the adaptive RAK profile"); + return true; + } +#else if (!ota_bootloader_self_update_caps_valid(bl)) { strcpy(reply, "ERR installed bootloader lacks safe LoRa bootloader-update support"); return true; } +#endif #if defined(OTA_SD_BOOTLOADER_UPDATE) SelfFwInfo sd_self; if (!ota_self_firmware(sd_self) || @@ -579,13 +596,21 @@ bool handle_ota_command(const char* command, char* reply, mesh::MainBoard& board return true; } #if defined(NRF52_PLATFORM) +#if defined(OTA_RAK_BOOTLOADER_RECOVERY) + const OtaBlCaps& bl = c.bootloaderAppCaps(); +#else const OtaBlCaps& bl = selboot ? c.bootloaderUpdateCaps() : c.bootloaderAppCaps(); +#endif if (!bl.present) { strcpy(reply, "ERR bootloader has no mOTA apply support; update it over USB first"); return true; } +#if defined(OTA_RAK_BOOTLOADER_RECOVERY) + const uint8_t need_abi = MOTA_APP_FORMAT_VER; +#else const uint8_t need_abi = selboot ? MOTA_BOOT_FORMAT_VER : MOTA_APP_FORMAT_VER; +#endif if (bl.apply_abi < need_abi || selcodec >= 16 || !(bl.codec_mask & (1u << selcodec))) { snprintf(reply, 160, "ERR bootloader cannot apply mOTA ABI %u codec %u (has abi=%u codecs=0x%x)", need_abi, (unsigned)selcodec, bl.apply_abi, bl.codec_mask); @@ -864,10 +889,17 @@ bool handle_ota_command(const char* command, char* reply, mesh::MainBoard& board strcpy(reply, "Bootloader update unavailable: installed embedded manifest/CRC is invalid"); return true; } +#if defined(OTA_RAK_BOOTLOADER_RECOVERY) + if (bl.storage_flags != OTA_BL_PROFILE_RAK_AUTO) { + strcpy(reply, "Bootloader recovery unavailable: installed loader is not adaptive RAK OTAFIX"); + return true; + } +#else if (!ota_bootloader_self_update_caps_valid(bl)) { strcpy(reply, "Bootloader update unavailable: installed bootloader supports application OTA only"); return true; } +#endif MotaManifest staged; char midhx[9] = "-", hashhx[17] = "-"; const bool ready = c.manager.fetchState() == OtaManager::COMPLETE && diff --git a/src/helpers/ota/OtaContext.h b/src/helpers/ota/OtaContext.h index c0cf8a04..dccbcd65 100644 --- a/src/helpers/ota/OtaContext.h +++ b/src/helpers/ota/OtaContext.h @@ -359,6 +359,19 @@ struct OtaContext { } const OtaBootloaderIdentity& installed = bootloaderIdentity(); #if defined(OTA_RAK_AUTO_STORE) +#if defined(OTA_RAK_BOOTLOADER_RECOVERY) + if (!fetch_store.usesInternal() && !fetch_store.usesExternal()) { + strncpy(msg, fetch_store.selectionReason(), 96); + msg[95] = 0; return false; + } + const bool internal = fetch_store.usesInternal(); + OtaStore& recovery_store = internal + ? static_cast(fetch_store.internalStore()) + : static_cast(fetch_store.externalStore()); + bool ok = ota_prepare_rak_bootloader_recovery( + recovery_store, internal, allow, installed, + manager.fetchManifestId(), operator_mid, operator_hash8, apply_st, msg); +#else if (!fetch_store.usesInternal()) { strncpy(msg, "bootloader LoRa update needs the internal OTAFIX profile", 96); msg[95] = 0; return false; @@ -366,6 +379,7 @@ struct OtaContext { bool ok = ota_prepare_bootloader_update_nrf52( fetch_store.internalStore(), allow, installed, manager.fetchManifestId(), operator_mid, operator_hash8, apply_st, msg); +#endif #else bool ok = ota_prepare_bootloader_update_nrf52( fetch_store, allow, installed, manager.fetchManifestId(), operator_mid, @@ -704,8 +718,15 @@ struct OtaContext { (defined(OTA_QSPI_BOOTLOADER_UPDATE) || defined(OTA_INTERNAL_BOOTLOADER_UPDATE) || \ defined(OTA_SD_BOOTLOADER_UPDATE)) #if defined(OTA_RAK_AUTO_STORE) +#if defined(OTA_RAK_BOOTLOADER_RECOVERY) + manager.set_accept_bootloader( + (fetch_store.usesInternal() || fetch_store.usesExternal()) && + bootloaderIdentity().crc_ok && + bootloaderAppCaps().storage_flags == OTA_BL_PROFILE_RAK_AUTO); +#else manager.set_accept_bootloader(fetch_store.usesInternal() && ota_bootloader_self_update_caps_valid(ota_bootloader_update_caps())); +#endif #else manager.set_accept_bootloader(true); #endif diff --git a/src/helpers/ota/OtaFlashLayout_nrf52.h b/src/helpers/ota/OtaFlashLayout_nrf52.h index a167bfd5..8d1b2dd7 100644 --- a/src/helpers/ota/OtaFlashLayout_nrf52.h +++ b/src/helpers/ota/OtaFlashLayout_nrf52.h @@ -52,6 +52,7 @@ static const uint32_t MOTA_NRF52_FLASH_PAGE = 4096u; static const uint32_t MOTA_NRF52_CONTAINER_MIN_SIZE = 8u + 197u + 5u; static const uint8_t GPREGRET_OTA_APPLY = 0x6Au; // distinct from DFU magics 0x57/0x4E/0xA8 static const uint8_t GPREGRET_OTA_BOOTLOADER_UPDATE = 0x6Bu; +static const uint8_t GPREGRET2_RAK_RECOVERY_FAILED = 0xCEu; 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; diff --git a/src/helpers/ota/OtaStoreFlashNrf52.cpp b/src/helpers/ota/OtaStoreFlashNrf52.cpp index 7d32dac5..9fda8236 100644 --- a/src/helpers/ota/OtaStoreFlashNrf52.cpp +++ b/src/helpers/ota/OtaStoreFlashNrf52.cpp @@ -4,6 +4,7 @@ #include "OtaSelf.h" #include "OtaBlInfo.h" +#include "OtaBootloaderUpdate.h" #include "MotaContainer.h" #include "OtaDebug.h" #include "OtaByteIO.h" // align_down / rd_u32le (flash-page geometry + header read) @@ -154,7 +155,11 @@ bool OtaStoreFlashNrf52::plan_layout(bool is_full, uint32_t image_size, #if defined(OTA_INTERNAL_BOOTLOADER_UPDATE) 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_update_caps())) + !(ota_bootloader_self_update_caps_valid(ota_bootloader_update_caps()) +#if defined(OTA_RAK_BOOTLOADER_RECOVERY) + || ota_bootloader_app_caps().storage_flags == OTA_BL_PROFILE_RAK_AUTO +#endif + )) return false; // This is the release-blocking no-EndF gate: package kind is known before @@ -165,7 +170,12 @@ bool OtaStoreFlashNrf52::plan_layout(bool is_full, uint32_t image_size, 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)) + (uint32_t)total64, app_base, true, app_base + fi.image_len, start) +#if defined(OTA_RAK_BOOTLOADER_RECOVERY) + || !ota_bootloader_live_bank_preserves_scratch( + app_base, fi.image_len, MOTA_NRF52_SHARED_BOOT_STAGE_START) +#endif + ) return false; _planned_bootloader = true; _planned_total = (uint32_t)total64; @@ -345,7 +355,12 @@ bool OtaStoreFlashNrf52::begin(uint32_t total_size) { !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) + checked_start != planned_start +#if defined(OTA_RAK_BOOTLOADER_RECOVERY) + || !ota_bootloader_live_bank_preserves_scratch( + app_base, fi.image_len, MOTA_NRF52_SHARED_BOOT_STAGE_START) +#endif + ) return false; app_end = app_base + fi.image_len; #else @@ -597,10 +612,19 @@ bool OtaStoreFlashNrf52::reopenFor(const uint8_t* want_mid, uint32_t expected_ta if (bootloader) { #if defined(OTA_INTERNAL_BOOTLOADER_UPDATE) SelfFwInfo fi; - if (!ota_bootloader_self_update_caps_valid(ota_bootloader_update_caps()) || + if (!(ota_bootloader_self_update_caps_valid(ota_bootloader_update_caps()) +#if defined(OTA_RAK_BOOTLOADER_RECOVERY) + || ota_bootloader_app_caps().storage_flags == OTA_BL_PROFILE_RAK_AUTO +#endif + ) || !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) + total, app_base, true, app_base + fi.image_len, want) || want != start +#if defined(OTA_RAK_BOOTLOADER_RECOVERY) + || !ota_bootloader_live_bank_preserves_scratch( + app_base, fi.image_len, MOTA_NRF52_SHARED_BOOT_STAGE_START) +#endif + ) continue; #else continue; diff --git a/src/helpers/ota/OtaStoreQspiNrf52.cpp b/src/helpers/ota/OtaStoreQspiNrf52.cpp index 05d53204..89de11c0 100644 --- a/src/helpers/ota/OtaStoreQspiNrf52.cpp +++ b/src/helpers/ota/OtaStoreQspiNrf52.cpp @@ -4,6 +4,10 @@ #include "OtaByteIO.h" #include "OtaFlashLayout_nrf52.h" +#if defined(OTA_RAK_BOOTLOADER_RECOVERY) + #include "OtaBootloaderUpdate.h" + #include "OtaSelf.h" +#endif #include "hal/nrf_gpio.h" #include "hal/nrf_qspi.h" #include "nrf.h" @@ -885,8 +889,9 @@ uint32_t OtaStoreQspiNrf52::capacity() const { return result; } -bool OtaStoreQspiNrf52::plan_layout(bool, uint32_t image_size, uint32_t, - uint32_t payload_size, bool) { +bool OtaStoreQspiNrf52::plan_layout(bool is_full, uint32_t image_size, + uint32_t payload_off, uint32_t payload_size, + bool is_bootloader) { // 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. @@ -895,6 +900,25 @@ bool OtaStoreQspiNrf52::plan_layout(bool, uint32_t image_size, uint32_t, _stage = OtaQspiStage::IDLE; const uint32_t app_base = mota_nrf52_app_base(); const uint32_t app_ceiling = mota_nrf52_application_ceiling(); +#if defined(OTA_RAK_BOOTLOADER_RECOVERY) + if (is_bootloader) { + SelfFwInfo fi; + if (!is_full || image_size != OTA_BOOT_IMAGE_SIZE || + payload_size != OTA_BOOT_IMAGE_SIZE || payload_off != 365u || + ota_nrf52_effective_stage_ceiling() != MOTA_NRF52_APP_END || + !ota_self_firmware(fi) || + !ota_bootloader_scratch_headroom_valid( + fi.valid, app_base, fi.image_len, MOTA_NRF52_SHARED_BOOT_STAGE_START) || + !ota_bootloader_live_bank_preserves_scratch( + app_base, fi.image_len, MOTA_NRF52_SHARED_BOOT_STAGE_START)) { + fail("recovery scratch is unavailable"); + releaseFlash(); + return false; + } + } +#else + (void)is_full; (void)payload_off; (void)is_bootloader; +#endif if (image_size == 0 || payload_size == 0 || app_base >= app_ceiling || image_size > app_ceiling - app_base) { fail("image exceeds nRF52 application region"); diff --git a/test/test_ota/test_ota_core.cpp b/test/test_ota/test_ota_core.cpp index b9acac5b..5ccb275b 100644 --- a/test/test_ota/test_ota_core.cpp +++ b/test/test_ota/test_ota_core.cpp @@ -278,6 +278,26 @@ TEST(OtaBootPackage, CandidateContinuityEnvelopeHasOneCanonicalFinalOffset) { store, 0, OTA_BL_PROFILE_SD_BOOT_UPDATE, identity, caps)); EXPECT_EQ(identity.manifest_offset, OTA_BOOT_CANDIDATE_MANIFEST_OFFSET); + // Adaptive RAK recovery is authenticated by the application, so its + // candidate retains ABI 2 and the exact 0x16 application capability marker. + auto rak_recovery = canonical; + rak_recovery[0x400u + 8u] = MOTA_APP_FORMAT_VER; + rak_recovery[0x400u + 12u] = OTA_BL_STORAGE_STAGE_CEILING | + OTA_BL_STORAGE_QSPI | + OTA_BL_STORAGE_HEADER_W25; + wr_u32le(rak_recovery.data() + OTA_BOOT_CANDIDATE_MANIFEST_OFFSET + 40u, + 0u); + wr_u32le(rak_recovery.data() + OTA_BOOT_CANDIDATE_MANIFEST_OFFSET + 40u, + ota_boot_image_crc32(rak_recovery.data(), rak_recovery.size(), + OTA_BOOT_CANDIDATE_MANIFEST_OFFSET + 40u)); + ASSERT_TRUE(store.begin((uint32_t)rak_recovery.size())); + ASSERT_TRUE(store.write(0, rak_recovery.data(), (uint32_t)rak_recovery.size())); + EXPECT_TRUE(ota_bootloader_external_image_metadata( + store, 0, rak_recovery[0x400u + 12u], identity, caps, false)); + EXPECT_EQ(caps.apply_abi, MOTA_APP_FORMAT_VER); + EXPECT_FALSE(ota_bootloader_external_image_metadata( + store, 0, rak_recovery[0x400u + 12u], identity, caps)); + // A second CRC-valid base identity still counts when its adjacent BLM2 // extension is only half present. These coupled values are the fixed point // for this deterministic image. Counting continuity first would wrongly diff --git a/tools/lora_ota/lora_ota.py b/tools/lora_ota/lora_ota.py index eef34b74..f8169870 100755 --- a/tools/lora_ota/lora_ota.py +++ b/tools/lora_ota/lora_ota.py @@ -149,7 +149,7 @@ MIN_MESHCLI_VERSION = (1, 6, 0) # Tested format-3 inspect/verify/serve support. Keep automatic repairs pinned; # neither a package nor a moving branch may choose code to install on the host. MOTATOOL_REPAIR_REPOSITORY = "https://github.com/mikecarper/motatool.git" -MOTATOOL_REPAIR_REVISION = "8c38369e7d35ad50cf74261869676d52dd24adf7" +MOTATOOL_REPAIR_REVISION = "beff192aec341077f6d9d4b4938e47323775b9eb" MOTATOOL_REPAIR_TIMEOUT_SECONDS = 60 * 60 # The pinned lockfile, not motatool's older package-level rust-version: zeroize # needs edition 2024 (1.85), and idna_adapter/ICU 2.2 require Rust 1.86. @@ -1085,11 +1085,15 @@ def compatible_mota(info: MotaInfo, target: TargetInfo) -> tuple[bool, str]: f"bootloader target {info.target_id:08X} hw={info.hw_id}, need " f"{target.boot_target_id:08X} hw={target.boot_hw_id}" ) - if target.bootloader_abi is None or target.bootloader_abi < 3: - return False, "bootloader self-update requires installed ABI 3 or newer" + recovery = (target.boot_storage == 0x16 and + info.bootloader_storage == 0x16) + if target.bootloader_abi is None or target.bootloader_abi < (2 if recovery else 3): + return False, "bootloader install requires a capable installed OTAFIX profile" if target.bootloader_codecs != 0x5: return False, "bootloader self-update requires FULL|INPLACE codecs 0x5" - expected_storage = 0x09 if target.nrf_sd else 0x0E if target.nrf_qspi else 0x0A + expected_storage = (0x16 if recovery else + 0x09 if target.nrf_sd else + 0x0E if target.nrf_qspi else 0x0A) if ( target.boot_storage != expected_storage or info.bootloader_storage != expected_storage @@ -2765,7 +2769,7 @@ def query_bootloader_target( if match is None: raise OtaError( f"destination cannot stage a bootloader update: {reply}; " - "requires exact-board ABI-3 self-update bootloader and capable application" + "requires an exact-board update or adaptive RAK recovery application" ) board, target_id, name, _crc, abi, caps = match.groups() library = bootloader_library() @@ -2779,7 +2783,9 @@ def query_bootloader_target( raise OtaError(f"invalid destination bootloader identity: {exc}") from exc if derived_target != int(target_id, 16): raise OtaError("destination bootloader board/name does not match its target ID") - if int(abi) < 3 or int(abi) != target.bootloader_abi: + recovery = int(caps, 16) == 0x16 and (int(board, 16), name) in \ + library.RAK_AUTO_RECOVERY_IDENTITIES + if int(abi) < (2 if recovery else 3) or int(abi) != target.bootloader_abi: raise OtaError("destination reports inconsistent or unsupported bootloader ABI") return replace( target, boot_target_id=derived_target, boot_hw_id=hw_id, diff --git a/tools/lora_ota/test_lora_ota_bootloader.py b/tools/lora_ota/test_lora_ota_bootloader.py index 0c5d71e9..36e0048a 100644 --- a/tools/lora_ota/test_lora_ota_bootloader.py +++ b/tools/lora_ota/test_lora_ota_bootloader.py @@ -21,7 +21,7 @@ import lora_ota as ota from test_lora_ota import firmware, mota_blob, prepare_args, target, VERSION_NEW -def boot_blob(*, storage=0x0A, version=0x0117010D): +def boot_blob(*, storage=0x0A, version=0x0117010D, rak_name="3401_AUTO_DFU"): """Build a signed test-only candidate using the canonical reference builder.""" ml = ota.bootloader_library() board, name, app_base = 0x239A0029, "3401_DFU", ml.NRF52_APP_BASE_S140_V6 @@ -30,9 +30,12 @@ def boot_blob(*, storage=0x0A, version=0x0117010D): name = ml.XIAO_BOOT_DEVICE_NAME.rstrip(b"\0").decode("ascii") elif storage == 0x09: board, name = 0x239A0071, "TOWER_V2_OTA" + elif storage == 0x16: + board, name = 0x239A0029, rak_name image = bytearray(b"\xff" * ml.XIAO_BOOT_IMAGE_SIZE) struct.pack_into("_` ID and collision-checked wire target from the full embedded manifest pair. Parsing and `verify()` repeat those gates; magic @@ -76,7 +78,8 @@ 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 +layout, plus the two exact adaptive RAK identities (`3401_AUTO_DFU` and +`4631_AUTO_DFU`). 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 diff --git a/tools/mota/motalib.py b/tools/mota/motalib.py index a80370f4..458a980a 100644 --- a/tools/mota/motalib.py +++ b/tools/mota/motalib.py @@ -108,11 +108,15 @@ 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_KNOWN = 0x1F +BOOT_STORAGE_HEADER_W25 = 0x10 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_STORAGE_RAK_AUTO_RECOVERY = (BOOT_STORAGE_STAGE_CEILING | + XIAO_BOOT_STORAGE_QSPI | + BOOT_STORAGE_HEADER_W25) BOOT_REQUIRED_APP_CODEC_MASK = ((1 << CODEC_FULL) | (1 << CODEC_DETOOLS_INPLACE)) @@ -138,6 +142,13 @@ INTERNAL_BOOTLOADER_IDENTITIES = ( (0x239A0029, "RTAG_DFU"), ) +# These adaptive loaders do not self-update. Their exact MeshCore application +# authenticates a format-3 package and uses the MBR to replace the bootloader. +RAK_AUTO_RECOVERY_IDENTITIES = ( + (0x239A0029, "3401_AUTO_DFU"), + (0x239A0029, "4631_AUTO_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 @@ -696,7 +707,7 @@ def audit_bootloader_target_inventory(application_target_ids=()) -> dict: identities = ( (XIAO_BOOT_BOARD_ID_BASE, "XIAO_DFU"), (XIAO_BOOT_BOARD_ID_SENSE, "XIAO_DFU"), - ) + INTERNAL_BOOTLOADER_IDENTITIES + ) + INTERNAL_BOOTLOADER_IDENTITIES + RAK_AUTO_RECOVERY_IDENTITIES app_ids = set(application_target_ids) targets = {} for identity in identities: @@ -714,6 +725,7 @@ def audit_bootloader_target_inventory(application_target_ids=()) -> dict: 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 RAK_AUTO_RECOVERY_IDENTITIES or (board_id, device_name) in ( (XIAO_BOOT_BOARD_ID_BASE, "XIAO_DFU"), (XIAO_BOOT_BOARD_ID_SENSE, "XIAO_DFU"))) @@ -743,6 +755,8 @@ def bootloader_qualified_storage_profiles(board_id: int, device_name: str): return (BOOT_STORAGE_INTERNAL_UPDATE, BOOT_STORAGE_SD_UPDATE) if identity in INTERNAL_BOOTLOADER_IDENTITIES: return (BOOT_STORAGE_INTERNAL_UPDATE,) + if identity in RAK_AUTO_RECOVERY_IDENTITIES: + return (BOOT_STORAGE_RAK_AUTO_RECOVERY,) return None @@ -819,7 +833,7 @@ def parse_xiao_bootloader_identity(image: bytes) -> Optional[XiaoBootloaderIdent def bootloader_caps_storage(image: bytes) -> Optional[int]: - """Return the one exact supported self-update storage marker, or None.""" + """Return one exact qualified self-update or app-owned recovery marker.""" found = None valid_count = 0 for off in range(0, len(image) - 16 + 1, 4): @@ -827,16 +841,22 @@ def bootloader_caps_storage(image: bytes) -> Optional[int]: continue abi, codecs = struct.unpack_from("