Allow key verification to work for unknown nodes. (#10669)

* Allow key verification to work for unknown nodes.

* trunk

* More reliable admin key decryption

* Add admin key fallback tests

* Actually check haveRemoteKey

* Logging cleanup

* Address review feedback

- Persist the committed key + manually-verified flag in
  commitVerifiedRemoteNode via saveToDisk(SEGMENT_NODEDATABASE),
  replacing the "todo: initiate save"
- Guard the CryptoEngine pending-key slot with a dedicated internal
  lock; the Router reads it while already holding the non-recursive
  cryptLock, so the accessors cannot reuse that lock
- Draw the security number from the hardware RNG (CryptRNG fallback)
  under cryptLock instead of random(); on nRF52 the entropy fill
  toggles the same CC310 the BLE task's packet crypto uses
- Return true after fully handling the hash2 response (restores
  develop behavior; consistent with the hash1 branch)
- Take uint32_t in the number-picker callbacks so 8-digit hex
  nodenums can't truncate through int
- Trim over-long comments flagged by review

* feat(tests): add deterministic tests for admin session-key behavior

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: Ben Meadors <benmmeadors@gmail.com>
This commit is contained in:
Jonathan Bennett
2026-07-14 21:38:41 -05:00
committed by GitHub
co-authored by GitHub Claude Opus 4.8 Ben Meadors
parent fc91a69ca4
commit 952c825167
12 changed files with 632 additions and 65 deletions
+5 -2
View File
@@ -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<void(uint32_t)> 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;
+2 -1
View File
@@ -344,7 +344,8 @@ class Screen : public concurrency::OSThread
void showOverlayBanner(BannerOverlayOptions);
void showNodePicker(const char *message, uint32_t durationMs, std::function<void(uint32_t)> bannerCallback);
void showNumberPicker(const char *message, uint32_t durationMs, uint8_t digits, std::function<void(uint32_t)> bannerCallback);
void showNumberPicker(const char *message, uint32_t durationMs, uint8_t digits, bool useBase16,
std::function<void(uint32_t)> bannerCallback);
void showTextInput(const char *header, const char *initialText, uint32_t durationMs,
std::function<void(const std::string &)> textCallback);
+5 -4
View File
@@ -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);
+26
View File
@@ -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;
+15
View File
@@ -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};
+56 -19
View File
@@ -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);
+108 -36
View File
@@ -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 <RNG.h>
#include <SHA256.h>
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(&currentSecurityNumber, sizeof(currentSecurityNumber));
hash.update(&currentNonce, sizeof(currentNonce));
hash.update(&currentRemoteNode, 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(&currentNonce, 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(&currentRemoteNode, 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)
+36 -2
View File
@@ -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<meshtastic_KeyVerification> //, private concurrency::OSThread //
{
// CallbackObserver<KeyVerificationModule, const meshtastic::Status *> nodeStatusObserver =
@@ -29,6 +62,7 @@ class KeyVerificationModule : public ProtobufModule<meshtastic_KeyVerification>
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<meshtastic_KeyVerification>
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;