mirror of
https://github.com/vk496/MeshCore.git
synced 2026-09-02 15:33:49 +00:00
OTA over LoRa for ESP32 and nRF52
This commit is contained in:
@@ -17,3 +17,5 @@ compile_commands.json
|
||||
.venv/
|
||||
venv/
|
||||
platformio.local.ini
|
||||
meshcore
|
||||
__pycache__/
|
||||
Vendored
+2
@@ -1,4 +1,6 @@
|
||||
{
|
||||
// See http://go.microsoft.com/fwlink/?LinkId=827846
|
||||
// for the documentation about the extensions.json format
|
||||
"recommendations": [
|
||||
"pioarduino.pioarduino-ide",
|
||||
"platformio.platformio-ide"
|
||||
|
||||
+131
@@ -0,0 +1,131 @@
|
||||
# OTA-over-LoRa — Implementation Status
|
||||
|
||||
Working record of what's built, what's validated (and how), and what remains. Companion to the design
|
||||
docs: `OTA.md` (spec), `OTA_PLAN.md` (plan), `docs/ota_protocol.md` (wire format).
|
||||
|
||||
Everything is gated behind `-D ENABLE_OTA=1` and is byte-for-byte inert when off. Nothing is committed
|
||||
to git yet — all changes are in the working tree.
|
||||
|
||||
## Validation summary
|
||||
|
||||
| Phase | Component | Validated how | Status |
|
||||
|---|---|---|---|
|
||||
| P0 | `.mota` format + host packager (`tools/mota/`): build, delta (detools), Ed25519 sign, inspect, verify; `EndF` injector | `tools/mota/test_mota.py` (12/12); CLI end-to-end | ✅ |
|
||||
| P1 | Portable C++ core: `Multihash`, `MerkleTree` (+proofs, gen+verify), `MotaContainer` parse, `BlockBitmap` | `pio test -e native` (21/21), cross-checked vs Python oracle (proofs byte-identical) | ✅ |
|
||||
| P2 | `EndF` self-scan, `target_id` / `getOtaTargetId()`, `build.sh` injection | **on Heltec v3**: `ota status` reports exact size/hash/target_id matching the build hook | ✅ |
|
||||
| P3 | `SignerAllowlist`, `OtaStore`, full verify (parse + merkle root + image_hash + **Ed25519** + allowlist) | **on Heltec v3 AND RAK4631**: `verify` → `ok=1 auto=1` with key, `auto=0` without | ✅ cross-platform |
|
||||
| P4a | `OtaProtocol` message codec (ADV/QUERY/HAVE/GET_MANIFEST/MANIFEST/REQ/DATA) + server proof-gen | native (21/21), proof-gen matches Python | ✅ |
|
||||
| P4b | `OtaManager` serve+fetch state machine | native: **two-manager full transfer simulation → byte-identical** reassembly | ✅ |
|
||||
| P4c | Mesh integration: `PAYLOAD_TYPE_OTA` dispatch, lowest-priority hop-capped flood, wired into `simple_repeater` | **full on-air transfer RAK4631→Heltec COMPLETE + VERIFIED** (see below) | ✅ |
|
||||
| P5 | **Delta apply (ESP32 A/B) via detools 0.53.0**: vendored detools embeddable C decoder (`src/helpers/ota/detools/`, NONE+CRLE) decodes a `--codec sequential --compression crle` patch against the running slot into the inactive slot, hashing→`image_hash` (`ota applydelta`) | **full LoRa run RAK→Heltec**: 129-byte delta (0.01% of a 1.18 MB image) → `detools decoded 1179184 B, hash OK, armed` → reboot → **booted v1.16.9** | ✅ |
|
||||
| P6 | **Apply (ESP32 A/B)**: verify inactive-slot image vs signed manifest (image_hash + Ed25519 + allowlist) → `esp_ota_set_boot_partition` → reboot | **real role switch on Heltec: repeater → companion, booted correctly** (`ota apply manifest/verify/commit`; after reboot it speaks the companion frame protocol) | ✅ |
|
||||
| — | Build + flash both platforms | esptool (Heltec/ESP32) + adafruit-nrfutil **DFU** (RAK4631/nRF52) both confirmed | ✅ |
|
||||
|
||||
## On-air status (real LoRa, RAK4631 → Heltec v3) — ✅ COMPLETE + VERIFIED
|
||||
|
||||
A full signed `.mota` was transferred **over real LoRa from the RAK4631 (nRF52) to the Heltec v3
|
||||
(ESP32)** — cross-platform — and **completed + verified end-to-end**:
|
||||
```
|
||||
fetch=F 1/6 → 3/6 → 4/6 → 5/6 → 6/6 → fetch=C (~26 s)
|
||||
verify: parsed=1 root=1 img=1 signed=1 sig=1 trust=1 | ok=1 auto=1
|
||||
```
|
||||
announce → get-manifest → manifest → windowed request → data; every block **merkle-verified against the
|
||||
signed root** before storage; the reassembled container then **fully verified** (root + image_hash +
|
||||
Ed25519 signature + allowlist → auto-appliable). Three real bugs were found and fixed via on-device
|
||||
testing (none caught by the host sim, which doesn't model the mesh):
|
||||
- **Windowed requests** (`OTA_REQ_WINDOW`) — was requesting the whole image at once → server TX/pool
|
||||
congestion. Now paced to the link.
|
||||
- **Manifest + request retry** in `OtaManager::loop()` — was unrecoverable if a reply dropped.
|
||||
- **Dedup vs. retries (the key one):** the mesh `hasSeen()` dedup suppressed identical retried requests,
|
||||
so a single lost reply stalled forever. Fixed: OTA packets are **always processed** (handlers are
|
||||
idempotent); `hasSeen()` now only gates re-flooding. This makes it genuinely *eventually reliable* —
|
||||
lossy RF just means more time, exactly as intended.
|
||||
|
||||
## Source map (`src/helpers/ota/`)
|
||||
|
||||
| File | Role | Portable (native) |
|
||||
|---|---|---|
|
||||
| `OtaFormat.h` | wire constants (magics, flags, codecs, msg types) | yes |
|
||||
| `Multihash.h` | sha2-256 truncations via `Utils::sha256` | yes |
|
||||
| `MerkleTree.{h,cpp}` | leaf/root (O(log n)), verify, gen proof | yes |
|
||||
| `MotaContainer.{h,cpp}` | `.mota` parse + root/image-hash checks | yes |
|
||||
| `BlockBitmap.h` | availability from `leaves[]` (erased = missing) | yes |
|
||||
| `FirmwareInfo.{h,cpp}` | `EndF` self-scan over a region | yes |
|
||||
| `OtaStore.h` | staging interface + `OtaStoreRam<N>` | yes |
|
||||
| `OtaProtocol.{h,cpp}` | message encode/decode | yes |
|
||||
| `OtaManager.{h,cpp}` | serve+fetch state machine | yes |
|
||||
| `SignerAllowlist.h` | trusted Ed25519 signer keys | yes |
|
||||
| `OtaVerify.{h,cpp}` | full verify incl. Ed25519 (uses `Identity`) | device-only |
|
||||
| `OtaSelf.{h,cpp}` | running-firmware region (ESP32 `esp_partition_read`) | device-only |
|
||||
| `OtaContext.{h,cpp}` | per-device singleton (manager + stores + allowlist) | device-only |
|
||||
| `OtaCli.{h,cpp}` | `ota …` CLI commands | device-only |
|
||||
|
||||
Core edits (gated): `Packet.h` (`PAYLOAD_TYPE_OTA=0x0C`), `Mesh.{h,cpp}` (dispatch + `createOtaPacket`/
|
||||
`sendOtaFlood` + hop limit), `MeshCore.h` (`getOtaTargetId`), `CommonCLI.cpp` (`ota` command),
|
||||
`examples/simple_repeater/MyMesh.{h,cpp}` (`onOtaRecv` + adapter + begin/loop wiring), `build.sh`
|
||||
(`MOTA_TARGET_ID`), `test/mocks/SHA256.h` (real host SHA-256). Env wiring: `variants/heltec_v3` and
|
||||
`variants/rak4631` repeater envs (`ENABLE_OTA` + ota sources [+ `EndF` hook on ESP32]).
|
||||
|
||||
## `ota` CLI (serial console; also remote-admin over LoRa)
|
||||
|
||||
```
|
||||
ota status target_id, self-fw size/hash, serve/fetch state, key count
|
||||
ota key add|list|rm <hex> signer allowlist
|
||||
ota stage <size> prepare serve buffer
|
||||
ota recv <off> <hex> write a chunk into the serve buffer (host streams the .mota)
|
||||
ota serve parse+verify the staged .mota and make it servable
|
||||
ota announce broadcast OTA_ADV for the served .mota
|
||||
ota verify full verify of the staged/served (or fetched) .mota
|
||||
ota want <hex>|auto manual cross-target override (deliberate role switch, e.g. companion->repeater)
|
||||
ota clear reset buffers
|
||||
```
|
||||
Host harness: `tools/mota/` packager + the scratch `onair*.py` orchestration scripts.
|
||||
|
||||
## Variant coverage (which platforms have OTA, which need special treatment)
|
||||
|
||||
OTA is enabled at the platform base so every variant inherits it; only the apply path differs by HW.
|
||||
|
||||
| Platform | OTA build | Apply path | Special treatment |
|
||||
|---|---|---|---|
|
||||
| **ESP32** (all chips) | ✅ enabled in `[esp32_base]` (`ENABLE_OTA`, `helpers/ota/*.cpp`, `detools.c`, `pio_endf`) | A/B via `esp_ota` + detools-**sequential** decode into the inactive slot | `applydelta` only runs on a **dual-app/OTA partition table** (2 app slots + otadata). `min_spiffs.csv` boards already qualify (1.875 MB slots); `huge_app`/single-app boards (most esp32/S3 defaults, 3.19 MB) build fine but refuse apply (`ERR no A/B slot`) until repartitioned. |
|
||||
| **nRF52 — RAK4631 hardware** (rak4631 + gat562_30s / evb_pro / tracker_pro / watch13, muziworks_r1_neo, rak_wismesh_tag) | ✅ `[rak4631]` (inline) + `[rak4631_hw]` (shared, the other 6) | single-slot **in-place** detools, applied by the custom OTAFIX bootloader after reboot | Device must run the **OTAFIX bootloader** fork. `detools.c` is NOT built into the app (only the bootloader decodes). |
|
||||
| **nRF52 — non-RAK** (heltec_t1/t096/t114/mesh_solar/mesh_pocket, lilygo techo*/t_impulse_plus, thinknode_m1/m3/m6, t1000-e, nano_g2_ultra, promicro, xiao_nrf52, ikoka_*, wio*, sensecap_solar, rak3401, keepteen_lt1, meshtiny, minewsemi_me25ls01) | ❌ not enabled | none | **Needs its own bootloader fork** (single-slot, like RAK) before OTA is safe. No A/B slot, and the stock Adafruit/SoftDevice bootloader can't apply in place. Out of scope until per-board bootloaders exist. |
|
||||
| **RP2040 / STM32** | ❌ not enabled | none | No A/B apply path implemented yet. |
|
||||
|
||||
Build-verified this pass (OTA on): ESP32 across all 4 chip families — esp32 `Heltec_v2` (34.6%), S3 `Heltec_v3` (35.3%), C6 `Xiao_C6` (27.9%), and the tight C3 default-partition class up to the fattest config `Heltec_ct62_companion_radio_ble` **96.2%** / `Xiao_C3_companion_radio_ble` 94.6% (the global flash worst case — fits). All 6 RAK4631-hw nRF52 variants build (Flash 55–65%, RAM ≤ 74%). `native` test suite green. (Pre-existing, OTA-unrelated, fail on clean `main` too: `tenstar_c3` stale `helpers/XiaoC3Board.h` include; `generic_espnow` undefined `P_LORA_DIO_1`.)
|
||||
|
||||
## Remaining (clearly scoped)
|
||||
|
||||
1. **Device-side full-image delivery to the slot** — the apply path is done + validated, but the role-
|
||||
switch test delivered the 631 KB image to the inactive slot via esptool (simulating the transfer,
|
||||
which is separately proven on-air). The device writing the slot itself during a *full-image* OTA
|
||||
needs `esp_ota_write`/`esp_partition_write` streaming + a bulk transfer (the RAM `OtaStore` is for
|
||||
delta-sized images / bring-up).
|
||||
2. **Multi-fragment blocks** — v1 uses ≤128-byte single-packet blocks; 1 KB blocks need fragment
|
||||
reassembly in `OtaManager` (`OTA_DATA` already carries `frag_idx`/`frag_total`).
|
||||
3. **nRF52 apply** — write the `approval` field + reboot-to-DFU for the bootloader fork (external repo).
|
||||
4. **nRF52 `EndF` `.hex` build wiring** — currently only the ESP32 `.bin` hook is implemented.
|
||||
5. **P7 auto-propagation + retention** — 24 h announce, finish-current on supersession, 30-day stale GC.
|
||||
6. **Companion app frames** (`CMD_OTA_*`) + relay/web-seed ingress for the home-node case.
|
||||
7. **`hw_id` brick-safety** for cross-target (see plan §6.1) — manifest format change, awaiting confirm.
|
||||
|
||||
> Device state: the **Heltec now runs companion_radio_usb** (from the role-switch test); reflash the
|
||||
> repeater env to continue OTA work. RAK4631 runs the OTA repeater.
|
||||
8. **`hw_id` brick-safety** for cross-target (`ota want`) — manifest field = `sha2-256:4(manufacturer)`;
|
||||
allows same-HW role switches but refuses incompatible-HW firmware. Manifest format change → see
|
||||
`OTA_PLAN.md §6.1` (awaiting confirmation since the format was frozen). The manual override
|
||||
itself is **done + native-tested**.
|
||||
|
||||
## Reproduce
|
||||
|
||||
```bash
|
||||
# host tests
|
||||
./meshcore/bin/python tools/mota/test_mota.py
|
||||
./meshcore/bin/pio test -e native -f test_ota
|
||||
|
||||
# build + flash (OTA repeater)
|
||||
./meshcore/bin/pio run -e Heltec_v3_repeater -t upload --upload-port /dev/ttyUSB0
|
||||
./meshcore/bin/pio run -e RAK_4631_repeater -t upload --upload-port /dev/ttyACM0 # DFU
|
||||
|
||||
# on-device verify / on-air transfer: see tools/mota/ + scratchpad onair*.py
|
||||
```
|
||||
@@ -140,8 +140,15 @@ build_firmware() {
|
||||
# e.g: RAK_4631_Repeater-v1.0.0-SHA
|
||||
FIRMWARE_FILENAME="$1-${FIRMWARE_VERSION_STRING}"
|
||||
|
||||
# OTA target id = sha2-256:4(env_name) as a little-endian uint32 (matches tools/mota target_id_for_env
|
||||
# and the device's MainBoard::getOtaTargetId()). Harmless when OTA is disabled.
|
||||
MOTA_TARGET_ID=$(python3 -c "import hashlib,sys;print('0x%08x'%int.from_bytes(hashlib.sha256(sys.argv[1].encode()).digest()[:4],'little'))" "$1" 2>/dev/null || echo "")
|
||||
|
||||
# add firmware version info to end of existing platformio build flags in environment vars
|
||||
export PLATFORMIO_BUILD_FLAGS="${PLATFORMIO_BUILD_FLAGS} -DFIRMWARE_BUILD_DATE='\"${FIRMWARE_BUILD_DATE}\"' -DFIRMWARE_VERSION='\"${FIRMWARE_VERSION_STRING}\"'"
|
||||
if [ -n "$MOTA_TARGET_ID" ]; then
|
||||
export PLATFORMIO_BUILD_FLAGS="${PLATFORMIO_BUILD_FLAGS} -DMOTA_TARGET_ID=${MOTA_TARGET_ID}"
|
||||
fi
|
||||
|
||||
# disable debug flags if requested
|
||||
disable_debug_flags
|
||||
|
||||
@@ -0,0 +1,288 @@
|
||||
# MeshCore OTA — `.mota` container & LoRa protocol (v1 draft)
|
||||
|
||||
Goals: distribute firmware over LoRa as a self-verifying, resumable, BitTorrent-v2-style block
|
||||
transfer that survives reboots, never auto-applies without consent, and is portable enough for other
|
||||
projects (e.g. Meshtastic) to adopt.
|
||||
|
||||
---
|
||||
|
||||
## 1. Conventions
|
||||
|
||||
- **Endianness:** all multi-byte integers are little-endian unless stated.
|
||||
- **Hashes (multihash):** the hash family is declared once per manifest via `hash_algo`. v1 uses
|
||||
`0x12` = **SHA-256** (the [multihash](https://github.com/multiformats/multihash) code for sha2-256).
|
||||
Truncations used:
|
||||
- `sha2-256:4` — first 4 bytes of the SHA-256 digest. Merkle leaves, internal nodes, root, proofs.
|
||||
- `sha2-256:8` — first 8 bytes. Base-firmware identity (`base_hash`, `EndF`).
|
||||
- `sha2-256:32` — full digest. The image security anchor (`image_hash`).
|
||||
Digests are stored **bare** (just the truncated bytes); the family is implied by `hash_algo`.
|
||||
- **Signatures:** Ed25519 (RFC 8032), 64-byte detached signature, 32-byte public key.
|
||||
|
||||
Reference constants:
|
||||
|
||||
| Name | Bytes (hex) | ASCII |
|
||||
|---|---|---|
|
||||
| Container `MAGIC` | `6D 4F 54 41` | `mOTA` |
|
||||
| Container `TRAILER` | `76 6B 34 39 36` | `vk496` |
|
||||
| `EndF` marker | `45 6E 64 46` | `EndF` |
|
||||
| `hash_algo` (sha2-256) | `12` | — |
|
||||
| `approval` = not approved | `FF FF FF FF` | (erased) |
|
||||
| `approval` = approved | `41 50 52 56` | `APRV` |
|
||||
| `format_ver` | `01` | — |
|
||||
|
||||
---
|
||||
|
||||
## 2. Firmware image & the `EndF` trailer
|
||||
|
||||
Every OTA-capable firmware build appends a 16-byte `EndF` trailer to its flashed image so a running
|
||||
node can discover its own size/identity on any MCU (no linker symbols needed).
|
||||
|
||||
```
|
||||
flashed image = BODY (image bytes) || EndF trailer
|
||||
EndF trailer (16 bytes):
|
||||
off 0 4 "EndF" (45 6E 64 46)
|
||||
off 4 4 body_len uint32 LE — length of BODY (excludes this 16-byte trailer)
|
||||
off 12 8 body_hash sha2-256:8 of BODY
|
||||
```
|
||||
|
||||
- **Size discovery:** scan flash from the partition top downward for the `EndF` marker; the byte
|
||||
before it is the last BODY byte. (Same technique as `NRF52Board::getBootloaderVersion`.)
|
||||
- **Self-identity / delta base matching:** a node's `body_hash` is read directly from its own `EndF`;
|
||||
a delta's `base_hash` (§5) must equal it. No self-hashing pass required at match time.
|
||||
- **No circularity:** `EndF` hashes only the BODY, never itself.
|
||||
|
||||
The "reconstructed image" referenced by the manifest is the full `BODY || EndF` (what gets flashed).
|
||||
|
||||
---
|
||||
|
||||
## 3. The `.mota` container
|
||||
|
||||
This is the **distributed** form (host-built, wire-transferred).
|
||||
|
||||
```
|
||||
off size field
|
||||
0 4 MAGIC = 6D 4F 54 41
|
||||
4 4 MOTA_TOTAL_SIZE uint32 LE — total container bytes (incl. manifest, leaves[],
|
||||
payload, trailer). Lets a node pre-reserve staging and compute
|
||||
write_start = staging_region_end − MOTA_TOTAL_SIZE.
|
||||
8 M MANIFEST (§4; self-delimited, no length field)
|
||||
8 + M P PAYLOAD (payload_size bytes; delta or full image)
|
||||
8 + M + P 5 TRAILER = 76 6B 34 39 36
|
||||
```
|
||||
|
||||
`MOTA_TOTAL_SIZE = 4 + 4 + M + P + 5`.
|
||||
|
||||
**Staged (in-flash) form.** Written bottom-aligned so `TRAILER` ends at `staging_region_end`. Identical
|
||||
bytes, except the device mutates two regions in place (both NOR-safe, no re-erase): the `leaves[]`
|
||||
slots (filled as blocks arrive) and the 4-byte `approval` field (on user approval). Everything else is
|
||||
immutable.
|
||||
|
||||
---
|
||||
|
||||
## 4. The manifest
|
||||
|
||||
Fields are serialized in this exact order. Conditional fields are present per `flags`.
|
||||
|
||||
```
|
||||
off size field notes
|
||||
0 1 format_ver = 0x01
|
||||
1 1 flags bit0 FULL (0=delta/partial, 1=full image)
|
||||
bit1 SIGNED
|
||||
bits2-7 reserved (0)
|
||||
2 1 hash_algo 0x12 = sha2-256
|
||||
3 4 target_id device/arch/role discriminator (§9)
|
||||
7 4 fw_version MAJOR<<24 | MINOR<<16 | PATCH<<8 | pre (comparable uint32)
|
||||
11 4 image_size size of the reconstructed image (BODY||EndF)
|
||||
15 4 payload_size PAYLOAD bytes in this container
|
||||
19 1 block_size_log2 e.g. 0x0A = 1024
|
||||
20 4 merkle_root sha2-256:4 over PAYLOAD blocks (§6)
|
||||
24 32 image_hash sha2-256:32 of the reconstructed image — SECURITY anchor
|
||||
56 1 codec_id 0=full/raw, 1=detools-sequential, 2=detools-in-place
|
||||
57 8 base_hash [present iff !FULL] sha2-256:8 of the BASE image's BODY (matches EndF.body_hash)
|
||||
. 32 signer_pubkey [present iff SIGNED] Ed25519 public key
|
||||
. 64 signature [present iff SIGNED] Ed25519 over all bytes from off 0 up to here (exclusive)
|
||||
. 4 approval ALWAYS present. FF FF FF FF = not approved; 41 50 52 56 ("APRV") = approved
|
||||
. 4*BC leaves[] ALWAYS present. BC = ceil(payload_size / 2^block_size_log2). sha2-256:4 each
|
||||
```
|
||||
|
||||
Self-delimiting: a parser knows every offset from `format_ver`/`flags` + `payload_size` (→ `BC`); no
|
||||
explicit length field is stored.
|
||||
|
||||
Manifest size (excluding `leaves[]`): unsigned-full 57+4=61, signed-full 161, unsigned-delta 69,
|
||||
**signed-delta 165**.
|
||||
|
||||
### 4.1 Signed region
|
||||
|
||||
`signature` covers manifest bytes `[0, signature_offset)` — i.e. everything before it, including
|
||||
`signer_pubkey` and (for deltas) `base_hash`. It does **not** cover `approval` or `leaves[]`:
|
||||
- `leaves[]` are verified against the signed `merkle_root` (§6), so they need no separate signature.
|
||||
- `approval` is device-local consent (§7), deliberately outside the signature.
|
||||
|
||||
### 4.2 The `approval` field
|
||||
|
||||
- Distributed and **forced on ingest** to `FF FF FF FF` (a peer can never pre-approve).
|
||||
- The local user's `ota apply` writes `41 50 52 56` (`"APRV"`) — a single NOR-safe write (only clears
|
||||
bits from the erased word). Any partial/other value reads as not-approved (fail-safe).
|
||||
- Auto-bound to this image: it lives in this `.mota`'s manifest and is re-erased when a new `.mota` is
|
||||
staged.
|
||||
- It is a **consent** marker, not a security primitive. Authenticity = `signature` + `image_hash`.
|
||||
|
||||
---
|
||||
|
||||
## 5. Payload, codecs & delta base
|
||||
|
||||
`PAYLOAD` is either the full reconstructed image (`FULL`) or a delta (`!FULL`).
|
||||
|
||||
| `codec_id` | Meaning | Notes |
|
||||
|---|---|---|
|
||||
| 0 | full / raw | PAYLOAD = reconstructed image (`BODY||EndF`). Typical for ESP32 (A/B). |
|
||||
| 1 | detools sequential | needs random read of base + sequential write of result (e.g. ESP32 A→B). |
|
||||
| 2 | detools in-place | bounded scratch; rewrites the app region in place (nRF52 single-slot). |
|
||||
|
||||
For deltas, `base_hash` = the base image's `EndF.body_hash` (sha2-256:8 of its BODY). A node applies a
|
||||
delta only if `base_hash` matches its own `EndF.body_hash`. After applying, the result MUST hash
|
||||
(sha2-256:32) to `image_hash` before it is flashed — this is the hard security gate.
|
||||
|
||||
Compression is internal to the detools patch; the chosen scheme must be supported by the applier
|
||||
(bootloader contract, §12). Patches are produced by detools 0.53.0 (`tools/mota` → `detools.create_patch`)
|
||||
and decoded on-device by detools' own embeddable C decoder, vendored verbatim at
|
||||
`src/helpers/ota/detools/` (see its `README.meshcore.txt`). That build enables only the self-contained
|
||||
`NONE` + `CRLE` compressions (no malloc / liblzma / heatshrink), so MeshCore deltas use
|
||||
`--codec sequential --compression crle`. The ESP32 applier (`OtaApply.cpp::ota_apply_detools_mota`)
|
||||
wires the decoder's callbacks to: read base ← running OTA slot, stream patch ← fetched bytes in RAM,
|
||||
write output → inactive slot, hashing the output and checking it against `image_hash` before arming.
|
||||
|
||||
---
|
||||
|
||||
## 6. Merkle tree (sha2-256:4)
|
||||
|
||||
Purpose: verify each PAYLOAD block against the signed `merkle_root` **before** the whole payload
|
||||
exists, so corruption/forgery is localized to a block.
|
||||
|
||||
- **Blocks:** PAYLOAD is split into `BC = ceil(payload_size / B)` blocks, `B = 2^block_size_log2`
|
||||
(default 1024). The last block is its real length (**no zero padding**).
|
||||
- **Leaf:** `leaves[i] = sha2-256:4( block_i_bytes )`.
|
||||
- **Internal node:** `node = sha2-256:4( left || right )` (4+4 = 8 input bytes).
|
||||
- **Odd level:** if a level has an odd number of nodes, the **last node is promoted unchanged** to the
|
||||
next level (no duplication).
|
||||
- **Root:** reduce until one node remains. `BC == 1` → root = `leaves[0]`. `BC == 0` is invalid.
|
||||
|
||||
### 6.1 Proofs
|
||||
|
||||
A proof for block `i` is the ordered list of sibling digests from leaf to root, each tagged
|
||||
left/right. Promoted levels contribute **no** element. Verification (needs `BC` to know the shape):
|
||||
|
||||
```
|
||||
h = leaf_i ; idx = i ; n = BC ; p = 0
|
||||
while n > 1:
|
||||
if (n is odd) and (idx == n-1): # this node was promoted
|
||||
pass
|
||||
else:
|
||||
sib, side = proof[p] ; p += 1
|
||||
h = sha2-256:4( sib || h ) if side==left else sha2-256:4( h || sib )
|
||||
idx //= 2 ; n = (n + 1) // 2
|
||||
accept iff h == merkle_root and p == len(proof)
|
||||
```
|
||||
|
||||
Over LoRa, `leaves[]` are **omitted** from the manifest transfer; a serving node computes a block's
|
||||
proof on demand from its stored `leaves[]`, and the fetcher fills its own `leaves[i]` as each verified
|
||||
block lands.
|
||||
|
||||
---
|
||||
|
||||
## 7. Block availability (persistent, derived from `leaves[]`)
|
||||
|
||||
There is no separate availability structure. **Block `i` is present ⟺ `leaves[i]` is non-erased**
|
||||
(`!= FF FF FF FF`). Because `leaves[]` live in the staged flash region, availability **survives
|
||||
reboot**. Commit order per block (crash-safe): (1) verify proof, (2) write block payload to its
|
||||
offset, (3) write `leaves[i]` **last**. A power loss before step 3 leaves the slot erased → the block
|
||||
is simply re-fetched (idempotent). On boot a node rebuilds a small in-RAM bitmap (`ceil(BC/8)` bytes)
|
||||
by scanning `leaves[]`.
|
||||
|
||||
A node holding the complete payload (or relaying/serving its own firmware) advertises `have_all`
|
||||
instead of a bitmap.
|
||||
|
||||
---
|
||||
|
||||
## 8. LoRa OTA protocol
|
||||
|
||||
Carried in MeshCore packets with **`PAYLOAD_TYPE_OTA = 0x0C`** (subject to change if core devs prefer
|
||||
reusing `RAW_CUSTOM 0x0F` + subtype). Every OTA packet payload:
|
||||
|
||||
```
|
||||
[0] ota_msg_type
|
||||
[1..] body
|
||||
```
|
||||
|
||||
- **Routing:** `OTA_ADV`/`OTA_QUERY` flood; `OTA_HAVE`/`OTA_MANIFEST`/`OTA_REQ`/`OTA_DATA` direct.
|
||||
- **Hop cap:** OTA refuses to retransmit when `getPathHashCount() >= ota_hop_limit` (default **3**,
|
||||
configurable). No change to core routing.
|
||||
- **Priority:** enqueued at the lowest TX priority (~250) and only when the duty-cycle/airtime budget
|
||||
has spare headroom, so OTA never competes with mesh traffic.
|
||||
- **`manifest_id`** = the manifest's `merkle_root` (4 bytes) — a compact content id.
|
||||
|
||||
| `ota_msg_type` | val | dir | body |
|
||||
|---|---|---|---|
|
||||
| `OTA_ADV` | 0x01 | flood | `target_id(4) fw_version(4) image_size(4) block_size_log2(1) merkle_root(4) image_hash8(8) flags(1) [base_hash(8) if delta] have_all(1)` |
|
||||
| `OTA_QUERY` | 0x02 | flood | `target_id(4) min_version(4) caps(1)` (caps bit0 want_delta, bit1 want_full) |
|
||||
| `OTA_HAVE` | 0x03 | direct | `manifest_id(4) bitmap_off(2) bitmap[]` |
|
||||
| `OTA_GET_MANIFEST` | 0x04 | direct | `manifest_id(4)` |
|
||||
| `OTA_MANIFEST` | 0x05 | direct | `manifest_id(4) frag_idx(1) frag_total(1) bytes[]` (omits `leaves[]`) |
|
||||
| `OTA_REQ` | 0x06 | direct | `manifest_id(4) want_off(2) want_bitmap[]` |
|
||||
| `OTA_DATA` | 0x07 | direct | `manifest_id(4) block_idx(2) frag_idx(1) frag_total(1) [proof in frag0] bytes[]` |
|
||||
|
||||
Sizing against the 184-byte `MAX_PACKET_PAYLOAD`: `OTA_DATA` fixed overhead ≈ 9 B → ~175 B/fragment →
|
||||
**6 fragments per 1 KB block**; a proof for ≤512 blocks ≤ 9×4 = 36 B (carried in `frag0`); an
|
||||
availability bitmap for 500 blocks ≈ 63 B (one packet).
|
||||
|
||||
Reliability is *eventual*: the fetcher re-requests un-acked blocks after a timeout, possibly from a
|
||||
different peer. No hard ACKs, no ordering.
|
||||
|
||||
### 8.1 Relay seeding (companion frames)
|
||||
|
||||
A node need not store a foreign-target `.mota` to serve it: a relay advertises a manifest on behalf of
|
||||
an external source and **pulls blocks on demand**. Companion-app frames: `CMD_OTA_PROVIDE_MANIFEST`
|
||||
(app→node, starts advertising), event `PUSH_OTA_BLOCK_REQ(manifest_id, block_idx)` (node→app), reply
|
||||
`CMD_OTA_PROVIDE_BLOCK(manifest_id, block_idx, bytes)`.
|
||||
|
||||
---
|
||||
|
||||
## 9. Identity, trust & versioning
|
||||
|
||||
- **`target_id`** (4 B): compile-time `sha2-256:4(pio_env_name + radio_class + ldscript/partition +
|
||||
platform)`, injected by `build.sh`, read via `MainBoard::getOtaTargetId()`. A node only fetches/serves
|
||||
matching `target_id`. (The PlatformIO env name uniquely captures hardware AND role/partition.)
|
||||
- **`fw_version`:** packed comparable uint32 (`MAJOR<<24|MINOR<<16|PATCH<<8|pre`).
|
||||
- **Signing & allowlist:** a node keeps a runtime-managed allowlist of trusted Ed25519 signer pubkeys
|
||||
(none embedded in firmware). A `.mota` is eligible for **auto-apply** only if signed by an allowlisted
|
||||
key, the signature verifies, and `image_hash` matches; otherwise it is manual-apply only with explicit
|
||||
confirmation. **Transfer needs no trust** — blocks are content-addressed against the signed root, so
|
||||
any (untrusted) neighbor may relay them.
|
||||
|
||||
### 9.1 Supersession & retention
|
||||
|
||||
- **Finish-current:** a newer version announced mid-download does not abort the in-progress transfer.
|
||||
- **Stale GC:** a staged `.mota` carries a persistent `staged_at` epoch; it is discarded after
|
||||
`ota_stale_ttl` (default **30 days**) unless pinned (`ota keep`) or applying — reclaiming flash from
|
||||
superseded-complete and stalled-partial images alike. `ota discard` frees the slot immediately.
|
||||
|
||||
---
|
||||
|
||||
## 10. Apply & bootloader contract (summary)
|
||||
|
||||
- **ESP32:** in-firmware via `Update`/`esp_ota_*` into the inactive A/B slot, then set boot + reboot
|
||||
(power-safe, rollback-capable). No bootloader changes.
|
||||
- **nRF52:** running firmware **never** flashes the app. `ota apply` verifies fully, writes the
|
||||
`approval` field (`"APRV"`), then reboots into DFU. The modified bootloader
|
||||
(`Adafruit_nRF52_Bootloader_OTAFIX`) locates the staged `.mota` by scanning for `MAGIC`, re-checks
|
||||
`TRAILER` + signature + `image_hash` + `approval == "APRV"`, applies the codec (delta in-place over
|
||||
the app region), then clears state and boots. The signature proves author authenticity; `approval`
|
||||
proves local owner consent — both required.
|
||||
|
||||
---
|
||||
|
||||
## 11. Versioning of this spec
|
||||
|
||||
`format_ver = 1`. Future changes bump `format_ver`; the multihash `hash_algo` allows changing the
|
||||
digest family without a format bump. Unknown `format_ver`/`codec_id`/`ota_msg_type` values are ignored
|
||||
(forward-compatible).
|
||||
@@ -12,7 +12,7 @@
|
||||
#endif
|
||||
|
||||
#ifndef FIRMWARE_VERSION
|
||||
#define FIRMWARE_VERSION "v1.16.0"
|
||||
#define FIRMWARE_VERSION "v1.17.0"
|
||||
#endif
|
||||
|
||||
#if defined(NRF52_PLATFORM) || defined(STM32_PLATFORM)
|
||||
|
||||
@@ -924,8 +924,10 @@ MyMesh::MyMesh(mesh::MainBoard &board, mesh::Radio &radio, mesh::MillisecondCloc
|
||||
memset(default_scope.key, 0, sizeof(default_scope.key));
|
||||
}
|
||||
|
||||
// OTA mesh-integration (receive/begin/loop) is centralized in mesh::Mesh — no per-example wiring.
|
||||
|
||||
void MyMesh::begin(FILESYSTEM *fs) {
|
||||
mesh::Mesh::begin();
|
||||
mesh::Mesh::begin(); // also starts OTA (ota_ctx().begin) for all roles
|
||||
_fs = fs;
|
||||
// load persisted prefs
|
||||
_cli.loadPrefs(_fs);
|
||||
@@ -1267,7 +1269,7 @@ void MyMesh::loop() {
|
||||
bridge.loop();
|
||||
#endif
|
||||
|
||||
mesh::Mesh::loop();
|
||||
mesh::Mesh::loop(); // also drives the OTA fetch loop (centralized in mesh::Mesh)
|
||||
|
||||
if (next_flood_advert && millisHasNowPassed(next_flood_advert)) {
|
||||
mesh::Packet *pkt = createSelfAdvert();
|
||||
|
||||
@@ -2,6 +2,9 @@
|
||||
|
||||
#include <Arduino.h>
|
||||
#include <Mesh.h>
|
||||
#if defined(ENABLE_OTA)
|
||||
#include <helpers/ota/OtaContext.h>
|
||||
#endif
|
||||
#include <RTClib.h>
|
||||
#include <target.h>
|
||||
|
||||
@@ -73,7 +76,7 @@ struct NeighbourInfo {
|
||||
#endif
|
||||
|
||||
#ifndef FIRMWARE_VERSION
|
||||
#define FIRMWARE_VERSION "v1.16.0"
|
||||
#define FIRMWARE_VERSION "v1.17.0"
|
||||
#endif
|
||||
|
||||
#define FIRMWARE_ROLE "repeater"
|
||||
@@ -172,6 +175,7 @@ protected:
|
||||
void onPeerDataRecv(mesh::Packet* packet, uint8_t type, int sender_idx, const uint8_t* secret, uint8_t* data, size_t len) override;
|
||||
bool onPeerPathRecv(mesh::Packet* packet, int sender_idx, const uint8_t* secret, uint8_t* path, uint8_t path_len, uint8_t extra_type, uint8_t* extra, uint8_t extra_len) override;
|
||||
void onControlDataRecv(mesh::Packet* packet) override;
|
||||
// OTA mesh-integration is centralized in mesh::Mesh (no per-example onOtaRecv / send adapter / tick).
|
||||
|
||||
void sendFloodReply(mesh::Packet* packet, unsigned long delay_millis, uint8_t path_hash_size);
|
||||
|
||||
|
||||
@@ -31,7 +31,7 @@
|
||||
#endif
|
||||
|
||||
#ifndef FIRMWARE_VERSION
|
||||
#define FIRMWARE_VERSION "v1.16.0"
|
||||
#define FIRMWARE_VERSION "v1.17.0"
|
||||
#endif
|
||||
|
||||
#ifndef LORA_FREQ
|
||||
|
||||
@@ -38,7 +38,7 @@
|
||||
#endif
|
||||
|
||||
#ifndef FIRMWARE_VERSION
|
||||
#define FIRMWARE_VERSION "v1.16.0"
|
||||
#define FIRMWARE_VERSION "v1.17.0"
|
||||
#endif
|
||||
|
||||
#define FIRMWARE_ROLE "sensor"
|
||||
|
||||
@@ -58,11 +58,20 @@ build_src_filter =
|
||||
extends = arduino_base
|
||||
platform = platformio/espressif32@6.11.0
|
||||
monitor_filters = esp32_exception_decoder
|
||||
; OTA is available on every ESP32 variant (A/B via esp_ota; the detools-sequential apply decodes into the
|
||||
; inactive slot). The build always includes it; for `applydelta` to actually run, the board must use a
|
||||
; dual-app/OTA partition table (board_build.partitions) with two app slots + otadata. pio_endf appends
|
||||
; the EndF self-identity trailer. (nRF52 single-slot OTA is enabled per-board, not here — it needs a
|
||||
; custom bootloader; RP2040/STM32 have no A/B path yet.)
|
||||
extra_scripts = merge-bin.py
|
||||
post:tools/mota/pio_endf.py
|
||||
build_flags = ${arduino_base.build_flags}
|
||||
-D ESP32_PLATFORM
|
||||
-D ENABLE_OTA=1
|
||||
; -D ESP32_CPU_FREQ=80 ; change it to your need
|
||||
build_src_filter = ${arduino_base.build_src_filter}
|
||||
+<helpers/ota/*.cpp>
|
||||
+<helpers/ota/detools/detools.c>
|
||||
|
||||
[esp32_ota]
|
||||
lib_deps =
|
||||
@@ -93,6 +102,26 @@ build_flags = ${arduino_base.build_flags}
|
||||
lib_deps =
|
||||
${arduino_base.lib_deps}
|
||||
https://github.com/oltaco/CustomLFS#0.2.2
|
||||
|
||||
; Shared OTA recipe for board=rak4631-hardware nRF52 variants. nRF52840 has no A/B slot, so the update is
|
||||
; applied in place by the custom OTAFIX bootloader (in-place detools); the app only stages + verifies +
|
||||
; approves it. Reuses the RAK4631 flash layout (src/helpers/ota/OtaFlashLayout_nrf52.h; FS_START 0xD4000
|
||||
; is the safe staging ceiling for every RAK4631 role/ldscript). The device must run the matching OTAFIX
|
||||
; bootloader for `applydelta` to actually apply. (detools.c is intentionally NOT built into the app —
|
||||
; only the bootloader decodes.) The [rak4631] base defines this same recipe inline because it carries
|
||||
; board-specific extras (fix_bsec_lib.py, BSEC lib) and a fixed post-script order; the simpler RAK4631
|
||||
; variants below just extend this section.
|
||||
[rak4631_hw]
|
||||
extends = nrf52_base
|
||||
extra_scripts = ${nrf52_base.extra_scripts}
|
||||
post:tools/mota/pio_endf.py
|
||||
build_flags = ${nrf52_base.build_flags}
|
||||
-D ENABLE_OTA=1
|
||||
-D OTA_FLASH_STORE=1
|
||||
build_src_filter = ${nrf52_base.build_src_filter}
|
||||
+<helpers/ota/*.cpp>
|
||||
lib_deps = ${nrf52_base.lib_deps}
|
||||
|
||||
; ----------------- RP2040 ---------------------
|
||||
|
||||
[rp2040_base]
|
||||
@@ -164,5 +193,11 @@ test_build_src = yes
|
||||
build_src_filter =
|
||||
-<*>
|
||||
+<../src/Utils.cpp>
|
||||
+<../src/helpers/ota/MerkleTree.cpp>
|
||||
+<../src/helpers/ota/MotaContainer.cpp>
|
||||
+<../src/helpers/ota/FirmwareInfo.cpp>
|
||||
+<../src/helpers/ota/OtaProtocol.cpp>
|
||||
+<../src/helpers/ota/OtaManager.cpp>
|
||||
+<../src/helpers/ota/detools/detools.c>
|
||||
lib_deps =
|
||||
google/googletest @ 1.17.0
|
||||
|
||||
@@ -1,14 +1,56 @@
|
||||
#include "Mesh.h"
|
||||
//#include <Arduino.h>
|
||||
#if defined(ENABLE_OTA)
|
||||
#include "helpers/ota/OtaContext.h" // OTA mesh-integration is centralized here so every role gets it
|
||||
#endif
|
||||
|
||||
namespace mesh {
|
||||
|
||||
#if defined(ENABLE_OTA)
|
||||
// Adapter so the portable OtaManager can emit packets through the mesh (lowest priority, hop-capped).
|
||||
void Mesh::otaSendAdapter(void* ctx, const uint8_t* msg, uint16_t len, bool /*flood*/) {
|
||||
Mesh* m = (Mesh*)ctx;
|
||||
Packet* p = m->createOtaPacket(msg, len);
|
||||
if (p) m->sendOtaFlood(p);
|
||||
}
|
||||
#endif
|
||||
|
||||
void Mesh::begin() {
|
||||
Dispatcher::begin();
|
||||
#if defined(ENABLE_OTA)
|
||||
uint32_t my_tid = 0;
|
||||
#ifdef MOTA_TARGET_ID
|
||||
my_tid = (uint32_t)(MOTA_TARGET_ID); // sha2-256:4(env name), injected by build.sh
|
||||
#endif
|
||||
ota::ota_ctx().begin(my_tid, Mesh::otaSendAdapter, this); // also sets the platform apply codec
|
||||
#endif
|
||||
}
|
||||
|
||||
void Mesh::loop() {
|
||||
Dispatcher::loop();
|
||||
#if defined(ENABLE_OTA)
|
||||
// Deferred apply-reboot: a verified `ota applydelta` approves the update but does NOT reboot inline,
|
||||
// so its "verified; applying" reply can be delivered first (over LoRa that reply is the operator's
|
||||
// only confirmation the apply started). Reboot once that reply has actually been transmitted (the
|
||||
// outbound queue drains) after a short grace to let it be queued, with a hard cap for a busy node
|
||||
// whose queue never idles.
|
||||
{
|
||||
ota::OtaContext& oc = ota::ota_ctx();
|
||||
if (oc.apply_pending) {
|
||||
if (oc.apply_at == 0) {
|
||||
oc.apply_at = futureMillis(1500);
|
||||
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 (millisHasNowPassed(_next_ota_tick)) {
|
||||
ota::ota_ctx().manager.loop(); // re-request still-missing OTA blocks (rate-limited)
|
||||
_next_ota_tick = futureMillis(3000);
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
bool Mesh::allowPacketForward(const mesh::Packet* packet) {
|
||||
@@ -309,6 +351,28 @@ DispatcherAction Mesh::onRecvPacket(Packet* pkt) {
|
||||
}
|
||||
break;
|
||||
|
||||
#if defined(ENABLE_OTA)
|
||||
case PAYLOAD_TYPE_OTA: {
|
||||
// ALWAYS process every received copy: OTA handlers are idempotent, and "eventually reliable"
|
||||
// retries deliberately re-send IDENTICAL requests — if we gated processing on hasSeen(), the
|
||||
// dedup would suppress those retries and the transfer could never recover from a lost reply.
|
||||
// hasSeen() is used ONLY to avoid re-flooding the same packet more than once.
|
||||
bool seen = _tables->hasSeen(pkt);
|
||||
ota::ota_ctx().manager.on_message(pkt->payload, pkt->payload_len); // central OTA receive (all roles)
|
||||
onOtaRecv(pkt); // optional per-example hook
|
||||
// Re-flood with a hop cap and the LOWEST priority, so OTA never competes with mesh traffic.
|
||||
uint8_t n = pkt->getPathHashCount();
|
||||
if (!seen && pkt->isRouteFlood() && !pkt->isMarkedDoNotRetransmit()
|
||||
&& n < getOtaHopLimit()
|
||||
&& (n + 1) * pkt->getPathHashSize() <= MAX_PATH_SIZE
|
||||
&& allowPacketForward(pkt)) {
|
||||
self_id.copyHashTo(&pkt->path[n * pkt->getPathHashSize()], pkt->getPathHashSize());
|
||||
pkt->setPathHashCount(n + 1);
|
||||
action = ACTION_RETRANSMIT_DELAYED(OTA_TX_PRIORITY, getRetransmitDelay(pkt));
|
||||
}
|
||||
break;
|
||||
}
|
||||
#endif
|
||||
default:
|
||||
MESH_DEBUG_PRINTLN("%s Mesh::onRecvPacket(): unknown payload type, header: %d", getLogDateTime(), (int) pkt->header);
|
||||
// Don't flood route unknown packet types! action = routeRecvPacket(pkt);
|
||||
@@ -619,6 +683,29 @@ Packet* Mesh::createControlData(const uint8_t* data, size_t len) {
|
||||
return packet;
|
||||
}
|
||||
|
||||
#if defined(ENABLE_OTA)
|
||||
Packet* Mesh::createOtaPacket(const uint8_t* data, size_t len) {
|
||||
if (len > sizeof(Packet::payload)) return NULL;
|
||||
Packet* packet = obtainNewPacket();
|
||||
if (packet == NULL) {
|
||||
MESH_DEBUG_PRINTLN("%s Mesh::createOtaPacket(): error, packet pool empty", getLogDateTime());
|
||||
return NULL;
|
||||
}
|
||||
packet->header = (PAYLOAD_TYPE_OTA << PH_TYPE_SHIFT); // ROUTE_TYPE_* set by sendOtaFlood
|
||||
memcpy(packet->payload, data, len);
|
||||
packet->payload_len = len;
|
||||
return packet;
|
||||
}
|
||||
|
||||
void Mesh::sendOtaFlood(Packet* packet, uint32_t delay_millis) {
|
||||
packet->header &= ~PH_ROUTE_MASK;
|
||||
packet->header |= ROUTE_TYPE_FLOOD;
|
||||
packet->setPathHashSizeAndCount(1, 0);
|
||||
_tables->hasSeen(packet); // mark as sent, in case it floods back to us
|
||||
sendPacket(packet, OTA_TX_PRIORITY, delay_millis);
|
||||
}
|
||||
#endif
|
||||
|
||||
void Mesh::sendFlood(Packet* packet, uint32_t delay_millis, uint8_t path_hash_size) {
|
||||
if (packet->getPayloadType() == PAYLOAD_TYPE_TRACE) {
|
||||
MESH_DEBUG_PRINTLN("%s Mesh::sendFlood(): TRACE type not suspported", getLogDateTime());
|
||||
|
||||
+33
@@ -2,6 +2,16 @@
|
||||
|
||||
#include <Dispatcher.h>
|
||||
|
||||
#if defined(ENABLE_OTA)
|
||||
// OTA-over-LoRa: lowest TX priority (selected only after all real traffic) + default hop cap.
|
||||
#ifndef OTA_TX_PRIORITY
|
||||
#define OTA_TX_PRIORITY 250
|
||||
#endif
|
||||
#ifndef OTA_HOP_LIMIT_DEFAULT
|
||||
#define OTA_HOP_LIMIT_DEFAULT 3
|
||||
#endif
|
||||
#endif
|
||||
|
||||
namespace mesh {
|
||||
|
||||
class GroupChannel {
|
||||
@@ -144,6 +154,22 @@ protected:
|
||||
*/
|
||||
virtual void onRawDataRecv(Packet* packet) { }
|
||||
|
||||
#if defined(ENABLE_OTA)
|
||||
/**
|
||||
* \brief An OTA-over-LoRa packet (PAYLOAD_TYPE_OTA) has been received. Subclasses forward the
|
||||
* payload bytes to their OtaManager. See docs/ota_protocol.md.
|
||||
*/
|
||||
virtual void onOtaRecv(Packet* packet) { }
|
||||
|
||||
/** \returns the max hop count for forwarding OTA flood packets (default 3). */
|
||||
virtual uint8_t getOtaHopLimit() const { return OTA_HOP_LIMIT_DEFAULT; }
|
||||
|
||||
// OTA mesh-integration is centralized in Mesh::begin()/loop()/dispatch, so every role (repeater,
|
||||
// companion, room, sensor, ...) gets fetch/serve/apply without per-example wiring.
|
||||
static void otaSendAdapter(void* ctx, const uint8_t* msg, uint16_t len, bool flood);
|
||||
unsigned long _next_ota_tick = 0;
|
||||
#endif
|
||||
|
||||
/**
|
||||
* \brief Perform search of local DB of matching GroupChannels.
|
||||
* \param channels OUT - store matching channels in this array, up to max_matches
|
||||
@@ -192,6 +218,13 @@ public:
|
||||
Packet* createPathReturn(const uint8_t* dest_hash, const uint8_t* secret, const uint8_t* path, uint8_t path_len, uint8_t extra_type, const uint8_t*extra, size_t extra_len);
|
||||
Packet* createPathReturn(const Identity& dest, const uint8_t* secret, const uint8_t* path, uint8_t path_len, uint8_t extra_type, const uint8_t*extra, size_t extra_len);
|
||||
Packet* createRawData(const uint8_t* data, size_t len);
|
||||
|
||||
#if defined(ENABLE_OTA)
|
||||
// Build a PAYLOAD_TYPE_OTA packet from raw OTA message bytes (route set by sendOtaFlood).
|
||||
Packet* createOtaPacket(const uint8_t* data, size_t len);
|
||||
// Flood-send at the lowest priority (so OTA never competes with mesh traffic).
|
||||
void sendOtaFlood(Packet* packet, uint32_t delay_millis = 0);
|
||||
#endif
|
||||
Packet* createTrace(uint32_t tag, uint32_t auth_code, uint8_t flags = 0);
|
||||
Packet* createControlData(const uint8_t* data, size_t len);
|
||||
|
||||
|
||||
@@ -64,6 +64,17 @@ public:
|
||||
virtual uint8_t getStartupReason() const = 0;
|
||||
virtual bool getBootloaderVersion(char* version, size_t max_len) { return false; }
|
||||
virtual bool startOTAUpdate(const char* id, char reply[]) { return false; } // not supported
|
||||
#if defined(ENABLE_OTA)
|
||||
// 4-byte build-target discriminator for OTA-over-LoRa (docs/ota_protocol.md §9). Default is the
|
||||
// MOTA_TARGET_ID build flag injected by build.sh; 0 when unset (e.g. a bare IDE build).
|
||||
virtual uint32_t getOtaTargetId() const {
|
||||
#ifdef MOTA_TARGET_ID
|
||||
return (uint32_t)(MOTA_TARGET_ID);
|
||||
#else
|
||||
return 0;
|
||||
#endif
|
||||
}
|
||||
#endif
|
||||
|
||||
// Power management interface (boards with power management override these)
|
||||
virtual bool isExternalPowered() { return false; }
|
||||
|
||||
@@ -28,6 +28,7 @@ namespace mesh {
|
||||
#define PAYLOAD_TYPE_TRACE 0x09 // trace a path, collecting SNI for each hop
|
||||
#define PAYLOAD_TYPE_MULTIPART 0x0A // packet is one of a set of packets
|
||||
#define PAYLOAD_TYPE_CONTROL 0x0B // a control/discovery packet
|
||||
#define PAYLOAD_TYPE_OTA 0x0C // OTA-over-LoRa firmware distribution (see docs/ota_protocol.md)
|
||||
//...
|
||||
#define PAYLOAD_TYPE_RAW_CUSTOM 0x0F // custom packet as raw bytes, for applications with custom encryption, payloads, etc
|
||||
|
||||
|
||||
@@ -4,6 +4,9 @@
|
||||
#include "AdvertDataHelpers.h"
|
||||
#include "TxtDataHelpers.h"
|
||||
#include <RTClib.h>
|
||||
#if defined(ENABLE_OTA)
|
||||
#include "ota/OtaCli.h"
|
||||
#endif
|
||||
|
||||
#ifndef BRIDGE_MAX_BAUD
|
||||
#define BRIDGE_MAX_BAUD 115200
|
||||
@@ -306,6 +309,10 @@ void CommonCLI::handleCommand(uint32_t sender_timestamp, char* command, char* re
|
||||
sprintf(reply, "%s (Build: %s)", _callbacks->getFirmwareVer(), _callbacks->getBuildDate());
|
||||
} else if (memcmp(command, "board", 5) == 0) {
|
||||
sprintf(reply, "%s", _board->getManufacturerName());
|
||||
#if defined(ENABLE_OTA)
|
||||
} else if (memcmp(command, "ota", 3) == 0 && (command[3] == 0 || command[3] == ' ')) {
|
||||
mesh::ota::handle_ota_command(command, reply, *_board);
|
||||
#endif
|
||||
} else if (memcmp(command, "sensor get ", 11) == 0) {
|
||||
const char* key = command + 11;
|
||||
const char* val = _sensors->getSettingByKey(key);
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
#pragma once
|
||||
|
||||
#include <stdint.h>
|
||||
#include <stddef.h>
|
||||
#include <string.h>
|
||||
|
||||
// Block-availability helpers (docs/ota_protocol.md §7).
|
||||
//
|
||||
// Availability is *derived* from the staged manifest's leaves[]: block i is present iff its 4-byte
|
||||
// leaf slot is non-erased (!= FF FF FF FF). No separate persistent structure. A compact bitmap
|
||||
// (1 bit/block) is used on the wire (OTA_HAVE) and as an in-RAM cache. All ops are caller-buffer
|
||||
// based; no allocation.
|
||||
|
||||
namespace mesh {
|
||||
namespace ota {
|
||||
|
||||
inline bool leaf_present(const uint8_t* leaves, uint32_t i) {
|
||||
const uint8_t* p = leaves + (size_t)i * 4;
|
||||
return !(p[0] == 0xFF && p[1] == 0xFF && p[2] == 0xFF && p[3] == 0xFF);
|
||||
}
|
||||
|
||||
inline uint32_t bitmap_bytes(uint32_t block_count) { return (block_count + 7) / 8; }
|
||||
|
||||
inline bool bitmap_get(const uint8_t* bm, uint32_t i) {
|
||||
return (bm[i >> 3] >> (i & 7)) & 1;
|
||||
}
|
||||
|
||||
inline void bitmap_set(uint8_t* bm, uint32_t i, bool v) {
|
||||
uint8_t mask = (uint8_t)(1u << (i & 7));
|
||||
if (v) bm[i >> 3] |= mask; else bm[i >> 3] &= (uint8_t)~mask;
|
||||
}
|
||||
|
||||
// Build a bitmap (caller buffer >= bitmap_bytes(count)) from leaves[].
|
||||
inline void leaves_to_bitmap(const uint8_t* leaves, uint32_t count, uint8_t* bm_out) {
|
||||
memset(bm_out, 0, bitmap_bytes(count));
|
||||
for (uint32_t i = 0; i < count; i++)
|
||||
if (leaf_present(leaves, i)) bitmap_set(bm_out, i, true);
|
||||
}
|
||||
|
||||
inline uint32_t count_present(const uint8_t* leaves, uint32_t count) {
|
||||
uint32_t n = 0;
|
||||
for (uint32_t i = 0; i < count; i++) if (leaf_present(leaves, i)) n++;
|
||||
return n;
|
||||
}
|
||||
|
||||
inline bool all_present(const uint8_t* leaves, uint32_t count) {
|
||||
for (uint32_t i = 0; i < count; i++) if (!leaf_present(leaves, i)) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
} // namespace ota
|
||||
} // namespace mesh
|
||||
@@ -0,0 +1,39 @@
|
||||
#include "FirmwareInfo.h"
|
||||
#include "Multihash.h"
|
||||
#include <string.h>
|
||||
|
||||
namespace mesh {
|
||||
namespace ota {
|
||||
|
||||
static uint32_t rd_u32(const uint8_t* p) {
|
||||
return (uint32_t)p[0] | ((uint32_t)p[1] << 8) | ((uint32_t)p[2] << 16) | ((uint32_t)p[3] << 24);
|
||||
}
|
||||
|
||||
bool find_self_firmware(const uint8_t* region, uint32_t region_len,
|
||||
SelfFwInfo& out, bool verify_body) {
|
||||
out = SelfFwInfo();
|
||||
if (!region || region_len < ENDF_LEN) return false;
|
||||
|
||||
for (uint32_t off = 0; off + ENDF_LEN <= region_len; off++) {
|
||||
if (region[off] != ENDF_MAGIC[0]) continue; // cheap pre-filter ('E')
|
||||
if (memcmp(region + off, ENDF_MAGIC, 4) != 0) continue;
|
||||
uint32_t body_len = rd_u32(region + off + 4);
|
||||
if (body_len != off) continue; // trailer must sit right after the body
|
||||
|
||||
if (verify_body) {
|
||||
uint8_t h[8];
|
||||
mh8(h, region, body_len);
|
||||
if (memcmp(h, region + off + 8, 8) != 0) continue; // coincidental marker — keep scanning
|
||||
}
|
||||
out.valid = true;
|
||||
out.endf_offset = off;
|
||||
out.body_len = body_len;
|
||||
out.image_len = off + ENDF_LEN;
|
||||
memcpy(out.body_hash, region + off + 8, 8);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
} // namespace ota
|
||||
} // namespace mesh
|
||||
@@ -0,0 +1,30 @@
|
||||
#pragma once
|
||||
|
||||
#include <stdint.h>
|
||||
#include <stddef.h>
|
||||
#include "OtaFormat.h"
|
||||
|
||||
// Locate the EndF trailer in a firmware image to learn the running firmware's size + identity
|
||||
// (docs/ota_protocol.md §2). Portable: operates on a contiguous, readable region — on nRF52/ESP32
|
||||
// the application flash is memory-mapped, so the region pointer is just (const uint8_t*)APP_BASE.
|
||||
|
||||
namespace mesh {
|
||||
namespace ota {
|
||||
|
||||
struct SelfFwInfo {
|
||||
bool valid = false;
|
||||
uint32_t body_len = 0; // firmware body length (excludes the 16-byte EndF trailer)
|
||||
uint32_t image_len = 0; // body_len + ENDF_LEN (what a delta base / full image hashes over)
|
||||
uint32_t endf_offset = 0; // offset of the "EndF" marker within the region (== body_len)
|
||||
uint8_t body_hash[8] = {0}; // sha2-256:8 of the body (read from EndF; == a delta's base_hash)
|
||||
};
|
||||
|
||||
// Scan `region[0..region_len)` for the firmware's EndF trailer. The body starts at offset 0, so the
|
||||
// trailer's offset must equal its stored body_len — this uniquely identifies the running firmware's
|
||||
// EndF even if a staged `.mota` (which contains its own embedded EndF) sits higher in the region.
|
||||
// If `verify_body` is true the body hash is recomputed and must match (rules out coincidental markers).
|
||||
bool find_self_firmware(const uint8_t* region, uint32_t region_len,
|
||||
SelfFwInfo& out, bool verify_body = false);
|
||||
|
||||
} // namespace ota
|
||||
} // namespace mesh
|
||||
@@ -0,0 +1,110 @@
|
||||
#include "MerkleTree.h"
|
||||
#include "Multihash.h"
|
||||
#include <string.h>
|
||||
|
||||
namespace mesh {
|
||||
namespace ota {
|
||||
|
||||
void merkle_leaf(uint8_t out[4], const uint8_t* block, uint32_t block_len) {
|
||||
mh4(out, block, block_len);
|
||||
}
|
||||
|
||||
void merkle_combine(uint8_t out[4], const uint8_t* left, const uint8_t* right) {
|
||||
sha256_trunc2(out, 4, left, 4, right, 4);
|
||||
}
|
||||
|
||||
// Root via binary-counter / Merkle-Mountain-Range with right-to-left bagging.
|
||||
// Equivalent to the level-by-level "pair adjacent, promote lone last (left||right)" reduction
|
||||
// (verified against the reference implementation across many counts in the native tests).
|
||||
void merkle_root(uint8_t out[4], const uint8_t* leaves, uint32_t count) {
|
||||
if (count == 0) { memset(out, 0, 4); return; }
|
||||
if (count == 1) { memcpy(out, leaves, 4); return; }
|
||||
|
||||
uint8_t peaks[32][4];
|
||||
bool valid[32] = { false };
|
||||
|
||||
for (uint32_t i = 0; i < count; i++) {
|
||||
uint8_t cur[4];
|
||||
memcpy(cur, leaves + (size_t)i * 4, 4);
|
||||
uint32_t level = 0;
|
||||
while (valid[level]) { // carry: combine with the pending peak at this level
|
||||
merkle_combine(cur, peaks[level], cur); // peak is earlier (left), cur is right
|
||||
valid[level] = false;
|
||||
level++;
|
||||
}
|
||||
memcpy(peaks[level], cur, 4);
|
||||
valid[level] = true;
|
||||
}
|
||||
|
||||
// bag peaks right-to-left: acc starts at the lowest set level (rightmost peak)
|
||||
int level = 0;
|
||||
while (level < 32 && !valid[level]) level++;
|
||||
uint8_t acc[4];
|
||||
memcpy(acc, peaks[level], 4);
|
||||
for (int l = level + 1; l < 32; l++) {
|
||||
if (valid[l]) merkle_combine(acc, peaks[l], acc); // higher peak is left, acc is right
|
||||
}
|
||||
memcpy(out, acc, 4);
|
||||
}
|
||||
|
||||
bool merkle_verify(const uint8_t* block, uint32_t block_len, uint32_t index,
|
||||
const uint8_t* siblings, uint8_t n_siblings,
|
||||
const uint8_t root[4], uint32_t count) {
|
||||
uint8_t leaf[4];
|
||||
merkle_leaf(leaf, block, block_len);
|
||||
return merkle_verify_from_leaf(leaf, index, siblings, n_siblings, root, count);
|
||||
}
|
||||
|
||||
bool merkle_verify_from_leaf(const uint8_t leaf[4], uint32_t index,
|
||||
const uint8_t* siblings, uint8_t n_siblings,
|
||||
const uint8_t root[4], uint32_t count) {
|
||||
if (count == 0 || index >= count) return false;
|
||||
uint8_t h[4];
|
||||
memcpy(h, leaf, 4);
|
||||
|
||||
uint32_t idx = index;
|
||||
uint32_t n = count;
|
||||
uint8_t p = 0;
|
||||
while (n > 1) {
|
||||
bool is_last_odd = (n & 1u) && (idx == n - 1);
|
||||
if (!is_last_odd) {
|
||||
if (p >= n_siblings) return false;
|
||||
const uint8_t* sib = siblings + (size_t)p * 4;
|
||||
p++;
|
||||
if (idx & 1u) merkle_combine(h, sib, h); // odd index -> sibling on the left
|
||||
else merkle_combine(h, h, sib); // even index -> sibling on the right
|
||||
}
|
||||
idx >>= 1;
|
||||
n = (n + 1) >> 1;
|
||||
}
|
||||
return (p == n_siblings) && (memcmp(h, root, 4) == 0);
|
||||
}
|
||||
|
||||
uint8_t merkle_gen_proof(const uint8_t* leaves, uint32_t count, uint32_t index,
|
||||
uint8_t* scratch, uint8_t* out_siblings) {
|
||||
if (count == 0 || index >= count) return 0;
|
||||
memcpy(scratch, leaves, (size_t)count * 4);
|
||||
uint32_t n = count, idx = index;
|
||||
uint8_t p = 0;
|
||||
while (n > 1) {
|
||||
bool is_last_odd = (n & 1u) && (idx == n - 1);
|
||||
if (!is_last_odd) {
|
||||
uint32_t s = (idx & 1u) ? idx - 1 : idx + 1;
|
||||
memcpy(out_siblings + (size_t)p * 4, scratch + (size_t)s * 4, 4);
|
||||
p++;
|
||||
}
|
||||
// reduce one level in place (parent m written from children 2m,2m+1; m <= i so it's safe)
|
||||
uint32_t m = 0;
|
||||
for (uint32_t i = 0; i < n; i += 2) {
|
||||
if (i + 1 < n) merkle_combine(scratch + (size_t)m * 4, scratch + (size_t)i * 4, scratch + (size_t)(i + 1) * 4);
|
||||
else memmove(scratch + (size_t)m * 4, scratch + (size_t)i * 4, 4);
|
||||
m++;
|
||||
}
|
||||
idx >>= 1;
|
||||
n = (n + 1) >> 1;
|
||||
}
|
||||
return p;
|
||||
}
|
||||
|
||||
} // namespace ota
|
||||
} // namespace mesh
|
||||
@@ -0,0 +1,46 @@
|
||||
#pragma once
|
||||
|
||||
#include <stdint.h>
|
||||
#include <stddef.h>
|
||||
|
||||
// Merkle tree over PAYLOAD blocks, sha2-256:4 (4-byte) leaves/nodes. See docs/ota_protocol.md §6.
|
||||
//
|
||||
// Scheme: leaf = H(block); node = H(left || right); on an odd level the last node is promoted
|
||||
// unchanged (no duplication). Root = single remaining node.
|
||||
//
|
||||
// No dynamic allocation: the root is computed with an O(log count) "binary counter" of partial
|
||||
// peaks (<= 32 levels => 128 bytes of stack). Proofs carry only sibling digests; the left/right
|
||||
// direction is derived from the block index + total count (no direction bits on the wire).
|
||||
|
||||
namespace mesh {
|
||||
namespace ota {
|
||||
|
||||
// leaf digest of one payload block
|
||||
void merkle_leaf(uint8_t out[4], const uint8_t* block, uint32_t block_len);
|
||||
|
||||
// parent of two 4-byte children
|
||||
void merkle_combine(uint8_t out[4], const uint8_t* left, const uint8_t* right);
|
||||
|
||||
// root over `count` contiguous 4-byte leaf digests (leaves[count*4]). count >= 1.
|
||||
void merkle_root(uint8_t out[4], const uint8_t* leaves, uint32_t count);
|
||||
|
||||
// Verify that `block` is block `index` of a `count`-block payload whose tree has the given `root`.
|
||||
// `siblings` is n_siblings contiguous 4-byte digests, ordered leaf->root (promoted levels omitted;
|
||||
// left/right direction derived from index + count).
|
||||
bool merkle_verify(const uint8_t* block, uint32_t block_len, uint32_t index,
|
||||
const uint8_t* siblings, uint8_t n_siblings,
|
||||
const uint8_t root[4], uint32_t count);
|
||||
|
||||
// Same, but starting from a precomputed 4-byte leaf digest (skips the H(block) step).
|
||||
bool merkle_verify_from_leaf(const uint8_t leaf[4], uint32_t index,
|
||||
const uint8_t* siblings, uint8_t n_siblings,
|
||||
const uint8_t root[4], uint32_t count);
|
||||
|
||||
// Generate the proof (ordered sibling digests) for block `index`, for a server holding leaves[].
|
||||
// `scratch` must be >= count*4 bytes (working buffer); `out_siblings` >= 32*4 bytes.
|
||||
// Returns the number of 4-byte siblings written. Output matches the wire form merkle_verify expects.
|
||||
uint8_t merkle_gen_proof(const uint8_t* leaves, uint32_t count, uint32_t index,
|
||||
uint8_t* scratch, uint8_t* out_siblings);
|
||||
|
||||
} // namespace ota
|
||||
} // namespace mesh
|
||||
@@ -0,0 +1,125 @@
|
||||
#include "MotaContainer.h"
|
||||
#include "MerkleTree.h"
|
||||
#include "Multihash.h"
|
||||
#include <string.h>
|
||||
|
||||
namespace mesh {
|
||||
namespace ota {
|
||||
|
||||
static uint32_t rd_u32(const uint8_t* p) {
|
||||
return (uint32_t)p[0] | ((uint32_t)p[1] << 8) | ((uint32_t)p[2] << 16) | ((uint32_t)p[3] << 24);
|
||||
}
|
||||
|
||||
bool MotaManifest::is_approved() const {
|
||||
return approval && memcmp(approval, APPROVAL_YES, 4) == 0;
|
||||
}
|
||||
|
||||
bool mota_parse(const uint8_t* buf, uint32_t len, MotaManifest& out) {
|
||||
out = MotaManifest();
|
||||
if (len < 4 + 4 + 5) return false;
|
||||
if (memcmp(buf, MOTA_MAGIC, 4) != 0) return false;
|
||||
if (memcmp(buf + len - 5, MOTA_TRAILER, 5) != 0) return false;
|
||||
uint32_t total = rd_u32(buf + 4);
|
||||
if (total != len) return false;
|
||||
|
||||
const uint8_t* p = buf + 8; // start of manifest
|
||||
const uint8_t* end = buf + len - 5; // start of trailer
|
||||
out.manifest_start = p;
|
||||
// helper bounds check
|
||||
#define NEED(n) do { if ((uint32_t)(end - p) < (uint32_t)(n)) return false; } while (0)
|
||||
|
||||
NEED(3 + 16 + 1 + 4 + 32 + 1);
|
||||
out.format_ver = p[0];
|
||||
if (out.format_ver != MOTA_FORMAT_VER) return false;
|
||||
out.flags = p[1];
|
||||
out.hash_algo = p[2];
|
||||
out.target_id = rd_u32(p + 3);
|
||||
out.fw_version = rd_u32(p + 7);
|
||||
out.image_size = rd_u32(p + 11);
|
||||
out.payload_size = rd_u32(p + 15);
|
||||
out.block_size_log2 = p[19];
|
||||
out.merkle_root = p + 20;
|
||||
out.image_hash = p + 24;
|
||||
out.codec_id = p[56];
|
||||
p += 57;
|
||||
|
||||
if (out.block_size_log2 == 0 || out.block_size_log2 > 24) return false;
|
||||
uint32_t bs = out.block_size();
|
||||
out.block_count = (out.payload_size + bs - 1) / bs;
|
||||
if (out.payload_size == 0 || out.block_count == 0) return false;
|
||||
|
||||
if (!out.is_full()) { NEED(8); out.base_hash = p; p += 8; }
|
||||
|
||||
if (out.is_signed()) {
|
||||
NEED(32); out.signer_pubkey = p; p += 32;
|
||||
out.signed_len = (uint32_t)(p - (buf + 8)); // signature covers everything up to here
|
||||
NEED(64); out.signature = p; p += 64;
|
||||
} else {
|
||||
out.signed_len = (uint32_t)(p - (buf + 8));
|
||||
}
|
||||
|
||||
NEED(4); out.approval = p; p += 4;
|
||||
|
||||
uint32_t leaves_bytes = out.block_count * 4;
|
||||
NEED(leaves_bytes); out.leaves = p; p += leaves_bytes;
|
||||
|
||||
NEED(out.payload_size); out.payload = p; p += out.payload_size;
|
||||
|
||||
// payload must end exactly at the trailer
|
||||
if (p != end) return false;
|
||||
#undef NEED
|
||||
return true;
|
||||
}
|
||||
|
||||
bool mota_parse_manifest(const uint8_t* mf, uint32_t len, MotaManifest& out) {
|
||||
out = MotaManifest();
|
||||
const uint8_t* p = mf;
|
||||
const uint8_t* end = mf + len;
|
||||
#define NEEDM(n) do { if ((uint32_t)(end - p) < (uint32_t)(n)) return false; } while (0)
|
||||
|
||||
NEEDM(57);
|
||||
out.manifest_start = mf;
|
||||
out.format_ver = p[0];
|
||||
if (out.format_ver != MOTA_FORMAT_VER) return false;
|
||||
out.flags = p[1];
|
||||
out.hash_algo = p[2];
|
||||
out.target_id = rd_u32(p + 3);
|
||||
out.fw_version = rd_u32(p + 7);
|
||||
out.image_size = rd_u32(p + 11);
|
||||
out.payload_size = rd_u32(p + 15);
|
||||
out.block_size_log2 = p[19];
|
||||
out.merkle_root = p + 20;
|
||||
out.image_hash = p + 24;
|
||||
out.codec_id = p[56];
|
||||
p += 57;
|
||||
if (!out.is_full()) { NEEDM(8); out.base_hash = p; p += 8; }
|
||||
if (out.is_signed()) {
|
||||
NEEDM(32); out.signer_pubkey = p; p += 32;
|
||||
out.signed_len = (uint32_t)(p - mf);
|
||||
NEEDM(64); out.signature = p; p += 64;
|
||||
} else {
|
||||
out.signed_len = (uint32_t)(p - mf);
|
||||
}
|
||||
NEEDM(4); out.approval = p; p += 4;
|
||||
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();
|
||||
#undef NEEDM
|
||||
return true;
|
||||
}
|
||||
|
||||
bool mota_check_root(const MotaManifest& m) {
|
||||
if (!m.leaves || m.block_count == 0) return false;
|
||||
uint8_t root[4];
|
||||
merkle_root(root, m.leaves, m.block_count);
|
||||
return memcmp(root, m.merkle_root, 4) == 0;
|
||||
}
|
||||
|
||||
bool mota_check_image_hash_full(const MotaManifest& m) {
|
||||
if (!m.is_full() || !m.payload || !m.image_hash) return false;
|
||||
uint8_t h[32];
|
||||
mh32(h, m.payload, m.payload_size);
|
||||
return memcmp(h, m.image_hash, 32) == 0;
|
||||
}
|
||||
|
||||
} // namespace ota
|
||||
} // namespace mesh
|
||||
@@ -0,0 +1,61 @@
|
||||
#pragma once
|
||||
|
||||
#include <stdint.h>
|
||||
#include <stddef.h>
|
||||
#include "OtaFormat.h"
|
||||
|
||||
// Parse/validate a `.mota` container that is fully present in a RAM buffer (docs/ota_protocol.md
|
||||
// §3-§4). Variable-length parts are referenced by pointer into the caller's buffer — no copies, no
|
||||
// allocation. (Device flash-backed staging gets a streaming variant in a later milestone; the field
|
||||
// layout here is the single source of truth.)
|
||||
|
||||
namespace mesh {
|
||||
namespace ota {
|
||||
|
||||
struct MotaManifest {
|
||||
uint8_t format_ver = 0;
|
||||
uint8_t flags = 0;
|
||||
uint8_t hash_algo = 0;
|
||||
uint32_t target_id = 0;
|
||||
uint32_t fw_version = 0;
|
||||
uint32_t image_size = 0;
|
||||
uint32_t payload_size = 0;
|
||||
uint8_t block_size_log2 = 0;
|
||||
uint8_t codec_id = 0;
|
||||
uint32_t block_count = 0;
|
||||
|
||||
const uint8_t* merkle_root = nullptr; // 4
|
||||
const uint8_t* image_hash = nullptr; // 32
|
||||
const uint8_t* base_hash = nullptr; // 8 (delta only)
|
||||
const uint8_t* signer_pubkey = nullptr; // 32 (signed only)
|
||||
const uint8_t* signature = nullptr; // 64 (signed only)
|
||||
const uint8_t* approval = nullptr; // 4
|
||||
const uint8_t* leaves = nullptr; // 4 * block_count
|
||||
const uint8_t* payload = nullptr; // payload_size
|
||||
const uint8_t* manifest_start = nullptr;// first manifest byte (== start of the signed region)
|
||||
uint32_t signed_len = 0; // #bytes the signature covers (from manifest_start)
|
||||
|
||||
bool is_full() const { return flags & MFLAG_FULL; }
|
||||
bool is_signed() const { return flags & MFLAG_SIGNED; }
|
||||
uint32_t block_size() const { return 1u << block_size_log2; }
|
||||
bool is_approved() const;
|
||||
};
|
||||
|
||||
// Parse a whole container in `buf[len]`. Returns true on success and fills `out` with pointers into
|
||||
// `buf`. Validates MAGIC, TRAILER, MOTA_TOTAL_SIZE, format_ver, and internal length consistency.
|
||||
bool mota_parse(const uint8_t* buf, uint32_t len, MotaManifest& out);
|
||||
|
||||
// Parse a standalone manifest (the bytes [manifest_start, leaves) of a container, i.e. without the
|
||||
// MAGIC/TOTAL_SIZE framing, leaves[] or payload). Used by the apply path, which receives the manifest
|
||||
// separately from the image. Sets the fixed fields + signer/signature + signed_len; leaves/payload
|
||||
// are left null.
|
||||
bool mota_parse_manifest(const uint8_t* mf, uint32_t len, MotaManifest& out);
|
||||
|
||||
// Recompute the merkle root from the manifest's leaves[] and compare to the merkle_root field.
|
||||
bool mota_check_root(const MotaManifest& m);
|
||||
|
||||
// For FULL images only: check sha2-256:32(payload) == image_hash.
|
||||
bool mota_check_image_hash_full(const MotaManifest& m);
|
||||
|
||||
} // namespace ota
|
||||
} // namespace mesh
|
||||
@@ -0,0 +1,28 @@
|
||||
#pragma once
|
||||
|
||||
#include <stdint.h>
|
||||
#include <stddef.h>
|
||||
#include "Utils.h" // mesh::Utils::sha256 (real on device; real host SHA-256 via test/mocks)
|
||||
#include "OtaFormat.h"
|
||||
|
||||
// Thin multihash helpers: SHA-256 truncated to N bytes. No state, no allocation.
|
||||
|
||||
namespace mesh {
|
||||
namespace ota {
|
||||
|
||||
inline void sha256_trunc(uint8_t* out, size_t out_len, const uint8_t* data, size_t len) {
|
||||
mesh::Utils::sha256(out, out_len, data, (int)len);
|
||||
}
|
||||
|
||||
inline void sha256_trunc2(uint8_t* out, size_t out_len,
|
||||
const uint8_t* a, size_t a_len,
|
||||
const uint8_t* b, size_t b_len) {
|
||||
mesh::Utils::sha256(out, out_len, a, (int)a_len, b, (int)b_len);
|
||||
}
|
||||
|
||||
inline void mh4(uint8_t out[4], const uint8_t* data, size_t len) { sha256_trunc(out, 4, data, len); }
|
||||
inline void mh8(uint8_t out[8], const uint8_t* data, size_t len) { sha256_trunc(out, 8, data, len); }
|
||||
inline void mh32(uint8_t out[32], const uint8_t* data, size_t len){ sha256_trunc(out, 32, data, len); }
|
||||
|
||||
} // namespace ota
|
||||
} // namespace mesh
|
||||
@@ -0,0 +1,265 @@
|
||||
#include "OtaApply.h"
|
||||
#include "OtaFormat.h"
|
||||
#include "MotaContainer.h"
|
||||
#include "Identity.h"
|
||||
#include <string.h>
|
||||
|
||||
#if defined(ESP32_PLATFORM)
|
||||
#include <SHA256.h> // rweather streaming SHA-256 (for hashing the slot in chunks)
|
||||
#include "esp_ota_ops.h"
|
||||
#include "esp_partition.h"
|
||||
#include "esp_system.h"
|
||||
extern "C" {
|
||||
#include "detools/detools.h" // vendored detools 0.53.0 embeddable decoder (CRLE-only build)
|
||||
}
|
||||
#elif defined(NRF52_PLATFORM)
|
||||
#include "OtaVerify.h"
|
||||
#include "OtaSelf.h"
|
||||
#include "OtaFlashLayout_nrf52.h"
|
||||
#include "flash/flash_nrf5x.h" // Adafruit core internal-flash driver (has its own extern "C")
|
||||
#include "nrf.h"
|
||||
#include "nrf_soc.h"
|
||||
#include "nrf_sdm.h"
|
||||
#endif
|
||||
|
||||
namespace mesh {
|
||||
namespace ota {
|
||||
|
||||
#if defined(ESP32_PLATFORM)
|
||||
|
||||
bool ota_apply_slot_info(uint32_t* addr, uint32_t* size) {
|
||||
const esp_partition_t* p = esp_ota_get_next_update_partition(nullptr);
|
||||
if (!p) return false;
|
||||
if (addr) *addr = p->address;
|
||||
if (size) *size = p->size;
|
||||
return true;
|
||||
}
|
||||
|
||||
bool ota_apply_set_manifest(const uint8_t* mf, uint32_t len, const SignerAllowlist& allow, ApplyState& st) {
|
||||
st = ApplyState();
|
||||
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)
|
||||
st.image_size = m.image_size;
|
||||
memcpy(st.image_hash, m.image_hash, 32);
|
||||
st.manifest_ok = true;
|
||||
if (m.is_signed()) {
|
||||
mesh::Identity signer(m.signer_pubkey);
|
||||
st.sig_ok = signer.verify(m.signature, m.manifest_start, (int)m.signed_len);
|
||||
st.trusted = st.sig_ok && allow.contains(m.signer_pubkey);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
bool ota_apply_verify_slot(ApplyState& st) {
|
||||
st.slot_ok = false;
|
||||
if (!st.manifest_ok || st.image_size == 0 || st.image_size > st.slot_size) return false;
|
||||
const esp_partition_t* p = esp_ota_get_next_update_partition(nullptr);
|
||||
if (!p) return false;
|
||||
SHA256 sha;
|
||||
uint8_t buf[512];
|
||||
uint32_t off = 0;
|
||||
while (off < st.image_size) {
|
||||
uint32_t n = st.image_size - off; if (n > sizeof(buf)) n = sizeof(buf);
|
||||
if (esp_partition_read(p, off, buf, n) != ESP_OK) return false;
|
||||
sha.update(buf, n);
|
||||
off += n;
|
||||
}
|
||||
uint8_t h[32];
|
||||
sha.finalize(h, 32);
|
||||
st.slot_ok = (memcmp(h, st.image_hash, 32) == 0);
|
||||
return st.slot_ok;
|
||||
}
|
||||
|
||||
bool ota_apply_commit() {
|
||||
const esp_partition_t* p = esp_ota_get_next_update_partition(nullptr);
|
||||
if (!p) return false;
|
||||
if (esp_ota_set_boot_partition(p) != ESP_OK) return false;
|
||||
esp_restart(); // does not return
|
||||
return true;
|
||||
}
|
||||
|
||||
// --- detools callback context -----------------------------------------------------------------
|
||||
// The delta base is the running OTA slot; the reconstructed image is streamed into the inactive slot
|
||||
// via esp_ota_write (sequential, append-only -- matches detools' sequential output ordering) and
|
||||
// hashed on the fly so we can check it against the signed manifest image_hash before arming.
|
||||
struct DetoolsCtx {
|
||||
const esp_partition_t* base; // delta base (running image), read at absolute `from_pos`
|
||||
long from_pos; // absolute byte offset into `base`
|
||||
const uint8_t* patch; // .mota payload (whole patch held in RAM)
|
||||
uint32_t patch_len;
|
||||
uint32_t patch_pos;
|
||||
esp_ota_handle_t out; // inactive slot write handle
|
||||
SHA256* sha; // running hash of the reconstructed output
|
||||
uint32_t out_pos; // #bytes written to the output slot
|
||||
bool io_ok;
|
||||
};
|
||||
|
||||
static int dt_from_read(void* arg, uint8_t* buf, size_t size) {
|
||||
DetoolsCtx* c = (DetoolsCtx*)arg;
|
||||
if (c->from_pos < 0 || (uint32_t)(c->from_pos) + size > c->base->size) return -DETOOLS_IO_FAILED;
|
||||
if (esp_partition_read(c->base, (size_t)c->from_pos, buf, size) != ESP_OK) { c->io_ok = false; return -DETOOLS_IO_FAILED; }
|
||||
c->from_pos += (long)size;
|
||||
return DETOOLS_OK;
|
||||
}
|
||||
static int dt_from_seek(void* arg, int offset) { // detools uses relative seeks
|
||||
DetoolsCtx* c = (DetoolsCtx*)arg;
|
||||
c->from_pos += offset;
|
||||
if (c->from_pos < 0 || (uint32_t)c->from_pos > c->base->size) return -DETOOLS_IO_FAILED;
|
||||
return DETOOLS_OK;
|
||||
}
|
||||
static int dt_patch_read(void* arg, uint8_t* buf, size_t size) {
|
||||
DetoolsCtx* c = (DetoolsCtx*)arg;
|
||||
if (c->patch_pos + size > c->patch_len) return -DETOOLS_IO_FAILED;
|
||||
memcpy(buf, c->patch + c->patch_pos, size);
|
||||
c->patch_pos += (uint32_t)size;
|
||||
return DETOOLS_OK;
|
||||
}
|
||||
static int dt_to_write(void* arg, const uint8_t* buf, size_t size) {
|
||||
DetoolsCtx* c = (DetoolsCtx*)arg;
|
||||
if (esp_ota_write(c->out, buf, size) != ESP_OK) { c->io_ok = false; return -DETOOLS_IO_FAILED; }
|
||||
c->sha->update(buf, size);
|
||||
c->out_pos += (uint32_t)size;
|
||||
return DETOOLS_OK;
|
||||
}
|
||||
|
||||
bool ota_apply_detools_mota(const uint8_t* buf, uint32_t len, const SignerAllowlist& allow,
|
||||
ApplyState& st, char* msg) {
|
||||
st = ApplyState();
|
||||
MotaManifest m;
|
||||
if (!mota_parse(buf, len, m)) { strcpy(msg, "no valid .mota (parse failed)"); 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);
|
||||
st.manifest_ok = true;
|
||||
// signature (if signed): valid Ed25519 AND signer in this device's allowlist — refuse otherwise,
|
||||
// BEFORE decoding an untrusted image into the slot. (The decoded result is also checked against the
|
||||
// manifest image_hash below, which is the target-firmware-hash gate.)
|
||||
if (m.is_signed()) {
|
||||
mesh::Identity signer(m.signer_pubkey);
|
||||
st.sig_ok = signer.verify(m.signature, m.manifest_start, (int)m.signed_len);
|
||||
st.trusted = st.sig_ok && allow.contains(m.signer_pubkey);
|
||||
if (!st.sig_ok) { strcpy(msg, "bad signature"); return false; }
|
||||
if (!st.trusted) { strcpy(msg, "untrusted signer (pubkey not in allowlist)"); return false; }
|
||||
}
|
||||
|
||||
const esp_partition_t* base = esp_ota_get_running_partition(); // delta base = what's running
|
||||
const esp_partition_t* out = esp_ota_get_next_update_partition(nullptr);
|
||||
if (!base || !out) { strcpy(msg, "no A/B slot"); return false; }
|
||||
st.slot_addr = out->address; st.slot_size = out->size;
|
||||
if (m.image_size > out->size) { strcpy(msg, "image > slot"); return false; }
|
||||
|
||||
esp_ota_handle_t h;
|
||||
if (esp_ota_begin(out, m.image_size, &h) != ESP_OK) { strcpy(msg, "ota_begin failed"); return false; }
|
||||
|
||||
SHA256 sha;
|
||||
DetoolsCtx ctx;
|
||||
ctx.base = base; ctx.from_pos = 0;
|
||||
ctx.patch = m.payload; ctx.patch_len = m.payload_size; ctx.patch_pos = 0;
|
||||
ctx.out = h; ctx.sha = &sha; ctx.out_pos = 0; ctx.io_ok = true;
|
||||
|
||||
int r = detools_apply_patch_callbacks(dt_from_read, dt_from_seek, dt_patch_read,
|
||||
(size_t)m.payload_size, dt_to_write, &ctx);
|
||||
if (r < 0 || !ctx.io_ok) { esp_ota_abort(h); sprintf(msg, "detools err %d @%u/%u",
|
||||
ctx.io_ok ? r : -DETOOLS_IO_FAILED, (unsigned)ctx.out_pos, (unsigned)m.image_size); return false; }
|
||||
if ((uint32_t)r != m.image_size || ctx.out_pos != m.image_size) {
|
||||
esp_ota_abort(h); sprintf(msg, "size mismatch %u!=%u", (unsigned)ctx.out_pos, (unsigned)m.image_size); return false; }
|
||||
|
||||
uint8_t hh[32]; sha.finalize(hh, 32);
|
||||
st.slot_ok = (memcmp(hh, m.image_hash, 32) == 0);
|
||||
if (!st.slot_ok) { esp_ota_abort(h); strcpy(msg, "image_hash MISMATCH after decode"); return false; }
|
||||
if (esp_ota_end(h) != ESP_OK) { strcpy(msg, "ota_end failed"); return false; }
|
||||
if (esp_ota_set_boot_partition(out) != ESP_OK) { strcpy(msg, "set_boot failed"); return false; }
|
||||
sprintf(msg, "verified%s; decoded %u B, image hash OK — armed, rebooting to apply",
|
||||
m.is_signed() ? " (signer trusted)" : " (unsigned)", (unsigned)m.image_size);
|
||||
return true;
|
||||
}
|
||||
|
||||
bool ota_apply_mota_nrf52(const uint8_t*, uint32_t, const SignerAllowlist&, ApplyState& st, char* msg) {
|
||||
st = ApplyState(); strcpy(msg, "nRF52-only (ESP32 uses ota_apply_detools_mota)"); return false;
|
||||
}
|
||||
|
||||
void ota_reboot_to_apply() { esp_restart(); } // boots the slot armed by ota_apply_detools_mota; no return
|
||||
|
||||
#elif defined(NRF52_PLATFORM) // single-slot: verify + mark APPROVED + hand off to the bootloader
|
||||
|
||||
// ESP32 A/B-only entry points are unsupported on nRF52.
|
||||
bool ota_apply_slot_info(uint32_t*, uint32_t*) { return false; }
|
||||
bool ota_apply_set_manifest(const uint8_t*, uint32_t, const SignerAllowlist&, ApplyState& st) { st = ApplyState(); return false; }
|
||||
bool ota_apply_verify_slot(ApplyState&) { return false; }
|
||||
bool ota_apply_commit() { return false; }
|
||||
bool ota_apply_detools_mota(const uint8_t*, uint32_t, const SignerAllowlist&, ApplyState& st, char* msg) { st = ApplyState(); strcpy(msg, "use ota_apply_mota_nrf52"); return false; }
|
||||
|
||||
void ota_reboot_to_apply() { // public: set the apply magic + reset (does not return)
|
||||
uint8_t sd_en = 0;
|
||||
sd_softdevice_is_enabled(&sd_en);
|
||||
if (sd_en) { // POWER is SD-restricted while the SoftDevice runs
|
||||
sd_power_gpregret_clr(0, 0xFFFFFFFF);
|
||||
sd_power_gpregret_set(0, GPREGRET_OTA_APPLY);
|
||||
} else {
|
||||
NRF_POWER->GPREGRET = GPREGRET_OTA_APPLY;
|
||||
}
|
||||
NVIC_SystemReset(); // does not return
|
||||
}
|
||||
|
||||
bool ota_apply_mota_nrf52(const uint8_t* buf, uint32_t len, const SignerAllowlist& allow,
|
||||
ApplyState& st, char* msg) {
|
||||
st = ApplyState();
|
||||
MotaManifest m;
|
||||
if (!mota_parse(buf, len, m)) { strcpy(msg, "parse failed"); 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);
|
||||
st.manifest_ok = true;
|
||||
|
||||
// Gated verification, in order, returning the FIRST failing reason (the bootloader re-checks integrity
|
||||
// again before booting, so authenticity is gated here and re-validated there):
|
||||
VerifyResult vr = ota_verify(buf, len, allow);
|
||||
st.sig_ok = vr.sig_ok; st.trusted = vr.trusted;
|
||||
|
||||
// 1) downloaded payload: the fetched blocks must match the manifest's merkle root (intact + complete)
|
||||
if (!vr.root_ok || !vr.image_ok) { strcpy(msg, "payload hash mismatch (incomplete or corrupt .mota)"); return false; }
|
||||
|
||||
// 2) target firmware: the delta must be built against THIS running image (base_hash == our EndF body
|
||||
// hash). The resulting image_hash is re-checked by the bootloader after the in-place decode -- a
|
||||
// single-slot device cannot produce the target image to hash it before applying.
|
||||
SelfFwInfo fi;
|
||||
if (!ota_self_firmware(fi) || !fi.valid) { strcpy(msg, "cannot read running firmware (no EndF)"); return false; }
|
||||
if (!m.base_hash || memcmp(m.base_hash, fi.body_hash, 8) != 0) { strcpy(msg, "not built for the running firmware (base mismatch)"); return false; }
|
||||
st.slot_ok = true;
|
||||
|
||||
// 3) signature (only if the .mota is signed): valid Ed25519 AND signer in this device's allowlist
|
||||
if (vr.is_signed) {
|
||||
if (!vr.sig_ok) { strcpy(msg, "bad signature"); return false; }
|
||||
if (!vr.trusted) { strcpy(msg, "untrusted signer (pubkey not in allowlist)"); return false; }
|
||||
}
|
||||
|
||||
// mark the staged manifest APPROVED in flash (buf is the memory-mapped staging region, so
|
||||
// m.approval is a real flash address). NOR-clear over the erased 0xFFFFFFFF -> "APRV".
|
||||
uint32_t approval_addr = (uint32_t)(uintptr_t)m.approval;
|
||||
if (flash_nrf5x_write(approval_addr, APPROVAL_YES, 4) < 0) { strcpy(msg, "approval write failed"); return false; }
|
||||
flash_nrf5x_flush();
|
||||
if (memcmp((const void*)(uintptr_t)approval_addr, APPROVAL_YES, 4) != 0) { strcpy(msg, "approval not set"); return false; }
|
||||
|
||||
// Approved. Do NOT reset here — return so the caller can deliver `msg` to the operator first; the
|
||||
// deferred ota_reboot_to_apply() (after the reply is sent) does the actual handoff to the bootloader.
|
||||
sprintf(msg, "verified%s; applying — rebooting into bootloader once this reply is sent",
|
||||
vr.is_signed ? " (signer trusted)" : " (unsigned)");
|
||||
return true;
|
||||
}
|
||||
|
||||
#else // native / other platforms
|
||||
|
||||
bool ota_apply_slot_info(uint32_t*, uint32_t*) { return false; }
|
||||
bool ota_apply_set_manifest(const uint8_t*, uint32_t, const SignerAllowlist&, ApplyState& st) { st = ApplyState(); return false; }
|
||||
bool ota_apply_verify_slot(ApplyState&) { return false; }
|
||||
bool ota_apply_commit() { return false; }
|
||||
bool ota_apply_detools_mota(const uint8_t*, uint32_t, const SignerAllowlist&, ApplyState& st, char* msg) { st = ApplyState(); strcpy(msg, "unsupported"); return false; }
|
||||
bool ota_apply_mota_nrf52(const uint8_t*, uint32_t, const SignerAllowlist&, ApplyState& st, char* msg) { st = ApplyState(); strcpy(msg, "unsupported"); return false; }
|
||||
void ota_reboot_to_apply() {}
|
||||
|
||||
#endif
|
||||
|
||||
} // namespace ota
|
||||
} // namespace mesh
|
||||
@@ -0,0 +1,57 @@
|
||||
#pragma once
|
||||
|
||||
#include <stdint.h>
|
||||
#include <stddef.h>
|
||||
#include "SignerAllowlist.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
|
||||
// by setting it as the boot partition and rebooting. Safe + rollback-capable (the bootloader validates
|
||||
// the image; a bad image rolls back). nRF52 apply is the bootloader-handoff path (separate). Functions
|
||||
// return false on platforms without an A/B OTA layout.
|
||||
|
||||
namespace mesh {
|
||||
namespace ota {
|
||||
|
||||
struct ApplyState {
|
||||
bool manifest_ok = false;
|
||||
bool sig_ok = false;
|
||||
bool trusted = false; // signer in allowlist
|
||||
bool slot_ok = false; // inactive slot image hashes to manifest.image_hash
|
||||
uint32_t slot_addr = 0, slot_size = 0;
|
||||
uint32_t image_size = 0;
|
||||
uint8_t image_hash[32] = {0};
|
||||
};
|
||||
|
||||
bool ota_apply_slot_info(uint32_t* addr, uint32_t* size); // the inactive A/B slot
|
||||
bool ota_apply_set_manifest(const uint8_t* mf, uint32_t len,
|
||||
const SignerAllowlist& allow, ApplyState& st); // parse + verify signature
|
||||
bool ota_apply_verify_slot(ApplyState& st); // hash the slot vs image_hash
|
||||
bool ota_apply_commit(); // set-boot + reboot (no return)
|
||||
|
||||
// Apply a detools-sequential delta `.mota` (whole container in `buf`) using detools' own embeddable
|
||||
// C decoder (CODEC_DETOOLS_SEQUENTIAL, --compression crle). The running slot is the delta base; the
|
||||
// decoder streams the patch (held in RAM) and writes the reconstructed image into the inactive slot,
|
||||
// while we hash the output and check it against the signed manifest image_hash. On success the
|
||||
// inactive slot is set as boot partition; the caller then reboots. `msg` (>=80 bytes) receives a
|
||||
// human-readable result. Returns true if the slot is verified + armed.
|
||||
bool ota_apply_detools_mota(const uint8_t* buf, uint32_t len,
|
||||
const SignerAllowlist& allow, ApplyState& st, char* msg);
|
||||
|
||||
// nRF52 (RAK4631) single-slot apply. The running app can't rewrite itself, so it does NOT decode: it
|
||||
// runs the gated verification chain (payload hash -> built-for-this-firmware -> signature/trust) and,
|
||||
// only if all pass, marks the staged manifest APPROVED in flash. It does NOT reboot — so the caller can
|
||||
// first send the result back to the operator — the actual handoff is ota_reboot_to_apply() below.
|
||||
// Returns true (msg = "verified...") when approved, false (msg = the first failing gate) otherwise.
|
||||
bool ota_apply_mota_nrf52(const uint8_t* buf, uint32_t len,
|
||||
const SignerAllowlist& allow, ApplyState& st, char* msg);
|
||||
|
||||
// Commit the (already approved/armed) update and reboot into it — does NOT return. Call this only after
|
||||
// a successful ota_apply_* AND after the confirmation reply has been delivered, so the operator knows
|
||||
// the apply started (over LoRa the device then goes silent while the bootloader applies). nRF52: set
|
||||
// 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();
|
||||
|
||||
} // namespace ota
|
||||
} // namespace mesh
|
||||
@@ -0,0 +1,178 @@
|
||||
#include "OtaCli.h"
|
||||
#include "OtaContext.h"
|
||||
#include "OtaVerify.h"
|
||||
#include "OtaSelf.h"
|
||||
#include "Utils.h"
|
||||
#include <stdio.h>
|
||||
#include <string.h>
|
||||
|
||||
namespace mesh {
|
||||
namespace ota {
|
||||
|
||||
static uint32_t parse_u32(const char* s) {
|
||||
uint32_t n = 0;
|
||||
while (*s == ' ') s++;
|
||||
while (*s >= '0' && *s <= '9') n = n * 10 + (uint32_t)(*s++ - '0');
|
||||
return n;
|
||||
}
|
||||
|
||||
static char fstate_char(OtaManager::FetchState s) {
|
||||
switch (s) {
|
||||
case OtaManager::IDLE: return 'I';
|
||||
case OtaManager::WANT_MANIFEST: return 'W';
|
||||
case OtaManager::FETCHING: return 'F';
|
||||
case OtaManager::COMPLETE: return 'C';
|
||||
default: return 'X';
|
||||
}
|
||||
}
|
||||
|
||||
bool handle_ota_command(const char* command, char* reply, mesh::MainBoard& board) {
|
||||
const char* a = command + 3;
|
||||
if (*a != 0 && *a != ' ') return false;
|
||||
while (*a == ' ') a++;
|
||||
OtaContext& c = ota_ctx();
|
||||
|
||||
if (*a == 0 || strncmp(a, "status", 6) == 0) {
|
||||
SelfFwInfo fi; bool s = ota_self_firmware(fi);
|
||||
sprintf(reply, "OTA tid=%08X self=%u serve=%u%s fetch=%c %u/%u keys=%u",
|
||||
(unsigned)board.getOtaTargetId(), (unsigned)(s ? fi.body_len : 0),
|
||||
(unsigned)c.serve_expected, c.serving ? "(on)" : "",
|
||||
fstate_char(c.manager.fetchState()),
|
||||
(unsigned)c.manager.blocksHave(), (unsigned)c.manager.blocksTotal(),
|
||||
(unsigned)c.allow.count());
|
||||
|
||||
} else if (strncmp(a, "key add ", 8) == 0) {
|
||||
uint8_t pub[32];
|
||||
strcpy(reply, (mesh::Utils::fromHex(pub, 32, a + 8) && c.allow.add(pub)) ? "OK key added" : "ERR key");
|
||||
|
||||
} else if (strncmp(a, "key list", 8) == 0) {
|
||||
int n = sprintf(reply, "keys=%u:", (unsigned)c.allow.count());
|
||||
for (uint8_t i = 0; i < c.allow.count() && n < 140; i++) {
|
||||
char hx[17]; mesh::Utils::toHex(hx, c.allow.get(i), 8);
|
||||
n += sprintf(reply + n, " %s", hx);
|
||||
}
|
||||
|
||||
} else if (strncmp(a, "key rm ", 7) == 0) {
|
||||
uint8_t pub[32];
|
||||
strcpy(reply, (mesh::Utils::fromHex(pub, 32, a + 7) && c.allow.remove(pub)) ? "OK removed" : "ERR");
|
||||
|
||||
} else if (strncmp(a, "stage ", 6) == 0) {
|
||||
uint32_t sz = parse_u32(a + 6);
|
||||
if (sz == 0 || sz > OTA_SERVE_BUF_SIZE) { sprintf(reply, "ERR size 1..%u", OTA_SERVE_BUF_SIZE); }
|
||||
else { memset(c.serve_buf, 0xFF, sz); c.serve_expected = sz; c.serving = false;
|
||||
sprintf(reply, "OK stage %u bytes", (unsigned)sz); }
|
||||
|
||||
} else if (strncmp(a, "recv ", 5) == 0) {
|
||||
const char* p = a + 5; uint32_t off = parse_u32(p);
|
||||
const char* hex = strchr(p, ' ');
|
||||
if (!hex) { strcpy(reply, "ERR usage: ota recv <off> <hex>"); return true; }
|
||||
hex++;
|
||||
int blen = (int)strlen(hex) / 2;
|
||||
uint8_t tmp[80];
|
||||
if (blen <= 0 || blen > (int)sizeof(tmp) || !mesh::Utils::fromHex(tmp, blen, hex)) strcpy(reply, "ERR hex");
|
||||
else if (off + blen > c.serve_expected) strcpy(reply, "ERR off>size (stage first)");
|
||||
else { memcpy(c.serve_buf + off, tmp, blen); sprintf(reply, "OK %d@%u", blen, (unsigned)off); }
|
||||
|
||||
} else if (strncmp(a, "serve", 5) == 0) {
|
||||
c.serving = c.manager.serve(c.serve_buf, c.serve_expected);
|
||||
if (!c.serving) { strcpy(reply, "ERR serve (bad .mota)"); return true; }
|
||||
VerifyResult r = ota_verify(c.serve_buf, c.serve_expected, c.allow);
|
||||
sprintf(reply, "OK serving | root=%d img=%d sig=%d trust=%d", r.root_ok, r.image_ok, r.sig_ok, r.trusted);
|
||||
|
||||
} else if (strncmp(a, "announce", 8) == 0) {
|
||||
if (!c.serving) { strcpy(reply, "ERR not serving (ota serve first)"); return true; }
|
||||
c.manager.announce();
|
||||
strcpy(reply, "OK announced");
|
||||
|
||||
} else if (strncmp(a, "verify", 6) == 0) {
|
||||
// verify whatever is staged-to-serve, OR the fetched container if a fetch is complete
|
||||
const uint8_t* buf; uint32_t len;
|
||||
if (c.manager.fetchState() == OtaManager::COMPLETE) { buf = c.fetch_store.data(); len = c.fetch_store.staged_size(); }
|
||||
else { buf = c.serve_buf; len = c.serve_expected; }
|
||||
if (len == 0) { strcpy(reply, "ERR nothing to verify"); return true; }
|
||||
VerifyResult r = ota_verify(buf, len, c.allow);
|
||||
sprintf(reply, "verify parsed=%d root=%d img=%d signed=%d sig=%d trust=%d | ok=%d auto=%d",
|
||||
r.parsed, r.root_ok, r.image_ok, r.is_signed, r.sig_ok, r.trusted,
|
||||
r.integrity_ok(), r.auto_appliable());
|
||||
|
||||
} else if (strncmp(a, "applydelta", 10) == 0) {
|
||||
// Apply the fetched delta. ESP32: detools-sequential decode into the inactive A/B slot + verify +
|
||||
// arm (reboot after). nRF52: verify + mark APPROVED in flash + reboot into the bootloader, which
|
||||
// does the in-place decode + verify before booting it (this call does not return on success).
|
||||
//
|
||||
// This is destructive (it reboots and reflashes). It is GATED, not interactive — no "type yes"
|
||||
// round-trip (unreliable over LoRa). First, refuse unless a full update is present: the fetch must
|
||||
// be COMPLETE (every block received AND the merkle root re-verified). Then the apply path validates
|
||||
// in order and returns the FIRST failing gate, so the operator knows exactly why it refused
|
||||
// (payload hash -> built-for-this-firmware -> signature/trust); it proceeds only if all pass.
|
||||
if (c.manager.fetchState() != OtaManager::COMPLETE || c.fetch_store.staged_size() == 0) {
|
||||
sprintf(reply, "ERR no complete update fetched (fetch=%c %u/%u)",
|
||||
fstate_char(c.manager.fetchState()), (unsigned)c.manager.blocksHave(),
|
||||
(unsigned)c.manager.blocksTotal());
|
||||
return true;
|
||||
}
|
||||
char m2[100];
|
||||
#if defined(NRF52_PLATFORM)
|
||||
bool ok = ota_apply_mota_nrf52(c.fetch_store.data(), c.fetch_store.staged_size(), c.allow, c.apply_st, m2);
|
||||
#else
|
||||
bool ok = ota_apply_detools_mota(c.fetch_store.data(), c.fetch_store.staged_size(), c.allow, c.apply_st, m2);
|
||||
#endif
|
||||
// On success the update is approved/armed but NOT yet rebooted — arm the deferred handoff so this
|
||||
// reply reaches the operator first; the mesh loop reboots once it has been transmitted.
|
||||
if (ok) c.apply_pending = true;
|
||||
sprintf(reply, "%s | %s", ok ? "OK" : "ERR", m2);
|
||||
|
||||
} else if (strncmp(a, "self", 4) == 0) {
|
||||
// running firmware identity (EndF): body_len + body_hash:8 — compare against a delta's base_hash
|
||||
SelfFwInfo fi;
|
||||
if (!ota_self_firmware(fi) || !fi.valid) { strcpy(reply, "ERR no EndF (firmware lacks the trailer?)"); return true; }
|
||||
char hx[17]; mesh::Utils::toHex(hx, fi.body_hash, 8);
|
||||
sprintf(reply, "self body=%u image=%u base_hash=%s", (unsigned)fi.body_len, (unsigned)fi.image_len, hx);
|
||||
|
||||
} else if (strncmp(a, "apply", 5) == 0) {
|
||||
const char* sub = a + 5;
|
||||
while (*sub == ' ') sub++;
|
||||
if (strncmp(sub, "slot", 4) == 0) {
|
||||
uint32_t addr = 0, size = 0;
|
||||
if (ota_apply_slot_info(&addr, &size)) sprintf(reply, "inactive slot addr=0x%X size=%u", (unsigned)addr, (unsigned)size);
|
||||
else strcpy(reply, "ERR no A/B slot (apply unsupported on this build)");
|
||||
} else if (strncmp(sub, "manifest", 8) == 0) {
|
||||
// the manifest-fixed bytes were loaded into serve_buf via `ota stage`/`ota recv`
|
||||
if (ota_apply_set_manifest(c.serve_buf, c.serve_expected, c.allow, c.apply_st))
|
||||
sprintf(reply, "manifest ok img=%u sig=%d trust=%d", (unsigned)c.apply_st.image_size,
|
||||
c.apply_st.sig_ok, c.apply_st.trusted);
|
||||
else strcpy(reply, "ERR manifest parse / not full-image / unsupported");
|
||||
} else if (strncmp(sub, "verify", 6) == 0) {
|
||||
bool ok = ota_apply_verify_slot(c.apply_st);
|
||||
sprintf(reply, "slot image_hash %s (size=%u)", ok ? "MATCH" : "MISMATCH", (unsigned)c.apply_st.image_size);
|
||||
} else if (strncmp(sub, "commit", 6) == 0) {
|
||||
if (!c.apply_st.slot_ok) { strcpy(reply, "ERR run 'ota apply verify' first (slot must match)"); return true; }
|
||||
// (D2: auto-apply would also require c.apply_st.trusted; a manual commit is allowed here.)
|
||||
ota_apply_commit(); // sets boot partition + reboots into the new image; no return
|
||||
strcpy(reply, "ERR commit failed (no A/B slot?)");
|
||||
} else {
|
||||
strcpy(reply, "ERR ota apply (slot|manifest|verify|commit)");
|
||||
}
|
||||
|
||||
} else if (strncmp(a, "want ", 5) == 0) {
|
||||
const char* p = a + 5;
|
||||
while (*p == ' ') p++;
|
||||
if (strncmp(p, "auto", 4) == 0) { c.manager.want(0); strcpy(reply, "OK auto (own target only)"); }
|
||||
else {
|
||||
uint32_t t = (uint32_t)strtoul(p, nullptr, 16); // hex target_id (e.g. from another env)
|
||||
c.manager.want(t);
|
||||
sprintf(reply, "OK cross-target: will fetch %08X (you ensure HW compatible)", (unsigned)t);
|
||||
}
|
||||
|
||||
} else if (strncmp(a, "clear", 5) == 0) {
|
||||
c.serve_expected = 0; c.serving = false; c.fetch_store.clear();
|
||||
strcpy(reply, "OK cleared");
|
||||
|
||||
} else {
|
||||
strcpy(reply, "ERR (status|self|key|stage|recv|serve|announce|verify|want|applydelta|clear)");
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
} // namespace ota
|
||||
} // namespace mesh
|
||||
@@ -0,0 +1,16 @@
|
||||
#pragma once
|
||||
|
||||
#include <MeshCore.h>
|
||||
|
||||
// Text-CLI surface for OTA (P3/P5). Wired from CommonCLI (and reachable over LoRa remote-admin).
|
||||
// Kept out of CommonCLI.cpp itself so the OTA state (allowlist, staging store) lives in the OTA module.
|
||||
|
||||
namespace mesh {
|
||||
namespace ota {
|
||||
|
||||
// Handle an "ota ..." command. `command` is the full line (starts with "ota"). Fills `reply`
|
||||
// (<= ~160 bytes, as per the CLI buffer). Returns true if it was an OTA command.
|
||||
bool handle_ota_command(const char* command, char* reply, mesh::MainBoard& board);
|
||||
|
||||
} // namespace ota
|
||||
} // namespace mesh
|
||||
@@ -0,0 +1,12 @@
|
||||
#include "OtaContext.h"
|
||||
|
||||
namespace mesh {
|
||||
namespace ota {
|
||||
|
||||
OtaContext& ota_ctx() {
|
||||
static OtaContext ctx;
|
||||
return ctx;
|
||||
}
|
||||
|
||||
} // namespace ota
|
||||
} // namespace mesh
|
||||
@@ -0,0 +1,66 @@
|
||||
#pragma once
|
||||
|
||||
#include "OtaManager.h"
|
||||
#include "OtaStore.h"
|
||||
#include "SignerAllowlist.h"
|
||||
#include "OtaApply.h"
|
||||
#include "OtaFormat.h"
|
||||
#if defined(NRF52_PLATFORM) && defined(OTA_FLASH_STORE)
|
||||
#include "OtaStoreFlashNrf52.h"
|
||||
#endif
|
||||
|
||||
// Per-device OTA singleton shared by the CLI (OtaCli) and the mesh adapter (the example's MyMesh).
|
||||
// Holds the session engine, a staging store (fetch), a RAM serve buffer, and the signer allowlist.
|
||||
// nRF52 stages into FLASH (OtaStoreFlashNrf52): a delta can be 100 KB+, too big to hold in RAM, and the
|
||||
// COMPLETE container must persist so the bootloader can apply it after reboot. A flash page-erase halts
|
||||
// the CPU (~85 ms) and starves the LoRa RX, so the store COALESCES writes to the 4 KB page (the erase
|
||||
// unit) and commits each page once, off the per-packet path (see OtaManager.h) — RAM stays O(one page).
|
||||
// (v1 has no mid-transfer resume; an interrupted fetch simply restarts.) ESP32/native use the RAM store.
|
||||
|
||||
namespace mesh {
|
||||
namespace ota {
|
||||
|
||||
#ifndef OTA_SERVE_BUF_SIZE
|
||||
#define OTA_SERVE_BUF_SIZE 16384
|
||||
#endif
|
||||
#ifndef OTA_FETCH_BUF_SIZE
|
||||
#define OTA_FETCH_BUF_SIZE 16384
|
||||
#endif
|
||||
|
||||
struct OtaContext {
|
||||
OtaManager manager;
|
||||
#if defined(NRF52_PLATFORM) && defined(OTA_FLASH_STORE)
|
||||
OtaStoreFlashNrf52 fetch_store; // persistent flash staging (survives reboot; large deltas)
|
||||
#else
|
||||
OtaStoreRam<OTA_FETCH_BUF_SIZE> fetch_store;
|
||||
#endif
|
||||
SignerAllowlist allow;
|
||||
uint8_t serve_buf[OTA_SERVE_BUF_SIZE];
|
||||
uint32_t serve_expected = 0; // size declared by `ota stage`
|
||||
bool serving = false; // manager.serve() succeeded
|
||||
ApplyState apply_st; // pending apply (P6)
|
||||
|
||||
// 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;
|
||||
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
|
||||
|
||||
void begin(uint32_t target_id, OtaSend send, void* ctx) {
|
||||
manager.begin(target_id, send, ctx);
|
||||
// a node only fetches firmware it can apply: ESP32 A/B -> sequential, nRF52 single-slot -> in-place
|
||||
#if defined(NRF52_PLATFORM)
|
||||
manager.set_apply_codec(CODEC_DETOOLS_INPLACE);
|
||||
#elif defined(ESP32_PLATFORM)
|
||||
manager.set_apply_codec(CODEC_DETOOLS_SEQUENTIAL);
|
||||
#endif
|
||||
manager.set_fetch_store(&fetch_store);
|
||||
}
|
||||
};
|
||||
|
||||
OtaContext& ota_ctx(); // process-wide singleton
|
||||
|
||||
} // namespace ota
|
||||
} // namespace mesh
|
||||
@@ -0,0 +1,11 @@
|
||||
#pragma once
|
||||
|
||||
// Opt-in OTA tracing over Serial: build with -D OTA_DEBUG to watch the fetch (ADV/REQ/block/page-flush)
|
||||
// during bring-up. Compiles to nothing otherwise, and on the native host (no Arduino), so it never
|
||||
// touches a non-debug or test build.
|
||||
#if defined(OTA_DEBUG) && defined(ARDUINO)
|
||||
#include <Arduino.h>
|
||||
#define OTA_DBG(...) do { Serial.printf(__VA_ARGS__); } while (0)
|
||||
#else
|
||||
#define OTA_DBG(...) do {} while (0)
|
||||
#endif
|
||||
@@ -0,0 +1,33 @@
|
||||
#pragma once
|
||||
|
||||
// Shared OTA flash-layout constants for the nRF52840 (RAK4631) single-slot delta-apply path.
|
||||
// SINGLE SOURCE OF TRUTH — keep byte-identical with the bootloader's src/ota_layout.h.
|
||||
//
|
||||
// The running app occupies [APP_BASE, app_end]; the primary LittleFS (InternalFS) starts at FS_START.
|
||||
// MeshCore stages a verified+approved `.mota` in the free flash below FS_START (bottom-aligned), then
|
||||
// sets GPREGRET_OTA_APPLY and resets; the bootloader scans [APP_BASE, FS_START) for it and applies it
|
||||
// in place. These must match the bootloader and the running SoftDevice's app base.
|
||||
|
||||
#include <stdint.h>
|
||||
|
||||
namespace mesh {
|
||||
namespace ota {
|
||||
|
||||
static const uint32_t MOTA_NRF52_APP_BASE = 0x00026000u; // S140 end (== CODE_REGION_1_START)
|
||||
// Staging ceiling: the lowest filesystem region above the app. RAK4631 companion builds use the
|
||||
// extrafs ldscript with ExtraFS at 0xD4000..0xED000 (and InternalFS at 0xED000), while the repeater
|
||||
// uses the default ldscript (InternalFS at 0xED000, 0xD4000..0xED000 free). 0xD4000 is the safe
|
||||
// universal ceiling for ALL RAK4631 roles: staging below it never touches ExtraFS or InternalFS, and
|
||||
// the app (~520 KB) sits well below 0xD4000 either way.
|
||||
static const uint32_t MOTA_NRF52_FS_START = 0x000D4000u; // ExtraFS start (universal staging ceiling)
|
||||
static const uint32_t MOTA_NRF52_FLASH_PAGE = 4096u;
|
||||
static const uint8_t GPREGRET_OTA_APPLY = 0x6Au; // distinct from DFU magics 0x57/0x4E/0xA8
|
||||
|
||||
// In-place patches are built with --inplace-memory = this (the apply workspace, from APP_BASE up).
|
||||
// It must hold the new image (~520 KB) yet leave the staged mota room below FS_START: workspace ends
|
||||
// at APP_BASE+this = 0xBE000, leaving 0xBE000..0xD4000 (~88 KB) for the staged delta. The bootloader
|
||||
// also bounds writes to < the (scanned) mota start, so a mis-sized memory still fails safe.
|
||||
static const uint32_t MOTA_NRF52_INPLACE_MEMORY = 0x00098000u; // 608 KB (APP_BASE .. 0xBE000)
|
||||
|
||||
} // namespace ota
|
||||
} // namespace mesh
|
||||
@@ -0,0 +1,57 @@
|
||||
#pragma once
|
||||
|
||||
#include <stdint.h>
|
||||
#include <stddef.h>
|
||||
|
||||
// On-the-wire constants for the MeshCore OTA `.mota` container and protocol.
|
||||
// Normative definition: docs/ota_protocol.md (v1). Mirrors tools/mota/motalib.py.
|
||||
//
|
||||
// Portable: no Arduino / RadioLib includes. Compiles on the native host (unit tests) and on device.
|
||||
|
||||
namespace mesh {
|
||||
namespace ota {
|
||||
|
||||
// ---- container framing ----------------------------------------------------
|
||||
static const uint8_t MOTA_MAGIC[4] = { 'm', 'O', 'T', 'A' }; // 6D 4F 54 41
|
||||
static const uint8_t MOTA_TRAILER[5] = { 'v', 'k', '4', '9', '6' }; // 76 6B 34 39 36
|
||||
static const uint8_t ENDF_MAGIC[4] = { 'E', 'n', 'd', 'F' }; // 45 6E 64 46
|
||||
static const uint32_t ENDF_LEN = 16; // marker(4)+body_len(4)+body_hash8(8)
|
||||
|
||||
// ---- manifest -------------------------------------------------------------
|
||||
static const uint8_t MOTA_FORMAT_VER = 1;
|
||||
static const uint8_t HASH_ALGO_SHA256 = 0x12; // multihash code
|
||||
|
||||
static const uint8_t MFLAG_FULL = 0x01; // 0 = delta/partial, 1 = full image
|
||||
static const uint8_t MFLAG_SIGNED = 0x02;
|
||||
|
||||
static const uint8_t CODEC_FULL = 0;
|
||||
static const uint8_t CODEC_DETOOLS_SEQUENTIAL = 1;
|
||||
static const uint8_t CODEC_DETOOLS_INPLACE = 2;
|
||||
|
||||
// ---- hash truncations -----------------------------------------------------
|
||||
static const uint8_t MH4 = 4; // sha2-256:4 (merkle leaves/nodes/root/proofs)
|
||||
static const uint8_t MH8 = 8; // sha2-256:8 (base/EndF body hash)
|
||||
static const uint8_t MH32 = 32; // sha2-256:32 (image security anchor)
|
||||
|
||||
// ---- approval marker (manifest field, after the signature) ----------------
|
||||
static const uint8_t APPROVAL_NOT[4] = { 0xFF, 0xFF, 0xFF, 0xFF };
|
||||
static const uint8_t APPROVAL_YES[4] = { 'A', 'P', 'R', 'V' }; // 41 50 52 56
|
||||
|
||||
// ---- LoRa protocol --------------------------------------------------------
|
||||
// The packet payload type is PAYLOAD_TYPE_OTA (0x0C), defined in src/Packet.h for the core dispatch.
|
||||
|
||||
enum OtaMsgType : uint8_t {
|
||||
OTA_ADV = 0x01,
|
||||
OTA_QUERY = 0x02,
|
||||
OTA_HAVE = 0x03,
|
||||
OTA_GET_MANIFEST = 0x04,
|
||||
OTA_MANIFEST = 0x05,
|
||||
OTA_REQ = 0x06,
|
||||
OTA_DATA = 0x07,
|
||||
};
|
||||
|
||||
static const uint16_t OTA_DEFAULT_BLOCK_SIZE = 1024;
|
||||
static const uint8_t OTA_DEFAULT_HOP_LIMIT = 3;
|
||||
|
||||
} // namespace ota
|
||||
} // namespace mesh
|
||||
@@ -0,0 +1,239 @@
|
||||
#include "OtaManager.h"
|
||||
#include "OtaProtocol.h"
|
||||
#include "MerkleTree.h"
|
||||
#include "Multihash.h"
|
||||
#include "OtaDebug.h"
|
||||
#include <string.h>
|
||||
|
||||
namespace mesh {
|
||||
namespace ota {
|
||||
|
||||
static uint32_t rd_u32(const uint8_t* p) {
|
||||
return (uint32_t)p[0] | ((uint32_t)p[1] << 8) | ((uint32_t)p[2] << 16) | ((uint32_t)p[3] << 24);
|
||||
}
|
||||
static void wr_u32(uint8_t* p, uint32_t v) { p[0]=v; p[1]=v>>8; p[2]=v>>16; p[3]=v>>24; }
|
||||
|
||||
void OtaManager::begin(uint32_t my_target_id, OtaSend send, void* ctx) {
|
||||
_target = my_target_id; _send = send; _ctx = ctx;
|
||||
_fstate = IDLE; _has_serve = false; _have = 0; _fbc = 0;
|
||||
}
|
||||
|
||||
// ---------------- serve ----------------
|
||||
|
||||
bool OtaManager::serve(const uint8_t* mota, uint32_t len) {
|
||||
if (!mota_parse(mota, len, _sm)) return false;
|
||||
_serve_buf = mota; _serve_len = len; _has_serve = true;
|
||||
return true;
|
||||
}
|
||||
|
||||
void OtaManager::announce() {
|
||||
if (!_has_serve) return;
|
||||
AdvMsg a;
|
||||
a.target_id = _sm.target_id;
|
||||
a.fw_version = _sm.fw_version;
|
||||
memcpy(a.manifest_id, _sm.merkle_root, 4);
|
||||
a.flags = _sm.flags;
|
||||
a.have_all = 1;
|
||||
a.codec_id = _sm.codec_id;
|
||||
uint8_t b[32];
|
||||
emit(b, encode_adv(b, sizeof(b), a), true);
|
||||
}
|
||||
|
||||
void OtaManager::handleGetManifest(const uint8_t* m, uint16_t n) {
|
||||
GetManifestMsg gm;
|
||||
if (!decode_get_manifest(m, n, gm) || !_has_serve) return;
|
||||
if (memcmp(gm.manifest_id, _sm.merkle_root, 4) != 0) return;
|
||||
// manifest-minus-leaves == bytes [manifest_start, leaves)
|
||||
uint32_t mfl = (uint32_t)(_sm.leaves - _sm.manifest_start);
|
||||
uint8_t b[MAX_PACKET_PAYLOAD];
|
||||
ManifestMsg mm;
|
||||
memcpy(mm.manifest_id, _sm.merkle_root, 4);
|
||||
mm.frag_idx = 0; mm.frag_total = 1; // fits one fragment (signed manifest <= ~165 B)
|
||||
mm.bytes = _sm.manifest_start; mm.len = (uint16_t)mfl;
|
||||
emit(b, encode_manifest(b, sizeof(b), mm), false);
|
||||
}
|
||||
|
||||
void OtaManager::handleReq(const uint8_t* m, uint16_t n) {
|
||||
ReqMsg rq;
|
||||
if (!decode_req(m, n, rq) || !_has_serve) return;
|
||||
if (memcmp(rq.manifest_id, _sm.merkle_root, 4) != 0) return;
|
||||
uint32_t bs = _sm.block_size();
|
||||
for (uint32_t k = 0; k < rq.count; k++) {
|
||||
uint32_t idx = rq.start_block + k;
|
||||
if (idx >= _sm.block_count) break;
|
||||
uint32_t off = idx * bs;
|
||||
uint32_t blen = (off + bs <= _sm.payload_size) ? bs : (_sm.payload_size - off);
|
||||
uint8_t proof[32 * 4];
|
||||
uint8_t np = merkle_gen_proof(_sm.leaves, _sm.block_count, idx, _scratch, proof);
|
||||
DataMsg dm;
|
||||
memcpy(dm.manifest_id, _sm.merkle_root, 4);
|
||||
dm.block_idx = (uint16_t)idx; dm.frag_idx = 0; dm.frag_total = 1;
|
||||
dm.n_proof = np; dm.proof = proof;
|
||||
dm.data = _sm.payload + off; dm.data_len = (uint16_t)blen;
|
||||
uint8_t b[MAX_PACKET_PAYLOAD];
|
||||
emit(b, encode_data(b, sizeof(b), dm), false);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------- fetch ----------------
|
||||
|
||||
void OtaManager::handleAdv(const uint8_t* m, uint16_t n) {
|
||||
AdvMsg a;
|
||||
if (!decode_adv(m, n, a)) return;
|
||||
// auto-fetch matches our own target; a manual `want(T)` override accepts target T instead.
|
||||
uint32_t accept = _desired_target ? _desired_target : _target;
|
||||
if (a.target_id != accept) return; // not the firmware we're (auto/manually) after
|
||||
if (!codecOk(a.codec_id)) return; // fw we can't apply on this platform — don't fetch
|
||||
if (!_fetch || _fstate == FETCHING || _fstate == WANT_MANIFEST) return;
|
||||
if (_fstate == COMPLETE && memcmp(a.manifest_id, _fid, 4) == 0) return; // already have it
|
||||
// interested: ask for the manifest
|
||||
memcpy(_fid, a.manifest_id, 4);
|
||||
_fstate = WANT_MANIFEST;
|
||||
GetManifestMsg gm; memcpy(gm.manifest_id, _fid, 4);
|
||||
uint8_t b[16];
|
||||
emit(b, encode_get_manifest(b, sizeof(b), gm), false);
|
||||
}
|
||||
|
||||
void OtaManager::handleManifest(const uint8_t* m, uint16_t n) {
|
||||
ManifestMsg mm;
|
||||
if (!decode_manifest(m, n, mm) || !_fetch) return;
|
||||
if (_fstate != WANT_MANIFEST || memcmp(mm.manifest_id, _fid, 4) != 0) return;
|
||||
if (mm.frag_total != 1) return; // multi-fragment manifest not supported yet
|
||||
|
||||
const uint8_t* mf = mm.bytes; // manifest-minus-leaves
|
||||
uint32_t mfl = mm.len;
|
||||
if (mfl < 57) { _fstate = FAILED; return; }
|
||||
if (!codecOk(mf[56])) { _fstate = IDLE; return; } // codec we can't apply (lying/stale ADV) — abort
|
||||
uint32_t payload_size = rd_u32(mf + 15);
|
||||
uint8_t bsl = mf[19];
|
||||
uint32_t bs = 1u << bsl;
|
||||
if (bs == 0 || payload_size == 0) { _fstate = FAILED; return; }
|
||||
uint32_t bc = (payload_size + bs - 1) / bs;
|
||||
memcpy(_froot, mf + 20, 4);
|
||||
|
||||
uint32_t leaves_off = 8 + mfl;
|
||||
uint32_t payload_off = leaves_off + bc * 4;
|
||||
uint32_t total = payload_off + payload_size + 5;
|
||||
|
||||
if (!_fetch->begin(total)) { _fstate = FAILED; return; }
|
||||
// declare the metadata extent so a flash store can pin it (leaves are written all transfer long)
|
||||
if (!_fetch->set_meta_size(payload_off)) { _fstate = FAILED; return; }
|
||||
uint8_t hdr[8];
|
||||
memcpy(hdr, MOTA_MAGIC, 4);
|
||||
wr_u32(hdr + 4, total);
|
||||
if (!_fetch->write(0, hdr, 8) ||
|
||||
!_fetch->write(8, mf, mfl) ||
|
||||
!_fetch->write(total - 5, MOTA_TRAILER, 5)) { _fstate = FAILED; return; }
|
||||
|
||||
_fpoff = payload_off; _floff = leaves_off; _fpsize = payload_size; _fbc = bc; _fbs = bs;
|
||||
_ftotal = total; _have = 0; _fstate = FETCHING;
|
||||
OTA_DBG("OTA: FETCHING bc=%u bs=%u total=%u\n", (unsigned)bc, (unsigned)bs, (unsigned)total);
|
||||
requestMissing();
|
||||
}
|
||||
|
||||
uint32_t OtaManager::blockLen(uint32_t i) const {
|
||||
uint32_t off = i * _fbs;
|
||||
return (off + _fbs <= _fpsize) ? _fbs : (_fpsize - off);
|
||||
}
|
||||
|
||||
bool OtaManager::blockPresent(uint32_t i) const {
|
||||
uint8_t leaf[4];
|
||||
if (!_fetch->read(_floff + i * 4, leaf, 4)) return false;
|
||||
return !(leaf[0]==0xFF && leaf[1]==0xFF && leaf[2]==0xFF && leaf[3]==0xFF);
|
||||
}
|
||||
|
||||
void OtaManager::handleData(const uint8_t* m, uint16_t n) {
|
||||
DataMsg dm;
|
||||
if (!decode_data(m, n, dm) || !_fetch) return;
|
||||
if (_fstate != FETCHING || memcmp(dm.manifest_id, _fid, 4) != 0) return;
|
||||
if (dm.frag_total != 1) return; // single-fragment blocks only (v1)
|
||||
if (dm.block_idx >= _fbc) return;
|
||||
if (blockPresent(dm.block_idx)) return; // already have it
|
||||
|
||||
uint32_t want = blockLen(dm.block_idx);
|
||||
if (dm.data_len != want) return;
|
||||
|
||||
// verify the block against the (signed) root via its proof — reject forged/corrupt data
|
||||
if (!merkle_verify(dm.data, dm.data_len, dm.block_idx, dm.proof, dm.n_proof, _froot, _fbc)) return;
|
||||
|
||||
// commit: payload block first, then the leaf (the commit marker)
|
||||
if (!_fetch->write(_fpoff + dm.block_idx * _fbs, dm.data, dm.data_len)) return;
|
||||
uint8_t leaf[4];
|
||||
merkle_leaf(leaf, dm.data, dm.data_len);
|
||||
if (!_fetch->write(_floff + dm.block_idx * 4, leaf, 4)) return;
|
||||
|
||||
_have++;
|
||||
OTA_DBG("OTA: block %u OK have=%u/%u\n", (unsigned)dm.block_idx, (unsigned)_have, (unsigned)_fbc);
|
||||
|
||||
// if the current request window is fully received, immediately ask for the next one
|
||||
// (paces the transfer to the link rate instead of flooding the whole image at once)
|
||||
if (_have < _fbc) {
|
||||
bool window_done = true;
|
||||
for (uint32_t i = _req_start; i < _req_start + _req_count && i < _fbc; i++) {
|
||||
if (!blockPresent(i)) { window_done = false; break; }
|
||||
}
|
||||
if (window_done) requestMissing();
|
||||
}
|
||||
|
||||
if (_have >= _fbc) {
|
||||
// recompute the root over all stored leaves as a final cross-check
|
||||
// (read leaves into the scratch buffer; bounded by OTA_PROOFGEN_SCRATCH)
|
||||
if (_fbc * 4 <= sizeof(_scratch) && _fetch->read(_floff, _scratch, _fbc * 4)) {
|
||||
uint8_t root[4];
|
||||
merkle_root(root, _scratch, _fbc);
|
||||
_fstate = (memcmp(root, _froot, 4) == 0) ? COMPLETE : FAILED;
|
||||
} else {
|
||||
_fstate = COMPLETE; // per-block proofs already guaranteed integrity vs the root
|
||||
}
|
||||
if (_fstate == COMPLETE) _fetch->finalize(); // commit the staged container to persistent storage
|
||||
OTA_DBG("OTA: transfer %s\n", _fstate == COMPLETE ? "COMPLETE" : "FAILED(root)");
|
||||
}
|
||||
}
|
||||
|
||||
void OtaManager::requestMissing() {
|
||||
if (_fstate != FETCHING) return;
|
||||
// request a small WINDOW of the next missing blocks (keeps the server's TX queue small,
|
||||
// so OTA never floods/saturates the mesh — docs/ota_protocol.md §8)
|
||||
uint32_t start = 0;
|
||||
while (start < _fbc && blockPresent(start)) start++;
|
||||
if (start >= _fbc) return;
|
||||
uint32_t count = _fbc - start;
|
||||
if (count > OTA_REQ_WINDOW) count = OTA_REQ_WINDOW;
|
||||
_req_start = start; _req_count = count;
|
||||
ReqMsg rq; memcpy(rq.manifest_id, _fid, 4);
|
||||
rq.start_block = (uint16_t)start; rq.count = (uint8_t)count;
|
||||
uint8_t b[16];
|
||||
OTA_DBG("OTA: REQ start=%u count=%u (have=%u/%u)\n",
|
||||
(unsigned)start, (unsigned)count, (unsigned)_have, (unsigned)_fbc);
|
||||
emit(b, encode_req(b, sizeof(b), rq), false);
|
||||
}
|
||||
|
||||
void OtaManager::loop() {
|
||||
if (_fstate == WANT_MANIFEST) {
|
||||
// the MANIFEST reply may have been lost on a marginal link — retry GET_MANIFEST
|
||||
GetManifestMsg gm; memcpy(gm.manifest_id, _fid, 4);
|
||||
uint8_t b[16];
|
||||
emit(b, encode_get_manifest(b, sizeof(b), gm), false);
|
||||
return;
|
||||
}
|
||||
if (_fstate != FETCHING) return;
|
||||
// retry only when a tick passed with no progress (avoids re-request spam during active flow)
|
||||
if (_have == _loop_last_have) requestMissing();
|
||||
_loop_last_have = _have;
|
||||
}
|
||||
|
||||
// ---------------- dispatch ----------------
|
||||
|
||||
void OtaManager::on_message(const uint8_t* msg, uint16_t len) {
|
||||
switch (ota_msg_type(msg, len)) {
|
||||
case OTA_ADV: handleAdv(msg, len); break;
|
||||
case OTA_GET_MANIFEST: handleGetManifest(msg, len); break;
|
||||
case OTA_MANIFEST: handleManifest(msg, len); break;
|
||||
case OTA_REQ: handleReq(msg, len); break;
|
||||
case OTA_DATA: handleData(msg, len); break;
|
||||
default: break;
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace ota
|
||||
} // namespace mesh
|
||||
@@ -0,0 +1,109 @@
|
||||
#pragma once
|
||||
|
||||
#include <stdint.h>
|
||||
#include <stddef.h>
|
||||
#include "OtaFormat.h"
|
||||
#include "OtaStore.h"
|
||||
#include "MotaContainer.h"
|
||||
|
||||
// Transport-agnostic OTA session engine (docs/ota_protocol.md §5/§8). It SERVES a complete `.mota`
|
||||
// (answering GET_MANIFEST / REQ) and/or FETCHES one into an OtaStore (verifying every block against
|
||||
// the signed merkle root via proofs). It is portable (no Arduino / radio / Ed25519) so it can be
|
||||
// driven by a host simulation; a thin Mesh adapter wires it to PAYLOAD_TYPE_OTA on device.
|
||||
//
|
||||
// v1 assumes single-fragment blocks (block_size small enough to fit one packet). Multi-fragment
|
||||
// reassembly (for 1 KB blocks) is a later optimization; the wire format already carries frag fields.
|
||||
|
||||
namespace mesh {
|
||||
namespace ota {
|
||||
|
||||
// Emit an OTA message (one packet payload). `flood`=true for announce/query, false for direct replies.
|
||||
typedef void (*OtaSend)(void* ctx, const uint8_t* msg, uint16_t len, bool flood);
|
||||
|
||||
#ifndef OTA_PROOFGEN_SCRATCH
|
||||
#define OTA_PROOFGEN_SCRATCH 4096 // server proof-gen working buffer (supports up to 1024 blocks)
|
||||
#endif
|
||||
|
||||
#ifndef OTA_REQ_WINDOW
|
||||
#define OTA_REQ_WINDOW 6 // blocks requested per REQ (keeps the server's TX queue small)
|
||||
#endif
|
||||
// nRF52 note: a flash page-erase halts the CPU (~85 ms, code runs from flash) and starves the LoRa RX,
|
||||
// so writing to flash on every received packet drops in-flight DATA and the transfer stalls. The SD-safe
|
||||
// driver (Adafruit flash_nrf5x) always erases on flush, so there is no erase-free write; instead
|
||||
// OtaStoreFlashNrf52 COALESCES to the 4 KB page (the erase unit) and writes each page once — RAM stays
|
||||
// O(one page), never O(mota). It pins flash page 0 (header+manifest+merkle leaves, which update all
|
||||
// transfer long) in RAM and streams the payload through one sliding page buffer, flushing page 0 and the
|
||||
// last page at finalize() (radio idle). Flash is then touched ~once per 4 KB (≈1 per 4 blocks), not per
|
||||
// packet; a small delta that fits page 0 does ZERO flash I/O until COMPLETE. (Pacing alone is not enough.)
|
||||
|
||||
class OtaManager {
|
||||
public:
|
||||
enum FetchState : uint8_t { IDLE, WANT_MANIFEST, FETCHING, COMPLETE, FAILED };
|
||||
|
||||
void begin(uint32_t my_target_id, OtaSend send, void* ctx);
|
||||
|
||||
// --- serve --- Provide a complete, contiguous `.mota` to serve (caller keeps it alive).
|
||||
bool serve(const uint8_t* mota, uint32_t len);
|
||||
void announce(); // broadcast OTA_ADV for the served .mota
|
||||
|
||||
// --- fetch --- Provide the staging store; fetching starts on a matching OTA_ADV.
|
||||
void set_fetch_store(OtaStore* s) { _fetch = s; }
|
||||
|
||||
// Manual cross-target override (decision: deliberate role switch, e.g. companion -> repeater on the
|
||||
// same hardware). Normally a node only auto-fetches its OWN target_id; `want(T)` makes it accept an
|
||||
// ADV for target T instead (T=0 restores auto). The user takes responsibility for HW compatibility;
|
||||
// a hw_id brick-safety check is the planned safety layer (see docs/ota_protocol.md / plan).
|
||||
void want(uint32_t target_id) { _desired_target = target_id; }
|
||||
uint32_t wanted() const { return _desired_target; }
|
||||
|
||||
// Codec compatibility: a node only fetches/accepts fw it can actually apply. CODEC_FULL is always
|
||||
// acceptable; the platform's single delta codec is set here (ESP32 A/B -> sequential, nRF52 single-
|
||||
// slot -> in-place). A mismatching `.mota` is rejected at OTA_ADV time, before fetching anything.
|
||||
void set_apply_codec(uint8_t c) { _apply_codec = c; }
|
||||
bool codecOk(uint8_t c) const { return c == CODEC_FULL || c == _apply_codec; }
|
||||
|
||||
void on_message(const uint8_t* msg, uint16_t len); // feed one received OTA message
|
||||
void loop(); // drive fetch (re-request missing blocks)
|
||||
|
||||
FetchState fetchState() const { return _fstate; }
|
||||
uint32_t blocksHave() const { return _have; }
|
||||
uint32_t blocksTotal() const { return _fbc; }
|
||||
const uint8_t* fetchManifestId() const { return _fid; }
|
||||
|
||||
private:
|
||||
void emit(const uint8_t* b, uint16_t n, bool flood) { if (_send && n) _send(_ctx, b, n, flood); }
|
||||
void handleAdv(const uint8_t* m, uint16_t n);
|
||||
void handleGetManifest(const uint8_t* m, uint16_t n);
|
||||
void handleManifest(const uint8_t* m, uint16_t n);
|
||||
void handleReq(const uint8_t* m, uint16_t n);
|
||||
void handleData(const uint8_t* m, uint16_t n);
|
||||
bool blockPresent(uint32_t i) const;
|
||||
void requestMissing();
|
||||
uint32_t blockLen(uint32_t i) const;
|
||||
|
||||
uint32_t _target = 0;
|
||||
OtaSend _send = nullptr;
|
||||
void* _ctx = nullptr;
|
||||
|
||||
// serve
|
||||
bool _has_serve = false;
|
||||
const uint8_t* _serve_buf = nullptr;
|
||||
uint32_t _serve_len = 0;
|
||||
MotaManifest _sm;
|
||||
uint8_t _scratch[OTA_PROOFGEN_SCRATCH];
|
||||
|
||||
// fetch
|
||||
OtaStore* _fetch = nullptr;
|
||||
FetchState _fstate = IDLE;
|
||||
uint8_t _fid[4] = {0};
|
||||
uint8_t _froot[4] = {0};
|
||||
uint32_t _ftotal = 0, _fpoff = 0, _floff = 0, _fpsize = 0, _fbc = 0, _fbs = 0;
|
||||
uint32_t _have = 0;
|
||||
uint32_t _req_start = 0, _req_count = 0; // current outstanding request window
|
||||
uint32_t _loop_last_have = 0; // for stall detection in loop()
|
||||
uint32_t _desired_target = 0; // manual cross-target override (0 = auto / own target)
|
||||
uint8_t _apply_codec = CODEC_DETOOLS_SEQUENTIAL; // platform's delta codec (OtaContext sets it)
|
||||
};
|
||||
|
||||
} // namespace ota
|
||||
} // namespace mesh
|
||||
@@ -0,0 +1,93 @@
|
||||
#include "OtaProtocol.h"
|
||||
#include <string.h>
|
||||
|
||||
namespace mesh {
|
||||
namespace ota {
|
||||
|
||||
// Little-endian cursor helpers.
|
||||
namespace {
|
||||
struct W {
|
||||
uint8_t* p; uint16_t cap; uint16_t n; bool ok;
|
||||
W(uint8_t* b, uint16_t c) : p(b), cap(c), n(0), ok(true) {}
|
||||
void u8(uint8_t v) { if (n + 1 > cap) { ok = false; return; } p[n++] = v; }
|
||||
void u16(uint16_t v){ u8(v & 0xFF); u8(v >> 8); }
|
||||
void u32(uint32_t v){ u8(v); u8(v >> 8); u8(v >> 16); u8(v >> 24); }
|
||||
void raw(const uint8_t* d, uint16_t l) { if (n + l > cap) { ok = false; return; } memcpy(p + n, d, l); n += l; }
|
||||
};
|
||||
struct R {
|
||||
const uint8_t* p; uint16_t len; uint16_t n; bool ok;
|
||||
R(const uint8_t* b, uint16_t l) : p(b), len(l), n(0), ok(true) {}
|
||||
uint8_t u8() { if (n + 1 > len) { ok = false; return 0; } return p[n++]; }
|
||||
uint16_t u16() { uint16_t a = u8(); return a | ((uint16_t)u8() << 8); }
|
||||
uint32_t u32() { uint32_t a = u8(); a |= (uint32_t)u8() << 8; a |= (uint32_t)u8() << 16; a |= (uint32_t)u8() << 24; return a; }
|
||||
const uint8_t* raw(uint16_t l) { if (n + l > len) { ok = false; return nullptr; } const uint8_t* r = p + n; n += l; return r; }
|
||||
uint16_t remaining() const { return len - n; }
|
||||
};
|
||||
} // namespace
|
||||
|
||||
uint16_t encode_adv(uint8_t* buf, uint16_t cap, const AdvMsg& m) {
|
||||
W w(buf, cap); w.u8(OTA_ADV); w.u32(m.target_id); w.u32(m.fw_version);
|
||||
w.raw(m.manifest_id, 4); w.u8(m.flags); w.u8(m.have_all); w.u8(m.codec_id);
|
||||
return w.ok ? w.n : 0;
|
||||
}
|
||||
bool decode_adv(const uint8_t* buf, uint16_t len, AdvMsg& m) {
|
||||
R r(buf, len); if (r.u8() != OTA_ADV) return false;
|
||||
m.target_id = r.u32(); m.fw_version = r.u32();
|
||||
const uint8_t* id = r.raw(4); if (id) memcpy(m.manifest_id, id, 4);
|
||||
m.flags = r.u8(); m.have_all = r.u8(); m.codec_id = r.u8();
|
||||
return r.ok;
|
||||
}
|
||||
|
||||
uint16_t encode_get_manifest(uint8_t* buf, uint16_t cap, const GetManifestMsg& m) {
|
||||
W w(buf, cap); w.u8(OTA_GET_MANIFEST); w.raw(m.manifest_id, 4);
|
||||
return w.ok ? w.n : 0;
|
||||
}
|
||||
bool decode_get_manifest(const uint8_t* buf, uint16_t len, GetManifestMsg& m) {
|
||||
R r(buf, len); if (r.u8() != OTA_GET_MANIFEST) return false;
|
||||
const uint8_t* id = r.raw(4); if (id) memcpy(m.manifest_id, id, 4);
|
||||
return r.ok;
|
||||
}
|
||||
|
||||
uint16_t encode_manifest(uint8_t* buf, uint16_t cap, const ManifestMsg& m) {
|
||||
W w(buf, cap); w.u8(OTA_MANIFEST); w.raw(m.manifest_id, 4); w.u8(m.frag_idx); w.u8(m.frag_total);
|
||||
w.raw(m.bytes, m.len);
|
||||
return w.ok ? w.n : 0;
|
||||
}
|
||||
bool decode_manifest(const uint8_t* buf, uint16_t len, ManifestMsg& m) {
|
||||
R r(buf, len); if (r.u8() != OTA_MANIFEST) return false;
|
||||
const uint8_t* id = r.raw(4); if (id) memcpy(m.manifest_id, id, 4);
|
||||
m.frag_idx = r.u8(); m.frag_total = r.u8();
|
||||
m.len = r.remaining(); m.bytes = r.raw(m.len);
|
||||
return r.ok;
|
||||
}
|
||||
|
||||
uint16_t encode_req(uint8_t* buf, uint16_t cap, const ReqMsg& m) {
|
||||
W w(buf, cap); w.u8(OTA_REQ); w.raw(m.manifest_id, 4); w.u16(m.start_block); w.u8(m.count);
|
||||
return w.ok ? w.n : 0;
|
||||
}
|
||||
bool decode_req(const uint8_t* buf, uint16_t len, ReqMsg& m) {
|
||||
R r(buf, len); if (r.u8() != OTA_REQ) return false;
|
||||
const uint8_t* id = r.raw(4); if (id) memcpy(m.manifest_id, id, 4);
|
||||
m.start_block = r.u16(); m.count = r.u8();
|
||||
return r.ok;
|
||||
}
|
||||
|
||||
uint16_t encode_data(uint8_t* buf, uint16_t cap, const DataMsg& m) {
|
||||
W w(buf, cap); w.u8(OTA_DATA); w.raw(m.manifest_id, 4); w.u16(m.block_idx);
|
||||
w.u8(m.frag_idx); w.u8(m.frag_total);
|
||||
if (m.frag_idx == 0) { w.u8(m.n_proof); w.raw(m.proof, (uint16_t)m.n_proof * 4); }
|
||||
w.raw(m.data, m.data_len);
|
||||
return w.ok ? w.n : 0;
|
||||
}
|
||||
bool decode_data(const uint8_t* buf, uint16_t len, DataMsg& m) {
|
||||
R r(buf, len); if (r.u8() != OTA_DATA) return false;
|
||||
const uint8_t* id = r.raw(4); if (id) memcpy(m.manifest_id, id, 4);
|
||||
m.block_idx = r.u16(); m.frag_idx = r.u8(); m.frag_total = r.u8();
|
||||
m.n_proof = 0; m.proof = nullptr;
|
||||
if (m.frag_idx == 0) { m.n_proof = r.u8(); m.proof = r.raw((uint16_t)m.n_proof * 4); }
|
||||
m.data_len = r.remaining(); m.data = r.raw(m.data_len);
|
||||
return r.ok;
|
||||
}
|
||||
|
||||
} // namespace ota
|
||||
} // namespace mesh
|
||||
@@ -0,0 +1,72 @@
|
||||
#pragma once
|
||||
|
||||
#include <stdint.h>
|
||||
#include <stddef.h>
|
||||
#include "OtaFormat.h"
|
||||
|
||||
// Encode/decode for the OTA LoRa messages (docs/ota_protocol.md §8). Each message is a packet payload:
|
||||
// [0]=ota_msg_type, then a fixed body. Portable + allocation-free; unit-tested on the host.
|
||||
//
|
||||
// manifest_id == the manifest's merkle_root (4 bytes), a compact content id.
|
||||
|
||||
namespace mesh {
|
||||
namespace ota {
|
||||
|
||||
// ---- OTA_ADV: "I have (part of) fw X for target T" (flood, periodic + on demand) ----
|
||||
struct AdvMsg {
|
||||
uint32_t target_id;
|
||||
uint32_t fw_version;
|
||||
uint8_t manifest_id[4]; // = merkle_root
|
||||
uint8_t flags; // manifest flags (FULL/SIGNED)
|
||||
uint8_t have_all; // 1 = holder has the complete payload
|
||||
uint8_t codec_id; // manifest codec (0=full,1=detools-seq,2=detools-inplace) — lets a
|
||||
// receiver reject fw it can't apply before fetching anything
|
||||
};
|
||||
|
||||
// ---- OTA_GET_MANIFEST: request the manifest for a content id (direct) ----
|
||||
struct GetManifestMsg { uint8_t manifest_id[4]; };
|
||||
|
||||
// ---- OTA_MANIFEST: the manifest-minus-leaves[], fragmented (direct) ----
|
||||
// body: manifest_id(4) frag_idx(1) frag_total(1) bytes[]
|
||||
struct ManifestMsg {
|
||||
uint8_t manifest_id[4];
|
||||
uint8_t frag_idx, frag_total;
|
||||
const uint8_t* bytes; uint16_t len;
|
||||
};
|
||||
|
||||
// ---- OTA_REQ: request a window of blocks (direct) ----
|
||||
struct ReqMsg { uint8_t manifest_id[4]; uint16_t start_block; uint8_t count; };
|
||||
|
||||
// ---- OTA_DATA: one (fragment of a) block (direct) ----
|
||||
// body: manifest_id(4) block_idx(2) frag_idx(1) frag_total(1) [frag0: n_proof(1) proof(n_proof*4)] data[]
|
||||
struct DataMsg {
|
||||
uint8_t manifest_id[4];
|
||||
uint16_t block_idx;
|
||||
uint8_t frag_idx, frag_total;
|
||||
uint8_t n_proof; // only meaningful on frag_idx==0
|
||||
const uint8_t* proof; // n_proof*4 bytes (frag0 only)
|
||||
const uint8_t* data; uint16_t data_len;
|
||||
};
|
||||
|
||||
// Each encode_* returns the total payload length (incl. the leading msg-type byte), 0 on overflow.
|
||||
// Each decode_* returns true on success (and points struct fields into `buf`).
|
||||
|
||||
uint16_t encode_adv(uint8_t* buf, uint16_t cap, const AdvMsg& m);
|
||||
bool decode_adv(const uint8_t* buf, uint16_t len, AdvMsg& m);
|
||||
|
||||
uint16_t encode_get_manifest(uint8_t* buf, uint16_t cap, const GetManifestMsg& m);
|
||||
bool decode_get_manifest(const uint8_t* buf, uint16_t len, GetManifestMsg& m);
|
||||
|
||||
uint16_t encode_manifest(uint8_t* buf, uint16_t cap, const ManifestMsg& m);
|
||||
bool decode_manifest(const uint8_t* buf, uint16_t len, ManifestMsg& m);
|
||||
|
||||
uint16_t encode_req(uint8_t* buf, uint16_t cap, const ReqMsg& m);
|
||||
bool decode_req(const uint8_t* buf, uint16_t len, ReqMsg& m);
|
||||
|
||||
uint16_t encode_data(uint8_t* buf, uint16_t cap, const DataMsg& m);
|
||||
bool decode_data(const uint8_t* buf, uint16_t len, DataMsg& m);
|
||||
|
||||
inline uint8_t ota_msg_type(const uint8_t* buf, uint16_t len) { return len ? buf[0] : 0xFF; }
|
||||
|
||||
} // namespace ota
|
||||
} // namespace mesh
|
||||
@@ -0,0 +1,64 @@
|
||||
#include "OtaSelf.h"
|
||||
#include "FirmwareInfo.h"
|
||||
#include <string.h>
|
||||
|
||||
#if defined(ESP32_PLATFORM)
|
||||
#include "esp_ota_ops.h"
|
||||
#include "esp_partition.h"
|
||||
#elif defined(NRF52_PLATFORM)
|
||||
#include "OtaFlashLayout_nrf52.h"
|
||||
#endif
|
||||
|
||||
namespace mesh {
|
||||
namespace ota {
|
||||
|
||||
#if defined(ESP32_PLATFORM)
|
||||
// Scan the running app partition for the firmware's EndF trailer using esp_partition_read (stable
|
||||
// across IDF versions — no mmap). Same rule as find_self_firmware(): the marker's absolute offset
|
||||
// must equal its stored body_len, which uniquely identifies the running firmware's own trailer.
|
||||
bool ota_self_firmware(SelfFwInfo& out) {
|
||||
out = SelfFwInfo();
|
||||
const esp_partition_t* p = esp_ota_get_running_partition();
|
||||
if (!p) return false;
|
||||
|
||||
const uint32_t CH = 512;
|
||||
uint8_t buf[CH + ENDF_LEN]; // overlap so a marker spanning a chunk edge is still seen
|
||||
for (uint32_t base = 0; base + ENDF_LEN <= p->size; base += CH) {
|
||||
uint32_t want = CH + ENDF_LEN;
|
||||
if (base + want > p->size) want = p->size - base;
|
||||
if (esp_partition_read(p, base, buf, want) != ESP_OK) return false;
|
||||
for (uint32_t i = 0; i + ENDF_LEN <= want; i++) {
|
||||
if (buf[i] != ENDF_MAGIC[0]) continue;
|
||||
if (memcmp(buf + i, ENDF_MAGIC, 4) != 0) continue;
|
||||
uint32_t body_len = (uint32_t)buf[i+4] | ((uint32_t)buf[i+5] << 8)
|
||||
| ((uint32_t)buf[i+6] << 16) | ((uint32_t)buf[i+7] << 24);
|
||||
if (body_len != base + i) continue; // must sit immediately after a body of that length
|
||||
out.valid = true;
|
||||
out.endf_offset = base + i;
|
||||
out.body_len = body_len;
|
||||
out.image_len = body_len + ENDF_LEN;
|
||||
memcpy(out.body_hash, buf + i + 8, 8);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
#elif defined(NRF52_PLATFORM)
|
||||
// nRF52 internal flash is memory-mapped, so the running app is directly scannable. The body starts at
|
||||
// APP_BASE; find_self_firmware() picks the EndF whose stored body_len equals its offset (the running
|
||||
// firmware's own trailer), ignoring any staged `.mota` (which carries its own embedded EndF) higher up.
|
||||
bool ota_self_firmware(SelfFwInfo& out) {
|
||||
const uint8_t* region = (const uint8_t*)(uintptr_t)MOTA_NRF52_APP_BASE;
|
||||
uint32_t region_len = MOTA_NRF52_FS_START - MOTA_NRF52_APP_BASE;
|
||||
return find_self_firmware(region, region_len, out, /*verify_body=*/true);
|
||||
}
|
||||
#else
|
||||
bool ota_self_firmware(SelfFwInfo& out) {
|
||||
// STM32/RP2040: app-region access lands with their apply path.
|
||||
out = SelfFwInfo();
|
||||
return false;
|
||||
}
|
||||
#endif
|
||||
|
||||
} // namespace ota
|
||||
} // namespace mesh
|
||||
@@ -0,0 +1,18 @@
|
||||
#pragma once
|
||||
|
||||
#include "FirmwareInfo.h"
|
||||
|
||||
// Device-side accessor for the running firmware's own image (to read its EndF trailer).
|
||||
// Per-platform: ESP32 memory-maps the running app partition; other platforms TBD (nRF52 uses the
|
||||
// bootloader-apply path, so its app-region wiring lands with that work). Not compiled on the native
|
||||
// host — the portable scan logic in FirmwareInfo.{h,cpp} is what gets unit-tested there.
|
||||
|
||||
namespace mesh {
|
||||
namespace ota {
|
||||
|
||||
// Locate this firmware's EndF trailer in its own flash image. Returns false if unsupported on this
|
||||
// platform or no valid EndF is present (e.g. firmware built without the EndF build hook).
|
||||
bool ota_self_firmware(SelfFwInfo& out);
|
||||
|
||||
} // namespace ota
|
||||
} // namespace mesh
|
||||
@@ -0,0 +1,69 @@
|
||||
#pragma once
|
||||
|
||||
#include <stdint.h>
|
||||
#include <stddef.h>
|
||||
#include <string.h>
|
||||
|
||||
// Staging backend for an in-transit `.mota` (docs/ota_protocol.md §7). Blocks may arrive out of order
|
||||
// and progress must survive reboots, so the store is random-access. The transfer/verify logic is
|
||||
// written against this interface; concrete impls are per-platform (RAM for tests/bring-up; persistent
|
||||
// flash — ESP32 OTA slot / nRF52 raw region — for production, dropped in behind the same interface).
|
||||
|
||||
namespace mesh {
|
||||
namespace ota {
|
||||
|
||||
class OtaStore {
|
||||
public:
|
||||
virtual ~OtaStore() {}
|
||||
// Prepare staging for a container of `total_size` bytes (erases/clears). false if it won't fit.
|
||||
virtual bool begin(uint32_t total_size) = 0;
|
||||
virtual bool write(uint32_t offset, const uint8_t* data, uint32_t len) = 0;
|
||||
virtual bool read(uint32_t offset, uint8_t* buf, uint32_t len) const = 0;
|
||||
virtual uint32_t capacity() const = 0;
|
||||
virtual uint32_t staged_size() const = 0; // total_size from begin(), 0 if none
|
||||
virtual void clear() = 0;
|
||||
|
||||
// Optional: declare the size of the leading metadata (header + manifest + merkle leaves, i.e.
|
||||
// everything before the payload). A flash-backed store keeps that region — which is updated
|
||||
// throughout the transfer (a leaf is committed per block) — pinned in one RAM page, so it can
|
||||
// flush the bulk payload page-by-page without re-erasing the leaves' page on every block.
|
||||
// Returns false if the metadata won't fit the store's pinned region (transfer is then refused).
|
||||
virtual bool set_meta_size(uint32_t meta_bytes) { (void)meta_bytes; return true; }
|
||||
|
||||
// Optional: commit any RAM-buffered data to persistent storage. Called once when the transfer
|
||||
// reaches COMPLETE (radio idle), so a flash store does its page writes off the RX critical path.
|
||||
// After this returns, a flash store's data() view is coherent. No-op for purely in-RAM stores.
|
||||
virtual void finalize() {}
|
||||
};
|
||||
|
||||
// Fixed-capacity RAM store — for native tests and device bring-up of the transfer/verify path.
|
||||
// (Does NOT survive reboot; a persistent flash store replaces it for production — see D1.)
|
||||
template <uint32_t CAP>
|
||||
class OtaStoreRam : public OtaStore {
|
||||
uint8_t _buf[CAP];
|
||||
uint32_t _total = 0;
|
||||
public:
|
||||
bool begin(uint32_t total_size) override {
|
||||
if (total_size > CAP) return false;
|
||||
_total = total_size;
|
||||
memset(_buf, 0xFF, total_size); // mimic erased flash (so unfilled leaf slots read as 'missing')
|
||||
return true;
|
||||
}
|
||||
bool write(uint32_t off, const uint8_t* d, uint32_t len) override {
|
||||
if ((uint64_t)off + len > _total) return false;
|
||||
memcpy(_buf + off, d, len);
|
||||
return true;
|
||||
}
|
||||
bool read(uint32_t off, uint8_t* b, uint32_t len) const override {
|
||||
if ((uint64_t)off + len > _total) return false;
|
||||
memcpy(b, _buf + off, len);
|
||||
return true;
|
||||
}
|
||||
uint32_t capacity() const override { return CAP; }
|
||||
uint32_t staged_size() const override { return _total; }
|
||||
void clear() override { _total = 0; }
|
||||
const uint8_t* data() const { return _buf; } // contiguous view (RAM store only)
|
||||
};
|
||||
|
||||
} // namespace ota
|
||||
} // namespace mesh
|
||||
@@ -0,0 +1,121 @@
|
||||
#include "OtaStoreFlashNrf52.h"
|
||||
|
||||
#if defined(NRF52_PLATFORM) && defined(OTA_FLASH_STORE)
|
||||
|
||||
#include "OtaSelf.h"
|
||||
#include "OtaDebug.h"
|
||||
#include <string.h>
|
||||
#include "flash/flash_nrf5x.h" // Adafruit core internal-flash driver (SoftDevice-safe; LittleFS path)
|
||||
|
||||
namespace mesh {
|
||||
namespace ota {
|
||||
|
||||
// Write one whole 4 KB page from `buf` to flash (erase + program, ~85 ms). `buf` is PG bytes, 0xFF-padded
|
||||
// past the container, so the program is clean. The last container page ends exactly at FS_START (both
|
||||
// FS_START and _write_start are page-aligned and the container ends <= FS_START), so a full-page write
|
||||
// never reaches into ExtraFS.
|
||||
void OtaStoreFlashNrf52::flush_page(uint32_t page_idx, const uint8_t* buf) {
|
||||
uint32_t addr = _write_start + page_idx * PG;
|
||||
if (addr + PG > MOTA_NRF52_FS_START) return; // defensive: never cross the staging ceiling
|
||||
OTA_DBG("OTA flash: write page %u @ %08x\n", (unsigned)page_idx, (unsigned)addr);
|
||||
flash_nrf5x_write(addr, buf, PG);
|
||||
flash_nrf5x_flush();
|
||||
}
|
||||
|
||||
void OtaStoreFlashNrf52::flush_pay() {
|
||||
if (_pay_idx != 0) flush_page(_pay_idx, _pay_page); // _pay_idx 0 == no payload page open
|
||||
}
|
||||
|
||||
uint32_t OtaStoreFlashNrf52::run(uint32_t pos, uint32_t remain) const {
|
||||
uint32_t trailer = _total - 5; // container is always >= 13 bytes (begin checks)
|
||||
if (pos >= trailer) return remain; // tail: caller bounds remain to <= 5 already
|
||||
uint32_t end = pos + remain;
|
||||
uint32_t page_end = (pos / PG + 1) * PG;
|
||||
if (end > page_end) end = page_end; // a run stays within one flash page,
|
||||
if (end > trailer) end = trailer; // and never crosses into the trailer tail
|
||||
return end - pos;
|
||||
}
|
||||
|
||||
const uint8_t* OtaStoreFlashNrf52::read_slot(uint32_t pos) const {
|
||||
if (pos >= _total - 5) return _trailer + (pos - (_total - 5)); // trailer tail (RAM until finalize)
|
||||
uint32_t page = pos / PG;
|
||||
if (page == 0) return _meta_page + pos; // pinned page 0 (incl. leaves)
|
||||
if (page == _pay_idx) return _pay_page + (pos - page * PG); // current sliding payload page
|
||||
return (const uint8_t*)(uintptr_t)(_write_start + pos); // already flushed -> memory-mapped
|
||||
}
|
||||
|
||||
uint8_t* OtaStoreFlashNrf52::write_slot(uint32_t pos) {
|
||||
if (pos >= _total - 5) return _trailer + (pos - (_total - 5));
|
||||
uint32_t page = pos / PG;
|
||||
if (page == 0) return _meta_page + pos;
|
||||
if (page > _pay_idx) { flush_pay(); _pay_idx = page; memset(_pay_page, 0xFF, PG); } // advance, fresh page
|
||||
if (page == _pay_idx) return _pay_page + (pos - page * PG);
|
||||
return nullptr; // page < _pay_idx: already flushed
|
||||
}
|
||||
|
||||
bool OtaStoreFlashNrf52::begin(uint32_t total_size) {
|
||||
clear();
|
||||
if (total_size < 13 || total_size > capacity()) return false; // 13 = header(8) + trailer(5)
|
||||
|
||||
// bottom-align against FS_START so the trailer ends exactly at FS_START (bootloader scans for it)
|
||||
uint32_t start = (MOTA_NRF52_FS_START - total_size) & ~(PG - 1);
|
||||
|
||||
// never collide with the running application image (its extent comes from its EndF trailer)
|
||||
uint32_t app_end = MOTA_NRF52_APP_BASE;
|
||||
SelfFwInfo fi;
|
||||
if (ota_self_firmware(fi) && fi.valid) app_end = MOTA_NRF52_APP_BASE + fi.image_len;
|
||||
if (start < app_end) return false;
|
||||
|
||||
_write_start = start;
|
||||
_total = total_size;
|
||||
memset(_meta_page, 0xFF, PG); // assemble page 0 in RAM; 0xFF = erased sentinel (unfilled leaf slots)
|
||||
memset(_trailer, 0xFF, sizeof(_trailer));
|
||||
_pay_idx = 0;
|
||||
_flushed = false;
|
||||
OTA_DBG("OTA flash: begin total=%u start=%08x app_end=%08x\n",
|
||||
(unsigned)total_size, (unsigned)start, (unsigned)app_end);
|
||||
return true; // no pre-erase: each page is erased by its own (single) flush
|
||||
}
|
||||
|
||||
bool OtaStoreFlashNrf52::write(uint32_t offset, const uint8_t* d, uint32_t len) {
|
||||
if ((uint64_t)offset + len > _total) return false;
|
||||
for (uint32_t pos = offset, end = offset + len; pos < end; ) {
|
||||
uint32_t n = run(pos, end - pos);
|
||||
if (uint8_t* dst = write_slot(pos)) {
|
||||
memcpy(dst, d, n);
|
||||
} else {
|
||||
// out-of-order write to an already-flushed page: read-modify-write straight to flash. Safe -- the
|
||||
// driver erases the page before programming, so re-touching it never breaks writes-per-word.
|
||||
OTA_DBG("OTA flash: RMW page %u (out-of-order) @ off %u\n", (unsigned)(pos / PG), (unsigned)pos);
|
||||
if (flash_nrf5x_write(_write_start + pos, d, n) < 0) return false;
|
||||
flash_nrf5x_flush();
|
||||
}
|
||||
pos += n; d += n;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
bool OtaStoreFlashNrf52::read(uint32_t offset, uint8_t* buf, uint32_t len) const {
|
||||
if ((uint64_t)offset + len > _total) return false;
|
||||
for (uint32_t pos = offset, end = offset + len; pos < end; ) {
|
||||
uint32_t n = run(pos, end - pos);
|
||||
memcpy(buf, read_slot(pos), n);
|
||||
pos += n; buf += n;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
void OtaStoreFlashNrf52::finalize() {
|
||||
if (_flushed || _total == 0) return;
|
||||
OTA_DBG("OTA flash: finalize total=%u\n", (unsigned)_total);
|
||||
flush_pay(); // the last (highest) payload page, if one is open
|
||||
flush_page(0, _meta_page); // page 0: header + manifest + leaves + first payload bytes
|
||||
flash_nrf5x_write(_write_start + _total - 5, _trailer, 5); // trailer tail (radio idle at COMPLETE)
|
||||
flash_nrf5x_flush();
|
||||
_flushed = true;
|
||||
}
|
||||
|
||||
} // namespace ota
|
||||
} // namespace mesh
|
||||
|
||||
#endif
|
||||
@@ -0,0 +1,75 @@
|
||||
#pragma once
|
||||
|
||||
#if defined(NRF52_PLATFORM) && defined(OTA_FLASH_STORE)
|
||||
|
||||
#include "OtaStore.h"
|
||||
#include "OtaFlashLayout_nrf52.h"
|
||||
|
||||
// Persistent flash-backed OtaStore for nRF52 (RAK4631). Stages the received `.mota` in the free flash
|
||||
// below the primary LittleFS (FS_START), bottom-aligned so its trailer ends at FS_START and the
|
||||
// bootloader can scan for it. Survives reboot — the whole point — so the bootloader can apply the
|
||||
// staged delta on the next boot.
|
||||
//
|
||||
// RAM is bounded to O(one flash page), NEVER O(mota): a 100 KB+ delta must not live in RAM.
|
||||
// - On nRF52 the flash *erase* unit is one 4 KB page and the only SoftDevice-safe writer
|
||||
// (Adafruit `flash_nrf5x`) erases the whole page on every flush (~85 ms, CPU stalled → LoRa RX
|
||||
// starved). Writing to flash per received packet therefore drops in-flight DATA and the transfer
|
||||
// stalls. The fix: coalesce to the *page*, the hardware-natural unit, and write each page once.
|
||||
// - `_meta_page` pins flash page 0 (header + manifest + the merkle-leaf progress markers, which are
|
||||
// written one-per-block all transfer long). Keeping it in RAM means streaming the payload never
|
||||
// re-erases the leaves' page. Flushed once at finalize(). Requires metadata <= one page
|
||||
// (set_meta_size enforces it; true for <= ~979 blocks, i.e. any realistic MeshCore image).
|
||||
// - `_pay_page` is a single sliding buffer for one payload page (index >= 1). It advances
|
||||
// monotonically with the (mostly in-order) block stream and flushes the page it leaves behind.
|
||||
// Rare out-of-order writes to an already-flushed page go straight to flash as a safe read-modify-
|
||||
// write (flash_nrf5x erases before programming, so re-touching a page never violates the
|
||||
// writes-per-word limit — it just costs one extra erase).
|
||||
// - The 5-byte trailer is buffered and written at finalize().
|
||||
// Net: flash is touched ~once per 4 KB page (≈ 1 per 4 blocks at 1 KB), off the per-packet path; page
|
||||
// 0 and the last page are written at finalize() with the radio idle. For a small delta (whole .mota
|
||||
// in page 0) there is ZERO flash I/O during the transfer.
|
||||
|
||||
namespace mesh {
|
||||
namespace ota {
|
||||
|
||||
class OtaStoreFlashNrf52 : public OtaStore {
|
||||
static const uint32_t PG = MOTA_NRF52_FLASH_PAGE; // 4096
|
||||
|
||||
uint32_t _write_start = 0; // flash address of container offset 0 (page-aligned)
|
||||
uint32_t _total = 0; // staged container size (0 = none)
|
||||
bool _flushed = false; // finalize() committed everything to flash
|
||||
|
||||
uint8_t _meta_page[PG]; // pinned flash page 0 (header + manifest + leaves + 1st payload)
|
||||
uint8_t _pay_page[PG]; // sliding buffer for one payload page (index _pay_idx)
|
||||
uint32_t _pay_idx = 0; // page index currently held in _pay_page (0 = none open; pages >= 1)
|
||||
uint8_t _trailer[5]; // last 5 container bytes (kept in RAM, written at finalize)
|
||||
|
||||
// Bytes from `pos` that stay in one store region (a single flash page, or the trailer tail).
|
||||
uint32_t run(uint32_t pos, uint32_t remain) const;
|
||||
// RAM home of byte `pos`: read_slot always resolves (flushed pages → memory-mapped flash); write_slot
|
||||
// opens/advances the sliding payload page and returns nullptr if `pos` is in an already-flushed page.
|
||||
const uint8_t* read_slot(uint32_t pos) const;
|
||||
uint8_t* write_slot(uint32_t pos);
|
||||
void flush_pay(); // commit _pay_page to flash (erase + program, one page)
|
||||
void flush_page(uint32_t page_idx, const uint8_t* buf); // write a full page to flash
|
||||
|
||||
public:
|
||||
bool begin(uint32_t total_size) override;
|
||||
bool write(uint32_t offset, const uint8_t* data, uint32_t len) override;
|
||||
bool read(uint32_t offset, uint8_t* buf, uint32_t len) const override;
|
||||
uint32_t capacity() const override { return MOTA_NRF52_FS_START - MOTA_NRF52_APP_BASE; }
|
||||
uint32_t staged_size() const override { return _total; }
|
||||
void clear() override { _total = 0; _pay_idx = 0; _flushed = false; }
|
||||
bool set_meta_size(uint32_t meta_bytes) override { return meta_bytes <= PG; } // leaves must fit page 0
|
||||
void finalize() override;
|
||||
|
||||
// Contiguous view (flash is memory-mapped). VALID ONLY AFTER finalize() — before that, page 0 and the
|
||||
// tail are still in RAM. OtaManager/OtaCli/verify use this only once the transfer is COMPLETE.
|
||||
const uint8_t* data() const { return (const uint8_t*)(uintptr_t)_write_start; }
|
||||
uint32_t write_start() const { return _write_start; }
|
||||
};
|
||||
|
||||
} // namespace ota
|
||||
} // namespace mesh
|
||||
|
||||
#endif
|
||||
@@ -0,0 +1,27 @@
|
||||
#include "OtaVerify.h"
|
||||
#include "MerkleTree.h"
|
||||
#include "Multihash.h"
|
||||
#include "Identity.h"
|
||||
|
||||
namespace mesh {
|
||||
namespace ota {
|
||||
|
||||
VerifyResult ota_verify(const uint8_t* buf, uint32_t len, const SignerAllowlist& allow) {
|
||||
VerifyResult r;
|
||||
MotaManifest m;
|
||||
if (!mota_parse(buf, len, m)) return r;
|
||||
r.parsed = true;
|
||||
r.root_ok = mota_check_root(m);
|
||||
r.image_ok = m.is_full() ? mota_check_image_hash_full(m)
|
||||
: true; // delta image_hash needs the base; verified at apply time
|
||||
r.is_signed = m.is_signed();
|
||||
if (r.is_signed) {
|
||||
mesh::Identity signer(m.signer_pubkey);
|
||||
r.sig_ok = signer.verify(m.signature, m.manifest_start, (int)m.signed_len);
|
||||
r.trusted = r.sig_ok && allow.contains(m.signer_pubkey);
|
||||
}
|
||||
return r;
|
||||
}
|
||||
|
||||
} // namespace ota
|
||||
} // namespace mesh
|
||||
@@ -0,0 +1,29 @@
|
||||
#pragma once
|
||||
|
||||
#include "MotaContainer.h"
|
||||
#include "SignerAllowlist.h"
|
||||
|
||||
// Full verification of a staged `.mota` (device-side: uses Ed25519 via mesh::Identity, so NOT compiled
|
||||
// on the native host — the portable integrity checks live in MotaContainer and are unit-tested there).
|
||||
|
||||
namespace mesh {
|
||||
namespace ota {
|
||||
|
||||
struct VerifyResult {
|
||||
bool parsed = false; // container + manifest parsed
|
||||
bool root_ok = false; // merkle_root recomputed from leaves[] matches
|
||||
bool image_ok = false; // full: sha2-256(payload)==image_hash; delta: deferred to apply (set true)
|
||||
bool is_signed = false;
|
||||
bool sig_ok = false; // Ed25519 signature valid for signer_pubkey
|
||||
bool trusted = false; // signer_pubkey is in the allowlist
|
||||
|
||||
// Integrity holds (safe to keep/serve). For a signed image, the signature must also verify.
|
||||
bool integrity_ok() const { return parsed && root_ok && image_ok && (!is_signed || sig_ok); }
|
||||
// Eligible for AUTO-apply: integrity + signed by an allowlisted key (decision D2).
|
||||
bool auto_appliable() const { return integrity_ok() && is_signed && sig_ok && trusted; }
|
||||
};
|
||||
|
||||
VerifyResult ota_verify(const uint8_t* buf, uint32_t len, const SignerAllowlist& allow);
|
||||
|
||||
} // namespace ota
|
||||
} // namespace mesh
|
||||
@@ -0,0 +1,72 @@
|
||||
#pragma once
|
||||
|
||||
#include <stdint.h>
|
||||
#include <stddef.h>
|
||||
#include <string.h>
|
||||
|
||||
// Runtime-managed allowlist of trusted Ed25519 firmware-signer public keys (docs/ota_protocol.md §9,
|
||||
// decision D2 + Q1: no key embedded in firmware; only allowlist-signed firmware may auto-apply).
|
||||
// Portable + fixed-capacity (no dynamic allocation). Persistence (load/save) is layered on per-platform.
|
||||
|
||||
namespace mesh {
|
||||
namespace ota {
|
||||
|
||||
#ifndef MAX_OTA_SIGNERS
|
||||
#define MAX_OTA_SIGNERS 4
|
||||
#endif
|
||||
|
||||
class SignerAllowlist {
|
||||
uint8_t _keys[MAX_OTA_SIGNERS][32];
|
||||
uint8_t _count = 0;
|
||||
|
||||
public:
|
||||
void clear() { _count = 0; }
|
||||
uint8_t count() const { return _count; }
|
||||
const uint8_t* get(uint8_t i) const { return (i < _count) ? _keys[i] : nullptr; }
|
||||
|
||||
bool contains(const uint8_t* pub) const {
|
||||
for (uint8_t i = 0; i < _count; i++)
|
||||
if (memcmp(_keys[i], pub, 32) == 0) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
// Add a key (idempotent). Returns false if the list is full.
|
||||
bool add(const uint8_t* pub) {
|
||||
if (contains(pub)) return true;
|
||||
if (_count >= MAX_OTA_SIGNERS) return false;
|
||||
memcpy(_keys[_count++], pub, 32);
|
||||
return true;
|
||||
}
|
||||
|
||||
bool remove(const uint8_t* pub) {
|
||||
for (uint8_t i = 0; i < _count; i++) {
|
||||
if (memcmp(_keys[i], pub, 32) == 0) {
|
||||
memmove(_keys[i], _keys[i + 1], (size_t)(_count - i - 1) * 32);
|
||||
_count--;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// Serialize as: count(1) || key0(32) || key1(32) ... Returns bytes written.
|
||||
uint32_t serialize(uint8_t* out, uint32_t max_len) const {
|
||||
uint32_t need = 1 + (uint32_t)_count * 32;
|
||||
if (max_len < need) return 0;
|
||||
out[0] = _count;
|
||||
memcpy(out + 1, _keys, (size_t)_count * 32);
|
||||
return need;
|
||||
}
|
||||
|
||||
bool deserialize(const uint8_t* in, uint32_t len) {
|
||||
if (len < 1) return false;
|
||||
uint8_t n = in[0];
|
||||
if (n > MAX_OTA_SIGNERS || (uint32_t)1 + n * 32 > len) return false;
|
||||
_count = n;
|
||||
memcpy(_keys, in + 1, (size_t)n * 32);
|
||||
return true;
|
||||
}
|
||||
};
|
||||
|
||||
} // namespace ota
|
||||
} // namespace mesh
|
||||
@@ -0,0 +1,42 @@
|
||||
Vendored detools embeddable C decoder
|
||||
=====================================
|
||||
|
||||
Source : https://github.com/eerimoq/detools (tag 0.53.0, c/ directory)
|
||||
Files : detools.c, detools.h
|
||||
License : BSD 2-Clause (Erik Moqvist; original bsdiff (c) Colin Percival).
|
||||
sais.c (host packager only, not vendored) is MIT.
|
||||
Compatible with MeshCore's MIT license; notices retained in-file.
|
||||
|
||||
Why vendored
|
||||
------------
|
||||
The detools PyPI package (used by tools/mota to *create* patches with
|
||||
detools.create_patch) ships only the Python library + patch-creation C
|
||||
extensions -- NOT the embeddable decoder. The on-device delta applier needs
|
||||
detools' own decoder, so we vendor c/detools.{c,h} verbatim. This is detools'
|
||||
official C implementation; MeshCore does not reimplement the delta codec.
|
||||
|
||||
Local modifications
|
||||
-------------------
|
||||
Only the config defaults at the top of detools.h were changed (upstream = 1):
|
||||
DETOOLS_CONFIG_FILE_IO -> 0 (no <stdio> file IO on device)
|
||||
DETOOLS_CONFIG_COMPRESSION_LZMA -> 0 (would need liblzma)
|
||||
DETOOLS_CONFIG_COMPRESSION_HEATSHRINK -> 0 (would need malloc + heatshrink/)
|
||||
DETOOLS_CONFIG_COMPRESSION_NONE = 1 (kept)
|
||||
DETOOLS_CONFIG_COMPRESSION_CRLE = 1 (kept)
|
||||
detools.c is byte-for-byte upstream.
|
||||
|
||||
With this config the decoder is self-contained (no malloc, no liblzma, no
|
||||
heatshrink/, no file IO) and applies `--codec sequential --compression crle`
|
||||
patches, which is what tools/mota produces for MeshCore .mota deltas.
|
||||
|
||||
Usage on device
|
||||
---------------
|
||||
src/helpers/ota/OtaApply.cpp wraps detools_apply_patch_callbacks():
|
||||
from_read/from_seek -> running OTA slot (the delta base, via esp_partition_read)
|
||||
patch_read -> the .mota payload held in RAM (fetched over LoRa)
|
||||
to_write -> inactive OTA slot (via esp_ota_write) + running SHA-256
|
||||
The decoded image is verified against the signed manifest image_hash before the
|
||||
slot is armed as boot partition.
|
||||
|
||||
To update: re-copy c/detools.{c,h} from the pinned detools tag and re-apply the
|
||||
three config-default edits above.
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,639 @@
|
||||
/**
|
||||
* BSD 2-Clause License
|
||||
*
|
||||
* Copyright (c) 2019-2020, Erik Moqvist
|
||||
* All rights reserved.
|
||||
*
|
||||
* Redistribution and use in source and binary forms, with or without
|
||||
* modification, are permitted provided that the following conditions are met:
|
||||
*
|
||||
* * Redistributions of source code must retain the above copyright notice, this
|
||||
* list of conditions and the following disclaimer.
|
||||
*
|
||||
* * Redistributions in binary form must reproduce the above copyright notice,
|
||||
* this list of conditions and the following disclaimer in the documentation
|
||||
* and/or other materials provided with the distribution.
|
||||
*
|
||||
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
|
||||
* AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
|
||||
* IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
|
||||
* DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
|
||||
* FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
|
||||
* DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
|
||||
* SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
|
||||
* CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
|
||||
* OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
|
||||
* OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
||||
*/
|
||||
|
||||
#ifndef DETOOLS_H
|
||||
#define DETOOLS_H
|
||||
|
||||
/*
|
||||
* Configuration.
|
||||
*
|
||||
* Define any of the defines below to 0 to disable given feature.
|
||||
*
|
||||
* MeshCore note: upstream defaults are all 1. We flip FILE_IO, LZMA and
|
||||
* HEATSHRINK off here so this vendored copy is safe-by-default on bare-metal
|
||||
* targets (no liblzma, no malloc/heatshrink, no <stdio> file IO) regardless of
|
||||
* build flags. MeshCore .mota deltas use --codec sequential --compression crle,
|
||||
* which is fully self-contained in detools.c (NONE + CRLE only). Re-enable a
|
||||
* feature by passing -DDETOOLS_CONFIG_..=1. See detools/README.meshcore.txt.
|
||||
*/
|
||||
|
||||
#ifndef DETOOLS_CONFIG_FILE_IO
|
||||
# define DETOOLS_CONFIG_FILE_IO 0
|
||||
#endif
|
||||
|
||||
#ifndef DETOOLS_CONFIG_COMPRESSION_NONE
|
||||
# define DETOOLS_CONFIG_COMPRESSION_NONE 1
|
||||
#endif
|
||||
|
||||
#ifndef DETOOLS_CONFIG_COMPRESSION_LZMA
|
||||
# define DETOOLS_CONFIG_COMPRESSION_LZMA 0
|
||||
#endif
|
||||
|
||||
#ifndef DETOOLS_CONFIG_COMPRESSION_CRLE
|
||||
# define DETOOLS_CONFIG_COMPRESSION_CRLE 1
|
||||
#endif
|
||||
|
||||
#ifndef DETOOLS_CONFIG_COMPRESSION_HEATSHRINK
|
||||
# define DETOOLS_CONFIG_COMPRESSION_HEATSHRINK 0
|
||||
#endif
|
||||
|
||||
#include <stdint.h>
|
||||
#include <string.h>
|
||||
#include <stdio.h>
|
||||
#include <stdbool.h>
|
||||
|
||||
#define DETOOLS_VERSION "0.53.0"
|
||||
|
||||
/* Error codes. */
|
||||
#define DETOOLS_OK 0
|
||||
#define DETOOLS_NOT_IMPLEMENTED 1
|
||||
#define DETOOLS_NOT_DONE 2
|
||||
#define DETOOLS_BAD_PATCH_TYPE 3
|
||||
#define DETOOLS_BAD_COMPRESSION 4
|
||||
#define DETOOLS_INTERNAL_ERROR 5
|
||||
#define DETOOLS_LZMA_INIT 6
|
||||
#define DETOOLS_LZMA_DECODE 7
|
||||
#define DETOOLS_OUT_OF_MEMORY 8
|
||||
#define DETOOLS_CORRUPT_PATCH 9
|
||||
#define DETOOLS_IO_FAILED 10
|
||||
#define DETOOLS_ALREADY_DONE 11
|
||||
#define DETOOLS_FILE_OPEN_FAILED 12
|
||||
#define DETOOLS_FILE_CLOSE_FAILED 13
|
||||
#define DETOOLS_FILE_READ_FAILED 14
|
||||
#define DETOOLS_FILE_WRITE_FAILED 15
|
||||
#define DETOOLS_FILE_SEEK_FAILED 16
|
||||
#define DETOOLS_FILE_TELL_FAILED 17
|
||||
#define DETOOLS_SHORT_HEADER 18
|
||||
#define DETOOLS_NOT_ENOUGH_PATCH_DATA 19
|
||||
#define DETOOLS_HEATSHRINK_SINK 20
|
||||
#define DETOOLS_HEATSHRINK_POLL 21
|
||||
#define DETOOLS_STEP_SET_FAILED 22
|
||||
#define DETOOLS_STEP_GET_FAILED 23
|
||||
#define DETOOLS_ALREADY_FAILED 24
|
||||
#define DETOOLS_CORRUPT_PATCH_OVERFLOW 25
|
||||
#define DETOOLS_CORRUPT_PATCH_CRLE_KIND 26
|
||||
#define DETOOLS_HEATSHRINK_HEADER 27
|
||||
|
||||
/**
|
||||
* Read callback.
|
||||
*
|
||||
* @param[in] arg_p User data passed to detools_apply_patch_init().
|
||||
* @param[out] buf_p Buffer to read into.
|
||||
* @param[in] size Number of bytes to read.
|
||||
*
|
||||
* @return zero(0) or negative error code.
|
||||
*/
|
||||
typedef int (*detools_read_t)(void *arg_p, uint8_t *buf_p, size_t size);
|
||||
|
||||
/**
|
||||
* Write callback.
|
||||
*
|
||||
* @param[in] arg_p User data passed to detools_apply_patch_init().
|
||||
* @param[in] buf_p Buffer to write.
|
||||
* @param[in] size Number of bytes to write.
|
||||
*
|
||||
* @return zero(0) or negative error code.
|
||||
*/
|
||||
typedef int (*detools_write_t)(void *arg_p, const uint8_t *buf_p, size_t size);
|
||||
|
||||
/**
|
||||
* Seek from current position callback.
|
||||
*
|
||||
* @param[in] arg_p User data passed to detools_apply_patch_init().
|
||||
* @param[in] offset Offset to seek to from current position.
|
||||
*
|
||||
* @return zero(0) or negative error code.
|
||||
*/
|
||||
typedef int (*detools_seek_t)(void *arg_p, int offset);
|
||||
|
||||
/**
|
||||
* Memory read callback.
|
||||
*
|
||||
* @param[in] arg_p User data passed to detools_apply_patch_init().
|
||||
* @param[out] dst_p Buffer to read into.
|
||||
* @param[in] src Address to read from.
|
||||
* @param[in] size Number of bytes to read.
|
||||
*
|
||||
* @return zero(0) or negative error code.
|
||||
*/
|
||||
typedef int (*detools_mem_read_t)(void *arg_p,
|
||||
void *dst_p,
|
||||
uintptr_t src,
|
||||
size_t size);
|
||||
|
||||
/**
|
||||
* Memory write callback.
|
||||
*
|
||||
* @param[in] arg_p User data passed to detools_apply_patch_init().
|
||||
* @param[in] dst Address to write to.
|
||||
* @param[in] addr src_p Buffer to write from.
|
||||
* @param[in] size Number of bytes to write.
|
||||
*
|
||||
* @return zero(0) or negative error code.
|
||||
*/
|
||||
typedef int (*detools_mem_write_t)(void *arg_p,
|
||||
uintptr_t dst,
|
||||
void *src_p,
|
||||
size_t size);
|
||||
|
||||
/**
|
||||
* Memory erase callback.
|
||||
*
|
||||
* @param[in] arg_p User data passed to detools_apply_patch_init().
|
||||
* @param[in] addr Address to erase from.
|
||||
* @param[in] size Number of bytes to erase.
|
||||
*
|
||||
* @return zero(0) or negative error code.
|
||||
*/
|
||||
typedef int (*detools_mem_erase_t)(void *arg_p, uintptr_t addr, size_t size);
|
||||
|
||||
/**
|
||||
* State read callback.
|
||||
*
|
||||
* @param[in] arg_p User data passed to detools_apply_patch_init().
|
||||
* @param[out] buf_p Buffer to read into.
|
||||
* @param[in] size Number of bytes to read.
|
||||
*
|
||||
* @return zero(0) or negative error code.
|
||||
*/
|
||||
typedef int (*detools_state_read_t)(void *arg_p, void *buf_p, size_t size);
|
||||
|
||||
/**
|
||||
* State write callback.
|
||||
*
|
||||
* @param[in] arg_p User data passed to detools_apply_patch_init().
|
||||
* @param[in] buf_p Buffer to write.
|
||||
* @param[in] size Number of bytes to write.
|
||||
*
|
||||
* @return zero(0) or negative error code.
|
||||
*/
|
||||
typedef int (*detools_state_write_t)(void *arg_p, const void *buf_p, size_t size);
|
||||
|
||||
/**
|
||||
* Step set callback.
|
||||
*
|
||||
* @param[in] arg_p User data passed to detools_apply_patch_init().
|
||||
* @param[in] step Step to set. Later read by the step get callback.
|
||||
*
|
||||
* @return zero(0) or negative error code.
|
||||
*/
|
||||
typedef int (*detools_step_set_t)(void *arg_p, int step);
|
||||
|
||||
/**
|
||||
* Step get callback.
|
||||
*
|
||||
* @param[in] arg_p User data passed to detools_apply_patch_init().
|
||||
* @param[out] step_p Outputs the most recently set step by the set
|
||||
* callback, or zero(0) if not yet set.
|
||||
*
|
||||
* @return zero(0) or negative error code.
|
||||
*/
|
||||
typedef int (*detools_step_get_t)(void *arg_p, int *step_p);
|
||||
|
||||
struct detools_apply_patch_size_t {
|
||||
int state;
|
||||
int value;
|
||||
int offset;
|
||||
bool is_signed;
|
||||
};
|
||||
|
||||
struct detools_apply_patch_patch_reader_none_t {
|
||||
size_t patch_size;
|
||||
size_t patch_offset;
|
||||
};
|
||||
|
||||
#if DETOOLS_CONFIG_COMPRESSION_LZMA == 1
|
||||
|
||||
#include <lzma.h>
|
||||
|
||||
struct detools_apply_patch_patch_reader_lzma_t {
|
||||
lzma_stream stream;
|
||||
uint8_t *input_p;
|
||||
uint8_t *output_p;
|
||||
size_t output_size;
|
||||
};
|
||||
|
||||
#endif
|
||||
|
||||
#if DETOOLS_CONFIG_COMPRESSION_HEATSHRINK == 1
|
||||
|
||||
#include "heatshrink_decoder.h"
|
||||
|
||||
struct detools_apply_patch_patch_reader_heatshrink_t {
|
||||
int8_t window_sz2;
|
||||
int8_t lookahead_sz2;
|
||||
heatshrink_decoder *decoder_p;
|
||||
#if HEATSHRINK_DYNAMIC_ALLOC == 0
|
||||
heatshrink_decoder decoder;
|
||||
#endif
|
||||
};
|
||||
|
||||
#endif
|
||||
|
||||
enum detools_unpack_usize_state_t {
|
||||
detools_unpack_usize_state_first_t = 0,
|
||||
detools_unpack_usize_state_consecutive_t
|
||||
};
|
||||
|
||||
struct detools_unpack_usize_t {
|
||||
enum detools_unpack_usize_state_t state;
|
||||
int value;
|
||||
int offset;
|
||||
};
|
||||
|
||||
enum detools_crle_state_t {
|
||||
detools_crle_state_idle_t = 0,
|
||||
detools_crle_state_scattered_size_t,
|
||||
detools_crle_state_scattered_data_t,
|
||||
detools_crle_state_repeated_repetitions_t,
|
||||
detools_crle_state_repeated_data_t,
|
||||
detools_crle_state_repeated_data_read_t
|
||||
};
|
||||
|
||||
struct detools_apply_patch_patch_reader_crle_t {
|
||||
enum detools_crle_state_t state;
|
||||
union {
|
||||
struct {
|
||||
size_t number_of_bytes_left;
|
||||
struct detools_unpack_usize_t size;
|
||||
} scattered;
|
||||
struct {
|
||||
uint8_t value;
|
||||
size_t number_of_bytes_left;
|
||||
struct detools_unpack_usize_t size;
|
||||
} repeated;
|
||||
} kind;
|
||||
};
|
||||
|
||||
struct detools_apply_patch_patch_reader_t {
|
||||
struct detools_apply_patch_chunk_t *patch_chunk_p;
|
||||
struct detools_apply_patch_size_t size;
|
||||
union {
|
||||
#if DETOOLS_CONFIG_COMPRESSION_NONE == 1
|
||||
struct detools_apply_patch_patch_reader_none_t none;
|
||||
#endif
|
||||
#if DETOOLS_CONFIG_COMPRESSION_LZMA == 1
|
||||
struct detools_apply_patch_patch_reader_lzma_t lzma;
|
||||
#endif
|
||||
#if DETOOLS_CONFIG_COMPRESSION_CRLE == 1
|
||||
struct detools_apply_patch_patch_reader_crle_t crle;
|
||||
#endif
|
||||
#if DETOOLS_CONFIG_COMPRESSION_HEATSHRINK == 1
|
||||
struct detools_apply_patch_patch_reader_heatshrink_t heatshrink;
|
||||
#endif
|
||||
} compression;
|
||||
int (*destroy)(struct detools_apply_patch_patch_reader_t *self_p);
|
||||
int (*decompress)(struct detools_apply_patch_patch_reader_t *self_p,
|
||||
uint8_t *buf_p,
|
||||
size_t *size_p);
|
||||
};
|
||||
|
||||
struct detools_apply_patch_chunk_t {
|
||||
const uint8_t *buf_p;
|
||||
size_t size;
|
||||
size_t offset;
|
||||
};
|
||||
|
||||
enum detools_apply_patch_state_t {
|
||||
detools_apply_patch_state_init_t = 0,
|
||||
detools_apply_patch_state_dfpatch_size_t,
|
||||
detools_apply_patch_state_diff_size_t,
|
||||
detools_apply_patch_state_diff_data_t,
|
||||
detools_apply_patch_state_extra_size_t,
|
||||
detools_apply_patch_state_extra_data_t,
|
||||
detools_apply_patch_state_adjustment_t,
|
||||
detools_apply_patch_state_done_t,
|
||||
detools_apply_patch_state_failed_t
|
||||
};
|
||||
|
||||
enum detools_apply_patch_init_state_t {
|
||||
detools_apply_patch_init_state_fixed_header_t = 0,
|
||||
detools_apply_patch_init_state_to_size_t
|
||||
};
|
||||
|
||||
/**
|
||||
* The apply patch data structure.
|
||||
*/
|
||||
struct detools_apply_patch_t {
|
||||
detools_read_t from_read;
|
||||
detools_seek_t from_seek;
|
||||
size_t patch_size;
|
||||
detools_write_t to_write;
|
||||
void *arg_p;
|
||||
enum detools_apply_patch_state_t state;
|
||||
enum detools_apply_patch_init_state_t init_state;
|
||||
int compression;
|
||||
size_t patch_offset;
|
||||
size_t to_offset;
|
||||
size_t to_size;
|
||||
int from_offset;
|
||||
size_t chunk_size;
|
||||
struct detools_apply_patch_patch_reader_t patch_reader;
|
||||
struct detools_apply_patch_chunk_t chunk;
|
||||
struct detools_apply_patch_size_t size;
|
||||
};
|
||||
|
||||
enum detools_apply_patch_in_place_init_state_t {
|
||||
detools_apply_patch_in_place_init_state_fixed_header_t = 0,
|
||||
detools_apply_patch_in_place_init_state_memory_size_t,
|
||||
detools_apply_patch_in_place_init_state_segment_size_t,
|
||||
detools_apply_patch_in_place_init_state_shift_size_t,
|
||||
detools_apply_patch_in_place_init_state_from_size_t,
|
||||
detools_apply_patch_in_place_init_state_to_size_t
|
||||
};
|
||||
|
||||
/**
|
||||
* The in-place apply patch data structure.
|
||||
*/
|
||||
struct detools_apply_patch_in_place_t {
|
||||
detools_mem_read_t mem_read;
|
||||
detools_mem_write_t mem_write;
|
||||
detools_mem_erase_t mem_erase;
|
||||
detools_step_set_t step_set;
|
||||
detools_step_get_t step_get;
|
||||
size_t patch_size;
|
||||
void *arg_p;
|
||||
enum detools_apply_patch_state_t state;
|
||||
enum detools_apply_patch_in_place_init_state_t init_state;
|
||||
int compression;
|
||||
int ongoing_step;
|
||||
size_t to_pos;
|
||||
size_t to_size;
|
||||
size_t from_size;
|
||||
size_t memory_size;
|
||||
size_t segment_size;
|
||||
size_t shift_size;
|
||||
size_t chunk_size;
|
||||
struct {
|
||||
size_t index;
|
||||
int from_offset;
|
||||
size_t to_offset;
|
||||
size_t to_size;
|
||||
size_t to_pos;
|
||||
} segment;
|
||||
struct detools_apply_patch_patch_reader_t patch_reader;
|
||||
struct detools_apply_patch_chunk_t chunk;
|
||||
struct detools_apply_patch_size_t size;
|
||||
};
|
||||
|
||||
/**
|
||||
* Initialize given apply patch object.
|
||||
*
|
||||
* @param[out] self_p Apply patch object to initialize.
|
||||
* @param[in] from_read Callback to read from-data.
|
||||
* @param[in] from_seek Callback to seek from current position in from-data.
|
||||
* @param[in] patch_size Patch size in bytes. Not used if
|
||||
* `detools_apply_patch_restore()` is called
|
||||
* immediately after this function.
|
||||
* @param[in] to_write Destination callback.
|
||||
* @param[in] arg_p Argument passed to the callbacks.
|
||||
*
|
||||
* @return zero(0) or negative error code.
|
||||
*/
|
||||
int detools_apply_patch_init(struct detools_apply_patch_t *self_p,
|
||||
detools_read_t from_read,
|
||||
detools_seek_t from_seek,
|
||||
size_t patch_size,
|
||||
detools_write_t to_write,
|
||||
void *arg_p);
|
||||
|
||||
/**
|
||||
* Dump given apply patch object state. Call
|
||||
* `detools_apply_patch_restore()` to restore an apply patch object to
|
||||
* the dumped state.
|
||||
*
|
||||
* @param[in] self_p Apply patch object to dump.
|
||||
* @param[in] write Write callback.
|
||||
*
|
||||
* @return zero(0) or negative error code.
|
||||
*/
|
||||
int detools_apply_patch_dump(struct detools_apply_patch_t *self_p,
|
||||
detools_state_write_t state_write);
|
||||
|
||||
/**
|
||||
* Restore given apply patch object to given dumped
|
||||
* state.
|
||||
*
|
||||
* `detools_apply_patch_get_to_offset()` and
|
||||
* `detools_apply_patch_get_patch_offset()` are often called after
|
||||
* this function to restore the to and patch streams.
|
||||
*
|
||||
* @param[in,out] self_p Initialized apply patch object to restore.
|
||||
* @param[in] read Callback to read the dumped state.
|
||||
*
|
||||
* @return zero(0) or negative error code.
|
||||
*/
|
||||
int detools_apply_patch_restore(struct detools_apply_patch_t *self_p,
|
||||
detools_state_read_t state_read);
|
||||
|
||||
/**
|
||||
* Get the current to stream offset. Often used to restore the to
|
||||
* stream after restore.
|
||||
*
|
||||
* @param[in] self_p Apply patch object.
|
||||
*
|
||||
* @return The current to stream offset.
|
||||
*/
|
||||
size_t detools_apply_patch_get_to_offset(struct detools_apply_patch_t *self_p);
|
||||
|
||||
/**
|
||||
* Get the current patch stream offset. Often used to restore the
|
||||
* patch stream after restore.
|
||||
*
|
||||
* @param[in] self_p Apply patch object.
|
||||
*
|
||||
* @return The current patch stream offset.
|
||||
*/
|
||||
size_t detools_apply_patch_get_patch_offset(struct detools_apply_patch_t *self_p);
|
||||
|
||||
/**
|
||||
* Call this function repeatedly until all patch data has been
|
||||
* processed or an error occurres. Call detools_apply_patch_finalize()
|
||||
* to finalize the patching, even if an error occurred.
|
||||
*
|
||||
* @param[in,out] self_p Initialized apply patch object.
|
||||
* @param[in] patch_p Next chunk of the patch.
|
||||
* @param[in] size Patch buffer size.
|
||||
*
|
||||
* @return zero(0) or negative error code.
|
||||
*/
|
||||
int detools_apply_patch_process(struct detools_apply_patch_t *self_p,
|
||||
const uint8_t *patch_p,
|
||||
size_t size);
|
||||
|
||||
/**
|
||||
* Call once after all data has been processed to finalize the
|
||||
* patching. The value returned from this function should be ignored
|
||||
* if an error occurred in detools_apply_patch_process().
|
||||
*
|
||||
* @param[in,out] self_p Initialized apply patch object.
|
||||
*
|
||||
* @return Size of to-data in bytes if the patch was applied
|
||||
* successfully, or negative error code.
|
||||
*/
|
||||
int detools_apply_patch_finalize(struct detools_apply_patch_t *self_p);
|
||||
|
||||
/**
|
||||
* Initialize given in-place apply patch object.
|
||||
*
|
||||
* @param[out] self_p In-place apply patch object to initialize.
|
||||
* @param[in] mem_read Callback to read data.
|
||||
* @param[in] mem_write Callback to write data.
|
||||
* @param[in] mem_erase Callback to erase data.
|
||||
* @param[in] step_set Callback to set the step.
|
||||
* @param[in] step_get Callback to get the step.
|
||||
* @param[in] patch_size Patch size in bytes.
|
||||
* @param[in] arg_p Argument passed to the callbacks.
|
||||
*
|
||||
* @return zero(0) or negative error code.
|
||||
*/
|
||||
int detools_apply_patch_in_place_init(
|
||||
struct detools_apply_patch_in_place_t *self_p,
|
||||
detools_mem_read_t mem_read,
|
||||
detools_mem_write_t mem_write,
|
||||
detools_mem_erase_t mem_erase,
|
||||
detools_step_set_t step_set,
|
||||
detools_step_get_t step_get,
|
||||
size_t patch_size,
|
||||
void *arg_p);
|
||||
|
||||
/**
|
||||
* Call this function repeatedly until all patch data has been
|
||||
* processed or an error occurres. Call
|
||||
* detools_apply_patch_in_place_finalize() to finalize the patching,
|
||||
* even if an error occurred.
|
||||
*
|
||||
* @param[in,out] self_p Initialized apply patch object.
|
||||
* @param[in] patch_p Next chunk of the patch.
|
||||
* @param[in] size Patch buffer size.
|
||||
*
|
||||
* @return zero(0) or negative error code.
|
||||
*/
|
||||
int detools_apply_patch_in_place_process(
|
||||
struct detools_apply_patch_in_place_t *self_p,
|
||||
const uint8_t *patch_p,
|
||||
size_t size);
|
||||
|
||||
/**
|
||||
* Call once after all data has been processed to finalize the
|
||||
* patching. The value returned from this function should be ignored
|
||||
* if an error occurred in detools_apply_patch_in_place_process().
|
||||
*
|
||||
* @param[in,out] self_p Initialized apply patch object.
|
||||
*
|
||||
* @return Size of to-data in bytes if the patch was applied
|
||||
* successfully, or negative error code.
|
||||
*/
|
||||
int detools_apply_patch_in_place_finalize(
|
||||
struct detools_apply_patch_in_place_t *self_p);
|
||||
|
||||
/**
|
||||
* Apply given patch using read, write and seek callbacks.
|
||||
*
|
||||
* @param[in] from_read Source read callback.
|
||||
* @param[in] from_seek Source seek callback.
|
||||
* @param[in] patch_read Patch read callback.
|
||||
* @param[in] patch_size Patch size in bytes.
|
||||
* @param[in] to_write Destination write callback.
|
||||
* @param[in] arg_p Argument passed to all callbacks.
|
||||
*
|
||||
* @return Size of to-data in bytes or negative error code.
|
||||
*/
|
||||
int detools_apply_patch_callbacks(detools_read_t from_read,
|
||||
detools_seek_t from_seek,
|
||||
detools_read_t patch_read,
|
||||
size_t patch_size,
|
||||
detools_write_t to_write,
|
||||
void *arg_p);
|
||||
|
||||
/**
|
||||
* Apply given in-place patch using read, write and erase callbacks.
|
||||
*
|
||||
* @param[in] mem_read Callback to read data.
|
||||
* @param[in] mem_write Callback to write data.
|
||||
* @param[in] mem_erase Callback to erase data.
|
||||
* @param[in] step_set Callback to set the step.
|
||||
* @param[in] step_get Callback to get the step.
|
||||
* @param[in] patch_read Patch read callback.
|
||||
* @param[in] patch_size Patch size in bytes.
|
||||
* @param[in] arg_p Argument passed to the callbacks.
|
||||
*
|
||||
* @return Size of to-data in bytes or negative error code.
|
||||
*/
|
||||
int detools_apply_patch_in_place_callbacks(detools_mem_read_t mem_read,
|
||||
detools_mem_write_t mem_write,
|
||||
detools_mem_erase_t mem_erase,
|
||||
detools_step_set_t step_set,
|
||||
detools_step_get_t step_get,
|
||||
detools_read_t patch_read,
|
||||
size_t patch_size,
|
||||
void *arg_p);
|
||||
|
||||
#if DETOOLS_CONFIG_FILE_IO == 1
|
||||
|
||||
/**
|
||||
* Apply given patch file to given from file and write the output to
|
||||
* given to file.
|
||||
*
|
||||
* @param[in] from_p Source file name.
|
||||
* @param[in] patch_p Patch file name.
|
||||
* @param[in] to_p Destination file name.
|
||||
*
|
||||
* @return Size of to-data in bytes or negative error code.
|
||||
*/
|
||||
int detools_apply_patch_filenames(const char *from_p,
|
||||
const char *patch_p,
|
||||
const char *to_p);
|
||||
|
||||
/**
|
||||
* Apply given patch file to given memory file.
|
||||
*
|
||||
* @param[in] memory_p Memory file name.
|
||||
* @param[in] patch_p Patch file name.
|
||||
* @param[in] step_set Callback to set the step.
|
||||
* @param[in] step_get Callback to get the step.
|
||||
*
|
||||
* @return Size of to-data in bytes or negative error code.
|
||||
*/
|
||||
int detools_apply_patch_in_place_filenames(const char *memory_p,
|
||||
const char *patch_p,
|
||||
detools_step_set_t step_set,
|
||||
detools_step_get_t step_get);
|
||||
|
||||
#endif
|
||||
|
||||
/**
|
||||
* Get the error string for given error code.
|
||||
*
|
||||
* @param[in] Error code.
|
||||
*
|
||||
* @return Error string.
|
||||
*/
|
||||
const char *detools_error_as_string(int error);
|
||||
|
||||
#endif
|
||||
+95
-6
@@ -2,13 +2,102 @@
|
||||
|
||||
#include <stdint.h>
|
||||
#include <stddef.h>
|
||||
#include <string.h>
|
||||
|
||||
// Mock SHA256 class for testing
|
||||
// Provides minimal interface to allow Utils.cpp to compile
|
||||
// Real SHA-256 for native/host tests. Mirrors the rweather/Crypto streaming API used by
|
||||
// src/Utils.cpp (update / finalize-with-truncation, plus resetHMAC / finalizeHMAC), so that
|
||||
// Utils::sha256(...) produces correct digests on the host and OTA merkle tests are meaningful.
|
||||
// (On-device the real rweather <SHA256.h> is used instead of this mock.)
|
||||
class SHA256 {
|
||||
uint32_t h[8];
|
||||
uint8_t buf[64];
|
||||
uint32_t buf_len;
|
||||
uint64_t total_len;
|
||||
uint8_t hmac_key[64];
|
||||
|
||||
static uint32_t ror(uint32_t x, uint32_t n) { return (x >> n) | (x << (32 - n)); }
|
||||
|
||||
void process(const uint8_t* p) {
|
||||
static const uint32_t K[64] = {
|
||||
0x428a2f98,0x71374491,0xb5c0fbcf,0xe9b5dba5,0x3956c25b,0x59f111f1,0x923f82a4,0xab1c5ed5,
|
||||
0xd807aa98,0x12835b01,0x243185be,0x550c7dc3,0x72be5d74,0x80deb1fe,0x9bdc06a7,0xc19bf174,
|
||||
0xe49b69c1,0xefbe4786,0x0fc19dc6,0x240ca1cc,0x2de92c6f,0x4a7484aa,0x5cb0a9dc,0x76f988da,
|
||||
0x983e5152,0xa831c66d,0xb00327c8,0xbf597fc7,0xc6e00bf3,0xd5a79147,0x06ca6351,0x14292967,
|
||||
0x27b70a85,0x2e1b2138,0x4d2c6dfc,0x53380d13,0x650a7354,0x766a0abb,0x81c2c92e,0x92722c85,
|
||||
0xa2bfe8a1,0xa81a664b,0xc24b8b70,0xc76c51a3,0xd192e819,0xd6990624,0xf40e3585,0x106aa070,
|
||||
0x19a4c116,0x1e376c08,0x2748774c,0x34b0bcb5,0x391c0cb3,0x4ed8aa4a,0x5b9cca4f,0x682e6ff3,
|
||||
0x748f82ee,0x78a5636f,0x84c87814,0x8cc70208,0x90befffa,0xa4506ceb,0xbef9a3f7,0xc67178f2};
|
||||
uint32_t w[64];
|
||||
for (int i = 0; i < 16; i++)
|
||||
w[i] = ((uint32_t)p[i*4] << 24) | ((uint32_t)p[i*4+1] << 16) | ((uint32_t)p[i*4+2] << 8) | p[i*4+3];
|
||||
for (int i = 16; i < 64; i++) {
|
||||
uint32_t s0 = ror(w[i-15],7) ^ ror(w[i-15],18) ^ (w[i-15] >> 3);
|
||||
uint32_t s1 = ror(w[i-2],17) ^ ror(w[i-2],19) ^ (w[i-2] >> 10);
|
||||
w[i] = w[i-16] + s0 + w[i-7] + s1;
|
||||
}
|
||||
uint32_t a=h[0],b=h[1],c=h[2],d=h[3],e=h[4],f=h[5],g=h[6],hh=h[7];
|
||||
for (int i = 0; i < 64; i++) {
|
||||
uint32_t S1 = ror(e,6) ^ ror(e,11) ^ ror(e,25);
|
||||
uint32_t ch = (e & f) ^ ((~e) & g);
|
||||
uint32_t t1 = hh + S1 + ch + K[i] + w[i];
|
||||
uint32_t S0 = ror(a,2) ^ ror(a,13) ^ ror(a,22);
|
||||
uint32_t maj = (a & b) ^ (a & c) ^ (b & c);
|
||||
uint32_t t2 = S0 + maj;
|
||||
hh=g; g=f; f=e; e=d+t1; d=c; c=b; b=a; a=t1+t2;
|
||||
}
|
||||
h[0]+=a; h[1]+=b; h[2]+=c; h[3]+=d; h[4]+=e; h[5]+=f; h[6]+=g; h[7]+=hh;
|
||||
}
|
||||
|
||||
public:
|
||||
void update(const uint8_t* data, size_t len) {}
|
||||
void finalize(uint8_t* hash, size_t hashLen) {}
|
||||
void resetHMAC(const uint8_t* key, size_t keyLen) {}
|
||||
void finalizeHMAC(const uint8_t* key, size_t keyLen, uint8_t* hash, size_t hashLen) {}
|
||||
SHA256() { reset(); }
|
||||
|
||||
void reset() {
|
||||
h[0]=0x6a09e667; h[1]=0xbb67ae85; h[2]=0x3c6ef372; h[3]=0xa54ff53a;
|
||||
h[4]=0x510e527f; h[5]=0x9b05688c; h[6]=0x1f83d9ab; h[7]=0x5be0cd19;
|
||||
buf_len = 0; total_len = 0;
|
||||
}
|
||||
|
||||
void update(const uint8_t* data, size_t n) {
|
||||
total_len += n;
|
||||
while (n) {
|
||||
size_t take = 64 - buf_len; if (take > n) take = n;
|
||||
memcpy(buf + buf_len, data, take); buf_len += (uint32_t)take; data += take; n -= take;
|
||||
if (buf_len == 64) { process(buf); buf_len = 0; }
|
||||
}
|
||||
}
|
||||
|
||||
void finalize(uint8_t* out, size_t out_len) {
|
||||
uint64_t bits = total_len * 8;
|
||||
buf[buf_len++] = 0x80;
|
||||
if (buf_len > 56) { while (buf_len < 64) buf[buf_len++] = 0; process(buf); buf_len = 0; }
|
||||
while (buf_len < 56) buf[buf_len++] = 0;
|
||||
for (int i = 0; i < 8; i++) buf[56 + i] = (uint8_t)(bits >> (56 - 8*i));
|
||||
process(buf); buf_len = 0;
|
||||
uint8_t full[32];
|
||||
for (int i = 0; i < 8; i++) {
|
||||
full[i*4] = (uint8_t)(h[i] >> 24); full[i*4+1] = (uint8_t)(h[i] >> 16);
|
||||
full[i*4+2] = (uint8_t)(h[i] >> 8); full[i*4+3] = (uint8_t)(h[i]);
|
||||
}
|
||||
if (out_len > 32) out_len = 32;
|
||||
memcpy(out, full, out_len);
|
||||
}
|
||||
|
||||
// Standard HMAC-SHA256 (kept correct for API parity; OTA tests don't exercise it).
|
||||
void resetHMAC(const uint8_t* key, size_t keyLen) {
|
||||
memset(hmac_key, 0, 64);
|
||||
if (keyLen > 64) { SHA256 t; t.update(key, keyLen); t.finalize(hmac_key, 32); }
|
||||
else memcpy(hmac_key, key, keyLen);
|
||||
reset();
|
||||
uint8_t ipad[64];
|
||||
for (int i = 0; i < 64; i++) ipad[i] = hmac_key[i] ^ 0x36;
|
||||
update(ipad, 64);
|
||||
}
|
||||
|
||||
void finalizeHMAC(const uint8_t* key, size_t keyLen, uint8_t* out, size_t out_len) {
|
||||
(void)key; (void)keyLen;
|
||||
uint8_t inner[32]; finalize(inner, 32);
|
||||
uint8_t opad[64];
|
||||
for (int i = 0; i < 64; i++) opad[i] = hmac_key[i] ^ 0x5c;
|
||||
reset(); update(opad, 64); update(inner, 32); finalize(out, out_len);
|
||||
}
|
||||
};
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,567 @@
|
||||
#include <gtest/gtest.h>
|
||||
#include <array>
|
||||
#include <vector>
|
||||
#include <cstring>
|
||||
|
||||
#include "helpers/ota/MotaContainer.h"
|
||||
#include "helpers/ota/MerkleTree.h"
|
||||
#include "helpers/ota/BlockBitmap.h"
|
||||
#include "helpers/ota/Multihash.h"
|
||||
#include "helpers/ota/FirmwareInfo.h"
|
||||
#include "helpers/ota/SignerAllowlist.h"
|
||||
#include "helpers/ota/OtaStore.h"
|
||||
#include "helpers/ota/OtaProtocol.h"
|
||||
#include "helpers/ota/OtaManager.h"
|
||||
#include "mota_vectors.h" // auto-generated by tools/mota/gen_vectors.py
|
||||
|
||||
extern "C" {
|
||||
#include "helpers/ota/detools/detools.h" // vendored detools 0.53.0 embeddable decoder
|
||||
}
|
||||
|
||||
using namespace mesh::ota;
|
||||
|
||||
// Build a flashed-image layout (body || EndF) the way the host packager / build hook do.
|
||||
static std::vector<uint8_t> make_image(const std::vector<uint8_t>& body) {
|
||||
std::vector<uint8_t> img = body;
|
||||
img.insert(img.end(), ENDF_MAGIC, ENDF_MAGIC + 4);
|
||||
uint32_t n = (uint32_t)body.size();
|
||||
for (int i = 0; i < 4; i++) img.push_back((uint8_t)(n >> (8 * i)));
|
||||
uint8_t h[8]; mh8(h, body.data(), body.size());
|
||||
img.insert(img.end(), h, h + 8);
|
||||
return img;
|
||||
}
|
||||
|
||||
// --- cross-check the C++ parser/merkle against the Python reference vectors ----------------
|
||||
|
||||
TEST(OtaParse, ParsesReferenceContainer) {
|
||||
MotaManifest m;
|
||||
ASSERT_TRUE(mota_parse(MOTA_VEC, MOTA_VEC_LEN, m));
|
||||
EXPECT_EQ(m.format_ver, MOTA_FORMAT_VER);
|
||||
EXPECT_TRUE(m.is_full());
|
||||
EXPECT_FALSE(m.is_signed());
|
||||
EXPECT_EQ(m.target_id, EXP_TARGET_ID);
|
||||
EXPECT_EQ(m.fw_version, EXP_FW_VERSION);
|
||||
EXPECT_EQ(m.image_size, EXP_IMAGE_SIZE);
|
||||
EXPECT_EQ(m.payload_size, EXP_PAYLOAD_SIZE);
|
||||
EXPECT_EQ(m.block_count, EXP_BLOCK_COUNT);
|
||||
EXPECT_EQ(m.block_size_log2, EXP_BLOCK_SIZE_LOG2);
|
||||
EXPECT_EQ(m.codec_id, EXP_CODEC_ID);
|
||||
EXPECT_EQ(0, memcmp(m.merkle_root, EXP_MERKLE_ROOT, 4));
|
||||
EXPECT_EQ(0, memcmp(m.image_hash, EXP_IMAGE_HASH, 32));
|
||||
EXPECT_EQ(0, memcmp(m.approval, APPROVAL_NOT, 4)); // distributed = not approved
|
||||
EXPECT_FALSE(m.is_approved());
|
||||
}
|
||||
|
||||
TEST(OtaParse, RejectsTampering) {
|
||||
MotaManifest m;
|
||||
// bad magic
|
||||
std::vector<uint8_t> b(MOTA_VEC, MOTA_VEC + MOTA_VEC_LEN);
|
||||
b[0] ^= 0xFF;
|
||||
EXPECT_FALSE(mota_parse(b.data(), b.size(), m));
|
||||
// bad trailer
|
||||
b.assign(MOTA_VEC, MOTA_VEC + MOTA_VEC_LEN);
|
||||
b[b.size() - 1] ^= 0xFF;
|
||||
EXPECT_FALSE(mota_parse(b.data(), b.size(), m));
|
||||
// wrong total-size field
|
||||
b.assign(MOTA_VEC, MOTA_VEC + MOTA_VEC_LEN);
|
||||
b[4] ^= 0x01;
|
||||
EXPECT_FALSE(mota_parse(b.data(), b.size(), m));
|
||||
}
|
||||
|
||||
TEST(OtaMerkle, RootMatchesVectorAndLeaves) {
|
||||
MotaManifest m;
|
||||
ASSERT_TRUE(mota_parse(MOTA_VEC, MOTA_VEC_LEN, m));
|
||||
// root recomputed from stored leaves[] == merkle_root field == Python's EXP_MERKLE_ROOT
|
||||
uint8_t root[4];
|
||||
merkle_root(root, m.leaves, m.block_count);
|
||||
EXPECT_EQ(0, memcmp(root, EXP_MERKLE_ROOT, 4));
|
||||
EXPECT_TRUE(mota_check_root(m));
|
||||
// recompute each leaf from the payload block and compare to the stored leaf
|
||||
uint32_t bs = m.block_size();
|
||||
for (uint32_t i = 0; i < m.block_count; i++) {
|
||||
uint32_t off = i * bs;
|
||||
uint32_t len = (off + bs <= m.payload_size) ? bs : (m.payload_size - off);
|
||||
uint8_t leaf[4];
|
||||
merkle_leaf(leaf, m.payload + off, len);
|
||||
EXPECT_EQ(0, memcmp(leaf, m.leaves + i * 4, 4)) << "leaf " << i;
|
||||
}
|
||||
}
|
||||
|
||||
TEST(OtaMerkle, FullImageHashMatches) {
|
||||
MotaManifest m;
|
||||
ASSERT_TRUE(mota_parse(MOTA_VEC, MOTA_VEC_LEN, m));
|
||||
EXPECT_TRUE(mota_check_image_hash_full(m));
|
||||
}
|
||||
|
||||
TEST(OtaMerkle, ProofFromReferenceVerifies) {
|
||||
MotaManifest m;
|
||||
ASSERT_TRUE(mota_parse(MOTA_VEC, MOTA_VEC_LEN, m));
|
||||
uint32_t bs = m.block_size();
|
||||
uint32_t off = PROOF_INDEX * bs;
|
||||
uint32_t len = (off + bs <= m.payload_size) ? bs : (m.payload_size - off);
|
||||
|
||||
EXPECT_TRUE(merkle_verify(m.payload + off, len, PROOF_INDEX,
|
||||
PROOF_SIBLINGS, PROOF_NSIB, EXP_MERKLE_ROOT, m.block_count));
|
||||
|
||||
// tampered block -> fails
|
||||
std::vector<uint8_t> blk(m.payload + off, m.payload + off + len);
|
||||
blk[0] ^= 0xFF;
|
||||
EXPECT_FALSE(merkle_verify(blk.data(), len, PROOF_INDEX,
|
||||
PROOF_SIBLINGS, PROOF_NSIB, EXP_MERKLE_ROOT, m.block_count));
|
||||
|
||||
// wrong index with the same proof -> fails
|
||||
EXPECT_FALSE(merkle_verify(m.payload + off, len, PROOF_INDEX + 1,
|
||||
PROOF_SIBLINGS, PROOF_NSIB, EXP_MERKLE_ROOT, m.block_count));
|
||||
}
|
||||
|
||||
// --- validate the O(log n) binary-counter root vs a plain level-by-level reference ----------
|
||||
|
||||
static void ref_root(uint8_t out[4], std::vector<std::array<uint8_t, 4>> level) {
|
||||
while (level.size() > 1) {
|
||||
std::vector<std::array<uint8_t, 4>> nxt;
|
||||
for (size_t i = 0; i < level.size(); i += 2) {
|
||||
if (i + 1 < level.size()) {
|
||||
std::array<uint8_t, 4> p;
|
||||
merkle_combine(p.data(), level[i].data(), level[i + 1].data());
|
||||
nxt.push_back(p);
|
||||
} else {
|
||||
nxt.push_back(level[i]); // promote lone last node
|
||||
}
|
||||
}
|
||||
level.swap(nxt);
|
||||
}
|
||||
std::memcpy(out, level[0].data(), 4);
|
||||
}
|
||||
|
||||
TEST(OtaMerkle, BinaryCounterMatchesLevelByLevel) {
|
||||
uint32_t state = 0x12345678;
|
||||
auto rnd = [&]() { state = state * 1103515245u + 12345u; return (uint8_t)(state >> 16); };
|
||||
// O(log n) root must equal the plain level-by-level root for every count
|
||||
for (uint32_t count = 1; count <= 600; count++) {
|
||||
std::vector<uint8_t> leaves(count * 4);
|
||||
std::vector<std::array<uint8_t, 4>> ref(count);
|
||||
for (uint32_t i = 0; i < count; i++)
|
||||
for (int j = 0; j < 4; j++) { uint8_t v = rnd(); leaves[i * 4 + j] = v; ref[i][j] = v; }
|
||||
uint8_t a[4], b[4];
|
||||
merkle_root(a, leaves.data(), count);
|
||||
ref_root(b, ref);
|
||||
ASSERT_EQ(0, std::memcmp(a, b, 4)) << "root mismatch count=" << count;
|
||||
}
|
||||
}
|
||||
|
||||
// Verify every block's proof for several tricky counts, using proofs generated by the Python
|
||||
// reference (the oracle) — covers deep promotion chains (100, 255, 256, ...).
|
||||
TEST(OtaMerkle, ReferenceProofsAllIndices) {
|
||||
for (int c = 0; c < N_PROOF_CASES; c++) {
|
||||
const ProofCase& pc = PROOF_CASES[c];
|
||||
uint8_t root[4];
|
||||
merkle_root(root, pc.leaves, pc.count);
|
||||
EXPECT_EQ(0, std::memcmp(root, pc.root, 4)) << "root mismatch count=" << pc.count;
|
||||
for (uint32_t i = 0; i < pc.count; i++) {
|
||||
EXPECT_TRUE(merkle_verify_from_leaf(pc.leaves + i * 4, i,
|
||||
pc.pblob + pc.poff[i], pc.pnsib[i], pc.root, pc.count))
|
||||
<< "count=" << pc.count << " idx=" << i;
|
||||
}
|
||||
// a wrong sibling for index 0 must fail
|
||||
if (pc.pnsib[0] > 0) {
|
||||
std::vector<uint8_t> bad(pc.pblob + pc.poff[0], pc.pblob + pc.poff[0] + pc.pnsib[0] * 4);
|
||||
bad[0] ^= 0xFF;
|
||||
EXPECT_FALSE(merkle_verify_from_leaf(pc.leaves, 0, bad.data(), pc.pnsib[0], pc.root, pc.count));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- availability bitmap (derived from leaves[]) -------------------------------------------
|
||||
|
||||
TEST(OtaBitmap, AllPresentForCompleteContainer) {
|
||||
MotaManifest m;
|
||||
ASSERT_TRUE(mota_parse(MOTA_VEC, MOTA_VEC_LEN, m));
|
||||
EXPECT_TRUE(all_present(m.leaves, m.block_count));
|
||||
EXPECT_EQ(count_present(m.leaves, m.block_count), m.block_count);
|
||||
|
||||
// a leaf slot of all-FF (erased) means "missing"; bitmap round-trips
|
||||
std::vector<uint8_t> leaves(m.leaves, m.leaves + m.block_count * 4);
|
||||
std::memset(&leaves[4], 0xFF, 4); // mark block 1 missing
|
||||
EXPECT_FALSE(leaf_present(leaves.data(), 1));
|
||||
EXPECT_FALSE(all_present(leaves.data(), m.block_count));
|
||||
EXPECT_EQ(count_present(leaves.data(), m.block_count), m.block_count - 1);
|
||||
|
||||
std::vector<uint8_t> bm(bitmap_bytes(m.block_count));
|
||||
leaves_to_bitmap(leaves.data(), m.block_count, bm.data());
|
||||
EXPECT_FALSE(bitmap_get(bm.data(), 1));
|
||||
EXPECT_TRUE(bitmap_get(bm.data(), 0));
|
||||
}
|
||||
|
||||
// --- EndF self-firmware scan (P2) -----------------------------------------------------------
|
||||
|
||||
TEST(OtaFirmwareInfo, FindsEndFInImage) {
|
||||
std::vector<uint8_t> body(4321);
|
||||
for (size_t i = 0; i < body.size(); i++) body[i] = (uint8_t)(i * 37 + 11);
|
||||
std::vector<uint8_t> img = make_image(body);
|
||||
|
||||
// simulate a flash region: image, then erased 0xFF up to the partition end
|
||||
std::vector<uint8_t> region = img;
|
||||
region.resize(img.size() + 4096, 0xFF);
|
||||
|
||||
SelfFwInfo fi;
|
||||
ASSERT_TRUE(find_self_firmware(region.data(), (uint32_t)region.size(), fi, /*verify_body=*/true));
|
||||
EXPECT_EQ(fi.body_len, body.size());
|
||||
EXPECT_EQ(fi.image_len, img.size());
|
||||
EXPECT_EQ(fi.endf_offset, body.size());
|
||||
uint8_t h[8]; mh8(h, body.data(), body.size());
|
||||
EXPECT_EQ(0, std::memcmp(fi.body_hash, h, 8));
|
||||
}
|
||||
|
||||
TEST(OtaFirmwareInfo, IgnoresStagedMotaHigherInRegion) {
|
||||
// The firmware's own EndF must win even when a staged .mota (which embeds its own EndF) sits
|
||||
// above it in the same region — the body_len == offset check disambiguates.
|
||||
std::vector<uint8_t> body(2000);
|
||||
for (size_t i = 0; i < body.size(); i++) body[i] = (uint8_t)(i ^ 0x5A);
|
||||
std::vector<uint8_t> img = make_image(body);
|
||||
|
||||
std::vector<uint8_t> region = img;
|
||||
region.resize(8192, 0xFF); // gap
|
||||
// drop the reference .mota (which contains an embedded EndF in its payload) higher up
|
||||
region.insert(region.end(), MOTA_VEC, MOTA_VEC + MOTA_VEC_LEN);
|
||||
|
||||
SelfFwInfo fi;
|
||||
ASSERT_TRUE(find_self_firmware(region.data(), (uint32_t)region.size(), fi, true));
|
||||
EXPECT_EQ(fi.endf_offset, body.size()); // found OUR firmware, not the .mota's
|
||||
EXPECT_EQ(fi.body_len, body.size());
|
||||
}
|
||||
|
||||
TEST(OtaFirmwareInfo, NoMarkerReturnsFalse) {
|
||||
std::vector<uint8_t> region(1000, 0xAB);
|
||||
SelfFwInfo fi;
|
||||
EXPECT_FALSE(find_self_firmware(region.data(), (uint32_t)region.size(), fi));
|
||||
}
|
||||
|
||||
// --- signer allowlist (P3) ------------------------------------------------------------------
|
||||
|
||||
TEST(OtaAllowlist, AddContainsRemoveSerialize) {
|
||||
SignerAllowlist a;
|
||||
uint8_t k1[32], k2[32], k3[32];
|
||||
memset(k1, 0x11, 32); memset(k2, 0x22, 32); memset(k3, 0x33, 32);
|
||||
EXPECT_FALSE(a.contains(k1));
|
||||
EXPECT_TRUE(a.add(k1));
|
||||
EXPECT_TRUE(a.add(k2));
|
||||
EXPECT_TRUE(a.add(k1)); // idempotent
|
||||
EXPECT_EQ(a.count(), 2);
|
||||
EXPECT_TRUE(a.contains(k1));
|
||||
EXPECT_FALSE(a.contains(k3));
|
||||
|
||||
uint8_t buf[1 + MAX_OTA_SIGNERS * 32];
|
||||
uint32_t n = a.serialize(buf, sizeof(buf));
|
||||
EXPECT_EQ(n, 1u + 2 * 32);
|
||||
SignerAllowlist b;
|
||||
EXPECT_TRUE(b.deserialize(buf, n));
|
||||
EXPECT_EQ(b.count(), 2);
|
||||
EXPECT_TRUE(b.contains(k1) && b.contains(k2));
|
||||
|
||||
EXPECT_TRUE(a.remove(k1));
|
||||
EXPECT_EQ(a.count(), 1);
|
||||
EXPECT_FALSE(a.contains(k1));
|
||||
EXPECT_TRUE(a.contains(k2));
|
||||
}
|
||||
|
||||
// --- RAM store: out-of-order writes + availability via leaves[] --------------------------------
|
||||
|
||||
TEST(OtaStoreRamTest, RandomAccessAndErasedSentinel) {
|
||||
OtaStoreRam<4096> s;
|
||||
ASSERT_TRUE(s.begin(1000));
|
||||
EXPECT_EQ(s.staged_size(), 1000u);
|
||||
uint8_t blk[8] = {1,2,3,4,5,6,7,8};
|
||||
EXPECT_TRUE(s.write(500, blk, 8)); // out-of-order offset
|
||||
EXPECT_TRUE(s.write(0, blk, 8));
|
||||
EXPECT_FALSE(s.write(998, blk, 8)); // out of range
|
||||
uint8_t rd[8];
|
||||
EXPECT_TRUE(s.read(500, rd, 8));
|
||||
EXPECT_EQ(0, memcmp(rd, blk, 8));
|
||||
// untouched region reads as erased 0xFF (so an unfilled leaf slot is "missing")
|
||||
EXPECT_TRUE(s.read(100, rd, 8));
|
||||
for (int i = 0; i < 8; i++) EXPECT_EQ(rd[i], 0xFF);
|
||||
}
|
||||
|
||||
// --- merkle proof GENERATION (server side) matches the Python oracle ---------------------------
|
||||
|
||||
TEST(OtaMerkle, GenProofMatchesPythonAndVerifies) {
|
||||
for (int c = 0; c < N_PROOF_CASES; c++) {
|
||||
const ProofCase& pc = PROOF_CASES[c];
|
||||
std::vector<uint8_t> scratch(pc.count * 4);
|
||||
uint8_t out[32 * 4];
|
||||
for (uint32_t i = 0; i < pc.count; i++) {
|
||||
uint8_t n = merkle_gen_proof(pc.leaves, pc.count, i, scratch.data(), out);
|
||||
ASSERT_EQ(n, pc.pnsib[i]) << "count=" << pc.count << " idx=" << i;
|
||||
EXPECT_EQ(0, std::memcmp(out, pc.pblob + pc.poff[i], (size_t)n * 4))
|
||||
<< "gen_proof != python count=" << pc.count << " idx=" << i;
|
||||
EXPECT_TRUE(merkle_verify_from_leaf(pc.leaves + i * 4, i, out, n, pc.root, pc.count));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- protocol codec round-trips ---------------------------------------------------------------
|
||||
|
||||
TEST(OtaProtocol, CodecRoundTrips) {
|
||||
uint8_t buf[200];
|
||||
|
||||
AdvMsg adv{0x11223344, 0x02000000, {0x29,0x17,0xe4,0xf7}, MFLAG_FULL | MFLAG_SIGNED, 1, CODEC_DETOOLS_INPLACE};
|
||||
uint16_t n = encode_adv(buf, sizeof(buf), adv);
|
||||
ASSERT_GT(n, 0); EXPECT_EQ(ota_msg_type(buf, n), OTA_ADV);
|
||||
AdvMsg a2; ASSERT_TRUE(decode_adv(buf, n, a2));
|
||||
EXPECT_EQ(a2.target_id, adv.target_id); EXPECT_EQ(a2.fw_version, adv.fw_version);
|
||||
EXPECT_EQ(0, memcmp(a2.manifest_id, adv.manifest_id, 4));
|
||||
EXPECT_EQ(a2.flags, adv.flags); EXPECT_EQ(a2.have_all, 1);
|
||||
EXPECT_EQ(a2.codec_id, CODEC_DETOOLS_INPLACE);
|
||||
|
||||
GetManifestMsg gm{{1,2,3,4}};
|
||||
n = encode_get_manifest(buf, sizeof(buf), gm);
|
||||
GetManifestMsg g2; ASSERT_TRUE(decode_get_manifest(buf, n, g2));
|
||||
EXPECT_EQ(0, memcmp(g2.manifest_id, gm.manifest_id, 4));
|
||||
|
||||
uint8_t mbytes[40]; for (int i = 0; i < 40; i++) mbytes[i] = (uint8_t)(i + 1);
|
||||
ManifestMsg mm{{9,8,7,6}, 0, 1, mbytes, 40};
|
||||
n = encode_manifest(buf, sizeof(buf), mm);
|
||||
ManifestMsg m2; ASSERT_TRUE(decode_manifest(buf, n, m2));
|
||||
EXPECT_EQ(m2.frag_idx, 0); EXPECT_EQ(m2.frag_total, 1); EXPECT_EQ(m2.len, 40);
|
||||
EXPECT_EQ(0, memcmp(m2.bytes, mbytes, 40));
|
||||
|
||||
ReqMsg rq{{4,3,2,1}, 7, 5};
|
||||
n = encode_req(buf, sizeof(buf), rq);
|
||||
ReqMsg r2; ASSERT_TRUE(decode_req(buf, n, r2));
|
||||
EXPECT_EQ(r2.start_block, 7); EXPECT_EQ(r2.count, 5);
|
||||
|
||||
uint8_t proof[12]; for (int i = 0; i < 12; i++) proof[i] = (uint8_t)(0xA0 + i);
|
||||
uint8_t data[100]; for (int i = 0; i < 100; i++) data[i] = (uint8_t)(i * 3);
|
||||
DataMsg dm{{0,1,2,3}, 42, 0, 1, 3, proof, data, 100};
|
||||
n = encode_data(buf, sizeof(buf), dm);
|
||||
DataMsg d2; ASSERT_TRUE(decode_data(buf, n, d2));
|
||||
EXPECT_EQ(d2.block_idx, 42); EXPECT_EQ(d2.frag_idx, 0); EXPECT_EQ(d2.n_proof, 3);
|
||||
EXPECT_EQ(0, memcmp(d2.proof, proof, 12));
|
||||
EXPECT_EQ(d2.data_len, 100); EXPECT_EQ(0, memcmp(d2.data, data, 100));
|
||||
|
||||
// a non-frag0 DATA carries no proof
|
||||
DataMsg dm2{{0,1,2,3}, 42, 2, 6, 0, nullptr, data, 50};
|
||||
n = encode_data(buf, sizeof(buf), dm2);
|
||||
DataMsg d3; ASSERT_TRUE(decode_data(buf, n, d3));
|
||||
EXPECT_EQ(d3.frag_idx, 2); EXPECT_EQ(d3.n_proof, 0); EXPECT_EQ(d3.data_len, 50);
|
||||
}
|
||||
|
||||
// --- full transfer simulation between two OtaManagers (P4b) ------------------------------------
|
||||
|
||||
namespace {
|
||||
struct SimMsg { OtaManager* dest; std::vector<uint8_t> bytes; };
|
||||
static std::vector<SimMsg> g_q;
|
||||
struct SendTo { OtaManager* dest; };
|
||||
static void sim_send(void* ctx, const uint8_t* msg, uint16_t len, bool /*flood*/) {
|
||||
g_q.push_back({((SendTo*)ctx)->dest, std::vector<uint8_t>(msg, msg + len)});
|
||||
}
|
||||
}
|
||||
|
||||
TEST(OtaTransfer, TwoManagersFullTransfer) {
|
||||
g_q.clear();
|
||||
OtaManager server, client;
|
||||
OtaStoreRam<4096> store;
|
||||
SendTo to_client{&client}, to_server{&server};
|
||||
|
||||
server.begin(/*server's own target irrelevant for serving*/ 0, sim_send, &to_client);
|
||||
client.begin(SIM_TARGET_ID, sim_send, &to_server);
|
||||
client.set_fetch_store(&store);
|
||||
|
||||
ASSERT_TRUE(server.serve(SIM_MOTA, SIM_MOTA_LEN));
|
||||
server.announce(); // -> client hears the ADV and starts fetching
|
||||
|
||||
// drain the message bus until the client completes (event cascade does the whole transfer)
|
||||
int guard = 0;
|
||||
while (!g_q.empty() && guard++ < 100000) {
|
||||
SimMsg m = std::move(g_q.front());
|
||||
g_q.erase(g_q.begin());
|
||||
m.dest->on_message(m.bytes.data(), (uint16_t)m.bytes.size());
|
||||
if (g_q.empty() && client.fetchState() == OtaManager::FETCHING) client.loop();
|
||||
}
|
||||
|
||||
EXPECT_EQ(client.fetchState(), OtaManager::COMPLETE);
|
||||
EXPECT_EQ(client.blocksHave(), client.blocksTotal());
|
||||
EXPECT_GT(client.blocksTotal(), 1u);
|
||||
|
||||
// the client's reassembled container must be byte-identical to the original .mota...
|
||||
ASSERT_EQ(store.staged_size(), SIM_MOTA_LEN);
|
||||
EXPECT_EQ(0, std::memcmp(store.data(), SIM_MOTA, SIM_MOTA_LEN));
|
||||
|
||||
// ...and independently re-verify it parses with a matching root + image_hash
|
||||
MotaManifest m;
|
||||
ASSERT_TRUE(mota_parse(store.data(), store.staged_size(), m));
|
||||
EXPECT_TRUE(mota_check_root(m));
|
||||
EXPECT_TRUE(mota_check_image_hash_full(m));
|
||||
}
|
||||
|
||||
TEST(OtaTransfer, ClientRejectsWrongTarget) {
|
||||
g_q.clear();
|
||||
OtaManager server, client;
|
||||
OtaStoreRam<4096> store;
|
||||
SendTo to_client{&client}, to_server{&server};
|
||||
server.begin(0, sim_send, &to_client);
|
||||
client.begin(SIM_TARGET_ID ^ 0x1u, sim_send, &to_server); // different target -> not interested
|
||||
client.set_fetch_store(&store);
|
||||
ASSERT_TRUE(server.serve(SIM_MOTA, SIM_MOTA_LEN));
|
||||
server.announce();
|
||||
int guard = 0;
|
||||
while (!g_q.empty() && guard++ < 1000) {
|
||||
SimMsg m = std::move(g_q.front()); g_q.erase(g_q.begin());
|
||||
m.dest->on_message(m.bytes.data(), (uint16_t)m.bytes.size());
|
||||
}
|
||||
EXPECT_EQ(client.fetchState(), OtaManager::IDLE); // never started
|
||||
}
|
||||
|
||||
TEST(OtaTransfer, ManualCrossTargetFetch) {
|
||||
// A node whose own target differs from the served firmware normally won't fetch (role-switch case:
|
||||
// e.g. companion wanting repeater firmware). An explicit want() override lets it fetch deliberately.
|
||||
g_q.clear();
|
||||
OtaManager server, client;
|
||||
OtaStoreRam<4096> store;
|
||||
SendTo to_client{&client}, to_server{&server};
|
||||
server.begin(0, sim_send, &to_client);
|
||||
client.begin(SIM_TARGET_ID ^ 0xABCDu, sim_send, &to_server); // DIFFERENT own target
|
||||
client.set_fetch_store(&store);
|
||||
ASSERT_TRUE(server.serve(SIM_MOTA, SIM_MOTA_LEN));
|
||||
|
||||
// without the override: ignores the ADV (wrong target)
|
||||
server.announce();
|
||||
for (int g = 0; !g_q.empty() && g < 1000; g++) { SimMsg m = std::move(g_q.front()); g_q.erase(g_q.begin()); m.dest->on_message(m.bytes.data(), (uint16_t)m.bytes.size()); }
|
||||
EXPECT_EQ(client.fetchState(), OtaManager::IDLE);
|
||||
|
||||
// with want(): deliberately fetch the different-target firmware to completion
|
||||
client.want(SIM_TARGET_ID);
|
||||
server.announce();
|
||||
int guard = 0;
|
||||
while (!g_q.empty() && guard++ < 100000) {
|
||||
SimMsg m = std::move(g_q.front()); g_q.erase(g_q.begin());
|
||||
m.dest->on_message(m.bytes.data(), (uint16_t)m.bytes.size());
|
||||
if (g_q.empty() && client.fetchState() == OtaManager::FETCHING) client.loop();
|
||||
}
|
||||
EXPECT_EQ(client.fetchState(), OtaManager::COMPLETE);
|
||||
ASSERT_EQ(store.staged_size(), SIM_MOTA_LEN);
|
||||
EXPECT_EQ(0, std::memcmp(store.data(), SIM_MOTA, SIM_MOTA_LEN));
|
||||
}
|
||||
|
||||
// A node must not fetch firmware it can't apply: an ADV whose codec the platform can't decode is
|
||||
// rejected at ADV time (never requests the manifest). FULL + the platform's delta codec are accepted.
|
||||
TEST(OtaTransfer, RejectsIncompatibleCodec) {
|
||||
OtaManager client; OtaStoreRam<4096> store;
|
||||
SendTo to_server{&client}; // dest unused (we only check client state)
|
||||
client.begin(SIM_TARGET_ID, sim_send, &to_server);
|
||||
client.set_fetch_store(&store);
|
||||
client.set_apply_codec(CODEC_DETOOLS_INPLACE); // nRF52-style: accepts only full + in-place
|
||||
uint8_t b[32];
|
||||
|
||||
// our target, but a SEQUENTIAL delta -> incompatible -> ignored (stays IDLE, no GET_MANIFEST)
|
||||
AdvMsg seq{SIM_TARGET_ID, 0x01000000, {1,2,3,4}, 0, 1, CODEC_DETOOLS_SEQUENTIAL};
|
||||
client.on_message(b, encode_adv(b, sizeof(b), seq));
|
||||
EXPECT_EQ(client.fetchState(), OtaManager::IDLE);
|
||||
EXPECT_TRUE(g_q.empty());
|
||||
|
||||
// our target, IN-PLACE delta -> compatible -> proceeds to request the manifest
|
||||
AdvMsg ip{SIM_TARGET_ID, 0x01000000, {5,6,7,8}, 0, 1, CODEC_DETOOLS_INPLACE};
|
||||
client.on_message(b, encode_adv(b, sizeof(b), ip));
|
||||
EXPECT_EQ(client.fetchState(), OtaManager::WANT_MANIFEST);
|
||||
g_q.clear();
|
||||
}
|
||||
|
||||
// --- detools delta decode (vendored detools C decoder, CRLE-only build) ----------------------
|
||||
// Mirrors the device apply path (src/helpers/ota/OtaApply.cpp): base read via from_read/from_seek,
|
||||
// patch streamed via patch_read, output written via to_write. Proves the on-device delta apply uses
|
||||
// detools 0.53.0's own decoder and reproduces the exact target the host packager targeted.
|
||||
namespace {
|
||||
struct DTMem {
|
||||
const uint8_t* base; long base_len; long base_pos;
|
||||
const uint8_t* patch; long patch_len; long patch_pos;
|
||||
std::vector<uint8_t> out;
|
||||
};
|
||||
int dt_from_read(void* a, uint8_t* b, size_t n) {
|
||||
DTMem* c = (DTMem*)a;
|
||||
if (c->base_pos < 0 || c->base_pos + (long)n > c->base_len) return -DETOOLS_IO_FAILED;
|
||||
std::memcpy(b, c->base + c->base_pos, n); c->base_pos += (long)n; return DETOOLS_OK;
|
||||
}
|
||||
int dt_from_seek(void* a, int off) {
|
||||
DTMem* c = (DTMem*)a; c->base_pos += off;
|
||||
if (c->base_pos < 0 || c->base_pos > c->base_len) return -DETOOLS_IO_FAILED;
|
||||
return DETOOLS_OK;
|
||||
}
|
||||
int dt_patch_read(void* a, uint8_t* b, size_t n) {
|
||||
DTMem* c = (DTMem*)a;
|
||||
if (c->patch_pos + (long)n > c->patch_len) return -DETOOLS_IO_FAILED;
|
||||
std::memcpy(b, c->patch + c->patch_pos, n); c->patch_pos += (long)n; return DETOOLS_OK;
|
||||
}
|
||||
int dt_to_write(void* a, const uint8_t* b, size_t n) {
|
||||
DTMem* c = (DTMem*)a; c->out.insert(c->out.end(), b, b + n); return DETOOLS_OK;
|
||||
}
|
||||
|
||||
// In-place apply over a flat memory region (models the nRF52 app workspace / the bootloader's flash).
|
||||
struct DTInPlace {
|
||||
std::vector<uint8_t> mem; // [0,memory_size): base in, target out
|
||||
const uint8_t* patch; long plen, ppos; int step;
|
||||
};
|
||||
int ip_mem_read(void* a, void* dst, uintptr_t src, size_t n) {
|
||||
DTInPlace* c = (DTInPlace*)a; if (src + n > c->mem.size()) return -DETOOLS_IO_FAILED;
|
||||
std::memcpy(dst, c->mem.data() + src, n); return DETOOLS_OK;
|
||||
}
|
||||
int ip_mem_write(void* a, uintptr_t dst, void* src, size_t n) {
|
||||
DTInPlace* c = (DTInPlace*)a; if (dst + n > c->mem.size()) return -DETOOLS_IO_FAILED;
|
||||
std::memcpy(c->mem.data() + dst, src, n); return DETOOLS_OK;
|
||||
}
|
||||
int ip_mem_erase(void* a, uintptr_t addr, size_t n) {
|
||||
DTInPlace* c = (DTInPlace*)a; if (addr + n > c->mem.size()) return -DETOOLS_IO_FAILED;
|
||||
std::memset(c->mem.data() + addr, 0xFF, n); return DETOOLS_OK;
|
||||
}
|
||||
int ip_step_set(void* a, int s) { ((DTInPlace*)a)->step = s; return DETOOLS_OK; }
|
||||
int ip_step_get(void* a, int* s) { *s = ((DTInPlace*)a)->step; return DETOOLS_OK; }
|
||||
int ip_patch_read(void* a, uint8_t* b, size_t n) {
|
||||
DTInPlace* c = (DTInPlace*)a; if (c->ppos + (long)n > c->plen) return -DETOOLS_IO_FAILED;
|
||||
std::memcpy(b, c->patch + c->ppos, n); c->ppos += (long)n; return DETOOLS_OK;
|
||||
}
|
||||
} // namespace
|
||||
|
||||
TEST(Detools, SequentialCrlePatchReproducesTarget) {
|
||||
DTMem c{DT_BASE, (long)DT_BASE_LEN, 0, DT_PATCH, (long)DT_PATCH_LEN, 0, {}};
|
||||
int r = detools_apply_patch_callbacks(dt_from_read, dt_from_seek, dt_patch_read,
|
||||
(size_t)DT_PATCH_LEN, dt_to_write, &c);
|
||||
ASSERT_EQ(r, (int)DT_TARGET_LEN); // returns to-size on success
|
||||
ASSERT_EQ(c.out.size(), (size_t)DT_TARGET_LEN);
|
||||
EXPECT_EQ(0, std::memcmp(c.out.data(), DT_TARGET, DT_TARGET_LEN));
|
||||
}
|
||||
|
||||
TEST(Detools, WrongBaseDoesNotReproduceTarget) {
|
||||
// a base that differs from the one the patch was built against must NOT yield the target
|
||||
std::vector<uint8_t> bad(DT_BASE, DT_BASE + DT_BASE_LEN);
|
||||
for (size_t i = 0; i < bad.size(); i += 7) bad[i] ^= 0xFF;
|
||||
DTMem c{bad.data(), (long)bad.size(), 0, DT_PATCH, (long)DT_PATCH_LEN, 0, {}};
|
||||
int r = detools_apply_patch_callbacks(dt_from_read, dt_from_seek, dt_patch_read,
|
||||
(size_t)DT_PATCH_LEN, dt_to_write, &c);
|
||||
bool reproduced = (r == (int)DT_TARGET_LEN && c.out.size() == (size_t)DT_TARGET_LEN &&
|
||||
std::memcmp(c.out.data(), DT_TARGET, DT_TARGET_LEN) == 0);
|
||||
EXPECT_FALSE(reproduced); // wrong base -> wrong/short output (the device then fails image_hash)
|
||||
}
|
||||
|
||||
TEST(Detools, TruncatedPatchFails) {
|
||||
DTMem c{DT_BASE, (long)DT_BASE_LEN, 0, DT_PATCH, (long)(DT_PATCH_LEN / 2), 0, {}};
|
||||
int r = detools_apply_patch_callbacks(dt_from_read, dt_from_seek, dt_patch_read,
|
||||
(size_t)(DT_PATCH_LEN / 2), dt_to_write, &c);
|
||||
EXPECT_TRUE(r < 0 || c.out.size() != (size_t)DT_TARGET_LEN);
|
||||
}
|
||||
|
||||
// nRF52 path: the bootloader applies an in-place patch over the single app slot. Model the app
|
||||
// region as a DT_IP_MEM buffer holding the base; after apply, region[0:to_size] must equal the target.
|
||||
TEST(Detools, InPlaceCrlePatchReproducesTarget) {
|
||||
DTInPlace c; c.mem.assign(DT_IP_MEM, 0xFF);
|
||||
std::memcpy(c.mem.data(), DT_IP_BASE, DT_IP_BASE_LEN); // base loaded at offset 0
|
||||
c.patch = DT_IP_PATCH; c.plen = DT_IP_PATCH_LEN; c.ppos = 0; c.step = 0;
|
||||
int r = detools_apply_patch_in_place_callbacks(ip_mem_read, ip_mem_write, ip_mem_erase,
|
||||
ip_step_set, ip_step_get, ip_patch_read,
|
||||
(size_t)DT_IP_PATCH_LEN, &c);
|
||||
ASSERT_EQ(r, (int)DT_IP_TARGET_LEN); // returns to-size on success
|
||||
EXPECT_EQ(0, std::memcmp(c.mem.data(), DT_IP_TARGET, DT_IP_TARGET_LEN));
|
||||
}
|
||||
|
||||
int main(int argc, char** argv) {
|
||||
::testing::InitGoogleTest(&argc, argv);
|
||||
return RUN_ALL_TESTS();
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
# `mota` — MeshCore OTA packaging tool
|
||||
|
||||
Host-side tooling for building and validating `.mota` firmware-update containers.
|
||||
Implements the wire spec in [`docs/ota_protocol.md`](../../docs/ota_protocol.md) (v1).
|
||||
|
||||
Part of the OTA-over-LoRa work — see `OTA_PLAN.md` (milestone **P0**).
|
||||
|
||||
## Setup
|
||||
|
||||
Uses the repo's Python venv (`meshcore/`). Dependencies: `detools` (delta), `cryptography` (Ed25519).
|
||||
|
||||
```bash
|
||||
./meshcore/bin/pip install detools cryptography
|
||||
```
|
||||
|
||||
## Files
|
||||
|
||||
| File | What |
|
||||
|---|---|
|
||||
| `motalib.py` | Core logic: multihash, EndF, merkle tree+proofs, manifest/container build+parse+verify. No CLI; unit-tested; the reference implementation of the spec. |
|
||||
| `mota.py` | CLI: `keygen` / `build` / `inspect` / `verify`. |
|
||||
| `endf.py` | Standalone `EndF` trailer injector (idempotent). |
|
||||
| `pio_endf.py` | PlatformIO post-build hook to inject `EndF` (gated on `-D ENABLE_OTA`). |
|
||||
| `test_mota.py` | Tests (run directly or via pytest). |
|
||||
|
||||
## Usage
|
||||
|
||||
```bash
|
||||
PY=./meshcore/bin/python
|
||||
|
||||
# 1. one-time: generate a signing keypair (raw 32-byte hex)
|
||||
$PY tools/mota/mota.py keygen --out-priv signer.priv
|
||||
|
||||
# 2a. full image (e.g. ESP32 A/B) — payload IS the flashable image
|
||||
$PY tools/mota/mota.py build \
|
||||
--fw firmware.bin --target-id 0x11223344 --fw-version 1.16.0 \
|
||||
--codec full --sign signer.priv --out fw_v1.16.0_full.mota
|
||||
|
||||
# 2b. delta against a previous release (e.g. RAK4631) — small patch payload
|
||||
$PY tools/mota/mota.py build \
|
||||
--fw firmware_new.bin --base firmware_old.bin \
|
||||
--target-id 0x11223344 --fw-version 1.16.0 \
|
||||
--codec sequential --sign signer.priv --out fw_v1.16.0_delta.mota
|
||||
# --codec inplace for single-slot in-place apply (nRF52); params must match the bootloader contract
|
||||
|
||||
# 3. inspect / validate
|
||||
$PY tools/mota/mota.py inspect fw_v1.16.0_delta.mota
|
||||
$PY tools/mota/mota.py verify fw_v1.16.0_delta.mota --pub signer.priv.pub --base firmware_old.bin
|
||||
```
|
||||
|
||||
`build` notes:
|
||||
- `--fw` may be a plain `.bin`; the tool appends `EndF` if absent (idempotent).
|
||||
- For deltas, `base_hash` is taken from the base image's `EndF` and embedded so a device can confirm
|
||||
the delta applies to its current firmware.
|
||||
- `image_hash` (full SHA-256, signed) is the security anchor checked on the reconstructed image before
|
||||
flashing; the 4-byte merkle tree is for per-block transfer verification.
|
||||
- `--compression` (delta only) defaults to `crle` (decode-cheap). `lzma` gives smaller deltas but a
|
||||
heavier on-device decoder — the final choice is pinned by the bootloader/applier contract.
|
||||
|
||||
## Tests
|
||||
|
||||
```bash
|
||||
./meshcore/bin/python tools/mota/test_mota.py # 11 tests: EndF, merkle+proofs, full/delta,
|
||||
# signing, tamper detection, approval enforcement
|
||||
```
|
||||
|
||||
## `EndF` build integration
|
||||
|
||||
`EndF` must live in the **flashed** firmware (not just inside the `.mota`), because a node serves its
|
||||
own firmware and matches a delta's `base_hash` against its own `EndF`. Wiring:
|
||||
|
||||
- **ESP32 / RP2040** (emit `firmware.bin`): add `post:tools/mota/pio_endf.py` to the env's
|
||||
`extra_scripts` and define `-D ENABLE_OTA=1`. The hook appends `EndF` to the app `.bin` before merge.
|
||||
- **nRF52 / STM32** (emit `.hex` → `.uf2`): the `.hex` must be rewritten with the trailer at the image
|
||||
end before `create-uf2.py` runs. This path + the on-device round-trip is completed and validated in
|
||||
milestone **P2** (which builds/flashes the RAK4631). The byte logic is the same `motalib.ensure_endf`
|
||||
used everywhere.
|
||||
|
||||
Until build integration lands, `mota build` still produces correct containers (it appends `EndF` to the
|
||||
image it packages); only the *running* firmware's self-`EndF` depends on the build hook.
|
||||
@@ -0,0 +1,44 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
endf — append the MeshCore ``EndF`` trailer to a firmware image.
|
||||
|
||||
The trailer lets a running node discover its own firmware size/identity on any MCU:
|
||||
|
||||
EndF (16 bytes): "EndF"(4) | body_len(4 LE) | sha2-256:8(body)(8)
|
||||
|
||||
Standalone usage (idempotent — a no-op if a valid EndF is already present):
|
||||
|
||||
./meshcore/bin/python tools/mota/endf.py firmware.bin # in place
|
||||
./meshcore/bin/python tools/mota/endf.py firmware.bin out.bin # to a new file
|
||||
|
||||
As a PlatformIO post-build step (see tools/mota/README.md), wire it so that for OTA-enabled builds
|
||||
the flashed artifact carries the trailer. EndF must be in the FLASHED image (not just the .mota),
|
||||
because a node serves its own firmware and matches a delta's base against its own EndF.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import motalib as ml
|
||||
|
||||
|
||||
def inject(in_path: str, out_path: str | None = None) -> int:
|
||||
image = Path(in_path).read_bytes()
|
||||
if ml.has_endf(image):
|
||||
body, h8 = ml.parse_endf(image)
|
||||
print(f"EndF already present: body_len={len(body)} body_hash={h8.hex()} (no change)")
|
||||
out = image
|
||||
else:
|
||||
out, h8 = ml.ensure_endf(image)
|
||||
print(f"EndF appended: body_len={len(image)} body_hash={h8.hex()} "
|
||||
f"({len(image)} -> {len(out)} bytes)")
|
||||
Path(out_path or in_path).write_bytes(out)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
if len(sys.argv) not in (2, 3):
|
||||
sys.exit(__doc__)
|
||||
raise SystemExit(inject(sys.argv[1], sys.argv[2] if len(sys.argv) == 3 else None))
|
||||
@@ -0,0 +1,18 @@
|
||||
"""Extract the payload (bootable image) + manifest-fixed bytes from a firmware .bin for the apply test.
|
||||
Usage: extract_apply.py <firmware.bin> <signer.priv> <out_payload.bin> <out_manifest.bin>"""
|
||||
import sys
|
||||
import motalib as ml
|
||||
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
|
||||
|
||||
fw = open(sys.argv[1], "rb").read()
|
||||
priv = Ed25519PrivateKey.from_private_bytes(bytes.fromhex(open(sys.argv[2]).read().strip()))
|
||||
image, _ = ml.ensure_endf(fw) # payload = bootable image (+ EndF), what goes to the slot
|
||||
m = ml.build_manifest(target_id=0, fw_version=ml.pack_version("1.16.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)
|
||||
manifest_fixed = m.signed_region() + m.signature + m.approval # manifest WITHOUT leaves[]
|
||||
open(sys.argv[3], "wb").write(image)
|
||||
open(sys.argv[4], "wb").write(manifest_fixed)
|
||||
print(f"payload(image)={len(image)} manifest_fixed={len(manifest_fixed)}")
|
||||
print(f"image_hash={m.image_hash.hex()}")
|
||||
print(f"signer={priv.public_key().public_bytes_raw().hex()}")
|
||||
@@ -0,0 +1,175 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Generate a C++ cross-check header from the reference implementation (motalib).
|
||||
|
||||
Emits test/test_ota/mota_vectors.h containing a real .mota built by the Python tool plus the
|
||||
expected parse results and a merkle proof, so the device-side C++ core (src/helpers/ota/) is
|
||||
verified to agree byte-for-byte with the host packager.
|
||||
|
||||
Run: ./meshcore/bin/python tools/mota/gen_vectors.py
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import random
|
||||
from pathlib import Path
|
||||
|
||||
import motalib as ml
|
||||
|
||||
OUT = Path(__file__).resolve().parents[2] / "test" / "test_ota" / "mota_vectors.h"
|
||||
|
||||
|
||||
def _carr(name, data: bytes) -> str:
|
||||
body = ",".join(str(b) for b in data)
|
||||
return f"static const uint8_t {name}[{len(data)}] = {{{body}}};\n"
|
||||
|
||||
|
||||
def build_full():
|
||||
random.seed(42)
|
||||
fw = bytes(random.getrandbits(8) for _ in range(5 * 1024 + 137)) # 6 blocks, last short
|
||||
image, _ = ml.ensure_endf(fw)
|
||||
m = ml.build_manifest(
|
||||
target_id=0x11223344, fw_version=ml.pack_version("1.16.0"),
|
||||
image_size=len(image), payload=image, block_size=1024,
|
||||
image_hash=ml.mh32(image), codec_id=ml.CODEC_FULL, is_full=True)
|
||||
return ml.build_container(m, image), m, image
|
||||
|
||||
|
||||
def emit_proof_case(idx, count) -> str:
|
||||
"""A tree of `count` arbitrary 4-byte leaves + every block's proof, computed by the reference."""
|
||||
random.seed(1000 + count)
|
||||
leaves = [bytes(random.getrandbits(8) for _ in range(4)) for _ in range(count)]
|
||||
root = ml.merkle_root(leaves)
|
||||
blob = b""
|
||||
offs, nsibs = [], []
|
||||
for i in range(count):
|
||||
sib = ml.proof_siblings(leaves, i)
|
||||
assert ml.verify_proof(leaves[i], i, ml.merkle_proof(leaves, i), root, count), (count, i)
|
||||
offs.append(len(blob))
|
||||
nsibs.append(len(sib) // 4)
|
||||
blob += sib
|
||||
p = f"T{idx}"
|
||||
out = [
|
||||
f"static const uint32_t {p}_COUNT = {count}u;",
|
||||
_carr(f"{p}_LEAVES", b"".join(leaves)),
|
||||
_carr(f"{p}_ROOT", root),
|
||||
f"static const uint16_t {p}_POFF[{count}] = {{{','.join(str(o) for o in offs)}}};",
|
||||
f"static const uint8_t {p}_PNSIB[{count}] = {{{','.join(str(n) for n in nsibs)}}};",
|
||||
_carr(f"{p}_PBLOB", blob),
|
||||
]
|
||||
return "\n".join(out)
|
||||
|
||||
|
||||
def main():
|
||||
blob, m, image = build_full()
|
||||
leaves = ml.leaf_hashes(image, 1024)
|
||||
assert ml.merkle_root(leaves) == m.merkle_root
|
||||
|
||||
proof_idx = 2 if m.block_count > 2 else 0
|
||||
siblings = ml.proof_siblings(leaves, proof_idx)
|
||||
assert ml.verify_proof(leaves[proof_idx], proof_idx, ml.merkle_proof(leaves, proof_idx),
|
||||
m.merkle_root, len(leaves))
|
||||
|
||||
lines = [
|
||||
"// AUTO-GENERATED by tools/mota/gen_vectors.py — do not edit by hand.",
|
||||
"// Cross-check vectors: a real .mota from the reference packager (motalib.py).",
|
||||
"#pragma once",
|
||||
"#include <stdint.h>",
|
||||
"",
|
||||
_carr("MOTA_VEC", blob),
|
||||
f"static const uint32_t MOTA_VEC_LEN = {len(blob)};",
|
||||
f"static const uint32_t EXP_TARGET_ID = 0x{m.target_id:08x}u;",
|
||||
f"static const uint32_t EXP_FW_VERSION = 0x{m.fw_version:08x}u;",
|
||||
f"static const uint32_t EXP_IMAGE_SIZE = {m.image_size}u;",
|
||||
f"static const uint32_t EXP_PAYLOAD_SIZE = {m.payload_size}u;",
|
||||
f"static const uint32_t EXP_BLOCK_COUNT = {m.block_count}u;",
|
||||
f"static const uint8_t EXP_BLOCK_SIZE_LOG2 = {m.block_size_log2};",
|
||||
f"static const uint8_t EXP_CODEC_ID = {m.codec_id};",
|
||||
_carr("EXP_MERKLE_ROOT", m.merkle_root),
|
||||
_carr("EXP_IMAGE_HASH", m.image_hash),
|
||||
f"static const uint32_t PROOF_INDEX = {proof_idx}u;",
|
||||
f"static const uint8_t PROOF_NSIB = {len(siblings)//4};",
|
||||
_carr("PROOF_SIBLINGS", siblings),
|
||||
]
|
||||
|
||||
# exhaustive per-index proof cases for several tricky counts (deep promotion chains)
|
||||
counts = [5, 7, 8, 65, 100, 255, 256]
|
||||
for i, c in enumerate(counts):
|
||||
lines.append(emit_proof_case(i, c))
|
||||
lines.append("struct ProofCase { uint32_t count; const uint8_t* leaves; const uint8_t* root;"
|
||||
" const uint16_t* poff; const uint8_t* pnsib; const uint8_t* pblob; };")
|
||||
rows = ",".join(f"{{T{i}_COUNT,T{i}_LEAVES,T{i}_ROOT,T{i}_POFF,T{i}_PNSIB,T{i}_PBLOB}}"
|
||||
for i in range(len(counts)))
|
||||
lines.append(f"static const ProofCase PROOF_CASES[] = {{{rows}}};")
|
||||
lines.append(f"static const int N_PROOF_CASES = {len(counts)};")
|
||||
|
||||
# small-block signed .mota for the host transfer simulation (each block fits one packet => no
|
||||
# fragmentation needed to validate the manager/protocol end-to-end). Deterministic signing key.
|
||||
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
|
||||
random.seed(99)
|
||||
sim_fw = bytes(random.getrandbits(8) for _ in range(2000))
|
||||
sim_image, _ = ml.ensure_endf(sim_fw)
|
||||
sim_priv = Ed25519PrivateKey.from_private_bytes(bytes(range(32)))
|
||||
sm = ml.build_manifest(target_id=0xCAFEBABE, fw_version=ml.pack_version("3.0.0"),
|
||||
image_size=len(sim_image), payload=sim_image, block_size=128,
|
||||
image_hash=ml.mh32(sim_image), codec_id=ml.CODEC_FULL,
|
||||
is_full=True, sign_priv=sim_priv)
|
||||
sim_blob = ml.build_container(sm, sim_image)
|
||||
lines.append("// small-block signed .mota for the OtaManager host transfer simulation")
|
||||
lines.append(_carr("SIM_MOTA", sim_blob))
|
||||
lines.append(f"static const uint32_t SIM_MOTA_LEN = {len(sim_blob)};")
|
||||
lines.append(f"static const uint32_t SIM_TARGET_ID = 0x{sm.target_id:08x}u;")
|
||||
|
||||
# detools sequential+crle delta vector: base image, the real detools 0.53.0 patch, and the
|
||||
# expected target image. The native test applies DT_PATCH to DT_BASE with the *vendored detools
|
||||
# C decoder* (src/helpers/ota/detools) and must reproduce DT_TARGET byte-for-byte -- proving the
|
||||
# on-device delta apply path uses detools, not a reimplementation.
|
||||
import io
|
||||
import detools
|
||||
random.seed(2024)
|
||||
dt_base_body = bytes(random.getrandbits(8) for _ in range(3000))
|
||||
dt_tgt = bytearray(dt_base_body)
|
||||
for off in (137, 138, 139, 1500, 2999): # localized edits, version-bump style
|
||||
dt_tgt[off] ^= 0x5A
|
||||
dt_tgt += bytes(random.getrandbits(8) for _ in range(200)) # small appended tail
|
||||
dt_base_img, _ = ml.ensure_endf(dt_base_body)
|
||||
dt_tgt_img, _ = ml.ensure_endf(bytes(dt_tgt))
|
||||
fp = io.BytesIO()
|
||||
detools.create_patch(io.BytesIO(dt_base_img), io.BytesIO(dt_tgt_img), fp,
|
||||
patch_type="sequential", compression="crle")
|
||||
dt_patch = fp.getvalue()
|
||||
lines.append("// detools sequential+crle delta: apply DT_PATCH to DT_BASE -> DT_TARGET")
|
||||
lines.append(_carr("DT_BASE", dt_base_img))
|
||||
lines.append(f"static const uint32_t DT_BASE_LEN = {len(dt_base_img)};")
|
||||
lines.append(_carr("DT_PATCH", dt_patch))
|
||||
lines.append(f"static const uint32_t DT_PATCH_LEN = {len(dt_patch)};")
|
||||
lines.append(_carr("DT_TARGET", dt_tgt_img))
|
||||
lines.append(f"static const uint32_t DT_TARGET_LEN = {len(dt_tgt_img)};")
|
||||
|
||||
# detools IN-PLACE+crle delta (nRF52 single-slot apply): apply DT_IP_PATCH to a memory region
|
||||
# holding DT_IP_BASE -> region[0:to_size] == DT_IP_TARGET. The native test runs the vendored
|
||||
# in-place decoder over a DT_IP_MEM-byte RAM buffer, exactly as the bootloader will over flash.
|
||||
DT_IP_MEM, DT_IP_SEG = 0x8000, 0x1000
|
||||
ip_patch_io = io.BytesIO()
|
||||
detools.create_patch(io.BytesIO(dt_base_img), io.BytesIO(dt_tgt_img), ip_patch_io,
|
||||
patch_type="in-place", memory_size=DT_IP_MEM, segment_size=DT_IP_SEG,
|
||||
compression="crle")
|
||||
dt_ip_patch = ip_patch_io.getvalue()
|
||||
lines.append("// detools in-place+crle delta: apply DT_IP_PATCH over a DT_IP_MEM buffer holding DT_IP_BASE")
|
||||
lines.append(_carr("DT_IP_BASE", dt_base_img))
|
||||
lines.append(f"static const uint32_t DT_IP_BASE_LEN = {len(dt_base_img)};")
|
||||
lines.append(_carr("DT_IP_PATCH", dt_ip_patch))
|
||||
lines.append(f"static const uint32_t DT_IP_PATCH_LEN = {len(dt_ip_patch)};")
|
||||
lines.append(_carr("DT_IP_TARGET", dt_tgt_img))
|
||||
lines.append(f"static const uint32_t DT_IP_TARGET_LEN = {len(dt_tgt_img)};")
|
||||
lines.append(f"static const uint32_t DT_IP_MEM = {DT_IP_MEM}u;")
|
||||
|
||||
OUT.parent.mkdir(parents=True, exist_ok=True)
|
||||
OUT.write_text("\n".join(lines) + "\n")
|
||||
print(f"wrote {OUT}")
|
||||
print(f" blob={len(blob)}B blocks={m.block_count} proof_idx={proof_idx} nsib={len(siblings)//4}")
|
||||
print(f" proof cases: counts={counts}")
|
||||
print(f" detools delta: base={len(dt_base_img)}B target={len(dt_tgt_img)}B seq_patch={len(dt_patch)}B inplace_patch={len(dt_ip_patch)}B")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,224 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
mota — build / inspect / verify MeshCore ``.mota`` firmware-update containers.
|
||||
|
||||
Implements docs/ota_protocol.md (v1). Run with the meshcore venv:
|
||||
|
||||
./meshcore/bin/python tools/mota/mota.py <command> ...
|
||||
|
||||
Commands:
|
||||
keygen generate an Ed25519 signing keypair (raw 32-byte hex)
|
||||
build build a .mota from a firmware image (full, or delta against a base)
|
||||
inspect print a .mota's manifest fields
|
||||
verify validate a .mota (magic/trailer/merkle/signature[/delta vs base])
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import io
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import motalib as ml
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# key helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _load_priv(path):
|
||||
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
|
||||
raw = bytes.fromhex(Path(path).read_text().strip())
|
||||
if len(raw) != 32:
|
||||
sys.exit(f"private key must be 32 raw bytes (64 hex chars), got {len(raw)}")
|
||||
return Ed25519PrivateKey.from_private_bytes(raw)
|
||||
|
||||
|
||||
def _parse_target_id(s) -> int:
|
||||
return int(s, 0) & 0xFFFFFFFF # accepts 0x.. or decimal
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# commands
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def cmd_keygen(args):
|
||||
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
|
||||
priv = Ed25519PrivateKey.generate()
|
||||
priv_hex = priv.private_bytes_raw().hex()
|
||||
pub_hex = priv.public_key().public_bytes_raw().hex()
|
||||
if args.out_priv:
|
||||
Path(args.out_priv).write_text(priv_hex + "\n")
|
||||
Path(args.out_pub or (args.out_priv + ".pub")).write_text(pub_hex + "\n")
|
||||
print(f"private -> {args.out_priv}")
|
||||
print(f"public -> {args.out_pub or (args.out_priv + '.pub')}")
|
||||
print(f"pubkey: {pub_hex}")
|
||||
|
||||
|
||||
def _resolve_target_id(args) -> int:
|
||||
if args.target_env:
|
||||
return ml.target_id_for_env(args.target_env)
|
||||
if args.target_id:
|
||||
return _parse_target_id(args.target_id)
|
||||
sys.exit("provide --target-id or --target-env")
|
||||
|
||||
|
||||
def cmd_build(args):
|
||||
fw = Path(args.fw).read_bytes()
|
||||
new_image, _ = ml.ensure_endf(fw) # reconstructed image == BODY || EndF
|
||||
image_hash = ml.mh32(new_image)
|
||||
image_size = len(new_image)
|
||||
|
||||
codec_map = {"full": ml.CODEC_FULL,
|
||||
"sequential": ml.CODEC_DETOOLS_SEQUENTIAL,
|
||||
"inplace": ml.CODEC_DETOOLS_INPLACE}
|
||||
codec_id = codec_map[args.codec]
|
||||
is_full = codec_id == ml.CODEC_FULL
|
||||
|
||||
base_hash = None
|
||||
if is_full:
|
||||
if args.base:
|
||||
sys.exit("--base is only for delta codecs")
|
||||
payload = new_image
|
||||
else:
|
||||
if not args.base:
|
||||
sys.exit("delta codec requires --base <old-firmware.bin>")
|
||||
old_image, base_hash = ml.ensure_endf(Path(args.base).read_bytes())
|
||||
payload = _make_delta(old_image, new_image, args.codec, args.compression, args)
|
||||
|
||||
sign_priv = _load_priv(args.sign) if args.sign else None
|
||||
|
||||
manifest = ml.build_manifest(
|
||||
target_id=_resolve_target_id(args),
|
||||
fw_version=ml.pack_version(args.fw_version),
|
||||
image_size=image_size,
|
||||
payload=payload,
|
||||
block_size=args.block_size,
|
||||
image_hash=image_hash,
|
||||
codec_id=codec_id,
|
||||
is_full=is_full,
|
||||
base_hash=base_hash,
|
||||
sign_priv=sign_priv,
|
||||
)
|
||||
blob = ml.build_container(manifest, payload)
|
||||
Path(args.out).write_bytes(blob)
|
||||
|
||||
print(f"wrote {args.out} ({len(blob)} bytes)")
|
||||
print(f" codec : {ml.CODEC_NAMES[codec_id]}")
|
||||
print(f" payload : {len(payload)} bytes ({manifest.block_count} blocks of {args.block_size})")
|
||||
print(f" image_size : {image_size} bytes (BODY+EndF)")
|
||||
print(f" merkle_root : {manifest.merkle_root.hex()}")
|
||||
print(f" image_hash : {manifest.image_hash.hex()}")
|
||||
if base_hash:
|
||||
print(f" base_hash : {base_hash.hex()}")
|
||||
print(f" signed : {manifest.is_signed}"
|
||||
+ (f" by {manifest.signer_pubkey.hex()}" if manifest.is_signed else ""))
|
||||
|
||||
|
||||
def _make_delta(old_image: bytes, new_image: bytes, codec: str, compression: str, args) -> bytes:
|
||||
import detools
|
||||
patch_type = "in-place" if codec == "inplace" else "sequential"
|
||||
fp = io.BytesIO()
|
||||
kwargs = dict(patch_type=patch_type, compression=compression)
|
||||
if patch_type == "in-place":
|
||||
# bounded-scratch params — MUST match the bootloader's applier contract (TBD with the fork).
|
||||
kwargs.update(memory_size=args.inplace_memory,
|
||||
segment_size=args.inplace_segment)
|
||||
detools.create_patch(io.BytesIO(old_image), io.BytesIO(new_image), fp, **kwargs)
|
||||
delta = fp.getvalue()
|
||||
full = len(new_image)
|
||||
print(f" delta : {len(delta)} bytes ({100*len(delta)/full:.1f}% of full {full})")
|
||||
return delta
|
||||
|
||||
|
||||
def cmd_inspect(args):
|
||||
parsed = ml.parse_container(Path(args.mota).read_bytes())
|
||||
m = parsed.manifest
|
||||
print(f"total_size : {parsed.total_size}")
|
||||
print(f"format_ver : {m.format_ver}")
|
||||
print(f"flags : 0x{m.flags:02x} FULL={m.is_full} SIGNED={m.is_signed}")
|
||||
print(f"hash_algo : 0x{m.hash_algo:02x} (sha2-256)")
|
||||
print(f"target_id : 0x{m.target_id:08x}")
|
||||
print(f"fw_version : {ml.unpack_version(m.fw_version)} (0x{m.fw_version:08x})")
|
||||
print(f"image_size : {m.image_size}")
|
||||
print(f"payload_size : {m.payload_size}")
|
||||
print(f"block_size : {m.block_size} (log2={m.block_size_log2}) block_count={m.block_count}")
|
||||
print(f"codec_id : {m.codec_id} ({ml.CODEC_NAMES.get(m.codec_id, '?')})")
|
||||
print(f"merkle_root : {m.merkle_root.hex()}")
|
||||
print(f"image_hash : {m.image_hash.hex()}")
|
||||
if m.base_hash:
|
||||
print(f"base_hash : {m.base_hash.hex()}")
|
||||
if m.is_signed:
|
||||
print(f"signer_pubkey : {m.signer_pubkey.hex()}")
|
||||
print(f"signature : {m.signature.hex()}")
|
||||
approved = m.approval == ml.APPROVAL_YES
|
||||
print(f"approval : {m.approval.hex()} ({'APPROVED' if approved else 'not approved'})")
|
||||
print(f"leaves[] : {len(m.leaves)} x 4 bytes")
|
||||
|
||||
|
||||
def cmd_verify(args):
|
||||
parsed = ml.parse_container(Path(args.mota).read_bytes())
|
||||
expect_pub = bytes.fromhex(Path(args.pub).read_text().strip()) if args.pub else None
|
||||
base_image = Path(args.base).read_bytes() if args.base else None
|
||||
problems = ml.verify(parsed, expect_pub=expect_pub, base_image=base_image)
|
||||
if problems:
|
||||
print("INVALID:")
|
||||
for p in problems:
|
||||
print(f" - {p}")
|
||||
sys.exit(1)
|
||||
print("OK — container, merkle tree"
|
||||
+ (", signature" if parsed.manifest.is_signed else "")
|
||||
+ (", delta->image_hash" if (base_image and not parsed.manifest.is_full) else
|
||||
(", image_hash" if parsed.manifest.is_full else ""))
|
||||
+ " all valid.")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# argparse
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def main(argv=None):
|
||||
p = argparse.ArgumentParser(prog="mota", description="MeshCore .mota packaging tool")
|
||||
sub = p.add_subparsers(dest="cmd", required=True)
|
||||
|
||||
g = sub.add_parser("keygen", help="generate an Ed25519 signing keypair")
|
||||
g.add_argument("--out-priv", help="write 32-byte private key (hex) here")
|
||||
g.add_argument("--out-pub", help="write 32-byte public key (hex) here")
|
||||
g.set_defaults(func=cmd_keygen)
|
||||
|
||||
b = sub.add_parser("build", help="build a .mota")
|
||||
b.add_argument("--fw", required=True, help="new firmware image (.bin / EndF appended if absent)")
|
||||
b.add_argument("--out", required=True, help="output .mota path")
|
||||
b.add_argument("--target-id", help="target_id (0x.. or decimal)")
|
||||
b.add_argument("--target-env", help="PlatformIO env name; target_id = sha2-256:4(env) "
|
||||
"(matches build.sh / device getOtaTargetId)")
|
||||
b.add_argument("--fw-version", required=True, help="e.g. 1.16.0 (or .pre as 1.16.0.2)")
|
||||
b.add_argument("--codec", choices=["full", "sequential", "inplace"], default="full")
|
||||
b.add_argument("--base", help="base firmware (.bin) for delta codecs")
|
||||
b.add_argument("--compression", default="crle",
|
||||
choices=["none", "crle", "lz4", "zstd", "lzma", "bz2"],
|
||||
help="delta patch compression (decode-cheap 'crle' default; must be supported by "
|
||||
"the applier. Ignored for --codec full, whose payload is the raw flashable image)")
|
||||
b.add_argument("--block-size", type=int, default=ml.DEFAULT_BLOCK_SIZE)
|
||||
b.add_argument("--sign", help="Ed25519 private key file (hex) to sign the manifest")
|
||||
b.add_argument("--inplace-memory", type=int, default=4096, help="detools in-place memory_size")
|
||||
b.add_argument("--inplace-segment", type=int, default=4096, help="detools in-place segment_size")
|
||||
b.set_defaults(func=cmd_build)
|
||||
|
||||
i = sub.add_parser("inspect", help="dump a .mota manifest")
|
||||
i.add_argument("mota")
|
||||
i.set_defaults(func=cmd_inspect)
|
||||
|
||||
v = sub.add_parser("verify", help="validate a .mota")
|
||||
v.add_argument("mota")
|
||||
v.add_argument("--pub", help="expected signer public key file (hex)")
|
||||
v.add_argument("--base", help="base firmware to fully validate a delta -> image_hash")
|
||||
v.set_defaults(func=cmd_verify)
|
||||
|
||||
args = p.parse_args(argv)
|
||||
args.func(args)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,457 @@
|
||||
"""
|
||||
motalib — build/parse/verify MeshCore ``.mota`` firmware-update containers.
|
||||
|
||||
Pure logic, no CLI. Implements docs/ota_protocol.md (v1, format_ver=1).
|
||||
|
||||
The wire format (all integers little-endian):
|
||||
|
||||
container = MAGIC(4) | MOTA_TOTAL_SIZE(4) | MANIFEST | PAYLOAD | TRAILER(5)
|
||||
|
||||
manifest = format_ver(1) flags(1) hash_algo(1) target_id(4) fw_version(4)
|
||||
image_size(4) payload_size(4) block_size_log2(1) merkle_root(4)
|
||||
image_hash(32) codec_id(1)
|
||||
[base_hash(8) if delta] [signer_pubkey(32) signature(64) if signed]
|
||||
approval(4) leaves[](4*BC)
|
||||
|
||||
Hashes are SHA-256, truncated per multihash convention (sha2-256:N = first N bytes).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import io
|
||||
import struct
|
||||
from dataclasses import dataclass, field
|
||||
from typing import List, Optional, Tuple
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Reference constants (must match docs/ota_protocol.md and the device code)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
MAGIC = b"mOTA" # 6D 4F 54 41
|
||||
TRAILER = b"vk496" # 76 6B 34 39 36
|
||||
ENDF_MAGIC = b"EndF" # 45 6E 64 46
|
||||
ENDF_LEN = 16 # marker(4) + body_len(4) + body_hash8(8)
|
||||
|
||||
FORMAT_VER = 1
|
||||
HASH_ALGO_SHA256 = 0x12 # multihash code for sha2-256
|
||||
|
||||
FLAG_FULL = 0x01
|
||||
FLAG_SIGNED = 0x02
|
||||
|
||||
CODEC_FULL = 0
|
||||
CODEC_DETOOLS_SEQUENTIAL = 1 # detools `sequential` patch (decoded on-device by vendored detools C)
|
||||
CODEC_DETOOLS_INPLACE = 2 # detools `in-place` patch (nRF52 bootloader-handoff path; TBD)
|
||||
CODEC_NAMES = {CODEC_FULL: "full", CODEC_DETOOLS_SEQUENTIAL: "detools-sequential",
|
||||
CODEC_DETOOLS_INPLACE: "detools-in-place"}
|
||||
|
||||
APPROVAL_NOT = b"\xff\xff\xff\xff" # erased = not approved
|
||||
APPROVAL_YES = b"APRV" # 41 50 52 56 = approved
|
||||
|
||||
DEFAULT_BLOCK_SIZE = 1024
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Multihash helpers (sha2-256 truncations)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def sha256(data: bytes) -> bytes:
|
||||
return hashlib.sha256(data).digest()
|
||||
|
||||
|
||||
def mh4(data: bytes) -> bytes:
|
||||
return hashlib.sha256(data).digest()[:4]
|
||||
|
||||
|
||||
def mh8(data: bytes) -> bytes:
|
||||
return hashlib.sha256(data).digest()[:8]
|
||||
|
||||
|
||||
def mh32(data: bytes) -> bytes:
|
||||
return hashlib.sha256(data).digest()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# fw_version packing
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def pack_version(s) -> int:
|
||||
"""'1.16.0' or '1.16.0.2' -> uint32 (MAJOR<<24|MINOR<<16|PATCH<<8|pre). Ints pass through."""
|
||||
if isinstance(s, int):
|
||||
return s & 0xFFFFFFFF
|
||||
s = s.strip().lstrip("vV")
|
||||
parts = [int(p) for p in s.split(".")]
|
||||
parts += [0] * (4 - len(parts))
|
||||
maj, mnr, pat, pre = parts[:4]
|
||||
return ((maj & 0xFF) << 24) | ((mnr & 0xFF) << 16) | ((pat & 0xFF) << 8) | (pre & 0xFF)
|
||||
|
||||
|
||||
def unpack_version(v: int) -> str:
|
||||
return f"{(v >> 24) & 0xFF}.{(v >> 16) & 0xFF}.{(v >> 8) & 0xFF}.{v & 0xFF}"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# target_id
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def target_id_for_env(env_name: str) -> int:
|
||||
"""4-byte build-target id = sha2-256:4(env_name), little-endian uint32.
|
||||
|
||||
The PlatformIO env name (e.g. 'RAK_4631_companion_radio_usb') uniquely captures hardware AND
|
||||
role/partition layout. build.sh injects the same value as -D MOTA_TARGET_ID so the device's
|
||||
getOtaTargetId() matches what the packager stamps into the manifest. (Must match build.sh.)
|
||||
"""
|
||||
d = hashlib.sha256(env_name.encode()).digest()[:4]
|
||||
return int.from_bytes(d, "little")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# EndF trailer
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def build_endf(body: bytes) -> bytes:
|
||||
"""The 16-byte EndF trailer for a firmware BODY."""
|
||||
return ENDF_MAGIC + struct.pack("<I", len(body)) + mh8(body)
|
||||
|
||||
|
||||
def has_endf(image: bytes) -> bool:
|
||||
"""True iff `image` ends with a self-consistent EndF trailer (image == BODY || EndF)."""
|
||||
if len(image) < ENDF_LEN:
|
||||
return False
|
||||
trailer = image[-ENDF_LEN:]
|
||||
if trailer[:4] != ENDF_MAGIC:
|
||||
return False
|
||||
body_len = struct.unpack("<I", trailer[4:8])[0]
|
||||
if body_len != len(image) - ENDF_LEN:
|
||||
return False
|
||||
return trailer[8:16] == mh8(image[:-ENDF_LEN])
|
||||
|
||||
|
||||
def parse_endf(image: bytes) -> Tuple[bytes, bytes]:
|
||||
"""Return (body, body_hash8) for an image that ends with a valid EndF. Raises otherwise."""
|
||||
if not has_endf(image):
|
||||
raise ValueError("image has no valid EndF trailer")
|
||||
return image[:-ENDF_LEN], image[-8:]
|
||||
|
||||
|
||||
def ensure_endf(image: bytes) -> Tuple[bytes, bytes]:
|
||||
"""Return (image_with_endf, body_hash8). Appends EndF if not already present."""
|
||||
if has_endf(image):
|
||||
return image, image[-8:]
|
||||
body_hash8 = mh8(image)
|
||||
return image + build_endf(image), body_hash8
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Merkle tree (sha2-256:4 leaves/nodes, promote-odd, no padding)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def block_count(payload_size: int, block_size: int) -> int:
|
||||
return (payload_size + block_size - 1) // block_size
|
||||
|
||||
|
||||
def leaf_hashes(payload: bytes, block_size: int) -> List[bytes]:
|
||||
return [mh4(payload[i:i + block_size]) for i in range(0, len(payload), block_size)]
|
||||
|
||||
|
||||
def merkle_root(leaves: List[bytes]) -> bytes:
|
||||
if not leaves:
|
||||
raise ValueError("empty payload / no leaves")
|
||||
level = list(leaves)
|
||||
while len(level) > 1:
|
||||
nxt = []
|
||||
n = len(level)
|
||||
for i in range(0, n, 2):
|
||||
if i + 1 < n:
|
||||
nxt.append(mh4(level[i] + level[i + 1]))
|
||||
else:
|
||||
nxt.append(level[i]) # promote lone last node unchanged
|
||||
level = nxt
|
||||
return level[0]
|
||||
|
||||
|
||||
def merkle_proof(leaves: List[bytes], index: int) -> List[Tuple[bytes, bool]]:
|
||||
"""Proof for block `index`: list of (sibling_digest, sibling_is_left)."""
|
||||
proof: List[Tuple[bytes, bool]] = []
|
||||
level = list(leaves)
|
||||
idx = index
|
||||
while len(level) > 1:
|
||||
n = len(level)
|
||||
is_last_odd = (n % 2 == 1) and (idx == n - 1)
|
||||
if not is_last_odd:
|
||||
if idx % 2 == 0:
|
||||
proof.append((level[idx + 1], False)) # sibling on the right
|
||||
else:
|
||||
proof.append((level[idx - 1], True)) # sibling on the left
|
||||
nxt = [mh4(level[i] + level[i + 1]) if i + 1 < n else level[i]
|
||||
for i in range(0, n, 2)]
|
||||
idx //= 2
|
||||
level = nxt
|
||||
return proof
|
||||
|
||||
|
||||
def proof_siblings(leaves: List[bytes], index: int) -> bytes:
|
||||
"""Wire form of a proof: just the ordered sibling digests, concatenated.
|
||||
|
||||
The left/right direction is derived by the verifier from the block index + count
|
||||
(sibling is on the left iff the current index is odd), so no direction bits are sent.
|
||||
"""
|
||||
return b"".join(sib for sib, _ in merkle_proof(leaves, index))
|
||||
|
||||
|
||||
def verify_proof(leaf: bytes, index: int, proof: List[Tuple[bytes, bool]],
|
||||
root: bytes, count: int) -> bool:
|
||||
h = leaf
|
||||
idx = index
|
||||
n = count
|
||||
p = 0
|
||||
while n > 1:
|
||||
is_last_odd = (n % 2 == 1) and (idx == n - 1)
|
||||
if is_last_odd:
|
||||
pass # promoted, no proof element
|
||||
else:
|
||||
if p >= len(proof):
|
||||
return False
|
||||
sib, is_left = proof[p]
|
||||
p += 1
|
||||
h = mh4(sib + h) if is_left else mh4(h + sib)
|
||||
idx //= 2
|
||||
n = (n + 1) // 2
|
||||
return h == root and p == len(proof)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Manifest + container
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@dataclass
|
||||
class Manifest:
|
||||
format_ver: int = FORMAT_VER
|
||||
flags: int = 0
|
||||
hash_algo: int = HASH_ALGO_SHA256
|
||||
target_id: int = 0
|
||||
fw_version: int = 0
|
||||
image_size: int = 0
|
||||
payload_size: int = 0
|
||||
block_size_log2: int = 10
|
||||
merkle_root: bytes = b"\0\0\0\0"
|
||||
image_hash: bytes = b"\0" * 32
|
||||
codec_id: int = CODEC_FULL
|
||||
base_hash: Optional[bytes] = None # 8 bytes, delta only
|
||||
signer_pubkey: Optional[bytes] = None # 32 bytes, signed only
|
||||
signature: Optional[bytes] = None # 64 bytes, signed only
|
||||
approval: bytes = APPROVAL_NOT
|
||||
leaves: List[bytes] = field(default_factory=list)
|
||||
|
||||
@property
|
||||
def is_full(self) -> bool:
|
||||
return bool(self.flags & FLAG_FULL)
|
||||
|
||||
@property
|
||||
def is_signed(self) -> bool:
|
||||
return bool(self.flags & FLAG_SIGNED)
|
||||
|
||||
@property
|
||||
def block_size(self) -> int:
|
||||
return 1 << self.block_size_log2
|
||||
|
||||
@property
|
||||
def block_count(self) -> int:
|
||||
return block_count(self.payload_size, self.block_size)
|
||||
|
||||
def signed_region(self) -> bytes:
|
||||
"""Bytes the Ed25519 signature covers: everything from format_ver up to (not incl.) signature."""
|
||||
out = bytearray()
|
||||
out += bytes([self.format_ver, self.flags, self.hash_algo])
|
||||
out += struct.pack("<IIII", self.target_id, self.fw_version,
|
||||
self.image_size, self.payload_size)
|
||||
out += bytes([self.block_size_log2])
|
||||
out += self.merkle_root
|
||||
out += self.image_hash
|
||||
out += bytes([self.codec_id])
|
||||
if not self.is_full:
|
||||
out += self.base_hash
|
||||
if self.is_signed:
|
||||
out += self.signer_pubkey
|
||||
return bytes(out)
|
||||
|
||||
def serialize(self) -> bytes:
|
||||
out = bytearray(self.signed_region())
|
||||
if self.is_signed:
|
||||
out += self.signature
|
||||
out += self.approval
|
||||
for lf in self.leaves:
|
||||
out += lf
|
||||
return bytes(out)
|
||||
|
||||
|
||||
def _validate_lengths(m: Manifest):
|
||||
assert len(m.merkle_root) == 4
|
||||
assert len(m.image_hash) == 32
|
||||
assert len(m.approval) == 4
|
||||
if not m.is_full:
|
||||
assert m.base_hash is not None and len(m.base_hash) == 8, "delta requires 8-byte base_hash"
|
||||
if m.is_signed:
|
||||
assert m.signer_pubkey is not None and len(m.signer_pubkey) == 32
|
||||
assert m.signature is not None and len(m.signature) == 64
|
||||
|
||||
|
||||
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) -> Manifest:
|
||||
assert (block_size & (block_size - 1)) == 0, "block_size must be a power of two"
|
||||
leaves = leaf_hashes(payload, block_size)
|
||||
m = Manifest(
|
||||
flags=(FLAG_FULL if is_full else 0) | (FLAG_SIGNED if sign_priv is not None else 0),
|
||||
target_id=target_id,
|
||||
fw_version=fw_version,
|
||||
image_size=image_size,
|
||||
payload_size=len(payload),
|
||||
block_size_log2=block_size.bit_length() - 1,
|
||||
merkle_root=merkle_root(leaves),
|
||||
image_hash=image_hash,
|
||||
codec_id=codec_id,
|
||||
base_hash=None if is_full else base_hash,
|
||||
leaves=leaves,
|
||||
)
|
||||
if sign_priv is not None:
|
||||
m.signer_pubkey = sign_priv.public_key().public_bytes_raw()
|
||||
m.signature = sign_priv.sign(m.signed_region())
|
||||
_validate_lengths(m)
|
||||
return m
|
||||
|
||||
|
||||
def build_container(manifest: Manifest, payload: bytes) -> bytes:
|
||||
assert len(payload) == manifest.payload_size
|
||||
mser = manifest.serialize()
|
||||
total = 4 + 4 + len(mser) + len(payload) + len(TRAILER)
|
||||
return MAGIC + struct.pack("<I", total) + mser + payload + TRAILER
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Parsing
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@dataclass
|
||||
class Parsed:
|
||||
manifest: Manifest
|
||||
payload: bytes
|
||||
total_size: int
|
||||
|
||||
|
||||
def parse_container(blob: bytes) -> Parsed:
|
||||
if blob[:4] != MAGIC:
|
||||
raise ValueError("bad MAGIC")
|
||||
if blob[-5:] != TRAILER:
|
||||
raise ValueError("bad TRAILER")
|
||||
total = struct.unpack("<I", blob[4:8])[0]
|
||||
if total != len(blob):
|
||||
raise ValueError(f"MOTA_TOTAL_SIZE {total} != actual {len(blob)}")
|
||||
|
||||
r = io.BytesIO(blob[8:-5]) # manifest + payload (trailer already validated/stripped)
|
||||
|
||||
def take(n):
|
||||
b = r.read(n)
|
||||
if len(b) != n:
|
||||
raise ValueError("truncated manifest")
|
||||
return b
|
||||
|
||||
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]
|
||||
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]
|
||||
m.merkle_root = take(4)
|
||||
m.image_hash = take(32)
|
||||
m.codec_id = take(1)[0]
|
||||
if not m.is_full:
|
||||
m.base_hash = take(8)
|
||||
if m.is_signed:
|
||||
m.signer_pubkey = take(32)
|
||||
m.signature = take(64)
|
||||
m.approval = take(4)
|
||||
bc = m.block_count
|
||||
m.leaves = [take(4) for _ in range(bc)]
|
||||
|
||||
payload = r.read(m.payload_size)
|
||||
if len(payload) != m.payload_size:
|
||||
raise ValueError("truncated payload")
|
||||
rest = r.read() # trailer was stripped above, so nothing should remain
|
||||
if rest != b"":
|
||||
raise ValueError("trailing bytes after payload")
|
||||
return Parsed(manifest=m, payload=payload, total_size=total)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Verification
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def verify(parsed: Parsed, *, expect_pub: Optional[bytes] = None,
|
||||
base_image: Optional[bytes] = None) -> List[str]:
|
||||
"""Return a list of problem strings (empty == fully valid for what could be checked)."""
|
||||
problems: List[str] = []
|
||||
m, payload = parsed.manifest, parsed.payload
|
||||
|
||||
# block_count / leaves
|
||||
if len(m.leaves) != m.block_count:
|
||||
problems.append(f"leaves count {len(m.leaves)} != block_count {m.block_count}")
|
||||
|
||||
# merkle root must match recomputation from the actual payload blocks
|
||||
recomputed_leaves = leaf_hashes(payload, m.block_size)
|
||||
if recomputed_leaves != m.leaves:
|
||||
problems.append("stored leaves[] do not match payload blocks")
|
||||
try:
|
||||
if merkle_root(recomputed_leaves) != m.merkle_root:
|
||||
problems.append("merkle_root does not match payload")
|
||||
except ValueError as e:
|
||||
problems.append(f"merkle: {e}")
|
||||
|
||||
# spot-check a proof round-trips (block 0 and last)
|
||||
if recomputed_leaves:
|
||||
for idx in {0, len(recomputed_leaves) - 1}:
|
||||
pr = merkle_proof(recomputed_leaves, idx)
|
||||
if not verify_proof(recomputed_leaves[idx], idx, pr, m.merkle_root, len(recomputed_leaves)):
|
||||
problems.append(f"merkle proof failed for block {idx}")
|
||||
|
||||
# approval must be 'not approved' in a distributed container
|
||||
if m.approval != APPROVAL_NOT:
|
||||
problems.append(f"approval is not the erased sentinel (got {m.approval.hex()})")
|
||||
|
||||
# signature
|
||||
if m.is_signed:
|
||||
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey
|
||||
from cryptography.exceptions import InvalidSignature
|
||||
pub = Ed25519PublicKey.from_public_bytes(m.signer_pubkey)
|
||||
try:
|
||||
pub.verify(m.signature, m.signed_region())
|
||||
except InvalidSignature:
|
||||
problems.append("Ed25519 signature INVALID")
|
||||
if expect_pub is not None and m.signer_pubkey != expect_pub:
|
||||
problems.append("signer_pubkey != expected key")
|
||||
|
||||
# image_hash: directly checkable only for full images (payload IS the image)
|
||||
if m.is_full:
|
||||
if mh32(payload) != m.image_hash:
|
||||
problems.append("image_hash does not match full payload")
|
||||
elif base_image is not None:
|
||||
# delta: optionally apply against a provided base to confirm image_hash
|
||||
try:
|
||||
import detools
|
||||
out = io.BytesIO()
|
||||
detools.apply_patch(io.BytesIO(_ensure_base(base_image)), io.BytesIO(payload), out)
|
||||
rebuilt = out.getvalue()
|
||||
if mh32(rebuilt) != m.image_hash:
|
||||
problems.append("delta applied to base does not match image_hash")
|
||||
if len(rebuilt) != m.image_size:
|
||||
problems.append("delta result size != image_size")
|
||||
except Exception as e: # noqa: BLE001
|
||||
problems.append(f"delta apply check failed: {e}")
|
||||
return problems
|
||||
|
||||
|
||||
def _ensure_base(base_image: bytes) -> bytes:
|
||||
img, _ = ensure_endf(base_image)
|
||||
return img
|
||||
@@ -0,0 +1,86 @@
|
||||
"""
|
||||
PlatformIO post-build extra-script: append the MeshCore ``EndF`` trailer to the
|
||||
firmware image so a running node can self-locate its size/identity (docs/ota_protocol.md §2).
|
||||
|
||||
Wire it (ONLY for OTA-enabled builds) from a variant/env, e.g.:
|
||||
|
||||
extra_scripts =
|
||||
${nrf52_base.extra_scripts}
|
||||
post:tools/mota/pio_endf.py
|
||||
|
||||
and define ``-D ENABLE_OTA=1``. With ENABLE_OTA unset this script is a no-op, so it is safe to
|
||||
leave wired everywhere.
|
||||
|
||||
The byte logic is the same `motalib.ensure_endf` exercised by `endf.py` and the unit tests.
|
||||
|
||||
ESP32 / RP2040 emit ${PROGNAME}.bin (the raw app image) -> EndF appended to the .bin.
|
||||
nRF52 emits ${PROGNAME}.hex (the app, for DFU/UF2) -> EndF appended into the .hex right after the
|
||||
app's last byte (so the downstream .uf2 / DFU .zip carry it). Both feed the same on-device EndF scan.
|
||||
"""
|
||||
|
||||
Import("env") # noqa: F821 (injected by PlatformIO/SCons)
|
||||
|
||||
import os
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, os.path.join(env["PROJECT_DIR"], "tools", "mota")) # noqa: F821
|
||||
import motalib as ml
|
||||
|
||||
|
||||
def _ota_enabled() -> bool:
|
||||
for d in env.get("CPPDEFINES", []): # noqa: F821
|
||||
name = d[0] if isinstance(d, (list, tuple)) else d
|
||||
if name == "ENABLE_OTA":
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _is_nrf52() -> bool:
|
||||
for d in env.get("CPPDEFINES", []): # noqa: F821
|
||||
name = d[0] if isinstance(d, (list, tuple)) else d
|
||||
if name == "NRF52_PLATFORM":
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _append_endf(source, target, env): # raw .bin path (ESP32 / RP2040)
|
||||
path = str(target[0])
|
||||
with open(path, "rb") as f:
|
||||
data = f.read()
|
||||
out, h8 = ml.ensure_endf(data)
|
||||
if len(out) != len(data):
|
||||
with open(path, "wb") as f:
|
||||
f.write(out)
|
||||
print(f"EndF: appended to {os.path.basename(path)} "
|
||||
f"(body_len={len(data)} body_hash={h8.hex()})")
|
||||
else:
|
||||
print(f"EndF: already present in {os.path.basename(path)} (no change)")
|
||||
|
||||
|
||||
def _append_endf_hex(source, target, env): # Intel-HEX path (nRF52: app for DFU/UF2)
|
||||
from intelhex import IntelHex
|
||||
path = str(target[0])
|
||||
ih = IntelHex(path)
|
||||
segs = ih.segments()
|
||||
if not segs:
|
||||
print("EndF: empty .hex, skipping"); return
|
||||
app_start, app_end = segs[0] # first (lowest) segment = the application image
|
||||
body = bytes(ih.tobinarray(start=app_start, size=app_end - app_start))
|
||||
out, h8 = ml.ensure_endf(body)
|
||||
if len(out) == len(body):
|
||||
print(f"EndF: already present in {os.path.basename(path)} (no change)"); return
|
||||
trailer = out[len(body):] # the 16-byte EndF trailer
|
||||
for i, b in enumerate(trailer):
|
||||
ih[app_end + i] = b # write it right after the app's last byte
|
||||
ih.write_hex_file(path)
|
||||
print(f"EndF: appended to {os.path.basename(path)} at 0x{app_end:X} "
|
||||
f"(app=0x{app_start:X}.. body_len={len(body)} body_hash={h8.hex()})")
|
||||
|
||||
|
||||
if _ota_enabled():
|
||||
if _is_nrf52():
|
||||
env.AddPostAction("$BUILD_DIR/${PROGNAME}.hex", _append_endf_hex) # noqa: F821
|
||||
else:
|
||||
env.AddPostAction("$BUILD_DIR/${PROGNAME}.bin", _append_endf) # noqa: F821
|
||||
else:
|
||||
print("EndF: ENABLE_OTA not defined; skipping trailer injection")
|
||||
@@ -0,0 +1,218 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Tests for motalib — run with the meshcore venv:
|
||||
|
||||
./meshcore/bin/python tools/mota/test_mota.py
|
||||
|
||||
(Also pytest-compatible: functions are named test_*.)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import os
|
||||
import random
|
||||
import struct
|
||||
|
||||
import motalib as ml
|
||||
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
|
||||
|
||||
|
||||
def _fw(seed, size):
|
||||
random.seed(seed)
|
||||
return bytes(random.getrandbits(8) for _ in range(size))
|
||||
|
||||
|
||||
# --- multihash / version ---------------------------------------------------
|
||||
|
||||
def test_version_pack_roundtrip():
|
||||
assert ml.pack_version("1.16.0") == (1 << 24) | (16 << 16)
|
||||
assert ml.unpack_version(ml.pack_version("1.16.0.2")) == "1.16.0.2"
|
||||
assert ml.pack_version(0x01100000) == 0x01100000
|
||||
|
||||
|
||||
def test_target_id_for_env():
|
||||
import hashlib
|
||||
env = "RAK_4631_companion_radio_usb"
|
||||
expect = int.from_bytes(hashlib.sha256(env.encode()).digest()[:4], "little")
|
||||
assert ml.target_id_for_env(env) == expect
|
||||
# distinct envs (same board, different role) get distinct ids
|
||||
assert ml.target_id_for_env("RAK_4631_repeater") != ml.target_id_for_env("RAK_4631_companion_radio_usb")
|
||||
|
||||
|
||||
# --- EndF ------------------------------------------------------------------
|
||||
|
||||
def test_endf_roundtrip_and_idempotent():
|
||||
body = _fw(1, 5000)
|
||||
img, h8 = ml.ensure_endf(body)
|
||||
assert len(img) == 5000 + ml.ENDF_LEN
|
||||
assert ml.has_endf(img)
|
||||
pbody, ph8 = ml.parse_endf(img)
|
||||
assert pbody == body and ph8 == h8 == ml.mh8(body)
|
||||
# idempotent: feeding an already-EndF'd image returns it unchanged
|
||||
img2, h82 = ml.ensure_endf(img)
|
||||
assert img2 == img and h82 == h8
|
||||
|
||||
|
||||
def test_endf_rejects_garbage_tail():
|
||||
assert not ml.has_endf(b"too short")
|
||||
body = _fw(2, 1000)
|
||||
img = body + ml.ENDF_MAGIC + struct.pack("<I", 999) + ml.mh8(body) # wrong body_len
|
||||
assert not ml.has_endf(img)
|
||||
|
||||
|
||||
# --- merkle ----------------------------------------------------------------
|
||||
|
||||
def test_merkle_single_block():
|
||||
leaves = [ml.mh4(b"x")]
|
||||
assert ml.merkle_root(leaves) == leaves[0]
|
||||
|
||||
|
||||
def test_merkle_proofs_all_indices_various_counts():
|
||||
for count in [1, 2, 3, 4, 5, 7, 8, 9, 16, 17, 100]:
|
||||
payload = _fw(count, count * 1024 - 13) # last block short, no padding
|
||||
leaves = ml.leaf_hashes(payload, 1024)
|
||||
assert len(leaves) == count
|
||||
root = ml.merkle_root(leaves)
|
||||
for i in range(count):
|
||||
proof = ml.merkle_proof(leaves, i)
|
||||
assert ml.verify_proof(leaves[i], i, proof, root, count), (count, i)
|
||||
# a tampered leaf must fail its proof
|
||||
bad = bytes([leaves[0][0] ^ 0xFF]) + leaves[0][1:]
|
||||
assert not ml.verify_proof(bad, 0, ml.merkle_proof(leaves, 0), root, count)
|
||||
|
||||
|
||||
# --- full container --------------------------------------------------------
|
||||
|
||||
def test_full_build_parse_verify():
|
||||
fw = _fw(10, 33 * 1024 + 7)
|
||||
image, _ = ml.ensure_endf(fw)
|
||||
m = ml.build_manifest(
|
||||
target_id=0xDEADBEEF, fw_version=ml.pack_version("1.16.0"),
|
||||
image_size=len(image), payload=image, block_size=1024,
|
||||
image_hash=ml.mh32(image), codec_id=ml.CODEC_FULL, is_full=True)
|
||||
blob = ml.build_container(m, image)
|
||||
|
||||
parsed = ml.parse_container(blob)
|
||||
assert parsed.manifest.target_id == 0xDEADBEEF
|
||||
assert parsed.manifest.is_full and not parsed.manifest.is_signed
|
||||
assert parsed.manifest.image_hash == ml.mh32(image)
|
||||
assert parsed.payload == image
|
||||
assert ml.verify(parsed) == []
|
||||
|
||||
|
||||
def test_tampered_payload_detected():
|
||||
fw = _fw(11, 10 * 1024)
|
||||
image, _ = ml.ensure_endf(fw)
|
||||
m = ml.build_manifest(target_id=1, 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)
|
||||
blob = bytearray(ml.build_container(m, image))
|
||||
# flip a byte inside the payload region
|
||||
payload_off = blob.index(image)
|
||||
blob[payload_off + 50] ^= 0xFF
|
||||
problems = ml.verify(ml.parse_container(bytes(blob)))
|
||||
assert any("leaves" in p or "merkle" in p or "image_hash" in p for p in problems), problems
|
||||
|
||||
|
||||
# --- signing ---------------------------------------------------------------
|
||||
|
||||
def test_signed_build_and_verify():
|
||||
priv = Ed25519PrivateKey.generate()
|
||||
fw = _fw(12, 20 * 1024)
|
||||
image, _ = ml.ensure_endf(fw)
|
||||
m = ml.build_manifest(target_id=7, fw_version=ml.pack_version("2.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)
|
||||
parsed = ml.parse_container(ml.build_container(m, image))
|
||||
assert parsed.manifest.is_signed
|
||||
assert ml.verify(parsed, expect_pub=priv.public_key().public_bytes_raw()) == []
|
||||
# wrong expected key -> flagged
|
||||
other = Ed25519PrivateKey.generate().public_key().public_bytes_raw()
|
||||
assert any("signer_pubkey" in p for p in ml.verify(parsed, expect_pub=other))
|
||||
|
||||
|
||||
def test_tampered_signature_detected():
|
||||
priv = Ed25519PrivateKey.generate()
|
||||
fw = _fw(13, 8 * 1024)
|
||||
image, _ = ml.ensure_endf(fw)
|
||||
m = ml.build_manifest(target_id=7, 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)
|
||||
blob = bytearray(ml.build_container(m, image))
|
||||
# flip a byte of target_id (inside signed region) without re-signing
|
||||
blob[10] ^= 0xFF
|
||||
problems = ml.verify(ml.parse_container(bytes(blob)))
|
||||
assert any("signature INVALID" in p for p in problems), problems
|
||||
|
||||
|
||||
# --- approval enforcement --------------------------------------------------
|
||||
|
||||
def test_approval_default_and_flagged_if_preapproved():
|
||||
fw = _fw(14, 4 * 1024)
|
||||
image, _ = ml.ensure_endf(fw)
|
||||
m = ml.build_manifest(target_id=1, 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)
|
||||
assert m.approval == ml.APPROVAL_NOT
|
||||
# simulate a malicious pre-approved container -> verify must flag it
|
||||
m.approval = ml.APPROVAL_YES
|
||||
parsed = ml.parse_container(ml.build_container(m, image))
|
||||
assert any("approval" in p for p in ml.verify(parsed))
|
||||
|
||||
|
||||
# --- delta -----------------------------------------------------------------
|
||||
|
||||
def test_delta_build_apply_verify():
|
||||
old_body = _fw(20, 40 * 1024)
|
||||
# new = old with a chunk changed + appended -> a real, small-ish delta
|
||||
new_body = bytearray(old_body)
|
||||
for i in range(1000, 1500):
|
||||
new_body[i] = (new_body[i] + 1) & 0xFF
|
||||
new_body += _fw(21, 2048)
|
||||
old_image, base_hash = ml.ensure_endf(bytes(old_body))
|
||||
new_image, _ = ml.ensure_endf(bytes(new_body))
|
||||
|
||||
import detools
|
||||
fp = io.BytesIO()
|
||||
detools.create_patch(io.BytesIO(old_image), io.BytesIO(new_image), fp,
|
||||
patch_type="sequential", compression="crle")
|
||||
delta = fp.getvalue()
|
||||
# with compression a near-identical-base delta is a fraction of the full image
|
||||
assert len(delta) < len(new_image) // 2, (len(delta), len(new_image))
|
||||
|
||||
m = ml.build_manifest(target_id=0xABCD, fw_version=ml.pack_version("1.2.0"),
|
||||
image_size=len(new_image), payload=delta, block_size=1024,
|
||||
image_hash=ml.mh32(new_image), codec_id=ml.CODEC_DETOOLS_SEQUENTIAL,
|
||||
is_full=False, base_hash=base_hash)
|
||||
parsed = ml.parse_container(ml.build_container(m, delta))
|
||||
assert parsed.manifest.base_hash == base_hash == ml.mh8(bytes(old_body))
|
||||
# full verify incl. applying the delta to the base and checking image_hash
|
||||
assert ml.verify(parsed, base_image=old_image) == []
|
||||
# wrong base must fail the delta->image_hash check
|
||||
wrong = ml.verify(parsed, base_image=_fw(99, 40 * 1024))
|
||||
assert wrong, "delta verify against a wrong base should fail"
|
||||
|
||||
|
||||
# --- runner ----------------------------------------------------------------
|
||||
|
||||
def _run():
|
||||
tests = {k: v for k, v in sorted(globals().items())
|
||||
if k.startswith("test_") and callable(v)}
|
||||
failed = 0
|
||||
for name, fn in tests.items():
|
||||
try:
|
||||
fn()
|
||||
print(f"ok {name}")
|
||||
except Exception as e: # noqa: BLE001
|
||||
failed += 1
|
||||
import traceback
|
||||
print(f"FAIL {name}: {e}")
|
||||
traceback.print_exc()
|
||||
print(f"\n{len(tests) - failed}/{len(tests)} passed")
|
||||
return 1 if failed else 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(_run())
|
||||
@@ -1,8 +1,8 @@
|
||||
[GAT562_30S_Mesh_Kit]
|
||||
extends = nrf52_base
|
||||
extends = rak4631_hw
|
||||
board = rak4631
|
||||
board_check = true
|
||||
build_flags = ${nrf52_base.build_flags}
|
||||
build_flags = ${rak4631_hw.build_flags}
|
||||
${sensor_base.build_flags}
|
||||
-I variants/gat562_30s_mesh_kit
|
||||
-D RAK_4631
|
||||
@@ -19,13 +19,13 @@ build_flags = ${nrf52_base.build_flags}
|
||||
-D PIN_BUZZER=33
|
||||
-D SX126X_RX_BOOSTED_GAIN=1
|
||||
-D SX126X_DIO2_AS_RF_SWITCH=true
|
||||
build_src_filter = ${nrf52_base.build_src_filter}
|
||||
build_src_filter = ${rak4631_hw.build_src_filter}
|
||||
+<../variants/gat562_30s_mesh_kit>
|
||||
+<helpers/ui/SSD1306Display.cpp>
|
||||
+<helpers/ui/MomentaryButton.cpp>
|
||||
+<helpers/sensors>
|
||||
lib_deps =
|
||||
${nrf52_base.lib_deps}
|
||||
${rak4631_hw.lib_deps}
|
||||
${sensor_base.lib_deps}
|
||||
adafruit/Adafruit SSD1306 @ ^2.5.13
|
||||
sparkfun/SparkFun u-blox GNSS Arduino Library@^2.2.27
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
[GAT562_Mesh_EVB_Pro]
|
||||
extends = nrf52_base
|
||||
extends = rak4631_hw
|
||||
board = rak4631
|
||||
board_check = true
|
||||
build_flags = ${nrf52_base.build_flags}
|
||||
build_flags = ${rak4631_hw.build_flags}
|
||||
${sensor_base.build_flags}
|
||||
-I variants/gat562_mesh_evb_pro
|
||||
-D NRF52_POWER_MANAGEMENT
|
||||
@@ -13,12 +13,12 @@ build_flags = ${nrf52_base.build_flags}
|
||||
-D LORA_TX_POWER=22
|
||||
-D SX126X_CURRENT_LIMIT=140
|
||||
-D SX126X_RX_BOOSTED_GAIN=1
|
||||
build_src_filter = ${nrf52_base.build_src_filter}
|
||||
build_src_filter = ${rak4631_hw.build_src_filter}
|
||||
+<../variants/gat562_mesh_evb_pro>
|
||||
+<helpers/ui/MomentaryButton.cpp>
|
||||
+<helpers/sensors>
|
||||
lib_deps =
|
||||
${nrf52_base.lib_deps}
|
||||
${rak4631_hw.lib_deps}
|
||||
${sensor_base.lib_deps}
|
||||
sparkfun/SparkFun u-blox GNSS Arduino Library@^2.2.27
|
||||
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
[GAT562_Mesh_Tracker_Pro]
|
||||
extends = nrf52_base
|
||||
extends = rak4631_hw
|
||||
board = rak4631
|
||||
board_check = true
|
||||
build_flags = ${nrf52_base.build_flags}
|
||||
build_flags = ${rak4631_hw.build_flags}
|
||||
${sensor_base.build_flags}
|
||||
-I variants/gat562_mesh_tracker_pro
|
||||
-D NRF52_POWER_MANAGEMENT
|
||||
@@ -15,13 +15,13 @@ build_flags = ${nrf52_base.build_flags}
|
||||
-D LORA_TX_POWER=22
|
||||
-D SX126X_CURRENT_LIMIT=140
|
||||
-D SX126X_RX_BOOSTED_GAIN=1
|
||||
build_src_filter = ${nrf52_base.build_src_filter}
|
||||
build_src_filter = ${rak4631_hw.build_src_filter}
|
||||
+<../variants/gat562_mesh_tracker_pro>
|
||||
+<helpers/ui/SSD1306Display.cpp>
|
||||
+<helpers/ui/MomentaryButton.cpp>
|
||||
+<helpers/sensors>
|
||||
lib_deps =
|
||||
${nrf52_base.lib_deps}
|
||||
${rak4631_hw.lib_deps}
|
||||
${sensor_base.lib_deps}
|
||||
adafruit/Adafruit SSD1306 @ ^2.5.13
|
||||
sparkfun/SparkFun u-blox GNSS Arduino Library@^2.2.27
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
[GAT562_Mesh_Watch13]
|
||||
extends = nrf52_base
|
||||
extends = rak4631_hw
|
||||
board = rak4631
|
||||
board_check = true
|
||||
build_flags = ${nrf52_base.build_flags}
|
||||
build_flags = ${rak4631_hw.build_flags}
|
||||
${sensor_base.build_flags}
|
||||
-UENV_INCLUDE_GPS
|
||||
-I variants/gat562_mesh_watch13
|
||||
@@ -18,13 +18,13 @@ build_flags = ${nrf52_base.build_flags}
|
||||
-D SX126X_CURRENT_LIMIT=140
|
||||
-D SX126X_RX_BOOSTED_GAIN=1
|
||||
-D QSPIFLASH=1
|
||||
build_src_filter = ${nrf52_base.build_src_filter}
|
||||
build_src_filter = ${rak4631_hw.build_src_filter}
|
||||
+<../variants/gat562_mesh_watch13>
|
||||
+<helpers/ui/SSD1306Display.cpp>
|
||||
+<helpers/ui/MomentaryButton.cpp>
|
||||
+<helpers/sensors>
|
||||
lib_deps =
|
||||
${nrf52_base.lib_deps}
|
||||
${rak4631_hw.lib_deps}
|
||||
${sensor_base.lib_deps}
|
||||
adafruit/Adafruit SSD1306 @ ^2.5.13
|
||||
|
||||
|
||||
@@ -47,10 +47,16 @@ build_flags =
|
||||
-D ADVERT_LON=0.0
|
||||
-D ADMIN_PASSWORD='"password"'
|
||||
-D MAX_NEIGHBOURS=50
|
||||
-D ENABLE_OTA=1
|
||||
; -D MESH_PACKET_LOGGING=1
|
||||
; -D MESH_DEBUG=1
|
||||
extra_scripts =
|
||||
merge-bin.py
|
||||
post:tools/mota/pio_endf.py
|
||||
build_src_filter = ${Heltec_lora32_v3.build_src_filter}
|
||||
+<helpers/ui/SSD1306Display.cpp>
|
||||
+<helpers/ota/*.cpp>
|
||||
+<helpers/ota/detools/detools.c>
|
||||
+<../examples/simple_repeater>
|
||||
lib_deps =
|
||||
${Heltec_lora32_v3.lib_deps}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
[R1Neo]
|
||||
extends = nrf52_base
|
||||
extends = rak4631_hw
|
||||
board = rak4631
|
||||
board_check = true
|
||||
build_flags = ${nrf52_base.build_flags}
|
||||
build_flags = ${rak4631_hw.build_flags}
|
||||
${sensor_base.build_flags}
|
||||
-I variants/muziworks_r1_neo
|
||||
-I src/helpers/ui
|
||||
@@ -19,13 +19,13 @@ build_flags = ${nrf52_base.build_flags}
|
||||
-D PIN_GPS_TX=25
|
||||
-D PIN_GPS_RX=24
|
||||
-D PIN_GPS_EN=33
|
||||
build_src_filter = ${nrf52_base.build_src_filter}
|
||||
build_src_filter = ${rak4631_hw.build_src_filter}
|
||||
+<../variants/muziworks_r1_neo>
|
||||
+<helpers/ui/MomentaryButton.cpp>
|
||||
+<helpers/ui/NullDisplayDriver.cpp>
|
||||
+<helpers/sensors>
|
||||
lib_deps =
|
||||
${nrf52_base.lib_deps}
|
||||
${rak4631_hw.lib_deps}
|
||||
${sensor_base.lib_deps}
|
||||
sparkfun/SparkFun u-blox GNSS Arduino Library@^2.2.27
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ board = rak4631
|
||||
board_check = true
|
||||
extra_scripts = ${nrf52_base.extra_scripts}
|
||||
post:variants/rak4631/fix_bsec_lib.py
|
||||
post:tools/mota/pio_endf.py ; EndF trailer for OTA self-identity (all RAK4631 roles)
|
||||
build_flags = ${nrf52_base.build_flags}
|
||||
${sensor_base.build_flags}
|
||||
-I variants/rak4631
|
||||
@@ -25,11 +26,14 @@ build_flags = ${nrf52_base.build_flags}
|
||||
-D ENV_INCLUDE_RAK12035=1
|
||||
-UENV_INCLUDE_BME680
|
||||
-D ENV_INCLUDE_BME680_BSEC=1
|
||||
-D ENABLE_OTA=1 ; OTA delta updates on every RAK4631 role (single-slot, bootloader-applied)
|
||||
-D OTA_FLASH_STORE=1 ; stage the received .mota in flash (survives reboot into the bootloader)
|
||||
build_src_filter = ${nrf52_base.build_src_filter}
|
||||
+<../variants/rak4631>
|
||||
+<helpers/sensors>
|
||||
+<helpers/ui/SSD1306Display.cpp>
|
||||
+<helpers/ui/MomentaryButton.cpp>
|
||||
+<helpers/ota/*.cpp>
|
||||
lib_deps =
|
||||
${nrf52_base.lib_deps}
|
||||
${sensor_base.lib_deps}
|
||||
@@ -47,10 +51,12 @@ build_flags =
|
||||
-D ADVERT_LON=0.0
|
||||
-D ADMIN_PASSWORD='"password"'
|
||||
-D MAX_NEIGHBOURS=50
|
||||
; -D OTA_DEBUG=1 ; bring-up: trace OTA fetch (REQ / block / page-flush) over Serial
|
||||
; -D MESH_PACKET_LOGGING=1
|
||||
; -D MESH_DEBUG=1
|
||||
build_src_filter = ${rak4631.build_src_filter}
|
||||
+<helpers/ui/SSD1306Display.cpp>
|
||||
; OTA (ENABLE_OTA + flash store + EndF + helpers/ota) is inherited from the [rak4631] base now
|
||||
+<../examples/simple_repeater>
|
||||
|
||||
[env:RAK_4631_repeater_bridge_rs232_serial1]
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
[rak_wismesh_tag]
|
||||
extends = nrf52_base
|
||||
extends = rak4631_hw
|
||||
board = rak4631
|
||||
board_check = true
|
||||
build_flags = ${nrf52_base.build_flags}
|
||||
build_flags = ${rak4631_hw.build_flags}
|
||||
${sensor_base.build_flags}
|
||||
-I variants/rak_wismesh_tag
|
||||
-I src/helpers/ui
|
||||
@@ -25,13 +25,13 @@ build_flags = ${nrf52_base.build_flags}
|
||||
-D PIN_BUZZER=21
|
||||
-D PIN_BOARD_SDA=PIN_WIRE_SDA
|
||||
-D PIN_BOARD_SCL=PIN_WIRE_SCL
|
||||
build_src_filter = ${nrf52_base.build_src_filter}
|
||||
build_src_filter = ${rak4631_hw.build_src_filter}
|
||||
+<../variants/rak_wismesh_tag>
|
||||
+<helpers/ui/MomentaryButton.cpp>
|
||||
+<helpers/ui/NullDisplayDriver.cpp>
|
||||
+<helpers/sensors>
|
||||
lib_deps =
|
||||
${nrf52_base.lib_deps}
|
||||
${rak4631_hw.lib_deps}
|
||||
${sensor_base.lib_deps}
|
||||
|
||||
[env:RAK_WisMesh_Tag_repeater]
|
||||
|
||||
Reference in New Issue
Block a user