From d6b12ea3f1fc83afcff63a7fb511d54c52cc871a Mon Sep 17 00:00:00 2001 From: Benjamin Faershtein <119711889+RCGV1@users.noreply.github.com> Date: Thu, 9 Jul 2026 13:36:10 -0700 Subject: [PATCH 01/15] feat(security): enforce packet authenticity policies --- src/main.cpp | 5 +- src/mesh/FloodingRouter.cpp | 37 +- src/mesh/FloodingRouter.h | 4 +- src/mesh/NextHopRouter.cpp | 26 +- src/mesh/NextHopRouter.h | 3 +- src/mesh/NodeDB.cpp | 16 + src/mesh/NodeDB.h | 4 + src/mesh/Router.cpp | 262 +++++- src/mesh/Router.h | 27 +- src/mesh/WarmNodeStore.h | 2 +- src/mesh/udp/UdpMulticastHandler.h | 6 +- src/modules/AdminModule.cpp | 10 + src/modules/NodeInfoModule.cpp | 11 +- src/mqtt/MQTT.cpp | 15 +- test/test_mqtt/MQTT.cpp | 59 ++ test/test_packet_signing/test_main.cpp | 792 ++++++++++++++++-- variants/stm32/CDEBYTE_E77-MBL/platformio.ini | 5 +- variants/stm32/rak3172/platformio.ini | 3 + variants/stm32/stm32.ini | 4 +- 19 files changed, 1129 insertions(+), 162 deletions(-) diff --git a/src/main.cpp b/src/main.cpp index 63cbf35ab07..7161ceff65b 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -1206,7 +1206,7 @@ bool runASAP; // TODO find better home than main.cpp extern meshtastic_DeviceMetadata getDeviceMetadata() { - meshtastic_DeviceMetadata deviceMetadata; + meshtastic_DeviceMetadata deviceMetadata = meshtastic_DeviceMetadata_init_zero; strncpy(deviceMetadata.firmware_version, optstr(APP_VERSION), sizeof(deviceMetadata.firmware_version)); deviceMetadata.device_state_version = DEVICESTATE_CUR_VER; deviceMetadata.canShutdown = pmu_found || HAS_CPU_SHUTDOWN; @@ -1262,6 +1262,9 @@ extern meshtastic_DeviceMetadata getDeviceMetadata() #if !(MESHTASTIC_EXCLUDE_PKI) deviceMetadata.hasPKC = true; +#endif +#if !(MESHTASTIC_EXCLUDE_PKI) && !(MESHTASTIC_EXCLUDE_XEDDSA) + deviceMetadata.has_xeddsa = true; #endif return deviceMetadata; } diff --git a/src/mesh/FloodingRouter.cpp b/src/mesh/FloodingRouter.cpp index 13f98299f93..7048cd91873 100644 --- a/src/mesh/FloodingRouter.cpp +++ b/src/mesh/FloodingRouter.cpp @@ -51,8 +51,8 @@ bool FloodingRouter::shouldFilterReceived(const meshtastic_MeshPacket *p) LOG_DEBUG("Repeated reliable tx"); // Check if it's still in the Tx queue, if not, we have to relay it again if (!findInTxQueue(p->from, p->id)) { - reprocessPacket(p); - perhapsRebroadcast(p); + if (reprocessPacket(p)) + perhapsRebroadcast(p); } } else { perhapsCancelDupe(p); @@ -68,6 +68,12 @@ bool FloodingRouter::perhapsHandleUpgradedPacket(const meshtastic_MeshPacket *p) { // isRebroadcaster() is duplicated in perhapsRebroadcast(), but this avoids confusing log messages if (isRebroadcaster() && iface && p->hop_limit > 0) { + // Verify the replacement before deleting the valid lower-hop copy waiting in the TX queue. + // This is intentionally redundant with ReliableRouter's ingress gate: it keeps this helper + // safe if another caller is introduced later. + if (passesRoutingAuthGate(const_cast(p)) != RoutingAuthVerdict::ACCEPT) + return true; + // If we overhear a duplicate copy of the packet with more hops left than the one we are waiting to // rebroadcast, then remove the packet currently sitting in the TX queue and use this one instead. uint8_t dropThreshold = p->hop_limit; // remove queued packets that have fewer hops remaining @@ -75,7 +81,8 @@ bool FloodingRouter::perhapsHandleUpgradedPacket(const meshtastic_MeshPacket *p) LOG_DEBUG("Processing upgraded packet 0x%08x for rebroadcast with hop limit %d (dropping queued < %d)", p->id, p->hop_limit, dropThreshold); - reprocessPacket(p); + if (!reprocessPacket(p)) + return true; perhapsRebroadcast(p); rxDupe++; @@ -87,32 +94,24 @@ bool FloodingRouter::perhapsHandleUpgradedPacket(const meshtastic_MeshPacket *p) return false; } -void FloodingRouter::reprocessPacket(const meshtastic_MeshPacket *p) +bool FloodingRouter::reprocessPacket(const meshtastic_MeshPacket *p) { + if (p->which_payload_variant != meshtastic_MeshPacket_decoded_tag) { + auto decodedState = perhapsDecode(const_cast(p)); + if (decodedState != DecodeState::DECODE_SUCCESS && decodedState != DecodeState::DECODE_OPAQUE) + return false; + } + if (nodeDB) nodeDB->updateFrom(*p); #if !MESHTASTIC_EXCLUDE_TRACEROUTE - if (traceRouteModule && p->which_payload_variant != meshtastic_MeshPacket_decoded_tag) { - // If we got a packet that is not decoded, try to decode it so we can check for traceroute. - auto decodedState = perhapsDecode(const_cast(p)); - if (decodedState == DecodeState::DECODE_SUCCESS) { - // parsing was successful, print for debugging - printPacket("reprocessPacket(DUP)", p); - } else { - // Fatal decoding error, we can't do anything with this packet - LOG_WARN( - "FloodingRouter::reprocessPacket: Fatal decode error (state=%d, id=0x%08x, from=%u), can't check for traceroute", - static_cast(decodedState), p->id, getFrom(p)); - return; - } - } - if (traceRouteModule && p->which_payload_variant == meshtastic_MeshPacket_decoded_tag && p->decoded.portnum == meshtastic_PortNum_TRACEROUTE_APP) { traceRouteModule->processUpgradedPacket(*p); } #endif + return true; } bool FloodingRouter::roleAllowsCancelingDupe(const meshtastic_MeshPacket *p) diff --git a/src/mesh/FloodingRouter.h b/src/mesh/FloodingRouter.h index e8a2e9685fa..6bd5fca4166 100644 --- a/src/mesh/FloodingRouter.h +++ b/src/mesh/FloodingRouter.h @@ -64,7 +64,7 @@ class FloodingRouter : public Router bool perhapsHandleUpgradedPacket(const meshtastic_MeshPacket *p); /* Call when we receive a packet that needs some reprocessing, but afterwards should be filtered */ - void reprocessPacket(const meshtastic_MeshPacket *p); + bool reprocessPacket(const meshtastic_MeshPacket *p); // Return false for roles like ROUTER which should always rebroadcast even when we've heard another rebroadcast of // the same packet @@ -75,4 +75,4 @@ class FloodingRouter : public Router // Return true if we are a rebroadcaster bool isRebroadcaster(); -}; \ No newline at end of file +}; diff --git a/src/mesh/NextHopRouter.cpp b/src/mesh/NextHopRouter.cpp index f33de779cc0..3e7361f191e 100644 --- a/src/mesh/NextHopRouter.cpp +++ b/src/mesh/NextHopRouter.cpp @@ -11,6 +11,25 @@ NextHopRouter::NextHopRouter() {} +bool NextHopRouter::relayOpaquePacket(const meshtastic_MeshPacket *p) +{ + // Opaque traffic is never admitted to PacketHistory, NodeDB, modules, phone, MQTT, or ACK + // handling. Relay only from the immutable outer routing header and let hop exhaustion bound it. + const auto mode = config.device.rebroadcast_mode; + if (!iface || isToUs(p) || isFromUs(p) || p->id == 0 || p->hop_limit == 0 || !isRebroadcaster() || owner.is_licensed || + !IS_ONE_OF(mode, meshtastic_Config_DeviceConfig_RebroadcastMode_ALL, + meshtastic_Config_DeviceConfig_RebroadcastMode_ALL_SKIP_DECODING) || + (p->next_hop != NO_NEXT_HOP_PREFERENCE && p->next_hop != nodeDB->getLastByteOfNodeNum(getNodeNum()))) + return false; + + meshtastic_MeshPacket *relay = packetPool.allocCopy(*p); + if (!relay) + return false; + relay->hop_limit--; + relay->relay_node = nodeDB->getLastByteOfNodeNum(getNodeNum()); + return Router::send(relay) == ERRNO_OK; +} + PendingPacket::PendingPacket(meshtastic_MeshPacket *p, uint8_t numRetransmissions) { packet = p; @@ -65,16 +84,15 @@ bool NextHopRouter::shouldFilterReceived(const meshtastic_MeshPacket *p) LOG_INFO("Fallback to flooding from relay_node=0x%x", p->relay_node); // Check if it's still in the Tx queue, if not, we have to relay it again if (!findInTxQueue(p->from, p->id)) { - reprocessPacket(p); - perhapsRebroadcast(p); + if (reprocessPacket(p)) + perhapsRebroadcast(p); } } else { bool isRepeated = getHopsAway(*p) == 0; // If repeated and not in Tx queue anymore, try relaying again, or if we are the destination, send the ACK again if (isRepeated) { if (!findInTxQueue(p->from, p->id)) { - reprocessPacket(p); - if (!perhapsRebroadcast(p) && isToUs(p) && p->want_ack) { + if (reprocessPacket(p) && !perhapsRebroadcast(p) && isToUs(p) && p->want_ack) { sendAckNak(meshtastic_Routing_Error_NONE, getFrom(p), p->id, p->channel, 0); } } diff --git a/src/mesh/NextHopRouter.h b/src/mesh/NextHopRouter.h index 467d9ca68bc..d5e2a9621f9 100644 --- a/src/mesh/NextHopRouter.h +++ b/src/mesh/NextHopRouter.h @@ -137,6 +137,7 @@ class NextHopRouter : public FloodingRouter * @return true to abandon the packet */ virtual bool shouldFilterReceived(const meshtastic_MeshPacket *p) override; + bool relayOpaquePacket(const meshtastic_MeshPacket *p) override; /** * Look for packets we need to relay @@ -209,4 +210,4 @@ class NextHopRouter : public FloodingRouter /** Check if we should be rebroadcasting this packet if so, do so. * @return true if we did rebroadcast */ bool perhapsRebroadcast(const meshtastic_MeshPacket *p) override; -}; \ No newline at end of file +}; diff --git a/src/mesh/NodeDB.cpp b/src/mesh/NodeDB.cpp index e05f8b8f0a2..0abc2eff72a 100644 --- a/src/mesh/NodeDB.cpp +++ b/src/mesh/NodeDB.cpp @@ -1855,6 +1855,8 @@ bool NodeDB::enforceSatelliteCaps() // them if they do); otherwise tracker/sensor/tak_tracker are role-protected. static uint8_t warmProtectedCategory(const meshtastic_NodeInfoLite &n) { + if (nodeInfoLiteHasXeddsaSigned(&n)) + return static_cast(WarmProtected::XeddsaSigner); if (n.bitfield & (NODEINFO_BITFIELD_IS_FAVORITE_MASK | NODEINFO_BITFIELD_IS_IGNORED_MASK | NODEINFO_BITFIELD_IS_KEY_MANUALLY_VERIFIED_MASK)) return static_cast(WarmProtected::Flag); @@ -3719,6 +3721,18 @@ bool NodeDB::copyPublicKey(NodeNum n, meshtastic_NodeInfoLite_public_key_t &out) return false; } +bool NodeDB::hasSeenXeddsaSigner(NodeNum n) +{ + if (nodeInfoLiteHasXeddsaSigned(getMeshNode(n))) + return true; +#if WARM_NODE_COUNT > 0 + uint8_t role = 0, prot = 0; + return warmStore.lookupMeta(n, role, prot) && prot == static_cast(WarmProtected::XeddsaSigner); +#else + return false; +#endif +} + meshtastic_Config_DeviceConfig_Role NodeDB::getNodeRole(NodeNum n) { const meshtastic_NodeInfoLite *info = getMeshNode(n); @@ -3811,6 +3825,8 @@ meshtastic_NodeInfoLite *NodeDB::getOrCreateMeshNode(NodeNum n) lite->public_key.size = 32; memcpy(lite->public_key.bytes, warm.public_key, 32); } + if (warmProtOf(warm) == static_cast(WarmProtected::XeddsaSigner)) + nodeInfoLiteSetBit(lite, NODEINFO_BITFIELD_HAS_XEDDSA_SIGNED_MASK, true); LOG_MIGRATION("Rehydrated node 0x%08x from warm tier (key=%d)", n, lite->public_key.size == 32); } #endif diff --git a/src/mesh/NodeDB.h b/src/mesh/NodeDB.h index 8aa32dcc75a..34c4d33c489 100644 --- a/src/mesh/NodeDB.h +++ b/src/mesh/NodeDB.h @@ -357,6 +357,10 @@ class NodeDB /// tier. Returns false if we don't know a key for n. bool copyPublicKey(NodeNum n, meshtastic_NodeInfoLite_public_key_t &out); + /// Whether this node has produced a verified XEdDSA signature, including while its + /// identity is resident only in the warm tier. + bool hasSeenXeddsaSigner(NodeNum n); + /// Resolve a node's device role - hot store (with user) first, then the role /// cached in the warm tier, else CLIENT. Lets role-aware policy keep firing for /// nodes that have aged out of the hot store. diff --git a/src/mesh/Router.cpp b/src/mesh/Router.cpp index 67d724433f2..c9688240c2d 100644 --- a/src/mesh/Router.cpp +++ b/src/mesh/Router.cpp @@ -12,6 +12,8 @@ #include "mesh-pb-constants.h" #include "meshUtils.h" #include "modules/RoutingModule.h" +#include +#include #include #if HAS_TRAFFIC_MANAGEMENT #include "modules/TrafficManagementModule.h" @@ -67,6 +69,79 @@ Allocator &packetPool = staticPool; static uint8_t bytes[MAX_LORA_PAYLOAD_LEN + 1] __attribute__((__aligned__)); +struct RoutingAuthCache { + bool valid = false; + meshtastic_Config_SecurityConfig_PacketSignaturePolicy policy = + meshtastic_Config_SecurityConfig_PacketSignaturePolicy_PACKET_SIGNATURE_POLICY_BALANCED; + meshtastic_MeshPacket wire = meshtastic_MeshPacket_init_zero; + meshtastic_MeshPacket authenticated = meshtastic_MeshPacket_init_zero; +}; +static RoutingAuthCache routingAuthCache; +static concurrency::Lock *routingAuthCacheLock; +static uint32_t routingAuthEvaluations; + +static bool routingAuthCacheMatches(const meshtastic_MeshPacket &packet) +{ + if (!routingAuthCacheLock) + return false; + concurrency::LockGuard guard(routingAuthCacheLock); + if (!routingAuthCache.valid) + return false; + if (routingAuthCache.policy != config.security.packet_signature_policy || + memcmp(&routingAuthCache.wire, &packet, sizeof(packet)) != 0) { + routingAuthCache.valid = false; + return false; + } + return true; +} + +static void storeRoutingAuthCache(const meshtastic_MeshPacket &wire, const meshtastic_MeshPacket &authenticated) +{ + concurrency::LockGuard guard(routingAuthCacheLock); + routingAuthCache.wire = wire; + routingAuthCache.authenticated = authenticated; + routingAuthCache.policy = config.security.packet_signature_policy; + routingAuthCache.valid = true; +} + +static bool applyRoutingAuthCache(meshtastic_MeshPacket *packet) +{ + if (!routingAuthCacheLock) + return false; + concurrency::LockGuard guard(routingAuthCacheLock); + if (!routingAuthCache.valid || routingAuthCache.policy != config.security.packet_signature_policy || + memcmp(&routingAuthCache.wire, packet, sizeof(*packet)) != 0) { + routingAuthCache.valid = false; + return false; + } + *packet = routingAuthCache.authenticated; + routingAuthCache.valid = false; + return true; +} + +static void clearRoutingAuthCache() +{ + if (!routingAuthCacheLock) + return; + concurrency::LockGuard guard(routingAuthCacheLock); + routingAuthCache.valid = false; +} + +#ifdef PIO_UNIT_TESTING +uint32_t routingAuthEvaluationCount() +{ + return routingAuthEvaluations; +} +void resetRoutingAuthEvaluationCount() +{ + routingAuthEvaluations = 0; + if (routingAuthCacheLock) { + concurrency::LockGuard guard(routingAuthCacheLock); + routingAuthCache.valid = false; + } +} +#endif + /** * Constructor * @@ -85,6 +160,8 @@ Router::Router() : concurrency::OSThread("Router"), fromRadioQueue(MAX_RX_FROMRA // init Lockguard for crypt operations assert(!cryptLock); cryptLock = new concurrency::Lock(); + if (!routingAuthCacheLock) + routingAuthCacheLock = new concurrency::Lock(); } bool Router::shouldDecrementHopLimit(const meshtastic_MeshPacket *p) @@ -247,8 +324,10 @@ ErrorCode Router::sendLocal(meshtastic_MeshPacket *p, RxSource src) // No need to deliver externally if the destination is the local node if (isToUs(p)) { printPacket("Enqueued local", p); - enqueueReceivedMessage(p); - return ERRNO_OK; + // Preserve the trusted origin explicitly. Queueing used to erase src and make a local + // phone/module packet indistinguishable from remote already-decoded ingress. + handleReceived(p, src); + return ERRNO_SHOULD_RELEASE; } else if (!iface) { // We must be sending to remote nodes also, fail if no interface found abortSendAndNak(meshtastic_Routing_Error_NO_INTERFACE, p); @@ -452,18 +531,55 @@ void Router::sniffReceived(const meshtastic_MeshPacket *p, const meshtastic_Rout } #if !(MESHTASTIC_EXCLUDE_PKI) && !(MESHTASTIC_EXCLUDE_XEDDSA) +enum class NodeInfoBootstrapResult { NOT_APPLICABLE, VERIFIED, INVALID }; + +static NodeInfoBootstrapResult verifyFirstContactNodeInfo(meshtastic_MeshPacket *p) +{ + if (p->decoded.portnum != meshtastic_PortNum_NODEINFO_APP) + return NodeInfoBootstrapResult::NOT_APPLICABLE; + + meshtastic_User user = meshtastic_User_init_zero; + if (!pb_decode_from_bytes(p->decoded.payload.bytes, p->decoded.payload.size, &meshtastic_User_msg, &user) || + user.public_key.size != 32 || crc32Buffer(user.public_key.bytes, user.public_key.size) != p->from || + !crypto->xeddsa_verify(user.public_key.bytes, p->from, p->id, p->decoded.portnum, p->decoded.payload.bytes, + p->decoded.payload.size, p->decoded.xeddsa_signature.bytes)) { + return NodeInfoBootstrapResult::INVALID; + } + + meshtastic_NodeInfoLite *node = nodeDB->getOrCreateMeshNode(p->from); + if (!node) + return NodeInfoBootstrapResult::INVALID; + node->public_key.size = user.public_key.size; + memcpy(node->public_key.bytes, user.public_key.bytes, user.public_key.size); + nodeInfoLiteSetBit(node, NODEINFO_BITFIELD_HAS_XEDDSA_SIGNED_MASK, true); + p->xeddsa_signed = true; + LOG_DEBUG("Verified first-contact XEdDSA NodeInfo from 0x%08x", p->from); + return NodeInfoBootstrapResult::VERIFIED; +} + bool checkXeddsaReceivePolicy(meshtastic_MeshPacket *p, size_t encodedDataSize) { + const auto policy = config.security.packet_signature_policy; + const bool strict = policy == meshtastic_Config_SecurityConfig_PacketSignaturePolicy_PACKET_SIGNATURE_POLICY_STRICT; + const bool compatible = policy == meshtastic_Config_SecurityConfig_PacketSignaturePolicy_PACKET_SIGNATURE_POLICY_COMPATIBLE; + // Only a signature we verify below may mark this packet signed; never trust an inbound flag. p->xeddsa_signed = false; if (p->decoded.xeddsa_signature.size == XEDDSA_SIGNATURE_SIZE) { + meshtastic_NodeInfoLite_public_key_t senderKey = {0, {0}}; meshtastic_NodeInfoLite *node = nodeDB->getMeshNode(p->from); - if (node && node->public_key.size == 32) { + if (nodeDB->copyPublicKey(p->from, senderKey)) { p->xeddsa_signed = - crypto->xeddsa_verify(node->public_key.bytes, p->from, p->id, p->decoded.portnum, p->decoded.payload.bytes, + crypto->xeddsa_verify(senderKey.bytes, p->from, p->id, p->decoded.portnum, p->decoded.payload.bytes, p->decoded.payload.size, p->decoded.xeddsa_signature.bytes); if (p->xeddsa_signed) { // Learn this node as a signer, so a later unsigned signable broadcast from it is dropped + // A warm-tier key must be re-admitted before setting the signer bit; otherwise Balanced + // forgets downgrade protection as soon as the node is evicted from the hot store. + if (!node) + node = nodeDB->getOrCreateMeshNode(p->from); + if (!node) + return false; nodeInfoLiteSetBit(node, NODEINFO_BITFIELD_HAS_XEDDSA_SIGNED_MASK, true); LOG_DEBUG("Verified XEdDSA signature from 0x%08x", p->from); } else { @@ -471,7 +587,16 @@ bool checkXeddsaReceivePolicy(meshtastic_MeshPacket *p, size_t encodedDataSize) return false; } } else { + const auto bootstrap = verifyFirstContactNodeInfo(p); + if (bootstrap == NodeInfoBootstrapResult::INVALID) { + LOG_WARN("Invalid first-contact XEdDSA NodeInfo from 0x%08x, dropping", p->from); + return false; + } + if (bootstrap == NodeInfoBootstrapResult::VERIFIED) + return true; LOG_DEBUG("No public key for 0x%08x, cannot verify XEdDSA signature", p->from); + if (strict) + return false; } } else if (p->decoded.xeddsa_signature.size != 0) { // A signature field that is neither empty nor a full 64 bytes is malformed - honest @@ -482,15 +607,22 @@ bool checkXeddsaReceivePolicy(meshtastic_MeshPacket *p, size_t encodedDataSize) p->from); return false; } else { - // Truly unsigned (signature size 0) - only reject the class a signing node always signs: a - // non-PKI broadcast whose signed encoding would still fit the LoRa frame. encodedDataSize is + if (p->pki_encrypted) + return true; + if (strict) { + LOG_WARN("Dropping unsigned packet from 0x%08x in Strict signature mode", p->from); + return false; + } + if (compatible) + return true; + + // In Balanced, preserve legacy unsigned-unicast compatibility and only reject a signable + // unsigned broadcast from a known signer. encodedDataSize is // the size of the encoded Data exactly as the sender built it (or 0 to size p->decoded // canonically); with no signature field present it is the unsigned base, and adding // XEDDSA_SIGNATURE_FIELD_BYTES mirrors the sender-side signedDataFits() gate per packet, - // whatever fields the Data carried. Unicast/PKI packets and broadcasts too big to carry a - // signature are never signed, so they must not be hard-failed here even for a known signer. - const meshtastic_NodeInfoLite *node = nodeDB->getMeshNode(p->from); - if (node && nodeInfoLiteHasXeddsaSigned(node) && !p->pki_encrypted && isBroadcast(p->to)) { + // whatever fields the Data carried. Oversized broadcasts remain compatible. + if (nodeDB->hasSeenXeddsaSigner(p->from) && isBroadcast(p->to)) { if (encodedDataSize == 0 && !pb_get_encoded_size(&encodedDataSize, &meshtastic_Data_msg, &p->decoded)) return true; // can't size it; never drop on a sizing failure if (encodedDataSize + XEDDSA_SIGNATURE_FIELD_BYTES + MESHTASTIC_HEADER_LENGTH <= MAX_LORA_PAYLOAD_LEN) { @@ -503,6 +635,55 @@ bool checkXeddsaReceivePolicy(meshtastic_MeshPacket *p, size_t encodedDataSize) } #endif +RoutingAuthVerdict passesRoutingAuthGate(meshtastic_MeshPacket *p) +{ + // Routing still needs the original encrypted representation for byte-for-byte relay and for + // MQTT uplink. Authenticate a copy here; handleReceived() performs the normal in-place decode + // only after stateful routing filters have completed. + if (routingAuthCacheMatches(*p)) + return RoutingAuthVerdict::ACCEPT; + + meshtastic_MeshPacket wire = *p; + meshtastic_MeshPacket authCandidate = *p; + routingAuthEvaluations++; + if (authCandidate.which_payload_variant == meshtastic_MeshPacket_decoded_tag) { + // Already-decoded remote ingress (notably Portduino SimRadio) did not pass through a + // decryptor. Never trust serialized local authentication metadata on that boundary. + authCandidate.pki_encrypted = false; + authCandidate.public_key.size = 0; +#if !(MESHTASTIC_EXCLUDE_PKI) && !(MESHTASTIC_EXCLUDE_XEDDSA) + concurrency::LockGuard g(cryptLock); + if (!checkXeddsaReceivePolicy(&authCandidate)) { + LOG_WARN("Already-decoded packet rejected by signature policy before routing state update"); + return RoutingAuthVerdict::REJECT; + } +#endif + p->xeddsa_signed = authCandidate.xeddsa_signed; + wire = *p; + storeRoutingAuthCache(wire, authCandidate); + return RoutingAuthVerdict::ACCEPT; + } + const DecodeState state = perhapsDecode(&authCandidate); + if (state == DecodeState::DECODE_POLICY_REJECT) { + LOG_WARN("Packet rejected by signature policy before routing state update"); + return RoutingAuthVerdict::REJECT; + } + if (state == DecodeState::DECODE_FATAL) { + LOG_WARN("Fatal decode error before routing state update"); + return RoutingAuthVerdict::REJECT; + } + if (state == DecodeState::DECODE_FAILURE) { + LOG_WARN("Decryptable packet failed decoding/authentication before routing state update"); + return RoutingAuthVerdict::REJECT; + } + + // Only an explicit unknown-channel result remains eligible for opaque relay. + if (state == DecodeState::DECODE_OPAQUE) + return RoutingAuthVerdict::OPAQUE_RELAY_ONLY; + storeRoutingAuthCache(wire, authCandidate); + return RoutingAuthVerdict::ACCEPT; +} + DecodeState perhapsDecode(meshtastic_MeshPacket *p) { concurrency::LockGuard g(cryptLock); @@ -516,12 +697,18 @@ DecodeState perhapsDecode(meshtastic_MeshPacket *p) if (p->which_payload_variant == meshtastic_MeshPacket_decoded_tag) return DecodeState::DECODE_SUCCESS; // If packet was already decoded just return + // Authentication metadata is local-only. Re-establish it below only after successful PKI decryption. + p->pki_encrypted = false; + p->public_key.size = 0; + size_t rawSize = p->encrypted.size; if (rawSize > sizeof(bytes)) { LOG_ERROR("Packet too large to attempt decryption! (rawSize=%d > 256)", rawSize); return DecodeState::DECODE_FATAL; } bool decrypted = false; + bool pkiAttempted = false; + bool matchedChannel = false; ChannelIndex chIndex = 0; #if !(MESHTASTIC_EXCLUDE_PKI) // Attempt PKI decryption first. The sender's key may come from the hot @@ -531,6 +718,7 @@ DecodeState perhapsDecode(meshtastic_MeshPacket *p) if (p->channel == 0 && isToUs(p) && p->to > 0 && !isBroadcast(p->to) && nodeDB->copyPublicKey(p->from, fromKey) && nodeDB->getMeshNode(p->to) != nullptr && nodeDB->getMeshNode(p->to)->public_key.size > 0 && rawSize > MESHTASTIC_PKC_OVERHEAD) { + pkiAttempted = true; LOG_DEBUG("Attempt PKI decryption"); if (crypto->decryptCurve25519(p->from, fromKey, p->id, rawSize, p->encrypted.bytes, bytes)) { @@ -564,6 +752,7 @@ DecodeState perhapsDecode(meshtastic_MeshPacket *p) for (chIndex = 0; chIndex < channels.getNumChannels(); chIndex++) { // Try to use this hash/channel pair if (channels.decryptForHash(chIndex, p->channel)) { + matchedChannel = true; // we have to copy into a scratch buffer, because these bytes are a union with the decoded protobuf. Create a // fresh copy for each decrypt attempt. memcpy(bytes, p->encrypted.bytes, rawSize); @@ -605,7 +794,7 @@ DecodeState perhapsDecode(meshtastic_MeshPacket *p) // MESHTASTIC_PKC_OVERHEAD subtraction preserves that, and PKI packets are unicast so the // downgrade predicate ignores them anyway). if (!checkXeddsaReceivePolicy(p, rawSize)) - return DecodeState::DECODE_FAILURE; + return DecodeState::DECODE_POLICY_REJECT; #endif /* Not actually ever used. @@ -660,7 +849,7 @@ DecodeState perhapsDecode(meshtastic_MeshPacket *p) return DecodeState::DECODE_SUCCESS; } else { LOG_WARN("No suitable channel found for decoding, hash was 0x%x!", p->channel); - return DecodeState::DECODE_FAILURE; + return (matchedChannel || pkiAttempted) ? DecodeState::DECODE_FAILURE : DecodeState::DECODE_OPAQUE; } } @@ -851,8 +1040,6 @@ NodeNum Router::getNodeNum() void Router::handleReceived(meshtastic_MeshPacket *p, RxSource src) { bool skipHandle = false; - // Also, we should set the time from the ISR and it should have msec level resolution - p->rx_time = getValidTime(RTCQualityFromNet); // store the arrival timestamp for the phone // Store a copy of the encrypted packet for MQTT. // Local, not a class member: handleReceived re-enters itself when a module @@ -863,12 +1050,31 @@ void Router::handleReceived(meshtastic_MeshPacket *p, RxSource src) meshtastic_MeshPacket *p_encrypted = packetPool.allocCopy(*p); DEBUG_HEAP_AFTER("Router::handleReceived", p_encrypted); + // Consume the decoded/authenticated handoff after preserving the exact encrypted packet and + // before mutating any packet fields that participate in the exact cache match. + if (src == RX_SRC_RADIO) + applyRoutingAuthCache(p); + + // Also, we should set the time from the ISR and it should have msec level resolution. + // Keep the decoded working packet and encrypted MQTT copy on the same local arrival timestamp. + const uint32_t rxTime = getValidTime(RTCQualityFromNet); + p->rx_time = rxTime; + if (p_encrypted) + p_encrypted->rx_time = rxTime; + // Take those raw bytes and convert them back into a well structured protobuf we can understand auto decodedState = perhapsDecode(p); - if (decodedState == DecodeState::DECODE_FATAL) { + if (decodedState == DecodeState::DECODE_FATAL || decodedState == DecodeState::DECODE_POLICY_REJECT || + decodedState == DecodeState::DECODE_FAILURE) { // Fatal decoding error, we can't do anything with this packet - LOG_WARN("Fatal decode error, dropping packet"); - cancelSending(p->from, p->id); + LOG_WARN(decodedState == DecodeState::DECODE_POLICY_REJECT + ? "Packet rejected by signature policy" + : (decodedState == DecodeState::DECODE_FATAL ? "Fatal decode error, dropping packet" + : "Decryptable packet failed decoding, dropping packet")); + // A policy rejection is attacker-controlled input and must not cancel a valid pending + // transmission with the same (from, id). Preserve the pre-existing fatal-decode behavior. + if (decodedState == DecodeState::DECODE_FATAL) + cancelSending(p->from, p->id); skipHandle = true; } else if (decodedState == DecodeState::DECODE_SUCCESS) { // parsing was successful, queue for our recipient @@ -932,7 +1138,7 @@ void Router::handleReceived(meshtastic_MeshPacket *p, RxSource src) } else { // Mark as pki_encrypted if it is not yet decoded and MQTT encryption is also enabled, hash matches and it's a DM not // to us (because we would be able to decrypt it) - if (decodedState == DecodeState::DECODE_FAILURE && moduleConfig.mqtt.encryption_enabled && p->channel == 0x00 && + if (decodedState == DecodeState::DECODE_OPAQUE && moduleConfig.mqtt.encryption_enabled && p->channel == 0x00 && !isBroadcast(p->to) && !isToUs(p)) p_encrypted->pki_encrypted = true; // After potentially altering it, publish received message to MQTT if we're not the original transmitter of the packet @@ -981,6 +1187,7 @@ void Router::perhapsHandleReceived(meshtastic_MeshPacket *p) #endif // assert(radioConfig.has_preferences); if (is_in_repeated(config.lora.ignore_incoming, p->from)) { + clearRoutingAuthCache(); LOG_DEBUG("Ignore msg, 0x%08x is in our ignore list", p->from); packetPool.release(p); return; @@ -988,30 +1195,49 @@ void Router::perhapsHandleReceived(meshtastic_MeshPacket *p) meshtastic_NodeInfoLite const *node = nodeDB->getMeshNode(p->from); if (nodeInfoLiteIsIgnored(node)) { + clearRoutingAuthCache(); LOG_DEBUG("Ignore msg, 0x%08x is ignored", p->from); packetPool.release(p); return; } if (p->from == NODENUM_BROADCAST) { + clearRoutingAuthCache(); LOG_DEBUG("Ignore msg from broadcast address"); packetPool.release(p); return; } if (config.lora.ignore_mqtt && p->via_mqtt) { + clearRoutingAuthCache(); LOG_DEBUG("Msg came in via MQTT from 0x%08x", p->from); packetPool.release(p); return; } if (shouldDropPacketForPreHop(*p)) { + clearRoutingAuthCache(); logHopStartDrop(*p, "pre-hop drop"); packetPool.release(p); return; } + // Decrypt and authenticate before Reliable/Flooding/NextHop filters can update retry + // timers, packet history, implicit ACK state, cancellation, or relay queues. A packet for + // an unknown channel passes as opaque traffic and retains the existing relay behavior. + const auto authVerdict = passesRoutingAuthGate(p); + if (authVerdict == RoutingAuthVerdict::REJECT) { + packetPool.release(p); + return; + } + if (authVerdict == RoutingAuthVerdict::OPAQUE_RELAY_ONLY) { + relayOpaquePacket(p); + packetPool.release(p); + return; + } + if (shouldFilterReceived(p)) { + clearRoutingAuthCache(); LOG_DEBUG("Incoming msg was filtered from 0x%08x", p->from); packetPool.release(p); return; diff --git a/src/mesh/Router.h b/src/mesh/Router.h index 954b88f2410..5711ab4e590 100644 --- a/src/mesh/Router.h +++ b/src/mesh/Router.h @@ -113,6 +113,9 @@ class Router : protected concurrency::OSThread, protected PacketHistory */ virtual bool shouldFilterReceived(const meshtastic_MeshPacket *p) { return false; } + /** Relay an opaque packet without admitting it to local routing/history state. */ + virtual bool relayOpaquePacket(const meshtastic_MeshPacket *) { return false; } + /** * Determine if hop_limit should be decremented for a relay operation. * Returns false (preserve hop_limit) only if all conditions are met: @@ -162,7 +165,8 @@ class Router : protected concurrency::OSThread, protected PacketHistory void abortSendAndNak(meshtastic_Routing_Error err, meshtastic_MeshPacket *p); }; -enum DecodeState { DECODE_SUCCESS, DECODE_FAILURE, DECODE_FATAL }; +enum DecodeState { DECODE_SUCCESS, DECODE_FAILURE, DECODE_OPAQUE, DECODE_FATAL, DECODE_POLICY_REJECT }; +enum class RoutingAuthVerdict { ACCEPT, OPAQUE_RELAY_ONLY, REJECT }; /** FIXME - move this into a mesh packet class * Remove any encryption and decode the protobufs inside this packet (if necessary). @@ -171,17 +175,26 @@ enum DecodeState { DECODE_SUCCESS, DECODE_FAILURE, DECODE_FATAL }; */ DecodeState perhapsDecode(meshtastic_MeshPacket *p); +/** Apply the receive authentication policy before routing state is mutated. + * Decryptable packets must pass their configured authenticity policy. Packets for unknown channels + * remain eligible for opaque relay, while fatal and policy-rejected packets are filtered. + */ +RoutingAuthVerdict passesRoutingAuthGate(meshtastic_MeshPacket *p); +#ifdef PIO_UNIT_TESTING +uint32_t routingAuthEvaluationCount(); +void resetRoutingAuthEvaluationCount(); +#endif + /** Return 0 for success or a Routing_Error code for failure */ meshtastic_Routing_Error perhapsEncode(meshtastic_MeshPacket *p); #if !(MESHTASTIC_EXCLUDE_PKI) && !(MESHTASTIC_EXCLUDE_XEDDSA) -/** XEdDSA receive-side signature policy. When the packet carries a 64-byte signature *and* the - * sender's public key is known, verify it: on success learn the sender's signer bit, on failure - * drop. If the key is unknown the signature is left unverified and the packet passes. A signature - * of any other non-zero length is treated as malformed and dropped. For unsigned packets, enforce - * downgrade protection: drop a non-PKI broadcast from a known signer whose signed encoding would - * still fit a LoRa frame (unicast, PKI, and oversized broadcasts always pass). +/** XEdDSA receive-side signature policy. Valid signatures are verified against a cached key or an + * identity-bound key in first-contact NodeInfo. Invalid and malformed signatures always fail. + * Compatible accepts unsigned traffic, Balanced rejects signable unsigned broadcasts from known + * signers, and Strict requires a verified existing signature or successful PKI authentication + * for all decoded traffic. * * encodedDataSize is the wire size of the encoded Data as the sender built it; pass 0 to size * p->decoded canonically instead (for already-decoded ingress such as plaintext-MQTT downlink, diff --git a/src/mesh/WarmNodeStore.h b/src/mesh/WarmNodeStore.h index 4cacbbc0ca3..eda6e777a55 100644 --- a/src/mesh/WarmNodeStore.h +++ b/src/mesh/WarmNodeStore.h @@ -58,7 +58,7 @@ static constexpr uint32_t WARM_PROT_SHIFT = 4; // bits [ static constexpr uint32_t WARM_PROT_MASK = 0x03u; // Protected category cached alongside role so consumers needn't re-derive the mapping. -enum class WarmProtected : uint8_t { None = 0, Role = 1, Flag = 2 }; +enum class WarmProtected : uint8_t { None = 0, Role = 1, Flag = 2, XeddsaSigner = 3 }; inline uint32_t warmPackLastHeard(uint32_t lastHeard, uint8_t role, uint8_t prot) { diff --git a/src/mesh/udp/UdpMulticastHandler.h b/src/mesh/udp/UdpMulticastHandler.h index 625dc008ac7..93f5dd6ac78 100644 --- a/src/mesh/udp/UdpMulticastHandler.h +++ b/src/mesh/udp/UdpMulticastHandler.h @@ -80,9 +80,9 @@ class UdpMulticastHandler final return; } mp.transport_mechanism = meshtastic_MeshPacket_TransportMechanism_TRANSPORT_MULTICAST_UDP; - // Preserve the whole MeshPacket as received: while payload_variant is encrypted, `channel` is a hash (and is 0 for - // PKI DMs), so it must be copied verbatim for the router to attempt PKI/channel decryption. Keep - // pki_encrypted/public_key too so downstream auth/metadata can reflect PKI usage correctly. + // Authentication metadata is local-only; Router re-establishes it after successful PKI decryption. + mp.pki_encrypted = false; + mp.public_key.size = 0; UniquePacketPoolPacket p = packetPool.allocUniqueCopy(mp); // Unset received SNR/RSSI p->rx_snr = 0; diff --git a/src/modules/AdminModule.cpp b/src/modules/AdminModule.cpp index a3ee10b121a..4dbe220340f 100644 --- a/src/modules/AdminModule.cpp +++ b/src/modules/AdminModule.cpp @@ -1061,6 +1061,16 @@ void AdminModule::handleSetConfig(const meshtastic_Config &c, bool fromOthers) incoming.private_key = config.security.private_key; incoming.public_key = config.security.public_key; } +#if MESHTASTIC_EXCLUDE_PKI || MESHTASTIC_EXCLUDE_XEDDSA + if (incoming.packet_signature_policy != + meshtastic_Config_SecurityConfig_PacketSignaturePolicy_PACKET_SIGNATURE_POLICY_BALANCED) { + incoming.packet_signature_policy = + meshtastic_Config_SecurityConfig_PacketSignaturePolicy_PACKET_SIGNATURE_POLICY_BALANCED; + const char *warning = "Packet authenticity policy is unavailable on this firmware build"; + LOG_WARN(warning); + sendWarning(warning); + } +#endif config.security = incoming; #if !(MESHTASTIC_EXCLUDE_PKI_KEYGEN) && !(MESHTASTIC_EXCLUDE_PKI) // First provisioning (no key) generates one; a private key supplied without its public key derives it. diff --git a/src/modules/NodeInfoModule.cpp b/src/modules/NodeInfoModule.cpp index c86c54aff34..66b4c6c5732 100644 --- a/src/modules/NodeInfoModule.cpp +++ b/src/modules/NodeInfoModule.cpp @@ -49,15 +49,6 @@ bool NodeInfoModule::handleReceivedProtobuf(const meshtastic_MeshPacket &mp, mes LOG_WARN("Invalid nodeInfo detected, is_licensed mismatch!"); return true; } - NodeNum sourceNum = getFrom(&mp); - const meshtastic_NodeInfoLite *node = nodeDB->getMeshNode(sourceNum); - // Broadcasts only: senders never sign unicast NodeInfo, so dropping it would break exchanges - // with signer nodes. Backstops ingress that skips Router's downgrade drop (e.g. decoded MQTT). - if (node && nodeInfoLiteHasXeddsaSigned(node) && !mp.xeddsa_signed && isBroadcast(mp.to)) { - LOG_WARN("Dropping unsigned NodeInfo broadcast from node 0x%08x that previously signed", sourceNum); - return true; - } - // Coerce user.id to be derived from the node number snprintf(p.id, sizeof(p.id), "!%08x", getFrom(&mp)); @@ -232,4 +223,4 @@ int32_t NodeInfoModule::runOnce() sendOurNodeInfo(NODENUM_BROADCAST, requestReplies); // Send our info (don't request replies) } return Default::getConfiguredOrDefaultMs(config.device.node_info_broadcast_secs, default_node_info_broadcast_secs); -} \ No newline at end of file +} diff --git a/src/mqtt/MQTT.cpp b/src/mqtt/MQTT.cpp index b6f0ce24d5b..7051285e2e7 100644 --- a/src/mqtt/MQTT.cpp +++ b/src/mqtt/MQTT.cpp @@ -117,7 +117,8 @@ inline void onReceiveProto(char *topic, byte *payload, size_t length) p->hop_limit = e.packet->hop_limit; p->hop_start = e.packet->hop_start; p->want_ack = e.packet->want_ack; - p->via_mqtt = true; // Mark that the packet was received via MQTT + p->via_mqtt = true; // Mark that the packet was received via MQTT + p->pki_encrypted = false; // Only local AES-CCM decryption may establish PKI authentication. p->transport_mechanism = meshtastic_MeshPacket_TransportMechanism_TRANSPORT_MQTT; p->which_payload_variant = e.packet->which_payload_variant; memcpy(&p->decoded, &e.packet->decoded, std::max(sizeof(p->decoded), sizeof(p->encrypted))); @@ -139,12 +140,9 @@ inline void onReceiveProto(char *topic, byte *payload, size_t length) // impersonate a signing node with unsigned broadcasts. Hold cryptLock like the RF path // (perhapsDecode) does - checkXeddsaReceivePolicy -> xeddsa_verify mutates shared // CryptoEngine cache state, and MQTT ingress can run on a different task. - { - concurrency::LockGuard g(cryptLock); - if (!checkXeddsaReceivePolicy(p.get())) { - LOG_INFO("Ignore decoded message failing XEdDSA policy"); - return; - } + if (passesRoutingAuthGate(p.get()) != RoutingAuthVerdict::ACCEPT) { + LOG_INFO("Ignore decoded message failing XEdDSA policy"); + return; } #endif } @@ -157,8 +155,7 @@ inline void onReceiveProto(char *topic, byte *payload, size_t length) // likely they discovered each other via a channel we have downlink enabled for if (isToUs(p.get()) || (nodeInfoLiteHasUser(tx) && nodeInfoLiteHasUser(rx))) router->enqueueReceivedMessage(p.release()); - } else if (router && - perhapsDecode(p.get()) == DecodeState::DECODE_SUCCESS) // ignore messages if we don't have the channel key + } else if (router && passesRoutingAuthGate(p.get()) == RoutingAuthVerdict::ACCEPT) router->enqueueReceivedMessage(p.release()); } diff --git a/test/test_mqtt/MQTT.cpp b/test/test_mqtt/MQTT.cpp index 3716e0a206f..270e8abe31a 100644 --- a/test/test_mqtt/MQTT.cpp +++ b/test/test_mqtt/MQTT.cpp @@ -334,6 +334,7 @@ const meshtastic_MeshPacket encrypted = { // Initialize mocks and configuration before running each test. void setUp(void) { + config = meshtastic_LocalConfig_init_zero; moduleConfig.mqtt = meshtastic_ModuleConfig_MQTTConfig{.enabled = true, .map_reporting_enabled = true, .has_map_report_settings = true}; moduleConfig.mqtt.map_report_settings = meshtastic_ModuleConfig_MapReportSettings{ @@ -596,6 +597,8 @@ void test_receiveWithoutChannelDownlink(void) // Test receiving an encrypted MeshPacket on the PKI topic. void test_receiveEncryptedPKITopicToUs(void) { + config.security.packet_signature_policy = + meshtastic_Config_SecurityConfig_PacketSignaturePolicy_PACKET_SIGNATURE_POLICY_STRICT; meshtastic_MeshPacket e = encrypted; e.to = myNodeInfo.my_node_num; @@ -687,6 +690,8 @@ static meshtastic_MeshPacket makeDecodedBroadcast() // signing node (audit F3). void test_receiveDropsUnsignedBroadcastFromSigner(void) { + config.security.packet_signature_policy = + meshtastic_Config_SecurityConfig_PacketSignaturePolicy_PACKET_SIGNATURE_POLICY_BALANCED; mockNodeDB->emptyNode.bitfield |= NODEINFO_BITFIELD_HAS_XEDDSA_SIGNED_MASK; const meshtastic_MeshPacket p = makeDecodedBroadcast(); @@ -698,6 +703,8 @@ void test_receiveDropsUnsignedBroadcastFromSigner(void) // The same unsigned broadcast from a node never seen signing is accepted. void test_receiveAcceptsUnsignedBroadcastFromNonSigner(void) { + config.security.packet_signature_policy = + meshtastic_Config_SecurityConfig_PacketSignaturePolicy_PACKET_SIGNATURE_POLICY_BALANCED; const meshtastic_MeshPacket p = makeDecodedBroadcast(); unitTest->publish(&p); @@ -729,6 +736,8 @@ void test_receiveVerifiesSignedDecodedDownlink(void) // A decoded downlink carrying a signature that fails verification is dropped. void test_receiveDropsBadSignatureOnDecodedDownlink(void) { + config.security.packet_signature_policy = + meshtastic_Config_SecurityConfig_PacketSignaturePolicy_PACKET_SIGNATURE_POLICY_COMPATIBLE; uint8_t pub[32], priv[32]; crypto->generateKeyPair(pub, priv); mockNodeDB->emptyNode.public_key.size = 32; @@ -744,6 +753,53 @@ void test_receiveDropsBadSignatureOnDecodedDownlink(void) TEST_ASSERT_TRUE(mockRouter->packets_.empty()); } + +void test_receiveCompatibleAcceptsUnsignedBroadcastFromSigner(void) +{ + config.security.packet_signature_policy = + meshtastic_Config_SecurityConfig_PacketSignaturePolicy_PACKET_SIGNATURE_POLICY_COMPATIBLE; + mockNodeDB->emptyNode.bitfield |= NODEINFO_BITFIELD_HAS_XEDDSA_SIGNED_MASK; + + const meshtastic_MeshPacket p = makeDecodedBroadcast(); + unitTest->publish(&p); + + TEST_ASSERT_EQUAL(1, mockRouter->packets_.size()); +} + +void test_receiveStrictDropsUnsignedPortnumsAndUnicast(void) +{ + config.security.packet_signature_policy = + meshtastic_Config_SecurityConfig_PacketSignaturePolicy_PACKET_SIGNATURE_POLICY_STRICT; + const meshtastic_PortNum ports[] = { + meshtastic_PortNum_TEXT_MESSAGE_APP, meshtastic_PortNum_POSITION_APP, meshtastic_PortNum_TELEMETRY_APP, + meshtastic_PortNum_NODEINFO_APP, meshtastic_PortNum_WAYPOINT_APP, + }; + for (const auto port : ports) { + meshtastic_MeshPacket p = makeDecodedBroadcast(); + p.decoded.portnum = port; + unitTest->publish(&p); + } + + meshtastic_MeshPacket unicast = makeDecodedBroadcast(); + unicast.to = myNodeInfo.my_node_num; + unicast.decoded.portnum = meshtastic_PortNum_POSITION_APP; + unitTest->publish(&unicast); + + TEST_ASSERT_TRUE(mockRouter->packets_.empty()); +} + +// A plaintext broker assertion is not evidence that AES-CCM authentication succeeded locally. +void test_receiveStrictDoesNotTrustDecodedPkiFlag(void) +{ + config.security.packet_signature_policy = + meshtastic_Config_SecurityConfig_PacketSignaturePolicy_PACKET_SIGNATURE_POLICY_STRICT; + meshtastic_MeshPacket p = makeDecodedBroadcast(); + p.to = myNodeInfo.my_node_num; + p.pki_encrypted = true; + unitTest->publish(&p); + + TEST_ASSERT_TRUE(mockRouter->packets_.empty()); +} #endif // !(MESHTASTIC_EXCLUDE_PKI) && !(MESHTASTIC_EXCLUDE_XEDDSA) // Only the same fields that are transmitted over LoRa should be set in MQTT messages. @@ -1063,6 +1119,9 @@ void setup() RUN_TEST(test_receiveAcceptsUnsignedBroadcastFromNonSigner); RUN_TEST(test_receiveVerifiesSignedDecodedDownlink); RUN_TEST(test_receiveDropsBadSignatureOnDecodedDownlink); + RUN_TEST(test_receiveCompatibleAcceptsUnsignedBroadcastFromSigner); + RUN_TEST(test_receiveStrictDropsUnsignedPortnumsAndUnicast); + RUN_TEST(test_receiveStrictDoesNotTrustDecodedPkiFlag); #endif RUN_TEST(test_receiveIgnoresUnexpectedFields); RUN_TEST(test_receiveIgnoresInvalidHopLimit); diff --git a/test/test_packet_signing/test_main.cpp b/test/test_packet_signing/test_main.cpp index 707d716195b..81465789179 100644 --- a/test/test_packet_signing/test_main.cpp +++ b/test/test_packet_signing/test_main.cpp @@ -3,11 +3,11 @@ // // The decision logic under test lives in Router.cpp free functions. Groups A/B drive a real // encode -> decode round-trip through the default channel (perhapsEncode/perhapsDecode, black-box, -// no production changes); Groups C-E exercise the policy helpers directly. +// no production changes); later groups exercise routing order and policy helpers directly. // // Group A receive-side accept/reject matrix (verify, downgrade protection, signer-bit learning) // Group B send-side signing policy (which outgoing packets perhapsEncode signs) -// Group C NodeInfoModule's broadcast-only "drop unsigned NodeInfo from a known signer" rule +// Group C routing pipeline ordering (authenticate before duplicate/retry/relay state) // Group D encoding invariants the routing gates depend on // Group E decoded-ingress policy (checkXeddsaReceivePolicy, the plaintext-MQTT trust boundary) @@ -21,9 +21,15 @@ #include "mesh/Channels.h" #include "mesh/CryptoEngine.h" +#include "mesh/MeshRadio.h" +#include "mesh/MeshService.h" #include "mesh/NodeDB.h" +#include "mesh/ReliableRouter.h" #include "mesh/Router.h" -#include "modules/NodeInfoModule.h" +#include "mesh/SinglePortModule.h" +#include "modules/RoutingModule.h" +#include "mqtt/MQTT.h" +#include #include #include #include @@ -86,6 +92,106 @@ class MockNodeDB : public NodeDB static MockNodeDB *mockNodeDB = nullptr; +class AuthPipelineRadio : public RadioInterface +{ + public: + ErrorCode send(meshtastic_MeshPacket *p) override + { + sendCalls++; + packetPool.release(p); + return ERRNO_OK; + } + bool cancelSending(NodeNum, PacketId) override + { + cancelCalls++; + return true; + } + bool findInTxQueue(NodeNum, PacketId) override + { + findCalls++; + return false; + } + bool removePendingTXPacket(NodeNum, PacketId, uint32_t) override + { + removeCalls++; + return true; + } + uint32_t getPacketTime(uint32_t, bool = false) override { return 7; } + void reset() { sendCalls = cancelCalls = findCalls = removeCalls = 0; } + + uint32_t sendCalls = 0; + uint32_t cancelCalls = 0; + uint32_t findCalls = 0; + uint32_t removeCalls = 0; +}; + +class AuthPipelineRouter : public ReliableRouter +{ + public: + bool filter(meshtastic_MeshPacket *p) { return ReliableRouter::shouldFilterReceived(p); } + bool historyContains(const meshtastic_MeshPacket *p) { return wasSeenRecently(p, false); } + void remember(const meshtastic_MeshPacket *p) { wasSeenRecently(p, true); } + void forgetRelayer(uint8_t relay, PacketId id, NodeNum from) { removeRelayer(relay, id, from); } + bool handleUpgrade(meshtastic_MeshPacket *p) { return perhapsHandleUpgradedPacket(p); } + void addPending(const meshtastic_MeshPacket &p, uint32_t nextTx) + { + auto *copy = packetPool.allocCopy(p); + TEST_ASSERT_NOT_NULL(copy); + const GlobalPacketId key(copy); + pending.emplace(key, PendingPacket(copy, NUM_INTERMEDIATE_RETX)); + pending.at(key).nextTxMsec = nextTx; + } + uint32_t pendingNextTx(NodeNum from, PacketId id) + { + PendingPacket *entry = findPendingPacket(from, id); + return entry ? entry->nextTxMsec : 0; + } + size_t pendingCount() const { return pending.size(); } + void clearPending() + { + for (auto &entry : pending) + packetPool.release(entry.second.packet); + pending.clear(); + } +}; + +class AuthPipelineRoutingModule : public RoutingModule +{ + public: + void sendAckNak(meshtastic_Routing_Error, NodeNum, PacketId, ChannelIndex, uint8_t = 0, bool = false) override { ackCalls++; } + uint32_t ackCalls = 0; +}; + +class AuthPipelineModule : public SinglePortModule +{ + public: + AuthPipelineModule() : SinglePortModule("authPipeline", meshtastic_PortNum_POSITION_APP) {} + ProcessMessage handleReceived(const meshtastic_MeshPacket &) override + { + calls++; + return ProcessMessage::CONTINUE; + } + uint32_t calls = 0; +}; + +class AuthPipelineMqtt : public MQTT +{ + public: + int queueSize() { return mqttQueue.numUsed(); } + void clearQueue() + { + while (QueueEntry *entry = mqttQueue.dequeuePtr(0)) + delete entry; + } +}; + +static AuthPipelineRouter *pipelineRouter = nullptr; +static AuthPipelineRadio *pipelineRadio = nullptr; +static AuthPipelineRoutingModule *pipelineRouting = nullptr; +static AuthPipelineModule *pipelineModule = nullptr; +static AuthPipelineMqtt *pipelineMqtt = nullptr; +static MeshService *pipelineService = nullptr; + // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- @@ -126,11 +232,48 @@ static DecodeState roundTrip(meshtastic_MeshPacket *p) return perhapsDecode(p); } +static meshtastic_MeshPacket channelEncode(meshtastic_MeshPacket p) +{ + uint8_t encoded[MAX_LORA_PAYLOAD_LEN + 1] = {}; + const size_t encodedSize = pb_encode_to_bytes(encoded, sizeof(encoded), &meshtastic_Data_msg, &p.decoded); + TEST_ASSERT_GREATER_THAN(0, encodedSize); + const int16_t hash = channels.setActiveByIndex(p.channel); + TEST_ASSERT_GREATER_OR_EQUAL(0, hash); + crypto->encryptPacket(p.from, p.id, encodedSize, encoded); + memcpy(p.encrypted.bytes, encoded, encodedSize); + p.encrypted.size = encodedSize; + p.channel = hash; + p.which_payload_variant = meshtastic_MeshPacket_encrypted_tag; + return p; +} + +static meshtastic_MeshPacket makeSignedWirePacket(NodeNum from, NodeNum to, PacketId id, uint8_t hopLimit = 1, + uint8_t hopStart = 2, uint8_t nextHop = NO_NEXT_HOP_PREFERENCE, + uint8_t relayNode = 0x33, bool valid = true) +{ + meshtastic_MeshPacket p = makeDecoded(from, to, meshtastic_PortNum_POSITION_APP, SMALL_PAYLOAD); + p.id = id; + p.hop_limit = hopLimit; + p.hop_start = hopStart; + p.next_hop = nextHop; + p.relay_node = relayNode; + p.transport_mechanism = meshtastic_MeshPacket_TransportMechanism_TRANSPORT_LORA; + signWithCurrentKey(&p); + if (!valid) + p.decoded.xeddsa_signature.bytes[0] ^= 0x80; + return channelEncode(p); +} + static bool remoteSignerBit() { return nodeInfoLiteHasXeddsaSigned(mockNodeDB->getMeshNode(REMOTE_NODE)); } +static void setPolicy(meshtastic_Config_SecurityConfig_PacketSignaturePolicy policy) +{ + config.security.packet_signature_policy = policy; +} + // Size a Data message exactly as the wire encoder would. static size_t encodedDataSize(const meshtastic_Data *d) { @@ -158,17 +301,32 @@ void setUp(void) // re-enables the PKI encrypt path and fails the unicast tests on hosts with leftover prefs. mockNodeDB = new MockNodeDB(); mockNodeDB->clearTestNodes(); +#if WARM_NODE_COUNT > 0 + mockNodeDB->warmStore.clear(); +#endif nodeDB = mockNodeDB; // Clean global config/owner AFTER the ctor; zeroed config => rebroadcast ALL (no KNOWN_ONLY // drop) and security.private_key.size == 0 (PKI encrypt path skipped => simple channel crypto). config = meshtastic_LocalConfig_init_zero; + moduleConfig = meshtastic_LocalModuleConfig_init_zero; owner = meshtastic_User_init_zero; myNodeInfo.my_node_num = LOCAL_NODE; // drives isFromUs()/getFrom()/isToUs() // Working primary channel with the default PSK so encrypt/decrypt round-trips. channels.initDefaults(); channels.onConfigChanged(); + + pipelineRouter->clearPending(); + pipelineRouter->rxDupe = 0; + pipelineRouter->txRelayCanceled = 0; + pipelineRadio->reset(); + pipelineRouting->ackCalls = 0; + pipelineModule->calls = 0; + pipelineMqtt->clearQueue(); + while (meshtastic_MeshPacket *queued = pipelineService->getForPhone()) + packetPool.release(queued); + resetRoutingAuthEvaluationCount(); } void tearDown(void) @@ -185,6 +343,7 @@ void tearDown(void) // A1: valid signature from a node whose key we know -> accepted, marked signed, signer bit learned. void test_A1_valid_signature_accepted_and_learns_signer(void) { + setPolicy(meshtastic_Config_SecurityConfig_PacketSignaturePolicy_PACKET_SIGNATURE_POLICY_STRICT); uint8_t pub[32], priv[32]; crypto->generateKeyPair(pub, priv); // engine now holds REMOTE's key mockNodeDB->addNode(REMOTE_NODE); @@ -203,6 +362,7 @@ void test_A1_valid_signature_accepted_and_learns_signer(void) // A2: a tampered signature from a known key -> dropped. void test_A2_bad_signature_dropped(void) { + setPolicy(meshtastic_Config_SecurityConfig_PacketSignaturePolicy_PACKET_SIGNATURE_POLICY_COMPATIBLE); uint8_t pub[32], priv[32]; crypto->generateKeyPair(pub, priv); mockNodeDB->addNode(REMOTE_NODE); @@ -212,12 +372,13 @@ void test_A2_bad_signature_dropped(void) signWithCurrentKey(&p); p.decoded.xeddsa_signature.bytes[0] ^= 0xFF; // corrupt the signature - TEST_ASSERT_EQUAL(DECODE_FAILURE, roundTrip(&p)); + TEST_ASSERT_EQUAL(DECODE_POLICY_REJECT, roundTrip(&p)); } // A3: signed packet but we have no key for the sender -> accepted unverified, signer bit NOT set. void test_A3_signed_no_pubkey_accepted_unverified(void) { + setPolicy(meshtastic_Config_SecurityConfig_PacketSignaturePolicy_PACKET_SIGNATURE_POLICY_BALANCED); uint8_t pub[32], priv[32]; crypto->generateKeyPair(pub, priv); mockNodeDB->addNode(REMOTE_NODE); // node exists, but no public key stored @@ -239,7 +400,7 @@ void test_A4_downgrade_unsigned_broadcast_from_signer_dropped(void) meshtastic_MeshPacket p = makeDecoded(REMOTE_NODE, NODENUM_BROADCAST, meshtastic_PortNum_TEXT_MESSAGE_APP, SMALL_PAYLOAD); // from != us, so perhapsEncode leaves it unsigned. - TEST_ASSERT_EQUAL(DECODE_FAILURE, roundTrip(&p)); + TEST_ASSERT_EQUAL(DECODE_POLICY_REJECT, roundTrip(&p)); } // A5: no prior knowledge - unsigned small broadcast from a non-signer -> accepted. @@ -321,9 +482,192 @@ void test_A9_unsigned_boundary_broadcast_from_signer_still_dropped(void) TEST_ASSERT_EQUAL_MESSAGE(MAX_LORA_PAYLOAD_LEN, encodedDataSize(&signedCopy) + MESHTASTIC_HEADER_LENGTH, "payload no longer sits exactly on the fit boundary - retune it"); - TEST_ASSERT_EQUAL(DECODE_FAILURE, roundTrip(&p)); + TEST_ASSERT_EQUAL(DECODE_POLICY_REJECT, roundTrip(&p)); +} + +void test_A10_compatible_accepts_unsigned_broadcast_from_signer(void) +{ + setPolicy(meshtastic_Config_SecurityConfig_PacketSignaturePolicy_PACKET_SIGNATURE_POLICY_COMPATIBLE); + mockNodeDB->addNode(REMOTE_NODE); + mockNodeDB->setSignerBit(REMOTE_NODE, true); + + meshtastic_MeshPacket p = makeDecoded(REMOTE_NODE, NODENUM_BROADCAST, meshtastic_PortNum_POSITION_APP, SMALL_PAYLOAD); + TEST_ASSERT_EQUAL(DECODE_SUCCESS, roundTrip(&p)); +} + +void test_A11_strict_rejects_unsigned_all_portnums_destinations_and_sizes(void) +{ + setPolicy(meshtastic_Config_SecurityConfig_PacketSignaturePolicy_PACKET_SIGNATURE_POLICY_STRICT); + const meshtastic_PortNum ports[] = { + meshtastic_PortNum_TEXT_MESSAGE_APP, meshtastic_PortNum_POSITION_APP, meshtastic_PortNum_TELEMETRY_APP, + meshtastic_PortNum_NODEINFO_APP, meshtastic_PortNum_WAYPOINT_APP, + }; + for (const auto port : ports) { + meshtastic_MeshPacket p = makeDecoded(REMOTE_NODE, NODENUM_BROADCAST, port, SMALL_PAYLOAD); + TEST_ASSERT_EQUAL(DECODE_POLICY_REJECT, roundTrip(&p)); + } + + meshtastic_MeshPacket unicast = makeDecoded(REMOTE_NODE, LOCAL_NODE, meshtastic_PortNum_POSITION_APP, SMALL_PAYLOAD); + TEST_ASSERT_EQUAL(DECODE_POLICY_REJECT, roundTrip(&unicast)); + + meshtastic_MeshPacket oversized = + makeDecoded(REMOTE_NODE, NODENUM_BROADCAST, meshtastic_PortNum_POSITION_APP, OVERSIZED_PAYLOAD); + TEST_ASSERT_EQUAL(DECODE_POLICY_REJECT, roundTrip(&oversized)); } +void test_A12_strict_rejects_signed_packet_without_key(void) +{ + setPolicy(meshtastic_Config_SecurityConfig_PacketSignaturePolicy_PACKET_SIGNATURE_POLICY_STRICT); + uint8_t pub[32], priv[32]; + crypto->generateKeyPair(pub, priv); + + meshtastic_MeshPacket p = makeDecoded(REMOTE_NODE, NODENUM_BROADCAST, meshtastic_PortNum_POSITION_APP, SMALL_PAYLOAD); + signWithCurrentKey(&p); + TEST_ASSERT_EQUAL(DECODE_POLICY_REJECT, roundTrip(&p)); +} + +void test_A13_strict_accepts_locally_authenticated_pki_packet(void) +{ + setPolicy(meshtastic_Config_SecurityConfig_PacketSignaturePolicy_PACKET_SIGNATURE_POLICY_STRICT); + uint8_t localPub[32], localPriv[32], remotePub[32], remotePriv[32]; + crypto->generateKeyPair(localPub, localPriv); + crypto->generateKeyPair(remotePub, remotePriv); + mockNodeDB->addNode(LOCAL_NODE); + mockNodeDB->setPublicKey(LOCAL_NODE, localPub); + mockNodeDB->addNode(REMOTE_NODE); + mockNodeDB->setPublicKey(REMOTE_NODE, remotePub); + + meshtastic_Data data = meshtastic_Data_init_zero; + data.portnum = meshtastic_PortNum_PRIVATE_APP; + data.payload.size = SMALL_PAYLOAD; + memset(data.payload.bytes, 0x5A, data.payload.size); + uint8_t plaintext[MAX_LORA_PAYLOAD_LEN + 1] = {}; + const size_t plaintextSize = pb_encode_to_bytes(plaintext, sizeof(plaintext), &meshtastic_Data_msg, &data); + TEST_ASSERT_GREATER_THAN(0, plaintextSize); + + meshtastic_NodeInfoLite_public_key_t localKey = {32, {0}}; + memcpy(localKey.bytes, localPub, sizeof(localPub)); + meshtastic_MeshPacket p = meshtastic_MeshPacket_init_zero; + p.from = REMOTE_NODE; + p.to = LOCAL_NODE; + p.id = 0x0CC01234; + p.channel = 0; + p.which_payload_variant = meshtastic_MeshPacket_encrypted_tag; + crypto->setDHPrivateKey(remotePriv); + TEST_ASSERT_TRUE(crypto->encryptCurve25519(p.to, p.from, localKey, p.id, plaintextSize, plaintext, p.encrypted.bytes)); + p.encrypted.size = plaintextSize + MESHTASTIC_PKC_OVERHEAD; + + // Only the receiver's private key can establish the local pki_encrypted authentication marker. + crypto->setDHPrivateKey(localPriv); + TEST_ASSERT_EQUAL(DECODE_SUCCESS, perhapsDecode(&p)); + TEST_ASSERT_TRUE(p.pki_encrypted); + TEST_ASSERT_EQUAL(meshtastic_PortNum_PRIVATE_APP, p.decoded.portnum); +} + +void test_A13b_strict_rejects_spoofed_pki_flag_on_encrypted_ingress(void) +{ + setPolicy(meshtastic_Config_SecurityConfig_PacketSignaturePolicy_PACKET_SIGNATURE_POLICY_STRICT); + meshtastic_MeshPacket p = makeDecoded(REMOTE_NODE, LOCAL_NODE, meshtastic_PortNum_POSITION_APP, SMALL_PAYLOAD); + TEST_ASSERT_EQUAL(meshtastic_Routing_Error_NONE, perhapsEncode(&p)); + p.pki_encrypted = true; + p.public_key.size = 32; + memset(p.public_key.bytes, 0xAB, p.public_key.size); + + TEST_ASSERT_EQUAL(DECODE_POLICY_REJECT, perhapsDecode(&p)); + TEST_ASSERT_FALSE(p.pki_encrypted); + TEST_ASSERT_EQUAL(0, p.public_key.size); +} + +void test_A14_strict_bootstraps_identity_bound_signed_nodeinfo(void) +{ + setPolicy(meshtastic_Config_SecurityConfig_PacketSignaturePolicy_PACKET_SIGNATURE_POLICY_STRICT); + uint8_t pub[32], priv[32]; + crypto->generateKeyPair(pub, priv); + const NodeNum signer = crc32Buffer(pub, sizeof(pub)); + + meshtastic_User user = meshtastic_User_init_zero; + user.public_key.size = sizeof(pub); + memcpy(user.public_key.bytes, pub, sizeof(pub)); + meshtastic_MeshPacket p = makeDecoded(signer, NODENUM_BROADCAST, meshtastic_PortNum_NODEINFO_APP, 0); + p.decoded.payload.size = + pb_encode_to_bytes(p.decoded.payload.bytes, sizeof(p.decoded.payload.bytes), &meshtastic_User_msg, &user); + signWithCurrentKey(&p); + + TEST_ASSERT_EQUAL(DECODE_SUCCESS, roundTrip(&p)); + const meshtastic_NodeInfoLite *node = mockNodeDB->getMeshNode(signer); + TEST_ASSERT_NOT_NULL(node); + TEST_ASSERT_EQUAL_UINT8_ARRAY(pub, node->public_key.bytes, sizeof(pub)); + TEST_ASSERT_TRUE(p.xeddsa_signed); +} + +void test_A15_strict_rejects_nodeinfo_key_without_identity_binding(void) +{ + setPolicy(meshtastic_Config_SecurityConfig_PacketSignaturePolicy_PACKET_SIGNATURE_POLICY_STRICT); + uint8_t pub[32], priv[32]; + crypto->generateKeyPair(pub, priv); + + meshtastic_User user = meshtastic_User_init_zero; + user.public_key.size = sizeof(pub); + memcpy(user.public_key.bytes, pub, sizeof(pub)); + meshtastic_MeshPacket p = + makeDecoded(crc32Buffer(pub, sizeof(pub)) ^ 1, NODENUM_BROADCAST, meshtastic_PortNum_NODEINFO_APP, 0); + p.decoded.payload.size = + pb_encode_to_bytes(p.decoded.payload.bytes, sizeof(p.decoded.payload.bytes), &meshtastic_User_msg, &user); + signWithCurrentKey(&p); + + TEST_ASSERT_EQUAL(DECODE_POLICY_REJECT, roundTrip(&p)); + TEST_ASSERT_NULL(mockNodeDB->getMeshNode(p.from)); +} + +void test_A16_compatible_rejects_invalid_first_contact_nodeinfo(void) +{ + setPolicy(meshtastic_Config_SecurityConfig_PacketSignaturePolicy_PACKET_SIGNATURE_POLICY_COMPATIBLE); + uint8_t pub[32], priv[32]; + crypto->generateKeyPair(pub, priv); + + meshtastic_User user = meshtastic_User_init_zero; + user.public_key.size = sizeof(pub); + memcpy(user.public_key.bytes, pub, sizeof(pub)); + meshtastic_MeshPacket p = + makeDecoded(crc32Buffer(pub, sizeof(pub)) ^ 1, NODENUM_BROADCAST, meshtastic_PortNum_NODEINFO_APP, 0); + p.decoded.payload.size = + pb_encode_to_bytes(p.decoded.payload.bytes, sizeof(p.decoded.payload.bytes), &meshtastic_User_msg, &user); + signWithCurrentKey(&p); + + TEST_ASSERT_EQUAL(DECODE_POLICY_REJECT, roundTrip(&p)); +} + +#if WARM_NODE_COUNT > 0 +void test_A17_strict_verifies_signer_from_warm_key_store(void) +{ + setPolicy(meshtastic_Config_SecurityConfig_PacketSignaturePolicy_PACKET_SIGNATURE_POLICY_STRICT); + uint8_t pub[32], priv[32]; + crypto->generateKeyPair(pub, priv); + TEST_ASSERT_TRUE(mockNodeDB->warmStore.absorb(REMOTE_NODE, 1, pub)); + TEST_ASSERT_NULL(mockNodeDB->getMeshNode(REMOTE_NODE)); + + meshtastic_MeshPacket p = makeDecoded(REMOTE_NODE, NODENUM_BROADCAST, meshtastic_PortNum_POSITION_APP, SMALL_PAYLOAD); + signWithCurrentKey(&p); + TEST_ASSERT_EQUAL(DECODE_SUCCESS, roundTrip(&p)); + TEST_ASSERT_TRUE(p.xeddsa_signed); + const meshtastic_NodeInfoLite *rehydrated = mockNodeDB->getMeshNode(REMOTE_NODE); + TEST_ASSERT_NOT_NULL_MESSAGE(rehydrated, "verified warm signer must be re-admitted to the hot store"); + TEST_ASSERT_EQUAL_UINT8_ARRAY(pub, rehydrated->public_key.bytes, sizeof(pub)); + TEST_ASSERT_TRUE_MESSAGE(nodeInfoLiteHasXeddsaSigned(rehydrated), "re-admitted signer must retain Balanced downgrade memory"); + + // Model its next hot-store eviction and prove Balanced still remembers the signer without + // allocating a hot node merely to evaluate an unsigned packet. + TEST_ASSERT_TRUE(mockNodeDB->warmStore.absorb(REMOTE_NODE, 2, pub, meshtastic_Config_DeviceConfig_Role_CLIENT, + static_cast(WarmProtected::XeddsaSigner))); + mockNodeDB->clearTestNodes(); + setPolicy(meshtastic_Config_SecurityConfig_PacketSignaturePolicy_PACKET_SIGNATURE_POLICY_BALANCED); + meshtastic_MeshPacket unsignedPacket = + makeDecoded(REMOTE_NODE, NODENUM_BROADCAST, meshtastic_PortNum_POSITION_APP, SMALL_PAYLOAD); + TEST_ASSERT_FALSE_MESSAGE(checkXeddsaReceivePolicy(&unsignedPacket), + "Balanced downgrade memory must survive repeated hot-store eviction"); +} +#endif + // =========================================================================== // Group B - send-side signing policy (perhapsEncode) // =========================================================================== @@ -343,15 +687,15 @@ void test_B1_local_broadcast_is_signed(void) TEST_ASSERT_TRUE(p.xeddsa_signed); } -// B2: our own unicast is NOT signed. +// B2: preserve the existing wire behavior: non-PKI unicast is not signed. void test_B2_local_unicast_not_signed(void) { mockNodeDB->addNode(REMOTE_NODE); - meshtastic_MeshPacket p = makeDecoded(LOCAL_NODE, REMOTE_NODE, meshtastic_PortNum_PRIVATE_APP, SMALL_PAYLOAD); + meshtastic_MeshPacket p = makeDecoded(LOCAL_NODE, REMOTE_NODE, meshtastic_PortNum_POSITION_APP, SMALL_PAYLOAD); TEST_ASSERT_EQUAL(DECODE_SUCCESS, roundTrip(&p)); - TEST_ASSERT_EQUAL_MESSAGE(0, p.decoded.xeddsa_signature.size, "unicast must not be signed"); + TEST_ASSERT_EQUAL_MESSAGE(0, p.decoded.xeddsa_signature.size, "unicast must remain unsigned"); } // B3: our own oversized broadcast is NOT signed (signature wouldn't fit). @@ -400,14 +744,12 @@ void test_B4_all_broadcast_sizes_deliverable_no_deadband(void) TEST_ASSERT_TRUE_MESSAGE(sawUnsigned, "sweep never crossed the fit boundary"); } -// B5: a client-preset signature on a packet we originate is discarded, not transmitted. -// perhapsEncode owns signing for our packets; a stale/garbage signature from a phone app on a -// packet we don't sign (here: unicast) would otherwise fail verification at every receiver. +// B5: a client-preset signature on a packet outside the existing broadcast sign class is discarded. void test_B5_preset_signature_on_local_packet_cleared(void) { mockNodeDB->addNode(REMOTE_NODE); - meshtastic_MeshPacket p = makeDecoded(LOCAL_NODE, REMOTE_NODE, meshtastic_PortNum_PRIVATE_APP, SMALL_PAYLOAD); + meshtastic_MeshPacket p = makeDecoded(LOCAL_NODE, REMOTE_NODE, meshtastic_PortNum_POSITION_APP, SMALL_PAYLOAD); p.decoded.xeddsa_signature.size = XEDDSA_SIGNATURE_SIZE; memset(p.decoded.xeddsa_signature.bytes, 0xAB, XEDDSA_SIGNATURE_SIZE); @@ -454,81 +796,345 @@ void test_B6_rich_shape_sweep_no_deadband(void) TEST_ASSERT_TRUE_MESSAGE(sawUnsigned, "rich sweep never crossed the fit boundary"); } +void test_B7_infrastructure_port_signing_matrix(void) +{ + uint8_t pub[32], priv[32]; + crypto->generateKeyPair(pub, priv); + mockNodeDB->addNode(LOCAL_NODE); + mockNodeDB->setPublicKey(LOCAL_NODE, pub); + + const meshtastic_PortNum ports[] = { + meshtastic_PortNum_NODEINFO_APP, + meshtastic_PortNum_ROUTING_APP, + meshtastic_PortNum_TRACEROUTE_APP, + meshtastic_PortNum_POSITION_APP, + }; + for (const auto port : ports) { + meshtastic_MeshPacket broadcast = makeDecoded(LOCAL_NODE, NODENUM_BROADCAST, port, SMALL_PAYLOAD); + TEST_ASSERT_EQUAL(DECODE_SUCCESS, roundTrip(&broadcast)); + TEST_ASSERT_EQUAL_MESSAGE(XEDDSA_SIGNATURE_SIZE, broadcast.decoded.xeddsa_signature.size, + "signable infrastructure broadcast must be signed"); + + meshtastic_MeshPacket unicast = makeDecoded(LOCAL_NODE, REMOTE_NODE, port, SMALL_PAYLOAD); + TEST_ASSERT_EQUAL(DECODE_SUCCESS, roundTrip(&unicast)); + TEST_ASSERT_EQUAL_MESSAGE(0, unicast.decoded.xeddsa_signature.size, + "infrastructure unicast must preserve existing unsigned behavior"); + } +} + // =========================================================================== -// Group C - NodeInfoModule downgrade drop (broadcast-only backstop for ingress paths that skip -// Router's check; unicast NodeInfo is never signed by senders, so it is exempt - see C4) +// Group C - routing pipeline authentication ordering // =========================================================================== -class NodeInfoTestShim : public NodeInfoModule + +static void preparePipelineSigner(NodeNum sender) { - public: - using NodeInfoModule::handleReceivedProtobuf; // protected virtual -> exposed for direct call -}; + uint8_t pub[32], priv[32]; + crypto->generateKeyPair(pub, priv); + mockNodeDB->addNode(sender); + mockNodeDB->setPublicKey(sender, pub); +} -static meshtastic_MeshPacket makeNodeInfoPacket(bool signed_) +static void runPipelineIngress(const meshtastic_MeshPacket &p) { - // Broadcast so the module's phone-forward path (which needs `service`) is skipped. - meshtastic_MeshPacket mp = makeDecoded(REMOTE_NODE, NODENUM_BROADCAST, meshtastic_PortNum_NODEINFO_APP, SMALL_PAYLOAD); - mp.xeddsa_signed = signed_; - return mp; + meshtastic_MeshPacket *copy = packetPool.allocCopy(p); + TEST_ASSERT_NOT_NULL(copy); + pipelineRouter->enqueueReceivedMessage(copy); + pipelineRouter->runOnce(); } -// C1: unsigned NodeInfo from a node that previously signed -> dropped. -void test_C1_unsigned_nodeinfo_from_signer_dropped(void) +static void assertNoRejectedPipelineEffects(NodeNum sender, uint32_t lastHeardBefore) { - mockNodeDB->addNode(REMOTE_NODE); - mockNodeDB->setSignerBit(REMOTE_NODE, true); + TEST_ASSERT_EQUAL(0, pipelineRadio->sendCalls); + TEST_ASSERT_EQUAL(0, pipelineRadio->cancelCalls); + TEST_ASSERT_EQUAL(0, pipelineRadio->findCalls); + TEST_ASSERT_EQUAL(0, pipelineRadio->removeCalls); + TEST_ASSERT_EQUAL(0, pipelineRouting->ackCalls); + TEST_ASSERT_EQUAL(0, pipelineRouter->rxDupe); + TEST_ASSERT_EQUAL(0, pipelineRouter->txRelayCanceled); + TEST_ASSERT_EQUAL(0, pipelineModule->calls); + TEST_ASSERT_EQUAL(0, pipelineMqtt->queueSize()); + TEST_ASSERT_NULL(pipelineService->getForPhone()); + const meshtastic_NodeInfoLite *node = mockNodeDB->getMeshNode(sender); + TEST_ASSERT_NOT_NULL(node); + TEST_ASSERT_EQUAL_UINT32(lastHeardBefore, node->last_heard); +} - NodeInfoTestShim shim; - meshtastic_MeshPacket mp = makeNodeInfoPacket(/*signed_=*/false); - meshtastic_User user = meshtastic_User_init_zero; - user.is_licensed = owner.is_licensed; +void test_C1_invalid_first_copy_does_not_poison_valid_same_id(void) +{ + setPolicy(meshtastic_Config_SecurityConfig_PacketSignaturePolicy_PACKET_SIGNATURE_POLICY_STRICT); + preparePipelineSigner(REMOTE_NODE); + const PacketId id = 0xC1000001; + const uint32_t lastHeard = mockNodeDB->getMeshNode(REMOTE_NODE)->last_heard; + + meshtastic_MeshPacket invalid = makeSignedWirePacket(REMOTE_NODE, NODENUM_BROADCAST, id, 1, 2, 0, 0x31, false); + moduleConfig.mqtt.enabled = true; + runPipelineIngress(invalid); + assertNoRejectedPipelineEffects(REMOTE_NODE, lastHeard); + TEST_ASSERT_FALSE(pipelineRouter->historyContains(&invalid)); + + meshtastic_MeshPacket valid = makeSignedWirePacket(REMOTE_NODE, NODENUM_BROADCAST, id); + TEST_ASSERT_EQUAL(static_cast(RoutingAuthVerdict::ACCEPT), static_cast(passesRoutingAuthGate(&valid))); + TEST_ASSERT_EQUAL_MESSAGE(meshtastic_MeshPacket_encrypted_tag, valid.which_payload_variant, + "routing auth gate must preserve encrypted relay/MQTT bytes"); + TEST_ASSERT_FALSE_MESSAGE(pipelineRouter->filter(&valid), "valid same-ID packet was poisoned by rejected first copy"); + TEST_ASSERT_TRUE(pipelineRouter->historyContains(&valid)); +} - TEST_ASSERT_TRUE_MESSAGE(shim.handleReceivedProtobuf(mp, &user), "unsigned NodeInfo from signer must be dropped"); +void test_C2_invalid_ordinary_duplicate_has_no_cancel_or_delivery_effects(void) +{ + setPolicy(meshtastic_Config_SecurityConfig_PacketSignaturePolicy_PACKET_SIGNATURE_POLICY_STRICT); + preparePipelineSigner(REMOTE_NODE); + const PacketId id = 0xC2000002; + meshtastic_MeshPacket prior = makeDecoded(REMOTE_NODE, NODENUM_BROADCAST, meshtastic_PortNum_POSITION_APP, SMALL_PAYLOAD); + prior.id = id; + prior.hop_limit = 1; + prior.hop_start = 2; + prior.transport_mechanism = meshtastic_MeshPacket_TransportMechanism_TRANSPORT_LORA; + pipelineRouter->remember(&prior); + const uint32_t lastHeard = mockNodeDB->getMeshNode(REMOTE_NODE)->last_heard; + + meshtastic_MeshPacket invalid = makeSignedWirePacket(REMOTE_NODE, NODENUM_BROADCAST, id, 1, 2, 0, 0x32, false); + runPipelineIngress(invalid); + assertNoRejectedPipelineEffects(REMOTE_NODE, lastHeard); + TEST_ASSERT_TRUE(pipelineRouter->historyContains(&prior)); } -// C2: signed NodeInfo from a known signer -> not dropped by this rule. -void test_C2_signed_nodeinfo_from_signer_not_dropped(void) +void test_C3_invalid_repeated_packet_cannot_ack_or_change_retry_state(void) { - mockNodeDB->addNode(REMOTE_NODE); - mockNodeDB->setSignerBit(REMOTE_NODE, true); + setPolicy(meshtastic_Config_SecurityConfig_PacketSignaturePolicy_PACKET_SIGNATURE_POLICY_STRICT); + preparePipelineSigner(LOCAL_NODE); + const PacketId id = 0xC3000003; + meshtastic_MeshPacket prior = makeDecoded(LOCAL_NODE, NODENUM_BROADCAST, meshtastic_PortNum_POSITION_APP, SMALL_PAYLOAD); + prior.id = id; + prior.hop_limit = 2; + prior.hop_start = 2; + prior.transport_mechanism = meshtastic_MeshPacket_TransportMechanism_TRANSPORT_LORA; + pipelineRouter->remember(&prior); + pipelineRouter->addPending(prior, UINT32_MAX); + const uint32_t lastHeard = mockNodeDB->getMeshNode(LOCAL_NODE)->last_heard; + + meshtastic_MeshPacket invalid = makeSignedWirePacket(LOCAL_NODE, NODENUM_BROADCAST, id, 2, 2, 0, 0x34, false); + runPipelineIngress(invalid); + assertNoRejectedPipelineEffects(LOCAL_NODE, lastHeard); + TEST_ASSERT_EQUAL(1, pipelineRouter->pendingCount()); + TEST_ASSERT_EQUAL_UINT32(UINT32_MAX, pipelineRouter->pendingNextTx(LOCAL_NODE, id)); +} - NodeInfoTestShim shim; - meshtastic_MeshPacket mp = makeNodeInfoPacket(/*signed_=*/true); - meshtastic_User user = meshtastic_User_init_zero; - user.is_licensed = owner.is_licensed; +void test_C4_invalid_fallback_packet_cannot_relay(void) +{ + setPolicy(meshtastic_Config_SecurityConfig_PacketSignaturePolicy_PACKET_SIGNATURE_POLICY_STRICT); + preparePipelineSigner(REMOTE_NODE); + const PacketId id = 0xC4000004; + const uint8_t ourRelay = mockNodeDB->getLastByteOfNodeNum(LOCAL_NODE); + meshtastic_MeshPacket prior = makeDecoded(REMOTE_NODE, LOCAL_NODE, meshtastic_PortNum_POSITION_APP, SMALL_PAYLOAD); + prior.id = id; + prior.next_hop = 0x22; + prior.relay_node = ourRelay; + prior.hop_limit = 1; + prior.hop_start = 2; + pipelineRouter->remember(&prior); + meshtastic_MeshPacket relayed = prior; + relayed.relay_node = 0x35; + pipelineRouter->remember(&relayed); + pipelineRouter->forgetRelayer(ourRelay, id, REMOTE_NODE); + const uint32_t lastHeard = mockNodeDB->getMeshNode(REMOTE_NODE)->last_heard; + + meshtastic_MeshPacket invalid = makeSignedWirePacket(REMOTE_NODE, LOCAL_NODE, id, 1, 2, 0, 0x35, false); + runPipelineIngress(invalid); + assertNoRejectedPipelineEffects(REMOTE_NODE, lastHeard); +} - TEST_ASSERT_FALSE(shim.handleReceivedProtobuf(mp, &user)); +void test_C5_invalid_upgrade_cannot_remove_pending_valid_send(void) +{ + setPolicy(meshtastic_Config_SecurityConfig_PacketSignaturePolicy_PACKET_SIGNATURE_POLICY_STRICT); + preparePipelineSigner(REMOTE_NODE); + const PacketId id = 0xC5000005; + meshtastic_MeshPacket prior = makeDecoded(REMOTE_NODE, NODENUM_BROADCAST, meshtastic_PortNum_POSITION_APP, SMALL_PAYLOAD); + prior.id = id; + prior.hop_limit = 1; + prior.hop_start = 2; + pipelineRouter->remember(&prior); + const uint32_t lastHeard = mockNodeDB->getMeshNode(REMOTE_NODE)->last_heard; + + meshtastic_MeshPacket directInvalid = makeSignedWirePacket(REMOTE_NODE, NODENUM_BROADCAST, id, 2, 2, 0, 0x36, false); + TEST_ASSERT_TRUE(pipelineRouter->handleUpgrade(&directInvalid)); + TEST_ASSERT_EQUAL(0, pipelineRadio->removeCalls); + + meshtastic_MeshPacket invalid = makeSignedWirePacket(REMOTE_NODE, NODENUM_BROADCAST, id, 2, 2, 0, 0x36, false); + runPipelineIngress(invalid); + assertNoRejectedPipelineEffects(REMOTE_NODE, lastHeard); + + // The rejected upgrade did not raise the history watermark or remove the queued valid copy; + // a later authenticated replacement still performs the intended upgrade. + meshtastic_MeshPacket valid = makeSignedWirePacket(REMOTE_NODE, NODENUM_BROADCAST, id, 2, 2, 0, 0x36, true); + TEST_ASSERT_EQUAL(static_cast(RoutingAuthVerdict::ACCEPT), static_cast(passesRoutingAuthGate(&valid))); + TEST_ASSERT_TRUE(pipelineRouter->filter(&valid)); + TEST_ASSERT_EQUAL(1, pipelineRadio->removeCalls); } -// C3: unsigned NodeInfo from a node we've never seen sign -> not dropped. -void test_C3_unsigned_nodeinfo_from_nonsigner_not_dropped(void) +void test_C6_opaque_unknown_channel_is_relay_only(void) { - mockNodeDB->addNode(REMOTE_NODE); // signer bit clear + setPolicy(meshtastic_Config_SecurityConfig_PacketSignaturePolicy_PACKET_SIGNATURE_POLICY_STRICT); + meshtastic_MeshPacket opaque = meshtastic_MeshPacket_init_zero; + opaque.from = REMOTE_NODE; + opaque.to = NODENUM_BROADCAST; + opaque.id = 0xC6000006; + opaque.channel = 0xFE; + opaque.hop_limit = 1; + opaque.hop_start = 2; + opaque.which_payload_variant = meshtastic_MeshPacket_encrypted_tag; + opaque.encrypted.size = 16; + memset(opaque.encrypted.bytes, 0xA5, opaque.encrypted.size); + + TEST_ASSERT_EQUAL(static_cast(RoutingAuthVerdict::OPAQUE_RELAY_ONLY), static_cast(passesRoutingAuthGate(&opaque))); + moduleConfig.mqtt.enabled = true; + runPipelineIngress(opaque); + TEST_ASSERT_EQUAL_MESSAGE(1, pipelineRadio->sendCalls, "opaque broadcast should take only the safety-controlled relay path"); + TEST_ASSERT_EQUAL(0, pipelineRouting->ackCalls); + TEST_ASSERT_EQUAL(0, pipelineModule->calls); + TEST_ASSERT_EQUAL(0, pipelineMqtt->queueSize()); + TEST_ASSERT_NULL(pipelineService->getForPhone()); + TEST_ASSERT_FALSE(pipelineRouter->historyContains(&opaque)); + TEST_ASSERT_NULL(mockNodeDB->getMeshNode(REMOTE_NODE)); + + pipelineRadio->reset(); + meshtastic_MeshPacket addressed = opaque; + addressed.to = LOCAL_NODE; + addressed.id++; + runPipelineIngress(addressed); + TEST_ASSERT_EQUAL_MESSAGE(0, pipelineRadio->sendCalls, "opaque packet addressed to us must not be relayed"); + TEST_ASSERT_EQUAL(0, pipelineRouting->ackCalls); + TEST_ASSERT_EQUAL(0, pipelineModule->calls); + TEST_ASSERT_EQUAL(0, pipelineMqtt->queueSize()); + TEST_ASSERT_NULL(pipelineService->getForPhone()); + TEST_ASSERT_FALSE(pipelineRouter->historyContains(&addressed)); + + const meshtastic_Config_DeviceConfig_RebroadcastMode blockedModes[] = { + meshtastic_Config_DeviceConfig_RebroadcastMode_LOCAL_ONLY, + meshtastic_Config_DeviceConfig_RebroadcastMode_CORE_PORTNUMS_ONLY, + meshtastic_Config_DeviceConfig_RebroadcastMode_NONE, + }; + for (const auto mode : blockedModes) { + pipelineRadio->reset(); + config.device.rebroadcast_mode = mode; + meshtastic_MeshPacket blocked = opaque; + blocked.id++; + blocked.id += static_cast(mode); + blocked.transport_mechanism = meshtastic_MeshPacket_TransportMechanism_TRANSPORT_MULTICAST_UDP; + runPipelineIngress(blocked); + TEST_ASSERT_EQUAL_MESSAGE(0, pipelineRadio->sendCalls, "restricted rebroadcast mode must suppress opaque relay"); + TEST_ASSERT_EQUAL(0, pipelineRouting->ackCalls); + TEST_ASSERT_EQUAL(0, pipelineModule->calls); + TEST_ASSERT_EQUAL(0, pipelineMqtt->queueSize()); + TEST_ASSERT_NULL(pipelineService->getForPhone()); + TEST_ASSERT_FALSE(pipelineRouter->historyContains(&blocked)); + } +} - NodeInfoTestShim shim; - meshtastic_MeshPacket mp = makeNodeInfoPacket(/*signed_=*/false); - meshtastic_User user = meshtastic_User_init_zero; - user.is_licensed = owner.is_licensed; +void test_C7_strict_rejects_unsigned_decoded_simradio_ingress(void) +{ + setPolicy(meshtastic_Config_SecurityConfig_PacketSignaturePolicy_PACKET_SIGNATURE_POLICY_STRICT); + mockNodeDB->addNode(REMOTE_NODE); + const uint32_t lastHeard = mockNodeDB->getMeshNode(REMOTE_NODE)->last_heard; + meshtastic_MeshPacket injected = makeDecoded(REMOTE_NODE, NODENUM_BROADCAST, meshtastic_PortNum_POSITION_APP, SMALL_PAYLOAD); + injected.transport_mechanism = meshtastic_MeshPacket_TransportMechanism_TRANSPORT_LORA; + runPipelineIngress(injected); + assertNoRejectedPipelineEffects(REMOTE_NODE, lastHeard); + TEST_ASSERT_FALSE(pipelineRouter->historyContains(&injected)); +} - TEST_ASSERT_FALSE(shim.handleReceivedProtobuf(mp, &user)); +void test_C8_trusted_local_decoded_delivery_is_not_filtered(void) +{ + setPolicy(meshtastic_Config_SecurityConfig_PacketSignaturePolicy_PACKET_SIGNATURE_POLICY_STRICT); + meshtastic_MeshPacket *local = + packetPool.allocCopy(makeDecoded(0, LOCAL_NODE, meshtastic_PortNum_POSITION_APP, SMALL_PAYLOAD)); + TEST_ASSERT_NOT_NULL(local); + TEST_ASSERT_EQUAL(ERRNO_SHOULD_RELEASE, pipelineRouter->sendLocal(local, RX_SRC_USER)); + TEST_ASSERT_EQUAL_MESSAGE(1, pipelineModule->calls, "trusted phone-origin packet must reach local modules"); + packetPool.release(local); } -// C4: F1 regression - unsigned UNICAST NodeInfo from a known signer -> NOT dropped. Unicast -// NodeInfo (want_response replies, phone-initiated exchanges) is never signed by the sender, -// so treating it as a downgrade broke NodeInfo exchange with signer nodes. -void test_C4_unsigned_unicast_nodeinfo_from_signer_accepted(void) +void test_C9_known_channel_malformed_plaintext_is_not_relayed_as_opaque(void) { + meshtastic_MeshPacket malformed = meshtastic_MeshPacket_init_zero; + malformed.from = REMOTE_NODE; + malformed.to = NODENUM_BROADCAST; + malformed.id = 0xC9000009; + malformed.which_payload_variant = meshtastic_MeshPacket_encrypted_tag; + malformed.encrypted.size = 3; + malformed.encrypted.bytes[0] = 0xFF; + malformed.encrypted.bytes[1] = 0xFF; + malformed.encrypted.bytes[2] = 0xFF; + malformed.channel = channels.setActiveByIndex(0); + crypto->encryptPacket(malformed.from, malformed.id, malformed.encrypted.size, malformed.encrypted.bytes); mockNodeDB->addNode(REMOTE_NODE); - mockNodeDB->setSignerBit(REMOTE_NODE, true); + const uint32_t lastHeard = mockNodeDB->getMeshNode(REMOTE_NODE)->last_heard; + runPipelineIngress(malformed); + assertNoRejectedPipelineEffects(REMOTE_NODE, lastHeard); + TEST_ASSERT_FALSE(pipelineRouter->historyContains(&malformed)); +} - NodeInfoTestShim shim; - meshtastic_MeshPacket mp = makeDecoded(REMOTE_NODE, LOCAL_NODE, meshtastic_PortNum_NODEINFO_APP, SMALL_PAYLOAD); - mp.xeddsa_signed = false; - meshtastic_User user = meshtastic_User_init_zero; - user.is_licensed = owner.is_licensed; +void test_C10_legacy_channel_dm_failure_has_no_pipeline_effects(void) +{ + meshtastic_MeshPacket legacyDm = makeDecoded(REMOTE_NODE, LOCAL_NODE, meshtastic_PortNum_TEXT_MESSAGE_APP, SMALL_PAYLOAD); + legacyDm = channelEncode(legacyDm); + mockNodeDB->addNode(REMOTE_NODE); + const uint32_t lastHeard = mockNodeDB->getMeshNode(REMOTE_NODE)->last_heard; + moduleConfig.mqtt.enabled = true; + runPipelineIngress(legacyDm); + assertNoRejectedPipelineEffects(REMOTE_NODE, lastHeard); + TEST_ASSERT_FALSE(pipelineRouter->historyContains(&legacyDm)); +} + +void test_C11_malformed_pki_plaintext_has_no_pipeline_effects(void) +{ + uint8_t localPub[32], localPriv[32], remotePub[32], remotePriv[32]; + crypto->generateKeyPair(localPub, localPriv); + crypto->generateKeyPair(remotePub, remotePriv); + mockNodeDB->addNode(LOCAL_NODE); + mockNodeDB->setPublicKey(LOCAL_NODE, localPub); + mockNodeDB->addNode(REMOTE_NODE); + mockNodeDB->setPublicKey(REMOTE_NODE, remotePub); + + const uint8_t malformedPlaintext[] = {0xFF, 0xFF, 0xFF}; + meshtastic_NodeInfoLite_public_key_t localKey = {32, {0}}; + memcpy(localKey.bytes, localPub, sizeof(localPub)); + meshtastic_MeshPacket malformed = meshtastic_MeshPacket_init_zero; + malformed.from = REMOTE_NODE; + malformed.to = LOCAL_NODE; + malformed.id = 0xCB00000B; + malformed.channel = 0; + malformed.which_payload_variant = meshtastic_MeshPacket_encrypted_tag; + crypto->setDHPrivateKey(remotePriv); + TEST_ASSERT_TRUE(crypto->encryptCurve25519(malformed.to, malformed.from, localKey, malformed.id, sizeof(malformedPlaintext), + malformedPlaintext, malformed.encrypted.bytes)); + malformed.encrypted.size = sizeof(malformedPlaintext) + MESHTASTIC_PKC_OVERHEAD; + crypto->setDHPrivateKey(localPriv); + + const uint32_t lastHeard = mockNodeDB->getMeshNode(REMOTE_NODE)->last_heard; + moduleConfig.mqtt.enabled = true; + runPipelineIngress(malformed); + assertNoRejectedPipelineEffects(REMOTE_NODE, lastHeard); + TEST_ASSERT_FALSE(pipelineRouter->historyContains(&malformed)); +} - TEST_ASSERT_FALSE_MESSAGE(shim.handleReceivedProtobuf(mp, &user), - "unsigned unicast NodeInfo from a signer must not be dropped"); +void test_C12_exact_authenticated_replay_reuses_verdict_without_collision_bypass(void) +{ + setPolicy(meshtastic_Config_SecurityConfig_PacketSignaturePolicy_PACKET_SIGNATURE_POLICY_STRICT); + preparePipelineSigner(REMOTE_NODE); + meshtastic_MeshPacket valid = makeSignedWirePacket(REMOTE_NODE, NODENUM_BROADCAST, 0xCC00000C); + // Full ingress replaces this nonzero wire timestamp with the local arrival time. The exact + // authentication handoff must be consumed before that mutation, avoiding a second evaluation. + valid.rx_time = 0x12345678; + runPipelineIngress(valid); + TEST_ASSERT_EQUAL_MESSAGE(1, routingAuthEvaluationCount(), "full ingress must consume the primed verdict exactly once"); + runPipelineIngress(valid); + TEST_ASSERT_EQUAL_MESSAGE(2, routingAuthEvaluationCount(), "consumed verdict must not authenticate a later replay"); + + meshtastic_MeshPacket collision = valid; + collision.encrypted.bytes[0] ^= 0x80; + TEST_ASSERT_EQUAL(static_cast(RoutingAuthVerdict::REJECT), static_cast(passesRoutingAuthGate(&collision))); + TEST_ASSERT_EQUAL_MESSAGE(3, routingAuthEvaluationCount(), "same packet ID with different bytes must be reevaluated"); } // =========================================================================== @@ -628,7 +1234,7 @@ void test_E5_decoded_unsigned_oversized_broadcast_from_signer_accepted(void) TEST_ASSERT_TRUE(checkXeddsaReceivePolicy(&p)); } -// E6: unsigned unicast from a signer -> accepted (unicast is never signed). +// E6: Balanced accepts unsigned unicast from a signer for legacy compatibility. void test_E6_decoded_unsigned_unicast_from_signer_accepted(void) { mockNodeDB->addNode(REMOTE_NODE); @@ -639,20 +1245,6 @@ void test_E6_decoded_unsigned_unicast_from_signer_accepted(void) TEST_ASSERT_TRUE(checkXeddsaReceivePolicy(&p)); } -// E7: unsigned PKI-flagged packet from a signer -> accepted. Senders never sign PKI traffic, -// so the predicate's !pki_encrypted guard must exempt it (pins the assumption that the -// downgrade drop can never fire on PKI packets, whatever their addressing). -void test_E7_decoded_unsigned_pki_from_signer_accepted(void) -{ - mockNodeDB->addNode(REMOTE_NODE); - mockNodeDB->setSignerBit(REMOTE_NODE, true); - - meshtastic_MeshPacket p = makeDecoded(REMOTE_NODE, NODENUM_BROADCAST, meshtastic_PortNum_TEXT_MESSAGE_APP, SMALL_PAYLOAD); - p.pki_encrypted = true; - - TEST_ASSERT_TRUE(checkXeddsaReceivePolicy(&p)); -} - // E8: a crafted partial (non-0, non-64) signature must not let a forged broadcast dodge the // downgrade drop. A 63-byte junk signature inflates the encoded size past the fit threshold, so // a size-only predicate would treat the packet as "too big to sign" and accept it as an @@ -688,6 +1280,19 @@ void test_E9_decoded_partial_signature_from_nonsigner_dropped(void) void setup() { initializeTestEnvironment(); + + config.lora.region = meshtastic_Config_LoRaConfig_RegionCode_US; + initRegion(); + pipelineRouter = new AuthPipelineRouter(); + auto pipelineRadioOwner = std::make_unique(); + pipelineRadio = pipelineRadioOwner.get(); + pipelineRouter->addInterface(std::move(pipelineRadioOwner)); + router = pipelineRouter; + routingModule = pipelineRouting = new AuthPipelineRoutingModule(); + pipelineModule = new AuthPipelineModule(); + service = pipelineService = new MeshService(); + mqtt = pipelineMqtt = new AuthPipelineMqtt(); + UNITY_BEGIN(); printf("\n=== Group A: receive-side accept/reject ===\n"); @@ -700,6 +1305,17 @@ void setup() RUN_TEST(test_A7_unsigned_oversized_broadcast_from_signer_accepted); RUN_TEST(test_A8_unsigned_deadband_broadcast_from_signer_accepted); RUN_TEST(test_A9_unsigned_boundary_broadcast_from_signer_still_dropped); + RUN_TEST(test_A10_compatible_accepts_unsigned_broadcast_from_signer); + RUN_TEST(test_A11_strict_rejects_unsigned_all_portnums_destinations_and_sizes); + RUN_TEST(test_A12_strict_rejects_signed_packet_without_key); + RUN_TEST(test_A13_strict_accepts_locally_authenticated_pki_packet); + RUN_TEST(test_A13b_strict_rejects_spoofed_pki_flag_on_encrypted_ingress); + RUN_TEST(test_A14_strict_bootstraps_identity_bound_signed_nodeinfo); + RUN_TEST(test_A15_strict_rejects_nodeinfo_key_without_identity_binding); + RUN_TEST(test_A16_compatible_rejects_invalid_first_contact_nodeinfo); +#if WARM_NODE_COUNT > 0 + RUN_TEST(test_A17_strict_verifies_signer_from_warm_key_store); +#endif printf("\n=== Group B: send-side signing policy ===\n"); RUN_TEST(test_B1_local_broadcast_is_signed); @@ -708,12 +1324,21 @@ void setup() RUN_TEST(test_B4_all_broadcast_sizes_deliverable_no_deadband); RUN_TEST(test_B5_preset_signature_on_local_packet_cleared); RUN_TEST(test_B6_rich_shape_sweep_no_deadband); - - printf("\n=== Group C: NodeInfoModule downgrade drop ===\n"); - RUN_TEST(test_C1_unsigned_nodeinfo_from_signer_dropped); - RUN_TEST(test_C2_signed_nodeinfo_from_signer_not_dropped); - RUN_TEST(test_C3_unsigned_nodeinfo_from_nonsigner_not_dropped); - RUN_TEST(test_C4_unsigned_unicast_nodeinfo_from_signer_accepted); + RUN_TEST(test_B7_infrastructure_port_signing_matrix); + + printf("\n=== Group C: routing pipeline authentication ordering ===\n"); + RUN_TEST(test_C1_invalid_first_copy_does_not_poison_valid_same_id); + RUN_TEST(test_C2_invalid_ordinary_duplicate_has_no_cancel_or_delivery_effects); + RUN_TEST(test_C3_invalid_repeated_packet_cannot_ack_or_change_retry_state); + RUN_TEST(test_C4_invalid_fallback_packet_cannot_relay); + RUN_TEST(test_C5_invalid_upgrade_cannot_remove_pending_valid_send); + RUN_TEST(test_C6_opaque_unknown_channel_is_relay_only); + RUN_TEST(test_C7_strict_rejects_unsigned_decoded_simradio_ingress); + RUN_TEST(test_C8_trusted_local_decoded_delivery_is_not_filtered); + RUN_TEST(test_C9_known_channel_malformed_plaintext_is_not_relayed_as_opaque); + RUN_TEST(test_C10_legacy_channel_dm_failure_has_no_pipeline_effects); + RUN_TEST(test_C11_malformed_pki_plaintext_has_no_pipeline_effects); + RUN_TEST(test_C12_exact_authenticated_replay_reuses_verdict_without_collision_bypass); printf("\n=== Group D: encoding invariants ===\n"); RUN_TEST(test_D1_signature_field_overhead_exact); @@ -725,7 +1350,6 @@ void setup() RUN_TEST(test_E4_decoded_bad_signature_dropped); RUN_TEST(test_E5_decoded_unsigned_oversized_broadcast_from_signer_accepted); RUN_TEST(test_E6_decoded_unsigned_unicast_from_signer_accepted); - RUN_TEST(test_E7_decoded_unsigned_pki_from_signer_accepted); RUN_TEST(test_E8_decoded_partial_signature_from_signer_dropped); RUN_TEST(test_E9_decoded_partial_signature_from_nonsigner_dropped); diff --git a/variants/stm32/CDEBYTE_E77-MBL/platformio.ini b/variants/stm32/CDEBYTE_E77-MBL/platformio.ini index cb980db10f0..f0f5d7cdc69 100644 --- a/variants/stm32/CDEBYTE_E77-MBL/platformio.ini +++ b/variants/stm32/CDEBYTE_E77-MBL/platformio.ini @@ -16,5 +16,8 @@ build_flags = -DMESHTASTIC_EXCLUDE_AIR_QUALITY_SENSOR=1 -DMESHTASTIC_EXCLUDE_I2C=1 -DMESHTASTIC_EXCLUDE_GPS=1 +build_unflags = + ${arduino_base.build_unflags} + -DMESHTASTIC_EXCLUDE_XEDDSA=1 -upload_port = stlink \ No newline at end of file +upload_port = stlink diff --git a/variants/stm32/rak3172/platformio.ini b/variants/stm32/rak3172/platformio.ini index de8f2b74b41..1b63eaadae9 100644 --- a/variants/stm32/rak3172/platformio.ini +++ b/variants/stm32/rak3172/platformio.ini @@ -15,5 +15,8 @@ build_flags = -DMESHTASTIC_EXCLUDE_AIR_QUALITY_SENSOR=1 -DMESHTASTIC_EXCLUDE_I2C=1 -DMESHTASTIC_EXCLUDE_GPS=1 +build_unflags = + ${arduino_base.build_unflags} + -DMESHTASTIC_EXCLUDE_XEDDSA=1 upload_port = stlink diff --git a/variants/stm32/stm32.ini b/variants/stm32/stm32.ini index 5726fd369a2..be4638e6852 100644 --- a/variants/stm32/stm32.ini +++ b/variants/stm32/stm32.ini @@ -25,7 +25,7 @@ build_flags = -DMESHTASTIC_EXCLUDE_BLUETOOTH=1 -DMESHTASTIC_EXCLUDE_WIFI=1 -DMESHTASTIC_EXCLUDE_TZ=1 ; Exclude TZ to save some flash space. - -DMESHTASTIC_EXCLUDE_XEDDSA=1 ; The Ed25519 signing code does not fit in the 256KB flash. Packets are sent unsigned, like pre-XEdDSA firmware. + -DMESHTASTIC_EXCLUDE_XEDDSA=1 ; Individual STM32WL variants opt in after size validation. -DSERIAL_RX_BUFFER_SIZE=256 ; For GPS - the default of 64 is too small. -DHAS_SCREEN=0 ; Always disable screen for STM32, it is not supported. ;-DPIO_FRAMEWORK_ARDUINO_NANOLIB_FLOAT_PRINTF ; Enable this if enabling debugg logging. It is REQUIRED for at least traceroute debug prints - without it the length returned by printf ends up uninitialized. @@ -61,4 +61,4 @@ lib_ignore = OneButton ; Set a custom linker script with a higher MinStackSize value, to match NRF52. -board_build.ldscript = $PROJECT_DIR/variants/stm32/stm32wle5xx.ld \ No newline at end of file +board_build.ldscript = $PROJECT_DIR/variants/stm32/stm32wle5xx.ld From 9d061e732263659d60bc114fcbe659f8fe9807d1 Mon Sep 17 00:00:00 2001 From: Benjamin Faershtein <119711889+RCGV1@users.noreply.github.com> Date: Thu, 9 Jul 2026 18:04:38 -0700 Subject: [PATCH 02/15] feat: sign licensed plaintext packets Preserve and publish identity keys in licensed mode so existing XEdDSA signatures can authenticate plaintext traffic. Sign licensed broadcasts and direct messages when they fit, while keeping PKI encryption disabled and normal routing behavior unchanged. --- src/mesh/NodeDB.cpp | 7 +- src/mesh/Router.cpp | 14 +- src/modules/AdminModule.cpp | 16 ++- src/modules/NodeInfoModule.cpp | 12 +- test/test_packet_signing/test_main.cpp | 175 ++++++++++++++++++++++++- 5 files changed, 199 insertions(+), 25 deletions(-) diff --git a/src/mesh/NodeDB.cpp b/src/mesh/NodeDB.cpp index e05f8b8f0a2..2c676c7078b 100644 --- a/src/mesh/NodeDB.cpp +++ b/src/mesh/NodeDB.cpp @@ -3863,15 +3863,14 @@ bool NodeDB::checkLowEntropyPublicKey(const meshtastic_Config_SecurityConfig_pub bool NodeDB::generateCryptoKeyPair(const uint8_t *privateKey) { #if !(MESHTASTIC_EXCLUDE_PKI_KEYGEN || MESHTASTIC_EXCLUDE_PKI) - // Only generate keys for non-licensed users and if the LoRa region is set. The native simulator - // boots region-UNSET but still needs a keypair so PKI-encrypted DMs work between sim nodes, so - // allow keygen there regardless of region. + // Generate identity keys once a LoRa region is set. Licensed operation still needs the identity + // key for plaintext signatures, even though the key is never used for PKI encryption. bool regionBlocksKeygen = config.lora.region == meshtastic_Config_LoRaConfig_RegionCode_UNSET; #if ARCH_PORTDUINO if (portduino_config.lora_module == use_simradio) regionBlocksKeygen = false; #endif - if (owner.is_licensed || regionBlocksKeygen) { + if (regionBlocksKeygen) { return false; } diff --git a/src/mesh/Router.cpp b/src/mesh/Router.cpp index 67d724433f2..5b15d5f48f7 100644 --- a/src/mesh/Router.cpp +++ b/src/mesh/Router.cpp @@ -528,9 +528,9 @@ DecodeState perhapsDecode(meshtastic_MeshPacket *p) // store or the warm tier (nodes evicted from the hot store keep their key // there), so DMs from long-tail nodes still decrypt. meshtastic_NodeInfoLite_public_key_t fromKey = {0, {0}}; - if (p->channel == 0 && isToUs(p) && p->to > 0 && !isBroadcast(p->to) && nodeDB->copyPublicKey(p->from, fromKey) && - nodeDB->getMeshNode(p->to) != nullptr && nodeDB->getMeshNode(p->to)->public_key.size > 0 && - rawSize > MESHTASTIC_PKC_OVERHEAD) { + if (!owner.is_licensed && p->channel == 0 && isToUs(p) && p->to > 0 && !isBroadcast(p->to) && + nodeDB->copyPublicKey(p->from, fromKey) && nodeDB->getMeshNode(p->to) != nullptr && + nodeDB->getMeshNode(p->to)->public_key.size > 0 && rawSize > MESHTASTIC_PKC_OVERHEAD) { LOG_DEBUG("Attempt PKI decryption"); if (crypto->decryptCurve25519(p->from, fromKey, p->id, rawSize, p->encrypted.bytes, bytes)) { @@ -698,12 +698,12 @@ meshtastic_Routing_Error perhapsEncode(meshtastic_MeshPacket *p) // verification at every XEdDSA-enabled receiver that knows our key. p->decoded.xeddsa_signature.size = 0; #if !(MESHTASTIC_EXCLUDE_PKI) && !(MESHTASTIC_EXCLUDE_XEDDSA) - // Sign broadcast packets when the Data still fits a LoRa frame with the signature - // attached. This must be the exact encoded-size criterion, not a payload-size - // heuristic: a heuristic band where we sign-then-fail-TOO_LARGE breaks packets that + // Licensed packets stay plaintext, so sign both broadcasts and unicasts. Normal mode + // continues to sign broadcasts only. Use the exact encoded size: a payload-size heuristic + // where we sign-then-fail-TOO_LARGE breaks packets that // were deliverable unsigned, and perhapsDecode() applies the mirror-image rule when // deciding whether an unsigned broadcast from a known signer is a downgrade. - if (!p->pki_encrypted && isBroadcast(p->to) && signedDataFits(&p->decoded)) { + if (!p->pki_encrypted && (owner.is_licensed || isBroadcast(p->to)) && signedDataFits(&p->decoded)) { if (crypto->xeddsa_sign(p->from, p->id, p->decoded.portnum, p->decoded.payload.bytes, p->decoded.payload.size, p->decoded.xeddsa_signature.bytes)) { p->decoded.xeddsa_signature.size = XEDDSA_SIGNATURE_SIZE; diff --git a/src/modules/AdminModule.cpp b/src/modules/AdminModule.cpp index a3ee10b121a..256345f1258 100644 --- a/src/modules/AdminModule.cpp +++ b/src/modules/AdminModule.cpp @@ -731,6 +731,7 @@ void AdminModule::handleGetModuleConfigResponse(const meshtastic_MeshPacket &mp, void AdminModule::handleSetOwner(const meshtastic_User &o) { int changed = 0; + bool identityGenerated = false; if (*o.long_name) { // Apps built against the older 39-byte limit may send longer names; clamp @@ -757,6 +758,12 @@ void AdminModule::handleSetOwner(const meshtastic_User &o) if (channels.ensureLicensedOperation()) { sendWarning(licensedModeMessage); } +#if !(MESHTASTIC_EXCLUDE_PKI_KEYGEN || MESHTASTIC_EXCLUDE_PKI) + if ((config.security.private_key.size != 32 || config.security.public_key.size != 32) && + config.lora.region != meshtastic_Config_LoRaConfig_RegionCode_UNSET) { + identityGenerated = nodeDB->generateCryptoKeyPair(); + } +#endif } if (owner.has_is_unmessagable != o.has_is_unmessagable || (o.has_is_unmessagable && owner.is_unmessagable != o.is_unmessagable)) { @@ -767,7 +774,7 @@ void AdminModule::handleSetOwner(const meshtastic_User &o) if (changed) { // If nothing really changed, don't broadcast on the network or write to flash service->reloadOwner(!hasOpenEditTransaction); - saveChanges(SEGMENT_DEVICESTATE | SEGMENT_NODEDATABASE); + saveChanges(SEGMENT_DEVICESTATE | SEGMENT_NODEDATABASE | (identityGenerated ? SEGMENT_CONFIG : 0)); } } @@ -934,7 +941,7 @@ void AdminModule::handleSetConfig(const meshtastic_Config &c, bool fromOthers) // If we're setting region for the first time, init the region and regenerate the keys if (isRegionUnset && validatedLora.region > meshtastic_Config_LoRaConfig_RegionCode_UNSET) { #if !(MESHTASTIC_EXCLUDE_PKI_KEYGEN || MESHTASTIC_EXCLUDE_PKI) - if (crypto) { + if (crypto && !owner.is_licensed) { crypto->ensurePkiKeys(config.security, owner); } #endif @@ -947,6 +954,11 @@ void AdminModule::handleSetConfig(const meshtastic_Config &c, bool fromOthers) } // Ensure initRegion() uses the newly validated region config.lora.region = validatedLora.region; +#if !(MESHTASTIC_EXCLUDE_PKI_KEYGEN || MESHTASTIC_EXCLUDE_PKI) + if (owner.is_licensed && isRegionUnset && validatedLora.region > meshtastic_Config_LoRaConfig_RegionCode_UNSET) { + nodeDB->generateCryptoKeyPair(); + } +#endif initRegion(); if (getEffectiveDutyCycle() < 100) { validatedLora.ignore_mqtt = true; // Ignore MQTT by default if region has a duty cycle limit diff --git a/src/modules/NodeInfoModule.cpp b/src/modules/NodeInfoModule.cpp index c86c54aff34..6c68168a793 100644 --- a/src/modules/NodeInfoModule.cpp +++ b/src/modules/NodeInfoModule.cpp @@ -167,14 +167,8 @@ meshtastic_MeshPacket *NodeInfoModule::allocReply() ignoreRequest = true; return NULL; } else { - ignoreRequest = false; // Don't ignore requests anymore - meshtastic_User u = owner; // deliberate copy: the licensed strip below must not clobber the global owner state - - // Strip the public key if the user is licensed - if (u.is_licensed && u.public_key.size > 0) { - memset(u.public_key.bytes, 0, sizeof(u.public_key.bytes)); - u.public_key.size = 0; - } + ignoreRequest = false; // Don't ignore requests anymore + meshtastic_User u = owner; // FIXME: Clear the user.id field since it should be derived from node number on the receiving end // u.id[0] = '\0'; @@ -232,4 +226,4 @@ int32_t NodeInfoModule::runOnce() sendOurNodeInfo(NODENUM_BROADCAST, requestReplies); // Send our info (don't request replies) } return Default::getConfiguredOrDefaultMs(config.device.node_info_broadcast_secs, default_node_info_broadcast_secs); -} \ No newline at end of file +} diff --git a/test/test_packet_signing/test_main.cpp b/test/test_packet_signing/test_main.cpp index 707d716195b..ecdd26bff40 100644 --- a/test/test_packet_signing/test_main.cpp +++ b/test/test_packet_signing/test_main.cpp @@ -12,7 +12,9 @@ // Group E decoded-ingress policy (checkXeddsaReceivePolicy, the plaintext-MQTT trust boundary) #include "MeshTypes.h" // include BEFORE TestUtil.h +#include "NodeStatus.h" #include "TestUtil.h" +#include "airtime.h" #include // The whole suite exercises XEdDSA sign/verify and checkXeddsaReceivePolicy, all of which are @@ -27,6 +29,7 @@ #include #include #include +#include #include #include @@ -343,7 +346,7 @@ void test_B1_local_broadcast_is_signed(void) TEST_ASSERT_TRUE(p.xeddsa_signed); } -// B2: our own unicast is NOT signed. +// B2: our own normal-mode unicast is NOT signed. void test_B2_local_unicast_not_signed(void) { mockNodeDB->addNode(REMOTE_NODE); @@ -351,7 +354,7 @@ void test_B2_local_unicast_not_signed(void) meshtastic_MeshPacket p = makeDecoded(LOCAL_NODE, REMOTE_NODE, meshtastic_PortNum_PRIVATE_APP, SMALL_PAYLOAD); TEST_ASSERT_EQUAL(DECODE_SUCCESS, roundTrip(&p)); - TEST_ASSERT_EQUAL_MESSAGE(0, p.decoded.xeddsa_signature.size, "unicast must not be signed"); + TEST_ASSERT_EQUAL_MESSAGE(0, p.decoded.xeddsa_signature.size, "normal unicast must not be signed"); } // B3: our own oversized broadcast is NOT signed (signature wouldn't fit). @@ -454,6 +457,115 @@ void test_B6_rich_shape_sweep_no_deadband(void) TEST_ASSERT_TRUE_MESSAGE(sawUnsigned, "rich sweep never crossed the fit boundary"); } +// B7: licensed operation signs both plaintext broadcasts and direct messages. +void test_B7_licensed_broadcast_and_unicast_are_signed(void) +{ + uint8_t pub[32], priv[32]; + crypto->generateKeyPair(pub, priv); + mockNodeDB->addNode(LOCAL_NODE); + mockNodeDB->setPublicKey(LOCAL_NODE, pub); + owner.is_licensed = true; + channels.ensureLicensedOperation(); + + meshtastic_MeshPacket broadcast = + makeDecoded(LOCAL_NODE, NODENUM_BROADCAST, meshtastic_PortNum_TEXT_MESSAGE_APP, SMALL_PAYLOAD); + TEST_ASSERT_EQUAL(DECODE_SUCCESS, roundTrip(&broadcast)); + TEST_ASSERT_EQUAL(XEDDSA_SIGNATURE_SIZE, broadcast.decoded.xeddsa_signature.size); + TEST_ASSERT_TRUE(broadcast.xeddsa_signed); + + meshtastic_MeshPacket direct = makeDecoded(LOCAL_NODE, REMOTE_NODE, meshtastic_PortNum_TEXT_MESSAGE_APP, SMALL_PAYLOAD); + TEST_ASSERT_EQUAL(DECODE_SUCCESS, roundTrip(&direct)); + TEST_ASSERT_EQUAL(XEDDSA_SIGNATURE_SIZE, direct.decoded.xeddsa_signature.size); + TEST_ASSERT_TRUE(direct.xeddsa_signed); +} + +// B8: even with both identity keys present, licensed direct messages use the plaintext channel path. +void test_B8_licensed_unicast_never_uses_pki_encryption(void) +{ + uint8_t localPub[32], localPriv[32], remotePub[32], remotePriv[32]; + crypto->generateKeyPair(localPub, localPriv); + memcpy(config.security.private_key.bytes, localPriv, sizeof(localPriv)); + config.security.private_key.size = sizeof(localPriv); + mockNodeDB->addNode(LOCAL_NODE); + mockNodeDB->setPublicKey(LOCAL_NODE, localPub); + mockNodeDB->addNode(REMOTE_NODE); + crypto->generateKeyPair(remotePub, remotePriv); + mockNodeDB->setPublicKey(REMOTE_NODE, remotePub); + crypto->setDHPrivateKey(localPriv); + + owner.is_licensed = true; + channels.ensureLicensedOperation(); + meshtastic_MeshPacket p = makeDecoded(LOCAL_NODE, REMOTE_NODE, meshtastic_PortNum_TEXT_MESSAGE_APP, SMALL_PAYLOAD); + + TEST_ASSERT_EQUAL(meshtastic_Routing_Error_NONE, perhapsEncode(&p)); + TEST_ASSERT_FALSE(p.pki_encrypted); + meshtastic_Data plaintext = meshtastic_Data_init_zero; + TEST_ASSERT_TRUE_MESSAGE(pb_decode_from_bytes(p.encrypted.bytes, p.encrypted.size, &meshtastic_Data_msg, &plaintext), + "licensed channel payload must remain plaintext"); + TEST_ASSERT_EQUAL(XEDDSA_SIGNATURE_SIZE, plaintext.xeddsa_signature.size); +} + +// B9: licensed direct messages remain deliverable unsigned when the existing signature will not fit. +void test_B9_licensed_oversized_unicast_remains_unsigned(void) +{ + owner.is_licensed = true; + channels.ensureLicensedOperation(); + meshtastic_MeshPacket p = makeDecoded(LOCAL_NODE, REMOTE_NODE, meshtastic_PortNum_TEXT_MESSAGE_APP, OVERSIZED_PAYLOAD); + + TEST_ASSERT_EQUAL(DECODE_SUCCESS, roundTrip(&p)); + TEST_ASSERT_EQUAL(0, p.decoded.xeddsa_signature.size); +} + +// B10: normal-mode direct messages retain their existing PKI encryption behavior. +void test_B10_normal_unicast_still_uses_pki(void) +{ + uint8_t localPub[32], localPriv[32], remotePub[32], remotePriv[32]; + crypto->generateKeyPair(localPub, localPriv); + crypto->generateKeyPair(remotePub, remotePriv); + mockNodeDB->addNode(LOCAL_NODE); + mockNodeDB->setPublicKey(LOCAL_NODE, localPub); + mockNodeDB->addNode(REMOTE_NODE); + mockNodeDB->setPublicKey(REMOTE_NODE, remotePub); + memcpy(config.security.private_key.bytes, localPriv, sizeof(localPriv)); + config.security.private_key.size = sizeof(localPriv); + crypto->setDHPrivateKey(localPriv); + + meshtastic_MeshPacket p = makeDecoded(LOCAL_NODE, REMOTE_NODE, meshtastic_PortNum_TEXT_MESSAGE_APP, SMALL_PAYLOAD); + TEST_ASSERT_EQUAL(meshtastic_Routing_Error_NONE, perhapsEncode(&p)); + TEST_ASSERT_TRUE(p.pki_encrypted); + + myNodeInfo.my_node_num = REMOTE_NODE; + crypto->setDHPrivateKey(remotePriv); + TEST_ASSERT_EQUAL(DECODE_SUCCESS, perhapsDecode(&p)); + TEST_ASSERT_TRUE(p.pki_encrypted); + TEST_ASSERT_EQUAL(0, p.decoded.xeddsa_signature.size); +} + +// B11: publishing a licensed node's key must not make inbound PKI decryption reachable. +void test_B11_licensed_receiver_does_not_decrypt_pki(void) +{ + uint8_t localPub[32], localPriv[32], remotePub[32], remotePriv[32]; + crypto->generateKeyPair(localPub, localPriv); + crypto->generateKeyPair(remotePub, remotePriv); + mockNodeDB->addNode(LOCAL_NODE); + mockNodeDB->setPublicKey(LOCAL_NODE, localPub); + mockNodeDB->addNode(REMOTE_NODE); + mockNodeDB->setPublicKey(REMOTE_NODE, remotePub); + memcpy(config.security.private_key.bytes, localPriv, sizeof(localPriv)); + config.security.private_key.size = sizeof(localPriv); + crypto->setDHPrivateKey(localPriv); + + meshtastic_MeshPacket p = makeDecoded(LOCAL_NODE, REMOTE_NODE, meshtastic_PortNum_TEXT_MESSAGE_APP, SMALL_PAYLOAD); + TEST_ASSERT_EQUAL(meshtastic_Routing_Error_NONE, perhapsEncode(&p)); + TEST_ASSERT_TRUE(p.pki_encrypted); + + owner.is_licensed = true; + channels.ensureLicensedOperation(); + myNodeInfo.my_node_num = REMOTE_NODE; + crypto->setDHPrivateKey(remotePriv); + TEST_ASSERT_EQUAL(DECODE_FAILURE, perhapsDecode(&p)); +} + // =========================================================================== // Group C - NodeInfoModule downgrade drop (broadcast-only backstop for ingress paths that skip // Router's check; unicast NodeInfo is never signed by senders, so it is exempt - see C4) @@ -461,9 +573,52 @@ void test_B6_rich_shape_sweep_no_deadband(void) class NodeInfoTestShim : public NodeInfoModule { public: + using NodeInfoModule::allocReply; using NodeInfoModule::handleReceivedProtobuf; // protected virtual -> exposed for direct call }; +// C0: licensed NodeInfo publishes the same public identity key used to verify plaintext signatures. +void test_C0_licensed_nodeinfo_publishes_public_key(void) +{ + owner.is_licensed = true; + owner.public_key.size = 32; + memset(owner.public_key.bytes, 0x5A, owner.public_key.size); + + NodeInfoTestShim shim; + meshtastic_MeshPacket *reply = shim.allocReply(); + TEST_ASSERT_NOT_NULL(reply); + meshtastic_User published = meshtastic_User_init_zero; + TEST_ASSERT_TRUE( + pb_decode_from_bytes(reply->decoded.payload.bytes, reply->decoded.payload.size, &meshtastic_User_msg, &published)); + TEST_ASSERT_TRUE(published.is_licensed); + TEST_ASSERT_EQUAL(32, published.public_key.size); + TEST_ASSERT_EQUAL_UINT8_ARRAY(owner.public_key.bytes, published.public_key.bytes, 32); + packetPool.release(reply); +} + +// C00: a pre-signing licensed install creates one identity and derives the same public key after reload. +void test_C00_licensed_identity_key_is_generated_and_preserved(void) +{ + owner.is_licensed = true; + config.lora.region = meshtastic_Config_LoRaConfig_RegionCode_US; + config.security = meshtastic_Config_SecurityConfig_init_zero; + + TEST_ASSERT_TRUE(mockNodeDB->generateCryptoKeyPair()); + uint8_t privateKey[32], publicKey[32]; + memcpy(privateKey, config.security.private_key.bytes, sizeof(privateKey)); + memcpy(publicKey, config.security.public_key.bytes, sizeof(publicKey)); + const NodeNum migratedNodeNum = myNodeInfo.my_node_num; + TEST_ASSERT_NOT_EQUAL(LOCAL_NODE, migratedNodeNum); + + config.security.public_key.size = 0; + owner.public_key.size = 0; + TEST_ASSERT_TRUE(mockNodeDB->generateCryptoKeyPair()); + TEST_ASSERT_EQUAL(migratedNodeNum, myNodeInfo.my_node_num); + TEST_ASSERT_EQUAL_UINT8_ARRAY(privateKey, config.security.private_key.bytes, sizeof(privateKey)); + TEST_ASSERT_EQUAL_UINT8_ARRAY(publicKey, config.security.public_key.bytes, sizeof(publicKey)); + TEST_ASSERT_EQUAL_UINT8_ARRAY(publicKey, owner.public_key.bytes, sizeof(publicKey)); +} + static meshtastic_MeshPacket makeNodeInfoPacket(bool signed_) { // Broadcast so the module's phone-forward path (which needs `service`) is skipped. @@ -688,6 +843,10 @@ void test_E9_decoded_partial_signature_from_nonsigner_dropped(void) void setup() { initializeTestEnvironment(); + AirTime testAirTime; + meshtastic::NodeStatus testNodeStatus; + airTime = &testAirTime; + nodeStatus = &testNodeStatus; UNITY_BEGIN(); printf("\n=== Group A: receive-side accept/reject ===\n"); @@ -708,8 +867,15 @@ void setup() RUN_TEST(test_B4_all_broadcast_sizes_deliverable_no_deadband); RUN_TEST(test_B5_preset_signature_on_local_packet_cleared); RUN_TEST(test_B6_rich_shape_sweep_no_deadband); + RUN_TEST(test_B7_licensed_broadcast_and_unicast_are_signed); + RUN_TEST(test_B8_licensed_unicast_never_uses_pki_encryption); + RUN_TEST(test_B9_licensed_oversized_unicast_remains_unsigned); + RUN_TEST(test_B10_normal_unicast_still_uses_pki); + RUN_TEST(test_B11_licensed_receiver_does_not_decrypt_pki); printf("\n=== Group C: NodeInfoModule downgrade drop ===\n"); + RUN_TEST(test_C0_licensed_nodeinfo_publishes_public_key); + RUN_TEST(test_C00_licensed_identity_key_is_generated_and_preserved); RUN_TEST(test_C1_unsigned_nodeinfo_from_signer_dropped); RUN_TEST(test_C2_signed_nodeinfo_from_signer_not_dropped); RUN_TEST(test_C3_unsigned_nodeinfo_from_nonsigner_not_dropped); @@ -729,7 +895,10 @@ void setup() RUN_TEST(test_E8_decoded_partial_signature_from_signer_dropped); RUN_TEST(test_E9_decoded_partial_signature_from_nonsigner_dropped); - exit(UNITY_END()); + const int result = UNITY_END(); + airTime = nullptr; + nodeStatus = nullptr; + exit(result); } void loop() {} From 82ad09aaee89b22c462391acda7835fcbdd81328 Mon Sep 17 00:00:00 2001 From: Benjamin Faershtein <119711889+RCGV1@users.noreply.github.com> Date: Thu, 9 Jul 2026 18:08:30 -0700 Subject: [PATCH 03/15] docs: clarify routing auth gate contract --- src/mesh/Router.h | 22 +++------------------- 1 file changed, 3 insertions(+), 19 deletions(-) diff --git a/src/mesh/Router.h b/src/mesh/Router.h index 5711ab4e590..74824be0a73 100644 --- a/src/mesh/Router.h +++ b/src/mesh/Router.h @@ -175,10 +175,7 @@ enum class RoutingAuthVerdict { ACCEPT, OPAQUE_RELAY_ONLY, REJECT }; */ DecodeState perhapsDecode(meshtastic_MeshPacket *p); -/** Apply the receive authentication policy before routing state is mutated. - * Decryptable packets must pass their configured authenticity policy. Packets for unknown channels - * remain eligible for opaque relay, while fatal and policy-rejected packets are filtered. - */ +/** Apply receive authentication before routing state mutation; unknown-channel packets may remain opaque relay-only. */ RoutingAuthVerdict passesRoutingAuthGate(meshtastic_MeshPacket *p); #ifdef PIO_UNIT_TESTING uint32_t routingAuthEvaluationCount(); @@ -190,21 +187,8 @@ void resetRoutingAuthEvaluationCount(); meshtastic_Routing_Error perhapsEncode(meshtastic_MeshPacket *p); #if !(MESHTASTIC_EXCLUDE_PKI) && !(MESHTASTIC_EXCLUDE_XEDDSA) -/** XEdDSA receive-side signature policy. Valid signatures are verified against a cached key or an - * identity-bound key in first-contact NodeInfo. Invalid and malformed signatures always fail. - * Compatible accepts unsigned traffic, Balanced rejects signable unsigned broadcasts from known - * signers, and Strict requires a verified existing signature or successful PKI authentication - * for all decoded traffic. - * - * encodedDataSize is the wire size of the encoded Data as the sender built it; pass 0 to size - * p->decoded canonically instead (for already-decoded ingress such as plaintext-MQTT downlink, - * which bypasses perhapsDecode's crypto path). - * - * The caller MUST hold cryptLock: verification runs through the shared CryptoEngine key cache. - * (perhapsDecode already holds it; other call sites must take it themselves.) - * - * @return false if the packet must be dropped. - */ +/** Enforce the configured XEdDSA receive policy; zero encodedDataSize derives it canonically. + * The caller must hold cryptLock. Returns false when the packet must be dropped. */ bool checkXeddsaReceivePolicy(meshtastic_MeshPacket *p, size_t encodedDataSize = 0); #endif From 449f1726b06c050608b44d7fe30eff5bb52947ce Mon Sep 17 00:00:00 2001 From: Benjamin Faershtein <119711889+RCGV1@users.noreply.github.com> Date: Thu, 9 Jul 2026 18:22:55 -0700 Subject: [PATCH 04/15] fix(security): persist licensed channel sanitation --- src/mesh/Channels.cpp | 14 ++--- src/mesh/NodeDB.cpp | 7 ++- src/mesh/NodeDB.h | 1 + src/modules/AdminModule.cpp | 47 ++++++++++++++--- src/modules/AdminModule.h | 8 ++- test/support/AdminModuleTestShim.h | 2 + test/test_admin_radio/test_main.cpp | 69 ++++++++++++++++++++++++ test/test_packet_signing/test_main.cpp | 72 ++++++++++++++++++++++++++ 8 files changed, 204 insertions(+), 16 deletions(-) diff --git a/src/mesh/Channels.cpp b/src/mesh/Channels.cpp index b086f5e6b3e..1cebcce1732 100644 --- a/src/mesh/Channels.cpp +++ b/src/mesh/Channels.cpp @@ -128,11 +128,13 @@ bool Channels::ensureLicensedOperation() } auto &channelSettings = channel.settings; if (strcasecmp(channelSettings.name, Channels::adminChannel) == 0) { - channel.role = meshtastic_Channel_Role_DISABLED; - channelSettings.psk.bytes[0] = 0; - channelSettings.psk.size = 0; - hasEncryptionOrAdmin = true; - channels.setChannel(channel); + if (channel.role != meshtastic_Channel_Role_DISABLED || channelSettings.psk.size > 0) { + channel.role = meshtastic_Channel_Role_DISABLED; + channelSettings.psk.bytes[0] = 0; + channelSettings.psk.size = 0; + hasEncryptionOrAdmin = true; + channels.setChannel(channel); + } } else if (channelSettings.psk.size > 0) { channelSettings.psk.bytes[0] = 0; @@ -563,4 +565,4 @@ bool Channels::setDefaultPresetCryptoForHash(ChannelHash channelHash) int16_t Channels::setActiveByIndex(ChannelIndex channelIndex) { return setCrypto(channelIndex); -} \ No newline at end of file +} diff --git a/src/mesh/NodeDB.cpp b/src/mesh/NodeDB.cpp index 2c676c7078b..7360db118cd 100644 --- a/src/mesh/NodeDB.cpp +++ b/src/mesh/NodeDB.cpp @@ -860,7 +860,7 @@ void NodeDB::installDefaultNodeDatabase() void NodeDB::installDefaultConfig(bool preserveKey = false) { uint8_t private_key_temp[32]; - bool shouldPreserveKey = preserveKey && config.has_security && config.security.private_key.size > 0; + bool shouldPreserveKey = preserveKey && config.has_security && config.security.private_key.size == 32; if (shouldPreserveKey) { memcpy(private_key_temp, config.security.private_key.bytes, config.security.private_key.size); } @@ -2599,6 +2599,11 @@ void NodeDB::loadFromDisk() moduleConfig.version = POSITION_TELEMETRY_OPTIN_VER; saveToDisk(SEGMENT_MODULECONFIG); } + + if (channels.ensureLicensedOperation()) { + LOG_WARN("Licensed operation removed persisted channel encryption/admin access"); + saveToDisk(SEGMENT_CHANNELS); + } #if ARCH_PORTDUINO // set any config overrides if (portduino_config.has_configDisplayMode) { diff --git a/src/mesh/NodeDB.h b/src/mesh/NodeDB.h index 8aa32dcc75a..a54f5b9d201 100644 --- a/src/mesh/NodeDB.h +++ b/src/mesh/NodeDB.h @@ -545,6 +545,7 @@ class NodeDB // Grant the unit-test shim access to the private maintenance paths below // (migration / cleanup / eviction) without relaxing production access. friend class NodeDBTestShim; + friend class MockNodeDB; #endif /// purge db entries without user info diff --git a/src/modules/AdminModule.cpp b/src/modules/AdminModule.cpp index 256345f1258..7288f4689e8 100644 --- a/src/modules/AdminModule.cpp +++ b/src/modules/AdminModule.cpp @@ -8,6 +8,7 @@ #include "SPILock.h" #include "input/InputBroker.h" #include "meshUtils.h" +#include #include #include // for better whitespace handling #if defined(ARCH_ESP32) && !MESHTASTIC_EXCLUDE_WIFI @@ -62,6 +63,17 @@ AdminModule *adminModule; bool hasOpenEditTransaction; +#if !(MESHTASTIC_EXCLUDE_PKI_KEYGEN || MESHTASTIC_EXCLUDE_PKI) +static bool licensedIdentityWillMigrate() +{ + if (config.security.private_key.size != 32 || config.security.public_key.size != 32) + return true; + if (nodeDB->checkLowEntropyPublicKey(config.security.public_key)) + return true; + return crc32Buffer(config.security.public_key.bytes, config.security.public_key.size) != nodeDB->getNodeNum(); +} +#endif + /// A special reserved string to indicate strings we can not share with external nodes. We will use this 'reserved' word instead. /// Also, to make setting work correctly, if someone tries to set a string to this reserved value we assume they don't really want /// a change. @@ -731,7 +743,8 @@ void AdminModule::handleGetModuleConfigResponse(const meshtastic_MeshPacket &mp, void AdminModule::handleSetOwner(const meshtastic_User &o) { int changed = 0; - bool identityGenerated = false; + bool identityUpdated = false; + bool channelsSanitized = false; if (*o.long_name) { // Apps built against the older 39-byte limit may send longer names; clamp @@ -750,21 +763,25 @@ void AdminModule::handleSetOwner(const meshtastic_User &o) owner.short_name[sizeof(owner.short_name) - 1] = '\0'; sanitizeUtf8(owner.short_name, sizeof(owner.short_name)); } - snprintf(owner.id, sizeof(owner.id), "!%08x", nodeDB->getNodeNum()); - if (owner.is_licensed != o.is_licensed) { changed = 1; +#if !(MESHTASTIC_EXCLUDE_PKI_KEYGEN || MESHTASTIC_EXCLUDE_PKI) + const bool identityWillMigrate = + o.is_licensed && config.lora.region != meshtastic_Config_LoRaConfig_RegionCode_UNSET && licensedIdentityWillMigrate(); + if (identityWillMigrate) + sendWarning(licensedIdentityMigrationMessage); +#endif owner.is_licensed = o.is_licensed; if (channels.ensureLicensedOperation()) { sendWarning(licensedModeMessage); + channelsSanitized = true; } #if !(MESHTASTIC_EXCLUDE_PKI_KEYGEN || MESHTASTIC_EXCLUDE_PKI) - if ((config.security.private_key.size != 32 || config.security.public_key.size != 32) && - config.lora.region != meshtastic_Config_LoRaConfig_RegionCode_UNSET) { - identityGenerated = nodeDB->generateCryptoKeyPair(); - } + if (owner.is_licensed && config.lora.region != meshtastic_Config_LoRaConfig_RegionCode_UNSET) + identityUpdated = nodeDB->generateCryptoKeyPair(); #endif } + snprintf(owner.id, sizeof(owner.id), "!%08x", nodeDB->getNodeNum()); if (owner.has_is_unmessagable != o.has_is_unmessagable || (o.has_is_unmessagable && owner.is_unmessagable != o.is_unmessagable)) { changed = 1; @@ -774,7 +791,8 @@ void AdminModule::handleSetOwner(const meshtastic_User &o) if (changed) { // If nothing really changed, don't broadcast on the network or write to flash service->reloadOwner(!hasOpenEditTransaction); - saveChanges(SEGMENT_DEVICESTATE | SEGMENT_NODEDATABASE | (identityGenerated ? SEGMENT_CONFIG : 0)); + saveChanges(SEGMENT_DEVICESTATE | SEGMENT_NODEDATABASE | (identityUpdated ? SEGMENT_CONFIG : 0) | + (channelsSanitized ? SEGMENT_CHANNELS : 0)); } } @@ -956,6 +974,8 @@ void AdminModule::handleSetConfig(const meshtastic_Config &c, bool fromOthers) config.lora.region = validatedLora.region; #if !(MESHTASTIC_EXCLUDE_PKI_KEYGEN || MESHTASTIC_EXCLUDE_PKI) if (owner.is_licensed && isRegionUnset && validatedLora.region > meshtastic_Config_LoRaConfig_RegionCode_UNSET) { + if (licensedIdentityWillMigrate()) + sendWarning(licensedIdentityMigrationMessage); nodeDB->generateCryptoKeyPair(); } #endif @@ -1694,6 +1714,9 @@ void AdminModule::reboot(int32_t seconds) void AdminModule::saveChanges(int saveWhat, bool shouldReboot) { +#ifdef PIO_UNIT_TESTING + lastSaveWhatForTest = saveWhat; +#endif if (!hasOpenEditTransaction) { LOG_INFO("Save changes to disk"); service->reloadConfig(saveWhat); // Calls saveToDisk among other things @@ -1747,6 +1770,14 @@ void AdminModule::handleSetHamMode(const meshtastic_HamParameters &p) config.device.rebroadcast_mode = meshtastic_Config_DeviceConfig_RebroadcastMode_LOCAL_ONLY; // Remove PSK of primary channel for plaintext amateur usage +#if !(MESHTASTIC_EXCLUDE_PKI_KEYGEN || MESHTASTIC_EXCLUDE_PKI) + if (config.lora.region != meshtastic_Config_LoRaConfig_RegionCode_UNSET) { + if (licensedIdentityWillMigrate()) + sendWarning(licensedIdentityMigrationMessage); + nodeDB->generateCryptoKeyPair(); + } +#endif + if (channels.ensureLicensedOperation()) { sendWarning(licensedModeMessage); } diff --git a/src/modules/AdminModule.h b/src/modules/AdminModule.h index 468e020eaf0..611d696d472 100644 --- a/src/modules/AdminModule.h +++ b/src/modules/AdminModule.h @@ -39,6 +39,9 @@ class AdminModule : public ProtobufModule, public Obser private: bool hasOpenEditTransaction = false; +#ifdef PIO_UNIT_TESTING + int lastSaveWhatForTest = 0; +#endif uint8_t session_passkey[8] = {0}; uint session_time = 0; @@ -94,9 +97,12 @@ class AdminModule : public ProtobufModule, public Obser static constexpr const char *licensedModeMessage = "Licensed mode activated, removing admin channel and encryption from all channels"; +static constexpr const char *licensedIdentityMigrationMessage = + "Licensed signing requires an identity key; this node identity will change after key generation"; + static constexpr const char *publicChannelPrecisionMessage = "Precise position is not allowed on a public (open / known-key) channel; reduced to coarse precision"; extern AdminModule *adminModule; -void disableBluetooth(); \ No newline at end of file +void disableBluetooth(); diff --git a/test/support/AdminModuleTestShim.h b/test/support/AdminModuleTestShim.h index 7dba0677e9b..71fa268bfe4 100644 --- a/test/support/AdminModuleTestShim.h +++ b/test/support/AdminModuleTestShim.h @@ -10,9 +10,11 @@ class AdminModuleTestShim : public AdminModule using AdminModule::handleReceivedProtobuf; using AdminModule::handleSetConfig; using AdminModule::handleSetModuleConfig; + using AdminModule::handleSetOwner; // With an "open edit transaction" saveChanges() is a pure no-op: no reloadConfig/saveToDisk/reboot. void deferSaves() { hasOpenEditTransaction = true; } + int savedSegments() const { return lastSaveWhatForTest; } // Setters may allocate an error reply from packetPool; drain it each iteration or the pool leaks. void drainReply() diff --git a/test/test_admin_radio/test_main.cpp b/test/test_admin_radio/test_main.cpp index 8a839f9345c..4dc3d49d48c 100644 --- a/test/test_admin_radio/test_main.cpp +++ b/test/test_admin_radio/test_main.cpp @@ -11,6 +11,7 @@ * 6. Channel spacing calculation (placeholder for future protobuf changes) */ +#include "Channels.h" #include "DisplayFormatters.h" #include "MeshRadio.h" #include "MeshService.h" @@ -18,6 +19,9 @@ #include "RadioInterface.h" #include "TestUtil.h" #include "modules/AdminModule.h" +#include "modules/NodeInfoModule.h" +#include +#include #include #include "meshtastic/config.pb.h" @@ -928,6 +932,69 @@ static void test_channelSpacingCalculation_placeholder() // AdminModuleTestShim comes from test/support - the friend seam AdminModule.h declares. static AdminModuleTestShim *testAdmin; +static void installEncryptedAndAdminChannels() +{ + channels.initDefaults(); + meshtastic_Channel admin = meshtastic_Channel_init_zero; + admin.index = 1; + admin.role = meshtastic_Channel_Role_SECONDARY; + admin.has_settings = true; + strncpy(admin.settings.name, Channels::adminChannel, sizeof(admin.settings.name)); + admin.settings.psk.size = 16; + memset(admin.settings.psk.bytes, 0xA5, admin.settings.psk.size); + channels.setChannel(admin); +} + +static void assertLicensedChannelsSanitized() +{ + TEST_ASSERT_EQUAL(0, channels.getByIndex(0).settings.psk.size); + TEST_ASSERT_EQUAL(meshtastic_Channel_Role_DISABLED, channels.getByIndex(1).role); + TEST_ASSERT_EQUAL(0, channels.getByIndex(1).settings.psk.size); +} + +static void test_handleSetOwner_persistsLicensedChannelSanitation() +{ + NodeDB *savedNodeDB = nodeDB; + nodeDB = new NodeDB(); + owner = meshtastic_User_init_zero; + config.lora.region = meshtastic_Config_LoRaConfig_RegionCode_UNSET; + installEncryptedAndAdminChannels(); + + meshtastic_User licensed = meshtastic_User_init_zero; + licensed.is_licensed = true; + testAdmin->deferSaves(); + NodeInfoModule *savedNodeInfoModule = nodeInfoModule; + nodeInfoModule = reinterpret_cast(1); // reloadOwner(false) only checks presence + testAdmin->handleSetOwner(licensed); + nodeInfoModule = savedNodeInfoModule; + + TEST_ASSERT_TRUE(testAdmin->savedSegments() & SEGMENT_CHANNELS); + assertLicensedChannelsSanitized(); + + uint8_t encoded[meshtastic_ChannelFile_size]; + const size_t encodedSize = pb_encode_to_bytes(encoded, sizeof(encoded), &meshtastic_ChannelFile_msg, &channelFile); + TEST_ASSERT_GREATER_THAN(0, encodedSize); + meshtastic_ChannelFile reloaded = meshtastic_ChannelFile_init_zero; + TEST_ASSERT_TRUE(pb_decode_from_bytes(encoded, encodedSize, &meshtastic_ChannelFile_msg, &reloaded)); + channelFile = reloaded; + assertLicensedChannelsSanitized(); + TEST_ASSERT_FALSE_MESSAGE(channels.ensureLicensedOperation(), "sanitized reload must not trigger another persistence write"); + + delete nodeDB; + nodeDB = savedNodeDB; +} + +static void test_bootDefense_sanitizesStaleLicensedChannelsOnce() +{ + owner = meshtastic_User_init_zero; + owner.is_licensed = true; + installEncryptedAndAdminChannels(); + + TEST_ASSERT_TRUE(channels.ensureLicensedOperation()); + assertLicensedChannelsSanitized(); + TEST_ASSERT_FALSE_MESSAGE(channels.ensureLicensedOperation(), "boot sanitation must be idempotent"); +} + static meshtastic_Config makeLoraSetConfig(meshtastic_Config_LoRaConfig_RegionCode region, bool usePreset, meshtastic_Config_LoRaConfig_ModemPreset preset) { @@ -1269,6 +1336,8 @@ void setup() UNITY_BEGIN(); // getRegion() + RUN_TEST(test_handleSetOwner_persistsLicensedChannelSanitation); + RUN_TEST(test_bootDefense_sanitizesStaleLicensedChannelsOnce); RUN_TEST(test_getRegion_returnsCorrectRegion_US); RUN_TEST(test_getRegion_returnsCorrectRegion_EU868); RUN_TEST(test_getRegion_returnsCorrectRegion_LORA24); diff --git a/test/test_packet_signing/test_main.cpp b/test/test_packet_signing/test_main.cpp index ecdd26bff40..afd9af6666a 100644 --- a/test/test_packet_signing/test_main.cpp +++ b/test/test_packet_signing/test_main.cpp @@ -51,6 +51,8 @@ static constexpr size_t OVERSIZED_PAYLOAD = 180; class MockNodeDB : public NodeDB { public: + void installDefaultsPreservingIdentity() { installDefaultConfig(true); } + void clearTestNodes() { testNodes.clear(); @@ -566,6 +568,31 @@ void test_B11_licensed_receiver_does_not_decrypt_pki(void) TEST_ASSERT_EQUAL(DECODE_FAILURE, perhapsDecode(&p)); } +void test_B12_licensed_port_and_destination_signing_matrix(void) +{ + uint8_t pub[32], priv[32]; + crypto->generateKeyPair(pub, priv); + mockNodeDB->addNode(LOCAL_NODE); + mockNodeDB->setPublicKey(LOCAL_NODE, pub); + owner.is_licensed = true; + channels.ensureLicensedOperation(); + + const meshtastic_PortNum ports[] = { + meshtastic_PortNum_TEXT_MESSAGE_APP, meshtastic_PortNum_POSITION_APP, meshtastic_PortNum_TELEMETRY_APP, + meshtastic_PortNum_ROUTING_APP, meshtastic_PortNum_NODEINFO_APP, + }; + const NodeNum destinations[] = {NODENUM_BROADCAST, REMOTE_NODE}; + for (const auto port : ports) { + for (const auto destination : destinations) { + meshtastic_MeshPacket packet = makeDecoded(LOCAL_NODE, destination, port, SMALL_PAYLOAD); + TEST_ASSERT_EQUAL(DECODE_SUCCESS, roundTrip(&packet)); + TEST_ASSERT_EQUAL(XEDDSA_SIGNATURE_SIZE, packet.decoded.xeddsa_signature.size); + TEST_ASSERT_TRUE(packet.xeddsa_signed); + TEST_ASSERT_FALSE(packet.pki_encrypted); + } + } +} + // =========================================================================== // Group C - NodeInfoModule downgrade drop (broadcast-only backstop for ingress paths that skip // Router's check; unicast NodeInfo is never signed by senders, so it is exempt - see C4) @@ -619,6 +646,48 @@ void test_C00_licensed_identity_key_is_generated_and_preserved(void) TEST_ASSERT_EQUAL_UINT8_ARRAY(publicKey, owner.public_key.bytes, sizeof(publicKey)); } +void test_C01_factory_config_reset_preserves_valid_identity_private_key(void) +{ + uint8_t publicKey[32], privateKey[32]; + crypto->generateKeyPair(publicKey, privateKey); + config.has_security = true; + config.security.private_key.size = 32; + memcpy(config.security.private_key.bytes, privateKey, sizeof(privateKey)); + + mockNodeDB->installDefaultsPreservingIdentity(); + TEST_ASSERT_EQUAL(32, config.security.private_key.size); + TEST_ASSERT_EQUAL_UINT8_ARRAY(privateKey, config.security.private_key.bytes, sizeof(privateKey)); + TEST_ASSERT_EQUAL(0, config.security.public_key.size); + + owner.is_licensed = true; + config.lora.region = meshtastic_Config_LoRaConfig_RegionCode_US; + TEST_ASSERT_TRUE(mockNodeDB->generateCryptoKeyPair()); + TEST_ASSERT_EQUAL_UINT8_ARRAY(privateKey, config.security.private_key.bytes, sizeof(privateKey)); + TEST_ASSERT_EQUAL_UINT8_ARRAY(publicKey, config.security.public_key.bytes, sizeof(publicKey)); +} + +void test_C02_licensed_low_entropy_identity_is_regenerated(void) +{ + static const uint8_t compromisedPublicKey[32] = { + 0xac, 0xaf, 0x8c, 0x1c, 0x3c, 0x1c, 0x37, 0xac, 0x4f, 0x03, 0xa1, 0xe9, 0xfc, 0x37, 0x23, 0x29, + 0xc8, 0xa3, 0x5d, 0x7f, 0x05, 0x26, 0xeb, 0x00, 0xbd, 0x26, 0xb8, 0x2e, 0xb1, 0x94, 0x7d, 0x24, + }; + owner.is_licensed = true; + config.lora.region = meshtastic_Config_LoRaConfig_RegionCode_US; + config.security.private_key.size = 32; + memset(config.security.private_key.bytes, 0xA5, 32); + config.security.public_key.size = 32; + memcpy(config.security.public_key.bytes, compromisedPublicKey, sizeof(compromisedPublicKey)); + TEST_ASSERT_TRUE(mockNodeDB->checkLowEntropyPublicKey(config.security.public_key)); + + uint8_t oldPrivateKey[32]; + memcpy(oldPrivateKey, config.security.private_key.bytes, sizeof(oldPrivateKey)); + TEST_ASSERT_TRUE(mockNodeDB->generateCryptoKeyPair()); + TEST_ASSERT_TRUE(mockNodeDB->keyIsLowEntropy); + TEST_ASSERT_FALSE(mockNodeDB->checkLowEntropyPublicKey(config.security.public_key)); + TEST_ASSERT_FALSE(memcmp(oldPrivateKey, config.security.private_key.bytes, sizeof(oldPrivateKey)) == 0); +} + static meshtastic_MeshPacket makeNodeInfoPacket(bool signed_) { // Broadcast so the module's phone-forward path (which needs `service`) is skipped. @@ -872,10 +941,13 @@ void setup() RUN_TEST(test_B9_licensed_oversized_unicast_remains_unsigned); RUN_TEST(test_B10_normal_unicast_still_uses_pki); RUN_TEST(test_B11_licensed_receiver_does_not_decrypt_pki); + RUN_TEST(test_B12_licensed_port_and_destination_signing_matrix); printf("\n=== Group C: NodeInfoModule downgrade drop ===\n"); RUN_TEST(test_C0_licensed_nodeinfo_publishes_public_key); RUN_TEST(test_C00_licensed_identity_key_is_generated_and_preserved); + RUN_TEST(test_C01_factory_config_reset_preserves_valid_identity_private_key); + RUN_TEST(test_C02_licensed_low_entropy_identity_is_regenerated); RUN_TEST(test_C1_unsigned_nodeinfo_from_signer_dropped); RUN_TEST(test_C2_signed_nodeinfo_from_signer_not_dropped); RUN_TEST(test_C3_unsigned_nodeinfo_from_nonsigner_not_dropped); From 8009692d48d1df39d94f27831fef1a0b665686d1 Mon Sep 17 00:00:00 2001 From: Benjamin Faershtein <119711889+RCGV1@users.noreply.github.com> Date: Thu, 9 Jul 2026 18:31:01 -0700 Subject: [PATCH 05/15] fix(security): close licensed migration lifecycle gaps --- src/main.cpp | 1 + src/mesh/NodeDB.cpp | 26 +++++++++++++++-- src/mesh/NodeDB.h | 5 ++++ src/modules/AdminModule.cpp | 15 ++++++++-- test/support/MockMeshService.h | 8 +++++- test/test_admin_radio/test_main.cpp | 40 ++++++++++++++++++++++++++ test/test_packet_signing/test_main.cpp | 11 +++++++ 7 files changed, 100 insertions(+), 6 deletions(-) diff --git a/src/main.cpp b/src/main.cpp index 63cbf35ab07..91633fbdb42 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -1062,6 +1062,7 @@ void setup() nodeDB->hasWarned = true; } #endif + nodeDB->notifyPendingLicensedIdentityMigration(); #if !MESHTASTIC_EXCLUDE_INPUTBROKER if (inputBroker) inputBroker->Init(); diff --git a/src/mesh/NodeDB.cpp b/src/mesh/NodeDB.cpp index 7360db118cd..577e7349348 100644 --- a/src/mesh/NodeDB.cpp +++ b/src/mesh/NodeDB.cpp @@ -3925,8 +3925,8 @@ bool NodeDB::generateCryptoKeyPair(const uint8_t *privateKey) LOG_DEBUG("Set DH private key for crypto operations"); crypto->setDHPrivateKey(config.security.private_key.bytes); - // Conditionally create new identity based on parameter - createNewIdentity(); + if (createNewIdentity() && owner.is_licensed) + licensedIdentityMigrationPending = true; } return keygenSuccess; #else @@ -3934,6 +3934,21 @@ bool NodeDB::generateCryptoKeyPair(const uint8_t *privateKey) #endif } +bool NodeDB::notifyPendingLicensedIdentityMigration() +{ + if (!licensedIdentityMigrationPending || !service) + return false; + meshtastic_ClientNotification *notification = clientNotificationPool.allocZeroed(); + if (!notification) + return false; + notification->level = meshtastic_LogRecord_Level_WARNING; + notification->time = getValidTime(RTCQualityFromNet); + snprintf(notification->message, sizeof(notification->message), "%s", LICENSED_IDENTITY_MIGRATION_WARNING); + service->sendClientNotification(notification); + licensedIdentityMigrationPending = false; + return true; +} + bool NodeDB::createNewIdentity() { uint32_t oldNodeNum = getNodeNum(); @@ -4037,6 +4052,13 @@ bool NodeDB::restorePreferences(meshtastic_AdminMessage_BackupLocation location, LOG_DEBUG("Restored channels"); } + if (owner.is_licensed && channels.ensureLicensedOperation()) { + restoreWhat |= SEGMENT_CHANNELS; + LOG_WARN("Licensed operation sanitized restored channel encryption/admin access"); + } + if (restoreWhat & SEGMENT_CHANNELS) + channels.onConfigChanged(); + success = saveToDisk(restoreWhat); if (success) { LOG_INFO("Restored preferences from backup"); diff --git a/src/mesh/NodeDB.h b/src/mesh/NodeDB.h index a54f5b9d201..08840c323f8 100644 --- a/src/mesh/NodeDB.h +++ b/src/mesh/NodeDB.h @@ -74,6 +74,8 @@ static const uint8_t LOW_ENTROPY_HASHES[][32] = { 0x37, 0x82, 0x8d, 0xb2, 0xcc, 0xd8, 0x97, 0x40, 0x9a, 0x5c, 0x8f, 0x40, 0x55, 0xcb, 0x4c, 0x3e}}; static const char LOW_ENTROPY_WARNING[] = "Compromised keys were detected and regenerated."; #endif +static const char LICENSED_IDENTITY_MIGRATION_WARNING[] = + "Licensed signing generated a new identity key; this node identity changed."; /* DeviceState versions used to be defined in the .proto file but really only this function cares. So changed to a #define here. @@ -218,6 +220,7 @@ class NodeDB bool keyIsLowEntropy = false; bool hasWarned = false; + bool licensedIdentityMigrationPending = false; /// don't do mesh based algorithm for node id assignment (initially) /// instead just store in flash - possibly even in the initial alpha release do this hack @@ -471,6 +474,8 @@ class NodeDB /// @param privateKey Optional 32-byte private key to use. If nullptr, generates new random keys. bool generateCryptoKeyPair(const uint8_t *privateKey = nullptr); + bool notifyPendingLicensedIdentityMigration(); + bool createNewIdentity(); bool backupPreferences(meshtastic_AdminMessage_BackupLocation location); diff --git a/src/modules/AdminModule.cpp b/src/modules/AdminModule.cpp index 7288f4689e8..f5c4d8b5b36 100644 --- a/src/modules/AdminModule.cpp +++ b/src/modules/AdminModule.cpp @@ -777,8 +777,11 @@ void AdminModule::handleSetOwner(const meshtastic_User &o) channelsSanitized = true; } #if !(MESHTASTIC_EXCLUDE_PKI_KEYGEN || MESHTASTIC_EXCLUDE_PKI) - if (owner.is_licensed && config.lora.region != meshtastic_Config_LoRaConfig_RegionCode_UNSET) + if (owner.is_licensed && config.lora.region != meshtastic_Config_LoRaConfig_RegionCode_UNSET) { identityUpdated = nodeDB->generateCryptoKeyPair(); + if (identityWillMigrate) + nodeDB->licensedIdentityMigrationPending = false; + } #endif } snprintf(owner.id, sizeof(owner.id), "!%08x", nodeDB->getNodeNum()); @@ -974,9 +977,12 @@ void AdminModule::handleSetConfig(const meshtastic_Config &c, bool fromOthers) config.lora.region = validatedLora.region; #if !(MESHTASTIC_EXCLUDE_PKI_KEYGEN || MESHTASTIC_EXCLUDE_PKI) if (owner.is_licensed && isRegionUnset && validatedLora.region > meshtastic_Config_LoRaConfig_RegionCode_UNSET) { - if (licensedIdentityWillMigrate()) + const bool identityWillMigrate = licensedIdentityWillMigrate(); + if (identityWillMigrate) sendWarning(licensedIdentityMigrationMessage); nodeDB->generateCryptoKeyPair(); + if (identityWillMigrate) + nodeDB->licensedIdentityMigrationPending = false; } #endif initRegion(); @@ -1772,9 +1778,12 @@ void AdminModule::handleSetHamMode(const meshtastic_HamParameters &p) #if !(MESHTASTIC_EXCLUDE_PKI_KEYGEN || MESHTASTIC_EXCLUDE_PKI) if (config.lora.region != meshtastic_Config_LoRaConfig_RegionCode_UNSET) { - if (licensedIdentityWillMigrate()) + const bool identityWillMigrate = licensedIdentityWillMigrate(); + if (identityWillMigrate) sendWarning(licensedIdentityMigrationMessage); nodeDB->generateCryptoKeyPair(); + if (identityWillMigrate) + nodeDB->licensedIdentityMigrationPending = false; } #endif diff --git a/test/support/MockMeshService.h b/test/support/MockMeshService.h index 6bfeed07792..1f863ef23de 100644 --- a/test/support/MockMeshService.h +++ b/test/support/MockMeshService.h @@ -6,5 +6,11 @@ class MockMeshService : public MeshService { public: - void sendClientNotification(meshtastic_ClientNotification *n) override { releaseClientNotificationToPool(n); } + void sendClientNotification(meshtastic_ClientNotification *n) override + { + notificationCount++; + releaseClientNotificationToPool(n); + } + + uint32_t notificationCount = 0; }; diff --git a/test/test_admin_radio/test_main.cpp b/test/test_admin_radio/test_main.cpp index 4dc3d49d48c..28422343a88 100644 --- a/test/test_admin_radio/test_main.cpp +++ b/test/test_admin_radio/test_main.cpp @@ -13,6 +13,7 @@ #include "Channels.h" #include "DisplayFormatters.h" +#include "FSCommon.h" #include "MeshRadio.h" #include "MeshService.h" #include "NodeDB.h" @@ -943,6 +944,15 @@ static void installEncryptedAndAdminChannels() admin.settings.psk.size = 16; memset(admin.settings.psk.bytes, 0xA5, admin.settings.psk.size); channels.setChannel(admin); + + meshtastic_Channel secondary = meshtastic_Channel_init_zero; + secondary.index = 2; + secondary.role = meshtastic_Channel_Role_SECONDARY; + secondary.has_settings = true; + strncpy(secondary.settings.name, "private", sizeof(secondary.settings.name)); + secondary.settings.psk.size = 32; + memset(secondary.settings.psk.bytes, 0x5A, secondary.settings.psk.size); + channels.setChannel(secondary); } static void assertLicensedChannelsSanitized() @@ -950,6 +960,7 @@ static void assertLicensedChannelsSanitized() TEST_ASSERT_EQUAL(0, channels.getByIndex(0).settings.psk.size); TEST_ASSERT_EQUAL(meshtastic_Channel_Role_DISABLED, channels.getByIndex(1).role); TEST_ASSERT_EQUAL(0, channels.getByIndex(1).settings.psk.size); + TEST_ASSERT_EQUAL(0, channels.getByIndex(2).settings.psk.size); } static void test_handleSetOwner_persistsLicensedChannelSanitation() @@ -995,6 +1006,34 @@ static void test_bootDefense_sanitizesStaleLicensedChannelsOnce() TEST_ASSERT_FALSE_MESSAGE(channels.ensureLicensedOperation(), "boot sanitation must be idempotent"); } +static void test_restorePreferences_sanitizesLicensedBackupBeforeReturn() +{ + NodeDB *savedNodeDB = nodeDB; + nodeDB = new NodeDB(); + const meshtastic_DeviceState savedDeviceState = devicestate; + const meshtastic_ChannelFile savedChannelFile = channelFile; + + owner = meshtastic_User_init_zero; + owner.is_licensed = true; + installEncryptedAndAdminChannels(); + TEST_ASSERT_TRUE(nodeDB->backupPreferences(meshtastic_AdminMessage_BackupLocation_FLASH)); + + owner.is_licensed = false; + channels.initDefaults(); + TEST_ASSERT_TRUE( + nodeDB->restorePreferences(meshtastic_AdminMessage_BackupLocation_FLASH, SEGMENT_DEVICESTATE | SEGMENT_CHANNELS)); + TEST_ASSERT_TRUE(owner.is_licensed); + assertLicensedChannelsSanitized(); + TEST_ASSERT_FALSE_MESSAGE(channels.ensureLicensedOperation(), "restored licensed channels must remain sanitized"); + + devicestate = savedDeviceState; + channelFile = savedChannelFile; + nodeDB->saveToDisk(SEGMENT_DEVICESTATE | SEGMENT_CHANNELS); + FSCom.remove(backupFileName); + delete nodeDB; + nodeDB = savedNodeDB; +} + static meshtastic_Config makeLoraSetConfig(meshtastic_Config_LoRaConfig_RegionCode region, bool usePreset, meshtastic_Config_LoRaConfig_ModemPreset preset) { @@ -1338,6 +1377,7 @@ void setup() // getRegion() RUN_TEST(test_handleSetOwner_persistsLicensedChannelSanitation); RUN_TEST(test_bootDefense_sanitizesStaleLicensedChannelsOnce); + RUN_TEST(test_restorePreferences_sanitizesLicensedBackupBeforeReturn); RUN_TEST(test_getRegion_returnsCorrectRegion_US); RUN_TEST(test_getRegion_returnsCorrectRegion_EU868); RUN_TEST(test_getRegion_returnsCorrectRegion_LORA24); diff --git a/test/test_packet_signing/test_main.cpp b/test/test_packet_signing/test_main.cpp index afd9af6666a..713c359caba 100644 --- a/test/test_packet_signing/test_main.cpp +++ b/test/test_packet_signing/test_main.cpp @@ -15,6 +15,7 @@ #include "NodeStatus.h" #include "TestUtil.h" #include "airtime.h" +#include "support/MockMeshService.h" #include // The whole suite exercises XEdDSA sign/verify and checkXeddsaReceivePolicy, all of which are @@ -636,6 +637,16 @@ void test_C00_licensed_identity_key_is_generated_and_preserved(void) memcpy(publicKey, config.security.public_key.bytes, sizeof(publicKey)); const NodeNum migratedNodeNum = myNodeInfo.my_node_num; TEST_ASSERT_NOT_EQUAL(LOCAL_NODE, migratedNodeNum); + TEST_ASSERT_TRUE(mockNodeDB->licensedIdentityMigrationPending); + + MockMeshService mockService; + service = &mockService; + TEST_ASSERT_TRUE(mockNodeDB->notifyPendingLicensedIdentityMigration()); + TEST_ASSERT_EQUAL(1, mockService.notificationCount); + TEST_ASSERT_FALSE(mockNodeDB->licensedIdentityMigrationPending); + TEST_ASSERT_FALSE(mockNodeDB->notifyPendingLicensedIdentityMigration()); + TEST_ASSERT_EQUAL(1, mockService.notificationCount); + service = nullptr; config.security.public_key.size = 0; owner.public_key.size = 0; From 0dd414d2069e280485d2ef6bca977dd5b4305589 Mon Sep 17 00:00:00 2001 From: Benjamin Faershtein <119711889+RCGV1@users.noreply.github.com> Date: Thu, 9 Jul 2026 21:07:08 -0700 Subject: [PATCH 06/15] fix(security): address licensed signing review feedback --- src/modules/AdminModule.cpp | 3 +- test/test_admin_radio/test_main.cpp | 60 +++++++++++++++++++++++--- test/test_packet_signing/test_main.cpp | 30 ++++++++++--- 3 files changed, 79 insertions(+), 14 deletions(-) diff --git a/src/modules/AdminModule.cpp b/src/modules/AdminModule.cpp index f5c4d8b5b36..d4f1617d867 100644 --- a/src/modules/AdminModule.cpp +++ b/src/modules/AdminModule.cpp @@ -981,6 +981,7 @@ void AdminModule::handleSetConfig(const meshtastic_Config &c, bool fromOthers) if (identityWillMigrate) sendWarning(licensedIdentityMigrationMessage); nodeDB->generateCryptoKeyPair(); + changes |= SEGMENT_DEVICESTATE | SEGMENT_NODEDATABASE; if (identityWillMigrate) nodeDB->licensedIdentityMigrationPending = false; } @@ -993,7 +994,7 @@ void AdminModule::handleSetConfig(const meshtastic_Config &c, bool fromOthers) // Default root is in use, so subscribe to the appropriate MQTT topic for this region snprintf(moduleConfig.mqtt.root, sizeof(moduleConfig.mqtt.root), "%s/%s", default_mqtt_root, myRegion->name); } - changes = SEGMENT_CONFIG | SEGMENT_MODULECONFIG; + changes |= SEGMENT_CONFIG | SEGMENT_MODULECONFIG; } else { // Region validation has failed, so just copy all of the old config over the new config validatedLora = oldLoraConfig; diff --git a/test/test_admin_radio/test_main.cpp b/test/test_admin_radio/test_main.cpp index 28422343a88..283a5266c07 100644 --- a/test/test_admin_radio/test_main.cpp +++ b/test/test_admin_radio/test_main.cpp @@ -933,6 +933,36 @@ static void test_channelSpacingCalculation_placeholder() // AdminModuleTestShim comes from test/support - the friend seam AdminModule.h declares. static AdminModuleTestShim *testAdmin; +class ScopedAdminRadioGlobals +{ + public: + ScopedAdminRadioGlobals() + : savedNodeDB(nodeDB), savedNodeInfoModule(nodeInfoModule), savedDeviceState(devicestate), savedConfig(config), + savedChannelFile(channelFile), testNodeDB(new NodeDB()) + { + nodeDB = testNodeDB; + } + + ~ScopedAdminRadioGlobals() + { + nodeInfoModule = savedNodeInfoModule; + nodeDB = savedNodeDB; + delete testNodeDB; + devicestate = savedDeviceState; + config = savedConfig; + channelFile = savedChannelFile; + initRegion(); + } + + private: + NodeDB *savedNodeDB; + NodeInfoModule *savedNodeInfoModule; + meshtastic_DeviceState savedDeviceState; + meshtastic_LocalConfig savedConfig; + meshtastic_ChannelFile savedChannelFile; + NodeDB *testNodeDB; +}; + static void installEncryptedAndAdminChannels() { channels.initDefaults(); @@ -965,8 +995,7 @@ static void assertLicensedChannelsSanitized() static void test_handleSetOwner_persistsLicensedChannelSanitation() { - NodeDB *savedNodeDB = nodeDB; - nodeDB = new NodeDB(); + ScopedAdminRadioGlobals globals; owner = meshtastic_User_init_zero; config.lora.region = meshtastic_Config_LoRaConfig_RegionCode_UNSET; installEncryptedAndAdminChannels(); @@ -974,10 +1003,8 @@ static void test_handleSetOwner_persistsLicensedChannelSanitation() meshtastic_User licensed = meshtastic_User_init_zero; licensed.is_licensed = true; testAdmin->deferSaves(); - NodeInfoModule *savedNodeInfoModule = nodeInfoModule; nodeInfoModule = reinterpret_cast(1); // reloadOwner(false) only checks presence testAdmin->handleSetOwner(licensed); - nodeInfoModule = savedNodeInfoModule; TEST_ASSERT_TRUE(testAdmin->savedSegments() & SEGMENT_CHANNELS); assertLicensedChannelsSanitized(); @@ -990,9 +1017,6 @@ static void test_handleSetOwner_persistsLicensedChannelSanitation() channelFile = reloaded; assertLicensedChannelsSanitized(); TEST_ASSERT_FALSE_MESSAGE(channels.ensureLicensedOperation(), "sanitized reload must not trigger another persistence write"); - - delete nodeDB; - nodeDB = savedNodeDB; } static void test_bootDefense_sanitizesStaleLicensedChannelsOnce() @@ -1045,6 +1069,27 @@ static meshtastic_Config makeLoraSetConfig(meshtastic_Config_LoRaConfig_RegionCo return c; } +static void test_handleSetConfig_persistsLicensedFirstRegionIdentity() +{ + ScopedAdminRadioGlobals globals; + owner = meshtastic_User_init_zero; + owner.is_licensed = true; + config.security = meshtastic_Config_SecurityConfig_init_zero; + config.lora = meshtastic_Config_LoRaConfig_init_zero; + config.lora.region = meshtastic_Config_LoRaConfig_RegionCode_UNSET; + initRegion(); + + testAdmin->deferSaves(); + const meshtastic_Config c = + makeLoraSetConfig(meshtastic_Config_LoRaConfig_RegionCode_US, true, meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST); + testAdmin->handleSetConfig(c, false); + + const int expectedSegments = SEGMENT_CONFIG | SEGMENT_MODULECONFIG | SEGMENT_DEVICESTATE | SEGMENT_NODEDATABASE; + TEST_ASSERT_EQUAL_INT(expectedSegments, testAdmin->savedSegments()); + TEST_ASSERT_EQUAL(32, config.security.private_key.size); + TEST_ASSERT_EQUAL(32, owner.public_key.size); +} + static void test_handleSetConfig_fromOthers_invalidPresetRejected() { // Set up a known-good baseline in the global config @@ -1376,6 +1421,7 @@ void setup() // getRegion() RUN_TEST(test_handleSetOwner_persistsLicensedChannelSanitation); + RUN_TEST(test_handleSetConfig_persistsLicensedFirstRegionIdentity); RUN_TEST(test_bootDefense_sanitizesStaleLicensedChannelsOnce); RUN_TEST(test_restorePreferences_sanitizesLicensedBackupBeforeReturn); RUN_TEST(test_getRegion_returnsCorrectRegion_US); diff --git a/test/test_packet_signing/test_main.cpp b/test/test_packet_signing/test_main.cpp index 713c359caba..0e29ffec3cb 100644 --- a/test/test_packet_signing/test_main.cpp +++ b/test/test_packet_signing/test_main.cpp @@ -92,6 +92,18 @@ class MockNodeDB : public NodeDB static MockNodeDB *mockNodeDB = nullptr; +#ifdef ARCH_PORTDUINO +class ScopedPkiRoutingForTest +{ + public: + ScopedPkiRoutingForTest() : savedForceSimRadio(portduino_config.force_simradio) { portduino_config.force_simradio = false; } + ~ScopedPkiRoutingForTest() { portduino_config.force_simradio = savedForceSimRadio; } + + private: + bool savedForceSimRadio; +}; +#endif + // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- @@ -522,6 +534,9 @@ void test_B9_licensed_oversized_unicast_remains_unsigned(void) // B10: normal-mode direct messages retain their existing PKI encryption behavior. void test_B10_normal_unicast_still_uses_pki(void) { +#ifdef ARCH_PORTDUINO + ScopedPkiRoutingForTest pkiRouting; +#endif uint8_t localPub[32], localPriv[32], remotePub[32], remotePriv[32]; crypto->generateKeyPair(localPub, localPriv); crypto->generateKeyPair(remotePub, remotePriv); @@ -547,6 +562,9 @@ void test_B10_normal_unicast_still_uses_pki(void) // B11: publishing a licensed node's key must not make inbound PKI decryption reachable. void test_B11_licensed_receiver_does_not_decrypt_pki(void) { +#ifdef ARCH_PORTDUINO + ScopedPkiRoutingForTest pkiRouting; +#endif uint8_t localPub[32], localPriv[32], remotePub[32], remotePriv[32]; crypto->generateKeyPair(localPub, localPriv); crypto->generateKeyPair(remotePub, remotePriv); @@ -594,10 +612,8 @@ void test_B12_licensed_port_and_destination_signing_matrix(void) } } -// =========================================================================== -// Group C - NodeInfoModule downgrade drop (broadcast-only backstop for ingress paths that skip -// Router's check; unicast NodeInfo is never signed by senders, so it is exempt - see C4) -// =========================================================================== +// Group C - NodeInfoModule downgrade drops for broadcast ingress paths; +// unicast NodeInfo is exempt because senders never sign it. class NodeInfoTestShim : public NodeInfoModule { public: @@ -923,6 +939,8 @@ void test_E9_decoded_partial_signature_from_nonsigner_dropped(void) void setup() { initializeTestEnvironment(); + AirTime *savedAirTime = airTime; + meshtastic::NodeStatus *savedNodeStatus = nodeStatus; AirTime testAirTime; meshtastic::NodeStatus testNodeStatus; airTime = &testAirTime; @@ -979,8 +997,8 @@ void setup() RUN_TEST(test_E9_decoded_partial_signature_from_nonsigner_dropped); const int result = UNITY_END(); - airTime = nullptr; - nodeStatus = nullptr; + airTime = savedAirTime; + nodeStatus = savedNodeStatus; exit(result); } From 155c4f76289e760bd789d36f3254105f6b8e1702 Mon Sep 17 00:00:00 2001 From: Benjamin Faershtein <119711889+RCGV1@users.noreply.github.com> Date: Thu, 9 Jul 2026 21:10:48 -0700 Subject: [PATCH 07/15] test: restore signing globals from Unity teardown --- test/test_admin_radio/test_main.cpp | 72 ++++++++++++++------------ test/test_packet_signing/test_main.cpp | 23 +++----- 2 files changed, 48 insertions(+), 47 deletions(-) diff --git a/test/test_admin_radio/test_main.cpp b/test/test_admin_radio/test_main.cpp index 283a5266c07..e16c990c505 100644 --- a/test/test_admin_radio/test_main.cpp +++ b/test/test_admin_radio/test_main.cpp @@ -932,36 +932,43 @@ static void test_channelSpacingCalculation_placeholder() // AdminModuleTestShim comes from test/support - the friend seam AdminModule.h declares. static AdminModuleTestShim *testAdmin; - -class ScopedAdminRadioGlobals -{ - public: - ScopedAdminRadioGlobals() - : savedNodeDB(nodeDB), savedNodeInfoModule(nodeInfoModule), savedDeviceState(devicestate), savedConfig(config), - savedChannelFile(channelFile), testNodeDB(new NodeDB()) - { - nodeDB = testNodeDB; - } - - ~ScopedAdminRadioGlobals() - { - nodeInfoModule = savedNodeInfoModule; - nodeDB = savedNodeDB; - delete testNodeDB; - devicestate = savedDeviceState; - config = savedConfig; - channelFile = savedChannelFile; - initRegion(); - } - - private: - NodeDB *savedNodeDB; - NodeInfoModule *savedNodeInfoModule; - meshtastic_DeviceState savedDeviceState; - meshtastic_LocalConfig savedConfig; - meshtastic_ChannelFile savedChannelFile; - NodeDB *testNodeDB; -}; +static bool adminRadioGlobalsActive; +static NodeDB *savedNodeDB; +static NodeDB *replacementNodeDB; +static NodeInfoModule *savedNodeInfoModule; +static meshtastic_DeviceState savedDeviceState; +static meshtastic_User savedOwner; +static meshtastic_LocalConfig savedConfig; +static meshtastic_ChannelFile savedChannelFile; + +static void replaceAdminRadioGlobals() +{ + savedNodeDB = nodeDB; + savedNodeInfoModule = nodeInfoModule; + savedDeviceState = devicestate; + savedOwner = owner; + savedConfig = config; + savedChannelFile = channelFile; + replacementNodeDB = new NodeDB(); + nodeDB = replacementNodeDB; + adminRadioGlobalsActive = true; +} + +static void restoreAdminRadioGlobals() +{ + if (!adminRadioGlobalsActive) + return; + nodeInfoModule = savedNodeInfoModule; + nodeDB = savedNodeDB; + delete replacementNodeDB; + replacementNodeDB = nullptr; + devicestate = savedDeviceState; + owner = savedOwner; + config = savedConfig; + channelFile = savedChannelFile; + initRegion(); + adminRadioGlobalsActive = false; +} static void installEncryptedAndAdminChannels() { @@ -995,7 +1002,7 @@ static void assertLicensedChannelsSanitized() static void test_handleSetOwner_persistsLicensedChannelSanitation() { - ScopedAdminRadioGlobals globals; + replaceAdminRadioGlobals(); owner = meshtastic_User_init_zero; config.lora.region = meshtastic_Config_LoRaConfig_RegionCode_UNSET; installEncryptedAndAdminChannels(); @@ -1071,7 +1078,7 @@ static meshtastic_Config makeLoraSetConfig(meshtastic_Config_LoRaConfig_RegionCo static void test_handleSetConfig_persistsLicensedFirstRegionIdentity() { - ScopedAdminRadioGlobals globals; + replaceAdminRadioGlobals(); owner = meshtastic_User_init_zero; owner.is_licensed = true; config.security = meshtastic_Config_SecurityConfig_init_zero; @@ -1403,6 +1410,7 @@ void setUp(void) } void tearDown(void) { + restoreAdminRadioGlobals(); service = nullptr; delete mockMeshService; mockMeshService = nullptr; diff --git a/test/test_packet_signing/test_main.cpp b/test/test_packet_signing/test_main.cpp index 0e29ffec3cb..a2f703a8274 100644 --- a/test/test_packet_signing/test_main.cpp +++ b/test/test_packet_signing/test_main.cpp @@ -93,15 +93,7 @@ class MockNodeDB : public NodeDB static MockNodeDB *mockNodeDB = nullptr; #ifdef ARCH_PORTDUINO -class ScopedPkiRoutingForTest -{ - public: - ScopedPkiRoutingForTest() : savedForceSimRadio(portduino_config.force_simradio) { portduino_config.force_simradio = false; } - ~ScopedPkiRoutingForTest() { portduino_config.force_simradio = savedForceSimRadio; } - - private: - bool savedForceSimRadio; -}; +static bool savedForceSimRadio; #endif // --------------------------------------------------------------------------- @@ -171,6 +163,10 @@ static bool signedEncodingFits(const meshtastic_Data *d) // --------------------------------------------------------------------------- void setUp(void) { +#ifdef ARCH_PORTDUINO + savedForceSimRadio = portduino_config.force_simradio; + portduino_config.force_simradio = false; +#endif // Construct the mock FIRST: the NodeDB constructor can reload persisted state from the // host filesystem (portduino VFS) and repopulate the globals - a saved private key // re-enables the PKI encrypt path and fails the unicast tests on hosts with leftover prefs. @@ -194,6 +190,9 @@ void tearDown(void) delete mockNodeDB; mockNodeDB = nullptr; nodeDB = nullptr; +#ifdef ARCH_PORTDUINO + portduino_config.force_simradio = savedForceSimRadio; +#endif } // =========================================================================== @@ -534,9 +533,6 @@ void test_B9_licensed_oversized_unicast_remains_unsigned(void) // B10: normal-mode direct messages retain their existing PKI encryption behavior. void test_B10_normal_unicast_still_uses_pki(void) { -#ifdef ARCH_PORTDUINO - ScopedPkiRoutingForTest pkiRouting; -#endif uint8_t localPub[32], localPriv[32], remotePub[32], remotePriv[32]; crypto->generateKeyPair(localPub, localPriv); crypto->generateKeyPair(remotePub, remotePriv); @@ -562,9 +558,6 @@ void test_B10_normal_unicast_still_uses_pki(void) // B11: publishing a licensed node's key must not make inbound PKI decryption reachable. void test_B11_licensed_receiver_does_not_decrypt_pki(void) { -#ifdef ARCH_PORTDUINO - ScopedPkiRoutingForTest pkiRouting; -#endif uint8_t localPub[32], localPriv[32], remotePub[32], remotePriv[32]; crypto->generateKeyPair(localPub, localPriv); crypto->generateKeyPair(remotePub, remotePriv); From 6c6389886656dc87fc8a958b77d3bc89e6376bfe Mon Sep 17 00:00:00 2001 From: Benjamin Faershtein <119711889+RCGV1@users.noreply.github.com> Date: Thu, 9 Jul 2026 21:38:14 -0700 Subject: [PATCH 08/15] fix(security): keep verified signer state packet-local Remove the process-global signer context and carry only the exact Router-verified key on the authenticated packet. Clear serialized authentication metadata before every untrusted decode, require both the verified-signature marker and packet-local key in licensed Admin authorization, and expand authorization/session/isolation coverage while cleaning routing test ownership. --- src/mesh/Router.cpp | 47 +---------- src/mesh/Router.h | 4 - src/modules/AdminModule.cpp | 5 +- test/test_admin_radio/test_main.cpp | 107 +++++++++++++++++++++++-- test/test_packet_signing/test_main.cpp | 39 +++++++++ 5 files changed, 146 insertions(+), 56 deletions(-) diff --git a/src/mesh/Router.cpp b/src/mesh/Router.cpp index 64364b497c2..efb3ed7a877 100644 --- a/src/mesh/Router.cpp +++ b/src/mesh/Router.cpp @@ -80,37 +80,6 @@ static RoutingAuthCache routingAuthCache; static concurrency::Lock *routingAuthCacheLock; static uint32_t routingAuthEvaluations; -struct VerifiedSignerContext { - const meshtastic_MeshPacket *packet = nullptr; - uint8_t key[32] = {0}; -}; -static VerifiedSignerContext verifiedSignerContext; - -class VerifiedSignerScope -{ - public: - VerifiedSignerScope(const meshtastic_MeshPacket *packet, bool authenticated) : previous(verifiedSignerContext) - { - verifiedSignerContext = {}; - if (authenticated && packet->xeddsa_signed && packet->public_key.size == 32) { - verifiedSignerContext.packet = packet; - memcpy(verifiedSignerContext.key, packet->public_key.bytes, sizeof(verifiedSignerContext.key)); - } - } - ~VerifiedSignerScope() { verifiedSignerContext = previous; } - - private: - VerifiedSignerContext previous; -}; - -bool copyVerifiedSignerKey(const meshtastic_MeshPacket &packet, uint8_t key[32]) -{ - if (verifiedSignerContext.packet != &packet) - return false; - memcpy(key, verifiedSignerContext.key, sizeof(verifiedSignerContext.key)); - return true; -} - static bool routingAuthCacheMatches(const meshtastic_MeshPacket &packet) { if (!routingAuthCacheLock) @@ -171,15 +140,6 @@ void resetRoutingAuthEvaluationCount() routingAuthCache.valid = false; } } -void setVerifiedSignerKeyForTest(const meshtastic_MeshPacket &packet, const uint8_t key[32]) -{ - verifiedSignerContext.packet = &packet; - memcpy(verifiedSignerContext.key, key, sizeof(verifiedSignerContext.key)); -} -void clearVerifiedSignerKeyForTest() -{ - verifiedSignerContext = {}; -} #endif /** @@ -697,6 +657,7 @@ RoutingAuthVerdict passesRoutingAuthGate(meshtastic_MeshPacket *p) // decryptor. Never trust serialized local authentication metadata on that boundary. authCandidate.pki_encrypted = false; authCandidate.public_key.size = 0; + authCandidate.xeddsa_signed = false; #if !(MESHTASTIC_EXCLUDE_PKI) && !(MESHTASTIC_EXCLUDE_XEDDSA) concurrency::LockGuard g(cryptLock); if (!checkXeddsaReceivePolicy(&authCandidate)) { @@ -704,7 +665,6 @@ RoutingAuthVerdict passesRoutingAuthGate(meshtastic_MeshPacket *p) return RoutingAuthVerdict::REJECT; } #endif - p->xeddsa_signed = authCandidate.xeddsa_signed; wire = *p; storeRoutingAuthCache(wire, authCandidate); return RoutingAuthVerdict::ACCEPT; @@ -746,6 +706,7 @@ DecodeState perhapsDecode(meshtastic_MeshPacket *p) // Authentication metadata is local-only. Re-establish it below only after successful PKI decryption. p->pki_encrypted = false; p->public_key.size = 0; + p->xeddsa_signed = false; size_t rawSize = p->encrypted.size; if (rawSize > sizeof(bytes)) { @@ -1101,7 +1062,8 @@ void Router::handleReceived(meshtastic_MeshPacket *p, RxSource src) // Consume the decoded/authenticated handoff after preserving the exact encrypted packet and // before mutating any packet fields that participate in the exact cache match. - const bool authenticatedHandoff = src == RX_SRC_RADIO && applyRoutingAuthCache(p); + if (src == RX_SRC_RADIO) + applyRoutingAuthCache(p); // Also, we should set the time from the ISR and it should have msec level resolution. // Keep the decoded working packet and encrypted MQTT copy on the same local arrival timestamp. @@ -1178,7 +1140,6 @@ void Router::handleReceived(meshtastic_MeshPacket *p, RxSource src) // call modules here // If this could be a spoofed packet, don't let the modules see it. if (!skipHandle) { - VerifiedSignerScope signerScope(p, authenticatedHandoff); MeshModule::callModules(*p, src); #if !MESHTASTIC_EXCLUDE_MQTT diff --git a/src/mesh/Router.h b/src/mesh/Router.h index f3aa722b780..1517aa64359 100644 --- a/src/mesh/Router.h +++ b/src/mesh/Router.h @@ -178,13 +178,9 @@ DecodeState perhapsDecode(meshtastic_MeshPacket *p); /** Apply receive authentication before routing state mutation; unknown-channel packets may remain opaque relay-only. */ RoutingAuthVerdict passesRoutingAuthGate(meshtastic_MeshPacket *p); -/** Copy the Router-verified XEdDSA signer key exposed only during module dispatch for this exact packet. */ -bool copyVerifiedSignerKey(const meshtastic_MeshPacket &packet, uint8_t key[32]); #ifdef PIO_UNIT_TESTING uint32_t routingAuthEvaluationCount(); void resetRoutingAuthEvaluationCount(); -void setVerifiedSignerKeyForTest(const meshtastic_MeshPacket &packet, const uint8_t key[32]); -void clearVerifiedSignerKeyForTest(); #endif /** Return 0 for success or a Routing_Error code for failure diff --git a/src/modules/AdminModule.cpp b/src/modules/AdminModule.cpp index e3b1da3ff0a..cd0c059a01f 100644 --- a/src/modules/AdminModule.cpp +++ b/src/modules/AdminModule.cpp @@ -137,18 +137,17 @@ bool AdminModule::handleReceivedProtobuf(const meshtastic_MeshPacket &mp, meshta #endif meshtastic_Channel *ch = &channels.getByIndex(mp.channel); const bool licensedRemote = owner.is_licensed && mp.from != 0; - uint8_t verifiedSignerKey[32] = {0}; bool authorizedLicensedSigner = false; if (licensedRemote) { const bool directedAdmin = mp.to == nodeDB->getNodeNum() && !isBroadcast(mp.to) && mp.decoded.portnum == meshtastic_PortNum_ADMIN_APP && !mp.pki_encrypted; - if (!directedAdmin || !copyVerifiedSignerKey(mp, verifiedSignerKey)) { + if (!directedAdmin || !mp.xeddsa_signed || mp.public_key.size != 32) { LOG_INFO("Ignore licensed admin payload without a directed Router-verified signature"); myReply = allocErrorResponse(meshtastic_Routing_Error_NOT_AUTHORIZED, &mp); return handled; } for (const auto &adminKey : config.security.admin_key) { - if (adminKey.size == 32 && memcmp(verifiedSignerKey, adminKey.bytes, sizeof(verifiedSignerKey)) == 0) { + if (adminKey.size == 32 && memcmp(mp.public_key.bytes, adminKey.bytes, 32) == 0) { authorizedLicensedSigner = true; break; } diff --git a/test/test_admin_radio/test_main.cpp b/test/test_admin_radio/test_main.cpp index f27a22b9655..11e567853ea 100644 --- a/test/test_admin_radio/test_main.cpp +++ b/test/test_admin_radio/test_main.cpp @@ -1416,6 +1416,13 @@ static meshtastic_AdminMessage makeOwnerRequest() return message; } +static void setVerifiedPacketSigner(meshtastic_MeshPacket &packet, const uint8_t key[32]) +{ + packet.xeddsa_signed = true; + packet.public_key.size = 32; + memcpy(packet.public_key.bytes, key, 32); +} + static void test_adminSession_zeroBeforeGenerationIsRejected() { testAdmin->clearSession(); @@ -1443,17 +1450,18 @@ static void test_licensedSignedAdminGetterUsesTransientAllowlistedKey() config.security.admin_key[0].size = 32; memcpy(config.security.admin_key[0].bytes, key, sizeof(key)); meshtastic_MeshPacket packet = makeRemoteAdminPacket(nodeDB->getNodeNum()); + packet.xeddsa_signed = true; + packet.public_key.size = 32; + memcpy(packet.public_key.bytes, key, sizeof(key)); meshtastic_AdminMessage request = makeOwnerRequest(); - setVerifiedSignerKeyForTest(packet, key); testAdmin->handleReceivedProtobuf(packet, &request); - clearVerifiedSignerKeyForTest(); TEST_ASSERT_NOT_NULL(testAdmin->reply()); TEST_ASSERT_EQUAL(meshtastic_PortNum_ADMIN_APP, testAdmin->reply()->decoded.portnum); TEST_ASSERT_EQUAL(packet.from, testAdmin->reply()->to); TEST_ASSERT_FALSE(testAdmin->reply()->pki_encrypted); } -static void test_licensedAdminRejectsSerializedAuthWithoutRouterContext() +static void test_licensedAdminRejectsKeyWithoutVerifiedSignature() { owner.is_licensed = true; uint8_t key[32]; @@ -1461,7 +1469,7 @@ static void test_licensedAdminRejectsSerializedAuthWithoutRouterContext() config.security.admin_key[0].size = 32; memcpy(config.security.admin_key[0].bytes, key, sizeof(key)); meshtastic_MeshPacket packet = makeRemoteAdminPacket(nodeDB->getNodeNum()); - packet.xeddsa_signed = true; + packet.xeddsa_signed = false; packet.public_key.size = 32; memcpy(packet.public_key.bytes, key, sizeof(key)); meshtastic_AdminMessage request = makeOwnerRequest(); @@ -1488,6 +1496,87 @@ static void test_normalModePkiAdminRemainsAuthorized() TEST_ASSERT_TRUE(testAdmin->reply()->pki_encrypted); } +static void test_licensedAdminRejectsVerifiedNonAllowlistedAndEmptySlots() +{ + owner.is_licensed = true; + uint8_t key[32]; + memset(key, 0x61, sizeof(key)); + config.security = meshtastic_Config_SecurityConfig_init_zero; + meshtastic_MeshPacket packet = makeRemoteAdminPacket(nodeDB->getNodeNum()); + setVerifiedPacketSigner(packet, key); + meshtastic_AdminMessage request = makeOwnerRequest(); + testAdmin->handleReceivedProtobuf(packet, &request); + TEST_ASSERT_EQUAL(meshtastic_PortNum_ROUTING_APP, testAdmin->reply()->decoded.portnum); +} + +static void test_licensedAdminRejectsBroadcastAndWrongDestination() +{ + owner.is_licensed = true; + uint8_t key[32]; + memset(key, 0x62, sizeof(key)); + config.security.admin_key[0].size = 32; + memcpy(config.security.admin_key[0].bytes, key, 32); + meshtastic_AdminMessage request = makeOwnerRequest(); + for (NodeNum destination : {NODENUM_BROADCAST, static_cast(nodeDB->getNodeNum() + 1)}) { + meshtastic_MeshPacket packet = makeRemoteAdminPacket(destination); + setVerifiedPacketSigner(packet, key); + testAdmin->handleReceivedProtobuf(packet, &request); + TEST_ASSERT_EQUAL(meshtastic_PortNum_ROUTING_APP, testAdmin->reply()->decoded.portnum); + testAdmin->drainReply(); + } +} + +static void test_licensedSignedResponseDoesNotRequireAdminAllowlist() +{ + owner.is_licensed = true; + uint8_t key[32]; + memset(key, 0x63, sizeof(key)); + config.security = meshtastic_Config_SecurityConfig_init_zero; + meshtastic_MeshPacket packet = makeRemoteAdminPacket(nodeDB->getNodeNum()); + setVerifiedPacketSigner(packet, key); + meshtastic_AdminMessage response = meshtastic_AdminMessage_init_zero; + response.which_payload_variant = meshtastic_AdminMessage_get_owner_response_tag; + testAdmin->handleReceivedProtobuf(packet, &response); + TEST_ASSERT_NULL(testAdmin->reply()); +} + +static meshtastic_AdminMessage makeOwnerMutation() +{ + meshtastic_AdminMessage mutation = meshtastic_AdminMessage_init_zero; + mutation.which_payload_variant = meshtastic_AdminMessage_set_owner_tag; + return mutation; +} + +static void test_licensedMutationRequiresCurrentSessionAndNewModuleInvalidatesIt() +{ + owner.is_licensed = true; + uint8_t key[32]; + memset(key, 0x64, sizeof(key)); + config.security.admin_key[0].size = 32; + memcpy(config.security.admin_key[0].bytes, key, 32); + meshtastic_MeshPacket packet = makeRemoteAdminPacket(nodeDB->getNodeNum()); + setVerifiedPacketSigner(packet, key); + + meshtastic_AdminMessage missing = makeOwnerMutation(); + testAdmin->handleReceivedProtobuf(packet, &missing); + TEST_ASSERT_EQUAL(meshtastic_PortNum_ROUTING_APP, testAdmin->reply()->decoded.portnum); + testAdmin->drainReply(); + + meshtastic_AdminMessage valid = makeOwnerMutation(); + testAdmin->generateSession(&valid); + testAdmin->deferSaves(); + testAdmin->handleReceivedProtobuf(packet, &valid); + TEST_ASSERT_NULL(testAdmin->reply()); + + testAdmin->expireSession(); + testAdmin->handleReceivedProtobuf(packet, &valid); + TEST_ASSERT_EQUAL(meshtastic_PortNum_ROUTING_APP, testAdmin->reply()->decoded.portnum); + testAdmin->drainReply(); + + AdminModuleTestShim rebooted; + TEST_ASSERT_FALSE(rebooted.sessionValid(&valid)); +} + // ----------------------------------------------------------------------- // Test runner // ----------------------------------------------------------------------- @@ -1500,6 +1589,7 @@ void setUp(void) } void tearDown(void) { + testAdmin->drainReply(); restoreAdminRadioGlobals(); service = nullptr; delete mockMeshService; @@ -1611,10 +1701,15 @@ void setup() RUN_TEST(test_adminSession_zeroBeforeGenerationIsRejected); RUN_TEST(test_adminSession_generatedKeyIsValidThenExpires); RUN_TEST(test_licensedSignedAdminGetterUsesTransientAllowlistedKey); - RUN_TEST(test_licensedAdminRejectsSerializedAuthWithoutRouterContext); + RUN_TEST(test_licensedAdminRejectsKeyWithoutVerifiedSignature); RUN_TEST(test_normalModePkiAdminRemainsAuthorized); + RUN_TEST(test_licensedAdminRejectsVerifiedNonAllowlistedAndEmptySlots); + RUN_TEST(test_licensedAdminRejectsBroadcastAndWrongDestination); + RUN_TEST(test_licensedSignedResponseDoesNotRequireAdminAllowlist); + RUN_TEST(test_licensedMutationRequiresCurrentSessionAndNewModuleInvalidatesIt); - exit(UNITY_END()); + const int result = UNITY_END(); + exit(result); } void loop() {} diff --git a/test/test_packet_signing/test_main.cpp b/test/test_packet_signing/test_main.cpp index 3f0f057d8f3..d5a68b7864b 100644 --- a/test/test_packet_signing/test_main.cpp +++ b/test/test_packet_signing/test_main.cpp @@ -46,6 +46,7 @@ // --------------------------------------------------------------------------- static constexpr NodeNum LOCAL_NODE = 0x0A0A0A0A; static constexpr NodeNum REMOTE_NODE = 0x0B0B0B0B; +static constexpr NodeNum OTHER_NODE = 0x0C0C0C0C; // A "small" broadcast payload whose signed encoding easily fits a LoRa frame, and an "oversized" // one whose signed encoding does not, yet still encodes within a LoRa frame unsigned. @@ -686,6 +687,30 @@ void test_A17_strict_verifies_signer_from_warm_key_store(void) } #endif +void test_A18_verifiedSignerKeyIsPacketLocalAndClearedOnNextIngress(void) +{ + uint8_t pub[32], priv[32]; + crypto->generateKeyPair(pub, priv); + mockNodeDB->addNode(REMOTE_NODE); + mockNodeDB->setPublicKey(REMOTE_NODE, pub); + + meshtastic_MeshPacket verified = makeDecoded(REMOTE_NODE, LOCAL_NODE, meshtastic_PortNum_ADMIN_APP, SMALL_PAYLOAD); + signWithCurrentKey(&verified); + TEST_ASSERT_TRUE(checkXeddsaReceivePolicy(&verified)); + TEST_ASSERT_TRUE(verified.xeddsa_signed); + TEST_ASSERT_EQUAL(32, verified.public_key.size); + TEST_ASSERT_EQUAL_UINT8_ARRAY(pub, verified.public_key.bytes, sizeof(pub)); + + setPolicy(meshtastic_Config_SecurityConfig_PacketSignaturePolicy_PACKET_SIGNATURE_POLICY_COMPATIBLE); + meshtastic_MeshPacket next = makeDecoded(OTHER_NODE, LOCAL_NODE, meshtastic_PortNum_ADMIN_APP, SMALL_PAYLOAD); + next.xeddsa_signed = true; + next.public_key.size = 32; + memcpy(next.public_key.bytes, pub, sizeof(pub)); + TEST_ASSERT_TRUE(checkXeddsaReceivePolicy(&next)); + TEST_ASSERT_FALSE(next.xeddsa_signed); + TEST_ASSERT_EQUAL(0, next.public_key.size); +} + // =========================================================================== // Group B - send-side signing policy (perhapsEncode) // =========================================================================== @@ -1610,6 +1635,7 @@ void setup() #if WARM_NODE_COUNT > 0 RUN_TEST(test_A17_strict_verifies_signer_from_warm_key_store); #endif + RUN_TEST(test_A18_verifiedSignerKeyIsPacketLocalAndClearedOnNextIngress); printf("\n=== Group B: send-side signing policy ===\n"); RUN_TEST(test_B1_local_broadcast_is_signed); @@ -1664,6 +1690,19 @@ void setup() RUN_TEST(test_E9_decoded_partial_signature_from_nonsigner_dropped); const int result = UNITY_END(); + pipelineRouter->clearPending(); + delete pipelineMqtt; + mqtt = pipelineMqtt = nullptr; + delete pipelineService; + service = pipelineService = nullptr; + delete pipelineModule; + pipelineModule = nullptr; + delete pipelineRouting; + routingModule = pipelineRouting = nullptr; + router = nullptr; + delete pipelineRouter; + pipelineRouter = nullptr; + pipelineRadio = nullptr; airTime = savedAirTime; nodeStatus = savedNodeStatus; exit(result); From 84689b0738d108c5336960cc2948f35c4c08a2b4 Mon Sep 17 00:00:00 2001 From: Benjamin Faershtein <119711889+RCGV1@users.noreply.github.com> Date: Thu, 9 Jul 2026 21:46:48 -0700 Subject: [PATCH 09/15] fix: address packet auth static analysis Make the routing authentication gate input const and collapse licensed Admin authorization into a single branch so legacy response, local, channel, and PKI checks run only for non-licensed-remote traffic. --- src/mesh/FloodingRouter.cpp | 2 +- src/mesh/Router.cpp | 2 +- src/mesh/Router.h | 2 +- src/modules/AdminModule.cpp | 7 ++----- 4 files changed, 5 insertions(+), 8 deletions(-) diff --git a/src/mesh/FloodingRouter.cpp b/src/mesh/FloodingRouter.cpp index 7048cd91873..5988bacac5d 100644 --- a/src/mesh/FloodingRouter.cpp +++ b/src/mesh/FloodingRouter.cpp @@ -71,7 +71,7 @@ bool FloodingRouter::perhapsHandleUpgradedPacket(const meshtastic_MeshPacket *p) // Verify the replacement before deleting the valid lower-hop copy waiting in the TX queue. // This is intentionally redundant with ReliableRouter's ingress gate: it keeps this helper // safe if another caller is introduced later. - if (passesRoutingAuthGate(const_cast(p)) != RoutingAuthVerdict::ACCEPT) + if (passesRoutingAuthGate(p) != RoutingAuthVerdict::ACCEPT) return true; // If we overhear a duplicate copy of the packet with more hops left than the one we are waiting to diff --git a/src/mesh/Router.cpp b/src/mesh/Router.cpp index efb3ed7a877..d26548c51fc 100644 --- a/src/mesh/Router.cpp +++ b/src/mesh/Router.cpp @@ -641,7 +641,7 @@ bool checkXeddsaReceivePolicy(meshtastic_MeshPacket *p, size_t encodedDataSize) } #endif -RoutingAuthVerdict passesRoutingAuthGate(meshtastic_MeshPacket *p) +RoutingAuthVerdict passesRoutingAuthGate(const meshtastic_MeshPacket *p) { // Routing still needs the original encrypted representation for byte-for-byte relay and for // MQTT uplink. Authenticate a copy here; handleReceived() performs the normal in-place decode diff --git a/src/mesh/Router.h b/src/mesh/Router.h index 1517aa64359..dfb23162e6c 100644 --- a/src/mesh/Router.h +++ b/src/mesh/Router.h @@ -176,7 +176,7 @@ enum class RoutingAuthVerdict { ACCEPT, OPAQUE_RELAY_ONLY, REJECT }; DecodeState perhapsDecode(meshtastic_MeshPacket *p); /** Apply receive authentication before routing state mutation; unknown-channel packets may remain opaque relay-only. */ -RoutingAuthVerdict passesRoutingAuthGate(meshtastic_MeshPacket *p); +RoutingAuthVerdict passesRoutingAuthGate(const meshtastic_MeshPacket *p); #ifdef PIO_UNIT_TESTING uint32_t routingAuthEvaluationCount(); diff --git a/src/modules/AdminModule.cpp b/src/modules/AdminModule.cpp index cd0c059a01f..eac1b677f48 100644 --- a/src/modules/AdminModule.cpp +++ b/src/modules/AdminModule.cpp @@ -138,6 +138,7 @@ bool AdminModule::handleReceivedProtobuf(const meshtastic_MeshPacket &mp, meshta meshtastic_Channel *ch = &channels.getByIndex(mp.channel); const bool licensedRemote = owner.is_licensed && mp.from != 0; bool authorizedLicensedSigner = false; + // Could tighten responses further by tracking the last public key queried. if (licensedRemote) { const bool directedAdmin = mp.to == nodeDB->getNodeNum() && !isBroadcast(mp.to) && mp.decoded.portnum == meshtastic_PortNum_ADMIN_APP && !mp.pki_encrypted; @@ -157,11 +158,7 @@ bool AdminModule::handleReceivedProtobuf(const meshtastic_MeshPacket &mp, meshta myReply = allocErrorResponse(meshtastic_Routing_Error_ADMIN_PUBLIC_KEY_UNAUTHORIZED, &mp); return handled; } - LOG_INFO("Signed licensed admin payload with authorized Router-verified sender"); - } - // Could tighten this up further by tracking the last public_key we went an AdminMessage request to - // and only allowing responses from that remote. - if (licensedRemote) { + LOG_INFO("Signed licensed admin payload with Router-verified sender"); LOG_DEBUG("Allow authenticated licensed admin payload"); } else if (messageIsResponse(r)) { LOG_DEBUG("Allow admin response message"); From 4cd335d03fd9498cc3c8fa12cfc51b5834d65e5a Mon Sep 17 00:00:00 2001 From: Benjamin Faershtein <119711889+RCGV1@users.noreply.github.com> Date: Thu, 9 Jul 2026 22:03:13 -0700 Subject: [PATCH 10/15] fix(security): reverify lost routing auth handoffs --- src/mesh/FloodingRouter.cpp | 4 +- src/mesh/Router.cpp | 66 ++++++++++++++++---------- src/mesh/Router.h | 6 ++- test/test_admin_radio/test_main.cpp | 8 +--- test/test_packet_signing/test_main.cpp | 38 ++++++++++++++- 5 files changed, 85 insertions(+), 37 deletions(-) diff --git a/src/mesh/FloodingRouter.cpp b/src/mesh/FloodingRouter.cpp index 5988bacac5d..1b2f05c978e 100644 --- a/src/mesh/FloodingRouter.cpp +++ b/src/mesh/FloodingRouter.cpp @@ -68,9 +68,7 @@ bool FloodingRouter::perhapsHandleUpgradedPacket(const meshtastic_MeshPacket *p) { // isRebroadcaster() is duplicated in perhapsRebroadcast(), but this avoids confusing log messages if (isRebroadcaster() && iface && p->hop_limit > 0) { - // Verify the replacement before deleting the valid lower-hop copy waiting in the TX queue. - // This is intentionally redundant with ReliableRouter's ingress gate: it keeps this helper - // safe if another caller is introduced later. + // Re-authenticate before replacing the queued lower-hop copy so future callers remain safe. if (passesRoutingAuthGate(p) != RoutingAuthVerdict::ACCEPT) return true; diff --git a/src/mesh/Router.cpp b/src/mesh/Router.cpp index d26548c51fc..c26d2a9e914 100644 --- a/src/mesh/Router.cpp +++ b/src/mesh/Router.cpp @@ -97,6 +97,8 @@ static bool routingAuthCacheMatches(const meshtastic_MeshPacket &packet) static void storeRoutingAuthCache(const meshtastic_MeshPacket &wire, const meshtastic_MeshPacket &authenticated) { + if (!routingAuthCacheLock) + return; concurrency::LockGuard guard(routingAuthCacheLock); routingAuthCache.wire = wire; routingAuthCache.authenticated = authenticated; @@ -641,55 +643,60 @@ bool checkXeddsaReceivePolicy(meshtastic_MeshPacket *p, size_t encodedDataSize) } #endif -RoutingAuthVerdict passesRoutingAuthGate(const meshtastic_MeshPacket *p) +static RoutingAuthVerdict evaluateRoutingAuth(const meshtastic_MeshPacket &wire, meshtastic_MeshPacket &authenticated) { - // Routing still needs the original encrypted representation for byte-for-byte relay and for - // MQTT uplink. Authenticate a copy here; handleReceived() performs the normal in-place decode - // only after stateful routing filters have completed. - if (routingAuthCacheMatches(*p)) - return RoutingAuthVerdict::ACCEPT; - - meshtastic_MeshPacket wire = *p; - meshtastic_MeshPacket authCandidate = *p; + authenticated = wire; routingAuthEvaluations++; - if (authCandidate.which_payload_variant == meshtastic_MeshPacket_decoded_tag) { + if (authenticated.which_payload_variant == meshtastic_MeshPacket_decoded_tag) { // Already-decoded remote ingress (notably Portduino SimRadio) did not pass through a // decryptor. Never trust serialized local authentication metadata on that boundary. - authCandidate.pki_encrypted = false; - authCandidate.public_key.size = 0; - authCandidate.xeddsa_signed = false; + authenticated.pki_encrypted = false; + authenticated.public_key.size = 0; + authenticated.xeddsa_signed = false; #if !(MESHTASTIC_EXCLUDE_PKI) && !(MESHTASTIC_EXCLUDE_XEDDSA) concurrency::LockGuard g(cryptLock); - if (!checkXeddsaReceivePolicy(&authCandidate)) { - LOG_WARN("Already-decoded packet rejected by signature policy before routing state update"); + if (!checkXeddsaReceivePolicy(&authenticated)) { + LOG_WARN("Already-decoded packet rejected by routing signature policy"); return RoutingAuthVerdict::REJECT; } #endif - wire = *p; - storeRoutingAuthCache(wire, authCandidate); return RoutingAuthVerdict::ACCEPT; } - const DecodeState state = perhapsDecode(&authCandidate); + const DecodeState state = perhapsDecode(&authenticated); if (state == DecodeState::DECODE_POLICY_REJECT) { - LOG_WARN("Packet rejected by signature policy before routing state update"); + LOG_WARN("Packet rejected by routing signature policy"); return RoutingAuthVerdict::REJECT; } if (state == DecodeState::DECODE_FATAL) { - LOG_WARN("Fatal decode error before routing state update"); + LOG_WARN("Fatal decode error during routing authentication"); return RoutingAuthVerdict::REJECT; } if (state == DecodeState::DECODE_FAILURE) { - LOG_WARN("Decryptable packet failed decoding/authentication before routing state update"); + LOG_WARN("Decryptable packet failed routing authentication"); return RoutingAuthVerdict::REJECT; } // Only an explicit unknown-channel result remains eligible for opaque relay. if (state == DecodeState::DECODE_OPAQUE) return RoutingAuthVerdict::OPAQUE_RELAY_ONLY; - storeRoutingAuthCache(wire, authCandidate); return RoutingAuthVerdict::ACCEPT; } +RoutingAuthVerdict passesRoutingAuthGate(const meshtastic_MeshPacket *p) +{ + // Routing still needs the original encrypted representation for byte-for-byte relay and for + // MQTT uplink. Authenticate a copy here; handleReceived() performs the normal in-place decode + // only after stateful routing filters have completed. + if (routingAuthCacheMatches(*p)) + return RoutingAuthVerdict::ACCEPT; + + meshtastic_MeshPacket authenticated = meshtastic_MeshPacket_init_zero; + const RoutingAuthVerdict verdict = evaluateRoutingAuth(*p, authenticated); + if (verdict == RoutingAuthVerdict::ACCEPT) + storeRoutingAuthCache(*p, authenticated); + return verdict; +} + DecodeState perhapsDecode(meshtastic_MeshPacket *p) { concurrency::LockGuard g(cryptLock); @@ -1047,7 +1054,7 @@ NodeNum Router::getNodeNum() * Handle any packet that is received by an interface on this node. * Note: some packets may merely being passed through this node and will be forwarded elsewhere. */ -void Router::handleReceived(meshtastic_MeshPacket *p, RxSource src) +void Router::handleReceived(meshtastic_MeshPacket *p, RxSource src, bool routingAuthRequired) { bool skipHandle = false; @@ -1062,8 +1069,15 @@ void Router::handleReceived(meshtastic_MeshPacket *p, RxSource src) // Consume the decoded/authenticated handoff after preserving the exact encrypted packet and // before mutating any packet fields that participate in the exact cache match. - if (src == RX_SRC_RADIO) - applyRoutingAuthCache(p); + if (routingAuthRequired && !applyRoutingAuthCache(p)) { + meshtastic_MeshPacket authenticated = meshtastic_MeshPacket_init_zero; + if (evaluateRoutingAuth(*p, authenticated) != RoutingAuthVerdict::ACCEPT) { + LOG_WARN("Routing authentication handoff was lost and packet re-verification failed"); + packetPool.release(p_encrypted); + return; + } + *p = authenticated; + } // Also, we should set the time from the ISR and it should have msec level resolution. // Keep the decoded working packet and encrypted MQTT copy on the same local arrival timestamp. @@ -1255,6 +1269,6 @@ void Router::perhapsHandleReceived(meshtastic_MeshPacket *p) // Note: we avoid calling shouldFilterReceived if we are supposed to ignore certain nodes - because some overrides might // cache/learn of the existence of nodes (i.e. FloodRouter) that they should not - handleReceived(p); + handleReceived(p, RX_SRC_RADIO, true); packetPool.release(p); } diff --git a/src/mesh/Router.h b/src/mesh/Router.h index dfb23162e6c..cd82dd5eacf 100644 --- a/src/mesh/Router.h +++ b/src/mesh/Router.h @@ -100,6 +100,10 @@ class Router : protected concurrency::OSThread, protected PacketHistory before us */ uint32_t rxDupe = 0, txRelayCanceled = 0; +#ifdef PIO_UNIT_TESTING + void handleReceivedAfterRoutingGateForTest(meshtastic_MeshPacket *p) { handleReceived(p, RX_SRC_RADIO, true); } +#endif + protected: friend class RoutingModule; @@ -159,7 +163,7 @@ class Router : protected concurrency::OSThread, protected PacketHistory * Note: this packet will never be called for messages sent/generated by this node. * Note: this method will free the provided packet. */ - void handleReceived(meshtastic_MeshPacket *p, RxSource src = RX_SRC_RADIO); + void handleReceived(meshtastic_MeshPacket *p, RxSource src = RX_SRC_RADIO, bool routingAuthRequired = false); /** Frees the provided packet, and generates a NAK indicating the specifed error while sending */ void abortSendAndNak(meshtastic_Routing_Error err, meshtastic_MeshPacket *p); diff --git a/test/test_admin_radio/test_main.cpp b/test/test_admin_radio/test_main.cpp index 11e567853ea..381217bfd6b 100644 --- a/test/test_admin_radio/test_main.cpp +++ b/test/test_admin_radio/test_main.cpp @@ -1028,6 +1028,7 @@ static void test_handleSetOwner_persistsLicensedChannelSanitation() static void test_bootDefense_sanitizesStaleLicensedChannelsOnce() { + replaceAdminRadioGlobals(); owner = meshtastic_User_init_zero; owner.is_licensed = true; installEncryptedAndAdminChannels(); @@ -1039,10 +1040,7 @@ static void test_bootDefense_sanitizesStaleLicensedChannelsOnce() static void test_restorePreferences_sanitizesLicensedBackupBeforeReturn() { - NodeDB *savedNodeDB = nodeDB; - nodeDB = new NodeDB(); - const meshtastic_DeviceState savedDeviceState = devicestate; - const meshtastic_ChannelFile savedChannelFile = channelFile; + replaceAdminRadioGlobals(); owner = meshtastic_User_init_zero; owner.is_licensed = true; @@ -1061,8 +1059,6 @@ static void test_restorePreferences_sanitizesLicensedBackupBeforeReturn() channelFile = savedChannelFile; nodeDB->saveToDisk(SEGMENT_DEVICESTATE | SEGMENT_CHANNELS); FSCom.remove(backupFileName); - delete nodeDB; - nodeDB = savedNodeDB; } static meshtastic_Config makeLoraSetConfig(meshtastic_Config_LoRaConfig_RegionCode region, bool usePreset, diff --git a/test/test_packet_signing/test_main.cpp b/test/test_packet_signing/test_main.cpp index d5a68b7864b..471e3ce6e5a 100644 --- a/test/test_packet_signing/test_main.cpp +++ b/test/test_packet_signing/test_main.cpp @@ -178,12 +178,14 @@ class AuthPipelineModule : public SinglePortModule { public: AuthPipelineModule() : SinglePortModule("authPipeline", meshtastic_PortNum_POSITION_APP) {} - ProcessMessage handleReceived(const meshtastic_MeshPacket &) override + ProcessMessage handleReceived(const meshtastic_MeshPacket &packet) override { calls++; + lastPacket = packet; return ProcessMessage::CONTINUE; } uint32_t calls = 0; + meshtastic_MeshPacket lastPacket = meshtastic_MeshPacket_init_zero; }; class AuthPipelineMqtt : public MQTT @@ -339,6 +341,7 @@ void setUp(void) pipelineRadio->reset(); pipelineRouting->ackCalls = 0; pipelineModule->calls = 0; + pipelineModule->lastPacket = meshtastic_MeshPacket_init_zero; pipelineMqtt->clearQueue(); while (meshtastic_MeshPacket *queued = pipelineService->getForPhone()) packetPool.release(queued); @@ -1450,6 +1453,38 @@ void test_P12_exact_authenticated_replay_reuses_verdict_without_collision_bypass TEST_ASSERT_EQUAL_MESSAGE(3, routingAuthEvaluationCount(), "same packet ID with different bytes must be reevaluated"); } +void test_P13_lost_decoded_handoff_reverifies_packet_local_signer(void) +{ + setPolicy(meshtastic_Config_SecurityConfig_PacketSignaturePolicy_PACKET_SIGNATURE_POLICY_STRICT); + uint8_t signerPublic[32], signerPrivate[32]; + crypto->generateKeyPair(signerPublic, signerPrivate); + mockNodeDB->addNode(REMOTE_NODE); + mockNodeDB->setPublicKey(REMOTE_NODE, signerPublic); + + meshtastic_MeshPacket packet = makeDecoded(REMOTE_NODE, LOCAL_NODE, meshtastic_PortNum_POSITION_APP, SMALL_PAYLOAD); + packet.id = 0xCD00000D; + signWithCurrentKey(&packet); + + uint8_t forgedKey[32]; + memset(forgedKey, 0xA5, sizeof(forgedKey)); + packet.xeddsa_signed = true; + packet.public_key.size = sizeof(forgedKey); + memcpy(packet.public_key.bytes, forgedKey, sizeof(forgedKey)); + + TEST_ASSERT_EQUAL(static_cast(RoutingAuthVerdict::ACCEPT), static_cast(passesRoutingAuthGate(&packet))); + resetRoutingAuthEvaluationCount(); // Simulate another ingress replacing the one-entry handoff. + + pipelineRouter->handleReceivedAfterRoutingGateForTest(&packet); + + TEST_ASSERT_EQUAL(1, pipelineModule->calls); + TEST_ASSERT_EQUAL(1, routingAuthEvaluationCount()); + TEST_ASSERT_TRUE(pipelineModule->lastPacket.xeddsa_signed); + TEST_ASSERT_EQUAL_UINT(32, pipelineModule->lastPacket.public_key.size); + TEST_ASSERT_EQUAL_MEMORY(signerPublic, pipelineModule->lastPacket.public_key.bytes, sizeof(signerPublic)); + TEST_ASSERT_FALSE_MESSAGE(memcmp(forgedKey, pipelineModule->lastPacket.public_key.bytes, sizeof(forgedKey)) == 0, + "serialized signer metadata survived a lost routing-auth handoff"); +} + // =========================================================================== // Group D - encoding invariants the routing gates depend on // =========================================================================== @@ -1675,6 +1710,7 @@ void setup() RUN_TEST(test_P10_legacy_channel_dm_failure_has_no_pipeline_effects); RUN_TEST(test_P11_malformed_pki_plaintext_has_no_pipeline_effects); RUN_TEST(test_P12_exact_authenticated_replay_reuses_verdict_without_collision_bypass); + RUN_TEST(test_P13_lost_decoded_handoff_reverifies_packet_local_signer); printf("\n=== Group D: encoding invariants ===\n"); RUN_TEST(test_D1_signature_field_overhead_exact); From d2aee1354dafe524535f8f932f8f76cecb5e8c63 Mon Sep 17 00:00:00 2001 From: Benjamin Faershtein <119711889+RCGV1@users.noreply.github.com> Date: Fri, 10 Jul 2026 16:32:33 -0700 Subject: [PATCH 11/15] chore: update protobuf bindings for signature policy --- protobufs | 2 +- src/mesh/generated/meshtastic/config.pb.cpp | 2 -- src/mesh/generated/meshtastic/config.pb.h | 29 ++++++++++++++++--- src/mesh/generated/meshtastic/deviceonly.pb.h | 2 +- src/mesh/generated/meshtastic/localonly.pb.h | 2 +- src/mesh/generated/meshtastic/mesh.pb.h | 13 ++++++--- 6 files changed, 37 insertions(+), 13 deletions(-) diff --git a/protobufs b/protobufs index 1ae3be3d541..19ab2d27110 160000 --- a/protobufs +++ b/protobufs @@ -1 +1 @@ -Subproject commit 1ae3be3d5413d2190c1eb0d3ced094237de68b81 +Subproject commit 19ab2d2711050cb462f0f57507f75b47bfa6832c diff --git a/src/mesh/generated/meshtastic/config.pb.cpp b/src/mesh/generated/meshtastic/config.pb.cpp index c554ca43ce9..52a591f3368 100644 --- a/src/mesh/generated/meshtastic/config.pb.cpp +++ b/src/mesh/generated/meshtastic/config.pb.cpp @@ -67,8 +67,6 @@ PB_BIND(meshtastic_Config_SessionkeyConfig, meshtastic_Config_SessionkeyConfig, - - diff --git a/src/mesh/generated/meshtastic/config.pb.h b/src/mesh/generated/meshtastic/config.pb.h index 1f49ef9f373..d6d149b253c 100644 --- a/src/mesh/generated/meshtastic/config.pb.h +++ b/src/mesh/generated/meshtastic/config.pb.h @@ -395,6 +395,18 @@ typedef enum _meshtastic_Config_BluetoothConfig_PairingMode { meshtastic_Config_BluetoothConfig_PairingMode_NO_PIN = 2 } meshtastic_Config_BluetoothConfig_PairingMode; +/* Controls how the device authenticates remotely received mesh packets. */ +typedef enum _meshtastic_Config_SecurityConfig_PacketSignaturePolicy { + /* Prefer authenticated packets while retaining compatibility with unsigned packets from nodes not known to sign. + This is the default and rejects unsigned, signable broadcasts from nodes that have previously signed. */ + meshtastic_Config_SecurityConfig_PacketSignaturePolicy_PACKET_SIGNATURE_POLICY_BALANCED = 0, + /* Accept unsigned packets for maximum compatibility while still rejecting malformed or invalid signatures. */ + meshtastic_Config_SecurityConfig_PacketSignaturePolicy_PACKET_SIGNATURE_POLICY_COMPATIBLE = 1, + /* Accept only packets authenticated by a verified XEdDSA signature or successful PKI decryption. + Unsigned, malformed, invalid, or unverifiable packets are ignored. */ + meshtastic_Config_SecurityConfig_PacketSignaturePolicy_PACKET_SIGNATURE_POLICY_STRICT = 2 +} meshtastic_Config_SecurityConfig_PacketSignaturePolicy; + /* Struct definitions */ /* Configuration */ typedef struct _meshtastic_Config_DeviceConfig { @@ -685,6 +697,8 @@ typedef struct _meshtastic_Config_SecurityConfig { bool debug_log_api_enabled; /* Allow incoming device control over the insecure legacy admin channel. */ bool admin_channel_enabled; + /* Determines the packet signature policy applied to remotely received mesh packets. */ + meshtastic_Config_SecurityConfig_PacketSignaturePolicy packet_signature_policy; } meshtastic_Config_SecurityConfig; /* Blank config request, strictly for getting the session key */ @@ -778,6 +792,10 @@ extern "C" { #define _meshtastic_Config_BluetoothConfig_PairingMode_MAX meshtastic_Config_BluetoothConfig_PairingMode_NO_PIN #define _meshtastic_Config_BluetoothConfig_PairingMode_ARRAYSIZE ((meshtastic_Config_BluetoothConfig_PairingMode)(meshtastic_Config_BluetoothConfig_PairingMode_NO_PIN+1)) +#define _meshtastic_Config_SecurityConfig_PacketSignaturePolicy_MIN meshtastic_Config_SecurityConfig_PacketSignaturePolicy_PACKET_SIGNATURE_POLICY_BALANCED +#define _meshtastic_Config_SecurityConfig_PacketSignaturePolicy_MAX meshtastic_Config_SecurityConfig_PacketSignaturePolicy_PACKET_SIGNATURE_POLICY_STRICT +#define _meshtastic_Config_SecurityConfig_PacketSignaturePolicy_ARRAYSIZE ((meshtastic_Config_SecurityConfig_PacketSignaturePolicy)(meshtastic_Config_SecurityConfig_PacketSignaturePolicy_PACKET_SIGNATURE_POLICY_STRICT+1)) + #define meshtastic_Config_DeviceConfig_role_ENUMTYPE meshtastic_Config_DeviceConfig_Role #define meshtastic_Config_DeviceConfig_rebroadcast_mode_ENUMTYPE meshtastic_Config_DeviceConfig_RebroadcastMode @@ -801,6 +819,7 @@ extern "C" { #define meshtastic_Config_BluetoothConfig_mode_ENUMTYPE meshtastic_Config_BluetoothConfig_PairingMode +#define meshtastic_Config_SecurityConfig_packet_signature_policy_ENUMTYPE meshtastic_Config_SecurityConfig_PacketSignaturePolicy @@ -814,7 +833,7 @@ extern "C" { #define meshtastic_Config_DisplayConfig_init_default {0, _meshtastic_Config_DisplayConfig_DeprecatedGpsCoordinateFormat_MIN, 0, 0, 0, _meshtastic_Config_DisplayConfig_DisplayUnits_MIN, _meshtastic_Config_DisplayConfig_OledType_MIN, _meshtastic_Config_DisplayConfig_DisplayMode_MIN, 0, 0, _meshtastic_Config_DisplayConfig_CompassOrientation_MIN, 0, 0, 0} #define meshtastic_Config_LoRaConfig_init_default {0, _meshtastic_Config_LoRaConfig_ModemPreset_MIN, 0, 0, 0, 0, _meshtastic_Config_LoRaConfig_RegionCode_MIN, 0, 0, 0, 0, 0, 0, 0, 0, 0, {0, 0, 0}, 0, 0, _meshtastic_Config_LoRaConfig_FEM_LNA_Mode_MIN, 0} #define meshtastic_Config_BluetoothConfig_init_default {0, _meshtastic_Config_BluetoothConfig_PairingMode_MIN, 0} -#define meshtastic_Config_SecurityConfig_init_default {{0, {0}}, {0, {0}}, 0, {{0, {0}}, {0, {0}}, {0, {0}}}, 0, 0, 0, 0} +#define meshtastic_Config_SecurityConfig_init_default {{0, {0}}, {0, {0}}, 0, {{0, {0}}, {0, {0}}, {0, {0}}}, 0, 0, 0, 0, _meshtastic_Config_SecurityConfig_PacketSignaturePolicy_MIN} #define meshtastic_Config_SessionkeyConfig_init_default {0} #define meshtastic_Config_init_zero {0, {meshtastic_Config_DeviceConfig_init_zero}} #define meshtastic_Config_DeviceConfig_init_zero {_meshtastic_Config_DeviceConfig_Role_MIN, 0, 0, 0, _meshtastic_Config_DeviceConfig_RebroadcastMode_MIN, 0, 0, 0, 0, "", 0, _meshtastic_Config_DeviceConfig_BuzzerMode_MIN} @@ -825,7 +844,7 @@ extern "C" { #define meshtastic_Config_DisplayConfig_init_zero {0, _meshtastic_Config_DisplayConfig_DeprecatedGpsCoordinateFormat_MIN, 0, 0, 0, _meshtastic_Config_DisplayConfig_DisplayUnits_MIN, _meshtastic_Config_DisplayConfig_OledType_MIN, _meshtastic_Config_DisplayConfig_DisplayMode_MIN, 0, 0, _meshtastic_Config_DisplayConfig_CompassOrientation_MIN, 0, 0, 0} #define meshtastic_Config_LoRaConfig_init_zero {0, _meshtastic_Config_LoRaConfig_ModemPreset_MIN, 0, 0, 0, 0, _meshtastic_Config_LoRaConfig_RegionCode_MIN, 0, 0, 0, 0, 0, 0, 0, 0, 0, {0, 0, 0}, 0, 0, _meshtastic_Config_LoRaConfig_FEM_LNA_Mode_MIN, 0} #define meshtastic_Config_BluetoothConfig_init_zero {0, _meshtastic_Config_BluetoothConfig_PairingMode_MIN, 0} -#define meshtastic_Config_SecurityConfig_init_zero {{0, {0}}, {0, {0}}, 0, {{0, {0}}, {0, {0}}, {0, {0}}}, 0, 0, 0, 0} +#define meshtastic_Config_SecurityConfig_init_zero {{0, {0}}, {0, {0}}, 0, {{0, {0}}, {0, {0}}, {0, {0}}}, 0, 0, 0, 0, _meshtastic_Config_SecurityConfig_PacketSignaturePolicy_MIN} #define meshtastic_Config_SessionkeyConfig_init_zero {0} /* Field tags (for use in manual encoding/decoding) */ @@ -921,6 +940,7 @@ extern "C" { #define meshtastic_Config_SecurityConfig_serial_enabled_tag 5 #define meshtastic_Config_SecurityConfig_debug_log_api_enabled_tag 6 #define meshtastic_Config_SecurityConfig_admin_channel_enabled_tag 8 +#define meshtastic_Config_SecurityConfig_packet_signature_policy_tag 9 #define meshtastic_Config_device_tag 1 #define meshtastic_Config_position_tag 2 #define meshtastic_Config_power_tag 3 @@ -1082,7 +1102,8 @@ X(a, STATIC, REPEATED, BYTES, admin_key, 3) \ X(a, STATIC, SINGULAR, BOOL, is_managed, 4) \ X(a, STATIC, SINGULAR, BOOL, serial_enabled, 5) \ X(a, STATIC, SINGULAR, BOOL, debug_log_api_enabled, 6) \ -X(a, STATIC, SINGULAR, BOOL, admin_channel_enabled, 8) +X(a, STATIC, SINGULAR, BOOL, admin_channel_enabled, 8) \ +X(a, STATIC, SINGULAR, UENUM, packet_signature_policy, 9) #define meshtastic_Config_SecurityConfig_CALLBACK NULL #define meshtastic_Config_SecurityConfig_DEFAULT NULL @@ -1126,7 +1147,7 @@ extern const pb_msgdesc_t meshtastic_Config_SessionkeyConfig_msg; #define meshtastic_Config_NetworkConfig_size 204 #define meshtastic_Config_PositionConfig_size 62 #define meshtastic_Config_PowerConfig_size 52 -#define meshtastic_Config_SecurityConfig_size 178 +#define meshtastic_Config_SecurityConfig_size 180 #define meshtastic_Config_SessionkeyConfig_size 0 #define meshtastic_Config_size 207 diff --git a/src/mesh/generated/meshtastic/deviceonly.pb.h b/src/mesh/generated/meshtastic/deviceonly.pb.h index c838dc21d85..059398cf057 100644 --- a/src/mesh/generated/meshtastic/deviceonly.pb.h +++ b/src/mesh/generated/meshtastic/deviceonly.pb.h @@ -452,7 +452,7 @@ extern const pb_msgdesc_t meshtastic_BackupPreferences_msg; /* Maximum encoded size of messages (where known) */ /* meshtastic_NodeDatabase_size depends on runtime parameters */ #define MESHTASTIC_MESHTASTIC_DEVICEONLY_PB_H_MAX_SIZE meshtastic_BackupPreferences_size -#define meshtastic_BackupPreferences_size 2738 +#define meshtastic_BackupPreferences_size 2740 #define meshtastic_ChannelFile_size 718 #define meshtastic_DeviceState_size 1944 #define meshtastic_NodeEnvironmentEntry_size 170 diff --git a/src/mesh/generated/meshtastic/localonly.pb.h b/src/mesh/generated/meshtastic/localonly.pb.h index 6fcaed30617..c560d5447ed 100644 --- a/src/mesh/generated/meshtastic/localonly.pb.h +++ b/src/mesh/generated/meshtastic/localonly.pb.h @@ -211,7 +211,7 @@ extern const pb_msgdesc_t meshtastic_LocalModuleConfig_msg; /* Maximum encoded size of messages (where known) */ #define MESHTASTIC_MESHTASTIC_LOCALONLY_PB_H_MAX_SIZE meshtastic_LocalModuleConfig_size -#define meshtastic_LocalConfig_size 757 +#define meshtastic_LocalConfig_size 759 #define meshtastic_LocalModuleConfig_size 1126 #ifdef __cplusplus diff --git a/src/mesh/generated/meshtastic/mesh.pb.h b/src/mesh/generated/meshtastic/mesh.pb.h index 98798173c85..23a9abdffd9 100644 --- a/src/mesh/generated/meshtastic/mesh.pb.h +++ b/src/mesh/generated/meshtastic/mesh.pb.h @@ -1395,6 +1395,9 @@ typedef struct _meshtastic_DeviceMetadata { /* Bit field of boolean for excluded modules (bitwise OR of ExcludedModules) */ uint32_t excluded_modules; + /* Indicates whether this firmware build includes XEdDSA packet signature verification. + This is a read-only capability and must be false when XEdDSA is not compiled in. */ + bool has_xeddsa; } meshtastic_DeviceMetadata; /* A distinct set of legal modem presets shared by one or more LoRa regions. @@ -1743,7 +1746,7 @@ extern "C" { #define meshtastic_Compressed_init_default {_meshtastic_PortNum_MIN, {0, {0}}} #define meshtastic_NeighborInfo_init_default {0, 0, 0, 0, {meshtastic_Neighbor_init_default, meshtastic_Neighbor_init_default, meshtastic_Neighbor_init_default, meshtastic_Neighbor_init_default, meshtastic_Neighbor_init_default, meshtastic_Neighbor_init_default, meshtastic_Neighbor_init_default, meshtastic_Neighbor_init_default, meshtastic_Neighbor_init_default, meshtastic_Neighbor_init_default}} #define meshtastic_Neighbor_init_default {0, 0, 0, 0} -#define meshtastic_DeviceMetadata_init_default {"", 0, 0, 0, 0, 0, _meshtastic_Config_DeviceConfig_Role_MIN, 0, _meshtastic_HardwareModel_MIN, 0, 0, 0} +#define meshtastic_DeviceMetadata_init_default {"", 0, 0, 0, 0, 0, _meshtastic_Config_DeviceConfig_Role_MIN, 0, _meshtastic_HardwareModel_MIN, 0, 0, 0, 0} #define meshtastic_LoRaPresetGroup_init_default {0, {_meshtastic_Config_LoRaConfig_ModemPreset_MIN, _meshtastic_Config_LoRaConfig_ModemPreset_MIN, _meshtastic_Config_LoRaConfig_ModemPreset_MIN, _meshtastic_Config_LoRaConfig_ModemPreset_MIN, _meshtastic_Config_LoRaConfig_ModemPreset_MIN, _meshtastic_Config_LoRaConfig_ModemPreset_MIN, _meshtastic_Config_LoRaConfig_ModemPreset_MIN, _meshtastic_Config_LoRaConfig_ModemPreset_MIN, _meshtastic_Config_LoRaConfig_ModemPreset_MIN, _meshtastic_Config_LoRaConfig_ModemPreset_MIN, _meshtastic_Config_LoRaConfig_ModemPreset_MIN}, _meshtastic_Config_LoRaConfig_ModemPreset_MIN, 0} #define meshtastic_LoRaRegionPresets_init_default {_meshtastic_Config_LoRaConfig_RegionCode_MIN, 0} #define meshtastic_LoRaRegionPresetMap_init_default {0, {meshtastic_LoRaPresetGroup_init_default, meshtastic_LoRaPresetGroup_init_default, meshtastic_LoRaPresetGroup_init_default, meshtastic_LoRaPresetGroup_init_default, meshtastic_LoRaPresetGroup_init_default, meshtastic_LoRaPresetGroup_init_default, meshtastic_LoRaPresetGroup_init_default, meshtastic_LoRaPresetGroup_init_default}, 0, {meshtastic_LoRaRegionPresets_init_default, meshtastic_LoRaRegionPresets_init_default, meshtastic_LoRaRegionPresets_init_default, meshtastic_LoRaRegionPresets_init_default, meshtastic_LoRaRegionPresets_init_default, meshtastic_LoRaRegionPresets_init_default, meshtastic_LoRaRegionPresets_init_default, meshtastic_LoRaRegionPresets_init_default, meshtastic_LoRaRegionPresets_init_default, meshtastic_LoRaRegionPresets_init_default, meshtastic_LoRaRegionPresets_init_default, meshtastic_LoRaRegionPresets_init_default, meshtastic_LoRaRegionPresets_init_default, meshtastic_LoRaRegionPresets_init_default, meshtastic_LoRaRegionPresets_init_default, meshtastic_LoRaRegionPresets_init_default, meshtastic_LoRaRegionPresets_init_default, meshtastic_LoRaRegionPresets_init_default, meshtastic_LoRaRegionPresets_init_default, meshtastic_LoRaRegionPresets_init_default, meshtastic_LoRaRegionPresets_init_default, meshtastic_LoRaRegionPresets_init_default, meshtastic_LoRaRegionPresets_init_default, meshtastic_LoRaRegionPresets_init_default, meshtastic_LoRaRegionPresets_init_default, meshtastic_LoRaRegionPresets_init_default, meshtastic_LoRaRegionPresets_init_default, meshtastic_LoRaRegionPresets_init_default, meshtastic_LoRaRegionPresets_init_default, meshtastic_LoRaRegionPresets_init_default, meshtastic_LoRaRegionPresets_init_default, meshtastic_LoRaRegionPresets_init_default, meshtastic_LoRaRegionPresets_init_default, meshtastic_LoRaRegionPresets_init_default, meshtastic_LoRaRegionPresets_init_default, meshtastic_LoRaRegionPresets_init_default, meshtastic_LoRaRegionPresets_init_default, meshtastic_LoRaRegionPresets_init_default}} @@ -1782,7 +1785,7 @@ extern "C" { #define meshtastic_Compressed_init_zero {_meshtastic_PortNum_MIN, {0, {0}}} #define meshtastic_NeighborInfo_init_zero {0, 0, 0, 0, {meshtastic_Neighbor_init_zero, meshtastic_Neighbor_init_zero, meshtastic_Neighbor_init_zero, meshtastic_Neighbor_init_zero, meshtastic_Neighbor_init_zero, meshtastic_Neighbor_init_zero, meshtastic_Neighbor_init_zero, meshtastic_Neighbor_init_zero, meshtastic_Neighbor_init_zero, meshtastic_Neighbor_init_zero}} #define meshtastic_Neighbor_init_zero {0, 0, 0, 0} -#define meshtastic_DeviceMetadata_init_zero {"", 0, 0, 0, 0, 0, _meshtastic_Config_DeviceConfig_Role_MIN, 0, _meshtastic_HardwareModel_MIN, 0, 0, 0} +#define meshtastic_DeviceMetadata_init_zero {"", 0, 0, 0, 0, 0, _meshtastic_Config_DeviceConfig_Role_MIN, 0, _meshtastic_HardwareModel_MIN, 0, 0, 0, 0} #define meshtastic_LoRaPresetGroup_init_zero {0, {_meshtastic_Config_LoRaConfig_ModemPreset_MIN, _meshtastic_Config_LoRaConfig_ModemPreset_MIN, _meshtastic_Config_LoRaConfig_ModemPreset_MIN, _meshtastic_Config_LoRaConfig_ModemPreset_MIN, _meshtastic_Config_LoRaConfig_ModemPreset_MIN, _meshtastic_Config_LoRaConfig_ModemPreset_MIN, _meshtastic_Config_LoRaConfig_ModemPreset_MIN, _meshtastic_Config_LoRaConfig_ModemPreset_MIN, _meshtastic_Config_LoRaConfig_ModemPreset_MIN, _meshtastic_Config_LoRaConfig_ModemPreset_MIN, _meshtastic_Config_LoRaConfig_ModemPreset_MIN}, _meshtastic_Config_LoRaConfig_ModemPreset_MIN, 0} #define meshtastic_LoRaRegionPresets_init_zero {_meshtastic_Config_LoRaConfig_RegionCode_MIN, 0} #define meshtastic_LoRaRegionPresetMap_init_zero {0, {meshtastic_LoRaPresetGroup_init_zero, meshtastic_LoRaPresetGroup_init_zero, meshtastic_LoRaPresetGroup_init_zero, meshtastic_LoRaPresetGroup_init_zero, meshtastic_LoRaPresetGroup_init_zero, meshtastic_LoRaPresetGroup_init_zero, meshtastic_LoRaPresetGroup_init_zero, meshtastic_LoRaPresetGroup_init_zero}, 0, {meshtastic_LoRaRegionPresets_init_zero, meshtastic_LoRaRegionPresets_init_zero, meshtastic_LoRaRegionPresets_init_zero, meshtastic_LoRaRegionPresets_init_zero, meshtastic_LoRaRegionPresets_init_zero, meshtastic_LoRaRegionPresets_init_zero, meshtastic_LoRaRegionPresets_init_zero, meshtastic_LoRaRegionPresets_init_zero, meshtastic_LoRaRegionPresets_init_zero, meshtastic_LoRaRegionPresets_init_zero, meshtastic_LoRaRegionPresets_init_zero, meshtastic_LoRaRegionPresets_init_zero, meshtastic_LoRaRegionPresets_init_zero, meshtastic_LoRaRegionPresets_init_zero, meshtastic_LoRaRegionPresets_init_zero, meshtastic_LoRaRegionPresets_init_zero, meshtastic_LoRaRegionPresets_init_zero, meshtastic_LoRaRegionPresets_init_zero, meshtastic_LoRaRegionPresets_init_zero, meshtastic_LoRaRegionPresets_init_zero, meshtastic_LoRaRegionPresets_init_zero, meshtastic_LoRaRegionPresets_init_zero, meshtastic_LoRaRegionPresets_init_zero, meshtastic_LoRaRegionPresets_init_zero, meshtastic_LoRaRegionPresets_init_zero, meshtastic_LoRaRegionPresets_init_zero, meshtastic_LoRaRegionPresets_init_zero, meshtastic_LoRaRegionPresets_init_zero, meshtastic_LoRaRegionPresets_init_zero, meshtastic_LoRaRegionPresets_init_zero, meshtastic_LoRaRegionPresets_init_zero, meshtastic_LoRaRegionPresets_init_zero, meshtastic_LoRaRegionPresets_init_zero, meshtastic_LoRaRegionPresets_init_zero, meshtastic_LoRaRegionPresets_init_zero, meshtastic_LoRaRegionPresets_init_zero, meshtastic_LoRaRegionPresets_init_zero, meshtastic_LoRaRegionPresets_init_zero}} @@ -1985,6 +1988,7 @@ extern "C" { #define meshtastic_DeviceMetadata_hasRemoteHardware_tag 10 #define meshtastic_DeviceMetadata_hasPKC_tag 11 #define meshtastic_DeviceMetadata_excluded_modules_tag 12 +#define meshtastic_DeviceMetadata_has_xeddsa_tag 14 #define meshtastic_LoRaPresetGroup_presets_tag 1 #define meshtastic_LoRaPresetGroup_default_preset_tag 2 #define meshtastic_LoRaPresetGroup_licensed_only_tag 3 @@ -2403,7 +2407,8 @@ X(a, STATIC, SINGULAR, UINT32, position_flags, 8) \ X(a, STATIC, SINGULAR, UENUM, hw_model, 9) \ X(a, STATIC, SINGULAR, BOOL, hasRemoteHardware, 10) \ X(a, STATIC, SINGULAR, BOOL, hasPKC, 11) \ -X(a, STATIC, SINGULAR, UINT32, excluded_modules, 12) +X(a, STATIC, SINGULAR, UINT32, excluded_modules, 12) \ +X(a, STATIC, SINGULAR, BOOL, has_xeddsa, 14) #define meshtastic_DeviceMetadata_CALLBACK NULL #define meshtastic_DeviceMetadata_DEFAULT NULL @@ -2552,7 +2557,7 @@ extern const pb_msgdesc_t meshtastic_ChunkedPayloadResponse_msg; #define meshtastic_ClientNotification_size 482 #define meshtastic_Compressed_size 239 #define meshtastic_Data_size 335 -#define meshtastic_DeviceMetadata_size 54 +#define meshtastic_DeviceMetadata_size 56 #define meshtastic_DuplicatedPublicKey_size 0 #define meshtastic_FileInfo_size 236 #define meshtastic_FromRadio_size 510 From c4579dd5d938f2b2b55ea69192eaffd93c2b23fb Mon Sep 17 00:00:00 2001 From: Benjamin Faershtein <119711889+RCGV1@users.noreply.github.com> Date: Fri, 10 Jul 2026 17:39:47 -0700 Subject: [PATCH 12/15] test: remove bypassed node info policy fixtures --- test/test_packet_signing/test_main.cpp | 60 +------------------------- 1 file changed, 2 insertions(+), 58 deletions(-) diff --git a/test/test_packet_signing/test_main.cpp b/test/test_packet_signing/test_main.cpp index 471e3ce6e5a..8f8b0a7f3d7 100644 --- a/test/test_packet_signing/test_main.cpp +++ b/test/test_packet_signing/test_main.cpp @@ -985,13 +985,11 @@ void test_B12_licensed_port_and_destination_signing_matrix(void) } } -// Group C - NodeInfoModule downgrade drops for broadcast ingress paths; -// unicast NodeInfo is exempt because senders never sign it. +// Group C - licensed NodeInfo publishes the signing identity key. class NodeInfoTestShim : public NodeInfoModule { public: using NodeInfoModule::allocReply; - using NodeInfoModule::handleReceivedProtobuf; // protected virtual -> exposed for direct call }; // C0: licensed NodeInfo publishes the same public identity key used to verify plaintext signatures. @@ -1088,56 +1086,6 @@ void test_C02_licensed_low_entropy_identity_is_regenerated(void) TEST_ASSERT_FALSE(memcmp(oldPrivateKey, config.security.private_key.bytes, sizeof(oldPrivateKey)) == 0); } -static meshtastic_MeshPacket makeNodeInfoPacket(bool signed_) -{ - meshtastic_MeshPacket mp = makeDecoded(REMOTE_NODE, NODENUM_BROADCAST, meshtastic_PortNum_NODEINFO_APP, SMALL_PAYLOAD); - mp.xeddsa_signed = signed_; - return mp; -} - -void test_C1_unsigned_nodeinfo_from_signer_dropped(void) -{ - mockNodeDB->addNode(REMOTE_NODE); - mockNodeDB->setSignerBit(REMOTE_NODE, true); - NodeInfoTestShim shim; - meshtastic_MeshPacket mp = makeNodeInfoPacket(false); - meshtastic_User user = meshtastic_User_init_zero; - user.is_licensed = owner.is_licensed; - TEST_ASSERT_TRUE(shim.handleReceivedProtobuf(mp, &user)); -} - -void test_C2_signed_nodeinfo_from_signer_not_dropped(void) -{ - mockNodeDB->addNode(REMOTE_NODE); - mockNodeDB->setSignerBit(REMOTE_NODE, true); - NodeInfoTestShim shim; - meshtastic_MeshPacket mp = makeNodeInfoPacket(true); - meshtastic_User user = meshtastic_User_init_zero; - user.is_licensed = owner.is_licensed; - TEST_ASSERT_FALSE(shim.handleReceivedProtobuf(mp, &user)); -} - -void test_C3_unsigned_nodeinfo_from_nonsigner_not_dropped(void) -{ - mockNodeDB->addNode(REMOTE_NODE); - NodeInfoTestShim shim; - meshtastic_MeshPacket mp = makeNodeInfoPacket(false); - meshtastic_User user = meshtastic_User_init_zero; - user.is_licensed = owner.is_licensed; - TEST_ASSERT_FALSE(shim.handleReceivedProtobuf(mp, &user)); -} - -void test_C4_unsigned_unicast_nodeinfo_from_signer_accepted(void) -{ - mockNodeDB->addNode(REMOTE_NODE); - mockNodeDB->setSignerBit(REMOTE_NODE, true); - NodeInfoTestShim shim; - meshtastic_MeshPacket mp = makeDecoded(REMOTE_NODE, LOCAL_NODE, meshtastic_PortNum_NODEINFO_APP, SMALL_PAYLOAD); - meshtastic_User user = meshtastic_User_init_zero; - user.is_licensed = owner.is_licensed; - TEST_ASSERT_FALSE(shim.handleReceivedProtobuf(mp, &user)); -} - // =========================================================================== // Group P - routing pipeline authentication ordering // =========================================================================== @@ -1687,15 +1635,11 @@ void setup() RUN_TEST(test_B11_licensed_receiver_does_not_decrypt_pki); RUN_TEST(test_B12_licensed_port_and_destination_signing_matrix); - printf("\n=== Group C: NodeInfoModule downgrade drop ===\n"); + printf("\n=== Group C: NodeInfo signing identity ===\n"); RUN_TEST(test_C0_licensed_nodeinfo_publishes_public_key); RUN_TEST(test_C00_licensed_identity_key_is_generated_and_preserved); RUN_TEST(test_C01_factory_config_reset_preserves_valid_identity_private_key); RUN_TEST(test_C02_licensed_low_entropy_identity_is_regenerated); - RUN_TEST(test_C1_unsigned_nodeinfo_from_signer_dropped); - RUN_TEST(test_C2_signed_nodeinfo_from_signer_not_dropped); - RUN_TEST(test_C3_unsigned_nodeinfo_from_nonsigner_not_dropped); - RUN_TEST(test_C4_unsigned_unicast_nodeinfo_from_signer_accepted); printf("\n=== Group P: routing pipeline authentication ordering ===\n"); RUN_TEST(test_P1_invalid_first_copy_does_not_poison_valid_same_id); From dcf6e253ab3705ae2a4bc1b7269255d930f71844 Mon Sep 17 00:00:00 2001 From: Benjamin Faershtein <119711889+RCGV1@users.noreply.github.com> Date: Fri, 10 Jul 2026 17:48:57 -0700 Subject: [PATCH 13/15] test: preserve mesh service across signing fixtures --- test/test_packet_signing/test_main.cpp | 21 ++++++++++++++------- 1 file changed, 14 insertions(+), 7 deletions(-) diff --git a/test/test_packet_signing/test_main.cpp b/test/test_packet_signing/test_main.cpp index 8f8b0a7f3d7..17a2cc40ef2 100644 --- a/test/test_packet_signing/test_main.cpp +++ b/test/test_packet_signing/test_main.cpp @@ -957,7 +957,9 @@ void test_B11_licensed_receiver_does_not_decrypt_pki(void) channels.ensureLicensedOperation(); myNodeInfo.my_node_num = REMOTE_NODE; crypto->setDHPrivateKey(remotePriv); - TEST_ASSERT_EQUAL(DECODE_FAILURE, perhapsDecode(&p)); + const DecodeState state = perhapsDecode(&p); + TEST_ASSERT_NOT_EQUAL(DECODE_SUCCESS, state); + TEST_ASSERT_FALSE(p.pki_encrypted); } void test_B12_licensed_port_and_destination_signing_matrix(void) @@ -1027,13 +1029,18 @@ void test_C00_licensed_identity_key_is_generated_and_preserved(void) TEST_ASSERT_TRUE(mockNodeDB->licensedIdentityMigrationPending); MockMeshService mockService; + MeshService *const savedService = service; service = &mockService; - TEST_ASSERT_TRUE(mockNodeDB->notifyPendingLicensedIdentityMigration()); - TEST_ASSERT_EQUAL(1, mockService.notificationCount); - TEST_ASSERT_FALSE(mockNodeDB->licensedIdentityMigrationPending); - TEST_ASSERT_FALSE(mockNodeDB->notifyPendingLicensedIdentityMigration()); - TEST_ASSERT_EQUAL(1, mockService.notificationCount); - service = nullptr; + const bool firstNotification = mockNodeDB->notifyPendingLicensedIdentityMigration(); + const bool migrationPending = mockNodeDB->licensedIdentityMigrationPending; + const bool secondNotification = mockNodeDB->notifyPendingLicensedIdentityMigration(); + const int notificationCount = mockService.notificationCount; + service = savedService; + + TEST_ASSERT_TRUE(firstNotification); + TEST_ASSERT_EQUAL(1, notificationCount); + TEST_ASSERT_FALSE(migrationPending); + TEST_ASSERT_FALSE(secondNotification); config.security.public_key.size = 0; owner.public_key.size = 0; From 30749616b3993d77038bb8a7f0f31cb147290ab4 Mon Sep 17 00:00:00 2001 From: Benjamin Faershtein <119711889+RCGV1@users.noreply.github.com> Date: Fri, 10 Jul 2026 21:42:26 -0700 Subject: [PATCH 14/15] test: stabilize signed admin native coverage --- src/modules/AdminModule.cpp | 2 +- test/test_admin_radio/test_main.cpp | 3 +++ 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/src/modules/AdminModule.cpp b/src/modules/AdminModule.cpp index eac1b677f48..5bf97839811 100644 --- a/src/modules/AdminModule.cpp +++ b/src/modules/AdminModule.cpp @@ -211,7 +211,7 @@ bool AdminModule::handleReceivedProtobuf(const meshtastic_MeshPacket &mp, meshta // that pointer is unrelated, so the path was unsafe.) // Automatically favorite the node that is using the admin key - auto remoteNode = nodeDB->getMeshNode(mp.from); + auto remoteNode = nodeDB ? nodeDB->getMeshNode(mp.from) : nullptr; if (remoteNode && !nodeInfoLiteIsFavorite(remoteNode)) { if (config.device.role == meshtastic_Config_DeviceConfig_Role_CLIENT_BASE) { // Special case for CLIENT_BASE: is_favorite has special meaning, and we don't want to automatically set it diff --git a/test/test_admin_radio/test_main.cpp b/test/test_admin_radio/test_main.cpp index 381217bfd6b..7c93976885f 100644 --- a/test/test_admin_radio/test_main.cpp +++ b/test/test_admin_radio/test_main.cpp @@ -1446,6 +1446,7 @@ static void test_licensedSignedAdminGetterUsesTransientAllowlistedKey() config.security.admin_key[0].size = 32; memcpy(config.security.admin_key[0].bytes, key, sizeof(key)); meshtastic_MeshPacket packet = makeRemoteAdminPacket(nodeDB->getNodeNum()); + packet.decoded.want_response = true; packet.xeddsa_signed = true; packet.public_key.size = 32; memcpy(packet.public_key.bytes, key, sizeof(key)); @@ -1482,6 +1483,7 @@ static void test_normalModePkiAdminRemainsAuthorized() config.security.admin_key[0].size = 32; memcpy(config.security.admin_key[0].bytes, key, sizeof(key)); meshtastic_MeshPacket packet = makeRemoteAdminPacket(nodeDB->getNodeNum()); + packet.decoded.want_response = true; packet.pki_encrypted = true; packet.public_key.size = 32; memcpy(packet.public_key.bytes, key, sizeof(key)); @@ -1540,6 +1542,7 @@ static meshtastic_AdminMessage makeOwnerMutation() { meshtastic_AdminMessage mutation = meshtastic_AdminMessage_init_zero; mutation.which_payload_variant = meshtastic_AdminMessage_set_owner_tag; + mutation.set_owner.is_licensed = true; return mutation; } From 65db823120c020104eebd886c8174515f1abbf00 Mon Sep 17 00:00:00 2001 From: Benjamin Faershtein <119711889+RCGV1@users.noreply.github.com> Date: Sun, 12 Jul 2026 15:24:55 -0700 Subject: [PATCH 15/15] fix(mqtt): preserve signed packet metadata --- src/mqtt/MQTT.cpp | 21 +++++++++++++++++---- test/test_fuzz_packets/test_main.cpp | 5 +++-- test/test_mqtt/MQTT.cpp | 7 +++++++ 3 files changed, 27 insertions(+), 6 deletions(-) diff --git a/src/mqtt/MQTT.cpp b/src/mqtt/MQTT.cpp index 7051285e2e7..8892b5bf6ea 100644 --- a/src/mqtt/MQTT.cpp +++ b/src/mqtt/MQTT.cpp @@ -92,7 +92,11 @@ inline void onReceiveProto(char *topic, byte *payload, size_t length) if (isFromUs(e.packet)) { auto pAck = routingModule->allocAckNak(meshtastic_Routing_Error_NONE, getFrom(e.packet), e.packet->id, ch.index); pAck->transport_mechanism = meshtastic_MeshPacket_TransportMechanism_TRANSPORT_MQTT; - router->sendLocal(pAck); + // sendLocal consumes packets sent to a live interface, but returns SHOULD_RELEASE + // when it handled a local delivery synchronously. Match MeshService::sendToMesh's + // ownership contract so the MQTT acknowledgement cannot leak on the local path. + if (router->sendLocal(pAck) == ERRNO_SHOULD_RELEASE) + packetPool.release(pAck); } else { LOG_INFO("Ignore downlink message we originally sent"); } @@ -123,6 +127,12 @@ inline void onReceiveProto(char *topic, byte *payload, size_t length) p->which_payload_variant = e.packet->which_payload_variant; memcpy(&p->decoded, &e.packet->decoded, std::max(sizeof(p->decoded), sizeof(p->encrypted))); + // A decoded MQTT packet has already crossed the broker boundary, so authenticate it in place + // before handing it to the router. The routing-auth cache intentionally keeps an authenticated + // copy separate for encrypted packets, but a cache hit alone does not update this packet's + // xeddsa_signed marker (which is part of the downstream message metadata). + bool decodedAuthChecked = false; + if (p->which_payload_variant == meshtastic_MeshPacket_decoded_tag) { if (moduleConfig.mqtt.encryption_enabled) { LOG_INFO("Ignore decoded message on MQTT, encryption is enabled"); @@ -139,11 +149,14 @@ inline void onReceiveProto(char *topic, byte *payload, size_t length) // protection for known signers. Without this, a peer on a plaintext broker could // impersonate a signing node with unsigned broadcasts. Hold cryptLock like the RF path // (perhapsDecode) does - checkXeddsaReceivePolicy -> xeddsa_verify mutates shared - // CryptoEngine cache state, and MQTT ingress can run on a different task. - if (passesRoutingAuthGate(p.get()) != RoutingAuthVerdict::ACCEPT) { + // CryptoEngine cache state, and MQTT ingress can run on a different task. The in-place + // call preserves the verified xeddsa_signed marker for downstream routing/UI consumers. + concurrency::LockGuard g(cryptLock); + if (!checkXeddsaReceivePolicy(p.get())) { LOG_INFO("Ignore decoded message failing XEdDSA policy"); return; } + decodedAuthChecked = true; #endif } @@ -155,7 +168,7 @@ inline void onReceiveProto(char *topic, byte *payload, size_t length) // likely they discovered each other via a channel we have downlink enabled for if (isToUs(p.get()) || (nodeInfoLiteHasUser(tx) && nodeInfoLiteHasUser(rx))) router->enqueueReceivedMessage(p.release()); - } else if (router && passesRoutingAuthGate(p.get()) == RoutingAuthVerdict::ACCEPT) + } else if (router && (decodedAuthChecked || passesRoutingAuthGate(p.get()) == RoutingAuthVerdict::ACCEPT)) router->enqueueReceivedMessage(p.release()); } diff --git a/test/test_fuzz_packets/test_main.cpp b/test/test_fuzz_packets/test_main.cpp index 196962aeada..0cd2b77da6e 100644 --- a/test/test_fuzz_packets/test_main.cpp +++ b/test/test_fuzz_packets/test_main.cpp @@ -158,8 +158,9 @@ void test_E1_perhaps_decode_fuzz(void) rngFill(p.encrypted.bytes, p.encrypted.size); DecodeState st = perhapsDecode(&p); - // Any verdict is fine; the contract is that arbitrary ciphertext never crashes the pipeline. - TEST_ASSERT_TRUE(st == DECODE_SUCCESS || st == DECODE_FAILURE || st == DECODE_FATAL); + // Unknown-channel ciphertext is intentionally opaque relay-only traffic; all four verdicts + // are valid here. The contract is that arbitrary ciphertext never crashes the pipeline. + TEST_ASSERT_TRUE(st == DECODE_SUCCESS || st == DECODE_FAILURE || st == DECODE_FATAL || st == DECODE_OPAQUE); } } diff --git a/test/test_mqtt/MQTT.cpp b/test/test_mqtt/MQTT.cpp index 270e8abe31a..f241b1ca319 100644 --- a/test/test_mqtt/MQTT.cpp +++ b/test/test_mqtt/MQTT.cpp @@ -53,6 +53,13 @@ class MockRouter : public Router class MockMeshService : public MeshService { public: + ~MockMeshService() + { + // handleFromRadio queues packet copies for the phone. There is no phone poller in this + // unit test, so release any queued copies before LeakSanitizer checks process shutdown. + while (auto *p = getForPhone()) + releaseToPool(p); + } void sendMqttMessageToClientProxy(meshtastic_MqttClientProxyMessage *m) override { messages_.emplace_back(*m);