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
+8 -6
View File
@@ -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;
@@ -563,4 +565,4 @@ bool Channels::setDefaultPresetCryptoForHash(ChannelHash channelHash)
int16_t Channels::setActiveByIndex(ChannelIndex channelIndex)
{
return setCrypto(channelIndex);
}
}
+33 -7
View File
@@ -832,7 +832,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);
}
@@ -2722,6 +2722,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) {
@@ -4161,15 +4166,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;
}
@@ -4219,8 +4223,8 @@ 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
@@ -4228,6 +4232,21 @@ bool NodeDB::generateCryptoKeyPair(const uint8_t *privateKey)
#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();
@@ -4331,6 +4350,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");
+6
View File
@@ -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.
@@ -267,6 +269,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
@@ -551,6 +554,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);
@@ -634,6 +639,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
+3 -2
View File
@@ -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);
@@ -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;
+4 -2
View File
@@ -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.
+13 -7
View File
@@ -810,12 +810,17 @@ DecodeState perhapsDecode(meshtastic_MeshPacket *p)
}
bool decrypted = false;
bool pkiAttempted = false;
bool licensedPkiCandidate = false;
bool matchedChannel = false;
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) {
const bool pkiCandidate = 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 (pkiCandidate && owner.is_licensed) {
licensedPkiCandidate = true;
} else if (pkiCandidate) {
pkiAttempted = true;
LOG_DEBUG("Attempt PKI decryption");
// Resolve the sender's key only for actual PKI-decrypt candidates, not every encrypted channel
@@ -998,7 +1003,8 @@ DecodeState perhapsDecode(meshtastic_MeshPacket *p)
return DecodeState::DECODE_SUCCESS;
} else {
LOG_WARN("No suitable channel found for decoding, hash was 0x%x!", p->channel);
return (matchedChannel || pkiAttempted) ? DecodeState::DECODE_FAILURE : DecodeState::DECODE_OPAQUE;
return (matchedChannel || pkiAttempted || licensedPkiCandidate) ? DecodeState::DECODE_FAILURE
: DecodeState::DECODE_OPAQUE;
}
}
@@ -1064,12 +1070,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;