Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 8 additions & 4 deletions src/mesh/NodeDB.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -1852,7 +1852,8 @@ void NodeDB::cleanupMeshDB()
// Keep any key we learned (e.g. via a DM before the NodeInfo
// exchange completed) rather than losing it with the purge.
if (n.public_key.size == 32)
warmStore.absorb(gone, n.last_heard, n.public_key.bytes, n.role, warmProtectedCategory(n));
warmStore.absorb(gone, n.last_heard, n.public_key.bytes, n.role, warmProtectedCategory(n),
nodeInfoLiteHasXeddsaSigned(&n));
#endif

eraseNodeSatellites(gone);
Expand Down Expand Up @@ -2036,7 +2037,7 @@ void NodeDB::demoteOldestHotNodesToWarm()
// Keep the public key if we have one (40 B warm record); keyless nodes
// still get a placeholder so re-admission restores last_heard.
warmStore.absorb(n.num, n.last_heard, n.public_key.size > 0 ? n.public_key.bytes : nullptr, n.role,
warmProtectedCategory(n));
warmProtectedCategory(n), nodeInfoLiteHasXeddsaSigned(&n));
// Demotion drops the node from the header table, so drop its satellites
// too (the eviction chokepoint) - they'd otherwise orphan until the next
// enforceSatelliteCaps pass.
Expand Down Expand Up @@ -3728,7 +3729,7 @@ meshtastic_NodeInfoLite *NodeDB::getOrCreateMeshNode(NodeNum n)
// Demote to the warm tier so the identity (and crucially the
// PKI key) outlives the hot-store slot.
warmStore.absorb(evicted.num, evicted.last_heard, evicted.public_key.size == 32 ? evicted.public_key.bytes : NULL,
evicted.role, warmProtectedCategory(evicted));
evicted.role, warmProtectedCategory(evicted), nodeInfoLiteHasXeddsaSigned(&evicted));
#endif
eraseNodeSatellites(evicted.num);
// Shove the remaining nodes down the chain
Expand Down Expand Up @@ -3757,10 +3758,13 @@ meshtastic_NodeInfoLite *NodeDB::getOrCreateMeshNode(NodeNum n)
// Re-admission: restore what the warm tier kept for this node
WarmNodeEntry warm;
if (warmStore.take(n, warm)) {
lite->last_heard = warmTimeOf(warm); // mask off the stolen role/protected metadata bits
lite->last_heard = warmTimeOf(warm); // mask off the stolen metadata bits
// 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
// 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));
if (!memfll(warm.public_key, 0, sizeof(warm.public_key))) {
lite->public_key.size = 32;
memcpy(lite->public_key.bytes, warm.public_key, 32);
Expand Down
96 changes: 57 additions & 39 deletions src/mesh/WarmNodeStore.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -13,12 +13,11 @@

#if defined(NRF52840_XXAA)
#include "flash/flash_nrf5x.h"
#define WARM_RING_MAGIC 0x324E5257u // "WRN2" - v2: last_heard low bits carry role + protected category
#define WARM_RING_MAGIC 0x334E5257u // "WRN3" - v3: last_heard low bits carry role + protected + signer
#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.
// v1 pages are still read on upgrade: we keep each record's identity + public key but
// DISCARD its last_heard (the old timestamp would be misread as role/protected bits).
// Records re-rank and re-learn their role on the next contact. Legacy pages convert to
// v2 naturally as the ring rotates.
// Older pages are still read on upgrade: v1 keeps identity + key but discards last_heard,
// v2 keeps the word but clears bit 6, which was still part of the timestamp then.
// A tombstone is an entry record whose last_heard is all-ones - getTime()
// (unix seconds) cannot reach 0xFFFFFFFF until 2106, and erased flash is
// detected via num == 0xFFFFFFFF before last_heard is ever inspected.
Expand All @@ -34,10 +33,13 @@ struct WarmStoreHeader {
};
static_assert(sizeof(WarmStoreHeader) == 16, "header layout is part of the persistence format");

#define WARM_STORE_MAGIC 0x324D5257u // "WRM2" - v2: last_heard low bits carry role + protected category
#define WARM_STORE_MAGIC 0x334D5257u // "WRM3" - v3: last_heard low bits carry role + protected + signer
#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.
#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 v2.
// identity + key but discard last_heard, then rewrite as v3.

#ifdef FSCom
static const char *warmFileName = "/prefs/warm.dat";
Expand Down Expand Up @@ -134,18 +136,18 @@ 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 WarmNodeStore::absorb(NodeNum num, uint32_t lastHeard, const uint8_t *key32, uint8_t role, uint8_t protectedCat, bool signer)
{
// Pack role + protected category 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);
// Pack role + protected category + signer 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 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 (now %u/%u)", (unsigned)num,
LOG_MIGRATION("WarmStore absorb 0x%08x key=%d last_heard=%u role=%u prot=%u signer=%u (now %u/%u)", (unsigned)num,
keyIsSet(slot->public_key) ? 1 : 0, (unsigned)warmTimeOf(*slot), (unsigned)role, (unsigned)protectedCat,
(unsigned)count(), (unsigned)capacity());
signer ? 1u : 0u, (unsigned)count(), (unsigned)capacity());
return true;
}

Expand Down Expand Up @@ -258,19 +260,24 @@ bool WarmNodeStore::saveIfDirty()
// (stranded live entries re-appended, then erased). Flash access holds spiLock -
// the page cache is shared with InternalFS/LittleFS.

bool WarmNodeStore::ringReadHeader(uint8_t page, WarmPageHeader &h, bool *legacy) const
bool WarmNodeStore::ringReadHeader(uint8_t page, WarmPageHeader &h, WarmFormat *fmt) const
{
flash_nrf5x_read(&h, WARM_FLASH_PAGE_ADDR(page), sizeof(h));
if (h.seq == 0xFFFFFFFFu)
return false; // erased page
if (h.magic == WARM_RING_MAGIC) {
if (legacy)
*legacy = false;
if (fmt)
*fmt = WarmFormat::Current;
return true;
}
if (h.magic == WARM_RING_MAGIC_V2) {
if (fmt)
*fmt = WarmFormat::V2; // replay it, but clear the signer bit (see WARM_RING_MAGIC_V2)
return true;
}
if (h.magic == WARM_RING_MAGIC_V1) {
if (legacy)
*legacy = true; // v1 page: replay it, but discard last_heard (see WARM_RING_MAGIC_V1)
if (fmt)
*fmt = WarmFormat::V1; // replay it, but discard last_heard (see WARM_RING_MAGIC_V1)
return true;
}
return false;
Expand Down Expand Up @@ -386,21 +393,21 @@ void WarmNodeStore::load()
// Order valid pages by ascending seq so replay applies oldest first
uint8_t order[WARM_FLASH_PAGES] = {};
uint32_t seqs[WARM_FLASH_PAGES] = {};
bool legacyOf[WARM_FLASH_PAGES] = {}; // per-page: v1 (WRNG) → discard last_heard on replay
WarmFormat fmtOf[WARM_FLASH_PAGES] = {}; // per-page on-flash format; older ones are normalised on replay
uint8_t nValid = 0;
uint8_t nCorrupt = 0;
for (uint8_t p = 0; p < WARM_FLASH_PAGES; p++) {
WarmPageHeader h;
bool legacy = false;
if (!ringReadHeader(p, h, &legacy)) {
WarmFormat fmt = WarmFormat::Current;
if (!ringReadHeader(p, h, &fmt)) {
// An erased page reads back all-ones; any other magic is a
// partially-written or bit-rotted header we're dropping, so flag it
// rather than silently treating the loss as a clean empty ring.
if (h.magic != 0xFFFFFFFFu)
nCorrupt++;
continue;
}
legacyOf[p] = legacy;
fmtOf[p] = fmt;
uint8_t pos = nValid;
while (pos > 0 && static_cast<int32_t>(h.seq - seqs[pos - 1]) < 0) {
order[pos] = order[pos - 1];
Expand All @@ -427,7 +434,7 @@ void WarmNodeStore::load()
uint32_t migrated = 0;
for (uint8_t k = 0; k < nValid; k++) {
const uint8_t p = order[k];
const bool legacy = legacyOf[p];
const WarmFormat fmt = fmtOf[p];
uint16_t slot = 0;
for (; slot < kRecordsPerPage; slot++) {
WarmNodeEntry rec;
Expand All @@ -444,12 +451,15 @@ void WarmNodeStore::load()
memset(e, 0, sizeof(*e));
}
} else {
// v1 (legacy) record: keep identity + key, but discard the old timestamp -
// its low bits would otherwise be misread as role/protected metadata.
// Normalise older records: v1's timestamp would be misread as role/protected,
// and v2's bit 6 as a signer we never verified.
uint32_t lh = rec.last_heard;
if (legacy) {
if (fmt == WarmFormat::V1) {
lh = 0;
migrated++;
} else if (fmt == WarmFormat::V2) {
lh &= ~(WARM_SIGNER_MASK << WARM_SIGNER_SHIFT);
migrated++;
}
const WarmNodeEntry *e = place(rec.num, lh, rec.public_key);
if (e)
Expand All @@ -460,17 +470,16 @@ void WarmNodeStore::load()
activePage = p;
writeSlot = slot;
nextSeq = seqs[k] + 1;
// If the head is a v1 page, force the next append to rotate into a fresh v2 page,
// so new (v2) records never land in a page whose header says v1 (which would make
// a later load discard their last_heard - including the role/protected we just set).
if (legacy)
// Rotate rather than append into an older-format page: a later load would
// normalise the new records to that page's format and drop metadata.
if (fmt != WarmFormat::Current)
writeSlot = kRecordsPerPage;
}
}
if (nCorrupt)
LOG_WARN("WarmStore: dropped %u corrupt ring page(s), some nodes lost", nCorrupt);
if (migrated)
LOG_INFO("WarmStore: migrated %u v1 record(s) (kept key, discarded last_heard)", (unsigned)migrated);
LOG_INFO("WarmStore: migrated %u legacy record(s) to the current format", (unsigned)migrated);
LOG_INFO("WarmStore: replayed %u ring records -> %u live nodes (page %u, slot %u)", (unsigned)replayed, (unsigned)count(),
activePage, writeSlot);
}
Expand Down Expand Up @@ -537,10 +546,14 @@ void WarmNodeStore::load()
LOG_WARN("WarmStore: %s header read failed, starting empty", warmFileName);
return;
}
// v1 (WRM1) is still accepted: same record size, but its last_heard was a plain
// timestamp. We keep identity + key and discard last_heard on load (see below).
const bool legacy = (h.magic == WARM_STORE_MAGIC_V1);
if ((h.magic != WARM_STORE_MAGIC && !legacy) || h.entrySize != sizeof(WarmNodeEntry) || h.count > WARM_NODE_COUNT) {
// Older snapshots are still accepted: same record size, fewer metadata bits in
// last_heard. Both are normalised to the current format below.
const bool known = h.magic == WARM_STORE_MAGIC || h.magic == WARM_STORE_MAGIC_V2 || h.magic == WARM_STORE_MAGIC_V1;
const WarmFormat fmt = h.magic == WARM_STORE_MAGIC_V1 ? WarmFormat::V1
: h.magic == WARM_STORE_MAGIC_V2 ? WarmFormat::V2
: WarmFormat::Current;
const bool legacy = fmt != WarmFormat::Current;
if (!known || h.entrySize != sizeof(WarmNodeEntry) || h.count > WARM_NODE_COUNT) {
f.close();
LOG_WARN("WarmStore: %s header invalid (magic=0x%08x entrySize=%u count=%u), starting empty", warmFileName, h.magic,
h.entrySize, h.count);
Expand All @@ -560,19 +573,24 @@ void WarmNodeStore::load()
memset(entries, 0, WARM_NODE_COUNT * sizeof(WarmNodeEntry));
return;
}
if (legacy) {
// Migrate v1 → v2: discard the old last_heard (its low bits would be misread as
// role/protected); keep num + public_key. Mark dirty so save() rewrites as v2.
// Normalise older records, then mark dirty so save() rewrites in the current format:
// v1's timestamp would be misread as role/protected, v2's bit 6 as an unverified signer.
if (fmt == WarmFormat::V1) {
for (size_t i = 0; i < WARM_NODE_COUNT; i++)
if (entries[i].num)
entries[i].last_heard = 0;
dirty = true;
} 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);
dirty = true;
}
} else {
f.close();
}
LOG_INFO("WarmStore: loaded %u warm nodes from %s%s", h.count, warmFileName,
legacy ? " (v1 migrated: discarded last_heard)" : "");
legacy ? " (migrated from an older format)" : "");
}

