From 447fb119e5b7940016ce332375e027e8076c92c6 Mon Sep 17 00:00:00 2001 From: Wessel Nieboer Date: Fri, 6 Feb 2026 01:57:27 +0100 Subject: [PATCH 01/56] Hold GC1109 PA_POWER during deep sleep for LNA RX wake The GC1109 FEM needs its VFEM_Ctrl pin held HIGH during deep sleep to keep the LNA active, enabling proper RX sensitivity for wake-on-packet. Without this, the LNA is unpowered during sleep and RX wake sensitivity is degraded by ~17dB. Release RTC holds in begin() after configuring GPIO registers (not before) to ensure glitch-free pin transitions on wake. Trade-off: ~6.5mA additional sleep current for significantly improved wake-on-packet range. --- variants/heltec_tracker_v2/HeltecTrackerV2Board.cpp | 11 +++++++++-- variants/heltec_tracker_v2/platformio.ini | 10 +++++----- variants/heltec_v4/HeltecV4Board.cpp | 11 +++++++++-- 3 files changed, 23 insertions(+), 9 deletions(-) diff --git a/variants/heltec_tracker_v2/HeltecTrackerV2Board.cpp b/variants/heltec_tracker_v2/HeltecTrackerV2Board.cpp index 4975d5cde..1b694c11d 100644 --- a/variants/heltec_tracker_v2/HeltecTrackerV2Board.cpp +++ b/variants/heltec_tracker_v2/HeltecTrackerV2Board.cpp @@ -6,12 +6,17 @@ void HeltecTrackerV2Board::begin() { pinMode(PIN_ADC_CTRL, OUTPUT); digitalWrite(PIN_ADC_CTRL, LOW); // Initially inactive + // Set up digital GPIO registers before releasing RTC hold. The hold latches + // the pad state including function select, so register writes accumulate + // without affecting the pad. On hold release, all changes apply atomically + // (IO MUX switches to digital GPIO with output already HIGH — no glitch). pinMode(P_LORA_PA_POWER, OUTPUT); digitalWrite(P_LORA_PA_POWER,HIGH); + rtc_gpio_hold_dis((gpio_num_t)P_LORA_PA_POWER); - rtc_gpio_hold_dis((gpio_num_t)P_LORA_PA_EN); pinMode(P_LORA_PA_EN, OUTPUT); digitalWrite(P_LORA_PA_EN,HIGH); + rtc_gpio_hold_dis((gpio_num_t)P_LORA_PA_EN); pinMode(P_LORA_PA_TX_EN, OUTPUT); digitalWrite(P_LORA_PA_TX_EN,LOW); @@ -48,7 +53,9 @@ void HeltecTrackerV2Board::begin() { rtc_gpio_hold_en((gpio_num_t)P_LORA_NSS); - rtc_gpio_hold_en((gpio_num_t)P_LORA_PA_EN); //It also needs to be enabled in receive mode + // Hold GC1109 FEM pins during sleep to keep LNA active for RX wake + rtc_gpio_hold_en((gpio_num_t)P_LORA_PA_POWER); + rtc_gpio_hold_en((gpio_num_t)P_LORA_PA_EN); if (pin_wake_btn < 0) { esp_sleep_enable_ext1_wakeup( (1L << P_LORA_DIO_1), ESP_EXT1_WAKEUP_ANY_HIGH); // wake up on: recv LoRa packet diff --git a/variants/heltec_tracker_v2/platformio.ini b/variants/heltec_tracker_v2/platformio.ini index 25d16f2f6..af41b4f56 100644 --- a/variants/heltec_tracker_v2/platformio.ini +++ b/variants/heltec_tracker_v2/platformio.ini @@ -17,11 +17,11 @@ build_flags = -D P_LORA_SCLK=9 -D P_LORA_MISO=11 -D P_LORA_MOSI=10 - -D P_LORA_PA_POWER=7 ;power en - -D P_LORA_PA_EN=4 - -D P_LORA_PA_TX_EN=46 ;enable tx - -D LORA_TX_POWER=10 ;If it is configured as 10 here, the final output will be 22 dbm. - -D MAX_LORA_TX_POWER=22 ;Max SX1262 output + -D P_LORA_PA_POWER=7 ; VFEM_Ctrl - GC1109 LDO power enable + -D P_LORA_PA_EN=4 ; CSD - GC1109 chip enable (HIGH=on) + -D P_LORA_PA_TX_EN=46 ; CPS - GC1109 PA mode (HIGH=full PA, LOW=bypass) + -D LORA_TX_POWER=10 ; 10dBm + ~11dB GC1109 gain = ~21dBm output + -D MAX_LORA_TX_POWER=22 ; Max SX1262 output -> ~28dBm at antenna -D SX126X_DIO2_AS_RF_SWITCH=true -D SX126X_DIO3_TCXO_VOLTAGE=1.8 -D SX126X_CURRENT_LIMIT=140 diff --git a/variants/heltec_v4/HeltecV4Board.cpp b/variants/heltec_v4/HeltecV4Board.cpp index 92f934376..626f25773 100644 --- a/variants/heltec_v4/HeltecV4Board.cpp +++ b/variants/heltec_v4/HeltecV4Board.cpp @@ -7,12 +7,17 @@ void HeltecV4Board::begin() { pinMode(PIN_ADC_CTRL, OUTPUT); digitalWrite(PIN_ADC_CTRL, LOW); // Initially inactive + // Set up digital GPIO registers before releasing RTC hold. The hold latches + // the pad state including function select, so register writes accumulate + // without affecting the pad. On hold release, all changes apply atomically + // (IO MUX switches to digital GPIO with output already HIGH — no glitch). pinMode(P_LORA_PA_POWER, OUTPUT); digitalWrite(P_LORA_PA_POWER,HIGH); + rtc_gpio_hold_dis((gpio_num_t)P_LORA_PA_POWER); - rtc_gpio_hold_dis((gpio_num_t)P_LORA_PA_EN); pinMode(P_LORA_PA_EN, OUTPUT); digitalWrite(P_LORA_PA_EN,HIGH); + rtc_gpio_hold_dis((gpio_num_t)P_LORA_PA_EN); pinMode(P_LORA_PA_TX_EN, OUTPUT); digitalWrite(P_LORA_PA_TX_EN,LOW); @@ -50,7 +55,9 @@ void HeltecV4Board::begin() { rtc_gpio_hold_en((gpio_num_t)P_LORA_NSS); - rtc_gpio_hold_en((gpio_num_t)P_LORA_PA_EN); //It also needs to be enabled in receive mode + // Hold GC1109 FEM pins during sleep to keep LNA active for RX wake + rtc_gpio_hold_en((gpio_num_t)P_LORA_PA_POWER); + rtc_gpio_hold_en((gpio_num_t)P_LORA_PA_EN); if (pin_wake_btn < 0) { esp_sleep_enable_ext1_wakeup( (1L << P_LORA_DIO_1), ESP_EXT1_WAKEUP_ANY_HIGH); // wake up on: recv LoRa packet From d42588c25d7d9f097e76ab5f425822bd02a9f689 Mon Sep 17 00:00:00 2001 From: Wessel Nieboer Date: Sun, 8 Feb 2026 16:36:13 +0100 Subject: [PATCH 02/56] Add 1ms delay after powering PA (cold-boot) --- variants/heltec_tracker_v2/HeltecTrackerV2Board.cpp | 7 +++++-- variants/heltec_v4/HeltecV4Board.cpp | 6 ++++-- 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/variants/heltec_tracker_v2/HeltecTrackerV2Board.cpp b/variants/heltec_tracker_v2/HeltecTrackerV2Board.cpp index 1b694c11d..bd7f680ea 100644 --- a/variants/heltec_tracker_v2/HeltecTrackerV2Board.cpp +++ b/variants/heltec_tracker_v2/HeltecTrackerV2Board.cpp @@ -20,9 +20,12 @@ void HeltecTrackerV2Board::begin() { pinMode(P_LORA_PA_TX_EN, OUTPUT); digitalWrite(P_LORA_PA_TX_EN,LOW); - periph_power.begin(); - esp_reset_reason_t reason = esp_reset_reason(); + if (reason != ESP_RST_DEEPSLEEP) { + delay(1); // GC1109 startup time after cold power-on + } + + periph_power.begin(); if (reason == ESP_RST_DEEPSLEEP) { long wakeup_source = esp_sleep_get_ext1_wakeup_status(); if (wakeup_source & (1 << P_LORA_DIO_1)) { // received a LoRa packet (while in deep sleep) diff --git a/variants/heltec_v4/HeltecV4Board.cpp b/variants/heltec_v4/HeltecV4Board.cpp index 626f25773..8186f2d4b 100644 --- a/variants/heltec_v4/HeltecV4Board.cpp +++ b/variants/heltec_v4/HeltecV4Board.cpp @@ -21,10 +21,12 @@ void HeltecV4Board::begin() { pinMode(P_LORA_PA_TX_EN, OUTPUT); digitalWrite(P_LORA_PA_TX_EN,LOW); + esp_reset_reason_t reason = esp_reset_reason(); + if (reason != ESP_RST_DEEPSLEEP) { + delay(1); // GC1109 startup time after cold power-on + } periph_power.begin(); - - esp_reset_reason_t reason = esp_reset_reason(); if (reason == ESP_RST_DEEPSLEEP) { long wakeup_source = esp_sleep_get_ext1_wakeup_status(); if (wakeup_source & (1 << P_LORA_DIO_1)) { // received a LoRa packet (while in deep sleep) From 671bb1927d64d244803f50eb76f58be69585c3f2 Mon Sep 17 00:00:00 2001 From: Wessel Nieboer Date: Thu, 26 Feb 2026 10:36:02 +0100 Subject: [PATCH 03/56] Update Heltec V4 code to support V4.3 with KCT8103L FEM - runtime auto-detection of v4.3 board (KCT8103L FEM) vs V4.0-V4.2 (GC1109) via GPIO2 pull level - different TX/RX path using PGIO5 for KCT8103L, GPIO46 CPS for GC1109 - hold both FEMs active for RX - report Heltec V4.3 in manufacturer name --- variants/heltec_v4/HeltecV4Board.cpp | 74 +++++++++++++++++++--------- variants/heltec_v4/HeltecV4Board.h | 2 + variants/heltec_v4/platformio.ini | 1 + 3 files changed, 54 insertions(+), 23 deletions(-) diff --git a/variants/heltec_v4/HeltecV4Board.cpp b/variants/heltec_v4/HeltecV4Board.cpp index 8186f2d4b..7e9ecb08e 100644 --- a/variants/heltec_v4/HeltecV4Board.cpp +++ b/variants/heltec_v4/HeltecV4Board.cpp @@ -3,49 +3,71 @@ void HeltecV4Board::begin() { ESP32Board::begin(); - pinMode(PIN_ADC_CTRL, OUTPUT); - digitalWrite(PIN_ADC_CTRL, LOW); // Initially inactive + digitalWrite(PIN_ADC_CTRL, LOW); - // Set up digital GPIO registers before releasing RTC hold. The hold latches - // the pad state including function select, so register writes accumulate - // without affecting the pad. On hold release, all changes apply atomically - // (IO MUX switches to digital GPIO with output already HIGH — no glitch). + // Power on FEM LDO — set registers before releasing RTC hold for + // atomic transition (no glitch on deep sleep wake). pinMode(P_LORA_PA_POWER, OUTPUT); - digitalWrite(P_LORA_PA_POWER,HIGH); + digitalWrite(P_LORA_PA_POWER, HIGH); rtc_gpio_hold_dis((gpio_num_t)P_LORA_PA_POWER); - pinMode(P_LORA_PA_EN, OUTPUT); - digitalWrite(P_LORA_PA_EN,HIGH); - rtc_gpio_hold_dis((gpio_num_t)P_LORA_PA_EN); - pinMode(P_LORA_PA_TX_EN, OUTPUT); - digitalWrite(P_LORA_PA_TX_EN,LOW); - esp_reset_reason_t reason = esp_reset_reason(); if (reason != ESP_RST_DEEPSLEEP) { - delay(1); // GC1109 startup time after cold power-on + delay(1); // FEM startup time after cold power-on + } + + // Auto-detect FEM type via GPIO2 default pull level. + // GC1109 CSD: internal pull-down → reads LOW + // KCT8103L CSD: internal pull-up → reads HIGH + rtc_gpio_hold_dis((gpio_num_t)P_LORA_PA_EN); + pinMode(P_LORA_PA_EN, INPUT); + delay(1); + is_kct8103l_ = (digitalRead(P_LORA_PA_EN) == HIGH); + + // CSD/enable: HIGH for both FEM types + pinMode(P_LORA_PA_EN, OUTPUT); + digitalWrite(P_LORA_PA_EN, HIGH); + + if (is_kct8103l_) { + // V4.3 — KCT8103L: CTX on GPIO5 controls TX/RX path + rtc_gpio_hold_dis((gpio_num_t)P_LORA_PA_CTX); + pinMode(P_LORA_PA_CTX, OUTPUT); + digitalWrite(P_LORA_PA_CTX, LOW); // RX mode (LNA enabled) + } else { + // V4.2 — GC1109: CPS on GPIO46 controls PA mode + pinMode(P_LORA_PA_TX_EN, OUTPUT); + digitalWrite(P_LORA_PA_TX_EN, LOW); // RX bypass mode } periph_power.begin(); + if (reason == ESP_RST_DEEPSLEEP) { long wakeup_source = esp_sleep_get_ext1_wakeup_status(); - if (wakeup_source & (1 << P_LORA_DIO_1)) { // received a LoRa packet (while in deep sleep) + if (wakeup_source & (1 << P_LORA_DIO_1)) { startup_reason = BD_STARTUP_RX_PACKET; } - rtc_gpio_hold_dis((gpio_num_t)P_LORA_NSS); rtc_gpio_deinit((gpio_num_t)P_LORA_DIO_1); } } void HeltecV4Board::onBeforeTransmit(void) { - digitalWrite(P_LORA_TX_LED, HIGH); // turn TX LED on - digitalWrite(P_LORA_PA_TX_EN,HIGH); + digitalWrite(P_LORA_TX_LED, HIGH); + if (is_kct8103l_) { + digitalWrite(P_LORA_PA_CTX, HIGH); // CTX: TX path + } else { + digitalWrite(P_LORA_PA_TX_EN, HIGH); // CPS: full PA + } } void HeltecV4Board::onAfterTransmit(void) { - digitalWrite(P_LORA_TX_LED, LOW); // turn TX LED off - digitalWrite(P_LORA_PA_TX_EN,LOW); + digitalWrite(P_LORA_TX_LED, LOW); + if (is_kct8103l_) { + digitalWrite(P_LORA_PA_CTX, LOW); // CTX: RX path (LNA on) + } else { + digitalWrite(P_LORA_PA_TX_EN, LOW); // CPS: bypass + } } void HeltecV4Board::enterDeepSleep(uint32_t secs, int pin_wake_btn) { @@ -57,10 +79,16 @@ void HeltecV4Board::begin() { rtc_gpio_hold_en((gpio_num_t)P_LORA_NSS); - // Hold GC1109 FEM pins during sleep to keep LNA active for RX wake + // Hold FEM pins during sleep to keep LNA active for RX wake rtc_gpio_hold_en((gpio_num_t)P_LORA_PA_POWER); rtc_gpio_hold_en((gpio_num_t)P_LORA_PA_EN); + if (is_kct8103l_) { + // Hold CTX LOW during deep sleep for RX wake (LNA enabled) + digitalWrite(P_LORA_PA_CTX, LOW); + rtc_gpio_hold_en((gpio_num_t)P_LORA_PA_CTX); + } + if (pin_wake_btn < 0) { esp_sleep_enable_ext1_wakeup( (1L << P_LORA_DIO_1), ESP_EXT1_WAKEUP_ANY_HIGH); // wake up on: recv LoRa packet } else { @@ -96,8 +124,8 @@ void HeltecV4Board::begin() { const char* HeltecV4Board::getManufacturerName() const { #ifdef HELTEC_LORA_V4_TFT - return "Heltec V4 TFT"; + return is_kct8103l_ ? "Heltec V4.3 TFT" : "Heltec V4 TFT"; #else - return "Heltec V4 OLED"; + return is_kct8103l_ ? "Heltec V4.3 OLED" : "Heltec V4 OLED"; #endif } diff --git a/variants/heltec_v4/HeltecV4Board.h b/variants/heltec_v4/HeltecV4Board.h index 745e8d8f3..8a2524a84 100644 --- a/variants/heltec_v4/HeltecV4Board.h +++ b/variants/heltec_v4/HeltecV4Board.h @@ -20,4 +20,6 @@ class HeltecV4Board : public ESP32Board { uint16_t getBattMilliVolts() override; const char* getManufacturerName() const override ; +private: + bool is_kct8103l_ = false; // true = V4.3 (KCT8103L), false = V4.2 (GC1109) }; diff --git a/variants/heltec_v4/platformio.ini b/variants/heltec_v4/platformio.ini index 71ffc2e6a..b8aedb0ba 100644 --- a/variants/heltec_v4/platformio.ini +++ b/variants/heltec_v4/platformio.ini @@ -20,6 +20,7 @@ build_flags = -D P_LORA_PA_POWER=7 ; VFEM_Ctrl - Power on GC1109 -D P_LORA_PA_EN=2 ; PA CSD - Enable GC1109 -D P_LORA_PA_TX_EN=46 ; PA CPS - GC1109 TX PA full(High) / bypass(Low) + -D P_LORA_PA_CTX=5 ; KCT8103L CTX pin (V4.3 only, auto-detected) -D PIN_USER_BTN=0 -D PIN_VEXT_EN=36 -D PIN_VEXT_EN_ACTIVE=HIGH From 2d43be094d2240727b42260d57d2f6625ee9b72c Mon Sep 17 00:00:00 2001 From: Wessel Nieboer Date: Thu, 8 Jan 2026 17:02:58 +0100 Subject: [PATCH 04/56] Save some more power when BLE/WiFi is disabled on the companion radio --- examples/companion_radio/MyMesh.cpp | 5 ++++ examples/companion_radio/MyMesh.h | 1 + examples/companion_radio/main.cpp | 35 +++++++++++++++++++++++ src/helpers/ESP32Board.h | 12 ++++++-- src/helpers/esp32/SerialWifiInterface.cpp | 22 +++++++++++++- src/helpers/esp32/SerialWifiInterface.h | 4 +++ 6 files changed, 76 insertions(+), 3 deletions(-) diff --git a/examples/companion_radio/MyMesh.cpp b/examples/companion_radio/MyMesh.cpp index c96f7e017..48a4ce3f4 100644 --- a/examples/companion_radio/MyMesh.cpp +++ b/examples/companion_radio/MyMesh.cpp @@ -2046,3 +2046,8 @@ bool MyMesh::advert() { return false; } } + +// Check if there is pending work (packets to send) +bool MyMesh::hasPendingWork() const { + return _mgr->getOutboundCount(0xFFFFFFFF) > 0; +} diff --git a/examples/companion_radio/MyMesh.h b/examples/companion_radio/MyMesh.h index 87e6cf338..ad766af1e 100644 --- a/examples/companion_radio/MyMesh.h +++ b/examples/companion_radio/MyMesh.h @@ -161,6 +161,7 @@ class MyMesh : public BaseChatMesh, public DataStoreHost { public: void savePrefs() { _store->savePrefs(_prefs, sensors.node_lat, sensors.node_lon); } + bool hasPendingWork() const; private: void writeOKFrame(); diff --git a/examples/companion_radio/main.cpp b/examples/companion_radio/main.cpp index eff9efca4..0674ddea8 100644 --- a/examples/companion_radio/main.cpp +++ b/examples/companion_radio/main.cpp @@ -99,6 +99,11 @@ MyMesh the_mesh(radio_driver, fast_rng, rtc_clock, tables, store #endif ); +// Power saving timing variables +unsigned long lastActive = 0; // Last time there was activity +unsigned long nextSleepInSecs = 120; // Wait 2 minutes before first sleep +const unsigned long WORK_TIME_SECS = 5; // Stay awake 5 seconds after wake/activity + /* END GLOBAL OBJECTS */ void halt() { @@ -216,6 +221,9 @@ void setup() { #ifdef DISPLAY_CLASS ui_task.begin(disp, &sensors, the_mesh.getNodePrefs()); // still want to pass this in as dependency, as prefs might be moved #endif + + // Initialize power saving timer + lastActive = millis(); } void loop() { @@ -225,4 +233,31 @@ void loop() { ui_task.loop(); #endif rtc_clock.tick(); + + // Power saving when BLE/WiFi is disabled + // Don't sleep if GPS is enabled - it needs continuous operation to maintain fix + // Note: Disabling BLE/WiFi via UI actually turns off the radio to save power + if (!serial_interface.isEnabled() && !the_mesh.getNodePrefs()->gps_enabled) { + // Check for pending work and update activity timer + if (the_mesh.hasPendingWork()) { + lastActive = millis(); + if (nextSleepInSecs < 10) { + nextSleepInSecs += 5; // Extend work time by 5s if still busy + } + } + + // Only sleep if enough time has passed since last activity + if (millis() >= lastActive + (nextSleepInSecs * 1000)) { +#ifdef PIN_USER_BTN + // Sleep for 30 minutes, wake on LoRa packet, timer, or button press + board.enterLightSleep(1800, PIN_USER_BTN); +#else + // Sleep for 30 minutes, wake on LoRa packet or timer + board.enterLightSleep(1800); +#endif + // Just woke up - reset timers + lastActive = millis(); + nextSleepInSecs = WORK_TIME_SECS; // Stay awake for 5s after wake + } + } } diff --git a/src/helpers/ESP32Board.h b/src/helpers/ESP32Board.h index bade3e898..c7c63b691 100644 --- a/src/helpers/ESP32Board.h +++ b/src/helpers/ESP32Board.h @@ -9,6 +9,7 @@ #include #include #include "driver/rtc_io.h" +#include "driver/gpio.h" class ESP32Board : public mesh::MainBoard { protected: @@ -56,11 +57,18 @@ class ESP32Board : public mesh::MainBoard { return raw / 4; } - void enterLightSleep(uint32_t secs) { + void enterLightSleep(uint32_t secs, int pin_wake_btn = -1) { #if defined(CONFIG_IDF_TARGET_ESP32S3) && defined(P_LORA_DIO_1) // Supported ESP32 variants if (rtc_gpio_is_valid_gpio((gpio_num_t)P_LORA_DIO_1)) { // Only enter sleep mode if P_LORA_DIO_1 is RTC pin esp_sleep_pd_config(ESP_PD_DOMAIN_RTC_PERIPH, ESP_PD_OPTION_ON); - esp_sleep_enable_ext1_wakeup((1L << P_LORA_DIO_1), ESP_EXT1_WAKEUP_ANY_HIGH); // To wake up when receiving a LoRa packet + + esp_sleep_enable_ext1_wakeup((1L << P_LORA_DIO_1), ESP_EXT1_WAKEUP_ANY_HIGH); // Wake on LoRa packet + + // Wake on button press (active-LOW: pin is HIGH when idle, LOW when pressed) + if (pin_wake_btn >= 0) { + gpio_wakeup_enable((gpio_num_t)pin_wake_btn, GPIO_INTR_LOW_LEVEL); + esp_sleep_enable_gpio_wakeup(); + } if (secs > 0) { esp_sleep_enable_timer_wakeup(secs * 1000000); // To wake up every hour to do periodically jobs diff --git a/src/helpers/esp32/SerialWifiInterface.cpp b/src/helpers/esp32/SerialWifiInterface.cpp index 462e3ecc3..f4ebf5d69 100644 --- a/src/helpers/esp32/SerialWifiInterface.cpp +++ b/src/helpers/esp32/SerialWifiInterface.cpp @@ -4,18 +4,38 @@ void SerialWifiInterface::begin(int port) { // wifi setup is handled outside of this class, only starts the server server.begin(port); + + // Store WiFi credentials for re-enable +#ifdef WIFI_SSID + _ssid = WIFI_SSID; + _password = WIFI_PWD; + _isEnabled = true; // WiFi starts enabled +#else + _ssid = nullptr; + _password = nullptr; +#endif } // ---------- public methods -void SerialWifiInterface::enable() { +void SerialWifiInterface::enable() { if (_isEnabled) return; _isEnabled = true; clearBuffers(); + + // Re-enable WiFi with stored credentials + if (_ssid != nullptr && _password != nullptr) { + WiFi.mode(WIFI_STA); + WiFi.begin(_ssid, _password); + } } void SerialWifiInterface::disable() { _isEnabled = false; + + // Actually turn off WiFi to save power + WiFi.disconnect(true); // Disconnect and clear config + WiFi.mode(WIFI_OFF); // Turn off WiFi radio } size_t SerialWifiInterface::writeFrame(const uint8_t src[], size_t len) { diff --git a/src/helpers/esp32/SerialWifiInterface.h b/src/helpers/esp32/SerialWifiInterface.h index 19291497f..f900d18bc 100644 --- a/src/helpers/esp32/SerialWifiInterface.h +++ b/src/helpers/esp32/SerialWifiInterface.h @@ -8,6 +8,8 @@ class SerialWifiInterface : public BaseSerialInterface { bool _isEnabled; unsigned long _last_write; unsigned long adv_restart_time; + const char* _ssid; + const char* _password; WiFiServer server; WiFiClient client; @@ -39,6 +41,8 @@ class SerialWifiInterface : public BaseSerialInterface { deviceConnected = false; _isEnabled = false; _last_write = 0; + _ssid = nullptr; + _password = nullptr; send_queue_len = recv_queue_len = 0; received_frame_header.type = 0; received_frame_header.length = 0; From fd7c82a54a6f943e4dca26817156593345970022 Mon Sep 17 00:00:00 2001 From: Wessel Nieboer Date: Wed, 11 Feb 2026 03:55:02 +0100 Subject: [PATCH 05/56] Fix millis() overflow in companion powersave sleep timing Use millisHasNowPassed() (2's complement safe) instead of direct comparison, consistent with the repeater's sleep timing logic. Co-Authored-By: Wessel --- examples/companion_radio/main.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/companion_radio/main.cpp b/examples/companion_radio/main.cpp index 0674ddea8..0dc5f1224 100644 --- a/examples/companion_radio/main.cpp +++ b/examples/companion_radio/main.cpp @@ -247,7 +247,7 @@ void loop() { } // Only sleep if enough time has passed since last activity - if (millis() >= lastActive + (nextSleepInSecs * 1000)) { + if (the_mesh.millisHasNowPassed(lastActive + (nextSleepInSecs * 1000))) { #ifdef PIN_USER_BTN // Sleep for 30 minutes, wake on LoRa packet, timer, or button press board.enterLightSleep(1800, PIN_USER_BTN); From 65f197329dc4c14d2e3e18758fbd9f4c41cb9dcf Mon Sep 17 00:00:00 2001 From: Wessel Nieboer Date: Fri, 13 Feb 2026 16:00:11 +0100 Subject: [PATCH 06/56] Short sleep cycle when phone disconnects from BLE companion radio MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When BLE is enabled but no phone has been connected for 60 seconds, enter a 12s sleep / 3s awake cycle to conserve power while remaining discoverable. On wake, BLE advertising is restarted so phones can reconnect. The cycle exits immediately when a connection is detected. Adds hasPendingConnection() to BaseSerialInterface (defaults to isConnected()). The ESP32 BLE override uses getConnectedCount() > 0 to detect mid-bonding connections, preventing sleep during the authentication handshake. Guarded by #ifndef WIFI_SSID — WiFi builds are unaffected. --- examples/companion_radio/main.cpp | 35 ++++++++++++++++++++++++ src/helpers/BaseSerialInterface.h | 1 + src/helpers/esp32/SerialBLEInterface.cpp | 4 +++ src/helpers/esp32/SerialBLEInterface.h | 1 + 4 files changed, 41 insertions(+) diff --git a/examples/companion_radio/main.cpp b/examples/companion_radio/main.cpp index 0dc5f1224..57cad75e7 100644 --- a/examples/companion_radio/main.cpp +++ b/examples/companion_radio/main.cpp @@ -104,6 +104,13 @@ unsigned long lastActive = 0; // Last time there was activity unsigned long nextSleepInSecs = 120; // Wait 2 minutes before first sleep const unsigned long WORK_TIME_SECS = 5; // Stay awake 5 seconds after wake/activity +// Short-sleep cycle when phone is disconnected but BLE is enabled +const unsigned long DISCONNECT_SLEEP_TIMEOUT_MS = 60000; // 60s before short-sleep cycle +const unsigned long SHORT_SLEEP_SECS = 12; // sleep duration per cycle +const unsigned long RECONNECT_WINDOW_MS = 3000; // awake time for BLE advertising +unsigned long disconnectTime = 0; // when phone disconnected (0 = connected/N/A) +unsigned long lastSleepWake = 0; // when we last woke from short sleep (0 = not in cycle) + /* END GLOBAL OBJECTS */ void halt() { @@ -234,6 +241,34 @@ void loop() { #endif rtc_clock.tick(); +#ifndef WIFI_SSID + // Track phone connection state for disconnect sleep + if (serial_interface.hasPendingConnection()) { + disconnectTime = 0; + lastSleepWake = 0; + } else if (serial_interface.isEnabled() && disconnectTime == 0) { + disconnectTime = millis(); + if (disconnectTime == 0) disconnectTime = 1; // avoid 0 sentinel collision + } + // Short-sleep cycle when BLE is enabled but phone is disconnected + if (serial_interface.isEnabled() && disconnectTime != 0 + && !the_mesh.getNodePrefs()->gps_enabled + && the_mesh.millisHasNowPassed(disconnectTime + DISCONNECT_SLEEP_TIMEOUT_MS) + && !the_mesh.hasPendingWork() + && (lastSleepWake == 0 || the_mesh.millisHasNowPassed(lastSleepWake + RECONNECT_WINDOW_MS))) { +#ifdef PIN_USER_BTN + board.enterLightSleep(SHORT_SLEEP_SECS, PIN_USER_BTN); +#else + board.enterLightSleep(SHORT_SLEEP_SECS); +#endif + // Restart BLE advertising after light sleep powers down the radio + serial_interface.disable(); + serial_interface.enable(); + lastSleepWake = millis(); + if (lastSleepWake == 0) lastSleepWake = 1; + } +#endif + // Power saving when BLE/WiFi is disabled // Don't sleep if GPS is enabled - it needs continuous operation to maintain fix // Note: Disabling BLE/WiFi via UI actually turns off the radio to save power diff --git a/src/helpers/BaseSerialInterface.h b/src/helpers/BaseSerialInterface.h index e60927654..7d9ff7263 100644 --- a/src/helpers/BaseSerialInterface.h +++ b/src/helpers/BaseSerialInterface.h @@ -14,6 +14,7 @@ class BaseSerialInterface { virtual bool isEnabled() const = 0; virtual bool isConnected() const = 0; + virtual bool hasPendingConnection() const { return isConnected(); } virtual bool isWriteBusy() const = 0; virtual size_t writeFrame(const uint8_t src[], size_t len) = 0; diff --git a/src/helpers/esp32/SerialBLEInterface.cpp b/src/helpers/esp32/SerialBLEInterface.cpp index dcfa0e1e3..d3ff10f91 100644 --- a/src/helpers/esp32/SerialBLEInterface.cpp +++ b/src/helpers/esp32/SerialBLEInterface.cpp @@ -251,3 +251,7 @@ size_t SerialBLEInterface::checkRecvFrame(uint8_t dest[]) { bool SerialBLEInterface::isConnected() const { return deviceConnected; //pServer != NULL && pServer->getConnectedCount() > 0; } + +bool SerialBLEInterface::hasPendingConnection() const { + return pServer != NULL && pServer->getConnectedCount() > 0; +} diff --git a/src/helpers/esp32/SerialBLEInterface.h b/src/helpers/esp32/SerialBLEInterface.h index 965e90fd1..519adbb2f 100644 --- a/src/helpers/esp32/SerialBLEInterface.h +++ b/src/helpers/esp32/SerialBLEInterface.h @@ -75,6 +75,7 @@ class SerialBLEInterface : public BaseSerialInterface, BLESecurityCallbacks, BLE bool isEnabled() const override { return _isEnabled; } bool isConnected() const override; + bool hasPendingConnection() const override; bool isWriteBusy() const override; size_t writeFrame(const uint8_t src[], size_t len) override; From 488616387257af96cc7b26612eb1b78477e6999e Mon Sep 17 00:00:00 2001 From: Wessel Nieboer Date: Wed, 11 Feb 2026 04:16:44 +0100 Subject: [PATCH 07/56] fix out_frame buffer overflow in companion radio response handlers The onContactResponse handler copies peer response data into out_frame (MAX_FRAME_SIZE + 1 bytes) without checking whether the data fits. A peer response with len close to MAX_PACKET_PAYLOAD (184) writes up to 188 bytes into the 173-byte buffer, overflowing by 15 bytes. This affects the status response, telemetry response, and binary response code paths. A malicious peer can trigger the overflow by sending a large response payload, corrupting the stack. Cap each memcpy to the remaining space in out_frame before copying. --- examples/companion_radio/MyMesh.cpp | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/examples/companion_radio/MyMesh.cpp b/examples/companion_radio/MyMesh.cpp index c96f7e017..88b90cc15 100644 --- a/examples/companion_radio/MyMesh.cpp +++ b/examples/companion_radio/MyMesh.cpp @@ -650,8 +650,10 @@ void MyMesh::onContactResponse(const ContactInfo &contact, const uint8_t *data, out_frame[i++] = 0; // reserved memcpy(&out_frame[i], contact.id.pub_key, 6); i += 6; // pub_key_prefix - memcpy(&out_frame[i], &data[4], len - 4); - i += (len - 4); + int copy_len = len - 4; + if (copy_len > MAX_FRAME_SIZE - i) copy_len = MAX_FRAME_SIZE - i; + memcpy(&out_frame[i], &data[4], copy_len); + i += copy_len; _serial->writeFrame(out_frame, i); } else if (len > 4 && tag == pending_telemetry) { // check for matching response tag pending_telemetry = 0; @@ -661,8 +663,10 @@ void MyMesh::onContactResponse(const ContactInfo &contact, const uint8_t *data, out_frame[i++] = 0; // reserved memcpy(&out_frame[i], contact.id.pub_key, 6); i += 6; // pub_key_prefix - memcpy(&out_frame[i], &data[4], len - 4); - i += (len - 4); + int copy_len = len - 4; + if (copy_len > MAX_FRAME_SIZE - i) copy_len = MAX_FRAME_SIZE - i; + memcpy(&out_frame[i], &data[4], copy_len); + i += copy_len; _serial->writeFrame(out_frame, i); } else if (len > 4 && tag == pending_req) { // check for matching response tag pending_req = 0; @@ -672,8 +676,10 @@ void MyMesh::onContactResponse(const ContactInfo &contact, const uint8_t *data, out_frame[i++] = 0; // reserved memcpy(&out_frame[i], &tag, 4); // app needs to match this to RESP_CODE_SENT.tag i += 4; - memcpy(&out_frame[i], &data[4], len - 4); - i += (len - 4); + int copy_len = len - 4; + if (copy_len > MAX_FRAME_SIZE - i) copy_len = MAX_FRAME_SIZE - i; + memcpy(&out_frame[i], &data[4], copy_len); + i += copy_len; _serial->writeFrame(out_frame, i); } } From 0473b6be631f3a5be64caf5e5de40cc42f85bea7 Mon Sep 17 00:00:00 2001 From: Wessel Nieboer Date: Mon, 16 Feb 2026 09:09:00 +0100 Subject: [PATCH 08/56] Fix sleep nRF52 --- src/helpers/NRF52Board.h | 1 + 1 file changed, 1 insertion(+) diff --git a/src/helpers/NRF52Board.h b/src/helpers/NRF52Board.h index 96f67dc95..f64e878cd 100644 --- a/src/helpers/NRF52Board.h +++ b/src/helpers/NRF52Board.h @@ -53,6 +53,7 @@ class NRF52Board : public mesh::MainBoard { virtual bool getBootloaderVersion(char* version, size_t max_len) override; virtual bool startOTAUpdate(const char *id, char reply[]) override; virtual void sleep(uint32_t secs) override; + void enterLightSleep(uint32_t secs, int pin_wake_btn = -1) { sleep(secs); } #ifdef NRF52_POWER_MANAGEMENT bool isExternalPowered() override; From ce99d64a2ee87e97037cf00ee21f59d848641712 Mon Sep 17 00:00:00 2001 From: Wessel Nieboer Date: Thu, 26 Feb 2026 09:44:26 +0100 Subject: [PATCH 09/56] Validate path length against max path size --- examples/simple_repeater/MyMesh.cpp | 3 +++ src/Packet.cpp | 6 ++++-- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/examples/simple_repeater/MyMesh.cpp b/examples/simple_repeater/MyMesh.cpp index 81c1dcb42..5a809e24f 100644 --- a/examples/simple_repeater/MyMesh.cpp +++ b/examples/simple_repeater/MyMesh.cpp @@ -151,6 +151,7 @@ uint8_t MyMesh::handleAnonRegionsReq(const mesh::Identity& sender, uint32_t send reply_path_hash_size = (*data >> 6) + 1; data++; + if (reply_path_len * reply_path_hash_size > MAX_PATH_SIZE) return 0; memcpy(reply_path, data, ((uint8_t)reply_path_len) * reply_path_hash_size); // data += (uint8_t)reply_path_len * reply_path_hash_size; @@ -170,6 +171,7 @@ uint8_t MyMesh::handleAnonOwnerReq(const mesh::Identity& sender, uint32_t sender reply_path_hash_size = (*data >> 6) + 1; data++; + if (reply_path_len * reply_path_hash_size > MAX_PATH_SIZE) return 0; memcpy(reply_path, data, ((uint8_t)reply_path_len) * reply_path_hash_size); // data += (uint8_t)reply_path_len * reply_path_hash_size; @@ -190,6 +192,7 @@ uint8_t MyMesh::handleAnonClockReq(const mesh::Identity& sender, uint32_t sender reply_path_hash_size = (*data >> 6) + 1; data++; + if (reply_path_len * reply_path_hash_size > MAX_PATH_SIZE) return 0; memcpy(reply_path, data, ((uint8_t)reply_path_len) * reply_path_hash_size); // data += (uint8_t)reply_path_len * reply_path_hash_size; diff --git a/src/Packet.cpp b/src/Packet.cpp index aad3e2f48..180e6eabb 100644 --- a/src/Packet.cpp +++ b/src/Packet.cpp @@ -22,7 +22,7 @@ size_t Packet::writePath(uint8_t* dest, const uint8_t* src, uint8_t path_len) { uint8_t hash_size = (path_len >> 6) + 1; size_t len = hash_count*hash_size; if (len > MAX_PATH_SIZE) { - MESH_DEBUG_PRINTLN("Packet::copyPath, invalid path_len=%d", (uint32_t)path_len); + MESH_DEBUG_PRINTLN("Packet::writePath, invalid path_len=%d", (uint32_t)path_len); return 0; // Error } memcpy(dest, src, len); @@ -30,7 +30,9 @@ size_t Packet::writePath(uint8_t* dest, const uint8_t* src, uint8_t path_len) { } uint8_t Packet::copyPath(uint8_t* dest, const uint8_t* src, uint8_t path_len) { - writePath(dest, src, path_len); + if (writePath(dest, src, path_len) == 0 && (path_len & 63) != 0) { + return 0; // Error: writePath failed for non-empty path + } return path_len; } From 61a125fb1abcc8ce239662e56277b23cd4364816 Mon Sep 17 00:00:00 2001 From: Wessel Nieboer Date: Wed, 11 Feb 2026 03:38:50 +0100 Subject: [PATCH 10/56] tighten TRACE path_len guard to account for SNR append The TRACE forwarding path appends an SNR byte to pkt->path via path_len++, but the guard only checked path_len < MAX_PATH_SIZE. When path_len entered as MAX_PATH_SIZE - 1, the write was in-bounds but left path_len equal to MAX_PATH_SIZE, which could cause off-by-one issues in downstream code that uses path_len as an index. Change the guard to path_len + 1 < MAX_PATH_SIZE so there is always room for the append without path_len reaching MAX_PATH_SIZE. --- src/Mesh.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Mesh.cpp b/src/Mesh.cpp index 57fee1403..5968313ef 100644 --- a/src/Mesh.cpp +++ b/src/Mesh.cpp @@ -40,7 +40,7 @@ int Mesh::searchChannelsByHash(const uint8_t* hash, GroupChannel channels[], int DispatcherAction Mesh::onRecvPacket(Packet* pkt) { if (pkt->isRouteDirect() && pkt->getPayloadType() == PAYLOAD_TYPE_TRACE) { - if (pkt->path_len < MAX_PATH_SIZE) { + if (pkt->path_len + 1 < MAX_PATH_SIZE) { uint8_t i = 0; uint32_t trace_tag; memcpy(&trace_tag, &pkt->payload[i], 4); i += 4; From ed04bc06dbadec386c95ba2518476e33763e2612 Mon Sep 17 00:00:00 2001 From: Wessel Nieboer Date: Thu, 12 Feb 2026 01:01:56 +0100 Subject: [PATCH 11/56] Add ChaChaPoly AEAD-4 decryption support (Phase 1) Add ChaCha20-Poly1305 AEAD decryption with 4-byte auth tag for peer messages and group channels, falling back to ECB for backward compatibility. Sending remains ECB-only in this phase. - Per-message key derivation: HMAC-SHA256(secret, nonce||dest||src) - Direction-dependent keys prevent bidirectional keystream reuse - 12-byte IV from nonce + dest_hash + src_hash - Advertise AEAD capability via feat1 bit 0 in adverts - Track peer AEAD support in ContactInfo.flags - Seed aead_nonce from HW RNG on contact creation and load --- src/Mesh.cpp | 39 ++++++++++-- src/MeshCore.h | 6 ++ src/Utils.cpp | 115 +++++++++++++++++++++++++++++++++++ src/Utils.h | 23 +++++++ src/helpers/BaseChatMesh.cpp | 12 ++++ src/helpers/CommonCLI.cpp | 3 + src/helpers/ContactInfo.h | 1 + 7 files changed, 193 insertions(+), 6 deletions(-) diff --git a/src/Mesh.cpp b/src/Mesh.cpp index 57fee1403..3a1f22d0c 100644 --- a/src/Mesh.cpp +++ b/src/Mesh.cpp @@ -146,9 +146,19 @@ DispatcherAction Mesh::onRecvPacket(Packet* pkt) { uint8_t secret[PUB_KEY_SIZE]; getPeerSharedSecret(secret, j); - // decrypt, checking MAC is valid uint8_t data[MAX_PACKET_PAYLOAD]; - int len = Utils::MACThenDecrypt(secret, data, macAndData, pkt->payload_len - i); + int macAndDataLen = pkt->payload_len - i; + + // Try ECB first (Phase 1: all senders use ECB), then AEAD-4 fallback. + // IMPORTANT: Phase 2 MUST swap to AEAD-first. ECB-first has a 1/65536 + // false-positive rate on AEAD packets (nonce bytes matching truncated HMAC), + // producing garbage plaintext. AEAD-first has only 1/2^32 false-positive on + // ECB packets, which is negligible. + int len = Utils::MACThenDecrypt(secret, data, macAndData, macAndDataLen); + if (len <= 0) { + uint8_t assoc[3] = { pkt->header, dest_hash, src_hash }; + len = Utils::aeadDecrypt(secret, data, macAndData, macAndDataLen, assoc, 3, dest_hash, src_hash); + } if (len > 0) { // success! if (pkt->getPayloadType() == PAYLOAD_TYPE_PATH) { int k = 0; @@ -198,9 +208,16 @@ DispatcherAction Mesh::onRecvPacket(Packet* pkt) { uint8_t secret[PUB_KEY_SIZE]; self_id.calcSharedSecret(secret, sender); - // decrypt, checking MAC is valid uint8_t data[MAX_PACKET_PAYLOAD]; - int len = Utils::MACThenDecrypt(secret, data, macAndData, pkt->payload_len - i); + int macAndDataLen = pkt->payload_len - i; + + // Try ECB first (Phase 1), then AEAD-4 fallback. + // Phase 2 MUST swap to AEAD-first (see peer message comment above). + int len = Utils::MACThenDecrypt(secret, data, macAndData, macAndDataLen); + if (len <= 0) { + uint8_t assoc[2] = { pkt->header, dest_hash }; + len = Utils::aeadDecrypt(secret, data, macAndData, macAndDataLen, assoc, 2, dest_hash, 0); + } if (len > 0) { // success! onAnonDataRecv(pkt, secret, sender, data, len); pkt->markDoNotRetransmit(); @@ -224,9 +241,19 @@ DispatcherAction Mesh::onRecvPacket(Packet* pkt) { int num = searchChannelsByHash(&channel_hash, channels, 4); // for each matching channel, try to decrypt data for (int j = 0; j < num; j++) { - // decrypt, checking MAC is valid uint8_t data[MAX_PACKET_PAYLOAD]; - int len = Utils::MACThenDecrypt(channels[j].secret, data, macAndData, pkt->payload_len - i); + int macAndDataLen = pkt->payload_len - i; + + // Try ECB first (Phase 1), then AEAD-4 fallback. + // Phase 2 MUST swap to AEAD-first (see peer message comment above). + // Note: group channels share a key, so nonce collisions across senders can leak + // P1 XOR P2 for colliding message pairs (no key recovery). Bounded risk, mainly + // worthwhile for public/hashtag channels where the PSK is already widely known. + int len = Utils::MACThenDecrypt(channels[j].secret, data, macAndData, macAndDataLen); + if (len <= 0) { + uint8_t assoc[2] = { pkt->header, channel_hash }; + len = Utils::aeadDecrypt(channels[j].secret, data, macAndData, macAndDataLen, assoc, 2, channel_hash, 0); + } if (len > 0) { // success! onGroupDataRecv(pkt, pkt->getPayloadType(), channels[j], data, len); break; diff --git a/src/MeshCore.h b/src/MeshCore.h index 70cd0f067..635401d93 100644 --- a/src/MeshCore.h +++ b/src/MeshCore.h @@ -16,6 +16,12 @@ #define CIPHER_MAC_SIZE 2 #define PATH_HASH_SIZE 1 +// AEAD-4 (ChaChaPoly) encryption +#define AEAD_TAG_SIZE 4 +#define AEAD_NONCE_SIZE 2 +#define CONTACT_FLAG_AEAD 0x02 // bit 1 of ContactInfo.flags (bit 0 = favourite) +#define FEAT1_AEAD_SUPPORT 0x0001 // bit 0 of feat1 uint16_t + #define MAX_PACKET_PAYLOAD 184 #define MAX_PATH_SIZE 64 #define MAX_TRANS_UNIT 255 diff --git a/src/Utils.cpp b/src/Utils.cpp index 186c8720a..a0c98b880 100644 --- a/src/Utils.cpp +++ b/src/Utils.cpp @@ -1,6 +1,7 @@ #include "Utils.h" #include #include +#include #ifdef ARDUINO #include @@ -87,6 +88,120 @@ int Utils::MACThenDecrypt(const uint8_t* shared_secret, uint8_t* dest, const uin return 0; // invalid HMAC } +/* + * AEAD-4: ChaCha20-Poly1305 authenticated encryption with 4-byte tag. + * + * Wire format (replaces ECB's [HMAC:2][ciphertext:N*16]): + * [nonce:2] [ciphertext:M] [tag:4] (M = exact plaintext length) + * + * Key derivation (per-message, eliminates nonce-reuse catastrophe): + * msg_key[32] = HMAC-SHA256(shared_secret[32], nonce_hi || nonce_lo || dest_hash || src_hash) + * Including hashes makes keys direction-dependent: Alice->Bob and Bob->Alice derive + * different keys even with the same nonce (for 255/256 peer pairs; the 1/256 where + * dest_hash == src_hash remains a residual risk inherent to 1-byte hashes). + * + * IV construction (12 bytes, from on-wire fields): + * iv[12] = { nonce_hi, nonce_lo, dest_hash, src_hash, 0, 0, 0, 0, 0, 0, 0, 0 } + * + * Associated data (authenticated but not encrypted): + * Peer msgs: header || dest_hash || src_hash + * Anon reqs: header || dest_hash + * Group msgs: header || channel_hash + * + * Nonce: 16-bit counter per peer, seeded from HW RNG on boot. With per-message + * key derivation, even a nonce collision (across reboots) only leaks P1 XOR P2 + * for that message pair — no key recovery, no impact on other messages. + * + * Group channels: all members share the same key, so cross-sender nonce + * collisions are possible (~300 msgs for 50% chance with random nonces). + * Damage is bounded (message pair leak, no key recovery). + */ +int Utils::aeadEncrypt(const uint8_t* shared_secret, + uint8_t* dest, + const uint8_t* src, int src_len, + const uint8_t* assoc_data, int assoc_len, + uint16_t nonce_counter, + uint8_t dest_hash, uint8_t src_hash) { + if (src_len <= 0) return 0; + + // Write nonce to output + dest[0] = (uint8_t)(nonce_counter >> 8); + dest[1] = (uint8_t)(nonce_counter & 0xFF); + + // Derive per-message key: HMAC-SHA256(shared_secret, nonce || dest_hash || src_hash) + // Including hashes makes the key direction-dependent, preventing keystream reuse + // when Alice->Bob and Bob->Alice use the same nonce (255/256 peer pairs). + uint8_t msg_key[32]; + { + uint8_t kdf_input[AEAD_NONCE_SIZE + 2] = { dest[0], dest[1], dest_hash, src_hash }; + SHA256 sha; + sha.resetHMAC(shared_secret, PUB_KEY_SIZE); + sha.update(kdf_input, sizeof(kdf_input)); + sha.finalizeHMAC(shared_secret, PUB_KEY_SIZE, msg_key, 32); + } + + // Build 12-byte IV from on-wire fields + uint8_t iv[12]; + iv[0] = dest[0]; // nonce_hi + iv[1] = dest[1]; // nonce_lo + iv[2] = dest_hash; + iv[3] = src_hash; + memset(&iv[4], 0, 8); + + ChaChaPoly cipher; + cipher.setKey(msg_key, 32); + cipher.setIV(iv, 12); + cipher.addAuthData(assoc_data, assoc_len); + cipher.encrypt(dest + AEAD_NONCE_SIZE, src, src_len); + cipher.computeTag(dest + AEAD_NONCE_SIZE + src_len, AEAD_TAG_SIZE); + cipher.clear(); + memset(msg_key, 0, 32); + + return AEAD_NONCE_SIZE + src_len + AEAD_TAG_SIZE; +} + +int Utils::aeadDecrypt(const uint8_t* shared_secret, + uint8_t* dest, + const uint8_t* src, int src_len, + const uint8_t* assoc_data, int assoc_len, + uint8_t dest_hash, uint8_t src_hash) { + // Minimum: nonce(2) + at least 1 byte ciphertext + tag(4) + if (src_len < AEAD_NONCE_SIZE + 1 + AEAD_TAG_SIZE) return 0; + + int ct_len = src_len - AEAD_NONCE_SIZE - AEAD_TAG_SIZE; + + // Derive per-message key: HMAC-SHA256(shared_secret, nonce || dest_hash || src_hash) + uint8_t msg_key[32]; + { + uint8_t kdf_input[AEAD_NONCE_SIZE + 2] = { src[0], src[1], dest_hash, src_hash }; + SHA256 sha; + sha.resetHMAC(shared_secret, PUB_KEY_SIZE); + sha.update(kdf_input, sizeof(kdf_input)); + sha.finalizeHMAC(shared_secret, PUB_KEY_SIZE, msg_key, 32); + } + + // Build 12-byte IV from on-wire fields + uint8_t iv[12]; + iv[0] = src[0]; // nonce_hi + iv[1] = src[1]; // nonce_lo + iv[2] = dest_hash; + iv[3] = src_hash; + memset(&iv[4], 0, 8); + + ChaChaPoly cipher; + cipher.setKey(msg_key, 32); + cipher.setIV(iv, 12); + cipher.addAuthData(assoc_data, assoc_len); + cipher.decrypt(dest, src + AEAD_NONCE_SIZE, ct_len); + + bool valid = cipher.checkTag(src + AEAD_NONCE_SIZE + ct_len, AEAD_TAG_SIZE); + cipher.clear(); + memset(msg_key, 0, 32); + if (!valid) memset(dest, 0, ct_len); + + return valid ? ct_len : 0; +} + static const char hex_chars[] = "0123456789ABCDEF"; void Utils::toHex(char* dest, const uint8_t* src, size_t len) { diff --git a/src/Utils.h b/src/Utils.h index 5736b8747..dba1989e1 100644 --- a/src/Utils.h +++ b/src/Utils.h @@ -54,6 +54,29 @@ class Utils { */ static int MACThenDecrypt(const uint8_t* shared_secret, uint8_t* dest, const uint8_t* src, int src_len); + /** + * \brief Encrypt with ChaChaPoly AEAD. Derives per-message key via HMAC-SHA256(shared_secret, nonce || dest_hash || src_hash). + * Output: [nonce:2][ciphertext:src_len][tag:4] + * \returns total output length (AEAD_NONCE_SIZE + src_len + AEAD_TAG_SIZE), or 0 on failure + */ + static int aeadEncrypt(const uint8_t* shared_secret, + uint8_t* dest, + const uint8_t* src, int src_len, + const uint8_t* assoc_data, int assoc_len, + uint16_t nonce_counter, + uint8_t dest_hash, uint8_t src_hash); + + /** + * \brief Decrypt with ChaChaPoly AEAD. Derives per-message key via HMAC-SHA256(shared_secret, nonce || dest_hash || src_hash). + * Input: [nonce:2][ciphertext:M][tag:4] + * \returns plaintext length, or 0 if tag verification fails + */ + static int aeadDecrypt(const uint8_t* shared_secret, + uint8_t* dest, + const uint8_t* src, int src_len, + const uint8_t* assoc_data, int assoc_len, + uint8_t dest_hash, uint8_t src_hash); + /** * \brief converts 'src' bytes with given length to Hex representation, and null terminates. */ diff --git a/src/helpers/BaseChatMesh.cpp b/src/helpers/BaseChatMesh.cpp index 5ec678c7f..9e5cfc970 100644 --- a/src/helpers/BaseChatMesh.cpp +++ b/src/helpers/BaseChatMesh.cpp @@ -21,6 +21,7 @@ mesh::Packet* BaseChatMesh::createSelfAdvert(const char* name) { uint8_t app_data_len; { AdvertDataBuilder builder(ADV_TYPE_CHAT, name); + builder.setFeat1(FEAT1_AEAD_SUPPORT); app_data_len = builder.encodeTo(app_data); } @@ -32,6 +33,7 @@ mesh::Packet* BaseChatMesh::createSelfAdvert(const char* name, double lat, doubl uint8_t app_data_len; { AdvertDataBuilder builder(ADV_TYPE_CHAT, name, lat, lon); + builder.setFeat1(FEAT1_AEAD_SUPPORT); app_data_len = builder.encodeTo(app_data); } @@ -101,6 +103,10 @@ void BaseChatMesh::populateContactFromAdvert(ContactInfo& ci, const mesh::Identi } ci.last_advert_timestamp = timestamp; ci.lastmod = getRTCClock()->getCurrentTime(); + getRNG()->random((uint8_t*)&ci.aead_nonce, sizeof(ci.aead_nonce)); // seed AEAD nonce from HW RNG + if (parser.getFeat1() & FEAT1_AEAD_SUPPORT) { + ci.flags |= CONTACT_FLAG_AEAD; + } } void BaseChatMesh::onAdvertRecv(mesh::Packet* packet, const mesh::Identity& id, uint32_t timestamp, const uint8_t* app_data, size_t app_data_len) { @@ -165,6 +171,11 @@ void BaseChatMesh::onAdvertRecv(mesh::Packet* packet, const mesh::Identity& id, } from->last_advert_timestamp = timestamp; from->lastmod = getRTCClock()->getCurrentTime(); + if (parser.getFeat1() & FEAT1_AEAD_SUPPORT) { + from->flags |= CONTACT_FLAG_AEAD; + } else { + from->flags &= ~CONTACT_FLAG_AEAD; + } onDiscoveredContact(*from, is_new, packet->path_len, packet->path); // let UI know } @@ -762,6 +773,7 @@ bool BaseChatMesh::addContact(const ContactInfo& contact) { if (dest) { *dest = contact; dest->shared_secret_valid = false; // mark shared_secret as needing calculation + getRNG()->random((uint8_t*)&dest->aead_nonce, sizeof(dest->aead_nonce)); // always seed fresh from HW RNG return true; // success } return false; diff --git a/src/helpers/CommonCLI.cpp b/src/helpers/CommonCLI.cpp index e20bbb1c0..c1f86423b 100644 --- a/src/helpers/CommonCLI.cpp +++ b/src/helpers/CommonCLI.cpp @@ -186,12 +186,15 @@ void CommonCLI::savePrefs() { uint8_t CommonCLI::buildAdvertData(uint8_t node_type, uint8_t* app_data) { if (_prefs->advert_loc_policy == ADVERT_LOC_NONE) { AdvertDataBuilder builder(node_type, _prefs->node_name); + builder.setFeat1(FEAT1_AEAD_SUPPORT); return builder.encodeTo(app_data); } else if (_prefs->advert_loc_policy == ADVERT_LOC_SHARE) { AdvertDataBuilder builder(node_type, _prefs->node_name, _sensors->node_lat, _sensors->node_lon); + builder.setFeat1(FEAT1_AEAD_SUPPORT); return builder.encodeTo(app_data); } else { AdvertDataBuilder builder(node_type, _prefs->node_name, _prefs->node_lat, _prefs->node_lon); + builder.setFeat1(FEAT1_AEAD_SUPPORT); return builder.encodeTo(app_data); } } diff --git a/src/helpers/ContactInfo.h b/src/helpers/ContactInfo.h index ede977cac..2ba81080e 100644 --- a/src/helpers/ContactInfo.h +++ b/src/helpers/ContactInfo.h @@ -17,6 +17,7 @@ struct ContactInfo { uint32_t lastmod; // by OUR clock int32_t gps_lat, gps_lon; // 6 dec places uint32_t sync_since; + uint16_t aead_nonce; // per-peer AEAD nonce counter for DMs (not used for group messages), seeded from HW RNG const uint8_t* getSharedSecret(const mesh::LocalIdentity& self_id) const { if (!shared_secret_valid) { From b61786eef0b67d5a1857f79a7de1df8b8d8b77d5 Mon Sep 17 00:00:00 2001 From: Wessel Nieboer Date: Thu, 12 Feb 2026 01:34:33 +0100 Subject: [PATCH 12/56] Enable AEAD-4 sending to peers that advertise support Send ChaChaPoly-encrypted messages to peers with CONTACT_FLAG_AEAD set, and try AEAD decode first for those peers (avoiding 1/65536 ECB false-positive). Legacy peers continue to use ECB in both directions. - Add aead_nonce parameter to createDatagram/createPathReturn (default 0 = ECB) - Add getPeerFlags/getPeerNextAeadNonce virtual methods for decode-order selection - Add ContactInfo::nextAeadNonce() helper (returns nonce++ if AEAD, 0 otherwise) - Update all BaseChatMesh send paths to pass nonce for AEAD-capable peers - Adaptive decode order: AEAD-first for known AEAD peers, ECB-first for others --- src/Mesh.cpp | 49 ++++++++++++++++++++++++------------ src/Mesh.h | 8 +++--- src/helpers/BaseChatMesh.cpp | 38 ++++++++++++++++++++-------- src/helpers/BaseChatMesh.h | 2 ++ src/helpers/ContactInfo.h | 9 ++++++- 5 files changed, 75 insertions(+), 31 deletions(-) diff --git a/src/Mesh.cpp b/src/Mesh.cpp index 3a1f22d0c..04e42e8f1 100644 --- a/src/Mesh.cpp +++ b/src/Mesh.cpp @@ -149,15 +149,16 @@ DispatcherAction Mesh::onRecvPacket(Packet* pkt) { uint8_t data[MAX_PACKET_PAYLOAD]; int macAndDataLen = pkt->payload_len - i; - // Try ECB first (Phase 1: all senders use ECB), then AEAD-4 fallback. - // IMPORTANT: Phase 2 MUST swap to AEAD-first. ECB-first has a 1/65536 - // false-positive rate on AEAD packets (nonce bytes matching truncated HMAC), - // producing garbage plaintext. AEAD-first has only 1/2^32 false-positive on - // ECB packets, which is negligible. - int len = Utils::MACThenDecrypt(secret, data, macAndData, macAndDataLen); - if (len <= 0) { - uint8_t assoc[3] = { pkt->header, dest_hash, src_hash }; + // Try-both decode: AEAD-first for peers known to support it (avoids 1/65536 + // ECB false-positive on AEAD packets), ECB-first for unknown/legacy peers. + uint8_t assoc[3] = { pkt->header, dest_hash, src_hash }; + int len; + if (getPeerFlags(j) & CONTACT_FLAG_AEAD) { len = Utils::aeadDecrypt(secret, data, macAndData, macAndDataLen, assoc, 3, dest_hash, src_hash); + if (len <= 0) len = Utils::MACThenDecrypt(secret, data, macAndData, macAndDataLen); + } else { + len = Utils::MACThenDecrypt(secret, data, macAndData, macAndDataLen); + if (len <= 0) len = Utils::aeadDecrypt(secret, data, macAndData, macAndDataLen, assoc, 3, dest_hash, src_hash); } if (len > 0) { // success! if (pkt->getPayloadType() == PAYLOAD_TYPE_PATH) { @@ -172,7 +173,7 @@ DispatcherAction Mesh::onRecvPacket(Packet* pkt) { if (onPeerPathRecv(pkt, j, secret, path, path_len, extra_type, extra, extra_len)) { if (pkt->isRouteFlood()) { // send a reciprocal return path to sender, but send DIRECTLY! - mesh::Packet* rpath = createPathReturn(&src_hash, secret, pkt->path, pkt->path_len, 0, NULL, 0); + mesh::Packet* rpath = createPathReturn(&src_hash, secret, pkt->path, pkt->path_len, 0, NULL, 0, getPeerNextAeadNonce(j)); if (rpath) sendDirect(rpath, path, path_len, 500); } } @@ -452,13 +453,13 @@ Packet* Mesh::createAdvert(const LocalIdentity& id, const uint8_t* app_data, siz #define MAX_COMBINED_PATH (MAX_PACKET_PAYLOAD - 2 - CIPHER_BLOCK_SIZE) -Packet* Mesh::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* Mesh::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, uint16_t aead_nonce) { uint8_t dest_hash[PATH_HASH_SIZE]; dest.copyHashTo(dest_hash); - return createPathReturn(dest_hash, secret, path, path_len, extra_type, extra, extra_len); + return createPathReturn(dest_hash, secret, path, path_len, extra_type, extra, extra_len, aead_nonce); } -Packet* Mesh::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* Mesh::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, uint16_t aead_nonce) { uint8_t path_hash_size = (path_len >> 6) + 1; uint8_t path_hash_count = path_len & 63; @@ -490,7 +491,14 @@ Packet* Mesh::createPathReturn(const uint8_t* dest_hash, const uint8_t* secret, getRNG()->random(&data[data_len], 4); data_len += 4; } - len += Utils::encryptThenMAC(secret, &packet->payload[len], data, data_len); + if (aead_nonce) { + uint8_t dh = packet->payload[0]; + uint8_t sh = packet->payload[1]; + uint8_t assoc[3] = { packet->header, dh, sh }; + len += Utils::aeadEncrypt(secret, &packet->payload[len], data, data_len, assoc, 3, aead_nonce, dh, sh); + } else { + len += Utils::encryptThenMAC(secret, &packet->payload[len], data, data_len); + } } packet->payload_len = len; @@ -498,9 +506,10 @@ Packet* Mesh::createPathReturn(const uint8_t* dest_hash, const uint8_t* secret, return packet; } -Packet* Mesh::createDatagram(uint8_t type, const Identity& dest, const uint8_t* secret, const uint8_t* data, size_t data_len) { +Packet* Mesh::createDatagram(uint8_t type, const Identity& dest, const uint8_t* secret, const uint8_t* data, size_t data_len, uint16_t aead_nonce) { if (type == PAYLOAD_TYPE_TXT_MSG || type == PAYLOAD_TYPE_REQ || type == PAYLOAD_TYPE_RESPONSE) { - if (data_len + CIPHER_MAC_SIZE + CIPHER_BLOCK_SIZE-1 > MAX_PACKET_PAYLOAD) return NULL; + size_t max_overhead = aead_nonce ? (AEAD_NONCE_SIZE + AEAD_TAG_SIZE) : (CIPHER_MAC_SIZE + CIPHER_BLOCK_SIZE-1); + if (data_len + max_overhead > MAX_PACKET_PAYLOAD) return NULL; } else { return NULL; // invalid type } @@ -515,7 +524,15 @@ Packet* Mesh::createDatagram(uint8_t type, const Identity& dest, const uint8_t* int len = 0; len += dest.copyHashTo(&packet->payload[len]); // dest hash len += self_id.copyHashTo(&packet->payload[len]); // src hash - len += Utils::encryptThenMAC(secret, &packet->payload[len], data, data_len); + + if (aead_nonce) { + uint8_t dest_hash = packet->payload[0]; + uint8_t src_hash = packet->payload[1]; + uint8_t assoc[3] = { packet->header, dest_hash, src_hash }; + len += Utils::aeadEncrypt(secret, &packet->payload[len], data, data_len, assoc, 3, aead_nonce, dest_hash, src_hash); + } else { + len += Utils::encryptThenMAC(secret, &packet->payload[len], data, data_len); + } packet->payload_len = len; diff --git a/src/Mesh.h b/src/Mesh.h index f9f878632..5cf8adb5a 100644 --- a/src/Mesh.h +++ b/src/Mesh.h @@ -82,6 +82,8 @@ class Mesh : public Dispatcher { * \param peer_idx index of peer, [0..n) where n is what searchPeersByHash() returned */ virtual void getPeerSharedSecret(uint8_t* dest_secret, int peer_idx) { } + virtual uint8_t getPeerFlags(int peer_idx) { return 0; } + virtual uint16_t getPeerNextAeadNonce(int peer_idx) { return 0; } /** * \brief A (now decrypted) data packet has been received (by a known peer). @@ -182,13 +184,13 @@ class Mesh : public Dispatcher { RTCClock* getRTCClock() const { return _rtc; } Packet* createAdvert(const LocalIdentity& id, const uint8_t* app_data=NULL, size_t app_data_len=0); - Packet* createDatagram(uint8_t type, const Identity& dest, const uint8_t* secret, const uint8_t* data, size_t len); + Packet* createDatagram(uint8_t type, const Identity& dest, const uint8_t* secret, const uint8_t* data, size_t len, uint16_t aead_nonce=0); Packet* createAnonDatagram(uint8_t type, const LocalIdentity& sender, const Identity& dest, const uint8_t* secret, const uint8_t* data, size_t data_len); Packet* createGroupDatagram(uint8_t type, const GroupChannel& channel, const uint8_t* data, size_t data_len); Packet* createAck(uint32_t ack_crc); Packet* createMultiAck(uint32_t ack_crc, uint8_t remaining); - 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* 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, uint16_t aead_nonce=0); + 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, uint16_t aead_nonce=0); Packet* createRawData(const uint8_t* data, size_t len); Packet* createTrace(uint32_t tag, uint32_t auth_code, uint8_t flags = 0); Packet* createControlData(const uint8_t* data, size_t len); diff --git a/src/helpers/BaseChatMesh.cpp b/src/helpers/BaseChatMesh.cpp index 9e5cfc970..d8f9066ad 100644 --- a/src/helpers/BaseChatMesh.cpp +++ b/src/helpers/BaseChatMesh.cpp @@ -199,6 +199,22 @@ void BaseChatMesh::getPeerSharedSecret(uint8_t* dest_secret, int peer_idx) { } } +uint8_t BaseChatMesh::getPeerFlags(int peer_idx) { + int i = matching_peer_indexes[peer_idx]; + if (i >= 0 && i < num_contacts) { + return contacts[i].flags; + } + return 0; +} + +uint16_t BaseChatMesh::getPeerNextAeadNonce(int peer_idx) { + int i = matching_peer_indexes[peer_idx]; + if (i >= 0 && i < num_contacts) { + return contacts[i].nextAeadNonce(); + } + return 0; +} + void BaseChatMesh::onPeerDataRecv(mesh::Packet* packet, uint8_t type, int sender_idx, const uint8_t* secret, uint8_t* data, size_t len) { int i = matching_peer_indexes[sender_idx]; if (i < 0 || i >= num_contacts) { @@ -226,7 +242,7 @@ void BaseChatMesh::onPeerDataRecv(mesh::Packet* packet, uint8_t type, int sender if (packet->isRouteFlood()) { // let this sender know path TO here, so they can use sendDirect(), and ALSO encode the ACK mesh::Packet* path = createPathReturn(from.id, secret, packet->path, packet->path_len, - PAYLOAD_TYPE_ACK, (uint8_t *) &ack_hash, 4); + PAYLOAD_TYPE_ACK, (uint8_t *) &ack_hash, 4, from.nextAeadNonce()); if (path) sendFloodScoped(from, path, TXT_ACK_DELAY); } else { sendAckTo(from, ack_hash); @@ -237,7 +253,7 @@ void BaseChatMesh::onPeerDataRecv(mesh::Packet* packet, uint8_t type, int sender if (packet->isRouteFlood()) { // let this sender know path TO here, so they can use sendDirect() (NOTE: no ACK as extra) - mesh::Packet* path = createPathReturn(from.id, secret, packet->path, packet->path_len, 0, NULL, 0); + mesh::Packet* path = createPathReturn(from.id, secret, packet->path, packet->path_len, 0, NULL, 0, from.nextAeadNonce()); if (path) sendFloodScoped(from, path); } } else if (flags == TXT_TYPE_SIGNED_PLAIN) { @@ -253,7 +269,7 @@ void BaseChatMesh::onPeerDataRecv(mesh::Packet* packet, uint8_t type, int sender if (packet->isRouteFlood()) { // let this sender know path TO here, so they can use sendDirect(), and ALSO encode the ACK mesh::Packet* path = createPathReturn(from.id, secret, packet->path, packet->path_len, - PAYLOAD_TYPE_ACK, (uint8_t *) &ack_hash, 4); + PAYLOAD_TYPE_ACK, (uint8_t *) &ack_hash, 4, from.nextAeadNonce()); if (path) sendFloodScoped(from, path, TXT_ACK_DELAY); } else { sendAckTo(from, ack_hash); @@ -269,10 +285,10 @@ void BaseChatMesh::onPeerDataRecv(mesh::Packet* packet, uint8_t type, int sender if (packet->isRouteFlood()) { // let this sender know path TO here, so they can use sendDirect(), and ALSO encode the response mesh::Packet* path = createPathReturn(from.id, secret, packet->path, packet->path_len, - PAYLOAD_TYPE_RESPONSE, temp_buf, reply_len); + PAYLOAD_TYPE_RESPONSE, temp_buf, reply_len, from.nextAeadNonce()); if (path) sendFloodScoped(from, path, SERVER_RESPONSE_DELAY); } else { - mesh::Packet* reply = createDatagram(PAYLOAD_TYPE_RESPONSE, from.id, secret, temp_buf, reply_len); + mesh::Packet* reply = createDatagram(PAYLOAD_TYPE_RESPONSE, from.id, secret, temp_buf, reply_len, from.nextAeadNonce()); if (reply) { if (from.out_path_len != OUT_PATH_UNKNOWN) { // we have an out_path, so send DIRECT sendDirect(reply, from.out_path, from.out_path_len, SERVER_RESPONSE_DELAY); @@ -338,7 +354,7 @@ void BaseChatMesh::onAckRecv(mesh::Packet* packet, uint32_t ack_crc) { void BaseChatMesh::handleReturnPathRetry(const ContactInfo& contact, const uint8_t* path, uint8_t path_len) { // NOTE: simplest impl is just to re-send a reciprocal return path to sender (DIRECTLY) // override this method in various firmwares, if there's a better strategy - mesh::Packet* rpath = createPathReturn(contact.id, contact.getSharedSecret(self_id), path, path_len, 0, NULL, 0); + mesh::Packet* rpath = createPathReturn(contact.id, contact.getSharedSecret(self_id), path, path_len, 0, NULL, 0, contact.nextAeadNonce()); if (rpath) sendDirect(rpath, contact.out_path, contact.out_path_len, 3000); // 3 second delay } @@ -387,7 +403,7 @@ mesh::Packet* BaseChatMesh::composeMsgPacket(const ContactInfo& recipient, uint3 temp[len++] = attempt; // hide attempt number at tail end of payload } - return createDatagram(PAYLOAD_TYPE_TXT_MSG, recipient.id, recipient.getSharedSecret(self_id), temp, len); + return createDatagram(PAYLOAD_TYPE_TXT_MSG, recipient.id, recipient.getSharedSecret(self_id), temp, len, recipient.nextAeadNonce()); } int BaseChatMesh::sendMessage(const ContactInfo& recipient, uint32_t timestamp, uint8_t attempt, const char* text, uint32_t& expected_ack, uint32_t& est_timeout) { @@ -418,7 +434,7 @@ int BaseChatMesh::sendCommandData(const ContactInfo& recipient, uint32_t timest temp[4] = (attempt & 3) | (TXT_TYPE_CLI_DATA << 2); memcpy(&temp[5], text, text_len + 1); - auto pkt = createDatagram(PAYLOAD_TYPE_TXT_MSG, recipient.id, recipient.getSharedSecret(self_id), temp, 5 + text_len); + auto pkt = createDatagram(PAYLOAD_TYPE_TXT_MSG, recipient.id, recipient.getSharedSecret(self_id), temp, 5 + text_len, recipient.nextAeadNonce()); if (pkt == NULL) return MSG_SEND_FAILED; uint32_t t = _radio->getEstAirtimeFor(pkt->getRawLength()); @@ -559,7 +575,7 @@ int BaseChatMesh::sendRequest(const ContactInfo& recipient, const uint8_t* req_ memcpy(temp, &tag, 4); // mostly an extra blob to help make packet_hash unique memcpy(&temp[4], req_data, data_len); - pkt = createDatagram(PAYLOAD_TYPE_REQ, recipient.id, recipient.getSharedSecret(self_id), temp, 4 + data_len); + pkt = createDatagram(PAYLOAD_TYPE_REQ, recipient.id, recipient.getSharedSecret(self_id), temp, 4 + data_len, recipient.nextAeadNonce()); } if (pkt) { uint32_t t = _radio->getEstAirtimeFor(pkt->getRawLength()); @@ -586,7 +602,7 @@ int BaseChatMesh::sendRequest(const ContactInfo& recipient, uint8_t req_type, u memset(&temp[5], 0, 4); // reserved (possibly for 'since' param) getRNG()->random(&temp[9], 4); // random blob to help make packet-hash unique - pkt = createDatagram(PAYLOAD_TYPE_REQ, recipient.id, recipient.getSharedSecret(self_id), temp, sizeof(temp)); + pkt = createDatagram(PAYLOAD_TYPE_REQ, recipient.id, recipient.getSharedSecret(self_id), temp, sizeof(temp), recipient.nextAeadNonce()); } if (pkt) { uint32_t t = _radio->getEstAirtimeFor(pkt->getRawLength()); @@ -709,7 +725,7 @@ void BaseChatMesh::checkConnections() { // calc expected ACK reply mesh::Utils::sha256((uint8_t *)&connections[i].expected_ack, 4, data, 9, self_id.pub_key, PUB_KEY_SIZE); - auto pkt = createDatagram(PAYLOAD_TYPE_REQ, contact->id, contact->getSharedSecret(self_id), data, 9); + auto pkt = createDatagram(PAYLOAD_TYPE_REQ, contact->id, contact->getSharedSecret(self_id), data, 9, contact->nextAeadNonce()); if (pkt) { sendDirect(pkt, contact->out_path, contact->out_path_len); } diff --git a/src/helpers/BaseChatMesh.h b/src/helpers/BaseChatMesh.h index fd391b980..516375efa 100644 --- a/src/helpers/BaseChatMesh.h +++ b/src/helpers/BaseChatMesh.h @@ -125,6 +125,8 @@ class BaseChatMesh : public mesh::Mesh { void onAdvertRecv(mesh::Packet* packet, const mesh::Identity& id, uint32_t timestamp, const uint8_t* app_data, size_t app_data_len) override; int searchPeersByHash(const uint8_t* hash) override; void getPeerSharedSecret(uint8_t* dest_secret, int peer_idx) override; + uint8_t getPeerFlags(int peer_idx) override; + uint16_t getPeerNextAeadNonce(int peer_idx) override; 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 onAckRecv(mesh::Packet* packet, uint32_t ack_crc) override; diff --git a/src/helpers/ContactInfo.h b/src/helpers/ContactInfo.h index 2ba81080e..864329910 100644 --- a/src/helpers/ContactInfo.h +++ b/src/helpers/ContactInfo.h @@ -17,7 +17,14 @@ struct ContactInfo { uint32_t lastmod; // by OUR clock int32_t gps_lat, gps_lon; // 6 dec places uint32_t sync_since; - uint16_t aead_nonce; // per-peer AEAD nonce counter for DMs (not used for group messages), seeded from HW RNG + mutable uint16_t aead_nonce; // per-peer AEAD nonce counter for DMs (not used for group messages), seeded from HW RNG + + // Returns next AEAD nonce (post-increment) if peer supports AEAD, 0 otherwise. + // When 0, callers use ECB encryption. + uint16_t nextAeadNonce() const { + if (flags & CONTACT_FLAG_AEAD) return ++aead_nonce; + return 0; + } const uint8_t* getSharedSecret(const mesh::LocalIdentity& self_id) const { if (!shared_secret_valid) { From 7ee53a81147e455097c994c4d591a7b6a968508a Mon Sep 17 00:00:00 2001 From: Wessel Nieboer Date: Thu, 12 Feb 2026 01:48:42 +0100 Subject: [PATCH 13/56] Fix AEAD-4 payload size check and nonce wrap-around --- src/Mesh.cpp | 3 ++- src/helpers/ContactInfo.h | 5 ++++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/src/Mesh.cpp b/src/Mesh.cpp index 04e42e8f1..2cb430443 100644 --- a/src/Mesh.cpp +++ b/src/Mesh.cpp @@ -508,8 +508,9 @@ Packet* Mesh::createPathReturn(const uint8_t* dest_hash, const uint8_t* secret, Packet* Mesh::createDatagram(uint8_t type, const Identity& dest, const uint8_t* secret, const uint8_t* data, size_t data_len, uint16_t aead_nonce) { if (type == PAYLOAD_TYPE_TXT_MSG || type == PAYLOAD_TYPE_REQ || type == PAYLOAD_TYPE_RESPONSE) { + size_t hash_prefix = PATH_HASH_SIZE * 2; // dest_hash + src_hash size_t max_overhead = aead_nonce ? (AEAD_NONCE_SIZE + AEAD_TAG_SIZE) : (CIPHER_MAC_SIZE + CIPHER_BLOCK_SIZE-1); - if (data_len + max_overhead > MAX_PACKET_PAYLOAD) return NULL; + if (data_len + hash_prefix + max_overhead > MAX_PACKET_PAYLOAD) return NULL; } else { return NULL; // invalid type } diff --git a/src/helpers/ContactInfo.h b/src/helpers/ContactInfo.h index 864329910..595c464c6 100644 --- a/src/helpers/ContactInfo.h +++ b/src/helpers/ContactInfo.h @@ -22,7 +22,10 @@ struct ContactInfo { // Returns next AEAD nonce (post-increment) if peer supports AEAD, 0 otherwise. // When 0, callers use ECB encryption. uint16_t nextAeadNonce() const { - if (flags & CONTACT_FLAG_AEAD) return ++aead_nonce; + if (flags & CONTACT_FLAG_AEAD) { + if (++aead_nonce == 0) ++aead_nonce; // skip 0 (sentinel for ECB) + return aead_nonce; + } return 0; } From 13bd7c33512e9362f3bfeab892accf94b99d09c3 Mon Sep 17 00:00:00 2001 From: Wessel Nieboer Date: Thu, 12 Feb 2026 02:16:56 +0100 Subject: [PATCH 14/56] =?UTF-8?q?Fix=20AEAD-4=20assoc=20data=20mismatch=20?= =?UTF-8?q?=E2=80=94=20route=20type=20bits=20set=20after=20encryption?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The header's route type bits (PH_ROUTE_MASK) are zero when createDatagram/createPathReturn encrypt with AEAD, but get changed to ROUTE_TYPE_FLOOD (1) or ROUTE_TYPE_DIRECT (2) by sendFlood/sendDirect afterwards. The receiver builds assoc from the received header (with route bits set), so the tag check always fails and every AEAD packet is silently dropped. Mask out route type bits in assoc data on all 5 encrypt/decrypt sites. Also track AEAD decode success to enable peer capability auto-detection. --- src/Mesh.cpp | 21 ++++++++++++++------- src/Mesh.h | 1 + 2 files changed, 15 insertions(+), 7 deletions(-) diff --git a/src/Mesh.cpp b/src/Mesh.cpp index 2cb430443..9ab0dc69e 100644 --- a/src/Mesh.cpp +++ b/src/Mesh.cpp @@ -151,16 +151,23 @@ DispatcherAction Mesh::onRecvPacket(Packet* pkt) { // Try-both decode: AEAD-first for peers known to support it (avoids 1/65536 // ECB false-positive on AEAD packets), ECB-first for unknown/legacy peers. - uint8_t assoc[3] = { pkt->header, dest_hash, src_hash }; + // Mask out route type bits — they are set after encryption and vary per hop. + uint8_t assoc[3] = { (uint8_t)(pkt->header & ~PH_ROUTE_MASK), dest_hash, src_hash }; int len; + bool decoded_aead = false; if (getPeerFlags(j) & CONTACT_FLAG_AEAD) { len = Utils::aeadDecrypt(secret, data, macAndData, macAndDataLen, assoc, 3, dest_hash, src_hash); - if (len <= 0) len = Utils::MACThenDecrypt(secret, data, macAndData, macAndDataLen); + if (len > 0) decoded_aead = true; + else len = Utils::MACThenDecrypt(secret, data, macAndData, macAndDataLen); } else { len = Utils::MACThenDecrypt(secret, data, macAndData, macAndDataLen); - if (len <= 0) len = Utils::aeadDecrypt(secret, data, macAndData, macAndDataLen, assoc, 3, dest_hash, src_hash); + if (len <= 0) { + len = Utils::aeadDecrypt(secret, data, macAndData, macAndDataLen, assoc, 3, dest_hash, src_hash); + if (len > 0) decoded_aead = true; + } } if (len > 0) { // success! + if (decoded_aead) onPeerAeadDetected(j); if (pkt->getPayloadType() == PAYLOAD_TYPE_PATH) { int k = 0; uint8_t path_len = data[k++]; @@ -216,7 +223,7 @@ DispatcherAction Mesh::onRecvPacket(Packet* pkt) { // Phase 2 MUST swap to AEAD-first (see peer message comment above). int len = Utils::MACThenDecrypt(secret, data, macAndData, macAndDataLen); if (len <= 0) { - uint8_t assoc[2] = { pkt->header, dest_hash }; + uint8_t assoc[2] = { (uint8_t)(pkt->header & ~PH_ROUTE_MASK), dest_hash }; len = Utils::aeadDecrypt(secret, data, macAndData, macAndDataLen, assoc, 2, dest_hash, 0); } if (len > 0) { // success! @@ -252,7 +259,7 @@ DispatcherAction Mesh::onRecvPacket(Packet* pkt) { // worthwhile for public/hashtag channels where the PSK is already widely known. int len = Utils::MACThenDecrypt(channels[j].secret, data, macAndData, macAndDataLen); if (len <= 0) { - uint8_t assoc[2] = { pkt->header, channel_hash }; + uint8_t assoc[2] = { (uint8_t)(pkt->header & ~PH_ROUTE_MASK), channel_hash }; len = Utils::aeadDecrypt(channels[j].secret, data, macAndData, macAndDataLen, assoc, 2, channel_hash, 0); } if (len > 0) { // success! @@ -494,7 +501,7 @@ Packet* Mesh::createPathReturn(const uint8_t* dest_hash, const uint8_t* secret, if (aead_nonce) { uint8_t dh = packet->payload[0]; uint8_t sh = packet->payload[1]; - uint8_t assoc[3] = { packet->header, dh, sh }; + uint8_t assoc[3] = { (uint8_t)(packet->header & ~PH_ROUTE_MASK), dh, sh }; len += Utils::aeadEncrypt(secret, &packet->payload[len], data, data_len, assoc, 3, aead_nonce, dh, sh); } else { len += Utils::encryptThenMAC(secret, &packet->payload[len], data, data_len); @@ -529,7 +536,7 @@ Packet* Mesh::createDatagram(uint8_t type, const Identity& dest, const uint8_t* if (aead_nonce) { uint8_t dest_hash = packet->payload[0]; uint8_t src_hash = packet->payload[1]; - uint8_t assoc[3] = { packet->header, dest_hash, src_hash }; + uint8_t assoc[3] = { (uint8_t)(packet->header & ~PH_ROUTE_MASK), dest_hash, src_hash }; len += Utils::aeadEncrypt(secret, &packet->payload[len], data, data_len, assoc, 3, aead_nonce, dest_hash, src_hash); } else { len += Utils::encryptThenMAC(secret, &packet->payload[len], data, data_len); diff --git a/src/Mesh.h b/src/Mesh.h index 5cf8adb5a..0176d8eff 100644 --- a/src/Mesh.h +++ b/src/Mesh.h @@ -84,6 +84,7 @@ class Mesh : public Dispatcher { virtual void getPeerSharedSecret(uint8_t* dest_secret, int peer_idx) { } virtual uint8_t getPeerFlags(int peer_idx) { return 0; } virtual uint16_t getPeerNextAeadNonce(int peer_idx) { return 0; } + virtual void onPeerAeadDetected(int peer_idx) { } /** * \brief A (now decrypted) data packet has been received (by a known peer). From f26b12cac92ae8494f429d46cf5fee7947dca9d6 Mon Sep 17 00:00:00 2001 From: Wessel Nieboer Date: Fri, 13 Feb 2026 00:04:49 +0100 Subject: [PATCH 15/56] Support AEAD responses in repeater/room/sensor --- examples/simple_repeater/MyMesh.cpp | 31 ++++++++++++++++++++--- examples/simple_repeater/MyMesh.h | 3 +++ examples/simple_room_server/MyMesh.cpp | 33 +++++++++++++++++++++--- examples/simple_room_server/MyMesh.h | 3 +++ examples/simple_sensor/SensorMesh.cpp | 35 ++++++++++++++++++++++---- examples/simple_sensor/SensorMesh.h | 3 +++ src/helpers/ClientACL.h | 9 +++++++ 7 files changed, 105 insertions(+), 12 deletions(-) diff --git a/examples/simple_repeater/MyMesh.cpp b/examples/simple_repeater/MyMesh.cpp index 81c1dcb42..aada99726 100644 --- a/examples/simple_repeater/MyMesh.cpp +++ b/examples/simple_repeater/MyMesh.cpp @@ -579,6 +579,31 @@ void MyMesh::getPeerSharedSecret(uint8_t *dest_secret, int peer_idx) { } } +uint8_t MyMesh::getPeerFlags(int peer_idx) { + int i = matching_peer_indexes[peer_idx]; + if (i >= 0 && i < acl.getNumClients()) + return acl.getClientByIdx(i)->flags; + return 0; +} + +uint16_t MyMesh::getPeerNextAeadNonce(int peer_idx) { + int i = matching_peer_indexes[peer_idx]; + if (i >= 0 && i < acl.getNumClients()) + return acl.getClientByIdx(i)->nextAeadNonce(); + return 0; +} + +void MyMesh::onPeerAeadDetected(int peer_idx) { + int i = matching_peer_indexes[peer_idx]; + if (i >= 0 && i < acl.getNumClients()) { + auto c = acl.getClientByIdx(i); + if (!(c->flags & CONTACT_FLAG_AEAD)) { + c->flags |= CONTACT_FLAG_AEAD; + getRNG()->random((uint8_t*)&c->aead_nonce, sizeof(c->aead_nonce)); + } + } +} + static bool isShare(const mesh::Packet *packet) { if (packet->hasTransportCodes()) { return packet->transport_codes[0] == 0 && packet->transport_codes[1] == 0; // codes { 0, 0 } means 'send to nowhere' @@ -622,11 +647,11 @@ void MyMesh::onPeerDataRecv(mesh::Packet *packet, uint8_t type, int sender_idx, if (packet->isRouteFlood()) { // let this sender know path TO here, so they can use sendDirect(), and ALSO encode the response mesh::Packet *path = createPathReturn(client->id, secret, packet->path, packet->path_len, - PAYLOAD_TYPE_RESPONSE, reply_data, reply_len); + PAYLOAD_TYPE_RESPONSE, reply_data, reply_len, client->nextAeadNonce()); if (path) sendFlood(path, SERVER_RESPONSE_DELAY, packet->getPathHashSize()); } else { mesh::Packet *reply = - createDatagram(PAYLOAD_TYPE_RESPONSE, client->id, secret, reply_data, reply_len); + createDatagram(PAYLOAD_TYPE_RESPONSE, client->id, secret, reply_data, reply_len, client->nextAeadNonce()); if (reply) { if (client->out_path_len != OUT_PATH_UNKNOWN) { // we have an out_path, so send DIRECT sendDirect(reply, client->out_path, client->out_path_len, SERVER_RESPONSE_DELAY); @@ -687,7 +712,7 @@ void MyMesh::onPeerDataRecv(mesh::Packet *packet, uint8_t type, int sender_idx, memcpy(temp, ×tamp, 4); // mostly an extra blob to help make packet_hash unique temp[4] = (TXT_TYPE_CLI_DATA << 2); // NOTE: legacy was: TXT_TYPE_PLAIN - auto reply = createDatagram(PAYLOAD_TYPE_TXT_MSG, client->id, secret, temp, 5 + text_len); + auto reply = createDatagram(PAYLOAD_TYPE_TXT_MSG, client->id, secret, temp, 5 + text_len, client->nextAeadNonce()); if (reply) { if (client->out_path_len == OUT_PATH_UNKNOWN) { sendFlood(reply, CLI_REPLY_DELAY_MILLIS, packet->getPathHashSize()); diff --git a/examples/simple_repeater/MyMesh.h b/examples/simple_repeater/MyMesh.h index 591f63662..954995916 100644 --- a/examples/simple_repeater/MyMesh.h +++ b/examples/simple_repeater/MyMesh.h @@ -167,6 +167,9 @@ class MyMesh : public mesh::Mesh, public CommonCLICallbacks { void onAnonDataRecv(mesh::Packet* packet, const uint8_t* secret, const mesh::Identity& sender, uint8_t* data, size_t len) override; int searchPeersByHash(const uint8_t* hash) override; void getPeerSharedSecret(uint8_t* dest_secret, int peer_idx) override; + uint8_t getPeerFlags(int peer_idx) override; + uint16_t getPeerNextAeadNonce(int peer_idx) override; + void onPeerAeadDetected(int peer_idx) override; void onAdvertRecv(mesh::Packet* packet, const mesh::Identity& id, uint32_t timestamp, const uint8_t* app_data, size_t app_data_len); 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; diff --git a/examples/simple_room_server/MyMesh.cpp b/examples/simple_room_server/MyMesh.cpp index 5451505a2..b80c0f6d5 100644 --- a/examples/simple_room_server/MyMesh.cpp +++ b/examples/simple_room_server/MyMesh.cpp @@ -71,7 +71,7 @@ void MyMesh::pushPostToClient(ClientInfo *client, PostInfo &post) { mesh::Utils::sha256((uint8_t *)&client->extra.room.pending_ack, 4, reply_data, len, client->id.pub_key, PUB_KEY_SIZE); client->extra.room.push_post_timestamp = post.post_timestamp; - auto reply = createDatagram(PAYLOAD_TYPE_TXT_MSG, client->id, client->shared_secret, reply_data, len); + auto reply = createDatagram(PAYLOAD_TYPE_TXT_MSG, client->id, client->shared_secret, reply_data, len, client->nextAeadNonce()); if (reply) { if (client->out_path_len == OUT_PATH_UNKNOWN) { unsigned long delay_millis = 0; @@ -389,6 +389,31 @@ void MyMesh::getPeerSharedSecret(uint8_t *dest_secret, int peer_idx) { } } +uint8_t MyMesh::getPeerFlags(int peer_idx) { + int i = matching_peer_indexes[peer_idx]; + if (i >= 0 && i < acl.getNumClients()) + return acl.getClientByIdx(i)->flags; + return 0; +} + +uint16_t MyMesh::getPeerNextAeadNonce(int peer_idx) { + int i = matching_peer_indexes[peer_idx]; + if (i >= 0 && i < acl.getNumClients()) + return acl.getClientByIdx(i)->nextAeadNonce(); + return 0; +} + +void MyMesh::onPeerAeadDetected(int peer_idx) { + int i = matching_peer_indexes[peer_idx]; + if (i >= 0 && i < acl.getNumClients()) { + auto c = acl.getClientByIdx(i); + if (!(c->flags & CONTACT_FLAG_AEAD)) { + c->flags |= CONTACT_FLAG_AEAD; + getRNG()->random((uint8_t*)&c->aead_nonce, sizeof(c->aead_nonce)); + } + } +} + void MyMesh::onPeerDataRecv(mesh::Packet *packet, uint8_t type, int sender_idx, const uint8_t *secret, uint8_t *data, size_t len) { int i = matching_peer_indexes[sender_idx]; @@ -482,7 +507,7 @@ void MyMesh::onPeerDataRecv(mesh::Packet *packet, uint8_t type, int sender_idx, // mesh::Utils::sha256((uint8_t *)&expected_ack_crc, 4, temp, 5 + text_len, self_id.pub_key, // PUB_KEY_SIZE); - auto reply = createDatagram(PAYLOAD_TYPE_TXT_MSG, client->id, secret, temp, 5 + text_len); + auto reply = createDatagram(PAYLOAD_TYPE_TXT_MSG, client->id, secret, temp, 5 + text_len, client->nextAeadNonce()); if (reply) { if (client->out_path_len == OUT_PATH_UNKNOWN) { sendFlood(reply, delay_millis + SERVER_RESPONSE_DELAY, packet->getPathHashSize()); @@ -539,10 +564,10 @@ void MyMesh::onPeerDataRecv(mesh::Packet *packet, uint8_t type, int sender_idx, if (packet->isRouteFlood()) { // let this sender know path TO here, so they can use sendDirect(), and ALSO encode the response mesh::Packet *path = createPathReturn(client->id, secret, packet->path, packet->path_len, - PAYLOAD_TYPE_RESPONSE, reply_data, reply_len); + PAYLOAD_TYPE_RESPONSE, reply_data, reply_len, client->nextAeadNonce()); if (path) sendFlood(path, SERVER_RESPONSE_DELAY, packet->getPathHashSize()); } else { - mesh::Packet *reply = createDatagram(PAYLOAD_TYPE_RESPONSE, client->id, secret, reply_data, reply_len); + mesh::Packet *reply = createDatagram(PAYLOAD_TYPE_RESPONSE, client->id, secret, reply_data, reply_len, client->nextAeadNonce()); if (reply) { if (client->out_path_len != OUT_PATH_UNKNOWN) { // we have an out_path, so send DIRECT sendDirect(reply, client->out_path, client->out_path_len, SERVER_RESPONSE_DELAY); diff --git a/examples/simple_room_server/MyMesh.h b/examples/simple_room_server/MyMesh.h index d21e225f2..0a4063a6c 100644 --- a/examples/simple_room_server/MyMesh.h +++ b/examples/simple_room_server/MyMesh.h @@ -148,6 +148,9 @@ class MyMesh : public mesh::Mesh, public CommonCLICallbacks { void onAnonDataRecv(mesh::Packet* packet, const uint8_t* secret, const mesh::Identity& sender, uint8_t* data, size_t len) override; int searchPeersByHash(const uint8_t* hash) override ; void getPeerSharedSecret(uint8_t* dest_secret, int peer_idx) override; + uint8_t getPeerFlags(int peer_idx) override; + uint16_t getPeerNextAeadNonce(int peer_idx) override; + void onPeerAeadDetected(int peer_idx) override; 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 onAckRecv(mesh::Packet* packet, uint32_t ack_crc) override; diff --git a/examples/simple_sensor/SensorMesh.cpp b/examples/simple_sensor/SensorMesh.cpp index 68fea474e..48af97c84 100644 --- a/examples/simple_sensor/SensorMesh.cpp +++ b/examples/simple_sensor/SensorMesh.cpp @@ -256,7 +256,7 @@ void SensorMesh::sendAlert(const ClientInfo* c, Trigger* t) { mesh::Utils::sha256((uint8_t *)&t->expected_acks[t->attempt], 4, data, 5 + text_len, self_id.pub_key, PUB_KEY_SIZE); t->attempt++; - auto pkt = createDatagram(PAYLOAD_TYPE_TXT_MSG, c->id, c->shared_secret, data, 5 + text_len); + auto pkt = createDatagram(PAYLOAD_TYPE_TXT_MSG, c->id, c->shared_secret, data, 5 + text_len, c->nextAeadNonce()); if (pkt) { if (c->out_path_len != OUT_PATH_UNKNOWN) { // we have an out_path, so send DIRECT sendDirect(pkt, c->out_path, c->out_path_len); @@ -497,6 +497,31 @@ void SensorMesh::getPeerSharedSecret(uint8_t* dest_secret, int peer_idx) { } } +uint8_t SensorMesh::getPeerFlags(int peer_idx) { + int i = matching_peer_indexes[peer_idx]; + if (i >= 0 && i < acl.getNumClients()) + return acl.getClientByIdx(i)->flags; + return 0; +} + +uint16_t SensorMesh::getPeerNextAeadNonce(int peer_idx) { + int i = matching_peer_indexes[peer_idx]; + if (i >= 0 && i < acl.getNumClients()) + return acl.getClientByIdx(i)->nextAeadNonce(); + return 0; +} + +void SensorMesh::onPeerAeadDetected(int peer_idx) { + int i = matching_peer_indexes[peer_idx]; + if (i >= 0 && i < acl.getNumClients()) { + auto c = acl.getClientByIdx(i); + if (!(c->flags & CONTACT_FLAG_AEAD)) { + c->flags |= CONTACT_FLAG_AEAD; + getRNG()->random((uint8_t*)&c->aead_nonce, sizeof(c->aead_nonce)); + } + } +} + void SensorMesh::sendAckTo(const ClientInfo& dest, uint32_t ack_hash, uint8_t path_hash_size) { if (dest.out_path_len == OUT_PATH_UNKNOWN) { mesh::Packet* ack = createAck(ack_hash); @@ -537,10 +562,10 @@ void SensorMesh::onPeerDataRecv(mesh::Packet* packet, uint8_t type, int sender_i if (packet->isRouteFlood()) { // let this sender know path TO here, so they can use sendDirect(), and ALSO encode the response mesh::Packet* path = createPathReturn(from->id, secret, packet->path, packet->path_len, - PAYLOAD_TYPE_RESPONSE, reply_data, reply_len); + PAYLOAD_TYPE_RESPONSE, reply_data, reply_len, from->nextAeadNonce()); if (path) sendFlood(path, SERVER_RESPONSE_DELAY, packet->getPathHashSize()); } else { - mesh::Packet* reply = createDatagram(PAYLOAD_TYPE_RESPONSE, from->id, secret, reply_data, reply_len); + mesh::Packet* reply = createDatagram(PAYLOAD_TYPE_RESPONSE, from->id, secret, reply_data, reply_len, from->nextAeadNonce()); if (reply) { if (from->out_path_len != OUT_PATH_UNKNOWN) { // we have an out_path, so send DIRECT sendDirect(reply, from->out_path, from->out_path_len, SERVER_RESPONSE_DELAY); @@ -567,7 +592,7 @@ void SensorMesh::onPeerDataRecv(mesh::Packet* packet, uint8_t type, int sender_i if (packet->isRouteFlood()) { // let this sender know path TO here, so they can use sendDirect(), and ALSO encode the ACK mesh::Packet* path = createPathReturn(from->id, secret, packet->path, packet->path_len, - PAYLOAD_TYPE_ACK, (uint8_t *) &ack_hash, 4); + PAYLOAD_TYPE_ACK, (uint8_t *) &ack_hash, 4, from->nextAeadNonce()); if (path) sendFlood(path, TXT_ACK_DELAY, packet->getPathHashSize()); } else { sendAckTo(*from, ack_hash, packet->getPathHashSize()); @@ -595,7 +620,7 @@ void SensorMesh::onPeerDataRecv(mesh::Packet* packet, uint8_t type, int sender_i memcpy(temp, ×tamp, 4); // mostly an extra blob to help make packet_hash unique temp[4] = (TXT_TYPE_CLI_DATA << 2); - auto reply = createDatagram(PAYLOAD_TYPE_TXT_MSG, from->id, secret, temp, 5 + text_len); + auto reply = createDatagram(PAYLOAD_TYPE_TXT_MSG, from->id, secret, temp, 5 + text_len, from->nextAeadNonce()); if (reply) { if (from->out_path_len == OUT_PATH_UNKNOWN) { sendFlood(reply, CLI_REPLY_DELAY_MILLIS, packet->getPathHashSize()); diff --git a/examples/simple_sensor/SensorMesh.h b/examples/simple_sensor/SensorMesh.h index b15a400a6..e4675c83b 100644 --- a/examples/simple_sensor/SensorMesh.h +++ b/examples/simple_sensor/SensorMesh.h @@ -123,6 +123,9 @@ class SensorMesh : public mesh::Mesh, public CommonCLICallbacks { void onAnonDataRecv(mesh::Packet* packet, const uint8_t* secret, const mesh::Identity& sender, uint8_t* data, size_t len) override; int searchPeersByHash(const uint8_t* hash) override; void getPeerSharedSecret(uint8_t* dest_secret, int peer_idx) override; + uint8_t getPeerFlags(int peer_idx) override; + uint16_t getPeerNextAeadNonce(int peer_idx) override; + void onPeerAeadDetected(int peer_idx) override; 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; diff --git a/src/helpers/ClientACL.h b/src/helpers/ClientACL.h index b758f7068..7e65c2ca3 100644 --- a/src/helpers/ClientACL.h +++ b/src/helpers/ClientACL.h @@ -15,6 +15,8 @@ struct ClientInfo { mesh::Identity id; uint8_t permissions; + uint8_t flags; // transient — includes CONTACT_FLAG_AEAD + mutable uint16_t aead_nonce; // transient — per-peer nonce counter uint8_t out_path_len; uint8_t out_path[MAX_PATH_SIZE]; uint8_t shared_secret[PUB_KEY_SIZE]; @@ -30,6 +32,13 @@ struct ClientInfo { } room; } extra; + uint16_t nextAeadNonce() const { + if (flags & CONTACT_FLAG_AEAD) { + if (++aead_nonce == 0) ++aead_nonce; // skip 0 (means ECB) + return aead_nonce; + } + return 0; + } bool isAdmin() const { return (permissions & PERM_ACL_ROLE_MASK) == PERM_ACL_ADMIN; } }; From 71469d151b517317eddae4e3cc2adeff8038449f Mon Sep 17 00:00:00 2001 From: Wessel Nieboer Date: Fri, 13 Feb 2026 00:26:42 +0100 Subject: [PATCH 16/56] Harden AEAD-4 bounds checks and add nonce wrap logging - Fix potential unsigned overflow in createDatagram size check by subtracting constants from MAX_PACKET_PAYLOAD instead of adding to data_len - Add upper-bound validation on src_len and assoc_len in aeadEncrypt and aeadDecrypt - Log peer name on AEAD nonce wraparound for debug builds --- src/Mesh.cpp | 3 ++- src/Utils.cpp | 6 ++++-- src/helpers/ContactInfo.h | 5 ++++- 3 files changed, 10 insertions(+), 4 deletions(-) diff --git a/src/Mesh.cpp b/src/Mesh.cpp index 9ab0dc69e..d9ab479cf 100644 --- a/src/Mesh.cpp +++ b/src/Mesh.cpp @@ -517,7 +517,8 @@ Packet* Mesh::createDatagram(uint8_t type, const Identity& dest, const uint8_t* if (type == PAYLOAD_TYPE_TXT_MSG || type == PAYLOAD_TYPE_REQ || type == PAYLOAD_TYPE_RESPONSE) { size_t hash_prefix = PATH_HASH_SIZE * 2; // dest_hash + src_hash size_t max_overhead = aead_nonce ? (AEAD_NONCE_SIZE + AEAD_TAG_SIZE) : (CIPHER_MAC_SIZE + CIPHER_BLOCK_SIZE-1); - if (data_len + hash_prefix + max_overhead > MAX_PACKET_PAYLOAD) return NULL; + size_t max_payload_data = MAX_PACKET_PAYLOAD - hash_prefix - max_overhead; + if (data_len > max_payload_data) return NULL; } else { return NULL; // invalid type } diff --git a/src/Utils.cpp b/src/Utils.cpp index a0c98b880..82882b2c4 100644 --- a/src/Utils.cpp +++ b/src/Utils.cpp @@ -122,7 +122,8 @@ int Utils::aeadEncrypt(const uint8_t* shared_secret, const uint8_t* assoc_data, int assoc_len, uint16_t nonce_counter, uint8_t dest_hash, uint8_t src_hash) { - if (src_len <= 0) return 0; + if (src_len <= 0 || src_len > MAX_PACKET_PAYLOAD) return 0; + if (assoc_len < 0 || assoc_len > MAX_PACKET_PAYLOAD) return 0; // Write nonce to output dest[0] = (uint8_t)(nonce_counter >> 8); @@ -166,7 +167,8 @@ int Utils::aeadDecrypt(const uint8_t* shared_secret, const uint8_t* assoc_data, int assoc_len, uint8_t dest_hash, uint8_t src_hash) { // Minimum: nonce(2) + at least 1 byte ciphertext + tag(4) - if (src_len < AEAD_NONCE_SIZE + 1 + AEAD_TAG_SIZE) return 0; + if (src_len < AEAD_NONCE_SIZE + 1 + AEAD_TAG_SIZE || src_len > MAX_PACKET_PAYLOAD) return 0; + if (assoc_len < 0 || assoc_len > MAX_PACKET_PAYLOAD) return 0; int ct_len = src_len - AEAD_NONCE_SIZE - AEAD_TAG_SIZE; diff --git a/src/helpers/ContactInfo.h b/src/helpers/ContactInfo.h index 595c464c6..da2b8b1f4 100644 --- a/src/helpers/ContactInfo.h +++ b/src/helpers/ContactInfo.h @@ -23,7 +23,10 @@ struct ContactInfo { // When 0, callers use ECB encryption. uint16_t nextAeadNonce() const { if (flags & CONTACT_FLAG_AEAD) { - if (++aead_nonce == 0) ++aead_nonce; // skip 0 (sentinel for ECB) + if (++aead_nonce == 0) { + ++aead_nonce; // skip 0 (sentinel for ECB) + MESH_DEBUG_PRINTLN("AEAD nonce wrapped for peer: %s", name); + } return aead_nonce; } return 0; From 50af9eba6a3b145e3db36af3f2827554111d552e Mon Sep 17 00:00:00 2001 From: Wessel Nieboer Date: Fri, 13 Feb 2026 00:38:26 +0100 Subject: [PATCH 17/56] Add comment about ECB path failure --- src/Utils.cpp | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/Utils.cpp b/src/Utils.cpp index 82882b2c4..d7b1b324a 100644 --- a/src/Utils.cpp +++ b/src/Utils.cpp @@ -85,7 +85,9 @@ int Utils::MACThenDecrypt(const uint8_t* shared_secret, uint8_t* dest, const uin if (memcmp(hmac, src, CIPHER_MAC_SIZE) == 0) { return decrypt(shared_secret, dest, src + CIPHER_MAC_SIZE, src_len - CIPHER_MAC_SIZE); } - return 0; // invalid HMAC + // No need to zero dest on failure — MAC is checked before decryption, + // so dest is never written to when authentication fails. + return 0; } /* From 4ddc3c316ba2176f5c29e01cabd70f64a7486123 Mon Sep 17 00:00:00 2001 From: Wessel Nieboer Date: Fri, 13 Feb 2026 15:26:30 +0100 Subject: [PATCH 18/56] Persist AEAD-4 nonces to flash across reboots Prevent nonce reuse after reboots by persisting per-peer nonce counters to a dedicated /nonces (companion) or /s_nonces (server) file. On dirty reset (power-on, watchdog, brownout), nonces are bumped by NONCE_BOOT_BUMP (100) to cover any unpersisted messages. Clean wakes (deep sleep, software restart) load nonces as-is. - Add nonce persistence to BaseChatMesh (companion) and ClientACL (server) - Add wasDirtyReset() helper to ArduinoHelpers.h for platform-specific reset reason detection (ESP32/NRF52) - Add onBeforeReboot() callback to CommonCLI for pre-reboot nonce flush - Wire nonce persistence into all firmware variants: companion radio, repeater, room server, and sensor - Only clear dirty flag on successful file write --- examples/companion_radio/DataStore.cpp | 30 ++++++++++ examples/companion_radio/DataStore.h | 4 ++ examples/companion_radio/MyMesh.cpp | 19 +++++++ examples/companion_radio/MyMesh.h | 4 ++ examples/simple_repeater/MyMesh.cpp | 26 +++++++-- examples/simple_repeater/MyMesh.h | 4 ++ examples/simple_room_server/MyMesh.cpp | 28 +++++++-- examples/simple_room_server/MyMesh.h | 4 ++ examples/simple_sensor/SensorMesh.cpp | 30 +++++++--- examples/simple_sensor/SensorMesh.h | 4 ++ src/MeshCore.h | 4 ++ src/helpers/ArduinoHelpers.h | 15 +++++ src/helpers/BaseChatMesh.cpp | 78 +++++++++++++++++++++----- src/helpers/BaseChatMesh.h | 17 ++++++ src/helpers/ClientACL.cpp | 76 +++++++++++++++++++++++++ src/helpers/ClientACL.h | 24 +++++++- src/helpers/CommonCLI.cpp | 2 + src/helpers/CommonCLI.h | 4 ++ 18 files changed, 339 insertions(+), 34 deletions(-) diff --git a/examples/companion_radio/DataStore.cpp b/examples/companion_radio/DataStore.cpp index fba64e8c6..8b3ab0648 100644 --- a/examples/companion_radio/DataStore.cpp +++ b/examples/companion_radio/DataStore.cpp @@ -375,6 +375,36 @@ void DataStore::saveChannels(DataStoreHost* host) { } } +void DataStore::loadNonces(DataStoreHost* host) { + File file = openRead(_getContactsChannelsFS(), "/nonces"); + if (file) { + uint8_t rec[6]; // 4-byte pub_key prefix + 2-byte nonce + while (file.read(rec, 6) == 6) { + uint16_t nonce; + memcpy(&nonce, &rec[4], 2); + host->onNonceLoaded(rec, nonce); + } + file.close(); + } +} + +bool DataStore::saveNonces(DataStoreHost* host) { + File file = openWrite(_getContactsChannelsFS(), "/nonces"); + if (file) { + int idx = 0; + uint8_t pub_key_prefix[4]; + uint16_t nonce; + while (host->getNonceForSave(idx, pub_key_prefix, &nonce)) { + file.write(pub_key_prefix, 4); + file.write((uint8_t*)&nonce, 2); + idx++; + } + file.close(); + return true; + } + return false; +} + #if defined(NRF52_PLATFORM) || defined(STM32_PLATFORM) #define MAX_ADVERT_PKT_LEN (2 + 32 + PUB_KEY_SIZE + 4 + SIGNATURE_SIZE + MAX_ADVERT_DATA_SIZE) diff --git a/examples/companion_radio/DataStore.h b/examples/companion_radio/DataStore.h index 58b4d5d28..d2ec9167e 100644 --- a/examples/companion_radio/DataStore.h +++ b/examples/companion_radio/DataStore.h @@ -11,6 +11,8 @@ class DataStoreHost { virtual bool getContactForSave(uint32_t idx, ContactInfo& contact) =0; virtual bool onChannelLoaded(uint8_t channel_idx, const ChannelDetails& ch) =0; virtual bool getChannelForSave(uint8_t channel_idx, ChannelDetails& ch) =0; + virtual bool onNonceLoaded(const uint8_t* pub_key_prefix, uint16_t nonce) { return false; } + virtual bool getNonceForSave(int idx, uint8_t* pub_key_prefix, uint16_t* nonce) { return false; } }; class DataStore { @@ -39,6 +41,8 @@ class DataStore { void saveContacts(DataStoreHost* host); void loadChannels(DataStoreHost* host); void saveChannels(DataStoreHost* host); + void loadNonces(DataStoreHost* host); + bool saveNonces(DataStoreHost* host); void migrateToSecondaryFS(); uint8_t getBlobByKey(const uint8_t key[], int key_len, uint8_t dest_buf[]); bool putBlobByKey(const uint8_t key[], int key_len, const uint8_t src_buf[], uint8_t len); diff --git a/examples/companion_radio/MyMesh.cpp b/examples/companion_radio/MyMesh.cpp index c96f7e017..904a2402f 100644 --- a/examples/companion_radio/MyMesh.cpp +++ b/examples/companion_radio/MyMesh.cpp @@ -802,6 +802,7 @@ MyMesh::MyMesh(mesh::Radio &radio, mesh::RNG &rng, mesh::RTCClock &rtc, SimpleMe next_ack_idx = 0; sign_data = NULL; dirty_contacts_expiry = 0; + next_nonce_persist = 0; memset(advert_paths, 0, sizeof(advert_paths)); memset(send_scope.key, 0, sizeof(send_scope.key)); @@ -878,6 +879,14 @@ void MyMesh::begin(bool has_display) { resetContacts(); _store->loadContacts(this); bootstrapRTCfromContacts(); + + // Load persisted AEAD nonces and apply boot bump if needed + _store->loadNonces(this); + bool dirty_reset = wasDirtyReset(board); + finalizeNonceLoad(dirty_reset); + if (dirty_reset) saveNonces(); // persist bumped nonces immediately + next_nonce_persist = futureMillis(60000); + addChannel("Public", PUBLIC_GROUP_PSK); // pre-configure Andy's public channel _store->loadChannels(this); @@ -1325,6 +1334,7 @@ void MyMesh::handleCmdFrame(size_t len) { if (dirty_contacts_expiry) { // is there are pending dirty contacts write needed? saveContacts(); } + if (isNonceDirty()) saveNonces(); board.reboot(); } else if (cmd_frame[0] == CMD_GET_BATT_AND_STORAGE) { uint8_t reply[11]; @@ -1976,6 +1986,7 @@ void MyMesh::checkCLIRescueCmd() { } } else if (strcmp(cli_command, "reboot") == 0) { + if (isNonceDirty()) saveNonces(); board.reboot(); // doesn't return } else { Serial.println(" Error: unknown command"); @@ -2027,6 +2038,14 @@ void MyMesh::loop() { dirty_contacts_expiry = 0; } + // periodic AEAD nonce persistence + if (next_nonce_persist && millisHasNowPassed(next_nonce_persist)) { + if (isNonceDirty()) { + saveNonces(); + } + next_nonce_persist = futureMillis(60000); + } + #ifdef DISPLAY_CLASS if (_ui) _ui->setHasConnection(_serial->isConnected()); #endif diff --git a/examples/companion_radio/MyMesh.h b/examples/companion_radio/MyMesh.h index 87e6cf338..49ef27183 100644 --- a/examples/companion_radio/MyMesh.h +++ b/examples/companion_radio/MyMesh.h @@ -154,6 +154,8 @@ class MyMesh : public BaseChatMesh, public DataStoreHost { bool getContactForSave(uint32_t idx, ContactInfo& contact) override { return getContactByIdx(idx, contact); } bool onChannelLoaded(uint8_t channel_idx, const ChannelDetails& ch) override { return setChannel(channel_idx, ch); } bool getChannelForSave(uint8_t channel_idx, ChannelDetails& ch) override { return getChannel(channel_idx, ch); } + bool onNonceLoaded(const uint8_t* pub_key_prefix, uint16_t nonce) override { return applyLoadedNonce(pub_key_prefix, nonce); } + bool getNonceForSave(int idx, uint8_t* pub_key_prefix, uint16_t* nonce) override { return getNonceEntry(idx, pub_key_prefix, nonce); } void clearPendingReqs() { pending_login = pending_status = pending_telemetry = pending_discovery = pending_req = 0; @@ -184,6 +186,7 @@ class MyMesh : public BaseChatMesh, public DataStoreHost { // helpers, short-cuts void saveChannels() { _store->saveChannels(this); } void saveContacts() { _store->saveContacts(this); } + void saveNonces() { if (_store->saveNonces(this)) clearNonceDirty(); } DataStore* _store; NodePrefs _prefs; @@ -205,6 +208,7 @@ class MyMesh : public BaseChatMesh, public DataStoreHost { uint8_t *sign_data; uint32_t sign_data_len; unsigned long dirty_contacts_expiry; + unsigned long next_nonce_persist; TransportKey send_scope; diff --git a/examples/simple_repeater/MyMesh.cpp b/examples/simple_repeater/MyMesh.cpp index aada99726..6ef46d0ae 100644 --- a/examples/simple_repeater/MyMesh.cpp +++ b/examples/simple_repeater/MyMesh.cpp @@ -589,7 +589,7 @@ uint8_t MyMesh::getPeerFlags(int peer_idx) { uint16_t MyMesh::getPeerNextAeadNonce(int peer_idx) { int i = matching_peer_indexes[peer_idx]; if (i >= 0 && i < acl.getNumClients()) - return acl.getClientByIdx(i)->nextAeadNonce(); + return acl.nextAeadNonceFor(*acl.getClientByIdx(i)); return 0; } @@ -599,7 +599,10 @@ void MyMesh::onPeerAeadDetected(int peer_idx) { auto c = acl.getClientByIdx(i); if (!(c->flags & CONTACT_FLAG_AEAD)) { c->flags |= CONTACT_FLAG_AEAD; - getRNG()->random((uint8_t*)&c->aead_nonce, sizeof(c->aead_nonce)); + if (c->aead_nonce == 0) { // no persisted nonce — seed from RNG to avoid deterministic start + getRNG()->random((uint8_t*)&c->aead_nonce, sizeof(c->aead_nonce)); + if (c->aead_nonce == 0) c->aead_nonce = 1; + } } } } @@ -647,11 +650,11 @@ void MyMesh::onPeerDataRecv(mesh::Packet *packet, uint8_t type, int sender_idx, if (packet->isRouteFlood()) { // let this sender know path TO here, so they can use sendDirect(), and ALSO encode the response mesh::Packet *path = createPathReturn(client->id, secret, packet->path, packet->path_len, - PAYLOAD_TYPE_RESPONSE, reply_data, reply_len, client->nextAeadNonce()); + PAYLOAD_TYPE_RESPONSE, reply_data, reply_len, acl.nextAeadNonceFor(*client)); if (path) sendFlood(path, SERVER_RESPONSE_DELAY, packet->getPathHashSize()); } else { mesh::Packet *reply = - createDatagram(PAYLOAD_TYPE_RESPONSE, client->id, secret, reply_data, reply_len, client->nextAeadNonce()); + createDatagram(PAYLOAD_TYPE_RESPONSE, client->id, secret, reply_data, reply_len, acl.nextAeadNonceFor(*client)); if (reply) { if (client->out_path_len != OUT_PATH_UNKNOWN) { // we have an out_path, so send DIRECT sendDirect(reply, client->out_path, client->out_path_len, SERVER_RESPONSE_DELAY); @@ -712,7 +715,7 @@ void MyMesh::onPeerDataRecv(mesh::Packet *packet, uint8_t type, int sender_idx, memcpy(temp, ×tamp, 4); // mostly an extra blob to help make packet_hash unique temp[4] = (TXT_TYPE_CLI_DATA << 2); // NOTE: legacy was: TXT_TYPE_PLAIN - auto reply = createDatagram(PAYLOAD_TYPE_TXT_MSG, client->id, secret, temp, 5 + text_len, client->nextAeadNonce()); + auto reply = createDatagram(PAYLOAD_TYPE_TXT_MSG, client->id, secret, temp, 5 + text_len, acl.nextAeadNonceFor(*client)); if (reply) { if (client->out_path_len == OUT_PATH_UNKNOWN) { sendFlood(reply, CLI_REPLY_DELAY_MILLIS, packet->getPathHashSize()); @@ -839,6 +842,7 @@ MyMesh::MyMesh(mesh::MainBoard &board, mesh::Radio &radio, mesh::MillisecondCloc uptime_millis = 0; next_local_advert = next_flood_advert = 0; dirty_contacts_expiry = 0; + next_nonce_persist = 0; set_radio_at = revert_radio_at = 0; _logging = false; region_load_active = false; @@ -893,6 +897,12 @@ void MyMesh::begin(FILESYSTEM *fs) { // load persisted prefs _cli.loadPrefs(_fs); acl.load(_fs, self_id); + acl.setRNG(getRNG()); + acl.loadNonces(); + bool dirty_reset = wasDirtyReset(board); + acl.finalizeNonceLoad(dirty_reset); + if (dirty_reset) acl.saveNonces(); // persist bumped nonces immediately + next_nonce_persist = futureMillis(60000); // TODO: key_store.begin(); region_map.load(_fs); @@ -1304,6 +1314,12 @@ void MyMesh::loop() { dirty_contacts_expiry = 0; } + // persist dirty AEAD nonces + if (next_nonce_persist && millisHasNowPassed(next_nonce_persist)) { + if (acl.isNonceDirty()) { acl.saveNonces(); } + next_nonce_persist = futureMillis(60000); + } + // update uptime uint32_t now = millis(); uptime_millis += now - last_millis; diff --git a/examples/simple_repeater/MyMesh.h b/examples/simple_repeater/MyMesh.h index 954995916..0a72103d8 100644 --- a/examples/simple_repeater/MyMesh.h +++ b/examples/simple_repeater/MyMesh.h @@ -102,6 +102,7 @@ class MyMesh : public mesh::Mesh, public CommonCLICallbacks { unsigned long pending_discover_until; bool region_load_active; unsigned long dirty_contacts_expiry; + unsigned long next_nonce_persist; #if MAX_NEIGHBOURS NeighbourInfo neighbours[MAX_NEIGHBOURS]; #endif @@ -191,6 +192,9 @@ class MyMesh : public mesh::Mesh, public CommonCLICallbacks { void savePrefs() override { _cli.savePrefs(_fs); } + void onBeforeReboot() override { + if (acl.isNonceDirty()) acl.saveNonces(); + } void applyTempRadioParams(float freq, float bw, uint8_t sf, uint8_t cr, int timeout_mins) override; bool formatFileSystem() override; diff --git a/examples/simple_room_server/MyMesh.cpp b/examples/simple_room_server/MyMesh.cpp index b80c0f6d5..d053964b1 100644 --- a/examples/simple_room_server/MyMesh.cpp +++ b/examples/simple_room_server/MyMesh.cpp @@ -71,7 +71,7 @@ void MyMesh::pushPostToClient(ClientInfo *client, PostInfo &post) { mesh::Utils::sha256((uint8_t *)&client->extra.room.pending_ack, 4, reply_data, len, client->id.pub_key, PUB_KEY_SIZE); client->extra.room.push_post_timestamp = post.post_timestamp; - auto reply = createDatagram(PAYLOAD_TYPE_TXT_MSG, client->id, client->shared_secret, reply_data, len, client->nextAeadNonce()); + auto reply = createDatagram(PAYLOAD_TYPE_TXT_MSG, client->id, client->shared_secret, reply_data, len, acl.nextAeadNonceFor(*client)); if (reply) { if (client->out_path_len == OUT_PATH_UNKNOWN) { unsigned long delay_millis = 0; @@ -399,7 +399,7 @@ uint8_t MyMesh::getPeerFlags(int peer_idx) { uint16_t MyMesh::getPeerNextAeadNonce(int peer_idx) { int i = matching_peer_indexes[peer_idx]; if (i >= 0 && i < acl.getNumClients()) - return acl.getClientByIdx(i)->nextAeadNonce(); + return acl.nextAeadNonceFor(*acl.getClientByIdx(i)); return 0; } @@ -409,7 +409,10 @@ void MyMesh::onPeerAeadDetected(int peer_idx) { auto c = acl.getClientByIdx(i); if (!(c->flags & CONTACT_FLAG_AEAD)) { c->flags |= CONTACT_FLAG_AEAD; - getRNG()->random((uint8_t*)&c->aead_nonce, sizeof(c->aead_nonce)); + if (c->aead_nonce == 0) { // no persisted nonce — seed from RNG to avoid deterministic start + getRNG()->random((uint8_t*)&c->aead_nonce, sizeof(c->aead_nonce)); + if (c->aead_nonce == 0) c->aead_nonce = 1; + } } } } @@ -507,7 +510,7 @@ void MyMesh::onPeerDataRecv(mesh::Packet *packet, uint8_t type, int sender_idx, // mesh::Utils::sha256((uint8_t *)&expected_ack_crc, 4, temp, 5 + text_len, self_id.pub_key, // PUB_KEY_SIZE); - auto reply = createDatagram(PAYLOAD_TYPE_TXT_MSG, client->id, secret, temp, 5 + text_len, client->nextAeadNonce()); + auto reply = createDatagram(PAYLOAD_TYPE_TXT_MSG, client->id, secret, temp, 5 + text_len, acl.nextAeadNonceFor(*client)); if (reply) { if (client->out_path_len == OUT_PATH_UNKNOWN) { sendFlood(reply, delay_millis + SERVER_RESPONSE_DELAY, packet->getPathHashSize()); @@ -564,10 +567,10 @@ void MyMesh::onPeerDataRecv(mesh::Packet *packet, uint8_t type, int sender_idx, if (packet->isRouteFlood()) { // let this sender know path TO here, so they can use sendDirect(), and ALSO encode the response mesh::Packet *path = createPathReturn(client->id, secret, packet->path, packet->path_len, - PAYLOAD_TYPE_RESPONSE, reply_data, reply_len, client->nextAeadNonce()); + PAYLOAD_TYPE_RESPONSE, reply_data, reply_len, acl.nextAeadNonceFor(*client)); if (path) sendFlood(path, SERVER_RESPONSE_DELAY, packet->getPathHashSize()); } else { - mesh::Packet *reply = createDatagram(PAYLOAD_TYPE_RESPONSE, client->id, secret, reply_data, reply_len, client->nextAeadNonce()); + mesh::Packet *reply = createDatagram(PAYLOAD_TYPE_RESPONSE, client->id, secret, reply_data, reply_len, acl.nextAeadNonceFor(*client)); if (reply) { if (client->out_path_len != OUT_PATH_UNKNOWN) { // we have an out_path, so send DIRECT sendDirect(reply, client->out_path, client->out_path_len, SERVER_RESPONSE_DELAY); @@ -619,6 +622,7 @@ MyMesh::MyMesh(mesh::MainBoard &board, mesh::Radio &radio, mesh::MillisecondCloc uptime_millis = 0; next_local_advert = next_flood_advert = 0; dirty_contacts_expiry = 0; + next_nonce_persist = 0; _logging = false; set_radio_at = revert_radio_at = 0; @@ -665,6 +669,12 @@ void MyMesh::begin(FILESYSTEM *fs) { _cli.loadPrefs(_fs); acl.load(_fs, self_id); + acl.setRNG(getRNG()); + acl.loadNonces(); + bool dirty_reset = wasDirtyReset(board); + acl.finalizeNonceLoad(dirty_reset); + if (dirty_reset) acl.saveNonces(); // persist bumped nonces immediately + next_nonce_persist = futureMillis(60000); radio_set_params(_prefs.freq, _prefs.bw, _prefs.sf, _prefs.cr); radio_set_tx_power(_prefs.tx_power_dbm); @@ -912,6 +922,12 @@ void MyMesh::loop() { dirty_contacts_expiry = 0; } + // persist dirty AEAD nonces + if (next_nonce_persist && millisHasNowPassed(next_nonce_persist)) { + if (acl.isNonceDirty()) { acl.saveNonces(); } + next_nonce_persist = futureMillis(60000); + } + // TODO: periodically check for OLD/inactive entries in known_clients[], and evict // update uptime diff --git a/examples/simple_room_server/MyMesh.h b/examples/simple_room_server/MyMesh.h index 0a4063a6c..c2d646548 100644 --- a/examples/simple_room_server/MyMesh.h +++ b/examples/simple_room_server/MyMesh.h @@ -97,6 +97,7 @@ class MyMesh : public mesh::Mesh, public CommonCLICallbacks { ClientACL acl; CommonCLI _cli; unsigned long dirty_contacts_expiry; + unsigned long next_nonce_persist; uint8_t reply_data[MAX_PACKET_PAYLOAD]; unsigned long next_push; uint16_t _num_posted, _num_post_pushes; @@ -177,6 +178,9 @@ class MyMesh : public mesh::Mesh, public CommonCLICallbacks { void savePrefs() override { _cli.savePrefs(_fs); } + void onBeforeReboot() override { + if (acl.isNonceDirty()) acl.saveNonces(); + } void applyTempRadioParams(float freq, float bw, uint8_t sf, uint8_t cr, int timeout_mins) override; bool formatFileSystem() override; diff --git a/examples/simple_sensor/SensorMesh.cpp b/examples/simple_sensor/SensorMesh.cpp index 48af97c84..96681dc89 100644 --- a/examples/simple_sensor/SensorMesh.cpp +++ b/examples/simple_sensor/SensorMesh.cpp @@ -256,7 +256,7 @@ void SensorMesh::sendAlert(const ClientInfo* c, Trigger* t) { mesh::Utils::sha256((uint8_t *)&t->expected_acks[t->attempt], 4, data, 5 + text_len, self_id.pub_key, PUB_KEY_SIZE); t->attempt++; - auto pkt = createDatagram(PAYLOAD_TYPE_TXT_MSG, c->id, c->shared_secret, data, 5 + text_len, c->nextAeadNonce()); + auto pkt = createDatagram(PAYLOAD_TYPE_TXT_MSG, c->id, c->shared_secret, data, 5 + text_len, acl.nextAeadNonceFor(*c)); if (pkt) { if (c->out_path_len != OUT_PATH_UNKNOWN) { // we have an out_path, so send DIRECT sendDirect(pkt, c->out_path, c->out_path_len); @@ -507,7 +507,7 @@ uint8_t SensorMesh::getPeerFlags(int peer_idx) { uint16_t SensorMesh::getPeerNextAeadNonce(int peer_idx) { int i = matching_peer_indexes[peer_idx]; if (i >= 0 && i < acl.getNumClients()) - return acl.getClientByIdx(i)->nextAeadNonce(); + return acl.nextAeadNonceFor(*acl.getClientByIdx(i)); return 0; } @@ -517,7 +517,10 @@ void SensorMesh::onPeerAeadDetected(int peer_idx) { auto c = acl.getClientByIdx(i); if (!(c->flags & CONTACT_FLAG_AEAD)) { c->flags |= CONTACT_FLAG_AEAD; - getRNG()->random((uint8_t*)&c->aead_nonce, sizeof(c->aead_nonce)); + if (c->aead_nonce == 0) { // no persisted nonce — seed from RNG to avoid deterministic start + getRNG()->random((uint8_t*)&c->aead_nonce, sizeof(c->aead_nonce)); + if (c->aead_nonce == 0) c->aead_nonce = 1; + } } } } @@ -562,10 +565,10 @@ void SensorMesh::onPeerDataRecv(mesh::Packet* packet, uint8_t type, int sender_i if (packet->isRouteFlood()) { // let this sender know path TO here, so they can use sendDirect(), and ALSO encode the response mesh::Packet* path = createPathReturn(from->id, secret, packet->path, packet->path_len, - PAYLOAD_TYPE_RESPONSE, reply_data, reply_len, from->nextAeadNonce()); + PAYLOAD_TYPE_RESPONSE, reply_data, reply_len, acl.nextAeadNonceFor(*from)); if (path) sendFlood(path, SERVER_RESPONSE_DELAY, packet->getPathHashSize()); } else { - mesh::Packet* reply = createDatagram(PAYLOAD_TYPE_RESPONSE, from->id, secret, reply_data, reply_len, from->nextAeadNonce()); + mesh::Packet* reply = createDatagram(PAYLOAD_TYPE_RESPONSE, from->id, secret, reply_data, reply_len, acl.nextAeadNonceFor(*from)); if (reply) { if (from->out_path_len != OUT_PATH_UNKNOWN) { // we have an out_path, so send DIRECT sendDirect(reply, from->out_path, from->out_path_len, SERVER_RESPONSE_DELAY); @@ -592,7 +595,7 @@ void SensorMesh::onPeerDataRecv(mesh::Packet* packet, uint8_t type, int sender_i if (packet->isRouteFlood()) { // let this sender know path TO here, so they can use sendDirect(), and ALSO encode the ACK mesh::Packet* path = createPathReturn(from->id, secret, packet->path, packet->path_len, - PAYLOAD_TYPE_ACK, (uint8_t *) &ack_hash, 4, from->nextAeadNonce()); + PAYLOAD_TYPE_ACK, (uint8_t *) &ack_hash, 4, acl.nextAeadNonceFor(*from)); if (path) sendFlood(path, TXT_ACK_DELAY, packet->getPathHashSize()); } else { sendAckTo(*from, ack_hash, packet->getPathHashSize()); @@ -620,7 +623,7 @@ void SensorMesh::onPeerDataRecv(mesh::Packet* packet, uint8_t type, int sender_i memcpy(temp, ×tamp, 4); // mostly an extra blob to help make packet_hash unique temp[4] = (TXT_TYPE_CLI_DATA << 2); - auto reply = createDatagram(PAYLOAD_TYPE_TXT_MSG, from->id, secret, temp, 5 + text_len, from->nextAeadNonce()); + auto reply = createDatagram(PAYLOAD_TYPE_TXT_MSG, from->id, secret, temp, 5 + text_len, acl.nextAeadNonceFor(*from)); if (reply) { if (from->out_path_len == OUT_PATH_UNKNOWN) { sendFlood(reply, CLI_REPLY_DELAY_MILLIS, packet->getPathHashSize()); @@ -725,6 +728,7 @@ SensorMesh::SensorMesh(mesh::MainBoard& board, mesh::Radio& radio, mesh::Millise { next_local_advert = next_flood_advert = 0; dirty_contacts_expiry = 0; + next_nonce_persist = 0; last_read_time = 0; num_alert_tasks = 0; set_radio_at = revert_radio_at = 0; @@ -763,6 +767,12 @@ void SensorMesh::begin(FILESYSTEM* fs) { _cli.loadPrefs(_fs); acl.load(_fs, self_id); + acl.setRNG(getRNG()); + acl.loadNonces(); + bool dirty_reset = wasDirtyReset(board); + acl.finalizeNonceLoad(dirty_reset); + if (dirty_reset) acl.saveNonces(); // persist bumped nonces immediately + next_nonce_persist = futureMillis(60000); radio_set_params(_prefs.freq, _prefs.bw, _prefs.sf, _prefs.cr); radio_set_tx_power(_prefs.tx_power_dbm); @@ -971,4 +981,10 @@ void SensorMesh::loop() { acl.save(_fs); dirty_contacts_expiry = 0; } + + // persist dirty AEAD nonces + if (next_nonce_persist && millisHasNowPassed(next_nonce_persist)) { + if (acl.isNonceDirty()) { acl.saveNonces(); } + next_nonce_persist = futureMillis(60000); + } } diff --git a/examples/simple_sensor/SensorMesh.h b/examples/simple_sensor/SensorMesh.h index e4675c83b..37dcfe7c1 100644 --- a/examples/simple_sensor/SensorMesh.h +++ b/examples/simple_sensor/SensorMesh.h @@ -59,6 +59,9 @@ class SensorMesh : public mesh::Mesh, public CommonCLICallbacks { const char* getNodeName() { return _prefs.node_name; } NodePrefs* getNodePrefs() { return &_prefs; } void savePrefs() override { _cli.savePrefs(_fs); } + void onBeforeReboot() override { + if (acl.isNonceDirty()) acl.saveNonces(); + } bool formatFileSystem() override; void sendSelfAdvertisement(int delay_millis, bool flood) override; void updateAdvertTimer() override; @@ -140,6 +143,7 @@ class SensorMesh : public mesh::Mesh, public CommonCLICallbacks { CommonCLI _cli; uint8_t reply_data[MAX_PACKET_PAYLOAD]; unsigned long dirty_contacts_expiry; + unsigned long next_nonce_persist; CayenneLPP telemetry; uint32_t last_read_time; int matching_peer_indexes[MAX_SEARCH_RESULTS]; diff --git a/src/MeshCore.h b/src/MeshCore.h index 635401d93..cc2a1440a 100644 --- a/src/MeshCore.h +++ b/src/MeshCore.h @@ -22,6 +22,10 @@ #define CONTACT_FLAG_AEAD 0x02 // bit 1 of ContactInfo.flags (bit 0 = favourite) #define FEAT1_AEAD_SUPPORT 0x0001 // bit 0 of feat1 uint16_t +// AEAD nonce persistence +#define NONCE_PERSIST_INTERVAL 50 // persist every N messages per peer +#define NONCE_BOOT_BUMP 100 // add this on load after dirty boot (must be >= 2 * PERSIST_INTERVAL) + #define MAX_PACKET_PAYLOAD 184 #define MAX_PATH_SIZE 64 #define MAX_TRANS_UNIT 255 diff --git a/src/helpers/ArduinoHelpers.h b/src/helpers/ArduinoHelpers.h index 97596daa3..9af0277e8 100644 --- a/src/helpers/ArduinoHelpers.h +++ b/src/helpers/ArduinoHelpers.h @@ -1,6 +1,7 @@ #pragma once #include +#include #include class VolatileRTCClock : public mesh::RTCClock { @@ -33,3 +34,17 @@ class StdRNG : public mesh::RNG { } } }; + +// Returns true for dirty resets (power-on, watchdog, brownout, panic). +// Returns false for clean wakes (deep sleep, software restart). +inline bool wasDirtyReset(mesh::MainBoard& board) { +#if defined(ESP32) + esp_reset_reason_t rst = esp_reset_reason(); + return (rst != ESP_RST_DEEPSLEEP && rst != ESP_RST_SW); +#elif defined(NRF52_PLATFORM) + return !(board.getResetReason() & POWER_RESETREAS_SREQ_Msk); +#else + (void)board; + return true; +#endif +} diff --git a/src/helpers/BaseChatMesh.cpp b/src/helpers/BaseChatMesh.cpp index d8f9066ad..b1137e132 100644 --- a/src/helpers/BaseChatMesh.cpp +++ b/src/helpers/BaseChatMesh.cpp @@ -9,6 +9,50 @@ #define TXT_ACK_DELAY 200 #endif +uint16_t BaseChatMesh::nextAeadNonceFor(const ContactInfo& contact) { + uint16_t nonce = contact.nextAeadNonce(); + if (nonce != 0) { + int idx = &contact - contacts; + if (idx >= 0 && idx < num_contacts && + (uint16_t)(contact.aead_nonce - nonce_at_last_persist[idx]) >= NONCE_PERSIST_INTERVAL) { + nonce_dirty = true; + } + } + return nonce; +} + +bool BaseChatMesh::applyLoadedNonce(const uint8_t* pub_key_prefix, uint16_t nonce) { + for (int i = 0; i < num_contacts; i++) { + if (memcmp(contacts[i].id.pub_key, pub_key_prefix, 4) == 0) { + contacts[i].aead_nonce = nonce; + return true; + } + } + return false; +} + +void BaseChatMesh::finalizeNonceLoad(bool needs_bump) { + for (int i = 0; i < num_contacts; i++) { + if (needs_bump) { + uint16_t old = contacts[i].aead_nonce; + contacts[i].aead_nonce += NONCE_BOOT_BUMP; + if (contacts[i].aead_nonce == 0) contacts[i].aead_nonce = 1; + if (contacts[i].aead_nonce < old) { + MESH_DEBUG_PRINTLN("AEAD nonce wrapped after boot bump for peer: %s", contacts[i].name); + } + } + nonce_at_last_persist[i] = contacts[i].aead_nonce; + } + nonce_dirty = false; +} + +bool BaseChatMesh::getNonceEntry(int idx, uint8_t* pub_key_prefix, uint16_t* nonce) { + if (idx >= num_contacts) return false; + memcpy(pub_key_prefix, contacts[idx].id.pub_key, 4); + *nonce = contacts[idx].aead_nonce; + return true; +} + void BaseChatMesh::sendFloodScoped(const ContactInfo& recipient, mesh::Packet* pkt, uint32_t delay_millis) { sendFlood(pkt, delay_millis); } @@ -104,6 +148,7 @@ void BaseChatMesh::populateContactFromAdvert(ContactInfo& ci, const mesh::Identi ci.last_advert_timestamp = timestamp; ci.lastmod = getRTCClock()->getCurrentTime(); getRNG()->random((uint8_t*)&ci.aead_nonce, sizeof(ci.aead_nonce)); // seed AEAD nonce from HW RNG + if (ci.aead_nonce == 0) ci.aead_nonce = 1; if (parser.getFeat1() & FEAT1_AEAD_SUPPORT) { ci.flags |= CONTACT_FLAG_AEAD; } @@ -157,7 +202,8 @@ void BaseChatMesh::onAdvertRecv(mesh::Packet* packet, const mesh::Identity& id, return; } - populateContactFromAdvert(*from, id, parser, timestamp); + populateContactFromAdvert(*from, id, parser, timestamp); // seeds aead_nonce from RNG + nonce_at_last_persist[from - contacts] = from->aead_nonce; from->sync_since = 0; from->shared_secret_valid = false; } @@ -210,7 +256,7 @@ uint8_t BaseChatMesh::getPeerFlags(int peer_idx) { uint16_t BaseChatMesh::getPeerNextAeadNonce(int peer_idx) { int i = matching_peer_indexes[peer_idx]; if (i >= 0 && i < num_contacts) { - return contacts[i].nextAeadNonce(); + return nextAeadNonceFor(contacts[i]); } return 0; } @@ -242,7 +288,7 @@ void BaseChatMesh::onPeerDataRecv(mesh::Packet* packet, uint8_t type, int sender if (packet->isRouteFlood()) { // let this sender know path TO here, so they can use sendDirect(), and ALSO encode the ACK mesh::Packet* path = createPathReturn(from.id, secret, packet->path, packet->path_len, - PAYLOAD_TYPE_ACK, (uint8_t *) &ack_hash, 4, from.nextAeadNonce()); + PAYLOAD_TYPE_ACK, (uint8_t *) &ack_hash, 4, nextAeadNonceFor(from)); if (path) sendFloodScoped(from, path, TXT_ACK_DELAY); } else { sendAckTo(from, ack_hash); @@ -253,7 +299,7 @@ void BaseChatMesh::onPeerDataRecv(mesh::Packet* packet, uint8_t type, int sender if (packet->isRouteFlood()) { // let this sender know path TO here, so they can use sendDirect() (NOTE: no ACK as extra) - mesh::Packet* path = createPathReturn(from.id, secret, packet->path, packet->path_len, 0, NULL, 0, from.nextAeadNonce()); + mesh::Packet* path = createPathReturn(from.id, secret, packet->path, packet->path_len, 0, NULL, 0, nextAeadNonceFor(from)); if (path) sendFloodScoped(from, path); } } else if (flags == TXT_TYPE_SIGNED_PLAIN) { @@ -269,7 +315,7 @@ void BaseChatMesh::onPeerDataRecv(mesh::Packet* packet, uint8_t type, int sender if (packet->isRouteFlood()) { // let this sender know path TO here, so they can use sendDirect(), and ALSO encode the ACK mesh::Packet* path = createPathReturn(from.id, secret, packet->path, packet->path_len, - PAYLOAD_TYPE_ACK, (uint8_t *) &ack_hash, 4, from.nextAeadNonce()); + PAYLOAD_TYPE_ACK, (uint8_t *) &ack_hash, 4, nextAeadNonceFor(from)); if (path) sendFloodScoped(from, path, TXT_ACK_DELAY); } else { sendAckTo(from, ack_hash); @@ -285,10 +331,10 @@ void BaseChatMesh::onPeerDataRecv(mesh::Packet* packet, uint8_t type, int sender if (packet->isRouteFlood()) { // let this sender know path TO here, so they can use sendDirect(), and ALSO encode the response mesh::Packet* path = createPathReturn(from.id, secret, packet->path, packet->path_len, - PAYLOAD_TYPE_RESPONSE, temp_buf, reply_len, from.nextAeadNonce()); + PAYLOAD_TYPE_RESPONSE, temp_buf, reply_len, nextAeadNonceFor(from)); if (path) sendFloodScoped(from, path, SERVER_RESPONSE_DELAY); } else { - mesh::Packet* reply = createDatagram(PAYLOAD_TYPE_RESPONSE, from.id, secret, temp_buf, reply_len, from.nextAeadNonce()); + mesh::Packet* reply = createDatagram(PAYLOAD_TYPE_RESPONSE, from.id, secret, temp_buf, reply_len, nextAeadNonceFor(from)); if (reply) { if (from.out_path_len != OUT_PATH_UNKNOWN) { // we have an out_path, so send DIRECT sendDirect(reply, from.out_path, from.out_path_len, SERVER_RESPONSE_DELAY); @@ -354,7 +400,7 @@ void BaseChatMesh::onAckRecv(mesh::Packet* packet, uint32_t ack_crc) { void BaseChatMesh::handleReturnPathRetry(const ContactInfo& contact, const uint8_t* path, uint8_t path_len) { // NOTE: simplest impl is just to re-send a reciprocal return path to sender (DIRECTLY) // override this method in various firmwares, if there's a better strategy - mesh::Packet* rpath = createPathReturn(contact.id, contact.getSharedSecret(self_id), path, path_len, 0, NULL, 0, contact.nextAeadNonce()); + mesh::Packet* rpath = createPathReturn(contact.id, contact.getSharedSecret(self_id), path, path_len, 0, NULL, 0, nextAeadNonceFor(contact)); if (rpath) sendDirect(rpath, contact.out_path, contact.out_path_len, 3000); // 3 second delay } @@ -403,7 +449,7 @@ mesh::Packet* BaseChatMesh::composeMsgPacket(const ContactInfo& recipient, uint3 temp[len++] = attempt; // hide attempt number at tail end of payload } - return createDatagram(PAYLOAD_TYPE_TXT_MSG, recipient.id, recipient.getSharedSecret(self_id), temp, len, recipient.nextAeadNonce()); + return createDatagram(PAYLOAD_TYPE_TXT_MSG, recipient.id, recipient.getSharedSecret(self_id), temp, len, nextAeadNonceFor(recipient)); } int BaseChatMesh::sendMessage(const ContactInfo& recipient, uint32_t timestamp, uint8_t attempt, const char* text, uint32_t& expected_ack, uint32_t& est_timeout) { @@ -434,7 +480,7 @@ int BaseChatMesh::sendCommandData(const ContactInfo& recipient, uint32_t timest temp[4] = (attempt & 3) | (TXT_TYPE_CLI_DATA << 2); memcpy(&temp[5], text, text_len + 1); - auto pkt = createDatagram(PAYLOAD_TYPE_TXT_MSG, recipient.id, recipient.getSharedSecret(self_id), temp, 5 + text_len, recipient.nextAeadNonce()); + auto pkt = createDatagram(PAYLOAD_TYPE_TXT_MSG, recipient.id, recipient.getSharedSecret(self_id), temp, 5 + text_len, nextAeadNonceFor(recipient)); if (pkt == NULL) return MSG_SEND_FAILED; uint32_t t = _radio->getEstAirtimeFor(pkt->getRawLength()); @@ -575,7 +621,7 @@ int BaseChatMesh::sendRequest(const ContactInfo& recipient, const uint8_t* req_ memcpy(temp, &tag, 4); // mostly an extra blob to help make packet_hash unique memcpy(&temp[4], req_data, data_len); - pkt = createDatagram(PAYLOAD_TYPE_REQ, recipient.id, recipient.getSharedSecret(self_id), temp, 4 + data_len, recipient.nextAeadNonce()); + pkt = createDatagram(PAYLOAD_TYPE_REQ, recipient.id, recipient.getSharedSecret(self_id), temp, 4 + data_len, nextAeadNonceFor(recipient)); } if (pkt) { uint32_t t = _radio->getEstAirtimeFor(pkt->getRawLength()); @@ -602,7 +648,7 @@ int BaseChatMesh::sendRequest(const ContactInfo& recipient, uint8_t req_type, u memset(&temp[5], 0, 4); // reserved (possibly for 'since' param) getRNG()->random(&temp[9], 4); // random blob to help make packet-hash unique - pkt = createDatagram(PAYLOAD_TYPE_REQ, recipient.id, recipient.getSharedSecret(self_id), temp, sizeof(temp), recipient.nextAeadNonce()); + pkt = createDatagram(PAYLOAD_TYPE_REQ, recipient.id, recipient.getSharedSecret(self_id), temp, sizeof(temp), nextAeadNonceFor(recipient)); } if (pkt) { uint32_t t = _radio->getEstAirtimeFor(pkt->getRawLength()); @@ -725,7 +771,7 @@ void BaseChatMesh::checkConnections() { // calc expected ACK reply mesh::Utils::sha256((uint8_t *)&connections[i].expected_ack, 4, data, 9, self_id.pub_key, PUB_KEY_SIZE); - auto pkt = createDatagram(PAYLOAD_TYPE_REQ, contact->id, contact->getSharedSecret(self_id), data, 9, contact->nextAeadNonce()); + auto pkt = createDatagram(PAYLOAD_TYPE_REQ, contact->id, contact->getSharedSecret(self_id), data, 9, nextAeadNonceFor(*contact)); if (pkt) { sendDirect(pkt, contact->out_path, contact->out_path_len); } @@ -787,9 +833,12 @@ ContactInfo* BaseChatMesh::lookupContactByPubKey(const uint8_t* pub_key, int pre bool BaseChatMesh::addContact(const ContactInfo& contact) { ContactInfo* dest = allocateContactSlot(); if (dest) { + int idx = dest - contacts; *dest = contact; dest->shared_secret_valid = false; // mark shared_secret as needing calculation getRNG()->random((uint8_t*)&dest->aead_nonce, sizeof(dest->aead_nonce)); // always seed fresh from HW RNG + if (dest->aead_nonce == 0) dest->aead_nonce = 1; + nonce_at_last_persist[idx] = dest->aead_nonce; return true; // success } return false; @@ -802,10 +851,11 @@ bool BaseChatMesh::removeContact(ContactInfo& contact) { } if (idx >= num_contacts) return false; // not found - // remove from contacts array + // remove from contacts array and parallel nonce tracking num_contacts--; while (idx < num_contacts) { contacts[idx] = contacts[idx + 1]; + nonce_at_last_persist[idx] = nonce_at_last_persist[idx + 1]; idx++; } return true; // Success diff --git a/src/helpers/BaseChatMesh.h b/src/helpers/BaseChatMesh.h index 516375efa..77c984129 100644 --- a/src/helpers/BaseChatMesh.h +++ b/src/helpers/BaseChatMesh.h @@ -71,6 +71,10 @@ class BaseChatMesh : public mesh::Mesh { uint8_t temp_buf[MAX_TRANS_UNIT]; ConnectionInfo connections[MAX_CONNECTIONS]; + // Nonce persistence state (parallel to contacts[]) + uint16_t nonce_at_last_persist[MAX_CONTACTS]; + bool nonce_dirty; + mesh::Packet* composeMsgPacket(const ContactInfo& recipient, uint32_t timestamp, uint8_t attempt, const char *text, uint32_t& expected_ack); void sendAckTo(const ContactInfo& dest, uint32_t ack_hash); @@ -86,6 +90,8 @@ class BaseChatMesh : public mesh::Mesh { txt_send_timeout = 0; _pendingLoopback = NULL; memset(connections, 0, sizeof(connections)); + memset(nonce_at_last_persist, 0, sizeof(nonce_at_last_persist)); + nonce_dirty = false; } void bootstrapRTCfromContacts(); @@ -121,6 +127,17 @@ class BaseChatMesh : public mesh::Mesh { virtual int getBlobByKey(const uint8_t key[], int key_len, uint8_t dest_buf[]) { return 0; } // not implemented virtual bool putBlobByKey(const uint8_t key[], int key_len, const uint8_t src_buf[], int len) { return false; } + // AEAD nonce persistence helpers + uint16_t nextAeadNonceFor(const ContactInfo& contact); // wraps nextAeadNonce() with dirty-check + bool applyLoadedNonce(const uint8_t* pub_key_prefix, uint16_t nonce); + void finalizeNonceLoad(bool needs_bump); + bool getNonceEntry(int idx, uint8_t* pub_key_prefix, uint16_t* nonce); + bool isNonceDirty() const { return nonce_dirty; } + void clearNonceDirty() { + for (int i = 0; i < num_contacts; i++) nonce_at_last_persist[i] = contacts[i].aead_nonce; + nonce_dirty = false; + } + // Mesh overrides void onAdvertRecv(mesh::Packet* packet, const mesh::Identity& id, uint32_t timestamp, const uint8_t* app_data, size_t app_data_len) override; int searchPeersByHash(const uint8_t* hash) override; diff --git a/src/helpers/ClientACL.cpp b/src/helpers/ClientACL.cpp index 128238273..56b1a79c2 100644 --- a/src/helpers/ClientACL.cpp +++ b/src/helpers/ClientACL.cpp @@ -1,4 +1,5 @@ #include "ClientACL.h" +#include static File openWrite(FILESYSTEM* _fs, const char* filename) { #if defined(NRF52_PLATFORM) || defined(STM32_PLATFORM) @@ -111,13 +112,87 @@ ClientInfo* ClientACL::putClient(const mesh::Identity& id, uint8_t init_perms) { } else { c = oldest; // evict least active contact } + int idx = c - clients; memset(c, 0, sizeof(*c)); c->permissions = init_perms; c->id = id; c->out_path_len = OUT_PATH_UNKNOWN; + if (_rng) { + _rng->random((uint8_t*)&c->aead_nonce, sizeof(c->aead_nonce)); + if (c->aead_nonce == 0) c->aead_nonce = 1; + } + nonce_at_last_persist[idx] = c->aead_nonce; return c; } +uint16_t ClientACL::nextAeadNonceFor(const ClientInfo& client) { + uint16_t nonce = client.nextAeadNonce(); + if (nonce != 0) { + int idx = &client - clients; + if (idx >= 0 && idx < num_clients && + (uint16_t)(client.aead_nonce - nonce_at_last_persist[idx]) >= NONCE_PERSIST_INTERVAL) { + nonce_dirty = true; + } + } + return nonce; +} + +void ClientACL::loadNonces() { + if (!_fs) return; +#if defined(RP2040_PLATFORM) + File file = _fs->open("/s_nonces", "r"); +#elif defined(NRF52_PLATFORM) || defined(STM32_PLATFORM) + File file = _fs->open("/s_nonces", FILE_O_READ); +#else + File file = _fs->open("/s_nonces", "r", false); +#endif + if (file) { + uint8_t rec[6]; // 4-byte pub_key prefix + 2-byte nonce + while (file.read(rec, 6) == 6) { + uint16_t nonce; + memcpy(&nonce, &rec[4], 2); + for (int i = 0; i < num_clients; i++) { + if (memcmp(clients[i].id.pub_key, rec, 4) == 0) { + clients[i].aead_nonce = nonce; + break; + } + } + } + file.close(); + } +} + +void ClientACL::saveNonces() { + if (!_fs) return; + File file = openWrite(_fs, "/s_nonces"); + if (file) { + for (int i = 0; i < num_clients; i++) { + file.write(clients[i].id.pub_key, 4); + file.write((uint8_t*)&clients[i].aead_nonce, 2); + nonce_at_last_persist[i] = clients[i].aead_nonce; + } + file.close(); + nonce_dirty = false; + } +} + +void ClientACL::finalizeNonceLoad(bool needs_bump) { + for (int i = 0; i < num_clients; i++) { + if (needs_bump) { + uint16_t old = clients[i].aead_nonce; + clients[i].aead_nonce += NONCE_BOOT_BUMP; + if (clients[i].aead_nonce == 0) clients[i].aead_nonce = 1; + if (clients[i].aead_nonce < old) { + MESH_DEBUG_PRINTLN("AEAD nonce wrapped after boot bump for client: %02x%02x%02x%02x", + clients[i].id.pub_key[0], clients[i].id.pub_key[1], + clients[i].id.pub_key[2], clients[i].id.pub_key[3]); + } + } + nonce_at_last_persist[i] = clients[i].aead_nonce; + } + nonce_dirty = false; +} + bool ClientACL::applyPermissions(const mesh::LocalIdentity& self_id, const uint8_t* pubkey, int key_len, uint8_t perms) { ClientInfo* c; if ((perms & PERM_ACL_ROLE_MASK) == PERM_ACL_GUEST) { // guest role is not persisted in contacts @@ -128,6 +203,7 @@ bool ClientACL::applyPermissions(const mesh::LocalIdentity& self_id, const uint8 int i = c - clients; while (i < num_clients) { clients[i] = clients[i + 1]; + nonce_at_last_persist[i] = nonce_at_last_persist[i + 1]; i++; } } else { diff --git a/src/helpers/ClientACL.h b/src/helpers/ClientACL.h index 7e65c2ca3..31788d272 100644 --- a/src/helpers/ClientACL.h +++ b/src/helpers/ClientACL.h @@ -31,7 +31,7 @@ struct ClientInfo { uint8_t push_failures; } room; } extra; - + uint16_t nextAeadNonce() const { if (flags & CONTACT_FLAG_AEAD) { if (++aead_nonce == 0) ++aead_nonce; // skip 0 (means ECB) @@ -51,10 +51,18 @@ class ClientACL { ClientInfo clients[MAX_CLIENTS]; int num_clients; + // Nonce persistence state (parallel to clients[]) + uint16_t nonce_at_last_persist[MAX_CLIENTS]; + bool nonce_dirty; + mesh::RNG* _rng; + public: - ClientACL() { + ClientACL() { memset(clients, 0, sizeof(clients)); + memset(nonce_at_last_persist, 0, sizeof(nonce_at_last_persist)); num_clients = 0; + nonce_dirty = false; + _rng = NULL; } void load(FILESYSTEM* _fs, const mesh::LocalIdentity& self_id); void save(FILESYSTEM* _fs, bool (*filter)(ClientInfo*)=NULL); @@ -66,4 +74,16 @@ class ClientACL { int getNumClients() const { return num_clients; } ClientInfo* getClientByIdx(int idx) { return &clients[idx]; } + + // AEAD nonce persistence + void setRNG(mesh::RNG* rng) { _rng = rng; } + uint16_t nextAeadNonceFor(const ClientInfo& client); + void loadNonces(); + void saveNonces(); + void finalizeNonceLoad(bool needs_bump); + bool isNonceDirty() const { return nonce_dirty; } + void clearNonceDirty() { + for (int i = 0; i < num_clients; i++) nonce_at_last_persist[i] = clients[i].aead_nonce; + nonce_dirty = false; + } }; diff --git a/src/helpers/CommonCLI.cpp b/src/helpers/CommonCLI.cpp index c1f86423b..41fa8e13d 100644 --- a/src/helpers/CommonCLI.cpp +++ b/src/helpers/CommonCLI.cpp @@ -201,10 +201,12 @@ uint8_t CommonCLI::buildAdvertData(uint8_t node_type, uint8_t* app_data) { void CommonCLI::handleCommand(uint32_t sender_timestamp, const char* command, char* reply) { if (memcmp(command, "reboot", 6) == 0) { + _callbacks->onBeforeReboot(); _board->reboot(); // doesn't return } else if (memcmp(command, "clkreboot", 9) == 0) { // Reset clock getRTCClock()->setCurrentTime(1715770351); // 15 May 2024, 8:50pm + _callbacks->onBeforeReboot(); _board->reboot(); // doesn't return } else if (memcmp(command, "advert", 6) == 0) { // send flood advert diff --git a/src/helpers/CommonCLI.h b/src/helpers/CommonCLI.h index 1e454ec29..7116dc30b 100644 --- a/src/helpers/CommonCLI.h +++ b/src/helpers/CommonCLI.h @@ -88,6 +88,10 @@ class CommonCLICallbacks { virtual void restartBridge() { // no op by default }; + + virtual void onBeforeReboot() { + // no op by default — override to flush nonces, etc. + }; }; class CommonCLI { From 2a8df870ee5cec7e4570be5fa39ec753c5432972 Mon Sep 17 00:00:00 2001 From: Wessel Nieboer Date: Sat, 14 Feb 2026 01:11:14 +0100 Subject: [PATCH 19/56] Implement session keys using --- examples/companion_radio/DataStore.cpp | 36 +++ examples/companion_radio/DataStore.h | 6 + examples/companion_radio/MyMesh.cpp | 22 +- examples/companion_radio/MyMesh.h | 10 + examples/simple_repeater/MyMesh.cpp | 65 ++-- examples/simple_repeater/MyMesh.h | 5 + examples/simple_room_server/MyMesh.cpp | 65 ++-- examples/simple_room_server/MyMesh.h | 5 + examples/simple_sensor/SensorMesh.cpp | 69 ++-- examples/simple_sensor/SensorMesh.h | 5 + src/Mesh.cpp | 44 ++- src/Mesh.h | 8 + src/MeshCore.h | 17 +- src/helpers/BaseChatMesh.cpp | 426 +++++++++++++++++++++++-- src/helpers/BaseChatMesh.h | 32 ++ src/helpers/ClientACL.cpp | 216 ++++++++++++- src/helpers/ClientACL.h | 30 ++ src/helpers/CommonCLI.cpp | 2 + src/helpers/ContactInfo.h | 4 + src/helpers/SessionKeyPool.h | 117 +++++++ 20 files changed, 1094 insertions(+), 90 deletions(-) create mode 100644 src/helpers/SessionKeyPool.h diff --git a/examples/companion_radio/DataStore.cpp b/examples/companion_radio/DataStore.cpp index 8b3ab0648..733d3ea4b 100644 --- a/examples/companion_radio/DataStore.cpp +++ b/examples/companion_radio/DataStore.cpp @@ -405,6 +405,42 @@ bool DataStore::saveNonces(DataStoreHost* host) { return false; } +void DataStore::loadSessionKeys(DataStoreHost* host) { + File file = openRead(_getContactsChannelsFS(), "/sess_keys"); + if (file) { + uint8_t rec[71]; // 4-byte pub_key prefix + 1 flags + 2 nonce + 32 session_key + 32 prev_session_key + while (file.read(rec, 71) == 71) { + uint16_t nonce; + memcpy(&nonce, &rec[5], 2); + host->onSessionKeyLoaded(rec, rec[4], nonce, &rec[7], &rec[39]); + } + file.close(); + } +} + +bool DataStore::saveSessionKeys(DataStoreHost* host) { + File file = openWrite(_getContactsChannelsFS(), "/sess_keys"); + if (file) { + uint8_t pub_key_prefix[4]; + uint8_t flags; + uint16_t nonce; + uint8_t session_key[32]; + uint8_t prev_session_key[32]; + for (int idx = 0; idx < MAX_SESSION_KEYS; idx++) { + if (host->getSessionKeyForSave(idx, pub_key_prefix, &flags, &nonce, session_key, prev_session_key)) { + file.write(pub_key_prefix, 4); + file.write(&flags, 1); + file.write((uint8_t*)&nonce, 2); + file.write(session_key, 32); + file.write(prev_session_key, 32); + } + } + file.close(); + return true; + } + return false; +} + #if defined(NRF52_PLATFORM) || defined(STM32_PLATFORM) #define MAX_ADVERT_PKT_LEN (2 + 32 + PUB_KEY_SIZE + 4 + SIGNATURE_SIZE + MAX_ADVERT_DATA_SIZE) diff --git a/examples/companion_radio/DataStore.h b/examples/companion_radio/DataStore.h index d2ec9167e..5f4170530 100644 --- a/examples/companion_radio/DataStore.h +++ b/examples/companion_radio/DataStore.h @@ -13,6 +13,10 @@ class DataStoreHost { virtual bool getChannelForSave(uint8_t channel_idx, ChannelDetails& ch) =0; virtual bool onNonceLoaded(const uint8_t* pub_key_prefix, uint16_t nonce) { return false; } virtual bool getNonceForSave(int idx, uint8_t* pub_key_prefix, uint16_t* nonce) { return false; } + virtual bool onSessionKeyLoaded(const uint8_t* pub_key_prefix, uint8_t flags, uint16_t nonce, + const uint8_t* session_key, const uint8_t* prev_session_key) { return false; } + virtual bool getSessionKeyForSave(int idx, uint8_t* pub_key_prefix, uint8_t* flags, uint16_t* nonce, + uint8_t* session_key, uint8_t* prev_session_key) { return false; } }; class DataStore { @@ -43,6 +47,8 @@ class DataStore { void saveChannels(DataStoreHost* host); void loadNonces(DataStoreHost* host); bool saveNonces(DataStoreHost* host); + void loadSessionKeys(DataStoreHost* host); + bool saveSessionKeys(DataStoreHost* host); void migrateToSecondaryFS(); uint8_t getBlobByKey(const uint8_t key[], int key_len, uint8_t dest_buf[]); bool putBlobByKey(const uint8_t key[], int key_len, const uint8_t src_buf[], uint8_t len); diff --git a/examples/companion_radio/MyMesh.cpp b/examples/companion_radio/MyMesh.cpp index 904a2402f..5eabec9ed 100644 --- a/examples/companion_radio/MyMesh.cpp +++ b/examples/companion_radio/MyMesh.cpp @@ -887,6 +887,8 @@ void MyMesh::begin(bool has_display) { if (dirty_reset) saveNonces(); // persist bumped nonces immediately next_nonce_persist = futureMillis(60000); + _store->loadSessionKeys(this); + addChannel("Public", PUBLIC_GROUP_PSK); // pre-configure Andy's public channel _store->loadChannels(this); @@ -1335,6 +1337,7 @@ void MyMesh::handleCmdFrame(size_t len) { saveContacts(); } if (isNonceDirty()) saveNonces(); + saveSessionKeys(); board.reboot(); } else if (cmd_frame[0] == CMD_GET_BATT_AND_STORAGE) { uint8_t reply[11]; @@ -1985,8 +1988,22 @@ void MyMesh::checkCLIRescueCmd() { } + } else if (memcmp(cli_command, "rekey ", 6) == 0) { + const char* name_prefix = &cli_command[6]; + ContactInfo* c = searchContactsByPrefix(name_prefix); + if (c) { + if (initiateSessionKeyNegotiation(*c)) { + Serial.print(" Session key negotiation started with: "); + Serial.println(c->name); + } else { + Serial.println(" Error: failed to initiate (no AEAD or pool full)"); + } + } else { + Serial.println(" Error: contact not found"); + } } else if (strcmp(cli_command, "reboot") == 0) { if (isNonceDirty()) saveNonces(); + saveSessionKeys(); board.reboot(); // doesn't return } else { Serial.println(" Error: unknown command"); @@ -2038,11 +2055,14 @@ void MyMesh::loop() { dirty_contacts_expiry = 0; } - // periodic AEAD nonce persistence + // periodic AEAD nonce and session key persistence if (next_nonce_persist && millisHasNowPassed(next_nonce_persist)) { if (isNonceDirty()) { saveNonces(); } + if (isSessionKeysDirty()) { + saveSessionKeys(); + } next_nonce_persist = futureMillis(60000); } diff --git a/examples/companion_radio/MyMesh.h b/examples/companion_radio/MyMesh.h index 49ef27183..2f38277bd 100644 --- a/examples/companion_radio/MyMesh.h +++ b/examples/companion_radio/MyMesh.h @@ -156,6 +156,14 @@ class MyMesh : public BaseChatMesh, public DataStoreHost { bool getChannelForSave(uint8_t channel_idx, ChannelDetails& ch) override { return getChannel(channel_idx, ch); } bool onNonceLoaded(const uint8_t* pub_key_prefix, uint16_t nonce) override { return applyLoadedNonce(pub_key_prefix, nonce); } bool getNonceForSave(int idx, uint8_t* pub_key_prefix, uint16_t* nonce) override { return getNonceEntry(idx, pub_key_prefix, nonce); } + bool onSessionKeyLoaded(const uint8_t* pub_key_prefix, uint8_t flags, uint16_t nonce, + const uint8_t* session_key, const uint8_t* prev_session_key) override { + return applyLoadedSessionKey(pub_key_prefix, flags, nonce, session_key, prev_session_key); + } + bool getSessionKeyForSave(int idx, uint8_t* pub_key_prefix, uint8_t* flags, uint16_t* nonce, + uint8_t* session_key, uint8_t* prev_session_key) override { + return getSessionKeyEntry(idx, pub_key_prefix, flags, nonce, session_key, prev_session_key); + } void clearPendingReqs() { pending_login = pending_status = pending_telemetry = pending_discovery = pending_req = 0; @@ -187,6 +195,8 @@ class MyMesh : public BaseChatMesh, public DataStoreHost { void saveChannels() { _store->saveChannels(this); } void saveContacts() { _store->saveContacts(this); } void saveNonces() { if (_store->saveNonces(this)) clearNonceDirty(); } + void saveSessionKeys() { if (_store->saveSessionKeys(this)) clearSessionKeysDirty(); } + void onSessionKeysUpdated() override { saveSessionKeys(); } DataStore* _store; NodePrefs _prefs; diff --git a/examples/simple_repeater/MyMesh.cpp b/examples/simple_repeater/MyMesh.cpp index 6ef46d0ae..18f8998fa 100644 --- a/examples/simple_repeater/MyMesh.cpp +++ b/examples/simple_repeater/MyMesh.cpp @@ -587,26 +587,36 @@ uint8_t MyMesh::getPeerFlags(int peer_idx) { } uint16_t MyMesh::getPeerNextAeadNonce(int peer_idx) { - int i = matching_peer_indexes[peer_idx]; - if (i >= 0 && i < acl.getNumClients()) - return acl.nextAeadNonceFor(*acl.getClientByIdx(i)); - return 0; + return acl.peerNextAeadNonce(peer_idx, matching_peer_indexes); } void MyMesh::onPeerAeadDetected(int peer_idx) { - int i = matching_peer_indexes[peer_idx]; - if (i >= 0 && i < acl.getNumClients()) { - auto c = acl.getClientByIdx(i); - if (!(c->flags & CONTACT_FLAG_AEAD)) { - c->flags |= CONTACT_FLAG_AEAD; - if (c->aead_nonce == 0) { // no persisted nonce — seed from RNG to avoid deterministic start - getRNG()->random((uint8_t*)&c->aead_nonce, sizeof(c->aead_nonce)); - if (c->aead_nonce == 0) c->aead_nonce = 1; - } + auto* c = acl.resolveClient(peer_idx, matching_peer_indexes); + if (c && !(c->flags & CONTACT_FLAG_AEAD)) { + c->flags |= CONTACT_FLAG_AEAD; + if (c->aead_nonce == 0) { // no persisted nonce — seed from RNG to avoid deterministic start + getRNG()->random((uint8_t*)&c->aead_nonce, sizeof(c->aead_nonce)); + if (c->aead_nonce == 0) c->aead_nonce = 1; } } } +const uint8_t* MyMesh::getPeerSessionKey(int peer_idx) { + return acl.peerSessionKey(peer_idx, matching_peer_indexes); +} +const uint8_t* MyMesh::getPeerPrevSessionKey(int peer_idx) { + return acl.peerPrevSessionKey(peer_idx, matching_peer_indexes); +} +void MyMesh::onSessionKeyDecryptSuccess(int peer_idx) { + acl.peerSessionKeyDecryptSuccess(peer_idx, matching_peer_indexes); +} +const uint8_t* MyMesh::getPeerEncryptionKey(int peer_idx, const uint8_t* static_secret) { + return acl.peerEncryptionKey(peer_idx, matching_peer_indexes, static_secret); +} +uint16_t MyMesh::getPeerEncryptionNonce(int peer_idx) { + return acl.peerEncryptionNonce(peer_idx, matching_peer_indexes); +} + static bool isShare(const mesh::Packet *packet) { if (packet->hasTransportCodes()) { return packet->transport_codes[0] == 0 && packet->transport_codes[1] == 0; // codes { 0, 0 } means 'send to nowhere' @@ -641,20 +651,37 @@ void MyMesh::onPeerDataRecv(mesh::Packet *packet, uint8_t type, int sender_idx, memcpy(×tamp, data, 4); if (timestamp > client->last_timestamp) { // prevent replay attacks - int reply_len = handleRequest(client, timestamp, &data[4], len - 4); + int reply_len; + bool use_static_secret = false; + + // Intercept session key INIT before handleRequest + if (data[4] == REQ_TYPE_SESSION_KEY_INIT && len >= 37) { + memcpy(reply_data, ×tamp, 4); + reply_data[4] = RESP_TYPE_SESSION_KEY_ACCEPT; + int n = acl.handleSessionKeyInit(client, &data[5], &reply_data[5], getRNG()); + reply_len = (n > 0) ? 5 + n : 0; + use_static_secret = true; // ACCEPT must use static secret (initiator doesn't have session key yet) + } else { + reply_len = handleRequest(client, timestamp, &data[4], len - 4); + } if (reply_len == 0) return; // invalid command client->last_timestamp = timestamp; client->last_activity = getRTCClock()->getCurrentTime(); + // Session key ACCEPT must be encrypted with static ECDH secret + static nonce, + // because the initiator hasn't derived the session key yet. + const uint8_t* enc_key = use_static_secret ? secret : acl.getEncryptionKey(*client); + uint16_t enc_nonce = use_static_secret ? acl.nextAeadNonceFor(*client) : acl.getEncryptionNonce(*client); + if (packet->isRouteFlood()) { // let this sender know path TO here, so they can use sendDirect(), and ALSO encode the response - mesh::Packet *path = createPathReturn(client->id, secret, packet->path, packet->path_len, - PAYLOAD_TYPE_RESPONSE, reply_data, reply_len, acl.nextAeadNonceFor(*client)); + mesh::Packet *path = createPathReturn(client->id, enc_key, packet->path, packet->path_len, + PAYLOAD_TYPE_RESPONSE, reply_data, reply_len, enc_nonce); if (path) sendFlood(path, SERVER_RESPONSE_DELAY, packet->getPathHashSize()); } else { mesh::Packet *reply = - createDatagram(PAYLOAD_TYPE_RESPONSE, client->id, secret, reply_data, reply_len, acl.nextAeadNonceFor(*client)); + createDatagram(PAYLOAD_TYPE_RESPONSE, client->id, enc_key, reply_data, reply_len, enc_nonce); if (reply) { if (client->out_path_len != OUT_PATH_UNKNOWN) { // we have an out_path, so send DIRECT sendDirect(reply, client->out_path, client->out_path_len, SERVER_RESPONSE_DELAY); @@ -715,7 +742,7 @@ void MyMesh::onPeerDataRecv(mesh::Packet *packet, uint8_t type, int sender_idx, memcpy(temp, ×tamp, 4); // mostly an extra blob to help make packet_hash unique temp[4] = (TXT_TYPE_CLI_DATA << 2); // NOTE: legacy was: TXT_TYPE_PLAIN - auto reply = createDatagram(PAYLOAD_TYPE_TXT_MSG, client->id, secret, temp, 5 + text_len, acl.nextAeadNonceFor(*client)); + auto reply = createDatagram(PAYLOAD_TYPE_TXT_MSG, client->id, acl.getEncryptionKey(*client), temp, 5 + text_len, acl.getEncryptionNonce(*client)); if (reply) { if (client->out_path_len == OUT_PATH_UNKNOWN) { sendFlood(reply, CLI_REPLY_DELAY_MILLIS, packet->getPathHashSize()); @@ -899,6 +926,7 @@ void MyMesh::begin(FILESYSTEM *fs) { acl.load(_fs, self_id); acl.setRNG(getRNG()); acl.loadNonces(); + acl.loadSessionKeys(); bool dirty_reset = wasDirtyReset(board); acl.finalizeNonceLoad(dirty_reset); if (dirty_reset) acl.saveNonces(); // persist bumped nonces immediately @@ -1317,6 +1345,7 @@ void MyMesh::loop() { // persist dirty AEAD nonces if (next_nonce_persist && millisHasNowPassed(next_nonce_persist)) { if (acl.isNonceDirty()) { acl.saveNonces(); } + if (acl.isSessionKeysDirty()) { acl.saveSessionKeys(); } next_nonce_persist = futureMillis(60000); } diff --git a/examples/simple_repeater/MyMesh.h b/examples/simple_repeater/MyMesh.h index 0a72103d8..7a53e90d3 100644 --- a/examples/simple_repeater/MyMesh.h +++ b/examples/simple_repeater/MyMesh.h @@ -171,6 +171,11 @@ class MyMesh : public mesh::Mesh, public CommonCLICallbacks { uint8_t getPeerFlags(int peer_idx) override; uint16_t getPeerNextAeadNonce(int peer_idx) override; void onPeerAeadDetected(int peer_idx) override; + const uint8_t* getPeerSessionKey(int peer_idx) override; + const uint8_t* getPeerPrevSessionKey(int peer_idx) override; + void onSessionKeyDecryptSuccess(int peer_idx) override; + const uint8_t* getPeerEncryptionKey(int peer_idx, const uint8_t* static_secret) override; + uint16_t getPeerEncryptionNonce(int peer_idx) override; void onAdvertRecv(mesh::Packet* packet, const mesh::Identity& id, uint32_t timestamp, const uint8_t* app_data, size_t app_data_len); 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; diff --git a/examples/simple_room_server/MyMesh.cpp b/examples/simple_room_server/MyMesh.cpp index d053964b1..aaffd71f4 100644 --- a/examples/simple_room_server/MyMesh.cpp +++ b/examples/simple_room_server/MyMesh.cpp @@ -71,7 +71,7 @@ void MyMesh::pushPostToClient(ClientInfo *client, PostInfo &post) { mesh::Utils::sha256((uint8_t *)&client->extra.room.pending_ack, 4, reply_data, len, client->id.pub_key, PUB_KEY_SIZE); client->extra.room.push_post_timestamp = post.post_timestamp; - auto reply = createDatagram(PAYLOAD_TYPE_TXT_MSG, client->id, client->shared_secret, reply_data, len, acl.nextAeadNonceFor(*client)); + auto reply = createDatagram(PAYLOAD_TYPE_TXT_MSG, client->id, acl.getEncryptionKey(*client), reply_data, len, acl.getEncryptionNonce(*client)); if (reply) { if (client->out_path_len == OUT_PATH_UNKNOWN) { unsigned long delay_millis = 0; @@ -397,26 +397,36 @@ uint8_t MyMesh::getPeerFlags(int peer_idx) { } uint16_t MyMesh::getPeerNextAeadNonce(int peer_idx) { - int i = matching_peer_indexes[peer_idx]; - if (i >= 0 && i < acl.getNumClients()) - return acl.nextAeadNonceFor(*acl.getClientByIdx(i)); - return 0; + return acl.peerNextAeadNonce(peer_idx, matching_peer_indexes); } void MyMesh::onPeerAeadDetected(int peer_idx) { - int i = matching_peer_indexes[peer_idx]; - if (i >= 0 && i < acl.getNumClients()) { - auto c = acl.getClientByIdx(i); - if (!(c->flags & CONTACT_FLAG_AEAD)) { - c->flags |= CONTACT_FLAG_AEAD; - if (c->aead_nonce == 0) { // no persisted nonce — seed from RNG to avoid deterministic start - getRNG()->random((uint8_t*)&c->aead_nonce, sizeof(c->aead_nonce)); - if (c->aead_nonce == 0) c->aead_nonce = 1; - } + auto* c = acl.resolveClient(peer_idx, matching_peer_indexes); + if (c && !(c->flags & CONTACT_FLAG_AEAD)) { + c->flags |= CONTACT_FLAG_AEAD; + if (c->aead_nonce == 0) { // no persisted nonce — seed from RNG to avoid deterministic start + getRNG()->random((uint8_t*)&c->aead_nonce, sizeof(c->aead_nonce)); + if (c->aead_nonce == 0) c->aead_nonce = 1; } } } +const uint8_t* MyMesh::getPeerSessionKey(int peer_idx) { + return acl.peerSessionKey(peer_idx, matching_peer_indexes); +} +const uint8_t* MyMesh::getPeerPrevSessionKey(int peer_idx) { + return acl.peerPrevSessionKey(peer_idx, matching_peer_indexes); +} +void MyMesh::onSessionKeyDecryptSuccess(int peer_idx) { + acl.peerSessionKeyDecryptSuccess(peer_idx, matching_peer_indexes); +} +const uint8_t* MyMesh::getPeerEncryptionKey(int peer_idx, const uint8_t* static_secret) { + return acl.peerEncryptionKey(peer_idx, matching_peer_indexes, static_secret); +} +uint16_t MyMesh::getPeerEncryptionNonce(int peer_idx) { + return acl.peerEncryptionNonce(peer_idx, matching_peer_indexes); +} + void MyMesh::onPeerDataRecv(mesh::Packet *packet, uint8_t type, int sender_idx, const uint8_t *secret, uint8_t *data, size_t len) { int i = matching_peer_indexes[sender_idx]; @@ -510,7 +520,7 @@ void MyMesh::onPeerDataRecv(mesh::Packet *packet, uint8_t type, int sender_idx, // mesh::Utils::sha256((uint8_t *)&expected_ack_crc, 4, temp, 5 + text_len, self_id.pub_key, // PUB_KEY_SIZE); - auto reply = createDatagram(PAYLOAD_TYPE_TXT_MSG, client->id, secret, temp, 5 + text_len, acl.nextAeadNonceFor(*client)); + auto reply = createDatagram(PAYLOAD_TYPE_TXT_MSG, client->id, acl.getEncryptionKey(*client), temp, 5 + text_len, acl.getEncryptionNonce(*client)); if (reply) { if (client->out_path_len == OUT_PATH_UNKNOWN) { sendFlood(reply, delay_millis + SERVER_RESPONSE_DELAY, packet->getPathHashSize()); @@ -562,15 +572,30 @@ void MyMesh::onPeerDataRecv(mesh::Packet *packet, uint8_t type, int sender_idx, } } } else { - int reply_len = handleRequest(client, sender_timestamp, &data[4], len - 4); + int reply_len; + bool use_static_secret = false; + + // Intercept session key INIT before handleRequest + if (data[4] == REQ_TYPE_SESSION_KEY_INIT && len >= 37) { + memcpy(reply_data, &sender_timestamp, 4); + reply_data[4] = RESP_TYPE_SESSION_KEY_ACCEPT; + int n = acl.handleSessionKeyInit(client, &data[5], &reply_data[5], getRNG()); + reply_len = (n > 0) ? 5 + n : 0; + use_static_secret = true; // ACCEPT must use static secret (initiator doesn't have session key yet) + } else { + reply_len = handleRequest(client, sender_timestamp, &data[4], len - 4); + } if (reply_len > 0) { // valid command + const uint8_t* enc_key = use_static_secret ? secret : acl.getEncryptionKey(*client); + uint16_t enc_nonce = use_static_secret ? acl.nextAeadNonceFor(*client) : acl.getEncryptionNonce(*client); + if (packet->isRouteFlood()) { // let this sender know path TO here, so they can use sendDirect(), and ALSO encode the response - mesh::Packet *path = createPathReturn(client->id, secret, packet->path, packet->path_len, - PAYLOAD_TYPE_RESPONSE, reply_data, reply_len, acl.nextAeadNonceFor(*client)); + mesh::Packet *path = createPathReturn(client->id, enc_key, packet->path, packet->path_len, + PAYLOAD_TYPE_RESPONSE, reply_data, reply_len, enc_nonce); if (path) sendFlood(path, SERVER_RESPONSE_DELAY, packet->getPathHashSize()); } else { - mesh::Packet *reply = createDatagram(PAYLOAD_TYPE_RESPONSE, client->id, secret, reply_data, reply_len, acl.nextAeadNonceFor(*client)); + mesh::Packet *reply = createDatagram(PAYLOAD_TYPE_RESPONSE, client->id, enc_key, reply_data, reply_len, enc_nonce); if (reply) { if (client->out_path_len != OUT_PATH_UNKNOWN) { // we have an out_path, so send DIRECT sendDirect(reply, client->out_path, client->out_path_len, SERVER_RESPONSE_DELAY); @@ -671,6 +696,7 @@ void MyMesh::begin(FILESYSTEM *fs) { acl.load(_fs, self_id); acl.setRNG(getRNG()); acl.loadNonces(); + acl.loadSessionKeys(); bool dirty_reset = wasDirtyReset(board); acl.finalizeNonceLoad(dirty_reset); if (dirty_reset) acl.saveNonces(); // persist bumped nonces immediately @@ -925,6 +951,7 @@ void MyMesh::loop() { // persist dirty AEAD nonces if (next_nonce_persist && millisHasNowPassed(next_nonce_persist)) { if (acl.isNonceDirty()) { acl.saveNonces(); } + if (acl.isSessionKeysDirty()) { acl.saveSessionKeys(); } next_nonce_persist = futureMillis(60000); } diff --git a/examples/simple_room_server/MyMesh.h b/examples/simple_room_server/MyMesh.h index c2d646548..24be6eea1 100644 --- a/examples/simple_room_server/MyMesh.h +++ b/examples/simple_room_server/MyMesh.h @@ -152,6 +152,11 @@ class MyMesh : public mesh::Mesh, public CommonCLICallbacks { uint8_t getPeerFlags(int peer_idx) override; uint16_t getPeerNextAeadNonce(int peer_idx) override; void onPeerAeadDetected(int peer_idx) override; + const uint8_t* getPeerSessionKey(int peer_idx) override; + const uint8_t* getPeerPrevSessionKey(int peer_idx) override; + void onSessionKeyDecryptSuccess(int peer_idx) override; + const uint8_t* getPeerEncryptionKey(int peer_idx, const uint8_t* static_secret) override; + uint16_t getPeerEncryptionNonce(int peer_idx) override; 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 onAckRecv(mesh::Packet* packet, uint32_t ack_crc) override; diff --git a/examples/simple_sensor/SensorMesh.cpp b/examples/simple_sensor/SensorMesh.cpp index 96681dc89..e1f2f677a 100644 --- a/examples/simple_sensor/SensorMesh.cpp +++ b/examples/simple_sensor/SensorMesh.cpp @@ -256,7 +256,7 @@ void SensorMesh::sendAlert(const ClientInfo* c, Trigger* t) { mesh::Utils::sha256((uint8_t *)&t->expected_acks[t->attempt], 4, data, 5 + text_len, self_id.pub_key, PUB_KEY_SIZE); t->attempt++; - auto pkt = createDatagram(PAYLOAD_TYPE_TXT_MSG, c->id, c->shared_secret, data, 5 + text_len, acl.nextAeadNonceFor(*c)); + auto pkt = createDatagram(PAYLOAD_TYPE_TXT_MSG, c->id, acl.getEncryptionKey(*c), data, 5 + text_len, acl.getEncryptionNonce(*c)); if (pkt) { if (c->out_path_len != OUT_PATH_UNKNOWN) { // we have an out_path, so send DIRECT sendDirect(pkt, c->out_path, c->out_path_len); @@ -505,26 +505,36 @@ uint8_t SensorMesh::getPeerFlags(int peer_idx) { } uint16_t SensorMesh::getPeerNextAeadNonce(int peer_idx) { - int i = matching_peer_indexes[peer_idx]; - if (i >= 0 && i < acl.getNumClients()) - return acl.nextAeadNonceFor(*acl.getClientByIdx(i)); - return 0; + return acl.peerNextAeadNonce(peer_idx, matching_peer_indexes); } void SensorMesh::onPeerAeadDetected(int peer_idx) { - int i = matching_peer_indexes[peer_idx]; - if (i >= 0 && i < acl.getNumClients()) { - auto c = acl.getClientByIdx(i); - if (!(c->flags & CONTACT_FLAG_AEAD)) { - c->flags |= CONTACT_FLAG_AEAD; - if (c->aead_nonce == 0) { // no persisted nonce — seed from RNG to avoid deterministic start - getRNG()->random((uint8_t*)&c->aead_nonce, sizeof(c->aead_nonce)); - if (c->aead_nonce == 0) c->aead_nonce = 1; - } + auto* c = acl.resolveClient(peer_idx, matching_peer_indexes); + if (c && !(c->flags & CONTACT_FLAG_AEAD)) { + c->flags |= CONTACT_FLAG_AEAD; + if (c->aead_nonce == 0) { // no persisted nonce — seed from RNG to avoid deterministic start + getRNG()->random((uint8_t*)&c->aead_nonce, sizeof(c->aead_nonce)); + if (c->aead_nonce == 0) c->aead_nonce = 1; } } } +const uint8_t* SensorMesh::getPeerSessionKey(int peer_idx) { + return acl.peerSessionKey(peer_idx, matching_peer_indexes); +} +const uint8_t* SensorMesh::getPeerPrevSessionKey(int peer_idx) { + return acl.peerPrevSessionKey(peer_idx, matching_peer_indexes); +} +void SensorMesh::onSessionKeyDecryptSuccess(int peer_idx) { + acl.peerSessionKeyDecryptSuccess(peer_idx, matching_peer_indexes); +} +const uint8_t* SensorMesh::getPeerEncryptionKey(int peer_idx, const uint8_t* static_secret) { + return acl.peerEncryptionKey(peer_idx, matching_peer_indexes, static_secret); +} +uint16_t SensorMesh::getPeerEncryptionNonce(int peer_idx) { + return acl.peerEncryptionNonce(peer_idx, matching_peer_indexes); +} + void SensorMesh::sendAckTo(const ClientInfo& dest, uint32_t ack_hash, uint8_t path_hash_size) { if (dest.out_path_len == OUT_PATH_UNKNOWN) { mesh::Packet* ack = createAck(ack_hash); @@ -556,19 +566,34 @@ void SensorMesh::onPeerDataRecv(mesh::Packet* packet, uint8_t type, int sender_i memcpy(×tamp, data, 4); if (timestamp > from->last_timestamp) { // prevent replay attacks - uint8_t reply_len = handleRequest(from->isAdmin() ? 0xFF : from->permissions, timestamp, data[4], &data[5], len - 5); + uint8_t reply_len; + bool use_static_secret = false; + if (data[4] == REQ_TYPE_SESSION_KEY_INIT && len >= 37) { // 4 (timestamp) + 1 (type) + 32 (ephemeral pub) + memcpy(reply_data, ×tamp, 4); + reply_data[4] = RESP_TYPE_SESSION_KEY_ACCEPT; + int n = acl.handleSessionKeyInit(from, &data[5], &reply_data[5], getRNG()); + reply_len = (n > 0) ? 5 + n : 0; + use_static_secret = true; // ACCEPT must use static secret (initiator doesn't have session key yet) + } else { + reply_len = handleRequest(from->isAdmin() ? 0xFF : from->permissions, timestamp, data[4], &data[5], len - 5); + } if (reply_len == 0) return; // invalid command from->last_timestamp = timestamp; from->last_activity = getRTCClock()->getCurrentTime(); + // Session key ACCEPT must be encrypted with static ECDH secret + static nonce, + // because the initiator hasn't derived the session key yet. + const uint8_t* enc_key = use_static_secret ? secret : acl.getEncryptionKey(*from); + uint16_t enc_nonce = use_static_secret ? acl.nextAeadNonceFor(*from) : acl.getEncryptionNonce(*from); + if (packet->isRouteFlood()) { // let this sender know path TO here, so they can use sendDirect(), and ALSO encode the response - mesh::Packet* path = createPathReturn(from->id, secret, packet->path, packet->path_len, - PAYLOAD_TYPE_RESPONSE, reply_data, reply_len, acl.nextAeadNonceFor(*from)); + mesh::Packet* path = createPathReturn(from->id, enc_key, packet->path, packet->path_len, + PAYLOAD_TYPE_RESPONSE, reply_data, reply_len, enc_nonce); if (path) sendFlood(path, SERVER_RESPONSE_DELAY, packet->getPathHashSize()); } else { - mesh::Packet* reply = createDatagram(PAYLOAD_TYPE_RESPONSE, from->id, secret, reply_data, reply_len, acl.nextAeadNonceFor(*from)); + mesh::Packet* reply = createDatagram(PAYLOAD_TYPE_RESPONSE, from->id, enc_key, reply_data, reply_len, enc_nonce); if (reply) { if (from->out_path_len != OUT_PATH_UNKNOWN) { // we have an out_path, so send DIRECT sendDirect(reply, from->out_path, from->out_path_len, SERVER_RESPONSE_DELAY); @@ -594,8 +619,8 @@ void SensorMesh::onPeerDataRecv(mesh::Packet* packet, uint8_t type, int sender_i if (packet->isRouteFlood()) { // let this sender know path TO here, so they can use sendDirect(), and ALSO encode the ACK - mesh::Packet* path = createPathReturn(from->id, secret, packet->path, packet->path_len, - PAYLOAD_TYPE_ACK, (uint8_t *) &ack_hash, 4, acl.nextAeadNonceFor(*from)); + mesh::Packet* path = createPathReturn(from->id, acl.getEncryptionKey(*from), packet->path, packet->path_len, + PAYLOAD_TYPE_ACK, (uint8_t *) &ack_hash, 4, acl.getEncryptionNonce(*from)); if (path) sendFlood(path, TXT_ACK_DELAY, packet->getPathHashSize()); } else { sendAckTo(*from, ack_hash, packet->getPathHashSize()); @@ -623,7 +648,7 @@ void SensorMesh::onPeerDataRecv(mesh::Packet* packet, uint8_t type, int sender_i memcpy(temp, ×tamp, 4); // mostly an extra blob to help make packet_hash unique temp[4] = (TXT_TYPE_CLI_DATA << 2); - auto reply = createDatagram(PAYLOAD_TYPE_TXT_MSG, from->id, secret, temp, 5 + text_len, acl.nextAeadNonceFor(*from)); + auto reply = createDatagram(PAYLOAD_TYPE_TXT_MSG, from->id, acl.getEncryptionKey(*from), temp, 5 + text_len, acl.getEncryptionNonce(*from)); if (reply) { if (from->out_path_len == OUT_PATH_UNKNOWN) { sendFlood(reply, CLI_REPLY_DELAY_MILLIS, packet->getPathHashSize()); @@ -769,6 +794,7 @@ void SensorMesh::begin(FILESYSTEM* fs) { acl.load(_fs, self_id); acl.setRNG(getRNG()); acl.loadNonces(); + acl.loadSessionKeys(); bool dirty_reset = wasDirtyReset(board); acl.finalizeNonceLoad(dirty_reset); if (dirty_reset) acl.saveNonces(); // persist bumped nonces immediately @@ -985,6 +1011,7 @@ void SensorMesh::loop() { // persist dirty AEAD nonces if (next_nonce_persist && millisHasNowPassed(next_nonce_persist)) { if (acl.isNonceDirty()) { acl.saveNonces(); } + if (acl.isSessionKeysDirty()) { acl.saveSessionKeys(); } next_nonce_persist = futureMillis(60000); } } diff --git a/examples/simple_sensor/SensorMesh.h b/examples/simple_sensor/SensorMesh.h index 37dcfe7c1..6cafa47ac 100644 --- a/examples/simple_sensor/SensorMesh.h +++ b/examples/simple_sensor/SensorMesh.h @@ -129,6 +129,11 @@ class SensorMesh : public mesh::Mesh, public CommonCLICallbacks { uint8_t getPeerFlags(int peer_idx) override; uint16_t getPeerNextAeadNonce(int peer_idx) override; void onPeerAeadDetected(int peer_idx) override; + const uint8_t* getPeerSessionKey(int peer_idx) override; + const uint8_t* getPeerPrevSessionKey(int peer_idx) override; + void onSessionKeyDecryptSuccess(int peer_idx) override; + const uint8_t* getPeerEncryptionKey(int peer_idx, const uint8_t* static_secret) override; + uint16_t getPeerEncryptionNonce(int peer_idx) override; 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; diff --git a/src/Mesh.cpp b/src/Mesh.cpp index d9ab479cf..6802cfe4a 100644 --- a/src/Mesh.cpp +++ b/src/Mesh.cpp @@ -149,17 +149,46 @@ DispatcherAction Mesh::onRecvPacket(Packet* pkt) { uint8_t data[MAX_PACKET_PAYLOAD]; int macAndDataLen = pkt->payload_len - i; - // Try-both decode: AEAD-first for peers known to support it (avoids 1/65536 - // ECB false-positive on AEAD packets), ECB-first for unknown/legacy peers. // Mask out route type bits — they are set after encryption and vary per hop. uint8_t assoc[3] = { (uint8_t)(pkt->header & ~PH_ROUTE_MASK), dest_hash, src_hash }; - int len; + int len = 0; bool decoded_aead = false; - if (getPeerFlags(j) & CONTACT_FLAG_AEAD) { + bool decoded_session = false; + + // Session key decode path: try session key(s) first if available + const uint8_t* sess_key = getPeerSessionKey(j); + if (sess_key) { + len = Utils::aeadDecrypt(sess_key, data, macAndData, macAndDataLen, assoc, 3, dest_hash, src_hash); + if (len > 0) { + decoded_session = true; + decoded_aead = true; + } else { + // Try prev_session_key (dual-decode window) + const uint8_t* prev_key = getPeerPrevSessionKey(j); + if (prev_key) { + len = Utils::aeadDecrypt(prev_key, data, macAndData, macAndDataLen, assoc, 3, dest_hash, src_hash); + if (len > 0) { + decoded_session = true; + decoded_aead = true; + } + } + } + if (!decoded_session) { + // Session key failed — try static ECDH, then ECB + len = Utils::aeadDecrypt(secret, data, macAndData, macAndDataLen, assoc, 3, dest_hash, src_hash); + if (len > 0) { + decoded_aead = true; + } else { + len = Utils::MACThenDecrypt(secret, data, macAndData, macAndDataLen); + } + } + } else if (getPeerFlags(j) & CONTACT_FLAG_AEAD) { + // No session key — standard AEAD-first decode for AEAD-capable peers len = Utils::aeadDecrypt(secret, data, macAndData, macAndDataLen, assoc, 3, dest_hash, src_hash); if (len > 0) decoded_aead = true; else len = Utils::MACThenDecrypt(secret, data, macAndData, macAndDataLen); } else { + // Legacy ECB-first decode len = Utils::MACThenDecrypt(secret, data, macAndData, macAndDataLen); if (len <= 0) { len = Utils::aeadDecrypt(secret, data, macAndData, macAndDataLen, assoc, 3, dest_hash, src_hash); @@ -167,7 +196,8 @@ DispatcherAction Mesh::onRecvPacket(Packet* pkt) { } } if (len > 0) { // success! - if (decoded_aead) onPeerAeadDetected(j); + if (decoded_session) onSessionKeyDecryptSuccess(j); + else if (decoded_aead) onPeerAeadDetected(j); if (pkt->getPayloadType() == PAYLOAD_TYPE_PATH) { int k = 0; uint8_t path_len = data[k++]; @@ -180,12 +210,12 @@ DispatcherAction Mesh::onRecvPacket(Packet* pkt) { if (onPeerPathRecv(pkt, j, secret, path, path_len, extra_type, extra, extra_len)) { if (pkt->isRouteFlood()) { // send a reciprocal return path to sender, but send DIRECTLY! - mesh::Packet* rpath = createPathReturn(&src_hash, secret, pkt->path, pkt->path_len, 0, NULL, 0, getPeerNextAeadNonce(j)); + mesh::Packet* rpath = createPathReturn(&src_hash, getPeerEncryptionKey(j, secret), pkt->path, pkt->path_len, 0, NULL, 0, getPeerEncryptionNonce(j)); if (rpath) sendDirect(rpath, path, path_len, 500); } } } else { - onPeerDataRecv(pkt, pkt->getPayloadType(), j, secret, data, len); + onPeerDataRecv(pkt, pkt->getPayloadType(), j, getPeerEncryptionKey(j, secret), data, len); } found = true; break; diff --git a/src/Mesh.h b/src/Mesh.h index 0176d8eff..01acb04e3 100644 --- a/src/Mesh.h +++ b/src/Mesh.h @@ -86,6 +86,14 @@ class Mesh : public Dispatcher { virtual uint16_t getPeerNextAeadNonce(int peer_idx) { return 0; } virtual void onPeerAeadDetected(int peer_idx) { } + // Session key support (Phase 2) + virtual const uint8_t* getPeerSessionKey(int peer_idx) { return NULL; } + virtual const uint8_t* getPeerPrevSessionKey(int peer_idx) { return NULL; } + virtual void onSessionKeyDecryptSuccess(int peer_idx) { } + // Encryption key/nonce for outgoing messages to peer (session key with static ECDH fallback) + virtual const uint8_t* getPeerEncryptionKey(int peer_idx, const uint8_t* static_secret) { return static_secret; } + virtual uint16_t getPeerEncryptionNonce(int peer_idx) { return getPeerNextAeadNonce(peer_idx); } + /** * \brief A (now decrypted) data packet has been received (by a known peer). * NOTE: these can be received multiple times (per sender/msg-id), via different routes diff --git a/src/MeshCore.h b/src/MeshCore.h index cc2a1440a..841a7ce72 100644 --- a/src/MeshCore.h +++ b/src/MeshCore.h @@ -9,6 +9,7 @@ #define SEED_SIZE 32 #define SIGNATURE_SIZE 64 #define MAX_ADVERT_DATA_SIZE 32 +#define SESSION_KEY_SIZE 32 #define CIPHER_KEY_SIZE 16 #define CIPHER_BLOCK_SIZE 16 @@ -24,7 +25,21 @@ // AEAD nonce persistence #define NONCE_PERSIST_INTERVAL 50 // persist every N messages per peer -#define NONCE_BOOT_BUMP 100 // add this on load after dirty boot (must be >= 2 * PERSIST_INTERVAL) +#define NONCE_BOOT_BUMP 50 // add this on load after dirty boot (must be >= PERSIST_INTERVAL) + +// Session key negotiation (Phase 2) +#define REQ_TYPE_SESSION_KEY_INIT 0x08 +#define RESP_TYPE_SESSION_KEY_ACCEPT 0x08 // response type byte in PAYLOAD_TYPE_RESPONSE + +#define NONCE_REKEY_THRESHOLD 60000 // start renegotiation when nonce exceeds this +#define NONCE_INITIAL_MIN 1000 // min random nonce seed for new contacts +#define NONCE_INITIAL_MAX 50000 // max random nonce seed for new contacts +#define SESSION_KEY_TIMEOUT_MS 180000 // 3 minutes per attempt +#define SESSION_KEY_MAX_RETRIES 3 // attempts per negotiation round +#define MAX_SESSION_KEYS 8 // max concurrent session key entries +#define SESSION_KEY_STALE_THRESHOLD 50 // sends without recv before fallback to static ECDH +#define SESSION_KEY_ECB_THRESHOLD 100 // sends without recv before fallback to ECB +#define SESSION_KEY_ABANDON_THRESHOLD 255 // sends without recv before clearing AEAD + session key #define MAX_PACKET_PAYLOAD 184 #define MAX_PATH_SIZE 64 diff --git a/src/helpers/BaseChatMesh.cpp b/src/helpers/BaseChatMesh.cpp index b1137e132..b5f88d54e 100644 --- a/src/helpers/BaseChatMesh.cpp +++ b/src/helpers/BaseChatMesh.cpp @@ -1,5 +1,7 @@ #include #include +#include +#include #ifndef SERVER_RESPONSE_DELAY #define SERVER_RESPONSE_DELAY 300 @@ -44,6 +46,20 @@ void BaseChatMesh::finalizeNonceLoad(bool needs_bump) { nonce_at_last_persist[i] = contacts[i].aead_nonce; } nonce_dirty = false; + + // Apply boot bump to session key nonces too + if (needs_bump) { + for (int i = 0; i < session_keys.getCount(); i++) { + auto entry = session_keys.getByIdx(i); + if (entry && (entry->state == SESSION_STATE_ACTIVE || entry->state == SESSION_STATE_DUAL_DECODE)) { + uint16_t old_nonce = entry->nonce; + entry->nonce += NONCE_BOOT_BUMP; + if (entry->nonce <= old_nonce) { + entry->nonce = 65535; // wrapped — force exhaustion so renegotiation happens + } + } + } + } } bool BaseChatMesh::getNonceEntry(int idx, uint8_t* pub_key_prefix, uint16_t* nonce) { @@ -147,8 +163,7 @@ void BaseChatMesh::populateContactFromAdvert(ContactInfo& ci, const mesh::Identi } ci.last_advert_timestamp = timestamp; ci.lastmod = getRTCClock()->getCurrentTime(); - getRNG()->random((uint8_t*)&ci.aead_nonce, sizeof(ci.aead_nonce)); // seed AEAD nonce from HW RNG - if (ci.aead_nonce == 0) ci.aead_nonce = 1; + ci.aead_nonce = (uint16_t)getRNG()->nextInt(NONCE_INITIAL_MIN, NONCE_INITIAL_MAX + 1); if (parser.getFeat1() & FEAT1_AEAD_SUPPORT) { ci.flags |= CONTACT_FLAG_AEAD; } @@ -287,8 +302,8 @@ void BaseChatMesh::onPeerDataRecv(mesh::Packet* packet, uint8_t type, int sender if (packet->isRouteFlood()) { // let this sender know path TO here, so they can use sendDirect(), and ALSO encode the ACK - mesh::Packet* path = createPathReturn(from.id, secret, packet->path, packet->path_len, - PAYLOAD_TYPE_ACK, (uint8_t *) &ack_hash, 4, nextAeadNonceFor(from)); + mesh::Packet* path = createPathReturn(from.id, getEncryptionKeyFor(from), packet->path, packet->path_len, + PAYLOAD_TYPE_ACK, (uint8_t *) &ack_hash, 4, getEncryptionNonceFor(from)); if (path) sendFloodScoped(from, path, TXT_ACK_DELAY); } else { sendAckTo(from, ack_hash); @@ -299,7 +314,7 @@ void BaseChatMesh::onPeerDataRecv(mesh::Packet* packet, uint8_t type, int sender if (packet->isRouteFlood()) { // let this sender know path TO here, so they can use sendDirect() (NOTE: no ACK as extra) - mesh::Packet* path = createPathReturn(from.id, secret, packet->path, packet->path_len, 0, NULL, 0, nextAeadNonceFor(from)); + mesh::Packet* path = createPathReturn(from.id, getEncryptionKeyFor(from), packet->path, packet->path_len, 0, NULL, 0, getEncryptionNonceFor(from)); if (path) sendFloodScoped(from, path); } } else if (flags == TXT_TYPE_SIGNED_PLAIN) { @@ -314,8 +329,8 @@ void BaseChatMesh::onPeerDataRecv(mesh::Packet* packet, uint8_t type, int sender if (packet->isRouteFlood()) { // let this sender know path TO here, so they can use sendDirect(), and ALSO encode the ACK - mesh::Packet* path = createPathReturn(from.id, secret, packet->path, packet->path_len, - PAYLOAD_TYPE_ACK, (uint8_t *) &ack_hash, 4, nextAeadNonceFor(from)); + mesh::Packet* path = createPathReturn(from.id, getEncryptionKeyFor(from), packet->path, packet->path_len, + PAYLOAD_TYPE_ACK, (uint8_t *) &ack_hash, 4, getEncryptionNonceFor(from)); if (path) sendFloodScoped(from, path, TXT_ACK_DELAY); } else { sendAckTo(from, ack_hash); @@ -326,15 +341,37 @@ void BaseChatMesh::onPeerDataRecv(mesh::Packet* packet, uint8_t type, int sender } else if (type == PAYLOAD_TYPE_REQ && len > 4) { uint32_t sender_timestamp; memcpy(&sender_timestamp, data, 4); - uint8_t reply_len = onContactRequest(from, sender_timestamp, &data[4], len - 4, temp_buf); + + uint8_t reply_len = 0; + bool use_static_secret = false; + + // Intercept session key INIT before subclass onContactRequest + if (len >= 5 + PUB_KEY_SIZE && data[4] == REQ_TYPE_SESSION_KEY_INIT) { + memcpy(temp_buf, &sender_timestamp, 4); + temp_buf[4] = RESP_TYPE_SESSION_KEY_ACCEPT; + uint8_t n = handleIncomingSessionKeyInit(from, &data[5], &temp_buf[5]); + if (n > 0) { + reply_len = 5 + n; + use_static_secret = true; // ACCEPT must use static secret (initiator doesn't have session key yet) + } + } + if (reply_len == 0) { + reply_len = onContactRequest(from, sender_timestamp, &data[4], len - 4, temp_buf); + } + if (reply_len > 0) { + // Session key ACCEPT must be encrypted with static ECDH secret, because + // the initiator hasn't derived the session key yet (they need our ephemeral_pub_B first). + const uint8_t* enc_key = use_static_secret ? from.getSharedSecret(self_id) : getEncryptionKeyFor(from); + uint16_t enc_nonce = use_static_secret ? nextAeadNonceFor(from) : getEncryptionNonceFor(from); + if (packet->isRouteFlood()) { // let this sender know path TO here, so they can use sendDirect(), and ALSO encode the response - mesh::Packet* path = createPathReturn(from.id, secret, packet->path, packet->path_len, - PAYLOAD_TYPE_RESPONSE, temp_buf, reply_len, nextAeadNonceFor(from)); + mesh::Packet* path = createPathReturn(from.id, enc_key, packet->path, packet->path_len, + PAYLOAD_TYPE_RESPONSE, temp_buf, reply_len, enc_nonce); if (path) sendFloodScoped(from, path, SERVER_RESPONSE_DELAY); } else { - mesh::Packet* reply = createDatagram(PAYLOAD_TYPE_RESPONSE, from.id, secret, temp_buf, reply_len, nextAeadNonceFor(from)); + mesh::Packet* reply = createDatagram(PAYLOAD_TYPE_RESPONSE, from.id, enc_key, temp_buf, reply_len, enc_nonce); if (reply) { if (from.out_path_len != OUT_PATH_UNKNOWN) { // we have an out_path, so send DIRECT sendDirect(reply, from.out_path, from.out_path_len, SERVER_RESPONSE_DELAY); @@ -345,7 +382,16 @@ void BaseChatMesh::onPeerDataRecv(mesh::Packet* packet, uint8_t type, int sender } } } else if (type == PAYLOAD_TYPE_RESPONSE && len > 0) { - onContactResponse(from, data, len); + // Intercept session key accept responses before passing to onContactResponse. + // Note: RESP_TYPE_SESSION_KEY_ACCEPT (0x08) could collide with a normal response whose + // 5th byte happens to be 0x08, but handleSessionKeyResponse has a secondary guard + // (requires INIT_SENT state for this peer) so false positives are extremely unlikely, + // and self-heal via session key invalidation if they ever occur. + if (len >= 5 && data[4] == RESP_TYPE_SESSION_KEY_ACCEPT && handleSessionKeyResponse(from, data, len)) { + // Session key response handled — don't pass to onContactResponse + } else { + onContactResponse(from, data, len); + } if (packet->isRouteFlood() && from.out_path_len != OUT_PATH_UNKNOWN) { // we have direct path, but other node is still sending flood response, so maybe they didn't receive reciprocal path properly(?) handleReturnPathRetry(from, packet->path, packet->path_len); @@ -379,7 +425,11 @@ bool BaseChatMesh::onContactPathRecv(ContactInfo& from, uint8_t* in_path, uint8_ txt_send_timeout = 0; // matched one we're waiting for, cancel timeout timer } } else if (extra_type == PAYLOAD_TYPE_RESPONSE && extra_len > 0) { - onContactResponse(from, extra, extra_len); + if (extra_len >= 5 && extra[4] == RESP_TYPE_SESSION_KEY_ACCEPT && handleSessionKeyResponse(from, extra, extra_len)) { + // Session key response handled + } else { + onContactResponse(from, extra, extra_len); + } } return true; // send reciprocal path if necessary } @@ -400,7 +450,7 @@ void BaseChatMesh::onAckRecv(mesh::Packet* packet, uint32_t ack_crc) { void BaseChatMesh::handleReturnPathRetry(const ContactInfo& contact, const uint8_t* path, uint8_t path_len) { // NOTE: simplest impl is just to re-send a reciprocal return path to sender (DIRECTLY) // override this method in various firmwares, if there's a better strategy - mesh::Packet* rpath = createPathReturn(contact.id, contact.getSharedSecret(self_id), path, path_len, 0, NULL, 0, nextAeadNonceFor(contact)); + mesh::Packet* rpath = createPathReturn(contact.id, getEncryptionKeyFor(contact), path, path_len, 0, NULL, 0, getEncryptionNonceFor(contact)); if (rpath) sendDirect(rpath, contact.out_path, contact.out_path_len, 3000); // 3 second delay } @@ -449,7 +499,7 @@ mesh::Packet* BaseChatMesh::composeMsgPacket(const ContactInfo& recipient, uint3 temp[len++] = attempt; // hide attempt number at tail end of payload } - return createDatagram(PAYLOAD_TYPE_TXT_MSG, recipient.id, recipient.getSharedSecret(self_id), temp, len, nextAeadNonceFor(recipient)); + return createDatagram(PAYLOAD_TYPE_TXT_MSG, recipient.id, getEncryptionKeyFor(recipient), temp, len, getEncryptionNonceFor(recipient)); } int BaseChatMesh::sendMessage(const ContactInfo& recipient, uint32_t timestamp, uint8_t attempt, const char* text, uint32_t& expected_ack, uint32_t& est_timeout) { @@ -480,7 +530,7 @@ int BaseChatMesh::sendCommandData(const ContactInfo& recipient, uint32_t timest temp[4] = (attempt & 3) | (TXT_TYPE_CLI_DATA << 2); memcpy(&temp[5], text, text_len + 1); - auto pkt = createDatagram(PAYLOAD_TYPE_TXT_MSG, recipient.id, recipient.getSharedSecret(self_id), temp, 5 + text_len, nextAeadNonceFor(recipient)); + auto pkt = createDatagram(PAYLOAD_TYPE_TXT_MSG, recipient.id, getEncryptionKeyFor(recipient), temp, 5 + text_len, getEncryptionNonceFor(recipient)); if (pkt == NULL) return MSG_SEND_FAILED; uint32_t t = _radio->getEstAirtimeFor(pkt->getRawLength()); @@ -621,7 +671,7 @@ int BaseChatMesh::sendRequest(const ContactInfo& recipient, const uint8_t* req_ memcpy(temp, &tag, 4); // mostly an extra blob to help make packet_hash unique memcpy(&temp[4], req_data, data_len); - pkt = createDatagram(PAYLOAD_TYPE_REQ, recipient.id, recipient.getSharedSecret(self_id), temp, 4 + data_len, nextAeadNonceFor(recipient)); + pkt = createDatagram(PAYLOAD_TYPE_REQ, recipient.id, getEncryptionKeyFor(recipient), temp, 4 + data_len, getEncryptionNonceFor(recipient)); } if (pkt) { uint32_t t = _radio->getEstAirtimeFor(pkt->getRawLength()); @@ -648,7 +698,7 @@ int BaseChatMesh::sendRequest(const ContactInfo& recipient, uint8_t req_type, u memset(&temp[5], 0, 4); // reserved (possibly for 'since' param) getRNG()->random(&temp[9], 4); // random blob to help make packet-hash unique - pkt = createDatagram(PAYLOAD_TYPE_REQ, recipient.id, recipient.getSharedSecret(self_id), temp, sizeof(temp), nextAeadNonceFor(recipient)); + pkt = createDatagram(PAYLOAD_TYPE_REQ, recipient.id, getEncryptionKeyFor(recipient), temp, sizeof(temp), getEncryptionNonceFor(recipient)); } if (pkt) { uint32_t t = _radio->getEstAirtimeFor(pkt->getRawLength()); @@ -771,7 +821,7 @@ void BaseChatMesh::checkConnections() { // calc expected ACK reply mesh::Utils::sha256((uint8_t *)&connections[i].expected_ack, 4, data, 9, self_id.pub_key, PUB_KEY_SIZE); - auto pkt = createDatagram(PAYLOAD_TYPE_REQ, contact->id, contact->getSharedSecret(self_id), data, 9, nextAeadNonceFor(*contact)); + auto pkt = createDatagram(PAYLOAD_TYPE_REQ, contact->id, getEncryptionKeyFor(*contact), data, 9, getEncryptionNonceFor(*contact)); if (pkt) { sendDirect(pkt, contact->out_path, contact->out_path_len); } @@ -836,8 +886,7 @@ bool BaseChatMesh::addContact(const ContactInfo& contact) { int idx = dest - contacts; *dest = contact; dest->shared_secret_valid = false; // mark shared_secret as needing calculation - getRNG()->random((uint8_t*)&dest->aead_nonce, sizeof(dest->aead_nonce)); // always seed fresh from HW RNG - if (dest->aead_nonce == 0) dest->aead_nonce = 1; + dest->aead_nonce = (uint16_t)getRNG()->nextInt(NONCE_INITIAL_MIN, NONCE_INITIAL_MAX + 1); nonce_at_last_persist[idx] = dest->aead_nonce; return true; // success } @@ -851,6 +900,8 @@ bool BaseChatMesh::removeContact(ContactInfo& contact) { } if (idx >= num_contacts) return false; // not found + session_keys.remove(contact.id.pub_key); // also remove session key if any + // remove from contacts array and parallel nonce tracking num_contacts--; while (idx < num_contacts) { @@ -953,4 +1004,337 @@ void BaseChatMesh::loop() { releasePacket(_pendingLoopback); // undo the obtainNewPacket() _pendingLoopback = NULL; } + + checkSessionKeyTimeouts(); + + // Process deferred session key negotiation (set by getEncryptionNonceFor) + if (_pending_rekey_idx >= 0 && _pending_rekey_idx < num_contacts) { + int idx = _pending_rekey_idx; + _pending_rekey_idx = -1; + initiateSessionKeyNegotiation(contacts[idx]); + } +} + +// --- Session key support (Phase 2 — initiator) --- + +static bool canUseSessionKey(const SessionKeyEntry* entry) { + if (!entry) return false; + // ACTIVE/DUAL_DECODE: normal session key use + // INIT_SENT with nonce > 1: renegotiation in progress, keep using old session key + // (nonce == 0 means fresh allocation with no prior session key) + bool valid_state = (entry->state == SESSION_STATE_ACTIVE || entry->state == SESSION_STATE_DUAL_DECODE) + || (entry->state == SESSION_STATE_INIT_SENT && entry->nonce > 1); + return valid_state + && entry->sends_since_last_recv < SESSION_KEY_STALE_THRESHOLD + && entry->nonce < 65535; // nonce exhausted → fall back to static ECDH +} + +const uint8_t* BaseChatMesh::getEncryptionKeyFor(const ContactInfo& contact) { + auto entry = session_keys.findByPrefix(contact.id.pub_key); + if (canUseSessionKey(entry)) { + return entry->session_key; + } + return contact.getSharedSecret(self_id); +} + +uint16_t BaseChatMesh::getEncryptionNonceFor(const ContactInfo& contact) { + uint16_t nonce = 0; + auto entry = session_keys.findByPrefix(contact.id.pub_key); + if (canUseSessionKey(entry)) { + ++entry->nonce; + if (entry->sends_since_last_recv < 255) entry->sends_since_last_recv++; + session_keys_dirty = true; + nonce = entry->nonce; + } else if (entry && entry->sends_since_last_recv < 255) { + // Progressive fallback: keep incrementing counter even when not using session key + entry->sends_since_last_recv++; + if (entry->sends_since_last_recv >= SESSION_KEY_ABANDON_THRESHOLD) { + // Give up: clear AEAD capability and remove session key + int idx = &contact - contacts; + if (idx >= 0 && idx < num_contacts) + contacts[idx].flags &= ~CONTACT_FLAG_AEAD; + session_keys.remove(contact.id.pub_key); + onSessionKeysUpdated(); + // nonce = 0 (ECB) + } else if (entry->sends_since_last_recv >= SESSION_KEY_ECB_THRESHOLD) { + // nonce = 0 (ECB) + } else { + nonce = nextAeadNonceFor(contact); + } + } else { + nonce = nextAeadNonceFor(contact); + } + + // Trigger session key negotiation on the next loop() tick. + // Checking here (the single funnel for all outgoing encryption) ensures no + // send path can silently skip a trigger — unlike the old per-call-site approach. + if (_pending_rekey_idx < 0 && shouldInitiateSessionKey(contact)) { + _pending_rekey_idx = &contact - contacts; + } + + return nonce; +} + +bool BaseChatMesh::shouldInitiateSessionKey(const ContactInfo& contact) { + // Only for AEAD-capable peers + if (!(contact.flags & CONTACT_FLAG_AEAD)) return false; + + // Need a known path to send the request + if (contact.out_path_len < 0) return false; + + auto entry = session_keys.findByPrefix(contact.id.pub_key); + + // Don't trigger if negotiation already in progress + if (entry && entry->state == SESSION_STATE_INIT_SENT) return false; + + // Determine intervals based on hop count tier: + // direct (0): static=100, session=100 + // 1–9 hops: static=500, session=300 + // 10+ hops: static=1000, session=300 + uint16_t static_interval, session_interval; + if (contact.out_path_len == 0) { + static_interval = 100; + session_interval = 100; + } else if (contact.out_path_len < 10) { + static_interval = 500; + session_interval = 300; + } else { + static_interval = 1000; + session_interval = 300; + } + + if (entry && (entry->state == SESSION_STATE_ACTIVE || entry->state == SESSION_STATE_DUAL_DECODE)) { + if (entry->nonce < 65535) { + // Active session key with remaining nonces — renegotiate after nonce > 60000 + if (entry->nonce <= NONCE_REKEY_THRESHOLD) return false; + return ((entry->nonce - NONCE_REKEY_THRESHOLD) % session_interval) == 0; + } + // Session key nonce exhausted — fall through to static ECDH trigger + } + + // No session key (or state=NONE) — trigger based on static ECDH nonce vs interval + if (contact.aead_nonce == 0) return false; // no messages sent yet + return (contact.aead_nonce % static_interval) == 0; +} + +bool BaseChatMesh::initiateSessionKeyNegotiation(const ContactInfo& contact) { + auto entry = session_keys.allocate(contact.id.pub_key); + if (!entry) return false; + + // Don't start a new negotiation if one is already pending + if (entry->state == SESSION_STATE_INIT_SENT) return false; + + // Generate ephemeral keypair A + uint8_t seed[SEED_SIZE]; + getRNG()->random(seed, SEED_SIZE); + ed25519_create_keypair(entry->ephemeral_pub, entry->ephemeral_prv, seed); + memset(seed, 0, SEED_SIZE); + + // Send REQ_TYPE_SESSION_KEY_INIT with ephemeral_pub_A + uint8_t req_data[1 + PUB_KEY_SIZE]; + req_data[0] = REQ_TYPE_SESSION_KEY_INIT; + memcpy(&req_data[1], entry->ephemeral_pub, PUB_KEY_SIZE); + + uint32_t tag, est_timeout; + int rc = sendRequest(contact, req_data, sizeof(req_data), tag, est_timeout); + if (rc == MSG_SEND_FAILED) { + memset(entry->ephemeral_prv, 0, PRV_KEY_SIZE); + memset(entry->ephemeral_pub, 0, PUB_KEY_SIZE); + return false; + } + + entry->state = SESSION_STATE_INIT_SENT; + entry->retries_left = SESSION_KEY_MAX_RETRIES - 1; + entry->timeout_at = futureMillis(SESSION_KEY_TIMEOUT_MS); + return true; +} + +bool BaseChatMesh::handleSessionKeyResponse(ContactInfo& contact, const uint8_t* data, uint8_t len) { + // Response format: [timestamp:4][RESP_TYPE_SESSION_KEY_ACCEPT:1][ephemeral_pub_B:32] + if (len < 5 + PUB_KEY_SIZE) return false; + if (data[4] != RESP_TYPE_SESSION_KEY_ACCEPT) return false; + + auto entry = session_keys.findByPrefix(contact.id.pub_key); + if (!entry || entry->state != SESSION_STATE_INIT_SENT) return false; + + const uint8_t* ephemeral_pub_B = &data[5]; + + // Compute ephemeral_secret via X25519 + uint8_t ephemeral_secret[PUB_KEY_SIZE]; + ed25519_key_exchange(ephemeral_secret, ephemeral_pub_B, entry->ephemeral_prv); + memset(entry->ephemeral_prv, 0, PRV_KEY_SIZE); + memset(entry->ephemeral_pub, 0, PUB_KEY_SIZE); + + // Derive session_key = HMAC-SHA256(static_shared_secret, ephemeral_secret) + const uint8_t* static_secret = contact.getSharedSecret(self_id); + uint8_t new_session_key[SESSION_KEY_SIZE]; + { + SHA256 sha; + sha.resetHMAC(static_secret, PUB_KEY_SIZE); + sha.update(ephemeral_secret, PUB_KEY_SIZE); + sha.finalizeHMAC(static_secret, PUB_KEY_SIZE, new_session_key, SESSION_KEY_SIZE); + } + memset(ephemeral_secret, 0, PUB_KEY_SIZE); + + // Activate session key + memcpy(entry->session_key, new_session_key, SESSION_KEY_SIZE); + memset(new_session_key, 0, SESSION_KEY_SIZE); + entry->nonce = 1; + entry->state = SESSION_STATE_ACTIVE; + entry->sends_since_last_recv = 0; + entry->retries_left = 0; + entry->timeout_at = 0; + + MESH_DEBUG_PRINTLN("Session key established with: %s", contact.name); + onSessionKeysUpdated(); + return true; +} + +uint8_t BaseChatMesh::handleIncomingSessionKeyInit(ContactInfo& from, const uint8_t* ephemeral_pub_A, uint8_t* reply_buf) { + // 1. Generate ephemeral keypair B + uint8_t seed[SEED_SIZE]; + getRNG()->random(seed, SEED_SIZE); + uint8_t ephemeral_pub_B[PUB_KEY_SIZE]; + uint8_t ephemeral_prv_B[PRV_KEY_SIZE]; + ed25519_create_keypair(ephemeral_pub_B, ephemeral_prv_B, seed); + memset(seed, 0, SEED_SIZE); + + // 2. Compute ephemeral_secret via X25519 + uint8_t ephemeral_secret[PUB_KEY_SIZE]; + ed25519_key_exchange(ephemeral_secret, ephemeral_pub_A, ephemeral_prv_B); + memset(ephemeral_prv_B, 0, PRV_KEY_SIZE); + + // 3. Derive session_key = HMAC-SHA256(static_shared_secret, ephemeral_secret) + const uint8_t* static_secret = from.getSharedSecret(self_id); + uint8_t new_session_key[SESSION_KEY_SIZE]; + { + SHA256 sha; + sha.resetHMAC(static_secret, PUB_KEY_SIZE); + sha.update(ephemeral_secret, PUB_KEY_SIZE); + sha.finalizeHMAC(static_secret, PUB_KEY_SIZE, new_session_key, SESSION_KEY_SIZE); + } + memset(ephemeral_secret, 0, PUB_KEY_SIZE); + + // 4. Store in pool (dual-decode: new key active, old key still valid) + auto entry = session_keys.allocate(from.id.pub_key); + if (!entry) return 0; + + if (entry->state == SESSION_STATE_ACTIVE || entry->state == SESSION_STATE_DUAL_DECODE) { + memcpy(entry->prev_session_key, entry->session_key, SESSION_KEY_SIZE); + } + memcpy(entry->session_key, new_session_key, SESSION_KEY_SIZE); + entry->nonce = 1; + entry->state = SESSION_STATE_DUAL_DECODE; + entry->sends_since_last_recv = 0; + memset(new_session_key, 0, SESSION_KEY_SIZE); + + // 5. Persist immediately + onSessionKeysUpdated(); + + // 6. Write ephemeral_pub_B to reply + memcpy(reply_buf, ephemeral_pub_B, PUB_KEY_SIZE); + MESH_DEBUG_PRINTLN("Session key INIT accepted from: %s", from.name); + return PUB_KEY_SIZE; +} + +void BaseChatMesh::checkSessionKeyTimeouts() { + for (int i = 0; i < session_keys.getCount(); i++) { + auto entry = session_keys.getByIdx(i); + if (!entry || entry->state != SESSION_STATE_INIT_SENT) continue; + if (entry->timeout_at == 0 || !millisHasNowPassed(entry->timeout_at)) continue; + + if (entry->retries_left > 0) { + // Retry: find the contact and resend INIT + for (int j = 0; j < num_contacts; j++) { + if (memcmp(contacts[j].id.pub_key, entry->peer_pub_prefix, 4) == 0) { + entry->retries_left--; + entry->timeout_at = futureMillis(SESSION_KEY_TIMEOUT_MS); + + // Regenerate ephemeral keypair for retry + uint8_t seed[SEED_SIZE]; + getRNG()->random(seed, SEED_SIZE); + ed25519_create_keypair(entry->ephemeral_pub, entry->ephemeral_prv, seed); + memset(seed, 0, SEED_SIZE); + + uint8_t req_data[1 + PUB_KEY_SIZE]; + req_data[0] = REQ_TYPE_SESSION_KEY_INIT; + memcpy(&req_data[1], entry->ephemeral_pub, PUB_KEY_SIZE); + + uint32_t tag, est_timeout; + sendRequest(contacts[j], req_data, sizeof(req_data), tag, est_timeout); + break; + } + } + } else { + // All retries exhausted — clean up + memset(entry->ephemeral_prv, 0, PRV_KEY_SIZE); + memset(entry->ephemeral_pub, 0, PUB_KEY_SIZE); + entry->state = SESSION_STATE_NONE; + entry->timeout_at = 0; + } + } +} + +// Virtual overrides for session key decrypt path +const uint8_t* BaseChatMesh::getPeerSessionKey(int peer_idx) { + int i = matching_peer_indexes[peer_idx]; + if (i >= 0 && i < num_contacts) { + auto entry = session_keys.findByPrefix(contacts[i].id.pub_key); + // Also try decode during INIT_SENT renegotiation (nonce > 1 means prior key exists) + if (entry && (entry->state == SESSION_STATE_ACTIVE || entry->state == SESSION_STATE_DUAL_DECODE + || (entry->state == SESSION_STATE_INIT_SENT && entry->nonce > 1))) + return entry->session_key; + } + return nullptr; +} + +const uint8_t* BaseChatMesh::getPeerPrevSessionKey(int peer_idx) { + int i = matching_peer_indexes[peer_idx]; + if (i >= 0 && i < num_contacts) { + auto entry = session_keys.findByPrefix(contacts[i].id.pub_key); + if (entry && entry->state == SESSION_STATE_DUAL_DECODE) + return entry->prev_session_key; + } + return nullptr; +} + +void BaseChatMesh::onSessionKeyDecryptSuccess(int peer_idx) { + int i = matching_peer_indexes[peer_idx]; + if (i >= 0 && i < num_contacts) { + auto entry = session_keys.findByPrefix(contacts[i].id.pub_key); + if (entry) { + bool changed = (entry->state == SESSION_STATE_DUAL_DECODE); + if (changed) { + memset(entry->prev_session_key, 0, SESSION_KEY_SIZE); + entry->state = SESSION_STATE_ACTIVE; + } + entry->sends_since_last_recv = 0; + if (changed) onSessionKeysUpdated(); + } + } +} + +const uint8_t* BaseChatMesh::getPeerEncryptionKey(int peer_idx, const uint8_t* static_secret) { + int i = matching_peer_indexes[peer_idx]; + if (i >= 0 && i < num_contacts) + return getEncryptionKeyFor(contacts[i]); + return static_secret; +} + +uint16_t BaseChatMesh::getPeerEncryptionNonce(int peer_idx) { + int i = matching_peer_indexes[peer_idx]; + if (i >= 0 && i < num_contacts) + return getEncryptionNonceFor(contacts[i]); + return getPeerNextAeadNonce(peer_idx); +} + +// Session key persistence helpers (delegated to subclass for file I/O) +bool BaseChatMesh::applyLoadedSessionKey(const uint8_t* pub_key_prefix, uint8_t flags, uint16_t nonce, + const uint8_t* session_key, const uint8_t* prev_session_key) { + return session_keys.applyLoaded(pub_key_prefix, flags, nonce, session_key, prev_session_key); +} + +bool BaseChatMesh::getSessionKeyEntry(int idx, uint8_t* pub_key_prefix, uint8_t* flags, uint16_t* nonce, + uint8_t* session_key, uint8_t* prev_session_key) { + return session_keys.getEntryForSave(idx, pub_key_prefix, flags, nonce, session_key, prev_session_key); } diff --git a/src/helpers/BaseChatMesh.h b/src/helpers/BaseChatMesh.h index 77c984129..24e14ffdf 100644 --- a/src/helpers/BaseChatMesh.h +++ b/src/helpers/BaseChatMesh.h @@ -8,6 +8,7 @@ #define MAX_TEXT_LEN (10*CIPHER_BLOCK_SIZE) // must be LESS than (MAX_PACKET_PAYLOAD - 4 - CIPHER_MAC_SIZE - 1) #include "ContactInfo.h" +#include "SessionKeyPool.h" #define MAX_SEARCH_RESULTS 8 @@ -75,6 +76,11 @@ class BaseChatMesh : public mesh::Mesh { uint16_t nonce_at_last_persist[MAX_CONTACTS]; bool nonce_dirty; + // Session key pool (Phase 2) + SessionKeyPool session_keys; + bool session_keys_dirty; + int _pending_rekey_idx; // contact index needing session key negotiation, -1 = none + mesh::Packet* composeMsgPacket(const ContactInfo& recipient, uint32_t timestamp, uint8_t attempt, const char *text, uint32_t& expected_ack); void sendAckTo(const ContactInfo& dest, uint32_t ack_hash); @@ -92,6 +98,8 @@ class BaseChatMesh : public mesh::Mesh { memset(connections, 0, sizeof(connections)); memset(nonce_at_last_persist, 0, sizeof(nonce_at_last_persist)); nonce_dirty = false; + session_keys_dirty = false; + _pending_rekey_idx = -1; } void bootstrapRTCfromContacts(); @@ -138,12 +146,36 @@ class BaseChatMesh : public mesh::Mesh { nonce_dirty = false; } + // Session key support (Phase 2 — initiator) + virtual void onSessionKeysUpdated() { session_keys_dirty = true; } // called when session key pool changes; override to persist + const uint8_t* getEncryptionKeyFor(const ContactInfo& contact); + uint16_t getEncryptionNonceFor(const ContactInfo& contact); + bool shouldInitiateSessionKey(const ContactInfo& contact); + bool initiateSessionKeyNegotiation(const ContactInfo& contact); + bool handleSessionKeyResponse(ContactInfo& contact, const uint8_t* data, uint8_t len); + uint8_t handleIncomingSessionKeyInit(ContactInfo& from, const uint8_t* ephemeral_pub_A, uint8_t* reply_buf); + void checkSessionKeyTimeouts(); + + // Session key persistence helpers (for subclass to call) + bool applyLoadedSessionKey(const uint8_t* pub_key_prefix, uint8_t flags, uint16_t nonce, + const uint8_t* session_key, const uint8_t* prev_session_key); + bool getSessionKeyEntry(int idx, uint8_t* pub_key_prefix, uint8_t* flags, uint16_t* nonce, + uint8_t* session_key, uint8_t* prev_session_key); + int getSessionKeyCount() const { return session_keys.getCount(); } + bool isSessionKeysDirty() const { return session_keys_dirty; } + void clearSessionKeysDirty() { session_keys_dirty = false; } + // Mesh overrides void onAdvertRecv(mesh::Packet* packet, const mesh::Identity& id, uint32_t timestamp, const uint8_t* app_data, size_t app_data_len) override; int searchPeersByHash(const uint8_t* hash) override; void getPeerSharedSecret(uint8_t* dest_secret, int peer_idx) override; uint8_t getPeerFlags(int peer_idx) override; uint16_t getPeerNextAeadNonce(int peer_idx) override; + const uint8_t* getPeerSessionKey(int peer_idx) override; + const uint8_t* getPeerPrevSessionKey(int peer_idx) override; + void onSessionKeyDecryptSuccess(int peer_idx) override; + const uint8_t* getPeerEncryptionKey(int peer_idx, const uint8_t* static_secret) override; + uint16_t getPeerEncryptionNonce(int peer_idx) override; 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 onAckRecv(mesh::Packet* packet, uint32_t ack_crc) override; diff --git a/src/helpers/ClientACL.cpp b/src/helpers/ClientACL.cpp index 56b1a79c2..1abfa9fa4 100644 --- a/src/helpers/ClientACL.cpp +++ b/src/helpers/ClientACL.cpp @@ -1,5 +1,7 @@ #include "ClientACL.h" #include +#include +#include static File openWrite(FILESYSTEM* _fs, const char* filename) { #if defined(NRF52_PLATFORM) || defined(STM32_PLATFORM) @@ -118,8 +120,7 @@ ClientInfo* ClientACL::putClient(const mesh::Identity& id, uint8_t init_perms) { c->id = id; c->out_path_len = OUT_PATH_UNKNOWN; if (_rng) { - _rng->random((uint8_t*)&c->aead_nonce, sizeof(c->aead_nonce)); - if (c->aead_nonce == 0) c->aead_nonce = 1; + c->aead_nonce = (uint16_t)_rng->nextInt(NONCE_INITIAL_MIN, NONCE_INITIAL_MAX + 1); } nonce_at_last_persist[idx] = c->aead_nonce; return c; @@ -191,6 +192,20 @@ void ClientACL::finalizeNonceLoad(bool needs_bump) { nonce_at_last_persist[i] = clients[i].aead_nonce; } nonce_dirty = false; + + // Apply boot bump to session key nonces too + if (needs_bump) { + for (int i = 0; i < session_keys.getCount(); i++) { + auto entry = session_keys.getByIdx(i); + if (entry && (entry->state == SESSION_STATE_ACTIVE || entry->state == SESSION_STATE_DUAL_DECODE)) { + uint16_t old_nonce = entry->nonce; + entry->nonce += NONCE_BOOT_BUMP; + if (entry->nonce <= old_nonce) { + entry->nonce = 65535; // wrapped — force exhaustion so renegotiation happens + } + } + } + } } bool ClientACL::applyPermissions(const mesh::LocalIdentity& self_id, const uint8_t* pubkey, int key_len, uint8_t perms) { @@ -199,6 +214,8 @@ bool ClientACL::applyPermissions(const mesh::LocalIdentity& self_id, const uint8 c = getClient(pubkey, key_len); if (c == NULL) return false; // partial pubkey not found + session_keys.remove(c->id.pub_key); // also remove session key if any + num_clients--; // delete from contacts[] int i = c - clients; while (i < num_clients) { @@ -217,3 +234,198 @@ bool ClientACL::applyPermissions(const mesh::LocalIdentity& self_id, const uint8 } return true; } + +// --- Session key support (Phase 2) --- + +int ClientACL::handleSessionKeyInit(const ClientInfo* client, const uint8_t* ephemeral_pub_A, uint8_t* reply_buf, mesh::RNG* rng) { + // 1. Generate ephemeral keypair B + uint8_t seed[SEED_SIZE]; + rng->random(seed, SEED_SIZE); + uint8_t ephemeral_pub_B[PUB_KEY_SIZE]; + uint8_t ephemeral_prv_B[PRV_KEY_SIZE]; + ed25519_create_keypair(ephemeral_pub_B, ephemeral_prv_B, seed); + memset(seed, 0, SEED_SIZE); + + // 2. Compute ephemeral_secret via X25519 + uint8_t ephemeral_secret[PUB_KEY_SIZE]; + ed25519_key_exchange(ephemeral_secret, ephemeral_pub_A, ephemeral_prv_B); + memset(ephemeral_prv_B, 0, PRV_KEY_SIZE); + + // 3. Derive session_key = HMAC-SHA256(static_shared_secret, ephemeral_secret) + uint8_t new_session_key[SESSION_KEY_SIZE]; + { + SHA256 sha; + sha.resetHMAC(client->shared_secret, PUB_KEY_SIZE); + sha.update(ephemeral_secret, PUB_KEY_SIZE); + sha.finalizeHMAC(client->shared_secret, PUB_KEY_SIZE, new_session_key, SESSION_KEY_SIZE); + } + memset(ephemeral_secret, 0, PUB_KEY_SIZE); + + // 4. Store in pool (dual-decode: new key active, old key still valid) + auto entry = session_keys.allocate(client->id.pub_key); + if (!entry) return 0; + + if (entry->state == SESSION_STATE_ACTIVE || entry->state == SESSION_STATE_DUAL_DECODE) { + memcpy(entry->prev_session_key, entry->session_key, SESSION_KEY_SIZE); + } + memcpy(entry->session_key, new_session_key, SESSION_KEY_SIZE); + entry->nonce = 1; + entry->state = SESSION_STATE_DUAL_DECODE; + entry->sends_since_last_recv = 0; + memset(new_session_key, 0, SESSION_KEY_SIZE); + + // 5. Persist immediately + saveSessionKeys(); + + // 6. Write ephemeral_pub_B to reply + memcpy(reply_buf, ephemeral_pub_B, PUB_KEY_SIZE); + return PUB_KEY_SIZE; +} + +const uint8_t* ClientACL::getSessionKey(const uint8_t* pub_key) { + auto entry = session_keys.findByPrefix(pub_key); + if (entry && (entry->state == SESSION_STATE_ACTIVE || entry->state == SESSION_STATE_DUAL_DECODE)) { + return entry->session_key; + } + return nullptr; +} + +const uint8_t* ClientACL::getPrevSessionKey(const uint8_t* pub_key) { + auto entry = session_keys.findByPrefix(pub_key); + if (entry && entry->state == SESSION_STATE_DUAL_DECODE) { + return entry->prev_session_key; + } + return nullptr; +} + +const uint8_t* ClientACL::getEncryptionKey(const ClientInfo& client) { + auto entry = session_keys.findByPrefix(client.id.pub_key); + if (entry && (entry->state == SESSION_STATE_ACTIVE || entry->state == SESSION_STATE_DUAL_DECODE) + && entry->sends_since_last_recv < SESSION_KEY_STALE_THRESHOLD + && entry->nonce < 65535) { + return entry->session_key; + } + return client.shared_secret; +} + +uint16_t ClientACL::getEncryptionNonce(const ClientInfo& client) { + auto entry = session_keys.findByPrefix(client.id.pub_key); + if (entry && (entry->state == SESSION_STATE_ACTIVE || entry->state == SESSION_STATE_DUAL_DECODE) + && entry->sends_since_last_recv < SESSION_KEY_STALE_THRESHOLD + && entry->nonce < 65535) { + ++entry->nonce; + if (entry->sends_since_last_recv < 255) entry->sends_since_last_recv++; + _session_keys_dirty = true; + return entry->nonce; + } + // Progressive fallback: keep incrementing counter even when not using session key + if (entry && entry->sends_since_last_recv < 255) { + entry->sends_since_last_recv++; + if (entry->sends_since_last_recv >= SESSION_KEY_ABANDON_THRESHOLD) { + int idx = &client - clients; + if (idx >= 0 && idx < num_clients) + clients[idx].flags &= ~CONTACT_FLAG_AEAD; + session_keys.remove(client.id.pub_key); + saveSessionKeys(); + return 0; // ECB + } + if (entry->sends_since_last_recv >= SESSION_KEY_ECB_THRESHOLD) { + return 0; // ECB + } + } + return nextAeadNonceFor(client); +} + +void ClientACL::onSessionConfirmed(const uint8_t* pub_key) { + auto entry = session_keys.findByPrefix(pub_key); + if (entry) { + if (entry->state == SESSION_STATE_DUAL_DECODE) { + memset(entry->prev_session_key, 0, SESSION_KEY_SIZE); + entry->state = SESSION_STATE_ACTIVE; + saveSessionKeys(); + } + entry->sends_since_last_recv = 0; + } +} + +// --- Peer-index forwarding helpers --- + +ClientInfo* ClientACL::resolveClient(int peer_idx, const int* matching_indexes) { + int i = matching_indexes[peer_idx]; + if (i >= 0 && i < num_clients) return &clients[i]; + return nullptr; +} + +uint16_t ClientACL::peerNextAeadNonce(int peer_idx, const int* matching_indexes) { + auto* c = resolveClient(peer_idx, matching_indexes); + return c ? nextAeadNonceFor(*c) : 0; +} + +const uint8_t* ClientACL::peerSessionKey(int peer_idx, const int* matching_indexes) { + auto* c = resolveClient(peer_idx, matching_indexes); + return c ? getSessionKey(c->id.pub_key) : nullptr; +} + +const uint8_t* ClientACL::peerPrevSessionKey(int peer_idx, const int* matching_indexes) { + auto* c = resolveClient(peer_idx, matching_indexes); + return c ? getPrevSessionKey(c->id.pub_key) : nullptr; +} + +void ClientACL::peerSessionKeyDecryptSuccess(int peer_idx, const int* matching_indexes) { + auto* c = resolveClient(peer_idx, matching_indexes); + if (c) onSessionConfirmed(c->id.pub_key); +} + +const uint8_t* ClientACL::peerEncryptionKey(int peer_idx, const int* matching_indexes, const uint8_t* fallback) { + auto* c = resolveClient(peer_idx, matching_indexes); + return c ? getEncryptionKey(*c) : fallback; +} + +uint16_t ClientACL::peerEncryptionNonce(int peer_idx, const int* matching_indexes) { + auto* c = resolveClient(peer_idx, matching_indexes); + return c ? getEncryptionNonce(*c) : 0; +} + +void ClientACL::loadSessionKeys() { + if (!_fs) return; +#if defined(RP2040_PLATFORM) + File file = _fs->open("/s_sess_keys", "r"); +#elif defined(NRF52_PLATFORM) || defined(STM32_PLATFORM) + File file = _fs->open("/s_sess_keys", FILE_O_READ); +#else + File file = _fs->open("/s_sess_keys", "r", false); +#endif + if (file) { + uint8_t rec[71]; // [pub_prefix:4][flags:1][nonce:2][session_key:32][prev_session_key:32] + while (file.read(rec, 71) == 71) { + uint8_t flags = rec[4]; + uint16_t nonce; + memcpy(&nonce, &rec[5], 2); + session_keys.applyLoaded(rec, flags, nonce, &rec[7], &rec[39]); + } + file.close(); + } +} + +void ClientACL::saveSessionKeys() { + _session_keys_dirty = false; + if (!_fs) return; + File file = openWrite(_fs, "/s_sess_keys"); + if (file) { + for (int i = 0; i < session_keys.getCount(); i++) { + uint8_t pub_key_prefix[4]; + uint8_t flags; + uint16_t nonce; + uint8_t session_key[SESSION_KEY_SIZE]; + uint8_t prev_session_key[SESSION_KEY_SIZE]; + if (session_keys.getEntryForSave(i, pub_key_prefix, &flags, &nonce, session_key, prev_session_key)) { + file.write(pub_key_prefix, 4); + file.write(&flags, 1); + file.write((uint8_t*)&nonce, 2); + file.write(session_key, SESSION_KEY_SIZE); + file.write(prev_session_key, SESSION_KEY_SIZE); + } + } + file.close(); + } +} diff --git a/src/helpers/ClientACL.h b/src/helpers/ClientACL.h index 31788d272..455c7ccef 100644 --- a/src/helpers/ClientACL.h +++ b/src/helpers/ClientACL.h @@ -3,6 +3,7 @@ #include // needed for PlatformIO #include #include +#include #define PERM_ACL_ROLE_MASK 3 // lower 2 bits #define PERM_ACL_GUEST 0 @@ -54,14 +55,19 @@ class ClientACL { // Nonce persistence state (parallel to clients[]) uint16_t nonce_at_last_persist[MAX_CLIENTS]; bool nonce_dirty; + bool _session_keys_dirty; mesh::RNG* _rng; + // Session key pool (Phase 2) + SessionKeyPool session_keys; + public: ClientACL() { memset(clients, 0, sizeof(clients)); memset(nonce_at_last_persist, 0, sizeof(nonce_at_last_persist)); num_clients = 0; nonce_dirty = false; + _session_keys_dirty = false; _rng = NULL; } void load(FILESYSTEM* _fs, const mesh::LocalIdentity& self_id); @@ -74,6 +80,7 @@ class ClientACL { int getNumClients() const { return num_clients; } ClientInfo* getClientByIdx(int idx) { return &clients[idx]; } + int getSessionKeyCount() const { return session_keys.getCount(); } // AEAD nonce persistence void setRNG(mesh::RNG* rng) { _rng = rng; } @@ -86,4 +93,27 @@ class ClientACL { for (int i = 0; i < num_clients; i++) nonce_at_last_persist[i] = clients[i].aead_nonce; nonce_dirty = false; } + + // Session key support (Phase 2) + int handleSessionKeyInit(const ClientInfo* client, const uint8_t* ephemeral_pub_A, uint8_t* reply_buf, mesh::RNG* rng); + const uint8_t* getSessionKey(const uint8_t* pub_key); + const uint8_t* getPrevSessionKey(const uint8_t* pub_key); + const uint8_t* getEncryptionKey(const ClientInfo& client); + uint16_t getEncryptionNonce(const ClientInfo& client); + void onSessionConfirmed(const uint8_t* pub_key); + bool isSessionKeysDirty() const { return _session_keys_dirty; } + void loadSessionKeys(); + void saveSessionKeys(); + + // Peer-index forwarding helpers for server-side Mesh overrides. + // These resolve peer_idx → ClientInfo via matching_indexes[], then delegate + // to the corresponding method above. Eliminates repeated boilerplate in + // repeater/room/sensor examples. + ClientInfo* resolveClient(int peer_idx, const int* matching_indexes); + uint16_t peerNextAeadNonce(int peer_idx, const int* matching_indexes); + const uint8_t* peerSessionKey(int peer_idx, const int* matching_indexes); + const uint8_t* peerPrevSessionKey(int peer_idx, const int* matching_indexes); + void peerSessionKeyDecryptSuccess(int peer_idx, const int* matching_indexes); + const uint8_t* peerEncryptionKey(int peer_idx, const int* matching_indexes, const uint8_t* fallback); + uint16_t peerEncryptionNonce(int peer_idx, const int* matching_indexes); }; diff --git a/src/helpers/CommonCLI.cpp b/src/helpers/CommonCLI.cpp index 41fa8e13d..d93a08902 100644 --- a/src/helpers/CommonCLI.cpp +++ b/src/helpers/CommonCLI.cpp @@ -812,6 +812,8 @@ void CommonCLI::handleCommand(uint32_t sender_timestamp, const char* command, ch _callbacks->formatRadioStatsReply(reply); } else if (sender_timestamp == 0 && memcmp(command, "stats-core", 10) == 0 && (command[10] == 0 || command[10] == ' ')) { _callbacks->formatStatsReply(reply); + } else if (memcmp(command, "rekey", 5) == 0) { + strcpy(reply, "rekey is client-initiated"); } else { strcpy(reply, "Unknown command"); } diff --git a/src/helpers/ContactInfo.h b/src/helpers/ContactInfo.h index da2b8b1f4..df60aff54 100644 --- a/src/helpers/ContactInfo.h +++ b/src/helpers/ContactInfo.h @@ -27,6 +27,10 @@ struct ContactInfo { ++aead_nonce; // skip 0 (sentinel for ECB) MESH_DEBUG_PRINTLN("AEAD nonce wrapped for peer: %s", name); } + if (aead_nonce < NONCE_INITIAL_MIN) { + aead_nonce = 1; // stay stuck in exhaustion zone, always return ECB + return 0; + } return aead_nonce; } return 0; diff --git a/src/helpers/SessionKeyPool.h b/src/helpers/SessionKeyPool.h new file mode 100644 index 000000000..b2c168a61 --- /dev/null +++ b/src/helpers/SessionKeyPool.h @@ -0,0 +1,117 @@ +#pragma once + +#include +#include + +#define SESSION_STATE_NONE 0 +#define SESSION_STATE_INIT_SENT 1 // initiator: INIT sent, waiting for ACCEPT +#define SESSION_STATE_DUAL_DECODE 2 // responder: new key active, old key still valid +#define SESSION_STATE_ACTIVE 3 // session key confirmed and in use + +#define SESSION_FLAG_PREV_VALID 0x01 // prev_session_key is valid for dual-decode + +struct SessionKeyEntry { + uint8_t peer_pub_prefix[4]; // first 4 bytes of peer's public key + uint8_t session_key[SESSION_KEY_SIZE]; + uint8_t prev_session_key[SESSION_KEY_SIZE]; + uint16_t nonce; // session key nonce counter (starts at 1) + uint8_t state; // SESSION_STATE_* + uint8_t sends_since_last_recv; // RAM-only counter, threshold SESSION_KEY_STALE_THRESHOLD + uint8_t retries_left; // remaining INIT retries this round + unsigned long timeout_at; // millis timestamp for INIT timeout + uint8_t ephemeral_prv[PRV_KEY_SIZE]; // initiator-only: ephemeral private key (zeroed after use) + uint8_t ephemeral_pub[PUB_KEY_SIZE]; // initiator-only: ephemeral public key +}; + +class SessionKeyPool { + SessionKeyEntry entries[MAX_SESSION_KEYS]; + int count; + +public: + SessionKeyPool() : count(0) { + memset(entries, 0, sizeof(entries)); + } + + SessionKeyEntry* findByPrefix(const uint8_t* pub_key) { + for (int i = 0; i < count; i++) { + if (memcmp(entries[i].peer_pub_prefix, pub_key, 4) == 0) { + return &entries[i]; + } + } + return nullptr; + } + + SessionKeyEntry* allocate(const uint8_t* pub_key) { + // Check if already exists + auto existing = findByPrefix(pub_key); + if (existing) return existing; + + // Find free slot or evict oldest + if (count < MAX_SESSION_KEYS) { + auto e = &entries[count++]; + memset(e, 0, sizeof(*e)); + memcpy(e->peer_pub_prefix, pub_key, 4); + return e; + } + // Pool full — evict the entry with state NONE, or the first one + for (int i = 0; i < MAX_SESSION_KEYS; i++) { + if (entries[i].state == SESSION_STATE_NONE) { + memset(&entries[i], 0, sizeof(entries[i])); + memcpy(entries[i].peer_pub_prefix, pub_key, 4); + return &entries[i]; + } + } + // All slots active — evict first entry + memset(&entries[0], 0, sizeof(entries[0])); + memcpy(entries[0].peer_pub_prefix, pub_key, 4); + return &entries[0]; + } + + void remove(const uint8_t* pub_key) { + for (int i = 0; i < count; i++) { + if (memcmp(entries[i].peer_pub_prefix, pub_key, 4) == 0) { + // Shift remaining entries down + count--; + for (int j = i; j < count; j++) { + entries[j] = entries[j + 1]; + } + memset(&entries[count], 0, sizeof(entries[count])); + return; + } + } + } + + int getCount() const { return count; } + SessionKeyEntry* getByIdx(int idx) { return (idx >= 0 && idx < count) ? &entries[idx] : nullptr; } + + // Persistence helpers — 71-byte records: [pub_prefix:4][flags:1][nonce:2][session_key:32][prev_session_key:32] + // Returns false when idx is past end + bool getEntryForSave(int idx, uint8_t* pub_key_prefix, uint8_t* flags, uint16_t* nonce, + uint8_t* session_key, uint8_t* prev_session_key) { + if (idx >= count) return false; + auto& e = entries[idx]; + if (e.state == SESSION_STATE_NONE || e.state == SESSION_STATE_INIT_SENT) return false; // don't persist pending negotiations + memcpy(pub_key_prefix, e.peer_pub_prefix, 4); + *flags = (e.state == SESSION_STATE_DUAL_DECODE) ? SESSION_FLAG_PREV_VALID : 0; + *nonce = e.nonce; + memcpy(session_key, e.session_key, SESSION_KEY_SIZE); + memcpy(prev_session_key, e.prev_session_key, SESSION_KEY_SIZE); + return true; + } + + bool applyLoaded(const uint8_t* pub_key_prefix, uint8_t flags, uint16_t nonce, + const uint8_t* session_key, const uint8_t* prev_session_key) { + auto e = allocate(pub_key_prefix); + if (!e) return false; + e->nonce = nonce; + e->state = (flags & SESSION_FLAG_PREV_VALID) ? SESSION_STATE_DUAL_DECODE : SESSION_STATE_ACTIVE; + e->sends_since_last_recv = 0; + e->retries_left = 0; + e->timeout_at = 0; + memcpy(e->session_key, session_key, SESSION_KEY_SIZE); + memcpy(e->prev_session_key, prev_session_key, SESSION_KEY_SIZE); + memset(e->ephemeral_prv, 0, sizeof(e->ephemeral_prv)); + memset(e->ephemeral_pub, 0, sizeof(e->ephemeral_pub)); + return true; + } +}; From 131af5b722b6e94066dc1f06ec29cf4a8c5b7326 Mon Sep 17 00:00:00 2001 From: Wessel Nieboer Date: Sat, 14 Feb 2026 13:39:52 +0100 Subject: [PATCH 20/56] Allow persisting more session to flash --- examples/companion_radio/DataStore.cpp | 114 ++++++++++++--- examples/companion_radio/DataStore.h | 4 + examples/companion_radio/MyMesh.cpp | 8 ++ examples/companion_radio/MyMesh.h | 11 +- src/MeshCore.h | 6 +- src/helpers/BaseChatMesh.cpp | 57 ++++++-- src/helpers/BaseChatMesh.h | 12 ++ src/helpers/ClientACL.cpp | 192 ++++++++++++++++++++----- src/helpers/ClientACL.h | 10 ++ src/helpers/SessionKeyPool.h | 72 +++++++--- 10 files changed, 394 insertions(+), 92 deletions(-) diff --git a/examples/companion_radio/DataStore.cpp b/examples/companion_radio/DataStore.cpp index 733d3ea4b..6d6abfe15 100644 --- a/examples/companion_radio/DataStore.cpp +++ b/examples/companion_radio/DataStore.cpp @@ -407,37 +407,105 @@ bool DataStore::saveNonces(DataStoreHost* host) { void DataStore::loadSessionKeys(DataStoreHost* host) { File file = openRead(_getContactsChannelsFS(), "/sess_keys"); - if (file) { - uint8_t rec[71]; // 4-byte pub_key prefix + 1 flags + 2 nonce + 32 session_key + 32 prev_session_key - while (file.read(rec, 71) == 71) { - uint16_t nonce; - memcpy(&nonce, &rec[5], 2); - host->onSessionKeyLoaded(rec, rec[4], nonce, &rec[7], &rec[39]); + if (!file) return; + while (true) { + uint8_t rec[SESSION_KEY_RECORD_MIN_SIZE]; + if (file.read(rec, SESSION_KEY_RECORD_MIN_SIZE) != SESSION_KEY_RECORD_MIN_SIZE) break; + uint8_t flags = rec[4]; + uint16_t nonce; + memcpy(&nonce, &rec[5], 2); + uint8_t prev_key[SESSION_KEY_SIZE]; + if (flags & SESSION_FLAG_PREV_VALID) { + if (file.read(prev_key, SESSION_KEY_SIZE) != SESSION_KEY_SIZE) break; + } else { + memset(prev_key, 0, SESSION_KEY_SIZE); } - file.close(); + host->onSessionKeyLoaded(rec, flags, nonce, &rec[7], prev_key); } + file.close(); } bool DataStore::saveSessionKeys(DataStoreHost* host) { - File file = openWrite(_getContactsChannelsFS(), "/sess_keys"); - if (file) { - uint8_t pub_key_prefix[4]; - uint8_t flags; - uint16_t nonce; - uint8_t session_key[32]; - uint8_t prev_session_key[32]; - for (int idx = 0; idx < MAX_SESSION_KEYS; idx++) { - if (host->getSessionKeyForSave(idx, pub_key_prefix, &flags, &nonce, session_key, prev_session_key)) { - file.write(pub_key_prefix, 4); - file.write(&flags, 1); - file.write((uint8_t*)&nonce, 2); - file.write(session_key, 32); - file.write(prev_session_key, 32); + FILESYSTEM* fs = _getContactsChannelsFS(); + + // 1. Read old flash file into buffer (variable-length records) + uint8_t old_buf[MAX_SESSION_KEYS_FLASH * SESSION_KEY_RECORD_SIZE]; + int old_len = 0; + File rf = openRead(fs, "/sess_keys"); + if (rf) { + while (true) { + if (old_len + SESSION_KEY_RECORD_MIN_SIZE > (int)sizeof(old_buf)) break; + if (rf.read(&old_buf[old_len], SESSION_KEY_RECORD_MIN_SIZE) != SESSION_KEY_RECORD_MIN_SIZE) break; + uint8_t flags = old_buf[old_len + 4]; + int rec_len = SESSION_KEY_RECORD_MIN_SIZE; + if (flags & SESSION_FLAG_PREV_VALID) { + if (old_len + SESSION_KEY_RECORD_SIZE > (int)sizeof(old_buf)) break; + if (rf.read(&old_buf[old_len + SESSION_KEY_RECORD_MIN_SIZE], SESSION_KEY_SIZE) != SESSION_KEY_SIZE) break; + rec_len = SESSION_KEY_RECORD_SIZE; } + old_len += rec_len; + } + rf.close(); + } + + // 2. Write merged file + File wf = openWrite(fs, "/sess_keys"); + if (!wf) return false; + + // Write kept old records (variable-length) + int pos = 0; + while (pos + SESSION_KEY_RECORD_MIN_SIZE <= old_len) { + uint8_t* rec = &old_buf[pos]; + uint8_t flags = rec[4]; + int rec_len = (flags & SESSION_FLAG_PREV_VALID) ? SESSION_KEY_RECORD_SIZE : SESSION_KEY_RECORD_MIN_SIZE; + if (pos + rec_len > old_len) break; + if (!host->isSessionKeyInRAM(rec) && !host->isSessionKeyRemoved(rec)) { + wf.write(rec, rec_len); + } + pos += rec_len; + } + // Write current RAM entries (variable-length) + for (int idx = 0; idx < MAX_SESSION_KEYS_RAM; idx++) { + uint8_t pk[4]; uint8_t fl; uint16_t n; uint8_t sk[32]; uint8_t psk[32]; + if (!host->getSessionKeyForSave(idx, pk, &fl, &n, sk, psk)) continue; + wf.write(pk, 4); wf.write(&fl, 1); wf.write((uint8_t*)&n, 2); + wf.write(sk, 32); + if (fl & SESSION_FLAG_PREV_VALID) { + wf.write(psk, 32); + } + } + wf.close(); + return true; +} + +bool DataStore::loadSessionKeyByPrefix(const uint8_t* prefix, + uint8_t* flags, uint16_t* nonce, uint8_t* session_key, uint8_t* prev_session_key) { + File f = openRead(_getContactsChannelsFS(), "/sess_keys"); + if (!f) return false; + while (true) { + uint8_t rec[SESSION_KEY_RECORD_MIN_SIZE]; + if (f.read(rec, SESSION_KEY_RECORD_MIN_SIZE) != SESSION_KEY_RECORD_MIN_SIZE) break; + uint8_t rec_flags = rec[4]; + bool has_prev = (rec_flags & SESSION_FLAG_PREV_VALID); + if (memcmp(rec, prefix, 4) == 0) { + *flags = rec_flags; + memcpy(nonce, &rec[5], 2); + memcpy(session_key, &rec[7], SESSION_KEY_SIZE); + if (has_prev) { + if (f.read(prev_session_key, SESSION_KEY_SIZE) != SESSION_KEY_SIZE) break; + } else { + memset(prev_session_key, 0, SESSION_KEY_SIZE); + } + f.close(); + return true; + } + // Skip prev_key if present + if (has_prev) { + uint8_t skip[SESSION_KEY_SIZE]; + if (f.read(skip, SESSION_KEY_SIZE) != SESSION_KEY_SIZE) break; } - file.close(); - return true; } + f.close(); return false; } diff --git a/examples/companion_radio/DataStore.h b/examples/companion_radio/DataStore.h index 5f4170530..011ce5a3d 100644 --- a/examples/companion_radio/DataStore.h +++ b/examples/companion_radio/DataStore.h @@ -17,6 +17,8 @@ class DataStoreHost { const uint8_t* session_key, const uint8_t* prev_session_key) { return false; } virtual bool getSessionKeyForSave(int idx, uint8_t* pub_key_prefix, uint8_t* flags, uint16_t* nonce, uint8_t* session_key, uint8_t* prev_session_key) { return false; } + virtual bool isSessionKeyInRAM(const uint8_t* pub_key_prefix) { return false; } + virtual bool isSessionKeyRemoved(const uint8_t* pub_key_prefix) { return false; } }; class DataStore { @@ -49,6 +51,8 @@ class DataStore { bool saveNonces(DataStoreHost* host); void loadSessionKeys(DataStoreHost* host); bool saveSessionKeys(DataStoreHost* host); + bool loadSessionKeyByPrefix(const uint8_t* prefix, + uint8_t* flags, uint16_t* nonce, uint8_t* session_key, uint8_t* prev_session_key); void migrateToSecondaryFS(); uint8_t getBlobByKey(const uint8_t key[], int key_len, uint8_t dest_buf[]); bool putBlobByKey(const uint8_t key[], int key_len, const uint8_t src_buf[], uint8_t len); diff --git a/examples/companion_radio/MyMesh.cpp b/examples/companion_radio/MyMesh.cpp index 5eabec9ed..bcb554489 100644 --- a/examples/companion_radio/MyMesh.cpp +++ b/examples/companion_radio/MyMesh.cpp @@ -2040,6 +2040,14 @@ void MyMesh::checkSerialInterface() { } } +bool MyMesh::isSessionKeyInRAM(const uint8_t* pub_key_prefix) { + return isSessionKeyInRAMPool(pub_key_prefix); +} + +bool MyMesh::isSessionKeyRemoved(const uint8_t* pub_key_prefix) { + return isSessionKeyRemovedFromPool(pub_key_prefix); +} + void MyMesh::loop() { BaseChatMesh::loop(); diff --git a/examples/companion_radio/MyMesh.h b/examples/companion_radio/MyMesh.h index 2f38277bd..01ff3ebf9 100644 --- a/examples/companion_radio/MyMesh.h +++ b/examples/companion_radio/MyMesh.h @@ -164,6 +164,8 @@ class MyMesh : public BaseChatMesh, public DataStoreHost { uint8_t* session_key, uint8_t* prev_session_key) override { return getSessionKeyEntry(idx, pub_key_prefix, flags, nonce, session_key, prev_session_key); } + bool isSessionKeyInRAM(const uint8_t* pub_key_prefix) override; + bool isSessionKeyRemoved(const uint8_t* pub_key_prefix) override; void clearPendingReqs() { pending_login = pending_status = pending_telemetry = pending_discovery = pending_req = 0; @@ -195,9 +197,16 @@ class MyMesh : public BaseChatMesh, public DataStoreHost { void saveChannels() { _store->saveChannels(this); } void saveContacts() { _store->saveContacts(this); } void saveNonces() { if (_store->saveNonces(this)) clearNonceDirty(); } - void saveSessionKeys() { if (_store->saveSessionKeys(this)) clearSessionKeysDirty(); } + void saveSessionKeys() { if (_store->saveSessionKeys(this)) { clearSessionKeysDirty(); clearSessionKeysRemoved(); } } void onSessionKeysUpdated() override { saveSessionKeys(); } + // Flash-backed session key overrides + bool loadSessionKeyRecordFromFlash(const uint8_t* pub_key_prefix, + uint8_t* flags, uint16_t* nonce, uint8_t* session_key, uint8_t* prev_session_key) override { + return _store->loadSessionKeyByPrefix(pub_key_prefix, flags, nonce, session_key, prev_session_key); + } + void mergeAndSaveSessionKeys() override { saveSessionKeys(); } + DataStore* _store; NodePrefs _prefs; uint32_t pending_login; diff --git a/src/MeshCore.h b/src/MeshCore.h index 841a7ce72..011b63485 100644 --- a/src/MeshCore.h +++ b/src/MeshCore.h @@ -36,7 +36,11 @@ #define NONCE_INITIAL_MAX 50000 // max random nonce seed for new contacts #define SESSION_KEY_TIMEOUT_MS 180000 // 3 minutes per attempt #define SESSION_KEY_MAX_RETRIES 3 // attempts per negotiation round -#define MAX_SESSION_KEYS 8 // max concurrent session key entries +#define MAX_SESSION_KEYS_RAM 8 // max concurrent session key entries in RAM (LRU cache) +#define MAX_SESSION_KEYS_FLASH 48 // max entries in flash file +#define SESSION_KEY_RECORD_SIZE 71 // max bytes per record (with prev_key) +#define SESSION_KEY_RECORD_MIN_SIZE 39 // min bytes per record: [pub_prefix:4][flags:1][nonce:2][key:32] +#define SESSION_FLAG_PREV_VALID 0x01 // prev_session_key is valid for dual-decode #define SESSION_KEY_STALE_THRESHOLD 50 // sends without recv before fallback to static ECDH #define SESSION_KEY_ECB_THRESHOLD 100 // sends without recv before fallback to ECB #define SESSION_KEY_ABANDON_THRESHOLD 255 // sends without recv before clearing AEAD + session key diff --git a/src/helpers/BaseChatMesh.cpp b/src/helpers/BaseChatMesh.cpp index b5f88d54e..03bc5ea7c 100644 --- a/src/helpers/BaseChatMesh.cpp +++ b/src/helpers/BaseChatMesh.cpp @@ -900,7 +900,7 @@ bool BaseChatMesh::removeContact(ContactInfo& contact) { } if (idx >= num_contacts) return false; // not found - session_keys.remove(contact.id.pub_key); // also remove session key if any + removeSessionKey(contact.id.pub_key); // also remove session key if any // remove from contacts array and parallel nonce tracking num_contacts--; @@ -1015,6 +1015,41 @@ void BaseChatMesh::loop() { } } +// --- Session key flash-backed wrappers --- + +SessionKeyEntry* BaseChatMesh::findSessionKey(const uint8_t* pub_key) { + auto entry = session_keys.findByPrefix(pub_key); + if (entry) return entry; + + // Cache miss — try flash + uint8_t flags; uint16_t nonce; + uint8_t sk[SESSION_KEY_SIZE], psk[SESSION_KEY_SIZE]; + if (!loadSessionKeyRecordFromFlash(pub_key, &flags, &nonce, sk, psk)) return nullptr; + + // Save dirty evictee before overwriting + if (session_keys.isFull() && session_keys_dirty) { + mergeAndSaveSessionKeys(); + } + session_keys.applyLoaded(pub_key, flags, nonce, sk, psk); + return session_keys.findByPrefix(pub_key); +} + +SessionKeyEntry* BaseChatMesh::allocateSessionKey(const uint8_t* pub_key) { + // Check RAM and flash first + auto entry = findSessionKey(pub_key); + if (entry) return entry; + + // Not found anywhere — save dirty evictee before allocating + if (session_keys.isFull() && session_keys_dirty) { + mergeAndSaveSessionKeys(); + } + return session_keys.allocate(pub_key); +} + +void BaseChatMesh::removeSessionKey(const uint8_t* pub_key) { + session_keys.remove(pub_key); +} + // --- Session key support (Phase 2 — initiator) --- static bool canUseSessionKey(const SessionKeyEntry* entry) { @@ -1030,7 +1065,7 @@ static bool canUseSessionKey(const SessionKeyEntry* entry) { } const uint8_t* BaseChatMesh::getEncryptionKeyFor(const ContactInfo& contact) { - auto entry = session_keys.findByPrefix(contact.id.pub_key); + auto entry = findSessionKey(contact.id.pub_key); if (canUseSessionKey(entry)) { return entry->session_key; } @@ -1039,7 +1074,7 @@ const uint8_t* BaseChatMesh::getEncryptionKeyFor(const ContactInfo& contact) { uint16_t BaseChatMesh::getEncryptionNonceFor(const ContactInfo& contact) { uint16_t nonce = 0; - auto entry = session_keys.findByPrefix(contact.id.pub_key); + auto entry = findSessionKey(contact.id.pub_key); if (canUseSessionKey(entry)) { ++entry->nonce; if (entry->sends_since_last_recv < 255) entry->sends_since_last_recv++; @@ -1053,7 +1088,7 @@ uint16_t BaseChatMesh::getEncryptionNonceFor(const ContactInfo& contact) { int idx = &contact - contacts; if (idx >= 0 && idx < num_contacts) contacts[idx].flags &= ~CONTACT_FLAG_AEAD; - session_keys.remove(contact.id.pub_key); + removeSessionKey(contact.id.pub_key); onSessionKeysUpdated(); // nonce = 0 (ECB) } else if (entry->sends_since_last_recv >= SESSION_KEY_ECB_THRESHOLD) { @@ -1082,7 +1117,7 @@ bool BaseChatMesh::shouldInitiateSessionKey(const ContactInfo& contact) { // Need a known path to send the request if (contact.out_path_len < 0) return false; - auto entry = session_keys.findByPrefix(contact.id.pub_key); + auto entry = findSessionKey(contact.id.pub_key); // Don't trigger if negotiation already in progress if (entry && entry->state == SESSION_STATE_INIT_SENT) return false; @@ -1118,7 +1153,7 @@ bool BaseChatMesh::shouldInitiateSessionKey(const ContactInfo& contact) { } bool BaseChatMesh::initiateSessionKeyNegotiation(const ContactInfo& contact) { - auto entry = session_keys.allocate(contact.id.pub_key); + auto entry = allocateSessionKey(contact.id.pub_key); if (!entry) return false; // Don't start a new negotiation if one is already pending @@ -1154,7 +1189,7 @@ bool BaseChatMesh::handleSessionKeyResponse(ContactInfo& contact, const uint8_t* if (len < 5 + PUB_KEY_SIZE) return false; if (data[4] != RESP_TYPE_SESSION_KEY_ACCEPT) return false; - auto entry = session_keys.findByPrefix(contact.id.pub_key); + auto entry = findSessionKey(contact.id.pub_key); if (!entry || entry->state != SESSION_STATE_INIT_SENT) return false; const uint8_t* ephemeral_pub_B = &data[5]; @@ -1216,7 +1251,7 @@ uint8_t BaseChatMesh::handleIncomingSessionKeyInit(ContactInfo& from, const uint memset(ephemeral_secret, 0, PUB_KEY_SIZE); // 4. Store in pool (dual-decode: new key active, old key still valid) - auto entry = session_keys.allocate(from.id.pub_key); + auto entry = allocateSessionKey(from.id.pub_key); if (!entry) return 0; if (entry->state == SESSION_STATE_ACTIVE || entry->state == SESSION_STATE_DUAL_DECODE) { @@ -1279,7 +1314,7 @@ void BaseChatMesh::checkSessionKeyTimeouts() { const uint8_t* BaseChatMesh::getPeerSessionKey(int peer_idx) { int i = matching_peer_indexes[peer_idx]; if (i >= 0 && i < num_contacts) { - auto entry = session_keys.findByPrefix(contacts[i].id.pub_key); + auto entry = findSessionKey(contacts[i].id.pub_key); // Also try decode during INIT_SENT renegotiation (nonce > 1 means prior key exists) if (entry && (entry->state == SESSION_STATE_ACTIVE || entry->state == SESSION_STATE_DUAL_DECODE || (entry->state == SESSION_STATE_INIT_SENT && entry->nonce > 1))) @@ -1291,7 +1326,7 @@ const uint8_t* BaseChatMesh::getPeerSessionKey(int peer_idx) { const uint8_t* BaseChatMesh::getPeerPrevSessionKey(int peer_idx) { int i = matching_peer_indexes[peer_idx]; if (i >= 0 && i < num_contacts) { - auto entry = session_keys.findByPrefix(contacts[i].id.pub_key); + auto entry = findSessionKey(contacts[i].id.pub_key); if (entry && entry->state == SESSION_STATE_DUAL_DECODE) return entry->prev_session_key; } @@ -1301,7 +1336,7 @@ const uint8_t* BaseChatMesh::getPeerPrevSessionKey(int peer_idx) { void BaseChatMesh::onSessionKeyDecryptSuccess(int peer_idx) { int i = matching_peer_indexes[peer_idx]; if (i >= 0 && i < num_contacts) { - auto entry = session_keys.findByPrefix(contacts[i].id.pub_key); + auto entry = findSessionKey(contacts[i].id.pub_key); if (entry) { bool changed = (entry->state == SESSION_STATE_DUAL_DECODE); if (changed) { diff --git a/src/helpers/BaseChatMesh.h b/src/helpers/BaseChatMesh.h index 24e14ffdf..5578b197b 100644 --- a/src/helpers/BaseChatMesh.h +++ b/src/helpers/BaseChatMesh.h @@ -148,6 +148,15 @@ class BaseChatMesh : public mesh::Mesh { // Session key support (Phase 2 — initiator) virtual void onSessionKeysUpdated() { session_keys_dirty = true; } // called when session key pool changes; override to persist + virtual bool loadSessionKeyRecordFromFlash(const uint8_t* pub_key_prefix, + uint8_t* flags, uint16_t* nonce, uint8_t* session_key, uint8_t* prev_session_key) { return false; } + virtual void mergeAndSaveSessionKeys() {} // merge RAM + flash, write back + + // Wrappers that add flash fallback on cache miss + SessionKeyEntry* findSessionKey(const uint8_t* pub_key); + SessionKeyEntry* allocateSessionKey(const uint8_t* pub_key); + void removeSessionKey(const uint8_t* pub_key); + const uint8_t* getEncryptionKeyFor(const ContactInfo& contact); uint16_t getEncryptionNonceFor(const ContactInfo& contact); bool shouldInitiateSessionKey(const ContactInfo& contact); @@ -164,6 +173,9 @@ class BaseChatMesh : public mesh::Mesh { int getSessionKeyCount() const { return session_keys.getCount(); } bool isSessionKeysDirty() const { return session_keys_dirty; } void clearSessionKeysDirty() { session_keys_dirty = false; } + bool isSessionKeyInRAMPool(const uint8_t* pub_key_prefix) { return session_keys.hasPrefix(pub_key_prefix); } + bool isSessionKeyRemovedFromPool(const uint8_t* pub_key_prefix) { return session_keys.isRemoved(pub_key_prefix); } + void clearSessionKeysRemoved() { session_keys.clearRemoved(); } // Mesh overrides void onAdvertRecv(mesh::Packet* packet, const mesh::Identity& id, uint32_t timestamp, const uint8_t* app_data, size_t app_data_len) override; diff --git a/src/helpers/ClientACL.cpp b/src/helpers/ClientACL.cpp index 1abfa9fa4..f3cda385a 100644 --- a/src/helpers/ClientACL.cpp +++ b/src/helpers/ClientACL.cpp @@ -214,7 +214,7 @@ bool ClientACL::applyPermissions(const mesh::LocalIdentity& self_id, const uint8 c = getClient(pubkey, key_len); if (c == NULL) return false; // partial pubkey not found - session_keys.remove(c->id.pub_key); // also remove session key if any + removeSessionKey(c->id.pub_key); // also remove session key if any num_clients--; // delete from contacts[] int i = c - clients; @@ -262,7 +262,7 @@ int ClientACL::handleSessionKeyInit(const ClientInfo* client, const uint8_t* eph memset(ephemeral_secret, 0, PUB_KEY_SIZE); // 4. Store in pool (dual-decode: new key active, old key still valid) - auto entry = session_keys.allocate(client->id.pub_key); + auto entry = allocateSessionKey(client->id.pub_key); if (!entry) return 0; if (entry->state == SESSION_STATE_ACTIVE || entry->state == SESSION_STATE_DUAL_DECODE) { @@ -283,7 +283,7 @@ int ClientACL::handleSessionKeyInit(const ClientInfo* client, const uint8_t* eph } const uint8_t* ClientACL::getSessionKey(const uint8_t* pub_key) { - auto entry = session_keys.findByPrefix(pub_key); + auto entry = findSessionKey(pub_key); if (entry && (entry->state == SESSION_STATE_ACTIVE || entry->state == SESSION_STATE_DUAL_DECODE)) { return entry->session_key; } @@ -291,7 +291,7 @@ const uint8_t* ClientACL::getSessionKey(const uint8_t* pub_key) { } const uint8_t* ClientACL::getPrevSessionKey(const uint8_t* pub_key) { - auto entry = session_keys.findByPrefix(pub_key); + auto entry = findSessionKey(pub_key); if (entry && entry->state == SESSION_STATE_DUAL_DECODE) { return entry->prev_session_key; } @@ -299,7 +299,7 @@ const uint8_t* ClientACL::getPrevSessionKey(const uint8_t* pub_key) { } const uint8_t* ClientACL::getEncryptionKey(const ClientInfo& client) { - auto entry = session_keys.findByPrefix(client.id.pub_key); + auto entry = findSessionKey(client.id.pub_key); if (entry && (entry->state == SESSION_STATE_ACTIVE || entry->state == SESSION_STATE_DUAL_DECODE) && entry->sends_since_last_recv < SESSION_KEY_STALE_THRESHOLD && entry->nonce < 65535) { @@ -309,7 +309,7 @@ const uint8_t* ClientACL::getEncryptionKey(const ClientInfo& client) { } uint16_t ClientACL::getEncryptionNonce(const ClientInfo& client) { - auto entry = session_keys.findByPrefix(client.id.pub_key); + auto entry = findSessionKey(client.id.pub_key); if (entry && (entry->state == SESSION_STATE_ACTIVE || entry->state == SESSION_STATE_DUAL_DECODE) && entry->sends_since_last_recv < SESSION_KEY_STALE_THRESHOLD && entry->nonce < 65535) { @@ -325,7 +325,7 @@ uint16_t ClientACL::getEncryptionNonce(const ClientInfo& client) { int idx = &client - clients; if (idx >= 0 && idx < num_clients) clients[idx].flags &= ~CONTACT_FLAG_AEAD; - session_keys.remove(client.id.pub_key); + removeSessionKey(client.id.pub_key); saveSessionKeys(); return 0; // ECB } @@ -337,7 +337,7 @@ uint16_t ClientACL::getEncryptionNonce(const ClientInfo& client) { } void ClientACL::onSessionConfirmed(const uint8_t* pub_key) { - auto entry = session_keys.findByPrefix(pub_key); + auto entry = findSessionKey(pub_key); if (entry) { if (entry->state == SESSION_STATE_DUAL_DECODE) { memset(entry->prev_session_key, 0, SESSION_KEY_SIZE); @@ -386,46 +386,160 @@ uint16_t ClientACL::peerEncryptionNonce(int peer_idx, const int* matching_indexe return c ? getEncryptionNonce(*c) : 0; } -void ClientACL::loadSessionKeys() { - if (!_fs) return; -#if defined(RP2040_PLATFORM) - File file = _fs->open("/s_sess_keys", "r"); -#elif defined(NRF52_PLATFORM) || defined(STM32_PLATFORM) - File file = _fs->open("/s_sess_keys", FILE_O_READ); +// --- Flash-backed session key wrappers --- + +static File openReadACL(FILESYSTEM* fs, const char* filename) { +#if defined(NRF52_PLATFORM) || defined(STM32_PLATFORM) + return fs->open(filename, FILE_O_READ); +#elif defined(RP2040_PLATFORM) + return fs->open(filename, "r"); #else - File file = _fs->open("/s_sess_keys", "r", false); + return fs->open(filename, "r", false); #endif - if (file) { - uint8_t rec[71]; // [pub_prefix:4][flags:1][nonce:2][session_key:32][prev_session_key:32] - while (file.read(rec, 71) == 71) { - uint8_t flags = rec[4]; - uint16_t nonce; - memcpy(&nonce, &rec[5], 2); - session_keys.applyLoaded(rec, flags, nonce, &rec[7], &rec[39]); +} + +bool ClientACL::loadSessionKeyRecordFromFlash(const uint8_t* prefix, + uint8_t* flags, uint16_t* nonce, uint8_t* session_key, uint8_t* prev_session_key) { + if (!_fs) return false; + File f = openReadACL(_fs, "/s_sess_keys"); + if (!f) return false; + while (true) { + uint8_t rec[SESSION_KEY_RECORD_MIN_SIZE]; + if (f.read(rec, SESSION_KEY_RECORD_MIN_SIZE) != SESSION_KEY_RECORD_MIN_SIZE) break; + uint8_t rec_flags = rec[4]; + bool has_prev = (rec_flags & SESSION_FLAG_PREV_VALID); + if (memcmp(rec, prefix, 4) == 0) { + *flags = rec_flags; + memcpy(nonce, &rec[5], 2); + memcpy(session_key, &rec[7], SESSION_KEY_SIZE); + if (has_prev) { + if (f.read(prev_session_key, SESSION_KEY_SIZE) != SESSION_KEY_SIZE) break; + } else { + memset(prev_session_key, 0, SESSION_KEY_SIZE); + } + f.close(); + return true; } - file.close(); + // Skip prev_key if present + if (has_prev) { + uint8_t skip[SESSION_KEY_SIZE]; + if (f.read(skip, SESSION_KEY_SIZE) != SESSION_KEY_SIZE) break; + } + } + f.close(); + return false; +} + +SessionKeyEntry* ClientACL::findSessionKey(const uint8_t* pub_key) { + auto entry = session_keys.findByPrefix(pub_key); + if (entry) return entry; + + // Cache miss — try flash + uint8_t flags; uint16_t nonce; + uint8_t sk[SESSION_KEY_SIZE], psk[SESSION_KEY_SIZE]; + if (!loadSessionKeyRecordFromFlash(pub_key, &flags, &nonce, sk, psk)) return nullptr; + + // Save dirty evictee before overwriting + if (session_keys.isFull() && _session_keys_dirty) { + saveSessionKeys(); + } + session_keys.applyLoaded(pub_key, flags, nonce, sk, psk); + return session_keys.findByPrefix(pub_key); +} + +SessionKeyEntry* ClientACL::allocateSessionKey(const uint8_t* pub_key) { + auto entry = findSessionKey(pub_key); + if (entry) return entry; + + // Not found anywhere — save dirty evictee before allocating + if (session_keys.isFull() && _session_keys_dirty) { + saveSessionKeys(); + } + return session_keys.allocate(pub_key); +} + +void ClientACL::removeSessionKey(const uint8_t* pub_key) { + session_keys.remove(pub_key); +} + +void ClientACL::loadSessionKeys() { + if (!_fs) return; + File file = openReadACL(_fs, "/s_sess_keys"); + if (!file) return; + while (true) { + uint8_t rec[SESSION_KEY_RECORD_MIN_SIZE]; + if (file.read(rec, SESSION_KEY_RECORD_MIN_SIZE) != SESSION_KEY_RECORD_MIN_SIZE) break; + uint8_t flags = rec[4]; + uint16_t nonce; + memcpy(&nonce, &rec[5], 2); + uint8_t prev_key[SESSION_KEY_SIZE]; + if (flags & SESSION_FLAG_PREV_VALID) { + if (file.read(prev_key, SESSION_KEY_SIZE) != SESSION_KEY_SIZE) break; + } else { + memset(prev_key, 0, SESSION_KEY_SIZE); + } + session_keys.applyLoaded(rec, flags, nonce, &rec[7], prev_key); } + file.close(); } void ClientACL::saveSessionKeys() { - _session_keys_dirty = false; if (!_fs) return; - File file = openWrite(_fs, "/s_sess_keys"); - if (file) { - for (int i = 0; i < session_keys.getCount(); i++) { - uint8_t pub_key_prefix[4]; - uint8_t flags; - uint16_t nonce; - uint8_t session_key[SESSION_KEY_SIZE]; - uint8_t prev_session_key[SESSION_KEY_SIZE]; - if (session_keys.getEntryForSave(i, pub_key_prefix, &flags, &nonce, session_key, prev_session_key)) { - file.write(pub_key_prefix, 4); - file.write(&flags, 1); - file.write((uint8_t*)&nonce, 2); - file.write(session_key, SESSION_KEY_SIZE); - file.write(prev_session_key, SESSION_KEY_SIZE); + + // 1. Read old flash file into buffer (variable-length records) + uint8_t old_buf[MAX_SESSION_KEYS_FLASH * SESSION_KEY_RECORD_SIZE]; + int old_len = 0; + File rf = openReadACL(_fs, "/s_sess_keys"); + if (rf) { + while (true) { + if (old_len + SESSION_KEY_RECORD_MIN_SIZE > (int)sizeof(old_buf)) break; + if (rf.read(&old_buf[old_len], SESSION_KEY_RECORD_MIN_SIZE) != SESSION_KEY_RECORD_MIN_SIZE) break; + uint8_t flags = old_buf[old_len + 4]; + int rec_len = SESSION_KEY_RECORD_MIN_SIZE; + if (flags & SESSION_FLAG_PREV_VALID) { + if (old_len + SESSION_KEY_RECORD_SIZE > (int)sizeof(old_buf)) break; + if (rf.read(&old_buf[old_len + SESSION_KEY_RECORD_MIN_SIZE], SESSION_KEY_SIZE) != SESSION_KEY_SIZE) break; + rec_len = SESSION_KEY_RECORD_SIZE; } + old_len += rec_len; } - file.close(); + rf.close(); } + + // 2. Write merged file + File wf = openWrite(_fs, "/s_sess_keys"); + if (!wf) return; + + // Write kept old records (variable-length) + int pos = 0; + while (pos + SESSION_KEY_RECORD_MIN_SIZE <= old_len) { + uint8_t* rec = &old_buf[pos]; + uint8_t flags = rec[4]; + int rec_len = (flags & SESSION_FLAG_PREV_VALID) ? SESSION_KEY_RECORD_SIZE : SESSION_KEY_RECORD_MIN_SIZE; + if (pos + rec_len > old_len) break; + if (!session_keys.hasPrefix(rec) && !session_keys.isRemoved(rec)) { + wf.write(rec, rec_len); + } + pos += rec_len; + } + // Write current RAM entries (variable-length) + for (int i = 0; i < session_keys.getCount(); i++) { + uint8_t pub_key_prefix[4]; + uint8_t flags; + uint16_t nonce; + uint8_t session_key[SESSION_KEY_SIZE]; + uint8_t prev_session_key[SESSION_KEY_SIZE]; + if (session_keys.getEntryForSave(i, pub_key_prefix, &flags, &nonce, session_key, prev_session_key)) { + wf.write(pub_key_prefix, 4); + wf.write(&flags, 1); + wf.write((uint8_t*)&nonce, 2); + wf.write(session_key, SESSION_KEY_SIZE); + if (flags & SESSION_FLAG_PREV_VALID) { + wf.write(prev_session_key, SESSION_KEY_SIZE); + } + } + } + wf.close(); + _session_keys_dirty = false; + session_keys.clearRemoved(); } diff --git a/src/helpers/ClientACL.h b/src/helpers/ClientACL.h index 455c7ccef..b47e18330 100644 --- a/src/helpers/ClientACL.h +++ b/src/helpers/ClientACL.h @@ -105,6 +105,16 @@ class ClientACL { void loadSessionKeys(); void saveSessionKeys(); +private: + // Flash-backed session key wrappers + SessionKeyEntry* findSessionKey(const uint8_t* pub_key); + SessionKeyEntry* allocateSessionKey(const uint8_t* pub_key); + void removeSessionKey(const uint8_t* pub_key); + bool loadSessionKeyRecordFromFlash(const uint8_t* pub_key_prefix, + uint8_t* flags, uint16_t* nonce, uint8_t* session_key, uint8_t* prev_session_key); + +public: + // Peer-index forwarding helpers for server-side Mesh overrides. // These resolve peer_idx → ClientInfo via matching_indexes[], then delegate // to the corresponding method above. Eliminates repeated boilerplate in diff --git a/src/helpers/SessionKeyPool.h b/src/helpers/SessionKeyPool.h index b2c168a61..d1f100fc0 100644 --- a/src/helpers/SessionKeyPool.h +++ b/src/helpers/SessionKeyPool.h @@ -8,7 +8,6 @@ #define SESSION_STATE_DUAL_DECODE 2 // responder: new key active, old key still valid #define SESSION_STATE_ACTIVE 3 // session key confirmed and in use -#define SESSION_FLAG_PREV_VALID 0x01 // prev_session_key is valid for dual-decode struct SessionKeyEntry { uint8_t peer_pub_prefix[4]; // first 4 bytes of peer's public key @@ -21,55 +20,85 @@ struct SessionKeyEntry { unsigned long timeout_at; // millis timestamp for INIT timeout uint8_t ephemeral_prv[PRV_KEY_SIZE]; // initiator-only: ephemeral private key (zeroed after use) uint8_t ephemeral_pub[PUB_KEY_SIZE]; // initiator-only: ephemeral public key + uint32_t last_used; // LRU counter (higher = more recent) }; class SessionKeyPool { - SessionKeyEntry entries[MAX_SESSION_KEYS]; + SessionKeyEntry entries[MAX_SESSION_KEYS_RAM]; int count; + uint32_t lru_counter; + + // Track prefixes removed since last save, so merge-save doesn't resurrect them + uint8_t removed_prefixes[MAX_SESSION_KEYS_RAM][4]; + int removed_count; + + void touch(SessionKeyEntry* entry) { + entry->last_used = ++lru_counter; + } public: - SessionKeyPool() : count(0) { + SessionKeyPool() : count(0), lru_counter(0), removed_count(0) { memset(entries, 0, sizeof(entries)); + memset(removed_prefixes, 0, sizeof(removed_prefixes)); } + bool isFull() const { return count >= MAX_SESSION_KEYS_RAM; } + SessionKeyEntry* findByPrefix(const uint8_t* pub_key) { for (int i = 0; i < count; i++) { if (memcmp(entries[i].peer_pub_prefix, pub_key, 4) == 0) { + touch(&entries[i]); return &entries[i]; } } return nullptr; } + // Lookup without updating LRU — use during save/merge to avoid perturbing eviction order + bool hasPrefix(const uint8_t* pub_key) const { + for (int i = 0; i < count; i++) { + if (memcmp(entries[i].peer_pub_prefix, pub_key, 4) == 0) return true; + } + return false; + } + SessionKeyEntry* allocate(const uint8_t* pub_key) { // Check if already exists auto existing = findByPrefix(pub_key); if (existing) return existing; - // Find free slot or evict oldest - if (count < MAX_SESSION_KEYS) { + // Find free slot + if (count < MAX_SESSION_KEYS_RAM) { auto e = &entries[count++]; memset(e, 0, sizeof(*e)); memcpy(e->peer_pub_prefix, pub_key, 4); + touch(e); return e; } - // Pool full — evict the entry with state NONE, or the first one - for (int i = 0; i < MAX_SESSION_KEYS; i++) { - if (entries[i].state == SESSION_STATE_NONE) { - memset(&entries[i], 0, sizeof(entries[i])); - memcpy(entries[i].peer_pub_prefix, pub_key, 4); - return &entries[i]; + // Pool full — LRU eviction, skip INIT_SENT entries (ephemeral keys are RAM-only) + int evict_idx = -1; + uint32_t min_used = 0xFFFFFFFF; + for (int i = 0; i < MAX_SESSION_KEYS_RAM; i++) { + if (entries[i].state == SESSION_STATE_INIT_SENT) continue; + if (entries[i].last_used < min_used) { + min_used = entries[i].last_used; + evict_idx = i; } } - // All slots active — evict first entry - memset(&entries[0], 0, sizeof(entries[0])); - memcpy(entries[0].peer_pub_prefix, pub_key, 4); - return &entries[0]; + if (evict_idx < 0) evict_idx = 0; // all INIT_SENT — shouldn't happen, fall back to [0] + memset(&entries[evict_idx], 0, sizeof(entries[evict_idx])); + memcpy(entries[evict_idx].peer_pub_prefix, pub_key, 4); + touch(&entries[evict_idx]); + return &entries[evict_idx]; } void remove(const uint8_t* pub_key) { for (int i = 0; i < count; i++) { if (memcmp(entries[i].peer_pub_prefix, pub_key, 4) == 0) { + // Track removed prefix for merge-save + if (removed_count < MAX_SESSION_KEYS_RAM) { + memcpy(removed_prefixes[removed_count++], entries[i].peer_pub_prefix, 4); + } // Shift remaining entries down count--; for (int j = i; j < count; j++) { @@ -81,11 +110,20 @@ class SessionKeyPool { } } + bool isRemoved(const uint8_t* pub_key_prefix) const { + for (int i = 0; i < removed_count; i++) { + if (memcmp(removed_prefixes[i], pub_key_prefix, 4) == 0) return true; + } + return false; + } + + void clearRemoved() { removed_count = 0; } + int getCount() const { return count; } SessionKeyEntry* getByIdx(int idx) { return (idx >= 0 && idx < count) ? &entries[idx] : nullptr; } - // Persistence helpers — 71-byte records: [pub_prefix:4][flags:1][nonce:2][session_key:32][prev_session_key:32] - // Returns false when idx is past end + // Persistence helpers — variable-length records: [pub_prefix:4][flags:1][nonce:2][session_key:32][prev_session_key:32 if flags & PREV_VALID] + // Returns false when idx is past end or entry is not persistable bool getEntryForSave(int idx, uint8_t* pub_key_prefix, uint8_t* flags, uint16_t* nonce, uint8_t* session_key, uint8_t* prev_session_key) { if (idx >= count) return false; From c0096070e7207f7a3522f524fb19e0e386082051 Mon Sep 17 00:00:00 2001 From: Wessel Nieboer Date: Sat, 10 Jan 2026 16:42:39 +0100 Subject: [PATCH 21/56] Backup contacts to tmpFile before saving --- examples/companion_radio/DataStore.cpp | 86 +++++++++++++++++--------- 1 file changed, 58 insertions(+), 28 deletions(-) diff --git a/examples/companion_radio/DataStore.cpp b/examples/companion_radio/DataStore.cpp index fba64e8c6..7a5c9072c 100644 --- a/examples/companion_radio/DataStore.cpp +++ b/examples/companion_radio/DataStore.cpp @@ -271,42 +271,57 @@ void DataStore::savePrefs(const NodePrefs& _prefs, double node_lat, double node_ } void DataStore::loadContacts(DataStoreHost* host) { -File file = openRead(_getContactsChannelsFS(), "/contacts3"); - if (file) { - bool full = false; - while (!full) { - ContactInfo c; - uint8_t pub_key[32]; - uint8_t unused; - - bool success = (file.read(pub_key, 32) == 32); - success = success && (file.read((uint8_t *)&c.name, 32) == 32); - success = success && (file.read(&c.type, 1) == 1); - success = success && (file.read(&c.flags, 1) == 1); - success = success && (file.read(&unused, 1) == 1); - success = success && (file.read((uint8_t *)&c.sync_since, 4) == 4); // was 'reserved' - success = success && (file.read((uint8_t *)&c.out_path_len, 1) == 1); - success = success && (file.read((uint8_t *)&c.last_advert_timestamp, 4) == 4); - success = success && (file.read(c.out_path, 64) == 64); - success = success && (file.read((uint8_t *)&c.lastmod, 4) == 4); - success = success && (file.read((uint8_t *)&c.gps_lat, 4) == 4); - success = success && (file.read((uint8_t *)&c.gps_lon, 4) == 4); - - if (!success) break; // EOF + FILESYSTEM* fs = _getContactsChannelsFS(); + File file = openRead(fs, "/contacts3"); + + // If main file doesn't exist or is empty, try backup + if (!file || file.size() == 0) { + if (file) file.close(); + if (fs->exists("/contacts3.bak")) { + MESH_DEBUG_PRINTLN("WARN: contacts3 missing/empty, loading from backup"); + file = openRead(fs, "/contacts3.bak"); + } + } - c.id = mesh::Identity(pub_key); - if (!host->onContactLoaded(c)) full = true; - } - file.close(); + if (file) { + bool full = false; + while (!full) { + ContactInfo c; + uint8_t pub_key[32]; + uint8_t unused; + + bool success = (file.read(pub_key, 32) == 32); + success = success && (file.read((uint8_t *)&c.name, 32) == 32); + success = success && (file.read(&c.type, 1) == 1); + success = success && (file.read(&c.flags, 1) == 1); + success = success && (file.read(&unused, 1) == 1); + success = success && (file.read((uint8_t *)&c.sync_since, 4) == 4); // was 'reserved' + success = success && (file.read((uint8_t *)&c.out_path_len, 1) == 1); + success = success && (file.read((uint8_t *)&c.last_advert_timestamp, 4) == 4); + success = success && (file.read(c.out_path, 64) == 64); + success = success && (file.read((uint8_t *)&c.lastmod, 4) == 4); + success = success && (file.read((uint8_t *)&c.gps_lat, 4) == 4); + success = success && (file.read((uint8_t *)&c.gps_lon, 4) == 4); + + if (!success) break; // EOF + + c.id = mesh::Identity(pub_key); + if (!host->onContactLoaded(c)) full = true; } + file.close(); + } } void DataStore::saveContacts(DataStoreHost* host) { - File file = openWrite(_getContactsChannelsFS(), "/contacts3"); + FILESYSTEM* fs = _getContactsChannelsFS(); + + // Write to temp file first (atomic write pattern) + File file = openWrite(fs, "/contacts3.tmp"); if (file) { uint32_t idx = 0; ContactInfo c; uint8_t unused = 0; + bool write_success = true; while (host->getContactForSave(idx, c)) { bool success = (file.write(c.id.pub_key, 32) == 32); @@ -322,11 +337,26 @@ void DataStore::saveContacts(DataStoreHost* host) { success = success && (file.write((uint8_t *)&c.gps_lat, 4) == 4); success = success && (file.write((uint8_t *)&c.gps_lon, 4) == 4); - if (!success) break; // write failed + if (!success) { + write_success = false; + break; // write failed + } idx++; // advance to next contact } + file.flush(); file.close(); + + if (write_success) { + // Atomic swap: remove old backup, rename current to backup, rename temp to current + fs->remove("/contacts3.bak"); + fs->rename("/contacts3", "/contacts3.bak"); + fs->rename("/contacts3.tmp", "/contacts3"); + } else { + // Write failed, remove incomplete temp file + fs->remove("/contacts3.tmp"); + MESH_DEBUG_PRINTLN("ERROR: saveContacts write failed, temp file removed"); + } } } From d4057b4c7f7f3fe4ae830e7d6d22c7e30afd82a5 Mon Sep 17 00:00:00 2001 From: Wessel Nieboer Date: Sat, 14 Feb 2026 14:00:15 +0100 Subject: [PATCH 22/56] Fix dirty session keys. --- src/helpers/BaseChatMesh.cpp | 4 ++++ src/helpers/ClientACL.cpp | 2 ++ 2 files changed, 6 insertions(+) diff --git a/src/helpers/BaseChatMesh.cpp b/src/helpers/BaseChatMesh.cpp index 03bc5ea7c..41d46fd08 100644 --- a/src/helpers/BaseChatMesh.cpp +++ b/src/helpers/BaseChatMesh.cpp @@ -909,6 +909,7 @@ bool BaseChatMesh::removeContact(ContactInfo& contact) { nonce_at_last_persist[idx] = nonce_at_last_persist[idx + 1]; idx++; } + memset(&contacts[num_contacts], 0, sizeof(ContactInfo)); return true; // Success } @@ -1048,6 +1049,7 @@ SessionKeyEntry* BaseChatMesh::allocateSessionKey(const uint8_t* pub_key) { void BaseChatMesh::removeSessionKey(const uint8_t* pub_key) { session_keys.remove(pub_key); + session_keys_dirty = true; } // --- Session key support (Phase 2 — initiator) --- @@ -1304,6 +1306,8 @@ void BaseChatMesh::checkSessionKeyTimeouts() { // All retries exhausted — clean up memset(entry->ephemeral_prv, 0, PRV_KEY_SIZE); memset(entry->ephemeral_pub, 0, PUB_KEY_SIZE); + memset(entry->session_key, 0, SESSION_KEY_SIZE); + memset(entry->prev_session_key, 0, SESSION_KEY_SIZE); entry->state = SESSION_STATE_NONE; entry->timeout_at = 0; } diff --git a/src/helpers/ClientACL.cpp b/src/helpers/ClientACL.cpp index f3cda385a..fae658aa5 100644 --- a/src/helpers/ClientACL.cpp +++ b/src/helpers/ClientACL.cpp @@ -223,6 +223,7 @@ bool ClientACL::applyPermissions(const mesh::LocalIdentity& self_id, const uint8 nonce_at_last_persist[i] = nonce_at_last_persist[i + 1]; i++; } + memset(&clients[num_clients], 0, sizeof(ClientInfo)); } else { if (key_len < PUB_KEY_SIZE) return false; // need complete pubkey when adding/modifying @@ -460,6 +461,7 @@ SessionKeyEntry* ClientACL::allocateSessionKey(const uint8_t* pub_key) { void ClientACL::removeSessionKey(const uint8_t* pub_key) { session_keys.remove(pub_key); + _session_keys_dirty = true; } void ClientACL::loadSessionKeys() { From bb4234dd7f85dae15212202fc16ac0052fb7b59c Mon Sep 17 00:00:00 2001 From: Wessel Nieboer Date: Thu, 22 Jan 2026 08:37:01 +0100 Subject: [PATCH 23/56] Only apply to devices with sufficient storage --- examples/companion_radio/DataStore.cpp | 21 +++++++++++++++++---- 1 file changed, 17 insertions(+), 4 deletions(-) diff --git a/examples/companion_radio/DataStore.cpp b/examples/companion_radio/DataStore.cpp index 7a5c9072c..6c65c407d 100644 --- a/examples/companion_radio/DataStore.cpp +++ b/examples/companion_radio/DataStore.cpp @@ -7,6 +7,12 @@ #define MAX_BLOBRECS 20 #endif +// Atomic writes require ~2x storage for contacts file +// Only enable on platforms with sufficient flash +#if !defined(NRF52_PLATFORM) || defined(EXTRAFS) || defined(QSPIFLASH) + #define HAS_ATOMIC_WRITE_SUPPORT +#endif + DataStore::DataStore(FILESYSTEM& fs, mesh::RTCClock& clock) : _fs(&fs), _fsExtra(nullptr), _clock(&clock), #if defined(NRF52_PLATFORM) || defined(STM32_PLATFORM) identity_store(fs, "") @@ -274,6 +280,7 @@ void DataStore::loadContacts(DataStoreHost* host) { FILESYSTEM* fs = _getContactsChannelsFS(); File file = openRead(fs, "/contacts3"); +#ifdef HAS_ATOMIC_WRITE_SUPPORT // If main file doesn't exist or is empty, try backup if (!file || file.size() == 0) { if (file) file.close(); @@ -282,6 +289,7 @@ void DataStore::loadContacts(DataStoreHost* host) { file = openRead(fs, "/contacts3.bak"); } } +#endif if (file) { bool full = false; @@ -315,8 +323,12 @@ void DataStore::loadContacts(DataStoreHost* host) { void DataStore::saveContacts(DataStoreHost* host) { FILESYSTEM* fs = _getContactsChannelsFS(); - // Write to temp file first (atomic write pattern) +#ifdef HAS_ATOMIC_WRITE_SUPPORT File file = openWrite(fs, "/contacts3.tmp"); +#else + File file = openWrite(fs, "/contacts3"); +#endif + if (file) { uint32_t idx = 0; ContactInfo c; @@ -347,16 +359,17 @@ void DataStore::saveContacts(DataStoreHost* host) { file.flush(); file.close(); +#ifdef HAS_ATOMIC_WRITE_SUPPORT if (write_success) { - // Atomic swap: remove old backup, rename current to backup, rename temp to current fs->remove("/contacts3.bak"); fs->rename("/contacts3", "/contacts3.bak"); fs->rename("/contacts3.tmp", "/contacts3"); + fs->remove("/contacts3.bak"); } else { - // Write failed, remove incomplete temp file fs->remove("/contacts3.tmp"); - MESH_DEBUG_PRINTLN("ERROR: saveContacts write failed, temp file removed"); + MESH_DEBUG_PRINTLN("ERROR: saveContacts write failed"); } +#endif } } From 1d6ae68eb2a00d14b729dcbd002c0a9ee578d9fd Mon Sep 17 00:00:00 2001 From: Wessel Nieboer Date: Sun, 15 Feb 2026 23:10:59 +0100 Subject: [PATCH 24/56] check if contact still exists --- src/helpers/BaseChatMesh.cpp | 36 +++++++++++++++++++++--------------- 1 file changed, 21 insertions(+), 15 deletions(-) diff --git a/src/helpers/BaseChatMesh.cpp b/src/helpers/BaseChatMesh.cpp index 41d46fd08..42f11114b 100644 --- a/src/helpers/BaseChatMesh.cpp +++ b/src/helpers/BaseChatMesh.cpp @@ -1282,26 +1282,32 @@ void BaseChatMesh::checkSessionKeyTimeouts() { if (entry->retries_left > 0) { // Retry: find the contact and resend INIT + ContactInfo* contact = nullptr; for (int j = 0; j < num_contacts; j++) { if (memcmp(contacts[j].id.pub_key, entry->peer_pub_prefix, 4) == 0) { - entry->retries_left--; - entry->timeout_at = futureMillis(SESSION_KEY_TIMEOUT_MS); - - // Regenerate ephemeral keypair for retry - uint8_t seed[SEED_SIZE]; - getRNG()->random(seed, SEED_SIZE); - ed25519_create_keypair(entry->ephemeral_pub, entry->ephemeral_prv, seed); - memset(seed, 0, SEED_SIZE); - - uint8_t req_data[1 + PUB_KEY_SIZE]; - req_data[0] = REQ_TYPE_SESSION_KEY_INIT; - memcpy(&req_data[1], entry->ephemeral_pub, PUB_KEY_SIZE); - - uint32_t tag, est_timeout; - sendRequest(contacts[j], req_data, sizeof(req_data), tag, est_timeout); + contact = &contacts[j]; break; } } + if (!contact) { + entry->retries_left = 0; // contact gone — fall through to cleanup on next tick + continue; + } + entry->retries_left--; + entry->timeout_at = futureMillis(SESSION_KEY_TIMEOUT_MS); + + // Regenerate ephemeral keypair for retry + uint8_t seed[SEED_SIZE]; + getRNG()->random(seed, SEED_SIZE); + ed25519_create_keypair(entry->ephemeral_pub, entry->ephemeral_prv, seed); + memset(seed, 0, SEED_SIZE); + + uint8_t req_data[1 + PUB_KEY_SIZE]; + req_data[0] = REQ_TYPE_SESSION_KEY_INIT; + memcpy(&req_data[1], entry->ephemeral_pub, PUB_KEY_SIZE); + + uint32_t tag, est_timeout; + sendRequest(*contact, req_data, sizeof(req_data), tag, est_timeout); } else { // All retries exhausted — clean up memset(entry->ephemeral_prv, 0, PRV_KEY_SIZE); From ff716bed6a291cf37213c683a879f403dab729f7 Mon Sep 17 00:00:00 2001 From: Wessel Nieboer Date: Wed, 11 Feb 2026 05:12:40 +0100 Subject: [PATCH 25/56] Remove unnecessary backup deletion before rename The fs->remove("/contacts3.bak") before the rename sequence creates a vulnerability window: if power is lost right after removing the backup but before the rename completes, both the backup and primary file could be lost. The remove is unnecessary since rename() on both SPIFFS and LittleFS replaces the target if it already exists. --- examples/companion_radio/DataStore.cpp | 1 - 1 file changed, 1 deletion(-) diff --git a/examples/companion_radio/DataStore.cpp b/examples/companion_radio/DataStore.cpp index 6c65c407d..f6e204ff8 100644 --- a/examples/companion_radio/DataStore.cpp +++ b/examples/companion_radio/DataStore.cpp @@ -361,7 +361,6 @@ void DataStore::saveContacts(DataStoreHost* host) { #ifdef HAS_ATOMIC_WRITE_SUPPORT if (write_success) { - fs->remove("/contacts3.bak"); fs->rename("/contacts3", "/contacts3.bak"); fs->rename("/contacts3.tmp", "/contacts3"); fs->remove("/contacts3.bak"); From 73978375afba0b7bf1319cc3020ee4a282824ded Mon Sep 17 00:00:00 2001 From: Wessel Nieboer Date: Mon, 16 Feb 2026 10:07:54 +0100 Subject: [PATCH 26/56] Address comments --- src/Mesh.cpp | 4 ++-- src/helpers/BaseChatMesh.cpp | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/Mesh.cpp b/src/Mesh.cpp index 6802cfe4a..78f2d58a1 100644 --- a/src/Mesh.cpp +++ b/src/Mesh.cpp @@ -138,7 +138,7 @@ DispatcherAction Mesh::onRecvPacket(Packet* pkt) { // FUTURE: could send back multiple paths, using createPathReturn(), and let sender choose which to use(?) if (self_id.isHashMatch(&dest_hash)) { - // scan contacts DB, for all matching hashes of 'src_hash' (max 4 matches supported ATM) + // scan contacts DB, for all matching hashes of 'src_hash' (max 8 matches supported ATM) int num = searchPeersByHash(&src_hash); // for each matching contact, try to decrypt data bool found = false; @@ -274,7 +274,7 @@ DispatcherAction Mesh::onRecvPacket(Packet* pkt) { if (i + 2 >= pkt->payload_len) { MESH_DEBUG_PRINTLN("%s Mesh::onRecvPacket(): incomplete data packet", getLogDateTime()); } else if (!_tables->hasSeen(pkt)) { - // scan channels DB, for all matching hashes of 'channel_hash' (max 4 matches supported ATM) + // scan channels DB, for all matching hashes of 'channel_hash' (max 8 matches supported ATM) GroupChannel channels[4]; int num = searchChannelsByHash(&channel_hash, channels, 4); // for each matching channel, try to decrypt data diff --git a/src/helpers/BaseChatMesh.cpp b/src/helpers/BaseChatMesh.cpp index 42f11114b..f8e223938 100644 --- a/src/helpers/BaseChatMesh.cpp +++ b/src/helpers/BaseChatMesh.cpp @@ -1117,7 +1117,7 @@ bool BaseChatMesh::shouldInitiateSessionKey(const ContactInfo& contact) { if (!(contact.flags & CONTACT_FLAG_AEAD)) return false; // Need a known path to send the request - if (contact.out_path_len < 0) return false; + if (contact.out_path_len == OUT_PATH_UNKNOWN) return false; auto entry = findSessionKey(contact.id.pub_key); @@ -1352,9 +1352,9 @@ void BaseChatMesh::onSessionKeyDecryptSuccess(int peer_idx) { if (changed) { memset(entry->prev_session_key, 0, SESSION_KEY_SIZE); entry->state = SESSION_STATE_ACTIVE; + onSessionKeysUpdated(); } entry->sends_since_last_recv = 0; - if (changed) onSessionKeysUpdated(); } } } From f2f502185dd1fafeb1e9cfa11196ff90cfb56238 Mon Sep 17 00:00:00 2001 From: Wessel Nieboer Date: Wed, 11 Feb 2026 04:35:34 +0100 Subject: [PATCH 27/56] Validate buffer length before reading fields in Packet::readFrom readFrom reads the header byte, transport codes (4 bytes), and path_len from the source buffer before any length validation. With a short input, these reads go past the end of the buffer. Add upfront length checks: minimum 2 bytes overall, transport codes require 4 additional bytes, and path must fit before the remaining payload. --- src/Packet.cpp | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/Packet.cpp b/src/Packet.cpp index aad3e2f48..66b65ba1b 100644 --- a/src/Packet.cpp +++ b/src/Packet.cpp @@ -63,9 +63,11 @@ uint8_t Packet::writeTo(uint8_t dest[]) const { } bool Packet::readFrom(const uint8_t src[], uint8_t len) { + if (len < 2) return false; // minimum: header + path_len uint8_t i = 0; header = src[i++]; if (hasTransportCodes()) { + if (i + 4 >= len) return false; // need 4 bytes for transport codes + path_len after memcpy(&transport_codes[0], &src[i], 2); i += 2; memcpy(&transport_codes[1], &src[i], 2); i += 2; } else { @@ -75,9 +77,8 @@ bool Packet::readFrom(const uint8_t src[], uint8_t len) { if (!isValidPathLen(path_len)) return false; // bad encoding uint8_t bl = getPathByteLen(); + if (i + bl >= len) return false; // path + at least 1 byte payload must fit memcpy(path, &src[i], bl); i += bl; - - if (i >= len) return false; // bad encoding payload_len = len - i; if (payload_len > sizeof(payload)) return false; // bad encoding memcpy(payload, &src[i], payload_len); //i += payload_len; From f0e405056e11b2ab1d5df37954d90268a4fa3512 Mon Sep 17 00:00:00 2001 From: Wessel Nieboer Date: Wed, 11 Feb 2026 03:30:41 +0100 Subject: [PATCH 28/56] use constant-time comparison for MAC verification The HMAC check in MACThenDecrypt used standard memcmp(), which short-circuits on the first mismatched byte. This makes the comparison time dependent on how many bytes of the MAC are correct, leaking information through a timing side-channel. With a 2-byte MAC (65,536 possible values), an attacker on a local interface (serial, BLE, or WiFi) can measure response latency to distinguish "first byte wrong" from "first byte correct, second wrong". This reduces a brute-force from 65,536 attempts down to roughly 384 (256 + 128 on average), making MAC forgery practical. An attacker could use this to forge packets that pass MAC verification without knowing the shared secret, allowing them to inject arbitrary messages that appear to come from a trusted peer. Replace memcmp with a constant-time XOR-accumulate loop so the comparison always takes the same time regardless of which bytes match. --- src/Utils.cpp | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/Utils.cpp b/src/Utils.cpp index 186c8720a..a07de2e8d 100644 --- a/src/Utils.cpp +++ b/src/Utils.cpp @@ -81,7 +81,10 @@ int Utils::MACThenDecrypt(const uint8_t* shared_secret, uint8_t* dest, const uin sha.update(src + CIPHER_MAC_SIZE, src_len - CIPHER_MAC_SIZE); sha.finalizeHMAC(shared_secret, PUB_KEY_SIZE, hmac, CIPHER_MAC_SIZE); } - if (memcmp(hmac, src, CIPHER_MAC_SIZE) == 0) { + // constant-time comparison to prevent timing side-channel attacks + uint8_t diff = 0; + for (int i = 0; i < CIPHER_MAC_SIZE; i++) diff |= hmac[i] ^ src[i]; + if (diff == 0) { return decrypt(shared_secret, dest, src + CIPHER_MAC_SIZE, src_len - CIPHER_MAC_SIZE); } return 0; // invalid HMAC From 97a74adbdf10bf971d5c70a02e295e5f0c2fc8bd Mon Sep 17 00:00:00 2001 From: Wessel Nieboer Date: Mon, 16 Feb 2026 09:49:19 +0100 Subject: [PATCH 29/56] Use secure_compare --- src/Utils.cpp | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/src/Utils.cpp b/src/Utils.cpp index a07de2e8d..31af2db14 100644 --- a/src/Utils.cpp +++ b/src/Utils.cpp @@ -1,5 +1,6 @@ #include "Utils.h" #include +#include #include #ifdef ARDUINO @@ -81,10 +82,7 @@ int Utils::MACThenDecrypt(const uint8_t* shared_secret, uint8_t* dest, const uin sha.update(src + CIPHER_MAC_SIZE, src_len - CIPHER_MAC_SIZE); sha.finalizeHMAC(shared_secret, PUB_KEY_SIZE, hmac, CIPHER_MAC_SIZE); } - // constant-time comparison to prevent timing side-channel attacks - uint8_t diff = 0; - for (int i = 0; i < CIPHER_MAC_SIZE; i++) diff |= hmac[i] ^ src[i]; - if (diff == 0) { + if (secure_compare(hmac, src, CIPHER_MAC_SIZE)) { return decrypt(shared_secret, dest, src + CIPHER_MAC_SIZE, src_len - CIPHER_MAC_SIZE); } return 0; // invalid HMAC From 74cd8406cf539df19bfe1bd049216d9063c36931 Mon Sep 17 00:00:00 2001 From: Wessel Nieboer Date: Fri, 6 Feb 2026 02:50:19 +0100 Subject: [PATCH 30/56] Implement LRU cache for storing hashes to filter flood Replace the fixed-size hash table with an LRU cache using actual timestamps instead of timeout-based eviction. Some busy nodes see more than 128 packets before duplicates arrive, so LRU ordering provides better eviction behavior. --- src/helpers/SimpleMeshTables.h | 56 ++++++++++++++++++++++++++-------- 1 file changed, 43 insertions(+), 13 deletions(-) diff --git a/src/helpers/SimpleMeshTables.h b/src/helpers/SimpleMeshTables.h index 2f8af52af..829f41f84 100644 --- a/src/helpers/SimpleMeshTables.h +++ b/src/helpers/SimpleMeshTables.h @@ -11,15 +11,15 @@ class SimpleMeshTables : public mesh::MeshTables { uint8_t _hashes[MAX_PACKET_HASHES*MAX_HASH_SIZE]; - int _next_idx; + uint32_t _last_seen[MAX_PACKET_HASHES]; // timestamp for LRU eviction uint32_t _acks[MAX_PACKET_ACKS]; int _next_ack_idx; uint32_t _direct_dups, _flood_dups; public: - SimpleMeshTables() { + SimpleMeshTables() { memset(_hashes, 0, sizeof(_hashes)); - _next_idx = 0; + memset(_last_seen, 0, sizeof(_last_seen)); memset(_acks, 0, sizeof(_acks)); _next_ack_idx = 0; _direct_dups = _flood_dups = 0; @@ -28,13 +28,26 @@ class SimpleMeshTables : public mesh::MeshTables { #ifdef ESP32 void restoreFrom(File f) { f.read(_hashes, sizeof(_hashes)); - f.read((uint8_t *) &_next_idx, sizeof(_next_idx)); + int dummy_idx; + f.read((uint8_t *) &dummy_idx, sizeof(dummy_idx)); // legacy, ignore f.read((uint8_t *) &_acks[0], sizeof(_acks)); f.read((uint8_t *) &_next_ack_idx, sizeof(_next_ack_idx)); + // Treat restored hashes as just seen - give them fresh timestamps + uint32_t now = millis(); + const uint8_t* sp = _hashes; + for (int i = 0; i < MAX_PACKET_HASHES; i++, sp += MAX_HASH_SIZE) { + // Check if slot has data (not all zeros) + bool empty = true; + for (int j = 0; j < MAX_HASH_SIZE && empty; j++) { + if (sp[j] != 0) empty = false; + } + _last_seen[i] = empty ? 0 : now; + } } void saveTo(File f) { f.write(_hashes, sizeof(_hashes)); - f.write((const uint8_t *) &_next_idx, sizeof(_next_idx)); + int dummy_idx = 0; + f.write((const uint8_t *) &dummy_idx, sizeof(dummy_idx)); // legacy format f.write((const uint8_t *) &_acks[0], sizeof(_acks)); f.write((const uint8_t *) &_next_ack_idx, sizeof(_next_ack_idx)); } @@ -45,7 +58,7 @@ class SimpleMeshTables : public mesh::MeshTables { uint32_t ack; memcpy(&ack, packet->payload, 4); for (int i = 0; i < MAX_PACKET_ACKS; i++) { - if (ack == _acks[i]) { + if (ack == _acks[i]) { if (packet->isRouteDirect()) { _direct_dups++; // keep some stats } else { @@ -54,18 +67,26 @@ class SimpleMeshTables : public mesh::MeshTables { return true; } } - + _acks[_next_ack_idx] = ack; - _next_ack_idx = (_next_ack_idx + 1) % MAX_PACKET_ACKS; // cyclic table + _next_ack_idx = (_next_ack_idx + 1) % MAX_PACKET_ACKS; // cyclic table return false; } + uint32_t now = millis(); uint8_t hash[MAX_HASH_SIZE]; packet->calculatePacketHash(hash); + int oldest_idx = 0; + uint32_t oldest_age = 0; + const uint8_t* sp = _hashes; for (int i = 0; i < MAX_PACKET_HASHES; i++, sp += MAX_HASH_SIZE) { - if (memcmp(hash, sp, MAX_HASH_SIZE) == 0) { + uint32_t age = now - _last_seen[i]; + + if (memcmp(hash, sp, MAX_HASH_SIZE) == 0 && _last_seen[i] != 0) { + // Match found - refresh timestamp (LRU touch) and return true + _last_seen[i] = now; if (packet->isRouteDirect()) { _direct_dups++; // keep some stats } else { @@ -73,10 +94,18 @@ class SimpleMeshTables : public mesh::MeshTables { } return true; } + + // Track oldest entry for LRU eviction + if (age > oldest_age) { + oldest_age = age; + oldest_idx = i; + } } - memcpy(&_hashes[_next_idx*MAX_HASH_SIZE], hash, MAX_HASH_SIZE); - _next_idx = (_next_idx + 1) % MAX_PACKET_HASHES; // cyclic table + // Not found - evict oldest (LRU) + int insert_idx = oldest_idx; + memcpy(&_hashes[insert_idx*MAX_HASH_SIZE], hash, MAX_HASH_SIZE); + _last_seen[insert_idx] = now; return false; } @@ -85,7 +114,7 @@ class SimpleMeshTables : public mesh::MeshTables { uint32_t ack; memcpy(&ack, packet->payload, 4); for (int i = 0; i < MAX_PACKET_ACKS; i++) { - if (ack == _acks[i]) { + if (ack == _acks[i]) { _acks[i] = 0; break; } @@ -96,8 +125,9 @@ class SimpleMeshTables : public mesh::MeshTables { uint8_t* sp = _hashes; for (int i = 0; i < MAX_PACKET_HASHES; i++, sp += MAX_HASH_SIZE) { - if (memcmp(hash, sp, MAX_HASH_SIZE) == 0) { + if (memcmp(hash, sp, MAX_HASH_SIZE) == 0) { memset(sp, 0, MAX_HASH_SIZE); + _last_seen[i] = 0; break; } } From 1c05fbca18e16ba4949beff568dd8bd6afafdcda Mon Sep 17 00:00:00 2001 From: Wessel Nieboer Date: Wed, 11 Feb 2026 03:30:02 +0100 Subject: [PATCH 31/56] fix out-of-bounds read in TRACE packet hash matching The TRACE handler uses isHashMatch() to compare this node's hash against an entry in the payload, but did not verify that enough bytes remain in the payload for the full hash comparison. The hash size is variable (1, 2, 4, or 8 bytes depending on path_sz), so when offset is close to the end of the payload, isHashMatch reads past the buffer boundary. Add a bounds check ensuring offset + hash_sz <= len before calling isHashMatch, preventing the over-read. --- src/Mesh.cpp | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/Mesh.cpp b/src/Mesh.cpp index 57fee1403..6b7e6d0bc 100644 --- a/src/Mesh.cpp +++ b/src/Mesh.cpp @@ -51,9 +51,10 @@ DispatcherAction Mesh::onRecvPacket(Packet* pkt) { uint8_t len = pkt->payload_len - i; uint8_t offset = pkt->path_len << path_sz; + uint8_t hash_sz = 1 << path_sz; if (offset >= len) { // TRACE has reached end of given path onTraceRecv(pkt, trace_tag, auth_code, flags, pkt->path, &pkt->payload[i], len); - } else if (self_id.isHashMatch(&pkt->payload[i + offset], 1 << path_sz) && allowPacketForward(pkt) && !_tables->hasSeen(pkt)) { + } else if (offset + hash_sz <= len && self_id.isHashMatch(&pkt->payload[i + offset], hash_sz) && allowPacketForward(pkt) && !_tables->hasSeen(pkt)) { // append SNR (Not hash!) pkt->path[pkt->path_len++] = (int8_t) (pkt->getSNR()*4); From 733cc39808b7f0afc328f16c59d915d94091b1c7 Mon Sep 17 00:00:00 2001 From: Wessel Nieboer Date: Wed, 11 Feb 2026 04:34:54 +0100 Subject: [PATCH 32/56] Bounds-check reply_path in anonymous request handlers The handleAnon*Req functions read a reply_path_len byte from the decrypted data and memcpy that many bytes into reply_path, without checking that the data buffer actually contains that many bytes. With a minimal-length packet, this reads up to 63 bytes of uninitialized stack memory. Add a data_len parameter to all three handlers and validate that the buffer contains enough bytes for the claimed reply_path_len before copying. Also guard the callers to ensure len > 5 before passing &data[5]. --- examples/simple_repeater/MyMesh.cpp | 27 +++++++++++++++------------ examples/simple_repeater/MyMesh.h | 6 +++--- 2 files changed, 18 insertions(+), 15 deletions(-) diff --git a/examples/simple_repeater/MyMesh.cpp b/examples/simple_repeater/MyMesh.cpp index 81c1dcb42..3a16bee24 100644 --- a/examples/simple_repeater/MyMesh.cpp +++ b/examples/simple_repeater/MyMesh.cpp @@ -144,13 +144,14 @@ uint8_t MyMesh::handleLoginReq(const mesh::Identity& sender, const uint8_t* secr return 13; // reply length } -uint8_t MyMesh::handleAnonRegionsReq(const mesh::Identity& sender, uint32_t sender_timestamp, const uint8_t* data) { +uint8_t MyMesh::handleAnonRegionsReq(const mesh::Identity& sender, uint32_t sender_timestamp, const uint8_t* data, size_t data_len) { if (anon_limiter.allow(rtc_clock.getCurrentTime())) { // request data has: {reply-path-len}{reply-path} + if (data_len < 1) return 0; reply_path_len = *data & 63; reply_path_hash_size = (*data >> 6) + 1; data++; - + if (1 + (size_t)reply_path_len * reply_path_hash_size > data_len) return 0; memcpy(reply_path, data, ((uint8_t)reply_path_len) * reply_path_hash_size); // data += (uint8_t)reply_path_len * reply_path_hash_size; @@ -163,13 +164,14 @@ uint8_t MyMesh::handleAnonRegionsReq(const mesh::Identity& sender, uint32_t send return 0; } -uint8_t MyMesh::handleAnonOwnerReq(const mesh::Identity& sender, uint32_t sender_timestamp, const uint8_t* data) { +uint8_t MyMesh::handleAnonOwnerReq(const mesh::Identity& sender, uint32_t sender_timestamp, const uint8_t* data, size_t data_len) { if (anon_limiter.allow(rtc_clock.getCurrentTime())) { // request data has: {reply-path-len}{reply-path} + if (data_len < 1) return 0; reply_path_len = *data & 63; reply_path_hash_size = (*data >> 6) + 1; data++; - + if (1 + (size_t)reply_path_len * reply_path_hash_size > data_len) return 0; memcpy(reply_path, data, ((uint8_t)reply_path_len) * reply_path_hash_size); // data += (uint8_t)reply_path_len * reply_path_hash_size; @@ -183,13 +185,14 @@ uint8_t MyMesh::handleAnonOwnerReq(const mesh::Identity& sender, uint32_t sender return 0; } -uint8_t MyMesh::handleAnonClockReq(const mesh::Identity& sender, uint32_t sender_timestamp, const uint8_t* data) { +uint8_t MyMesh::handleAnonClockReq(const mesh::Identity& sender, uint32_t sender_timestamp, const uint8_t* data, size_t data_len) { if (anon_limiter.allow(rtc_clock.getCurrentTime())) { // request data has: {reply-path-len}{reply-path} + if (data_len < 1) return 0; reply_path_len = *data & 63; reply_path_hash_size = (*data >> 6) + 1; data++; - + if (1 + (size_t)reply_path_len * reply_path_hash_size > data_len) return 0; memcpy(reply_path, data, ((uint8_t)reply_path_len) * reply_path_hash_size); // data += (uint8_t)reply_path_len * reply_path_hash_size; @@ -531,12 +534,12 @@ void MyMesh::onAnonDataRecv(mesh::Packet *packet, const uint8_t *secret, const m reply_path_len = -1; if (data[4] == 0 || data[4] >= ' ') { // is password, ie. a login request reply_len = handleLoginReq(sender, secret, timestamp, &data[4], packet->isRouteFlood()); - } else if (data[4] == ANON_REQ_TYPE_REGIONS && packet->isRouteDirect()) { - reply_len = handleAnonRegionsReq(sender, timestamp, &data[5]); - } else if (data[4] == ANON_REQ_TYPE_OWNER && packet->isRouteDirect()) { - reply_len = handleAnonOwnerReq(sender, timestamp, &data[5]); - } else if (data[4] == ANON_REQ_TYPE_BASIC && packet->isRouteDirect()) { - reply_len = handleAnonClockReq(sender, timestamp, &data[5]); + } else if (data[4] == ANON_REQ_TYPE_REGIONS && packet->isRouteDirect() && len > 5) { + reply_len = handleAnonRegionsReq(sender, timestamp, &data[5], len - 5); + } else if (data[4] == ANON_REQ_TYPE_OWNER && packet->isRouteDirect() && len > 5) { + reply_len = handleAnonOwnerReq(sender, timestamp, &data[5], len - 5); + } else if (data[4] == ANON_REQ_TYPE_BASIC && packet->isRouteDirect() && len > 5) { + reply_len = handleAnonClockReq(sender, timestamp, &data[5], len - 5); } else { reply_len = 0; // unknown/invalid request type } diff --git a/examples/simple_repeater/MyMesh.h b/examples/simple_repeater/MyMesh.h index 591f63662..0d45b6589 100644 --- a/examples/simple_repeater/MyMesh.h +++ b/examples/simple_repeater/MyMesh.h @@ -121,9 +121,9 @@ class MyMesh : public mesh::Mesh, public CommonCLICallbacks { void putNeighbour(const mesh::Identity& id, uint32_t timestamp, float snr); void sendNodeDiscoverReq(); uint8_t handleLoginReq(const mesh::Identity& sender, const uint8_t* secret, uint32_t sender_timestamp, const uint8_t* data, bool is_flood); - uint8_t handleAnonRegionsReq(const mesh::Identity& sender, uint32_t sender_timestamp, const uint8_t* data); - uint8_t handleAnonOwnerReq(const mesh::Identity& sender, uint32_t sender_timestamp, const uint8_t* data); - uint8_t handleAnonClockReq(const mesh::Identity& sender, uint32_t sender_timestamp, const uint8_t* data); + uint8_t handleAnonRegionsReq(const mesh::Identity& sender, uint32_t sender_timestamp, const uint8_t* data, size_t data_len); + uint8_t handleAnonOwnerReq(const mesh::Identity& sender, uint32_t sender_timestamp, const uint8_t* data, size_t data_len); + uint8_t handleAnonClockReq(const mesh::Identity& sender, uint32_t sender_timestamp, const uint8_t* data, size_t data_len); int handleRequest(ClientInfo* sender, uint32_t sender_timestamp, uint8_t* payload, size_t payload_len); mesh::Packet* createSelfAdvert(); From 89bf6e2aa4b79624d3964a77aacc58874d416658 Mon Sep 17 00:00:00 2001 From: Wessel Nieboer Date: Sat, 10 Jan 2026 17:57:39 +0100 Subject: [PATCH 33/56] Pass rtc_clock to all MicroNMEALocationProvider instances Enable GPS time synchronization across all variants by passing &rtc_clock to MicroNMEALocationProvider. When GPS gets a valid fix, the RTC clock is now updated automatically every 30 minutes. Updated 16 variants: rak4631, lilygo_tbeam_SX1262, rak_wismesh_tag, lilygo_tbeam_supreme_SX1262, thinknode_m3, heltec_v4, thinknode_m1, lilygo_tbeam_SX1276, meshadventurer, nano_g2_ultra, heltec_v3, promicro, xiao_c3, heltec_tracker_v2, keepteen_lt1, heltec_mesh_solar. --- variants/heltec_mesh_solar/target.cpp | 2 +- variants/heltec_tracker_v2/target.cpp | 2 +- variants/heltec_v3/target.cpp | 2 +- variants/heltec_v4/target.cpp | 2 +- variants/keepteen_lt1/target.cpp | 2 +- variants/lilygo_tbeam_1w/target.cpp | 2 +- variants/lilygo_tbeam_SX1262/target.cpp | 2 +- variants/lilygo_tbeam_SX1276/target.cpp | 2 +- variants/lilygo_tbeam_supreme_SX1262/target.cpp | 2 +- variants/meshadventurer/target.cpp | 2 +- variants/nano_g2_ultra/target.cpp | 2 +- variants/promicro/target.cpp | 2 +- variants/rak3112/target.cpp | 2 +- variants/rak3401/target.cpp | 2 +- variants/rak4631/target.cpp | 2 +- variants/rak_wismesh_tag/target.cpp | 2 +- variants/thinknode_m3/target.cpp | 2 +- variants/xiao_c3/target.cpp | 2 +- 18 files changed, 18 insertions(+), 18 deletions(-) diff --git a/variants/heltec_mesh_solar/target.cpp b/variants/heltec_mesh_solar/target.cpp index 9852b68f8..1ea33e1f2 100644 --- a/variants/heltec_mesh_solar/target.cpp +++ b/variants/heltec_mesh_solar/target.cpp @@ -11,7 +11,7 @@ WRAPPER_CLASS radio_driver(radio, board); VolatileRTCClock fallback_clock; AutoDiscoverRTCClock rtc_clock(fallback_clock); -MicroNMEALocationProvider nmea = MicroNMEALocationProvider(Serial1); +MicroNMEALocationProvider nmea = MicroNMEALocationProvider(Serial1, &rtc_clock); SolarSensorManager sensors = SolarSensorManager(nmea); #ifdef DISPLAY_CLASS diff --git a/variants/heltec_tracker_v2/target.cpp b/variants/heltec_tracker_v2/target.cpp index c2e26b20d..0b349231c 100644 --- a/variants/heltec_tracker_v2/target.cpp +++ b/variants/heltec_tracker_v2/target.cpp @@ -17,7 +17,7 @@ AutoDiscoverRTCClock rtc_clock(fallback_clock); #if ENV_INCLUDE_GPS #include - MicroNMEALocationProvider nmea = MicroNMEALocationProvider(Serial1, NULL, GPS_RESET, GPS_EN, &board.periph_power); + MicroNMEALocationProvider nmea = MicroNMEALocationProvider(Serial1, &rtc_clock, GPS_RESET, GPS_EN, &board.periph_power); EnvironmentSensorManager sensors = EnvironmentSensorManager(nmea); #else EnvironmentSensorManager sensors; diff --git a/variants/heltec_v3/target.cpp b/variants/heltec_v3/target.cpp index cdd2535e8..460c3c9b7 100644 --- a/variants/heltec_v3/target.cpp +++ b/variants/heltec_v3/target.cpp @@ -17,7 +17,7 @@ AutoDiscoverRTCClock rtc_clock(fallback_clock); #if ENV_INCLUDE_GPS #include - MicroNMEALocationProvider nmea = MicroNMEALocationProvider(Serial1); + MicroNMEALocationProvider nmea = MicroNMEALocationProvider(Serial1, &rtc_clock); EnvironmentSensorManager sensors = EnvironmentSensorManager(nmea); #else EnvironmentSensorManager sensors; diff --git a/variants/heltec_v4/target.cpp b/variants/heltec_v4/target.cpp index 54fc05e89..e44ec448a 100644 --- a/variants/heltec_v4/target.cpp +++ b/variants/heltec_v4/target.cpp @@ -17,7 +17,7 @@ AutoDiscoverRTCClock rtc_clock(fallback_clock); #if ENV_INCLUDE_GPS #include - MicroNMEALocationProvider nmea = MicroNMEALocationProvider(Serial1); + MicroNMEALocationProvider nmea = MicroNMEALocationProvider(Serial1, &rtc_clock); EnvironmentSensorManager sensors = EnvironmentSensorManager(nmea); #else EnvironmentSensorManager sensors; diff --git a/variants/keepteen_lt1/target.cpp b/variants/keepteen_lt1/target.cpp index e2e183a70..85f11232a 100644 --- a/variants/keepteen_lt1/target.cpp +++ b/variants/keepteen_lt1/target.cpp @@ -12,7 +12,7 @@ VolatileRTCClock fallback_clock; AutoDiscoverRTCClock rtc_clock(fallback_clock); #if ENV_INCLUDE_GPS #include - MicroNMEALocationProvider nmea = MicroNMEALocationProvider(Serial1); + MicroNMEALocationProvider nmea = MicroNMEALocationProvider(Serial1, &rtc_clock); EnvironmentSensorManager sensors = EnvironmentSensorManager(nmea); #else EnvironmentSensorManager sensors; diff --git a/variants/lilygo_tbeam_1w/target.cpp b/variants/lilygo_tbeam_1w/target.cpp index 8cb6bdfa3..7cfd93563 100644 --- a/variants/lilygo_tbeam_1w/target.cpp +++ b/variants/lilygo_tbeam_1w/target.cpp @@ -19,7 +19,7 @@ AutoDiscoverRTCClock rtc_clock(fallback_clock); #if ENV_INCLUDE_GPS #include - MicroNMEALocationProvider nmea = MicroNMEALocationProvider(Serial1); + MicroNMEALocationProvider nmea = MicroNMEALocationProvider(Serial1, &rtc_clock); EnvironmentSensorManager sensors = EnvironmentSensorManager(nmea); #else EnvironmentSensorManager sensors; diff --git a/variants/lilygo_tbeam_SX1262/target.cpp b/variants/lilygo_tbeam_SX1262/target.cpp index f85049d7c..a851fd25c 100644 --- a/variants/lilygo_tbeam_SX1262/target.cpp +++ b/variants/lilygo_tbeam_SX1262/target.cpp @@ -17,7 +17,7 @@ AutoDiscoverRTCClock rtc_clock(fallback_clock); #if ENV_INCLUDE_GPS #include - MicroNMEALocationProvider nmea = MicroNMEALocationProvider(Serial1); + MicroNMEALocationProvider nmea = MicroNMEALocationProvider(Serial1, &rtc_clock); EnvironmentSensorManager sensors = EnvironmentSensorManager(nmea); #else EnvironmentSensorManager sensors; diff --git a/variants/lilygo_tbeam_SX1276/target.cpp b/variants/lilygo_tbeam_SX1276/target.cpp index 5fe82e111..5865faa39 100644 --- a/variants/lilygo_tbeam_SX1276/target.cpp +++ b/variants/lilygo_tbeam_SX1276/target.cpp @@ -17,7 +17,7 @@ AutoDiscoverRTCClock rtc_clock(fallback_clock); #if ENV_INCLUDE_GPS #include - MicroNMEALocationProvider nmea = MicroNMEALocationProvider(Serial1); + MicroNMEALocationProvider nmea = MicroNMEALocationProvider(Serial1, &rtc_clock); EnvironmentSensorManager sensors = EnvironmentSensorManager(nmea); #else EnvironmentSensorManager sensors; diff --git a/variants/lilygo_tbeam_supreme_SX1262/target.cpp b/variants/lilygo_tbeam_supreme_SX1262/target.cpp index 6fec6f583..23983bd36 100644 --- a/variants/lilygo_tbeam_supreme_SX1262/target.cpp +++ b/variants/lilygo_tbeam_supreme_SX1262/target.cpp @@ -19,7 +19,7 @@ AutoDiscoverRTCClock rtc_clock(fallback_clock); #if ENV_INCLUDE_GPS #include - MicroNMEALocationProvider nmea = MicroNMEALocationProvider(Serial1); + MicroNMEALocationProvider nmea = MicroNMEALocationProvider(Serial1, &rtc_clock); EnvironmentSensorManager sensors = EnvironmentSensorManager(nmea); #else EnvironmentSensorManager sensors; diff --git a/variants/meshadventurer/target.cpp b/variants/meshadventurer/target.cpp index 0edd44030..9ef58d0e3 100644 --- a/variants/meshadventurer/target.cpp +++ b/variants/meshadventurer/target.cpp @@ -11,7 +11,7 @@ WRAPPER_CLASS radio_driver(radio, board); ESP32RTCClock fallback_clock; AutoDiscoverRTCClock rtc_clock(fallback_clock); -MicroNMEALocationProvider nmea = MicroNMEALocationProvider(Serial1); +MicroNMEALocationProvider nmea = MicroNMEALocationProvider(Serial1, &rtc_clock); MASensorManager sensors = MASensorManager(nmea); #ifdef DISPLAY_CLASS diff --git a/variants/nano_g2_ultra/target.cpp b/variants/nano_g2_ultra/target.cpp index aad10c505..bd4e9b480 100644 --- a/variants/nano_g2_ultra/target.cpp +++ b/variants/nano_g2_ultra/target.cpp @@ -12,7 +12,7 @@ WRAPPER_CLASS radio_driver(radio, board); VolatileRTCClock fallback_clock; AutoDiscoverRTCClock rtc_clock(fallback_clock); -MicroNMEALocationProvider nmea = MicroNMEALocationProvider(Serial1); +MicroNMEALocationProvider nmea = MicroNMEALocationProvider(Serial1, &rtc_clock); NanoG2UltraSensorManager sensors = NanoG2UltraSensorManager(nmea); #ifdef DISPLAY_CLASS diff --git a/variants/promicro/target.cpp b/variants/promicro/target.cpp index 61eab91c2..e4a4442ab 100644 --- a/variants/promicro/target.cpp +++ b/variants/promicro/target.cpp @@ -12,7 +12,7 @@ VolatileRTCClock fallback_clock; AutoDiscoverRTCClock rtc_clock(fallback_clock); #if ENV_INCLUDE_GPS #include - MicroNMEALocationProvider nmea = MicroNMEALocationProvider(Serial1); + MicroNMEALocationProvider nmea = MicroNMEALocationProvider(Serial1, &rtc_clock); EnvironmentSensorManager sensors = EnvironmentSensorManager(nmea); #else EnvironmentSensorManager sensors; diff --git a/variants/rak3112/target.cpp b/variants/rak3112/target.cpp index 6cddfce51..86955b96e 100644 --- a/variants/rak3112/target.cpp +++ b/variants/rak3112/target.cpp @@ -17,7 +17,7 @@ AutoDiscoverRTCClock rtc_clock(fallback_clock); #if ENV_INCLUDE_GPS #include - MicroNMEALocationProvider nmea = MicroNMEALocationProvider(Serial1); + MicroNMEALocationProvider nmea = MicroNMEALocationProvider(Serial1, &rtc_clock); EnvironmentSensorManager sensors = EnvironmentSensorManager(nmea); #else EnvironmentSensorManager sensors; diff --git a/variants/rak3401/target.cpp b/variants/rak3401/target.cpp index ec4fc28c0..77fb0e5f2 100644 --- a/variants/rak3401/target.cpp +++ b/variants/rak3401/target.cpp @@ -26,7 +26,7 @@ AutoDiscoverRTCClock rtc_clock(fallback_clock); #if ENV_INCLUDE_GPS #include - MicroNMEALocationProvider nmea = MicroNMEALocationProvider(Serial1); + MicroNMEALocationProvider nmea = MicroNMEALocationProvider(Serial1, &rtc_clock); EnvironmentSensorManager sensors = EnvironmentSensorManager(nmea); #else EnvironmentSensorManager sensors; diff --git a/variants/rak4631/target.cpp b/variants/rak4631/target.cpp index ea6a2bd4a..ac1ac7cac 100644 --- a/variants/rak4631/target.cpp +++ b/variants/rak4631/target.cpp @@ -26,7 +26,7 @@ AutoDiscoverRTCClock rtc_clock(fallback_clock); #if ENV_INCLUDE_GPS #include - MicroNMEALocationProvider nmea = MicroNMEALocationProvider(Serial1); + MicroNMEALocationProvider nmea = MicroNMEALocationProvider(Serial1, &rtc_clock); EnvironmentSensorManager sensors = EnvironmentSensorManager(nmea); #else EnvironmentSensorManager sensors; diff --git a/variants/rak_wismesh_tag/target.cpp b/variants/rak_wismesh_tag/target.cpp index 9646375e6..d42c0d58e 100644 --- a/variants/rak_wismesh_tag/target.cpp +++ b/variants/rak_wismesh_tag/target.cpp @@ -22,7 +22,7 @@ AutoDiscoverRTCClock rtc_clock(fallback_clock); #if ENV_INCLUDE_GPS #include - MicroNMEALocationProvider nmea = MicroNMEALocationProvider(Serial1); + MicroNMEALocationProvider nmea = MicroNMEALocationProvider(Serial1, &rtc_clock); EnvironmentSensorManager sensors = EnvironmentSensorManager(nmea); #else EnvironmentSensorManager sensors; diff --git a/variants/thinknode_m3/target.cpp b/variants/thinknode_m3/target.cpp index ca2b0aa06..7303eb4ca 100644 --- a/variants/thinknode_m3/target.cpp +++ b/variants/thinknode_m3/target.cpp @@ -11,7 +11,7 @@ WRAPPER_CLASS radio_driver(radio, board); VolatileRTCClock fallback_clock; AutoDiscoverRTCClock rtc_clock(fallback_clock); #ifdef ENV_INCLUDE_GPS -MicroNMEALocationProvider nmea = MicroNMEALocationProvider(Serial1); +MicroNMEALocationProvider nmea = MicroNMEALocationProvider(Serial1, &rtc_clock); EnvironmentSensorManager sensors = EnvironmentSensorManager(nmea); #else EnvironmentSensorManager sensors = EnvironmentSensorManager(); diff --git a/variants/xiao_c3/target.cpp b/variants/xiao_c3/target.cpp index f8ee3d92c..09461d10a 100644 --- a/variants/xiao_c3/target.cpp +++ b/variants/xiao_c3/target.cpp @@ -17,7 +17,7 @@ AutoDiscoverRTCClock rtc_clock(fallback_clock); #if ENV_INCLUDE_GPS #include - MicroNMEALocationProvider nmea = MicroNMEALocationProvider(Serial1); + MicroNMEALocationProvider nmea = MicroNMEALocationProvider(Serial1, &rtc_clock); EnvironmentSensorManager sensors = EnvironmentSensorManager(nmea); #else EnvironmentSensorManager sensors; From f19a8259c08c63f1fc7b78598d3585d67885da8a Mon Sep 17 00:00:00 2001 From: Wessel Nieboer Date: Wed, 11 Feb 2026 03:19:13 +0100 Subject: [PATCH 34/56] fix bounds check on PAYLOAD_TYPE_PATH decrypted data The path_len field inside the decrypted PATH payload was used to advance the parse cursor without validating it against the actual decrypted data length. A malicious peer sharing a key could craft a PATH packet with an oversized path_len, causing out-of-bounds reads past the decrypted buffer when accessing the extra_type byte and extra data pointer. Add a bounds check after reading path_len to ensure the decrypted buffer contains enough bytes for the claimed path plus the mandatory extra_type byte before dereferencing. --- src/Mesh.cpp | 1 + 1 file changed, 1 insertion(+) diff --git a/src/Mesh.cpp b/src/Mesh.cpp index 57fee1403..afc46df11 100644 --- a/src/Mesh.cpp +++ b/src/Mesh.cpp @@ -155,6 +155,7 @@ DispatcherAction Mesh::onRecvPacket(Packet* pkt) { uint8_t path_len = data[k++]; uint8_t hash_size = (path_len >> 6) + 1; uint8_t hash_count = path_len & 63; + if (k + hash_size*hash_count + 1 > len) break; // bounds check: need path bytes + extra_type byte uint8_t* path = &data[k]; k += hash_size*hash_count; uint8_t extra_type = data[k++] & 0x0F; // upper 4 bits reserved for future use uint8_t* extra = &data[k]; From b436192c3f9ddfb5a0443a9b19bf712f85d2011c Mon Sep 17 00:00:00 2001 From: Wessel Nieboer Date: Wed, 11 Feb 2026 03:41:16 +0100 Subject: [PATCH 35/56] add debug log for malformed PATH payload Log path_len and len when the bounds check fails, making it easier to diagnose malformed or corrupt packets during development. --- src/Mesh.cpp | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/Mesh.cpp b/src/Mesh.cpp index afc46df11..4dd4bc6d3 100644 --- a/src/Mesh.cpp +++ b/src/Mesh.cpp @@ -155,7 +155,10 @@ DispatcherAction Mesh::onRecvPacket(Packet* pkt) { uint8_t path_len = data[k++]; uint8_t hash_size = (path_len >> 6) + 1; uint8_t hash_count = path_len & 63; - if (k + hash_size*hash_count + 1 > len) break; // bounds check: need path bytes + extra_type byte + if (k + hash_size*hash_count + 1 > len) { // bounds check: need path bytes + extra_type byte + MESH_DEBUG_PRINTLN("%s Mesh::onRecvPacket(): bad PATH payload format, path_len=%d len=%d", getLogDateTime(), (int)path_len, (int)len); + break; + } uint8_t* path = &data[k]; k += hash_size*hash_count; uint8_t extra_type = data[k++] & 0x0F; // upper 4 bits reserved for future use uint8_t* extra = &data[k]; From 07b3d23c4d3cee79699d576e5e09b3c29f47d614 Mon Sep 17 00:00:00 2001 From: Wessel Nieboer Date: Wed, 11 Feb 2026 04:33:48 +0100 Subject: [PATCH 36/56] Add minimum payload_len check for TRACE packet parsing The TRACE handler reads 9 bytes (trace_tag, auth_code, flags) from the payload before any length validation. A short TRACE packet causes reads of stale buffer data and an underflow in the remaining-length calculation (uint8_t len = payload_len - 9 wraps to ~247). Add payload_len >= 9 to the existing guard condition so undersized TRACE packets are silently dropped. --- src/Mesh.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Mesh.cpp b/src/Mesh.cpp index 57fee1403..f867ccd23 100644 --- a/src/Mesh.cpp +++ b/src/Mesh.cpp @@ -40,7 +40,7 @@ int Mesh::searchChannelsByHash(const uint8_t* hash, GroupChannel channels[], int DispatcherAction Mesh::onRecvPacket(Packet* pkt) { if (pkt->isRouteDirect() && pkt->getPayloadType() == PAYLOAD_TYPE_TRACE) { - if (pkt->path_len < MAX_PATH_SIZE) { + if (pkt->path_len < MAX_PATH_SIZE && pkt->payload_len >= 9) { // need trace_tag(4) + auth_code(4) + flags(1) uint8_t i = 0; uint32_t trace_tag; memcpy(&trace_tag, &pkt->payload[i], 4); i += 4; From 1e179c233c2e09b55a6822d2216e515362a7c259 Mon Sep 17 00:00:00 2001 From: Wessel Nieboer Date: Wed, 18 Feb 2026 01:16:52 +0100 Subject: [PATCH 37/56] Use hardware channel activity detection for checking interference --- examples/companion_radio/MyMesh.cpp | 2 +- examples/simple_repeater/MyMesh.cpp | 2 +- examples/simple_room_server/MyMesh.cpp | 2 +- examples/simple_sensor/SensorMesh.cpp | 2 +- src/helpers/radiolib/RadioLibWrappers.cpp | 13 ++++++++++--- src/helpers/radiolib/RadioLibWrappers.h | 3 ++- 6 files changed, 16 insertions(+), 8 deletions(-) diff --git a/examples/companion_radio/MyMesh.cpp b/examples/companion_radio/MyMesh.cpp index c96f7e017..563f9323d 100644 --- a/examples/companion_radio/MyMesh.cpp +++ b/examples/companion_radio/MyMesh.cpp @@ -250,7 +250,7 @@ float MyMesh::getAirtimeBudgetFactor() const { } int MyMesh::getInterferenceThreshold() const { - return 0; // disabled for now, until currentRSSI() problem is resolved + return 1; // non-zero enables hardware CAD (Channel Activity Detection) before TX } int MyMesh::calcRxDelay(float score, uint32_t air_time) const { diff --git a/examples/simple_repeater/MyMesh.cpp b/examples/simple_repeater/MyMesh.cpp index 81c1dcb42..609d00e20 100644 --- a/examples/simple_repeater/MyMesh.cpp +++ b/examples/simple_repeater/MyMesh.cpp @@ -840,7 +840,7 @@ MyMesh::MyMesh(mesh::MainBoard &board, mesh::Radio &radio, mesh::MillisecondCloc _prefs.advert_interval = 1; // default to 2 minutes for NEW installs _prefs.flood_advert_interval = 12; // 12 hours _prefs.flood_max = 64; - _prefs.interference_threshold = 0; // disabled + _prefs.interference_threshold = 1; // non-zero enables hardware CAD before TX // bridge defaults _prefs.bridge_enabled = 1; // enabled diff --git a/examples/simple_room_server/MyMesh.cpp b/examples/simple_room_server/MyMesh.cpp index 5451505a2..cfd5344ff 100644 --- a/examples/simple_room_server/MyMesh.cpp +++ b/examples/simple_room_server/MyMesh.cpp @@ -616,7 +616,7 @@ MyMesh::MyMesh(mesh::MainBoard &board, mesh::Radio &radio, mesh::MillisecondCloc _prefs.advert_interval = 1; // default to 2 minutes for NEW installs _prefs.flood_advert_interval = 12; // 12 hours _prefs.flood_max = 64; - _prefs.interference_threshold = 0; // disabled + _prefs.interference_threshold = 1; // non-zero enables hardware CAD before TX #ifdef ROOM_PASSWORD StrHelper::strncpy(_prefs.guest_password, ROOM_PASSWORD, sizeof(_prefs.guest_password)); #endif diff --git a/examples/simple_sensor/SensorMesh.cpp b/examples/simple_sensor/SensorMesh.cpp index 68fea474e..7758beadb 100644 --- a/examples/simple_sensor/SensorMesh.cpp +++ b/examples/simple_sensor/SensorMesh.cpp @@ -723,7 +723,7 @@ SensorMesh::SensorMesh(mesh::MainBoard& board, mesh::Radio& radio, mesh::Millise _prefs.flood_advert_interval = 0; // disabled _prefs.disable_fwd = true; _prefs.flood_max = 64; - _prefs.interference_threshold = 0; // disabled + _prefs.interference_threshold = 1; // non-zero enables hardware CAD before TX // GPS defaults _prefs.gps_enabled = 0; diff --git a/src/helpers/radiolib/RadioLibWrappers.cpp b/src/helpers/radiolib/RadioLibWrappers.cpp index cf3e1266b..948625de8 100644 --- a/src/helpers/radiolib/RadioLibWrappers.cpp +++ b/src/helpers/radiolib/RadioLibWrappers.cpp @@ -157,10 +157,17 @@ void RadioLibWrapper::onSendFinished() { state = STATE_IDLE; } +int16_t RadioLibWrapper::performChannelScan() { + return _radio->scanChannel(); +} + bool RadioLibWrapper::isChannelActive() { - return _threshold == 0 - ? false // interference check is disabled - : getCurrentRSSI() > _noise_floor + _threshold; + if (_threshold == 0) return false; // interference check is disabled + + int16_t result = performChannelScan(); + // scanChannel() leaves radio in standby — restart RX regardless of result + startRecv(); + return (result == RADIOLIB_LORA_DETECTED); } float RadioLibWrapper::getLastRSSI() const { diff --git a/src/helpers/radiolib/RadioLibWrappers.h b/src/helpers/radiolib/RadioLibWrappers.h index 9ac1bbaeb..f70cbcb80 100644 --- a/src/helpers/radiolib/RadioLibWrappers.h +++ b/src/helpers/radiolib/RadioLibWrappers.h @@ -30,13 +30,14 @@ class RadioLibWrapper : public mesh::Radio { bool isInRecvMode() const override; bool isChannelActive(); - bool isReceiving() override { + bool isReceiving() override { if (isReceivingPacket()) return true; return isChannelActive(); } virtual float getCurrentRSSI() =0; + virtual int16_t performChannelScan(); int getNoiseFloor() const override { return _noise_floor; } void triggerNoiseFloorCalibrate(int threshold) override; From 4f36d806388703172ba86f48aed7a12fe015b687 Mon Sep 17 00:00:00 2001 From: Wessel Nieboer Date: Sun, 22 Feb 2026 16:05:18 +0100 Subject: [PATCH 38/56] Also return busy if preamble detected --- src/helpers/radiolib/RadioLibWrappers.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/helpers/radiolib/RadioLibWrappers.cpp b/src/helpers/radiolib/RadioLibWrappers.cpp index 948625de8..d9b1909ea 100644 --- a/src/helpers/radiolib/RadioLibWrappers.cpp +++ b/src/helpers/radiolib/RadioLibWrappers.cpp @@ -167,7 +167,7 @@ bool RadioLibWrapper::isChannelActive() { int16_t result = performChannelScan(); // scanChannel() leaves radio in standby — restart RX regardless of result startRecv(); - return (result == RADIOLIB_LORA_DETECTED); + return (result == RADIOLIB_LORA_DETECTED || result == RADIOLIB_PREAMBLE_DETECTED); } float RadioLibWrapper::getLastRSSI() const { From 75da5d5ba37ccaca5413f01e18ef3be86927b2f0 Mon Sep 17 00:00:00 2001 From: Wessel Nieboer Date: Sun, 22 Feb 2026 16:08:04 +0100 Subject: [PATCH 39/56] Just check for not channel free --- src/helpers/radiolib/RadioLibWrappers.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/helpers/radiolib/RadioLibWrappers.cpp b/src/helpers/radiolib/RadioLibWrappers.cpp index d9b1909ea..e73dddd10 100644 --- a/src/helpers/radiolib/RadioLibWrappers.cpp +++ b/src/helpers/radiolib/RadioLibWrappers.cpp @@ -167,7 +167,7 @@ bool RadioLibWrapper::isChannelActive() { int16_t result = performChannelScan(); // scanChannel() leaves radio in standby — restart RX regardless of result startRecv(); - return (result == RADIOLIB_LORA_DETECTED || result == RADIOLIB_PREAMBLE_DETECTED); + return result != RADIOLIB_CHANNEL_FREE; } float RadioLibWrapper::getLastRSSI() const { From d46fd6fe05f9c4ffee92289e14bdb573468b51a4 Mon Sep 17 00:00:00 2001 From: Wessel Nieboer Date: Wed, 11 Feb 2026 04:33:30 +0100 Subject: [PATCH 40/56] Validate PATH path_len against MAX_PATH_SIZE before use The path_len field inside decrypted PATH payloads was validated against the decrypted buffer size but not against MAX_PATH_SIZE (64). A malicious contact could send a PATH packet with path_len up to 178, overflowing out_path[64] in onPeerPathRecv and packet->path[64] in sendDirect. Add a MAX_PATH_SIZE check after parsing path_len from the decrypted PATH payload. Also add defensive bounds checks in sendDirect for both the TRACE payload-append path and the normal path-copy path. --- src/Mesh.cpp | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/src/Mesh.cpp b/src/Mesh.cpp index 57fee1403..23a92bf85 100644 --- a/src/Mesh.cpp +++ b/src/Mesh.cpp @@ -155,6 +155,10 @@ DispatcherAction Mesh::onRecvPacket(Packet* pkt) { uint8_t path_len = data[k++]; uint8_t hash_size = (path_len >> 6) + 1; uint8_t hash_count = path_len & 63; + if (hash_size*hash_count > MAX_PATH_SIZE) { + MESH_DEBUG_PRINTLN("%s Mesh::onRecvPacket(): bad PATH path_len=%d exceeds MAX_PATH_SIZE", getLogDateTime(), (int)path_len); + break; + } uint8_t* path = &data[k]; k += hash_size*hash_count; uint8_t extra_type = data[k++] & 0x0F; // upper 4 bits reserved for future use uint8_t* extra = &data[k]; @@ -683,12 +687,20 @@ void Mesh::sendDirect(Packet* packet, const uint8_t* path, uint8_t path_len, uin uint8_t pri; if (packet->getPayloadType() == PAYLOAD_TYPE_TRACE) { // TRACE packets are different // for TRACE packets, path is appended to end of PAYLOAD. (path is used for SNR's) + if (packet->payload_len + path_len > sizeof(packet->payload)) { + _mgr->free(packet); + return; + } memcpy(&packet->payload[packet->payload_len], path, path_len); // NOTE: path_len here can be > 64, and NOT in the new scheme packet->payload_len += path_len; packet->path_len = 0; pri = 5; // maybe make this configurable } else { + if (path_len > MAX_PATH_SIZE) { + _mgr->free(packet); + return; + } packet->path_len = Packet::copyPath(packet->path, path, path_len); if (packet->getPayloadType() == PAYLOAD_TYPE_PATH) { pri = 1; // slightly less priority From 7aae793551dd41b95625f54b94936bbec954befe Mon Sep 17 00:00:00 2001 From: Wessel Nieboer Date: Fri, 9 Jan 2026 04:54:51 +0100 Subject: [PATCH 41/56] Sync time with GPS every 30 minutes Unless your GPS is being spoofed there isn't really a downside to syncing more often with GPS. I understand the RTC is very stable, but especially with powersaving now clock drift is worse, we should sync more often. --- src/helpers/sensors/MicroNMEALocationProvider.h | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/helpers/sensors/MicroNMEALocationProvider.h b/src/helpers/sensors/MicroNMEALocationProvider.h index 574570a35..754cdf140 100644 --- a/src/helpers/sensors/MicroNMEALocationProvider.h +++ b/src/helpers/sensors/MicroNMEALocationProvider.h @@ -43,6 +43,8 @@ class MicroNMEALocationProvider : public LocationProvider { int _pin_en; long next_check = 0; long time_valid = 0; + unsigned long _last_time_sync = 0; + static const unsigned long TIME_SYNC_INTERVAL = 1800000; // Re-sync every 30 minutes public : MicroNMEALocationProvider(Stream& ser, mesh::RTCClock* clock = NULL, int pin_reset = GPS_RESET, int pin_en = GPS_EN,RefCountedDigitalPin* peripher_power=NULL) : @@ -126,10 +128,15 @@ public : if (millis() > next_check) { next_check = millis() + 1000; + // Re-enable time sync periodically when GPS has valid fix + if (!_time_sync_needed && _clock != NULL && (millis() - _last_time_sync) > TIME_SYNC_INTERVAL) { + _time_sync_needed = true; + } if (_time_sync_needed && time_valid > 2) { if (_clock != NULL) { _clock->setCurrentTime(getTimestamp()); _time_sync_needed = false; + _last_time_sync = millis(); } } if (isValid()) { From 4ae839ea726a5c6f5f4b832a154812cc4cfaebea Mon Sep 17 00:00:00 2001 From: Wessel Nieboer Date: Wed, 11 Feb 2026 04:18:18 +0100 Subject: [PATCH 42/56] validate advert payload length before parsing The ADVERT handler copied pub_key, timestamp, and signature from the payload before checking whether payload_len was large enough to contain them. With a short payload, the memcpy operations read uninitialized data from within the payload buffer. Move the bounds check before any parsing so undersized adverts are rejected immediately. The minimum required is PUB_KEY_SIZE + 4 + SIGNATURE_SIZE (100 bytes). --- src/Mesh.cpp | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/src/Mesh.cpp b/src/Mesh.cpp index 57fee1403..63c7c1e11 100644 --- a/src/Mesh.cpp +++ b/src/Mesh.cpp @@ -238,6 +238,12 @@ DispatcherAction Mesh::onRecvPacket(Packet* pkt) { } case PAYLOAD_TYPE_ADVERT: { int i = 0; + int min_advert_len = PUB_KEY_SIZE + 4 + SIGNATURE_SIZE; + if (pkt->payload_len < min_advert_len) { + MESH_DEBUG_PRINTLN("%s Mesh::onRecvPacket(): incomplete advertisement packet, payload_len=%d", getLogDateTime(), (int)pkt->payload_len); + break; + } + Identity id; memcpy(id.pub_key, &pkt->payload[i], PUB_KEY_SIZE); i += PUB_KEY_SIZE; @@ -245,9 +251,7 @@ DispatcherAction Mesh::onRecvPacket(Packet* pkt) { memcpy(×tamp, &pkt->payload[i], 4); i += 4; const uint8_t* signature = &pkt->payload[i]; i += SIGNATURE_SIZE; - if (i > pkt->payload_len) { - MESH_DEBUG_PRINTLN("%s Mesh::onRecvPacket(): incomplete advertisement packet", getLogDateTime()); - } else if (self_id.matches(id.pub_key)) { + if (self_id.matches(id.pub_key)) { MESH_DEBUG_PRINTLN("%s Mesh::onRecvPacket(): receiving SELF advert packet", getLogDateTime()); } else if (!_tables->hasSeen(pkt)) { uint8_t* app_data = &pkt->payload[i]; From eb8c5901f4414f86d8cf09ea561f4e4b59292669 Mon Sep 17 00:00:00 2001 From: Wessel Nieboer Date: Sat, 7 Feb 2026 16:18:23 +0100 Subject: [PATCH 43/56] Default button polarity to active-LOW across all firmware types Nearly all LoRa boards use a boot button that pulls to ground when pressed. --- examples/simple_repeater/UITask.cpp | 6 +++++- examples/simple_room_server/UITask.cpp | 6 +++++- examples/simple_sensor/UITask.cpp | 6 +++++- src/helpers/ESP32Board.h | 4 ++++ variants/minewsemi_me25ls01/MinewsemiME25LS01Board.h | 2 +- variants/minewsemi_me25ls01/platformio.ini | 1 - variants/t1000-e/T1000eBoard.h | 4 ++-- variants/t1000-e/platformio.ini | 1 - variants/thinknode_m3/platformio.ini | 1 - variants/wio-e5-mini/platformio.ini | 1 - variants/xiao_nrf52/XiaoNrf52Board.h | 6 +++++- 11 files changed, 27 insertions(+), 11 deletions(-) diff --git a/examples/simple_repeater/UITask.cpp b/examples/simple_repeater/UITask.cpp index d096d14b2..acb463258 100644 --- a/examples/simple_repeater/UITask.cpp +++ b/examples/simple_repeater/UITask.cpp @@ -2,6 +2,10 @@ #include #include +#ifndef USER_BTN_PRESSED +#define USER_BTN_PRESSED LOW +#endif + #define AUTO_OFF_MILLIS 20000 // 20 seconds #define BOOT_SCREEN_MILLIS 4000 // 4 seconds @@ -85,7 +89,7 @@ void UITask::loop() { if (millis() >= _next_read) { int btnState = digitalRead(PIN_USER_BTN); if (btnState != _prevBtnState) { - if (btnState == LOW) { // pressed? + if (btnState == USER_BTN_PRESSED) { // pressed? if (_display->isOn()) { // TODO: any action ? } else { diff --git a/examples/simple_room_server/UITask.cpp b/examples/simple_room_server/UITask.cpp index 46311c5eb..42bc14d4a 100644 --- a/examples/simple_room_server/UITask.cpp +++ b/examples/simple_room_server/UITask.cpp @@ -2,6 +2,10 @@ #include #include +#ifndef USER_BTN_PRESSED +#define USER_BTN_PRESSED LOW +#endif + #define AUTO_OFF_MILLIS 20000 // 20 seconds #define BOOT_SCREEN_MILLIS 4000 // 4 seconds @@ -85,7 +89,7 @@ void UITask::loop() { if (millis() >= _next_read) { int btnState = digitalRead(PIN_USER_BTN); if (btnState != _prevBtnState) { - if (btnState == LOW) { // pressed? + if (btnState == USER_BTN_PRESSED) { // pressed? if (_display->isOn()) { // TODO: any action ? } else { diff --git a/examples/simple_sensor/UITask.cpp b/examples/simple_sensor/UITask.cpp index 0694bc3c1..0e78fee00 100644 --- a/examples/simple_sensor/UITask.cpp +++ b/examples/simple_sensor/UITask.cpp @@ -2,6 +2,10 @@ #include #include +#ifndef USER_BTN_PRESSED +#define USER_BTN_PRESSED LOW +#endif + #define AUTO_OFF_MILLIS 20000 // 20 seconds #define BOOT_SCREEN_MILLIS 4000 // 4 seconds @@ -85,7 +89,7 @@ void UITask::loop() { if (millis() >= _next_read) { int btnState = digitalRead(PIN_USER_BTN); if (btnState != _prevBtnState) { - if (btnState == LOW) { // pressed? + if (btnState == USER_BTN_PRESSED) { // pressed? if (_display->isOn()) { // TODO: any action ? } else { diff --git a/src/helpers/ESP32Board.h b/src/helpers/ESP32Board.h index bade3e898..c2d78ae08 100644 --- a/src/helpers/ESP32Board.h +++ b/src/helpers/ESP32Board.h @@ -3,6 +3,10 @@ #include #include +#ifndef USER_BTN_PRESSED +#define USER_BTN_PRESSED LOW +#endif + #if defined(ESP_PLATFORM) #include diff --git a/variants/minewsemi_me25ls01/MinewsemiME25LS01Board.h b/variants/minewsemi_me25ls01/MinewsemiME25LS01Board.h index 6858a1062..4fa5cd41e 100644 --- a/variants/minewsemi_me25ls01/MinewsemiME25LS01Board.h +++ b/variants/minewsemi_me25ls01/MinewsemiME25LS01Board.h @@ -63,7 +63,7 @@ class MinewsemiME25LS01Board : public NRF52BoardDCDC { digitalWrite(LED_PIN, LOW); #endif #ifdef BUTTON_PIN - nrf_gpio_cfg_sense_input(digitalPinToInterrupt(BUTTON_PIN), NRF_GPIO_PIN_PULLUP, NRF_GPIO_PIN_SENSE_HIGH); + nrf_gpio_cfg_sense_input(digitalPinToInterrupt(BUTTON_PIN), NRF_GPIO_PIN_PULLUP, NRF_GPIO_PIN_SENSE_LOW); #endif sd_power_system_off(); } diff --git a/variants/minewsemi_me25ls01/platformio.ini b/variants/minewsemi_me25ls01/platformio.ini index fd9c3819f..dacd8d34e 100644 --- a/variants/minewsemi_me25ls01/platformio.ini +++ b/variants/minewsemi_me25ls01/platformio.ini @@ -21,7 +21,6 @@ build_flags = ${nrf52840_me25ls01.build_flags} -I variants/minewsemi_me25ls01 -D me25ls01 -D PIN_USER_BTN=27 - -D USER_BTN_PRESSED=HIGH -D PIN_STATUS_LED=39 -D P_LORA_TX_LED=22 -D RADIO_CLASS=CustomLR1110 diff --git a/variants/t1000-e/T1000eBoard.h b/variants/t1000-e/T1000eBoard.h index 492236077..8db270f9c 100644 --- a/variants/t1000-e/T1000eBoard.h +++ b/variants/t1000-e/T1000eBoard.h @@ -78,14 +78,14 @@ class T1000eBoard : public NRF52BoardDCDC { digitalWrite(LED_PIN, HIGH); #endif #ifdef BUTTON_PIN - while(digitalRead(BUTTON_PIN)); + while(digitalRead(BUTTON_PIN) == LOW); #endif #ifdef LED_PIN digitalWrite(LED_PIN, LOW); #endif #ifdef BUTTON_PIN - nrf_gpio_cfg_sense_input(BUTTON_PIN, NRF_GPIO_PIN_NOPULL, NRF_GPIO_PIN_SENSE_HIGH); + nrf_gpio_cfg_sense_input(BUTTON_PIN, NRF_GPIO_PIN_NOPULL, NRF_GPIO_PIN_SENSE_LOW); #endif sd_power_system_off(); diff --git a/variants/t1000-e/platformio.ini b/variants/t1000-e/platformio.ini index ac9293086..dcf691de7 100644 --- a/variants/t1000-e/platformio.ini +++ b/variants/t1000-e/platformio.ini @@ -10,7 +10,6 @@ build_flags = ${nrf52_base.build_flags} -I src/helpers/ui -D T1000_E -D PIN_USER_BTN=6 - -D USER_BTN_PRESSED=HIGH -D PIN_STATUS_LED=24 -D RADIO_CLASS=CustomLR1110 -D WRAPPER_CLASS=CustomLR1110Wrapper diff --git a/variants/thinknode_m3/platformio.ini b/variants/thinknode_m3/platformio.ini index 8ef2ba54a..88fd487aa 100644 --- a/variants/thinknode_m3/platformio.ini +++ b/variants/thinknode_m3/platformio.ini @@ -10,7 +10,6 @@ build_flags = ${nrf52_base.build_flags} -I src/helpers/ui -D THINKNODE_M3 -D PIN_USER_BTN=12 - -D USER_BTN_PRESSED=LOW -D PIN_STATUS_LED=35 -D RADIO_CLASS=CustomLR1110 -D WRAPPER_CLASS=CustomLR1110Wrapper diff --git a/variants/wio-e5-mini/platformio.ini b/variants/wio-e5-mini/platformio.ini index 837844437..f589ea032 100644 --- a/variants/wio-e5-mini/platformio.ini +++ b/variants/wio-e5-mini/platformio.ini @@ -9,7 +9,6 @@ build_flags = ${stm32_base.build_flags} -D RX_BOOSTED_GAIN=true -D P_LORA_TX_LED=LED_RED -D PIN_USER_BTN=USER_BTN - -D USER_BTN_PRESSED=LOW -I variants/wio-e5-mini build_src_filter = ${stm32_base.build_src_filter} +<../variants/wio-e5-mini> diff --git a/variants/xiao_nrf52/XiaoNrf52Board.h b/variants/xiao_nrf52/XiaoNrf52Board.h index bd0fd9b12..2790dbad9 100644 --- a/variants/xiao_nrf52/XiaoNrf52Board.h +++ b/variants/xiao_nrf52/XiaoNrf52Board.h @@ -4,6 +4,10 @@ #include #include +#ifndef USER_BTN_PRESSED +#define USER_BTN_PRESSED LOW +#endif + #ifdef XIAO_NRF52 class XiaoNrf52Board : public NRF52BoardDCDC { @@ -35,7 +39,7 @@ class XiaoNrf52Board : public NRF52BoardDCDC { // set led on and wait for button release before poweroff digitalWrite(PIN_LED, LOW); #ifdef PIN_USER_BTN - while(digitalRead(PIN_USER_BTN) == LOW); + while(digitalRead(PIN_USER_BTN) == USER_BTN_PRESSED); #endif digitalWrite(LED_GREEN, HIGH); digitalWrite(LED_BLUE, HIGH); From 762b0ec7c6b8997b9d90c5780df59cbc37cf3cae Mon Sep 17 00:00:00 2001 From: Wessel Nieboer Date: Sun, 8 Feb 2026 16:08:01 +0100 Subject: [PATCH 44/56] Fix T1000E press=high --- variants/t1000-e/T1000eBoard.h | 6 +++--- variants/t1000-e/platformio.ini | 1 + 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/variants/t1000-e/T1000eBoard.h b/variants/t1000-e/T1000eBoard.h index 8db270f9c..e7653fb2f 100644 --- a/variants/t1000-e/T1000eBoard.h +++ b/variants/t1000-e/T1000eBoard.h @@ -43,7 +43,7 @@ class T1000eBoard : public NRF52BoardDCDC { uint8_t v = digitalRead(BUTTON_PIN); if (v != btn_prev_state) { btn_prev_state = v; - return (v == LOW) ? 1 : -1; + return (v == USER_BTN_PRESSED) ? 1 : -1; } #endif return 0; @@ -78,14 +78,14 @@ class T1000eBoard : public NRF52BoardDCDC { digitalWrite(LED_PIN, HIGH); #endif #ifdef BUTTON_PIN - while(digitalRead(BUTTON_PIN) == LOW); + while(digitalRead(BUTTON_PIN)); #endif #ifdef LED_PIN digitalWrite(LED_PIN, LOW); #endif #ifdef BUTTON_PIN - nrf_gpio_cfg_sense_input(BUTTON_PIN, NRF_GPIO_PIN_NOPULL, NRF_GPIO_PIN_SENSE_LOW); + nrf_gpio_cfg_sense_input(BUTTON_PIN, NRF_GPIO_PIN_NOPULL, NRF_GPIO_PIN_SENSE_HIGH); #endif sd_power_system_off(); diff --git a/variants/t1000-e/platformio.ini b/variants/t1000-e/platformio.ini index dcf691de7..ac9293086 100644 --- a/variants/t1000-e/platformio.ini +++ b/variants/t1000-e/platformio.ini @@ -10,6 +10,7 @@ build_flags = ${nrf52_base.build_flags} -I src/helpers/ui -D T1000_E -D PIN_USER_BTN=6 + -D USER_BTN_PRESSED=HIGH -D PIN_STATUS_LED=24 -D RADIO_CLASS=CustomLR1110 -D WRAPPER_CLASS=CustomLR1110Wrapper From d5a512c960d3344c5167fa2c1398ddff80caa55f Mon Sep 17 00:00:00 2001 From: Wessel Nieboer Date: Mon, 16 Feb 2026 16:55:32 +0100 Subject: [PATCH 45/56] Make retry delay random between 50,501 to prevent collisions even more --- src/Mesh.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Mesh.cpp b/src/Mesh.cpp index 57fee1403..effc39591 100644 --- a/src/Mesh.cpp +++ b/src/Mesh.cpp @@ -27,7 +27,7 @@ uint8_t Mesh::getExtraAckTransmitCount() const { } uint32_t Mesh::getCADFailRetryDelay() const { - return _rng->nextInt(1, 4)*120; + return _rng->nextInt(50, 501); } int Mesh::searchPeersByHash(const uint8_t* hash) { From e518469c69342f25a05bf639e24bd2cc698f2236 Mon Sep 17 00:00:00 2001 From: Wessel Nieboer Date: Fri, 6 Feb 2026 03:31:34 +0100 Subject: [PATCH 46/56] Allow setting RTC clock backwards and fix elapsed-time underflow Remove the "clock cannot go backwards" restriction from all set-time paths (CLI `time`, `clock sync`, companion radio CMD_SET_DEVICE_TIME, and simple_secure_chat). The ESP32-S3 RTC drifts 5-10% during deep sleep, making backwards correction necessary after even a few days. Add safeElapsedSecs() helper in ArduinoHelpers.h that clamps elapsed time to 0 when a stored timestamp appears to be in the future after a clock correction. Applied to: - Neighbor "heard X ago" displays in simple_repeater - UI time displays in companion_radio - TimeSeriesData calculations in simple_sensor Switch BaseChatMesh connection expiry from RTC timestamps to monotonic millis(), making it immune to RTC adjustments from GPS, NTP, or manual sync. Rename last_activity to last_activity_ms to reflect the change. --- examples/companion_radio/MyMesh.cpp | 9 ++------ examples/companion_radio/ui-new/UITask.cpp | 4 ++-- examples/simple_repeater/MyMesh.cpp | 4 ++-- examples/simple_secure_chat/main.cpp | 9 ++------ examples/simple_sensor/TimeSeriesData.cpp | 3 ++- src/helpers/ArduinoHelpers.h | 9 ++++++++ src/helpers/BaseChatMesh.cpp | 19 +++++++++------- src/helpers/BaseChatMesh.h | 2 +- src/helpers/CommonCLI.cpp | 26 +++++++--------------- 9 files changed, 39 insertions(+), 46 deletions(-) diff --git a/examples/companion_radio/MyMesh.cpp b/examples/companion_radio/MyMesh.cpp index c96f7e017..9d2487cf2 100644 --- a/examples/companion_radio/MyMesh.cpp +++ b/examples/companion_radio/MyMesh.cpp @@ -1099,13 +1099,8 @@ void MyMesh::handleCmdFrame(size_t len) { } else if (cmd_frame[0] == CMD_SET_DEVICE_TIME && len >= 5) { uint32_t secs; memcpy(&secs, &cmd_frame[1], 4); - uint32_t curr = getRTCClock()->getCurrentTime(); - if (secs >= curr) { - getRTCClock()->setCurrentTime(secs); - writeOKFrame(); - } else { - writeErrFrame(ERR_CODE_ILLEGAL_ARG); - } + getRTCClock()->setCurrentTime(secs); + writeOKFrame(); } else if (cmd_frame[0] == CMD_SEND_SELF_ADVERT) { mesh::Packet* pkt; if (_prefs.advert_loc_policy == ADVERT_LOC_NONE) { diff --git a/examples/companion_radio/ui-new/UITask.cpp b/examples/companion_radio/ui-new/UITask.cpp index 265532be0..4eaa80684 100644 --- a/examples/companion_radio/ui-new/UITask.cpp +++ b/examples/companion_radio/ui-new/UITask.cpp @@ -233,7 +233,7 @@ class HomeScreen : public UIScreen { for (int i = 0; i < UI_RECENT_LIST_SIZE; i++, y += 11) { auto a = &recent[i]; if (a->name[0] == 0) continue; // empty slot - int secs = _rtc->getCurrentTime() - a->recv_timestamp; + uint32_t secs = safeElapsedSecs(_rtc->getCurrentTime(), a->recv_timestamp); if (secs < 60) { sprintf(tmp, "%ds", secs); } else if (secs < 60*60) { @@ -496,7 +496,7 @@ class MsgPreviewScreen : public UIScreen { auto p = &unread[head]; - int secs = _rtc->getCurrentTime() - p->timestamp; + uint32_t secs = safeElapsedSecs(_rtc->getCurrentTime(), p->timestamp); if (secs < 60) { sprintf(tmp, "%ds", secs); } else if (secs < 60*60) { diff --git a/examples/simple_repeater/MyMesh.cpp b/examples/simple_repeater/MyMesh.cpp index 81c1dcb42..d18ab7628 100644 --- a/examples/simple_repeater/MyMesh.cpp +++ b/examples/simple_repeater/MyMesh.cpp @@ -355,7 +355,7 @@ int MyMesh::handleRequest(ClientInfo *sender, uint32_t sender_timestamp, uint8_t #if MAX_NEIGHBOURS // add next neighbour to results auto neighbour = sorted_neighbours[index + offset]; - uint32_t heard_seconds_ago = getRTCClock()->getCurrentTime() - neighbour->heard_timestamp; + uint32_t heard_seconds_ago = safeElapsedSecs(getRTCClock()->getCurrentTime(), neighbour->heard_timestamp); memcpy(&results_buffer[results_offset], neighbour->id.pub_key, pubkey_prefix_length); results_offset += pubkey_prefix_length; memcpy(&results_buffer[results_offset], &heard_seconds_ago, 4); results_offset += 4; memcpy(&results_buffer[results_offset], &neighbour->snr, 1); results_offset += 1; @@ -993,7 +993,7 @@ void MyMesh::formatNeighborsReply(char *reply) { mesh::Utils::toHex(hex, neighbour->id.pub_key, 4); // add next neighbour - uint32_t secs_ago = getRTCClock()->getCurrentTime() - neighbour->heard_timestamp; + uint32_t secs_ago = safeElapsedSecs(getRTCClock()->getCurrentTime(), neighbour->heard_timestamp); sprintf(dp, "%s:%d:%d", hex, secs_ago, neighbour->snr); while (*dp) dp++; // find end of string diff --git a/examples/simple_secure_chat/main.cpp b/examples/simple_secure_chat/main.cpp index c1ed710ab..38abca44c 100644 --- a/examples/simple_secure_chat/main.cpp +++ b/examples/simple_secure_chat/main.cpp @@ -158,13 +158,8 @@ class MyMesh : public BaseChatMesh, ContactVisitor { } void setClock(uint32_t timestamp) { - uint32_t curr = getRTCClock()->getCurrentTime(); - if (timestamp > curr) { - getRTCClock()->setCurrentTime(timestamp); - Serial.println(" (OK - clock set!)"); - } else { - Serial.println(" (ERR: clock cannot go backwards)"); - } + getRTCClock()->setCurrentTime(timestamp); + Serial.println(" (OK - clock set!)"); } void importCard(const char* command) { diff --git a/examples/simple_sensor/TimeSeriesData.cpp b/examples/simple_sensor/TimeSeriesData.cpp index f6157f9af..c780c7de2 100644 --- a/examples/simple_sensor/TimeSeriesData.cpp +++ b/examples/simple_sensor/TimeSeriesData.cpp @@ -1,4 +1,5 @@ #include "TimeSeriesData.h" +#include void TimeSeriesData::recordData(mesh::RTCClock* clock, float value) { uint32_t now = clock->getCurrentTime(); @@ -12,7 +13,7 @@ void TimeSeriesData::recordData(mesh::RTCClock* clock, float value) { void TimeSeriesData::calcMinMaxAvg(mesh::RTCClock* clock, uint32_t start_secs_ago, uint32_t end_secs_ago, MinMaxAvg* dest, uint8_t channel, uint8_t lpp_type) const { int i = next, n = num_slots; - uint32_t ago = clock->getCurrentTime() - last_timestamp; + uint32_t ago = safeElapsedSecs(clock->getCurrentTime(), last_timestamp); int num_values = 0; float total = 0.0f; diff --git a/src/helpers/ArduinoHelpers.h b/src/helpers/ArduinoHelpers.h index 97596daa3..5f12e5b01 100644 --- a/src/helpers/ArduinoHelpers.h +++ b/src/helpers/ArduinoHelpers.h @@ -3,6 +3,15 @@ #include #include +// Safe elapsed time calculation that handles clock corrections (when RTC is set backwards). +// Returns 0 if recorded_timestamp is in the "future" relative to current_time. +inline uint32_t safeElapsedSecs(uint32_t current_time, uint32_t recorded_timestamp) { + if (recorded_timestamp > current_time) { + return 0; // Clock was corrected backwards; treat as "just now" + } + return current_time - recorded_timestamp; +} + class VolatileRTCClock : public mesh::RTCClock { uint32_t base_time; uint64_t accumulator; diff --git a/src/helpers/BaseChatMesh.cpp b/src/helpers/BaseChatMesh.cpp index 5ec678c7f..11e416120 100644 --- a/src/helpers/BaseChatMesh.cpp +++ b/src/helpers/BaseChatMesh.cpp @@ -609,7 +609,7 @@ bool BaseChatMesh::startConnection(const ContactInfo& contact, uint16_t keep_ali uint32_t interval = connections[use_idx].keep_alive_millis = ((uint32_t)keep_alive_secs)*1000; connections[use_idx].next_ping = futureMillis(interval); connections[use_idx].expected_ack = 0; - connections[use_idx].last_activity = getRTCClock()->getCurrentTime(); + connections[use_idx].last_activity_ms = _ms->getMillis(); return true; // success } @@ -619,7 +619,7 @@ void BaseChatMesh::stopConnection(const uint8_t* pub_key) { connections[i].keep_alive_millis = 0; // mark slot as now free connections[i].next_ping = 0; connections[i].expected_ack = 0; - connections[i].last_activity = 0; + connections[i].last_activity_ms = 0; break; } } @@ -635,7 +635,7 @@ bool BaseChatMesh::hasConnectionTo(const uint8_t* pub_key) { void BaseChatMesh::markConnectionActive(const ContactInfo& contact) { for (int i = 0; i < MAX_CONNECTIONS; i++) { if (connections[i].keep_alive_millis > 0 && connections[i].server_id.matches(contact.id)) { - connections[i].last_activity = getRTCClock()->getCurrentTime(); + connections[i].last_activity_ms = _ms->getMillis(); // re-schedule next KEEP_ALIVE, now that we have heard from server connections[i].next_ping = futureMillis(connections[i].keep_alive_millis); @@ -649,7 +649,7 @@ ContactInfo* BaseChatMesh::checkConnectionsAck(const uint8_t* data) { if (connections[i].keep_alive_millis > 0 && memcmp(&connections[i].expected_ack, data, 4) == 0) { // yes, got an ack for our keep_alive request! connections[i].expected_ack = 0; - connections[i].last_activity = getRTCClock()->getCurrentTime(); + connections[i].last_activity_ms = _ms->getMillis(); // re-schedule next KEEP_ALIVE, now that we have heard from server connections[i].next_ping = futureMillis(connections[i].keep_alive_millis); @@ -666,14 +666,17 @@ void BaseChatMesh::checkConnections() { for (int i = 0; i < MAX_CONNECTIONS; i++) { if (connections[i].keep_alive_millis == 0) continue; // unused slot - uint32_t now = getRTCClock()->getCurrentTime(); - uint32_t expire_secs = (connections[i].keep_alive_millis / 1000) * 5 / 2; // 2.5 x keep_alive interval - if (now >= connections[i].last_activity + expire_secs) { + // Monotonic time is immune to RTC clock changes (GPS, NTP, manual sync). + // Assumes light sleep (millis() keeps incrementing). Deep sleep resets millis(), + // but BaseChatMesh is only used by companion_radio which uses light sleep. + unsigned long now = _ms->getMillis(); + unsigned long expire_millis = (connections[i].keep_alive_millis * 5UL) / 2; // 2.5 x keep_alive interval + if ((now - connections[i].last_activity_ms) >= expire_millis) { // connection now lost connections[i].keep_alive_millis = 0; connections[i].next_ping = 0; connections[i].expected_ack = 0; - connections[i].last_activity = 0; + connections[i].last_activity_ms = 0; continue; } diff --git a/src/helpers/BaseChatMesh.h b/src/helpers/BaseChatMesh.h index fd391b980..019a34298 100644 --- a/src/helpers/BaseChatMesh.h +++ b/src/helpers/BaseChatMesh.h @@ -44,7 +44,7 @@ class ContactsIterator { struct ConnectionInfo { mesh::Identity server_id; unsigned long next_ping; - uint32_t last_activity; + unsigned long last_activity_ms; // monotonic millis() for connection expiry uint32_t keep_alive_millis; uint32_t expected_ack; }; diff --git a/src/helpers/CommonCLI.cpp b/src/helpers/CommonCLI.cpp index e20bbb1c0..aa2a79c9a 100644 --- a/src/helpers/CommonCLI.cpp +++ b/src/helpers/CommonCLI.cpp @@ -208,15 +208,10 @@ void CommonCLI::handleCommand(uint32_t sender_timestamp, const char* command, ch _callbacks->sendSelfAdvertisement(1500, true); // longer delay, give CLI response time to be sent first strcpy(reply, "OK - Advert sent"); } else if (memcmp(command, "clock sync", 10) == 0) { - uint32_t curr = getRTCClock()->getCurrentTime(); - if (sender_timestamp > curr) { - getRTCClock()->setCurrentTime(sender_timestamp + 1); - uint32_t now = getRTCClock()->getCurrentTime(); - DateTime dt = DateTime(now); - sprintf(reply, "OK - clock set: %02d:%02d - %d/%d/%d UTC", dt.hour(), dt.minute(), dt.day(), dt.month(), dt.year()); - } else { - strcpy(reply, "ERR: clock cannot go backwards"); - } + getRTCClock()->setCurrentTime(sender_timestamp + 1); + uint32_t now = getRTCClock()->getCurrentTime(); + DateTime dt = DateTime(now); + sprintf(reply, "OK - clock set: %02d:%02d - %d/%d/%d UTC", dt.hour(), dt.minute(), dt.day(), dt.month(), dt.year()); } else if (memcmp(command, "start ota", 9) == 0) { if (!_board->startOTAUpdate(_prefs->node_name, reply)) { strcpy(reply, "Error"); @@ -227,15 +222,10 @@ void CommonCLI::handleCommand(uint32_t sender_timestamp, const char* command, ch sprintf(reply, "%02d:%02d - %d/%d/%d UTC", dt.hour(), dt.minute(), dt.day(), dt.month(), dt.year()); } else if (memcmp(command, "time ", 5) == 0) { // set time (to epoch seconds) uint32_t secs = _atoi(&command[5]); - uint32_t curr = getRTCClock()->getCurrentTime(); - if (secs > curr) { - getRTCClock()->setCurrentTime(secs); - uint32_t now = getRTCClock()->getCurrentTime(); - DateTime dt = DateTime(now); - sprintf(reply, "OK - clock set: %02d:%02d - %d/%d/%d UTC", dt.hour(), dt.minute(), dt.day(), dt.month(), dt.year()); - } else { - strcpy(reply, "(ERR: clock cannot go backwards)"); - } + getRTCClock()->setCurrentTime(secs); + uint32_t now = getRTCClock()->getCurrentTime(); + DateTime dt = DateTime(now); + sprintf(reply, "OK - clock set: %02d:%02d - %d/%d/%d UTC", dt.hour(), dt.minute(), dt.day(), dt.month(), dt.year()); } else if (memcmp(command, "neighbors", 9) == 0) { _callbacks->formatNeighborsReply(reply); } else if (memcmp(command, "neighbor.remove ", 16) == 0) { From f2c78c675ca794fba340c06ce9228618be2b12ca Mon Sep 17 00:00:00 2001 From: Wessel Nieboer Date: Wed, 11 Feb 2026 04:17:26 +0100 Subject: [PATCH 47/56] fix infinite loop in WiFi frame skip when read fails The frame-skip loops in checkRecvFrame subtract the return value of client.read() from frame_length. On ESP32, WiFiClient::read() returns -1 on error. Subtracting -1 increments frame_length instead of decrementing it, turning the loop into an infinite hang. A WiFi client can trigger this by sending a frame header with a large length and then disconnecting (or sending fewer bytes than claimed). The node locks up in the skip loop and stops processing all traffic. Switch to single-byte client.read() which returns the byte value or -1, and break out of the loop on error. Decrement frame_length by exactly 1 per successful read. --- src/helpers/esp32/SerialWifiInterface.cpp | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/helpers/esp32/SerialWifiInterface.cpp b/src/helpers/esp32/SerialWifiInterface.cpp index 462e3ecc3..7203d9d22 100644 --- a/src/helpers/esp32/SerialWifiInterface.cpp +++ b/src/helpers/esp32/SerialWifiInterface.cpp @@ -131,9 +131,9 @@ size_t SerialWifiInterface::checkRecvFrame(uint8_t dest[]) { if(frame_length > MAX_FRAME_SIZE){ WIFI_DEBUG_PRINTLN("Skipping frame: length=%d is larger than MAX_FRAME_SIZE=%d", frame_length, MAX_FRAME_SIZE); while(frame_length > 0){ - uint8_t skip[1]; - int skipped = client.read(skip, 1); - frame_length -= skipped; + int skipped = client.read(); + if(skipped < 0) break; // read error, stop draining + frame_length--; } resetReceivedFrameHeader(); return 0; @@ -144,9 +144,9 @@ size_t SerialWifiInterface::checkRecvFrame(uint8_t dest[]) { if(frame_type != '<'){ WIFI_DEBUG_PRINTLN("Skipping frame: type=0x%x is unexpected", frame_type); while(frame_length > 0){ - uint8_t skip[1]; - int skipped = client.read(skip, 1); - frame_length -= skipped; + int skipped = client.read(); + if(skipped < 0) break; // read error, stop draining + frame_length--; } resetReceivedFrameHeader(); return 0; From a2108ec9541f7dcaf5842f5c5187135b054e08ae Mon Sep 17 00:00:00 2001 From: Wessel Nieboer Date: Fri, 6 Feb 2026 03:48:15 +0100 Subject: [PATCH 48/56] Fix orphan blob accumulation on firmware upgrade Before PR#1495, blobs were written for every advert regardless of whether the advertiser became a contact. Nodes upgrading from older firmware (e.g., v1.11.0) have thousands of orphan blobs consuming storage space. This adds a one-time cleanup on first boot that: - Iterates all blob files in /bl/ - Deletes any blob that doesn't match a current contact's pub_key - Creates a marker file to prevent running again Affects ESP32 and RP2040 platforms (which use /bl/ directory). --- examples/companion_radio/MyMesh.cpp | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/examples/companion_radio/MyMesh.cpp b/examples/companion_radio/MyMesh.cpp index c96f7e017..ab46f9960 100644 --- a/examples/companion_radio/MyMesh.cpp +++ b/examples/companion_radio/MyMesh.cpp @@ -878,6 +878,35 @@ void MyMesh::begin(bool has_display) { resetContacts(); _store->loadContacts(this); bootstrapRTCfromContacts(); + +#if defined(ESP32) || defined(RP2040_PLATFORM) + // One-time cleanup of orphan blobs from pre-v1.13 firmware + FILESYSTEM* fs = _store->getPrimaryFS(); + if (!fs->exists("/bl/.cleaned")) { + MESH_DEBUG_PRINTLN("Cleaning orphan blobs..."); + File root = _store->openRead("/bl"); + if (root) { + for (File f = root.openNextFile(); f; f = root.openNextFile()) { + const char* name = f.name(); + f.close(); + uint8_t key[8]; + if (name[0] != '.' && strlen(name) == 16 && mesh::Utils::fromHex(key, 8, name)) { + bool found = false; + for (int i = 0; i < num_contacts && !found; i++) + found = (memcmp(contacts[i].id.pub_key, key, 8) == 0); + if (!found) _store->deleteBlobByKey(key, 8); + } + } + root.close(); + } +#if defined(ESP32) + File m = fs->open("/bl/.cleaned", "w", true); +#else + File m = fs->open("/bl/.cleaned", "w"); +#endif + if (m) m.close(); + } +#endif addChannel("Public", PUBLIC_GROUP_PSK); // pre-configure Andy's public channel _store->loadChannels(this); From 8e715086e7b4e43bf2c511c2af46f26eb50b9474 Mon Sep 17 00:00:00 2001 From: Wessel Nieboer Date: Fri, 6 Feb 2026 09:39:26 +0100 Subject: [PATCH 49/56] Move cleanup logic to DataStore --- examples/companion_radio/DataStore.cpp | 35 +++++++++++++++++++++++++- examples/companion_radio/DataStore.h | 1 + examples/companion_radio/MyMesh.cpp | 30 ++-------------------- 3 files changed, 37 insertions(+), 29 deletions(-) diff --git a/examples/companion_radio/DataStore.cpp b/examples/companion_radio/DataStore.cpp index fba64e8c6..e97461a85 100644 --- a/examples/companion_radio/DataStore.cpp +++ b/examples/companion_radio/DataStore.cpp @@ -565,6 +565,7 @@ bool DataStore::putBlobByKey(const uint8_t key[], int key_len, const uint8_t src bool DataStore::deleteBlobByKey(const uint8_t key[], int key_len) { return true; // this is just a stub on NRF52/STM32 platforms } +void DataStore::cleanOrphanBlobs(DataStoreHost* host) {} #else inline void makeBlobPath(const uint8_t key[], int key_len, char* path, size_t path_size) { char fname[18]; @@ -608,7 +609,39 @@ bool DataStore::deleteBlobByKey(const uint8_t key[], int key_len) { makeBlobPath(key, key_len, path, sizeof(path)); _fs->remove(path); - + return true; // return true even if file did not exist } + +void DataStore::cleanOrphanBlobs(DataStoreHost* host) { + if (_fs->exists("/bl/.cleaned")) return; + MESH_DEBUG_PRINTLN("Cleaning orphan blobs..."); + File root = openRead("/bl"); + if (root) { + for (File f = root.openNextFile(); f; f = root.openNextFile()) { + const char* name = f.name(); + f.close(); + if (name[0] == '.' || strlen(name) != 16) continue; + uint8_t file_key[8]; + if (!mesh::Utils::fromHex(file_key, 8, name)) continue; + bool found = false; + ContactInfo c; + for (uint32_t i = 0; host->getContactForSave(i, c) && !found; i++) { + found = (memcmp(file_key, c.id.pub_key, 8) == 0); + } + if (!found) { + char path[24]; + sprintf(path, "/bl/%s", name); + _fs->remove(path); + } + } + root.close(); + } +#if defined(ESP32) + File m = _fs->open("/bl/.cleaned", "w", true); +#else + File m = _fs->open("/bl/.cleaned", "w"); +#endif + if (m) m.close(); +} #endif diff --git a/examples/companion_radio/DataStore.h b/examples/companion_radio/DataStore.h index 58b4d5d28..91dd329a3 100644 --- a/examples/companion_radio/DataStore.h +++ b/examples/companion_radio/DataStore.h @@ -43,6 +43,7 @@ class DataStore { uint8_t getBlobByKey(const uint8_t key[], int key_len, uint8_t dest_buf[]); bool putBlobByKey(const uint8_t key[], int key_len, const uint8_t src_buf[], uint8_t len); bool deleteBlobByKey(const uint8_t key[], int key_len); + void cleanOrphanBlobs(DataStoreHost* host); File openRead(const char* filename); File openRead(FILESYSTEM* fs, const char* filename); bool removeFile(const char* filename); diff --git a/examples/companion_radio/MyMesh.cpp b/examples/companion_radio/MyMesh.cpp index ab46f9960..c6978edaa 100644 --- a/examples/companion_radio/MyMesh.cpp +++ b/examples/companion_radio/MyMesh.cpp @@ -879,34 +879,8 @@ void MyMesh::begin(bool has_display) { _store->loadContacts(this); bootstrapRTCfromContacts(); -#if defined(ESP32) || defined(RP2040_PLATFORM) - // One-time cleanup of orphan blobs from pre-v1.13 firmware - FILESYSTEM* fs = _store->getPrimaryFS(); - if (!fs->exists("/bl/.cleaned")) { - MESH_DEBUG_PRINTLN("Cleaning orphan blobs..."); - File root = _store->openRead("/bl"); - if (root) { - for (File f = root.openNextFile(); f; f = root.openNextFile()) { - const char* name = f.name(); - f.close(); - uint8_t key[8]; - if (name[0] != '.' && strlen(name) == 16 && mesh::Utils::fromHex(key, 8, name)) { - bool found = false; - for (int i = 0; i < num_contacts && !found; i++) - found = (memcmp(contacts[i].id.pub_key, key, 8) == 0); - if (!found) _store->deleteBlobByKey(key, 8); - } - } - root.close(); - } -#if defined(ESP32) - File m = fs->open("/bl/.cleaned", "w", true); -#else - File m = fs->open("/bl/.cleaned", "w"); -#endif - if (m) m.close(); - } -#endif + _store->cleanOrphanBlobs(this); + addChannel("Public", PUBLIC_GROUP_PSK); // pre-configure Andy's public channel _store->loadChannels(this); From a7e92f62acd9a0bb56879bb9b65b118bf886ae9a Mon Sep 17 00:00:00 2001 From: Wessel Nieboer Date: Thu, 8 Jan 2026 17:02:58 +0100 Subject: [PATCH 50/56] Save some more power when BLE/WiFi is disabled on the companion radio --- examples/companion_radio/MyMesh.cpp | 5 ++++ examples/companion_radio/MyMesh.h | 1 + examples/companion_radio/main.cpp | 35 +++++++++++++++++++++++ src/helpers/ESP32Board.h | 12 ++++++-- src/helpers/esp32/SerialWifiInterface.cpp | 22 +++++++++++++- src/helpers/esp32/SerialWifiInterface.h | 4 +++ 6 files changed, 76 insertions(+), 3 deletions(-) diff --git a/examples/companion_radio/MyMesh.cpp b/examples/companion_radio/MyMesh.cpp index c96f7e017..48a4ce3f4 100644 --- a/examples/companion_radio/MyMesh.cpp +++ b/examples/companion_radio/MyMesh.cpp @@ -2046,3 +2046,8 @@ bool MyMesh::advert() { return false; } } + +// Check if there is pending work (packets to send) +bool MyMesh::hasPendingWork() const { + return _mgr->getOutboundCount(0xFFFFFFFF) > 0; +} diff --git a/examples/companion_radio/MyMesh.h b/examples/companion_radio/MyMesh.h index 87e6cf338..ad766af1e 100644 --- a/examples/companion_radio/MyMesh.h +++ b/examples/companion_radio/MyMesh.h @@ -161,6 +161,7 @@ class MyMesh : public BaseChatMesh, public DataStoreHost { public: void savePrefs() { _store->savePrefs(_prefs, sensors.node_lat, sensors.node_lon); } + bool hasPendingWork() const; private: void writeOKFrame(); diff --git a/examples/companion_radio/main.cpp b/examples/companion_radio/main.cpp index eff9efca4..0674ddea8 100644 --- a/examples/companion_radio/main.cpp +++ b/examples/companion_radio/main.cpp @@ -99,6 +99,11 @@ MyMesh the_mesh(radio_driver, fast_rng, rtc_clock, tables, store #endif ); +// Power saving timing variables +unsigned long lastActive = 0; // Last time there was activity +unsigned long nextSleepInSecs = 120; // Wait 2 minutes before first sleep +const unsigned long WORK_TIME_SECS = 5; // Stay awake 5 seconds after wake/activity + /* END GLOBAL OBJECTS */ void halt() { @@ -216,6 +221,9 @@ void setup() { #ifdef DISPLAY_CLASS ui_task.begin(disp, &sensors, the_mesh.getNodePrefs()); // still want to pass this in as dependency, as prefs might be moved #endif + + // Initialize power saving timer + lastActive = millis(); } void loop() { @@ -225,4 +233,31 @@ void loop() { ui_task.loop(); #endif rtc_clock.tick(); + + // Power saving when BLE/WiFi is disabled + // Don't sleep if GPS is enabled - it needs continuous operation to maintain fix + // Note: Disabling BLE/WiFi via UI actually turns off the radio to save power + if (!serial_interface.isEnabled() && !the_mesh.getNodePrefs()->gps_enabled) { + // Check for pending work and update activity timer + if (the_mesh.hasPendingWork()) { + lastActive = millis(); + if (nextSleepInSecs < 10) { + nextSleepInSecs += 5; // Extend work time by 5s if still busy + } + } + + // Only sleep if enough time has passed since last activity + if (millis() >= lastActive + (nextSleepInSecs * 1000)) { +#ifdef PIN_USER_BTN + // Sleep for 30 minutes, wake on LoRa packet, timer, or button press + board.enterLightSleep(1800, PIN_USER_BTN); +#else + // Sleep for 30 minutes, wake on LoRa packet or timer + board.enterLightSleep(1800); +#endif + // Just woke up - reset timers + lastActive = millis(); + nextSleepInSecs = WORK_TIME_SECS; // Stay awake for 5s after wake + } + } } diff --git a/src/helpers/ESP32Board.h b/src/helpers/ESP32Board.h index bade3e898..c7c63b691 100644 --- a/src/helpers/ESP32Board.h +++ b/src/helpers/ESP32Board.h @@ -9,6 +9,7 @@ #include #include #include "driver/rtc_io.h" +#include "driver/gpio.h" class ESP32Board : public mesh::MainBoard { protected: @@ -56,11 +57,18 @@ class ESP32Board : public mesh::MainBoard { return raw / 4; } - void enterLightSleep(uint32_t secs) { + void enterLightSleep(uint32_t secs, int pin_wake_btn = -1) { #if defined(CONFIG_IDF_TARGET_ESP32S3) && defined(P_LORA_DIO_1) // Supported ESP32 variants if (rtc_gpio_is_valid_gpio((gpio_num_t)P_LORA_DIO_1)) { // Only enter sleep mode if P_LORA_DIO_1 is RTC pin esp_sleep_pd_config(ESP_PD_DOMAIN_RTC_PERIPH, ESP_PD_OPTION_ON); - esp_sleep_enable_ext1_wakeup((1L << P_LORA_DIO_1), ESP_EXT1_WAKEUP_ANY_HIGH); // To wake up when receiving a LoRa packet + + esp_sleep_enable_ext1_wakeup((1L << P_LORA_DIO_1), ESP_EXT1_WAKEUP_ANY_HIGH); // Wake on LoRa packet + + // Wake on button press (active-LOW: pin is HIGH when idle, LOW when pressed) + if (pin_wake_btn >= 0) { + gpio_wakeup_enable((gpio_num_t)pin_wake_btn, GPIO_INTR_LOW_LEVEL); + esp_sleep_enable_gpio_wakeup(); + } if (secs > 0) { esp_sleep_enable_timer_wakeup(secs * 1000000); // To wake up every hour to do periodically jobs diff --git a/src/helpers/esp32/SerialWifiInterface.cpp b/src/helpers/esp32/SerialWifiInterface.cpp index 462e3ecc3..f4ebf5d69 100644 --- a/src/helpers/esp32/SerialWifiInterface.cpp +++ b/src/helpers/esp32/SerialWifiInterface.cpp @@ -4,18 +4,38 @@ void SerialWifiInterface::begin(int port) { // wifi setup is handled outside of this class, only starts the server server.begin(port); + + // Store WiFi credentials for re-enable +#ifdef WIFI_SSID + _ssid = WIFI_SSID; + _password = WIFI_PWD; + _isEnabled = true; // WiFi starts enabled +#else + _ssid = nullptr; + _password = nullptr; +#endif } // ---------- public methods -void SerialWifiInterface::enable() { +void SerialWifiInterface::enable() { if (_isEnabled) return; _isEnabled = true; clearBuffers(); + + // Re-enable WiFi with stored credentials + if (_ssid != nullptr && _password != nullptr) { + WiFi.mode(WIFI_STA); + WiFi.begin(_ssid, _password); + } } void SerialWifiInterface::disable() { _isEnabled = false; + + // Actually turn off WiFi to save power + WiFi.disconnect(true); // Disconnect and clear config + WiFi.mode(WIFI_OFF); // Turn off WiFi radio } size_t SerialWifiInterface::writeFrame(const uint8_t src[], size_t len) { diff --git a/src/helpers/esp32/SerialWifiInterface.h b/src/helpers/esp32/SerialWifiInterface.h index 19291497f..f900d18bc 100644 --- a/src/helpers/esp32/SerialWifiInterface.h +++ b/src/helpers/esp32/SerialWifiInterface.h @@ -8,6 +8,8 @@ class SerialWifiInterface : public BaseSerialInterface { bool _isEnabled; unsigned long _last_write; unsigned long adv_restart_time; + const char* _ssid; + const char* _password; WiFiServer server; WiFiClient client; @@ -39,6 +41,8 @@ class SerialWifiInterface : public BaseSerialInterface { deviceConnected = false; _isEnabled = false; _last_write = 0; + _ssid = nullptr; + _password = nullptr; send_queue_len = recv_queue_len = 0; received_frame_header.type = 0; received_frame_header.length = 0; From 0a09f738e08fe1ab65ea45b3d12574c13dae5fd9 Mon Sep 17 00:00:00 2001 From: Wessel Nieboer Date: Wed, 11 Feb 2026 03:55:02 +0100 Subject: [PATCH 51/56] Fix millis() overflow in companion powersave sleep timing Use millisHasNowPassed() (2's complement safe) instead of direct comparison, consistent with the repeater's sleep timing logic. Co-Authored-By: Wessel --- examples/companion_radio/main.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/companion_radio/main.cpp b/examples/companion_radio/main.cpp index 0674ddea8..0dc5f1224 100644 --- a/examples/companion_radio/main.cpp +++ b/examples/companion_radio/main.cpp @@ -247,7 +247,7 @@ void loop() { } // Only sleep if enough time has passed since last activity - if (millis() >= lastActive + (nextSleepInSecs * 1000)) { + if (the_mesh.millisHasNowPassed(lastActive + (nextSleepInSecs * 1000))) { #ifdef PIN_USER_BTN // Sleep for 30 minutes, wake on LoRa packet, timer, or button press board.enterLightSleep(1800, PIN_USER_BTN); From c089637c92434077011cf5c8bebe4d01b4c6a184 Mon Sep 17 00:00:00 2001 From: Wessel Nieboer Date: Mon, 16 Feb 2026 09:09:00 +0100 Subject: [PATCH 52/56] Fix sleep nRF52 --- src/helpers/NRF52Board.h | 1 + 1 file changed, 1 insertion(+) diff --git a/src/helpers/NRF52Board.h b/src/helpers/NRF52Board.h index 96f67dc95..f64e878cd 100644 --- a/src/helpers/NRF52Board.h +++ b/src/helpers/NRF52Board.h @@ -53,6 +53,7 @@ class NRF52Board : public mesh::MainBoard { virtual bool getBootloaderVersion(char* version, size_t max_len) override; virtual bool startOTAUpdate(const char *id, char reply[]) override; virtual void sleep(uint32_t secs) override; + void enterLightSleep(uint32_t secs, int pin_wake_btn = -1) { sleep(secs); } #ifdef NRF52_POWER_MANAGEMENT bool isExternalPowered() override; From a02031540a7c7ec3aa5cb81d73ff0925e234ec26 Mon Sep 17 00:00:00 2001 From: Wessel Nieboer Date: Fri, 13 Feb 2026 22:59:15 +0100 Subject: [PATCH 53/56] Be more patient: increase companion timeouts And use attempt to progressively wait longer. Currently we only wait 3-4 hops of airtime. When destination is 10-20 hops away we send 3 flood messages before the first one will have a chance of arriving. --- examples/companion_radio/MyMesh.cpp | 12 ++++++------ examples/companion_radio/MyMesh.h | 2 +- examples/simple_secure_chat/main.cpp | 4 ++-- src/helpers/BaseChatMesh.cpp | 4 ++-- src/helpers/BaseChatMesh.h | 2 +- 5 files changed, 12 insertions(+), 12 deletions(-) diff --git a/examples/companion_radio/MyMesh.cpp b/examples/companion_radio/MyMesh.cpp index c96f7e017..962b349a0 100644 --- a/examples/companion_radio/MyMesh.cpp +++ b/examples/companion_radio/MyMesh.cpp @@ -92,10 +92,10 @@ #define RESP_CODE_AUTOADD_CONFIG 25 #define RESP_ALLOWED_REPEAT_FREQ 26 -#define SEND_TIMEOUT_BASE_MILLIS 500 -#define FLOOD_SEND_TIMEOUT_FACTOR 16.0f -#define DIRECT_SEND_PERHOP_FACTOR 6.0f -#define DIRECT_SEND_PERHOP_EXTRA_MILLIS 250 +#define SEND_TIMEOUT_BASE_MILLIS 1000 +#define FLOOD_SEND_TIMEOUT_FACTOR 32.0f +#define DIRECT_SEND_PERHOP_FACTOR 10.0f +#define DIRECT_SEND_PERHOP_EXTRA_MILLIS 500 #define LAZY_CONTACTS_WRITE_DELAY 5000 #define PUBLIC_GROUP_PSK "izOH6cXN6mrJ5e26oRXNcg==" @@ -779,8 +779,8 @@ void MyMesh::onTraceRecv(mesh::Packet *packet, uint32_t tag, uint32_t auth_code, } } -uint32_t MyMesh::calcFloodTimeoutMillisFor(uint32_t pkt_airtime_millis) const { - return SEND_TIMEOUT_BASE_MILLIS + (FLOOD_SEND_TIMEOUT_FACTOR * pkt_airtime_millis); +uint32_t MyMesh::calcFloodTimeoutMillisFor(uint32_t pkt_airtime_millis, uint8_t attempt) const { + return (SEND_TIMEOUT_BASE_MILLIS + (FLOOD_SEND_TIMEOUT_FACTOR * pkt_airtime_millis)) * (attempt + 1); } uint32_t MyMesh::calcDirectTimeoutMillisFor(uint32_t pkt_airtime_millis, uint8_t path_len) const { uint8_t path_hash_count = path_len & 63; diff --git a/examples/companion_radio/MyMesh.h b/examples/companion_radio/MyMesh.h index 87e6cf338..d9550a94b 100644 --- a/examples/companion_radio/MyMesh.h +++ b/examples/companion_radio/MyMesh.h @@ -145,7 +145,7 @@ class MyMesh : public BaseChatMesh, public DataStoreHost { void onTraceRecv(mesh::Packet *packet, uint32_t tag, uint32_t auth_code, uint8_t flags, const uint8_t *path_snrs, const uint8_t *path_hashes, uint8_t path_len) override; - uint32_t calcFloodTimeoutMillisFor(uint32_t pkt_airtime_millis) const override; + uint32_t calcFloodTimeoutMillisFor(uint32_t pkt_airtime_millis, uint8_t attempt = 0) const override; uint32_t calcDirectTimeoutMillisFor(uint32_t pkt_airtime_millis, uint8_t path_len) const override; void onSendTimeout() override; diff --git a/examples/simple_secure_chat/main.cpp b/examples/simple_secure_chat/main.cpp index c1ed710ab..b7a7f31ad 100644 --- a/examples/simple_secure_chat/main.cpp +++ b/examples/simple_secure_chat/main.cpp @@ -262,8 +262,8 @@ class MyMesh : public BaseChatMesh, ContactVisitor { // not supported } - uint32_t calcFloodTimeoutMillisFor(uint32_t pkt_airtime_millis) const override { - return SEND_TIMEOUT_BASE_MILLIS + (FLOOD_SEND_TIMEOUT_FACTOR * pkt_airtime_millis); + uint32_t calcFloodTimeoutMillisFor(uint32_t pkt_airtime_millis, uint8_t attempt = 0) const override { + return (SEND_TIMEOUT_BASE_MILLIS + (FLOOD_SEND_TIMEOUT_FACTOR * pkt_airtime_millis)) * (attempt + 1); } uint32_t calcDirectTimeoutMillisFor(uint32_t pkt_airtime_millis, uint8_t path_len) const override { uint8_t path_hash_count = path_len & 63; diff --git a/src/helpers/BaseChatMesh.cpp b/src/helpers/BaseChatMesh.cpp index 5ec678c7f..5af8fc84e 100644 --- a/src/helpers/BaseChatMesh.cpp +++ b/src/helpers/BaseChatMesh.cpp @@ -388,7 +388,7 @@ int BaseChatMesh::sendMessage(const ContactInfo& recipient, uint32_t timestamp, int rc; if (recipient.out_path_len == OUT_PATH_UNKNOWN) { sendFloodScoped(recipient, pkt); - txt_send_timeout = futureMillis(est_timeout = calcFloodTimeoutMillisFor(t)); + txt_send_timeout = futureMillis(est_timeout = calcFloodTimeoutMillisFor(t, attempt)); rc = MSG_SEND_SENT_FLOOD; } else { sendDirect(pkt, recipient.out_path, recipient.out_path_len); @@ -414,7 +414,7 @@ int BaseChatMesh::sendCommandData(const ContactInfo& recipient, uint32_t timest int rc; if (recipient.out_path_len == OUT_PATH_UNKNOWN) { sendFloodScoped(recipient, pkt); - txt_send_timeout = futureMillis(est_timeout = calcFloodTimeoutMillisFor(t)); + txt_send_timeout = futureMillis(est_timeout = calcFloodTimeoutMillisFor(t, attempt)); rc = MSG_SEND_SENT_FLOOD; } else { sendDirect(pkt, recipient.out_path, recipient.out_path_len); diff --git a/src/helpers/BaseChatMesh.h b/src/helpers/BaseChatMesh.h index fd391b980..2484da6ca 100644 --- a/src/helpers/BaseChatMesh.h +++ b/src/helpers/BaseChatMesh.h @@ -106,7 +106,7 @@ class BaseChatMesh : public mesh::Mesh { virtual void onMessageRecv(const ContactInfo& contact, mesh::Packet* pkt, uint32_t sender_timestamp, const char *text) = 0; virtual void onCommandDataRecv(const ContactInfo& contact, mesh::Packet* pkt, uint32_t sender_timestamp, const char *text) = 0; virtual void onSignedMessageRecv(const ContactInfo& contact, mesh::Packet* pkt, uint32_t sender_timestamp, const uint8_t *sender_prefix, const char *text) = 0; - virtual uint32_t calcFloodTimeoutMillisFor(uint32_t pkt_airtime_millis) const = 0; + virtual uint32_t calcFloodTimeoutMillisFor(uint32_t pkt_airtime_millis, uint8_t attempt = 0) const = 0; virtual uint32_t calcDirectTimeoutMillisFor(uint32_t pkt_airtime_millis, uint8_t path_len) const = 0; virtual void onSendTimeout() = 0; virtual void onChannelMessageRecv(const mesh::GroupChannel& channel, mesh::Packet* pkt, uint32_t timestamp, const char *text) = 0; From d41863a1f5c2c4259f67231d7ceeb736eb146a8b Mon Sep 17 00:00:00 2001 From: Wessel Nieboer Date: Wed, 11 Feb 2026 04:43:33 +0100 Subject: [PATCH 54/56] Clarify bounds check comment in Packet::readFrom --- src/Packet.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Packet.cpp b/src/Packet.cpp index 66b65ba1b..3944eaf89 100644 --- a/src/Packet.cpp +++ b/src/Packet.cpp @@ -67,7 +67,7 @@ bool Packet::readFrom(const uint8_t src[], uint8_t len) { uint8_t i = 0; header = src[i++]; if (hasTransportCodes()) { - if (i + 4 >= len) return false; // need 4 bytes for transport codes + path_len after + if (i + 4 >= len) return false; // need 4 transport bytes + the path_len byte memcpy(&transport_codes[0], &src[i], 2); i += 2; memcpy(&transport_codes[1], &src[i], 2); i += 2; } else { From a44f7fc15de59917affe42ad6e24042f6eb41876 Mon Sep 17 00:00:00 2001 From: Wessel Nieboer Date: Mon, 2 Mar 2026 10:11:51 +0100 Subject: [PATCH 55/56] Fix 1970 date after crash/watchdog/brownout reset on ESP32 Currently, time is only set to ~2024 when it powers on cleanly. When time is lost, this results in time dropping to 1970. Save time as backup to RTC slow memory. Should fix time staying accurate during brownouts etc. --- src/helpers/ESP32Board.h | 35 ++++++++++++++++++++++++++++------- 1 file changed, 28 insertions(+), 7 deletions(-) diff --git a/src/helpers/ESP32Board.h b/src/helpers/ESP32Board.h index bade3e898..a541308ea 100644 --- a/src/helpers/ESP32Board.h +++ b/src/helpers/ESP32Board.h @@ -128,29 +128,50 @@ class ESP32Board : public mesh::MainBoard { } }; +static RTC_NOINIT_ATTR uint32_t _rtc_backup_time; +static RTC_NOINIT_ATTR uint32_t _rtc_backup_magic; +#define RTC_BACKUP_MAGIC 0xAA55CC33 +#define RTC_TIME_MIN 1772323200 // 1 Mar 2026 + class ESP32RTCClock : public mesh::RTCClock { public: ESP32RTCClock() { } void begin() { esp_reset_reason_t reason = esp_reset_reason(); - if (reason == ESP_RST_POWERON) { - // start with some date/time in the recent past - struct timeval tv; - tv.tv_sec = 1715770351; // 15 May 2024, 8:50pm - tv.tv_usec = 0; - settimeofday(&tv, NULL); + if (reason == ESP_RST_DEEPSLEEP) { + return; // ESP-IDF preserves system time across deep sleep + } + // All other resets (power-on, crash, WDT, brownout) lose system time. + // Restore from RTC backup if valid, otherwise use hardcoded seed. + struct timeval tv; + if (_rtc_backup_magic == RTC_BACKUP_MAGIC && _rtc_backup_time > RTC_TIME_MIN) { + tv.tv_sec = _rtc_backup_time; + } else { + tv.tv_sec = 1772323200; // 1 Mar 2026 } + tv.tv_usec = 0; + settimeofday(&tv, NULL); } uint32_t getCurrentTime() override { time_t _now; time(&_now); return _now; } - void setCurrentTime(uint32_t time) override { + void setCurrentTime(uint32_t time) override { struct timeval tv; tv.tv_sec = time; tv.tv_usec = 0; settimeofday(&tv, NULL); + _rtc_backup_time = time; + _rtc_backup_magic = RTC_BACKUP_MAGIC; + } + void tick() override { + time_t now; + time(&now); + if (now > RTC_TIME_MIN && (uint32_t)now != _rtc_backup_time) { + _rtc_backup_time = (uint32_t)now; + _rtc_backup_magic = RTC_BACKUP_MAGIC; + } } }; From 8c7cf2a6293df5cbbc0ca87b028ceb666e0a2676 Mon Sep 17 00:00:00 2001 From: Andy Shinn Date: Tue, 10 Feb 2026 22:04:56 -0600 Subject: [PATCH 56/56] move statusLED to its own class and allow using for repeater --- examples/companion_radio/ui-new/UITask.cpp | 36 ++++----------- examples/companion_radio/ui-new/UITask.h | 16 +++---- examples/companion_radio/ui-orig/UITask.cpp | 45 +++++------------- examples/companion_radio/ui-orig/UITask.h | 13 +++++- examples/simple_repeater/main.cpp | 12 +++++ src/helpers/StatusLED.h | 51 +++++++++++++++++++++ 6 files changed, 101 insertions(+), 72 deletions(-) create mode 100644 src/helpers/StatusLED.h diff --git a/examples/companion_radio/ui-new/UITask.cpp b/examples/companion_radio/ui-new/UITask.cpp index 4eaa80684..3d90f7880 100644 --- a/examples/companion_radio/ui-new/UITask.cpp +++ b/examples/companion_radio/ui-new/UITask.cpp @@ -11,12 +11,6 @@ #endif #define BOOT_SCREEN_MILLIS 3000 // 3 seconds -#ifdef PIN_STATUS_LED -#define LED_ON_MILLIS 20 -#define LED_ON_MSG_MILLIS 200 -#define LED_CYCLE_MILLIS 4000 -#endif - #define LONG_PRESS_MILLIS 1200 #ifndef UI_RECENT_LIST_SIZE @@ -585,6 +579,10 @@ void UITask::begin(DisplayDriver* display, SensorManager* sensors, NodePrefs* no vibration.begin(); #endif +#ifdef PIN_STATUS_LED + status_led.begin(); +#endif + ui_started_at = millis(); _alert_expiry = 0; @@ -653,27 +651,6 @@ void UITask::newMsg(uint8_t path_len, const char* from_name, const char* text, i } } -void UITask::userLedHandler() { -#ifdef PIN_STATUS_LED - int cur_time = millis(); - if (cur_time > next_led_change) { - if (led_state == 0) { - led_state = 1; - if (_msgcount > 0) { - last_led_increment = LED_ON_MSG_MILLIS; - } else { - last_led_increment = LED_ON_MILLIS; - } - next_led_change = cur_time + last_led_increment; - } else { - led_state = 0; - next_led_change = cur_time + LED_CYCLE_MILLIS - last_led_increment; - } - digitalWrite(PIN_STATUS_LED, led_state == LED_STATE_ON); - } -#endif -} - void UITask::setCurrScreen(UIScreen* c) { curr = c; _next_refresh = 100; @@ -784,7 +761,10 @@ void UITask::loop() { _next_refresh = 100; // trigger refresh } - userLedHandler(); +#ifdef PIN_STATUS_LED + status_led.setAlert(_msgcount > 0); + status_led.loop(); +#endif #ifdef PIN_BUZZER if (buzzer.isPlaying()) buzzer.loop(); diff --git a/examples/companion_radio/ui-new/UITask.h b/examples/companion_radio/ui-new/UITask.h index a77ad6e7e..0d3e295b3 100644 --- a/examples/companion_radio/ui-new/UITask.h +++ b/examples/companion_radio/ui-new/UITask.h @@ -8,8 +8,8 @@ #include #include -#ifndef LED_STATE_ON - #define LED_STATE_ON 1 +#ifdef PIN_STATUS_LED + #include #endif #ifdef PIN_BUZZER @@ -39,9 +39,7 @@ class UITask : public AbstractUITask { unsigned long ui_started_at, next_batt_chck; int next_backlight_btn_check = 0; #ifdef PIN_STATUS_LED - int led_state = 0; - int next_led_change = 0; - int last_led_increment = 0; + StatusLED status_led; #endif #ifdef PIN_USER_BTN_ANA @@ -53,8 +51,6 @@ class UITask : public AbstractUITask { UIScreen* msg_preview; UIScreen* curr; - void userLedHandler(); - // Button action handlers char checkDisplayOn(char c); char handleLongPress(char c); @@ -65,7 +61,11 @@ class UITask : public AbstractUITask { public: - UITask(mesh::MainBoard* board, BaseSerialInterface* serial) : AbstractUITask(board, serial), _display(NULL), _sensors(NULL) { + UITask(mesh::MainBoard* board, BaseSerialInterface* serial) : AbstractUITask(board, serial), _display(NULL), _sensors(NULL) +#ifdef PIN_STATUS_LED + , status_led(PIN_STATUS_LED) +#endif + { next_batt_chck = _next_refresh = 0; ui_started_at = 0; curr = NULL; diff --git a/examples/companion_radio/ui-orig/UITask.cpp b/examples/companion_radio/ui-orig/UITask.cpp index 3ad36fb00..83886b2ef 100644 --- a/examples/companion_radio/ui-orig/UITask.cpp +++ b/examples/companion_radio/ui-orig/UITask.cpp @@ -6,12 +6,6 @@ #define AUTO_OFF_MILLIS 15000 // 15 seconds #define BOOT_SCREEN_MILLIS 3000 // 3 seconds -#ifdef PIN_STATUS_LED -#define LED_ON_MILLIS 20 -#define LED_ON_MSG_MILLIS 200 -#define LED_CYCLE_MILLIS 4000 -#endif - #ifndef USER_BTN_PRESSED #define USER_BTN_PRESSED LOW #endif @@ -59,6 +53,10 @@ void UITask::begin(DisplayDriver* display, SensorManager* sensors, NodePrefs* no buzzer.quiet(_node_prefs->buzzer_quiet); #endif +#ifdef PIN_STATUS_LED + status_led.begin(); +#endif + // Initialize digital button if available #ifdef PIN_USER_BTN _userButton = new Button(PIN_USER_BTN, USER_BTN_PRESSED); @@ -260,33 +258,8 @@ void UITask::renderCurrScreen() { _need_refresh = false; } -void UITask::userLedHandler() { -#ifdef PIN_STATUS_LED - static int state = 0; - static int next_change = 0; - static int last_increment = 0; - - int cur_time = millis(); - if (cur_time > next_change) { - if (state == 0) { - state = 1; - if (_msgcount > 0) { - last_increment = LED_ON_MSG_MILLIS; - } else { - last_increment = LED_ON_MILLIS; - } - next_change = cur_time + last_increment; - } else { - state = 0; - next_change = cur_time + LED_CYCLE_MILLIS - last_increment; - } - digitalWrite(PIN_STATUS_LED, state == LED_STATE_ON); - } -#endif -} - -/* - hardware-agnostic pre-shutdown activity should be done here +/* + hardware-agnostic pre-shutdown activity should be done here */ void UITask::shutdown(bool restart){ @@ -322,7 +295,11 @@ void UITask::loop() { _userButtonAnalog->update(); } #endif - userLedHandler(); + +#ifdef PIN_STATUS_LED + status_led.setAlert(_msgcount > 0); + status_led.loop(); +#endif #ifdef PIN_BUZZER if (buzzer.isPlaying()) buzzer.loop(); diff --git a/examples/companion_radio/ui-orig/UITask.h b/examples/companion_radio/ui-orig/UITask.h index 60cd0d042..b45d870b2 100644 --- a/examples/companion_radio/ui-orig/UITask.h +++ b/examples/companion_radio/ui-orig/UITask.h @@ -8,6 +8,9 @@ #ifdef PIN_BUZZER #include #endif +#ifdef PIN_STATUS_LED + #include +#endif #include "../AbstractUITask.h" #include "../NodePrefs.h" @@ -30,6 +33,9 @@ class UITask : public AbstractUITask { bool _need_refresh = true; bool _displayWasOn = false; // Track display state before button press unsigned long ui_started_at; +#ifdef PIN_STATUS_LED + StatusLED status_led; +#endif // Button handlers #ifdef PIN_USER_BTN @@ -40,7 +46,6 @@ class UITask : public AbstractUITask { #endif void renderCurrScreen(); - void userLedHandler(); void renderBatteryIndicator(uint16_t batteryMilliVolts); // Button action handlers @@ -54,7 +59,11 @@ class UITask : public AbstractUITask { public: - UITask(mesh::MainBoard* board, BaseSerialInterface* serial) : AbstractUITask(board, serial), _display(NULL), _sensors(NULL) { + UITask(mesh::MainBoard* board, BaseSerialInterface* serial) : AbstractUITask(board, serial), _display(NULL), _sensors(NULL) +#ifdef PIN_STATUS_LED + , status_led(PIN_STATUS_LED) +#endif + { _next_refresh = 0; ui_started_at = 0; } diff --git a/examples/simple_repeater/main.cpp b/examples/simple_repeater/main.cpp index d226d1fa7..3c767d20a 100644 --- a/examples/simple_repeater/main.cpp +++ b/examples/simple_repeater/main.cpp @@ -3,6 +3,11 @@ #include "MyMesh.h" +#ifdef PIN_STATUS_LED + #include + static StatusLED status_led(PIN_STATUS_LED); +#endif + #ifdef DISPLAY_CLASS #include "UITask.h" static UITask ui_task(display); @@ -35,6 +40,10 @@ void setup() { delay(5000); #endif +#ifdef PIN_STATUS_LED + status_led.begin(); +#endif + // For power saving lastActive = millis(); // mark last active time since boot @@ -133,6 +142,9 @@ void loop() { ui_task.loop(); #endif rtc_clock.tick(); +#ifdef PIN_STATUS_LED + status_led.loop(); +#endif if (the_mesh.getNodePrefs()->powersaving_enabled && !the_mesh.hasPendingWork()) { #if defined(NRF52_PLATFORM) diff --git a/src/helpers/StatusLED.h b/src/helpers/StatusLED.h new file mode 100644 index 000000000..be940a457 --- /dev/null +++ b/src/helpers/StatusLED.h @@ -0,0 +1,51 @@ +#pragma once + +#include + +#ifndef LED_ON_MILLIS + #define LED_ON_MILLIS 20 +#endif +#ifndef LED_ON_MSG_MILLIS + #define LED_ON_MSG_MILLIS 200 +#endif +#ifndef LED_CYCLE_MILLIS + #define LED_CYCLE_MILLIS 4000 +#endif +#ifndef LED_STATE_ON + #define LED_STATE_ON 1 +#endif + +class StatusLED { + uint8_t _pin; + uint8_t _active; + unsigned long _next_change = 0; + unsigned long _last_on_duration = 0; + uint8_t _state = 0; + bool _alert = false; + +public: + StatusLED(uint8_t pin, uint8_t active = LED_STATE_ON) : _pin(pin), _active(active) { } + + void begin() { + pinMode(_pin, OUTPUT); + digitalWrite(_pin, _active ? LOW : HIGH); // Start with LED off + } + + void setAlert(bool alert) { _alert = alert; } + bool isAlert() const { return _alert; } + + void loop() { + unsigned long now = millis(); + if (now > _next_change) { + if (_state == 0) { + _state = 1; + _last_on_duration = _alert ? LED_ON_MSG_MILLIS : LED_ON_MILLIS; + _next_change = now + _last_on_duration; + } else { + _state = 0; + _next_change = now + LED_CYCLE_MILLIS - _last_on_duration; + } + digitalWrite(_pin, (_state == _active) ? HIGH : LOW); + } + } +};