feat(security): enforce packet authenticity policies
This commit is contained in:
+4
-1
@@ -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;
|
||||
}
|
||||
|
||||
+18
-19
@@ -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<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)
|
||||
|
||||
@@ -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();
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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<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);
|
||||
@@ -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<uint8_t>(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<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
|
||||
|
||||
@@ -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.
|
||||
|
||||
+244
-18
@@ -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
|
||||
#include "modules/TrafficManagementModule.h"
|
||||
@@ -67,6 +69,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
|
||||
*
|
||||
@@ -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;
|
||||
|
||||
+20
-7
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
{
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
+6
-9
@@ -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());
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 <ErriezCRC32.h>
|
||||
#include <cstdio>
|
||||
#include <cstring>
|
||||
#include <memory>
|
||||
@@ -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<uint8_t>(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
|
||||
{
|
||||
public:
|
||||
using NodeInfoModule::handleReceivedProtobuf; // protected virtual -> exposed for direct call
|
||||
};
|
||||
|
||||
static meshtastic_MeshPacket makeNodeInfoPacket(bool signed_)
|
||||
static void preparePipelineSigner(NodeNum sender)
|
||||
{
|
||||
// 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;
|
||||
uint8_t pub[32], priv[32];
|
||||
crypto->generateKeyPair(pub, priv);
|
||||
mockNodeDB->addNode(sender);
|
||||
mockNodeDB->setPublicKey(sender, pub);
|
||||
}
|
||||
|
||||
// C1: unsigned NodeInfo from a node that previously signed -> dropped.
|
||||
void test_C1_unsigned_nodeinfo_from_signer_dropped(void)
|
||||
static void runPipelineIngress(const meshtastic_MeshPacket &p)
|
||||
{
|
||||
meshtastic_MeshPacket *copy = packetPool.allocCopy(p);
|
||||
TEST_ASSERT_NOT_NULL(copy);
|
||||
pipelineRouter->enqueueReceivedMessage(copy);
|
||||
pipelineRouter->runOnce();
|
||||
}
|
||||
|
||||
static void assertNoRejectedPipelineEffects(NodeNum sender, uint32_t lastHeardBefore)
|
||||
{
|
||||
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);
|
||||
}
|
||||
|
||||
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<int>(RoutingAuthVerdict::ACCEPT), static_cast<int>(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));
|
||||
}
|
||||
|
||||
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));
|
||||
}
|
||||
|
||||
void test_C3_invalid_repeated_packet_cannot_ack_or_change_retry_state(void)
|
||||
{
|
||||
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));
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
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<int>(RoutingAuthVerdict::ACCEPT), static_cast<int>(passesRoutingAuthGate(&valid)));
|
||||
TEST_ASSERT_TRUE(pipelineRouter->filter(&valid));
|
||||
TEST_ASSERT_EQUAL(1, pipelineRadio->removeCalls);
|
||||
}
|
||||
|
||||
void test_C6_opaque_unknown_channel_is_relay_only(void)
|
||||
{
|
||||
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<int>(RoutingAuthVerdict::OPAQUE_RELAY_ONLY), static_cast<int>(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<uint32_t>(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));
|
||||
}
|
||||
}
|
||||
|
||||
void test_C7_strict_rejects_unsigned_decoded_simradio_ingress(void)
|
||||
{
|
||||
setPolicy(meshtastic_Config_SecurityConfig_PacketSignaturePolicy_PACKET_SIGNATURE_POLICY_STRICT);
|
||||
mockNodeDB->addNode(REMOTE_NODE);
|
||||
mockNodeDB->setSignerBit(REMOTE_NODE, true);
|
||||
|
||||
NodeInfoTestShim shim;
|
||||
meshtastic_MeshPacket mp = makeNodeInfoPacket(/*signed_=*/false);
|
||||
meshtastic_User user = meshtastic_User_init_zero;
|
||||
user.is_licensed = owner.is_licensed;
|
||||
|
||||
TEST_ASSERT_TRUE_MESSAGE(shim.handleReceivedProtobuf(mp, &user), "unsigned NodeInfo from signer must be dropped");
|
||||
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));
|
||||
}
|
||||
|
||||
// C2: signed NodeInfo from a known signer -> not dropped by this rule.
|
||||
void test_C2_signed_nodeinfo_from_signer_not_dropped(void)
|
||||
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);
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
NodeInfoTestShim shim;
|
||||
meshtastic_MeshPacket mp = makeNodeInfoPacket(/*signed_=*/true);
|
||||
meshtastic_User user = meshtastic_User_init_zero;
|
||||
user.is_licensed = owner.is_licensed;
|
||||
|
||||
TEST_ASSERT_FALSE(shim.handleReceivedProtobuf(mp, &user));
|
||||
const uint32_t lastHeard = mockNodeDB->getMeshNode(REMOTE_NODE)->last_heard;
|
||||
runPipelineIngress(malformed);
|
||||
assertNoRejectedPipelineEffects(REMOTE_NODE, lastHeard);
|
||||
TEST_ASSERT_FALSE(pipelineRouter->historyContains(&malformed));
|
||||
}
|
||||
|
||||
// C3: unsigned NodeInfo from a node we've never seen sign -> not dropped.
|
||||
void test_C3_unsigned_nodeinfo_from_nonsigner_not_dropped(void)
|
||||
{
|
||||
mockNodeDB->addNode(REMOTE_NODE); // signer bit clear
|
||||
|
||||
NodeInfoTestShim shim;
|
||||
meshtastic_MeshPacket mp = makeNodeInfoPacket(/*signed_=*/false);
|
||||
meshtastic_User user = meshtastic_User_init_zero;
|
||||
user.is_licensed = owner.is_licensed;
|
||||
|
||||
TEST_ASSERT_FALSE(shim.handleReceivedProtobuf(mp, &user));
|
||||
}
|
||||
|
||||
// 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_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);
|
||||
mockNodeDB->setSignerBit(REMOTE_NODE, true);
|
||||
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));
|
||||
}
|
||||
|
||||
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_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);
|
||||
|
||||
TEST_ASSERT_FALSE_MESSAGE(shim.handleReceivedProtobuf(mp, &user),
|
||||
"unsigned unicast NodeInfo from a signer must not be dropped");
|
||||
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));
|
||||
}
|
||||
|
||||
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<int>(RoutingAuthVerdict::REJECT), static_cast<int>(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<AuthPipelineRadio>();
|
||||
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);
|
||||
RUN_TEST(test_B7_infrastructure_port_signing_matrix);
|
||||
|
||||
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);
|
||||
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);
|
||||
|
||||
|
||||
@@ -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
|
||||
upload_port = stlink
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
board_build.ldscript = $PROJECT_DIR/variants/stm32/stm32wle5xx.ld
|
||||
|
||||
Reference in New Issue
Block a user