Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
44 commits
Select commit Hold shift + click to select a range
4471b7d
Harden TMM NodeInfo direct-response: staleness, key hygiene, throttle
claude Jul 16, 2026
87be307
Add TODO(T1-T9) markers for code-review findings on NodeInfo hardening
claude Jul 16, 2026
5a6a60e
Address code-review cleanups T6, T7, T8 in NodeInfo direct-response
claude Jul 16, 2026
257033c
Throttle the NodeDB fallback direct-response path (T3)
claude Jul 17, 2026
bdd1170
Make NodeInfo staleness/throttle wrap- and sentinel-safe (T4, T9)
claude Jul 17, 2026
99f9912
Stamp PSRAM throttle entry by captured index, not a rescan (T5)
claude Jul 17, 2026
03b2b85
Document accepted per-target throttle tradeoffs (T1, T2)
claude Jul 17, 2026
e25b1c7
Track signed-provenance of cached NodeInfo public keys
claude Jul 17, 2026
ede3001
Draw public keys from the TMM NodeInfo cache as a last resort
claude Jul 17, 2026
2147452
Retain keyed NodeInfo entries as a pubkey pool; trust-tier eviction
claude Jul 17, 2026
e0565aa
Test signed-provenance flag and copyPublicKey key-pool source
claude Jul 17, 2026
9d073ff
Inherit signer provenance from NodeDB when caching a re-found node
claude Jul 17, 2026
84fd673
Lock NodeInfoPayloadEntry packing with a size assert
claude Jul 17, 2026
62b270a
Pack NodeInfo cache booleans into 1-bit fields
claude Jul 17, 2026
6d0d642
Gate NodeInfo replay on signer-proven provenance (compile-time, defau…
claude Jul 17, 2026
30041c6
Rehydrate re-admitted node names from the TMM NodeInfo cache
claude Jul 17, 2026
b55d91b
Drop non-portable NodeInfoPayloadEntry size assert (fixes pico build)
claude Jul 17, 2026
789f412
Run the NodeInfo cache paths in native tests (TMM_HAS_NODEINFO_CACHE)
claude Jul 17, 2026
af2e9ef
Pin NodeInfo cache keys against the warm tier, not just the hot store
claude Jul 17, 2026
122991c
Convert NodeInfo cache clocks to uint8 tick counters
claude Jul 17, 2026
3c17e7c
Keep the NodeInfo cache a superset of NodeDB (seed + keep-alive)
claude Jul 17, 2026
c745eb7
Write-through NodeDB identity commits into the NodeInfo cache
claude Jul 17, 2026
73f5d20
Purge TrafficManagement caches on explicit node removal
claude Jul 17, 2026
14bacd9
Document the tmm-fix-2 / tmm-fix-superset reconciliation decisions
claude Jul 17, 2026
1b072bb
Reconcile retention: no timed eviction, obsTick LRU, hourly seeding
claude Jul 17, 2026
2282df9
Reconcile hooks: key-commit write-through, purgeAll, key-matched signer
claude Jul 17, 2026
90cd01f
Merge the two branches' test suites for the reconciled design
claude Jul 17, 2026
051714d
Adapt cherry-picked and seeding tests to the reconciled semantics
claude Jul 17, 2026
44b53ce
trunk fml
NomDeTom Jul 17, 2026
eb2123e
Doc tidyup
NomDeTom Jul 17, 2026
527ee4d
TrafficManagement: key NodeInfo-cache maintenance to its own guard
NomDeTom Jul 17, 2026
c0f94b0
TrafficManagement: forward throttled NodeInfo requests instead of con…
NomDeTom Jul 17, 2026
f2864d5
TrafficManagement: consolidate NodeInfo-cache #if sprawl into one region
NomDeTom Jul 17, 2026
5244963
Router: resolve sender key only for PKI-decrypt candidates
NomDeTom Jul 17, 2026
1e482a9
TrafficManagement: refresh NodeInfo membership hourly, not per-sweep
NomDeTom Jul 17, 2026
ac05e13
NodeDB: centralize bare-key commits in commitRemoteKey()
NomDeTom Jul 17, 2026
635f6ff
docs: tick wrap-safety analysis, updated cadences, CodeRabbit doc fixes
NomDeTom Jul 17, 2026
33c85f3
TrafficManagement: adapt tests to forwarded throttles and hourly memb…
NomDeTom Jul 17, 2026
d5a716c
expand test coverage
NomDeTom Jul 18, 2026
5c5a080
nitpicks and docs update
NomDeTom Jul 18, 2026
5490537
more comment fixes
NomDeTom Jul 18, 2026
e03e558
nitpicks
NomDeTom Jul 18, 2026
5a0ca93
test: make TMM fixture cleanup abort-safe in tearDown()
NomDeTom Jul 18, 2026
6c3ec52
docs tidy
NomDeTom Jul 20, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
237 changes: 237 additions & 0 deletions docs/node_info_stores.md

Large diffs are not rendered by default.

128 changes: 127 additions & 1 deletion src/mesh/NodeDB.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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>
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -3400,6 +3420,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;
}

