Skip to content

Commit 1e982fa

Browse files
RCGV1thebenternvidplace7
authored
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>
1 parent 45cb8e7 commit 1e982fa

15 files changed

Lines changed: 576 additions & 49 deletions

File tree

src/graphics/draw/MenuHandler.cpp

Lines changed: 7 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -285,21 +285,19 @@ void menuHandler::LoraRegionPicker(uint32_t duration)
285285
return;
286286
}
287287

288-
// Guard: without a reboot, reconfigure() applies the region directly, so reject
289-
// regions this node can't use up front: unrecognized codes, licensed-only regions,
290-
// and radio hardware mismatches (2.4 GHz vs sub-GHz) - the same checks the admin
291-
// set-config path applies, but side-effect-free: ignoring a menu selection should
292-
// not record a critical error or notify clients. getRadio() used to catch hardware
293-
// mismatches post-reboot only.
288+
const RegionInfo *selectedRegionInfo = getRegion(selectedRegion);
289+
bool hamMode = selectedRegionInfo->code == selectedRegion && selectedRegionInfo->profile &&
290+
selectedRegionInfo->profile->licensedOnly;
291+
292+
// Validate radio compatibility for a prospective Ham region before confirmation.
294293
auto candidateLora = config.lora;
295294
candidateLora.region = selectedRegion;
296-
char regionErr[160];
297-
if (!RadioInterface::checkConfigRegion(candidateLora, regionErr, sizeof(regionErr))) {
295+
char regionErr[160] = {};
296+
if (!RadioInterface::checkConfigRegion(candidateLora, regionErr, sizeof(regionErr), hamMode)) {
298297
LOG_WARN("Ignoring region selection: %s", regionErr);
299298
return;
300299
}
301300

302-
bool hamMode = getRegion(selectedRegion)->profile->licensedOnly;
303301
if (hamMode) {
304302
LOG_INFO("User chose an amateur radio mode region");
305303
pendingRegion = selectedRegion;

src/main.cpp

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1078,6 +1078,7 @@ void setup()
10781078
nodeDB->hasWarned = true;
10791079
}
10801080
#endif
1081+
nodeDB->notifyPendingLicensedIdentityMigration();
10811082
#if !MESHTASTIC_EXCLUDE_INPUTBROKER
10821083
if (inputBroker)
10831084
inputBroker->Init();

src/mesh/Channels.cpp

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -128,11 +128,13 @@ bool Channels::ensureLicensedOperation()
128128
}
129129
auto &channelSettings = channel.settings;
130130
if (strcasecmp(channelSettings.name, Channels::adminChannel) == 0) {
131-
channel.role = meshtastic_Channel_Role_DISABLED;
132-
channelSettings.psk.bytes[0] = 0;
133-
channelSettings.psk.size = 0;
134-
hasEncryptionOrAdmin = true;
135-
channels.setChannel(channel);
131+
if (channel.role != meshtastic_Channel_Role_DISABLED || channelSettings.psk.size > 0) {
132+
channel.role = meshtastic_Channel_Role_DISABLED;
133+
channelSettings.psk.bytes[0] = 0;
134+
channelSettings.psk.size = 0;
135+
hasEncryptionOrAdmin = true;
136+
channels.setChannel(channel);
137+
}
136138

137139
} else if (channelSettings.psk.size > 0) {
138140
channelSettings.psk.bytes[0] = 0;
@@ -563,4 +565,4 @@ bool Channels::setDefaultPresetCryptoForHash(ChannelHash channelHash)
563565
int16_t Channels::setActiveByIndex(ChannelIndex channelIndex)
564566
{
565567
return setCrypto(channelIndex);
566-
}
568+
}

src/mesh/NodeDB.cpp

