From 8599cf0acbac611336af42a6abe64fd6ea491f8c Mon Sep 17 00:00:00 2001 From: mikecarper Date: Tue, 14 Jul 2026 14:42:26 -0700 Subject: [PATCH] Verify staged OTA payloads and flash writes --- platformio.ini | 2 + src/helpers/ota/FolderMotaStore.cpp | 4 +- src/helpers/ota/FolderMotaStore.h | 2 +- src/helpers/ota/MotaContainer.cpp | 16 ++++++ src/helpers/ota/MotaContainer.h | 5 ++ src/helpers/ota/OtaApply.cpp | 4 +- src/helpers/ota/OtaCli.cpp | 8 +-- src/helpers/ota/OtaManager.cpp | 10 ++-- src/helpers/ota/OtaStore.h | 5 +- src/helpers/ota/OtaStoreFlashEsp32.cpp | 8 +-- src/helpers/ota/OtaStoreFlashEsp32.h | 2 +- src/helpers/ota/OtaStoreFlashNrf52.cpp | 67 ++++++++++++++++++-------- src/helpers/ota/OtaStoreFlashNrf52.h | 11 +++-- src/helpers/ota/OtaVerify.cpp | 1 + src/helpers/ota/OtaVerify.h | 3 +- test/test_ota/test_ota_core.cpp | 15 ++++++ variants/heltec_t114/platformio.ini | 1 + variants/rak4631/platformio.ini | 1 + 18 files changed, 120 insertions(+), 45 deletions(-) diff --git a/platformio.ini b/platformio.ini index 69ced38a..ce095d0b 100644 --- a/platformio.ini +++ b/platformio.ini @@ -70,6 +70,7 @@ 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 OTA_FLASH_STORE=1 -D OTA_FOLDER_SERIAL ; `ota folder on` relays a host folder of .mota over the USB console (no extra HW) ; -D ESP32_CPU_FREQ=80 ; change it to your need @@ -120,6 +121,7 @@ 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 -D OTA_FOLDER_SERIAL ; `ota folder on` relays a host folder of .mota over the USB console (no extra HW) build_src_filter = ${nrf52_base.build_src_filter} diff --git a/src/helpers/ota/FolderMotaStore.cpp b/src/helpers/ota/FolderMotaStore.cpp index bc84a03d..139edcb2 100644 --- a/src/helpers/ota/FolderMotaStore.cpp +++ b/src/helpers/ota/FolderMotaStore.cpp @@ -95,8 +95,8 @@ bool FolderMotaStore::read(uint32_t off, uint8_t* buf, uint32_t len) const { return true; } -void FolderMotaStore::finalize() { - txn(MS_OP_FIN, _mid, 4, nullptr, 0); // publish .mota.part as .mota (best-effort) +bool FolderMotaStore::finalize() { + return txn(MS_OP_FIN, _mid, 4, nullptr, 0); // publish .mota.part as .mota } bool FolderMotaStore::reopen() { diff --git a/src/helpers/ota/FolderMotaStore.h b/src/helpers/ota/FolderMotaStore.h index 63823af4..9a3ff673 100644 --- a/src/helpers/ota/FolderMotaStore.h +++ b/src/helpers/ota/FolderMotaStore.h @@ -33,7 +33,7 @@ public: uint32_t capacity() const override { return 0xF0000000u; } // host disk — effectively unbounded uint32_t staged_size() const override { return _total; } void clear() override { _total = 0; } - void finalize() override; + bool finalize() override; bool reopen() override; // OP_STAT: adopt an existing file for resume // A host-backed store is fully random-access, so it needs no pinned-meta RAM page and no layout planning. diff --git a/src/helpers/ota/MotaContainer.cpp b/src/helpers/ota/MotaContainer.cpp index 282d4175..dcdd9538 100644 --- a/src/helpers/ota/MotaContainer.cpp +++ b/src/helpers/ota/MotaContainer.cpp @@ -72,6 +72,22 @@ bool mota_check_root(const MotaManifest& m) { return memcmp(root, m.merkle_root, 4) == 0; } +bool mota_check_payload(const MotaManifest& m) { + if (!m.payload || !m.leaves || m.block_count == 0) return false; + uint32_t off = 0; + uint32_t bs = m.block_size(); + for (uint32_t i = 0; i < m.block_count; i++) { + if (off >= m.payload_size) return false; + uint32_t len = m.payload_size - off; + if (len > bs) len = bs; + uint8_t leaf[4]; + merkle_leaf(leaf, m.payload + off, len); + if (memcmp(leaf, m.leaves + i * 4, 4) != 0) return false; + off += len; + } + return off == m.payload_size; +} + bool mota_check_image_hash_full(const MotaManifest& m) { if (!m.is_full() || !m.payload || !m.image_hash) return false; uint8_t h[32]; diff --git a/src/helpers/ota/MotaContainer.h b/src/helpers/ota/MotaContainer.h index 4425e62b..f344fd97 100644 --- a/src/helpers/ota/MotaContainer.h +++ b/src/helpers/ota/MotaContainer.h @@ -57,6 +57,11 @@ 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); +// Rehash every actual payload block and compare it with the corresponding stored leaf. This must be +// checked in addition to mota_check_root(): a self-consistent leaves[] array alone says nothing about +// bytes lost or corrupted while programming the staging flash. +bool mota_check_payload(const MotaManifest& m); + // For FULL images only: check sha2-256:32(payload) == image_hash. bool mota_check_image_hash_full(const MotaManifest& m); diff --git a/src/helpers/ota/OtaApply.cpp b/src/helpers/ota/OtaApply.cpp index f63f4d68..81425f5f 100644 --- a/src/helpers/ota/OtaApply.cpp +++ b/src/helpers/ota/OtaApply.cpp @@ -462,7 +462,9 @@ bool ota_apply_mota_nrf52(const uint8_t* buf, uint32_t len, const SignerAllowlis 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; } + if (!vr.root_ok || !vr.payload_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 diff --git a/src/helpers/ota/OtaCli.cpp b/src/helpers/ota/OtaCli.cpp index 039f10f1..f7103675 100644 --- a/src/helpers/ota/OtaCli.cpp +++ b/src/helpers/ota/OtaCli.cpp @@ -433,7 +433,8 @@ static bool handle_dev(const char* d, char* reply, OtaContext& c) { 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); + sprintf(reply, "OK serving | root=%d payload=%d img=%d sig=%d trust=%d", + r.root_ok, r.payload_ok, r.image_ok, r.sig_ok, r.trusted); } else if (strncmp(d, "resume", 6) == 0) { // re-adopt a container already staged in flash (test/debug) bool ok = c.manager.resumeStaged(nullptr); @@ -451,8 +452,9 @@ static bool handle_dev(const char* d, char* reply, OtaContext& c) { else { buf = c.serve_buf; len = c.serve_expected; } if (len == 0 || !buf) { strcpy(reply, "ERR nothing to verify (flash-staged: applydelta verifies)"); 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()); + sprintf(reply, "verify parsed=%d root=%d payload=%d img=%d signed=%d sig=%d trust=%d | ok=%d auto=%d", + r.parsed, r.root_ok, r.payload_ok, r.image_ok, r.is_signed, r.sig_ok, r.trusted, + r.integrity_ok(), r.auto_appliable()); } else if (strncmp(d, "want ", 5) == 0) { const char* p = d + 5; while (*p == ' ') p++; diff --git a/src/helpers/ota/OtaManager.cpp b/src/helpers/ota/OtaManager.cpp index f47af79f..b2208bda 100644 --- a/src/helpers/ota/OtaManager.cpp +++ b/src/helpers/ota/OtaManager.cpp @@ -585,7 +585,7 @@ void OtaManager::diffStep() { OTA_DBG("OTA: leaf-diff %u/%u already valid; fetching the rest\n", (unsigned)_have, (unsigned)_fbc); freeLeaves(); // clears _diffing + frees the buffer if (_have >= _fbc) { // seed covered the whole image - _fstate = COMPLETE; _fetch->finalize(); + _fstate = _fetch->finalize() ? COMPLETE : FAILED; return; } _fstate = FETCHING; requestMissing(); @@ -630,7 +630,7 @@ bool OtaManager::resumeStaged(const uint8_t* want_mid) { } else { _fstate = COMPLETE; } - if (_fstate == COMPLETE) _fetch->finalize(); + if (_fstate == COMPLETE && !_fetch->finalize()) _fstate = FAILED; return true; } _fstate = FETCHING; // resume fetching the holes @@ -687,7 +687,7 @@ void OtaManager::handleProof(const uint8_t* m, uint16_t n) { // verified -> commit the payload block, then its leaf (the present marker). A write failure here means a // FOLDER destination's seeder link dropped mid-transfer: PAUSE (hold progress on the host, stop // requesting, do NOT fall back to RAM/flash). The block is left uncommitted (its leaf stays 0xFF), so on - // reconnect resumeStaged() re-requests exactly it. (A flash store never fails these writes.) + // reconnect resumeStaged() re-requests exactly it. Flash failures also pause instead of being ignored. uint8_t leaf[4]; merkle_leaf(leaf, _reasm_buf, blen); if (!_fetch->write(_fpoff + (uint32_t)_reasm_block * _fbs, _reasm_buf, blen) || !_fetch->write(_floff + (uint32_t)_reasm_block * 4, leaf, 4)) { @@ -707,8 +707,8 @@ void OtaManager::handleProof(const uint8_t* m, uint16_t n) { } else { _fstate = COMPLETE; // per-block proofs already guaranteed integrity vs the root } - if (_fstate == COMPLETE) _fetch->finalize(); - OTA_DBG("OTA: transfer %s\n", _fstate == COMPLETE ? "COMPLETE" : "FAILED(root)"); + if (_fstate == COMPLETE && !_fetch->finalize()) _fstate = FAILED; + OTA_DBG("OTA: transfer %s\n", _fstate == COMPLETE ? "COMPLETE" : "FAILED(integrity/storage)"); } void OtaManager::requestMissing() { diff --git a/src/helpers/ota/OtaStore.h b/src/helpers/ota/OtaStore.h index 27f2fb29..8fb0563b 100644 --- a/src/helpers/ota/OtaStore.h +++ b/src/helpers/ota/OtaStore.h @@ -33,8 +33,9 @@ public: // 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() {} + // After a true return, a flash store's data() view is coherent. False means persistence failed and the + // transfer must not be marked COMPLETE or approved for installation. No-op for purely in-RAM stores. + virtual bool finalize() { return true; } // Optional: persist in-progress state (the metadata/leaf-progress page + any open payload buffer) so a // reboot mid-transfer can resume. Called by OtaManager every OTA_CHECKPOINT_BLOCKS committed blocks. diff --git a/src/helpers/ota/OtaStoreFlashEsp32.cpp b/src/helpers/ota/OtaStoreFlashEsp32.cpp index adfda271..0fae329c 100644 --- a/src/helpers/ota/OtaStoreFlashEsp32.cpp +++ b/src/helpers/ota/OtaStoreFlashEsp32.cpp @@ -165,8 +165,9 @@ bool OtaStoreFlashEsp32::read(uint32_t offset, uint8_t* buf, uint32_t len) const return true; } -void OtaStoreFlashEsp32::finalize() { - if (_flushed || _total == 0) return; +bool OtaStoreFlashEsp32::finalize() { + if (_flushed) return _io_ok; + if (_total == 0 || !_io_ok) return false; if (!_full) { // delta: drop the 5-byte trailer into the sliding payload sector(s) so it flushes with them (the // trailer sits right after the payload; this also covers the rare case it spills into a fresh sector). @@ -181,8 +182,9 @@ void OtaStoreFlashEsp32::finalize() { } flush_pay(); // last payload sector(s) (+ delta trailer) flush_sector(_meta_part, _meta, _meta_flush); // meta (+ trailer for full) - _flushed = true; + _flushed = _io_ok; OTA_DBG("OTA esp32: finalize %s io_ok=%d\n", _full ? "FULL" : "DELTA", (int)_io_ok); + return _io_ok; } // Persist mid-transfer progress so a reboot can resume. Flush the open payload sector (KEEP it buffered so diff --git a/src/helpers/ota/OtaStoreFlashEsp32.h b/src/helpers/ota/OtaStoreFlashEsp32.h index f0eee558..9a7e3ba5 100644 --- a/src/helpers/ota/OtaStoreFlashEsp32.h +++ b/src/helpers/ota/OtaStoreFlashEsp32.h @@ -92,7 +92,7 @@ public: uint32_t staged_size() const override { return _total; } void clear() override; bool set_meta_size(uint32_t meta_bytes) override { return meta_bytes < OTA_ESP32_META_CAP; } - void finalize() override; + bool finalize() override; void checkpoint() override; // persist meta(leaves) + open payload sector so a reboot can resume bool reopen() override; // re-attach to a container already staged in the slot (scan + rebuild geometry) diff --git a/src/helpers/ota/OtaStoreFlashNrf52.cpp b/src/helpers/ota/OtaStoreFlashNrf52.cpp index e1f0aa2f..5f9ad465 100644 --- a/src/helpers/ota/OtaStoreFlashNrf52.cpp +++ b/src/helpers/ota/OtaStoreFlashNrf52.cpp @@ -15,16 +15,28 @@ namespace ota { // 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(); +static bool flash_matches(uint32_t addr, const uint8_t* expected, uint32_t n) { + const volatile uint8_t* actual = (const volatile uint8_t*)(uintptr_t)addr; + for (uint32_t i = 0; i < n; i++) if (actual[i] != expected[i]) return false; + return true; } -void OtaStoreFlashNrf52::flush_pay() { - if (_pay_idx != 0) flush_page(_pay_idx, _pay_page); // _pay_idx 0 == no payload page open +bool OtaStoreFlashNrf52::flush_page(uint32_t page_idx, const uint8_t* buf) { + if (!_io_ok || _write_start > MOTA_NRF52_FS_START - PG || + page_idx > (MOTA_NRF52_FS_START - PG - _write_start) / PG) { + _io_ok = false; + return false; + } + uint32_t addr = _write_start + page_idx * PG; + OTA_DBG("OTA flash: write page %u @ %08x\n", (unsigned)page_idx, (unsigned)addr); + if (flash_nrf5x_write(addr, buf, PG) < 0) { _io_ok = false; return false; } + flash_nrf5x_flush(); + if (!flash_matches(addr, buf, PG)) { _io_ok = false; return false; } + return true; +} + +bool OtaStoreFlashNrf52::flush_pay() { + return _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 { @@ -49,7 +61,10 @@ 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) { + if (!flush_pay()) return nullptr; + _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 } @@ -73,23 +88,28 @@ bool OtaStoreFlashNrf52::begin(uint32_t total_size) { memset(_trailer, 0xFF, sizeof(_trailer)); _pay_idx = 0; _flushed = false; + _io_ok = true; 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; + if ((uint64_t)offset + len > _total || !_io_ok) 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)) { + uint8_t* dst = write_slot(pos); + if (!_io_ok) return false; + if (dst) { 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; + uint32_t addr = _write_start + pos; + if (flash_nrf5x_write(addr, d, n) < 0) { _io_ok = false; return false; } flash_nrf5x_flush(); + if (!flash_matches(addr, d, n)) { _io_ok = false; return false; } } pos += n; d += n; } @@ -97,7 +117,7 @@ bool OtaStoreFlashNrf52::write(uint32_t offset, const uint8_t* d, uint32_t len) } bool OtaStoreFlashNrf52::read(uint32_t offset, uint8_t* buf, uint32_t len) const { - if ((uint64_t)offset + len > _total) return false; + if ((uint64_t)offset + len > _total || !_io_ok) 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); @@ -106,14 +126,18 @@ bool OtaStoreFlashNrf52::read(uint32_t offset, uint8_t* buf, uint32_t len) const return true; } -void OtaStoreFlashNrf52::finalize() { - if (_flushed || _total == 0) return; +bool OtaStoreFlashNrf52::finalize() { + if (_flushed) return _io_ok; + if (_total == 0 || !_io_ok) return false; 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) + if (!flush_pay()) return false; // the last (highest) payload page, if one is open + if (!flush_page(0, _meta_page)) return false; // header + manifest + leaves + first payload bytes + uint32_t trailer_addr = _write_start + _total - 5; + if (flash_nrf5x_write(trailer_addr, _trailer, 5) < 0) { _io_ok = false; return false; } flash_nrf5x_flush(); + if (!flash_matches(trailer_addr, _trailer, 5)) { _io_ok = false; return false; } _flushed = true; + return true; } // Persist mid-transfer progress so a reboot can resume. Order matters for consistency: flush the open @@ -121,9 +145,9 @@ void OtaStoreFlashNrf52::finalize() { // also has its payload in flash. Infrequent (every OTA_CHECKPOINT_BLOCKS blocks), so the 2 extra page // erases don't matter; at LoRa block rates it's roughly once per many minutes. void OtaStoreFlashNrf52::checkpoint() { - if (_total == 0 || _flushed) return; - flush_pay(); // keep _pay_idx open (it may still receive writes); just re-flush its bytes - flush_page(0, _meta_page); // header + manifest + leaves accumulated so far + if (_total == 0 || _flushed || !_io_ok) return; + if (!flush_pay()) return; // keep _pay_idx open; it may still receive writes + flush_page(0, _meta_page); // header + manifest + leaves accumulated so far } // Re-attach to a container already staged in flash (after a reboot), without erasing. The container is @@ -147,6 +171,7 @@ bool OtaStoreFlashNrf52::reopen() { memcpy(_trailer, p + (total - 5), 5); // recover the trailer tail (flushed at last finalize, if any) _pay_idx = 0; _flushed = false; + _io_ok = true; OTA_DBG("OTA flash: reopen total=%u start=%08x\n", (unsigned)total, (unsigned)start); return true; } diff --git a/src/helpers/ota/OtaStoreFlashNrf52.h b/src/helpers/ota/OtaStoreFlashNrf52.h index 37d64cef..e8fbf675 100644 --- a/src/helpers/ota/OtaStoreFlashNrf52.h +++ b/src/helpers/ota/OtaStoreFlashNrf52.h @@ -38,6 +38,7 @@ class OtaStoreFlashNrf52 : public OtaStore { 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 + bool _io_ok = true; // cleared on a failed/out-of-bounds flash write or readback mismatch 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) @@ -50,8 +51,8 @@ class OtaStoreFlashNrf52 : public OtaStore { // 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 + bool flush_pay(); // commit _pay_page to flash (erase + program, one page) + bool flush_page(uint32_t page_idx, const uint8_t* buf); // write + verify a full page public: bool begin(uint32_t total_size) override; @@ -59,15 +60,15 @@ public: 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; } + void clear() override { _total = 0; _pay_idx = 0; _flushed = false; _io_ok = true; } bool set_meta_size(uint32_t meta_bytes) override { return meta_bytes <= PG; } // leaves must fit page 0 - void finalize() override; + bool finalize() override; void checkpoint() override; // persist page 0 (leaves) + the open payload page so a reboot can resume bool reopen() override; // re-attach to a container already staged in flash (scan for it) // 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; } + const uint8_t* data() const { return (_flushed && _io_ok) ? (const uint8_t*)(uintptr_t)_write_start : nullptr; } uint32_t write_start() const { return _write_start; } }; diff --git a/src/helpers/ota/OtaVerify.cpp b/src/helpers/ota/OtaVerify.cpp index 42b6d53f..5ac8b49c 100644 --- a/src/helpers/ota/OtaVerify.cpp +++ b/src/helpers/ota/OtaVerify.cpp @@ -12,6 +12,7 @@ VerifyResult ota_verify(const uint8_t* buf, uint32_t len, const SignerAllowlist& if (!mota_parse(buf, len, m)) return r; r.parsed = true; r.root_ok = mota_check_root(m); + r.payload_ok = mota_check_payload(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(); diff --git a/src/helpers/ota/OtaVerify.h b/src/helpers/ota/OtaVerify.h index 85fb9683..d706827c 100644 --- a/src/helpers/ota/OtaVerify.h +++ b/src/helpers/ota/OtaVerify.h @@ -12,13 +12,14 @@ namespace ota { struct VerifyResult { bool parsed = false; // container + manifest parsed bool root_ok = false; // merkle_root recomputed from leaves[] matches + bool payload_ok = false; // every staged payload block hashes to its stored merkle leaf 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); } + bool integrity_ok() const { return parsed && root_ok && payload_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; } }; diff --git a/test/test_ota/test_ota_core.cpp b/test/test_ota/test_ota_core.cpp index 88bbe329..cbb76b70 100644 --- a/test/test_ota/test_ota_core.cpp +++ b/test/test_ota/test_ota_core.cpp @@ -115,6 +115,21 @@ TEST(OtaMerkle, RootMatchesVectorAndLeaves) { merkle_leaf(leaf, m.payload + off, len); EXPECT_EQ(0, memcmp(leaf, m.leaves + i * 4, 4)) << "leaf " << i; } + EXPECT_TRUE(mota_check_payload(m)); +} + +TEST(OtaMerkle, PayloadCheckDetectsCorruptionNotVisibleInStoredLeaves) { + std::vector b(MOTA_VEC, MOTA_VEC + MOTA_VEC_LEN); + MotaManifest m; + ASSERT_TRUE(mota_parse(b.data(), b.size(), m)); + ASSERT_TRUE(mota_check_root(m)); + ASSERT_TRUE(mota_check_payload(m)); + + size_t payload_off = (size_t)(m.payload - b.data()); + b[payload_off + m.payload_size / 2] ^= 0x01; + ASSERT_TRUE(mota_parse(b.data(), b.size(), m)); + EXPECT_TRUE(mota_check_root(m)); // leaves[] and its root are still self-consistent + EXPECT_FALSE(mota_check_payload(m)); // actual staged payload no longer matches those leaves } TEST(OtaMerkle, FullImageHashMatches) { diff --git a/variants/heltec_t114/platformio.ini b/variants/heltec_t114/platformio.ini index 679cc108..8ecfdfc3 100644 --- a/variants/heltec_t114/platformio.ini +++ b/variants/heltec_t114/platformio.ini @@ -7,6 +7,7 @@ board = heltec_t114 board_build.ldscript = boards/nrf52840_s140_v6.ld build_flags = ${rak4631_hw.build_flags} ${sensor_base.build_flags} + -D MOTA_HW_ID='"Heltec_t114"' ; OTA hardware tag (apply refuses a .mota built for other hardware) -I lib/nrf52/s140_nrf52_6.1.1_API/include -I lib/nrf52/s140_nrf52_6.1.1_API/include/nrf52 -I variants/heltec_t114 diff --git a/variants/rak4631/platformio.ini b/variants/rak4631/platformio.ini index ecf51eb7..2c3407b0 100644 --- a/variants/rak4631/platformio.ini +++ b/variants/rak4631/platformio.ini @@ -28,6 +28,7 @@ build_flags = ${nrf52_base.build_flags} -D ENV_INCLUDE_RAK12035=1 -UENV_INCLUDE_BME680 -D ENV_INCLUDE_BME680_BSEC=1 + -D ENABLE_OTA=1 ; LoRa OTA on every RAK4631 role (single-slot, bootloader-applied) -D OTA_FLASH_STORE=1 ; stage the received .mota in flash (survives reboot into the bootloader) -D OTA_FOLDER_SERIAL ; `ota folder on` relays a host folder of .mota over the USB console (no extra HW) build_src_filter = ${nrf52_base.build_src_filter}