Skip to content
Open
1 change: 1 addition & 0 deletions src/main.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -1078,6 +1078,7 @@ void setup()
nodeDB->hasWarned = true;
}
#endif
nodeDB->notifyPendingLicensedIdentityMigration();
#if !MESHTASTIC_EXCLUDE_INPUTBROKER
if (inputBroker)
inputBroker->Init();
Expand Down
14 changes: 8 additions & 6 deletions src/mesh/Channels.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -128,11 +128,13 @@ bool Channels::ensureLicensedOperation()
}
auto &channelSettings = channel.settings;
if (strcasecmp(channelSettings.name, Channels::adminChannel) == 0) {
channel.role = meshtastic_Channel_Role_DISABLED;
channelSettings.psk.bytes[0] = 0;
channelSettings.psk.size = 0;
hasEncryptionOrAdmin = true;
channels.setChannel(channel);
if (channel.role != meshtastic_Channel_Role_DISABLED || channelSettings.psk.size > 0) {
channel.role = meshtastic_Channel_Role_DISABLED;
channelSettings.psk.bytes[0] = 0;
channelSettings.psk.size = 0;
hasEncryptionOrAdmin = true;
channels.setChannel(channel);
}

} else if (channelSettings.psk.size > 0) {
channelSettings.psk.bytes[0] = 0;
Expand Down Expand Up @@ -563,4 +565,4 @@ bool Channels::setDefaultPresetCryptoForHash(ChannelHash channelHash)
int16_t Channels::setActiveByIndex(ChannelIndex channelIndex)
{
return setCrypto(channelIndex);
}
}
6 changes: 2 additions & 4 deletions src/mesh/FloodingRouter.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -68,10 +68,8 @@ bool FloodingRouter::perhapsHandleUpgradedPacket(const meshtastic_MeshPacket *p)
{
// isRebroadcaster() is duplicated in perhapsRebroadcast(), but this avoids confusing log messages
if (isRebroadcaster() && iface && p->hop_limit > 0) {
// Verify the replacement before deleting the valid lower-hop copy waiting in the TX queue.
// This is intentionally redundant with ReliableRouter's ingress gate: it keeps this helper
// safe if another caller is introduced later.
if (passesRoutingAuthGate(const_cast<meshtastic_MeshPacket *>(p)) != RoutingAuthVerdict::ACCEPT)
// Re-authenticate before replacing the queued lower-hop copy so future callers remain safe.
if (passesRoutingAuthGate(p) != RoutingAuthVerdict::ACCEPT)
return true;

// If we overhear a duplicate copy of the packet with more hops left than the one we are waiting to
Expand Down
40 changes: 33 additions & 7 deletions src/mesh/NodeDB.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -831,7 +831,7 @@ void NodeDB::installDefaultNodeDatabase()
void NodeDB::installDefaultConfig(bool preserveKey = false)
{
uint8_t private_key_temp[32];
bool shouldPreserveKey = preserveKey && config.has_security && config.security.private_key.size > 0;
bool shouldPreserveKey = preserveKey && config.has_security && config.security.private_key.size == 32;
if (shouldPreserveKey) {
memcpy(private_key_temp, config.security.private_key.bytes, config.security.private_key.size);
}
Expand Down Expand Up @@ -2614,6 +2614,11 @@ void NodeDB::loadFromDisk()
moduleConfig.version = POSITION_TELEMETRY_OPTIN_VER;
saveToDisk(SEGMENT_MODULECONFIG);
}

if (channels.ensureLicensedOperation()) {
LOG_WARN("Licensed operation removed persisted channel encryption/admin access");
saveToDisk(SEGMENT_CHANNELS);
}
#if ARCH_PORTDUINO
// set any config overrides
if (portduino_config.has_configDisplayMode) {
Expand Down Expand Up @@ -4029,15 +4034,14 @@ bool NodeDB::checkLowEntropyPublicKey(const meshtastic_Config_SecurityConfig_pub
bool NodeDB::generateCryptoKeyPair(const uint8_t *privateKey)
{
#if !(MESHTASTIC_EXCLUDE_PKI_KEYGEN || MESHTASTIC_EXCLUDE_PKI)
// Only generate keys for non-licensed users and if the LoRa region is set. The native simulator
// boots region-UNSET but still needs a keypair so PKI-encrypted DMs work between sim nodes, so
// allow keygen there regardless of region.
// Generate identity keys once a LoRa region is set. Licensed operation still needs the identity
// key for plaintext signatures, even though the key is never used for PKI encryption.
bool regionBlocksKeygen = config.lora.region == meshtastic_Config_LoRaConfig_RegionCode_UNSET;
#if ARCH_PORTDUINO
if (portduino_config.lora_module == use_simradio)
regionBlocksKeygen = false;
#endif
if (owner.is_licensed || regionBlocksKeygen) {
if (regionBlocksKeygen) {
return false;
}

Expand Down Expand Up @@ -4087,15 +4091,30 @@ bool NodeDB::generateCryptoKeyPair(const uint8_t *privateKey)
LOG_DEBUG("Set DH private key for crypto operations");
crypto->setDHPrivateKey(config.security.private_key.bytes);

// Conditionally create new identity based on parameter
createNewIdentity();
if (createNewIdentity() && owner.is_licensed)
licensedIdentityMigrationPending = true;
}
return keygenSuccess;
#else
return false;
#endif
}

bool NodeDB::notifyPendingLicensedIdentityMigration()
{
if (!licensedIdentityMigrationPending || !service)
return false;
meshtastic_ClientNotification *notification = clientNotificationPool.allocZeroed();
if (!notification)
return false;
notification->level = meshtastic_LogRecord_Level_WARNING;
notification->time = getValidTime(RTCQualityFromNet);
snprintf(notification->message, sizeof(notification->message), "%s", LICENSED_IDENTITY_MIGRATION_WARNING);
service->sendClientNotification(notification);
licensedIdentityMigrationPending = false;
return true;
}

bool NodeDB::createNewIdentity()
{
uint32_t oldNodeNum = getNodeNum();
Expand Down Expand Up @@ -4199,6 +4218,13 @@ bool NodeDB::restorePreferences(meshtastic_AdminMessage_BackupLocation location,
LOG_DEBUG("Restored channels");
}

if (owner.is_licensed && channels.ensureLicensedOperation()) {
restoreWhat |= SEGMENT_CHANNELS;
LOG_WARN("Licensed operation sanitized restored channel encryption/admin access");
}
if (restoreWhat & SEGMENT_CHANNELS)
channels.onConfigChanged();

success = saveToDisk(restoreWhat);
if (success) {
LOG_INFO("Restored preferences from backup");
Expand Down
6 changes: 6 additions & 0 deletions src/mesh/NodeDB.h
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,8 @@ static const uint8_t LOW_ENTROPY_HASHES[][32] = {
0x37, 0x82, 0x8d, 0xb2, 0xcc, 0xd8, 0x97, 0x40, 0x9a, 0x5c, 0x8f, 0x40, 0x55, 0xcb, 0x4c, 0x3e}};
static const char LOW_ENTROPY_WARNING[] = "Compromised keys were detected and regenerated.";
#endif
static const char LICENSED_IDENTITY_MIGRATION_WARNING[] =
"Licensed signing generated a new identity key; this node identity changed.";
/*
DeviceState versions used to be defined in the .proto file but really only this function cares. So changed to a
#define here.
Expand Down Expand Up @@ -221,6 +223,7 @@ class NodeDB

bool keyIsLowEntropy = false;
bool hasWarned = false;
bool licensedIdentityMigrationPending = false;

/// don't do mesh based algorithm for node id assignment (initially)
/// instead just store in flash - possibly even in the initial alpha release do this hack
Expand Down Expand Up @@ -505,6 +508,8 @@ class NodeDB
/// @param privateKey Optional 32-byte private key to use. If nullptr, generates new random keys.
bool generateCryptoKeyPair(const uint8_t *privateKey = nullptr);

bool notifyPendingLicensedIdentityMigration();

bool createNewIdentity();

bool backupPreferences(meshtastic_AdminMessage_BackupLocation location);
Expand Down Expand Up @@ -580,6 +585,7 @@ class NodeDB
// Grant the unit-test shim access to the private maintenance paths below
// (migration / cleanup / eviction) without relaxing production access.
friend class NodeDBTestShim;
friend class MockNodeDB;
#endif

/// purge db entries without user info
Expand Down
79 changes: 47 additions & 32 deletions src/mesh/Router.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,8 @@ static bool routingAuthCacheMatches(const meshtastic_MeshPacket &packet)

static void storeRoutingAuthCache(const meshtastic_MeshPacket &wire, const meshtastic_MeshPacket &authenticated)
{
if (!routingAuthCacheLock)
return;
concurrency::LockGuard guard(routingAuthCacheLock);
routingAuthCache.wire = wire;
routingAuthCache.authenticated = authenticated;
Expand Down Expand Up @@ -688,55 +690,60 @@ bool checkXeddsaReceivePolicy(meshtastic_MeshPacket *p)
}
#endif

RoutingAuthVerdict passesRoutingAuthGate(meshtastic_MeshPacket *p)
static RoutingAuthVerdict evaluateRoutingAuth(const meshtastic_MeshPacket &wire, meshtastic_MeshPacket &authenticated)
{
// Routing still needs the original encrypted representation for byte-for-byte relay and for
// MQTT uplink. Authenticate a copy here; handleReceived() performs the normal in-place decode
// only after stateful routing filters have completed.
if (routingAuthCacheMatches(*p))
return RoutingAuthVerdict::ACCEPT;

meshtastic_MeshPacket wire = *p;
meshtastic_MeshPacket authCandidate = *p;
authenticated = wire;
routingAuthEvaluations++;
if (authCandidate.which_payload_variant == meshtastic_MeshPacket_decoded_tag) {
if (authenticated.which_payload_variant == meshtastic_MeshPacket_decoded_tag) {
// Already-decoded remote ingress (notably Portduino SimRadio) did not pass through a
// decryptor. Never trust serialized local authentication metadata on that boundary.
authCandidate.pki_encrypted = false;
authCandidate.public_key.size = 0;
authenticated.pki_encrypted = false;
authenticated.public_key.size = 0;
authenticated.xeddsa_signed = false;
#if !(MESHTASTIC_EXCLUDE_PKI) && !(MESHTASTIC_EXCLUDE_XEDDSA)
concurrency::LockGuard g(cryptLock);
if (!checkXeddsaReceivePolicy(&authCandidate)) {
LOG_WARN("Already-decoded packet rejected by signature policy");
if (!checkXeddsaReceivePolicy(&authenticated)) {
LOG_WARN("Already-decoded packet rejected by routing signature policy");
return RoutingAuthVerdict::REJECT;
}
#endif
p->xeddsa_signed = authCandidate.xeddsa_signed;
wire = *p;
storeRoutingAuthCache(wire, authCandidate);
return RoutingAuthVerdict::ACCEPT;
}
const DecodeState state = perhapsDecode(&authCandidate);
const DecodeState state = perhapsDecode(&authenticated);
if (state == DecodeState::DECODE_POLICY_REJECT) {
LOG_WARN("Packet rejected by signature policy");
LOG_WARN("Packet rejected by routing signature policy");
return RoutingAuthVerdict::REJECT;
}
if (state == DecodeState::DECODE_FATAL) {
LOG_WARN("Fatal decode error, dropping packet");
LOG_WARN("Fatal decode error during routing authentication");
return RoutingAuthVerdict::REJECT;
}
if (state == DecodeState::DECODE_FAILURE) {
LOG_WARN("Decryptable packet failed decoding, dropping packet");
LOG_WARN("Decryptable packet failed routing authentication");
return RoutingAuthVerdict::REJECT;
}

// Only an explicit unknown-channel result remains eligible for opaque relay.
if (state == DecodeState::DECODE_OPAQUE)
return RoutingAuthVerdict::OPAQUE_RELAY_ONLY;
storeRoutingAuthCache(wire, authCandidate);
return RoutingAuthVerdict::ACCEPT;
}

RoutingAuthVerdict passesRoutingAuthGate(const meshtastic_MeshPacket *p)
{
// Routing still needs the original encrypted representation for byte-for-byte relay and for
// MQTT uplink. Authenticate a copy here; handleReceived() performs the normal in-place decode
// only after stateful routing filters have completed.
if (routingAuthCacheMatches(*p))
return RoutingAuthVerdict::ACCEPT;

meshtastic_MeshPacket authenticated = meshtastic_MeshPacket_init_zero;
const RoutingAuthVerdict verdict = evaluateRoutingAuth(*p, authenticated);
if (verdict == RoutingAuthVerdict::ACCEPT)
storeRoutingAuthCache(*p, authenticated);
return verdict;
}

#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.
Expand Down Expand Up @@ -801,6 +808,7 @@ DecodeState perhapsDecode(meshtastic_MeshPacket *p)
// Authentication metadata is local-only. Re-establish it below only after successful PKI decryption.
p->pki_encrypted = false;
p->public_key.size = 0;
p->xeddsa_signed = false;

size_t rawSize = p->encrypted.size;
if (rawSize > sizeof(bytes)) {
Expand All @@ -813,8 +821,8 @@ DecodeState perhapsDecode(meshtastic_MeshPacket *p)
ChannelIndex chIndex = 0;
#if !(MESHTASTIC_EXCLUDE_PKI)
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) {
if (!owner.is_licensed && 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) {
pkiAttempted = true;
LOG_DEBUG("Attempt PKI decryption");
// Resolve the sender's key only for actual PKI-decrypt candidates, not every encrypted channel
Expand Down Expand Up @@ -1063,12 +1071,12 @@ meshtastic_Routing_Error perhapsEncode(meshtastic_MeshPacket *p)
// verification at every XEdDSA-enabled receiver that knows our key.
p->decoded.xeddsa_signature.size = 0;
#if !(MESHTASTIC_EXCLUDE_PKI) && !(MESHTASTIC_EXCLUDE_XEDDSA)
// Sign broadcast packets when the Data still fits a LoRa frame with the signature
// attached. This must be the exact encoded-size criterion, not a payload-size
// heuristic: a heuristic band where we sign-then-fail-TOO_LARGE breaks packets that
// Licensed packets stay plaintext, so sign both broadcasts and unicasts. Normal mode
// continues to sign broadcasts only. Use the exact encoded size: a payload-size heuristic
// where we sign-then-fail-TOO_LARGE breaks packets that
// were deliverable unsigned, and perhapsDecode() applies the mirror-image rule when
// deciding whether an unsigned broadcast from a known signer is a downgrade.
if (!p->pki_encrypted && isBroadcast(p->to) && signedDataFits(&p->decoded)) {
if (!p->pki_encrypted && (owner.is_licensed || isBroadcast(p->to)) && signedDataFits(&p->decoded)) {
if (crypto->xeddsa_sign(p->from, p->id, p->decoded.portnum, p->decoded.payload.bytes, p->decoded.payload.size,
p->decoded.xeddsa_signature.bytes)) {
p->decoded.xeddsa_signature.size = XEDDSA_SIGNATURE_SIZE;
Expand Down Expand Up @@ -1205,7 +1213,7 @@ NodeNum Router::getNodeNum()
* Handle any packet that is received by an interface on this node.
* Note: some packets may merely being passed through this node and will be forwarded elsewhere.
*/
void Router::handleReceived(meshtastic_MeshPacket *p, RxSource src)
void Router::handleReceived(meshtastic_MeshPacket *p, RxSource src, bool routingAuthRequired)
{
bool skipHandle = false;

Expand All @@ -1220,8 +1228,15 @@ void Router::handleReceived(meshtastic_MeshPacket *p, RxSource src)

// Consume the decoded/authenticated handoff after preserving the exact encrypted packet and
// before mutating any packet fields that participate in the exact cache match.
if (src == RX_SRC_RADIO)
applyRoutingAuthCache(p);
if (routingAuthRequired && !applyRoutingAuthCache(p)) {
meshtastic_MeshPacket authenticated = meshtastic_MeshPacket_init_zero;
if (evaluateRoutingAuth(*p, authenticated) != RoutingAuthVerdict::ACCEPT) {
LOG_WARN("Routing authentication handoff was lost and packet re-verification failed");
packetPool.release(p_encrypted);
return;
}
*p = authenticated;
}

// Also, we should set the time from the ISR and it should have msec level resolution.
// Keep the decoded working packet and encrypted MQTT copy on the same local arrival timestamp.
Expand Down Expand Up @@ -1425,6 +1440,6 @@ void Router::perhapsHandleReceived(meshtastic_MeshPacket *p)

// Note: we avoid calling shouldFilterReceived if we are supposed to ignore certain nodes - because some overrides might
// cache/learn of the existence of nodes (i.e. FloodRouter) that they should not
handleReceived(p);
handleReceived(p, RX_SRC_RADIO, true);
packetPool.release(p);
}
8 changes: 6 additions & 2 deletions src/mesh/Router.h
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,10 @@ class Router : protected concurrency::OSThread, protected PacketHistory
before us */
uint32_t rxDupe = 0, txRelayCanceled = 0;

#ifdef PIO_UNIT_TESTING
void handleReceivedAfterRoutingGateForTest(meshtastic_MeshPacket *p) { handleReceived(p, RX_SRC_RADIO, true); }
#endif

protected:
friend class RoutingModule;

Expand Down Expand Up @@ -158,7 +162,7 @@ class Router : protected concurrency::OSThread, protected PacketHistory
* Note: this packet will never be called for messages sent/generated by this node.
* Note: this method will free the provided packet.
*/
void handleReceived(meshtastic_MeshPacket *p, RxSource src = RX_SRC_RADIO);
void handleReceived(meshtastic_MeshPacket *p, RxSource src = RX_SRC_RADIO, bool routingAuthRequired = false);

/** Frees the provided packet, and generates a NAK indicating the specifed error while sending */
void abortSendAndNak(meshtastic_Routing_Error err, meshtastic_MeshPacket *p);
Expand All @@ -175,7 +179,7 @@ enum class RoutingAuthVerdict { ACCEPT, OPAQUE_RELAY_ONLY, REJECT };
DecodeState perhapsDecode(meshtastic_MeshPacket *p);

/** Apply receive authentication before routing state mutation; unknown-channel packets may remain opaque relay-only. */
RoutingAuthVerdict passesRoutingAuthGate(meshtastic_MeshPacket *p);
RoutingAuthVerdict passesRoutingAuthGate(const meshtastic_MeshPacket *p);
#ifdef PIO_UNIT_TESTING
uint32_t routingAuthEvaluationCount();
void resetRoutingAuthEvaluationCount();
Expand Down
Loading
Loading