Sign plaintext packets in licensed mode (#10969)

* feat: sign licensed plaintext packets

Preserve and publish identity keys in licensed mode so existing XEdDSA signatures can authenticate plaintext traffic. Sign licensed broadcasts and direct messages when they fit, while keeping PKI encryption disabled and normal routing behavior unchanged.

* fix(security): persist licensed channel sanitation

* fix(security): close licensed migration lifecycle gaps

* fix(security): address licensed signing review feedback

* test: restore signing globals from Unity teardown

* fix(baseui): allow confirmed ham region selection

* fix(baseui): guard region picker validation

* style: trunk fmt

---------

Co-authored-by: Ben Meadors <benmmeadors@gmail.com>
Co-authored-by: Austin <vidplace7@gmail.com>
This commit is contained in:
Benjamin Faershtein
2026-07-27 02:57:15 +00:00
committed by GitHub
co-authored by Ben Meadors Austin
parent 45cb8e7500
commit 1e982fa78c
15 changed files with 576 additions and 49 deletions
+58 -5
View File
@@ -11,6 +11,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
@@ -70,6 +71,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.
@@ -765,6 +777,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
@@ -783,15 +797,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;
@@ -801,7 +828,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));
}
}
@@ -1004,7 +1032,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
@@ -1017,6 +1045,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
initRegion();
if (getEffectiveDutyCycle() < 100) {
validatedLora.ignore_mqtt = true; // Ignore MQTT by default if region has a duty cycle limit
@@ -1025,7 +1064,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;
@@ -1838,6 +1877,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
@@ -1891,6 +1933,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();
}
+7 -1
View File
@@ -40,6 +40,9 @@ class AdminModule : public ProtobufModule<meshtastic_AdminMessage>, public Obser
private:
bool hasOpenEditTransaction = false;
#ifdef PIO_UNIT_TESTING
int lastSaveWhatForTest = 0;
#endif
uint8_t session_passkey[8] = {0};
uint32_t session_time = 0; // millis() when the current session passkey was issued
@@ -155,9 +158,12 @@ class AdminModule : public ProtobufModule<meshtastic_AdminMessage>, public Obser
static constexpr const char *licensedModeMessage =
"Licensed mode activated, removing admin channel and encryption from all channels";
static constexpr const char *licensedIdentityMigrationMessage =
"Licensed signing requires an identity key; this node identity will change after key generation";
static constexpr const char *publicChannelPrecisionMessage =
"Precise position is not allowed on a public (open / known-key) channel; reduced to coarse precision";
extern AdminModule *adminModule;
void disableBluetooth();
void disableBluetooth();
+2 -8
View File
@@ -169,14 +169,8 @@ meshtastic_MeshPacket *NodeInfoModule::allocReply()
ignoreRequest = true;
return NULL;
} else {
ignoreRequest = false; // Don't ignore requests anymore
meshtastic_User u = owner; // deliberate copy: the licensed strip below must not clobber the global owner state
// Strip the public key if the user is licensed
if (u.is_licensed && u.public_key.size > 0) {
memset(u.public_key.bytes, 0, sizeof(u.public_key.bytes));
u.public_key.size = 0;
}
ignoreRequest = false; // Don't ignore requests anymore
meshtastic_User u = owner;
// FIXME: Clear the user.id field since it should be derived from node number on the receiving end
// u.id[0] = '\0';