Add signed XIAO bootloader updates over LoRa

This commit is contained in:
mikecarper committed 2026-08-20 22:05:11 -07:00
1 parent 9aae602023
commit 75a2b8f002
34 files changed
+1604 -78

No files matched your search

@@ -0,0 +1,53 @@
/* XIAO nRF52840 LoRa bootloader-update layout.
*
* The application ends at 0xE0000. OTAFIX owns 0xE0000..0xEA000 as a 40 KiB internal scratch bank
* while replacing its own padded 0xF4000..0xFE000 bootloader region from the verified QSPI package.
*/
SEARCH_DIR(.)
GROUP(-lgcc -lc -lnosys)
MEMORY
{
FLASH (rx) : ORIGIN = 0x27000, LENGTH = 0xE0000 - 0x27000
/* To keep data in RAM across resets */
PERSISTENT_RAM (rwx) : ORIGIN = 0x20006000, LENGTH = 8
/* SRAM required by SoftDevice depend on
* - Attribute Table Size (Number of Services and Characteristics)
* - Vendor UUID count
* - Max ATT MTU
* - Concurrent connection peripheral + central + secure links
* - Event Len, HVN queue, Write CMD queue
*/
RAM (rwx) : ORIGIN = 0x20006000 + 8, LENGTH = 0x20040000 - 0x20006000 - 8
}
SECTIONS
{
. = ALIGN(4);
.persistent (NOLOAD) :
{
KEEP(*(.persistent_magic))
KEEP(*(.persistent_data))
. = ALIGN(4);
} > PERSISTENT_RAM
. = 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"
+2
View File
@@ -166,6 +166,7 @@ fix, no WiFi connection, an inactive bridge, or an nRF52 bootloader without
| LoRa OTA | [`ota get`; `ota pull`; `ota download`](ota_protocol.md#11-cli-surface-otaclicpp) | LoRa OTA build; internal-store nRF52 installs in-place deltas, while matched SD/QSPI nRF52 also accepts full images | No | No | Yes |
| LoRa OTA | [`ota install`; `ota apply`; `ota applydelta`](ota_protocol.md#11-cli-surface-otaclicpp) | Compatible bootloader and completed update | No | No | Yes |
| LoRa OTA | [`ota rescue install <base_hash16>`](ota_protocol.md#12-apply-bootloader-contract) | Internal-flash nRF52 LoRa OTA build with failed app-side EndF validation | No | No | Feature |
| LoRa OTA | [`ota bootloader`; `ota bootloader install <MID8> <HASH16>`](ota_nrf52_qspi.md#explicit-xiao-bootloader-updates-over-lora) | Boot-update-capable XIAO-module QSPI repeater with ABI-3 exact-board OTAFIX | No | No | Feature |
| LoRa OTA | [`ota cancel`; `ota drop`; `ota stop`](ota_protocol.md#11-cli-surface-otaclicpp) | LoRa OTA build | No | No | Yes |
| LoRa OTA | [`ota announce`; `ota adv`](ota_protocol.md#11-cli-surface-otaclicpp) | LoRa OTA build | No | No | Yes |
| LoRa OTA | [`ota self`; `ota id`](ota_protocol.md#11-cli-surface-otaclicpp) | Firmware with EndF trailer | No | No | Yes |
@@ -325,6 +326,7 @@ fix, no WiFi connection, an inactive bridge, or an nRF52 bootloader without
| LoRa OTA | [`ota get`; `ota pull`; `ota download`](ota_protocol.md#11-cli-surface-otaclicpp) | LoRa OTA build | No | No | Yes | Yes | Yes |
| LoRa OTA | [`ota install`; `ota apply`; `ota applydelta`](ota_protocol.md#11-cli-surface-otaclicpp) | Compatible completed update | No | No | Yes | Yes | Yes |
| LoRa OTA | [`ota rescue install <base_hash16>`](ota_protocol.md#12-apply-bootloader-contract) | Internal-flash nRF52 LoRa OTA build with failed app-side EndF validation | No | No | Feature | No | No |
| LoRa OTA | [`ota bootloader`; `ota bootloader install <MID8> <HASH16>`](ota_nrf52_qspi.md#explicit-xiao-bootloader-updates-over-lora) | nRF52 XIAO QSPI only | No | No | No | No | No |
| LoRa OTA | [`ota cancel`; `ota drop`; `ota stop`](ota_protocol.md#11-cli-surface-otaclicpp) | LoRa OTA build | No | No | Yes | Yes | Yes |
| LoRa OTA | [`ota announce`; `ota adv`](ota_protocol.md#11-cli-surface-otaclicpp) | LoRa OTA build | No | No | Yes | Yes | Yes |
| LoRa OTA | [`ota self`; `ota id`](ota_protocol.md#11-cli-surface-otaclicpp) | Firmware with EndF trailer | No | No | Yes | Yes | Yes |
+11 -5
View File
@@ -13,8 +13,13 @@ the right container, move the participating nodes to a temporary radio
channel, serve and monitor the download, request installation, restore the
controller, and check the rebooted node.
The script cannot install the destination's first OTA-capable firmware or its
nRF52 bootloader. Do those one-time jobs over USB before using LoRa OTA.
The script cannot install the destination's first OTA-capable firmware. It is
also deliberately application-only: it rejects both v3 bootloader containers
and a v2 container carrying the bootloader flag, and it never sends the
privileged install command. Do the first nRF52 bootloader installation over USB;
an already-capable XIAO QSPI repeater may later use the separate, manual
[`ota bootloader install`](ota_nrf52_qspi.md#explicit-xiao-bootloader-updates-over-lora)
workflow.
## Required topology
@@ -425,9 +430,10 @@ the destination.
2. Authenticate to the target and query its target ID, hardware, running body
hash, firmware version, bootloader version, and nRF52 bootloader
capabilities.
3. Select or build one compatible mOTA and verify all block hashes, Merkle
root, full-image hash where applicable, identity fields, signature, codec,
base, and the firmware's 1024-byte maximum block size.
3. Select or build one compatible **v2 application** mOTA and verify all block
hashes, Merkle root, full-image hash where applicable, identity fields,
signature, codec, base, and the firmware's 1024-byte maximum block size.
Version-3 bootloader packages are refused before any target state changes.
4. Save the controller's normal radio tuple and show the confirmation prompt.
5. Start TempRadio on the target, then far-to-near relays, then the source;
finally switch the controller to the same tuple and read it back. The runner
+4
View File
@@ -333,6 +333,10 @@ Discovery is asynchronous. `ota ls` says `refreshing`; wait a few seconds and ru
shows an older row. Select `[same target]`: it can say `full` for ESP32 and external SD/QSPI nRF52 targets,
or `delta` for any supported nRF52 target. Do not select `[unsupported]` (for example, a source's self-served
full image on an internal-staging nRF52).
Rows labelled `bootloader` are also outside this ordinary application flow:
they are never automatic and `ota install` refuses them. Only an already-capable
XIAO QSPI repeater can use the separate
[explicit bootloader workflow](ota_nrf52_qspi.md#explicit-xiao-bootloader-updates-over-lora).
Use the row's stable eight-hex manifest ID rather than its changing list position:
```text
+62 -5
View File
@@ -13,8 +13,9 @@ an update is downloaded.
## Supported repeater families
QSPI staging is enabled only for repeater-role environments on these currently
matched families:
QSPI staging is enabled only for explicitly matched repeater-role environments
(and RS232 bridge variants that directly inherit one of those repeater targets)
on these currently matched families:
- Seeed XIAO nRF52840 and XIAO nRF52840 Sense modules, including the
`Xiao_nrf52`, SolarXiao 30S/33S, and XIAO-module Ikoka handheld, Nano, and
@@ -40,8 +41,8 @@ T-Echo target.
Companion builds are intentionally excluded. Some companion targets use the
same external QSPI as a LittleFS message/data store, where raw OTA staging
would corrupt the filesystem; other companions simply do not assign that chip
to OTA. Room-server, sensor, KISS, and repeater-bridge roles are also unchanged.
Raw QSPI OTA is scoped to the explicitly matched repeater environments.
to OTA. Room-server, sensor, and KISS roles are unchanged. Raw QSPI OTA is
scoped to the explicitly matched repeater-derived environments.
### RAK15001 placement and module conflicts
@@ -135,13 +136,21 @@ starting another pull; later capacity probes preserve that failure detail.
The store reads the JEDEC capacity at runtime and accepts supported 1 MiB
through 16 MiB devices using 24-bit addressing. QSPI capacity is not the final
firmware limit. The reconstructed application, including its 56-byte `EndF`
trailer, must fit below InternalFS at `0xED000`:
trailer, must fit its build's linked application region:
| SoftDevice layout | Application region | Maximum image |
| --- | --- | --- |
| S140 v7, boot-update-capable XIAO module | `0x27000..0xE0000` | `0xB9000` (757,760 bytes) |
| S140 v7, app base `0x27000` | `0x27000..0xED000` | `0xC6000` (811,008 bytes) |
| S140 v6, app base `0x26000` | `0x26000..0xED000` | `0xC7000` (815,104 bytes) |
The XIAO limit is deliberately 52 KiB smaller. Its linker and post-link record
cap every ordinary application at `0xE0000`; OTAFIX reserves
`0xE0000..0xEA000` as a 40 KiB self-update scratch bank, with the remaining gap
left unused before InternalFS. Full and delta application packages for these
targets derive their effective application end from that `0xE0000` record and
are rejected if their reconstructed image or detools geometry crosses it.
A full package needs only the new raw `firmware.hex` or non-merged application
image. An in-place delta still needs the exact image currently running. The
automation uses a conservative `0xC6000` detools workspace for external nRF52
@@ -165,6 +174,54 @@ operator can deliberately override the routing target for a role change, so
`target_id` is not an apply-time safety assertion; the destination still
enforces the package's hardware identity before approval.
## Explicit XIAO bootloader updates over LoRa
Bootloader delivery is available only when all of these are already true:
- the application is an `OTA_QSPI_BOOTLOADER_UPDATE` XIAO-module repeater build
linked below `0xE0000`;
- `ota bootloader` shows a CRC-valid installed `XIAO_DFU` embedded identity for
board ID `28860044` (base) or `28860045` (Sense);
- the installed OTAFIX marker reports ABI 3 or newer and both QSPI (`0x04`) and
boot-update (`0x08`) capability bits;
- the package is the exact signed v3/40-KiB/1-KiB-block profile for that board,
and its signer is already in `ota key`'s trusted allowlist.
This is not part of normal automation. Bootloader catalog rows are labelled
`bootloader`; even a capable node never autofetches or autoinstalls them, and
ordinary `ota install` rejects them in every application backend. The
`tools/lora_ota` deployment runner also refuses v3 packages. Fetch and arm one
only with the explicit flow:
```text
ota ls
ota pull <MID8> flash
# wait for ota status to report the same bootloader download as ready
ota bootloader
# copy the exact mid= and hash= values printed above
ota bootloader install <MID8> <HASH16>
```
The pull command is only transport intent; it does not authorize an install.
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.
After the reply drains, GPREGRET `0x6B` and GPREGRET2 `0x51` enter the special
OTAFIX path. OTAFIX independently rechecks the package before using the
reserved scratch bank to replace its own `0xF4000..0xFE000` region. The running
application remains preserved; a rejected candidate returns to it unchanged.
Post-reboot `ota status` reports bootloader-update diagnostics as `blup:C0` to
`blup:CF` (`blup:C8` is success), separately from ordinary application `blrc`.
This feature cannot update a stock or old bootloader that lacks the capability
marker: perform the first exact-board combined bootloader+SoftDevice migration
over USB/BLE DFU or SWD. Do not attempt to bootstrap it with a v3 package.
## Storage ownership and recovery
The QSPI store is raw, not a file inside LittleFS. It owns the flash from
+50 -10
View File
@@ -61,11 +61,13 @@ the authoritative reference for byte-level details.
| Container `TRAILER` | `76 6B 34 39 36` | `vk496` |
| `EndF` marker | `45 6E 64 46` | `EndF` |
| `hash_algo` (sha2-256) | `0x12` | multihash code |
| `format_ver` | `0x02` | this spec |
| application `format_ver` | `0x02` | ordinary full/delta application package |
| bootloader `format_ver` | `0x03` | privileged XIAO bootloader package only |
| `approval` = not approved | `FF FF FF FF` | erased NOR word |
| `approval` = approved | `41 50 52 56` | `APRV` |
| `MFLAG_FULL` | `0x01` | flags bit0 |
| `MFLAG_SIGNED` | `0x02` | flags bit1 |
| `MFLAG_BOOTLOADER` | `0x04` | flags bit2; valid only in the exact v3 bootloader profile |
| `CODEC_FULL` / `_SEQUENTIAL` / `_INPLACE` | `0` / `1` / `2` | Section 5 |
| `PAYLOAD_TYPE_OTA` | `0x0C` | MeshCore packet type (`src/Packet.h`) |
| `MAX_PACKET_PAYLOAD` | `184` | usable bytes per packet (`src/MeshCore.h`) |
@@ -209,8 +211,8 @@ Only `leaves[]` is variable (one 4-byte hash per block). So the manifest-minus-l
```
off size field notes
0 1 format_ver = 0x02
1 1 flags bit0 FULL (0=delta/partial, 1=full image); bit1 SIGNED; bits2-7 reserved 0
0 1 format_ver = 0x02 application, or 0x03 privileged bootloader package
1 1 flags bit0 FULL; bit1 SIGNED; bit2 BOOTLOADER; bits3-7 reserved 0
2 1 hash_algo 0x12 = sha2-256
3 4 target_id device/arch/role discriminator (Section 9)
7 4 fw_version MAJOR<<24 | MINOR<<16 | PATCH<<8 | pre (comparable uint32)
@@ -237,6 +239,11 @@ Manifest-minus-leaves size (`mfl`) is a constant **197 bytes** for every contain
or unsigned). At 197 bytes the manifest exceeds one packet, so `OTA_MANIFEST` is always sent multi-fragment
(Section 8.4, 2 fragments) and reassembled by the fetcher.
The two versions are deliberately disjoint. Version 2 accepts application packages only and rejects the
BOOTLOADER bit. Version 3 accepts only flags exactly `FULL|SIGNED|BOOTLOADER`; a non-bootloader v3 package
is invalid. Consequently, deployed v2-only application parsers reject a bootloader package before they can
mistake its raw 40 KiB payload for an application image.
### 4.1 Signed region
`signature` covers manifest bytes `[0, 129)` - the head + `base_hash` + `signer_pubkey`. It does **not**
@@ -253,6 +260,23 @@ cover `approval` or `leaves[]`:
- Bound to this image (lives in this `.mota`'s manifest, re-erased when a new `.mota` is staged).
- A **consent** marker, not a security primitive. Authenticity = `signature` + `image_hash` + `hw_id`.
### 4.3 Privileged XIAO bootloader package profile
A v3 bootloader package has a deliberately narrow, non-extensible profile:
- flags exactly `FULL|SIGNED|BOOTLOADER`, `CODEC_FULL`, nonzero `fw_version`, zero `base_hash`;
- a raw payload and `image_size` of exactly `0xA000` (40 KiB), split into exactly forty 1024-byte blocks;
- `target_id` equal to the installed OTAFIX embedded board ID (`0x28860044` for XIAO nRF52840 or
`0x28860045` for XIAO nRF52840 Sense);
- signed `hw_id` exactly `XIAO_BL_28860044` or `XIAO_BL_28860045`, derived from that board ID;
- a sane nRF52840 vector table, a CRC-valid embedded manifest v1 with the exact padded device name
`XIAO_DFU`, and a `MOTABLDR` marker advertising ABI >= 3, full codec, QSPI, and boot-update continuity.
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
application packages, there is no unsigned manual-install exception.
---
## 5. Payload, codecs & delta base
@@ -746,6 +770,9 @@ ota get | pull | download <mid8|#index> flash [rescue] | folder [validate]
fetch by stable mid8 (preferred) or current page index
ota install | apply | applydelta verify + approve + (ESP32) apply / (nRF52) reboot-to-bootloader
ota rescue install <base_hash16> internal-flash nRF52 only: recover from failed app-side EndF validation
ota bootloader [status] capable XIAO QSPI repeater: installed BL identity/caps + staged confirmation
ota bootloader install <MID8> <HASH16>
explicitly verify/arm one complete trusted v3 package; never automatic
ota cancel | drop | stop drop the current fetch session (frees the slot)
ota announce | adv serve self + send a beacon now
ota self | id print this firmware's EndF (body/image size, base_hash)
@@ -817,10 +844,22 @@ ota dev ... bring-up helpers (stage/recv/serve/verify)
complete internal application region as workspace. Companion builds never enable this raw store: some use
QSPI as a filesystem, while others simply leave that chip outside OTA ownership. See
[the nRF52 QSPI guide](ota_nrf52_qspi.md).
- **XIAO bootloader self-update (explicit only):** selected XIAO-module QSPI repeater builds link the
ordinary application below `0xE0000`, reserving `0xE0000..0xEA000` as a 40 KiB internal scratch bank.
They accept a v3 bootloader package only through an exact manual MID pull. Ordinary `ota install`,
autofetch, autoinstall, and every application apply backend reject it. The operator then copies the
staged package's exact values from `ota bootloader` into
`ota bootloader install <MID8> <HASH16>`. The app repeats the strict v3 geometry, installed/candidate
identity, vectors, embedded CRC/capabilities, complete payload/image hashes, signature, and trusted-key
gates before writing `APRV`. GPREGRET `0x6B` plus GPREGRET2 `0x51` hands the QSPI package to OTAFIX.
OTAFIX independently repeats all privileged checks, uses the scratch bank to preserve the running
application while replacing `0xF4000..0xFE000`, and reports boot-update results in GPREGRET2 `0xC0..0xCF`
(`0xC8` success). This mechanism cannot bootstrap a stock/older bootloader; install an ABI-3,
boot-update-capable exact-board OTAFIX over USB/BLE DFU or SWD once first.
A signature, when present, proves author authenticity and must pass the device allowlist. Unsigned packages
remain installable when local policy permits them. The one-shot `approval` marker records local consent for
either form before the bootloader may apply it.
A signature, when present, proves author authenticity and must pass the device allowlist. Unsigned v2
application packages remain installable when local policy permits them. A v3 bootloader package is always
signed and trusted. The one-shot `approval` marker records local consent before the bootloader may apply it.
> **Bootloader testing note:** always test apply with a *real different* image (base != target). A same-image
> (X->X) "delta" trivially reproduces the target and gives a false positive.
@@ -829,7 +868,8 @@ either form before the bootloader may apply it.
## 13. Versioning of this spec
`format_ver = 2`. A parser accepts exactly this value and rejects anything else - there is one container
format, fixed-layout, and no compatibility shims to carry. If the format ever needs to change, bump
`format_ver`; the multihash `hash_algo` separately allows swapping the digest family without a format
bump. Unknown `codec_id` / `ota_msg_type` values are ignored (a node simply won't fetch what it can't apply).
The fixed byte layout has two intentionally disjoint profiles: `format_ver = 2` for ordinary application
packages and `format_ver = 3` only for the exact privileged bootloader profile in Section 4.3. A parser must
reject v2+BOOTLOADER, v3 without exact `FULL|SIGNED|BOOTLOADER`, and every other version. The multihash
`hash_algo` separately allows swapping the digest family without a format bump. Unknown `codec_id` /
`ota_msg_type` values are ignored (a node simply will not fetch what it cannot apply).
+34
View File
@@ -216,6 +216,40 @@ rejected; trusted signed images can auto-install only when that policy is enable
After it reboots, run `ota status` to confirm the new version.
### Updating a XIAO bootloader (advanced, explicit only)
This is available only on the specially marked XIAO nRF52840/Sense QSPI
repeater builds after a one-time exact-board ABI-3 OTAFIX installation over
USB/BLE DFU or SWD. It is not the normal firmware update path. Check support
first:
```text
ota bootloader
```
The reply must show a valid `XIAO_DFU` board identity plus ABI 3 and the QSPI /
boot-update capability bits. A bootloader package appears as `bootloader` in
`ota ls`. It is never downloaded or installed automatically, even if both OTA
automation settings are enabled. Use its stable ID explicitly:
```text
ota pull <MID8> flash
# wait until ota status says this bootloader download is ready
ota bootloader
ota bootloader install <MID8> <HASH16>
```
Copy both confirmation values exactly from the second `ota bootloader` reply.
Ordinary `ota install` deliberately refuses this package. The privileged
command requires an exact 40 KiB candidate for the installed XIAO board, a
valid embedded identity/CRC and vector table, continued boot-update support,
and a valid signature from a key already in `ota key`'s trusted allowlist. It
preserves the running application while OTAFIX replaces itself; `blup:C8` in
post-reboot `ota status` means success. Any node lacking this command or those
capabilities must update its bootloader locally instead. See
[the nRF52 QSPI guide](ota_nrf52_qspi.md#explicit-xiao-bootloader-updates-over-lora)
for the complete safety contract.
### 5. If something goes wrong
- A download that stalls or gets interrupted just **resumes** later, or you can `ota cancel` and try again.
+4 -2
View File
@@ -335,7 +335,8 @@ void __attribute__((noinline)) Mesh::serviceLoopMaintenance() {
oc.apply_hard = futureMillis(15000);
} else if (millisHasNowPassed(oc.apply_at) &&
(_mgr->getOutboundTotal() == 0 || millisHasNowPassed(oc.apply_hard))) {
ota::ota_reboot_to_apply(); // does not return
if (oc.bootloader_apply_pending) ota::ota_reboot_to_bootloader_update();
else ota::ota_reboot_to_apply();
}
}
}
@@ -410,7 +411,8 @@ void __attribute__((noinline)) Mesh::serviceLoopMaintenance() {
_ota_autoinstall_tried = false;
} else if (!_ota_autoinstall_tried && !oc.apply_pending
&& oc.autoinstall == ota::OtaContext::AUTOINSTALL_TRUSTED
&& oc.manager.fetched_is_signed()) {
&& oc.manager.fetched_is_signed()
&& !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
+16 -1
View File
@@ -17,8 +17,16 @@ bool MotaManifest::is_approved() const {
// always covers manifest[0, MOTA_SIGNED_LEN). Returns false on any over-read or bad format_ver.
static bool parse_manifest_fields(ByteReader& r, MotaManifest& out) {
out.format_ver = r.u8();
if (out.format_ver != MOTA_FORMAT_VER) return false;
out.flags = r.u8();
// v2 is application-only and v3 is bootloader-only. Requiring exact flags on v3 prevents a
// malformed or downgraded package from entering either apply path.
if (out.format_ver == MOTA_APP_FORMAT_VER) {
if (out.flags & MFLAG_BOOTLOADER || out.flags & ~MFLAG_KNOWN) return false;
} else if (out.format_ver == MOTA_BOOT_FORMAT_VER) {
if (out.flags != (MFLAG_FULL | MFLAG_SIGNED | MFLAG_BOOTLOADER)) return false;
} else {
return false;
}
out.hash_algo = r.u8();
out.target_id = r.u32();
out.fw_version = r.u32();
@@ -37,6 +45,13 @@ static bool parse_manifest_fields(ByteReader& r, MotaManifest& out) {
if (!r.ok) return false;
if (out.block_size_log2 == 0 || out.block_size_log2 > 24 || out.payload_size == 0) return false;
out.block_count = (out.payload_size + out.block_size() - 1) / out.block_size();
if (out.is_bootloader()) {
static const uint8_t zero_base[8] = {0};
if (out.fw_version == 0 || out.codec_id != CODEC_FULL || out.image_size != 0xA000u ||
out.payload_size != 0xA000u || out.block_size_log2 != 10 ||
out.block_count != 40 || memcmp(out.base_hash, zero_base, sizeof(zero_base)) != 0)
return false;
}
// block_idx is uint16 on the wire; capping here also keeps block_count*4 (leaves length) from overflowing.
return out.block_count != 0 && out.block_count <= 0xFFFFu;
}
+1
View File
@@ -40,6 +40,7 @@ struct MotaManifest {
bool is_full() const { return flags & MFLAG_FULL; }
bool is_signed() const { return flags & MFLAG_SIGNED; }
bool is_bootloader() const { return flags & MFLAG_BOOTLOADER; }
uint32_t block_size() const { return 1u << block_size_log2; }
bool is_approved() const;
};
+182 -4
View File
@@ -52,7 +52,7 @@ bool ota_apply_set_manifest(const uint8_t* mf, uint32_t len, const SignerAllowli
ota_apply_slot_info(&st.slot_addr, &st.slot_size);
MotaManifest m;
if (!mota_parse_manifest(mf, len, m)) return false;
if (!m.is_full()) return false; // A/B apply takes a full image (delta would need decode)
if (m.is_bootloader() || !m.is_full()) return false; // boot packages never enter an application slot
st.image_size = m.image_size;
memcpy(st.image_hash, m.image_hash, 32);
st.manifest_ok = true;
@@ -293,6 +293,7 @@ bool ota_apply_detools_mota(OtaStoreFlashEsp32& store, const SignerAllowlist& al
MotaManifest m;
if (mflen < 57 || !store.read(8, mfbuf, mflen) || !mota_parse_manifest(mfbuf, mflen, m)) {
strcpy(msg, "manifest parse failed"); return false; }
if (m.is_bootloader()) { strcpy(msg, "bootloader package requires explicit bootloader install"); return false; }
st.image_size = m.image_size; memcpy(st.image_hash, m.image_hash, 32); st.manifest_ok = true;
if (m.image_size == 0 || m.image_size > slot->size) { strcpy(msg, "image > slot"); return false; }
@@ -367,6 +368,7 @@ bool ota_apply_detools_mota(const uint8_t* buf, uint32_t len, const SignerAllowl
st = ApplyState();
MotaManifest m;
if (!mota_parse(buf, len, m)) { strcpy(msg, "no valid .mota (parse failed)"); return false; }
if (m.is_bootloader()) { strcpy(msg, "bootloader package cannot enter an application slot"); return false; }
if (m.is_full() || m.codec_id != CODEC_DETOOLS_SEQUENTIAL) { strcpy(msg, "not a detools-sequential delta"); return false; }
st.image_size = m.image_size;
memcpy(st.image_hash, m.image_hash, 32);
@@ -416,6 +418,10 @@ bool ota_rescue_mota_nrf52(const uint8_t*, uint32_t, const SignerAllowlist&, con
}
void ota_reboot_to_apply() { esp_restart(); } // boots the slot armed by ota_apply_detools_mota; no return
void ota_reboot_to_bootloader_update() {}
bool ota_installed_bootloader_identity(OtaBootloaderIdentity& out) {
out = OtaBootloaderIdentity(); return false;
}
#elif defined(NRF52_PLATFORM) // single-slot: verify + mark APPROVED + hand off to the bootloader
@@ -473,8 +479,28 @@ void ota_reboot_to_apply() { // public: set the apply magic +
NVIC_SystemReset(); // does not return
}
void ota_reboot_to_bootloader_update() {
uint8_t sd_en = 0;
sd_softdevice_is_enabled(&sd_en);
if (sd_en) {
sd_power_gpregret_clr(1, 0xFFFFFFFF);
sd_power_gpregret_set(1, GPREGRET2_OTA_STAGE_QSPI);
sd_power_gpregret_clr(0, 0xFFFFFFFF);
sd_power_gpregret_set(0, GPREGRET_OTA_BOOTLOADER_UPDATE);
} else {
NRF_POWER->GPREGRET2 = GPREGRET2_OTA_STAGE_QSPI;
NRF_POWER->GPREGRET = GPREGRET_OTA_BOOTLOADER_UPDATE;
}
NVIC_SystemReset();
}
uint8_t ota_bootloader_last_rc() { return g_bootloader_last_rc; }
bool ota_installed_bootloader_identity(OtaBootloaderIdentity& out) {
return ota_bootloader_identity_from_image(
(const uint8_t*)(uintptr_t)OTA_BOOT_IMAGE_START, OTA_BOOT_IMAGE_SIZE, out);
}
static bool ota_apply_mota_nrf52_impl(const uint8_t* buf, uint32_t len,
const SignerAllowlist& allow,
const uint8_t* rescue_base_hash,
@@ -483,6 +509,7 @@ static bool ota_apply_mota_nrf52_impl(const uint8_t* buf, uint32_t len,
st = ApplyState();
MotaManifest m;
if (!mota_parse(buf, len, m)) { strcpy(msg, "parse failed"); return false; }
if (m.is_bootloader()) { strcpy(msg, "use explicit ota bootloader install"); return false; }
if (m.is_full() || m.codec_id != CODEC_DETOOLS_INPLACE) { strcpy(msg, "not an in-place delta"); return false; }
st.image_size = m.image_size;
memcpy(st.image_hash, m.image_hash, 32);
@@ -618,6 +645,10 @@ static bool ota_apply_mota_nrf52_external(Store& store, const SignerAllowlist& a
snprintf(msg, NRF52_APPLY_MSG_CAP, "%s manifest parse failed", storage_name);
return false;
}
if (m.is_bootloader()) {
strcpy(msg, "use explicit ota bootloader install");
return false;
}
const bool full = m.is_full() && m.codec_id == CODEC_FULL &&
m.payload_size == m.image_size;
const bool delta = !m.is_full() && m.codec_id == CODEC_DETOOLS_INPLACE;
@@ -627,8 +658,9 @@ static bool ota_apply_mota_nrf52_external(Store& store, const SignerAllowlist& a
return false;
}
const uint32_t app_base = mota_nrf52_app_base();
if (m.image_size == 0 || app_base >= MOTA_NRF52_APP_END ||
m.image_size > MOTA_NRF52_APP_END - app_base) {
const uint32_t app_ceiling = mota_nrf52_application_ceiling();
if (m.image_size == 0 || app_base >= app_ceiling ||
m.image_size > app_ceiling - app_base) {
strcpy(msg, "image exceeds nRF52 application region");
return false;
}
@@ -687,7 +719,7 @@ static bool ota_apply_mota_nrf52_external(Store& store, const SignerAllowlist& a
}
if (!mota_nrf52_external_patch_geometry_valid(
d.memory, d.segment, d.shift, d.from, d.to,
MOTA_NRF52_APP_END - app_base, fi.image_len, m.image_size)) {
app_ceiling - app_base, fi.image_len, m.image_size)) {
strcpy(msg, "invalid in-place patch geometry");
return false;
}
@@ -717,6 +749,148 @@ bool ota_apply_mota_nrf52(OtaStoreQspiNrf52& store, const SignerAllowlist& allow
ApplyState& st, char* msg) {
return ota_apply_mota_nrf52_external(store, allow, OTA_BL_STORAGE_QSPI, "QSPI", st, msg);
}
#if defined(OTA_QSPI_BOOTLOADER_UPDATE)
static bool qspi_bootloader_crc_ok(OtaStoreQspiNrf52& store, uint32_t payload_off,
const OtaBootloaderIdentity& identity) {
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;
}
static bool qspi_bootloader_image_metadata(OtaStoreQspiNrf52& store, uint32_t payload_off,
const OtaBootloaderIdentity& installed,
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;
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 (!caps.present && (absolute & 3u) == 0 && local + 16u <= len) {
OtaBootloaderCapsMarker parsed;
if (ota_bootloader_caps_marker_parse(buf + local, parsed) &&
parsed.apply_abi >= MOTA_BOOT_FORMAT_VER &&
(parsed.codec_mask & (1u << CODEC_FULL)) != 0 &&
(parsed.storage_flags & (OTA_BL_STORAGE_QSPI | OTA_BL_STORAGE_BOOT_UPDATE)) ==
(OTA_BL_STORAGE_QSPI | OTA_BL_STORAGE_BOOT_UPDATE)) {
caps = parsed;
}
}
if ((absolute & 3u) != 0 || local + OTA_BOOT_MANIFEST_SIZE > len) continue;
OtaBootloaderIdentity parsed;
if (!ota_bootloader_manifest_parse(buf + local, absolute, parsed) ||
parsed.board_id != installed.board_id ||
memcmp(parsed.device_name, installed.device_name, OTA_BOOT_DEVICE_NAME_SIZE) != 0) continue;
if (qspi_bootloader_crc_ok(store, payload_off, parsed)) {
parsed.crc_ok = true;
if (!ota_bootloader_identity_add_unique(parsed, candidate, valid_identities)) return false;
}
}
}
return valid_identities == 1u && candidate.present && candidate.crc_ok && caps.present;
}
bool ota_prepare_bootloader_update_nrf52(OtaStoreQspiNrf52& store,
const SignerAllowlist& allow,
const OtaBootloaderIdentity& installed,
const uint8_t actual_mid[4],
const uint8_t operator_mid[4],
const uint8_t operator_hash8[8],
ApplyState& st, char* msg) {
static const size_t CAP = 96;
st = ApplyState();
const uint32_t total = store.staged_size();
uint8_t hdr[8], manifest[MOTA_MFL];
if (total < 8u + MOTA_MFL + 5u || !store.read(0, hdr, sizeof(hdr)) ||
memcmp(hdr, MOTA_MAGIC, sizeof(MOTA_MAGIC)) != 0 || rd_u32le(hdr + 4) != total ||
!store.read(8, manifest, sizeof(manifest))) {
strcpy(msg, "QSPI bootloader container parse failed"); return false;
}
MotaManifest m;
if (!mota_parse_manifest(manifest, sizeof(manifest), m)) {
strcpy(msg, "not a valid v3 bootloader package"); return false;
}
switch (ota_bootloader_confirmation_gate(m, installed, actual_mid, operator_mid,
operator_hash8)) {
case OTA_BOOT_CONFIRM_OK: break;
case OTA_BOOT_CONFIRM_NOT_BOOT_PACKAGE: strcpy(msg, "not a bootloader package"); return false;
case OTA_BOOT_CONFIRM_GEOMETRY: strcpy(msg, "bootloader package geometry mismatch"); return false;
case OTA_BOOT_CONFIRM_LOCAL_IDENTITY: strcpy(msg, "installed bootloader identity is invalid"); return false;
case OTA_BOOT_CONFIRM_TARGET: strcpy(msg, "bootloader board ID mismatch"); return false;
case OTA_BOOT_CONFIRM_HW_ID: strcpy(msg, "bootloader signed hw_id mismatch"); return false;
case OTA_BOOT_CONFIRM_MID: strcpy(msg, "bootloader MID confirmation mismatch"); return false;
case OTA_BOOT_CONFIRM_IMAGE_HASH: strcpy(msg, "bootloader image-hash confirmation mismatch"); return false;
}
const OtaBlCaps current_caps = ota_bootloader_caps();
if (!current_caps.present || current_caps.apply_abi < MOTA_BOOT_FORMAT_VER ||
(current_caps.codec_mask & (1u << CODEC_FULL)) == 0 ||
(current_caps.storage_flags & (OTA_BL_STORAGE_QSPI | OTA_BL_STORAGE_BOOT_UPDATE)) !=
(OTA_BL_STORAGE_QSPI | OTA_BL_STORAGE_BOOT_UPDATE)) {
strcpy(msg, "installed bootloader cannot safely self-update from QSPI"); return false;
}
const uint64_t payload_off64 = 8u + MOTA_MFL + (uint64_t)m.block_count * 4u;
if (payload_off64 > UINT32_MAX || payload_off64 + OTA_BOOT_IMAGE_SIZE + 5u != total) {
strcpy(msg, "bootloader container layout mismatch"); return false;
}
const uint32_t payload_off = (uint32_t)payload_off64;
VerifyResult vr = ota_verify(static_cast<const OtaStore&>(store), allow);
st.manifest_ok = vr.parsed;
st.sig_ok = vr.sig_ok;
st.trusted = vr.trusted;
st.image_size = m.image_size;
memcpy(st.image_hash, m.image_hash, sizeof(st.image_hash));
if (!vr.auto_appliable()) {
if (!vr.root_ok || !vr.payload_ok || !vr.image_ok)
strcpy(msg, "bootloader payload hash mismatch");
else if (!vr.sig_ok)
strcpy(msg, "bootloader package signature invalid");
else
strcpy(msg, "bootloader signer is not in the trusted allowlist");
return false;
}
uint8_t vectors[8];
if (!store.read(payload_off, vectors, sizeof(vectors)) || !ota_bootloader_vector_sane(vectors)) {
strcpy(msg, "bootloader vector table is invalid"); return false;
}
OtaBootloaderIdentity candidate;
OtaBootloaderCapsMarker candidate_caps;
if (!qspi_bootloader_image_metadata(store, payload_off, installed, candidate, candidate_caps) ||
!ota_bootloader_identity_matches(installed, candidate)) {
strcpy(msg, "candidate bootloader identity/capability/CRC mismatch"); return false;
}
st.slot_ok = true;
if (!store.approve_for_bootloader()) {
snprintf(msg, CAP, "QSPI bootloader handoff failed: %s", store.last_error()); return false;
}
strcpy(msg, "trusted bootloader verified and armed; rebooting after this reply");
return true;
}
#endif
#endif
#else // native / other platforms
@@ -732,6 +906,10 @@ bool ota_rescue_mota_nrf52(const uint8_t*, uint32_t, const SignerAllowlist&, con
st = ApplyState(); strcpy(msg, "unsupported"); return false;
}
void ota_reboot_to_apply() {}
void ota_reboot_to_bootloader_update() {}
bool ota_installed_bootloader_identity(OtaBootloaderIdentity& out) {
out = OtaBootloaderIdentity(); return false;
}
uint8_t ota_bootloader_last_rc() { return 0; }
#endif
+18 -1
View File
@@ -4,6 +4,7 @@
#include <stddef.h>
#include <string.h>
#include "SignerAllowlist.h"
#include "OtaBootloaderUpdate.h"
// P6 apply (full-image, ESP32 A/B). The new image is delivered into the inactive OTA slot; the device
// then verifies that slot against the signed manifest's image_hash (+ Ed25519/allowlist), and commits
@@ -90,6 +91,15 @@ bool ota_apply_mota_nrf52(OtaStoreSdNrf52& store,
class OtaStoreQspiNrf52;
bool ota_apply_mota_nrf52(OtaStoreQspiNrf52& store,
const SignerAllowlist& allow, ApplyState& st, char* msg);
#if defined(OTA_QSPI_BOOTLOADER_UPDATE)
bool ota_prepare_bootloader_update_nrf52(OtaStoreQspiNrf52& store,
const SignerAllowlist& allow,
const OtaBootloaderIdentity& installed,
const uint8_t actual_mid[4],
const uint8_t operator_mid[4],
const uint8_t operator_hash8[8],
ApplyState& st, char* msg);
#endif
#endif
// Commit the (already approved/armed) update and reboot into it - does NOT return. Call this only after
@@ -98,10 +108,17 @@ bool ota_apply_mota_nrf52(OtaStoreQspiNrf52& store,
// the GPREGRET apply magic + reset (the bootloader does the in-place decode + verify). ESP32: reboot
// into the slot already armed by ota_apply_detools_mota.
void ota_reboot_to_apply();
void ota_reboot_to_bootloader_update();
bool ota_installed_bootloader_identity(OtaBootloaderIdentity& out);
inline uint8_t ota_nrf52_boot_result_or_zero(uint8_t value) {
return ((value >= 0x90u && value <= 0x9Fu) ||
(value >= 0xB0u && value <= 0xBCu)) ? value : 0u;
(value >= 0xB0u && value <= 0xBCu) ||
(value >= 0xC0u && value <= 0xCFu)) ? value : 0u;
}
inline bool ota_nrf52_boot_update_result(uint8_t value) {
return value >= 0xC0u && value <= 0xCFu;
}
// DIAGNOSTIC (nRF52): the bootloader stashes its last in-place-apply bail/progress code in GPREGRET2 (see
+51 -10
View File
@@ -1,5 +1,6 @@
#pragma once
#include <stddef.h>
#include <stdint.h>
#include <string.h>
@@ -31,23 +32,63 @@ struct OtaBlCaps {
static const uint8_t OTA_BL_STORAGE_SD = 0x01;
static const uint8_t OTA_BL_STORAGE_STAGE_CEILING = 0x02;
static const uint8_t OTA_BL_STORAGE_QSPI = 0x04;
static const uint8_t OTA_BL_STORAGE_BOOT_UPDATE = 0x08;
// Prefer a continuity-capable marker over a numerically newer legacy-looking candidate. Bootloader
// images contain the marker magic in code/literal data as well as in the real structure, so the scan
// must not let a later plausible literal shadow the privileged marker merely because its following
// bytes decode as a larger ABI.
inline bool ota_bl_caps_prefer(const OtaBlCaps& current, uint16_t candidate_abi,
uint8_t candidate_storage, bool require_boot_update) {
if (!current.present) return true;
if (require_boot_update) {
const bool candidate_boot = (candidate_storage & OTA_BL_STORAGE_BOOT_UPDATE) != 0;
const bool current_boot = (current.storage_flags & OTA_BL_STORAGE_BOOT_UPDATE) != 0;
if (candidate_boot != current_boot) return candidate_boot;
}
return candidate_abi > current.apply_abi;
}
// Scan a region whose offset zero is word-aligned. The marker structure contains
// uint16_t fields and OTAFIX emits it aligned(4); accepting a byte-shifted magic
// occurrence would let instruction/literal bytes masquerade as capabilities.
inline OtaBlCaps ota_bl_caps_scan_aligned(const uint8_t* bytes, size_t len,
bool require_boot_update) {
OtaBlCaps c;
if (!bytes) return c;
for (size_t off = 0; off + 16u <= len; off += 4u) {
const uint8_t* p = bytes + off;
if (p[0] != OTA_BL_MAGIC[0] || memcmp(p, OTA_BL_MAGIC, 8) != 0) continue;
const uint16_t abi = (uint16_t)(p[8] | ((uint16_t)p[9] << 8));
const uint16_t codecs = (uint16_t)(p[10] | ((uint16_t)p[11] << 8));
const uint8_t storage = p[12];
if (abi == 0 || abi == 0xFFFFu || codecs == 0 || (storage & ~0x0Fu) != 0 ||
p[13] != 0 || p[14] != 0 || p[15] != 0) continue;
if (ota_bl_caps_prefer(c, abi, storage, require_boot_update)) {
c.present = true;
c.apply_abi = abi;
c.codec_mask = codecs;
c.storage_flags = storage;
}
}
return c;
}
// Scan the bootloader flash region for the marker. Returns {present=false} if not found / non-nRF52.
inline OtaBlCaps ota_bootloader_caps() {
OtaBlCaps c;
#if defined(NRF52_PLATFORM)
const uint8_t* lo = (const uint8_t*)(uintptr_t)MOTA_NRF52_BL_START;
const uint8_t* hi = (const uint8_t*)(uintptr_t)MOTA_NRF52_BL_END;
for (const uint8_t* p = lo; p + 16 <= hi; p++) {
if (p[0] != OTA_BL_MAGIC[0] || memcmp(p, OTA_BL_MAGIC, 8) != 0) continue;
c.present = true;
c.apply_abi = (uint16_t)(p[8] | ((uint16_t)p[9] << 8));
c.codec_mask = (uint16_t)(p[10] | ((uint16_t)p[11] << 8));
c.storage_flags = p[12];
break;
}
return ota_bl_caps_scan_aligned(lo, (size_t)(hi - lo),
#if defined(OTA_QSPI_BOOTLOADER_UPDATE)
true
#else
false
#endif
);
#else
return OtaBlCaps();
#endif
return c;
}
// True if this device's bootloader can apply a .mota of the given format_ver + codec_id.
+242
View File
@@ -0,0 +1,242 @@
#pragma once
#include <stddef.h>
#include <stdint.h>
#include <string.h>
#include "MotaContainer.h"
#if defined(OTA_QSPI_BOOTLOADER_UPDATE)
#if !defined(NRF52_PLATFORM) || !defined(OTA_QSPI_STORE)
#error "OTA_QSPI_BOOTLOADER_UPDATE requires nRF52 raw-QSPI staging"
#endif
#if !defined(XIAO_NRF52) && !defined(IKOKA_NRF52)
#error "OTA_QSPI_BOOTLOADER_UPDATE is restricted to XIAO nRF52840 module builds"
#endif
#if defined(QSPIFLASH) || defined(OTA_SD_STORE)
#error "bootloader-update QSPI cannot share a filesystem or SD staging configuration"
#endif
#endif
namespace mesh {
namespace ota {
// Mirror of OTAFIX src/usb/uf2/bootloader_image.h. This manifest is embedded in the complete,
// padded bootloader region and is covered both by its own CRC32 and the signed .mota image_hash.
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 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_SCRATCH_START = 0x000E0000UL;
static const uint32_t OTA_BOOT_SCRATCH_END = 0x000EA000UL;
static const uint32_t OTA_NRF52840_RAM_START = 0x20000000UL;
static const uint32_t OTA_NRF52840_RAM_END = 0x20040000UL;
// Only the two official XIAO nRF52840 OTAFIX identities are eligible. Runtime matching against the
// installed, CRC-valid manifest distinguishes the base and Sense modules even for carrier-board aliases.
static const uint32_t OTA_XIAO_BOARD_ID_BASE = 0x28860044UL;
static const uint32_t OTA_XIAO_BOARD_ID_SENSE = 0x28860045UL;
static const uint8_t OTA_XIAO_BOOT_DEVICE_NAME[OTA_BOOT_DEVICE_NAME_SIZE] = {
'X','I','A','O','_','D','F','U', 0,0,0,0,0,0,0,0
};
struct OtaBootloaderIdentity {
bool present = false;
bool crc_ok = false;
uint32_t manifest_offset = 0;
uint32_t image_start = 0;
uint32_t image_size = 0;
uint32_t board_id = 0;
char device_name[OTA_BOOT_DEVICE_NAME_SIZE + 1] = {0};
uint32_t crc32 = 0;
};
// Accumulate a stream of already CRC-validated candidates without silently
// choosing between two privileged identities. Both the memory-mapped and QSPI
// scanners use this helper so their duplicate policy cannot drift.
inline bool ota_bootloader_identity_add_unique(const OtaBootloaderIdentity& candidate,
OtaBootloaderIdentity& selected,
uint8_t& valid_count) {
if (!candidate.present || !candidate.crc_ok) return true;
if (valid_count != 0u) {
selected = OtaBootloaderIdentity();
return false;
}
selected = candidate;
valid_count = 1u;
return true;
}
inline uint16_t ota_boot_rd16(const uint8_t* p) {
return (uint16_t)(p[0] | ((uint16_t)p[1] << 8));
}
inline uint32_t ota_boot_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 bool ota_xiao_bootloader_board_id(uint32_t board_id) {
return board_id == OTA_XIAO_BOARD_ID_BASE || board_id == OTA_XIAO_BOARD_ID_SENSE;
}
// Canonical signed .mota hw_id. It is intentionally distinct from application hw_id values and is
// derived solely from the exact embedded board ID.
inline bool ota_xiao_bootloader_hw_id(uint32_t board_id, uint8_t out[32]) {
if (!out || !ota_xiao_bootloader_board_id(board_id)) return false;
memset(out, 0, 32);
const char* value = board_id == OTA_XIAO_BOARD_ID_BASE
? "XIAO_BL_28860044" : "XIAO_BL_28860045";
memcpy(out, value, strlen(value));
return true;
}
inline uint32_t ota_boot_crc32_update(uint32_t crc, uint8_t value) {
crc ^= value;
for (uint8_t bit = 0; bit < 8; bit++)
crc = (crc >> 1) ^ (0xEDB88320UL & (uint32_t)-(int32_t)(crc & 1U));
return crc;
}
inline uint32_t ota_boot_image_crc32(const uint8_t* image, size_t image_size,
size_t crc_offset) {
if (!image || crc_offset > image_size || image_size - crc_offset < sizeof(uint32_t)) return 0;
uint32_t crc = UINT32_MAX;
for (size_t i = 0; i < image_size; i++) {
const uint8_t value = (i >= crc_offset && i < crc_offset + sizeof(uint32_t)) ? 0 : image[i];
crc = ota_boot_crc32_update(crc, value);
}
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 ||
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 ||
ota_boot_rd32(raw + 12) != OTA_BOOT_IMAGE_START ||
ota_boot_rd32(raw + 16) != OTA_BOOT_IMAGE_SIZE ||
!ota_xiao_bootloader_board_id(ota_boot_rd32(raw + 20)) ||
memcmp(raw + 24, OTA_XIAO_BOOT_DEVICE_NAME, OTA_BOOT_DEVICE_NAME_SIZE) != 0) return false;
out = OtaBootloaderIdentity();
out.present = true;
out.manifest_offset = raw_offset;
out.image_start = ota_boot_rd32(raw + 12);
out.image_size = ota_boot_rd32(raw + 16);
out.board_id = ota_boot_rd32(raw + 20);
memcpy(out.device_name, raw + 24, OTA_BOOT_DEVICE_NAME_SIZE);
out.device_name[OTA_BOOT_DEVICE_NAME_SIZE] = 0;
out.crc32 = ota_boot_rd32(raw + 40);
return true;
}
// Validate an installed memory-mapped bootloader and recover its exact identity. Scan on word
// boundaries to mirror OTAFIX's self-update validator.
inline bool ota_bootloader_identity_from_image(const uint8_t* image, size_t image_size,
OtaBootloaderIdentity& out) {
out = OtaBootloaderIdentity();
if (!image || image_size != OTA_BOOT_IMAGE_SIZE) return false;
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;
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;
}
inline bool ota_bootloader_identity_matches(const OtaBootloaderIdentity& a,
const OtaBootloaderIdentity& b) {
return a.present && a.crc_ok && b.present && b.crc_ok &&
a.image_start == b.image_start && a.image_size == b.image_size &&
a.board_id == b.board_id &&
memcmp(a.device_name, b.device_name, OTA_BOOT_DEVICE_NAME_SIZE) == 0;
}
// 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.
inline bool ota_bootloader_vector_sane(const uint8_t vectors[8]) {
if (!vectors) return false;
const uint32_t sp = ota_boot_rd32(vectors);
const uint32_t reset = ota_boot_rd32(vectors + 4);
const uint32_t entry = reset & ~1UL;
return (sp & 7u) == 0 && sp >= OTA_NRF52840_RAM_START && sp <= OTA_NRF52840_RAM_END &&
(reset & 1u) != 0 && entry >= OTA_BOOT_IMAGE_START &&
entry < OTA_BOOT_IMAGE_START + OTA_BOOT_IMAGE_SIZE;
}
struct OtaBootloaderCapsMarker {
bool present = false;
uint16_t apply_abi = 0;
uint16_t codec_mask = 0;
uint8_t storage_flags = 0;
};
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) ||
(raw[10] == 0 && raw[11] == 0) || (raw[12] & ~0x0Fu) != 0 ||
raw[13] != 0 || raw[14] != 0 || raw[15] != 0) return false;
out.present = true;
out.apply_abi = ota_boot_rd16(raw + 8);
out.codec_mask = ota_boot_rd16(raw + 10);
out.storage_flags = raw[12];
return true;
}
enum OtaBootloaderConfirmGate : uint8_t {
OTA_BOOT_CONFIRM_OK = 0,
OTA_BOOT_CONFIRM_NOT_BOOT_PACKAGE,
OTA_BOOT_CONFIRM_GEOMETRY,
OTA_BOOT_CONFIRM_LOCAL_IDENTITY,
OTA_BOOT_CONFIRM_TARGET,
OTA_BOOT_CONFIRM_HW_ID,
OTA_BOOT_CONFIRM_MID,
OTA_BOOT_CONFIRM_IMAGE_HASH,
};
// Pure explicit-confirmation gate used by the device command and native tests. Cryptographic and
// candidate-image checks run immediately after this structural/typed gate.
inline OtaBootloaderConfirmGate ota_bootloader_confirmation_gate(
const MotaManifest& m, const OtaBootloaderIdentity& installed,
const uint8_t actual_mid[4], const uint8_t operator_mid[4],
const uint8_t operator_hash8[8]) {
if (m.format_ver != MOTA_BOOT_FORMAT_VER ||
m.flags != (MFLAG_FULL | MFLAG_SIGNED | MFLAG_BOOTLOADER))
return OTA_BOOT_CONFIRM_NOT_BOOT_PACKAGE;
static const uint8_t zero_base[8] = {0};
if (m.codec_id != CODEC_FULL || m.image_size != OTA_BOOT_IMAGE_SIZE ||
m.payload_size != OTA_BOOT_IMAGE_SIZE || m.block_size_log2 != 10 ||
m.block_count != OTA_BOOT_IMAGE_SIZE / 1024u || !m.base_hash ||
memcmp(m.base_hash, zero_base, sizeof(zero_base)) != 0)
return OTA_BOOT_CONFIRM_GEOMETRY;
if (!installed.present || !installed.crc_ok ||
!ota_xiao_bootloader_board_id(installed.board_id))
return OTA_BOOT_CONFIRM_LOCAL_IDENTITY;
if (m.target_id != installed.board_id) return OTA_BOOT_CONFIRM_TARGET;
uint8_t expected_hw[32];
if (!ota_xiao_bootloader_hw_id(installed.board_id, expected_hw) || !m.hw_id ||
memcmp(m.hw_id, expected_hw, sizeof(expected_hw)) != 0)
return OTA_BOOT_CONFIRM_HW_ID;
if (!actual_mid || !operator_mid || memcmp(actual_mid, operator_mid, 4) != 0 ||
!m.merkle_root || memcmp(m.merkle_root, actual_mid, 4) != 0)
return OTA_BOOT_CONFIRM_MID;
if (!operator_hash8 || !m.image_hash || memcmp(m.image_hash, operator_hash8, 8) != 0)
return OTA_BOOT_CONFIRM_IMAGE_HASH;
return OTA_BOOT_CONFIRM_OK;
}
} // namespace ota
} // namespace mesh
+133 -11
View File
@@ -51,8 +51,30 @@ static char fstate_char(OtaManager::FetchState s) {
}
}
// For users, the only distinction that matters is full image vs. delta (which delta codec is internal).
static const char* codec_kind(uint8_t c) { return c == CODEC_FULL ? "full" : "delta"; }
// For users, the only distinction that matters is application full/delta vs the separately gated
// bootloader package. Which application delta codec is an implementation detail.
static const char* codec_kind(uint8_t flags, uint8_t c) {
return (flags & MFLAG_BOOTLOADER) ? "bootloader" : c == CODEC_FULL ? "full" : "delta";
}
static bool parse_hex_exact(const char* text, uint8_t* out, size_t bytes) {
if (!text || strlen(text) != bytes * 2u) return false;
for (size_t i = 0; i < bytes * 2u; i++) {
const char c = text[i];
if (!((c >= '0' && c <= '9') || (c >= 'a' && c <= 'f') || (c >= 'A' && c <= 'F')))
return false;
}
return mesh::Utils::fromHex(out, bytes, text);
}
static bool staged_manifest(OtaContext& c, MotaManifest& m) {
uint8_t hdr[8], manifest[MOTA_MFL];
const uint32_t total = c.fetch_store.staged_size();
return total >= 8u + MOTA_MFL + 5u && c.fetch_store.read(0, hdr, sizeof(hdr)) &&
memcmp(hdr, MOTA_MAGIC, sizeof(MOTA_MAGIC)) == 0 && rd_u32le(hdr + 4) == total &&
c.fetch_store.read(8, manifest, sizeof(manifest)) &&
mota_parse_manifest(manifest, sizeof(manifest), m);
}
// A plain-language word for the fetch state (shown in `ota status`).
static const char* state_word(OtaManager::FetchState s) {
@@ -152,9 +174,15 @@ bool handle_ota_command(const char* command, char* reply, mesh::MainBoard& board
"OTA: status | stats | ls | get <id> flash [rescue] | install | rescue install <hash16> | "
"cancel | announce | self | folder | config | key");
#elif defined(NRF52_PLATFORM) && defined(OTA_QSPI_STORE)
#if defined(OTA_QSPI_BOOTLOADER_UPDATE)
strcpy(reply,
"OTA: status | stats | ls | get <id> flash | install | bootloader | cancel | announce | self | "
"qspi | folder | config | key");
#else
strcpy(reply,
"OTA: status | stats | ls | get <id> flash | install | cancel | announce | self | qspi | "
"folder | config | key");
#endif
#else
snprintf(reply, 160,
"OTA: status | stats | ls | get <id> flash | install | cancel | announce | self | folder | "
@@ -186,7 +214,8 @@ bool handle_ota_command(const char* command, char* reply, mesh::MainBoard& board
snprintf(dl, sizeof dl, "download: failed (%s) %u/%u id=%s",
fetch_error_word(c.manager.fetchError()), have, tot, midhx);
else
snprintf(dl, sizeof dl, "download: %s %u/%u (%u%%) id=%s %us",
snprintf(dl, sizeof dl, "%sdownload: %s %u/%u (%u%%) id=%s %us",
c.manager.fetched_is_bootloader() ? "bootloader " : "",
state_word(fs), have, tot, pct, midhx, age);
}
const char* hw = (c.hw_id[0]) ? c.hw_id : "?";
@@ -208,8 +237,13 @@ bool handle_ota_command(const char* command, char* reply, mesh::MainBoard& board
#else
const char* bl_state = bl.present ? "apply" : "NONE";
#endif
if (n < 146) n += snprintf(reply + n, 160 - n, " | bl:%s blrc:%02X",
bl_state, ota_bootloader_last_rc());
const uint8_t last_rc = ota_bootloader_last_rc();
if (n < 146) {
if (ota_nrf52_boot_update_result(last_rc))
n += snprintf(reply + n, 160 - n, " | bl:%s blup:%02X", bl_state, last_rc);
else
n += snprintf(reply + n, 160 - n, " | bl:%s blrc:%02X", bl_state, last_rc);
}
#endif
#endif
@@ -304,7 +338,20 @@ bool handle_ota_command(const char* command, char* reply, mesh::MainBoard& board
char hwbuf[16];
const char* fit;
const char* env = ota_target_env_name(h->target_id);
if (myt && h->target_id == myt) {
const bool boot_package = (h->flags & MFLAG_BOOTLOADER) != 0;
if (boot_package) {
bool installable = false;
#if defined(NRF52_PLATFORM) && defined(OTA_QSPI_STORE) && defined(OTA_QSPI_BOOTLOADER_UPDATE)
const OtaBootloaderIdentity& bid = c.bootloaderIdentity();
installable = h->flags == (MFLAG_FULL | MFLAG_SIGNED | MFLAG_BOOTLOADER) &&
h->codec == CODEC_FULL && bid.present && bid.crc_ok &&
h->target_id == bid.board_id && list_bl.present &&
list_bl.apply_abi >= MOTA_BOOT_FORMAT_VER &&
(list_bl.storage_flags & (OTA_BL_STORAGE_QSPI | OTA_BL_STORAGE_BOOT_UPDATE)) ==
(OTA_BL_STORAGE_QSPI | OTA_BL_STORAGE_BOOT_UPDATE);
#endif
fit = installable ? "yours" : "bootloader unsupported";
} else if (myt && h->target_id == myt) {
bool installable = c.manager.codecOk(h->codec);
#if defined(NRF52_PLATFORM)
installable = installable && list_bl.present && list_bl.apply_abi >= MOTA_FORMAT_VER
@@ -326,7 +373,7 @@ bool handle_ota_command(const char* command, char* reply, mesh::MainBoard& board
else { snprintf(hwbuf, sizeof hwbuf, "hw %08X", (unsigned)h->target_id); fit = hwbuf; }
char midhx[9]; mesh::Utils::toHex(midhx, h->mid, 4);
n += snprintf(reply + n, CAP - n, "\n %u) %s %s %s [%s] %un %us%s",
(unsigned)(i + 1), midhx, ver, codec_kind(h->codec), fit,
(unsigned)(i + 1), midhx, ver, codec_kind(h->flags, h->codec), fit,
(unsigned)h->n_seeders, (unsigned)age, tag);
shown++;
}
@@ -401,6 +448,8 @@ bool handle_ota_command(const char* command, char* reply, mesh::MainBoard& board
}
uint8_t selmid[4]; uint32_t seltgt = sel->target_id; uint8_t selcodec = sel->codec;
const uint8_t selflags = sel->flags;
const bool selboot = (selflags & MFLAG_BOOTLOADER) != 0;
memcpy(selmid, sel->mid, 4); // sel may move when catalog traffic arrives
#if defined(NRF52_PLATFORM) && defined(OTA_SD_STORE)
@@ -418,10 +467,33 @@ bool handle_ota_command(const char* command, char* reply, mesh::MainBoard& board
strcpy(reply, "ERR seeder-only build cannot stage or install firmware; use `folder`");
return true;
#else
if (!c.manager.codecOk(selcodec)) {
if (selboot) {
#if defined(NRF52_PLATFORM) && defined(OTA_QSPI_STORE) && defined(OTA_QSPI_BOOTLOADER_UPDATE)
const OtaBootloaderIdentity& bid = c.bootloaderIdentity();
const OtaBlCaps& bl = c.bootloaderCaps();
if (selflags != (MFLAG_FULL | MFLAG_SIGNED | MFLAG_BOOTLOADER) ||
selcodec != CODEC_FULL) {
strcpy(reply, "ERR malformed bootloader catalog row; capture it to folder for inspection");
return true;
}
if (!bid.present || !bid.crc_ok || seltgt != bid.board_id) {
strcpy(reply, "ERR bootloader package does not match this installed XIAO bootloader");
return true;
}
if (!bl.present || bl.apply_abi < MOTA_BOOT_FORMAT_VER ||
(bl.storage_flags & (OTA_BL_STORAGE_QSPI | OTA_BL_STORAGE_BOOT_UPDATE)) !=
(OTA_BL_STORAGE_QSPI | OTA_BL_STORAGE_BOOT_UPDATE)) {
strcpy(reply, "ERR installed bootloader lacks safe LoRa bootloader-update support");
return true;
}
#else
strcpy(reply, "ERR this build cannot install bootloader packages; use folder capture or USB DFU");
return true;
#endif
} else if (!c.manager.codecOk(selcodec)) {
snprintf(reply, 160,
"ERR %s codec %u cannot be installed by this build; use `ota pull %s folder` to capture it",
codec_kind(selcodec), (unsigned)selcodec, selstr);
codec_kind(selflags, selcodec), (unsigned)selcodec, selstr);
return true;
}
#if defined(NRF52_PLATFORM)
@@ -430,9 +502,10 @@ bool handle_ota_command(const char* command, char* reply, mesh::MainBoard& board
strcpy(reply, "ERR bootloader has no mOTA apply support; update it over USB first");
return true;
}
if (bl.apply_abi < MOTA_FORMAT_VER || selcodec >= 16 || !(bl.codec_mask & (1u << selcodec))) {
const uint8_t need_abi = selboot ? MOTA_BOOT_FORMAT_VER : MOTA_APP_FORMAT_VER;
if (bl.apply_abi < need_abi || selcodec >= 16 || !(bl.codec_mask & (1u << selcodec))) {
snprintf(reply, 160, "ERR bootloader cannot apply mOTA ABI %u codec %u (has abi=%u codecs=0x%x)",
MOTA_FORMAT_VER, (unsigned)selcodec, bl.apply_abi, bl.codec_mask);
need_abi, (unsigned)selcodec, bl.apply_abi, bl.codec_mask);
return true;
}
#if defined(OTA_SD_STORE)
@@ -498,6 +571,7 @@ bool handle_ota_command(const char* command, char* reply, mesh::MainBoard& board
// ---- discard the current session (e.g. a stalled old fetch) to free the slot ----
} else if (is_cmd(a, "drop|cancel|stop", &rest)) {
if (c.apply_pending) { strcpy(reply, "ERR update is armed; reboot is pending"); return true; }
OtaManager::FetchState fs = c.manager.fetchState();
char midhx[9]; strcpy(midhx, "-");
if (fs != OtaManager::IDLE) mesh::Utils::toHex(midhx, c.manager.fetchManifestId(), 4);
@@ -607,6 +681,54 @@ bool handle_ota_command(const char* command, char* reply, mesh::MainBoard& board
strcpy(reply, "ERR rescue requires an internal-flash nRF52 LoRa OTA build");
#endif
} else if (is_cmd(a, "bootloader|blupdate", &rest)) {
#if defined(NRF52_PLATFORM) && defined(OTA_QSPI_STORE) && \
defined(OTA_QSPI_BOOTLOADER_UPDATE) && !defined(OTA_SEEDER_ONLY)
const OtaBootloaderIdentity& bid = c.bootloaderIdentity();
const OtaBlCaps& bl = c.bootloaderCaps();
if (*rest == 0 || strcmp(rest, "status") == 0) {
if (!bid.present || !bid.crc_ok) {
strcpy(reply, "Bootloader update unavailable: installed embedded manifest/CRC is invalid");
return true;
}
MotaManifest staged;
char midhx[9] = "-", hashhx[17] = "-";
const bool ready = c.manager.fetchState() == OtaManager::COMPLETE &&
staged_manifest(c, staged) && staged.is_bootloader();
if (ready) {
mesh::Utils::toHex(midhx, c.manager.fetchManifestId(), 4);
mesh::Utils::toHex(hashhx, staged.image_hash, 8);
}
snprintf(reply, 160, "BL board=%08X name=%s crc=%08X abi=%u caps=%02X | staged:%s mid=%s hash=%s",
(unsigned)bid.board_id, bid.device_name, (unsigned)bid.crc32,
bl.apply_abi, bl.storage_flags, ready ? "ready" : "none", midhx, hashhx);
return true;
}
const char* confirm = nullptr;
if (!is_cmd(rest, "install", &confirm)) {
strcpy(reply, "ERR usage: ota bootloader install <MID8> <HASH16>"); return true;
}
char midtxt[9] = {0}, hashtxt[17] = {0}, extra[2] = {0};
if (sscanf(confirm, "%8s %16s %1s", midtxt, hashtxt, extra) != 2) {
strcpy(reply, "ERR usage: ota bootloader install <MID8> <HASH16>"); return true;
}
uint8_t operator_mid[4], operator_hash8[8];
if (!parse_hex_exact(midtxt, operator_mid, sizeof(operator_mid)) ||
!parse_hex_exact(hashtxt, operator_hash8, sizeof(operator_hash8))) {
strcpy(reply, "ERR MID must be 8 hex and HASH must be 16 hex characters"); return true;
}
if (c.apply_pending) { strcpy(reply, "ERR another update is already armed"); return true; }
if (c.fetch_to_folder || c.manager.fetchState() != OtaManager::COMPLETE ||
c.fetch_store.staged_size() == 0 || !c.manager.fetched_is_bootloader()) {
strcpy(reply, "ERR no complete bootloader package in local QSPI storage"); return true;
}
char m2[100];
const bool ok = c.apply_fetched_bootloader(operator_mid, operator_hash8, m2);
snprintf(reply, 160, "%s | %s", ok ? "OK" : "ERR", m2);
#else
strcpy(reply, "ERR this build cannot update its bootloader over LoRa");
#endif
} else if (is_cmd(a, "install|apply|applydelta", &rest)) {
// Apply the fetched update. Destructive (reflashes + reboots) and GATED, not interactive (no "type
// yes" round-trip - unreliable over LoRa): refuse unless the fetch is COMPLETE, then the apply path
+49 -2
View File
@@ -187,7 +187,14 @@ struct OtaContext {
if (total >= 13 && fetch_store.read(0, hdr, 8) && memcmp(hdr, MOTA_MAGIC, 4) == 0) {
uint32_t mr = total - 8; if (mr > sizeof(mb)) mr = sizeof(mb);
MotaManifest mm;
if (fetch_store.read(8, mb, mr) && mota_parse_manifest(mb, mr, mm) && !hwMatches(mm.hw_id)) {
if (!fetch_store.read(8, mb, mr) || !mota_parse_manifest(mb, mr, mm)) {
strncpy(msg, "refused: invalid staged manifest", 96); msg[95] = 0; return false;
}
if (mm.is_bootloader()) {
strncpy(msg, "refused: use ota bootloader install <MID8> <HASH16>", 96);
msg[95] = 0; 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);
return false;
@@ -209,16 +216,42 @@ struct OtaContext {
#else
ok = ota_apply_detools_mota(fetch_store.data(), fetch_store.staged_size(), allow, apply_st, msg);
#endif
if (ok) apply_pending = true;
if (ok) { bootloader_apply_pending = false; apply_pending = true; }
return ok;
#endif
}
bool apply_fetched_bootloader(const uint8_t operator_mid[4],
const uint8_t operator_hash8[8], char* msg) {
#if defined(NRF52_PLATFORM) && defined(OTA_QSPI_STORE) && \
defined(OTA_QSPI_BOOTLOADER_UPDATE) && !defined(OTA_SEEDER_ONLY)
if (fetch_to_folder) {
strncpy(msg, "refused: bootloader package is not in local QSPI storage", 96);
msg[95] = 0; return false;
}
if (manager.fetchState() != OtaManager::COMPLETE || fetch_store.staged_size() == 0) {
strncpy(msg, "refused: no complete bootloader package fetched", 96);
msg[95] = 0; return false;
}
const OtaBootloaderIdentity& installed = bootloaderIdentity();
bool ok = ota_prepare_bootloader_update_nrf52(
fetch_store, allow, installed, manager.fetchManifestId(), operator_mid,
operator_hash8, apply_st, msg);
if (ok) { bootloader_apply_pending = true; apply_pending = true; }
return ok;
#else
(void)operator_mid; (void)operator_hash8;
strncpy(msg, "refused: this build cannot update its bootloader over LoRa", 96);
msg[95] = 0; return false;
#endif
}
// Deferred apply-reboot: a verified `ota applydelta` approves the update but does NOT reboot inline,
// so the CLI can first deliver the "verified; applying" reply (over LoRa it's the only way the
// operator learns the apply started). The mesh loop then calls ota_reboot_to_apply() once that reply
// has actually been transmitted. apply_at/apply_hard are mesh-clock deadlines the loop fills in.
bool apply_pending = false;
bool bootloader_apply_pending = false;
uint32_t apply_at = 0; // earliest reboot time (lets the reply get queued + start sending)
uint32_t apply_hard = 0; // hard cap, in case the TX queue never idles on a busy node
@@ -230,6 +263,15 @@ struct OtaContext {
if (!_bl_caps_read) { _bl_caps = ota_bootloader_caps(); _bl_caps_read = true; }
return _bl_caps;
}
OtaBootloaderIdentity _bl_identity;
bool _bl_identity_read = false;
const OtaBootloaderIdentity& bootloaderIdentity() {
if (!_bl_identity_read) {
ota_installed_bootloader_identity(_bl_identity);
_bl_identity_read = true;
}
return _bl_identity;
}
// --- discovery: the "what mOTAs are available around me" view ----------------------------------
// The catalog (heard mOTAs) + the heard-sources table now live in OtaManager (built from beacons +
@@ -460,6 +502,11 @@ struct OtaContext {
manager.set_accept_full(true);
manager.set_apply_codec(CODEC_DETOOLS_SEQUENTIAL); // preferred (streams straight to the slot)
manager.set_apply_codec2(CODEC_DETOOLS_INPLACE); // also accepted -> a single in-place .mota fits both
#endif
#if defined(NRF52_PLATFORM) && defined(OTA_QSPI_STORE) && defined(OTA_QSPI_BOOTLOADER_UPDATE)
manager.set_accept_bootloader(true);
#else
manager.set_accept_bootloader(false);
#endif
manager.set_fetch_store(&fetch_store);
#if defined(NRF52_PLATFORM) && defined(OTA_SD_STORE)
+22 -2
View File
@@ -27,6 +27,11 @@ inline uint32_t mota_nrf52_app_base() {
inline uint32_t mota_nrf52_app_base() { return MOTA_NRF52_APP_BASE_S140_V6; }
#endif
static const uint32_t MOTA_NRF52_EXTRAFS_START = 0x000D4000u;
// Bootloader-update-capable XIAO builds deliberately end the linked application here. The following
// 40 KiB is reserved as internal scratch while OTAFIX copies a verified QSPI image into its own
// 0xF4000..0xFE000 region. Ordinary nRF52 builds continue to use the 0xED000 application ceiling.
static const uint32_t MOTA_NRF52_BOOT_SCRATCH_START = 0x000E0000u;
static const uint32_t MOTA_NRF52_BOOT_SCRATCH_END = 0x000EA000u;
static const uint32_t MOTA_NRF52_APP_END = 0x000ED000u;
static const uint32_t MOTA_NRF52_STAGE_CEILING_LEGACY = MOTA_NRF52_EXTRAFS_START;
static const uint32_t MOTA_NRF52_STAGE_CEILING_EXPANDED = MOTA_NRF52_APP_END;
@@ -35,6 +40,7 @@ static const uint32_t MOTA_NRF52_STAGE_CEILING_EXPANDED = MOTA_NRF52_APP_END;
static const uint32_t MOTA_NRF52_FS_START = MOTA_NRF52_EXTRAFS_START;
static const uint32_t MOTA_NRF52_FLASH_PAGE = 4096u;
static const uint8_t GPREGRET_OTA_APPLY = 0x6Au; // distinct from DFU magics 0x57/0x4E/0xA8
static const uint8_t GPREGRET_OTA_BOOTLOADER_UPDATE = 0x6Bu;
static const uint8_t GPREGRET2_OTA_STAGE_LEGACY = 0xD4u;
static const uint8_t GPREGRET2_OTA_STAGE_EXPANDED = 0xEDu;
static const uint8_t GPREGRET2_OTA_STAGE_QSPI = 0x51u;
@@ -44,12 +50,20 @@ static const uint8_t GPREGRET2_OTA_STAGE_QSPI = 0x51u;
// staged-container start; the app and bootloader both validate that per-patch value before writing.
static const uint32_t MOTA_NRF52_FALLBACK_INPLACE_MEMORY = 0x00098000u; // 608 KB
// Bootloader flash region (nRF52840: 39 KB ending just below the CF2/MBR-params pages). The app scans
// Bootloader flash region (nRF52840: 40 KiB ending just below the CF2/MBR-params pages). The app scans
// this for the bootloader capability marker (OtaBlInfo.h) to know whether THIS device's bootloader can
// actually apply a .mota before staging+approving+rebooting.
static const uint32_t MOTA_NRF52_BL_START = 0x000F4000u;
static const uint32_t MOTA_NRF52_BL_END = 0x000FE000u;
inline uint32_t mota_nrf52_application_ceiling() {
#if defined(OTA_QSPI_BOOTLOADER_UPDATE)
return MOTA_NRF52_BOOT_SCRATCH_START;
#else
return MOTA_NRF52_APP_END;
#endif
}
// Compile-time layout-ordering invariants. If a constant above is edited inconsistently these fail the
// BUILD rather than silently letting a stage/apply corrupt the filesystem (user prefs) or the app.
static_assert((MOTA_NRF52_APP_BASE_S140_V6 % MOTA_NRF52_FLASH_PAGE) == 0, "S140 v6 base must be page-aligned");
@@ -60,6 +74,10 @@ static_assert((MOTA_NRF52_STAGE_CEILING_EXPANDED % MOTA_NRF52_FLASH_PAGE) == 0,
"expanded staging ceiling must be page-aligned");
static_assert(MOTA_NRF52_STAGE_CEILING_LEGACY < MOTA_NRF52_STAGE_CEILING_EXPANDED,
"legacy staging ceiling must precede expanded ceiling");
static_assert(MOTA_NRF52_BOOT_SCRATCH_END - MOTA_NRF52_BOOT_SCRATCH_START == 0xA000u,
"bootloader scratch must be exactly one padded bootloader region");
static_assert(MOTA_NRF52_BOOT_SCRATCH_END <= MOTA_NRF52_APP_END,
"bootloader scratch must remain below InternalFS");
static_assert(MOTA_NRF52_STAGE_CEILING_EXPANDED < MOTA_NRF52_BL_START,
"staging and filesystems must end below the bootloader");
static_assert(MOTA_NRF52_BL_START < MOTA_NRF52_BL_END, "bootloader region must be non-empty");
@@ -74,7 +92,9 @@ static_assert(MOTA_NRF52_APP_BASE_S140_V7 + MOTA_NRF52_FALLBACK_INPLACE_MEMORY <
inline uint32_t mota_nrf52_stage_ceiling_for_layout(uint32_t linked_app_end,
bool uses_internal_extrafs) {
if (uses_internal_extrafs) return MOTA_NRF52_STAGE_CEILING_LEGACY;
if (linked_app_end == MOTA_NRF52_APP_END || linked_app_end == MOTA_NRF52_EXTRAFS_START)
if (linked_app_end == MOTA_NRF52_APP_END ||
linked_app_end == MOTA_NRF52_BOOT_SCRATCH_START ||
linked_app_end == MOTA_NRF52_EXTRAFS_START)
return MOTA_NRF52_STAGE_CEILING_EXPANDED;
return MOTA_NRF52_STAGE_CEILING_LEGACY;
}
+8 -1
View File
@@ -20,7 +20,12 @@ static const uint8_t ENDF_MAGIC[4] = { 'E', 'n', 'd', 'F' }; // 45 6E 64 4
static const uint32_t ENDF_LEN = 56;
// ---- manifest -------------------------------------------------------------
static const uint8_t MOTA_FORMAT_VER = 2; // fixed-layout manifest (see offsets below)
// Application packages remain v2. Bootloader packages deliberately use v3 even though the byte
// layout is unchanged: every pre-bootloader-update parser rejects v3 before it can mistake the raw
// bootloader region for a full application image.
static const uint8_t MOTA_FORMAT_VER = 2; // application package (compatibility name)
static const uint8_t MOTA_APP_FORMAT_VER = 2;
static const uint8_t MOTA_BOOT_FORMAT_VER = 3;
static const uint8_t HASH_ALGO_SHA256 = 0x12; // multihash code
// Fixed manifest layout (manifest-minus-leaves) - every field is present at a constant offset, so the
@@ -42,6 +47,8 @@ static const uint8_t MOTA_HW_ID_LEN = 32;
static const uint8_t MFLAG_FULL = 0x01; // 0 = delta/partial, 1 = full image
static const uint8_t MFLAG_SIGNED = 0x02;
static const uint8_t MFLAG_BOOTLOADER = 0x04; // v3 only; exact flags must be FULL|SIGNED|BOOTLOADER
static const uint8_t MFLAG_KNOWN = MFLAG_FULL | MFLAG_SIGNED | MFLAG_BOOTLOADER;
static const uint8_t CODEC_FULL = 0;
static const uint8_t CODEC_DETOOLS_SEQUENTIAL = 1;
+17 -6
View File
@@ -921,6 +921,10 @@ bool OtaManager::wantRow(const uint8_t* mid, uint32_t target, uint8_t codec, uin
|| _fstate == WANT_LEAVES || _fstate == VERIFYING_STAGED
|| _fstate == PAUSED) return false; // busy
if (_fstate == COMPLETE && memcmp(mid, _fid, 4) == 0) return false; // already have it
if (flags & ~MFLAG_KNOWN) return false;
const bool bootloader = (flags & MFLAG_BOOTLOADER) != 0;
if (!_archive_fetch && bootloader &&
(!_accept_bootloader || !_have_desired_mid || codec != CODEC_FULL)) return false;
if (!_archive_fetch && !codecOk(codec)) return false; // can't apply this codec
if (_have_desired_mid) // manual pull of a specific mid
return memcmp(mid, _desired_mid, 4) == 0 && (_desired_target == 0 || target == _desired_target);
@@ -1169,10 +1173,16 @@ void OtaManager::handleManifest(const uint8_t* m, uint16_t n) {
const uint8_t* mf = _mf_buf; // fully reassembled manifest-minus-leaves
uint32_t mfl = _mf_len;
if (mfl != MOTA_MFL) { failFetch(FETCH_ERROR_MANIFEST); return; } // fixed 197-byte manifest
if (mf[2] != HASH_ALGO_SHA256) { failFetch(FETCH_ERROR_HASH_ALGO); return; }
if (!_archive_fetch && !codecOk(mf[56])) { failFetch(FETCH_ERROR_CODEC); return; }
uint32_t payload_size = rd_u32le(mf + 15);
uint8_t bsl = mf[19];
MotaManifest parsed;
if (!mota_parse_manifest(mf, mfl, parsed)) { failFetch(FETCH_ERROR_MANIFEST); return; }
if (parsed.hash_algo != HASH_ALGO_SHA256) { failFetch(FETCH_ERROR_HASH_ALGO); return; }
if (!_archive_fetch && parsed.is_bootloader() &&
(!_accept_bootloader || !_have_desired_mid)) {
failFetch(FETCH_ERROR_CODEC); return; // bootloader packages are manual-pull only
}
if (!_archive_fetch && !codecOk(parsed.codec_id)) { failFetch(FETCH_ERROR_CODEC); return; }
uint32_t payload_size = parsed.payload_size;
uint8_t bsl = parsed.block_size_log2;
if (bsl >= 32) { failFetch(FETCH_ERROR_GEOMETRY); return; }
uint32_t bs = 1u << bsl;
// a block must fit our reassembly buffer (and be non-empty) - reject an oversized block_size up front
@@ -1193,8 +1203,8 @@ void OtaManager::handleManifest(const uint8_t* m, uint16_t n) {
// Hand the store the parsed layout BEFORE begin(), so a partition-backed store (ESP32) can choose
// placement and refuse an unfittable fetch up front: a FULL payload streams to the inactive slot,
// a delta's whole container is staged together. (image_size at mf+11, is_full from flags at mf+1.)
bool is_full = (mf[1] & MFLAG_FULL) != 0;
if (!_fetch->plan_layout(is_full, rd_u32le(mf + 11), payload_off, payload_size)) {
bool is_full = parsed.is_full();
if (!_fetch->plan_layout(is_full, parsed.image_size, payload_off, payload_size)) {
failFetch(FETCH_ERROR_STORAGE); return;
}
if (!_fetch->begin(total)) { failFetch(FETCH_ERROR_STORAGE); return; }
@@ -1316,6 +1326,7 @@ bool OtaManager::resumeStaged(const uint8_t* want_mid) {
if (!_fetch->read(8, mbuf, mread) || !mota_parse_manifest(mbuf, mread, m)) return false;
if (want_mid && memcmp(m.merkle_root, want_mid, 4) != 0) return false; // a different fw is staged
if (m.hash_algo != HASH_ALGO_SHA256) return false;
if (!_archive_fetch && m.is_bootloader() && !_accept_bootloader) return false;
if (!_archive_fetch && !codecOk(m.codec_id)) return false;
uint32_t mfl = (uint32_t)(m.approval - m.manifest_start) + 4; // manifest-minus-leaves length
uint32_t bs = m.block_size();
+4
View File
@@ -359,6 +359,8 @@ public:
// in-place `.mota` can target both ESP32 and nRF52). 0xFF = unset.
void set_apply_codec2(uint8_t c) { _apply_codec2 = c; }
void set_accept_full(bool on) { _accept_full = on; }
void set_accept_bootloader(bool on) { _accept_bootloader = on; }
bool accepts_bootloader() const { return _accept_bootloader; }
bool codecOk(uint8_t c) const {
return (c == CODEC_FULL && _accept_full) || c == _apply_codec || c == _apply_codec2;
}
@@ -391,6 +393,7 @@ public:
void set_max_hops(uint8_t h) { _max_hops = h; }
uint8_t max_hops() const { return _max_hops; }
bool fetched_is_signed() const { return (_fflags & MFLAG_SIGNED) != 0; } // flags of the fetched manifest
bool fetched_is_bootloader() const { return (_fflags & MFLAG_BOOTLOADER) != 0; }
// This node's id (pubkey[0:4]), stamped into adverts we send so receivers can count distinct seeders.
void set_seeder_id(const uint8_t* id4) { if (id4) for (int i = 0; i < 4; i++) _seeder_id[i] = id4[i]; }
@@ -601,6 +604,7 @@ private:
uint8_t _apply_codec = CODEC_DETOOLS_SEQUENTIAL; // platform's delta codec (OtaContext sets it)
uint8_t _apply_codec2 = 0xFF; // optional 2nd accepted delta codec (ESP32: in-place)
bool _accept_full = true; // false on nRF52 flash (single-slot cannot apply full)
bool _accept_bootloader = false; // explicit/manual only; never an auto-fetch signal
bool _archive_fetch = false; // capture-only pull: accept any target/codec
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
+3 -2
View File
@@ -684,8 +684,9 @@ bool OtaStoreQspiNrf52::plan_layout(bool, uint32_t image_size, uint32_t, uint32_
_io_ok = true;
_stage = OtaQspiStage::IDLE;
const uint32_t app_base = mota_nrf52_app_base();
if (image_size == 0 || payload_size == 0 || app_base >= MOTA_NRF52_APP_END ||
image_size > MOTA_NRF52_APP_END - app_base) {
const uint32_t app_ceiling = mota_nrf52_application_ceiling();
if (image_size == 0 || payload_size == 0 || app_base >= app_ceiling ||
image_size > app_ceiling - app_base) {
fail("image exceeds nRF52 application region");
releaseFlash();
return false;
+1 -1
View File
@@ -70,7 +70,7 @@ does not reflect the GoogleTest count -- run the built binary directly
| `test_user_gpio` | `src/helpers/UserGpio.cpp`, `UserGpioReplyTracker.h` | board-approved pins, get/set/reset, timed nonblocking transitions, duplicate suppression, rollover, and completion-reply routing |
| `test_utf8_helpers` | `src/helpers/UTF8Helpers.h` | byte-limit truncation at complete code-point boundaries and rejection of malformed or truncated UTF-8 |
| `test_wifi_ota_seeder_policy` | `src/helpers/WiFiOtaSeederPolicy.h`, `WiFiOtaSeederStatus.h` | listener state versus network availability, serial/TCP folder ownership, detach detection, and bounded status formatting |
| `test_ota` | `src/helpers/ota/` | container and EndF integrity; protocol codecs; transfer, resume, and apply safety; adaptive 2-to-4 block-request window growth and stall contraction; active-transfer priority classification |
| `test_ota` | `src/helpers/ota/` | v2 application/v3 bootloader parser separation; XIAO identity, vector, capability, explicit-confirmation and no-autofetch gates; container and EndF integrity; protocol codecs; transfer, resume, and layered apply safety; adaptive 2-to-4 block-request window growth and stall contraction; active-transfer priority classification |
| `test_trace_retry` | `src/Mesh.cpp` retry and relay policy | opaque OTA relay behavior during TempRadio; background discovery priority; immediate primary transfer relay, receive-delay bypass, fast CAD retry, and no generic flood retry; trace and non-OTA flood retry timing |
| `test_utils` | `src/Utils.cpp` | `Utils::toHex` (upstream) |
+201
View File
@@ -15,6 +15,7 @@
#include "helpers/ota/OtaProtocol.h"
#include "helpers/ota/OtaManager.h"
#include "helpers/ota/OtaApply.h"
#include "helpers/ota/OtaBlInfo.h"
#include "mota_vectors.h" // auto-generated by tools/mota/gen_vectors.py
extern "C" {
@@ -29,6 +30,11 @@ TEST(OtaBootResult, AcceptsOnlyOtafixApplyDiagnostics) {
EXPECT_EQ(ota_nrf52_boot_result_or_zero(0xB0), 0xB0);
EXPECT_EQ(ota_nrf52_boot_result_or_zero(0xB8), 0xB8);
EXPECT_EQ(ota_nrf52_boot_result_or_zero(0xBC), 0xBC);
EXPECT_EQ(ota_nrf52_boot_result_or_zero(0xC0), 0xC0);
EXPECT_EQ(ota_nrf52_boot_result_or_zero(0xC8), 0xC8);
EXPECT_EQ(ota_nrf52_boot_result_or_zero(0xCF), 0xCF);
EXPECT_TRUE(ota_nrf52_boot_update_result(0xC8));
EXPECT_FALSE(ota_nrf52_boot_update_result(0xB8));
EXPECT_EQ(ota_nrf52_boot_result_or_zero(0x00), 0x00);
EXPECT_EQ(ota_nrf52_boot_result_or_zero(0x4C), 0x00); // shutdown reason
@@ -38,6 +44,154 @@ TEST(OtaBootResult, AcceptsOnlyOtafixApplyDiagnostics) {
EXPECT_EQ(ota_nrf52_boot_result_or_zero(0xBD), 0x00);
}
static std::vector<uint8_t> boot_manifest_bytes() {
std::vector<uint8_t> m(MOTA_MFL, 0);
m[0] = MOTA_BOOT_FORMAT_VER;
m[1] = MFLAG_FULL | MFLAG_SIGNED | MFLAG_BOOTLOADER;
m[2] = HASH_ALGO_SHA256;
wr_u32le(m.data() + 3, OTA_XIAO_BOARD_ID_BASE);
wr_u32le(m.data() + 7, 1);
wr_u32le(m.data() + 11, OTA_BOOT_IMAGE_SIZE);
wr_u32le(m.data() + 15, OTA_BOOT_IMAGE_SIZE);
m[19] = 10;
m[20] = 0x11; m[21] = 0x22; m[22] = 0x33; m[23] = 0x44;
for (uint8_t i = 0; i < 32; i++) m[24 + i] = (uint8_t)(0x80 + i);
m[56] = CODEC_FULL;
uint8_t hw[32]; EXPECT_TRUE(ota_xiao_bootloader_hw_id(OTA_XIAO_BOARD_ID_BASE, hw));
memcpy(m.data() + 57, hw, sizeof(hw));
memset(m.data() + MOTA_OFF_APPROVAL, 0xFF, 4);
return m;
}
TEST(OtaBootPackage, ParserSeparatesV2ApplicationsFromStrictV3Bootloader) {
MotaManifest parsed;
auto m = boot_manifest_bytes();
ASSERT_TRUE(mota_parse_manifest(m.data(), m.size(), parsed));
EXPECT_TRUE(parsed.is_bootloader());
EXPECT_EQ(parsed.block_count, 40u);
m[0] = MOTA_APP_FORMAT_VER;
EXPECT_FALSE(mota_parse_manifest(m.data(), m.size(), parsed));
m = boot_manifest_bytes(); m[1] &= ~MFLAG_BOOTLOADER;
EXPECT_FALSE(mota_parse_manifest(m.data(), m.size(), parsed));
m = boot_manifest_bytes(); m[19] = 9;
EXPECT_FALSE(mota_parse_manifest(m.data(), m.size(), parsed));
m = boot_manifest_bytes(); memset(m.data() + 7, 0, 4);
EXPECT_FALSE(mota_parse_manifest(m.data(), m.size(), parsed));
m = boot_manifest_bytes(); m[MOTA_OFF_BASE_HASH] = 1;
EXPECT_FALSE(mota_parse_manifest(m.data(), m.size(), parsed));
}
TEST(OtaBootPackage, EmbeddedIdentityVectorCapsAndExplicitConfirmationGate) {
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);
ASSERT_TRUE(ota_bootloader_vector_sane(image.data()));
image[4] &= 0xFE;
EXPECT_FALSE(ota_bootloader_vector_sane(image.data()));
wr_u32le(image.data() + 4, OTA_BOOT_IMAGE_START + 0x101u);
const uint32_t moff = 0x100;
memset(image.data() + moff, 0, OTA_BOOT_MANIFEST_SIZE);
wr_u32le(image.data() + moff, OTA_BOOT_MANIFEST_MAGIC0);
wr_u32le(image.data() + moff + 4, OTA_BOOT_MANIFEST_MAGIC1);
image[moff + 8] = OTA_BOOT_MANIFEST_VERSION;
image[moff + 10] = OTA_BOOT_MANIFEST_SIZE;
wr_u32le(image.data() + moff + 12, OTA_BOOT_IMAGE_START);
wr_u32le(image.data() + moff + 16, OTA_BOOT_IMAGE_SIZE);
wr_u32le(image.data() + moff + 20, OTA_XIAO_BOARD_ID_BASE);
memcpy(image.data() + moff + 24, OTA_XIAO_BOOT_DEVICE_NAME,
OTA_BOOT_DEVICE_NAME_SIZE);
wr_u32le(image.data() + moff + 40,
ota_boot_image_crc32(image.data(), image.size(), moff + 40));
const std::vector<uint8_t> canonical_image = image;
OtaBootloaderIdentity identity;
ASSERT_TRUE(ota_bootloader_identity_from_image(image.data(), image.size(), identity));
EXPECT_TRUE(identity.crc_ok);
EXPECT_EQ(identity.board_id, OTA_XIAO_BOARD_ID_BASE);
// A structurally complete bad-CRC decoy before the real manifest must not
// shadow the later valid identity.
const uint32_t decoy = 0x20;
memcpy(image.data() + decoy, image.data() + moff, OTA_BOOT_MANIFEST_SIZE);
wr_u32le(image.data() + decoy + 40, 0xA5A5A5A5u);
wr_u32le(image.data() + moff + 40, 0);
wr_u32le(image.data() + moff + 40,
ota_boot_image_crc32(image.data(), image.size(), moff + 40));
ASSERT_NE(ota_boot_image_crc32(image.data(), image.size(), decoy + 40), 0xA5A5A5A5u);
ASSERT_TRUE(ota_bootloader_identity_from_image(image.data(), image.size(), identity));
EXPECT_EQ(identity.manifest_offset, moff);
// These coupled CRC values make both otherwise identical manifests valid
// in this deterministic fixture. Two privileged identities are ambiguous
// and must fail closed in the helper shared with the staged-QSPI scanner.
image = canonical_image;
const uint32_t second = 0x200;
memcpy(image.data() + second, image.data() + moff, OTA_BOOT_MANIFEST_SIZE);
wr_u32le(image.data() + moff + 40, 0x07033138u);
wr_u32le(image.data() + second + 40, 0x0998508Cu);
ASSERT_EQ(ota_boot_image_crc32(image.data(), image.size(), moff + 40), 0x07033138u);
ASSERT_EQ(ota_boot_image_crc32(image.data(), image.size(), second + 40), 0x0998508Cu);
EXPECT_FALSE(ota_bootloader_identity_from_image(image.data(), image.size(), identity));
image = canonical_image;
image[0x300] ^= 1;
EXPECT_FALSE(ota_bootloader_identity_from_image(image.data(), image.size(), identity));
uint8_t marker[16] = {'M','O','T','A','B','L','D','R', 3,0, 1,0,
(uint8_t)(OTA_BL_STORAGE_QSPI | OTA_BL_STORAGE_BOOT_UPDATE), 0,0,0};
OtaBootloaderCapsMarker caps;
EXPECT_TRUE(ota_bootloader_caps_marker_parse(marker, caps));
marker[15] = 1;
EXPECT_FALSE(ota_bootloader_caps_marker_parse(marker, caps));
OtaBlCaps selected;
selected.present = true;
selected.apply_abi = 3;
selected.storage_flags = OTA_BL_STORAGE_QSPI | OTA_BL_STORAGE_BOOT_UPDATE;
EXPECT_FALSE(ota_bl_caps_prefer(selected, 99, OTA_BL_STORAGE_QSPI, true));
EXPECT_TRUE(ota_bl_caps_prefer(selected, 4,
OTA_BL_STORAGE_QSPI | OTA_BL_STORAGE_BOOT_UPDATE, true));
auto mf = boot_manifest_bytes();
MotaManifest m;
ASSERT_TRUE(mota_parse_manifest(mf.data(), mf.size(), m));
OtaBootloaderIdentity installed;
installed.present = installed.crc_ok = true;
installed.image_start = OTA_BOOT_IMAGE_START;
installed.image_size = OTA_BOOT_IMAGE_SIZE;
installed.board_id = OTA_XIAO_BOARD_ID_BASE;
memcpy(installed.device_name, OTA_XIAO_BOOT_DEVICE_NAME,
OTA_BOOT_DEVICE_NAME_SIZE);
uint8_t mid[4] = {0x11,0x22,0x33,0x44};
uint8_t hash8[8]; memcpy(hash8, m.image_hash, sizeof(hash8));
EXPECT_EQ(ota_bootloader_confirmation_gate(m, installed, mid, mid, hash8), OTA_BOOT_CONFIRM_OK);
uint8_t bad_mid[4] = {0};
EXPECT_EQ(ota_bootloader_confirmation_gate(m, installed, mid, bad_mid, hash8),
OTA_BOOT_CONFIRM_MID);
hash8[0] ^= 1;
EXPECT_EQ(ota_bootloader_confirmation_gate(m, installed, mid, mid, hash8),
OTA_BOOT_CONFIRM_IMAGE_HASH);
}
TEST(OtaBootPackage, CapabilityScannerRejectsAnOtherwiseValidUnalignedMarker) {
const uint8_t marker[16] = {'M','O','T','A','B','L','D','R', 3,0, 1,0,
(uint8_t)(OTA_BL_STORAGE_QSPI | OTA_BL_STORAGE_BOOT_UPDATE), 0,0,0};
uint8_t image[64];
memset(image, 0xFF, sizeof(image));
memcpy(image + 1, marker, sizeof(marker));
EXPECT_FALSE(ota_bl_caps_scan_aligned(image, sizeof(image), true).present);
memset(image, 0xFF, sizeof(image));
memcpy(image + 4, marker, sizeof(marker));
const OtaBlCaps caps = ota_bl_caps_scan_aligned(image, sizeof(image), true);
ASSERT_TRUE(caps.present);
EXPECT_EQ(caps.apply_abi, 3u);
EXPECT_EQ(caps.storage_flags,
(uint8_t)(OTA_BL_STORAGE_QSPI | OTA_BL_STORAGE_BOOT_UPDATE));
}
class FakeMotaSeederStream : public Stream {
public:
using Stream::write;
@@ -1812,6 +1966,53 @@ static uint16_t make_have1(uint8_t* buf, uint16_t cap, const uint8_t mid[4],
return encode_have(buf, cap, hv);
}
TEST(OtaTransfer, BootloaderPackageRequiresCapableExplicitMidPull) {
const auto manifest = boot_manifest_bytes();
const uint8_t mid[4] = {0x11, 0x22, 0x33, 0x44};
const uint8_t boot_flags = MFLAG_FULL | MFLAG_SIGNED | MFLAG_BOOTLOADER;
uint8_t wire[64];
// Even a capable XIAO must never autofetch a bootloader package. The target is intentionally
// its exact installed-bootloader board ID, so this proves the manual-MID gate rather than a
// coincidental target mismatch.
OtaManager capable;
OtaStoreRam<45000> capable_store;
capable.begin(OTA_XIAO_BOARD_ID_BASE, nullptr, nullptr);
capable.set_fetch_store(&capable_store);
capable.set_accept_full(true);
capable.set_accept_bootloader(true);
capable.set_autofetch(OtaManager::AUTOFETCH_ANY);
capable.on_message(
wire, make_have1(wire, sizeof(wire), mid, OTA_XIAO_BOARD_ID_BASE, 1,
CODEC_FULL, boot_flags));
EXPECT_EQ(capable.fetchState(), OtaManager::IDLE);
EXPECT_EQ(capable_store.staged_size(), 0u);
// An exact explicit pull admits the strict v3 manifest and opens the store for the 40 KiB payload.
EXPECT_EQ(capable.pull(mid, OTA_XIAO_BOARD_ID_BASE), OtaManager::PULL_STARTED);
deliver_manifest_fragment(capable, mid, 0, manifest.data(), OTA_MF_FRAG);
deliver_manifest_fragment(capable, mid, 1, manifest.data() + OTA_MF_FRAG,
(uint16_t)(MOTA_MFL - OTA_MF_FRAG));
EXPECT_EQ(capable.fetchState(), OtaManager::FETCHING);
EXPECT_TRUE(capable.fetched_is_bootloader());
EXPECT_GT(capable_store.staged_size(), OTA_BOOT_IMAGE_SIZE);
// The same explicit pull is rejected before storage begin when this firmware lacks the privileged
// capability. This is the manager-layer guard; every apply backend has an independent rejection too.
OtaManager incapable;
OtaStoreRam<45000> incapable_store;
incapable.begin(OTA_XIAO_BOARD_ID_BASE, nullptr, nullptr);
incapable.set_fetch_store(&incapable_store);
incapable.set_accept_full(true);
EXPECT_EQ(incapable.pull(mid, OTA_XIAO_BOARD_ID_BASE), OtaManager::PULL_STARTED);
deliver_manifest_fragment(incapable, mid, 0, manifest.data(), OTA_MF_FRAG);
deliver_manifest_fragment(incapable, mid, 1, manifest.data() + OTA_MF_FRAG,
(uint16_t)(MOTA_MFL - OTA_MF_FRAG));
EXPECT_EQ(incapable.fetchState(), OtaManager::FAILED);
EXPECT_EQ(incapable.fetchError(), OtaManager::FETCH_ERROR_CODEC);
EXPECT_EQ(incapable_store.staged_size(), 0u);
}
// A node must not fetch firmware it can't apply: a catalog row whose codec the platform can't decode is
// not fetched. Full-image acceptance is platform-selectable (nRF52 single-slot disables it).
TEST(OtaTransfer, RejectsIncompatibleCodec) {
+3 -1
View File
@@ -69,8 +69,10 @@ TEST(OtaFlashPlan, SelectsCeilingFromLinkedLayoutAndStorage) {
// Standard or ExtraFS linker without an internal secondary filesystem reclaims the unused 100 KiB.
EXPECT_EQ(mota_nrf52_stage_ceiling_for_layout(EXPANDED, false), EXPANDED);
EXPECT_EQ(mota_nrf52_stage_ceiling_for_layout(LEGACY, false), EXPANDED);
// The explicit XIAO boot-update linker leaves 40 KiB scratch at 0xE0000 and still stages externally.
EXPECT_EQ(mota_nrf52_stage_ceiling_for_layout(MOTA_NRF52_BOOT_SCRATCH_START, false), EXPANDED);
// An unrecognized linker region is never permission to erase a larger window.
EXPECT_EQ(mota_nrf52_stage_ceiling_for_layout(0xE0000u, false), LEGACY);
EXPECT_EQ(mota_nrf52_stage_ceiling_for_layout(0xE1000u, false), LEGACY);
}
TEST(OtaFlashPlan, ValidatesExternalInplacePatchGeometryBeforeHandoff) {
+12 -1
View File
@@ -43,8 +43,12 @@ import zipfile
MOTA_MAGIC = b"mOTA"
MOTA_TRAILER = b"vk496"
MOTA_FORMAT_VERSION = 2
MOTA_BOOT_FORMAT_VERSION = 3
MOTA_FIXED_MANIFEST_SIZE = 197
MOTA_FLAG_FULL = 0x01
MOTA_FLAG_SIGNED = 0x02
MOTA_FLAG_BOOTLOADER = 0x04
MOTA_KNOWN_FLAGS = MOTA_FLAG_FULL | MOTA_FLAG_SIGNED | MOTA_FLAG_BOOTLOADER
MOTA_CODEC_FULL = 0
MOTA_CODEC_SEQUENTIAL = 1
MOTA_CODEC_IN_PLACE = 2
@@ -462,12 +466,19 @@ def parse_mota(blob: bytes, path: Path | None = None) -> MotaInfo:
raise OtaError(
f"mOTA size field is {declared_size}, but the file is {len(blob)} bytes"
)
flags = blob[9]
if blob[8] == MOTA_BOOT_FORMAT_VERSION and flags & MOTA_FLAG_BOOTLOADER:
raise OtaError(
"bootloader mOTA packages require the device's explicit `ota bootloader install` "
"workflow; this application-update runner deliberately refuses them"
)
if blob[8] != MOTA_FORMAT_VERSION:
raise OtaError(f"unsupported mOTA format version {blob[8]}")
if flags & MOTA_FLAG_BOOTLOADER or flags & ~MOTA_KNOWN_FLAGS:
raise OtaError("invalid flags in v2 application mOTA")
if blob[10] != 0x12:
raise OtaError(f"unsupported mOTA hash algorithm 0x{blob[10]:02x}")
flags = blob[9]
target_id, fw_version, image_size, payload_size = struct.unpack_from("<IIII", blob, 11)
block_size_log2 = blob[27]
if block_size_log2 == 0 or block_size_log2 > 10:
+11
View File
@@ -137,6 +137,17 @@ class FormatTests(unittest.TestCase):
with self.assertRaisesRegex(ota.OtaError, "block hashes"):
ota.parse_mota(bytes(blob))
def test_bootloader_packages_are_explicitly_refused(self) -> None:
blob = bytearray(mota_blob(firmware(b"B" * 5000, VERSION_NEW)))
blob[8] = ota.MOTA_BOOT_FORMAT_VERSION
blob[9] |= ota.MOTA_FLAG_SIGNED | ota.MOTA_FLAG_BOOTLOADER
with self.assertRaisesRegex(ota.OtaError, "bootloader mOTA packages"):
ota.parse_mota(bytes(blob))
blob[8] = ota.MOTA_FORMAT_VERSION
with self.assertRaisesRegex(ota.OtaError, "invalid flags in v2"):
ota.parse_mota(bytes(blob))
def test_block_larger_than_firmware_buffer_is_rejected(self) -> None:
image = firmware(b"C" * 5000, VERSION_NEW)
with self.assertRaisesRegex(ota.OtaError, "at most 1024 bytes"):
+19 -2
View File
@@ -31,8 +31,8 @@ Uses the repo's Python venv (`meshcore/`). Dependencies: `detools` (delta), `cry
## Tests
```bash
./meshcore/bin/python tools/mota/test_mota.py # EndF, merkle+proofs, full/delta, signing,
# tamper detection, approval enforcement
./meshcore/bin/python tools/mota/test_mota.py # EndF, merkle+proofs, v2 apps/v3 XIAO bootloader,
# signing, tamper detection, approval enforcement
./meshcore/bin/python tools/mota/gen_vectors.py # regenerate the native-test cross-check vectors
./meshcore/bin/python tools/mota/gen_targets.py # regenerate src/helpers/ota/OtaTargets.h (needs `pio`)
```
@@ -51,3 +51,20 @@ firmware and matches a delta's `base_hash` against its own `EndF`. Wiring (handl
`target_id` = `sha2-256:4(pio_env_name)`, `hw_id` = explicit `-D MOTA_HW_ID` or a role-stripped hardware
family derived from the environment name, and `fw_version` = parsed from `FIRMWARE_VERSION`. A node (and
`motatool`, reading the firmware's `EndF`) therefore auto-discovers identity without relying on filenames.
## Privileged XIAO bootloader reference builder
Ordinary calls to `build_manifest()` continue to emit v2 application packages.
Passing `bootloader=True` is a deliberately narrow reference-only path: it
requires a nonzero version, a signed exact 40 KiB XIAO OTAFIX region, 1024-byte
blocks, full codec, zero base hash, a sane vector table, exact `XIAO_DFU`
embedded manifest/CRC/board identity, and an ABI-3 marker retaining QSPI plus
boot-update capabilities. It derives the signed `XIAO_BL_28860044` or
`XIAO_BL_28860045` hardware ID from the embedded board ID. Parsing and
`verify()` repeat those gates; magic literals that are not complete valid
structures are skipped.
This library does not authorize a device update. A capable node will only arm
such a v3 package through the exact manual confirmation described in
[`docs/ota_nrf52_qspi.md`](../../docs/ota_nrf52_qspi.md#explicit-xiao-bootloader-updates-over-lora).
The ordinary `tools/lora_ota` runner rejects bootloader packages.
+168 -9
View File
@@ -1,7 +1,7 @@
"""
motalib - build/parse/verify MeshCore ``.mota`` firmware-update containers.
Pure logic, no CLI. Implements docs/ota_protocol.md (format_ver=2, fixed layout).
Pure logic, no CLI. Implements docs/ota_protocol.md (v2 application / v3 bootloader, fixed layout).
The wire format (all integers little-endian):
@@ -37,11 +37,15 @@ ENDF_MAGIC = b"EndF" # 45 6E 64 46
# Fixed-length trailer: marker(4) + body_len(4) + body_hash8(8) + fw_version(4) + target_id(4) + hw_id(32).
ENDF_LEN = 56
FORMAT_VER = 2 # the one manifest format (fixed layout, see Manifest); other values are rejected
FORMAT_VER = 2 # application compatibility name
APP_FORMAT_VER = 2
BOOT_FORMAT_VER = 3
HASH_ALGO_SHA256 = 0x12 # multihash code for sha2-256
FLAG_FULL = 0x01
FLAG_SIGNED = 0x02
FLAG_BOOTLOADER = 0x04
FLAG_KNOWN = FLAG_FULL | FLAG_SIGNED | FLAG_BOOTLOADER
CODEC_FULL = 0
CODEC_DETOOLS_SEQUENTIAL = 1 # detools `sequential` patch (decoded on-device by vendored detools C)
@@ -61,6 +65,8 @@ NRF52_INPLACE_MEMORY = 0x00098000
NRF52_APP_BASE_S140_V6 = 0x00026000
NRF52_APP_BASE_S140_V7 = 0x00027000
NRF52_EXTRAFS_START = 0x000D4000
NRF52_BOOT_SCRATCH_START = 0x000E0000
NRF52_BOOT_SCRATCH_END = 0x000EA000
NRF52_APP_END = 0x000ED000
NRF52_FLASH_PAGE = 4096
@@ -73,6 +79,19 @@ NRF52_LAYOUT_LEN = 24
NRF52_LAYOUT_FLAG_SD = 0x01
NRF52_LAYOUT_FLAG_INTERNAL_EXTRAFS = 0x02
NRF52_LAYOUT_FLAG_QSPI = 0x04
NRF52_LAYOUT_FLAG_BOOTLOADER_SCRATCH = 0x08
XIAO_BOOT_BOARD_ID_BASE = 0x28860044
XIAO_BOOT_BOARD_ID_SENSE = 0x28860045
XIAO_BOOT_IMAGE_START = 0x000F4000
XIAO_BOOT_IMAGE_SIZE = 0x0000A000
XIAO_BOOT_MANIFEST_MAGIC = b"BLMFCRC1"
XIAO_BOOT_MANIFEST_VERSION = 1
XIAO_BOOT_MANIFEST_SIZE = 44
XIAO_BOOT_DEVICE_NAME = b"XIAO_DFU".ljust(16, b"\0")
XIAO_BOOT_CAPS_MAGIC = b"MOTABLDR"
XIAO_BOOT_STORAGE_QSPI = 0x04
XIAO_BOOT_STORAGE_UPDATE = 0x08
# 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.
@@ -199,12 +218,16 @@ class Nrf52Layout:
def external_backed(self) -> bool:
return self.sd_backed or self.qspi_backed
@property
def bootloader_scratch(self) -> bool:
return bool(self.flags & NRF52_LAYOUT_FLAG_BOOTLOADER_SCRATCH)
def nrf52_stage_ceiling_for_layout(linked_app_end: int, uses_internal_extrafs: bool) -> int:
"""Select a safe staging ceiling from linker geometry and actual secondary-storage type."""
if uses_internal_extrafs:
return NRF52_EXTRAFS_START
if linked_app_end in (NRF52_EXTRAFS_START, NRF52_APP_END):
if linked_app_end in (NRF52_EXTRAFS_START, NRF52_BOOT_SCRATCH_START, NRF52_APP_END):
return NRF52_APP_END
return NRF52_EXTRAFS_START
@@ -212,20 +235,25 @@ def nrf52_stage_ceiling_for_layout(linked_app_end: int, uses_internal_extrafs: b
def build_nrf52_layout(layout: Nrf52Layout) -> bytes:
if layout.app_base not in (NRF52_APP_BASE_S140_V6, NRF52_APP_BASE_S140_V7):
raise ValueError(f"unsupported nRF52 app base 0x{layout.app_base:X}")
if layout.linked_app_end not in (NRF52_EXTRAFS_START, NRF52_APP_END):
if layout.linked_app_end not in (NRF52_EXTRAFS_START, NRF52_BOOT_SCRATCH_START, NRF52_APP_END):
raise ValueError(f"unsupported nRF52 linked app end 0x{layout.linked_app_end:X}")
if layout.stage_ceiling not in (NRF52_EXTRAFS_START, NRF52_APP_END):
raise ValueError(f"unsupported nRF52 staging ceiling 0x{layout.stage_ceiling:X}")
if not (layout.app_base < layout.linked_app_end <= NRF52_APP_END):
raise ValueError("invalid nRF52 app region")
known_flags = (NRF52_LAYOUT_FLAG_SD | NRF52_LAYOUT_FLAG_INTERNAL_EXTRAFS |
NRF52_LAYOUT_FLAG_QSPI)
NRF52_LAYOUT_FLAG_QSPI | NRF52_LAYOUT_FLAG_BOOTLOADER_SCRATCH)
if layout.flags & ~known_flags:
raise ValueError(f"unsupported nRF52 layout flags 0x{layout.flags:X}")
if layout.sd_backed and layout.qspi_backed:
raise ValueError("nRF52 layout cannot use both SD and QSPI staging")
if layout.external_backed and layout.flags & NRF52_LAYOUT_FLAG_INTERNAL_EXTRAFS:
raise ValueError("nRF52 external staging cannot also reserve internal ExtraFS")
if layout.bootloader_scratch:
if not layout.qspi_backed or layout.sd_backed or layout.linked_app_end != NRF52_BOOT_SCRATCH_START:
raise ValueError("nRF52 bootloader scratch requires QSPI and linked app end 0xE0000")
elif layout.linked_app_end == NRF52_BOOT_SCRATCH_START:
raise ValueError("nRF52 linked app end 0xE0000 requires the bootloader-scratch flag")
expected_ceiling = (NRF52_APP_END if layout.external_backed else
nrf52_stage_ceiling_for_layout(
layout.linked_app_end,
@@ -417,6 +445,10 @@ class Manifest:
def is_signed(self) -> bool:
return bool(self.flags & FLAG_SIGNED)
@property
def is_bootloader(self) -> bool:
return bool(self.flags & FLAG_BOOTLOADER)
@property
def block_size(self) -> int:
return 1 << self.block_size_log2
@@ -460,6 +492,98 @@ def hw_id_bytes(s) -> bytes:
return raw + b"\0" * (32 - len(raw))
def xiao_bootloader_hw_id(board_id: int) -> str:
if board_id == XIAO_BOOT_BOARD_ID_BASE:
return "XIAO_BL_28860044"
if board_id == XIAO_BOOT_BOARD_ID_SENSE:
return "XIAO_BL_28860045"
raise ValueError(f"unsupported XIAO bootloader board ID 0x{board_id:08X}")
@dataclass(frozen=True)
class XiaoBootloaderIdentity:
manifest_offset: int
image_start: int
image_size: int
board_id: int
device_name: str
crc32: int
def _xiao_bootloader_vector_sane(image: bytes) -> bool:
if len(image) < 8:
return False
sp, reset = struct.unpack_from("<II", image)
entry = reset & ~1
return (sp & 7) == 0 and 0x20000000 <= sp <= 0x20040000 and (reset & 1) != 0 \
and XIAO_BOOT_IMAGE_START <= entry < XIAO_BOOT_IMAGE_START + XIAO_BOOT_IMAGE_SIZE
def parse_xiao_bootloader_identity(image: bytes) -> Optional[XiaoBootloaderIdentity]:
"""Find exactly one CRC-valid embedded v1 manifest, continuing past invalid decoys."""
if len(image) != XIAO_BOOT_IMAGE_SIZE:
return None
found = None
for off in range(0, len(image) - XIAO_BOOT_MANIFEST_SIZE + 1, 4):
if image[off:off + 8] != XIAO_BOOT_MANIFEST_MAGIC:
continue
version, size = struct.unpack_from("<HH", image, off + 8)
start, image_size, board_id = struct.unpack_from("<III", image, off + 12)
name_raw = image[off + 24:off + 40]
stored_crc = struct.unpack_from("<I", image, off + 40)[0]
if (version != XIAO_BOOT_MANIFEST_VERSION or size != XIAO_BOOT_MANIFEST_SIZE or
start != XIAO_BOOT_IMAGE_START or image_size != XIAO_BOOT_IMAGE_SIZE or
board_id not in (XIAO_BOOT_BOARD_ID_BASE, XIAO_BOOT_BOARD_ID_SENSE) or
name_raw != XIAO_BOOT_DEVICE_NAME):
continue
crc_image = bytearray(image)
crc_image[off + 40:off + 44] = b"\0" * 4
import zlib
if zlib.crc32(crc_image) & 0xFFFFFFFF != stored_crc:
continue
candidate = XiaoBootloaderIdentity(
off, start, image_size, board_id,
name_raw.split(b"\0", 1)[0].decode("ascii", "strict"), stored_crc)
if found is not None:
return None
found = candidate
return found
def xiao_bootloader_caps_ok(image: bytes) -> bool:
"""Find a real capability marker; a bare MOTABLDR literal is skipped."""
# The structure is emitted with uint16_t fields, so only aligned candidates
# are valid. This also avoids accepting an incidental magic string embedded
# in instruction/literal data as the privileged continuity marker.
for off in range(0, len(image) - 16 + 1, 4):
if image[off:off + 8] != XIAO_BOOT_CAPS_MAGIC:
continue
abi, codecs = struct.unpack_from("<HH", image, off + 8)
storage = image[off + 12]
if (abi >= BOOT_FORMAT_VER and abi != 0xFFFF and
codecs & (1 << CODEC_FULL) and (storage & ~0x0F) == 0 and
storage & (XIAO_BOOT_STORAGE_QSPI | XIAO_BOOT_STORAGE_UPDATE) ==
(XIAO_BOOT_STORAGE_QSPI | XIAO_BOOT_STORAGE_UPDATE) and
image[off + 13:off + 16] == b"\0\0\0"):
return True
return False
def validate_xiao_bootloader_image(image: bytes, board_id: Optional[int] = None) -> XiaoBootloaderIdentity:
if len(image) != XIAO_BOOT_IMAGE_SIZE:
raise ValueError(f"bootloader image must be exactly {XIAO_BOOT_IMAGE_SIZE} bytes")
if not _xiao_bootloader_vector_sane(image):
raise ValueError("bootloader vector table is invalid")
identity = parse_xiao_bootloader_identity(image)
if identity is None:
raise ValueError("bootloader embedded manifest/CRC is invalid")
if board_id is not None and identity.board_id != board_id:
raise ValueError("bootloader embedded board ID does not match package target")
if not xiao_bootloader_caps_ok(image):
raise ValueError("bootloader lacks ABI 3 QSPI boot-update continuity capabilities")
return identity
def _validate_lengths(m: Manifest):
assert len(m.merkle_root) == 4
assert len(m.image_hash) == 32
@@ -472,11 +596,26 @@ def _validate_lengths(m: Manifest):
def build_manifest(*, target_id: int, fw_version: int, image_size: int, payload: bytes,
block_size: int, image_hash: bytes, codec_id: int, is_full: bool,
base_hash: Optional[bytes] = None, sign_priv=None, hw_id=None) -> Manifest:
base_hash: Optional[bytes] = None, sign_priv=None, hw_id=None,
bootloader: bool = False) -> Manifest:
assert (block_size & (block_size - 1)) == 0, "block_size must be a power of two"
leaves = leaf_hashes(payload, block_size)
if bootloader:
identity = validate_xiao_bootloader_image(payload, target_id)
if (fw_version == 0 or not is_full or codec_id != CODEC_FULL or
image_size != XIAO_BOOT_IMAGE_SIZE or
block_size != DEFAULT_BLOCK_SIZE or base_hash not in (None, b"\0" * 8)):
raise ValueError("bootloader package needs a nonzero version and a 40 KiB CODEC_FULL image with 1 KiB blocks")
if sign_priv is None:
raise ValueError("bootloader package must be signed")
expected_hw = xiao_bootloader_hw_id(identity.board_id)
if hw_id is not None and hw_id_bytes(hw_id) != hw_id_bytes(expected_hw):
raise ValueError("bootloader package hw_id must be canonical for its embedded board ID")
hw_id = expected_hw
m = Manifest(
flags=(FLAG_FULL if is_full else 0) | (FLAG_SIGNED if sign_priv is not None else 0),
format_ver=BOOT_FORMAT_VER if bootloader else APP_FORMAT_VER,
flags=((FLAG_FULL if is_full else 0) | (FLAG_SIGNED if sign_priv is not None else 0) |
(FLAG_BOOTLOADER if bootloader else 0)),
target_id=target_id,
fw_version=fw_version,
image_size=image_size,
@@ -535,9 +674,15 @@ def parse_container(blob: bytes) -> Parsed:
m = Manifest()
m.format_ver = take(1)[0]
if m.format_ver != FORMAT_VER:
raise ValueError(f"unsupported format_ver {m.format_ver}")
m.flags = take(1)[0]
if m.format_ver == APP_FORMAT_VER:
if m.flags & FLAG_BOOTLOADER or m.flags & ~FLAG_KNOWN:
raise ValueError("v2 application manifest has invalid flags")
elif m.format_ver == BOOT_FORMAT_VER:
if m.flags != FLAG_FULL | FLAG_SIGNED | FLAG_BOOTLOADER:
raise ValueError("v3 bootloader manifest requires exact FULL|SIGNED|BOOTLOADER flags")
else:
raise ValueError(f"unsupported format_ver {m.format_ver}")
m.hash_algo = take(1)[0]
m.target_id, m.fw_version, m.image_size, m.payload_size = struct.unpack("<IIII", take(16))
m.block_size_log2 = take(1)[0]
@@ -558,6 +703,14 @@ def parse_container(blob: bytes) -> Parsed:
rest = r.read() # trailer was stripped above, so nothing should remain
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
m.image_size != XIAO_BOOT_IMAGE_SIZE or
m.payload_size != XIAO_BOOT_IMAGE_SIZE or
m.block_size_log2 != 10 or m.block_count != 40 or m.base_hash != b"\0" * 8 or
m.target_id not in (XIAO_BOOT_BOARD_ID_BASE, XIAO_BOOT_BOARD_ID_SENSE) or
m.hw_id != hw_id_bytes(xiao_bootloader_hw_id(m.target_id))):
raise ValueError("v3 bootloader manifest geometry/identity is invalid")
return Parsed(manifest=m, payload=payload, total_size=total)
@@ -571,6 +724,12 @@ def verify(parsed: Parsed, *, expect_pub: Optional[bytes] = None,
problems: List[str] = []
m, payload = parsed.manifest, parsed.payload
if m.is_bootloader:
try:
validate_xiao_bootloader_image(payload, m.target_id)
except (UnicodeDecodeError, ValueError) as exc:
problems.append(f"bootloader image contract: {exc}")
# block_count / leaves
if len(m.leaves) != m.block_count:
problems.append(f"leaves count {len(m.leaves)} != block_count {m.block_count}")
+9 -1
View File
@@ -179,15 +179,23 @@ def _append_endf_hex(source, target, env): # Intel-HEX path (nRF52: app f
and _builds_companion_radio())
sd_backed = _cppdef("OTA_SD_STORE") is not None
qspi_backed = _cppdef("OTA_QSPI_STORE") is not None
bootloader_update = _cppdef("OTA_QSPI_BOOTLOADER_UPDATE") is not None
if sd_backed and qspi_backed:
raise RuntimeError("nRF52 build cannot enable both SD and QSPI OTA stores")
if qspi_backed and _cppdef("QSPIFLASH") is not None:
raise RuntimeError("raw QSPI OTA staging cannot share a chip with QSPIFLASH")
if bootloader_update:
xiao_module = _cppdef("XIAO_NRF52") is not None or _cppdef("IKOKA_NRF52") is not None
if not qspi_backed or sd_backed or not xiao_module:
raise RuntimeError("bootloader update requires a XIAO nRF52840 raw-QSPI OTA build")
if linked_app_end != ml.NRF52_BOOT_SCRATCH_START:
raise RuntimeError("bootloader-update build must link exactly below scratch at 0xE0000")
stage_ceiling = (ml.NRF52_APP_END if (sd_backed or qspi_backed) else
ml.nrf52_stage_ceiling_for_layout(linked_app_end, internal_extrafs))
layout_flags = ((ml.NRF52_LAYOUT_FLAG_SD if sd_backed else 0) |
(ml.NRF52_LAYOUT_FLAG_QSPI if qspi_backed else 0) |
(ml.NRF52_LAYOUT_FLAG_INTERNAL_EXTRAFS if internal_extrafs else 0))
(ml.NRF52_LAYOUT_FLAG_INTERNAL_EXTRAFS if internal_extrafs else 0) |
(ml.NRF52_LAYOUT_FLAG_BOOTLOADER_SCRATCH if bootloader_update else 0))
layout = ml.Nrf52Layout(app_start, linked_app_end, stage_ceiling, layout_flags)
body = ml.ensure_nrf52_layout(raw_body, layout)
ident = _firmware_ident()
+199 -1
View File
@@ -121,7 +121,8 @@ def test_nrf52_layout_record_roundtrip_and_policy():
== ml.NRF52_EXTRAFS_START)
assert (ml.nrf52_stage_ceiling_for_layout(ml.NRF52_EXTRAFS_START, False)
== ml.NRF52_APP_END)
assert ml.nrf52_stage_ceiling_for_layout(0xE0000, False) == ml.NRF52_EXTRAFS_START
assert (ml.nrf52_stage_ceiling_for_layout(ml.NRF52_BOOT_SCRATCH_START, False)
== ml.NRF52_APP_END)
layout = ml.Nrf52Layout(ml.NRF52_APP_BASE_S140_V7, ml.NRF52_EXTRAFS_START,
ml.NRF52_APP_END, 0)
@@ -142,6 +143,13 @@ def test_nrf52_layout_record_roundtrip_and_policy():
qspi_image, _ = ml.ensure_endf(ml.ensure_nrf52_layout(_fw(7, 2048), qspi))
assert ml.parse_nrf52_layout(qspi_image) == qspi
assert qspi.qspi_backed and qspi.external_backed and not qspi.sd_backed
boot_qspi = ml.Nrf52Layout(
ml.NRF52_APP_BASE_S140_V7, ml.NRF52_BOOT_SCRATCH_START,
ml.NRF52_APP_END,
ml.NRF52_LAYOUT_FLAG_QSPI | ml.NRF52_LAYOUT_FLAG_BOOTLOADER_SCRATCH)
boot_qspi_image, _ = ml.ensure_endf(ml.ensure_nrf52_layout(_fw(8, 2048), boot_qspi))
assert ml.parse_nrf52_layout(boot_qspi_image) == boot_qspi
assert boot_qspi.bootloader_scratch
try:
ml.build_nrf52_layout(ml.Nrf52Layout(
ml.NRF52_APP_BASE_S140_V7, ml.NRF52_EXTRAFS_START,
@@ -162,6 +170,196 @@ def test_nrf52_layout_record_roundtrip_and_policy():
pass
def _xiao_bootloader_image(board_id=ml.XIAO_BOOT_BOARD_ID_BASE):
import zlib
image = bytearray(b"\xff" * ml.XIAO_BOOT_IMAGE_SIZE)
struct.pack_into("<II", image, 0, 0x20040000, ml.XIAO_BOOT_IMAGE_START + 0x101)
struct.pack_into("<8sHHB3x", image, 0x80, ml.XIAO_BOOT_CAPS_MAGIC,
ml.BOOT_FORMAT_VER, 1 << ml.CODEC_FULL,
ml.XIAO_BOOT_STORAGE_QSPI | ml.XIAO_BOOT_STORAGE_UPDATE)
name = ml.XIAO_BOOT_DEVICE_NAME
struct.pack_into("<8sHHIII16sI", image, 0x100, 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)
crc = zlib.crc32(image) & 0xFFFFFFFF
struct.pack_into("<I", image, 0x100 + 40, crc)
return bytes(image)
def _xiao_manifest_crc(image, offset):
import zlib
trial = bytearray(image)
trial[offset + 40:offset + 44] = b"\0" * 4
return zlib.crc32(trial) & 0xFFFFFFFF
def _set_two_valid_xiao_manifest_crcs(image, first, second):
"""Solve the two coupled whole-image CRC fields over GF(2)."""
first_crc, second_crc = first + 40, second + 40
image[first_crc:first_crc + 4] = b"\0" * 4
image[second_crc:second_crc + 4] = b"\0" * 4
def residual(value):
trial = bytearray(image)
struct.pack_into("<I", trial, first_crc, value & 0xFFFFFFFF)
struct.pack_into("<I", trial, second_crc, value >> 32)
first_error = struct.unpack_from("<I", trial, first_crc)[0] ^ _xiao_manifest_crc(trial, first)
second_error = struct.unpack_from("<I", trial, second_crc)[0] ^ _xiao_manifest_crc(trial, second)
return first_error | (second_error << 32)
affine = residual(0)
columns = [residual(1 << bit) ^ affine for bit in range(64)]
rows = []
for output_bit in range(64):
row = sum(1 << input_bit for input_bit, column in enumerate(columns)
if (column >> output_bit) & 1)
rows.append(row | (((affine >> output_bit) & 1) << 64))
pivot_columns = []
pivot_row = 0
for column in range(64):
found = next((row for row in range(pivot_row, 64)
if (rows[row] >> column) & 1), None)
if found is None:
continue
rows[pivot_row], rows[found] = rows[found], rows[pivot_row]
for row in range(64):
if row != pivot_row and (rows[row] >> column) & 1:
rows[row] ^= rows[pivot_row]
pivot_columns.append(column)
pivot_row += 1
assert all((row & ((1 << 64) - 1)) or not ((row >> 64) & 1) for row in rows)
solution = 0
for row, column in enumerate(pivot_columns):
if (rows[row] >> 64) & 1:
solution |= 1 << column
assert residual(solution) == 0
struct.pack_into("<I", image, first_crc, solution & 0xFFFFFFFF)
struct.pack_into("<I", image, second_crc, solution >> 32)
def test_xiao_bootloader_identity_skips_bad_decoy_and_rejects_two_valid_manifests():
real_offset, decoy_offset, second_offset = 0x100, 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]
struct.pack_into("<I", image, decoy_offset + 40, 0xA5A5A5A5)
struct.pack_into("<I", image, real_offset + 40, _xiao_manifest_crc(image, real_offset))
assert _xiao_manifest_crc(image, decoy_offset) != 0xA5A5A5A5
identity = ml.parse_xiao_bootloader_identity(bytes(image))
assert identity is not None and identity.manifest_offset == real_offset
duplicate = bytearray(_xiao_bootloader_image())
duplicate[second_offset:second_offset + ml.XIAO_BOOT_MANIFEST_SIZE] = \
duplicate[real_offset:real_offset + ml.XIAO_BOOT_MANIFEST_SIZE]
_set_two_valid_xiao_manifest_crcs(duplicate, real_offset, second_offset)
assert struct.unpack_from("<I", duplicate, real_offset + 40)[0] == \
_xiao_manifest_crc(duplicate, real_offset)
assert struct.unpack_from("<I", duplicate, second_offset + 40)[0] == \
_xiao_manifest_crc(duplicate, second_offset)
assert ml.parse_xiao_bootloader_identity(bytes(duplicate)) is None
try:
ml.validate_xiao_bootloader_image(bytes(duplicate))
assert False, "two CRC-valid embedded identities accepted"
except ValueError:
pass
def test_xiao_bootloader_caps_rejects_malformed_or_unaligned_markers():
def caps(*, offset=0, abi=ml.BOOT_FORMAT_VER,
codecs=1 << ml.CODEC_FULL,
storage=ml.XIAO_BOOT_STORAGE_QSPI | ml.XIAO_BOOT_STORAGE_UPDATE,
reserved=b"\0\0\0"):
image = bytearray(b"\xff" * 64)
struct.pack_into("<8sHHB3s", image, offset, ml.XIAO_BOOT_CAPS_MAGIC,
abi, codecs, storage, reserved)
return bytes(image)
assert ml.xiao_bootloader_caps_ok(caps())
assert not ml.xiao_bootloader_caps_ok(caps(offset=1))
assert not ml.xiao_bootloader_caps_ok(caps(abi=0xFFFF))
assert not ml.xiao_bootloader_caps_ok(caps(codecs=0))
assert not ml.xiao_bootloader_caps_ok(caps(storage=0x1C))
assert not ml.xiao_bootloader_caps_ok(caps(reserved=b"\0\x01\0"))
def test_bootloader_v3_build_parse_and_strict_contract():
priv = Ed25519PrivateKey.generate()
image = _xiao_bootloader_image()
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"),
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)
blob = ml.build_container(m, image)
parsed = ml.parse_container(blob)
assert parsed.manifest.format_ver == ml.BOOT_FORMAT_VER
assert parsed.manifest.flags == ml.FLAG_FULL | ml.FLAG_SIGNED | ml.FLAG_BOOTLOADER
assert parsed.manifest.hw_id == ml.hw_id_bytes("XIAO_BL_28860044")
assert ml.verify(parsed, expect_pub=priv.public_key().public_bytes_raw()) == []
invalid_payload = bytearray(parsed.payload)
invalid_payload[4] &= 0xFE
parsed.payload = bytes(invalid_payload)
assert any(problem.startswith("bootloader image contract:")
for problem in ml.verify(parsed))
bad = bytearray(blob)
bad[8] = ml.APP_FORMAT_VER
try:
ml.parse_container(bytes(bad))
assert False, "v2 bootloader flag accepted"
except ValueError:
pass
bad = bytearray(blob)
bad[9] &= ~ml.FLAG_BOOTLOADER
try:
ml.parse_container(bytes(bad))
assert False, "v3 non-bootloader flags accepted"
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,
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)
for change in (
{"sign_priv": None},
{"block_size": 512},
{"fw_version": 0},
{"target_id": ml.XIAO_BOOT_BOARD_ID_SENSE},
{"hw_id": "wrong"},
):
try:
ml.build_manifest(**(kwargs | change))
assert False, f"invalid bootloader build accepted: {change}"
except ValueError:
pass
damaged = bytearray(image); damaged[0x200] ^= 1
try:
ml.validate_xiao_bootloader_image(bytes(damaged))
assert False, "bad embedded CRC accepted"
except ValueError:
pass
import zlib
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)
try:
ml.validate_xiao_bootloader_image(bytes(wrong_name))
assert False, "noncanonical embedded device name accepted"
except ValueError:
pass
# --- merkle ----------------------------------------------------------------
def test_merkle_single_block():
@@ -88,8 +88,11 @@ build_src_filter = ${ikoka_handheld_nrf_ssd1306_companion.build_src_filter}
[env:ikoka_handheld_nrf_e22_30dbm_repeater]
extends = ikoka_handheld_nrf
board_build.ldscript = boards/nrf52840_s140_v7_xiao_bootloader_ota.ld
board_upload.maximum_size = 757760
build_flags = ${ikoka_handheld_nrf.build_flags}
${nrf52_qspi_ota.build_flags}
-D OTA_QSPI_BOOTLOADER_UPDATE=1
-D ADVERT_NAME='"ikoka_handheld Repeater"'
-D ADVERT_LAT=0.0
-D ADVERT_LON=0.0
+3
View File
@@ -118,9 +118,12 @@ lib_deps =
[ikoka_nano_nrf_repeater]
extends = ikoka_nano_nrf
board_build.ldscript = boards/nrf52840_s140_v7_xiao_bootloader_ota.ld
board_upload.maximum_size = 757760
build_flags =
${ikoka_nano_nrf.build_flags}
${nrf52_qspi_ota.build_flags}
-D OTA_QSPI_BOOTLOADER_UPDATE=1
-D ADVERT_NAME='"Ikoka Nano Repeater"'
-D ADVERT_LAT=0.0
-D ADVERT_LON=0.0
+3
View File
@@ -124,9 +124,12 @@ lib_deps =
[ikoka_stick_nrf_repeater]
extends = ikoka_stick_nrf
board_build.ldscript = boards/nrf52840_s140_v7_xiao_bootloader_ota.ld
board_upload.maximum_size = 757760
build_flags =
${ikoka_stick_nrf.build_flags}
${nrf52_qspi_ota.build_flags}
-D OTA_QSPI_BOOTLOADER_UPDATE=1
-D ADVERT_NAME='"Ikoka Stick Repeater"'
-D ADVERT_LAT=0.0
-D ADVERT_LON=0.0
+6
View File
@@ -90,9 +90,12 @@ lib_deps =
[env:Xiao_nrf52_repeater]
extends = Xiao_nrf52
board_build.ldscript = boards/nrf52840_s140_v7_xiao_bootloader_ota.ld
board_upload.maximum_size = 757760
build_flags =
${Xiao_nrf52.build_flags}
${nrf52_qspi_ota.build_flags}
-D OTA_QSPI_BOOTLOADER_UPDATE=1
-D ADVERT_NAME='"Xiao_nrf52 Repeater"'
-D ADVERT_LAT=0.0
-D ADVERT_LON=0.0
@@ -198,9 +201,12 @@ build_flags =
[env:solarxiao_30S_repeater]
extends = solarxiao
board_build.ldscript = boards/nrf52840_s140_v7_xiao_bootloader_ota.ld
board_upload.maximum_size = 757760
build_flags =
${solarxiao.build_flags}
${nrf52_qspi_ota.build_flags}
-D OTA_QSPI_BOOTLOADER_UPDATE=1
-D ADVERT_NAME='"SolarXiao 30S Repeater"'
-D ADVERT_LAT=0.0
-D ADVERT_LON=0.0