Lines changed: 33 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -832,7 +832,7 @@ void NodeDB::installDefaultNodeDatabase()
832832
void NodeDB::installDefaultConfig(bool preserveKey = false)
833833
{
834834
uint8_t private_key_temp[32];
835-
bool shouldPreserveKey = preserveKey && config.has_security && config.security.private_key.size > 0;
835+
bool shouldPreserveKey = preserveKey && config.has_security && config.security.private_key.size == 32;
836836
if (shouldPreserveKey) {
837837
memcpy(private_key_temp, config.security.private_key.bytes, config.security.private_key.size);
838838
}
@@ -2722,6 +2722,11 @@ void NodeDB::loadFromDisk()
27222722
moduleConfig.version = POSITION_TELEMETRY_OPTIN_VER;
27232723
saveToDisk(SEGMENT_MODULECONFIG);
27242724
}
2725+
2726+
if (channels.ensureLicensedOperation()) {
2727+
LOG_WARN("Licensed operation removed persisted channel encryption/admin access");
2728+
saveToDisk(SEGMENT_CHANNELS);
2729+
}
27252730
#if ARCH_PORTDUINO
27262731
// set any config overrides
27272732
if (portduino_config.has_configDisplayMode) {
@@ -4161,15 +4166,14 @@ bool NodeDB::checkLowEntropyPublicKey(const meshtastic_Config_SecurityConfig_pub
41614166
bool NodeDB::generateCryptoKeyPair(const uint8_t *privateKey)
41624167
{
41634168
#if !(MESHTASTIC_EXCLUDE_PKI_KEYGEN || MESHTASTIC_EXCLUDE_PKI)
4164-
// Only generate keys for non-licensed users and if the LoRa region is set. The native simulator
4165-
// boots region-UNSET but still needs a keypair so PKI-encrypted DMs work between sim nodes, so
4166-
// allow keygen there regardless of region.
4169+
// Generate identity keys once a LoRa region is set. Licensed operation still needs the identity
4170+
// key for plaintext signatures, even though the key is never used for PKI encryption.
41674171
bool regionBlocksKeygen = config.lora.region == meshtastic_Config_LoRaConfig_RegionCode_UNSET;
41684172
#if ARCH_PORTDUINO
41694173
if (portduino_config.lora_module == use_simradio)
41704174
regionBlocksKeygen = false;
41714175
#endif
4172-
if (owner.is_licensed || regionBlocksKeygen) {
4176+
if (regionBlocksKeygen) {
41734177
return false;
41744178
}
41754179

@@ -4219,15 +4223,30 @@ bool NodeDB::generateCryptoKeyPair(const uint8_t *privateKey)
42194223
LOG_DEBUG("Set DH private key for crypto operations");
42204224
crypto->setDHPrivateKey(config.security.private_key.bytes);
42214225

4222-
// Conditionally create new identity based on parameter
4223-
createNewIdentity();
4226+
if (createNewIdentity() && owner.is_licensed)
4227+
licensedIdentityMigrationPending = true;
42244228
}
42254229
return keygenSuccess;
42264230
#else
42274231
return false;
42284232
#endif
42294233
}
42304234

4235+
bool NodeDB::notifyPendingLicensedIdentityMigration()
4236+
{
4237+
if (!licensedIdentityMigrationPending || !service)
4238+
return false;
4239+
meshtastic_ClientNotification *notification = clientNotificationPool.allocZeroed();
4240+
if (!notification)
4241+
return false;
4242+
notification->level = meshtastic_LogRecord_Level_WARNING;
4243+
notification->time = getValidTime(RTCQualityFromNet);
4244+
snprintf(notification->message, sizeof(notification->message), "%s", LICENSED_IDENTITY_MIGRATION_WARNING);
4245+
service->sendClientNotification(notification);
4246+
licensedIdentityMigrationPending = false;
4247+
return true;
4248+
}
4249+
42314250
bool NodeDB::createNewIdentity()
42324251
{
42334252
uint32_t oldNodeNum = getNodeNum();
@@ -4331,6 +4350,13 @@ bool NodeDB::restorePreferences(meshtastic_AdminMessage_BackupLocation location,
43314350
LOG_DEBUG("Restored channels");
43324351
}
43334352

4353+
if (owner.is_licensed && channels.ensureLicensedOperation()) {
4354+
restoreWhat |= SEGMENT_CHANNELS;
4355+
LOG_WARN("Licensed operation sanitized restored channel encryption/admin access");
4356+
}
4357+
if (restoreWhat & SEGMENT_CHANNELS)
4358+
channels.onConfigChanged();
4359+
43344360
success = saveToDisk(restoreWhat);
43354361
if (success) {
43364362
LOG_INFO("Restored preferences from backup");

src/mesh/NodeDB.h

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,8 @@ static const uint8_t LOW_ENTROPY_HASHES[][32] = {
7474
0x37, 0x82, 0x8d, 0xb2, 0xcc, 0xd8, 0x97, 0x40, 0x9a, 0x5c, 0x8f, 0x40, 0x55, 0xcb, 0x4c, 0x3e}};
7575
static const char LOW_ENTROPY_WARNING[] = "Compromised keys were detected and regenerated.";
7676
#endif
77+
static const char LICENSED_IDENTITY_MIGRATION_WARNING[] =
78+
"Licensed signing generated a new identity key; this node identity changed.";
7779
/*
7880
DeviceState versions used to be defined in the .proto file but really only this function cares. So changed to a
7981
#define here.
@@ -267,6 +269,7 @@ class NodeDB
267269

268270
bool keyIsLowEntropy = false;
269271
bool hasWarned = false;
272+
bool licensedIdentityMigrationPending = false;
270273

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

557+
bool notifyPendingLicensedIdentityMigration();
558+
554559
bool createNewIdentity();
555560

556561
bool backupPreferences(meshtastic_AdminMessage_BackupLocation location);
@@ -634,6 +639,7 @@ class NodeDB
634639
// Grant the unit-test shim access to the private maintenance paths below
635640
// (migration / cleanup / eviction) without relaxing production access.
636641
friend class NodeDBTestShim;
642+
friend class MockNodeDB;
637643
#endif
638644

639645
/// purge db entries without user info

src/mesh/RadioInterface.cpp

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1022,7 +1022,8 @@ const RegionInfo *RadioInterface::regionSwapForPreset(meshtastic_Config_LoRaConf
10221022
* receives the human-readable failure reason.
10231023
* Returns false if not compatible.
10241024
*/
1025-
bool RadioInterface::checkConfigRegion(const meshtastic_Config_LoRaConfig &loraConfig, char *errBuf, size_t errLen)
1025+
bool RadioInterface::checkConfigRegion(const meshtastic_Config_LoRaConfig &loraConfig, char *errBuf, size_t errLen,
1026+
bool prospectiveLicensedOwner)
10261027
{
10271028
const RegionInfo *newRegion = getRegion(loraConfig.region);
10281029

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

10361037
// If you are not licensed, you can't use ham regions.
1037-
if (newRegion->profile->licensedOnly && !devicestate.owner.is_licensed) {
1038+
if (newRegion->profile->licensedOnly && !devicestate.owner.is_licensed && !prospectiveLicensedOwner) {
10381039
if (errBuf)
10391040
snprintf(errBuf, errLen, "Region %s requires licensed mode", newRegion->name);
10401041
return false;

src/mesh/RadioInterface.h

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -256,8 +256,10 @@ class RadioInterface
256256
static bool checkOrClampConfigLora(meshtastic_Config_LoRaConfig &loraConfig, bool clamp);
257257

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

262264
// Check if a candidate region is compatible and valid. On failure, logs at ERROR,
263265
// records a critical error, and sends a client notification.

src/mesh/Router.cpp

Lines changed: 13 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -810,12 +810,17 @@ DecodeState perhapsDecode(meshtastic_MeshPacket *p)
810810
}
811811
bool decrypted = false;
812812
bool pkiAttempted = false;
813+
bool licensedPkiCandidate = false;
813814
bool matchedChannel = false;
814815
ChannelIndex chIndex = 0;
815816
#if !(MESHTASTIC_EXCLUDE_PKI)
816817
meshtastic_NodeInfoLite *ourNode = nullptr;
817-
if (p->channel == 0 && isToUs(p) && p->to > 0 && !isBroadcast(p->to) && rawSize > MESHTASTIC_PKC_OVERHEAD &&
818-
(ourNode = nodeDB->getMeshNode(p->to)) != nullptr && ourNode->public_key.size > 0) {
818+
const bool pkiCandidate = p->channel == 0 && isToUs(p) && p->to > 0 && !isBroadcast(p->to) &&
819+
rawSize > MESHTASTIC_PKC_OVERHEAD && (ourNode = nodeDB->getMeshNode(p->to)) != nullptr &&
820+
ourNode->public_key.size > 0;
821+
if (pkiCandidate && owner.is_licensed) {
822+
licensedPkiCandidate = true;
823+
} else if (pkiCandidate) {
819824
pkiAttempted = true;
820825
LOG_DEBUG("Attempt PKI decryption");
821826
// Resolve the sender's key only for actual PKI-decrypt candidates, not every encrypted channel
@@ -998,7 +1003,8 @@ DecodeState perhapsDecode(meshtastic_MeshPacket *p)
9981003
return DecodeState::DECODE_SUCCESS;
9991004
} else {
10001005
LOG_WARN("No suitable channel found for decoding, hash was 0x%x!", p->channel);
1001-
return (matchedChannel || pkiAttempted) ? DecodeState::DECODE_FAILURE : DecodeState::DECODE_OPAQUE;
1006+
return (matchedChannel || pkiAttempted || licensedPkiCandidate) ? DecodeState::DECODE_FAILURE
1007+
: DecodeState::DECODE_OPAQUE;
10021008
}
10031009
}
10041010

@@ -1064,12 +1070,12 @@ meshtastic_Routing_Error perhapsEncode(meshtastic_MeshPacket *p)
10641070
// verification at every XEdDSA-enabled receiver that knows our key.
10651071
p->decoded.xeddsa_signature.size = 0;
10661072
#if !(MESHTASTIC_EXCLUDE_PKI) && !(MESHTASTIC_EXCLUDE_XEDDSA)
1067-
// Sign broadcast packets when the Data still fits a LoRa frame with the signature
1068-
// attached. This must be the exact encoded-size criterion, not a payload-size
1069-
// heuristic: a heuristic band where we sign-then-fail-TOO_LARGE breaks packets that
1073+
// Licensed packets stay plaintext, so sign both broadcasts and unicasts. Normal mode
1074+
// continues to sign broadcasts only. Use the exact encoded size: a payload-size heuristic
1075+
// where we sign-then-fail-TOO_LARGE breaks packets that
10701076
// were deliverable unsigned, and perhapsDecode() applies the mirror-image rule when
10711077
// deciding whether an unsigned broadcast from a known signer is a downgrade.
1072-
if (!p->pki_encrypted && isBroadcast(p->to) && signedDataFits(&p->decoded)) {
1078+
if (!p->pki_encrypted && (owner.is_licensed || isBroadcast(p->to)) && signedDataFits(&p->decoded)) {
10731079
if (crypto->xeddsa_sign(p->from, p->id, p->decoded.portnum, p->decoded.payload.bytes, p->decoded.payload.size,
10741080
p->decoded.xeddsa_signature.bytes)) {
10751081
p->decoded.xeddsa_signature.size = XEDDSA_SIGNATURE_SIZE;

0 commit comments

Comments
 (0)