TMM extend to ephemeral 3rd tier store (#11050)

* Harden TMM NodeInfo direct-response: staleness, key hygiene, throttle

The NodeInfo direct-response path answered queries on behalf of other nodes
from an unauthenticated, never-expiring cache while suppressing the genuine
request (STOP). That let a long-gone or forged identity be served as a
fresh-looking, authoritative reply indefinitely. Three fixes:

- Staleness gate: refuse to spoof a reply for a node not actually heard within
  ~6h (PSRAM cache via lastObservedMs; NodeDB fallback via sinceLastSeen, which
  tolerates last_heard==0 when the clock is unset). A stale entry now falls
  through so the real request propagates instead of being answered for a dead
  node.

- Key hygiene: mirror NodeDB::updateUser() PKI checks when caching overheard
  NodeInfo - reject a packet advertising our own public key, and pin keys (drop
  a NodeInfo whose key mismatches an already-known key from NodeDB or from our
  own cache). Prevents cache poisoning / key substitution via the spoofed-reply
  path.

- Response throttle: at most one spoofed reply per target node per 30s. Direct
  responses bypass the per-sender rate limiter (they STOP the request first) and
  the reply target is attacker-controlled, so this bounds airtime exhaustion and
  reflected floods. Recorded in a new NodeInfoPayloadEntry.lastResponseMs
  (PSRAM; self-expiring timestamp compare, no sweep needed).

Tests: native NodeDB-fallback staleness (stale drops, fresh serves) plus
PSRAM-guarded staleness, throttle, and key-mismatch cases.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01G7QggdwMM1CJkguuCmMfZs

* Add TODO(T1-T9) markers for code-review findings on NodeInfo hardening

Comment-only. Tags each site flagged in the review of the direct-response
throttle/staleness/key-hygiene change so the follow-ups are visible in-context:

T1 throttle black-holes distinct requestors; T2 per-target key doesn't bound
aggregate TX; T3 non-PSRAM fallback unthrottled; T4 millis-wrap defeats the
staleness gate; T5 redundant second O(n) scan for the throttle stamp;
T6 duplicated 32-byte key compares; T7 sinceLastSeen() called twice;
T8 two 6h constants can desync; T9 clockMs()==0 collides with the 0 sentinel.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01G7QggdwMM1CJkguuCmMfZs

* Address code-review cleanups T6, T7, T8 in NodeInfo direct-response

T8: derive kNodeInfoMaxServeAgeSecs from kNodeInfoMaxServeAgeMs so the PSRAM
    and NodeDB-fallback staleness windows cannot desync.
T6: collapse the three duplicated 32-byte key compares in cacheNodeInfoPacket
    (owner-impersonation + NodeDB pin + cache TOFU pin) into one pubKeysEqual()
    helper, so the compare lives in one place.
T7: compute sinceLastSeen(node) once on the fallback staleness path so the
    tested age and the logged age cannot diverge (it calls getTime() each time).

Remaining review items still marked in-code: T1/T2/T3 (throttle design),
T4/T9 (millis-wrap + 0-sentinel), T5 (redundant throttle-stamp scan).

Native test_traffic_management suite: 48/48 passing.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01G7QggdwMM1CJkguuCmMfZs

* Throttle the NodeDB fallback direct-response path (T3)

The per-target NodeInfo response throttle lived only in the PSRAM
NodeInfoPayloadEntry, so non-PSRAM boards - which answer from the NodeDB
fallback path - emitted spoofed direct replies with no rate limit at all,
leaving the airtime/reflection surface the throttle exists to close.

Add a single module-global fallback stamp (nodeInfoFallbackLastResponseMs,
4 bytes, guarded by cacheLock) that throttles the fallback path to one
spoofed reply per window across all targets. Coarser than the PSRAM
per-target throttle, but the fallback path has no per-node slot to stamp,
and a global cap still bounds spoofed transmissions - which is the point.

Add a native test exercising the fallback throttle window.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01G7QggdwMM1CJkguuCmMfZs

* Make NodeInfo staleness/throttle wrap- and sentinel-safe (T4, T9)

T9: the millis-based fields that use 0 as a "never" sentinel
(lastObservedMs, lastResponseMs, nodeInfoFallbackLastResponseMs) could be
stamped with a literal 0 during the one-millisecond clockMs()==0 instant at
each ~49.7-day wrap, momentarily colliding with the sentinel and disabling
the staleness/throttle gate for that entry. Route all such stamps through a
new nowStampMs() that maps 0 -> 1; the 1 ms skew is irrelevant to every
window these fields feed.

T4: the staleness gate's modular age compare is only unambiguous while true
age < 2^32 ms (~49.7 days); an entry that lingered unrefreshed that long
could wrap to a small age and read as fresh, defeating the gate. Add a
NodeInfo eviction pass to the maintenance sweep that drops entries past the
serve window, so an entry is removed long before its age can approach the
wrap boundary. This also frees slots holding stale identities.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01G7QggdwMM1CJkguuCmMfZs

* Stamp PSRAM throttle entry by captured index, not a rescan (T5)

The post-send throttle stamp did a second full O(n) scan of the PSRAM
NodeInfo array under a fresh lock, even though findNodeInfoEntry() had
already located the slot at the top of shouldRespondToNodeInfo(). Capture
the slot index during that initial lookup and address the entry directly on
the stamp path. Because the cache lock is released between the two accesses,
the slot could have been evicted or reused, so re-validate node == p->to
under the lock before stamping.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01G7QggdwMM1CJkguuCmMfZs

* Document accepted per-target throttle tradeoffs (T1, T2)

Convert the T1/T2 TODO markers into a NOTE: the per-target PSRAM throttle
keying and its lack of an aggregate bound are deliberate design choices, not
pending work. A distinct requestor being throttled for one window is
harmless on a redundant mesh, and keeping the PSRAM path per-target
preserves throughput for legitimate multi-target responders (the fallback
path already bounds aggregate via its global stamp).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01G7QggdwMM1CJkguuCmMfZs

* Track signed-provenance of cached NodeInfo public keys

Add keySignerProven to NodeInfoPayloadEntry, set when an observed NODEINFO
frame's XEdDSA signature was verified (mp.xeddsa_signed). This distinguishes
a trust-on-first-use key from one proven to belong to a signer. The flag is
monotonic - once proven it stays proven, and the existing key-pin checks
forbid the underlying key from changing - so a later unsigned frame cannot
downgrade it. A signature can only be verified against a key we already
held, so a first-contact key is always TOFU until a later signed frame
upgrades it.

Groundwork for using the TMM cache as a last-resort public-key source, where
this flag serves as a trust tier.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01G7QggdwMM1CJkguuCmMfZs

* Draw public keys from the TMM NodeInfo cache as a last resort

Add TrafficManagementModule::copyPublicKey() and consult it from
NodeDB::copyPublicKey() after the hot (NodeInfoLite) and warm
(WarmNodeStore) tiers miss. This extends the pool of peers the node can
encrypt to: a key the NodeInfo direct-response cache overheard for a node
that has since aged out of both NodeDB tiers can still be used.

The getter reports whether the key is signer-proven or trust-on-first-use.
NodeDB serves TOFU keys here too - the same first-contact trust NodeDB
already applies in updateUser() - so the pool actually expands to new
long-tail nodes rather than only re-confirming known keys.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01G7QggdwMM1CJkguuCmMfZs

* Retain keyed NodeInfo entries as a pubkey pool; trust-tier eviction

Now that NodeDB::copyPublicKey() draws keys from the NodeInfo cache, the flat
6 h serve-window eviction would discard useful keys the moment a node stops
being servable. Split retention: an entry carrying a 32-byte public key is
kept for kNodeInfoKeyRetentionMs (7 d), while a keyless entry still expires
at the serve window. Both windows stay well under the ~49.7-day millis wrap,
preserving the T4 wrap-safety guarantee.

Make LRU victim selection trust-tiered to match: a keyless slot is sacrificed
before any keyed one, and a trust-on-first-use key before a signer-proven
key; within a tier the oldest loses. Mirrors WarmNodeStore's keyed-first
admission so the most valuable keys are the stickiest under memory pressure.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01G7QggdwMM1CJkguuCmMfZs

* Test signed-provenance flag and copyPublicKey key-pool source

PSRAM-gated tests (run on the ESP32 build, alongside the existing NodeInfo
PSRAM tests):
- copyPublicKey serves a TOFU key learned from an unsigned NodeInfo and
  reports signerProven=false
- a later signature-verified NodeInfo upgrades provenance to signer-proven
  while the pinned key bytes stay unchanged
- copyPublicKey reports a miss for an uncached node

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01G7QggdwMM1CJkguuCmMfZs

* Inherit signer provenance from NodeDB when caching a re-found node

When the TrafficManagement NodeInfo cache re-caches a node, mark its key
signer-proven if NodeDB already knows the node as a verified signer for that
same key - even if this particular (unicast/unsigned) frame carried no
signature. Previously the flag only upgraded on a frame we verified
ourselves, so a node we had already proven elsewhere looked TOFU here.

Add NodeDB::isVerifiedSignerForKey(), which checks both tiers - the hot
store's signed bitfield and the warm tier's cached signer bit - and requires
the key to match so a rotated/mismatched key never inherits a stale verdict.
Add WarmNodeStore::isVerifiedSigner() to expose the warm signer bit (the
rebase onto develop added the bit itself; this surfaces it for lookups).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01G7QggdwMM1CJkguuCmMfZs

* Lock NodeInfoPayloadEntry packing with a size assert

keySignerProven cost zero bytes: sourceChannel, the two bools, and
decodedBitfield are four 1-byte fields that fill a single 4-byte tail word
(struct alignment is 4), so the flag consumed former padding rather than
growing the 2000-entry PSRAM array. Add a static_assert pinning the entry to
sizeof(meshtastic_User) + 20 so a future 5th trailing byte - which would open
a fresh word (~8 KB PSRAM across the array) - fails the build instead of
silently costing memory, prompting new flags to be packed into existing bytes.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01G7QggdwMM1CJkguuCmMfZs

* Pack NodeInfo cache booleans into 1-bit fields

Fold hasDecodedBitfield and keySignerProven into adjacent uint8_t:1 bitfields
so they share a single byte, reserving 6 spare bits for future flags without
growing the 2000-entry PSRAM array. sizeof is unchanged (still one packed tail
word, sizeof(meshtastic_User) + 20); access is by name exactly as before, so no
call sites change. Reorders decodedBitfield ahead of the flags so the two
1-bit members stay adjacent and the compiler packs them together.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01G7QggdwMM1CJkguuCmMfZs

* Gate NodeInfo replay on signer-proven provenance (compile-time, default on)

Add TMM_NODEINFO_REPLAY_REQUIRE_SIGNED (default 1): the direct-response path
now only spoofs a reply for a node whose key is signer-proven, in addition to
the staleness gate - so replay is based on flagging AND staleness. Replay is a
courtesy feature, and vouching for an unverified (trust-on-first-use) identity
to other nodes is the risk this closes, so signed-only is the safer default.
Define the macro to 0 at build time to also serve fresh TOFU-only nodes.

Both paths are gated: the PSRAM path checks the cached keySignerProven flag,
the NodeDB fallback checks nodeInfoLiteHasXeddsaSigned(node). The effective
gate (TMM_NODEINFO_REPLAY_SIGNED_GATE) is bypassed when PKI is excluded from
the build, since nothing can be signed there and the courtesy feature would
otherwise be disabled outright.

Tests: existing reply-expecting tests establish signer-proven state (a new
markKeySignerProvenForTest hook for the PSRAM cache, the NodeDB signed bit for
the fallback path); add fallback and PSRAM tests asserting an unsigned/TOFU
node is withheld under the default gate.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01G7QggdwMM1CJkguuCmMfZs

* Rehydrate re-admitted node names from the TMM NodeInfo cache

The warm tier keeps an evicted node's key but not its name (the 40-byte
record has no room), so a re-admitted long-tail node is nameless until its
next NodeInfo broadcast. The TrafficManagement NodeInfo cache is much larger
(2000 PSRAM entries) and commonly still holds the full User, so use it as a
name reservoir the warm tier structurally cannot be.

