Limit flood advert forwarding and persistent abuse
This commit is contained in:
1 parent
27ff4b9063
commit
23fe7c8a9d
15 files changed
+985
-6
No files matched your search
@@ -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`
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)};
|
||||
|
||||
@@ -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)};
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
@@ -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);
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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();
|
||||
|
||||
Reference in new issue
Block a user