Merge branch 'develop' into agent/keyverification-allocation-safety

This commit is contained in:
Ben Meadors
2026-07-21 16:35:38 -05:00
committed by GitHub
co-authored by GitHub
20 changed files with 1212 additions and 157 deletions
+20
View File
@@ -1111,6 +1111,23 @@ void setup()
#endif
#endif
#if defined(SENSECAP_INDICATOR)
// The ST7701 panel shares SCK/MOSI/MISO (41/48/47) with the SX1262, and its host is SPI2_HOST,
// which on the S3 is the same peripheral as the Arduino `SPI` object (FSPI == SPI2).
// LovyanGFX bit-bangs the ST7701 init sequence on those pins, and because this variant builds
// with USE_ARDUINO_HAL_GPIO it does so via Arduino pinMode()/digitalWrite(). pinMode() calls
// perimanSetPinBus(.., ESP32_BUS_TYPE_GPIO, ..), whose deinit callback (spiDetachBus_SCK) ends
// up in spiStopBus() and gates the SPI2 clock. RadioLib then spins forever in spiTransferByte()
// waiting on cmd.update, which never clears on a stopped peripheral, and the watchdog fires.
//
// Restart the bus here, after the panel is up and before the radio is touched. Note that
// SPIClass::begin() early-returns when _spi is already non-NULL, so end() first is required.
SPI.end();
SPI.begin(LORA_SCK, LORA_MISO, LORA_MOSI, -1); // CS is an IO-expander pin, driven by RadioLib
SPI.setFrequency(4000000);
LOG_DEBUG("SPI2 restarted after ST7701 init (SCK=%d, MISO=%d, MOSI=%d)", LORA_SCK, LORA_MISO, LORA_MOSI);
#endif
auto rIf = initLoRa();
lateInitVariant(); // Do board specific init (see extra_variants/README.md for documentation)
@@ -1278,6 +1295,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;
}
+17 -18
View File
@@ -51,7 +51,7 @@ 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);
if (reprocessPacket(p))
perhapsRebroadcast(p);
}
} else {
@@ -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<meshtastic_MeshPacket *>(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<meshtastic_MeshPacket *>(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<meshtastic_MeshPacket *>(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<int>(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)
+1 -1
View File
@@ -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
+21 -3
View File
@@ -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,7 +84,7 @@ 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);
if (reprocessPacket(p))
perhapsRebroadcast(p);
}
} else {
@@ -73,8 +92,7 @@ bool NextHopRouter::shouldFilterReceived(const meshtastic_MeshPacket *p)
// 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);
}
}
+1
View File
@@ -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
+4
View File
@@ -1861,6 +1861,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<uint8_t>(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<uint8_t>(WarmProtected::Flag);
@@ -3934,6 +3936,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<uint8_t>(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
+248 -20
View File
@@ -12,6 +12,8 @@
#include "mesh-pb-constants.h"
#include "meshUtils.h"
#include "modules/RoutingModule.h"
#include <ErriezCRC32.h>
#include <pb_decode.h>
#include <pb_encode.h>
#if HAS_TRAFFIC_MANAGEMENT
#endif
@@ -66,6 +68,79 @@ Allocator<meshtastic_MeshPacket> &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
*
@@ -84,6 +159,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)
@@ -248,8 +325,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);
@@ -500,18 +579,55 @@ static bool canonicalSignableSize(meshtastic_Data *d, size_t *size)
return pb_get_encoded_size(size, &meshtastic_Data_msg, d);
}
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)
{
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 {
@@ -519,7 +635,16 @@ bool checkXeddsaReceivePolicy(meshtastic_MeshPacket *p)
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
@@ -530,15 +655,24 @@ bool checkXeddsaReceivePolicy(meshtastic_MeshPacket *p)
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. Size p->decoded
// canonically so this counts the same fields the sender's signedDataFits() gate counted;
// adding XEDDSA_SIGNATURE_FIELD_BYTES to that unsigned base mirrors it exactly, 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.
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 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) && !p->pki_encrypted && isBroadcast(p->to)) {
if (nodeDB->isKnownXeddsaSigner(p->from) && isBroadcast(p->to)) {
size_t canonicalSize;
if (!canonicalSignableSize(&p->decoded, &canonicalSize))
return true; // can't size it; never drop on a sizing failure
@@ -552,6 +686,55 @@ bool checkXeddsaReceivePolicy(meshtastic_MeshPacket *p)
}
#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;
}
#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.
@@ -613,17 +796,25 @@ 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)
meshtastic_NodeInfoLite *ourNode = nullptr;
if (p->channel == 0 && isToUs(p) && p->to > 0 && !isBroadcast(p->to) && rawSize > MESHTASTIC_PKC_OVERHEAD &&
(ourNode = nodeDB->getMeshNode(p->to)) != nullptr && ourNode->public_key.size > 0) {
pkiAttempted = true;
LOG_DEBUG("Attempt PKI decryption");
// Resolve the sender's public key only for actual PKI-decrypt candidates: prefer NodeDB
// (hot store or warm tier), else a not-yet-committed key held during an in-progress
// key-verification handshake. On a full NodeDB miss, copyPublicKey() falls through to a
@@ -708,6 +899,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);
@@ -743,10 +935,9 @@ DecodeState perhapsDecode(meshtastic_MeshPacket *p)
p->channel = chIndex; // change to store the index instead of the hash
#if !(MESHTASTIC_EXCLUDE_PKI) && !(MESHTASTIC_EXCLUDE_XEDDSA)
// Runs before the bitfield merge below: that merge can set want_response, adding wire bytes
// the sender never encoded and skewing the policy's sizing of p->decoded.
// Run before merging local-only bitfield state into the decoded Data.
if (!checkXeddsaReceivePolicy(p))
return DecodeState::DECODE_FAILURE;
return DecodeState::DECODE_POLICY_REJECT;
#endif
if (p->decoded.has_bitfield)
@@ -804,7 +995,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;
}
}
@@ -1015,8 +1206,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
@@ -1027,11 +1216,30 @@ 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");
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) {
@@ -1108,7 +1316,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
@@ -1157,6 +1365,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;
@@ -1164,30 +1373,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;
+14 -16
View File
@@ -112,6 +112,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:
@@ -161,7 +164,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).
@@ -170,26 +174,20 @@ enum DecodeState { DECODE_SUCCESS, DECODE_FAILURE, DECODE_FATAL };
*/
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);
#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).
*
* The fit test sizes p->decoded with the real encoder, so it measures the fields the sender
* encoded rather than any raw wire length.
*
* 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. The caller must hold cryptLock.
* Returns false when the packet must be dropped. */
bool checkXeddsaReceivePolicy(meshtastic_MeshPacket *p);
#endif
+1 -1
View File
@@ -63,7 +63,7 @@ static constexpr uint32_t WARM_SIGNER_MASK = 0x01u;
enum class WarmFormat : uint8_t { Current, V2, V1 };
// 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, bool signer)
{
+3 -3
View File
@@ -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;
+10
View File
@@ -1136,6 +1136,16 @@ void AdminModule::handleSetConfig(const meshtastic_Config &c, bool fromOthers)
rotated.private_key = incoming.private_key;
incoming = rotated;
}
#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.
+3 -6
View File
@@ -151,6 +151,7 @@ inline void onReceiveProto(char *topic, byte *payload, size_t length)
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->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)));
@@ -175,13 +176,10 @@ 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())) {
if (passesRoutingAuthGate(p.get()) != RoutingAuthVerdict::ACCEPT) {
LOG_INFO("Ignore decoded message failing XEdDSA policy");
return;
}
}
#endif
}
@@ -193,8 +191,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());
}
@@ -21,6 +21,10 @@
#include "support/MockMeshService.h"
#include <cstring>
#ifdef ARCH_PORTDUINO
#include "platform/portduino/PortduinoGlue.h"
#endif
static constexpr NodeNum LOCAL_NODE = 0x0A0A0A0A;
static constexpr NodeNum ADMIN_NODE = 0x0B0B0B0B; // authorized admin, sends remote admin to us
static constexpr NodeNum QUERIED_NODE = 0x0C0C0C0C; // a remote we send admin requests to
@@ -157,6 +161,14 @@ void setUp(void)
nodeDB = mockNodeDB;
myNodeInfo.my_node_num = LOCAL_NODE;
#ifdef ARCH_PORTDUINO
// The native test harness boots Portduino in simulated mode, and wouldEncryptWithPKC()
// hard-disables PKC whenever force_simradio is set. Left true, no outgoing admin request is
// ever key-pinned, so the pinning tests below cannot exercise what they are asserting.
// Model a real (non-sim) device instead.
portduino_config.force_simradio = false;
#endif
config = meshtastic_LocalConfig_init_zero;
// A real device always holds a private key; without one perhapsEncode never picks PKC.
config.security.private_key.size = 32;
+2 -1
View File
@@ -159,7 +159,8 @@ void test_E1_perhaps_decode_fuzz(void)
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);
TEST_ASSERT_TRUE(st == DECODE_SUCCESS || st == DECODE_FAILURE || st == DECODE_OPAQUE || st == DECODE_FATAL ||
st == DECODE_POLICY_REJECT);
}
}
+68
View File
@@ -53,6 +53,15 @@ class MockRouter : public Router
class MockMeshService : public MeshService
{
public:
// No PhoneAPI reader exists in these tests, so packets the receive pipeline forwards to the phone
// (MeshService::sendToPhone enqueues pooled copies into toPhoneQueue) would leak at teardown. Drain
// the queue like the phone would. This surfaced once sendLocal() began dispatching local packets
// through handleReceived() directly rather than via the (mock-overridden) enqueueReceivedMessage().
~MockMeshService()
{
while (meshtastic_MeshPacket *p = getForPhone())
releaseToPool(p);
}
void sendMqttMessageToClientProxy(meshtastic_MqttClientProxyMessage *m) override
{
messages_.emplace_back(*m);
@@ -334,6 +343,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{
@@ -627,6 +637,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;
@@ -718,6 +730,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();
@@ -729,6 +743,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);
@@ -760,6 +776,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;
@@ -775,6 +793,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.
@@ -1096,6 +1161,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);
File diff suppressed because it is too large Load Diff
@@ -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
+3
View File
@@ -15,6 +15,9 @@ 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
lib_deps =
${stm32_base.lib_deps}
+1 -1
View File
@@ -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.
-DMESHTASTIC_EXCLUDE_PKT_HISTORY_HASH=1
-DMESHTASTIC_EXCLUDE_RANGETEST=1
-DMESHTASTIC_EXCLUDE_WAYPOINT=1