Signing and ingress hardening (#11282)

* Include warm-tier signers in the identity update gate

* Fail the send when PKI encryption fails

* Require signatures on licensed unicasts

* Include warm-tier signers in the NodeInfo downgrade drop

* Clamp hop fields on UDP multicast ingress

* 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.
This commit is contained in:
Thomas Göttgens
2026-07-29 22:28:25 +00:00
committed by GitHub
co-authored by GitHub
parent 50a37b3a19
commit df6e67f70b
4 changed files with 21 additions and 19 deletions
+3 -4
View File
@@ -3504,10 +3504,9 @@ 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.
const meshtastic_NodeInfoLite *existing = getMeshNode(nodeId);
if (nodeId != getNodeNum() && existing && nodeInfoLiteHasXeddsaSigned(existing) && !xeddsaSigned) {
// 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;
}
+10 -11
View File
@@ -681,20 +681,14 @@ 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
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;
}
}
@@ -1172,7 +1166,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;
+5
View File
@@ -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;
+3 -4
View File
@@ -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;
}