Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 2 additions & 4 deletions src/mesh/FloodingRouter.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -68,10 +68,8 @@ 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<meshtastic_MeshPacket *>(p)) != RoutingAuthVerdict::ACCEPT)
// Re-authenticate before replacing the queued lower-hop copy so future callers remain safe.
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
Expand Down
86 changes: 51 additions & 35 deletions src/mesh/Router.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -168,16 +168,15 @@ static bool routingAuthCacheMatches(const meshtastic_MeshPacket &packet)
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;
if (routingAuthCache.policy != config.security.packet_signature_policy)
return false;
}
return true;
return memcmp(&routingAuthCache.wire, &packet, sizeof(packet)) == 0;
}

static void storeRoutingAuthCache(const meshtastic_MeshPacket &wire, const meshtastic_MeshPacket &authenticated)
{
if (!routingAuthCacheLock)
return;
concurrency::LockGuard guard(routingAuthCacheLock);
routingAuthCache.wire = wire;
routingAuthCache.authenticated = authenticated;
Expand Down Expand Up @@ -720,6 +719,8 @@ static NodeInfoBootstrapResult verifyFirstContactNodeInfo(meshtastic_MeshPacket
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;
p->public_key.size = user.public_key.size;
memcpy(p->public_key.bytes, user.public_key.bytes, user.public_key.size);
LOG_DEBUG("Verified first-contact XEdDSA NodeInfo from 0x%08x", p->from);
return NodeInfoBootstrapResult::VERIFIED;
}
Expand All @@ -742,6 +743,8 @@ bool checkXeddsaReceivePolicy(meshtastic_MeshPacket *p)
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) {
memcpy(p->public_key.bytes, senderKey.bytes, 32);
p->public_key.size = 32;
// 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.
Expand Down Expand Up @@ -800,61 +803,66 @@ bool checkXeddsaReceivePolicy(meshtastic_MeshPacket *p)
}
#endif

RoutingAuthVerdict passesRoutingAuthGate(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;
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");
if (!checkXeddsaReceivePolicy(&authenticated)) {
LOG_WARN("Already-decoded packet rejected by routing signature policy");
return RoutingAuthVerdict::REJECT;
}
#endif
p->xeddsa_signed = authCandidate.xeddsa_signed;
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");
LOG_WARN("Packet rejected by routing signature policy");
return RoutingAuthVerdict::REJECT;
}
if (state == DecodeState::DECODE_FATAL) {
LOG_WARN("Fatal decode error, drop packet");
LOG_WARN("Fatal decode error during routing authentication");
return RoutingAuthVerdict::REJECT;
}
if (state == DecodeState::DECODE_FAILURE) {
// One-byte hash collisions are indistinguishable from tampering, so relay opaquely
// instead of blackholing; isFromUs stays REJECT to keep forged senders off the ACK path.
if (!isToUs(p) && !isFromUs(p)) {
if (!isToUs(&wire) && !isFromUs(&wire)) {
LOG_WARN("Decryptable packet failed decoding, relay opaquely");
return RoutingAuthVerdict::OPAQUE_RELAY_ONLY;
}
LOG_WARN("Decryptable packet failed decoding, drop");
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;
}

#if !(MESHTASTIC_EXCLUDE_PKI)
// The fallback costs three X25519 ops before the AEAD tag is checked. Budget is global because p->from is
// attacker-controlled; successful runs refund, and their key is then persisted for the fast path.
Expand Down Expand Up @@ -932,6 +940,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)) {
Expand Down Expand Up @@ -1423,7 +1432,7 @@ void Router::deliverLocal(meshtastic_MeshPacket *p, RxSource src)
* 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)
{
{
concurrency::LockGuard g(&deferredLock);
Expand All @@ -1434,7 +1443,7 @@ void Router::handleReceived(meshtastic_MeshPacket *p, RxSource src)
#endif
}

dispatchReceived(p, src);
dispatchReceived(p, src, routingAuthRequired);

// Decide "am I the last frame" and drop the depth in one critical section. Splitting them lets
// two frames both read the same pre-decrement value, skip the drain, and strand the ring.
Expand All @@ -1455,12 +1464,12 @@ void Router::handleReceived(meshtastic_MeshPacket *p, RxSource src)
}
// Depth stays at 1 across the drain, so a loopback from these modules defers instead of
// recursing, and dispatch runs outside the lock.
dispatchReceived(d.p, d.src);
dispatchReceived(d.p, d.src, false);
packetPool.release(d.p);
}
}

