From 42f0eec7b1d36b6153a0236dbede61646ceb2e39 Mon Sep 17 00:00:00 2001 From: James L Date: Thu, 5 Mar 2026 23:33:18 -0500 Subject: [PATCH] Rename to RTNode-HeltecV4, replace 'boundary' with 'transport' in docs - Rename project from RNodeTHV4 to RTNode-HeltecV4 - Update GitHub repo URL, firmware binary names (rtnode_heltec_v4.bin, rtnode_heltec_v3.bin) - Replace 'boundary node' with 'transport node' in README and flash.py descriptions - Update OLED title bar to 'RTNode' - Bump version to v1.0.18 --- .gitignore | 4 + BoundaryConfig.h | 44 +++++++ BoundaryMode.h | 43 ++++++- Display.h | 6 +- MICRORETICULUM_BUGS.md | 2 +- README.md | 64 +++++----- RNode_Firmware.ino | 19 ++- flash.py | 18 +-- lib/microReticulum/src/Interface.cpp | 49 ++++++++ lib/microReticulum/src/Interface.h | 8 ++ lib/microReticulum/src/Reticulum.h | 9 ++ lib/microReticulum/src/Transport.cpp | 169 +++++++++++++++------------ 12 files changed, 316 insertions(+), 119 deletions(-) diff --git a/.gitignore b/.gitignore index 079a43a..521bdb1 100755 --- a/.gitignore +++ b/.gitignore @@ -3,7 +3,11 @@ *.pyc TODO rnodethv4_firmware.bin +rtnode_heltecv4_firmware.bin +rtnode_heltec_v4.bin rnodethv3_firmware.bin +rtnode_heltecv3_firmware.bin +rtnode_heltec_v3.bin .firmware_cache/ Release/*.hex Release/*.zip diff --git a/BoundaryConfig.h b/BoundaryConfig.h index de9c203..abbe080 100644 --- a/BoundaryConfig.h +++ b/BoundaryConfig.h @@ -265,6 +265,32 @@ static void config_send_html() { html += F(" dBm (with PA)

"); #endif + // ── IFAC (Interface Access Code) Section ── + html += F( + "

🔒 Network Access (IFAC)

" + "

Set a network name and/or passphrase to restrict LoRa interface access. " + "Only nodes with matching settings can communicate. Both fields are optional.

" + "" + ""); + + html += F(""); + html += F(""); + + html += F(""); + html += F(""); + // ── Options Section ── html += F( "

⚙ Options

" @@ -362,6 +388,24 @@ static void config_handle_save() { boundary_state.ap_tcp_port = (uint16_t)config_server->arg("ap_tcp_port").toInt(); if (boundary_state.ap_tcp_port == 0) boundary_state.ap_tcp_port = 4242; + // ── IFAC settings ── + boundary_state.ifac_enabled = (config_server->arg("ifac_en").toInt() == 1); + + String ifac_name = config_server->arg("ifac_name"); + memset(boundary_state.ifac_netname, 0, sizeof(boundary_state.ifac_netname)); + strncpy(boundary_state.ifac_netname, ifac_name.c_str(), sizeof(boundary_state.ifac_netname) - 1); + + String ifac_pass = config_server->arg("ifac_pass"); + memset(boundary_state.ifac_passphrase, 0, sizeof(boundary_state.ifac_passphrase)); + strncpy(boundary_state.ifac_passphrase, ifac_pass.c_str(), sizeof(boundary_state.ifac_passphrase) - 1); + + // If IFAC is enabled but both fields are empty, disable it + if (boundary_state.ifac_enabled && + boundary_state.ifac_netname[0] == '\0' && + boundary_state.ifac_passphrase[0] == '\0') { + boundary_state.ifac_enabled = false; + } + // Save boundary config to EEPROM boundary_save_config(); diff --git a/BoundaryMode.h b/BoundaryMode.h index 293af38..c77b987 100755 --- a/BoundaryMode.h +++ b/BoundaryMode.h @@ -68,7 +68,12 @@ #define ADDR_CONF_AP_SSID 0x93 // AP SSID (33 bytes, null-terminated) #define ADDR_CONF_AP_PSK 0xB4 // AP PSK (33 bytes, null-terminated) #define ADDR_CONF_WIFI_EN 0xD5 // WiFi enable flag (1 byte, 0x73 = enabled) -// Total: 0xD6 (214 bytes used of 256 CONFIG area) +// IFAC (Interface Access Code) settings for LoRa interface +#define ADDR_CONF_IFAC_EN 0xD6 // IFAC enable flag (1 byte, 0x73 = enabled) +#define ADDR_CONF_IFAC_NAME 0xD7 // Network name (33 bytes, null-terminated) +#define ADDR_CONF_IFAC_PASS 0xF8 // Passphrase (33 bytes, null-terminated) +// Total: 0x119 (281 bytes — extends beyond 256-byte CONFIG area into +// unused EEPROM gap; safe on ESP32 where EEPROM starts at 824) #define BOUNDARY_ENABLE_BYTE 0x73 @@ -89,6 +94,11 @@ struct BoundaryState { char ap_ssid[33]; // AP SSID char ap_psk[33]; // AP PSK (empty = open) + // IFAC settings for LoRa interface + bool ifac_enabled; // Whether IFAC is configured + char ifac_netname[33]; // Network name (empty = not set) + char ifac_passphrase[33]; // Passphrase (empty = not set) + // Runtime state bool wifi_connected; bool tcp_connected; // Backbone (WAN) connected @@ -123,6 +133,9 @@ inline void boundary_load_config() { boundary_state.ap_tcp_port = 4242; boundary_state.ap_ssid[0] = '\0'; boundary_state.ap_psk[0] = '\0'; + boundary_state.ifac_enabled = false; + boundary_state.ifac_netname[0] = '\0'; + boundary_state.ifac_passphrase[0] = '\0'; // Mark as enabled since we're compiled with BOUNDARY_MODE boundary_state.enabled = true; return; @@ -181,6 +194,22 @@ inline void boundary_load_config() { } boundary_state.ap_psk[32] = '\0'; + // Load IFAC settings + boundary_state.ifac_enabled = + (EEPROM.read(config_addr(ADDR_CONF_IFAC_EN)) == BOUNDARY_ENABLE_BYTE); + + for (int i = 0; i < 32; i++) { + boundary_state.ifac_netname[i] = EEPROM.read(config_addr(ADDR_CONF_IFAC_NAME + i)); + if (boundary_state.ifac_netname[i] == (char)0xFF) boundary_state.ifac_netname[i] = '\0'; + } + boundary_state.ifac_netname[32] = '\0'; + + for (int i = 0; i < 32; i++) { + boundary_state.ifac_passphrase[i] = EEPROM.read(config_addr(ADDR_CONF_IFAC_PASS + i)); + if (boundary_state.ifac_passphrase[i] == (char)0xFF) boundary_state.ifac_passphrase[i] = '\0'; + } + boundary_state.ifac_passphrase[32] = '\0'; + // Reset runtime state boundary_state.packets_bridged_lora_to_tcp = 0; boundary_state.packets_bridged_tcp_to_lora = 0; @@ -218,6 +247,18 @@ inline void boundary_save_config() { } EEPROM.write(config_addr(ADDR_CONF_AP_PSK + 32), 0x00); + // IFAC settings + EEPROM.write(config_addr(ADDR_CONF_IFAC_EN), + boundary_state.ifac_enabled ? BOUNDARY_ENABLE_BYTE : 0x00); + for (int i = 0; i < 32; i++) { + EEPROM.write(config_addr(ADDR_CONF_IFAC_NAME + i), boundary_state.ifac_netname[i]); + } + EEPROM.write(config_addr(ADDR_CONF_IFAC_NAME + 32), 0x00); + for (int i = 0; i < 32; i++) { + EEPROM.write(config_addr(ADDR_CONF_IFAC_PASS + i), boundary_state.ifac_passphrase[i]); + } + EEPROM.write(config_addr(ADDR_CONF_IFAC_PASS + 32), 0x00); + EEPROM.commit(); } diff --git a/Display.h b/Display.h index a71104d..0fac47a 100755 --- a/Display.h +++ b/Display.h @@ -56,6 +56,10 @@ struct BoundaryState { uint16_t ap_tcp_port; char ap_ssid[33]; char ap_psk[33]; + // IFAC settings for LoRa interface + bool ifac_enabled; + char ifac_netname[33]; + char ifac_passphrase[33]; bool wifi_connected; bool tcp_connected; // Backbone (WAN) connected bool ap_tcp_connected; // Local TCP server (LAN) has client @@ -940,7 +944,7 @@ void draw_disp_area() { disp_area.setTextColor(SSD1306_BLACK); disp_area.setTextSize(1); disp_area.setCursor(4, 7); - disp_area.print("RNodeTHV4"); + disp_area.print("RTNode"); disp_area.setTextColor(SSD1306_WHITE); diff --git a/MICRORETICULUM_BUGS.md b/MICRORETICULUM_BUGS.md index dc64c1c..11ca71a 100755 --- a/MICRORETICULUM_BUGS.md +++ b/MICRORETICULUM_BUGS.md @@ -415,7 +415,7 @@ MTU CLAMP: path=8192 ph=1064 nh=1064 -> clamped=1064 ### Test Reproduction ```bash -cd test-harnesses/RNodeTHV4 +cd test-harnesses/RTNode-HeltecV4 bash run_test.sh ``` diff --git a/README.md b/README.md index e459a06..6c73826 100755 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ -# RNodeTHV4 — Reticulum Boundary Node for Heltec WiFi LoRa 32 V3 / V4 +# RTNode-HeltecV4 — Reticulum Transport Node for Heltec WiFi LoRa 32 V3 / V4 -A custom firmware for the **Heltec WiFi LoRa 32 V3** and **V4** (ESP32-S3 + SX1262) that operates as a **Boundary Node** — bridging a local LoRa radio network with a remote TCP/IP backbone (such as [rmap.world](https://rmap.world)) over WiFi. +A custom firmware for the **Heltec WiFi LoRa 32 V3** and **V4** (ESP32-S3 + SX1262) that operates as a **Transport Node** — bridging a local LoRa radio network with a remote TCP/IP backbone (such as [rmap.world](https://rmap.world)) over WiFi. ``` Android / Sideband Remote @@ -10,8 +10,8 @@ A custom firmware for the **Heltec WiFi LoRa 32 V3** and **V4** (ESP32-S3 + SX12 └──────────┘ │ rmap.world) LoRa Radio ▲ │ ┌──────────────┐ WiFi │ - ◄── RF mesh ──────► │ RNodeTHV4 │ ◄─TCP──┘ - │ │ Boundary Node│ ▲ + ◄── RF mesh ──────► │ RTNode-HV4 │ ◄─TCP──┘ + │ │Transport Node│ ▲ Other RNodes └──────────────┘ │ ┌───┴───┐ │ Router│ @@ -56,8 +56,8 @@ The easiest way to flash a pre-built firmware. You only need Python 3 and a USB pip install esptool # Clone this repo (or download just flash.py + the firmware binary) -git clone https://github.com/jrl290/RNodeTHV4.git -cd RNodeTHV4 +git clone https://github.com/jrl290/RTNode-HeltecV4.git +cd RTNode-HeltecV4 # Download latest firmware from GitHub Releases and flash # (auto-detects V3 vs V4 from flash size) @@ -68,7 +68,7 @@ python flash.py --download --board v3 python flash.py --download --board v4 # Or flash a local binary -python flash.py --file rnodethv4_firmware.bin +python flash.py --file rtnode_heltec_v4.bin ``` The flash utility auto-detects whether a V3 or V4 is connected by querying the flash size (8MB = V3, 16MB = V4). You can override with `--board v3` or `--board v4`. It will list all available serial ports and prompt you to choose one. If no ports are detected, you may need to hold the **BOOT** button while pressing **RESET** to enter download mode. @@ -80,8 +80,8 @@ For development or customization: ```bash # Prerequisites: PlatformIO installed (VS Code extension or CLI) -git clone https://github.com/jrl290/RNodeTHV4.git -cd RNodeTHV4 +git clone https://github.com/jrl290/RTNode-HeltecV4.git +cd RTNode-HeltecV4 # Build for V4 pio run -e heltec_V4_boundary @@ -102,12 +102,12 @@ pio device monitor -e heltec_V4_boundary ### Option C: Manual esptool Flash -If you have the merged binary (`rnodethv4_firmware.bin`), you can flash it with a single esptool command: +If you have the merged binary (`rtnode_heltec_v4.bin`), you can flash it with a single esptool command: ```bash esptool.py --chip esp32s3 --port /dev/ttyACM0 --baud 921600 \ write_flash -z --flash_mode qio --flash_freq 80m --flash_size 16MB \ - 0x0 rnodethv4_firmware.bin + 0x0 rtnode_heltec_v4.bin ``` Replace `/dev/ttyACM0` with your serial port (`/dev/cu.usbmodem*` on macOS, `COM3` on Windows). @@ -183,7 +183,7 @@ The 128×64 OLED is split into two panels: ### Right Panel — Device Info (64×64) ``` - ▓▓ RNodeTHV4 ▓▓ ← title bar (inverted) + ▓▓ RTNode-HV4 ▓▓ ← title bar (inverted) 867.200MHz ← LoRa frequency SF7 125k ← spreading factor & bandwidth ──────────────── ← separator @@ -204,14 +204,14 @@ The firmware runs up to **three RNS interfaces** simultaneously, using different The LoRa radio operates in **Access Point mode**. In Reticulum, this means: - The interface broadcasts its own announces but **blocks rebroadcast of remote announces** from crossing to LoRa - This prevents backbone announces (hundreds of remote destinations) from flooding the limited-bandwidth LoRa channel -- Local nodes discover the boundary node directly; the boundary node answers path requests for remote destinations from its cache +- Local nodes discover the transport node directly; the transport node answers path requests for remote destinations from its cache ### TCP Backbone Interface — `MODE_BOUNDARY` -The TCP backbone connection uses `MODE_BOUNDARY` (`0x20`), a custom implementation of the Reticulum boundary concept adapted for the memory-constrained ESP32 environment. In this implementation, boundary mode means: +The TCP backbone connection uses `MODE_BOUNDARY` (`0x20`), a custom transport mode adapted for the memory-constrained ESP32 environment. In this mode: - Incoming announces from the backbone are received and cached, but **not stored in the path table by default** — only stored when specifically requested via a path request from a local LoRa node - This prevents the path table (limited to 48 entries on ESP32) from being overwhelmed by thousands of backbone destinations -- When the path table needs to be culled, **boundary-mode paths are evicted first**, preserving locally-needed LoRa paths +- When the path table needs to be culled, **backbone-learned paths are evicted first**, preserving locally-needed LoRa paths ### Optional Local TCP Server — `MODE_ACCESS_POINT` @@ -228,18 +228,18 @@ The ESP32-S3 has limited RAM compared to a desktop Reticulum node. Several custo ### Table Size Limits -| Table | Default (Desktop) | RNodeTHV4 | Rationale | +| Table | Default (Desktop) | RTNode-HeltecV4 | Rationale | |-------|-------------------|-----------|-----------| -| Path table (`_destination_table`) | Unbounded | **48 entries** | Prevents unbounded growth; boundary paths evicted first | +| Path table (`_destination_table`) | Unbounded | **48 entries** | Prevents unbounded growth; backbone-learned paths evicted first | | Hash list (`_hashlist`) | 1,000,000 | **32** | Packet dedup list; small is fine for low-throughput LoRa | | Path request tags (`_max_pr_tags`) | 32,000 | **32** | Pending path requests rarely exceed a few dozen | -| Known destinations | 100 | **24** | Identity cache; rarely need more on a boundary node | +| Known destinations | 100 | **24** | Identity cache; rarely need more on a transport node | | Max queued announces | 16 | **4** | Outbound announce queue; LoRa is slow, no point queuing many | | Max receipts | 1,024 | **20** | Packet receipt tracking | ### Timeout Reductions -| Setting | Default | RNodeTHV4 | Rationale | +| Setting | Default | RTNode-HeltecV4 | Rationale | |---------|---------|-----------|-----------| | Destination timeout | 7 days | **1 day** | Free memory faster; stale paths re-resolve automatically | | Pathfinder expiry | 7 days | **1 day** | Same as above | @@ -254,18 +254,18 @@ The most critical optimization: **backbone announces are not stored in the path Instead: 1. Backbone announces are received and their packets cached to flash storage -2. When a local LoRa node requests a path, the boundary checks its cache and responds directly +2. When a local LoRa node requests a path, the transport node checks its cache and responds directly 3. Only **specifically requested** paths get a path table entry 4. Path table culling prioritizes evicting backbone entries over local ones ### Default Route Forwarding -When a transport-addressed packet arrives from LoRa but the boundary has no path table entry for it, the firmware: +When a transport-addressed packet arrives from LoRa but the transport node has no path table entry for it, the firmware: 1. Strips the transport headers (converts `HEADER_2` → `HEADER_1/BROADCAST`) 2. Forwards the raw packet to the backbone interface 3. Creates reverse-table entries so proofs can route back to the sender -This acts as a **default route** — any packet the boundary can't route locally gets forwarded to the backbone. +This acts as a **default route** — any packet the transport node can't route locally gets forwarded to the backbone. ### Cached Packet Unpacking Fix @@ -279,7 +279,7 @@ This was changed to call `unpack()` instead, which parses all packet fields AND The C++ `std::map::insert()` method silently does nothing when a key already exists — unlike Python's `dict[key] = value` which replaces. The original microReticulum code used `insert()` to update path table entries, meaning stale LoRa paths were never replaced by newer TCP paths (or vice versa). -This was fixed by calling `erase()` before `insert()`, ensuring updated path entries always replace stale ones. Without this fix, the boundary node would continue routing packets via an old interface even after a better path was learned. +This was fixed by calling `erase()` before `insert()`, ensuring updated path entries always replace stale ones. Without this fix, the transport node would continue routing packets via an old interface even after a better path was learned. ### Interface Name Uniqueness @@ -310,21 +310,21 @@ On your server, configure `rnsd` with a TCP Server Interface in `~/.reticulum/co listen_port = 4242 ``` -Then configure the boundary node as a **Client** pointing to your server's IP. +Then configure the transport node as a **Client** pointing to your server's IP. -### Example: rnsd Connects to Boundary +### Example: rnsd Connects to Transport Node On your server, configure `rnsd` with a TCP Client Interface: ```ini [interfaces] - [[TCP Client to Boundary]] + [[TCP Client to Transport Node]] type = TCPClientInterface - target_host = + target_host = target_port = 4242 ``` -Set the boundary node's **Local TCP Server** to **Enabled** (port 4242). +Set the transport node's **Local TCP Server** to **Enabled** (port 4242). ## Architecture @@ -332,11 +332,11 @@ Set the boundary node's **Local TCP Server** to **Enabled** (port 4242). | File | Purpose | |------|---------| -| `RNode_Firmware.ino` | Main firmware — boundary mode initialization, interface setup, button handling | -| `BoundaryMode.h` | Boundary state struct, EEPROM load/save, configuration defaults | +| `RNode_Firmware.ino` | Main firmware — transport mode initialization, interface setup, button handling | +| `BoundaryMode.h` | Transport node state struct, EEPROM load/save, configuration defaults | | `BoundaryConfig.h` | Web-based captive portal for configuration | | `TcpInterface.h` | TCP interface for both backbone and local server (implements `RNS::InterfaceImpl`) with HDLC framing, unique naming, and 10 Mbps bitrate | -| `Display.h` | OLED display layout — boundary-specific status page | +| `Display.h` | OLED display layout — transport node status page | | `flash.py` | Flash utility — list serial ports, download from GitHub, merge & flash firmware | | `Boards.h` | Board variant definitions for V3 and V4 | | `platformio.ini` | Build targets: `heltec_V3_boundary`, `heltec_V4_boundary`, and `heltec_V4_boundary-local` | @@ -347,7 +347,7 @@ The firmware depends on [microReticulum](https://github.com/attermann/microRetic | File | Changes | |------|---------| -| `Transport.cpp` | Selective caching, default route forwarding, boundary-aware culling, `get_cached_packet()` unpack fix, path table `erase()+insert()` fix, memory limits | +| `Transport.cpp` | Selective caching, default route forwarding, transport-aware culling, `get_cached_packet()` unpack fix, path table `erase()+insert()` fix, memory limits | | `Transport.h` | `MODE_BOUNDARY`, `PacketEntry`, `Callbacks`, `cull_path_table()`, configurable table sizes | | `Identity.cpp` | `_known_destinations_maxsize` = 24, `cull_known_destinations()` | | `Type.h` | `MODE_BOUNDARY` = 0x20, reduced `MAX_QUEUED_ANNOUNCES`, `MAX_RECEIPTS`, shorter timeouts | diff --git a/RNode_Firmware.ino b/RNode_Firmware.ino index fcb3576..9dd1968 100755 --- a/RNode_Firmware.ino +++ b/RNode_Firmware.ino @@ -565,7 +565,10 @@ void setup() { #ifdef BOUNDARY_MODE // Initialize bt_devname for WiFi hostname when BT is disabled - if (!bt_init_ran) { + #if HAS_BLUETOOTH || HAS_BLE == true + if (!bt_init_ran) + #endif + { uint8_t mac[6]; esp_read_mac(mac, ESP_MAC_WIFI_STA); sprintf(bt_devname, "RNode %02X%02X", mac[4], mac[5]); @@ -730,6 +733,20 @@ void setup() { RNS::Transport::path_table_maxpersist(12); boundary_load_config(); + // Set up IFAC on the LoRa interface if configured + if (boundary_state.ifac_enabled && + (boundary_state.ifac_netname[0] != '\0' || boundary_state.ifac_passphrase[0] != '\0')) { + HEAD("Setting up IFAC on LoRa interface...", RNS::LOG_TRACE); + lora_interface.setup_ifac(boundary_state.ifac_netname, boundary_state.ifac_passphrase); + { + char _ifac_msg[96]; + snprintf(_ifac_msg, sizeof(_ifac_msg), "IFAC configured: netname=%s, passphrase=%s", + boundary_state.ifac_netname[0] ? boundary_state.ifac_netname : "(none)", + boundary_state.ifac_passphrase[0] ? "***" : "(none)"); + HEAD(_ifac_msg, RNS::LOG_TRACE); + } + } + // Start WiFi if enabled if (boundary_state.wifi_enabled) { if (!wifi_initialized) { diff --git a/flash.py b/flash.py index 3943f22..291d603 100755 --- a/flash.py +++ b/flash.py @@ -1,8 +1,8 @@ #!/usr/bin/env python3 """ -RNodeTHV4 Flash Utility +RTNode-HeltecV4 Flash Utility -Flash the RNodeTHV4 boundary node firmware to a Heltec WiFi LoRa 32 V3 or V4. +Flash the RTNode-HeltecV4 transport node firmware to a Heltec WiFi LoRa 32 V3 or V4. No PlatformIO required — just Python 3 and a USB cable. By default, downloads the latest firmware from GitHub Releases (if newer than @@ -47,11 +47,11 @@ import time # ── Configuration ────────────────────────────────────────────────────────────── -VERSION = "1.0.17" +VERSION = "1.0.18" CHIP = "esp32s3" FLASH_MODE = "qio" # Global default; overridden by board profile FLASH_FREQ = "80m" -GITHUB_REPO = "jrl290/RNodeTHV4" +GITHUB_REPO = "jrl290/RTNode-HeltecV4" # Runtime state (set automatically during main()) _flash_mode_override = None # CLI --flash-mode sets this; otherwise board profile wins @@ -72,7 +72,7 @@ BOARD_PROFILES = { "pio_env": "heltec_V4_boundary", "build_dir": ".pio/build/heltec_V4_boundary", "firmware_bin": "rnode_firmware_heltec32v4_boundary.bin", - "merged_filename": "rnodethv4_firmware.bin", + "merged_filename": "rtnode_heltec_v4.bin", "flash_size": "16MB", "baud_rate": "921600", "flash_mode": "dio", # DIO is universally compatible with all flash chips @@ -82,7 +82,7 @@ BOARD_PROFILES = { "pio_env": "heltec_V3_boundary", "build_dir": ".pio/build/heltec_V3_boundary", "firmware_bin": "rnode_firmware_heltec32v3.bin", - "merged_filename": "rnodethv3_firmware.bin", + "merged_filename": "rtnode_heltec_v3.bin", "flash_size": "8MB", "baud_rate": "460800", "flash_mode": "dio", # V3 uses DIO — some flash chips do not support QIO @@ -933,7 +933,7 @@ def _monitor_boot(port, timeout=8): def main(): global _board parser = argparse.ArgumentParser( - description="RNodeTHV4 Flash Utility — flash boundary node firmware to Heltec V3/V4", + description="RTNode-HeltecV4 Flash Utility — flash transport node firmware to Heltec V3/V4", formatter_class=argparse.RawDescriptionHelpFormatter, epilog=""" Examples: @@ -1017,7 +1017,7 @@ Examples: print() print("╔══════════════════════════════════════════╗") - print("║ RNodeTHV4 Flash Utility ║") + print("║ RTNode-HeltecV4 Flash Utility ║") print(f"║ {bp['name']:^40s} ║") print("╚══════════════════════════════════════════╝") print() @@ -1192,7 +1192,7 @@ Examples: print(f"\n App-only update: {os.path.basename(firmware_path)} → 0x{APP_ADDR:05x}") print(f" Size: {os.path.getsize(firmware_path):,} bytes") - print(f" WiFi/boundary settings will be preserved") + print(f" WiFi/transport settings will be preserved") # ── Interactive options ───────────────────────────────────────────────── diff --git a/lib/microReticulum/src/Interface.cpp b/lib/microReticulum/src/Interface.cpp index 18bde30..30c1493 100755 --- a/lib/microReticulum/src/Interface.cpp +++ b/lib/microReticulum/src/Interface.cpp @@ -2,12 +2,61 @@ #include "Identity.h" #include "Transport.h" +#include "Reticulum.h" +#include "Cryptography/Hashes.h" +#include "Cryptography/HKDF.h" using namespace RNS; using namespace RNS::Type::Interface; /*static*/ uint8_t Interface::DISCOVER_PATHS_FOR = MODE_ACCESS_POINT | MODE_GATEWAY; +void Interface::setup_ifac(const char* ifac_netname, const char* ifac_netkey) { + assert(_impl); + if (ifac_netname == nullptr && ifac_netkey == nullptr) { + return; + } + // If both are empty strings, treat as no IFAC + bool has_netname = (ifac_netname != nullptr && ifac_netname[0] != '\0'); + bool has_netkey = (ifac_netkey != nullptr && ifac_netkey[0] != '\0'); + if (!has_netname && !has_netkey) { + return; + } + + TRACE("Interface::setup_ifac: setting up IFAC for " + _impl->_name); + + // Build ifac_origin = SHA256(netname) || SHA256(netkey) + Bytes ifac_origin; + if (has_netname) { + Bytes netname_bytes((const uint8_t*)ifac_netname, strlen(ifac_netname)); + Bytes hash = Identity::full_hash(netname_bytes); + ifac_origin = ifac_origin + hash; + } + if (has_netkey) { + Bytes netkey_bytes((const uint8_t*)ifac_netkey, strlen(ifac_netkey)); + Bytes hash = Identity::full_hash(netkey_bytes); + ifac_origin = ifac_origin + hash; + } + + // Hash the combined origin + Bytes ifac_origin_hash = Identity::full_hash(ifac_origin); + + // Derive ifac_key via HKDF(salt=IFAC_SALT, ikm=ifac_origin_hash, length=64) + Bytes salt(IFAC_SALT, IFAC_SALT_SIZE); + _impl->_ifac_key = Cryptography::hkdf(64, ifac_origin_hash, salt); + + // Create an identity from the derived key (64 bytes = 32 X25519 + 32 Ed25519) + Identity ifac_id(false); // don't auto-generate keys + ifac_id.load_private_key(_impl->_ifac_key); + _impl->_ifac_id = ifac_id; + + // Set _ifac_identity to non-empty to flag IFAC as enabled + // (Transport checks this with operator bool) + _impl->_ifac_identity = ifac_id.get_public_key(); + + TRACE("Interface::setup_ifac: IFAC configured, ifac_size=" + std::to_string(_impl->_ifac_size)); +} + void InterfaceImpl::handle_outgoing(const Bytes& data) { //TRACE("InterfaceImpl.handle_outgoing: data: " + data.toHex()); TRACE("InterfaceImpl.handle_outgoing"); diff --git a/lib/microReticulum/src/Interface.h b/lib/microReticulum/src/Interface.h index 166b4be..bef5a0f 100755 --- a/lib/microReticulum/src/Interface.h +++ b/lib/microReticulum/src/Interface.h @@ -72,6 +72,9 @@ namespace RNS { size_t _txb = 0; bool _online = false; Bytes _ifac_identity; + Bytes _ifac_key; + Identity _ifac_id = {Type::NONE}; + uint8_t _ifac_size = 8; // DEFAULT_IFAC_SIZE for LoRa-type interfaces Type::Interface::modes _mode = Type::Interface::MODE_NONE; uint32_t _bitrate = 0; uint16_t _HW_MTU = 0; @@ -187,6 +190,11 @@ namespace RNS { inline bool online() const { assert(_impl); return _impl->_online; } inline std::string name() const { assert(_impl); return _impl->_name; } inline const Bytes& ifac_identity() const { assert(_impl); return _impl->_ifac_identity; } + inline const Bytes& ifac_key() const { assert(_impl); return _impl->_ifac_key; } + inline const Identity& ifac_id() const { assert(_impl); return _impl->_ifac_id; } + inline uint8_t ifac_size() const { assert(_impl); return _impl->_ifac_size; } + inline void ifac_size(uint8_t size) { assert(_impl); _impl->_ifac_size = size; } + void setup_ifac(const char* ifac_netname, const char* ifac_netkey); inline Type::Interface::modes mode() const { assert(_impl); return _impl->_mode; } inline void mode(Type::Interface::modes mode) { assert(_impl); _impl->_mode = mode; } inline uint32_t bitrate() const { assert(_impl); return _impl->_bitrate; } diff --git a/lib/microReticulum/src/Reticulum.h b/lib/microReticulum/src/Reticulum.h index 6cd12af..8d9606e 100755 --- a/lib/microReticulum/src/Reticulum.h +++ b/lib/microReticulum/src/Reticulum.h @@ -14,6 +14,15 @@ namespace RNS { + // IFAC salt used for key derivation (matches Python RNS Reticulum.IFAC_SALT) + static const uint8_t IFAC_SALT[] = { + 0xad, 0xf5, 0x4d, 0x88, 0x2c, 0x9a, 0x9b, 0x80, + 0x77, 0x1e, 0xb4, 0x99, 0x5d, 0x70, 0x2d, 0x4a, + 0x3e, 0x73, 0x33, 0x91, 0xb2, 0xa0, 0xf5, 0x3f, + 0x41, 0x6d, 0x9f, 0x90, 0x7e, 0x55, 0xcf, 0xf8 + }; + static const size_t IFAC_SALT_SIZE = sizeof(IFAC_SALT); + class Reticulum { public: diff --git a/lib/microReticulum/src/Transport.cpp b/lib/microReticulum/src/Transport.cpp index 74a640e..df8f4fc 100755 --- a/lib/microReticulum/src/Transport.cpp +++ b/lib/microReticulum/src/Transport.cpp @@ -8,6 +8,7 @@ #include "Interface.h" #include "Log.h" #include "Cryptography/Random.h" +#include "Cryptography/HKDF.h" #include "Utilities/OS.h" #include "Utilities/Persistence.h" @@ -734,43 +735,48 @@ static bool is_backbone_interface(const Interface& iface) { try { //if hasattr(interface, "ifac_identity") and interface.ifac_identity != None: if (interface.ifac_identity()) { -// TODO -/*p - // Calculate packet access code - ifac = interface.ifac_identity.sign(raw)[-interface.ifac_size:] + // Calculate packet access code by signing the raw packet + // and taking the last ifac_size bytes of the signature + Bytes signature = interface.ifac_id().sign(raw); + Bytes ifac = signature.right(interface.ifac_size()); - // Generate mask - mask = RNS.Cryptography.hkdf( - length=len(raw)+interface.ifac_size, - derive_from=ifac, - salt=interface.ifac_key, - context=None, - ) + // Generate mask via HKDF + Bytes mask = Cryptography::hkdf( + raw.size() + interface.ifac_size(), + ifac, + interface.ifac_key() + ); - // Set IFAC flag - new_header = bytes([raw[0] | 0x80, raw[1]]) + // Set IFAC flag in header byte 0 + uint8_t new_header0 = raw[0] | 0x80; + uint8_t new_header1 = raw[1]; + + // Assemble new payload: new_header + ifac + raw[2:] + Bytes new_raw; + new_raw.append(new_header0); + new_raw.append(new_header1); + new_raw.append(ifac); + new_raw.append(raw.mid(2)); - // Assemble new payload with IFAC - new_raw = new_header+ifac+raw[2:] - // Mask payload - i = 0; masked_raw = b"" - for byte in new_raw: - if i == 0: - // Mask first header byte, but make sure the - // IFAC flag is still set - masked_raw += bytes([byte ^ mask[i] | 0x80]) - elif i == 1 or i > interface.ifac_size+1: + Bytes masked_raw; + for (size_t i = 0; i < new_raw.size(); i++) { + if (i == 0) { + // Mask first header byte, keep IFAC flag set + masked_raw.append((uint8_t)((new_raw[i] ^ mask[i]) | 0x80)); + } + else if (i == 1 || i > (size_t)(interface.ifac_size() + 1)) { // Mask second header byte and payload - masked_raw += bytes([byte ^ mask[i]]) - else: + masked_raw.append((uint8_t)(new_raw[i] ^ mask[i])); + } + else { // Don't mask the IFAC itself - masked_raw += bytes([byte]) - i += 1 + masked_raw.append(new_raw[i]); + } + } // Send it - interface.on_outgoing(masked_raw) -*/ + interface.send_outgoing(masked_raw); } else { interface.send_outgoing(raw); @@ -1258,8 +1264,8 @@ static bool is_backbone_interface(const Interface& iface) { return false; } -/*static*/ void Transport::inbound(const Bytes& raw, const Interface& interface /*= {Type::NONE}*/) { - TRACEF("Transport::inbound: received %d bytes", raw.size()); +/*static*/ void Transport::inbound(const Bytes& raw_in, const Interface& interface /*= {Type::NONE}*/) { + TRACEF("Transport::inbound: received %d bytes", raw_in.size()); ++_packets_received; // Heap telemetry: snapshot at entry @@ -1267,79 +1273,94 @@ static bool is_backbone_interface(const Interface& iface) { // CBA if (_callbacks._receive_packet) { try { - _callbacks._receive_packet(raw, interface); + _callbacks._receive_packet(raw_in, interface); } catch (std::exception& e) { DEBUG("Error while executing receive packet callback. The contained exception was: " + std::string(e.what())); } } -// TODO -/*p + + // Mutable copy of raw data for IFAC processing + Bytes raw = raw_in; + // If interface access codes are enabled, // we must authenticate each packet. - //if len(raw) > 2: if (raw.size() > 2) { - if interface != None and hasattr(interface, "ifac_identity") and interface.ifac_identity != None: + if (interface && interface.ifac_identity()) { // Check that IFAC flag is set - if raw[0] & 0x80 == 0x80: - if len(raw) > 2+interface.ifac_size: + if ((raw[0] & 0x80) == 0x80) { + if (raw.size() > (size_t)(2 + interface.ifac_size())) { // Extract IFAC - ifac = raw[2:2+interface.ifac_size] + Bytes ifac = raw.mid(2, interface.ifac_size()); // Generate mask - mask = RNS.Cryptography.hkdf( - length=len(raw), - derive_from=ifac, - salt=interface.ifac_key, - context=None, - ) + Bytes mask = Cryptography::hkdf( + raw.size(), + ifac, + interface.ifac_key() + ); // Unmask payload - i = 0; unmasked_raw = b"" - for byte in raw: - if i <= 1 or i > interface.ifac_size+1: + Bytes unmasked_raw; + for (size_t i = 0; i < raw.size(); i++) { + if (i <= 1 || i > (size_t)(interface.ifac_size() + 1)) { // Unmask header bytes and payload - unmasked_raw += bytes([byte ^ mask[i]]) - else: + unmasked_raw.append((uint8_t)(raw[i] ^ mask[i])); + } + else { // Don't unmask IFAC itself - unmasked_raw += bytes([byte]) - i += 1 - raw = unmasked_raw + unmasked_raw.append(raw[i]); + } + } + raw = unmasked_raw; // Unset IFAC flag - new_header = bytes([raw[0] & 0x7f, raw[1]]) + uint8_t new_header0 = raw[0] & 0x7F; + uint8_t new_header1 = raw[1]; - // Re-assemble packet - new_raw = new_header+raw[2+interface.ifac_size:] + // Re-assemble packet without IFAC bytes + Bytes new_raw; + new_raw.append(new_header0); + new_raw.append(new_header1); + new_raw.append(raw.mid(2 + interface.ifac_size())); // Calculate expected IFAC - expected_ifac = interface.ifac_identity.sign(new_raw)[-interface.ifac_size:] + Bytes expected_signature = interface.ifac_id().sign(new_raw); + Bytes expected_ifac = expected_signature.right(interface.ifac_size()); // Check it - if ifac == expected_ifac: - raw = new_raw - else: - return - - else: - return - - else: - // If the IFAC flag is not set, but should be, - // drop the packet. - return - - else: + if (ifac == expected_ifac) { + raw = new_raw; + } + else { + TRACE("Transport::inbound: IFAC authentication failed, dropping packet"); + return; + } + } + else { + TRACE("Transport::inbound: packet too short for IFAC, dropping"); + return; + } + } + else { + // If the IFAC flag is not set, but should be, drop the packet + TRACE("Transport::inbound: IFAC required but flag not set, dropping packet"); + return; + } + } + else { // If the interface does not have IFAC enabled, // check the received packet IFAC flag. - if raw[0] & 0x80 == 0x80: + if ((raw[0] & 0x80) == 0x80) { // If the flag is set, drop the packet - return + TRACE("Transport::inbound: IFAC flag set but interface has no IFAC, dropping packet"); + return; + } + } } else { return; } -*/ while (_jobs_running) { TRACE("Transport::inbound: sleeping...");