From 085d99188175c3f230f47684db2db00e7a1ccce0 Mon Sep 17 00:00:00 2001 From: jmp Date: Sat, 26 Sep 2026 07:49:24 +0000 Subject: [PATCH] Implement interface access codes (IFAC) microReticulum parsed IFAC-flagged packets as ordinary ones: the IFAC code in Transport was a commented-out TODO, so a node could not take part in a network whose interfaces use ifac_netname / ifac_passphrase. This follows Python RNS: - Interface::setup_ifac(netname, netkey) derives ifac_key = HKDF(64, SHA256(SHA256(netname) || SHA256(netkey)), IFAC_SALT) and the IFAC signing identity from it (ifac_size defaults to 8, the RNode/LoRa value). - Transport::ifac_mask(): the IFAC is the last ifac_size bytes of the identity's signature of the packet; header and payload are masked with HKDF(ifac) salted with ifac_key. Used by Transport::transmit(). - Transport::ifac_unmask(): unmask, then check the code; Transport::inbound() drops packets without a valid IFAC on an IFAC interface, and packets that carry one on an interface without IFAC (as Python RNS does). Ported from the IFAC implementation in the microReticulum copy bundled with jrl290/RTNode-HeltecV4 (Apache-2.0), with the masking factored into testable helpers. test/test_ifac checks the derived key, the masked packet and the unmasked packet byte-for-byte against vectors produced by Python RNS 1.5.4 itself (its key derivation and RNS.Transport.transmit()), and that tampered, foreign-network, unflagged and truncated packets are rejected. Co-Authored-By: Claude Opus 5.5 --- README.md | 1 + src/microReticulum/Interface.cpp | 37 ++++++ src/microReticulum/Interface.h | 12 ++ src/microReticulum/Reticulum.h | 10 ++ src/microReticulum/Transport.cpp | 186 ++++++++++++++----------------- src/microReticulum/Transport.h | 2 + test/test_ifac/test_ifac.cpp | 102 +++++++++++++++++ 7 files changed, 246 insertions(+), 104 deletions(-) create mode 100644 test/test_ifac/test_ifac.cpp diff --git a/README.md b/README.md index 96629e3c..e95fb424 100644 --- a/README.md +++ b/README.md @@ -367,6 +367,7 @@ Use of nrf52840 based boards as a transport node will necessitate the use of ext - [x] Link example app - [x] NomadNet example app - [x] Add flexible provisioning subsystem +- [x] Implement interface access codes (IFAC) Please open an Issue if you have trouble building ior using the API, and feel free to start a new Discussion for anything else. diff --git a/src/microReticulum/Interface.cpp b/src/microReticulum/Interface.cpp index 46cd09d5..20d32fe2 100644 --- a/src/microReticulum/Interface.cpp +++ b/src/microReticulum/Interface.cpp @@ -16,12 +16,49 @@ #include "Identity.h" #include "Transport.h" +#include "Reticulum.h" +#include "Cryptography/HKDF.h" + +#include using namespace RNS; using namespace RNS::Type::Interface; /*static*/ uint8_t Interface::DISCOVER_PATHS_FOR = MODE_ACCESS_POINT | MODE_GATEWAY | MODE_ROAMING; +// Ported from the IFAC implementation of RTNode-HeltecV4's microReticulum +// copy (Apache-2.0), itself following Python RNS Reticulum._add_interface(): +// ifac_key = HKDF(64, SHA256(SHA256(netname) || SHA256(netkey)), IFAC_SALT), +// used both as a signing identity and as the mask salt. +void Interface::setup_ifac(const char* ifac_netname, const char* ifac_netkey) { + assert(_impl); + 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) { + _impl->_ifac_identity = {Bytes::NONE}; + _impl->_ifac_key = {Bytes::NONE}; + _impl->_ifac_id = {Type::NONE}; + return; + } + + Bytes ifac_origin; + if (has_netname) { + ifac_origin += Identity::full_hash(Bytes((const uint8_t*)ifac_netname, strlen(ifac_netname))); + } + if (has_netkey) { + ifac_origin += Identity::full_hash(Bytes((const uint8_t*)ifac_netkey, strlen(ifac_netkey))); + } + Bytes ifac_origin_hash = Identity::full_hash(ifac_origin); + _impl->_ifac_key = Cryptography::hkdf(64, ifac_origin_hash, Bytes(IFAC_SALT, IFAC_SALT_SIZE)); + + Identity ifac_id(false); + ifac_id.load_private_key(_impl->_ifac_key); + _impl->_ifac_id = ifac_id; + // Non-empty = IFAC on (Transport tests it with operator bool). + _impl->_ifac_identity = ifac_id.get_public_key(); + DEBUGF("Interface::setup_ifac: IFAC enabled on %s, ifac_size=%d", _impl->_name.c_str(), _impl->_ifac_size); +} + void InterfaceImpl::handle_outgoing(const Bytes& data) { //TRACEF("InterfaceImpl.handle_outgoing: data: %s", data.toHex().c_str()); //TRACE("InterfaceImpl.handle_outgoing"); diff --git a/src/microReticulum/Interface.h b/src/microReticulum/Interface.h index 0d939b9b..9e14d9b8 100644 --- a/src/microReticulum/Interface.h +++ b/src/microReticulum/Interface.h @@ -95,6 +95,9 @@ namespace RNS { std::string _name; bool _online = false; Bytes _ifac_identity; + Bytes _ifac_key; + Identity _ifac_id = {Type::NONE}; + uint8_t _ifac_size = 8; // Python RNS: DEFAULT_IFAC_SIZE of LoRa (RNode) interfaces Type::Interface::modes _mode = Type::Interface::MODE_NONE; uint32_t _bitrate = 0; uint16_t _HW_MTU = 0; @@ -238,6 +241,15 @@ 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; } + // Interface access codes (IFAC), as Python RNS: packets carry a + // truncated Ed25519 signature and are masked with a key derived + // from the network name and/or passphrase. Empty/null for both + // leaves IFAC off. + 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/src/microReticulum/Reticulum.h b/src/microReticulum/Reticulum.h index 0ef1436b..09cbf37a 100644 --- a/src/microReticulum/Reticulum.h +++ b/src/microReticulum/Reticulum.h @@ -31,6 +31,16 @@ using namespace RNS::Persistence; namespace RNS { + // IFAC salt used for key derivation (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 { private: diff --git a/src/microReticulum/Transport.cpp b/src/microReticulum/Transport.cpp index 89b5238b..f49e1647 100644 --- a/src/microReticulum/Transport.cpp +++ b/src/microReticulum/Transport.cpp @@ -22,6 +22,7 @@ #include "Interface.h" #include "Log.h" #include "Cryptography/Random.h" +#include "Cryptography/HKDF.h" #include "Utilities/OS.h" #include "Utilities/Persistence.h" @@ -1089,6 +1090,67 @@ TRACEF("path_request_conditions=%u", path_request_conditions); } } +// Interface access codes (Python RNS Transport.transmit/inbound; ported +// from RTNode-HeltecV4's microReticulum copy, Apache-2.0). The IFAC is the +// last ifac_size bytes of the IFAC identity's signature of the packet; +// header and payload are then masked with HKDF(ifac) salted by the IFAC key. +/*static*/ const Bytes Transport::ifac_mask(const Interface& interface, const Bytes& raw) { + const size_t ifac_size = interface.ifac_size(); + Bytes ifac = interface.ifac_id().sign(raw).right(ifac_size); + Bytes mask = Cryptography::hkdf(raw.size() + ifac_size, ifac, interface.ifac_key()); + + Bytes new_raw; + new_raw.append((uint8_t)(raw[0] | 0x80)); + new_raw.append(raw[1]); + new_raw.append(ifac); + new_raw.append(raw.mid(2)); + + Bytes masked_raw; + for (size_t i = 0; i < new_raw.size(); i++) { + if (i == 0) { + // Mask the first header byte, keeping the IFAC flag set + masked_raw.append((uint8_t)((new_raw[i] ^ mask[i]) | 0x80)); + } + else if (i == 1 || i > ifac_size + 1) { + // Mask the second header byte and the payload + masked_raw.append((uint8_t)(new_raw[i] ^ mask[i])); + } + else { + // The IFAC itself stays clear + masked_raw.append(new_raw[i]); + } + } + return masked_raw; +} + +// The packet without its IFAC, or NONE when the IFAC flag is missing, the +// packet is too short, or the code doesn't authenticate. +/*static*/ const Bytes Transport::ifac_unmask(const Interface& interface, const Bytes& raw) { + const size_t ifac_size = interface.ifac_size(); + if (raw.size() <= 2 + ifac_size || (raw[0] & 0x80) != 0x80) { + return {Bytes::NONE}; + } + Bytes ifac = raw.mid(2, ifac_size); + Bytes mask = Cryptography::hkdf(raw.size(), ifac, interface.ifac_key()); + Bytes unmasked_raw; + for (size_t i = 0; i < raw.size(); i++) { + if (i <= 1 || i > ifac_size + 1) { + unmasked_raw.append((uint8_t)(raw[i] ^ mask[i])); + } + else { + unmasked_raw.append(raw[i]); + } + } + Bytes new_raw; + new_raw.append((uint8_t)(unmasked_raw[0] & 0x7F)); + new_raw.append(unmasked_raw[1]); + new_raw.append(unmasked_raw.mid(2 + ifac_size)); + if (interface.ifac_id().sign(new_raw).right(ifac_size) != ifac) { + return {Bytes::NONE}; + } + return new_raw; +} + /*static*/ bool Transport::transmit(Interface& interface, const Bytes& raw) { TRACE("Transport::transmit()"); bool sent = false; @@ -1104,43 +1166,7 @@ TRACEF("path_request_conditions=%u", path_request_conditions); 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:] - - // Generate mask - mask = RNS.Cryptography.hkdf( - length=len(raw)+interface.ifac_size, - derive_from=ifac, - salt=interface.ifac_key, - context=None, - ) - - // Set IFAC flag - new_header = bytes([raw[0] | 0x80, raw[1]]) - - // 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: - // Mask second header byte and payload - masked_raw += bytes([byte ^ mask[i]]) - else: - // Don't mask the IFAC itself - masked_raw += bytes([byte]) - i += 1 - - // Send it - sent = interface.on_outgoing(masked_raw) -*/ + sent = interface.send_outgoing(ifac_mask(interface, raw)); } else { sent = interface.send_outgoing(raw); @@ -1694,85 +1720,37 @@ TRACEF("path_request_conditions=%u", path_request_conditions); 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; // CBA if (_callbacks._receive_packet) { try { - _callbacks._receive_packet(raw, interface); + _callbacks._receive_packet(raw_in, interface); } catch (const std::exception& e) { DEBUGF("Error while executing receive packet callback. The contained exception was: %s", e.what()); } } -// TODO -/*p - // 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: - // Check that IFAC flag is set - if raw[0] & 0x80 == 0x80: - if len(raw) > 2+interface.ifac_size: - // Extract IFAC - ifac = raw[2:2+interface.ifac_size] - - // Generate mask - mask = RNS.Cryptography.hkdf( - length=len(raw), - derive_from=ifac, - salt=interface.ifac_key, - context=None, - ) - - // Unmask payload - i = 0; unmasked_raw = b"" - for byte in raw: - if i <= 1 or i > interface.ifac_size+1: - // Unmask header bytes and payload - unmasked_raw += bytes([byte ^ mask[i]]) - else: - // Don't unmask IFAC itself - unmasked_raw += bytes([byte]) - i += 1 - raw = unmasked_raw - - // Unset IFAC flag - new_header = bytes([raw[0] & 0x7f, raw[1]]) - - // Re-assemble packet - new_raw = new_header+raw[2+interface.ifac_size:] - - // Calculate expected IFAC - expected_ifac = interface.ifac_identity.sign(new_raw)[-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 the interface does not have IFAC enabled, - // check the received packet IFAC flag. - if raw[0] & 0x80 == 0x80: - // If the flag is set, drop the packet - return + // If interface access codes are enabled, every packet must carry a + // valid one (Python RNS Transport.inbound; ported from + // RTNode-HeltecV4, Apache-2.0). Without IFAC, a packet flagged with + // one is dropped rather than misread as garbage. + Bytes raw = raw_in; + if (raw.size() <= 2) { + return; } - else { + if (interface && interface.ifac_identity()) { + raw = ifac_unmask(interface, raw); + if (!raw) { + TRACE("Transport::inbound: missing or invalid IFAC, dropping packet"); + return; + } + } + else if ((raw[0] & 0x80) == 0x80) { + TRACE("Transport::inbound: IFAC flag set but interface has no IFAC, dropping packet"); return; } -*/ if (_jobs_running) DEBUG("Transport::inbound: jobs still running!"); while (_jobs_running) { diff --git a/src/microReticulum/Transport.h b/src/microReticulum/Transport.h index 4ce06bd6..5d5a34e2 100644 --- a/src/microReticulum/Transport.h +++ b/src/microReticulum/Transport.h @@ -352,6 +352,8 @@ namespace RNS { static void start(const Reticulum& reticulum_instance); static void loop(); static void jobs(); + static const Bytes ifac_mask(const Interface& interface, const Bytes& raw); + static const Bytes ifac_unmask(const Interface& interface, const Bytes& raw); static bool transmit(Interface& interface, const Bytes& raw); static bool outbound(Packet& packet); static void add_packet_hash(const Bytes& packet_hash); diff --git a/test/test_ifac/test_ifac.cpp b/test/test_ifac/test_ifac.cpp new file mode 100644 index 00000000..724c8244 --- /dev/null +++ b/test/test_ifac/test_ifac.cpp @@ -0,0 +1,102 @@ +#include + +#include "microReticulum.h" + +// Reference vectors produced by Python RNS 1.5.4 itself: the IFAC key +// derivation of Reticulum's interface setup, and RNS.Transport.transmit() +// masking a packet on an interface with ifac_size 8 (LoRa/RNode default). +static const char* NETNAME = "t114-test"; +static const char* NETKEY = "correct horse battery"; +static const char* KEY_HEX = + "fd8c2d34e726f864a29dbcc21699ba7e5a71be366739dcc81a8bbf800a535744" + "717704cfedbd9b122e9b377989af898cecfbc1f2ecda9e285bcc2d266b390821"; +static const char* RAW_HEX = + "0100867d6e042dcce3ed972c2d0f4f2123a9002f5d209d6de938eec6f269f5e6a90858"; +static const char* MASKED_HEX = + "8711f21f1698735aba0cf40cbdbebb25afb3577006ea611e6c3a472cd552c8f6e085bd2a526db83728c2bb"; + +class NullInterface : public RNS::InterfaceImpl { +public: + NullInterface() : RNS::InterfaceImpl("NullInterface") { _OUT = true; _IN = true; } + virtual bool send_outgoing(const RNS::Bytes& data) { return true; } +}; + +static RNS::Bytes hex(const char* h) { + RNS::Bytes b; + b.assignHex(h); + return b; +} + +static RNS::Interface ifac_interface() { + RNS::Interface iface(new NullInterface()); + iface.setup_ifac(NETNAME, NETKEY); + return iface; +} + +void testKeyDerivationMatchesPython() { + RNS::Interface iface = ifac_interface(); + TEST_ASSERT_TRUE((bool)iface.ifac_identity()); + TEST_ASSERT_EQUAL_STRING(KEY_HEX, iface.ifac_key().toHex().c_str()); +} + +void testMaskMatchesPython() { + RNS::Interface iface = ifac_interface(); + RNS::Bytes masked = RNS::Transport::ifac_mask(iface, hex(RAW_HEX)); + TEST_ASSERT_EQUAL_STRING(MASKED_HEX, masked.toHex().c_str()); +} + +void testUnmaskRecoversPythonPacket() { + RNS::Interface iface = ifac_interface(); + RNS::Bytes raw = RNS::Transport::ifac_unmask(iface, hex(MASKED_HEX)); + TEST_ASSERT_EQUAL_STRING(RAW_HEX, raw.toHex().c_str()); +} + +void testTamperedOrForeignPacketsAreRejected() { + RNS::Interface iface = ifac_interface(); + // One flipped payload bit: the code no longer authenticates. + RNS::Bytes tampered = hex(MASKED_HEX); + tampered[tampered.size() - 1] ^= 0x01; + TEST_ASSERT_FALSE((bool)RNS::Transport::ifac_unmask(iface, tampered)); + // Another network's key. + RNS::Interface other(new NullInterface()); + other.setup_ifac("another-net", NETKEY); + TEST_ASSERT_FALSE((bool)RNS::Transport::ifac_unmask(other, hex(MASKED_HEX))); + // No IFAC flag, or too short. + TEST_ASSERT_FALSE((bool)RNS::Transport::ifac_unmask(iface, hex(RAW_HEX))); + TEST_ASSERT_FALSE((bool)RNS::Transport::ifac_unmask(iface, hex("8711f21f16"))); +} + +void testEmptyNameAndKeyLeaveIfacOff() { + RNS::Interface iface(new NullInterface()); + iface.setup_ifac("", nullptr); + TEST_ASSERT_FALSE((bool)iface.ifac_identity()); +} + +void setUp(void) {} +void tearDown(void) {} + +int runUnityTests(void) { + UNITY_BEGIN(); + RUN_TEST(testKeyDerivationMatchesPython); + RUN_TEST(testMaskMatchesPython); + RUN_TEST(testUnmaskRecoversPythonPacket); + RUN_TEST(testTamperedOrForeignPacketsAreRejected); + RUN_TEST(testEmptyNameAndKeyLeaveIfacOff); + return UNITY_END(); +} + +int main(void) { + return runUnityTests(); +} + +#ifdef ARDUINO +void setup() { + delay(2000); + runUnityTests(); +} +void loop() {} +#endif + +void app_main() { + runUnityTests(); +}