diff --git a/src/graphics/Screen.cpp b/src/graphics/Screen.cpp index 8aebfec6e..024fa409f 100644 --- a/src/graphics/Screen.cpp +++ b/src/graphics/Screen.cpp @@ -343,7 +343,7 @@ void Screen::showNodePicker(const char *message, uint32_t durationMs, std::funct } // Called to trigger a banner with custom message and duration -void Screen::showNumberPicker(const char *message, uint32_t durationMs, uint8_t digits, +void Screen::showNumberPicker(const char *message, uint32_t durationMs, uint8_t digits, bool useBase16, std::function bannerCallback) { #ifdef USE_EINK @@ -356,7 +356,10 @@ void Screen::showNumberPicker(const char *message, uint32_t durationMs, uint8_t NotificationRenderer::alertBannerCallback = bannerCallback; NotificationRenderer::pauseBanner = false; NotificationRenderer::curSelected = 0; - NotificationRenderer::current_notification_type = notificationTypeEnum::number_picker; + if (useBase16) + NotificationRenderer::current_notification_type = notificationTypeEnum::hex_picker; + else + NotificationRenderer::current_notification_type = notificationTypeEnum::number_picker; NotificationRenderer::numDigits = digits; NotificationRenderer::currentNumber = 0; diff --git a/src/graphics/Screen.h b/src/graphics/Screen.h index 424b162b4..d72826996 100644 --- a/src/graphics/Screen.h +++ b/src/graphics/Screen.h @@ -344,7 +344,8 @@ class Screen : public concurrency::OSThread void showOverlayBanner(BannerOverlayOptions); void showNodePicker(const char *message, uint32_t durationMs, std::function bannerCallback); - void showNumberPicker(const char *message, uint32_t durationMs, uint8_t digits, std::function bannerCallback); + void showNumberPicker(const char *message, uint32_t durationMs, uint8_t digits, bool useBase16, + std::function bannerCallback); void showTextInput(const char *header, const char *initialText, uint32_t durationMs, std::function textCallback); diff --git a/src/graphics/draw/MenuHandler.cpp b/src/graphics/draw/MenuHandler.cpp index 23e0c7b5f..e6f1fa10e 100644 --- a/src/graphics/draw/MenuHandler.cpp +++ b/src/graphics/draw/MenuHandler.cpp @@ -2323,8 +2323,10 @@ void menuHandler::testMenu() void menuHandler::numberTest() { - screen->showNumberPicker("Pick a number\n ", 30000, 4, - [](int number_picked) -> void { LOG_WARN("Nodenum: %u", number_picked); }); + screen->showNumberPicker("Verify Nodenum:\n ", 30000, 8, true, [](uint32_t number_picked) -> void { + LOG_DEBUG("Nodenum: 0x%08x", number_picked); + keyVerificationModule->sendInitialRequest(number_picked); + }); } void menuHandler::wifiBaseMenu() @@ -2508,8 +2510,7 @@ void menuHandler::keyVerificationFinalPrompt() options.notificationType = graphics::notificationTypeEnum::selection_picker; options.bannerCallback = [=](int selected) { if (selected == 1) { - auto remoteNodePtr = nodeDB->getMeshNode(keyVerificationModule->getCurrentRemoteNode()); - remoteNodePtr->bitfield |= NODEINFO_BITFIELD_IS_KEY_MANUALLY_VERIFIED_MASK; + keyVerificationModule->commitVerifiedRemoteNode(); } }; screen->showOverlayBanner(options); diff --git a/src/mesh/CryptoEngine.cpp b/src/mesh/CryptoEngine.cpp index 73ccb2166..e28df442b 100644 --- a/src/mesh/CryptoEngine.cpp +++ b/src/mesh/CryptoEngine.cpp @@ -341,6 +341,32 @@ bool CryptoEngine::setDHPublicKey(uint8_t *pubKey) return true; } +void CryptoEngine::setPendingPublicKey(uint32_t node, const uint8_t *key) +{ + concurrency::LockGuard g(&pendingKeyLock); + pendingKeyVerificationNode = node; + memcpy(pendingKeyVerificationPublicKey, key, 32); + hasPendingKeyVerificationKey = true; +} + +void CryptoEngine::clearPendingPublicKey() +{ + concurrency::LockGuard g(&pendingKeyLock); + pendingKeyVerificationNode = 0; + memset(pendingKeyVerificationPublicKey, 0, 32); + hasPendingKeyVerificationKey = false; +} + +bool CryptoEngine::getPendingPublicKey(uint32_t node, meshtastic_NodeInfoLite_public_key_t &out) +{ + concurrency::LockGuard g(&pendingKeyLock); + if (!hasPendingKeyVerificationKey || node == 0 || node != pendingKeyVerificationNode) + return false; + out.size = 32; + memcpy(out.bytes, pendingKeyVerificationPublicKey, 32); + return true; +} + #endif concurrency::Lock *cryptLock; diff --git a/src/mesh/CryptoEngine.h b/src/mesh/CryptoEngine.h index 2b0090d5c..95c7eb8ec 100644 --- a/src/mesh/CryptoEngine.h +++ b/src/mesh/CryptoEngine.h @@ -59,6 +59,17 @@ class CryptoEngine virtual bool setDHPublicKey(uint8_t *publicKey); virtual void hash(uint8_t *bytes, size_t numBytes); + // Temporary holder for a peer's not-yet-verified public key, learned in-band during an + // in-progress key-verification handshake before it is committed to NodeDB. Lets the Router + // run the DH handshake to encode/decode the follow-on PKI packet. Single slot is enough: + // only one verification runs at a time. Discarded when the handshake ends (resetToIdle). + // Internally guarded by pendingKeyLock, not cryptLock: the Router calls the getter while + // already holding the non-recursive cryptLock; KeyVerificationModule writes from elsewhere. + void setPendingPublicKey(uint32_t node, const uint8_t *key); + void clearPendingPublicKey(); + // Fills `out` (size set to 32) and returns true iff a pending key is held for `node`. + bool getPendingPublicKey(uint32_t node, meshtastic_NodeInfoLite_public_key_t &out); + virtual void aesSetKey(const uint8_t *key, size_t key_len); virtual void aesEncrypt(uint8_t *in, uint8_t *out); @@ -94,6 +105,10 @@ class CryptoEngine #if !(MESHTASTIC_EXCLUDE_PKI) uint8_t shared_key[32] = {0}; uint8_t private_key[32] = {0}; + uint32_t pendingKeyVerificationNode = 0; + uint8_t pendingKeyVerificationPublicKey[32] = {0}; + bool hasPendingKeyVerificationKey = false; + concurrency::Lock pendingKeyLock; #if !(MESHTASTIC_EXCLUDE_XEDDSA) uint8_t xeddsa_public_key[32] = {0}; uint8_t xeddsa_private_key[32] = {0}; diff --git a/src/mesh/Router.cpp b/src/mesh/Router.cpp index 0641cbc3c..24f7f586b 100644 --- a/src/mesh/Router.cpp +++ b/src/mesh/Router.cpp @@ -524,36 +524,60 @@ DecodeState perhapsDecode(meshtastic_MeshPacket *p) bool decrypted = false; ChannelIndex chIndex = 0; #if !(MESHTASTIC_EXCLUDE_PKI) - // Attempt PKI decryption first. The sender's key may come from the hot - // store or the warm tier (nodes evicted from the hot store keep their key - // there), so DMs from long-tail nodes still decrypt. - meshtastic_NodeInfoLite_public_key_t fromKey = {0, {0}}; - if (p->channel == 0 && isToUs(p) && p->to > 0 && !isBroadcast(p->to) && nodeDB->copyPublicKey(p->from, fromKey) && - nodeDB->getMeshNode(p->to) != nullptr && nodeDB->getMeshNode(p->to)->public_key.size > 0 && - rawSize > MESHTASTIC_PKC_OVERHEAD) { - LOG_DEBUG("Attempt PKI decryption"); + // Resolve the sender's public key: prefer the one stored in NodeDB (hot store or warm tier), else + // fall back to a not-yet-committed key held during an in-progress key-verification handshake. + meshtastic_NodeInfoLite_public_key_t remotePublic = {0, {0}}; + bool haveRemoteKey = nodeDB->copyPublicKey(p->from, remotePublic) || crypto->getPendingPublicKey(p->from, remotePublic); - if (crypto->decryptCurve25519(p->from, fromKey, p->id, rawSize, p->encrypted.bytes, bytes)) { + meshtastic_NodeInfoLite *ourNode = nullptr; + if (p->channel == 0 && isToUs(p) && p->to > 0 && !isBroadcast(p->to) && rawSize > MESHTASTIC_PKC_OVERHEAD && + (ourNode = nodeDB->getMeshNode(p->to)) != nullptr && ourNode->public_key.size > 0) { + // Try the sender's known key first, then each configured admin key so an authorized admin can + // reach a node that has not yet learned their key. AES-CCM AEAD rejects wrong candidates. + bool viaAdminKey = false; + if (haveRemoteKey && crypto->decryptCurve25519(p->from, remotePublic, p->id, rawSize, p->encrypted.bytes, bytes)) { + decrypted = true; + } + 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) { LOG_INFO("PKI Decryption worked!"); - meshtastic_Data decodedtmp; memset(&decodedtmp, 0, sizeof(decodedtmp)); - rawSize -= MESHTASTIC_PKC_OVERHEAD; - if (pb_decode_from_bytes(bytes, rawSize, &meshtastic_Data_msg, &decodedtmp) && + size_t payloadSize = rawSize - MESHTASTIC_PKC_OVERHEAD; + if (pb_decode_from_bytes(bytes, payloadSize, &meshtastic_Data_msg, &decodedtmp) && decodedtmp.portnum != meshtastic_PortNum_UNKNOWN_APP) { decrypted = true; + rawSize = payloadSize; // commit the overhead subtraction only on full success LOG_INFO("Packet decrypted using PKI!"); p->pki_encrypted = true; - memcpy(p->public_key.bytes, fromKey.bytes, 32); + memcpy(p->public_key.bytes, remotePublic.bytes, 32); p->public_key.size = 32; p->decoded = decodedtmp; p->which_payload_variant = meshtastic_MeshPacket_decoded_tag; // change type to decoded + if (viaAdminKey) { + // Persist the admin key for the sender so future packets take the fast path and we can + // PKI-reply; p->from is bound into the AEAD nonce, so the trusted admin authenticated it. + meshtastic_NodeInfoLite *fromNode = nodeDB->getOrCreateMeshNode(p->from); + if (fromNode != nullptr) + fromNode->public_key = remotePublic; + } } else { + // AEAD already authenticated this ciphertext, so no other candidate could decode it - + // the payload is simply malformed. LOG_ERROR("PKC Decrypted, but pb_decode failed!"); return DecodeState::DECODE_FAILURE; } - } else { - LOG_WARN("PKC decrypt attempted but failed!"); } } #endif @@ -758,10 +782,17 @@ meshtastic_Routing_Error perhapsEncode(meshtastic_MeshPacket *p) ChannelIndex chIndex = p->channel; // keep as a local because we are about to change it #if !(MESHTASTIC_EXCLUDE_PKI) - // Destination key from the hot store or the warm tier (evicted - // long-tail nodes keep their key there) + // Resolve the destination's public key: prefer NodeDB (hot store or warm tier - evicted + // long-tail nodes keep their key there), otherwise (for a key-verification follow-on packet + // that explicitly requested PKI) fall back to the not-yet-verified key held during an + // in-progress handshake. This lets us DH-encode the follow-on packet before the peer's key + // has been committed to NodeDB. meshtastic_NodeInfoLite_public_key_t destKey = {0, {0}}; bool haveDestKey = nodeDB->copyPublicKey(p->to, destKey); + if (!haveDestKey && p->pki_encrypted && p->decoded.portnum == meshtastic_PortNum_KEY_VERIFICATION_APP && + crypto->getPendingPublicKey(p->to, destKey)) { + haveDestKey = true; + } // We may want to retool things so we can send a PKC packet when the client specifies a key and nodenum, even if the node // is not in the local nodedb // First, only PKC encrypt packets we are originating @@ -779,11 +810,17 @@ meshtastic_Routing_Error perhapsEncode(meshtastic_MeshPacket *p) config.security.private_key.size == 32 && !isBroadcast(p->to) && // Some portnums either make no sense to send with PKC p->decoded.portnum != meshtastic_PortNum_TRACEROUTE_APP && p->decoded.portnum != meshtastic_PortNum_NODEINFO_APP && - p->decoded.portnum != meshtastic_PortNum_ROUTING_APP && p->decoded.portnum != meshtastic_PortNum_POSITION_APP) { + p->decoded.portnum != meshtastic_PortNum_ROUTING_APP && p->decoded.portnum != meshtastic_PortNum_POSITION_APP && + // We allow Key Verification messages to be sent without a known destination key, since the point of those messages is + // to exchange keys. The first exchange (no usable key yet) falls through to channel encryption; the follow-on packet + // uses the pending key resolved into haveDestKey/destKey above. + // Though possible the first packet each direction should go non-pkc + // to handle the case where the remote node has our key, but we don't have theirs. + !(p->decoded.portnum == meshtastic_PortNum_KEY_VERIFICATION_APP && !haveDestKey)) { LOG_DEBUG("Use PKI!"); if (numbytes + MESHTASTIC_HEADER_LENGTH + MESHTASTIC_PKC_OVERHEAD > MAX_LORA_PAYLOAD_LEN) return meshtastic_Routing_Error_TOO_LARGE; - // Check for a known public key for the destination + // Check for a usable public key for the destination (NodeDB or a pending key-verification key) if (!haveDestKey) { LOG_WARN("Unknown public key for destination node 0x%08x (portnum %d), refusing to send legacy DM", p->to, p->decoded.portnum); diff --git a/src/modules/KeyVerificationModule.cpp b/src/modules/KeyVerificationModule.cpp index de3474aa2..4c2782241 100644 --- a/src/modules/KeyVerificationModule.cpp +++ b/src/modules/KeyVerificationModule.cpp @@ -1,11 +1,15 @@ #if !MESHTASTIC_EXCLUDE_PKI #include "KeyVerificationModule.h" +#include "CryptoEngine.h" +#include "HardwareRNG.h" #include "MeshService.h" #include "RTC.h" #include "graphics/draw/MenuHandler.h" #include "main.h" #include "meshUtils.h" #include "modules/AdminModule.h" +#include "modules/NodeInfoModule.h" +#include #include KeyVerificationModule *keyVerificationModule; @@ -34,7 +38,7 @@ AdminMessageHandleResult KeyVerificationModule::handleAdminMessageForModule(cons { updateState(); if (request->which_payload_variant == meshtastic_AdminMessage_key_verification_tag && mp.from == 0) { - LOG_WARN("Handling Key Verification Admin Message type %u", request->key_verification.message_type); + LOG_DEBUG("Handling Key Verification Admin Message type %u", request->key_verification.message_type); if (request->key_verification.message_type == meshtastic_KeyVerificationAdmin_MessageType_INITIATE_VERIFICATION && currentState == KEY_VERIFICATION_IDLE) { @@ -48,9 +52,7 @@ AdminMessageHandleResult KeyVerificationModule::handleAdminMessageForModule(cons } else if (request->key_verification.message_type == meshtastic_KeyVerificationAdmin_MessageType_DO_VERIFY && request->key_verification.nonce == currentNonce) { - auto remoteNodePtr = nodeDB->getMeshNode(currentRemoteNode); - if (remoteNodePtr) - remoteNodePtr->bitfield |= NODEINFO_BITFIELD_IS_KEY_MANUALLY_VERIFIED_MASK; + commitVerifiedRemoteNode(); resetToIdle(); } else if (request->key_verification.message_type == meshtastic_KeyVerificationAdmin_MessageType_DO_NOT_VERIFY) { resetToIdle(); @@ -63,9 +65,8 @@ AdminMessageHandleResult KeyVerificationModule::handleAdminMessageForModule(cons bool KeyVerificationModule::handleReceivedProtobuf(const meshtastic_MeshPacket &mp, meshtastic_KeyVerification *r) { updateState(); - if (mp.pki_encrypted == false) { - return false; - } + // Note: pki_encrypted is not required here. The first response (M2) may arrive channel-encrypted in + // the bootstrap case; the follow-on hash1 packet (M3) is required to be PKI in its branch below. if (mp.from != currentRemoteNode) { // because the inital connection request is handled in allocReply() return false; } @@ -74,9 +75,14 @@ bool KeyVerificationModule::handleReceivedProtobuf(const meshtastic_MeshPacket & } if (currentState == KEY_VERIFICATION_SENDER_HAS_INITIATED && r->nonce == currentNonce && r->hash2.size == 32 && - r->hash1.size == 0) { + r->hash1.size == 32) { memcpy(hash2, r->hash2.bytes, 32); - IF_SCREEN(screen->showNumberPicker("Enter Security Number", 60000, 6, [](int number_picked) -> void { + // The response carries the responder's public key in hash1. If we don't already hold it, stash it + // as a pending key so the Router can PKI-encrypt our follow-on packet (committed to NodeDB on accept). + auto *responderNode = nodeDB->getMeshNode(currentRemoteNode); + if (responderNode == nullptr || responderNode->public_key.size != 32) + crypto->setPendingPublicKey(currentRemoteNode, r->hash1.bytes); + IF_SCREEN(screen->showNumberPicker("Enter Security Number", 60000, 6, false, [](uint32_t number_picked) -> void { keyVerificationModule->processSecurityNumber(number_picked); });) @@ -95,7 +101,8 @@ bool KeyVerificationModule::handleReceivedProtobuf(const meshtastic_MeshPacket & currentState = KEY_VERIFICATION_SENDER_AWAITING_NUMBER; return true; - } else if (currentState == KEY_VERIFICATION_RECEIVER_AWAITING_HASH1 && r->hash1.size == 32 && r->nonce == currentNonce) { + } else if (currentState == KEY_VERIFICATION_RECEIVER_AWAITING_HASH1 && mp.pki_encrypted && r->hash1.size == 32 && + r->nonce == currentNonce) { if (memcmp(hash1, r->hash1.bytes, 32) == 0) { memset(message, 0, sizeof(message)); sprintf(message, "Verification: \n"); @@ -108,10 +115,9 @@ bool KeyVerificationModule::handleReceivedProtobuf(const meshtastic_MeshPacket & options.notificationType = graphics::notificationTypeEnum::selection_picker; options.bannerCallback = [=](int selected) { + LOG_DEBUG("User selected %d for key verification", selected); if (selected == 1) { - auto remoteNodePtr = nodeDB->getMeshNode(currentRemoteNode); - if (remoteNodePtr) - remoteNodePtr->bitfield |= NODEINFO_BITFIELD_IS_KEY_MANUALLY_VERIFIED_MASK; + keyVerificationModule->commitVerifiedRemoteNode(); } }; screen->showOverlayBanner(options);) @@ -139,22 +145,29 @@ bool KeyVerificationModule::sendInitialRequest(NodeNum remoteNode) { LOG_DEBUG("keyVerification start"); // generate nonce - updateState(); + updateState(false); if (currentState != KEY_VERIFICATION_IDLE) { IF_SCREEN(graphics::menuHandler::menuQueue = graphics::menuHandler::ThrottleMessage;) return false; } + updateState(true); currentNonce = random(); currentNonceTimestamp = getTime(); currentRemoteNode = remoteNode; meshtastic_KeyVerification KeyVerification = meshtastic_KeyVerification_init_zero; KeyVerification.nonce = currentNonce; KeyVerification.hash2.size = 0; - KeyVerification.hash1.size = 0; + // Carry our public key in the otherwise-unused hash1 field so a peer that does not yet hold our + // key can learn it from this first message (bootstrap / onboarding). + KeyVerification.hash1.size = 32; + memcpy(KeyVerification.hash1.bytes, owner.public_key.bytes, 32); meshtastic_MeshPacket *p = allocDataProtobuf(KeyVerification); p->to = remoteNode; p->channel = 0; - p->pki_encrypted = true; + // Only request PKI when we already hold the destination's key. Otherwise this first message goes out + // channel-encrypted (the Router falls back) so the peer can bootstrap from the key carried in hash1. + auto *remoteNodePtr = nodeDB->getMeshNode(remoteNode); + p->pki_encrypted = (remoteNodePtr != nullptr && remoteNodePtr->public_key.size == 32); p->decoded.want_response = true; p->priority = meshtastic_MeshPacket_Priority_HIGH; service->sendToMesh(p, RX_SRC_LOCAL, true); @@ -171,9 +184,6 @@ meshtastic_MeshPacket *KeyVerificationModule::allocReply() if (currentState != KEY_VERIFICATION_IDLE) { // TODO: cooldown period LOG_WARN("Key Verification requested, but already in a request"); return nullptr; - } else if (!currentRequest->pki_encrypted) { - LOG_WARN("Key Verification requested, but not in a PKI packet"); - return nullptr; } currentState = KEY_VERIFICATION_RECEIVER_AWAITING_HASH1; @@ -188,15 +198,43 @@ meshtastic_MeshPacket *KeyVerificationModule::allocReply() response.nonce = scratch.nonce; currentRemoteNode = req.from; currentNonceTimestamp = getTime(); - currentSecurityNumber = random(1, 999999); + // The security number is the handshake's MitM-resistance entropy, so draw it from the hardware + // RNG (falling back to the CSPRNG used for key material), never the predictable random(). + // Hold cryptLock like xeddsa_sign does: on nRF52 the fill toggles the CC310 that packet crypto + // on the BLE task also uses, and the CryptRNG state is shared with the signing path. + uint32_t securityEntropy = 0; + { + concurrency::LockGuard g(cryptLock); + if (!HardwareRNG::fill((uint8_t *)&securityEntropy, sizeof(securityEntropy))) + CryptRNG.rand((uint8_t *)&securityEntropy, sizeof(securityEntropy)); + } + currentSecurityNumber = (securityEntropy % 999999) + 1; - // generate hash1 + // Resolve the requester's public key: from the PKI envelope, else carried in hash1 (bootstrap). + // Stash unknown keys as pending (committed to NodeDB only once verification is accepted). + const uint8_t *senderKey = nullptr; + if (currentRequest->pki_encrypted && currentRequest->public_key.size == 32) { + senderKey = currentRequest->public_key.bytes; // this is bizarre, fixme + } else if (scratch.hash1.size == 32) { + senderKey = scratch.hash1.bytes; + } + if (senderKey == nullptr) { + LOG_WARN("Key Verification request without a usable public key"); + resetToIdle(); + return nullptr; + } + auto *senderNode = nodeDB->getMeshNode(currentRemoteNode); + bool senderKeyInNodeDB = (senderNode != nullptr && senderNode->public_key.size == 32); + if (!senderKeyInNodeDB) + crypto->setPendingPublicKey(currentRemoteNode, senderKey); + + // generate local hash1 hash.reset(); hash.update(¤tSecurityNumber, sizeof(currentSecurityNumber)); hash.update(¤tNonce, sizeof(currentNonce)); hash.update(¤tRemoteNode, sizeof(currentRemoteNode)); hash.update(&ourNodeNum, sizeof(ourNodeNum)); - hash.update(currentRequest->public_key.bytes, currentRequest->public_key.size); + hash.update(senderKey, 32); hash.update(owner.public_key.bytes, owner.public_key.size); hash.finalize(hash1, 32); @@ -205,15 +243,19 @@ meshtastic_MeshPacket *KeyVerificationModule::allocReply() hash.update(¤tNonce, sizeof(currentNonce)); hash.update(hash1, 32); hash.finalize(hash2, 32); - response.hash1.size = 0; + // Carry our public key in hash1 of the response so the requester can bootstrap our key as well. + response.hash1.size = 32; + memcpy(response.hash1.bytes, owner.public_key.bytes, 32); response.hash2.size = 32; memcpy(response.hash2.bytes, hash2, 32); responsePacket = allocDataProtobuf(response); - responsePacket->pki_encrypted = true; + // PKI-encrypt the response only if we already held the requester's key. In the bootstrap case it goes + // out channel-encrypted so the requester (who lacks our key) can decode it and read hash1. + responsePacket->pki_encrypted = senderKeyInNodeDB; IF_SCREEN(snprintf(message, 25, "Security Number \n%03u %03u", currentSecurityNumber / 1000, currentSecurityNumber % 1000); - screen->showSimpleBanner(message, 30000); LOG_WARN("%s", message);) + screen->showSimpleBanner(message, 30000); LOG_DEBUG("%s", message);) meshtastic_ClientNotification *cn = clientNotificationPool.allocZeroed(); if (cn) { cn->level = meshtastic_LogRecord_Level_WARNING; @@ -227,7 +269,7 @@ meshtastic_MeshPacket *KeyVerificationModule::allocReply() cn->payload_variant.key_verification_number_inform.security_number = currentSecurityNumber; service->sendClientNotification(cn); } - LOG_WARN("Security Number %04u, nonce %llu", currentSecurityNumber, currentNonce); + LOG_DEBUG("Security Number %04u, nonce %llu", currentSecurityNumber, currentNonce); return responsePacket; } @@ -236,14 +278,18 @@ void KeyVerificationModule::processSecurityNumber(uint32_t incomingNumber) SHA256 hash; NodeNum ourNodeNum = nodeDB->getNodeNum(); uint8_t scratch_hash[32] = {0}; - LOG_WARN("received security number: %u", incomingNumber); - meshtastic_NodeInfoLite *remoteNodePtr = nullptr; - remoteNodePtr = nodeDB->getMeshNode(currentRemoteNode); - if (!remoteNodePtr || !nodeInfoLiteHasUser(remoteNodePtr) || remoteNodePtr->public_key.size != 32) { - currentState = KEY_VERIFICATION_IDLE; - return; // should we throw an error here? + LOG_DEBUG("received security number: %u", incomingNumber); + meshtastic_NodeInfoLite *remoteNodePtr = nodeDB->getMeshNode(currentRemoteNode); + // Resolve the remote public key: NodeDB if known, otherwise the pending key learned during this + // handshake (bootstrap case). + meshtastic_NodeInfoLite_public_key_t remotePublic = {0, {0}}; + if (remoteNodePtr != nullptr && remoteNodePtr->public_key.size == 32) { + remotePublic = remoteNodePtr->public_key; + } else if (!crypto->getPendingPublicKey(currentRemoteNode, remotePublic)) { + LOG_WARN("No public key available for remote node, aborting key verification"); + resetToIdle(); + return; } - LOG_WARN("hashing "); // calculate hash1 hash.reset(); hash.update(&incomingNumber, sizeof(incomingNumber)); @@ -252,7 +298,7 @@ void KeyVerificationModule::processSecurityNumber(uint32_t incomingNumber) hash.update(¤tRemoteNode, sizeof(currentRemoteNode)); hash.update(owner.public_key.bytes, owner.public_key.size); - hash.update(remoteNodePtr->public_key.bytes, remoteNodePtr->public_key.size); + hash.update(remotePublic.bytes, 32); hash.finalize(hash1, 32); hash.reset(); @@ -297,13 +343,13 @@ void KeyVerificationModule::processSecurityNumber(uint32_t incomingNumber) return; } -void KeyVerificationModule::updateState() +void KeyVerificationModule::updateState(bool resetTimer) { if (currentState != KEY_VERIFICATION_IDLE) { // check for the 60 second timeout if (currentNonceTimestamp < getTime() - 60) { resetToIdle(); - } else { + } else if (resetTimer) { currentNonceTimestamp = getTime(); } } @@ -318,6 +364,32 @@ void KeyVerificationModule::resetToIdle() currentSecurityNumber = 0; currentRemoteNode = 0; currentState = KEY_VERIFICATION_IDLE; + // Discard any not-yet-verified key learned during this handshake; on reject/timeout it is never trusted. + crypto->clearPendingPublicKey(); +} + +void KeyVerificationModule::commitVerifiedRemoteNode() +{ + // The remote node already has a NodeDB entry by this point (packets were exchanged during the + // handshake), so getMeshNode is sufficient; bail defensively if it is somehow absent. + meshtastic_NodeInfoLite *node = nodeDB->getMeshNode(currentRemoteNode); + if (!node) { + LOG_WARN("Attempted to commit key, but unknown node"); + return; + } + // If we only held the peer's key as a pending (unverified) key during the handshake, commit it to + // NodeDB now that the user has confirmed the verification, so future PKI traffic can use it. + meshtastic_NodeInfoLite_public_key_t pending = {0, {0}}; + if (node->public_key.size != 32 && crypto->getPendingPublicKey(currentRemoteNode, pending)) + node->public_key = pending; + node->bitfield |= NODEINFO_BITFIELD_IS_KEY_MANUALLY_VERIFIED_MASK; + LOG_INFO("Node 0x%08x manually verified with security number %u", currentRemoteNode, currentSecurityNumber); + if (nodeInfoModule) + nodeInfoModule->sendOurNodeInfo(currentRemoteNode, false, node->channel, true); + crypto->clearPendingPublicKey(); + currentState = KEY_VERIFICATION_IDLE; + // Persist the committed key and verified flag so manual verification survives a reboot. + nodeDB->saveToDisk(SEGMENT_NODEDATABASE); } void KeyVerificationModule::generateVerificationCode(char *readableCode) diff --git a/src/modules/KeyVerificationModule.h b/src/modules/KeyVerificationModule.h index d5dba01d7..9496649d3 100644 --- a/src/modules/KeyVerificationModule.h +++ b/src/modules/KeyVerificationModule.h @@ -12,6 +12,39 @@ enum KeyVerificationState { KEY_VERIFICATION_RECEIVER_AWAITING_HASH1, }; +// KeyVerification Module overview +// This module allows for two useful functions. First, it implements a 2sv process that can manually verify a trustworthy +// connection with another node. It specifically verifies that the other node holds the correct private key for its public key, so +// it is resistant to MitM attacks. Second, it can be used to bootstrap trust in a new node by carrying the public key in the +// initial unencrypted message (in the hash1 field of the KeyVerification protobuf). This allows a user to manually verify a new +// node even if they don't have that node in the local nodeDB at all. + +// The handshake process is as follows (NodeA = initiator, NodeB = responder): +// 1. NodeA sends a KeyVerification message containing a random nonce and its own public key (in the +// hash1 field) to NodeB. Implemented in sendInitialRequest(). It is PKI-encrypted if NodeA already +// holds NodeB's key, otherwise channel-encrypted (the bootstrap case). +// +// 2. NodeB replies (allocReply()) with its own public key (hash1 field) and hash2. NodeB generates a +// random 6-digit security number and stashes NodeA's public key (as a pending key if not already in +// the nodeDB). It computes hash1 = SHA256(securityNumber, nonce, NodeA_num, NodeB_num, PK_A, PK_B), +// then hash2 = SHA256(nonce, hash1). The reply is PKI-encrypted only if NodeB already held NodeA's +// key; in the bootstrap case it is channel-encrypted so NodeA can read NodeB's key from hash1. +// +// 3. NodeA receives the reply (handleReceivedProtobuf()), checks the nonce, stashes NodeB's public key, +// and prompts the user to enter the security number. The security number is never sent over the mesh +// and must be communicated over a secondary channel. processSecurityNumber() recomputes hash1 from +// the entered number and verifies SHA256(nonce, hash1) matches the received hash2. NodeA then sends +// its hash1 back to NodeB in a PKI-encrypted KeyVerification message (the follow-on PKI packet) and +// shows the KeyVerificationFinalPrompt menu, displaying 8 characters derived from hash1. +// +// 4. NodeB receives NodeA's hash1 (handleReceivedProtobuf(); required to be PKI-encrypted), checks it +// matches the hash1 NodeB generated, and shows the same 8-character code for final confirmation. +// +// The final on-screen code comparison is the actual manual verification: the user confirms the codes +// match on both devices, proving the two nodes agree on the same public keys (no MitM substitution). +// PKI-encrypting the follow-on packet additionally proves each node holds the private key for the +// agreed public key. + class KeyVerificationModule : public ProtobufModule //, private concurrency::OSThread // { // CallbackObserver nodeStatusObserver = @@ -29,6 +62,7 @@ class KeyVerificationModule : public ProtobufModule bool sendInitialRequest(NodeNum remoteNode); void generateVerificationCode(char *); // fills char with the user readable verification code uint32_t getCurrentRemoteNode() { return currentRemoteNode; } + void commitVerifiedRemoteNode(); // Commit a pending key to NodeDB and mark the node manually verified protected: /* Called to handle a particular incoming message @@ -58,8 +92,8 @@ class KeyVerificationModule : public ProtobufModule char message[40] = {0}; void processSecurityNumber(uint32_t); - void updateState(); // check the timeouts and maybe reset the state to idle - void resetToIdle(); // Zero out module state + void updateState(bool resetTimer = true); // check the timeouts and maybe reset the state to idle + void resetToIdle(); // Zero out module state }; extern KeyVerificationModule *keyVerificationModule; \ No newline at end of file diff --git a/test/native-suite-count b/test/native-suite-count index e85087aff..f5c89552b 100644 --- a/test/native-suite-count +++ b/test/native-suite-count @@ -1 +1 @@ -31 +32 diff --git a/test/support/AdminModuleTestShim.h b/test/support/AdminModuleTestShim.h index 7dba0677e..d3670b09a 100644 --- a/test/support/AdminModuleTestShim.h +++ b/test/support/AdminModuleTestShim.h @@ -7,9 +7,11 @@ class AdminModuleTestShim : public AdminModule { public: + using AdminModule::checkPassKey; // session-key gate seam (see test_admin_session_repro) using AdminModule::handleReceivedProtobuf; using AdminModule::handleSetConfig; using AdminModule::handleSetModuleConfig; + using AdminModule::setPassKey; // With an "open edit transaction" saveChanges() is a pure no-op: no reloadConfig/saveToDisk/reboot. void deferSaves() { hasOpenEditTransaction = true; } diff --git a/test/test_admin_session_repro/test_main.cpp b/test/test_admin_session_repro/test_main.cpp new file mode 100644 index 000000000..f1bb1711e --- /dev/null +++ b/test/test_admin_session_repro/test_main.cpp @@ -0,0 +1,153 @@ +// Deterministic reproduction of the admin session-key behavior discussed on PR #10669 +// (ndoo's "Admin message without session_key!" report). +// +// Drives the REAL incoming-admin path (AdminModule::handleReceivedProtobuf) with a remote +// (from != 0) PKC-authorized set_owner, exercising the exact checkPassKey/setPassKey gate. +// A local (from == 0) admin bypasses that gate, so the bug only reproduces from != 0. + +#include "MeshTypes.h" // include BEFORE TestUtil.h +#include "TestUtil.h" +#include + +#if !(MESHTASTIC_EXCLUDE_PKI) + +#include "mesh/Channels.h" +#include "mesh/NodeDB.h" +#include "modules/AdminModule.h" +#include "support/AdminModuleTestShim.h" +#include "support/MockMeshService.h" +#include + +static constexpr NodeNum LOCAL_NODE = 0x0A0A0A0A; +static constexpr NodeNum ADMIN_NODE = 0x0B0B0B0B; // authorized admin, sends remote admin to us +static const uint8_t ADMIN_KEY[32] = {0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88, 0x99, 0xaa, 0xbb, + 0xcc, 0xdd, 0xee, 0xff, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, + 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, 0x0f, 0x10, 0x20}; + +static MockMeshService *mockService = nullptr; +static AdminModuleTestShim *admin = nullptr; + +// A remote, PKC-authorized set_owner. `session` (if non-empty) is the session_passkey the client presents. +static meshtastic_MeshPacket makeRemoteSetOwner(const char *newLongName, const uint8_t *session, size_t sessionLen, + meshtastic_AdminMessage &out) +{ + out = meshtastic_AdminMessage_init_zero; + out.which_payload_variant = meshtastic_AdminMessage_set_owner_tag; + strncpy(out.set_owner.long_name, newLongName, sizeof(out.set_owner.long_name) - 1); + if (session) { + out.session_passkey.size = sessionLen; + memcpy(out.session_passkey.bytes, session, sessionLen); + } + + meshtastic_MeshPacket mp = meshtastic_MeshPacket_init_zero; + mp.from = ADMIN_NODE; // REMOTE: this is what makes the session gate apply + mp.channel = 0; + mp.which_payload_variant = meshtastic_MeshPacket_decoded_tag; + mp.pki_encrypted = true; // arrived over PKC + mp.public_key.size = 32; + memcpy(mp.public_key.bytes, ADMIN_KEY, 32); // matches config.security.admin_key[0] -> authorized + return mp; +} + +void setUp(void) +{ + mockService = new MockMeshService(); + service = mockService; + admin = new AdminModuleTestShim(); + admin->deferSaves(); // no disk/reboot side effects when a setter is accepted + + if (!nodeDB) + nodeDB = new NodeDB(); + myNodeInfo.my_node_num = LOCAL_NODE; + + config = meshtastic_LocalConfig_init_zero; + // Authorize ADMIN_NODE's key as an admin key so the PKC path accepts it and we reach the session gate. + config.security.admin_key[0].size = 32; + memcpy(config.security.admin_key[0].bytes, ADMIN_KEY, 32); + + owner = meshtastic_User_init_zero; + strncpy(owner.long_name, "Original", sizeof(owner.long_name) - 1); + + channels.initDefaults(); + channels.onConfigChanged(); +} + +void tearDown(void) +{ + service = nullptr; + delete mockService; + mockService = nullptr; + delete admin; + admin = nullptr; +} + +// ndoo's report: a setter from a remote node with NO valid session is rejected, and the node's +// expected session key is all-zero because it has minted none since boot. +void test_remote_setter_without_session_is_rejected(void) +{ + meshtastic_AdminMessage m; + meshtastic_MeshPacket mp = makeRemoteSetOwner("Hijacked", nullptr, 0, m); + + admin->handleReceivedProtobuf(mp, &m); + admin->drainReply(); + + // Rejected at the session gate -> owner unchanged (this is ndoo's "Admin message without session_key!"). + TEST_ASSERT_EQUAL_STRING("Original", owner.long_name); +} + +// The node's session key is minted only by setPassKey (which runs when it answers an admin GET), +// so before any GET the expected key is all-zero and any presented key mismatches. +void test_expected_session_key_is_zero_before_any_get(void) +{ + uint8_t zero[8] = {0}; + meshtastic_AdminMessage probe = meshtastic_AdminMessage_init_zero; + probe.session_passkey.size = 8; + memcpy(probe.session_passkey.bytes, zero, 8); // even all-zeros must not authorize a state change + // A fresh module has minted no session; a stale/guessed key does not match. + // (checkPassKey also requires size==8 AND session_time freshness.) + uint8_t stale[8] = {0x29, 0x04, 0xb4, 0x78, 0xd8, 0x68, 0xa7, 0xff}; // ndoo's presented key + meshtastic_AdminMessage staleMsg = meshtastic_AdminMessage_init_zero; + staleMsg.session_passkey.size = 8; + memcpy(staleMsg.session_passkey.bytes, stale, 8); + TEST_ASSERT_FALSE(admin->checkPassKey(&staleMsg)); // Expected: 00..00 vs Incoming: 29 04 b4 78.. -> reject +} + +// The fix path: once the node answers a GET (setPassKey mints/returns the key), the session gate +// accepts a setter carrying that key. Asserting the gate (checkPassKey) directly is the mechanism; +// driving the full handleSetOwner would need the NodeInfoModule scaffolding, out of scope here. +void test_session_gate_accepts_key_from_a_get_response(void) +{ + // Simulate the node answering an admin GET: setPassKey mints the session and writes it into the response. + meshtastic_AdminMessage getResponse = meshtastic_AdminMessage_init_zero; + admin->setPassKey(&getResponse); + TEST_ASSERT_EQUAL(8, getResponse.session_passkey.size); // node handed the client a session key + + // A setter carrying that exact key passes the gate (would be accepted). + meshtastic_AdminMessage good = meshtastic_AdminMessage_init_zero; + good.session_passkey = getResponse.session_passkey; + TEST_ASSERT_TRUE(admin->checkPassKey(&good)); + + // A setter carrying a stale/guessed key still fails (no session replay). + meshtastic_AdminMessage bad = meshtastic_AdminMessage_init_zero; + bad.session_passkey.size = 8; + uint8_t stale[8] = {0x29, 0x04, 0xb4, 0x78, 0xd8, 0x68, 0xa7, 0xff}; + memcpy(bad.session_passkey.bytes, stale, 8); + TEST_ASSERT_FALSE(admin->checkPassKey(&bad)); +} + +#endif // !(MESHTASTIC_EXCLUDE_PKI) + +void setup() +{ + delay(10); + initializeTestEnvironment(); + UNITY_BEGIN(); +#if !(MESHTASTIC_EXCLUDE_PKI) + RUN_TEST(test_remote_setter_without_session_is_rejected); + RUN_TEST(test_expected_session_key_is_zero_before_any_get); + RUN_TEST(test_session_gate_accepts_key_from_a_get_response); +#endif + exit(UNITY_END()); +} + +void loop() {} diff --git a/test/test_pki_admin_fallback/test_main.cpp b/test/test_pki_admin_fallback/test_main.cpp new file mode 100644 index 000000000..c03652244 --- /dev/null +++ b/test/test_pki_admin_fallback/test_main.cpp @@ -0,0 +1,223 @@ +// Tests for the admin-key fallback in Router::perhapsDecode: a PKI unicast from an unknown sender is +// tried against each configured admin_key; on success the packet decodes and the key is persisted to +// NodeDB. Drives the real crypto + NodeDB path with packets encrypted exactly as an admin radio would. + +#include "MeshTypes.h" // include BEFORE TestUtil.h +#include "TestUtil.h" +#include + +// The whole feature is compiled out when PKI is excluded. +#if !(MESHTASTIC_EXCLUDE_PKI) + +#include "mesh/Channels.h" +#include "mesh/CryptoEngine.h" +#include "mesh/NodeDB.h" +#include "mesh/RadioInterface.h" // MESHTASTIC_PKC_OVERHEAD +#include "mesh/Router.h" +#include +#include +#include + +static constexpr NodeNum LOCAL_NODE = 0x0A0A0A0A; // us (the receiver) +static constexpr NodeNum ADMIN_NODE = 0x0B0B0B0B; // an authorized admin, absent from our NodeDB +static constexpr uint32_t PKT_ID = 0x12345678; + +// MockNodeDB - inject nodes with controlled public keys (meshNodes/numMeshNodes are public on NodeDB). +// Mirrors test/test_packet_signing. +class MockNodeDB : public NodeDB +{ + public: + void clearTestNodes() + { + testNodes.clear(); + meshNodes = &testNodes; + numMeshNodes = 0; + } + + void addNode(NodeNum num) + { + meshtastic_NodeInfoLite node = meshtastic_NodeInfoLite_init_zero; + node.num = num; + testNodes.push_back(node); + meshNodes = &testNodes; + numMeshNodes = testNodes.size(); + } + + void setPublicKey(NodeNum num, const uint8_t *pubKey) + { + meshtastic_NodeInfoLite *n = getMeshNode(num); + TEST_ASSERT_NOT_NULL(n); + n->public_key.size = 32; + memcpy(n->public_key.bytes, pubKey, 32); + } + + std::vector testNodes; +}; + +static MockNodeDB *mockNodeDB = nullptr; + +// Keypairs, regenerated fresh each test in setUp(). "our" == the receiver, "admin" == the sender. +static uint8_t ourPub[32], ourPriv[32]; +static uint8_t adminPub[32], adminPriv[32]; + +// Store a 32-byte key into config.security.admin_key[slot]. +static void setAdminKey(int slot, const uint8_t *key32) +{ + config.security.admin_key[slot].size = 32; + memcpy(config.security.admin_key[slot].bytes, key32, 32); + if (slot + 1 > (int)config.security.admin_key_count) + config.security.admin_key_count = slot + 1; +} + +// Build a PKI-encrypted unicast from `from` to us, encrypted with `senderPriv` against our public key, +// leaving the engine holding our private key afterwards (as during receive) so perhapsDecode can decrypt. +static meshtastic_MeshPacket makePkiPacket(NodeNum from, meshtastic_PortNum port, size_t payloadLen, const uint8_t *senderPriv) +{ + meshtastic_Data data = meshtastic_Data_init_zero; + data.portnum = port; + data.payload.size = payloadLen; + for (size_t i = 0; i < payloadLen; i++) + data.payload.bytes[i] = (uint8_t)(i & 0xff); + + uint8_t plain[meshtastic_Constants_DATA_PAYLOAD_LEN]; + size_t plainLen = pb_encode_to_bytes(plain, sizeof(plain), &meshtastic_Data_msg, &data); + TEST_ASSERT_TRUE_MESSAGE(plainLen > 0, "pb_encode_to_bytes failed in test setup"); + + meshtastic_NodeInfoLite_public_key_t ourPubStruct; + ourPubStruct.size = 32; + memcpy(ourPubStruct.bytes, ourPub, 32); + + meshtastic_MeshPacket p = meshtastic_MeshPacket_init_zero; + p.from = from; + p.to = LOCAL_NODE; + p.id = PKT_ID; + p.channel = 0; // PKI packets carry channel hash 0 + p.which_payload_variant = meshtastic_MeshPacket_encrypted_tag; + + // Encrypt AS the sender: shared secret = DH(senderPriv, ourPub). + crypto->setDHPrivateKey(const_cast(senderPriv)); + bool ok = crypto->encryptCurve25519(p.to, p.from, ourPubStruct, p.id, plainLen, plain, p.encrypted.bytes); + TEST_ASSERT_TRUE_MESSAGE(ok, "encryptCurve25519 failed in test setup"); + p.encrypted.size = plainLen + MESHTASTIC_PKC_OVERHEAD; + + // Restore the engine to our private key, as it is when receiving. + crypto->setDHPrivateKey(ourPriv); + return p; +} + +// Assert the packet decoded via PKI and that we learned `expectedKey` for its sender. +static void assertDecodedAndLearned(meshtastic_MeshPacket *p, const uint8_t *expectedKey) +{ + TEST_ASSERT_EQUAL(meshtastic_MeshPacket_decoded_tag, p->which_payload_variant); + TEST_ASSERT_TRUE(p->pki_encrypted); + TEST_ASSERT_EQUAL(meshtastic_PortNum_PRIVATE_APP, p->decoded.portnum); + TEST_ASSERT_EQUAL(32, p->public_key.size); + TEST_ASSERT_EQUAL_MEMORY(expectedKey, p->public_key.bytes, 32); + + meshtastic_NodeInfoLite *learned = mockNodeDB->getMeshNode(p->from); + TEST_ASSERT_NOT_NULL_MESSAGE(learned, "sender should have been created in NodeDB"); + TEST_ASSERT_EQUAL_MESSAGE(32, learned->public_key.size, "sender key should have been persisted"); + TEST_ASSERT_EQUAL_MEMORY_MESSAGE(expectedKey, learned->public_key.bytes, 32, "persisted key mismatch"); +} + +void setUp(void) +{ + // Construct the mock FIRST: the NodeDB ctor can reload persisted host state and repopulate globals. + mockNodeDB = new MockNodeDB(); + mockNodeDB->clearTestNodes(); + nodeDB = mockNodeDB; + + config = meshtastic_LocalConfig_init_zero; + owner = meshtastic_User_init_zero; + myNodeInfo.my_node_num = LOCAL_NODE; // drives isToUs()/getFrom() + + channels.initDefaults(); + channels.onConfigChanged(); + + // Fresh keypairs for us and the admin (independent, valid Curve25519 pairs). + crypto->generateKeyPair(ourPub, ourPriv); + crypto->generateKeyPair(adminPub, adminPriv); + + // perhapsDecode's PKI gate requires that we have our own key (getMeshNode(p->to)->public_key). + mockNodeDB->addNode(LOCAL_NODE); + mockNodeDB->setPublicKey(LOCAL_NODE, ourPub); + + // During receive the engine holds our private key. + crypto->setDHPrivateKey(ourPriv); +} + +void tearDown(void) +{ + delete mockNodeDB; + mockNodeDB = nullptr; + nodeDB = nullptr; +} + +// Admin key in slot 0: a DM from an unknown sender decrypts via the fallback, and the key is persisted. +void test_admin_key_slot0_decrypts_and_persists(void) +{ + setAdminKey(0, adminPub); + TEST_ASSERT_NULL_MESSAGE(mockNodeDB->getMeshNode(ADMIN_NODE), "precondition: sender is unknown to us"); + + meshtastic_MeshPacket p = makePkiPacket(ADMIN_NODE, meshtastic_PortNum_PRIVATE_APP, 16, adminPriv); + TEST_ASSERT_EQUAL(DECODE_SUCCESS, perhapsDecode(&p)); + + assertDecodedAndLearned(&p, adminPub); +} + +// The loop scans every admin slot, not just [0]: a key provisioned only in slot 2 still works. +void test_admin_key_slot2_only_decrypts(void) +{ + setAdminKey(2, adminPub); // slots 0 and 1 left empty + + meshtastic_MeshPacket p = makePkiPacket(ADMIN_NODE, meshtastic_PortNum_PRIVATE_APP, 16, adminPriv); + TEST_ASSERT_EQUAL(DECODE_SUCCESS, perhapsDecode(&p)); + + assertDecodedAndLearned(&p, adminPub); +} + +// No admin key configured + unknown sender: nothing decrypts, and we must NOT invent a key for anyone. +void test_no_admin_key_unknown_sender_not_decoded(void) +{ + // config (incl. admin_key) is zeroed by setUp(); ADMIN_NODE is absent from NodeDB. + meshtastic_MeshPacket p = makePkiPacket(ADMIN_NODE, meshtastic_PortNum_PRIVATE_APP, 16, adminPriv); + + TEST_ASSERT_NOT_EQUAL(DECODE_SUCCESS, perhapsDecode(&p)); + TEST_ASSERT_NOT_EQUAL(meshtastic_MeshPacket_decoded_tag, p.which_payload_variant); + TEST_ASSERT_NULL_MESSAGE(mockNodeDB->getMeshNode(ADMIN_NODE), "must not learn a key when nothing decrypted"); +} + +// A configured admin key that is NOT the sender's must fail authentication (no bogus key learned). +void test_wrong_admin_key_does_not_decode(void) +{ + uint8_t otherPub[32], otherPriv[32]; + crypto->generateKeyPair(otherPub, otherPriv); // unrelated key + crypto->setDHPrivateKey(ourPriv); // restore receive key (generateKeyPair changed it) + setAdminKey(0, otherPub); // admin slot holds a key that did NOT encrypt the packet + + meshtastic_MeshPacket p = makePkiPacket(ADMIN_NODE, meshtastic_PortNum_PRIVATE_APP, 16, adminPriv); + + TEST_ASSERT_NOT_EQUAL(DECODE_SUCCESS, perhapsDecode(&p)); + TEST_ASSERT_NOT_EQUAL(meshtastic_MeshPacket_decoded_tag, p.which_payload_variant); + TEST_ASSERT_NULL(mockNodeDB->getMeshNode(ADMIN_NODE)); +} + +#endif // !(MESHTASTIC_EXCLUDE_PKI) + +void setup() +{ + delay(10); + delay(2000); + + initializeTestEnvironment(); + UNITY_BEGIN(); +#if !(MESHTASTIC_EXCLUDE_PKI) + RUN_TEST(test_admin_key_slot0_decrypts_and_persists); + 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); +#endif + exit(UNITY_END()); +} + +void loop() {}