Merge branch 'develop' into codex/packet-auth-policy

Resolve conflicts against the NodeDB signer/key primitives (#11050) and the
admin-key PKI decrypt budget (#11100).

- NodeDB: drop this branch's hasSeenXeddsaSigner in favour of develop's
  isKnownXeddsaSigner. They answer the same question, but develop's reads the
  dedicated warm signer bit (warmSignerOf) rather than the WarmProtected
  category, and TrafficManagementModule already depends on it. Keep develop's
  copyPublicKey/copyPublicKeyAuthoritative, isVerifiedSignerForKey and
  commitRemoteKey/KeyCommitTrust.
- checkXeddsaReceivePolicy: keep this branch's Strict/Balanced/Compatible
  policy, which is a superset of develop's balanced-only downgrade gate, and
  call isKnownXeddsaSigner from it. develop's !pki_encrypted term is dropped
  because the policy returns early for PKI packets before that check.
- perhapsDecode: keep develop's key resolution (NodeDB then pending-key, only
  for real PKI candidates) plus its admin-key token bucket, and re-apply this
  branch's pkiAttempted flag feeding the DECODE_OPAQUE verdict. Keep both
  passesRoutingAuthGate and adminKeyFallbackAllowed/Refund.
- test_A17: model eviction the way NodeDB actually does it, passing the warm
  signer bit as well as the XeddsaSigner category, since isKnownXeddsaSigner
  reads the former.

Native suite: 38 suites, 743/743 cases, no sanitizer findings.
This commit is contained in:
Ben Meadors
2026-07-21 06:09:57 -05:00
57 changed files with 4366 additions and 618 deletions
+17 -4
View File
@@ -18,7 +18,7 @@ uint8_t MeshModule::numPeriodicModules = 0;
*/
meshtastic_MeshPacket *MeshModule::currentReply;
MeshModule::MeshModule(const char *_name) : name(_name)
MeshModule::MeshModule(const char *_name, meshtastic_PortNum _ourPortNum) : name(_name), ourPortNum(_ourPortNum)
{
// Can't trust static initializer order, so we check each time
if (!modules)
@@ -27,6 +27,12 @@ MeshModule::MeshModule(const char *_name) : name(_name)
modules->push_back(this);
}
bool MeshModule::replyPortMatches(meshtastic_PortNum modulePort, const meshtastic_MeshPacket &mp)
{
return modulePort != meshtastic_PortNum_UNKNOWN_APP && mp.which_payload_variant == meshtastic_MeshPacket_decoded_tag &&
mp.decoded.portnum == modulePort;
}
void MeshModule::setup() {}
MeshModule::~MeshModule()
@@ -57,6 +63,8 @@ meshtastic_MeshPacket *MeshModule::allocAckNak(meshtastic_Routing_Error err, Nod
// So we manually call pb_encode_to_bytes and specify routing port number
// auto p = allocDataProtobuf(c);
meshtastic_MeshPacket *p = router->allocForSending();
if (!p)
return nullptr;
p->decoded.portnum = meshtastic_PortNum_ROUTING_APP;
p->decoded.payload.size =
pb_encode_to_bytes(p->decoded.payload.bytes, sizeof(p->decoded.payload.bytes), &meshtastic_Routing_msg, &c);
@@ -105,6 +113,7 @@ void MeshModule::callModules(meshtastic_MeshPacket &mp, RxSource src)
auto &pi = **i;
pi.currentRequest = ∓
pi.ignoreRequest = false;
/// We only call modules that are interested in the packet (and the message is destined to us or we are promiscious)
bool wantsPacket = (isDecoded || pi.encryptedOk) && (pi.isPromiscuous || toUs) && pi.wantPacket(&mp);
@@ -155,9 +164,13 @@ void MeshModule::callModules(meshtastic_MeshPacket &mp, RxSource src)
// better solution (FIXME) would be to let phones have their own distinct addresses and we 'route' to them like
// any other node.
if (isDecoded && mp.decoded.want_response && toUs && (!isFromUs(&mp) || isToUs(&mp)) && !currentReply) {
pi.sendResponse(mp);
if (replyPortMatches(pi.ourPortNum, mp)) {
pi.sendResponse(mp);
LOG_INFO("Asked module '%s' to send a response", pi.name);
} else {
LOG_DEBUG("Module '%s' cannot respond on portnum=%d", pi.name, mp.decoded.portnum);
}
ignoreRequest = ignoreRequest || pi.ignoreRequest; // If at least one module asks it, we may ignore a request
LOG_INFO("Asked module '%s' to send a response", pi.name);
} else {
LOG_DEBUG("Module '%s' considered", pi.name);
}
@@ -311,4 +324,4 @@ bool MeshModule::isRequestingFocus()
} else
return false;
}
#endif
#endif
+5 -2
View File
@@ -68,10 +68,12 @@ class MeshModule
/** Constructor
* name is for debugging output
*/
MeshModule(const char *_name);
MeshModule(const char *_name, meshtastic_PortNum _ourPortNum = meshtastic_PortNum_UNKNOWN_APP);
virtual ~MeshModule();
static bool replyPortMatches(meshtastic_PortNum modulePort, const meshtastic_MeshPacket &mp);
/** For use only by MeshService
*/
static void callModules(meshtastic_MeshPacket &mp, RxSource src = RX_SRC_RADIO);
@@ -88,6 +90,7 @@ class MeshModule
#endif
protected:
const char *name;
meshtastic_PortNum ourPortNum;
/** Most modules only care about packets that are destined for their node (i.e. broadcasts or has their node as the specific
recipient) But some plugs might want to 'sniff' packets that are merely being routed (passing through the current node). Those
@@ -103,7 +106,7 @@ class MeshModule
* flag */
bool encryptedOk = false;
/* We allow modules to ignore a request without sending an error if they have a specific reason for it. */
/* Per-packet flag cleared by callModules(); modules can suppress an error response for a specific request. */
bool ignoreRequest = false;
/**
+142 -12
View File
@@ -31,6 +31,9 @@
#if HAS_VARIABLE_HOPS
#include "modules/HopScalingModule.h"
#endif
#if HAS_TRAFFIC_MANAGEMENT
#include "modules/TrafficManagementModule.h"
#endif
#include "xmodem.h"
#include <ErriezCRC32.h>
#include <algorithm>
@@ -767,6 +770,12 @@ bool NodeDB::factoryReset(bool eraseBleBonds)
warmStore.clear();
warmStore.saveIfDirty();
#endif
#if HAS_TRAFFIC_MANAGEMENT
// Factory reset forgets everything; TMM's RAM caches must not survive to resurrect
// identities (the device usually reboots after this, but don't rely on it).
if (trafficManagementModule)
trafficManagementModule->purgeAll();
#endif
// second, install default state (this will deal with the duplicate mac address issue)
installDefaultNodeDatabase();
@@ -1597,6 +1606,11 @@ void NodeDB::resetNodes(bool keepFavorites)
#if WARM_NODE_COUNT > 0
warmStore.clear(); // warm entries are never favorites; a DB reset clears them too
#endif
#if HAS_TRAFFIC_MANAGEMENT
// A user-initiated DB reset forgets everything; TMM's caches must not resurrect it.
if (trafficManagementModule)
trafficManagementModule->purgeAll();
#endif
devicestate.has_rx_waypoint = false;
saveNodeDatabaseToDisk();
@@ -1629,6 +1643,12 @@ void NodeDB::removeNodeByNum(NodeNum nodeNum)
// Explicit user removal: don't let the warm tier resurrect the node
warmStore.remove(nodeNum);
#endif
#if HAS_TRAFFIC_MANAGEMENT
// Explicit removal is full removal: the TrafficManagement caches (unified slot +
// NodeInfo identity cache) must not keep serving or resurrect the node either.
if (trafficManagementModule)
trafficManagementModule->purgeNode(nodeNum);
#endif
LOG_DEBUG("NodeDB::removeNodeByNum purged %d entries. Save changes", removed);
saveNodeDatabaseToDisk();
@@ -3122,6 +3142,9 @@ size_t NodeDB::getNumOnlineMeshNodes(bool localOnly)
#include "MeshModule.h"
#include "Throttle.h"
// Minimum spacing between evictions once the node database is full.
#define NODEDB_FULL_EVICTION_INTERVAL_MS (2 * 1000UL)
static constexpr uint32_t HOPSTART_DROP_LOG_INTERVAL_MS = 15000;
void logHopStartDrop(const meshtastic_MeshPacket &p, const char *context)
@@ -3314,15 +3337,16 @@ void NodeDB::addFromContact(meshtastic_SharedContact contact)
*/
bool NodeDB::updateUser(uint32_t nodeId, meshtastic_User &p, uint8_t channelIndex, bool xeddsaSigned)
{
meshtastic_NodeInfoLite *info = getOrCreateMeshNode(nodeId);
if (!info) {
// Only a signed update may change the identity of a node that has proven it signs; our own record is
// exempt. Checked before getOrCreateMeshNode so a refused update cannot evict or write the warm tier.
const meshtastic_NodeInfoLite *existing = getMeshNode(nodeId);
if (nodeId != getNodeNum() && existing && nodeInfoLiteHasXeddsaSigned(existing) && !xeddsaSigned) {
LOG_WARN("Refusing unsigned identity update for node 0x%08x that previously signed", nodeId);
return false;
}
// Once a node has proven it signs, only a signed update may change its identity. The public-key guard
// below is no help - an attacker can replay the victim's real (public) key. Our own record is exempt.
if (nodeId != getNodeNum() && nodeInfoLiteHasXeddsaSigned(info) && !xeddsaSigned) {
LOG_WARN("Refusing unsigned identity update for node 0x%08x that previously signed", nodeId);
meshtastic_NodeInfoLite *info = getOrCreateMeshNode(nodeId);
if (!info) {
return false;
}
@@ -3402,6 +3426,20 @@ bool NodeDB::updateUser(uint32_t nodeId, meshtastic_User &p, uint8_t channelInde
}
}
#if HAS_TRAFFIC_MANAGEMENT
// Write-through: every accepted remote-identity commit lands here (NodeInfoModule,
// MeshService, and TMM's requester learning all funnel through updateUser; the two
// key-write sites that bypass it call onNodeKeyCommitted instead), so TMM's NodeInfo
// cache reflects the commit immediately rather than at the next reconcile pass. Runs on
// acceptance, not on `changed`: an identical update still proves the identity is
// current. `p` is the post-hygiene payload; signerKnown transfers only key-matched
// verified-signer status (isVerifiedSignerForKey semantics), never a bare node flag.
if (nodeId != getNodeNum() && trafficManagementModule) {
const bool signerKnown = p.public_key.size == 32 && isVerifiedSignerForKey(nodeId, p.public_key.bytes);
trafficManagementModule->onNodeIdentityCommitted(nodeId, p, signerKnown);
}
#endif
return changed;
}
@@ -3416,7 +3454,19 @@ void NodeDB::updateFrom(const meshtastic_MeshPacket &mp)
if (mp.which_payload_variant == meshtastic_MeshPacket_decoded_tag && mp.from) {
LOG_DEBUG("Update DB node 0x%08x, rx_time=%u", mp.from, mp.rx_time);
meshtastic_NodeInfoLite *info = getOrCreateMeshNode(getFrom(&mp));
// mp.from is unauthenticated, so rate-limit admission once the database is full: otherwise
// invented node numbers churn it at packet rate and push real neighbours out.
meshtastic_NodeInfoLite *info = getMeshNode(getFrom(&mp));
if (!info) {
if (isFull()) {
if (Throttle::isWithinTimespanMs(lastFullEvictionMs, NODEDB_FULL_EVICTION_INTERVAL_MS)) {
LOG_DEBUG("Node database full, defer admitting 0x%08x", mp.from);
return;
}
lastFullEvictionMs = millis();
}
info = getOrCreateMeshNode(getFrom(&mp));
}
if (!info) {
return;
}
@@ -3700,7 +3750,7 @@ uint32_t NodeDB::hotNodeLastHeard(NodeNum n) const
return 0;
}
bool NodeDB::copyPublicKey(NodeNum n, meshtastic_NodeInfoLite_public_key_t &out)
bool NodeDB::copyPublicKeyAuthoritative(NodeNum n, meshtastic_NodeInfoLite_public_key_t &out)
{
const meshtastic_NodeInfoLite *info = getMeshNode(n);
if (info && info->public_key.size == 32) {
@@ -3716,18 +3766,81 @@ bool NodeDB::copyPublicKey(NodeNum n, meshtastic_NodeInfoLite_public_key_t &out)
return false;
}
bool NodeDB::hasSeenXeddsaSigner(NodeNum n)
bool NodeDB::copyPublicKey(NodeNum n, meshtastic_NodeInfoLite_public_key_t &out)
{
if (nodeInfoLiteHasXeddsaSigned(getMeshNode(n)))
if (copyPublicKeyAuthoritative(n, out))
return true;
#if HAS_TRAFFIC_MANAGEMENT
// Last resort: a key the TrafficManagement NodeInfo cache learned from an observed frame
// for a node no longer in either NodeDB tier. This extends the pool of peers we can
// encrypt to. Keys here may be trust-on-first-use (see copyPublicKey's signerProven), the
// same first-contact trust NodeDB itself applies via updateUser().
if (trafficManagementModule && trafficManagementModule->copyPublicKey(n, out.bytes)) {
out.size = 32;
return true;
}
#endif
return false;
}
bool NodeDB::isVerifiedSignerForKey(NodeNum n, const uint8_t *key32)
{
if (!key32)
return false;
// Hot store is authoritative when present; a node lives in the hot XOR warm tier, so if the
// hot store holds it the warm tier does not, and we decide entirely from the hot entry.
const meshtastic_NodeInfoLite *info = getMeshNode(n);
if (info)
return info->public_key.size == 32 && nodeInfoLiteHasXeddsaSigned(info) && memcmp(info->public_key.bytes, key32, 32) == 0;
#if WARM_NODE_COUNT > 0
uint8_t role = 0, prot = 0;
return warmStore.lookupMeta(n, role, prot) && prot == static_cast<uint8_t>(WarmProtected::XeddsaSigner);
uint8_t warmKey[32];
if (warmStore.copyKey(n, warmKey) && memcmp(warmKey, key32, 32) == 0)
return warmStore.isVerifiedSigner(n);
#endif
return false;
}
bool NodeDB::isKnownXeddsaSigner(NodeNum n)
{
// A node lives in the hot XOR warm tier, so the hot verdict is final when present.
const meshtastic_NodeInfoLite *info = getMeshNode(n);
if (info)
return nodeInfoLiteHasXeddsaSigned(info);
#if WARM_NODE_COUNT > 0
return warmStore.isVerifiedSigner(n);
#else
return false;
#endif
}
void NodeDB::commitRemoteKey(NodeNum n, const uint8_t key32[32], KeyCommitTrust trust)
{
if (!key32 || n == 0)
return;
// Local copy first: callers may pass the node's own key bytes back in (e.g. manual
// verification re-committing an already-stored key), and memcpy forbids overlap.
uint8_t key[32];
memcpy(key, key32, 32);
meshtastic_NodeInfoLite *info = getOrCreateMeshNode(n);
if (!info)
return;
// Unconditional overwrite - deliberately NOT updateUser()'s "don't replace a known key" pin.
// That pin protects against unauthenticated NodeInfo broadcasts; the only callers here are
// possession/authority-proven (ManuallyVerified = user confirmed the key; AdminChannelProven =
// decrypted via the admin key with p->from bound into the AEAD nonce), i.e. exactly the paths
// meant to establish or rotate a key. Keep new call sites to that same trust bar.
memcpy(info->public_key.bytes, key, 32);
info->public_key.size = 32;
#if HAS_TRAFFIC_MANAGEMENT
// Write-through, mirroring updateUser()'s identity hook: without it the TrafficManagement
// NodeInfo cache diverges until the next hourly reconcile.
if (trafficManagementModule)
trafficManagementModule->onNodeKeyCommitted(n, key, trust == KeyCommitTrust::ManuallyVerified);
#endif
}
meshtastic_Config_DeviceConfig_Role NodeDB::getNodeRole(NodeNum n)
{
const meshtastic_NodeInfoLite *info = getMeshNode(n);
@@ -3827,6 +3940,23 @@ meshtastic_NodeInfoLite *NodeDB::getOrCreateMeshNode(NodeNum n)
nodeInfoLiteSetBit(lite, NODEINFO_BITFIELD_HAS_XEDDSA_SIGNED_MASK, true);
LOG_MIGRATION("Rehydrated node 0x%08x from warm tier (key=%d)", n, lite->public_key.size == 32);
}
#endif
#if HAS_TRAFFIC_MANAGEMENT
// Name rehydration: the warm tier keeps a node's key but not its name, so a re-admitted
// long-tail node is nameless until its next NodeInfo. The TrafficManagement NodeInfo
// cache is much larger and often still holds the full User. Restore it - but only when
// its cached key matches the key we just restored from warm, so a name never attaches to
// a different identity than the one we encrypt to. No-op without the TMM NodeInfo cache
// or when no key is present (key-matched by design). CopyUserToNodeInfoLite sets only the
// user-related bits, so the warm-restored signer bit survives.
if (lite->public_key.size == 32 && !nodeInfoLiteHasUser(lite) && trafficManagementModule) {
meshtastic_User tmmUser = meshtastic_User_init_zero;
if (trafficManagementModule->copyUser(n, tmmUser) && tmmUser.public_key.size == 32 &&
memcmp(tmmUser.public_key.bytes, lite->public_key.bytes, 32) == 0) {
TypeConversions::CopyUserToNodeInfoLite(lite, tmmUser);
LOG_INFO("Rehydrated node 0x%08x identity from TMM NodeInfo cache", n);
}
}
#endif
LOG_INFO("Adding node to database with %i nodes and %u bytes free!", numMeshNodes, memGet.getFreeHeap());
}
+30 -6
View File
@@ -360,9 +360,32 @@ class NodeDB
/// tier. Returns false if we don't know a key for n.
bool copyPublicKey(NodeNum n, meshtastic_NodeInfoLite_public_key_t &out);
/// Whether this node has produced a verified XEdDSA signature, including while its
/// identity is resident only in the warm tier.
bool hasSeenXeddsaSigner(NodeNum n);
/// Copy the 32-byte key for n from the AUTHORITATIVE tiers only (hot, then warm; never
/// opportunistic caches) - the pin reference for caches that mirror NodeDB's key hygiene.
bool copyPublicKeyAuthoritative(NodeNum n, meshtastic_NodeInfoLite_public_key_t &out);
/// True if n is a known XEdDSA signer for exactly `key32` (hot signed bitfield or warm
/// signer bit); the key match stops a rotated key inheriting a stale signer verdict.
bool isVerifiedSignerForKey(NodeNum n, const uint8_t *key32);
/// Key-agnostic "should n's signable traffic arrive signed", per hot bitfield or warm signer
/// bit - hot-only gates would let a warm-evicted signer be impersonated with unsigned frames.
bool isKnownXeddsaSigner(NodeNum n);
/// Provenance of a bare-key commit that deliberately bypasses updateUser()'s
/// User-payload / TOFU-pin path. Maps to the TrafficManagement cache's `proven` flag:
/// only ManuallyVerified vouches for possession of exactly this key.
enum class KeyCommitTrust : uint8_t {
AdminChannelProven, // possession shown to the admin channel (AEAD) - TOFU-grade for signing
ManuallyVerified, // the user confirmed possession of exactly this key
};
/// THE primitive for key writes that bypass updateUser() (no User payload; provenance
/// differs from a received NodeInfo): writes the 32-byte key to the hot store and
/// write-through to the TrafficManagement NodeInfo cache. Any future direct key-write
/// site must call this rather than assigning info->public_key, or the TrafficManagement
/// cache silently diverges until the next hourly reconcile.
void commitRemoteKey(NodeNum n, const uint8_t key32[32], KeyCommitTrust trust);
/// Resolve a node's device role - hot store (with user) first, then the role
/// cached in the warm tier, else CLIENT. Lets role-aware policy keep firing for
@@ -533,9 +556,10 @@ class NodeDB
/// skip boot keygen and skip persisting defaults, so a transient read failure can't change our NodeNum
/// or overwrite the on-disk config. Cleared at the top of every loadFromDisk() run.
bool configDecodeFailed = false;
uint32_t lastNodeDbSave = 0; // when we last saved our db to flash
uint32_t lastBackupAttempt = 0; // when we last tried a backup automatically or manually
uint32_t lastSort = 0; // When last sorted the nodeDB
uint32_t lastNodeDbSave = 0; // when we last saved our db to flash
uint32_t lastFullEvictionMs = 0; // when we last evicted to admit a new node, once the db is full
uint32_t lastBackupAttempt = 0; // when we last tried a backup automatically or manually
uint32_t lastSort = 0; // When last sorted the nodeDB
/*
* Internal boolean to track sorting paused
+2
View File
@@ -44,6 +44,8 @@ template <class T> class ProtobufModule : protected SinglePortModule
{
// Update our local node info with our position (even if we don't decide to update anyone else)
meshtastic_MeshPacket *p = allocDataPacket();
if (!p)
return nullptr;
p->decoded.payload.size =
pb_encode_to_bytes(p->decoded.payload.bytes, sizeof(p->decoded.payload.bytes), fields, &payload);
+100 -24
View File
@@ -16,7 +16,6 @@
#include <pb_decode.h>
#include <pb_encode.h>
#if HAS_TRAFFIC_MANAGEMENT
#include "modules/TrafficManagementModule.h"
#endif
#if HAS_VARIABLE_HOPS
#include "modules/HopScalingModule.h"
@@ -271,6 +270,8 @@ PacketId generatePacketId()
meshtastic_MeshPacket *Router::allocForSending()
{
meshtastic_MeshPacket *p = packetPool.allocZeroed();
if (!p)
return nullptr;
p->which_payload_variant = meshtastic_MeshPacket_decoded_tag; // Assume payload is decoded at start.
p->from = nodeDB->getNodeNum();
@@ -663,11 +664,15 @@ bool checkXeddsaReceivePolicy(meshtastic_MeshPacket *p)
if (compatible)
return true;
// In Balanced, preserve legacy unsigned-unicast compatibility and only reject a signable
// unsigned broadcast from a known signer. Canonical sizing removes unknown protobuf
// fields before mirroring the sender-side signedDataFits() gate. Oversized broadcasts
// remain compatible.
if (nodeDB->hasSeenXeddsaSigner(p->from) && isBroadcast(p->to)) {
// In Balanced, preserve legacy unsigned-unicast compatibility and only reject the class a
// signing node always signs: a non-PKI broadcast whose signed encoding would still fit the
// LoRa frame. Canonical sizing removes unknown protobuf fields before mirroring the
// sender-side signedDataFits() gate, so this counts the same fields that gate counted.
// Unicast packets and broadcasts too big to carry a signature are never signed, so they
// must not be hard-failed here even for a known signer (PKI already returned above).
// isKnownXeddsaSigner consults the warm tier too: a signer evicted from the hot store
// must not become impersonatable via unsigned broadcasts until it is re-heard.
if (nodeDB->isKnownXeddsaSigner(p->from) && isBroadcast(p->to)) {
size_t canonicalSize;
if (!canonicalSignableSize(&p->decoded, &canonicalSize))
return true; // can't size it; never drop on a sizing failure
@@ -730,6 +735,54 @@ RoutingAuthVerdict passesRoutingAuthGate(meshtastic_MeshPacket *p)
return RoutingAuthVerdict::ACCEPT;
}
#if !(MESHTASTIC_EXCLUDE_PKI)
// The fallback costs three X25519 ops before the AEAD tag is checked. Budget is global because p->from is
// attacker-controlled; successful runs refund, and their key is then persisted for the fast path.
#define ADMIN_KEY_FALLBACK_BURST 8
#define ADMIN_KEY_FALLBACK_REFILL_MS 250
static uint32_t adminKeyFallbackTokens = ADMIN_KEY_FALLBACK_BURST;
static uint32_t adminKeyFallbackRefillMs = 0;
static bool adminKeyFallbackAllowed()
{
bool haveAdminKey = false;
for (int i = 0; i < 3; i++) {
if (config.security.admin_key[i].size == 32) {
haveAdminKey = true;
break;
}
}
if (!haveAdminKey)
return false; // nothing to try, so do not spend a token
uint32_t now = millis();
if (adminKeyFallbackRefillMs == 0)
adminKeyFallbackRefillMs = now;
uint32_t elapsed = now - adminKeyFallbackRefillMs;
if (elapsed >= ADMIN_KEY_FALLBACK_REFILL_MS) {
uint32_t refill = elapsed / ADMIN_KEY_FALLBACK_REFILL_MS;
adminKeyFallbackRefillMs += refill * ADMIN_KEY_FALLBACK_REFILL_MS;
if (refill >= ADMIN_KEY_FALLBACK_BURST - adminKeyFallbackTokens)
adminKeyFallbackTokens = ADMIN_KEY_FALLBACK_BURST;
else
adminKeyFallbackTokens += refill;
}
if (adminKeyFallbackTokens == 0)
return false;
adminKeyFallbackTokens--;
return true;
}
static void adminKeyFallbackRefund()
{
if (adminKeyFallbackTokens < ADMIN_KEY_FALLBACK_BURST)
adminKeyFallbackTokens++;
}
#endif
DecodeState perhapsDecode(meshtastic_MeshPacket *p)
{
concurrency::LockGuard g(cryptLock);
@@ -757,33 +810,48 @@ DecodeState perhapsDecode(meshtastic_MeshPacket *p)
bool matchedChannel = false;
ChannelIndex chIndex = 0;
#if !(MESHTASTIC_EXCLUDE_PKI)
// Resolve the sender's public key: prefer the one stored in NodeDB (hot store or warm tier), else
// fall back to a not-yet-committed key held during an in-progress key-verification handshake.
meshtastic_NodeInfoLite_public_key_t remotePublic = {0, {0}};
bool haveRemoteKey = nodeDB->copyPublicKey(p->from, remotePublic) || crypto->getPendingPublicKey(p->from, remotePublic);
meshtastic_NodeInfoLite *ourNode = nullptr;
if (p->channel == 0 && isToUs(p) && p->to > 0 && !isBroadcast(p->to) && rawSize > MESHTASTIC_PKC_OVERHEAD &&
(ourNode = nodeDB->getMeshNode(p->to)) != nullptr && ourNode->public_key.size > 0) {
pkiAttempted = true;
LOG_DEBUG("Attempt PKI decryption");
// Resolve the sender's public key only for actual PKI-decrypt candidates: prefer NodeDB
// (hot store or warm tier), else a not-yet-committed key held during an in-progress
// key-verification handshake. On a full NodeDB miss, copyPublicKey() falls through to a
// linear scan of TrafficManagement's large NodeInfo cache, so it must not run for every
// encrypted channel packet from an unknown sender - only for packets we might decrypt.
meshtastic_NodeInfoLite_public_key_t remotePublic = {0, {0}};
bool haveRemoteKey = nodeDB->copyPublicKey(p->from, remotePublic);
// A pending key is an unverified identity claim supplied by whoever opened the handshake, so it is
// accepted only for the exchange itself (checked after decode). perhapsEncode applies the same rule.
bool havePendingKey = false;
if (!haveRemoteKey) {
havePendingKey = crypto->getPendingPublicKey(p->from, remotePublic);
haveRemoteKey = havePendingKey;
}
// Try the sender's known key first, then each configured admin key so an authorized admin can
// reach a node that has not yet learned their key. AES-CCM AEAD rejects wrong candidates.
bool viaAdminKey = false;
bool viaPendingKey = false;
if (haveRemoteKey && crypto->decryptCurve25519(p->from, remotePublic, p->id, rawSize, p->encrypted.bytes, bytes)) {
decrypted = true;
viaPendingKey = havePendingKey;
}
for (int i = 0; i < 3 && !decrypted; i++) {
if (config.security.admin_key[i].size != 32)
continue;
remotePublic.size = 32;
memcpy(remotePublic.bytes, config.security.admin_key[i].bytes, 32);
if (!decrypted && adminKeyFallbackAllowed()) {
for (int i = 0; i < 3 && !decrypted; i++) {
if (config.security.admin_key[i].size != 32)
continue;
remotePublic.size = 32;
memcpy(remotePublic.bytes, config.security.admin_key[i].bytes, 32);
if (crypto->decryptCurve25519(p->from, remotePublic, p->id, rawSize, p->encrypted.bytes, bytes)) {
decrypted = true;
viaAdminKey = true;
break; // stop after first successful decryption
if (crypto->decryptCurve25519(p->from, remotePublic, p->id, rawSize, p->encrypted.bytes, bytes)) {
decrypted = true;
viaAdminKey = true;
break; // stop after first successful decryption
}
}
if (decrypted)
adminKeyFallbackRefund();
}
if (decrypted) {
LOG_INFO("PKI Decryption worked!");
@@ -792,6 +860,12 @@ DecodeState perhapsDecode(meshtastic_MeshPacket *p)
size_t payloadSize = rawSize - MESHTASTIC_PKC_OVERHEAD;
if (pb_decode_from_bytes(bytes, payloadSize, &meshtastic_Data_msg, &decodedtmp) &&
decodedtmp.portnum != meshtastic_PortNum_UNKNOWN_APP) {
if (viaPendingKey && decodedtmp.portnum != meshtastic_PortNum_KEY_VERIFICATION_APP) {
// The pending key only proves the handshake initiator holds it, not that they are
// p->from. Beyond the exchange it would let them send DMs that look authenticated.
LOG_WARN("Refusing pending-key decrypt of port %u from 0x%08x", (unsigned)decodedtmp.portnum, p->from);
return DecodeState::DECODE_FAILURE;
}
decrypted = true;
rawSize = payloadSize; // commit the overhead subtraction only on full success
LOG_INFO("Packet decrypted using PKI!");
@@ -802,10 +876,12 @@ DecodeState perhapsDecode(meshtastic_MeshPacket *p)
p->which_payload_variant = meshtastic_MeshPacket_decoded_tag; // change type to decoded
if (viaAdminKey) {
// Persist the admin key for the sender so future packets take the fast path and we can
// PKI-reply; p->from is bound into the AEAD nonce, so the trusted admin authenticated it.
meshtastic_NodeInfoLite *fromNode = nodeDB->getOrCreateMeshNode(p->from);
if (fromNode != nullptr)
fromNode->public_key = remotePublic;
// PKI-reply; p->from is bound into the AEAD nonce, so the trusted admin authenticated
// it. commitRemoteKey is the bare-key commit primitive: it bypasses updateUser's
// User-payload path deliberately and handles the TrafficManagement write-through.
// AdminChannelProven = possession shown to the admin channel, not via an XEdDSA
// NodeInfo signature, so the key stays TOFU-grade for signing purposes.
nodeDB->commitRemoteKey(p->from, remotePublic.bytes, NodeDB::KeyCommitTrust::AdminChannelProven);
}
} else {
// AEAD already authenticated this ciphertext, so no other candidate could decode it -
+4 -5
View File
@@ -8,14 +8,11 @@
*/
class SinglePortModule : public MeshModule
{
protected:
meshtastic_PortNum ourPortNum;
public:
/** Constructor
* name is for debugging output
*/
SinglePortModule(const char *_name, meshtastic_PortNum _ourPortNum) : MeshModule(_name), ourPortNum(_ourPortNum) {}
SinglePortModule(const char *_name, meshtastic_PortNum _ourPortNum) : MeshModule(_name, _ourPortNum) {}
protected:
/**
@@ -32,8 +29,10 @@ class SinglePortModule : public MeshModule
{
// Update our local node info with our position (even if we don't decide to update anyone else)
meshtastic_MeshPacket *p = router->allocForSending();
if (!p)
return nullptr;
p->decoded.portnum = ourPortNum;
return p;
}
};
};
+6
View File
@@ -161,6 +161,12 @@ bool WarmNodeStore::lookupMeta(NodeNum num, uint8_t &role, uint8_t &protectedCat
return true;
}
bool WarmNodeStore::isVerifiedSigner(NodeNum num) const
{
const WarmNodeEntry *e = find(num);
return e && warmSignerOf(*e);
}
bool WarmNodeStore::take(NodeNum num, WarmNodeEntry &out)
{
WarmNodeEntry *e = find(num);
+13
View File
@@ -119,6 +119,10 @@ class WarmNodeStore
/// @return false if the node is not in the warm tier.
bool lookupMeta(NodeNum num, uint8_t &role, uint8_t &protectedCat) const;
/// True if the warm tier holds this node with its signer bit set (an XEdDSA signature
/// was verified from it before eviction).
bool isVerifiedSigner(NodeNum num) const;
/// Find and remove an entry (used when the node is re-admitted to the hot store).
bool take(NodeNum num, WarmNodeEntry &out);
@@ -131,6 +135,15 @@ class WarmNodeStore
size_t count() const;
size_t capacity() const { return entries ? WARM_NODE_COUNT : 0; }
/// Slot-indexed read for whole-tier reconciliation: the entry in slot i, or nullptr
/// when the slot is empty or i >= capacity().
const WarmNodeEntry *entryAt(size_t i) const
{
if (!entries || i >= WARM_NODE_COUNT || entries[i].num == 0)
return nullptr;
return &entries[i];
}
#if MESHTASTIC_NODEDB_MIGRATION_VERBOSE
/// Debug: dump every live warm entry (num / last_heard / has-key) to the
/// console. Compiled out unless MESHTASTIC_NODEDB_MIGRATION_VERBOSE.