From 23fe7c8a9d791668ab9eb72c3bb54aaffbc55f1d Mon Sep 17 00:00:00 2001 From: mikecarper Date: Wed, 9 Sep 2026 17:16:00 -0700 Subject: [PATCH] Limit flood advert forwarding and persistent abuse --- docs/cli_commands.md | 11 + docs/flood_filtering.md | 96 ++++++ examples/simple_repeater/MyMesh.h | 4 + examples/simple_room_server/MyMesh.h | 2 + examples/simple_sensor/SensorMesh.h | 2 + platformio.ini | 1 + src/Mesh.cpp | 51 ++- src/Mesh.h | 4 + src/helpers/CommonCLI.cpp | 2 + src/helpers/CommonCLI.h | 1 + src/helpers/FloodAdvertCLI.h | 44 +++ src/helpers/FloodAdvertLimiter.cpp | 186 +++++++++++ src/helpers/FloodAdvertLimiter.h | 82 +++++ .../test_flood_advert_limiter.cpp | 312 ++++++++++++++++++ test/test_trace_retry/test_trace_retry.cpp | 193 +++++++++++ 15 files changed, 985 insertions(+), 6 deletions(-) create mode 100644 src/helpers/FloodAdvertCLI.h create mode 100644 src/helpers/FloodAdvertLimiter.cpp create mode 100644 src/helpers/FloodAdvertLimiter.h create mode 100644 test/test_flood_advert_limiter/test_flood_advert_limiter.cpp diff --git a/docs/cli_commands.md b/docs/cli_commands.md index 0446a452..a27fc79e 100644 --- a/docs/cli_commands.md +++ b/docs/cli_commands.md @@ -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` diff --git a/docs/flood_filtering.md b/docs/flood_filtering.md index 999fdff5..4dfc8b87 100644 --- a/docs/flood_filtering.md +++ b/docs/flood_filtering.md @@ -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: diff --git a/examples/simple_repeater/MyMesh.h b/examples/simple_repeater/MyMesh.h index d14b75f7..8c072eb0 100644 --- a/examples/simple_repeater/MyMesh.h +++ b/examples/simple_repeater/MyMesh.h @@ -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 _local_cli_output{File(InternalFS)}; diff --git a/examples/simple_room_server/MyMesh.h b/examples/simple_room_server/MyMesh.h index 5d51f50f..3705c05c 100644 --- a/examples/simple_room_server/MyMesh.h +++ b/examples/simple_room_server/MyMesh.h @@ -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 _local_cli_output{File(InternalFS)}; diff --git a/examples/simple_sensor/SensorMesh.h b/examples/simple_sensor/SensorMesh.h index b2eaaacd..4743c085 100644 --- a/examples/simple_sensor/SensorMesh.h +++ b/examples/simple_sensor/SensorMesh.h @@ -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); diff --git a/platformio.ini b/platformio.ini index 6375a326..fd80a126 100644 --- a/platformio.ini +++ b/platformio.ini @@ -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> diff --git a/src/Mesh.cpp b/src/Mesh.cpp index 6426b75f..d660d6c5 100644 --- a/src/Mesh.cpp +++ b/src/Mesh.cpp @@ -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); diff --git a/src/Mesh.h b/src/Mesh.h index 1e951c54..411ef6d1 100644 --- a/src/Mesh.h +++ b/src/Mesh.h @@ -1,6 +1,7 @@ #pragma once #include +#include // 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. */ diff --git a/src/helpers/CommonCLI.cpp b/src/helpers/CommonCLI.cpp index 1bdcdcb8..13fbc27b 100644 --- a/src/helpers/CommonCLI.cpp +++ b/src/helpers/CommonCLI.cpp @@ -1,6 +1,7 @@ #include #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) { diff --git a/src/helpers/CommonCLI.h b/src/helpers/CommonCLI.h index 8eadbef8..9ba125c0 100644 --- a/src/helpers/CommonCLI.h +++ b/src/helpers/CommonCLI.h @@ -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. diff --git a/src/helpers/FloodAdvertCLI.h b/src/helpers/FloodAdvertCLI.h new file mode 100644 index 00000000..5af3e58a --- /dev/null +++ b/src/helpers/FloodAdvertCLI.h @@ -0,0 +1,44 @@ +#pragma once + +#include "FloodAdvertLimiter.h" +#include + +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 diff --git a/src/helpers/FloodAdvertLimiter.cpp b/src/helpers/FloodAdvertLimiter.cpp new file mode 100644 index 00000000..487f36de --- /dev/null +++ b/src/helpers/FloodAdvertLimiter.cpp @@ -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 diff --git a/src/helpers/FloodAdvertLimiter.h b/src/helpers/FloodAdvertLimiter.h new file mode 100644 index 00000000..6d52e81e --- /dev/null +++ b/src/helpers/FloodAdvertLimiter.h @@ -0,0 +1,82 @@ +#pragma once + +#include +#include + +#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 +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 diff --git a/test/test_flood_advert_limiter/test_flood_advert_limiter.cpp b/test/test_flood_advert_limiter/test_flood_advert_limiter.cpp new file mode 100644 index 00000000..51a24240 --- /dev/null +++ b/test/test_flood_advert_limiter/test_flood_advert_limiter.cpp @@ -0,0 +1,312 @@ +#include +#include +#include +#include + +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 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 diff --git a/test/test_trace_retry/test_trace_retry.cpp b/test/test_trace_retry/test_trace_retry.cpp index e4a0f108..0f743124 100644 --- a/test/test_trace_retry/test_trace_retry.cpp +++ b/test/test_trace_retry/test_trace_retry.cpp @@ -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();