void Router::dispatchReceived(meshtastic_MeshPacket *p, RxSource src)
void Router::dispatchReceived(meshtastic_MeshPacket *p, RxSource src, bool routingAuthRequired)
{
bool skipHandle = false;

Expand All @@ -1476,8 +1485,15 @@ void Router::dispatchReceived(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;
}

// Keep the decoded working packet and encrypted MQTT copy on the same local arrival timestamp.
// See computeRxTimeStamp() for the placeholder/has_rx_time semantics.
Expand Down Expand Up @@ -1710,6 +1726,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);
}
10 changes: 7 additions & 3 deletions src/mesh/Router.h
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,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;

Expand Down Expand Up @@ -188,14 +192,14 @@ class Router : protected concurrency::OSThread, protected PacketHistory
* Called from perhapsHandleReceived() for radio ingress and from deliverLocal() for our own
* loopback, so p may be locally generated. Does NOT free p; the caller still owns it.
*/
void handleReceived(meshtastic_MeshPacket *p, RxSource src = RX_SRC_RADIO);
void handleReceived(meshtastic_MeshPacket *p, RxSource src = RX_SRC_RADIO, bool routingAuthRequired = false);

/**
* The body of handleReceived(): decode, run modules, publish to MQTT. Split out so the
* depth-guarded drain in handleReceived() can process a deferred packet without re-entering
* the drain (and without touching handleDepth) - keeping the stack flat.
*/
void dispatchReceived(meshtastic_MeshPacket *p, RxSource src);
void dispatchReceived(meshtastic_MeshPacket *p, RxSource src, bool routingAuthRequired = false);

/**
* Route a packet addressed to us (or a local broadcast we loop back) into handleReceived().
Expand Down Expand Up @@ -265,7 +269,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();
void resetRoutingAuthEvaluationCount();
Expand Down
28 changes: 27 additions & 1 deletion src/modules/AdminModule.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,30 @@ 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;
bool authorizedLicensedSigner = false;
// Could tighten responses further by tracking the last public key queried.
if (licensedRemote) {
const bool directedAdmin = nodeDB && mp.to == nodeDB->getNodeNum() && !isBroadcast(mp.to) &&
mp.decoded.portnum == meshtastic_PortNum_ADMIN_APP && !mp.pki_encrypted;
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(mp.public_key.bytes, adminKey.bytes, 32) == 0) {
authorizedLicensedSigner = true;
break;
}
}
if (!messageIsResponse(r) && !authorizedLicensedSigner) {
LOG_INFO("Received signed licensed admin payload from a non-allowlisted key");
myReply = allocErrorResponse(meshtastic_Routing_Error_ADMIN_PUBLIC_KEY_UNAUTHORIZED, &mp);
return handled;
}
LOG_INFO("Signed licensed admin payload with Router-verified sender");
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
if (messageIsResponse(r)) {
// Only accept a response from a remote we sent the matching request to. from == 0 is a
// local client, which PhoneAPI has already gated.
Expand Down Expand Up @@ -179,6 +203,8 @@ bool AdminModule::handleReceivedProtobuf(const meshtastic_MeshPacket &mp, meshta
return handled;
}
#endif
} else if (authorizedLicensedSigner) {
// Router verified a plaintext licensed-mode signer against the admin allowlist above.
} else if (strcasecmp(ch->settings.name, Channels::adminChannel) == 0) {
if (!config.security.admin_channel_enabled) {
LOG_INFO("Ignore admin channel, legacy admin disabled");
Expand Down Expand Up @@ -206,7 +232,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
Expand Down
13 changes: 9 additions & 4 deletions src/mqtt/MQTT.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,7 @@ inline void onReceiveProto(char *topic, byte *payload, size_t length)
if (!pAck)
return;
pAck->transport_mechanism = meshtastic_MeshPacket_TransportMechanism_TRANSPORT_MQTT;
// Release locally handled ACKs only when sendLocal requests it.
if (router->sendLocal(pAck) == ERRNO_SHOULD_RELEASE)
packetPool.release(pAck);
} else {
Expand Down Expand Up @@ -160,6 +161,8 @@ inline void onReceiveProto(char *topic, byte *payload, size_t length)

if (shouldDropMqttDownlink(*p))
return;
// Authenticate decoded MQTT packets in place so xeddsa_signed reaches consumers.
bool decodedAuthChecked = false;

if (p->which_payload_variant == meshtastic_MeshPacket_decoded_tag) {
if (moduleConfig.mqtt.encryption_enabled) {
Expand All @@ -176,12 +179,14 @@ inline void onReceiveProto(char *topic, byte *payload, size_t length)
// signature policy here: verify a carried signature and apply unsigned-downgrade
// 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 msg failing XEdDSA policy");
return;
}
decodedAuthChecked = true;
#endif
}

Expand All @@ -193,7 +198,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());
}

Expand Down
Loading
Loading