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:
11 files changed
+314
-189
No files matched your search
+43
-34
@@ -44,8 +44,8 @@ part is deliberately class-deviant and the reason is given under the table.
|
||||
members). Everything else in this document is a cache or a fallback for it.
|
||||
- **Eviction:** oldest non-protected node when full (`getOrCreateMeshNode`). On eviction
|
||||
the node's essentials are **absorbed into the warm tier** (see §2); on re-admission the
|
||||
warm record is rehydrated back (`take()`), including the signer bit.
|
||||
- **Persistence:** the node database file, saved on the usual NodeDB cadence.
|
||||
warm record is rehydrated back (`take()`), including the XEdDSA-signed bit.
|
||||
- **Persistence:** the node database file in LittleFS, saved on the usual NodeDB cadence.
|
||||
- **Authority:** key pinning (`updateUser`'s "Public Key mismatch" drop), signer
|
||||
provenance, and identity content all originate here. The lookup helpers that other
|
||||
stores mirror:
|
||||
@@ -77,13 +77,14 @@ inside the stock 28 KB LittleFS.
|
||||
else rebuilds from traffic in seconds.
|
||||
- **Entry:** exactly 40 bytes - `num(4) | last_heard(4) | public_key(32)`. The low 7 bits
|
||||
of `last_heard` are omitted, and replaced with metadata (role: 4 bits, protected
|
||||
category: 2, signer bit: 1), leaving ~128 s recency resolution - plenty for LRU ranking.
|
||||
category: 2, XEdDSA-signed bit: 1), leaving ~128 s recency resolution - plenty for LRU ranking.
|
||||
- **Capacity:** `WARM_NODE_COUNT` (100 on constrained parts; platform-tiered).
|
||||
- **Eviction:** LRU by `last_heard`, with keyed entries outranking keyless; keyless
|
||||
candidates never displace keyed entries.
|
||||
- **Persistence:** nRF52840 uses a 12 KB raw-flash record-ring below LittleFS
|
||||
(append/replay/compact); everywhere else `/prefs/warm.dat`.
|
||||
(append/replay/compact); everywhere else `/prefs/warm.dat` (LittleFS).
|
||||
- **Membership invariant:** a node lives in the hot **XOR** warm tier. `take()` removes
|
||||
the warm record when the node is re-admitted hot, restoring role/protected/signer bits.
|
||||
the warm record when the node is re-admitted hot, restoring role/protected/XEdDSA-signed bits.
|
||||
|
||||
**Capacity** - `WARM_NODE_COUNT` (`mesh-pb-constants.h`):
|
||||
|
||||
@@ -116,7 +117,7 @@ TINY's 0 disables the tier outright. At 40 B/entry, LARGE costs ~80 KB and lives
|
||||
preferring to keep entries with a `next_hop` hint **or** a cached special (non-`CLIENT`)
|
||||
role - the long-tail state this cache exists to retain (`findOrCreateEntry`'s `preferred`
|
||||
test covers both, not just `next_hop`).
|
||||
- **Persistence:** none - RAM/PSRAM only, rebuilt from traffic.
|
||||
- **Persistence:** none - PSRAM (or heap) only, rebuilt from traffic.
|
||||
|
||||
**Capacity** - `TRAFFIC_MANAGEMENT_CACHE_SIZE` (`mesh-pb-constants.h`), variant-overridable:
|
||||
|
||||
@@ -141,7 +142,7 @@ heap reason as the warm tier (its class would give 400); 250 entries still track
|
||||
heap so the trust/retention paths run in CI.
|
||||
- **Entry:** `node`, `user` (full nanopb `User`), the `obsTick` recency stamp (3 min/tick),
|
||||
`sourceChannel`, `decodedBitfield`, and packed 1-bit flags: `hasDecodedBitfield`,
|
||||
`keySignerProven`, `hasObserved`, `hasFullUser`, `isMember`. (The direct-response throttle
|
||||
`keyXeddsaSigned`, `keyManuallyVerified`, `hasObserved`, `hasFullUser`, `isMember`. (The direct-response throttle
|
||||
no longer keeps per-entry state here - it is a pair of separate RAM tables; see the module
|
||||
doc.)
|
||||
- **Persistence:** none - this tier is deliberately ephemeral; it reconstructs from NodeDB
|
||||
@@ -165,9 +166,14 @@ paths run in CI. Linear scan in every build - NodeInfo traffic is low-rate.
|
||||
pin), and, failing NodeDB knowledge, against the cache's **own previously cached key**
|
||||
(TOFU pin). Mismatches are dropped, never overwritten. A frame advertising _our own_ key
|
||||
is dropped outright (impersonation).
|
||||
- **`keySignerProven`:** set when a frame's XEdDSA signature was router-verified
|
||||
(`mp.xeddsa_signed`) or when NodeDB already knew the node as a signer **for the same
|
||||
key** (`isVerifiedSignerForKey`). Monotonic per slot; a changed key resets it.
|
||||
- **Key provenance (`keyXeddsaSigned` + `keyManuallyVerified`, combined via `keyProven()`):**
|
||||
`keyXeddsaSigned` is set when a frame's XEdDSA signature was router-verified
|
||||
(`mp.xeddsa_signed`) or when NodeDB already knew the node as a signer **for the same key**
|
||||
(`isVerifiedSignerForKey`). `keyManuallyVerified` is set when the user confirmed possession
|
||||
out-of-band (QR / fingerprint), routed via `onNodeKeyCommitted(proven)` and re-seeded from the
|
||||
hot store's `is_key_manually_verified` bit at reconcile. Either bit makes `keyProven()` true -
|
||||
the predicate the replay gate, eviction tiering, and pubkey-pool callers use. Both are monotonic
|
||||
per slot; a changed key resets both.
|
||||
- **Unsigned-identity gate:** a NodeInfo arriving _unsigned_ from a node we have ever
|
||||
verified as a signer - per `NodeDB::isKnownXeddsaSigner()`, which covers hot **and
|
||||
warm** tiers - drives no cache, role, or `updateUser()` write. (Warm coverage matters: a
|
||||
@@ -175,10 +181,10 @@ paths run in CI. Linear scan in every build - NodeInfo traffic is low-rate.
|
||||
until re-heard. The same rule guards `Router::checkXeddsaReceivePolicy`'s
|
||||
unsigned-broadcast drop.)
|
||||
- **Serve gate honesty:** only a genuinely _heard_ NODEINFO frame stamps
|
||||
`obsTick`/`hasObserved`. Seeding and write-through are knowledge, not observation - they
|
||||
can never make a silent node look alive to the replay path. The 6 h serve window is
|
||||
enforced by the sweep-cleared `hasObserved` bit; the spoofed-reply throttle that gate
|
||||
feeds lives in the module (see [traffic_management_module.md](traffic_management_module.md)).
|
||||
`obsTick`/`hasObserved` - seeding and write-through don't, so a silent node never looks alive
|
||||
to the replay path. The sweep clears `hasObserved` to enforce the 6 h serve window. The
|
||||
spoofed-reply throttle this gate feeds lives in the module (see
|
||||
[traffic_management_module.md](traffic_management_module.md)).
|
||||
|
||||
### Consistency with NodeDB (anti-entropy)
|
||||
|
||||
@@ -201,14 +207,14 @@ independent of `hasFullUser`. Because the re-mark is only hourly, hook-driven ad
|
||||
an hour.
|
||||
|
||||
**Retention:** no timed eviction. Slots die only by LRU displacement on insert, ranked by
|
||||
trust tiers - members and signer-proven keys are stickiest; the seeding pass additionally
|
||||
trust tiers - members and key-proven keys are stickiest; the seeding pass additionally
|
||||
refuses to churn one member out for another (`spareMembers`).
|
||||
|
||||
**Key-commit funnel:** every path that writes a remote key into the hot store must route
|
||||
the write-through. Full-identity commits funnel through `NodeDB::updateUser()`; bare-key
|
||||
commits (admin-channel learn in `Router::perhapsDecode`, manual verification in
|
||||
`KeyVerificationModule`) funnel through `NodeDB::commitRemoteKey()`, which carries an
|
||||
explicit `KeyCommitTrust` provenance (`ManuallyVerified` maps to `proven=true` in this
|
||||
explicit `KeyCommitTrust` provenance (`ManuallyVerified` sets the `keyManuallyVerified` bit in this
|
||||
cache). Never assign `info->public_key` directly when **learning or rotating a remote
|
||||
key** - the cache would silently diverge until the next reconcile. (The lone direct write
|
||||
in `getOrCreateMeshNode()`'s warm-tier re-admission is exempt: it restores a key the warm
|
||||
@@ -251,23 +257,26 @@ behaviour - is documented with the module in
|
||||
Side-by-side view of what each store actually holds ("-" = not held). Details and
|
||||
rationale live in the per-store sections above.
|
||||
|
||||
| Property | 1. Hot store (`NodeInfoLite`) | 2. Warm tier (`WarmNodeEntry`) | 3. NodeInfo cache (`NodeInfoPayloadEntry`) | 4. Unified cache (`UnifiedCacheEntry`) |
|
||||
| ------------------------- | -------------------------------- | ---------------------------------------------- | ------------------------------------------------------------- | -------------------------------------------------- |
|
||||
| Node number | yes | yes | yes (0 = free slot) | yes (0 = free slot) |
|
||||
| Names + user id | yes (flattened fields) | - | yes (full `User`, when `hasFullUser`) | - |
|
||||
| Public key (32 B) | yes (authoritative) | yes (keyed entries) | yes (TOFU or proven; pinned against tiers 1-2) | - |
|
||||
| Signer provenance | `HAS_XEDDSA_SIGNED` bitfield bit | 1 signer bit (shared with `last_heard`) | `keySignerProven` (monotonic per key) | - |
|
||||
| Device role | `role` field | 4-bit role (metadata steal) | inside the cached `User` | 4-bit role in count-byte top bits (final fallback) |
|
||||
| Recency | `last_heard` (unix secs) | `last_heard` (unix secs, 128 s quantised) | `obsTick` (3 min modular tick) + `hasObserved` | pos/rate/unknown modular ticks |
|
||||
| Position / telemetry | via satellite copy-out accessors | - | - | 8-bit position _fingerprint_ only (dedup) |
|
||||
| Protected / favorite | bitfield flags | 2-bit protected category | - (`isMember` keep-alive instead) | - |
|
||||
| Routing hint (`next_hop`) | yes (persisted field) | - | - | ACK-confirmed relay byte (preloaded from tier 1) |
|
||||
| Direct-reply metadata | - | - | `sourceChannel`, `decodedBitfield` (+ `hasDecodedBitfield`) | - |
|
||||
| Traffic-shaping counters | - | - | - | rate + unknown counts, pos fingerprint |
|
||||
| Entry size | largest (full struct) | 40 B exact | ~`sizeof(User)`+8, platform-padded (no size assert by design) | 10 B exact |
|
||||
| Capacity | `MAX_NUM_NODES` (10-250) | `WARM_NODE_COUNT` (0-2000) | `kNodeInfoCacheEntries` (2000) | `TRAFFIC_MANAGEMENT_CACHE_SIZE` (0-2048) |
|
||||
| Persistence | node DB file | raw-flash ring (nRF52840) or `/prefs/warm.dat` | none (rebuilt from seed + traffic) | none |
|
||||
| Storage | RAM | RAM + flash | PSRAM on hardware; plain heap in native tests | PSRAM when available, else heap |
|
||||
| Property | 1. Hot store | 2. Warm tier | 3. NodeInfo cache | 4. Unified cache |
|
||||
| -------------------------- | ------------------------------ | ------------------------------ | ---------------------------------- | ------------------------------- |
|
||||
| Struct | `NodeInfoLite` | `WarmNodeEntry` | `NodeInfoPayloadEntry` | `UnifiedCacheEntry` |
|
||||
| Node number | yes | yes | yes (0 = free) | yes (0 = free) |
|
||||
| Names + user id | yes (flattened) | - | yes (full `User`) | - |
|
||||
| Public key (32 B) | yes (authoritative) | yes (keyed entries) | yes (TOFU/proven; pinned) | - |
|
||||
| Key source - XEdDSA signed | `HAS_XEDDSA_SIGNED` bit | 1 bit (in `last_heard`) | `keyXeddsaSigned` | - |
|
||||
| Key source - manual scan | `IS_KEY_MANUALLY_VERIFIED` bit | - (not carried) | `keyManuallyVerified` | - |
|
||||
| Device role | `role` field | 4-bit role (metadata steal) | in cached `User` | 4-bit role (final fallback) |
|
||||
| Recency | `last_heard` (unix s) | `last_heard` (128 s quant.) | `obsTick` (3 min) + `hasObserved` | modular ticks |
|
||||
| Position / telemetry | satellite accessors | - | - | 8-bit pos fingerprint (dedup) |
|
||||
| Protected / favorite | bitfield flags | 2-bit protected category | - (`isMember` instead) | - |
|
||||
| Routing hint (`next_hop`) | yes (persisted) | - | - | ACK-confirmed relay byte |
|
||||
| Direct-reply metadata | - | - | `sourceChannel`, `decodedBitfield` | - |
|
||||
| Traffic-shaping counters | - | - | - | rate + unknown counts, pos fp |
|
||||
| Entry size | largest (full struct) | 40 B exact | ~`sizeof(User)`+8 (padded) | 10 B exact |
|
||||
| Capacity (symbol) | `MAX_NUM_NODES` | `WARM_NODE_COUNT` | `kNodeInfoCacheEntries` | `TRAFFIC_MANAGEMENT_CACHE_SIZE` |
|
||||
| Capacity (entries) | 250/120/10 | ~100 | 2000 | 2048/500/400/250/0 |
|
||||
| Persistence (durable) | LittleFS (node DB) | flash ring (nRF52840)/LittleFS | none (rebuilt) | none |
|
||||
| Storage (runtime) | heap | heap / PSRAM (ESP32) | PSRAM (hw) / heap (test) | PSRAM / heap |
|
||||
|
||||
## How a lookup falls through the tiers
|
||||
|
||||
@@ -278,7 +287,7 @@ identity/role/key consumer
|
||||
1. hot store (NodeInfoLite) full identity, authoritative
|
||||
│ miss
|
||||
▼
|
||||
2. warm tier (WarmNodeStore) key + role/protected/signer bits, persisted
|
||||
2. warm tier (WarmNodeStore) key + role/protected/XEdDSA-signed bits, persisted
|
||||
│ miss
|
||||
▼
|
||||
3. TMM NodeInfo cache (extended) full User payloads + TOFU/proven keys, ephemeral
|
||||
|
||||
@@ -111,10 +111,12 @@ and the request is left to propagate normally:
|
||||
4. **Staleness gate (6 h)**: never vouch for a node not genuinely _heard_ within the serve
|
||||
window. Only a real observed frame stamps the recency bit - seeding and write-through are
|
||||
knowledge, not observation, so a silent node can never look alive to this path.
|
||||
5. **Signer-provenance gate** (`TMM_NODEINFO_REPLAY_SIGNED_GATE`, default on): vouch only for
|
||||
an identity whose key is signer-proven (XEdDSA-verified, directly or inherited from
|
||||
NodeDB). A trust-on-first-use identity is left for the genuine node - or another
|
||||
cache-holder that _has_ proof - to answer. Bypassed when PKI is compiled out.
|
||||
5. **Key-provenance gate** (`TMM_NODEINFO_REPLAY_SIGNED_GATE`, default on): vouch only for
|
||||
an identity whose key is proven - XEdDSA-verified (directly or inherited from NodeDB) **or**
|
||||
manually verified out-of-band. Both paths honour both channels: the cache path via
|
||||
`keyProven()`, the NodeDB fallback path via `HAS_XEDDSA_SIGNED | IS_KEY_MANUALLY_VERIFIED`. A
|
||||
trust-on-first-use identity is left for the genuine node - or another cache-holder that _has_
|
||||
proof - to answer. Bypassed when PKI is compiled out.
|
||||
6. **Throttle** (`directResponseAllowed()`): see the next section.
|
||||
|
||||
**The spoofed reply.** On success TMM emits a NodeInfo reply with `from` set to the _target_
|
||||
|
||||
+9
-9
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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);
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -107,10 +107,10 @@ class MockNodeDB : public NodeDB
|
||||
numMeshNodes = 2;
|
||||
}
|
||||
|
||||
// Seed a full identity (name, 32-byte key of `keyByte`, optional signer bit) into the
|
||||
// hot-store buffer at index 1, for reconcile/seeding tests that iterate
|
||||
// getMeshNodeByIndex().
|
||||
void setHotNodeIdentity(NodeNum n, const char *longName, uint8_t keyByte, bool signer)
|
||||
// Seed a full identity (name, 32-byte key of `keyByte`, optional XEdDSA-signed and/or
|
||||
// manually-verified provenance bits) into the hot-store buffer at index 1, for
|
||||
// reconcile/seeding tests that iterate getMeshNodeByIndex().
|
||||
void setHotNodeIdentity(NodeNum n, const char *longName, uint8_t keyByte, bool xeddsaSigned, bool manuallyVerified = false)
|
||||
{
|
||||
setHotNode(n, 0);
|
||||
meshtastic_NodeInfoLite &info = (*meshNodes)[1];
|
||||
@@ -118,8 +118,10 @@ class MockNodeDB : public NodeDB
|
||||
info.public_key.size = 32;
|
||||
memset(info.public_key.bytes, keyByte, 32);
|
||||
info.bitfield |= NODEINFO_BITFIELD_HAS_USER_MASK;
|
||||
if (signer)
|
||||
if (xeddsaSigned)
|
||||
info.bitfield |= NODEINFO_BITFIELD_HAS_XEDDSA_SIGNED_MASK;
|
||||
if (manuallyVerified)
|
||||
info.bitfield |= NODEINFO_BITFIELD_IS_KEY_MANUALLY_VERIFIED_MASK;
|
||||
}
|
||||
|
||||
// Evict everything but "self" - simulates the hot DB rolling over. Logical
|
||||
@@ -196,7 +198,7 @@ class TrafficManagementModuleTestShim : public TrafficManagementModule
|
||||
using TrafficManagementModule::dropNodeInfoCacheForTest;
|
||||
using TrafficManagementModule::flushCache;
|
||||
using TrafficManagementModule::handleReceived;
|
||||
using TrafficManagementModule::markKeySignerProvenForTest;
|
||||
using TrafficManagementModule::markKeyXeddsaSignedForTest;
|
||||
using TrafficManagementModule::nodeInfoCacheCapacityForTest;
|
||||
using TrafficManagementModule::peekCachedRole;
|
||||
using TrafficManagementModule::peekNodeInfoFlagsForTest;
|
||||
@@ -770,8 +772,8 @@ static void test_tm_nodeinfo_directResponse_psramCacheRespondsAndPreservesBitfie
|
||||
|
||||
ProcessMessage observedResult = module.handleReceived(observed);
|
||||
TEST_ASSERT_EQUAL_INT(static_cast<int>(ProcessMessage::CONTINUE), static_cast<int>(observedResult));
|
||||
// Signed-only replay gate (default) requires signer-proven provenance to serve.
|
||||
module.markKeySignerProvenForTest(kTargetNode);
|
||||
// Signed-only replay gate (default) requires key-proven provenance to serve.
|
||||
module.markKeyXeddsaSignedForTest(kTargetNode);
|
||||
|
||||
meshtastic_MeshPacket request = makeDecodedPacket(meshtastic_PortNum_NODEINFO_APP, kRemoteNode, kTargetNode);
|
||||
request.decoded.want_response = true;
|
||||
@@ -851,8 +853,8 @@ static void test_tm_nodeinfo_directResponse_psramStaleEntryNotServed(void)
|
||||
// Learn a NodeInfo for the target into the NodeInfo cache (broadcast, so it is only cached).
|
||||
meshtastic_MeshPacket observed = makeNodeInfoPacket(kTargetNode, "target-long", "tg");
|
||||
module.handleReceived(observed);
|
||||
// Signer-proven so staleness is the sole reason it is not served (isolates the gate under test).
|
||||
module.markKeySignerProvenForTest(kTargetNode);
|
||||
// Key-proven so staleness is the sole reason it is not served (isolates the gate under test).
|
||||
module.markKeyXeddsaSignedForTest(kTargetNode);
|
||||
|
||||
// Advance the virtual clock just past the 6 h serve window.
|
||||
// 6 h + two 3-min observation ticks: guarantees the modular obs-tick age exceeds the
|
||||
@@ -893,8 +895,8 @@ static void test_tm_nodeinfo_directResponse_psramThrottlesWithinWindow(void)
|
||||
|
||||
meshtastic_MeshPacket observed = makeNodeInfoPacket(kTargetNode, "target-long", "tg");
|
||||
module.handleReceived(observed);
|
||||
// Signed-only replay gate (default) requires signer-proven provenance to serve.
|
||||
module.markKeySignerProvenForTest(kTargetNode);
|
||||
// Signed-only replay gate (default) requires key-proven provenance to serve.
|
||||
module.markKeyXeddsaSignedForTest(kTargetNode);
|
||||
|
||||
meshtastic_MeshPacket request = makeDecodedPacket(meshtastic_PortNum_NODEINFO_APP, kRemoteNode, kTargetNode);
|
||||
request.decoded.want_response = true;
|
||||
@@ -966,8 +968,8 @@ static void test_tm_nodeinfo_cache_rejectsMismatchedKey(void)
|
||||
module.handleReceived(makeNodeInfoPacketWithKey(kTargetNode, "genuine", 0x11));
|
||||
// Poisoning attempt with a different key (0x22...) must be rejected.
|
||||
module.handleReceived(makeNodeInfoPacketWithKey(kTargetNode, "attacker", 0x22));
|
||||
// Signed-only replay gate (default) requires signer-proven provenance to serve the reply.
|
||||
module.markKeySignerProvenForTest(kTargetNode);
|
||||
// Signed-only replay gate (default) requires key-proven provenance to serve the reply.
|
||||
module.markKeyXeddsaSignedForTest(kTargetNode);
|
||||
|
||||
meshtastic_MeshPacket request = makeDecodedPacket(meshtastic_PortNum_NODEINFO_APP, kRemoteNode, kTargetNode);
|
||||
request.decoded.want_response = true;
|
||||
@@ -1030,7 +1032,7 @@ static void test_tm_nodeinfo_cache_pinsAgainstWarmTierKey(void)
|
||||
/**
|
||||
* Unsigned-identity gate, warm tier: a verified signer evicted to the warm tier must not be
|
||||
* impersonatable. An attacker can forge an unsigned NodeInfo carrying the signer's real
|
||||
* (public!) key - it passes the key pin and would inherit warm signer provenance - so the
|
||||
* (public!) key - it passes the key pin and would inherit warm XEdDSA-signed provenance - so the
|
||||
* gate must classify warm-tier signers, not only hot-store ones. A signature-verified frame
|
||||
* (control) is still learned.
|
||||
*/
|
||||
@@ -1043,7 +1045,7 @@ static void test_tm_nodeinfo_gate_blocksUnsignedWarmSignerForgery(void)
|
||||
uint8_t warmKey[32];
|
||||
memset(warmKey, 0x5A, 32);
|
||||
mockNodeDB->warmStore.clear();
|
||||
mockNodeDB->warmStore.absorb(kTargetNode, 1000000, warmKey, 0, 0, /*signer=*/true);
|
||||
mockNodeDB->warmStore.absorb(kTargetNode, 1000000, warmKey, 0, 0, /*xeddsaSigned=*/true);
|
||||
|
||||
MockRouter mockRouter;
|
||||
mockRouter.addInterface(std::unique_ptr<RadioInterface>(new MockRadioInterface()));
|
||||
@@ -1071,9 +1073,15 @@ static void test_tm_nodeinfo_gate_blocksUnsignedWarmSignerForgery(void)
|
||||
mockNodeDB->warmStore.clear();
|
||||
}
|
||||
|
||||
// Bit positions returned by peekNodeInfoFlagsForTest().
|
||||
constexpr int kFlagObserved = 1;
|
||||
constexpr int kFlagMember = 2;
|
||||
constexpr int kFlagFullUser = 4;
|
||||
constexpr int kFlagKeyProven = 8; // keyProven() == keyXeddsaSigned | keyManuallyVerified
|
||||
|
||||
/**
|
||||
* Reconcile seeding, serve-gate honesty: a hot-store identity is seeded into the cache by
|
||||
* the maintenance sweep (name + key + signer provenance usable via copyUser/copyPublicKey),
|
||||
* the maintenance sweep (name + key + key provenance usable via copyUser/copyPublicKey),
|
||||
* but is NEVER served as a spoofed reply until a genuine NODEINFO frame is heard - seeding
|
||||
* and retention must not make a silent node look alive.
|
||||
*/
|
||||
@@ -1083,7 +1091,7 @@ static void test_tm_nodeinfo_reconcile_seedsFromHotStoreButNeverServes(void)
|
||||
config.device.role = meshtastic_Config_DeviceConfig_Role_CLIENT;
|
||||
mockNodeDB->clearCachedNode();
|
||||
mockNodeDB->warmStore.clear();
|
||||
mockNodeDB->setHotNodeIdentity(kTargetNode, "hot-name", 0x77, /*signer=*/true);
|
||||
mockNodeDB->setHotNodeIdentity(kTargetNode, "hot-name", 0x77, /*xeddsaSigned=*/true);
|
||||
|
||||
MockRouter mockRouter;
|
||||
mockRouter.addInterface(std::unique_ptr<RadioInterface>(new MockRadioInterface()));
|
||||
@@ -1099,7 +1107,7 @@ static void test_tm_nodeinfo_reconcile_seedsFromHotStoreButNeverServes(void)
|
||||
bool proven = false;
|
||||
TEST_ASSERT_TRUE(module.copyPublicKey(kTargetNode, key, &proven));
|
||||
TEST_ASSERT_EQUAL_UINT8(0x77, key[0]);
|
||||
TEST_ASSERT_TRUE(proven); // inherited from the hot store's signer bit, key-matched
|
||||
TEST_ASSERT_TRUE(proven); // inherited from the hot store's XEdDSA-signed bit, key-matched
|
||||
meshtastic_User seeded = meshtastic_User_init_zero;
|
||||
TEST_ASSERT_TRUE(module.copyUser(kTargetNode, seeded, nullptr));
|
||||
TEST_ASSERT_EQUAL_STRING("hot-name", seeded.long_name);
|
||||
@@ -1125,9 +1133,49 @@ static void test_tm_nodeinfo_reconcile_seedsFromHotStoreButNeverServes(void)
|
||||
mockNodeDB->rollHotStore();
|
||||
}
|
||||
|
||||
/**
|
||||
* Reconcile re-seeds manual verification: a hot-store node carrying IS_KEY_MANUALLY_VERIFIED
|
||||
* (but NOT XEdDSA-signed) is reconciled into the cache with keyProven() set via the manual
|
||||
* channel alone, so copyPublicKey reports it proven and the replay gate would vouch for it.
|
||||
* Guards the second provenance channel independently of the XEdDSA path.
|
||||
*/
|
||||
static void test_tm_nodeinfo_reconcile_seedsManualVerificationFromHotStore(void)
|
||||
{
|
||||
moduleConfig.traffic_management.nodeinfo_direct_response_max_hops = 10;
|
||||
config.device.role = meshtastic_Config_DeviceConfig_Role_CLIENT;
|
||||
mockNodeDB->clearCachedNode();
|
||||
mockNodeDB->warmStore.clear();
|
||||
// Manually verified but NOT XEdDSA-signed: isolates the manual provenance channel.
|
||||
mockNodeDB->setHotNodeIdentity(kTargetNode, "hot-name", 0x5A, /*xeddsaSigned=*/false,
|
||||
/*manuallyVerified=*/true);
|
||||
|
||||
MockRouter mockRouter;
|
||||
mockRouter.addInterface(std::unique_ptr<RadioInterface>(new MockRadioInterface()));
|
||||
MeshService mockService;
|
||||
router = &mockRouter;
|
||||
service = &mockService;
|
||||
|
||||
TrafficManagementModuleTestShim module;
|
||||
module.runOnce(); // maintenance sweep -> reconcile seeds the hot identity
|
||||
|
||||
// keyProven() is set even though keyXeddsaSigned is false - the manual bit alone proves it.
|
||||
const int flags = module.peekNodeInfoFlagsForTest(kTargetNode);
|
||||
TEST_ASSERT_TRUE(flags >= 0);
|
||||
TEST_ASSERT_TRUE(flags & kFlagKeyProven);
|
||||
|
||||
// The pubkey pool sees it as proven, sourced from the manual channel.
|
||||
uint8_t key[32] = {0};
|
||||
bool proven = false;
|
||||
TEST_ASSERT_TRUE(module.copyPublicKey(kTargetNode, key, &proven));
|
||||
TEST_ASSERT_EQUAL_UINT8(0x5A, key[0]);
|
||||
TEST_ASSERT_TRUE(proven);
|
||||
|
||||
mockNodeDB->rollHotStore();
|
||||
}
|
||||
|
||||
/**
|
||||
* Reconcile seeding from the warm tier yields a key-only record: usable by copyPublicKey
|
||||
* (with the warm signer bit inherited), but never by copyUser - the warm tier keeps no
|
||||
* (with the warm XEdDSA-signed bit inherited), but never by copyUser - the warm tier keeps no
|
||||
* names, and a nameless User must not reach name-rehydration.
|
||||
*/
|
||||
static void test_tm_nodeinfo_reconcile_seedsKeyOnlyFromWarmTier(void)
|
||||
@@ -1139,7 +1187,7 @@ static void test_tm_nodeinfo_reconcile_seedsKeyOnlyFromWarmTier(void)
|
||||
uint8_t warmKey[32];
|
||||
memset(warmKey, 0x44, 32);
|
||||
mockNodeDB->warmStore.clear();
|
||||
mockNodeDB->warmStore.absorb(kTargetNode, 1000000, warmKey, 0, 0, /*signer=*/true);
|
||||
mockNodeDB->warmStore.absorb(kTargetNode, 1000000, warmKey, 0, 0, /*xeddsaSigned=*/true);
|
||||
|
||||
MockRouter mockRouter;
|
||||
mockRouter.addInterface(std::unique_ptr<RadioInterface>(new MockRadioInterface()));
|
||||
@@ -1239,7 +1287,7 @@ static void test_tm_nodeinfo_updateUserHook_writesThrough(void)
|
||||
bool proven = true;
|
||||
TEST_ASSERT_TRUE(module.copyPublicKey(kTargetNode, key, &proven));
|
||||
TEST_ASSERT_EQUAL_UINT8(0x5A, key[0]);
|
||||
TEST_ASSERT_FALSE(proven); // committed TOFU key: no signer bit on the node yet
|
||||
TEST_ASSERT_FALSE(proven); // committed TOFU key: no XEdDSA-signed bit on the node yet
|
||||
meshtastic_User out = meshtastic_User_init_zero;
|
||||
TEST_ASSERT_TRUE(module.copyUser(kTargetNode, out, nullptr));
|
||||
TEST_ASSERT_EQUAL_STRING("committed", out.long_name);
|
||||
@@ -1314,7 +1362,7 @@ static void test_tm_nodeinfo_noTimedEviction_quietKeyedEntrySurvives(void)
|
||||
|
||||
TrafficManagementModuleTestShim module;
|
||||
module.handleReceived(makeNodeInfoPacketWithKey(kTargetNode, "quiet", 0x21));
|
||||
module.markKeySignerProvenForTest(kTargetNode); // isolate: staleness, not the signed gate
|
||||
module.markKeyXeddsaSignedForTest(kTargetNode); // isolate: staleness, not the signed gate
|
||||
|
||||
// Nine days of silence, swept every three days. The old design would have evicted the
|
||||
// entry at the 7-day retention TTL; now nothing expires by timer.
|
||||
@@ -1340,7 +1388,7 @@ static void test_tm_nodeinfo_noTimedEviction_quietKeyedEntrySurvives(void)
|
||||
|
||||
/**
|
||||
* Feature #2: a key learned from an (unsigned) NodeInfo is served by copyPublicKey() as a
|
||||
* trust-on-first-use key, so it can extend the encryption pool. signerProven must be false.
|
||||
* trust-on-first-use key, so it can extend the encryption pool. keyProven must be false.
|
||||
*/
|
||||
static void test_tm_nodeinfo_copyPublicKey_servesTofuKey(void)
|
||||
{
|
||||
@@ -1367,9 +1415,9 @@ static void test_tm_nodeinfo_copyPublicKey_servesTofuKey(void)
|
||||
|
||||
/**
|
||||
* Feature #1: a later signature-verified NodeInfo upgrades the cached key's provenance to
|
||||
* signer-proven (monotonic), while the key bytes stay pinned.
|
||||
* XEdDSA-signed (monotonic), while the key bytes stay pinned.
|
||||
*/
|
||||
static void test_tm_nodeinfo_copyPublicKey_upgradesToSignerProven(void)
|
||||
static void test_tm_nodeinfo_copyPublicKey_upgradesToXeddsaSigned(void)
|
||||
{
|
||||
moduleConfig.traffic_management.nodeinfo_direct_response_max_hops = 10;
|
||||
config.device.role = meshtastic_Config_DeviceConfig_Role_CLIENT;
|
||||
@@ -1444,7 +1492,7 @@ static void test_tm_nodeinfo_copyUser_returnsCachedIdentity(void)
|
||||
|
||||
#if TMM_NODEINFO_REPLAY_SIGNED_GATE
|
||||
/**
|
||||
* Replay gate (cache path): a fresh but trust-on-first-use (never signer-proven) cached entry
|
||||
* Replay gate (cache path): a fresh but trust-on-first-use (never key-proven) cached entry
|
||||
* is withheld - the reply is suppressed though the entry is fresh.
|
||||
*/
|
||||
static void test_tm_nodeinfo_directResponse_psramUnsignedNotServed(void)
|
||||
@@ -1460,7 +1508,7 @@ static void test_tm_nodeinfo_directResponse_psramUnsignedNotServed(void)
|
||||
service = &mockService;
|
||||
|
||||
TrafficManagementModuleTestShim module;
|
||||
// Cache a fresh but unsigned (TOFU) NodeInfo and do NOT mark it signer-proven.
|
||||
// Cache a fresh but unsigned (TOFU) NodeInfo and do NOT mark it key-proven.
|
||||
module.handleReceived(makeNodeInfoPacket(kTargetNode, "target-long", "tg"));
|
||||
|
||||
meshtastic_MeshPacket request = makeDecodedPacket(meshtastic_PortNum_NODEINFO_APP, kRemoteNode, kTargetNode);
|
||||
@@ -1475,11 +1523,6 @@ static void test_tm_nodeinfo_directResponse_psramUnsignedNotServed(void)
|
||||
#endif // TMM_NODEINFO_REPLAY_SIGNED_GATE
|
||||
#endif // !MESHTASTIC_EXCLUDE_PKI
|
||||
|
||||
// Bit positions returned by peekNodeInfoFlagsForTest().
|
||||
constexpr int kFlagObserved = 1;
|
||||
constexpr int kFlagMember = 2;
|
||||
constexpr int kFlagFullUser = 4;
|
||||
|
||||
/**
|
||||
* Key-commit hook (ported from tmm-fix-superset): a TOFU learn lands the key in the pool
|
||||
* without a User payload; manual verification upgrades provenance; a NodeDB-senior rotation
|
||||
@@ -1534,7 +1577,7 @@ static void test_tm_nodeinfo_tickSaturation_sweepClearsObserved(void)
|
||||
|
||||
TrafficManagementModuleTestShim module;
|
||||
module.handleReceived(makeNodeInfoPacket(kTargetNode, "target-long", "tg"));
|
||||
module.markKeySignerProvenForTest(kTargetNode);
|
||||
module.markKeyXeddsaSignedForTest(kTargetNode);
|
||||
const uint32_t stampMs = TrafficManagementModule::s_testNowMs;
|
||||
int flags = module.peekNodeInfoFlagsForTest(kTargetNode);
|
||||
TEST_ASSERT_TRUE(flags >= 0 && (flags & kFlagObserved));
|
||||
@@ -1572,7 +1615,7 @@ static void test_tm_nodeinfo_reconcileMembershipMarking(void)
|
||||
config.device.role = meshtastic_Config_DeviceConfig_Role_CLIENT;
|
||||
mockNodeDB->clearCachedNode();
|
||||
mockNodeDB->warmStore.clear();
|
||||
mockNodeDB->setHotNodeIdentity(kTargetNode, "seeded-name", 0x5C, /*signer=*/false);
|
||||
mockNodeDB->setHotNodeIdentity(kTargetNode, "seeded-name", 0x5C, /*xeddsaSigned=*/false);
|
||||
|
||||
MockRouter mockRouter;
|
||||
mockRouter.addInterface(std::unique_ptr<RadioInterface>(new MockRadioInterface()));
|
||||
@@ -1765,7 +1808,7 @@ static void fillNodeInfoCacheWithTofuStrangers(TrafficManagementModuleTestShim &
|
||||
|
||||
/**
|
||||
* Tiered LRU eviction, tier boundaries: with the cache exactly full, a new stranger's insert
|
||||
* evicts a keyless stranger - never a TOFU-keyed entry, a signer-proven entry, or a NodeDB
|
||||
* evicts a keyless stranger - never a TOFU-keyed entry, a key-proven entry, or a NodeDB
|
||||
* member - even though those higher-tier entries are the OLDEST observations in the cache
|
||||
* (tier outranks recency).
|
||||
*/
|
||||
@@ -1778,7 +1821,7 @@ static void test_tm_nodeinfo_eviction_keyedTiersOutrankKeyless(void)
|
||||
constexpr NodeNum kTofu = 0x51000001, kProven = 0x51000002, kMember = 0x51000003, kNewcomer = 0x51000004;
|
||||
module.handleReceived(makeNodeInfoPacketWithKey(kTofu, "tofu", 0x11));
|
||||
module.handleReceived(makeNodeInfoPacketWithKey(kProven, "proven", 0x22));
|
||||
module.markKeySignerProvenForTest(kProven);
|
||||
module.markKeyXeddsaSignedForTest(kProven);
|
||||
uint8_t memberKey[32];
|
||||
memset(memberKey, 0x33, sizeof(memberKey));
|
||||
module.onNodeKeyCommitted(kMember, memberKey, false);
|
||||
@@ -1801,8 +1844,8 @@ static void test_tm_nodeinfo_eviction_keyedTiersOutrankKeyless(void)
|
||||
|
||||
/**
|
||||
* Tiered LRU eviction, keyed tiers: in a cache saturated with TOFU-keyed strangers, keyed
|
||||
* inserts displace TOFU entries while a signer-proven stranger and a NodeDB member survive
|
||||
* (keyless < TOFU < signer-proven, +membership).
|
||||
* inserts displace TOFU entries while a key-proven stranger and a NodeDB member survive
|
||||
* (keyless < TOFU < key-proven, +membership).
|
||||
*/
|
||||
static void test_tm_nodeinfo_eviction_tofuLosesBeforeProvenAndMember(void)
|
||||
{
|
||||
@@ -1816,7 +1859,7 @@ static void test_tm_nodeinfo_eviction_tofuLosesBeforeProvenAndMember(void)
|
||||
const uint16_t cap = TrafficManagementModuleTestShim::nodeInfoCacheCapacityForTest();
|
||||
fillNodeInfoCacheWithTofuStrangers(module, cap - 2u, kFillBase);
|
||||
module.handleReceived(makeNodeInfoPacketWithKey(kProven, "proven", 0x22));
|
||||
module.markKeySignerProvenForTest(kProven);
|
||||
module.markKeyXeddsaSignedForTest(kProven);
|
||||
uint8_t memberKey[32];
|
||||
memset(memberKey, 0x33, sizeof(memberKey));
|
||||
module.onNodeKeyCommitted(kMember, memberKey, false);
|
||||
@@ -1979,7 +2022,7 @@ static void test_tm_nodeinfo_directResponse_fallbackStaleEntryNotServed(void)
|
||||
|
||||
mockNodeDB->setCachedNode(kTargetNode);
|
||||
mockNodeDB->cachedNodeForTest().last_heard = now - (7UL * 60UL * 60UL); // 7 h ago -> stale
|
||||
// Signer-proven so staleness is the sole reason this is not served (isolates the gate under test).
|
||||
// Key-proven so staleness is the sole reason this is not served (isolates the gate under test).
|
||||
mockNodeDB->cachedNodeForTest().bitfield |= NODEINFO_BITFIELD_HAS_XEDDSA_SIGNED_MASK;
|
||||
|
||||
MockRouter mockRouter;
|
||||
@@ -2044,6 +2087,46 @@ static void test_tm_nodeinfo_directResponse_fallbackFreshEntryServed(void)
|
||||
resetRTCStateForTests();
|
||||
}
|
||||
|
||||
#if TMM_NODEINFO_REPLAY_SIGNED_GATE
|
||||
/**
|
||||
* Replay gate (fallback path): a manually-verified NodeDB node - IS_KEY_MANUALLY_VERIFIED set,
|
||||
* but NOT XEdDSA-signed - passes the broadened key-proven gate and is served, mirroring the
|
||||
* cache path's keyProven() (XEdDSA | manual). Companion to fallbackUnsignedNotServed.
|
||||
*/
|
||||
static void test_tm_nodeinfo_directResponse_fallbackManuallyVerifiedServed(void)
|
||||
{
|
||||
moduleConfig.traffic_management.nodeinfo_direct_response_max_hops = 10;
|
||||
config.device.role = meshtastic_Config_DeviceConfig_Role_CLIENT;
|
||||
|
||||
setBootRelativeTimeForUnitTest(1000000);
|
||||
const uint32_t now = getTime();
|
||||
|
||||
mockNodeDB->setCachedNode(kTargetNode);
|
||||
mockNodeDB->cachedNodeForTest().last_heard = now - 60UL; // fresh, passes staleness
|
||||
// Manually verified, NOT XEdDSA-signed: the key-proven gate must accept the manual channel.
|
||||
mockNodeDB->cachedNodeForTest().bitfield |= NODEINFO_BITFIELD_IS_KEY_MANUALLY_VERIFIED_MASK;
|
||||
|
||||
MockRouter mockRouter;
|
||||
mockRouter.addInterface(std::unique_ptr<RadioInterface>(new MockRadioInterface()));
|
||||
MeshService mockService;
|
||||
router = &mockRouter;
|
||||
service = &mockService;
|
||||
|
||||
TrafficManagementModuleTestShim module;
|
||||
module.dropNodeInfoCacheForTest(); // exercise the NodeDB fallback path
|
||||
meshtastic_MeshPacket request = makeDecodedPacket(meshtastic_PortNum_NODEINFO_APP, kRemoteNode, kTargetNode);
|
||||
request.decoded.want_response = true;
|
||||
request.hop_start = 3;
|
||||
request.hop_limit = 3;
|
||||
|
||||
ProcessMessage result = module.handleReceived(request);
|
||||
TEST_ASSERT_EQUAL_INT(static_cast<int>(ProcessMessage::STOP), static_cast<int>(result));
|
||||
TEST_ASSERT_EQUAL_UINT32(1, static_cast<uint32_t>(mockRouter.sentPackets.size()));
|
||||
|
||||
resetRTCStateForTests();
|
||||
}
|
||||
#endif // TMM_NODEINFO_REPLAY_SIGNED_GATE
|
||||
|
||||
/**
|
||||
* Per-target direct-response throttle on the NodeDB-fallback path (no PSRAM NodeInfo cache). The
|
||||
* per-target RAM table is not the cache, so it throttles this path identically: a burst for a fresh
|
||||
@@ -2129,14 +2212,14 @@ static void test_tm_nodeinfo_directResponse_perRequesterAndGlobalFloor(void)
|
||||
|
||||
TrafficManagementModuleTestShim module;
|
||||
|
||||
// Three distinct, freshly-observed, signer-proven targets. Using a fresh target on each step keeps
|
||||
// Three distinct, freshly-observed, key-proven targets. Using a fresh target on each step keeps
|
||||
// the per-target axis from ever being the bound here, so the reply is gated only by the axis under
|
||||
// test: per-requester (step 2) or the global floor (step 4).
|
||||
constexpr NodeNum kTargetA = 0x33330001, kTargetB = 0x33330002, kTargetC = 0x33330003;
|
||||
constexpr NodeNum kRemoteNode3 = 0x66666666;
|
||||
for (NodeNum t : {kTargetA, kTargetB, kTargetC}) {
|
||||
module.handleReceived(makeNodeInfoPacket(t, "target-long", "tg"));
|
||||
module.markKeySignerProvenForTest(t);
|
||||
module.markKeyXeddsaSignedForTest(t);
|
||||
}
|
||||
|
||||
meshtastic_MeshPacket request = makeDecodedPacket(meshtastic_PortNum_NODEINFO_APP, kRemoteNode, kTargetA);
|
||||
@@ -3221,6 +3304,7 @@ TM_TEST_ENTRY void setup()
|
||||
RUN_TEST(test_tm_nodeinfo_directResponse_perRequesterAndGlobalFloor);
|
||||
#if TMM_NODEINFO_REPLAY_SIGNED_GATE
|
||||
RUN_TEST(test_tm_nodeinfo_directResponse_fallbackUnsignedNotServed);
|
||||
RUN_TEST(test_tm_nodeinfo_directResponse_fallbackManuallyVerifiedServed);
|
||||
#endif
|
||||
#if TMM_HAS_NODEINFO_CACHE
|
||||
RUN_TEST(test_tm_nodeinfo_directResponse_psramCacheRespondsAndPreservesBitfield);
|
||||
@@ -3233,6 +3317,7 @@ TM_TEST_ENTRY void setup()
|
||||
RUN_TEST(test_tm_nodeinfo_cache_pinsAgainstWarmTierKey);
|
||||
RUN_TEST(test_tm_nodeinfo_gate_blocksUnsignedWarmSignerForgery);
|
||||
RUN_TEST(test_tm_nodeinfo_reconcile_seedsFromHotStoreButNeverServes);
|
||||
RUN_TEST(test_tm_nodeinfo_reconcile_seedsManualVerificationFromHotStore);
|
||||
RUN_TEST(test_tm_nodeinfo_reconcile_seedsKeyOnlyFromWarmTier);
|
||||
RUN_TEST(test_tm_nodeinfo_reconcile_keepsTofuKeyOnKeylessHotIdentity);
|
||||
RUN_TEST(test_tm_nodeinfo_updateUserHook_writesThrough);
|
||||
@@ -3240,7 +3325,7 @@ TM_TEST_ENTRY void setup()
|
||||
RUN_TEST(test_tm_nodeinfo_noTimedEviction_quietKeyedEntrySurvives);
|
||||
#endif
|
||||
RUN_TEST(test_tm_nodeinfo_copyPublicKey_servesTofuKey);
|
||||
RUN_TEST(test_tm_nodeinfo_copyPublicKey_upgradesToSignerProven);
|
||||
RUN_TEST(test_tm_nodeinfo_copyPublicKey_upgradesToXeddsaSigned);
|
||||
RUN_TEST(test_tm_nodeinfo_copyPublicKey_missReturnsFalse);
|
||||
RUN_TEST(test_tm_nodeinfo_copyUser_returnsCachedIdentity);
|
||||
#if TMM_NODEINFO_REPLAY_SIGNED_GATE
|
||||
|
||||
@@ -171,19 +171,19 @@ void test_ws_meta_roundTrip()
|
||||
TEST_ASSERT_EQUAL((uint8_t)WarmProtected::None, prot);
|
||||
}
|
||||
|
||||
// The signer flag rides the same packed word as role/protected, so it must survive a
|
||||
// The XEdDSA-signed flag rides the same packed word as role/protected, so it must survive a
|
||||
// round trip without disturbing them (or the quantised timestamp).
|
||||
void test_ws_signer_roundTrip()
|
||||
void test_ws_xeddsaSigned_roundTrip()
|
||||
{
|
||||
WarmNodeStore ws;
|
||||
uint8_t key[32];
|
||||
makeKey(key, 0x78);
|
||||
TEST_ASSERT_TRUE(ws.absorb(0x710, 1234, key, 5 /* TRACKER */, (uint8_t)WarmProtected::Role, /*signer=*/true));
|
||||
TEST_ASSERT_TRUE(ws.absorb(0x711, 1234, key, 5 /* TRACKER */, (uint8_t)WarmProtected::Role, /*signer=*/false));
|
||||
TEST_ASSERT_TRUE(ws.absorb(0x710, 1234, key, 5 /* TRACKER */, (uint8_t)WarmProtected::Role, /*xeddsaSigned=*/true));
|
||||
TEST_ASSERT_TRUE(ws.absorb(0x711, 1234, key, 5 /* TRACKER */, (uint8_t)WarmProtected::Role, /*xeddsaSigned=*/false));
|
||||
|
||||
WarmNodeEntry e;
|
||||
TEST_ASSERT_TRUE(ws.take(0x710, e));
|
||||
TEST_ASSERT_TRUE_MESSAGE(warmSignerOf(e), "signer flag must round trip");
|
||||
TEST_ASSERT_TRUE_MESSAGE(warmXeddsaSignedOf(e), "xeddsa-signed flag must round trip");
|
||||
TEST_ASSERT_EQUAL(5, warmRoleOf(e)); // and must not disturb its neighbours in the word
|
||||
TEST_ASSERT_EQUAL((uint8_t)WarmProtected::Role, warmProtOf(e));
|
||||
TEST_ASSERT_EQUAL(1234u & WARM_TIME_MASK, warmTimeOf(e));
|
||||
@@ -191,7 +191,7 @@ void test_ws_signer_roundTrip()
|
||||
// Control: without the flag the same entry reads back clear, so the accessor is
|
||||
// reporting the stored bit rather than always-true.
|
||||
TEST_ASSERT_TRUE(ws.take(0x711, e));
|
||||
TEST_ASSERT_FALSE(warmSignerOf(e));
|
||||
TEST_ASSERT_FALSE(warmXeddsaSignedOf(e));
|
||||
TEST_ASSERT_EQUAL(5, warmRoleOf(e));
|
||||
TEST_ASSERT_EQUAL((uint8_t)WarmProtected::Role, warmProtOf(e));
|
||||
}
|
||||
@@ -282,15 +282,15 @@ void test_ws_v1_migration_discardsLastHeard()
|
||||
b.saveIfDirty();
|
||||
}
|
||||
|
||||
// A v2 (WRM2) warm.dat used bit 6 as a timestamp bit, so loading one must not read it as a
|
||||
// signer, while role/protected/time carry over. File backend only.
|
||||
void test_ws_v2_migration_clearsSignerBit()
|
||||
// A v2 (WRM2) warm.dat used bit 6 as a timestamp bit, so loading one must not read it as
|
||||
// XEdDSA-signed, while role/protected/time carry over. File backend only.
|
||||
void test_ws_v2_migration_clearsXeddsaSignedBit()
|
||||
{
|
||||
WarmNodeStore a;
|
||||
uint8_t key[32], got[32];
|
||||
makeKey(key, 0x67);
|
||||
// signer=true sets bit 6, standing in for a v2 record whose timestamp had it set.
|
||||
a.absorb(0x910, 123456, key, 5 /* TRACKER */, (uint8_t)WarmProtected::Role, /*signer=*/true);
|
||||
// xeddsaSigned=true sets bit 6, standing in for a v2 record whose timestamp had it set.
|
||||
a.absorb(0x910, 123456, key, 5 /* TRACKER */, (uint8_t)WarmProtected::Role, /*xeddsaSigned=*/true);
|
||||
if (!a.saveIfDirty()) {
|
||||
TEST_IGNORE_MESSAGE("Filesystem not available in this test environment");
|
||||
return;
|
||||
@@ -329,7 +329,7 @@ void test_ws_v2_migration_clearsSignerBit()
|
||||
|
||||
WarmNodeEntry e;
|
||||
TEST_ASSERT_TRUE(b.take(0x910, e));
|
||||
TEST_ASSERT_FALSE_MESSAGE(warmSignerOf(e), "a v2 timestamp bit must not read as a signer");
|
||||
TEST_ASSERT_FALSE_MESSAGE(warmXeddsaSignedOf(e), "a v2 timestamp bit must not read as xeddsa-signed");
|
||||
// Unlike v1, v2 kept role/protected/time in place, so they survive the migration.
|
||||
TEST_ASSERT_EQUAL(123456u & WARM_TIME_MASK, warmTimeOf(e));
|
||||
TEST_ASSERT_EQUAL(5, warmRoleOf(e));
|
||||
@@ -396,11 +396,11 @@ WS_TEST_ENTRY void setup()
|
||||
RUN_TEST(test_ws_keyedCandidate_evictsOldestKeylessFirst);
|
||||
RUN_TEST(test_ws_keyedCandidate_evictsOldestKeyedWhenNoKeyless);
|
||||
RUN_TEST(test_ws_meta_roundTrip);
|
||||
RUN_TEST(test_ws_signer_roundTrip);
|
||||
RUN_TEST(test_ws_xeddsaSigned_roundTrip);
|
||||
RUN_TEST(test_ws_remove_and_clear);
|
||||
RUN_TEST(test_ws_persistence_roundTrip);
|
||||
RUN_TEST(test_ws_v1_migration_discardsLastHeard);
|
||||
RUN_TEST(test_ws_v2_migration_clearsSignerBit);
|
||||
RUN_TEST(test_ws_v2_migration_clearsXeddsaSignedBit);
|
||||
RUN_TEST(test_ws_load_rejectsOversizedSnapshot);
|
||||
exit(UNITY_END());
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user