bool WarmNodeStore::save()
Expand Down
32 changes: 22 additions & 10 deletions src/mesh/WarmNodeStore.h
Original file line number Diff line number Diff line change
Expand Up @@ -43,27 +43,33 @@ 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, 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.
// the evicted node's device role, a protected category + a signer 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 = 6; // role(4) + protected(2)
static constexpr uint32_t WARM_META_MASK = (1u << WARM_META_BITS) - 1; // 0x3F64 s quantum
static constexpr uint32_t WARM_TIME_MASK = ~WARM_META_MASK; // 0xFFFFFFC0
static constexpr uint32_t WARM_META_BITS = 7; // role(4) + protected(2) + signer(1)
static constexpr uint32_t WARM_META_MASK = (1u << WARM_META_BITS) - 1; // 0x7F128 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;

// On-disk record format, from the page/file magic; older ones are normalised by load().
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 };

inline uint32_t warmPackLastHeard(uint32_t lastHeard, uint8_t role, uint8_t prot)
inline uint32_t warmPackLastHeard(uint32_t lastHeard, uint8_t role, uint8_t prot, bool signer)
{
return (lastHeard & WARM_TIME_MASK) | (static_cast<uint32_t>(role) & WARM_ROLE_MASK) |
((static_cast<uint32_t>(prot) & WARM_PROT_MASK) << WARM_PROT_SHIFT);
((static_cast<uint32_t>(prot) & WARM_PROT_MASK) << WARM_PROT_SHIFT) |
((signer ? WARM_SIGNER_MASK : 0u) << WARM_SIGNER_SHIFT);
}
inline uint32_t warmTimeOf(const WarmNodeEntry &e)
{
Expand All @@ -77,6 +83,10 @@ 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)
{
return ((e.last_heard >> WARM_SIGNER_SHIFT) & WARM_SIGNER_MASK) != 0;
}
Comment thread
caveman99 marked this conversation as resolved.

// Gated on NRF52840_XXAA: the ring sits at 0xEA000
// valid only on the 1 MB-flash nRF52840.
Expand All @@ -99,9 +109,11 @@ 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
/// 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);
uint8_t protectedCat = 0, bool signer = false);

/// Look up the cached device role + protected category for a warm node.
/// @return false if the node is not in the warm tier.
Expand Down Expand Up @@ -167,7 +179,7 @@ class WarmNodeStore
void ringAppend(const WarmNodeEntry &rec, int storeSlot /* -1 for tombstones */);
void ringRotate(); // reclaim oldest page, compacting stranded live entries
void ringOpenPage(uint8_t page); // erase + write header (seq = nextSeq++)
bool ringReadHeader(uint8_t page, WarmPageHeader &h, bool *legacy = nullptr) const;
bool ringReadHeader(uint8_t page, WarmPageHeader &h, WarmFormat *fmt = nullptr) const;
#endif

bool save();
Expand Down
Loading
Loading