Harden signed SD and bootloader OTA

This commit is contained in:
mikecarper
2026-08-21 12:15:54 -07:00
parent 3d78d9c846
commit d9f5dfa8a2
29 changed files with 1846 additions and 408 deletions
+46
View File
@@ -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"
+5
View File
@@ -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
+5
View File
@@ -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 |
+57 -33
View File
@@ -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
+64 -24
View File
@@ -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).
+7 -3
View File
@@ -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
+44 -12
View File
@@ -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_<BOARD_ID>_<DEVICE_NAME>` for a generic target;
- a sane nRF52840 vector table, exactly one CRC-valid embedded manifest v1 with the exact board/name pair,
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 <N>`, 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 <MID8>`; 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 <hex>] trusted signer allowlist ; bare = list
ota dev ... bring-up helpers (stage/recv/serve/verify)
ota dev ... bring-up helpers (stage/recv/serve/resume <MID8>/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
+36 -14
View File
@@ -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 <MID8>` remains the deliberate override for an older or unsigned partial.
For bring-up/debugging, `ota dev resume <MID8>` 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 <public-key-hex> # 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.
---
+1 -1
View File
@@ -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
+3 -1
View File
@@ -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);
+98 -71
View File
@@ -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 <typename Store>
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<const OtaStore&>(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<const OtaStore&>(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 <typename Store>
static bool external_bootloader_crc_ok(Store& store, uint32_t payload_off,
const OtaBootloaderIdentity& identity) {
uint8_t buf[512];
uint32_t crc = UINT32_MAX;
for (uint32_t off = 0; off < OTA_BOOT_IMAGE_SIZE; off += sizeof(buf)) {
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 <typename Store>
static bool external_bootloader_image_metadata(Store& store, uint32_t payload_off,
OtaBootloaderIdentity& candidate,
OtaBootloaderCapsMarker& caps) {
// Keep enough overlap to catch a 44-byte manifest or 16-byte capability marker split across a
// window. Every aligned manifest candidate is considered; a magic string in a literal pool is not
// a terminal failure.
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;
+14 -9
View File
@@ -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;
}
+255 -10
View File
@@ -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 <typename Store>
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 <typename Store>
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,
+19 -3
View File
@@ -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 <mid8>");
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;
+25 -3
View File
@@ -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 <MID8> <HASH16>", 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
+6
View File
@@ -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)
+52 -4
View File
@@ -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.
+21 -6
View File
@@ -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)
+112
View File
@@ -0,0 +1,112 @@
#pragma once
#include <stddef.h>
#include <stdint.h>
#include <string.h>
// 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
-64
View File
@@ -1,64 +0,0 @@
#pragma once
#include <stdint.h>
#include <stddef.h>
// 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
+68 -84
View File
@@ -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<volatile uint8_t*>(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<volatile uint8_t*>(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;
+6 -9
View File
@@ -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};
};
+15
View File
@@ -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);
+2
View File
@@ -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); }
+539 -5
View File
@@ -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<uint8_t> 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<OTA_BOOT_IMAGE_SIZE> 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<uint8_t> 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<uint8_t, MOTA_MFL> 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<uint8_t, MOTA_MFL> 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<uint8_t> 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<uint8_t, MOTA_MFL> 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;
-20
View File
@@ -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));
}
+128 -18
View File
@@ -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("<HH", ext, 8)
boot_version, family, fwid, app_base, layout_abi, compat, reserved = \
struct.unpack_from("<IHHIHHI", ext, 12)
if (ext_version != BOOT_CONTINUITY_VERSION or ext_size != BOOT_CONTINUITY_SIZE or
not bootloader_version_valid(boot_version) or family == 0 or fwid == 0 or
app_base == 0 or layout_abi == 0 or compat != 0 or reserved != 0):
return None
return BootloaderIdentity(found.manifest_offset, found.image_start, found.image_size,
found.board_id, found.device_name, found.crc32,
boot_version, family, fwid, app_base, layout_abi, compat)
def parse_xiao_bootloader_identity(image: bytes) -> 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}")
+217 -14
View File
@@ -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("<II", image, 0, 0x20040000, ml.XIAO_BOOT_IMAGE_START + 0x101)
@@ -216,16 +229,19 @@ def _xiao_bootloader_image(board_id=ml.XIAO_BOOT_BOARD_ID_BASE):
ml.BOOT_FORMAT_VER, ml.BOOT_REQUIRED_APP_CODEC_MASK,
ml.BOOT_STORAGE_QSPI_UPDATE)
name = ml.XIAO_BOOT_DEVICE_NAME
struct.pack_into("<8sHHIII16sI", image, 0x100, ml.XIAO_BOOT_MANIFEST_MAGIC,
off = ml.BOOT_CANDIDATE_MANIFEST_OFFSET
struct.pack_into("<8sHHIII16sI", image, off, ml.XIAO_BOOT_MANIFEST_MAGIC,
ml.XIAO_BOOT_MANIFEST_VERSION, ml.XIAO_BOOT_MANIFEST_SIZE,
ml.XIAO_BOOT_IMAGE_START, ml.XIAO_BOOT_IMAGE_SIZE, board_id, name, 0)
_write_boot_continuity(image, off, version=boot_version)
crc = zlib.crc32(image) & 0xFFFFFFFF
struct.pack_into("<I", image, 0x100 + 40, crc)
struct.pack_into("<I", image, off + 40, crc)
return bytes(image)
def _generic_bootloader_image(board_id=0x239A0029, device_name="3401_DFU",
storage=ml.BOOT_STORAGE_INTERNAL_UPDATE):
storage=ml.BOOT_STORAGE_INTERNAL_UPDATE,
boot_version=0x0117010D):
import zlib
image = bytearray(b"\xff" * ml.XIAO_BOOT_IMAGE_SIZE)
struct.pack_into("<II", image, 0, 0x20040000, ml.XIAO_BOOT_IMAGE_START + 0x101)
@@ -233,13 +249,27 @@ def _generic_bootloader_image(board_id=0x239A0029, device_name="3401_DFU",
ml.BOOT_FORMAT_VER, ml.BOOT_REQUIRED_APP_CODEC_MASK,
storage)
name = device_name.encode("ascii").ljust(16, b"\0")
struct.pack_into("<8sHHIII16sI", image, 0x100, ml.XIAO_BOOT_MANIFEST_MAGIC,
off = ml.BOOT_CANDIDATE_MANIFEST_OFFSET
struct.pack_into("<8sHHIII16sI", image, off, ml.XIAO_BOOT_MANIFEST_MAGIC,
ml.XIAO_BOOT_MANIFEST_VERSION, ml.XIAO_BOOT_MANIFEST_SIZE,
ml.XIAO_BOOT_IMAGE_START, ml.XIAO_BOOT_IMAGE_SIZE, board_id, name, 0)
struct.pack_into("<I", image, 0x100 + 40, zlib.crc32(image) & 0xFFFFFFFF)
_write_boot_continuity(image, off, version=boot_version,
app_base=ml.NRF52_APP_BASE_S140_V6)
struct.pack_into("<I", image, off + 40, zlib.crc32(image) & 0xFFFFFFFF)
return bytes(image)
def _rewrite_boot_image(image, mutate):
import zlib
rewritten = bytearray(image)
mutate(rewritten)
off = ml.BOOT_CANDIDATE_MANIFEST_OFFSET
rewritten[off + 40:off + 44] = b"\0" * 4
struct.pack_into("<I", rewritten, off + 40,
zlib.crc32(rewritten) & 0xFFFFFFFF)
return bytes(rewritten)
def _xiao_manifest_crc(image, offset):
import zlib
trial = bytearray(image)
@@ -294,7 +324,8 @@ def _set_two_valid_xiao_manifest_crcs(image, first, second):
def test_xiao_bootloader_identity_skips_bad_decoy_and_rejects_two_valid_manifests():
real_offset, decoy_offset, second_offset = 0x100, 0x20, 0x200
real_offset = ml.BOOT_CANDIDATE_MANIFEST_OFFSET
decoy_offset, second_offset = 0x20, 0x200
image = bytearray(_xiao_bootloader_image())
image[decoy_offset:decoy_offset + ml.XIAO_BOOT_MANIFEST_SIZE] = \
image[real_offset:real_offset + ml.XIAO_BOOT_MANIFEST_SIZE]
@@ -319,6 +350,37 @@ def test_xiao_bootloader_identity_skips_bad_decoy_and_rejects_two_valid_manifest
except ValueError:
pass
# Base identity counting happens before adjacent continuity parsing. A
# CRC-valid decoy cannot hide merely by claiming one corrupt BLM2 magic;
# this must match deployed legacy-updater duplicate semantics.
corrupt_extension = bytearray(_xiao_bootloader_image())
corrupt_extension[second_offset:second_offset + ml.XIAO_BOOT_MANIFEST_SIZE] = \
corrupt_extension[real_offset:real_offset + ml.XIAO_BOOT_MANIFEST_SIZE]
corrupt_extension[second_offset + ml.XIAO_BOOT_MANIFEST_SIZE:
second_offset + ml.XIAO_BOOT_MANIFEST_SIZE + 8] = b"BLM2BAD!"
_set_two_valid_xiao_manifest_crcs(corrupt_extension, real_offset, second_offset)
assert ml.parse_xiao_bootloader_identity(bytes(corrupt_extension)) is None
try:
ml.validate_xiao_bootloader_image(bytes(corrupt_extension))
assert False, "CRC-valid identity with corrupt BLM2 decoy was hidden"
except ValueError:
pass
# The same malformed extension does not matter when the associated base
# CRC is invalid; the one canonical CRC-valid identity remains usable.
bad_crc_extension = bytearray(_xiao_bootloader_image())
bad_crc_extension[second_offset:second_offset + ml.XIAO_BOOT_MANIFEST_SIZE] = \
bad_crc_extension[real_offset:real_offset + ml.XIAO_BOOT_MANIFEST_SIZE]
bad_crc_extension[second_offset + ml.XIAO_BOOT_MANIFEST_SIZE:
second_offset + ml.XIAO_BOOT_MANIFEST_SIZE + 8] = b"BLM2BAD!"
struct.pack_into("<I", bad_crc_extension, second_offset + 40, 0xA5A5A5A5)
struct.pack_into("<I", bad_crc_extension, real_offset + 40,
_xiao_manifest_crc(bad_crc_extension, real_offset))
assert _xiao_manifest_crc(bad_crc_extension, second_offset) != 0xA5A5A5A5
parsed = ml.parse_xiao_bootloader_identity(bytes(bad_crc_extension))
assert parsed is not None and parsed.manifest_offset == real_offset
ml.validate_xiao_bootloader_image(bytes(bad_crc_extension))
def test_xiao_bootloader_caps_rejects_malformed_or_unaligned_markers():
def caps(*, offset=0, abi=ml.BOOT_FORMAT_VER,
@@ -342,6 +404,18 @@ def test_xiao_bootloader_caps_rejects_malformed_or_unaligned_markers():
ml.BOOT_FORMAT_VER, ml.BOOT_REQUIRED_APP_CODEC_MASK,
ml.BOOT_STORAGE_INTERNAL_UPDATE, b"\0\0\0")
assert ml.bootloader_caps_storage(bytes(duplicate)) is None
privileged_ambiguous = bytearray(caps())
struct.pack_into("<8sHHB3s", privileged_ambiguous, 24,
ml.XIAO_BOOT_CAPS_MAGIC, ml.BOOT_FORMAT_VER,
ml.BOOT_REQUIRED_APP_CODEC_MASK,
ml.XIAO_BOOT_STORAGE_UPDATE, b"\0\0\0")
assert ml.bootloader_caps_storage(bytes(privileged_ambiguous)) is None
malformed_decoy = bytearray(caps())
struct.pack_into("<8sHHB3s", malformed_decoy, 24,
ml.XIAO_BOOT_CAPS_MAGIC, ml.BOOT_FORMAT_VER,
ml.BOOT_REQUIRED_APP_CODEC_MASK, 0x1C, b"\0\0\0")
assert ml.bootloader_caps_storage(bytes(malformed_decoy)) == \
ml.BOOT_STORAGE_QSPI_UPDATE
def test_bootloader_v3_build_parse_and_strict_contract():
@@ -350,7 +424,7 @@ def test_bootloader_v3_build_parse_and_strict_contract():
identity = ml.validate_xiao_bootloader_image(image, ml.XIAO_BOOT_BOARD_ID_BASE)
assert identity.board_id == ml.XIAO_BOOT_BOARD_ID_BASE
m = ml.build_manifest(
target_id=identity.board_id, fw_version=ml.pack_version("1.0.0"),
target_id=identity.board_id, fw_version=identity.boot_version,
image_size=len(image), payload=image, block_size=1024,
image_hash=ml.mh32(image), codec_id=ml.CODEC_FULL, is_full=True,
sign_priv=priv, bootloader=True)
@@ -394,7 +468,7 @@ def test_generic_internal_bootloader_build_parse_and_strict_contract():
assert ml.bootloader_hw_id(identity.board_id, identity.device_name) == expected_hw
manifest = ml.build_manifest(
target_id=target, fw_version=ml.pack_version("1.0.0"),
target_id=target, fw_version=identity.boot_version,
image_size=len(image), payload=image, block_size=1024,
image_hash=ml.mh32(image), codec_id=ml.CODEC_FULL, is_full=True,
sign_priv=priv, bootloader=True)
@@ -425,7 +499,7 @@ def test_meshtower_sd_bootloader_profile_builds_the_same_exact_identity():
target = ml.bootloader_target_id(identity.board_id, identity.device_name)
assert target == 0x1150F50E
manifest = ml.build_manifest(
target_id=target, fw_version=ml.pack_version("1.0.0"),
target_id=target, fw_version=identity.boot_version,
image_size=len(image), payload=image, block_size=1024,
image_hash=ml.mh32(image), codec_id=ml.CODEC_FULL, is_full=True,
sign_priv=priv, bootloader=True)
@@ -442,6 +516,11 @@ def test_meshtower_sd_bootloader_build_flag_is_exact_target_only():
section = section.split("[env:", 1)[0]
assert "-D OTA_SD_STORE=1" in section
assert "-D OTA_SD_BOOTLOADER_UPDATE=1" in section
store = (root / "src/helpers/ota/OtaStoreSdNrf52.cpp").read_text(encoding="utf-8")
assert "readSector(" not in store
assert "writeSector(" not in store
assert "OtaSdHandoff" not in store
assert not (root / "src/helpers/ota/OtaSdHandoff.h").exists()
def test_bootloader_build_inventory_is_unique_and_disjoint_from_app_targets():
@@ -491,10 +570,83 @@ def test_bootloader_build_inventory_is_unique_and_disjoint_from_app_targets():
pass
def test_qualified_bootloader_platform_and_storage_profiles_are_exact():
qualified = (
(ml.XIAO_BOOT_BOARD_ID_BASE, "XIAO_DFU"),
(ml.XIAO_BOOT_BOARD_ID_SENSE, "XIAO_DFU"),
) + ml.INTERNAL_BOOTLOADER_IDENTITIES
for board_id, device_name in qualified:
family, fwid, app_base, layout_abi = \
ml.bootloader_qualified_platform_profile(board_id, device_name)
storages = ml.bootloader_qualified_storage_profiles(board_id, device_name)
assert family == ml.BOOT_CONTINUITY_FAMILY_S140
assert layout_abi == ml.BOOT_CONTINUITY_LAYOUT_ABI
if (board_id, device_name) in ml.BOOTLOADER_S140_V7_IDENTITIES:
assert (fwid, app_base) == (ml.BOOTLOADER_S140_V7_FWID,
ml.NRF52_APP_BASE_S140_V7)
else:
assert (fwid, app_base) == (ml.BOOTLOADER_S140_V6_FWID,
ml.NRF52_APP_BASE_S140_V6)
for storage in storages:
if board_id in (ml.XIAO_BOOT_BOARD_ID_BASE,
ml.XIAO_BOOT_BOARD_ID_SENSE):
image = _xiao_bootloader_image(board_id)
else:
image = bytearray(_generic_bootloader_image(
board_id=board_id, device_name=device_name, storage=storage))
off = ml.BOOT_CANDIDATE_MANIFEST_OFFSET
_write_boot_continuity(image, off, fwid=fwid,
app_base=app_base)
image = _rewrite_boot_image(bytes(image), lambda raw: None)
parsed = ml.validate_bootloader_image(image)
assert (parsed.softdevice_family, parsed.softdevice_fwid,
parsed.app_base, parsed.layout_abi) == \
(family, fwid, app_base, layout_abi)
assert ml.bootloader_caps_storage(image) == storage
# Every individually well-formed but wrong continuity field must fail the
# qualified signing validator, for both v6 and v7 inventory profiles.
for image in (_generic_bootloader_image(), _xiao_bootloader_image()):
off = ml.BOOT_CANDIDATE_MANIFEST_OFFSET
for field_offset, field_format, wrong in (
(60, "<H", 141),
(62, "<H", 0xBEEF),
(64, "<I", 0x28000),
(68, "<H", 2),
):
invalid = _rewrite_boot_image(
image, lambda raw, at=field_offset, fmt=field_format, value=wrong:
struct.pack_into(fmt, raw, off + at, value))
try:
ml.validate_bootloader_image(invalid)
assert False, f"qualified image accepted wrong continuity field +{field_offset}"
except ValueError:
pass
# Capability storage is part of the profile too. Tower alone admits both
# internal and SD; QSPI is XIAO-only and non-Tower internals cannot claim SD.
for invalid in (
_generic_bootloader_image(storage=ml.BOOT_STORAGE_SD_UPDATE),
_generic_bootloader_image(board_id=0x239A0071,
device_name="TOWER_V2_OTA",
storage=ml.BOOT_STORAGE_QSPI_UPDATE),
_rewrite_boot_image(
_xiao_bootloader_image(),
lambda raw: raw.__setitem__(0x80 + 12,
ml.BOOT_STORAGE_INTERNAL_UPDATE)),
):
try:
ml.validate_bootloader_image(invalid)
assert False, "qualified identity accepted wrong storage profile"
except ValueError:
pass
def test_bootloader_builder_rejects_wrong_identity_geometry_and_continuity():
priv = Ed25519PrivateKey.generate()
image = _xiao_bootloader_image()
kwargs = dict(target_id=ml.XIAO_BOOT_BOARD_ID_BASE, fw_version=1,
kwargs = dict(target_id=ml.XIAO_BOOT_BOARD_ID_BASE,
fw_version=ml.validate_xiao_bootloader_image(image).boot_version,
image_size=len(image), payload=image, block_size=1024,
image_hash=ml.mh32(image), codec_id=ml.CODEC_FULL,
is_full=True, sign_priv=priv, bootloader=True)
@@ -516,11 +668,62 @@ def test_bootloader_builder_rejects_wrong_identity_geometry_and_continuity():
assert False, "bad embedded CRC accepted"
except ValueError:
pass
missing_extension = _rewrite_boot_image(
image, lambda raw: raw.__setitem__(
slice(ml.BOOT_CANDIDATE_MANIFEST_OFFSET + 44,
ml.BOOT_CANDIDATE_MANIFEST_OFFSET + 76), b"\xff" * 32))
half_extension = _rewrite_boot_image(
image, lambda raw: raw.__setitem__(
slice(ml.BOOT_CANDIDATE_MANIFEST_OFFSET + 48,
ml.BOOT_CANDIDATE_MANIFEST_OFFSET + 52), b"FAIL"))
low_byte_zero = _rewrite_boot_image(
image, lambda raw: struct.pack_into(
"<I", raw, ml.BOOT_CANDIDATE_MANIFEST_OFFSET + 56, 0x02040100))
all_ones = _rewrite_boot_image(
image, lambda raw: struct.pack_into(
"<I", raw, ml.BOOT_CANDIDATE_MANIFEST_OFFSET + 56, 0xFFFFFFFF))
for invalid, why in (
(missing_extension, "missing continuity extension"),
(half_extension, "half-present continuity extension"),
(low_byte_zero, "invalid preview zero"),
(all_ones, "un-upgradable all-ones version"),
):
try:
ml.validate_xiao_bootloader_image(invalid)
assert False, f"accepted {why}"
except ValueError:
pass
import zlib
wrong_offset = bytearray(image)
canonical = ml.BOOT_CANDIDATE_MANIFEST_OFFSET
relocated = 0x8000
envelope = bytes(wrong_offset[canonical:canonical + ml.BOOT_ENVELOPE_SIZE])
wrong_offset[canonical:canonical + ml.BOOT_ENVELOPE_SIZE] = \
b"\xff" * ml.BOOT_ENVELOPE_SIZE
wrong_offset[relocated:relocated + ml.BOOT_ENVELOPE_SIZE] = envelope
wrong_offset[relocated + 40:relocated + 44] = b"\0" * 4
struct.pack_into("<I", wrong_offset, relocated + 40,
zlib.crc32(wrong_offset) & 0xFFFFFFFF)
parsed_relocated = ml.parse_xiao_bootloader_identity(bytes(wrong_offset))
assert parsed_relocated is not None and parsed_relocated.manifest_offset == relocated
try:
ml.validate_xiao_bootloader_image(bytes(wrong_offset))
assert False, "otherwise-valid BLM2 envelope at a noncanonical offset accepted"
except ValueError:
pass
try:
ml.build_manifest(**(kwargs | {"fw_version": kwargs["fw_version"] + 1}))
assert False, "outer version differing from embedded BLM2 accepted"
except ValueError:
pass
wrong_name = bytearray(image)
wrong_name[0x100 + 24:0x100 + 40] = b"FRIENDLY NAME".ljust(16, b"\0")
wrong_name[0x100 + 40:0x100 + 44] = b"\0" * 4
struct.pack_into("<I", wrong_name, 0x100 + 40, zlib.crc32(wrong_name) & 0xFFFFFFFF)
off = ml.BOOT_CANDIDATE_MANIFEST_OFFSET
wrong_name[off + 24:off + 40] = b"FRIENDLY NAME".ljust(16, b"\0")
wrong_name[off + 40:off + 44] = b"\0" * 4
struct.pack_into("<I", wrong_name, off + 40, zlib.crc32(wrong_name) & 0xFFFFFFFF)
try:
ml.validate_xiao_bootloader_image(bytes(wrong_name))
assert False, "noncanonical embedded device name accepted"
+1
View File
@@ -53,6 +53,7 @@ build_flags =
; packages when OTAFIX advertises the matching SD self-update profile.
[Heltec_tower_v2_sdcard]
extends = Heltec_tower_v2
board_build.ldscript = boards/nrf52840_s140_v6_sd_ota.ld
build_flags =
${Heltec_tower_v2.build_flags}
-D HELTEC_TOWER_V2_SDCARD=1