From 4a214ae3abf64c3277d29cda259a0a6d00ce3245 Mon Sep 17 00:00:00 2001 From: overkillfpv Date: Mon, 20 Apr 2026 21:05:53 +1000 Subject: [PATCH 1/2] feat: implement blacklist functionality for path and channel filtering --- examples/simple_repeater/MyMesh.cpp | 397 ++++++++++++++++++++++++++++ examples/simple_repeater/MyMesh.h | 33 +++ 2 files changed, 430 insertions(+) diff --git a/examples/simple_repeater/MyMesh.cpp b/examples/simple_repeater/MyMesh.cpp index 666f79fc5..8513b717a 100644 --- a/examples/simple_repeater/MyMesh.cpp +++ b/examples/simple_repeater/MyMesh.cpp @@ -43,6 +43,9 @@ #define FIRMWARE_VER_LEVEL 2 +#define PATH_BLACKLIST_FILE "/path_bl" +#define CHAN_BLACKLIST_FILE "/chan_bl" + #define REQ_TYPE_GET_STATUS 0x01 // same as _GET_STATS #define REQ_TYPE_KEEP_ALIVE 0x02 #define REQ_TYPE_GET_TELEMETRY_DATA 0x03 @@ -545,6 +548,293 @@ uint32_t MyMesh::getDirectRetransmitDelay(const mesh::Packet *packet) { return getRNG()->nextInt(0, 5*t + 1); } +/* ----------------------- Blacklist helpers -------------------------------- */ + +// Returns true if any path entry in the packet matches a path-prefix blacklist entry. +// Comparison uses min(entry.len, hash_size) bytes, so shorter entries match any hash size. +bool MyMesh::isPathBlacklisted(const mesh::Packet* packet) const { + uint8_t hash_size = packet->getPathHashSize(); + uint8_t hash_count = packet->getPathHashCount(); + const uint8_t* ptr = packet->path; + for (uint8_t h = 0; h < hash_count; h++, ptr += hash_size) { + for (int b = 0; b < MAX_BLACKLIST_ENTRIES; b++) { + if (_path_blacklist[b].len == 0) continue; + uint8_t cmp_len = (_path_blacklist[b].len < hash_size) ? _path_blacklist[b].len : hash_size; + if (memcmp(ptr, _path_blacklist[b].prefix, cmp_len) == 0) return true; + } + } + return false; +} + +// Returns true if the channel hash at the start of a GRP_TXT/GRP_DATA payload matches +// a channel-hash blacklist entry (hex prefix or #channel_name with decrypt verification). +bool MyMesh::isChanBlacklisted(const mesh::Packet* packet) const { + uint8_t pt = packet->getPayloadType(); + if (pt != PAYLOAD_TYPE_GRP_TXT && pt != PAYLOAD_TYPE_GRP_DATA) return false; + if (packet->payload_len < PATH_HASH_SIZE) return false; + + // Check hex prefix entries + for (int b = 0; b < MAX_BLACKLIST_ENTRIES; b++) { + if (_chan_blacklist[b].len == 0) continue; + uint8_t avail = (packet->payload_len < MAX_PATH_PREFIX_LEN) ? (uint8_t)packet->payload_len : MAX_PATH_PREFIX_LEN; + uint8_t cmp_len = (_chan_blacklist[b].len < avail) ? _chan_blacklist[b].len : avail; + if (memcmp(packet->payload, _chan_blacklist[b].prefix, cmp_len) == 0) return true; + } + + // Check #channel_name entries with test decryption + if (_num_chan_name_filters > 0 && packet->payload_len > PATH_HASH_SIZE + CIPHER_MAC_SIZE) { + uint8_t channel_hash = packet->payload[0]; + const uint8_t* macAndData = &packet->payload[PATH_HASH_SIZE]; // MAC + encrypted data + int enc_len = packet->payload_len - PATH_HASH_SIZE; + + for (int i = 0; i < _num_chan_name_filters; i++) { + // Quick hash check first + if (channel_hash != _chan_name_filters[i].hash[0]) continue; + // Try to decrypt to verify this is actually the channel + uint8_t tmp[MAX_PACKET_PAYLOAD]; + int len = mesh::Utils::MACThenDecrypt(_chan_name_filters[i].secret, tmp, macAndData, enc_len); + if (len > 0) return true; // Successfully decrypted, confirmed channel match + } + } + + return false; +} + +bool MyMesh::addToBlacklist(BlacklistEntry* list, const uint8_t* prefix, uint8_t len) { + if (len == 0 || len > MAX_PATH_PREFIX_LEN) return false; + // if already present, consider success + for (int i = 0; i < MAX_BLACKLIST_ENTRIES; i++) { + if (list[i].len == len && memcmp(list[i].prefix, prefix, len) == 0) return true; + } + // find an empty slot + for (int i = 0; i < MAX_BLACKLIST_ENTRIES; i++) { + if (list[i].len == 0) { + list[i].len = len; + memcpy(list[i].prefix, prefix, len); + return true; + } + } + return false; // list is full +} + +bool MyMesh::removeFromBlacklist(BlacklistEntry* list, const uint8_t* prefix, uint8_t len) { + for (int i = 0; i < MAX_BLACKLIST_ENTRIES; i++) { + if (list[i].len == len && memcmp(list[i].prefix, prefix, len) == 0) { + list[i].len = 0; // mark slot as empty + return true; + } + } + return false; +} + +void MyMesh::formatBlacklist(const BlacklistEntry* list, char* reply) { + char* dp = reply; + int count = 0; + for (int i = 0; i < MAX_BLACKLIST_ENTRIES; i++) { + if (list[i].len == 0) continue; + if (count > 0) *dp++ = '\n'; + mesh::Utils::toHex(dp, list[i].prefix, list[i].len); + dp += list[i].len * 2; + count++; + } + if (count == 0) { + strcpy(reply, "-none-"); + } else { + *dp = 0; + } +} + +void MyMesh::loadBlacklist(const char* fname, BlacklistEntry* list) { + memset(list, 0, sizeof(BlacklistEntry) * MAX_BLACKLIST_ENTRIES); +#if defined(RP2040_PLATFORM) + File f = _fs->open(fname, "r"); +#else + File f = _fs->open(fname); +#endif + if (!f) return; + int idx = 0; + char line[MAX_PATH_PREFIX_LEN * 2 + 4]; + int line_len = 0; + while (f.available() && idx < MAX_BLACKLIST_ENTRIES) { + int c = f.read(); + if (c < 0) break; + if (c == '\n' || c == '\r') { + if (line_len >= 2 && (line_len % 2 == 0)) { + line[line_len] = 0; + uint8_t prefix[MAX_PATH_PREFIX_LEN]; + int byte_len = line_len / 2; + if (byte_len <= MAX_PATH_PREFIX_LEN && mesh::Utils::fromHex(prefix, byte_len, line)) { + list[idx].len = (uint8_t)byte_len; + memcpy(list[idx].prefix, prefix, byte_len); + idx++; + } + } + line_len = 0; + } else if (line_len < (int)(sizeof(line) - 1)) { + line[line_len++] = (char)c; + } + } + // handle last line with no trailing newline + if (line_len >= 2 && (line_len % 2 == 0) && idx < MAX_BLACKLIST_ENTRIES) { + line[line_len] = 0; + uint8_t prefix[MAX_PATH_PREFIX_LEN]; + int byte_len = line_len / 2; + if (byte_len <= MAX_PATH_PREFIX_LEN && mesh::Utils::fromHex(prefix, byte_len, line)) { + list[idx].len = (uint8_t)byte_len; + memcpy(list[idx].prefix, prefix, byte_len); + } + } + f.close(); +} + +void MyMesh::saveBlacklist(const char* fname, const BlacklistEntry* list) { +#if defined(NRF52_PLATFORM) || defined(STM32_PLATFORM) + _fs->remove(fname); + File f = _fs->open(fname, FILE_O_WRITE); +#elif defined(RP2040_PLATFORM) + File f = _fs->open(fname, "w"); +#else + File f = _fs->open(fname, "w", true); +#endif + if (!f) return; + for (int i = 0; i < MAX_BLACKLIST_ENTRIES; i++) { + if (list[i].len == 0) continue; + char hex[MAX_PATH_PREFIX_LEN * 2 + 2]; + mesh::Utils::toHex(hex, list[i].prefix, list[i].len); + f.println(hex); + } + f.close(); +} + +/* ------------------- Channel name filter helpers -------------------------- */ + +void MyMesh::deriveChanNameFilter(ChanNameFilter& entry, const char* name) { + // Derive channel secret: first 16 bytes of sha256(name) + uint8_t full_hash[32]; + mesh::Utils::sha256(full_hash, 32, (const uint8_t*)name, strlen(name)); + memcpy(entry.secret, full_hash, CIPHER_KEY_SIZE); + // Derive channel hash from the secret + mesh::Utils::sha256(entry.hash, sizeof(entry.hash), entry.secret, CIPHER_KEY_SIZE); + StrHelper::strncpy(entry.name, name, sizeof(entry.name)); +} + +bool MyMesh::addChanNameFilter(const char* name) { + // Check for duplicate + for (int i = 0; i < _num_chan_name_filters; i++) { + if (strcmp(_chan_name_filters[i].name, name) == 0) return true; // already exists + } + if (_num_chan_name_filters >= MAX_CHAN_NAME_FILTERS) return false; // full + deriveChanNameFilter(_chan_name_filters[_num_chan_name_filters], name); + _num_chan_name_filters++; + return true; +} + +bool MyMesh::removeChanNameFilter(const char* name) { + for (int i = 0; i < _num_chan_name_filters; i++) { + if (strcmp(_chan_name_filters[i].name, name) == 0) { + // shift remaining entries down + for (int j = i; j < _num_chan_name_filters - 1; j++) { + _chan_name_filters[j] = _chan_name_filters[j + 1]; + } + _num_chan_name_filters--; + memset(&_chan_name_filters[_num_chan_name_filters], 0, sizeof(ChanNameFilter)); + return true; + } + } + return false; +} + +void MyMesh::loadChanBlacklist(const char* fname) { + // Load both hex prefix entries and #channel_name entries from the same file + loadBlacklist(fname, _chan_blacklist); // loads hex entries into _chan_blacklist + + // Now re-read the file to pick up #channel_name lines + _num_chan_name_filters = 0; + memset(_chan_name_filters, 0, sizeof(_chan_name_filters)); +#if defined(RP2040_PLATFORM) + File f = _fs->open(fname, "r"); +#else + File f = _fs->open(fname); +#endif + if (!f) return; + char line[40]; + int line_len = 0; + while (f.available() && _num_chan_name_filters < MAX_CHAN_NAME_FILTERS) { + int c = f.read(); + if (c < 0) break; + if (c == '\n' || c == '\r') { + if (line_len > 0 && line[0] == '#') { + line[line_len] = 0; + deriveChanNameFilter(_chan_name_filters[_num_chan_name_filters], line); + _num_chan_name_filters++; + } + line_len = 0; + } else if (line_len < (int)(sizeof(line) - 1)) { + line[line_len++] = (char)c; + } + } + // handle last line with no trailing newline + if (line_len > 0 && line[0] == '#' && _num_chan_name_filters < MAX_CHAN_NAME_FILTERS) { + line[line_len] = 0; + deriveChanNameFilter(_chan_name_filters[_num_chan_name_filters], line); + _num_chan_name_filters++; + } + f.close(); +} + +void MyMesh::saveChanBlacklist(const char* fname) { +#if defined(NRF52_PLATFORM) || defined(STM32_PLATFORM) + _fs->remove(fname); + File f = _fs->open(fname, FILE_O_WRITE); +#elif defined(RP2040_PLATFORM) + File f = _fs->open(fname, "w"); +#else + File f = _fs->open(fname, "w", true); +#endif + if (!f) return; + // Write hex prefix entries + for (int i = 0; i < MAX_BLACKLIST_ENTRIES; i++) { + if (_chan_blacklist[i].len == 0) continue; + char hex[MAX_PATH_PREFIX_LEN * 2 + 2]; + mesh::Utils::toHex(hex, _chan_blacklist[i].prefix, _chan_blacklist[i].len); + f.println(hex); + } + // Write #channel_name entries + for (int i = 0; i < _num_chan_name_filters; i++) { + f.println(_chan_name_filters[i].name); + } + f.close(); +} + +void MyMesh::formatChanBlacklist(char* reply) { + char* dp = reply; + char* end = reply + MAX_PACKET_PAYLOAD - 6; // leave room for null + safety + int count = 0; + // Format hex prefix entries + for (int i = 0; i < MAX_BLACKLIST_ENTRIES; i++) { + if (_chan_blacklist[i].len == 0) continue; + if (dp + _chan_blacklist[i].len * 2 + 1 >= end) break; + if (count > 0) *dp++ = '\n'; + mesh::Utils::toHex(dp, _chan_blacklist[i].prefix, _chan_blacklist[i].len); + dp += _chan_blacklist[i].len * 2; + count++; + } + // Format #channel_name entries + for (int i = 0; i < _num_chan_name_filters; i++) { + int len = strlen(_chan_name_filters[i].name); + if (dp + len + 1 >= end) break; + if (count > 0) *dp++ = '\n'; + memcpy(dp, _chan_name_filters[i].name, len); + dp += len; + count++; + } + if (count == 0) { + strcpy(reply, "-none-"); + } else { + *dp = 0; + } +} + bool MyMesh::filterRecvFloodPacket(mesh::Packet* pkt) { // just try to determine region for packet (apply later in allowPacketForward()) if (pkt->getRouteType() == ROUTE_TYPE_TRANSPORT_FLOOD) { @@ -558,6 +848,19 @@ bool MyMesh::filterRecvFloodPacket(mesh::Packet* pkt) { } else { recv_pkt_region = NULL; } + + // Drop packets whose path contains a blacklisted node prefix + if (isPathBlacklisted(pkt)) { + MESH_DEBUG_PRINTLN("filterRecvFloodPacket: path prefix blacklisted, dropping!"); + return true; + } + + // Drop group-channel packets whose channel hash is blacklisted + if (isChanBlacklisted(pkt)) { + MESH_DEBUG_PRINTLN("filterRecvFloodPacket: channel hash blacklisted, dropping!"); + return true; + } + // do normal processing return false; } @@ -864,6 +1167,11 @@ MyMesh::MyMesh(mesh::MainBoard &board, mesh::Radio &radio, mesh::MillisecondCloc _logging = false; region_load_active = false; + memset(_path_blacklist, 0, sizeof(_path_blacklist)); + memset(_chan_blacklist, 0, sizeof(_chan_blacklist)); + memset(_chan_name_filters, 0, sizeof(_chan_name_filters)); + _num_chan_name_filters = 0; + #if MAX_NEIGHBOURS memset(neighbours, 0, sizeof(neighbours)); #endif @@ -926,6 +1234,8 @@ void MyMesh::begin(FILESYSTEM *fs) { acl.load(_fs, self_id); // TODO: key_store.begin(); region_map.load(_fs); + loadBlacklist(PATH_BLACKLIST_FILE, _path_blacklist); + loadChanBlacklist(CHAN_BLACKLIST_FILE); // establish default-scope { @@ -1251,6 +1561,93 @@ void MyMesh::handleCommand(uint32_t sender_timestamp, char *command, char *reply sendNodeDiscoverReq(); strcpy(reply, "OK - Discover sent"); } + } else if (memcmp(command, "blacklist ", 10) == 0) { + // Commands: + // blacklist path list + // blacklist path add [,,...] (each hex = 2-8 even chars → 1-4 bytes) + // blacklist path rem [,,...] + // blacklist path clear + // blacklist chan list + // blacklist chan add [,,...] + // blacklist chan rem [,,...] + // blacklist chan clear + const char* parts[5]; + int n = mesh::Utils::parseTextParts(command, parts, 5, ' '); + + BlacklistEntry* list = NULL; + const char* list_file = NULL; + bool is_chan = false; + if (n >= 2 && strcmp(parts[1], "path") == 0) { + list = _path_blacklist; list_file = PATH_BLACKLIST_FILE; + } else if (n >= 2 && strcmp(parts[1], "chan") == 0) { + list = _chan_blacklist; list_file = CHAN_BLACKLIST_FILE; + is_chan = true; + } + + if (list && n >= 3 && strcmp(parts[2], "list") == 0) { + if (is_chan) { + formatChanBlacklist(reply); + } else { + formatBlacklist(list, reply); + } + } else if (list && n >= 3 && strcmp(parts[2], "clear") == 0) { + memset(list, 0, sizeof(BlacklistEntry) * MAX_BLACKLIST_ENTRIES); + if (is_chan) { + _num_chan_name_filters = 0; + memset(_chan_name_filters, 0, sizeof(_chan_name_filters)); + saveChanBlacklist(list_file); + } else { + saveBlacklist(list_file, list); + } + strcpy(reply, "OK"); + } else if (list && n >= 4 && (strcmp(parts[2], "add") == 0 || strcmp(parts[2], "rem") == 0)) { + bool is_add = (parts[2][0] == 'a'); + // parts[3] may be a comma-separated list of hex entries or #channel_name entries + char tokens[MAX_PATH_PREFIX_LEN * 2 * MAX_BLACKLIST_ENTRIES + MAX_BLACKLIST_ENTRIES + 2]; + strncpy(tokens, parts[3], sizeof(tokens) - 1); + tokens[sizeof(tokens) - 1] = 0; + + const char* tok_parts[MAX_BLACKLIST_ENTRIES]; + int tok_n = mesh::Utils::parseTextParts(tokens, tok_parts, MAX_BLACKLIST_ENTRIES, ','); + + bool any_ok = false, any_err = false; + for (int t = 0; t < tok_n; t++) { + if (is_chan && tok_parts[t][0] == '#') { + // #channel_name entry + bool ok = is_add ? addChanNameFilter(tok_parts[t]) + : removeChanNameFilter(tok_parts[t]); + if (ok) any_ok = true; else any_err = true; + } else { + int hex_str_len = strlen(tok_parts[t]); + if (hex_str_len < 2 || hex_str_len > MAX_PATH_PREFIX_LEN * 2 || (hex_str_len % 2) != 0) { + any_err = true; continue; + } + uint8_t prefix[MAX_PATH_PREFIX_LEN]; + int byte_len = hex_str_len / 2; + if (!mesh::Utils::fromHex(prefix, byte_len, tok_parts[t])) { + any_err = true; continue; + } + bool ok = is_add ? addToBlacklist(list, prefix, (uint8_t)byte_len) + : removeFromBlacklist(list, prefix, (uint8_t)byte_len); + if (ok) any_ok = true; else any_err = true; + } + } + + // auto-save on any successful mutation + if (any_ok) { + if (is_chan) { + saveChanBlacklist(list_file); + } else { + saveBlacklist(list_file, list); + } + } + + if (any_ok && !any_err) strcpy(reply, "OK"); + else if (any_ok && any_err) strcpy(reply, "OK (partial)"); + else strcpy(reply, is_add ? "Err - list full or bad input" : "Err - not found or bad input"); + } else { + strcpy(reply, "Err - usage: blacklist [hex|#name[,...]]"); + } } else{ _cli.handleCommand(sender_timestamp, command, reply); // common CLI commands } diff --git a/examples/simple_repeater/MyMesh.h b/examples/simple_repeater/MyMesh.h index 8dab87395..0466ed3ee 100644 --- a/examples/simple_repeater/MyMesh.h +++ b/examples/simple_repeater/MyMesh.h @@ -61,6 +61,21 @@ struct RepeaterStats { #define MAX_CLIENTS 32 #endif +#define MAX_PATH_PREFIX_LEN 4 +#define MAX_BLACKLIST_ENTRIES 16 +#define MAX_CHAN_NAME_FILTERS 8 + +struct BlacklistEntry { + uint8_t len; // 0 = empty slot + uint8_t prefix[MAX_PATH_PREFIX_LEN]; +}; + +struct ChanNameFilter { + uint8_t hash[PATH_HASH_SIZE]; + uint8_t secret[CIPHER_KEY_SIZE]; + char name[32]; +}; + struct NeighbourInfo { mesh::Identity id; uint32_t advert_timestamp; @@ -103,6 +118,10 @@ class MyMesh : public mesh::Mesh, public CommonCLICallbacks { unsigned long pending_discover_until; bool region_load_active; unsigned long dirty_contacts_expiry; + BlacklistEntry _path_blacklist[MAX_BLACKLIST_ENTRIES]; + BlacklistEntry _chan_blacklist[MAX_BLACKLIST_ENTRIES]; + ChanNameFilter _chan_name_filters[MAX_CHAN_NAME_FILTERS]; + int _num_chan_name_filters; #if MAX_NEIGHBOURS NeighbourInfo neighbours[MAX_NEIGHBOURS]; #endif @@ -130,6 +149,20 @@ class MyMesh : public mesh::Mesh, public CommonCLICallbacks { File openAppend(const char* fname); bool isLooped(const mesh::Packet* packet, const uint8_t max_counters[]); + bool isPathBlacklisted(const mesh::Packet* packet) const; + bool isChanBlacklisted(const mesh::Packet* packet) const; + void loadBlacklist(const char* fname, BlacklistEntry* list); + void saveBlacklist(const char* fname, const BlacklistEntry* list); + bool addToBlacklist(BlacklistEntry* list, const uint8_t* prefix, uint8_t len); + bool removeFromBlacklist(BlacklistEntry* list, const uint8_t* prefix, uint8_t len); + void formatBlacklist(const BlacklistEntry* list, char* reply); + void deriveChanNameFilter(ChanNameFilter& entry, const char* name); + bool addChanNameFilter(const char* name); + bool removeChanNameFilter(const char* name); + void loadChanBlacklist(const char* fname); + void saveChanBlacklist(const char* fname); + void formatChanBlacklist(char* reply); + protected: float getAirtimeBudgetFactor() const override { return _prefs.airtime_factor; From 5266ffcaf3ede4bf1f04d52a2254a6ddce4db858 Mon Sep 17 00:00:00 2001 From: overkillfpv Date: Mon, 20 Apr 2026 21:10:16 +1000 Subject: [PATCH 2/2] fix: update ChanNameFilter secret size to match PUB_KEY_SIZE --- examples/simple_repeater/MyMesh.cpp | 6 ++++-- examples/simple_repeater/MyMesh.h | 2 +- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/examples/simple_repeater/MyMesh.cpp b/examples/simple_repeater/MyMesh.cpp index 8513b717a..5b532d1de 100644 --- a/examples/simple_repeater/MyMesh.cpp +++ b/examples/simple_repeater/MyMesh.cpp @@ -709,11 +709,13 @@ void MyMesh::saveBlacklist(const char* fname, const BlacklistEntry* list) { /* ------------------- Channel name filter helpers -------------------------- */ void MyMesh::deriveChanNameFilter(ChanNameFilter& entry, const char* name) { - // Derive channel secret: first 16 bytes of sha256(name) + // Derive channel secret: first 16 bytes of sha256(name), zero-padded to PUB_KEY_SIZE + // to match GroupChannel.secret layout (MACThenDecrypt reads PUB_KEY_SIZE bytes) uint8_t full_hash[32]; mesh::Utils::sha256(full_hash, 32, (const uint8_t*)name, strlen(name)); + memset(entry.secret, 0, sizeof(entry.secret)); memcpy(entry.secret, full_hash, CIPHER_KEY_SIZE); - // Derive channel hash from the secret + // Derive channel hash from the secret (using 16-byte key length, matching addChannel) mesh::Utils::sha256(entry.hash, sizeof(entry.hash), entry.secret, CIPHER_KEY_SIZE); StrHelper::strncpy(entry.name, name, sizeof(entry.name)); } diff --git a/examples/simple_repeater/MyMesh.h b/examples/simple_repeater/MyMesh.h index 0466ed3ee..a0312bd07 100644 --- a/examples/simple_repeater/MyMesh.h +++ b/examples/simple_repeater/MyMesh.h @@ -72,7 +72,7 @@ struct BlacklistEntry { struct ChanNameFilter { uint8_t hash[PATH_HASH_SIZE]; - uint8_t secret[CIPHER_KEY_SIZE]; + uint8_t secret[PUB_KEY_SIZE]; char name[32]; };