diff --git a/docs/node_info_stores.md b/docs/node_info_stores.md new file mode 100644 index 000000000..33b0c366d --- /dev/null +++ b/docs/node_info_stores.md @@ -0,0 +1,248 @@ +# 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 to keep entries with a `next_hop` hint **or** a cached special (non-`CLIENT`) + role - the long-tail state this cache exists to retain (`findOrCreateEntry`'s `preferred` + test covers both, not just `next_hop`). +- **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 that + backs TMM's **spoofed direct NodeInfo replies** on a target's behalf, independent of + NodeDB (the serve/throttle behaviour is documented in + [traffic_management_module.md](traffic_management_module.md)). 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`), the `obsTick` recency stamp (3 min/tick), + `sourceChannel`, `decodedBitfield`, and packed 1-bit flags: `hasDecodedBitfield`, + `keySignerProven`, `hasObserved`, `hasFullUser`, `isMember`. (The direct-response throttle + no longer keeps per-entry state here - it is a pair of separate RAM tables; see the module + doc.) +- **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 6 h serve window is + enforced by the sweep-cleared `hasObserved` bit; the spoofed-reply throttle that gate + feeds lives in the module (see [traffic_management_module.md](traffic_management_module.md)). + +### Consistency with NodeDB (anti-entropy) + +Four mechanisms keep this tier a superset of NodeDB's identities. All **merge rather than +overwrite**, so a keyless commit never costs the cache a learned TOFU key. + +| Mechanism | When | Role | +| --------------------------------------------------------------------- | --------------------------- | -------------------------------- | +| Write-through hooks (`onNodeIdentityCommitted`, `onNodeKeyCommitted`) | every identity/key commit | immediate upsert | +| Reconcile sweep (`reconcileNodeInfoFromNodeDBLocked`) | boot seed, then hourly | re-seed from hot + warm tiers | +| Membership refresh | inside the hourly reconcile | re-mark which nodes NodeDB holds | +| Purge hooks (`purgeNode`, `purgeAll`) | node removal / reset | drop the node from both caches | + +Two details that bite: the reconcile sweep transfers signer verdicts only when **key-matched**; +and membership refresh clears-then-re-marks from both tiers rather than a per-entry NodeDB lookup +each sweep (which would be O(entries x members) under the lock). A keyless warm-tier record still +marks membership (`isMember`) even though it has no `User` to seed - `isMember` is a keep-alive, +independent of `hasFullUser`. Because the re-mark is only hourly, hook-driven additions and +`purgeNode()` removals are immediate, but a **passive** NodeDB eviction may lag membership by up to +an hour. + +**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 when **learning or rotating a remote +key** - the cache would silently diverge until the next reconcile. (The lone direct write +in `getOrCreateMeshNode()`'s warm-tier re-admission is exempt: it restores a key the warm +tier already holds, which this cache already tracks as a member, so nothing new is learned +and the hourly reconcile re-seeds it even if the packet path had LRU-evicted that slot.) + +**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) from `clockMs()`; modular +subtraction is correct only while the true age stays below the counter period, so every clock +needs something to clear expired state before it aliases. + +| Clock | Tick / period | Window | Kept honest by | +| ------------------ | -------------- | --------------- | -------------------------------------------------- | +| pos | 6 min / 25.6 h | <=255 ticks | 60 s sweep (margin as low as 1 tick at the clamp) | +| rate | 5 min / 80 min | <=15 ticks | sweep + read-time window reset (`isRateLimited()`) | +| unknown | 1 min / 16 min | 12 ticks | sweep + read-time window reset | +| NodeInfo `obsTick` | 3 min / 12.8 h | 120 ticks (6 h) | sweep only | + +`obsTick` is the sharp case: `maintainNodeInfoCacheLocked()` clearing `hasObserved` is the +_sole_ guarantee the 6 h serve gate never reads an aliased stamp. That makes the sweep a +compile-time invariant - guarded by `TMM_HAS_NODEINFO_CACHE` **alone** (never +`TRAFFIC_MANAGEMENT_CACHE_SIZE`, which a variant may zero independently), mirroring `purgeAll()`: +a build that has the cache always has its sweep. + +The warm tier is different by design: `WarmNodeStore.last_heard` is an **absolute** unix-seconds +timestamp (128 s quantised), so it cannot wrap until 2106 and needs no sweep - the TMM caches +chose 1-byte ticks instead to stay at 10 B/entry across up to 2048 entries. + +### Direct-response behavior + +How this cache's identities are served as spoofed direct NodeInfo replies - the serve gates, +the per-requester/per-target/global throttle, and the "throttled forwards, not dropped" +behaviour - is documented with the module in +[traffic_management_module.md](traffic_management_module.md). + +--- + +## 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 | - | - | `sourceChannel`, `decodedBitfield` (+ `hasDecodedBitfield`) | - | +| 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/docs/traffic_management_module.md b/docs/traffic_management_module.md new file mode 100644 index 000000000..bb533edbe --- /dev/null +++ b/docs/traffic_management_module.md @@ -0,0 +1,193 @@ +# The Traffic Management Module (TMM) + +TMM is an optional module that shapes **transit** traffic on busy meshes. Large networks get +noisy fast - repeated position packets, bursty senders, and unknown/undecryptable frames all +burn limited airtime and power - and TMM filters or answers that traffic before it is +rebroadcast. On supported targets it **ships enabled** (`has_traffic_management` defaults to +true) with position dedup running at its 11 h default; the other features each default off, so +the module is on out of the box but opt-in per feature. It was introduced in +[meshtastic/firmware#9358](https://github.com/meshtastic/firmware/pull/9358). + +This document covers the module's behaviour, with a deep dive on the two TMM-specific +NodeInfo features - **direct-serve** (answering NodeInfo requests on another node's behalf) +and the **throttling** that bounds it. The identity/traffic-state stores those features read +from are documented separately in [node_info_stores.md](node_info_stores.md); this file owns +the direct-serve and throttle behaviour, that file owns the stores. + +Sources of truth: `src/modules/TrafficManagementModule.{h,cpp}`, defaults in +`src/mesh/Default.h`. + +--- + +## How it runs + +- **Enablement is three-gated.** Compile-time `HAS_TRAFFIC_MANAGEMENT` (with the + `MESHTASTIC_EXCLUDE_TRAFFIC_MANAGEMENT` build exclusion), then the runtime + `moduleConfig.has_traffic_management` presence flag. While the runtime gate is off, the + packet path, the maintenance sweep, the NodeDB write-through hooks, and the cache accessors + all no-op - content, maintenance, and reads are keyed to the same condition. +- **It runs before `RoutingModule`** in `callModules()`. Returning `STOP` from + `handleReceived()` fully consumes a packet, so it is never rebroadcast; `CONTINUE` lets it + proceed through normal relay handling. +- **State is cheap.** Per-node traffic-shaping counters live in a flat 10-byte + `UnifiedCacheEntry` array (position fingerprint, rate/unknown counters, modular tick + stamps, a next-hop hint, and a 4-bit role fallback) - see + [node_info_stores.md §3](node_info_stores.md). Direct-serve additionally reads the PSRAM + NodeInfo payload cache (or the NodeDB fallback when that cache is absent). + +## What it does + +| Feature | Default | In one line | +| ------------------------ | -------------- | -------------------------------------------------------------- | +| Position dedup | on, 11 h | Suppresses a stationary sender's repeated position broadcasts. | +| Per-sender rate limit | off | Caps how many transit packets one sender may spend per window. | +| Unknown-packet filter | off | Drops a sender's undecryptable traffic past a threshold. | +| NodeInfo direct response | off | Answers a NodeInfo request on the target's behalf (see below). | +| Position precision clamp | channel-driven | Truncates relayed position to the channel's precision. | + +Config lives under `moduleConfig.traffic_management`; the per-feature sections below give the +exact fields, defaults, and behaviour. NodeInfo direct response has its own deep-dive sections +after these. + +### Position dedup + +`position_min_interval_secs` (default 11 h; `0` disables). Drops a duplicate position from the +same sender inside the interval, where "duplicate" means the same fingerprint on the channel's +`position_precision` grid (firmware default 19-bit, ~90 m cells). Role caps only ever _shorten_ +the interval: **tracker / TAK tracker → 1 h**, **lost-and-found → 15 min**. + +### Per-sender rate limit + +`rate_limit_window_secs` + `rate_limit_max_packets` (default off; either `0` disables). Drops a +sender's transit packets once it exceeds the budget within the window. + +### Unknown-packet filter + +`unknown_packet_threshold` (default `0` = off). Drops undecryptable traffic from a sender once it +passes the threshold within a ~5 min window. + +### NodeInfo direct response + +`nodeinfo_direct_response_max_hops` (default `0` = off). When set, a neighbour that already +holds the target's identity answers a unicast NodeInfo request on its behalf, saving the full +round trip. This is TMM's most security-sensitive feature; the serve gates and the throttle +that bounds it are covered in the two dedicated sections below. + +### Position precision clamp + +Driven by the channel's `position_precision` ceiling (else the 19-bit firmware default). +`alterReceived()` truncates relayed position coordinates to that precision. + +### Shelved + +Present in the config surface but currently no-ops in the module, deferred until the right +heuristics are settled: hop exhaustion for position/telemetry (`exhaust_hop_position` / +`exhaust_hop_telemetry`) and `router_preserve_hops`. `alterReceived()` leaves rebroadcast hop +handling untouched. + +--- + +## NodeInfo direct response (direct-serve) + +Normally a unicast NodeInfo request travels all the way to the target and the reply travels +all the way back. On a large mesh that is several hops of airtime per lookup. When +`nodeinfo_direct_response_max_hops > 0`, a neighbour that already holds the target's identity +answers **on the target's behalf** with a spoofed reply, cutting the round trip to one hop. + +**Data source.** The reply payload comes from the TMM NodeInfo payload cache (PSRAM-backed; +full cached `User` plus provenance metadata) or, on builds without that cache, from the +NodeDB fallback. Both are described in [node_info_stores.md §4](node_info_stores.md); this +feature is a _consumer_ of them. + +**Decision pipeline** (`shouldRespondToNodeInfo()`), in order - any failure returns `false` +and the request is left to propagate normally: + +1. **Eligibility** (checked by the caller): `nodeinfo_direct_response_max_hops > 0`, + `NODEINFO_APP` portnum, `want_response`, and the packet is unicast, not to us, not from us. +2. **Hop clamp** (`isMinHopsFromRequestor()`): respond only when the requester is within the + role-clamped hop ceiling - **routers up to 3 hops** (`kRouterDefaultMaxHops`, may be + lowered by config), **clients direct-only, 0 hops** (`kClientDefaultMaxHops`). +3. **Identity lookup**: NodeInfo cache hit (cache path) or NodeDB fallback (fallback path). +4. **Staleness gate (6 h)**: never vouch for a node not genuinely _heard_ within the serve + window. Only a real observed frame stamps the recency bit - seeding and write-through are + knowledge, not observation, so a silent node can never look alive to this path. +5. **Signer-provenance gate** (`TMM_NODEINFO_REPLAY_SIGNED_GATE`, default on): vouch only for + an identity whose key is signer-proven (XEdDSA-verified, directly or inherited from + NodeDB). A trust-on-first-use identity is left for the genuine node - or another + cache-holder that _has_ proof - to answer. Bypassed when PKI is compiled out. +6. **Throttle** (`directResponseAllowed()`): see the next section. + +**The spoofed reply.** On success TMM emits a NodeInfo reply with `from` set to the _target_ +(so the requester sees a valid answer), `to` the requester, `hop_limit = 0` (one hop only), +`request_id` the original packet id, and the OK_TO_MQTT bit set from local +`config.lora.config_ok_to_mqtt` policy. The requester's own identity claim in the request is +**not** written back to NodeDB - a unicast NodeInfo is unsigned, so treating it as an +identity update would be unauthenticated. `nodeinfo_cache_hits` counts only replies actually +sent. + +--- + +## Throttling direct responses + +A direct reply is addressed to the requesting packet's `from` and spoofs the requested +target - and **both fields are unauthenticated header data**. Without a bound, an attacker +crafts requests carrying a victim's address as `from`, and every neighbour holding the target +transmits at the victim: a reflector-amplification primitive. The throttle is the security +core of this feature, checked immediately before a reply would go out so requests declined for +other reasons never consume the budget. + +**Three bounds**, all keyed off `clockMs()` and evaluated under `cacheLock`: + +| Bound | Window | Bounds | +| ------------------------------------------------ | ------ | ------------------------------------------------ | +| Per requester (`kDirectResponsePerRequesterMs`) | 60 s | how much any single node can be made to receive | +| Per target (`kDirectResponsePerTargetMs`) | 60 s | how often we vouch for the same identity | +| Global airtime floor (`kDirectResponseGlobalMs`) | 1 s | total spoofed TX, regardless of key distribution | + +**Mechanism.** The two per-key bounds are fixed **8-slot LRU tables in internal RAM** +(`directRequesterSeen`, `directTargetSeen`) - _not_ the PSRAM NodeInfo cache - so they behave +identically with and without PSRAM, on the cache path and the NodeDB-fallback path alike. +Timestamps are full `uint32` milliseconds compared by wrap-safe subtraction, so there is no +tick clock and no maintenance sweep to keep them honest. `directResponseAllowed(requester, +target, now)` resolves a slot in _both_ tables before stamping either - so a reply one axis +throttles never consumes the other axis's budget - then records the send on all three bounds. +The global floor is a single stamp, checked first as the cheap common case. + +**When a table fills.** For an unseen key with no free slot, `directResponseSlot()` evicts the +**least-recently-used** entry (smallest last-reply time) and admits the new key. The LRU +victim is by construction the entry closest to expiring anyway, so eviction is the +lowest-cost choice. An attacker who cycles more than 8 distinct requesters or targets - easy, +since both are unauthenticated - evicts entries and defeats _per-key_ throttling for the +cycled keys; that is expected, and why the **global 1 s floor is the hard backstop**. It is a +single stamp, cannot fill, and caps total spoofed replies at ~1/s no matter what. Per-key +throttling degrades gracefully to the floor under pressure. + +**Throttled is not dropped.** A throttled request returns `false`, which lets +`handleReceived()` `CONTINUE`: the request forwards toward the genuine target (which can +answer itself) rather than being black-holed. 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. + +**Evolution.** The original design split throttling by path: a per-entry `respTick` stamp in +each NodeInfo cache slot (cache path, 30 s, swept for wrap-safety) plus a single module-global +stamp for the NodeDB fallback (30 s, neither per-requester nor per-target). Those two routes +were unified into the symmetric per-requester + per-target RAM tables above, aligned to a +single 60 s window, so both axes hold with and without PSRAM and the cache entry no longer +carries throttle state. + +--- + +## Configuration + +All tunables live under `moduleConfig.traffic_management`; the whole module is gated by the +`has_traffic_management` presence flag, and each per-feature section above lists its own +field(s) and default. Two related sets of knobs are **firmware constants, not config**: the +role-based position caps `default_traffic_mgmt_tracker_position_min_interval_secs` (1 h) and +`default_traffic_mgmt_lost_and_found_position_min_interval_secs` (15 min), and the direct-serve +throttle windows (the `kDirectResponse*Ms` constants). + +## See also + +- [node_info_stores.md](node_info_stores.md) - the NodeDB hot store, warm tier, TMM NodeInfo + payload cache, and unified cache that the direct-serve path reads from, plus their trust, + provenance, and anti-entropy model. diff --git a/extra_scripts/esp32_pre.py b/extra_scripts/esp32_pre.py index 8e21770e9..b2c4171e7 100755 --- a/extra_scripts/esp32_pre.py +++ b/extra_scripts/esp32_pre.py @@ -71,3 +71,99 @@ def mtjson_esp32_part(target, source, env): env.AddPreAction("mtjson", mtjson_esp32_part) + + +# --------------------------------------------------------------------------- +# HybridCompile cache-poisoning workaround (pioarduino platform-espressif32) +# +# The platform decides whether the precompiled Arduino IDF libs can be reused +# by hashing only the custom_sdkconfig text + mcu +# (builder/frameworks/arduino.py::matching_custom_sdkconfig). The board JSON's +# PSRAM configuration (BOARD_HAS_PSRAM / memory_type) is not part of that +# hash, and nearly all our S3 variants share esp32s3_base's identical +# custom_sdkconfig. Building a PSRAM env (e.g. heltec-v4) followed by a +# no-PSRAM env (e.g. heltec-v3) therefore skips the framework-libs recompile +# and links CONFIG_SPIRAM=y libs into the no-PSRAM firmware, which boot-loops +# with "quad_psram: PSRAM chip is not connected". +# +# Workaround: before the platform's arduino.py SConscript reads the option, +# append a comment line derived from the board's PSRAM/memory configuration to +# this env's custom_sdkconfig. The comment is inert in kconfig but changes the +# md5, forcing the IDF-libs recompile whenever the PSRAM class changes. The +# derivation mirrors espidf.py::generate_board_specific_config(). +# +# The marker must stay lowercase: arduino.py greps custom_sdkconfig for the +# substrings "PSRAM" and "CONFIG_SPIRAM=y" (has_psram_config) and would +# otherwise treat every board as having PSRAM. +# --------------------------------------------------------------------------- + +SDKCONFIG_CACHE_KEY = "meshtastic_hybridcompile_cache_key" + + +def spiram_cache_class(env): + board = env.BoardConfig() + mcu = board.get("build.mcu", "esp32") + + # memory_type: platformio.ini override > build.arduino.memory_type > build.memory_type + memory_type = None + try: + memory_type = env.GetProjectOption("board_build.memory_type", None) + except Exception: + pass + if not memory_type: + build_section = board.get("build", {}) + memory_type = build_section.get("arduino", {}).get( + "memory_type" + ) or build_section.get("memory_type") + + extra_flags = board.get("build.extra_flags", "") + if isinstance(extra_flags, str): + has_psram = "PSRAM" in extra_flags + else: + has_psram = any("PSRAM" in str(flag) for flag in extra_flags) + if not has_psram: + if memory_type and ( + "opi" in memory_type.lower() or "psram" in memory_type.lower() + ): + has_psram = True + elif "psram_type" in board.get("build", {}): + has_psram = True + + if has_psram: + psram_type = None + try: + psram_type = env.GetProjectOption("board_build.psram_type", None) + except Exception: + pass + if not psram_type and memory_type and len(memory_type.split("_")) == 2: + psram_type = memory_type.split("_")[1] + if not psram_type: + psram_type = board.get("build.psram_type", "") or ( + "hex" if mcu == "esp32p4" else "qio" + ) + psram_type = psram_type.lower() + if psram_type == "opi" and mcu == "esp32s3": + spiram = "oct" + elif psram_type == "hex": + spiram = "hex" + else: + spiram = "quad" + else: + spiram = "none" + + return "memory_type=%s spiram=%s" % ((memory_type or "default").lower(), spiram) + + +def tag_sdkconfig_cache_key(env): + config = env.GetProjectConfig() + section = "env:" + env["PIOENV"] + if not config.has_option(section, "custom_sdkconfig"): + return + current = env.GetProjectOption("custom_sdkconfig") + if SDKCONFIG_CACHE_KEY in current: + return + marker = "# %s: %s" % (SDKCONFIG_CACHE_KEY, spiram_cache_class(env)) + config.set(section, "custom_sdkconfig", current.rstrip("\n") + "\n" + marker) + + +tag_sdkconfig_cache_key(env) diff --git a/platformio.ini b/platformio.ini index 5dd81dd6f..dcd27e8c8 100644 --- a/platformio.ini +++ b/platformio.ini @@ -127,7 +127,7 @@ lib_deps = [device-ui_base] lib_deps = # renovate: datasource=git-refs depName=meshtastic/device-ui packageName=https://github.com/meshtastic/device-ui gitBranch=master - https://github.com/meshtastic/device-ui/archive/33917d583eb3f4d6f93a24122a88101221afcc2d.zip + https://github.com/meshtastic/device-ui/archive/ef573c368767625ffbe8c32cf921ea7366f2dd53.zip ; Common libs for environmental measurements in telemetry module [environmental_base] diff --git a/src/graphics/draw/MessageRenderer.cpp b/src/graphics/draw/MessageRenderer.cpp index 6c202cb6d..af5ffe93d 100644 --- a/src/graphics/draw/MessageRenderer.cpp +++ b/src/graphics/draw/MessageRenderer.cpp @@ -17,6 +17,7 @@ #include "graphics/emotes.h" #include "main.h" #include "meshUtils.h" +#include "modules/CannedMessageModule.h" #include #include @@ -1073,6 +1074,7 @@ void handleNewMessage(OLEDDisplay *display, const StoredMessage &sm, const mesht { if (packet.from != 0) { hasUnreadMessage = true; + const bool suppressBanner = cannedMessageModule && cannedMessageModule->isFreeTextActive(); // Determine if message belongs to a muted channel bool isChannelMuted = false; @@ -1163,11 +1165,13 @@ void handleNewMessage(OLEDDisplay *display, const StoredMessage &sm, const mesht // Shorter banner if already in a conversation (Channel or Direct) bool inThread = (getThreadMode() != ThreadMode::ALL); - if (shouldWakeOnReceivedMessage()) { + if (!suppressBanner && shouldWakeOnReceivedMessage()) { screen->setOn(true); } - screen->showSimpleBanner(banner, inThread ? 1000 : 3000); + if (!suppressBanner) { + screen->showSimpleBanner(banner, inThread ? 1000 : 3000); + } } // Always focus into the correct conversation thread when a message with real text arrives diff --git a/src/graphics/draw/UIRenderer.cpp b/src/graphics/draw/UIRenderer.cpp index 1f5cccd65..5656d78f2 100644 --- a/src/graphics/draw/UIRenderer.cpp +++ b/src/graphics/draw/UIRenderer.cpp @@ -42,9 +42,9 @@ static inline void drawSatelliteIcon(OLEDDisplay *display, int16_t x, int16_t y) { int yOffset = (currentResolution == ScreenResolution::High) ? -5 : 1; if (currentResolution == ScreenResolution::High) { - NodeListRenderer::drawScaledXBitmap16x16(x, y + yOffset, imgSatellite_width, imgSatellite_height, imgSatellite, display); + NodeListRenderer::drawScaledXBitmap16x16(x, y + yOffset, imgGPS_width, imgGPS_height, imgGPS, display); } else { - display->drawXbm(x + 1, y + yOffset, imgSatellite_width, imgSatellite_height, imgSatellite); + display->drawXbm(x + 1, y + yOffset, imgGPS_width, imgGPS_height, imgGPS); } } @@ -521,9 +521,9 @@ void UIRenderer::drawGps(OLEDDisplay *display, int16_t x, int16_t y, const mesht { // Draw satellite image if (currentResolution == ScreenResolution::High) { - NodeListRenderer::drawScaledXBitmap16x16(x, y - 2, imgSatellite_width, imgSatellite_height, imgSatellite, display); + NodeListRenderer::drawScaledXBitmap16x16(x, y - 2, imgGPS_width, imgGPS_height, imgGPS, display); } else { - display->drawXbm(x + 1, y + 1, imgSatellite_width, imgSatellite_height, imgSatellite); + display->drawXbm(x + 1, y + 1, imgGPS_width, imgGPS_height, imgGPS); } char textString[10]; diff --git a/src/graphics/images.h b/src/graphics/images.h index 86d9efb7b..3234ff566 100644 --- a/src/graphics/images.h +++ b/src/graphics/images.h @@ -11,6 +11,9 @@ const uint8_t SATELLITE_IMAGE[] PROGMEM = {0x00, 0x08, 0x00, 0x1C, 0x00, 0x0E, 0 const uint8_t imgSatellite[] PROGMEM = { 0b00000000, 0b00000000, 0b00000000, 0b00011000, 0b11011011, 0b11111111, 0b11011011, 0b00011000, }; +#define imgGPS_width 8 +#define imgGPS_height 8 +const unsigned char imgGPS[] PROGMEM = {0x00, 0x07, 0x39, 0x2D, 0xFF, 0x48, 0x48, 0x60}; const uint8_t imgUSB[] PROGMEM = {0x00, 0xfc, 0xf0, 0xfc, 0x88, 0xff, 0x86, 0xfe, 0x85, 0xfe, 0x89, 0xff, 0xf1, 0xfc, 0x00, 0xfc}; const uint8_t imgUSB_HighResolution[] PROGMEM = {0x00, 0x3e, 0xf8, 0x80, 0x43, 0xf8, 0xc0, 0xc2, 0xff, 0x60, 0x42, 0xfc, diff --git a/src/graphics/niche/InkHUD/Applets/System/Menu/MenuApplet.cpp b/src/graphics/niche/InkHUD/Applets/System/Menu/MenuApplet.cpp index ac1fd1e73..5e8a08e75 100644 --- a/src/graphics/niche/InkHUD/Applets/System/Menu/MenuApplet.cpp +++ b/src/graphics/niche/InkHUD/Applets/System/Menu/MenuApplet.cpp @@ -2614,6 +2614,8 @@ uint16_t InkHUD::MenuApplet::getSystemInfoPanelHeight() void InkHUD::MenuApplet::sendText(NodeNum dest, ChannelIndex channel, const char *message) { meshtastic_MeshPacket *p = router->allocForSending(); + if (!p) + return; p->decoded.portnum = meshtastic_PortNum_TEXT_MESSAGE_APP; p->to = dest; p->channel = channel; @@ -2622,7 +2624,7 @@ void InkHUD::MenuApplet::sendText(NodeNum dest, ChannelIndex channel, const char memcpy(p->decoded.payload.bytes, message, p->decoded.payload.size); // Tack on a bell character if requested - if (moduleConfig.canned_message.send_bell && p->decoded.payload.size < meshtastic_Constants_DATA_PAYLOAD_LEN) { + if (moduleConfig.canned_message.send_bell && p->decoded.payload.size + 1 < meshtastic_Constants_DATA_PAYLOAD_LEN) { p->decoded.payload.bytes[p->decoded.payload.size] = 7; // Bell character p->decoded.payload.bytes[p->decoded.payload.size + 1] = '\0'; // Append Null Terminator p->decoded.payload.size++; diff --git a/src/mesh/MeshModule.cpp b/src/mesh/MeshModule.cpp index a4acea7f1..5dc7fba4a 100644 --- a/src/mesh/MeshModule.cpp +++ b/src/mesh/MeshModule.cpp @@ -18,7 +18,7 @@ uint8_t MeshModule::numPeriodicModules = 0; */ meshtastic_MeshPacket *MeshModule::currentReply; -MeshModule::MeshModule(const char *_name) : name(_name) +MeshModule::MeshModule(const char *_name, meshtastic_PortNum _ourPortNum) : name(_name), ourPortNum(_ourPortNum) { // Can't trust static initializer order, so we check each time if (!modules) @@ -27,6 +27,12 @@ MeshModule::MeshModule(const char *_name) : name(_name) modules->push_back(this); } +bool MeshModule::replyPortMatches(meshtastic_PortNum modulePort, const meshtastic_MeshPacket &mp) +{ + return modulePort != meshtastic_PortNum_UNKNOWN_APP && mp.which_payload_variant == meshtastic_MeshPacket_decoded_tag && + mp.decoded.portnum == modulePort; +} + void MeshModule::setup() {} MeshModule::~MeshModule() @@ -57,6 +63,8 @@ meshtastic_MeshPacket *MeshModule::allocAckNak(meshtastic_Routing_Error err, Nod // So we manually call pb_encode_to_bytes and specify routing port number // auto p = allocDataProtobuf(c); meshtastic_MeshPacket *p = router->allocForSending(); + if (!p) + return nullptr; p->decoded.portnum = meshtastic_PortNum_ROUTING_APP; p->decoded.payload.size = pb_encode_to_bytes(p->decoded.payload.bytes, sizeof(p->decoded.payload.bytes), &meshtastic_Routing_msg, &c); @@ -105,6 +113,7 @@ void MeshModule::callModules(meshtastic_MeshPacket &mp, RxSource src) auto &pi = **i; pi.currentRequest = ∓ + pi.ignoreRequest = false; /// We only call modules that are interested in the packet (and the message is destined to us or we are promiscious) bool wantsPacket = (isDecoded || pi.encryptedOk) && (pi.isPromiscuous || toUs) && pi.wantPacket(&mp); @@ -155,9 +164,13 @@ void MeshModule::callModules(meshtastic_MeshPacket &mp, RxSource src) // better solution (FIXME) would be to let phones have their own distinct addresses and we 'route' to them like // any other node. if (isDecoded && mp.decoded.want_response && toUs && (!isFromUs(&mp) || isToUs(&mp)) && !currentReply) { - pi.sendResponse(mp); + if (replyPortMatches(pi.ourPortNum, mp)) { + pi.sendResponse(mp); + LOG_INFO("Asked module '%s' to send a response", pi.name); + } else { + LOG_DEBUG("Module '%s' cannot respond on portnum=%d", pi.name, mp.decoded.portnum); + } ignoreRequest = ignoreRequest || pi.ignoreRequest; // If at least one module asks it, we may ignore a request - LOG_INFO("Asked module '%s' to send a response", pi.name); } else { LOG_DEBUG("Module '%s' considered", pi.name); } @@ -311,4 +324,4 @@ bool MeshModule::isRequestingFocus() } else return false; } -#endif \ No newline at end of file +#endif diff --git a/src/mesh/MeshModule.h b/src/mesh/MeshModule.h index 9d579d4f1..3dc6414a5 100644 --- a/src/mesh/MeshModule.h +++ b/src/mesh/MeshModule.h @@ -68,10 +68,12 @@ class MeshModule /** Constructor * name is for debugging output */ - MeshModule(const char *_name); + MeshModule(const char *_name, meshtastic_PortNum _ourPortNum = meshtastic_PortNum_UNKNOWN_APP); virtual ~MeshModule(); + static bool replyPortMatches(meshtastic_PortNum modulePort, const meshtastic_MeshPacket &mp); + /** For use only by MeshService */ static void callModules(meshtastic_MeshPacket &mp, RxSource src = RX_SRC_RADIO); @@ -88,6 +90,7 @@ class MeshModule #endif protected: const char *name; + meshtastic_PortNum ourPortNum; /** Most modules only care about packets that are destined for their node (i.e. broadcasts or has their node as the specific recipient) But some plugs might want to 'sniff' packets that are merely being routed (passing through the current node). Those @@ -103,7 +106,7 @@ class MeshModule * flag */ bool encryptedOk = false; - /* We allow modules to ignore a request without sending an error if they have a specific reason for it. */ + /* Per-packet flag cleared by callModules(); modules can suppress an error response for a specific request. */ bool ignoreRequest = false; /** diff --git a/src/mesh/NodeDB.cpp b/src/mesh/NodeDB.cpp index b6bdf81f6..0ecfb73d7 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(); @@ -3122,6 +3142,9 @@ size_t NodeDB::getNumOnlineMeshNodes(bool localOnly) #include "MeshModule.h" #include "Throttle.h" +// Minimum spacing between evictions once the node database is full. +#define NODEDB_FULL_EVICTION_INTERVAL_MS (2 * 1000UL) + static constexpr uint32_t HOPSTART_DROP_LOG_INTERVAL_MS = 15000; void logHopStartDrop(const meshtastic_MeshPacket &p, const char *context) @@ -3314,15 +3337,16 @@ void NodeDB::addFromContact(meshtastic_SharedContact contact) */ bool NodeDB::updateUser(uint32_t nodeId, meshtastic_User &p, uint8_t channelIndex, bool xeddsaSigned) { - meshtastic_NodeInfoLite *info = getOrCreateMeshNode(nodeId); - if (!info) { + // Only a signed update may change the identity of a node that has proven it signs; our own record is + // exempt. Checked before getOrCreateMeshNode so a refused update cannot evict or write the warm tier. + const meshtastic_NodeInfoLite *existing = getMeshNode(nodeId); + if (nodeId != getNodeNum() && existing && nodeInfoLiteHasXeddsaSigned(existing) && !xeddsaSigned) { + LOG_WARN("Refusing unsigned identity update for node 0x%08x that previously signed", nodeId); return false; } - // Once a node has proven it signs, only a signed update may change its identity. The public-key guard - // below is no help - an attacker can replay the victim's real (public) key. Our own record is exempt. - if (nodeId != getNodeNum() && nodeInfoLiteHasXeddsaSigned(info) && !xeddsaSigned) { - LOG_WARN("Refusing unsigned identity update for node 0x%08x that previously signed", nodeId); + meshtastic_NodeInfoLite *info = getOrCreateMeshNode(nodeId); + if (!info) { return false; } @@ -3402,6 +3426,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; } @@ -3416,7 +3454,19 @@ void NodeDB::updateFrom(const meshtastic_MeshPacket &mp) if (mp.which_payload_variant == meshtastic_MeshPacket_decoded_tag && mp.from) { LOG_DEBUG("Update DB node 0x%08x, rx_time=%u", mp.from, mp.rx_time); - meshtastic_NodeInfoLite *info = getOrCreateMeshNode(getFrom(&mp)); + // mp.from is unauthenticated, so rate-limit admission once the database is full: otherwise + // invented node numbers churn it at packet rate and push real neighbours out. + meshtastic_NodeInfoLite *info = getMeshNode(getFrom(&mp)); + if (!info) { + if (isFull()) { + if (Throttle::isWithinTimespanMs(lastFullEvictionMs, NODEDB_FULL_EVICTION_INTERVAL_MS)) { + LOG_DEBUG("Node database full, defer admitting 0x%08x", mp.from); + return; + } + lastFullEvictionMs = millis(); + } + info = getOrCreateMeshNode(getFrom(&mp)); + } if (!info) { return; } @@ -3700,7 +3750,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) { @@ -3716,18 +3766,81 @@ bool NodeDB::copyPublicKey(NodeNum n, meshtastic_NodeInfoLite_public_key_t &out) return false; } -bool NodeDB::hasSeenXeddsaSigner(NodeNum n) +bool NodeDB::copyPublicKey(NodeNum n, meshtastic_NodeInfoLite_public_key_t &out) { - if (nodeInfoLiteHasXeddsaSigned(getMeshNode(n))) + 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 role = 0, prot = 0; - return warmStore.lookupMeta(n, role, prot) && prot == static_cast(WarmProtected::XeddsaSigner); + 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); @@ -3827,6 +3940,23 @@ meshtastic_NodeInfoLite *NodeDB::getOrCreateMeshNode(NodeNum n) nodeInfoLiteSetBit(lite, NODEINFO_BITFIELD_HAS_XEDDSA_SIGNED_MASK, true); 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 ac6780d77..76729578f 100644 --- a/src/mesh/NodeDB.h +++ b/src/mesh/NodeDB.h @@ -360,9 +360,32 @@ class NodeDB /// tier. Returns false if we don't know a key for n. bool copyPublicKey(NodeNum n, meshtastic_NodeInfoLite_public_key_t &out); - /// Whether this node has produced a verified XEdDSA signature, including while its - /// identity is resident only in the warm tier. - bool hasSeenXeddsaSigner(NodeNum n); + /// Copy the 32-byte key for n from the AUTHORITATIVE tiers only (hot, then warm; never + /// opportunistic caches) - the pin reference for caches that mirror NodeDB's key hygiene. + bool copyPublicKeyAuthoritative(NodeNum n, meshtastic_NodeInfoLite_public_key_t &out); + + /// True if 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 @@ -533,9 +556,10 @@ class NodeDB /// skip boot keygen and skip persisting defaults, so a transient read failure can't change our NodeNum /// or overwrite the on-disk config. Cleared at the top of every loadFromDisk() run. bool configDecodeFailed = false; - uint32_t lastNodeDbSave = 0; // when we last saved our db to flash - uint32_t lastBackupAttempt = 0; // when we last tried a backup automatically or manually - uint32_t lastSort = 0; // When last sorted the nodeDB + uint32_t lastNodeDbSave = 0; // when we last saved our db to flash + uint32_t lastFullEvictionMs = 0; // when we last evicted to admit a new node, once the db is full + uint32_t lastBackupAttempt = 0; // when we last tried a backup automatically or manually + uint32_t lastSort = 0; // When last sorted the nodeDB /* * Internal boolean to track sorting paused diff --git a/src/mesh/ProtobufModule.h b/src/mesh/ProtobufModule.h index 42d80d5d6..1d1441c4d 100644 --- a/src/mesh/ProtobufModule.h +++ b/src/mesh/ProtobufModule.h @@ -44,6 +44,8 @@ template class ProtobufModule : protected SinglePortModule { // Update our local node info with our position (even if we don't decide to update anyone else) meshtastic_MeshPacket *p = allocDataPacket(); + if (!p) + return nullptr; p->decoded.payload.size = pb_encode_to_bytes(p->decoded.payload.bytes, sizeof(p->decoded.payload.bytes), fields, &payload); diff --git a/src/mesh/Router.cpp b/src/mesh/Router.cpp index 118d434d4..a31c1d97e 100644 --- a/src/mesh/Router.cpp +++ b/src/mesh/Router.cpp @@ -16,7 +16,6 @@ #include #include #if HAS_TRAFFIC_MANAGEMENT -#include "modules/TrafficManagementModule.h" #endif #if HAS_VARIABLE_HOPS #include "modules/HopScalingModule.h" @@ -271,6 +270,8 @@ PacketId generatePacketId() meshtastic_MeshPacket *Router::allocForSending() { meshtastic_MeshPacket *p = packetPool.allocZeroed(); + if (!p) + return nullptr; p->which_payload_variant = meshtastic_MeshPacket_decoded_tag; // Assume payload is decoded at start. p->from = nodeDB->getNodeNum(); @@ -663,11 +664,15 @@ bool checkXeddsaReceivePolicy(meshtastic_MeshPacket *p) if (compatible) return true; - // In Balanced, preserve legacy unsigned-unicast compatibility and only reject a signable - // unsigned broadcast from a known signer. Canonical sizing removes unknown protobuf - // fields before mirroring the sender-side signedDataFits() gate. Oversized broadcasts - // remain compatible. - if (nodeDB->hasSeenXeddsaSigner(p->from) && isBroadcast(p->to)) { + // In Balanced, preserve legacy unsigned-unicast compatibility and only reject the class a + // signing node always signs: a non-PKI broadcast whose signed encoding would still fit the + // LoRa frame. Canonical sizing removes unknown protobuf fields before mirroring the + // sender-side signedDataFits() gate, so this counts the same fields that gate counted. + // Unicast 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 (PKI already returned above). + // 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) && isBroadcast(p->to)) { size_t canonicalSize; if (!canonicalSignableSize(&p->decoded, &canonicalSize)) return true; // can't size it; never drop on a sizing failure @@ -730,6 +735,54 @@ RoutingAuthVerdict passesRoutingAuthGate(meshtastic_MeshPacket *p) return RoutingAuthVerdict::ACCEPT; } +#if !(MESHTASTIC_EXCLUDE_PKI) +// The fallback costs three X25519 ops before the AEAD tag is checked. Budget is global because p->from is +// attacker-controlled; successful runs refund, and their key is then persisted for the fast path. +#define ADMIN_KEY_FALLBACK_BURST 8 +#define ADMIN_KEY_FALLBACK_REFILL_MS 250 + +static uint32_t adminKeyFallbackTokens = ADMIN_KEY_FALLBACK_BURST; +static uint32_t adminKeyFallbackRefillMs = 0; + +static bool adminKeyFallbackAllowed() +{ + bool haveAdminKey = false; + for (int i = 0; i < 3; i++) { + if (config.security.admin_key[i].size == 32) { + haveAdminKey = true; + break; + } + } + if (!haveAdminKey) + return false; // nothing to try, so do not spend a token + + uint32_t now = millis(); + if (adminKeyFallbackRefillMs == 0) + adminKeyFallbackRefillMs = now; + uint32_t elapsed = now - adminKeyFallbackRefillMs; + if (elapsed >= ADMIN_KEY_FALLBACK_REFILL_MS) { + uint32_t refill = elapsed / ADMIN_KEY_FALLBACK_REFILL_MS; + adminKeyFallbackRefillMs += refill * ADMIN_KEY_FALLBACK_REFILL_MS; + if (refill >= ADMIN_KEY_FALLBACK_BURST - adminKeyFallbackTokens) + adminKeyFallbackTokens = ADMIN_KEY_FALLBACK_BURST; + else + adminKeyFallbackTokens += refill; + } + + if (adminKeyFallbackTokens == 0) + return false; + + adminKeyFallbackTokens--; + return true; +} + +static void adminKeyFallbackRefund() +{ + if (adminKeyFallbackTokens < ADMIN_KEY_FALLBACK_BURST) + adminKeyFallbackTokens++; +} +#endif + DecodeState perhapsDecode(meshtastic_MeshPacket *p) { concurrency::LockGuard g(cryptLock); @@ -757,33 +810,48 @@ DecodeState perhapsDecode(meshtastic_MeshPacket *p) bool matchedChannel = 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) { pkiAttempted = true; LOG_DEBUG("Attempt PKI decryption"); + // 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); + // A pending key is an unverified identity claim supplied by whoever opened the handshake, so it is + // accepted only for the exchange itself (checked after decode). perhapsEncode applies the same rule. + bool havePendingKey = false; + if (!haveRemoteKey) { + havePendingKey = crypto->getPendingPublicKey(p->from, remotePublic); + haveRemoteKey = havePendingKey; + } // 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; + bool viaPendingKey = false; if (haveRemoteKey && crypto->decryptCurve25519(p->from, remotePublic, p->id, rawSize, p->encrypted.bytes, bytes)) { decrypted = true; + viaPendingKey = havePendingKey; } - for (int i = 0; i < 3 && !decrypted; i++) { - if (config.security.admin_key[i].size != 32) - continue; - remotePublic.size = 32; - memcpy(remotePublic.bytes, config.security.admin_key[i].bytes, 32); + if (!decrypted && adminKeyFallbackAllowed()) { + for (int i = 0; i < 3 && !decrypted; i++) { + if (config.security.admin_key[i].size != 32) + continue; + remotePublic.size = 32; + memcpy(remotePublic.bytes, config.security.admin_key[i].bytes, 32); - if (crypto->decryptCurve25519(p->from, remotePublic, p->id, rawSize, p->encrypted.bytes, bytes)) { - decrypted = true; - viaAdminKey = true; - break; // stop after first successful decryption + if (crypto->decryptCurve25519(p->from, remotePublic, p->id, rawSize, p->encrypted.bytes, bytes)) { + decrypted = true; + viaAdminKey = true; + break; // stop after first successful decryption + } } + if (decrypted) + adminKeyFallbackRefund(); } if (decrypted) { LOG_INFO("PKI Decryption worked!"); @@ -792,6 +860,12 @@ DecodeState perhapsDecode(meshtastic_MeshPacket *p) size_t payloadSize = rawSize - MESHTASTIC_PKC_OVERHEAD; if (pb_decode_from_bytes(bytes, payloadSize, &meshtastic_Data_msg, &decodedtmp) && decodedtmp.portnum != meshtastic_PortNum_UNKNOWN_APP) { + if (viaPendingKey && decodedtmp.portnum != meshtastic_PortNum_KEY_VERIFICATION_APP) { + // The pending key only proves the handshake initiator holds it, not that they are + // p->from. Beyond the exchange it would let them send DMs that look authenticated. + LOG_WARN("Refusing pending-key decrypt of port %u from 0x%08x", (unsigned)decodedtmp.portnum, p->from); + return DecodeState::DECODE_FAILURE; + } decrypted = true; rawSize = payloadSize; // commit the overhead subtraction only on full success LOG_INFO("Packet decrypted using PKI!"); @@ -802,10 +876,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/SinglePortModule.h b/src/mesh/SinglePortModule.h index e43de09d1..d20a9b7e4 100644 --- a/src/mesh/SinglePortModule.h +++ b/src/mesh/SinglePortModule.h @@ -8,14 +8,11 @@ */ class SinglePortModule : public MeshModule { - protected: - meshtastic_PortNum ourPortNum; - public: /** Constructor * name is for debugging output */ - SinglePortModule(const char *_name, meshtastic_PortNum _ourPortNum) : MeshModule(_name), ourPortNum(_ourPortNum) {} + SinglePortModule(const char *_name, meshtastic_PortNum _ourPortNum) : MeshModule(_name, _ourPortNum) {} protected: /** @@ -32,8 +29,10 @@ class SinglePortModule : public MeshModule { // Update our local node info with our position (even if we don't decide to update anyone else) meshtastic_MeshPacket *p = router->allocForSending(); + if (!p) + return nullptr; p->decoded.portnum = ourPortNum; return p; } -}; \ No newline at end of file +}; diff --git a/src/mesh/WarmNodeStore.cpp b/src/mesh/WarmNodeStore.cpp index cce88e6b0..d36cd67db 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 b19ea782d..26c6f3f0c 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/AdminModule.cpp b/src/modules/AdminModule.cpp index 9d723a32e..cd33f34aa 100644 --- a/src/modules/AdminModule.cpp +++ b/src/modules/AdminModule.cpp @@ -1,5 +1,6 @@ #include "AdminModule.h" #include "Channels.h" +#include "CryptoEngine.h" #include "DisplayFormatters.h" #include "HardwareRNG.h" #include "MeshService.h" @@ -928,11 +929,15 @@ void AdminModule::handleSetConfig(const meshtastic_Config &c, bool fromOthers) config.power.on_battery_shutdown_after_secs = 30; } break; - case meshtastic_Config_network_tag: + case meshtastic_Config_network_tag: { LOG_INFO("Set config: WiFi"); config.has_network = true; + char prevPsk[sizeof(config.network.wifi_psk)]; + memcpy(prevPsk, config.network.wifi_psk, sizeof(prevPsk)); config.network = c.payload_variant.network; + writeSecret(config.network.wifi_psk, sizeof(config.network.wifi_psk), prevPsk); break; + } case meshtastic_Config_display_tag: LOG_INFO("Set config: Display"); config.has_display = true; @@ -1203,7 +1208,12 @@ bool AdminModule::handleSetModuleConfig(const meshtastic_ModuleConfig &c) // Disable Bluetooth to prevent interference during MQTT configuration disableBluetooth(); moduleConfig.has_mqtt = true; - moduleConfig.mqtt = c.payload_variant.mqtt; + { + char prevPass[sizeof(moduleConfig.mqtt.password)]; + memcpy(prevPass, moduleConfig.mqtt.password, sizeof(prevPass)); + moduleConfig.mqtt = c.payload_variant.mqtt; + writeSecret(moduleConfig.mqtt.password, sizeof(moduleConfig.mqtt.password), prevPass); + } #endif break; case meshtastic_ModuleConfig_serial_tag: @@ -1426,6 +1436,9 @@ void AdminModule::handleGetOwner(const meshtastic_MeshPacket &req) res.which_payload_variant = meshtastic_AdminMessage_get_owner_response_tag; setPassKey(&res); myReply = allocDataProtobuf(res); + if (!myReply) { + return; + } if (req.pki_encrypted) { myReply->pki_encrypted = true; } @@ -1457,8 +1470,9 @@ void AdminModule::handleGetConfig(const meshtastic_MeshPacket &req, const uint32 LOG_INFO("Get config: Network"); res.get_config_response.which_payload_variant = meshtastic_Config_network_tag; res.get_config_response.payload_variant.network = config.network; - writeSecret(res.get_config_response.payload_variant.network.wifi_psk, - sizeof(res.get_config_response.payload_variant.network.wifi_psk), config.network.wifi_psk); + if (req.from != 0) + strncpy(res.get_config_response.payload_variant.network.wifi_psk, secretReserved, + sizeof(res.get_config_response.payload_variant.network.wifi_psk)); break; case meshtastic_AdminMessage_ConfigType_DISPLAY_CONFIG: LOG_INFO("Get config: Display"); @@ -1509,6 +1523,9 @@ void AdminModule::handleGetConfig(const meshtastic_MeshPacket &req, const uint32 res.which_payload_variant = meshtastic_AdminMessage_get_config_response_tag; setPassKey(&res); myReply = allocDataProtobuf(res); + if (!myReply) { + return; + } if (req.pki_encrypted) { myReply->pki_encrypted = true; } @@ -1526,6 +1543,9 @@ void AdminModule::handleGetModuleConfig(const meshtastic_MeshPacket &req, const configName = "MQTT"; res.get_module_config_response.which_payload_variant = meshtastic_ModuleConfig_mqtt_tag; res.get_module_config_response.payload_variant.mqtt = moduleConfig.mqtt; + if (req.from != 0) + strncpy(res.get_module_config_response.payload_variant.mqtt.password, secretReserved, + sizeof(res.get_module_config_response.payload_variant.mqtt.password)); break; case meshtastic_AdminMessage_ModuleConfigType_SERIAL_CONFIG: configName = "Serial"; @@ -1618,6 +1638,9 @@ void AdminModule::handleGetModuleConfig(const meshtastic_MeshPacket &req, const res.which_payload_variant = meshtastic_AdminMessage_get_module_config_response_tag; setPassKey(&res); myReply = allocDataProtobuf(res); + if (!myReply) { + return; + } if (req.pki_encrypted) { myReply->pki_encrypted = true; } @@ -1645,6 +1668,9 @@ void AdminModule::handleGetNodeRemoteHardwarePins(const meshtastic_MeshPacket &r } setPassKey(&r); myReply = allocDataProtobuf(r); + if (!myReply) { + return; + } if (req.pki_encrypted) { myReply->pki_encrypted = true; } @@ -1664,6 +1690,9 @@ void AdminModule::handleGetDeviceMetadata(const meshtastic_MeshPacket &req) r.which_payload_variant = meshtastic_AdminMessage_get_device_metadata_response_tag; setPassKey(&r); myReply = allocDataProtobuf(r); + if (!myReply) { + return; + } if (req.pki_encrypted) { myReply->pki_encrypted = true; } @@ -1739,6 +1768,9 @@ void AdminModule::handleGetDeviceConnectionStatus(const meshtastic_MeshPacket &r r.which_payload_variant = meshtastic_AdminMessage_get_device_connection_status_response_tag; setPassKey(&r); myReply = allocDataProtobuf(r); + if (!myReply) { + return; + } if (req.pki_encrypted) { myReply->pki_encrypted = true; } @@ -1753,6 +1785,9 @@ void AdminModule::handleGetChannel(const meshtastic_MeshPacket &req, uint32_t ch r.which_payload_variant = meshtastic_AdminMessage_get_channel_response_tag; setPassKey(&r); myReply = allocDataProtobuf(r); + if (!myReply) { + return; + } if (req.pki_encrypted) { myReply->pki_encrypted = true; } @@ -1765,6 +1800,9 @@ void AdminModule::handleGetDeviceUIConfig(const meshtastic_MeshPacket &req) r.which_payload_variant = meshtastic_AdminMessage_get_ui_config_response_tag; r.get_ui_config_response = uiconfig; myReply = allocDataProtobuf(r); + if (!myReply) { + return; + } if (req.pki_encrypted) { myReply->pki_encrypted = true; } @@ -1859,8 +1897,14 @@ void AdminModule::setPassKey(meshtastic_AdminMessage *res) if (!sessionPasskeyValid || !Throttle::isWithinTimespanMs(session_time, 150 * 1000UL)) { // Session passkey authenticates admin replies, so it must be unpredictable: prefer the // hardware RNG, falling back to the seeded CSPRNG only when no hardware source exists. - if (!HardwareRNG::fill(session_passkey, sizeof(session_passkey))) - CryptRNG.rand(session_passkey, sizeof(session_passkey)); + // Hold cryptLock like the signing path does: this runs on the admin receive path, which on + // nRF52 is the BLE task, and the fill toggles the CC310 that packet crypto also uses while + // the CryptRNG state is shared with signing. + { + concurrency::LockGuard g(cryptLock); + if (!HardwareRNG::fill(session_passkey, sizeof(session_passkey))) + CryptRNG.rand(session_passkey, sizeof(session_passkey)); + } session_time = millis(); sessionPasskeyValid = true; } diff --git a/src/modules/CannedMessageModule.cpp b/src/modules/CannedMessageModule.cpp index dada48193..d86878a8d 100644 --- a/src/modules/CannedMessageModule.cpp +++ b/src/modules/CannedMessageModule.cpp @@ -1022,6 +1022,8 @@ void CannedMessageModule::sendText(NodeNum dest, ChannelIndex channel, const cha lastDestSet = true; meshtastic_MeshPacket *p = allocDataPacket(); + if (!p) + return; p->to = dest; p->channel = channel; p->want_ack = true; @@ -1054,7 +1056,7 @@ void CannedMessageModule::sendText(NodeNum dest, ChannelIndex channel, const cha p->decoded.payload.size = strlen(message); memcpy(p->decoded.payload.bytes, message, p->decoded.payload.size); - if (moduleConfig.canned_message.send_bell && p->decoded.payload.size < meshtastic_Constants_DATA_PAYLOAD_LEN) { + if (moduleConfig.canned_message.send_bell && p->decoded.payload.size + 1 < meshtastic_Constants_DATA_PAYLOAD_LEN) { p->decoded.payload.bytes[p->decoded.payload.size++] = 7; p->decoded.payload.bytes[p->decoded.payload.size] = '\0'; } diff --git a/src/modules/CannedMessageModule.h b/src/modules/CannedMessageModule.h index 67334dd5e..fe12bd468 100644 --- a/src/modules/CannedMessageModule.h +++ b/src/modules/CannedMessageModule.h @@ -72,6 +72,7 @@ class CannedMessageModule : public SinglePortModule, public Observablewant_ack = false; p->decoded.payload.size = strlen(message); memcpy(p->decoded.payload.bytes, message, p->decoded.payload.size); - if (moduleConfig.detection_sensor.send_bell && p->decoded.payload.size < meshtastic_Constants_DATA_PAYLOAD_LEN) { + if (moduleConfig.detection_sensor.send_bell && p->decoded.payload.size + 1 < meshtastic_Constants_DATA_PAYLOAD_LEN) { p->decoded.payload.bytes[p->decoded.payload.size] = 7; // Bell character p->decoded.payload.bytes[p->decoded.payload.size + 1] = '\0'; // Bell character p->decoded.payload.size++; @@ -153,6 +157,10 @@ void DetectionSensorModule::sendCurrentStateMessage(bool state) char *message = new char[40]; sprintf(message, "%s state: %i", moduleConfig.detection_sensor.name, state); meshtastic_MeshPacket *p = allocDataPacket(); + if (!p) { + delete[] message; + return; + } p->want_ack = false; p->decoded.payload.size = strlen(message); memcpy(p->decoded.payload.bytes, message, p->decoded.payload.size); diff --git a/src/modules/DropzoneModule.cpp b/src/modules/DropzoneModule.cpp index 4b8f2fec8..2340f31f2 100644 --- a/src/modules/DropzoneModule.cpp +++ b/src/modules/DropzoneModule.cpp @@ -68,6 +68,8 @@ meshtastic_MeshPacket *DropzoneModule::sendConditions() // the dropzone is open auto dropzoneStatus = analogRead(A1) < 100 ? "OPEN" : "CLOSED"; auto reply = allocDataPacket(); + if (!reply) + return nullptr; auto node = nodeDB->getMeshNode(nodeDB->getNodeNum()); if (sensor.hasSensor()) { diff --git a/src/modules/KeyVerificationModule.cpp b/src/modules/KeyVerificationModule.cpp index c275ce1d9..adc05531f 100644 --- a/src/modules/KeyVerificationModule.cpp +++ b/src/modules/KeyVerificationModule.cpp @@ -6,12 +6,19 @@ #include "gps/RTC.h" #include "graphics/draw/MenuHandler.h" #include "main.h" +#include "mesh/Throttle.h" #include "meshUtils.h" #include "modules/AdminModule.h" #include "modules/NodeInfoModule.h" #include #include +#define KEY_VERIFICATION_TIMEOUT_SECS 60 +// Hard cap on one session, independent of the refreshable idle timeout above. +#define KEY_VERIFICATION_MAX_SESSION_MS (3 * 60 * 1000UL) +// Minimum spacing between remote-initiated sessions. +#define KEY_VERIFICATION_REMOTE_COOLDOWN_MS (60 * 1000UL) + KeyVerificationModule *keyVerificationModule; namespace @@ -64,7 +71,8 @@ AdminMessageHandleResult KeyVerificationModule::handleAdminMessageForModule(cons bool KeyVerificationModule::handleReceivedProtobuf(const meshtastic_MeshPacket &mp, meshtastic_KeyVerification *r) { - updateState(); + // No refresh here: this runs before the sender is checked, so any node could hold the session open. + updateState(false); // Note: pki_encrypted is not required here. The first response (M2) may arrive channel-encrypted in // the bootstrap case; the follow-on hash1 packet (M3) is required to be PKI in its branch below. if (mp.from != currentRemoteNode) { // because the inital connection request is handled in allocReply() @@ -98,6 +106,7 @@ bool KeyVerificationModule::handleReceivedProtobuf(const meshtastic_MeshPacket & service->sendClientNotification(cn); } LOG_INFO("Received hash2"); + currentNonceTimestamp = getTime(); // the protocol advanced, so extend the deadline currentState = KEY_VERIFICATION_SENDER_AWAITING_NUMBER; return true; @@ -134,6 +143,7 @@ bool KeyVerificationModule::handleReceivedProtobuf(const meshtastic_MeshPacket & service->sendClientNotification(cn); } + currentNonceTimestamp = getTime(); // the protocol advanced, so extend the deadline currentState = KEY_VERIFICATION_RECEIVER_AWAITING_USER; return true; } @@ -151,8 +161,16 @@ bool KeyVerificationModule::sendInitialRequest(NodeNum remoteNode) return false; } updateState(true); - currentNonce = random(); + // The nonce binds the handshake, so draw it from the hardware RNG (falling back to the CSPRNG) + // under cryptLock, as allocReply does for the security number. random() is both predictable and + // only 32 bits wide, leaving half of this nonce zero. + { + concurrency::LockGuard g(cryptLock); + if (!HardwareRNG::fill((uint8_t *)¤tNonce, sizeof(currentNonce))) + CryptRNG.rand((uint8_t *)¤tNonce, sizeof(currentNonce)); + } currentNonceTimestamp = getTime(); + sessionStartedMs = millis(); currentRemoteNode = remoteNode; meshtastic_KeyVerification KeyVerification = meshtastic_KeyVerification_init_zero; KeyVerification.nonce = currentNonce; @@ -162,6 +180,8 @@ bool KeyVerificationModule::sendInitialRequest(NodeNum remoteNode) KeyVerification.hash1.size = 32; memcpy(KeyVerification.hash1.bytes, owner.public_key.bytes, 32); meshtastic_MeshPacket *p = allocDataProtobuf(KeyVerification); + if (!p) + return false; p->to = remoteNode; p->channel = 0; // Only request PKI when we already hold the destination's key. Otherwise this first message goes out @@ -181,10 +201,19 @@ meshtastic_MeshPacket *KeyVerificationModule::allocReply() SHA256 hash; NodeNum ourNodeNum = nodeDB->getNodeNum(); updateState(); - if (currentState != KEY_VERIFICATION_IDLE) { // TODO: cooldown period + if (currentState != KEY_VERIFICATION_IDLE) { LOG_WARN("Key Verification requested, but already in a request"); + ignoreRequest = true; // do not let the busy path emit a NAK back to the requester return nullptr; } + // Opening a session raises a banner and locks the only slot, before the peer has authenticated. + if (lastRemoteSessionMs != 0 && Throttle::isWithinTimespanMs(lastRemoteSessionMs, KEY_VERIFICATION_REMOTE_COOLDOWN_MS)) { + LOG_WARN("Key Verification requested, but within cooldown"); + ignoreRequest = true; + return nullptr; + } + sessionStartedMs = millis(); + sessionFromRemote = true; currentState = KEY_VERIFICATION_RECEIVER_AWAITING_HASH1; auto req = *currentRequest; @@ -318,6 +347,8 @@ void KeyVerificationModule::processSecurityNumber(uint32_t incomingNumber) KeyVerification.hash1.size = 32; memcpy(KeyVerification.hash1.bytes, hash1, 32); meshtastic_MeshPacket *p = allocDataProtobuf(KeyVerification); + if (!p) + return; p->to = currentRemoteNode; p->channel = 0; p->pki_encrypted = true; @@ -346,11 +377,14 @@ void KeyVerificationModule::processSecurityNumber(uint32_t incomingNumber) void KeyVerificationModule::updateState(bool resetTimer) { if (currentState != KEY_VERIFICATION_IDLE) { - // check for the 60 second timeout - if (currentNonceTimestamp < getTime() - 60) { + uint32_t now = getTime(); + // Absolute cap first: it is millis() based, so it bounds the session even when the RTC is unset. + if (!Throttle::isWithinTimespanMs(sessionStartedMs, KEY_VERIFICATION_MAX_SESSION_MS)) { + resetToIdle(); + } else if (now - currentNonceTimestamp >= KEY_VERIFICATION_TIMEOUT_SECS) { resetToIdle(); } else if (resetTimer) { - currentNonceTimestamp = getTime(); + currentNonceTimestamp = now; } } } @@ -359,8 +393,12 @@ void KeyVerificationModule::resetToIdle() { memset(hash1, 0, 32); memset(hash2, 0, 32); + if (sessionFromRemote) + lastRemoteSessionMs = millis(); // start the cooldown when the session ends, not when it opened + sessionFromRemote = false; currentNonce = 0; currentNonceTimestamp = 0; + sessionStartedMs = 0; currentSecurityNumber = 0; currentRemoteNode = 0; currentState = KEY_VERIFICATION_IDLE; @@ -383,6 +421,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/KeyVerificationModule.h b/src/modules/KeyVerificationModule.h index 9496649d3..d33a89c5e 100644 --- a/src/modules/KeyVerificationModule.h +++ b/src/modules/KeyVerificationModule.h @@ -84,6 +84,12 @@ class KeyVerificationModule : public ProtobufModule private: uint64_t currentNonce = 0; uint32_t currentNonceTimestamp = 0; + // millis() the session opened, never refreshed, so a peer cannot hold the slot open indefinitely. + uint32_t sessionStartedMs = 0; + // millis() a remote-initiated session last ended. Spacing sessions bounds the slot DoS and the + // banner spam; stamped at the end rather than the start so the cooldown is a real gap. + uint32_t lastRemoteSessionMs = 0; + bool sessionFromRemote = false; NodeNum currentRemoteNode = 0; uint32_t currentSecurityNumber = 0; KeyVerificationState currentState = KEY_VERIFICATION_IDLE; diff --git a/src/modules/NeighborInfoModule.cpp b/src/modules/NeighborInfoModule.cpp index a626bbcaa..f42194a81 100644 --- a/src/modules/NeighborInfoModule.cpp +++ b/src/modules/NeighborInfoModule.cpp @@ -110,6 +110,8 @@ void NeighborInfoModule::sendNeighborInfo(NodeNum dest, bool wantReplies) // only send neighbours if we have some to send if (neighborInfo.neighbors_count > 0) { meshtastic_MeshPacket *p = allocDataProtobuf(neighborInfo); + if (!p) + return; p->to = dest; p->decoded.want_response = wantReplies; p->priority = meshtastic_MeshPacket_Priority_BACKGROUND; diff --git a/src/modules/PositionModule.cpp b/src/modules/PositionModule.cpp index 18931da12..40055006a 100644 --- a/src/modules/PositionModule.cpp +++ b/src/modules/PositionModule.cpp @@ -292,6 +292,8 @@ meshtastic_MeshPacket *PositionModule::allocAtakPli() { LOG_INFO("Send TAK V2 PLI packet"); meshtastic_MeshPacket *mp = allocDataPacket(); + if (!mp) + return nullptr; mp->decoded.portnum = meshtastic_PortNum_ATAK_PLUGIN_V2; meshtastic_TAKPacketV2 takPacket = meshtastic_TAKPacketV2_init_zero; @@ -572,6 +574,8 @@ int32_t PositionModule::runOnce() void PositionModule::sendLostAndFoundText() { meshtastic_MeshPacket *p = allocDataPacket(); + if (!p) + return; p->to = NODENUM_BROADCAST; char message[128]; int written = snprintf(message, sizeof(message), "🚨I'm lost! Lat / Lon: %f, %f\a", (lastGpsLatitude * 1e-7), diff --git a/src/modules/RangeTestModule.cpp b/src/modules/RangeTestModule.cpp index a02f0594e..dad748699 100644 --- a/src/modules/RangeTestModule.cpp +++ b/src/modules/RangeTestModule.cpp @@ -114,6 +114,8 @@ int32_t RangeTestModule::runOnce() void RangeTestModuleRadio::sendPayload(NodeNum dest, bool wantReplies) { meshtastic_MeshPacket *p = allocDataPacket(); + if (!p) + return; p->to = dest; p->decoded.want_response = wantReplies; p->hop_limit = 0; diff --git a/src/modules/ReplyBotModule.cpp b/src/modules/ReplyBotModule.cpp index 3f8788735..52934c800 100644 --- a/src/modules/ReplyBotModule.cpp +++ b/src/modules/ReplyBotModule.cpp @@ -168,6 +168,8 @@ void ReplyBotModule::sendDm(const meshtastic_MeshPacket &rx, const char *text) if (!text) return; meshtastic_MeshPacket *p = allocDataPacket(); + if (!p) + return; p->to = rx.from; p->channel = rx.channel; p->want_ack = false; diff --git a/src/modules/ReplyModule.cpp b/src/modules/ReplyModule.cpp index c48b16d53..7a97c0c68 100644 --- a/src/modules/ReplyModule.cpp +++ b/src/modules/ReplyModule.cpp @@ -16,7 +16,9 @@ meshtastic_MeshPacket *ReplyModule::allocReply() #endif const char *replyStr = "Message Received"; - auto reply = allocDataPacket(); // Allocate a packet for sending + auto reply = allocDataPacket(); // Allocate a packet for sending + if (!reply) + return nullptr; reply->decoded.payload.size = strlen(replyStr); // You must specify how many bytes are in the reply memcpy(reply->decoded.payload.bytes, replyStr, reply->decoded.payload.size); diff --git a/src/modules/RoutingModule.cpp b/src/modules/RoutingModule.cpp index aba0751f3..1ce7c4502 100644 --- a/src/modules/RoutingModule.cpp +++ b/src/modules/RoutingModule.cpp @@ -51,6 +51,8 @@ void RoutingModule::sendAckNak(meshtastic_Routing_Error err, NodeNum to, PacketI bool ackWantsAck) { auto p = allocAckNak(err, to, idFrom, chIndex, hopLimit); + if (!p) + return; // Allow the caller to set want_ack on this ACK packet if it's important that the ACK be delivered reliably p->want_ack = ackWantsAck; diff --git a/src/modules/SerialModule.cpp b/src/modules/SerialModule.cpp index aab79ffd5..cb481e6a5 100644 --- a/src/modules/SerialModule.cpp +++ b/src/modules/SerialModule.cpp @@ -109,7 +109,7 @@ bool SerialModule::isValidConfig(const meshtastic_ModuleConfig_SerialConfig &con return true; } -SerialModuleRadio::SerialModuleRadio() : MeshModule("SerialModuleRadio") +SerialModuleRadio::SerialModuleRadio() : SinglePortModule("SerialModuleRadio", meshtastic_PortNum_SERIAL_APP) { switch (moduleConfig.serial.mode) { case meshtastic_ModuleConfig_SerialConfig_Serial_Mode_TEXTMSG: @@ -314,6 +314,8 @@ int32_t SerialModule::runOnce() void SerialModule::sendTelemetry(meshtastic_Telemetry m) { meshtastic_MeshPacket *p = router->allocForSending(); + if (!p) + return; p->decoded.portnum = meshtastic_PortNum_TELEMETRY_APP; p->decoded.payload.size = pb_encode_to_bytes(p->decoded.payload.bytes, sizeof(p->decoded.payload.bytes), &meshtastic_Telemetry_msg, &m); @@ -328,18 +330,6 @@ void SerialModule::sendTelemetry(meshtastic_Telemetry m) service->sendToMesh(p, RX_SRC_LOCAL, true); } -/** - * Allocates a new mesh packet for use as a reply to a received packet. - * - * @return A pointer to the newly allocated mesh packet. - */ -meshtastic_MeshPacket *SerialModuleRadio::allocReply() -{ - auto reply = allocDataPacket(); // Allocate a packet for sending - - return reply; -} - /** * Sends a payload to a specified destination node. * @@ -349,7 +339,9 @@ meshtastic_MeshPacket *SerialModuleRadio::allocReply() void SerialModuleRadio::sendPayload(NodeNum dest, bool wantReplies) { const meshtastic_Channel *ch = (boundChannel != NULL) ? &channels.getByName(boundChannel) : NULL; - meshtastic_MeshPacket *p = allocReply(); + meshtastic_MeshPacket *p = allocDataPacket(); + if (!p) + return; p->to = dest; if (ch != NULL) { p->channel = ch->index; diff --git a/src/modules/SerialModule.h b/src/modules/SerialModule.h index dbe4f75db..5cbca7824 100644 --- a/src/modules/SerialModule.h +++ b/src/modules/SerialModule.h @@ -40,7 +40,7 @@ extern SerialModule *serialModule; * Radio interface for SerialModule * */ -class SerialModuleRadio : public MeshModule +class SerialModuleRadio : public SinglePortModule { uint32_t lastRxID = 0; char outbuf[90] = ""; @@ -54,29 +54,14 @@ class SerialModuleRadio : public MeshModule void sendPayload(NodeNum dest = NODENUM_BROADCAST, bool wantReplies = false); protected: - virtual meshtastic_MeshPacket *allocReply() override; - /** Called to handle a particular incoming message @return ProcessMessage::STOP if you've guaranteed you've handled this message and no other handlers should be considered for it */ virtual ProcessMessage handleReceived(const meshtastic_MeshPacket &mp) override; - - meshtastic_PortNum ourPortNum; - - virtual bool wantPacket(const meshtastic_MeshPacket *p) override { return p->decoded.portnum == ourPortNum; } - - meshtastic_MeshPacket *allocDataPacket() - { - // Update our local node info with our position (even if we don't decide to update anyone else) - meshtastic_MeshPacket *p = router->allocForSending(); - p->decoded.portnum = ourPortNum; - - return p; - } }; extern SerialModuleRadio *serialModuleRadio; -#endif \ No newline at end of file +#endif diff --git a/src/modules/StatusMessageModule.cpp b/src/modules/StatusMessageModule.cpp index 2f05517f3..bf4badc4b 100644 --- a/src/modules/StatusMessageModule.cpp +++ b/src/modules/StatusMessageModule.cpp @@ -15,6 +15,8 @@ int32_t StatusMessageModule::runOnce() strncpy(ourStatus.status, moduleConfig.statusmessage.node_status, sizeof(ourStatus.status)); ourStatus.status[sizeof(ourStatus.status) - 1] = '\0'; // ensure null termination meshtastic_MeshPacket *p = allocDataPacket(); + if (!p) + return 1000 * 12 * 60 * 60; p->decoded.payload.size = pb_encode_to_bytes(p->decoded.payload.bytes, sizeof(p->decoded.payload.bytes), meshtastic_StatusMessage_fields, &ourStatus); p->to = NODENUM_BROADCAST; diff --git a/src/modules/StoreForwardModule.cpp b/src/modules/StoreForwardModule.cpp index 3254d11a3..de1d6865c 100644 --- a/src/modules/StoreForwardModule.cpp +++ b/src/modules/StoreForwardModule.cpp @@ -248,6 +248,8 @@ meshtastic_MeshPacket *StoreForwardModule::preparePayload(NodeNum dest, uint32_t (this->packetHistory[i].to == NODENUM_BROADCAST || this->packetHistory[i].to == dest)) { meshtastic_MeshPacket *p = allocDataPacket(); + if (!p) + return nullptr; p->to = local ? this->packetHistory[i].to : dest; // PhoneAPI can handle original `to` p->from = this->packetHistory[i].from; @@ -304,6 +306,8 @@ meshtastic_MeshPacket *StoreForwardModule::preparePayload(NodeNum dest, uint32_t void StoreForwardModule::sendMessage(NodeNum dest, const meshtastic_StoreAndForward &payload) { meshtastic_MeshPacket *p = allocDataProtobuf(payload); + if (!p) + return; p->to = dest; @@ -340,6 +344,8 @@ void StoreForwardModule::sendMessage(NodeNum dest, meshtastic_StoreAndForward_Re void StoreForwardModule::sendErrorTextMessage(NodeNum dest, bool want_response) { meshtastic_MeshPacket *pr = allocDataPacket(); + if (!pr) + return; pr->to = dest; pr->priority = meshtastic_MeshPacket_Priority_BACKGROUND; pr->want_ack = false; diff --git a/src/modules/Telemetry/AirQualityTelemetry.cpp b/src/modules/Telemetry/AirQualityTelemetry.cpp index 7ab0ed3d4..9ea8d9e39 100644 --- a/src/modules/Telemetry/AirQualityTelemetry.cpp +++ b/src/modules/Telemetry/AirQualityTelemetry.cpp @@ -464,35 +464,39 @@ bool AirQualityTelemetryModule::sendTelemetry(NodeNum dest, bool phoneOnly) } meshtastic_MeshPacket *p = allocDataProtobuf(m); - p->to = dest; - p->decoded.want_response = false; - if (config.device.role == meshtastic_Config_DeviceConfig_Role_SENSOR) - p->priority = meshtastic_MeshPacket_Priority_RELIABLE; - else - p->priority = meshtastic_MeshPacket_Priority_BACKGROUND; - - // release previous packet before occupying a new spot - if (lastMeasurementPacket != nullptr) - packetPool.release(lastMeasurementPacket); - - lastMeasurementPacket = packetPool.allocCopy(*p); - if (phoneOnly) { - LOG_INFO("Sending packet to phone"); - service->sendToPhone(p); + if (!p) { + validTelemetry = false; } else { - LOG_INFO("Sending packet to mesh"); - service->sendToMesh(p, RX_SRC_LOCAL, true); + p->to = dest; + p->decoded.want_response = false; + if (config.device.role == meshtastic_Config_DeviceConfig_Role_SENSOR) + p->priority = meshtastic_MeshPacket_Priority_RELIABLE; + else + p->priority = meshtastic_MeshPacket_Priority_BACKGROUND; - if (isPowerSavingSensor()) { - meshtastic_ClientNotification *notification = clientNotificationPool.allocZeroed(); - if (notification) { - notification->level = meshtastic_LogRecord_Level_INFO; - notification->time = getValidTime(RTCQualityFromNet); - sprintf(notification->message, "Sending telemetry and sleeping for %us interval in a moment", - Default::getConfiguredOrDefaultMs(moduleConfig.telemetry.air_quality_interval, - default_telemetry_broadcast_interval_secs) / - 1000U); - service->sendClientNotification(notification); + // release previous packet before occupying a new spot + if (lastMeasurementPacket != nullptr) + packetPool.release(lastMeasurementPacket); + + lastMeasurementPacket = packetPool.allocCopy(*p); + if (phoneOnly) { + LOG_INFO("Sending packet to phone"); + service->sendToPhone(p); + } else { + LOG_INFO("Sending packet to mesh"); + service->sendToMesh(p, RX_SRC_LOCAL, true); + + if (isPowerSavingSensor()) { + meshtastic_ClientNotification *notification = clientNotificationPool.allocZeroed(); + if (notification) { + notification->level = meshtastic_LogRecord_Level_INFO; + notification->time = getValidTime(RTCQualityFromNet); + sprintf(notification->message, "Sending telemetry and sleeping for %us interval in a moment", + Default::getConfiguredOrDefaultMs(moduleConfig.telemetry.air_quality_interval, + default_telemetry_broadcast_interval_secs) / + 1000U); + service->sendClientNotification(notification); + } } } } diff --git a/src/modules/Telemetry/DeviceTelemetry.cpp b/src/modules/Telemetry/DeviceTelemetry.cpp index d8f17963d..7ae6ac615 100644 --- a/src/modules/Telemetry/DeviceTelemetry.cpp +++ b/src/modules/Telemetry/DeviceTelemetry.cpp @@ -172,6 +172,8 @@ meshtastic_Telemetry DeviceTelemetryModule::getLocalStatsTelemetry() void DeviceTelemetryModule::sendLocalStatsToPhone() { meshtastic_MeshPacket *p = allocDataProtobuf(getLocalStatsTelemetry()); + if (!p) + return; p->to = NODENUM_BROADCAST; p->decoded.want_response = false; p->priority = meshtastic_MeshPacket_Priority_BACKGROUND; @@ -191,6 +193,8 @@ bool DeviceTelemetryModule::sendTelemetry(NodeNum dest, bool phoneOnly) meshtastic_MeshPacket *p = allocDataProtobuf(telemetry); DEBUG_HEAP_AFTER("DeviceTelemetryModule::sendTelemetry", p); + if (!p) + return false; p->to = dest; p->decoded.want_response = false; p->priority = meshtastic_MeshPacket_Priority_BACKGROUND; diff --git a/src/modules/Telemetry/EnvironmentTelemetry.cpp b/src/modules/Telemetry/EnvironmentTelemetry.cpp index 63273239d..659ed3278 100644 --- a/src/modules/Telemetry/EnvironmentTelemetry.cpp +++ b/src/modules/Telemetry/EnvironmentTelemetry.cpp @@ -661,34 +661,38 @@ bool EnvironmentTelemetryModule::sendTelemetry(NodeNum dest, bool phoneOnly) m.variant.environment_metrics.soil_moisture); meshtastic_MeshPacket *p = allocDataProtobuf(m); - p->to = dest; - p->decoded.want_response = false; - if (config.device.role == meshtastic_Config_DeviceConfig_Role_SENSOR) - p->priority = meshtastic_MeshPacket_Priority_RELIABLE; - else - p->priority = meshtastic_MeshPacket_Priority_BACKGROUND; - // release previous packet before occupying a new spot - if (lastMeasurementPacket != nullptr) - packetPool.release(lastMeasurementPacket); - - lastMeasurementPacket = packetPool.allocCopy(*p); - if (phoneOnly) { - LOG_INFO("Send packet to phone"); - service->sendToPhone(p); + if (!p) { + validTelemetry = false; } else { - LOG_INFO("Send packet to mesh"); - service->sendToMesh(p, RX_SRC_LOCAL, true); + p->to = dest; + p->decoded.want_response = false; + if (config.device.role == meshtastic_Config_DeviceConfig_Role_SENSOR) + p->priority = meshtastic_MeshPacket_Priority_RELIABLE; + else + p->priority = meshtastic_MeshPacket_Priority_BACKGROUND; + // release previous packet before occupying a new spot + if (lastMeasurementPacket != nullptr) + packetPool.release(lastMeasurementPacket); - if (isPowerSavingSensor()) { - meshtastic_ClientNotification *notification = clientNotificationPool.allocZeroed(); - if (notification) { - notification->level = meshtastic_LogRecord_Level_INFO; - notification->time = getValidTime(RTCQualityFromNet); - sprintf(notification->message, "Sending telemetry and sleeping for %us interval in a moment", - Default::getConfiguredOrDefaultMs(moduleConfig.telemetry.environment_update_interval, - default_telemetry_broadcast_interval_secs) / - 1000U); - service->sendClientNotification(notification); + lastMeasurementPacket = packetPool.allocCopy(*p); + if (phoneOnly) { + LOG_INFO("Send packet to phone"); + service->sendToPhone(p); + } else { + LOG_INFO("Send packet to mesh"); + service->sendToMesh(p, RX_SRC_LOCAL, true); + + if (isPowerSavingSensor()) { + meshtastic_ClientNotification *notification = clientNotificationPool.allocZeroed(); + if (notification) { + notification->level = meshtastic_LogRecord_Level_INFO; + notification->time = getValidTime(RTCQualityFromNet); + sprintf(notification->message, "Sending telemetry and sleeping for %us interval in a moment", + Default::getConfiguredOrDefaultMs(moduleConfig.telemetry.environment_update_interval, + default_telemetry_broadcast_interval_secs) / + 1000U); + service->sendClientNotification(notification); + } } } } diff --git a/src/modules/Telemetry/HealthTelemetry.cpp b/src/modules/Telemetry/HealthTelemetry.cpp index f68c92e1b..944bc4db4 100644 --- a/src/modules/Telemetry/HealthTelemetry.cpp +++ b/src/modules/Telemetry/HealthTelemetry.cpp @@ -248,23 +248,27 @@ bool HealthTelemetryModule::sendTelemetry(NodeNum dest, bool phoneOnly) sensor_read_error_count = 0; meshtastic_MeshPacket *p = allocDataProtobuf(m); - p->to = dest; - p->decoded.want_response = false; - if (config.device.role == meshtastic_Config_DeviceConfig_Role_SENSOR) - p->priority = meshtastic_MeshPacket_Priority_RELIABLE; - else - p->priority = meshtastic_MeshPacket_Priority_BACKGROUND; - // release previous packet before occupying a new spot - if (lastMeasurementPacket != nullptr) - packetPool.release(lastMeasurementPacket); - - lastMeasurementPacket = packetPool.allocCopy(*p); - if (phoneOnly) { - LOG_INFO("Send packet to phone"); - service->sendToPhone(p); + if (!p) { + validTelemetry = false; } else { - LOG_INFO("Send packet to mesh"); - service->sendToMesh(p, RX_SRC_LOCAL, true); + p->to = dest; + p->decoded.want_response = false; + if (config.device.role == meshtastic_Config_DeviceConfig_Role_SENSOR) + p->priority = meshtastic_MeshPacket_Priority_RELIABLE; + else + p->priority = meshtastic_MeshPacket_Priority_BACKGROUND; + // release previous packet before occupying a new spot + if (lastMeasurementPacket != nullptr) + packetPool.release(lastMeasurementPacket); + + lastMeasurementPacket = packetPool.allocCopy(*p); + if (phoneOnly) { + LOG_INFO("Send packet to phone"); + service->sendToPhone(p); + } else { + LOG_INFO("Send packet to mesh"); + service->sendToMesh(p, RX_SRC_LOCAL, true); + } } } diff --git a/src/modules/Telemetry/HostMetrics.cpp b/src/modules/Telemetry/HostMetrics.cpp index 577132006..a9490bc10 100644 --- a/src/modules/Telemetry/HostMetrics.cpp +++ b/src/modules/Telemetry/HostMetrics.cpp @@ -128,6 +128,8 @@ bool HostMetricsModule::sendMetrics() // telemetry.variant.host_metrics.has_user_string ? telemetry.variant.host_metrics.user_string : ""); meshtastic_MeshPacket *p = allocDataProtobuf(telemetry); + if (!p) + return false; p->to = NODENUM_BROADCAST; p->decoded.want_response = false; p->priority = meshtastic_MeshPacket_Priority_BACKGROUND; diff --git a/src/modules/Telemetry/PowerTelemetry.cpp b/src/modules/Telemetry/PowerTelemetry.cpp index 816f02898..23ef56ba8 100644 --- a/src/modules/Telemetry/PowerTelemetry.cpp +++ b/src/modules/Telemetry/PowerTelemetry.cpp @@ -275,23 +275,27 @@ bool PowerTelemetryModule::sendTelemetry(NodeNum dest, bool phoneOnly) sensor_read_error_count = 0; meshtastic_MeshPacket *p = allocDataProtobuf(m); - p->to = dest; - p->decoded.want_response = false; - if (config.device.role == meshtastic_Config_DeviceConfig_Role_SENSOR) - p->priority = meshtastic_MeshPacket_Priority_RELIABLE; - else - p->priority = meshtastic_MeshPacket_Priority_BACKGROUND; - // release previous packet before occupying a new spot - if (lastMeasurementPacket != nullptr) - packetPool.release(lastMeasurementPacket); - - lastMeasurementPacket = packetPool.allocCopy(*p); - if (phoneOnly) { - LOG_INFO("Send packet to phone"); - service->sendToPhone(p); + if (!p) { + validTelemetry = false; } else { - LOG_INFO("Send packet to mesh"); - service->sendToMesh(p, RX_SRC_LOCAL, true); + p->to = dest; + p->decoded.want_response = false; + if (config.device.role == meshtastic_Config_DeviceConfig_Role_SENSOR) + p->priority = meshtastic_MeshPacket_Priority_RELIABLE; + else + p->priority = meshtastic_MeshPacket_Priority_BACKGROUND; + // release previous packet before occupying a new spot + if (lastMeasurementPacket != nullptr) + packetPool.release(lastMeasurementPacket); + + lastMeasurementPacket = packetPool.allocCopy(*p); + if (phoneOnly) { + LOG_INFO("Send packet to phone"); + service->sendToPhone(p); + } else { + LOG_INFO("Send packet to mesh"); + service->sendToMesh(p, RX_SRC_LOCAL, true); + } } } diff --git a/src/modules/TraceRouteModule.cpp b/src/modules/TraceRouteModule.cpp index 11019dcf9..7cfe3259f 100644 --- a/src/modules/TraceRouteModule.cpp +++ b/src/modules/TraceRouteModule.cpp @@ -305,6 +305,15 @@ void TraceRouteModule::updateNextHops(const meshtastic_MeshPacket &p, meshtastic } uint8_t nextHopByte = nodeDB->getLastByteOfNodeNum(nextHop); + // The route array is unauthenticated payload, so only learn from it when the node it names as our + // next hop is the one that actually relayed this packet to us. Otherwise a forged response could + // point any node's next_hop anywhere. relay_node is 0 for MQTT-sourced packets, which cannot + // corroborate an RF route either. + if (p.relay_node == NO_RELAY_NODE || nextHopByte != p.relay_node) { + LOG_DEBUG("Ignore traceroute next-hop 0x%02x, packet was relayed by 0x%02x", nextHopByte, p.relay_node); + return; + } + // For the rest of the nodes in the route, set their next-hop // Note: if we are the last in the route, this loop will not run for (int8_t i = nextHopIndex; i < r->route_count; i++) { diff --git a/src/modules/TrafficManagementModule.cpp b/src/modules/TrafficManagementModule.cpp index ea5054ded..e75273b78 100644 --- a/src/modules/TrafficManagementModule.cpp +++ b/src/modules/TrafficManagementModule.cpp @@ -32,39 +32,34 @@ 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) + +/// 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,10 +73,16 @@ uint8_t sanitizePositionPrecision(uint8_t precision) return 32; } -/** - * Return a short human-readable name for common port numbers. - * Falls back to "port:" for unknown ports. - */ +/// 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. const char *portName(int portnum) { switch (portnum) { @@ -122,6 +123,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 @@ -157,11 +159,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; @@ -169,22 +173,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 @@ -224,9 +231,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 @@ -256,25 +261,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) @@ -290,12 +327,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 @@ -314,6 +348,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 @@ -333,37 +370,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; } @@ -382,9 +417,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; @@ -393,68 +435,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; @@ -464,14 +503,265 @@ uint16_t TrafficManagementModule::countNodeInfoEntriesLocked() const count++; } return count; -#else - return 0; -#endif } +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 (this hourly pass owns it): clear every isMember bit, then re-mark from + // both NodeDB tiers. Runs AFTER seeding so the upsert still sees last pass's bits (spareMembers). + // Cost/lag rationale in docs/node_info_stores.md "Consistency with NodeDB (anti-entropy)". + 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(); + 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++; + } + // 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(): content and maintenance stay keyed to the + // same condition. Defensive - the object is only constructed while the flag is set + // (Modules.cpp) and no live path clears it (AdminModule only writes it true; config + // changes reboot), so a disabled module never reaches these hooks with a live cache. + 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; @@ -479,50 +769,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->isMember ? 2 : 0) | (entry->hasFullUser ? 4 : 0) | + (entry->keySignerProven ? 8 : 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) { @@ -617,60 +1022,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) @@ -703,14 +1082,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); @@ -720,20 +1098,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, mp.xeddsa_signed); } logAction("respond", &mp, "nodeinfo-cache"); @@ -795,24 +1173,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; @@ -847,21 +1218,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 - // 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 || TMM_HAS_NODEINFO_CACHE + // Mirrors purgeAll(): the two caches are compile-time independent (a variant may zero the + // unified cache while the NodeInfo cache exists, or vice versa), so each is maintained + // under its own guard below - a build with only one of them must still sweep it. + concurrency::LockGuard guard(&cacheLock); +#endif + +#if TRAFFIC_MANAGEMENT_CACHE_SIZE > 0 + // TTLs in free-running ticks: pos 4x position interval (<=255 @ 6 min/tick), rate 2x the + // configured window (<=15 @ 5 min/tick), unknown fixed 12 @ 1 min/tick. const uint32_t positionIntervalMs = secsToMs(Default::getConfiguredOrDefault( moduleConfig.traffic_management.position_min_interval_secs, default_traffic_mgmt_position_min_interval_secs)); const uint8_t posTtlTicks = @@ -883,9 +1256,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; @@ -922,12 +1292,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; @@ -944,15 +1311,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; } @@ -981,10 +1343,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; @@ -993,11 +1353,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 @@ -1050,13 +1407,20 @@ 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; + // 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. The + // response throttle no longer distinguishes the paths - the per-requester/per-target RAM + // tables cover both - but the replay gate below still keys off it. + bool usedFallback = false; { concurrency::LockGuard guard(&cacheLock); @@ -1067,31 +1431,77 @@ 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; + cachedKeySignerProven = entry->keySignerProven; } } 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); + usedFallback = true; } + // 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; + // Throttle the spoofed reply (per requester + per target + 1 s global floor; checked here so a + // request declined above never spends the budget). false forwards the request instead of consuming + // it. Rationale in docs/traffic_management_module.md "Throttling direct responses". + if (!directResponseAllowed(getFrom(p), p->to, clockMs())) { + TM_LOG_DEBUG("NodeInfo direct response throttled for 0x%08x; forwarding request instead", getFrom(p)); + return false; + } + meshtastic_MeshPacket *reply = router->allocForSending(); if (!reply) { TM_LOG_WARN("NodeInfo direct response dropped: no packet buffer"); @@ -1104,7 +1514,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; @@ -1120,11 +1530,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. @@ -1141,6 +1550,58 @@ bool TrafficManagementModule::shouldRespondToNodeInfo(const meshtastic_MeshPacke reply->priority = meshtastic_MeshPacket_Priority_DEFAULT; service->sendToMesh(reply); + + // The throttle state was already recorded by directResponseAllowed() above (it stamps only when + // it admits a reply), so there is nothing to stamp here. + + return true; +} + +TrafficManagementModule::DirectResponseThrottleEntry * +TrafficManagementModule::directResponseSlot(DirectResponseThrottleEntry *table, NodeNum key, uint32_t nowMs, uint32_t windowMs) +{ + // Known key still inside its window -> throttled (nullptr). Outside it -> reuse that slot. + for (size_t i = 0; i < kDirectResponseTrackedNodes; i++) { + if (table[i].key == key) + return ((nowMs - table[i].lastReplyMs) < windowMs) ? nullptr : &table[i]; + } + // Unseen key: take a free slot, otherwise evict the least recently used one. + DirectResponseThrottleEntry *slot = &table[0]; + for (size_t i = 0; i < kDirectResponseTrackedNodes; i++) { + if (table[i].key == 0) + return &table[i]; + if (table[i].lastReplyMs < slot->lastReplyMs) + slot = &table[i]; + } + return slot; +} + +bool TrafficManagementModule::directResponseAllowed(NodeNum requester, NodeNum target, uint32_t nowMs) +{ + // Reached from the packet path (and shares state with the cache), so take the same lock. + concurrency::LockGuard guard(&cacheLock); + + // Global airtime floor first: cheapest, and the backstop once an attacker cycles requester/target + // past the 8-slot tables. + if (lastDirectResponseMs != 0 && (nowMs - lastDirectResponseMs) < kDirectResponseGlobalMs) + return false; + + // Resolve both slots before stamping either, so a reply the target axis throttles does not consume + // the requester's budget (and vice versa). + DirectResponseThrottleEntry *reqSlot = + directResponseSlot(directRequesterSeen, requester, nowMs, kDirectResponsePerRequesterMs); + if (reqSlot == nullptr) + return false; + DirectResponseThrottleEntry *tgtSlot = directResponseSlot(directTargetSeen, target, nowMs, kDirectResponsePerTargetMs); + if (tgtSlot == nullptr) + return false; + + // All windows clear: admit the reply and record it on every axis. + reqSlot->key = requester; + reqSlot->lastReplyMs = nowMs; + tgtSlot->key = target; + tgtSlot->lastReplyMs = nowMs; + lastDirectResponseMs = nowMs; return true; } @@ -1199,9 +1660,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; @@ -1245,12 +1706,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 f4a1d5954..83d5ff60e 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,41 +44,66 @@ 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 @@ -79,43 +111,40 @@ class TrafficManagementModule : public MeshModule, private concurrency::OSThread #endif 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 isMember, bit2 hasFullUser, bit3 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 @@ -128,80 +157,72 @@ 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 observation tick (same idiom). The 60 s sweep clears the presence bit once the serve + // window passes, so the stamp is never read near its uint8 aliasing horizon. (Response throttling + // no longer lives here - it is the fixed per-requester/per-target RAM tables below, which use + // wrap-safe uint32 ms compares and so need no tick clock or sweep.) + static constexpr uint32_t kNodeInfoObsTickMs = 180000UL; // 3 min/tick (12.8 h period) + static constexpr uint8_t kNodeInfoMaxServeAgeTicks = 120; // 6 h serve window + + /// Current NodeInfo observation tick (3 min/tick). + static uint8_t currentObsTick() { return static_cast(clockMs() / kNodeInfoObsTickMs); } + static_assert(kNodeInfoMaxServeAgeTicks * kNodeInfoObsTickMs == 6UL * 60UL * 60UL * 1000UL, + "cache serve window must equal the fallback path's 6 h"); + + /// 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); // ========================================================================= @@ -213,42 +234,60 @@ 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. - - // 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 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; // 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; - }; - NodeInfoPayloadEntry *nodeInfoPayload = nullptr; // NodeInfo payloads in PSRAM (flat array, linear scan) + // 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; + + // `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 (flat array; PSRAM on hardware, heap in tests) bool nodeInfoPayloadFromPsram = false; // Tracks allocator for correct deallocation 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; @@ -256,48 +295,97 @@ 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 the expired obsTick stamp (wrap-safety + /// for the modular clock) and run the boot/hourly reconcile. Guarded by TMM_HAS_NODEINFO_CACHE alone + /// (never the unified cache size); see docs/node_info_stores.md "Tick clocks and wrap safety". + void maintainNodeInfoCacheLocked(); + + /// Anti-entropy under cacheLock: upsert hot-store + warm-tier records this cache lacks (never sets + /// hasObserved - seeding is knowledge, not observation), and refresh isMember from both NodeDB + /// tiers. Cost/lag: docs/node_info_stores.md "Consistency with NodeDB (anti-entropy)". + 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); + + // Direct-response throttles bounding the reflector risk of spoofed replies: three fixed bounds + // (per requester, per target, 1 s global airtime floor) via 8-slot LRU RAM tables, wrap-safe and + // PSRAM-agnostic. Design & rationale: docs/traffic_management_module.md "Throttling direct responses". + static constexpr uint32_t kDirectResponsePerRequesterMs = 60'000UL; + static constexpr uint32_t kDirectResponsePerTargetMs = 60'000UL; + static constexpr uint32_t kDirectResponseGlobalMs = 1'000UL; + static constexpr size_t kDirectResponseTrackedNodes = 8; + struct DirectResponseThrottleEntry { + NodeNum key; // requester or target node; 0 = unused slot + uint32_t lastReplyMs; // clockMs() of our last reply keyed on this node + }; + DirectResponseThrottleEntry directRequesterSeen[kDirectResponseTrackedNodes] = {}; + DirectResponseThrottleEntry directTargetSeen[kDirectResponseTrackedNodes] = {}; + uint32_t lastDirectResponseMs = 0; + /// True (and records the send) when a spoofed direct reply to `requester` for `target` is within + /// every throttle window; false throttles it. Caller must NOT hold cacheLock (this takes it). + bool directResponseAllowed(NodeNum requester, NodeNum target, uint32_t nowMs); + /// Slot in `table` to stamp for `key` at `nowMs`, or nullptr if `key` is still within `windowMs` + /// of its last reply (throttled). Does not stamp - the caller stamps only once all windows pass. + static DirectResponseThrottleEntry *directResponseSlot(DirectResponseThrottleEntry *table, NodeNum key, uint32_t nowMs, + uint32_t windowMs); + /// 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/src/modules/esp32/AudioModule.cpp b/src/modules/esp32/AudioModule.cpp index 815e426a2..0dc9832f3 100644 --- a/src/modules/esp32/AudioModule.cpp +++ b/src/modules/esp32/AudioModule.cpp @@ -258,6 +258,8 @@ bool AudioModule::shouldDraw() void AudioModule::sendPayload(NodeNum dest, bool wantReplies) { meshtastic_MeshPacket *p = allocReply(); + if (!p) + return; p->to = dest; p->decoded.want_response = wantReplies; diff --git a/src/modules/esp32/PaxcounterModule.cpp b/src/modules/esp32/PaxcounterModule.cpp index c9eca1c74..db38165c6 100644 --- a/src/modules/esp32/PaxcounterModule.cpp +++ b/src/modules/esp32/PaxcounterModule.cpp @@ -81,6 +81,8 @@ bool PaxcounterModule::sendInfo(NodeNum dest) pl.uptime = millis() / 1000; meshtastic_MeshPacket *p = allocDataProtobuf(pl); + if (!p) + return false; p->to = dest; p->decoded.want_response = false; p->priority = meshtastic_MeshPacket_Priority_BACKGROUND; diff --git a/src/modules/games/GamesModule.cpp b/src/modules/games/GamesModule.cpp index 1d72ba632..606d1d505 100644 --- a/src/modules/games/GamesModule.cpp +++ b/src/modules/games/GamesModule.cpp @@ -105,6 +105,8 @@ void GamesModule::announceHighScore(const char *initials, uint32_t score) if (!initials || initials[0] == '\0') return; meshtastic_MeshPacket *p = allocDataPacket(); + if (!p) + return; p->to = NODENUM_BROADCAST; p->channel = 0; // primary channel p->decoded.portnum = meshtastic_PortNum_TEXT_MESSAGE_APP; diff --git a/src/mqtt/MQTT.cpp b/src/mqtt/MQTT.cpp index e0b3a7bff..251c632a1 100644 --- a/src/mqtt/MQTT.cpp +++ b/src/mqtt/MQTT.cpp @@ -121,6 +121,8 @@ inline void onReceiveProto(char *topic, byte *payload, size_t length) // receives it when we get our own packet back. Then we'll stop our retransmissions. if (isFromUs(e.packet)) { auto pAck = routingModule->allocAckNak(meshtastic_Routing_Error_NONE, getFrom(e.packet), e.packet->id, ch.index); + if (!pAck) + return; pAck->transport_mechanism = meshtastic_MeshPacket_TransportMechanism_TRANSPORT_MQTT; if (router->sendLocal(pAck) == ERRNO_SHOULD_RELEASE) packetPool.release(pAck); @@ -332,7 +334,20 @@ void MQTT::mqttCallback(char *topic, byte *payload, unsigned int length) void MQTT::onClientProxyReceive(meshtastic_MqttClientProxyMessage msg) { - onReceive(msg.topic, msg.payload_variant.data.bytes, msg.payload_variant.data.size); + // payload_variant is a union: on the text variant, data.size aliases the first bytes of the + // string, so reading it unconditionally let a client name any length up to PB_SIZE_MAX. + switch (msg.which_payload_variant) { + case meshtastic_MqttClientProxyMessage_data_tag: + onReceive(msg.topic, msg.payload_variant.data.bytes, msg.payload_variant.data.size); + break; + case meshtastic_MqttClientProxyMessage_text_tag: + onReceive(msg.topic, (byte *)msg.payload_variant.text, + strnlen(msg.payload_variant.text, sizeof(msg.payload_variant.text))); + break; + default: + LOG_WARN("MQTT proxy message carries no payload, topic %s", msg.topic); + break; + } } void MQTT::onReceive(char *topic, byte *payload, size_t length) diff --git a/test/native-suite-count b/test/native-suite-count index 81b5c5d06..e522732c7 100644 --- a/test/native-suite-count +++ b/test/native-suite-count @@ -1 +1 @@ -37 +38 diff --git a/test/support/AdminModuleTestShim.h b/test/support/AdminModuleTestShim.h index c189bee84..002a9cc6c 100644 --- a/test/support/AdminModuleTestShim.h +++ b/test/support/AdminModuleTestShim.h @@ -9,6 +9,7 @@ class AdminModuleTestShim : public AdminModule public: using AdminModule::checkPassKey; // session-key gate seam (see test_admin_session_repro) using AdminModule::handleGetConfig; + using AdminModule::handleGetModuleConfig; using AdminModule::handleReceivedProtobuf; using AdminModule::handleSetConfig; using AdminModule::handleSetModuleConfig; diff --git a/test/test_admin_session_repro/test_main.cpp b/test/test_admin_session_repro/test_main.cpp index 2a4cdda8d..890a9079e 100644 --- a/test/test_admin_session_repro/test_main.cpp +++ b/test/test_admin_session_repro/test_main.cpp @@ -250,6 +250,107 @@ void test_local_security_config_keeps_private_key(void) admin->drainReply(); } +// Decode the NetworkConfig / MqttConfig out of the response a handler queued in myReply. +static bool decodeNetworkFromReply(meshtastic_MeshPacket *reply, meshtastic_Config_NetworkConfig &out) +{ + meshtastic_AdminMessage am = meshtastic_AdminMessage_init_zero; + if (!reply || reply->which_payload_variant != meshtastic_MeshPacket_decoded_tag) + return false; + if (!pb_decode_from_bytes(reply->decoded.payload.bytes, reply->decoded.payload.size, &meshtastic_AdminMessage_msg, &am)) + return false; + if (am.which_payload_variant != meshtastic_AdminMessage_get_config_response_tag || + am.get_config_response.which_payload_variant != meshtastic_Config_network_tag) + return false; + out = am.get_config_response.payload_variant.network; + return true; +} + +static bool decodeMqttFromReply(meshtastic_MeshPacket *reply, meshtastic_ModuleConfig_MQTTConfig &out) +{ + meshtastic_AdminMessage am = meshtastic_AdminMessage_init_zero; + if (!reply || reply->which_payload_variant != meshtastic_MeshPacket_decoded_tag) + return false; + if (!pb_decode_from_bytes(reply->decoded.payload.bytes, reply->decoded.payload.size, &meshtastic_AdminMessage_msg, &am)) + return false; + if (am.which_payload_variant != meshtastic_AdminMessage_get_module_config_response_tag || + am.get_module_config_response.which_payload_variant != meshtastic_ModuleConfig_mqtt_tag) + return false; + out = am.get_module_config_response.payload_variant.mqtt; + return true; +} + +// A remote requester gets the sentinel; the set path swaps the stored value back. +void test_remote_network_config_omits_wifi_psk(void) +{ + strcpy(config.network.wifi_psk, "hunter2hunter2"); + + meshtastic_MeshPacket req = makeGetConfigRequest(ADMIN_NODE); + admin->handleGetConfig(req, meshtastic_AdminMessage_ConfigType_NETWORK_CONFIG); + + meshtastic_Config_NetworkConfig net; + TEST_ASSERT_TRUE(decodeNetworkFromReply(admin->reply(), net)); + TEST_ASSERT_EQUAL_STRING_MESSAGE("sekrit", net.wifi_psk, "remote network config must not carry the real psk"); + admin->drainReply(); +} + +// Control: the local path still receives the stored psk. +void test_local_network_config_keeps_wifi_psk(void) +{ + strcpy(config.network.wifi_psk, "hunter2hunter2"); + + meshtastic_MeshPacket req = makeGetConfigRequest(0); + admin->handleGetConfig(req, meshtastic_AdminMessage_ConfigType_NETWORK_CONFIG); + + meshtastic_Config_NetworkConfig net; + TEST_ASSERT_TRUE(decodeNetworkFromReply(admin->reply(), net)); + TEST_ASSERT_EQUAL_STRING_MESSAGE("hunter2hunter2", net.wifi_psk, "local client must still receive the psk"); + admin->drainReply(); +} + +void test_remote_mqtt_config_omits_password(void) +{ + strcpy(moduleConfig.mqtt.password, "brokerpass"); + + meshtastic_MeshPacket req = makeGetConfigRequest(ADMIN_NODE); + admin->handleGetModuleConfig(req, meshtastic_AdminMessage_ModuleConfigType_MQTT_CONFIG); + + meshtastic_ModuleConfig_MQTTConfig mqtt; + TEST_ASSERT_TRUE(decodeMqttFromReply(admin->reply(), mqtt)); + TEST_ASSERT_EQUAL_STRING_MESSAGE("sekrit", mqtt.password, "remote mqtt config must not carry the broker password"); + admin->drainReply(); +} + +void test_local_mqtt_config_keeps_password(void) +{ + strcpy(moduleConfig.mqtt.password, "brokerpass"); + + meshtastic_MeshPacket req = makeGetConfigRequest(0); + admin->handleGetModuleConfig(req, meshtastic_AdminMessage_ModuleConfigType_MQTT_CONFIG); + + meshtastic_ModuleConfig_MQTTConfig mqtt; + TEST_ASSERT_TRUE(decodeMqttFromReply(admin->reply(), mqtt)); + TEST_ASSERT_EQUAL_STRING_MESSAGE("brokerpass", mqtt.password, "local client must still receive the password"); + admin->drainReply(); +} + +// A client that GETs and writes the config straight back must not wipe the stored value. +void test_set_config_sentinel_psk_preserves_stored_value(void) +{ + strcpy(config.network.wifi_psk, "hunter2hunter2"); + + meshtastic_Config c = meshtastic_Config_init_zero; + c.which_payload_variant = meshtastic_Config_network_tag; + c.payload_variant.network = config.network; + strcpy(c.payload_variant.network.wifi_psk, "sekrit"); + + admin->deferSaves(); + admin->handleSetConfig(c, true); + + TEST_ASSERT_EQUAL_STRING_MESSAGE("hunter2hunter2", config.network.wifi_psk, + "a read-modify-write round trip must not wipe the psk"); + admin->drainReply(); +} + // An admin response carries no session passkey and its sender is not an admin-key holder, so a // request we sent is the only thing vouching for it. A get_module_config_response from a node we // never queried is not. @@ -431,6 +532,11 @@ void setup() RUN_TEST(test_session_gate_accepts_key_from_a_get_response); RUN_TEST(test_remote_security_config_omits_private_key); RUN_TEST(test_local_security_config_keeps_private_key); + RUN_TEST(test_remote_network_config_omits_wifi_psk); + RUN_TEST(test_local_network_config_keeps_wifi_psk); + RUN_TEST(test_remote_mqtt_config_omits_password); + RUN_TEST(test_local_mqtt_config_keeps_password); + RUN_TEST(test_set_config_sentinel_psk_preserves_stored_value); RUN_TEST(test_unsolicited_response_is_not_solicited); RUN_TEST(test_response_after_our_request_is_solicited); RUN_TEST(test_request_to_one_node_does_not_admit_another); diff --git a/test/test_mesh_module/test_main.cpp b/test/test_mesh_module/test_main.cpp index ec7b1808e..6ba038af3 100644 --- a/test/test_mesh_module/test_main.cpp +++ b/test/test_mesh_module/test_main.cpp @@ -3,6 +3,23 @@ #include "TestUtil.h" #include +#include "configuration.h" +#include "mesh/CryptoEngine.h" +#include "mesh/MeshService.h" +#include "mesh/NodeDB.h" +#include "mesh/RadioInterface.h" +#include "mesh/Router.h" +#include "modules/NeighborInfoModule.h" +#include "modules/RoutingModule.h" +#include "support/MockMeshService.h" +#include +#include + +namespace +{ +constexpr NodeNum LOCAL_NODE = 0x11111111; +constexpr NodeNum REMOTE_NODE = 0x22222222; + // Minimal concrete subclass for testing the base class helper class TestModule : public MeshModule { @@ -13,11 +30,207 @@ class TestModule : public MeshModule using MeshModule::isMultiHopBroadcastRequest; }; +class MockNodeDB : public NodeDB +{ +}; + +class MockRadioInterface : public RadioInterface +{ + public: + ErrorCode send(meshtastic_MeshPacket *p) override + { + packetPool.release(p); + return ERRNO_OK; + } + + uint32_t getPacketTime(uint32_t totalPacketLen, bool received = false) override + { + (void)totalPacketLen; + (void)received; + return 0; + } +}; + +class MockRouter : public Router +{ + public: + ~MockRouter() + { + delete cryptLock; + cryptLock = nullptr; + } + + ErrorCode send(meshtastic_MeshPacket *p) override + { + sentPackets.push_back(*p); + packetPool.release(p); + return ERRNO_OK; + } + + std::vector sentPackets; +}; + +struct AckNak { + meshtastic_Routing_Error error; + NodeNum to; + PacketId requestId; + ChannelIndex channel; +}; + +class MockRoutingModule : public RoutingModule +{ + public: + void sendAckNak(meshtastic_Routing_Error err, NodeNum to, PacketId idFrom, ChannelIndex chIndex, uint8_t hopLimit = 0, + bool ackWantsAck = false) override + { + (void)hopLimit; + (void)ackWantsAck; + ackNaks.push_back({err, to, idFrom, chIndex}); + } + + std::vector ackNaks; + + protected: + bool wantPacket(const meshtastic_MeshPacket *p) override + { + (void)p; + return false; + } +}; + +class SyntheticReplyModule : public MeshModule +{ + public: + SyntheticReplyModule(const char *name, meshtastic_PortNum modulePort, meshtastic_PortNum replyPort, + bool acceptsEveryPort = false) + : MeshModule(name, modulePort), replyPort(replyPort), acceptsEveryPort(acceptsEveryPort) + { + isPromiscuous = acceptsEveryPort; + } + + uint32_t allocReplyCalls = 0; + + protected: + bool wantPacket(const meshtastic_MeshPacket *p) override { return acceptsEveryPort || p->decoded.portnum == ourPortNum; } + + meshtastic_MeshPacket *allocReply() override + { + allocReplyCalls++; + meshtastic_MeshPacket *reply = router->allocForSending(); + reply->decoded.portnum = replyPort; + return reply; + } + + private: + meshtastic_PortNum replyPort; + bool acceptsEveryPort; +}; + +class ObservingIgnoreModule : public MeshModule +{ + public: + ObservingIgnoreModule() : MeshModule("storeforward-shaped", meshtastic_PortNum_STORE_FORWARD_APP) {} + + uint32_t allocReplyCalls = 0; + + protected: + bool wantPacket(const meshtastic_MeshPacket *p) override { return p->decoded.portnum == meshtastic_PortNum_TEXT_MESSAGE_APP; } + + ProcessMessage handleReceived(const meshtastic_MeshPacket &mp) override + { + (void)mp; + ignoreRequest = true; + return ProcessMessage::CONTINUE; + } + + meshtastic_MeshPacket *allocReply() override + { + allocReplyCalls++; + return nullptr; + } +}; + +class ReplyIgnoreModule : public MeshModule +{ + public: + ReplyIgnoreModule() : MeshModule("reply-ignore", meshtastic_PortNum_NEIGHBORINFO_APP) {} + + uint32_t allocReplyCalls = 0; + + protected: + bool wantPacket(const meshtastic_MeshPacket *p) override { return p->decoded.portnum == ourPortNum; } + + meshtastic_MeshPacket *allocReply() override + { + allocReplyCalls++; + ignoreRequest = true; + return nullptr; + } +}; + static TestModule *testModule; static meshtastic_MeshPacket testPacket; +static MockNodeDB *mockNodeDB; +static MockMeshService *mockService; +static MockRouter *mockRouter; +static MockRoutingModule *mockRoutingModule; +static NeighborInfoModule *realNeighborInfoModule; +static std::vector dispatchModules; + +template static T *registerDispatchModule(T *module) +{ + dispatchModules.push_back(module); + return module; +} + +static meshtastic_MeshPacket makeRequest(meshtastic_PortNum port) +{ + meshtastic_MeshPacket packet = meshtastic_MeshPacket_init_zero; + packet.from = REMOTE_NODE; + packet.to = LOCAL_NODE; + packet.id = 0x12345678; + packet.channel = 0; + packet.hop_start = 3; + packet.hop_limit = 3; + packet.which_payload_variant = meshtastic_MeshPacket_decoded_tag; + packet.decoded.portnum = port; + packet.decoded.want_response = true; + return packet; +} + +static void dispatch(meshtastic_PortNum port) +{ + meshtastic_MeshPacket request = makeRequest(port); + MeshModule::callModules(request); +} + +} // namespace void setUp(void) { + config = meshtastic_LocalConfig_init_zero; + moduleConfig = meshtastic_LocalModuleConfig_init_zero; + channelFile = meshtastic_ChannelFile_init_zero; + owner = meshtastic_User_init_zero; + myNodeInfo.my_node_num = LOCAL_NODE; + + mockNodeDB = new MockNodeDB(); + nodeDB = mockNodeDB; + myNodeInfo.my_node_num = LOCAL_NODE; + + mockService = new MockMeshService(); + service = mockService; + + channels.initDefaults(); + channels.onConfigChanged(); + + mockRouter = new MockRouter(); + mockRouter->addInterface(std::unique_ptr(new MockRadioInterface())); + router = mockRouter; + + mockRoutingModule = new MockRoutingModule(); + routingModule = mockRoutingModule; + testModule = new TestModule(); memset(&testPacket, 0, sizeof(testPacket)); TestModule::currentRequest = &testPacket; @@ -26,7 +239,34 @@ void setUp(void) void tearDown(void) { TestModule::currentRequest = NULL; + + for (auto it = dispatchModules.rbegin(); it != dispatchModules.rend(); ++it) + delete *it; + dispatchModules.clear(); + + delete realNeighborInfoModule; + realNeighborInfoModule = nullptr; + delete testModule; + testModule = nullptr; + + delete mockRoutingModule; + mockRoutingModule = nullptr; + routingModule = nullptr; + + while (auto *status = mockService->getQueueStatusForPhone()) + mockService->releaseQueueStatusToPool(status); + delete mockService; + mockService = nullptr; + service = nullptr; + + delete mockRouter; + mockRouter = nullptr; + router = nullptr; + + delete mockNodeDB; + mockNodeDB = nullptr; + nodeDB = nullptr; } // Zero-hop broadcast (hop_limit == hop_start): should be allowed @@ -98,6 +338,139 @@ static void test_singleHopRelayedBroadcast_isBlocked() TEST_ASSERT_TRUE(testModule->isMultiHopBroadcastRequest()); } +static void test_replyPortMatches_ownPort() +{ + meshtastic_MeshPacket request = makeRequest(meshtastic_PortNum_TELEMETRY_APP); + + TEST_ASSERT_TRUE(MeshModule::replyPortMatches(meshtastic_PortNum_TELEMETRY_APP, request)); +} + +static void test_replyPortMatches_neighborInfoVsTelemetry() +{ + meshtastic_MeshPacket request = makeRequest(meshtastic_PortNum_TELEMETRY_APP); + + TEST_ASSERT_FALSE(MeshModule::replyPortMatches(meshtastic_PortNum_NEIGHBORINFO_APP, request)); +} + +static void test_replyPortMatches_positionRequest() +{ + meshtastic_MeshPacket request = makeRequest(meshtastic_PortNum_POSITION_APP); + + TEST_ASSERT_TRUE(MeshModule::replyPortMatches(meshtastic_PortNum_POSITION_APP, request)); +} + +static void test_replyPortMatches_unknownModulePort() +{ + meshtastic_MeshPacket request = makeRequest(meshtastic_PortNum_TELEMETRY_APP); + + TEST_ASSERT_FALSE(MeshModule::replyPortMatches(meshtastic_PortNum_UNKNOWN_APP, request)); +} + +static void test_replyPortMatches_unknownRequestPort() +{ + meshtastic_MeshPacket request = makeRequest(meshtastic_PortNum_UNKNOWN_APP); + + TEST_ASSERT_FALSE(MeshModule::replyPortMatches(meshtastic_PortNum_TELEMETRY_APP, request)); +} + +static void test_dispatch_foreignPortOffenderCannotShadowOwner() +{ + auto *offender = registerDispatchModule(new SyntheticReplyModule("neighbor-shaped", meshtastic_PortNum_NEIGHBORINFO_APP, + meshtastic_PortNum_NEIGHBORINFO_APP, true)); + auto *owner = registerDispatchModule( + new SyntheticReplyModule("telemetry-owner", meshtastic_PortNum_TELEMETRY_APP, meshtastic_PortNum_TELEMETRY_APP)); + + dispatch(meshtastic_PortNum_TELEMETRY_APP); + + TEST_ASSERT_EQUAL_UINT32(0, offender->allocReplyCalls); + TEST_ASSERT_EQUAL_UINT32(1, owner->allocReplyCalls); + TEST_ASSERT_EQUAL_UINT32(1, mockRouter->sentPackets.size()); + TEST_ASSERT_EQUAL(meshtastic_PortNum_TELEMETRY_APP, mockRouter->sentPackets[0].decoded.portnum); + TEST_ASSERT_EQUAL_UINT32(0, mockRoutingModule->ackNaks.size()); +} + +static void test_dispatch_ownerPortStillReplies() +{ + auto *offender = registerDispatchModule(new SyntheticReplyModule("neighbor-shaped", meshtastic_PortNum_NEIGHBORINFO_APP, + meshtastic_PortNum_NEIGHBORINFO_APP, true)); + auto *owner = registerDispatchModule( + new SyntheticReplyModule("telemetry-owner", meshtastic_PortNum_TELEMETRY_APP, meshtastic_PortNum_TELEMETRY_APP)); + + dispatch(meshtastic_PortNum_NEIGHBORINFO_APP); + + TEST_ASSERT_EQUAL_UINT32(1, offender->allocReplyCalls); + TEST_ASSERT_EQUAL_UINT32(0, owner->allocReplyCalls); + TEST_ASSERT_EQUAL_UINT32(1, mockRouter->sentPackets.size()); + TEST_ASSERT_EQUAL(meshtastic_PortNum_NEIGHBORINFO_APP, mockRouter->sentPackets[0].decoded.portnum); + TEST_ASSERT_EQUAL_UINT32(0, mockRoutingModule->ackNaks.size()); +} + +static void test_dispatch_crossPortReplyUsesRequestOwner() +{ + auto *position = registerDispatchModule( + new SyntheticReplyModule("position-owner", meshtastic_PortNum_POSITION_APP, meshtastic_PortNum_ATAK_PLUGIN_V2)); + + dispatch(meshtastic_PortNum_POSITION_APP); + + TEST_ASSERT_EQUAL_UINT32(1, position->allocReplyCalls); + TEST_ASSERT_EQUAL_UINT32(1, mockRouter->sentPackets.size()); + TEST_ASSERT_EQUAL(meshtastic_PortNum_ATAK_PLUGIN_V2, mockRouter->sentPackets[0].decoded.portnum); + TEST_ASSERT_EQUAL_UINT32(0, mockRoutingModule->ackNaks.size()); +} + +static void test_dispatch_foreignPortObserverCanSuppressNak() +{ + auto *observer = registerDispatchModule(new ObservingIgnoreModule()); + + dispatch(meshtastic_PortNum_TEXT_MESSAGE_APP); + + TEST_ASSERT_EQUAL_UINT32(0, observer->allocReplyCalls); + TEST_ASSERT_EQUAL_UINT32(0, mockRouter->sentPackets.size()); + TEST_ASSERT_EQUAL_UINT32(0, mockRoutingModule->ackNaks.size()); +} + +static void test_dispatch_noResponderSendsNak() +{ + dispatch(meshtastic_PortNum_TELEMETRY_APP); + + TEST_ASSERT_EQUAL_UINT32(0, mockRouter->sentPackets.size()); + TEST_ASSERT_EQUAL_UINT32(1, mockRoutingModule->ackNaks.size()); + TEST_ASSERT_EQUAL(meshtastic_Routing_Error_NO_RESPONSE, mockRoutingModule->ackNaks[0].error); + TEST_ASSERT_EQUAL_HEX32(REMOTE_NODE, mockRoutingModule->ackNaks[0].to); +} + +static void test_dispatch_ignoreRequestIsClearedPerPacket() +{ + auto *ignoring = registerDispatchModule(new ReplyIgnoreModule()); + + dispatch(meshtastic_PortNum_NEIGHBORINFO_APP); + + TEST_ASSERT_EQUAL_UINT32(1, ignoring->allocReplyCalls); + TEST_ASSERT_EQUAL_UINT32(0, mockRouter->sentPackets.size()); + TEST_ASSERT_EQUAL_UINT32(0, mockRoutingModule->ackNaks.size()); + + dispatch(meshtastic_PortNum_TELEMETRY_APP); + + TEST_ASSERT_EQUAL_UINT32(1, ignoring->allocReplyCalls); + TEST_ASSERT_EQUAL_UINT32(0, mockRouter->sentPackets.size()); + TEST_ASSERT_EQUAL_UINT32(1, mockRoutingModule->ackNaks.size()); + TEST_ASSERT_EQUAL(meshtastic_Routing_Error_NO_RESPONSE, mockRoutingModule->ackNaks[0].error); +} + +static void test_dispatch_realNeighborInfoCannotShadowTelemetryOwner() +{ + moduleConfig.neighbor_info.enabled = true; + realNeighborInfoModule = new NeighborInfoModule(); + registerDispatchModule( + new SyntheticReplyModule("telemetry-owner", meshtastic_PortNum_TELEMETRY_APP, meshtastic_PortNum_TELEMETRY_APP)); + + dispatch(meshtastic_PortNum_TELEMETRY_APP); + + TEST_ASSERT_EQUAL_UINT32(1, mockRouter->sentPackets.size()); + TEST_ASSERT_EQUAL(meshtastic_PortNum_TELEMETRY_APP, mockRouter->sentPackets[0].decoded.portnum); + TEST_ASSERT_EQUAL_UINT32(0, mockRoutingModule->ackNaks.size()); +} + void setup() { initializeTestEnvironment(); @@ -110,6 +483,18 @@ void setup() RUN_TEST(test_noCurrentRequest_isAllowed); RUN_TEST(test_legacyPacket_zeroHopStart_isAllowed); RUN_TEST(test_singleHopRelayedBroadcast_isBlocked); + RUN_TEST(test_replyPortMatches_ownPort); + RUN_TEST(test_replyPortMatches_neighborInfoVsTelemetry); + RUN_TEST(test_replyPortMatches_positionRequest); + RUN_TEST(test_replyPortMatches_unknownModulePort); + RUN_TEST(test_replyPortMatches_unknownRequestPort); + RUN_TEST(test_dispatch_foreignPortOffenderCannotShadowOwner); + RUN_TEST(test_dispatch_ownerPortStillReplies); + RUN_TEST(test_dispatch_crossPortReplyUsesRequestOwner); + RUN_TEST(test_dispatch_foreignPortObserverCanSuppressNak); + RUN_TEST(test_dispatch_noResponderSendsNak); + RUN_TEST(test_dispatch_ignoreRequestIsClearedPerPacket); + RUN_TEST(test_dispatch_realNeighborInfoCannotShadowTelemetryOwner); exit(UNITY_END()); } diff --git a/test/test_mqtt/MQTT.cpp b/test/test_mqtt/MQTT.cpp index 78f8b32f5..ab9a64e44 100644 --- a/test/test_mqtt/MQTT.cpp +++ b/test/test_mqtt/MQTT.cpp @@ -593,6 +593,37 @@ void test_receiveEmptyDataFromProxy(void) TEST_ASSERT_TRUE(mockRouter->packets_.empty()); } +// Text must be read as text: data.size aliases the string's first bytes, so reading it regardless +// of the variant let a client name a length of up to PB_SIZE_MAX. There is no delivery control for +// this variant: an encoded ServiceEnvelope always contains NUL, so text can never carry one. +void test_receiveTextVariantFromProxyIsNotReadAsBytes(void) +{ + meshtastic_MqttClientProxyMessage message = meshtastic_MqttClientProxyMessage_init_default; + snprintf(message.topic, sizeof(message.topic), "msh/2/e/test/!87654321"); + message.which_payload_variant = meshtastic_MqttClientProxyMessage_text_tag; + // data.size would read these as the largest length a pb_size_t can name. + memset(message.payload_variant.text, 0xFF, sizeof(message.payload_variant.text) - 1); + message.payload_variant.text[sizeof(message.payload_variant.text) - 1] = '\0'; + + mqtt->onClientProxyReceive(message); + + TEST_ASSERT_TRUE(mockRouter->packets_.empty()); +} + +// A proxy message with no payload variant set must be ignored rather than read as bytes. +void test_receiveNoVariantFromProxyIsIgnored(void) +{ + meshtastic_MqttClientProxyMessage message = meshtastic_MqttClientProxyMessage_init_default; + snprintf(message.topic, sizeof(message.topic), "msh/2/e/test/!87654321"); + message.which_payload_variant = 0; + memset(message.payload_variant.data.bytes, 0xFF, sizeof(message.payload_variant.data.bytes)); + message.payload_variant.data.size = sizeof(message.payload_variant.data.bytes); + + mqtt->onClientProxyReceive(message); + + TEST_ASSERT_TRUE(mockRouter->packets_.empty()); +} + // Packets should be ignored if downlink is not enabled. void test_receiveWithoutChannelDownlink(void) { @@ -1116,6 +1147,8 @@ void setup() RUN_TEST(test_receiveDecodedProto); RUN_TEST(test_receiveDecodedProtoFromProxy); RUN_TEST(test_receiveEmptyDataFromProxy); + RUN_TEST(test_receiveTextVariantFromProxyIsNotReadAsBytes); + RUN_TEST(test_receiveNoVariantFromProxyIsIgnored); RUN_TEST(test_receiveWithoutChannelDownlink); RUN_TEST(test_receiveEncryptedPKITopicToUs); RUN_TEST(test_receiveIgnoresOwnPublishedMessages); diff --git a/test/test_packet_signing/test_main.cpp b/test/test_packet_signing/test_main.cpp index 0da6e9a20..f5eaa44bb 100644 --- a/test/test_packet_signing/test_main.cpp +++ b/test/test_packet_signing/test_main.cpp @@ -729,8 +729,11 @@ void test_A17_strict_verifies_signer_from_warm_key_store(void) // Model its next hot-store eviction and prove Balanced still remembers the signer without // allocating a hot node merely to evaluate an unsigned packet. + // Mirror what NodeDB eviction actually stores for a signer: warmProtectedCategory() yields + // XeddsaSigner *and* the dedicated warm signer bit is set from nodeInfoLiteHasXeddsaSigned(). + // isKnownXeddsaSigner() reads that signer bit, not the protected category. TEST_ASSERT_TRUE(mockNodeDB->warmStore.absorb(REMOTE_NODE, 2, pub, meshtastic_Config_DeviceConfig_Role_CLIENT, - static_cast(WarmProtected::XeddsaSigner))); + static_cast(WarmProtected::XeddsaSigner), /*signer=*/true)); mockNodeDB->clearTestNodes(); setPolicy(meshtastic_Config_SecurityConfig_PacketSignaturePolicy_PACKET_SIGNATURE_POLICY_BALANCED); meshtastic_MeshPacket unsignedPacket = diff --git a/test/test_pki_admin_fallback/test_main.cpp b/test/test_pki_admin_fallback/test_main.cpp index c03652244..5c3b408c9 100644 --- a/test/test_pki_admin_fallback/test_main.cpp +++ b/test/test_pki_admin_fallback/test_main.cpp @@ -202,6 +202,62 @@ void test_wrong_admin_key_does_not_decode(void) TEST_ASSERT_NULL(mockNodeDB->getMeshNode(ADMIN_NODE)); } +// The fallback is budget-limited against flooding; see Router.cpp for why the budget is global. +void test_admin_key_fallback_is_rate_limited(void) +{ + // Start from a full bucket regardless of what earlier tests consumed (8 tokens, one per 250ms). + delay(2500); + + uint8_t otherPub[32], otherPriv[32]; + crypto->generateKeyPair(otherPub, otherPriv); + crypto->setDHPrivateKey(ourPriv); + setAdminKey(0, otherPub); // wrong key, so every attempt below fails and keeps its token spent + + // Drain the burst with undecryptable packets, as a flooding attacker would. + for (int i = 0; i < 8; i++) { + meshtastic_MeshPacket junk = makePkiPacket(ADMIN_NODE, meshtastic_PortNum_PRIVATE_APP, 16, adminPriv); + TEST_ASSERT_NOT_EQUAL(DECODE_SUCCESS, perhapsDecode(&junk)); + } + + // Budget exhausted: the fallback is skipped, so even a correct admin key does not decrypt. + setAdminKey(0, adminPub); + meshtastic_MeshPacket blocked = makePkiPacket(ADMIN_NODE, meshtastic_PortNum_PRIVATE_APP, 16, adminPriv); + TEST_ASSERT_NOT_EQUAL_MESSAGE(DECODE_SUCCESS, perhapsDecode(&blocked), "fallback should be budget-limited"); + + // The budget refills, so the throttle is not a permanent lockout. + delay(600); + meshtastic_MeshPacket allowed = makePkiPacket(ADMIN_NODE, meshtastic_PortNum_PRIVATE_APP, 16, adminPriv); + TEST_ASSERT_EQUAL_MESSAGE(DECODE_SUCCESS, perhapsDecode(&allowed), "budget should refill over time"); + assertDecodedAndLearned(&allowed, adminPub); +} + +// A pending key is an unverified identity claim from whoever opened a key-verification handshake, so it +// must decrypt only the exchange itself. Otherwise they could send DMs that look PKI-authenticated as a +// node they never proved they are. +void test_pending_key_decrypts_only_key_verification(void) +{ + // PEER is unknown to us; the handshake stashed its claimed key as pending. + static constexpr NodeNum PEER = 0x0C0C0C0C; + uint8_t peerPub[32], peerPriv[32]; + crypto->generateKeyPair(peerPub, peerPriv); + crypto->setDHPrivateKey(ourPriv); // generateKeyPair changed it + crypto->setPendingPublicKey(PEER, peerPub); + + // A DM on any other port must not decode, even though the pending key would decrypt it. + meshtastic_MeshPacket spoofed = makePkiPacket(PEER, meshtastic_PortNum_TEXT_MESSAGE_APP, 16, peerPriv); + TEST_ASSERT_NOT_EQUAL_MESSAGE(DECODE_SUCCESS, perhapsDecode(&spoofed), "pending key must not decrypt a text DM"); + TEST_ASSERT_FALSE_MESSAGE(spoofed.pki_encrypted, "spoofed DM must not be marked PKI-authenticated"); + + // The key-verification exchange itself still works, so bootstrapping is unaffected. + meshtastic_MeshPacket handshake = makePkiPacket(PEER, meshtastic_PortNum_KEY_VERIFICATION_APP, 16, peerPriv); + TEST_ASSERT_EQUAL_MESSAGE(DECODE_SUCCESS, perhapsDecode(&handshake), "key verification must still decrypt"); + TEST_ASSERT_TRUE(handshake.pki_encrypted); + + // A pending key is never persisted, so the peer stays unknown until verification commits it. + TEST_ASSERT_NULL_MESSAGE(mockNodeDB->getMeshNode(PEER), "pending key must not be learned into NodeDB"); + crypto->clearPendingPublicKey(); +} + #endif // !(MESHTASTIC_EXCLUDE_PKI) void setup() @@ -216,6 +272,8 @@ void setup() RUN_TEST(test_admin_key_slot2_only_decrypts); RUN_TEST(test_no_admin_key_unknown_sender_not_decoded); RUN_TEST(test_wrong_admin_key_does_not_decode); + RUN_TEST(test_admin_key_fallback_is_rate_limited); + RUN_TEST(test_pending_key_decrypts_only_key_verification); #endif exit(UNITY_END()); } diff --git a/test/test_traceroute_nexthop/test_main.cpp b/test/test_traceroute_nexthop/test_main.cpp new file mode 100644 index 000000000..5f8d546db --- /dev/null +++ b/test/test_traceroute_nexthop/test_main.cpp @@ -0,0 +1,162 @@ +// Tests that TraceRouteModule only learns next_hop from a traceroute response when the route array +// agrees with the node that actually relayed the packet. The route is unauthenticated payload, so +// without that check a forged response could point any node's next_hop anywhere. + +#include "MeshTypes.h" // include BEFORE TestUtil.h +#include "TestUtil.h" +#include + +#include "mesh/NodeDB.h" +#include "modules/TraceRouteModule.h" +#include + +static constexpr NodeNum LOCAL_NODE = 0x0A0A0A0A; // us, the node that asked for the traceroute +static constexpr NodeNum RELAY_B = 0x0B0B0B0B; // the honest first hop back towards us +static constexpr NodeNum NODE_C = 0x0C0C0C0C; +static constexpr NodeNum NODE_D = 0x0D0D0D0D; // the traceroute target, which answers +static constexpr uint8_t ATTACKER_RELAY_BYTE = 0xEE; // some node that is not RELAY_B + +class MockNodeDB : public NodeDB +{ + public: + void clearTestNodes() + { + testNodes.clear(); + meshNodes = &testNodes; + numMeshNodes = 0; + } + + void addNode(NodeNum num) + { + meshtastic_NodeInfoLite node = meshtastic_NodeInfoLite_init_zero; + node.num = num; + testNodes.push_back(node); + meshNodes = &testNodes; + numMeshNodes = testNodes.size(); + } + + uint8_t nextHopOf(NodeNum num) + { + meshtastic_NodeInfoLite *n = getMeshNode(num); + TEST_ASSERT_NOT_NULL(n); + return n->next_hop; + } + + std::vector testNodes; +}; + +// alterReceivedProtobuf is the real entry point; updateNextHops is private behind it. +class TraceRouteModuleTestShim : public TraceRouteModule +{ + public: + using TraceRouteModule::alterReceivedProtobuf; +}; + +static MockNodeDB *mockNodeDB = nullptr; +static TraceRouteModuleTestShim *shim = nullptr; + +// A traceroute response addressed to us, claiming the forward route LOCAL -> RELAY_B -> NODE_C -> NODE_D, +// carried to us by whichever node `relayByte` names. +static meshtastic_MeshPacket makeResponse(uint8_t relayByte, meshtastic_RouteDiscovery *r) +{ + *r = meshtastic_RouteDiscovery_init_zero; + r->route_count = 3; + r->route[0] = RELAY_B; + r->route[1] = NODE_C; + r->route[2] = NODE_D; + + meshtastic_MeshPacket p = meshtastic_MeshPacket_init_zero; + p.from = NODE_D; // the target answered + p.to = LOCAL_NODE; + p.id = 0x1234; + p.relay_node = relayByte; + p.hop_start = 3; + p.hop_limit = 1; + p.which_payload_variant = meshtastic_MeshPacket_decoded_tag; + p.decoded.portnum = meshtastic_PortNum_TRACEROUTE_APP; + p.decoded.request_id = 0x9999; // non-zero marks this a response, which is what drives updateNextHops + return p; +} + +void setUp(void) +{ + mockNodeDB = new MockNodeDB(); + mockNodeDB->clearTestNodes(); + nodeDB = mockNodeDB; + + config = meshtastic_LocalConfig_init_zero; + owner = meshtastic_User_init_zero; + myNodeInfo.my_node_num = LOCAL_NODE; // drives isToUs() + + mockNodeDB->addNode(LOCAL_NODE); + mockNodeDB->addNode(RELAY_B); + mockNodeDB->addNode(NODE_C); + mockNodeDB->addNode(NODE_D); + + shim = new TraceRouteModuleTestShim(); +} + +void tearDown(void) +{ + delete shim; + shim = nullptr; + delete mockNodeDB; + mockNodeDB = nullptr; + nodeDB = nullptr; +} + +// The honest case: the route names RELAY_B as our next hop, and RELAY_B is who handed us the packet. +void test_nexthop_learned_when_route_matches_relay(void) +{ + meshtastic_RouteDiscovery r; + meshtastic_MeshPacket p = makeResponse(nodeDB->getLastByteOfNodeNum(RELAY_B), &r); + + shim->alterReceivedProtobuf(p, &r); + + const uint8_t expected = nodeDB->getLastByteOfNodeNum(RELAY_B); + TEST_ASSERT_EQUAL_MESSAGE(expected, mockNodeDB->nextHopOf(RELAY_B), "next hop for the relay itself"); + TEST_ASSERT_EQUAL_MESSAGE(expected, mockNodeDB->nextHopOf(NODE_C), "next hop for a node beyond the relay"); + TEST_ASSERT_EQUAL_MESSAGE(expected, mockNodeDB->nextHopOf(NODE_D), "next hop for the target"); +} + +// A forged response: the attacker transmits it themselves, so relay_node is their byte, but the route +// claims RELAY_B. Believing the payload here would let them redirect traffic for every node listed. +void test_nexthop_ignored_when_route_contradicts_relay(void) +{ + meshtastic_RouteDiscovery r; + meshtastic_MeshPacket p = makeResponse(ATTACKER_RELAY_BYTE, &r); + + shim->alterReceivedProtobuf(p, &r); + + TEST_ASSERT_EQUAL_MESSAGE(0, mockNodeDB->nextHopOf(RELAY_B), "forged route must not set a next hop"); + TEST_ASSERT_EQUAL_MESSAGE(0, mockNodeDB->nextHopOf(NODE_C), "forged route must not set a next hop"); + TEST_ASSERT_EQUAL_MESSAGE(0, mockNodeDB->nextHopOf(NODE_D), "forged route must not set a next hop"); +} + +// MQTT-sourced packets carry relay_node 0, so nothing corroborates the route and we must not learn. +void test_nexthop_ignored_without_a_relay(void) +{ + meshtastic_RouteDiscovery r; + meshtastic_MeshPacket p = makeResponse(NO_RELAY_NODE, &r); + + shim->alterReceivedProtobuf(p, &r); + + TEST_ASSERT_EQUAL_MESSAGE(0, mockNodeDB->nextHopOf(RELAY_B), "no relay means no corroboration"); + TEST_ASSERT_EQUAL_MESSAGE(0, mockNodeDB->nextHopOf(NODE_C), "no relay means no corroboration"); + TEST_ASSERT_EQUAL_MESSAGE(0, mockNodeDB->nextHopOf(NODE_D), "no relay means no corroboration"); +} + +void setup() +{ + delay(10); + delay(2000); + + initializeTestEnvironment(); + UNITY_BEGIN(); + RUN_TEST(test_nexthop_learned_when_route_matches_relay); + RUN_TEST(test_nexthop_ignored_when_route_contradicts_relay); + RUN_TEST(test_nexthop_ignored_without_a_relay); + exit(UNITY_END()); +} + +void loop() {} diff --git a/test/test_traffic_management/test_main.cpp b/test/test_traffic_management/test_main.cpp index f64ea814f..afdcb9646 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" @@ -31,6 +32,10 @@ namespace constexpr NodeNum kLocalNode = 0x11111111; constexpr NodeNum kRemoteNode = 0x22222222; constexpr NodeNum kTargetNode = 0x33333333; +// A second, distinct requester. The per-requester direct-response throttle (60 s) is keyed on the +// requesting packet's `from`, so tests that exercise the per-target / fallback / sweep throttles use +// a fresh requester for their "served again" step to avoid the per-requester window masking them. +constexpr NodeNum kRemoteNode2 = 0x44444444; // Telemetry hop exhaustion is gated on channel congestion (alterReceived checks // airTime->isTxAllowedChannelUtil/isTxAllowedAirUtil). Installs a global @@ -102,6 +107,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 +193,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 +369,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)); } /** @@ -534,6 +566,8 @@ static void test_tm_nodeinfo_directResponse_respondsFromCache(void) config.device.role = meshtastic_Config_DeviceConfig_Role_CLIENT; config.lora.config_ok_to_mqtt = true; mockNodeDB->setCachedNode(kTargetNode); + // Signed-only replay gate (default) requires the target be a known signer to be served. + mockNodeDB->cachedNodeForTest().bitfield |= NODEINFO_BITFIELD_HAS_XEDDSA_SIGNED_MASK; MockRouter mockRouter; mockRouter.addInterface(std::unique_ptr(new MockRadioInterface())); @@ -542,6 +576,7 @@ static void test_tm_nodeinfo_directResponse_respondsFromCache(void) service = &mockService; TrafficManagementModuleTestShim module; + module.dropNodeInfoCacheForTest(); // exercise the NodeDB fallback path meshtastic_MeshPacket request = makeDecodedPacket(meshtastic_PortNum_NODEINFO_APP, kRemoteNode, kTargetNode); request.decoded.want_response = true; request.id = 0x13572468; @@ -578,6 +613,8 @@ static void test_tm_nodeinfo_directResponse_learnsRequestorNodeInfo(void) moduleConfig.traffic_management.nodeinfo_direct_response_max_hops = 10; config.device.role = meshtastic_Config_DeviceConfig_Role_CLIENT; mockNodeDB->setCachedNode(kTargetNode); + // Signed-only replay gate (default) requires the target be a known signer to be served. + mockNodeDB->cachedNodeForTest().bitfield |= NODEINFO_BITFIELD_HAS_XEDDSA_SIGNED_MASK; MockRouter mockRouter; mockRouter.addInterface(std::unique_ptr(new MockRadioInterface())); @@ -586,6 +623,7 @@ static void test_tm_nodeinfo_directResponse_learnsRequestorNodeInfo(void) service = &mockService; TrafficManagementModuleTestShim module; + module.dropNodeInfoCacheForTest(); // exercise the NodeDB fallback path meshtastic_MeshPacket request = makeNodeInfoPacket(kRemoteNode, "requester-long", "rq"); request.to = kTargetNode; request.decoded.want_response = true; @@ -614,7 +652,9 @@ static void test_tm_nodeinfo_directResponse_ignoresUnsignedSignerIdentity(void) { moduleConfig.traffic_management.nodeinfo_direct_response_max_hops = 10; config.device.role = meshtastic_Config_DeviceConfig_Role_CLIENT; - mockNodeDB->setCachedNode(kTargetNode); // the direct-response target + mockNodeDB->setCachedNode(kTargetNode); // the direct-response target + // Signed-only replay gate (default) requires the target be a known signer to be served. + mockNodeDB->cachedNodeForTest().bitfield |= NODEINFO_BITFIELD_HAS_XEDDSA_SIGNED_MASK; mockNodeDB->setSignerHotNode(kRemoteNode, "victim-real"); // the requester is a known signer MockRouter mockRouter; @@ -624,6 +664,7 @@ static void test_tm_nodeinfo_directResponse_ignoresUnsignedSignerIdentity(void) service = &mockService; TrafficManagementModuleTestShim module; + module.dropNodeInfoCacheForTest(); // exercise the NodeDB fallback path this test was written for meshtastic_MeshPacket request = makeNodeInfoPacket(kRemoteNode, "attacker-name", "atk"); request.to = kTargetNode; request.decoded.want_response = true; @@ -667,11 +708,10 @@ static void test_tm_nodeinfo_clientClamp_skipsWhenNotDirect(void) TEST_ASSERT_FALSE(module.ignoreRequestFlag()); } -#if !(defined(ARCH_ESP32) && defined(BOARD_HAS_PSRAM)) /** - * Verify non-PSRAM builds require NodeDB for direct NodeInfo responses. + * Verify the NodeDB-fallback path requires NodeDB for direct NodeInfo responses. * Important because fallback should only happen through node-wide data when - * the dedicated PSRAM cache does not exist. + * the dedicated NodeInfo cache does not exist. */ static void test_tm_nodeinfo_directResponse_withoutNodeDbEntry_skips(void) { @@ -686,6 +726,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; @@ -699,11 +740,10 @@ static void test_tm_nodeinfo_directResponse_withoutNodeDbEntry_skips(void) TEST_ASSERT_EQUAL_UINT32(0, stats.nodeinfo_cache_hits); TEST_ASSERT_EQUAL_UINT32(0, static_cast(mockRouter.sentPackets.size())); } -#endif -#if defined(ARCH_ESP32) && defined(BOARD_HAS_PSRAM) +#if TMM_HAS_NODEINFO_CACHE /** - * Verify PSRAM NodeInfo cache can answer requests without NodeDB and that + * Verify the NodeInfo cache can answer requests without NodeDB and that * shouldRespondToNodeInfo() uses cached bitfield metadata. */ static void test_tm_nodeinfo_directResponse_psramCacheRespondsAndPreservesBitfield(void) @@ -729,6 +769,8 @@ static void test_tm_nodeinfo_directResponse_psramCacheRespondsAndPreservesBitfie ProcessMessage observedResult = module.handleReceived(observed); TEST_ASSERT_EQUAL_INT(static_cast(ProcessMessage::CONTINUE), static_cast(observedResult)); + // Signed-only replay gate (default) requires signer-proven provenance to serve. + module.markKeySignerProvenForTest(kTargetNode); meshtastic_MeshPacket request = makeDecodedPacket(meshtastic_PortNum_NODEINFO_APP, kRemoteNode, kTargetNode); request.decoded.want_response = true; @@ -755,9 +797,9 @@ static void test_tm_nodeinfo_directResponse_psramCacheRespondsAndPreservesBitfie } /** - * Verify PSRAM cache misses do not fall back to NodeDB. - * Important so the dedicated PSRAM index stays logically separate from - * NodeInfoModule/NodeDB when PSRAM is available. + * Verify NodeInfo cache misses do not fall back to NodeDB. + * Important so the dedicated cache index stays logically separate from + * NodeInfoModule/NodeDB when the cache is available. */ static void test_tm_nodeinfo_directResponse_psramMissDoesNotFallbackToNodeDb(void) { @@ -785,8 +827,1395 @@ static void test_tm_nodeinfo_directResponse_psramMissDoesNotFallbackToNodeDb(voi TEST_ASSERT_EQUAL_UINT32(0, stats.nodeinfo_cache_hits); TEST_ASSERT_EQUAL_UINT32(0, static_cast(mockRouter.sentPackets.size())); } + +/** + * Verify a 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())); +} + +/** + * Per-target direct-response throttle on the PSRAM cache path: repeated NodeInfo requests for the + * same target yield one spoofed reply per 60 s window - even from a DIFFERENT requester, since the + * target axis is independent of the requester axis - then a reply is allowed again once it elapses. + */ +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 window, from a DIFFERENT requester so only the per-target axis can + // throttle it: 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 < 60 s window + request.from = kRemoteNode2; + 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 per-target window: served again. Back to the original requester, whose own 60 s + // per-requester window from the first reply has also elapsed, so only the per-target release is + // under test here. + TrafficManagementModule::s_testNowMs += 60000; // now > 60 s since first reply + request.from = kRemoteNode; + 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 and the hot store are reset in tearDown()/setUp(). +} + +/** + * 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 is reset in tearDown(). +} + +/** + * 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 kFlagMember = 2; +constexpr int kFlagFullUser = 4; + +/** + * 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); +} + +/** + * 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")); +} + +// Fill `count` NodeInfo cache slots with TOFU-keyed observed strangers (eviction tier 1), +// numbered from `baseNode`, all sharing key byte 0x0F. +static void fillNodeInfoCacheWithTofuStrangers(TrafficManagementModuleTestShim &module, uint32_t count, NodeNum baseNode) +{ + for (uint32_t i = 0; i < count; i++) + module.handleReceived(makeNodeInfoPacketWithKey(baseNode + i, "filler", 0x0F)); +} + +/** + * Tiered LRU eviction, tier boundaries: with the cache exactly full, a new stranger's insert + * evicts a keyless stranger - never a TOFU-keyed entry, a signer-proven entry, or a NodeDB + * member - even though those higher-tier entries are the OLDEST observations in the cache + * (tier outranks recency). + */ +static void test_tm_nodeinfo_eviction_keyedTiersOutrankKeyless(void) +{ + mockNodeDB->clearCachedNode(); + mockNodeDB->rollHotStore(); + TrafficManagementModuleTestShim module; + + constexpr NodeNum kTofu = 0x51000001, kProven = 0x51000002, kMember = 0x51000003, kNewcomer = 0x51000004; + module.handleReceived(makeNodeInfoPacketWithKey(kTofu, "tofu", 0x11)); + module.handleReceived(makeNodeInfoPacketWithKey(kProven, "proven", 0x22)); + module.markKeySignerProvenForTest(kProven); + uint8_t memberKey[32]; + memset(memberKey, 0x33, sizeof(memberKey)); + module.onNodeKeyCommitted(kMember, memberKey, false); + + // Age the specials by two observation ticks, then fill every remaining slot with + // fresher keyless strangers so the cache is exactly full. + TrafficManagementModule::s_testNowMs += 2UL * 180000UL; + const uint16_t cap = TrafficManagementModuleTestShim::nodeInfoCacheCapacityForTest(); + fillNodeInfoCacheWithKeylessStrangers(module, cap - 3u, 0x40000000); + + // The newcomer's insert must claim a keyless slot. + module.handleReceived(makeNodeInfoPacket(kNewcomer, "newcomer", "nc")); + + uint8_t key[32] = {0}; + TEST_ASSERT_TRUE(module.copyPublicKey(kTofu, key, nullptr)); + TEST_ASSERT_TRUE(module.copyPublicKey(kProven, key, nullptr)); + TEST_ASSERT_TRUE(module.copyPublicKey(kMember, key, nullptr)); + TEST_ASSERT_TRUE(module.peekNodeInfoFlagsForTest(kNewcomer) >= 0); +} + +/** + * Tiered LRU eviction, keyed tiers: in a cache saturated with TOFU-keyed strangers, keyed + * inserts displace TOFU entries while a signer-proven stranger and a NodeDB member survive + * (keyless < TOFU < signer-proven, +membership). + */ +static void test_tm_nodeinfo_eviction_tofuLosesBeforeProvenAndMember(void) +{ + mockNodeDB->clearCachedNode(); + mockNodeDB->rollHotStore(); + TrafficManagementModuleTestShim module; + + constexpr NodeNum kFillBase = 0x40000000; + constexpr NodeNum kProven = 0x52000001, kMember = 0x52000002, kNew1 = 0x52000003, kNew2 = 0x52000004; + + const uint16_t cap = TrafficManagementModuleTestShim::nodeInfoCacheCapacityForTest(); + fillNodeInfoCacheWithTofuStrangers(module, cap - 2u, kFillBase); + module.handleReceived(makeNodeInfoPacketWithKey(kProven, "proven", 0x22)); + module.markKeySignerProvenForTest(kProven); + uint8_t memberKey[32]; + memset(memberKey, 0x33, sizeof(memberKey)); + module.onNodeKeyCommitted(kMember, memberKey, false); + + // Age the saturated cache so each newcomer is fresher than the remaining fillers + // (otherwise the second insert would reclaim the first newcomer's equal-age slot). + TrafficManagementModule::s_testNowMs += 2UL * 180000UL; + module.handleReceived(makeNodeInfoPacketWithKey(kNew1, "new1", 0x44)); + module.handleReceived(makeNodeInfoPacketWithKey(kNew2, "new2", 0x55)); + + uint8_t key[32] = {0}; + TEST_ASSERT_TRUE(module.copyPublicKey(kProven, key, nullptr)); + TEST_ASSERT_TRUE(module.copyPublicKey(kMember, key, nullptr)); + TEST_ASSERT_TRUE(module.copyPublicKey(kNew1, key, nullptr)); + TEST_ASSERT_TRUE(module.copyPublicKey(kNew2, key, nullptr)); + // The two victims were the first TOFU fillers scanned, not the protected entries. + TEST_ASSERT_FALSE(module.copyPublicKey(kFillBase + 0, key, nullptr)); + TEST_ASSERT_FALSE(module.copyPublicKey(kFillBase + 1, key, nullptr)); +} + +/** + * Within-tier LRU: among same-tier entries the stalest observation is evicted first, not + * whichever slot happens to be scanned first. + */ +static void test_tm_nodeinfo_eviction_withinTierStalestLoses(void) +{ + mockNodeDB->clearCachedNode(); + mockNodeDB->rollHotStore(); + TrafficManagementModuleTestShim module; + + constexpr NodeNum kFillBase = 0x40000000; + constexpr NodeNum kStale = 0x53000001, kNewcomer = 0x53000002; + module.handleReceived(makeNodeInfoPacketWithKey(kStale, "stale", 0x11)); + + // Everything else is observed two ticks later... + TrafficManagementModule::s_testNowMs += 2UL * 180000UL; + const uint16_t cap = TrafficManagementModuleTestShim::nodeInfoCacheCapacityForTest(); + fillNodeInfoCacheWithTofuStrangers(module, cap - 1u, kFillBase); + + // ...so the newcomer's insert reclaims kStale's slot specifically. + module.handleReceived(makeNodeInfoPacketWithKey(kNewcomer, "newcomer", 0x22)); + + uint8_t key[32] = {0}; + TEST_ASSERT_FALSE(module.copyPublicKey(kStale, key, nullptr)); + TEST_ASSERT_TRUE(module.peekNodeInfoFlagsForTest(kNewcomer) >= 0); + TEST_ASSERT_TRUE(module.copyPublicKey(kFillBase + 0, key, nullptr)); // fresher same-tier entry survives +} + +/** + * Member saturation: with every slot holding a NodeDB member, the write-through hooks skip + * rather than churn one member out for another (spareMembers), while the packet path - a + * genuinely observed frame - does evict a member. + */ +static void test_tm_nodeinfo_memberSaturated_hooksSkipPacketPathEvicts(void) +{ + mockNodeDB->clearCachedNode(); + mockNodeDB->rollHotStore(); + TrafficManagementModuleTestShim module; + + constexpr NodeNum kFillBase = 0x40000000; + constexpr NodeNum kExtra = 0x54000001, kObserved = 0x54000002; + + const uint16_t cap = TrafficManagementModuleTestShim::nodeInfoCacheCapacityForTest(); + uint8_t k[32]; + memset(k, 0x11, sizeof(k)); + for (uint32_t i = 0; i < cap; i++) + module.onNodeKeyCommitted(kFillBase + i, k, false); + + // Hook inserts for one more member: skipped rather than evicting an existing member. + uint8_t extraKey[32]; + memset(extraKey, 0x22, sizeof(extraKey)); + module.onNodeKeyCommitted(kExtra, extraKey, false); + uint8_t key[32] = {0}; + TEST_ASSERT_FALSE(module.copyPublicKey(kExtra, key, nullptr)); + module.onNodeIdentityCommitted(kExtra, makeCommittedUser("extra", 0x22), false); + meshtastic_User out = meshtastic_User_init_zero; + TEST_ASSERT_FALSE(module.copyUser(kExtra, out, nullptr)); + + // The packet path may churn a member: the observed stranger lands (in the first-scanned + // member's slot) and is correctly NOT marked a member itself. + module.handleReceived(makeNodeInfoPacketWithKey(kObserved, "observed", 0x33)); + TEST_ASSERT_TRUE(module.copyPublicKey(kObserved, key, nullptr)); + const int flags = module.peekNodeInfoFlagsForTest(kObserved); + TEST_ASSERT_TRUE(flags >= 0); + TEST_ASSERT_TRUE(flags & kFlagObserved); + TEST_ASSERT_FALSE(flags & kFlagMember); + TEST_ASSERT_FALSE(module.copyPublicKey(kFillBase + 0, key, nullptr)); // the evicted member +} + +/** + * Full DB reset: NodeDB::resetNodes() purges this module's caches through purgeAll(), so no + * identity, key, or next-hop hint survives a user-initiated "forget everything". + */ +static void test_tm_nodeinfo_resetNodes_purgesAllCaches(void) +{ + moduleConfig.traffic_management.nodeinfo_direct_response_max_hops = 10; + config.device.role = meshtastic_Config_DeviceConfig_Role_CLIENT; + mockNodeDB->clearCachedNode(); + mockNodeDB->rollHotStore(); + + MockRouter mockRouter; + mockRouter.addInterface(std::unique_ptr(new MockRadioInterface())); + MeshService mockService; + router = &mockRouter; + service = &mockService; + + TrafficManagementModuleTestShim module; + trafficManagementModule = &module; // the NodeDB purge hook reaches the module via the global + + module.handleReceived(makeNodeInfoPacketWithKey(kTargetNode, "victim", 0x37)); + module.setNextHop(kTargetNode, 0x42); + uint8_t key[32] = {0}; + TEST_ASSERT_TRUE(module.copyPublicKey(kTargetNode, key, nullptr)); + TEST_ASSERT_EQUAL_UINT8(0x42, module.getNextHopHint(kTargetNode)); + + mockNodeDB->resetNodes(); + + TEST_ASSERT_FALSE(module.copyPublicKey(kTargetNode, key, nullptr)); + meshtastic_User out = meshtastic_User_init_zero; + TEST_ASSERT_FALSE(module.copyUser(kTargetNode, out, nullptr)); + TEST_ASSERT_EQUAL_INT(-1, module.peekNodeInfoFlagsForTest(kTargetNode)); + TEST_ASSERT_EQUAL_UINT8(0, module.getNextHopHint(kTargetNode)); + // trafficManagementModule is reset in tearDown(). +} + +/** + * A NodeInfo advertising OUR OWN public key is impersonating us and must never be cached + * (mirrors NodeDB::updateUser()'s key hygiene for the store that feeds spoofed replies). + */ +static void test_tm_nodeinfo_cache_dropsFrameCarryingOwnerKey(void) +{ + mockNodeDB->clearCachedNode(); + TrafficManagementModuleTestShim module; + + owner.public_key.size = 32; + memset(owner.public_key.bytes, 0x42, 32); + + module.handleReceived(makeNodeInfoPacketWithKey(kTargetNode, "imposter", 0x42)); + + TEST_ASSERT_EQUAL_INT(-1, module.peekNodeInfoFlagsForTest(kTargetNode)); + uint8_t key[32] = {0}; + TEST_ASSERT_FALSE(module.copyPublicKey(kTargetNode, key, nullptr)); + // owner.public_key is reset in tearDown() (guaranteed even if an assertion above aborts). +} +#endif // !MESHTASTIC_EXCLUDE_PKI #endif +/** + * 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_fallbackStaleEntryNotServed(void) +{ + moduleConfig.traffic_management.nodeinfo_direct_response_max_hops = 10; + config.device.role = meshtastic_Config_DeviceConfig_Role_CLIENT; + + // Drive getTime() to a known uptime so sinceLastSeen() is deterministic. + setBootRelativeTimeForUnitTest(1000000); + const uint32_t now = getTime(); + + mockNodeDB->setCachedNode(kTargetNode); + mockNodeDB->cachedNodeForTest().last_heard = now - (7UL * 60UL * 60UL); // 7 h ago -> stale + // 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())); + 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_EQUAL_UINT32(0, stats.nodeinfo_cache_hits); + TEST_ASSERT_EQUAL_UINT32(0, static_cast(mockRouter.sentPackets.size())); + + resetRTCStateForTests(); +} + +/** + * Verify the NodeDB-fallback path still answers when the node was heard recently. + */ +static void test_tm_nodeinfo_directResponse_fallbackFreshEntryServed(void) +{ + moduleConfig.traffic_management.nodeinfo_direct_response_max_hops = 10; + config.device.role = meshtastic_Config_DeviceConfig_Role_CLIENT; + + setBootRelativeTimeForUnitTest(1000000); + const uint32_t now = getTime(); + + mockNodeDB->setCachedNode(kTargetNode); + mockNodeDB->cachedNodeForTest().last_heard = now - 60UL; // 1 min ago -> fresh + // 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.hop_start = 3; + request.hop_limit = 3; + + ProcessMessage result = module.handleReceived(request); + meshtastic_TrafficManagementStats stats = module.getStats(); + + TEST_ASSERT_EQUAL_INT(static_cast(ProcessMessage::STOP), static_cast(result)); + TEST_ASSERT_EQUAL_UINT32(1, stats.nodeinfo_cache_hits); + TEST_ASSERT_EQUAL_UINT32(1, static_cast(mockRouter.sentPackets.size())); + + resetRTCStateForTests(); +} + +/** + * Per-target direct-response throttle on the NodeDB-fallback path (no PSRAM NodeInfo cache). The + * per-target RAM table is not the cache, so it throttles this path identically: a burst for a fresh + * node yields one spoofed reply per 60 s window, even from a different requester, then serves again. + */ +static void test_tm_nodeinfo_directResponse_fallbackThrottlesWithinWindow(void) +{ + moduleConfig.traffic_management.nodeinfo_direct_response_max_hops = 10; + config.device.role = meshtastic_Config_DeviceConfig_Role_CLIENT; + + setBootRelativeTimeForUnitTest(1000000); + const uint32_t now = getTime(); + TrafficManagementModule::s_testNowMs = 3600000; // known base for the throttle windows + + 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())); + 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; + + // First request: served from the NodeDB fallback. + request.id = 0xBBBB0001; + TEST_ASSERT_EQUAL_INT(static_cast(ProcessMessage::STOP), static_cast(module.handleReceived(request))); + TEST_ASSERT_EQUAL_UINT32(1, static_cast(mockRouter.sentPackets.size())); + + // Second request within the window, from a DIFFERENT requester so only the per-target axis can + // throttle it: 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 < 60 s window + request.from = kRemoteNode2; + 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())); + + // Past the per-target window: served again. Back to the original requester (its 60 s per-requester + // window from the first reply has also elapsed), so only the per-target release is under test. + TrafficManagementModule::s_testNowMs += 60000; // now > 60 s since first reply + request.from = kRemoteNode; + 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(); +} + +/** + * The per-requester and global-floor axes, isolated from per-target (which has its own cache/fallback + * tests). Both bound spoofed direct replies by the unauthenticated requester address and by total + * airtime; each step keeps the per-target axis fresh (distinct targets) so only the axis under test + * gates the reply: + * - the same requester asking for a DIFFERENT target within 60 s is still throttled (the + * reflector-flood case: the per-target axis alone would not bound this); + * - a fresh requester is served, but only once the 1 s global floor has passed; + * - after the per-requester window elapses, the original requester is served again. + */ +static void test_tm_nodeinfo_directResponse_perRequesterAndGlobalFloor(void) +{ + moduleConfig.traffic_management.nodeinfo_direct_response_max_hops = 10; + config.device.role = meshtastic_Config_DeviceConfig_Role_CLIENT; + mockNodeDB->clearCachedNode(); + + const uint32_t base = 3600000; + TrafficManagementModule::s_testNowMs = base; + + MockRouter mockRouter; + mockRouter.addInterface(std::unique_ptr(new MockRadioInterface())); + MeshService mockService; + router = &mockRouter; + service = &mockService; + + TrafficManagementModuleTestShim module; + + // Three distinct, freshly-observed, signer-proven targets. Using a fresh target on each step keeps + // the per-target axis from ever being the bound here, so the reply is gated only by the axis under + // test: per-requester (step 2) or the global floor (step 4). + constexpr NodeNum kTargetA = 0x33330001, kTargetB = 0x33330002, kTargetC = 0x33330003; + constexpr NodeNum kRemoteNode3 = 0x66666666; + for (NodeNum t : {kTargetA, kTargetB, kTargetC}) { + module.handleReceived(makeNodeInfoPacket(t, "target-long", "tg")); + module.markKeySignerProvenForTest(t); + } + + meshtastic_MeshPacket request = makeDecodedPacket(meshtastic_PortNum_NODEINFO_APP, kRemoteNode, kTargetA); + request.decoded.want_response = true; + request.hop_start = 3; + request.hop_limit = 3; + + // First reply for this requester: served. + request.id = 0xC0DE0001; + TEST_ASSERT_EQUAL_INT(static_cast(ProcessMessage::STOP), static_cast(module.handleReceived(request))); + TEST_ASSERT_EQUAL_UINT32(1, static_cast(mockRouter.sentPackets.size())); + + // Same requester, DIFFERENT target, 10 s later: the per-target throttle can't fire (fresh entry), + // but the per-requester window (60 s) still suppresses our TX. Forwarded, not sent. + TrafficManagementModule::s_testNowMs = base + 10000; + request.to = kTargetB; + request.id = 0xC0DE0002; + TEST_ASSERT_EQUAL_INT(static_cast(ProcessMessage::CONTINUE), static_cast(module.handleReceived(request))); + TEST_ASSERT_EQUAL_UINT32(1, static_cast(mockRouter.sentPackets.size())); + + // A DIFFERENT requester (fresh slot) is served - the 1 s global floor has long passed. + request.from = kRemoteNode2; + request.id = 0xC0DE0003; + TEST_ASSERT_EQUAL_INT(static_cast(ProcessMessage::STOP), static_cast(module.handleReceived(request))); + TEST_ASSERT_EQUAL_UINT32(2, static_cast(mockRouter.sentPackets.size())); + + // Yet a third fresh requester in the SAME instant, for a fresh target (so per-target can't be the + // cause), is deferred by the 1 s global airtime floor. Forwarded, not sent. + request.from = kRemoteNode3; + request.to = kTargetC; + request.id = 0xC0DE0004; + TEST_ASSERT_EQUAL_INT(static_cast(ProcessMessage::CONTINUE), static_cast(module.handleReceived(request))); + TEST_ASSERT_EQUAL_UINT32(2, static_cast(mockRouter.sentPackets.size())); + + // After the per-requester window elapses, the original requester is served again. + TrafficManagementModule::s_testNowMs = base + 70000; + request.from = kRemoteNode; + request.to = kTargetA; + request.id = 0xC0DE0005; + TEST_ASSERT_EQUAL_INT(static_cast(ProcessMessage::STOP), static_cast(module.handleReceived(request))); + TEST_ASSERT_EQUAL_UINT32(3, static_cast(mockRouter.sentPackets.size())); +} + +#if TMM_NODEINFO_REPLAY_SIGNED_GATE +/** + * Replay gate (fallback path): a fresh NodeDB node that is NOT a known signer is withheld - + * the courtesy reply is suppressed and the genuine request propagates (CONTINUE, no TX). + */ +static void test_tm_nodeinfo_directResponse_fallbackUnsignedNotServed(void) +{ + moduleConfig.traffic_management.nodeinfo_direct_response_max_hops = 10; + config.device.role = meshtastic_Config_DeviceConfig_Role_CLIENT; + setBootRelativeTimeForUnitTest(1000000); + const uint32_t now = getTime(); + + mockNodeDB->setCachedNode(kTargetNode); + mockNodeDB->cachedNodeForTest().last_heard = now - 60UL; // fresh, passes staleness + // Deliberately NOT flagged as a signer -> the signed-only gate must withhold the reply. + + MockRouter mockRouter; + mockRouter.addInterface(std::unique_ptr(new MockRadioInterface())); + MeshService mockService; + router = &mockRouter; + service = &mockService; + + TrafficManagementModuleTestShim module; + 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); + TEST_ASSERT_EQUAL_INT(static_cast(ProcessMessage::CONTINUE), static_cast(result)); + TEST_ASSERT_EQUAL_UINT32(0, static_cast(mockRouter.sentPackets.size())); + + resetRTCStateForTests(); +} +#endif // TMM_NODEINFO_REPLAY_SIGNED_GATE + /** * Verify relayed telemetry broadcasts are NOT hop-exhausted. * exhaust_hop_telemetry / exhaust_hop_position have been removed from the config @@ -1747,7 +3176,20 @@ void setUp(void) { resetTrafficConfig(); } -void tearDown(void) {} +void tearDown(void) +{ + // Runs even when a TEST_ASSERT_* aborts a test mid-way (Unity longjmps out, skipping any + // cleanup the test itself does after the assertion), so per-test global state can never + // dangle into later cases. The dangling-pointer path is concrete: a test points the global + // at its stack `module`, and the next setUp()'s resetNodes() would then call + // trafficManagementModule->purgeAll() on a destroyed object. + trafficManagementModule = nullptr; + owner.public_key.size = 0; + memset(owner.public_key.bytes, 0, sizeof(owner.public_key.bytes)); + // Neutralize the RTC fake clock a fallback test may have set (the virtual s_testNowMs is + // already reset by setUp via resetTrafficConfig). + setBootRelativeTimeForUnitTest(0); +} TM_TEST_ENTRY void setup() { @@ -1771,12 +3213,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); + RUN_TEST(test_tm_nodeinfo_directResponse_perRequesterAndGlobalFloor); +#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_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); diff --git a/variants/native/portduino.ini b/variants/native/portduino.ini index 8ea17653a..b1756b9aa 100644 --- a/variants/native/portduino.ini +++ b/variants/native/portduino.ini @@ -56,7 +56,6 @@ build_flags_common = -luv -std=gnu17 -std=gnu++17 - -DBASEUI_HAS_GAMES=1 -DMAX_TFT_COLOR_REGIONS=64 build_flags =