diff --git a/docs/node_info_stores.md b/docs/node_info_stores.md index 71266d2a17c..4905b72c8bd 100644 --- a/docs/node_info_stores.md +++ b/docs/node_info_stores.md @@ -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 diff --git a/docs/traffic_management_module.md b/docs/traffic_management_module.md index 99305009dd8..cf4f9538e04 100644 --- a/docs/traffic_management_module.md +++ b/docs/traffic_management_module.md @@ -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_ diff --git a/src/mesh/NodeDB.cpp b/src/mesh/NodeDB.cpp index f1a32b4b9c4..9988b16e6a5 100644 --- a/src/mesh/NodeDB.cpp +++ b/src/mesh/NodeDB.cpp @@ -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(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 && diff --git a/src/mesh/NodeDB.h b/src/mesh/NodeDB.h index 152fdee1d47..23b9be99db4 100644 --- a/src/mesh/NodeDB.h +++ b/src/mesh/NodeDB.h @@ -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 diff --git a/src/mesh/Router.cpp b/src/mesh/Router.cpp index 38048fddbe1..4b2c426938c 100644 --- a/src/mesh/Router.cpp +++ b/src/mesh/Router.cpp @@ -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); diff --git a/src/mesh/WarmNodeStore.cpp b/src/mesh/WarmNodeStore.cpp index d36cd67dbd0..d8d9dcf296d 100644 --- a/src/mesh/WarmNodeStore.cpp +++ b/src/mesh/WarmNodeStore.cpp @@ -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 { diff --git a/src/mesh/WarmNodeStore.h b/src/mesh/WarmNodeStore.h index 26c6f3f0c53..c7d116891bf 100644 --- a/src/mesh/WarmNodeStore.h +++ b/src/mesh/WarmNodeStore.h @@ -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(role) & WARM_ROLE_MASK) | ((static_cast(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((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); diff --git a/src/modules/TrafficManagementModule.cpp b/src/modules/TrafficManagementModule.cpp index e75273b785b..50cc81e3fad 100644 --- a/src/modules/TrafficManagementModule.cpp +++ b/src/modules/TrafficManagementModule.cpp @@ -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(((entry.user.public_key.size != 32) ? 0 : (entry.keySignerProven ? 2 : 1)) + + const uint8_t tier = static_cast(((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 diff --git a/src/modules/TrafficManagementModule.h b/src/modules/TrafficManagementModule.h index 83d5ff60e9e..e01cdefdb96 100644 --- a/src/modules/TrafficManagementModule.h +++ b/src/modules/TrafficManagementModule.h @@ -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. diff --git a/test/test_traffic_management/test_main.cpp b/test/test_traffic_management/test_main.cpp index 34f5a545601..0395f583095 100644 --- a/test/test_traffic_management/test_main.cpp +++ b/test/test_traffic_management/test_main.cpp @@ -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(ProcessMessage::CONTINUE), static_cast(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(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(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(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(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(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(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(ProcessMessage::STOP), static_cast(result)); + TEST_ASSERT_EQUAL_UINT32(1, static_cast(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 diff --git a/test/test_warm_store/test_main.cpp b/test/test_warm_store/test_main.cpp index 389ddc56027..bc2ae27e591 100644 --- a/test/test_warm_store/test_main.cpp +++ b/test/test_warm_store/test_main.cpp @@ -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()); }