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(); +}