On re-admission (getOrCreateMeshNode), after the warm-tier restore, copy the
cached identity from the TMM cache via a new copyUser() getter - but only when
its cached key matches the key just restored from warm, so a name never
attaches to a different identity than the one we encrypt to (key-matched
trust). Guarded by HAS_TRAFFIC_MANAGEMENT and a null check; a no-op without
the PSRAM cache or when no key is present. CopyUserToNodeInfoLite sets only
user-related bits, so the warm-restored signer bit is preserved.

Limitations (by design): PSRAM-only, and the TMM cache is RAM-only, so this
helps within an uptime session, not across reboot.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01G7QggdwMM1CJkguuCmMfZs

* Drop non-portable NodeInfoPayloadEntry size assert (fixes pico build)

The static_assert pinned sizeof(NodeInfoPayloadEntry) to
sizeof(meshtastic_User) + 20, but nanopb packs the generated User struct
differently per platform: on pico its size is not a multiple of 4, so
alignment padding before the uint32 timestamps makes the overhead 22, not
20, and the assert failed the build (native happened to be +20 and passed,
hiding it). The bitfield packing it was guarding still stands; replace the
fixed-count assert with a comment, since no portable byte count exists.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01G7QggdwMM1CJkguuCmMfZs

* Run the NodeInfo cache paths in native tests (TMM_HAS_NODEINFO_CACHE)

