Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
42 commits
Select commit Hold shift + click to select a range
3ef2a82
Harden TMM NodeInfo direct-response: staleness, key hygiene, throttle
claude Jul 16, 2026
60f5a89
Add TODO(T1-T9) markers for code-review findings on NodeInfo hardening
claude Jul 16, 2026
a387e49
Address code-review cleanups T6, T7, T8 in NodeInfo direct-response
claude Jul 16, 2026
2633cbf
Throttle the NodeDB fallback direct-response path (T3)
claude Jul 17, 2026
0631562
Make NodeInfo staleness/throttle wrap- and sentinel-safe (T4, T9)
claude Jul 17, 2026
02b5671
Stamp PSRAM throttle entry by captured index, not a rescan (T5)
claude Jul 17, 2026
7eedec2
Document accepted per-target throttle tradeoffs (T1, T2)
claude Jul 17, 2026
8f792a4
Track signed-provenance of cached NodeInfo public keys
claude Jul 17, 2026
482ef53
Draw public keys from the TMM NodeInfo cache as a last resort
claude Jul 17, 2026
16d302a
Retain keyed NodeInfo entries as a pubkey pool; trust-tier eviction
claude Jul 17, 2026
0756fb1
Test signed-provenance flag and copyPublicKey key-pool source
claude Jul 17, 2026
b240726
Inherit signer provenance from NodeDB when caching a re-found node
claude Jul 17, 2026
04b8952
Lock NodeInfoPayloadEntry packing with a size assert
claude Jul 17, 2026
0b42295
Pack NodeInfo cache booleans into 1-bit fields
claude Jul 17, 2026
0844244
Gate NodeInfo replay on signer-proven provenance (compile-time, defau…
claude Jul 17, 2026
1d76523
Rehydrate re-admitted node names from the TMM NodeInfo cache
claude Jul 17, 2026
173651b
Drop non-portable NodeInfoPayloadEntry size assert (fixes pico build)
claude Jul 17, 2026
bccf8c3
Run the NodeInfo cache paths in native tests (TMM_HAS_NODEINFO_CACHE)
claude Jul 17, 2026
028ea17
Pin NodeInfo cache keys against the warm tier, not just the hot store
claude Jul 17, 2026
e02c0b2
Convert NodeInfo cache clocks to uint8 tick counters
claude Jul 17, 2026
7b63f2f
Keep the NodeInfo cache a superset of NodeDB (seed + keep-alive)
claude Jul 17, 2026
cccc1f4
Write-through NodeDB identity commits into the NodeInfo cache
claude Jul 17, 2026
c67a864
Purge TrafficManagement caches on explicit node removal
claude Jul 17, 2026
471b9fb
Document the tmm-fix-2 / tmm-fix-superset reconciliation decisions
claude Jul 17, 2026
cef9d64
Reconcile retention: no timed eviction, obsTick LRU, hourly seeding
claude Jul 17, 2026
d81ad7b
Reconcile hooks: key-commit write-through, purgeAll, key-matched signer
claude Jul 17, 2026
213b838
Merge the two branches' test suites for the reconciled design
claude Jul 17, 2026
fadcb74
Adapt cherry-picked and seeding tests to the reconciled semantics
claude Jul 17, 2026
8fdadc2
trunk fml
NomDeTom Jul 17, 2026
596529c
Doc tidyup
NomDeTom Jul 17, 2026
4b579eb
TrafficManagement: key NodeInfo-cache maintenance to its own guard
NomDeTom Jul 17, 2026
4d9cb90
TrafficManagement: forward throttled NodeInfo requests instead of con…
NomDeTom Jul 17, 2026
da81ae2
TrafficManagement: consolidate NodeInfo-cache #if sprawl into one region
NomDeTom Jul 17, 2026
76a397e
Router: resolve sender key only for PKI-decrypt candidates
NomDeTom Jul 17, 2026
d6abd3a
TrafficManagement: refresh NodeInfo membership hourly, not per-sweep
NomDeTom Jul 17, 2026
e978c39
NodeDB: centralize bare-key commits in commitRemoteKey()
NomDeTom Jul 17, 2026
cfe1e62
docs: tick wrap-safety analysis, updated cadences, CodeRabbit doc fixes
NomDeTom Jul 17, 2026
7e2e7e0
TrafficManagement: adapt tests to forwarded throttles and hourly memb…
NomDeTom Jul 17, 2026
0012f43
expand test coverage
NomDeTom Jul 18, 2026
3f502ef
nitpicks and docs update
NomDeTom Jul 18, 2026
69c12c3
more comment fixes
NomDeTom Jul 18, 2026
84ed705
nitpicks
NomDeTom Jul 18, 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
232 changes: 232 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 @@ -3390,6 +3410,20 @@ bool NodeDB::updateUser(uint32_t nodeId, meshtastic_User &p, uint8_t channelInde
}
}

