mirror of
https://github.com/meshtastic/firmware.git
synced 2026-09-15 07:48:14 -04:00
node bridge
This commit is contained in:
1 parent
46b4fdc799
commit
6fa3df0af3
25 files changed
+1225
-319
No files matched your search
@@ -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;
|
||||
|
||||
@@ -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
@@ -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
@@ -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;
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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"
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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());
|
||||
}
|
||||
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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
|
||||
@@ -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}
|
||||
@@ -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
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
@@ -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}
|
||||
|
||||
@@ -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
|
||||
Reference in new issue
Block a user