node bridge

This commit is contained in:
HarukiToreda committed 2026-09-14 16:20:07 -04:00
1 parent 46b4fdc799
commit 6fa3df0af3
25 files changed
+1225 -319

No files matched your search

+147 -1
View File
@@ -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;
+15
View File
@@ -1,6 +1,10 @@
#pragma once
#if HAS_SCREEN
#include "configuration.h"
#include <functional>
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 */
+526 -94
View File
@@ -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 <ErriezCRC32.h>
#include <RNG.h>
#include <SHA256.h>
#include <cstddef>
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<uint8_t>(totalLen - 4);
out[4] = BLE_MESH_AD_TYPE_MFG_DATA;
out[5] = static_cast<uint8_t>(BLE_MESH_COMPANY_ID & 0xff);
out[6] = static_cast<uint8_t>(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<uint8_t *>(&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<const uint8_t *>(&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<NodeNum, BLE_MESH_MAX_PAIRED_NODES> 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<NodeNum, BLE_MESH_MAX_PAIRED_NODES> 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<uint8_t>(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<uint8_t *>(&freshNonce), sizeof(freshNonce)))
CryptRNG.rand(reinterpret_cast<uint8_t *>(&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<uint8_t>(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<uint8_t>(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<NodeNum, BLE_MESH_MAX_PAIRED_NODES> 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)
+76 -13
View File
@@ -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 <array>
#include <functional>
// 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<void(NodeNum, uint32_t)>;
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<NodeNum, BLE_MESH_MAX_PAIRED_NODES> 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;
+2 -2
View File
@@ -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)
+15
View File
@@ -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);
+5 -1
View File
@@ -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;
extern CryptoEngine *crypto;
+38
View File
@@ -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
+2
View File
@@ -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;
+2
View File
@@ -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;
+5 -6
View File
@@ -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.
+8 -9
View File
@@ -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
@@ -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"
+8 -9
View File
@@ -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
+1
View File
@@ -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
Can't render this file because it contains an unexpected character in line 5 and column 48.
+306 -73
View File
@@ -1,13 +1,24 @@
#include "DebugConfiguration.h"
#include "FSCommon.h"
#include "SPILock.h"
#include "TestUtil.h"
#include "concurrency/LockGuard.h"
#include <unity.h>
#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 <Curve25519.h>
#include <ErriezCRC32.h>
#include <array>
#include <vector>
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<uint8_t, 32> privateKey{};
std::array<uint8_t, 32> 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<uint8_t>(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<uint8_t> frameBody(const std::vector<uint8_t> &advertisement)
{
return std::vector<uint8_t>(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());
}
+49
View File
@@ -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());
+13 -18
View File
@@ -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
+2
View File
@@ -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
@@ -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}
+2
View File
@@ -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
+2
View File
@@ -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}
-15
View File
@@ -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}
+1
View File
@@ -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}
-11
View File
@@ -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