From 5a9fe443eeefb6cc8b595cc689b74b4ab86c33f1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thomas=20G=C3=B6ttgens?= Date: Wed, 29 Jul 2026 20:58:29 +0200 Subject: [PATCH 1/6] Include warm-tier signers in the identity update gate --- src/mesh/NodeDB.cpp | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/mesh/NodeDB.cpp b/src/mesh/NodeDB.cpp index 32e851d8b88..ccb75237fd5 100644 --- a/src/mesh/NodeDB.cpp +++ b/src/mesh/NodeDB.cpp @@ -3506,8 +3506,9 @@ bool NodeDB::updateUser(uint32_t nodeId, meshtastic_User &p, uint8_t channelInde { // Only a signed update may change the identity of a node that has proven it signs; our own record is // exempt. Checked before getOrCreateMeshNode so a refused update cannot evict or write the warm tier. - const meshtastic_NodeInfoLite *existing = getMeshNode(nodeId); - if (nodeId != getNodeNum() && existing && nodeInfoLiteHasXeddsaSigned(existing) && !xeddsaSigned) { + // isKnownXeddsaSigner consults the warm tier as well: getMeshNode() alone misses a signer that + // has been evicted, and this check runs before getOrCreateMeshNode() rehydrates it. + if (nodeId != getNodeNum() && isKnownXeddsaSigner(nodeId) && !xeddsaSigned) { LOG_WARN("Refusing unsigned identity update for node 0x%08x that previously signed", nodeId); return false; } From 7f65511ff7f21656881cf3f4c221f13885fd355b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thomas=20G=C3=B6ttgens?= Date: Wed, 29 Jul 2026 20:58:51 +0200 Subject: [PATCH 2/6] Fail the send when PKI encryption fails --- src/mesh/Router.cpp | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/mesh/Router.cpp b/src/mesh/Router.cpp index cf0e05c9ae8..2487aca32bf 100644 --- a/src/mesh/Router.cpp +++ b/src/mesh/Router.cpp @@ -1172,7 +1172,12 @@ meshtastic_Routing_Error perhapsEncode(meshtastic_MeshPacket *p) *destKey.bytes); return meshtastic_Routing_Error_PKI_FAILED; } - crypto->encryptCurve25519(p->to, getFrom(p), destKey, p->id, numbytes, bytes, p->encrypted.bytes); + // On failure encrypted.bytes holds no ciphertext, so continuing would put the plaintext + // on the air labelled pki_encrypted. + if (!crypto->encryptCurve25519(p->to, getFrom(p), destKey, p->id, numbytes, bytes, p->encrypted.bytes)) { + LOG_WARN("PKI encryption failed for destination node 0x%08x", p->to); + return meshtastic_Routing_Error_PKI_FAILED; + } numbytes += MESHTASTIC_PKC_OVERHEAD; p->channel = 0; p->pki_encrypted = true; From d899e9bf8fa9f853b84fca16e37345ce50f8f434 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thomas=20G=C3=B6ttgens?= Date: Wed, 29 Jul 2026 20:59:32 +0200 Subject: [PATCH 3/6] Require signatures on licensed unicasts --- src/mesh/Router.cpp | 12 +++--------- 1 file changed, 3 insertions(+), 9 deletions(-) diff --git a/src/mesh/Router.cpp b/src/mesh/Router.cpp index 2487aca32bf..84e6485249a 100644 --- a/src/mesh/Router.cpp +++ b/src/mesh/Router.cpp @@ -681,15 +681,9 @@ bool checkXeddsaReceivePolicy(meshtastic_MeshPacket *p) if (compatible) return true; - // In Balanced, preserve legacy unsigned-unicast compatibility and only reject the class a - // signing node always signs: a non-PKI broadcast whose signed encoding would still fit the - // LoRa frame. Canonical sizing removes unknown protobuf fields before mirroring the - // sender-side signedDataFits() gate, so this counts the same fields that gate counted. - // Unicast 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 (PKI already returned above). - // isKnownXeddsaSigner consults the warm tier too: a signer evicted from the hot store - // must not become impersonatable via unsigned broadcasts until it is re-heard. - if (nodeDB->isKnownXeddsaSigner(p->from) && isBroadcast(p->to)) { + // Balanced rejects only what a signer always signs: non-PKI broadcasts whose signed encoding + // would have fit, plus unicasts on ham where licensed senders sign too. Mirrors perhapsEncode. + if (nodeDB->isKnownXeddsaSigner(p->from) && (isBroadcast(p->to) || owner.is_licensed)) { size_t canonicalSize; if (!canonicalSignableSize(&p->decoded, &canonicalSize)) return true; // can't size it; never drop on a sizing failure From bd5139e351bd7b9271cd071ea3f4cc0e92bdc2d1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thomas=20G=C3=B6ttgens?= Date: Wed, 29 Jul 2026 21:00:45 +0200 Subject: [PATCH 4/6] Include warm-tier signers in the NodeInfo downgrade drop --- src/modules/NodeInfoModule.cpp | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/src/modules/NodeInfoModule.cpp b/src/modules/NodeInfoModule.cpp index cf6180ca28e..91ec9d6338f 100644 --- a/src/modules/NodeInfoModule.cpp +++ b/src/modules/NodeInfoModule.cpp @@ -53,10 +53,9 @@ bool NodeInfoModule::handleReceivedProtobuf(const meshtastic_MeshPacket &mp, mes 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)) { + // Broadcasts only: unicast NodeInfo is unsigned off ham, so updateUser refuses the identity + // write instead. isKnownXeddsaSigner also covers the warm tier. + if (nodeDB->isKnownXeddsaSigner(sourceNum) && !mp.xeddsa_signed && isBroadcast(mp.to)) { LOG_WARN("Dropping unsigned NodeInfo broadcast from node 0x%08x that previously signed", sourceNum); return true; } From 3f3e1864fc85f84422dfe4a5ad2df8c1d4d772df Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thomas=20G=C3=B6ttgens?= Date: Wed, 29 Jul 2026 21:01:05 +0200 Subject: [PATCH 5/6] Clamp hop fields on UDP multicast ingress --- src/mesh/udp/UdpMulticastHandler.h | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/mesh/udp/UdpMulticastHandler.h b/src/mesh/udp/UdpMulticastHandler.h index 60967ccefc6..4a05d1292c8 100644 --- a/src/mesh/udp/UdpMulticastHandler.h +++ b/src/mesh/udp/UdpMulticastHandler.h @@ -79,6 +79,11 @@ class UdpMulticastHandler final LOG_WARN("UDP packet with spoofed local from=0x%08x, dropping", mp.from); return; } + // Same clamp the MQTT ingress applies: an out-of-range hop count is not relayable. + if (mp.hop_limit > HOP_MAX || mp.hop_start > HOP_MAX) { + LOG_WARN("UDP packet with invalid hop_limit(%u) or hop_start(%u), dropping", mp.hop_limit, mp.hop_start); + return; + } mp.transport_mechanism = meshtastic_MeshPacket_TransportMechanism_TRANSPORT_MULTICAST_UDP; // Authentication metadata is local-only; Router re-establishes it after successful PKI decryption. mp.pki_encrypted = false; From 62e6e3c632aa0fcf381521e7927e75a10fe1931f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thomas=20G=C3=B6ttgens?= Date: Wed, 29 Jul 2026 22:43:33 +0200 Subject: [PATCH 6/6] Address review comments on signing hardening Condense the updateUser rationale to two lines and stop calling the Balanced-mode drop a broadcast now that licensed unicasts reach it. --- src/mesh/NodeDB.cpp | 6 ++---- src/mesh/Router.cpp | 2 +- 2 files changed, 3 insertions(+), 5 deletions(-) diff --git a/src/mesh/NodeDB.cpp b/src/mesh/NodeDB.cpp index ccb75237fd5..7b126314714 100644 --- a/src/mesh/NodeDB.cpp +++ b/src/mesh/NodeDB.cpp @@ -3504,10 +3504,8 @@ void NodeDB::addFromContact(meshtastic_SharedContact contact) */ bool NodeDB::updateUser(uint32_t nodeId, meshtastic_User &p, uint8_t channelIndex, bool xeddsaSigned) { - // Only a signed update may change the identity of a node that has proven it signs; our own record is - // exempt. Checked before getOrCreateMeshNode so a refused update cannot evict or write the warm tier. - // isKnownXeddsaSigner consults the warm tier as well: getMeshNode() alone misses a signer that - // has been evicted, and this check runs before getOrCreateMeshNode() rehydrates it. + // Only a signed update may change the identity of a proven signer; our own record is exempt. + // Checked before getOrCreateMeshNode so a refusal cannot evict; isKnownXeddsaSigner covers the warm tier. if (nodeId != getNodeNum() && isKnownXeddsaSigner(nodeId) && !xeddsaSigned) { LOG_WARN("Refusing unsigned identity update for node 0x%08x that previously signed", nodeId); return false; diff --git a/src/mesh/Router.cpp b/src/mesh/Router.cpp index 84e6485249a..3abcf64b33c 100644 --- a/src/mesh/Router.cpp +++ b/src/mesh/Router.cpp @@ -688,7 +688,7 @@ bool checkXeddsaReceivePolicy(meshtastic_MeshPacket *p) if (!canonicalSignableSize(&p->decoded, &canonicalSize)) return true; // can't size it; never drop on a sizing failure if (canonicalSize + XEDDSA_SIGNATURE_FIELD_BYTES + MESHTASTIC_HEADER_LENGTH <= MAX_LORA_PAYLOAD_LEN) { - LOG_WARN("Dropping unsigned broadcast from 0x%08x that previously signed", p->from); + LOG_WARN("Dropping unsigned packet from 0x%08x that previously signed", p->from); return false; } }