From 3ef2a82882bb41ff3f32702b608dbd5e34d13033 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 16 Jul 2026 18:26:30 +0000 Subject: [PATCH 01/44] Harden TMM NodeInfo direct-response: staleness, key hygiene, throttle The NodeInfo direct-response path answered queries on behalf of other nodes from an unauthenticated, never-expiring cache while suppressing the genuine request (STOP). That let a long-gone or forged identity be served as a fresh-looking, authoritative reply indefinitely. Three fixes: - Staleness gate: refuse to spoof a reply for a node not actually heard within ~6h (PSRAM cache via lastObservedMs; NodeDB fallback via sinceLastSeen, which tolerates last_heard==0 when the clock is unset). A stale entry now falls through so the real request propagates instead of being answered for a dead node. - Key hygiene: mirror NodeDB::updateUser() PKI checks when caching overheard NodeInfo - reject a packet advertising our own public key, and pin keys (drop a NodeInfo whose key mismatches an already-known key from NodeDB or from our own cache). Prevents cache poisoning / key substitution via the spoofed-reply path. - Response throttle: at most one spoofed reply per target node per 30s. Direct responses bypass the per-sender rate limiter (they STOP the request first) and the reply target is attacker-controlled, so this bounds airtime exhaustion and reflected floods. Recorded in a new NodeInfoPayloadEntry.lastResponseMs (PSRAM; self-expiring timestamp compare, no sweep needed). Tests: native NodeDB-fallback staleness (stale drops, fresh serves) plus PSRAM-guarded staleness, throttle, and key-mismatch cases. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01G7QggdwMM1CJkguuCmMfZs --- src/modules/TrafficManagementModule.cpp | 105 +++++++++- src/modules/TrafficManagementModule.h | 7 + test/test_traffic_management/test_main.cpp | 227 +++++++++++++++++++++ 3 files changed, 337 insertions(+), 2 deletions(-) diff --git a/src/modules/TrafficManagementModule.cpp b/src/modules/TrafficManagementModule.cpp index 74e8de6c24a..0c34578d9a2 100644 --- a/src/modules/TrafficManagementModule.cpp +++ b/src/modules/TrafficManagementModule.cpp @@ -39,6 +39,21 @@ constexpr uint32_t kMaintenanceIntervalMs = 60 * 1000UL; // Cache cleanup interv constexpr uint32_t kRouterDefaultMaxHops = 3; // Routers: max 3 hops (can set lower via config) constexpr uint32_t kClientDefaultMaxHops = 0; // Clients: direct only (cannot increase) +// NodeInfo direct-response safety limits. +// +// Staleness: never spoof a NodeInfo reply on behalf of a node we have not actually +// heard from within this window. Without it, a cached (or forged) entry is served +// indefinitely for a long-gone node while the genuine request is suppressed - the +// requestor sees a fresh-looking answer for a node that may no longer exist. +constexpr uint32_t kNodeInfoMaxServeAgeMs = 6UL * 60UL * 60UL * 1000UL; // 6 h (PSRAM cache path) +constexpr uint32_t kNodeInfoMaxServeAgeSecs = 6UL * 60UL * 60UL; // 6 h (NodeDB fallback path) + +// Throttle: emit at most one spoofed direct reply per target node per this interval. +// Direct responses are otherwise un-throttled (they STOP the request before the +// per-sender rate limiter runs) and the reply target is attacker-controlled, so an +// attacker could otherwise drive unbounded local transmissions / reflected floods. +constexpr uint32_t kNodeInfoResponseThrottleMs = 30UL * 1000UL; // 30 s + /** * Convert seconds to milliseconds with overflow protection. */ @@ -502,17 +517,55 @@ void TrafficManagementModule::cacheNodeInfoPacket(const meshtastic_MeshPacket &m if (!pb_decode_from_bytes(mp.decoded.payload.bytes, mp.decoded.payload.size, &meshtastic_User_msg, &user)) return; + const NodeNum from = getFrom(&mp); + // Normalize user.id to the packet sender's node number. - snprintf(user.id, sizeof(user.id), "!%08x", getFrom(&mp)); + snprintf(user.id, sizeof(user.id), "!%08x", from); + +#if !(MESHTASTIC_EXCLUDE_PKI) + // Mirror NodeDB::updateUser() key hygiene before we let an overheard NodeInfo into + // the direct-response cache. This cache is served back to requestors as a spoofed + // reply on the target's behalf, so poisoning it is materially worse than a plain + // NodeDB overwrite - apply the same protections NodeDB applies to itself. + if (user.public_key.size == 32) { + // Someone advertising our own public key is impersonating this node - never cache it. + if (owner.public_key.size == 32 && memcmp(user.public_key.bytes, owner.public_key.bytes, 32) == 0) { + TM_LOG_WARN("NodeInfo cache: incoming key matches owner, dropping 0x%08x", from); + return; + } + } + // Key pinning against the authoritative NodeDB key: once a 32-byte key is known for a + // node, refuse to cache a NodeInfo carrying a different (or missing) key. This is the + // same rule as NodeDB::updateUser() ("Public Key mismatch, dropping NodeInfo"). + const meshtastic_NodeInfoLite *dbNode = nodeDB ? nodeDB->getMeshNode(from) : nullptr; + if (dbNode && dbNode->public_key.size == 32) { + if (user.public_key.size != 32 || memcmp(user.public_key.bytes, dbNode->public_key.bytes, 32) != 0) { + TM_LOG_WARN("NodeInfo cache: public key mismatch vs NodeDB, dropping 0x%08x", from); + return; + } + } +#endif bool usedEmptySlot = false; uint16_t cachedCount = 0; { concurrency::LockGuard guard(&cacheLock); - NodeInfoPayloadEntry *entry = findOrCreateNodeInfoEntry(getFrom(&mp), &usedEmptySlot); + NodeInfoPayloadEntry *entry = findOrCreateNodeInfoEntry(from, &usedEmptySlot); if (!entry) return; +#if !(MESHTASTIC_EXCLUDE_PKI) + // Trust-on-first-use pinning within our own cache: if NodeDB has since evicted the + // node but we already cached a key for it, still refuse a mismatching key. (A matched + // existing slot is returned un-zeroed, so entry->user holds the previously cached key.) + if (!usedEmptySlot && entry->node == from && entry->user.public_key.size == 32) { + if (user.public_key.size != 32 || memcmp(user.public_key.bytes, entry->user.public_key.bytes, 32) != 0) { + TM_LOG_WARN("NodeInfo cache: public key mismatch vs cache, dropping 0x%08x", from); + return; + } + } +#endif + // Cache both payload and response metadata so direct replies can use // richer context than "just the user protobuf" when PSRAM is present. // This path is intentionally independent from NodeInfoModule/NodeDB. @@ -1080,6 +1133,7 @@ bool TrafficManagementModule::shouldRespondToNodeInfo(const meshtastic_MeshPacke uint8_t cachedSourceChannel = 0; uint32_t cachedLastObservedMs = 0; uint32_t cachedLastObservedRxTime = 0; + uint32_t cachedLastResponseMs = 0; { concurrency::LockGuard guard(&cacheLock); @@ -1092,6 +1146,7 @@ bool TrafficManagementModule::shouldRespondToNodeInfo(const meshtastic_MeshPacke cachedSourceChannel = entry->sourceChannel; cachedLastObservedMs = entry->lastObservedMs; cachedLastObservedRxTime = entry->lastObservedRxTime; + cachedLastResponseMs = entry->lastResponseMs; } } @@ -1109,12 +1164,42 @@ bool TrafficManagementModule::shouldRespondToNodeInfo(const meshtastic_MeshPacke const meshtastic_NodeInfoLite *node = nodeDB->getMeshNode(p->to); if (!nodeInfoLiteHasUser(node)) return false; + // Staleness gate (fallback path): don't spoof a reply for a node NodeDB last + // heard more than kNodeInfoMaxServeAgeSecs ago. last_heard == 0 means recency is + // unknown (e.g. clock not yet set) - we can't prove it's stale, so we still answer, + // matching sinceLastSeen()'s own "clock not set" tolerance. + if (node->last_heard != 0 && sinceLastSeen(node) > kNodeInfoMaxServeAgeSecs) { + TM_LOG_DEBUG("NodeInfo NodeDB entry for 0x%08x stale (%us ago), not responding", p->to, + static_cast(sinceLastSeen(node))); + return false; + } cachedUser = TypeConversions::ConvertToUser(node); } + // Staleness gate (PSRAM cache path): never spoof a reply on behalf of a node we have + // not actually heard from within the serve window. cachedLastObservedMs is only set on + // a PSRAM cache hit, so this leaves the NodeDB fallback (gated above) untouched. + if (cachedLastObservedMs != 0 && (clockMs() - cachedLastObservedMs) > kNodeInfoMaxServeAgeMs) { + TM_LOG_DEBUG("NodeInfo PSRAM entry for 0x%08x stale (age=%lu ms), not responding", p->to, + static_cast(clockMs() - cachedLastObservedMs)); + return false; + } + if (!sendResponse) return true; + // Response throttle: at most one spoofed reply per target node per throttle window. + // Direct responses bypass the per-sender rate limiter (they STOP the request first), + // and the reply target is attacker-controlled, so without this an attacker could drive + // unbounded local transmissions / reflected floods. Suppress the duplicate request + // (return true) rather than letting it propagate and generate more mesh traffic. + // Only the PSRAM path tracks lastResponseMs; the NodeDB fallback is left unthrottled. + if (cachedLastResponseMs != 0 && (clockMs() - cachedLastResponseMs) < kNodeInfoResponseThrottleMs) { + TM_LOG_DEBUG("NodeInfo response throttled for 0x%08x (%lu ms since last)", p->to, + static_cast(clockMs() - cachedLastResponseMs)); + return true; + } + meshtastic_MeshPacket *reply = router->allocForSending(); if (!reply) { TM_LOG_WARN("NodeInfo direct response dropped: no packet buffer"); @@ -1164,6 +1249,22 @@ bool TrafficManagementModule::shouldRespondToNodeInfo(const meshtastic_MeshPacke reply->priority = meshtastic_MeshPacket_Priority_DEFAULT; service->sendToMesh(reply); + + // Record the send so the throttle can suppress a burst of requests for this same node. + // Only meaningful on the PSRAM cache path (the entry we just served from); the NodeDB + // fallback has no per-node entry to stamp. +#if defined(ARCH_ESP32) && defined(BOARD_HAS_PSRAM) + if (nodeInfoPayload) { + concurrency::LockGuard guard(&cacheLock); + for (uint16_t i = 0; i < nodeInfoTargetEntries(); i++) { + if (nodeInfoPayload[i].node == p->to) { + nodeInfoPayload[i].lastResponseMs = clockMs(); + break; + } + } + } +#endif + return true; } diff --git a/src/modules/TrafficManagementModule.h b/src/modules/TrafficManagementModule.h index 864b542bf7d..d27c100131a 100644 --- a/src/modules/TrafficManagementModule.h +++ b/src/modules/TrafficManagementModule.h @@ -230,6 +230,13 @@ class TrafficManagementModule : public MeshModule, private concurrency::OSThread // Last local uptime tick (millis) when this entry was refreshed. uint32_t lastObservedMs; + // Local uptime (millis) of the most recent spoofed direct reply we emitted + // for this node. 0 = never. Used purely to throttle direct responses (at most + // one per node per kNodeInfoResponseThrottleMs); the modular/subtraction compare + // self-expires, so no sweep is needed to "clear" it. This is separate from + // lastObservedMs, which tracks when we last *heard* the node (LRU + staleness). + uint32_t lastResponseMs; + // Last RTC/packet timestamp (seconds) observed for this NodeInfo frame. // If unavailable in packet, remains 0. uint32_t lastObservedRxTime; diff --git a/test/test_traffic_management/test_main.cpp b/test/test_traffic_management/test_main.cpp index f64ea814f5b..c9baa824d85 100644 --- a/test/test_traffic_management/test_main.cpp +++ b/test/test_traffic_management/test_main.cpp @@ -12,6 +12,7 @@ #if HAS_TRAFFIC_MANAGEMENT #include "airtime.h" +#include "gps/RTC.h" #include "mesh/CryptoEngine.h" #include "mesh/Default.h" #include "mesh/MeshService.h" @@ -785,6 +786,225 @@ static void test_tm_nodeinfo_directResponse_psramMissDoesNotFallbackToNodeDb(voi TEST_ASSERT_EQUAL_UINT32(0, stats.nodeinfo_cache_hits); TEST_ASSERT_EQUAL_UINT32(0, static_cast(mockRouter.sentPackets.size())); } + +/** + * Verify a PSRAM-cached NodeInfo is NOT served once it ages past the serve window. + * Important: without this gate a long-gone (or forged) node's cached NodeInfo would be + * spoofed back to requestors indefinitely while the genuine request is suppressed. + */ +static void test_tm_nodeinfo_directResponse_psramStaleEntryNotServed(void) +{ + moduleConfig.traffic_management.nodeinfo_direct_response_max_hops = 10; + config.device.role = meshtastic_Config_DeviceConfig_Role_CLIENT; + mockNodeDB->clearCachedNode(); + + MockRouter mockRouter; + mockRouter.addInterface(std::unique_ptr(new MockRadioInterface())); + MeshService mockService; + router = &mockRouter; + service = &mockService; + + TrafficManagementModuleTestShim module; + + // Learn a NodeInfo for the target into the PSRAM cache (broadcast, so it is only cached). + meshtastic_MeshPacket observed = makeNodeInfoPacket(kTargetNode, "target-long", "tg"); + module.handleReceived(observed); + + // Advance the virtual clock just past the 6 h serve window. + TrafficManagementModule::s_testNowMs += (6UL * 60UL * 60UL * 1000UL) + 1000UL; + + meshtastic_MeshPacket request = makeDecodedPacket(meshtastic_PortNum_NODEINFO_APP, kRemoteNode, kTargetNode); + request.decoded.want_response = true; + request.hop_start = 3; + request.hop_limit = 3; + + ProcessMessage result = module.handleReceived(request); + meshtastic_TrafficManagementStats stats = module.getStats(); + + TEST_ASSERT_EQUAL_INT(static_cast(ProcessMessage::CONTINUE), static_cast(result)); + TEST_ASSERT_EQUAL_UINT32(0, stats.nodeinfo_cache_hits); + TEST_ASSERT_EQUAL_UINT32(0, static_cast(mockRouter.sentPackets.size())); +} + +/** + * Verify repeated NodeInfo requests for the same target are throttled to one spoofed + * reply per throttle window, then allowed again once the window elapses. + * Important: direct responses bypass the per-sender rate limiter, so this is the only + * bound on attacker-driven transmissions / reflected floods. + */ +static void test_tm_nodeinfo_directResponse_psramThrottlesWithinWindow(void) +{ + moduleConfig.traffic_management.nodeinfo_direct_response_max_hops = 10; + config.device.role = meshtastic_Config_DeviceConfig_Role_CLIENT; + mockNodeDB->clearCachedNode(); + + MockRouter mockRouter; + mockRouter.addInterface(std::unique_ptr(new MockRadioInterface())); + MeshService mockService; + router = &mockRouter; + service = &mockService; + + TrafficManagementModuleTestShim module; + + meshtastic_MeshPacket observed = makeNodeInfoPacket(kTargetNode, "target-long", "tg"); + module.handleReceived(observed); + + meshtastic_MeshPacket request = makeDecodedPacket(meshtastic_PortNum_NODEINFO_APP, kRemoteNode, kTargetNode); + request.decoded.want_response = true; + request.hop_start = 3; + request.hop_limit = 3; + + // First request: served. + request.id = 0xAAAA0001; + TEST_ASSERT_EQUAL_INT(static_cast(ProcessMessage::STOP), static_cast(module.handleReceived(request))); + TEST_ASSERT_EQUAL_UINT32(1, static_cast(mockRouter.sentPackets.size())); + + // Second request within the throttle window: suppressed (STOP) but NOT transmitted again. + TrafficManagementModule::s_testNowMs += 5000; // 5 s < 30 s window + request.id = 0xAAAA0002; + TEST_ASSERT_EQUAL_INT(static_cast(ProcessMessage::STOP), static_cast(module.handleReceived(request))); + TEST_ASSERT_TRUE(module.ignoreRequestFlag()); + TEST_ASSERT_EQUAL_UINT32(1, static_cast(mockRouter.sentPackets.size())); + + // Past the throttle window: served again. + TrafficManagementModule::s_testNowMs += 30000; // now > 30 s since first reply + request.id = 0xAAAA0003; + TEST_ASSERT_EQUAL_INT(static_cast(ProcessMessage::STOP), static_cast(module.handleReceived(request))); + TEST_ASSERT_EQUAL_UINT32(2, static_cast(mockRouter.sentPackets.size())); +} + +#if !(MESHTASTIC_EXCLUDE_PKI) +// Build a NODEINFO_APP broadcast whose User carries a 32-byte public key of `keyByte`. +static meshtastic_MeshPacket makeNodeInfoPacketWithKey(NodeNum from, const char *longName, uint8_t keyByte) +{ + meshtastic_MeshPacket packet = makeDecodedPacket(meshtastic_PortNum_NODEINFO_APP, from, NODENUM_BROADCAST); + meshtastic_User user = meshtastic_User_init_zero; + snprintf(user.id, sizeof(user.id), "!%08x", from); + strncpy(user.long_name, longName, sizeof(user.long_name) - 1); + user.public_key.size = 32; + memset(user.public_key.bytes, keyByte, 32); + packet.decoded.payload.size = + pb_encode_to_bytes(packet.decoded.payload.bytes, sizeof(packet.decoded.payload.bytes), &meshtastic_User_msg, &user); + return packet; +} + +/** + * Verify the NodeInfo cache pins the first-seen public key: a later NodeInfo for the same + * node carrying a DIFFERENT key is rejected, and the served reply keeps the original key. + * Mirrors NodeDB::updateUser()'s "Public Key mismatch, dropping NodeInfo" protection. + */ +static void test_tm_nodeinfo_cache_rejectsMismatchedKey(void) +{ + moduleConfig.traffic_management.nodeinfo_direct_response_max_hops = 10; + config.device.role = meshtastic_Config_DeviceConfig_Role_CLIENT; + mockNodeDB->clearCachedNode(); // no authoritative NodeDB key -> exercise the cache's own TOFU pin + + MockRouter mockRouter; + mockRouter.addInterface(std::unique_ptr(new MockRadioInterface())); + MeshService mockService; + router = &mockRouter; + service = &mockService; + + TrafficManagementModuleTestShim module; + + // First-seen key (0x11...) is pinned. + module.handleReceived(makeNodeInfoPacketWithKey(kTargetNode, "genuine", 0x11)); + // Poisoning attempt with a different key (0x22...) must be rejected. + module.handleReceived(makeNodeInfoPacketWithKey(kTargetNode, "attacker", 0x22)); + + meshtastic_MeshPacket request = makeDecodedPacket(meshtastic_PortNum_NODEINFO_APP, kRemoteNode, kTargetNode); + request.decoded.want_response = true; + request.hop_start = 3; + request.hop_limit = 3; + TEST_ASSERT_EQUAL_INT(static_cast(ProcessMessage::STOP), static_cast(module.handleReceived(request))); + TEST_ASSERT_EQUAL_UINT32(1, static_cast(mockRouter.sentPackets.size())); + + // The served reply must still carry the original (pinned) key, not the attacker's. + meshtastic_User served = meshtastic_User_init_zero; + const meshtastic_MeshPacket &reply = mockRouter.sentPackets.front(); + TEST_ASSERT_TRUE( + pb_decode_from_bytes(reply.decoded.payload.bytes, reply.decoded.payload.size, &meshtastic_User_msg, &served)); + TEST_ASSERT_EQUAL_UINT32(32, served.public_key.size); + TEST_ASSERT_EQUAL_UINT8(0x11, served.public_key.bytes[0]); + TEST_ASSERT_EQUAL_UINT8(0x11, served.public_key.bytes[31]); +} +#endif // !MESHTASTIC_EXCLUDE_PKI +#endif + +/** + * Verify the NodeDB-fallback direct-response path (non-PSRAM) refuses to spoof a reply + * for a node NodeDB last heard beyond the serve window, but still answers a fresh one. + */ +#if !(defined(ARCH_ESP32) && defined(BOARD_HAS_PSRAM)) +static void test_tm_nodeinfo_directResponse_fallbackStaleEntryNotServed(void) +{ + moduleConfig.traffic_management.nodeinfo_direct_response_max_hops = 10; + config.device.role = meshtastic_Config_DeviceConfig_Role_CLIENT; + + // Drive getTime() to a known uptime so sinceLastSeen() is deterministic. + setBootRelativeTimeForUnitTest(1000000); + const uint32_t now = getTime(); + + mockNodeDB->setCachedNode(kTargetNode); + mockNodeDB->cachedNodeForTest().last_heard = now - (7UL * 60UL * 60UL); // 7 h ago -> stale + + MockRouter mockRouter; + mockRouter.addInterface(std::unique_ptr(new MockRadioInterface())); + MeshService mockService; + router = &mockRouter; + service = &mockService; + + TrafficManagementModuleTestShim module; + meshtastic_MeshPacket request = makeDecodedPacket(meshtastic_PortNum_NODEINFO_APP, kRemoteNode, kTargetNode); + request.decoded.want_response = true; + request.hop_start = 3; + request.hop_limit = 3; + + ProcessMessage result = module.handleReceived(request); + meshtastic_TrafficManagementStats stats = module.getStats(); + + TEST_ASSERT_EQUAL_INT(static_cast(ProcessMessage::CONTINUE), static_cast(result)); + TEST_ASSERT_EQUAL_UINT32(0, stats.nodeinfo_cache_hits); + TEST_ASSERT_EQUAL_UINT32(0, static_cast(mockRouter.sentPackets.size())); + + resetRTCStateForTests(); +} + +/** + * Verify the NodeDB-fallback path still answers when the node was heard recently. + */ +static void test_tm_nodeinfo_directResponse_fallbackFreshEntryServed(void) +{ + moduleConfig.traffic_management.nodeinfo_direct_response_max_hops = 10; + config.device.role = meshtastic_Config_DeviceConfig_Role_CLIENT; + + setBootRelativeTimeForUnitTest(1000000); + const uint32_t now = getTime(); + + mockNodeDB->setCachedNode(kTargetNode); + mockNodeDB->cachedNodeForTest().last_heard = now - 60UL; // 1 min ago -> fresh + + MockRouter mockRouter; + mockRouter.addInterface(std::unique_ptr(new MockRadioInterface())); + MeshService mockService; + router = &mockRouter; + service = &mockService; + + TrafficManagementModuleTestShim module; + meshtastic_MeshPacket request = makeDecodedPacket(meshtastic_PortNum_NODEINFO_APP, kRemoteNode, kTargetNode); + request.decoded.want_response = true; + request.hop_start = 3; + request.hop_limit = 3; + + ProcessMessage result = module.handleReceived(request); + meshtastic_TrafficManagementStats stats = module.getStats(); + + TEST_ASSERT_EQUAL_INT(static_cast(ProcessMessage::STOP), static_cast(result)); + TEST_ASSERT_EQUAL_UINT32(1, stats.nodeinfo_cache_hits); + TEST_ASSERT_EQUAL_UINT32(1, static_cast(mockRouter.sentPackets.size())); + + resetRTCStateForTests(); +} #endif /** @@ -1773,10 +1993,17 @@ TM_TEST_ENTRY void setup() RUN_TEST(test_tm_nodeinfo_clientClamp_skipsWhenNotDirect); #if !(defined(ARCH_ESP32) && defined(BOARD_HAS_PSRAM)) RUN_TEST(test_tm_nodeinfo_directResponse_withoutNodeDbEntry_skips); + RUN_TEST(test_tm_nodeinfo_directResponse_fallbackStaleEntryNotServed); + RUN_TEST(test_tm_nodeinfo_directResponse_fallbackFreshEntryServed); #endif #if defined(ARCH_ESP32) && defined(BOARD_HAS_PSRAM) RUN_TEST(test_tm_nodeinfo_directResponse_psramCacheRespondsAndPreservesBitfield); RUN_TEST(test_tm_nodeinfo_directResponse_psramMissDoesNotFallbackToNodeDb); + RUN_TEST(test_tm_nodeinfo_directResponse_psramStaleEntryNotServed); + RUN_TEST(test_tm_nodeinfo_directResponse_psramThrottlesWithinWindow); +#if !(MESHTASTIC_EXCLUDE_PKI) + RUN_TEST(test_tm_nodeinfo_cache_rejectsMismatchedKey); +#endif #endif RUN_TEST(test_tm_alterReceived_telemetryBroadcast_hopLimitUnchanged); RUN_TEST(test_tm_alterReceived_skipsLocalAndUnicast); From 60f5a891c47cab902977a061c12835b0ec1abdc6 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 16 Jul 2026 18:48:39 +0000 Subject: [PATCH 02/44] Add TODO(T1-T9) markers for code-review findings on NodeInfo hardening Comment-only. Tags each site flagged in the review of the direct-response throttle/staleness/key-hygiene change so the follow-ups are visible in-context: T1 throttle black-holes distinct requestors; T2 per-target key doesn't bound aggregate TX; T3 non-PSRAM fallback unthrottled; T4 millis-wrap defeats the staleness gate; T5 redundant second O(n) scan for the throttle stamp; T6 duplicated 32-byte key compares; T7 sinceLastSeen() called twice; T8 two 6h constants can desync; T9 clockMs()==0 collides with the 0 sentinel. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01G7QggdwMM1CJkguuCmMfZs --- src/modules/TrafficManagementModule.cpp | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/src/modules/TrafficManagementModule.cpp b/src/modules/TrafficManagementModule.cpp index 0c34578d9a2..0f53921af01 100644 --- a/src/modules/TrafficManagementModule.cpp +++ b/src/modules/TrafficManagementModule.cpp @@ -45,6 +45,8 @@ constexpr uint32_t kClientDefaultMaxHops = 0; // Clients: direct only (cannot in // heard from within this window. Without it, a cached (or forged) entry is served // indefinitely for a long-gone node while the genuine request is suppressed - the // requestor sees a fresh-looking answer for a node that may no longer exist. +// TODO(T8): these two constants encode the same 6 h window independently and can desync; +// derive one from the other (e.g. kNodeInfoMaxServeAgeSecs = kNodeInfoMaxServeAgeMs / 1000UL). constexpr uint32_t kNodeInfoMaxServeAgeMs = 6UL * 60UL * 60UL * 1000UL; // 6 h (PSRAM cache path) constexpr uint32_t kNodeInfoMaxServeAgeSecs = 6UL * 60UL * 60UL; // 6 h (NodeDB fallback path) @@ -527,6 +529,9 @@ void TrafficManagementModule::cacheNodeInfoPacket(const meshtastic_MeshPacket &m // the direct-response cache. This cache is served back to requestors as a spoofed // reply on the target's behalf, so poisoning it is materially worse than a plain // NodeDB overwrite - apply the same protections NodeDB applies to itself. + // TODO(T6): the owner-match and the two pin blocks below repeat the same 32-byte + // key compare; extract a local helper (e.g. keyMismatch(a, b)) to document intent + // once and avoid drift if the compare ever changes (e.g. constant-time). if (user.public_key.size == 32) { // Someone advertising our own public key is impersonating this node - never cache it. if (owner.public_key.size == 32 && memcmp(user.public_key.bytes, owner.public_key.bytes, 32) == 0) { @@ -1168,6 +1173,9 @@ bool TrafficManagementModule::shouldRespondToNodeInfo(const meshtastic_MeshPacke // heard more than kNodeInfoMaxServeAgeSecs ago. last_heard == 0 means recency is // unknown (e.g. clock not yet set) - we can't prove it's stale, so we still answer, // matching sinceLastSeen()'s own "clock not set" tolerance. + // TODO(T7): sinceLastSeen(node) is called twice here (condition + log); it calls + // getTime() each time, so the logged age can differ from the tested one. Cache it + // in a local (const uint32_t age = sinceLastSeen(node);). if (node->last_heard != 0 && sinceLastSeen(node) > kNodeInfoMaxServeAgeSecs) { TM_LOG_DEBUG("NodeInfo NodeDB entry for 0x%08x stale (%us ago), not responding", p->to, static_cast(sinceLastSeen(node))); @@ -1179,6 +1187,10 @@ bool TrafficManagementModule::shouldRespondToNodeInfo(const meshtastic_MeshPacke // Staleness gate (PSRAM cache path): never spoof a reply on behalf of a node we have // not actually heard from within the serve window. cachedLastObservedMs is only set on // a PSRAM cache hit, so this leaves the NodeDB fallback (gated above) untouched. + // TODO(T4): this age uses millis(); at a ~49.7-day uptime wrap boundary an entry that + // is never LRU-evicted can wrap to a small age and read as fresh, defeating the gate. + // TODO(T9): 0 is the "never observed" sentinel, but clockMs()==0 is a real value at the + // millis wrap instant, which would skip this gate for that entry until re-observed. if (cachedLastObservedMs != 0 && (clockMs() - cachedLastObservedMs) > kNodeInfoMaxServeAgeMs) { TM_LOG_DEBUG("NodeInfo PSRAM entry for 0x%08x stale (age=%lu ms), not responding", p->to, static_cast(clockMs() - cachedLastObservedMs)); @@ -1194,6 +1206,14 @@ bool TrafficManagementModule::shouldRespondToNodeInfo(const meshtastic_MeshPacke // unbounded local transmissions / reflected floods. Suppress the duplicate request // (return true) rather than letting it propagate and generate more mesh traffic. // Only the PSRAM path tracks lastResponseMs; the NodeDB fallback is left unthrottled. + // TODO(T1): keyed on p->to, so a DISTINCT legitimate requestor for the same target gets + // no reply for up to the window (returning true STOPs it). Consider returning false when + // throttled (let it propagate), or key the throttle on the requestor. + // TODO(T2): per-target key does not bound aggregate local TX - an attacker cycling N + // distinct cached targets still gets N spoofed transmits per window. A global/per-requestor + // rate cap would actually bound the flood this throttle targets. + // TODO(T3): throttle state lives only in the PSRAM cache entry, so non-PSRAM boards (NodeDB + // fallback path) are entirely unthrottled - the flood mitigation misses that path. if (cachedLastResponseMs != 0 && (clockMs() - cachedLastResponseMs) < kNodeInfoResponseThrottleMs) { TM_LOG_DEBUG("NodeInfo response throttled for 0x%08x (%lu ms since last)", p->to, static_cast(clockMs() - cachedLastResponseMs)); @@ -1253,6 +1273,9 @@ bool TrafficManagementModule::shouldRespondToNodeInfo(const meshtastic_MeshPacke // Record the send so the throttle can suppress a burst of requests for this same node. // Only meaningful on the PSRAM cache path (the entry we just served from); the NodeDB // fallback has no per-node entry to stamp. + // TODO(T5): this is a second full O(n) scan under a second lock, after findNodeInfoEntry() + // already located this slot at the top of the function. Capture the index in the first + // pass (re-validate node == p->to after re-locking) to avoid the redundant scan. #if defined(ARCH_ESP32) && defined(BOARD_HAS_PSRAM) if (nodeInfoPayload) { concurrency::LockGuard guard(&cacheLock); From a387e49367392d54af8b0829a8b79c25f5673cea Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 16 Jul 2026 19:11:00 +0000 Subject: [PATCH 03/44] Address code-review cleanups T6, T7, T8 in NodeInfo direct-response T8: derive kNodeInfoMaxServeAgeSecs from kNodeInfoMaxServeAgeMs so the PSRAM and NodeDB-fallback staleness windows cannot desync. T6: collapse the three duplicated 32-byte key compares in cacheNodeInfoPacket (owner-impersonation + NodeDB pin + cache TOFU pin) into one pubKeysEqual() helper, so the compare lives in one place. T7: compute sinceLastSeen(node) once on the fallback staleness path so the tested age and the logged age cannot diverge (it calls getTime() each time). Remaining review items still marked in-code: T1/T2/T3 (throttle design), T4/T9 (millis-wrap + 0-sentinel), T5 (redundant throttle-stamp scan). Native test_traffic_management suite: 48/48 passing. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01G7QggdwMM1CJkguuCmMfZs --- src/modules/TrafficManagementModule.cpp | 62 +++++++++++++------------ 1 file changed, 33 insertions(+), 29 deletions(-) diff --git a/src/modules/TrafficManagementModule.cpp b/src/modules/TrafficManagementModule.cpp index 0f53921af01..34d0d595e1d 100644 --- a/src/modules/TrafficManagementModule.cpp +++ b/src/modules/TrafficManagementModule.cpp @@ -45,10 +45,10 @@ constexpr uint32_t kClientDefaultMaxHops = 0; // Clients: direct only (cannot in // heard from within this window. Without it, a cached (or forged) entry is served // indefinitely for a long-gone node while the genuine request is suppressed - the // requestor sees a fresh-looking answer for a node that may no longer exist. -// TODO(T8): these two constants encode the same 6 h window independently and can desync; -// derive one from the other (e.g. kNodeInfoMaxServeAgeSecs = kNodeInfoMaxServeAgeMs / 1000UL). -constexpr uint32_t kNodeInfoMaxServeAgeMs = 6UL * 60UL * 60UL * 1000UL; // 6 h (PSRAM cache path) -constexpr uint32_t kNodeInfoMaxServeAgeSecs = 6UL * 60UL * 60UL; // 6 h (NodeDB fallback path) +// One source of truth for the 6 h window; the seconds form (NodeDB fallback path) is +// derived from the millis form (PSRAM cache path) so the two paths cannot desync. +constexpr uint32_t kNodeInfoMaxServeAgeMs = 6UL * 60UL * 60UL * 1000UL; // 6 h (PSRAM cache path) +constexpr uint32_t kNodeInfoMaxServeAgeSecs = kNodeInfoMaxServeAgeMs / 1000UL; // 6 h (NodeDB fallback path) // Throttle: emit at most one spoofed direct reply per target node per this interval. // Direct responses are otherwise un-throttled (they STOP the request before the @@ -509,6 +509,17 @@ uint16_t TrafficManagementModule::countNodeInfoEntriesLocked() const #endif } +#if defined(ARCH_ESP32) && defined(BOARD_HAS_PSRAM) && !(MESHTASTIC_EXCLUDE_PKI) +// True iff both are full 32-byte public keys with identical bytes. Single point of +// truth for the NodeInfo-cache key-hygiene checks (owner impersonation + key pinning), +// so the compare (and any future hardening, e.g. constant-time) lives in one place. +// Takes raw ptr+size because the User and NodeInfoLite key fields are distinct types. +static bool pubKeysEqual(const uint8_t *a, size_t aSize, const uint8_t *b, size_t bSize) +{ + return aSize == 32 && bSize == 32 && memcmp(a, b, 32) == 0; +} +#endif + void TrafficManagementModule::cacheNodeInfoPacket(const meshtastic_MeshPacket &mp) { #if defined(ARCH_ESP32) && defined(BOARD_HAS_PSRAM) @@ -529,25 +540,19 @@ void TrafficManagementModule::cacheNodeInfoPacket(const meshtastic_MeshPacket &m // the direct-response cache. This cache is served back to requestors as a spoofed // reply on the target's behalf, so poisoning it is materially worse than a plain // NodeDB overwrite - apply the same protections NodeDB applies to itself. - // TODO(T6): the owner-match and the two pin blocks below repeat the same 32-byte - // key compare; extract a local helper (e.g. keyMismatch(a, b)) to document intent - // once and avoid drift if the compare ever changes (e.g. constant-time). - if (user.public_key.size == 32) { - // Someone advertising our own public key is impersonating this node - never cache it. - if (owner.public_key.size == 32 && memcmp(user.public_key.bytes, owner.public_key.bytes, 32) == 0) { - TM_LOG_WARN("NodeInfo cache: incoming key matches owner, dropping 0x%08x", from); - return; - } + // Someone advertising our own public key is impersonating this node - never cache it. + if (pubKeysEqual(user.public_key.bytes, user.public_key.size, owner.public_key.bytes, owner.public_key.size)) { + TM_LOG_WARN("NodeInfo cache: incoming key matches owner, dropping 0x%08x", from); + return; } // Key pinning against the authoritative NodeDB key: once a 32-byte key is known for a // node, refuse to cache a NodeInfo carrying a different (or missing) key. This is the // same rule as NodeDB::updateUser() ("Public Key mismatch, dropping NodeInfo"). const meshtastic_NodeInfoLite *dbNode = nodeDB ? nodeDB->getMeshNode(from) : nullptr; - if (dbNode && dbNode->public_key.size == 32) { - if (user.public_key.size != 32 || memcmp(user.public_key.bytes, dbNode->public_key.bytes, 32) != 0) { - TM_LOG_WARN("NodeInfo cache: public key mismatch vs NodeDB, dropping 0x%08x", from); - return; - } + if (dbNode && dbNode->public_key.size == 32 && + !pubKeysEqual(user.public_key.bytes, user.public_key.size, dbNode->public_key.bytes, dbNode->public_key.size)) { + TM_LOG_WARN("NodeInfo cache: public key mismatch vs NodeDB, dropping 0x%08x", from); + return; } #endif @@ -563,11 +568,11 @@ void TrafficManagementModule::cacheNodeInfoPacket(const meshtastic_MeshPacket &m // Trust-on-first-use pinning within our own cache: if NodeDB has since evicted the // node but we already cached a key for it, still refuse a mismatching key. (A matched // existing slot is returned un-zeroed, so entry->user holds the previously cached key.) - if (!usedEmptySlot && entry->node == from && entry->user.public_key.size == 32) { - if (user.public_key.size != 32 || memcmp(user.public_key.bytes, entry->user.public_key.bytes, 32) != 0) { - TM_LOG_WARN("NodeInfo cache: public key mismatch vs cache, dropping 0x%08x", from); - return; - } + if (!usedEmptySlot && entry->node == from && entry->user.public_key.size == 32 && + !pubKeysEqual(user.public_key.bytes, user.public_key.size, entry->user.public_key.bytes, + entry->user.public_key.size)) { + TM_LOG_WARN("NodeInfo cache: public key mismatch vs cache, dropping 0x%08x", from); + return; } #endif @@ -1172,13 +1177,12 @@ bool TrafficManagementModule::shouldRespondToNodeInfo(const meshtastic_MeshPacke // Staleness gate (fallback path): don't spoof a reply for a node NodeDB last // heard more than kNodeInfoMaxServeAgeSecs ago. last_heard == 0 means recency is // unknown (e.g. clock not yet set) - we can't prove it's stale, so we still answer, - // matching sinceLastSeen()'s own "clock not set" tolerance. - // TODO(T7): sinceLastSeen(node) is called twice here (condition + log); it calls - // getTime() each time, so the logged age can differ from the tested one. Cache it - // in a local (const uint32_t age = sinceLastSeen(node);). - if (node->last_heard != 0 && sinceLastSeen(node) > kNodeInfoMaxServeAgeSecs) { + // matching sinceLastSeen()'s own "clock not set" tolerance. Compute the age once so + // the tested value and the logged value can't diverge (sinceLastSeen calls getTime()). + const uint32_t nodeAgeSecs = sinceLastSeen(node); + if (node->last_heard != 0 && nodeAgeSecs > kNodeInfoMaxServeAgeSecs) { TM_LOG_DEBUG("NodeInfo NodeDB entry for 0x%08x stale (%us ago), not responding", p->to, - static_cast(sinceLastSeen(node))); + static_cast(nodeAgeSecs)); return false; } cachedUser = TypeConversions::ConvertToUser(node); From 2633cbf8e11f855de6bdf7f5f6156c3ac487e20a Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 17 Jul 2026 00:29:32 +0000 Subject: [PATCH 04/44] Throttle the NodeDB fallback direct-response path (T3) The per-target NodeInfo response throttle lived only in the PSRAM NodeInfoPayloadEntry, so non-PSRAM boards - which answer from the NodeDB fallback path - emitted spoofed direct replies with no rate limit at all, leaving the airtime/reflection surface the throttle exists to close. Add a single module-global fallback stamp (nodeInfoFallbackLastResponseMs, 4 bytes, guarded by cacheLock) that throttles the fallback path to one spoofed reply per window across all targets. Coarser than the PSRAM per-target throttle, but the fallback path has no per-node slot to stamp, and a global cap still bounds spoofed transmissions - which is the point. Add a native test exercising the fallback throttle window. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01G7QggdwMM1CJkguuCmMfZs --- src/modules/TrafficManagementModule.cpp | 59 ++++++++++++++-------- src/modules/TrafficManagementModule.h | 10 ++++ test/test_traffic_management/test_main.cpp | 51 +++++++++++++++++++ 3 files changed, 99 insertions(+), 21 deletions(-) diff --git a/src/modules/TrafficManagementModule.cpp b/src/modules/TrafficManagementModule.cpp index 34d0d595e1d..061c212d453 100644 --- a/src/modules/TrafficManagementModule.cpp +++ b/src/modules/TrafficManagementModule.cpp @@ -1144,6 +1144,10 @@ bool TrafficManagementModule::shouldRespondToNodeInfo(const meshtastic_MeshPacke uint32_t cachedLastObservedMs = 0; uint32_t cachedLastObservedRxTime = 0; uint32_t cachedLastResponseMs = 0; + // True once we commit to answering from the NodeDB fallback (non-PSRAM) path, so the + // shared throttle check/stamp below target the module-global fallback stamp instead of + // a per-node PSRAM entry (which does not exist on this path). + bool usedFallback = false; { concurrency::LockGuard guard(&cacheLock); @@ -1186,6 +1190,14 @@ bool TrafficManagementModule::shouldRespondToNodeInfo(const meshtastic_MeshPacke return false; } cachedUser = TypeConversions::ConvertToUser(node); + // T3: the fallback path has no per-node cache entry, so it throttles against the + // module-global stamp. Load it here so the shared throttle check below covers this + // path too (previously only the PSRAM path was throttled). + usedFallback = true; + { + concurrency::LockGuard guard(&cacheLock); + cachedLastResponseMs = nodeInfoFallbackLastResponseMs; + } } // Staleness gate (PSRAM cache path): never spoof a reply on behalf of a node we have @@ -1204,20 +1216,20 @@ bool TrafficManagementModule::shouldRespondToNodeInfo(const meshtastic_MeshPacke if (!sendResponse) return true; - // Response throttle: at most one spoofed reply per target node per throttle window. - // Direct responses bypass the per-sender rate limiter (they STOP the request first), - // and the reply target is attacker-controlled, so without this an attacker could drive - // unbounded local transmissions / reflected floods. Suppress the duplicate request - // (return true) rather than letting it propagate and generate more mesh traffic. - // Only the PSRAM path tracks lastResponseMs; the NodeDB fallback is left unthrottled. - // TODO(T1): keyed on p->to, so a DISTINCT legitimate requestor for the same target gets - // no reply for up to the window (returning true STOPs it). Consider returning false when - // throttled (let it propagate), or key the throttle on the requestor. - // TODO(T2): per-target key does not bound aggregate local TX - an attacker cycling N - // distinct cached targets still gets N spoofed transmits per window. A global/per-requestor - // rate cap would actually bound the flood this throttle targets. - // TODO(T3): throttle state lives only in the PSRAM cache entry, so non-PSRAM boards (NodeDB - // fallback path) are entirely unthrottled - the flood mitigation misses that path. + // Response throttle: bound how often we emit a spoofed reply. Direct responses bypass + // the per-sender rate limiter (they STOP the request first), and the reply target is + // attacker-controlled, so without this an attacker could drive unbounded local + // transmissions / reflected floods. Suppress the duplicate request (return true) rather + // than letting it propagate and generate more mesh traffic. + // - PSRAM path: cachedLastResponseMs is per target node (NodeInfoPayloadEntry). + // - Fallback path: cachedLastResponseMs is the module-global stamp (no per-node slot). + // TODO(T1): on the PSRAM path this is keyed on p->to, so a DISTINCT legitimate requestor + // for the same target gets no reply for up to the window (returning true STOPs it). + // Accepted: the mesh is redundant - the genuine node or another cache-holder answers. + // TODO(T2): the per-target PSRAM key does not bound aggregate local TX - an attacker + // cycling N distinct cached targets still gets N spoofed transmits per window. The + // fallback path's global stamp does bound aggregate; a global cap on the PSRAM path + // would too, at the cost of throughput on multi-target responders. if (cachedLastResponseMs != 0 && (clockMs() - cachedLastResponseMs) < kNodeInfoResponseThrottleMs) { TM_LOG_DEBUG("NodeInfo response throttled for 0x%08x (%lu ms since last)", p->to, static_cast(clockMs() - cachedLastResponseMs)); @@ -1274,14 +1286,19 @@ bool TrafficManagementModule::shouldRespondToNodeInfo(const meshtastic_MeshPacke service->sendToMesh(reply); - // Record the send so the throttle can suppress a burst of requests for this same node. - // Only meaningful on the PSRAM cache path (the entry we just served from); the NodeDB - // fallback has no per-node entry to stamp. - // TODO(T5): this is a second full O(n) scan under a second lock, after findNodeInfoEntry() - // already located this slot at the top of the function. Capture the index in the first - // pass (re-validate node == p->to after re-locking) to avoid the redundant scan. + // Record the send so the throttle can suppress a burst of requests. The fallback path + // stamps the module-global marker; the PSRAM path stamps the per-node entry we served + // from. clockMs()==0 (the millis wrap instant) collides with the "never" sentinel and + // skips one window - the same negligible T9 edge as the staleness gate. + if (usedFallback) { + concurrency::LockGuard guard(&cacheLock); + nodeInfoFallbackLastResponseMs = clockMs(); + } + // TODO(T5): the PSRAM stamp below is a second full O(n) scan under a second lock, after + // findNodeInfoEntry() already located this slot at the top of the function. Capture the + // index in the first pass (re-validate node == p->to after re-locking) to avoid the rescan. #if defined(ARCH_ESP32) && defined(BOARD_HAS_PSRAM) - if (nodeInfoPayload) { + if (!usedFallback && nodeInfoPayload) { concurrency::LockGuard guard(&cacheLock); for (uint16_t i = 0; i < nodeInfoTargetEntries(); i++) { if (nodeInfoPayload[i].node == p->to) { diff --git a/src/modules/TrafficManagementModule.h b/src/modules/TrafficManagementModule.h index d27c100131a..95da65860ec 100644 --- a/src/modules/TrafficManagementModule.h +++ b/src/modules/TrafficManagementModule.h @@ -253,6 +253,16 @@ class TrafficManagementModule : public MeshModule, private concurrency::OSThread NodeInfoPayloadEntry *nodeInfoPayload = nullptr; // NodeInfo payloads in PSRAM (flat array, linear scan) bool nodeInfoPayloadFromPsram = false; // Tracks allocator for correct deallocation + // Throttle stamp for the NodeDB fallback direct-response path (non-PSRAM boards). + // The PSRAM path throttles per target via NodeInfoPayloadEntry::lastResponseMs, but + // the fallback path has no per-node slot to stamp, so it would otherwise emit spoofed + // replies with no rate limit at all. This single module-global stamp throttles that + // path to at most one spoofed reply per kNodeInfoResponseThrottleMs across all targets. + // Coarser than per-target, but the fallback path has nowhere to store per-node state, + // and a global cap still bounds spoofed transmissions - which is the point of the + // throttle. 0 = never responded. Guarded by cacheLock. Local uptime (millis). + uint32_t nodeInfoFallbackLastResponseMs = 0; + meshtastic_TrafficManagementStats stats; // Flag set during alterReceived() when packet should be exhausted. diff --git a/test/test_traffic_management/test_main.cpp b/test/test_traffic_management/test_main.cpp index c9baa824d85..baa0f582b46 100644 --- a/test/test_traffic_management/test_main.cpp +++ b/test/test_traffic_management/test_main.cpp @@ -1005,6 +1005,56 @@ static void test_tm_nodeinfo_directResponse_fallbackFreshEntryServed(void) resetRTCStateForTests(); } + +/** + * T3: verify the NodeDB-fallback path (non-PSRAM) is throttled too. A burst of requests + * for a fresh node must yield exactly one spoofed reply per throttle window - previously + * this path had no per-node slot to stamp and so emitted a reply for every request. + */ +static void test_tm_nodeinfo_directResponse_fallbackThrottlesWithinWindow(void) +{ + moduleConfig.traffic_management.nodeinfo_direct_response_max_hops = 10; + config.device.role = meshtastic_Config_DeviceConfig_Role_CLIENT; + + setBootRelativeTimeForUnitTest(1000000); + const uint32_t now = getTime(); + TrafficManagementModule::s_testNowMs = 3600000; // known base for the throttle clock + + mockNodeDB->setCachedNode(kTargetNode); + mockNodeDB->cachedNodeForTest().last_heard = now - 60UL; // fresh, passes staleness gate + + MockRouter mockRouter; + mockRouter.addInterface(std::unique_ptr(new MockRadioInterface())); + MeshService mockService; + router = &mockRouter; + service = &mockService; + + TrafficManagementModuleTestShim module; + meshtastic_MeshPacket request = makeDecodedPacket(meshtastic_PortNum_NODEINFO_APP, kRemoteNode, kTargetNode); + request.decoded.want_response = true; + request.hop_start = 3; + request.hop_limit = 3; + + // First request: served from the NodeDB fallback. + request.id = 0xBBBB0001; + TEST_ASSERT_EQUAL_INT(static_cast(ProcessMessage::STOP), static_cast(module.handleReceived(request))); + TEST_ASSERT_EQUAL_UINT32(1, static_cast(mockRouter.sentPackets.size())); + + // Second request within the throttle window: suppressed (STOP) but NOT transmitted again. + TrafficManagementModule::s_testNowMs += 5000; // 5 s < 30 s window + request.id = 0xBBBB0002; + TEST_ASSERT_EQUAL_INT(static_cast(ProcessMessage::STOP), static_cast(module.handleReceived(request))); + TEST_ASSERT_TRUE(module.ignoreRequestFlag()); + TEST_ASSERT_EQUAL_UINT32(1, static_cast(mockRouter.sentPackets.size())); + + // Past the throttle window: served again. + TrafficManagementModule::s_testNowMs += 30000; // now > 30 s since first reply + request.id = 0xBBBB0003; + TEST_ASSERT_EQUAL_INT(static_cast(ProcessMessage::STOP), static_cast(module.handleReceived(request))); + TEST_ASSERT_EQUAL_UINT32(2, static_cast(mockRouter.sentPackets.size())); + + resetRTCStateForTests(); +} #endif /** @@ -1995,6 +2045,7 @@ TM_TEST_ENTRY void setup() RUN_TEST(test_tm_nodeinfo_directResponse_withoutNodeDbEntry_skips); RUN_TEST(test_tm_nodeinfo_directResponse_fallbackStaleEntryNotServed); RUN_TEST(test_tm_nodeinfo_directResponse_fallbackFreshEntryServed); + RUN_TEST(test_tm_nodeinfo_directResponse_fallbackThrottlesWithinWindow); #endif #if defined(ARCH_ESP32) && defined(BOARD_HAS_PSRAM) RUN_TEST(test_tm_nodeinfo_directResponse_psramCacheRespondsAndPreservesBitfield); From 0631562314e8ab0a31fe1ae2ce6e660b3e854b62 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 17 Jul 2026 00:37:11 +0000 Subject: [PATCH 05/44] Make NodeInfo staleness/throttle wrap- and sentinel-safe (T4, T9) T9: the millis-based fields that use 0 as a "never" sentinel (lastObservedMs, lastResponseMs, nodeInfoFallbackLastResponseMs) could be stamped with a literal 0 during the one-millisecond clockMs()==0 instant at each ~49.7-day wrap, momentarily colliding with the sentinel and disabling the staleness/throttle gate for that entry. Route all such stamps through a new nowStampMs() that maps 0 -> 1; the 1 ms skew is irrelevant to every window these fields feed. T4: the staleness gate's modular age compare is only unambiguous while true age < 2^32 ms (~49.7 days); an entry that lingered unrefreshed that long could wrap to a small age and read as fresh, defeating the gate. Add a NodeInfo eviction pass to the maintenance sweep that drops entries past the serve window, so an entry is removed long before its age can approach the wrap boundary. This also frees slots holding stale identities. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01G7QggdwMM1CJkguuCmMfZs --- src/modules/TrafficManagementModule.cpp | 37 +++++++++++++++++-------- src/modules/TrafficManagementModule.h | 12 ++++++++ 2 files changed, 38 insertions(+), 11 deletions(-) diff --git a/src/modules/TrafficManagementModule.cpp b/src/modules/TrafficManagementModule.cpp index 061c212d453..6fd0d8bbd66 100644 --- a/src/modules/TrafficManagementModule.cpp +++ b/src/modules/TrafficManagementModule.cpp @@ -580,7 +580,7 @@ void TrafficManagementModule::cacheNodeInfoPacket(const meshtastic_MeshPacket &m // richer context than "just the user protobuf" when PSRAM is present. // This path is intentionally independent from NodeInfoModule/NodeDB. entry->user = user; - entry->lastObservedMs = clockMs(); + entry->lastObservedMs = nowStampMs(); entry->lastObservedRxTime = mp.rx_time; entry->sourceChannel = mp.channel; entry->hasDecodedBitfield = mp.decoded.has_bitfield; @@ -1032,8 +1032,23 @@ int32_t TrafficManagementModule::runOnce() #if defined(ARCH_ESP32) && defined(BOARD_HAS_PSRAM) if (nodeInfoPayload) { - TM_LOG_DEBUG("NodeInfo PSRAM cache: %u/%u", static_cast(countNodeInfoEntriesLocked()), - static_cast(nodeInfoTargetEntries())); + // Evict NodeInfo payloads past the serve window. Beyond freeing slots, this bounds + // how long any entry can sit unrefreshed: the staleness gate's modular age compare + // (shouldRespondToNodeInfo) is only unambiguous while true age < 2^32 ms (~49.7 d), + // so removing stale entries here keeps them far from that wrap boundary (T4). Uses + // the same clock as the gate; entries stamped with nowStampMs() so 0 means "never". + uint16_t nodeInfoExpired = 0; + for (uint16_t i = 0; i < nodeInfoTargetEntries(); i++) { + NodeInfoPayloadEntry &e = nodeInfoPayload[i]; + if (e.node == 0 || e.lastObservedMs == 0) + continue; + if ((nowMs - e.lastObservedMs) > kNodeInfoMaxServeAgeMs) { + memset(&e, 0, sizeof(NodeInfoPayloadEntry)); + nodeInfoExpired++; + } + } + TM_LOG_DEBUG("NodeInfo PSRAM cache: %u/%u (%u expired)", static_cast(countNodeInfoEntriesLocked()), + static_cast(nodeInfoTargetEntries()), static_cast(nodeInfoExpired)); } #endif @@ -1203,10 +1218,11 @@ bool TrafficManagementModule::shouldRespondToNodeInfo(const meshtastic_MeshPacke // Staleness gate (PSRAM cache path): never spoof a reply on behalf of a node we have // not actually heard from within the serve window. cachedLastObservedMs is only set on // a PSRAM cache hit, so this leaves the NodeDB fallback (gated above) untouched. - // TODO(T4): this age uses millis(); at a ~49.7-day uptime wrap boundary an entry that - // is never LRU-evicted can wrap to a small age and read as fresh, defeating the gate. - // TODO(T9): 0 is the "never observed" sentinel, but clockMs()==0 is a real value at the - // millis wrap instant, which would skip this gate for that entry until re-observed. + // Wrap safety (T4): the modular age below is only correct while true age < 2^32 ms + // (~49.7 days); an entry that lingered that long could wrap to a small age and read as + // fresh. The maintenance sweep evicts NodeInfo entries once they exceed the serve window + // (see runOnce()), so an entry is gone long before its age can approach the wrap, keeping + // this compare unambiguous. The 0-sentinel instant (T9) is handled by nowStampMs(). if (cachedLastObservedMs != 0 && (clockMs() - cachedLastObservedMs) > kNodeInfoMaxServeAgeMs) { TM_LOG_DEBUG("NodeInfo PSRAM entry for 0x%08x stale (age=%lu ms), not responding", p->to, static_cast(clockMs() - cachedLastObservedMs)); @@ -1288,11 +1304,10 @@ bool TrafficManagementModule::shouldRespondToNodeInfo(const meshtastic_MeshPacke // Record the send so the throttle can suppress a burst of requests. The fallback path // stamps the module-global marker; the PSRAM path stamps the per-node entry we served - // from. clockMs()==0 (the millis wrap instant) collides with the "never" sentinel and - // skips one window - the same negligible T9 edge as the staleness gate. + // from. nowStampMs() keeps the stamp off the 0 "never" sentinel at the millis wrap (T9). if (usedFallback) { concurrency::LockGuard guard(&cacheLock); - nodeInfoFallbackLastResponseMs = clockMs(); + nodeInfoFallbackLastResponseMs = nowStampMs(); } // TODO(T5): the PSRAM stamp below is a second full O(n) scan under a second lock, after // findNodeInfoEntry() already located this slot at the top of the function. Capture the @@ -1302,7 +1317,7 @@ bool TrafficManagementModule::shouldRespondToNodeInfo(const meshtastic_MeshPacke concurrency::LockGuard guard(&cacheLock); for (uint16_t i = 0; i < nodeInfoTargetEntries(); i++) { if (nodeInfoPayload[i].node == p->to) { - nodeInfoPayload[i].lastResponseMs = clockMs(); + nodeInfoPayload[i].lastResponseMs = nowStampMs(); break; } } diff --git a/src/modules/TrafficManagementModule.h b/src/modules/TrafficManagementModule.h index 95da65860ec..55cd0e9b7e3 100644 --- a/src/modules/TrafficManagementModule.h +++ b/src/modules/TrafficManagementModule.h @@ -80,6 +80,18 @@ class TrafficManagementModule : public MeshModule, private concurrency::OSThread static uint32_t clockMs() { return millis(); } #endif + // Timestamp for the millis-based fields that use 0 as a "never" sentinel + // (NodeInfoPayloadEntry::lastObservedMs / lastResponseMs, nodeInfoFallbackLastResponseMs). + // clockMs() is 0 for exactly one millisecond every ~49.7-day wrap, which would collide + // with the sentinel and momentarily disable the staleness/throttle gate for a freshly + // stamped entry. Map that one instant to 1; the 1 ms skew is irrelevant to every window + // these fields feed. (T9) + static uint32_t nowStampMs() + { + const uint32_t t = clockMs(); + return t == 0 ? 1u : t; + } + protected: ProcessMessage handleReceived(const meshtastic_MeshPacket &mp) override; bool wantPacket(const meshtastic_MeshPacket *p) override { return true; } From 02b5671e60b4a095bd982562ddce07155498cb47 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 17 Jul 2026 00:38:16 +0000 Subject: [PATCH 06/44] Stamp PSRAM throttle entry by captured index, not a rescan (T5) The post-send throttle stamp did a second full O(n) scan of the PSRAM NodeInfo array under a fresh lock, even though findNodeInfoEntry() had already located the slot at the top of shouldRespondToNodeInfo(). Capture the slot index during that initial lookup and address the entry directly on the stamp path. Because the cache lock is released between the two accesses, the slot could have been evicted or reused, so re-validate node == p->to under the lock before stamping. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01G7QggdwMM1CJkguuCmMfZs --- src/modules/TrafficManagementModule.cpp | 26 +++++++++++++++---------- 1 file changed, 16 insertions(+), 10 deletions(-) diff --git a/src/modules/TrafficManagementModule.cpp b/src/modules/TrafficManagementModule.cpp index 6fd0d8bbd66..6a246d1767f 100644 --- a/src/modules/TrafficManagementModule.cpp +++ b/src/modules/TrafficManagementModule.cpp @@ -1163,6 +1163,11 @@ bool TrafficManagementModule::shouldRespondToNodeInfo(const meshtastic_MeshPacke // shared throttle check/stamp below target the module-global fallback stamp instead of // a per-node PSRAM entry (which does not exist on this path). bool usedFallback = false; +#if defined(ARCH_ESP32) && defined(BOARD_HAS_PSRAM) + // Slot index of the PSRAM cache hit, captured here so the post-send throttle stamp can + // address the entry directly instead of rescanning the array (T5). -1 = no hit. + int32_t cachedNodeInfoIndex = -1; +#endif { concurrency::LockGuard guard(&cacheLock); @@ -1176,6 +1181,9 @@ bool TrafficManagementModule::shouldRespondToNodeInfo(const meshtastic_MeshPacke cachedLastObservedMs = entry->lastObservedMs; cachedLastObservedRxTime = entry->lastObservedRxTime; cachedLastResponseMs = entry->lastResponseMs; +#if defined(ARCH_ESP32) && defined(BOARD_HAS_PSRAM) + cachedNodeInfoIndex = static_cast(entry - nodeInfoPayload); +#endif } } @@ -1309,18 +1317,16 @@ bool TrafficManagementModule::shouldRespondToNodeInfo(const meshtastic_MeshPacke concurrency::LockGuard guard(&cacheLock); nodeInfoFallbackLastResponseMs = nowStampMs(); } - // TODO(T5): the PSRAM stamp below is a second full O(n) scan under a second lock, after - // findNodeInfoEntry() already located this slot at the top of the function. Capture the - // index in the first pass (re-validate node == p->to after re-locking) to avoid the rescan. + // Stamp the PSRAM entry we served from, addressing it by the index captured during the + // initial lookup rather than rescanning the array (T5). The cache lock was released in + // between, so the slot could have been evicted or reused for a different node; re-validate + // node == p->to under the lock and skip the stamp if it no longer matches. #if defined(ARCH_ESP32) && defined(BOARD_HAS_PSRAM) - if (!usedFallback && nodeInfoPayload) { + if (!usedFallback && nodeInfoPayload && cachedNodeInfoIndex >= 0) { concurrency::LockGuard guard(&cacheLock); - for (uint16_t i = 0; i < nodeInfoTargetEntries(); i++) { - if (nodeInfoPayload[i].node == p->to) { - nodeInfoPayload[i].lastResponseMs = nowStampMs(); - break; - } - } + NodeInfoPayloadEntry &e = nodeInfoPayload[cachedNodeInfoIndex]; + if (e.node == p->to) + e.lastResponseMs = nowStampMs(); } #endif From 7eedec253280c74e54918f1b3083265da117cce3 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 17 Jul 2026 00:38:52 +0000 Subject: [PATCH 07/44] Document accepted per-target throttle tradeoffs (T1, T2) Convert the T1/T2 TODO markers into a NOTE: the per-target PSRAM throttle keying and its lack of an aggregate bound are deliberate design choices, not pending work. A distinct requestor being throttled for one window is harmless on a redundant mesh, and keeping the PSRAM path per-target preserves throughput for legitimate multi-target responders (the fallback path already bounds aggregate via its global stamp). Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01G7QggdwMM1CJkguuCmMfZs --- src/modules/TrafficManagementModule.cpp | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/src/modules/TrafficManagementModule.cpp b/src/modules/TrafficManagementModule.cpp index 6a246d1767f..b42f17dbfaa 100644 --- a/src/modules/TrafficManagementModule.cpp +++ b/src/modules/TrafficManagementModule.cpp @@ -1247,13 +1247,16 @@ bool TrafficManagementModule::shouldRespondToNodeInfo(const meshtastic_MeshPacke // than letting it propagate and generate more mesh traffic. // - PSRAM path: cachedLastResponseMs is per target node (NodeInfoPayloadEntry). // - Fallback path: cachedLastResponseMs is the module-global stamp (no per-node slot). - // TODO(T1): on the PSRAM path this is keyed on p->to, so a DISTINCT legitimate requestor - // for the same target gets no reply for up to the window (returning true STOPs it). - // Accepted: the mesh is redundant - the genuine node or another cache-holder answers. - // TODO(T2): the per-target PSRAM key does not bound aggregate local TX - an attacker - // cycling N distinct cached targets still gets N spoofed transmits per window. The - // fallback path's global stamp does bound aggregate; a global cap on the PSRAM path - // would too, at the cost of throughput on multi-target responders. + // NOTE (accepted design, not a defect): + // - On the PSRAM path this is keyed on p->to, so a DISTINCT legitimate requestor for the + // same target gets no reply for up to one window (returning true STOPs it). That is + // fine: the mesh is redundant - the genuine node or another cache-holder answers, and + // a real node would rate-limit its own replies the same way. + // - The per-target PSRAM key does not bound aggregate local TX: an attacker cycling N + // distinct cached targets still draws N spoofed transmits per window. The fallback + // path's global stamp does bound aggregate; a global cap on the PSRAM path would too, + // but at the cost of throughput on legitimate multi-target responders, so it is left + // per-target by choice. if (cachedLastResponseMs != 0 && (clockMs() - cachedLastResponseMs) < kNodeInfoResponseThrottleMs) { TM_LOG_DEBUG("NodeInfo response throttled for 0x%08x (%lu ms since last)", p->to, static_cast(clockMs() - cachedLastResponseMs)); From 8f792a4de1cced474a37d51608705fff4a07bd8c Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 17 Jul 2026 01:03:59 +0000 Subject: [PATCH 08/44] Track signed-provenance of cached NodeInfo public keys Add keySignerProven to NodeInfoPayloadEntry, set when an observed NODEINFO frame's XEdDSA signature was verified (mp.xeddsa_signed). This distinguishes a trust-on-first-use key from one proven to belong to a signer. The flag is monotonic - once proven it stays proven, and the existing key-pin checks forbid the underlying key from changing - so a later unsigned frame cannot downgrade it. A signature can only be verified against a key we already held, so a first-contact key is always TOFU until a later signed frame upgrades it. Groundwork for using the TMM cache as a last-resort public-key source, where this flag serves as a trust tier. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01G7QggdwMM1CJkguuCmMfZs --- src/modules/TrafficManagementModule.cpp | 8 ++++++++ src/modules/TrafficManagementModule.h | 10 ++++++++++ 2 files changed, 18 insertions(+) diff --git a/src/modules/TrafficManagementModule.cpp b/src/modules/TrafficManagementModule.cpp index b42f17dbfaa..a4be603dca5 100644 --- a/src/modules/TrafficManagementModule.cpp +++ b/src/modules/TrafficManagementModule.cpp @@ -586,6 +586,14 @@ void TrafficManagementModule::cacheNodeInfoPacket(const meshtastic_MeshPacket &m entry->hasDecodedBitfield = mp.decoded.has_bitfield; entry->decodedBitfield = mp.decoded.bitfield; + // Upgrade key provenance to "signer-proven" if this frame's XEdDSA signature was + // verified (Router::checkXeddsaReceivePolicy sets mp.xeddsa_signed only after a + // successful verify against the node's key). Never downgrade: a later unsigned frame + // for an already-proven key leaves the flag set. The key itself cannot change here - + // the key-pin checks above reject a mismatching key before we reach this point. + if (mp.xeddsa_signed && user.public_key.size == 32) + entry->keySignerProven = true; + if (usedEmptySlot) cachedCount = countNodeInfoEntriesLocked(); } diff --git a/src/modules/TrafficManagementModule.h b/src/modules/TrafficManagementModule.h index 55cd0e9b7e3..db67ec69cc9 100644 --- a/src/modules/TrafficManagementModule.h +++ b/src/modules/TrafficManagementModule.h @@ -260,6 +260,16 @@ class TrafficManagementModule : public MeshModule, private concurrency::OSThread // We preserve non-OK_TO_MQTT bits in direct replies when available. bool hasDecodedBitfield; uint8_t decodedBitfield; + + // Provenance of user.public_key: true once we have observed a NODEINFO_APP frame for + // this node whose XEdDSA signature we verified (mp.xeddsa_signed) - i.e. the key is + // proven to belong to a signer, not merely trust-on-first-use. Monotonic: once proven + // it stays proven for the life of the slot (the key-pin checks forbid the key changing + // underneath it). Used as a trust tier: proven keys are the stickiest under LRU + // eviction and are reported to copyPublicKey() consumers. A signature can only be + // verified against a key we already held, so a first-contact key is always TOFU + // (false) until a later signed frame upgrades it. + bool keySignerProven; }; NodeInfoPayloadEntry *nodeInfoPayload = nullptr; // NodeInfo payloads in PSRAM (flat array, linear scan) From 482ef53b55564e72700d00e266319c7e37fe19da Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 17 Jul 2026 01:05:13 +0000 Subject: [PATCH 09/44] Draw public keys from the TMM NodeInfo cache as a last resort Add TrafficManagementModule::copyPublicKey() and consult it from NodeDB::copyPublicKey() after the hot (NodeInfoLite) and warm (WarmNodeStore) tiers miss. This extends the pool of peers the node can encrypt to: a key the NodeInfo direct-response cache overheard for a node that has since aged out of both NodeDB tiers can still be used. The getter reports whether the key is signer-proven or trust-on-first-use. NodeDB serves TOFU keys here too - the same first-contact trust NodeDB already applies in updateUser() - so the pool actually expands to new long-tail nodes rather than only re-confirming known keys. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01G7QggdwMM1CJkguuCmMfZs --- src/mesh/NodeDB.cpp | 13 +++++++++++++ src/modules/TrafficManagementModule.cpp | 23 +++++++++++++++++++++++ src/modules/TrafficManagementModule.h | 9 +++++++++ 3 files changed, 45 insertions(+) diff --git a/src/mesh/NodeDB.cpp b/src/mesh/NodeDB.cpp index 4c2879adc21..f09607ae921 100644 --- a/src/mesh/NodeDB.cpp +++ b/src/mesh/NodeDB.cpp @@ -31,6 +31,9 @@ #if HAS_VARIABLE_HOPS #include "modules/HopScalingModule.h" #endif +#if HAS_TRAFFIC_MANAGEMENT +#include "modules/TrafficManagementModule.h" +#endif #include "xmodem.h" #include #include @@ -3700,6 +3703,16 @@ bool NodeDB::copyPublicKey(NodeNum n, meshtastic_NodeInfoLite_public_key_t &out) out.size = 32; return true; } +#endif +#if HAS_TRAFFIC_MANAGEMENT + // Last resort: a key the TrafficManagement NodeInfo cache learned from an observed frame + // for a node no longer in either NodeDB tier. This extends the pool of peers we can + // encrypt to. Keys here may be trust-on-first-use (see copyPublicKey's signerProven), the + // same first-contact trust NodeDB itself applies via updateUser(). + if (trafficManagementModule && trafficManagementModule->copyPublicKey(n, out.bytes)) { + out.size = 32; + return true; + } #endif return false; } diff --git a/src/modules/TrafficManagementModule.cpp b/src/modules/TrafficManagementModule.cpp index a4be603dca5..871f1396ba6 100644 --- a/src/modules/TrafficManagementModule.cpp +++ b/src/modules/TrafficManagementModule.cpp @@ -509,6 +509,29 @@ uint16_t TrafficManagementModule::countNodeInfoEntriesLocked() const #endif } +bool TrafficManagementModule::copyPublicKey(NodeNum node, uint8_t out[32], bool *signerProven) const +{ +#if defined(ARCH_ESP32) && defined(BOARD_HAS_PSRAM) + if (!nodeInfoPayload || node == 0 || !out) + return false; + + concurrency::LockGuard guard(&cacheLock); + const NodeInfoPayloadEntry *entry = findNodeInfoEntry(node); + if (!entry || entry->user.public_key.size != 32) + return false; + + memcpy(out, entry->user.public_key.bytes, 32); + if (signerProven) + *signerProven = entry->keySignerProven; + return true; +#else + (void)node; + (void)out; + (void)signerProven; + return false; +#endif +} + #if defined(ARCH_ESP32) && defined(BOARD_HAS_PSRAM) && !(MESHTASTIC_EXCLUDE_PKI) // True iff both are full 32-byte public keys with identical bytes. Single point of // truth for the NodeInfo-cache key-hygiene checks (owner impersonation + key pinning), diff --git a/src/modules/TrafficManagementModule.h b/src/modules/TrafficManagementModule.h index db67ec69cc9..262ad16a27a 100644 --- a/src/modules/TrafficManagementModule.h +++ b/src/modules/TrafficManagementModule.h @@ -59,6 +59,15 @@ class TrafficManagementModule : public MeshModule, private concurrency::OSThread // prerequisites (cache, nodeDB) weren't ready yet, so the caller should retry. bool preloadNextHopsFromNodeDB(); + // Last-resort public-key source for NodeDB::copyPublicKey(). After the hot (NodeInfoLite) + // and warm (WarmNodeStore) tiers miss, the NodeInfo direct-response cache may still hold a + // key learned from an observed NODEINFO frame, extending the pool of keys the node can + // encrypt to. Copies the 32-byte key for `node` into out[32] and returns true if present; + // false otherwise (including builds without the PSRAM NodeInfo cache). If `signerProven` + // is non-null, reports whether that key was verified via an XEdDSA signature (true) or is + // trust-on-first-use (false), so callers can weigh its trust. Thread-safe (takes cacheLock). + bool copyPublicKey(NodeNum node, uint8_t out[32], bool *signerProven = nullptr) const; + /** * Check if this packet should have its hops exhausted. * Called from perhapsRebroadcast() to force hop_limit = 0 regardless of From 16d302acd64a47a780fbed842f6986ea64b52a34 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 17 Jul 2026 01:06:21 +0000 Subject: [PATCH 10/44] Retain keyed NodeInfo entries as a pubkey pool; trust-tier eviction Now that NodeDB::copyPublicKey() draws keys from the NodeInfo cache, the flat 6 h serve-window eviction would discard useful keys the moment a node stops being servable. Split retention: an entry carrying a 32-byte public key is kept for kNodeInfoKeyRetentionMs (7 d), while a keyless entry still expires at the serve window. Both windows stay well under the ~49.7-day millis wrap, preserving the T4 wrap-safety guarantee. Make LRU victim selection trust-tiered to match: a keyless slot is sacrificed before any keyed one, and a trust-on-first-use key before a signer-proven key; within a tier the oldest loses. Mirrors WarmNodeStore's keyed-first admission so the most valuable keys are the stickiest under memory pressure. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01G7QggdwMM1CJkguuCmMfZs --- src/modules/TrafficManagementModule.cpp | 43 ++++++++++++++++++------- 1 file changed, 32 insertions(+), 11 deletions(-) diff --git a/src/modules/TrafficManagementModule.cpp b/src/modules/TrafficManagementModule.cpp index 871f1396ba6..85977bbf12e 100644 --- a/src/modules/TrafficManagementModule.cpp +++ b/src/modules/TrafficManagementModule.cpp @@ -50,6 +50,16 @@ constexpr uint32_t kClientDefaultMaxHops = 0; // Clients: direct only (cannot in constexpr uint32_t kNodeInfoMaxServeAgeMs = 6UL * 60UL * 60UL * 1000UL; // 6 h (PSRAM cache path) constexpr uint32_t kNodeInfoMaxServeAgeSecs = kNodeInfoMaxServeAgeMs / 1000UL; // 6 h (NodeDB fallback path) +// Key-retention window: how long a NodeInfo cache entry that carries a 32-byte public key is +// kept after we last heard the node. This is deliberately much longer than the serve window +// (above): once a node ages past the serve window we stop spoofing NodeInfo replies for it, +// but its key remains valuable as a last-resort encryption source (NodeDB::copyPublicKey), +// so the entry is retained to widen the pool of peers we can encrypt to. Kept well under the +// ~49.7-day millis wrap so the maintenance sweep always evicts a stale entry before its +// modular age could wrap (the wrap-safety guarantee from T4). Keyless entries do not get this +// grace - they expire at the serve window since they hold nothing worth retaining. +constexpr uint32_t kNodeInfoKeyRetentionMs = 7UL * 24UL * 60UL * 60UL * 1000UL; // 7 d + // Throttle: emit at most one spoofed direct reply per target node per this interval. // Direct responses are otherwise un-throttled (they STOP the request before the // per-sender rate limiter runs) and the reply target is attacker-controlled, so an @@ -441,9 +451,13 @@ const TrafficManagementModule::NodeInfoPayloadEntry *TrafficManagementModule::fi /** * Find or create a NodeInfo payload entry (linear scan of the flat PSRAM - * array). One pass tracks the match, the first empty slot, and the LRU - * victim by lastObservedMs (wrap-safe age). NodeInfo traffic is low-rate, - * so the O(n) scan is negligible. + * array). One pass tracks the match, the first empty slot, and the eviction + * victim. Victim selection is trust-tiered so the cache doubles as a pubkey + * pool (NodeDB::copyPublicKey): a keyless entry is sacrificed before any + * keyed one, and a trust-on-first-use key before a signer-proven key; within + * a tier the oldest (wrap-safe age by lastObservedMs) loses. Mirrors + * WarmNodeStore's keyed-first admission. NodeInfo traffic is low-rate, so the + * O(n) scan is negligible. */ TrafficManagementModule::NodeInfoPayloadEntry *TrafficManagementModule::findOrCreateNodeInfoEntry(NodeNum node, bool *usedEmptySlot) @@ -457,6 +471,7 @@ TrafficManagementModule::NodeInfoPayloadEntry *TrafficManagementModule::findOrCr NodeInfoPayloadEntry *empty = nullptr; NodeInfoPayloadEntry *lru = nullptr; + uint8_t lruTier = 0xFF; uint32_t lruAge = 0; const uint32_t now = clockMs(); @@ -470,10 +485,13 @@ TrafficManagementModule::NodeInfoPayloadEntry *TrafficManagementModule::findOrCr continue; } if (empty) - continue; // an empty slot beats any victim; stop scoring + continue; // an empty slot beats any victim; stop scoring + // Eviction tier (lower loses first): 0 keyless, 1 TOFU key, 2 signer-proven key. + const uint8_t tier = (e.user.public_key.size != 32) ? 0 : (e.keySignerProven ? 2 : 1); const uint32_t age = now - e.lastObservedMs; // unsigned subtraction is wrap-safe - if (!lru || age > lruAge) { + if (!lru || tier < lruTier || (tier == lruTier && age > lruAge)) { lru = &e; + lruTier = tier; lruAge = age; } } @@ -1063,17 +1081,20 @@ int32_t TrafficManagementModule::runOnce() #if defined(ARCH_ESP32) && defined(BOARD_HAS_PSRAM) if (nodeInfoPayload) { - // Evict NodeInfo payloads past the serve window. Beyond freeing slots, this bounds - // how long any entry can sit unrefreshed: the staleness gate's modular age compare - // (shouldRespondToNodeInfo) is only unambiguous while true age < 2^32 ms (~49.7 d), - // so removing stale entries here keeps them far from that wrap boundary (T4). Uses - // the same clock as the gate; entries stamped with nowStampMs() so 0 means "never". + // Evict stale NodeInfo payloads. Two windows: an entry carrying a 32-byte public key + // is retained for kNodeInfoKeyRetentionMs (it is a last-resort encryption key source, + // see NodeDB::copyPublicKey), while a keyless entry expires at the serve window since + // it holds nothing worth keeping past that point. Both windows sit well under the + // ~49.7-day millis wrap, so an entry is always removed before its modular age could + // wrap and read as fresh - the wrap-safety guarantee behind the staleness gate (T4). + // Entries are stamped with nowStampMs() so 0 reliably means "never observed". uint16_t nodeInfoExpired = 0; for (uint16_t i = 0; i < nodeInfoTargetEntries(); i++) { NodeInfoPayloadEntry &e = nodeInfoPayload[i]; if (e.node == 0 || e.lastObservedMs == 0) continue; - if ((nowMs - e.lastObservedMs) > kNodeInfoMaxServeAgeMs) { + const uint32_t ttl = (e.user.public_key.size == 32) ? kNodeInfoKeyRetentionMs : kNodeInfoMaxServeAgeMs; + if ((nowMs - e.lastObservedMs) > ttl) { memset(&e, 0, sizeof(NodeInfoPayloadEntry)); nodeInfoExpired++; } From 0756fb148cdeb14c9a08fe1b95ce1108cfe78132 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 17 Jul 2026 01:09:17 +0000 Subject: [PATCH 11/44] Test signed-provenance flag and copyPublicKey key-pool source PSRAM-gated tests (run on the ESP32 build, alongside the existing NodeInfo PSRAM tests): - copyPublicKey serves a TOFU key learned from an unsigned NodeInfo and reports signerProven=false - a later signature-verified NodeInfo upgrades provenance to signer-proven while the pinned key bytes stay unchanged - copyPublicKey reports a miss for an uncached node Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01G7QggdwMM1CJkguuCmMfZs --- test/test_traffic_management/test_main.cpp | 77 ++++++++++++++++++++++ 1 file changed, 77 insertions(+) diff --git a/test/test_traffic_management/test_main.cpp b/test/test_traffic_management/test_main.cpp index baa0f582b46..b7e033114d1 100644 --- a/test/test_traffic_management/test_main.cpp +++ b/test/test_traffic_management/test_main.cpp @@ -928,6 +928,80 @@ static void test_tm_nodeinfo_cache_rejectsMismatchedKey(void) TEST_ASSERT_EQUAL_UINT8(0x11, served.public_key.bytes[0]); TEST_ASSERT_EQUAL_UINT8(0x11, served.public_key.bytes[31]); } + +/** + * Feature #2: a key learned from an (unsigned) NodeInfo is served by copyPublicKey() as a + * trust-on-first-use key, so it can extend the encryption pool. signerProven must be false. + */ +static void test_tm_nodeinfo_copyPublicKey_servesTofuKey(void) +{ + moduleConfig.traffic_management.nodeinfo_direct_response_max_hops = 10; + config.device.role = meshtastic_Config_DeviceConfig_Role_CLIENT; + mockNodeDB->clearCachedNode(); + + MockRouter mockRouter; + mockRouter.addInterface(std::unique_ptr(new MockRadioInterface())); + MeshService mockService; + router = &mockRouter; + service = &mockService; + + TrafficManagementModuleTestShim module; + module.handleReceived(makeNodeInfoPacketWithKey(kTargetNode, "genuine", 0x33)); + + uint8_t key[32] = {0}; + bool proven = true; // must be overwritten to false + TEST_ASSERT_TRUE(module.copyPublicKey(kTargetNode, key, &proven)); + TEST_ASSERT_EQUAL_UINT8(0x33, key[0]); + TEST_ASSERT_EQUAL_UINT8(0x33, key[31]); + TEST_ASSERT_FALSE(proven); +} + +/** + * Feature #1: a later signature-verified NodeInfo upgrades the cached key's provenance to + * signer-proven (monotonic), while the key bytes stay pinned. + */ +static void test_tm_nodeinfo_copyPublicKey_upgradesToSignerProven(void) +{ + moduleConfig.traffic_management.nodeinfo_direct_response_max_hops = 10; + config.device.role = meshtastic_Config_DeviceConfig_Role_CLIENT; + mockNodeDB->clearCachedNode(); + + MockRouter mockRouter; + mockRouter.addInterface(std::unique_ptr(new MockRadioInterface())); + MeshService mockService; + router = &mockRouter; + service = &mockService; + + TrafficManagementModuleTestShim module; + + // First contact is unsigned -> TOFU. + module.handleReceived(makeNodeInfoPacketWithKey(kTargetNode, "genuine", 0x44)); + uint8_t key[32] = {0}; + bool proven = true; + TEST_ASSERT_TRUE(module.copyPublicKey(kTargetNode, key, &proven)); + TEST_ASSERT_FALSE(proven); + + // A later frame whose signature we verified upgrades provenance. + meshtastic_MeshPacket signed_ni = makeNodeInfoPacketWithKey(kTargetNode, "genuine", 0x44); + signed_ni.xeddsa_signed = true; + module.handleReceived(signed_ni); + + proven = false; + TEST_ASSERT_TRUE(module.copyPublicKey(kTargetNode, key, &proven)); + TEST_ASSERT_TRUE(proven); + TEST_ASSERT_EQUAL_UINT8(0x44, key[0]); // key unchanged +} + +/** + * copyPublicKey() reports a miss (false) for a node we have never cached. + */ +static void test_tm_nodeinfo_copyPublicKey_missReturnsFalse(void) +{ + mockNodeDB->clearCachedNode(); + TrafficManagementModuleTestShim module; + uint8_t key[32] = {0}; + TEST_ASSERT_FALSE(module.copyPublicKey(kTargetNode, key, nullptr)); +} #endif // !MESHTASTIC_EXCLUDE_PKI #endif @@ -2054,6 +2128,9 @@ TM_TEST_ENTRY void setup() RUN_TEST(test_tm_nodeinfo_directResponse_psramThrottlesWithinWindow); #if !(MESHTASTIC_EXCLUDE_PKI) RUN_TEST(test_tm_nodeinfo_cache_rejectsMismatchedKey); + RUN_TEST(test_tm_nodeinfo_copyPublicKey_servesTofuKey); + RUN_TEST(test_tm_nodeinfo_copyPublicKey_upgradesToSignerProven); + RUN_TEST(test_tm_nodeinfo_copyPublicKey_missReturnsFalse); #endif #endif RUN_TEST(test_tm_alterReceived_telemetryBroadcast_hopLimitUnchanged); From b240726b1b339bc2cd76189b381438c439aa70c4 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 17 Jul 2026 01:18:47 +0000 Subject: [PATCH 12/44] Inherit signer provenance from NodeDB when caching a re-found node When the TrafficManagement NodeInfo cache re-caches a node, mark its key signer-proven if NodeDB already knows the node as a verified signer for that same key - even if this particular (unicast/unsigned) frame carried no signature. Previously the flag only upgraded on a frame we verified ourselves, so a node we had already proven elsewhere looked TOFU here. Add NodeDB::isVerifiedSignerForKey(), which checks both tiers - the hot store's signed bitfield and the warm tier's cached signer bit - and requires the key to match so a rotated/mismatched key never inherits a stale verdict. Add WarmNodeStore::isVerifiedSigner() to expose the warm signer bit (the rebase onto develop added the bit itself; this surfaces it for lookups). Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01G7QggdwMM1CJkguuCmMfZs --- src/mesh/NodeDB.cpp | 17 +++++++++++++++++ src/mesh/NodeDB.h | 7 +++++++ src/mesh/WarmNodeStore.cpp | 6 ++++++ src/mesh/WarmNodeStore.h | 7 ++++++- src/modules/TrafficManagementModule.cpp | 23 +++++++++++++++++------ 5 files changed, 53 insertions(+), 7 deletions(-) diff --git a/src/mesh/NodeDB.cpp b/src/mesh/NodeDB.cpp index f09607ae921..040ff0e019e 100644 --- a/src/mesh/NodeDB.cpp +++ b/src/mesh/NodeDB.cpp @@ -3717,6 +3717,23 @@ bool NodeDB::copyPublicKey(NodeNum n, meshtastic_NodeInfoLite_public_key_t &out) return false; } +bool NodeDB::isVerifiedSignerForKey(NodeNum n, const uint8_t *key32) +{ + if (!key32) + return false; + // Hot store is authoritative when present; a node lives in the hot XOR warm tier, so if the + // hot store holds it the warm tier does not, and we decide entirely from the hot entry. + const meshtastic_NodeInfoLite *info = getMeshNode(n); + if (info) + return info->public_key.size == 32 && nodeInfoLiteHasXeddsaSigned(info) && memcmp(info->public_key.bytes, key32, 32) == 0; +#if WARM_NODE_COUNT > 0 + uint8_t warmKey[32]; + if (warmStore.copyKey(n, warmKey) && memcmp(warmKey, key32, 32) == 0) + return warmStore.isVerifiedSigner(n); +#endif + return false; +} + meshtastic_Config_DeviceConfig_Role NodeDB::getNodeRole(NodeNum n) { const meshtastic_NodeInfoLite *info = getMeshNode(n); diff --git a/src/mesh/NodeDB.h b/src/mesh/NodeDB.h index f7494811f68..eb65848caa7 100644 --- a/src/mesh/NodeDB.h +++ b/src/mesh/NodeDB.h @@ -360,6 +360,13 @@ class NodeDB /// tier. Returns false if we don't know a key for n. bool copyPublicKey(NodeNum n, meshtastic_NodeInfoLite_public_key_t &out); + // True if node `n` is a known XEdDSA signer for exactly the 32-byte key `key32`, per either + // NodeDB tier: the hot store's signed bitfield or the warm tier's cached signer bit. The + // key must match so a rotated/mismatched key never inherits a stale signer verdict. Lets + // an opportunistic cache (e.g. TrafficManagement's NodeInfo cache) mark a re-found node's + // key signer-proven from what NodeDB already knows, without re-verifying a signature. + bool isVerifiedSignerForKey(NodeNum n, const uint8_t *key32); + /// Resolve a node's device role - hot store (with user) first, then the role /// cached in the warm tier, else CLIENT. Lets role-aware policy keep firing for /// nodes that have aged out of the hot store. diff --git a/src/mesh/WarmNodeStore.cpp b/src/mesh/WarmNodeStore.cpp index cce88e6b058..d36cd67dbd0 100644 --- a/src/mesh/WarmNodeStore.cpp +++ b/src/mesh/WarmNodeStore.cpp @@ -161,6 +161,12 @@ bool WarmNodeStore::lookupMeta(NodeNum num, uint8_t &role, uint8_t &protectedCat return true; } +bool WarmNodeStore::isVerifiedSigner(NodeNum num) const +{ + const WarmNodeEntry *e = find(num); + return e && warmSignerOf(*e); +} + bool WarmNodeStore::take(NodeNum num, WarmNodeEntry &out) { WarmNodeEntry *e = find(num); diff --git a/src/mesh/WarmNodeStore.h b/src/mesh/WarmNodeStore.h index 96bfd3ec0ee..3fe92496c11 100644 --- a/src/mesh/WarmNodeStore.h +++ b/src/mesh/WarmNodeStore.h @@ -94,7 +94,7 @@ inline bool warmSignerOf(const WarmNodeEntry &e) #define WARM_FLASH_PAGE_SIZE 4096u #define WARM_FLASH_PAGES 3u #define WARM_FLASH_REGION_BASE (0xED000u - WARM_FLASH_PAGES * WARM_FLASH_PAGE_SIZE) // 0xEA000 -#define WARM_FLASH_PAGE_ADDR(i) (WARM_FLASH_REGION_BASE + (i)*WARM_FLASH_PAGE_SIZE) +#define WARM_FLASH_PAGE_ADDR(i) (WARM_FLASH_REGION_BASE + (i) * WARM_FLASH_PAGE_SIZE) #endif class WarmNodeStore @@ -119,6 +119,11 @@ class WarmNodeStore /// @return false if the node is not in the warm tier. bool lookupMeta(NodeNum num, uint8_t &role, uint8_t &protectedCat) const; + /// @return true if the warm tier holds this node AND its cached signer bit is set + /// (we verified an XEdDSA signature from it before it was evicted). False if absent + /// or not a known signer. + bool isVerifiedSigner(NodeNum num) const; + /// Find and remove an entry (used when the node is re-admitted to the hot store). bool take(NodeNum num, WarmNodeEntry &out); diff --git a/src/modules/TrafficManagementModule.cpp b/src/modules/TrafficManagementModule.cpp index 85977bbf12e..eedc048b20e 100644 --- a/src/modules/TrafficManagementModule.cpp +++ b/src/modules/TrafficManagementModule.cpp @@ -576,6 +576,10 @@ void TrafficManagementModule::cacheNodeInfoPacket(const meshtastic_MeshPacket &m // Normalize user.id to the packet sender's node number. snprintf(user.id, sizeof(user.id), "!%08x", from); + // Whether NodeDB already knows this node as a verified signer for this key. Lets a + // re-found node inherit proven provenance even when THIS frame happens to be unsigned. + bool dbSaysSigner = false; + #if !(MESHTASTIC_EXCLUDE_PKI) // Mirror NodeDB::updateUser() key hygiene before we let an overheard NodeInfo into // the direct-response cache. This cache is served back to requestors as a spoofed @@ -595,6 +599,12 @@ void TrafficManagementModule::cacheNodeInfoPacket(const meshtastic_MeshPacket &m TM_LOG_WARN("NodeInfo cache: public key mismatch vs NodeDB, dropping 0x%08x", from); return; } + // Re-found in NodeDB as a known signer: inherit that verdict so the cached key is proven + // even when this particular frame was unsigned. isVerifiedSignerForKey consults both the + // hot store's signed bitfield and the warm tier's cached signer bit, and requires the key + // to match, so a warm-only signer is covered and a rotated key never inherits a stale + // verdict. + dbSaysSigner = nodeDB && user.public_key.size == 32 && nodeDB->isVerifiedSignerForKey(from, user.public_key.bytes); #endif bool usedEmptySlot = false; @@ -627,12 +637,13 @@ void TrafficManagementModule::cacheNodeInfoPacket(const meshtastic_MeshPacket &m entry->hasDecodedBitfield = mp.decoded.has_bitfield; entry->decodedBitfield = mp.decoded.bitfield; - // Upgrade key provenance to "signer-proven" if this frame's XEdDSA signature was - // verified (Router::checkXeddsaReceivePolicy sets mp.xeddsa_signed only after a - // successful verify against the node's key). Never downgrade: a later unsigned frame - // for an already-proven key leaves the flag set. The key itself cannot change here - - // the key-pin checks above reject a mismatching key before we reach this point. - if (mp.xeddsa_signed && user.public_key.size == 32) + // Upgrade key provenance to "signer-proven" when either this frame's XEdDSA signature + // was verified (Router::checkXeddsaReceivePolicy sets mp.xeddsa_signed only after a + // successful verify against the node's key) or NodeDB already knows this node as a + // signer for this same key (dbSaysSigner). Never downgrade: a later unsigned frame for + // an already-proven key leaves the flag set. The key itself cannot change here - the + // key-pin checks above reject a mismatching key before we reach this point. + if ((mp.xeddsa_signed || dbSaysSigner) && user.public_key.size == 32) entry->keySignerProven = true; if (usedEmptySlot) From 04b89527001205e80ac052273e587377a379e23e Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 17 Jul 2026 01:28:36 +0000 Subject: [PATCH 13/44] Lock NodeInfoPayloadEntry packing with a size assert keySignerProven cost zero bytes: sourceChannel, the two bools, and decodedBitfield are four 1-byte fields that fill a single 4-byte tail word (struct alignment is 4), so the flag consumed former padding rather than growing the 2000-entry PSRAM array. Add a static_assert pinning the entry to sizeof(meshtastic_User) + 20 so a future 5th trailing byte - which would open a fresh word (~8 KB PSRAM across the array) - fails the build instead of silently costing memory, prompting new flags to be packed into existing bytes. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01G7QggdwMM1CJkguuCmMfZs --- src/modules/TrafficManagementModule.h | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/modules/TrafficManagementModule.h b/src/modules/TrafficManagementModule.h index 262ad16a27a..57081e0310a 100644 --- a/src/modules/TrafficManagementModule.h +++ b/src/modules/TrafficManagementModule.h @@ -280,6 +280,15 @@ class TrafficManagementModule : public MeshModule, private concurrency::OSThread // (false) until a later signed frame upgrades it. bool keySignerProven; }; + // sourceChannel + the two bools + decodedBitfield are four 1-byte fields that pack into a + // single 4-byte tail word (struct alignment is 4), so keySignerProven cost zero bytes - it + // fills what was padding. This holds only while the metadata past `user` stays within + // node(4) + three uint32 timestamps(12) + one tail word(4) = 20 bytes. The array is 2000 + // entries in PSRAM, so a 5th trailing byte would open a fresh word (~8 KB). If this fires, + // pack new flags into a bit of the existing bytes (e.g. fold the bools into a flags byte) + // rather than adding a field. + static_assert(sizeof(NodeInfoPayloadEntry) == sizeof(meshtastic_User) + 20, + "NodeInfoPayloadEntry grew past its packed tail word - pack new flags into existing bytes"); NodeInfoPayloadEntry *nodeInfoPayload = nullptr; // NodeInfo payloads in PSRAM (flat array, linear scan) bool nodeInfoPayloadFromPsram = false; // Tracks allocator for correct deallocation From 0b422951c761324c4873cfa945c78968636ce155 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 17 Jul 2026 01:31:56 +0000 Subject: [PATCH 14/44] Pack NodeInfo cache booleans into 1-bit fields Fold hasDecodedBitfield and keySignerProven into adjacent uint8_t:1 bitfields so they share a single byte, reserving 6 spare bits for future flags without growing the 2000-entry PSRAM array. sizeof is unchanged (still one packed tail word, sizeof(meshtastic_User) + 20); access is by name exactly as before, so no call sites change. Reorders decodedBitfield ahead of the flags so the two 1-bit members stay adjacent and the compiler packs them together. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01G7QggdwMM1CJkguuCmMfZs --- src/modules/TrafficManagementModule.h | 41 +++++++++++++++------------ 1 file changed, 23 insertions(+), 18 deletions(-) diff --git a/src/modules/TrafficManagementModule.h b/src/modules/TrafficManagementModule.h index 57081e0310a..da19084a03c 100644 --- a/src/modules/TrafficManagementModule.h +++ b/src/modules/TrafficManagementModule.h @@ -267,28 +267,33 @@ class TrafficManagementModule : public MeshModule, private concurrency::OSThread // Cached decoded bitfield metadata from the source packet. // We preserve non-OK_TO_MQTT bits in direct replies when available. - bool hasDecodedBitfield; uint8_t decodedBitfield; - // Provenance of user.public_key: true once we have observed a NODEINFO_APP frame for - // this node whose XEdDSA signature we verified (mp.xeddsa_signed) - i.e. the key is - // proven to belong to a signer, not merely trust-on-first-use. Monotonic: once proven - // it stays proven for the life of the slot (the key-pin checks forbid the key changing - // underneath it). Used as a trust tier: proven keys are the stickiest under LRU - // eviction and are reported to copyPublicKey() consumers. A signature can only be - // verified against a key we already held, so a first-contact key is always TOFU - // (false) until a later signed frame upgrades it. - bool keySignerProven; + // Boolean flags, declared adjacent as 1-bit fields so the compiler packs them into a + // single byte, leaving 6 spare bits for future flags without growing the 2000-entry + // PSRAM array. Access is by name, exactly like plain bools. + + // The source packet carried a decoded bitfield (so decodedBitfield is meaningful). + uint8_t hasDecodedBitfield : 1; + + // Provenance of user.public_key: 1 once we have observed a NODEINFO_APP frame for this + // node whose XEdDSA signature we verified (directly via mp.xeddsa_signed, or inherited + // from NodeDB via isVerifiedSignerForKey) - i.e. the key is proven to belong to a + // signer, not merely trust-on-first-use. Monotonic: once set it stays set for the life + // of the slot (the key-pin checks forbid the key changing underneath it). Used as a + // trust tier: proven keys are the stickiest under LRU eviction and are reported to + // copyPublicKey() consumers. A signature can only be verified against a key we already + // held, so a first-contact key is always TOFU (0) until a later signed frame upgrades it. + uint8_t keySignerProven : 1; }; - // sourceChannel + the two bools + decodedBitfield are four 1-byte fields that pack into a - // single 4-byte tail word (struct alignment is 4), so keySignerProven cost zero bytes - it - // fills what was padding. This holds only while the metadata past `user` stays within - // node(4) + three uint32 timestamps(12) + one tail word(4) = 20 bytes. The array is 2000 - // entries in PSRAM, so a 5th trailing byte would open a fresh word (~8 KB). If this fires, - // pack new flags into a bit of the existing bytes (e.g. fold the bools into a flags byte) - // rather than adding a field. + // Tail metadata past `user` is node(4) + three uint32 timestamps(12) + one 4-byte word + // holding sourceChannel + decodedBitfield + the packed flag byte (+1 pad) = 20 bytes; the + // struct's 4-byte alignment rounds the three trailing bytes up to that word. New boolean + // flags must go in the spare bits above (6 left), not new fields: the array is 2000 entries + // in PSRAM, so a 4th trailing byte would open a fresh word (~8 KB). This assert fires if + // that happens. static_assert(sizeof(NodeInfoPayloadEntry) == sizeof(meshtastic_User) + 20, - "NodeInfoPayloadEntry grew past its packed tail word - pack new flags into existing bytes"); + "NodeInfoPayloadEntry grew past its packed tail word - pack new flags into the spare flag bits"); NodeInfoPayloadEntry *nodeInfoPayload = nullptr; // NodeInfo payloads in PSRAM (flat array, linear scan) bool nodeInfoPayloadFromPsram = false; // Tracks allocator for correct deallocation From 0844244c90f94c1570ad870b4fe30b92c268439d Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 17 Jul 2026 01:45:08 +0000 Subject: [PATCH 15/44] Gate NodeInfo replay on signer-proven provenance (compile-time, default on) Add TMM_NODEINFO_REPLAY_REQUIRE_SIGNED (default 1): the direct-response path now only spoofs a reply for a node whose key is signer-proven, in addition to the staleness gate - so replay is based on flagging AND staleness. Replay is a courtesy feature, and vouching for an unverified (trust-on-first-use) identity to other nodes is the risk this closes, so signed-only is the safer default. Define the macro to 0 at build time to also serve fresh TOFU-only nodes. Both paths are gated: the PSRAM path checks the cached keySignerProven flag, the NodeDB fallback checks nodeInfoLiteHasXeddsaSigned(node). The effective gate (TMM_NODEINFO_REPLAY_SIGNED_GATE) is bypassed when PKI is excluded from the build, since nothing can be signed there and the courtesy feature would otherwise be disabled outright. Tests: existing reply-expecting tests establish signer-proven state (a new markKeySignerProvenForTest hook for the PSRAM cache, the NodeDB signed bit for the fallback path); add fallback and PSRAM tests asserting an unsigned/TOFU node is withheld under the default gate. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01G7QggdwMM1CJkguuCmMfZs --- src/modules/TrafficManagementModule.cpp | 40 ++++++++++ src/modules/TrafficManagementModule.h | 24 ++++++ test/test_traffic_management/test_main.cpp | 93 ++++++++++++++++++++++ 3 files changed, 157 insertions(+) diff --git a/src/modules/TrafficManagementModule.cpp b/src/modules/TrafficManagementModule.cpp index eedc048b20e..0749bc99fc5 100644 --- a/src/modules/TrafficManagementModule.cpp +++ b/src/modules/TrafficManagementModule.cpp @@ -306,6 +306,23 @@ int TrafficManagementModule::peekCachedRole(NodeNum node) #endif } +void TrafficManagementModule::markKeySignerProvenForTest(NodeNum node) +{ +#if defined(ARCH_ESP32) && defined(BOARD_HAS_PSRAM) + concurrency::LockGuard guard(&cacheLock); + if (!nodeInfoPayload) + return; + for (uint16_t i = 0; i < nodeInfoTargetEntries(); i++) { + if (nodeInfoPayload[i].node == node) { + nodeInfoPayload[i].keySignerProven = true; + return; + } + } +#else + (void)node; +#endif +} + /** * Find or create an entry for the given node. * @@ -1222,6 +1239,9 @@ bool TrafficManagementModule::shouldRespondToNodeInfo(const meshtastic_MeshPacke uint32_t cachedLastObservedMs = 0; uint32_t cachedLastObservedRxTime = 0; uint32_t cachedLastResponseMs = 0; + // Signer-proven provenance of the cached key, consumed by the replay gate below + // (maybe_unused: read only when TMM_NODEINFO_REPLAY_SIGNED_GATE is compiled in). + [[maybe_unused]] bool cachedKeySignerProven = false; // True once we commit to answering from the NodeDB fallback (non-PSRAM) path, so the // shared throttle check/stamp below target the module-global fallback stamp instead of // a per-node PSRAM entry (which does not exist on this path). @@ -1244,6 +1264,7 @@ bool TrafficManagementModule::shouldRespondToNodeInfo(const meshtastic_MeshPacke cachedLastObservedMs = entry->lastObservedMs; cachedLastObservedRxTime = entry->lastObservedRxTime; cachedLastResponseMs = entry->lastResponseMs; + cachedKeySignerProven = entry->keySignerProven; #if defined(ARCH_ESP32) && defined(BOARD_HAS_PSRAM) cachedNodeInfoIndex = static_cast(entry - nodeInfoPayload); #endif @@ -1275,6 +1296,15 @@ bool TrafficManagementModule::shouldRespondToNodeInfo(const meshtastic_MeshPacke static_cast(nodeAgeSecs)); return false; } +#if TMM_NODEINFO_REPLAY_SIGNED_GATE + // Replay provenance gate (fallback path): only vouch for a node NodeDB knows as a + // verified signer. An unproven (trust-on-first-use) identity is left for the genuine + // node or another cache-holder to answer. + if (!nodeInfoLiteHasXeddsaSigned(node)) { + TM_LOG_DEBUG("NodeInfo NodeDB entry for 0x%08x not signer-proven, not responding", p->to); + return false; + } +#endif cachedUser = TypeConversions::ConvertToUser(node); // T3: the fallback path has no per-node cache entry, so it throttles against the // module-global stamp. Load it here so the shared throttle check below covers this @@ -1300,6 +1330,16 @@ bool TrafficManagementModule::shouldRespondToNodeInfo(const meshtastic_MeshPacke return false; } +#if TMM_NODEINFO_REPLAY_SIGNED_GATE + // Replay provenance gate (PSRAM path): only spoof a reply for a signer-proven cached key. + // The fallback path is gated above; this covers the PSRAM cache hit. usedFallback entries + // were already gated, so skip them here. See TMM_NODEINFO_REPLAY_REQUIRE_SIGNED. + if (!usedFallback && !cachedKeySignerProven) { + TM_LOG_DEBUG("NodeInfo PSRAM entry for 0x%08x not signer-proven, not responding", p->to); + return false; + } +#endif + if (!sendResponse) return true; diff --git a/src/modules/TrafficManagementModule.h b/src/modules/TrafficManagementModule.h index da19084a03c..eb2e6ecd26a 100644 --- a/src/modules/TrafficManagementModule.h +++ b/src/modules/TrafficManagementModule.h @@ -8,6 +8,25 @@ #if HAS_TRAFFIC_MANAGEMENT +// Replay provenance gate (compile-time). When 1 (default), the NodeInfo direct-response path +// only spoofs a reply on behalf of a node whose cached key is signer-proven - an XEdDSA +// signature was verified against it - in addition to the staleness gate. Replay is a courtesy +// feature, and vouching for an unverified (trust-on-first-use) identity to other nodes is the +// risk this closes, so signed-only is the safer default. Define as 0 at build time to also +// serve fresh TOFU-only nodes (the prior, more permissive behavior). No effect when PKI is +// excluded from the build: nothing can be signed there, so the gate is bypassed to avoid +// disabling the courtesy feature outright. +#ifndef TMM_NODEINFO_REPLAY_REQUIRE_SIGNED +#define TMM_NODEINFO_REPLAY_REQUIRE_SIGNED 1 +#endif + +// Effective gate: only meaningful when PKI is compiled in. +#if TMM_NODEINFO_REPLAY_REQUIRE_SIGNED && !(MESHTASTIC_EXCLUDE_PKI) +#define TMM_NODEINFO_REPLAY_SIGNED_GATE 1 +#else +#define TMM_NODEINFO_REPLAY_SIGNED_GATE 0 +#endif + /** * TrafficManagementModule - Packet inspection and traffic shaping for mesh networks. * @@ -112,6 +131,11 @@ class TrafficManagementModule : public MeshModule, private concurrency::OSThread // no cache entry (distinguishes "not tracked / evicted" from CLIENT == 0). int peekCachedRole(NodeNum node); + // Test hook: force a cached NodeInfo entry's key to signer-proven, so replay-gate tests can + // exercise the signed-only path without standing up a full XEdDSA verification. No-op if the + // node is not cached or the PSRAM NodeInfo cache is absent. + void markKeySignerProvenForTest(NodeNum node); + private: // ========================================================================= // Unified Cache Entry (10 bytes) - Same for ALL platforms diff --git a/test/test_traffic_management/test_main.cpp b/test/test_traffic_management/test_main.cpp index b7e033114d1..241810fe1eb 100644 --- a/test/test_traffic_management/test_main.cpp +++ b/test/test_traffic_management/test_main.cpp @@ -176,6 +176,7 @@ class TrafficManagementModuleTestShim : public TrafficManagementModule using TrafficManagementModule::alterReceived; using TrafficManagementModule::flushCache; using TrafficManagementModule::handleReceived; + using TrafficManagementModule::markKeySignerProvenForTest; using TrafficManagementModule::peekCachedRole; using TrafficManagementModule::runOnce; @@ -535,6 +536,8 @@ static void test_tm_nodeinfo_directResponse_respondsFromCache(void) config.device.role = meshtastic_Config_DeviceConfig_Role_CLIENT; config.lora.config_ok_to_mqtt = true; mockNodeDB->setCachedNode(kTargetNode); + // Signed-only replay gate (default) requires the target be a known signer to be served. + mockNodeDB->cachedNodeForTest().bitfield |= NODEINFO_BITFIELD_HAS_XEDDSA_SIGNED_MASK; MockRouter mockRouter; mockRouter.addInterface(std::unique_ptr(new MockRadioInterface())); @@ -579,6 +582,8 @@ static void test_tm_nodeinfo_directResponse_learnsRequestorNodeInfo(void) moduleConfig.traffic_management.nodeinfo_direct_response_max_hops = 10; config.device.role = meshtastic_Config_DeviceConfig_Role_CLIENT; mockNodeDB->setCachedNode(kTargetNode); + // Signed-only replay gate (default) requires the target be a known signer to be served. + mockNodeDB->cachedNodeForTest().bitfield |= NODEINFO_BITFIELD_HAS_XEDDSA_SIGNED_MASK; MockRouter mockRouter; mockRouter.addInterface(std::unique_ptr(new MockRadioInterface())); @@ -730,6 +735,8 @@ static void test_tm_nodeinfo_directResponse_psramCacheRespondsAndPreservesBitfie ProcessMessage observedResult = module.handleReceived(observed); TEST_ASSERT_EQUAL_INT(static_cast(ProcessMessage::CONTINUE), static_cast(observedResult)); + // Signed-only replay gate (default) requires signer-proven provenance to serve. + module.markKeySignerProvenForTest(kTargetNode); meshtastic_MeshPacket request = makeDecodedPacket(meshtastic_PortNum_NODEINFO_APP, kRemoteNode, kTargetNode); request.decoded.want_response = true; @@ -809,6 +816,8 @@ static void test_tm_nodeinfo_directResponse_psramStaleEntryNotServed(void) // Learn a NodeInfo for the target into the PSRAM cache (broadcast, so it is only cached). meshtastic_MeshPacket observed = makeNodeInfoPacket(kTargetNode, "target-long", "tg"); module.handleReceived(observed); + // Signer-proven so staleness is the sole reason it is not served (isolates the gate under test). + module.markKeySignerProvenForTest(kTargetNode); // Advance the virtual clock just past the 6 h serve window. TrafficManagementModule::s_testNowMs += (6UL * 60UL * 60UL * 1000UL) + 1000UL; @@ -848,6 +857,8 @@ static void test_tm_nodeinfo_directResponse_psramThrottlesWithinWindow(void) meshtastic_MeshPacket observed = makeNodeInfoPacket(kTargetNode, "target-long", "tg"); module.handleReceived(observed); + // Signed-only replay gate (default) requires signer-proven provenance to serve. + module.markKeySignerProvenForTest(kTargetNode); meshtastic_MeshPacket request = makeDecodedPacket(meshtastic_PortNum_NODEINFO_APP, kRemoteNode, kTargetNode); request.decoded.want_response = true; @@ -911,6 +922,8 @@ static void test_tm_nodeinfo_cache_rejectsMismatchedKey(void) module.handleReceived(makeNodeInfoPacketWithKey(kTargetNode, "genuine", 0x11)); // Poisoning attempt with a different key (0x22...) must be rejected. module.handleReceived(makeNodeInfoPacketWithKey(kTargetNode, "attacker", 0x22)); + // Signed-only replay gate (default) requires signer-proven provenance to serve the reply. + module.markKeySignerProvenForTest(kTargetNode); meshtastic_MeshPacket request = makeDecodedPacket(meshtastic_PortNum_NODEINFO_APP, kRemoteNode, kTargetNode); request.decoded.want_response = true; @@ -1002,6 +1015,38 @@ static void test_tm_nodeinfo_copyPublicKey_missReturnsFalse(void) uint8_t key[32] = {0}; TEST_ASSERT_FALSE(module.copyPublicKey(kTargetNode, key, nullptr)); } + +#if TMM_NODEINFO_REPLAY_SIGNED_GATE +/** + * Replay gate (PSRAM path): a fresh but trust-on-first-use (never signer-proven) cached entry + * is withheld - the reply is suppressed though the entry is fresh. + */ +static void test_tm_nodeinfo_directResponse_psramUnsignedNotServed(void) +{ + moduleConfig.traffic_management.nodeinfo_direct_response_max_hops = 10; + config.device.role = meshtastic_Config_DeviceConfig_Role_CLIENT; + mockNodeDB->clearCachedNode(); + + MockRouter mockRouter; + mockRouter.addInterface(std::unique_ptr(new MockRadioInterface())); + MeshService mockService; + router = &mockRouter; + service = &mockService; + + TrafficManagementModuleTestShim module; + // Cache a fresh but unsigned (TOFU) NodeInfo and do NOT mark it signer-proven. + module.handleReceived(makeNodeInfoPacket(kTargetNode, "target-long", "tg")); + + meshtastic_MeshPacket request = makeDecodedPacket(meshtastic_PortNum_NODEINFO_APP, kRemoteNode, kTargetNode); + request.decoded.want_response = true; + request.hop_start = 3; + request.hop_limit = 3; + + ProcessMessage result = module.handleReceived(request); + TEST_ASSERT_EQUAL_INT(static_cast(ProcessMessage::CONTINUE), static_cast(result)); + TEST_ASSERT_EQUAL_UINT32(0, static_cast(mockRouter.sentPackets.size())); +} +#endif // TMM_NODEINFO_REPLAY_SIGNED_GATE #endif // !MESHTASTIC_EXCLUDE_PKI #endif @@ -1021,6 +1066,8 @@ static void test_tm_nodeinfo_directResponse_fallbackStaleEntryNotServed(void) mockNodeDB->setCachedNode(kTargetNode); mockNodeDB->cachedNodeForTest().last_heard = now - (7UL * 60UL * 60UL); // 7 h ago -> stale + // Signer-proven so staleness is the sole reason this is not served (isolates the gate under test). + mockNodeDB->cachedNodeForTest().bitfield |= NODEINFO_BITFIELD_HAS_XEDDSA_SIGNED_MASK; MockRouter mockRouter; mockRouter.addInterface(std::unique_ptr(new MockRadioInterface())); @@ -1057,6 +1104,8 @@ static void test_tm_nodeinfo_directResponse_fallbackFreshEntryServed(void) mockNodeDB->setCachedNode(kTargetNode); mockNodeDB->cachedNodeForTest().last_heard = now - 60UL; // 1 min ago -> fresh + // Signed-only replay gate (default) requires the target be a known signer to be served. + mockNodeDB->cachedNodeForTest().bitfield |= NODEINFO_BITFIELD_HAS_XEDDSA_SIGNED_MASK; MockRouter mockRouter; mockRouter.addInterface(std::unique_ptr(new MockRadioInterface())); @@ -1096,6 +1145,8 @@ static void test_tm_nodeinfo_directResponse_fallbackThrottlesWithinWindow(void) mockNodeDB->setCachedNode(kTargetNode); mockNodeDB->cachedNodeForTest().last_heard = now - 60UL; // fresh, passes staleness gate + // Signed-only replay gate (default) requires the target be a known signer to be served. + mockNodeDB->cachedNodeForTest().bitfield |= NODEINFO_BITFIELD_HAS_XEDDSA_SIGNED_MASK; MockRouter mockRouter; mockRouter.addInterface(std::unique_ptr(new MockRadioInterface())); @@ -1129,6 +1180,42 @@ static void test_tm_nodeinfo_directResponse_fallbackThrottlesWithinWindow(void) resetRTCStateForTests(); } + +#if TMM_NODEINFO_REPLAY_SIGNED_GATE +/** + * Replay gate (fallback path): a fresh NodeDB node that is NOT a known signer is withheld - + * the courtesy reply is suppressed and the genuine request propagates (CONTINUE, no TX). + */ +static void test_tm_nodeinfo_directResponse_fallbackUnsignedNotServed(void) +{ + moduleConfig.traffic_management.nodeinfo_direct_response_max_hops = 10; + config.device.role = meshtastic_Config_DeviceConfig_Role_CLIENT; + setBootRelativeTimeForUnitTest(1000000); + const uint32_t now = getTime(); + + mockNodeDB->setCachedNode(kTargetNode); + mockNodeDB->cachedNodeForTest().last_heard = now - 60UL; // fresh, passes staleness + // Deliberately NOT flagged as a signer -> the signed-only gate must withhold the reply. + + MockRouter mockRouter; + mockRouter.addInterface(std::unique_ptr(new MockRadioInterface())); + MeshService mockService; + router = &mockRouter; + service = &mockService; + + TrafficManagementModuleTestShim module; + meshtastic_MeshPacket request = makeDecodedPacket(meshtastic_PortNum_NODEINFO_APP, kRemoteNode, kTargetNode); + request.decoded.want_response = true; + request.hop_start = 3; + request.hop_limit = 3; + + ProcessMessage result = module.handleReceived(request); + TEST_ASSERT_EQUAL_INT(static_cast(ProcessMessage::CONTINUE), static_cast(result)); + TEST_ASSERT_EQUAL_UINT32(0, static_cast(mockRouter.sentPackets.size())); + + resetRTCStateForTests(); +} +#endif // TMM_NODEINFO_REPLAY_SIGNED_GATE #endif /** @@ -2120,6 +2207,9 @@ TM_TEST_ENTRY void setup() RUN_TEST(test_tm_nodeinfo_directResponse_fallbackStaleEntryNotServed); RUN_TEST(test_tm_nodeinfo_directResponse_fallbackFreshEntryServed); RUN_TEST(test_tm_nodeinfo_directResponse_fallbackThrottlesWithinWindow); +#if TMM_NODEINFO_REPLAY_SIGNED_GATE + RUN_TEST(test_tm_nodeinfo_directResponse_fallbackUnsignedNotServed); +#endif #endif #if defined(ARCH_ESP32) && defined(BOARD_HAS_PSRAM) RUN_TEST(test_tm_nodeinfo_directResponse_psramCacheRespondsAndPreservesBitfield); @@ -2131,6 +2221,9 @@ TM_TEST_ENTRY void setup() RUN_TEST(test_tm_nodeinfo_copyPublicKey_servesTofuKey); RUN_TEST(test_tm_nodeinfo_copyPublicKey_upgradesToSignerProven); RUN_TEST(test_tm_nodeinfo_copyPublicKey_missReturnsFalse); +#if TMM_NODEINFO_REPLAY_SIGNED_GATE + RUN_TEST(test_tm_nodeinfo_directResponse_psramUnsignedNotServed); +#endif #endif #endif RUN_TEST(test_tm_alterReceived_telemetryBroadcast_hopLimitUnchanged); From 1d76523aa2cff0a8bae9699a4d7cd398666de868 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 17 Jul 2026 01:51:27 +0000 Subject: [PATCH 16/44] Rehydrate re-admitted node names from the TMM NodeInfo cache The warm tier keeps an evicted node's key but not its name (the 40-byte record has no room), so a re-admitted long-tail node is nameless until its next NodeInfo broadcast. The TrafficManagement NodeInfo cache is much larger (2000 PSRAM entries) and commonly still holds the full User, so use it as a name reservoir the warm tier structurally cannot be. On re-admission (getOrCreateMeshNode), after the warm-tier restore, copy the cached identity from the TMM cache via a new copyUser() getter - but only when its cached key matches the key just restored from warm, so a name never attaches to a different identity than the one we encrypt to (key-matched trust). Guarded by HAS_TRAFFIC_MANAGEMENT and a null check; a no-op without the PSRAM cache or when no key is present. CopyUserToNodeInfoLite sets only user-related bits, so the warm-restored signer bit is preserved. Limitations (by design): PSRAM-only, and the TMM cache is RAM-only, so this helps within an uptime session, not across reboot. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01G7QggdwMM1CJkguuCmMfZs --- src/mesh/NodeDB.cpp | 17 ++++++++++++ src/modules/TrafficManagementModule.cpp | 23 ++++++++++++++++ src/modules/TrafficManagementModule.h | 7 +++++ test/test_traffic_management/test_main.cpp | 31 ++++++++++++++++++++++ 4 files changed, 78 insertions(+) diff --git a/src/mesh/NodeDB.cpp b/src/mesh/NodeDB.cpp index 040ff0e019e..08700bd4e69 100644 --- a/src/mesh/NodeDB.cpp +++ b/src/mesh/NodeDB.cpp @@ -3831,6 +3831,23 @@ meshtastic_NodeInfoLite *NodeDB::getOrCreateMeshNode(NodeNum n) } LOG_MIGRATION("Rehydrated node 0x%08x from warm tier (key=%d)", n, lite->public_key.size == 32); } +#endif +#if HAS_TRAFFIC_MANAGEMENT + // Name rehydration: the warm tier keeps a node's key but not its name, so a re-admitted + // long-tail node is nameless until its next NodeInfo. The TrafficManagement NodeInfo + // cache is much larger and often still holds the full User. Restore it - but only when + // its cached key matches the key we just restored from warm, so a name never attaches to + // a different identity than the one we encrypt to. No-op without the PSRAM NodeInfo cache + // or when no key is present (key-matched by design). CopyUserToNodeInfoLite sets only the + // user-related bits, so the warm-restored signer bit survives. + if (lite->public_key.size == 32 && !nodeInfoLiteHasUser(lite) && trafficManagementModule) { + meshtastic_User tmmUser = meshtastic_User_init_zero; + if (trafficManagementModule->copyUser(n, tmmUser) && tmmUser.public_key.size == 32 && + memcmp(tmmUser.public_key.bytes, lite->public_key.bytes, 32) == 0) { + TypeConversions::CopyUserToNodeInfoLite(lite, tmmUser); + LOG_INFO("Rehydrated node 0x%08x identity from TMM NodeInfo cache", n); + } + } #endif LOG_INFO("Adding node to database with %i nodes and %u bytes free!", numMeshNodes, memGet.getFreeHeap()); } diff --git a/src/modules/TrafficManagementModule.cpp b/src/modules/TrafficManagementModule.cpp index 0749bc99fc5..62d1db136ec 100644 --- a/src/modules/TrafficManagementModule.cpp +++ b/src/modules/TrafficManagementModule.cpp @@ -567,6 +567,29 @@ bool TrafficManagementModule::copyPublicKey(NodeNum node, uint8_t out[32], bool #endif } +bool TrafficManagementModule::copyUser(NodeNum node, meshtastic_User &out, bool *signerProven) const +{ +#if defined(ARCH_ESP32) && defined(BOARD_HAS_PSRAM) + if (!nodeInfoPayload || node == 0) + return false; + + concurrency::LockGuard guard(&cacheLock); + const NodeInfoPayloadEntry *entry = findNodeInfoEntry(node); + if (!entry) + return false; + + out = entry->user; + if (signerProven) + *signerProven = entry->keySignerProven; + return true; +#else + (void)node; + (void)out; + (void)signerProven; + return false; +#endif +} + #if defined(ARCH_ESP32) && defined(BOARD_HAS_PSRAM) && !(MESHTASTIC_EXCLUDE_PKI) // True iff both are full 32-byte public keys with identical bytes. Single point of // truth for the NodeInfo-cache key-hygiene checks (owner impersonation + key pinning), diff --git a/src/modules/TrafficManagementModule.h b/src/modules/TrafficManagementModule.h index eb2e6ecd26a..3a23081b422 100644 --- a/src/modules/TrafficManagementModule.h +++ b/src/modules/TrafficManagementModule.h @@ -87,6 +87,13 @@ class TrafficManagementModule : public MeshModule, private concurrency::OSThread // trust-on-first-use (false), so callers can weigh its trust. Thread-safe (takes cacheLock). bool copyPublicKey(NodeNum node, uint8_t out[32], bool *signerProven = nullptr) const; + // Copy the full cached User (name, short name, hw model, role, key, flags) for `node`, if the + // NodeInfo direct-response cache holds one. Used by NodeDB to rehydrate a re-admitted node's + // identity - the warm tier keeps a node's key but not its name, and this cache (much larger) + // often still has it. Returns false on miss or on builds without the PSRAM cache. If + // `signerProven` is non-null, reports the cached key's provenance. Thread-safe (takes cacheLock). + bool copyUser(NodeNum node, meshtastic_User &out, bool *signerProven = nullptr) const; + /** * Check if this packet should have its hops exhausted. * Called from perhapsRebroadcast() to force hop_limit = 0 regardless of diff --git a/test/test_traffic_management/test_main.cpp b/test/test_traffic_management/test_main.cpp index 241810fe1eb..cec6e37622b 100644 --- a/test/test_traffic_management/test_main.cpp +++ b/test/test_traffic_management/test_main.cpp @@ -1016,6 +1016,36 @@ static void test_tm_nodeinfo_copyPublicKey_missReturnsFalse(void) TEST_ASSERT_FALSE(module.copyPublicKey(kTargetNode, key, nullptr)); } +/** + * copyUser() returns the full cached identity (name + key) for name rehydration, and reports a + * miss for an uncached node. + */ +static void test_tm_nodeinfo_copyUser_returnsCachedIdentity(void) +{ + moduleConfig.traffic_management.nodeinfo_direct_response_max_hops = 10; + config.device.role = meshtastic_Config_DeviceConfig_Role_CLIENT; + mockNodeDB->clearCachedNode(); + + MockRouter mockRouter; + mockRouter.addInterface(std::unique_ptr(new MockRadioInterface())); + MeshService mockService; + router = &mockRouter; + service = &mockService; + + TrafficManagementModuleTestShim module; + module.handleReceived(makeNodeInfoPacketWithKey(kTargetNode, "target-long", 0x77)); + + meshtastic_User out = meshtastic_User_init_zero; + TEST_ASSERT_TRUE(module.copyUser(kTargetNode, out, nullptr)); + TEST_ASSERT_EQUAL_STRING("target-long", out.long_name); + TEST_ASSERT_EQUAL_UINT32(32, out.public_key.size); + TEST_ASSERT_EQUAL_UINT8(0x77, out.public_key.bytes[0]); + + // Uncached node -> miss. + meshtastic_User miss = meshtastic_User_init_zero; + TEST_ASSERT_FALSE(module.copyUser(kRemoteNode, miss, nullptr)); +} + #if TMM_NODEINFO_REPLAY_SIGNED_GATE /** * Replay gate (PSRAM path): a fresh but trust-on-first-use (never signer-proven) cached entry @@ -2221,6 +2251,7 @@ TM_TEST_ENTRY void setup() RUN_TEST(test_tm_nodeinfo_copyPublicKey_servesTofuKey); RUN_TEST(test_tm_nodeinfo_copyPublicKey_upgradesToSignerProven); RUN_TEST(test_tm_nodeinfo_copyPublicKey_missReturnsFalse); + RUN_TEST(test_tm_nodeinfo_copyUser_returnsCachedIdentity); #if TMM_NODEINFO_REPLAY_SIGNED_GATE RUN_TEST(test_tm_nodeinfo_directResponse_psramUnsignedNotServed); #endif From 173651badf0113faf5160110cfc1b567471d30ee Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 17 Jul 2026 02:05:02 +0000 Subject: [PATCH 17/44] Drop non-portable NodeInfoPayloadEntry size assert (fixes pico build) The static_assert pinned sizeof(NodeInfoPayloadEntry) to sizeof(meshtastic_User) + 20, but nanopb packs the generated User struct differently per platform: on pico its size is not a multiple of 4, so alignment padding before the uint32 timestamps makes the overhead 22, not 20, and the assert failed the build (native happened to be +20 and passed, hiding it). The bitfield packing it was guarding still stands; replace the fixed-count assert with a comment, since no portable byte count exists. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01G7QggdwMM1CJkguuCmMfZs --- src/modules/TrafficManagementModule.h | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/src/modules/TrafficManagementModule.h b/src/modules/TrafficManagementModule.h index 3a23081b422..c45dd9d1a57 100644 --- a/src/modules/TrafficManagementModule.h +++ b/src/modules/TrafficManagementModule.h @@ -317,14 +317,13 @@ class TrafficManagementModule : public MeshModule, private concurrency::OSThread // held, so a first-contact key is always TOFU (0) until a later signed frame upgrades it. uint8_t keySignerProven : 1; }; - // Tail metadata past `user` is node(4) + three uint32 timestamps(12) + one 4-byte word - // holding sourceChannel + decodedBitfield + the packed flag byte (+1 pad) = 20 bytes; the - // struct's 4-byte alignment rounds the three trailing bytes up to that word. New boolean - // flags must go in the spare bits above (6 left), not new fields: the array is 2000 entries - // in PSRAM, so a 4th trailing byte would open a fresh word (~8 KB). This assert fires if - // that happens. - static_assert(sizeof(NodeInfoPayloadEntry) == sizeof(meshtastic_User) + 20, - "NodeInfoPayloadEntry grew past its packed tail word - pack new flags into the spare flag bits"); + // sourceChannel, decodedBitfield, and the two 1-bit flags (hasDecodedBitfield, + // keySignerProven) make up the entry's trailing metadata; the two bits share a single byte, + // leaving 6 spare bits. Add future booleans as more 1-bit fields here rather than new bytes - + // the array is 2000 entries in PSRAM, so a fresh byte can cost a whole aligned word. (No + // exact-size static_assert: sizeof(meshtastic_User) and its trailing padding vary by platform + // - nanopb packs the generated struct differently on embedded targets - so any fixed byte + // count is non-portable and would fail the build on some boards.) NodeInfoPayloadEntry *nodeInfoPayload = nullptr; // NodeInfo payloads in PSRAM (flat array, linear scan) bool nodeInfoPayloadFromPsram = false; // Tracks allocator for correct deallocation From bccf8c3a32c1c724b2dcaa8924efe88e0e258219 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 17 Jul 2026 08:35:12 +0000 Subject: [PATCH 18/44] Run the NodeInfo cache paths in native tests (TMM_HAS_NODEINFO_CACHE) The NodeInfo direct-response cache and everything layered on it - key pinning, signer provenance, staleness, throttle - was compiled only for ARCH_ESP32 + BOARD_HAS_PSRAM, so its entire test set was skipped by the native CI suite and ran nowhere but real hardware. Fold the scattered platform guards into one TMM_HAS_NODEINFO_CACHE macro that also enables the cache (plain heap, delete[] path already existed) for ARCH_PORTDUINO unit-test builds. Production portduino and embedded test builds are unchanged. Tests that exercise the NodeDB fallback path drop the cache explicitly via a new dropNodeInfoCacheForTest() hook, so both response paths are covered in one binary. Native suite: 60/60 (was 50 with the 10 cache tests compiled out). Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01KWHDDxY4U2XLYW2WJJ2oLT (cherry picked from commit b2dafbbbd6501b42fc27175e599fb38be292b0e3) --- src/modules/TrafficManagementModule.cpp | 54 ++++++++++++++++------ src/modules/TrafficManagementModule.h | 20 +++++++- test/test_traffic_management/test_main.cpp | 28 +++++------ 3 files changed, 73 insertions(+), 29 deletions(-) diff --git a/src/modules/TrafficManagementModule.cpp b/src/modules/TrafficManagementModule.cpp index 62d1db136ec..ae6b95bae77 100644 --- a/src/modules/TrafficManagementModule.cpp +++ b/src/modules/TrafficManagementModule.cpp @@ -194,11 +194,14 @@ TrafficManagementModule::TrafficManagementModule() : MeshModule("TrafficManageme memaudit::set("tmm", cache ? allocSize * sizeof(UnifiedCacheEntry) : 0); #endif // TRAFFIC_MANAGEMENT_CACHE_SIZE > 0 -#if defined(ARCH_ESP32) && defined(BOARD_HAS_PSRAM) - TM_LOG_INFO("Allocating NodeInfo cache: %u entries, %u bytes (PSRAM flat array)", +#if TMM_HAS_NODEINFO_CACHE + TM_LOG_INFO("Allocating NodeInfo cache: %u entries, %u bytes (flat array)", static_cast(nodeInfoTargetEntries()), static_cast(nodeInfoTargetEntries() * sizeof(NodeInfoPayloadEntry))); +#if defined(ARCH_ESP32) && defined(BOARD_HAS_PSRAM) + // Production home of this cache: ESP32 PSRAM. No heap fallback here - at 2000 entries the + // array is too large for MCU internal RAM, so a PSRAM failure disables the cache instead. nodeInfoPayload = static_cast(ps_calloc(nodeInfoTargetEntries(), sizeof(NodeInfoPayloadEntry))); if (nodeInfoPayload) { nodeInfoPayloadFromPsram = true; @@ -206,9 +209,14 @@ TrafficManagementModule::TrafficManagementModule() : MeshModule("TrafficManageme } else { TM_LOG_WARN("NodeInfo PSRAM payload allocation failed; direct responses will fall back to NodeDB"); } +#else + // Native unit-test build (see TMM_HAS_NODEINFO_CACHE): plain heap, so the cache paths + // run in CI. nodeInfoPayloadFromPsram stays false and the destructor uses delete[]. + nodeInfoPayload = new NodeInfoPayloadEntry[nodeInfoTargetEntries()](); +#endif memaudit::set("tmm_ni", nodeInfoPayload ? nodeInfoTargetEntries() * sizeof(NodeInfoPayloadEntry) : 0); #else - TM_LOG_DEBUG("NodeInfo PSRAM cache not available on this target"); + TM_LOG_DEBUG("NodeInfo cache not available on this target"); #endif setIntervalFromNow(kMaintenanceIntervalMs); @@ -306,9 +314,25 @@ int TrafficManagementModule::peekCachedRole(NodeNum node) #endif } +void TrafficManagementModule::dropNodeInfoCacheForTest() +{ +#if TMM_HAS_NODEINFO_CACHE + concurrency::LockGuard guard(&cacheLock); + if (!nodeInfoPayload) + return; + if (nodeInfoPayloadFromPsram) + free(nodeInfoPayload); + else + delete[] nodeInfoPayload; + nodeInfoPayload = nullptr; + nodeInfoPayloadFromPsram = false; + memaudit::set("tmm_ni", 0); +#endif +} + void TrafficManagementModule::markKeySignerProvenForTest(NodeNum node) { -#if defined(ARCH_ESP32) && defined(BOARD_HAS_PSRAM) +#if TMM_HAS_NODEINFO_CACHE concurrency::LockGuard guard(&cacheLock); if (!nodeInfoPayload) return; @@ -451,7 +475,7 @@ TrafficManagementModule::UnifiedCacheEntry *TrafficManagementModule::findOrCreat const TrafficManagementModule::NodeInfoPayloadEntry *TrafficManagementModule::findNodeInfoEntry(NodeNum node) const { -#if defined(ARCH_ESP32) && defined(BOARD_HAS_PSRAM) +#if TMM_HAS_NODEINFO_CACHE if (!nodeInfoPayload || node == 0) return nullptr; @@ -482,7 +506,7 @@ TrafficManagementModule::NodeInfoPayloadEntry *TrafficManagementModule::findOrCr if (usedEmptySlot) *usedEmptySlot = false; -#if defined(ARCH_ESP32) && defined(BOARD_HAS_PSRAM) +#if TMM_HAS_NODEINFO_CACHE if (!nodeInfoPayload || node == 0) return nullptr; @@ -529,7 +553,7 @@ TrafficManagementModule::NodeInfoPayloadEntry *TrafficManagementModule::findOrCr uint16_t TrafficManagementModule::countNodeInfoEntriesLocked() const { -#if defined(ARCH_ESP32) && defined(BOARD_HAS_PSRAM) +#if TMM_HAS_NODEINFO_CACHE if (!nodeInfoPayload) return 0; @@ -546,7 +570,7 @@ uint16_t TrafficManagementModule::countNodeInfoEntriesLocked() const bool TrafficManagementModule::copyPublicKey(NodeNum node, uint8_t out[32], bool *signerProven) const { -#if defined(ARCH_ESP32) && defined(BOARD_HAS_PSRAM) +#if TMM_HAS_NODEINFO_CACHE if (!nodeInfoPayload || node == 0 || !out) return false; @@ -569,7 +593,7 @@ bool TrafficManagementModule::copyPublicKey(NodeNum node, uint8_t out[32], bool bool TrafficManagementModule::copyUser(NodeNum node, meshtastic_User &out, bool *signerProven) const { -#if defined(ARCH_ESP32) && defined(BOARD_HAS_PSRAM) +#if TMM_HAS_NODEINFO_CACHE if (!nodeInfoPayload || node == 0) return false; @@ -590,7 +614,7 @@ bool TrafficManagementModule::copyUser(NodeNum node, meshtastic_User &out, bool #endif } -#if defined(ARCH_ESP32) && defined(BOARD_HAS_PSRAM) && !(MESHTASTIC_EXCLUDE_PKI) +#if TMM_HAS_NODEINFO_CACHE && !(MESHTASTIC_EXCLUDE_PKI) // True iff both are full 32-byte public keys with identical bytes. Single point of // truth for the NodeInfo-cache key-hygiene checks (owner impersonation + key pinning), // so the compare (and any future hardening, e.g. constant-time) lives in one place. @@ -603,7 +627,7 @@ static bool pubKeysEqual(const uint8_t *a, size_t aSize, const uint8_t *b, size_ void TrafficManagementModule::cacheNodeInfoPacket(const meshtastic_MeshPacket &mp) { -#if defined(ARCH_ESP32) && defined(BOARD_HAS_PSRAM) +#if TMM_HAS_NODEINFO_CACHE if (!nodeInfoPayload || mp.decoded.payload.size == 0) return; @@ -1130,7 +1154,7 @@ int32_t TrafficManagementModule::runOnce() static_cast(activeEntries), static_cast(cacheSize()), static_cast(TrafficManagementModule::clockMs() - sweepStartMs)); -#if defined(ARCH_ESP32) && defined(BOARD_HAS_PSRAM) +#if TMM_HAS_NODEINFO_CACHE if (nodeInfoPayload) { // Evict stale NodeInfo payloads. Two windows: an entry carrying a 32-byte public key // is retained for kNodeInfoKeyRetentionMs (it is a last-resort encryption key source, @@ -1269,7 +1293,7 @@ bool TrafficManagementModule::shouldRespondToNodeInfo(const meshtastic_MeshPacke // shared throttle check/stamp below target the module-global fallback stamp instead of // a per-node PSRAM entry (which does not exist on this path). bool usedFallback = false; -#if defined(ARCH_ESP32) && defined(BOARD_HAS_PSRAM) +#if TMM_HAS_NODEINFO_CACHE // Slot index of the PSRAM cache hit, captured here so the post-send throttle stamp can // address the entry directly instead of rescanning the array (T5). -1 = no hit. int32_t cachedNodeInfoIndex = -1; @@ -1288,7 +1312,7 @@ bool TrafficManagementModule::shouldRespondToNodeInfo(const meshtastic_MeshPacke cachedLastObservedRxTime = entry->lastObservedRxTime; cachedLastResponseMs = entry->lastResponseMs; cachedKeySignerProven = entry->keySignerProven; -#if defined(ARCH_ESP32) && defined(BOARD_HAS_PSRAM) +#if TMM_HAS_NODEINFO_CACHE cachedNodeInfoIndex = static_cast(entry - nodeInfoPayload); #endif } @@ -1450,7 +1474,7 @@ bool TrafficManagementModule::shouldRespondToNodeInfo(const meshtastic_MeshPacke // initial lookup rather than rescanning the array (T5). The cache lock was released in // between, so the slot could have been evicted or reused for a different node; re-validate // node == p->to under the lock and skip the stamp if it no longer matches. -#if defined(ARCH_ESP32) && defined(BOARD_HAS_PSRAM) +#if TMM_HAS_NODEINFO_CACHE if (!usedFallback && nodeInfoPayload && cachedNodeInfoIndex >= 0) { concurrency::LockGuard guard(&cacheLock); NodeInfoPayloadEntry &e = nodeInfoPayload[cachedNodeInfoIndex]; diff --git a/src/modules/TrafficManagementModule.h b/src/modules/TrafficManagementModule.h index c45dd9d1a57..bde7a2a5cfb 100644 --- a/src/modules/TrafficManagementModule.h +++ b/src/modules/TrafficManagementModule.h @@ -27,6 +27,19 @@ #define TMM_NODEINFO_REPLAY_SIGNED_GATE 0 #endif +// NodeInfo cache availability (compile-time). The cache's production home is ESP32 boards +// with PSRAM - at 2000 entries the flat array is far too large for MCU internal RAM - but +// the code itself is portable. Native unit-test builds (ARCH_PORTDUINO + PIO_UNIT_TESTING) +// enable it on the plain heap so the cache paths - key pinning, signer provenance, +// staleness, throttle, retention - run in CI instead of only on hardware. Production +// portduino (meshtasticd) and embedded test builds are unaffected. Tests that need the +// NodeDB fallback path instead call dropNodeInfoCacheForTest(). +#if (defined(ARCH_ESP32) && defined(BOARD_HAS_PSRAM)) || (defined(ARCH_PORTDUINO) && defined(PIO_UNIT_TESTING)) +#define TMM_HAS_NODEINFO_CACHE 1 +#else +#define TMM_HAS_NODEINFO_CACHE 0 +#endif + /** * TrafficManagementModule - Packet inspection and traffic shaping for mesh networks. * @@ -140,9 +153,14 @@ class TrafficManagementModule : public MeshModule, private concurrency::OSThread // Test hook: force a cached NodeInfo entry's key to signer-proven, so replay-gate tests can // exercise the signed-only path without standing up a full XEdDSA verification. No-op if the - // node is not cached or the PSRAM NodeInfo cache is absent. + // node is not cached or the NodeInfo cache is absent. void markKeySignerProvenForTest(NodeNum node); + // Test hook: free and clear the NodeInfo cache so the NodeDB fallback direct-response + // path can be exercised in a build where the cache is compiled in (native test builds + // allocate it on the heap - see TMM_HAS_NODEINFO_CACHE). No-op when already absent. + void dropNodeInfoCacheForTest(); + private: // ========================================================================= // Unified Cache Entry (10 bytes) - Same for ALL platforms diff --git a/test/test_traffic_management/test_main.cpp b/test/test_traffic_management/test_main.cpp index cec6e37622b..1fe8849041d 100644 --- a/test/test_traffic_management/test_main.cpp +++ b/test/test_traffic_management/test_main.cpp @@ -176,6 +176,7 @@ class TrafficManagementModuleTestShim : public TrafficManagementModule using TrafficManagementModule::alterReceived; using TrafficManagementModule::flushCache; using TrafficManagementModule::handleReceived; + using TrafficManagementModule::dropNodeInfoCacheForTest; using TrafficManagementModule::markKeySignerProvenForTest; using TrafficManagementModule::peekCachedRole; using TrafficManagementModule::runOnce; @@ -546,6 +547,7 @@ static void test_tm_nodeinfo_directResponse_respondsFromCache(void) service = &mockService; TrafficManagementModuleTestShim module; + module.dropNodeInfoCacheForTest(); // exercise the NodeDB fallback path meshtastic_MeshPacket request = makeDecodedPacket(meshtastic_PortNum_NODEINFO_APP, kRemoteNode, kTargetNode); request.decoded.want_response = true; request.id = 0x13572468; @@ -592,6 +594,7 @@ static void test_tm_nodeinfo_directResponse_learnsRequestorNodeInfo(void) service = &mockService; TrafficManagementModuleTestShim module; + module.dropNodeInfoCacheForTest(); // exercise the NodeDB fallback path meshtastic_MeshPacket request = makeNodeInfoPacket(kRemoteNode, "requester-long", "rq"); request.to = kTargetNode; request.decoded.want_response = true; @@ -673,11 +676,10 @@ static void test_tm_nodeinfo_clientClamp_skipsWhenNotDirect(void) TEST_ASSERT_FALSE(module.ignoreRequestFlag()); } -#if !(defined(ARCH_ESP32) && defined(BOARD_HAS_PSRAM)) /** - * Verify non-PSRAM builds require NodeDB for direct NodeInfo responses. + * Verify the NodeDB-fallback path requires NodeDB for direct NodeInfo responses. * Important because fallback should only happen through node-wide data when - * the dedicated PSRAM cache does not exist. + * the dedicated NodeInfo cache does not exist. */ static void test_tm_nodeinfo_directResponse_withoutNodeDbEntry_skips(void) { @@ -692,6 +694,7 @@ static void test_tm_nodeinfo_directResponse_withoutNodeDbEntry_skips(void) service = &mockService; TrafficManagementModuleTestShim module; + module.dropNodeInfoCacheForTest(); // exercise the NodeDB fallback path meshtastic_MeshPacket request = makeDecodedPacket(meshtastic_PortNum_NODEINFO_APP, kRemoteNode, kTargetNode); request.decoded.want_response = true; request.hop_start = 3; @@ -705,11 +708,10 @@ static void test_tm_nodeinfo_directResponse_withoutNodeDbEntry_skips(void) TEST_ASSERT_EQUAL_UINT32(0, stats.nodeinfo_cache_hits); TEST_ASSERT_EQUAL_UINT32(0, static_cast(mockRouter.sentPackets.size())); } -#endif -#if defined(ARCH_ESP32) && defined(BOARD_HAS_PSRAM) +#if TMM_HAS_NODEINFO_CACHE /** - * Verify PSRAM NodeInfo cache can answer requests without NodeDB and that + * Verify the NodeInfo cache can answer requests without NodeDB and that * shouldRespondToNodeInfo() uses cached bitfield metadata. */ static void test_tm_nodeinfo_directResponse_psramCacheRespondsAndPreservesBitfield(void) @@ -1081,10 +1083,9 @@ static void test_tm_nodeinfo_directResponse_psramUnsignedNotServed(void) #endif /** - * Verify the NodeDB-fallback direct-response path (non-PSRAM) refuses to spoof a reply - * for a node NodeDB last heard beyond the serve window, but still answers a fresh one. + * Verify the NodeDB-fallback direct-response path (no NodeInfo cache) refuses to spoof a + * reply for a node NodeDB last heard beyond the serve window, but still answers a fresh one. */ -#if !(defined(ARCH_ESP32) && defined(BOARD_HAS_PSRAM)) static void test_tm_nodeinfo_directResponse_fallbackStaleEntryNotServed(void) { moduleConfig.traffic_management.nodeinfo_direct_response_max_hops = 10; @@ -1106,6 +1107,7 @@ static void test_tm_nodeinfo_directResponse_fallbackStaleEntryNotServed(void) service = &mockService; TrafficManagementModuleTestShim module; + module.dropNodeInfoCacheForTest(); // exercise the NodeDB fallback path meshtastic_MeshPacket request = makeDecodedPacket(meshtastic_PortNum_NODEINFO_APP, kRemoteNode, kTargetNode); request.decoded.want_response = true; request.hop_start = 3; @@ -1144,6 +1146,7 @@ static void test_tm_nodeinfo_directResponse_fallbackFreshEntryServed(void) service = &mockService; TrafficManagementModuleTestShim module; + module.dropNodeInfoCacheForTest(); // exercise the NodeDB fallback path meshtastic_MeshPacket request = makeDecodedPacket(meshtastic_PortNum_NODEINFO_APP, kRemoteNode, kTargetNode); request.decoded.want_response = true; request.hop_start = 3; @@ -1185,6 +1188,7 @@ static void test_tm_nodeinfo_directResponse_fallbackThrottlesWithinWindow(void) service = &mockService; TrafficManagementModuleTestShim module; + module.dropNodeInfoCacheForTest(); // exercise the NodeDB fallback path meshtastic_MeshPacket request = makeDecodedPacket(meshtastic_PortNum_NODEINFO_APP, kRemoteNode, kTargetNode); request.decoded.want_response = true; request.hop_start = 3; @@ -1234,6 +1238,7 @@ static void test_tm_nodeinfo_directResponse_fallbackUnsignedNotServed(void) service = &mockService; TrafficManagementModuleTestShim module; + module.dropNodeInfoCacheForTest(); // exercise the NodeDB fallback path meshtastic_MeshPacket request = makeDecodedPacket(meshtastic_PortNum_NODEINFO_APP, kRemoteNode, kTargetNode); request.decoded.want_response = true; request.hop_start = 3; @@ -1246,7 +1251,6 @@ static void test_tm_nodeinfo_directResponse_fallbackUnsignedNotServed(void) resetRTCStateForTests(); } #endif // TMM_NODEINFO_REPLAY_SIGNED_GATE -#endif /** * Verify relayed telemetry broadcasts are NOT hop-exhausted. @@ -2232,7 +2236,6 @@ TM_TEST_ENTRY void setup() RUN_TEST(test_tm_nodeinfo_directResponse_learnsRequestorNodeInfo); RUN_TEST(test_tm_nodeinfo_directResponse_ignoresUnsignedSignerIdentity); RUN_TEST(test_tm_nodeinfo_clientClamp_skipsWhenNotDirect); -#if !(defined(ARCH_ESP32) && defined(BOARD_HAS_PSRAM)) RUN_TEST(test_tm_nodeinfo_directResponse_withoutNodeDbEntry_skips); RUN_TEST(test_tm_nodeinfo_directResponse_fallbackStaleEntryNotServed); RUN_TEST(test_tm_nodeinfo_directResponse_fallbackFreshEntryServed); @@ -2240,8 +2243,7 @@ TM_TEST_ENTRY void setup() #if TMM_NODEINFO_REPLAY_SIGNED_GATE RUN_TEST(test_tm_nodeinfo_directResponse_fallbackUnsignedNotServed); #endif -#endif -#if defined(ARCH_ESP32) && defined(BOARD_HAS_PSRAM) +#if TMM_HAS_NODEINFO_CACHE RUN_TEST(test_tm_nodeinfo_directResponse_psramCacheRespondsAndPreservesBitfield); RUN_TEST(test_tm_nodeinfo_directResponse_psramMissDoesNotFallbackToNodeDb); RUN_TEST(test_tm_nodeinfo_directResponse_psramStaleEntryNotServed); From 028ea172a9f0a6b3e8313ed94e8b6be2e2615b72 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 17 Jul 2026 12:12:55 +0000 Subject: [PATCH 19/44] Pin NodeInfo cache keys against the warm tier, not just the hot store cacheNodeInfoPacket() mirrored NodeDB::updateUser()'s key pin but checked only getMeshNode() - the hot store. updateUser's own pin effectively covers the warm tier too (getOrCreateMeshNode rehydrates the warm key before the check), so the mirror had a gap: for a node evicted to the warm tier whose cache slot was also gone, an attacker's NodeInfo with a bogus key passed the pin, and the cache's TOFU pin then locked the genuine node's frames out until the poisoned entry aged away. Split the authoritative lookup out of NodeDB::copyPublicKey() as copyPublicKeyAuthoritative() (hot store, then warm tier - never the opportunistic TMM tier, which would compare the cache against itself) and pin against that. Test: warm-tier-only key rejects a mismatching NodeInfo and accepts the matching one. Native suite: 61/61. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01KWHDDxY4U2XLYW2WJJ2oLT (cherry picked from commit 28e880875ef1f8bf16bdf301a4e6036537a84b4b) --- src/mesh/NodeDB.cpp | 9 ++++- src/mesh/NodeDB.h | 9 +++++ src/modules/TrafficManagementModule.cpp | 17 +++++--- test/test_traffic_management/test_main.cpp | 45 ++++++++++++++++++++++ 4 files changed, 73 insertions(+), 7 deletions(-) diff --git a/src/mesh/NodeDB.cpp b/src/mesh/NodeDB.cpp index 08700bd4e69..7c62752e1f8 100644 --- a/src/mesh/NodeDB.cpp +++ b/src/mesh/NodeDB.cpp @@ -3691,7 +3691,7 @@ uint32_t NodeDB::hotNodeLastHeard(NodeNum n) const return 0; } -bool NodeDB::copyPublicKey(NodeNum n, meshtastic_NodeInfoLite_public_key_t &out) +bool NodeDB::copyPublicKeyAuthoritative(NodeNum n, meshtastic_NodeInfoLite_public_key_t &out) { const meshtastic_NodeInfoLite *info = getMeshNode(n); if (info && info->public_key.size == 32) { @@ -3704,6 +3704,13 @@ bool NodeDB::copyPublicKey(NodeNum n, meshtastic_NodeInfoLite_public_key_t &out) return true; } #endif + return false; +} + +bool NodeDB::copyPublicKey(NodeNum n, meshtastic_NodeInfoLite_public_key_t &out) +{ + if (copyPublicKeyAuthoritative(n, out)) + return true; #if HAS_TRAFFIC_MANAGEMENT // Last resort: a key the TrafficManagement NodeInfo cache learned from an observed frame // for a node no longer in either NodeDB tier. This extends the pool of peers we can diff --git a/src/mesh/NodeDB.h b/src/mesh/NodeDB.h index eb65848caa7..87bd61cfbe1 100644 --- a/src/mesh/NodeDB.h +++ b/src/mesh/NodeDB.h @@ -360,6 +360,15 @@ class NodeDB /// tier. Returns false if we don't know a key for n. bool copyPublicKey(NodeNum n, meshtastic_NodeInfoLite_public_key_t &out); + /// Copy the 32-byte public key for node n from the AUTHORITATIVE NodeDB tiers only + /// (hot store, then warm tier) - never from opportunistic caches. This is the pin + /// reference for caches that mirror NodeDB's key hygiene (e.g. TrafficManagement's + /// NodeInfo cache): pinning against copyPublicKey() would compare such a cache + /// against its own contents. Matches the coverage of updateUser()'s own pin, which + /// sees warm keys because getOrCreateMeshNode() rehydrates them before the check. + /// Returns false if neither tier knows a key for n. + bool copyPublicKeyAuthoritative(NodeNum n, meshtastic_NodeInfoLite_public_key_t &out); + // True if node `n` is a known XEdDSA signer for exactly the 32-byte key `key32`, per either // NodeDB tier: the hot store's signed bitfield or the warm tier's cached signer bit. The // key must match so a rotated/mismatched key never inherits a stale signer verdict. Lets diff --git a/src/modules/TrafficManagementModule.cpp b/src/modules/TrafficManagementModule.cpp index ae6b95bae77..3dd827a101d 100644 --- a/src/modules/TrafficManagementModule.cpp +++ b/src/modules/TrafficManagementModule.cpp @@ -654,12 +654,17 @@ void TrafficManagementModule::cacheNodeInfoPacket(const meshtastic_MeshPacket &m TM_LOG_WARN("NodeInfo cache: incoming key matches owner, dropping 0x%08x", from); return; } - // Key pinning against the authoritative NodeDB key: once a 32-byte key is known for a - // node, refuse to cache a NodeInfo carrying a different (or missing) key. This is the - // same rule as NodeDB::updateUser() ("Public Key mismatch, dropping NodeInfo"). - const meshtastic_NodeInfoLite *dbNode = nodeDB ? nodeDB->getMeshNode(from) : nullptr; - if (dbNode && dbNode->public_key.size == 32 && - !pubKeysEqual(user.public_key.bytes, user.public_key.size, dbNode->public_key.bytes, dbNode->public_key.size)) { + // Key pinning against the authoritative NodeDB key - hot store, then warm tier: once a + // 32-byte key is known for a node, refuse to cache a NodeInfo carrying a different (or + // missing) key. This is the same rule as NodeDB::updateUser() ("Public Key mismatch, + // dropping NodeInfo") - and the same coverage: updateUser's pin sees warm-tier keys + // because getOrCreateMeshNode() rehydrates them before the check runs, so this pin must + // consult the warm tier too. Checking only the hot store would let an attacker seed this + // cache with a bogus key for a warm-evicted node, and the TOFU pin below would then lock + // the genuine node's frames out until the entry ages away. + meshtastic_NodeInfoLite_public_key_t dbKey = {0, {0}}; + if (nodeDB && nodeDB->copyPublicKeyAuthoritative(from, dbKey) && + !pubKeysEqual(user.public_key.bytes, user.public_key.size, dbKey.bytes, dbKey.size)) { TM_LOG_WARN("NodeInfo cache: public key mismatch vs NodeDB, dropping 0x%08x", from); return; } diff --git a/test/test_traffic_management/test_main.cpp b/test/test_traffic_management/test_main.cpp index 1fe8849041d..1d565f1feba 100644 --- a/test/test_traffic_management/test_main.cpp +++ b/test/test_traffic_management/test_main.cpp @@ -944,6 +944,48 @@ static void test_tm_nodeinfo_cache_rejectsMismatchedKey(void) TEST_ASSERT_EQUAL_UINT8(0x11, served.public_key.bytes[31]); } +#if WARM_NODE_COUNT > 0 +/** + * Verify the NodeInfo cache pin also covers the WARM tier: for a node evicted from the hot + * store whose key lives only in the warm tier (and whose cache slot is empty), a NodeInfo + * carrying a different key must be rejected, and one carrying the warm key accepted. + * Important: with a hot-store-only pin, an attacker could seed this cache with a bogus key + * for a warm-evicted node; the cache's own TOFU pin would then lock the genuine node's + * frames out until the poisoned entry aged away. + */ +static void test_tm_nodeinfo_cache_pinsAgainstWarmTierKey(void) +{ + moduleConfig.traffic_management.nodeinfo_direct_response_max_hops = 10; + config.device.role = meshtastic_Config_DeviceConfig_Role_CLIENT; + mockNodeDB->clearCachedNode(); // hot store misses... + uint8_t warmKey[32]; + memset(warmKey, 0x55, 32); + mockNodeDB->warmStore.clear(); + mockNodeDB->warmStore.absorb(kTargetNode, 1000000, warmKey); // ...but the warm tier holds the key + + MockRouter mockRouter; + mockRouter.addInterface(std::unique_ptr(new MockRadioInterface())); + MeshService mockService; + router = &mockRouter; + service = &mockService; + + TrafficManagementModuleTestShim module; + + // Poisoning attempt with a key that mismatches the warm tier: must not be cached. + module.handleReceived(makeNodeInfoPacketWithKey(kTargetNode, "attacker", 0x66)); + uint8_t out[32] = {0}; + TEST_ASSERT_FALSE(module.copyPublicKey(kTargetNode, out, nullptr)); + + // The genuine key (matching the warm tier) is accepted. + module.handleReceived(makeNodeInfoPacketWithKey(kTargetNode, "genuine", 0x55)); + TEST_ASSERT_TRUE(module.copyPublicKey(kTargetNode, out, nullptr)); + TEST_ASSERT_EQUAL_UINT8(0x55, out[0]); + TEST_ASSERT_EQUAL_UINT8(0x55, out[31]); + + mockNodeDB->warmStore.clear(); +} +#endif // WARM_NODE_COUNT > 0 + /** * Feature #2: a key learned from an (unsigned) NodeInfo is served by copyPublicKey() as a * trust-on-first-use key, so it can extend the encryption pool. signerProven must be false. @@ -2250,6 +2292,9 @@ TM_TEST_ENTRY void setup() RUN_TEST(test_tm_nodeinfo_directResponse_psramThrottlesWithinWindow); #if !(MESHTASTIC_EXCLUDE_PKI) RUN_TEST(test_tm_nodeinfo_cache_rejectsMismatchedKey); +#if WARM_NODE_COUNT > 0 + RUN_TEST(test_tm_nodeinfo_cache_pinsAgainstWarmTierKey); +#endif RUN_TEST(test_tm_nodeinfo_copyPublicKey_servesTofuKey); RUN_TEST(test_tm_nodeinfo_copyPublicKey_upgradesToSignerProven); RUN_TEST(test_tm_nodeinfo_copyPublicKey_missReturnsFalse); From e02c0b2c140540025ca4dc9645f5a38c6dd4bf7e Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 17 Jul 2026 12:27:39 +0000 Subject: [PATCH 20/44] Convert NodeInfo cache clocks to uint8 tick counters Replace the entry's uint32 millis stamps (lastObservedMs, lastResponseMs) with three uint8 free-running tick clocks - the same modular scheme the UnifiedCache counters already use: obsTick 3 min/tick (12.8 h period) - replay staleness gate respTick 5 s/tick (21.3 min) - per-target response throttle retTick 1 h/tick (10.67 d) - retention TTL + LRU age Validity is an explicit flag bit (hasObserved / hasResponded), not a 0 sentinel, and the maintenance sweep saturates a stamp (clears its flag) once its window passes, so no stamp can age toward its ~256-tick aliasing horizon. That retires the wrap/sentinel special cases (T4, T9) - nowStampMs() survives only for the fallback path's module-global millis stamp. obsTick is separated from retention by design: only a genuinely heard NODEINFO frame stamps it, so later membership-based retention refresh can never make a silent node look servable. Also drops lastObservedRxTime, which was only echoed in a debug log. Entry shrinks 136 -> 128 B; the 2000-entry array 272 kB -> 256 kB, 8 kB below the original develop footprint while keeping the throttle and retention features. Tick granularity costs at most one tick per window (+-5 s on 30 s, +-3 min on 6 h, +-1 h on 7 d). Native suite: 61/61. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01KWHDDxY4U2XLYW2WJJ2oLT (cherry picked from commit e48ed9cfacfed0131b77ed2f1992e8720720d387) --- src/modules/TrafficManagementModule.cpp | 194 ++++++++++++++------- src/modules/TrafficManagementModule.h | 63 ++++--- test/test_traffic_management/test_main.cpp | 4 +- 3 files changed, 169 insertions(+), 92 deletions(-) diff --git a/src/modules/TrafficManagementModule.cpp b/src/modules/TrafficManagementModule.cpp index 3dd827a101d..604df4a2498 100644 --- a/src/modules/TrafficManagementModule.cpp +++ b/src/modules/TrafficManagementModule.cpp @@ -41,30 +41,65 @@ constexpr uint32_t kClientDefaultMaxHops = 0; // Clients: direct only (cannot in // NodeInfo direct-response safety limits. // +// The per-entry clocks (observation, response throttle, retention) use the same +// free-running modular tick scheme as the UnifiedCache counters (see "Free-Running Tick +// Counters" in the header): a uint8_t holds clockMs()/tickMs, age is modular uint8 +// subtraction, and validity is an explicit flag bit (hasObserved / hasResponded) - no 0 +// sentinel. The maintenance sweep clears a stamp's flag once its window has passed +// (saturation) and re-stamps retained entries, so no stamp ever survives toward its +// ~256-tick aliasing horizon; every period below dwarfs the 60 s sweep interval. This +// replaces the old uint32 millis stamps and their wrap/sentinel special cases (T4, T9). +constexpr uint32_t kNodeInfoObsTickMs = 3UL * 60UL * 1000UL; // observation clock: 3 min/tick, period 12.8 h +constexpr uint32_t kNodeInfoRespTickMs = 5UL * 1000UL; // response-throttle clock: 5 s/tick, period 21.3 min +constexpr uint32_t kNodeInfoRetTickMs = 60UL * 60UL * 1000UL; // retention clock: 1 h/tick, period 10.67 d + // Staleness: never spoof a NodeInfo reply on behalf of a node we have not actually // heard from within this window. Without it, a cached (or forged) entry is served // indefinitely for a long-gone node while the genuine request is suppressed - the // requestor sees a fresh-looking answer for a node that may no longer exist. -// One source of truth for the 6 h window; the seconds form (NodeDB fallback path) is -// derived from the millis form (PSRAM cache path) so the two paths cannot desync. -constexpr uint32_t kNodeInfoMaxServeAgeMs = 6UL * 60UL * 60UL * 1000UL; // 6 h (PSRAM cache path) -constexpr uint32_t kNodeInfoMaxServeAgeSecs = kNodeInfoMaxServeAgeMs / 1000UL; // 6 h (NodeDB fallback path) - -// Key-retention window: how long a NodeInfo cache entry that carries a 32-byte public key is -// kept after we last heard the node. This is deliberately much longer than the serve window -// (above): once a node ages past the serve window we stop spoofing NodeInfo replies for it, -// but its key remains valuable as a last-resort encryption source (NodeDB::copyPublicKey), -// so the entry is retained to widen the pool of peers we can encrypt to. Kept well under the -// ~49.7-day millis wrap so the maintenance sweep always evicts a stale entry before its -// modular age could wrap (the wrap-safety guarantee from T4). Keyless entries do not get this -// grace - they expire at the serve window since they hold nothing worth retaining. -constexpr uint32_t kNodeInfoKeyRetentionMs = 7UL * 24UL * 60UL * 60UL * 1000UL; // 7 d +// One source of truth for the 6 h window; the seconds form (NodeDB fallback path) and +// the tick form (cache path) are both derived from it so the paths cannot desync. +constexpr uint32_t kNodeInfoMaxServeAgeSecs = 6UL * 60UL * 60UL; // 6 h (NodeDB fallback path) +constexpr uint8_t kNodeInfoMaxServeAgeObsTicks = + static_cast(kNodeInfoMaxServeAgeSecs * 1000UL / kNodeInfoObsTickMs); // 120 ticks (cache path) +static_assert(kNodeInfoMaxServeAgeSecs * 1000UL / kNodeInfoObsTickMs < 200, + "serve window must sit well inside the 256-tick observation period"); + +// Key-retention window: how long a NodeInfo cache entry that carries a 32-byte public key +// is kept after it was last retained (heard, or membership-refreshed by the sweep). This is +// deliberately much longer than the serve window: once a node ages past the serve window we +// stop spoofing NodeInfo replies for it, but its key remains valuable as a last-resort +// encryption source (NodeDB::copyPublicKey), so the entry is retained to widen the pool of +// peers we can encrypt to. Keyless entries do not get this grace - they expire at the serve +// window since they hold nothing worth retaining. +constexpr uint8_t kNodeInfoKeyRetentionRetTicks = 7 * 24; // 7 d in 1-h ticks +constexpr uint8_t kNodeInfoKeylessRetentionRetTicks = kNodeInfoMaxServeAgeSecs / 3600; // 6 h in 1-h ticks +static_assert(kNodeInfoKeyRetentionRetTicks < 200, "retention must sit well inside the 256-tick period"); // Throttle: emit at most one spoofed direct reply per target node per this interval. // Direct responses are otherwise un-throttled (they STOP the request before the // per-sender rate limiter runs) and the reply target is attacker-controlled, so an // attacker could otherwise drive unbounded local transmissions / reflected floods. +// The ms form throttles the NodeDB fallback path (module-global uint32 stamp); the tick +// form throttles the cache path (per-entry respTick). constexpr uint32_t kNodeInfoResponseThrottleMs = 30UL * 1000UL; // 30 s +constexpr uint8_t kNodeInfoResponseThrottleRespTicks = + static_cast(kNodeInfoResponseThrottleMs / kNodeInfoRespTickMs); // 6 ticks + +// Current value of each NodeInfo tick clock (free-running, from clockMs() so the unit-test +// clock hook applies). +static inline uint8_t nodeInfoObsTickNow() +{ + return static_cast(TrafficManagementModule::clockMs() / kNodeInfoObsTickMs); +} +static inline uint8_t nodeInfoRespTickNow() +{ + return static_cast(TrafficManagementModule::clockMs() / kNodeInfoRespTickMs); +} +static inline uint8_t nodeInfoRetTickNow() +{ + return static_cast(TrafficManagementModule::clockMs() / kNodeInfoRetTickMs); +} /** * Convert seconds to milliseconds with overflow protection. @@ -496,7 +531,7 @@ const TrafficManagementModule::NodeInfoPayloadEntry *TrafficManagementModule::fi * victim. Victim selection is trust-tiered so the cache doubles as a pubkey * pool (NodeDB::copyPublicKey): a keyless entry is sacrificed before any * keyed one, and a trust-on-first-use key before a signer-proven key; within - * a tier the oldest (wrap-safe age by lastObservedMs) loses. Mirrors + * a tier the oldest (modular retention age by retTick) loses. Mirrors * WarmNodeStore's keyed-first admission. NodeInfo traffic is low-rate, so the * O(n) scan is negligible. */ @@ -513,8 +548,8 @@ TrafficManagementModule::NodeInfoPayloadEntry *TrafficManagementModule::findOrCr NodeInfoPayloadEntry *empty = nullptr; NodeInfoPayloadEntry *lru = nullptr; uint8_t lruTier = 0xFF; - uint32_t lruAge = 0; - const uint32_t now = clockMs(); + uint8_t lruAge = 0; + const uint8_t now = nodeInfoRetTickNow(); for (uint16_t i = 0; i < nodeInfoTargetEntries(); i++) { NodeInfoPayloadEntry &e = nodeInfoPayload[i]; @@ -529,7 +564,9 @@ TrafficManagementModule::NodeInfoPayloadEntry *TrafficManagementModule::findOrCr continue; // an empty slot beats any victim; stop scoring // Eviction tier (lower loses first): 0 keyless, 1 TOFU key, 2 signer-proven key. const uint8_t tier = (e.user.public_key.size != 32) ? 0 : (e.keySignerProven ? 2 : 1); - const uint32_t age = now - e.lastObservedMs; // unsigned subtraction is wrap-safe + // Modular retention age: safe because the sweep evicts/re-stamps entries long + // before retTick could age past the 256-tick period. + const uint8_t age = static_cast(now - e.retTick); if (!lru || tier < lruTier || (tier == lruTier && age > lruAge)) { lru = &e; lruTier = tier; @@ -697,11 +734,12 @@ void TrafficManagementModule::cacheNodeInfoPacket(const meshtastic_MeshPacket &m #endif // Cache both payload and response metadata so direct replies can use - // richer context than "just the user protobuf" when PSRAM is present. + // richer context than "just the user protobuf" when the cache is present. // This path is intentionally independent from NodeInfoModule/NodeDB. entry->user = user; - entry->lastObservedMs = nowStampMs(); - entry->lastObservedRxTime = mp.rx_time; + entry->obsTick = nodeInfoObsTickNow(); // a genuine observation - the only place obsTick is stamped + entry->hasObserved = true; + entry->retTick = nodeInfoRetTickNow(); entry->sourceChannel = mp.channel; entry->hasDecodedBitfield = mp.decoded.has_bitfield; entry->decodedBitfield = mp.decoded.bitfield; @@ -1161,20 +1199,32 @@ int32_t TrafficManagementModule::runOnce() #if TMM_HAS_NODEINFO_CACHE if (nodeInfoPayload) { - // Evict stale NodeInfo payloads. Two windows: an entry carrying a 32-byte public key - // is retained for kNodeInfoKeyRetentionMs (it is a last-resort encryption key source, - // see NodeDB::copyPublicKey), while a keyless entry expires at the serve window since - // it holds nothing worth keeping past that point. Both windows sit well under the - // ~49.7-day millis wrap, so an entry is always removed before its modular age could - // wrap and read as fresh - the wrap-safety guarantee behind the staleness gate (T4). - // Entries are stamped with nowStampMs() so 0 reliably means "never observed". + // Saturate and evict NodeInfo entries. Saturation is what keeps every modular tick + // compare unambiguous: once a stamp's window has passed, its validity flag is cleared + // (the exact age no longer matters - both states mean "outside the window"), so a + // stamp can never survive toward its ~256-tick aliasing horizon. Every period + // (12.8 h observation / 21.3 min throttle / 10.67 d retention) dwarfs the 60 s sweep + // interval, so saturation always lands with huge margin. + // + // Eviction runs on the retention clock. Two windows: an entry carrying a 32-byte + // public key is retained for kNodeInfoKeyRetentionRetTicks (it is a last-resort + // encryption key source, see NodeDB::copyPublicKey), while a keyless entry expires + // at the serve window since it holds nothing worth keeping past that point. + const uint8_t obsNow = nodeInfoObsTickNow(); + const uint8_t respNow = nodeInfoRespTickNow(); + const uint8_t retNow = nodeInfoRetTickNow(); uint16_t nodeInfoExpired = 0; for (uint16_t i = 0; i < nodeInfoTargetEntries(); i++) { NodeInfoPayloadEntry &e = nodeInfoPayload[i]; - if (e.node == 0 || e.lastObservedMs == 0) + if (e.node == 0) continue; - const uint32_t ttl = (e.user.public_key.size == 32) ? kNodeInfoKeyRetentionMs : kNodeInfoMaxServeAgeMs; - if ((nowMs - e.lastObservedMs) > ttl) { + if (e.hasObserved && static_cast(obsNow - e.obsTick) > kNodeInfoMaxServeAgeObsTicks) + e.hasObserved = false; + if (e.hasResponded && static_cast(respNow - e.respTick) >= kNodeInfoResponseThrottleRespTicks) + e.hasResponded = false; + const uint8_t ttlTicks = + (e.user.public_key.size == 32) ? kNodeInfoKeyRetentionRetTicks : kNodeInfoKeylessRetentionRetTicks; + if (static_cast(retNow - e.retTick) > ttlTicks) { memset(&e, 0, sizeof(NodeInfoPayloadEntry)); nodeInfoExpired++; } @@ -1288,9 +1338,13 @@ bool TrafficManagementModule::shouldRespondToNodeInfo(const meshtastic_MeshPacke bool cachedHasDecodedBitfield = false; uint8_t cachedDecodedBitfield = 0; uint8_t cachedSourceChannel = 0; - uint32_t cachedLastObservedMs = 0; - uint32_t cachedLastObservedRxTime = 0; - uint32_t cachedLastResponseMs = 0; + bool cachedHasObserved = false; + uint8_t cachedObsTick = 0; + bool cachedHasResponded = false; + uint8_t cachedRespTick = 0; + // Fallback-path throttle stamp (module-global uint32 millis; the cache path throttles + // per entry via respTick instead). + uint32_t cachedFallbackResponseMs = 0; // Signer-proven provenance of the cached key, consumed by the replay gate below // (maybe_unused: read only when TMM_NODEINFO_REPLAY_SIGNED_GATE is compiled in). [[maybe_unused]] bool cachedKeySignerProven = false; @@ -1313,9 +1367,10 @@ bool TrafficManagementModule::shouldRespondToNodeInfo(const meshtastic_MeshPacke cachedHasDecodedBitfield = entry->hasDecodedBitfield; cachedDecodedBitfield = entry->decodedBitfield; cachedSourceChannel = entry->sourceChannel; - cachedLastObservedMs = entry->lastObservedMs; - cachedLastObservedRxTime = entry->lastObservedRxTime; - cachedLastResponseMs = entry->lastResponseMs; + cachedHasObserved = entry->hasObserved; + cachedObsTick = entry->obsTick; + cachedHasResponded = entry->hasResponded; + cachedRespTick = entry->respTick; cachedKeySignerProven = entry->keySignerProven; #if TMM_HAS_NODEINFO_CACHE cachedNodeInfoIndex = static_cast(entry - nodeInfoPayload); @@ -1364,21 +1419,19 @@ bool TrafficManagementModule::shouldRespondToNodeInfo(const meshtastic_MeshPacke usedFallback = true; { concurrency::LockGuard guard(&cacheLock); - cachedLastResponseMs = nodeInfoFallbackLastResponseMs; + cachedFallbackResponseMs = nodeInfoFallbackLastResponseMs; } } - // Staleness gate (PSRAM cache path): never spoof a reply on behalf of a node we have - // not actually heard from within the serve window. cachedLastObservedMs is only set on - // a PSRAM cache hit, so this leaves the NodeDB fallback (gated above) untouched. - // Wrap safety (T4): the modular age below is only correct while true age < 2^32 ms - // (~49.7 days); an entry that lingered that long could wrap to a small age and read as - // fresh. The maintenance sweep evicts NodeInfo entries once they exceed the serve window - // (see runOnce()), so an entry is gone long before its age can approach the wrap, keeping - // this compare unambiguous. The 0-sentinel instant (T9) is handled by nowStampMs(). - if (cachedLastObservedMs != 0 && (clockMs() - cachedLastObservedMs) > kNodeInfoMaxServeAgeMs) { - TM_LOG_DEBUG("NodeInfo PSRAM entry for 0x%08x stale (age=%lu ms), not responding", p->to, - static_cast(clockMs() - cachedLastObservedMs)); + // Staleness gate (cache path): never spoof a reply on behalf of a node we have not + // actually HEARD within the serve window. hasObserved carries the stamp's validity - + // it distinguishes a genuinely observed entry from one merely seeded or retained, and + // the maintenance sweep clears it once the window passes (saturation), so this modular + // tick compare can never see an aliased age. Only cache hits are gated here; the NodeDB + // fallback was gated above on last_heard. + if (hasCachedUser && (!cachedHasObserved || + static_cast(nodeInfoObsTickNow() - cachedObsTick) > kNodeInfoMaxServeAgeObsTicks)) { + TM_LOG_DEBUG("NodeInfo cache entry for 0x%08x not freshly observed, not responding", p->to); return false; } @@ -1400,21 +1453,26 @@ bool TrafficManagementModule::shouldRespondToNodeInfo(const meshtastic_MeshPacke // attacker-controlled, so without this an attacker could drive unbounded local // transmissions / reflected floods. Suppress the duplicate request (return true) rather // than letting it propagate and generate more mesh traffic. - // - PSRAM path: cachedLastResponseMs is per target node (NodeInfoPayloadEntry). - // - Fallback path: cachedLastResponseMs is the module-global stamp (no per-node slot). + // - Cache path: respTick is per target node (NodeInfoPayloadEntry, 5-s tick clock; + // hasResponded carries validity and the sweep clears it after the window). + // - Fallback path: the module-global millis stamp (no per-node slot). // NOTE (accepted design, not a defect): - // - On the PSRAM path this is keyed on p->to, so a DISTINCT legitimate requestor for the + // - On the cache path this is keyed on p->to, so a DISTINCT legitimate requestor for the // same target gets no reply for up to one window (returning true STOPs it). That is // fine: the mesh is redundant - the genuine node or another cache-holder answers, and // a real node would rate-limit its own replies the same way. - // - The per-target PSRAM key does not bound aggregate local TX: an attacker cycling N + // - The per-target key does not bound aggregate local TX: an attacker cycling N // distinct cached targets still draws N spoofed transmits per window. The fallback - // path's global stamp does bound aggregate; a global cap on the PSRAM path would too, + // path's global stamp does bound aggregate; a global cap on the cache path would too, // but at the cost of throughput on legitimate multi-target responders, so it is left // per-target by choice. - if (cachedLastResponseMs != 0 && (clockMs() - cachedLastResponseMs) < kNodeInfoResponseThrottleMs) { - TM_LOG_DEBUG("NodeInfo response throttled for 0x%08x (%lu ms since last)", p->to, - static_cast(clockMs() - cachedLastResponseMs)); + const bool throttled = + usedFallback + ? (cachedFallbackResponseMs != 0 && (clockMs() - cachedFallbackResponseMs) < kNodeInfoResponseThrottleMs) + : (cachedHasResponded && + static_cast(nodeInfoRespTickNow() - cachedRespTick) < kNodeInfoResponseThrottleRespTicks); + if (throttled) { + TM_LOG_DEBUG("NodeInfo response throttled for 0x%08x", p->to); return true; } @@ -1446,11 +1504,10 @@ bool TrafficManagementModule::shouldRespondToNodeInfo(const meshtastic_MeshPacke if (config.lora.config_ok_to_mqtt) reply->decoded.bitfield |= BITFIELD_OK_TO_MQTT_MASK; - if (hasCachedUser && cachedLastObservedMs != 0) { - uint32_t ageMs = clockMs() - cachedLastObservedMs; - TM_LOG_DEBUG("NodeInfo PSRAM hit node=0x%08x age=%lu ms src_ch=%u req_ch=%u rx_time=%lu", p->to, - static_cast(ageMs), static_cast(cachedSourceChannel), - static_cast(p->channel), static_cast(cachedLastObservedRxTime)); + if (hasCachedUser) { + TM_LOG_DEBUG("NodeInfo cache hit node=0x%08x age=%u obs-ticks src_ch=%u req_ch=%u", p->to, + static_cast(static_cast(nodeInfoObsTickNow() - cachedObsTick)), + static_cast(cachedSourceChannel), static_cast(p->channel)); } // Spoof the sender as the target node so the requestor sees a valid NodeInfo response. @@ -1469,13 +1526,14 @@ bool TrafficManagementModule::shouldRespondToNodeInfo(const meshtastic_MeshPacke service->sendToMesh(reply); // Record the send so the throttle can suppress a burst of requests. The fallback path - // stamps the module-global marker; the PSRAM path stamps the per-node entry we served - // from. nowStampMs() keeps the stamp off the 0 "never" sentinel at the millis wrap (T9). + // stamps the module-global millis marker (nowStampMs() keeps it off the 0 "never" + // sentinel at the wrap, T9); the cache path stamps the served entry's respTick, whose + // validity travels in hasResponded instead of a sentinel. if (usedFallback) { concurrency::LockGuard guard(&cacheLock); nodeInfoFallbackLastResponseMs = nowStampMs(); } - // Stamp the PSRAM entry we served from, addressing it by the index captured during the + // Stamp the cache entry we served from, addressing it by the index captured during the // initial lookup rather than rescanning the array (T5). The cache lock was released in // between, so the slot could have been evicted or reused for a different node; re-validate // node == p->to under the lock and skip the stamp if it no longer matches. @@ -1483,8 +1541,10 @@ bool TrafficManagementModule::shouldRespondToNodeInfo(const meshtastic_MeshPacke if (!usedFallback && nodeInfoPayload && cachedNodeInfoIndex >= 0) { concurrency::LockGuard guard(&cacheLock); NodeInfoPayloadEntry &e = nodeInfoPayload[cachedNodeInfoIndex]; - if (e.node == p->to) - e.lastResponseMs = nowStampMs(); + if (e.node == p->to) { + e.respTick = nodeInfoRespTickNow(); + e.hasResponded = true; + } } #endif diff --git a/src/modules/TrafficManagementModule.h b/src/modules/TrafficManagementModule.h index bde7a2a5cfb..38cc3ed23de 100644 --- a/src/modules/TrafficManagementModule.h +++ b/src/modules/TrafficManagementModule.h @@ -128,12 +128,12 @@ class TrafficManagementModule : public MeshModule, private concurrency::OSThread static uint32_t clockMs() { return millis(); } #endif - // Timestamp for the millis-based fields that use 0 as a "never" sentinel - // (NodeInfoPayloadEntry::lastObservedMs / lastResponseMs, nodeInfoFallbackLastResponseMs). - // clockMs() is 0 for exactly one millisecond every ~49.7-day wrap, which would collide - // with the sentinel and momentarily disable the staleness/throttle gate for a freshly - // stamped entry. Map that one instant to 1; the 1 ms skew is irrelevant to every window - // these fields feed. (T9) + // Timestamp for the one remaining millis-based field that uses 0 as a "never" sentinel + // (nodeInfoFallbackLastResponseMs - the cache entries use tick clocks with explicit + // validity bits instead). clockMs() is 0 for exactly one millisecond every ~49.7-day + // wrap, which would collide with the sentinel and momentarily disable the fallback + // throttle for a freshly stamped reply. Map that one instant to 1; the 1 ms skew is + // irrelevant to the throttle window. (T9) static uint32_t nowStampMs() { const uint32_t t = clockMs(); @@ -230,7 +230,7 @@ class TrafficManagementModule : public MeshModule, private concurrency::OSThread static constexpr uint16_t cacheSize() { return TRAFFIC_MANAGEMENT_CACHE_SIZE; } // NodeInfo cache configuration (PSRAM path): a flat PSRAM array of payload - // entries, linear scan keyed by `node`, LRU eviction by lastObservedMs. + // entries, linear scan keyed by `node`, tiered LRU eviction by retTick. // NodeInfo traffic is low-rate, so a full scan per lookup/insert is fine. static constexpr uint16_t kNodeInfoCacheEntries = 2000; static constexpr uint16_t nodeInfoTargetEntries() { return kNodeInfoCacheEntries; } @@ -297,19 +297,25 @@ class TrafficManagementModule : public MeshModule, private concurrency::OSThread // packet for this node. shouldRespondToNodeInfo() uses this metadata when // building spoofed replies for requesting clients. - // Last local uptime tick (millis) when this entry was refreshed. - uint32_t lastObservedMs; + // Free-running tick stamps (uint8 modular clocks, see kNodeInfo*TickMs in the .cpp - + // same scheme as the UnifiedCache tick counters). Validity of obsTick/respTick is + // carried by the hasObserved/hasResponded flag bits below, not a 0 sentinel; the + // maintenance sweep clears a flag once its window passes, so no stamp can age far + // enough for its modular compare to alias. - // Local uptime (millis) of the most recent spoofed direct reply we emitted - // for this node. 0 = never. Used purely to throttle direct responses (at most - // one per node per kNodeInfoResponseThrottleMs); the modular/subtraction compare - // self-expires, so no sweep is needed to "clear" it. This is separate from - // lastObservedMs, which tracks when we last *heard* the node (LRU + staleness). - uint32_t lastResponseMs; + // When we last actually HEARD a NODEINFO_APP frame from this node (3-min ticks). + // Drives the replay staleness gate and nothing else - retention refresh never + // touches it, so a spoofed reply is only ever backed by genuine recent observation. + uint8_t obsTick; - // Last RTC/packet timestamp (seconds) observed for this NodeInfo frame. - // If unavailable in packet, remains 0. - uint32_t lastObservedRxTime; + // When we last emitted a spoofed direct reply for this node (5-s ticks). + // Drives the per-target response throttle. + uint8_t respTick; + + // When this entry was last retained (1-h ticks): stamped on observation, and by the + // maintenance sweep for entries it keeps alive. Drives eviction (retention TTL) + // and the LRU age used by victim selection. + uint8_t retTick; // Channel where we most recently heard this node's NodeInfo. uint8_t sourceChannel; @@ -334,12 +340,21 @@ class TrafficManagementModule : public MeshModule, private concurrency::OSThread // copyPublicKey() consumers. A signature can only be verified against a key we already // held, so a first-contact key is always TOFU (0) until a later signed frame upgrades it. uint8_t keySignerProven : 1; + + // obsTick is valid: we have actually heard a NODEINFO_APP frame from this node + // within the observation clock's horizon. Cleared by the sweep once the serve + // window passes (saturation) - both states mean "do not serve a spoofed reply". + uint8_t hasObserved : 1; + + // respTick is valid: we emitted a spoofed reply within the throttle clock's + // horizon. Cleared by the sweep once the throttle window passes. + uint8_t hasResponded : 1; }; - // sourceChannel, decodedBitfield, and the two 1-bit flags (hasDecodedBitfield, - // keySignerProven) make up the entry's trailing metadata; the two bits share a single byte, - // leaving 6 spare bits. Add future booleans as more 1-bit fields here rather than new bytes - - // the array is 2000 entries in PSRAM, so a fresh byte can cost a whole aligned word. (No - // exact-size static_assert: sizeof(meshtastic_User) and its trailing padding vary by platform + // The tick stamps, sourceChannel, decodedBitfield, and the 1-bit flags make up the + // entry's trailing metadata; the flag bits share a single byte, leaving 4 spare bits. + // Add future booleans as more 1-bit fields here rather than new bytes - the array is + // 2000 entries in PSRAM, so a fresh byte can cost a whole aligned word. (No exact-size + // static_assert: sizeof(meshtastic_User) and its trailing padding vary by platform // - nanopb packs the generated struct differently on embedded targets - so any fixed byte // count is non-portable and would fail the build on some boards.) @@ -347,7 +362,7 @@ class TrafficManagementModule : public MeshModule, private concurrency::OSThread bool nodeInfoPayloadFromPsram = false; // Tracks allocator for correct deallocation // Throttle stamp for the NodeDB fallback direct-response path (non-PSRAM boards). - // The PSRAM path throttles per target via NodeInfoPayloadEntry::lastResponseMs, but + // The cache path throttles per target via NodeInfoPayloadEntry::respTick, but // the fallback path has no per-node slot to stamp, so it would otherwise emit spoofed // replies with no rate limit at all. This single module-global stamp throttles that // path to at most one spoofed reply per kNodeInfoResponseThrottleMs across all targets. diff --git a/test/test_traffic_management/test_main.cpp b/test/test_traffic_management/test_main.cpp index 1d565f1feba..13f716b7124 100644 --- a/test/test_traffic_management/test_main.cpp +++ b/test/test_traffic_management/test_main.cpp @@ -822,7 +822,9 @@ static void test_tm_nodeinfo_directResponse_psramStaleEntryNotServed(void) module.markKeySignerProvenForTest(kTargetNode); // Advance the virtual clock just past the 6 h serve window. - TrafficManagementModule::s_testNowMs += (6UL * 60UL * 60UL * 1000UL) + 1000UL; + // 6 h + two 3-min observation ticks: guarantees the modular obs-tick age exceeds the + // 120-tick serve window whatever the clock's offset within its current tick. + TrafficManagementModule::s_testNowMs += (6UL * 60UL * 60UL * 1000UL) + (2UL * 3UL * 60UL * 1000UL); meshtastic_MeshPacket request = makeDecodedPacket(meshtastic_PortNum_NODEINFO_APP, kRemoteNode, kTargetNode); request.decoded.want_response = true; From 7b63f2f42a03bfed48e933542fe14bf772599d1e Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 17 Jul 2026 12:37:00 +0000 Subject: [PATCH 21/44] Keep the NodeInfo cache a superset of NodeDB (seed + keep-alive) The cache learned identities only from overheard NODEINFO frames, so its membership was opportunistic: a NodeDB-tier node whose frame was never heard this boot had no entry (no key pin to protect it, no name to rehydrate), and the retention TTL evicted entries for nodes that still lived in the hot store or warm tier. Add an anti-entropy pass to the maintenance sweep (reconcileNodeInfoMembershipLocked): every hot-store / warm-tier node gets an entry - full identity from the hot store (hasFullUser), key-only from the warm tier - with signer provenance inherited key-matched, and NodeDB's key adopted wholesale on conflict. Member entries (isMember) are re-stamped each sweep, so they never age toward the retention TTL; when a node leaves both tiers the keep-alive stops and its entry ages out from its final member re-stamp. Membership also outranks key trust in LRU victim selection, and the reconcile pass skips seeding rather than churn one member out for another when hot+warm exceeds the cache. Two properties are load-bearing: - seeding/keep-alive never touch obsTick/hasObserved, so a seeded or retained entry is never served as a spoofed reply - only genuinely heard frames make a node servable; - copyUser() now requires hasFullUser, so a key-only warm seed can never stamp HAS_USER onto a nameless node via name-rehydration. Native suite: 64/64. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01KWHDDxY4U2XLYW2WJJ2oLT (cherry picked from commit 4f1b30d838b2f7b11173373b8f1ba97cf91bef6b) --- src/mesh/WarmNodeStore.h | 10 ++ src/modules/TrafficManagementModule.cpp | 113 ++++++++++++++- src/modules/TrafficManagementModule.h | 28 +++- test/test_traffic_management/test_main.cpp | 151 +++++++++++++++++++++ 4 files changed, 296 insertions(+), 6 deletions(-) diff --git a/src/mesh/WarmNodeStore.h b/src/mesh/WarmNodeStore.h index 3fe92496c11..10383e0e8c8 100644 --- a/src/mesh/WarmNodeStore.h +++ b/src/mesh/WarmNodeStore.h @@ -136,6 +136,16 @@ class WarmNodeStore size_t count() const; size_t capacity() const { return entries ? WARM_NODE_COUNT : 0; } + /// Slot-indexed read access for consumers that reconcile against the whole warm tier + /// (e.g. TrafficManagement's NodeInfo cache seeding). @return the entry in slot i, or + /// nullptr when the slot is empty or i >= capacity(). Iterate i in [0, capacity()). + const WarmNodeEntry *entryAt(size_t i) const + { + if (!entries || i >= WARM_NODE_COUNT || entries[i].num == 0) + return nullptr; + return &entries[i]; + } + #if MESHTASTIC_NODEDB_MIGRATION_VERBOSE /// Debug: dump every live warm entry (num / last_heard / has-key) to the /// console. Compiled out unless MESHTASTIC_NODEDB_MIGRATION_VERBOSE. diff --git a/src/modules/TrafficManagementModule.cpp b/src/modules/TrafficManagementModule.cpp index 604df4a2498..8972784993b 100644 --- a/src/modules/TrafficManagementModule.cpp +++ b/src/modules/TrafficManagementModule.cpp @@ -536,7 +536,8 @@ const TrafficManagementModule::NodeInfoPayloadEntry *TrafficManagementModule::fi * O(n) scan is negligible. */ TrafficManagementModule::NodeInfoPayloadEntry *TrafficManagementModule::findOrCreateNodeInfoEntry(NodeNum node, - bool *usedEmptySlot) + bool *usedEmptySlot, + bool spareMembers) { if (usedEmptySlot) *usedEmptySlot = false; @@ -562,8 +563,11 @@ TrafficManagementModule::NodeInfoPayloadEntry *TrafficManagementModule::findOrCr } if (empty) continue; // an empty slot beats any victim; stop scoring - // Eviction tier (lower loses first): 0 keyless, 1 TOFU key, 2 signer-proven key. - const uint8_t tier = (e.user.public_key.size != 32) ? 0 : (e.keySignerProven ? 2 : 1); + // Eviction tier (lower loses first): 0 keyless, 1 TOFU key, 2 signer-proven key; + // +3 when the node is a NodeDB member - the cache must not shed a NodeDB-tier + // identity while it still holds opportunistic strangers. + const uint8_t tier = static_cast(((e.user.public_key.size != 32) ? 0 : (e.keySignerProven ? 2 : 1)) + + (e.isMember ? 3 : 0)); // Modular retention age: safe because the sweep evicts/re-stamps entries long // before retTick could age past the 256-tick period. const uint8_t age = static_cast(now - e.retTick); @@ -577,6 +581,8 @@ TrafficManagementModule::NodeInfoPayloadEntry *TrafficManagementModule::findOrCr NodeInfoPayloadEntry *slot = empty ? empty : lru; if (!slot) return nullptr; + if (spareMembers && slot == lru && lru->isMember) + return nullptr; // caller would rather skip than churn one member out for another memset(slot, 0, sizeof(NodeInfoPayloadEntry)); slot->node = node; if (usedEmptySlot) @@ -605,6 +611,96 @@ uint16_t TrafficManagementModule::countNodeInfoEntriesLocked() const #endif } +void TrafficManagementModule::reconcileNodeInfoMembershipLocked() +{ +#if TMM_HAS_NODEINFO_CACHE + if (!nodeInfoPayload || !nodeDB) + return; + + const uint8_t retNow = nodeInfoRetTickNow(); + const NodeNum self = nodeDB->getNodeNum(); + + // Entries touched by this pass, so membership can be cleared exactly on the ones that + // were not (a departed node's entry then ages out on the normal retention TTL from its + // final member re-stamp). A retTick comparison can't do this - an entry observed within + // the current 1-h tick would be indistinguishable from one re-stamped by this pass. + // Static: 250 B of .bss beats a stack excursion under the cache lock. + static uint8_t marked[(kNodeInfoCacheEntries + 7) / 8]; + memset(marked, 0, sizeof(marked)); + + // Upsert one NodeDB-tier node. Cost note: a miss in findOrCreateNodeInfoEntry scans the + // whole array, so this pass is O(members x entries) once per minute - tens of ms on a + // saturated ESP32, on the module thread, and accepted: the alternative is an index the + // entry format doesn't have room for. + auto reconcileOne = [&](NodeNum n, const uint8_t *key32, bool signerKnown, const meshtastic_NodeInfoLite *hot) { + if (n == 0 || n == self) + return; + bool usedEmptySlot = false; + NodeInfoPayloadEntry *e = findOrCreateNodeInfoEntry(n, &usedEmptySlot, /*spareMembers=*/true); + if (!e) + return; // cache is member-saturated; skip rather than churn a member out + + // Key conflict against the authoritative NodeDB key: NodeDB wins. Wipe and reseed + // rather than patch, so no name or provenance pinned to the old key survives onto + // the new one. (Possible for residue predating the warm pin, or a NodeDB re-key.) + if (!usedEmptySlot && key32 && e->user.public_key.size == 32 && + memcmp(e->user.public_key.bytes, key32, 32) != 0) { + memset(e, 0, sizeof(*e)); + e->node = n; + usedEmptySlot = true; + } + + if (usedEmptySlot) { + // Fresh slot: seed what the tier knows. A hot node with a User contributes the + // full identity; a warm node yields a key-only record (the warm tier keeps no + // names). Never touches hasObserved - seeding must not make a node servable. + if (hot && nodeInfoLiteHasUser(hot)) { + e->user = TypeConversions::ConvertToUser(hot); + e->hasFullUser = true; + } + snprintf(e->user.id, sizeof(e->user.id), "!%08x", n); + } + // Backfill a missing key (fresh seed, or an entry cached from a keyless frame). + if (key32 && e->user.public_key.size != 32) { + memcpy(e->user.public_key.bytes, key32, 32); + e->user.public_key.size = 32; + } + // Signer provenance transfers only alongside a matching key (monotonic upgrade). + if (signerKnown && key32 && e->user.public_key.size == 32 && memcmp(e->user.public_key.bytes, key32, 32) == 0) + e->keySignerProven = true; + + e->isMember = true; + e->retTick = retNow; // keep-alive: a member entry never ages toward the retention TTL + const size_t idx = static_cast(e - nodeInfoPayload); + marked[idx / 8] |= static_cast(1u << (idx % 8)); + }; + + for (size_t i = 0; i < nodeDB->getNumMeshNodes(); i++) { + const meshtastic_NodeInfoLite *info = nodeDB->getMeshNodeByIndex(i); + if (!info || info->num == 0) + continue; + const uint8_t *key = (info->public_key.size == 32) ? info->public_key.bytes : nullptr; + reconcileOne(info->num, key, nodeInfoLiteHasXeddsaSigned(info), info); + } +#if WARM_NODE_COUNT > 0 + for (size_t i = 0; i < nodeDB->warmStore.capacity(); i++) { + const WarmNodeEntry *w = nodeDB->warmStore.entryAt(i); + if (!w) + continue; + const bool hasKey = !memfll(w->public_key, 0, sizeof(w->public_key)); + reconcileOne(w->num, hasKey ? w->public_key : nullptr, warmSignerOf(*w), nullptr); + } +#endif + + // Clear membership on every entry this pass did not touch: its node has left both + // NodeDB tiers (or lost the spareMembers race), so it reverts to normal-tier aging. + for (uint16_t i = 0; i < nodeInfoTargetEntries(); i++) { + if (nodeInfoPayload[i].node != 0 && !(marked[i / 8] & (1u << (i % 8)))) + nodeInfoPayload[i].isMember = false; + } +#endif +} + bool TrafficManagementModule::copyPublicKey(NodeNum node, uint8_t out[32], bool *signerProven) const { #if TMM_HAS_NODEINFO_CACHE @@ -636,7 +732,9 @@ bool TrafficManagementModule::copyUser(NodeNum node, meshtastic_User &out, bool concurrency::LockGuard guard(&cacheLock); const NodeInfoPayloadEntry *entry = findNodeInfoEntry(node); - if (!entry) + // Key-only records (seeded from the warm tier, which keeps no names) are not a User: + // handing one to name-rehydration would stamp HAS_USER onto a nameless node. + if (!entry || !entry->hasFullUser) return false; out = entry->user; @@ -739,6 +837,7 @@ void TrafficManagementModule::cacheNodeInfoPacket(const meshtastic_MeshPacket &m entry->user = user; entry->obsTick = nodeInfoObsTickNow(); // a genuine observation - the only place obsTick is stamped entry->hasObserved = true; + entry->hasFullUser = true; // an observed NODEINFO frame always carries the full User entry->retTick = nodeInfoRetTickNow(); entry->sourceChannel = mp.channel; entry->hasDecodedBitfield = mp.decoded.has_bitfield; @@ -1229,6 +1328,12 @@ int32_t TrafficManagementModule::runOnce() nodeInfoExpired++; } } + + // Anti-entropy: reseed/refresh an entry for every NodeDB-tier node, so the cache + // stays a superset of NodeDB's identities (write-through hooks give immediacy; + // this pass self-heals whatever they miss, and is what keeps member entries' + // retTick from ever reaching the eviction TTL above). + reconcileNodeInfoMembershipLocked(); TM_LOG_DEBUG("NodeInfo PSRAM cache: %u/%u (%u expired)", static_cast(countNodeInfoEntriesLocked()), static_cast(nodeInfoTargetEntries()), static_cast(nodeInfoExpired)); } diff --git a/src/modules/TrafficManagementModule.h b/src/modules/TrafficManagementModule.h index 38cc3ed23de..549fe479b05 100644 --- a/src/modules/TrafficManagementModule.h +++ b/src/modules/TrafficManagementModule.h @@ -349,9 +349,21 @@ class TrafficManagementModule : public MeshModule, private concurrency::OSThread // respTick is valid: we emitted a spoofed reply within the throttle clock's // horizon. Cleared by the sweep once the throttle window passes. uint8_t hasResponded : 1; + + // `user` carries a real User payload (name etc.) - from an observed NODEINFO frame + // or a hot-store seed - rather than being a key-only record seeded from the warm + // tier. copyUser()/name-rehydration require it; copyPublicKey() does not. + uint8_t hasFullUser : 1; + + // The node existed in NodeDB (hot store or warm tier) at the last maintenance + // sweep. Member entries are kept alive (retTick refreshed) and are the stickiest + // under LRU eviction, so the cache stays a superset of NodeDB's identities; when + // membership lapses the entry starts aging toward the normal retention TTL from + // its final member re-stamp. May be up to one sweep (60 s) stale. + uint8_t isMember : 1; }; // The tick stamps, sourceChannel, decodedBitfield, and the 1-bit flags make up the - // entry's trailing metadata; the flag bits share a single byte, leaving 4 spare bits. + // entry's trailing metadata; the flag bits share a single byte, leaving 2 spare bits. // Add future booleans as more 1-bit fields here rather than new bytes - the array is // 2000 entries in PSRAM, so a fresh byte can cost a whole aligned word. (No exact-size // static_assert: sizeof(meshtastic_User) and its trailing padding vary by platform @@ -410,8 +422,20 @@ class TrafficManagementModule : public MeshModule, private concurrency::OSThread // NodeInfo cache operations (flat PSRAM payload array, linear scan) const NodeInfoPayloadEntry *findNodeInfoEntry(NodeNum node) const; - NodeInfoPayloadEntry *findOrCreateNodeInfoEntry(NodeNum node, bool *usedEmptySlot); + // spareMembers: when creating would evict an isMember entry, return nullptr instead. + // The reconcile pass uses it so seeding one NodeDB-tier node never churns out another + // when the cache is smaller than hot+warm; the packet path leaves it false (a freshly + // observed stranger may displace the LRU member - observed freshness has value). + NodeInfoPayloadEntry *findOrCreateNodeInfoEntry(NodeNum node, bool *usedEmptySlot, bool spareMembers = false); uint16_t countNodeInfoEntriesLocked() const; + + // Anti-entropy pass called from the maintenance sweep, under cacheLock. Keeps the + // NodeInfo cache a superset of NodeDB's identities: ensures every hot-store / warm-tier + // node has an entry (seeding name/key/signer-provenance from whichever tier holds + // them), refreshes member entries' retTick (keep-alive) and isMember, and adopts + // NodeDB's key when a cached key conflicts with the authoritative one. Seeding never + // sets hasObserved, so it can never make a silent node servable by the replay path. + void reconcileNodeInfoMembershipLocked(); void cacheNodeInfoPacket(const meshtastic_MeshPacket &mp); // ========================================================================= diff --git a/test/test_traffic_management/test_main.cpp b/test/test_traffic_management/test_main.cpp index 13f716b7124..96c7879df1c 100644 --- a/test/test_traffic_management/test_main.cpp +++ b/test/test_traffic_management/test_main.cpp @@ -103,6 +103,21 @@ class MockNodeDB : public NodeDB numMeshNodes = 2; } + // Seed a full identity (name, 32-byte key of `keyByte`, optional signer bit) into the + // hot-store buffer at index 1, for reconcile/seeding tests that iterate + // getMeshNodeByIndex(). + void setHotNodeIdentity(NodeNum n, const char *longName, uint8_t keyByte, bool signer) + { + setHotNode(n, 0); + meshtastic_NodeInfoLite &info = (*meshNodes)[1]; + strncpy(info.long_name, longName, sizeof(info.long_name) - 1); + info.public_key.size = 32; + memset(info.public_key.bytes, keyByte, 32); + info.bitfield |= NODEINFO_BITFIELD_HAS_USER_MASK; + if (signer) + info.bitfield |= NODEINFO_BITFIELD_HAS_XEDDSA_SIGNED_MASK; + } + // Evict everything but "self" - simulates the hot DB rolling over. Logical // count only; the buffer is left intact so the invariant holds. void rollHotStore() @@ -986,6 +1001,139 @@ static void test_tm_nodeinfo_cache_pinsAgainstWarmTierKey(void) mockNodeDB->warmStore.clear(); } + +/** + * Reconcile seeding, serve-gate honesty: a hot-store identity is seeded into the cache by + * the maintenance sweep (name + key + signer provenance usable via copyUser/copyPublicKey), + * but is NEVER served as a spoofed reply until a genuine NODEINFO frame is heard - seeding + * and retention must not make a silent node look alive. + */ +static void test_tm_nodeinfo_reconcile_seedsFromHotStoreButNeverServes(void) +{ + moduleConfig.traffic_management.nodeinfo_direct_response_max_hops = 10; + config.device.role = meshtastic_Config_DeviceConfig_Role_CLIENT; + mockNodeDB->clearCachedNode(); + mockNodeDB->warmStore.clear(); + mockNodeDB->setHotNodeIdentity(kTargetNode, "hot-name", 0x77, /*signer=*/true); + + MockRouter mockRouter; + mockRouter.addInterface(std::unique_ptr(new MockRadioInterface())); + MeshService mockService; + router = &mockRouter; + service = &mockService; + + TrafficManagementModuleTestShim module; + module.runOnce(); // maintenance sweep -> reconcile seeds the hot identity + + // Seeded identity is available to the key pool and name rehydration... + uint8_t key[32] = {0}; + bool proven = false; + TEST_ASSERT_TRUE(module.copyPublicKey(kTargetNode, key, &proven)); + TEST_ASSERT_EQUAL_UINT8(0x77, key[0]); + TEST_ASSERT_TRUE(proven); // inherited from the hot store's signer bit, key-matched + meshtastic_User seeded = meshtastic_User_init_zero; + TEST_ASSERT_TRUE(module.copyUser(kTargetNode, seeded, nullptr)); + TEST_ASSERT_EQUAL_STRING("hot-name", seeded.long_name); + + // ...but a request for it is NOT answered: never observed, so never servable. + meshtastic_MeshPacket request = makeDecodedPacket(meshtastic_PortNum_NODEINFO_APP, kRemoteNode, kTargetNode); + request.decoded.want_response = true; + request.hop_start = 3; + request.hop_limit = 3; + TEST_ASSERT_EQUAL_INT(static_cast(ProcessMessage::CONTINUE), static_cast(module.handleReceived(request))); + TEST_ASSERT_EQUAL_UINT32(0, static_cast(mockRouter.sentPackets.size())); + + // A genuinely observed frame (key matches the NodeDB pin) makes it servable. + module.handleReceived(makeNodeInfoPacketWithKey(kTargetNode, "hot-name", 0x77)); + request.id = 0xCCCC0002; + TEST_ASSERT_EQUAL_INT(static_cast(ProcessMessage::STOP), static_cast(module.handleReceived(request))); + TEST_ASSERT_EQUAL_UINT32(1, static_cast(mockRouter.sentPackets.size())); + + mockNodeDB->rollHotStore(); +} + +/** + * Reconcile seeding from the warm tier yields a key-only record: usable by copyPublicKey + * (with the warm signer bit inherited), but never by copyUser - the warm tier keeps no + * names, and a nameless User must not reach name-rehydration. + */ +static void test_tm_nodeinfo_reconcile_seedsKeyOnlyFromWarmTier(void) +{ + moduleConfig.traffic_management.nodeinfo_direct_response_max_hops = 10; + config.device.role = meshtastic_Config_DeviceConfig_Role_CLIENT; + mockNodeDB->clearCachedNode(); + mockNodeDB->rollHotStore(); + uint8_t warmKey[32]; + memset(warmKey, 0x44, 32); + mockNodeDB->warmStore.clear(); + mockNodeDB->warmStore.absorb(kTargetNode, 1000000, warmKey, 0, 0, /*signer=*/true); + + MockRouter mockRouter; + mockRouter.addInterface(std::unique_ptr(new MockRadioInterface())); + MeshService mockService; + router = &mockRouter; + service = &mockService; + + TrafficManagementModuleTestShim module; + module.runOnce(); + + uint8_t key[32] = {0}; + bool proven = false; + TEST_ASSERT_TRUE(module.copyPublicKey(kTargetNode, key, &proven)); + TEST_ASSERT_EQUAL_UINT8(0x44, key[31]); + TEST_ASSERT_TRUE(proven); + meshtastic_User out = meshtastic_User_init_zero; + TEST_ASSERT_FALSE(module.copyUser(kTargetNode, out, nullptr)); // key-only record + + mockNodeDB->warmStore.clear(); +} + +/** + * Membership keep-alive vs retention TTL: an entry whose node still lives in a NodeDB tier + * is re-stamped by every sweep and outlives the 7-day retention window; a non-member entry + * of the same age is evicted; and once membership lapses the survivor ages out too. + */ +static void test_tm_nodeinfo_membership_keepAliveOutlivesRetention(void) +{ + moduleConfig.traffic_management.nodeinfo_direct_response_max_hops = 10; + config.device.role = meshtastic_Config_DeviceConfig_Role_CLIENT; + mockNodeDB->clearCachedNode(); + mockNodeDB->rollHotStore(); + uint8_t warmKey[32]; + memset(warmKey, 0x21, 32); + mockNodeDB->warmStore.clear(); + mockNodeDB->warmStore.absorb(kTargetNode, 1000000, warmKey); // kTargetNode is a member + + MockRouter mockRouter; + mockRouter.addInterface(std::unique_ptr(new MockRadioInterface())); + MeshService mockService; + router = &mockRouter; + service = &mockService; + + TrafficManagementModuleTestShim module; + // Both keyed entries observed now; kRemoteNode is in no NodeDB tier (non-member). + module.handleReceived(makeNodeInfoPacketWithKey(kTargetNode, "member", 0x21)); + module.handleReceived(makeNodeInfoPacketWithKey(kRemoteNode, "stranger", 0x22)); + + uint8_t key[32] = {0}; + // Two sweeps 4 days apart: the member is re-stamped each time; the stranger's age + // crosses the 7-day keyed retention TTL at the second sweep and is evicted. + TrafficManagementModule::s_testNowMs += 4UL * 24UL * 60UL * 60UL * 1000UL; + module.runOnce(); + TrafficManagementModule::s_testNowMs += 4UL * 24UL * 60UL * 60UL * 1000UL; + module.runOnce(); + TEST_ASSERT_TRUE(module.copyPublicKey(kTargetNode, key, nullptr)); + TEST_ASSERT_FALSE(module.copyPublicKey(kRemoteNode, key, nullptr)); + + // Membership lapses: the node leaves the warm tier, keep-alive stops, and the entry + // ages out on the normal keyed TTL (one sweep to notice, one past the window). + mockNodeDB->warmStore.clear(); + TrafficManagementModule::s_testNowMs += 1000; // same tick; sweep just clears isMember + module.runOnce(); + TrafficManagementModule::s_testNowMs += 8UL * 24UL * 60UL * 60UL * 1000UL; + module.runOnce(); + TEST_ASSERT_FALSE(module.copyPublicKey(kTargetNode, key, nullptr)); +} #endif // WARM_NODE_COUNT > 0 /** @@ -2296,6 +2444,9 @@ TM_TEST_ENTRY void setup() RUN_TEST(test_tm_nodeinfo_cache_rejectsMismatchedKey); #if WARM_NODE_COUNT > 0 RUN_TEST(test_tm_nodeinfo_cache_pinsAgainstWarmTierKey); + RUN_TEST(test_tm_nodeinfo_reconcile_seedsFromHotStoreButNeverServes); + RUN_TEST(test_tm_nodeinfo_reconcile_seedsKeyOnlyFromWarmTier); + RUN_TEST(test_tm_nodeinfo_membership_keepAliveOutlivesRetention); #endif RUN_TEST(test_tm_nodeinfo_copyPublicKey_servesTofuKey); RUN_TEST(test_tm_nodeinfo_copyPublicKey_upgradesToSignerProven); From cccc1f4281fb4f895a6aee46fa9e707a7742c9f8 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 17 Jul 2026 12:41:15 +0000 Subject: [PATCH 22/44] Write-through NodeDB identity commits into the NodeInfo cache updateUser() is the single chokepoint through which every remote identity/key write enters NodeDB (key-verification keys stay in CryptoEngine's pending buffer until a NodeInfo carries them here), so one hook at its tail lets the NodeInfo cache reflect a commit immediately instead of waiting up to a minute for the reconcile sweep - which stays in place as the anti-entropy backstop. onNodeIdentityCommitted() upserts the full User: NodeDB's key is authoritative (a conflicting cached key is stale residue - replaced, provenance dropped), a keyless commit keeps an already-cached TOFU key, and signer provenance transfers only for the committed key. The observation stamp is never touched: knowledge is not observation, so a hook write can never make a never-heard node servable - covered by the new test alongside immediate copyUser/copyPublicKey visibility. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01KWHDDxY4U2XLYW2WJJ2oLT (cherry picked from commit e160b899728be8d951d4e7bdf5106c777832b917) --- src/mesh/NodeDB.cpp | 10 +++++ src/modules/TrafficManagementModule.cpp | 41 +++++++++++++++++ src/modules/TrafficManagementModule.h | 12 +++++ test/test_traffic_management/test_main.cpp | 51 ++++++++++++++++++++++ 4 files changed, 114 insertions(+) diff --git a/src/mesh/NodeDB.cpp b/src/mesh/NodeDB.cpp index 7c62752e1f8..0f170e4cae7 100644 --- a/src/mesh/NodeDB.cpp +++ b/src/mesh/NodeDB.cpp @@ -3393,6 +3393,16 @@ bool NodeDB::updateUser(uint32_t nodeId, meshtastic_User &p, uint8_t channelInde } } +#if HAS_TRAFFIC_MANAGEMENT + // Write-through to the TrafficManagement NodeInfo cache, so it reflects this commit + // immediately rather than at the next reconcile sweep. updateUser is the single + // chokepoint through which every remote identity/key write flows, which is what makes + // this one call sufficient. `p` is the post-hygiene payload (id normalized, key pin + // already enforced above); the signer bit transfers the node's verified-signer status. + if (trafficManagementModule) + trafficManagementModule->onNodeIdentityCommitted(nodeId, p, nodeInfoLiteHasXeddsaSigned(info)); +#endif + return changed; } diff --git a/src/modules/TrafficManagementModule.cpp b/src/modules/TrafficManagementModule.cpp index 8972784993b..53e47d1ad03 100644 --- a/src/modules/TrafficManagementModule.cpp +++ b/src/modules/TrafficManagementModule.cpp @@ -701,6 +701,47 @@ void TrafficManagementModule::reconcileNodeInfoMembershipLocked() #endif } +void TrafficManagementModule::onNodeIdentityCommitted(NodeNum node, const meshtastic_User &user, bool signerKnown) +{ +#if TMM_HAS_NODEINFO_CACHE + if (node == 0 || (nodeDB && node == nodeDB->getNodeNum())) + return; + concurrency::LockGuard guard(&cacheLock); + if (!nodeInfoPayload) + return; + bool usedEmptySlot = false; + NodeInfoPayloadEntry *e = findOrCreateNodeInfoEntry(node, &usedEmptySlot, /*spareMembers=*/true); + if (!e) + return; // member-saturated cache: the reconcile sweep owns the tradeoff + + // Merge: NodeDB's commit is authoritative for everything it carries, but a keyless + // commit (possible while the node is unpinned in NodeDB) must not cost this cache a + // TOFU key it already learned. + meshtastic_User merged = user; + if (merged.public_key.size != 32 && !usedEmptySlot && e->user.public_key.size == 32) + merged.public_key = e->user.public_key; + + // Provenance survives only alongside an unchanged key; a replaced key (stale residue + // predating the authoritative pin) starts from scratch and may be re-proven by + // signerKnown below - which vouches for the COMMITTED key, never the kept TOFU one. + const bool sameKey = !usedEmptySlot && e->user.public_key.size == 32 && merged.public_key.size == 32 && + memcmp(e->user.public_key.bytes, merged.public_key.bytes, 32) == 0; + const bool provenBefore = !usedEmptySlot && e->keySignerProven && sameKey; + + e->user = merged; + snprintf(e->user.id, sizeof(e->user.id), "!%08x", node); + e->hasFullUser = true; + e->keySignerProven = provenBefore || (signerKnown && user.public_key.size == 32); + e->isMember = true; // committed via updateUser => it sits in the hot store right now + e->retTick = nodeInfoRetTickNow(); + // obsTick/hasObserved deliberately untouched: only a heard frame makes a node servable. +#else + (void)node; + (void)user; + (void)signerKnown; +#endif +} + bool TrafficManagementModule::copyPublicKey(NodeNum node, uint8_t out[32], bool *signerProven) const { #if TMM_HAS_NODEINFO_CACHE diff --git a/src/modules/TrafficManagementModule.h b/src/modules/TrafficManagementModule.h index 549fe479b05..c842f655626 100644 --- a/src/modules/TrafficManagementModule.h +++ b/src/modules/TrafficManagementModule.h @@ -107,6 +107,18 @@ class TrafficManagementModule : public MeshModule, private concurrency::OSThread // `signerProven` is non-null, reports the cached key's provenance. Thread-safe (takes cacheLock). bool copyUser(NodeNum node, meshtastic_User &out, bool *signerProven = nullptr) const; + // Write-through hook from NodeDB: called after updateUser() commits a remote node's + // identity, so this cache reflects NodeDB immediately instead of waiting for the next + // reconcile sweep (which remains the anti-entropy backstop). Upserts the full User; + // NodeDB's key is authoritative (updateUser's own pin already rejected conflicts + // upstream, so a differing cached key is stale residue and is replaced, dropping its + // provenance), while a keyless commit keeps a key this cache already holds. When + // signerKnown (the node's verified-signer bit) and the commit carries a key, the key + // is marked signer-proven. Never touches the observation stamp: knowledge is not + // observation, so a hook write can never make a node servable by the replay path. + // No-op without the cache. Thread-safe (takes cacheLock). + void onNodeIdentityCommitted(NodeNum node, const meshtastic_User &user, bool signerKnown); + /** * Check if this packet should have its hops exhausted. * Called from perhapsRebroadcast() to force hop_limit = 0 regardless of diff --git a/test/test_traffic_management/test_main.cpp b/test/test_traffic_management/test_main.cpp index 96c7879df1c..6ec5f42d038 100644 --- a/test/test_traffic_management/test_main.cpp +++ b/test/test_traffic_management/test_main.cpp @@ -1088,6 +1088,56 @@ static void test_tm_nodeinfo_reconcile_seedsKeyOnlyFromWarmTier(void) mockNodeDB->warmStore.clear(); } +/** + * Write-through hook: an identity committed through NodeDB::updateUser() lands in the + * NodeInfo cache immediately (name + key), without waiting for a maintenance sweep - and + * is still not servable, because the node was never actually heard. + */ +static void test_tm_nodeinfo_updateUserHook_writesThrough(void) +{ + moduleConfig.traffic_management.nodeinfo_direct_response_max_hops = 10; + config.device.role = meshtastic_Config_DeviceConfig_Role_CLIENT; + mockNodeDB->clearCachedNode(); + mockNodeDB->rollHotStore(); + mockNodeDB->warmStore.clear(); + + MockRouter mockRouter; + mockRouter.addInterface(std::unique_ptr(new MockRadioInterface())); + MeshService mockService; + router = &mockRouter; + service = &mockService; + + TrafficManagementModuleTestShim module; + trafficManagementModule = &module; // the NodeDB hook reaches the module via the global + + meshtastic_User user = meshtastic_User_init_zero; + strncpy(user.long_name, "committed", sizeof(user.long_name) - 1); + user.public_key.size = 32; + memset(user.public_key.bytes, 0x5A, 32); + mockNodeDB->updateUser(kTargetNode, user, 0); + + // Immediately visible to the key pool and name rehydration (no sweep has run)... + uint8_t key[32] = {0}; + bool proven = true; + TEST_ASSERT_TRUE(module.copyPublicKey(kTargetNode, key, &proven)); + TEST_ASSERT_EQUAL_UINT8(0x5A, key[0]); + TEST_ASSERT_FALSE(proven); // committed TOFU key: no signer bit on the node yet + meshtastic_User out = meshtastic_User_init_zero; + TEST_ASSERT_TRUE(module.copyUser(kTargetNode, out, nullptr)); + TEST_ASSERT_EQUAL_STRING("committed", out.long_name); + + // ...but known-not-heard is never servable. + meshtastic_MeshPacket request = makeDecodedPacket(meshtastic_PortNum_NODEINFO_APP, kRemoteNode, kTargetNode); + request.decoded.want_response = true; + request.hop_start = 3; + request.hop_limit = 3; + TEST_ASSERT_EQUAL_INT(static_cast(ProcessMessage::CONTINUE), static_cast(module.handleReceived(request))); + TEST_ASSERT_EQUAL_UINT32(0, static_cast(mockRouter.sentPackets.size())); + + trafficManagementModule = nullptr; + mockNodeDB->rollHotStore(); // updateUser admitted the node to the hot store +} + /** * Membership keep-alive vs retention TTL: an entry whose node still lives in a NodeDB tier * is re-stamped by every sweep and outlives the 7-day retention window; a non-member entry @@ -2446,6 +2496,7 @@ TM_TEST_ENTRY void setup() RUN_TEST(test_tm_nodeinfo_cache_pinsAgainstWarmTierKey); RUN_TEST(test_tm_nodeinfo_reconcile_seedsFromHotStoreButNeverServes); RUN_TEST(test_tm_nodeinfo_reconcile_seedsKeyOnlyFromWarmTier); + RUN_TEST(test_tm_nodeinfo_updateUserHook_writesThrough); RUN_TEST(test_tm_nodeinfo_membership_keepAliveOutlivesRetention); #endif RUN_TEST(test_tm_nodeinfo_copyPublicKey_servesTofuKey); From c67a8646854aca53f9ac854f556c016670eb6721 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 17 Jul 2026 12:44:35 +0000 Subject: [PATCH 23/44] Purge TrafficManagement caches on explicit node removal NodeDB::removeNodeByNum() already forgets the node everywhere NodeDB owns - hot store, satellite stores, warm tier - but the TrafficManagement caches kept the deleted identity: the NodeInfo entry went on feeding the key pool (NodeDB::copyPublicKey) and name rehydration, and the unified slot kept its role/next-hop/dedup state, resurrecting the node on next contact. Add purgeNode(): clears both the unified cache slot and the NodeInfo cache entry, called from removeNodeByNum() alongside the warm-tier removal. Removal means full removal; passive eviction never calls this, and the reconcile sweep will not re-seed a node absent from both NodeDB tiers. Test: an observed identity plus a next-hop hint both vanish after removeNodeByNum(). Left for CI to execute per request. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01KWHDDxY4U2XLYW2WJJ2oLT (cherry picked from commit b5564c173337de99e15ef998a5c698550238fd3c) --- src/mesh/NodeDB.cpp | 6 ++++ src/modules/TrafficManagementModule.cpp | 27 +++++++++++++++ src/modules/TrafficManagementModule.h | 8 +++++ test/test_traffic_management/test_main.cpp | 40 ++++++++++++++++++++++ 4 files changed, 81 insertions(+) diff --git a/src/mesh/NodeDB.cpp b/src/mesh/NodeDB.cpp index 0f170e4cae7..42d28ef41b2 100644 --- a/src/mesh/NodeDB.cpp +++ b/src/mesh/NodeDB.cpp @@ -1632,6 +1632,12 @@ void NodeDB::removeNodeByNum(NodeNum nodeNum) // Explicit user removal: don't let the warm tier resurrect the node warmStore.remove(nodeNum); #endif +#if HAS_TRAFFIC_MANAGEMENT + // Explicit removal is full removal: the TrafficManagement caches (unified slot + + // NodeInfo identity cache) must not keep serving or resurrect the node either. + if (trafficManagementModule) + trafficManagementModule->purgeNode(nodeNum); +#endif LOG_DEBUG("NodeDB::removeNodeByNum purged %d entries. Save changes", removed); saveNodeDatabaseToDisk(); diff --git a/src/modules/TrafficManagementModule.cpp b/src/modules/TrafficManagementModule.cpp index 53e47d1ad03..51da3b1e76a 100644 --- a/src/modules/TrafficManagementModule.cpp +++ b/src/modules/TrafficManagementModule.cpp @@ -349,6 +349,33 @@ int TrafficManagementModule::peekCachedRole(NodeNum node) #endif } +void TrafficManagementModule::purgeNode(NodeNum node) +{ + if (node == 0) + return; + concurrency::LockGuard guard(&cacheLock); +#if TRAFFIC_MANAGEMENT_CACHE_SIZE > 0 + if (cache) { + for (uint16_t i = 0; i < cacheSize(); i++) { + if (cache[i].node == node) { + memset(&cache[i], 0, sizeof(UnifiedCacheEntry)); + break; // a node occupies at most one unified slot + } + } + } +#endif +#if TMM_HAS_NODEINFO_CACHE + if (nodeInfoPayload) { + for (uint16_t i = 0; i < nodeInfoTargetEntries(); i++) { + if (nodeInfoPayload[i].node == node) { + memset(&nodeInfoPayload[i], 0, sizeof(NodeInfoPayloadEntry)); + break; // likewise unique in the NodeInfo cache + } + } + } +#endif +} + void TrafficManagementModule::dropNodeInfoCacheForTest() { #if TMM_HAS_NODEINFO_CACHE diff --git a/src/modules/TrafficManagementModule.h b/src/modules/TrafficManagementModule.h index c842f655626..605696f81fd 100644 --- a/src/modules/TrafficManagementModule.h +++ b/src/modules/TrafficManagementModule.h @@ -119,6 +119,14 @@ class TrafficManagementModule : public MeshModule, private concurrency::OSThread // No-op without the cache. Thread-safe (takes cacheLock). void onNodeIdentityCommitted(NodeNum node, const meshtastic_User &user, bool signerKnown); + // Full-removal hook from NodeDB::removeNodeByNum(): an explicit removal must forget the + // node here too - the unified cache slot (role, next-hop hint, dedup/rate state) and the + // NodeInfo cache entry (name/key/provenance) - or these caches would keep serving the + // deleted identity (key pool, rehydration) and resurrect it on next contact. Passive + // NodeDB eviction never calls this; the reconcile sweep will not re-seed a node that is + // gone from both NodeDB tiers. Thread-safe (takes cacheLock). + void purgeNode(NodeNum node); + /** * Check if this packet should have its hops exhausted. * Called from perhapsRebroadcast() to force hop_limit = 0 regardless of diff --git a/test/test_traffic_management/test_main.cpp b/test/test_traffic_management/test_main.cpp index 6ec5f42d038..a4c62c077b1 100644 --- a/test/test_traffic_management/test_main.cpp +++ b/test/test_traffic_management/test_main.cpp @@ -1138,6 +1138,45 @@ static void test_tm_nodeinfo_updateUserHook_writesThrough(void) mockNodeDB->rollHotStore(); // updateUser admitted the node to the hot store } +/** + * Full removal: NodeDB::removeNodeByNum() must purge this module's caches too - both the + * NodeInfo identity entry (name/key) and the unified slot (next-hop hint etc.) - or the + * deleted identity would keep feeding the key pool and resurrect on next contact. + */ +static void test_tm_nodeinfo_removeNode_purgesCaches(void) +{ + moduleConfig.traffic_management.nodeinfo_direct_response_max_hops = 10; + config.device.role = meshtastic_Config_DeviceConfig_Role_CLIENT; + mockNodeDB->clearCachedNode(); + mockNodeDB->rollHotStore(); + mockNodeDB->warmStore.clear(); + + MockRouter mockRouter; + mockRouter.addInterface(std::unique_ptr(new MockRadioInterface())); + MeshService mockService; + router = &mockRouter; + service = &mockService; + + TrafficManagementModuleTestShim module; + trafficManagementModule = &module; // the NodeDB purge hook reaches the module via the global + + // Learn an identity (observed frame) and a routing hint for the node. + module.handleReceived(makeNodeInfoPacketWithKey(kTargetNode, "victim", 0x37)); + module.setNextHop(kTargetNode, 0x42); + uint8_t key[32] = {0}; + TEST_ASSERT_TRUE(module.copyPublicKey(kTargetNode, key, nullptr)); + TEST_ASSERT_EQUAL_UINT8(0x42, module.getNextHopHint(kTargetNode)); + + mockNodeDB->removeNodeByNum(kTargetNode); + + TEST_ASSERT_FALSE(module.copyPublicKey(kTargetNode, key, nullptr)); + meshtastic_User out = meshtastic_User_init_zero; + TEST_ASSERT_FALSE(module.copyUser(kTargetNode, out, nullptr)); + TEST_ASSERT_EQUAL_UINT8(0, module.getNextHopHint(kTargetNode)); + + trafficManagementModule = nullptr; +} + /** * Membership keep-alive vs retention TTL: an entry whose node still lives in a NodeDB tier * is re-stamped by every sweep and outlives the 7-day retention window; a non-member entry @@ -2497,6 +2536,7 @@ TM_TEST_ENTRY void setup() RUN_TEST(test_tm_nodeinfo_reconcile_seedsFromHotStoreButNeverServes); RUN_TEST(test_tm_nodeinfo_reconcile_seedsKeyOnlyFromWarmTier); RUN_TEST(test_tm_nodeinfo_updateUserHook_writesThrough); + RUN_TEST(test_tm_nodeinfo_removeNode_purgesCaches); RUN_TEST(test_tm_nodeinfo_membership_keepAliveOutlivesRetention); #endif RUN_TEST(test_tm_nodeinfo_copyPublicKey_servesTofuKey); From 471b9fb1aed86206a716028c20ba1eb46c27e5f4 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 17 Jul 2026 13:04:59 +0000 Subject: [PATCH 24/44] Document the tmm-fix-2 / tmm-fix-superset reconciliation decisions Two parallel implementations of the same superset design are being combined on this branch. This decision matrix records every divergence and which side each reconciled feature takes, ahead of the code commits that apply them. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01KWHDDxY4U2XLYW2WJJ2oLT --- .notes/tmm-super-superset-reconciliation.md | 39 +++++++++++++++++++++ 1 file changed, 39 insertions(+) create mode 100644 .notes/tmm-super-superset-reconciliation.md diff --git a/.notes/tmm-super-superset-reconciliation.md b/.notes/tmm-super-superset-reconciliation.md new file mode 100644 index 00000000000..716842596f7 --- /dev/null +++ b/.notes/tmm-super-superset-reconciliation.md @@ -0,0 +1,39 @@ +# tmm-super-superset: reconciliation of tmm-fix-2 and tmm-fix-superset + +Two branches implemented the same design brief independently on top of the shared +`tmm-fix` series: keep the TrafficManagement NodeInfo cache a superset of NodeDB +(hot store ∪ warm tier), with tick-based clocks, write-through hooks, and +purge-on-removal. This branch combines them, taking each divergence on its merits. +Base: `origin/develop` (8ebdae9) + the rebased `tmm-fix` series, plus cherry-picks +of upstream #11035 (dependency), PR #11048 (2 commits), PR #11046, PR #11047 (3). + +## Decision matrix + +| # | Divergence | tmm-fix-2 | tmm-fix-superset | Taken | Why | +|---|-----------|-----------|------------------|-------|-----| +| 1 | Native test coverage | `TMM_HAS_NODEINFO_CACHE` macro: cache builds on heap under ARCH_PORTDUINO+PIO_UNIT_TESTING, so all cache tests run in CI; `dropNodeInfoCacheForTest()` for fallback-path tests | none - cache tests compile only for ESP32+PSRAM (hardware-only) | **tmm-fix-2** | CI coverage of the security-relevant paths was a review finding; hardware-only tests rot. | +| 2 | Warm-tier key pin | `NodeDB::copyPublicKeyAuthoritative()` (hot→warm), reused by `copyPublicKey()`; TMM pins against it | inline `nodeDB->warmStore.copyKey()` second check inside TMM | **tmm-fix-2** | Encapsulation: one authoritative-tier lookup owned by NodeDB; TMM doesn't reach into NodeDB internals; `copyPublicKey` reuses it so the tier order can never desync. | +| 3 | Retention | third tick clock (`retTick`, 1 h) + timed eviction: 7 d keyed / 6 h keyless for non-members; members re-stamped (keep-alive) | **no timed eviction at all**: slots reclaimed only by tiered LRU on insert or explicit purge; LRU age = `obsTick` with never-observed scored 0xFF (oldest) | **tmm-fix-superset** | Matches the design owner's explicit dislike of the 7-day eviction. Wrap-safety needs only obs/resp saturation (retention was the only reason `retTick` existed). A quiet entry keeps value (key pool, name rehydration). Drops a byte per entry. | +| 4 | Membership + seeding cadence | full two-direction reconcile every 60 s sweep (bitmap-marked clear pass), `spareMembers` eviction guard | per-entry membership refresh every sweep (entry→NodeDB `contains` checks); hot-store-only seeding, boot + hourly | **mixed** | Superset's cadence (cheap member-bit refresh each sweep; heavy seeding hourly + boot, write-through hooks give immediacy) is better balanced. But its seeding covers the hot store only - this branch keeps tmm-fix-2's warm-tier key-only seeding leg (via `WarmNodeStore::entryAt`) so the invariant is genuinely TMM ⊇ hot ∪ warm, and keeps the `spareMembers` guard because with warm seeding the member population can exceed the cache. | +| 5 | updateUser write-through | `signerKnown = nodeInfoLiteHasXeddsaSigned(info)`; provenance-carry via explicit `provenBefore` | `signerKnown = isVerifiedSignerForKey(nodeId, p.key)` (key-matched); simpler keyChanged/reset logic; self-skip at call site | **tmm-fix-superset** | Key-matched signer transfer is the stricter, self-documenting form; the simpler provenance logic is behaviorally equivalent (keyless commits leave the flag untouched, key changes reset it). Self-skip kept in both places (cheap, defensive). | +| 6 | Key-only commit hook | absent | `onNodeKeyCommitted(node, key32, proven)` + call sites in Router (admin-key learn) and KeyVerificationModule (manual verification, proven=true) | **tmm-fix-superset** | Genuine coverage gap in tmm-fix-2: both sites write `node->public_key` directly, bypassing `updateUser`. Manual verification is the strongest provenance the cache can carry. | +| 7 | Reset purging | `purgeNode()` from `removeNodeByNum()` only | also `purgeAll()` from `factoryReset()` and `resetNodes()` | **tmm-fix-superset** | Removal-is-full-removal applies to bulk resets too; don't rely on the post-reset reboot. | +| 8 | purgeNode implementation | open-coded scans of both arrays | reuses `findEntry`/`findNodeInfoEntryMutable`, logs the purge | **tmm-fix-superset** | Less duplication; the log line is genuinely useful for a user-initiated action. | +| 9 | Tick constants home | file-scope constexprs + free functions in the .cpp | header, next to the existing UnifiedCache tick helpers (`currentObsTick()` style) | **tmm-fix-superset** | Consistency with the established tick idiom in the same class. | +| 10 | `lastObservedRxTime` | dropped (was only echoed in one debug log) | kept for the debug log | **tmm-fix-2** | 4 B × 2000 = 8 KB of PSRAM for a log field; the tick-age log line carries the same signal. | +| 11 | Test hooks | `dropNodeInfoCacheForTest()` | `peekNodeInfoFlagsForTest()` (flag introspection) | **both** | Orthogonal: one enables fallback-path tests in native builds, the other lets saturation/membership tests assert sweep effects directly. | +| 12 | Fallback/serve/throttle gates | identical semantics | identical semantics (cosmetic differences) | tmm-fix-2 text | Same behavior; kept the wording already on this branch, with superset's better "stale vs never observed" log distinction folded in. | + +## Entry layout after reconciliation + +`node(4) + meshtastic_User(116) + obsTick + respTick + sourceChannel + +decodedBitfield + flags(1)` = 125 → 128 B padded (2000 entries = 256 KB PSRAM, +8 KB below the original develop footprint). Flag byte: hasDecodedBitfield, +keySignerProven, hasFullUser, hasObserved, hasResponded, isMember (2 spare bits). + +## Tests + +Union of both suites: tmm-fix-2's warm-pin, hot/warm seeding, updateUser hook, +and removal-purge tests (adapted: the TTL-based retention test is superseded by +no-TTL semantics) + tmm-fix-superset's key-hook provenance, tick-saturation, and +sweep-membership tests (adapted to run natively under TMM_HAS_NODEINFO_CACHE). From cef9d640d4d14f8ed925a786a298c88c43c17574 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 17 Jul 2026 13:09:12 +0000 Subject: [PATCH 25/44] Reconcile retention: no timed eviction, obsTick LRU, hourly seeding Adopt tmm-fix-superset's retention model (reconciliation decisions #3, #4, #9 in .notes/tmm-super-superset-reconciliation.md): - Entries are never evicted on a timer. The 7-day retention TTL and its third tick clock (retTick) are gone; wrap-safety rests entirely on the sweep's presence-bit saturation, and a quiet entry keeps its value (pubkey pool, name rehydration). Slots are reclaimed only by trust/membership-tiered LRU on insert - age scored by obsTick, with never-observed entries counting oldest - or by an explicit purge. - Membership refresh (entry -> NodeDB contains checks) runs every sweep; the heavy seeding pass runs at boot and then hourly, with the write-through hooks carrying immediacy in between. - Tick constants and helpers move into the header beside the existing UnifiedCache tick idiom, with static_asserts tying the tick windows to the fallback path's seconds/ms forms. Kept from tmm-fix-2 (decision #4): the warm tier is still seeded (key-only records via WarmNodeStore::entryAt) so the invariant remains cache superset-of hot AND warm, and the spareMembers guard still stops seeding from churning one member out for another when hot+warm exceeds the cache. Entry shrinks to 128 B (2000 entries = 256 kB). The TTL-based retention test is superseded by a no-timed-eviction test: a quiet keyed entry survives 9 days of sweeps while the serve gate saturates as before. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01KWHDDxY4U2XLYW2WJJ2oLT --- src/modules/TrafficManagementModule.cpp | 240 +++++++++------------ src/modules/TrafficManagementModule.h | 83 ++++--- test/test_traffic_management/test_main.cpp | 49 ++--- 3 files changed, 175 insertions(+), 197 deletions(-) diff --git a/src/modules/TrafficManagementModule.cpp b/src/modules/TrafficManagementModule.cpp index 51da3b1e76a..34f18eed19d 100644 --- a/src/modules/TrafficManagementModule.cpp +++ b/src/modules/TrafficManagementModule.cpp @@ -41,65 +41,31 @@ constexpr uint32_t kClientDefaultMaxHops = 0; // Clients: direct only (cannot in // NodeInfo direct-response safety limits. // -// The per-entry clocks (observation, response throttle, retention) use the same -// free-running modular tick scheme as the UnifiedCache counters (see "Free-Running Tick -// Counters" in the header): a uint8_t holds clockMs()/tickMs, age is modular uint8 -// subtraction, and validity is an explicit flag bit (hasObserved / hasResponded) - no 0 -// sentinel. The maintenance sweep clears a stamp's flag once its window has passed -// (saturation) and re-stamps retained entries, so no stamp ever survives toward its -// ~256-tick aliasing horizon; every period below dwarfs the 60 s sweep interval. This -// replaces the old uint32 millis stamps and their wrap/sentinel special cases (T4, T9). -constexpr uint32_t kNodeInfoObsTickMs = 3UL * 60UL * 1000UL; // observation clock: 3 min/tick, period 12.8 h -constexpr uint32_t kNodeInfoRespTickMs = 5UL * 1000UL; // response-throttle clock: 5 s/tick, period 21.3 min -constexpr uint32_t kNodeInfoRetTickMs = 60UL * 60UL * 1000UL; // retention clock: 1 h/tick, period 10.67 d - +// The per-entry clocks live in the header (kNodeInfoObsTickMs / kNodeInfoRespTickMs and the +// currentObsTick()/currentRespTick() helpers) beside the UnifiedCache tick idiom they share. +// // Staleness: never spoof a NodeInfo reply on behalf of a node we have not actually // heard from within this window. Without it, a cached (or forged) entry is served // indefinitely for a long-gone node while the genuine request is suppressed - the // requestor sees a fresh-looking answer for a node that may no longer exist. -// One source of truth for the 6 h window; the seconds form (NodeDB fallback path) and -// the tick form (cache path) are both derived from it so the paths cannot desync. +// The cache path enforces this in ticks (kNodeInfoMaxServeAgeTicks x kNodeInfoObsTickMs +// = 6 h, see the header); the NodeDB fallback path uses this seconds form of the same window. constexpr uint32_t kNodeInfoMaxServeAgeSecs = 6UL * 60UL * 60UL; // 6 h (NodeDB fallback path) -constexpr uint8_t kNodeInfoMaxServeAgeObsTicks = - static_cast(kNodeInfoMaxServeAgeSecs * 1000UL / kNodeInfoObsTickMs); // 120 ticks (cache path) -static_assert(kNodeInfoMaxServeAgeSecs * 1000UL / kNodeInfoObsTickMs < 200, - "serve window must sit well inside the 256-tick observation period"); - -// Key-retention window: how long a NodeInfo cache entry that carries a 32-byte public key -// is kept after it was last retained (heard, or membership-refreshed by the sweep). This is -// deliberately much longer than the serve window: once a node ages past the serve window we -// stop spoofing NodeInfo replies for it, but its key remains valuable as a last-resort -// encryption source (NodeDB::copyPublicKey), so the entry is retained to widen the pool of -// peers we can encrypt to. Keyless entries do not get this grace - they expire at the serve -// window since they hold nothing worth retaining. -constexpr uint8_t kNodeInfoKeyRetentionRetTicks = 7 * 24; // 7 d in 1-h ticks -constexpr uint8_t kNodeInfoKeylessRetentionRetTicks = kNodeInfoMaxServeAgeSecs / 3600; // 6 h in 1-h ticks -static_assert(kNodeInfoKeyRetentionRetTicks < 200, "retention must sit well inside the 256-tick period"); + +// Entries are never evicted on a timer. Wrap-correctness of the tick stamps is guaranteed by +// the sweep's presence-bit saturation (see runOnce), not by freeing slots, and a slot that +// has gone quiet still holds value: its key remains a last-resort encryption source +// (NodeDB::copyPublicKey) and its User payload rehydrates a re-admitted node's name. Slots +// are reclaimed only by trust/membership-tiered LRU on insert, or by an explicit purge when +// the user deliberately removes the node. // Throttle: emit at most one spoofed direct reply per target node per this interval. // Direct responses are otherwise un-throttled (they STOP the request before the // per-sender rate limiter runs) and the reply target is attacker-controlled, so an // attacker could otherwise drive unbounded local transmissions / reflected floods. -// The ms form throttles the NodeDB fallback path (module-global uint32 stamp); the tick -// form throttles the cache path (per-entry respTick). +// This ms form throttles the NodeDB fallback path (module-global uint32 stamp); the cache +// path throttles per entry in ticks (kNodeInfoThrottleTicks, header). constexpr uint32_t kNodeInfoResponseThrottleMs = 30UL * 1000UL; // 30 s -constexpr uint8_t kNodeInfoResponseThrottleRespTicks = - static_cast(kNodeInfoResponseThrottleMs / kNodeInfoRespTickMs); // 6 ticks - -// Current value of each NodeInfo tick clock (free-running, from clockMs() so the unit-test -// clock hook applies). -static inline uint8_t nodeInfoObsTickNow() -{ - return static_cast(TrafficManagementModule::clockMs() / kNodeInfoObsTickMs); -} -static inline uint8_t nodeInfoRespTickNow() -{ - return static_cast(TrafficManagementModule::clockMs() / kNodeInfoRespTickMs); -} -static inline uint8_t nodeInfoRetTickNow() -{ - return static_cast(TrafficManagementModule::clockMs() / kNodeInfoRetTickMs); -} /** * Convert seconds to milliseconds with overflow protection. @@ -556,9 +522,11 @@ const TrafficManagementModule::NodeInfoPayloadEntry *TrafficManagementModule::fi * Find or create a NodeInfo payload entry (linear scan of the flat PSRAM * array). One pass tracks the match, the first empty slot, and the eviction * victim. Victim selection is trust-tiered so the cache doubles as a pubkey - * pool (NodeDB::copyPublicKey): a keyless entry is sacrificed before any - * keyed one, and a trust-on-first-use key before a signer-proven key; within - * a tier the oldest (modular retention age by retTick) loses. Mirrors + * pool (NodeDB::copyPublicKey): membership outranks key trust (a node still + * present in NodeDB hot/warm loses last - evicting it just to reseed it via + * reconciliation would churn), then keyless < TOFU key < signer-proven key; + * within a tier the oldest observation loses (modular obsTick age; a + * never-observed entry counts as oldest of all). Mirrors * WarmNodeStore's keyed-first admission. NodeInfo traffic is low-rate, so the * O(n) scan is negligible. */ @@ -577,7 +545,7 @@ TrafficManagementModule::NodeInfoPayloadEntry *TrafficManagementModule::findOrCr NodeInfoPayloadEntry *lru = nullptr; uint8_t lruTier = 0xFF; uint8_t lruAge = 0; - const uint8_t now = nodeInfoRetTickNow(); + const uint8_t nowObs = currentObsTick(); for (uint16_t i = 0; i < nodeInfoTargetEntries(); i++) { NodeInfoPayloadEntry &e = nodeInfoPayload[i]; @@ -595,9 +563,9 @@ TrafficManagementModule::NodeInfoPayloadEntry *TrafficManagementModule::findOrCr // identity while it still holds opportunistic strangers. const uint8_t tier = static_cast(((e.user.public_key.size != 32) ? 0 : (e.keySignerProven ? 2 : 1)) + (e.isMember ? 3 : 0)); - // Modular retention age: safe because the sweep evicts/re-stamps entries long - // before retTick could age past the 256-tick period. - const uint8_t age = static_cast(now - e.retTick); + // Modular observation age; saturation keeps real ages far below 0xFF, so a + // never-observed entry scored at 0xFF is always the oldest in its tier. + const uint8_t age = e.hasObserved ? static_cast(nowObs - e.obsTick) : 0xFF; if (!lru || tier < lruTier || (tier == lruTier && age > lruAge)) { lru = &e; lruTier = tier; @@ -638,68 +606,53 @@ uint16_t TrafficManagementModule::countNodeInfoEntriesLocked() const #endif } -void TrafficManagementModule::reconcileNodeInfoMembershipLocked() +void TrafficManagementModule::reconcileNodeInfoFromNodeDBLocked() { #if TMM_HAS_NODEINFO_CACHE if (!nodeInfoPayload || !nodeDB) return; - const uint8_t retNow = nodeInfoRetTickNow(); const NodeNum self = nodeDB->getNodeNum(); + uint16_t seeded = 0; - // Entries touched by this pass, so membership can be cleared exactly on the ones that - // were not (a departed node's entry then ages out on the normal retention TTL from its - // final member re-stamp). A retTick comparison can't do this - an entry observed within - // the current 1-h tick would be indistinguishable from one re-stamped by this pass. - // Static: 250 B of .bss beats a stack excursion under the cache lock. - static uint8_t marked[(kNodeInfoCacheEntries + 7) / 8]; - memset(marked, 0, sizeof(marked)); - - // Upsert one NodeDB-tier node. Cost note: a miss in findOrCreateNodeInfoEntry scans the - // whole array, so this pass is O(members x entries) once per minute - tens of ms on a - // saturated ESP32, on the module thread, and accepted: the alternative is an index the - // entry format doesn't have room for. + // Upsert one NodeDB-tier node. `hot` non-null contributes the full identity; a warm + // record is key-only. Cost note: a miss in findOrCreateNodeInfoEntry scans the whole + // array, so this pass is O(members x entries) - which is why it runs hourly (plus one + // boot seed), not on every sweep; the write-through hooks carry the interim. auto reconcileOne = [&](NodeNum n, const uint8_t *key32, bool signerKnown, const meshtastic_NodeInfoLite *hot) { if (n == 0 || n == self) return; - bool usedEmptySlot = false; - NodeInfoPayloadEntry *e = findOrCreateNodeInfoEntry(n, &usedEmptySlot, /*spareMembers=*/true); - if (!e) - return; // cache is member-saturated; skip rather than churn a member out - - // Key conflict against the authoritative NodeDB key: NodeDB wins. Wipe and reseed - // rather than patch, so no name or provenance pinned to the old key survives onto - // the new one. (Possible for residue predating the warm pin, or a NodeDB re-key.) - if (!usedEmptySlot && key32 && e->user.public_key.size == 32 && - memcmp(e->user.public_key.bytes, key32, 32) != 0) { - memset(e, 0, sizeof(*e)); - e->node = n; - usedEmptySlot = true; + if (!hot && !key32) + return; // a warm record without a key has nothing worth seeding + + NodeInfoPayloadEntry *e = findNodeInfoEntryMutable(n); + if (!e) { + bool usedEmptySlot = false; + e = findOrCreateNodeInfoEntry(n, &usedEmptySlot, /*spareMembers=*/true); + if (!e) + return; // cache is member-saturated; skip rather than churn a member out + seeded++; } - if (usedEmptySlot) { - // Fresh slot: seed what the tier knows. A hot node with a User contributes the - // full identity; a warm node yields a key-only record (the warm tier keeps no - // names). Never touches hasObserved - seeding must not make a node servable. - if (hot && nodeInfoLiteHasUser(hot)) { - e->user = TypeConversions::ConvertToUser(hot); - e->hasFullUser = true; - } + // NodeDB is the authority on identity content: adopt its User payload and key. A key + // change relative to a stale TMM TOFU pin resets provenance; the signer verdict then + // transfers only via the key-matched rule below. hasObserved/obsTick are deliberately + // untouched - seeding is knowledge, not an observation, and the replay serve-gate + // must stay keyed to genuinely heard frames. + const bool keyChanged = e->user.public_key.size == 32 && key32 && memcmp(e->user.public_key.bytes, key32, 32) != 0; + if (hot && nodeInfoLiteHasUser(hot)) { + e->user = TypeConversions::ConvertToUser(hot); + e->hasFullUser = true; snprintf(e->user.id, sizeof(e->user.id), "!%08x", n); - } - // Backfill a missing key (fresh seed, or an entry cached from a keyless frame). - if (key32 && e->user.public_key.size != 32) { + } else if (key32) { memcpy(e->user.public_key.bytes, key32, 32); e->user.public_key.size = 32; } - // Signer provenance transfers only alongside a matching key (monotonic upgrade). + if (keyChanged) + e->keySignerProven = false; if (signerKnown && key32 && e->user.public_key.size == 32 && memcmp(e->user.public_key.bytes, key32, 32) == 0) e->keySignerProven = true; - e->isMember = true; - e->retTick = retNow; // keep-alive: a member entry never ages toward the retention TTL - const size_t idx = static_cast(e - nodeInfoPayload); - marked[idx / 8] |= static_cast(1u << (idx % 8)); }; for (size_t i = 0; i < nodeDB->getNumMeshNodes(); i++) { @@ -710,6 +663,8 @@ void TrafficManagementModule::reconcileNodeInfoMembershipLocked() reconcileOne(info->num, key, nodeInfoLiteHasXeddsaSigned(info), info); } #if WARM_NODE_COUNT > 0 + // Warm tier: key-only records (the warm tier keeps no names), so the cache holds a key + // for every NodeDB identity - the superset the pubkey pool and the key pin rely on. for (size_t i = 0; i < nodeDB->warmStore.capacity(); i++) { const WarmNodeEntry *w = nodeDB->warmStore.entryAt(i); if (!w) @@ -719,12 +674,9 @@ void TrafficManagementModule::reconcileNodeInfoMembershipLocked() } #endif - // Clear membership on every entry this pass did not touch: its node has left both - // NodeDB tiers (or lost the spareMembers race), so it reverts to normal-tier aging. - for (uint16_t i = 0; i < nodeInfoTargetEntries(); i++) { - if (nodeInfoPayload[i].node != 0 && !(marked[i / 8] & (1u << (i % 8)))) - nodeInfoPayload[i].isMember = false; - } + if (seeded) + TM_LOG_INFO("NodeInfo cache reconciled: %u seeded from NodeDB, %u/%u total", static_cast(seeded), + static_cast(countNodeInfoEntriesLocked()), static_cast(nodeInfoTargetEntries())); #endif } @@ -760,7 +712,6 @@ void TrafficManagementModule::onNodeIdentityCommitted(NodeNum node, const meshta e->hasFullUser = true; e->keySignerProven = provenBefore || (signerKnown && user.public_key.size == 32); e->isMember = true; // committed via updateUser => it sits in the hot store right now - e->retTick = nodeInfoRetTickNow(); // obsTick/hasObserved deliberately untouched: only a heard frame makes a node servable. #else (void)node; @@ -903,10 +854,9 @@ void TrafficManagementModule::cacheNodeInfoPacket(const meshtastic_MeshPacket &m // richer context than "just the user protobuf" when the cache is present. // This path is intentionally independent from NodeInfoModule/NodeDB. entry->user = user; - entry->obsTick = nodeInfoObsTickNow(); // a genuine observation - the only place obsTick is stamped + entry->obsTick = currentObsTick(); // a genuine observation - the only place obsTick is stamped entry->hasObserved = true; entry->hasFullUser = true; // an observed NODEINFO frame always carries the full User - entry->retTick = nodeInfoRetTickNow(); entry->sourceChannel = mp.channel; entry->hasDecodedBitfield = mp.decoded.has_bitfield; entry->decodedBitfield = mp.decoded.bitfield; @@ -1267,8 +1217,6 @@ int32_t TrafficManagementModule::runOnce() return INT32_MAX; #if TRAFFIC_MANAGEMENT_CACHE_SIZE > 0 - const uint32_t nowMs = TrafficManagementModule::clockMs(); - // Warm-start the next-hop cache from persisted NodeInfoLite hints once nodeDB // is populated. Done here (not in the constructor) so nodeDB has finished // loading. Takes its own lock, so call before acquiring the sweep guard below. @@ -1366,44 +1314,50 @@ int32_t TrafficManagementModule::runOnce() #if TMM_HAS_NODEINFO_CACHE if (nodeInfoPayload) { - // Saturate and evict NodeInfo entries. Saturation is what keeps every modular tick - // compare unambiguous: once a stamp's window has passed, its validity flag is cleared - // (the exact age no longer matters - both states mean "outside the window"), so a - // stamp can never survive toward its ~256-tick aliasing horizon. Every period - // (12.8 h observation / 21.3 min throttle / 10.67 d retention) dwarfs the 60 s sweep - // interval, so saturation always lands with huge margin. - // - // Eviction runs on the retention clock. Two windows: an entry carrying a 32-byte - // public key is retained for kNodeInfoKeyRetentionRetTicks (it is a last-resort - // encryption key source, see NodeDB::copyPublicKey), while a keyless entry expires - // at the serve window since it holds nothing worth keeping past that point. - const uint8_t obsNow = nodeInfoObsTickNow(); - const uint8_t respNow = nodeInfoRespTickNow(); - const uint8_t retNow = nodeInfoRetTickNow(); - uint16_t nodeInfoExpired = 0; + // Saturate expired tick stamps. Once a stamp's age exceeds its window, its presence + // bit is cleared here, so no stamp is ever read anywhere near its uint8 aliasing + // horizon (obs: 6 h window vs 12.8 h period; resp: 30 s vs 21.3 min) - this sweep, + // not slot eviction, is the wrap-safety guarantee. Entries themselves are never + // freed on a timer: a quiet entry's key and User payload keep their value (pubkey + // pool, name rehydration), and slots are reclaimed by tiered LRU on insert or by an + // explicit user-initiated purge. + uint16_t nodeInfoSaturated = 0; + const uint8_t nowObs = currentObsTick(); + const uint8_t nowResp = currentRespTick(); for (uint16_t i = 0; i < nodeInfoTargetEntries(); i++) { NodeInfoPayloadEntry &e = nodeInfoPayload[i]; if (e.node == 0) continue; - if (e.hasObserved && static_cast(obsNow - e.obsTick) > kNodeInfoMaxServeAgeObsTicks) + if (e.hasObserved && static_cast(nowObs - e.obsTick) > kNodeInfoMaxServeAgeTicks) { e.hasObserved = false; - if (e.hasResponded && static_cast(respNow - e.respTick) >= kNodeInfoResponseThrottleRespTicks) + nodeInfoSaturated++; + } + if (e.hasResponded && static_cast(nowResp - e.respTick) >= kNodeInfoThrottleTicks) e.hasResponded = false; - const uint8_t ttlTicks = - (e.user.public_key.size == 32) ? kNodeInfoKeyRetentionRetTicks : kNodeInfoKeylessRetentionRetTicks; - if (static_cast(retNow - e.retTick) > ttlTicks) { - memset(&e, 0, sizeof(NodeInfoPayloadEntry)); - nodeInfoExpired++; + // Refresh membership: the bit is the keep-alive that makes a node still present + // in NodeDB (hot or warm) the stickiest under LRU eviction, and its clearing is + // what lets a node NodeDB has fully forgotten become evictable again. Read-only + // NodeDB lookups under cacheLock follow the resolveSenderRole precedent. + bool member = nodeDB && nodeDB->getMeshNode(e.node) != nullptr; +#if WARM_NODE_COUNT > 0 + if (!member && nodeDB) + member = nodeDB->warmStore.contains(e.node); +#endif + e.isMember = member; + } + TM_LOG_DEBUG("NodeInfo cache: %u/%u (%u went stale)", static_cast(countNodeInfoEntriesLocked()), + static_cast(nodeInfoTargetEntries()), static_cast(nodeInfoSaturated)); + + // Anti-entropy: seed identities NodeDB knows but this cache lacks - a full pass at + // boot (once nodeDB is ready), then hourly. The write-through hooks provide + // immediacy between passes. + if (!nodeInfoSeeded || ++sweepsSinceNodeInfoReconcile >= kNodeInfoReconcileSweeps) { + if (nodeDB) { + reconcileNodeInfoFromNodeDBLocked(); + nodeInfoSeeded = true; + sweepsSinceNodeInfoReconcile = 0; } } - - // Anti-entropy: reseed/refresh an entry for every NodeDB-tier node, so the cache - // stays a superset of NodeDB's identities (write-through hooks give immediacy; - // this pass self-heals whatever they miss, and is what keeps member entries' - // retTick from ever reaching the eviction TTL above). - reconcileNodeInfoMembershipLocked(); - TM_LOG_DEBUG("NodeInfo PSRAM cache: %u/%u (%u expired)", static_cast(countNodeInfoEntriesLocked()), - static_cast(nodeInfoTargetEntries()), static_cast(nodeInfoExpired)); } #endif @@ -1603,7 +1557,7 @@ bool TrafficManagementModule::shouldRespondToNodeInfo(const meshtastic_MeshPacke // tick compare can never see an aliased age. Only cache hits are gated here; the NodeDB // fallback was gated above on last_heard. if (hasCachedUser && (!cachedHasObserved || - static_cast(nodeInfoObsTickNow() - cachedObsTick) > kNodeInfoMaxServeAgeObsTicks)) { + static_cast(currentObsTick() - cachedObsTick) > kNodeInfoMaxServeAgeTicks)) { TM_LOG_DEBUG("NodeInfo cache entry for 0x%08x not freshly observed, not responding", p->to); return false; } @@ -1643,7 +1597,7 @@ bool TrafficManagementModule::shouldRespondToNodeInfo(const meshtastic_MeshPacke usedFallback ? (cachedFallbackResponseMs != 0 && (clockMs() - cachedFallbackResponseMs) < kNodeInfoResponseThrottleMs) : (cachedHasResponded && - static_cast(nodeInfoRespTickNow() - cachedRespTick) < kNodeInfoResponseThrottleRespTicks); + static_cast(currentRespTick() - cachedRespTick) < kNodeInfoThrottleTicks); if (throttled) { TM_LOG_DEBUG("NodeInfo response throttled for 0x%08x", p->to); return true; @@ -1679,7 +1633,7 @@ bool TrafficManagementModule::shouldRespondToNodeInfo(const meshtastic_MeshPacke if (hasCachedUser) { TM_LOG_DEBUG("NodeInfo cache hit node=0x%08x age=%u obs-ticks src_ch=%u req_ch=%u", p->to, - static_cast(static_cast(nodeInfoObsTickNow() - cachedObsTick)), + static_cast(static_cast(currentObsTick() - cachedObsTick)), static_cast(cachedSourceChannel), static_cast(p->channel)); } @@ -1715,7 +1669,7 @@ bool TrafficManagementModule::shouldRespondToNodeInfo(const meshtastic_MeshPacke concurrency::LockGuard guard(&cacheLock); NodeInfoPayloadEntry &e = nodeInfoPayload[cachedNodeInfoIndex]; if (e.node == p->to) { - e.respTick = nodeInfoRespTickNow(); + e.respTick = currentRespTick(); e.hasResponded = true; } } diff --git a/src/modules/TrafficManagementModule.h b/src/modules/TrafficManagementModule.h index 605696f81fd..dd936984128 100644 --- a/src/modules/TrafficManagementModule.h +++ b/src/modules/TrafficManagementModule.h @@ -250,7 +250,7 @@ class TrafficManagementModule : public MeshModule, private concurrency::OSThread static constexpr uint16_t cacheSize() { return TRAFFIC_MANAGEMENT_CACHE_SIZE; } // NodeInfo cache configuration (PSRAM path): a flat PSRAM array of payload - // entries, linear scan keyed by `node`, tiered LRU eviction by retTick. + // entries, linear scan keyed by `node`, trust/membership-tiered LRU eviction on insert. // NodeInfo traffic is low-rate, so a full scan per lookup/insert is fine. static constexpr uint16_t kNodeInfoCacheEntries = 2000; static constexpr uint16_t nodeInfoTargetEntries() { return kNodeInfoCacheEntries; } @@ -279,6 +279,25 @@ class TrafficManagementModule : public MeshModule, private concurrency::OSThread static uint8_t currentPosTick() { return static_cast(clockMs() / kPosTimeTickMs); } static uint8_t currentRateTick() { return static_cast((clockMs() / kRateTimeTickMs) & 0x0F); } static uint8_t currentUnknownTick() { return static_cast((clockMs() / kUnknownTimeTickMs) & 0x0F); } + + // NodeInfo cache ticks (same idiom, applied to NodeInfoPayloadEntry): + // obsTick : uint8 (256 ticks x 3 min = 12.8 h period; serve window 6 h = 120 ticks, 2.1x margin) + // respTick: uint8 (256 ticks x 5 s = 21.3 min period; throttle window 30 s = 6 ticks, 42x margin) + // Presence is an explicit bit per stamp (hasObserved / hasResponded), not a 0-sentinel; + // the 60 s maintenance sweep clears each bit once its window passes ("saturation"), so a + // stamp is never read anywhere near its aliasing horizon. +-1 tick granularity error + // (+-3 min on a 6 h gate, +-5 s on a 30 s throttle) is noise for these windows. + static constexpr uint32_t kNodeInfoObsTickMs = 180000UL; // 3 min/tick + static constexpr uint32_t kNodeInfoRespTickMs = 5000UL; // 5 s/tick + static constexpr uint8_t kNodeInfoMaxServeAgeTicks = 120; // 6 h serve window + static constexpr uint8_t kNodeInfoThrottleTicks = 6; // 30 s throttle window + + static uint8_t currentObsTick() { return static_cast(clockMs() / kNodeInfoObsTickMs); } + static uint8_t currentRespTick() { return static_cast(clockMs() / kNodeInfoRespTickMs); } + static_assert(kNodeInfoMaxServeAgeTicks * kNodeInfoObsTickMs == 6UL * 60UL * 60UL * 1000UL, + "cache serve window must equal the fallback path's 6 h"); + static_assert(kNodeInfoThrottleTicks * kNodeInfoRespTickMs == 30UL * 1000UL, + "cache throttle window must equal the fallback path's 30 s"); // ========================================================================= // Position Fingerprint // ========================================================================= @@ -317,26 +336,22 @@ class TrafficManagementModule : public MeshModule, private concurrency::OSThread // packet for this node. shouldRespondToNodeInfo() uses this metadata when // building spoofed replies for requesting clients. - // Free-running tick stamps (uint8 modular clocks, see kNodeInfo*TickMs in the .cpp - + // Free-running tick stamps (uint8 modular clocks, kNodeInfo*TickMs in the header - // same scheme as the UnifiedCache tick counters). Validity of obsTick/respTick is // carried by the hasObserved/hasResponded flag bits below, not a 0 sentinel; the - // maintenance sweep clears a flag once its window passes, so no stamp can age far - // enough for its modular compare to alias. + // maintenance sweep clears a flag once its window passes (saturation), so no stamp + // can age far enough for its modular compare to alias. - // When we last actually HEARD a NODEINFO_APP frame from this node (3-min ticks). - // Drives the replay staleness gate and nothing else - retention refresh never - // touches it, so a spoofed reply is only ever backed by genuine recent observation. + // Free-running tick (kNodeInfoObsTickMs) of the last genuinely HEARD NODEINFO frame + // from this node. Drives the replay staleness gate and the LRU age - seeding and + // write-through hooks never touch it, so a spoofed reply is only ever backed by + // genuine recent observation. uint8_t obsTick; - // When we last emitted a spoofed direct reply for this node (5-s ticks). - // Drives the per-target response throttle. + // Free-running tick (kNodeInfoRespTickMs) of the most recent spoofed direct reply + // we emitted for this node. Drives the per-target response throttle. uint8_t respTick; - // When this entry was last retained (1-h ticks): stamped on observation, and by the - // maintenance sweep for entries it keeps alive. Drives eviction (retention TTL) - // and the LRU age used by victim selection. - uint8_t retTick; - // Channel where we most recently heard this node's NodeInfo. uint8_t sourceChannel; @@ -375,11 +390,10 @@ class TrafficManagementModule : public MeshModule, private concurrency::OSThread // tier. copyUser()/name-rehydration require it; copyPublicKey() does not. uint8_t hasFullUser : 1; - // The node existed in NodeDB (hot store or warm tier) at the last maintenance - // sweep. Member entries are kept alive (retTick refreshed) and are the stickiest - // under LRU eviction, so the cache stays a superset of NodeDB's identities; when - // membership lapses the entry starts aging toward the normal retention TTL from - // its final member re-stamp. May be up to one sweep (60 s) stale. + // The node currently exists in NodeDB (hot store or warm tier), per the last + // maintenance-sweep membership check (may be up to one sweep / 60 s stale). Member + // entries are the stickiest under LRU eviction, keeping the cache a superset of + // NodeDB's identities; the bit itself is the keep-alive - nothing expires by TTL. uint8_t isMember : 1; }; // The tick stamps, sourceChannel, decodedBitfield, and the 1-bit flags make up the @@ -415,6 +429,14 @@ class TrafficManagementModule : public MeshModule, private concurrency::OSThread // One-shot guard: warm-start next-hop cache from NodeDB on first maintenance pass. bool nextHopPreloaded = false; + // Anti-entropy cadence for reconcileNodeInfoFromNodeDBLocked(): a full boot seed on the + // first maintenance pass (once nodeDB is ready), then one reconciliation per hour of + // sweeps. The write-through hooks give immediacy; this periodic repair self-heals + // anything they miss (boot ordering, a write path without a hook, future NodeDB paths). + static constexpr uint8_t kNodeInfoReconcileSweeps = 60; // sweeps between reconciliations (60 x 60 s = 1 h) + bool nodeInfoSeeded = false; + uint8_t sweepsSinceNodeInfoReconcile = 0; + // ========================================================================= // Cache Operations // ========================================================================= @@ -442,20 +464,25 @@ class TrafficManagementModule : public MeshModule, private concurrency::OSThread // NodeInfo cache operations (flat PSRAM payload array, linear scan) const NodeInfoPayloadEntry *findNodeInfoEntry(NodeNum node) const; + NodeInfoPayloadEntry *findNodeInfoEntryMutable(NodeNum node) + { + return const_cast(findNodeInfoEntry(node)); + } // spareMembers: when creating would evict an isMember entry, return nullptr instead. - // The reconcile pass uses it so seeding one NodeDB-tier node never churns out another - // when the cache is smaller than hot+warm; the packet path leaves it false (a freshly + // The seeding pass uses it so seeding one NodeDB-tier node never churns out another + // when hot+warm exceeds the cache; the packet path leaves it false (a freshly // observed stranger may displace the LRU member - observed freshness has value). NodeInfoPayloadEntry *findOrCreateNodeInfoEntry(NodeNum node, bool *usedEmptySlot, bool spareMembers = false); uint16_t countNodeInfoEntriesLocked() const; - // Anti-entropy pass called from the maintenance sweep, under cacheLock. Keeps the - // NodeInfo cache a superset of NodeDB's identities: ensures every hot-store / warm-tier - // node has an entry (seeding name/key/signer-provenance from whichever tier holds - // them), refreshes member entries' retTick (keep-alive) and isMember, and adopts - // NodeDB's key when a cached key conflicts with the authoritative one. Seeding never - // sets hasObserved, so it can never make a silent node servable by the replay path. - void reconcileNodeInfoMembershipLocked(); + // Anti-entropy seeding, under cacheLock: walk the NodeDB hot store (full identity) and + // warm tier (key-only records) and upsert whatever this cache lacks, marking members + // and adopting NodeDB's key when a cached key conflicts with the authoritative one. + // Runs at boot and then hourly (kNodeInfoReconcileSweeps); the write-through hooks give + // immediacy in between, and per-entry membership refresh happens every sweep. Never + // sets hasObserved - seeding is knowledge, not observation, so it can never make a + // silent node servable by the replay path. + void reconcileNodeInfoFromNodeDBLocked(); void cacheNodeInfoPacket(const meshtastic_MeshPacket &mp); // ========================================================================= diff --git a/test/test_traffic_management/test_main.cpp b/test/test_traffic_management/test_main.cpp index a4c62c077b1..fbc313247e5 100644 --- a/test/test_traffic_management/test_main.cpp +++ b/test/test_traffic_management/test_main.cpp @@ -1178,20 +1178,17 @@ static void test_tm_nodeinfo_removeNode_purgesCaches(void) } /** - * Membership keep-alive vs retention TTL: an entry whose node still lives in a NodeDB tier - * is re-stamped by every sweep and outlives the 7-day retention window; a non-member entry - * of the same age is evicted; and once membership lapses the survivor ages out too. + * No timed eviction: a quiet keyed entry survives arbitrarily long (its key keeps feeding + * the pubkey pool via copyPublicKey), while tick saturation stops it being SERVED long + * before that. Slots are reclaimed only by LRU pressure on insert or an explicit purge. */ -static void test_tm_nodeinfo_membership_keepAliveOutlivesRetention(void) +static void test_tm_nodeinfo_noTimedEviction_quietKeyedEntrySurvives(void) { moduleConfig.traffic_management.nodeinfo_direct_response_max_hops = 10; config.device.role = meshtastic_Config_DeviceConfig_Role_CLIENT; mockNodeDB->clearCachedNode(); mockNodeDB->rollHotStore(); - uint8_t warmKey[32]; - memset(warmKey, 0x21, 32); mockNodeDB->warmStore.clear(); - mockNodeDB->warmStore.absorb(kTargetNode, 1000000, warmKey); // kTargetNode is a member MockRouter mockRouter; mockRouter.addInterface(std::unique_ptr(new MockRadioInterface())); @@ -1200,28 +1197,28 @@ static void test_tm_nodeinfo_membership_keepAliveOutlivesRetention(void) service = &mockService; TrafficManagementModuleTestShim module; - // Both keyed entries observed now; kRemoteNode is in no NodeDB tier (non-member). - module.handleReceived(makeNodeInfoPacketWithKey(kTargetNode, "member", 0x21)); - module.handleReceived(makeNodeInfoPacketWithKey(kRemoteNode, "stranger", 0x22)); + module.handleReceived(makeNodeInfoPacketWithKey(kTargetNode, "quiet", 0x21)); + module.markKeySignerProvenForTest(kTargetNode); // isolate: staleness, not the signed gate + + // Nine days of silence, swept every three days. The old design would have evicted the + // entry at the 7-day retention TTL; now nothing expires by timer. + for (int i = 0; i < 3; i++) { + TrafficManagementModule::s_testNowMs += 3UL * 24UL * 60UL * 60UL * 1000UL; + module.runOnce(); + } + // The key still feeds the pool... uint8_t key[32] = {0}; - // Two sweeps 4 days apart: the member is re-stamped each time; the stranger's age - // crosses the 7-day keyed retention TTL at the second sweep and is evicted. - TrafficManagementModule::s_testNowMs += 4UL * 24UL * 60UL * 60UL * 1000UL; - module.runOnce(); - TrafficManagementModule::s_testNowMs += 4UL * 24UL * 60UL * 60UL * 1000UL; - module.runOnce(); TEST_ASSERT_TRUE(module.copyPublicKey(kTargetNode, key, nullptr)); - TEST_ASSERT_FALSE(module.copyPublicKey(kRemoteNode, key, nullptr)); + TEST_ASSERT_EQUAL_UINT8(0x21, key[0]); - // Membership lapses: the node leaves the warm tier, keep-alive stops, and the entry - // ages out on the normal keyed TTL (one sweep to notice, one past the window). - mockNodeDB->warmStore.clear(); - TrafficManagementModule::s_testNowMs += 1000; // same tick; sweep just clears isMember - module.runOnce(); - TrafficManagementModule::s_testNowMs += 8UL * 24UL * 60UL * 60UL * 1000UL; - module.runOnce(); - TEST_ASSERT_FALSE(module.copyPublicKey(kTargetNode, key, nullptr)); + // ...but the serve gate saturated long ago: the request propagates unanswered. + meshtastic_MeshPacket request = makeDecodedPacket(meshtastic_PortNum_NODEINFO_APP, kRemoteNode, kTargetNode); + request.decoded.want_response = true; + request.hop_start = 3; + request.hop_limit = 3; + TEST_ASSERT_EQUAL_INT(static_cast(ProcessMessage::CONTINUE), static_cast(module.handleReceived(request))); + TEST_ASSERT_EQUAL_UINT32(0, static_cast(mockRouter.sentPackets.size())); } #endif // WARM_NODE_COUNT > 0 @@ -2537,7 +2534,7 @@ TM_TEST_ENTRY void setup() RUN_TEST(test_tm_nodeinfo_reconcile_seedsKeyOnlyFromWarmTier); RUN_TEST(test_tm_nodeinfo_updateUserHook_writesThrough); RUN_TEST(test_tm_nodeinfo_removeNode_purgesCaches); - RUN_TEST(test_tm_nodeinfo_membership_keepAliveOutlivesRetention); + RUN_TEST(test_tm_nodeinfo_noTimedEviction_quietKeyedEntrySurvives); #endif RUN_TEST(test_tm_nodeinfo_copyPublicKey_servesTofuKey); RUN_TEST(test_tm_nodeinfo_copyPublicKey_upgradesToSignerProven); From d81ad7b5768fe2699f651582f615c1ab6d012cc7 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 17 Jul 2026 13:12:15 +0000 Subject: [PATCH 26/44] Reconcile hooks: key-commit write-through, purgeAll, key-matched signer Adopt tmm-fix-superset's hook coverage (reconciliation decisions #5-#8): - onNodeKeyCommitted(): write-through for the two key-write sites that bypass NodeDB::updateUser - the Router's admin-key learn (TOFU-grade) and KeyVerificationModule's manual-verification commit (proven=true, the strongest provenance the cache can carry). Both were coverage gaps in the tmm-fix-2 write-through design. - updateUser's hook call now transfers signer status key-matched (isVerifiedSignerForKey) instead of the node's bare signed flag, and runs on acceptance rather than only on change. - purgeAll(): factoryReset() and resetNodes() clear both TMM tables - removal-is-full-removal applies to bulk resets too, without relying on the usual post-reset reboot. - purgeNode() reuses the existing finders instead of open-coded scans and logs the (user-initiated) purge. - peekNodeInfoFlagsForTest(): flag introspection so saturation and membership tests can assert sweep effects directly. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01KWHDDxY4U2XLYW2WJJ2oLT --- src/mesh/NodeDB.cpp | 29 ++++++-- src/mesh/Router.cpp | 7 ++ src/modules/KeyVerificationModule.cpp | 8 +++ src/modules/TrafficManagementModule.cpp | 90 ++++++++++++++++++++----- src/modules/TrafficManagementModule.h | 27 ++++++-- 5 files changed, 131 insertions(+), 30 deletions(-) diff --git a/src/mesh/NodeDB.cpp b/src/mesh/NodeDB.cpp index 42d28ef41b2..79c97db1fef 100644 --- a/src/mesh/NodeDB.cpp +++ b/src/mesh/NodeDB.cpp @@ -770,6 +770,12 @@ bool NodeDB::factoryReset(bool eraseBleBonds) warmStore.clear(); warmStore.saveIfDirty(); #endif +#if HAS_TRAFFIC_MANAGEMENT + // Factory reset forgets everything; TMM's RAM caches must not survive to resurrect + // identities (the device usually reboots after this, but don't rely on it). + if (trafficManagementModule) + trafficManagementModule->purgeAll(); +#endif // second, install default state (this will deal with the duplicate mac address issue) installDefaultNodeDatabase(); @@ -1600,6 +1606,11 @@ void NodeDB::resetNodes(bool keepFavorites) #if WARM_NODE_COUNT > 0 warmStore.clear(); // warm entries are never favorites; a DB reset clears them too #endif +#if HAS_TRAFFIC_MANAGEMENT + // A user-initiated DB reset forgets everything; TMM's caches must not resurrect it. + if (trafficManagementModule) + trafficManagementModule->purgeAll(); +#endif devicestate.has_rx_waypoint = false; saveNodeDatabaseToDisk(); @@ -3400,13 +3411,17 @@ bool NodeDB::updateUser(uint32_t nodeId, meshtastic_User &p, uint8_t channelInde } #if HAS_TRAFFIC_MANAGEMENT - // Write-through to the TrafficManagement NodeInfo cache, so it reflects this commit - // immediately rather than at the next reconcile sweep. updateUser is the single - // chokepoint through which every remote identity/key write flows, which is what makes - // this one call sufficient. `p` is the post-hygiene payload (id normalized, key pin - // already enforced above); the signer bit transfers the node's verified-signer status. - if (trafficManagementModule) - trafficManagementModule->onNodeIdentityCommitted(nodeId, p, nodeInfoLiteHasXeddsaSigned(info)); + // Write-through: every accepted remote-identity commit lands here (NodeInfoModule, + // MeshService, and TMM's requester learning all funnel through updateUser; the two + // key-write sites that bypass it call onNodeKeyCommitted instead), so TMM's NodeInfo + // cache reflects the commit immediately rather than at the next reconcile pass. Runs on + // acceptance, not on `changed`: an identical update still proves the identity is + // current. `p` is the post-hygiene payload; signerKnown transfers only key-matched + // verified-signer status (isVerifiedSignerForKey semantics), never a bare node flag. + if (nodeId != getNodeNum() && trafficManagementModule) { + const bool signerKnown = p.public_key.size == 32 && isVerifiedSignerForKey(nodeId, p.public_key.bytes); + trafficManagementModule->onNodeIdentityCommitted(nodeId, p, signerKnown); + } #endif return changed; diff --git a/src/mesh/Router.cpp b/src/mesh/Router.cpp index 9b1cc3ff28d..24b4f9655b6 100644 --- a/src/mesh/Router.cpp +++ b/src/mesh/Router.cpp @@ -571,6 +571,13 @@ DecodeState perhapsDecode(meshtastic_MeshPacket *p) meshtastic_NodeInfoLite *fromNode = nodeDB->getOrCreateMeshNode(p->from); if (fromNode != nullptr) fromNode->public_key = remotePublic; +#if HAS_TRAFFIC_MANAGEMENT + // This learn bypasses NodeDB::updateUser, so write the key through to TMM's + // NodeInfo cache too. proven=false: possession was shown to the admin channel, + // not via an XEdDSA NodeInfo signature, so it stays TOFU-grade there. + if (fromNode != nullptr && trafficManagementModule) + trafficManagementModule->onNodeKeyCommitted(p->from, remotePublic.bytes, false); +#endif } } else { // AEAD already authenticated this ciphertext, so no other candidate could decode it - diff --git a/src/modules/KeyVerificationModule.cpp b/src/modules/KeyVerificationModule.cpp index c275ce1d9de..e435f4b0ea1 100644 --- a/src/modules/KeyVerificationModule.cpp +++ b/src/modules/KeyVerificationModule.cpp @@ -9,6 +9,7 @@ #include "meshUtils.h" #include "modules/AdminModule.h" #include "modules/NodeInfoModule.h" +#include "modules/TrafficManagementModule.h" #include #include @@ -383,6 +384,13 @@ void KeyVerificationModule::commitVerifiedRemoteNode() if (node->public_key.size != 32 && crypto->getPendingPublicKey(currentRemoteNode, pending)) node->public_key = pending; node->bitfield |= NODEINFO_BITFIELD_IS_KEY_MANUALLY_VERIFIED_MASK; +#if HAS_TRAFFIC_MANAGEMENT + // This commit bypasses NodeDB::updateUser (it writes node->public_key directly), so push + // the key into TMM's NodeInfo cache here. proven=true: the user just confirmed possession + // of this exact key - the strongest provenance any key in that cache can carry. + if (node->public_key.size == 32 && trafficManagementModule) + trafficManagementModule->onNodeKeyCommitted(currentRemoteNode, node->public_key.bytes, true); +#endif LOG_INFO("Node 0x%08x manually verified with security number %u", currentRemoteNode, currentSecurityNumber); if (nodeInfoModule) nodeInfoModule->sendOurNodeInfo(currentRemoteNode, false, node->channel, true); diff --git a/src/modules/TrafficManagementModule.cpp b/src/modules/TrafficManagementModule.cpp index 34f18eed19d..38d7cd94d87 100644 --- a/src/modules/TrafficManagementModule.cpp +++ b/src/modules/TrafficManagementModule.cpp @@ -317,28 +317,35 @@ int TrafficManagementModule::peekCachedRole(NodeNum node) void TrafficManagementModule::purgeNode(NodeNum node) { +#if TRAFFIC_MANAGEMENT_CACHE_SIZE > 0 if (node == 0) return; concurrency::LockGuard guard(&cacheLock); -#if TRAFFIC_MANAGEMENT_CACHE_SIZE > 0 - if (cache) { - for (uint16_t i = 0; i < cacheSize(); i++) { - if (cache[i].node == node) { - memset(&cache[i], 0, sizeof(UnifiedCacheEntry)); - break; // a node occupies at most one unified slot - } - } - } + UnifiedCacheEntry *entry = findEntry(node); + if (entry) + memset(entry, 0, sizeof(UnifiedCacheEntry)); +#if TMM_HAS_NODEINFO_CACHE + NodeInfoPayloadEntry *info = findNodeInfoEntryMutable(node); + if (info) + memset(info, 0, sizeof(NodeInfoPayloadEntry)); +#endif + TM_LOG_INFO("Purged node 0x%08x from traffic caches", node); +#else + (void)node; #endif +} + +void TrafficManagementModule::purgeAll() +{ +#if TRAFFIC_MANAGEMENT_CACHE_SIZE > 0 + concurrency::LockGuard guard(&cacheLock); + if (cache) + memset(cache, 0, static_cast(cacheSize()) * sizeof(UnifiedCacheEntry)); #if TMM_HAS_NODEINFO_CACHE - if (nodeInfoPayload) { - for (uint16_t i = 0; i < nodeInfoTargetEntries(); i++) { - if (nodeInfoPayload[i].node == node) { - memset(&nodeInfoPayload[i], 0, sizeof(NodeInfoPayloadEntry)); - break; // likewise unique in the NodeInfo cache - } - } - } + if (nodeInfoPayload) + memset(nodeInfoPayload, 0, static_cast(nodeInfoTargetEntries()) * sizeof(NodeInfoPayloadEntry)); +#endif + TM_LOG_INFO("Purged all traffic caches"); #endif } @@ -358,6 +365,21 @@ void TrafficManagementModule::dropNodeInfoCacheForTest() #endif } +int TrafficManagementModule::peekNodeInfoFlagsForTest(NodeNum node) +{ +#if TMM_HAS_NODEINFO_CACHE + concurrency::LockGuard guard(&cacheLock); + const NodeInfoPayloadEntry *entry = findNodeInfoEntry(node); + if (!entry) + return -1; + return (entry->hasObserved ? 1 : 0) | (entry->hasResponded ? 2 : 0) | (entry->isMember ? 4 : 0) | + (entry->hasFullUser ? 8 : 0) | (entry->keySignerProven ? 16 : 0); +#else + (void)node; + return -1; +#endif +} + void TrafficManagementModule::markKeySignerProvenForTest(NodeNum node) { #if TMM_HAS_NODEINFO_CACHE @@ -720,6 +742,37 @@ void TrafficManagementModule::onNodeIdentityCommitted(NodeNum node, const meshta #endif } +void TrafficManagementModule::onNodeKeyCommitted(NodeNum node, const uint8_t key32[32], bool proven) +{ +#if TMM_HAS_NODEINFO_CACHE + if (node == 0 || !key32 || (nodeDB && node == nodeDB->getNodeNum())) + return; + concurrency::LockGuard guard(&cacheLock); + if (!nodeInfoPayload) + return; + bool usedEmptySlot = false; + NodeInfoPayloadEntry *entry = findOrCreateNodeInfoEntry(node, &usedEmptySlot, /*spareMembers=*/true); + if (!entry) + return; + + const bool keyChanged = entry->user.public_key.size == 32 && memcmp(entry->user.public_key.bytes, key32, 32) != 0; + memcpy(entry->user.public_key.bytes, key32, 32); + entry->user.public_key.size = 32; + entry->isMember = true; // the caller just committed it to the hot store + // A rotated key never inherits the old key's verdict; `proven` (manual verification of + // exactly this key) is the strongest provenance this cache can carry. + if (keyChanged) + entry->keySignerProven = false; + if (proven) + entry->keySignerProven = true; + // hasObserved/obsTick untouched: a key commit is knowledge, not an observation. +#else + (void)node; + (void)key32; + (void)proven; +#endif +} + bool TrafficManagementModule::copyPublicKey(NodeNum node, uint8_t out[32], bool *signerProven) const { #if TMM_HAS_NODEINFO_CACHE @@ -1104,6 +1157,9 @@ ProcessMessage TrafficManagementModule::handleReceived(const meshtastic_MeshPack meshtastic_User requester = meshtastic_User_init_zero; if (!unauthenticatedSigner && pb_decode_from_bytes(mp.decoded.payload.bytes, mp.decoded.payload.size, &meshtastic_User_msg, &requester)) { + // Re-enters this module: updateUser's write-through hook calls back into + // onNodeIdentityCommitted, which takes cacheLock - safe here because this + // call site never holds cacheLock. nodeDB->updateUser(getFrom(&mp), requester, mp.channel); } logAction("respond", &mp, "nodeinfo-cache"); diff --git a/src/modules/TrafficManagementModule.h b/src/modules/TrafficManagementModule.h index dd936984128..47070448b5e 100644 --- a/src/modules/TrafficManagementModule.h +++ b/src/modules/TrafficManagementModule.h @@ -119,13 +119,22 @@ class TrafficManagementModule : public MeshModule, private concurrency::OSThread // No-op without the cache. Thread-safe (takes cacheLock). void onNodeIdentityCommitted(NodeNum node, const meshtastic_User &user, bool signerKnown); - // Full-removal hook from NodeDB::removeNodeByNum(): an explicit removal must forget the - // node here too - the unified cache slot (role, next-hop hint, dedup/rate state) and the - // NodeInfo cache entry (name/key/provenance) - or these caches would keep serving the - // deleted identity (key pool, rehydration) and resurrect it on next contact. Passive - // NodeDB eviction never calls this; the reconcile sweep will not re-seed a node that is - // gone from both NodeDB tiers. Thread-safe (takes cacheLock). + // Key-only commit hook, for the key-write sites that bypass NodeDB::updateUser (the + // Router's admin-key learn, KeyVerificationModule's manual-verification commit). Same + // rules as onNodeIdentityCommitted: NodeDB is senior on key content, a changed key + // resets provenance, and the observation stamp is never touched. `proven` marks the key + // signer-proven: pass true only when the commit itself established possession (a + // completed manual key verification), not for a TOFU-grade learn. Thread-safe. + void onNodeKeyCommitted(NodeNum node, const uint8_t key32[32], bool proven); + + // User-initiated removal is total: NodeDB's removal paths call these so no TMM tier can + // resurrect an identity the user deliberately deleted. purgeNode zeroes the node's + // NodeInfo cache slot (identity, key, provenance) AND its unified-cache slot (cached + // role, next-hop hint, dedup state); purgeAll clears both tables outright (resetNodes / + // factory reset). Passive eviction is unaffected - a node that merely ages out of NodeDB + // keeps its cache entries and can be re-recognized on next contact. Thread-safe. void purgeNode(NodeNum node); + void purgeAll(); /** * Check if this packet should have its hops exhausted. @@ -181,6 +190,12 @@ class TrafficManagementModule : public MeshModule, private concurrency::OSThread // allocate it on the heap - see TMM_HAS_NODEINFO_CACHE). No-op when already absent. void dropNodeInfoCacheForTest(); + // Test introspection: the NodeInfo entry's flag bits for `node`, or -1 if absent (or no + // cache). bit0 hasObserved, bit1 hasResponded, bit2 isMember, bit3 hasFullUser, + // bit4 keySignerProven. Lets saturation/membership tests assert sweep effects directly + // instead of inferring them from response behavior. + int peekNodeInfoFlagsForTest(NodeNum node); + private: // ========================================================================= // Unified Cache Entry (10 bytes) - Same for ALL platforms From 213b83829c0803d98e99f476e7517ca59cb01fd1 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 17 Jul 2026 13:14:35 +0000 Subject: [PATCH 27/44] Merge the two branches' test suites for the reconciled design Port tmm-fix-superset's unique tests, adapted to run natively under TMM_HAS_NODEINFO_CACHE (reconciliation decision #11): - keyHook_upsertsAndGovernsProvenance: TOFU learn -> manual-verification upgrade -> NodeDB-senior rotation resets provenance. - tickSaturation_sweepClearsObserved: the sweep saturates hasObserved past the serve window, the entry persists (no TTL), and a full 256-tick clock wrap cannot alias a saturated stamp back to fresh. - sweepMembershipMarking: reconciliation seeds a hot identity as member+fullUser+unobserved; the next sweep clears membership once the node leaves NodeDB, while the entry itself persists. tmm-fix-2's tests (warm pin, hot/warm seeding, updateUser write-through, removal purge - now also asserting the entry is gone via the new peek hook - and the no-timed-eviction rewrite) were already on this branch. Suite now counts 70 test functions. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01KWHDDxY4U2XLYW2WJJ2oLT --- test/test_traffic_management/test_main.cpp | 124 +++++++++++++++++++++ 1 file changed, 124 insertions(+) diff --git a/test/test_traffic_management/test_main.cpp b/test/test_traffic_management/test_main.cpp index fbc313247e5..243164799a7 100644 --- a/test/test_traffic_management/test_main.cpp +++ b/test/test_traffic_management/test_main.cpp @@ -193,6 +193,7 @@ class TrafficManagementModuleTestShim : public TrafficManagementModule using TrafficManagementModule::handleReceived; using TrafficManagementModule::dropNodeInfoCacheForTest; using TrafficManagementModule::markKeySignerProvenForTest; + using TrafficManagementModule::peekNodeInfoFlagsForTest; using TrafficManagementModule::peekCachedRole; using TrafficManagementModule::runOnce; @@ -1173,6 +1174,7 @@ static void test_tm_nodeinfo_removeNode_purgesCaches(void) meshtastic_User out = meshtastic_User_init_zero; TEST_ASSERT_FALSE(module.copyUser(kTargetNode, out, nullptr)); TEST_ASSERT_EQUAL_UINT8(0, module.getNextHopHint(kTargetNode)); + TEST_ASSERT_EQUAL_INT(-1, module.peekNodeInfoFlagsForTest(kTargetNode)); trafficManagementModule = nullptr; } @@ -1358,6 +1360,125 @@ static void test_tm_nodeinfo_directResponse_psramUnsignedNotServed(void) } #endif // TMM_NODEINFO_REPLAY_SIGNED_GATE #endif // !MESHTASTIC_EXCLUDE_PKI + +// Bit positions returned by peekNodeInfoFlagsForTest(). +constexpr int kFlagObserved = 1; +constexpr int kFlagMember = 4; +constexpr int kFlagFullUser = 8; + +/** + * Key-commit hook (ported from tmm-fix-superset): a TOFU learn lands the key in the pool + * without a User payload; manual verification upgrades provenance; a NodeDB-senior rotation + * replaces the key and never inherits the old key's verdict. + */ +static void test_tm_nodeinfo_keyHook_upsertsAndGovernsProvenance(void) +{ + mockNodeDB->clearCachedNode(); + TrafficManagementModuleTestShim module; + + uint8_t k[32]; + memset(k, 0x99, sizeof(k)); + module.onNodeKeyCommitted(kTargetNode, k, false); // TOFU-grade learn + uint8_t key[32] = {0}; + bool proven = true; + TEST_ASSERT_TRUE(module.copyPublicKey(kTargetNode, key, &proven)); + TEST_ASSERT_EQUAL_UINT8(0x99, key[0]); + TEST_ASSERT_FALSE(proven); + meshtastic_User out = meshtastic_User_init_zero; + TEST_ASSERT_FALSE(module.copyUser(kTargetNode, out, nullptr)); // key-only: nothing to rehydrate + + module.onNodeKeyCommitted(kTargetNode, k, true); // manual verification of the same key + TEST_ASSERT_TRUE(module.copyPublicKey(kTargetNode, key, &proven)); + TEST_ASSERT_TRUE(proven); + + uint8_t rotated[32]; + memset(rotated, 0xAA, sizeof(rotated)); + module.onNodeKeyCommitted(kTargetNode, rotated, false); // NodeDB-senior rotation + TEST_ASSERT_TRUE(module.copyPublicKey(kTargetNode, key, &proven)); + TEST_ASSERT_EQUAL_UINT8(0xAA, key[0]); + TEST_ASSERT_FALSE(proven); // rotated key must not inherit the old key's verdict +} + +/** + * Tick saturation (ported from tmm-fix-superset): the sweep clears hasObserved once the + * serve window passes, the entry itself persists (no TTL eviction), and a full 256-tick + * wrap of the clock cannot alias a saturated stamp back to "fresh". + */ +static void test_tm_nodeinfo_tickSaturation_sweepClearsObserved(void) +{ + moduleConfig.traffic_management.nodeinfo_direct_response_max_hops = 10; + config.device.role = meshtastic_Config_DeviceConfig_Role_CLIENT; + mockNodeDB->clearCachedNode(); + mockNodeDB->warmStore.clear(); + mockNodeDB->rollHotStore(); + + MockRouter mockRouter; + mockRouter.addInterface(std::unique_ptr(new MockRadioInterface())); + MeshService mockService; + router = &mockRouter; + service = &mockService; + + TrafficManagementModuleTestShim module; + module.handleReceived(makeNodeInfoPacket(kTargetNode, "target-long", "tg")); + module.markKeySignerProvenForTest(kTargetNode); + const uint32_t stampMs = TrafficManagementModule::s_testNowMs; + int flags = module.peekNodeInfoFlagsForTest(kTargetNode); + TEST_ASSERT_TRUE(flags >= 0 && (flags & kFlagObserved)); + + // Past the serve window (plus one tick for granularity): the sweep saturates the stamp + // but keeps the entry. + TrafficManagementModule::s_testNowMs += (6UL * 60UL * 60UL * 1000UL) + 180000UL + 1000UL; + module.runOnce(); + flags = module.peekNodeInfoFlagsForTest(kTargetNode); + TEST_ASSERT_TRUE(flags >= 0); + TEST_ASSERT_FALSE(flags & kFlagObserved); + + // Advance to exactly one full uint8 tick period after the original stamp: the raw tick + // age would read ~0 (fresh) if the bit were still trusted. It isn't - the cleared bit, + // not the tick value, is authoritative. + TrafficManagementModule::s_testNowMs = stampMs + 256UL * 180000UL; + meshtastic_MeshPacket request = makeDecodedPacket(meshtastic_PortNum_NODEINFO_APP, kRemoteNode, kTargetNode); + request.decoded.want_response = true; + request.hop_start = 3; + request.hop_limit = 3; + TEST_ASSERT_EQUAL_INT(static_cast(ProcessMessage::CONTINUE), static_cast(module.handleReceived(request))); + TEST_ASSERT_EQUAL_UINT32(0, static_cast(mockRouter.sentPackets.size())); +} + +/** + * Membership marking (ported from tmm-fix-superset): the sweep marks entries whose node + * exists in NodeDB and clears the mark when the node is gone; the entry itself persists + * (no TTL) and reconciliation-seeded identities stay unservable. + */ +static void test_tm_nodeinfo_sweepMembershipMarking(void) +{ + moduleConfig.traffic_management.nodeinfo_direct_response_max_hops = 10; + config.device.role = meshtastic_Config_DeviceConfig_Role_CLIENT; + mockNodeDB->clearCachedNode(); + mockNodeDB->warmStore.clear(); + mockNodeDB->setHotNodeIdentity(kTargetNode, "seeded-name", 0x5C, /*signer=*/false); + + MockRouter mockRouter; + mockRouter.addInterface(std::unique_ptr(new MockRadioInterface())); + MeshService mockService; + router = &mockRouter; + service = &mockService; + + TrafficManagementModuleTestShim module; + module.runOnce(); // boot reconciliation pass seeds the hot identity + int flags = module.peekNodeInfoFlagsForTest(kTargetNode); + TEST_ASSERT_TRUE(flags >= 0); + TEST_ASSERT_TRUE(flags & kFlagMember); + TEST_ASSERT_TRUE(flags & kFlagFullUser); + TEST_ASSERT_FALSE(flags & kFlagObserved); + + // Node drops out of NodeDB entirely: the next sweep clears the membership mark. + mockNodeDB->rollHotStore(); + module.runOnce(); + flags = module.peekNodeInfoFlagsForTest(kTargetNode); + TEST_ASSERT_TRUE(flags >= 0); // entry persists (no TTL) ... + TEST_ASSERT_FALSE(flags & kFlagMember); // ... but is no longer pinned as a member +} #endif /** @@ -2544,6 +2665,9 @@ TM_TEST_ENTRY void setup() RUN_TEST(test_tm_nodeinfo_directResponse_psramUnsignedNotServed); #endif #endif + RUN_TEST(test_tm_nodeinfo_keyHook_upsertsAndGovernsProvenance); + RUN_TEST(test_tm_nodeinfo_tickSaturation_sweepClearsObserved); + RUN_TEST(test_tm_nodeinfo_sweepMembershipMarking); #endif RUN_TEST(test_tm_alterReceived_telemetryBroadcast_hopLimitUnchanged); RUN_TEST(test_tm_alterReceived_skipsLocalAndUnicast); From fadcb742b5d55eb8a19a32b5b88d7cec26de48d8 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 17 Jul 2026 13:23:47 +0000 Subject: [PATCH 28/44] Adapt cherry-picked and seeding tests to the reconciled semantics Two tests needed updating for interactions the validation run surfaced: - The #11035 test (ignoresUnsignedSignerIdentity) was written for a world without the native NodeInfo cache: it needs the NodeDB fallback path (dropNodeInfoCacheForTest) and the signed replay gate satisfied for its target, like the other fallback tests on this branch. - The hot-seed test's "observed frame makes it servable" step now sends a signature-verified frame: its node is a known signer, and per #11035 an unsigned frame from a signer must not (and does not) drive cache writes - the gate working as designed. Full native suite: 70/70 under ASan. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01KWHDDxY4U2XLYW2WJJ2oLT --- test/test_traffic_management/test_main.cpp | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/test/test_traffic_management/test_main.cpp b/test/test_traffic_management/test_main.cpp index 243164799a7..dd001ecabcd 100644 --- a/test/test_traffic_management/test_main.cpp +++ b/test/test_traffic_management/test_main.cpp @@ -639,7 +639,9 @@ static void test_tm_nodeinfo_directResponse_ignoresUnsignedSignerIdentity(void) { moduleConfig.traffic_management.nodeinfo_direct_response_max_hops = 10; config.device.role = meshtastic_Config_DeviceConfig_Role_CLIENT; - mockNodeDB->setCachedNode(kTargetNode); // the direct-response target + mockNodeDB->setCachedNode(kTargetNode); // the direct-response target + // Signed-only replay gate (default) requires the target be a known signer to be served. + mockNodeDB->cachedNodeForTest().bitfield |= NODEINFO_BITFIELD_HAS_XEDDSA_SIGNED_MASK; mockNodeDB->setSignerHotNode(kRemoteNode, "victim-real"); // the requester is a known signer MockRouter mockRouter; @@ -649,6 +651,7 @@ static void test_tm_nodeinfo_directResponse_ignoresUnsignedSignerIdentity(void) service = &mockService; TrafficManagementModuleTestShim module; + module.dropNodeInfoCacheForTest(); // exercise the NodeDB fallback path this test was written for meshtastic_MeshPacket request = makeNodeInfoPacket(kRemoteNode, "attacker-name", "atk"); request.to = kTargetNode; request.decoded.want_response = true; @@ -1044,8 +1047,12 @@ static void test_tm_nodeinfo_reconcile_seedsFromHotStoreButNeverServes(void) TEST_ASSERT_EQUAL_INT(static_cast(ProcessMessage::CONTINUE), static_cast(module.handleReceived(request))); TEST_ASSERT_EQUAL_UINT32(0, static_cast(mockRouter.sentPackets.size())); - // A genuinely observed frame (key matches the NodeDB pin) makes it servable. - module.handleReceived(makeNodeInfoPacketWithKey(kTargetNode, "hot-name", 0x77)); + // A genuinely observed frame (key matches the NodeDB pin) makes it servable. The node is + // a known signer, so per #11035 only a signature-verified frame may drive cache writes - + // mark it as Router-verified, as the real receive path would. + meshtastic_MeshPacket observed = makeNodeInfoPacketWithKey(kTargetNode, "hot-name", 0x77); + observed.xeddsa_signed = true; + module.handleReceived(observed); request.id = 0xCCCC0002; TEST_ASSERT_EQUAL_INT(static_cast(ProcessMessage::STOP), static_cast(module.handleReceived(request))); TEST_ASSERT_EQUAL_UINT32(1, static_cast(mockRouter.sentPackets.size())); From 8fdadc25fec621685d8f9a5f468724fc759f2230 Mon Sep 17 00:00:00 2001 From: nomdetom Date: Fri, 17 Jul 2026 20:45:37 +0100 Subject: [PATCH 29/44] trunk fml --- src/mesh/WarmNodeStore.h | 2 +- src/modules/TrafficManagementModule.cpp | 24 +++++++++------------- src/modules/TrafficManagementModule.h | 4 ++-- test/test_traffic_management/test_main.cpp | 8 ++++---- 4 files changed, 17 insertions(+), 21 deletions(-) diff --git a/src/mesh/WarmNodeStore.h b/src/mesh/WarmNodeStore.h index 10383e0e8c8..ffcc08e3142 100644 --- a/src/mesh/WarmNodeStore.h +++ b/src/mesh/WarmNodeStore.h @@ -94,7 +94,7 @@ inline bool warmSignerOf(const WarmNodeEntry &e) #define WARM_FLASH_PAGE_SIZE 4096u #define WARM_FLASH_PAGES 3u #define WARM_FLASH_REGION_BASE (0xED000u - WARM_FLASH_PAGES * WARM_FLASH_PAGE_SIZE) // 0xEA000 -#define WARM_FLASH_PAGE_ADDR(i) (WARM_FLASH_REGION_BASE + (i) * WARM_FLASH_PAGE_SIZE) +#define WARM_FLASH_PAGE_ADDR(i) (WARM_FLASH_REGION_BASE + (i)*WARM_FLASH_PAGE_SIZE) #endif class WarmNodeStore diff --git a/src/modules/TrafficManagementModule.cpp b/src/modules/TrafficManagementModule.cpp index 38d7cd94d87..26578c3c5a4 100644 --- a/src/modules/TrafficManagementModule.cpp +++ b/src/modules/TrafficManagementModule.cpp @@ -196,8 +196,7 @@ TrafficManagementModule::TrafficManagementModule() : MeshModule("TrafficManageme #endif // TRAFFIC_MANAGEMENT_CACHE_SIZE > 0 #if TMM_HAS_NODEINFO_CACHE - TM_LOG_INFO("Allocating NodeInfo cache: %u entries, %u bytes (flat array)", - static_cast(nodeInfoTargetEntries()), + TM_LOG_INFO("Allocating NodeInfo cache: %u entries, %u bytes (flat array)", static_cast(nodeInfoTargetEntries()), static_cast(nodeInfoTargetEntries() * sizeof(NodeInfoPayloadEntry))); #if defined(ARCH_ESP32) && defined(BOARD_HAS_PSRAM) @@ -552,9 +551,8 @@ const TrafficManagementModule::NodeInfoPayloadEntry *TrafficManagementModule::fi * WarmNodeStore's keyed-first admission. NodeInfo traffic is low-rate, so the * O(n) scan is negligible. */ -TrafficManagementModule::NodeInfoPayloadEntry *TrafficManagementModule::findOrCreateNodeInfoEntry(NodeNum node, - bool *usedEmptySlot, - bool spareMembers) +TrafficManagementModule::NodeInfoPayloadEntry * +TrafficManagementModule::findOrCreateNodeInfoEntry(NodeNum node, bool *usedEmptySlot, bool spareMembers) { if (usedEmptySlot) *usedEmptySlot = false; @@ -583,8 +581,8 @@ TrafficManagementModule::NodeInfoPayloadEntry *TrafficManagementModule::findOrCr // Eviction tier (lower loses first): 0 keyless, 1 TOFU key, 2 signer-proven key; // +3 when the node is a NodeDB member - the cache must not shed a NodeDB-tier // identity while it still holds opportunistic strangers. - const uint8_t tier = static_cast(((e.user.public_key.size != 32) ? 0 : (e.keySignerProven ? 2 : 1)) + - (e.isMember ? 3 : 0)); + const uint8_t tier = + static_cast(((e.user.public_key.size != 32) ? 0 : (e.keySignerProven ? 2 : 1)) + (e.isMember ? 3 : 0)); // Modular observation age; saturation keeps real ages far below 0xFF, so a // never-observed entry scored at 0xFF is always the oldest in its tier. const uint8_t age = e.hasObserved ? static_cast(nowObs - e.obsTick) : 0xFF; @@ -765,7 +763,7 @@ void TrafficManagementModule::onNodeKeyCommitted(NodeNum node, const uint8_t key entry->keySignerProven = false; if (proven) entry->keySignerProven = true; - // hasObserved/obsTick untouched: a key commit is knowledge, not an observation. + // hasObserved/obsTick untouched: a key commit is knowledge, not an observation. #else (void)node; (void)key32; @@ -1612,8 +1610,8 @@ bool TrafficManagementModule::shouldRespondToNodeInfo(const meshtastic_MeshPacke // the maintenance sweep clears it once the window passes (saturation), so this modular // tick compare can never see an aliased age. Only cache hits are gated here; the NodeDB // fallback was gated above on last_heard. - if (hasCachedUser && (!cachedHasObserved || - static_cast(currentObsTick() - cachedObsTick) > kNodeInfoMaxServeAgeTicks)) { + if (hasCachedUser && + (!cachedHasObserved || static_cast(currentObsTick() - cachedObsTick) > kNodeInfoMaxServeAgeTicks)) { TM_LOG_DEBUG("NodeInfo cache entry for 0x%08x not freshly observed, not responding", p->to); return false; } @@ -1650,10 +1648,8 @@ bool TrafficManagementModule::shouldRespondToNodeInfo(const meshtastic_MeshPacke // but at the cost of throughput on legitimate multi-target responders, so it is left // per-target by choice. const bool throttled = - usedFallback - ? (cachedFallbackResponseMs != 0 && (clockMs() - cachedFallbackResponseMs) < kNodeInfoResponseThrottleMs) - : (cachedHasResponded && - static_cast(currentRespTick() - cachedRespTick) < kNodeInfoThrottleTicks); + usedFallback ? (cachedFallbackResponseMs != 0 && (clockMs() - cachedFallbackResponseMs) < kNodeInfoResponseThrottleMs) + : (cachedHasResponded && static_cast(currentRespTick() - cachedRespTick) < kNodeInfoThrottleTicks); if (throttled) { TM_LOG_DEBUG("NodeInfo response throttled for 0x%08x", p->to); return true; diff --git a/src/modules/TrafficManagementModule.h b/src/modules/TrafficManagementModule.h index 47070448b5e..22559883ddf 100644 --- a/src/modules/TrafficManagementModule.h +++ b/src/modules/TrafficManagementModule.h @@ -302,8 +302,8 @@ class TrafficManagementModule : public MeshModule, private concurrency::OSThread // the 60 s maintenance sweep clears each bit once its window passes ("saturation"), so a // stamp is never read anywhere near its aliasing horizon. +-1 tick granularity error // (+-3 min on a 6 h gate, +-5 s on a 30 s throttle) is noise for these windows. - static constexpr uint32_t kNodeInfoObsTickMs = 180000UL; // 3 min/tick - static constexpr uint32_t kNodeInfoRespTickMs = 5000UL; // 5 s/tick + static constexpr uint32_t kNodeInfoObsTickMs = 180000UL; // 3 min/tick + static constexpr uint32_t kNodeInfoRespTickMs = 5000UL; // 5 s/tick static constexpr uint8_t kNodeInfoMaxServeAgeTicks = 120; // 6 h serve window static constexpr uint8_t kNodeInfoThrottleTicks = 6; // 30 s throttle window diff --git a/test/test_traffic_management/test_main.cpp b/test/test_traffic_management/test_main.cpp index dd001ecabcd..8755d7cfb0c 100644 --- a/test/test_traffic_management/test_main.cpp +++ b/test/test_traffic_management/test_main.cpp @@ -189,12 +189,12 @@ class TrafficManagementModuleTestShim : public TrafficManagementModule { public: using TrafficManagementModule::alterReceived; + using TrafficManagementModule::dropNodeInfoCacheForTest; using TrafficManagementModule::flushCache; using TrafficManagementModule::handleReceived; - using TrafficManagementModule::dropNodeInfoCacheForTest; using TrafficManagementModule::markKeySignerProvenForTest; - using TrafficManagementModule::peekNodeInfoFlagsForTest; using TrafficManagementModule::peekCachedRole; + using TrafficManagementModule::peekNodeInfoFlagsForTest; using TrafficManagementModule::runOnce; bool ignoreRequestFlag() const { return ignoreRequest; } @@ -1483,8 +1483,8 @@ static void test_tm_nodeinfo_sweepMembershipMarking(void) mockNodeDB->rollHotStore(); module.runOnce(); flags = module.peekNodeInfoFlagsForTest(kTargetNode); - TEST_ASSERT_TRUE(flags >= 0); // entry persists (no TTL) ... - TEST_ASSERT_FALSE(flags & kFlagMember); // ... but is no longer pinned as a member + TEST_ASSERT_TRUE(flags >= 0); // entry persists (no TTL) ... + TEST_ASSERT_FALSE(flags & kFlagMember); // ... but is no longer pinned as a member } #endif From 596529c3bdc22202e802fb41ce811a8dd48946a9 Mon Sep 17 00:00:00 2001 From: nomdetom Date: Fri, 17 Jul 2026 22:34:29 +0100 Subject: [PATCH 30/44] Doc tidyup --- .notes/tmm-super-superset-reconciliation.md | 39 -- docs/node_info_stores.md | 156 +++++ src/mesh/NodeDB.cpp | 13 + src/mesh/NodeDB.h | 8 + src/mesh/Router.cpp | 5 +- src/modules/TrafficManagementModule.cpp | 605 ++++++++------------ src/modules/TrafficManagementModule.h | 434 +++++--------- test/test_traffic_management/test_main.cpp | 91 +++ 8 files changed, 645 insertions(+), 706 deletions(-) delete mode 100644 .notes/tmm-super-superset-reconciliation.md create mode 100644 docs/node_info_stores.md diff --git a/.notes/tmm-super-superset-reconciliation.md b/.notes/tmm-super-superset-reconciliation.md deleted file mode 100644 index 716842596f7..00000000000 --- a/.notes/tmm-super-superset-reconciliation.md +++ /dev/null @@ -1,39 +0,0 @@ -# tmm-super-superset: reconciliation of tmm-fix-2 and tmm-fix-superset - -Two branches implemented the same design brief independently on top of the shared -`tmm-fix` series: keep the TrafficManagement NodeInfo cache a superset of NodeDB -(hot store ∪ warm tier), with tick-based clocks, write-through hooks, and -purge-on-removal. This branch combines them, taking each divergence on its merits. -Base: `origin/develop` (8ebdae9) + the rebased `tmm-fix` series, plus cherry-picks -of upstream #11035 (dependency), PR #11048 (2 commits), PR #11046, PR #11047 (3). - -## Decision matrix - -| # | Divergence | tmm-fix-2 | tmm-fix-superset | Taken | Why | -|---|-----------|-----------|------------------|-------|-----| -| 1 | Native test coverage | `TMM_HAS_NODEINFO_CACHE` macro: cache builds on heap under ARCH_PORTDUINO+PIO_UNIT_TESTING, so all cache tests run in CI; `dropNodeInfoCacheForTest()` for fallback-path tests | none - cache tests compile only for ESP32+PSRAM (hardware-only) | **tmm-fix-2** | CI coverage of the security-relevant paths was a review finding; hardware-only tests rot. | -| 2 | Warm-tier key pin | `NodeDB::copyPublicKeyAuthoritative()` (hot→warm), reused by `copyPublicKey()`; TMM pins against it | inline `nodeDB->warmStore.copyKey()` second check inside TMM | **tmm-fix-2** | Encapsulation: one authoritative-tier lookup owned by NodeDB; TMM doesn't reach into NodeDB internals; `copyPublicKey` reuses it so the tier order can never desync. | -| 3 | Retention | third tick clock (`retTick`, 1 h) + timed eviction: 7 d keyed / 6 h keyless for non-members; members re-stamped (keep-alive) | **no timed eviction at all**: slots reclaimed only by tiered LRU on insert or explicit purge; LRU age = `obsTick` with never-observed scored 0xFF (oldest) | **tmm-fix-superset** | Matches the design owner's explicit dislike of the 7-day eviction. Wrap-safety needs only obs/resp saturation (retention was the only reason `retTick` existed). A quiet entry keeps value (key pool, name rehydration). Drops a byte per entry. | -| 4 | Membership + seeding cadence | full two-direction reconcile every 60 s sweep (bitmap-marked clear pass), `spareMembers` eviction guard | per-entry membership refresh every sweep (entry→NodeDB `contains` checks); hot-store-only seeding, boot + hourly | **mixed** | Superset's cadence (cheap member-bit refresh each sweep; heavy seeding hourly + boot, write-through hooks give immediacy) is better balanced. But its seeding covers the hot store only - this branch keeps tmm-fix-2's warm-tier key-only seeding leg (via `WarmNodeStore::entryAt`) so the invariant is genuinely TMM ⊇ hot ∪ warm, and keeps the `spareMembers` guard because with warm seeding the member population can exceed the cache. | -| 5 | updateUser write-through | `signerKnown = nodeInfoLiteHasXeddsaSigned(info)`; provenance-carry via explicit `provenBefore` | `signerKnown = isVerifiedSignerForKey(nodeId, p.key)` (key-matched); simpler keyChanged/reset logic; self-skip at call site | **tmm-fix-superset** | Key-matched signer transfer is the stricter, self-documenting form; the simpler provenance logic is behaviorally equivalent (keyless commits leave the flag untouched, key changes reset it). Self-skip kept in both places (cheap, defensive). | -| 6 | Key-only commit hook | absent | `onNodeKeyCommitted(node, key32, proven)` + call sites in Router (admin-key learn) and KeyVerificationModule (manual verification, proven=true) | **tmm-fix-superset** | Genuine coverage gap in tmm-fix-2: both sites write `node->public_key` directly, bypassing `updateUser`. Manual verification is the strongest provenance the cache can carry. | -| 7 | Reset purging | `purgeNode()` from `removeNodeByNum()` only | also `purgeAll()` from `factoryReset()` and `resetNodes()` | **tmm-fix-superset** | Removal-is-full-removal applies to bulk resets too; don't rely on the post-reset reboot. | -| 8 | purgeNode implementation | open-coded scans of both arrays | reuses `findEntry`/`findNodeInfoEntryMutable`, logs the purge | **tmm-fix-superset** | Less duplication; the log line is genuinely useful for a user-initiated action. | -| 9 | Tick constants home | file-scope constexprs + free functions in the .cpp | header, next to the existing UnifiedCache tick helpers (`currentObsTick()` style) | **tmm-fix-superset** | Consistency with the established tick idiom in the same class. | -| 10 | `lastObservedRxTime` | dropped (was only echoed in one debug log) | kept for the debug log | **tmm-fix-2** | 4 B × 2000 = 8 KB of PSRAM for a log field; the tick-age log line carries the same signal. | -| 11 | Test hooks | `dropNodeInfoCacheForTest()` | `peekNodeInfoFlagsForTest()` (flag introspection) | **both** | Orthogonal: one enables fallback-path tests in native builds, the other lets saturation/membership tests assert sweep effects directly. | -| 12 | Fallback/serve/throttle gates | identical semantics | identical semantics (cosmetic differences) | tmm-fix-2 text | Same behavior; kept the wording already on this branch, with superset's better "stale vs never observed" log distinction folded in. | - -## Entry layout after reconciliation - -`node(4) + meshtastic_User(116) + obsTick + respTick + sourceChannel + -decodedBitfield + flags(1)` = 125 → 128 B padded (2000 entries = 256 KB PSRAM, -8 KB below the original develop footprint). Flag byte: hasDecodedBitfield, -keySignerProven, hasFullUser, hasObserved, hasResponded, isMember (2 spare bits). - -## Tests - -Union of both suites: tmm-fix-2's warm-pin, hot/warm seeding, updateUser hook, -and removal-purge tests (adapted: the TTL-based retention test is superseded by -no-TTL semantics) + tmm-fix-superset's key-hook provenance, tick-saturation, and -sweep-membership tests (adapted to run natively under TMM_HAS_NODEINFO_CACHE). diff --git a/docs/node_info_stores.md b/docs/node_info_stores.md new file mode 100644 index 00000000000..192f34b2722 --- /dev/null +++ b/docs/node_info_stores.md @@ -0,0 +1,156 @@ +# NodeInfo stores: the base and extended databases + +This document is an overview of the node-identity and traffic-state databases that the +TrafficManagementModule (TMM) either owns or leans on. There are four stores in play, +ordered here as a node's identity flows through them: + +1. **NodeDB hot store** - the authoritative `NodeInfoLite` array. +2. **Warm tier** (`WarmNodeStore`) - minimal persisted records for hot-store evictees. +3. **TMM unified cache** (base) - TMM's own flat 10-byte-per-node traffic-shaping state. +4. **TMM NodeInfo payload cache** (extended) - the ephemeral third identity tier: full + `User` payloads plus direct-response metadata, in PSRAM. + +Sources of truth: `src/mesh/NodeDB.{h,cpp}`, `src/mesh/WarmNodeStore.h`, +`src/modules/TrafficManagementModule.{h,cpp}`, sizing in `src/mesh/mesh-pb-constants.h`. + +--- + +## 1. NodeDB hot store (authoritative) + +- **What:** the classic `meshNodes` array of `meshtastic_NodeInfoLite` - full identity + (names, role, position, telemetry pointers, public key, bitfield flags such as + `HAS_XEDDSA_SIGNED`). Everything else in this document is a cache or a fallback for it. +- **Capacity:** `MAX_NUM_NODES`, per platform - 250 on native, 120 on nRF52840/generic + ESP32, 10 on STM32WL (see `mesh-pb-constants.h`). +- **Eviction:** oldest non-protected node when full (`getOrCreateMeshNode`). On eviction + the node's essentials are **absorbed into the warm tier** (see §2); on re-admission the + warm record is rehydrated back (`take()`), including the signer bit. +- **Persistence:** the node database file, saved on the usual NodeDB cadence. +- **Authority:** key pinning (`updateUser`'s "Public Key mismatch" drop), signer + provenance, and identity content all originate here. The lookup helpers that other + stores mirror: + - `copyPublicKeyAuthoritative(n, out)` - hot store, then warm tier. The pin reference + for caches; never consults opportunistic caches. + - `copyPublicKey(n, out)` - the above, then **TMM's NodeInfo cache as last resort** + (extends the encrypt-to pool for nodes both tiers have forgotten). + - `isVerifiedSignerForKey(n, key32)` - key-matched signer verdict across hot + warm. + - `isKnownXeddsaSigner(n)` - key-agnostic "should this node's signable traffic arrive + signed", across hot + warm. Gates that check only the hot store would let a + warm-evicted signer be impersonated with unsigned frames. + - `getNodeRole(n)` - hot store, then the role cached in the warm tier, else `CLIENT`. + +## 2. Warm tier - `WarmNodeStore` (NodeDB-owned) + +- **What:** the "long-tail" second tier. When a node ages out of the hot store, a minimal + record survives so DMs keep encrypting: the key is expensive to re-learn; everything + else rebuilds from traffic in seconds. +- **Entry:** exactly 40 bytes - `num(4) | last_heard(4) | public_key(32)`. The low 7 bits + of `last_heard` are stolen for metadata (role: 4 bits, protected category: 2, signer + bit: 1), leaving ~128 s recency resolution - plenty for LRU ranking. +- **Capacity:** `WARM_NODE_COUNT` (100 on constrained parts; platform-tiered). +- **Eviction:** LRU by `last_heard`, with keyed entries outranking keyless; keyless + candidates never displace keyed entries. +- **Persistence:** nRF52840 uses a 12 KB raw-flash record-ring below LittleFS + (append/replay/compact); everywhere else `/prefs/warm.dat`. +- **Membership invariant:** a node lives in the hot **XOR** warm tier. `take()` removes + the warm record when the node is re-admitted hot, restoring role/protected/signer bits. + +## 3. TMM unified cache (base, traffic state) + +- **What:** TMM's own flat array of packed 10-byte `UnifiedCacheEntry` records - the + per-node state behind position dedup, rate limiting, unknown-packet filtering, plus two + piggybacked caches: + - `next_hop` - last-byte relay hint, written only from ACK-confirmed NextHopRouter + decisions (no TTL; keeps the slot alive across sweeps). + - a **4-bit device role** (split across the top bits of two count bytes) - the _third_ + fallback for role-aware policy after the hot store and warm tier, surviving even + total NodeDB eviction. Read through `resolveSenderRole()`, refreshed by + `updateCachedRoleFromNodeInfo()` on observed NodeInfo. +- **Entry layout:** `node(4) | pos_fingerprint(1) | rate_count(1) | unknown_count(1) | +pos_time(1) | rate_unknown_time(1) | next_hop(1)` = 10 bytes, all platforms. + Timestamps are free-running modular ticks (uint8 / nibbles) with presence carried by + non-zero sentinels - no epochs, no absolute time. +- **Capacity:** `TRAFFIC_MANAGEMENT_CACHE_SIZE`, per memory class: 2048 (PSRAM S3 / + native), 500 (medium), 400 (small), 250 (nRF52840 - deliberately class-deviant for + heap headroom), 0 when `HAS_TRAFFIC_MANAGEMENT=0`. Variant-overridable. +- **Eviction:** linear scan; insertion on a full cache evicts the stalest entry, + preferring entries without a `next_hop` hint. +- **Persistence:** none - RAM/PSRAM only, rebuilt from traffic. + +## 4. TMM NodeInfo payload cache (extended, the ephemeral third tier) + +- **What:** a flat PSRAM array of `NodeInfoPayloadEntry` - the full cached `User` + payload (names, role, key) plus the metadata needed to serve **spoofed direct + NodeInfo replies** on a target's behalf, independent of NodeDB. Also the last-resort + key source for `NodeDB::copyPublicKey()`. +- **Availability:** `TMM_HAS_NODEINFO_CACHE` - ESP32 with PSRAM (production home; + 2000 entries is too large for MCU internal RAM), plus native unit-test builds on the + plain heap so the trust/retention paths run in CI. +- **Entry:** `node`, `user` (full nanopb `User`), tick stamps (`obsTick` 3 min/tick, + `respTick` 5 s/tick), `sourceChannel`, `decodedBitfield`, and packed 1-bit flags: + `hasDecodedBitfield`, `keySignerProven`, `hasObserved`, `hasResponded`, `hasFullUser`, + `isMember`. +- **Capacity:** `kNodeInfoCacheEntries = 2000`, linear scan (NodeInfo traffic is + low-rate). +- **Persistence:** none - this tier is deliberately ephemeral; it reconstructs from + NodeDB seeding plus observed traffic after every boot. + +### Trust & provenance model + +- **Key pin, three layers deep:** an incoming NodeInfo key is checked against + `copyPublicKeyAuthoritative()` (hot then warm - the same coverage as + `updateUser`'s own pin), and, failing NodeDB knowledge, against the cache's **own + previously cached key** (TOFU pin). Mismatches are dropped, never overwritten. A frame + advertising _our own_ key is dropped outright (impersonation). +- **`keySignerProven`:** set when a frame's XEdDSA signature was router-verified + (`mp.xeddsa_signed`) or when NodeDB already knew the node as a signer **for the same + key** (`isVerifiedSignerForKey`). Monotonic per slot; a changed key resets it. +- **Unsigned-identity gate:** a NodeInfo arriving _unsigned_ from a node we have ever + verified as a signer - per `NodeDB::isKnownXeddsaSigner()`, which covers hot **and + warm** tiers - drives no cache, role, or `updateUser()` write. (Warm coverage matters: + a signer evicted to the warm tier would otherwise be forgeable with its own public key + until re-heard. The same rule guards `Router::checkXeddsaReceivePolicy`'s + unsigned-broadcast drop.) +- **Serve gate honesty:** only a genuinely _heard_ NODEINFO frame stamps + `obsTick`/`hasObserved`. Seeding and write-through are knowledge, not observation - + they can never make a silent node look alive to the replay path. The serve window + (6 h) and per-target throttle (30 s) are enforced by the sweep-cleared flag bits. + +### Consistency with NodeDB (anti-entropy) + +Three mechanisms keep this tier a superset of NodeDB's identities: + +| Mechanism | When | What | +| --------------------------------------------------------------------- | ----------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Write-through hooks (`onNodeIdentityCommitted`, `onNodeKeyCommitted`) | on every NodeDB identity/key commit | immediate upsert; merges rather than overwrites - a keyless commit never costs the cache a learned TOFU key | +| Reconcile sweep (`reconcileNodeInfoFromNodeDBLocked`) | boot seed, then hourly | walks hot store (full identity) + warm tier (key-only records); adopts NodeDB content under the same keyless-merge rule; transfers signer verdicts only key-matched | +| Membership refresh | every 60 s sweep | re-checks hot+warm membership per entry; `isMember` is the keep-alive | +| Purge hooks (`purgeNode`, `purgeAll`) | NodeDB node removal / reset | clear both the unified cache and the NodeInfo cache, each under its own compile guard | + +**Retention:** no timed eviction. Slots die only by LRU displacement on insert, ranked by +trust tiers - members and signer-proven keys are stickiest; the seeding pass additionally +refuses to churn one member out for another (`spareMembers`). + +--- + +## How a lookup falls through the tiers + +```text +identity/role/key consumer + │ + ▼ + 1. hot store (NodeInfoLite) full identity, authoritative + │ miss + ▼ + 2. warm tier (WarmNodeStore) key + role/protected/signer bits, persisted + │ miss + ▼ + 3. TMM NodeInfo cache (PSRAM) full User payloads + TOFU/proven keys, ephemeral + │ miss (role-only: 4-bit role in the unified cache) + ▼ + defaults (no key; role = CLIENT) +``` + +The unified cache (§3) sits beside this chain rather than in it: it is traffic-shaping +state keyed by the same NodeNum, whose role bits act as the final role fallback when all +three identity tiers miss. diff --git a/src/mesh/NodeDB.cpp b/src/mesh/NodeDB.cpp index 79c97db1fef..d07ddb41943 100644 --- a/src/mesh/NodeDB.cpp +++ b/src/mesh/NodeDB.cpp @@ -3772,6 +3772,19 @@ bool NodeDB::isVerifiedSignerForKey(NodeNum n, const uint8_t *key32) return false; } +bool NodeDB::isKnownXeddsaSigner(NodeNum n) +{ + // A node lives in the hot XOR warm tier, so the hot verdict is final when present. + const meshtastic_NodeInfoLite *info = getMeshNode(n); + if (info) + return nodeInfoLiteHasXeddsaSigned(info); +#if WARM_NODE_COUNT > 0 + return warmStore.isVerifiedSigner(n); +#else + return false; +#endif +} + meshtastic_Config_DeviceConfig_Role NodeDB::getNodeRole(NodeNum n) { const meshtastic_NodeInfoLite *info = getMeshNode(n); diff --git a/src/mesh/NodeDB.h b/src/mesh/NodeDB.h index 87bd61cfbe1..54f8085022c 100644 --- a/src/mesh/NodeDB.h +++ b/src/mesh/NodeDB.h @@ -376,6 +376,14 @@ class NodeDB // key signer-proven from what NodeDB already knows, without re-verifying a signature. bool isVerifiedSignerForKey(NodeNum n, const uint8_t *key32); + /// True when we have ever verified an XEdDSA signature from node `n`, per either NodeDB + /// tier: the hot store's signed bitfield, or the signer bit the warm tier cached at + /// eviction. Key-agnostic - answers "should this node's signable traffic arrive signed", + /// not "is this particular key proven" (that is isVerifiedSignerForKey). Gates that only + /// consult the hot store would let a warm-evicted signer be impersonated with unsigned + /// frames until it happens to be re-heard. + bool isKnownXeddsaSigner(NodeNum n); + /// Resolve a node's device role - hot store (with user) first, then the role /// cached in the warm tier, else CLIENT. Lets role-aware policy keep firing for /// nodes that have aged out of the hot store. diff --git a/src/mesh/Router.cpp b/src/mesh/Router.cpp index 24b4f9655b6..83321fdc403 100644 --- a/src/mesh/Router.cpp +++ b/src/mesh/Router.cpp @@ -488,8 +488,9 @@ bool checkXeddsaReceivePolicy(meshtastic_MeshPacket *p) // adding XEDDSA_SIGNATURE_FIELD_BYTES to that unsigned base mirrors it exactly, whatever // fields the Data carried. Unicast/PKI packets and broadcasts too big to carry a signature // are never signed, so they must not be hard-failed here even for a known signer. - const meshtastic_NodeInfoLite *node = nodeDB->getMeshNode(p->from); - if (node && nodeInfoLiteHasXeddsaSigned(node) && !p->pki_encrypted && isBroadcast(p->to)) { + // isKnownXeddsaSigner consults the warm tier too: a signer evicted from the hot store + // must not become impersonatable via unsigned broadcasts until it is re-heard. + if (nodeDB->isKnownXeddsaSigner(p->from) && !p->pki_encrypted && isBroadcast(p->to)) { size_t canonicalSize; if (!pb_get_encoded_size(&canonicalSize, &meshtastic_Data_msg, &p->decoded)) return true; // can't size it; never drop on a sizing failure diff --git a/src/modules/TrafficManagementModule.cpp b/src/modules/TrafficManagementModule.cpp index 26578c3c5a4..c8fffac2375 100644 --- a/src/modules/TrafficManagementModule.cpp +++ b/src/modules/TrafficManagementModule.cpp @@ -32,67 +32,39 @@ namespace constexpr uint32_t kMaintenanceIntervalMs = 60 * 1000UL; // Cache cleanup interval -// NodeInfo direct response: enforced maximum hops by device role -// Both use maxHops logic (respond when hopsAway <= threshold) -// Config value is clamped to these role-based limits -// Note: nodeinfo_direct_response must also be enabled for this to take effect +// NodeInfo direct response: role-enforced hop ceilings (respond when hopsAway <= threshold); +// config can only tighten them. nodeinfo_direct_response must also be enabled. constexpr uint32_t kRouterDefaultMaxHops = 3; // Routers: max 3 hops (can set lower via config) constexpr uint32_t kClientDefaultMaxHops = 0; // Clients: direct only (cannot increase) -// NodeInfo direct-response safety limits. -// -// The per-entry clocks live in the header (kNodeInfoObsTickMs / kNodeInfoRespTickMs and the -// currentObsTick()/currentRespTick() helpers) beside the UnifiedCache tick idiom they share. -// -// Staleness: never spoof a NodeInfo reply on behalf of a node we have not actually -// heard from within this window. Without it, a cached (or forged) entry is served -// indefinitely for a long-gone node while the genuine request is suppressed - the -// requestor sees a fresh-looking answer for a node that may no longer exist. -// The cache path enforces this in ticks (kNodeInfoMaxServeAgeTicks x kNodeInfoObsTickMs -// = 6 h, see the header); the NodeDB fallback path uses this seconds form of the same window. +// Staleness window: never spoof a reply for a node not actually heard within it, or a cached +// entry would be served indefinitely for a long-gone node while the genuine request is +// suppressed. The cache path enforces the same 6 h in ticks (kNodeInfoMaxServeAgeTicks, header). constexpr uint32_t kNodeInfoMaxServeAgeSecs = 6UL * 60UL * 60UL; // 6 h (NodeDB fallback path) -// Entries are never evicted on a timer. Wrap-correctness of the tick stamps is guaranteed by -// the sweep's presence-bit saturation (see runOnce), not by freeing slots, and a slot that -// has gone quiet still holds value: its key remains a last-resort encryption source -// (NodeDB::copyPublicKey) and its User payload rehydrates a re-admitted node's name. Slots -// are reclaimed only by trust/membership-tiered LRU on insert, or by an explicit purge when -// the user deliberately removes the node. - -// Throttle: emit at most one spoofed direct reply per target node per this interval. -// Direct responses are otherwise un-throttled (they STOP the request before the -// per-sender rate limiter runs) and the reply target is attacker-controlled, so an -// attacker could otherwise drive unbounded local transmissions / reflected floods. -// This ms form throttles the NodeDB fallback path (module-global uint32 stamp); the cache -// path throttles per entry in ticks (kNodeInfoThrottleTicks, header). +// Throttle: at most one spoofed reply per target per interval - the reply target is +// attacker-controlled, so this bounds reflected floods. This ms form covers the NodeDB +// fallback path; the cache path throttles per entry in ticks (kNodeInfoThrottleTicks, header). constexpr uint32_t kNodeInfoResponseThrottleMs = 30UL * 1000UL; // 30 s -/** - * Convert seconds to milliseconds with overflow protection. - */ +/// Convert seconds to milliseconds with overflow protection. uint32_t secsToMs(uint32_t secs) { - uint64_t ms = static_cast(secs) * 1000ULL; - if (ms > UINT32_MAX) + uint64_t milliseconds = static_cast(secs) * 1000ULL; + if (milliseconds > UINT32_MAX) return UINT32_MAX; - return static_cast(ms); + return static_cast(milliseconds); } -// Advertised role of the originating node (from NodeDB), or CLIENT (no exception) if unknown. -// Position filtering grants two role exceptions: trackers may refresh duplicates hourly, and -// lost-and-found is throttled only to the shortest dedup window. Both are still subject to -// the channel-precision ceiling in alterReceived(). +/// Advertised role of the originating node, resolved hot store -> warm tier -> CLIENT, so the +/// position dedup role exceptions keep firing for nodes aged out of the hot store. meshtastic_Config_DeviceConfig_Role originRole(NodeNum from) { - // Resolve via NodeDB: hot store (with user) → warm-tier cached role → CLIENT. The - // warm fallback keeps role exceptions firing for trackers/etc. aged out of the hot store. return nodeDB ? nodeDB->getNodeRole(from) : meshtastic_Config_DeviceConfig_Role_CLIENT; } -/** - * Clamp precision to a valid dedup range. - * Invalid values use the module default precision. - */ +/// Clamp precision to a valid dedup range. +/// Invalid values use the module default precision. uint8_t sanitizePositionPrecision(uint8_t precision) { if (precision > 0 && precision <= 32) @@ -106,20 +78,16 @@ uint8_t sanitizePositionPrecision(uint8_t precision) return 32; } -/** - * Saturating increment for uint8_t counters. - * Prevents overflow by capping at UINT8_MAX (255). - */ +/// Saturating increment for uint8_t counters. +/// Prevents overflow by capping at UINT8_MAX (255). inline void saturatingIncrement(uint8_t &counter) { if (counter < UINT8_MAX) counter++; } -/** - * Return a short human-readable name for common port numbers. - * Falls back to "port:" for unknown ports. - */ +/// Return a short human-readable name for common port numbers. +/// Falls back to "port:" for unknown ports. const char *portName(int portnum) { switch (portnum) { @@ -160,6 +128,7 @@ TrafficManagementModule *trafficManagementModule; // Constructor // ============================================================================= +/// Allocate the unified cache (and, where available, the PSRAM NodeInfo cache) and start the sweep. TrafficManagementModule::TrafficManagementModule() : MeshModule("TrafficManagement"), concurrency::OSThread("TrafficManagement") { // Module configuration @@ -222,14 +191,12 @@ TrafficManagementModule::TrafficManagementModule() : MeshModule("TrafficManageme setIntervalFromNow(kMaintenanceIntervalMs); } -// Cache may have been allocated via ps_calloc (PSRAM, C allocator) or new[] (heap). -// Must use the matching deallocator: free() for ps_calloc, delete[] for new[]. +/// Both caches may come from ps_calloc (PSRAM, C allocator) or new[] (heap); +/// each must be released with the matching deallocator. TrafficManagementModule::~TrafficManagementModule() { #if TRAFFIC_MANAGEMENT_CACHE_SIZE > 0 if (cache) { - // Cache may be from ps_calloc (PSRAM, C allocator) or new[] (heap). - // Use the matching deallocator for the allocation source. if (cacheFromPsram) free(cache); else @@ -282,9 +249,7 @@ void TrafficManagementModule::incrementStat(uint32_t *field) // Flat Unified Cache Operations // ============================================================================= -/** - * Find an existing entry for the given node (linear scan). - */ +/// Find an existing entry for the given node (linear scan). TrafficManagementModule::UnifiedCacheEntry *TrafficManagementModule::findEntry(NodeNum node) { #if TRAFFIC_MANAGEMENT_CACHE_SIZE == 0 @@ -314,15 +279,20 @@ int TrafficManagementModule::peekCachedRole(NodeNum node) #endif } +// The two caches are compile-time independent (TMM_HAS_NODEINFO_CACHE keys on PSRAM, the +// unified cache on a per-variant size that may be overridden to 0), so each is purged under +// its own guard - a build with only one of them must still forget deleted nodes. void TrafficManagementModule::purgeNode(NodeNum node) { -#if TRAFFIC_MANAGEMENT_CACHE_SIZE > 0 +#if TRAFFIC_MANAGEMENT_CACHE_SIZE > 0 || TMM_HAS_NODEINFO_CACHE if (node == 0) return; concurrency::LockGuard guard(&cacheLock); +#if TRAFFIC_MANAGEMENT_CACHE_SIZE > 0 UnifiedCacheEntry *entry = findEntry(node); if (entry) memset(entry, 0, sizeof(UnifiedCacheEntry)); +#endif #if TMM_HAS_NODEINFO_CACHE NodeInfoPayloadEntry *info = findNodeInfoEntryMutable(node); if (info) @@ -336,10 +306,12 @@ void TrafficManagementModule::purgeNode(NodeNum node) void TrafficManagementModule::purgeAll() { -#if TRAFFIC_MANAGEMENT_CACHE_SIZE > 0 +#if TRAFFIC_MANAGEMENT_CACHE_SIZE > 0 || TMM_HAS_NODEINFO_CACHE concurrency::LockGuard guard(&cacheLock); +#if TRAFFIC_MANAGEMENT_CACHE_SIZE > 0 if (cache) memset(cache, 0, static_cast(cacheSize()) * sizeof(UnifiedCacheEntry)); +#endif #if TMM_HAS_NODEINFO_CACHE if (nodeInfoPayload) memset(nodeInfoPayload, 0, static_cast(nodeInfoTargetEntries()) * sizeof(NodeInfoPayloadEntry)); @@ -396,25 +368,9 @@ void TrafficManagementModule::markKeySignerProvenForTest(NodeNum node) #endif } -/** - * Find or create an entry for the given node. - * - * One linear pass tracks the match, the first empty slot, and the eviction - * victim. When the cache is full, the victim is the stalest entry (largest - * of its three relative timestamps is smallest), preferring entries without - * a next_hop hint - those hints are the long-tail routing state the cache - * exists to keep, and the maintenance sweep never ages them out. - * - * @param node NodeNum to find or create - * @param isNew Set to true if a new entry was created - * @return Pointer to entry, or nullptr if the cache is unavailable - */ -// Sender-role resolution for the position hot path. The tier-3 cache is authoritative -// here and is kept fresh by updateCachedRoleFromNodeInfo() - i.e. updated at the same -// time NodeDB learns a role, not re-derived on every packet. We only fall back to a -// NodeDB scan (tiers 1+2) the first time we start tracking a node, to seed the cache so -// a resident special-role node is correct from its very first position. Thereafter the -// read is O(1) and survives the node aging out of both NodeDB stores. +/// Sender-role resolution for the position hot path. NodeDB is scanned only when a node is first +/// tracked (seeding the tier-3 cache so a resident special-role node is correct from its first +/// position); thereafter the cached role is authoritative, kept fresh by updateCachedRoleFromNodeInfo(). meshtastic_Config_DeviceConfig_Role TrafficManagementModule::resolveSenderRole(NodeNum from, UnifiedCacheEntry *entry, bool isNew) { if (!entry) @@ -430,12 +386,9 @@ meshtastic_Config_DeviceConfig_Role TrafficManagementModule::resolveSenderRole(N return static_cast(entry->getCachedRole()); } -// Refresh the tier-3 role cache from an observed NodeInfo - the same event that updates -// NodeDB's role - so role changes (including demotion back to CLIENT) are picked up -// without scanning NodeDB on the position hot path. Role is read straight from the -// packet's User payload (authoritative regardless of module ordering). Only updates nodes -// we already track (findEntry, no create) so NodeInfo from non-position nodes can't pollute -// the cache; the role rides along with the node's existing position/rate/unknown state. +/// Refresh the tier-3 role cache from an observed NodeInfo (the same event that updates NodeDB's +/// role), reading the role straight from the packet's User payload. Only updates nodes already +/// tracked, so NodeInfo from non-position nodes can't pollute the cache. void TrafficManagementModule::updateCachedRoleFromNodeInfo(const meshtastic_MeshPacket &mp) { #if TRAFFIC_MANAGEMENT_CACHE_SIZE > 0 @@ -454,6 +407,9 @@ void TrafficManagementModule::updateCachedRoleFromNodeInfo(const meshtastic_Mesh #endif } +/// Find or create the unified-cache entry for `node`. One linear pass tracks the match, the +/// first empty slot, and the eviction victim: when full, the stalest entry loses, preferring +/// entries without a next-hop hint or special role (the long-tail state this cache retains). TrafficManagementModule::UnifiedCacheEntry *TrafficManagementModule::findOrCreateEntry(NodeNum node, bool *isNew) { #if TRAFFIC_MANAGEMENT_CACHE_SIZE == 0 @@ -473,37 +429,35 @@ TrafficManagementModule::UnifiedCacheEntry *TrafficManagementModule::findOrCreat uint8_t victimRecency = UINT8_MAX; for (uint16_t i = 0; i < cacheSize(); i++) { - UnifiedCacheEntry &e = cache[i]; - if (e.node == node) - return &e; - if (e.node == 0) { + UnifiedCacheEntry &entry = cache[i]; + if (entry.node == node) + return &entry; + if (entry.node == 0) { if (!empty) - empty = &e; + empty = &entry; continue; } if (empty) continue; // an empty slot beats any victim; stop scoring - // "Preferred" entries are evicted last: a confirmed next-hop hint (routing overflow - // store) or a cached special (non-CLIENT) role (tracker / lost-and-found / router). - // Both are the long-tail state this cache exists to retain. - const bool preferred = e.next_hop != 0 || e.getCachedRole() != meshtastic_Config_DeviceConfig_Role_CLIENT; - // Age in pos-ticks (8-bit modular, wraps correctly). Entries with no - // pos state (pos_time==0) score as maximally old (age=currentPosTick()). + // "Preferred" entries are evicted last: a confirmed next-hop hint or a cached + // special (non-CLIENT) role - the long-tail state this cache exists to retain. + const bool preferred = entry.next_hop != 0 || entry.getCachedRole() != meshtastic_Config_DeviceConfig_Role_CLIENT; + // Age in pos-ticks (8-bit modular). Entries with no pos state score as maximally old. const uint8_t nowPosTick = currentPosTick(); - const uint8_t posAge = static_cast(nowPosTick - e.pos_time); + const uint8_t posAge = static_cast(nowPosTick - entry.pos_time); // Blend in rate/unknown ages scaled to pos-tick units (coarser = conservative). const uint8_t rateAgePosScale = - static_cast(static_cast((currentRateTick() - e.getRateTime()) & 0x0F) * 5 / 3); + static_cast(static_cast((currentRateTick() - entry.getRateTime()) & 0x0F) * 5 / 3); const uint8_t unknownAgePosScale = - static_cast(static_cast((currentUnknownTick() - e.getUnknownTime()) & 0x0F) / 6); + static_cast(static_cast((currentUnknownTick() - entry.getUnknownTime()) & 0x0F) / 6); uint8_t recencyAge = posAge; - if (e.getRateCount() != 0 && rateAgePosScale > recencyAge) + if (entry.getRateCount() != 0 && rateAgePosScale > recencyAge) recencyAge = rateAgePosScale; - if (e.getUnknownCount() != 0 && unknownAgePosScale > recencyAge) + if (entry.getUnknownCount() != 0 && unknownAgePosScale > recencyAge) recencyAge = unknownAgePosScale; const uint8_t recency = static_cast(UINT8_MAX - recencyAge); if (!victim || (preferred == leastPreferredVictim ? recency < victimRecency : !preferred)) { - victim = &e; + victim = &entry; leastPreferredVictim = preferred; victimRecency = recency; } @@ -539,18 +493,9 @@ const TrafficManagementModule::NodeInfoPayloadEntry *TrafficManagementModule::fi #endif } -/** - * Find or create a NodeInfo payload entry (linear scan of the flat PSRAM - * array). One pass tracks the match, the first empty slot, and the eviction - * victim. Victim selection is trust-tiered so the cache doubles as a pubkey - * pool (NodeDB::copyPublicKey): membership outranks key trust (a node still - * present in NodeDB hot/warm loses last - evicting it just to reseed it via - * reconciliation would churn), then keyless < TOFU key < signer-proven key; - * within a tier the oldest observation loses (modular obsTick age; a - * never-observed entry counts as oldest of all). Mirrors - * WarmNodeStore's keyed-first admission. NodeInfo traffic is low-rate, so the - * O(n) scan is negligible. - */ +/// Find or create a NodeInfo payload entry. Victim selection is trust-tiered so the cache +/// doubles as a pubkey pool: NodeDB membership outranks key trust, then keyless < TOFU key < +/// signer-proven key; within a tier the oldest observation loses (never-observed = oldest). TrafficManagementModule::NodeInfoPayloadEntry * TrafficManagementModule::findOrCreateNodeInfoEntry(NodeNum node, bool *usedEmptySlot, bool spareMembers) { @@ -562,41 +507,40 @@ TrafficManagementModule::findOrCreateNodeInfoEntry(NodeNum node, bool *usedEmpty return nullptr; NodeInfoPayloadEntry *empty = nullptr; - NodeInfoPayloadEntry *lru = nullptr; - uint8_t lruTier = 0xFF; - uint8_t lruAge = 0; + NodeInfoPayloadEntry *victim = nullptr; + uint8_t victimTier = 0xFF; + uint8_t victimAge = 0; const uint8_t nowObs = currentObsTick(); for (uint16_t i = 0; i < nodeInfoTargetEntries(); i++) { - NodeInfoPayloadEntry &e = nodeInfoPayload[i]; - if (e.node == node) - return &e; - if (e.node == 0) { + NodeInfoPayloadEntry &entry = nodeInfoPayload[i]; + if (entry.node == node) + return &entry; + if (entry.node == 0) { if (!empty) - empty = &e; + empty = &entry; continue; } if (empty) continue; // an empty slot beats any victim; stop scoring // Eviction tier (lower loses first): 0 keyless, 1 TOFU key, 2 signer-proven key; - // +3 when the node is a NodeDB member - the cache must not shed a NodeDB-tier - // identity while it still holds opportunistic strangers. - const uint8_t tier = - static_cast(((e.user.public_key.size != 32) ? 0 : (e.keySignerProven ? 2 : 1)) + (e.isMember ? 3 : 0)); - // Modular observation age; saturation keeps real ages far below 0xFF, so a - // never-observed entry scored at 0xFF is always the oldest in its tier. - const uint8_t age = e.hasObserved ? static_cast(nowObs - e.obsTick) : 0xFF; - if (!lru || tier < lruTier || (tier == lruTier && age > lruAge)) { - lru = &e; - lruTier = tier; - lruAge = age; + // +3 for NodeDB members - never shed a NodeDB-tier identity over a stranger. + const uint8_t tier = static_cast(((entry.user.public_key.size != 32) ? 0 : (entry.keySignerProven ? 2 : 1)) + + (entry.isMember ? 3 : 0)); + // Modular observation age; saturation keeps real ages far below the 0xFF a + // never-observed entry scores, so that entry is always the oldest in its tier. + const uint8_t age = entry.hasObserved ? static_cast(nowObs - entry.obsTick) : 0xFF; + if (!victim || tier < victimTier || (tier == victimTier && age > victimAge)) { + victim = &entry; + victimTier = tier; + victimAge = age; } } - NodeInfoPayloadEntry *slot = empty ? empty : lru; + NodeInfoPayloadEntry *slot = empty ? empty : victim; if (!slot) return nullptr; - if (spareMembers && slot == lru && lru->isMember) + if (spareMembers && slot == victim && victim->isMember) return nullptr; // caller would rather skip than churn one member out for another memset(slot, 0, sizeof(NodeInfoPayloadEntry)); slot->node = node; @@ -635,62 +579,65 @@ void TrafficManagementModule::reconcileNodeInfoFromNodeDBLocked() const NodeNum self = nodeDB->getNodeNum(); uint16_t seeded = 0; - // Upsert one NodeDB-tier node. `hot` non-null contributes the full identity; a warm - // record is key-only. Cost note: a miss in findOrCreateNodeInfoEntry scans the whole - // array, so this pass is O(members x entries) - which is why it runs hourly (plus one - // boot seed), not on every sweep; the write-through hooks carry the interim. - auto reconcileOne = [&](NodeNum n, const uint8_t *key32, bool signerKnown, const meshtastic_NodeInfoLite *hot) { - if (n == 0 || n == self) + // Upsert one NodeDB-tier node (`hot` non-null = full identity, warm record = key-only). + // A miss scans the whole array, so this pass is O(members x entries) - which is why it + // runs hourly plus one boot seed; the write-through hooks carry the interim. + auto reconcileOne = [&](NodeNum node, const uint8_t *key32, bool signerKnown, const meshtastic_NodeInfoLite *hot) { + if (node == 0 || node == self) return; if (!hot && !key32) return; // a warm record without a key has nothing worth seeding - NodeInfoPayloadEntry *e = findNodeInfoEntryMutable(n); - if (!e) { + NodeInfoPayloadEntry *entry = findNodeInfoEntryMutable(node); + if (!entry) { bool usedEmptySlot = false; - e = findOrCreateNodeInfoEntry(n, &usedEmptySlot, /*spareMembers=*/true); - if (!e) + entry = findOrCreateNodeInfoEntry(node, &usedEmptySlot, /*spareMembers=*/true); + if (!entry) return; // cache is member-saturated; skip rather than churn a member out seeded++; } // NodeDB is the authority on identity content: adopt its User payload and key. A key - // change relative to a stale TMM TOFU pin resets provenance; the signer verdict then - // transfers only via the key-matched rule below. hasObserved/obsTick are deliberately - // untouched - seeding is knowledge, not an observation, and the replay serve-gate - // must stay keyed to genuinely heard frames. - const bool keyChanged = e->user.public_key.size == 32 && key32 && memcmp(e->user.public_key.bytes, key32, 32) != 0; + // change against a stale TMM TOFU pin resets provenance (the signer verdict transfers + // only key-matched, below). hasObserved/obsTick stay untouched: seeding is not observation. + const bool keyChanged = + entry->user.public_key.size == 32 && key32 && memcmp(entry->user.public_key.bytes, key32, 32) != 0; if (hot && nodeInfoLiteHasUser(hot)) { - e->user = TypeConversions::ConvertToUser(hot); - e->hasFullUser = true; - snprintf(e->user.id, sizeof(e->user.id), "!%08x", n); + meshtastic_User merged = TypeConversions::ConvertToUser(hot); + // Same rule as onNodeIdentityCommitted: a keyless hot identity must not cost this + // cache a TOFU key it already learned (the kept key stays unproven). + if (merged.public_key.size != 32 && entry->user.public_key.size == 32) + merged.public_key = entry->user.public_key; + entry->user = merged; + entry->hasFullUser = true; + snprintf(entry->user.id, sizeof(entry->user.id), "!%08x", node); } else if (key32) { - memcpy(e->user.public_key.bytes, key32, 32); - e->user.public_key.size = 32; + memcpy(entry->user.public_key.bytes, key32, 32); + entry->user.public_key.size = 32; } if (keyChanged) - e->keySignerProven = false; - if (signerKnown && key32 && e->user.public_key.size == 32 && memcmp(e->user.public_key.bytes, key32, 32) == 0) - e->keySignerProven = true; - e->isMember = true; + entry->keySignerProven = false; + if (signerKnown && key32 && entry->user.public_key.size == 32 && memcmp(entry->user.public_key.bytes, key32, 32) == 0) + entry->keySignerProven = true; + entry->isMember = true; }; for (size_t i = 0; i < nodeDB->getNumMeshNodes(); i++) { const meshtastic_NodeInfoLite *info = nodeDB->getMeshNodeByIndex(i); if (!info || info->num == 0) continue; - const uint8_t *key = (info->public_key.size == 32) ? info->public_key.bytes : nullptr; - reconcileOne(info->num, key, nodeInfoLiteHasXeddsaSigned(info), info); + const uint8_t *hotKey = (info->public_key.size == 32) ? info->public_key.bytes : nullptr; + reconcileOne(info->num, hotKey, nodeInfoLiteHasXeddsaSigned(info), info); } #if WARM_NODE_COUNT > 0 // Warm tier: key-only records (the warm tier keeps no names), so the cache holds a key // for every NodeDB identity - the superset the pubkey pool and the key pin rely on. for (size_t i = 0; i < nodeDB->warmStore.capacity(); i++) { - const WarmNodeEntry *w = nodeDB->warmStore.entryAt(i); - if (!w) + const WarmNodeEntry *warm = nodeDB->warmStore.entryAt(i); + if (!warm) continue; - const bool hasKey = !memfll(w->public_key, 0, sizeof(w->public_key)); - reconcileOne(w->num, hasKey ? w->public_key : nullptr, warmSignerOf(*w), nullptr); + const bool hasKey = !memfll(warm->public_key, 0, sizeof(warm->public_key)); + reconcileOne(warm->num, hasKey ? warm->public_key : nullptr, warmSignerOf(*warm), nullptr); } #endif @@ -709,29 +656,28 @@ void TrafficManagementModule::onNodeIdentityCommitted(NodeNum node, const meshta if (!nodeInfoPayload) return; bool usedEmptySlot = false; - NodeInfoPayloadEntry *e = findOrCreateNodeInfoEntry(node, &usedEmptySlot, /*spareMembers=*/true); - if (!e) + NodeInfoPayloadEntry *entry = findOrCreateNodeInfoEntry(node, &usedEmptySlot, /*spareMembers=*/true); + if (!entry) return; // member-saturated cache: the reconcile sweep owns the tradeoff // Merge: NodeDB's commit is authoritative for everything it carries, but a keyless // commit (possible while the node is unpinned in NodeDB) must not cost this cache a // TOFU key it already learned. meshtastic_User merged = user; - if (merged.public_key.size != 32 && !usedEmptySlot && e->user.public_key.size == 32) - merged.public_key = e->user.public_key; - - // Provenance survives only alongside an unchanged key; a replaced key (stale residue - // predating the authoritative pin) starts from scratch and may be re-proven by - // signerKnown below - which vouches for the COMMITTED key, never the kept TOFU one. - const bool sameKey = !usedEmptySlot && e->user.public_key.size == 32 && merged.public_key.size == 32 && - memcmp(e->user.public_key.bytes, merged.public_key.bytes, 32) == 0; - const bool provenBefore = !usedEmptySlot && e->keySignerProven && sameKey; - - e->user = merged; - snprintf(e->user.id, sizeof(e->user.id), "!%08x", node); - e->hasFullUser = true; - e->keySignerProven = provenBefore || (signerKnown && user.public_key.size == 32); - e->isMember = true; // committed via updateUser => it sits in the hot store right now + if (merged.public_key.size != 32 && !usedEmptySlot && entry->user.public_key.size == 32) + merged.public_key = entry->user.public_key; + + // Provenance survives only alongside an unchanged key; a replaced key starts from scratch + // and may be re-proven by signerKnown below - which vouches for the COMMITTED key only. + const bool sameKey = !usedEmptySlot && entry->user.public_key.size == 32 && merged.public_key.size == 32 && + memcmp(entry->user.public_key.bytes, merged.public_key.bytes, 32) == 0; + const bool provenBefore = !usedEmptySlot && entry->keySignerProven && sameKey; + + entry->user = merged; + snprintf(entry->user.id, sizeof(entry->user.id), "!%08x", node); + entry->hasFullUser = true; + entry->keySignerProven = provenBefore || (signerKnown && user.public_key.size == 32); + entry->isMember = true; // committed via updateUser => it sits in the hot store right now // obsTick/hasObserved deliberately untouched: only a heard frame makes a node servable. #else (void)node; @@ -820,13 +766,11 @@ bool TrafficManagementModule::copyUser(NodeNum node, meshtastic_User &out, bool } #if TMM_HAS_NODEINFO_CACHE && !(MESHTASTIC_EXCLUDE_PKI) -// True iff both are full 32-byte public keys with identical bytes. Single point of -// truth for the NodeInfo-cache key-hygiene checks (owner impersonation + key pinning), -// so the compare (and any future hardening, e.g. constant-time) lives in one place. -// Takes raw ptr+size because the User and NodeInfoLite key fields are distinct types. -static bool pubKeysEqual(const uint8_t *a, size_t aSize, const uint8_t *b, size_t bSize) +/// True iff both are full 32-byte public keys with identical bytes. Single point of truth for +/// the key-hygiene checks; raw ptr+size because User and NodeInfoLite key fields differ in type. +static bool pubKeysEqual(const uint8_t *keyA, size_t sizeA, const uint8_t *keyB, size_t sizeB) { - return aSize == 32 && bSize == 32 && memcmp(a, b, 32) == 0; + return sizeA == 32 && sizeB == 32 && memcmp(keyA, keyB, 32) == 0; } #endif @@ -850,34 +794,24 @@ void TrafficManagementModule::cacheNodeInfoPacket(const meshtastic_MeshPacket &m bool dbSaysSigner = false; #if !(MESHTASTIC_EXCLUDE_PKI) - // Mirror NodeDB::updateUser() key hygiene before we let an overheard NodeInfo into - // the direct-response cache. This cache is served back to requestors as a spoofed - // reply on the target's behalf, so poisoning it is materially worse than a plain - // NodeDB overwrite - apply the same protections NodeDB applies to itself. - // Someone advertising our own public key is impersonating this node - never cache it. + // This cache is served back as spoofed replies, so mirror NodeDB::updateUser()'s key + // hygiene. First: a frame advertising our own key is impersonating us - never cache it. if (pubKeysEqual(user.public_key.bytes, user.public_key.size, owner.public_key.bytes, owner.public_key.size)) { TM_LOG_WARN("NodeInfo cache: incoming key matches owner, dropping 0x%08x", from); return; } - // Key pinning against the authoritative NodeDB key - hot store, then warm tier: once a - // 32-byte key is known for a node, refuse to cache a NodeInfo carrying a different (or - // missing) key. This is the same rule as NodeDB::updateUser() ("Public Key mismatch, - // dropping NodeInfo") - and the same coverage: updateUser's pin sees warm-tier keys - // because getOrCreateMeshNode() rehydrates them before the check runs, so this pin must - // consult the warm tier too. Checking only the hot store would let an attacker seed this - // cache with a bogus key for a warm-evicted node, and the TOFU pin below would then lock - // the genuine node's frames out until the entry ages away. + // Key pin against the authoritative NodeDB key (hot store, then warm tier - the same + // coverage as updateUser's pin): a hot-only check would let an attacker seed a bogus key + // for a warm-evicted node, and the TOFU pin below would then lock the genuine node out. meshtastic_NodeInfoLite_public_key_t dbKey = {0, {0}}; if (nodeDB && nodeDB->copyPublicKeyAuthoritative(from, dbKey) && !pubKeysEqual(user.public_key.bytes, user.public_key.size, dbKey.bytes, dbKey.size)) { TM_LOG_WARN("NodeInfo cache: public key mismatch vs NodeDB, dropping 0x%08x", from); return; } - // Re-found in NodeDB as a known signer: inherit that verdict so the cached key is proven - // even when this particular frame was unsigned. isVerifiedSignerForKey consults both the - // hot store's signed bitfield and the warm tier's cached signer bit, and requires the key - // to match, so a warm-only signer is covered and a rotated key never inherits a stale - // verdict. + // Re-found in NodeDB as a known signer: inherit that verdict even off an unsigned frame. + // isVerifiedSignerForKey covers both tiers and requires the key to match, so a warm-only + // signer is included and a rotated key never inherits a stale verdict. dbSaysSigner = nodeDB && user.public_key.size == 32 && nodeDB->isVerifiedSignerForKey(from, user.public_key.bytes); #endif @@ -901,9 +835,8 @@ void TrafficManagementModule::cacheNodeInfoPacket(const meshtastic_MeshPacket &m } #endif - // Cache both payload and response metadata so direct replies can use - // richer context than "just the user protobuf" when the cache is present. - // This path is intentionally independent from NodeInfoModule/NodeDB. + // Cache payload plus response metadata so direct replies carry richer context than + // just the User protobuf. Intentionally independent from NodeInfoModule/NodeDB. entry->user = user; entry->obsTick = currentObsTick(); // a genuine observation - the only place obsTick is stamped entry->hasObserved = true; @@ -912,12 +845,9 @@ void TrafficManagementModule::cacheNodeInfoPacket(const meshtastic_MeshPacket &m entry->hasDecodedBitfield = mp.decoded.has_bitfield; entry->decodedBitfield = mp.decoded.bitfield; - // Upgrade key provenance to "signer-proven" when either this frame's XEdDSA signature - // was verified (Router::checkXeddsaReceivePolicy sets mp.xeddsa_signed only after a - // successful verify against the node's key) or NodeDB already knows this node as a - // signer for this same key (dbSaysSigner). Never downgrade: a later unsigned frame for - // an already-proven key leaves the flag set. The key itself cannot change here - the - // key-pin checks above reject a mismatching key before we reach this point. + // Upgrade to signer-proven on a Router-verified signature or a NodeDB signer verdict + // for this same key. Never downgrade (a later unsigned frame leaves the flag set), + // and the key itself cannot change here - the pin checks above already rejected that. if ((mp.xeddsa_signed || dbSaysSigner) && user.public_key.size == 32) entry->keySignerProven = true; @@ -937,13 +867,9 @@ void TrafficManagementModule::cacheNodeInfoPacket(const meshtastic_MeshPacket &m // ============================================================================= // Next-Hop Overflow Cache // ============================================================================= -// -// A routing hint store. The byte is the last byte of the NodeNum to use as next -// hop to reach `dest`. It is written ONLY from NextHopRouter's ACK-confirmed -// decision (a bidirectionally-verified relay) - never inferred one-way from -// relayed traffic. The TMM cache holds confirmed next-hops that have aged out of -// the hot NodeDB (NodeInfoLite), and NextHopRouter::getNextHop() consults it as a -// fallback after the hot store. +// Routing hints (last byte of the next-hop NodeNum), written ONLY from NextHopRouter's +// ACK-confirmed decisions - never inferred one-way. Holds confirmed hops that aged out of +// the hot NodeDB; NextHopRouter::getNextHop() consults it as a fallback after the hot store. void TrafficManagementModule::setNextHop(NodeNum dest, uint8_t nextHopByte) { @@ -1038,60 +964,34 @@ void TrafficManagementModule::flushCache() // Position Hash (Compact Mode) // ============================================================================= -/** - * Compute 8-bit position fingerprint from truncated lat/lon coordinates. - * - * Unlike a hash, this is deterministic: adjacent grid cells have sequential - * fingerprints, so nearby positions never collide. The fingerprint extracts - * the lower 4 significant bits from each truncated coordinate. - * - * Example with precision=16: - * lat_truncated = 0x12340000 (top 16 bits significant) - * Significant portion = 0x1234, lower 4 bits = 0x4 - * - * fingerprint = (lat_low4 << 4) | lon_low4 = 8 bits total - * - * Collision: Two positions collide only if they differ by a multiple of 16 - * grid cells in BOTH lat and lon dimensions simultaneously - very unlikely - * for typical position update patterns. - * - * @param lat_truncated Precision-truncated latitude - * @param lon_truncated Precision-truncated longitude - * @param precision Number of significant bits (1-32) - * @return 8-bit fingerprint (4 bits lat + 4 bits lon) - */ +/// 8-bit position fingerprint: (lat_low4 << 4) | lon_low4 from the truncated coordinates' +/// lower significant bits. Deterministic, so adjacent grid cells never collide; two positions +/// collide only when 16+ cells apart in BOTH dimensions. uint8_t TrafficManagementModule::computePositionFingerprint(int32_t lat_truncated, int32_t lon_truncated, uint8_t precision) { precision = sanitizePositionPrecision(precision); - // Guard: if precision < 4, we have fewer bits to work with - // Take min(precision, 4) bits from each coordinate + // With precision < 4 there are fewer significant bits to take. uint8_t bitsToTake = (precision < 4) ? precision : 4; - // Shift to move significant bits to bottom, then mask lower bits - // For precision=16: shift by 16 to get the 16 significant bits at bottom + // Shift the significant bits to the bottom, then mask the low bits of each coordinate. uint8_t shift = 32 - precision; uint8_t latBits = (static_cast(lat_truncated) >> shift) & ((1u << bitsToTake) - 1); uint8_t lonBits = (static_cast(lon_truncated) >> shift) & ((1u << bitsToTake) - 1); - const uint8_t fp = static_cast((latBits << 4) | lonBits); - // 0 is the "no position seen" sentinel for pos_fingerprint, so a real position that happens to - // hash to 0 must not collide with it (otherwise its duplicates would never dedup). Remap 0 -> 0xFF, - // mirroring NodeDB::getLastByteOfNodeNum()'s 0 -> 0xFF idiom. Cost: the 0x00 bucket merges into - // 0xFF (one extra collision in 256 - negligible; the fingerprint already collides every 16 cells). - return fp ? fp : 0xFF; + const uint8_t fingerprint = static_cast((latBits << 4) | lonBits); + // 0 is the "no position seen" sentinel, so remap a computed 0 -> 0xFF (mirrors + // getLastByteOfNodeNum). Cost: one extra collision bucket in 256 - negligible. + return fingerprint ? fingerprint : 0xFF; } // ============================================================================= // Packet Handling // ============================================================================= -// Processing order matters: this module runs BEFORE RoutingModule in the callModules() loop. -// - STOP prevents RoutingModule from calling sniffReceived() → perhapsRebroadcast(), -// so the packet is fully consumed (not forwarded). -// - ignoreRequest suppresses the default "no one responded" NAK for want_response packets. -// - exhaustRequested is set by alterReceived() and checked by perhapsRebroadcast() to -// force hop_limit=0 on the rebroadcast copy, allowing one final relay hop. +/// Runs BEFORE RoutingModule in callModules(): STOP fully consumes the packet (no rebroadcast), +/// ignoreRequest suppresses the default NAK for want_response packets, and exhaustRequested +/// (set by alterReceived) makes perhapsRebroadcast() force hop_limit=0 on the relayed copy. ProcessMessage TrafficManagementModule::handleReceived(const meshtastic_MeshPacket &mp) { if (!moduleConfig.has_traffic_management) @@ -1124,12 +1024,11 @@ ProcessMessage TrafficManagementModule::handleReceived(const meshtastic_MeshPack return ProcessMessage::CONTINUE; } - // A known signer's NodeInfo arriving unsigned is unauthenticated (the sender is forgeable), so - // it must not drive any cache or identity write below. Computed once here (getMeshNode is O(N)) - // and reused by both the cache-refresh and the direct-response identity path. + // A known signer's NodeInfo arriving unsigned is unauthenticated and must not drive any + // cache or identity write below. isKnownXeddsaSigner covers the warm tier too - a forged + // unsigned NodeInfo carrying a warm-evicted signer's real (public!) key passes the key pin. const bool isNodeInfo = mp.decoded.portnum == meshtastic_PortNum_NODEINFO_APP; - const meshtastic_NodeInfoLite *senderNode = isNodeInfo ? nodeDB->getMeshNode(getFrom(&mp)) : nullptr; - const bool unauthenticatedSigner = senderNode && nodeInfoLiteHasXeddsaSigned(senderNode) && !mp.xeddsa_signed; + const bool unauthenticatedSigner = isNodeInfo && !mp.xeddsa_signed && nodeDB && nodeDB->isKnownXeddsaSigner(getFrom(&mp)); // Learn NodeInfo payloads into the dedicated PSRAM cache, and refresh the tier-3 // role cache for any node we already track (keeps the dedup role exception current). @@ -1141,17 +1040,14 @@ ProcessMessage TrafficManagementModule::handleReceived(const meshtastic_MeshPack // ------------------------------------------------------------------------- // NodeInfo Direct Response // ------------------------------------------------------------------------- - // When we see a unicast NodeInfo request for a node we know about, - // respond directly from cache instead of forwarding the request. - // STOP prevents the request from being rebroadcast toward the target node, - // and our cached response is sent back to the requestor with hop_limit=0. + // Answer a unicast NodeInfo request for a known node straight from cache: STOP keeps the + // request from being rebroadcast, and the reply goes back to the requestor with hop_limit=0. if (cfg.nodeinfo_direct_response_max_hops > 0 && mp.decoded.portnum == meshtastic_PortNum_NODEINFO_APP && mp.decoded.want_response && !isBroadcast(mp.to) && !isToUs(&mp) && !isFromUs(&mp)) { if (shouldRespondToNodeInfo(&mp, true)) { // Unicast NodeInfo is never signed, so a known signer's identity claim here is // unauthenticated: don't overwrite its stored name. The cached response is unaffected. - // unauthenticatedSigner was computed above (this branch is NodeInfo-only). meshtastic_User requester = meshtastic_User_init_zero; if (!unauthenticatedSigner && pb_decode_from_bytes(mp.decoded.payload.bytes, mp.decoded.payload.size, &meshtastic_User_msg, &requester)) { @@ -1219,24 +1115,17 @@ void TrafficManagementModule::alterReceived(meshtastic_MeshPacket &mp) if (isFromUs(&mp)) return; - // exhaust_hop_telemetry / exhaust_hop_position / router_preserve_hops: - // not suitable right now - the right heuristics for when to exhaust or - // preserve hops need more field data before we expose them as config knobs. - // exhaustRequested stays false; perhapsRebroadcast() behaves normally. + // exhaust_hop_telemetry / exhaust_hop_position / router_preserve_hops: shelved until the + // right heuristics are clearer; exhaustRequested stays false and rebroadcast is normal. const bool isPosition = mp.decoded.portnum == meshtastic_PortNum_POSITION_APP; // ------------------------------------------------------------------------- // Relayed Position Precision Clamp // ------------------------------------------------------------------------- - // Clamp relayed position broadcasts to the channel's configured precision - // ceiling. Guards against forwarding more-precise coordinates than the - // channel is intended to carry (e.g. a LongFast channel set to 13-bit / - // ~1.5 km). chanPrec==0 means position sharing is disabled on the channel; - // skip - not our job to zero positions on relay. - // Ham mode (owner.is_licensed) is exempt. Lost-and-found is NOT exempt - its relayed - // positions get the same precision clamp as any node. - // Compile USERPREFS_TMM_APPLY_TO_PRIVATE_CHANNELS to extend to private channels. + // Never forward more-precise coordinates than the channel is configured to carry + // (chanPrec==0 = sharing disabled on channel: skip). Ham mode is exempt; lost-and-found + // is not. Compile USERPREFS_TMM_APPLY_TO_PRIVATE_CHANNELS to extend to private channels. if (!owner.is_licensed && isPosition && isBroadcast(mp.to)) { #ifdef USERPREFS_TMM_APPLY_TO_PRIVATE_CHANNELS const bool shouldClamp = true; @@ -1271,19 +1160,14 @@ int32_t TrafficManagementModule::runOnce() return INT32_MAX; #if TRAFFIC_MANAGEMENT_CACHE_SIZE > 0 - // Warm-start the next-hop cache from persisted NodeInfoLite hints once nodeDB - // is populated. Done here (not in the constructor) so nodeDB has finished - // loading. Takes its own lock, so call before acquiring the sweep guard below. - // Only latch the one-shot guard once the preload actually ran; if nodeDB wasn't - // ready yet, retry on the next maintenance pass instead of skipping it forever. + // Warm-start the next-hop cache once nodeDB has finished loading (hence here, not the + // constructor; it takes its own lock, so run before the sweep guard). Latch the one-shot + // guard only when the preload actually ran, so a not-ready nodeDB gets a retry. if (!nextHopPreloaded && preloadNextHopsFromNodeDB()) nextHopPreloaded = true; - // Free-running tick counters (no epoch needed). - // TTL expressed in ticks: - // pos: 4× position_min_interval_secs (clamped to 255 ticks @ 6 min/tick) - // rate: 2× rate_limit_window_secs (clamped to 15 ticks @ 5 min/tick; only relevant when rate limits are configured) - // unknown: fixed 12 ticks @ 1 min/tick (only relevant when unknown_packet_threshold > 0) + // TTLs in free-running ticks: pos 4x position interval (<=255 @ 6 min/tick), rate 2x the + // configured window (<=15 @ 5 min/tick), unknown fixed 12 @ 1 min/tick. const uint32_t positionIntervalMs = secsToMs(Default::getConfiguredOrDefault( moduleConfig.traffic_management.position_min_interval_secs, default_traffic_mgmt_position_min_interval_secs)); const uint8_t posTtlTicks = @@ -1344,12 +1228,9 @@ int32_t TrafficManagementModule::runOnce() } } - // Two fields have no TTL of their own and pin the slot, so they outlive the - // dedup/rate/unknown state: - // - a confirmed next-hop hint (the routing overflow store), and - // - a cached special (non-CLIENT) role, so a tracker / lost-and-found / router - // keeps its dedup-window exception across quiet periods rather than reverting - // to CLIENT the moment its timed state expires. + // Confirmed next-hop hints and cached special roles have no TTL and pin the slot, so + // a tracker/router keeps its dedup exception across quiet periods instead of + // reverting to CLIENT the moment its timed state expires. if (cache[i].next_hop != 0 || cache[i].getCachedRole() != meshtastic_Config_DeviceConfig_Role_CLIENT) anyValid = true; @@ -1368,36 +1249,30 @@ int32_t TrafficManagementModule::runOnce() #if TMM_HAS_NODEINFO_CACHE if (nodeInfoPayload) { - // Saturate expired tick stamps. Once a stamp's age exceeds its window, its presence - // bit is cleared here, so no stamp is ever read anywhere near its uint8 aliasing - // horizon (obs: 6 h window vs 12.8 h period; resp: 30 s vs 21.3 min) - this sweep, - // not slot eviction, is the wrap-safety guarantee. Entries themselves are never - // freed on a timer: a quiet entry's key and User payload keep their value (pubkey - // pool, name rehydration), and slots are reclaimed by tiered LRU on insert or by an - // explicit user-initiated purge. + // Saturate expired tick stamps: clearing the presence bit once a stamp's age exceeds + // its window is the wrap-safety guarantee (stamps never approach their uint8 aliasing + // horizon). Entries are never freed on a timer - slots die by tiered LRU or purge. uint16_t nodeInfoSaturated = 0; const uint8_t nowObs = currentObsTick(); const uint8_t nowResp = currentRespTick(); for (uint16_t i = 0; i < nodeInfoTargetEntries(); i++) { - NodeInfoPayloadEntry &e = nodeInfoPayload[i]; - if (e.node == 0) + NodeInfoPayloadEntry &entry = nodeInfoPayload[i]; + if (entry.node == 0) continue; - if (e.hasObserved && static_cast(nowObs - e.obsTick) > kNodeInfoMaxServeAgeTicks) { - e.hasObserved = false; + if (entry.hasObserved && static_cast(nowObs - entry.obsTick) > kNodeInfoMaxServeAgeTicks) { + entry.hasObserved = false; nodeInfoSaturated++; } - if (e.hasResponded && static_cast(nowResp - e.respTick) >= kNodeInfoThrottleTicks) - e.hasResponded = false; - // Refresh membership: the bit is the keep-alive that makes a node still present - // in NodeDB (hot or warm) the stickiest under LRU eviction, and its clearing is - // what lets a node NodeDB has fully forgotten become evictable again. Read-only - // NodeDB lookups under cacheLock follow the resolveSenderRole precedent. - bool member = nodeDB && nodeDB->getMeshNode(e.node) != nullptr; + if (entry.hasResponded && static_cast(nowResp - entry.respTick) >= kNodeInfoThrottleTicks) + entry.hasResponded = false; + // Refresh membership: the bit keeps NodeDB-present nodes stickiest under LRU, and + // its clearing is what lets a fully forgotten node become evictable again. + bool member = nodeDB && nodeDB->getMeshNode(entry.node) != nullptr; #if WARM_NODE_COUNT > 0 if (!member && nodeDB) - member = nodeDB->warmStore.contains(e.node); + member = nodeDB->warmStore.contains(entry.node); #endif - e.isMember = member; + entry.isMember = member; } TM_LOG_DEBUG("NodeInfo cache: %u/%u (%u went stale)", static_cast(countNodeInfoEntriesLocked()), static_cast(nodeInfoTargetEntries()), static_cast(nodeInfoSaturated)); @@ -1445,10 +1320,8 @@ bool TrafficManagementModule::shouldDropPosition(const meshtastic_MeshPacket *p, const int32_t lat_truncated = truncateCoordinate(pos->latitude_i, precision); const int32_t lon_truncated = truncateCoordinate(pos->longitude_i, precision); const uint8_t fingerprint = computePositionFingerprint(lat_truncated, lon_truncated, precision); - // Drop gate uses the RAW configured interval: 0 means "dedup disabled" (the - // contract documented below). The 12h default is only for resolution/TTL - // sizing (constructor / runOnce), not for deciding whether to drop - feeding - // the default here would silently turn the 0-disables-dedup contract off. + // Drop gate uses the RAW configured interval: 0 means "dedup disabled". The 12 h default + // is only for TTL sizing - feeding it here would silently defeat that contract. uint32_t minIntervalMs = secsToMs(moduleConfig.traffic_management.position_min_interval_secs); bool isNew = false; @@ -1457,11 +1330,8 @@ bool TrafficManagementModule::shouldDropPosition(const meshtastic_MeshPacket *p, if (!entry) return false; - // Role exceptions keyed on the originating node's advertised role, resolved across - // all three tiers (hot store → warm store → TMM live cache). The position path is - // the one place that needs sender-role, and it also keeps tier 3 warm so the - // exception survives the node aging out of both NodeDB stores - important in the - // common dedup-only config, where isRateLimited()'s role write never runs. + // Role exceptions keyed on the sender's advertised role, resolved hot -> warm -> tier-3 + // cache; this path also keeps tier 3 warm so the exception survives NodeDB eviction. const meshtastic_Config_DeviceConfig_Role role = resolveSenderRole(p->from, entry, isNew); if (role == meshtastic_Config_DeviceConfig_Role_LOST_AND_FOUND) { // Lost-and-found may refresh a duplicate position at most every ~15 min (cap, never @@ -1573,11 +1443,9 @@ bool TrafficManagementModule::shouldRespondToNodeInfo(const meshtastic_MeshPacke const meshtastic_NodeInfoLite *node = nodeDB->getMeshNode(p->to); if (!nodeInfoLiteHasUser(node)) return false; - // Staleness gate (fallback path): don't spoof a reply for a node NodeDB last - // heard more than kNodeInfoMaxServeAgeSecs ago. last_heard == 0 means recency is - // unknown (e.g. clock not yet set) - we can't prove it's stale, so we still answer, - // matching sinceLastSeen()'s own "clock not set" tolerance. Compute the age once so - // the tested value and the logged value can't diverge (sinceLastSeen calls getTime()). + // Staleness gate (fallback path): don't spoof a reply for a node last heard beyond the + // serve window. last_heard == 0 means recency is unknown (clock not set) - we can't + // prove staleness, so still answer. Age computed once so test and log can't diverge. const uint32_t nodeAgeSecs = sinceLastSeen(node); if (node->last_heard != 0 && nodeAgeSecs > kNodeInfoMaxServeAgeSecs) { TM_LOG_DEBUG("NodeInfo NodeDB entry for 0x%08x stale (%us ago), not responding", p->to, @@ -1604,12 +1472,9 @@ bool TrafficManagementModule::shouldRespondToNodeInfo(const meshtastic_MeshPacke } } - // Staleness gate (cache path): never spoof a reply on behalf of a node we have not - // actually HEARD within the serve window. hasObserved carries the stamp's validity - - // it distinguishes a genuinely observed entry from one merely seeded or retained, and - // the maintenance sweep clears it once the window passes (saturation), so this modular - // tick compare can never see an aliased age. Only cache hits are gated here; the NodeDB - // fallback was gated above on last_heard. + // Staleness gate (cache path): never spoof a reply for a node not actually HEARD within + // the serve window. hasObserved distinguishes genuinely observed entries from seeded ones, + // and the sweep's saturation keeps this modular tick compare alias-free. if (hasCachedUser && (!cachedHasObserved || static_cast(currentObsTick() - cachedObsTick) > kNodeInfoMaxServeAgeTicks)) { TM_LOG_DEBUG("NodeInfo cache entry for 0x%08x not freshly observed, not responding", p->to); @@ -1618,8 +1483,7 @@ bool TrafficManagementModule::shouldRespondToNodeInfo(const meshtastic_MeshPacke #if TMM_NODEINFO_REPLAY_SIGNED_GATE // Replay provenance gate (PSRAM path): only spoof a reply for a signer-proven cached key. - // The fallback path is gated above; this covers the PSRAM cache hit. usedFallback entries - // were already gated, so skip them here. See TMM_NODEINFO_REPLAY_REQUIRE_SIGNED. + // usedFallback entries were already gated above. See TMM_NODEINFO_REPLAY_REQUIRE_SIGNED. if (!usedFallback && !cachedKeySignerProven) { TM_LOG_DEBUG("NodeInfo PSRAM entry for 0x%08x not signer-proven, not responding", p->to); return false; @@ -1629,24 +1493,9 @@ bool TrafficManagementModule::shouldRespondToNodeInfo(const meshtastic_MeshPacke if (!sendResponse) return true; - // Response throttle: bound how often we emit a spoofed reply. Direct responses bypass - // the per-sender rate limiter (they STOP the request first), and the reply target is - // attacker-controlled, so without this an attacker could drive unbounded local - // transmissions / reflected floods. Suppress the duplicate request (return true) rather - // than letting it propagate and generate more mesh traffic. - // - Cache path: respTick is per target node (NodeInfoPayloadEntry, 5-s tick clock; - // hasResponded carries validity and the sweep clears it after the window). - // - Fallback path: the module-global millis stamp (no per-node slot). - // NOTE (accepted design, not a defect): - // - On the cache path this is keyed on p->to, so a DISTINCT legitimate requestor for the - // same target gets no reply for up to one window (returning true STOPs it). That is - // fine: the mesh is redundant - the genuine node or another cache-holder answers, and - // a real node would rate-limit its own replies the same way. - // - The per-target key does not bound aggregate local TX: an attacker cycling N - // distinct cached targets still draws N spoofed transmits per window. The fallback - // path's global stamp does bound aggregate; a global cap on the cache path would too, - // but at the cost of throughput on legitimate multi-target responders, so it is left - // per-target by choice. + // Response throttle: the reply target is attacker-controlled and direct responses bypass + // the rate limiter, so bound spoofed replies (cache path: per-target respTick; fallback: + // global stamp). Accepted: per-target keying doesn't bound aggregate TX across targets. const bool throttled = usedFallback ? (cachedFallbackResponseMs != 0 && (clockMs() - cachedFallbackResponseMs) < kNodeInfoResponseThrottleMs) : (cachedHasResponded && static_cast(currentRespTick() - cachedRespTick) < kNodeInfoThrottleTicks); @@ -1704,25 +1553,22 @@ bool TrafficManagementModule::shouldRespondToNodeInfo(const meshtastic_MeshPacke service->sendToMesh(reply); - // Record the send so the throttle can suppress a burst of requests. The fallback path - // stamps the module-global millis marker (nowStampMs() keeps it off the 0 "never" - // sentinel at the wrap, T9); the cache path stamps the served entry's respTick, whose - // validity travels in hasResponded instead of a sentinel. + // Record the send so the throttle can suppress a burst: fallback path stamps the global + // marker (nowStampMs() keeps it off the 0 "never" sentinel at the wrap, T9); the cache + // path stamps the served entry's respTick below. if (usedFallback) { concurrency::LockGuard guard(&cacheLock); nodeInfoFallbackLastResponseMs = nowStampMs(); } - // Stamp the cache entry we served from, addressing it by the index captured during the - // initial lookup rather than rescanning the array (T5). The cache lock was released in - // between, so the slot could have been evicted or reused for a different node; re-validate - // node == p->to under the lock and skip the stamp if it no longer matches. + // Stamp the served entry by the index captured at lookup instead of rescanning (T5). The + // lock was released in between, so re-validate node == p->to in case the slot was reused. #if TMM_HAS_NODEINFO_CACHE if (!usedFallback && nodeInfoPayload && cachedNodeInfoIndex >= 0) { concurrency::LockGuard guard(&cacheLock); - NodeInfoPayloadEntry &e = nodeInfoPayload[cachedNodeInfoIndex]; - if (e.node == p->to) { - e.respTick = currentRespTick(); - e.hasResponded = true; + NodeInfoPayloadEntry &entry = nodeInfoPayload[cachedNodeInfoIndex]; + if (entry.node == p->to) { + entry.respTick = currentRespTick(); + entry.hasResponded = true; } } #endif @@ -1785,9 +1631,9 @@ bool TrafficManagementModule::isRateLimited(NodeNum from, uint32_t nowMs) } // Increment counter, saturating at 63 (6-bit field max). - const uint8_t cur = entry->getRateCount(); - if (cur < 0x3F) - entry->setRateCount(static_cast(cur + 1)); + const uint8_t currentCount = entry->getRateCount(); + if (currentCount < 0x3F) + entry->setRateCount(static_cast(currentCount + 1)); // Threshold capped at 60 so a saturated reading (63) always exceeds it. uint32_t threshold = moduleConfig.traffic_management.rate_limit_max_packets; @@ -1831,12 +1677,11 @@ bool TrafficManagementModule::shouldDropUnknown(const meshtastic_MeshPacket *p, entry->setUnknownCount(0); } - // Increment counter, saturating at 63 (6-bit field max). With threshold - // capped at 60, a saturated reading always exceeds the limit - no special - // already-saturated edge case needed. - const uint8_t cur = entry->getUnknownCount(); - if (cur < 0x3F) - entry->setUnknownCount(static_cast(cur + 1)); + // Increment counter, saturating at 63 (6-bit field max). With the threshold capped at + // 60, a saturated reading always exceeds the limit - no special edge case needed. + const uint8_t currentCount = entry->getUnknownCount(); + if (currentCount < 0x3F) + entry->setUnknownCount(static_cast(currentCount + 1)); // Threshold capped at 60 so a saturated reading (63) always exceeds it. uint32_t threshold = moduleConfig.traffic_management.unknown_packet_threshold; diff --git a/src/modules/TrafficManagementModule.h b/src/modules/TrafficManagementModule.h index 22559883ddf..b9084450316 100644 --- a/src/modules/TrafficManagementModule.h +++ b/src/modules/TrafficManagementModule.h @@ -8,14 +8,9 @@ #if HAS_TRAFFIC_MANAGEMENT -// Replay provenance gate (compile-time). When 1 (default), the NodeInfo direct-response path -// only spoofs a reply on behalf of a node whose cached key is signer-proven - an XEdDSA -// signature was verified against it - in addition to the staleness gate. Replay is a courtesy -// feature, and vouching for an unverified (trust-on-first-use) identity to other nodes is the -// risk this closes, so signed-only is the safer default. Define as 0 at build time to also -// serve fresh TOFU-only nodes (the prior, more permissive behavior). No effect when PKI is -// excluded from the build: nothing can be signed there, so the gate is bypassed to avoid -// disabling the courtesy feature outright. +// Replay provenance gate: when 1 (default), direct responses are spoofed only for nodes whose +// cached key is signer-proven (XEdDSA-verified), not for trust-on-first-use identities. +// Define as 0 to also serve fresh TOFU-only nodes; bypassed entirely when PKI is excluded. #ifndef TMM_NODEINFO_REPLAY_REQUIRE_SIGNED #define TMM_NODEINFO_REPLAY_REQUIRE_SIGNED 1 #endif @@ -27,38 +22,18 @@ #define TMM_NODEINFO_REPLAY_SIGNED_GATE 0 #endif -// NodeInfo cache availability (compile-time). The cache's production home is ESP32 boards -// with PSRAM - at 2000 entries the flat array is far too large for MCU internal RAM - but -// the code itself is portable. Native unit-test builds (ARCH_PORTDUINO + PIO_UNIT_TESTING) -// enable it on the plain heap so the cache paths - key pinning, signer provenance, -// staleness, throttle, retention - run in CI instead of only on hardware. Production -// portduino (meshtasticd) and embedded test builds are unaffected. Tests that need the -// NodeDB fallback path instead call dropNodeInfoCacheForTest(). +// NodeInfo cache availability. Production home is ESP32+PSRAM (the 2000-entry array is too big +// for MCU internal RAM); native unit-test builds enable it on the plain heap so the cache paths +// run in CI (tests needing the NodeDB fallback call dropNodeInfoCacheForTest()). #if (defined(ARCH_ESP32) && defined(BOARD_HAS_PSRAM)) || (defined(ARCH_PORTDUINO) && defined(PIO_UNIT_TESTING)) #define TMM_HAS_NODEINFO_CACHE 1 #else #define TMM_HAS_NODEINFO_CACHE 0 #endif -/** - * TrafficManagementModule - Packet inspection and traffic shaping for mesh networks. - * - * This module provides: - * - Position deduplication (drop redundant position broadcasts) - * - Per-node rate limiting (throttle chatty nodes) - * - Unknown packet filtering (drop undecoded packets from repeat offenders) - * - NodeInfo direct response (answer queries from cache to reduce mesh chatter) - * - Local-only telemetry/position (exhaust hop_limit for local broadcasts) - * - Router hop preservation (maintain hop_limit for router-to-router traffic) - * - * Memory Optimization: - * Uses one flat unified cache (plain array, linear scan) shared by all - * per-node features instead of separate per-feature caches. Timestamps are - * stored as free-running modular tick counters (pos: 8-bit 360 s/tick; - * rate+unknown: paired 4-bit nibbles in one byte) for a 10-byte entry. - * LoRa packet rates are low enough that an O(n) scan of ~1000 entries is - * negligible next to packet processing. - */ +/// Packet inspection and traffic shaping: position dedup, per-node rate limiting, unknown-packet +/// filtering, NodeInfo direct response, and the next-hop/role overflow caches. One flat 10-byte +/// unified cache backs all per-node features; see docs/tmm_node_stores.md for the store overview. class TrafficManagementModule : public MeshModule, private concurrency::OSThread { public: @@ -69,160 +44,109 @@ class TrafficManagementModule : public MeshModule, private concurrency::OSThread TrafficManagementModule(const TrafficManagementModule &) = delete; TrafficManagementModule &operator=(const TrafficManagementModule &) = delete; + /// Snapshot of the module's counters (thread-safe). meshtastic_TrafficManagementStats getStats() const; + /// Zero all counters (thread-safe). void resetStats(); + /// Placeholder for the removed router_preserve_hops stat. void recordRouterHopPreserved(); - // Next-hop overflow cache (routing hint). - // setNextHop: store a confirmed last-byte next hop for `dest`. Called by - // NextHopRouter from its ACK-confirmed decision (see sniffReceived). The - // byte must come from a bidirectionally-verified relay, not one-way inference. - // getNextHopHint: return the cached next-hop byte for `dest`, 0 if unknown. - // clearNextHop: forget any cached next hop for `dest` (setNextHop refuses to store - // 0, so this is the way NextHopRouter decays a stale/failing overflow route). + /// Store a confirmed last-byte next hop for `dest`. Called only from NextHopRouter's + /// ACK-confirmed decision - the byte must come from a bidirectionally-verified relay. void setNextHop(NodeNum dest, uint8_t nextHopByte); + /// Cached next-hop byte for `dest`, 0 if unknown. uint8_t getNextHopHint(NodeNum dest); + /// Forget the cached next hop for `dest` (how NextHopRouter decays a failing route). void clearNextHop(NodeNum dest); - // Warm-start the next-hop cache from persisted NodeInfoLite hints so confirmed - // hops survive later hot-store (NodeDB) eviction. Idempotent; runs once after - // nodeDB is populated (lazily on first maintenance pass). - // @return true if it actually ran (prereqs met / nothing to do); false if - // prerequisites (cache, nodeDB) weren't ready yet, so the caller should retry. + /// Warm-start the next-hop cache from persisted NodeInfoLite hints so confirmed hops survive + /// hot-store eviction. @return true if it ran; false if prerequisites (cache, nodeDB) weren't + /// ready and the caller should retry on a later pass. bool preloadNextHopsFromNodeDB(); - // Last-resort public-key source for NodeDB::copyPublicKey(). After the hot (NodeInfoLite) - // and warm (WarmNodeStore) tiers miss, the NodeInfo direct-response cache may still hold a - // key learned from an observed NODEINFO frame, extending the pool of keys the node can - // encrypt to. Copies the 32-byte key for `node` into out[32] and returns true if present; - // false otherwise (including builds without the PSRAM NodeInfo cache). If `signerProven` - // is non-null, reports whether that key was verified via an XEdDSA signature (true) or is - // trust-on-first-use (false), so callers can weigh its trust. Thread-safe (takes cacheLock). + /// Last-resort key source for NodeDB::copyPublicKey() after the hot and warm tiers miss. + /// Copies the 32-byte key for `node` into out[32]; `signerProven` (optional) reports whether + /// the key was XEdDSA-verified vs trust-on-first-use. Thread-safe. bool copyPublicKey(NodeNum node, uint8_t out[32], bool *signerProven = nullptr) const; - // Copy the full cached User (name, short name, hw model, role, key, flags) for `node`, if the - // NodeInfo direct-response cache holds one. Used by NodeDB to rehydrate a re-admitted node's - // identity - the warm tier keeps a node's key but not its name, and this cache (much larger) - // often still has it. Returns false on miss or on builds without the PSRAM cache. If - // `signerProven` is non-null, reports the cached key's provenance. Thread-safe (takes cacheLock). + /// Copy the full cached User for `node` (used by NodeDB to rehydrate a re-admitted node's + /// name - the warm tier keeps keys but not names). False on miss or key-only records. + /// `signerProven` (optional) reports the cached key's provenance. Thread-safe. bool copyUser(NodeNum node, meshtastic_User &out, bool *signerProven = nullptr) const; - // Write-through hook from NodeDB: called after updateUser() commits a remote node's - // identity, so this cache reflects NodeDB immediately instead of waiting for the next - // reconcile sweep (which remains the anti-entropy backstop). Upserts the full User; - // NodeDB's key is authoritative (updateUser's own pin already rejected conflicts - // upstream, so a differing cached key is stale residue and is replaced, dropping its - // provenance), while a keyless commit keeps a key this cache already holds. When - // signerKnown (the node's verified-signer bit) and the commit carries a key, the key - // is marked signer-proven. Never touches the observation stamp: knowledge is not - // observation, so a hook write can never make a node servable by the replay path. - // No-op without the cache. Thread-safe (takes cacheLock). + /// Write-through hook from NodeDB::updateUser(): upsert the committed identity immediately + /// (the reconcile sweep remains the backstop). NodeDB's key is authoritative, but a keyless + /// commit keeps a TOFU key this cache already holds; never touches the observation stamp. void onNodeIdentityCommitted(NodeNum node, const meshtastic_User &user, bool signerKnown); - // Key-only commit hook, for the key-write sites that bypass NodeDB::updateUser (the - // Router's admin-key learn, KeyVerificationModule's manual-verification commit). Same - // rules as onNodeIdentityCommitted: NodeDB is senior on key content, a changed key - // resets provenance, and the observation stamp is never touched. `proven` marks the key - // signer-proven: pass true only when the commit itself established possession (a - // completed manual key verification), not for a TOFU-grade learn. Thread-safe. + /// Key-only commit hook for key writes that bypass updateUser (admin-key learn, manual key + /// verification). A changed key resets provenance; pass proven=true only when the commit + /// itself established possession. Never touches the observation stamp. Thread-safe. void onNodeKeyCommitted(NodeNum node, const uint8_t key32[32], bool proven); - // User-initiated removal is total: NodeDB's removal paths call these so no TMM tier can - // resurrect an identity the user deliberately deleted. purgeNode zeroes the node's - // NodeInfo cache slot (identity, key, provenance) AND its unified-cache slot (cached - // role, next-hop hint, dedup state); purgeAll clears both tables outright (resetNodes / - // factory reset). Passive eviction is unaffected - a node that merely ages out of NodeDB - // keeps its cache entries and can be re-recognized on next contact. Thread-safe. + /// Zero one node's slots in both caches (identity, key, provenance, role, next-hop, dedup + /// state). Called by NodeDB removal so no TMM tier resurrects a deliberately deleted node; + /// passive eviction is unaffected. Thread-safe. void purgeNode(NodeNum node); + /// Clear both cache tables outright (resetNodes / factory reset). Thread-safe. void purgeAll(); - /** - * Check if this packet should have its hops exhausted. - * Called from perhapsRebroadcast() to force hop_limit = 0 regardless of - * router_preserve_hops or favorite node logic. - */ + /// True when perhapsRebroadcast() must force hop_limit=0 for this packet, regardless of + /// router_preserve_hops or favorite-node logic (set by alterReceived()). bool shouldExhaustHops(const meshtastic_MeshPacket &mp) const { return exhaustRequested && exhaustRequestedFrom == getFrom(&mp) && exhaustRequestedId == mp.id; } - // Injectable monotonic clock (ms). All TMM time reads go through clockMs() so unit tests can - // advance a virtual timebase instead of sleeping real seconds across the 6 min/360 s tick. - // Mirrors HopScalingModule::s_testNowMs. Writable from tests as TrafficManagementModule::s_testNowMs; - // ignored in production (clockMs() returns millis()). + // Injectable monotonic clock (ms): tests advance s_testNowMs instead of sleeping across + // ticks (mirrors HopScalingModule); production reads millis(). inline static uint32_t s_testNowMs = 0; + /// Monotonic module clock in ms (virtual under PIO_UNIT_TESTING). #ifdef PIO_UNIT_TESTING static uint32_t clockMs() { return s_testNowMs; } #else static uint32_t clockMs() { return millis(); } #endif - // Timestamp for the one remaining millis-based field that uses 0 as a "never" sentinel - // (nodeInfoFallbackLastResponseMs - the cache entries use tick clocks with explicit - // validity bits instead). clockMs() is 0 for exactly one millisecond every ~49.7-day - // wrap, which would collide with the sentinel and momentarily disable the fallback - // throttle for a freshly stamped reply. Map that one instant to 1; the 1 ms skew is - // irrelevant to the throttle window. (T9) + /// clockMs() that never returns 0, for nodeInfoFallbackLastResponseMs whose 0 means "never". + /// The 1 ms skew at the ~49.7-day wrap is irrelevant to the throttle window. (T9) static uint32_t nowStampMs() { - const uint32_t t = clockMs(); - return t == 0 ? 1u : t; + const uint32_t nowMs = clockMs(); + return nowMs == 0 ? 1u : nowMs; } protected: + /// Inspect a received packet; may consume it (STOP) for dedup/rate/unknown/direct-response. ProcessMessage handleReceived(const meshtastic_MeshPacket &mp) override; + /// Promiscuous: this module inspects every packet. bool wantPacket(const meshtastic_MeshPacket *p) override { return true; } + /// Mutate relayed packets in place (position precision clamp). void alterReceived(meshtastic_MeshPacket &mp) override; + /// 60 s maintenance sweep: expire timed state, saturate tick stamps, reconcile with NodeDB. int32_t runOnce() override; - // Protected so test shims can flush per-node traffic state. + /// Clear all per-node traffic state (protected for test shims). void flushCache(); - // Introspection for tests: the cached device role for a node, or -1 if the node has - // no cache entry (distinguishes "not tracked / evicted" from CLIENT == 0). + /// Test introspection: the cached role for `node`, or -1 when it has no entry + /// (distinguishes "not tracked" from CLIENT == 0). int peekCachedRole(NodeNum node); - // Test hook: force a cached NodeInfo entry's key to signer-proven, so replay-gate tests can - // exercise the signed-only path without standing up a full XEdDSA verification. No-op if the - // node is not cached or the NodeInfo cache is absent. + /// Test hook: force a cached NodeInfo entry's key to signer-proven so replay-gate tests + /// can skip a full XEdDSA verification. No-op if absent. void markKeySignerProvenForTest(NodeNum node); - // Test hook: free and clear the NodeInfo cache so the NodeDB fallback direct-response - // path can be exercised in a build where the cache is compiled in (native test builds - // allocate it on the heap - see TMM_HAS_NODEINFO_CACHE). No-op when already absent. + /// Test hook: free the NodeInfo cache so the NodeDB fallback path can be exercised in + /// builds where the cache is compiled in. No-op when already absent. void dropNodeInfoCacheForTest(); - // Test introspection: the NodeInfo entry's flag bits for `node`, or -1 if absent (or no - // cache). bit0 hasObserved, bit1 hasResponded, bit2 isMember, bit3 hasFullUser, - // bit4 keySignerProven. Lets saturation/membership tests assert sweep effects directly - // instead of inferring them from response behavior. + /// Test introspection: NodeInfo flag bits for `node` (-1 if absent): bit0 hasObserved, + /// bit1 hasResponded, bit2 isMember, bit3 hasFullUser, bit4 keySignerProven. int peekNodeInfoFlagsForTest(NodeNum node); private: - // ========================================================================= - // Unified Cache Entry (10 bytes) - Same for ALL platforms - // ========================================================================= - // - // Layout: - // [0-3] node - NodeNum (4 bytes, 0 = empty slot) - // [4] pos_fingerprint - 4 bits lat + 4 bits lon (0 = no position seen) - // [5] rate_count - [7:6] role[3:2] | [5:0] packets in rate window (0 = no window active) - // [6] unknown_count - [7:6] role[1:0] | [5:0] unknown packets in window (0 = no window active) - // [7] pos_time - Position tick (uint8, free-running 360 s/tick) - // [8] rate_unknown_time - [7:4] rate nibble (300 s/tick) | [3:0] unknown nibble (60 s/tick) - // [9] next_hop - Last-byte relay to reach `node` (0 = none) - // - // The 4-bit device role (bits [7:6] of rate_count paired with [7:6] of unknown_count) - // caches the sender's meshtastic_Config_DeviceConfig_Role as a third fallback after the - // hot store and warm store, for nodes evicted from both. Read/written via - // resolveSenderRole(). Max encodable value is 15. - // - // Presence sentinels (no epoch, no +1 offset needed): - // pos active: pos_fingerprint != 0 - // rate active: getRateCount() != 0 (low 6 bits only) - // unknown active: getUnknownCount() != 0 (low 6 bits only) - // - // next_hop: routing hint written only from ACK-confirmed NextHopRouter decisions. - // No TTL - keeps the slot alive across maintenance sweeps. - // + // 10-byte packed entry, all platforms. Tick stamps are free-running modular counters with + // non-zero presence sentinels; the 4-bit cached role rides the top bits of the two count + // bytes (tier-3 role fallback). Full layout and rationale: docs/tmm_node_stores.md. #if _meshtastic_Config_DeviceConfig_Role_MAX > 15 #warning "Device role enum max exceeds 15 - TMM 4-bit role cache (rate_count[7:6]/unknown_count[7:6]) will truncate new values" #endif @@ -235,99 +159,75 @@ class TrafficManagementModule : public MeshModule, private concurrency::OSThread uint8_t rate_unknown_time; uint8_t next_hop; + /// Packets seen in the current rate window (low 6 bits). uint8_t getRateCount() const { return rate_count & 0x3F; } + /// Set the rate-window count, preserving the role bits. void setRateCount(uint8_t c) { rate_count = static_cast((rate_count & 0xC0) | (c & 0x3F)); } + /// Unknown packets seen in the current window (low 6 bits). uint8_t getUnknownCount() const { return unknown_count & 0x3F; } + /// Set the unknown-window count, preserving the role bits. void setUnknownCount(uint8_t c) { unknown_count = static_cast((unknown_count & 0xC0) | (c & 0x3F)); } + /// Cached 4-bit device role, reassembled from the two count bytes' top bits. uint8_t getCachedRole() const { return static_cast(((rate_count >> 6) << 2) | (unknown_count >> 6)); } + /// Store a 4-bit device role across the two count bytes' top bits. void setCachedRole(uint8_t role) { rate_count = static_cast((rate_count & 0x3F) | ((role >> 2) << 6)); unknown_count = static_cast((unknown_count & 0x3F) | ((role & 0x03) << 6)); } + /// Rate-window tick nibble. uint8_t getRateTime() const { return (rate_unknown_time >> 4) & 0x0F; } + /// Unknown-window tick nibble. uint8_t getUnknownTime() const { return rate_unknown_time & 0x0F; } + /// Set the rate-window tick nibble. void setRateTime(uint8_t t) { rate_unknown_time = static_cast((rate_unknown_time & 0x0F) | ((t & 0x0F) << 4)); } + /// Set the unknown-window tick nibble. void setUnknownTime(uint8_t t) { rate_unknown_time = static_cast((rate_unknown_time & 0xF0) | (t & 0x0F)); } }; static_assert(sizeof(UnifiedCacheEntry) == 10, "UnifiedCacheEntry should be 10 bytes"); - // ========================================================================= - // Flat unified cache - // ========================================================================= - // - // Plain array, linear scan (same idiom as WarmNodeStore). A lookup walks at - // most cacheSize() × 10 B - microseconds at LoRa packet rates, not worth a - // hash table. Insertion on a full cache evicts the stalest entry, - // preferring entries without a next_hop hint (those are the long-tail - // routing state this cache exists to keep). - // + /// Unified cache capacity. Plain array, linear scan (same idiom as WarmNodeStore); insertion + /// on a full cache evicts the stalest entry, preferring ones without a next_hop hint. static constexpr uint16_t cacheSize() { return TRAFFIC_MANAGEMENT_CACHE_SIZE; } - // NodeInfo cache configuration (PSRAM path): a flat PSRAM array of payload - // entries, linear scan keyed by `node`, trust/membership-tiered LRU eviction on insert. - // NodeInfo traffic is low-rate, so a full scan per lookup/insert is fine. + // NodeInfo cache (PSRAM path): flat payload array, linear scan, trust/membership-tiered + // LRU eviction on insert. NodeInfo traffic is low-rate, so full scans are fine. static constexpr uint16_t kNodeInfoCacheEntries = 2000; + /// NodeInfo cache capacity. static constexpr uint16_t nodeInfoTargetEntries() { return kNodeInfoCacheEntries; } - // ========================================================================= - // Free-Running Tick Counters - // ========================================================================= - // - // Timestamps are stored as free-running modular tick counters derived from - // millis(). No epoch anchor needed: modular subtraction gives correct age - // as long as the true age stays below the counter period. - // - // pos_time : uint8 (256 ticks × 360 s = 25.6 h period; max window 12 h = 120 ticks) - // rate_time : nibble (16 ticks × 300 s = 80 min period; max window 1 h = 12 ticks) - // unknown_time: nibble (16 ticks × 60 s = 16 min period; max window 12 min = 12 ticks) - // - // Presence sentinels (no +1 offset needed; count fields serve as guards): - // pos active: pos_fingerprint != 0 (0 is reserved sentinel; computePositionFingerprint() remaps computed-0 → 0xFF) - // rate active: getRateCount() != 0 (low 6 bits; high 2 bits are cached role) - // unknown active: getUnknownCount() != 0 - // - static constexpr uint32_t kPosTimeTickMs = 360'000UL; // 6 min/tick - static constexpr uint32_t kRateTimeTickMs = 300'000UL; // 5 min/tick - static constexpr uint32_t kUnknownTimeTickMs = 60'000UL; // 1 min/tick + // Free-running modular tick clocks derived from clockMs(); modular subtraction gives correct + // age while true age stays below the counter period. Presence is carried by non-zero + // sentinels (unified cache) or explicit validity bits (NodeInfo cache). + static constexpr uint32_t kPosTimeTickMs = 360'000UL; // 6 min/tick (uint8: 25.6 h period) + static constexpr uint32_t kRateTimeTickMs = 300'000UL; // 5 min/tick (nibble: 80 min period) + static constexpr uint32_t kUnknownTimeTickMs = 60'000UL; // 1 min/tick (nibble: 16 min period) + /// Current position-clock tick (6 min/tick). static uint8_t currentPosTick() { return static_cast(clockMs() / kPosTimeTickMs); } + /// Current rate-clock tick nibble (5 min/tick). static uint8_t currentRateTick() { return static_cast((clockMs() / kRateTimeTickMs) & 0x0F); } + /// Current unknown-clock tick nibble (1 min/tick). static uint8_t currentUnknownTick() { return static_cast((clockMs() / kUnknownTimeTickMs) & 0x0F); } - // NodeInfo cache ticks (same idiom, applied to NodeInfoPayloadEntry): - // obsTick : uint8 (256 ticks x 3 min = 12.8 h period; serve window 6 h = 120 ticks, 2.1x margin) - // respTick: uint8 (256 ticks x 5 s = 21.3 min period; throttle window 30 s = 6 ticks, 42x margin) - // Presence is an explicit bit per stamp (hasObserved / hasResponded), not a 0-sentinel; - // the 60 s maintenance sweep clears each bit once its window passes ("saturation"), so a - // stamp is never read anywhere near its aliasing horizon. +-1 tick granularity error - // (+-3 min on a 6 h gate, +-5 s on a 30 s throttle) is noise for these windows. - static constexpr uint32_t kNodeInfoObsTickMs = 180000UL; // 3 min/tick - static constexpr uint32_t kNodeInfoRespTickMs = 5000UL; // 5 s/tick + // NodeInfo cache ticks (same idiom). The 60 s sweep clears each presence bit once its window + // passes, so stamps are never read near their uint8 aliasing horizon. + static constexpr uint32_t kNodeInfoObsTickMs = 180000UL; // 3 min/tick (12.8 h period) + static constexpr uint32_t kNodeInfoRespTickMs = 5000UL; // 5 s/tick (21.3 min period) static constexpr uint8_t kNodeInfoMaxServeAgeTicks = 120; // 6 h serve window static constexpr uint8_t kNodeInfoThrottleTicks = 6; // 30 s throttle window + /// Current NodeInfo observation tick (3 min/tick). static uint8_t currentObsTick() { return static_cast(clockMs() / kNodeInfoObsTickMs); } + /// Current NodeInfo response-throttle tick (5 s/tick). static uint8_t currentRespTick() { return static_cast(clockMs() / kNodeInfoRespTickMs); } static_assert(kNodeInfoMaxServeAgeTicks * kNodeInfoObsTickMs == 6UL * 60UL * 60UL * 1000UL, "cache serve window must equal the fallback path's 6 h"); static_assert(kNodeInfoThrottleTicks * kNodeInfoRespTickMs == 30UL * 1000UL, "cache throttle window must equal the fallback path's 30 s"); - // ========================================================================= - // Position Fingerprint - // ========================================================================= - // - // Computes 8-bit fingerprint from truncated lat/lon coordinates. - // Extracts lower 4 significant bits from each coordinate. - // - // fingerprint = (lat_low4 << 4) | lon_low4 - // - // Unlike a hash, adjacent grid cells have sequential fingerprints, - // so nearby positions never collide. Collisions only occur for - // positions 16+ grid cells apart in both dimensions. - // - // Guards: If precision < 4 bits, uses min(precision, 4) bits. - // + + /// 8-bit position fingerprint from truncated lat/lon: low 4 significant bits of each, so + /// adjacent grid cells never collide (collisions need 16+ cells apart in both dimensions). static uint8_t computePositionFingerprint(int32_t lat_truncated, int32_t lon_truncated, uint8_t precision); // ========================================================================= @@ -339,104 +239,71 @@ class TrafficManagementModule : public MeshModule, private concurrency::OSThread bool cacheFromPsram = false; // Tracks allocator for correct deallocation struct NodeInfoPayloadEntry { - // Node identifier associated with this payload slot. - // 0 means the slot is currently unused. + // Node identifier for this slot; 0 means unused. NodeNum node; - // Cached NODEINFO_APP payload body. This is separate from NodeDB and is only - // used by the PSRAM-backed direct-response path in this module. + // Cached NODEINFO_APP payload, independent of NodeDB; serves the PSRAM-backed + // direct-response path and the last-resort pubkey pool. meshtastic_User user; - // Extra response metadata captured from the latest observed NODEINFO_APP - // packet for this node. shouldRespondToNodeInfo() uses this metadata when - // building spoofed replies for requesting clients. - - // Free-running tick stamps (uint8 modular clocks, kNodeInfo*TickMs in the header - - // same scheme as the UnifiedCache tick counters). Validity of obsTick/respTick is - // carried by the hasObserved/hasResponded flag bits below, not a 0 sentinel; the - // maintenance sweep clears a flag once its window passes (saturation), so no stamp - // can age far enough for its modular compare to alias. - - // Free-running tick (kNodeInfoObsTickMs) of the last genuinely HEARD NODEINFO frame - // from this node. Drives the replay staleness gate and the LRU age - seeding and - // write-through hooks never touch it, so a spoofed reply is only ever backed by - // genuine recent observation. + // Tick of the last genuinely HEARD NODEINFO frame (kNodeInfoObsTickMs clock). Drives the + // replay staleness gate and LRU age; seeding/write-through never touch it, so a spoofed + // reply is only ever backed by genuine recent observation. Validity: hasObserved. uint8_t obsTick; - // Free-running tick (kNodeInfoRespTickMs) of the most recent spoofed direct reply - // we emitted for this node. Drives the per-target response throttle. + // Tick of our most recent spoofed direct reply (kNodeInfoRespTickMs clock). Drives the + // per-target response throttle. Validity: hasResponded. uint8_t respTick; // Channel where we most recently heard this node's NodeInfo. uint8_t sourceChannel; - // Cached decoded bitfield metadata from the source packet. - // We preserve non-OK_TO_MQTT bits in direct replies when available. + // Cached decoded bitfield from the source packet (non-OK_TO_MQTT bits are preserved + // in direct replies). Validity: hasDecodedBitfield. uint8_t decodedBitfield; - // Boolean flags, declared adjacent as 1-bit fields so the compiler packs them into a - // single byte, leaving 6 spare bits for future flags without growing the 2000-entry - // PSRAM array. Access is by name, exactly like plain bools. + // 1-bit flags, packed into one byte (6 spare bits; add future booleans here rather + // than new bytes - the array is 2000 entries in PSRAM). // The source packet carried a decoded bitfield (so decodedBitfield is meaningful). uint8_t hasDecodedBitfield : 1; - // Provenance of user.public_key: 1 once we have observed a NODEINFO_APP frame for this - // node whose XEdDSA signature we verified (directly via mp.xeddsa_signed, or inherited - // from NodeDB via isVerifiedSignerForKey) - i.e. the key is proven to belong to a - // signer, not merely trust-on-first-use. Monotonic: once set it stays set for the life - // of the slot (the key-pin checks forbid the key changing underneath it). Used as a - // trust tier: proven keys are the stickiest under LRU eviction and are reported to - // copyPublicKey() consumers. A signature can only be verified against a key we already - // held, so a first-contact key is always TOFU (0) until a later signed frame upgrades it. + // Key provenance: set once an XEdDSA signature was verified for user.public_key + // (directly, or inherited from NodeDB via isVerifiedSignerForKey). Monotonic per slot; + // the key-pin checks forbid the key changing underneath it. TOFU keys start at 0. uint8_t keySignerProven : 1; - // obsTick is valid: we have actually heard a NODEINFO_APP frame from this node - // within the observation clock's horizon. Cleared by the sweep once the serve - // window passes (saturation) - both states mean "do not serve a spoofed reply". + // obsTick is valid: a NODEINFO frame was actually heard within the observation clock's + // horizon. Cleared by the sweep once the serve window passes (saturation). uint8_t hasObserved : 1; - // respTick is valid: we emitted a spoofed reply within the throttle clock's - // horizon. Cleared by the sweep once the throttle window passes. + // respTick is valid: a spoofed reply was emitted within the throttle clock's horizon. + // Cleared by the sweep once the throttle window passes. uint8_t hasResponded : 1; - // `user` carries a real User payload (name etc.) - from an observed NODEINFO frame - // or a hot-store seed - rather than being a key-only record seeded from the warm - // tier. copyUser()/name-rehydration require it; copyPublicKey() does not. + // `user` carries a real User payload (from an observed frame or hot-store seed) rather + // than a key-only warm-tier record. copyUser()/name-rehydration require it. uint8_t hasFullUser : 1; - // The node currently exists in NodeDB (hot store or warm tier), per the last - // maintenance-sweep membership check (may be up to one sweep / 60 s stale). Member - // entries are the stickiest under LRU eviction, keeping the cache a superset of - // NodeDB's identities; the bit itself is the keep-alive - nothing expires by TTL. + // Node currently exists in NodeDB (hot or warm), per the last sweep's membership check. + // Member entries are stickiest under LRU; the bit is the keep-alive (no TTL). uint8_t isMember : 1; }; - // The tick stamps, sourceChannel, decodedBitfield, and the 1-bit flags make up the - // entry's trailing metadata; the flag bits share a single byte, leaving 2 spare bits. - // Add future booleans as more 1-bit fields here rather than new bytes - the array is - // 2000 entries in PSRAM, so a fresh byte can cost a whole aligned word. (No exact-size - // static_assert: sizeof(meshtastic_User) and its trailing padding vary by platform - // - nanopb packs the generated struct differently on embedded targets - so any fixed byte - // count is non-portable and would fail the build on some boards.) + // No exact-size static_assert: sizeof(meshtastic_User) and its padding vary by platform, so + // any fixed byte count would fail the build on some boards. NodeInfoPayloadEntry *nodeInfoPayload = nullptr; // NodeInfo payloads in PSRAM (flat array, linear scan) bool nodeInfoPayloadFromPsram = false; // Tracks allocator for correct deallocation - // Throttle stamp for the NodeDB fallback direct-response path (non-PSRAM boards). - // The cache path throttles per target via NodeInfoPayloadEntry::respTick, but - // the fallback path has no per-node slot to stamp, so it would otherwise emit spoofed - // replies with no rate limit at all. This single module-global stamp throttles that - // path to at most one spoofed reply per kNodeInfoResponseThrottleMs across all targets. - // Coarser than per-target, but the fallback path has nowhere to store per-node state, - // and a global cap still bounds spoofed transmissions - which is the point of the - // throttle. 0 = never responded. Guarded by cacheLock. Local uptime (millis). + // Fallback-path response throttle stamp (module-global; the cache path throttles per entry + // via respTick). Bounds spoofed replies to one per kNodeInfoResponseThrottleMs across all + // targets on non-PSRAM boards. 0 = never responded. Guarded by cacheLock. Local uptime (ms). uint32_t nodeInfoFallbackLastResponseMs = 0; meshtastic_TrafficManagementStats stats; - // Flag set during alterReceived() when packet should be exhausted. - // Checked by perhapsRebroadcast() to force hop_limit = 0 only for the - // matching packet key (from + id). Reset at start of handleReceived(). + // Set during alterReceived() when the packet's hops should be exhausted; checked by + // perhapsRebroadcast() for the matching packet key. Reset at start of handleReceived(). bool exhaustRequested = false; NodeNum exhaustRequestedFrom = 0; PacketId exhaustRequestedId = 0; @@ -444,10 +311,8 @@ class TrafficManagementModule : public MeshModule, private concurrency::OSThread // One-shot guard: warm-start next-hop cache from NodeDB on first maintenance pass. bool nextHopPreloaded = false; - // Anti-entropy cadence for reconcileNodeInfoFromNodeDBLocked(): a full boot seed on the - // first maintenance pass (once nodeDB is ready), then one reconciliation per hour of - // sweeps. The write-through hooks give immediacy; this periodic repair self-heals - // anything they miss (boot ordering, a write path without a hook, future NodeDB paths). + // Reconcile cadence: full boot seed on the first maintenance pass, then hourly. The + // write-through hooks give immediacy; this periodic repair self-heals anything they miss. static constexpr uint8_t kNodeInfoReconcileSweeps = 60; // sweeps between reconciliations (60 x 60 s = 1 h) bool nodeInfoSeeded = false; uint8_t sweepsSinceNodeInfoReconcile = 0; @@ -456,61 +321,60 @@ class TrafficManagementModule : public MeshModule, private concurrency::OSThread // Cache Operations // ========================================================================= - // Find or create entry for node (linear scan; stalest-first eviction when full) + /// Find or create the unified-cache entry for `node` (stalest-first eviction when full). UnifiedCacheEntry *findOrCreateEntry(NodeNum node, bool *isNew); - // Find existing entry (no creation) + /// Find an existing unified-cache entry (no creation). UnifiedCacheEntry *findEntry(NodeNum node); - // Resolve a sender's advertised device role for the position hot path. The tier-3 - // cache (this entry's getCachedRole) is authoritative and is kept fresh by - // updateCachedRoleFromNodeInfo() - updated when NodeDB learns a role, not re-derived - // per packet. Only on first tracking (isNew) do we scan NodeDB (hot store → warm - // store, via getNodeRole) to seed the cache, so a resident special-role node is - // correct from its first position; after that the read is O(1) and survives the node - // aging out of both NodeDB stores. Caller must hold cacheLock; entry may be null - // (→ NodeDB scan only). + /// Resolve a sender's device role for the position hot path. The tier-3 cache is + /// authoritative once seeded (NodeDB is scanned only on first tracking), so the read is O(1) + /// and survives the node aging out of both NodeDB stores. Caller must hold cacheLock. meshtastic_Config_DeviceConfig_Role resolveSenderRole(NodeNum from, UnifiedCacheEntry *entry, bool isNew); - // Refresh the tier-3 role cache from an observed NodeInfo (the same event that updates - // NodeDB's role). Reads role from the packet's User payload; updates only nodes already - // tracked (no entry creation). Takes cacheLock. + /// Refresh the tier-3 role cache from an observed NodeInfo (the same event that updates + /// NodeDB's role). Updates only nodes already tracked. Takes cacheLock. void updateCachedRoleFromNodeInfo(const meshtastic_MeshPacket &mp); - // NodeInfo cache operations (flat PSRAM payload array, linear scan) + /// Find an existing NodeInfo cache entry (no creation). const NodeInfoPayloadEntry *findNodeInfoEntry(NodeNum node) const; + /// Mutable variant of findNodeInfoEntry(). NodeInfoPayloadEntry *findNodeInfoEntryMutable(NodeNum node) { return const_cast(findNodeInfoEntry(node)); } - // spareMembers: when creating would evict an isMember entry, return nullptr instead. - // The seeding pass uses it so seeding one NodeDB-tier node never churns out another - // when hot+warm exceeds the cache; the packet path leaves it false (a freshly - // observed stranger may displace the LRU member - observed freshness has value). + /// Find or create a NodeInfo cache entry, evicting by trust/membership tier when full. + /// With spareMembers, returns nullptr instead of evicting an isMember entry (the seeding + /// pass never churns one NodeDB-tier node out for another; the packet path may). NodeInfoPayloadEntry *findOrCreateNodeInfoEntry(NodeNum node, bool *usedEmptySlot, bool spareMembers = false); + /// Number of occupied NodeInfo cache slots. Caller must hold cacheLock. uint16_t countNodeInfoEntriesLocked() const; - // Anti-entropy seeding, under cacheLock: walk the NodeDB hot store (full identity) and - // warm tier (key-only records) and upsert whatever this cache lacks, marking members - // and adopting NodeDB's key when a cached key conflicts with the authoritative one. - // Runs at boot and then hourly (kNodeInfoReconcileSweeps); the write-through hooks give - // immediacy in between, and per-entry membership refresh happens every sweep. Never - // sets hasObserved - seeding is knowledge, not observation, so it can never make a - // silent node servable by the replay path. + /// Anti-entropy seeding under cacheLock: upsert hot-store identities and warm-tier key-only + /// records this cache lacks. Never sets hasObserved - seeding is knowledge, not observation, + /// so it can never make a silent node servable by the replay path. void reconcileNodeInfoFromNodeDBLocked(); + /// Learn an observed NODEINFO frame into the cache (key hygiene + provenance rules apply). void cacheNodeInfoPacket(const meshtastic_MeshPacket &mp); // ========================================================================= // Traffic Management Logic // ========================================================================= + /// True when this position broadcast duplicates the sender's last one within the dedup window. bool shouldDropPosition(const meshtastic_MeshPacket *p, const meshtastic_Position *pos, uint32_t nowMs); + /// Decide (and with sendResponse, emit) a spoofed direct NodeInfo reply for a unicast request. bool shouldRespondToNodeInfo(const meshtastic_MeshPacket *p, bool sendResponse); + /// True when the requestor is within the role-clamped hop limit for direct responses. bool isMinHopsFromRequestor(const meshtastic_MeshPacket *p) const; + /// True when `from` exceeded the configured packet budget for the current rate window. bool isRateLimited(NodeNum from, uint32_t nowMs); + /// True when `p`'s sender exceeded the undecodable-packet threshold for the current window. bool shouldDropUnknown(const meshtastic_MeshPacket *p, uint32_t nowMs); + /// Log a traffic action (drop/respond/clamp) with port name and packet routing context. void logAction(const char *action, const meshtastic_MeshPacket *p, const char *reason) const; + /// Increment a stats counter under cacheLock. void incrementStat(uint32_t *field); }; diff --git a/test/test_traffic_management/test_main.cpp b/test/test_traffic_management/test_main.cpp index 8755d7cfb0c..02e763addca 100644 --- a/test/test_traffic_management/test_main.cpp +++ b/test/test_traffic_management/test_main.cpp @@ -1006,6 +1006,50 @@ static void test_tm_nodeinfo_cache_pinsAgainstWarmTierKey(void) mockNodeDB->warmStore.clear(); } +/** + * Unsigned-identity gate, warm tier: a verified signer evicted to the warm tier must not be + * impersonatable. An attacker can forge an unsigned NodeInfo carrying the signer's real + * (public!) key - it passes the key pin and would inherit warm signer provenance - so the + * gate must classify warm-tier signers, not only hot-store ones. A signature-verified frame + * (control) is still learned. + */ +static void test_tm_nodeinfo_gate_blocksUnsignedWarmSignerForgery(void) +{ + moduleConfig.traffic_management.nodeinfo_direct_response_max_hops = 10; + config.device.role = meshtastic_Config_DeviceConfig_Role_CLIENT; + mockNodeDB->clearCachedNode(); + mockNodeDB->rollHotStore(); // hot store misses; only the warm tier knows the signer + uint8_t warmKey[32]; + memset(warmKey, 0x5A, 32); + mockNodeDB->warmStore.clear(); + mockNodeDB->warmStore.absorb(kTargetNode, 1000000, warmKey, 0, 0, /*signer=*/true); + + MockRouter mockRouter; + mockRouter.addInterface(std::unique_ptr(new MockRadioInterface())); + MeshService mockService; + router = &mockRouter; + service = &mockService; + + TrafficManagementModuleTestShim module; + + // Forgery: unsigned NodeInfo with the REAL key (passes the pin) and an attacker name. + meshtastic_MeshPacket forged = makeNodeInfoPacketWithKey(kTargetNode, "attacker", 0x5A); + forged.xeddsa_signed = false; + module.handleReceived(forged); + TEST_ASSERT_EQUAL_INT(-1, module.peekNodeInfoFlagsForTest(kTargetNode)); // nothing cached + meshtastic_User out = meshtastic_User_init_zero; + TEST_ASSERT_FALSE(module.copyUser(kTargetNode, out, nullptr)); + + // Control: the same identity, signature-verified, is learned. + meshtastic_MeshPacket genuine = makeNodeInfoPacketWithKey(kTargetNode, "genuine", 0x5A); + genuine.xeddsa_signed = true; + module.handleReceived(genuine); + TEST_ASSERT_TRUE(module.copyUser(kTargetNode, out, nullptr)); + TEST_ASSERT_EQUAL_STRING("genuine", out.long_name); + + mockNodeDB->warmStore.clear(); +} + /** * Reconcile seeding, serve-gate honesty: a hot-store identity is seeded into the cache by * the maintenance sweep (name + key + signer provenance usable via copyUser/copyPublicKey), @@ -1096,6 +1140,51 @@ static void test_tm_nodeinfo_reconcile_seedsKeyOnlyFromWarmTier(void) mockNodeDB->warmStore.clear(); } +/** + * Reconcile must not let a keyless hot-store identity erase a TOFU key this cache already + * learned (the same merge rule as onNodeIdentityCommitted): the hot name is adopted, the + * kept key survives for the copyPublicKey pool - and stays unproven, because the hot signer + * bit vouches only for a NodeDB-supplied key, never the kept TOFU one. + */ +static void test_tm_nodeinfo_reconcile_keepsTofuKeyOnKeylessHotIdentity(void) +{ + moduleConfig.traffic_management.nodeinfo_direct_response_max_hops = 10; + config.device.role = meshtastic_Config_DeviceConfig_Role_CLIENT; + mockNodeDB->clearCachedNode(); + mockNodeDB->rollHotStore(); + mockNodeDB->warmStore.clear(); + + MockRouter mockRouter; + mockRouter.addInterface(std::unique_ptr(new MockRadioInterface())); + MeshService mockService; + router = &mockRouter; + service = &mockService; + + TrafficManagementModuleTestShim module; + + // TOFU learn while NodeDB knows nothing about the node. + module.handleReceived(makeNodeInfoPacketWithKey(kTargetNode, "tofu-name", 0x33)); + uint8_t key[32] = {0}; + TEST_ASSERT_TRUE(module.copyPublicKey(kTargetNode, key, nullptr)); + + // NodeDB then learns the node with a User but NO key; its signed bit is even set, which + // must vouch for nothing here since NodeDB supplies no key to match it against. + mockNodeDB->setSignerHotNode(kTargetNode, "hot-name"); + module.runOnce(); // maintenance sweep -> reconcile adopts the hot identity + + bool proven = true; + memset(key, 0, sizeof(key)); + TEST_ASSERT_TRUE(module.copyPublicKey(kTargetNode, key, &proven)); // TOFU key survived + TEST_ASSERT_EQUAL_UINT8(0x33, key[0]); + TEST_ASSERT_EQUAL_UINT8(0x33, key[31]); + TEST_ASSERT_FALSE(proven); + meshtastic_User out = meshtastic_User_init_zero; + TEST_ASSERT_TRUE(module.copyUser(kTargetNode, out, nullptr)); + TEST_ASSERT_EQUAL_STRING("hot-name", out.long_name); // hot identity adopted + + mockNodeDB->rollHotStore(); +} + /** * Write-through hook: an identity committed through NodeDB::updateUser() lands in the * NodeInfo cache immediately (name + key), without waiting for a maintenance sweep - and @@ -2658,8 +2747,10 @@ TM_TEST_ENTRY void setup() RUN_TEST(test_tm_nodeinfo_cache_rejectsMismatchedKey); #if WARM_NODE_COUNT > 0 RUN_TEST(test_tm_nodeinfo_cache_pinsAgainstWarmTierKey); + RUN_TEST(test_tm_nodeinfo_gate_blocksUnsignedWarmSignerForgery); RUN_TEST(test_tm_nodeinfo_reconcile_seedsFromHotStoreButNeverServes); RUN_TEST(test_tm_nodeinfo_reconcile_seedsKeyOnlyFromWarmTier); + RUN_TEST(test_tm_nodeinfo_reconcile_keepsTofuKeyOnKeylessHotIdentity); RUN_TEST(test_tm_nodeinfo_updateUserHook_writesThrough); RUN_TEST(test_tm_nodeinfo_removeNode_purgesCaches); RUN_TEST(test_tm_nodeinfo_noTimedEviction_quietKeyedEntrySurvives); From 4b579ebb9602d55e415a3c8b97eeb25d6404caa0 Mon Sep 17 00:00:00 2001 From: nomdetom Date: Fri, 17 Jul 2026 23:20:04 +0100 Subject: [PATCH 31/44] TrafficManagement: key NodeInfo-cache maintenance to its own guard runOnce() nested the entire NodeInfo maintenance block (tick-stamp saturation, membership refresh, boot/hourly reconcile) inside #if TRAFFIC_MANAGEMENT_CACHE_SIZE > 0, so a variant overriding the unified cache to 0 on a PSRAM board would compile the NodeInfo cache without its maintenance: hasObserved would never saturate, obsTick would alias past its 12.8 h wrap, and the 6 h staleness gate - whose wrap-safety explicitly depends on the sweep - would serve spoofed replies for long-gone nodes. Extract maintainNodeInfoCacheLocked(), guarded by TMM_HAS_NODEINFO_CACHE alone, and give runOnce() the same independent-guard structure purgeAll() already has. Also close the runtime cousin of the same mismatch: the write-through hooks (onNodeIdentityCommitted / onNodeKeyCommitted) now no-op while moduleConfig.has_traffic_management is off. Previously they kept filling the cache from NodeDB commits while runOnce() returned INT32_MAX, accumulating entries that were never swept or reconciled. Purges and reads stay ungated: removal must always work, and reads just miss an empty cache. Co-Authored-By: Claude Fable 5 --- src/modules/TrafficManagementModule.cpp | 112 ++++++++++++++---------- src/modules/TrafficManagementModule.h | 9 ++ 2 files changed, 76 insertions(+), 45 deletions(-) diff --git a/src/modules/TrafficManagementModule.cpp b/src/modules/TrafficManagementModule.cpp index c8fffac2375..d4dfc4a28bb 100644 --- a/src/modules/TrafficManagementModule.cpp +++ b/src/modules/TrafficManagementModule.cpp @@ -647,9 +647,61 @@ void TrafficManagementModule::reconcileNodeInfoFromNodeDBLocked() #endif } +void TrafficManagementModule::maintainNodeInfoCacheLocked() +{ +#if TMM_HAS_NODEINFO_CACHE + if (!nodeInfoPayload) + return; + + // Saturate expired tick stamps: clearing the presence bit once a stamp's age exceeds + // its window is the wrap-safety guarantee (stamps never approach their uint8 aliasing + // horizon). Entries are never freed on a timer - slots die by tiered LRU or purge. + uint16_t nodeInfoSaturated = 0; + const uint8_t nowObs = currentObsTick(); + const uint8_t nowResp = currentRespTick(); + for (uint16_t i = 0; i < nodeInfoTargetEntries(); i++) { + NodeInfoPayloadEntry &entry = nodeInfoPayload[i]; + if (entry.node == 0) + continue; + if (entry.hasObserved && static_cast(nowObs - entry.obsTick) > kNodeInfoMaxServeAgeTicks) { + entry.hasObserved = false; + nodeInfoSaturated++; + } + if (entry.hasResponded && static_cast(nowResp - entry.respTick) >= kNodeInfoThrottleTicks) + entry.hasResponded = false; + // Refresh membership: the bit keeps NodeDB-present nodes stickiest under LRU, and + // its clearing is what lets a fully forgotten node become evictable again. + bool member = nodeDB && nodeDB->getMeshNode(entry.node) != nullptr; +#if WARM_NODE_COUNT > 0 + if (!member && nodeDB) + member = nodeDB->warmStore.contains(entry.node); +#endif + entry.isMember = member; + } + TM_LOG_DEBUG("NodeInfo cache: %u/%u (%u went stale)", static_cast(countNodeInfoEntriesLocked()), + static_cast(nodeInfoTargetEntries()), static_cast(nodeInfoSaturated)); + + // Anti-entropy: seed identities NodeDB knows but this cache lacks - a full pass at + // boot (once nodeDB is ready), then hourly. The write-through hooks provide + // immediacy between passes. + if (!nodeInfoSeeded || ++sweepsSinceNodeInfoReconcile >= kNodeInfoReconcileSweeps) { + if (nodeDB) { + reconcileNodeInfoFromNodeDBLocked(); + nodeInfoSeeded = true; + sweepsSinceNodeInfoReconcile = 0; + } + } +#endif +} + void TrafficManagementModule::onNodeIdentityCommitted(NodeNum node, const meshtastic_User &user, bool signerKnown) { #if TMM_HAS_NODEINFO_CACHE + // Same gate as handleReceived()/runOnce(): with the module disabled, maintenance + // (sweep + reconcile) never runs, so the hooks must not fill the cache either - + // content and maintenance stay keyed to the same condition. + if (!moduleConfig.has_traffic_management) + return; if (node == 0 || (nodeDB && node == nodeDB->getNodeNum())) return; concurrency::LockGuard guard(&cacheLock); @@ -689,6 +741,9 @@ void TrafficManagementModule::onNodeIdentityCommitted(NodeNum node, const meshta void TrafficManagementModule::onNodeKeyCommitted(NodeNum node, const uint8_t key32[32], bool proven) { #if TMM_HAS_NODEINFO_CACHE + // Same module-disabled gate as onNodeIdentityCommitted (see there for rationale). + if (!moduleConfig.has_traffic_management) + return; if (node == 0 || !key32 || (nodeDB && node == nodeDB->getNodeNum())) return; concurrency::LockGuard guard(&cacheLock); @@ -1165,7 +1220,16 @@ int32_t TrafficManagementModule::runOnce() // guard only when the preload actually ran, so a not-ready nodeDB gets a retry. if (!nextHopPreloaded && preloadNextHopsFromNodeDB()) nextHopPreloaded = true; +#endif + +#if TRAFFIC_MANAGEMENT_CACHE_SIZE > 0 || TMM_HAS_NODEINFO_CACHE + // Mirrors purgeAll(): the two caches are compile-time independent (a variant may zero the + // unified cache while the NodeInfo cache exists, or vice versa), so each is maintained + // under its own guard below - a build with only one of them must still sweep it. + concurrency::LockGuard guard(&cacheLock); +#endif +#if TRAFFIC_MANAGEMENT_CACHE_SIZE > 0 // TTLs in free-running ticks: pos 4x position interval (<=255 @ 6 min/tick), rate 2x the // configured window (<=15 @ 5 min/tick), unknown fixed 12 @ 1 min/tick. const uint32_t positionIntervalMs = secsToMs(Default::getConfiguredOrDefault( @@ -1189,9 +1253,6 @@ int32_t TrafficManagementModule::runOnce() uint16_t expiredEntries = 0; const uint32_t sweepStartMs = TrafficManagementModule::clockMs(); - const auto &cfg = moduleConfig.traffic_management; - concurrency::LockGuard guard(&cacheLock); - for (uint16_t i = 0; i < cacheSize(); i++) { if (cache[i].node == 0) continue; @@ -1247,51 +1308,12 @@ int32_t TrafficManagementModule::runOnce() static_cast(activeEntries), static_cast(cacheSize()), static_cast(TrafficManagementModule::clockMs() - sweepStartMs)); +#endif // TRAFFIC_MANAGEMENT_CACHE_SIZE > 0 + #if TMM_HAS_NODEINFO_CACHE - if (nodeInfoPayload) { - // Saturate expired tick stamps: clearing the presence bit once a stamp's age exceeds - // its window is the wrap-safety guarantee (stamps never approach their uint8 aliasing - // horizon). Entries are never freed on a timer - slots die by tiered LRU or purge. - uint16_t nodeInfoSaturated = 0; - const uint8_t nowObs = currentObsTick(); - const uint8_t nowResp = currentRespTick(); - for (uint16_t i = 0; i < nodeInfoTargetEntries(); i++) { - NodeInfoPayloadEntry &entry = nodeInfoPayload[i]; - if (entry.node == 0) - continue; - if (entry.hasObserved && static_cast(nowObs - entry.obsTick) > kNodeInfoMaxServeAgeTicks) { - entry.hasObserved = false; - nodeInfoSaturated++; - } - if (entry.hasResponded && static_cast(nowResp - entry.respTick) >= kNodeInfoThrottleTicks) - entry.hasResponded = false; - // Refresh membership: the bit keeps NodeDB-present nodes stickiest under LRU, and - // its clearing is what lets a fully forgotten node become evictable again. - bool member = nodeDB && nodeDB->getMeshNode(entry.node) != nullptr; -#if WARM_NODE_COUNT > 0 - if (!member && nodeDB) - member = nodeDB->warmStore.contains(entry.node); -#endif - entry.isMember = member; - } - TM_LOG_DEBUG("NodeInfo cache: %u/%u (%u went stale)", static_cast(countNodeInfoEntriesLocked()), - static_cast(nodeInfoTargetEntries()), static_cast(nodeInfoSaturated)); - - // Anti-entropy: seed identities NodeDB knows but this cache lacks - a full pass at - // boot (once nodeDB is ready), then hourly. The write-through hooks provide - // immediacy between passes. - if (!nodeInfoSeeded || ++sweepsSinceNodeInfoReconcile >= kNodeInfoReconcileSweeps) { - if (nodeDB) { - reconcileNodeInfoFromNodeDBLocked(); - nodeInfoSeeded = true; - sweepsSinceNodeInfoReconcile = 0; - } - } - } + maintainNodeInfoCacheLocked(); #endif -#endif // TRAFFIC_MANAGEMENT_CACHE_SIZE > 0 - return kMaintenanceIntervalMs; } diff --git a/src/modules/TrafficManagementModule.h b/src/modules/TrafficManagementModule.h index b9084450316..3ff6b34e5b9 100644 --- a/src/modules/TrafficManagementModule.h +++ b/src/modules/TrafficManagementModule.h @@ -77,11 +77,13 @@ class TrafficManagementModule : public MeshModule, private concurrency::OSThread /// Write-through hook from NodeDB::updateUser(): upsert the committed identity immediately /// (the reconcile sweep remains the backstop). NodeDB's key is authoritative, but a keyless /// commit keeps a TOFU key this cache already holds; never touches the observation stamp. + /// No-op while the module is disabled in moduleConfig (maintenance is gated the same way). void onNodeIdentityCommitted(NodeNum node, const meshtastic_User &user, bool signerKnown); /// Key-only commit hook for key writes that bypass updateUser (admin-key learn, manual key /// verification). A changed key resets provenance; pass proven=true only when the commit /// itself established possession. Never touches the observation stamp. Thread-safe. + /// No-op while the module is disabled in moduleConfig (maintenance is gated the same way). void onNodeKeyCommitted(NodeNum node, const uint8_t key32[32], bool proven); /// Zero one node's slots in both caches (identity, key, provenance, role, next-hop, dedup @@ -350,6 +352,13 @@ class TrafficManagementModule : public MeshModule, private concurrency::OSThread /// Number of occupied NodeInfo cache slots. Caller must hold cacheLock. uint16_t countNodeInfoEntriesLocked() const; + /// 60 s NodeInfo-cache maintenance under cacheLock: saturate expired tick stamps (the + /// wrap-safety guarantee for the modular obs/resp clocks), refresh NodeDB membership, and + /// run the boot/hourly reconcile. Guarded by TMM_HAS_NODEINFO_CACHE alone - never by the + /// unified cache size - so a build with only this cache still maintains it (the caches are + /// compile-time independent; see purgeAll()). + void maintainNodeInfoCacheLocked(); + /// Anti-entropy seeding under cacheLock: upsert hot-store identities and warm-tier key-only /// records this cache lacks. Never sets hasObserved - seeding is knowledge, not observation, /// so it can never make a silent node servable by the replay path. From 4d9cb90f727d8d855b509d5d8a4fde2e0d52ca23 Mon Sep 17 00:00:00 2001 From: nomdetom Date: Fri, 17 Jul 2026 23:20:48 +0100 Subject: [PATCH 32/44] TrafficManagement: forward throttled NodeInfo requests instead of consuming them The direct-response throttle returned true, which made handleReceived() STOP the request: within the 30 s window a repeat request was neither answered nor forwarded toward the genuine target, and the call site still logged "respond" and bumped nodeinfo_cache_hits for a reply that was never sent. A requester whose first reply was lost on a noisy link got silence for the whole window. Return false instead: the request flows through normal relay handling, so the genuine node (or another cache-holder) can answer, while our own spoofed TX stays bounded. Repeats of the same packet id are already absorbed by the router's duplicate detection, and the stats counter now only counts replies actually sent. Also log purgeNode() only when a slot was actually cleared - it runs for every NodeDB removal, including nodes the caches never tracked. Co-Authored-By: Claude Fable 5 --- src/modules/TrafficManagementModule.cpp | 23 ++++++++++++++++++----- 1 file changed, 18 insertions(+), 5 deletions(-) diff --git a/src/modules/TrafficManagementModule.cpp b/src/modules/TrafficManagementModule.cpp index d4dfc4a28bb..65858577cb2 100644 --- a/src/modules/TrafficManagementModule.cpp +++ b/src/modules/TrafficManagementModule.cpp @@ -288,17 +288,25 @@ void TrafficManagementModule::purgeNode(NodeNum node) if (node == 0) return; concurrency::LockGuard guard(&cacheLock); + bool purged = false; #if TRAFFIC_MANAGEMENT_CACHE_SIZE > 0 UnifiedCacheEntry *entry = findEntry(node); - if (entry) + if (entry) { memset(entry, 0, sizeof(UnifiedCacheEntry)); + purged = true; + } #endif #if TMM_HAS_NODEINFO_CACHE NodeInfoPayloadEntry *info = findNodeInfoEntryMutable(node); - if (info) + if (info) { memset(info, 0, sizeof(NodeInfoPayloadEntry)); + purged = true; + } #endif - TM_LOG_INFO("Purged node 0x%08x from traffic caches", node); + // Log only real purges: removeNodeByNum() calls this for every deletion, including + // nodes these caches never tracked. + if (purged) + TM_LOG_INFO("Purged node 0x%08x from traffic caches", node); #else (void)node; #endif @@ -1522,8 +1530,13 @@ bool TrafficManagementModule::shouldRespondToNodeInfo(const meshtastic_MeshPacke usedFallback ? (cachedFallbackResponseMs != 0 && (clockMs() - cachedFallbackResponseMs) < kNodeInfoResponseThrottleMs) : (cachedHasResponded && static_cast(currentRespTick() - cachedRespTick) < kNodeInfoThrottleTicks); if (throttled) { - TM_LOG_DEBUG("NodeInfo response throttled for 0x%08x", p->to); - return true; + // Bound only OUR spoofed TX - never black-hole the request. Returning false lets + // handleReceived() CONTINUE, so the request forwards toward the genuine target (which + // can answer), instead of being consumed with no reply. A requester whose first reply + // was lost on a noisy link would otherwise get silence for the whole window. Repeats + // of the same packet id are already absorbed by the router's duplicate detection. + TM_LOG_DEBUG("NodeInfo response throttled for 0x%08x; forwarding request instead", p->to); + return false; } meshtastic_MeshPacket *reply = router->allocForSending(); From da81ae21974611b0cdd138f144b97e445843da61 Mon Sep 17 00:00:00 2001 From: nomdetom Date: Fri, 17 Jul 2026 23:39:30 +0100 Subject: [PATCH 33/44] TrafficManagement: consolidate NodeInfo-cache #if sprawl into one region Fourteen scattered #if TMM_HAS_NODEINFO_CACHE guards (one per function, each with its own #else stub of (void) casts) collapse into a single guarded region holding every NodeInfo-cache-only function, terminated by one #else block of no-op stub definitions. Because the stubs now exist on every build, the call sites in runOnce(), purgeNode() and purgeAll() drop their guards too; inner guards remain only for orthogonal features (PKI, warm tier) and for real conditional work (constructor allocation, PSRAM paths in shouldRespondToNodeInfo). No behavior change. Compile-checked on both sides of the macro: the native app build (stubs) and the native unit-test build (region). Co-Authored-By: Claude Fable 5 --- src/modules/TrafficManagementModule.cpp | 194 +++++++++++------------- 1 file changed, 89 insertions(+), 105 deletions(-) diff --git a/src/modules/TrafficManagementModule.cpp b/src/modules/TrafficManagementModule.cpp index 65858577cb2..ebe412df474 100644 --- a/src/modules/TrafficManagementModule.cpp +++ b/src/modules/TrafficManagementModule.cpp @@ -296,13 +296,12 @@ void TrafficManagementModule::purgeNode(NodeNum node) purged = true; } #endif -#if TMM_HAS_NODEINFO_CACHE + // No NodeInfo-cache guard needed: without the cache this is a no-op stub returning null. NodeInfoPayloadEntry *info = findNodeInfoEntryMutable(node); if (info) { memset(info, 0, sizeof(NodeInfoPayloadEntry)); purged = true; } -#endif // Log only real purges: removeNodeByNum() calls this for every deletion, including // nodes these caches never tracked. if (purged) @@ -320,62 +319,13 @@ void TrafficManagementModule::purgeAll() if (cache) memset(cache, 0, static_cast(cacheSize()) * sizeof(UnifiedCacheEntry)); #endif -#if TMM_HAS_NODEINFO_CACHE + // nodeInfoPayload stays nullptr on builds without the NodeInfo cache; no guard needed. if (nodeInfoPayload) memset(nodeInfoPayload, 0, static_cast(nodeInfoTargetEntries()) * sizeof(NodeInfoPayloadEntry)); -#endif TM_LOG_INFO("Purged all traffic caches"); #endif } -void TrafficManagementModule::dropNodeInfoCacheForTest() -{ -#if TMM_HAS_NODEINFO_CACHE - concurrency::LockGuard guard(&cacheLock); - if (!nodeInfoPayload) - return; - if (nodeInfoPayloadFromPsram) - free(nodeInfoPayload); - else - delete[] nodeInfoPayload; - nodeInfoPayload = nullptr; - nodeInfoPayloadFromPsram = false; - memaudit::set("tmm_ni", 0); -#endif -} - -int TrafficManagementModule::peekNodeInfoFlagsForTest(NodeNum node) -{ -#if TMM_HAS_NODEINFO_CACHE - concurrency::LockGuard guard(&cacheLock); - const NodeInfoPayloadEntry *entry = findNodeInfoEntry(node); - if (!entry) - return -1; - return (entry->hasObserved ? 1 : 0) | (entry->hasResponded ? 2 : 0) | (entry->isMember ? 4 : 0) | - (entry->hasFullUser ? 8 : 0) | (entry->keySignerProven ? 16 : 0); -#else - (void)node; - return -1; -#endif -} - -void TrafficManagementModule::markKeySignerProvenForTest(NodeNum node) -{ -#if TMM_HAS_NODEINFO_CACHE - concurrency::LockGuard guard(&cacheLock); - if (!nodeInfoPayload) - return; - for (uint16_t i = 0; i < nodeInfoTargetEntries(); i++) { - if (nodeInfoPayload[i].node == node) { - nodeInfoPayload[i].keySignerProven = true; - return; - } - } -#else - (void)node; -#endif -} - /// Sender-role resolution for the position hot path. NodeDB is scanned only when a node is first /// tracked (seeding the tier-3 cache so a resident special-role node is correct from its first /// position); thereafter the cached role is authoritative, kept fresh by updateCachedRoleFromNodeInfo(). @@ -484,9 +434,16 @@ TrafficManagementModule::UnifiedCacheEntry *TrafficManagementModule::findOrCreat #endif } +// ============================================================================= +// NodeInfo Payload Cache +// ============================================================================= +// One region for every NodeInfo-cache-only function; the #else block at the end +// provides the no-op stubs, so call sites need no guards of their own. Inner +// guards below are only for orthogonal features (PKI, warm tier). +#if TMM_HAS_NODEINFO_CACHE + const TrafficManagementModule::NodeInfoPayloadEntry *TrafficManagementModule::findNodeInfoEntry(NodeNum node) const { -#if TMM_HAS_NODEINFO_CACHE if (!nodeInfoPayload || node == 0) return nullptr; @@ -495,10 +452,6 @@ const TrafficManagementModule::NodeInfoPayloadEntry *TrafficManagementModule::fi return &nodeInfoPayload[i]; } return nullptr; -#else - (void)node; - return nullptr; -#endif } /// Find or create a NodeInfo payload entry. Victim selection is trust-tiered so the cache @@ -510,7 +463,6 @@ TrafficManagementModule::findOrCreateNodeInfoEntry(NodeNum node, bool *usedEmpty if (usedEmptySlot) *usedEmptySlot = false; -#if TMM_HAS_NODEINFO_CACHE if (!nodeInfoPayload || node == 0) return nullptr; @@ -555,15 +507,10 @@ TrafficManagementModule::findOrCreateNodeInfoEntry(NodeNum node, bool *usedEmpty if (usedEmptySlot) *usedEmptySlot = (slot == empty); return slot; -#else - (void)node; - return nullptr; -#endif } uint16_t TrafficManagementModule::countNodeInfoEntriesLocked() const { -#if TMM_HAS_NODEINFO_CACHE if (!nodeInfoPayload) return 0; @@ -573,14 +520,10 @@ uint16_t TrafficManagementModule::countNodeInfoEntriesLocked() const count++; } return count; -#else - return 0; -#endif } void TrafficManagementModule::reconcileNodeInfoFromNodeDBLocked() { -#if TMM_HAS_NODEINFO_CACHE if (!nodeInfoPayload || !nodeDB) return; @@ -652,12 +595,10 @@ void TrafficManagementModule::reconcileNodeInfoFromNodeDBLocked() if (seeded) TM_LOG_INFO("NodeInfo cache reconciled: %u seeded from NodeDB, %u/%u total", static_cast(seeded), static_cast(countNodeInfoEntriesLocked()), static_cast(nodeInfoTargetEntries())); -#endif } void TrafficManagementModule::maintainNodeInfoCacheLocked() { -#if TMM_HAS_NODEINFO_CACHE if (!nodeInfoPayload) return; @@ -699,12 +640,10 @@ void TrafficManagementModule::maintainNodeInfoCacheLocked() sweepsSinceNodeInfoReconcile = 0; } } -#endif } void TrafficManagementModule::onNodeIdentityCommitted(NodeNum node, const meshtastic_User &user, bool signerKnown) { -#if TMM_HAS_NODEINFO_CACHE // Same gate as handleReceived()/runOnce(): with the module disabled, maintenance // (sweep + reconcile) never runs, so the hooks must not fill the cache either - // content and maintenance stay keyed to the same condition. @@ -739,16 +678,10 @@ void TrafficManagementModule::onNodeIdentityCommitted(NodeNum node, const meshta entry->keySignerProven = provenBefore || (signerKnown && user.public_key.size == 32); entry->isMember = true; // committed via updateUser => it sits in the hot store right now // obsTick/hasObserved deliberately untouched: only a heard frame makes a node servable. -#else - (void)node; - (void)user; - (void)signerKnown; -#endif } void TrafficManagementModule::onNodeKeyCommitted(NodeNum node, const uint8_t key32[32], bool proven) { -#if TMM_HAS_NODEINFO_CACHE // Same module-disabled gate as onNodeIdentityCommitted (see there for rationale). if (!moduleConfig.has_traffic_management) return; @@ -772,17 +705,11 @@ void TrafficManagementModule::onNodeKeyCommitted(NodeNum node, const uint8_t key entry->keySignerProven = false; if (proven) entry->keySignerProven = true; - // hasObserved/obsTick untouched: a key commit is knowledge, not an observation. -#else - (void)node; - (void)key32; - (void)proven; -#endif + // hasObserved/obsTick untouched: a key commit is knowledge, not an observation. } bool TrafficManagementModule::copyPublicKey(NodeNum node, uint8_t out[32], bool *signerProven) const { -#if TMM_HAS_NODEINFO_CACHE if (!nodeInfoPayload || node == 0 || !out) return false; @@ -795,17 +722,10 @@ bool TrafficManagementModule::copyPublicKey(NodeNum node, uint8_t out[32], bool if (signerProven) *signerProven = entry->keySignerProven; return true; -#else - (void)node; - (void)out; - (void)signerProven; - return false; -#endif } bool TrafficManagementModule::copyUser(NodeNum node, meshtastic_User &out, bool *signerProven) const { -#if TMM_HAS_NODEINFO_CACHE if (!nodeInfoPayload || node == 0) return false; @@ -820,15 +740,9 @@ bool TrafficManagementModule::copyUser(NodeNum node, meshtastic_User &out, bool if (signerProven) *signerProven = entry->keySignerProven; return true; -#else - (void)node; - (void)out; - (void)signerProven; - return false; -#endif } -#if TMM_HAS_NODEINFO_CACHE && !(MESHTASTIC_EXCLUDE_PKI) +#if !(MESHTASTIC_EXCLUDE_PKI) /// True iff both are full 32-byte public keys with identical bytes. Single point of truth for /// the key-hygiene checks; raw ptr+size because User and NodeInfoLite key fields differ in type. static bool pubKeysEqual(const uint8_t *keyA, size_t sizeA, const uint8_t *keyB, size_t sizeB) @@ -839,7 +753,6 @@ static bool pubKeysEqual(const uint8_t *keyA, size_t sizeA, const uint8_t *keyB, void TrafficManagementModule::cacheNodeInfoPacket(const meshtastic_MeshPacket &mp) { -#if TMM_HAS_NODEINFO_CACHE if (!nodeInfoPayload || mp.decoded.payload.size == 0) return; @@ -922,11 +835,84 @@ void TrafficManagementModule::cacheNodeInfoPacket(const meshtastic_MeshPacket &m TM_LOG_INFO("NodeInfo PSRAM cache entries: %u/%u", static_cast(cachedCount), static_cast(nodeInfoTargetEntries())); } -#else - (void)mp; -#endif } +void TrafficManagementModule::dropNodeInfoCacheForTest() +{ + concurrency::LockGuard guard(&cacheLock); + if (!nodeInfoPayload) + return; + if (nodeInfoPayloadFromPsram) + free(nodeInfoPayload); + else + delete[] nodeInfoPayload; + nodeInfoPayload = nullptr; + nodeInfoPayloadFromPsram = false; + memaudit::set("tmm_ni", 0); +} + +int TrafficManagementModule::peekNodeInfoFlagsForTest(NodeNum node) +{ + concurrency::LockGuard guard(&cacheLock); + const NodeInfoPayloadEntry *entry = findNodeInfoEntry(node); + if (!entry) + return -1; + return (entry->hasObserved ? 1 : 0) | (entry->hasResponded ? 2 : 0) | (entry->isMember ? 4 : 0) | + (entry->hasFullUser ? 8 : 0) | (entry->keySignerProven ? 16 : 0); +} + +void TrafficManagementModule::markKeySignerProvenForTest(NodeNum node) +{ + concurrency::LockGuard guard(&cacheLock); + if (!nodeInfoPayload) + return; + for (uint16_t i = 0; i < nodeInfoTargetEntries(); i++) { + if (nodeInfoPayload[i].node == node) { + nodeInfoPayload[i].keySignerProven = true; + return; + } + } +} + +#else // !TMM_HAS_NODEINFO_CACHE: no-op stubs so call sites need no guards of their own + +const TrafficManagementModule::NodeInfoPayloadEntry *TrafficManagementModule::findNodeInfoEntry(NodeNum) const +{ + return nullptr; +} +TrafficManagementModule::NodeInfoPayloadEntry *TrafficManagementModule::findOrCreateNodeInfoEntry(NodeNum, bool *usedEmptySlot, + bool) +{ + if (usedEmptySlot) + *usedEmptySlot = false; + return nullptr; +} +uint16_t TrafficManagementModule::countNodeInfoEntriesLocked() const +{ + return 0; +} +void TrafficManagementModule::reconcileNodeInfoFromNodeDBLocked() {} +void TrafficManagementModule::maintainNodeInfoCacheLocked() {} +void TrafficManagementModule::onNodeIdentityCommitted(NodeNum, const meshtastic_User &, bool) {} +void TrafficManagementModule::onNodeKeyCommitted(NodeNum, const uint8_t[32], bool) {} +bool TrafficManagementModule::copyPublicKey(NodeNum, uint8_t[32], bool *) const +{ + return false; +} +bool TrafficManagementModule::copyUser(NodeNum, meshtastic_User &, bool *) const +{ + return false; +} +void TrafficManagementModule::cacheNodeInfoPacket(const meshtastic_MeshPacket &) {} +void TrafficManagementModule::dropNodeInfoCacheForTest() {} +int TrafficManagementModule::peekNodeInfoFlagsForTest(NodeNum) +{ + return -1; +} +void TrafficManagementModule::markKeySignerProvenForTest(NodeNum) {} + +#endif // TMM_HAS_NODEINFO_CACHE + // ============================================================================= // Next-Hop Overflow Cache // ============================================================================= @@ -1318,9 +1304,7 @@ int32_t TrafficManagementModule::runOnce() #endif // TRAFFIC_MANAGEMENT_CACHE_SIZE > 0 -#if TMM_HAS_NODEINFO_CACHE - maintainNodeInfoCacheLocked(); -#endif + maintainNodeInfoCacheLocked(); // no-op stub on builds without the NodeInfo cache return kMaintenanceIntervalMs; } From 76a397ed8836f82d9ea20d2488b19895c6738405 Mon Sep 17 00:00:00 2001 From: nomdetom Date: Fri, 17 Jul 2026 23:46:15 +0100 Subject: [PATCH 34/44] Router: resolve sender key only for PKI-decrypt candidates perhapsDecode() resolved the sender's public key unconditionally for every encrypted packet, before even checking whether the packet could be PKI-decrypted (channel 0, unicast to us). Since copyPublicKey() grew its TrafficManagement fallback tier, a full hot+warm miss - the common case for channel traffic from senders outside both NodeDB tiers - additionally walked the 2000-entry NodeInfo cache under its lock, per packet, for a key that was then discarded. Move the resolution inside the PKI-candidate branch; remotePublic and haveRemoteKey had no consumers outside it. Co-Authored-By: Claude Fable 5 --- src/mesh/Router.cpp | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/src/mesh/Router.cpp b/src/mesh/Router.cpp index 83321fdc403..6996e08512f 100644 --- a/src/mesh/Router.cpp +++ b/src/mesh/Router.cpp @@ -525,14 +525,16 @@ DecodeState perhapsDecode(meshtastic_MeshPacket *p) bool decrypted = false; ChannelIndex chIndex = 0; #if !(MESHTASTIC_EXCLUDE_PKI) - // Resolve the sender's public key: prefer the one stored in NodeDB (hot store or warm tier), else - // fall back to a not-yet-committed key held during an in-progress key-verification handshake. - meshtastic_NodeInfoLite_public_key_t remotePublic = {0, {0}}; - bool haveRemoteKey = nodeDB->copyPublicKey(p->from, remotePublic) || crypto->getPendingPublicKey(p->from, remotePublic); - meshtastic_NodeInfoLite *ourNode = nullptr; if (p->channel == 0 && isToUs(p) && p->to > 0 && !isBroadcast(p->to) && rawSize > MESHTASTIC_PKC_OVERHEAD && (ourNode = nodeDB->getMeshNode(p->to)) != nullptr && ourNode->public_key.size > 0) { + // Resolve the sender's public key only for actual PKI-decrypt candidates: prefer NodeDB + // (hot store or warm tier), else a not-yet-committed key held during an in-progress + // key-verification handshake. On a full NodeDB miss, copyPublicKey() falls through to a + // linear scan of TrafficManagement's large NodeInfo cache, so it must not run for every + // encrypted channel packet from an unknown sender - only for packets we might decrypt. + meshtastic_NodeInfoLite_public_key_t remotePublic = {0, {0}}; + bool haveRemoteKey = nodeDB->copyPublicKey(p->from, remotePublic) || crypto->getPendingPublicKey(p->from, remotePublic); // Try the sender's known key first, then each configured admin key so an authorized admin can // reach a node that has not yet learned their key. AES-CCM AEAD rejects wrong candidates. bool viaAdminKey = false; From d6abd3ac02daaae34753594c21186cba5bd12d7f Mon Sep 17 00:00:00 2001 From: nomdetom Date: Fri, 17 Jul 2026 23:53:38 +0100 Subject: [PATCH 35/44] TrafficManagement: refresh NodeInfo membership hourly, not per-sweep The 60 s sweep re-derived isMember with a per-entry NodeDB lookup: O(entries x members) - about 700k node-number comparisons per minute on a full PSRAM cache (2000 entries x 250 hot + 100 warm), with strided PSRAM reads, all while holding cacheLock against the packet path. Move the refresh into the hourly reconcile pass, which is already O(members x entries): after the seeding loops (so the upsert pass still sees last pass's bits for spareMembers protection), clear every isMember bit and re-mark from both NodeDB tiers - including keyless warm records, which seed nothing but are still members. Accepted tradeoff, now documented on the field: membership lags a passive NodeDB eviction by up to an hour (the entry just stays LRU-sticky slightly longer). Additions stay immediate via the write-through hooks, explicit removals via purgeNode(). Co-Authored-By: Claude Fable 5 --- src/modules/TrafficManagementModule.cpp | 39 ++++++++++++++++++++----- src/modules/TrafficManagementModule.h | 19 +++++++----- 2 files changed, 43 insertions(+), 15 deletions(-) diff --git a/src/modules/TrafficManagementModule.cpp b/src/modules/TrafficManagementModule.cpp index ebe412df474..33657ed9362 100644 --- a/src/modules/TrafficManagementModule.cpp +++ b/src/modules/TrafficManagementModule.cpp @@ -592,6 +592,34 @@ void TrafficManagementModule::reconcileNodeInfoFromNodeDBLocked() } #endif + // Membership refresh (owned by this hourly pass; per-minute per-entry NodeDB lookups in + // the sweep were O(entries x members) under cacheLock). Runs AFTER seeding so the upsert + // pass still sees last pass's bits for its spareMembers protection, then: clear all bits, + // re-mark from both tiers. Keyless warm records mark membership here even though they had + // nothing to seed. isMember may now lag a passive NodeDB eviction by up to an hour (the + // entry just stays LRU-sticky slightly longer); the write-through hooks keep additions + // immediate, and explicit removals stay immediate via purgeNode(). + for (uint16_t i = 0; i < nodeInfoTargetEntries(); i++) + nodeInfoPayload[i].isMember = false; + for (size_t i = 0; i < nodeDB->getNumMeshNodes(); i++) { + const meshtastic_NodeInfoLite *info = nodeDB->getMeshNodeByIndex(i); + if (!info || info->num == 0) + continue; + NodeInfoPayloadEntry *entry = findNodeInfoEntryMutable(info->num); + if (entry) + entry->isMember = true; + } +#if WARM_NODE_COUNT > 0 + for (size_t i = 0; i < nodeDB->warmStore.capacity(); i++) { + const WarmNodeEntry *warm = nodeDB->warmStore.entryAt(i); + if (!warm) + continue; + NodeInfoPayloadEntry *entry = findNodeInfoEntryMutable(warm->num); + if (entry) + entry->isMember = true; + } +#endif + if (seeded) TM_LOG_INFO("NodeInfo cache reconciled: %u seeded from NodeDB, %u/%u total", static_cast(seeded), static_cast(countNodeInfoEntriesLocked()), static_cast(nodeInfoTargetEntries())); @@ -618,14 +646,9 @@ void TrafficManagementModule::maintainNodeInfoCacheLocked() } if (entry.hasResponded && static_cast(nowResp - entry.respTick) >= kNodeInfoThrottleTicks) entry.hasResponded = false; - // Refresh membership: the bit keeps NodeDB-present nodes stickiest under LRU, and - // its clearing is what lets a fully forgotten node become evictable again. - bool member = nodeDB && nodeDB->getMeshNode(entry.node) != nullptr; -#if WARM_NODE_COUNT > 0 - if (!member && nodeDB) - member = nodeDB->warmStore.contains(entry.node); -#endif - entry.isMember = member; + // Membership is NOT refreshed here: a per-entry NodeDB lookup would be + // O(entries x members) every 60 s under cacheLock. The hourly reconcile pass + // owns it (see reconcileNodeInfoFromNodeDBLocked). } TM_LOG_DEBUG("NodeInfo cache: %u/%u (%u went stale)", static_cast(countNodeInfoEntriesLocked()), static_cast(nodeInfoTargetEntries()), static_cast(nodeInfoSaturated)); diff --git a/src/modules/TrafficManagementModule.h b/src/modules/TrafficManagementModule.h index 3ff6b34e5b9..1499f0752a0 100644 --- a/src/modules/TrafficManagementModule.h +++ b/src/modules/TrafficManagementModule.h @@ -287,8 +287,10 @@ class TrafficManagementModule : public MeshModule, private concurrency::OSThread // than a key-only warm-tier record. copyUser()/name-rehydration require it. uint8_t hasFullUser : 1; - // Node currently exists in NodeDB (hot or warm), per the last sweep's membership check. - // Member entries are stickiest under LRU; the bit is the keep-alive (no TTL). + // Node currently exists in NodeDB (hot or warm), per the last hourly reconcile pass + // (write-through hooks set it immediately on commit; purgeNode clears immediately on + // removal; a passive NodeDB eviction may lag up to an hour). Member entries are + // stickiest under LRU; the bit is the keep-alive (no TTL). uint8_t isMember : 1; }; // No exact-size static_assert: sizeof(meshtastic_User) and its padding vary by platform, so @@ -353,15 +355,18 @@ class TrafficManagementModule : public MeshModule, private concurrency::OSThread uint16_t countNodeInfoEntriesLocked() const; /// 60 s NodeInfo-cache maintenance under cacheLock: saturate expired tick stamps (the - /// wrap-safety guarantee for the modular obs/resp clocks), refresh NodeDB membership, and - /// run the boot/hourly reconcile. Guarded by TMM_HAS_NODEINFO_CACHE alone - never by the - /// unified cache size - so a build with only this cache still maintains it (the caches are - /// compile-time independent; see purgeAll()). + /// wrap-safety guarantee for the modular obs/resp clocks) and run the boot/hourly + /// reconcile. Guarded by TMM_HAS_NODEINFO_CACHE alone - never by the unified cache size - + /// so a build with only this cache still maintains it (the caches are compile-time + /// independent; see purgeAll()). void maintainNodeInfoCacheLocked(); /// Anti-entropy seeding under cacheLock: upsert hot-store identities and warm-tier key-only /// records this cache lacks. Never sets hasObserved - seeding is knowledge, not observation, - /// so it can never make a silent node servable by the replay path. + /// so it can never make a silent node servable by the replay path. Also owns the isMember + /// refresh (clear-all, then re-mark from both NodeDB tiers) - membership therefore lags a + /// passive NodeDB eviction by up to an hour, while the write-through hooks and purgeNode() + /// keep additions and explicit removals immediate. void reconcileNodeInfoFromNodeDBLocked(); /// Learn an observed NODEINFO frame into the cache (key hygiene + provenance rules apply). void cacheNodeInfoPacket(const meshtastic_MeshPacket &mp); From e978c39232cb452e3034839888b0725743e00082 Mon Sep 17 00:00:00 2001 From: nomdetom Date: Sat, 18 Jul 2026 00:03:11 +0100 Subject: [PATCH 36/44] NodeDB: centralize bare-key commits in commitRemoteKey() The two key-write sites that bypass updateUser() (admin-channel learn in Router::perhapsDecode, manual verification in KeyVerificationModule) each carried their own #if HAS_TRAFFIC_MANAGEMENT write-through boilerplate - the pattern this PR itself had to retrofit twice, and which any future direct key write would silently miss, leaving the TrafficManagement cache divergent until the next hourly reconcile. Add NodeDB::commitRemoteKey(n, key32, KeyCommitTrust): writes the key to the hot store and routes the TrafficManagement write-through in one place, with provenance explicit at the call site (AdminChannelProven maps to TOFU-grade, ManuallyVerified to proven). The bypass sites keep their reason for existing - bare-key commits with provenance that updateUser's User-payload/TOFU-pin path cannot express - but no longer know about TrafficManagement at all; both stale includes are dropped (Router's was already unused on develop). Co-Authored-By: Claude Fable 5 --- src/mesh/NodeDB.cpp | 23 +++++++++++++++++++++++ src/mesh/NodeDB.h | 15 +++++++++++++++ src/mesh/Router.cpp | 18 ++++++------------ src/modules/KeyVerificationModule.cpp | 13 +++++-------- 4 files changed, 49 insertions(+), 20 deletions(-) diff --git a/src/mesh/NodeDB.cpp b/src/mesh/NodeDB.cpp index d07ddb41943..f660cc3da0e 100644 --- a/src/mesh/NodeDB.cpp +++ b/src/mesh/NodeDB.cpp @@ -3785,6 +3785,29 @@ bool NodeDB::isKnownXeddsaSigner(NodeNum n) #endif } +void NodeDB::commitRemoteKey(NodeNum n, const uint8_t key32[32], KeyCommitTrust trust) +{ + if (!key32 || n == 0) + return; + // Local copy first: callers may pass the node's own key bytes back in (e.g. manual + // verification re-committing an already-stored key), and memcpy forbids overlap. + uint8_t key[32]; + memcpy(key, key32, 32); + + meshtastic_NodeInfoLite *info = getOrCreateMeshNode(n); + if (!info) + return; + memcpy(info->public_key.bytes, key, 32); + info->public_key.size = 32; + +#if HAS_TRAFFIC_MANAGEMENT + // Write-through, mirroring updateUser()'s identity hook: without it the TrafficManagement + // NodeInfo cache diverges until the next hourly reconcile. + if (trafficManagementModule) + trafficManagementModule->onNodeKeyCommitted(n, key, trust == KeyCommitTrust::ManuallyVerified); +#endif +} + meshtastic_Config_DeviceConfig_Role NodeDB::getNodeRole(NodeNum n) { const meshtastic_NodeInfoLite *info = getMeshNode(n); diff --git a/src/mesh/NodeDB.h b/src/mesh/NodeDB.h index 54f8085022c..2e3c5972715 100644 --- a/src/mesh/NodeDB.h +++ b/src/mesh/NodeDB.h @@ -384,6 +384,21 @@ class NodeDB /// frames until it happens to be re-heard. bool isKnownXeddsaSigner(NodeNum n); + /// Provenance of a bare-key commit that deliberately bypasses updateUser()'s + /// User-payload / TOFU-pin path. Maps to the TrafficManagement cache's `proven` flag: + /// only ManuallyVerified vouches for possession of exactly this key. + enum class KeyCommitTrust : uint8_t { + AdminChannelProven, // possession shown to the admin channel (AEAD) - TOFU-grade for signing + ManuallyVerified, // the user confirmed possession of exactly this key + }; + + /// THE primitive for key writes that bypass updateUser() (no User payload; provenance + /// differs from a received NodeInfo): writes the 32-byte key to the hot store and + /// write-through to the TrafficManagement NodeInfo cache. Any future direct key-write + /// site must call this rather than assigning info->public_key, or the TrafficManagement + /// cache silently diverges until the next hourly reconcile. + void commitRemoteKey(NodeNum n, const uint8_t key32[32], KeyCommitTrust trust); + /// Resolve a node's device role - hot store (with user) first, then the role /// cached in the warm tier, else CLIENT. Lets role-aware policy keep firing for /// nodes that have aged out of the hot store. diff --git a/src/mesh/Router.cpp b/src/mesh/Router.cpp index 6996e08512f..5d40033f7ff 100644 --- a/src/mesh/Router.cpp +++ b/src/mesh/Router.cpp @@ -14,7 +14,6 @@ #include "modules/RoutingModule.h" #include #if HAS_TRAFFIC_MANAGEMENT -#include "modules/TrafficManagementModule.h" #endif #if HAS_VARIABLE_HOPS #include "modules/HopScalingModule.h" @@ -570,17 +569,12 @@ DecodeState perhapsDecode(meshtastic_MeshPacket *p) p->which_payload_variant = meshtastic_MeshPacket_decoded_tag; // change type to decoded if (viaAdminKey) { // Persist the admin key for the sender so future packets take the fast path and we can - // PKI-reply; p->from is bound into the AEAD nonce, so the trusted admin authenticated it. - meshtastic_NodeInfoLite *fromNode = nodeDB->getOrCreateMeshNode(p->from); - if (fromNode != nullptr) - fromNode->public_key = remotePublic; -#if HAS_TRAFFIC_MANAGEMENT - // This learn bypasses NodeDB::updateUser, so write the key through to TMM's - // NodeInfo cache too. proven=false: possession was shown to the admin channel, - // not via an XEdDSA NodeInfo signature, so it stays TOFU-grade there. - if (fromNode != nullptr && trafficManagementModule) - trafficManagementModule->onNodeKeyCommitted(p->from, remotePublic.bytes, false); -#endif + // PKI-reply; p->from is bound into the AEAD nonce, so the trusted admin authenticated + // it. commitRemoteKey is the bare-key commit primitive: it bypasses updateUser's + // User-payload path deliberately and handles the TrafficManagement write-through. + // AdminChannelProven = possession shown to the admin channel, not via an XEdDSA + // NodeInfo signature, so the key stays TOFU-grade for signing purposes. + nodeDB->commitRemoteKey(p->from, remotePublic.bytes, NodeDB::KeyCommitTrust::AdminChannelProven); } } else { // AEAD already authenticated this ciphertext, so no other candidate could decode it - diff --git a/src/modules/KeyVerificationModule.cpp b/src/modules/KeyVerificationModule.cpp index e435f4b0ea1..f7ffeb540bd 100644 --- a/src/modules/KeyVerificationModule.cpp +++ b/src/modules/KeyVerificationModule.cpp @@ -9,7 +9,6 @@ #include "meshUtils.h" #include "modules/AdminModule.h" #include "modules/NodeInfoModule.h" -#include "modules/TrafficManagementModule.h" #include #include @@ -384,13 +383,11 @@ void KeyVerificationModule::commitVerifiedRemoteNode() if (node->public_key.size != 32 && crypto->getPendingPublicKey(currentRemoteNode, pending)) node->public_key = pending; node->bitfield |= NODEINFO_BITFIELD_IS_KEY_MANUALLY_VERIFIED_MASK; -#if HAS_TRAFFIC_MANAGEMENT - // This commit bypasses NodeDB::updateUser (it writes node->public_key directly), so push - // the key into TMM's NodeInfo cache here. proven=true: the user just confirmed possession - // of this exact key - the strongest provenance any key in that cache can carry. - if (node->public_key.size == 32 && trafficManagementModule) - trafficManagementModule->onNodeKeyCommitted(currentRemoteNode, node->public_key.bytes, true); -#endif + // Re-commit via the bare-key primitive: writing the same bytes back is a no-op for the hot + // store, but it routes the TrafficManagement write-through. ManuallyVerified: the user just + // confirmed possession of exactly this key - the strongest provenance that cache can carry. + if (node->public_key.size == 32) + nodeDB->commitRemoteKey(currentRemoteNode, node->public_key.bytes, NodeDB::KeyCommitTrust::ManuallyVerified); LOG_INFO("Node 0x%08x manually verified with security number %u", currentRemoteNode, currentSecurityNumber); if (nodeInfoModule) nodeInfoModule->sendOurNodeInfo(currentRemoteNode, false, node->channel, true); From cfe1e6280b653e20e8f6f7038e3c61170c42176e Mon Sep 17 00:00:00 2001 From: nomdetom Date: Sat, 18 Jul 2026 00:03:36 +0100 Subject: [PATCH 37/44] docs: tick wrap-safety analysis, updated cadences, CodeRabbit doc fixes node_info_stores.md gains a "Tick clocks and wrap safety" section recording which mechanism keeps each modular tick clock honest: the unified-cache clocks pair the 60 s sweep with read-time window resets, the NodeInfo obs/resp clocks are sweep-only (hence the compile-time invariant that maintainNodeInfoCacheLocked() is guarded by TMM_HAS_NODEINFO_CACHE alone), and the warm tier is immune by design - absolute unix-seconds, no wrap until 2106. Also brought current with this series: membership refresh moved to the hourly reconcile, throttled direct-response requests forward instead of being consumed, the commitRemoteKey() bare-key funnel, and the module-disabled gate on the write-through hooks. CodeRabbit doc review (PR #11050): present the NodeInfo payload cache as the third *identity* tier with the unified cache beside the chain, and describe NodeInfoLite's flattened fields / satellite copy-out accessors instead of the removed nested members. Two stale docs/tmm_node_stores.md references in the header now point at the real file. Co-Authored-By: Claude Fable 5 --- docs/node_info_stores.md | 76 ++++++++++++++++++++++----- src/modules/TrafficManagementModule.h | 4 +- 2 files changed, 64 insertions(+), 16 deletions(-) diff --git a/docs/node_info_stores.md b/docs/node_info_stores.md index 192f34b2722..9ca75beed72 100644 --- a/docs/node_info_stores.md +++ b/docs/node_info_stores.md @@ -2,14 +2,18 @@ This document is an overview of the node-identity and traffic-state databases that the TrafficManagementModule (TMM) either owns or leans on. There are four stores in play, -ordered here as a node's identity flows through them: +but only three form the identity lookup chain: -1. **NodeDB hot store** - the authoritative `NodeInfoLite` array. -2. **Warm tier** (`WarmNodeStore`) - minimal persisted records for hot-store evictees. -3. **TMM unified cache** (base) - TMM's own flat 10-byte-per-node traffic-shaping state. -4. **TMM NodeInfo payload cache** (extended) - the ephemeral third identity tier: full +1. **NodeDB hot store** - the authoritative `NodeInfoLite` array (identity tier 1). +2. **Warm tier** (`WarmNodeStore`) - minimal persisted records for hot-store evictees + (identity tier 2). +3. **TMM NodeInfo payload cache** (extended) - the ephemeral **third identity tier**: full `User` payloads plus direct-response metadata, in PSRAM. +The fourth store, the **TMM unified cache** (base - flat 10-byte-per-node traffic-shaping +state), is not part of that chain: it sits beside it, keyed by the same NodeNum, and only +its 4-bit cached role acts as a final fallback when all three identity tiers miss. + Sources of truth: `src/mesh/NodeDB.{h,cpp}`, `src/mesh/WarmNodeStore.h`, `src/modules/TrafficManagementModule.{h,cpp}`, sizing in `src/mesh/mesh-pb-constants.h`. @@ -17,9 +21,10 @@ Sources of truth: `src/mesh/NodeDB.{h,cpp}`, `src/mesh/WarmNodeStore.h`, ## 1. NodeDB hot store (authoritative) -- **What:** the classic `meshNodes` array of `meshtastic_NodeInfoLite` - full identity - (names, role, position, telemetry pointers, public key, bitfield flags such as - `HAS_XEDDSA_SIGNED`). Everything else in this document is a cache or a fallback for it. +- **What:** the classic `meshNodes` array of `meshtastic_NodeInfoLite` - full identity as + flattened fields (names, role, public key, bitfield flags such as `HAS_XEDDSA_SIGNED`; + position/telemetry live in satellite stores reached via copy-out accessors, not nested + members). Everything else in this document is a cache or a fallback for it. - **Capacity:** `MAX_NUM_NODES`, per platform - 250 on native, 120 on nRF52840/generic ESP32, 10 on STM32WL (see `mesh-pb-constants.h`). - **Eviction:** oldest non-protected node when full (`getOrCreateMeshNode`). On eviction @@ -120,17 +125,60 @@ pos_time(1) | rate_unknown_time(1) | next_hop(1)` = 10 bytes, all platforms. Three mechanisms keep this tier a superset of NodeDB's identities: -| Mechanism | When | What | -| --------------------------------------------------------------------- | ----------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| Write-through hooks (`onNodeIdentityCommitted`, `onNodeKeyCommitted`) | on every NodeDB identity/key commit | immediate upsert; merges rather than overwrites - a keyless commit never costs the cache a learned TOFU key | -| Reconcile sweep (`reconcileNodeInfoFromNodeDBLocked`) | boot seed, then hourly | walks hot store (full identity) + warm tier (key-only records); adopts NodeDB content under the same keyless-merge rule; transfers signer verdicts only key-matched | -| Membership refresh | every 60 s sweep | re-checks hot+warm membership per entry; `isMember` is the keep-alive | -| Purge hooks (`purgeNode`, `purgeAll`) | NodeDB node removal / reset | clear both the unified cache and the NodeInfo cache, each under its own compile guard | +| Mechanism | When | What | +| --------------------------------------------------------------------- | ----------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Write-through hooks (`onNodeIdentityCommitted`, `onNodeKeyCommitted`) | on every NodeDB identity/key commit | immediate upsert; merges rather than overwrites - a keyless commit never costs the cache a learned TOFU key | +| Reconcile sweep (`reconcileNodeInfoFromNodeDBLocked`) | boot seed, then hourly | walks hot store (full identity) + warm tier (key-only records); adopts NodeDB content under the same keyless-merge rule; transfers signer verdicts only key-matched | +| Membership refresh | inside the hourly reconcile | clear-all then re-mark from both tiers (a per-entry NodeDB lookup every sweep would be O(entries x members) under the lock); additions stay immediate via the hooks, explicit removals via `purgeNode()`; a **passive** NodeDB eviction may lag up to an hour | +| Purge hooks (`purgeNode`, `purgeAll`) | NodeDB node removal / reset | clear both the unified cache and the NodeInfo cache, each under its own compile guard | **Retention:** no timed eviction. Slots die only by LRU displacement on insert, ranked by trust tiers - members and signer-proven keys are stickiest; the seeding pass additionally refuses to churn one member out for another (`spareMembers`). +**Key-commit funnel:** every path that writes a remote key into the hot store must route the +write-through. Full-identity commits funnel through `NodeDB::updateUser()`; bare-key commits +(admin-channel learn in `Router::perhapsDecode`, manual verification in +`KeyVerificationModule`) funnel through `NodeDB::commitRemoteKey()`, which carries an explicit +`KeyCommitTrust` provenance (`ManuallyVerified` maps to `proven=true` in this cache). Never +assign `info->public_key` directly - the cache would silently diverge until the next reconcile. + +**Enable gate:** the write-through hooks, like the sweep and the packet path, no-op while +`moduleConfig.has_traffic_management` is off, so cache content and cache maintenance are keyed +to the same condition. Corollary: the pubkey-pool superset property only holds while the +module is enabled. + +### Tick clocks and wrap safety + +All TMM timestamps are free-running modular ticks (uint8 or nibble) derived from `clockMs()`; +modular subtraction gives a correct age only while the true age stays below the counter +period. What keeps each clock honest differs, and matters when touching the sweep: + +| Clock | Period | Window/TTL | Wrap safety | +| ---------------------------- | -------- | --------------- | --------------------------------------------------------------------------------------------------------------------------------------------- | +| pos (uint8, 6 min/tick) | 25.6 h | <=255 ticks | 60 s sweep clears expired state; margin as low as 1 tick (6 min) at the clamp | +| rate (nibble, 5 min/tick) | 80 min | <=15 ticks | sweep, **plus** read-time window reset in `isRateLimited()` | +| unknown (nibble, 1 min/tick) | 16 min | 12 ticks | sweep, plus read-time window reset | +| NodeInfo `obsTick` (3 min) | 12.8 h | 120 ticks (6 h) | **sweep only** - `maintainNodeInfoCacheLocked()` clearing `hasObserved` is the sole guarantee the 6 h serve gate never reads an aliased stamp | +| NodeInfo `respTick` (5 s) | 21.3 min | 6 ticks (30 s) | sweep only (worst case of a missed clear: one spurious 30 s throttle) | + +The warm tier is different by design: `WarmNodeStore.last_heard` is an **absolute** +unix-seconds timestamp (quantised to 128 s by the metadata steal), so it cannot wrap until +2106 and needs no sweep - affordable at 100 x 40 B, where the TMM caches chose 1-byte ticks +to stay at 10 B/entry x up to 2048. + +Because the NodeInfo clocks are sweep-dependent, the maintenance invariant is compile-time: +`maintainNodeInfoCacheLocked()` is guarded by `TMM_HAS_NODEINFO_CACHE` **alone** (never by +`TRAFFIC_MANAGEMENT_CACHE_SIZE`, which a variant may zero independently), mirroring +`purgeAll()` - a build that has the cache always has its sweep. + +### Direct-response behavior under throttle + +The per-target response throttle bounds only **our spoofed TX**. A request that arrives inside +the throttle window is **not consumed**: `shouldRespondToNodeInfo()` returns false and the +request forwards through normal relay handling toward the genuine target, which can answer +itself. `nodeinfo_cache_hits` counts only replies actually sent. + --- ## How a lookup falls through the tiers diff --git a/src/modules/TrafficManagementModule.h b/src/modules/TrafficManagementModule.h index 1499f0752a0..a3ccc7886c4 100644 --- a/src/modules/TrafficManagementModule.h +++ b/src/modules/TrafficManagementModule.h @@ -33,7 +33,7 @@ /// Packet inspection and traffic shaping: position dedup, per-node rate limiting, unknown-packet /// filtering, NodeInfo direct response, and the next-hop/role overflow caches. One flat 10-byte -/// unified cache backs all per-node features; see docs/tmm_node_stores.md for the store overview. +/// unified cache backs all per-node features; see docs/node_info_stores.md for the store overview. class TrafficManagementModule : public MeshModule, private concurrency::OSThread { public: @@ -148,7 +148,7 @@ class TrafficManagementModule : public MeshModule, private concurrency::OSThread private: // 10-byte packed entry, all platforms. Tick stamps are free-running modular counters with // non-zero presence sentinels; the 4-bit cached role rides the top bits of the two count - // bytes (tier-3 role fallback). Full layout and rationale: docs/tmm_node_stores.md. + // bytes (tier-3 role fallback). Full layout and rationale: docs/node_info_stores.md. #if _meshtastic_Config_DeviceConfig_Role_MAX > 15 #warning "Device role enum max exceeds 15 - TMM 4-bit role cache (rate_count[7:6]/unknown_count[7:6]) will truncate new values" #endif From 7e2e7e06bec541b460ca40074d194c33baebd190 Mon Sep 17 00:00:00 2001 From: nomdetom Date: Sat, 18 Jul 2026 00:12:16 +0100 Subject: [PATCH 38/44] TrafficManagement: adapt tests to forwarded throttles and hourly membership Throttle tests (PSRAM and NodeDB-fallback paths): a request inside the 30 s window now CONTINUEs into normal relay handling instead of being consumed - assert no spoofed TX, no NAK suppression, and (PSRAM path) that nodeinfo_cache_hits counts only replies actually sent. Membership test (renamed reconcileMembershipMarking): the per-minute sweep no longer refreshes isMember, so the test now pins down both halves of the new contract - the very next sweep after a passive NodeDB eviction still shows the stale member bit (the documented up-to-an-hour lag), and a reconcile interval's worth of sweeps clears it. Disabled-module test additionally proves the write-through hooks share the has_traffic_management gate: a key commit while disabled must not land in the NodeInfo cache. Not covered here: a build permutation with TRAFFIC_MANAGEMENT_CACHE_SIZE overridden to 0 (the configuration the maintenance-guard fix protects) would need its own PlatformIO env plus guards on every unified-cache test - left as a follow-up. Co-Authored-By: Claude Fable 5 --- test/test_traffic_management/test_main.cpp | 48 ++++++++++++++++------ 1 file changed, 36 insertions(+), 12 deletions(-) diff --git a/test/test_traffic_management/test_main.cpp b/test/test_traffic_management/test_main.cpp index 02e763addca..9a438e581c6 100644 --- a/test/test_traffic_management/test_main.cpp +++ b/test/test_traffic_management/test_main.cpp @@ -364,6 +364,14 @@ static void test_tm_moduleDisabled_doesNothing(void) TEST_ASSERT_EQUAL_UINT32(0, stats.packets_inspected); TEST_ASSERT_EQUAL_UINT32(0, stats.unknown_packet_drops); TEST_ASSERT_FALSE(module.ignoreRequestFlag()); + + // The write-through hooks share the disabled gate: with maintenance (sweep + reconcile) + // off, NodeDB commits must not fill the NodeInfo cache either. + uint8_t key[32]; + memset(key, 0x42, sizeof(key)); + module.onNodeKeyCommitted(kRemoteNode, key, false); + uint8_t out[32]; + TEST_ASSERT_FALSE(module.copyPublicKey(kRemoteNode, out, nullptr)); } /** @@ -893,12 +901,16 @@ static void test_tm_nodeinfo_directResponse_psramThrottlesWithinWindow(void) TEST_ASSERT_EQUAL_INT(static_cast(ProcessMessage::STOP), static_cast(module.handleReceived(request))); TEST_ASSERT_EQUAL_UINT32(1, static_cast(mockRouter.sentPackets.size())); - // Second request within the throttle window: suppressed (STOP) but NOT transmitted again. + // Second request within the throttle window: our spoofed TX is suppressed, but the request + // is NOT consumed - it CONTINUEs into normal relay handling so the genuine target can answer + // itself. (Previously it was black-holed: STOPped with nothing sent.) The cache-hit stat + // counts only replies actually sent. TrafficManagementModule::s_testNowMs += 5000; // 5 s < 30 s window request.id = 0xAAAA0002; - TEST_ASSERT_EQUAL_INT(static_cast(ProcessMessage::STOP), static_cast(module.handleReceived(request))); - TEST_ASSERT_TRUE(module.ignoreRequestFlag()); + TEST_ASSERT_EQUAL_INT(static_cast(ProcessMessage::CONTINUE), static_cast(module.handleReceived(request))); + TEST_ASSERT_FALSE(module.ignoreRequestFlag()); TEST_ASSERT_EQUAL_UINT32(1, static_cast(mockRouter.sentPackets.size())); + TEST_ASSERT_EQUAL_UINT32(1, module.getStats().nodeinfo_cache_hits); // Past the throttle window: served again. TrafficManagementModule::s_testNowMs += 30000; // now > 30 s since first reply @@ -1542,11 +1554,13 @@ static void test_tm_nodeinfo_tickSaturation_sweepClearsObserved(void) } /** - * Membership marking (ported from tmm-fix-superset): the sweep marks entries whose node - * exists in NodeDB and clears the mark when the node is gone; the entry itself persists - * (no TTL) and reconciliation-seeded identities stay unservable. + * Membership marking: the hourly reconcile marks entries whose node exists in NodeDB and + * clears the mark when the node is gone. A plain 60 s sweep does NOT refresh membership + * (that per-entry NodeDB scan was moved into the reconcile), so a passive NodeDB eviction + * lags by up to an hour; the entry itself persists (no TTL) and reconciliation-seeded + * identities stay unservable. */ -static void test_tm_nodeinfo_sweepMembershipMarking(void) +static void test_tm_nodeinfo_reconcileMembershipMarking(void) { moduleConfig.traffic_management.nodeinfo_direct_response_max_hops = 10; config.device.role = meshtastic_Config_DeviceConfig_Role_CLIENT; @@ -1568,10 +1582,19 @@ static void test_tm_nodeinfo_sweepMembershipMarking(void) TEST_ASSERT_TRUE(flags & kFlagFullUser); TEST_ASSERT_FALSE(flags & kFlagObserved); - // Node drops out of NodeDB entirely: the next sweep clears the membership mark. + // Node drops out of NodeDB entirely. Membership is refreshed by the hourly reconcile, + // not the per-minute sweep, so the very next sweep still shows the stale member bit - + // the documented up-to-an-hour lag for passive evictions. mockNodeDB->rollHotStore(); module.runOnce(); flags = module.peekNodeInfoFlagsForTest(kTargetNode); + TEST_ASSERT_TRUE(flags >= 0); + TEST_ASSERT_TRUE(flags & kFlagMember); // stale by design between reconciles + + // After a reconcile interval's worth of sweeps, the mark is cleared. + for (int i = 0; i < 60; i++) + module.runOnce(); + flags = module.peekNodeInfoFlagsForTest(kTargetNode); TEST_ASSERT_TRUE(flags >= 0); // entry persists (no TTL) ... TEST_ASSERT_FALSE(flags & kFlagMember); // ... but is no longer pinned as a member } @@ -1694,11 +1717,12 @@ static void test_tm_nodeinfo_directResponse_fallbackThrottlesWithinWindow(void) TEST_ASSERT_EQUAL_INT(static_cast(ProcessMessage::STOP), static_cast(module.handleReceived(request))); TEST_ASSERT_EQUAL_UINT32(1, static_cast(mockRouter.sentPackets.size())); - // Second request within the throttle window: suppressed (STOP) but NOT transmitted again. + // Second request within the throttle window: our spoofed TX is suppressed, but the request + // is NOT consumed - it CONTINUEs so the genuine target (or another cache-holder) can answer. TrafficManagementModule::s_testNowMs += 5000; // 5 s < 30 s window request.id = 0xBBBB0002; - TEST_ASSERT_EQUAL_INT(static_cast(ProcessMessage::STOP), static_cast(module.handleReceived(request))); - TEST_ASSERT_TRUE(module.ignoreRequestFlag()); + TEST_ASSERT_EQUAL_INT(static_cast(ProcessMessage::CONTINUE), static_cast(module.handleReceived(request))); + TEST_ASSERT_FALSE(module.ignoreRequestFlag()); TEST_ASSERT_EQUAL_UINT32(1, static_cast(mockRouter.sentPackets.size())); // Past the throttle window: served again. @@ -2765,7 +2789,7 @@ TM_TEST_ENTRY void setup() #endif RUN_TEST(test_tm_nodeinfo_keyHook_upsertsAndGovernsProvenance); RUN_TEST(test_tm_nodeinfo_tickSaturation_sweepClearsObserved); - RUN_TEST(test_tm_nodeinfo_sweepMembershipMarking); + RUN_TEST(test_tm_nodeinfo_reconcileMembershipMarking); #endif RUN_TEST(test_tm_alterReceived_telemetryBroadcast_hopLimitUnchanged); RUN_TEST(test_tm_alterReceived_skipsLocalAndUnicast); From 0012f4399b2a02039c280b3f1692f44300be55ce Mon Sep 17 00:00:00 2001 From: nomdetom Date: Sat, 18 Jul 2026 04:29:15 +0100 Subject: [PATCH 39/44] expand test coverage --- src/modules/TrafficManagementModule.h | 4 + test/test_traffic_management/test_main.cpp | 390 +++++++++++++++++++++ 2 files changed, 394 insertions(+) diff --git a/src/modules/TrafficManagementModule.h b/src/modules/TrafficManagementModule.h index a3ccc7886c4..0d8d5697dd4 100644 --- a/src/modules/TrafficManagementModule.h +++ b/src/modules/TrafficManagementModule.h @@ -145,6 +145,10 @@ class TrafficManagementModule : public MeshModule, private concurrency::OSThread /// bit1 hasResponded, bit2 isMember, bit3 hasFullUser, bit4 keySignerProven. int peekNodeInfoFlagsForTest(NodeNum node); + /// Test introspection: NodeInfo cache capacity (kNodeInfoCacheEntries), so tests can + /// fill the cache exactly and force the tiered-LRU eviction paths. + static constexpr uint16_t nodeInfoCacheCapacityForTest() { return kNodeInfoCacheEntries; } + private: // 10-byte packed entry, all platforms. Tick stamps are free-running modular counters with // non-zero presence sentinels; the 4-bit cached role rides the top bits of the two count diff --git a/test/test_traffic_management/test_main.cpp b/test/test_traffic_management/test_main.cpp index 9a438e581c6..618bcb50548 100644 --- a/test/test_traffic_management/test_main.cpp +++ b/test/test_traffic_management/test_main.cpp @@ -193,6 +193,7 @@ class TrafficManagementModuleTestShim : public TrafficManagementModule using TrafficManagementModule::flushCache; using TrafficManagementModule::handleReceived; using TrafficManagementModule::markKeySignerProvenForTest; + using TrafficManagementModule::nodeInfoCacheCapacityForTest; using TrafficManagementModule::peekCachedRole; using TrafficManagementModule::peekNodeInfoFlagsForTest; using TrafficManagementModule::runOnce; @@ -1471,6 +1472,7 @@ static void test_tm_nodeinfo_directResponse_psramUnsignedNotServed(void) // Bit positions returned by peekNodeInfoFlagsForTest(). constexpr int kFlagObserved = 1; +constexpr int kFlagResponded = 2; constexpr int kFlagMember = 4; constexpr int kFlagFullUser = 8; @@ -1598,6 +1600,382 @@ static void test_tm_nodeinfo_reconcileMembershipMarking(void) TEST_ASSERT_TRUE(flags >= 0); // entry persists (no TTL) ... TEST_ASSERT_FALSE(flags & kFlagMember); // ... but is no longer pinned as a member } + +/** + * cacheNodeInfoPacket() normalizes user.id to the packet sender: a payload claiming another + * node's id string must not plant a mismatched id into served replies or rehydrated names. + */ +static void test_tm_nodeinfo_cache_normalizesSpoofedUserId(void) +{ + mockNodeDB->clearCachedNode(); + TrafficManagementModuleTestShim module; + + meshtastic_MeshPacket packet = makeDecodedPacket(meshtastic_PortNum_NODEINFO_APP, kTargetNode, NODENUM_BROADCAST); + meshtastic_User user = meshtastic_User_init_zero; + snprintf(user.id, sizeof(user.id), "!%08x", static_cast(kRemoteNode)); // spoofed id + strncpy(user.long_name, "spoofer", sizeof(user.long_name) - 1); + packet.decoded.payload.size = + pb_encode_to_bytes(packet.decoded.payload.bytes, sizeof(packet.decoded.payload.bytes), &meshtastic_User_msg, &user); + module.handleReceived(packet); + + meshtastic_User out = meshtastic_User_init_zero; + TEST_ASSERT_TRUE(module.copyUser(kTargetNode, out, nullptr)); + char expected[16]; + snprintf(expected, sizeof(expected), "!%08x", static_cast(kTargetNode)); + TEST_ASSERT_EQUAL_STRING(expected, out.id); +} + +/** + * Wrap safety for the response-throttle clock: the sweep clears hasResponded once the 30 s + * window passes, so a stale respTick can never alias back to "just responded" after a full + * 256-tick wrap of the 5 s clock - and the target becomes servable again end-to-end. + */ +static void test_tm_nodeinfo_sweepClearsResponseThrottleFlag(void) +{ + moduleConfig.traffic_management.nodeinfo_direct_response_max_hops = 10; + config.device.role = meshtastic_Config_DeviceConfig_Role_CLIENT; + mockNodeDB->clearCachedNode(); + mockNodeDB->rollHotStore(); + + MockRouter mockRouter; + mockRouter.addInterface(std::unique_ptr(new MockRadioInterface())); + MeshService mockService; + router = &mockRouter; + service = &mockService; + + TrafficManagementModuleTestShim module; + module.handleReceived(makeNodeInfoPacket(kTargetNode, "target-long", "tg")); + module.markKeySignerProvenForTest(kTargetNode); + + meshtastic_MeshPacket request = makeDecodedPacket(meshtastic_PortNum_NODEINFO_APP, kRemoteNode, kTargetNode); + request.decoded.want_response = true; + request.hop_start = 3; + request.hop_limit = 3; + request.id = 0xBBBB0001; + TEST_ASSERT_EQUAL_INT(static_cast(ProcessMessage::STOP), static_cast(module.handleReceived(request))); + int flags = module.peekNodeInfoFlagsForTest(kTargetNode); + TEST_ASSERT_TRUE(flags >= 0 && (flags & kFlagResponded)); + + // Past the throttle window, the sweep clears the flag (the entry and its observation stay). + TrafficManagementModule::s_testNowMs += 31000; + module.runOnce(); + flags = module.peekNodeInfoFlagsForTest(kTargetNode); + TEST_ASSERT_TRUE(flags >= 0); + TEST_ASSERT_FALSE(flags & kFlagResponded); + TEST_ASSERT_TRUE(flags & kFlagObserved); + + // And the target is servable again. + request.id = 0xBBBB0002; + TEST_ASSERT_EQUAL_INT(static_cast(ProcessMessage::STOP), static_cast(module.handleReceived(request))); + TEST_ASSERT_EQUAL_UINT32(2, static_cast(mockRouter.sentPackets.size())); +} + +/** + * The write-through hooks share the module-enabled gate with maintenance: while traffic + * management is disabled in moduleConfig, neither hook fills the cache (content and + * maintenance stay keyed to the same condition); re-enabling restores write-through. + */ +static void test_tm_nodeinfo_hooks_noopWhileModuleDisabled(void) +{ + mockNodeDB->clearCachedNode(); + TrafficManagementModuleTestShim module; // constructed while enabled, so the cache is allocated + + moduleConfig.has_traffic_management = false; + uint8_t k[32]; + memset(k, 0x6B, sizeof(k)); + module.onNodeKeyCommitted(kTargetNode, k, true); + meshtastic_User user = meshtastic_User_init_zero; + strncpy(user.long_name, "disabled", sizeof(user.long_name) - 1); + module.onNodeIdentityCommitted(kTargetNode, user, false); + + uint8_t key[32] = {0}; + TEST_ASSERT_FALSE(module.copyPublicKey(kTargetNode, key, nullptr)); + meshtastic_User out = meshtastic_User_init_zero; + TEST_ASSERT_FALSE(module.copyUser(kTargetNode, out, nullptr)); + TEST_ASSERT_EQUAL_INT(-1, module.peekNodeInfoFlagsForTest(kTargetNode)); + + // Control: the same hook lands once the module is enabled again. + moduleConfig.has_traffic_management = true; + module.onNodeKeyCommitted(kTargetNode, k, true); + TEST_ASSERT_TRUE(module.copyPublicKey(kTargetNode, key, nullptr)); + TEST_ASSERT_EQUAL_UINT8(0x6B, key[0]); +} + +// Build a User for onNodeIdentityCommitted; keyByte < 0 means a keyless commit. +static meshtastic_User makeCommittedUser(const char *longName, int keyByte) +{ + meshtastic_User user = meshtastic_User_init_zero; + strncpy(user.long_name, longName, sizeof(user.long_name) - 1); + if (keyByte >= 0) { + user.public_key.size = 32; + memset(user.public_key.bytes, static_cast(keyByte), 32); + } + return user; +} + +/** + * Identity write-through hook, key semantics: a keyless commit keeps an already-learned key + * (NodeDB may commit an unpinned identity); provenance follows the committed key - kept + * while the key is unchanged, granted by signerKnown, and reset by a rotation. + */ +static void test_tm_nodeinfo_identityHook_keySemantics(void) +{ + mockNodeDB->clearCachedNode(); + TrafficManagementModuleTestShim module; + + // TOFU-grade identity commit with key K1. + module.onNodeIdentityCommitted(kTargetNode, makeCommittedUser("first", 0x51), false); + uint8_t key[32] = {0}; + bool proven = true; + TEST_ASSERT_TRUE(module.copyPublicKey(kTargetNode, key, &proven)); + TEST_ASSERT_EQUAL_UINT8(0x51, key[0]); + TEST_ASSERT_FALSE(proven); + + // Keyless commit: the name updates but the learned key must survive, still unproven. + module.onNodeIdentityCommitted(kTargetNode, makeCommittedUser("renamed", -1), false); + meshtastic_User out = meshtastic_User_init_zero; + TEST_ASSERT_TRUE(module.copyUser(kTargetNode, out, &proven)); + TEST_ASSERT_EQUAL_STRING("renamed", out.long_name); + TEST_ASSERT_EQUAL_UINT32(32, out.public_key.size); + TEST_ASSERT_EQUAL_UINT8(0x51, out.public_key.bytes[0]); + TEST_ASSERT_FALSE(proven); + + // signerKnown commit of the same key grants provenance... + module.onNodeIdentityCommitted(kTargetNode, makeCommittedUser("proven", 0x51), true); + TEST_ASSERT_TRUE(module.copyPublicKey(kTargetNode, key, &proven)); + TEST_ASSERT_TRUE(proven); + + // ...which a later same-key commit without the signer verdict does not revoke... + module.onNodeIdentityCommitted(kTargetNode, makeCommittedUser("still-proven", 0x51), false); + TEST_ASSERT_TRUE(module.copyPublicKey(kTargetNode, key, &proven)); + TEST_ASSERT_TRUE(proven); + + // ...but a rotated key never inherits the old key's verdict. + module.onNodeIdentityCommitted(kTargetNode, makeCommittedUser("rotated", 0x52), false); + TEST_ASSERT_TRUE(module.copyPublicKey(kTargetNode, key, &proven)); + TEST_ASSERT_EQUAL_UINT8(0x52, key[0]); + TEST_ASSERT_FALSE(proven); +} + +#if !(MESHTASTIC_EXCLUDE_PKI) +// Fill `count` NodeInfo cache slots with keyless observed strangers (eviction tier 0), +// numbered from `baseNode`. +static void fillNodeInfoCacheWithKeylessStrangers(TrafficManagementModuleTestShim &module, uint32_t count, NodeNum baseNode) +{ + for (uint32_t i = 0; i < count; i++) + module.handleReceived(makeNodeInfoPacket(baseNode + i, "filler", "fl")); +} + +// Fill `count` NodeInfo cache slots with TOFU-keyed observed strangers (eviction tier 1), +// numbered from `baseNode`, all sharing key byte 0x0F. +static void fillNodeInfoCacheWithTofuStrangers(TrafficManagementModuleTestShim &module, uint32_t count, NodeNum baseNode) +{ + for (uint32_t i = 0; i < count; i++) + module.handleReceived(makeNodeInfoPacketWithKey(baseNode + i, "filler", 0x0F)); +} + +/** + * Tiered LRU eviction, tier boundaries: with the cache exactly full, a new stranger's insert + * evicts a keyless stranger - never a TOFU-keyed entry, a signer-proven entry, or a NodeDB + * member - even though those higher-tier entries are the OLDEST observations in the cache + * (tier outranks recency). + */ +static void test_tm_nodeinfo_eviction_keyedTiersOutrankKeyless(void) +{ + mockNodeDB->clearCachedNode(); + mockNodeDB->rollHotStore(); + TrafficManagementModuleTestShim module; + + constexpr NodeNum kTofu = 0x51000001, kProven = 0x51000002, kMember = 0x51000003, kNewcomer = 0x51000004; + module.handleReceived(makeNodeInfoPacketWithKey(kTofu, "tofu", 0x11)); + module.handleReceived(makeNodeInfoPacketWithKey(kProven, "proven", 0x22)); + module.markKeySignerProvenForTest(kProven); + uint8_t memberKey[32]; + memset(memberKey, 0x33, sizeof(memberKey)); + module.onNodeKeyCommitted(kMember, memberKey, false); + + // Age the specials by two observation ticks, then fill every remaining slot with + // fresher keyless strangers so the cache is exactly full. + TrafficManagementModule::s_testNowMs += 2UL * 180000UL; + const uint16_t cap = TrafficManagementModuleTestShim::nodeInfoCacheCapacityForTest(); + fillNodeInfoCacheWithKeylessStrangers(module, cap - 3u, 0x40000000); + + // The newcomer's insert must claim a keyless slot. + module.handleReceived(makeNodeInfoPacket(kNewcomer, "newcomer", "nc")); + + uint8_t key[32] = {0}; + TEST_ASSERT_TRUE(module.copyPublicKey(kTofu, key, nullptr)); + TEST_ASSERT_TRUE(module.copyPublicKey(kProven, key, nullptr)); + TEST_ASSERT_TRUE(module.copyPublicKey(kMember, key, nullptr)); + TEST_ASSERT_TRUE(module.peekNodeInfoFlagsForTest(kNewcomer) >= 0); +} + +/** + * Tiered LRU eviction, keyed tiers: in a cache saturated with TOFU-keyed strangers, keyed + * inserts displace TOFU entries while a signer-proven stranger and a NodeDB member survive + * (keyless < TOFU < signer-proven, +membership). + */ +static void test_tm_nodeinfo_eviction_tofuLosesBeforeProvenAndMember(void) +{ + mockNodeDB->clearCachedNode(); + mockNodeDB->rollHotStore(); + TrafficManagementModuleTestShim module; + + constexpr NodeNum kFillBase = 0x40000000; + constexpr NodeNum kProven = 0x52000001, kMember = 0x52000002, kNew1 = 0x52000003, kNew2 = 0x52000004; + + const uint16_t cap = TrafficManagementModuleTestShim::nodeInfoCacheCapacityForTest(); + fillNodeInfoCacheWithTofuStrangers(module, cap - 2u, kFillBase); + module.handleReceived(makeNodeInfoPacketWithKey(kProven, "proven", 0x22)); + module.markKeySignerProvenForTest(kProven); + uint8_t memberKey[32]; + memset(memberKey, 0x33, sizeof(memberKey)); + module.onNodeKeyCommitted(kMember, memberKey, false); + + // Age the saturated cache so each newcomer is fresher than the remaining fillers + // (otherwise the second insert would reclaim the first newcomer's equal-age slot). + TrafficManagementModule::s_testNowMs += 2UL * 180000UL; + module.handleReceived(makeNodeInfoPacketWithKey(kNew1, "new1", 0x44)); + module.handleReceived(makeNodeInfoPacketWithKey(kNew2, "new2", 0x55)); + + uint8_t key[32] = {0}; + TEST_ASSERT_TRUE(module.copyPublicKey(kProven, key, nullptr)); + TEST_ASSERT_TRUE(module.copyPublicKey(kMember, key, nullptr)); + TEST_ASSERT_TRUE(module.copyPublicKey(kNew1, key, nullptr)); + TEST_ASSERT_TRUE(module.copyPublicKey(kNew2, key, nullptr)); + // The two victims were the first TOFU fillers scanned, not the protected entries. + TEST_ASSERT_FALSE(module.copyPublicKey(kFillBase + 0, key, nullptr)); + TEST_ASSERT_FALSE(module.copyPublicKey(kFillBase + 1, key, nullptr)); +} + +/** + * Within-tier LRU: among same-tier entries the stalest observation is evicted first, not + * whichever slot happens to be scanned first. + */ +static void test_tm_nodeinfo_eviction_withinTierStalestLoses(void) +{ + mockNodeDB->clearCachedNode(); + mockNodeDB->rollHotStore(); + TrafficManagementModuleTestShim module; + + constexpr NodeNum kFillBase = 0x40000000; + constexpr NodeNum kStale = 0x53000001, kNewcomer = 0x53000002; + module.handleReceived(makeNodeInfoPacketWithKey(kStale, "stale", 0x11)); + + // Everything else is observed two ticks later... + TrafficManagementModule::s_testNowMs += 2UL * 180000UL; + const uint16_t cap = TrafficManagementModuleTestShim::nodeInfoCacheCapacityForTest(); + fillNodeInfoCacheWithTofuStrangers(module, cap - 1u, kFillBase); + + // ...so the newcomer's insert reclaims kStale's slot specifically. + module.handleReceived(makeNodeInfoPacketWithKey(kNewcomer, "newcomer", 0x22)); + + uint8_t key[32] = {0}; + TEST_ASSERT_FALSE(module.copyPublicKey(kStale, key, nullptr)); + TEST_ASSERT_TRUE(module.peekNodeInfoFlagsForTest(kNewcomer) >= 0); + TEST_ASSERT_TRUE(module.copyPublicKey(kFillBase + 0, key, nullptr)); // fresher same-tier entry survives +} + +/** + * Member saturation: with every slot holding a NodeDB member, the write-through hooks skip + * rather than churn one member out for another (spareMembers), while the packet path - a + * genuinely observed frame - does evict a member. + */ +static void test_tm_nodeinfo_memberSaturated_hooksSkipPacketPathEvicts(void) +{ + mockNodeDB->clearCachedNode(); + mockNodeDB->rollHotStore(); + TrafficManagementModuleTestShim module; + + constexpr NodeNum kFillBase = 0x40000000; + constexpr NodeNum kExtra = 0x54000001, kObserved = 0x54000002; + + const uint16_t cap = TrafficManagementModuleTestShim::nodeInfoCacheCapacityForTest(); + uint8_t k[32]; + memset(k, 0x11, sizeof(k)); + for (uint32_t i = 0; i < cap; i++) + module.onNodeKeyCommitted(kFillBase + i, k, false); + + // Hook inserts for one more member: skipped rather than evicting an existing member. + uint8_t extraKey[32]; + memset(extraKey, 0x22, sizeof(extraKey)); + module.onNodeKeyCommitted(kExtra, extraKey, false); + uint8_t key[32] = {0}; + TEST_ASSERT_FALSE(module.copyPublicKey(kExtra, key, nullptr)); + module.onNodeIdentityCommitted(kExtra, makeCommittedUser("extra", 0x22), false); + meshtastic_User out = meshtastic_User_init_zero; + TEST_ASSERT_FALSE(module.copyUser(kExtra, out, nullptr)); + + // The packet path may churn a member: the observed stranger lands (in the first-scanned + // member's slot) and is correctly NOT marked a member itself. + module.handleReceived(makeNodeInfoPacketWithKey(kObserved, "observed", 0x33)); + TEST_ASSERT_TRUE(module.copyPublicKey(kObserved, key, nullptr)); + const int flags = module.peekNodeInfoFlagsForTest(kObserved); + TEST_ASSERT_TRUE(flags >= 0); + TEST_ASSERT_TRUE(flags & kFlagObserved); + TEST_ASSERT_FALSE(flags & kFlagMember); + TEST_ASSERT_FALSE(module.copyPublicKey(kFillBase + 0, key, nullptr)); // the evicted member +} + +/** + * Full DB reset: NodeDB::resetNodes() purges this module's caches through purgeAll(), so no + * identity, key, or next-hop hint survives a user-initiated "forget everything". + */ +static void test_tm_nodeinfo_resetNodes_purgesAllCaches(void) +{ + moduleConfig.traffic_management.nodeinfo_direct_response_max_hops = 10; + config.device.role = meshtastic_Config_DeviceConfig_Role_CLIENT; + mockNodeDB->clearCachedNode(); + mockNodeDB->rollHotStore(); + + MockRouter mockRouter; + mockRouter.addInterface(std::unique_ptr(new MockRadioInterface())); + MeshService mockService; + router = &mockRouter; + service = &mockService; + + TrafficManagementModuleTestShim module; + trafficManagementModule = &module; // the NodeDB purge hook reaches the module via the global + + module.handleReceived(makeNodeInfoPacketWithKey(kTargetNode, "victim", 0x37)); + module.setNextHop(kTargetNode, 0x42); + uint8_t key[32] = {0}; + TEST_ASSERT_TRUE(module.copyPublicKey(kTargetNode, key, nullptr)); + TEST_ASSERT_EQUAL_UINT8(0x42, module.getNextHopHint(kTargetNode)); + + mockNodeDB->resetNodes(); + + TEST_ASSERT_FALSE(module.copyPublicKey(kTargetNode, key, nullptr)); + meshtastic_User out = meshtastic_User_init_zero; + TEST_ASSERT_FALSE(module.copyUser(kTargetNode, out, nullptr)); + TEST_ASSERT_EQUAL_INT(-1, module.peekNodeInfoFlagsForTest(kTargetNode)); + TEST_ASSERT_EQUAL_UINT8(0, module.getNextHopHint(kTargetNode)); + + trafficManagementModule = nullptr; +} + +/** + * A NodeInfo advertising OUR OWN public key is impersonating us and must never be cached + * (mirrors NodeDB::updateUser()'s key hygiene for the store that feeds spoofed replies). + */ +static void test_tm_nodeinfo_cache_dropsFrameCarryingOwnerKey(void) +{ + mockNodeDB->clearCachedNode(); + TrafficManagementModuleTestShim module; + + owner.public_key.size = 32; + memset(owner.public_key.bytes, 0x42, 32); + + module.handleReceived(makeNodeInfoPacketWithKey(kTargetNode, "imposter", 0x42)); + + TEST_ASSERT_EQUAL_INT(-1, module.peekNodeInfoFlagsForTest(kTargetNode)); + uint8_t key[32] = {0}; + TEST_ASSERT_FALSE(module.copyPublicKey(kTargetNode, key, nullptr)); + + owner.public_key.size = 0; // restore for later tests + memset(owner.public_key.bytes, 0, sizeof(owner.public_key.bytes)); +} +#endif // !MESHTASTIC_EXCLUDE_PKI #endif /** @@ -2790,6 +3168,18 @@ TM_TEST_ENTRY void setup() RUN_TEST(test_tm_nodeinfo_keyHook_upsertsAndGovernsProvenance); RUN_TEST(test_tm_nodeinfo_tickSaturation_sweepClearsObserved); RUN_TEST(test_tm_nodeinfo_reconcileMembershipMarking); + RUN_TEST(test_tm_nodeinfo_cache_normalizesSpoofedUserId); + RUN_TEST(test_tm_nodeinfo_sweepClearsResponseThrottleFlag); + RUN_TEST(test_tm_nodeinfo_hooks_noopWhileModuleDisabled); + RUN_TEST(test_tm_nodeinfo_identityHook_keySemantics); +#if !(MESHTASTIC_EXCLUDE_PKI) + RUN_TEST(test_tm_nodeinfo_eviction_keyedTiersOutrankKeyless); + RUN_TEST(test_tm_nodeinfo_eviction_tofuLosesBeforeProvenAndMember); + RUN_TEST(test_tm_nodeinfo_eviction_withinTierStalestLoses); + RUN_TEST(test_tm_nodeinfo_memberSaturated_hooksSkipPacketPathEvicts); + RUN_TEST(test_tm_nodeinfo_resetNodes_purgesAllCaches); + RUN_TEST(test_tm_nodeinfo_cache_dropsFrameCarryingOwnerKey); +#endif #endif RUN_TEST(test_tm_alterReceived_telemetryBroadcast_hopLimitUnchanged); RUN_TEST(test_tm_alterReceived_skipsLocalAndUnicast); From 3f502ef28b9b74341a3b276b902e90d061ec9f6e Mon Sep 17 00:00:00 2001 From: nomdetom Date: Sat, 18 Jul 2026 04:45:29 +0100 Subject: [PATCH 40/44] nitpicks and docs update --- docs/node_info_stores.md | 134 ++++++++++++--------- src/mesh/NodeDB.cpp | 2 +- src/modules/TrafficManagementModule.cpp | 37 +++--- src/modules/TrafficManagementModule.h | 11 +- test/test_traffic_management/test_main.cpp | 14 +-- 5 files changed, 113 insertions(+), 85 deletions(-) diff --git a/docs/node_info_stores.md b/docs/node_info_stores.md index 9ca75beed72..67df6801e4d 100644 --- a/docs/node_info_stores.md +++ b/docs/node_info_stores.md @@ -1,14 +1,15 @@ # NodeInfo stores: the base and extended databases This document is an overview of the node-identity and traffic-state databases that the -TrafficManagementModule (TMM) either owns or leans on. There are four stores in play, -but only three form the identity lookup chain: +TrafficManagementModule (TMM) either owns or leans on. There are four stores in play, but +only three form the identity lookup chain: 1. **NodeDB hot store** - the authoritative `NodeInfoLite` array (identity tier 1). 2. **Warm tier** (`WarmNodeStore`) - minimal persisted records for hot-store evictees (identity tier 2). 3. **TMM NodeInfo payload cache** (extended) - the ephemeral **third identity tier**: full - `User` payloads plus direct-response metadata, in PSRAM. + `User` payloads plus direct-response metadata; PSRAM-backed on hardware, plain heap in + native tests. The fourth store, the **TMM unified cache** (base - flat 10-byte-per-node traffic-shaping state), is not part of that chain: it sits beside it, keyed by the same NodeNum, and only @@ -50,8 +51,8 @@ Sources of truth: `src/mesh/NodeDB.{h,cpp}`, `src/mesh/WarmNodeStore.h`, record survives so DMs keep encrypting: the key is expensive to re-learn; everything else rebuilds from traffic in seconds. - **Entry:** exactly 40 bytes - `num(4) | last_heard(4) | public_key(32)`. The low 7 bits - of `last_heard` are stolen for metadata (role: 4 bits, protected category: 2, signer - bit: 1), leaving ~128 s recency resolution - plenty for LRU ranking. + of `last_heard` are omitted, and replaced with metadata (role: 4 bits, protected + category: 2, signer bit: 1), leaving ~128 s recency resolution - plenty for LRU ranking. - **Capacity:** `WARM_NODE_COUNT` (100 on constrained parts; platform-tiered). - **Eviction:** LRU by `last_heard`, with keyed entries outranking keyless; keyless candidates never displace keyed entries. @@ -68,58 +69,58 @@ Sources of truth: `src/mesh/NodeDB.{h,cpp}`, `src/mesh/WarmNodeStore.h`, - `next_hop` - last-byte relay hint, written only from ACK-confirmed NextHopRouter decisions (no TTL; keeps the slot alive across sweeps). - a **4-bit device role** (split across the top bits of two count bytes) - the _third_ - fallback for role-aware policy after the hot store and warm tier, surviving even - total NodeDB eviction. Read through `resolveSenderRole()`, refreshed by + fallback for role-aware policy after the hot store and warm tier, surviving even total + NodeDB eviction. Read through `resolveSenderRole()`, refreshed by `updateCachedRoleFromNodeInfo()` on observed NodeInfo. -- **Entry layout:** `node(4) | pos_fingerprint(1) | rate_count(1) | unknown_count(1) | -pos_time(1) | rate_unknown_time(1) | next_hop(1)` = 10 bytes, all platforms. - Timestamps are free-running modular ticks (uint8 / nibbles) with presence carried by - non-zero sentinels - no epochs, no absolute time. +- **Entry layout:** + `node(4) | pos_fingerprint(1) | rate_count(1) | unknown_count(1) | pos_time(1) | rate_unknown_time(1) | next_hop(1)` + = 10 bytes, all platforms. Timestamps are free-running modular ticks (uint8 / nibbles) + with presence carried by non-zero sentinels - no epochs, no absolute time. - **Capacity:** `TRAFFIC_MANAGEMENT_CACHE_SIZE`, per memory class: 2048 (PSRAM S3 / - native), 500 (medium), 400 (small), 250 (nRF52840 - deliberately class-deviant for - heap headroom), 0 when `HAS_TRAFFIC_MANAGEMENT=0`. Variant-overridable. + native), 500 (medium), 400 (small), 250 (nRF52840 - deliberately class-deviant for heap + headroom), 0 when `HAS_TRAFFIC_MANAGEMENT=0`. Variant-overridable. - **Eviction:** linear scan; insertion on a full cache evicts the stalest entry, preferring entries without a `next_hop` hint. - **Persistence:** none - RAM/PSRAM only, rebuilt from traffic. ## 4. TMM NodeInfo payload cache (extended, the ephemeral third tier) -- **What:** a flat PSRAM array of `NodeInfoPayloadEntry` - the full cached `User` - payload (names, role, key) plus the metadata needed to serve **spoofed direct - NodeInfo replies** on a target's behalf, independent of NodeDB. Also the last-resort - key source for `NodeDB::copyPublicKey()`. -- **Availability:** `TMM_HAS_NODEINFO_CACHE` - ESP32 with PSRAM (production home; - 2000 entries is too large for MCU internal RAM), plus native unit-test builds on the - plain heap so the trust/retention paths run in CI. +- **What:** a flat array of `NodeInfoPayloadEntry` (PSRAM-backed on hardware; see + Availability) - the full cached `User` payload (names, role, key) plus the metadata + needed to serve **spoofed direct NodeInfo replies** on a target's behalf, independent of + NodeDB. Also the last-resort key source for `NodeDB::copyPublicKey()`. +- **Availability:** `TMM_HAS_NODEINFO_CACHE` - ESP32 with PSRAM (production home; 2000 + entries is too large for MCU internal RAM), plus native unit-test builds on the plain + heap so the trust/retention paths run in CI. - **Entry:** `node`, `user` (full nanopb `User`), tick stamps (`obsTick` 3 min/tick, `respTick` 5 s/tick), `sourceChannel`, `decodedBitfield`, and packed 1-bit flags: `hasDecodedBitfield`, `keySignerProven`, `hasObserved`, `hasResponded`, `hasFullUser`, `isMember`. - **Capacity:** `kNodeInfoCacheEntries = 2000`, linear scan (NodeInfo traffic is low-rate). -- **Persistence:** none - this tier is deliberately ephemeral; it reconstructs from - NodeDB seeding plus observed traffic after every boot. +- **Persistence:** none - this tier is deliberately ephemeral; it reconstructs from NodeDB + seeding plus observed traffic after every boot. ### Trust & provenance model - **Key pin, three layers deep:** an incoming NodeInfo key is checked against - `copyPublicKeyAuthoritative()` (hot then warm - the same coverage as - `updateUser`'s own pin), and, failing NodeDB knowledge, against the cache's **own - previously cached key** (TOFU pin). Mismatches are dropped, never overwritten. A frame - advertising _our own_ key is dropped outright (impersonation). + `copyPublicKeyAuthoritative()` (hot then warm - the same coverage as `updateUser`'s own + pin), and, failing NodeDB knowledge, against the cache's **own previously cached key** + (TOFU pin). Mismatches are dropped, never overwritten. A frame advertising _our own_ key + is dropped outright (impersonation). - **`keySignerProven`:** set when a frame's XEdDSA signature was router-verified (`mp.xeddsa_signed`) or when NodeDB already knew the node as a signer **for the same key** (`isVerifiedSignerForKey`). Monotonic per slot; a changed key resets it. - **Unsigned-identity gate:** a NodeInfo arriving _unsigned_ from a node we have ever verified as a signer - per `NodeDB::isKnownXeddsaSigner()`, which covers hot **and - warm** tiers - drives no cache, role, or `updateUser()` write. (Warm coverage matters: - a signer evicted to the warm tier would otherwise be forgeable with its own public key + warm** tiers - drives no cache, role, or `updateUser()` write. (Warm coverage matters: a + signer evicted to the warm tier would otherwise be forgeable with its own public key until re-heard. The same rule guards `Router::checkXeddsaReceivePolicy`'s unsigned-broadcast drop.) - **Serve gate honesty:** only a genuinely _heard_ NODEINFO frame stamps - `obsTick`/`hasObserved`. Seeding and write-through are knowledge, not observation - - they can never make a silent node look alive to the replay path. The serve window - (6 h) and per-target throttle (30 s) are enforced by the sweep-cleared flag bits. + `obsTick`/`hasObserved`. Seeding and write-through are knowledge, not observation - they + can never make a silent node look alive to the replay path. The serve window (6 h) and + per-target throttle (30 s) are enforced by the sweep-cleared flag bits. ### Consistency with NodeDB (anti-entropy) @@ -136,23 +137,25 @@ Three mechanisms keep this tier a superset of NodeDB's identities: trust tiers - members and signer-proven keys are stickiest; the seeding pass additionally refuses to churn one member out for another (`spareMembers`). -**Key-commit funnel:** every path that writes a remote key into the hot store must route the -write-through. Full-identity commits funnel through `NodeDB::updateUser()`; bare-key commits -(admin-channel learn in `Router::perhapsDecode`, manual verification in -`KeyVerificationModule`) funnel through `NodeDB::commitRemoteKey()`, which carries an explicit -`KeyCommitTrust` provenance (`ManuallyVerified` maps to `proven=true` in this cache). Never -assign `info->public_key` directly - the cache would silently diverge until the next reconcile. +**Key-commit funnel:** every path that writes a remote key into the hot store must route +the write-through. Full-identity commits funnel through `NodeDB::updateUser()`; bare-key +commits (admin-channel learn in `Router::perhapsDecode`, manual verification in +`KeyVerificationModule`) funnel through `NodeDB::commitRemoteKey()`, which carries an +explicit `KeyCommitTrust` provenance (`ManuallyVerified` maps to `proven=true` in this +cache). Never assign `info->public_key` directly - the cache would silently diverge until +the next reconcile. **Enable gate:** the write-through hooks, like the sweep and the packet path, no-op while -`moduleConfig.has_traffic_management` is off, so cache content and cache maintenance are keyed -to the same condition. Corollary: the pubkey-pool superset property only holds while the -module is enabled. +`moduleConfig.has_traffic_management` is off, so cache content and cache maintenance are +keyed to the same condition. Corollary: the pubkey-pool superset property only holds while +the module is enabled. ### Tick clocks and wrap safety -All TMM timestamps are free-running modular ticks (uint8 or nibble) derived from `clockMs()`; -modular subtraction gives a correct age only while the true age stays below the counter -period. What keeps each clock honest differs, and matters when touching the sweep: +All TMM timestamps are free-running modular ticks (uint8 or nibble) derived from +`clockMs()`; modular subtraction gives a correct age only while the true age stays below +the counter period. What keeps each clock honest differs, and matters when touching the +sweep: | Clock | Period | Window/TTL | Wrap safety | | ---------------------------- | -------- | --------------- | --------------------------------------------------------------------------------------------------------------------------------------------- | @@ -164,23 +167,46 @@ period. What keeps each clock honest differs, and matters when touching the swee The warm tier is different by design: `WarmNodeStore.last_heard` is an **absolute** unix-seconds timestamp (quantised to 128 s by the metadata steal), so it cannot wrap until -2106 and needs no sweep - affordable at 100 x 40 B, where the TMM caches chose 1-byte ticks -to stay at 10 B/entry x up to 2048. +2106 and needs no sweep - affordable at 100 x 40 B, where the TMM caches chose 1-byte +ticks to stay at 10 B/entry x up to 2048. -Because the NodeInfo clocks are sweep-dependent, the maintenance invariant is compile-time: -`maintainNodeInfoCacheLocked()` is guarded by `TMM_HAS_NODEINFO_CACHE` **alone** (never by -`TRAFFIC_MANAGEMENT_CACHE_SIZE`, which a variant may zero independently), mirroring -`purgeAll()` - a build that has the cache always has its sweep. +Because the NodeInfo clocks are sweep-dependent, the maintenance invariant is +compile-time: `maintainNodeInfoCacheLocked()` is guarded by `TMM_HAS_NODEINFO_CACHE` +**alone** (never by `TRAFFIC_MANAGEMENT_CACHE_SIZE`, which a variant may zero +independently), mirroring `purgeAll()` - a build that has the cache always has its sweep. ### Direct-response behavior under throttle -The per-target response throttle bounds only **our spoofed TX**. A request that arrives inside -the throttle window is **not consumed**: `shouldRespondToNodeInfo()` returns false and the -request forwards through normal relay handling toward the genuine target, which can answer -itself. `nodeinfo_cache_hits` counts only replies actually sent. +The per-target response throttle bounds only **our spoofed TX**. A request that arrives +inside the throttle window is **not consumed**: `shouldRespondToNodeInfo()` returns false +and the request forwards through normal relay handling toward the genuine target, which +can answer itself. `nodeinfo_cache_hits` counts only replies actually sent. --- +## Property matrix + +Side-by-side view of what each store actually holds ("-" = not held). Details and +rationale live in the per-store sections above. + +| Property | 1. Hot store (`NodeInfoLite`) | 2. Warm tier (`WarmNodeEntry`) | 3. NodeInfo cache (`NodeInfoPayloadEntry`) | 4. Unified cache (`UnifiedCacheEntry`) | +| ------------------------- | -------------------------------- | ---------------------------------------------- | -------------------------------------------------------------- | ---------------------------------------------------- | +| Node number | yes | yes | yes (0 = free slot) | yes (0 = free slot) | +| Names + user id | yes (flattened fields) | - | yes (full `User`, when `hasFullUser`) | - | +| Public key (32 B) | yes (authoritative) | yes (keyed entries) | yes (TOFU or proven; pinned against tiers 1-2) | - | +| Signer provenance | `HAS_XEDDSA_SIGNED` bitfield bit | 1 signer bit (shared with `last_heard`) | `keySignerProven` (monotonic per key) | - | +| Device role | `role` field | 4-bit role (metadata steal) | inside the cached `User` | 4-bit role in count-byte top bits (final fallback) | +| Recency | `last_heard` (unix secs) | `last_heard` (unix secs, 128 s quantised) | `obsTick` (3 min modular tick) + `hasObserved` | pos/rate/unknown modular ticks | +| Position / telemetry | via satellite copy-out accessors | - | - | 8-bit position _fingerprint_ only (dedup) | +| Protected / favorite | bitfield flags | 2-bit protected category | - (`isMember` keep-alive instead) | - | +| Routing hint (`next_hop`) | yes (persisted field) | - | - | ACK-confirmed relay byte (preloaded from tier 1) | +| Direct-reply metadata | - | - | `respTick`, `hasResponded`, `sourceChannel`, `decodedBitfield` | - | +| Traffic-shaping counters | - | - | - | rate + unknown counts, pos fingerprint | +| Entry size | largest (full struct) | 40 B exact | ~`sizeof(User)`+8, platform-padded (no size assert by design) | 10 B exact | +| Capacity | `MAX_NUM_NODES` (250/120/10) | `WARM_NODE_COUNT` (~100) | `kNodeInfoCacheEntries` (2000) | `TRAFFIC_MANAGEMENT_CACHE_SIZE` (2048/500/400/250/0) | +| Persistence | node DB file | raw-flash ring (nRF52840) or `/prefs/warm.dat` | none (rebuilt from seed + traffic) | none | +| Storage | RAM | RAM + flash | PSRAM on hardware; plain heap in native tests | PSRAM when available, else heap | + ## How a lookup falls through the tiers ```text @@ -193,7 +219,7 @@ identity/role/key consumer 2. warm tier (WarmNodeStore) key + role/protected/signer bits, persisted │ miss ▼ - 3. TMM NodeInfo cache (PSRAM) full User payloads + TOFU/proven keys, ephemeral + 3. TMM NodeInfo cache (extended) full User payloads + TOFU/proven keys, ephemeral │ miss (role-only: 4-bit role in the unified cache) ▼ defaults (no key; role = CLIENT) diff --git a/src/mesh/NodeDB.cpp b/src/mesh/NodeDB.cpp index f660cc3da0e..92c6d9cccef 100644 --- a/src/mesh/NodeDB.cpp +++ b/src/mesh/NodeDB.cpp @@ -3911,7 +3911,7 @@ meshtastic_NodeInfoLite *NodeDB::getOrCreateMeshNode(NodeNum n) // long-tail node is nameless until its next NodeInfo. The TrafficManagement NodeInfo // cache is much larger and often still holds the full User. Restore it - but only when // its cached key matches the key we just restored from warm, so a name never attaches to - // a different identity than the one we encrypt to. No-op without the PSRAM NodeInfo cache + // a different identity than the one we encrypt to. No-op without the TMM NodeInfo cache // or when no key is present (key-matched by design). CopyUserToNodeInfoLite sets only the // user-related bits, so the warm-restored signer bit survives. if (lite->public_key.size == 32 && !nodeInfoLiteHasUser(lite) && trafficManagementModule) { diff --git a/src/modules/TrafficManagementModule.cpp b/src/modules/TrafficManagementModule.cpp index 33657ed9362..7642eae9731 100644 --- a/src/modules/TrafficManagementModule.cpp +++ b/src/modules/TrafficManagementModule.cpp @@ -128,7 +128,7 @@ TrafficManagementModule *trafficManagementModule; // Constructor // ============================================================================= -/// Allocate the unified cache (and, where available, the PSRAM NodeInfo cache) and start the sweep. +/// Allocate the unified cache (and, where available, the NodeInfo cache) and start the sweep. TrafficManagementModule::TrafficManagementModule() : MeshModule("TrafficManagement"), concurrency::OSThread("TrafficManagement") { // Module configuration @@ -279,7 +279,8 @@ int TrafficManagementModule::peekCachedRole(NodeNum node) #endif } -// The two caches are compile-time independent (TMM_HAS_NODEINFO_CACHE keys on PSRAM, the +// The two caches are compile-time independent (TMM_HAS_NODEINFO_CACHE keys on PSRAM or +// native tests, the // unified cache on a per-variant size that may be overridden to 0), so each is purged under // its own guard - a build with only one of them must still forget deleted nodes. void TrafficManagementModule::purgeNode(NodeNum node) @@ -855,7 +856,7 @@ void TrafficManagementModule::cacheNodeInfoPacket(const meshtastic_MeshPacket &m } if (usedEmptySlot) { - TM_LOG_INFO("NodeInfo PSRAM cache entries: %u/%u", static_cast(cachedCount), + TM_LOG_INFO("NodeInfo cache entries: %u/%u", static_cast(cachedCount), static_cast(nodeInfoTargetEntries())); } } @@ -1102,7 +1103,7 @@ ProcessMessage TrafficManagementModule::handleReceived(const meshtastic_MeshPack const bool isNodeInfo = mp.decoded.portnum == meshtastic_PortNum_NODEINFO_APP; const bool unauthenticatedSigner = isNodeInfo && !mp.xeddsa_signed && nodeDB && nodeDB->isKnownXeddsaSigner(getFrom(&mp)); - // Learn NodeInfo payloads into the dedicated PSRAM cache, and refresh the tier-3 + // Learn NodeInfo payloads into the dedicated NodeInfo cache, and refresh the tier-3 // role cache for any node we already track (keeps the dedup role exception current). if (isNodeInfo && !unauthenticatedSigner) { cacheNodeInfoPacket(mp); @@ -1421,7 +1422,7 @@ bool TrafficManagementModule::shouldRespondToNodeInfo(const meshtastic_MeshPacke meshtastic_User cachedUser = meshtastic_User_init_zero; bool hasCachedUser = false; - // Extra metadata consumed only by the PSRAM-backed cache path. + // Extra metadata consumed only by the NodeInfo-cache path. // Defaults preserve previous behavior when cache metadata is unavailable. bool cachedHasDecodedBitfield = false; uint8_t cachedDecodedBitfield = 0; @@ -1436,12 +1437,12 @@ bool TrafficManagementModule::shouldRespondToNodeInfo(const meshtastic_MeshPacke // Signer-proven provenance of the cached key, consumed by the replay gate below // (maybe_unused: read only when TMM_NODEINFO_REPLAY_SIGNED_GATE is compiled in). [[maybe_unused]] bool cachedKeySignerProven = false; - // True once we commit to answering from the NodeDB fallback (non-PSRAM) path, so the - // shared throttle check/stamp below target the module-global fallback stamp instead of - // a per-node PSRAM entry (which does not exist on this path). + // True once we commit to answering from the NodeDB fallback (no NodeInfo cache) path, so + // the shared throttle check/stamp below target the module-global fallback stamp instead + // of a per-node cache entry (which does not exist on this path). bool usedFallback = false; #if TMM_HAS_NODEINFO_CACHE - // Slot index of the PSRAM cache hit, captured here so the post-send throttle stamp can + // Slot index of the NodeInfo cache hit, captured here so the post-send throttle stamp can // address the entry directly instead of rescanning the array (T5). -1 = no hit. int32_t cachedNodeInfoIndex = -1; #endif @@ -1467,15 +1468,15 @@ bool TrafficManagementModule::shouldRespondToNodeInfo(const meshtastic_MeshPacke } if (!hasCachedUser) { - // If the PSRAM cache exists but misses, we intentionally do not fall back - // to the node-wide table. This keeps the PSRAM direct-reply path separate - // from NodeInfoModule/NodeDB behavior when PSRAM is available. + // If the NodeInfo cache exists but misses, we intentionally do not fall back + // to the node-wide table. This keeps the cache-backed direct-reply path separate + // from NodeInfoModule/NodeDB behavior when the cache is available. if (nodeInfoPayload) { - TM_LOG_DEBUG("NodeInfo PSRAM cache miss for node=0x%08x", p->to); + TM_LOG_DEBUG("NodeInfo cache miss for node=0x%08x", p->to); return false; } - // Fallback only when PSRAM cache is unavailable on this target. + // Fallback only when the NodeInfo cache is unavailable on this target. // In this mode we use the node-wide table maintained by NodeInfoModule. const meshtastic_NodeInfoLite *node = nodeDB->getMeshNode(p->to); if (!nodeInfoLiteHasUser(node)) @@ -1501,7 +1502,7 @@ bool TrafficManagementModule::shouldRespondToNodeInfo(const meshtastic_MeshPacke cachedUser = TypeConversions::ConvertToUser(node); // T3: the fallback path has no per-node cache entry, so it throttles against the // module-global stamp. Load it here so the shared throttle check below covers this - // path too (previously only the PSRAM path was throttled). + // path too (previously only the cache path was throttled). usedFallback = true; { concurrency::LockGuard guard(&cacheLock); @@ -1519,10 +1520,10 @@ bool TrafficManagementModule::shouldRespondToNodeInfo(const meshtastic_MeshPacke } #if TMM_NODEINFO_REPLAY_SIGNED_GATE - // Replay provenance gate (PSRAM path): only spoof a reply for a signer-proven cached key. + // Replay provenance gate (cache path): only spoof a reply for a signer-proven cached key. // usedFallback entries were already gated above. See TMM_NODEINFO_REPLAY_REQUIRE_SIGNED. if (!usedFallback && !cachedKeySignerProven) { - TM_LOG_DEBUG("NodeInfo PSRAM entry for 0x%08x not signer-proven, not responding", p->to); + TM_LOG_DEBUG("NodeInfo cache entry for 0x%08x not signer-proven, not responding", p->to); return false; } #endif @@ -1558,7 +1559,7 @@ bool TrafficManagementModule::shouldRespondToNodeInfo(const meshtastic_MeshPacke reply->decoded.want_response = false; // Start from cached bitfield metadata when available. This lets direct - // responses preserve more of the original packet semantics (PSRAM path), + // responses preserve more of the original packet semantics (cache path), // while still enforcing local policy for OK_TO_MQTT below. if (cachedHasDecodedBitfield) reply->decoded.bitfield = cachedDecodedBitfield; diff --git a/src/modules/TrafficManagementModule.h b/src/modules/TrafficManagementModule.h index 0d8d5697dd4..199e4f1ef1b 100644 --- a/src/modules/TrafficManagementModule.h +++ b/src/modules/TrafficManagementModule.h @@ -196,8 +196,9 @@ class TrafficManagementModule : public MeshModule, private concurrency::OSThread /// on a full cache evicts the stalest entry, preferring ones without a next_hop hint. static constexpr uint16_t cacheSize() { return TRAFFIC_MANAGEMENT_CACHE_SIZE; } - // NodeInfo cache (PSRAM path): flat payload array, linear scan, trust/membership-tiered - // LRU eviction on insert. NodeInfo traffic is low-rate, so full scans are fine. + // NodeInfo cache (PSRAM-backed on hardware, heap in native tests): flat payload array, + // linear scan, trust/membership-tiered LRU eviction on insert. NodeInfo traffic is + // low-rate, so full scans are fine. static constexpr uint16_t kNodeInfoCacheEntries = 2000; /// NodeInfo cache capacity. static constexpr uint16_t nodeInfoTargetEntries() { return kNodeInfoCacheEntries; } @@ -269,7 +270,7 @@ class TrafficManagementModule : public MeshModule, private concurrency::OSThread uint8_t decodedBitfield; // 1-bit flags, packed into one byte (6 spare bits; add future booleans here rather - // than new bytes - the array is 2000 entries in PSRAM). + // than new bytes - the array is 2000 entries). // The source packet carried a decoded bitfield (so decodedBitfield is meaningful). uint8_t hasDecodedBitfield : 1; @@ -300,12 +301,12 @@ class TrafficManagementModule : public MeshModule, private concurrency::OSThread // No exact-size static_assert: sizeof(meshtastic_User) and its padding vary by platform, so // any fixed byte count would fail the build on some boards. - NodeInfoPayloadEntry *nodeInfoPayload = nullptr; // NodeInfo payloads in PSRAM (flat array, linear scan) + NodeInfoPayloadEntry *nodeInfoPayload = nullptr; // NodeInfo payloads (flat array; PSRAM on hardware, heap in tests) bool nodeInfoPayloadFromPsram = false; // Tracks allocator for correct deallocation // Fallback-path response throttle stamp (module-global; the cache path throttles per entry // via respTick). Bounds spoofed replies to one per kNodeInfoResponseThrottleMs across all - // targets on non-PSRAM boards. 0 = never responded. Guarded by cacheLock. Local uptime (ms). + // targets without the NodeInfo cache. 0 = never responded. Guarded by cacheLock. Local uptime (ms). uint32_t nodeInfoFallbackLastResponseMs = 0; meshtastic_TrafficManagementStats stats; diff --git a/test/test_traffic_management/test_main.cpp b/test/test_traffic_management/test_main.cpp index 618bcb50548..2b206af8164 100644 --- a/test/test_traffic_management/test_main.cpp +++ b/test/test_traffic_management/test_main.cpp @@ -793,9 +793,9 @@ static void test_tm_nodeinfo_directResponse_psramCacheRespondsAndPreservesBitfie } /** - * Verify PSRAM cache misses do not fall back to NodeDB. - * Important so the dedicated PSRAM index stays logically separate from - * NodeInfoModule/NodeDB when PSRAM is available. + * Verify NodeInfo cache misses do not fall back to NodeDB. + * Important so the dedicated cache index stays logically separate from + * NodeInfoModule/NodeDB when the cache is available. */ static void test_tm_nodeinfo_directResponse_psramMissDoesNotFallbackToNodeDb(void) { @@ -825,7 +825,7 @@ static void test_tm_nodeinfo_directResponse_psramMissDoesNotFallbackToNodeDb(voi } /** - * Verify a PSRAM-cached NodeInfo is NOT served once it ages past the serve window. + * Verify a cached NodeInfo is NOT served once it ages past the serve window. * Important: without this gate a long-gone (or forged) node's cached NodeInfo would be * spoofed back to requestors indefinitely while the genuine request is suppressed. */ @@ -843,7 +843,7 @@ static void test_tm_nodeinfo_directResponse_psramStaleEntryNotServed(void) TrafficManagementModuleTestShim module; - // Learn a NodeInfo for the target into the PSRAM cache (broadcast, so it is only cached). + // Learn a NodeInfo for the target into the NodeInfo cache (broadcast, so it is only cached). meshtastic_MeshPacket observed = makeNodeInfoPacket(kTargetNode, "target-long", "tg"); module.handleReceived(observed); // Signer-proven so staleness is the sole reason it is not served (isolates the gate under test). @@ -1439,7 +1439,7 @@ static void test_tm_nodeinfo_copyUser_returnsCachedIdentity(void) #if TMM_NODEINFO_REPLAY_SIGNED_GATE /** - * Replay gate (PSRAM path): a fresh but trust-on-first-use (never signer-proven) cached entry + * Replay gate (cache path): a fresh but trust-on-first-use (never signer-proven) cached entry * is withheld - the reply is suppressed though the entry is fresh. */ static void test_tm_nodeinfo_directResponse_psramUnsignedNotServed(void) @@ -2059,7 +2059,7 @@ static void test_tm_nodeinfo_directResponse_fallbackFreshEntryServed(void) } /** - * T3: verify the NodeDB-fallback path (non-PSRAM) is throttled too. A burst of requests + * T3: verify the NodeDB-fallback path (no NodeInfo cache) is throttled too. A burst of requests * for a fresh node must yield exactly one spoofed reply per throttle window - previously * this path had no per-node slot to stamp and so emitted a reply for every request. */ From 69c12c3a690365ad3b88cfaf17902b94417bfa54 Mon Sep 17 00:00:00 2001 From: nomdetom Date: Sat, 18 Jul 2026 04:51:08 +0100 Subject: [PATCH 41/44] more comment fixes --- src/mesh/NodeDB.h | 24 ++++++------------------ src/mesh/WarmNodeStore.h | 10 ++++------ 2 files changed, 10 insertions(+), 24 deletions(-) diff --git a/src/mesh/NodeDB.h b/src/mesh/NodeDB.h index 2e3c5972715..162ae3a4270 100644 --- a/src/mesh/NodeDB.h +++ b/src/mesh/NodeDB.h @@ -360,28 +360,16 @@ class NodeDB /// tier. Returns false if we don't know a key for n. bool copyPublicKey(NodeNum n, meshtastic_NodeInfoLite_public_key_t &out); - /// Copy the 32-byte public key for node n from the AUTHORITATIVE NodeDB tiers only - /// (hot store, then warm tier) - never from opportunistic caches. This is the pin - /// reference for caches that mirror NodeDB's key hygiene (e.g. TrafficManagement's - /// NodeInfo cache): pinning against copyPublicKey() would compare such a cache - /// against its own contents. Matches the coverage of updateUser()'s own pin, which - /// sees warm keys because getOrCreateMeshNode() rehydrates them before the check. - /// Returns false if neither tier knows a key for n. + /// Copy the 32-byte key for n from the AUTHORITATIVE tiers only (hot, then warm; never + /// opportunistic caches) - the pin reference for caches that mirror NodeDB's key hygiene. bool copyPublicKeyAuthoritative(NodeNum n, meshtastic_NodeInfoLite_public_key_t &out); - // True if node `n` is a known XEdDSA signer for exactly the 32-byte key `key32`, per either - // NodeDB tier: the hot store's signed bitfield or the warm tier's cached signer bit. The - // key must match so a rotated/mismatched key never inherits a stale signer verdict. Lets - // an opportunistic cache (e.g. TrafficManagement's NodeInfo cache) mark a re-found node's - // key signer-proven from what NodeDB already knows, without re-verifying a signature. + /// True if n is a known XEdDSA signer for exactly `key32` (hot signed bitfield or warm + /// signer bit); the key match stops a rotated key inheriting a stale signer verdict. bool isVerifiedSignerForKey(NodeNum n, const uint8_t *key32); - /// True when we have ever verified an XEdDSA signature from node `n`, per either NodeDB - /// tier: the hot store's signed bitfield, or the signer bit the warm tier cached at - /// eviction. Key-agnostic - answers "should this node's signable traffic arrive signed", - /// not "is this particular key proven" (that is isVerifiedSignerForKey). Gates that only - /// consult the hot store would let a warm-evicted signer be impersonated with unsigned - /// frames until it happens to be re-heard. + /// Key-agnostic "should n's signable traffic arrive signed", per hot bitfield or warm signer + /// bit - hot-only gates would let a warm-evicted signer be impersonated with unsigned frames. bool isKnownXeddsaSigner(NodeNum n); /// Provenance of a bare-key commit that deliberately bypasses updateUser()'s diff --git a/src/mesh/WarmNodeStore.h b/src/mesh/WarmNodeStore.h index ffcc08e3142..bdaa6911591 100644 --- a/src/mesh/WarmNodeStore.h +++ b/src/mesh/WarmNodeStore.h @@ -119,9 +119,8 @@ class WarmNodeStore /// @return false if the node is not in the warm tier. bool lookupMeta(NodeNum num, uint8_t &role, uint8_t &protectedCat) const; - /// @return true if the warm tier holds this node AND its cached signer bit is set - /// (we verified an XEdDSA signature from it before it was evicted). False if absent - /// or not a known signer. + /// True if the warm tier holds this node with its signer bit set (an XEdDSA signature + /// was verified from it before eviction). bool isVerifiedSigner(NodeNum num) const; /// Find and remove an entry (used when the node is re-admitted to the hot store). @@ -136,9 +135,8 @@ class WarmNodeStore size_t count() const; size_t capacity() const { return entries ? WARM_NODE_COUNT : 0; } - /// Slot-indexed read access for consumers that reconcile against the whole warm tier - /// (e.g. TrafficManagement's NodeInfo cache seeding). @return the entry in slot i, or - /// nullptr when the slot is empty or i >= capacity(). Iterate i in [0, capacity()). + /// Slot-indexed read for whole-tier reconciliation: the entry in slot i, or nullptr + /// when the slot is empty or i >= capacity(). const WarmNodeEntry *entryAt(size_t i) const { if (!entries || i >= WARM_NODE_COUNT || entries[i].num == 0) From 84ed7056fb937a7e5827f238ce8b1678992e7cf9 Mon Sep 17 00:00:00 2001 From: nomdetom Date: Sat, 18 Jul 2026 05:46:53 +0100 Subject: [PATCH 42/44] nitpicks --- docs/node_info_stores.md | 10 ++++--- src/mesh/NodeDB.cpp | 5 ++++ src/modules/TrafficManagementModule.cpp | 9 +++++++ test/test_traffic_management/test_main.cpp | 31 ++++++++++++++++++++++ 4 files changed, 51 insertions(+), 4 deletions(-) diff --git a/docs/node_info_stores.md b/docs/node_info_stores.md index 67df6801e4d..5c560b22ba6 100644 --- a/docs/node_info_stores.md +++ b/docs/node_info_stores.md @@ -145,10 +145,12 @@ explicit `KeyCommitTrust` provenance (`ManuallyVerified` maps to `proven=true` i cache). Never assign `info->public_key` directly - the cache would silently diverge until the next reconcile. -**Enable gate:** the write-through hooks, like the sweep and the packet path, no-op while -`moduleConfig.has_traffic_management` is off, so cache content and cache maintenance are -keyed to the same condition. Corollary: the pubkey-pool superset property only holds while -the module is enabled. +**Enable gate:** the write-through hooks, the sweep, the packet path, **and the +`copyPublicKey()`/`copyUser()` accessors** all no-op while `moduleConfig.has_traffic_management` +is off, so cache content, maintenance, and reads are keyed to the same condition. This enforces +(not just documents) the corollary that the pubkey-pool superset property holds only while the +module is enabled: a disabled module's frozen cache never feeds PKI resolution or name +rehydration. ### Tick clocks and wrap safety diff --git a/src/mesh/NodeDB.cpp b/src/mesh/NodeDB.cpp index 92c6d9cccef..1624b9cf980 100644 --- a/src/mesh/NodeDB.cpp +++ b/src/mesh/NodeDB.cpp @@ -3797,6 +3797,11 @@ void NodeDB::commitRemoteKey(NodeNum n, const uint8_t key32[32], KeyCommitTrust meshtastic_NodeInfoLite *info = getOrCreateMeshNode(n); if (!info) return; + // Unconditional overwrite - deliberately NOT updateUser()'s "don't replace a known key" pin. + // That pin protects against unauthenticated NodeInfo broadcasts; the only callers here are + // possession/authority-proven (ManuallyVerified = user confirmed the key; AdminChannelProven = + // decrypted via the admin key with p->from bound into the AEAD nonce), i.e. exactly the paths + // meant to establish or rotate a key. Keep new call sites to that same trust bar. memcpy(info->public_key.bytes, key, 32); info->public_key.size = 32; diff --git a/src/modules/TrafficManagementModule.cpp b/src/modules/TrafficManagementModule.cpp index 7642eae9731..76164055e70 100644 --- a/src/modules/TrafficManagementModule.cpp +++ b/src/modules/TrafficManagementModule.cpp @@ -734,6 +734,11 @@ void TrafficManagementModule::onNodeKeyCommitted(NodeNum node, const uint8_t key bool TrafficManagementModule::copyPublicKey(NodeNum node, uint8_t out[32], bool *signerProven) const { + // Same enable gate as the write-through hooks and maintenance: a disabled module stops + // updating and sweeping the cache, so its frozen contents must not keep feeding PKI key + // resolution either. Enforces the "superset only while enabled" corollary (node_info_stores.md). + if (!moduleConfig.has_traffic_management) + return false; if (!nodeInfoPayload || node == 0 || !out) return false; @@ -750,6 +755,10 @@ bool TrafficManagementModule::copyPublicKey(NodeNum node, uint8_t out[32], bool bool TrafficManagementModule::copyUser(NodeNum node, meshtastic_User &out, bool *signerProven) const { + // Enable gate, as in copyPublicKey(): a disabled module must not feed name rehydration + // from frozen cache contents once its maintenance/write-through have stopped. + if (!moduleConfig.has_traffic_management) + return false; if (!nodeInfoPayload || node == 0) return false; diff --git a/test/test_traffic_management/test_main.cpp b/test/test_traffic_management/test_main.cpp index 2b206af8164..fae4c77cfe6 100644 --- a/test/test_traffic_management/test_main.cpp +++ b/test/test_traffic_management/test_main.cpp @@ -1757,6 +1757,36 @@ static void test_tm_nodeinfo_identityHook_keySemantics(void) TEST_ASSERT_FALSE(proven); } +/** + * Read gate: copyPublicKey()/copyUser() share the module-enabled gate with the writers, so a + * cache populated while enabled stops feeding PKI resolution and name rehydration the moment + * the module is disabled - and resumes when re-enabled (the entry itself is never freed). + */ +static void test_tm_nodeinfo_reads_gatedWhileModuleDisabled(void) +{ + mockNodeDB->clearCachedNode(); + TrafficManagementModuleTestShim module; + + // Populate while enabled (setUp left has_traffic_management = true). + module.onNodeIdentityCommitted(kTargetNode, makeCommittedUser("present", 0x7C), false); + uint8_t key[32] = {0}; + TEST_ASSERT_TRUE(module.copyPublicKey(kTargetNode, key, nullptr)); + meshtastic_User out = meshtastic_User_init_zero; + TEST_ASSERT_TRUE(module.copyUser(kTargetNode, out, nullptr)); + + // Disabled: the frozen entry persists but the accessors refuse to serve it. + moduleConfig.has_traffic_management = false; + TEST_ASSERT_FALSE(module.copyPublicKey(kTargetNode, key, nullptr)); + TEST_ASSERT_FALSE(module.copyUser(kTargetNode, out, nullptr)); + + // Re-enabled: same entry served again (nothing was purged). + moduleConfig.has_traffic_management = true; + TEST_ASSERT_TRUE(module.copyPublicKey(kTargetNode, key, nullptr)); + TEST_ASSERT_EQUAL_UINT8(0x7C, key[0]); + TEST_ASSERT_TRUE(module.copyUser(kTargetNode, out, nullptr)); + TEST_ASSERT_EQUAL_STRING("present", out.long_name); +} + #if !(MESHTASTIC_EXCLUDE_PKI) // Fill `count` NodeInfo cache slots with keyless observed strangers (eviction tier 0), // numbered from `baseNode`. @@ -3172,6 +3202,7 @@ TM_TEST_ENTRY void setup() RUN_TEST(test_tm_nodeinfo_sweepClearsResponseThrottleFlag); RUN_TEST(test_tm_nodeinfo_hooks_noopWhileModuleDisabled); RUN_TEST(test_tm_nodeinfo_identityHook_keySemantics); + RUN_TEST(test_tm_nodeinfo_reads_gatedWhileModuleDisabled); #if !(MESHTASTIC_EXCLUDE_PKI) RUN_TEST(test_tm_nodeinfo_eviction_keyedTiersOutrankKeyless); RUN_TEST(test_tm_nodeinfo_eviction_tofuLosesBeforeProvenAndMember); From fcb5b1f25206f52aa8e92ee238e72594e4e4f4e9 Mon Sep 17 00:00:00 2001 From: nomdetom Date: Sat, 18 Jul 2026 15:24:07 +0100 Subject: [PATCH 43/44] test: make TMM fixture cleanup abort-safe in tearDown() Several tests reset per-test global state (trafficManagementModule pointing at a stack `module`, owner.public_key, the RTC fake clock) only after their assertions. A TEST_ASSERT_* failure longjmps out and skips that trailing cleanup, leaving the state to dangle into later cases - concretely, the next setUp()'s resetNodes() would call trafficManagementModule->purgeAll() on a destroyed object. Move the resets into tearDown(), which runs unconditionally between tests, and drop the now-redundant per-test cleanups. Addresses a CodeRabbit review comment on PR #11050. clod helped too --- test/test_traffic_management/test_main.cpp | 29 ++++++++++++++-------- 1 file changed, 18 insertions(+), 11 deletions(-) diff --git a/test/test_traffic_management/test_main.cpp b/test/test_traffic_management/test_main.cpp index fae4c77cfe6..2098198697d 100644 --- a/test/test_traffic_management/test_main.cpp +++ b/test/test_traffic_management/test_main.cpp @@ -1243,9 +1243,7 @@ static void test_tm_nodeinfo_updateUserHook_writesThrough(void) request.hop_limit = 3; TEST_ASSERT_EQUAL_INT(static_cast(ProcessMessage::CONTINUE), static_cast(module.handleReceived(request))); TEST_ASSERT_EQUAL_UINT32(0, static_cast(mockRouter.sentPackets.size())); - - trafficManagementModule = nullptr; - mockNodeDB->rollHotStore(); // updateUser admitted the node to the hot store + // trafficManagementModule and the hot store are reset in tearDown()/setUp(). } /** @@ -1284,8 +1282,7 @@ static void test_tm_nodeinfo_removeNode_purgesCaches(void) TEST_ASSERT_FALSE(module.copyUser(kTargetNode, out, nullptr)); TEST_ASSERT_EQUAL_UINT8(0, module.getNextHopHint(kTargetNode)); TEST_ASSERT_EQUAL_INT(-1, module.peekNodeInfoFlagsForTest(kTargetNode)); - - trafficManagementModule = nullptr; + // trafficManagementModule is reset in tearDown(). } /** @@ -1980,8 +1977,7 @@ static void test_tm_nodeinfo_resetNodes_purgesAllCaches(void) TEST_ASSERT_FALSE(module.copyUser(kTargetNode, out, nullptr)); TEST_ASSERT_EQUAL_INT(-1, module.peekNodeInfoFlagsForTest(kTargetNode)); TEST_ASSERT_EQUAL_UINT8(0, module.getNextHopHint(kTargetNode)); - - trafficManagementModule = nullptr; + // trafficManagementModule is reset in tearDown(). } /** @@ -2001,9 +1997,7 @@ static void test_tm_nodeinfo_cache_dropsFrameCarryingOwnerKey(void) TEST_ASSERT_EQUAL_INT(-1, module.peekNodeInfoFlagsForTest(kTargetNode)); uint8_t key[32] = {0}; TEST_ASSERT_FALSE(module.copyPublicKey(kTargetNode, key, nullptr)); - - owner.public_key.size = 0; // restore for later tests - memset(owner.public_key.bytes, 0, sizeof(owner.public_key.bytes)); + // owner.public_key is reset in tearDown() (guaranteed even if an assertion above aborts). } #endif // !MESHTASTIC_EXCLUDE_PKI #endif @@ -3139,7 +3133,20 @@ void setUp(void) { resetTrafficConfig(); } -void tearDown(void) {} +void tearDown(void) +{ + // Runs even when a TEST_ASSERT_* aborts a test mid-way (Unity longjmps out, skipping any + // cleanup the test itself does after the assertion), so per-test global state can never + // dangle into later cases. The dangling-pointer path is concrete: a test points the global + // at its stack `module`, and the next setUp()'s resetNodes() would then call + // trafficManagementModule->purgeAll() on a destroyed object. + trafficManagementModule = nullptr; + owner.public_key.size = 0; + memset(owner.public_key.bytes, 0, sizeof(owner.public_key.bytes)); + // Neutralize the RTC fake clock a fallback test may have set (the virtual s_testNowMs is + // already reset by setUp via resetTrafficConfig). + setBootRelativeTimeForUnitTest(0); +} TM_TEST_ENTRY void setup() { From 6b8fb0f4d35e477b89893fb7f18ca5b55574e6f4 Mon Sep 17 00:00:00 2001 From: nomdetom Date: Sat, 18 Jul 2026 15:24:20 +0100 Subject: [PATCH 44/44] test: correct native-suite-count to 37 The canonical suite count drifted from the actual test/test_*/ directory count upstream on develop: #10669 added two suites but bumped the count by one, and #11037 added test_xmodem without bumping it at all - leaving native-suite-count at 35 against 37 real suites, which run-tests.sh reports as AMBER. Correct it to 37. --- test/native-suite-count | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/native-suite-count b/test/native-suite-count index 8f92bfdd497..81b5c5d06cc 100644 --- a/test/native-suite-count +++ b/test/native-suite-count @@ -1 +1 @@ -35 +37