From 6fa3df0af3305f28ba9e72659cdab245d3f4eb7c Mon Sep 17 00:00:00 2001 From: HarukiToreda Date: Mon, 14 Sep 2026 16:20:07 -0400 Subject: [PATCH] node bridge --- src/graphics/draw/MenuHandler.cpp | 148 ++++- src/graphics/draw/MenuHandler.h | 15 + src/mesh/BLEMeshHandler.cpp | 620 +++++++++++++++--- src/mesh/BLEMeshHandler.h | 89 ++- src/mesh/Channels.cpp | 4 +- src/mesh/CryptoEngine.cpp | 15 + src/mesh/CryptoEngine.h | 6 +- src/mesh/Router.cpp | 38 ++ src/platform/esp32/ESP32BLEMesh.cpp | 2 + src/platform/nrf52/NRF52BLEMesh.cpp | 2 + src/platform/nrf52/NRF52Bluetooth.cpp | 11 +- src/platform/nrf52/nrf52840_s140_v6.ld | 17 +- .../nrf52/nrf52840_s140_v6_blemesh.ld | 56 -- src/platform/nrf52/nrf52840_s140_v7.ld | 17 +- test/state-manifest.tsv | 1 + test/test_ble_mesh/BleMesh.cpp | 379 ++++++++--- test/test_channel_keys/test_main.cpp | 49 ++ variants/esp32/esp32-common.ini | 31 +- variants/esp32c3/esp32c3.ini | 2 + .../esp32c3/heltec_esp32c3/platformio.ini | 11 - variants/esp32c6/esp32c6.ini | 2 + variants/esp32s3/esp32s3.ini | 2 + variants/esp32s3/heltec_v3/platformio.ini | 15 - variants/nrf52840/nrf52840.ini | 1 + variants/nrf52840/rak4631/platformio.ini | 11 - 25 files changed, 1225 insertions(+), 319 deletions(-) delete mode 100644 src/platform/nrf52/nrf52840_s140_v6_blemesh.ld diff --git a/src/graphics/draw/MenuHandler.cpp b/src/graphics/draw/MenuHandler.cpp index 8627dd2f89..a0e257c58d 100644 --- a/src/graphics/draw/MenuHandler.cpp +++ b/src/graphics/draw/MenuHandler.cpp @@ -9,6 +9,7 @@ #include "MeshService.h" #include "MessageStore.h" #include "NodeDB.h" +#include "UptimeClock.h" #include "buzz.h" #include "graphics/Backlight.h" #include "graphics/Screen.h" @@ -1303,7 +1304,19 @@ void menuHandler::textMessageBaseMenu() void menuHandler::systemBaseMenu() { - enum optionsNumbers { Back, Notifications, ScreenOptions, Bluetooth, WiFiToggle, PowerMenu, Test, enumEnd }; + enum optionsNumbers { + Back, + Notifications, + ScreenOptions, + Bluetooth, +#if HAS_BLE_MESH + NodePairing, +#endif + WiFiToggle, + PowerMenu, + Test, + enumEnd + }; static const char *optionsArray[enumEnd] = {"Back"}; static int optionsEnumArray[enumEnd] = {Back}; int options = 1; @@ -1320,6 +1333,10 @@ void menuHandler::systemBaseMenu() optionsArray[options] = "Bluetooth Toggle"; } optionsEnumArray[options++] = Bluetooth; +#if HAS_BLE_MESH + optionsArray[options] = "Node Pairing"; + optionsEnumArray[options++] = NodePairing; +#endif #if HAS_WIFI && !defined(ARCH_PORTDUINO) optionsArray[options] = "WiFi Toggle"; optionsEnumArray[options++] = WiFiToggle; @@ -1361,6 +1378,11 @@ void menuHandler::systemBaseMenu() } else if (selected == Bluetooth) { menuQueue = BluetoothToggleMenu; screen->runNow(); +#if HAS_BLE_MESH + } else if (selected == NodePairing) { + menuQueue = NodePairingMenu; + screen->runNow(); +#endif #if HAS_WIFI && !defined(ARCH_PORTDUINO) } else if (selected == WiFiToggle) { menuQueue = WifiToggleMenu; @@ -2288,6 +2310,125 @@ void menuHandler::bluetoothToggleMenu() screen->showOverlayBanner(bannerOptions); } +#if HAS_BLE_MESH +bool menuHandler::setNodePairingEnabled(bool enabled) +{ + bool needsReboot = false; + config.has_network = true; + + if (enabled) { + config.network.enabled_protocols |= meshtastic_Config_NetworkConfig_ProtocolFlags_BLE_BROADCAST; + if (!config.bluetooth.enabled) { + config.bluetooth.enabled = true; + needsReboot = true; + } +#if HAS_WIFI && defined(ARCH_ESP32) + if (config.network.wifi_enabled) { + config.network.wifi_enabled = false; + needsReboot = true; + } +#endif + if (bleMeshHandler) + bleMeshHandler->start(); + } else { + config.network.enabled_protocols &= ~meshtastic_Config_NetworkConfig_ProtocolFlags_BLE_BROADCAST; + if (bleMeshHandler) + bleMeshHandler->stop(); + } + + return needsReboot; +} + +void menuHandler::nodePairingMenu() +{ + enum optionsNumbers { Back, PairNew, Toggle, Forget, enumEnd }; + static const char *optionsArray[enumEnd] = {"Back", "Pair New", nullptr, "Forget Node"}; + static int optionsEnumArray[enumEnd] = {Back, PairNew, Toggle, Forget}; + const bool enabled = config.network.enabled_protocols & meshtastic_Config_NetworkConfig_ProtocolFlags_BLE_BROADCAST; + optionsArray[Toggle] = enabled ? "Bridge Off" : "Bridge On"; + const int optionCount = bleMeshHandler && bleMeshHandler->pairedCount() ? enumEnd : enumEnd - 1; + + BannerOverlayOptions bannerOptions; + bannerOptions.message = currentResolution == ScreenResolution::UltraLow ? "Node Pair" : "Node Pairing"; + bannerOptions.optionsArrayPtr = optionsArray; + bannerOptions.optionsEnumPtr = optionsEnumArray; + bannerOptions.optionsCount = optionCount; + bannerOptions.bannerCallback = [](int selected) -> void { + if (selected == Back) + return; + + if (selected == PairNew) { + if (bleMeshHandler && bleMeshHandler->pairedCount() >= BLE_MESH_MAX_PAIRED_NODES) { + screen->showSimpleBanner("Pair list full", 3000); + return; + } + const bool enabledNow = + config.network.enabled_protocols & meshtastic_Config_NetworkConfig_ProtocolFlags_BLE_BROADCAST; + if (!enabledNow) { + const bool needsReboot = setNodePairingEnabled(true); + nodeDB->saveToDisk(SEGMENT_CONFIG); + if (needsReboot) { + screen->showSimpleBanner("Bridge enabled\nPair after reboot", 3000); + rebootAtMsec = Time::getMillis() + DEFAULT_REBOOT_SECONDS * 1000; + return; + } + } + if (!bleMeshHandler || + !bleMeshHandler->beginPairing([](NodeNum nodeNum, uint32_t code) { showNodePairingCandidate(nodeNum, code); })) { + screen->showSimpleBanner("Pairing unavailable", 3000); + return; + } + screen->showSimpleBanner("Pairing for 60s\nWaiting for node", BLE_MESH_PAIRING_TIMEOUT_MS); + return; + } + + if (selected == Toggle) { + const bool current = config.network.enabled_protocols & meshtastic_Config_NetworkConfig_ProtocolFlags_BLE_BROADCAST; + const bool needsReboot = setNodePairingEnabled(!current); + nodeDB->saveToDisk(SEGMENT_CONFIG); + if (needsReboot) { + screen->showSimpleBanner("Bridge ON\nRebooting", 3000); + rebootAtMsec = Time::getMillis() + DEFAULT_REBOOT_SECONDS * 1000; + } else { + screen->showSimpleBanner(current ? "Bridge OFF" : "Bridge ON", 3000); + } + return; + } + + if (selected == Forget && bleMeshHandler) { + screen->showNodePicker("Forget Bridge Node", 30000, [](NodeNum nodeNum) { + screen->showSimpleBanner(bleMeshHandler->forgetPeer(nodeNum) ? "Bridge node forgotten" : "Node not paired", 3000); + }); + } + }; + screen->showOverlayBanner(bannerOptions); +} + +void menuHandler::showNodePairingCandidate(uint32_t nodeNum, uint32_t verificationCode) +{ + char message[64]; + snprintf(message, sizeof(message), "Pair !%08x?\nCode %03u %03u", nodeNum, verificationCode / 1000, verificationCode % 1000); + static const char *optionsArray[] = {"Reject", "Accept"}; + BannerOverlayOptions options; + options.message = message; + options.durationMs = 30000; + options.optionsArrayPtr = optionsArray; + options.optionsCount = 2; + options.notificationType = notificationTypeEnum::selection_picker; + options.bannerCallback = [](int selected) { + if (!bleMeshHandler) + return; + if (selected == 1) { + screen->showSimpleBanner(bleMeshHandler->approvePairingCandidate() ? "Node approved" : "Pairing failed", 3000); + } else { + bleMeshHandler->cancelPairing(); + screen->showSimpleBanner("Pairing rejected", 3000); + } + }; + screen->showOverlayBanner(options); +} +#endif + void menuHandler::BuzzerModeMenu() { static const char *optionsArray[] = {"All Enabled", "All Disabled", "Notifications", "System Only", "DMs Only"}; @@ -3266,6 +3407,11 @@ void menuHandler::handleMenuSwitch(OLEDDisplay *display) case BluetoothToggleMenu: bluetoothToggleMenu(); break; +#if HAS_BLE_MESH + case NodePairingMenu: + nodePairingMenu(); + break; +#endif case ScreenOptionsMenu: screenOptionsMenu(); break; diff --git a/src/graphics/draw/MenuHandler.h b/src/graphics/draw/MenuHandler.h index a2cc14e677..88ed98e853 100644 --- a/src/graphics/draw/MenuHandler.h +++ b/src/graphics/draw/MenuHandler.h @@ -1,6 +1,10 @@ #pragma once #if HAS_SCREEN #include "configuration.h" +#include + +class OLEDDisplay; + namespace graphics { @@ -47,6 +51,9 @@ class menuHandler EnvironmentTelemetrySourceMenu, WifiToggleMenu, BluetoothToggleMenu, +#if HAS_BLE_MESH + NodePairingMenu, +#endif ScreenOptionsMenu, PowerMenu, SystemBaseMenu, @@ -145,12 +152,20 @@ class menuHandler // the selection is written. static meshtastic_Config_LoRaConfig_ModemPreset presetForRegionSelection(const meshtastic_Config_LoRaConfig &lora, meshtastic_Config_LoRaConfig_RegionCode selected); +#if HAS_BLE_MESH + // Apply the BLE mesh setting and report whether Bluetooth/WiFi changes require a reboot. + static bool setNodePairingEnabled(bool enabled); +#endif private: static void saveUIConfig(); static void keyVerificationInitMenu(); static void keyVerificationFinalPrompt(); static void bluetoothToggleMenu(); +#if HAS_BLE_MESH + static void nodePairingMenu(); + static void showNodePairingCandidate(uint32_t nodeNum, uint32_t verificationCode); +#endif }; /* Generic Menu Options designations */ diff --git a/src/mesh/BLEMeshHandler.cpp b/src/mesh/BLEMeshHandler.cpp index 4945f7a913..4885a51cd7 100644 --- a/src/mesh/BLEMeshHandler.cpp +++ b/src/mesh/BLEMeshHandler.cpp @@ -3,7 +3,19 @@ #if HAS_BLE_MESH #include "BLEMeshHandler.h" +#include "FSCommon.h" +#include "HardwareRNG.h" +#include "SafeFile.h" +#include "Throttle.h" +#include "UptimeClock.h" +#include "concurrency/LockGuard.h" #include "main.h" +#include "meshUtils.h" + +#include +#include +#include +#include BLEMeshHandler *bleMeshHandler = nullptr; @@ -13,49 +25,380 @@ BLEMeshHandler *bleMeshHandler = nullptr; #define BLE_MESH_AD_TYPE_MFG_DATA 0xFF #define BLE_MESH_AD_FLAGS_LE_GENERAL_DISC_BREDR_UNSUP 0x06 -uint8_t BLEMeshHandler::buildAdvPayload(const meshtastic_MeshPacket *mp, uint8_t *out, size_t outCap) +namespace +{ +constexpr uint32_t PAIR_STORE_MAGIC = 0x42504d32; +constexpr uint8_t PAIR_STORE_VERSION = 2; +constexpr const char *PAIR_STORE_FILE = "/prefs/ble-pairs.dat"; +constexpr uint8_t BRIDGE_KEY_CONTEXT[] = "Meshtastic BLE bridge v2"; +constexpr NodeNum BRIDGE_NONCE_DOMAIN = 0x424c4500; +constexpr size_t PAIRING_HELLO_LEN = 1 + sizeof(NodeNum) + sizeof(uint64_t) + 32; + +#pragma pack(push, 1) +struct PersistedPairStore { + uint32_t magic; + uint8_t version; + uint8_t count; + uint8_t reserved[2]; + NodeNum localNode; + NodeNum nodes[BLE_MESH_MAX_PAIRED_NODES]; + uint32_t crc; +}; +#pragma pack(pop) + +void writeU32(uint8_t *out, uint32_t value) +{ + memcpy(out, &value, sizeof(value)); +} + +uint32_t readU32(const uint8_t *in) +{ + uint32_t value; + memcpy(&value, in, sizeof(value)); + return value; +} + +void writeU64(uint8_t *out, uint64_t value) +{ + memcpy(out, &value, sizeof(value)); +} + +uint64_t readU64(const uint8_t *in) +{ + uint64_t value; + memcpy(&value, in, sizeof(value)); + return value; +} + +void writeAdvertisementPrefix(uint8_t *out, size_t totalLen) +{ + // Flags AD structure. + out[0] = 2; + out[1] = BLE_MESH_AD_TYPE_FLAGS; + out[2] = BLE_MESH_AD_FLAGS_LE_GENERAL_DISC_BREDR_UNSUP; + // Manufacturer-specific data AD structure: length covers everything after the length byte. + out[3] = static_cast(totalLen - 4); + out[4] = BLE_MESH_AD_TYPE_MFG_DATA; + out[5] = static_cast(BLE_MESH_COMPANY_ID & 0xff); + out[6] = static_cast(BLE_MESH_COMPANY_ID >> 8); + out[7] = BLE_MESH_PROTOCOL_VERSION; +} + +} // namespace + +bool BLEMeshHandler::validateIdentity(NodeNum nodeNum, const uint8_t publicKey[32]) +{ + return nodeNum != 0 && publicKey && crc32Buffer(publicKey, 32) == nodeNum; +} + +void BLEMeshHandler::ensurePairsLoaded() +{ + if (!pairsLoaded) + loadPairs(); +} + +void BLEMeshHandler::loadPairs() +{ + pairsLoaded = true; + pairCount = 0; + +#ifdef FSCom + concurrency::LockGuard fsGuard(spiLock); + auto file = FSCom.open(PAIR_STORE_FILE, FILE_O_READ); + if (!file) + return; + + PersistedPairStore stored{}; + const bool readOk = file.read(reinterpret_cast(&stored), sizeof(stored)) == sizeof(stored); + file.close(); + const uint32_t expectedCrc = crc32Buffer(&stored, offsetof(PersistedPairStore, crc)); + if (!readOk || stored.magic != PAIR_STORE_MAGIC || stored.version != PAIR_STORE_VERSION || + stored.count > BLE_MESH_MAX_PAIRED_NODES || stored.crc != expectedCrc || !nodeDB || + stored.localNode != nodeDB->getNodeNum()) { + LOG_WARN("BLE pairing: ignoring invalid or stale peer store"); + return; + } + + for (uint8_t i = 0; i < stored.count; i++) { + if (stored.nodes[i] == 0 || stored.nodes[i] == nodeDB->getNodeNum() || findPair(stored.nodes[i]) >= 0) + continue; + pairs[pairCount++] = stored.nodes[i]; + } + LOG_INFO("BLE pairing: restored %u approved peer(s)", pairCount); +#endif +} + +bool BLEMeshHandler::savePairs() +{ +#ifdef FSCom + PersistedPairStore stored{}; + stored.magic = PAIR_STORE_MAGIC; + stored.version = PAIR_STORE_VERSION; + if (!nodeDB) + return false; + stored.localNode = nodeDB->getNodeNum(); + { + concurrency::LockGuard guard(&pairLock); + stored.count = pairCount; + for (uint8_t i = 0; i < pairCount; i++) + stored.nodes[i] = pairs[i]; + } + stored.crc = crc32Buffer(&stored, offsetof(PersistedPairStore, crc)); + + { + concurrency::LockGuard fsGuard(spiLock); + FSCom.mkdir("/prefs"); + } + SafeFile file(PAIR_STORE_FILE, true); + const size_t written = file.write(reinterpret_cast(&stored), sizeof(stored)); + return written == sizeof(stored) && file.close(); +#else + return true; +#endif +} + +int BLEMeshHandler::findPair(NodeNum nodeNum) +{ + for (uint8_t i = 0; i < pairCount; i++) { + if (pairs[i] == nodeNum) + return i; + } + return -1; +} + +bool BLEMeshHandler::addPair(NodeNum nodeNum, const uint8_t publicKey[32]) +{ + ensurePairsLoaded(); + if (!validateIdentity(nodeNum, publicKey)) + return false; + + std::array previousPairs; + uint8_t previousCount; + { + concurrency::LockGuard guard(&pairLock); + previousPairs = pairs; + previousCount = pairCount; + int index = findPair(nodeNum); + if (index < 0) { + if (pairCount >= BLE_MESH_MAX_PAIRED_NODES) + return false; + index = pairCount++; + } + pairs[index] = nodeNum; + } + if (savePairs()) { + nodeDB->commitRemoteKey(nodeNum, publicKey, NodeDB::KeyCommitTrust::ManuallyVerified); + auto *node = nodeDB->getMeshNode(nodeNum); + if (node) + nodeInfoLiteSetBit(node, NODEINFO_BITFIELD_IS_KEY_MANUALLY_VERIFIED_MASK, true); + nodeDB->saveToDisk(SEGMENT_NODEDATABASE); + return true; + } + concurrency::LockGuard guard(&pairLock); + pairs = previousPairs; + pairCount = previousCount; + return false; +} + +#ifdef PIO_UNIT_TESTING +bool BLEMeshHandler::addPairForTest(NodeNum nodeNum, const uint8_t publicKey[32]) +{ + return addPair(nodeNum, publicKey); +} +#endif + +bool BLEMeshHandler::forgetPeer(NodeNum nodeNum) +{ + ensurePairsLoaded(); + std::array previousPairs; + uint8_t previousCount; + { + concurrency::LockGuard guard(&pairLock); + previousPairs = pairs; + previousCount = pairCount; + int index = findPair(nodeNum); + if (index < 0) + return false; + for (uint8_t i = static_cast(index); i + 1 < pairCount; i++) + pairs[i] = pairs[i + 1]; + pairs[--pairCount] = 0; + } + if (savePairs()) + return true; + concurrency::LockGuard guard(&pairLock); + pairs = previousPairs; + pairCount = previousCount; + return false; +} + +bool BLEMeshHandler::isPaired(NodeNum nodeNum) +{ + ensurePairsLoaded(); + concurrency::LockGuard guard(&pairLock); + return findPair(nodeNum) >= 0; +} + +uint8_t BLEMeshHandler::pairedCount() +{ + ensurePairsLoaded(); + concurrency::LockGuard guard(&pairLock); + return pairCount; +} + +bool BLEMeshHandler::deriveBridgeKey(const uint8_t peerKey[32], uint8_t out[32]) +{ + meshtastic_NodeInfoLite_public_key_t remote = {32, {0}}; + memcpy(remote.bytes, peerKey, 32); + concurrency::LockGuard guard(cryptLock); + return crypto && crypto->deriveSharedKey(remote, BRIDGE_KEY_CONTEXT, sizeof(BRIDGE_KEY_CONTEXT) - 1, out); +} + +bool BLEMeshHandler::beginPairing(PairingCandidateCallback callback) +{ + if (!isRunning || !nodeDB || config.security.public_key.size != 32 || config.security.private_key.size != 32 || + !validateIdentity(nodeDB->getNodeNum(), config.security.public_key.bytes)) + return false; + + uint64_t freshNonce = 0; + { + concurrency::LockGuard guard(cryptLock); + if (!HardwareRNG::fill(reinterpret_cast(&freshNonce), sizeof(freshNonce))) + CryptRNG.rand(reinterpret_cast(&freshNonce), sizeof(freshNonce)); + } + if (freshNonce == 0) + return false; + + { + concurrency::LockGuard guard(&pairLock); + pairingActive = true; + pairingStartedMs = Time::getMillis(); + lastPairingAdvertisementMs = 0; + pairingNonce = freshNonce; + pairingCandidate = PairingCandidate{}; + pairingCallback = callback; + } + setIntervalFromNow(0); + concurrency::mainDelay.interrupt(); + LOG_INFO("BLE pairing: discovery window opened"); + return true; +} + +void BLEMeshHandler::cancelPairing() +{ + concurrency::LockGuard guard(&pairLock); + pairingActive = false; + pairingStartedMs = 0; + lastPairingAdvertisementMs = 0; + pairingNonce = 0; + pairingCandidate = PairingCandidate{}; + pairingCallback = nullptr; +} + +uint32_t BLEMeshHandler::pairingVerificationCode(const PairingCandidate &candidate) +{ + uint8_t bridgeKey[32]; + if (!deriveBridgeKey(candidate.publicKey, bridgeKey)) + return UINT32_MAX; + + const NodeNum localNode = nodeDB->getNodeNum(); + SHA256 digest; + digest.reset(); + digest.update(bridgeKey, sizeof(bridgeKey)); + if (localNode < candidate.nodeNum) { + digest.update(&localNode, sizeof(localNode)); + digest.update(&pairingNonce, sizeof(pairingNonce)); + digest.update(config.security.public_key.bytes, 32); + digest.update(&candidate.nodeNum, sizeof(candidate.nodeNum)); + digest.update(&candidate.nonce, sizeof(candidate.nonce)); + digest.update(candidate.publicKey, 32); + } else { + digest.update(&candidate.nodeNum, sizeof(candidate.nodeNum)); + digest.update(&candidate.nonce, sizeof(candidate.nonce)); + digest.update(candidate.publicKey, 32); + digest.update(&localNode, sizeof(localNode)); + digest.update(&pairingNonce, sizeof(pairingNonce)); + digest.update(config.security.public_key.bytes, 32); + } + uint8_t result[32]; + digest.finalize(result, sizeof(result)); + memset(bridgeKey, 0, sizeof(bridgeKey)); + uint32_t code = readU32(result) % 1000000; + memset(result, 0, sizeof(result)); + return code; +} + +bool BLEMeshHandler::approvePairingCandidate() +{ + PairingCandidate candidate; + { + concurrency::LockGuard guard(&pairLock); + if (!pairingActive || !pairingCandidate.valid) + return false; + candidate = pairingCandidate; + } + + if (!addPair(candidate.nodeNum, candidate.publicKey)) + return false; + + cancelPairing(); + LOG_INFO("BLE pairing: approved node 0x%08x", candidate.nodeNum); + return true; +} + +uint8_t BLEMeshHandler::buildPairingAdvertisement(uint8_t *out, size_t outCap) +{ + const size_t total = BLE_MESH_ADV_OVERHEAD + PAIRING_HELLO_LEN; + if (!out || outCap < total || !pairingActive || config.security.public_key.size != 32) + return 0; + + writeAdvertisementPrefix(out, total); + uint8_t *frame = out + BLE_MESH_ADV_OVERHEAD; + frame[0] = BLE_MESH_FRAME_PAIRING_HELLO; + writeU32(frame + 1, nodeDB->getNodeNum()); + writeU64(frame + 5, pairingNonce); + memcpy(frame + 13, config.security.public_key.bytes, 32); + return static_cast(total); +} + +uint8_t BLEMeshHandler::buildAdvPayload(const meshtastic_MeshPacket *mp, NodeNum peer, uint8_t *out, size_t outCap) { // Router::send() encrypts before it reaches any transport, so an unencrypted packet here is a // bug upstream, not something to quietly put on the air. - if (mp->which_payload_variant != meshtastic_MeshPacket_encrypted_tag) { - LOG_WARN("BLE mesh: refusing to broadcast an unencrypted packet 0x%08x", mp->id); + if (!mp || !out || mp->which_payload_variant != meshtastic_MeshPacket_encrypted_tag || mp->from == 0) return 0; - } - if (mp->from == 0) { - LOG_WARN("BLE mesh: refusing to broadcast a packet with no sender"); + + meshtastic_NodeInfoLite_public_key_t remote = {0, {0}}; + if (!nodeDB || !nodeDB->copyPublicKeyForDecrypt(peer, remote)) return 0; - } uint8_t proto[BLE_MESH_MAX_PROTO_LEN]; - size_t protoLen = pb_encode_to_bytes(proto, sizeof(proto), &meshtastic_MeshPacket_msg, mp); + const size_t protoLen = pb_encode_to_bytes(proto, sizeof(proto), &meshtastic_MeshPacket_msg, mp); if (protoLen == 0) { // pb_encode_to_bytes returns 0 both for a genuine encode failure and for a packet that does // not fit the buffer. Either way it cannot ride BLE; it still goes out over LoRa. - LOG_WARN("BLE mesh: drop 0x%08x, does not fit %u-byte advertisement budget", mp->id, (unsigned)BLE_MESH_MAX_PROTO_LEN); + LOG_WARN("BLE mesh: drop 0x%08x, does not fit authenticated advertisement", mp->id); return 0; } - const size_t total = BLE_MESH_ADV_OVERHEAD + protoLen; + const size_t total = BLE_MESH_ADV_OVERHEAD + BLE_MESH_DATA_HEADER_LEN + protoLen + MESHTASTIC_PKC_OVERHEAD; if (total > outCap || total > BLE_MESH_ADV_TOTAL_MAX) return 0; - uint8_t *p = out; - // Flags AD structure. - *p++ = 2; - *p++ = BLE_MESH_AD_TYPE_FLAGS; - *p++ = BLE_MESH_AD_FLAGS_LE_GENERAL_DISC_BREDR_UNSUP; + writeAdvertisementPrefix(out, total); + uint8_t *header = out + BLE_MESH_ADV_OVERHEAD; + header[0] = BLE_MESH_FRAME_DATA; + writeU32(header + 1, nodeDB->getNodeNum()); + writeU32(header + 5, peer); + writeU32(header + 9, mp->id); - // Manufacturer-specific data AD structure: length covers everything after the length byte. - *p++ = (uint8_t)(1 /* type */ + 2 /* company */ + 1 /* version */ + protoLen); - *p++ = BLE_MESH_AD_TYPE_MFG_DATA; - *p++ = (uint8_t)(BLE_MESH_COMPANY_ID & 0xFF); - *p++ = (uint8_t)((BLE_MESH_COMPANY_ID >> 8) & 0xFF); - *p++ = BLE_MESH_PROTOCOL_VERSION; - - memcpy(p, proto, protoLen); - p += protoLen; - - return (uint8_t)(p - out); + uint8_t *ciphertext = header + BLE_MESH_DATA_HEADER_LEN; + { + concurrency::LockGuard guard(cryptLock); + if (!crypto || !crypto->encryptCurve25519(peer, nodeDB->getNodeNum() ^ BRIDGE_NONCE_DOMAIN, remote, mp->id, protoLen, + proto, ciphertext)) + return 0; + } + return static_cast(total); } bool BLEMeshHandler::onSend(const meshtastic_MeshPacket *mp) @@ -63,31 +406,39 @@ bool BLEMeshHandler::onSend(const meshtastic_MeshPacket *mp) if (!isRunning || !mp) return false; - // Deliberately NOT the guard UdpMulticastHandler carries. A packet that arrived over BLE and - // comes back through Router::send is a rebroadcast: NextHopRouter::perhapsRebroadcast allocCopy()s - // the received packet, and nothing on the TX path rewrites transport_mechanism (RadioInterface - // stamps TRANSPORT_LORA in deliverToReceiver, which is RX-only). Refusing it caps the BLE mesh at - // a single hop. Loop protection is the same as LoRa's: PacketHistory drops a packet seen - // recently, hop_limit decrements per relay, and deliverToRouter ignores frames sent by us. - if (mp->transport_mechanism == meshtastic_MeshPacket_TransportMechanism_TRANSPORT_BLE_ADV) - LOG_DEBUG("BLE mesh: re-advertising relayed packet 0x%08x", mp->id); - - AdvSlot slot; - slot.len = buildAdvPayload(mp, slot.data.data(), slot.data.size()); - if (slot.len == 0) - return false; - - if (txCount >= BLE_MESH_TX_QUEUE_SIZE) { - LOG_WARN("BLE mesh: TX queue full, dropping 0x%08x", mp->id); - return false; + ensurePairsLoaded(); + std::array approved{}; + uint8_t approvedCount; + { + concurrency::LockGuard guard(&pairLock); + approvedCount = pairCount; + for (uint8_t i = 0; i < pairCount; i++) + approved[i] = pairs[i]; } - txQueue[txTail] = slot; - txTail = (txTail + 1) % BLE_MESH_TX_QUEUE_SIZE; - txCount++; - setIntervalFromNow(0); - concurrency::mainDelay.interrupt(); - return true; + bool queued = false; + for (uint8_t i = 0; i < approvedCount; i++) { + // BLE-sourced packets are valid rebroadcasts. PacketHistory and hop_limit provide the same + // loop protection used by the LoRa path. + if (txCount >= BLE_MESH_TX_QUEUE_SIZE) { + LOG_WARN("BLE mesh: TX queue full, dropping peer copy of 0x%08x", mp->id); + break; + } + AdvSlot slot; + slot.len = buildAdvPayload(mp, approved[i], slot.data.data(), slot.data.size()); + if (slot.len == 0) + continue; + txQueue[txTail] = slot; + txTail = (txTail + 1) % BLE_MESH_TX_QUEUE_SIZE; + txCount++; + queued = true; + } + + if (queued) { + setIntervalFromNow(0); + concurrency::mainDelay.interrupt(); + } + return queued; } int32_t BLEMeshHandler::runOnce() @@ -107,75 +458,156 @@ int32_t BLEMeshHandler::runOnce() advertising = false; } - if (txCount == 0) - return 100; - AdvSlot slot = txQueue[txHead]; - txHead = (txHead + 1) % BLE_MESH_TX_QUEUE_SIZE; - txCount--; + PairingCandidate candidate; + PairingCandidateCallback callback; + bool pairingTimedOut = false; + { + concurrency::LockGuard guard(&pairLock); + if (pairingActive && Throttle::hasElapsed(pairingStartedMs, BLE_MESH_PAIRING_TIMEOUT_MS)) { + pairingTimedOut = true; + } else if (pairingActive && pairingCandidate.valid && !pairingCandidate.notified) { + pairingCandidate.notified = true; + candidate = pairingCandidate; + callback = pairingCallback; + } + } + if (pairingTimedOut) { + LOG_INFO("BLE pairing: discovery window expired"); + cancelPairing(); + } else if (callback) { + const uint32_t code = pairingVerificationCode(candidate); + if (code != UINT32_MAX) + callback(candidate.nodeNum, code); + } - if (platformBeginAdvertising(slot.data.data(), slot.len)) - advertising = true; + if (txCount != 0) { + AdvSlot slot = txQueue[txHead]; + txHead = (txHead + 1) % BLE_MESH_TX_QUEUE_SIZE; + txCount--; + if (platformBeginAdvertising(slot.data.data(), slot.len)) + advertising = true; + return 10; + } - return 10; + bool pairingDue = false; + { + concurrency::LockGuard guard(&pairLock); + pairingDue = pairingActive && (lastPairingAdvertisementMs == 0 || + Throttle::hasElapsed(lastPairingAdvertisementMs, BLE_MESH_PAIRING_ADV_INTERVAL_MS)); + } + if (pairingDue) { + uint8_t adv[BLE_MESH_ADV_TOTAL_MAX]; + const uint8_t len = buildPairingAdvertisement(adv, sizeof(adv)); + if (len && platformBeginAdvertising(adv, len)) { + concurrency::LockGuard guard(&pairLock); + lastPairingAdvertisementMs = Time::getMillis(); + advertising = true; + } + return 10; + } + + return 100; } -void BLEMeshHandler::deliverToRouter(const uint8_t *data, size_t len, int8_t rssi) +void BLEMeshHandler::handlePairingHello(const uint8_t *data, size_t len, int8_t rssi) { - if (!isRunning || !nodeDB || !data) + if (len != PAIRING_HELLO_LEN) + return; + + const NodeNum sender = readU32(data + 1); + const uint64_t nonce = readU64(data + 5); + const uint8_t *publicKey = data + 13; + if (sender == nodeDB->getNodeNum() || nonce == 0 || !validateIdentity(sender, publicKey)) + return; + + concurrency::LockGuard guard(&pairLock); + if (!pairingActive) + return; + if (pairingCandidate.valid && pairingCandidate.notified && pairingCandidate.nodeNum != sender) + return; + if (!pairingCandidate.valid || pairingCandidate.nodeNum == sender || rssi > pairingCandidate.rssi) { + const bool sameCandidate = pairingCandidate.valid && pairingCandidate.nodeNum == sender && + pairingCandidate.nonce == nonce && memcmp(pairingCandidate.publicKey, publicKey, 32) == 0; + pairingCandidate.valid = true; + pairingCandidate.notified = sameCandidate ? pairingCandidate.notified : false; + pairingCandidate.nodeNum = sender; + pairingCandidate.nonce = nonce; + pairingCandidate.rssi = rssi; + memcpy(pairingCandidate.publicKey, publicKey, 32); + concurrency::mainDelay.interrupt(); + } +} + +void BLEMeshHandler::handleAuthenticatedData(const uint8_t *data, size_t len, int8_t rssi) +{ + if (len <= BLE_MESH_DATA_HEADER_LEN + MESHTASTIC_PKC_OVERHEAD) + return; + + const NodeNum sender = readU32(data + 1); + const NodeNum recipient = readU32(data + 5); + const uint32_t frameId = readU32(data + 9); + // Nothing legitimate has no bridge sender, and our own advertisement echoing back would loop. + if (sender == 0 || sender == nodeDB->getNodeNum() || recipient != nodeDB->getNodeNum()) + return; + + meshtastic_NodeInfoLite_public_key_t remote = {0, {0}}; + { + concurrency::LockGuard guard(&pairLock); + if (findPair(sender) < 0 || !nodeDB->copyPublicKeyForDecrypt(sender, remote)) + return; + } + + const size_t encryptedLen = len - BLE_MESH_DATA_HEADER_LEN; + const size_t plaintextLen = encryptedLen - MESHTASTIC_PKC_OVERHEAD; + if (plaintextLen > BLE_MESH_MAX_PROTO_LEN) + return; + const uint8_t *ciphertext = data + BLE_MESH_DATA_HEADER_LEN; + uint8_t plaintext[BLE_MESH_MAX_PROTO_LEN]; + bool authenticated; + { + concurrency::LockGuard guard(cryptLock); + authenticated = crypto && crypto->decryptCurve25519(sender ^ BRIDGE_NONCE_DOMAIN, remote, frameId, encryptedLen, + ciphertext, plaintext); + } + if (!authenticated) return; meshtastic_MeshPacket mp = meshtastic_MeshPacket_init_zero; - if (!pb_decode_from_bytes(data, len, &meshtastic_MeshPacket_msg, &mp)) + if (!pb_decode_from_bytes(plaintext, plaintextLen, &meshtastic_MeshPacket_msg, &mp)) return; - if (mp.which_payload_variant != meshtastic_MeshPacket_encrypted_tag) + // An out-of-range hop count is not relayable; UdpMulticastHandler drops it identically. + if (mp.which_payload_variant != meshtastic_MeshPacket_encrypted_tag || mp.from == 0 || mp.from == nodeDB->getNodeNum() || + mp.hop_limit > HOP_MAX || mp.hop_start > HOP_MAX) return; - // Guard 1 (mirrors UdpMulticastHandler): spoofed local origin. Nothing legitimate advertises - // from=0, and our own advertisement echoing back into our own scanner would loop. - if (mp.from == 0) { - LOG_WARN("BLE mesh: advertisement with no sender, dropping"); - return; - } - if (mp.from == nodeDB->getNodeNum()) - return; // our own advertisement, heard by our own scanner - - // Guard 2 (mirrors UdpMulticastHandler): an out-of-range hop count is not relayable. - if (mp.hop_limit > HOP_MAX || mp.hop_start > HOP_MAX) { - LOG_WARN("BLE mesh: invalid hop_limit(%u)/hop_start(%u), dropping", mp.hop_limit, mp.hop_start); - return; - } - + // Wire-carried flags and authentication metadata are local-only; rebuild local BLE metadata. mp.transport_mechanism = meshtastic_MeshPacket_TransportMechanism_TRANSPORT_BLE_ADV; - // Wire-carried flags that only the local stack may set: a sender must not suppress our MQTT uplink - // or schedule our transmit. mp.via_mqtt = false; mp.tx_after = 0; - // priority is local-only too, and unlike want_ack/next_hop/relay_node it is NOT a field the LoRa - // header carries - so fixPriority() always derives it locally for a LoRa arrival, and this bearer - // is the first that lets a sender choose it. Left as sent, a crafted frame with priority MAX - // outranks ACK (the ceiling fixPriority assigns) and, once perhapsRebroadcast copies it into the - // TX queue, replaceLowerPriorityPacket evicts one of ours to make room for it. mp.priority = meshtastic_MeshPacket_Priority_UNSET; - - // Guard 3 (mirrors UdpMulticastHandler): authentication metadata is local-only. The Router - // re-establishes it after a successful PKI decrypt; carrying it in from the wire would let a - // sender assert its own packet was PKI-authenticated. mp.pki_encrypted = false; mp.public_key.size = 0; memset(mp.public_key.bytes, 0, sizeof(mp.public_key.bytes)); - - // Guard 4: no LoRa measurement exists for a BLE arrival. Unlike the UDP case there IS a real - // measurement of this hop, so rx_rssi is populated and has_rx_rssi set rather than cleared. mp.rx_snr = 0; mp.rx_rssi = rssi; mp.has_rx_rssi = true; - UniquePacketPoolPacket p = packetPool.allocUniqueCopy(mp); - if (!p) + UniquePacketPoolPacket packet = packetPool.allocUniqueCopy(mp); + if (!packet) return; + LOG_DEBUG("BLE mesh authenticated RX bridge=0x%08x from=0x%08x id=0x%08x", sender, mp.from, mp.id); + enqueueReceived(packet.release()); +} - LOG_DEBUG("BLE mesh RX from=0x%08x to=0x%08x id=0x%08x rssi=%d len=%u", mp.from, mp.to, mp.id, rssi, (unsigned)len); - enqueueReceived(p.release()); +void BLEMeshHandler::deliverToRouter(const uint8_t *data, size_t len, int8_t rssi) +{ + if (!isRunning || !nodeDB || !data || len == 0) + return; + ensurePairsLoaded(); + if (data[0] == BLE_MESH_FRAME_PAIRING_HELLO) + handlePairingHello(data, len, rssi); + else if (data[0] == BLE_MESH_FRAME_DATA) + handleAuthenticatedData(data, len, rssi); } void BLEMeshHandler::enqueueReceived(meshtastic_MeshPacket *p) diff --git a/src/mesh/BLEMeshHandler.h b/src/mesh/BLEMeshHandler.h index 26b1c11c74..965448c5b7 100644 --- a/src/mesh/BLEMeshHandler.h +++ b/src/mesh/BLEMeshHandler.h @@ -7,17 +7,19 @@ #include "NodeDB.h" #include "RadioInterface.h" #include "Router.h" +#include "concurrency/Lock.h" #include "concurrency/OSThread.h" #include "mesh-pb-constants.h" #include +#include // Meshtastic BLE mesh manufacturer data identifier. // 0xFFFF is the SIG-reserved "internal/test" company ID. A shipping build needs either a member // company ID or - better, because iOS can only scan in the background when filtering by service // UUID - an assigned 16-bit service UUID with the payload as service data. #define BLE_MESH_COMPANY_ID 0xFFFF -#define BLE_MESH_PROTOCOL_VERSION 1 +#define BLE_MESH_PROTOCOL_VERSION 2 // A single unfragmented extended advertising payload is capped at 251 bytes, not the 254 an // AUX_ADV_IND could hold: the HCI LE Set Extended Advertising Data command spends four of its 255 @@ -28,7 +30,24 @@ // Flags AD structure (3) + manufacturer-data AD header (2) + company ID (2) + version (1). #define BLE_MESH_ADV_OVERHEAD 8 -#define BLE_MESH_MAX_PROTO_LEN (BLE_MESH_ADV_TOTAL_MAX - BLE_MESH_ADV_OVERHEAD) + +#define BLE_MESH_FRAME_DATA 1 +#define BLE_MESH_FRAME_PAIRING_HELLO 2 +#define BLE_MESH_DATA_HEADER_LEN 13 +#define BLE_MESH_MAX_PROTO_LEN \ + (BLE_MESH_ADV_TOTAL_MAX - BLE_MESH_ADV_OVERHEAD - BLE_MESH_DATA_HEADER_LEN - MESHTASTIC_PKC_OVERHEAD) + +#ifndef BLE_MESH_MAX_PAIRED_NODES +#define BLE_MESH_MAX_PAIRED_NODES 3 +#endif + +#ifndef BLE_MESH_PAIRING_TIMEOUT_MS +#define BLE_MESH_PAIRING_TIMEOUT_MS 60000 +#endif + +#ifndef BLE_MESH_PAIRING_ADV_INTERVAL_MS +#define BLE_MESH_PAIRING_ADV_INTERVAL_MS 500 +#endif // Outbound frames waiting for the advertiser. Extended advertising is set-and-repeat, not a packet // queue - the instance holds one payload and repeats it - so a burst has to be clocked through one @@ -45,15 +64,13 @@ /** * Carries mesh frames between nodes over connectionless BLE extended advertisements. * - * A second broadcast transport alongside LoRa, wired the way UdpMulticastHandler is: ingress hands - * decoded frames to Router::enqueueReceivedMessage, egress is a copy taken in Router::send. It is - * never the only path to the mesh - Router::send still asserts a LoRa iface. + * A second transport alongside LoRa: ingress authenticates the immediate bridge peer before handing + * the inner packet to Router::enqueueReceivedMessage, and egress makes one encrypted copy per + * approved peer. It is never the only path to the mesh - Router::send still asserts a LoRa iface. * - * Connectionless, not GATT. GATT is point-to-point: reaching N peers costs N writes and no peer - * overhears another, where one advertisement reaches every neighbour at once - the same one-to-many - * shape LoRa has. Note this does not yet buy dupe suppression: FloodingRouter::perhapsCancelDupe is - * gated on TRANSPORT_LORA and Router::cancelSending reaches only iface's TX queue, never this ring. - * Advertising keeps suppression possible later; it is not active today. + * Connectionless, not GATT. Each advertisement is addressed and authenticated for one stored peer; + * other scanners can observe its presence and size but cannot decrypt or inject it. Pairing beacons + * are accepted only during a user-opened 60-second window, and both displays must show the same code. * * onSend() only encodes and queues. The advertising itself is clocked by runOnce() on the main * thread, because Router::send() is not a place to block: an implementation that advertises @@ -63,6 +80,8 @@ class BLEMeshHandler : private concurrency::OSThread, public MeshTransportBase { public: + using PairingCandidateCallback = std::function; + BLEMeshHandler() : concurrency::OSThread("BLEMesh") {} virtual ~BLEMeshHandler() {} @@ -79,6 +98,13 @@ class BLEMeshHandler : private concurrency::OSThread, public MeshTransportBase /// Called from Router::send(). Encodes and queues; never transmits inline. bool onSend(const meshtastic_MeshPacket *mp) override; + bool beginPairing(PairingCandidateCallback callback); + void cancelPairing(); + bool approvePairingCandidate(); + bool forgetPeer(NodeNum nodeNum); + bool isPaired(NodeNum nodeNum); + uint8_t pairedCount(); + protected: /// One queued outbound frame, already built into a complete AD payload. struct AdvSlot { @@ -100,19 +126,32 @@ class BLEMeshHandler : private concurrency::OSThread, public MeshTransportBase int32_t runOnce() override; - /// Decode a received advertisement payload and enqueue it into the router. + /// Authenticate a received v2 frame and enqueue its inner packet into the router. void deliverToRouter(const uint8_t *data, size_t len, int8_t rssi); /// Hand an accepted packet on. Virtual only so the native tests can observe what survives the /// ingress guards without standing up a live Router; production always takes the default. virtual void enqueueReceived(meshtastic_MeshPacket *p); - /// Build the complete AD payload (flags + manufacturer data) for `mp`. Returns 0 on refusal. - uint8_t buildAdvPayload(const meshtastic_MeshPacket *mp, uint8_t *out, size_t outCap); + /// Build a complete authenticated AD payload for one approved peer. Returns 0 on refusal. + uint8_t buildAdvPayload(const meshtastic_MeshPacket *mp, NodeNum peer, uint8_t *out, size_t outCap); + +#ifdef PIO_UNIT_TESTING + bool addPairForTest(NodeNum nodeNum, const uint8_t publicKey[32]); +#endif bool isRunning = false; private: + struct PairingCandidate { + bool valid = false; + bool notified = false; + NodeNum nodeNum = 0; + uint64_t nonce = 0; + int8_t rssi = -128; + uint8_t publicKey[32] = {0}; + }; + // No lock. Both ends of this ring run on the main task: onSend() is reached from Router::send(), // and runOnce() is an OSThread on the same task. The BLE callbacks (NimBLE host task on ESP32, // SoftDevice on nRF52) only ever reach deliverToRouter(), which touches the packet pool and the @@ -132,6 +171,30 @@ class BLEMeshHandler : private concurrency::OSThread, public MeshTransportBase // ready" forever while the stack was already up. runOnce() polls platformReady() instead and // calls onBluetoothReady() itself, exactly once, whenever readiness actually arrives. bool readyHandled = false; + + std::array pairs{}; + uint8_t pairCount = 0; + bool pairsLoaded = false; + concurrency::Lock pairLock; + + bool pairingActive = false; + uint32_t pairingStartedMs = 0; + uint32_t lastPairingAdvertisementMs = 0; + uint64_t pairingNonce = 0; + PairingCandidate pairingCandidate; + PairingCandidateCallback pairingCallback; + + void ensurePairsLoaded(); + void loadPairs(); + bool savePairs(); + int findPair(NodeNum nodeNum); + bool addPair(NodeNum nodeNum, const uint8_t publicKey[32]); + bool validateIdentity(NodeNum nodeNum, const uint8_t publicKey[32]); + bool deriveBridgeKey(const uint8_t peerKey[32], uint8_t out[32]); + uint8_t buildPairingAdvertisement(uint8_t *out, size_t outCap); + void handlePairingHello(const uint8_t *data, size_t len, int8_t rssi); + void handleAuthenticatedData(const uint8_t *data, size_t len, int8_t rssi); + uint32_t pairingVerificationCode(const PairingCandidate &candidate); }; extern BLEMeshHandler *bleMeshHandler; diff --git a/src/mesh/Channels.cpp b/src/mesh/Channels.cpp index ec1a418eae..90abc5d7f7 100644 --- a/src/mesh/Channels.cpp +++ b/src/mesh/Channels.cpp @@ -552,8 +552,8 @@ bool Channels::setDefaultPresetCryptoForHash(ChannelHash channelHash) // Iterate all known presets for (int preset = _meshtastic_Config_LoRaConfig_ModemPreset_MIN; preset <= _meshtastic_Config_LoRaConfig_ModemPreset_MAX; ++preset) { - const char *name = DisplayFormatters::getModemPresetDisplayName((meshtastic_Config_LoRaConfig_ModemPreset)preset, false, - config.lora.use_preset); + const char *name = + DisplayFormatters::getModemPresetDisplayName((meshtastic_Config_LoRaConfig_ModemPreset)preset, false, true); if (!name) continue; if (strcmp(name, "Invalid") == 0) diff --git a/src/mesh/CryptoEngine.cpp b/src/mesh/CryptoEngine.cpp index bd199e8fd9..d2fa115c1e 100644 --- a/src/mesh/CryptoEngine.cpp +++ b/src/mesh/CryptoEngine.cpp @@ -287,6 +287,21 @@ bool CryptoEngine::decryptCurve25519(uint32_t fromNode, meshtastic_NodeInfoLite_ return aes_ccm_ad(shared_key, 32, nonce, 8, bytes, numBytes - 12, nullptr, 0, auth, bytesOut); } +bool CryptoEngine::deriveSharedKey(meshtastic_NodeInfoLite_public_key_t remotePublic, const uint8_t *context, size_t contextLen, + uint8_t out[32]) +{ + if (remotePublic.size != 32 || !context || contextLen == 0 || !out || !setDHPublicKey(remotePublic.bytes)) + return false; + + SHA256 kdf; + kdf.reset(); + kdf.update(shared_key, sizeof(shared_key)); + kdf.update(context, contextLen); + kdf.finalize(out, 32); + memset(shared_key, 0, sizeof(shared_key)); + return true; +} + void CryptoEngine::setDHPrivateKey(uint8_t *_private_key) { memcpy(private_key, _private_key, 32); diff --git a/src/mesh/CryptoEngine.h b/src/mesh/CryptoEngine.h index 95c7eb8ece..2e245253f6 100644 --- a/src/mesh/CryptoEngine.h +++ b/src/mesh/CryptoEngine.h @@ -56,6 +56,10 @@ class CryptoEngine uint64_t packetNum, size_t numBytes, const uint8_t *bytes, uint8_t *bytesOut); virtual bool decryptCurve25519(uint32_t fromNode, meshtastic_NodeInfoLite_public_key_t remotePublic, uint64_t packetNum, size_t numBytes, const uint8_t *bytes, uint8_t *bytesOut); + // Derive a domain-separated 32-byte key from our X25519 identity and a peer key. + // Callers must hold cryptLock for this operation and any immediately following cipher use. + bool deriveSharedKey(meshtastic_NodeInfoLite_public_key_t remotePublic, const uint8_t *context, size_t contextLen, + uint8_t out[32]); virtual bool setDHPublicKey(uint8_t *publicKey); virtual void hash(uint8_t *bytes, size_t numBytes); @@ -129,4 +133,4 @@ class CryptoEngine void initNonce(uint32_t fromNode, uint64_t packetId, uint32_t extraNonce = 0); }; -extern CryptoEngine *crypto; \ No newline at end of file +extern CryptoEngine *crypto; diff --git a/src/mesh/Router.cpp b/src/mesh/Router.cpp index 4e388e5c4a..35a2488bb9 100644 --- a/src/mesh/Router.cpp +++ b/src/mesh/Router.cpp @@ -1076,6 +1076,44 @@ DecodeState perhapsDecode(meshtastic_MeshPacket *p) } } +#if HAS_BLE_MESH || HAS_BLE_GATT_MESH + const bool arrivedViaBleMesh = p->transport_mechanism == meshtastic_MeshPacket_TransportMechanism_TRANSPORT_BLE_ADV || + p->transport_mechanism == meshtastic_MeshPacket_TransportMechanism_TRANSPORT_BLE_GATT; + if (!decrypted && arrivedViaBleMesh) { + bool hasLocalDefaultChannel = false; + ChannelIndex localDefaultChannel = channels.getPrimaryIndex(); + for (ChannelIndex i = 0; i < channels.getNumChannels(); ++i) { + if (channels.isDefaultChannel(i)) { + localDefaultChannel = i; + hasLocalDefaultChannel = true; + break; + } + } + + if (hasLocalDefaultChannel && channels.setDefaultPresetCryptoForHash(p->channel)) { + memcpy(bytes, p->encrypted.bytes, rawSize); + crypto->decrypt(p->from, p->id, rawSize, bytes); + + meshtastic_Data decodedtmp; + memset(&decodedtmp, 0, sizeof(decodedtmp)); + if (pb_decode_from_bytes(bytes, rawSize, &meshtastic_Data_msg, &decodedtmp) && + decodedtmp.portnum != meshtastic_PortNum_UNKNOWN_APP) { +#if !(MESHTASTIC_EXCLUDE_PKI) + if (!owner.is_licensed && isToUs(p) && decodedtmp.portnum == meshtastic_PortNum_TEXT_MESSAGE_APP) { + LOG_WARN("Rejecting legacy DM"); + return DecodeState::DECODE_FAILURE; + } +#endif + p->decoded = decodedtmp; + p->which_payload_variant = meshtastic_MeshPacket_decoded_tag; + chIndex = localDefaultChannel; + decrypted = true; + LOG_INFO("Decoded BLE mesh packet from another default preset"); + } + } + } +#endif + if (decrypted) { // parsing was successful p->channel = chIndex; // change to store the index instead of the hash diff --git a/src/platform/esp32/ESP32BLEMesh.cpp b/src/platform/esp32/ESP32BLEMesh.cpp index e586483f98..1ba60057c0 100644 --- a/src/platform/esp32/ESP32BLEMesh.cpp +++ b/src/platform/esp32/ESP32BLEMesh.cpp @@ -24,6 +24,7 @@ void ESP32BLEMesh::start() return; } + pairedCount(); memset(peers, 0, sizeof(peers)); peerCount = 0; isRunning = true; @@ -55,6 +56,7 @@ void ESP32BLEMesh::stop() if (!isRunning) return; + cancelPairing(); platformEndAdvertising(); stopScanning(); isRunning = false; diff --git a/src/platform/nrf52/NRF52BLEMesh.cpp b/src/platform/nrf52/NRF52BLEMesh.cpp index a040650f45..6837ebc973 100644 --- a/src/platform/nrf52/NRF52BLEMesh.cpp +++ b/src/platform/nrf52/NRF52BLEMesh.cpp @@ -34,6 +34,7 @@ void NRF52BLEMesh::start() return; } + pairedCount(); instance = this; memset(peers, 0, sizeof(peers)); peerCount = 0; @@ -61,6 +62,7 @@ void NRF52BLEMesh::stop() if (!isRunning) return; + cancelPairing(); platformEndAdvertising(); stopScanning(); isRunning = false; diff --git a/src/platform/nrf52/NRF52Bluetooth.cpp b/src/platform/nrf52/NRF52Bluetooth.cpp index 403891923b..54351e20b0 100644 --- a/src/platform/nrf52/NRF52Bluetooth.cpp +++ b/src/platform/nrf52/NRF52Bluetooth.cpp @@ -342,12 +342,11 @@ void NRF52Bluetooth::setup() Bluefruit.autoConnLed(false); Bluefruit.configPrphBandwidth(BANDWIDTH_MAX); #if HAS_BLE_MESH && defined(BLE_MESH_NRF52_CENTRAL) - // BLE mesh scans, and scanning needs a central link: Bluefruit.begin() defaults to (1, 0), so - // sd_ble_gap_scan_start fails outright without one. But asking for it raises the SoftDevice's - // RAM requirement above what the linker ORIGIN below reserves, and sd_ble_enable() then rejects - // the RAM base - see the failure path just below. So this is gated behind its own flag until - // nrf52840_s140_v*.ld is re-based; enabling it without that change gets you a node with no - // Bluetooth at all. + // BLE node pairing scans, and scanning needs a central link: Bluefruit.begin() defaults to + // (1 peripheral, 0 central), so sd_ble_gap_scan_start fails without one. Asking for it raises + // the SoftDevice RAM requirement above the former 0x20004000 linker origin. The standard + // nrf52840_s140_v*.ld scripts reserve 0x20006000 and the failure path below catches future + // configuration changes that outgrow it. Bluefruit.configCentralBandwidth(BANDWIDTH_MAX); #if HAS_BLE_GATT_MESH // Two peripheral links: the phone and one mesh peer. diff --git a/src/platform/nrf52/nrf52840_s140_v6.ld b/src/platform/nrf52/nrf52840_s140_v6.ld index a956c6583f..4d6b5ce697 100644 --- a/src/platform/nrf52/nrf52840_s140_v6.ld +++ b/src/platform/nrf52/nrf52840_s140_v6.ld @@ -22,16 +22,15 @@ MEMORY * - Concurrent connection peripheral + central + secure links * - Event Len, HVN queue, Write CMD queue * - * With our fixed Bluefruit config (1 peripheral link, BANDWIDTH_MAX / MTU 247, - * 0x1000 attr table) sd_ble_enable() reports a requirement well below the old - * 0x20006000 ORIGIN; every byte of gap is unusable RAM. 0x20004000 keeps a - * ~2+ KB margin over the reported base. NRF52Bluetooth::setup() now checks - * Bluefruit.begin() and records a critical error if the SoftDevice ever - * rejects this base (e.g. after an SD/Bluefruit config change) - re-measure - * with CFG_DEBUG=1 (Bluefruit logs "SoftDevice's RAM requires: 0x...") - * before raising bandwidth/MTU/link settings. + * With our fixed Bluefruit config (1 peripheral link, 1 central link, + * BANDWIDTH_MAX / MTU 247, 0x1000 attr table) sd_ble_enable() reports a + * requirement above 0x20004000. 0x20006000 keeps the known-safe margin. + * NRF52Bluetooth::setup() now checks Bluefruit.begin() and records a critical + * error if the SoftDevice ever rejects this base (e.g. after an SD/Bluefruit + * config change) - re-measure with CFG_DEBUG=1 (Bluefruit logs "SoftDevice's + * RAM requires: 0x...") before raising bandwidth/MTU/link settings. */ - RAM (rwx) : ORIGIN = 0x20004000, LENGTH = 0x20040000 - 0x20004000 + RAM (rwx) : ORIGIN = 0x20006000, LENGTH = 0x20040000 - 0x20006000 } SECTIONS diff --git a/src/platform/nrf52/nrf52840_s140_v6_blemesh.ld b/src/platform/nrf52/nrf52840_s140_v6_blemesh.ld deleted file mode 100644 index 21fc90b161..0000000000 --- a/src/platform/nrf52/nrf52840_s140_v6_blemesh.ld +++ /dev/null @@ -1,56 +0,0 @@ -/* Linker script to configure memory regions. */ - -SEARCH_DIR(.) -GROUP(-lgcc -lc -lnosys) - -MEMORY -{ - /* App region ends at 0xEA000, not 0xED000: the 12 KB warm-node-store - * record-ring (3 x 4 KB pages, src/mesh/WarmNodeStore.h) occupies - * 0xEA000-0xED000, directly below the stock LittleFS partition. Boards on - * the framework-default linker script are covered by the post-link guard - * in extra_scripts/nrf52_warm_region.py instead. - * - * S140 v6.x app region starts at 0x26000 (152 KB SoftDevice); v7.x uses - * 0x27000. All other boundaries are identical — see nrf52840_s140_v7.ld. */ - FLASH (rx) : ORIGIN = 0x26000, LENGTH = 0xEA000 - 0x26000 - - /* BLE-mesh variant of nrf52840_s140_v6.ld: same FLASH layout, higher RAM base. - * - * The stock 0x20004000 was measured for Bluefruit's default (1 peripheral link, - * 0 central). BLE mesh scans, and scanning needs a central link, which raises - * what sd_ble_enable() demands - past 0x20004000, so the SoftDevice rejects the - * RAM base and the node comes up with no Bluetooth at all. Observed on a - * RAK4631: it went silent on the serial API until reflashed. - * - * 0x20006000 is the origin this tree shipped before the 0x20004000 measurement, - * i.e. a value known to satisfy a strictly more generous SoftDevice config than - * 1-peripheral-plus-1-central. It costs 8 KB of RAM, which is why this is a - * separate script selected only by BLE-mesh builds rather than a change to the - * shared one. - * - * Re-measure before changing bandwidth/MTU/link settings again: build with - * CFG_DEBUG=1 and read Bluefruit's "SoftDevice's RAM requires: 0x...". - */ - RAM (rwx) : ORIGIN = 0x20006000, LENGTH = 0x20040000 - 0x20006000 -} - -SECTIONS -{ - . = ALIGN(4); - .svc_data : - { - PROVIDE(__start_svc_data = .); - KEEP(*(.svc_data)) - PROVIDE(__stop_svc_data = .); - } > RAM - - .fs_data : - { - PROVIDE(__start_fs_data = .); - KEEP(*(.fs_data)) - PROVIDE(__stop_fs_data = .); - } > RAM -} INSERT AFTER .data; - -INCLUDE "nrf52_common.ld" diff --git a/src/platform/nrf52/nrf52840_s140_v7.ld b/src/platform/nrf52/nrf52840_s140_v7.ld index dbaf7dfb76..990554c22a 100644 --- a/src/platform/nrf52/nrf52840_s140_v7.ld +++ b/src/platform/nrf52/nrf52840_s140_v7.ld @@ -19,16 +19,15 @@ MEMORY * - Concurrent connection peripheral + central + secure links * - Event Len, HVN queue, Write CMD queue * - * With our fixed Bluefruit config (1 peripheral link, BANDWIDTH_MAX / MTU 247, - * 0x1000 attr table) sd_ble_enable() reports a requirement well below the old - * 0x20006000 ORIGIN; every byte of gap is unusable RAM. 0x20004000 keeps a - * ~2+ KB margin over the reported base. NRF52Bluetooth::setup() now checks - * Bluefruit.begin() and records a critical error if the SoftDevice ever - * rejects this base (e.g. after an SD/Bluefruit config change) - re-measure - * with CFG_DEBUG=1 (Bluefruit logs "SoftDevice's RAM requires: 0x...") - * before raising bandwidth/MTU/link settings. + * With our fixed Bluefruit config (1 peripheral link, 1 central link, + * BANDWIDTH_MAX / MTU 247, 0x1000 attr table) sd_ble_enable() reports a + * requirement above 0x20004000. 0x20006000 keeps the known-safe margin. + * NRF52Bluetooth::setup() now checks Bluefruit.begin() and records a critical + * error if the SoftDevice ever rejects this base (e.g. after an SD/Bluefruit + * config change) - re-measure with CFG_DEBUG=1 (Bluefruit logs "SoftDevice's + * RAM requires: 0x...") before raising bandwidth/MTU/link settings. */ - RAM (rwx) : ORIGIN = 0x20004000, LENGTH = 0x20040000 - 0x20004000 + RAM (rwx) : ORIGIN = 0x20006000, LENGTH = 0x20040000 - 0x20006000 } SECTIONS diff --git a/test/state-manifest.tsv b/test/state-manifest.tsv index f2ef9ff4c1..65928e7e0b 100644 --- a/test/state-manifest.tsv +++ b/test/state-manifest.tsv @@ -48,6 +48,7 @@ # suite flags reason test_admin_radio writes=config.proto,module.proto,device.proto,channels.proto,nodes.proto,warm.dat,Messages_default.msgs errors=400 per-test NodeDB fixture, and the admin handlers under test persist config, channels and node metadata test_admin_session_repro writes=config.proto,module.proto,device.proto,channels.proto,nodes.proto constructs a NodeDB, whose constructor persists a default set when the prefs directory is empty +test_ble_mesh writes=config.proto,module.proto,device.proto,channels.proto,nodes.proto constructs a NodeDB and exercises the approved-peer store in a fresh sandbox test_event_channel_phone_api writes=config.proto,module.proto,device.proto,channels.proto,nodes.proto constructs a NodeDB, whose constructor persists a default set when the prefs directory is empty test_event_channel_router writes=config.proto,module.proto,device.proto,channels.proto,nodes.proto errors=200 subclasses NodeDB for the event-channel fixtures; the base constructor persists a default set when the prefs directory is empty test_firmware_edition writes=config.proto,module.proto,device.proto,channels.proto,nodes.proto persists an event firmware_edition in devicestate, then reboots a NodeDB to prove a vanilla build resets it diff --git a/test/test_ble_mesh/BleMesh.cpp b/test/test_ble_mesh/BleMesh.cpp index 01476ff3e8..580114edf9 100644 --- a/test/test_ble_mesh/BleMesh.cpp +++ b/test/test_ble_mesh/BleMesh.cpp @@ -1,13 +1,24 @@ #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 @@ -18,7 +29,8 @@ namespace * * 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. + * not real. The secure envelope, approval checks, verification code, and tamper detection are also + * pinned here. */ class FakeBLEMesh : public BLEMeshHandler { @@ -33,7 +45,11 @@ class FakeBLEMesh : public BLEMeshHandler // 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, uint8_t *out, size_t cap) { return buildAdvPayload(mp, out, cap); } + 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 @@ -71,13 +87,57 @@ meshtastic_MeshPacket encryptedPacket(uint32_t from = 0x3061b02e, uint32_t id = 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); -/// Encode `mp` the way the transport does, so ingress tests have a real advertisement body. -size_t encodeForAir(const meshtastic_MeshPacket &mp, uint8_t *out, size_t cap) +uint8_t buildAuthenticatedAdvertisement(FakeBLEMesh &handler, const TestIdentity &sender, const TestIdentity &recipient, + const meshtastic_MeshPacket &packet, uint8_t out[BLE_MESH_ADV_TOTAL_MAX]) { - return pb_encode_to_bytes(out, cap, &meshtastic_MeshPacket_msg, &mp); + 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 @@ -85,11 +145,12 @@ size_t encodeForAir(const meshtastic_MeshPacket &mp, uint8_t *out, size_t cap) void test_advertisement_carries_the_packet(void) { FakeBLEMesh h; - h.start(); + const auto sender = makeIdentity(1); + const auto recipient = makeIdentity(65); auto p = encryptedPacket(); uint8_t adv[BLE_MESH_ADV_TOTAL_MAX]; - uint8_t len = h.build(&p, adv, sizeof(adv)); + 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. @@ -99,6 +160,9 @@ void test_advertisement_carries_the_packet(void) 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]); } @@ -106,6 +170,9 @@ void test_advertisement_carries_the_packet(void) 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; @@ -113,22 +180,32 @@ void test_refuses_an_unencrypted_packet(void) 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. - TEST_ASSERT_EQUAL_UINT8(0, h.build(&p, adv, sizeof(adv))); + 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]; - TEST_ASSERT_EQUAL_UINT8(0, h.build(&p, adv, sizeof(adv))); + // 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 @@ -136,13 +213,18 @@ void test_drops_a_packet_too_large_for_one_advertisement(void) auto p = encryptedPacket(0x3061b02e, 0x04050b6e, MAX_ENCRYPTED_FOR_TEST); uint8_t adv[BLE_MESH_ADV_TOTAL_MAX]; - TEST_ASSERT_EQUAL_UINT8(0, h.build(&p, adv, sizeof(adv))); + 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)); @@ -158,7 +240,11 @@ void test_send_queues_rather_than_transmitting(void) 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++) { @@ -173,7 +259,11 @@ void test_tx_queue_is_bounded(void) 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; @@ -184,20 +274,76 @@ void test_a_relayed_packet_is_re_advertised(void) TEST_ASSERT_TRUE_MESSAGE(h.onSend(&p), "relay must not be refused"); } -void test_ingress_accepts_a_well_formed_frame(void) +void test_pairing_hello_produces_the_same_verification_code(void) { - FakeBLEMesh h; - h.start(); - auto p = encryptedPacket(); + const auto a = makeIdentity(1); + const auto b = makeIdentity(65); + FakeBLEMesh nodeA; + FakeBLEMesh nodeB; + uint32_t codeA = UINT32_MAX; + uint32_t codeB = UINT32_MAX; - uint8_t body[meshtastic_MeshPacket_size]; - size_t n = encodeForAir(p, body, sizeof(body)); - TEST_ASSERT_TRUE(n > 0); + 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; - h.feed(body, n, -42); + 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; - TEST_ASSERT_EQUAL_MESSAGE(1, h.received.size(), "delivered to the router"); - const auto &got = h.received[0]; + 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); @@ -205,75 +351,103 @@ void test_ingress_accepts_a_well_formed_frame(void) 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_drops_a_frame_with_no_sender(void) +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(0 /* from */); - + auto p = encryptedPacket(); uint8_t body[meshtastic_MeshPacket_size]; - size_t n = encodeForAir(p, body, sizeof(body)); + const size_t n = pb_encode_to_bytes(body, sizeof(body), &meshtastic_MeshPacket_msg, &p); - // 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.feed(body, n, -50); - TEST_ASSERT_EQUAL_MESSAGE(0, h.received.size(), "spoofed origin rejected"); + TEST_ASSERT_EQUAL_MESSAGE(0, h.received.size(), "legacy unauthenticated frame rejected"); } void test_ingress_drops_an_impossible_hop_count(void) { - FakeBLEMesh h; - h.start(); + 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); - uint8_t body[meshtastic_MeshPacket_size]; - size_t n = encodeForAir(p, body, sizeof(body)); - - // An out-of-range hop count is not relayable; UdpMulticastHandler drops it identically. - h.feed(body, n, -50); - TEST_ASSERT_EQUAL_MESSAGE(0, h.received.size(), "invalid hop count rejected"); -} - -void test_ingress_clears_pki_metadata(void) -{ - FakeBLEMesh h; - h.start(); - auto p = encryptedPacket(); - // A sender must not be able to assert its own packet was PKI-authenticated: that flag is - // local state the Router sets after a successful decrypt, never something off the wire. - p.pki_encrypted = true; - p.public_key.size = 32; - - uint8_t body[meshtastic_MeshPacket_size]; - size_t n = encodeForAir(p, body, sizeof(body)); - - h.feed(body, n, -50); - TEST_ASSERT_EQUAL(1, h.received.size()); - TEST_ASSERT_FALSE_MESSAGE(h.received[0].pki_encrypted, "claimed authentication stripped"); - TEST_ASSERT_EQUAL(0, h.received[0].public_key.size); -} - -void test_ingress_ignores_our_own_advertisement(void) -{ - FakeBLEMesh h; - h.start(); - auto p = encryptedPacket(nodeDB->getNodeNum()); - - uint8_t body[meshtastic_MeshPacket_size]; - size_t n = encodeForAir(p, body, sizeof(body)); - - // Our own advertisement echoing back into our own scanner would loop. - h.feed(body, n, -50); - TEST_ASSERT_EQUAL_MESSAGE(0, h.received.size(), "self-echo dropped"); + 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)); @@ -288,8 +462,61 @@ void test_pump_waits_for_the_platform(void) 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) {} +void tearDown(void) +{ + concurrency::LockGuard guard(spiLock); + if (FSCom.exists("/prefs/ble-pairs.dat")) + FSCom.remove("/prefs/ble-pairs.dat"); +} void setup() { @@ -306,12 +533,18 @@ void setup() 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_ingress_accepts_a_well_formed_frame); - RUN_TEST(test_ingress_drops_a_frame_with_no_sender); + 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_ingress_clears_pki_metadata); - RUN_TEST(test_ingress_ignores_our_own_advertisement); 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()); } diff --git a/test/test_channel_keys/test_main.cpp b/test/test_channel_keys/test_main.cpp index 34c42b3328..e8db0b62a1 100644 --- a/test/test_channel_keys/test_main.cpp +++ b/test/test_channel_keys/test_main.cpp @@ -1,8 +1,11 @@ // Channel key derivation and hash layer: getKey() PSK expansion, generateHash() golden values, // onConfigChanged() primary restore, setChannel() demotion, and perhapsDecode()'s hash fall-through. +// BLE cases additionally pin bridging between public default channels on different presets without +// allowing a private-only receiver to surface public traffic as private-channel data. #include "Channels.h" #include "CryptoEngine.h" +#include "DisplayFormatters.h" #include "MeshTypes.h" // Include BEFORE TestUtil.h (provides NodeNum, isBroadcast, etc.) #include "NodeDB.h" #include "Router.h" @@ -508,6 +511,48 @@ void test_perhapsdecode_unknown_hash_is_opaque() TEST_ASSERT_EQUAL(meshtastic_MeshPacket_encrypted_tag, p.which_payload_variant); } +#if HAS_BLE_MESH +static uint8_t defaultPresetHash(meshtastic_Config_LoRaConfig_ModemPreset preset) +{ + const char *name = DisplayFormatters::getModemPresetDisplayName(preset, false, true); + return refHash(name, defaultpsk, sizeof(defaultpsk)); +} + +void test_ble_bridge_decodes_public_packet_from_another_preset() +{ + owner.is_licensed = true; + const uint8_t mediumFastHash = defaultPresetHash(meshtastic_Config_LoRaConfig_ModemPreset_MEDIUM_FAST); + TEST_ASSERT_NOT_EQUAL(channels.getHash(0), mediumFastHash); + + TEST_ASSERT_TRUE(channels.setDefaultPresetCryptoForHash(mediumFastHash)); + meshtastic_MeshPacket blePacket = makeEncryptedPacket(mediumFastHash, makeProbeData()); + meshtastic_MeshPacket loraPacket = blePacket; + blePacket.transport_mechanism = meshtastic_MeshPacket_TransportMechanism_TRANSPORT_BLE_ADV; + loraPacket.transport_mechanism = meshtastic_MeshPacket_TransportMechanism_TRANSPORT_LORA; + + TEST_ASSERT_EQUAL_INT(DecodeState::DECODE_SUCCESS, perhapsDecode(&blePacket)); + TEST_ASSERT_EQUAL_UINT8(0, blePacket.channel); + TEST_ASSERT_EQUAL(meshtastic_MeshPacket_decoded_tag, blePacket.which_payload_variant); + TEST_ASSERT_EQUAL_INT(DecodeState::DECODE_OPAQUE, perhapsDecode(&loraPacket)); +} + +void test_ble_bridge_does_not_map_public_data_to_a_private_primary() +{ + owner.is_licensed = true; + const uint8_t mediumFastHash = defaultPresetHash(meshtastic_Config_LoRaConfig_ModemPreset_MEDIUM_FAST); + TEST_ASSERT_TRUE(channels.setDefaultPresetCryptoForHash(mediumFastHash)); + meshtastic_MeshPacket packet = makeEncryptedPacket(mediumFastHash, makeProbeData()); + packet.transport_mechanism = meshtastic_MeshPacket_TransportMechanism_TRANSPORT_BLE_ADV; + + static const uint8_t privatePsk[16] = {0x42}; + setSlot(0, meshtastic_Channel_Role_PRIMARY, "private", privatePsk, sizeof(privatePsk)); + channels.onConfigChanged(); + + TEST_ASSERT_EQUAL_INT(DecodeState::DECODE_OPAQUE, perhapsDecode(&packet)); + TEST_ASSERT_EQUAL(meshtastic_MeshPacket_encrypted_tag, packet.which_payload_variant); +} +#endif + #endif // !USERPREFS_BLOCK_POSITION_ON_EVENT_CHANNEL // --- Unity lifecycle --- @@ -578,6 +623,10 @@ CK_TEST_ENTRY void setup() RUN_TEST(test_perhapsdecode_collision_selects_matching_psk); RUN_TEST(test_perhapsdecode_wrong_key_is_decode_failure); RUN_TEST(test_perhapsdecode_unknown_hash_is_opaque); +#if HAS_BLE_MESH + RUN_TEST(test_ble_bridge_decodes_public_packet_from_another_preset); + RUN_TEST(test_ble_bridge_does_not_map_public_data_to_a_private_primary); +#endif #endif exit(UNITY_END()); diff --git a/variants/esp32/esp32-common.ini b/variants/esp32/esp32-common.ini index bb73e8618a..ba161e4d89 100644 --- a/variants/esp32/esp32-common.ini +++ b/variants/esp32/esp32-common.ini @@ -367,18 +367,18 @@ custom_sdkconfig = CONFIG_ARDUINO_SELECTIVE_Insights=n ; --------------------------------------------------------------------------------------------- -; BLE mesh opt-in for ESP32 parts. Reference both values from a variant that wants the transport: +; Shared by standard BLE 5-capable ESP32 builds. Reference both values after esp32_common: ; ; build_flags = ${esp32_common.build_flags} ${ble_mesh_esp32.build_flags} ; custom_sdkconfig = ${esp32_common.custom_sdkconfig} ${ble_mesh_esp32.custom_sdkconfig} ; ; Order matters: esp32_common sets all three NimBLE options to n, so this must come after it. ; Every line here was needed to make the transport work on hardware, and each failed differently. -; The same ordering is what resolves the two values this appends for keys esp32_common already sets -; (CONFIG_BT_NIMBLE_MAX_CONNECTIONS 1 -> 2, CONFIG_BT_CTRL_BLE_MAX_ACT 2 -> 6): pioarduino keeps the -; last occurrence of a duplicated key, and the generated sdkconfig.defaults carries the values from -; this section. Read that file, or the built ELF, when in doubt - never the framework's shared -; sdkconfig, which reflects whichever env built last. Editing any option line inside a +; The same ordering is what resolves the value this appends for a key esp32_common already sets +; (CONFIG_BT_CTRL_BLE_MAX_ACT 2 -> 4): pioarduino keeps the last occurrence of a duplicated key, +; and the generated sdkconfig.defaults carries the values from this section. Read that file, or the +; built ELF, when in doubt - never the framework's shared sdkconfig, which reflects whichever env +; built last. Editing any option line inside a ; custom_sdkconfig value changes its hash and rebuilds the IDF libraries from source; comment lines ; do not - the parser strips them before the value is hashed. [ble_mesh_esp32] @@ -388,12 +388,11 @@ build_flags = ; prebuilt esp_nimble_cfg.h, which does not reflect custom_sdkconfig, so it reads 0 even in a ; build whose rebuilt NimBLE has ext-adv - silently compiling the feature out and dropping the ; transport to the legacy 31-byte path, which cannot carry a mesh frame at all. + -DCONFIG_BT_NIMBLE_EXT_ADV=1 -DBLE_MESH_USE_EXT_ADV=1 - ; The mesh-peer GATT service: phones connect to the node as mesh peers (the SIG Mesh "GATT proxy" - ; role). Needs the second connection and the third advertising instance configured below. - -DHAS_BLE_GATT_MESH=1 custom_sdkconfig = CONFIG_BT_NIMBLE_EXT_ADV=y + CONFIG_BT_NIMBLE_EXT_SCAN=y CONFIG_BT_NIMBLE_TRANSPORT_EVT_SIZE=257 CONFIG_BT_NIMBLE_MAX_EXT_ADV_INSTANCES=2 ; Scanning is a role, and the stock Arduino NimBLE build compiles it out (ROLE_BROADCASTER=y, @@ -405,12 +404,8 @@ custom_sdkconfig = CONFIG_BT_NIMBLE_EXT_ADV_MAX_SIZE=257 ; The controller counts advertising sets, scans and connections all as "activities", and the ; default budget of 2 is exactly the stock build: one advertisement plus one connection. This - ; transport needs three - the PhoneAPI advertisement, the mesh advertisement, and a scan - and - ; without the extra headroom both the scan enable and the second ext_adv_configure come back - ; HCI 0x07, Memory Capacity Exceeded (NimBLE 519). The mesh-peer edge adds a third advertising - ; set (its own connectable advertisement, on instance 2 - the host already addresses instances - ; 0..MAX_EXT_ADV_INSTANCES) and a second peripheral connection (the PhoneAPI link plus one mesh - ; phone), so the budget is 3 + 1 + 2 = 6 and the NimBLE host is allowed two concurrent connections. - ; Both peers are centrals connecting inward, so ROLE_CENTRAL stays off. - CONFIG_BT_CTRL_BLE_MAX_ACT=6 - CONFIG_BT_NIMBLE_MAX_CONNECTIONS=2 + ; transport needs four - the PhoneAPI advertisement and connection, the mesh advertisement, and + ; a scan - and without the extra headroom scan enable or ext_adv_configure comes back HCI 0x07, + ; Memory Capacity Exceeded (NimBLE 519). Both peers remain connectionless, so ROLE_CENTRAL stays + ; off and the host still needs only the phone connection. + CONFIG_BT_CTRL_BLE_MAX_ACT=4 diff --git a/variants/esp32c3/esp32c3.ini b/variants/esp32c3/esp32c3.ini index dd05b7c816..9276ac868b 100644 --- a/variants/esp32c3/esp32c3.ini +++ b/variants/esp32c3/esp32c3.ini @@ -7,11 +7,13 @@ monitor_filters = esp32_c3_exception_decoder build_flags = ${esp32_common.build_flags} + ${ble_mesh_esp32.build_flags} ; Linker script to align text.handler_execute section to 4 bytes -Wl,-Tsrc/platform/esp32/align-text.handler_execute-4.ld custom_sdkconfig = ${esp32_common.custom_sdkconfig} + ${ble_mesh_esp32.custom_sdkconfig} ; ESP32c3 doesn't support SD_MMC CONFIG_ARDUINO_SELECTIVE_SD_MMC=n diff --git a/variants/esp32c3/heltec_esp32c3/platformio.ini b/variants/esp32c3/heltec_esp32c3/platformio.ini index 84c9d8099a..d087e4fd02 100644 --- a/variants/esp32c3/heltec_esp32c3/platformio.ini +++ b/variants/esp32c3/heltec_esp32c3/platformio.ini @@ -19,14 +19,3 @@ monitor_speed = 115200 upload_protocol = esptool ;upload_port = /dev/ttyUSB0 upload_speed = 921600 - -; BLE-mesh build, opt-in - see the heltec-v3_blemesh comment. Links on the C3 with the mesh -; section (RAM 34.2%, flash 87.2%); no C3 has been on the bench, so it is build-verified only. -[env:heltec-ht62-esp32c3-sx1262_blemesh] -extends = env:heltec-ht62-esp32c3-sx1262 -build_flags = - ${env:heltec-ht62-esp32c3-sx1262.build_flags} - ${ble_mesh_esp32.build_flags} -custom_sdkconfig = - ${esp32c3_base.custom_sdkconfig} - ${ble_mesh_esp32.custom_sdkconfig} diff --git a/variants/esp32c6/esp32c6.ini b/variants/esp32c6/esp32c6.ini index ae91e6e81f..a55c065a54 100644 --- a/variants/esp32c6/esp32c6.ini +++ b/variants/esp32c6/esp32c6.ini @@ -4,6 +4,7 @@ custom_esp32_kind = esp32c6 build_flags = ${esp32_common.build_flags} + ${ble_mesh_esp32.build_flags} ; Linker script to align text.handler_execute section to 4 bytes -Wl,-Tsrc/platform/esp32/align-text.handler_execute-4.ld ; Exclude Paxcounter, it uses 'esp_vhci_host_send_packet' whch is not available on ESP32-C6 @@ -18,6 +19,7 @@ monitor_filters = esp32_c3_exception_decoder custom_sdkconfig = ${esp32_common.custom_sdkconfig} + ${ble_mesh_esp32.custom_sdkconfig} ; ESP32c6 doesn't support SD_MMC CONFIG_ARDUINO_SELECTIVE_SD_MMC=n ; CONFIG_BT_NIMBLE_EXT_ADV=y diff --git a/variants/esp32s3/esp32s3.ini b/variants/esp32s3/esp32s3.ini index c2d0ee0106..37c4f79f02 100644 --- a/variants/esp32s3/esp32s3.ini +++ b/variants/esp32s3/esp32s3.ini @@ -6,10 +6,12 @@ monitor_speed = 115200 build_flags = ${esp32_common.build_flags} + ${ble_mesh_esp32.build_flags} -mtext-section-literals custom_sdkconfig = ${esp32_common.custom_sdkconfig} + ${ble_mesh_esp32.custom_sdkconfig} lib_deps = ${esp32_common.lib_deps} diff --git a/variants/esp32s3/heltec_v3/platformio.ini b/variants/esp32s3/heltec_v3/platformio.ini index 01fd9417d9..2a25815f85 100644 --- a/variants/esp32s3/heltec_v3/platformio.ini +++ b/variants/esp32s3/heltec_v3/platformio.ini @@ -17,18 +17,3 @@ build_flags = ${esp32s3_base.build_flags} -D HELTEC_V3 -I variants/esp32s3/heltec_v3 - -; BLE-mesh build, opt-in. A separate env rather than a flag on heltec-v3, for the same -; reason rak4631_blemesh is separate: turning the transport on is not free. It rebuilds -; NimBLE with ext-adv, the observer role and a larger activity budget, and -; BLE_MESH_USE_EXT_ADV replaces NimbleBluetooth::startAdvertising() outright - so the phone -; advertisement on a build with this flag is not the one every other S3 board ships. -; That has to be a board somebody chose, not something every esp32s3 variant inherits. -[env:heltec-v3_blemesh] -extends = env:heltec-v3 -build_flags = - ${env:heltec-v3.build_flags} - ${ble_mesh_esp32.build_flags} -custom_sdkconfig = - ${esp32s3_base.custom_sdkconfig} - ${ble_mesh_esp32.custom_sdkconfig} diff --git a/variants/nrf52840/nrf52840.ini b/variants/nrf52840/nrf52840.ini index 610b5b5303..522956a1f4 100644 --- a/variants/nrf52840/nrf52840.ini +++ b/variants/nrf52840/nrf52840.ini @@ -9,6 +9,7 @@ build_flags = ${nrf52_base.build_flags} -DSERIAL_BUFFER_SIZE=4096 -DLED_BUILTIN=-1 + -DBLE_MESH_NRF52_CENTRAL=1 lib_deps = ${nrf52_base.lib_deps} diff --git a/variants/nrf52840/rak4631/platformio.ini b/variants/nrf52840/rak4631/platformio.ini index 8ed6f4e7c5..70095f334a 100644 --- a/variants/nrf52840/rak4631/platformio.ini +++ b/variants/nrf52840/rak4631/platformio.ini @@ -100,14 +100,3 @@ upload_protocol = stlink ; eventually use platformio/tool-pyocd@2.3600.0 instad ;upload_protocol = custom ;upload_command = pyocd flash -t nrf52840 $UPLOADERFLAGS $SOURCE - -; BLE-mesh build. Separate env rather than a flag on rak4631 because it needs a -; different linker script: scanning requires a central link, which pushes the -; SoftDevice's RAM requirement past the shared script's 0x20004000 origin. -; See src/platform/nrf52/nrf52840_s140_v6_blemesh.ld. -[env:rak4631_blemesh] -extends = env:rak4631 -board_build.ldscript = src/platform/nrf52/nrf52840_s140_v6_blemesh.ld -build_flags = ${env:rak4631.build_flags} - -DBLE_MESH_NRF52_CENTRAL=1 - -DHAS_BLE_GATT_MESH=1