diff --git a/src/mesh/CryptoEngine.cpp b/src/mesh/CryptoEngine.cpp index fa3bcd331..73ccb2166 100644 --- a/src/mesh/CryptoEngine.cpp +++ b/src/mesh/CryptoEngine.cpp @@ -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; } diff --git a/src/mesh/CryptoEngine.h b/src/mesh/CryptoEngine.h index 96e1909b3..2b0090d5c 100644 --- a/src/mesh/CryptoEngine.h +++ b/src/mesh/CryptoEngine.h @@ -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 { diff --git a/src/mesh/Router.cpp b/src/mesh/Router.cpp index 977d478d4..e2fbcf419 100644 --- a/src/mesh/Router.cpp +++ b/src/mesh/Router.cpp @@ -12,6 +12,7 @@ #include "mesh-pb-constants.h" #include "meshUtils.h" #include "modules/RoutingModule.h" +#include #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; diff --git a/src/mesh/Router.h b/src/mesh/Router.h index b9c1d9c54..954b88f24 100644 --- a/src/mesh/Router.h +++ b/src/mesh/Router.h @@ -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 diff --git a/src/modules/NodeInfoModule.cpp b/src/modules/NodeInfoModule.cpp index a97ce04a7..7b9f29201 100644 --- a/src/modules/NodeInfoModule.cpp +++ b/src/modules/NodeInfoModule.cpp @@ -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; } diff --git a/src/mqtt/MQTT.cpp b/src/mqtt/MQTT.cpp index 8d5ef8ebe..9c2acf5a4 100644 --- a/src/mqtt/MQTT.cpp +++ b/src/mqtt/MQTT.cpp @@ -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 diff --git a/test/test_crypto/test_main.cpp b/test/test_crypto/test_main.cpp index 99edfa69c..30ed9490d 100644 --- a/test/test_crypto/test_main.cpp +++ b/test/test_crypto/test_main.cpp @@ -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 } diff --git a/test/test_mqtt/MQTT.cpp b/test/test_mqtt/MQTT.cpp index c091243ab..7052b1bcd 100644 --- a/test/test_mqtt/MQTT.cpp +++ b/test/test_mqtt/MQTT.cpp @@ -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(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); diff --git a/test/test_packet_signing/test_main.cpp b/test/test_packet_signing/test_main.cpp index 9bddc7527..707d71619 100644 --- a/test/test_packet_signing/test_main.cpp +++ b/test/test_packet_signing/test_main.cpp @@ -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 -#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 #include #include +#include #include // --------------------------------------------------------------------------- @@ -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(©) + 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) {} diff --git a/variants/esp32/esp32-common.ini b/variants/esp32/esp32-common.ini index fac7cc911..49a9b8e5f 100644 --- a/variants/esp32/esp32-common.ini +++ b/variants/esp32/esp32-common.ini @@ -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 diff --git a/variants/esp32/esp32.ini b/variants/esp32/esp32.ini index 511c94870..2a61bfa97 100644 --- a/variants/esp32/esp32.ini +++ b/variants/esp32/esp32.ini @@ -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 diff --git a/variants/esp32p4/esp32p4.ini b/variants/esp32p4/esp32p4.ini index 5682a0ad7..85abaee5c 100644 --- a/variants/esp32p4/esp32p4.ini +++ b/variants/esp32p4/esp32p4.ini @@ -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 diff --git a/variants/native/portduino.ini b/variants/native/portduino.ini index f7f3eab9d..98cbaf6c3 100644 --- a/variants/native/portduino.ini +++ b/variants/native/portduino.ini @@ -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 diff --git a/variants/native/portduino/platformio.ini b/variants/native/portduino/platformio.ini index f29da3de2..d3cdeee77 100644 --- a/variants/native/portduino/platformio.ini +++ b/variants/native/portduino/platformio.ini @@ -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 diff --git a/variants/nrf52840/nrf52.ini b/variants/nrf52840/nrf52.ini index 5dc8ee2e5..f1502ecf3 100644 --- a/variants/nrf52840/nrf52.ini +++ b/variants/nrf52840/nrf52.ini @@ -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 diff --git a/variants/nrf54l15/nrf54l15.ini b/variants/nrf54l15/nrf54l15.ini index 6b004a675..6a299c9d4 100644 --- a/variants/nrf54l15/nrf54l15.ini +++ b/variants/nrf54l15/nrf54l15.ini @@ -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 diff --git a/variants/rp2040/rp2040.ini b/variants/rp2040/rp2040.ini index c560d21a6..7182fa833 100644 --- a/variants/rp2040/rp2040.ini +++ b/variants/rp2040/rp2040.ini @@ -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 diff --git a/variants/rp2350/rp2350.ini b/variants/rp2350/rp2350.ini index d2958d965..5a51bd8bb 100644 --- a/variants/rp2350/rp2350.ini +++ b/variants/rp2350/rp2350.ini @@ -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 diff --git a/variants/stm32/stm32.ini b/variants/stm32/stm32.ini index ba11bea31..5726fd369 100644 --- a/variants/stm32/stm32.ini +++ b/variants/stm32/stm32.ini @@ -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