fix(lxmf): bound automatic path requests to 30 min per identity

Greptile P2 on PR #92: with 64 tracked sources, evicting the oldest
entry discarded its open cooldown, so a flood of distinct bogus
identities reset other sources' windows and forced unbounded path
requests (each answered by every peer holding the announce).

- Per-identity cooldown 5 min -> 30 min.
- The table no longer evicts an open window: while all 64 slots hold
  unexpired windows, never-before-seen identities are deferred until a
  slot frees (at most one 30-minute window) instead of dropping
  someone else's cooldown. Open windows are only ever pruned after
  they expire.
- Aggregate bound: at most kMaxTrackedSources automatic path requests
  per rolling 30-minute window, capping the worst-case network cost of
  a rotating-identity flood.

Host tests extended: saturated-table deferral, 512-identity flood bound
(one window and across consecutive windows), lazy expiry/prune,
boundary checks at the 30-minute cooldown.
This commit is contained in:
Torlando
2026-09-02 17:05:14 +00:00
parent 2eec34dfe1
commit 3de986daed
2 changed files with 177 additions and 32 deletions
+115 -19
View File
@@ -4,9 +4,11 @@
// The policy decides when the firmware fires an RNS path request for an
// LXMF message from an identity it has not learned (Sideband's "Query
// Network For Keys" equivalent). The decision must be pure and bounded:
// one request per source per cooldown, independent per source, and a
// capped tracking table so an opportunistic flood of unknown senders can
// neither hammer the transport nor starve the table forever.
// one request per source per 30-minute window, independent per source,
// and an aggregate bound — a flood of distinct bogus identities must
// never force more than kMaxTrackedSources requests per window, and a
// source whose window is open must stay suppressed for the whole window
// no matter what.
#include <cstddef>
#include <cstdint>
#include <iostream>
@@ -32,8 +34,8 @@ int failures = 0;
using UI::LXMF::UnknownSourceKeyRequestPolicy;
std::string sourceHex(unsigned seed) {
// Distinct 8-hex string per seed up to 0xffffff (the tests use <= 65),
// so the capacity-cap test has no hash collisions.
// Distinct 8-hex string per seed up to 0xffffff (the tests use <=
// 1512), so the flood tests have no hash collisions.
const char* digits = "0123456789abcdef";
std::string hex;
for (unsigned shift : {28U, 24U, 20U, 16U, 12U, 8U, 4U, 0U}) {
@@ -53,7 +55,7 @@ void testCooldownSuppressesRepeat() {
UnknownSourceKeyRequestPolicy policy;
const std::string src = sourceHex(2);
policy.record_request(src, 1000);
// Within the 5-minute cooldown: suppressed.
// Within the 30-minute cooldown: suppressed.
CHECK(!policy.should_request(src, 1001));
CHECK(!policy.should_request(src, 1000 + UnknownSourceKeyRequestPolicy::kCooldownMillis - 1));
// At and after the cooldown: requested again.
@@ -85,22 +87,32 @@ void testSourcesTrackedIndependently() {
CHECK(!policy.should_request(b, 300));
}
void testCapacityCapEvictsOldest() {
void testSaturatedTableDeclinesNewSources() {
// While every tracking slot holds an open window, a never-before-seen
// source is DECLINED (deferred), not tracked by dropping someone
// else's window. Existing sources stay suppressed throughout.
UnknownSourceKeyRequestPolicy policy;
const unsigned cap = UnknownSourceKeyRequestPolicy::kMaxTrackedSources;
for (unsigned i = 0; i < cap; ++i) {
policy.record_request(sourceHex(i + 1), 1000 + static_cast<unsigned long long>(i));
for (unsigned i = 1; i <= cap; ++i) {
policy.record_request(sourceHex(i),
1000 + static_cast<unsigned long long>(i) * 1000);
}
CHECK(policy.last_requested.size() == cap);
// New source pushes the oldest (seed 1) out of the table.
const std::string evicted = sourceHex(1);
const std::string newest = sourceHex(cap + 1);
policy.record_request(newest, 5000);
const std::string stranger = sourceHex(1000);
CHECK(!policy.should_request(stranger, 2000));
// Nobody's open window was dropped to make room: existing sources are
// still suppressed.
CHECK(!policy.should_request(sourceHex(1), 2000));
CHECK(!policy.should_request(sourceHex(cap), 2000));
CHECK(policy.last_requested.size() == cap);
// The deferred source stays declined while the budget is full, and
// becomes eligible as soon as the first recorded window expires
// (src 1, recorded at 2000, expires at 2000 + cooldown).
CHECK(!policy.should_request(stranger, 1801000 - 1)); // all still open
const unsigned long long after_windows = 2000 + UnknownSourceKeyRequestPolicy::kCooldownMillis;
CHECK(policy.should_request(stranger, after_windows));
policy.record_request(stranger, after_windows);
CHECK(policy.last_requested.size() == cap);
// The evicted source is no longer rate-limited (its entry is gone).
CHECK(policy.should_request(evicted, 1010));
// The most recent source IS rate-limited.
CHECK(!policy.should_request(newest, 5001));
}
void testSteadyStateCostIsZero() {
@@ -119,8 +131,89 @@ void testSteadyStateCostIsZero() {
CHECK(policy.should_request(learned, UnknownSourceKeyRequestPolicy::kCooldownMillis));
}
void testFloodBoundWorstCaseAirtime() {
// Worst-case airtime an attacker can force: flood more distinct bogus
// identities than the table can track, all inside one 30-minute
// window. The total number of requests fired must be bounded by the
// table cap, and every fired source must stay suppressed for the rest
// of the window.
UnknownSourceKeyRequestPolicy policy;
const unsigned cap = UnknownSourceKeyRequestPolicy::kMaxTrackedSources;
const unsigned long long t0 = 100000;
const unsigned flood = 512;
unsigned requests_fired = 0;
for (unsigned i = 1; i <= flood; ++i) {
const std::string src = sourceHex(1000 + i);
const unsigned long long now = t0 + static_cast<unsigned long long>(i) * 1000;
if (policy.should_request(src, now)) {
++requests_fired;
policy.record_request(src, now);
}
}
CHECK(requests_fired == cap);
CHECK(policy.last_requested.size() == cap);
// No identity whose window is open can fire a second time.
unsigned violations = 0;
for (unsigned i = 1; i <= flood; ++i) {
const std::string src = sourceHex(1000 + i);
const unsigned long long now = t0 + 29U * 60U * 1000U;
if (policy.should_request(src, now)) {
++violations;
}
}
CHECK(violations == 0);
}
void testFloodBoundAcrossConsecutiveWindows() {
// Aggregate bound: the same 512 identities re-ask in a second
// 30-minute window (each exactly one window after its first request,
// staggered one per second). The first window's budget only frees one
// slot per second as the first requests expire, so the 65th identity's
// second request is still declined — at most cap requests may fire in
// the second window too.
UnknownSourceKeyRequestPolicy policy;
const unsigned cap = UnknownSourceKeyRequestPolicy::kMaxTrackedSources;
const unsigned long long t0 = 100000;
const unsigned flood = 512;
unsigned first_window = 0;
unsigned second_window = 0;
for (unsigned i = 1; i <= flood; ++i) {
const std::string src = sourceHex(1000 + i);
const unsigned long long w1 = t0 + static_cast<unsigned long long>(i) * 1000;
if (policy.should_request(src, w1)) {
++first_window;
policy.record_request(src, w1);
}
const unsigned long long w2 = w1 + UnknownSourceKeyRequestPolicy::kCooldownMillis;
if (policy.should_request(src, w2)) {
++second_window;
policy.record_request(src, w2);
}
}
CHECK(first_window == cap);
CHECK(second_window == cap);
}
void testWindowExpiryPrunesAndAllows() {
UnknownSourceKeyRequestPolicy policy;
const std::string a = sourceHex(20);
const std::string b = sourceHex(21);
policy.record_request(a, 0);
policy.record_request(b, 5000);
CHECK(!policy.should_request(a, 6000));
CHECK(!policy.should_request(b, 6000));
// a's window expires before b's; the table prunes a lazily and makes
// room for a new source while b stays suppressed.
CHECK(policy.should_request(a, UnknownSourceKeyRequestPolicy::kCooldownMillis));
const std::string c = sourceHex(22);
CHECK(policy.should_request(c, UnknownSourceKeyRequestPolicy::kCooldownMillis));
policy.record_request(c, UnknownSourceKeyRequestPolicy::kCooldownMillis);
CHECK(!policy.should_request(b, UnknownSourceKeyRequestPolicy::kCooldownMillis));
CHECK(!policy.should_request(c, UnknownSourceKeyRequestPolicy::kCooldownMillis + 1));
}
void testConstants() {
CHECK(UnknownSourceKeyRequestPolicy::kCooldownMillis == 5U * 60U * 1000U);
CHECK(UnknownSourceKeyRequestPolicy::kCooldownMillis == 30U * 60U * 1000U);
CHECK(UnknownSourceKeyRequestPolicy::kMaxTrackedSources == 64U);
}
@@ -132,8 +225,11 @@ int main() {
testCooldownSuppressesRepeat();
testReRecordResetsCooldown();
testSourcesTrackedIndependently();
testCapacityCapEvictsOldest();
testSaturatedTableDeclinesNewSources();
testSteadyStateCostIsZero();
testFloodBoundWorstCaseAirtime();
testFloodBoundAcrossConsecutiveWindows();
testWindowExpiryPrunesAndAllows();
std::cout << "unknown source key request policy: " << passed << " passed, "
<< failures << " failed" << std::endl;
return failures == 0 ? 0 : 1;