Merge branch 'develop' into codex/packet-auth-policy

Resolve conflicts against the NodeDB signer/key primitives (#11050) and the
admin-key PKI decrypt budget (#11100).

- NodeDB: drop this branch's hasSeenXeddsaSigner in favour of develop's
  isKnownXeddsaSigner. They answer the same question, but develop's reads the
  dedicated warm signer bit (warmSignerOf) rather than the WarmProtected
  category, and TrafficManagementModule already depends on it. Keep develop's
  copyPublicKey/copyPublicKeyAuthoritative, isVerifiedSignerForKey and
  commitRemoteKey/KeyCommitTrust.
- checkXeddsaReceivePolicy: keep this branch's Strict/Balanced/Compatible
  policy, which is a superset of develop's balanced-only downgrade gate, and
  call isKnownXeddsaSigner from it. develop's !pki_encrypted term is dropped
  because the policy returns early for PKI packets before that check.
- perhapsDecode: keep develop's key resolution (NodeDB then pending-key, only
  for real PKI candidates) plus its admin-key token bucket, and re-apply this
  branch's pkiAttempted flag feeding the DECODE_OPAQUE verdict. Keep both
  passesRoutingAuthGate and adminKeyFallbackAllowed/Refund.
- test_A17: model eviction the way NodeDB actually does it, passing the warm
  signer bit as well as the XeddsaSigner category, since isKnownXeddsaSigner
  reads the former.

Native suite: 38 suites, 743/743 cases, no sanitizer findings.
This commit is contained in:
Ben Meadors
2026-07-21 06:09:57 -05:00
57 changed files with 4366 additions and 618 deletions
+1 -1
View File
@@ -1 +1 @@
37
38
+1
View File
@@ -9,6 +9,7 @@ class AdminModuleTestShim : public AdminModule
public:
using AdminModule::checkPassKey; // session-key gate seam (see test_admin_session_repro)
using AdminModule::handleGetConfig;
using AdminModule::handleGetModuleConfig;
using AdminModule::handleReceivedProtobuf;
using AdminModule::handleSetConfig;
using AdminModule::handleSetModuleConfig;
+106
View File
@@ -250,6 +250,107 @@ void test_local_security_config_keeps_private_key(void)
admin->drainReply();
}
// Decode the NetworkConfig / MqttConfig out of the response a handler queued in myReply.
static bool decodeNetworkFromReply(meshtastic_MeshPacket *reply, meshtastic_Config_NetworkConfig &out)
{
meshtastic_AdminMessage am = meshtastic_AdminMessage_init_zero;
if (!reply || reply->which_payload_variant != meshtastic_MeshPacket_decoded_tag)
return false;
if (!pb_decode_from_bytes(reply->decoded.payload.bytes, reply->decoded.payload.size, &meshtastic_AdminMessage_msg, &am))
return false;
if (am.which_payload_variant != meshtastic_AdminMessage_get_config_response_tag ||
am.get_config_response.which_payload_variant != meshtastic_Config_network_tag)
return false;
out = am.get_config_response.payload_variant.network;
return true;
}
static bool decodeMqttFromReply(meshtastic_MeshPacket *reply, meshtastic_ModuleConfig_MQTTConfig &out)
{
meshtastic_AdminMessage am = meshtastic_AdminMessage_init_zero;
if (!reply || reply->which_payload_variant != meshtastic_MeshPacket_decoded_tag)
return false;
if (!pb_decode_from_bytes(reply->decoded.payload.bytes, reply->decoded.payload.size, &meshtastic_AdminMessage_msg, &am))
return false;
if (am.which_payload_variant != meshtastic_AdminMessage_get_module_config_response_tag ||
am.get_module_config_response.which_payload_variant != meshtastic_ModuleConfig_mqtt_tag)
return false;
out = am.get_module_config_response.payload_variant.mqtt;
return true;
}
// A remote requester gets the sentinel; the set path swaps the stored value back.
void test_remote_network_config_omits_wifi_psk(void)
{
strcpy(config.network.wifi_psk, "hunter2hunter2");
meshtastic_MeshPacket req = makeGetConfigRequest(ADMIN_NODE);
admin->handleGetConfig(req, meshtastic_AdminMessage_ConfigType_NETWORK_CONFIG);
meshtastic_Config_NetworkConfig net;
TEST_ASSERT_TRUE(decodeNetworkFromReply(admin->reply(), net));
TEST_ASSERT_EQUAL_STRING_MESSAGE("sekrit", net.wifi_psk, "remote network config must not carry the real psk");
admin->drainReply();
}
// Control: the local path still receives the stored psk.
void test_local_network_config_keeps_wifi_psk(void)
{
strcpy(config.network.wifi_psk, "hunter2hunter2");
meshtastic_MeshPacket req = makeGetConfigRequest(0);
admin->handleGetConfig(req, meshtastic_AdminMessage_ConfigType_NETWORK_CONFIG);
meshtastic_Config_NetworkConfig net;
TEST_ASSERT_TRUE(decodeNetworkFromReply(admin->reply(), net));
TEST_ASSERT_EQUAL_STRING_MESSAGE("hunter2hunter2", net.wifi_psk, "local client must still receive the psk");
admin->drainReply();
}
void test_remote_mqtt_config_omits_password(void)
{
strcpy(moduleConfig.mqtt.password, "brokerpass");
meshtastic_MeshPacket req = makeGetConfigRequest(ADMIN_NODE);
admin->handleGetModuleConfig(req, meshtastic_AdminMessage_ModuleConfigType_MQTT_CONFIG);
meshtastic_ModuleConfig_MQTTConfig mqtt;
TEST_ASSERT_TRUE(decodeMqttFromReply(admin->reply(), mqtt));
TEST_ASSERT_EQUAL_STRING_MESSAGE("sekrit", mqtt.password, "remote mqtt config must not carry the broker password");
admin->drainReply();
}
void test_local_mqtt_config_keeps_password(void)
{
strcpy(moduleConfig.mqtt.password, "brokerpass");
meshtastic_MeshPacket req = makeGetConfigRequest(0);
admin->handleGetModuleConfig(req, meshtastic_AdminMessage_ModuleConfigType_MQTT_CONFIG);
meshtastic_ModuleConfig_MQTTConfig mqtt;
TEST_ASSERT_TRUE(decodeMqttFromReply(admin->reply(), mqtt));
TEST_ASSERT_EQUAL_STRING_MESSAGE("brokerpass", mqtt.password, "local client must still receive the password");
admin->drainReply();
}
// A client that GETs and writes the config straight back must not wipe the stored value.
void test_set_config_sentinel_psk_preserves_stored_value(void)
{
strcpy(config.network.wifi_psk, "hunter2hunter2");
meshtastic_Config c = meshtastic_Config_init_zero;
c.which_payload_variant = meshtastic_Config_network_tag;
c.payload_variant.network = config.network;
strcpy(c.payload_variant.network.wifi_psk, "sekrit");
admin->deferSaves();
admin->handleSetConfig(c, true);
TEST_ASSERT_EQUAL_STRING_MESSAGE("hunter2hunter2", config.network.wifi_psk,
"a read-modify-write round trip must not wipe the psk");
admin->drainReply();
}
// An admin response carries no session passkey and its sender is not an admin-key holder, so a
// request we sent is the only thing vouching for it. A get_module_config_response from a node we
// never queried is not.
@@ -431,6 +532,11 @@ void setup()
RUN_TEST(test_session_gate_accepts_key_from_a_get_response);
RUN_TEST(test_remote_security_config_omits_private_key);
RUN_TEST(test_local_security_config_keeps_private_key);
RUN_TEST(test_remote_network_config_omits_wifi_psk);
RUN_TEST(test_local_network_config_keeps_wifi_psk);
RUN_TEST(test_remote_mqtt_config_omits_password);
RUN_TEST(test_local_mqtt_config_keeps_password);
RUN_TEST(test_set_config_sentinel_psk_preserves_stored_value);
RUN_TEST(test_unsolicited_response_is_not_solicited);
RUN_TEST(test_response_after_our_request_is_solicited);
RUN_TEST(test_request_to_one_node_does_not_admit_another);
+385
View File
@@ -3,6 +3,23 @@
#include "TestUtil.h"
#include <unity.h>
#include "configuration.h"
#include "mesh/CryptoEngine.h"
#include "mesh/MeshService.h"
#include "mesh/NodeDB.h"
#include "mesh/RadioInterface.h"
#include "mesh/Router.h"
#include "modules/NeighborInfoModule.h"
#include "modules/RoutingModule.h"
#include "support/MockMeshService.h"
#include <memory>
#include <vector>
namespace
{
constexpr NodeNum LOCAL_NODE = 0x11111111;
constexpr NodeNum REMOTE_NODE = 0x22222222;
// Minimal concrete subclass for testing the base class helper
class TestModule : public MeshModule
{
@@ -13,11 +30,207 @@ class TestModule : public MeshModule
using MeshModule::isMultiHopBroadcastRequest;
};
class MockNodeDB : public NodeDB
{
};
class MockRadioInterface : public RadioInterface
{
public:
ErrorCode send(meshtastic_MeshPacket *p) override
{
packetPool.release(p);
return ERRNO_OK;
}
uint32_t getPacketTime(uint32_t totalPacketLen, bool received = false) override
{
(void)totalPacketLen;
(void)received;
return 0;
}
};
class MockRouter : public Router
{
public:
~MockRouter()
{
delete cryptLock;
cryptLock = nullptr;
}
ErrorCode send(meshtastic_MeshPacket *p) override
{
sentPackets.push_back(*p);
packetPool.release(p);
return ERRNO_OK;
}
std::vector<meshtastic_MeshPacket> sentPackets;
};
struct AckNak {
meshtastic_Routing_Error error;
NodeNum to;
PacketId requestId;
ChannelIndex channel;
};
class MockRoutingModule : public RoutingModule
{
public:
void sendAckNak(meshtastic_Routing_Error err, NodeNum to, PacketId idFrom, ChannelIndex chIndex, uint8_t hopLimit = 0,
bool ackWantsAck = false) override
{
(void)hopLimit;
(void)ackWantsAck;
ackNaks.push_back({err, to, idFrom, chIndex});
}
std::vector<AckNak> ackNaks;
protected:
bool wantPacket(const meshtastic_MeshPacket *p) override
{
(void)p;
return false;
}
};
class SyntheticReplyModule : public MeshModule
{
public:
SyntheticReplyModule(const char *name, meshtastic_PortNum modulePort, meshtastic_PortNum replyPort,
bool acceptsEveryPort = false)
: MeshModule(name, modulePort), replyPort(replyPort), acceptsEveryPort(acceptsEveryPort)
{
isPromiscuous = acceptsEveryPort;
}
uint32_t allocReplyCalls = 0;
protected:
bool wantPacket(const meshtastic_MeshPacket *p) override { return acceptsEveryPort || p->decoded.portnum == ourPortNum; }
meshtastic_MeshPacket *allocReply() override
{
allocReplyCalls++;
meshtastic_MeshPacket *reply = router->allocForSending();
reply->decoded.portnum = replyPort;
return reply;
}
private:
meshtastic_PortNum replyPort;
bool acceptsEveryPort;
};
class ObservingIgnoreModule : public MeshModule
{
public:
ObservingIgnoreModule() : MeshModule("storeforward-shaped", meshtastic_PortNum_STORE_FORWARD_APP) {}
uint32_t allocReplyCalls = 0;
protected:
bool wantPacket(const meshtastic_MeshPacket *p) override { return p->decoded.portnum == meshtastic_PortNum_TEXT_MESSAGE_APP; }
ProcessMessage handleReceived(const meshtastic_MeshPacket &mp) override
{
(void)mp;
ignoreRequest = true;
return ProcessMessage::CONTINUE;
}
meshtastic_MeshPacket *allocReply() override
{
allocReplyCalls++;
return nullptr;
}
};
class ReplyIgnoreModule : public MeshModule
{
public:
ReplyIgnoreModule() : MeshModule("reply-ignore", meshtastic_PortNum_NEIGHBORINFO_APP) {}
uint32_t allocReplyCalls = 0;
protected:
bool wantPacket(const meshtastic_MeshPacket *p) override { return p->decoded.portnum == ourPortNum; }
meshtastic_MeshPacket *allocReply() override
{
allocReplyCalls++;
ignoreRequest = true;
return nullptr;
}
};
static TestModule *testModule;
static meshtastic_MeshPacket testPacket;
static MockNodeDB *mockNodeDB;
static MockMeshService *mockService;
static MockRouter *mockRouter;
static MockRoutingModule *mockRoutingModule;
static NeighborInfoModule *realNeighborInfoModule;
static std::vector<MeshModule *> dispatchModules;
template <typename T> static T *registerDispatchModule(T *module)
{
dispatchModules.push_back(module);
return module;
}
static meshtastic_MeshPacket makeRequest(meshtastic_PortNum port)
{
meshtastic_MeshPacket packet = meshtastic_MeshPacket_init_zero;
packet.from = REMOTE_NODE;
packet.to = LOCAL_NODE;
packet.id = 0x12345678;
packet.channel = 0;
packet.hop_start = 3;
packet.hop_limit = 3;
packet.which_payload_variant = meshtastic_MeshPacket_decoded_tag;
packet.decoded.portnum = port;
packet.decoded.want_response = true;
return packet;
}
static void dispatch(meshtastic_PortNum port)
{
meshtastic_MeshPacket request = makeRequest(port);
MeshModule::callModules(request);
}
} // namespace
void setUp(void)
{
config = meshtastic_LocalConfig_init_zero;
moduleConfig = meshtastic_LocalModuleConfig_init_zero;
channelFile = meshtastic_ChannelFile_init_zero;
owner = meshtastic_User_init_zero;
myNodeInfo.my_node_num = LOCAL_NODE;
mockNodeDB = new MockNodeDB();
nodeDB = mockNodeDB;
myNodeInfo.my_node_num = LOCAL_NODE;
mockService = new MockMeshService();
service = mockService;
channels.initDefaults();
channels.onConfigChanged();
mockRouter = new MockRouter();
mockRouter->addInterface(std::unique_ptr<RadioInterface>(new MockRadioInterface()));
router = mockRouter;
mockRoutingModule = new MockRoutingModule();
routingModule = mockRoutingModule;
testModule = new TestModule();
memset(&testPacket, 0, sizeof(testPacket));
TestModule::currentRequest = &testPacket;
@@ -26,7 +239,34 @@ void setUp(void)
void tearDown(void)
{
TestModule::currentRequest = NULL;
for (auto it = dispatchModules.rbegin(); it != dispatchModules.rend(); ++it)
delete *it;
dispatchModules.clear();
delete realNeighborInfoModule;
realNeighborInfoModule = nullptr;
delete testModule;
testModule = nullptr;
delete mockRoutingModule;
mockRoutingModule = nullptr;
routingModule = nullptr;
while (auto *status = mockService->getQueueStatusForPhone())
mockService->releaseQueueStatusToPool(status);
delete mockService;
mockService = nullptr;
service = nullptr;
delete mockRouter;
mockRouter = nullptr;
router = nullptr;
delete mockNodeDB;
mockNodeDB = nullptr;
nodeDB = nullptr;
}
// Zero-hop broadcast (hop_limit == hop_start): should be allowed
@@ -98,6 +338,139 @@ static void test_singleHopRelayedBroadcast_isBlocked()
TEST_ASSERT_TRUE(testModule->isMultiHopBroadcastRequest());
}
static void test_replyPortMatches_ownPort()
{
meshtastic_MeshPacket request = makeRequest(meshtastic_PortNum_TELEMETRY_APP);
TEST_ASSERT_TRUE(MeshModule::replyPortMatches(meshtastic_PortNum_TELEMETRY_APP, request));
}
static void test_replyPortMatches_neighborInfoVsTelemetry()
{
meshtastic_MeshPacket request = makeRequest(meshtastic_PortNum_TELEMETRY_APP);
TEST_ASSERT_FALSE(MeshModule::replyPortMatches(meshtastic_PortNum_NEIGHBORINFO_APP, request));
}
static void test_replyPortMatches_positionRequest()
{
meshtastic_MeshPacket request = makeRequest(meshtastic_PortNum_POSITION_APP);
TEST_ASSERT_TRUE(MeshModule::replyPortMatches(meshtastic_PortNum_POSITION_APP, request));
}
static void test_replyPortMatches_unknownModulePort()
{
meshtastic_MeshPacket request = makeRequest(meshtastic_PortNum_TELEMETRY_APP);
TEST_ASSERT_FALSE(MeshModule::replyPortMatches(meshtastic_PortNum_UNKNOWN_APP, request));
}
static void test_replyPortMatches_unknownRequestPort()
{
meshtastic_MeshPacket request = makeRequest(meshtastic_PortNum_UNKNOWN_APP);
TEST_ASSERT_FALSE(MeshModule::replyPortMatches(meshtastic_PortNum_TELEMETRY_APP, request));
}
static void test_dispatch_foreignPortOffenderCannotShadowOwner()
{
auto *offender = registerDispatchModule(new SyntheticReplyModule("neighbor-shaped", meshtastic_PortNum_NEIGHBORINFO_APP,
meshtastic_PortNum_NEIGHBORINFO_APP, true));
auto *owner = registerDispatchModule(
new SyntheticReplyModule("telemetry-owner", meshtastic_PortNum_TELEMETRY_APP, meshtastic_PortNum_TELEMETRY_APP));
dispatch(meshtastic_PortNum_TELEMETRY_APP);
TEST_ASSERT_EQUAL_UINT32(0, offender->allocReplyCalls);
TEST_ASSERT_EQUAL_UINT32(1, owner->allocReplyCalls);
TEST_ASSERT_EQUAL_UINT32(1, mockRouter->sentPackets.size());
TEST_ASSERT_EQUAL(meshtastic_PortNum_TELEMETRY_APP, mockRouter->sentPackets[0].decoded.portnum);
TEST_ASSERT_EQUAL_UINT32(0, mockRoutingModule->ackNaks.size());
}
static void test_dispatch_ownerPortStillReplies()
{
auto *offender = registerDispatchModule(new SyntheticReplyModule("neighbor-shaped", meshtastic_PortNum_NEIGHBORINFO_APP,
meshtastic_PortNum_NEIGHBORINFO_APP, true));
auto *owner = registerDispatchModule(
new SyntheticReplyModule("telemetry-owner", meshtastic_PortNum_TELEMETRY_APP, meshtastic_PortNum_TELEMETRY_APP));
dispatch(meshtastic_PortNum_NEIGHBORINFO_APP);
TEST_ASSERT_EQUAL_UINT32(1, offender->allocReplyCalls);
TEST_ASSERT_EQUAL_UINT32(0, owner->allocReplyCalls);
TEST_ASSERT_EQUAL_UINT32(1, mockRouter->sentPackets.size());
TEST_ASSERT_EQUAL(meshtastic_PortNum_NEIGHBORINFO_APP, mockRouter->sentPackets[0].decoded.portnum);
TEST_ASSERT_EQUAL_UINT32(0, mockRoutingModule->ackNaks.size());
}
static void test_dispatch_crossPortReplyUsesRequestOwner()
{
auto *position = registerDispatchModule(
new SyntheticReplyModule("position-owner", meshtastic_PortNum_POSITION_APP, meshtastic_PortNum_ATAK_PLUGIN_V2));
dispatch(meshtastic_PortNum_POSITION_APP);
TEST_ASSERT_EQUAL_UINT32(1, position->allocReplyCalls);
TEST_ASSERT_EQUAL_UINT32(1, mockRouter->sentPackets.size());
TEST_ASSERT_EQUAL(meshtastic_PortNum_ATAK_PLUGIN_V2, mockRouter->sentPackets[0].decoded.portnum);
TEST_ASSERT_EQUAL_UINT32(0, mockRoutingModule->ackNaks.size());
}
static void test_dispatch_foreignPortObserverCanSuppressNak()
{
auto *observer = registerDispatchModule(new ObservingIgnoreModule());
dispatch(meshtastic_PortNum_TEXT_MESSAGE_APP);
TEST_ASSERT_EQUAL_UINT32(0, observer->allocReplyCalls);
TEST_ASSERT_EQUAL_UINT32(0, mockRouter->sentPackets.size());
TEST_ASSERT_EQUAL_UINT32(0, mockRoutingModule->ackNaks.size());
}
static void test_dispatch_noResponderSendsNak()
{
dispatch(meshtastic_PortNum_TELEMETRY_APP);
TEST_ASSERT_EQUAL_UINT32(0, mockRouter->sentPackets.size());
TEST_ASSERT_EQUAL_UINT32(1, mockRoutingModule->ackNaks.size());
TEST_ASSERT_EQUAL(meshtastic_Routing_Error_NO_RESPONSE, mockRoutingModule->ackNaks[0].error);
TEST_ASSERT_EQUAL_HEX32(REMOTE_NODE, mockRoutingModule->ackNaks[0].to);
}
static void test_dispatch_ignoreRequestIsClearedPerPacket()
{
auto *ignoring = registerDispatchModule(new ReplyIgnoreModule());
dispatch(meshtastic_PortNum_NEIGHBORINFO_APP);
TEST_ASSERT_EQUAL_UINT32(1, ignoring->allocReplyCalls);
TEST_ASSERT_EQUAL_UINT32(0, mockRouter->sentPackets.size());
TEST_ASSERT_EQUAL_UINT32(0, mockRoutingModule->ackNaks.size());
dispatch(meshtastic_PortNum_TELEMETRY_APP);
TEST_ASSERT_EQUAL_UINT32(1, ignoring->allocReplyCalls);
TEST_ASSERT_EQUAL_UINT32(0, mockRouter->sentPackets.size());
TEST_ASSERT_EQUAL_UINT32(1, mockRoutingModule->ackNaks.size());
TEST_ASSERT_EQUAL(meshtastic_Routing_Error_NO_RESPONSE, mockRoutingModule->ackNaks[0].error);
}
static void test_dispatch_realNeighborInfoCannotShadowTelemetryOwner()
{
moduleConfig.neighbor_info.enabled = true;
realNeighborInfoModule = new NeighborInfoModule();
registerDispatchModule(
new SyntheticReplyModule("telemetry-owner", meshtastic_PortNum_TELEMETRY_APP, meshtastic_PortNum_TELEMETRY_APP));
dispatch(meshtastic_PortNum_TELEMETRY_APP);
TEST_ASSERT_EQUAL_UINT32(1, mockRouter->sentPackets.size());
TEST_ASSERT_EQUAL(meshtastic_PortNum_TELEMETRY_APP, mockRouter->sentPackets[0].decoded.portnum);
TEST_ASSERT_EQUAL_UINT32(0, mockRoutingModule->ackNaks.size());
}
void setup()
{
initializeTestEnvironment();
@@ -110,6 +483,18 @@ void setup()
RUN_TEST(test_noCurrentRequest_isAllowed);
RUN_TEST(test_legacyPacket_zeroHopStart_isAllowed);
RUN_TEST(test_singleHopRelayedBroadcast_isBlocked);
RUN_TEST(test_replyPortMatches_ownPort);
RUN_TEST(test_replyPortMatches_neighborInfoVsTelemetry);
RUN_TEST(test_replyPortMatches_positionRequest);
RUN_TEST(test_replyPortMatches_unknownModulePort);
RUN_TEST(test_replyPortMatches_unknownRequestPort);
RUN_TEST(test_dispatch_foreignPortOffenderCannotShadowOwner);
RUN_TEST(test_dispatch_ownerPortStillReplies);
RUN_TEST(test_dispatch_crossPortReplyUsesRequestOwner);
RUN_TEST(test_dispatch_foreignPortObserverCanSuppressNak);
RUN_TEST(test_dispatch_noResponderSendsNak);
RUN_TEST(test_dispatch_ignoreRequestIsClearedPerPacket);
RUN_TEST(test_dispatch_realNeighborInfoCannotShadowTelemetryOwner);
exit(UNITY_END());
}
+33
View File
@@ -593,6 +593,37 @@ void test_receiveEmptyDataFromProxy(void)
TEST_ASSERT_TRUE(mockRouter->packets_.empty());
}
// Text must be read as text: data.size aliases the string's first bytes, so reading it regardless
// of the variant let a client name a length of up to PB_SIZE_MAX. There is no delivery control for
// this variant: an encoded ServiceEnvelope always contains NUL, so text can never carry one.
void test_receiveTextVariantFromProxyIsNotReadAsBytes(void)
{
meshtastic_MqttClientProxyMessage message = meshtastic_MqttClientProxyMessage_init_default;
snprintf(message.topic, sizeof(message.topic), "msh/2/e/test/!87654321");
message.which_payload_variant = meshtastic_MqttClientProxyMessage_text_tag;
// data.size would read these as the largest length a pb_size_t can name.
memset(message.payload_variant.text, 0xFF, sizeof(message.payload_variant.text) - 1);
message.payload_variant.text[sizeof(message.payload_variant.text) - 1] = '\0';
mqtt->onClientProxyReceive(message);
TEST_ASSERT_TRUE(mockRouter->packets_.empty());
}
// A proxy message with no payload variant set must be ignored rather than read as bytes.
void test_receiveNoVariantFromProxyIsIgnored(void)
{
meshtastic_MqttClientProxyMessage message = meshtastic_MqttClientProxyMessage_init_default;
snprintf(message.topic, sizeof(message.topic), "msh/2/e/test/!87654321");
message.which_payload_variant = 0;
memset(message.payload_variant.data.bytes, 0xFF, sizeof(message.payload_variant.data.bytes));
message.payload_variant.data.size = sizeof(message.payload_variant.data.bytes);
mqtt->onClientProxyReceive(message);
TEST_ASSERT_TRUE(mockRouter->packets_.empty());
}
// Packets should be ignored if downlink is not enabled.
void test_receiveWithoutChannelDownlink(void)
{
@@ -1116,6 +1147,8 @@ void setup()
RUN_TEST(test_receiveDecodedProto);
RUN_TEST(test_receiveDecodedProtoFromProxy);
RUN_TEST(test_receiveEmptyDataFromProxy);
RUN_TEST(test_receiveTextVariantFromProxyIsNotReadAsBytes);
RUN_TEST(test_receiveNoVariantFromProxyIsIgnored);
RUN_TEST(test_receiveWithoutChannelDownlink);
RUN_TEST(test_receiveEncryptedPKITopicToUs);
RUN_TEST(test_receiveIgnoresOwnPublishedMessages);
+4 -1
View File
@@ -729,8 +729,11 @@ void test_A17_strict_verifies_signer_from_warm_key_store(void)
// Model its next hot-store eviction and prove Balanced still remembers the signer without
// allocating a hot node merely to evaluate an unsigned packet.
// Mirror what NodeDB eviction actually stores for a signer: warmProtectedCategory() yields
// XeddsaSigner *and* the dedicated warm signer bit is set from nodeInfoLiteHasXeddsaSigned().
// isKnownXeddsaSigner() reads that signer bit, not the protected category.
TEST_ASSERT_TRUE(mockNodeDB->warmStore.absorb(REMOTE_NODE, 2, pub, meshtastic_Config_DeviceConfig_Role_CLIENT,
static_cast<uint8_t>(WarmProtected::XeddsaSigner)));
static_cast<uint8_t>(WarmProtected::XeddsaSigner), /*signer=*/true));
mockNodeDB->clearTestNodes();
setPolicy(meshtastic_Config_SecurityConfig_PacketSignaturePolicy_PACKET_SIGNATURE_POLICY_BALANCED);
meshtastic_MeshPacket unsignedPacket =
@@ -202,6 +202,62 @@ void test_wrong_admin_key_does_not_decode(void)
TEST_ASSERT_NULL(mockNodeDB->getMeshNode(ADMIN_NODE));
}
// The fallback is budget-limited against flooding; see Router.cpp for why the budget is global.
void test_admin_key_fallback_is_rate_limited(void)
{
// Start from a full bucket regardless of what earlier tests consumed (8 tokens, one per 250ms).
delay(2500);
uint8_t otherPub[32], otherPriv[32];
crypto->generateKeyPair(otherPub, otherPriv);
crypto->setDHPrivateKey(ourPriv);
setAdminKey(0, otherPub); // wrong key, so every attempt below fails and keeps its token spent
// Drain the burst with undecryptable packets, as a flooding attacker would.
for (int i = 0; i < 8; i++) {
meshtastic_MeshPacket junk = makePkiPacket(ADMIN_NODE, meshtastic_PortNum_PRIVATE_APP, 16, adminPriv);
TEST_ASSERT_NOT_EQUAL(DECODE_SUCCESS, perhapsDecode(&junk));
}
// Budget exhausted: the fallback is skipped, so even a correct admin key does not decrypt.
setAdminKey(0, adminPub);
meshtastic_MeshPacket blocked = makePkiPacket(ADMIN_NODE, meshtastic_PortNum_PRIVATE_APP, 16, adminPriv);
TEST_ASSERT_NOT_EQUAL_MESSAGE(DECODE_SUCCESS, perhapsDecode(&blocked), "fallback should be budget-limited");
// The budget refills, so the throttle is not a permanent lockout.
delay(600);
meshtastic_MeshPacket allowed = makePkiPacket(ADMIN_NODE, meshtastic_PortNum_PRIVATE_APP, 16, adminPriv);
TEST_ASSERT_EQUAL_MESSAGE(DECODE_SUCCESS, perhapsDecode(&allowed), "budget should refill over time");
assertDecodedAndLearned(&allowed, adminPub);
}
// A pending key is an unverified identity claim from whoever opened a key-verification handshake, so it
// must decrypt only the exchange itself. Otherwise they could send DMs that look PKI-authenticated as a
// node they never proved they are.
void test_pending_key_decrypts_only_key_verification(void)
{
// PEER is unknown to us; the handshake stashed its claimed key as pending.
static constexpr NodeNum PEER = 0x0C0C0C0C;
uint8_t peerPub[32], peerPriv[32];
crypto->generateKeyPair(peerPub, peerPriv);
crypto->setDHPrivateKey(ourPriv); // generateKeyPair changed it
crypto->setPendingPublicKey(PEER, peerPub);
// A DM on any other port must not decode, even though the pending key would decrypt it.
meshtastic_MeshPacket spoofed = makePkiPacket(PEER, meshtastic_PortNum_TEXT_MESSAGE_APP, 16, peerPriv);
TEST_ASSERT_NOT_EQUAL_MESSAGE(DECODE_SUCCESS, perhapsDecode(&spoofed), "pending key must not decrypt a text DM");
TEST_ASSERT_FALSE_MESSAGE(spoofed.pki_encrypted, "spoofed DM must not be marked PKI-authenticated");
// The key-verification exchange itself still works, so bootstrapping is unaffected.
meshtastic_MeshPacket handshake = makePkiPacket(PEER, meshtastic_PortNum_KEY_VERIFICATION_APP, 16, peerPriv);
TEST_ASSERT_EQUAL_MESSAGE(DECODE_SUCCESS, perhapsDecode(&handshake), "key verification must still decrypt");
TEST_ASSERT_TRUE(handshake.pki_encrypted);
// A pending key is never persisted, so the peer stays unknown until verification commits it.
TEST_ASSERT_NULL_MESSAGE(mockNodeDB->getMeshNode(PEER), "pending key must not be learned into NodeDB");
crypto->clearPendingPublicKey();
}
#endif // !(MESHTASTIC_EXCLUDE_PKI)
void setup()
@@ -216,6 +272,8 @@ void setup()
RUN_TEST(test_admin_key_slot2_only_decrypts);
RUN_TEST(test_no_admin_key_unknown_sender_not_decoded);
RUN_TEST(test_wrong_admin_key_does_not_decode);
RUN_TEST(test_admin_key_fallback_is_rate_limited);
RUN_TEST(test_pending_key_decrypts_only_key_verification);
#endif
exit(UNITY_END());
}
+162
View File
@@ -0,0 +1,162 @@
// Tests that TraceRouteModule only learns next_hop from a traceroute response when the route array
// agrees with the node that actually relayed the packet. The route is unauthenticated payload, so
// without that check a forged response could point any node's next_hop anywhere.
#include "MeshTypes.h" // include BEFORE TestUtil.h
#include "TestUtil.h"
#include <unity.h>
#include "mesh/NodeDB.h"
#include "modules/TraceRouteModule.h"
#include <vector>
static constexpr NodeNum LOCAL_NODE = 0x0A0A0A0A; // us, the node that asked for the traceroute
static constexpr NodeNum RELAY_B = 0x0B0B0B0B; // the honest first hop back towards us
static constexpr NodeNum NODE_C = 0x0C0C0C0C;
static constexpr NodeNum NODE_D = 0x0D0D0D0D; // the traceroute target, which answers
static constexpr uint8_t ATTACKER_RELAY_BYTE = 0xEE; // some node that is not RELAY_B
class MockNodeDB : public NodeDB
{
public:
void clearTestNodes()
{
testNodes.clear();
meshNodes = &testNodes;
numMeshNodes = 0;
}
void addNode(NodeNum num)
{
meshtastic_NodeInfoLite node = meshtastic_NodeInfoLite_init_zero;
node.num = num;
testNodes.push_back(node);
meshNodes = &testNodes;
numMeshNodes = testNodes.size();
}
uint8_t nextHopOf(NodeNum num)
{
meshtastic_NodeInfoLite *n = getMeshNode(num);
TEST_ASSERT_NOT_NULL(n);
return n->next_hop;
}
std::vector<meshtastic_NodeInfoLite> testNodes;
};
// alterReceivedProtobuf is the real entry point; updateNextHops is private behind it.
class TraceRouteModuleTestShim : public TraceRouteModule
{
public:
using TraceRouteModule::alterReceivedProtobuf;
};
static MockNodeDB *mockNodeDB = nullptr;
static TraceRouteModuleTestShim *shim = nullptr;
// A traceroute response addressed to us, claiming the forward route LOCAL -> RELAY_B -> NODE_C -> NODE_D,
// carried to us by whichever node `relayByte` names.
static meshtastic_MeshPacket makeResponse(uint8_t relayByte, meshtastic_RouteDiscovery *r)
{
*r = meshtastic_RouteDiscovery_init_zero;
r->route_count = 3;
r->route[0] = RELAY_B;
r->route[1] = NODE_C;
r->route[2] = NODE_D;
meshtastic_MeshPacket p = meshtastic_MeshPacket_init_zero;
p.from = NODE_D; // the target answered
p.to = LOCAL_NODE;
p.id = 0x1234;
p.relay_node = relayByte;
p.hop_start = 3;
p.hop_limit = 1;
p.which_payload_variant = meshtastic_MeshPacket_decoded_tag;
p.decoded.portnum = meshtastic_PortNum_TRACEROUTE_APP;
p.decoded.request_id = 0x9999; // non-zero marks this a response, which is what drives updateNextHops
return p;
}
void setUp(void)
{
mockNodeDB = new MockNodeDB();
mockNodeDB->clearTestNodes();
nodeDB = mockNodeDB;
config = meshtastic_LocalConfig_init_zero;
owner = meshtastic_User_init_zero;
myNodeInfo.my_node_num = LOCAL_NODE; // drives isToUs()
mockNodeDB->addNode(LOCAL_NODE);
mockNodeDB->addNode(RELAY_B);
mockNodeDB->addNode(NODE_C);
mockNodeDB->addNode(NODE_D);
shim = new TraceRouteModuleTestShim();
}
void tearDown(void)
{
delete shim;
shim = nullptr;
delete mockNodeDB;
mockNodeDB = nullptr;
nodeDB = nullptr;
}
// The honest case: the route names RELAY_B as our next hop, and RELAY_B is who handed us the packet.
void test_nexthop_learned_when_route_matches_relay(void)
{
meshtastic_RouteDiscovery r;
meshtastic_MeshPacket p = makeResponse(nodeDB->getLastByteOfNodeNum(RELAY_B), &r);
shim->alterReceivedProtobuf(p, &r);
const uint8_t expected = nodeDB->getLastByteOfNodeNum(RELAY_B);
TEST_ASSERT_EQUAL_MESSAGE(expected, mockNodeDB->nextHopOf(RELAY_B), "next hop for the relay itself");
TEST_ASSERT_EQUAL_MESSAGE(expected, mockNodeDB->nextHopOf(NODE_C), "next hop for a node beyond the relay");
TEST_ASSERT_EQUAL_MESSAGE(expected, mockNodeDB->nextHopOf(NODE_D), "next hop for the target");
}
// A forged response: the attacker transmits it themselves, so relay_node is their byte, but the route
// claims RELAY_B. Believing the payload here would let them redirect traffic for every node listed.
void test_nexthop_ignored_when_route_contradicts_relay(void)
{
meshtastic_RouteDiscovery r;
meshtastic_MeshPacket p = makeResponse(ATTACKER_RELAY_BYTE, &r);
shim->alterReceivedProtobuf(p, &r);
TEST_ASSERT_EQUAL_MESSAGE(0, mockNodeDB->nextHopOf(RELAY_B), "forged route must not set a next hop");
TEST_ASSERT_EQUAL_MESSAGE(0, mockNodeDB->nextHopOf(NODE_C), "forged route must not set a next hop");
TEST_ASSERT_EQUAL_MESSAGE(0, mockNodeDB->nextHopOf(NODE_D), "forged route must not set a next hop");
}
// MQTT-sourced packets carry relay_node 0, so nothing corroborates the route and we must not learn.
void test_nexthop_ignored_without_a_relay(void)
{
meshtastic_RouteDiscovery r;
meshtastic_MeshPacket p = makeResponse(NO_RELAY_NODE, &r);
shim->alterReceivedProtobuf(p, &r);
TEST_ASSERT_EQUAL_MESSAGE(0, mockNodeDB->nextHopOf(RELAY_B), "no relay means no corroboration");
TEST_ASSERT_EQUAL_MESSAGE(0, mockNodeDB->nextHopOf(NODE_C), "no relay means no corroboration");
TEST_ASSERT_EQUAL_MESSAGE(0, mockNodeDB->nextHopOf(NODE_D), "no relay means no corroboration");
}
void setup()
{
delay(10);
delay(2000);
initializeTestEnvironment();
UNITY_BEGIN();
RUN_TEST(test_nexthop_learned_when_route_matches_relay);
RUN_TEST(test_nexthop_ignored_when_route_contradicts_relay);
RUN_TEST(test_nexthop_ignored_without_a_relay);
exit(UNITY_END());
}
void loop() {}
File diff suppressed because it is too large Load Diff