Limit flood advert forwarding and persistent abuse

This commit is contained in:
mikecarper committed 2026-09-09 17:16:00 -07:00
1 parent 27ff4b9063
commit 23fe7c8a9d
15 files changed
+985 -6

No files matched your search

+11
View File
@@ -447,6 +447,17 @@ Elsewhere it replies `Err - neighbors not enabled in this build`. If a
## Statistics
### Clear Automatic Flood Advert History
**Usage:** `clear flood.advert all` or `clear flood.advert <64-hex-full-public-key>`
Clears automatic advert forwarding quotas, first strikes, and seven-day bad-list
history for all keys or one exact full key. Available on repeaters, room servers,
and forwarding sensors through their ordinary local/admin CLI (including admin
LoRa CLI). A missing selector, a key prefix, or a malformed key is rejected.
It does not change saved settings or manual flood rules. Reboot also clears this
RAM-only history. See [automatic advert limits](flood_filtering.md#automatic-flood-advert-limits).
### Clear Stats
**Usage:** `clear stats`
+96
View File
@@ -20,6 +20,102 @@ The route and payload values follow the upstream
[payload layouts](https://docs.meshcore.io/payloads/), with this fork's LoRa
OTA assignment noted below.
## Automatic flood-advert limits
Repeaters, room servers (including non-FULL builds), and forwarding sensors
automatically limit relayed `ADVERT` packets. No CLI rule is needed. Companions
and the repeater's receive-only MQTT observer do not allocate this history.
This is separate from discovery-response limits, self-advert timers, hop caps,
and the per-rule `rate=N/min` setting below.
The ordinary quota is shared by the **first 12 public-key hex characters**
(six bytes of the advertised origin key, not a path hash). Each prefix has a
fixed 180-minute window starting with its first tracked valid flood advert.
| Shortest received hops in that window | Distinct adverts forwarded per 180 minutes |
| --- | --- |
| 0–1 | 10 |
| 2 | 9 |
| 3 | 8 |
| 4 | 6 |
| 5 | 5 |
| 6 | 4 |
| 7 | 3 |
| 8+ | 2 |
The minimum received hop **count**, before adding this relay, sets the quota;
one-, two-, and three-byte path hashes have the same policy. A shorter valid
duplicate may increase the quota without restarting the window or clearing
its count. Longer routes never decrease the allowance during that window.
The payload signature must verify before any history changes. Path metadata
itself is not signed, so this is a received-distance heuristic, not proof of
physical distance.
### Continuing abuse and recovery
- A full public key that exceeds the normal level in one window receives a
first strike. If it exceeds that level again in the immediately following
window, it enters the bad list. A quiet intervening window breaks this chain.
- The bad list matches **all 32 public-key bytes**, not the prefix. Different
full keys sharing a prefix share the ordinary forwarding quota, but their
received counts are not combined to accuse an innocent key of ongoing abuse.
- A bad-listed key may have at most one distinct advert forwarded every
**12 hours**, measured from its last admitted forward (including before
escalation). Ordinary forwarding filters still apply.
- Removal requires **seven uninterrupted days without exceeding the normal
hop-based level**. Recovery is measured against received distinct adverts,
not the much smaller number allowed to be forwarded. Silence also qualifies.
- Each over-limit window restarts recovery from that window's end. Continued
abuse can therefore extend the restriction indefinitely; it does not expire
automatically seven days after the first offence.
All timers use rollover-safe uptime, so setting the clock forward or backward
cannot clear the restriction. This history is currently RAM-only: rebooting
the relay clears it, and time while powered off is not tracked. A manual clear
also resets both the ordinary quota and abuse history for its selected target:
```text
clear flood.advert all
clear flood.advert <64-hex-full-public-key>
```
These use the ordinary local/admin CLI authorization, including authenticated
admin LoRa CLI. A prefix is never accepted for a targeted clear; missing or
invalid selectors do not clear anything. Clearing an exact key preserves other
full keys even when their 12-hex prefixes match. No preferences, contacts,
neighbours, replay timestamps, or manually configured flood rules are erased.
### Scope and resource bounds
Only forwarding is suppressed. Valid adverts remain available to local
contact/neighbour handling and can still appear in raw RX logs or MQTT. This
cannot prevent the origin or upstream repeaters from transmitting. Direct
adverts, discovery control responses, messages, OTA packets, and this node's
own scheduled adverts are not charged. Existing physical retry attempts of
an admitted advert are not additional **distinct** adverts.
The limiter retains up to eleven 64-bit payload hashes per full key per
window: ten possible ordinary admissions plus evidence of exceeding the
largest quota. Paths and transport scopes do not change these hashes.
Retained duplicate hashes cannot consume additional quota even if the general
packet-seen cache has evicted them. Once the eleven distinct receive slots are
full, additional unretained payloads are suppressed until the next window.
Only packets passing all ordinary forwarding gates consume forwarding quota;
valid received traffic still supplies abuse evidence when another gate blocks it.
The default is 128 full-key slots (18,432 bytes of entry storage plus small
bookkeeping), 96 on nRF52 (13,824 bytes, preserving the mOTA runtime RAM
reserve), or eight on RAM-constrained STM32 builds (1,152 bytes).
`FLOOD_ADVERT_SOURCE_SLOTS` overrides the capacity at compile time. When full,
the **least-recently-heard normal key is removed first**. Valid retained
duplicates update last-heard too. Evicting a normal key discards its ordinary
quota history, so capacity should still be sized for the deployment's active
origins. First-strike evidence and bad-list entries are protected from eviction:
if every slot protects abuse history, untracked origins are not forwarded until
a slot expires or an administrator clears history. Table churn cannot erase an
existing bad-list penalty. No heap allocation or per-packet filesystem writes
are used.
## Before making changes
On a repeater, show the current forwarding controls:
+4
View File
@@ -299,6 +299,10 @@ class MyMesh : public mesh::Mesh, public CommonCLICallbacks
};
FILESYSTEM* _fs;
#ifndef PORTABLE_MQTT_OBSERVER
mesh::StaticFloodAdvertLimiter<> flood_advert_limiter;
mesh::FloodAdvertLimiter* getFloodAdvertLimiter() override { return &flood_advert_limiter; }
#endif
#if defined(WITH_WEBCONFIG) || defined(ETHERNET_ENABLED)
#ifdef NRF52_PLATFORM
mesh::LocalCliOutput<File> _local_cli_output{File(InternalFS)};
+2
View File
@@ -159,6 +159,8 @@ class MyMesh : public mesh::Mesh, public CommonCLICallbacks,
#endif
{
FILESYSTEM* _fs;
mesh::StaticFloodAdvertLimiter<> flood_advert_limiter;
mesh::FloodAdvertLimiter* getFloodAdvertLimiter() override { return &flood_advert_limiter; }
#if defined(WITH_WEBCONFIG) || defined(ETHERNET_ENABLED)
#ifdef NRF52_PLATFORM
mesh::LocalCliOutput<File> _local_cli_output{File(InternalFS)};
+2
View File
@@ -52,6 +52,8 @@
class SensorMesh : public mesh::Mesh, public CommonCLICallbacks,
public mesh::MeshClockSyncCallbacks {
mesh::StaticFloodAdvertLimiter<> flood_advert_limiter;
mesh::FloodAdvertLimiter* getFloodAdvertLimiter() override { return &flood_advert_limiter; }
public:
SensorMesh(mesh::MainBoard& board, mesh::Radio& radio, mesh::MillisecondClock& ms, mesh::RNG& rng, mesh::RTCClock& rtc, mesh::MeshTables& tables);
void begin(FILESYSTEM* fs);
+1
View File
@@ -379,6 +379,7 @@ build_src_filter =
+<../src/Mesh.cpp>
+<../src/helpers/TxtDataHelpers.cpp>
+<../src/helpers/StaticPoolPacketManager.cpp>
+<../src/helpers/FloodAdvertLimiter.cpp>
+<../src/helpers/ota/MerkleTree.cpp>
+<../src/helpers/ota/MotaContainer.cpp>
+<../src/helpers/ota/FirmwareInfo.cpp>
+45 -6
View File
@@ -192,6 +192,7 @@ uint8_t Mesh::getOtaHopLimit() const { return ota::ota_hop_limit(); }
#endif
void Mesh::begin() {
if (auto* limiter = getFloodAdvertLimiter()) limiter->reset();
_active_direct_retry_count = 0;
_active_flood_retry_count = 0;
_waiting_direct_retry_count = 0;
@@ -285,6 +286,7 @@ bool Mesh::hasPendingOtaApply() const {
}
void __attribute__((noinline)) Mesh::serviceLoopMaintenance() {
if (auto* limiter = getFloodAdvertLimiter()) limiter->tick(_ms->getMillis());
if (_waiting_direct_retry_count != 0
&& millisHasNowPassed(_next_direct_retry_timeout)) {
for (int i = 0; i < MAX_DIRECT_RETRY_SLOTS; i++) {
@@ -1006,6 +1008,13 @@ DispatcherAction Mesh::onRecvPacket(Packet* pkt) {
MESH_DEBUG_PRINTLN("%s Mesh::onRecvPacket(): incomplete advertisement packet", getLogDateTime());
break;
}
// Do not accept unsigned trailing data by truncating it for signature
// verification. An attacker could vary that tail to manufacture distinct
// packet hashes and falsely accumulate abuse against an authentic key.
if (pkt->payload_len > min_advert_len + MAX_ADVERT_DATA_SIZE) {
MESH_DEBUG_PRINTLN("%s Mesh::onRecvPacket(): oversized advertisement packet", getLogDateTime());
break;
}
int i = 0;
Identity id;
@@ -1017,11 +1026,21 @@ DispatcherAction Mesh::onRecvPacket(Packet* pkt) {
if (self_id.matches(id.pub_key)) {
MESH_DEBUG_PRINTLN("%s Mesh::onRecvPacket(): receiving SELF advert packet", getLogDateTime());
} else if (!_tables->wasSeen(pkt)) {
_tables->markSeen(pkt);
} else {
const bool seen = _tables->wasSeen(pkt);
auto* limiter = pkt->isRouteFlood() ? getFloodAdvertLimiter() : nullptr;
const uint32_t now = _ms->getMillis();
if (seen && limiter) {
uint8_t hash[MAX_HASH_SIZE];
pkt->calculatePacketHash(hash);
limiter->noteKnownCopy(id.pub_key, hash, now);
}
// Even a duplicate can reveal a shorter route. Re-verify its signature
// before using that evidence, without delivering or relaying it twice.
if (seen && !(limiter && limiter->needsShorterPath(id.pub_key, pkt->getPathHashCount(), now))) break;
if (!seen) _tables->markSeen(pkt);
uint8_t* app_data = &pkt->payload[i];
int app_data_len = pkt->payload_len - i;
if (app_data_len > MAX_ADVERT_DATA_SIZE) { app_data_len = MAX_ADVERT_DATA_SIZE; }
// check that signature is valid
bool is_ok;
@@ -1036,8 +1055,15 @@ DispatcherAction Mesh::onRecvPacket(Packet* pkt) {
}
if (is_ok) {
MESH_DEBUG_PRINTLN("%s Mesh::onRecvPacket(): valid advertisement received!", getLogDateTime());
onAdvertRecv(pkt, id, timestamp, app_data, app_data_len);
action = routeRecvPacket(pkt);
if (limiter) {
uint8_t hash[MAX_HASH_SIZE];
pkt->calculatePacketHash(hash);
limiter->observe(id.pub_key, hash, pkt->getPathHashCount(), now, seen);
}
if (!seen) {
onAdvertRecv(pkt, id, timestamp, app_data, app_data_len);
action = routeRecvPacket(pkt);
}
} else {
MESH_DEBUG_PRINTLN("%s Mesh::onRecvPacket(): received advertisement with forged signature! (app_data_len=%d)", getLogDateTime(), app_data_len);
}
@@ -1158,7 +1184,20 @@ DispatcherAction Mesh::routeRecvPacket(Packet* packet) {
uint8_t n = packet->getPathHashCount();
if (packet->isRouteFlood() && !packet->isMarkedDoNotRetransmit()
&& (n + 1)*packet->getPathHashSize() <= MAX_PATH_SIZE && allowPacketForward(packet)) {
&& (n + 1)*packet->getPathHashSize() <= MAX_PATH_SIZE) {
auto* limiter = packet->getPayloadType() == PAYLOAD_TYPE_ADVERT ? getFloodAdvertLimiter() : nullptr;
uint8_t hash[MAX_HASH_SIZE];
const uint32_t now = _ms->getMillis();
if (limiter) {
packet->calculatePacketHash(hash);
auto decision = limiter->check(packet->payload, hash, now);
if (decision != FloodAdvertLimiter::Decision::Allow) {
MESH_DEBUG_PRINTLN("%s Mesh::routeRecvPacket(): advert limited (reason=%u)", getLogDateTime(), unsigned(decision));
return ACTION_RELEASE;
}
}
if (!allowPacketForward(packet)) return ACTION_RELEASE;
if (limiter) limiter->commit(packet->payload, hash, now);
// append this node's hash to 'path'
self_id.copyHashTo(&packet->path[n * packet->getPathHashSize()], packet->getPathHashSize());
packet->setPathHashCount(n + 1);
+4
View File
@@ -1,6 +1,7 @@
#pragma once
#include <Dispatcher.h>
#include <helpers/FloodAdvertLimiter.h>
// OTA-over-LoRa transport is understood by every Mesh role even when the OTA manager/installer is not
// compiled in. Repeaters can therefore relay PAYLOAD_TYPE_OTA opaquely while they are on TempRadio.
@@ -228,6 +229,9 @@ protected:
*/
virtual bool allowPacketForward(const Packet* packet);
// Forwarding roles own the RAM; companions need no advert-limiter table.
virtual FloodAdvertLimiter* getFloodAdvertLimiter() { return nullptr; }
/**
* \returns number of milliseconds delay to apply to retransmitting the given packet.
*/
+2
View File
@@ -1,6 +1,7 @@
#include <Arduino.h>
#include "CommonCLI.h"
#include "CLICommandUtils.h"
#include "FloodAdvertCLI.h"
#include "StorageLayout.h"
#include "radiolib/RadioPowerLimits.h"
#include "radiolib/LR2021SideDetectorConfig.h"
@@ -2460,6 +2461,7 @@ void CommonCLI::handleCommand(uint32_t sender_timestamp, char* command, char* re
// Observer-only top-level commands (ota check/update, tls.bundletest, alert test)
// live in CommonCLI_Observer.cpp.
if (handleObserverCommand(sender_timestamp, command, reply)) return;
if (mesh::cli::handleFloodAdvertClear(_callbacks->getFloodAdvertLimiter(), command, reply)) return;
if (memcmp(command, "poweroff", 8) == 0 || memcmp(command, "shutdown", 8) == 0) {
_board->powerOff(); // doesn't return
} else if (memcmp(command, "reboot", 6) == 0) {
+1
View File
@@ -446,6 +446,7 @@ struct LegacyObserverTail {
class CommonCLICallbacks {
public:
virtual mesh::FloodAdvertLimiter* getFloodAdvertLimiter() { return nullptr; }
// Ordinary CommonCLI setters mutate NodePrefs and therefore save only the
// common image. Observer setters explicitly request Observer; only
// cross-file migration and mixed-owner setters request Both.
+44
View File
@@ -0,0 +1,44 @@
#pragma once
#include "FloodAdvertLimiter.h"
#include <Utils.h>
namespace mesh {
namespace cli {
// Dispatch only after the role's ordinary CLI authorization checks. Requiring
// an explicit "all" avoids treating missing/malformed keys as a global reset.
inline bool handleFloodAdvertClear(FloodAdvertLimiter* limiter, const char* command, char* reply) {
static const char prefix[] = "clear flood.advert";
const size_t prefix_len = sizeof(prefix) - 1;
if (strncmp(command, prefix, prefix_len) != 0) return false;
const char* arg = command + prefix_len;
if (*arg && *arg != ' ' && *arg != '\t') return false;
while (*arg == ' ' || *arg == '\t') ++arg;
const char* end = arg;
while (*end && *end != ' ' && *end != '\t' && *end != '\r' && *end != '\n') ++end;
const size_t len = end - arg;
while (*end == ' ' || *end == '\t' || *end == '\r' || *end == '\n') ++end;
uint8_t key[PUB_KEY_SIZE];
const bool all = len == 3 && strncmp(arg, "all", 3) == 0;
bool valid = all;
if (len == PUB_KEY_SIZE * 2) {
char hex[PUB_KEY_SIZE * 2 + 1];
memcpy(hex, arg, len);
hex[len] = 0;
valid = Utils::fromHex(key, PUB_KEY_SIZE, hex);
}
if (*end || !valid) {
strcpy(reply, "ERR: clear flood.advert all|<64-hex-key>");
} else if (!limiter) {
strcpy(reply, "ERR: advert limiter unavailable on this role");
} else {
if (all) limiter->reset();
else limiter->clear(key);
strcpy(reply, all ? "OK - all advert limit history cleared" : "OK - key advert limit history cleared");
}
return true;
}
} // namespace cli
} // namespace mesh
+186
View File
@@ -0,0 +1,186 @@
#include "FloodAdvertLimiter.h"
namespace mesh {
uint8_t FloodAdvertLimiter::allowance(uint8_t hops) {
static const uint8_t limits[] = {10, 10, 9, 8, 6, 5, 4, 3, 2};
return limits[hops < 8 ? hops : 8];
}
void FloodAdvertLimiter::reset() {
for (size_t i = 0; i < _capacity; ++i) _entries[i].flags = 0;
_last_sweep = 0;
}
FloodAdvertLimiter::Entry* FloodAdvertLimiter::find(const uint8_t* key) {
for (size_t i = 0; i < _capacity; ++i) {
if ((_entries[i].flags & ACTIVE) && memcmp(_entries[i].key, key, PUB_KEY_SIZE) == 0) {
return &_entries[i];
}
}
return nullptr;
}
void FloodAdvertLimiter::clear(const uint8_t* full_key) {
Entry* entry = find(full_key);
if (entry) entry->flags = 0;
}
int FloodAdvertLimiter::hashIndex(const Entry& entry, const uint8_t* hash) {
for (uint8_t i = 0; i < entry.count; ++i) {
if (memcmp(entry.hashes[i], hash, MAX_HASH_SIZE) == 0) return i;
}
return -1;
}
void FloodAdvertLimiter::advance(Entry& entry, uint32_t now) {
if (!(entry.flags & ACTIVE)) return;
if ((entry.flags & HAS_FORWARDED) && uint32_t(now - entry.last_forwarded) >= BAD_INTERVAL_MS) {
entry.flags &= ~HAS_FORWARDED;
}
uint32_t elapsed = now - entry.window_start;
if (elapsed >= WINDOW_MS) {
const bool over = entry.count > allowance(entry.min_hops);
if (over) {
// Recovery starts after the last over-limit window, not after the last
// forwarded packet. Suppressed RX traffic still counts as abuse.
entry.last_violation = entry.window_start + WINDOW_MS;
}
entry.flags &= ~PREVIOUS_OVER;
if (over && elapsed < 2 * WINDOW_MS) entry.flags |= PREVIOUS_OVER;
entry.window_start += (elapsed / WINDOW_MS) * WINDOW_MS;
entry.count = 0;
entry.forwarded = 0;
entry.min_hops = 255;
// Quiet ordinary sources need no history beyond their three-hour window.
// Keep a first strike through the next window; never evict a bad entry.
if (!(entry.flags & (BAD | PREVIOUS_OVER))) entry.flags = 0;
}
if ((entry.flags & BAD) && uint32_t(now - entry.last_violation) >= RECOVERY_MS) {
entry.flags &= ~BAD;
if (entry.count == 0 && !(entry.flags & PREVIOUS_OVER)) entry.flags = 0;
}
}
void FloodAdvertLimiter::refresh(uint32_t now) {
for (size_t i = 0; i < _capacity; ++i) advance(_entries[i], now);
_last_sweep = now;
}
void FloodAdvertLimiter::tick(uint32_t now) {
// Periodic expiry prevents stale history resurrecting after a millis wrap,
// even when no adverts arrive for weeks. Receive operations also expire it
// immediately at the exact window/recovery boundary.
if (uint32_t(now - _last_sweep) >= 60000UL) refresh(now);
}
bool FloodAdvertLimiter::needsShorterPath(const uint8_t* key, uint8_t hops, uint32_t now) {
refresh(now);
Entry* entry = find(key);
return entry && hops < entry->min_hops;
}
void FloodAdvertLimiter::noteKnownCopy(const uint8_t* key, const uint8_t* hash, uint32_t now) {
refresh(now);
Entry* entry = find(key);
// Only the exact retained hash of a previously verified payload can refresh
// last-heard without another signature check. Never trust just the key/path.
if (entry && hashIndex(*entry, hash) >= 0) entry->last_heard = now;
}
void FloodAdvertLimiter::observe(const uint8_t* key, const uint8_t* hash, uint8_t hops,
uint32_t now, bool previously_seen) {
refresh(now);
Entry* entry = find(key);
if (!entry) {
if (previously_seen) return;
uint32_t start = now;
// Keys sharing the first 12 hex characters share a normal quota AND its epoch.
for (size_t i = 0; i < _capacity; ++i) {
if ((_entries[i].flags & ACTIVE) && memcmp(_entries[i].key, key, PREFIX_BYTES) == 0) {
start = _entries[i].window_start;
if (_entries[i].min_hops < hops) hops = _entries[i].min_hops;
break;
}
}
Entry* oldest = nullptr;
for (size_t i = 0; i < _capacity; ++i) {
if (!(_entries[i].flags & ACTIVE)) {
entry = &_entries[i];
break;
}
// Preserve both bad-list entries and first-strike evidence. Evict only
// normal entries, least recently heard first, using rollover-safe ages.
const Entry& candidate = _entries[i];
if (!(candidate.flags & (BAD | PREVIOUS_OVER))
&& candidate.count <= allowance(candidate.min_hops)
&& (!oldest || uint32_t(now - candidate.last_heard) > uint32_t(now - oldest->last_heard))) {
oldest = &_entries[i];
}
}
if (!entry) entry = oldest;
if (!entry) return; // all slots protect abuse history: fail closed
memset(entry, 0, sizeof(*entry));
memcpy(entry->key, key, PUB_KEY_SIZE);
entry->window_start = start;
entry->min_hops = hops;
entry->flags = ACTIVE;
}
entry->last_heard = now;
// The normal level belongs to the prefix, including its shortest RX path.
// Keep that minimum on every colliding full key for consistent window-close
// abuse accounting, but never combine their received counts into a strike.
if (hops < entry->min_hops) entry->min_hops = hops;
for (size_t i = 0; i < _capacity; ++i) {
if ((_entries[i].flags & ACTIVE) && memcmp(_entries[i].key, key, PREFIX_BYTES) == 0
&& hops < _entries[i].min_hops) _entries[i].min_hops = hops;
}
if (previously_seen || hashIndex(*entry, hash) >= 0 || entry->count == HASH_SLOTS) return;
memcpy(entry->hashes[entry->count++], hash, MAX_HASH_SIZE);
if (entry->count > allowance(entry->min_hops)) {
entry->last_violation = now;
if (entry->flags & PREVIOUS_OVER) entry->flags |= BAD;
}
}
FloodAdvertLimiter::Decision FloodAdvertLimiter::check(const uint8_t* key, const uint8_t* hash,
uint32_t now) {
refresh(now);
Entry* entry = find(key);
if (!entry) return Decision::Capacity;
int idx = hashIndex(*entry, hash);
if (idx < 0) return Decision::Quota; // saturated receive history is fail-closed
if (entry->forwarded & (1U << idx)) return Decision::Duplicate;
if ((entry->flags & (BAD | HAS_FORWARDED)) == (BAD | HAS_FORWARDED)
&& uint32_t(now - entry->last_forwarded) < BAD_INTERVAL_MS) return Decision::BadList;
uint8_t hops = entry->min_hops;
unsigned forwarded = 0;
for (size_t i = 0; i < _capacity; ++i) {
const Entry& other = _entries[i];
if (!(other.flags & ACTIVE) || memcmp(other.key, key, PREFIX_BYTES) != 0) continue;
if (other.min_hops < hops) hops = other.min_hops;
for (uint16_t bits = other.forwarded; bits; bits &= bits - 1) ++forwarded;
}
return forwarded < allowance(hops) ? Decision::Allow : Decision::Quota;
}
void FloodAdvertLimiter::commit(const uint8_t* key, const uint8_t* hash, uint32_t now) {
// Caller has passed check() and ALL other forwarding gates, before appending
// its own path hop. Retries of this same advert are not new distinct adverts.
Entry* entry = find(key);
if (!entry) return;
int idx = hashIndex(*entry, hash);
if (idx < 0) return;
entry->forwarded |= uint16_t(1U << idx);
entry->last_forwarded = now;
entry->flags |= HAS_FORWARDED;
}
bool FloodAdvertLimiter::isBad(const uint8_t* key, uint32_t now) {
refresh(now);
Entry* entry = find(key);
return entry && (entry->flags & BAD);
}
} // namespace mesh
+82
View File
@@ -0,0 +1,82 @@
#pragma once
#include <MeshCore.h>
#include <string.h>
#ifndef FLOOD_ADVERT_SOURCE_SLOTS
#if defined(STM32_PLATFORM)
#define FLOOD_ADVERT_SOURCE_SLOTS 8
#elif defined(NRF52_PLATFORM)
// Preserve the runtime heap reserve alongside the fixed 64 KiB mOTA arena.
#define FLOOD_ADVERT_SOURCE_SLOTS 96
#else
#define FLOOD_ADVERT_SOURCE_SLOTS 128
#endif
#endif
namespace mesh {
// Volatile, bounded receive history. Only forwarding roles own this table.
// All times are uint32_t uptime milliseconds, never advert timestamps or RTC.
class FloodAdvertLimiter {
public:
static constexpr uint32_t WINDOW_MS = 180UL * 60UL * 1000UL;
static constexpr uint32_t BAD_INTERVAL_MS = 12UL * 60UL * 60UL * 1000UL;
static constexpr uint32_t RECOVERY_MS = 7UL * 24UL * 60UL * 60UL * 1000UL;
static constexpr uint8_t HASH_SLOTS = 11; // maximum normal allowance + evidence of excess
static constexpr uint8_t PREFIX_BYTES = 6; // first 12 public-key hex characters
enum class Decision : uint8_t { Allow, Duplicate, Quota, BadList, Capacity };
struct Entry {
uint8_t key[PUB_KEY_SIZE];
uint8_t hashes[HASH_SLOTS][MAX_HASH_SIZE];
uint32_t window_start;
uint32_t last_violation;
uint32_t last_forwarded;
uint32_t last_heard;
uint16_t forwarded;
uint8_t min_hops;
uint8_t count;
uint8_t flags;
};
void reset();
void clear(const uint8_t* full_key);
void tick(uint32_t now);
static uint8_t allowance(uint8_t hops);
bool needsShorterPath(const uint8_t* key, uint8_t hops, uint32_t now);
void noteKnownCopy(const uint8_t* key, const uint8_t* hash, uint32_t now);
// Call ONLY after signature validation. Previously seen copies may improve
// the minimum path, but cannot add evidence or consume forwarding quota.
void observe(const uint8_t* key, const uint8_t* hash, uint8_t hops,
uint32_t now, bool previously_seen = false);
Decision check(const uint8_t* key, const uint8_t* hash, uint32_t now);
void commit(const uint8_t* key, const uint8_t* hash, uint32_t now);
bool isBad(const uint8_t* key, uint32_t now);
protected:
FloodAdvertLimiter(Entry* entries, size_t capacity) : _entries(entries), _capacity(capacity) {}
FloodAdvertLimiter(const FloodAdvertLimiter&) = delete;
FloodAdvertLimiter& operator=(const FloodAdvertLimiter&) = delete;
private:
enum : uint8_t { ACTIVE = 1, PREVIOUS_OVER = 2, BAD = 4, HAS_FORWARDED = 8 };
Entry* _entries;
size_t _capacity;
uint32_t _last_sweep = 0;
Entry* find(const uint8_t* key);
static int hashIndex(const Entry& entry, const uint8_t* hash);
static void advance(Entry& entry, uint32_t now);
void refresh(uint32_t now);
};
template<size_t Capacity = FLOOD_ADVERT_SOURCE_SLOTS>
class StaticFloodAdvertLimiter : public FloodAdvertLimiter {
static_assert(Capacity > 0, "Advert limiter needs at least one source slot");
Entry _storage[Capacity];
public:
StaticFloodAdvertLimiter() : FloodAdvertLimiter(_storage, Capacity) { reset(); }
};
} // namespace mesh
@@ -0,0 +1,312 @@
#include <gtest/gtest.h>
#include <helpers/FloodAdvertLimiter.h>
#include <helpers/FloodAdvertCLI.h>
#include <array>
using Limiter = mesh::FloodAdvertLimiter;
using Decision = Limiter::Decision;
static constexpr uint32_t W = Limiter::WINDOW_MS;
static constexpr uint32_t H12 = Limiter::BAD_INTERVAL_MS;
static constexpr uint32_t D7 = Limiter::RECOVERY_MS;
class FloodAdvertLimit : public ::testing::Test {
protected:
mesh::StaticFloodAdvertLimiter<4> limiter;
std::array<uint8_t, PUB_KEY_SIZE> key{{0xBA, 0xDB, 0xEE, 0x5C}};
Decision receive(unsigned seq, uint8_t hops, uint32_t now,
const uint8_t* source = nullptr, bool forward = true) {
uint8_t hash[MAX_HASH_SIZE] = {};
memcpy(hash, &seq, sizeof(seq));
if (!source) source = key.data();
limiter.observe(source, hash, hops, now);
Decision result = limiter.check(source, hash, now);
if (forward && result == Decision::Allow) limiter.commit(source, hash, now);
return result;
}
void burst(uint32_t now, uint8_t hops = 8, unsigned count = 3) {
for (unsigned i = 0; i < count; ++i) receive(i, hops, now);
}
void bad(uint32_t start = 0) {
burst(start);
EXPECT_FALSE(limiter.isBad(key.data(), start));
burst(start + W);
ASSERT_TRUE(limiter.isBad(key.data(), start + W));
}
};
TEST_F(FloodAdvertLimit, EveryHopAllowanceAndSaturation) {
const uint8_t expected[] = {10, 10, 9, 8, 6, 5, 4, 3, 2, 2, 2};
for (uint8_t hops = 0; hops < sizeof(expected); ++hops) {
limiter.reset();
EXPECT_EQ(expected[hops], Limiter::allowance(hops));
for (unsigned i = 0; i < expected[hops]; ++i) EXPECT_EQ(Decision::Allow, receive(i, hops, 0));
EXPECT_EQ(Decision::Quota, receive(expected[hops], hops, 0));
}
EXPECT_EQ(2, Limiter::allowance(63));
EXPECT_EQ(144U, sizeof(Limiter::Entry));
}
TEST_F(FloodAdvertLimit, PrefixIsSixBytesAndCollisionsShareQuota) {
auto collision = key;
collision[31] = 1;
EXPECT_EQ(Decision::Allow, receive(1, 8, 0));
EXPECT_EQ(Decision::Allow, receive(2, 8, 100, collision.data()));
EXPECT_EQ(Decision::Quota, receive(3, 8, 200, collision.data()));
auto separate = key;
separate[5] ^= 1;
EXPECT_EQ(Decision::Allow, receive(4, 8, 200, separate.data()));
EXPECT_EQ(Decision::Allow, receive(5, 8, W, collision.data()));
}
TEST_F(FloodAdvertLimit, ShortestPathRaisesQuotaWithoutResettingCountOrEpoch) {
EXPECT_EQ(Decision::Allow, receive(1, 8, 0));
EXPECT_EQ(Decision::Allow, receive(2, 9, 1));
EXPECT_EQ(Decision::Quota, receive(3, 10, 2));
uint8_t hash[MAX_HASH_SIZE] = {1};
ASSERT_TRUE(limiter.needsShorterPath(key.data(), 1, 10));
limiter.observe(key.data(), hash, 1, 10, true);
EXPECT_EQ(Decision::Duplicate, limiter.check(key.data(), hash, 10));
for (unsigned i = 4; i < 12; ++i) EXPECT_EQ(Decision::Allow, receive(i, 8, 10));
EXPECT_EQ(Decision::Quota, receive(12, 8, W - 1));
EXPECT_EQ(Decision::Allow, receive(13, 8, W));
EXPECT_FALSE(limiter.isBad(key.data(), W));
}
TEST_F(FloodAdvertLimit, DuplicateCopiesDoNotConsumeQuotaOrBecomeAbuse) {
for (uint32_t window = 0; window < 10; ++window) {
for (int copy = 0; copy < 300; ++copy) {
Decision result = receive(1, 8, window * W);
EXPECT_EQ(copy == 0 ? Decision::Allow : Decision::Duplicate, result);
}
EXPECT_FALSE(limiter.isBad(key.data(), window * W));
EXPECT_EQ(Decision::Allow, receive(2, 8, window * W));
}
}
TEST_F(FloodAdvertLimit, AtTheLimitIsNotAbuseAndSingleBurstDoesNotEscalate) {
for (unsigned window = 0; window < 5; ++window) {
burst(window * W, 8, 2);
EXPECT_FALSE(limiter.isBad(key.data(), window * W));
}
limiter.reset();
burst(0);
burst(2 * W); // a whole quiet window interrupts continuing abuse
EXPECT_FALSE(limiter.isBad(key.data(), 2 * W));
}
TEST_F(FloodAdvertLimit, BadListIsFullKeyNotPrefixAndUsesTwelveHourSpacing) {
bad();
auto collision = key;
collision[31] = 1;
EXPECT_FALSE(limiter.isBad(collision.data(), W));
EXPECT_EQ(Decision::BadList, receive(4, 8, 2 * W));
EXPECT_EQ(Decision::Allow, receive(1, 8, 2 * W, collision.data()));
EXPECT_EQ(Decision::BadList, receive(5, 8, W + H12 - 1));
EXPECT_EQ(Decision::Allow, receive(6, 8, W + H12));
EXPECT_EQ(Decision::BadList, receive(7, 8, W + H12 + 1));
}
TEST_F(FloodAdvertLimit, AggregatePrefixExcessDoesNotBlameAnInnocentFullKey) {
auto collision = key;
collision[31] = 1;
for (unsigned window = 0; window < 3; ++window) {
burst(window * W, 8, 2);
receive(1, 8, window * W, collision.data());
receive(2, 8, window * W, collision.data());
EXPECT_FALSE(limiter.isBad(key.data(), window * W));
EXPECT_FALSE(limiter.isBad(collision.data(), window * W));
}
}
TEST_F(FloodAdvertLimit, ShortestPrefixPathAlsoDefinesTheNormalRecoveryLevel) {
auto collision = key;
collision[31] = 1;
for (unsigned window = 0; window < 3; ++window) {
receive(1, 1, window * W, collision.data());
burst(window * W, 8, 3);
EXPECT_FALSE(limiter.isBad(key.data(), window * W));
}
}
TEST_F(FloodAdvertLimit, RecoveryRequiresSevenDaysBelowNormalNotBelowPunitiveLimit) {
bad();
// Two received adverts each three hours is within the normal 8-hop budget,
// even though many cannot be forwarded under the punitive 12-hour limit.
for (uint32_t now = 2 * W; now < 2 * W + D7; now += W) {
EXPECT_TRUE(limiter.isBad(key.data(), now));
burst(now, 8, 2);
}
EXPECT_TRUE(limiter.isBad(key.data(), 2 * W + D7 - 1));
EXPECT_FALSE(limiter.isBad(key.data(), 2 * W + D7));
EXPECT_EQ(Decision::Allow, receive(50, 8, 2 * W + D7));
}
TEST_F(FloodAdvertLimit, OngoingSuppressedAbuseExtendsRecoveryIndefinitely) {
bad();
// More than one millis rollover and many seven-day periods of continuous abuse.
for (uint64_t elapsed = 2ULL * W; elapsed < 80ULL * 24 * 60 * 60 * 1000; elapsed += W) {
uint32_t now = uint32_t(elapsed);
burst(now);
EXPECT_TRUE(limiter.isBad(key.data(), now));
}
}
TEST_F(FloodAdvertLimit, RelapseOnDaySixRestartsEntireRecoveryPeriod) {
bad();
const uint32_t relapse = 2 * W + D7 - 8 * W;
burst(relapse);
EXPECT_TRUE(limiter.isBad(key.data(), 2 * W + D7));
EXPECT_TRUE(limiter.isBad(key.data(), relapse + W + D7 - 1));
EXPECT_FALSE(limiter.isBad(key.data(), relapse + W + D7));
}
TEST_F(FloodAdvertLimit, QuietRecoveryAndInitialWindowWorkAcrossMillisWrap) {
const uint32_t start = UINT32_MAX - W / 2;
bad(start);
EXPECT_TRUE(limiter.isBad(key.data(), start + 2 * W + D7 - 1));
EXPECT_FALSE(limiter.isBad(key.data(), start + 2 * W + D7));
}
TEST_F(FloodAdvertLimit, FullTableEvictsOldestNormalSourceButNeverBadList) {
bad();
for (unsigned i = 1; i <= 3; ++i) {
auto source = key;
source[0] += i;
EXPECT_EQ(Decision::Allow, receive(1, 8, W + i, source.data()));
}
auto new_source = key;
new_source[0] += 4;
EXPECT_EQ(Decision::Allow, receive(1, 8, W + 4, new_source.data()));
EXPECT_TRUE(limiter.isBad(key.data(), W + 4));
uint8_t hash[MAX_HASH_SIZE] = {1};
auto oldest = key;
oldest[0] += 1;
EXPECT_EQ(Decision::Capacity, limiter.check(oldest.data(), hash, W + 4));
EXPECT_EQ(Decision::Duplicate, receive(1, 8, 2 * W + 3, new_source.data()));
EXPECT_EQ(Decision::Allow, receive(1, 8, 2 * W + 4, new_source.data()));
EXPECT_TRUE(limiter.isBad(key.data(), 2 * W + 4));
}
TEST_F(FloodAdvertLimit, KnownDuplicateUpdatesLastHeardAndUnknownHashCannot) {
for (unsigned i = 0; i < 4; ++i) {
auto source = key;
source[0] += i;
receive(1, 8, i, source.data());
}
uint8_t hash[MAX_HASH_SIZE] = {1};
limiter.noteKnownCopy(key.data(), hash, 10); // oldest becomes most recently heard
auto second = key;
second[0] += 1;
uint8_t forged_hash[MAX_HASH_SIZE] = {9};
limiter.noteKnownCopy(second.data(), forged_hash, 11);
auto new_source = key;
new_source[0] += 4;
EXPECT_EQ(Decision::Allow, receive(1, 8, 12, new_source.data()));
EXPECT_EQ(Decision::Duplicate, limiter.check(key.data(), hash, 12));
EXPECT_EQ(Decision::Capacity, limiter.check(second.data(), hash, 12));
}
TEST_F(FloodAdvertLimit, CapacityCannotEvictFirstStrikeEvidence) {
for (unsigned i = 0; i < 4; ++i) {
auto source = key;
source[0] += i;
for (unsigned seq = 1; seq <= 3; ++seq) receive(seq, 8, 0, source.data());
}
auto new_source = key;
new_source[0] += 4;
EXPECT_EQ(Decision::Capacity, receive(1, 8, 1, new_source.data()));
EXPECT_EQ(Decision::Capacity, receive(1, 8, W, new_source.data()));
EXPECT_EQ(Decision::Allow, receive(1, 8, 2 * W, new_source.data()));
}
TEST_F(FloodAdvertLimit, LastHeardEvictionWorksAcrossMillisWrap) {
for (unsigned i = 0; i < 4; ++i) {
auto source = key;
source[0] += i;
receive(1, 8, UINT32_MAX - 2 + i, source.data());
}
auto new_source = key;
new_source[0] += 4;
EXPECT_EQ(Decision::Allow, receive(1, 8, 2, new_source.data()));
uint8_t hash[MAX_HASH_SIZE] = {1};
EXPECT_EQ(Decision::Capacity, limiter.check(key.data(), hash, 2));
}
TEST_F(FloodAdvertLimit, RejectedForwardDoesNotSpendForwardingQuota) {
EXPECT_EQ(Decision::Allow, receive(1, 8, 0, nullptr, false));
EXPECT_EQ(Decision::Allow, receive(2, 8, 0));
EXPECT_EQ(Decision::Allow, receive(3, 8, 0));
EXPECT_EQ(Decision::Quota, receive(4, 8, 0));
}
TEST_F(FloodAdvertLimit, PeriodicMaintenanceExpiresIdleHistoryBeforeTimerWrap) {
bad();
for (uint64_t now = 2ULL * W; now < uint64_t(UINT32_MAX) + W; now += W) limiter.tick(uint32_t(now));
EXPECT_FALSE(limiter.isBad(key.data(), W));
EXPECT_EQ(Decision::Allow, receive(5, 8, W));
}
TEST_F(FloodAdvertLimit, CliExactKeyClearDoesNotClearCollidingFullKey) {
auto collision = key;
collision[31] = 1;
for (unsigned window = 0; window < 2; ++window) {
burst(window * W);
for (unsigned seq = 0; seq < 3; ++seq) receive(seq, 8, window * W, collision.data());
}
ASSERT_TRUE(limiter.isBad(key.data(), W));
ASSERT_TRUE(limiter.isBad(collision.data(), W));
char hex[PUB_KEY_SIZE * 2 + 1];
mesh::Utils::toHex(hex, key.data(), PUB_KEY_SIZE);
std::string command = std::string("clear flood.advert ") + hex;
char reply[160] = {};
ASSERT_TRUE(mesh::cli::handleFloodAdvertClear(&limiter, command.c_str(), reply));
EXPECT_STREQ("OK - key advert limit history cleared", reply);
EXPECT_FALSE(limiter.isBad(key.data(), W));
EXPECT_TRUE(limiter.isBad(collision.data(), W));
// Clearing an already cleared key is idempotent for LoRa retries.
EXPECT_TRUE(mesh::cli::handleFloodAdvertClear(&limiter, command.c_str(), reply));
EXPECT_STREQ("OK - key advert limit history cleared", reply);
}
TEST_F(FloodAdvertLimit, CliAllResetAndRebootClearEveryKindOfHistory) {
bad();
char reply[160] = {};
EXPECT_TRUE(mesh::cli::handleFloodAdvertClear(&limiter, "clear flood.advert all \r\n", reply));
EXPECT_STREQ("OK - all advert limit history cleared", reply);
EXPECT_FALSE(limiter.isBad(key.data(), W));
EXPECT_EQ(Decision::Allow, receive(4, 8, W));
EXPECT_EQ(Decision::Allow, receive(5, 8, W));
limiter.reset(); // Mesh::begin() uses the same reset on reboot.
bad();
limiter.reset();
EXPECT_FALSE(limiter.isBad(key.data(), 0));
}
TEST_F(FloodAdvertLimit, CliInvalidSelectorsNeverClearAnything) {
bad();
const char* invalid[] = {
"clear flood.advert", "clear flood.advert ", "clear flood.advert BA",
"clear flood.advert BADBEE5C0000", "clear flood.advert all extra",
"clear flood.advert 000000000000000000000000000000000000000000000000000000000000000Z",
"clear flood.advert 00000000000000000000000000000000000000000000000000000000000000000"
};
for (auto* command : invalid) {
char reply[160] = {};
EXPECT_TRUE(mesh::cli::handleFloodAdvertClear(&limiter, command, reply));
EXPECT_EQ(0, strncmp(reply, "ERR:", 4));
EXPECT_TRUE(limiter.isBad(key.data(), W));
}
char reply[160] = {};
EXPECT_FALSE(mesh::cli::handleFloodAdvertClear(&limiter, "clear flood.advertisement all", reply));
EXPECT_FALSE(mesh::cli::handleFloodAdvertClear(&limiter, "clear stats", reply));
EXPECT_TRUE(mesh::cli::handleFloodAdvertClear(nullptr, "clear flood.advert all", reply));
EXPECT_STREQ("ERR: advert limiter unavailable on this role", reply);
}
#ifndef FLOOD_ADVERT_COMBINED_TEST
int main(int argc, char** argv) {
::testing::InitGoogleTest(&argc, argv);
return RUN_ALL_TESTS();
}
#endif
+193
View File
@@ -1016,6 +1016,199 @@ TEST(MessageRetry, ReplacementWorksAcrossFloodAndDirectRoutes) {
EXPECT_NE(old_retry, manager.getOutboundByIdx(0));
}
class AdvertLimitedTestMesh : public TraceTestMesh {
public:
mesh::StaticFloodAdvertLimiter<4> limiter;
unsigned advert_callbacks = 0;
unsigned forwarding_checks = 0;
using TraceTestMesh::TraceTestMesh;
mesh::FloodAdvertLimiter* getFloodAdvertLimiter() override { return &limiter; }
bool allowPacketForward(const mesh::Packet* packet) override {
++forwarding_checks;
return TraceTestMesh::allowPacketForward(packet);
}
void onAdvertRecv(mesh::Packet*, const mesh::Identity&, uint32_t,
const uint8_t*, size_t) override { ++advert_callbacks; }
};
class AdvertReceiveLimit : public ::testing::Test {
protected:
TraceTestClock clock;
TraceTestRTC rtc;
TraceTestRNG rng;
TraceTestRadio radio;
ForwardingTestTables tables;
StaticPoolPacketManager manager{12};
AdvertLimitedTestMesh node{radio, clock, rng, rtc, manager, tables};
void SetUp() override {
node.begin();
node.forwardFloods = true;
node.floodRetriesAllowed = false;
g_mock_ed25519_verify_result = true;
g_mock_ed25519_verify_calls = 0;
}
void TearDown() override { g_mock_ed25519_verify_result = true; }
mesh::Packet advert(unsigned seq, uint8_t hops = 8, uint8_t hash_size = 1) {
mesh::Packet packet = makeFloodPacket(PAYLOAD_TYPE_ADVERT);
packet.payload_len = PUB_KEY_SIZE + 4 + SIGNATURE_SIZE;
memset(packet.payload, 0, packet.payload_len);
packet.payload[0] = 0xBA;
memcpy(packet.payload + PUB_KEY_SIZE, &seq, 4);
memset(packet.path, 0x45, sizeof(packet.path));
packet.setPathHashSizeAndCount(hash_size, hops);
return packet;
}
mesh::DispatcherAction receive(unsigned seq, uint8_t hops = 8, bool seen = false) {
tables.seen = seen;
auto packet = advert(seq, hops);
return node.receivePacket(&packet);
}
};
TEST_F(AdvertReceiveLimit, StopsOnlyForwardingAndKeepsLocalAdvertCallbacks) {
EXPECT_NE(ACTION_RELEASE, receive(1));
EXPECT_NE(ACTION_RELEASE, receive(2));
EXPECT_EQ(ACTION_RELEASE, receive(3));
EXPECT_EQ(3U, node.advert_callbacks);
EXPECT_EQ(2U, node.forwarding_checks); // before side-effectful rule counters
tables.seen = false;
auto message = makeFloodPacket(PAYLOAD_TYPE_RAW_CUSTOM);
EXPECT_NE(ACTION_RELEASE, node.receivePacket(&message));
}
TEST_F(AdvertReceiveLimit, VerifiedShorterDuplicateRaisesAllowanceWithoutRelayingIt) {
receive(1);
receive(2);
EXPECT_EQ(ACTION_RELEASE, receive(1, 1, true));
EXPECT_EQ(3U, g_mock_ed25519_verify_calls);
EXPECT_EQ(2U, node.advert_callbacks);
for (unsigned seq = 3; seq <= 10; ++seq) EXPECT_NE(ACTION_RELEASE, receive(seq));
EXPECT_EQ(ACTION_RELEASE, receive(11));
}
TEST_F(AdvertReceiveLimit, ForgedShorterDuplicateCannotRaiseAllowance) {
receive(1);
receive(2);
g_mock_ed25519_verify_result = false;
EXPECT_EQ(ACTION_RELEASE, receive(1, 0, true));
g_mock_ed25519_verify_result = true;
EXPECT_EQ(ACTION_RELEASE, receive(3));
EXPECT_EQ(3U, node.advert_callbacks);
}
TEST_F(AdvertReceiveLimit, InvalidSelfAndMalformedAdvertsCannotCreateAbuseHistory) {
g_mock_ed25519_verify_result = false;
for (unsigned seq = 0; seq < 20; ++seq) EXPECT_EQ(ACTION_RELEASE, receive(seq));
g_mock_ed25519_verify_result = true;
auto self = advert(30);
memcpy(self.payload, node.self_id.pub_key, PUB_KEY_SIZE);
tables.seen = false;
EXPECT_EQ(ACTION_RELEASE, node.receivePacket(&self));
auto malformed = advert(31);
malformed.payload_len = PUB_KEY_SIZE;
EXPECT_EQ(ACTION_RELEASE, node.receivePacket(&malformed));
EXPECT_NE(ACTION_RELEASE, receive(40));
EXPECT_NE(ACTION_RELEASE, receive(41));
EXPECT_EQ(2U, node.advert_callbacks);
}
TEST_F(AdvertReceiveLimit, AdvertDuplicatesStaySuppressedAfterGeneralSeenCacheEviction) {
receive(1);
for (unsigned i = 0; i < 300; ++i) EXPECT_EQ(ACTION_RELEASE, receive(1));
EXPECT_NE(ACTION_RELEASE, receive(2));
EXPECT_EQ(ACTION_RELEASE, receive(3));
}
TEST_F(AdvertReceiveLimit, UnsignedTrailingDataCannotManufactureDistinctAdverts) {
for (unsigned seq = 0; seq < 20; ++seq) {
auto packet = advert(1);
packet.payload_len += MAX_ADVERT_DATA_SIZE + 1;
memset(packet.payload + PUB_KEY_SIZE + 4 + SIGNATURE_SIZE, 0, MAX_ADVERT_DATA_SIZE + 1);
packet.payload[packet.payload_len - 1] = seq;
tables.seen = false;
EXPECT_EQ(ACTION_RELEASE, node.receivePacket(&packet));
}
EXPECT_EQ(0U, g_mock_ed25519_verify_calls);
EXPECT_EQ(0U, node.advert_callbacks);
EXPECT_NE(ACTION_RELEASE, receive(2));
EXPECT_NE(ACTION_RELEASE, receive(3));
// The maximum supported signed app data remains valid.
auto largest = advert(4);
largest.payload_len += MAX_ADVERT_DATA_SIZE;
memset(largest.payload + PUB_KEY_SIZE + 4 + SIGNATURE_SIZE, 0, MAX_ADVERT_DATA_SIZE);
tables.seen = false;
EXPECT_EQ(ACTION_RELEASE, node.receivePacket(&largest)); // quota, not parser rejection
EXPECT_EQ(3U, node.advert_callbacks);
}
TEST_F(AdvertReceiveLimit, UsesHopCountNotPathBytesAndIgnoresRtcJumps) {
for (uint8_t size = 1; size <= 3; ++size) {
node.limiter.reset();
for (unsigned seq = 1; seq <= 3; ++seq) {
auto packet = advert(seq, 8, size);
tables.seen = false;
rtc.now = seq == 2 ? UINT32_MAX : 1;
auto action = node.receivePacket(&packet);
if (seq <= 2) EXPECT_NE(ACTION_RELEASE, action);
else EXPECT_EQ(ACTION_RELEASE, action);
}
}
}
TEST_F(AdvertReceiveLimit, DirectAdvertsAreNotFloodQuotaOrAbuseEvidence) {
for (unsigned seq = 0; seq < 20; ++seq) {
auto packet = advert(seq, 0);
packet.header = ROUTE_TYPE_DIRECT | (PAYLOAD_TYPE_ADVERT << PH_TYPE_SHIFT);
tables.seen = false;
EXPECT_EQ(ACTION_RELEASE, node.receivePacket(&packet));
}
EXPECT_NE(ACTION_RELEASE, receive(30));
EXPECT_NE(ACTION_RELEASE, receive(31));
}
TEST_F(AdvertReceiveLimit, SuppressedTrafficStillEscalatesAndRemainsLocallyVisible) {
for (unsigned seq = 1; seq <= 3; ++seq) receive(seq);
clock.now = mesh::FloodAdvertLimiter::WINDOW_MS;
for (unsigned seq = 4; seq <= 6; ++seq) receive(seq);
auto packet = advert(7);
ASSERT_TRUE(node.limiter.isBad(packet.payload, clock.now));
EXPECT_EQ(ACTION_RELEASE, receive(7));
EXPECT_EQ(7U, node.advert_callbacks);
node.begin(); // reboot clears both abuse and ordinary quota history
EXPECT_FALSE(node.limiter.isBad(packet.payload, clock.now));
EXPECT_NE(ACTION_RELEASE, receive(8));
EXPECT_NE(ACTION_RELEASE, receive(9));
}
TEST_F(AdvertReceiveLimit, SeenVerifiedDuplicateRefreshesLastHeardWithoutSignatureWork) {
for (unsigned source = 0; source < 4; ++source) {
auto packet = advert(1);
packet.payload[0] += source;
tables.seen = false;
clock.now = source;
ASSERT_NE(ACTION_RELEASE, node.receivePacket(&packet));
}
clock.now = 10;
ASSERT_EQ(ACTION_RELEASE, receive(1, 8, true));
EXPECT_EQ(4U, g_mock_ed25519_verify_calls);
auto new_source = advert(1);
new_source.payload[0] += 4;
tables.seen = false;
clock.now = 11;
EXPECT_NE(ACTION_RELEASE, node.receivePacket(&new_source));
auto oldest = advert(1);
oldest.payload[0] += 1;
uint8_t hash[MAX_HASH_SIZE];
oldest.calculatePacketHash(hash);
EXPECT_EQ(mesh::FloodAdvertLimiter::Decision::Capacity,
node.limiter.check(oldest.payload, hash, clock.now));
auto refreshed = advert(1);
refreshed.calculatePacketHash(hash);
EXPECT_EQ(mesh::FloodAdvertLimiter::Decision::Duplicate,
node.limiter.check(refreshed.payload, hash, clock.now));
}
int main(int argc, char** argv) {
::testing::InitGoogleTest(&argc, argv);
return RUN_ALL_TESTS();