mirror of
https://github.com/alexhopeoconnor/firmware.git
synced 2026-10-16 16:41:33 +10:00
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:
@@ -439,7 +439,7 @@ const TrafficManagementModule::NodeInfoPayloadEntry *TrafficManagementModule::fi
|
||||
|
||||
/// Find or create a NodeInfo payload entry. Victim selection is trust-tiered so the cache
|
||||
/// doubles as a pubkey pool: NodeDB membership outranks key trust, then keyless < TOFU key <
|
||||
/// signer-proven key; within a tier the oldest observation loses (never-observed = oldest).
|
||||
/// key-proven key; within a tier the oldest observation loses (never-observed = oldest).
|
||||
TrafficManagementModule::NodeInfoPayloadEntry *
|
||||
TrafficManagementModule::findOrCreateNodeInfoEntry(NodeNum node, bool *usedEmptySlot, bool spareMembers)
|
||||
{
|
||||
@@ -466,9 +466,9 @@ TrafficManagementModule::findOrCreateNodeInfoEntry(NodeNum node, bool *usedEmpty
|
||||
}
|
||||
if (empty)
|
||||
continue; // an empty slot beats any victim; stop scoring
|
||||
// Eviction tier (lower loses first): 0 keyless, 1 TOFU key, 2 signer-proven key;
|
||||
// Eviction tier (lower loses first): 0 keyless, 1 TOFU key, 2 key-proven key;
|
||||
// +3 for NodeDB members - never shed a NodeDB-tier identity over a stranger.
|
||||
const uint8_t tier = static_cast<uint8_t>(((entry.user.public_key.size != 32) ? 0 : (entry.keySignerProven ? 2 : 1)) +
|
||||
const uint8_t tier = static_cast<uint8_t>(((entry.user.public_key.size != 32) ? 0 : (entry.keyProven() ? 2 : 1)) +
|
||||
(entry.isMember ? 3 : 0));
|
||||
// Modular observation age; saturation keeps real ages far below the 0xFF a
|
||||
// never-observed entry scores, so that entry is always the oldest in its tier.
|
||||
@@ -549,10 +549,18 @@ void TrafficManagementModule::reconcileNodeInfoFromNodeDBLocked()
|
||||
memcpy(entry->user.public_key.bytes, key32, 32);
|
||||
entry->user.public_key.size = 32;
|
||||
}
|
||||
if (keyChanged)
|
||||
entry->keySignerProven = false;
|
||||
if (signerKnown && key32 && entry->user.public_key.size == 32 && memcmp(entry->user.public_key.bytes, key32, 32) == 0)
|
||||
entry->keySignerProven = true;
|
||||
if (keyChanged) {
|
||||
entry->keyXeddsaSigned = false;
|
||||
entry->keyManuallyVerified = false;
|
||||
}
|
||||
const bool keyMatch = key32 && entry->user.public_key.size == 32 && memcmp(entry->user.public_key.bytes, key32, 32) == 0;
|
||||
if (signerKnown && keyMatch)
|
||||
entry->keyXeddsaSigned = true;
|
||||
// Manual verification is a hot-store fact (is_key_manually_verified); re-seed it here so a
|
||||
// reconciled/re-created slot doesn't silently drop it. Warm-only records (hot == nullptr)
|
||||
// don't carry the flag, so this only fires on the hot-tier pass.
|
||||
if (hot && keyMatch && nodeInfoLiteIsKeyManuallyVerified(hot))
|
||||
entry->keyManuallyVerified = true;
|
||||
entry->isMember = true;
|
||||
};
|
||||
|
||||
@@ -571,7 +579,7 @@ void TrafficManagementModule::reconcileNodeInfoFromNodeDBLocked()
|
||||
if (!warm)
|
||||
continue;
|
||||
const bool hasKey = !memfll(warm->public_key, 0, sizeof(warm->public_key));
|
||||
reconcileOne(warm->num, hasKey ? warm->public_key : nullptr, warmSignerOf(*warm), nullptr);
|
||||
reconcileOne(warm->num, hasKey ? warm->public_key : nullptr, warmXeddsaSignedOf(*warm), nullptr);
|
||||
}
|
||||
#endif
|
||||
|
||||
@@ -670,12 +678,17 @@ void TrafficManagementModule::onNodeIdentityCommitted(NodeNum node, const meshta
|
||||
// and may be re-proven by signerKnown below - which vouches for the COMMITTED key only.
|
||||
const bool sameKey = !usedEmptySlot && entry->user.public_key.size == 32 && merged.public_key.size == 32 &&
|
||||
memcmp(entry->user.public_key.bytes, merged.public_key.bytes, 32) == 0;
|
||||
const bool provenBefore = !usedEmptySlot && entry->keySignerProven && sameKey;
|
||||
// Each provenance channel survives independently alongside an unchanged key. signerKnown
|
||||
// (from updateUser's isVerifiedSignerForKey) is the XEdDSA verdict for the COMMITTED key;
|
||||
// the manual bit isn't carried on this path, so it's only preserved, never freshly set here.
|
||||
const bool xeddsaBefore = !usedEmptySlot && entry->keyXeddsaSigned && sameKey;
|
||||
const bool manualBefore = !usedEmptySlot && entry->keyManuallyVerified && sameKey;
|
||||
|
||||
entry->user = merged;
|
||||
snprintf(entry->user.id, sizeof(entry->user.id), "!%08x", node);
|
||||
entry->hasFullUser = true;
|
||||
entry->keySignerProven = provenBefore || (signerKnown && user.public_key.size == 32);
|
||||
entry->keyXeddsaSigned = xeddsaBefore || (signerKnown && user.public_key.size == 32);
|
||||
entry->keyManuallyVerified = manualBefore;
|
||||
entry->isMember = true; // committed via updateUser => it sits in the hot store right now
|
||||
// obsTick/hasObserved deliberately untouched: only a heard frame makes a node servable.
|
||||
}
|
||||
@@ -699,16 +712,19 @@ void TrafficManagementModule::onNodeKeyCommitted(NodeNum node, const uint8_t key
|
||||
memcpy(entry->user.public_key.bytes, key32, 32);
|
||||
entry->user.public_key.size = 32;
|
||||
entry->isMember = true; // the caller just committed it to the hot store
|
||||
// A rotated key never inherits the old key's verdict; `proven` (manual verification of
|
||||
// exactly this key) is the strongest provenance this cache can carry.
|
||||
if (keyChanged)
|
||||
entry->keySignerProven = false;
|
||||
// A rotated key never inherits the old key's verdict; `proven` here means the user manually
|
||||
// verified possession of exactly this key (KeyCommitTrust::ManuallyVerified) - it routes to
|
||||
// the manual bit, not the XEdDSA one.
|
||||
if (keyChanged) {
|
||||
entry->keyXeddsaSigned = false;
|
||||
entry->keyManuallyVerified = false;
|
||||
}
|
||||
if (proven)
|
||||
entry->keySignerProven = true;
|
||||
entry->keyManuallyVerified = true;
|
||||
// hasObserved/obsTick untouched: a key commit is knowledge, not an observation.
|
||||
}
|
||||
|
||||
bool TrafficManagementModule::copyPublicKey(NodeNum node, uint8_t out[32], bool *signerProven) const
|
||||
bool TrafficManagementModule::copyPublicKey(NodeNum node, uint8_t out[32], bool *keyProven) const
|
||||
{
|
||||
// Same enable gate as the write-through hooks and maintenance: a disabled module stops
|
||||
// updating and sweeping the cache, so its frozen contents must not keep feeding PKI key
|
||||
@@ -724,12 +740,12 @@ bool TrafficManagementModule::copyPublicKey(NodeNum node, uint8_t out[32], bool
|
||||
return false;
|
||||
|
||||
memcpy(out, entry->user.public_key.bytes, 32);
|
||||
if (signerProven)
|
||||
*signerProven = entry->keySignerProven;
|
||||
if (keyProven)
|
||||
*keyProven = entry->keyProven();
|
||||
return true;
|
||||
}
|
||||
|
||||
bool TrafficManagementModule::copyUser(NodeNum node, meshtastic_User &out, bool *signerProven) const
|
||||
bool TrafficManagementModule::copyUser(NodeNum node, meshtastic_User &out, bool *keyProven) const
|
||||
{
|
||||
// Enable gate, as in copyPublicKey(): a disabled module must not feed name rehydration
|
||||
// from frozen cache contents once its maintenance/write-through have stopped.
|
||||
@@ -746,8 +762,8 @@ bool TrafficManagementModule::copyUser(NodeNum node, meshtastic_User &out, bool
|
||||
return false;
|
||||
|
||||
out = entry->user;
|
||||
if (signerProven)
|
||||
*signerProven = entry->keySignerProven;
|
||||
if (keyProven)
|
||||
*keyProven = entry->keyProven();
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -830,11 +846,12 @@ void TrafficManagementModule::cacheNodeInfoPacket(const meshtastic_MeshPacket &m
|
||||
entry->hasDecodedBitfield = mp.decoded.has_bitfield;
|
||||
entry->decodedBitfield = mp.decoded.bitfield;
|
||||
|
||||
// Upgrade to signer-proven on a Router-verified signature or a NodeDB signer verdict
|
||||
// for this same key. Never downgrade (a later unsigned frame leaves the flag set),
|
||||
// and the key itself cannot change here - the pin checks above already rejected that.
|
||||
// Upgrade the XEdDSA-signed bit on a Router-verified signature or a NodeDB signer verdict
|
||||
// for this same key (both are XEdDSA provenance). Never downgrade (a later unsigned frame
|
||||
// leaves it set), and the key cannot change here - the pin checks above already rejected
|
||||
// that. The manual-verification bit is orthogonal and untouched on this observation path.
|
||||
if ((mp.xeddsa_signed || dbSaysSigner) && user.public_key.size == 32)
|
||||
entry->keySignerProven = true;
|
||||
entry->keyXeddsaSigned = true;
|
||||
|
||||
if (usedEmptySlot)
|
||||
cachedCount = countNodeInfoEntriesLocked();
|
||||
@@ -866,18 +883,17 @@ int TrafficManagementModule::peekNodeInfoFlagsForTest(NodeNum node)
|
||||
const NodeInfoPayloadEntry *entry = findNodeInfoEntry(node);
|
||||
if (!entry)
|
||||
return -1;
|
||||
return (entry->hasObserved ? 1 : 0) | (entry->isMember ? 2 : 0) | (entry->hasFullUser ? 4 : 0) |
|
||||
(entry->keySignerProven ? 8 : 0);
|
||||
return (entry->hasObserved ? 1 : 0) | (entry->isMember ? 2 : 0) | (entry->hasFullUser ? 4 : 0) | (entry->keyProven() ? 8 : 0);
|
||||
}
|
||||
|
||||
void TrafficManagementModule::markKeySignerProvenForTest(NodeNum node)
|
||||
void TrafficManagementModule::markKeyXeddsaSignedForTest(NodeNum node)
|
||||
{
|
||||
concurrency::LockGuard guard(&cacheLock);
|
||||
if (!nodeInfoPayload)
|
||||
return;
|
||||
for (uint16_t i = 0; i < nodeInfoTargetEntries(); i++) {
|
||||
if (nodeInfoPayload[i].node == node) {
|
||||
nodeInfoPayload[i].keySignerProven = true;
|
||||
nodeInfoPayload[i].keyXeddsaSigned = true;
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -918,7 +934,7 @@ int TrafficManagementModule::peekNodeInfoFlagsForTest(NodeNum)
|
||||
{
|
||||
return -1;
|
||||
}
|
||||
void TrafficManagementModule::markKeySignerProvenForTest(NodeNum) {}
|
||||
void TrafficManagementModule::markKeyXeddsaSignedForTest(NodeNum) {}
|
||||
|
||||
#endif // TMM_HAS_NODEINFO_CACHE
|
||||
|
||||
@@ -1414,9 +1430,9 @@ bool TrafficManagementModule::shouldRespondToNodeInfo(const meshtastic_MeshPacke
|
||||
uint8_t cachedSourceChannel = 0;
|
||||
bool cachedHasObserved = false;
|
||||
uint8_t cachedObsTick = 0;
|
||||
// Signer-proven provenance of the cached key, consumed by the replay gate below
|
||||
// Key-proven provenance (XEdDSA-signed | manually verified) of the cached key, consumed by the replay gate below
|
||||
// (maybe_unused: read only when TMM_NODEINFO_REPLAY_SIGNED_GATE is compiled in).
|
||||
[[maybe_unused]] bool cachedKeySignerProven = false;
|
||||
[[maybe_unused]] bool cachedKeyProven = false;
|
||||
// True once we commit to answering from the NodeDB fallback (no NodeInfo cache) path. The
|
||||
// response throttle no longer distinguishes the paths - the per-requester/per-target RAM
|
||||
// tables cover both - but the replay gate below still keys off it.
|
||||
@@ -1433,7 +1449,7 @@ bool TrafficManagementModule::shouldRespondToNodeInfo(const meshtastic_MeshPacke
|
||||
cachedSourceChannel = entry->sourceChannel;
|
||||
cachedHasObserved = entry->hasObserved;
|
||||
cachedObsTick = entry->obsTick;
|
||||
cachedKeySignerProven = entry->keySignerProven;
|
||||
cachedKeyProven = entry->keyProven();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1461,11 +1477,12 @@ bool TrafficManagementModule::shouldRespondToNodeInfo(const meshtastic_MeshPacke
|
||||
return false;
|
||||
}
|
||||
#if TMM_NODEINFO_REPLAY_SIGNED_GATE
|
||||
// Replay provenance gate (fallback path): only vouch for a node NodeDB knows as a
|
||||
// verified signer. An unproven (trust-on-first-use) identity is left for the genuine
|
||||
// node or another cache-holder to answer.
|
||||
if (!nodeInfoLiteHasXeddsaSigned(node)) {
|
||||
TM_LOG_DEBUG("NodeInfo NodeDB entry for 0x%08x not signer-proven, not responding", p->to);
|
||||
// Replay provenance gate (fallback path): only vouch for a node whose key NodeDB has
|
||||
// proven - an XEdDSA-verified signer or a manually-verified key. This mirrors the cache
|
||||
// path's keyProven() (XEdDSA | manual). An unproven (trust-on-first-use) identity is left
|
||||
// for the genuine node or another cache-holder to answer.
|
||||
if (!nodeInfoLiteHasXeddsaSigned(node) && !nodeInfoLiteIsKeyManuallyVerified(node)) {
|
||||
TM_LOG_DEBUG("NodeInfo NodeDB entry for 0x%08x not key-proven, not responding", p->to);
|
||||
return false;
|
||||
}
|
||||
#endif
|
||||
@@ -1483,10 +1500,10 @@ bool TrafficManagementModule::shouldRespondToNodeInfo(const meshtastic_MeshPacke
|
||||
}
|
||||
|
||||
#if TMM_NODEINFO_REPLAY_SIGNED_GATE
|
||||
// Replay provenance gate (cache path): only spoof a reply for a signer-proven cached key.
|
||||
// Replay provenance gate (cache path): only spoof a reply for a key-proven cached key.
|
||||
// usedFallback entries were already gated above. See TMM_NODEINFO_REPLAY_REQUIRE_SIGNED.
|
||||
if (!usedFallback && !cachedKeySignerProven) {
|
||||
TM_LOG_DEBUG("NodeInfo cache entry for 0x%08x not signer-proven, not responding", p->to);
|
||||
if (!usedFallback && !cachedKeyProven) {
|
||||
TM_LOG_DEBUG("NodeInfo cache entry for 0x%08x not key-proven, not responding", p->to);
|
||||
return false;
|
||||
}
|
||||
#endif
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
#if HAS_TRAFFIC_MANAGEMENT
|
||||
|
||||
// Replay provenance gate: when 1 (default), direct responses are spoofed only for nodes whose
|
||||
// cached key is signer-proven (XEdDSA-verified), not for trust-on-first-use identities.
|
||||
// cached key is key-proven (XEdDSA-signed or manually verified), not for trust-on-first-use identities.
|
||||
// Define as 0 to also serve fresh TOFU-only nodes; bypassed entirely when PKI is excluded.
|
||||
#ifndef TMM_NODEINFO_REPLAY_REQUIRE_SIGNED
|
||||
#define TMM_NODEINFO_REPLAY_REQUIRE_SIGNED 1
|
||||
@@ -65,14 +65,14 @@ class TrafficManagementModule : public MeshModule, private concurrency::OSThread
|
||||
bool preloadNextHopsFromNodeDB();
|
||||
|
||||
/// Last-resort key source for NodeDB::copyPublicKey() after the hot and warm tiers miss.
|
||||
/// Copies the 32-byte key for `node` into out[32]; `signerProven` (optional) reports whether
|
||||
/// the key was XEdDSA-verified vs trust-on-first-use. Thread-safe.
|
||||
bool copyPublicKey(NodeNum node, uint8_t out[32], bool *signerProven = nullptr) const;
|
||||
/// Copies the 32-byte key for `node` into out[32]; `keyProven` (optional) reports whether
|
||||
/// the key is proven (XEdDSA-signed or manually verified) vs trust-on-first-use. Thread-safe.
|
||||
bool copyPublicKey(NodeNum node, uint8_t out[32], bool *keyProven = nullptr) const;
|
||||
|
||||
/// Copy the full cached User for `node` (used by NodeDB to rehydrate a re-admitted node's
|
||||
/// name - the warm tier keeps keys but not names). False on miss or key-only records.
|
||||
/// `signerProven` (optional) reports the cached key's provenance. Thread-safe.
|
||||
bool copyUser(NodeNum node, meshtastic_User &out, bool *signerProven = nullptr) const;
|
||||
/// `keyProven` (optional) reports the cached key's provenance. Thread-safe.
|
||||
bool copyUser(NodeNum node, meshtastic_User &out, bool *keyProven = nullptr) const;
|
||||
|
||||
/// Write-through hook from NodeDB::updateUser(): upsert the committed identity immediately
|
||||
/// (the reconcile sweep remains the backstop). NodeDB's key is authoritative, but a keyless
|
||||
@@ -125,16 +125,16 @@ class TrafficManagementModule : public MeshModule, private concurrency::OSThread
|
||||
/// (distinguishes "not tracked" from CLIENT == 0).
|
||||
int peekCachedRole(NodeNum node);
|
||||
|
||||
/// Test hook: force a cached NodeInfo entry's key to signer-proven so replay-gate tests
|
||||
/// Test hook: force a cached NodeInfo entry's key to XEdDSA-signed so replay-gate tests
|
||||
/// can skip a full XEdDSA verification. No-op if absent.
|
||||
void markKeySignerProvenForTest(NodeNum node);
|
||||
void markKeyXeddsaSignedForTest(NodeNum node);
|
||||
|
||||
/// Test hook: free the NodeInfo cache so the NodeDB fallback path can be exercised in
|
||||
/// builds where the cache is compiled in. No-op when already absent.
|
||||
void dropNodeInfoCacheForTest();
|
||||
|
||||
/// Test introspection: NodeInfo flag bits for `node` (-1 if absent): bit0 hasObserved,
|
||||
/// bit1 isMember, bit2 hasFullUser, bit3 keySignerProven.
|
||||
/// bit1 isMember, bit2 hasFullUser, bit3 keyProven (keyXeddsaSigned | keyManuallyVerified).
|
||||
int peekNodeInfoFlagsForTest(NodeNum node);
|
||||
|
||||
/// Test introspection: NodeInfo cache capacity (kNodeInfoCacheEntries), so tests can
|
||||
@@ -253,16 +253,23 @@ class TrafficManagementModule : public MeshModule, private concurrency::OSThread
|
||||
// in direct replies). Validity: hasDecodedBitfield.
|
||||
uint8_t decodedBitfield;
|
||||
|
||||
// 1-bit flags, packed into one byte (6 spare bits; add future booleans here rather
|
||||
// 1-bit flags, packed into one byte (2 spare bits; add future booleans here rather
|
||||
// than new bytes - the array is 2000 entries).
|
||||
|
||||
// The source packet carried a decoded bitfield (so decodedBitfield is meaningful).
|
||||
uint8_t hasDecodedBitfield : 1;
|
||||
|
||||
// Key provenance: set once an XEdDSA signature was verified for user.public_key
|
||||
// (directly, or inherited from NodeDB via isVerifiedSignerForKey). Monotonic per slot;
|
||||
// the key-pin checks forbid the key changing underneath it. TOFU keys start at 0.
|
||||
uint8_t keySignerProven : 1;
|
||||
// Key provenance, split by how possession was established (either one implies "proven" -
|
||||
// read the pair via keyProven()). Both are monotonic per slot until the key rotates (the
|
||||
// key-pin checks forbid the key changing underneath them), and TOFU keys start at 0.
|
||||
//
|
||||
// keyXeddsaSigned: an XEdDSA signature was verified for user.public_key - a heard signed
|
||||
// frame, or inherited from NodeDB via isVerifiedSignerForKey.
|
||||
uint8_t keyXeddsaSigned : 1;
|
||||
// keyManuallyVerified: the user confirmed possession of exactly this key out-of-band
|
||||
// (QR / fingerprint). Routed here via onNodeKeyCommitted(proven) and re-seeded from the
|
||||
// hot-store is_key_manually_verified bit at reconcile (warm records don't carry it).
|
||||
uint8_t keyManuallyVerified : 1;
|
||||
|
||||
// obsTick is valid: a NODEINFO frame was actually heard within the observation clock's
|
||||
// horizon. Cleared by the sweep once the serve window passes (saturation).
|
||||
@@ -277,6 +284,10 @@ class TrafficManagementModule : public MeshModule, private concurrency::OSThread
|
||||
// removal; a passive NodeDB eviction may lag up to an hour). Member entries are
|
||||
// stickiest under LRU; the bit is the keep-alive (no TTL).
|
||||
uint8_t isMember : 1;
|
||||
|
||||
// Possession proven by either channel - the "key-proven" predicate the replay gate,
|
||||
// eviction tiering, and NodeDB pubkey-pool callers consume.
|
||||
bool keyProven() const { return keyXeddsaSigned || keyManuallyVerified; }
|
||||
};
|
||||
// No exact-size static_assert: sizeof(meshtastic_User) and its padding vary by platform, so
|
||||
// any fixed byte count would fail the build on some boards.
|
||||
|
||||
Reference in New Issue
Block a user