From 640facd5a20dcd459c9fbd0198ccb5513e0c2b4f Mon Sep 17 00:00:00 2001 From: mikecarper Date: Thu, 24 Sep 2026 12:15:56 -0700 Subject: [PATCH] Safely hand off expanded ESP32 LoRa bridge without resume record --- examples/esp32_partition_migrator/main.cpp | 132 ++++++++++++++---- scripts/package_esp32_partition_migration.py | 9 ++ src/helpers/ESP32PartitionMigrationPolicy.h | 24 ++++ .../test_esp32_partition_migration_policy.cpp | 26 ++++ test/test_esp32_partition_migration_recipe.py | 1 + 5 files changed, 165 insertions(+), 27 deletions(-) diff --git a/examples/esp32_partition_migrator/main.cpp b/examples/esp32_partition_migrator/main.cpp index d88805ce2..f886bb962 100644 --- a/examples/esp32_partition_migrator/main.cpp +++ b/examples/esp32_partition_migrator/main.cpp @@ -11,6 +11,7 @@ #include #if defined(MESHCORE_MIGRATION_RESUME_OTA) #include +#include #endif #include @@ -39,6 +40,9 @@ constexpr char kMigrationIdentityPendingKey[] = "id-pending"; #if defined(MESHCORE_MIGRATION_RESUME_OTA) constexpr char kMigrationResumeSlotKey[] = "resume-slot"; constexpr size_t kEndfBytes = 56; +constexpr char kBridgeImageMarker[] = "MeshCore ESP32 partition migration bridge image"; +// Earlier bridge packages predate the dedicated marker but contain this page title. +constexpr char kLegacyBridgeImageMarker[] = "MeshCore Wi-Fi partition migration"; #endif static_assert(sizeof(kMigrationNvsNamespace) - 1 <= 15, "ESP32 NVS namespace names are limited to 15 characters"); @@ -46,6 +50,8 @@ static_assert(sizeof(kMigrationNvsNamespace) - 1 <= 15, AsyncWebServer server(80); bool migration_started = false; bool migration_complete = false; +bool expanded_layout_ready = false; +bool identity_ready = false; bool reboot_requested = false; uint32_t migration_at = 0; uint32_t reboot_at = 0; @@ -348,11 +354,12 @@ uint32_t readLe32(const uint8_t* bytes) { struct OtaImageIdentity { uint32_t target_id = 0; + uint32_t body_bytes = 0; uint8_t hardware_id[32] = {}; }; // The package appends an exact-target EndF to this bridge. Read and verify -// it from the running image, then require the preserved old receiver to have +// it from the running image, then require the opposite-slot firmware to have // that same target and hardware identity. A generic chip-family bridge is // therefore reusable without accepting a cross-board LoRa handoff. bool readVerifiedOtaIdentity(const esp_partition_t& source, @@ -393,6 +400,7 @@ bool readVerifiedOtaIdentity(const esp_partition_t& source, mbedtls_sha256_free(&hash); if (valid) { identity.target_id = readLe32(trailer + 20); + identity.body_bytes = marker; memcpy(identity.hardware_id, trailer + 24, sizeof(identity.hardware_id)); if (identity.target_id != 0) return true; strcpy(status_text, "Refused: LoRa firmware EndF has no target ID"); @@ -406,7 +414,38 @@ bool readVerifiedOtaIdentity(const esp_partition_t& source, return false; } -bool validLegacyOtaReceiver(const esp_partition_t& bridge, +enum class BridgeMarkerResult { Missing, Present, ReadFailure }; + +BridgeMarkerResult containsBridgeMarker(const esp_partition_t& image, + uint32_t body_bytes) { + constexpr size_t marker_bytes = sizeof(kBridgeImageMarker) - 1; + constexpr size_t legacy_marker_bytes = sizeof(kLegacyBridgeImageMarker) - 1; + size_t matched = 0; + size_t matched_legacy = 0; + for (uint32_t offset = 0; offset < body_bytes;) { + const uint32_t count = body_bytes - offset < sizeof(copy_buffer) + ? body_bytes - offset : sizeof(copy_buffer); + if (esp_partition_read(&image, offset, copy_buffer, count) != ESP_OK) { + strcpy(status_text, "Refused: could not inspect other LoRa image"); + return BridgeMarkerResult::ReadFailure; + } + for (uint32_t index = 0; index < count; ++index) { + matched = copy_buffer[index] == kBridgeImageMarker[matched] + ? matched + 1 + : (copy_buffer[index] == kBridgeImageMarker[0] ? 1 : 0); + matched_legacy = copy_buffer[index] == kLegacyBridgeImageMarker[matched_legacy] + ? matched_legacy + 1 + : (copy_buffer[index] == kLegacyBridgeImageMarker[0] ? 1 : 0); + if (matched == marker_bytes || matched_legacy == legacy_marker_bytes) + return BridgeMarkerResult::Present; + } + offset += count; + delay(1); + } + return BridgeMarkerResult::Missing; +} + +bool validOtherLoRaFirmware(const esp_partition_t& bridge, const esp_partition_t& receiver) { OtaImageIdentity bridge_identity; OtaImageIdentity receiver_identity; @@ -415,7 +454,14 @@ bool validLegacyOtaReceiver(const esp_partition_t& bridge, if (bridge_identity.target_id != receiver_identity.target_id || memcmp(bridge_identity.hardware_id, receiver_identity.hardware_id, sizeof(bridge_identity.hardware_id)) != 0) { - strcpy(status_text, "Refused: old LoRa receiver target does not match bridge"); + strcpy(status_text, "Refused: other LoRa firmware target does not match bridge"); + return false; + } + const BridgeMarkerResult marker = containsBridgeMarker( + receiver, receiver_identity.body_bytes); + if (marker != BridgeMarkerResult::Missing) { + if (marker == BridgeMarkerResult::Present) + strcpy(status_text, "Refused: other LoRa slot contains a migration bridge"); return false; } return true; @@ -430,34 +476,55 @@ bool stageResumeSlot(uint8_t slot) { return saved; } -bool resumeLegacyOtaReceiver() { - Preferences migration_nvs; - if (!migration_nvs.begin(kMigrationNvsNamespace, false)) { - strcpy(status_text, "Could not read old LoRa application slot"); +bool resumeLegacyOtaReceiver(const migration::PartitionGeometry& geometry) { + uint8_t slot = 0xFF; + nvs_handle_t nvs_handle; + const esp_err_t opened = nvs_open(kMigrationNvsNamespace, NVS_READONLY, &nvs_handle); + if (opened == ESP_OK) { + const esp_err_t read = nvs_get_u8(nvs_handle, kMigrationResumeSlotKey, &slot); + nvs_close(nvs_handle); + if (read != ESP_OK && read != ESP_ERR_NVS_NOT_FOUND) { + snprintf(status_text, sizeof(status_text), "Refused: LoRa handoff NVS read failed: %s", + errName(read)); + return false; + } + } else if (opened != ESP_ERR_NVS_NOT_FOUND) { + snprintf(status_text, sizeof(status_text), "Refused: LoRa handoff NVS open failed: %s", + errName(opened)); return false; } - const uint8_t slot = migration_nvs.getUChar(kMigrationResumeSlotKey, 0xFF); - migration_nvs.end(); - if (slot > 1) { - strcpy(status_text, "Expanded layout ready; no LoRa resume record"); + const esp_partition_t* running = esp_ota_get_running_partition(); + const migration::LoRaHandoffPlan handoff = migration::planLoRaHandoff( + geometry, running ? running->address : 0, slot); + if (!handoff.valid) { + strcpy(status_text, "Refused: LoRa handoff record or running slot is unsafe"); return false; } - const esp_partition_t* old_app = esp_partition_find_first( + const esp_partition_t* other_app = esp_partition_find_first( ESP_PARTITION_TYPE_APP, - slot == 0 ? ESP_PARTITION_SUBTYPE_APP_OTA_0 : ESP_PARTITION_SUBTYPE_APP_OTA_1, + handoff.other_slot == 0 ? ESP_PARTITION_SUBTYPE_APP_OTA_0 + : ESP_PARTITION_SUBTYPE_APP_OTA_1, nullptr); - const esp_err_t selected = old_app - ? esp_ota_set_boot_partition(old_app) : ESP_ERR_NOT_FOUND; + if (!other_app || !running || other_app->address == running->address + || !validOtherLoRaFirmware(*running, *other_app)) { + if (!other_app || !running || other_app->address == running->address) + strcpy(status_text, "Refused: other LoRa application slot is unavailable"); + return false; + } + const esp_err_t selected = esp_ota_set_boot_partition(other_app); if (selected != ESP_OK) { - snprintf(status_text, sizeof(status_text), "Could not resume old LoRa app: %s", + snprintf(status_text, sizeof(status_text), "Could not boot verified LoRa app: %s", errName(selected)); return false; } - if (migration_nvs.begin(kMigrationNvsNamespace, false)) { - migration_nvs.remove(kMigrationResumeSlotKey); - migration_nvs.end(); + if (handoff.has_record) { + Preferences migration_nvs; + if (migration_nvs.begin(kMigrationNvsNamespace, false)) { + migration_nvs.remove(kMigrationResumeSlotKey); + migration_nvs.end(); + } } - strcpy(status_text, "Private key restored; resuming old LoRa firmware"); + strcpy(status_text, "Expanded layout ready; returning to verified LoRa firmware"); reboot_at = millis() + 1000; return true; } @@ -553,7 +620,7 @@ void runMigration() { } const esp_partition_t* old_receiver = resume.resume_slot == 0 ? refs.app0 : refs.app1; - if (!validLegacyOtaReceiver(*running, *old_receiver)) { + if (!validOtherLoRaFirmware(*running, *old_receiver)) { Serial.println(status_text); return; } @@ -661,13 +728,22 @@ void sendHome(AsyncWebServerRequest* request) { String page; page.reserve(1000); page += ""; +#if defined(MESHCORE_MIGRATION_RESUME_OTA) + page += ""; +#endif page += "