#if HAS_TRAFFIC_MANAGEMENT
// Write-through: every accepted remote-identity commit lands here (NodeInfoModule,
// MeshService, and TMM's requester learning all funnel through updateUser; the two
// key-write sites that bypass it call onNodeKeyCommitted instead), so TMM's NodeInfo
// cache reflects the commit immediately rather than at the next reconcile pass. Runs on
// acceptance, not on `changed`: an identical update still proves the identity is
// current. `p` is the post-hygiene payload; signerKnown transfers only key-matched
// verified-signer status (isVerifiedSignerForKey semantics), never a bare node flag.
if (nodeId != getNodeNum() && trafficManagementModule) {
const bool signerKnown = p.public_key.size == 32 && isVerifiedSignerForKey(nodeId, p.public_key.bytes);
trafficManagementModule->onNodeIdentityCommitted(nodeId, p, signerKnown);
}
#endif

return changed;
}

Expand Down Expand Up @@ -3688,7 +3722,7 @@ uint32_t NodeDB::hotNodeLastHeard(NodeNum n) const
return 0;
}

bool NodeDB::copyPublicKey(NodeNum n, meshtastic_NodeInfoLite_public_key_t &out)
bool NodeDB::copyPublicKeyAuthoritative(NodeNum n, meshtastic_NodeInfoLite_public_key_t &out)
{
const meshtastic_NodeInfoLite *info = getMeshNode(n);
if (info && info->public_key.size == 32) {
Expand All @@ -3704,6 +3738,81 @@ bool NodeDB::copyPublicKey(NodeNum n, meshtastic_NodeInfoLite_public_key_t &out)
return false;
}

bool NodeDB::copyPublicKey(NodeNum n, meshtastic_NodeInfoLite_public_key_t &out)
{
if (copyPublicKeyAuthoritative(n, out))
return true;
#if HAS_TRAFFIC_MANAGEMENT
// Last resort: a key the TrafficManagement NodeInfo cache learned from an observed frame
// for a node no longer in either NodeDB tier. This extends the pool of peers we can
// encrypt to. Keys here may be trust-on-first-use (see copyPublicKey's signerProven), the
// same first-contact trust NodeDB itself applies via updateUser().
if (trafficManagementModule && trafficManagementModule->copyPublicKey(n, out.bytes)) {
out.size = 32;
return true;
}
#endif
return false;
}

bool NodeDB::isVerifiedSignerForKey(NodeNum n, const uint8_t *key32)
{
if (!key32)
return false;
// Hot store is authoritative when present; a node lives in the hot XOR warm tier, so if the
// hot store holds it the warm tier does not, and we decide entirely from the hot entry.
const meshtastic_NodeInfoLite *info = getMeshNode(n);
if (info)
return info->public_key.size == 32 && nodeInfoLiteHasXeddsaSigned(info) && memcmp(info->public_key.bytes, key32, 32) == 0;
#if WARM_NODE_COUNT > 0
uint8_t warmKey[32];
if (warmStore.copyKey(n, warmKey) && memcmp(warmKey, key32, 32) == 0)
return warmStore.isVerifiedSigner(n);
#endif
return false;
}

bool NodeDB::isKnownXeddsaSigner(NodeNum n)
{
// A node lives in the hot XOR warm tier, so the hot verdict is final when present.
const meshtastic_NodeInfoLite *info = getMeshNode(n);
if (info)
return nodeInfoLiteHasXeddsaSigned(info);
#if WARM_NODE_COUNT > 0
return warmStore.isVerifiedSigner(n);
#else
return false;
#endif
}

void NodeDB::commitRemoteKey(NodeNum n, const uint8_t key32[32], KeyCommitTrust trust)
{
if (!key32 || n == 0)
return;
// Local copy first: callers may pass the node's own key bytes back in (e.g. manual
// verification re-committing an already-stored key), and memcpy forbids overlap.
uint8_t key[32];
memcpy(key, key32, 32);

meshtastic_NodeInfoLite *info = getOrCreateMeshNode(n);
if (!info)
return;
// Unconditional overwrite - deliberately NOT updateUser()'s "don't replace a known key" pin.
// That pin protects against unauthenticated NodeInfo broadcasts; the only callers here are
// possession/authority-proven (ManuallyVerified = user confirmed the key; AdminChannelProven =
// decrypted via the admin key with p->from bound into the AEAD nonce), i.e. exactly the paths
// meant to establish or rotate a key. Keep new call sites to that same trust bar.
memcpy(info->public_key.bytes, key, 32);
info->public_key.size = 32;

#if HAS_TRAFFIC_MANAGEMENT
// Write-through, mirroring updateUser()'s identity hook: without it the TrafficManagement
// NodeInfo cache diverges until the next hourly reconcile.
if (trafficManagementModule)
trafficManagementModule->onNodeKeyCommitted(n, key, trust == KeyCommitTrust::ManuallyVerified);
#endif
}

meshtastic_Config_DeviceConfig_Role NodeDB::getNodeRole(NodeNum n)
{
const meshtastic_NodeInfoLite *info = getMeshNode(n);
Expand Down Expand Up @@ -3801,6 +3910,23 @@ meshtastic_NodeInfoLite *NodeDB::getOrCreateMeshNode(NodeNum n)
}
LOG_MIGRATION("Rehydrated node 0x%08x from warm tier (key=%d)", n, lite->public_key.size == 32);
}
#endif
#if HAS_TRAFFIC_MANAGEMENT
// Name rehydration: the warm tier keeps a node's key but not its name, so a re-admitted
// long-tail node is nameless until its next NodeInfo. The TrafficManagement NodeInfo
// cache is much larger and often still holds the full User. Restore it - but only when
// its cached key matches the key we just restored from warm, so a name never attaches to
// a different identity than the one we encrypt to. No-op without the TMM NodeInfo cache
// or when no key is present (key-matched by design). CopyUserToNodeInfoLite sets only the
// user-related bits, so the warm-restored signer bit survives.
if (lite->public_key.size == 32 && !nodeInfoLiteHasUser(lite) && trafficManagementModule) {
meshtastic_User tmmUser = meshtastic_User_init_zero;
if (trafficManagementModule->copyUser(n, tmmUser) && tmmUser.public_key.size == 32 &&
memcmp(tmmUser.public_key.bytes, lite->public_key.bytes, 32) == 0) {
TypeConversions::CopyUserToNodeInfoLite(lite, tmmUser);
LOG_INFO("Rehydrated node 0x%08x identity from TMM NodeInfo cache", n);
}
}
#endif
LOG_INFO("Adding node to database with %i nodes and %u bytes free!", numMeshNodes, memGet.getFreeHeap());
}
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
28 changes: 16 additions & 12 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 @@ -488,8 +487,9 @@ bool checkXeddsaReceivePolicy(meshtastic_MeshPacket *p)
// adding XEDDSA_SIGNATURE_FIELD_BYTES to that unsigned base mirrors it exactly, whatever
// fields the Data carried. Unicast/PKI packets and broadcasts too big to carry a signature
// are never signed, so they must not be hard-failed here even for a known signer.
const meshtastic_NodeInfoLite *node = nodeDB->getMeshNode(p->from);
if (node && nodeInfoLiteHasXeddsaSigned(node) && !p->pki_encrypted && isBroadcast(p->to)) {
// isKnownXeddsaSigner consults the warm tier too: a signer evicted from the hot store
// must not become impersonatable via unsigned broadcasts until it is re-heard.
if (nodeDB->isKnownXeddsaSigner(p->from) && !p->pki_encrypted && isBroadcast(p->to)) {
size_t canonicalSize;
if (!pb_get_encoded_size(&canonicalSize, &meshtastic_Data_msg, &p->decoded))
return true; // can't size it; never drop on a sizing failure
Expand Down Expand Up @@ -524,14 +524,16 @@ DecodeState perhapsDecode(meshtastic_MeshPacket *p)
bool decrypted = false;
ChannelIndex chIndex = 0;
#if !(MESHTASTIC_EXCLUDE_PKI)
// Resolve the sender's public key: prefer the one stored in NodeDB (hot store or warm tier), else
// fall back to a not-yet-committed key held during an in-progress key-verification handshake.
meshtastic_NodeInfoLite_public_key_t remotePublic = {0, {0}};
bool haveRemoteKey = nodeDB->copyPublicKey(p->from, remotePublic) || crypto->getPendingPublicKey(p->from, remotePublic);

meshtastic_NodeInfoLite *ourNode = nullptr;
if (p->channel == 0 && isToUs(p) && p->to > 0 && !isBroadcast(p->to) && rawSize > MESHTASTIC_PKC_OVERHEAD &&
(ourNode = nodeDB->getMeshNode(p->to)) != nullptr && ourNode->public_key.size > 0) {
// Resolve the sender's public key only for actual PKI-decrypt candidates: prefer NodeDB
// (hot store or warm tier), else a not-yet-committed key held during an in-progress
// key-verification handshake. On a full NodeDB miss, copyPublicKey() falls through to a
// linear scan of TrafficManagement's large NodeInfo cache, so it must not run for every
// encrypted channel packet from an unknown sender - only for packets we might decrypt.
meshtastic_NodeInfoLite_public_key_t remotePublic = {0, {0}};
bool haveRemoteKey = nodeDB->copyPublicKey(p->from, remotePublic) || crypto->getPendingPublicKey(p->from, remotePublic);
// Try the sender's known key first, then each configured admin key so an authorized admin can
// reach a node that has not yet learned their key. AES-CCM AEAD rejects wrong candidates.
bool viaAdminKey = false;
Expand Down Expand Up @@ -567,10 +569,12 @@ DecodeState perhapsDecode(meshtastic_MeshPacket *p)
p->which_payload_variant = meshtastic_MeshPacket_decoded_tag; // change type to decoded
if (viaAdminKey) {
// Persist the admin key for the sender so future packets take the fast path and we can
// PKI-reply; p->from is bound into the AEAD nonce, so the trusted admin authenticated it.
meshtastic_NodeInfoLite *fromNode = nodeDB->getOrCreateMeshNode(p->from);
if (fromNode != nullptr)
fromNode->public_key = remotePublic;
// PKI-reply; p->from is bound into the AEAD nonce, so the trusted admin authenticated
// it. commitRemoteKey is the bare-key commit primitive: it bypasses updateUser's
// User-payload path deliberately and handles the TrafficManagement write-through.
// AdminChannelProven = possession shown to the admin channel, not via an XEdDSA
// NodeInfo signature, so the key stays TOFU-grade for signing purposes.
nodeDB->commitRemoteKey(p->from, remotePublic.bytes, NodeDB::KeyCommitTrust::AdminChannelProven);
}
} else {
// AEAD already authenticated this ciphertext, so no other candidate could decode it -
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