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
72 changes: 62 additions & 10 deletions src/mesh/Router.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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 (crypto->decryptCurve25519(p->from, remotePublic, p->id, rawSize, p->encrypted.bytes, bytes)) {
decrypted = true;
viaAdminKey = true;
break; // stop after first successful decryption
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 (decrypted)
adminKeyFallbackRefund();
}
if (decrypted) {
LOG_INFO("PKI Decryption worked!");
Expand Down
30 changes: 30 additions & 0 deletions test/test_pki_admin_fallback/test_main.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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());
Expand Down
Loading