MeshCore Wi-Fi partition migration

"; page += mode; page += "

"; - if (strstr(status_text, "ready") != nullptr) { - page += "

The expanded partition layout is active and the private device " - "identity was staged and restored before this uploader was exposed.

" - "

Upload the full application image for this board..

"; + if (expanded_layout_ready && identity_ready) { + page += "

The expanded partition layout is active. If the bridge could " + "not return to a verified LoRa image, Wi-Fi recovery is available " + "here: upload the correct Full application. " + "Check the status above before proceeding.

"; + } else if (expanded_layout_ready) { + page += "

Private identity recovery did not complete. Firmware upload is " + "disabled; restart the bridge and inspect the status before retrying.

"; } else if (migration_complete) { page += "

Partition-table bytes were read back successfully. The test harness " "is waiting for an explicit reboot.

Restart now

"; @@ -717,7 +793,7 @@ void startServer() { request->send(200, "text/plain", "Restarting migration bridge"); reboot_requested = true; }); - AsyncElegantOTA.begin(&server); + if (!expanded_layout_ready || identity_ready) AsyncElegantOTA.begin(&server); server.begin(); } @@ -732,10 +808,12 @@ void setup() { const uint32_t flash_bytes = ESP.getFlashChipSize(); if (findPartitions(refs, geometry) && migration::isTargetLayout(flash_bytes, geometry)) { + expanded_layout_ready = true; if (restoreStagedIdentity()) { + identity_ready = true; strcpy(status_text, "Expanded layout ready"); #if defined(MESHCORE_MIGRATION_RESUME_OTA) - resumeLegacyOtaReceiver(); + resumeLegacyOtaReceiver(geometry); #endif } } else if (findPartitions(refs, geometry) diff --git a/scripts/package_esp32_partition_migration.py b/scripts/package_esp32_partition_migration.py index 1e94567a6..84de6fcde 100644 --- a/scripts/package_esp32_partition_migration.py +++ b/scripts/package_esp32_partition_migration.py @@ -358,6 +358,15 @@ and the exact target ID in `manifest.json`. A stock image without that updater cannot take this route. The bridge checks the old receiver before changing the table and refuses if its image or target does not match. +If this bridge is accidentally installed on a node that already has the +expanded table, it verifies the other OTA slot's target, EndF image hash and +bootability, then returns to that firmware without changing the table. It +refuses a second migration bridge or an invalid other image; Wi-Fi recovery +remains available if no safe LoRa image exists and identity restoration has +completed. A failed identity restore disables firmware upload. Do not +deliberately install the bridge on an already-expanded node; use the Full image +directly. + {slot_instructions}The Full image has {full_mota_blocks} blocks and needs a seeder with at least {full_mota_blocks * 4} bytes of proof scratch. Older 4 KiB-scratch seeders cannot serve more than 1024 blocks. Verify that a capable seeder lists the Full image diff --git a/src/helpers/ESP32PartitionMigrationPolicy.h b/src/helpers/ESP32PartitionMigrationPolicy.h index d10674263..33edaf577 100644 --- a/src/helpers/ESP32PartitionMigrationPolicy.h +++ b/src/helpers/ESP32PartitionMigrationPolicy.h @@ -280,6 +280,30 @@ struct LoRaResumePlan { bool restore_identity_in_full; }; +struct LoRaHandoffPlan { + bool valid; + uint8_t other_slot; + bool has_record; +}; + +// An already-expanded LoRa bridge has no handoff record. It may return to +// the opposite slot, but never to itself or to a slot contradicted by a +// staged record. Runtime code must still verify that image's identity and +// bootability before selecting it. +inline LoRaHandoffPlan planLoRaHandoff(const PartitionGeometry& layout, + uint32_t running_address, + uint8_t staged_slot) { + const bool in_app0 = running_address == layout.app0_address; + const bool in_app1 = running_address == layout.app1_address; + const uint8_t other = in_app0 ? 1 : 0; + const bool absent = staged_slot == 0xFF; + return { + (in_app0 || in_app1) && (absent || staged_slot == other), + other, + !absent, + }; +} + constexpr bool overlaps(uint32_t a, uint32_t a_size, uint32_t b, uint32_t b_size) { return static_cast(a) < static_cast(b) + b_size diff --git a/test/test_esp32_partition_migration_policy/test_esp32_partition_migration_policy.cpp b/test/test_esp32_partition_migration_policy/test_esp32_partition_migration_policy.cpp index f03fb5e8d..85e2dc4b8 100644 --- a/test/test_esp32_partition_migration_policy/test_esp32_partition_migration_policy.cpp +++ b/test/test_esp32_partition_migration_policy/test_esp32_partition_migration_policy.cpp @@ -103,6 +103,32 @@ TEST(Esp32PartitionMigrationPolicy, LoRaResumePreservesTheOldApplicationInEither EXPECT_TRUE(small_from_b.restore_identity_in_full); } +TEST(Esp32PartitionMigrationPolicy, ExpandedLoRaHandoffUsesOnlyTheOppositeSlot) { + for (const auto& layout : {policy::kExpanded4MBLayout, + policy::kExpanded8MBLayout, + policy::kExpandedLayout}) { + const auto no_record_from_a = policy::planLoRaHandoff( + layout, layout.app0_address, 0xFF); + EXPECT_TRUE(no_record_from_a.valid); + EXPECT_EQ(1, no_record_from_a.other_slot); + EXPECT_FALSE(no_record_from_a.has_record); + + const auto no_record_from_b = policy::planLoRaHandoff( + layout, layout.app1_address, 0xFF); + EXPECT_TRUE(no_record_from_b.valid); + EXPECT_EQ(0, no_record_from_b.other_slot); + EXPECT_FALSE(no_record_from_b.has_record); + + const auto recorded = policy::planLoRaHandoff( + layout, layout.app1_address, 0); + EXPECT_TRUE(recorded.valid); + EXPECT_TRUE(recorded.has_record); + EXPECT_FALSE(policy::planLoRaHandoff(layout, layout.app1_address, 1).valid); + EXPECT_FALSE(policy::planLoRaHandoff(layout, layout.app0_address, 2).valid); + EXPECT_FALSE(policy::planLoRaHandoff(layout, 0x300000, 0xFF).valid); + } +} + TEST(Esp32PartitionMigrationPolicy, GeneratedTablePrefixContainsAllSixEntriesAndMd5) { EXPECT_EQ(0xE0U, policy::kExpandedPartitionTablePrefixBytes); EXPECT_EQ(0xAA, policy::kExpandedPartitionTablePrefix[0]); diff --git a/test/test_esp32_partition_migration_recipe.py b/test/test_esp32_partition_migration_recipe.py index 2ca406d22..ccb1f761b 100644 --- a/test/test_esp32_partition_migration_recipe.py +++ b/test/test_esp32_partition_migration_recipe.py @@ -110,6 +110,7 @@ class Esp32MigrationRecipeTest(unittest.TestCase): "v1.17.1.7-test", "01234567", full_mota_blocks=900) self.assertIn("old slot B", guide) self.assertIn("original private key from NVS", guide) + self.assertIn("accidentally installed on a node that already has", guide) self.assertIn("power loss during", guide) self.assertIn("exact\n `Started:` URL", guide) self.assertIn("ota pull ", guide)