* 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>
745 lines
36 KiB
C++
745 lines
36 KiB
C++
#pragma once
|
|
|
|
#include "Observer.h"
|
|
#include <Arduino.h>
|
|
#include <algorithm>
|
|
#include <assert.h>
|
|
#include <map>
|
|
#include <pb_encode.h>
|
|
#include <string>
|
|
#include <vector>
|
|
|
|
#include "MeshTypes.h"
|
|
#include "NodeStatus.h"
|
|
#include "WarmNodeStore.h"
|
|
#include "concurrency/Lock.h"
|
|
#include "configuration.h"
|
|
#include "mesh-pb-constants.h"
|
|
#include "mesh/generated/meshtastic/mesh.pb.h" // For CriticalErrorCode
|
|
|
|
#if ARCH_PORTDUINO
|
|
#include "PortduinoGlue.h"
|
|
#endif
|
|
|
|
#if !defined(MESHTASTIC_EXCLUDE_PKI)
|
|
// E3B0C442 is the blank hash
|
|
static const uint8_t LOW_ENTROPY_HASHES[][32] = {
|
|
{0xf4, 0x7e, 0xcc, 0x17, 0xe6, 0xb4, 0xa3, 0x22, 0xec, 0xee, 0xd9, 0x08, 0x4f, 0x39, 0x63, 0xea,
|
|
0x80, 0x75, 0xe1, 0x24, 0xce, 0x05, 0x36, 0x69, 0x63, 0xb2, 0xcb, 0xc0, 0x28, 0xd3, 0x34, 0x8b},
|
|
{0x5a, 0x9e, 0xa2, 0xa6, 0x8a, 0xa6, 0x66, 0xc1, 0x5f, 0x55, 0x00, 0x64, 0xa3, 0xa6, 0xfe, 0x71,
|
|
0xc0, 0xbb, 0x82, 0xc3, 0x32, 0x3d, 0x7a, 0x7a, 0xe3, 0x6e, 0xfd, 0xdd, 0xad, 0x3a, 0x66, 0xb9},
|
|
{0xb3, 0xdf, 0x3b, 0x2e, 0x67, 0xb6, 0xd5, 0xf8, 0xdf, 0x76, 0x2c, 0x45, 0x5e, 0x2e, 0xbd, 0x16,
|
|
0xc5, 0xf8, 0x67, 0xaa, 0x15, 0xf8, 0x92, 0x0b, 0xdf, 0x5a, 0x66, 0x50, 0xac, 0x0d, 0xbb, 0x2f},
|
|
{0x3b, 0x8f, 0x86, 0x3a, 0x38, 0x1f, 0x77, 0x39, 0xa9, 0x4e, 0xef, 0x91, 0x18, 0x5a, 0x62, 0xe1,
|
|
0xaa, 0x9d, 0x36, 0xea, 0xce, 0x60, 0x35, 0x8d, 0x9d, 0x1f, 0xf4, 0xb8, 0xc9, 0x13, 0x6a, 0x5d},
|
|
{0x36, 0x7e, 0x2d, 0xe1, 0x84, 0x5f, 0x42, 0x52, 0x29, 0x11, 0x0a, 0x25, 0x64, 0x54, 0x6a, 0x6b,
|
|
0xfd, 0xb6, 0x65, 0xff, 0x15, 0x1a, 0x51, 0x71, 0x22, 0x40, 0x57, 0xf6, 0x91, 0x9b, 0x64, 0x58},
|
|
{0x16, 0x77, 0xeb, 0xa4, 0x52, 0x91, 0xfb, 0x26, 0xcf, 0x8f, 0xd7, 0xd9, 0xd1, 0x5d, 0xc4, 0x68,
|
|
0x73, 0x75, 0xed, 0xc5, 0x95, 0x58, 0xee, 0x90, 0x56, 0xd4, 0x2f, 0x31, 0x29, 0xf7, 0x8c, 0x1f},
|
|
{0x31, 0x8c, 0xa9, 0x5e, 0xed, 0x3c, 0x12, 0xbf, 0x97, 0x9c, 0x47, 0x8e, 0x98, 0x9d, 0xc2, 0x3e,
|
|
0x86, 0x23, 0x90, 0x29, 0xc8, 0xb0, 0x20, 0xf8, 0xb1, 0xb0, 0xaa, 0x19, 0x2a, 0xcf, 0x0a, 0x54},
|
|
{0xa4, 0x8a, 0x99, 0x0e, 0x51, 0xdc, 0x12, 0x20, 0xf3, 0x13, 0xf5, 0x2b, 0x3a, 0xe2, 0x43, 0x42,
|
|
0xc6, 0x52, 0x98, 0xcd, 0xbb, 0xca, 0xb1, 0x31, 0xa0, 0xd4, 0xd6, 0x30, 0xf3, 0x27, 0xfb, 0x49},
|
|
{0xd2, 0x3f, 0x13, 0x8d, 0x22, 0x04, 0x8d, 0x07, 0x59, 0x58, 0xa0, 0xf9, 0x55, 0xcf, 0x30, 0xa0,
|
|
0x2e, 0x2f, 0xca, 0x80, 0x20, 0xe4, 0xde, 0xa1, 0xad, 0xd9, 0x58, 0xb3, 0x43, 0x2b, 0x22, 0x70},
|
|
{0x40, 0x41, 0xec, 0x6a, 0xd2, 0xd6, 0x03, 0xe4, 0x9a, 0x9e, 0xbd, 0x6c, 0x0a, 0x9b, 0x75, 0xa4,
|
|
0xbc, 0xab, 0x6f, 0xa7, 0x95, 0xff, 0x2d, 0xf6, 0xe9, 0xb9, 0xab, 0x4c, 0x0c, 0x1c, 0xd0, 0x3b},
|
|
{0x22, 0x49, 0x32, 0x2b, 0x00, 0xf9, 0x22, 0xfa, 0x17, 0x02, 0xe9, 0x64, 0x82, 0xf0, 0x4d, 0x1b,
|
|
0xc7, 0x04, 0xfc, 0xdc, 0x8c, 0x5e, 0xb6, 0xd9, 0x16, 0xd6, 0x37, 0xce, 0x59, 0xaa, 0x09, 0x49},
|
|
{0x48, 0x6f, 0x1e, 0x48, 0x97, 0x88, 0x64, 0xac, 0xe8, 0xeb, 0x30, 0xa3, 0xc3, 0xe1, 0xcf, 0x97,
|
|
0x39, 0xa6, 0x55, 0x5b, 0x5f, 0xbf, 0x18, 0xb7, 0x3a, 0xdf, 0xa8, 0x75, 0xe7, 0x9d, 0xe0, 0x1e},
|
|
{0x09, 0xb4, 0xe2, 0x6d, 0x28, 0x98, 0xc9, 0x47, 0x66, 0x46, 0xbf, 0xff, 0x58, 0x17, 0x91, 0xaa,
|
|
0xc3, 0xbf, 0x4a, 0x9d, 0x0b, 0x88, 0xb1, 0xf1, 0x03, 0xdd, 0x61, 0xd7, 0xba, 0x9e, 0x64, 0x98},
|
|
{0x39, 0x39, 0x84, 0xe0, 0x22, 0x2f, 0x7d, 0x78, 0x45, 0x18, 0x72, 0xb4, 0x13, 0xd2, 0x01, 0x2f,
|
|
0x3c, 0xa1, 0xb0, 0xfe, 0x39, 0xd0, 0xf1, 0x3c, 0x72, 0xd6, 0xef, 0x54, 0xd5, 0x77, 0x22, 0xa0},
|
|
{0x0a, 0xda, 0x5f, 0xec, 0xff, 0x5c, 0xc0, 0x2e, 0x5f, 0xc4, 0x8d, 0x03, 0xe5, 0x80, 0x59, 0xd3,
|
|
0x5d, 0x49, 0x86, 0xe9, 0x8d, 0xf6, 0xf6, 0x16, 0x35, 0x3d, 0xf9, 0x9b, 0x29, 0x55, 0x9e, 0x64},
|
|
{0x08, 0x56, 0xF0, 0xD7, 0xEF, 0x77, 0xD6, 0x11, 0x1C, 0x8F, 0x95, 0x2D, 0x3C, 0xDF, 0xB1, 0x22,
|
|
0xBF, 0x60, 0x9B, 0xE5, 0xA9, 0xC0, 0x6E, 0x4B, 0x01, 0xDC, 0xD1, 0x57, 0x44, 0xB2, 0xA5, 0xCF},
|
|
{0x2C, 0xB2, 0x77, 0x85, 0xD6, 0xB7, 0x48, 0x9C, 0xFE, 0xBC, 0x80, 0x26, 0x60, 0xF4, 0x6D, 0xCE,
|
|
0x11, 0x31, 0xA2, 0x1E, 0x33, 0x0A, 0x6D, 0x2B, 0x00, 0xFA, 0x0C, 0x90, 0x95, 0x8F, 0x5C, 0x6B},
|
|
{0xFA, 0x59, 0xC8, 0x6E, 0x94, 0xEE, 0x75, 0xC9, 0x9A, 0xB0, 0xFE, 0x89, 0x36, 0x40, 0xC9, 0x99,
|
|
0x4A, 0x3B, 0xF4, 0xAA, 0x12, 0x24, 0xA2, 0x0F, 0xF9, 0xD1, 0x08, 0xCB, 0x78, 0x19, 0xAA, 0xE5},
|
|
{0x6E, 0x42, 0x7A, 0x4A, 0x8C, 0x61, 0x62, 0x22, 0xA1, 0x89, 0xD3, 0xA4, 0xC2, 0x19, 0xA3, 0x83,
|
|
0x53, 0xA7, 0x7A, 0x0A, 0x89, 0xE2, 0x54, 0x52, 0x62, 0x3D, 0xE7, 0xCA, 0x8C, 0xF6, 0x6A, 0x60},
|
|
{0x20, 0x27, 0x2F, 0xBA, 0x0C, 0x99, 0xD7, 0x29, 0xF3, 0x11, 0x35, 0x89, 0x9D, 0x0E, 0x24, 0xA1,
|
|
0xC3, 0xCB, 0xDF, 0x8A, 0xF1, 0xC6, 0xFE, 0xD0, 0xD7, 0x9F, 0x92, 0xD6, 0x8F, 0x59, 0xBF, 0xE4},
|
|
{0x91, 0x70, 0xb4, 0x7c, 0xfb, 0xff, 0xa0, 0x59, 0x6a, 0x25, 0x1c, 0xa9, 0x9e, 0xe9, 0x43, 0x81,
|
|
0x5d, 0x74, 0xb1, 0xb1, 0x09, 0x28, 0x00, 0x4a, 0xaf, 0xe3, 0xfc, 0xa9, 0x4e, 0x27, 0x76, 0x4c},
|
|
{0x85, 0xfe, 0x7c, 0xec, 0xb6, 0x78, 0x74, 0xc3, 0xec, 0xe1, 0x32, 0x7f, 0xb0, 0xb7, 0x02, 0x74,
|
|
0xf9, 0x23, 0xd8, 0xe7, 0xfa, 0x14, 0xe6, 0xee, 0x66, 0x44, 0xb1, 0x8c, 0xa5, 0x2f, 0x7e, 0xd2},
|
|
{0x8e, 0x66, 0x65, 0x7b, 0x3b, 0x6f, 0x7e, 0xcc, 0x57, 0xb4, 0x57, 0xea, 0xcc, 0x83, 0xf5, 0xaa,
|
|
0xf7, 0x65, 0xa3, 0xce, 0x93, 0x72, 0x13, 0xc1, 0xb6, 0x46, 0x7b, 0x29, 0x45, 0xb5, 0xc8, 0x93},
|
|
{0xcc, 0x11, 0xfb, 0x1a, 0xab, 0xa1, 0x31, 0x87, 0x6a, 0xc6, 0xde, 0x88, 0x87, 0xa9, 0xb9, 0x59,
|
|
0x37, 0x82, 0x8d, 0xb2, 0xcc, 0xd8, 0x97, 0x40, 0x9a, 0x5c, 0x8f, 0x40, 0x55, 0xcb, 0x4c, 0x3e}};
|
|
static const char LOW_ENTROPY_WARNING[] = "Compromised keys were detected and regenerated.";
|
|
#endif
|
|
/*
|
|
DeviceState versions used to be defined in the .proto file but really only this function cares. So changed to a
|
|
#define here.
|
|
*/
|
|
|
|
#define SEGMENT_CONFIG 1
|
|
#define SEGMENT_MODULECONFIG 2
|
|
#define SEGMENT_DEVICESTATE 4
|
|
#define SEGMENT_CHANNELS 8
|
|
#define SEGMENT_NODEDATABASE 16
|
|
|
|
#define DEVICESTATE_CUR_VER 25
|
|
// Lowest on-disk version we still know how to load. v24 saves are migrated
|
|
// at boot via the parallel deviceonly_legacy descriptor and re-saved as v25.
|
|
#define DEVICESTATE_MIN_VER 24
|
|
|
|
// One-time behavioral migration marker for the 2.8 position/telemetry opt-in flip.
|
|
// Deliberately kept separate from DEVICESTATE_CUR_VER: that constant also drives the
|
|
// NodeDatabase slim-schema legacy gate (NodeDB.cpp, `nodeDatabase.version < CUR_VER`),
|
|
// so bumping it would wrongly re-run the v24 legacy decoder on already-migrated v25
|
|
// node DBs. This watermark is stamped only onto channelFile.version / moduleConfig.version
|
|
// once the opt-in migration has run. RESERVES 26 - the next real on-disk schema change
|
|
// should raise DEVICESTATE_CUR_VER to 27, not 26.
|
|
#define POSITION_TELEMETRY_OPTIN_VER 26
|
|
|
|
extern meshtastic_DeviceState devicestate;
|
|
extern meshtastic_NodeDatabase nodeDatabase;
|
|
extern meshtastic_ChannelFile channelFile;
|
|
extern meshtastic_MyNodeInfo &myNodeInfo;
|
|
extern meshtastic_LocalConfig config;
|
|
extern meshtastic_DeviceUIConfig uiconfig;
|
|
extern meshtastic_LocalModuleConfig moduleConfig;
|
|
extern meshtastic_User &owner;
|
|
extern meshtastic_Position localPosition;
|
|
|
|
static constexpr const char *deviceStateFileName = "/prefs/device.proto";
|
|
static constexpr const char *legacyPrefFileName = "/prefs/db.proto";
|
|
static constexpr const char *nodeDatabaseFileName = "/prefs/nodes.proto";
|
|
static constexpr const char *configFileName = "/prefs/config.proto";
|
|
static constexpr const char *uiconfigFileName = "/prefs/uiconfig.proto";
|
|
static constexpr const char *moduleConfigFileName = "/prefs/module.proto";
|
|
static constexpr const char *channelFileName = "/prefs/channels.proto";
|
|
static constexpr const char *backupFileName = "/backups/backup.proto";
|
|
|
|
/// Given a node, return how many seconds in the past (vs now) that we last heard from it
|
|
uint32_t sinceLastSeen(const meshtastic_NodeInfoLite *n);
|
|
|
|
/// Given a packet, return how many seconds in the past (vs now) it was received
|
|
uint32_t sinceReceived(const meshtastic_MeshPacket *p);
|
|
|
|
/// Outcome of mapping a single on-wire last-byte (next_hop / relay_node) back to a full NodeNum.
|
|
/// Because the wire only carries the last byte of a 32-bit node number, the mapping is ambiguous on
|
|
/// dense meshes (the "birthday problem"). Callers must treat Ambiguous and None as "don't trust it".
|
|
enum class LastByteResolution : uint8_t {
|
|
None, ///< no relevant candidate node has this last byte
|
|
Unique, ///< exactly one relevant candidate -> `num` is valid
|
|
Ambiguous, ///< two or more relevant candidates collide on this byte
|
|
};
|
|
|
|
struct ResolvedNode {
|
|
LastByteResolution status = LastByteResolution::None;
|
|
NodeNum num = 0; ///< valid only when status == Unique
|
|
};
|
|
|
|
/// Given a packet, return the number of hops used to reach this node.
|
|
/// Returns defaultIfUnknown if the number of hops couldn't be determined.
|
|
int8_t getHopsAway(const meshtastic_MeshPacket &p, int8_t defaultIfUnknown = -1);
|
|
|
|
enum class HopStartStatus : uint8_t { VALID = 0, MISSING_OR_UNKNOWN, INVALID };
|
|
|
|
/// Classify hop_start validity for forwarding decisions.
|
|
HopStartStatus classifyHopStart(const meshtastic_MeshPacket &p);
|
|
|
|
inline bool shouldDropPacketForPreHop(const meshtastic_MeshPacket &p)
|
|
{
|
|
#if !MESHTASTIC_PREHOP_DROP
|
|
(void)p;
|
|
return false;
|
|
#else
|
|
if (isFromUs(&p)) {
|
|
return false; // local-originated packets should never be dropped by pre-hop drop policy
|
|
}
|
|
// Pre-decode: the channel-encrypted bitfield isn't readable yet, so a missing/unknown hop_start can't be
|
|
// distinguished from a modern packet. Only drop the provably-corrupt case here; the bitfield-dependent
|
|
// verdict is re-checked post-decode in Router::handleReceived.
|
|
return classifyHopStart(p) == HopStartStatus::INVALID;
|
|
#endif
|
|
}
|
|
|
|
/// Rate-limited debug log when hop_start is invalid/missing and packet is dropped.
|
|
void logHopStartDrop(const meshtastic_MeshPacket &p, const char *context);
|
|
|
|
/// 2.8 position/telemetry opt-in migration (pure field mutators; exposed for native tests).
|
|
/// Disable position broadcast on every PUBLIC/default-PSK channel (precision -> 0); private-PSK
|
|
/// channels (deliberate trusted groups) are left untouched.
|
|
void optInDisablePositionSharing(meshtastic_ChannelFile &cf);
|
|
/// Force all mesh-broadcast device telemetry (and the MQTT map-report location) back to opt-in/off.
|
|
void optInDisableTelemetryBroadcast(meshtastic_LocalModuleConfig &mc);
|
|
|
|
enum LoadFileResult {
|
|
// Successfully opened the file
|
|
LOAD_SUCCESS = 1,
|
|
// File does not exist
|
|
NOT_FOUND = 2,
|
|
// Device does not have a filesystem
|
|
NO_FILESYSTEM = 3,
|
|
// File exists, but could not decode protobufs
|
|
DECODE_FAILED = 4,
|
|
// File exists, but open failed for some reason
|
|
OTHER_FAILURE = 5
|
|
};
|
|
|
|
enum UserLicenseStatus { NotKnown, NotLicensed, Licensed };
|
|
|
|
class NodeDB
|
|
{
|
|
// NodeNum provisionalNodeNum; // if we are trying to find a node num this is our current attempt
|
|
|
|
// A NodeInfo for every node we've seen
|
|
// Eventually use a smarter datastructure
|
|
// HashMap<NodeNum, NodeInfo> nodes;
|
|
// Note: these two references just point into our static array we serialize to/from disk
|
|
|
|
public:
|
|
std::vector<meshtastic_NodeInfoLite> *meshNodes;
|
|
bool updateGUI = false; // we think the gui should definitely be redrawn, screen will clear this once handled
|
|
meshtastic_NodeInfoLite *updateGUIforNode = NULL; // if currently showing this node, we think you should update the GUI
|
|
Observable<const meshtastic::NodeStatus *> newStatus;
|
|
pb_size_t numMeshNodes;
|
|
|
|
// Satellite per-NodeNum maps. std::map avoids unordered_map's bucket-array
|
|
// preallocation; O(log N) lookup is fine at these sizes.
|
|
#if !MESHTASTIC_EXCLUDE_POSITIONDB
|
|
std::map<NodeNum, meshtastic_PositionLite> nodePositions;
|
|
#endif
|
|
#if !MESHTASTIC_EXCLUDE_TELEMETRYDB
|
|
std::map<NodeNum, meshtastic_DeviceMetrics> nodeTelemetry;
|
|
#endif
|
|
#if !MESHTASTIC_EXCLUDE_ENVIRONMENTDB
|
|
std::map<NodeNum, meshtastic_EnvironmentMetrics> nodeEnvironment;
|
|
#endif
|
|
#if !MESHTASTIC_EXCLUDE_STATUSDB
|
|
std::map<NodeNum, meshtastic_StatusMessage> nodeStatus;
|
|
#endif
|
|
|
|
bool keyIsLowEntropy = false;
|
|
bool hasWarned = false;
|
|
|
|
/// don't do mesh based algorithm for node id assignment (initially)
|
|
/// instead just store in flash - possibly even in the initial alpha release do this hack
|
|
NodeDB();
|
|
|
|
/// write to flash
|
|
/// @return true if the save was successful
|
|
bool saveToDisk(int saveWhat = SEGMENT_CONFIG | SEGMENT_MODULECONFIG | SEGMENT_DEVICESTATE | SEGMENT_CHANNELS |
|
|
SEGMENT_NODEDATABASE);
|
|
|
|
/** Reinit radio config if needed, because either:
|
|
* a) sometimes a buggy android app might send us bogus settings or
|
|
* b) the client set factory_reset
|
|
*
|
|
* @param factory_reset if true, reset all settings to factory defaults
|
|
* @param is_fresh_install set to true after a fresh install, to trigger NodeInfo/Position requests
|
|
* @return true if the config was completely reset, in that case, we should send it back to the client
|
|
*/
|
|
void resetRadioConfig(bool is_fresh_install = false);
|
|
|
|
/// given a subpacket sniffed from the network, update our DB state
|
|
/// we updateGUI and updateGUIforNode if we think our this change is big enough for a redraw
|
|
void updateFrom(const meshtastic_MeshPacket &p);
|
|
|
|
void addFromContact(const meshtastic_SharedContact);
|
|
|
|
/** Update position info for this node based on received position data
|
|
*/
|
|
void updatePosition(uint32_t nodeId, const meshtastic_Position &p, RxSource src = RX_SRC_RADIO);
|
|
|
|
/** Update telemetry info for this node based on received metrics
|
|
*/
|
|
void updateTelemetry(uint32_t nodeId, const meshtastic_Telemetry &t, RxSource src = RX_SRC_RADIO);
|
|
|
|
/** Update user info and channel for this node based on received user data.
|
|
* A known signer's identity is only learned when xeddsaSigned; defaults false so callers fail closed. */
|
|
bool updateUser(uint32_t nodeId, meshtastic_User &p, uint8_t channelIndex = 0, bool xeddsaSigned = false);
|
|
|
|
/*
|
|
* Sets a node either favorite or unfavorite. Returns true if the node ends
|
|
* up in the requested state; false if the node is unknown or favouriting
|
|
* was refused by the protected-node cap (MAX_NUM_NODES - 2).
|
|
*/
|
|
bool set_favorite(bool is_favorite, uint32_t nodeId);
|
|
|
|
/// Count of eviction-protected (favourite/ignored/manually-verified) nodes.
|
|
int numProtectedNodes() const;
|
|
|
|
/// printf-style warning emitted when setProtectedFlag() refuses a node at
|
|
/// the cap. %s = verb (favorite/ignore), 0x%08x = node, %d = cap. Shared by
|
|
/// LOG_WARN here and AdminModule::sendWarning so the wording stays in sync.
|
|
static constexpr const char *PROTECTED_CAP_WARN_FMT = "Can't %s 0x%08x: protected-node limit (%d) reached";
|
|
|
|
/// Turn an eviction-protection flag (favourite/ignored/verified) on/off. Off
|
|
/// always succeeds; on returns false (no change) once the protected set hits
|
|
/// the cap (MAX_NUM_NODES-2), keeping >=2 always-evictable slots. Callers
|
|
/// surface the refusal to the user.
|
|
bool setProtectedFlag(meshtastic_NodeInfoLite *node, uint32_t mask, bool on);
|
|
|
|
/*
|
|
* Returns true if the node is in the NodeDB and marked as favorite
|
|
*/
|
|
bool isFavorite(uint32_t nodeId);
|
|
|
|
/*
|
|
* Returns true if p->from or p->to is a favorited node
|
|
*/
|
|
bool isFromOrToFavoritedNode(const meshtastic_MeshPacket &p);
|
|
|
|
/**
|
|
* Other functions like the node picker can request a pause in the node sorting
|
|
*/
|
|
void pause_sort(bool paused);
|
|
|
|
/// @return our node number
|
|
NodeNum getNodeNum() { return myNodeInfo.my_node_num; }
|
|
|
|
/// @return our node ID as a string in the format "!xxxxxxxx"
|
|
std::string getNodeId() const;
|
|
|
|
// @return last byte of a NodeNum, 0xFF if it ended at 0x00
|
|
uint8_t getLastByteOfNodeNum(NodeNum num) { return (uint8_t)((num & 0xFF) ? (num & 0xFF) : 0xFF); }
|
|
|
|
/// if returns false, that means our node should send a DenyNodeNum response. If true, we think the number is okay for use
|
|
// bool handleWantNodeNum(NodeNum n);
|
|
|
|
/* void handleDenyNodeNum(NodeNum FIXME read mesh proto docs, perhaps picking a random node num is not a great idea
|
|
and instead we should use a special 'im unconfigured node number' and include our desired node number in the wantnum message.
|
|
the unconfigured node num would only be used while initially joining the mesh so low odds of conflicting (especially if we
|
|
randomly select from a small number of nodenums which can be used temporarily for this operation). figure out what the lower
|
|
level mesh sw does if it does conflict? would it be better for people who are replying with denynode num to just broadcast
|
|
their denial?)
|
|
*/
|
|
|
|
// get channel channel index we heard a nodeNum on, defaults to 0 if not found
|
|
uint8_t getMeshNodeChannel(NodeNum n);
|
|
|
|
/* Return the number of nodes we've heard from recently (within the last 2 hrs?)
|
|
* @param localOnly if true, ignore nodes heard via MQTT
|
|
*/
|
|
size_t getNumOnlineMeshNodes(bool localOnly = false);
|
|
|
|
void initConfigIntervals(), initModuleConfigIntervals(), resetNodes(bool keepFavorites = false),
|
|
removeNodeByNum(NodeNum nodeNum);
|
|
|
|
bool factoryReset(bool eraseBleBonds = false);
|
|
|
|
LoadFileResult loadProto(const char *filename, size_t protoSize, size_t objSize, const pb_msgdesc_t *fields,
|
|
void *dest_struct);
|
|
bool saveProto(const char *filename, size_t protoSize, const pb_msgdesc_t *fields, const void *dest_struct,
|
|
bool fullAtomic = true);
|
|
|
|
void installRoleDefaults(meshtastic_Config_DeviceConfig_Role role);
|
|
|
|
const meshtastic_NodeInfoLite *readNextMeshNode(uint32_t &readIndex);
|
|
|
|
meshtastic_NodeInfoLite *getMeshNodeByIndex(size_t x)
|
|
{
|
|
assert(x < numMeshNodes);
|
|
return &meshNodes->at(x);
|
|
}
|
|
|
|
virtual meshtastic_NodeInfoLite *getMeshNode(NodeNum n);
|
|
size_t getNumMeshNodes() { return numMeshNodes; }
|
|
/// Find a node in our DB, create an empty NodeInfoLite if missing (evicting
|
|
/// the oldest non-protected node when full). Public so admin handlers can
|
|
/// register a node we have not heard from yet (e.g. to block it by ID).
|
|
meshtastic_NodeInfoLite *getOrCreateMeshNode(NodeNum n);
|
|
|
|
#if WARM_NODE_COUNT > 0
|
|
// Warm ("long-tail") tier: minimal {num, last_heard, public_key} records
|
|
// for nodes evicted from the hot store. See WarmNodeStore.h.
|
|
WarmNodeStore warmStore;
|
|
#endif
|
|
|
|
/// Copy the 32-byte public key for node n - hot store first, then the warm
|
|
/// 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.
|
|
meshtastic_Config_DeviceConfig_Role getNodeRole(NodeNum n);
|
|
|
|
/// last_heard of a hot-store node, or 0 if absent. Plain scan of meshNodes
|
|
/// with no allocation side effects (unlike getOrCreateMeshNode).
|
|
uint32_t hotNodeLastHeard(NodeNum n) const;
|
|
|
|
/**
|
|
* Resolve a single on-wire last-byte (e.g. next_hop / relay_node) back to a unique full NodeNum,
|
|
* detecting last-byte collisions instead of silently picking the first match. A 1-byte id only
|
|
* needs to be unique among a node's plausible relays, not the whole mesh, so we scope the search:
|
|
* - requireDirectNeighbor == true : candidates are direct neighbors (hops_away==0) heard within
|
|
* NEXTHOP_NEIGHBOR_FRESH_SECS. Use on the SEND path.
|
|
* - requireDirectNeighbor == false : also accept favorites and router-role nodes (unknown hop
|
|
* distance allowed). Use when learning / preserving hops.
|
|
* Ignored nodes, our own node, and the broadcast/0 sentinels are never candidates. On a tie the
|
|
* result is Ambiguous (no tie-break) so callers fall back to flooding rather than misroute.
|
|
*/
|
|
ResolvedNode resolveLastByte(uint8_t lastByte, bool requireDirectNeighbor);
|
|
|
|
/// Convenience wrapper around resolveLastByte(): true iff exactly one relevant candidate matches.
|
|
/// Ambiguous and None both return false (the safe answer for learning / hop preservation).
|
|
bool resolveUniqueLastByte(uint8_t lastByte, bool requireDirectNeighbor, NodeNum *outNum = nullptr);
|
|
|
|
// Thread-safe satellite-map accessors. Return false if absent or the
|
|
// corresponding DB is compiled out.
|
|
bool copyNodePosition(NodeNum n, meshtastic_PositionLite &out) const;
|
|
bool copyNodeTelemetry(NodeNum n, meshtastic_DeviceMetrics &out) const;
|
|
bool copyNodeEnvironment(NodeNum n, meshtastic_EnvironmentMetrics &out) const;
|
|
bool copyNodeStatus(NodeNum n, meshtastic_StatusMessage &out) const;
|
|
std::vector<NodeNum> snapshotPositionNodeNums(NodeNum exclude) const;
|
|
std::vector<NodeNum> snapshotTelemetryNodeNums(NodeNum exclude) const;
|
|
std::vector<NodeNum> snapshotEnvironmentNodeNums(NodeNum exclude) const;
|
|
std::vector<NodeNum> snapshotStatusNodeNums(NodeNum exclude) const;
|
|
|
|
void setNodeStatus(NodeNum n, const meshtastic_StatusMessage &status);
|
|
void touchNodePositionTime(NodeNum n, uint32_t time);
|
|
|
|
bool hasNodePosition(NodeNum n) const
|
|
{
|
|
meshtastic_PositionLite scratch;
|
|
return copyNodePosition(n, scratch);
|
|
}
|
|
bool hasNodeTelemetry(NodeNum n) const
|
|
{
|
|
meshtastic_DeviceMetrics scratch;
|
|
return copyNodeTelemetry(n, scratch);
|
|
}
|
|
bool hasNodeEnvironment(NodeNum n) const
|
|
{
|
|
meshtastic_EnvironmentMetrics scratch;
|
|
return copyNodeEnvironment(n, scratch);
|
|
}
|
|
bool hasNodeStatus(NodeNum n) const
|
|
{
|
|
meshtastic_StatusMessage scratch;
|
|
return copyNodeStatus(n, scratch);
|
|
}
|
|
|
|
void eraseNodeSatellites(NodeNum n);
|
|
|
|
UserLicenseStatus getLicenseStatus(uint32_t nodeNum);
|
|
|
|
size_t getMaxNodesAllocatedSize()
|
|
{
|
|
meshtastic_NodeDatabase emptyNodeDatabase;
|
|
emptyNodeDatabase.version = DEVICESTATE_CUR_VER;
|
|
size_t nodeDatabaseSize;
|
|
pb_get_encoded_size(&nodeDatabaseSize, meshtastic_NodeDatabase_fields, &emptyNodeDatabase);
|
|
// Decode-stream size ceiling only - no buffer this big is allocated (load
|
|
// streams from the file). Sized for the largest file any prior firmware
|
|
// could write (250-node ESP32-S3, satellites uncapped) so capacity
|
|
// downgrades / peer backups still decode; excess is trimmed after load.
|
|
// (not constexpr: portduino resolves MAX_NUM_NODES from runtime config)
|
|
const size_t loadCeiling = ((size_t)MAX_NUM_NODES > 250) ? (size_t)MAX_NUM_NODES : 250;
|
|
return nodeDatabaseSize + (loadCeiling * meshtastic_NodeInfoLite_size) +
|
|
(loadCeiling * meshtastic_NodePositionEntry_size) + (loadCeiling * meshtastic_NodeTelemetryEntry_size) +
|
|
(loadCeiling * meshtastic_NodeEnvironmentEntry_size) + (loadCeiling * meshtastic_NodeStatusEntry_size);
|
|
}
|
|
|
|
// returns true if the maximum number of nodes is reached or we are running low on memory
|
|
bool isFull();
|
|
|
|
void clearLocalPosition();
|
|
|
|
void setLocalPosition(meshtastic_Position position, bool timeOnly = false)
|
|
{
|
|
if (timeOnly) {
|
|
LOG_DEBUG("Set local position time only: time=%u timestamp=%u", position.time, position.timestamp);
|
|
localPosition.time = position.time;
|
|
localPosition.timestamp = position.timestamp > 0 ? position.timestamp : position.time;
|
|
return;
|
|
}
|
|
LOG_DEBUG("Set local position: lat=%i lon=%i time=%u timestamp=%u", position.latitude_i, position.longitude_i,
|
|
position.time, position.timestamp);
|
|
localPosition = position;
|
|
if (position.latitude_i != 0 || position.longitude_i != 0) {
|
|
localPositionUpdatedSinceBoot = true;
|
|
}
|
|
}
|
|
|
|
bool hasValidPosition(const meshtastic_NodeInfoLite *n);
|
|
bool hasLocalPositionSinceBoot() const { return localPositionUpdatedSinceBoot; }
|
|
|
|
#if !defined(MESHTASTIC_EXCLUDE_PKI)
|
|
bool checkLowEntropyPublicKey(const meshtastic_Config_SecurityConfig_public_key_t &keyToTest);
|
|
#endif
|
|
|
|
/// Consolidate crypto key generation logic used across multiple modules
|
|
/// @param privateKey Optional 32-byte private key to use. If nullptr, generates new random keys.
|
|
bool generateCryptoKeyPair(const uint8_t *privateKey = nullptr);
|
|
|
|
bool createNewIdentity();
|
|
|
|
bool backupPreferences(meshtastic_AdminMessage_BackupLocation location);
|
|
bool restorePreferences(meshtastic_AdminMessage_BackupLocation location,
|
|
int restoreWhat = SEGMENT_CONFIG | SEGMENT_MODULECONFIG | SEGMENT_DEVICESTATE | SEGMENT_CHANNELS);
|
|
|
|
/// Notify observers of changes to the DB
|
|
void notifyObservers(bool forceUpdate = false)
|
|
{
|
|
// Notify observers of the current node state
|
|
const meshtastic::NodeStatus status = meshtastic::NodeStatus(getNumOnlineMeshNodes(), getNumMeshNodes(), forceUpdate);
|
|
newStatus.notifyObservers(&status);
|
|
}
|
|
|
|
#ifdef MESHTASTIC_ENCRYPTED_STORAGE
|
|
/// Re-run loadFromDisk() after the encrypted storage is unlocked at runtime.
|
|
/// Trigger: PhoneAPI::handleLockdownAuthInline sets lockdownReloadPending
|
|
/// on a successful provisionPassphrase / unlockWithPassphrase; the main
|
|
/// loop in main.cpp services the flag and calls this method on the main
|
|
/// thread. The transport callback stack (BLE/USB) is too small for the
|
|
/// file IO + MAX_NUM_NODES vector reserve + proto decode this triggers.
|
|
///
|
|
/// Returns true iff every encrypted file decrypted and decoded cleanly.
|
|
/// On false the caller MUST treat the storage as corrupt: leave the
|
|
/// connection unauthenticated, emit a LOCKED(storage_corrupt) status,
|
|
/// and refuse to call setAdminAuthorized - otherwise a subsequent
|
|
/// set_config would re-encrypt a wrong baseline (the locked-default
|
|
/// values still resident in `config` / `channelFile` / `nodeDatabase`)
|
|
/// and overwrite the operator's persisted state.
|
|
bool reloadFromDisk();
|
|
|
|
/// Disable lockdown: decrypt every encrypted pref file back to plaintext,
|
|
/// then remove the DEK / token / counter / backoff artifacts. Requires
|
|
/// EncryptedStorage to be unlocked (DEK in RAM). Returns false if any
|
|
/// file failed to revert - in which case the DEK is still present and the
|
|
/// device remains in lockdown so the operator can retry. APPROTECT is not
|
|
/// reversed. Called from the main loop via lockdownDisablePending.
|
|
bool disableLockdownToPlaintext();
|
|
|
|
/// Set by loadProto when any encrypted file fails to decrypt or decode.
|
|
/// Tracked across an entire loadFromDisk pass so reloadFromDisk can
|
|
/// surface the condition without callers re-walking each loadProto
|
|
/// result. Cleared at the top of every loadFromDisk run.
|
|
bool storageCorruptThisLoad = false;
|
|
#endif
|
|
|
|
private:
|
|
mutable concurrency::Lock satelliteMutex;
|
|
bool duplicateWarned = false;
|
|
bool localPositionUpdatedSinceBoot = false;
|
|
bool migrationSavePending = false;
|
|
/// Set when loadFromDisk() hit a present-but-undecodable config (DECODE_FAILED). The ctor uses it to
|
|
/// skip boot keygen and skip persisting defaults, so a transient read failure can't change our NodeNum
|
|
/// or overwrite the on-disk config. Cleared at the top of every loadFromDisk() run.
|
|
bool configDecodeFailed = false;
|
|
uint32_t lastNodeDbSave = 0; // when we last saved our db to flash
|
|
uint32_t lastFullEvictionMs = 0; // when we last evicted to admit a new node, once the db is full
|
|
uint32_t lastBackupAttempt = 0; // when we last tried a backup automatically or manually
|
|
uint32_t lastSort = 0; // When last sorted the nodeDB
|
|
|
|
/*
|
|
* Internal boolean to track sorting paused
|
|
*/
|
|
bool sortingIsPaused = false;
|
|
|
|
/// pick a provisional nodenum we hope no one is using
|
|
void pickNewNodeNum();
|
|
|
|
/// read our db from flash
|
|
void loadFromDisk();
|
|
|
|
#ifdef PIO_UNIT_TESTING
|
|
// Grant the unit-test shim access to the private maintenance paths below
|
|
// (migration / cleanup / eviction) without relaxing production access.
|
|
friend class NodeDBTestShim;
|
|
#endif
|
|
|
|
/// purge db entries without user info
|
|
void cleanupMeshDB();
|
|
|
|
/// Trim each satellite map down to MAX_SATELLITE_NODES, dropping the
|
|
/// stalest entries (used after loading files written before the cap, or by
|
|
/// a build with a larger cap). Returns true iff anything was trimmed.
|
|
bool enforceSatelliteCaps();
|
|
|
|
/// Node-DB self-care; call only once identity is established (getNodeNum()
|
|
/// valid). Confirms self is present, trims/demotes only NON-self overflow, and
|
|
/// rewrites the store once when something changed (never while storage locked).
|
|
void nodeDBSelfCare();
|
|
|
|
#if WARM_NODE_COUNT > 0
|
|
/// A database from a larger-cap build (e.g. the pre-fork 150-node nRF52 store)
|
|
/// can exceed MAX_NUM_NODES on load. Rank the hot store, demote the oldest
|
|
/// overflow into the warm tier preserving {num, last_heard, public_key} so PKI
|
|
/// DMs survive instead of dropping on truncation.
|
|
void demoteOldestHotNodesToWarm();
|
|
#endif
|
|
|
|
/// Reinit device state from scratch (not loading from disk)
|
|
void installDefaultDeviceState(), installDefaultNodeDatabase(), installDefaultChannels(),
|
|
installDefaultConfig(bool preserveKey), installDefaultModuleConfig();
|
|
|
|
/// write to flash
|
|
/// @return true if the save was successful
|
|
bool saveToDiskNoRetry(int saveWhat);
|
|
|
|
bool saveChannelsToDisk();
|
|
bool saveDeviceStateToDisk();
|
|
bool saveNodeDatabaseToDisk();
|
|
void sortMeshDB();
|
|
|
|
// Defined in NodeDBLegacyMigration.cpp. Decodes /prefs/nodes.proto via
|
|
// the legacy descriptor and copies entries into the v25 layout. Caller
|
|
// is responsible for save / install-default on the result.
|
|
bool migrateLegacyNodeDatabase();
|
|
|
|
// Route satellite-store decode entries straight into our maps instead of
|
|
// temp vectors. Must be paired - disarm before any other NodeDatabase decode.
|
|
void armNodeDatabaseDecodeTargets();
|
|
void disarmNodeDatabaseDecodeTargets();
|
|
};
|
|
|
|
extern NodeDB *nodeDB;
|
|
|
|
/*
|
|
If is_router is set, we use a number of different default values
|
|
|
|
# FIXME - after tuning, move these params into the on-device defaults based on is_router and is_power_saving
|
|
|
|
# prefs.position_broadcast_secs = FIXME possibly broadcast only once an hr
|
|
prefs.wait_bluetooth_secs = 1 # Don't stay in bluetooth mode
|
|
# try to stay in light sleep one full day, then briefly wake and sleep again
|
|
|
|
prefs.ls_secs = oneday
|
|
|
|
prefs.position_broadcast_secs = 12 hours # send either position or owner every 12hrs
|
|
|
|
# get a new GPS position once per day
|
|
prefs.gps_update_interval = oneday
|
|
|
|
prefs.is_power_saving = True
|
|
*/
|
|
|
|
/** The current change # for radio settings. Starts at 0 on boot and any time the radio settings
|
|
* might have changed is incremented. Allows others to detect they might now be on a new channel.
|
|
*/
|
|
extern uint32_t radioGeneration;
|
|
|
|
extern meshtastic_CriticalErrorCode error_code;
|
|
|
|
/*
|
|
* A numeric error address (nonzero if available)
|
|
*/
|
|
extern uint32_t error_address;
|
|
// Bit assignments for meshtastic_NodeInfoLite.bitfield.
|
|
#define NODEINFO_BITFIELD_IS_KEY_MANUALLY_VERIFIED_SHIFT 0
|
|
#define NODEINFO_BITFIELD_IS_KEY_MANUALLY_VERIFIED_MASK (1u << NODEINFO_BITFIELD_IS_KEY_MANUALLY_VERIFIED_SHIFT)
|
|
#define NODEINFO_BITFIELD_IS_MUTED_SHIFT 1
|
|
#define NODEINFO_BITFIELD_IS_MUTED_MASK (1u << NODEINFO_BITFIELD_IS_MUTED_SHIFT)
|
|
#define NODEINFO_BITFIELD_VIA_MQTT_SHIFT 2
|
|
#define NODEINFO_BITFIELD_VIA_MQTT_MASK (1u << NODEINFO_BITFIELD_VIA_MQTT_SHIFT)
|
|
#define NODEINFO_BITFIELD_IS_FAVORITE_SHIFT 3
|
|
#define NODEINFO_BITFIELD_IS_FAVORITE_MASK (1u << NODEINFO_BITFIELD_IS_FAVORITE_SHIFT)
|
|
#define NODEINFO_BITFIELD_IS_IGNORED_SHIFT 4
|
|
#define NODEINFO_BITFIELD_IS_IGNORED_MASK (1u << NODEINFO_BITFIELD_IS_IGNORED_SHIFT)
|
|
#define NODEINFO_BITFIELD_HAS_USER_SHIFT 5
|
|
#define NODEINFO_BITFIELD_HAS_USER_MASK (1u << NODEINFO_BITFIELD_HAS_USER_SHIFT)
|
|
#define NODEINFO_BITFIELD_IS_LICENSED_SHIFT 6
|
|
#define NODEINFO_BITFIELD_IS_LICENSED_MASK (1u << NODEINFO_BITFIELD_IS_LICENSED_SHIFT)
|
|
#define NODEINFO_BITFIELD_IS_UNMESSAGABLE_SHIFT 7
|
|
#define NODEINFO_BITFIELD_IS_UNMESSAGABLE_MASK (1u << NODEINFO_BITFIELD_IS_UNMESSAGABLE_SHIFT)
|
|
#define NODEINFO_BITFIELD_HAS_IS_UNMESSAGABLE_SHIFT 8
|
|
#define NODEINFO_BITFIELD_HAS_IS_UNMESSAGABLE_MASK (1u << NODEINFO_BITFIELD_HAS_IS_UNMESSAGABLE_SHIFT)
|
|
#define NODEINFO_BITFIELD_HAS_XEDDSA_SIGNED_SHIFT 9
|
|
#define NODEINFO_BITFIELD_HAS_XEDDSA_SIGNED_MASK (1u << NODEINFO_BITFIELD_HAS_XEDDSA_SIGNED_SHIFT)
|
|
// Bits 10..31 reserved for future single-bit flags.
|
|
|
|
// Convenience accessors so call sites read like the old struct fields.
|
|
inline bool nodeInfoLiteHasUser(const meshtastic_NodeInfoLite *n)
|
|
{
|
|
return n && (n->bitfield & NODEINFO_BITFIELD_HAS_USER_MASK);
|
|
}
|
|
inline bool nodeInfoLiteViaMqtt(const meshtastic_NodeInfoLite *n)
|
|
{
|
|
return n && (n->bitfield & NODEINFO_BITFIELD_VIA_MQTT_MASK);
|
|
}
|
|
inline bool nodeInfoLiteIsFavorite(const meshtastic_NodeInfoLite *n)
|
|
{
|
|
return n && (n->bitfield & NODEINFO_BITFIELD_IS_FAVORITE_MASK);
|
|
}
|
|
inline bool nodeInfoLiteIsIgnored(const meshtastic_NodeInfoLite *n)
|
|
{
|
|
return n && (n->bitfield & NODEINFO_BITFIELD_IS_IGNORED_MASK);
|
|
}
|
|
inline bool nodeInfoLiteIsLicensed(const meshtastic_NodeInfoLite *n)
|
|
{
|
|
return n && (n->bitfield & NODEINFO_BITFIELD_IS_LICENSED_MASK);
|
|
}
|
|
inline bool nodeInfoLiteHasIsUnmessagable(const meshtastic_NodeInfoLite *n)
|
|
{
|
|
return n && (n->bitfield & NODEINFO_BITFIELD_HAS_IS_UNMESSAGABLE_MASK);
|
|
}
|
|
inline bool nodeInfoLiteIsUnmessagable(const meshtastic_NodeInfoLite *n)
|
|
{
|
|
return n && (n->bitfield & NODEINFO_BITFIELD_IS_UNMESSAGABLE_MASK);
|
|
}
|
|
inline bool nodeInfoLiteIsMuted(const meshtastic_NodeInfoLite *n)
|
|
{
|
|
return n && (n->bitfield & NODEINFO_BITFIELD_IS_MUTED_MASK);
|
|
}
|
|
inline bool nodeInfoLiteIsKeyManuallyVerified(const meshtastic_NodeInfoLite *n)
|
|
{
|
|
return n && (n->bitfield & NODEINFO_BITFIELD_IS_KEY_MANUALLY_VERIFIED_MASK);
|
|
}
|
|
inline bool nodeInfoLiteHasXeddsaSigned(const meshtastic_NodeInfoLite *n)
|
|
{
|
|
return n && (n->bitfield & NODEINFO_BITFIELD_HAS_XEDDSA_SIGNED_MASK);
|
|
}
|
|
/// A node that the eviction/migration paths must not drop: a favourite, an
|
|
/// ignored (blocked) node, or a manually-verified key.
|
|
inline bool nodeInfoLiteIsProtected(const meshtastic_NodeInfoLite *n)
|
|
{
|
|
return nodeInfoLiteIsFavorite(n) || nodeInfoLiteIsIgnored(n) || nodeInfoLiteIsKeyManuallyVerified(n);
|
|
}
|
|
|
|
inline void nodeInfoLiteSetBit(meshtastic_NodeInfoLite *n, uint32_t mask, bool value)
|
|
{
|
|
if (!n)
|
|
return;
|
|
if (value)
|
|
n->bitfield |= mask;
|
|
else
|
|
n->bitfield &= ~mask;
|
|
}
|
|
|
|
#define Module_Config_size \
|
|
(ModuleConfig_CannedMessageConfig_size + ModuleConfig_ExternalNotificationConfig_size + ModuleConfig_MQTTConfig_size + \
|
|
ModuleConfig_RangeTestConfig_size + ModuleConfig_SerialConfig_size + ModuleConfig_StoreForwardConfig_size + \
|
|
ModuleConfig_TelemetryConfig_size + ModuleConfig_size)
|
|
|
|
// Please do not remove this comment, it makes trunk and compiler happy at the same time.
|