#include "DebugConfiguration.h" #include "FSCommon.h" #include "SPILock.h" #include "TestUtil.h" #include "concurrency/LockGuard.h" #include #if defined(ARCH_PORTDUINO) && HAS_BLE_MESH #if HAS_SCREEN #include "graphics/draw/MenuHandler.h" #endif #include "mesh/BLEMeshHandler.h" #include "mesh/CryptoEngine.h" #include "mesh/NodeDB.h" #include "mesh/PacketHistory.h" #include "mesh/Router.h" #include #include #include #include namespace { /** * A BLEMeshHandler with the radio replaced by a record of what it was asked to send. * * Everything worth testing here is platform-independent - the advertisement the transport builds * and the guards it applies to what it receives - so the platform hooks only need to be observable, * not real. The secure envelope, approval checks, verification code, and tamper detection are also * pinned here. */ class FakeBLEMesh : public BLEMeshHandler { public: std::vector> sent; std::vector received; bool ready = true; bool advertising = false; void start() override { isRunning = true; } void stop() override { isRunning = false; } // Exposed so tests can drive ingress without a BLE stack. void feed(const uint8_t *data, size_t len, int8_t rssi) { deliverToRouter(data, len, rssi); } uint8_t build(const meshtastic_MeshPacket *mp, NodeNum peer, uint8_t *out, size_t cap) { return buildAdvPayload(mp, peer, out, cap); } bool approveForTest(NodeNum nodeNum, const uint8_t publicKey[32]) { return addPairForTest(nodeNum, publicKey); } int32_t pump() { return runOnce(); } void enqueueReceived(meshtastic_MeshPacket *p) override { received.push_back(*p); packetPool.release(p); } protected: bool platformBeginAdvertising(const uint8_t *adv, size_t len) override { sent.emplace_back(adv, adv + len); advertising = true; return true; } bool platformAdvertisingActive() override { return advertising; } void platformEndAdvertising() override { advertising = false; } bool platformReady() override { return ready; } }; /// A packet in the state Router::send hands to a transport: encrypted, with a real sender. meshtastic_MeshPacket encryptedPacket(uint32_t from = 0x3061b02e, uint32_t id = 0x04050b6e, size_t payload = 32) { meshtastic_MeshPacket p = meshtastic_MeshPacket_init_zero; p.from = from; p.to = NODENUM_BROADCAST; p.id = id; p.channel = 50; p.hop_limit = 3; p.hop_start = 3; p.which_payload_variant = meshtastic_MeshPacket_encrypted_tag; p.encrypted.size = payload; for (size_t i = 0; i < payload; i++) p.encrypted.bytes[i] = (uint8_t)i; return p; } struct TestIdentity { std::array privateKey{}; std::array publicKey{}; NodeNum nodeNum = 0; }; TestIdentity makeIdentity(uint8_t seed) { TestIdentity identity; for (size_t i = 0; i < identity.privateKey.size(); i++) identity.privateKey[i] = seed + static_cast(i); Curve25519::eval(identity.publicKey.data(), identity.privateKey.data(), nullptr); identity.nodeNum = crc32Buffer(identity.publicKey.data(), identity.publicKey.size()); return identity; } void useIdentity(const TestIdentity &identity) { config.security.private_key.size = 32; config.security.public_key.size = 32; memcpy(config.security.private_key.bytes, identity.privateKey.data(), 32); memcpy(config.security.public_key.bytes, identity.publicKey.data(), 32); owner.public_key.size = 32; memcpy(owner.public_key.bytes, identity.publicKey.data(), 32); myNodeInfo.my_node_num = identity.nodeNum; crypto->setDHPrivateKey(config.security.private_key.bytes); } std::vector frameBody(const std::vector &advertisement) { return std::vector(advertisement.begin() + BLE_MESH_ADV_OVERHEAD, advertisement.end()); } /// Largest ciphertext a MeshPacket can carry, i.e. one guaranteed not to fit an advertisement. constexpr size_t MAX_ENCRYPTED_FOR_TEST = sizeof(meshtastic_MeshPacket().encrypted.bytes); uint8_t buildAuthenticatedAdvertisement(FakeBLEMesh &handler, const TestIdentity &sender, const TestIdentity &recipient, const meshtastic_MeshPacket &packet, uint8_t out[BLE_MESH_ADV_TOTAL_MAX]) { useIdentity(sender); handler.start(); if (!handler.approveForTest(recipient.nodeNum, recipient.publicKey.data())) return 0; return handler.build(&packet, recipient.nodeNum, out, BLE_MESH_ADV_TOTAL_MAX); } uint32_t readTestU32(const uint8_t *in) { uint32_t value; memcpy(&value, in, sizeof(value)); return value; } } // namespace void test_advertisement_carries_the_packet(void) { FakeBLEMesh h; const auto sender = makeIdentity(1); const auto recipient = makeIdentity(65); auto p = encryptedPacket(); uint8_t adv[BLE_MESH_ADV_TOTAL_MAX]; uint8_t len = buildAuthenticatedAdvertisement(h, sender, recipient, p, adv); TEST_ASSERT_TRUE_MESSAGE(len > BLE_MESH_ADV_OVERHEAD, "built an advertisement"); // Flags AD, then manufacturer-specific data with our company ID and protocol version. TEST_ASSERT_EQUAL_UINT8(2, adv[0]); TEST_ASSERT_EQUAL_UINT8(0x01, adv[1]); TEST_ASSERT_EQUAL_UINT8(0xFF, adv[4]); TEST_ASSERT_EQUAL_UINT8(BLE_MESH_COMPANY_ID & 0xFF, adv[5]); TEST_ASSERT_EQUAL_UINT8((BLE_MESH_COMPANY_ID >> 8) & 0xFF, adv[6]); TEST_ASSERT_EQUAL_UINT8(BLE_MESH_PROTOCOL_VERSION, adv[7]); TEST_ASSERT_EQUAL_UINT8(BLE_MESH_FRAME_DATA, adv[BLE_MESH_ADV_OVERHEAD]); TEST_ASSERT_EQUAL_UINT32(sender.nodeNum, readTestU32(&adv[BLE_MESH_ADV_OVERHEAD + 1])); TEST_ASSERT_EQUAL_UINT32(recipient.nodeNum, readTestU32(&adv[BLE_MESH_ADV_OVERHEAD + 5])); // The AD length byte counts everything after itself. TEST_ASSERT_EQUAL_UINT8(len - 4, adv[3]); } void test_refuses_an_unencrypted_packet(void) { FakeBLEMesh h; const auto sender = makeIdentity(1); const auto recipient = makeIdentity(65); useIdentity(sender); h.start(); auto p = encryptedPacket(); p.which_payload_variant = meshtastic_MeshPacket_decoded_tag; uint8_t adv[BLE_MESH_ADV_TOTAL_MAX]; // Router::send encrypts before any transport sees a packet, so plaintext here is a bug // upstream - putting it on air would leak the message. h.approveForTest(recipient.nodeNum, recipient.publicKey.data()); TEST_ASSERT_EQUAL_UINT8(0, h.build(&p, recipient.nodeNum, adv, sizeof(adv))); } void test_refuses_a_packet_with_no_sender(void) { FakeBLEMesh h; const auto sender = makeIdentity(1); const auto recipient = makeIdentity(65); useIdentity(sender); h.start(); auto p = encryptedPacket(0 /* from */); uint8_t adv[BLE_MESH_ADV_TOTAL_MAX]; // Nothing legitimate advertises from=0, and a packet with no sender can reach remote admin // without authorisation - the LoRa path refuses it for the same reason. h.approveForTest(recipient.nodeNum, recipient.publicKey.data()); TEST_ASSERT_EQUAL_UINT8(0, h.build(&p, recipient.nodeNum, adv, sizeof(adv))); } void test_drops_a_packet_too_large_for_one_advertisement(void) { FakeBLEMesh h; const auto sender = makeIdentity(1); const auto recipient = makeIdentity(65); useIdentity(sender); h.start(); // A single unfragmented extended advertisement holds 251 bytes and we never chain, so the // largest packets cannot ride BLE. They still go out over LoRa - Router::send has already // handed them to the radio by the time we refuse. auto p = encryptedPacket(0x3061b02e, 0x04050b6e, MAX_ENCRYPTED_FOR_TEST); uint8_t adv[BLE_MESH_ADV_TOTAL_MAX]; h.approveForTest(recipient.nodeNum, recipient.publicKey.data()); TEST_ASSERT_EQUAL_UINT8(0, h.build(&p, recipient.nodeNum, adv, sizeof(adv))); } void test_send_queues_rather_than_transmitting(void) { FakeBLEMesh h; const auto sender = makeIdentity(1); const auto recipient = makeIdentity(65); useIdentity(sender); h.start(); TEST_ASSERT_TRUE(h.approveForTest(recipient.nodeNum, recipient.publicKey.data())); auto p = encryptedPacket(); TEST_ASSERT_TRUE(h.onSend(&p)); // onSend is reached from Router::send on the main task. An implementation that advertised // inline would stall the router - and so LoRa timing and the whole main loop - for the length // of every burst. TEST_ASSERT_EQUAL_MESSAGE(0, h.sent.size(), "nothing on air yet"); h.pump(); TEST_ASSERT_EQUAL_MESSAGE(1, h.sent.size(), "the pump transmits it"); } void test_tx_queue_is_bounded(void) { FakeBLEMesh h; const auto sender = makeIdentity(1); const auto recipient = makeIdentity(65); useIdentity(sender); h.start(); TEST_ASSERT_TRUE(h.approveForTest(recipient.nodeNum, recipient.publicKey.data())); size_t accepted = 0; for (size_t i = 0; i < BLE_MESH_TX_QUEUE_SIZE * 3; i++) { auto p = encryptedPacket(0x3061b02e, (uint32_t)(0x1000 + i)); if (h.onSend(&p)) accepted++; } // A full ring drops rather than growing without bound or overwriting an unsent frame. TEST_ASSERT_EQUAL(BLE_MESH_TX_QUEUE_SIZE, accepted); } void test_a_relayed_packet_is_re_advertised(void) { FakeBLEMesh h; const auto sender = makeIdentity(1); const auto recipient = makeIdentity(65); useIdentity(sender); h.start(); TEST_ASSERT_TRUE(h.approveForTest(recipient.nodeNum, recipient.publicKey.data())); auto p = encryptedPacket(); p.transport_mechanism = meshtastic_MeshPacket_TransportMechanism_TRANSPORT_BLE_ADV; // A packet already marked as BLE-sourced is what a *rebroadcast* looks like: // perhapsRebroadcast allocCopy()s the received packet and nothing on the TX path rewrites // transport_mechanism. Refusing it caps the mesh at a single hop - two nodes can talk and a // three-node chain cannot form. TEST_ASSERT_TRUE_MESSAGE(h.onSend(&p), "relay must not be refused"); } void test_pairing_hello_produces_the_same_verification_code(void) { const auto a = makeIdentity(1); const auto b = makeIdentity(65); FakeBLEMesh nodeA; FakeBLEMesh nodeB; uint32_t codeA = UINT32_MAX; uint32_t codeB = UINT32_MAX; useIdentity(a); nodeA.start(); TEST_ASSERT_TRUE(nodeA.beginPairing([&codeA](NodeNum, uint32_t code) { codeA = code; })); nodeA.pump(); TEST_ASSERT_EQUAL(1, nodeA.sent.size()); nodeA.advertising = false; useIdentity(b); nodeB.start(); TEST_ASSERT_TRUE(nodeB.beginPairing([&codeB](NodeNum, uint32_t code) { codeB = code; })); nodeB.pump(); TEST_ASSERT_EQUAL(1, nodeB.sent.size()); nodeB.advertising = false; auto helloA = frameBody(nodeA.sent[0]); auto helloB = frameBody(nodeB.sent[0]); nodeB.feed(helloA.data(), helloA.size(), -40); nodeB.pump(); useIdentity(a); nodeA.feed(helloB.data(), helloB.size(), -41); nodeA.pump(); TEST_ASSERT_NOT_EQUAL(UINT32_MAX, codeA); TEST_ASSERT_EQUAL(codeA, codeB); nodeA.cancelPairing(); nodeB.cancelPairing(); } void test_approved_peer_survives_a_handler_restart(void) { const auto sender = makeIdentity(1); const auto recipient = makeIdentity(65); useIdentity(sender); FakeBLEMesh first; TEST_ASSERT_TRUE(first.approveForTest(recipient.nodeNum, recipient.publicKey.data())); FakeBLEMesh restarted; TEST_ASSERT_TRUE(restarted.isPaired(recipient.nodeNum)); TEST_ASSERT_EQUAL_UINT8(1, restarted.pairedCount()); } void test_ingress_accepts_an_authenticated_approved_peer(void) { const auto sender = makeIdentity(1); const auto recipient = makeIdentity(65); FakeBLEMesh outbound; FakeBLEMesh inbound; auto p = encryptedPacket(0x3061b02e); uint8_t adv[BLE_MESH_ADV_TOTAL_MAX]; const uint8_t len = buildAuthenticatedAdvertisement(outbound, sender, recipient, p, adv); TEST_ASSERT_TRUE(len > BLE_MESH_ADV_OVERHEAD); useIdentity(recipient); inbound.start(); TEST_ASSERT_TRUE(inbound.approveForTest(sender.nodeNum, sender.publicKey.data())); inbound.feed(adv + BLE_MESH_ADV_OVERHEAD, len - BLE_MESH_ADV_OVERHEAD, -42); TEST_ASSERT_EQUAL_MESSAGE(1, inbound.received.size(), "delivered to the router"); const auto &got = inbound.received[0]; TEST_ASSERT_EQUAL_UINT32(0x3061b02e, got.from); // Stamped so the router - and anything downstream - can tell how it arrived. TEST_ASSERT_EQUAL(meshtastic_MeshPacket_TransportMechanism_TRANSPORT_BLE_ADV, got.transport_mechanism); // Unlike UDP there IS a real measurement of this hop, so it is reported rather than cleared. TEST_ASSERT_TRUE(got.has_rx_rssi); TEST_ASSERT_EQUAL_INT(-42, got.rx_rssi); TEST_ASSERT_EQUAL_MESSAGE(0, got.rx_snr, "no SNR exists for a BLE arrival"); TEST_ASSERT_FALSE_MESSAGE(got.pki_encrypted, "claimed authentication stripped"); TEST_ASSERT_EQUAL(0, got.public_key.size); } void test_ingress_rejects_an_unapproved_peer(void) { const auto sender = makeIdentity(1); const auto recipient = makeIdentity(65); FakeBLEMesh outbound; FakeBLEMesh inbound; auto p = encryptedPacket(); uint8_t adv[BLE_MESH_ADV_TOTAL_MAX]; const uint8_t len = buildAuthenticatedAdvertisement(outbound, sender, recipient, p, adv); useIdentity(recipient); inbound.start(); inbound.feed(adv + BLE_MESH_ADV_OVERHEAD, len - BLE_MESH_ADV_OVERHEAD, -50); TEST_ASSERT_EQUAL_MESSAGE(0, inbound.received.size(), "unapproved bridge rejected"); } void test_ingress_rejects_a_tampered_frame(void) { const auto sender = makeIdentity(1); const auto recipient = makeIdentity(65); FakeBLEMesh outbound; FakeBLEMesh inbound; auto p = encryptedPacket(); uint8_t adv[BLE_MESH_ADV_TOTAL_MAX]; const uint8_t len = buildAuthenticatedAdvertisement(outbound, sender, recipient, p, adv); adv[len - 1] ^= 0x80; useIdentity(recipient); inbound.start(); TEST_ASSERT_TRUE(inbound.approveForTest(sender.nodeNum, sender.publicKey.data())); inbound.feed(adv + BLE_MESH_ADV_OVERHEAD, len - BLE_MESH_ADV_OVERHEAD, -50); TEST_ASSERT_EQUAL_MESSAGE(0, inbound.received.size(), "tampered frame rejected"); } void test_existing_packet_history_rejects_an_authenticated_replay(void) { const auto sender = makeIdentity(1); const auto recipient = makeIdentity(65); FakeBLEMesh outbound; FakeBLEMesh inbound; auto p = encryptedPacket(); uint8_t adv[BLE_MESH_ADV_TOTAL_MAX]; const uint8_t len = buildAuthenticatedAdvertisement(outbound, sender, recipient, p, adv); useIdentity(recipient); inbound.start(); TEST_ASSERT_TRUE(inbound.approveForTest(sender.nodeNum, sender.publicKey.data())); inbound.feed(adv + BLE_MESH_ADV_OVERHEAD, len - BLE_MESH_ADV_OVERHEAD, -50); inbound.feed(adv + BLE_MESH_ADV_OVERHEAD, len - BLE_MESH_ADV_OVERHEAD, -50); TEST_ASSERT_EQUAL_MESSAGE(2, inbound.received.size(), "transport hands authenticated packets to the router"); PacketHistory history(4); TEST_ASSERT_FALSE(history.wasSeenRecently(&inbound.received[0])); TEST_ASSERT_TRUE_MESSAGE(history.wasSeenRecently(&inbound.received[1]), "existing packet history rejects replay"); } void test_ingress_rejects_the_removed_legacy_format(void) { FakeBLEMesh h; h.start(); auto p = encryptedPacket(); uint8_t body[meshtastic_MeshPacket_size]; const size_t n = pb_encode_to_bytes(body, sizeof(body), &meshtastic_MeshPacket_msg, &p); h.feed(body, n, -50); TEST_ASSERT_EQUAL_MESSAGE(0, h.received.size(), "legacy unauthenticated frame rejected"); } void test_ingress_drops_an_impossible_hop_count(void) { const auto sender = makeIdentity(1); const auto recipient = makeIdentity(65); FakeBLEMesh outbound; FakeBLEMesh inbound; auto p = encryptedPacket(); p.hop_limit = HOP_MAX + 1; uint8_t adv[BLE_MESH_ADV_TOTAL_MAX]; const uint8_t len = buildAuthenticatedAdvertisement(outbound, sender, recipient, p, adv); useIdentity(recipient); inbound.start(); TEST_ASSERT_TRUE(inbound.approveForTest(sender.nodeNum, sender.publicKey.data())); inbound.feed(adv + BLE_MESH_ADV_OVERHEAD, len - BLE_MESH_ADV_OVERHEAD, -50); TEST_ASSERT_EQUAL_MESSAGE(0, inbound.received.size(), "invalid hop count rejected"); } void test_pump_waits_for_the_platform(void) { FakeBLEMesh h; const auto sender = makeIdentity(1); const auto recipient = makeIdentity(65); useIdentity(sender); h.start(); TEST_ASSERT_TRUE(h.approveForTest(recipient.nodeNum, recipient.publicKey.data())); h.ready = false; auto p = encryptedPacket(); TEST_ASSERT_TRUE(h.onSend(&p)); h.pump(); // Readiness is polled rather than pushed: the BLE stack comes up before main() constructs the // handler about half the time, so a one-shot "ready" callback is a race that loses silently. TEST_ASSERT_EQUAL_MESSAGE(0, h.sent.size(), "nothing transmitted before the stack is up"); h.ready = true; h.pump(); TEST_ASSERT_EQUAL_MESSAGE(1, h.sent.size(), "transmits once ready"); } #if HAS_BLE_MESH // menuHandler::setNodePairingEnabled() in MenuHandler.cpp is the System-screen control plane for // this transport. This pins the flag preservation and lifecycle calls so the menu cannot become // cosmetic, or silently disable another configured network transport while enabling BLE sharing. void test_node_pairing_menu_controls_ble_mesh_transport() { struct StateRestore { meshtastic_LocalConfig savedConfig; BLEMeshHandler *savedHandler; ~StateRestore() { bleMeshHandler = savedHandler; config = savedConfig; } } restore{config, bleMeshHandler}; FakeBLEMesh handler; bleMeshHandler = &handler; config.has_network = false; config.bluetooth.enabled = false; config.network.enabled_protocols = meshtastic_Config_NetworkConfig_ProtocolFlags_UDP_BROADCAST | meshtastic_Config_NetworkConfig_ProtocolFlags_BLE_GATT_PEER; TEST_ASSERT_TRUE(graphics::menuHandler::setNodePairingEnabled(true)); TEST_ASSERT_TRUE(config.has_network); TEST_ASSERT_TRUE(config.bluetooth.enabled); TEST_ASSERT_BITS_HIGH(meshtastic_Config_NetworkConfig_ProtocolFlags_BLE_BROADCAST, config.network.enabled_protocols); TEST_ASSERT_BITS_HIGH(meshtastic_Config_NetworkConfig_ProtocolFlags_UDP_BROADCAST | meshtastic_Config_NetworkConfig_ProtocolFlags_BLE_GATT_PEER, config.network.enabled_protocols); const auto sender = makeIdentity(1); const auto recipient = makeIdentity(65); useIdentity(sender); TEST_ASSERT_TRUE(handler.approveForTest(recipient.nodeNum, recipient.publicKey.data())); auto packet = encryptedPacket(); TEST_ASSERT_TRUE(handler.onSend(&packet)); TEST_ASSERT_FALSE(graphics::menuHandler::setNodePairingEnabled(false)); TEST_ASSERT_BITS_LOW(meshtastic_Config_NetworkConfig_ProtocolFlags_BLE_BROADCAST, config.network.enabled_protocols); TEST_ASSERT_BITS_HIGH(meshtastic_Config_NetworkConfig_ProtocolFlags_UDP_BROADCAST | meshtastic_Config_NetworkConfig_ProtocolFlags_BLE_GATT_PEER, config.network.enabled_protocols); TEST_ASSERT_FALSE(handler.onSend(&packet)); } #endif void setUp(void) {} void tearDown(void) { concurrency::LockGuard guard(spiLock); if (FSCom.exists("/prefs/ble-pairs.dat")) FSCom.remove("/prefs/ble-pairs.dat"); } void setup() { initializeTestEnvironment(); // deliverToRouter consults nodeDB to recognise - and drop - our own advertisement echoing back // into our own scanner, so the ingress tests need a real one. if (!nodeDB) nodeDB = new NodeDB(); UNITY_BEGIN(); RUN_TEST(test_advertisement_carries_the_packet); RUN_TEST(test_refuses_an_unencrypted_packet); RUN_TEST(test_refuses_a_packet_with_no_sender); RUN_TEST(test_drops_a_packet_too_large_for_one_advertisement); RUN_TEST(test_send_queues_rather_than_transmitting); RUN_TEST(test_tx_queue_is_bounded); RUN_TEST(test_a_relayed_packet_is_re_advertised); RUN_TEST(test_pairing_hello_produces_the_same_verification_code); RUN_TEST(test_approved_peer_survives_a_handler_restart); RUN_TEST(test_ingress_accepts_an_authenticated_approved_peer); RUN_TEST(test_ingress_rejects_an_unapproved_peer); RUN_TEST(test_ingress_rejects_a_tampered_frame); RUN_TEST(test_existing_packet_history_rejects_an_authenticated_replay); RUN_TEST(test_ingress_rejects_the_removed_legacy_format); RUN_TEST(test_ingress_drops_an_impossible_hop_count); RUN_TEST(test_pump_waits_for_the_platform); #if HAS_BLE_MESH RUN_TEST(test_node_pairing_menu_controls_ble_mesh_transport); #endif exit(UNITY_END()); } #else void setup() { initializeTestEnvironment(); LOG_WARN("BLE mesh tests require ARCH_PORTDUINO with HAS_BLE_MESH"); UNITY_BEGIN(); exit(UNITY_END()); } #endif // ARCH_PORTDUINO && HAS_BLE_MESH void loop() {}