diff --git a/boards/nrf52840_s140_v6_sd_ota.ld b/boards/nrf52840_s140_v6_sd_ota.ld new file mode 100644 index 00000000..2880ed27 --- /dev/null +++ b/boards/nrf52840_s140_v6_sd_ota.ld @@ -0,0 +1,46 @@ +/* S140 v6 application layout for the MeshTower SD OTA target. */ + +SEARCH_DIR(.) +GROUP(-lgcc -lc -lnosys) + +MEMORY +{ + FLASH (rx) : ORIGIN = 0x26000, LENGTH = 0xED000 - 0x26000 + + /* 8-byte retained clock state followed by the 72-byte MOTASDA2 authorization record. */ + PERSISTENT_RAM (rwx) : ORIGIN = 0x20006000, LENGTH = 80 + + /* SRAM required by SoftDevice depends on configured BLE resources. */ + RAM (rwx) : ORIGIN = 0x20006000 + 80, LENGTH = 0x20040000 - 0x20006000 - 80 +} + +SECTIONS +{ + . = ALIGN(4); + .persistent (NOLOAD) : + { + KEEP(*(.persistent_magic)) + KEEP(*(.persistent_data)) + . = ALIGN(4); + } > PERSISTENT_RAM + + ASSERT(SIZEOF(.persistent) == 8, + "MeshTower SD OTA retained clock prefix must remain exactly 8 bytes") + + . = ALIGN(4); + .svc_data : + { + PROVIDE(__start_svc_data = .); + KEEP(*(.svc_data)) + PROVIDE(__stop_svc_data = .); + } > RAM + + .fs_data : + { + PROVIDE(__start_fs_data = .); + KEEP(*(.fs_data)) + PROVIDE(__stop_fs_data = .); + } > RAM +} + +INCLUDE "nrf52_common.ld" diff --git a/docs/lora_ota_automation.md b/docs/lora_ota_automation.md index 92c0a630..a69758b6 100644 --- a/docs/lora_ota_automation.md +++ b/docs/lora_ota_automation.md @@ -69,6 +69,11 @@ while the same link carries framed folder requests. BLE remains available. | nRF52 repeater, external QSPI | Full image or in-place delta | QSPI-aware exact-board OTAFIX bootloader and matched repeater build | Builds a full mOTA; adding `--base` requests a delta | | MeshTower V2 nRF52, microSD | Full image or in-place delta | SD-aware exact-board OTAFIX bootloader and compatible card | Builds a full mOTA; adding `--base` requests a delta | +MeshTower V2 microSD application and bootloader containers require an Ed25519 signature from a key in the +device allowlist. Supply `--sign-key` when the runner must build a container, and install the BLM2-capable +SD-aware bootloader locally before attempting application or bootloader OTA. Preview.12 requires USB/BLE +DFU or SWD; see [MeshTower V2 microSD LoRa OTA](ota_meshtower_v2_sdcard.md). + The firmware inside a raw ZIP must have a valid MeshCore `EndF` trailer. An ESP32 merged/factory image is not an application image and is rejected. A generic vendor DFU ZIP may also be unusable if it does not contain the raw diff --git a/docs/ota_easy.md b/docs/ota_easy.md index 62884e38..344d7207 100644 --- a/docs/ota_easy.md +++ b/docs/ota_easy.md @@ -18,6 +18,11 @@ Matched QSPI repeater targets and the MeshTower V2 microSD target can also insta complete container stays off-chip. See [nRF52 repeater OTA with external QSPI](ota_nrf52_qspi.md) and [MeshTower V2 microSD LoRa OTA](ota_meshtower_v2_sdcard.md). +MeshTower V2 microSD application and bootloader containers must be Ed25519-signed by a key in the device +allowlist; pass `--sign-key` when building one. Its BLM2-capable SD-aware bootloader is also required before +ordinary application or bootloader OTA can use the reset-retained authorization record. Upgrade preview.12 +locally over USB/BLE DFU or SWD first; there is no raw-card compatibility handoff. + ## Temporary OTA channel used in this guide | Setting | Value | diff --git a/docs/ota_meshtower_v2_sdcard.md b/docs/ota_meshtower_v2_sdcard.md index f8b3c662..19832f7e 100644 --- a/docs/ota_meshtower_v2_sdcard.md +++ b/docs/ota_meshtower_v2_sdcard.md @@ -22,14 +22,16 @@ LoRa radio pinout. ## Card requirements -Use a FAT16, FAT32, or exFAT card with an MBR partition table whose first -partition starts after sector 1. This is the normal layout produced by most SD -formatters. GPT and unpartitioned "super-floppy" layouts are rejected. +Use a FAT16, FAT32, or exFAT card accepted by the bundled SdFat version. The +normal update path imposes no additional MBR/sector-1 layout requirement; +filesystem layouts SdFat cannot mount are still rejected. MeshCore creates +`/meshcore-ota.mota` as a contiguous file and passes its exact sector range to +OTAFIX in reset-retained MCU RAM. -MeshCore creates `/meshcore-ota.mota` as a contiguous file. Sector 1, which is -outside the partition, holds a checksummed bootloader handoff record. The -firmware validates this gap before writing it; an incompatible card layout -fails safely without modifying sector 1. +MeshCore never reads or writes raw sector 1 and does not infer ownership from +blank card sectors. Both application and bootloader OTA require a locally +provisioned BLM2-capable bootloader that understands the retained-RAM record. +Preview.12 must first be upgraded over USB/BLE DFU or SWD. ## Capacity and update types @@ -43,7 +45,8 @@ For this S140 v6 target, the maximum application image including its `EndF` trailer is `0xC7000` bytes (815,104 bytes). To package a full self-update: ```bash -motatool build --fw ./Heltec_tower_v2_sdcard-new.hex --out-dir ./motas +motatool build --fw ./Heltec_tower_v2_sdcard-new.hex \ + --sign-key ./trusted-signer.key --out-dir ./motas motatool verify ./motas/*.mota ``` @@ -57,19 +60,23 @@ motatool build \ --fw ./Heltec_tower_v2_sdcard-new.hex \ --patch-type in-place \ --inplace-memory 0xC7000 \ + --sign-key ./trusted-signer.key \ --out-dir ./motas motatool verify ./motas/*.mota ``` The download is resumable because the partial `.mota` stays on the card. Once it reaches `ready`, `ota install` performs the final verification, -publishes the bootloader handoff, and reboots. Keep the card inserted through -the reboot and installation. +publishes a one-reset authorization record, and reboots. SD application +installation requires a valid signature from a key in the node's allowlist. +Keep the card inserted through the reboot and installation. -The SD-aware bootloader is mandatory. `ota install` refuses to reboot if the -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. +The BLM2 retained-auth SD-aware bootloader is mandatory for application OTA. +`ota install` refuses to reboot if its continuity metadata does not match the +running S140 family/FWID/application layout or if its 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 @@ -78,14 +85,15 @@ 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. +path. A stock bootloader still requires USB/BLE DFU or SWD. The sole remote +bootstrap is intentionally absent: preview.12 and any other legacy-v1 image +must be upgraded locally before either SD application or bootloader OTA. 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`. +It uses the same contiguous `/meshcore-ota.mota` file 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 @@ -102,17 +110,33 @@ 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. +authenticated. For both fmt2 application and fmt3 bootloader packages, +MeshCore authenticates one exact signed manifest, requires every streamed +manifest byte to match it, and verifies the leaves, payload, and image. During +that same pass it hashes the entire container with only the mutable four-byte +`APRV` field normalized to zero. After writing and syncing `APRV`, MeshCore +publishes a 72-byte `MOTASDA2` record at reset-retained RAM address +`0x20006008`. The record binds package purpose/format, exact LBA range, card +size, container length, and that normalized SHA-256. OTAFIX consumes and clears +the record before reading the card; changing the card or file can only fail. +A power loss erases the authorization and also fails closed. + +For fmt3, MeshCore additionally writes and readback-verifies the temporary +64-byte `MOTASDBL` token at `0xE0000`, binding the exact total and signed +manifest `image_hash`. OTAFIX requires the parsed manifest, streamed payload, +and final scratch image to match it. The token page becomes scratch during a +successful boot update and is not permanently reserved. + +Every new candidate also carries a backward-compatible `BLM2`/`SOFT` +continuity extension next to its legacy embedded manifest. The complete +76-byte envelope is fixed at final raw-image offset `0x9FB4`; a relocated copy +is not a valid candidate. Its embedded +bootloader version must equal the outer package version, match the runtime +SoftDevice family/FWID, application base, and layout ABI, and be strictly newer +than an installed BLM2 version. Preview values use low bytes `1..254` and a +stable release uses `0xFF`; zero-preview and all-ones versions are invalid. +Remote bootloader rollback is not supported; use local DFU/SWD when rollback +or migration is intentional. Bootloader packages are never autofetched or autoinstalled. Select and confirm one exact package manually: @@ -235,9 +259,9 @@ clean replacement instead of treating path existence as a valid cache hit. To prepare and load the card: 1. Format it as described under [Card requirements](#card-requirements). The - easiest way to guarantee the expected layout is to insert it in the - repeater, run `set sdcard format`, power the repeater off, and then move the - card to the computer. Formatting destroys the existing card contents. + easiest route is to insert it in the repeater, run `set sdcard format`, power + the repeater off, and then move the card to the computer. Formatting destroys + the existing card contents. 2. Mount the card on the computer and create a directory named `mota` at the filesystem root. Do not use a nested directory such as `/firmware/mota`. 3. Run `motatool verify FILE.mota` for every source file. Do not copy a file diff --git a/docs/ota_nrf52_bootloader_update.md b/docs/ota_nrf52_bootloader_update.md index 75385d65..3859d9af 100644 --- a/docs/ota_nrf52_bootloader_update.md +++ b/docs/ota_nrf52_bootloader_update.md @@ -2,9 +2,10 @@ Selected nRF52840 repeater LoRa-OTA builds can replace their matching OTAFIX bootloader without replacing the running application. This is a privileged -maintenance path, not a normal firmware update. It cannot bootstrap a stock or -older bootloader: install the exact ABI-3 self-update-capable OTAFIX build once -over USB/BLE DFU or SWD. +maintenance path, not a normal firmware update. A stock bootloader must first +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. ## Storage layouts @@ -28,17 +29,29 @@ 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 removable SD authorization is fail-closed for both application and +bootloader packages. MeshCore directly authenticates one exact signed +manifest, requires the streamed copy to remain byte-identical, verifies all +leaves/payload/image bytes, and computes a normalized full-container SHA-256 in +that same pass (`APRV` bytes are treated as zero). After syncing `APRV`, it +publishes a 72-byte `MOTASDA2` record in reset-retained RAM at `0x20006008`. +That record binds purpose/format, first LBA, sector count, total length, card +sector count, and the normalized digest. OTAFIX copies, validates, and clears +the record before SD access. A card swap/mutation or a power cycle therefore +fails closed; there is no general sector-1 ownership inference. + +For fmt3, MeshCore also writes and readback-verifies a 64-byte `MOTASDBL` token +at `0xE0000`. That token contains the exact container length and the exact +authenticated signed manifest `image_hash`. OTAFIX requires the same hash in +the parsed manifest, streamed payload, and final scratch image. The page is +consumed as scratch during success and is not permanently reserved. + +The first BLM2-capable MeshTower bootloader cannot be delivered to preview.12 +through the retained-RAM protocol that preview.12 predates. MeshCore does not +write a raw-sector compatibility record because a blank sector is not proof of +ownership. Upgrade preview.12 through USB/BLE DFU or SWD. Both fmt2 application +and fmt3 bootloader OTA then require the installed BLM2 metadata to match the +live SoftDevice/application layout; neither MeshCore nor OTAFIX uses sector 1. The internal path does not change the application linker or permanently set aside separate app-OTA, boot-package, and scratch regions. The ordinary @@ -105,6 +118,30 @@ duplicates and collisions with application target IDs. Generic image parsing can inspect a future canonical identity, but signing/building a package fails until that exact identity is in the qualified inventory. +## Embedded continuity and version policy + +Every remotely supplied successor retains the legacy 44-byte CRC-valid `BLMF` +v1 record for identity continuity and diagnostics. Immediately after that +record is a 32-byte `BLM2`/`SOFT` extension. The complete 76-byte envelope is +fixed at the final raw-image offset `0x9FB4`; relocated candidates are refused, +while installed legacy-v1 discovery remains a generic diagnostic scan. The extension carries the actual packed +bootloader version, SoftDevice family and FWID, application base, layout ABI, +and zero compatibility/reserved fields; the legacy whole-image CRC covers the +extension too. Scanners first count CRC-valid 44-byte base manifests, then +interpret continuity only after one base identity remains. A malformed claimed +extension therefore cannot make a duplicate identity disappear, and a sole +half-present extension is rejected rather than treated as legacy. + +MeshCore requires the candidate extension, exact equality between its embedded +version and the signed outer mOTA version, and exact agreement with the running +SoftDevice family/FWID/application layout. Qualified internal/QSPI paths may +bootstrap a CRC-valid installed legacy-v1 image once; the SD path never does +and requires local BLM2 provisioning. Once the installed bootloader has BLM2 +metadata, the candidate version must be strictly greater. Preview low bytes are `1..254`, a +stable release uses `0xFF`, and low-byte zero or all-ones values are rejected. +There is no remote rollback/migration override; intentional rollback uses +USB/BLE DFU or SWD. + The `no_external_sensors` profiles omit optional/add-on sensor and GPS packages to preserve flash headroom. Board-integrated GPS can be retained by a board's recipe. The RAK3401 lean target specifically omits RAK12501/add-on GPS; use the @@ -156,9 +193,12 @@ 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, 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 +typed MID/hash confirmation. The manifest root and target inside the received +manifest must also equal the MID/target that opened the fetch before any store +is allocated. On SD, `APRV`, the retained geometry/container authorization, +and the internal signed-image-hash token are published in that order before +the reset trigger. 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, the applicable live `EndF`/bank-settings no-overlap geometry, the SD token @@ -173,13 +213,13 @@ 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. +consumes the trigger and authorization before its first destructive action, so +an interrupted operation cannot automatically retry a partly consumed +package. On SD, the retained-RAM authorization is zero-consumed before media +access; `APRV` and the fmt3 flash token may persist but are inert without a new +explicit authenticated re-arm and reset trigger. No SD raw-sector handoff is +created or consumed. +Use USB/BLE DFU or SWD for initial provisioning and local recovery. For XIAO and ordinary external-QSPI details, see [nRF52 repeater LoRa OTA with external QSPI](ota_nrf52_qspi.md). diff --git a/docs/ota_nrf52_qspi.md b/docs/ota_nrf52_qspi.md index 05ccbcdc..517b953f 100644 --- a/docs/ota_nrf52_qspi.md +++ b/docs/ota_nrf52_qspi.md @@ -212,9 +212,13 @@ The final command must reproduce both the complete staged manifest ID and the first eight bytes of its signed image hash. Before approval the application again checks package/root/payload/image hashes, Ed25519 signature and trusted allowlist, exact signed `XIAO_BL_...` ID, installed and incoming embedded -manifest/CRC/name/board identity, vector table, and ABI-3 QSPI+boot-update -continuity. A signed older bootloader that would remove remote update support -is refused. + manifest/CRC/name/board identity, vector table, ABI-3 QSPI+boot-update marker, + and the adjacent CRC-covered `BLM2`/`SOFT` continuity extension at the exact + final-image offset `0x9FB4`. The embedded + boot version must equal the signed outer package version, the SoftDevice + family/FWID/application base/layout ABI must match the running platform, and + a successor to installed BLM2 must be strictly newer. Low-byte-zero and + all-ones versions are invalid. Remote rollback is refused; use local DFU/SWD. After the reply drains, GPREGRET `0x6B` and GPREGRET2 `0x51` enter the special OTAFIX path. `APRV` carries the app's signature/allowlist and explicit operator diff --git a/docs/ota_protocol.md b/docs/ota_protocol.md index 1d871706..6dd77ec4 100644 --- a/docs/ota_protocol.md +++ b/docs/ota_protocol.md @@ -271,14 +271,25 @@ A v3 bootloader package has a deliberately narrow, non-extensible profile: - signed `hw_id` exactly `XIAO_BL_28860044`/`XIAO_BL_28860045` for deployed XIAO, or the zero-padded 32-byte `NRF_BL__` for a generic target; - a sane nRF52840 vector table, exactly one CRC-valid embedded manifest v1 with the exact board/name pair, + followed by the required CRC-covered `BLM2`/`SOFT` continuity extension (embedded boot version, + SoftDevice family/FWID, application base, and layout ABI), with that complete 76-byte envelope at the + canonical final-image offset `0x9FB4`, 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 -manifest. A package must be signed by a key already in the device's trusted allowlist; unlike ordinary +manifest. Duplicate accounting counts each CRC-valid 44-byte base record before interpreting adjacent +continuity metadata, so a corrupt or half-present `BLM2` extension cannot hide a second identity; after +exactly one base record is selected, malformed claimed continuity fails closed. A package must be signed by +a key already in the device's trusted allowlist; unlike ordinary application packages, there is no unsigned manual-install exception. +The signed outer `fw_version` must equal the embedded boot version. Qualified internal/QSPI targets may +bootstrap a CRC-valid legacy-v1 installed bootloader once; MeshTower SD instead requires local BLM2 +provisioning because it has no safe legacy media handoff. After bootstrap every remote successor must be strictly newer and match the live +SoftDevice/application layout. Low-byte zero and all-ones boot versions are invalid. Remote rollback has no +override and must use local DFU/SWD. --- @@ -360,7 +371,13 @@ There is no separate availability structure. **Block `i` is present <=> `leaves[ the staged container after a reboot - re-parse the stored manifest, recompute geometry, count present blocks, continue fetching the holes (or jump straight to COMPLETE). The checkpoint cadence (persist progress every N committed blocks) is runtime-tunable (`ota config checkpoint `, 0 = only finalized containers -resume). Stores keep `leaves[]` in RAM until flush and never auto-GC, preserving resumable progress. +resume). Boot-time adoption is an automatic fetch decision: current `autofetch` must be enabled, the stored +target must equal the node target, policy `signed` requires the signed bit, and an enabled running-version +floor requires a strictly newer manifest. An explicit MID pull may deliberately resume an older or unsigned +package and keeps target `0` as a MID-only wildcard. Stores keep `leaves[]` in RAM until flush and never +auto-GC, preserving resumable progress. The debug/operator equivalent is `ota dev resume `; after a +reboot the MID is mandatory, while a no-argument form may only reuse a still-active session MID. It never +uses the `nullptr` automatic-adoption path, so a malformed MID or no active MID fails closed. **Flash-store note (RX-safe writes):** a flash page-erase halts the CPU (~85 ms on nRF52) and starves LoRa RX, so the flash stores (`OtaStoreFlashNrf52`/`OtaStoreFlashEsp32`) **coalesce writes to the erase unit** @@ -494,6 +511,11 @@ fetcher server (any node that has the mid) when all blocks present: verify full merkle_root + image_hash -> COMPLETE ``` +Before allocating or writing the selected store, the receiver parses the reassembled manifest and requires +its `merkle_root` to equal the requested/wire `manifest_id` and its `target_id` to equal the catalog or +explicit-pull target that opened the receive slot. The wire envelope and HAVE row are advisory; they cannot +label and stage a different manifest. + ### 8.4 Message bodies (transfer) All offsets after the 1-byte type. Encoders/decoders in `OtaProtocol.cpp`; constants in `OtaManager.h`. @@ -630,8 +652,13 @@ transmission per hop. independent of signature. - **Signing & allowlist:** a node keeps a runtime allowlist of trusted Ed25519 signer pubkeys (none embedded in firmware; `ota key add/list/rm`). A `.mota` is eligible for **auto-install** only if signed by an - allowlisted key, the signature verifies, and `image_hash` matches. Manual install permits unsigned packages, + allowlisted key, the signature verifies, `image_hash` matches, and its nonzero signed `fw_version` is + strictly greater than the running hash-valid EndF version. Both catalog admission and final automatic + apply enforce the version floor, so a lying HAVE row cannot bypass it. Manual `ota install` is the explicit + equal-version/rollback override and generally permits unsigned packages, but a package that claims to be signed must have a valid signature from an allowlisted key or it is rejected. + The removable-SD target is stricter: every application install needs a valid allowlisted signature because + the app mints an authenticated one-reset media authorization for OTAFIX. **Transfer needs no trust** - blocks are content-addressed against the manifest's merkle root. - **Policies (persisted):** `autofetch` in {off, any, signed} (default off) gates automatic block fetching of own-target adverts; `autoinstall` in {off, trusted} (default off) gates auto-apply of a COMPLETE signed + @@ -783,7 +810,7 @@ ota qspi | storage QSPI nRF52 only: JEDEC/SR1/stage/latched stor ota folder | fold [on|off] attach/detach an external .mota folder (host daemon) ; bare = list ota config | cfg | set [autofetch|autoinstall|checkpoint] ... show/set persisted policy ota key | keys [add|rm ] trusted signer allowlist ; bare = list -ota dev ... bring-up helpers (stage/recv/serve/verify) +ota dev ... bring-up helpers (stage/recv/serve/resume /verify) ``` --- @@ -840,22 +867,27 @@ ota dev ... bring-up helpers (stage/recv/serve/verify) Internal-bootloader-self-update builds are a stricter exception: because their ordinary linker may extend through `0xED000`, an absent/corrupt live `EndF` disables every internal staging pull before erase instead of trusting the legacy 608 KiB estimate. -- **MeshTower V2 SD nRF52:** the application stores a contiguous `/meshcore-ota.mota` on microSD and - publishes its raw sector range in a checksummed handoff record outside the MBR partition. The matching - bootloader reads the card without mounting FAT, supports either a full image or an in-place delta, +- **MeshTower V2 SD nRF52:** the application stores a contiguous `/meshcore-ota.mota` on microSD. After + authenticating one exact signed manifest and verifying the leaves/payload/image, it hashes the exact full + container with only `APRV` normalized to zero and publishes a 72-byte reset-retained `MOTASDA2` record. + That record binds app-vs-boot purpose, format, raw sector range, total/card geometry, and normalized digest; + OTAFIX consumes and clears it before reading the card. There is no normal sector-1 handoff or additional + OTA-specific partition-layout requirement beyond what the bundled SdFat can mount. The matching + bootloader reads the authorized sectors 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. 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 + also cover that complete live image and stop by `0xE0000`. For fmt3 MeshCore additionally writes a readback-checked + `MOTASDBL` token at `0xE0000` containing the exact total and signed `image_hash`. 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. + headroom restriction. Both fmt2 application apply and fmt3 bootloader apply require installed BLM2 + continuity matching the live S140 FWID/application layout. Preview.12 must be upgraded locally over + USB/BLE DFU or SWD. MeshCore never writes a raw sector-1 handoff. - **Matched external-QSPI nRF52 repeaters:** the application reserves the board's dedicated QSPI NOR as a raw store beginning at offset zero. It obtains a 1-16 MiB capacity from JEDEC, checkpoints payload before leaf metadata, and verifies each erased/programmed page. GPREGRET2 `0x51` selects QSPI only when the diff --git a/docs/ota_user_guide.md b/docs/ota_user_guide.md index c0662058..57390737 100644 --- a/docs/ota_user_guide.md +++ b/docs/ota_user_guide.md @@ -211,8 +211,11 @@ ota install The node verifies the firmware one last time, and if everything checks out it installs it and **reboots into the new version**. If the check fails, it tells you why and does **not** install. Unsigned images -install only through this explicit command. A signed image whose signer is not in the device allowlist is -rejected; trusted signed images can auto-install only when that policy is enabled. +normally install only through this explicit command. The MeshTower V2 SD target is stricter and requires an +allowlisted signature even for a manual application install, because it authorizes removable-media bytes for +the bootloader. A signed image whose signer is not in the device allowlist is rejected; trusted signed images +can auto-install only when that policy is enabled and the signed version is strictly newer than the running +hash-valid EndF version. Manual `ota install` remains the deliberate equal-version/rollback override. After it reboots, run `ota status` to confirm the new version. @@ -246,10 +249,13 @@ Copy both confirmation values exactly from the second `ota bootloader` reply. Ordinary `ota install` deliberately refuses this package. The privileged command requires an exact 40 KiB candidate payload in the fixed 41,330-byte container, a valid exact embedded identity/CRC and vector table, continued -boot-update support, and a valid signature from a key already in `ota key`'s -trusted allowlist. It preserves the running application while OTAFIX replaces -itself; `blup:C8` in post-reboot `ota status` means success. Any node lacking -this command or those capabilities must update its bootloader locally instead. +boot-update support, required `BLM2`/`SOFT` continuity metadata at canonical +raw-image offset `0x9FB4`, a boot version +that exactly matches the package and is newer than installed BLM2, and a valid +signature from a key already in `ota key`'s trusted allowlist. It preserves the +running application while OTAFIX replaces itself; `blup:C8` in post-reboot +`ota status` means success. Remote rollback is refused. Any node lacking this +command or those capabilities must update its bootloader locally instead. On an internal-flash target, the package shares the ordinary store below `0xED000` and bottom-aligns at `0xE2000`; there is no separate reserved scratch @@ -260,9 +266,14 @@ 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. +also ending by `0xE0000`. MeshCore binds both application and bootloader SD +approval to purpose, exact raw geometry, and a normalized SHA-256 in a +reset-retained `MOTASDA2` record. Boot updates also bind the exact authenticated +signed image hash in the E0000 token. OTAFIX consumes the retained record before +media access, so a later card change or power cycle fails instead of authorizing +different bytes. MeshCore never claims or writes raw sector 1. Both application +and bootloader OTA wait until a matching BLM2 bootloader has been provisioned +locally; preview.12 requires USB/BLE DFU or SWD first. 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 @@ -309,7 +320,7 @@ ota config autofetch any # auto-DOWNLOAD any compatible update for this n ota config autofetch signed # auto-download only signed updates ota config autofetch off # back to manual (default) -ota config autoinstall trusted # auto-INSTALL a downloaded update IF it's signed by a key you trust +ota config autoinstall trusted # auto-INSTALL only a trusted signed version newer than the running EndF ota config autoinstall off # never auto-install (default) ota config advert 1440 # re-advertise every N minutes while temp radio is running @@ -321,8 +332,18 @@ ota config hops 0 # only exchange OTA with directly-connected node ota config # show the current settings ``` +These policies also govern automatic adoption of an interrupted staged download after reboot. `off` leaves +it untouched, `signed` requires the stored manifest's signed flag, and automatic resume requires the stored +target to match this node and its version to be newer than the running valid EndF. Reissuing an explicit +`ota pull ` remains the deliberate override for an older or unsigned partial. +For bring-up/debugging, `ota dev resume ` performs the same explicit MID-bound re-adoption without +starting a new network fetch. After reboot it requires the MID; bare `ota dev resume` is accepted only while +an active/requested session MID still exists, and malformed or missing identifiers are rejected. + Recommended for most people: leave both **off** and update by hand. Use `autoinstall trusted` only once -you've added the signer's key (next section) and you trust them to push updates unattended. +you've added the signer's key (next section) and you trust them to push updates unattended. Automatic +admission and final apply both reject zero, equal, or older signed versions; a dishonest catalog version +cannot bypass the manifest check. Use manual `ota pull` plus `ota install` for an intentional rollback. The MeshTower V2 SD OTA target has a separate, default-on **archive** policy. It saves all mOTAs it sees to the SD card so the repeater can seed them later; this does not install them and does not change the @@ -345,9 +366,10 @@ ota key list # show trusted signers ota key rm # stop trusting one ``` -Only updates signed by a trusted key are eligible for auto-install. Manual `ota install` permits an unsigned -package after all integrity, hardware, base, and bootloader checks pass. A signed package whose signer is not -in the device allowlist is rejected rather than silently treated as unsigned. +Only strictly newer updates signed by a trusted key are eligible for auto-install. Manual `ota install` +permits an unsigned package after all integrity, hardware, base, and bootloader checks pass, except on the +MeshTower V2 removable-SD path where all application installs must be signed and allowlisted. A signed +package whose signer is not in the device allowlist is rejected rather than silently treated as unsigned. --- diff --git a/src/Mesh.cpp b/src/Mesh.cpp index 75f57dd0..89f0a9b6 100644 --- a/src/Mesh.cpp +++ b/src/Mesh.cpp @@ -415,7 +415,7 @@ void __attribute__((noinline)) Mesh::serviceLoopMaintenance() { && !oc.manager.fetched_is_bootloader()) { _ota_autoinstall_tried = true; char msg[100]; - oc.apply_fetched(msg); // arms + sets apply_pending only if signed & allowlisted; refused otherwise + oc.apply_fetched_trusted_auto(msg); // signed/trusted and strictly newer; manual install may override } } #endif diff --git a/src/helpers/esp32/WiFiOtaSeeder.cpp b/src/helpers/esp32/WiFiOtaSeeder.cpp index 515f5fec..1373be31 100644 --- a/src/helpers/esp32/WiFiOtaSeeder.cpp +++ b/src/helpers/esp32/WiFiOtaSeeder.cpp @@ -107,7 +107,9 @@ void WiFiOtaSeeder::loop() { seeder_client.remoteIP().toString().c_str()); context.set_folder_dest(&folder_store, link_info); if (context.manager.fetchState() == OtaManager::PAUSED) { - context.manager.resumeStaged(nullptr); + // This continues the active host-selected MID; it is not a boot-time + // automatic adoption governed by application autofetch policy. + context.manager.resumeStaged(context.manager.fetchManifestId()); } context.manager.announce(); Serial.printf("OTA seeder client connected (%s)\n", link_info); diff --git a/src/helpers/ota/OtaApply.cpp b/src/helpers/ota/OtaApply.cpp index b828549f..bafa51fb 100644 --- a/src/helpers/ota/OtaApply.cpp +++ b/src/helpers/ota/OtaApply.cpp @@ -462,7 +462,9 @@ bool ota_apply_detools_mota(const uint8_t*, uint32_t, const SignerAllowlist&, Ap void ota_reboot_to_apply() { // public: set the apply magic + reset (does not return) uint8_t stage_handoff = GPREGRET2_OTA_STAGE_LEGACY; -#if defined(OTA_QSPI_STORE) +#if defined(OTA_SD_STORE) + stage_handoff = GPREGRET2_OTA_STAGE_SD; +#elif defined(OTA_QSPI_STORE) stage_handoff = GPREGRET2_OTA_STAGE_QSPI; #elif defined(OTA_FLASH_STORE) stage_handoff = mota_nrf52_flash_stage_handoff(ota_nrf52_effective_stage_ceiling()); @@ -641,9 +643,24 @@ bool ota_rescue_mota_nrf52(const uint8_t* buf, uint32_t len, const SignerAllowli #if defined(OTA_SD_STORE) || defined(OTA_QSPI_STORE) static const size_t NRF52_APPLY_MSG_CAP = 96; +#if defined(OTA_SD_STORE) +static bool external_application_approve(OtaStoreSdNrf52& store, + const uint8_t container_hash[32]) { + return store.approve_for_bootloader(nullptr, container_hash); +} +#endif + +#if defined(OTA_QSPI_STORE) +static bool external_application_approve(OtaStoreQspiNrf52& store, + const uint8_t[32]) { + return store.approve_for_bootloader(); +} +#endif + template static bool ota_apply_mota_nrf52_external(Store& store, const SignerAllowlist& allow, uint8_t storage_flag, const char* storage_name, + bool require_trusted_signature, ApplyState& st, char* msg) { st = ApplyState(); uint8_t hdr[8], manifest[MOTA_MFL]; @@ -695,14 +712,24 @@ static bool ota_apply_mota_nrf52_external(Store& store, const SignerAllowlist& a return false; } - VerifyResult vr = ota_verify(static_cast(store), allow); + // Require the streamed manifest to remain byte-identical to the local copy + // parsed above. The verifier also computes the normalized full-container + // digest during that same integrity/authentication pass for removable SD. + VerifyResult vr = ota_verify(static_cast(store), allow, manifest); st.sig_ok = vr.sig_ok; st.trusted = vr.trusted; - if (!vr.root_ok || !vr.payload_ok || !vr.image_ok) { + if (!vr.root_ok || !vr.payload_ok || !vr.image_ok || !vr.container_hash_ok) { snprintf(msg, NRF52_APPLY_MSG_CAP, "payload hash mismatch (incomplete or corrupt %s .mota)", storage_name); return false; } + if (require_trusted_signature && !vr.auto_appliable()) { + if (!vr.is_signed || !vr.sig_ok) + strcpy(msg, "SD application OTA requires a valid signed manifest"); + else + strcpy(msg, "SD application OTA signer is not in the trusted allowlist"); + return false; + } SelfFwInfo fi; if (delta) { @@ -738,7 +765,7 @@ static bool ota_apply_mota_nrf52_external(Store& store, const SignerAllowlist& a return false; } } - if (!store.approve_for_bootloader()) { + if (!external_application_approve(store, vr.container_hash)) { snprintf(msg, NRF52_APPLY_MSG_CAP, "%s handoff failed: %s", storage_name, store.last_error()); return false; @@ -751,80 +778,39 @@ static bool ota_apply_mota_nrf52_external(Store& store, const SignerAllowlist& a } #endif -#if defined(OTA_SD_BOOTLOADER_UPDATE) || defined(OTA_QSPI_BOOTLOADER_UPDATE) -template -static bool external_bootloader_crc_ok(Store& store, uint32_t payload_off, - const OtaBootloaderIdentity& identity) { - uint8_t buf[512]; - uint32_t crc = UINT32_MAX; - for (uint32_t off = 0; off < OTA_BOOT_IMAGE_SIZE; off += sizeof(buf)) { - uint32_t len = OTA_BOOT_IMAGE_SIZE - off; - if (len > sizeof(buf)) len = sizeof(buf); - if (!store.read(payload_off + off, buf, len)) return false; - for (uint32_t i = 0; i < len; i++) { - const uint32_t pos = off + i; - const uint8_t value = pos >= identity.manifest_offset + 40u && - pos < identity.manifest_offset + 44u ? 0 : buf[i]; - crc = ota_boot_crc32_update(crc, value); - } - } - return ~crc == identity.crc32; +#if defined(OTA_SD_BOOTLOADER_UPDATE) || defined(OTA_QSPI_BOOTLOADER_UPDATE) || \ + defined(OTA_INTERNAL_BOOTLOADER_UPDATE) +static uint16_t ota_runtime_softdevice_fwid() { + // Nordic's S140 information structure lives at MBR(0x1000) + 0x2000; + // FWID is the little-endian uint16 at +0x0C. + return *(const volatile uint16_t*)(uintptr_t)0x0000300Cu; } -template -static bool external_bootloader_image_metadata(Store& store, uint32_t payload_off, - OtaBootloaderIdentity& candidate, - OtaBootloaderCapsMarker& caps) { - // Keep enough overlap to catch a 44-byte manifest or 16-byte capability marker split across a - // window. Every aligned manifest candidate is considered; a magic string in a literal pool is not - // a terminal failure. - static const uint32_t STEP = 512; - uint8_t buf[STEP + OTA_BOOT_MANIFEST_SIZE - 1]; - uint8_t valid_identities = 0; - uint8_t valid_caps = 0; - for (uint32_t base = 0; base < OTA_BOOT_IMAGE_SIZE; base += STEP) { - uint32_t len = OTA_BOOT_IMAGE_SIZE - base; - if (len > sizeof(buf)) len = sizeof(buf); - if (!store.read(payload_off + base, buf, len)) return false; - const uint32_t starts = (OTA_BOOT_IMAGE_SIZE - base < STEP) - ? OTA_BOOT_IMAGE_SIZE - base : STEP; - for (uint32_t local = 0; local < starts; local++) { - const uint32_t absolute = base + local; - if ((absolute & 3u) == 0 && local + 16u <= len) { - OtaBootloaderCapsMarker parsed; - if (ota_bootloader_caps_marker_parse(buf + local, parsed) && - parsed.apply_abi >= MOTA_BOOT_FORMAT_VER && - (parsed.codec_mask & 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; - } - } - if ((absolute & 3u) != 0 || local + OTA_BOOT_MANIFEST_SIZE > len) continue; - OtaBootloaderIdentity 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; - } - } +static const char* ota_bootloader_continuity_error(OtaBootloaderContinuityGate gate) { + switch (gate) { + case OTA_BOOT_CONTINUITY_CANDIDATE_MISSING: return "candidate lacks boot continuity metadata"; + case OTA_BOOT_CONTINUITY_OUTER_VERSION: return "candidate embedded/package version mismatch"; + case OTA_BOOT_CONTINUITY_PLATFORM: return "candidate SoftDevice/application layout mismatch"; + case OTA_BOOT_CONTINUITY_INSTALLED_PLATFORM: return "installed boot continuity metadata mismatch"; + case OTA_BOOT_CONTINUITY_NOT_NEWER: return "candidate bootloader is not newer"; + default: return "boot continuity mismatch"; } - return valid_identities == 1u && valid_caps == 1u && - candidate.present && candidate.crc_ok && caps.present; } #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); + const uint8_t image_hash[32], + const uint8_t container_hash[32]) { + return store.approve_for_bootloader(image_hash, container_hash); } #endif #if defined(OTA_QSPI_BOOTLOADER_UPDATE) static bool external_bootloader_approve(OtaStoreQspiNrf52& store, - const uint8_t image_hash[32]) { + const uint8_t image_hash[32], + const uint8_t container_hash[32]) { (void)image_hash; + (void)container_hash; return store.approve_for_bootloader(); } #endif @@ -893,7 +879,7 @@ static bool ota_prepare_bootloader_update_external(Store& store, st.trusted = vr.trusted; st.image_size = m.image_size; memcpy(st.image_hash, m.image_hash, sizeof(st.image_hash)); - if (!vr.auto_appliable()) { + if (!vr.auto_appliable() || !vr.container_hash_ok) { if (!vr.root_ok || !vr.payload_ok || !vr.image_ok) strcpy(msg, "bootloader payload hash mismatch"); else if (!vr.sig_ok) @@ -909,12 +895,23 @@ static bool ota_prepare_bootloader_update_external(Store& store, } OtaBootloaderIdentity candidate; OtaBootloaderCapsMarker candidate_caps; - if (!external_bootloader_image_metadata(store, payload_off, candidate, candidate_caps) || + if (!ota_bootloader_external_image_metadata( + store, payload_off, ota_bootloader_update_storage_flags(), + candidate, candidate_caps) || !ota_bootloader_identity_matches(installed, candidate)) { strcpy(msg, "candidate bootloader identity/capability/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(), mota_nrf52_app_base(), + OTA_BOOT_CONTINUITY_LAYOUT_ABI); + if (continuity != OTA_BOOT_CONTINUITY_OK) { + strncpy(msg, ota_bootloader_continuity_error(continuity), CAP); + msg[CAP - 1] = 0; + return false; + } st.slot_ok = true; - if (!external_bootloader_approve(store, m.image_hash)) { + if (!external_bootloader_approve(store, m.image_hash, vr.container_hash)) { snprintf(msg, CAP, "%s bootloader handoff failed: %s", storage_name, store.last_error()); return false; } @@ -926,7 +923,19 @@ static bool ota_prepare_bootloader_update_external(Store& store, #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) + OtaBootloaderIdentity installed; + if (!ota_installed_bootloader_identity(installed) || + !ota_bootloader_sd_retained_auth_ready( + installed, OTA_BOOT_CONTINUITY_FAMILY_S140, + ota_runtime_softdevice_fwid(), mota_nrf52_app_base(), + OTA_BOOT_CONTINUITY_LAYOUT_ABI)) { + st = ApplyState(); + strcpy(msg, "SD application OTA requires the BLM2 retained-auth bootloader"); + return false; + } +#endif + return ota_apply_mota_nrf52_external(store, allow, OTA_BL_STORAGE_SD, "SD", true, st, msg); } #if defined(OTA_SD_BOOTLOADER_UPDATE) @@ -937,6 +946,14 @@ bool ota_prepare_bootloader_update_nrf52(OtaStoreSdNrf52& store, const uint8_t operator_mid[4], const uint8_t operator_hash8[8], ApplyState& st, char* msg) { + if (!ota_bootloader_sd_retained_auth_ready( + installed, OTA_BOOT_CONTINUITY_FAMILY_S140, + ota_runtime_softdevice_fwid(), mota_nrf52_app_base(), + OTA_BOOT_CONTINUITY_LAYOUT_ABI)) { + st = ApplyState(); + strcpy(msg, "SD bootloader OTA requires a locally installed BLM2 retained-auth bootloader"); + return false; + } SelfFwInfo fi; if (!ota_self_firmware(fi) || !ota_bootloader_scratch_headroom_valid( @@ -956,7 +973,7 @@ bool ota_prepare_bootloader_update_nrf52(OtaStoreSdNrf52& store, #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); + return ota_apply_mota_nrf52_external(store, allow, OTA_BL_STORAGE_QSPI, "QSPI", false, st, msg); } #if defined(OTA_QSPI_BOOTLOADER_UPDATE) @@ -1043,7 +1060,8 @@ bool ota_prepare_bootloader_update_nrf52(OtaStoreFlashNrf52& store, } OtaBootloaderIdentity candidate; OtaBootloaderCapsMarker candidate_caps; - if (!ota_bootloader_identity_from_image(image, OTA_BOOT_IMAGE_SIZE, candidate) || + if (!ota_bootloader_candidate_identity_from_image( + image, OTA_BOOT_IMAGE_SIZE, candidate) || !ota_bootloader_identity_matches(installed, candidate) || !ota_bootloader_caps_from_image( image, OTA_BOOT_IMAGE_SIZE, @@ -1051,6 +1069,15 @@ bool ota_prepare_bootloader_update_nrf52(OtaStoreFlashNrf52& store, candidate_caps)) { strcpy(msg, "candidate bootloader identity/capability/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(), mota_nrf52_app_base(), + OTA_BOOT_CONTINUITY_LAYOUT_ABI); + if (continuity != OTA_BOOT_CONTINUITY_OK) { + strncpy(msg, ota_bootloader_continuity_error(continuity), CAP); + msg[CAP - 1] = 0; + return false; + } st.slot_ok = true; if (!store.approve_for_bootloader()) { snprintf(msg, CAP, "internal bootloader handoff failed: %s", store.last_error()); return false; diff --git a/src/helpers/ota/OtaBlInfo.h b/src/helpers/ota/OtaBlInfo.h index f21102d8..998d3c5f 100644 --- a/src/helpers/ota/OtaBlInfo.h +++ b/src/helpers/ota/OtaBlInfo.h @@ -94,6 +94,7 @@ inline OtaBlCaps ota_bl_caps_scan_aligned(const uint8_t* bytes, size_t len, OtaBlCaps c; if (!bytes) return c; uint8_t privileged_count = 0; + bool exact_privileged = false; for (size_t off = 0; off + 16u <= len; off += 4u) { const uint8_t* p = bytes + off; if (p[0] != OTA_BL_MAGIC[0] || memcmp(p, OTA_BL_MAGIC, 8) != 0) continue; @@ -103,18 +104,21 @@ 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 && + if (require_boot_update && 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 - // two copies of the expected exact profile). Malformed magic/literal - // decoys were filtered above and do not count. + // not silently choose between two otherwise valid structures, even if + // one claims a different known storage profile (or only BOOT_UPDATE). + // Malformed magic/literal decoys were filtered above and do not count. if (++privileged_count != 1u) return OtaBlCaps(); - c.present = true; - c.apply_abi = abi; - c.codec_mask = codecs; - c.storage_flags = storage; + if (exact_profile) { + exact_privileged = true; + c.present = true; + c.apply_abi = abi; + c.codec_mask = codecs; + c.storage_flags = storage; + } continue; } if (ota_bl_caps_prefer(c, abi, storage, require_boot_update)) { @@ -124,7 +128,8 @@ inline OtaBlCaps ota_bl_caps_scan_aligned(const uint8_t* bytes, size_t len, c.storage_flags = storage; } } - if (require_boot_update && privileged_count != 1u) return OtaBlCaps(); + if (require_boot_update && + (privileged_count != 1u || !exact_privileged)) return OtaBlCaps(); return c; } diff --git a/src/helpers/ota/OtaBootloaderUpdate.h b/src/helpers/ota/OtaBootloaderUpdate.h index cdfb5fbf..c1995e9e 100644 --- a/src/helpers/ota/OtaBootloaderUpdate.h +++ b/src/helpers/ota/OtaBootloaderUpdate.h @@ -52,9 +52,20 @@ static const uint32_t OTA_BOOT_MANIFEST_MAGIC0 = 0x464D4C42UL; // bytes "BLMF" static const uint32_t OTA_BOOT_MANIFEST_MAGIC1 = 0x31435243UL; // bytes "CRC1" static const uint16_t OTA_BOOT_MANIFEST_VERSION = 1; static const uint16_t OTA_BOOT_MANIFEST_SIZE = 44; +static const uint32_t OTA_BOOT_CONTINUITY_MAGIC0 = 0x324D4C42UL; // bytes "BLM2" +static const uint32_t OTA_BOOT_CONTINUITY_MAGIC1 = 0x54464F53UL; // bytes "SOFT" +static const uint16_t OTA_BOOT_CONTINUITY_VERSION = 2; +static const uint16_t OTA_BOOT_CONTINUITY_SIZE = 32; +static const uint16_t OTA_BOOT_CONTINUITY_FAMILY_S140 = 140; +static const uint16_t OTA_BOOT_CONTINUITY_LAYOUT_ABI = 1; +static const uint32_t OTA_BOOT_CONTINUITY_OFFSET = OTA_BOOT_MANIFEST_SIZE; +static const uint32_t OTA_BOOT_ENVELOPE_SIZE = + OTA_BOOT_MANIFEST_SIZE + OTA_BOOT_CONTINUITY_SIZE; static const uint8_t OTA_BOOT_DEVICE_NAME_SIZE = 16; static const uint32_t OTA_BOOT_IMAGE_START = 0x000F4000UL; static const uint32_t OTA_BOOT_IMAGE_SIZE = 0x0000A000UL; // F4000..FE000, padded to exactly 40 KiB +static const uint32_t OTA_BOOT_CANDIDATE_MANIFEST_OFFSET = + OTA_BOOT_IMAGE_SIZE - OTA_BOOT_ENVELOPE_SIZE; // final 76 bytes: 0x9FB4 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; @@ -133,6 +144,13 @@ struct OtaBootloaderIdentity { uint32_t board_id = 0; char device_name[OTA_BOOT_DEVICE_NAME_SIZE + 1] = {0}; uint32_t crc32 = 0; + bool continuity_present = false; + uint32_t boot_version = 0; + uint16_t softdevice_family = 0; + uint16_t softdevice_fwid = 0; + uint32_t app_base = 0; + uint16_t layout_abi = 0; + uint16_t compat_flags = 0; }; // Accumulate a stream of already CRC-validated candidates without silently @@ -160,6 +178,12 @@ inline uint32_t ota_boot_rd32(const uint8_t* p) { ((uint32_t)p[3] << 24); } +inline bool ota_bootloader_version_valid(uint32_t version) { + // Boot builds use preview 1..254 and 0xFF for a stable release. Zero is not + // a boot release, and all-ones must not establish an un-upgradable floor. + return version != 0 && version != UINT32_MAX && (version & 0xFFu) != 0; +} + inline bool ota_xiao_bootloader_board_id(uint32_t board_id) { return board_id == OTA_XIAO_BOARD_ID_BASE || board_id == OTA_XIAO_BOARD_ID_SENSE; } @@ -246,11 +270,16 @@ inline uint32_t ota_boot_image_crc32(const uint8_t* image, size_t image_size, return ~crc; } -// Parse one candidate header. The caller independently validates the complete-region CRC so a magic -// string in a literal pool cannot establish an identity. -inline bool ota_bootloader_manifest_parse(const uint8_t* raw, uint32_t raw_offset, - OtaBootloaderIdentity& out) { - if (!raw || ota_boot_rd32(raw) != OTA_BOOT_MANIFEST_MAGIC0 || +// Parse only the legacy-v1 identity envelope. The complete-image CRC is checked +// by the caller. Identity scanners deliberately count this base record before +// interpreting an adjacent BLM2 extension: deployed legacy bootloaders do the +// same, so a CRC-valid base record with a corrupt extension must not disappear +// from duplicate-identity accounting. +inline bool ota_bootloader_manifest_base_parse(const uint8_t* raw, uint32_t raw_offset, + OtaBootloaderIdentity& out, + size_t available = OTA_BOOT_MANIFEST_SIZE) { + if (!raw || available < OTA_BOOT_MANIFEST_SIZE || + ota_boot_rd32(raw) != OTA_BOOT_MANIFEST_MAGIC0 || ota_boot_rd32(raw + 4) != OTA_BOOT_MANIFEST_MAGIC1 || ota_boot_rd16(raw + 8) != OTA_BOOT_MANIFEST_VERSION || ota_boot_rd16(raw + 10) != OTA_BOOT_MANIFEST_SIZE || @@ -271,6 +300,52 @@ inline bool ota_bootloader_manifest_parse(const uint8_t* raw, uint32_t raw_offse return true; } +// Interpret continuity metadata only after the scanner has established that +// exactly one CRC-valid base identity exists. Neither magic word means a true +// legacy-v1 record. One magic word, truncated metadata, or invalid claimed +// metadata fails closed instead of silently downgrading to legacy behavior. +inline bool ota_bootloader_continuity_parse(const uint8_t* raw, size_t available, + OtaBootloaderIdentity& out) { + out.continuity_present = false; + out.boot_version = 0; + out.softdevice_family = 0; + out.softdevice_fwid = 0; + out.app_base = 0; + out.layout_abi = 0; + out.compat_flags = 0; + if (!raw || available <= OTA_BOOT_MANIFEST_SIZE) return true; + + const bool magic0 = available >= OTA_BOOT_MANIFEST_SIZE + 4u && + ota_boot_rd32(raw + 44) == OTA_BOOT_CONTINUITY_MAGIC0; + const bool magic1 = available >= OTA_BOOT_MANIFEST_SIZE + 8u && + ota_boot_rd32(raw + 48) == OTA_BOOT_CONTINUITY_MAGIC1; + if (!magic0 && !magic1) return true; + if (available < OTA_BOOT_ENVELOPE_SIZE || !magic0 || !magic1 || + ota_boot_rd16(raw + 52) != OTA_BOOT_CONTINUITY_VERSION || + ota_boot_rd16(raw + 54) != OTA_BOOT_CONTINUITY_SIZE || + !ota_bootloader_version_valid(ota_boot_rd32(raw + 56)) || + ota_boot_rd16(raw + 60) == 0 || ota_boot_rd16(raw + 62) == 0 || + ota_boot_rd32(raw + 64) == 0 || ota_boot_rd16(raw + 68) == 0 || + ota_boot_rd16(raw + 70) != 0 || ota_boot_rd32(raw + 72) != 0) return false; + out.continuity_present = true; + out.boot_version = ota_boot_rd32(raw + 56); + out.softdevice_family = ota_boot_rd16(raw + 60); + out.softdevice_fwid = ota_boot_rd16(raw + 62); + out.app_base = ota_boot_rd32(raw + 64); + out.layout_abi = ota_boot_rd16(raw + 68); + out.compat_flags = ota_boot_rd16(raw + 70); + return true; +} + +// Convenience parser for a single already-selected envelope. Whole-image +// scanners must use the base parser for duplicate counting first. +inline bool ota_bootloader_manifest_parse(const uint8_t* raw, uint32_t raw_offset, + OtaBootloaderIdentity& out, + size_t available = OTA_BOOT_MANIFEST_SIZE) { + return ota_bootloader_manifest_base_parse(raw, raw_offset, out, available) && + ota_bootloader_continuity_parse(raw, available, out); +} + inline bool ota_bootloader_hw_id(const OtaBootloaderIdentity& identity, uint8_t out[32]) { return identity.present && identity.crc_ok && ota_bootloader_hw_id(identity.board_id, @@ -292,12 +367,30 @@ inline bool ota_bootloader_identity_from_image(const uint8_t* image, size_t imag uint8_t valid_count = 0; for (size_t offset = 0; offset + OTA_BOOT_MANIFEST_SIZE <= image_size; offset += 4) { OtaBootloaderIdentity candidate; - if (!ota_bootloader_manifest_parse(image + offset, (uint32_t)offset, candidate)) continue; + if (!ota_bootloader_manifest_base_parse(image + offset, (uint32_t)offset, candidate, + image_size - offset)) continue; candidate.crc_ok = ota_boot_image_crc32(image, image_size, offset + 40) == candidate.crc32; if (!candidate.crc_ok) continue; if (!ota_bootloader_identity_add_unique(candidate, out, valid_count)) return false; } - return valid_count == 1u; + return valid_count == 1u && + ota_bootloader_continuity_parse(image + out.manifest_offset, + image_size - out.manifest_offset, out); +} + +inline bool ota_bootloader_candidate_identity_canonical( + const OtaBootloaderIdentity& identity) { + return identity.present && identity.crc_ok && identity.continuity_present && + identity.manifest_offset == OTA_BOOT_CANDIDATE_MANIFEST_OFFSET; +} + +// New candidates place the complete v1+v2 envelope at one canonical offset. +// Installed legacy-v1 discovery above intentionally remains a whole-image +// scan for diagnostics and qualified non-SD transition paths. +inline bool ota_bootloader_candidate_identity_from_image( + const uint8_t* image, size_t image_size, OtaBootloaderIdentity& out) { + return ota_bootloader_identity_from_image(image, image_size, out) && + ota_bootloader_candidate_identity_canonical(out); } inline bool ota_bootloader_identity_matches(const OtaBootloaderIdentity& a, @@ -308,6 +401,79 @@ inline bool ota_bootloader_identity_matches(const OtaBootloaderIdentity& a, memcmp(a.device_name, b.device_name, OTA_BOOT_DEVICE_NAME_SIZE) == 0; } +enum OtaBootloaderContinuityGate : uint8_t { + OTA_BOOT_CONTINUITY_OK = 0, + OTA_BOOT_CONTINUITY_CANDIDATE_MISSING, + OTA_BOOT_CONTINUITY_OUTER_VERSION, + OTA_BOOT_CONTINUITY_PLATFORM, + OTA_BOOT_CONTINUITY_INSTALLED_PLATFORM, + OTA_BOOT_CONTINUITY_NOT_NEWER, +}; + +inline bool ota_bootloader_continuity_matches_runtime( + const OtaBootloaderIdentity& identity, + uint16_t runtime_softdevice_family, + uint16_t runtime_softdevice_fwid, + uint32_t runtime_app_base, + uint16_t runtime_layout_abi) { + return identity.continuity_present && + ota_bootloader_version_valid(identity.boot_version) && + identity.softdevice_family == runtime_softdevice_family && + identity.softdevice_fwid == runtime_softdevice_fwid && + identity.app_base == runtime_app_base && + identity.layout_abi == runtime_layout_abi && identity.compat_flags == 0; +} + +// SD OTAFIX accepts geometry only from the reset-retained authorization +// record. A legacy BLMF-v1 bootloader predates that protocol and must be +// upgraded locally; neither application nor bootloader OTA may fall back to a +// raw card sector. +inline bool ota_bootloader_sd_retained_auth_ready( + const OtaBootloaderIdentity& identity, + uint16_t runtime_softdevice_family, + uint16_t runtime_softdevice_fwid, + uint32_t runtime_app_base, + uint16_t runtime_layout_abi) { + return identity.present && identity.crc_ok && + ota_bootloader_continuity_matches_runtime( + identity, runtime_softdevice_family, runtime_softdevice_fwid, + runtime_app_base, runtime_layout_abi); +} + +// Qualified internal/QSPI paths may bootstrap a legacy-v1 installed manifest +// once because deployed builds do not carry continuity metadata. SD callers +// independently require retained-auth continuity before entering this gate. +// Every remotely supplied successor must carry the extension. Once v2 is +// installed, remote updates are strictly monotonic; rollback/migration remains +// a local DFU/SWD operation. +inline OtaBootloaderContinuityGate ota_bootloader_continuity_gate( + const OtaBootloaderIdentity& installed, + const OtaBootloaderIdentity& candidate, + uint32_t outer_fw_version, + uint16_t runtime_softdevice_family, + uint16_t runtime_softdevice_fwid, + uint32_t runtime_app_base, + uint16_t runtime_layout_abi) { + if (!candidate.continuity_present) + return OTA_BOOT_CONTINUITY_CANDIDATE_MISSING; + if (!ota_bootloader_version_valid(candidate.boot_version) || + outer_fw_version != candidate.boot_version) + return OTA_BOOT_CONTINUITY_OUTER_VERSION; + if (!ota_bootloader_continuity_matches_runtime( + candidate, runtime_softdevice_family, runtime_softdevice_fwid, + runtime_app_base, runtime_layout_abi)) + return OTA_BOOT_CONTINUITY_PLATFORM; + if (installed.continuity_present) { + if (!ota_bootloader_continuity_matches_runtime( + installed, runtime_softdevice_family, runtime_softdevice_fwid, + runtime_app_base, runtime_layout_abi)) + return OTA_BOOT_CONTINUITY_INSTALLED_PLATFORM; + if (candidate.boot_version <= installed.boot_version) + return OTA_BOOT_CONTINUITY_NOT_NEWER; + } + return OTA_BOOT_CONTINUITY_OK; +} + // Cheap first-line rejection of a malformed raw bootloader region. OTAFIX repeats this check before // touching internal flash. MSP may point one byte beyond RAM; the reset vector must be Thumb code in // the exact bootloader image range. @@ -331,12 +497,13 @@ struct OtaBootloaderCapsMarker { inline bool ota_bootloader_caps_marker_parse(const uint8_t raw[16], OtaBootloaderCapsMarker& out) { static const uint8_t magic[8] = {'M','O','T','A','B','L','D','R'}; - if (!raw || memcmp(raw, magic, sizeof(magic)) != 0 || - raw[8] == 0 || (raw[8] == 0xFF && raw[9] == 0xFF) || + if (!raw || memcmp(raw, magic, sizeof(magic)) != 0) return false; + const uint16_t apply_abi = ota_boot_rd16(raw + 8); + if (apply_abi == 0 || apply_abi == UINT16_MAX || (raw[10] == 0 && raw[11] == 0) || (raw[12] & ~OTA_BL_STORAGE_KNOWN) != 0 || raw[13] != 0 || raw[14] != 0 || raw[15] != 0) return false; out.present = true; - out.apply_abi = ota_boot_rd16(raw + 8); + out.apply_abi = apply_abi; out.codec_mask = ota_boot_rd16(raw + 10); out.storage_flags = raw[12]; return true; @@ -362,6 +529,84 @@ inline bool ota_bootloader_caps_from_image(const uint8_t* image, size_t image_si return valid_count == 1u && out.present; } +// Stream the bootloader payload through an arbitrary random-access store. Keeping +// this scanner beside the memory-image parser makes the canonical-envelope and +// duplicate-marker policy directly testable and identical for SD/QSPI staging. +template +inline bool ota_bootloader_external_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)) { + uint32_t len = OTA_BOOT_IMAGE_SIZE - off; + if (len > sizeof(buf)) len = sizeof(buf); + if (!store.read(payload_off + off, buf, len)) return false; + for (uint32_t i = 0; i < len; ++i) { + const uint32_t pos = off + i; + const uint8_t value = pos >= identity.manifest_offset + 40u && + pos < identity.manifest_offset + 44u ? 0 : buf[i]; + crc = ota_boot_crc32_update(crc, value); + } + } + return ~crc == identity.crc32; +} + +template +inline bool ota_bootloader_external_image_metadata( + Store& store, uint32_t payload_off, uint8_t exact_storage_flags, + OtaBootloaderIdentity& candidate, OtaBootloaderCapsMarker& caps) { + candidate = OtaBootloaderIdentity(); + caps = OtaBootloaderCapsMarker(); + if (exact_storage_flags == 0u) return false; + + static const uint32_t STEP = 512u; + uint8_t buf[STEP + OTA_BOOT_ENVELOPE_SIZE - 1u]; + uint8_t valid_identities = 0; + uint8_t valid_caps = 0; + for (uint32_t base = 0; base < OTA_BOOT_IMAGE_SIZE; base += STEP) { + uint32_t len = OTA_BOOT_IMAGE_SIZE - base; + if (len > sizeof(buf)) len = sizeof(buf); + if (!store.read(payload_off + base, buf, len)) return false; + const uint32_t starts = (OTA_BOOT_IMAGE_SIZE - base < STEP) + ? OTA_BOOT_IMAGE_SIZE - base : STEP; + for (uint32_t local = 0; local < starts; ++local) { + const uint32_t absolute = base + local; + if ((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.codec_mask & OTA_BL_REQUIRED_APP_CODEC_MASK) == + OTA_BL_REQUIRED_APP_CODEC_MASK && + (parsed.storage_flags & OTA_BL_STORAGE_BOOT_UPDATE) != 0u) { + if (++valid_caps != 1u) return false; + if (parsed.storage_flags == exact_storage_flags) caps = parsed; + } + } + if ((absolute & 3u) != 0u || + local + OTA_BOOT_MANIFEST_SIZE > len) continue; + OtaBootloaderIdentity parsed; + if (!ota_bootloader_manifest_base_parse( + buf + local, absolute, parsed, len - local)) continue; + if (ota_bootloader_external_crc_ok(store, payload_off, parsed)) { + parsed.crc_ok = true; + if (!ota_bootloader_identity_add_unique( + parsed, candidate, valid_identities)) return false; + } + } + } + if (valid_identities != 1u || valid_caps != 1u || !caps.present || + candidate.manifest_offset > OTA_BOOT_IMAGE_SIZE - OTA_BOOT_MANIFEST_SIZE) + return false; + const uint32_t available = OTA_BOOT_IMAGE_SIZE - candidate.manifest_offset; + uint8_t envelope[OTA_BOOT_ENVELOPE_SIZE]; + uint32_t envelope_len = available; + if (envelope_len > sizeof(envelope)) envelope_len = sizeof(envelope); + if (!store.read(payload_off + candidate.manifest_offset, envelope, envelope_len) || + !ota_bootloader_continuity_parse(envelope, envelope_len, candidate)) return false; + return ota_bootloader_candidate_identity_canonical(candidate); +} + enum OtaBootloaderConfirmGate : uint8_t { OTA_BOOT_CONFIRM_OK = 0, OTA_BOOT_CONFIRM_NOT_BOOT_PACKAGE, diff --git a/src/helpers/ota/OtaCli.cpp b/src/helpers/ota/OtaCli.cpp index 510471b2..b5d66785 100644 --- a/src/helpers/ota/OtaCli.cpp +++ b/src/helpers/ota/OtaCli.cpp @@ -109,6 +109,7 @@ static const char* fetch_error_word(OtaManager::FetchError error) { switch (error) { case OtaManager::FETCH_ERROR_MANIFEST: return "invalid manifest"; case OtaManager::FETCH_ERROR_HASH_ALGO: return "unsupported hash"; + case OtaManager::FETCH_ERROR_VERSION: return "not a newer version"; case OtaManager::FETCH_ERROR_CODEC: return "unsupported codec"; case OtaManager::FETCH_ERROR_GEOMETRY: return "invalid geometry"; case OtaManager::FETCH_ERROR_TOO_LARGE: return "image too large"; @@ -1051,8 +1052,23 @@ static bool handle_dev(const char* d, char* reply, OtaContext& c) { sprintf(reply, "OK serving | root=%d payload=%d img=%d sig=%d trust=%d", r.root_ok, r.payload_ok, r.image_ok, r.sig_ok, r.trusted); - } else if (strncmp(d, "resume", 6) == 0) { // re-adopt a container already staged in flash (test/debug) - bool ok = c.manager.resumeStaged(nullptr); + } else if (is_cmd(d, "resume", &d)) { // explicit re-adopt of a known/active staged MID (test/debug) + uint8_t mid[4]; + if (*d) { + if (!parse_hex_exact(d, mid, sizeof(mid))) { + strcpy(reply, "ERR usage: ota dev resume [mid8]"); + return true; + } + } else { + // With no argument, reuse only an active/requested session MID. After a reboot the operator must + // name the persisted MID explicitly; nullptr is reserved for policy-governed automatic adoption. + if (c.manager.fetchState() == OtaManager::IDLE) { + strcpy(reply, "ERR usage: ota dev resume "); + return true; + } + memcpy(mid, c.manager.fetchManifestId(), sizeof(mid)); + } + bool ok = c.manager.resumeStagedExplicit(mid, 0); sprintf(reply, "%s resume: sess=%c %u/%u", ok ? "OK" : "ERR", fstate_char(c.manager.fetchState()), (unsigned)c.manager.blocksHave(), (unsigned)c.manager.blocksTotal()); @@ -1125,7 +1141,7 @@ static bool handle_dev(const char* d, char* reply, OtaContext& c) { strcpy(reply, "OK cleared"); } else { - strcpy(reply, "ota dev: stage|recv|serve|announce|verify|want|apply slot|manifest|verify|commit|clear"); + strcpy(reply, "ota dev: stage|recv|serve|announce|resume [mid8]|verify|want|apply|clear"); } #endif return true; diff --git a/src/helpers/ota/OtaContext.h b/src/helpers/ota/OtaContext.h index f3f1bb09..8286c5e3 100644 --- a/src/helpers/ota/OtaContext.h +++ b/src/helpers/ota/OtaContext.h @@ -146,7 +146,13 @@ struct OtaContext { // so the deferred-reboot path (mesh loop) takes over. Caller ensures the fetch is COMPLETE. Shared by // manual `ota applydelta` and the auto-install path. bool apply_fetched(char* msg) { - return apply_fetched_impl(nullptr, msg); + return apply_fetched_impl(nullptr, false, msg); + } + + // Automatic installation has an additional anti-replay/rollback gate. Manual `ota install` remains + // the deliberate operator override for equal or older signed firmware. + bool apply_fetched_trusted_auto(char* msg) { + return apply_fetched_impl(nullptr, true, msg); } // Explicit recovery entry point for an internal-flash nRF52 whose normal EndF validation fails. This @@ -158,11 +164,11 @@ struct OtaContext { msg[95] = 0; return false; #else - return apply_fetched_impl(operator_base_hash, msg); + return apply_fetched_impl(operator_base_hash, false, msg); #endif } - bool apply_fetched_impl(const uint8_t* rescue_base_hash, char* msg) { + bool apply_fetched_impl(const uint8_t* rescue_base_hash, bool trusted_auto, char* msg) { if (fetch_to_folder) { strncpy(msg, "refused: completed fetch was captured to a folder, not local install storage", 96); msg[95] = 0; @@ -209,6 +215,21 @@ struct OtaContext { strncpy(msg, "refused: use ota bootloader install ", 96); msg[95] = 0; return false; } + if (trusted_auto) { + SelfFwInfo self; + if (!mm.is_signed()) { + strncpy(msg, "refused: trusted auto-install requires a signed package", 96); + msg[95] = 0; return false; + } + if (!ota_self_firmware(self) || !self.valid || + !ota_trusted_auto_version_allows(self.fw_version, mm.fw_version)) { + snprintf(msg, 96, + "refused: trusted auto-install is forward-only (running=%08lX candidate=%08lX); use ota install to override", + (unsigned long)(self.valid ? self.fw_version : 0), + (unsigned long)mm.fw_version); + return false; + } + } if (!hwMatches(mm.hw_id)) { char want[33] = {0}; memcpy(want, mm.hw_id, 32); snprintf(msg, 96, "refused: .mota hw_id '%.32s' != this device '%s' (incompatible hardware)", want, hw_id); @@ -501,6 +522,7 @@ struct OtaContext { target_id = 0; #endif manager.begin(target_id, send, ctx); + manager.set_auto_version_floor(_fi.valid ? _fi.fw_version : 0, true); fetch_to_folder = false; if (hw) { strncpy(hw_id, hw, sizeof(hw_id) - 1); hw_id[sizeof(hw_id) - 1] = 0; } // a node only fetches firmware it can apply: ESP32 A/B -> sequential, nRF52 single-slot -> in-place diff --git a/src/helpers/ota/OtaFormat.h b/src/helpers/ota/OtaFormat.h index d18de276..0ea22039 100644 --- a/src/helpers/ota/OtaFormat.h +++ b/src/helpers/ota/OtaFormat.h @@ -68,6 +68,12 @@ struct FwVersion { } }; +inline bool ota_trusted_auto_version_allows(uint32_t running_version, + uint32_t candidate_version) { + return running_version != 0 && candidate_version != 0 && + candidate_version > running_version; +} + // ---- hash truncations ----------------------------------------------------- static const uint8_t MH4 = 4; // sha2-256:4 (merkle leaves/nodes/root/proofs) static const uint8_t MH8 = 8; // sha2-256:8 (base/EndF body hash) diff --git a/src/helpers/ota/OtaManager.cpp b/src/helpers/ota/OtaManager.cpp index 0cd07863..64d67e89 100644 --- a/src/helpers/ota/OtaManager.cpp +++ b/src/helpers/ota/OtaManager.cpp @@ -901,7 +901,7 @@ void OtaManager::handleHave(const uint8_t* m, uint16_t n) { if (c.seeder_have[k] > c.have_max) c.have_max = c.seeder_have[k]; if (c.seeder_last_ms[k] > c.last_ms) c.last_ms = c.seeder_last_ms[k]; } - if (wantRow(mid, target, codec, flags)) startFetch(mid, target); + if (wantRow(mid, target, fwver, codec, flags)) startFetch(mid, target); } } @@ -916,7 +916,8 @@ void OtaManager::deferCatalog(const uint8_t mid[4], uint32_t until_ms) { } } -bool OtaManager::wantRow(const uint8_t* mid, uint32_t target, uint8_t codec, uint8_t flags) const { +bool OtaManager::wantRow(const uint8_t* mid, uint32_t target, uint32_t fw_version, + uint8_t codec, uint8_t flags) const { if (!_fetch || _fstate == FETCHING || _fstate == WANT_MANIFEST || _fstate == WANT_LEAVES || _fstate == VERIFYING_STAGED || _fstate == PAUSED) return false; // busy @@ -931,6 +932,8 @@ bool OtaManager::wantRow(const uint8_t* mid, uint32_t target, uint8_t codec, uin if (_autofetch == AUTOFETCH_OFF) return false; // discover only if (target != _target) return false; // auto-fetch = our own target if (_autofetch == AUTOFETCH_SIGNED && !(flags & MFLAG_SIGNED)) return false; // signed-only policy + if (_enforce_auto_version && + !ota_trusted_auto_version_allows(_running_fw_version, fw_version)) return false; return true; } @@ -1068,6 +1071,7 @@ void OtaManager::clearFetchIntent() { _desired_target = 0; _have_desired_mid = false; memset(_desired_mid, 0, sizeof(_desired_mid)); + _fexpected_target = 0; } void OtaManager::failFetch(FetchError error) { @@ -1121,11 +1125,11 @@ OtaManager::PullResult OtaManager::pull_archive(const uint8_t* mid, uint32_t tar // Begin (or resume) fetching a chosen mid: try a staged-partial resume first, else request the manifest. OtaManager::PullResult OtaManager::startFetch(const uint8_t* mid, uint32_t target, bool validate) { - (void)target; if (!mid) return PULL_BAD_MID; if (!_fetch) return PULL_NO_STORE; if (fetchActive()) return PULL_BUSY; _fetch_error = FETCH_ERROR_NONE; + _fexpected_target = target; _validate = validate; // motatool folder-capture warm-start (seed leaf-diff) // A validate pull is a FRESH seed capture, not a resume: the store already holds the seed's payload (not a // real partial), so never adopt it via resumeStaged - always re-begin and run the manifest->leaves->diff. @@ -1142,6 +1146,20 @@ OtaManager::PullResult OtaManager::startFetch(const uint8_t* mid, uint32_t targe return PULL_STARTED; } +bool OtaManager::resumeStagedExplicit(const uint8_t* want_mid, uint32_t expected_target) { + if (!want_mid || !_fetch || fetchActive()) return false; + _fetch_error = FETCH_ERROR_NONE; + _validate = false; + _archive_fetch = false; + _desired_target = expected_target; + memcpy(_desired_mid, want_mid, sizeof(_desired_mid)); + _have_desired_mid = true; + _fexpected_target = expected_target; + const bool adopted = resumeStaged(want_mid); + if (!adopted) clearFetchIntent(); + return adopted; +} + void OtaManager::handleManifest(const uint8_t* m, uint16_t n) { ManifestMsg mm; if (!decode_manifest(m, n, mm) || !_fetch) return; @@ -1174,6 +1192,24 @@ void OtaManager::handleManifest(const uint8_t* m, uint16_t n) { if (mfl != MOTA_MFL) { failFetch(FETCH_ERROR_MANIFEST); return; } // fixed 197-byte manifest MotaManifest parsed; if (!mota_parse_manifest(mf, mfl, parsed)) { failFetch(FETCH_ERROR_MANIFEST); return; } + // The OTA_MANIFEST envelope is supplied by the peer and is not an authenticated description of the + // bytes inside it. Bind both identifiers from the parsed manifest to the catalog row / explicit pull + // that opened this receive slot before the store is planned, erased, or written. Otherwise a peer can + // label an unrelated manifest with the requested MID and make us stage a different target/package. + if (memcmp(parsed.merkle_root, _fid, sizeof(_fid)) != 0 || + (_fexpected_target != 0 && parsed.target_id != _fexpected_target)) { + failFetch(FETCH_ERROR_MANIFEST); return; + } + // A HAVE row is advisory and may lie about its version. Repeat the + // forward-only check against the parsed manifest before touching storage. + const bool automatic_fetch = !_have_desired_mid && _desired_target == 0 && !_archive_fetch; + if (_enforce_auto_version && automatic_fetch && + !ota_trusted_auto_version_allows(_running_fw_version, parsed.fw_version)) { + failFetch(FETCH_ERROR_VERSION); return; + } + if (automatic_fetch && _autofetch == AUTOFETCH_SIGNED && !parsed.is_signed()) { + failFetch(FETCH_ERROR_MANIFEST); return; + } if (parsed.hash_algo != HASH_ALGO_SHA256) { failFetch(FETCH_ERROR_HASH_ALGO); return; } if (!_archive_fetch && !fetchCodecOk(parsed.codec_id, parsed.is_bootloader(), _have_desired_mid)) { @@ -1190,7 +1226,7 @@ void OtaManager::handleManifest(const uint8_t* m, uint16_t n) { if (_archive_fetch && (uint64_t)bc * 4 > OTA_PROOFGEN_SCRATCH) { failFetch(FETCH_ERROR_TOO_LARGE); return; // retaining an image we cannot seed is useless } - memcpy(_froot, mf + 20, 4); + memcpy(_froot, parsed.merkle_root, sizeof(_froot)); uint32_t leaves_off = 8 + mfl; uint32_t payload_off = leaves_off + bc * 4; @@ -1324,6 +1360,18 @@ bool OtaManager::resumeStaged(const uint8_t* want_mid) { MotaManifest m; if (!_fetch->read(8, mbuf, mread) || !mota_parse_manifest(mbuf, mread, m)) return false; if (want_mid && memcmp(m.merkle_root, want_mid, 4) != 0) return false; // a different fw is staged + const bool automatic_resume = want_mid == nullptr; + // A boot-time resume is an automatic fetch decision and therefore belongs + // only to this node. An explicit pull retains target=0 as an intentional + // MID-only wildcard instead of silently narrowing it to the local target. + const uint32_t expected_target = automatic_resume ? _target : _fexpected_target; + if (expected_target != 0 && m.target_id != expected_target) return false; + if (automatic_resume) { + if (_autofetch == AUTOFETCH_OFF) return false; + if (_autofetch == AUTOFETCH_SIGNED && !m.is_signed()) return false; + if (_enforce_auto_version && + !ota_trusted_auto_version_allows(_running_fw_version, m.fw_version)) return false; + } if (m.hash_algo != HASH_ALGO_SHA256) return false; // A bootloader partial is resumed only as part of the same explicit MID pull. // Boot-time resumeStaged(nullptr) must not silently re-adopt privileged data. diff --git a/src/helpers/ota/OtaManager.h b/src/helpers/ota/OtaManager.h index d27947b6..48ff1797 100644 --- a/src/helpers/ota/OtaManager.h +++ b/src/helpers/ota/OtaManager.h @@ -221,6 +221,7 @@ public: FETCH_ERROR_NONE, FETCH_ERROR_MANIFEST, FETCH_ERROR_HASH_ALGO, + FETCH_ERROR_VERSION, FETCH_ERROR_CODEC, FETCH_ERROR_GEOMETRY, FETCH_ERROR_TOO_LARGE, @@ -302,13 +303,19 @@ public: // --- fetch --- Provide the staging store; fetching starts on a matching OTA_ADV. void set_fetch_store(OtaStore* s) { _fetch = s; } - // Resume a fetch from a container already persisted in the store (after a reboot). want_mid=nullptr - // accepts whatever is staged; otherwise only resumes if the staged manifest_id matches. Re-parses the - // stored manifest, recomputes geometry, then incrementally rehashes every staged payload block before - // continuing FETCHING the holes. A fully staged image also has to reproduce the manifest Merkle root - // before it can become COMPLETE. Returns true if it adopted a staged container. + // Resume a fetch from a container already persisted in the store (after a reboot). want_mid=nullptr is + // automatic adoption and rechecks current autofetch/target/version policy; a non-null MID is an explicit + // resume and must match exactly. Re-parses the stored manifest, recomputes geometry, then incrementally + // rehashes every staged payload block before continuing FETCHING the holes. A fully staged image also has + // to reproduce the manifest Merkle root before it can become COMPLETE. Returns true if it adopted one. bool resumeStaged(const uint8_t* want_mid); + // Explicit operator/debug re-adoption of one known MID without starting a network fetch. This installs + // the same manual intent used by pull(), so target=0 remains a deliberate wildcard and bootloader FULL + // containers are eligible for the separately authenticated manual path. Unlike resumeStaged(nullptr), + // this never applies boot-time autofetch/version policy. + bool resumeStagedExplicit(const uint8_t* want_mid, uint32_t expected_target = 0); + // Manual cross-target override (decision: deliberate role switch, e.g. companion -> repeater on the // same hardware). Normally a node only auto-fetches its OWN target_id; `want(T)` makes it accept an // ADV for target T instead (T=0 restores auto). The user takes responsibility for HW compatibility; @@ -377,6 +384,10 @@ public: static const uint8_t AUTOFETCH_OFF = 0, AUTOFETCH_ANY = 1, AUTOFETCH_SIGNED = 2; void set_autofetch(uint8_t p) { _autofetch = p; reDiscover(); } uint8_t autofetch() const { return _autofetch; } + void set_auto_version_floor(uint32_t running_version, bool enforce = true) { + _running_fw_version = running_version; + _enforce_auto_version = enforce; + } // A persistent archive needs full catalogs even when install-oriented autofetch is off. void set_archive_interest(bool on) { if (_archive_interest != on) { _archive_interest = on; reDiscover(); } @@ -488,7 +499,8 @@ private: bool handleReqProof(const uint8_t* m, uint16_t n); bool handleProof(const uint8_t* m, uint16_t n); PullResult startFetch(const uint8_t* mid, uint32_t target, bool validate = false); // begin/resume a fetch - bool wantRow(const uint8_t* mid, uint32_t target, uint8_t codec, uint8_t flags) const; // fetch this row? + bool wantRow(const uint8_t* mid, uint32_t target, uint32_t fw_version, + uint8_t codec, uint8_t flags) const; // fetch this row? bool fetchActive() const; void clearFetchIntent(); void failFetch(FetchError error); @@ -599,6 +611,7 @@ private: FetchError _fetch_error = FETCH_ERROR_NONE; uint8_t _fid[4] = {0}; uint8_t _froot[4] = {0}; + uint32_t _fexpected_target = 0; // catalog/CLI target bound to the requested manifest uint32_t _ftotal = 0, _fpoff = 0, _floff = 0, _fpsize = 0, _fbc = 0, _fbs = 0; uint32_t _have = 0; uint32_t _resume_verify_idx = 0; @@ -616,6 +629,8 @@ private: bool _archive_interest = false; // query full catalogs for the persistent archive uint8_t _seeder_id[4] = {0,0,0,0}; // our node id (pubkey[0:4]) for advert seeder counting uint8_t _autofetch = AUTOFETCH_OFF; // auto-fetch policy (persisted in NodePrefs) + uint32_t _running_fw_version = 0; // trusted EndF version used only for automatic admission + bool _enforce_auto_version = false; // OtaContext enables this even when EndF/version is unknown uint16_t _checkpoint_blocks = OTA_CHECKPOINT_BLOCKS; // resume checkpoint cadence (persisted) uint16_t _advert_mins = OTA_ADVERT_INTERVAL_MINS; // beacon re-advertise cadence, minutes; 0=off (persisted) uint8_t _max_hops = OTA_HOP_LIMIT_DEFAULT; // OTA flood reach in hops; 0=direct only (persisted) diff --git a/src/helpers/ota/OtaSdAuthRecord.h b/src/helpers/ota/OtaSdAuthRecord.h new file mode 100644 index 00000000..5190fe5a --- /dev/null +++ b/src/helpers/ota/OtaSdAuthRecord.h @@ -0,0 +1,112 @@ +#pragma once + +#include +#include +#include + +// Reset-retained authorization shared byte-for-byte with OTAFIX. This record is intentionally in MCU RAM, +// not on removable media: the application publishes it only after authenticating/verifying the exact SD +// container, and OTAFIX consumes it before trusting any card geometry or bytes. A power cycle erases it. +namespace mesh { +namespace ota { + +static const uintptr_t MOTA_SD_AUTH_ADDR = 0x20006008u; +static const uint32_t MOTA_SD_AUTH_SECTOR_SIZE = 512u; +static const uint16_t MOTA_SD_AUTH_VERSION = 2u; +static const uint16_t MOTA_SD_AUTH_LEN = 72u; +static const uint8_t MOTA_SD_AUTH_PURPOSE_APP = 1u; +static const uint8_t MOTA_SD_AUTH_PURPOSE_BOOTLOADER = 2u; +static const uint8_t MOTA_SD_AUTH_MAGIC[8] = { + 'M', 'O', 'T', 'A', 'S', 'D', 'A', '2' +}; + +inline uint16_t mota_sd_auth_rd16(const uint8_t* p) { + return (uint16_t)p[0] | ((uint16_t)p[1] << 8); +} + +inline uint32_t mota_sd_auth_rd32(const uint8_t* p) { + return (uint32_t)p[0] | ((uint32_t)p[1] << 8) | + ((uint32_t)p[2] << 16) | ((uint32_t)p[3] << 24); +} + +inline void mota_sd_auth_wr16(uint8_t* p, uint16_t value) { + p[0] = (uint8_t)value; + p[1] = (uint8_t)(value >> 8); +} + +inline void mota_sd_auth_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); +} + +inline uint32_t mota_sd_auth_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; +} + +inline bool mota_sd_auth_geometry_valid(uint32_t first_lba, uint32_t sector_count, + uint32_t container_total, uint32_t card_sectors) { + const uint64_t needed = ((uint64_t)container_total + MOTA_SD_AUTH_SECTOR_SIZE - 1u) / + MOTA_SD_AUTH_SECTOR_SIZE; + return first_lba != 0 && sector_count != 0 && container_total != 0 && + card_sectors != 0 && needed == sector_count && + first_lba < card_sectors && sector_count <= card_sectors - first_lba; +} + +inline bool mota_sd_auth_encode(uint8_t out[MOTA_SD_AUTH_LEN], uint8_t purpose, + uint8_t format, uint32_t first_lba, + uint32_t sector_count, uint32_t container_total, + uint32_t card_sectors, + const uint8_t container_sha256[32]) { + if (!out || !container_sha256 || + (purpose != MOTA_SD_AUTH_PURPOSE_APP && + purpose != MOTA_SD_AUTH_PURPOSE_BOOTLOADER) || + ((purpose == MOTA_SD_AUTH_PURPOSE_APP && format != 2u) || + (purpose == MOTA_SD_AUTH_PURPOSE_BOOTLOADER && format != 3u)) || + !mota_sd_auth_geometry_valid(first_lba, sector_count, container_total, card_sectors)) + return false; + memset(out, 0, MOTA_SD_AUTH_LEN); + memcpy(out, MOTA_SD_AUTH_MAGIC, sizeof(MOTA_SD_AUTH_MAGIC)); + mota_sd_auth_wr16(out + 8u, MOTA_SD_AUTH_VERSION); + mota_sd_auth_wr16(out + 10u, MOTA_SD_AUTH_LEN); + out[12] = purpose; + out[13] = format; + mota_sd_auth_wr32(out + 16u, first_lba); + mota_sd_auth_wr32(out + 20u, sector_count); + mota_sd_auth_wr32(out + 24u, container_total); + mota_sd_auth_wr32(out + 28u, card_sectors); + memcpy(out + 32u, container_sha256, 32u); + const uint32_t crc = mota_sd_auth_crc32(out, 64u); + mota_sd_auth_wr32(out + 64u, crc); + mota_sd_auth_wr32(out + 68u, ~crc); + return true; +} + +inline bool mota_sd_auth_valid(const uint8_t record[MOTA_SD_AUTH_LEN]) { + if (!record || memcmp(record, MOTA_SD_AUTH_MAGIC, sizeof(MOTA_SD_AUTH_MAGIC)) != 0 || + mota_sd_auth_rd16(record + 8u) != MOTA_SD_AUTH_VERSION || + mota_sd_auth_rd16(record + 10u) != MOTA_SD_AUTH_LEN || + mota_sd_auth_rd16(record + 14u) != 0 || + ((record[12] == MOTA_SD_AUTH_PURPOSE_APP && record[13] != 2u) || + (record[12] == MOTA_SD_AUTH_PURPOSE_BOOTLOADER && record[13] != 3u) || + (record[12] != MOTA_SD_AUTH_PURPOSE_APP && + record[12] != MOTA_SD_AUTH_PURPOSE_BOOTLOADER))) + return false; + const uint32_t crc = mota_sd_auth_rd32(record + 64u); + return mota_sd_auth_geometry_valid(mota_sd_auth_rd32(record + 16u), + mota_sd_auth_rd32(record + 20u), + mota_sd_auth_rd32(record + 24u), + mota_sd_auth_rd32(record + 28u)) && + mota_sd_auth_crc32(record, 64u) == crc && + mota_sd_auth_rd32(record + 68u) == ~crc; +} + +} // namespace ota +} // namespace mesh diff --git a/src/helpers/ota/OtaSdHandoff.h b/src/helpers/ota/OtaSdHandoff.h deleted file mode 100644 index 38a51840..00000000 --- a/src/helpers/ota/OtaSdHandoff.h +++ /dev/null @@ -1,64 +0,0 @@ -#pragma once - -#include -#include - -// On-card handoff shared with Adafruit_nRF52_Bootloader_OTAFIX. The staged -// .mota is a normal, contiguous filesystem file. Sector 1 is in the unused -// gap between the MBR and the first partition and tells the bootloader where -// that file's sectors live. Cards without such a gap are rejected. - -namespace mesh { -namespace ota { - -static const uint32_t MOTA_SD_SECTOR_SIZE = 512u; -static const uint32_t MOTA_SD_HANDOFF_SECTOR = 1u; -static const uint32_t MOTA_SD_HANDOFF_VERSION = 1u; -static const uint32_t MOTA_SD_HANDOFF_LEN = 36u; -static const uint8_t MOTA_SD_HANDOFF_MAGIC[8] = { - 'M', 'O', 'T', 'A', 'S', 'D', '0', '1' -}; - -inline uint32_t mota_sd_rd32(const uint8_t* p) { - return (uint32_t)p[0] | ((uint32_t)p[1] << 8) | - ((uint32_t)p[2] << 16) | ((uint32_t)p[3] << 24); -} - -inline void mota_sd_wr32(uint8_t* p, uint32_t v) { - p[0] = (uint8_t)v; - p[1] = (uint8_t)(v >> 8); - p[2] = (uint8_t)(v >> 16); - p[3] = (uint8_t)(v >> 24); -} - -inline uint32_t mota_sd_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; -} - -inline void mota_sd_encode_handoff(uint8_t sector[MOTA_SD_SECTOR_SIZE], - uint32_t first_sector, - uint32_t sector_count, - uint32_t total_size, - uint32_t card_sectors) { - // Only own the record bytes. The caller preserves the rest of sector 1 in - // case a card formatter placed non-partition metadata there. - for (uint32_t i = 0; i < MOTA_SD_HANDOFF_LEN; i++) sector[i] = 0xFF; - for (uint8_t i = 0; i < 8; i++) sector[i] = MOTA_SD_HANDOFF_MAGIC[i]; - mota_sd_wr32(sector + 8, MOTA_SD_HANDOFF_VERSION); - mota_sd_wr32(sector + 12, first_sector); - mota_sd_wr32(sector + 16, sector_count); - mota_sd_wr32(sector + 20, total_size); - mota_sd_wr32(sector + 24, ~total_size); - mota_sd_wr32(sector + 28, card_sectors); - mota_sd_wr32(sector + 32, mota_sd_crc32(sector, 32)); -} - -} // namespace ota -} // namespace mesh diff --git a/src/helpers/ota/OtaStoreSdNrf52.cpp b/src/helpers/ota/OtaStoreSdNrf52.cpp index d4b27605..2da36c91 100644 --- a/src/helpers/ota/OtaStoreSdNrf52.cpp +++ b/src/helpers/ota/OtaStoreSdNrf52.cpp @@ -12,8 +12,8 @@ #include "OtaBootloaderUpdate.h" #include "OtaByteIO.h" #include "OtaFlashLayout_nrf52.h" +#include "OtaSdAuthRecord.h" #include "OtaSdBootToken.h" -#include "OtaSdHandoff.h" #include "OtaSelf.h" #include "flash/flash_nrf5x.h" @@ -27,10 +27,45 @@ namespace mesh { namespace ota { +namespace { + +static void clear_sd_auth_record() { + volatile uint8_t* const retained = + reinterpret_cast(MOTA_SD_AUTH_ADDR); + for (uint32_t i = 0; i < MOTA_SD_AUTH_LEN; ++i) retained[i] = 0; + __DMB(); + __DSB(); +} + +static bool publish_sd_auth_record(const uint8_t record[MOTA_SD_AUTH_LEN]) { + volatile uint8_t* const retained = + reinterpret_cast(MOTA_SD_AUTH_ADDR); + // Invalidate magic first, then publish the body and magic last. A reset at any + // intermediate instruction therefore leaves a record OTAFIX rejects. + for (uint32_t i = 0; i < sizeof(MOTA_SD_AUTH_MAGIC); ++i) retained[i] = 0; + __DMB(); + for (uint32_t i = sizeof(MOTA_SD_AUTH_MAGIC); i < MOTA_SD_AUTH_LEN; ++i) + retained[i] = record[i]; + __DMB(); + for (uint32_t i = 0; i < sizeof(MOTA_SD_AUTH_MAGIC); ++i) retained[i] = record[i]; + __DMB(); + __DSB(); + for (uint32_t i = 0; i < MOTA_SD_AUTH_LEN; ++i) + if (retained[i] != record[i]) return false; + return true; +} + +} // namespace + const char* const OtaStoreSdNrf52::PATH = "/meshcore-ota.mota"; OtaStoreSdNrf52::OtaStoreSdNrf52() - : _sd(new (std::nothrow) SdFs()), _file(new (std::nothrow) FsFile()) {} + : _sd(new (std::nothrow) SdFs()), _file(new (std::nothrow) FsFile()) { + // If an earlier reset did not enter OTAFIX, stale authorization must not arm a + // later unrelated reboot. OTAFIX runs before application constructors and + // consumes a valid record on the intended apply boot. + clear_sd_auth_record(); +} OtaStoreSdNrf52::~OtaStoreSdNrf52() { if (_file) { _file->close(); delete _file; } @@ -46,8 +81,6 @@ void OtaStoreSdNrf52::resetStoreState() { _total = 0; _first_sector = 0; _allocated_sectors = 0; - _partition_start = 0; - _partition_end = 0; _planned_bootloader = false; if (_file && *_file) _file->close(); if (_sd) _sd->end(); @@ -75,15 +108,11 @@ bool OtaStoreSdNrf52::mount() { return false; } _mounted = true; - if (!inspect_mbr()) { - _sd->end(); - _mounted = false; - return false; - } return true; } bool OtaStoreSdNrf52::formatCard(MainBoard& board) { + clear_sd_auth_record(); if (!beginCardOnly()) return false; board.serviceWatchdog(); @@ -100,22 +129,12 @@ bool OtaStoreSdNrf52::formatCard(MainBoard& board) { } _mounted = true; - if (!inspect_mbr()) { - _sd->end(); - _mounted = false; - return false; - } - if (!invalidate_handoff()) { - fail("SD handoff clear failed"); - _sd->end(); - _mounted = false; - return false; - } board.serviceWatchdog(); return true; } bool OtaStoreSdNrf52::eraseCard(MainBoard& board) { + clear_sd_auth_record(); if (!beginCardOnly()) return false; SdCard* card = _sd->card(); @@ -312,58 +331,18 @@ bool OtaStoreSdNrf52::listFiles(MainBoard& board, uint16_t page, return true; } -bool OtaStoreSdNrf52::inspect_mbr() { - uint8_t sector[MOTA_SD_SECTOR_SIZE]; - if (!_sd->card() || !_sd->card()->readSector(0, sector)) { - fail("SD MBR read failed"); - return false; - } - if (sector[510] != 0x55 || sector[511] != 0xAA) { - fail("SD must use an MBR partition table"); - return false; - } - - // SdFs mounts the first usable MBR partition. Require its start to leave - // sector 1 unused; a protective GPT entry (type 0xEE) is not safe here. - for (uint8_t i = 0; i < 4; i++) { - const uint8_t* p = sector + 446 + (uint32_t)i * 16; - uint8_t type = p[4]; - uint32_t start = mota_sd_rd32(p + 8); - uint32_t count = mota_sd_rd32(p + 12); - if (type == 0 || count == 0) continue; - if (type == 0xEE || start <= MOTA_SD_HANDOFF_SECTOR || - start >= _sd->card()->sectorCount() || - count > _sd->card()->sectorCount() - start) { - fail("SD needs an MBR partition starting after sector 1"); - return false; - } - _partition_start = start; - _partition_end = start + count; - return true; - } - fail("SD has no usable MBR partition"); - return false; -} - -bool OtaStoreSdNrf52::invalidate_handoff() { - if (!_mounted || !_sd->card()) return false; - uint8_t sector[MOTA_SD_SECTOR_SIZE]; - if (!_sd->card()->readSector(MOTA_SD_HANDOFF_SECTOR, sector)) return false; - memset(sector, 0xFF, MOTA_SD_HANDOFF_LEN); - return _sd->card()->writeSector(MOTA_SD_HANDOFF_SECTOR, sector) && - _sd->card()->syncDevice(); -} - bool OtaStoreSdNrf52::locate_file() { Sector_t first = 0, last = 0; if (!_file || !_file->contiguousRange(&first, &last) || last < first) { fail("OTA file is not contiguous"); return false; } - uint64_t need = ((uint64_t)_total + MOTA_SD_SECTOR_SIZE - 1) / MOTA_SD_SECTOR_SIZE; + uint64_t need = ((uint64_t)_total + MOTA_SD_AUTH_SECTOR_SIZE - 1) / + MOTA_SD_AUTH_SECTOR_SIZE; uint64_t available = (uint64_t)last - first + 1; - if (need == 0 || need > available || first < _partition_start || - (uint64_t)first + need > _partition_end) { + const uint32_t card_sectors = _sd->card() ? _sd->card()->sectorCount() : 0; + if (need == 0 || need > available || first == 0 || card_sectors == 0 || + first >= card_sectors || need > card_sectors - first) { fail("OTA file sector range is invalid"); return false; } @@ -401,11 +380,8 @@ bool OtaStoreSdNrf52::begin(uint32_t total_size) { _total = 0; _first_sector = 0; _allocated_sectors = 0; + clear_sd_auth_record(); if (total_size < 13 || !mount()) return false; - if (!invalidate_handoff()) { - fail("SD handoff clear failed"); - return false; - } if (*_file) _file->close(); _sd->remove(PATH); if (!_file->open(PATH, O_RDWR | O_CREAT | O_EXCL)) { @@ -460,12 +436,13 @@ bool OtaStoreSdNrf52::read(uint32_t offset, uint8_t* buf, uint32_t len) const { uint32_t OtaStoreSdNrf52::capacity() const { if (!_mounted || !_sd || !_sd->card()) return 0; - uint64_t bytes = (uint64_t)_sd->card()->sectorCount() * MOTA_SD_SECTOR_SIZE; + uint64_t bytes = (uint64_t)_sd->card()->sectorCount() * MOTA_SD_AUTH_SECTOR_SIZE; return bytes > UINT32_MAX ? UINT32_MAX : (uint32_t)bytes; } bool OtaStoreSdNrf52::finalize() { - if (!_file || !*_file || !_file->sync() || !_sd->card()->syncDevice()) { + SdCard* const card = _sd ? _sd->card() : nullptr; + if (!_file || !*_file || !card || !_file->sync() || !card->syncDevice()) { fail("SD OTA sync failed"); return false; } @@ -537,15 +514,22 @@ void OtaStoreSdNrf52::clear() { _planned_bootloader = false; _first_sector = 0; _allocated_sectors = 0; + clear_sd_auth_record(); if (!mount()) return; - invalidate_handoff(); if (_file && *_file) _file->close(); _sd->remove(PATH); } bool OtaStoreSdNrf52::approve_for_bootloader( - const uint8_t expected_boot_image_hash[32]) { + const uint8_t expected_boot_image_hash[32], + const uint8_t authorized_container_hash[32]) { if (!_total || !finalize()) return false; + if (!authorized_container_hash) { + fail("missing authenticated SD container hash"); + return false; + } + SdCard* const card = _sd ? _sd->card() : nullptr; + const uint32_t card_sectors = card ? card->sectorCount() : 0; #if defined(OTA_SD_BOOTLOADER_UPDATE) if (_planned_bootloader) { SelfFwInfo fi; @@ -563,7 +547,7 @@ bool OtaStoreSdNrf52::approve_for_bootloader( } #endif if (!write(8 + MOTA_OFF_APPROVAL, APPROVAL_YES, sizeof(APPROVAL_YES)) || - !_file->sync() || !_sd->card()->syncDevice()) { + !_file->sync() || !card || !card->syncDevice()) { fail("SD approval write failed"); return false; } @@ -591,16 +575,16 @@ bool OtaStoreSdNrf52::approve_for_bootloader( } #endif - uint8_t sector[MOTA_SD_SECTOR_SIZE]; - if (!_sd->card()->readSector(MOTA_SD_HANDOFF_SECTOR, sector)) { - fail("SD handoff sector read failed"); - return false; - } - mota_sd_encode_handoff(sector, _first_sector, _allocated_sectors, - _total, _sd->card()->sectorCount()); - if (!_sd->card()->writeSector(MOTA_SD_HANDOFF_SECTOR, sector) || - !_sd->card()->syncDevice()) { - fail("SD bootloader handoff write failed"); + uint8_t auth[MOTA_SD_AUTH_LEN]; + const uint8_t purpose = _planned_bootloader + ? MOTA_SD_AUTH_PURPOSE_BOOTLOADER : MOTA_SD_AUTH_PURPOSE_APP; + const uint8_t format = _planned_bootloader ? MOTA_BOOT_FORMAT_VER : MOTA_APP_FORMAT_VER; + if (!mota_sd_auth_encode(auth, purpose, format, _first_sector, + _allocated_sectors, _total, card_sectors, + authorized_container_hash) || + !publish_sd_auth_record(auth)) { + clear_sd_auth_record(); + fail("SD retained authorization publish failed"); return false; } return true; diff --git a/src/helpers/ota/OtaStoreSdNrf52.h b/src/helpers/ota/OtaStoreSdNrf52.h index 6662188b..d73239fe 100644 --- a/src/helpers/ota/OtaStoreSdNrf52.h +++ b/src/helpers/ota/OtaStoreSdNrf52.h @@ -40,12 +40,13 @@ public: // 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. + // Every package writes APRV, then publishes a reset-retained authorization + // record containing geometry plus the normalized SHA-256 produced by the + // same verification pass. Bootloader packages additionally bind the exact + // authenticated signed image_hash to the E0000 scratch token. bool approve_for_bootloader( - const uint8_t expected_boot_image_hash[32] = nullptr); + const uint8_t expected_boot_image_hash[32], + const uint8_t authorized_container_hash[32]); bool formatCard(MainBoard& board); bool eraseCard(MainBoard& board); bool getSpace(MainBoard& board, uint64_t& used_bytes, uint64_t& free_bytes); @@ -58,9 +59,7 @@ private: bool mount(); bool beginCardOnly(); - bool inspect_mbr(); bool locate_file(); - bool invalidate_handoff(); void resetStoreState(); void fail(const char* message); @@ -71,8 +70,6 @@ private: uint32_t _total = 0; uint32_t _first_sector = 0; uint32_t _allocated_sectors = 0; - uint32_t _partition_start = 0; - uint32_t _partition_end = 0; char _error[80] = {0}; }; diff --git a/src/helpers/ota/OtaVerify.cpp b/src/helpers/ota/OtaVerify.cpp index 358410e2..6cda29ab 100644 --- a/src/helpers/ota/OtaVerify.cpp +++ b/src/helpers/ota/OtaVerify.cpp @@ -60,6 +60,14 @@ static VerifyResult ota_verify_store(const OtaStore& store, } r.parsed = true; + // Bind the removable-media authorization to the exact stream verified below. Approval is the one mutable + // field, so normalize its four bytes to zero: the digest remains stable after APRV is written and synced. + SHA256 container_sha; + static const uint8_t zero_approval[4] = {0, 0, 0, 0}; + container_sha.update(hdr, sizeof(hdr)); + container_sha.update(manifest, MOTA_OFF_APPROVAL); + container_sha.update(zero_approval, sizeof(zero_approval)); + container_sha.update(leaves, leaves_len); uint8_t root[4]; merkle_root(root, leaves, m.block_count); r.root_ok = memcmp(root, m.merkle_root, sizeof(root)) == 0; @@ -81,6 +89,7 @@ static VerifyResult ota_verify_store(const OtaStore& store, break; } if (m.is_full()) image_sha.update(block, n); + container_sha.update(block, n); off += n; } if (off != m.payload_size) r.payload_ok = false; @@ -92,6 +101,12 @@ static VerifyResult ota_verify_store(const OtaStore& store, r.image_ok = !m.is_full(); } + if (r.root_ok && r.payload_ok && r.image_ok) { + container_sha.update(trailer, sizeof(trailer)); + container_sha.finalize(r.container_hash, sizeof(r.container_hash)); + r.container_hash_ok = true; + } + r.is_signed = m.is_signed(); if (r.is_signed) { mesh::Identity signer(m.signer_pubkey); diff --git a/src/helpers/ota/OtaVerify.h b/src/helpers/ota/OtaVerify.h index 397a1923..99061db3 100644 --- a/src/helpers/ota/OtaVerify.h +++ b/src/helpers/ota/OtaVerify.h @@ -18,6 +18,8 @@ struct VerifyResult { bool is_signed = false; bool sig_ok = false; // Ed25519 signature valid for signer_pubkey bool trusted = false; // signer_pubkey is in the allowlist + bool container_hash_ok = false; // normalized full-container SHA was produced from the verified stream + uint8_t container_hash[32] = {0}; // SHA256(container with approval bytes normalized to zero) // Integrity holds (safe to keep/serve). For a signed image, the signature must also verify. bool integrity_ok() const { return parsed && root_ok && payload_ok && image_ok && (!is_signed || sig_ok); } diff --git a/test/test_ota/test_ota_core.cpp b/test/test_ota/test_ota_core.cpp index 30a8b7a8..1b99992b 100644 --- a/test/test_ota/test_ota_core.cpp +++ b/test/test_ota/test_ota_core.cpp @@ -18,6 +18,7 @@ #include "helpers/ota/OtaBlInfo.h" #include "helpers/ota/OtaFlashLayout_nrf52.h" #include "helpers/ota/OtaSdBootToken.h" +#include "helpers/ota/OtaSdAuthRecord.h" #include "mota_vectors.h" // auto-generated by tools/mota/gen_vectors.py extern "C" { @@ -50,6 +51,14 @@ TEST(OtaBootResult, AcceptsOnlyOtafixApplyDiagnostics) { EXPECT_EQ(ota_nrf52_boot_result_or_zero(0xBD), 0x00); } +TEST(OtaPolicy, TrustedAutoInstallIsStrictlyForwardOnly) { + EXPECT_FALSE(ota_trusted_auto_version_allows(0, 0x01170104u)); + EXPECT_FALSE(ota_trusted_auto_version_allows(0x01170103u, 0)); + EXPECT_FALSE(ota_trusted_auto_version_allows(0x01170103u, 0x01170102u)); + EXPECT_FALSE(ota_trusted_auto_version_allows(0x01170103u, 0x01170103u)); + EXPECT_TRUE(ota_trusted_auto_version_allows(0x01170103u, 0x01170104u)); +} + TEST(OtaBootPackage, StorageProfilesAndSdGeometryAreExact) { EXPECT_EQ(OTA_BL_PROFILE_SD_BOOT_UPDATE, 0x09u); EXPECT_EQ(OTA_BL_PROFILE_INTERNAL_BOOT_UPDATE, 0x0Au); @@ -89,6 +98,251 @@ TEST(OtaBootPackage, StorageProfilesAndSdGeometryAreExact) { 0x00FFu, 0u, 0u, live_size, safe_span)); } +TEST(OtaSdAuth, RetainedRecordBindsPurposeGeometryAndContainerDigest) { + uint8_t digest[32]; + for (uint8_t i = 0; i < sizeof(digest); ++i) digest[i] = (uint8_t)(0x40u + i); + uint8_t record[MOTA_SD_AUTH_LEN]; + const uint32_t total = 41330u; + const uint32_t sectors = (total + 511u) / 512u; + ASSERT_TRUE(mota_sd_auth_encode(record, MOTA_SD_AUTH_PURPOSE_BOOTLOADER, + MOTA_BOOT_FORMAT_VER, 2048u, sectors, + total, 8000000u, digest)); + EXPECT_EQ(0, memcmp(record, "MOTASDA2", 8)); + EXPECT_EQ(mota_sd_auth_rd16(record + 8), 2u); + EXPECT_EQ(mota_sd_auth_rd16(record + 10), 72u); + EXPECT_EQ(record[12], MOTA_SD_AUTH_PURPOSE_BOOTLOADER); + EXPECT_EQ(record[13], MOTA_BOOT_FORMAT_VER); + EXPECT_EQ(mota_sd_auth_rd32(record + 16), 2048u); + EXPECT_EQ(mota_sd_auth_rd32(record + 20), sectors); + EXPECT_EQ(mota_sd_auth_rd32(record + 24), total); + EXPECT_EQ(mota_sd_auth_rd32(record + 28), 8000000u); + EXPECT_EQ(0, memcmp(record + 32, digest, sizeof(digest))); + EXPECT_TRUE(mota_sd_auth_valid(record)); + + record[32] ^= 1u; + EXPECT_FALSE(mota_sd_auth_valid(record)); + record[32] ^= 1u; + EXPECT_FALSE(mota_sd_auth_encode(record, MOTA_SD_AUTH_PURPOSE_APP, + MOTA_BOOT_FORMAT_VER, 2048u, sectors, + total, 8000000u, digest)); + EXPECT_FALSE(mota_sd_auth_encode(record, MOTA_SD_AUTH_PURPOSE_APP, + MOTA_APP_FORMAT_VER, 2048u, sectors + 1u, + total, 8000000u, digest)); +} + +TEST(OtaBootPackage, ContinuityExtensionIsBackwardCompatibleAndStrictlyMonotonic) { + uint8_t envelope[OTA_BOOT_ENVELOPE_SIZE] = {0}; + wr_u32le(envelope, OTA_BOOT_MANIFEST_MAGIC0); + wr_u32le(envelope + 4, OTA_BOOT_MANIFEST_MAGIC1); + envelope[8] = (uint8_t)OTA_BOOT_MANIFEST_VERSION; + envelope[10] = (uint8_t)OTA_BOOT_MANIFEST_SIZE; + wr_u32le(envelope + 12, OTA_BOOT_IMAGE_START); + wr_u32le(envelope + 16, OTA_BOOT_IMAGE_SIZE); + wr_u32le(envelope + 20, 0x239A0071u); + memcpy(envelope + 24, "TOWER_V2_OTA", 12); + wr_u32le(envelope + 44, OTA_BOOT_CONTINUITY_MAGIC0); + wr_u32le(envelope + 48, OTA_BOOT_CONTINUITY_MAGIC1); + envelope[52] = (uint8_t)OTA_BOOT_CONTINUITY_VERSION; + envelope[54] = (uint8_t)OTA_BOOT_CONTINUITY_SIZE; + const uint32_t candidate_version = 0x0117010Du; + wr_u32le(envelope + 56, candidate_version); + envelope[60] = (uint8_t)OTA_BOOT_CONTINUITY_FAMILY_S140; + envelope[62] = 0xB6; + wr_u32le(envelope + 64, MOTA_NRF52_APP_BASE_S140_V6); + envelope[68] = (uint8_t)OTA_BOOT_CONTINUITY_LAYOUT_ABI; + + OtaBootloaderIdentity candidate; + ASSERT_TRUE(ota_bootloader_manifest_parse(envelope, 0x100u, candidate, + sizeof(envelope))); + ASSERT_TRUE(candidate.continuity_present); + EXPECT_EQ(candidate.boot_version, candidate_version); + EXPECT_EQ(candidate.softdevice_family, 140u); + EXPECT_EQ(candidate.softdevice_fwid, 0x00B6u); + EXPECT_EQ(candidate.app_base, MOTA_NRF52_APP_BASE_S140_V6); + + OtaBootloaderIdentity legacy = candidate; + legacy.continuity_present = false; + legacy.crc_ok = true; + EXPECT_FALSE(ota_bootloader_sd_retained_auth_ready( + legacy, 140u, 0x00B6u, MOTA_NRF52_APP_BASE_S140_V6, 1u)); + candidate.crc_ok = true; + EXPECT_TRUE(ota_bootloader_sd_retained_auth_ready( + candidate, 140u, 0x00B6u, MOTA_NRF52_APP_BASE_S140_V6, 1u)); + EXPECT_EQ(ota_bootloader_continuity_gate( + legacy, candidate, candidate_version, 140u, 0x00B6u, + MOTA_NRF52_APP_BASE_S140_V6, 1u), + OTA_BOOT_CONTINUITY_OK); // one-time v1 bootstrap + EXPECT_EQ(ota_bootloader_continuity_gate( + legacy, candidate, candidate_version + 1u, 140u, 0x00B6u, + MOTA_NRF52_APP_BASE_S140_V6, 1u), + OTA_BOOT_CONTINUITY_OUTER_VERSION); + + OtaBootloaderIdentity installed = candidate; + installed.boot_version = candidate_version - 1u; + EXPECT_EQ(ota_bootloader_continuity_gate( + installed, candidate, candidate_version, 140u, 0x00B6u, + MOTA_NRF52_APP_BASE_S140_V6, 1u), + OTA_BOOT_CONTINUITY_OK); + installed.boot_version = candidate_version; + EXPECT_EQ(ota_bootloader_continuity_gate( + installed, candidate, candidate_version, 140u, 0x00B6u, + MOTA_NRF52_APP_BASE_S140_V6, 1u), + OTA_BOOT_CONTINUITY_NOT_NEWER); + EXPECT_EQ(ota_bootloader_continuity_gate( + legacy, candidate, candidate_version, 140u, 0x00B7u, + MOTA_NRF52_APP_BASE_S140_V6, 1u), + OTA_BOOT_CONTINUITY_PLATFORM); + + EXPECT_FALSE(ota_bootloader_version_valid(0x02040100u)); + EXPECT_FALSE(ota_bootloader_version_valid(UINT32_MAX)); + EXPECT_TRUE(ota_bootloader_version_valid(0x020401FFu)); + candidate.boot_version = UINT32_MAX; + EXPECT_EQ(ota_bootloader_continuity_gate( + legacy, candidate, UINT32_MAX, 140u, 0x00B6u, + MOTA_NRF52_APP_BASE_S140_V6, 1u), + OTA_BOOT_CONTINUITY_OUTER_VERSION); + candidate.boot_version = candidate_version; + + envelope[48] ^= 1u; + EXPECT_FALSE(ota_bootloader_manifest_parse(envelope, 0x100u, candidate, + sizeof(envelope))); +} + +TEST(OtaBootPackage, CandidateContinuityEnvelopeHasOneCanonicalFinalOffset) { + auto make_image = [](uint32_t manifest_offset) { + std::vector image(OTA_BOOT_IMAGE_SIZE, 0xFF); + wr_u32le(image.data(), OTA_NRF52840_RAM_END); + wr_u32le(image.data() + 4, OTA_BOOT_IMAGE_START + 0x101u); + uint8_t* caps = image.data() + 0x400u; + memset(caps, 0, 16u); + memcpy(caps, "MOTABLDR", 8u); + caps[8] = MOTA_BOOT_FORMAT_VER; + caps[10] = (uint8_t)OTA_BL_REQUIRED_APP_CODEC_MASK; + caps[12] = OTA_BL_PROFILE_SD_BOOT_UPDATE; + uint8_t* envelope = image.data() + manifest_offset; + memset(envelope, 0, OTA_BOOT_ENVELOPE_SIZE); + wr_u32le(envelope, OTA_BOOT_MANIFEST_MAGIC0); + wr_u32le(envelope + 4, OTA_BOOT_MANIFEST_MAGIC1); + envelope[8] = (uint8_t)OTA_BOOT_MANIFEST_VERSION; + envelope[10] = (uint8_t)OTA_BOOT_MANIFEST_SIZE; + wr_u32le(envelope + 12, OTA_BOOT_IMAGE_START); + wr_u32le(envelope + 16, OTA_BOOT_IMAGE_SIZE); + wr_u32le(envelope + 20, OTA_XIAO_BOARD_ID_BASE); + memcpy(envelope + 24, OTA_XIAO_BOOT_DEVICE_NAME, + OTA_BOOT_DEVICE_NAME_SIZE); + wr_u32le(envelope + 44, OTA_BOOT_CONTINUITY_MAGIC0); + wr_u32le(envelope + 48, OTA_BOOT_CONTINUITY_MAGIC1); + envelope[52] = (uint8_t)OTA_BOOT_CONTINUITY_VERSION; + envelope[54] = (uint8_t)OTA_BOOT_CONTINUITY_SIZE; + wr_u32le(envelope + 56, 0x0117010Du); + envelope[60] = (uint8_t)OTA_BOOT_CONTINUITY_FAMILY_S140; + envelope[62] = 0x23u; + envelope[63] = 0x01u; + wr_u32le(envelope + 64, MOTA_NRF52_APP_BASE_S140_V7); + envelope[68] = (uint8_t)OTA_BOOT_CONTINUITY_LAYOUT_ABI; + wr_u32le(envelope + 40, + ota_boot_image_crc32(image.data(), image.size(), manifest_offset + 40)); + return image; + }; + + OtaBootloaderIdentity identity; + auto canonical = make_image(OTA_BOOT_CANDIDATE_MANIFEST_OFFSET); + ASSERT_TRUE(ota_bootloader_candidate_identity_from_image( + canonical.data(), canonical.size(), identity)); + EXPECT_TRUE(ota_bootloader_candidate_identity_canonical(identity)); + EXPECT_EQ(identity.manifest_offset, 0x9FB4u); + + auto relocated = make_image(0x8000u); + ASSERT_TRUE(ota_bootloader_identity_from_image( + relocated.data(), relocated.size(), identity)); // installed/legacy scanner remains generic + EXPECT_FALSE(ota_bootloader_candidate_identity_canonical(identity)); + EXPECT_FALSE(ota_bootloader_candidate_identity_from_image( + relocated.data(), relocated.size(), identity)); + + OtaStoreRam store; + OtaBootloaderCapsMarker caps; + ASSERT_TRUE(store.begin((uint32_t)canonical.size())); + ASSERT_TRUE(store.write(0, canonical.data(), (uint32_t)canonical.size())); + EXPECT_TRUE(ota_bootloader_external_image_metadata( + store, 0, OTA_BL_PROFILE_SD_BOOT_UPDATE, identity, caps)); + EXPECT_EQ(identity.manifest_offset, OTA_BOOT_CANDIDATE_MANIFEST_OFFSET); + + // 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 + // discard the decoy and disagree with a deployed legacy updater. + auto ambiguous = canonical; + const uint32_t corrupt_decoy = 0x200u; + memcpy(ambiguous.data() + corrupt_decoy, + ambiguous.data() + OTA_BOOT_CANDIDATE_MANIFEST_OFFSET, + OTA_BOOT_MANIFEST_SIZE); + wr_u32le(ambiguous.data() + corrupt_decoy + 44, OTA_BOOT_CONTINUITY_MAGIC0); + wr_u32le(ambiguous.data() + corrupt_decoy + 48, 0x21444142u); // "BAD!" + wr_u32le(ambiguous.data() + OTA_BOOT_CANDIDATE_MANIFEST_OFFSET + 40, + 0x614E59E1u); + wr_u32le(ambiguous.data() + corrupt_decoy + 40, 0x4A03F7A2u); + ASSERT_EQ(ota_boot_image_crc32( + ambiguous.data(), ambiguous.size(), + OTA_BOOT_CANDIDATE_MANIFEST_OFFSET + 40), 0x614E59E1u); + ASSERT_EQ(ota_boot_image_crc32( + ambiguous.data(), ambiguous.size(), corrupt_decoy + 40), + 0x4A03F7A2u); + EXPECT_FALSE(ota_bootloader_candidate_identity_from_image( + ambiguous.data(), ambiguous.size(), identity)); + ASSERT_TRUE(store.begin((uint32_t)ambiguous.size())); + ASSERT_TRUE(store.write(0, ambiguous.data(), (uint32_t)ambiguous.size())); + EXPECT_FALSE(ota_bootloader_external_image_metadata( + store, 0, OTA_BL_PROFILE_SD_BOOT_UPDATE, identity, caps)); + + // The same malformed extension is harmless when its base CRC is invalid; + // only the canonical CRC-valid identity is selected and interpreted. + auto bad_crc_decoy = canonical; + memcpy(bad_crc_decoy.data() + corrupt_decoy, + bad_crc_decoy.data() + OTA_BOOT_CANDIDATE_MANIFEST_OFFSET, + OTA_BOOT_MANIFEST_SIZE); + wr_u32le(bad_crc_decoy.data() + corrupt_decoy + 44, + OTA_BOOT_CONTINUITY_MAGIC0); + wr_u32le(bad_crc_decoy.data() + corrupt_decoy + 48, 0x21444142u); + wr_u32le(bad_crc_decoy.data() + corrupt_decoy + 40, 0xA5A5A5A5u); + wr_u32le(bad_crc_decoy.data() + OTA_BOOT_CANDIDATE_MANIFEST_OFFSET + 40, 0u); + wr_u32le(bad_crc_decoy.data() + OTA_BOOT_CANDIDATE_MANIFEST_OFFSET + 40, + ota_boot_image_crc32( + bad_crc_decoy.data(), bad_crc_decoy.size(), + OTA_BOOT_CANDIDATE_MANIFEST_OFFSET + 40)); + ASSERT_NE(ota_boot_image_crc32( + bad_crc_decoy.data(), bad_crc_decoy.size(), corrupt_decoy + 40), + 0xA5A5A5A5u); + EXPECT_TRUE(ota_bootloader_candidate_identity_from_image( + bad_crc_decoy.data(), bad_crc_decoy.size(), identity)); + ASSERT_TRUE(store.begin((uint32_t)bad_crc_decoy.size())); + ASSERT_TRUE(store.write(0, bad_crc_decoy.data(), + (uint32_t)bad_crc_decoy.size())); + EXPECT_TRUE(ota_bootloader_external_image_metadata( + store, 0, OTA_BL_PROFILE_SD_BOOT_UPDATE, identity, caps)); + + // A sole CRC-valid identity that claims a corrupt extension is not a legacy + // image; installed diagnostics and candidate validation both fail closed. + auto sole_half = canonical; + wr_u32le(sole_half.data() + OTA_BOOT_CANDIDATE_MANIFEST_OFFSET + 48, + 0x21444142u); + wr_u32le(sole_half.data() + OTA_BOOT_CANDIDATE_MANIFEST_OFFSET + 40, 0u); + wr_u32le(sole_half.data() + OTA_BOOT_CANDIDATE_MANIFEST_OFFSET + 40, + ota_boot_image_crc32( + sole_half.data(), sole_half.size(), + OTA_BOOT_CANDIDATE_MANIFEST_OFFSET + 40)); + EXPECT_FALSE(ota_bootloader_identity_from_image( + sole_half.data(), sole_half.size(), identity)); + ASSERT_TRUE(store.begin((uint32_t)sole_half.size())); + ASSERT_TRUE(store.write(0, sole_half.data(), (uint32_t)sole_half.size())); + EXPECT_FALSE(ota_bootloader_external_image_metadata( + store, 0, OTA_BL_PROFILE_SD_BOOT_UPDATE, identity, caps)); + + ASSERT_TRUE(store.begin((uint32_t)relocated.size())); + ASSERT_TRUE(store.write(0, relocated.data(), (uint32_t)relocated.size())); + EXPECT_FALSE(ota_bootloader_external_image_metadata( + store, 0, OTA_BL_PROFILE_SD_BOOT_UPDATE, identity, caps)); +} + TEST(OtaBootPackage, SdApprovalTokenBindsTheAuthenticatedImageHash) { uint8_t hash[32]; for (uint8_t i = 0; i < sizeof(hash); i++) hash[i] = i; @@ -279,6 +533,14 @@ TEST(OtaBootPackage, EmbeddedIdentityVectorCapsAndExplicitConfirmationGate) { EXPECT_TRUE(ota_bootloader_caps_marker_parse(marker, caps)); marker[15] = 1; EXPECT_FALSE(ota_bootloader_caps_marker_parse(marker, caps)); + marker[15] = 0; + marker[8] = 0; marker[9] = 1; // ABI 0x0100: valid little-endian u16 + ASSERT_TRUE(ota_bootloader_caps_marker_parse(marker, caps)); + EXPECT_EQ(caps.apply_abi, 0x0100u); + marker[8] = 0; marker[9] = 0; + EXPECT_FALSE(ota_bootloader_caps_marker_parse(marker, caps)); + marker[8] = 0xFF; marker[9] = 0xFF; + EXPECT_FALSE(ota_bootloader_caps_marker_parse(marker, caps)); std::vector internal_caps_image(64, 0xFF); const uint8_t internal_marker[16] = { @@ -342,8 +604,8 @@ 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, 5,0, - qspi_profile, 0,0,0}; + 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)); memcpy(image + 1, marker, sizeof(marker)); @@ -356,6 +618,17 @@ TEST(OtaBootPackage, CapabilityScannerRejectsAnOtherwiseValidUnalignedMarker) { EXPECT_EQ(caps.apply_abi, 3u); EXPECT_EQ(caps.storage_flags, qspi_profile); + marker[8] = 0; marker[9] = 1; + memset(image, 0xFF, sizeof(image)); + memcpy(image + 4, marker, sizeof(marker)); + const OtaBlCaps high_abi = + ota_bl_caps_scan_aligned(image, sizeof(image), true, qspi_profile); + ASSERT_TRUE(high_abi.present); + EXPECT_EQ(high_abi.apply_abi, 0x0100u); + marker[8] = 3; marker[9] = 0; + memset(image, 0xFF, sizeof(image)); + memcpy(image + 4, marker, sizeof(marker)); + // A malformed aligned magic decoy is ignored, but a second fully valid // privileged marker makes the installed capability identity ambiguous. memcpy(image + 24, marker, sizeof(marker)); @@ -365,12 +638,19 @@ TEST(OtaBootPackage, CapabilityScannerRejectsAnOtherwiseValidUnalignedMarker) { image[24 + 13] = 1; EXPECT_TRUE(ota_bl_caps_scan_aligned(image, sizeof(image), true, qspi_profile).present); - // A structurally valid marker for the other storage profile is not an - // installed-profile candidate and cannot shadow the exact marker. + // Any second structurally valid privileged marker is ambiguous, including + // another known storage profile or the bare BOOT_UPDATE capability. memcpy(image + 24, marker, sizeof(marker)); image[24 + 12] = OTA_BL_STORAGE_STAGE_CEILING | OTA_BL_STORAGE_BOOT_UPDATE; - EXPECT_TRUE(ota_bl_caps_scan_aligned(image, sizeof(image), true, qspi_profile).present); + EXPECT_FALSE(ota_bl_caps_scan_aligned(image, sizeof(image), true, qspi_profile).present); + image[24 + 12] = OTA_BL_STORAGE_BOOT_UPDATE; + EXPECT_FALSE(ota_bl_caps_scan_aligned(image, sizeof(image), true, qspi_profile).present); + + memset(image, 0xFF, sizeof(image)); + memcpy(image + 4, marker, sizeof(marker)); + image[4 + 12] = OTA_BL_STORAGE_BOOT_UPDATE; + EXPECT_FALSE(ota_bl_caps_scan_aligned(image, sizeof(image), true, qspi_profile).present); } class FakeMotaSeederStream : public Stream { @@ -1764,6 +2044,50 @@ TEST(OtaTransfer, RejectsUnsupportedManifestHashAlgorithm) { EXPECT_EQ(client.fetchState(), OtaManager::FAILED); } +TEST(OtaTransfer, RejectsEnvelopeMidThatDoesNotMatchParsedManifestRootBeforeStaging) { + MotaManifest manifest; + ASSERT_TRUE(mota_parse(SIM_MOTA, SIM_MOTA_LEN, manifest)); + std::array bytes; + memcpy(bytes.data(), manifest.manifest_start, bytes.size()); + bytes[20] ^= 0x80; // parsed merkle_root; the peer keeps the requested MID in the wire envelope + + OtaManager client; + OtaStoreRam<4096> store; + CapturedMessages sent; + client.begin(SIM_TARGET_ID, capture_send, &sent); + client.set_fetch_store(&store); + ASSERT_EQ(client.pull(manifest.merkle_root, manifest.target_id), OtaManager::PULL_STARTED); + + deliver_manifest_fragment(client, manifest.merkle_root, 0, bytes.data(), OTA_MF_FRAG); + deliver_manifest_fragment(client, manifest.merkle_root, 1, bytes.data() + OTA_MF_FRAG, + (uint16_t)(MOTA_MFL - OTA_MF_FRAG)); + EXPECT_EQ(client.fetchState(), OtaManager::FAILED); + EXPECT_EQ(client.fetchError(), OtaManager::FETCH_ERROR_MANIFEST); + EXPECT_EQ(store.staged_size(), 0u); +} + +TEST(OtaTransfer, RejectsParsedManifestTargetThatDoesNotMatchRequestedCatalogTargetBeforeStaging) { + MotaManifest manifest; + ASSERT_TRUE(mota_parse(SIM_MOTA, SIM_MOTA_LEN, manifest)); + std::array bytes; + memcpy(bytes.data(), manifest.manifest_start, bytes.size()); + wr_u32le(bytes.data() + 3, manifest.target_id ^ 0x01000000u); + + OtaManager client; + OtaStoreRam<4096> store; + CapturedMessages sent; + client.begin(SIM_TARGET_ID, capture_send, &sent); + client.set_fetch_store(&store); + ASSERT_EQ(client.pull(manifest.merkle_root, manifest.target_id), OtaManager::PULL_STARTED); + + deliver_manifest_fragment(client, manifest.merkle_root, 0, bytes.data(), OTA_MF_FRAG); + deliver_manifest_fragment(client, manifest.merkle_root, 1, bytes.data() + OTA_MF_FRAG, + (uint16_t)(MOTA_MFL - OTA_MF_FRAG)); + EXPECT_EQ(client.fetchState(), OtaManager::FAILED); + EXPECT_EQ(client.fetchError(), OtaManager::FETCH_ERROR_MANIFEST); + EXPECT_EQ(store.staged_size(), 0u); +} + // Same end-to-end transfer, but with 1 KB logical blocks: each block is delivered as several // self-describing DATA fragments (frag_off), reassembled by the client, then its merkle PROOF is // requested + verified separately before the block is committed. Exercises the multi-fragment path. @@ -2017,6 +2341,7 @@ TEST(OtaTransfer, ResumeAfterReboot) { SendTo to_server2{&server}; client2.begin(SIM_TARGET_ID, sim_send, &to_server2); client2.set_fetch_store(&store); + client2.set_autofetch(OtaManager::AUTOFETCH_ANY); ASSERT_TRUE(client2.resumeStaged(nullptr)); // adopt whatever is staged EXPECT_EQ(client2.fetchState(), OtaManager::VERIFYING_STAGED); finish_staged_verification(client2); @@ -2030,6 +2355,131 @@ TEST(OtaTransfer, ResumeAfterReboot) { EXPECT_EQ(0, std::memcmp(store.data(), SIM_MOTA_1K, SIM_MOTA_1K_LEN)); // byte-identical to the original } +TEST(OtaTransfer, BootResumeHonorsCurrentAutofetchTargetAndVersionPolicy) { + MotaManifest manifest; + ASSERT_TRUE(mota_parse(SIM_MOTA, SIM_MOTA_LEN, manifest)); + ASSERT_TRUE(manifest.is_signed()); + auto stage = [](OtaStoreRam<4096>& store, const uint8_t* bytes, + uint32_t length) { + return store.begin(length) && store.write(0, bytes, length); + }; + + OtaStoreRam<4096> off_store; + ASSERT_TRUE(stage(off_store, SIM_MOTA, SIM_MOTA_LEN)); + OtaManager off; + off.begin(manifest.target_id, nullptr, nullptr); + off.set_fetch_store(&off_store); + EXPECT_FALSE(off.resumeStaged(nullptr)); + + std::vector unsigned_mota(SIM_MOTA, SIM_MOTA + SIM_MOTA_LEN); + unsigned_mota[8u + 1u] &= (uint8_t)~MFLAG_SIGNED; + memset(unsigned_mota.data() + 8u + 97u, 0, 32u + 64u); + MotaManifest unsigned_manifest; + ASSERT_TRUE(mota_parse(unsigned_mota.data(), + (uint32_t)unsigned_mota.size(), unsigned_manifest)); + ASSERT_FALSE(unsigned_manifest.is_signed()); + OtaStoreRam<4096> unsigned_store; + ASSERT_TRUE(stage(unsigned_store, unsigned_mota.data(), + (uint32_t)unsigned_mota.size())); + OtaManager signed_only; + signed_only.begin(manifest.target_id, nullptr, nullptr); + signed_only.set_fetch_store(&unsigned_store); + signed_only.set_autofetch(OtaManager::AUTOFETCH_SIGNED); + signed_only.set_auto_version_floor(manifest.fw_version - 1u, true); + EXPECT_FALSE(signed_only.resumeStaged(nullptr)); + + OtaStoreRam<4096> replay_store; + ASSERT_TRUE(stage(replay_store, SIM_MOTA, SIM_MOTA_LEN)); + OtaManager replay; + replay.begin(manifest.target_id, nullptr, nullptr); + replay.set_fetch_store(&replay_store); + replay.set_autofetch(OtaManager::AUTOFETCH_ANY); + replay.set_auto_version_floor(manifest.fw_version, true); + EXPECT_FALSE(replay.resumeStaged(nullptr)); + + OtaStoreRam<4096> wrong_target_store; + ASSERT_TRUE(stage(wrong_target_store, SIM_MOTA, SIM_MOTA_LEN)); + OtaManager wrong_target; + wrong_target.begin(manifest.target_id ^ 1u, nullptr, nullptr); + wrong_target.set_fetch_store(&wrong_target_store); + wrong_target.set_autofetch(OtaManager::AUTOFETCH_ANY); + wrong_target.set_auto_version_floor(manifest.fw_version - 1u, true); + EXPECT_FALSE(wrong_target.resumeStaged(nullptr)); + + OtaStoreRam<4096> newer_store; + ASSERT_TRUE(stage(newer_store, SIM_MOTA, SIM_MOTA_LEN)); + OtaManager newer; + newer.begin(manifest.target_id, nullptr, nullptr); + newer.set_fetch_store(&newer_store); + newer.set_autofetch(OtaManager::AUTOFETCH_SIGNED); + newer.set_auto_version_floor(manifest.fw_version - 1u, true); + EXPECT_TRUE(newer.resumeStaged(nullptr)); + EXPECT_EQ(newer.fetchState(), OtaManager::VERIFYING_STAGED); +} + +TEST(OtaTransfer, ExplicitResumePreservesTargetZeroWildcard) { + MotaManifest manifest; + ASSERT_TRUE(mota_parse(SIM_MOTA, SIM_MOTA_LEN, manifest)); + auto stage = [](OtaStoreRam<4096>& store) { + return store.begin(SIM_MOTA_LEN) && + store.write(0, SIM_MOTA, SIM_MOTA_LEN); + }; + + OtaStoreRam<4096> manual_store; + ASSERT_TRUE(stage(manual_store)); + OtaManager manual; + manual.begin(manifest.target_id ^ 1u, nullptr, nullptr); + manual.set_fetch_store(&manual_store); + EXPECT_EQ(manual.pull(manifest.merkle_root, 0), OtaManager::PULL_RESUMED); + + OtaStoreRam<4096> archive_store; + ASSERT_TRUE(stage(archive_store)); + OtaManager archive; + archive.begin(manifest.target_id ^ 1u, nullptr, nullptr); + archive.set_fetch_store(&archive_store); + EXPECT_EQ(archive.pull_archive(manifest.merkle_root, 0), + OtaManager::PULL_RESUMED); + + OtaStoreRam<4096> mismatch_store; + ASSERT_TRUE(stage(mismatch_store)); + OtaManager mismatch; + mismatch.begin(manifest.target_id, nullptr, nullptr); + mismatch.set_fetch_store(&mismatch_store); + EXPECT_EQ(mismatch.pull(manifest.merkle_root, manifest.target_id ^ 1u), + OtaManager::PULL_STARTED); + EXPECT_EQ(mismatch.fetchState(), OtaManager::WANT_MANIFEST); +} + +TEST(OtaTransfer, ExplicitDebugResumeUsesManualIntentNotAutofetchPolicy) { + MotaManifest manifest; + ASSERT_TRUE(mota_parse(SIM_MOTA, SIM_MOTA_LEN, manifest)); + OtaStoreRam<4096> store; + ASSERT_TRUE(store.begin(SIM_MOTA_LEN)); + ASSERT_TRUE(store.write(0, SIM_MOTA, SIM_MOTA_LEN)); + + // A deliberate debug/operator resume remains possible with autofetch disabled, an equal running + // version, and a cross-target wildcard. Those same properties are rejected by resumeStaged(nullptr). + OtaManager explicit_resume; + explicit_resume.begin(manifest.target_id ^ 1u, nullptr, nullptr); + explicit_resume.set_fetch_store(&store); + explicit_resume.set_autofetch(OtaManager::AUTOFETCH_OFF); + explicit_resume.set_auto_version_floor(manifest.fw_version, true); + ASSERT_TRUE(explicit_resume.resumeStagedExplicit(manifest.merkle_root, 0)); + EXPECT_EQ(explicit_resume.fetchState(), OtaManager::VERIFYING_STAGED); + + OtaStoreRam<4096> wrong_mid_store; + ASSERT_TRUE(wrong_mid_store.begin(SIM_MOTA_LEN)); + ASSERT_TRUE(wrong_mid_store.write(0, SIM_MOTA, SIM_MOTA_LEN)); + OtaManager wrong_mid; + wrong_mid.begin(manifest.target_id, nullptr, nullptr); + wrong_mid.set_fetch_store(&wrong_mid_store); + uint8_t other_mid[4]; + memcpy(other_mid, manifest.merkle_root, sizeof(other_mid)); + other_mid[0] ^= 1u; + EXPECT_FALSE(wrong_mid.resumeStagedExplicit(other_mid, 0)); + EXPECT_EQ(wrong_mid.fetchState(), OtaManager::IDLE); +} + TEST(OtaTransfer, ResumeRehashesPayloadBeforeTrustingPresentLeaf) { g_q.clear(); OtaManager server, client; @@ -2056,6 +2506,7 @@ TEST(OtaTransfer, ResumeRehashesPayloadBeforeTrustingPresentLeaf) { OtaManager resumed; resumed.begin(SIM_TARGET_ID, nullptr, nullptr); resumed.set_fetch_store(&store); + resumed.set_autofetch(OtaManager::AUTOFETCH_ANY); ASSERT_TRUE(resumed.resumeStaged(nullptr)); finish_staged_verification(resumed); EXPECT_EQ(resumed.fetchState(), OtaManager::FETCHING); @@ -2087,6 +2538,7 @@ TEST(OtaTransfer, ResumeReadFailureCanNeverBecomeComplete) { OtaManager resumed; resumed.begin(SIM_TARGET_ID, nullptr, nullptr); resumed.set_fetch_store(&store); + resumed.set_autofetch(OtaManager::AUTOFETCH_ANY); ASSERT_TRUE(resumed.resumeStaged(nullptr)); finish_staged_verification(resumed); EXPECT_EQ(resumed.fetchState(), OtaManager::FAILED); @@ -2256,6 +2708,88 @@ TEST(OtaTransfer, ManualPullReportsIncompatibleManifestInsteadOfGoingIdle) { EXPECT_EQ(store.staged_size(), 0u); // compatibility is rejected before the store is begun } +TEST(OtaTransfer, AutomaticAdmissionRejectsReplayAndRechecksLyingHaveVersion) { + MotaManifest manifest; + ASSERT_TRUE(mota_parse(SIM_MOTA, SIM_MOTA_LEN, manifest)); + OtaManager client; + OtaStoreRam<4096> store; + CapturedMessages sent; + client.begin(SIM_TARGET_ID, capture_send, &sent); + client.set_fetch_store(&store); + client.set_autofetch(OtaManager::AUTOFETCH_ANY); + client.set_auto_version_floor(manifest.fw_version, true); + + uint8_t wire[64]; + client.on_message(wire, make_have1(wire, sizeof(wire), manifest.merkle_root, + manifest.target_id, manifest.fw_version, + manifest.codec_id, manifest.flags)); + EXPECT_EQ(client.fetchState(), OtaManager::IDLE); // equal-version replay rejected at catalog admission + + client.on_message(wire, make_have1(wire, sizeof(wire), manifest.merkle_root, + manifest.target_id, manifest.fw_version + 1u, + manifest.codec_id, manifest.flags)); + ASSERT_EQ(client.fetchState(), OtaManager::WANT_MANIFEST); + deliver_manifest_fragment(client, manifest.merkle_root, 0, + manifest.manifest_start, OTA_MF_FRAG); + deliver_manifest_fragment(client, manifest.merkle_root, 1, + manifest.manifest_start + OTA_MF_FRAG, + (uint16_t)(MOTA_MFL - OTA_MF_FRAG)); + EXPECT_EQ(client.fetchState(), OtaManager::FAILED); // parsed version defeats the lying HAVE row + EXPECT_EQ(client.fetchError(), OtaManager::FETCH_ERROR_VERSION); + EXPECT_EQ(store.staged_size(), 0u); +} + +TEST(OtaTransfer, SignedOnlyAutofetchRechecksTheParsedManifestFlag) { + MotaManifest manifest; + ASSERT_TRUE(mota_parse(SIM_MOTA, SIM_MOTA_LEN, manifest)); + ASSERT_TRUE(manifest.is_signed()); + std::array unsigned_bytes; + memcpy(unsigned_bytes.data(), manifest.manifest_start, unsigned_bytes.size()); + unsigned_bytes[1] &= (uint8_t)~MFLAG_SIGNED; + OtaManager client; + OtaStoreRam<4096> store; + CapturedMessages sent; + client.begin(SIM_TARGET_ID, capture_send, &sent); + client.set_fetch_store(&store); + client.set_autofetch(OtaManager::AUTOFETCH_SIGNED); + client.set_auto_version_floor(manifest.fw_version - 1u, true); + + uint8_t wire[64]; + client.on_message(wire, make_have1(wire, sizeof(wire), manifest.merkle_root, + manifest.target_id, manifest.fw_version, + manifest.codec_id, + (uint8_t)(manifest.flags | MFLAG_SIGNED))); + ASSERT_EQ(client.fetchState(), OtaManager::WANT_MANIFEST); + deliver_manifest_fragment(client, manifest.merkle_root, 0, + unsigned_bytes.data(), OTA_MF_FRAG); + deliver_manifest_fragment(client, manifest.merkle_root, 1, + unsigned_bytes.data() + OTA_MF_FRAG, + (uint16_t)(MOTA_MFL - OTA_MF_FRAG)); + EXPECT_EQ(client.fetchState(), OtaManager::FAILED); + EXPECT_EQ(client.fetchError(), OtaManager::FETCH_ERROR_MANIFEST); + EXPECT_EQ(store.staged_size(), 0u); +} + +TEST(OtaTransfer, ExplicitManualPullMayOverrideAutomaticVersionFloor) { + MotaManifest manifest; + ASSERT_TRUE(mota_parse(SIM_MOTA, SIM_MOTA_LEN, manifest)); + OtaManager client; + OtaStoreRam<4096> store; + CapturedMessages sent; + client.begin(SIM_TARGET_ID, capture_send, &sent); + client.set_fetch_store(&store); + client.set_auto_version_floor(manifest.fw_version, true); + + ASSERT_EQ(client.pull(manifest.merkle_root, manifest.target_id), OtaManager::PULL_STARTED); + deliver_manifest_fragment(client, manifest.merkle_root, 0, + manifest.manifest_start, OTA_MF_FRAG); + deliver_manifest_fragment(client, manifest.merkle_root, 1, + manifest.manifest_start + OTA_MF_FRAG, + (uint16_t)(MOTA_MFL - OTA_MF_FRAG)); + EXPECT_EQ(client.fetchState(), OtaManager::FETCHING); + EXPECT_GT(store.staged_size(), 0u); +} + TEST(OtaTransfer, PullAdmissionReportsNoStoreAndBusyWithoutReplacingIntent) { g_q.clear(); OtaManager client; diff --git a/test/test_ota/test_ota_flashplan.cpp b/test/test_ota/test_ota_flashplan.cpp index 2079c6dc..1b72871e 100644 --- a/test/test_ota/test_ota_flashplan.cpp +++ b/test/test_ota/test_ota_flashplan.cpp @@ -3,7 +3,6 @@ #include "helpers/ota/OtaFlashLayout_nrf52.h" #include "helpers/ota/OtaStoreQspiNrf52.h" -#include "helpers/ota/OtaSdHandoff.h" using namespace mesh::ota; @@ -325,22 +324,3 @@ TEST(OtaFlashPlan, LeavesOutputUntouchedOnReject) { EXPECT_FALSE(mota_nrf52_stage_plan(CAP_V6 + 1, APP_V6, APP_END_V6, LEGACY, start)); EXPECT_EQ(start, 0x1234ABCDu); } - -TEST(OtaSdHandoff, EncodesChecksummedRecordAndPreservesSectorTail) { - uint8_t sector[MOTA_SD_SECTOR_SIZE]; - std::memset(sector, 0xA5, sizeof(sector)); - mota_sd_encode_handoff(sector, 2048, 1234, 630000, 8000000); - - EXPECT_EQ(0, std::memcmp(sector, MOTA_SD_HANDOFF_MAGIC, 8)); - EXPECT_EQ(mota_sd_rd32(sector + 8), MOTA_SD_HANDOFF_VERSION); - EXPECT_EQ(mota_sd_rd32(sector + 12), 2048u); - EXPECT_EQ(mota_sd_rd32(sector + 16), 1234u); - EXPECT_EQ(mota_sd_rd32(sector + 20), 630000u); - EXPECT_EQ(mota_sd_rd32(sector + 24), ~630000u); - EXPECT_EQ(mota_sd_rd32(sector + 28), 8000000u); - EXPECT_EQ(mota_sd_rd32(sector + 32), mota_sd_crc32(sector, 32)); - EXPECT_EQ(sector[MOTA_SD_HANDOFF_LEN], 0xA5); // bytes outside our record are untouched - - sector[20] ^= 1u; - EXPECT_NE(mota_sd_rd32(sector + 32), mota_sd_crc32(sector, 32)); -} diff --git a/tools/mota/motalib.py b/tools/mota/motalib.py index ff4b78d1..bc878c9b 100644 --- a/tools/mota/motalib.py +++ b/tools/mota/motalib.py @@ -90,6 +90,13 @@ XIAO_BOOT_IMAGE_SIZE = 0x0000A000 XIAO_BOOT_MANIFEST_MAGIC = b"BLMFCRC1" XIAO_BOOT_MANIFEST_VERSION = 1 XIAO_BOOT_MANIFEST_SIZE = 44 +BOOT_CONTINUITY_MAGIC = b"BLM2SOFT" +BOOT_CONTINUITY_VERSION = 2 +BOOT_CONTINUITY_SIZE = 32 +BOOT_CONTINUITY_FAMILY_S140 = 140 +BOOT_CONTINUITY_LAYOUT_ABI = 1 +BOOT_ENVELOPE_SIZE = XIAO_BOOT_MANIFEST_SIZE + BOOT_CONTINUITY_SIZE +BOOT_CANDIDATE_MANIFEST_OFFSET = XIAO_BOOT_IMAGE_SIZE - BOOT_ENVELOPE_SIZE XIAO_BOOT_DEVICE_NAME = b"XIAO_DFU".ljust(16, b"\0") XIAO_BOOT_CAPS_MAGIC = b"MOTABLDR" BOOT_STORAGE_SD = 0x01 @@ -133,6 +140,21 @@ SD_BOOTLOADER_IDENTITIES = ( (0x239A0071, "TOWER_V2_OTA"), ) +# Exact runtime continuity profiles for signed, remotely installable +# bootloaders. A CRC-valid BLM2 envelope is not sufficient for package +# creation: signing the wrong SoftDevice/application layout only produces a +# package every qualified device will refuse. XIAO/Sense, Minew MX25LE01, and +# T1000-E run S140 v7; the remaining inventory (including both Tower storage +# profiles) runs S140 v6. +BOOTLOADER_S140_V7_IDENTITIES = ( + (XIAO_BOOT_BOARD_ID_BASE, "XIAO_DFU"), + (XIAO_BOOT_BOARD_ID_SENSE, "XIAO_DFU"), + (0x239A0029, "MX25_DFU"), + (0x28860057, "T1KE_DFU"), +) +BOOTLOADER_S140_V6_FWID = 0x00B6 +BOOTLOADER_S140_V7_FWID = 0x0123 + # 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. @@ -550,11 +572,21 @@ class BootloaderIdentity: board_id: int device_name: str crc32: int + boot_version: Optional[int] = None + softdevice_family: Optional[int] = None + softdevice_fwid: Optional[int] = None + app_base: Optional[int] = None + layout_abi: Optional[int] = None + compat_flags: Optional[int] = None XiaoBootloaderIdentity = BootloaderIdentity # compatibility for callers of the original reference API +def bootloader_version_valid(version: int) -> bool: + return version not in (0, 0xFFFFFFFF) and version & 0xFF != 0 + + def _bootloader_device_name(board_id: int, name_raw: bytes) -> Optional[str]: if len(name_raw) != 16 or board_id in (0, 0xFFFFFFFF): return None @@ -618,6 +650,33 @@ def bootloader_identity_is_buildable(board_id: int, device_name: str) -> bool: (XIAO_BOOT_BOARD_ID_SENSE, "XIAO_DFU"))) +def bootloader_qualified_platform_profile(board_id: int, device_name: str): + """Return exact (family, FWID, app_base, layout ABI), or None if unqualified.""" + identity = (board_id, device_name) + if not bootloader_identity_is_buildable(*identity): + return None + if identity in BOOTLOADER_S140_V7_IDENTITIES: + return (BOOT_CONTINUITY_FAMILY_S140, BOOTLOADER_S140_V7_FWID, + NRF52_APP_BASE_S140_V7, BOOT_CONTINUITY_LAYOUT_ABI) + return (BOOT_CONTINUITY_FAMILY_S140, BOOTLOADER_S140_V6_FWID, + NRF52_APP_BASE_S140_V6, BOOT_CONTINUITY_LAYOUT_ABI) + + +def bootloader_qualified_storage_profiles(board_id: int, device_name: str): + """Return the exact allowed capability-marker profiles for one identity.""" + identity = (board_id, device_name) + if identity in ((XIAO_BOOT_BOARD_ID_BASE, "XIAO_DFU"), + (XIAO_BOOT_BOARD_ID_SENSE, "XIAO_DFU")): + return (BOOT_STORAGE_QSPI_UPDATE,) + if identity in SD_BOOTLOADER_IDENTITIES: + # One embedded Tower identity supports two distinct application + # targets; its marker selects either the shared internal slot or SD. + return (BOOT_STORAGE_INTERNAL_UPDATE, BOOT_STORAGE_SD_UPDATE) + if identity in INTERNAL_BOOTLOADER_IDENTITIES: + return (BOOT_STORAGE_INTERNAL_UPDATE,) + return None + + def _bootloader_vector_sane(image: bytes) -> bool: if len(image) < 8: return False @@ -628,7 +687,13 @@ def _bootloader_vector_sane(image: bytes) -> bool: def parse_bootloader_identity(image: bytes) -> Optional[BootloaderIdentity]: - """Find exactly one CRC-valid embedded v1 manifest, continuing past invalid decoys.""" + """Find one CRC-valid base identity, then validate its optional BLM2 extension. + + Duplicate accounting intentionally mirrors deployed legacy bootloaders: a + CRC-valid BLMF-v1 record counts even when adjacent claimed BLM2 metadata is + malformed. Otherwise a corrupt extension could hide a second privileged + identity from a new packager while an installed legacy updater rejects it. + """ if len(image) != XIAO_BOOT_IMAGE_SIZE: return None found = None @@ -649,11 +714,32 @@ def parse_bootloader_identity(image: bytes) -> Optional[BootloaderIdentity]: import zlib if zlib.crc32(crc_image) & 0xFFFFFFFF != stored_crc: continue - candidate = BootloaderIdentity(off, start, image_size, board_id, device_name, stored_crc) + candidate = BootloaderIdentity(off, start, image_size, board_id, device_name, + stored_crc) if found is not None: return None found = candidate - return found + if found is None: + return None + + ext = image[found.manifest_offset + XIAO_BOOT_MANIFEST_SIZE: + found.manifest_offset + BOOT_ENVELOPE_SIZE] + magic0 = len(ext) >= 4 and ext[:4] == BOOT_CONTINUITY_MAGIC[:4] + magic1 = len(ext) >= 8 and ext[4:8] == BOOT_CONTINUITY_MAGIC[4:8] + if not magic0 and not magic1: + return found + if len(ext) != BOOT_CONTINUITY_SIZE or not magic0 or not magic1: + return None + ext_version, ext_size = struct.unpack_from(" Optional[XiaoBootloaderIdentity]: @@ -666,6 +752,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.""" found = None + valid_count = 0 for off in range(0, len(image) - 16 + 1, 4): if image[off:off + 8] != XIAO_BOOT_CAPS_MAGIC: continue @@ -674,13 +761,15 @@ def bootloader_caps_storage(image: bytes) -> Optional[int]: 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_SD_UPDATE, BOOT_STORAGE_QSPI_UPDATE, - BOOT_STORAGE_INTERNAL_UPDATE)): + not storage & XIAO_BOOT_STORAGE_UPDATE): continue - if found is not None: + valid_count += 1 + if valid_count != 1: return None - found = storage - return found + if storage in (BOOT_STORAGE_SD_UPDATE, BOOT_STORAGE_QSPI_UPDATE, + BOOT_STORAGE_INTERNAL_UPDATE): + found = storage + return found if valid_count == 1 else None def xiao_bootloader_caps_ok(image: bytes) -> bool: @@ -696,22 +785,38 @@ def validate_bootloader_image(image: bytes, target_id: Optional[int] = None, identity = parse_bootloader_identity(image) if identity is None: raise ValueError("bootloader embedded manifest/CRC is invalid or ambiguous") + if identity.boot_version is None: + raise ValueError("bootloader lacks the BLM2/SOFT continuity extension") + if identity.manifest_offset != BOOT_CANDIDATE_MANIFEST_OFFSET: + raise ValueError( + f"bootloader continuity envelope must be at exact offset " + f"0x{BOOT_CANDIDATE_MANIFEST_OFFSET:04X}") expected_target = bootloader_target_id(identity.board_id, identity.device_name) if target_id is not None and target_id != expected_target: raise ValueError("bootloader target ID does not match embedded identity") expected_hw = bootloader_hw_id(identity.board_id, identity.device_name) if signed_hw_id is not None and bytes(signed_hw_id) != expected_hw: raise ValueError("bootloader signed hw_id does not match embedded identity") - 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 = bootloader_qualified_storage_profiles( + identity.board_id, identity.device_name) + if expected_storage is None: + # Keep generic parsing/inspection useful for future canonical images; + # only the signing/build path below admits the curated inventory. 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}") + expected_platform = bootloader_qualified_platform_profile( + identity.board_id, identity.device_name) + if expected_platform is not None: + actual_platform = (identity.softdevice_family, identity.softdevice_fwid, + identity.app_base, identity.layout_abi) + if actual_platform != expected_platform: + family, fwid, app_base, layout_abi = expected_platform + raise ValueError( + "bootloader continuity platform does not match qualified " + f"profile S{family}/0x{fwid:04X}/0x{app_base:X}/ABI{layout_abi}") return identity @@ -745,10 +850,11 @@ def build_manifest(*, target_id: int, fw_version: int, image_size: int, payload: audit_bootloader_target_inventory() if not bootloader_identity_is_buildable(identity.board_id, identity.device_name): raise ValueError("bootloader embedded identity is not in the qualified build inventory") - if (fw_version == 0 or not is_full or codec_id != CODEC_FULL or + if (not bootloader_version_valid(fw_version) or + fw_version != identity.boot_version or not is_full or codec_id != CODEC_FULL or image_size != XIAO_BOOT_IMAGE_SIZE or block_size != DEFAULT_BLOCK_SIZE or base_hash not in (None, b"\0" * 8)): - raise ValueError("bootloader package needs a nonzero version and a 40 KiB CODEC_FULL image with 1 KiB blocks") + raise ValueError("bootloader package version must equal BLM2 metadata and use a 40 KiB CODEC_FULL image with 1 KiB blocks") if sign_priv is None: raise ValueError("bootloader package must be signed") expected_hw = bootloader_hw_id(identity.board_id, identity.device_name) @@ -847,13 +953,15 @@ def parse_container(blob: bytes) -> Parsed: if rest != b"": raise ValueError("trailing bytes after payload") if m.is_bootloader: - if (m.fw_version == 0 or m.codec_id != CODEC_FULL or + if (not bootloader_version_valid(m.fw_version) or m.codec_id != CODEC_FULL or m.image_size != XIAO_BOOT_IMAGE_SIZE or m.payload_size != XIAO_BOOT_IMAGE_SIZE or m.block_size_log2 != 10 or m.block_count != 40 or m.base_hash != b"\0" * 8): raise ValueError("v3 bootloader manifest geometry/identity is invalid") try: - validate_bootloader_image(payload, m.target_id, m.hw_id) + identity = validate_bootloader_image(payload, m.target_id, m.hw_id) + if m.fw_version != identity.boot_version: + raise ValueError("outer bootloader version does not match BLM2 metadata") except (UnicodeDecodeError, ValueError) as exc: raise ValueError(f"v3 bootloader image identity/capability is invalid: {exc}") from exc return Parsed(manifest=m, payload=payload, total_size=total) @@ -871,7 +979,9 @@ def verify(parsed: Parsed, *, expect_pub: Optional[bytes] = None, if m.is_bootloader: try: - validate_bootloader_image(payload, m.target_id, m.hw_id) + identity = validate_bootloader_image(payload, m.target_id, m.hw_id) + if m.fw_version != identity.boot_version: + problems.append("outer bootloader version does not match BLM2 metadata") except (UnicodeDecodeError, ValueError) as exc: problems.append(f"bootloader image contract: {exc}") diff --git a/tools/mota/test_mota.py b/tools/mota/test_mota.py index d8f41390..dc27fded 100644 --- a/tools/mota/test_mota.py +++ b/tools/mota/test_mota.py @@ -208,7 +208,20 @@ def test_nrf52_layout_record_roundtrip_and_policy(): pass -def _xiao_bootloader_image(board_id=ml.XIAO_BOOT_BOARD_ID_BASE): +def _write_boot_continuity(image, offset, *, version=0x0117010D, + family=ml.BOOT_CONTINUITY_FAMILY_S140, + fwid=None, app_base=ml.NRF52_APP_BASE_S140_V7, + layout_abi=ml.BOOT_CONTINUITY_LAYOUT_ABI): + if fwid is None: + fwid = 0x0123 if app_base == ml.NRF52_APP_BASE_S140_V7 else 0x00B6 + struct.pack_into("<8sHHIHHIHHI", image, offset + ml.XIAO_BOOT_MANIFEST_SIZE, + ml.BOOT_CONTINUITY_MAGIC, ml.BOOT_CONTINUITY_VERSION, + ml.BOOT_CONTINUITY_SIZE, version, + family, fwid, app_base, layout_abi, 0, 0) + + +def _xiao_bootloader_image(board_id=ml.XIAO_BOOT_BOARD_ID_BASE, + boot_version=0x0117010D): import zlib image = bytearray(b"\xff" * ml.XIAO_BOOT_IMAGE_SIZE) struct.pack_into("