14 KiB
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 uses0x12= SHA-256 (the 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 byhash_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
EndFmarker; the byte before it is the last BODY byte. (Same technique asNRF52Board::getBootloaderVersion.) - Self-identity / delta base matching: a node's
body_hashis read directly from its ownEndF; a delta'sbase_hash(§5) must equal it. No self-hashing pass required at match time. - No circularity:
EndFhashes 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 signedmerkle_root(§6), so they need no separate signature.approvalis 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 applywrites41 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.motais 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 |
| 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 == 0is 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_QUERYflood;OTA_HAVE/OTA_MANIFEST/OTA_REQ/OTA_DATAdirect. - 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'smerkle_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-timesha2-256:4(pio_env_name + radio_class + ldscript/partition + platform), injected bybuild.sh, read viaMainBoard::getOtaTargetId(). A node only fetches/serves matchingtarget_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
.motais eligible for auto-apply only if signed by an allowlisted key, the signature verifies, andimage_hashmatches; 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
.motacarries a persistentstaged_atepoch; it is discarded afterota_stale_ttl(default 30 days) unless pinned (ota keep) or applying — reclaiming flash from superseded-complete and stalled-partial images alike.ota discardfrees 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 applyverifies fully, writes theapprovalfield ("APRV"), then reboots into DFU. The modified bootloader (Adafruit_nRF52_Bootloader_OTAFIX) locates the staged.motaby scanning forMAGIC, re-checksTRAILER+ 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;approvalproves 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).