Skip to content

Repeater admin login/telemetry deterministically fails once >8 contacts share a public-key's first byte #3242

Description

@smellyspice

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

  1. 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).
  2. Attempt to log into (or fetch telemetry from) the repeater whose contact entry is the 9th (or
    later) added among that colliding set.
  3. Login/telemetry request will time out / fail every time, with no dependence on radio
    conditions, hop count, or retry count.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions