Harden XEdDSA unsigned-packet policy and add coverage (#10858)

Audit of the XEdDSA packet-signing implementation (#10478) surfaced several
issues in when unsigned packets are accepted on receive or emitted on send.
This fixes them and adds regression coverage.

- Unicast NodeInfo exchange no longer breaks against signer nodes: the
  NodeInfoModule downgrade drop is gated to broadcasts, since senders never
  sign unicast (want_response replies, directed exchanges).
- Replace the payload-size sign heuristic with an exact encoded-size gate
  (signedDataFits) and mirror it on the receive side, removing a dead band
  where 167-168 B broadcasts were signed then failed TOO_LARGE.
- Extract the receive policy into checkXeddsaReceivePolicy() and apply it to
  plaintext-MQTT decoded downlink, which previously skipped signature
  verification and downgrade protection entirely.
- Reject signatures whose length is neither 0 nor 64 as malformed, so a
  crafted partial signature can't inflate the size estimate and dodge the
  unsigned-downgrade drop.
- Hold cryptLock on the MQTT verify path (shared Ed25519 key cache).
- Clear any client-preset signature on packets we originate, on all builds.
- Randomized (hedged) signing per the Signal XEdDSA spec: bump the
  meshtastic/Crypto pin to the build where XEdDSA::sign mixes 32 bytes of
  caller randomness into the nonce as Z (meshtastic/Crypto#3), and seed those
  bytes in xeddsa_sign from HardwareRNG (checked, with a seeded-CSPRNG
  fallback). test_crypto pins that repeated signs differ and both verify.

Adds test coverage: test_packet_signing groups A-E (receive matrix, send
policy, NodeInfo backstop, encoding invariants, decoded-ingress policy),
test_mqtt end-to-end downlink cases, and a test_crypto randomization check.
This commit is contained in:
Ben Meadors
2026-07-02 11:48:58 -05:00
committed by GitHub
co-authored by GitHub
parent d4db80ebb7
commit 0e84c1a827
19 changed files with 614 additions and 69 deletions
+5 -2
View File
@@ -116,8 +116,11 @@ bool CryptoEngine::xeddsa_sign(uint32_t fromNode, uint32_t packetId, uint32_t po
size_t sigLen = buildSigningBuffer(sigBuf, sizeof(sigBuf), fromNode, packetId, portnum, payload, payloadLen);
if (sigLen == 0)
return false;
// the XEdDSA::sign function requires at least the first 32 bytes of signature to be pre-filled with randomness
HardwareRNG::fill(signature, 32);
// XEdDSA::sign mixes signature[0..31] into the nonce as the spec's random Z (meshtastic/Crypto#3)
// for hedged signatures, so seed it - hardware RNG, else the seeded CSPRNG. A weak Z is still
// safe against nonce reuse (defense-in-depth only), so we never fail signing over it.
if (!HardwareRNG::fill(signature, 32))
CryptRNG.rand(signature, 32);
XEdDSA::sign(signature, xeddsa_private_key, xeddsa_public_key, sigBuf, sigLen);
return true;
}
+3
View File
@@ -24,6 +24,9 @@ struct CryptoKey {
#define MAX_BLOCKSIZE 256
#define TEST_CURVE25519_FIELD_OPS // Exposes Curve25519::isWeakPoint() for testing keys
#define XEDDSA_SIGNATURE_SIZE 64
// Encoded size the signature adds to the Data protobuf: 1 tag byte (field 10 < 16) +
// 1 length byte (64 < 128) + 64 signature bytes. test_packet_signing asserts this stays exact.
#define XEDDSA_SIGNATURE_FIELD_BYTES (XEDDSA_SIGNATURE_SIZE + 2)
class CryptoEngine
{
+83 -33
View File
@@ -12,6 +12,7 @@
#include "mesh-pb-constants.h"
#include "meshUtils.h"
#include "modules/RoutingModule.h"
#include <pb_encode.h>
#if HAS_TRAFFIC_MANAGEMENT
#include "modules/TrafficManagementModule.h"
#endif
@@ -444,6 +445,58 @@ void Router::sniffReceived(const meshtastic_MeshPacket *p, const meshtastic_Rout
// FIXME, update nodedb here for any packet that passes through us
}
#if !(MESHTASTIC_EXCLUDE_PKI) && !(MESHTASTIC_EXCLUDE_XEDDSA)
bool checkXeddsaReceivePolicy(meshtastic_MeshPacket *p, size_t encodedDataSize)
{
// 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 *node = nodeDB->getMeshNode(p->from);
if (node && node->public_key.size == 32) {
p->xeddsa_signed =
crypto->xeddsa_verify(node->public_key.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
nodeInfoLiteSetBit(node, NODEINFO_BITFIELD_HAS_XEDDSA_SIGNED_MASK, true);
LOG_DEBUG("Verified XEdDSA signature from 0x%08x", p->from);
} else {
LOG_WARN("XEdDSA signature verification failed from 0x%08x, dropping", p->from);
return false;
}
} else {
LOG_DEBUG("No public key for 0x%08x, cannot verify XEdDSA signature", p->from);
}
} else if (p->decoded.xeddsa_signature.size != 0) {
// A signature field that is neither empty nor a full 64 bytes is malformed - honest
// senders emit only those two sizes (perhapsEncode sets 0 or XEDDSA_SIGNATURE_SIZE). Drop
// it: a crafted partial signature would otherwise land in the unsigned branch below while
// its bytes inflated the size estimate, letting a forged broadcast dodge the downgrade drop.
LOG_WARN("Malformed XEdDSA signature (%u bytes) from 0x%08x, dropping", (unsigned)p->decoded.xeddsa_signature.size,
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
// 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)) {
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) {
LOG_WARN("Dropping unsigned broadcast from 0x%08x that previously signed", p->from);
return false;
}
}
}
return true;
}
#endif
DecodeState perhapsDecode(meshtastic_MeshPacket *p)
{
concurrency::LockGuard g(cryptLock);
@@ -542,35 +595,11 @@ DecodeState perhapsDecode(meshtastic_MeshPacket *p)
p->decoded.want_response |= p->decoded.bitfield & BITFIELD_WANT_RESPONSE_MASK;
#if !(MESHTASTIC_EXCLUDE_PKI) && !(MESHTASTIC_EXCLUDE_XEDDSA)
if (p->decoded.xeddsa_signature.size == XEDDSA_SIGNATURE_SIZE) {
meshtastic_NodeInfoLite *node = nodeDB->getMeshNode(p->from);
if (node && node->public_key.size == 32) {
p->xeddsa_signed =
crypto->xeddsa_verify(node->public_key.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) {
// Mark this node as a signer so future unsigned packets from it are rejected
nodeInfoLiteSetBit(node, NODEINFO_BITFIELD_HAS_XEDDSA_SIGNED_MASK, true);
LOG_DEBUG("Verified XEdDSA signature from 0x%08x", p->from);
} else {
LOG_WARN("XEdDSA signature verification failed from 0x%08x, dropping", p->from);
return DecodeState::DECODE_FAILURE;
}
} else {
LOG_DEBUG("No public key for 0x%08x, cannot verify XEdDSA signature", p->from);
}
} else {
// Unsigned packet - only reject the class of packet a signing node always signs:
// an unencrypted broadcast small enough to also carry a signature (see perhapsEncode()).
// Unicast packets and oversized broadcasts are never signed, so they must not be
// hard-failed here even if this node has signed before.
const meshtastic_NodeInfoLite *node = nodeDB->getMeshNode(p->from);
if (node && nodeInfoLiteHasXeddsaSigned(node) && isBroadcast(p->to) &&
p->decoded.payload.size + XEDDSA_SIGNATURE_SIZE < meshtastic_Constants_DATA_PAYLOAD_LEN) {
LOG_WARN("Dropping unsigned broadcast from 0x%08x that previously signed", p->from);
return DecodeState::DECODE_FAILURE;
}
}
// rawSize is the size of the encoded Data exactly as the sender built it (the PKI branch's
// 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;
#endif
/* Not actually ever used.
@@ -629,6 +658,20 @@ DecodeState perhapsDecode(meshtastic_MeshPacket *p)
}
}
#if !(MESHTASTIC_EXCLUDE_PKI) && !(MESHTASTIC_EXCLUDE_XEDDSA)
/** Exact sender-side sign gate: would this Data still fit the LoRa frame with a 64-byte
* signature attached? Sized with the real encoder so it tracks whatever fields are present. */
static bool signedDataFits(meshtastic_Data *d)
{
const pb_size_t prevSize = d->xeddsa_signature.size;
d->xeddsa_signature.size = XEDDSA_SIGNATURE_SIZE;
size_t encodedSize;
const bool sized = pb_get_encoded_size(&encodedSize, &meshtastic_Data_msg, d);
d->xeddsa_signature.size = prevSize;
return sized && encodedSize + MESHTASTIC_HEADER_LENGTH <= MAX_LORA_PAYLOAD_LEN;
}
#endif
/** Return 0 for success or a Routing_Error code for failure
*/
meshtastic_Routing_Error perhapsEncode(meshtastic_MeshPacket *p)
@@ -643,11 +686,18 @@ meshtastic_Routing_Error perhapsEncode(meshtastic_MeshPacket *p)
p->decoded.has_bitfield = true;
p->decoded.bitfield |= (config.lora.config_ok_to_mqtt << BITFIELD_OK_TO_MQTT_SHIFT);
p->decoded.bitfield |= (p->decoded.want_response << BITFIELD_WANT_RESPONSE_SHIFT);
// We own signing for packets we originate; discard any signature a client preset.
// Outside the XEdDSA guard: the field exists in the protobuf on every build, and a
// stale/garbage signature transmitted by a non-signing build would hard-fail
// verification at every XEdDSA-enabled receiver that knows our key.
p->decoded.xeddsa_signature.size = 0;
#if !(MESHTASTIC_EXCLUDE_PKI) && !(MESHTASTIC_EXCLUDE_XEDDSA)
// Sign broadcast packets if payload + signature fits within the max Data payload.
// The actual encoded size is checked after pb_encode (TOO_LARGE).
if (!p->pki_encrypted && isBroadcast(p->to) &&
p->decoded.payload.size + XEDDSA_SIGNATURE_SIZE < meshtastic_Constants_DATA_PAYLOAD_LEN) {
// Sign broadcast packets when the Data still fits a LoRa frame with the signature
// attached. This must be the exact encoded-size criterion, not a payload-size
// heuristic: a heuristic band where we sign-then-fail-TOO_LARGE breaks packets that
// were deliverable unsigned, and perhapsDecode() applies the mirror-image rule when
// deciding whether an unsigned broadcast from a known signer is a downgrade.
if (!p->pki_encrypted && isBroadcast(p->to) && signedDataFits(&p->decoded)) {
if (crypto->xeddsa_sign(p->from, p->id, p->decoded.portnum, p->decoded.payload.bytes, p->decoded.payload.size,
p->decoded.xeddsa_signature.bytes)) {
p->decoded.xeddsa_signature.size = XEDDSA_SIGNATURE_SIZE;
+20
View File
@@ -175,6 +175,26 @@ DecodeState perhapsDecode(meshtastic_MeshPacket *p);
*/
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).
*
* 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,
* which bypasses perhapsDecode's crypto path).
*
* The caller MUST hold cryptLock: verification runs through the shared CryptoEngine key cache.
* (perhapsDecode already holds it; other call sites must take it themselves.)
*
* @return false if the packet must be dropped.
*/
bool checkXeddsaReceivePolicy(meshtastic_MeshPacket *p, size_t encodedDataSize = 0);
#endif
extern Router *router;
/// Generate a unique packet id
+4 -2
View File
@@ -51,8 +51,10 @@ bool NodeInfoModule::handleReceivedProtobuf(const meshtastic_MeshPacket &mp, mes
}
NodeNum sourceNum = getFrom(&mp);
const meshtastic_NodeInfoLite *node = nodeDB->getMeshNode(sourceNum);
if (node && nodeInfoLiteHasXeddsaSigned(node) && !mp.xeddsa_signed) {
LOG_WARN("Dropping unsigned NodeInfo from node 0x%08x that previously signed", 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;
}
+16
View File
@@ -6,6 +6,7 @@
#include "configuration.h"
#include "main.h"
#include "mesh/Channels.h"
#include "mesh/CryptoEngine.h"
#include "mesh/Router.h"
#include "mesh/generated/meshtastic/mqtt.pb.h"
#include "mesh/generated/meshtastic/telemetry.pb.h"
@@ -131,6 +132,21 @@ inline void onReceiveProto(char *topic, byte *payload, size_t length)
return;
}
p->channel = ch.index;
#if !(MESHTASTIC_EXCLUDE_PKI) && !(MESHTASTIC_EXCLUDE_XEDDSA)
// Already-decoded downlink skips perhapsDecode's crypto path entirely, so enforce the
// signature policy here: verify a carried signature and apply unsigned-downgrade
// protection for known signers. Without this, a peer on a plaintext broker could
// impersonate a signing node with unsigned broadcasts. Hold cryptLock like the RF path
// (perhapsDecode) does - checkXeddsaReceivePolicy -> xeddsa_verify mutates shared
// CryptoEngine cache state, and MQTT ingress can run on a different task.
{
concurrency::LockGuard g(cryptLock);
if (!checkXeddsaReceivePolicy(p.get())) {
LOG_INFO("Ignore decoded message failing XEdDSA policy");
return;
}
}
#endif
}
// PKI messages get accepted even if we can't decrypt
+9 -6
View File
@@ -263,11 +263,12 @@ void test_XEdDSA_max_payload(void)
TEST_ASSERT_FALSE(crypto->xeddsa_verify(pub, fromNode, packetId, portnum, payload, len, signature));
}
// Signing the same message twice yields signatures that both verify. This XEdDSA implementation is
// deterministic in practice (the two signatures are typically byte-identical, even though
// HardwareRNG::fill provides real entropy on this platform), so we assert only the security-relevant
// property - every produced signature verifies - rather than asserting (non-)determinism.
void test_XEdDSA_repeated_sign_verifies(void)
// XEdDSA is a randomized (hedged) scheme: the nonce mixes in Z, caller-supplied randomness
// (Signal spec; meshtastic/Crypto#3). CryptoEngine::xeddsa_sign seeds Z from the hardware RNG, so
// signing the same message twice yields *different* signatures that both verify. This pins that
// the randomization is actually wired through end to end - if signing regresses to deterministic
// (Z dropped by the library, or xeddsa_sign stops seeding entropy), the inequality assertion fails.
void test_XEdDSA_repeated_sign_is_randomized(void)
{
uint8_t pub[32], priv[32], sig1[64], sig2[64];
uint8_t message[] = "same message";
@@ -277,6 +278,8 @@ void test_XEdDSA_repeated_sign_verifies(void)
TEST_ASSERT(crypto->xeddsa_sign(fromNode, packetId, portnum, message, sizeof(message), sig1));
TEST_ASSERT(crypto->xeddsa_sign(fromNode, packetId, portnum, message, sizeof(message), sig2));
TEST_ASSERT_TRUE_MESSAGE(memcmp(sig1, sig2, sizeof(sig1)) != 0,
"signatures must differ - XEdDSA Z randomization is not wired through");
TEST_ASSERT_TRUE(crypto->xeddsa_verify(pub, fromNode, packetId, portnum, message, sizeof(message), sig1));
TEST_ASSERT_TRUE(crypto->xeddsa_verify(pub, fromNode, packetId, portnum, message, sizeof(message), sig2));
}
@@ -326,7 +329,7 @@ void setup()
RUN_TEST(test_XEdDSA_empty_key_sign_fails);
RUN_TEST(test_XEdDSA_curve_to_ed_cache);
RUN_TEST(test_XEdDSA_max_payload);
RUN_TEST(test_XEdDSA_repeated_sign_verifies);
RUN_TEST(test_XEdDSA_repeated_sign_is_randomized);
exit(UNITY_END()); // stop unit testing
}
+93 -2
View File
@@ -224,6 +224,7 @@ MockPubSubServer *pubsub;
MockRoutingModule *mockRoutingModule;
MockMeshService *mockMeshService;
MockRouter *mockRouter;
MockNodeDB *mockNodeDB;
// Keep running the loop until either conditionMet returns true or 4 seconds elapse.
// Returns true if conditionMet returns true, returns false on timeout.
@@ -340,6 +341,11 @@ void setUp(void)
localPosition =
meshtastic_Position{.has_latitude_i = true, .latitude_i = 700000000, .has_longitude_i = true, .longitude_i = 300000000};
// The shared MockNodeDB node is mutated by the XEdDSA policy tests (signer bit, public
// key); reset it so state can't leak between tests.
if (mockNodeDB)
mockNodeDB->emptyNode = meshtastic_NodeInfoLite();
router = mockRouter = new MockRouter();
service = mockMeshService = new MockMeshService();
routingModule = mockRoutingModule = new MockRoutingModule();
@@ -651,6 +657,86 @@ void test_receiveIgnoresDecodedAdminApp(void)
TEST_ASSERT_TRUE(mockRouter->packets_.empty());
}
#if !(MESHTASTIC_EXCLUDE_PKI) && !(MESHTASTIC_EXCLUDE_XEDDSA)
// Small decoded broadcast from a remote node, as a plaintext broker would deliver it.
static meshtastic_MeshPacket makeDecodedBroadcast()
{
meshtastic_MeshPacket p = meshtastic_MeshPacket_init_zero;
p.from = 1;
p.to = NODENUM_BROADCAST;
p.id = 7;
p.which_payload_variant = meshtastic_MeshPacket_decoded_tag;
p.decoded.portnum = meshtastic_PortNum_TEXT_MESSAGE_APP;
p.decoded.payload.size = 5;
memcpy(p.decoded.payload.bytes, "hello", 5);
return p;
}
// Decoded (plaintext-broker) downlink skips perhapsDecode's crypto path, so MQTT applies
// checkXeddsaReceivePolicy at ingress. An unsigned broadcast claiming to come from a node that
// previously signed must be dropped - without this, a rogue broker peer could impersonate any
// signing node (audit F3).
void test_receiveDropsUnsignedBroadcastFromSigner(void)
{
mockNodeDB->emptyNode.bitfield |= NODEINFO_BITFIELD_HAS_XEDDSA_SIGNED_MASK;
const meshtastic_MeshPacket p = makeDecodedBroadcast();
unitTest->publish(&p);
TEST_ASSERT_TRUE(mockRouter->packets_.empty());
}
// The same unsigned broadcast from a node never seen signing is accepted.
void test_receiveAcceptsUnsignedBroadcastFromNonSigner(void)
{
const meshtastic_MeshPacket p = makeDecodedBroadcast();
unitTest->publish(&p);
TEST_ASSERT_EQUAL(1, mockRouter->packets_.size());
TEST_ASSERT_FALSE(mockRouter->packets_.front().xeddsa_signed);
}
// A validly signed decoded downlink verifies at ingress: delivered with xeddsa_signed set and
// the sender's signer bit learned.
void test_receiveVerifiesSignedDecodedDownlink(void)
{
uint8_t pub[32], priv[32];
crypto->generateKeyPair(pub, priv);
mockNodeDB->emptyNode.public_key.size = 32;
memcpy(mockNodeDB->emptyNode.public_key.bytes, pub, 32);
meshtastic_MeshPacket p = makeDecodedBroadcast();
TEST_ASSERT_TRUE(crypto->xeddsa_sign(p.from, p.id, p.decoded.portnum, p.decoded.payload.bytes, p.decoded.payload.size,
p.decoded.xeddsa_signature.bytes));
p.decoded.xeddsa_signature.size = XEDDSA_SIGNATURE_SIZE;
unitTest->publish(&p);
TEST_ASSERT_EQUAL(1, mockRouter->packets_.size());
TEST_ASSERT_TRUE(mockRouter->packets_.front().xeddsa_signed);
TEST_ASSERT_TRUE(mockNodeDB->emptyNode.bitfield & NODEINFO_BITFIELD_HAS_XEDDSA_SIGNED_MASK);
}
// A decoded downlink carrying a signature that fails verification is dropped.
void test_receiveDropsBadSignatureOnDecodedDownlink(void)
{
uint8_t pub[32], priv[32];
crypto->generateKeyPair(pub, priv);
mockNodeDB->emptyNode.public_key.size = 32;
memcpy(mockNodeDB->emptyNode.public_key.bytes, pub, 32);
meshtastic_MeshPacket p = makeDecodedBroadcast();
TEST_ASSERT_TRUE(crypto->xeddsa_sign(p.from, p.id, p.decoded.portnum, p.decoded.payload.bytes, p.decoded.payload.size,
p.decoded.xeddsa_signature.bytes));
p.decoded.xeddsa_signature.size = XEDDSA_SIGNATURE_SIZE;
p.decoded.xeddsa_signature.bytes[0] ^= 0xFF;
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.
void test_receiveIgnoresUnexpectedFields(void)
{
@@ -880,8 +966,7 @@ void test_configWithTLSEnabled(void)
void setup()
{
initializeTestEnvironment();
const std::unique_ptr<MockNodeDB> mockNodeDB(new MockNodeDB());
nodeDB = mockNodeDB.get();
nodeDB = mockNodeDB = new MockNodeDB(); // freed implicitly by exit(UNITY_END()) below
UNITY_BEGIN();
RUN_TEST(test_sendDirectlyConnectedDecoded);
@@ -905,6 +990,12 @@ void setup()
RUN_TEST(test_receiveIgnoresSentMessagesFromOthers);
RUN_TEST(test_receiveIgnoresDecodedWhenEncryptionEnabled);
RUN_TEST(test_receiveIgnoresDecodedAdminApp);
#if !(MESHTASTIC_EXCLUDE_PKI) && !(MESHTASTIC_EXCLUDE_XEDDSA)
RUN_TEST(test_receiveDropsUnsignedBroadcastFromSigner);
RUN_TEST(test_receiveAcceptsUnsignedBroadcastFromNonSigner);
RUN_TEST(test_receiveVerifiesSignedDecodedDownlink);
RUN_TEST(test_receiveDropsBadSignatureOnDecodedDownlink);
#endif
RUN_TEST(test_receiveIgnoresUnexpectedFields);
RUN_TEST(test_receiveIgnoresInvalidHopLimit);
RUN_TEST(test_publishTextMessageDirect);
+371 -14
View File
@@ -1,19 +1,23 @@
// Tests for XEdDSA packet-signing *policy* - the receive-path accept/reject behavior and the
// send-path signing policy - as opposed to the raw sign/verify primitive (covered in test_crypto).
//
// The decision logic under test lives inside perhapsDecode()/perhapsEncode() (free functions in
// Router.cpp). It only runs after a packet is decrypted, so every case drives a real
// encode -> decode round-trip through the default channel (black-box, no production changes).
// 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.
//
// 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 stricter "drop unsigned NodeInfo from a known signer" rule
// Group C NodeInfoModule's broadcast-only "drop unsigned NodeInfo from a known signer" rule
// Group D encoding invariants the routing gates depend on
// Group E decoded-ingress policy (checkXeddsaReceivePolicy, the plaintext-MQTT trust boundary)
#include "MeshTypes.h" // include BEFORE TestUtil.h
#include "TestUtil.h"
#include <unity.h>
#if !(MESHTASTIC_EXCLUDE_PKI)
// The whole suite exercises XEdDSA sign/verify and checkXeddsaReceivePolicy, all of which are
// compiled out unless both PKI and XEdDSA are enabled (e.g. stm32 sets MESHTASTIC_EXCLUDE_XEDDSA).
#if !(MESHTASTIC_EXCLUDE_PKI) && !(MESHTASTIC_EXCLUDE_XEDDSA)
#include "mesh/Channels.h"
#include "mesh/CryptoEngine.h"
@@ -23,6 +27,7 @@
#include <cstdio>
#include <cstring>
#include <memory>
#include <pb_encode.h>
#include <vector>
// ---------------------------------------------------------------------------
@@ -31,8 +36,8 @@
static constexpr NodeNum LOCAL_NODE = 0x0A0A0A0A;
static constexpr NodeNum REMOTE_NODE = 0x0B0B0B0B;
// A "small" broadcast payload that leaves room for a 64-byte signature (payload + 64 < 233),
// and an "oversized" one that does not (payload + 64 >= 233) yet still encodes within a LoRa frame.
// A "small" broadcast payload whose signed encoding easily fits a LoRa frame, and an "oversized"
// one whose signed encoding does not, yet still encodes within a LoRa frame unsigned.
static constexpr size_t SMALL_PAYLOAD = 16;
static constexpr size_t OVERSIZED_PAYLOAD = 180;
@@ -126,19 +131,39 @@ static bool remoteSignerBit()
return nodeInfoLiteHasXeddsaSigned(mockNodeDB->getMeshNode(REMOTE_NODE));
}
// Size a Data message exactly as the wire encoder would.
static size_t encodedDataSize(const meshtastic_Data *d)
{
size_t s = 0;
TEST_ASSERT_TRUE_MESSAGE(pb_get_encoded_size(&s, &meshtastic_Data_msg, d), "pb_get_encoded_size failed");
return s;
}
// Would this Data still fit a LoRa frame with a 64-byte signature attached? Mirror of the
// production gate in Router.cpp (signedDataFits / the perhapsDecode downgrade predicate).
static bool signedEncodingFits(const meshtastic_Data *d)
{
meshtastic_Data copy = *d;
copy.xeddsa_signature.size = XEDDSA_SIGNATURE_SIZE;
return encodedDataSize(&copy) + MESHTASTIC_HEADER_LENGTH <= MAX_LORA_PAYLOAD_LEN;
}
// ---------------------------------------------------------------------------
// Unity lifecycle
// ---------------------------------------------------------------------------
void setUp(void)
{
// Clean global config/owner; 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;
owner = meshtastic_User_init_zero;
// Construct the mock FIRST: the NodeDB constructor can reload persisted state from the
// host filesystem (portduino VFS) and repopulate the globals - a saved private key
// re-enables the PKI encrypt path and fails the unicast tests on hosts with leftover prefs.
mockNodeDB = new MockNodeDB();
mockNodeDB->clearTestNodes();
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;
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.
@@ -251,6 +276,54 @@ void test_A7_unsigned_oversized_broadcast_from_signer_accepted(void)
TEST_ASSERT_EQUAL(DECODE_SUCCESS, roundTrip(&p));
}
// A8: F2 regression - unsigned broadcast from a signer in the old "dead band": its *encoded* Data
// can't take a 64-byte signature and still fit a LoRa frame, but the old payload-size heuristic
// (payload + 64 < DATA_PAYLOAD_LEN) judged it signable and dropped it as a downgrade. Must be
// accepted: an honest signer physically cannot sign this packet.
void test_A8_unsigned_deadband_broadcast_from_signer_accepted(void)
{
mockNodeDB->addNode(REMOTE_NODE);
mockNodeDB->setSignerBit(REMOTE_NODE, true);
// Shape it like a real sender's Data: perhapsEncode adds the bitfield to packets a node
// originates, so remote broadcast traffic carries it too.
meshtastic_MeshPacket p = makeDecoded(REMOTE_NODE, NODENUM_BROADCAST, meshtastic_PortNum_TEXT_MESSAGE_APP, 167);
p.decoded.has_bitfield = true;
p.decoded.bitfield = 0;
// Pin the payload inside the dead band; if Data's encoding ever shifts, retune the payload
// size above instead of letting this test pass vacuously.
TEST_ASSERT_TRUE_MESSAGE(p.decoded.payload.size + XEDDSA_SIGNATURE_SIZE < meshtastic_Constants_DATA_PAYLOAD_LEN,
"payload must sit in the old heuristic's drop range");
TEST_ASSERT_FALSE_MESSAGE(signedEncodingFits(&p.decoded), "signed encoding must NOT fit a LoRa frame");
TEST_ASSERT_EQUAL(DECODE_SUCCESS, roundTrip(&p));
TEST_ASSERT_FALSE(p.xeddsa_signed);
}
// A9: the boundary holds - the largest broadcast whose signed encoding still fits is still
// subject to the downgrade drop when it arrives unsigned from a known signer.
// (Deliberately non-discriminating: the old heuristic dropped this packet too. A9 pins the
// boundary against over-correction; A8 and B4 are the F2 regression discriminators.)
void test_A9_unsigned_boundary_broadcast_from_signer_still_dropped(void)
{
mockNodeDB->addNode(REMOTE_NODE);
mockNodeDB->setSignerBit(REMOTE_NODE, true);
meshtastic_MeshPacket p = makeDecoded(REMOTE_NODE, NODENUM_BROADCAST, meshtastic_PortNum_TEXT_MESSAGE_APP, 166);
p.decoded.has_bitfield = true;
p.decoded.bitfield = 0;
// Exactly at the limit: signed encoding fills the frame to the last byte. Pinned so the
// boundary can't silently drift.
meshtastic_Data signedCopy = p.decoded;
signedCopy.xeddsa_signature.size = XEDDSA_SIGNATURE_SIZE;
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));
}
// ===========================================================================
// Group B - send-side signing policy (perhapsEncode)
// ===========================================================================
@@ -290,8 +363,100 @@ void test_B3_local_oversized_broadcast_not_signed(void)
TEST_ASSERT_EQUAL_MESSAGE(0, p.decoded.xeddsa_signature.size, "oversized broadcast must not be signed");
}
// B4: F2 regression sweep - every broadcast payload size that fits a LoRa frame unsigned must
// still be deliverable: signing steps aside exactly when the signed encoding stops fitting,
// never producing TOO_LARGE (the old heuristic dead-banded payloads 167-168). Because the first
// verified packet sets our signer bit in the mock DB, the later unsigned sizes also prove the
// receiver's downgrade predicate stays exactly symmetric with the sender's sign gate.
void test_B4_all_broadcast_sizes_deliverable_no_deadband(void)
{
uint8_t pub[32], priv[32];
crypto->generateKeyPair(pub, priv);
mockNodeDB->addNode(LOCAL_NODE);
mockNodeDB->setPublicKey(LOCAL_NODE, pub);
bool sawSigned = false, sawUnsigned = false;
for (size_t n = 1; n <= 232; n++) {
char msg[32];
snprintf(msg, sizeof(msg), "payload size %u", (unsigned)n);
meshtastic_MeshPacket p = makeDecoded(LOCAL_NODE, NODENUM_BROADCAST, meshtastic_PortNum_TEXT_MESSAGE_APP, n);
TEST_ASSERT_EQUAL_MESSAGE(DECODE_SUCCESS, roundTrip(&p), msg);
// Exact oracle: signed iff the signed encoding fits the frame. signedEncodingFits() forces
// the signature size itself, so it reads the same whether or not p.decoded came back signed.
const bool isSigned = p.decoded.xeddsa_signature.size == XEDDSA_SIGNATURE_SIZE;
TEST_ASSERT_EQUAL_MESSAGE(signedEncodingFits(&p.decoded), isSigned, msg);
if (isSigned) {
TEST_ASSERT_FALSE_MESSAGE(sawUnsigned, msg); // monotonic: once too big, never signed again
TEST_ASSERT_TRUE_MESSAGE(p.xeddsa_signed, msg); // and it verified on the way back in
sawSigned = true;
} else {
sawUnsigned = true;
}
}
TEST_ASSERT_TRUE_MESSAGE(sawSigned, "sweep never produced a signed packet");
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.
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);
p.decoded.xeddsa_signature.size = XEDDSA_SIGNATURE_SIZE;
memset(p.decoded.xeddsa_signature.bytes, 0xAB, XEDDSA_SIGNATURE_SIZE);
TEST_ASSERT_EQUAL(DECODE_SUCCESS, roundTrip(&p));
TEST_ASSERT_EQUAL_MESSAGE(0, p.decoded.xeddsa_signature.size, "preset signature must be discarded on unicast");
}
// B6: the exact-fit gate tracks Data *shape*, not just payload size. A tapback-style broadcast
// (want_response + reply_id + emoji) carries extra wire bytes that shift the fit boundary; the
// sweep proves no dead band exists for that shape either, and - once the signer bit is learned -
// that the receiver's rawSize-driven downgrade predicate stays symmetric for it too. Window
// straddles this shape's boundary; capped at 200 so even the unsigned rich encoding stays well
// inside the frame (at n=221 it first hits the pre-existing, signing-unrelated TOO_LARGE).
void test_B6_rich_shape_sweep_no_deadband(void)
{
uint8_t pub[32], priv[32];
crypto->generateKeyPair(pub, priv);
mockNodeDB->addNode(LOCAL_NODE);
mockNodeDB->setPublicKey(LOCAL_NODE, pub);
bool sawSigned = false, sawUnsigned = false;
for (size_t n = 100; n <= 200; n++) {
char msg[32];
snprintf(msg, sizeof(msg), "payload size %u", (unsigned)n);
meshtastic_MeshPacket p = makeDecoded(LOCAL_NODE, NODENUM_BROADCAST, meshtastic_PortNum_TEXT_MESSAGE_APP, n);
p.decoded.want_response = true;
p.decoded.reply_id = 0x11223344;
p.decoded.emoji = 1;
TEST_ASSERT_EQUAL_MESSAGE(DECODE_SUCCESS, roundTrip(&p), msg);
const bool isSigned = p.decoded.xeddsa_signature.size == XEDDSA_SIGNATURE_SIZE;
TEST_ASSERT_EQUAL_MESSAGE(signedEncodingFits(&p.decoded), isSigned, msg);
if (isSigned) {
TEST_ASSERT_FALSE_MESSAGE(sawUnsigned, msg);
TEST_ASSERT_TRUE_MESSAGE(p.xeddsa_signed, msg);
sawSigned = true;
} else {
sawUnsigned = true;
}
}
TEST_ASSERT_TRUE_MESSAGE(sawSigned, "rich sweep never produced a signed packet");
TEST_ASSERT_TRUE_MESSAGE(sawUnsigned, "rich sweep never crossed the fit boundary");
}
// ===========================================================================
// Group C - NodeInfoModule downgrade drop (stricter: any unsigned NodeInfo from a known signer)
// 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)
// ===========================================================================
class NodeInfoTestShim : public NodeInfoModule
{
@@ -348,6 +513,178 @@ void test_C3_unsigned_nodeinfo_from_nonsigner_not_dropped(void)
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)
{
mockNodeDB->addNode(REMOTE_NODE);
mockNodeDB->setSignerBit(REMOTE_NODE, true);
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;
TEST_ASSERT_FALSE_MESSAGE(shim.handleReceivedProtobuf(mp, &user),
"unsigned unicast NodeInfo from a signer must not be dropped");
}
// ===========================================================================
// Group D - encoding invariants the routing gates depend on
// ===========================================================================
// D1: the encoded overhead of the signature field must be exactly XEDDSA_SIGNATURE_FIELD_BYTES
// (1 tag byte + 1 length byte + 64 signature bytes). The receiver downgrade predicate adds this
// constant to the unsigned size; this test pins that it matches the real wire overhead the
// sender's encoder produces, keeping the two sides symmetric. It drifts if the field number ever
// moves to >= 16 or the signature grows past 127 bytes.
void test_D1_signature_field_overhead_exact(void)
{
meshtastic_Data d = meshtastic_Data_init_zero;
d.portnum = meshtastic_PortNum_TEXT_MESSAGE_APP;
d.payload.size = 100;
const size_t without = encodedDataSize(&d);
d.xeddsa_signature.size = XEDDSA_SIGNATURE_SIZE;
const size_t with = encodedDataSize(&d);
TEST_ASSERT_EQUAL_MESSAGE(XEDDSA_SIGNATURE_FIELD_BYTES, with - without, "signature field wire overhead drifted");
}
// ===========================================================================
// Group E - decoded-ingress policy (checkXeddsaReceivePolicy)
// ===========================================================================
// Already-decoded packets never reach perhapsDecode's crypto path (it early-returns), so
// plaintext-MQTT downlink applies this policy function directly at ingress (MQTT.cpp). These
// tests drive it the same way: decoded packets, encodedDataSize = 0 (canonical sizing).
// End-to-end MQTT wiring is covered in test_mqtt.
// E1: unsigned small broadcast from a known signer -> dropped (downgrade protection holds on
// the decoded-ingress path too - the F3 bypass).
void test_E1_decoded_unsigned_broadcast_from_signer_dropped(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);
TEST_ASSERT_FALSE(checkXeddsaReceivePolicy(&p));
}
// E2: unsigned broadcast from a non-signer -> accepted.
void test_E2_decoded_unsigned_broadcast_from_nonsigner_accepted(void)
{
mockNodeDB->addNode(REMOTE_NODE); // signer bit clear
meshtastic_MeshPacket p = makeDecoded(REMOTE_NODE, NODENUM_BROADCAST, meshtastic_PortNum_TEXT_MESSAGE_APP, SMALL_PAYLOAD);
TEST_ASSERT_TRUE(checkXeddsaReceivePolicy(&p));
TEST_ASSERT_FALSE(p.xeddsa_signed);
}
// E3: valid signature with a known key -> accepted, marked verified, signer bit learned.
void test_E3_decoded_valid_signature_verified_and_learns_signer(void)
{
uint8_t pub[32], priv[32];
crypto->generateKeyPair(pub, priv);
mockNodeDB->addNode(REMOTE_NODE);
mockNodeDB->setPublicKey(REMOTE_NODE, pub);
meshtastic_MeshPacket p = makeDecoded(REMOTE_NODE, NODENUM_BROADCAST, meshtastic_PortNum_TEXT_MESSAGE_APP, SMALL_PAYLOAD);
signWithCurrentKey(&p);
TEST_ASSERT_TRUE(checkXeddsaReceivePolicy(&p));
TEST_ASSERT_TRUE(p.xeddsa_signed);
TEST_ASSERT_TRUE_MESSAGE(remoteSignerBit(), "verified signature must set the signer bit");
}
// E4: corrupted signature with a known key -> dropped.
void test_E4_decoded_bad_signature_dropped(void)
{
uint8_t pub[32], priv[32];
crypto->generateKeyPair(pub, priv);
mockNodeDB->addNode(REMOTE_NODE);
mockNodeDB->setPublicKey(REMOTE_NODE, pub);
meshtastic_MeshPacket p = makeDecoded(REMOTE_NODE, NODENUM_BROADCAST, meshtastic_PortNum_TEXT_MESSAGE_APP, SMALL_PAYLOAD);
signWithCurrentKey(&p);
p.decoded.xeddsa_signature.bytes[0] ^= 0xFF;
TEST_ASSERT_FALSE(checkXeddsaReceivePolicy(&p));
TEST_ASSERT_FALSE(p.xeddsa_signed);
}
// E5: unsigned oversized broadcast from a signer -> accepted (canonical sizing exempts packets
// whose signed encoding wouldn't fit, mirroring the RF-path rawSize rule).
void test_E5_decoded_unsigned_oversized_broadcast_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, OVERSIZED_PAYLOAD);
TEST_ASSERT_TRUE(checkXeddsaReceivePolicy(&p));
}
// E6: unsigned unicast from a signer -> accepted (unicast is never signed).
void test_E6_decoded_unsigned_unicast_from_signer_accepted(void)
{
mockNodeDB->addNode(REMOTE_NODE);
mockNodeDB->setSignerBit(REMOTE_NODE, true);
meshtastic_MeshPacket p = makeDecoded(REMOTE_NODE, LOCAL_NODE, meshtastic_PortNum_PRIVATE_APP, SMALL_PAYLOAD);
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
// impersonation of signer REMOTE. The malformed-size reject drops it before that math runs.
void test_E8_decoded_partial_signature_from_signer_dropped(void)
{
mockNodeDB->addNode(REMOTE_NODE);
mockNodeDB->setSignerBit(REMOTE_NODE, true);
// 146-byte payload sits in the band that WOULD fit a signature (so an honest unsigned one is a
// downgrade), but the 63 bogus signature bytes push the raw size over the frame limit.
meshtastic_MeshPacket p = makeDecoded(REMOTE_NODE, NODENUM_BROADCAST, meshtastic_PortNum_TEXT_MESSAGE_APP, 146);
p.decoded.xeddsa_signature.size = XEDDSA_SIGNATURE_SIZE - 1;
memset(p.decoded.xeddsa_signature.bytes, 0xCD, p.decoded.xeddsa_signature.size);
TEST_ASSERT_FALSE_MESSAGE(checkXeddsaReceivePolicy(&p), "partial signature from a signer must be dropped");
TEST_ASSERT_FALSE(p.xeddsa_signed);
}
// E9: the malformed-size reject is unconditional - a partial signature is dropped even from a
// node we've never seen sign (an honest sender never emits a 1..63-byte signature field).
void test_E9_decoded_partial_signature_from_nonsigner_dropped(void)
{
mockNodeDB->addNode(REMOTE_NODE); // signer bit clear
meshtastic_MeshPacket p = makeDecoded(REMOTE_NODE, NODENUM_BROADCAST, meshtastic_PortNum_TEXT_MESSAGE_APP, SMALL_PAYLOAD);
p.decoded.xeddsa_signature.size = 10;
memset(p.decoded.xeddsa_signature.bytes, 0x5A, p.decoded.xeddsa_signature.size);
TEST_ASSERT_FALSE_MESSAGE(checkXeddsaReceivePolicy(&p), "partial signature must be dropped as malformed");
}
void setup()
{
initializeTestEnvironment();
@@ -361,23 +698,43 @@ void setup()
RUN_TEST(test_A5_unsigned_broadcast_from_nonsigner_accepted);
RUN_TEST(test_A6_unsigned_unicast_from_signer_accepted);
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);
printf("\n=== Group B: send-side signing policy ===\n");
RUN_TEST(test_B1_local_broadcast_is_signed);
RUN_TEST(test_B2_local_unicast_not_signed);
RUN_TEST(test_B3_local_oversized_broadcast_not_signed);
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);
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 D: encoding invariants ===\n");
RUN_TEST(test_D1_signature_field_overhead_exact);
printf("\n=== Group E: decoded-ingress policy ===\n");
RUN_TEST(test_E1_decoded_unsigned_broadcast_from_signer_dropped);
RUN_TEST(test_E2_decoded_unsigned_broadcast_from_nonsigner_accepted);
RUN_TEST(test_E3_decoded_valid_signature_verified_and_learns_signer);
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);
exit(UNITY_END());
}
void loop() {}
#else // MESHTASTIC_EXCLUDE_PKI
#else // XEdDSA or PKI excluded
void setUp(void) {}
void tearDown(void) {}
+1 -1
View File
@@ -71,7 +71,7 @@ lib_deps =
# renovate: datasource=custom.pio depName=XPowersLib packageName=lewisxhe/library/XPowersLib
lewisxhe/XPowersLib@0.3.3
# renovate: datasource=git-refs depName=meshtastic/Crypto packageName=https://github.com/meshtastic/Crypto gitBranch=master
https://github.com/meshtastic/Crypto/archive/1aa30eb536bd52a576fde6dfa393bf7349cf102d.zip
https://github.com/meshtastic/Crypto/archive/591ff9a690e8168ccb7a36abde8d7783e448d395.zip
lib_ignore =
segger_rtt
+1 -1
View File
@@ -56,4 +56,4 @@ lib_deps =
# renovate: datasource=custom.pio depName=XPowersLib packageName=lewisxhe/library/XPowersLib
lewisxhe/XPowersLib@0.3.3
# renovate: datasource=git-refs depName=meshtastic/Crypto packageName=https://github.com/meshtastic/Crypto gitBranch=master
https://github.com/meshtastic/Crypto/archive/1aa30eb536bd52a576fde6dfa393bf7349cf102d.zip
https://github.com/meshtastic/Crypto/archive/591ff9a690e8168ccb7a36abde8d7783e448d395.zip
+1 -1
View File
@@ -105,6 +105,6 @@ lib_deps =
${environmental_base.lib_deps}
${radiolib_base.lib_deps}
# renovate: datasource=git-refs depName=meshtastic/Crypto packageName=https://github.com/meshtastic/Crypto gitBranch=master
https://github.com/meshtastic/Crypto/archive/1aa30eb536bd52a576fde6dfa393bf7349cf102d.zip
https://github.com/meshtastic/Crypto/archive/591ff9a690e8168ccb7a36abde8d7783e448d395.zip
# renovate: datasource=git-refs depName=meshtastic-ESP32_Codec2 packageName=https://github.com/meshtastic/ESP32_Codec2 gitBranch=master
https://github.com/meshtastic/ESP32_Codec2/archive/633326c78ac251c059ab3a8c430fcdf25b41672f.zip
+1 -1
View File
@@ -24,7 +24,7 @@ lib_deps =
${radiolib_base.lib_deps}
${environmental_base.lib_deps}
# renovate: datasource=git-refs depName=meshtastic/Crypto packageName=https://github.com/meshtastic/Crypto gitBranch=master
https://github.com/meshtastic/Crypto/archive/1aa30eb536bd52a576fde6dfa393bf7349cf102d.zip
https://github.com/meshtastic/Crypto/archive/591ff9a690e8168ccb7a36abde8d7783e448d395.zip
# renovate: datasource=custom.pio depName=LovyanGFX packageName=lovyan03/library/LovyanGFX
lovyan03/LovyanGFX@1.2.24
; # renovate: datasource=git-refs depName=libch341-spi-userspace packageName=https://github.com/pine64/libch341-spi-userspace gitBranch=main
+1 -1
View File
@@ -320,7 +320,7 @@ lib_deps =
${env.lib_deps}
${radiolib_base.lib_deps}
# renovate: datasource=git-refs depName=meshtastic/Crypto packageName=https://github.com/meshtastic/Crypto gitBranch=master
https://github.com/meshtastic/Crypto/archive/1aa30eb536bd52a576fde6dfa393bf7349cf102d.zip
https://github.com/meshtastic/Crypto/archive/591ff9a690e8168ccb7a36abde8d7783e448d395.zip
# renovate: datasource=custom.pio depName=Melopero RV3028 packageName=melopero/library/Melopero RV3028
melopero/Melopero RV3028@1.2.0
+1 -1
View File
@@ -50,7 +50,7 @@ lib_deps=
${arduino_base.lib_deps}
${radiolib_base.lib_deps}
# renovate: datasource=github-tags depName=meshtastic/Crypto packageName=meshtastic/Crypto
https://github.com/meshtastic/Crypto/archive/1aa30eb536bd52a576fde6dfa393bf7349cf102d.zip
https://github.com/meshtastic/Crypto/archive/591ff9a690e8168ccb7a36abde8d7783e448d395.zip
lib_ignore =
BluetoothOTA
+1 -1
View File
@@ -50,7 +50,7 @@ lib_deps =
${arduino_base.lib_deps}
${radiolib_base.lib_deps}
# renovate: datasource=git-refs depName=meshtastic/Crypto packageName=https://github.com/meshtastic/Crypto gitBranch=master
https://github.com/meshtastic/Crypto/archive/1aa30eb536bd52a576fde6dfa393bf7349cf102d.zip
https://github.com/meshtastic/Crypto/archive/591ff9a690e8168ccb7a36abde8d7783e448d395.zip
; Cherry-picked sensor libs from environmental_base. The full
; environmental_base pulls Adafruit_SSD1306 / GFX which need Arduino
; pin macros (digitalPinToPort / portOutputRegister) that the Zephyr
+1 -1
View File
@@ -32,4 +32,4 @@ lib_deps =
${environmental_extra.lib_deps}
${radiolib_base.lib_deps}
# renovate: datasource=github-tags depName=meshtastic/Crypto packageName=meshtastic/Crypto
https://github.com/meshtastic/Crypto/archive/1aa30eb536bd52a576fde6dfa393bf7349cf102d.zip
https://github.com/meshtastic/Crypto/archive/591ff9a690e8168ccb7a36abde8d7783e448d395.zip
+1 -1
View File
@@ -29,4 +29,4 @@ lib_deps =
${environmental_extra.lib_deps}
${radiolib_base.lib_deps}
# renovate: datasource=github-tags depName=meshtastic/Crypto packageName=meshtastic/Crypto
https://github.com/meshtastic/Crypto/archive/1aa30eb536bd52a576fde6dfa393bf7349cf102d.zip
https://github.com/meshtastic/Crypto/archive/591ff9a690e8168ccb7a36abde8d7783e448d395.zip
+1 -1
View File
@@ -55,7 +55,7 @@ lib_deps =
${env.lib_deps}
${radiolib_base.lib_deps}
# renovate: datasource=git-refs depName=meshtastic/Crypto packageName=https://github.com/meshtastic/Crypto gitBranch=main
https://github.com/meshtastic/Crypto/archive/1aa30eb536bd52a576fde6dfa393bf7349cf102d.zip
https://github.com/meshtastic/Crypto/archive/591ff9a690e8168ccb7a36abde8d7783e448d395.zip
lib_ignore =
OneButton