The NodeInfo direct-response cache and everything layered on it - key
pinning, signer provenance, staleness, throttle - was compiled only for
ARCH_ESP32 + BOARD_HAS_PSRAM, so its entire test set was skipped by the
native CI suite and ran nowhere but real hardware.

Fold the scattered platform guards into one TMM_HAS_NODEINFO_CACHE macro
that also enables the cache (plain heap, delete[] path already existed)
for ARCH_PORTDUINO unit-test builds. Production portduino and embedded
test builds are unchanged. Tests that exercise the NodeDB fallback path
drop the cache explicitly via a new dropNodeInfoCacheForTest() hook, so
both response paths are covered in one binary.

Native suite: 60/60 (was 50 with the 10 cache tests compiled out).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KWHDDxY4U2XLYW2WJJ2oLT
(cherry picked from commit b2dafbbbd6501b42fc27175e599fb38be292b0e3)

* Pin NodeInfo cache keys against the warm tier, not just the hot store

cacheNodeInfoPacket() mirrored NodeDB::updateUser()'s key pin but checked
only getMeshNode() - the hot store. updateUser's own pin effectively
covers the warm tier too (getOrCreateMeshNode rehydrates the warm key
before the check), so the mirror had a gap: for a node evicted to the
warm tier whose cache slot was also gone, an attacker's NodeInfo with a
bogus key passed the pin, and the cache's TOFU pin then locked the
genuine node's frames out until the poisoned entry aged away.

Split the authoritative lookup out of NodeDB::copyPublicKey() as
copyPublicKeyAuthoritative() (hot store, then warm tier - never the
opportunistic TMM tier, which would compare the cache against itself)
and pin against that.

Test: warm-tier-only key rejects a mismatching NodeInfo and accepts the
matching one. Native suite: 61/61.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KWHDDxY4U2XLYW2WJJ2oLT
(cherry picked from commit 28e880875ef1f8bf16bdf301a4e6036537a84b4b)

* Convert NodeInfo cache clocks to uint8 tick counters

Replace the entry's uint32 millis stamps (lastObservedMs,
lastResponseMs) with three uint8 free-running tick clocks - the same
modular scheme the UnifiedCache counters already use:

  obsTick   3 min/tick (12.8 h period) - replay staleness gate
  respTick  5 s/tick   (21.3 min)      - per-target response throttle
  retTick   1 h/tick   (10.67 d)       - retention TTL + LRU age

Validity is an explicit flag bit (hasObserved / hasResponded), not a 0
sentinel, and the maintenance sweep saturates a stamp (clears its flag)
once its window passes, so no stamp can age toward its ~256-tick
aliasing horizon. That retires the wrap/sentinel special cases (T4, T9)
- nowStampMs() survives only for the fallback path's module-global
millis stamp. obsTick is separated from retention by design: only a
genuinely heard NODEINFO frame stamps it, so later membership-based
retention refresh can never make a silent node look servable.

Also drops lastObservedRxTime, which was only echoed in a debug log.

Entry shrinks 136 -> 128 B; the 2000-entry array 272 kB -> 256 kB, 8 kB
below the original develop footprint while keeping the throttle and
retention features. Tick granularity costs at most one tick per window
(+-5 s on 30 s, +-3 min on 6 h, +-1 h on 7 d).

Native suite: 61/61.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KWHDDxY4U2XLYW2WJJ2oLT
(cherry picked from commit e48ed9cfacfed0131b77ed2f1992e8720720d387)

* Keep the NodeInfo cache a superset of NodeDB (seed + keep-alive)

The cache learned identities only from overheard NODEINFO frames, so its
membership was opportunistic: a NodeDB-tier node whose frame was never
heard this boot had no entry (no key pin to protect it, no name to
rehydrate), and the retention TTL evicted entries for nodes that still
lived in the hot store or warm tier.

Add an anti-entropy pass to the maintenance sweep
(reconcileNodeInfoMembershipLocked): every hot-store / warm-tier node
gets an entry - full identity from the hot store (hasFullUser), key-only
from the warm tier - with signer provenance inherited key-matched, and
NodeDB's key adopted wholesale on conflict. Member entries (isMember)
are re-stamped each sweep, so they never age toward the retention TTL;
when a node leaves both tiers the keep-alive stops and its entry ages
out from its final member re-stamp. Membership also outranks key trust
in LRU victim selection, and the reconcile pass skips seeding rather
than churn one member out for another when hot+warm exceeds the cache.

Two properties are load-bearing:
 - seeding/keep-alive never touch obsTick/hasObserved, so a seeded or
   retained entry is never served as a spoofed reply - only genuinely
   heard frames make a node servable;
 - copyUser() now requires hasFullUser, so a key-only warm seed can
   never stamp HAS_USER onto a nameless node via name-rehydration.

Native suite: 64/64.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KWHDDxY4U2XLYW2WJJ2oLT
(cherry picked from commit 4f1b30d838b2f7b11173373b8f1ba97cf91bef6b)

