From 7bdc2569e8f94cf43da8e428180129c170dd642e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thomas=20G=C3=B6ttgens?= Date: Tue, 21 Jul 2026 09:48:09 +0200 Subject: [PATCH] Budget the admin-key PKI decrypt fallback (#11100) --- src/mesh/Router.cpp | 70 +++++++++++++++++++--- test/test_pki_admin_fallback/test_main.cpp | 30 ++++++++++ 2 files changed, 91 insertions(+), 9 deletions(-) diff --git a/src/mesh/Router.cpp b/src/mesh/Router.cpp index 032f0a851..afa0e0293 100644 --- a/src/mesh/Router.cpp +++ b/src/mesh/Router.cpp @@ -553,6 +553,54 @@ bool checkXeddsaReceivePolicy(meshtastic_MeshPacket *p) } #endif +#if !(MESHTASTIC_EXCLUDE_PKI) +// The fallback costs three X25519 ops before the AEAD tag is checked. Budget is global because p->from is +// attacker-controlled; successful runs refund, and their key is then persisted for the fast path. +#define ADMIN_KEY_FALLBACK_BURST 8 +#define ADMIN_KEY_FALLBACK_REFILL_MS 250 + +static uint32_t adminKeyFallbackTokens = ADMIN_KEY_FALLBACK_BURST; +static uint32_t adminKeyFallbackRefillMs = 0; + +static bool adminKeyFallbackAllowed() +{ + bool haveAdminKey = false; + for (int i = 0; i < 3; i++) { + if (config.security.admin_key[i].size == 32) { + haveAdminKey = true; + break; + } + } + if (!haveAdminKey) + return false; // nothing to try, so do not spend a token + + uint32_t now = millis(); + if (adminKeyFallbackRefillMs == 0) + adminKeyFallbackRefillMs = now; + uint32_t elapsed = now - adminKeyFallbackRefillMs; + if (elapsed >= ADMIN_KEY_FALLBACK_REFILL_MS) { + uint32_t refill = elapsed / ADMIN_KEY_FALLBACK_REFILL_MS; + adminKeyFallbackRefillMs += refill * ADMIN_KEY_FALLBACK_REFILL_MS; + if (refill >= ADMIN_KEY_FALLBACK_BURST - adminKeyFallbackTokens) + adminKeyFallbackTokens = ADMIN_KEY_FALLBACK_BURST; + else + adminKeyFallbackTokens += refill; + } + + if (adminKeyFallbackTokens == 0) + return false; + + adminKeyFallbackTokens--; + return true; +} + +static void adminKeyFallbackRefund() +{ + if (adminKeyFallbackTokens < ADMIN_KEY_FALLBACK_BURST) + adminKeyFallbackTokens++; +} +#endif + DecodeState perhapsDecode(meshtastic_MeshPacket *p) { concurrency::LockGuard g(cryptLock); @@ -597,17 +645,21 @@ DecodeState perhapsDecode(meshtastic_MeshPacket *p) decrypted = true; viaPendingKey = havePendingKey; } - for (int i = 0; i < 3 && !decrypted; i++) { - if (config.security.admin_key[i].size != 32) - continue; - remotePublic.size = 32; - memcpy(remotePublic.bytes, config.security.admin_key[i].bytes, 32); + if (!decrypted && adminKeyFallbackAllowed()) { + for (int i = 0; i < 3 && !decrypted; i++) { + if (config.security.admin_key[i].size != 32) + continue; + remotePublic.size = 32; + memcpy(remotePublic.bytes, config.security.admin_key[i].bytes, 32); - if (crypto->decryptCurve25519(p->from, remotePublic, p->id, rawSize, p->encrypted.bytes, bytes)) { - decrypted = true; - viaAdminKey = true; - break; // stop after first successful decryption + if (crypto->decryptCurve25519(p->from, remotePublic, p->id, rawSize, p->encrypted.bytes, bytes)) { + decrypted = true; + viaAdminKey = true; + break; // stop after first successful decryption + } } + if (decrypted) + adminKeyFallbackRefund(); } if (decrypted) { LOG_INFO("PKI Decryption worked!"); diff --git a/test/test_pki_admin_fallback/test_main.cpp b/test/test_pki_admin_fallback/test_main.cpp index 18e5e2260..5c3b408c9 100644 --- a/test/test_pki_admin_fallback/test_main.cpp +++ b/test/test_pki_admin_fallback/test_main.cpp @@ -202,6 +202,35 @@ void test_wrong_admin_key_does_not_decode(void) TEST_ASSERT_NULL(mockNodeDB->getMeshNode(ADMIN_NODE)); } +// The fallback is budget-limited against flooding; see Router.cpp for why the budget is global. +void test_admin_key_fallback_is_rate_limited(void) +{ + // Start from a full bucket regardless of what earlier tests consumed (8 tokens, one per 250ms). + delay(2500); + + uint8_t otherPub[32], otherPriv[32]; + crypto->generateKeyPair(otherPub, otherPriv); + crypto->setDHPrivateKey(ourPriv); + setAdminKey(0, otherPub); // wrong key, so every attempt below fails and keeps its token spent + + // Drain the burst with undecryptable packets, as a flooding attacker would. + for (int i = 0; i < 8; i++) { + meshtastic_MeshPacket junk = makePkiPacket(ADMIN_NODE, meshtastic_PortNum_PRIVATE_APP, 16, adminPriv); + TEST_ASSERT_NOT_EQUAL(DECODE_SUCCESS, perhapsDecode(&junk)); + } + + // Budget exhausted: the fallback is skipped, so even a correct admin key does not decrypt. + setAdminKey(0, adminPub); + meshtastic_MeshPacket blocked = makePkiPacket(ADMIN_NODE, meshtastic_PortNum_PRIVATE_APP, 16, adminPriv); + TEST_ASSERT_NOT_EQUAL_MESSAGE(DECODE_SUCCESS, perhapsDecode(&blocked), "fallback should be budget-limited"); + + // The budget refills, so the throttle is not a permanent lockout. + delay(600); + meshtastic_MeshPacket allowed = makePkiPacket(ADMIN_NODE, meshtastic_PortNum_PRIVATE_APP, 16, adminPriv); + TEST_ASSERT_EQUAL_MESSAGE(DECODE_SUCCESS, perhapsDecode(&allowed), "budget should refill over time"); + assertDecodedAndLearned(&allowed, adminPub); +} + // A pending key is an unverified identity claim from whoever opened a key-verification handshake, so it // must decrypt only the exchange itself. Otherwise they could send DMs that look PKI-authenticated as a // node they never proved they are. @@ -243,6 +272,7 @@ void setup() RUN_TEST(test_admin_key_slot2_only_decrypts); RUN_TEST(test_no_admin_key_unknown_sender_not_decoded); RUN_TEST(test_wrong_admin_key_does_not_decode); + RUN_TEST(test_admin_key_fallback_is_rate_limited); RUN_TEST(test_pending_key_decrypts_only_key_verification); #endif exit(UNITY_END());