diff --git a/docs/node_info_stores.md b/docs/node_info_stores.md new file mode 100644 index 00000000000..5c560b22ba6 --- /dev/null +++ b/docs/node_info_stores.md @@ -0,0 +1,232 @@ +# 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: + +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; 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 +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`. + +--- + +## 1. NodeDB hot store (authoritative) + +- **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 + 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 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. +- **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 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. + +### 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 | 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, 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 + +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. + +--- + +## 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 +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 (extended) 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 4c2879adc21..1624b9cf980 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 @@ -767,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(); @@ -1597,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(); @@ -1629,6 +1643,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(); @@ -3390,6 +3410,20 @@ bool NodeDB::updateUser(uint32_t nodeId, meshtastic_User &p, uint8_t channelInde } } +#if HAS_TRAFFIC_MANAGEMENT + // 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; } @@ -3688,7 +3722,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 +3738,81 @@ bool NodeDB::copyPublicKey(NodeNum n, meshtastic_NodeInfoLite_public_key_t &out) 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 + // 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; +} + +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; +} + +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 +} + +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; + // 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; + +#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); @@ -3801,6 +3910,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 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) { + 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/mesh/NodeDB.h b/src/mesh/NodeDB.h index f7494811f68..162ae3a4270 100644 --- a/src/mesh/NodeDB.h +++ b/src/mesh/NodeDB.h @@ -360,6 +360,33 @@ 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 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 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); + + /// 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 + /// 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 9b1cc3ff28d..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" @@ -488,8 +487,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 @@ -524,14 +524,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; @@ -567,10 +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; + // 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/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..bdaa6911591 100644 --- a/src/mesh/WarmNodeStore.h +++ b/src/mesh/WarmNodeStore.h @@ -119,6 +119,10 @@ class WarmNodeStore /// @return false if the node is not in the warm tier. bool lookupMeta(NodeNum num, uint8_t &role, uint8_t &protectedCat) const; + /// 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). bool take(NodeNum num, WarmNodeEntry &out); @@ -131,6 +135,15 @@ class WarmNodeStore size_t count() const; size_t capacity() const { return entries ? WARM_NODE_COUNT : 0; } + /// 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) + 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/KeyVerificationModule.cpp b/src/modules/KeyVerificationModule.cpp index c275ce1d9de..f7ffeb540bd 100644 --- a/src/modules/KeyVerificationModule.cpp +++ b/src/modules/KeyVerificationModule.cpp @@ -383,6 +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; + // 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); diff --git a/src/modules/TrafficManagementModule.cpp b/src/modules/TrafficManagementModule.cpp index 74e8de6c24a..76164055e70 100644 --- a/src/modules/TrafficManagementModule.cpp +++ b/src/modules/TrafficManagementModule.cpp @@ -32,39 +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) -/** - * Convert seconds to milliseconds with overflow protection. - */ +// 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) + +// 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. 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) @@ -78,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) { @@ -132,6 +128,7 @@ TrafficManagementModule *trafficManagementModule; // Constructor // ============================================================================= +/// Allocate the unified cache (and, where available, the NodeInfo cache) and start the sweep. TrafficManagementModule::TrafficManagementModule() : MeshModule("TrafficManagement"), concurrency::OSThread("TrafficManagement") { // Module configuration @@ -167,11 +164,13 @@ 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)", - static_cast(nodeInfoTargetEntries()), +#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; @@ -179,22 +178,25 @@ 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); } -// 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 @@ -247,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 @@ -279,25 +279,57 @@ int TrafficManagementModule::peekCachedRole(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. +// 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) +{ +#if TRAFFIC_MANAGEMENT_CACHE_SIZE > 0 || TMM_HAS_NODEINFO_CACHE + if (node == 0) + return; + concurrency::LockGuard guard(&cacheLock); + bool purged = false; +#if TRAFFIC_MANAGEMENT_CACHE_SIZE > 0 + UnifiedCacheEntry *entry = findEntry(node); + if (entry) { + memset(entry, 0, sizeof(UnifiedCacheEntry)); + purged = true; + } +#endif + // 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; + } + // 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 +} + +void TrafficManagementModule::purgeAll() +{ +#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 + // nodeInfoPayload stays nullptr on builds without the NodeInfo cache; no guard needed. + if (nodeInfoPayload) + memset(nodeInfoPayload, 0, static_cast(nodeInfoTargetEntries()) * sizeof(NodeInfoPayloadEntry)); + TM_LOG_INFO("Purged all traffic caches"); +#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(). meshtastic_Config_DeviceConfig_Role TrafficManagementModule::resolveSenderRole(NodeNum from, UnifiedCacheEntry *entry, bool isNew) { if (!entry) @@ -313,12 +345,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 @@ -337,6 +366,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 @@ -356,37 +388,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; } @@ -405,9 +435,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 defined(ARCH_ESP32) && defined(BOARD_HAS_PSRAM) if (!nodeInfoPayload || node == 0) return nullptr; @@ -416,68 +453,65 @@ const TrafficManagementModule::NodeInfoPayloadEntry *TrafficManagementModule::fi return &nodeInfoPayload[i]; } return nullptr; -#else - (void)node; - return nullptr; -#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 LRU - * victim by lastObservedMs (wrap-safe age). NodeInfo traffic is low-rate, - * so the O(n) scan is negligible. - */ -TrafficManagementModule::NodeInfoPayloadEntry *TrafficManagementModule::findOrCreateNodeInfoEntry(NodeNum node, - bool *usedEmptySlot) +/// 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) { if (usedEmptySlot) *usedEmptySlot = false; -#if defined(ARCH_ESP32) && defined(BOARD_HAS_PSRAM) if (!nodeInfoPayload || node == 0) return nullptr; NodeInfoPayloadEntry *empty = nullptr; - NodeInfoPayloadEntry *lru = nullptr; - uint32_t lruAge = 0; - const uint32_t now = clockMs(); + 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 - const uint32_t age = now - e.lastObservedMs; // unsigned subtraction is wrap-safe - if (!lru || age > lruAge) { - lru = &e; - lruAge = age; + continue; // an empty slot beats any victim; stop scoring + // Eviction tier (lower loses first): 0 keyless, 1 TOFU key, 2 signer-proven key; + // +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 == victim && victim->isMember) + return nullptr; // caller would rather skip than churn one member out for another memset(slot, 0, sizeof(NodeInfoPayloadEntry)); slot->node = node; if (usedEmptySlot) *usedEmptySlot = (slot == empty); return slot; -#else - (void)node; - return nullptr; -#endif } uint16_t TrafficManagementModule::countNodeInfoEntriesLocked() const { -#if defined(ARCH_ESP32) && defined(BOARD_HAS_PSRAM) if (!nodeInfoPayload) return 0; @@ -487,14 +521,271 @@ uint16_t TrafficManagementModule::countNodeInfoEntriesLocked() const count++; } return count; -#else - return 0; +} + +void TrafficManagementModule::reconcileNodeInfoFromNodeDBLocked() +{ + if (!nodeInfoPayload || !nodeDB) + return; + + const NodeNum self = nodeDB->getNodeNum(); + uint16_t seeded = 0; + + // 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 *entry = findNodeInfoEntryMutable(node); + if (!entry) { + bool usedEmptySlot = false; + 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 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)) { + 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(entry->user.public_key.bytes, key32, 32); + entry->user.public_key.size = 32; + } + if (keyChanged) + 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 *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 *warm = nodeDB->warmStore.entryAt(i); + if (!warm) + continue; + const bool hasKey = !memfll(warm->public_key, 0, sizeof(warm->public_key)); + reconcileOne(warm->num, hasKey ? warm->public_key : nullptr, warmSignerOf(*warm), nullptr); + } #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())); +} + +void TrafficManagementModule::maintainNodeInfoCacheLocked() +{ + 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; + // 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)); + + // 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; + } + } +} + +void TrafficManagementModule::onNodeIdentityCommitted(NodeNum node, const meshtastic_User &user, bool signerKnown) +{ + // 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); + if (!nodeInfoPayload) + return; + bool usedEmptySlot = false; + 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 && 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. +} + +void TrafficManagementModule::onNodeKeyCommitted(NodeNum node, const uint8_t key32[32], bool proven) +{ + // 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); + 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. +} + +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; + + 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; +} + +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; + + concurrency::LockGuard guard(&cacheLock); + const NodeInfoPayloadEntry *entry = findNodeInfoEntry(node); + // 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; + if (signerProven) + *signerProven = entry->keySignerProven; + return true; } +#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) +{ + return sizeA == 32 && sizeB == 32 && memcmp(keyA, keyB, 32) == 0; +} +#endif + void TrafficManagementModule::cacheNodeInfoPacket(const meshtastic_MeshPacket &mp) { -#if defined(ARCH_ESP32) && defined(BOARD_HAS_PSRAM) if (!nodeInfoPayload || mp.decoded.payload.size == 0) return; @@ -502,50 +793,165 @@ 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); + + // 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) + // 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 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 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 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; - // 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. +#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 && + !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 + + // 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->lastObservedMs = clockMs(); - entry->lastObservedRxTime = mp.rx_time; + 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->sourceChannel = mp.channel; entry->hasDecodedBitfield = mp.decoded.has_bitfield; entry->decodedBitfield = mp.decoded.bitfield; + // 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; + if (usedEmptySlot) cachedCount = countNodeInfoEntriesLocked(); } 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())); } -#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 // ============================================================================= -// -// 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) { @@ -640,60 +1046,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) @@ -726,14 +1106,13 @@ 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 + // 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); @@ -743,20 +1122,20 @@ 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)) { + // 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"); @@ -818,24 +1197,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; @@ -870,21 +1242,23 @@ 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. - // 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; +#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 - // 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) +#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( moduleConfig.traffic_management.position_min_interval_secs, default_traffic_mgmt_position_min_interval_secs)); const uint8_t posTtlTicks = @@ -906,9 +1280,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; @@ -945,12 +1316,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; @@ -967,15 +1335,10 @@ int32_t TrafficManagementModule::runOnce() static_cast(activeEntries), static_cast(cacheSize()), static_cast(TrafficManagementModule::clockMs() - sweepStartMs)); -#if defined(ARCH_ESP32) && defined(BOARD_HAS_PSRAM) - if (nodeInfoPayload) { - TM_LOG_DEBUG("NodeInfo PSRAM cache: %u/%u", static_cast(countNodeInfoEntriesLocked()), - static_cast(nodeInfoTargetEntries())); - } -#endif - #endif // TRAFFIC_MANAGEMENT_CACHE_SIZE > 0 + maintainNodeInfoCacheLocked(); // no-op stub on builds without the NodeInfo cache + return kMaintenanceIntervalMs; } @@ -1004,10 +1367,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; @@ -1016,11 +1377,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 @@ -1073,13 +1431,30 @@ 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; uint8_t cachedSourceChannel = 0; - uint32_t cachedLastObservedMs = 0; - uint32_t cachedLastObservedRxTime = 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; + // 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 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 { concurrency::LockGuard guard(&cacheLock); @@ -1090,31 +1465,97 @@ bool TrafficManagementModule::shouldRespondToNodeInfo(const meshtastic_MeshPacke cachedHasDecodedBitfield = entry->hasDecodedBitfield; cachedDecodedBitfield = entry->decodedBitfield; cachedSourceChannel = entry->sourceChannel; - cachedLastObservedMs = entry->lastObservedMs; - cachedLastObservedRxTime = entry->lastObservedRxTime; + 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); +#endif } } 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)) return false; + // 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, + 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 + // path too (previously only the cache path was throttled). + usedFallback = true; + { + concurrency::LockGuard guard(&cacheLock); + cachedFallbackResponseMs = nodeInfoFallbackLastResponseMs; + } } + // 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); + return false; + } + +#if TMM_NODEINFO_REPLAY_SIGNED_GATE + // 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 cache entry for 0x%08x not signer-proven, not responding", p->to); + return false; + } +#endif + if (!sendResponse) return true; + // 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); + if (throttled) { + // 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(); if (!reply) { TM_LOG_WARN("NodeInfo direct response dropped: no packet buffer"); @@ -1127,7 +1568,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; @@ -1143,11 +1584,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(currentObsTick() - cachedObsTick)), + static_cast(cachedSourceChannel), static_cast(p->channel)); } // Spoof the sender as the target node so the requestor sees a valid NodeInfo response. @@ -1164,6 +1604,27 @@ 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: 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 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 &entry = nodeInfoPayload[cachedNodeInfoIndex]; + if (entry.node == p->to) { + entry.respTick = currentRespTick(); + entry.hasResponded = true; + } + } +#endif + return true; } @@ -1222,9 +1683,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; @@ -1268,12 +1729,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 864b542bf7d..199e4f1ef1b 100644 --- a/src/modules/TrafficManagementModule.h +++ b/src/modules/TrafficManagementModule.h @@ -8,25 +8,32 @@ #if HAS_TRAFFIC_MANAGEMENT -/** - * 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. - */ +// 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 + +// 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 + +// 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 + +/// 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/node_info_stores.md for the store overview. class TrafficManagementModule : public MeshModule, private concurrency::OSThread { public: @@ -37,87 +44,115 @@ 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(); - /** - * 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. - */ + /// 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 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::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 + /// 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(); + + /// 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 + /// 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 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 skip a full XEdDSA verification. No-op if absent. + void markKeySignerProvenForTest(NodeNum node); + + /// 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: NodeInfo flag bits for `node` (-1 if absent): bit0 hasObserved, + /// 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: - // ========================================================================= - // 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/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 @@ -130,80 +165,76 @@ 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`, LRU eviction by lastObservedMs. - // NodeInfo traffic is low-rate, so a full scan per lookup/insert is 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; } - // ========================================================================= - // 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); } - // ========================================================================= - // 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. - // + + // 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"); + + /// 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); // ========================================================================= @@ -215,42 +246,73 @@ 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. + // 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; - // Last local uptime tick (millis) when this entry was refreshed. - uint32_t lastObservedMs; - - // Last RTC/packet timestamp (seconds) observed for this NodeInfo frame. - // If unavailable in packet, remains 0. - uint32_t lastObservedRxTime; + // 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. - bool hasDecodedBitfield; + // Cached decoded bitfield from the source packet (non-OK_TO_MQTT bits are preserved + // in direct replies). Validity: hasDecodedBitfield. 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). + + // The source packet carried a decoded bitfield (so decodedBitfield is meaningful). + uint8_t hasDecodedBitfield : 1; + + // 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: 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: 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 (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; + + // 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 + // 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 without the NodeInfo cache. 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; @@ -258,48 +320,80 @@ class TrafficManagementModule : public MeshModule, private concurrency::OSThread // One-shot guard: warm-start next-hop cache from NodeDB on first maintenance pass. bool nextHopPreloaded = false; + // 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; + // ========================================================================= // 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; - NodeInfoPayloadEntry *findOrCreateNodeInfoEntry(NodeNum node, bool *usedEmptySlot); + /// Mutable variant of findNodeInfoEntry(). + NodeInfoPayloadEntry *findNodeInfoEntryMutable(NodeNum node) + { + return const_cast(findNodeInfoEntry(node)); + } + /// 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; + + /// 60 s NodeInfo-cache maintenance under cacheLock: saturate expired tick stamps (the + /// 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. 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); // ========================================================================= // 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 f64ea814f5b..fae4c77cfe6 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" @@ -102,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() @@ -173,9 +189,13 @@ class TrafficManagementModuleTestShim : public TrafficManagementModule { public: using TrafficManagementModule::alterReceived; + using TrafficManagementModule::dropNodeInfoCacheForTest; using TrafficManagementModule::flushCache; using TrafficManagementModule::handleReceived; + using TrafficManagementModule::markKeySignerProvenForTest; + using TrafficManagementModule::nodeInfoCacheCapacityForTest; using TrafficManagementModule::peekCachedRole; + using TrafficManagementModule::peekNodeInfoFlagsForTest; using TrafficManagementModule::runOnce; bool ignoreRequestFlag() const { return ignoreRequest; } @@ -345,6 +365,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)); } /** @@ -427,113 +455,1508 @@ static void test_tm_rateLimit_dropsOnlyAfterThreshold(void) TrafficManagementModuleTestShim module; meshtastic_MeshPacket packet = makeDecodedPacket(meshtastic_PortNum_TELEMETRY_APP, kRemoteNode); - ProcessMessage r1 = module.handleReceived(packet); - ProcessMessage r2 = module.handleReceived(packet); - ProcessMessage r3 = module.handleReceived(packet); - ProcessMessage r4 = module.handleReceived(packet); - meshtastic_TrafficManagementStats stats = module.getStats(); + ProcessMessage r1 = module.handleReceived(packet); + ProcessMessage r2 = module.handleReceived(packet); + ProcessMessage r3 = module.handleReceived(packet); + ProcessMessage r4 = module.handleReceived(packet); + meshtastic_TrafficManagementStats stats = module.getStats(); + + TEST_ASSERT_EQUAL_INT(static_cast(ProcessMessage::CONTINUE), static_cast(r1)); + TEST_ASSERT_EQUAL_INT(static_cast(ProcessMessage::CONTINUE), static_cast(r2)); + TEST_ASSERT_EQUAL_INT(static_cast(ProcessMessage::CONTINUE), static_cast(r3)); + TEST_ASSERT_EQUAL_INT(static_cast(ProcessMessage::STOP), static_cast(r4)); + TEST_ASSERT_EQUAL_UINT32(1, stats.rate_limit_drops); + TEST_ASSERT_TRUE(module.ignoreRequestFlag()); +} +/** + * Verify packets sourced from this node bypass dedup and rate limiting. + * Important so local transmissions are not accidentally self-throttled. + */ +static void test_tm_fromUs_bypassesPositionAndRateFilters(void) +{ + moduleConfig.traffic_management.position_min_interval_secs = 300; + moduleConfig.traffic_management.rate_limit_window_secs = 60; + moduleConfig.traffic_management.rate_limit_max_packets = 1; + installWellKnownPrimaryChannelWithPrecision(16); + TrafficManagementModuleTestShim module; + + meshtastic_MeshPacket positionPacket = makePositionPacket(kLocalNode, 374221234, -1220845678); + meshtastic_MeshPacket textPacket = makeDecodedPacket(meshtastic_PortNum_TEXT_MESSAGE_APP, kLocalNode); + + ProcessMessage p1 = module.handleReceived(positionPacket); + ProcessMessage p2 = module.handleReceived(positionPacket); + ProcessMessage t1 = module.handleReceived(textPacket); + ProcessMessage t2 = module.handleReceived(textPacket); + + meshtastic_TrafficManagementStats stats = module.getStats(); + TEST_ASSERT_EQUAL_INT(static_cast(ProcessMessage::CONTINUE), static_cast(p1)); + TEST_ASSERT_EQUAL_INT(static_cast(ProcessMessage::CONTINUE), static_cast(p2)); + TEST_ASSERT_EQUAL_INT(static_cast(ProcessMessage::CONTINUE), static_cast(t1)); + TEST_ASSERT_EQUAL_INT(static_cast(ProcessMessage::CONTINUE), static_cast(t2)); + TEST_ASSERT_EQUAL_UINT32(0, stats.position_dedup_drops); + TEST_ASSERT_EQUAL_UINT32(0, stats.rate_limit_drops); +} + +/** + * Verify locally addressed packets are never dropped by transit shaping. + * Important so dedup/rate limiting do not suppress end-user delivery. + */ +static void test_tm_localDestination_bypassesTransitFilters(void) +{ + moduleConfig.traffic_management.position_min_interval_secs = 300; + moduleConfig.traffic_management.rate_limit_window_secs = 60; + moduleConfig.traffic_management.rate_limit_max_packets = 1; + installWellKnownPrimaryChannelWithPrecision(16); + TrafficManagementModuleTestShim module; + + meshtastic_MeshPacket position1 = makePositionPacket(kRemoteNode, 374221234, -1220845678, kLocalNode); + meshtastic_MeshPacket position2 = makePositionPacket(kRemoteNode, 374221234, -1220845678, kLocalNode); + meshtastic_MeshPacket text1 = makeDecodedPacket(meshtastic_PortNum_TEXT_MESSAGE_APP, kRemoteNode, kLocalNode); + meshtastic_MeshPacket text2 = makeDecodedPacket(meshtastic_PortNum_TEXT_MESSAGE_APP, kRemoteNode, kLocalNode); + + ProcessMessage p1 = module.handleReceived(position1); + ProcessMessage p2 = module.handleReceived(position2); + ProcessMessage t1 = module.handleReceived(text1); + ProcessMessage t2 = module.handleReceived(text2); + meshtastic_TrafficManagementStats stats = module.getStats(); + + TEST_ASSERT_EQUAL_INT(static_cast(ProcessMessage::CONTINUE), static_cast(p1)); + TEST_ASSERT_EQUAL_INT(static_cast(ProcessMessage::CONTINUE), static_cast(p2)); + TEST_ASSERT_EQUAL_INT(static_cast(ProcessMessage::CONTINUE), static_cast(t1)); + TEST_ASSERT_EQUAL_INT(static_cast(ProcessMessage::CONTINUE), static_cast(t2)); + TEST_ASSERT_EQUAL_UINT32(0, stats.position_dedup_drops); + TEST_ASSERT_EQUAL_UINT32(0, stats.rate_limit_drops); +} + +/** + * Verify router role clamps NodeInfo response hops to router-safe maximum. + * Important so large config values cannot widen response scope unexpectedly. + */ +static void test_tm_nodeinfo_routerClamp_skipsWhenTooManyHops(void) +{ + moduleConfig.traffic_management.nodeinfo_direct_response_max_hops = 10; + config.device.role = meshtastic_Config_DeviceConfig_Role_ROUTER; + mockNodeDB->setCachedNode(kTargetNode); + + TrafficManagementModuleTestShim module; + meshtastic_MeshPacket request = makeDecodedPacket(meshtastic_PortNum_NODEINFO_APP, kRemoteNode, kTargetNode); + request.decoded.want_response = true; + request.hop_start = 5; + request.hop_limit = 1; // 4 hops away; router clamp should cap max at 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_FALSE(module.ignoreRequestFlag()); +} + +/** + * Verify NodeInfo direct-response success path and reply packet fields. + * Important because this path consumes the request and generates a spoofed cached reply. + */ +static void test_tm_nodeinfo_directResponse_respondsFromCache(void) +{ + moduleConfig.traffic_management.nodeinfo_direct_response_max_hops = 10; + 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())); + MeshService mockService; + router = &mockRouter; + 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; + request.hop_start = 3; + request.hop_limit = 3; // direct request (0 hops away) + + ProcessMessage result = module.handleReceived(request); + meshtastic_TrafficManagementStats stats = module.getStats(); + + TEST_ASSERT_EQUAL_INT(static_cast(ProcessMessage::STOP), static_cast(result)); + TEST_ASSERT_TRUE(module.ignoreRequestFlag()); + TEST_ASSERT_EQUAL_UINT32(1, stats.nodeinfo_cache_hits); + TEST_ASSERT_EQUAL_UINT32(1, static_cast(mockRouter.sentPackets.size())); + + const meshtastic_MeshPacket &reply = mockRouter.sentPackets.front(); + TEST_ASSERT_EQUAL_INT(meshtastic_PortNum_NODEINFO_APP, reply.decoded.portnum); + TEST_ASSERT_EQUAL_UINT32(kTargetNode, reply.from); + TEST_ASSERT_EQUAL_UINT32(kRemoteNode, reply.to); + TEST_ASSERT_EQUAL_UINT32(request.id, reply.decoded.request_id); + TEST_ASSERT_FALSE(reply.decoded.want_response); + TEST_ASSERT_EQUAL_UINT8(0, reply.hop_limit); + TEST_ASSERT_EQUAL_UINT8(0, reply.hop_start); + TEST_ASSERT_EQUAL_UINT8(mockNodeDB->getLastByteOfNodeNum(kRemoteNode), reply.next_hop); + TEST_ASSERT_TRUE(reply.decoded.has_bitfield); + TEST_ASSERT_EQUAL_UINT8(BITFIELD_OK_TO_MQTT_MASK, reply.decoded.bitfield); +} + +/** + * Verify cached direct replies still preserve requester NodeInfo learning. + * Important so consuming the request does not skip NodeDB refresh for observers. + */ +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())); + MeshService mockService; + router = &mockRouter; + 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; + request.id = 0x01020304; + request.hop_start = 3; + request.hop_limit = 3; + + ProcessMessage result = module.handleReceived(request); + meshtastic_NodeInfoLite *requestor = mockNodeDB->getMeshNode(kRemoteNode); + + TEST_ASSERT_EQUAL_INT(static_cast(ProcessMessage::STOP), static_cast(result)); + TEST_ASSERT_NOT_NULL(requestor); + TEST_ASSERT_TRUE((requestor->bitfield & NODEINFO_BITFIELD_HAS_USER_MASK) != 0); + TEST_ASSERT_EQUAL_STRING("requester-long", requestor->long_name); + TEST_ASSERT_EQUAL_STRING("rq", requestor->short_name); + TEST_ASSERT_EQUAL_UINT8(request.channel, requestor->channel); +} + +/** + * A unicast NodeInfo request is never signed, so a known signer's identity claim on the + * direct-response path is unauthenticated. It must not overwrite the stored name (spoofing + * defense), while the direct response itself still goes out. The non-signer case + * (test_tm_nodeinfo_directResponse_learnsRequestorNodeInfo) is the control: it still learns. + */ +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 + // 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; + mockRouter.addInterface(std::unique_ptr(new MockRadioInterface())); + MeshService mockService; + router = &mockRouter; + 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; + request.id = 0x0A0B0C0D; + request.hop_start = 3; + request.hop_limit = 3; + request.xeddsa_signed = false; // unicast: never signed + + ProcessMessage result = module.handleReceived(request); + + // The response still went out (the request was consumed from cache)... + TEST_ASSERT_EQUAL_INT(static_cast(ProcessMessage::STOP), static_cast(result)); + TEST_ASSERT_EQUAL_UINT32(1, module.getStats().nodeinfo_cache_hits); + // ...but the known signer's stored name was not overwritten by the unauthenticated claim. + const meshtastic_NodeInfoLite *requestor = mockNodeDB->getMeshNode(kRemoteNode); + TEST_ASSERT_NOT_NULL(requestor); + TEST_ASSERT_EQUAL_STRING("victim-real", requestor->long_name); +} + +/** + * Verify client role only answers direct (0-hop) NodeInfo requests. + * Important so clients do not answer relayed requests outside intended scope. + */ +static void test_tm_nodeinfo_clientClamp_skipsWhenNotDirect(void) +{ + moduleConfig.traffic_management.nodeinfo_direct_response_max_hops = 10; + config.device.role = meshtastic_Config_DeviceConfig_Role_CLIENT; + mockNodeDB->setCachedNode(kTargetNode); + + TrafficManagementModuleTestShim module; + meshtastic_MeshPacket request = makeDecodedPacket(meshtastic_PortNum_NODEINFO_APP, kRemoteNode, kTargetNode); + request.decoded.want_response = true; + request.hop_start = 2; + request.hop_limit = 1; // 1 hop away; clients are clamped to max 0 + + 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_FALSE(module.ignoreRequestFlag()); +} + +/** + * Verify the NodeDB-fallback path requires NodeDB for direct NodeInfo responses. + * Important because fallback should only happen through node-wide data when + * the dedicated NodeInfo cache does not exist. + */ +static void test_tm_nodeinfo_directResponse_withoutNodeDbEntry_skips(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.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; + 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_FALSE(module.ignoreRequestFlag()); + TEST_ASSERT_EQUAL_UINT32(0, stats.nodeinfo_cache_hits); + TEST_ASSERT_EQUAL_UINT32(0, static_cast(mockRouter.sentPackets.size())); +} + +#if TMM_HAS_NODEINFO_CACHE +/** + * Verify the NodeInfo cache can answer requests without NodeDB and that + * shouldRespondToNodeInfo() uses cached bitfield metadata. + */ +static void test_tm_nodeinfo_directResponse_psramCacheRespondsAndPreservesBitfield(void) +{ + moduleConfig.traffic_management.nodeinfo_direct_response_max_hops = 10; + config.device.role = meshtastic_Config_DeviceConfig_Role_CLIENT; + config.lora.config_ok_to_mqtt = true; + 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"); + observed.decoded.has_bitfield = true; + observed.decoded.bitfield = BITFIELD_WANT_RESPONSE_MASK; + observed.channel = 2; + observed.rx_time = 123456; + + 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; + request.id = 0x24681357; + request.channel = 1; + 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_TRUE(module.ignoreRequestFlag()); + TEST_ASSERT_EQUAL_UINT32(1, stats.nodeinfo_cache_hits); + TEST_ASSERT_EQUAL_UINT32(1, static_cast(mockRouter.sentPackets.size())); + + const meshtastic_MeshPacket &reply = mockRouter.sentPackets.front(); + TEST_ASSERT_TRUE(reply.decoded.has_bitfield); + TEST_ASSERT_EQUAL_UINT8(static_cast(BITFIELD_WANT_RESPONSE_MASK | BITFIELD_OK_TO_MQTT_MASK), reply.decoded.bitfield); + TEST_ASSERT_EQUAL_UINT32(kTargetNode, reply.from); + TEST_ASSERT_EQUAL_UINT32(kRemoteNode, reply.to); + TEST_ASSERT_EQUAL_UINT8(request.channel, reply.channel); + TEST_ASSERT_EQUAL_UINT32(request.id, reply.decoded.request_id); +} + +/** + * 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) +{ + moduleConfig.traffic_management.nodeinfo_direct_response_max_hops = 10; + config.device.role = meshtastic_Config_DeviceConfig_Role_CLIENT; + mockNodeDB->setCachedNode(kTargetNode); + + 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_FALSE(module.ignoreRequestFlag()); + TEST_ASSERT_EQUAL_UINT32(0, stats.nodeinfo_cache_hits); + TEST_ASSERT_EQUAL_UINT32(0, static_cast(mockRouter.sentPackets.size())); +} + +/** + * 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. + */ +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 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). + module.markKeySignerProvenForTest(kTargetNode); + + // Advance the virtual clock just past the 6 h serve window. + // 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; + 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); + // 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; + 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: 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::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 + 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)); + // 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; + 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]); +} + +#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(); +} + +/** + * 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), + * 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. 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())); + + 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(); +} + +/** + * 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 + * 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 +} + +/** + * 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)); + TEST_ASSERT_EQUAL_INT(-1, module.peekNodeInfoFlagsForTest(kTargetNode)); + + trafficManagementModule = nullptr; +} + +/** + * 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_noTimedEviction_quietKeyedEntrySurvives(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; + 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}; + TEST_ASSERT_TRUE(module.copyPublicKey(kTargetNode, key, nullptr)); + TEST_ASSERT_EQUAL_UINT8(0x21, key[0]); + + // ...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 + +/** + * 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)); +} + +/** + * 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 (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) +{ + 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 + +// Bit positions returned by peekNodeInfoFlagsForTest(). +constexpr int kFlagObserved = 1; +constexpr int kFlagResponded = 2; +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: 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_reconcileMembershipMarking(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. 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 +} + +/** + * 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); +} + +/** + * 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`. +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")); +} - TEST_ASSERT_EQUAL_INT(static_cast(ProcessMessage::CONTINUE), static_cast(r1)); - TEST_ASSERT_EQUAL_INT(static_cast(ProcessMessage::CONTINUE), static_cast(r2)); - TEST_ASSERT_EQUAL_INT(static_cast(ProcessMessage::CONTINUE), static_cast(r3)); - TEST_ASSERT_EQUAL_INT(static_cast(ProcessMessage::STOP), static_cast(r4)); - TEST_ASSERT_EQUAL_UINT32(1, stats.rate_limit_drops); - TEST_ASSERT_TRUE(module.ignoreRequestFlag()); +// 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)); } + /** - * Verify packets sourced from this node bypass dedup and rate limiting. - * Important so local transmissions are not accidentally self-throttled. + * 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_fromUs_bypassesPositionAndRateFilters(void) +static void test_tm_nodeinfo_eviction_keyedTiersOutrankKeyless(void) { - moduleConfig.traffic_management.position_min_interval_secs = 300; - moduleConfig.traffic_management.rate_limit_window_secs = 60; - moduleConfig.traffic_management.rate_limit_max_packets = 1; - installWellKnownPrimaryChannelWithPrecision(16); + mockNodeDB->clearCachedNode(); + mockNodeDB->rollHotStore(); TrafficManagementModuleTestShim module; - meshtastic_MeshPacket positionPacket = makePositionPacket(kLocalNode, 374221234, -1220845678); - meshtastic_MeshPacket textPacket = makeDecodedPacket(meshtastic_PortNum_TEXT_MESSAGE_APP, kLocalNode); + 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); +} - ProcessMessage p1 = module.handleReceived(positionPacket); - ProcessMessage p2 = module.handleReceived(positionPacket); - ProcessMessage t1 = module.handleReceived(textPacket); - ProcessMessage t2 = module.handleReceived(textPacket); +/** + * 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; - meshtastic_TrafficManagementStats stats = module.getStats(); - TEST_ASSERT_EQUAL_INT(static_cast(ProcessMessage::CONTINUE), static_cast(p1)); - TEST_ASSERT_EQUAL_INT(static_cast(ProcessMessage::CONTINUE), static_cast(p2)); - TEST_ASSERT_EQUAL_INT(static_cast(ProcessMessage::CONTINUE), static_cast(t1)); - TEST_ASSERT_EQUAL_INT(static_cast(ProcessMessage::CONTINUE), static_cast(t2)); - TEST_ASSERT_EQUAL_UINT32(0, stats.position_dedup_drops); - TEST_ASSERT_EQUAL_UINT32(0, stats.rate_limit_drops); + 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)); } /** - * Verify locally addressed packets are never dropped by transit shaping. - * Important so dedup/rate limiting do not suppress end-user delivery. + * 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_localDestination_bypassesTransitFilters(void) +static void test_tm_nodeinfo_eviction_withinTierStalestLoses(void) { - moduleConfig.traffic_management.position_min_interval_secs = 300; - moduleConfig.traffic_management.rate_limit_window_secs = 60; - moduleConfig.traffic_management.rate_limit_max_packets = 1; - installWellKnownPrimaryChannelWithPrecision(16); + mockNodeDB->clearCachedNode(); + mockNodeDB->rollHotStore(); TrafficManagementModuleTestShim module; - meshtastic_MeshPacket position1 = makePositionPacket(kRemoteNode, 374221234, -1220845678, kLocalNode); - meshtastic_MeshPacket position2 = makePositionPacket(kRemoteNode, 374221234, -1220845678, kLocalNode); - meshtastic_MeshPacket text1 = makeDecodedPacket(meshtastic_PortNum_TEXT_MESSAGE_APP, kRemoteNode, kLocalNode); - meshtastic_MeshPacket text2 = makeDecodedPacket(meshtastic_PortNum_TEXT_MESSAGE_APP, kRemoteNode, kLocalNode); + constexpr NodeNum kFillBase = 0x40000000; + constexpr NodeNum kStale = 0x53000001, kNewcomer = 0x53000002; + module.handleReceived(makeNodeInfoPacketWithKey(kStale, "stale", 0x11)); - ProcessMessage p1 = module.handleReceived(position1); - ProcessMessage p2 = module.handleReceived(position2); - ProcessMessage t1 = module.handleReceived(text1); - ProcessMessage t2 = module.handleReceived(text2); - meshtastic_TrafficManagementStats stats = module.getStats(); + // Everything else is observed two ticks later... + TrafficManagementModule::s_testNowMs += 2UL * 180000UL; + const uint16_t cap = TrafficManagementModuleTestShim::nodeInfoCacheCapacityForTest(); + fillNodeInfoCacheWithTofuStrangers(module, cap - 1u, kFillBase); - TEST_ASSERT_EQUAL_INT(static_cast(ProcessMessage::CONTINUE), static_cast(p1)); - TEST_ASSERT_EQUAL_INT(static_cast(ProcessMessage::CONTINUE), static_cast(p2)); - TEST_ASSERT_EQUAL_INT(static_cast(ProcessMessage::CONTINUE), static_cast(t1)); - TEST_ASSERT_EQUAL_INT(static_cast(ProcessMessage::CONTINUE), static_cast(t2)); - TEST_ASSERT_EQUAL_UINT32(0, stats.position_dedup_drops); - TEST_ASSERT_EQUAL_UINT32(0, stats.rate_limit_drops); + // ...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 } /** - * Verify router role clamps NodeInfo response hops to router-safe maximum. - * Important so large config values cannot widen response scope unexpectedly. + * 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_routerClamp_skipsWhenTooManyHops(void) +static void test_tm_nodeinfo_memberSaturated_hooksSkipPacketPathEvicts(void) { - moduleConfig.traffic_management.nodeinfo_direct_response_max_hops = 10; - config.device.role = meshtastic_Config_DeviceConfig_Role_ROUTER; - mockNodeDB->setCachedNode(kTargetNode); - + mockNodeDB->clearCachedNode(); + mockNodeDB->rollHotStore(); TrafficManagementModuleTestShim module; - meshtastic_MeshPacket request = makeDecodedPacket(meshtastic_PortNum_NODEINFO_APP, kRemoteNode, kTargetNode); - request.decoded.want_response = true; - request.hop_start = 5; - request.hop_limit = 1; // 4 hops away; router clamp should cap max at 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_FALSE(module.ignoreRequestFlag()); + 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 } /** - * Verify NodeInfo direct-response success path and reply packet fields. - * Important because this path consumes the request and generates a spoofed cached reply. + * 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_directResponse_respondsFromCache(void) +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; - config.lora.config_ok_to_mqtt = true; - mockNodeDB->setCachedNode(kTargetNode); + mockNodeDB->clearCachedNode(); + mockNodeDB->rollHotStore(); MockRouter mockRouter; mockRouter.addInterface(std::unique_ptr(new MockRadioInterface())); @@ -542,80 +1965,66 @@ static void test_tm_nodeinfo_directResponse_respondsFromCache(void) service = &mockService; TrafficManagementModuleTestShim module; - meshtastic_MeshPacket request = makeDecodedPacket(meshtastic_PortNum_NODEINFO_APP, kRemoteNode, kTargetNode); - request.decoded.want_response = true; - request.id = 0x13572468; - request.hop_start = 3; - request.hop_limit = 3; // direct request (0 hops away) + trafficManagementModule = &module; // the NodeDB purge hook reaches the module via the global - ProcessMessage result = module.handleReceived(request); - meshtastic_TrafficManagementStats stats = module.getStats(); + 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)); - TEST_ASSERT_EQUAL_INT(static_cast(ProcessMessage::STOP), static_cast(result)); - TEST_ASSERT_TRUE(module.ignoreRequestFlag()); - TEST_ASSERT_EQUAL_UINT32(1, stats.nodeinfo_cache_hits); - TEST_ASSERT_EQUAL_UINT32(1, static_cast(mockRouter.sentPackets.size())); + mockNodeDB->resetNodes(); - const meshtastic_MeshPacket &reply = mockRouter.sentPackets.front(); - TEST_ASSERT_EQUAL_INT(meshtastic_PortNum_NODEINFO_APP, reply.decoded.portnum); - TEST_ASSERT_EQUAL_UINT32(kTargetNode, reply.from); - TEST_ASSERT_EQUAL_UINT32(kRemoteNode, reply.to); - TEST_ASSERT_EQUAL_UINT32(request.id, reply.decoded.request_id); - TEST_ASSERT_FALSE(reply.decoded.want_response); - TEST_ASSERT_EQUAL_UINT8(0, reply.hop_limit); - TEST_ASSERT_EQUAL_UINT8(0, reply.hop_start); - TEST_ASSERT_EQUAL_UINT8(mockNodeDB->getLastByteOfNodeNum(kRemoteNode), reply.next_hop); - TEST_ASSERT_TRUE(reply.decoded.has_bitfield); - TEST_ASSERT_EQUAL_UINT8(BITFIELD_OK_TO_MQTT_MASK, reply.decoded.bitfield); + 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; } /** - * Verify cached direct replies still preserve requester NodeInfo learning. - * Important so consuming the request does not skip NodeDB refresh for observers. + * 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_directResponse_learnsRequestorNodeInfo(void) +static void test_tm_nodeinfo_cache_dropsFrameCarryingOwnerKey(void) { - moduleConfig.traffic_management.nodeinfo_direct_response_max_hops = 10; - config.device.role = meshtastic_Config_DeviceConfig_Role_CLIENT; - mockNodeDB->setCachedNode(kTargetNode); + mockNodeDB->clearCachedNode(); + TrafficManagementModuleTestShim module; - MockRouter mockRouter; - mockRouter.addInterface(std::unique_ptr(new MockRadioInterface())); - MeshService mockService; - router = &mockRouter; - service = &mockService; + owner.public_key.size = 32; + memset(owner.public_key.bytes, 0x42, 32); - TrafficManagementModuleTestShim module; - meshtastic_MeshPacket request = makeNodeInfoPacket(kRemoteNode, "requester-long", "rq"); - request.to = kTargetNode; - request.decoded.want_response = true; - request.id = 0x01020304; - request.hop_start = 3; - request.hop_limit = 3; + module.handleReceived(makeNodeInfoPacketWithKey(kTargetNode, "imposter", 0x42)); - ProcessMessage result = module.handleReceived(request); - meshtastic_NodeInfoLite *requestor = mockNodeDB->getMeshNode(kRemoteNode); + TEST_ASSERT_EQUAL_INT(-1, module.peekNodeInfoFlagsForTest(kTargetNode)); + uint8_t key[32] = {0}; + TEST_ASSERT_FALSE(module.copyPublicKey(kTargetNode, key, nullptr)); - TEST_ASSERT_EQUAL_INT(static_cast(ProcessMessage::STOP), static_cast(result)); - TEST_ASSERT_NOT_NULL(requestor); - TEST_ASSERT_TRUE((requestor->bitfield & NODEINFO_BITFIELD_HAS_USER_MASK) != 0); - TEST_ASSERT_EQUAL_STRING("requester-long", requestor->long_name); - TEST_ASSERT_EQUAL_STRING("rq", requestor->short_name); - TEST_ASSERT_EQUAL_UINT8(request.channel, requestor->channel); + 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 /** - * A unicast NodeInfo request is never signed, so a known signer's identity claim on the - * direct-response path is unauthenticated. It must not overwrite the stored name (spoofing - * defense), while the direct response itself still goes out. The non-signer case - * (test_tm_nodeinfo_directResponse_learnsRequestorNodeInfo) is the control: it still learns. + * 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. */ -static void test_tm_nodeinfo_directResponse_ignoresUnsignedSignerIdentity(void) +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; - mockNodeDB->setCachedNode(kTargetNode); // the direct-response target - mockNodeDB->setSignerHotNode(kRemoteNode, "victim-real"); // the requester is a known signer + + // 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 + // 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())); @@ -624,60 +2033,37 @@ static void test_tm_nodeinfo_directResponse_ignoresUnsignedSignerIdentity(void) service = &mockService; TrafficManagementModuleTestShim module; - meshtastic_MeshPacket request = makeNodeInfoPacket(kRemoteNode, "attacker-name", "atk"); - request.to = kTargetNode; + module.dropNodeInfoCacheForTest(); // exercise the NodeDB fallback path + meshtastic_MeshPacket request = makeDecodedPacket(meshtastic_PortNum_NODEINFO_APP, kRemoteNode, kTargetNode); request.decoded.want_response = true; - request.id = 0x0A0B0C0D; request.hop_start = 3; request.hop_limit = 3; - request.xeddsa_signed = false; // unicast: never signed - - ProcessMessage result = module.handleReceived(request); - - // The response still went out (the request was consumed from cache)... - TEST_ASSERT_EQUAL_INT(static_cast(ProcessMessage::STOP), static_cast(result)); - TEST_ASSERT_EQUAL_UINT32(1, module.getStats().nodeinfo_cache_hits); - // ...but the known signer's stored name was not overwritten by the unauthenticated claim. - const meshtastic_NodeInfoLite *requestor = mockNodeDB->getMeshNode(kRemoteNode); - TEST_ASSERT_NOT_NULL(requestor); - TEST_ASSERT_EQUAL_STRING("victim-real", requestor->long_name); -} - -/** - * Verify client role only answers direct (0-hop) NodeInfo requests. - * Important so clients do not answer relayed requests outside intended scope. - */ -static void test_tm_nodeinfo_clientClamp_skipsWhenNotDirect(void) -{ - moduleConfig.traffic_management.nodeinfo_direct_response_max_hops = 10; - config.device.role = meshtastic_Config_DeviceConfig_Role_CLIENT; - mockNodeDB->setCachedNode(kTargetNode); - - TrafficManagementModuleTestShim module; - meshtastic_MeshPacket request = makeDecodedPacket(meshtastic_PortNum_NODEINFO_APP, kRemoteNode, kTargetNode); - request.decoded.want_response = true; - request.hop_start = 2; - request.hop_limit = 1; // 1 hop away; clients are clamped to max 0 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_FALSE(module.ignoreRequestFlag()); + TEST_ASSERT_EQUAL_UINT32(0, static_cast(mockRouter.sentPackets.size())); + + resetRTCStateForTests(); } -#if !(defined(ARCH_ESP32) && defined(BOARD_HAS_PSRAM)) /** - * Verify non-PSRAM builds require NodeDB for direct NodeInfo responses. - * Important because fallback should only happen through node-wide data when - * the dedicated PSRAM cache does not exist. + * Verify the NodeDB-fallback path still answers when the node was heard recently. */ -static void test_tm_nodeinfo_directResponse_withoutNodeDbEntry_skips(void) +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; - mockNodeDB->clearCachedNode(); + + setBootRelativeTimeForUnitTest(1000000); + const uint32_t now = getTime(); + + 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())); @@ -686,6 +2072,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; @@ -694,24 +2081,31 @@ static void test_tm_nodeinfo_directResponse_withoutNodeDbEntry_skips(void) ProcessMessage result = module.handleReceived(request); meshtastic_TrafficManagementStats stats = module.getStats(); - TEST_ASSERT_EQUAL_INT(static_cast(ProcessMessage::CONTINUE), static_cast(result)); - TEST_ASSERT_FALSE(module.ignoreRequestFlag()); - TEST_ASSERT_EQUAL_UINT32(0, stats.nodeinfo_cache_hits); - TEST_ASSERT_EQUAL_UINT32(0, static_cast(mockRouter.sentPackets.size())); + 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 -#if defined(ARCH_ESP32) && defined(BOARD_HAS_PSRAM) /** - * Verify PSRAM NodeInfo cache can answer requests without NodeDB and that - * shouldRespondToNodeInfo() uses cached bitfield metadata. + * 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. */ -static void test_tm_nodeinfo_directResponse_psramCacheRespondsAndPreservesBitfield(void) +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; - config.lora.config_ok_to_mqtt = true; - mockNodeDB->clearCachedNode(); + + 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 + // 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())); @@ -720,50 +2114,49 @@ static void test_tm_nodeinfo_directResponse_psramCacheRespondsAndPreservesBitfie service = &mockService; TrafficManagementModuleTestShim module; - - meshtastic_MeshPacket observed = makeNodeInfoPacket(kTargetNode, "target-long", "tg"); - observed.decoded.has_bitfield = true; - observed.decoded.bitfield = BITFIELD_WANT_RESPONSE_MASK; - observed.channel = 2; - observed.rx_time = 123456; - - ProcessMessage observedResult = module.handleReceived(observed); - TEST_ASSERT_EQUAL_INT(static_cast(ProcessMessage::CONTINUE), static_cast(observedResult)); - + module.dropNodeInfoCacheForTest(); // exercise the NodeDB fallback path meshtastic_MeshPacket request = makeDecodedPacket(meshtastic_PortNum_NODEINFO_APP, kRemoteNode, kTargetNode); request.decoded.want_response = true; - request.id = 0x24681357; - request.channel = 1; request.hop_start = 3; request.hop_limit = 3; - ProcessMessage result = module.handleReceived(request); - meshtastic_TrafficManagementStats stats = module.getStats(); + // 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())); - TEST_ASSERT_EQUAL_INT(static_cast(ProcessMessage::STOP), static_cast(result)); - TEST_ASSERT_TRUE(module.ignoreRequestFlag()); - TEST_ASSERT_EQUAL_UINT32(1, stats.nodeinfo_cache_hits); + // 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::CONTINUE), static_cast(module.handleReceived(request))); + TEST_ASSERT_FALSE(module.ignoreRequestFlag()); TEST_ASSERT_EQUAL_UINT32(1, static_cast(mockRouter.sentPackets.size())); - const meshtastic_MeshPacket &reply = mockRouter.sentPackets.front(); - TEST_ASSERT_TRUE(reply.decoded.has_bitfield); - TEST_ASSERT_EQUAL_UINT8(static_cast(BITFIELD_WANT_RESPONSE_MASK | BITFIELD_OK_TO_MQTT_MASK), reply.decoded.bitfield); - TEST_ASSERT_EQUAL_UINT32(kTargetNode, reply.from); - TEST_ASSERT_EQUAL_UINT32(kRemoteNode, reply.to); - TEST_ASSERT_EQUAL_UINT8(request.channel, reply.channel); - TEST_ASSERT_EQUAL_UINT32(request.id, reply.decoded.request_id); + // 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(); } +#if TMM_NODEINFO_REPLAY_SIGNED_GATE /** - * 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. + * 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_psramMissDoesNotFallbackToNodeDb(void) +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())); @@ -772,20 +2165,19 @@ static void test_tm_nodeinfo_directResponse_psramMissDoesNotFallbackToNodeDb(voi 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; 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_FALSE(module.ignoreRequestFlag()); - TEST_ASSERT_EQUAL_UINT32(0, stats.nodeinfo_cache_hits); TEST_ASSERT_EQUAL_UINT32(0, static_cast(mockRouter.sentPackets.size())); + + resetRTCStateForTests(); } -#endif +#endif // TMM_NODEINFO_REPLAY_SIGNED_GATE /** * Verify relayed telemetry broadcasts are NOT hop-exhausted. @@ -1771,12 +3163,54 @@ 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); + RUN_TEST(test_tm_nodeinfo_directResponse_fallbackThrottlesWithinWindow); +#if TMM_NODEINFO_REPLAY_SIGNED_GATE + RUN_TEST(test_tm_nodeinfo_directResponse_fallbackUnsignedNotServed); #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); + 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); + 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); +#endif + 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 +#endif + 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); + 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); + 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);