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:
@@ -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(©) + 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) {}
|
||||
|
||||
Reference in New Issue
Block a user