Title
Repeater admin login/telemetry deterministically fails once >8 contacts share a public-key's first byte
Summary
BaseChatMesh::searchPeersByHash() (src/helpers/BaseChatMesh.cpp:201-209) stops scanning the
contact list after collecting MAX_SEARCH_RESULTS (8, BaseChatMesh.h:12) matches on a 1-byte
identity hash. Once a 9th contact shares that byte, it can never be found by this search again,
so any reply from it — including a repeater admin login (RESP_SERVER_LOGIN_OK) or telemetry
response — permanently fails to decrypt. This is a deterministic, silent, per-contact hard
failure, not an intermittent reliability issue: it reproduces 100% of the time for an affected
contact and 0% of the time otherwise, entirely dependent on your own contact list's contents.
Root cause
Identity hashing for message/login delivery is fixed-width, independent of the runtime
hash_mode/path-hash-size setting:
Identity.h:19-21 / Identity.h:27-28 — the parameterless copyHashTo()/isHashMatch()
overloads always use the compile-time PATH_HASH_SIZE constant, #define PATH_HASH_SIZE 1
(MeshCore.h:18) — exactly 1 byte, always.
- This fixed-width overload is what's used for
ANON_REQ (login, Mesh.cpp:528), and
REQ/RESPONSE/TXT_MSG (Mesh.cpp:503-504) dest/src hash construction, and their
corresponding receive-side checks (Mesh.cpp:147, Mesh.cpp:207).
- The separate, runtime-configurable
hash_mode setting (1-3 bytes,
getPathHashSize()/setPathHashSizeAndCount()) only affects the per-hop routing path
array (Mesh.cpp:349), a completely different mechanism despite the confusingly similar name.
Raising hash_mode has no effect on this bug.
On the receiving side of a reply, searchPeersByHash() scans your contact array for other
contacts sharing that 1-byte prefix, trying each one's shared secret to decrypt:
// src/helpers/BaseChatMesh.cpp:201-209
int BaseChatMesh::searchPeersByHash(const uint8_t* hash) {
int n = 0;
for (int i = 0; i < num_contacts && n < MAX_SEARCH_RESULTS; i++) {
if (contacts[i].id.isHashMatch(hash)) {
matching_peer_indexes[n++] = i;
}
}
return n;
}
With MAX_SEARCH_RESULTS fixed at 8 (BaseChatMesh.h:12), the scan bails out the instant it's
collected 8 matches — any further same-prefix contact later in the array (i.e. any contact added
after 8 others sharing its prefix) is never even checked. There's no security reason for this
cap: each candidate is tried against its own real shared secret, and a genuine mismatch simply
fails to authenticate, so scanning further candidates costs nothing but a few extra byte
comparisons.
Why this affects login/telemetry specifically (and looks intermittent-but-isn't)
- Login/telemetry requires a confirmed request→reply round trip — the client must correctly
attribute the reply to the right contact, or the login visibly fails ("connection failed").
- Channel messages and one-way DM sends don't hit this at all in the same visible way: channel
traffic is addressed to a shared channel hash, not a personal identity, and ordinary sends
report "sent" without confirming a round trip — so the same underlying collision could silently
affect DM replies too, just without surfacing as an obvious failure.
Real-world plausibility
Checked against a live regional MeshCore observer network (~3790 nodes total). With only 256
possible first-byte values, that's an average of ~14.8 nodes per byte value network-wide — well
past the cap of 8. Spot-checking one specific node's real first-byte prefix against the same
dataset directly turned up 4 other real nodes sharing it in a single (likely non-exhaustive)
search. A companion with a large contact list (this repo's own companion_radio firmware
supports MAX_CONTACTS up to 350) built up over time via advert auto-discovery can plausibly
accumulate 8+ contacts sharing one specific repeater's prefix, especially in denser mesh regions.
Suggested fix
Not proposing a wire-protocol change (widening the identity hash) here — that's a bigger,
ecosystem-wide compatibility discussion for maintainers to weigh in on.
The immediate bug (early-terminating the search instead of checking the whole contact list) is
fixable with no wire-format impact: size MAX_SEARCH_RESULTS from the actual contact list bound
(MAX_CONTACTS+MAX_ANON_CONTACTS) instead of a fixed 8, so the search can never be capped before
every real candidate has been checked. Cost is a few hundred bytes at most (verified building
companion firmware at both default MAX_CONTACTS=32 and MAX_CONTACTS=350, plus a
RAM-constrained nRF52 target — no meaningful RAM impact). PR incoming.
How to reproduce
- Build a companion contact list with 9+ contacts whose public key shares the same first byte
(achievable organically with auto-add enabled on a dense mesh, or synthetically by adding
known-colliding identities).
- Attempt to log into (or fetch telemetry from) the repeater whose contact entry is the 9th (or
later) added among that colliding set.
- Login/telemetry request will time out / fail every time, with no dependence on radio
conditions, hop count, or retry count.
Title
Repeater admin login/telemetry deterministically fails once >8 contacts share a public-key's first byte
Summary
BaseChatMesh::searchPeersByHash()(src/helpers/BaseChatMesh.cpp:201-209) stops scanning thecontact list after collecting
MAX_SEARCH_RESULTS(8,BaseChatMesh.h:12) matches on a 1-byteidentity hash. Once a 9th contact shares that byte, it can never be found by this search again,
so any reply from it — including a repeater admin login (
RESP_SERVER_LOGIN_OK) or telemetryresponse — permanently fails to decrypt. This is a deterministic, silent, per-contact hard
failure, not an intermittent reliability issue: it reproduces 100% of the time for an affected
contact and 0% of the time otherwise, entirely dependent on your own contact list's contents.
Root cause
Identity hashing for message/login delivery is fixed-width, independent of the runtime
hash_mode/path-hash-size setting:Identity.h:19-21/Identity.h:27-28— the parameterlesscopyHashTo()/isHashMatch()overloads always use the compile-time
PATH_HASH_SIZEconstant,#define PATH_HASH_SIZE 1(
MeshCore.h:18) — exactly 1 byte, always.ANON_REQ(login,Mesh.cpp:528), andREQ/RESPONSE/TXT_MSG(Mesh.cpp:503-504) dest/src hash construction, and theircorresponding receive-side checks (
Mesh.cpp:147,Mesh.cpp:207).hash_modesetting (1-3 bytes,getPathHashSize()/setPathHashSizeAndCount()) only affects the per-hop routing patharray (
Mesh.cpp:349), a completely different mechanism despite the confusingly similar name.Raising
hash_modehas no effect on this bug.On the receiving side of a reply,
searchPeersByHash()scans your contact array for othercontacts sharing that 1-byte prefix, trying each one's shared secret to decrypt:
With
MAX_SEARCH_RESULTSfixed at 8 (BaseChatMesh.h:12), the scan bails out the instant it'scollected 8 matches — any further same-prefix contact later in the array (i.e. any contact added
after 8 others sharing its prefix) is never even checked. There's no security reason for this
cap: each candidate is tried against its own real shared secret, and a genuine mismatch simply
fails to authenticate, so scanning further candidates costs nothing but a few extra byte
comparisons.
Why this affects login/telemetry specifically (and looks intermittent-but-isn't)
attribute the reply to the right contact, or the login visibly fails ("connection failed").
traffic is addressed to a shared channel hash, not a personal identity, and ordinary sends
report "sent" without confirming a round trip — so the same underlying collision could silently
affect DM replies too, just without surfacing as an obvious failure.
Real-world plausibility
Checked against a live regional MeshCore observer network (~3790 nodes total). With only 256
possible first-byte values, that's an average of ~14.8 nodes per byte value network-wide — well
past the cap of 8. Spot-checking one specific node's real first-byte prefix against the same
dataset directly turned up 4 other real nodes sharing it in a single (likely non-exhaustive)
search. A companion with a large contact list (this repo's own
companion_radiofirmwaresupports
MAX_CONTACTSup to 350) built up over time via advert auto-discovery can plausiblyaccumulate 8+ contacts sharing one specific repeater's prefix, especially in denser mesh regions.
Suggested fix
Not proposing a wire-protocol change (widening the identity hash) here — that's a bigger,
ecosystem-wide compatibility discussion for maintainers to weigh in on.
The immediate bug (early-terminating the search instead of checking the whole contact list) is
fixable with no wire-format impact: size
MAX_SEARCH_RESULTSfrom the actual contact list bound(
MAX_CONTACTS+MAX_ANON_CONTACTS) instead of a fixed 8, so the search can never be capped beforeevery real candidate has been checked. Cost is a few hundred bytes at most (verified building
companion firmware at both default
MAX_CONTACTS=32andMAX_CONTACTS=350, plus aRAM-constrained nRF52 target — no meaningful RAM impact). PR incoming.
How to reproduce
(achievable organically with auto-add enabled on a dense mesh, or synthetically by adding
known-colliding identities).
later) added among that colliding set.
conditions, hop count, or retry count.