* Write-through NodeDB identity commits into the NodeInfo cache

updateUser() is the single chokepoint through which every remote
identity/key write enters NodeDB (key-verification keys stay in
CryptoEngine's pending buffer until a NodeInfo carries them here), so
one hook at its tail lets the NodeInfo cache reflect a commit
immediately instead of waiting up to a minute for the reconcile sweep -
which stays in place as the anti-entropy backstop.

onNodeIdentityCommitted() upserts the full User: NodeDB's key is
authoritative (a conflicting cached key is stale residue - replaced,
provenance dropped), a keyless commit keeps an already-cached TOFU key,
and signer provenance transfers only for the committed key. The
observation stamp is never touched: knowledge is not observation, so a
hook write can never make a never-heard node servable - covered by the
new test alongside immediate copyUser/copyPublicKey visibility.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KWHDDxY4U2XLYW2WJJ2oLT
(cherry picked from commit e160b899728be8d951d4e7bdf5106c777832b917)

* Purge TrafficManagement caches on explicit node removal

NodeDB::removeNodeByNum() already forgets the node everywhere NodeDB
owns - hot store, satellite stores, warm tier - but the TrafficManagement
caches kept the deleted identity: the NodeInfo entry went on feeding the
key pool (NodeDB::copyPublicKey) and name rehydration, and the unified
slot kept its role/next-hop/dedup state, resurrecting the node on next
contact.

Add purgeNode(): clears both the unified cache slot and the NodeInfo
cache entry, called from removeNodeByNum() alongside the warm-tier
removal. Removal means full removal; passive eviction never calls this,
and the reconcile sweep will not re-seed a node absent from both NodeDB
tiers.

Test: an observed identity plus a next-hop hint both vanish after
removeNodeByNum(). Left for CI to execute per request.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KWHDDxY4U2XLYW2WJJ2oLT
(cherry picked from commit b5564c173337de99e15ef998a5c698550238fd3c)

* Document the tmm-fix-2 / tmm-fix-superset reconciliation decisions

Two parallel implementations of the same superset design are being
combined on this branch. This decision matrix records every divergence
and which side each reconciled feature takes, ahead of the code commits
that apply them.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KWHDDxY4U2XLYW2WJJ2oLT

* Reconcile retention: no timed eviction, obsTick LRU, hourly seeding

Adopt tmm-fix-superset's retention model (reconciliation decisions #3,
#4, #9 in .notes/tmm-super-superset-reconciliation.md):

- Entries are never evicted on a timer. The 7-day retention TTL and its
  third tick clock (retTick) are gone; wrap-safety rests entirely on the
  sweep's presence-bit saturation, and a quiet entry keeps its value
  (pubkey pool, name rehydration). Slots are reclaimed only by
  trust/membership-tiered LRU on insert - age scored by obsTick, with
  never-observed entries counting oldest - or by an explicit purge.
- Membership refresh (entry -> NodeDB contains checks) runs every sweep;
  the heavy seeding pass runs at boot and then hourly, with the
  write-through hooks carrying immediacy in between.
- Tick constants and helpers move into the header beside the existing
  UnifiedCache tick idiom, with static_asserts tying the tick windows to
  the fallback path's seconds/ms forms.

Kept from tmm-fix-2 (decision #4): the warm tier is still seeded
(key-only records via WarmNodeStore::entryAt) so the invariant remains
cache superset-of hot AND warm, and the spareMembers guard still stops
seeding from churning one member out for another when hot+warm exceeds
the cache. Entry shrinks to 128 B (2000 entries = 256 kB).

The TTL-based retention test is superseded by a no-timed-eviction test:
a quiet keyed entry survives 9 days of sweeps while the serve gate
saturates as before.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KWHDDxY4U2XLYW2WJJ2oLT

* Reconcile hooks: key-commit write-through, purgeAll, key-matched signer

Adopt tmm-fix-superset's hook coverage (reconciliation decisions #5-#8):

- onNodeKeyCommitted(): write-through for the two key-write sites that
  bypass NodeDB::updateUser - the Router's admin-key learn (TOFU-grade)
  and KeyVerificationModule's manual-verification commit (proven=true,
  the strongest provenance the cache can carry). Both were coverage gaps
  in the tmm-fix-2 write-through design.
- updateUser's hook call now transfers signer status key-matched
  (isVerifiedSignerForKey) instead of the node's bare signed flag, and
  runs on acceptance rather than only on change.
- purgeAll(): factoryReset() and resetNodes() clear both TMM tables -
  removal-is-full-removal applies to bulk resets too, without relying on
  the usual post-reset reboot.
- purgeNode() reuses the existing finders instead of open-coded scans
  and logs the (user-initiated) purge.
- peekNodeInfoFlagsForTest(): flag introspection so saturation and
  membership tests can assert sweep effects directly.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KWHDDxY4U2XLYW2WJJ2oLT

* Merge the two branches' test suites for the reconciled design

Port tmm-fix-superset's unique tests, adapted to run natively under
TMM_HAS_NODEINFO_CACHE (reconciliation decision #11):

- keyHook_upsertsAndGovernsProvenance: TOFU learn -> manual-verification
  upgrade -> NodeDB-senior rotation resets provenance.
- tickSaturation_sweepClearsObserved: the sweep saturates hasObserved
  past the serve window, the entry persists (no TTL), and a full
  256-tick clock wrap cannot alias a saturated stamp back to fresh.
- sweepMembershipMarking: reconciliation seeds a hot identity as
  member+fullUser+unobserved; the next sweep clears membership once the
  node leaves NodeDB, while the entry itself persists.

tmm-fix-2's tests (warm pin, hot/warm seeding, updateUser write-through,
removal purge - now also asserting the entry is gone via the new peek
hook - and the no-timed-eviction rewrite) were already on this branch.
Suite now counts 70 test functions.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KWHDDxY4U2XLYW2WJJ2oLT

* Adapt cherry-picked and seeding tests to the reconciled semantics

Two tests needed updating for interactions the validation run surfaced:

- The #11035 test (ignoresUnsignedSignerIdentity) was written for a
  world without the native NodeInfo cache: it needs the NodeDB fallback
  path (dropNodeInfoCacheForTest) and the signed replay gate satisfied
  for its target, like the other fallback tests on this branch.
- The hot-seed test's "observed frame makes it servable" step now sends
  a signature-verified frame: its node is a known signer, and per #11035
  an unsigned frame from a signer must not (and does not) drive cache
  writes - the gate working as designed.

Full native suite: 70/70 under ASan.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KWHDDxY4U2XLYW2WJJ2oLT

* trunk fml

* Doc tidyup

* TrafficManagement: key NodeInfo-cache maintenance to its own guard

runOnce() nested the entire NodeInfo maintenance block (tick-stamp
saturation, membership refresh, boot/hourly reconcile) inside
#if TRAFFIC_MANAGEMENT_CACHE_SIZE > 0, so a variant overriding the
unified cache to 0 on a PSRAM board would compile the NodeInfo cache
without its maintenance: hasObserved would never saturate, obsTick
would alias past its 12.8 h wrap, and the 6 h staleness gate - whose
wrap-safety explicitly depends on the sweep - would serve spoofed
replies for long-gone nodes. Extract maintainNodeInfoCacheLocked(),
guarded by TMM_HAS_NODEINFO_CACHE alone, and give runOnce() the same
independent-guard structure purgeAll() already has.

Also close the runtime cousin of the same mismatch: the write-through
hooks (onNodeIdentityCommitted / onNodeKeyCommitted) now no-op while
moduleConfig.has_traffic_management is off. Previously they kept
filling the cache from NodeDB commits while runOnce() returned
INT32_MAX, accumulating entries that were never swept or reconciled.
Purges and reads stay ungated: removal must always work, and reads
just miss an empty cache.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* TrafficManagement: forward throttled NodeInfo requests instead of consuming them

The direct-response throttle returned true, which made handleReceived()
STOP the request: within the 30 s window a repeat request was neither
answered nor forwarded toward the genuine target, and the call site
still logged "respond" and bumped nodeinfo_cache_hits for a reply that
was never sent. A requester whose first reply was lost on a noisy link
got silence for the whole window.

Return false instead: the request flows through normal relay handling,
so the genuine node (or another cache-holder) can answer, while our own
spoofed TX stays bounded. Repeats of the same packet id are already
absorbed by the router's duplicate detection, and the stats counter now
only counts replies actually sent.

Also log purgeNode() only when a slot was actually cleared - it runs
for every NodeDB removal, including nodes the caches never tracked.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* TrafficManagement: consolidate NodeInfo-cache #if sprawl into one region

Fourteen scattered #if TMM_HAS_NODEINFO_CACHE guards (one per function,
each with its own #else stub of (void) casts) collapse into a single
guarded region holding every NodeInfo-cache-only function, terminated
by one #else block of no-op stub definitions. Because the stubs now
exist on every build, the call sites in runOnce(), purgeNode() and
purgeAll() drop their guards too; inner guards remain only for
orthogonal features (PKI, warm tier) and for real conditional work
(constructor allocation, PSRAM paths in shouldRespondToNodeInfo).

No behavior change. Compile-checked on both sides of the macro: the
native app build (stubs) and the native unit-test build (region).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Router: resolve sender key only for PKI-decrypt candidates

perhapsDecode() resolved the sender's public key unconditionally for
every encrypted packet, before even checking whether the packet could
be PKI-decrypted (channel 0, unicast to us). Since copyPublicKey()
grew its TrafficManagement fallback tier, a full hot+warm miss - the
common case for channel traffic from senders outside both NodeDB
tiers - additionally walked the 2000-entry NodeInfo cache under its
lock, per packet, for a key that was then discarded.

Move the resolution inside the PKI-candidate branch; remotePublic and
haveRemoteKey had no consumers outside it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* TrafficManagement: refresh NodeInfo membership hourly, not per-sweep

The 60 s sweep re-derived isMember with a per-entry NodeDB lookup:
O(entries x members) - about 700k node-number comparisons per minute on
a full PSRAM cache (2000 entries x 250 hot + 100 warm), with strided
PSRAM reads, all while holding cacheLock against the packet path.

Move the refresh into the hourly reconcile pass, which is already
O(members x entries): after the seeding loops (so the upsert pass still
sees last pass's bits for spareMembers protection), clear every
isMember bit and re-mark from both NodeDB tiers - including keyless
warm records, which seed nothing but are still members.

Accepted tradeoff, now documented on the field: membership lags a
passive NodeDB eviction by up to an hour (the entry just stays
LRU-sticky slightly longer). Additions stay immediate via the
write-through hooks, explicit removals via purgeNode().

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* NodeDB: centralize bare-key commits in commitRemoteKey()

The two key-write sites that bypass updateUser() (admin-channel learn
in Router::perhapsDecode, manual verification in KeyVerificationModule)
each carried their own #if HAS_TRAFFIC_MANAGEMENT write-through
boilerplate - the pattern this PR itself had to retrofit twice, and
which any future direct key write would silently miss, leaving the
TrafficManagement cache divergent until the next hourly reconcile.

Add NodeDB::commitRemoteKey(n, key32, KeyCommitTrust): writes the key
to the hot store and routes the TrafficManagement write-through in one
place, with provenance explicit at the call site (AdminChannelProven
maps to TOFU-grade, ManuallyVerified to proven). The bypass sites keep
their reason for existing - bare-key commits with provenance that
updateUser's User-payload/TOFU-pin path cannot express - but no longer
know about TrafficManagement at all; both stale includes are dropped
(Router's was already unused on develop).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* docs: tick wrap-safety analysis, updated cadences, CodeRabbit doc fixes

node_info_stores.md gains a "Tick clocks and wrap safety" section
recording which mechanism keeps each modular tick clock honest: the
unified-cache clocks pair the 60 s sweep with read-time window resets,
the NodeInfo obs/resp clocks are sweep-only (hence the compile-time
invariant that maintainNodeInfoCacheLocked() is guarded by
TMM_HAS_NODEINFO_CACHE alone), and the warm tier is immune by design -
absolute unix-seconds, no wrap until 2106.

Also brought current with this series: membership refresh moved to the
hourly reconcile, throttled direct-response requests forward instead of
being consumed, the commitRemoteKey() bare-key funnel, and the
module-disabled gate on the write-through hooks.

CodeRabbit doc review (PR #11050): present the NodeInfo payload cache
as the third *identity* tier with the unified cache beside the chain,
and describe NodeInfoLite's flattened fields / satellite copy-out
accessors instead of the removed nested members. Two stale
docs/tmm_node_stores.md references in the header now point at the real
file.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* TrafficManagement: adapt tests to forwarded throttles and hourly membership

Throttle tests (PSRAM and NodeDB-fallback paths): a request inside the
30 s window now CONTINUEs into normal relay handling instead of being
consumed - assert no spoofed TX, no NAK suppression, and (PSRAM path)
that nodeinfo_cache_hits counts only replies actually sent.

Membership test (renamed reconcileMembershipMarking): the per-minute
sweep no longer refreshes isMember, so the test now pins down both
halves of the new contract - the very next sweep after a passive NodeDB
eviction still shows the stale member bit (the documented up-to-an-hour
lag), and a reconcile interval's worth of sweeps clears it.

Disabled-module test additionally proves the write-through hooks share
the has_traffic_management gate: a key commit while disabled must not
land in the NodeInfo cache.

Not covered here: a build permutation with TRAFFIC_MANAGEMENT_CACHE_SIZE
overridden to 0 (the configuration the maintenance-guard fix protects)
would need its own PlatformIO env plus guards on every unified-cache
test - left as a follow-up.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* expand test coverage

* nitpicks and docs update

* more comment fixes

* nitpicks

* test: make TMM fixture cleanup abort-safe in tearDown()

Several tests reset per-test global state (trafficManagementModule pointing at a
stack `module`, owner.public_key, the RTC fake clock) only after their assertions.
A TEST_ASSERT_* failure longjmps out and skips that trailing cleanup, leaving the
state to dangle into later cases - concretely, the next setUp()'s resetNodes()
would call trafficManagementModule->purgeAll() on a destroyed object.

Move the resets into tearDown(), which runs unconditionally between tests, and drop
the now-redundant per-test cleanups. Addresses a CodeRabbit review comment on
PR #11050.

clod helped too

* docs tidy

* Throttle NodeInfo direct responses

A direct response is addressed to the requesting packet's from field, which
is unauthenticated, and is sent by every neighbour that holds the target in
cache. One request therefore makes several nodes transmit at an address the
requester chose, and nothing limited how often that could be repeated.

Replies are now spaced per requester, which bounds how much any single node
can be made to receive, plus a global floor on how much airtime the feature
can consume. The check sits where a reply is about to be sent, so requests
declined for other reasons do not consume the budget.

* test: reconcile TMM direct-response tests with #11104 throttle

Makes the suite green for now. #11104's per-requester + global-floor
throttle (60 s) is layered over the branch's per-target throttle, so the
three existing "served again" checks (psram, fallback, sweep) now use a
fresh requester to avoid the per-requester window masking the mechanism
each one actually exercises. Adds a dedicated test for the new
per-requester + 1 s global-floor behaviour (the reflector-flood gap the
per-target throttle leaves open, and the bound that survives on
non-PSRAM builds).

Deferred: the branch's now-redundant fallback global stamp
(nodeInfoFallbackLastResponseMs) is left in place as a follow-up cleanup.

Clod wants credit.

* TMM: unify direct-response throttles into per-sender + per-target RAM tables

Replace the two path-specific NodeInfo-spoof throttles (per-entry respTick on
the PSRAM cache path, single global stamp on the fallback path) with three
symmetric bounds that behave identically with and without PSRAM:

  - per requester (60 s): how much any one node can be made to receive;
  - per target   (60 s): how often we vouch for the same identity;
  - global floor (1 s):  total airtime, the backstop once an attacker cycles
    requester/target past the 8-slot tables.

Both axes are fixed 8-slot LRU tables in internal RAM (not the PSRAM NodeInfo
cache), compared by wrap-safe uint32 ms subtraction, so there is no tick clock
and no sweep to maintain. directResponseAllowed() resolves both slots before
stamping either, so a reply one axis throttles never consumes the other's
budget, and records the send itself.

Retires: nodeInfoFallbackLastResponseMs, kNodeInfoResponseThrottleMs,
nowStampMs, the respTick byte + hasResponded bit on every cache entry,
currentRespTick/kNodeInfoRespTickMs/kNodeInfoThrottleTicks, and the sweep's
respTick clear. Renumbers peekNodeInfoFlagsForTest (drops the responded bit).

Tests: the psram/fallback throttle tests now assert the per-target axis at 60 s,
isolated via a different requester, proving it holds with and without PSRAM;
perRequesterAndGlobalFloor isolates the other two axes; the obsolete respTick
wrap-safety test is removed. 83/83 native cases pass.

clod helped too

* whats up, doc?

* trimming the comments

* test: bump native-suite-count to 38 after upstream rebase

Upstream develop carries 38 test_* suite directories but its
native-suite-count file still reads 37 (a suite was added without
bumping the count). Rebasing onto develop inherits that stale file,
so the runner flags AMBER. Correct the registered total to 38.

clod helped too

---------

Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Thomas Göttgens <tgoettgens@gmail.com>
This commit is contained in:
Tom
2026-07-21 12:19:43 +02:00
committed by GitHub
co-authored by GitHub Claude Thomas Göttgens
parent 0165e914a9
commit 1872e5b306
12 changed files with 3080 additions and 483 deletions
+248
View File
@@ -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.
+193
View File
@@ -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.
+127 -1
View File
@@ -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 <ErriezCRC32.h>
#include <algorithm>
@@ -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();
@@ -3404,6 +3424,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;
}
@@ -3714,7 +3748,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) {
@@ -3730,6 +3764,81 @@ bool NodeDB::copyPublicKey(NodeNum n, meshtastic_NodeInfoLite_public_key_t &out)
return false;
}
bool NodeDB::copyPublicKey(NodeNum n, meshtastic_NodeInfoLite_public_key_t &out)
{
if (copyPublicKeyAuthoritative(n, out))
return true;
#if HAS_TRAFFIC_MANAGEMENT
// Last resort: a key the TrafficManagement NodeInfo cache learned from an observed frame
// for a node no longer in either NodeDB tier. This extends the pool of peers we can
// encrypt to. Keys here may be trust-on-first-use (see copyPublicKey's signerProven), the
// same first-contact trust NodeDB itself applies via updateUser().
if (trafficManagementModule && trafficManagementModule->copyPublicKey(n, out.bytes)) {
out.size = 32;
return true;
}
#endif
return false;
}
bool NodeDB::isVerifiedSignerForKey(NodeNum n, const uint8_t *key32)
{
if (!key32)
return false;
// Hot store is authoritative when present; a node lives in the hot XOR warm tier, so if the
// hot store holds it the warm tier does not, and we decide entirely from the hot entry.
const meshtastic_NodeInfoLite *info = getMeshNode(n);
if (info)
return info->public_key.size == 32 && nodeInfoLiteHasXeddsaSigned(info) && memcmp(info->public_key.bytes, key32, 32) == 0;
#if WARM_NODE_COUNT > 0
uint8_t warmKey[32];
if (warmStore.copyKey(n, warmKey) && memcmp(warmKey, key32, 32) == 0)
return warmStore.isVerifiedSigner(n);
#endif
return false;
}
bool NodeDB::isKnownXeddsaSigner(NodeNum n)
{
// A node lives in the hot XOR warm tier, so the hot verdict is final when present.
const meshtastic_NodeInfoLite *info = getMeshNode(n);
if (info)
return nodeInfoLiteHasXeddsaSigned(info);
#if WARM_NODE_COUNT > 0
return warmStore.isVerifiedSigner(n);
#else
return false;
#endif
}
void NodeDB::commitRemoteKey(NodeNum n, const uint8_t key32[32], KeyCommitTrust trust)
{
if (!key32 || n == 0)
return;
// Local copy first: callers may pass the node's own key bytes back in (e.g. manual
// verification re-committing an already-stored key), and memcpy forbids overlap.
uint8_t key[32];
memcpy(key, key32, 32);
meshtastic_NodeInfoLite *info = getOrCreateMeshNode(n);
if (!info)
return;
// Unconditional overwrite - deliberately NOT updateUser()'s "don't replace a known key" pin.
// That pin protects against unauthenticated NodeInfo broadcasts; the only callers here are
// possession/authority-proven (ManuallyVerified = user confirmed the key; AdminChannelProven =
// decrypted via the admin key with p->from bound into the AEAD nonce), i.e. exactly the paths
// meant to establish or rotate a key. Keep new call sites to that same trust bar.
memcpy(info->public_key.bytes, key, 32);
info->public_key.size = 32;
#if HAS_TRAFFIC_MANAGEMENT
// Write-through, mirroring updateUser()'s identity hook: without it the TrafficManagement
// NodeInfo cache diverges until the next hourly reconcile.
if (trafficManagementModule)
trafficManagementModule->onNodeKeyCommitted(n, key, trust == KeyCommitTrust::ManuallyVerified);
#endif
}
meshtastic_Config_DeviceConfig_Role NodeDB::getNodeRole(NodeNum n)
{
const meshtastic_NodeInfoLite *info = getMeshNode(n);
@@ -3827,6 +3936,23 @@ meshtastic_NodeInfoLite *NodeDB::getOrCreateMeshNode(NodeNum n)
}
LOG_MIGRATION("Rehydrated node 0x%08x from warm tier (key=%d)", n, lite->public_key.size == 32);
}
#endif
#if HAS_TRAFFIC_MANAGEMENT
// Name rehydration: the warm tier keeps a node's key but not its name, so a re-admitted
// long-tail node is nameless until its next NodeInfo. The TrafficManagement NodeInfo
// cache is much larger and often still holds the full User. Restore it - but only when
// its cached key matches the key we just restored from warm, so a name never attaches to
// a different identity than the one we encrypt to. No-op without the TMM NodeInfo cache
// or when no key is present (key-matched by design). CopyUserToNodeInfoLite sets only the
// user-related bits, so the warm-restored signer bit survives.
if (lite->public_key.size == 32 && !nodeInfoLiteHasUser(lite) && trafficManagementModule) {
meshtastic_User tmmUser = meshtastic_User_init_zero;
if (trafficManagementModule->copyUser(n, tmmUser) && tmmUser.public_key.size == 32 &&
memcmp(tmmUser.public_key.bytes, lite->public_key.bytes, 32) == 0) {
TypeConversions::CopyUserToNodeInfoLite(lite, tmmUser);
LOG_INFO("Rehydrated node 0x%08x identity from TMM NodeInfo cache", n);
}
}
#endif
LOG_INFO("Adding node to database with %i nodes and %u bytes free!", numMeshNodes, memGet.getFreeHeap());
}
+27
View File
@@ -360,6 +360,33 @@ class NodeDB
/// tier. Returns false if we don't know a key for n.
bool copyPublicKey(NodeNum n, meshtastic_NodeInfoLite_public_key_t &out);
/// Copy the 32-byte key for n from the AUTHORITATIVE tiers only (hot, then warm; never
/// opportunistic caches) - the pin reference for caches that mirror NodeDB's key hygiene.
bool copyPublicKeyAuthoritative(NodeNum n, meshtastic_NodeInfoLite_public_key_t &out);
/// True if n is a known XEdDSA signer for exactly `key32` (hot signed bitfield or warm
/// signer bit); the key match stops a rotated key inheriting a stale signer verdict.
bool isVerifiedSignerForKey(NodeNum n, const uint8_t *key32);
/// Key-agnostic "should n's signable traffic arrive signed", per hot bitfield or warm signer
/// bit - hot-only gates would let a warm-evicted signer be impersonated with unsigned frames.
bool isKnownXeddsaSigner(NodeNum n);
/// Provenance of a bare-key commit that deliberately bypasses updateUser()'s
/// User-payload / TOFU-pin path. Maps to the TrafficManagement cache's `proven` flag:
/// only ManuallyVerified vouches for possession of exactly this key.
enum class KeyCommitTrust : uint8_t {
AdminChannelProven, // possession shown to the admin channel (AEAD) - TOFU-grade for signing
ManuallyVerified, // the user confirmed possession of exactly this key
};
/// THE primitive for key writes that bypass updateUser() (no User payload; provenance
/// differs from a received NodeInfo): writes the 32-byte key to the hot store and
/// write-through to the TrafficManagement NodeInfo cache. Any future direct key-write
/// site must call this rather than assigning info->public_key, or the TrafficManagement
/// cache silently diverges until the next hourly reconcile.
void commitRemoteKey(NodeNum n, const uint8_t key32[32], KeyCommitTrust trust);
/// Resolve a node's device role - hot store (with user) first, then the role
/// cached in the warm tier, else CLIENT. Lets role-aware policy keep firing for
/// nodes that have aged out of the hot store.
+25 -22
View File
@@ -14,7 +14,6 @@
#include "modules/RoutingModule.h"
#include <pb_encode.h>
#if HAS_TRAFFIC_MANAGEMENT
#include "modules/TrafficManagementModule.h"
#endif
#if HAS_VARIABLE_HOPS
#include "modules/HopScalingModule.h"
@@ -535,11 +534,11 @@ bool checkXeddsaReceivePolicy(meshtastic_MeshPacket *p)
// non-PKI broadcast whose signed encoding would still fit the LoRa frame. Size p->decoded
// canonically so this counts the same fields the sender's signedDataFits() gate counted;
// adding XEDDSA_SIGNATURE_FIELD_BYTES to that unsigned base mirrors it exactly, whatever
// fields the Data carried, with padding hidden inside Data.payload stripped. Unicast/PKI
// packets and broadcasts too big to carry a signature are never signed, so they must not be
// hard-failed here even for a known signer.
const meshtastic_NodeInfoLite *node = nodeDB->getMeshNode(p->from);
if (node && nodeInfoLiteHasXeddsaSigned(node) && !p->pki_encrypted && isBroadcast(p->to)) {
// fields the Data carried. Unicast/PKI packets and broadcasts too big to carry a signature
// are never signed, so they must not be hard-failed here even for a known signer.
// isKnownXeddsaSigner consults the warm tier too: a signer evicted from the hot store
// must not become impersonatable via unsigned broadcasts until it is re-heard.
if (nodeDB->isKnownXeddsaSigner(p->from) && !p->pki_encrypted && isBroadcast(p->to)) {
size_t canonicalSize;
if (!canonicalSignableSize(&p->decoded, &canonicalSize))
return true; // can't size it; never drop on a sizing failure
@@ -622,21 +621,23 @@ DecodeState perhapsDecode(meshtastic_MeshPacket *p)
bool decrypted = false;
ChannelIndex chIndex = 0;
#if !(MESHTASTIC_EXCLUDE_PKI)
// Resolve the sender's public key: prefer the one stored in NodeDB (hot store or warm tier), else
// fall back to a not-yet-committed key held during an in-progress key-verification handshake.
meshtastic_NodeInfoLite_public_key_t remotePublic = {0, {0}};
bool haveRemoteKey = nodeDB->copyPublicKey(p->from, remotePublic);
// 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;
}
meshtastic_NodeInfoLite *ourNode = nullptr;
if (p->channel == 0 && isToUs(p) && p->to > 0 && !isBroadcast(p->to) && rawSize > MESHTASTIC_PKC_OVERHEAD &&
(ourNode = nodeDB->getMeshNode(p->to)) != nullptr && ourNode->public_key.size > 0) {
// Resolve the sender's public key only for actual PKI-decrypt candidates: prefer NodeDB
// (hot store or warm tier), else a not-yet-committed key held during an in-progress
// key-verification handshake. On a full NodeDB miss, copyPublicKey() falls through to a
// linear scan of TrafficManagement's large NodeInfo cache, so it must not run for every
// encrypted channel packet from an unknown sender - only for packets we might decrypt.
meshtastic_NodeInfoLite_public_key_t remotePublic = {0, {0}};
bool haveRemoteKey = nodeDB->copyPublicKey(p->from, remotePublic);
// 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;
@@ -684,10 +685,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 -
+6
View File
@@ -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);
+13
View File
@@ -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.
+5
View File
@@ -421,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);
File diff suppressed because it is too large Load Diff
+237 -162
View File
@@ -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<uint8_t>((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<uint8_t>((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<uint8_t>(((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<uint8_t>((rate_count & 0x3F) | ((role >> 2) << 6));
unknown_count = static_cast<uint8_t>((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<uint8_t>((rate_unknown_time & 0x0F) | ((t & 0x0F) << 4)); }
/// Set the unknown-window tick nibble.
void setUnknownTime(uint8_t t) { rate_unknown_time = static_cast<uint8_t>((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<uint8_t>(clockMs() / kPosTimeTickMs); }
/// Current rate-clock tick nibble (5 min/tick).
static uint8_t currentRateTick() { return static_cast<uint8_t>((clockMs() / kRateTimeTickMs) & 0x0F); }
/// Current unknown-clock tick nibble (1 min/tick).
static uint8_t currentUnknownTick() { return static_cast<uint8_t>((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<uint8_t>(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,61 +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<NodeInfoPayloadEntry *>(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);
// Replies go to the requesting packet's unauthenticated `from`, so space them per requester to bound
// what any one node can be made to receive, plus a global floor on airtime.
static constexpr uint32_t kDirectResponsePerRequestorMs = 60'000UL;
// 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 kDirectResponseTrackedRequestors = 8;
static constexpr size_t kDirectResponseTrackedNodes = 8;
struct DirectResponseThrottleEntry {
NodeNum requestor;
uint32_t lastReplyMs;
NodeNum key; // requester or target node; 0 = unused slot
uint32_t lastReplyMs; // clockMs() of our last reply keyed on this node
};
DirectResponseThrottleEntry directResponseSeen[kDirectResponseTrackedRequestors] = {};
DirectResponseThrottleEntry directRequesterSeen[kDirectResponseTrackedNodes] = {};
DirectResponseThrottleEntry directTargetSeen[kDirectResponseTrackedNodes] = {};
uint32_t lastDirectResponseMs = 0;
bool directResponseAllowed(NodeNum requestor, uint32_t nowMs);
/// 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);
};
+1 -1
View File
@@ -1 +1 @@
37
38
File diff suppressed because it is too large Load Diff