Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 7 additions & 9 deletions src/graphics/draw/MenuHandler.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -285,21 +285,19 @@ void menuHandler::LoraRegionPicker(uint32_t duration)
return;
}

// Guard: without a reboot, reconfigure() applies the region directly, so reject
// regions this node can't use up front: unrecognized codes, licensed-only regions,
// and radio hardware mismatches (2.4 GHz vs sub-GHz) - the same checks the admin
// set-config path applies, but side-effect-free: ignoring a menu selection should
// not record a critical error or notify clients. getRadio() used to catch hardware
// mismatches post-reboot only.
const RegionInfo *selectedRegionInfo = getRegion(selectedRegion);
bool hamMode = selectedRegionInfo->code == selectedRegion && selectedRegionInfo->profile &&
selectedRegionInfo->profile->licensedOnly;

// Validate radio compatibility for a prospective Ham region before confirmation.
auto candidateLora = config.lora;
candidateLora.region = selectedRegion;
char regionErr[160];
if (!RadioInterface::checkConfigRegion(candidateLora, regionErr, sizeof(regionErr))) {
char regionErr[160] = {};
if (!RadioInterface::checkConfigRegion(candidateLora, regionErr, sizeof(regionErr), hamMode)) {
Comment thread
coderabbitai[bot] marked this conversation as resolved.
LOG_WARN("Ignoring region selection: %s", regionErr);
return;
}

bool hamMode = getRegion(selectedRegion)->profile->licensedOnly;
if (hamMode) {
LOG_INFO("User chose an amateur radio mode region");
pendingRegion = selectedRegion;
Expand Down
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);
}
}
40 changes: 33 additions & 7 deletions src/mesh/NodeDB.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -822,7 +822,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 @@ -2586,6 +2586,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 @@ -3854,15 +3859,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 @@ -3912,15 +3916,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 @@ -4024,6 +4043,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 @@ -474,6 +477,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 @@ -548,6 +553,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
5 changes: 3 additions & 2 deletions src/mesh/RadioInterface.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -1022,7 +1022,8 @@ const RegionInfo *RadioInterface::regionSwapForPreset(meshtastic_Config_LoRaConf
* receives the human-readable failure reason.
* Returns false if not compatible.
*/
bool RadioInterface::checkConfigRegion(const meshtastic_Config_LoRaConfig &loraConfig, char *errBuf, size_t errLen)
bool RadioInterface::checkConfigRegion(const meshtastic_Config_LoRaConfig &loraConfig, char *errBuf, size_t errLen,
bool prospectiveLicensedOwner)
{
const RegionInfo *newRegion = getRegion(loraConfig.region);

Expand All @@ -1034,7 +1035,7 @@ bool RadioInterface::checkConfigRegion(const meshtastic_Config_LoRaConfig &loraC
}

// If you are not licensed, you can't use ham regions.
if (newRegion->profile->licensedOnly && !devicestate.owner.is_licensed) {
if (newRegion->profile->licensedOnly && !devicestate.owner.is_licensed && !prospectiveLicensedOwner) {
if (errBuf)
snprintf(errBuf, errLen, "Region %s requires licensed mode", newRegion->name);
return false;
Expand Down
6 changes: 4 additions & 2 deletions src/mesh/RadioInterface.h
Original file line number Diff line number Diff line change
Expand Up @@ -256,8 +256,10 @@ class RadioInterface
static bool checkOrClampConfigLora(meshtastic_Config_LoRaConfig &loraConfig, bool clamp);

// Check if a candidate region is compatible and valid, with no side effects (safe for
// speculative UI checks). errBuf, if given, receives the failure reason.
static bool checkConfigRegion(const meshtastic_Config_LoRaConfig &loraConfig, char *errBuf = nullptr, size_t errLen = 0);
// speculative UI checks). prospectiveLicensedOwner is for a UI flow that requires
// confirmation before it sets the owner licensed. errBuf, if given, receives the failure reason.
static bool checkConfigRegion(const meshtastic_Config_LoRaConfig &loraConfig, char *errBuf = nullptr, size_t errLen = 0,
bool prospectiveLicensedOwner = false);

// Check if a candidate region is compatible and valid. On failure, logs at ERROR,
// records a critical error, and sends a client notification.
Expand Down
11 changes: 6 additions & 5 deletions src/mesh/Router.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -530,7 +530,8 @@ DecodeState perhapsDecode(meshtastic_MeshPacket *p)
bool haveRemoteKey = nodeDB->copyPublicKey(p->from, remotePublic) || crypto->getPendingPublicKey(p->from, remotePublic);

meshtastic_NodeInfoLite *ourNode = nullptr;
if (p->channel == 0 && isToUs(p) && p->to > 0 && !isBroadcast(p->to) && rawSize > MESHTASTIC_PKC_OVERHEAD &&
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) {
// 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.
Expand Down Expand Up @@ -722,12 +723,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
63 changes: 58 additions & 5 deletions src/modules/AdminModule.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
#include "gps/RTC.h"
#include "input/InputBroker.h"
#include "meshUtils.h"
#include <ErriezCRC32.h>
#include <FSCommon.h>
#include <Throttle.h>
#include <ctype.h> // for better whitespace handling
Expand Down Expand Up @@ -69,6 +70,17 @@
AdminModule *adminModule;
bool hasOpenEditTransaction;

#if !(MESHTASTIC_EXCLUDE_PKI_KEYGEN || MESHTASTIC_EXCLUDE_PKI)
static bool licensedIdentityWillMigrate()
{
if (config.security.private_key.size != 32 || config.security.public_key.size != 32)
return true;
if (nodeDB->checkLowEntropyPublicKey(config.security.public_key))
return true;
return crc32Buffer(config.security.public_key.bytes, config.security.public_key.size) != nodeDB->getNodeNum();
}
#endif

/// A special reserved string to indicate strings we can not share with external nodes. We will use this 'reserved' word instead.
/// Also, to make setting work correctly, if someone tries to set a string to this reserved value we assume they don't really want
/// a change.
Expand Down Expand Up @@ -754,6 +766,8 @@ void AdminModule::handleGetModuleConfigResponse(const meshtastic_MeshPacket &mp,
void AdminModule::handleSetOwner(const meshtastic_User &o)
{
int changed = 0;
bool identityUpdated = false;
bool channelsSanitized = false;

if (*o.long_name) {
// Apps built against the older 39-byte limit may send longer names; clamp
Expand All @@ -772,15 +786,28 @@ void AdminModule::handleSetOwner(const meshtastic_User &o)
owner.short_name[sizeof(owner.short_name) - 1] = '\0';
sanitizeUtf8(owner.short_name, sizeof(owner.short_name));
}
snprintf(owner.id, sizeof(owner.id), "!%08x", nodeDB->getNodeNum());

if (owner.is_licensed != o.is_licensed) {
changed = 1;
#if !(MESHTASTIC_EXCLUDE_PKI_KEYGEN || MESHTASTIC_EXCLUDE_PKI)
const bool identityWillMigrate =
o.is_licensed && config.lora.region != meshtastic_Config_LoRaConfig_RegionCode_UNSET && licensedIdentityWillMigrate();
if (identityWillMigrate)
sendWarning(licensedIdentityMigrationMessage);
#endif
owner.is_licensed = o.is_licensed;
if (channels.ensureLicensedOperation()) {
warnLicensedMode();
channelsSanitized = true;
}
#if !(MESHTASTIC_EXCLUDE_PKI_KEYGEN || MESHTASTIC_EXCLUDE_PKI)
if (owner.is_licensed && config.lora.region != meshtastic_Config_LoRaConfig_RegionCode_UNSET) {
identityUpdated = nodeDB->generateCryptoKeyPair();
if (identityWillMigrate)
nodeDB->licensedIdentityMigrationPending = false;
}
#endif
}
snprintf(owner.id, sizeof(owner.id), "!%08x", nodeDB->getNodeNum());
if (owner.has_is_unmessagable != o.has_is_unmessagable ||
(o.has_is_unmessagable && owner.is_unmessagable != o.is_unmessagable)) {
changed = 1;
Expand All @@ -790,7 +817,8 @@ void AdminModule::handleSetOwner(const meshtastic_User &o)

if (changed) { // If nothing really changed, don't broadcast on the network or write to flash
service->reloadOwner(!hasOpenEditTransaction);
saveChanges(SEGMENT_DEVICESTATE | SEGMENT_NODEDATABASE);
saveChanges(SEGMENT_DEVICESTATE | SEGMENT_NODEDATABASE | (identityUpdated ? SEGMENT_CONFIG : 0) |
(channelsSanitized ? SEGMENT_CHANNELS : 0));
}
}

Expand Down Expand Up @@ -972,7 +1000,7 @@ void AdminModule::handleSetConfig(const meshtastic_Config &c, bool fromOthers)
// If we're setting region for the first time, init the region and regenerate the keys
if (isRegionUnset && validatedLora.region > meshtastic_Config_LoRaConfig_RegionCode_UNSET) {
#if !(MESHTASTIC_EXCLUDE_PKI_KEYGEN || MESHTASTIC_EXCLUDE_PKI)
if (crypto) {
if (crypto && !owner.is_licensed) {
crypto->ensurePkiKeys(config.security, owner);
}
#endif
Expand All @@ -985,6 +1013,17 @@ void AdminModule::handleSetConfig(const meshtastic_Config &c, bool fromOthers)
}
// Ensure initRegion() uses the newly validated region
config.lora.region = validatedLora.region;
#if !(MESHTASTIC_EXCLUDE_PKI_KEYGEN || MESHTASTIC_EXCLUDE_PKI)
if (owner.is_licensed && isRegionUnset && validatedLora.region > meshtastic_Config_LoRaConfig_RegionCode_UNSET) {
const bool identityWillMigrate = licensedIdentityWillMigrate();
if (identityWillMigrate)
sendWarning(licensedIdentityMigrationMessage);
nodeDB->generateCryptoKeyPair();
changes |= SEGMENT_DEVICESTATE | SEGMENT_NODEDATABASE;
if (identityWillMigrate)
nodeDB->licensedIdentityMigrationPending = false;
}
#endif
Comment thread
coderabbitai[bot] marked this conversation as resolved.
initRegion();
if (getEffectiveDutyCycle() < 100) {
validatedLora.ignore_mqtt = true; // Ignore MQTT by default if region has a duty cycle limit
Expand All @@ -993,7 +1032,7 @@ void AdminModule::handleSetConfig(const meshtastic_Config &c, bool fromOthers)
// Default root is in use, so subscribe to the appropriate MQTT topic for this region
snprintf(moduleConfig.mqtt.root, sizeof(moduleConfig.mqtt.root), "%s/%s", default_mqtt_root, myRegion->name);
}
changes = SEGMENT_CONFIG | SEGMENT_MODULECONFIG;
changes |= SEGMENT_CONFIG | SEGMENT_MODULECONFIG;
} else {
// Region validation has failed, so just copy all of the old config over the new config
validatedLora = oldLoraConfig;
Expand Down Expand Up @@ -1743,6 +1782,9 @@ void AdminModule::reboot(int32_t seconds)

void AdminModule::saveChanges(int saveWhat, bool shouldReboot)
{
#ifdef PIO_UNIT_TESTING
lastSaveWhatForTest = saveWhat;
#endif
if (!hasOpenEditTransaction) {
LOG_INFO("Save changes to disk");
service->reloadConfig(saveWhat); // Calls saveToDisk among other things
Expand Down Expand Up @@ -1796,6 +1838,17 @@ void AdminModule::handleSetHamMode(const meshtastic_HamParameters &p)
config.device.rebroadcast_mode = meshtastic_Config_DeviceConfig_RebroadcastMode_LOCAL_ONLY;
// Remove PSK of primary channel for plaintext amateur usage

#if !(MESHTASTIC_EXCLUDE_PKI_KEYGEN || MESHTASTIC_EXCLUDE_PKI)
if (config.lora.region != meshtastic_Config_LoRaConfig_RegionCode_UNSET) {
const bool identityWillMigrate = licensedIdentityWillMigrate();
if (identityWillMigrate)
sendWarning(licensedIdentityMigrationMessage);
nodeDB->generateCryptoKeyPair();
if (identityWillMigrate)
nodeDB->licensedIdentityMigrationPending = false;
}
#endif

if (channels.ensureLicensedOperation()) {
warnLicensedMode();
}
Expand Down
Loading
Loading