feat(security): enforce packet authenticity policies

This commit is contained in:
Benjamin Faershtein
2026-07-09 17:48:55 -07:00
parent b3ddeca9d0
commit d6b12ea3f1
19 changed files with 1136 additions and 169 deletions
+59
View File
@@ -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);
+715 -91
View File
@@ -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);