Expand Down Expand Up @@ -3698,7 +3732,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) {
Expand All @@ -3714,6 +3748,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);
Expand Down Expand Up @@ -3811,6 +3920,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());
}
Expand Down
27 changes: 27 additions & 0 deletions src/mesh/NodeDB.h
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
33 changes: 18 additions & 15 deletions src/mesh/Router.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -533,11 +532,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
Expand Down Expand Up @@ -572,14 +571,16 @@ DecodeState perhapsDecode(meshtastic_MeshPacket *p)
bool decrypted = false;
ChannelIndex chIndex = 0;
#if !(MESHTASTIC_EXCLUDE_PKI)
// Resolve the sender's public key: prefer the one stored in NodeDB (hot store or warm tier), else
// fall back to a not-yet-committed key held during an in-progress key-verification handshake.
meshtastic_NodeInfoLite_public_key_t remotePublic = {0, {0}};
bool haveRemoteKey = nodeDB->copyPublicKey(p->from, remotePublic) || crypto->getPendingPublicKey(p->from, remotePublic);

meshtastic_NodeInfoLite *ourNode = nullptr;
if (p->channel == 0 && isToUs(p) && p->to > 0 && !isBroadcast(p->to) && rawSize > MESHTASTIC_PKC_OVERHEAD &&
(ourNode = nodeDB->getMeshNode(p->to)) != nullptr && ourNode->public_key.size > 0) {
// Resolve the sender's public key only for actual PKI-decrypt candidates: prefer NodeDB
// (hot store or warm tier), else a not-yet-committed key held during an in-progress
// key-verification handshake. On a full NodeDB miss, copyPublicKey() falls through to a
// linear scan of TrafficManagement's large NodeInfo cache, so it must not run for every
// encrypted channel packet from an unknown sender - only for packets we might decrypt.
meshtastic_NodeInfoLite_public_key_t remotePublic = {0, {0}};
bool haveRemoteKey = nodeDB->copyPublicKey(p->from, remotePublic) || crypto->getPendingPublicKey(p->from, remotePublic);
// Try the sender's known key first, then each configured admin key so an authorized admin can
// reach a node that has not yet learned their key. AES-CCM AEAD rejects wrong candidates.
bool viaAdminKey = false;
Expand Down Expand Up @@ -615,10 +616,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 -
Expand Down
6 changes: 6 additions & 0 deletions src/mesh/WarmNodeStore.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
13 changes: 13 additions & 0 deletions src/mesh/WarmNodeStore.h
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand All @@ -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.
Expand Down
5 changes: 5 additions & 0 deletions src/modules/KeyVerificationModule.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -383,6 +383,11 @@ void KeyVerificationModule::commitVerifiedRemoteNode()
if (node->public_key.size != 32 && crypto->getPendingPublicKey(currentRemoteNode, pending))
node->public_key = pending;
node->bitfield |= NODEINFO_BITFIELD_IS_KEY_MANUALLY_VERIFIED_MASK;
// Re-commit via the bare-key primitive: writing the same bytes back is a no-op for the hot
// store, but it routes the TrafficManagement write-through. ManuallyVerified: the user just
// confirmed possession of exactly this key - the strongest provenance that cache can carry.
if (node->public_key.size == 32)
nodeDB->commitRemoteKey(currentRemoteNode, node->public_key.bytes, NodeDB::KeyCommitTrust::ManuallyVerified);
LOG_INFO("Node 0x%08x manually verified with security number %u", currentRemoteNode, currentSecurityNumber);
if (nodeInfoModule)
nodeInfoModule->sendOurNodeInfo(currentRemoteNode, false, node->channel, true);
Expand Down
Loading
Loading