crypto: mitigate login plaintext-password vulnerability (server-side)

Tracks upstream meshcore-dev/MeshCore#2556 — passwords sent as
plaintext over encrypted links are vulnerable to evil-twin/phishing
attacks (attacker advertises a repeater with the same name but a
different pubkey; user picks the wrong one and types the password).

The structural fix is a protocol change (PAKE adoption — SPAKE2,
OPAQUE, or HMAC-with-both-pubkeys) and must land synchronously
across all implementations. Diverging unilaterally would break
interop with Arduino-based companions and repeaters, so we wait
for upstream.

Two within-protocol mitigations applied server-side:

1. Constant-time password comparison.  Replaced strcmp() in
   RepeaterMesh::handleLoginReq with a local ct_memeq() helper.
   Pads the received password to the full 16-byte storage size,
   XOR-accumulates byte differences with no early exit. Compares
   both admin and guest passwords unconditionally so timing is
   identical regardless of which (if any) the attempt resembled.
   Eliminates the timing oracle that lets an already-MITM
   attacker recover the stored password byte-by-byte.

2. Failed-login rate limit.  New login_fail_limiter(4, 180)
   RateLimiter — 4 wrong-password attempts per 180s, matching the
   existing anon_limiter pattern. Hitting the cap trips a distinct
   LOG_WRN so operators see active brute-force attempts in logs.
   Global rate (not per-sender) — simpler, no ACL state bloat;
   trade-off documented in CRYPTO_AUDIT_INDEX.md.

What's NOT fixed: the wire protocol still carries plaintext
passwords. The evil-twin attack itself remains possible; these
mitigations raise the attacker's cost (no timing leak, no
brute-force at line rate) but don't replace the structural fix.
UI-side defenses (TOFU warnings on duplicate names, pubkey
fingerprint display) are valuable companion-side mitigations
but out of scope for this audit's server-side commit.
This commit is contained in:
liquidraver
2026-05-28 09:49:28 +02:00
parent 515f3610e1
commit 8d138c41b3
2 changed files with 54 additions and 5 deletions
+53 -4
View File
@@ -118,6 +118,24 @@ void RepeaterMesh::putNeighbour(const mesh::Identity& id, uint32_t timestamp, fl
#endif
}
/* Constant-time byte-equality over a fixed length. Returns true iff every
* byte of `a` matches `b`. No early exit — timing is independent of input,
* defeating timing-leak attacks against password comparison.
*
* Mitigation for the password-handling weakness tracked upstream as
* meshcore-dev/MeshCore#2556. The protocol still sends plaintext passwords
* (encrypted only at the mesh layer to whatever pubkey the user selected),
* but this fix removes the timing oracle that would otherwise let an
* already-MITM attacker reveal the stored password byte-by-byte. */
static bool ct_memeq(const uint8_t *a, const uint8_t *b, size_t n)
{
uint8_t diff = 0;
for (size_t i = 0; i < n; i++) {
diff |= (uint8_t)(a[i] ^ b[i]);
}
return diff == 0;
}
uint8_t RepeaterMesh::handleLoginReq(const mesh::Identity& sender, const uint8_t* secret, uint32_t sender_timestamp, const uint8_t* data, bool is_flood) {
ClientInfo* client = nullptr;
@@ -127,12 +145,37 @@ uint8_t RepeaterMesh::handleLoginReq(const mesh::Identity& sender, const uint8_t
if (client == nullptr) {
uint8_t perms;
if (strcmp((char*)data, _prefs.password) == 0) {
/* Constant-time comparison: pad the received password to the full
* 16-byte storage size with zeros, then compare against both
* stored passwords (which are already zero-padded by initNodePrefs).
* Compare both unconditionally so timing is identical for any
* wrong password regardless of which (admin/guest) it most
* resembles. */
uint8_t received[sizeof(_prefs.password)] = {0};
size_t r_len = strnlen((const char *)data, sizeof(received) - 1);
memcpy(received, data, r_len);
bool admin_match = ct_memeq(received,
(const uint8_t *)_prefs.password,
sizeof(received));
bool guest_match = ct_memeq(received,
(const uint8_t *)_prefs.guest_password,
sizeof(received));
if (admin_match) {
perms = PERM_ACL_ADMIN;
} else if (strcmp((char*)data, _prefs.guest_password) == 0) {
} else if (guest_match) {
perms = PERM_ACL_GUEST;
} else {
LOG_WRN("Invalid password");
/* Apply global failed-login rate limit. The check itself is
* unconditional regardless of admin/guest path so its timing
* doesn't leak which credential the attempt was closer to. */
if (!login_fail_limiter.allow(getRTCClock()->getCurrentTime())) {
LOG_WRN("Login rate-limited (failed attempts exceeded)");
} else {
LOG_WRN("Invalid password");
}
return 0;
}
@@ -857,7 +900,13 @@ RepeaterMesh::RepeaterMesh(mesh::MainBoard& board, mesh::Radio& radio, mesh::Mil
_cli(board, rtc, acl, &_prefs, this),
region_map(key_store), temp_map(key_store),
discover_limiter(4, 120),
anon_limiter(4, 180) {
anon_limiter(4, 180),
/* Failed-login rate limit: 4 wrong-password attempts per 180s. Matches
* anon_limiter's shape so legitimate operators don't notice; brute-force
* attempts hit the cap quickly and trip the LOG_WRN below. Global rate
* (not per-sender) — trade-off documented in CRYPTO_AUDIT_INDEX.md
* Phase 4 (mitigation for upstream MeshCore#2556). */
login_fail_limiter(4, 180) {
_store = nullptr;
last_millis = 0;
+1 -1
View File
@@ -95,7 +95,7 @@ class RepeaterMesh : public mesh::Mesh, public CommonCLICallbacks {
RegionEntry* load_stack[8];
RegionEntry* recv_pkt_region;
TransportKey default_scope;
RateLimiter discover_limiter, anon_limiter;
RateLimiter discover_limiter, anon_limiter, login_fail_limiter;
uint32_t pending_discover_tag;
unsigned long pending_discover_until;
bool region_load_active;