TMM & Warmstore key source naming (#11119)

* one becomes two

* warmstore clarify

* Address PR review: key-provenance terminology consistency

- Log line now says "not key-proven" (gate is XEdDSA OR manual, not just signer)
- Rename markKeySignerProvenForTest -> markKeyXeddsaSignedForTest (sets only the XEdDSA bit)
- Docs + test comments: "signer bit" -> "XEdDSA-signed bit"

clod helped too

* Rename signer-proven -> key-proven for broadened provenance predicate

Address PR #11119 review: the copyPublicKey()/copyUser() out-parameter and
the cache-path replay gate now report entry->keyProven() (XEdDSA-signed OR
manually verified), so the "signerProven" name and "signer-proven" comments
were misleading. Rename the public out-param to keyProven, the local
cachedKeySignerProven to cachedKeyProven, and update coupled callers, log
strings, docs headings, and comments to say "key-proven".

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* nitpicks

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Tom
2026-07-31 10:39:39 +00:00
committed by GitHub
co-authored by Claude Opus 4.8
parent 6367132919
commit e26b6bfc5a
11 changed files with 314 additions and 189 deletions
+9 -9
View File
@@ -3974,7 +3974,7 @@ bool NodeDB::copyPublicKey(NodeNum n, meshtastic_NodeInfoLite_public_key_t &out)
#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
// encrypt to. Keys here may be trust-on-first-use (see copyPublicKey's keyProven), the
// same first-contact trust NodeDB itself applies via updateUser().
if (trafficManagementModule && trafficManagementModule->copyPublicKey(n, out.bytes)) {
out.size = 32;
@@ -3989,10 +3989,10 @@ bool NodeDB::copyPublicKeyForDecrypt(NodeNum n, meshtastic_NodeInfoLite_public_k
if (copyPublicKeyAuthoritative(n, out))
return true;
#if HAS_TRAFFIC_MANAGEMENT
// A cold-tier cache key backs an authenticated decrypt only when signer-proven; unverified TOFU
// A cold-tier cache key backs an authenticated decrypt only when key-proven; unverified TOFU
// cache keys must not. Outbound encryption still uses the opportunistic copyPublicKey().
bool signerProven = false;
if (trafficManagementModule && trafficManagementModule->copyPublicKey(n, out.bytes, &signerProven) && signerProven) {
bool keyProven = false;
if (trafficManagementModule && trafficManagementModule->copyPublicKey(n, out.bytes, &keyProven) && keyProven) {
out.size = 32;
return true;
}
@@ -4012,7 +4012,7 @@ bool NodeDB::isVerifiedSignerForKey(NodeNum n, const uint8_t *key32)
#if WARM_NODE_COUNT > 0
uint8_t warmKey[32];
if (warmStore.copyKey(n, warmKey) && memcmp(warmKey, key32, 32) == 0)
return warmStore.isVerifiedSigner(n);
return warmStore.hasXeddsaSigned(n);
#endif
return false;
}
@@ -4024,7 +4024,7 @@ bool NodeDB::isKnownXeddsaSigner(NodeNum n)
if (info)
return nodeInfoLiteHasXeddsaSigned(info);
#if WARM_NODE_COUNT > 0
return warmStore.isVerifiedSigner(n);
return warmStore.hasXeddsaSigned(n);
#else
return false;
#endif
@@ -4146,9 +4146,9 @@ meshtastic_NodeInfoLite *NodeDB::getOrCreateMeshNode(NodeNum n)
// Restore the role the warm tier cached, so re-admission isn't stuck at CLIENT
// until the next NodeInfo arrives.
lite->role = static_cast<meshtastic_Config_DeviceConfig_Role>(warmRoleOf(warm));
// Restore the signer bit too: it is learned from verified traffic, not from
// Restore the XEdDSA-signed bit too: it is learned from verified traffic, not from
// NodeInfo, so a round trip through the warm tier must not relearn it from zero.
nodeInfoLiteSetBit(lite, NODEINFO_BITFIELD_HAS_XEDDSA_SIGNED_MASK, warmSignerOf(warm));
nodeInfoLiteSetBit(lite, NODEINFO_BITFIELD_HAS_XEDDSA_SIGNED_MASK, warmXeddsaSignedOf(warm));
if (!memfll(warm.public_key, 0, sizeof(warm.public_key))) {
lite->public_key.size = 32;
memcpy(lite->public_key.bytes, warm.public_key, 32);
@@ -4165,7 +4165,7 @@ meshtastic_NodeInfoLite *NodeDB::getOrCreateMeshNode(NodeNum n)
// 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.
// user-related bits, so the warm-restored XEdDSA-signed 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 &&
+4 -4
View File
@@ -419,15 +419,15 @@ class NodeDB
bool copyPublicKeyAuthoritative(NodeNum n, meshtastic_NodeInfoLite_public_key_t &out);
/// Key for the inbound-decrypt path: authoritative (hot/warm), or a cold-tier cache key only when
/// it is signer-proven. Keeps unverified TOFU cache keys from backing pki_encrypted attribution.
/// it is key-proven. Keeps unverified TOFU cache keys from backing pki_encrypted attribution.
bool copyPublicKeyForDecrypt(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.
/// xeddsa-signed 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.
/// Key-agnostic "should n's signable traffic arrive signed", per hot bitfield or warm
/// xeddsa-signed 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
+1 -1
View File
@@ -838,7 +838,7 @@ DecodeState perhapsDecode(meshtastic_MeshPacket *p)
// Resolve the sender's key only for actual PKI-decrypt candidates, not every encrypted channel
// packet: copyPublicKeyForDecrypt() can fall through to a linear scan of TrafficManagement's large
// NodeInfo cache. It returns authoritative keys (hot/warm), or a cold-tier cache key only when it is
// signer-proven - an unverified TOFU cache key must not back authenticated (pki_encrypted, p->from)
// key-proven - an unverified TOFU cache key must not back authenticated (pki_encrypted, p->from)
// DM attribution.
meshtastic_NodeInfoLite_public_key_t remotePublic = {0, {0}};
bool haveRemoteKey = nodeDB->copyPublicKeyForDecrypt(p->from, remotePublic);
+13 -12
View File
@@ -13,7 +13,7 @@
#if defined(NRF52840_XXAA)
#include "flash/flash_nrf5x.h"
#define WARM_RING_MAGIC 0x334E5257u // "WRN3" - v3: last_heard low bits carry role + protected + signer
#define WARM_RING_MAGIC 0x334E5257u // "WRN3" - v3: last_heard low bits carry role + protected + xeddsa-signed
#define WARM_RING_MAGIC_V2 0x324E5257u // "WRN2" - v2: role + protected only; bit 6 was still timestamp.
#define WARM_RING_MAGIC_V1 0x474E5257u // "WRNG" - v1: last_heard was a plain timestamp.
// Older pages are still read on upgrade: v1 keeps identity + key but discards last_heard,
@@ -33,10 +33,10 @@ struct WarmStoreHeader {
};
static_assert(sizeof(WarmStoreHeader) == 16, "header layout is part of the persistence format");
#define WARM_STORE_MAGIC 0x334D5257u // "WRM3" - v3: last_heard low bits carry role + protected + signer
#define WARM_STORE_MAGIC 0x334D5257u // "WRM3" - v3: last_heard low bits carry role + protected + xeddsa-signed
#define WARM_STORE_MAGIC_V2 \
0x324D5257u // "WRM2" - v2: role + protected only; bit 6 was still timestamp. On upgrade
// we clear the signer bit, then rewrite as v3.
// we clear the xeddsa-signed bit, then rewrite as v3.
#define WARM_STORE_MAGIC_V1 \
0x314D5257u // "WRM1" - v1: last_heard was a plain timestamp. On upgrade we keep
// identity + key but discard last_heard, then rewrite as v3.
@@ -136,18 +136,19 @@ WarmNodeEntry *WarmNodeStore::place(NodeNum num, uint32_t lastHeard, const uint8
return slot;
}
bool WarmNodeStore::absorb(NodeNum num, uint32_t lastHeard, const uint8_t *key32, uint8_t role, uint8_t protectedCat, bool signer)
bool WarmNodeStore::absorb(NodeNum num, uint32_t lastHeard, const uint8_t *key32, uint8_t role, uint8_t protectedCat,
bool xeddsaSigned)
{
// Pack role + protected category + signer into the low bits of last_heard. place() and
// Pack role + protected category + xeddsa-signed into the low bits of last_heard. place() and
// ring replay store the raw word verbatim, so the metadata round-trips through flash.
const uint32_t packed = warmPackLastHeard(lastHeard, role, protectedCat, signer);
const uint32_t packed = warmPackLastHeard(lastHeard, role, protectedCat, xeddsaSigned);
const WarmNodeEntry *slot = place(num, packed, key32);
if (!slot)
return false;
persistEntry(*slot);
LOG_MIGRATION("WarmStore absorb 0x%08x key=%d last_heard=%u role=%u prot=%u signer=%u (now %u/%u)", (unsigned)num,
LOG_MIGRATION("WarmStore absorb 0x%08x key=%d last_heard=%u role=%u prot=%u xeddsa=%u (now %u/%u)", (unsigned)num,
keyIsSet(slot->public_key) ? 1 : 0, (unsigned)warmTimeOf(*slot), (unsigned)role, (unsigned)protectedCat,
signer ? 1u : 0u, (unsigned)count(), (unsigned)capacity());
xeddsaSigned ? 1u : 0u, (unsigned)count(), (unsigned)capacity());
return true;
}
@@ -161,10 +162,10 @@ bool WarmNodeStore::lookupMeta(NodeNum num, uint8_t &role, uint8_t &protectedCat
return true;
}
bool WarmNodeStore::isVerifiedSigner(NodeNum num) const
bool WarmNodeStore::hasXeddsaSigned(NodeNum num) const
{
const WarmNodeEntry *e = find(num);
return e && warmSignerOf(*e);
return e && warmXeddsaSignedOf(*e);
}
bool WarmNodeStore::take(NodeNum num, WarmNodeEntry &out)
@@ -464,7 +465,7 @@ void WarmNodeStore::load()
lh = 0;
migrated++;
} else if (fmt == WarmFormat::V2) {
lh &= ~(WARM_SIGNER_MASK << WARM_SIGNER_SHIFT);
lh &= ~(WARM_XEDDSA_SIGNED_MASK << WARM_XEDDSA_SIGNED_SHIFT);
migrated++;
}
const WarmNodeEntry *e = place(rec.num, lh, rec.public_key);
@@ -589,7 +590,7 @@ void WarmNodeStore::load()
} else if (fmt == WarmFormat::V2) {
for (size_t i = 0; i < WARM_NODE_COUNT; i++)
if (entries[i].num)
entries[i].last_heard &= ~(WARM_SIGNER_MASK << WARM_SIGNER_SHIFT);
entries[i].last_heard &= ~(WARM_XEDDSA_SIGNED_MASK << WARM_XEDDSA_SIGNED_SHIFT);
dirty = true;
}
} else {
+12 -12
View File
@@ -43,21 +43,21 @@ static_assert(sizeof(WarmNodeEntry) == 40, "WarmNodeEntry must stay 40 B - persi
//
// The warm tier only uses last_heard to LRU-rank evicted (long-tail) nodes, so ~minute
// recency resolution is plenty. We reclaim the low WARM_META_BITS of that field to carry
// the evicted node's device role, a protected category + a signer flag, at zero cost to
// the evicted node's device role, a protected category + an XEdDSA-signed flag, at zero cost to
// record size (entry stays 40 B; no RAM/flash growth). The high bits remain a real
// unix-seconds timestamp quantised to (1 << WARM_META_BITS) seconds.
//
// Safe because: a real timestamp can never be all-ones (the tombstone sentinel) before
// 2106, and tombstones/erased flash are detected via num before last_heard is read. Only
// the LOW bits are stolen - the high (era) bits are untouched, so the time range is intact.
static constexpr uint32_t WARM_META_BITS = 7; // role(4) + protected(2) + signer(1)
static constexpr uint32_t WARM_META_BITS = 7; // role(4) + protected(2) + xeddsa-signed(1)
static constexpr uint32_t WARM_META_MASK = (1u << WARM_META_BITS) - 1; // 0x7F → 128 s quantum
static constexpr uint32_t WARM_TIME_MASK = ~WARM_META_MASK; // 0xFFFFFF80
static constexpr uint32_t WARM_ROLE_MASK = 0x0Fu; // bits [3:0] device role (0..12)
static constexpr uint32_t WARM_PROT_SHIFT = 4; // bits [5:4] protected category
static constexpr uint32_t WARM_PROT_MASK = 0x03u;
static constexpr uint32_t WARM_SIGNER_SHIFT = 6; // bit [6] we verified an XEdDSA signature from this node
static constexpr uint32_t WARM_SIGNER_MASK = 0x01u;
static constexpr uint32_t WARM_XEDDSA_SIGNED_SHIFT = 6; // bit [6] we verified an XEdDSA signature from this node
static constexpr uint32_t WARM_XEDDSA_SIGNED_MASK = 0x01u;
// On-disk record format, from the page/file magic; older ones are normalised by load().
enum class WarmFormat : uint8_t { Current, V2, V1 };
@@ -65,11 +65,11 @@ enum class WarmFormat : uint8_t { Current, V2, V1 };
// Protected category cached alongside role so consumers needn't re-derive the mapping.
enum class WarmProtected : uint8_t { None = 0, Role = 1, Flag = 2, XeddsaSigner = 3 };
inline uint32_t warmPackLastHeard(uint32_t lastHeard, uint8_t role, uint8_t prot, bool signer)
inline uint32_t warmPackLastHeard(uint32_t lastHeard, uint8_t role, uint8_t prot, bool xeddsaSigned)
{
return (lastHeard & WARM_TIME_MASK) | (static_cast<uint32_t>(role) & WARM_ROLE_MASK) |
((static_cast<uint32_t>(prot) & WARM_PROT_MASK) << WARM_PROT_SHIFT) |
((signer ? WARM_SIGNER_MASK : 0u) << WARM_SIGNER_SHIFT);
((xeddsaSigned ? WARM_XEDDSA_SIGNED_MASK : 0u) << WARM_XEDDSA_SIGNED_SHIFT);
}
inline uint32_t warmTimeOf(const WarmNodeEntry &e)
{
@@ -83,9 +83,9 @@ inline uint8_t warmProtOf(const WarmNodeEntry &e)
{
return static_cast<uint8_t>((e.last_heard >> WARM_PROT_SHIFT) & WARM_PROT_MASK);
}
inline bool warmSignerOf(const WarmNodeEntry &e)
inline bool warmXeddsaSignedOf(const WarmNodeEntry &e)
{
return ((e.last_heard >> WARM_SIGNER_SHIFT) & WARM_SIGNER_MASK) != 0;
return ((e.last_heard >> WARM_XEDDSA_SIGNED_SHIFT) & WARM_XEDDSA_SIGNED_MASK) != 0;
}
// Gated on NRF52840_XXAA: the ring sits at 0xEA000
@@ -109,19 +109,19 @@ class WarmNodeStore
/// entries; otherwise the oldest (keyless-first) entry is replaced.
/// @param role the node's device role (meshtastic_Config_DeviceConfig_Role, 0..12)
/// @param protectedCat WarmProtected category cached for the hop-trim path
/// @param signer true if we ever verified an XEdDSA signature from this node, so
/// @param xeddsaSigned true if we ever verified an XEdDSA signature from this node, so
/// re-admission restores the bit rather than relearning it
/// @return true if the node was stored or updated
bool absorb(NodeNum num, uint32_t lastHeard, const uint8_t *key32 /* may be NULL */, uint8_t role = 0,
uint8_t protectedCat = 0, bool signer = false);
uint8_t protectedCat = 0, bool xeddsaSigned = false);
/// Look up the cached device role + protected category for a warm node.
/// @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
/// True if the warm tier holds this node with its XEdDSA-signed bit set (an XEdDSA signature
/// was verified from it before eviction).
bool isVerifiedSigner(NodeNum num) const;
bool hasXeddsaSigned(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);