Router: size the fit check from the decoded Data (#11018)

This commit is contained in:
Thomas Göttgens
2026-07-16 08:51:10 -05:00
committed by GitHub
co-authored by GitHub
parent f7fd058308
commit f52d7753ad
3 changed files with 111 additions and 23 deletions
+93 -4
View File
@@ -148,6 +148,58 @@ static bool signedEncodingFits(const meshtastic_Data *d)
return encodedDataSize(&copy) + MESHTASTIC_HEADER_LENGTH <= MAX_LORA_PAYLOAD_LEN;
}
// Append a length-delimited field whose tag this build's Data schema does not define, as a sender
// on a newer schema would emit. nanopb skips unknown fields at decode, so these bytes count toward
// the raw wire size but not the decoded struct. Returns the number of bytes appended.
static size_t appendUnknownField(uint8_t *dst, size_t dstLen, size_t contentLen)
{
constexpr uint32_t UNKNOWN_FIELD_NUMBER = 100; // not a field of meshtastic_Data
std::vector<uint8_t> content(contentLen, 0x77);
pb_ostream_t stream = pb_ostream_from_buffer(dst, dstLen);
TEST_ASSERT_TRUE(pb_encode_tag(&stream, PB_WT_STRING, UNKNOWN_FIELD_NUMBER));
TEST_ASSERT_TRUE(pb_encode_string(&stream, content.data(), content.size()));
return stream.bytes_written;
}
// Channel-encrypt raw Data bytes into a packet, exactly as perhapsEncode's non-PKI path does.
// Used to inject wire bytes perhapsEncode would never produce (it only encodes p->decoded).
static void encryptAsChannelPacket(meshtastic_MeshPacket *p, uint8_t *wire, size_t size)
{
const int16_t hash = channels.setActiveByIndex(0);
TEST_ASSERT_GREATER_OR_EQUAL_MESSAGE(0, hash, "no usable primary channel");
crypto->encryptPacket(getFrom(p), p->id, size, wire);
memcpy(p->encrypted.bytes, wire, size);
p->encrypted.size = size;
p->channel = hash; // on the wire the channel field carries the hash, not the index
p->which_payload_variant = meshtastic_MeshPacket_encrypted_tag;
}
// Build A10's frame: an unsigned broadcast carrying a POSITION payload plus unknown fields, sized
// so the raw wire length exceeds the signature-fit threshold while the decoded fields stay under
// it. Channel-encrypted like a normal sender. The asserts pin that split, which is what makes A10
// and A11 meaningful.
static meshtastic_MeshPacket makeBroadcastWithUnknownFields()
{
meshtastic_MeshPacket p = makeDecoded(REMOTE_NODE, NODENUM_BROADCAST, meshtastic_PortNum_POSITION_APP, SMALL_PAYLOAD);
uint8_t wire[MAX_LORA_PAYLOAD_LEN + 1];
const size_t base = pb_encode_to_bytes(wire, sizeof(wire), &meshtastic_Data_msg, &p.decoded);
TEST_ASSERT_GREATER_THAN_MESSAGE(0, base, "failed to encode the base Data");
const size_t raw = base + appendUnknownField(wire + base, sizeof(wire) - base, 160);
// The decoded fields fit a signature, so a sender that signs would have signed this Data.
TEST_ASSERT_LESS_OR_EQUAL_MESSAGE(MAX_LORA_PAYLOAD_LEN, base + XEDDSA_SIGNATURE_FIELD_BYTES + MESHTASTIC_HEADER_LENGTH,
"decoded fields must fit a signature, else the test is vacuous");
// The unknown fields put the raw size over that threshold, so the two sizings disagree here.
TEST_ASSERT_GREATER_THAN_MESSAGE(MAX_LORA_PAYLOAD_LEN, raw + XEDDSA_SIGNATURE_FIELD_BYTES + MESHTASTIC_HEADER_LENGTH,
"unknown fields must push the raw size past the fit threshold");
// The frame is still one a radio could actually send.
TEST_ASSERT_LESS_OR_EQUAL_MESSAGE(MAX_LORA_PAYLOAD_LEN, raw + MESHTASTIC_HEADER_LENGTH, "frame must still fit a LoRa frame");
encryptAsChannelPacket(&p, wire, raw);
return p;
}
// ---------------------------------------------------------------------------
// Unity lifecycle
// ---------------------------------------------------------------------------
@@ -324,6 +376,41 @@ void test_A9_unsigned_boundary_broadcast_from_signer_still_dropped(void)
TEST_ASSERT_EQUAL(DECODE_FAILURE, roundTrip(&p));
}
// A10: unknown fields must not sway the downgrade decision. A sender on a newer schema can include
// Data fields this build doesn't define; nanopb skips them, so they grow the frame without changing
// what decodes. The decision sizes p->decoded, keeping it on the fields the sender encoded, so an
// unsigned broadcast from a known signer is dropped whether or not unknown fields came along.
void test_A10_unsigned_broadcast_from_signer_with_unknown_fields_dropped(void)
{
mockNodeDB->addNode(REMOTE_NODE);
mockNodeDB->setSignerBit(REMOTE_NODE, true); // we've seen this node sign before
meshtastic_MeshPacket p = makeBroadcastWithUnknownFields();
TEST_ASSERT_EQUAL_MESSAGE(DECODE_FAILURE, perhapsDecode(&p),
"unsigned broadcast from a signer must be dropped despite unknown fields");
}
// A11: A10's control - the same frame from a node we've never seen sign is accepted and still
// delivers its payload, so A10's DECODE_FAILURE is the downgrade drop and not a decode failure
// caused by the unknown fields.
void test_A11_unsigned_broadcast_from_nonsigner_with_unknown_fields_accepted(void)
{
mockNodeDB->addNode(REMOTE_NODE); // signer bit clear
meshtastic_MeshPacket p = makeBroadcastWithUnknownFields();
const size_t rawSize = p.encrypted.size; // read before decode: encrypted/decoded share a union
TEST_ASSERT_EQUAL_MESSAGE(DECODE_SUCCESS, perhapsDecode(&p), "frame from a non-signer must still decode");
TEST_ASSERT_EQUAL_MESSAGE(meshtastic_PortNum_POSITION_APP, p.decoded.portnum, "unknown fields must not disturb the portnum");
TEST_ASSERT_EQUAL_MESSAGE(SMALL_PAYLOAD, p.decoded.payload.size, "payload must survive the unknown fields");
TEST_ASSERT_FALSE(p.xeddsa_signed);
// The unknown fields are gone from the decoded struct: the gap the sizing basis has to account for.
TEST_ASSERT_LESS_THAN_MESSAGE(rawSize, encodedDataSize(&p.decoded),
"unknown fields must drop at decode, leaving decoded size < raw");
}
// ===========================================================================
// Group B - send-side signing policy (perhapsEncode)
// ===========================================================================
@@ -418,7 +505,7 @@ void test_B5_preset_signature_on_local_packet_cleared(void)
// 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
// that the receiver's 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)
@@ -558,7 +645,7 @@ void test_D1_signature_field_overhead_exact(void)
// ===========================================================================
// 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).
// tests drive it the same way: decoded packets, sized from p->decoded exactly as the RF path is.
// End-to-end MQTT wiring is covered in test_mqtt.
// E1: unsigned small broadcast from a known signer -> dropped (downgrade protection holds on
@@ -616,8 +703,8 @@ void test_E4_decoded_bad_signature_dropped(void)
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).
// E5: unsigned oversized broadcast from a signer -> accepted (packets whose signed encoding
// wouldn't fit are exempt, identically to the RF path: both size p->decoded).
void test_E5_decoded_unsigned_oversized_broadcast_from_signer_accepted(void)
{
mockNodeDB->addNode(REMOTE_NODE);
@@ -700,6 +787,8 @@ void setup()
RUN_TEST(test_A7_unsigned_oversized_broadcast_from_signer_accepted);
RUN_TEST(test_A8_unsigned_deadband_broadcast_from_signer_accepted);
RUN_TEST(test_A9_unsigned_boundary_broadcast_from_signer_still_dropped);
RUN_TEST(test_A10_unsigned_broadcast_from_signer_with_unknown_fields_dropped);
RUN_TEST(test_A11_unsigned_broadcast_from_nonsigner_with_unknown_fields_accepted);
printf("\n=== Group B: send-side signing policy ===\n");
RUN_TEST(test_B1_local_broadcast_is_signed);