Safely hand off expanded ESP32 LoRa bridge without resume record

This commit is contained in:
mikecarper committed 2026-09-24 12:17:49 -07:00
1 parent c0ef07a968
commit 640facd5a2
5 files changed
+165 -27

No files matched your search

+105 -27
View File
@@ -11,6 +11,7 @@
#include <esp_partition.h>
#if defined(MESHCORE_MIGRATION_RESUME_OTA)
#include <mbedtls/sha256.h>
#include <nvs.h>
#endif
#include <helpers/ESP32PartitionMigrationPolicy.h>
@@ -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 += "<!doctype html><meta name=viewport content='width=device-width,initial-scale=1'>";
#if defined(MESHCORE_MIGRATION_RESUME_OTA)
page += "<!--";
page += kBridgeImageMarker;
page += "-->";
#endif
page += "<h2>MeshCore Wi-Fi partition migration</h2><p>";
page += mode;
page += "</p>";
if (strstr(status_text, "ready") != nullptr) {
page += "<p>The expanded partition layout is active and the private device "
"identity was staged and restored before this uploader was exposed.</p>"
"<p><a href='/update'>Upload the full application image for this board.</a>.</p>";
if (expanded_layout_ready && identity_ready) {
page += "<p>The expanded partition layout is active. If the bridge could "
"not return to a verified LoRa image, Wi-Fi recovery is available "
"here: <a href='/update'>upload the correct Full application</a>. "
"Check the status above before proceeding.</p>";
} else if (expanded_layout_ready) {
page += "<p>Private identity recovery did not complete. Firmware upload is "
"disabled; restart the bridge and inspect the status before retrying.</p>";
} else if (migration_complete) {
page += "<p>Partition-table bytes were read back successfully. The test harness "
"is waiting for an explicit reboot.</p><p><a href='/reboot'>Restart now</a></p>";
@@ -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)
@@ -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
@@ -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<uint64_t>(a) < static_cast<uint64_t>(b) + b_size
@@ -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]);
@@ -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 <id>", guide)