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:
co-authored by
GitHub
Ben Meadors
Austin
parent
45cb8e7500
commit
1e982fa78c
@@ -285,21 +285,19 @@ void menuHandler::LoraRegionPicker(uint32_t duration)
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Guard: without a reboot, reconfigure() applies the region directly, so reject
|
const RegionInfo *selectedRegionInfo = getRegion(selectedRegion);
|
||||||
// regions this node can't use up front: unrecognized codes, licensed-only regions,
|
bool hamMode = selectedRegionInfo->code == selectedRegion && selectedRegionInfo->profile &&
|
||||||
// and radio hardware mismatches (2.4 GHz vs sub-GHz) - the same checks the admin
|
selectedRegionInfo->profile->licensedOnly;
|
||||||
// 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
|
// Validate radio compatibility for a prospective Ham region before confirmation.
|
||||||
// mismatches post-reboot only.
|
|
||||||
auto candidateLora = config.lora;
|
auto candidateLora = config.lora;
|
||||||
candidateLora.region = selectedRegion;
|
candidateLora.region = selectedRegion;
|
||||||
char regionErr[160];
|
char regionErr[160] = {};
|
||||||
if (!RadioInterface::checkConfigRegion(candidateLora, regionErr, sizeof(regionErr))) {
|
if (!RadioInterface::checkConfigRegion(candidateLora, regionErr, sizeof(regionErr), hamMode)) {
|
||||||
LOG_WARN("Ignoring region selection: %s", regionErr);
|
LOG_WARN("Ignoring region selection: %s", regionErr);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
bool hamMode = getRegion(selectedRegion)->profile->licensedOnly;
|
|
||||||
if (hamMode) {
|
if (hamMode) {
|
||||||
LOG_INFO("User chose an amateur radio mode region");
|
LOG_INFO("User chose an amateur radio mode region");
|
||||||
pendingRegion = selectedRegion;
|
pendingRegion = selectedRegion;
|
||||||
|
|||||||
@@ -1078,6 +1078,7 @@ void setup()
|
|||||||
nodeDB->hasWarned = true;
|
nodeDB->hasWarned = true;
|
||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
|
nodeDB->notifyPendingLicensedIdentityMigration();
|
||||||
#if !MESHTASTIC_EXCLUDE_INPUTBROKER
|
#if !MESHTASTIC_EXCLUDE_INPUTBROKER
|
||||||
if (inputBroker)
|
if (inputBroker)
|
||||||
inputBroker->Init();
|
inputBroker->Init();
|
||||||
|
|||||||
@@ -128,11 +128,13 @@ bool Channels::ensureLicensedOperation()
|
|||||||
}
|
}
|
||||||
auto &channelSettings = channel.settings;
|
auto &channelSettings = channel.settings;
|
||||||
if (strcasecmp(channelSettings.name, Channels::adminChannel) == 0) {
|
if (strcasecmp(channelSettings.name, Channels::adminChannel) == 0) {
|
||||||
channel.role = meshtastic_Channel_Role_DISABLED;
|
if (channel.role != meshtastic_Channel_Role_DISABLED || channelSettings.psk.size > 0) {
|
||||||
channelSettings.psk.bytes[0] = 0;
|
channel.role = meshtastic_Channel_Role_DISABLED;
|
||||||
channelSettings.psk.size = 0;
|
channelSettings.psk.bytes[0] = 0;
|
||||||
hasEncryptionOrAdmin = true;
|
channelSettings.psk.size = 0;
|
||||||
channels.setChannel(channel);
|
hasEncryptionOrAdmin = true;
|
||||||
|
channels.setChannel(channel);
|
||||||
|
}
|
||||||
|
|
||||||
} else if (channelSettings.psk.size > 0) {
|
} else if (channelSettings.psk.size > 0) {
|
||||||
channelSettings.psk.bytes[0] = 0;
|
channelSettings.psk.bytes[0] = 0;
|
||||||
@@ -563,4 +565,4 @@ bool Channels::setDefaultPresetCryptoForHash(ChannelHash channelHash)
|
|||||||
int16_t Channels::setActiveByIndex(ChannelIndex channelIndex)
|
int16_t Channels::setActiveByIndex(ChannelIndex channelIndex)
|
||||||
{
|
{
|
||||||
return setCrypto(channelIndex);
|
return setCrypto(channelIndex);
|
||||||
}
|
}
|
||||||
|
|||||||
+33
-7
@@ -832,7 +832,7 @@ void NodeDB::installDefaultNodeDatabase()
|
|||||||
void NodeDB::installDefaultConfig(bool preserveKey = false)
|
void NodeDB::installDefaultConfig(bool preserveKey = false)
|
||||||
{
|
{
|
||||||
uint8_t private_key_temp[32];
|
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) {
|
if (shouldPreserveKey) {
|
||||||
memcpy(private_key_temp, config.security.private_key.bytes, config.security.private_key.size);
|
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;
|
moduleConfig.version = POSITION_TELEMETRY_OPTIN_VER;
|
||||||
saveToDisk(SEGMENT_MODULECONFIG);
|
saveToDisk(SEGMENT_MODULECONFIG);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (channels.ensureLicensedOperation()) {
|
||||||
|
LOG_WARN("Licensed operation removed persisted channel encryption/admin access");
|
||||||
|
saveToDisk(SEGMENT_CHANNELS);
|
||||||
|
}
|
||||||
#if ARCH_PORTDUINO
|
#if ARCH_PORTDUINO
|
||||||
// set any config overrides
|
// set any config overrides
|
||||||
if (portduino_config.has_configDisplayMode) {
|
if (portduino_config.has_configDisplayMode) {
|
||||||
@@ -4161,15 +4166,14 @@ bool NodeDB::checkLowEntropyPublicKey(const meshtastic_Config_SecurityConfig_pub
|
|||||||
bool NodeDB::generateCryptoKeyPair(const uint8_t *privateKey)
|
bool NodeDB::generateCryptoKeyPair(const uint8_t *privateKey)
|
||||||
{
|
{
|
||||||
#if !(MESHTASTIC_EXCLUDE_PKI_KEYGEN || MESHTASTIC_EXCLUDE_PKI)
|
#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
|
// Generate identity keys once a LoRa region is set. Licensed operation still needs the identity
|
||||||
// boots region-UNSET but still needs a keypair so PKI-encrypted DMs work between sim nodes, so
|
// key for plaintext signatures, even though the key is never used for PKI encryption.
|
||||||
// allow keygen there regardless of region.
|
|
||||||
bool regionBlocksKeygen = config.lora.region == meshtastic_Config_LoRaConfig_RegionCode_UNSET;
|
bool regionBlocksKeygen = config.lora.region == meshtastic_Config_LoRaConfig_RegionCode_UNSET;
|
||||||
#if ARCH_PORTDUINO
|
#if ARCH_PORTDUINO
|
||||||
if (portduino_config.lora_module == use_simradio)
|
if (portduino_config.lora_module == use_simradio)
|
||||||
regionBlocksKeygen = false;
|
regionBlocksKeygen = false;
|
||||||
#endif
|
#endif
|
||||||
if (owner.is_licensed || regionBlocksKeygen) {
|
if (regionBlocksKeygen) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -4219,8 +4223,8 @@ bool NodeDB::generateCryptoKeyPair(const uint8_t *privateKey)
|
|||||||
LOG_DEBUG("Set DH private key for crypto operations");
|
LOG_DEBUG("Set DH private key for crypto operations");
|
||||||
crypto->setDHPrivateKey(config.security.private_key.bytes);
|
crypto->setDHPrivateKey(config.security.private_key.bytes);
|
||||||
|
|
||||||
// Conditionally create new identity based on parameter
|
if (createNewIdentity() && owner.is_licensed)
|
||||||
createNewIdentity();
|
licensedIdentityMigrationPending = true;
|
||||||
}
|
}
|
||||||
return keygenSuccess;
|
return keygenSuccess;
|
||||||
#else
|
#else
|
||||||
@@ -4228,6 +4232,21 @@ bool NodeDB::generateCryptoKeyPair(const uint8_t *privateKey)
|
|||||||
#endif
|
#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()
|
bool NodeDB::createNewIdentity()
|
||||||
{
|
{
|
||||||
uint32_t oldNodeNum = getNodeNum();
|
uint32_t oldNodeNum = getNodeNum();
|
||||||
@@ -4331,6 +4350,13 @@ bool NodeDB::restorePreferences(meshtastic_AdminMessage_BackupLocation location,
|
|||||||
LOG_DEBUG("Restored channels");
|
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);
|
success = saveToDisk(restoreWhat);
|
||||||
if (success) {
|
if (success) {
|
||||||
LOG_INFO("Restored preferences from backup");
|
LOG_INFO("Restored preferences from backup");
|
||||||
|
|||||||
@@ -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}};
|
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.";
|
static const char LOW_ENTROPY_WARNING[] = "Compromised keys were detected and regenerated.";
|
||||||
#endif
|
#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
|
DeviceState versions used to be defined in the .proto file but really only this function cares. So changed to a
|
||||||
#define here.
|
#define here.
|
||||||
@@ -267,6 +269,7 @@ class NodeDB
|
|||||||
|
|
||||||
bool keyIsLowEntropy = false;
|
bool keyIsLowEntropy = false;
|
||||||
bool hasWarned = false;
|
bool hasWarned = false;
|
||||||
|
bool licensedIdentityMigrationPending = false;
|
||||||
|
|
||||||
/// don't do mesh based algorithm for node id assignment (initially)
|
/// 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
|
/// 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.
|
/// @param privateKey Optional 32-byte private key to use. If nullptr, generates new random keys.
|
||||||
bool generateCryptoKeyPair(const uint8_t *privateKey = nullptr);
|
bool generateCryptoKeyPair(const uint8_t *privateKey = nullptr);
|
||||||
|
|
||||||
|
bool notifyPendingLicensedIdentityMigration();
|
||||||
|
|
||||||
bool createNewIdentity();
|
bool createNewIdentity();
|
||||||
|
|
||||||
bool backupPreferences(meshtastic_AdminMessage_BackupLocation location);
|
bool backupPreferences(meshtastic_AdminMessage_BackupLocation location);
|
||||||
@@ -634,6 +639,7 @@ class NodeDB
|
|||||||
// Grant the unit-test shim access to the private maintenance paths below
|
// Grant the unit-test shim access to the private maintenance paths below
|
||||||
// (migration / cleanup / eviction) without relaxing production access.
|
// (migration / cleanup / eviction) without relaxing production access.
|
||||||
friend class NodeDBTestShim;
|
friend class NodeDBTestShim;
|
||||||
|
friend class MockNodeDB;
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
/// purge db entries without user info
|
/// purge db entries without user info
|
||||||
|
|||||||
@@ -1022,7 +1022,8 @@ const RegionInfo *RadioInterface::regionSwapForPreset(meshtastic_Config_LoRaConf
|
|||||||
* receives the human-readable failure reason.
|
* receives the human-readable failure reason.
|
||||||
* Returns false if not compatible.
|
* 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);
|
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 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)
|
if (errBuf)
|
||||||
snprintf(errBuf, errLen, "Region %s requires licensed mode", newRegion->name);
|
snprintf(errBuf, errLen, "Region %s requires licensed mode", newRegion->name);
|
||||||
return false;
|
return false;
|
||||||
|
|||||||
@@ -256,8 +256,10 @@ class RadioInterface
|
|||||||
static bool checkOrClampConfigLora(meshtastic_Config_LoRaConfig &loraConfig, bool clamp);
|
static bool checkOrClampConfigLora(meshtastic_Config_LoRaConfig &loraConfig, bool clamp);
|
||||||
|
|
||||||
// Check if a candidate region is compatible and valid, with no side effects (safe for
|
// 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.
|
// speculative UI checks). prospectiveLicensedOwner is for a UI flow that requires
|
||||||
static bool checkConfigRegion(const meshtastic_Config_LoRaConfig &loraConfig, char *errBuf = nullptr, size_t errLen = 0);
|
// 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,
|
// Check if a candidate region is compatible and valid. On failure, logs at ERROR,
|
||||||
// records a critical error, and sends a client notification.
|
// records a critical error, and sends a client notification.
|
||||||
|
|||||||
+13
-7
@@ -810,12 +810,17 @@ DecodeState perhapsDecode(meshtastic_MeshPacket *p)
|
|||||||
}
|
}
|
||||||
bool decrypted = false;
|
bool decrypted = false;
|
||||||
bool pkiAttempted = false;
|
bool pkiAttempted = false;
|
||||||
|
bool licensedPkiCandidate = false;
|
||||||
bool matchedChannel = false;
|
bool matchedChannel = false;
|
||||||
ChannelIndex chIndex = 0;
|
ChannelIndex chIndex = 0;
|
||||||
#if !(MESHTASTIC_EXCLUDE_PKI)
|
#if !(MESHTASTIC_EXCLUDE_PKI)
|
||||||
meshtastic_NodeInfoLite *ourNode = nullptr;
|
meshtastic_NodeInfoLite *ourNode = nullptr;
|
||||||
if (p->channel == 0 && isToUs(p) && p->to > 0 && !isBroadcast(p->to) && rawSize > MESHTASTIC_PKC_OVERHEAD &&
|
const bool pkiCandidate = p->channel == 0 && isToUs(p) && p->to > 0 && !isBroadcast(p->to) &&
|
||||||
(ourNode = nodeDB->getMeshNode(p->to)) != nullptr && ourNode->public_key.size > 0) {
|
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;
|
pkiAttempted = true;
|
||||||
LOG_DEBUG("Attempt PKI decryption");
|
LOG_DEBUG("Attempt PKI decryption");
|
||||||
// Resolve the sender's key only for actual PKI-decrypt candidates, not every encrypted channel
|
// 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;
|
return DecodeState::DECODE_SUCCESS;
|
||||||
} else {
|
} else {
|
||||||
LOG_WARN("No suitable channel found for decoding, hash was 0x%x!", p->channel);
|
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.
|
// verification at every XEdDSA-enabled receiver that knows our key.
|
||||||
p->decoded.xeddsa_signature.size = 0;
|
p->decoded.xeddsa_signature.size = 0;
|
||||||
#if !(MESHTASTIC_EXCLUDE_PKI) && !(MESHTASTIC_EXCLUDE_XEDDSA)
|
#if !(MESHTASTIC_EXCLUDE_PKI) && !(MESHTASTIC_EXCLUDE_XEDDSA)
|
||||||
// Sign broadcast packets when the Data still fits a LoRa frame with the signature
|
// Licensed packets stay plaintext, so sign both broadcasts and unicasts. Normal mode
|
||||||
// attached. This must be the exact encoded-size criterion, not a payload-size
|
// continues to sign broadcasts only. Use the exact encoded size: a payload-size heuristic
|
||||||
// heuristic: a heuristic band where we sign-then-fail-TOO_LARGE breaks packets that
|
// where we sign-then-fail-TOO_LARGE breaks packets that
|
||||||
// were deliverable unsigned, and perhapsDecode() applies the mirror-image rule when
|
// were deliverable unsigned, and perhapsDecode() applies the mirror-image rule when
|
||||||
// deciding whether an unsigned broadcast from a known signer is a downgrade.
|
// 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,
|
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.bytes)) {
|
||||||
p->decoded.xeddsa_signature.size = XEDDSA_SIGNATURE_SIZE;
|
p->decoded.xeddsa_signature.size = XEDDSA_SIGNATURE_SIZE;
|
||||||
|
|||||||
@@ -11,6 +11,7 @@
|
|||||||
#include "gps/RTC.h"
|
#include "gps/RTC.h"
|
||||||
#include "input/InputBroker.h"
|
#include "input/InputBroker.h"
|
||||||
#include "meshUtils.h"
|
#include "meshUtils.h"
|
||||||
|
#include <ErriezCRC32.h>
|
||||||
#include <FSCommon.h>
|
#include <FSCommon.h>
|
||||||
#include <Throttle.h>
|
#include <Throttle.h>
|
||||||
#include <ctype.h> // for better whitespace handling
|
#include <ctype.h> // for better whitespace handling
|
||||||
@@ -70,6 +71,17 @@
|
|||||||
AdminModule *adminModule;
|
AdminModule *adminModule;
|
||||||
bool hasOpenEditTransaction;
|
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.
|
/// 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
|
/// 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.
|
/// a change.
|
||||||
@@ -765,6 +777,8 @@ void AdminModule::handleGetModuleConfigResponse(const meshtastic_MeshPacket &mp,
|
|||||||
void AdminModule::handleSetOwner(const meshtastic_User &o)
|
void AdminModule::handleSetOwner(const meshtastic_User &o)
|
||||||
{
|
{
|
||||||
int changed = 0;
|
int changed = 0;
|
||||||
|
bool identityUpdated = false;
|
||||||
|
bool channelsSanitized = false;
|
||||||
|
|
||||||
if (*o.long_name) {
|
if (*o.long_name) {
|
||||||
// Apps built against the older 39-byte limit may send longer names; clamp
|
// 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';
|
owner.short_name[sizeof(owner.short_name) - 1] = '\0';
|
||||||
sanitizeUtf8(owner.short_name, sizeof(owner.short_name));
|
sanitizeUtf8(owner.short_name, sizeof(owner.short_name));
|
||||||
}
|
}
|
||||||
snprintf(owner.id, sizeof(owner.id), "!%08x", nodeDB->getNodeNum());
|
|
||||||
|
|
||||||
if (owner.is_licensed != o.is_licensed) {
|
if (owner.is_licensed != o.is_licensed) {
|
||||||
changed = 1;
|
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;
|
owner.is_licensed = o.is_licensed;
|
||||||
if (channels.ensureLicensedOperation()) {
|
if (channels.ensureLicensedOperation()) {
|
||||||
warnLicensedMode();
|
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 ||
|
if (owner.has_is_unmessagable != o.has_is_unmessagable ||
|
||||||
(o.has_is_unmessagable && owner.is_unmessagable != o.is_unmessagable)) {
|
(o.has_is_unmessagable && owner.is_unmessagable != o.is_unmessagable)) {
|
||||||
changed = 1;
|
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
|
if (changed) { // If nothing really changed, don't broadcast on the network or write to flash
|
||||||
service->reloadOwner(!hasOpenEditTransaction);
|
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 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 (isRegionUnset && validatedLora.region > meshtastic_Config_LoRaConfig_RegionCode_UNSET) {
|
||||||
#if !(MESHTASTIC_EXCLUDE_PKI_KEYGEN || MESHTASTIC_EXCLUDE_PKI)
|
#if !(MESHTASTIC_EXCLUDE_PKI_KEYGEN || MESHTASTIC_EXCLUDE_PKI)
|
||||||
if (crypto) {
|
if (crypto && !owner.is_licensed) {
|
||||||
crypto->ensurePkiKeys(config.security, owner);
|
crypto->ensurePkiKeys(config.security, owner);
|
||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
@@ -1017,6 +1045,17 @@ void AdminModule::handleSetConfig(const meshtastic_Config &c, bool fromOthers)
|
|||||||
}
|
}
|
||||||
// Ensure initRegion() uses the newly validated region
|
// Ensure initRegion() uses the newly validated region
|
||||||
config.lora.region = validatedLora.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();
|
initRegion();
|
||||||
if (getEffectiveDutyCycle() < 100) {
|
if (getEffectiveDutyCycle() < 100) {
|
||||||
validatedLora.ignore_mqtt = true; // Ignore MQTT by default if region has a duty cycle limit
|
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
|
// 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);
|
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 {
|
} else {
|
||||||
// Region validation has failed, so just copy all of the old config over the new config
|
// Region validation has failed, so just copy all of the old config over the new config
|
||||||
validatedLora = oldLoraConfig;
|
validatedLora = oldLoraConfig;
|
||||||
@@ -1838,6 +1877,9 @@ void AdminModule::reboot(int32_t seconds)
|
|||||||
|
|
||||||
void AdminModule::saveChanges(int saveWhat, bool shouldReboot)
|
void AdminModule::saveChanges(int saveWhat, bool shouldReboot)
|
||||||
{
|
{
|
||||||
|
#ifdef PIO_UNIT_TESTING
|
||||||
|
lastSaveWhatForTest = saveWhat;
|
||||||
|
#endif
|
||||||
if (!hasOpenEditTransaction) {
|
if (!hasOpenEditTransaction) {
|
||||||
LOG_INFO("Save changes to disk");
|
LOG_INFO("Save changes to disk");
|
||||||
service->reloadConfig(saveWhat); // Calls saveToDisk among other things
|
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;
|
config.device.rebroadcast_mode = meshtastic_Config_DeviceConfig_RebroadcastMode_LOCAL_ONLY;
|
||||||
// Remove PSK of primary channel for plaintext amateur usage
|
// 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()) {
|
if (channels.ensureLicensedOperation()) {
|
||||||
warnLicensedMode();
|
warnLicensedMode();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -40,6 +40,9 @@ class AdminModule : public ProtobufModule<meshtastic_AdminMessage>, public Obser
|
|||||||
|
|
||||||
private:
|
private:
|
||||||
bool hasOpenEditTransaction = false;
|
bool hasOpenEditTransaction = false;
|
||||||
|
#ifdef PIO_UNIT_TESTING
|
||||||
|
int lastSaveWhatForTest = 0;
|
||||||
|
#endif
|
||||||
|
|
||||||
uint8_t session_passkey[8] = {0};
|
uint8_t session_passkey[8] = {0};
|
||||||
uint32_t session_time = 0; // millis() when the current session passkey was issued
|
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 =
|
static constexpr const char *licensedModeMessage =
|
||||||
"Licensed mode activated, removing admin channel and encryption from all channels";
|
"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 =
|
static constexpr const char *publicChannelPrecisionMessage =
|
||||||
"Precise position is not allowed on a public (open / known-key) channel; reduced to coarse precision";
|
"Precise position is not allowed on a public (open / known-key) channel; reduced to coarse precision";
|
||||||
|
|
||||||
extern AdminModule *adminModule;
|
extern AdminModule *adminModule;
|
||||||
|
|
||||||
void disableBluetooth();
|
void disableBluetooth();
|
||||||
|
|||||||
@@ -169,14 +169,8 @@ meshtastic_MeshPacket *NodeInfoModule::allocReply()
|
|||||||
ignoreRequest = true;
|
ignoreRequest = true;
|
||||||
return NULL;
|
return NULL;
|
||||||
} else {
|
} else {
|
||||||
ignoreRequest = false; // Don't ignore requests anymore
|
ignoreRequest = false; // Don't ignore requests anymore
|
||||||
meshtastic_User u = owner; // deliberate copy: the licensed strip below must not clobber the global owner state
|
meshtastic_User u = owner;
|
||||||
|
|
||||||
// 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;
|
|
||||||
}
|
|
||||||
|
|
||||||
// FIXME: Clear the user.id field since it should be derived from node number on the receiving end
|
// FIXME: Clear the user.id field since it should be derived from node number on the receiving end
|
||||||
// u.id[0] = '\0';
|
// u.id[0] = '\0';
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ class AdminModuleTestShim : public AdminModule
|
|||||||
using AdminModule::handleReceivedProtobuf;
|
using AdminModule::handleReceivedProtobuf;
|
||||||
using AdminModule::handleSetConfig;
|
using AdminModule::handleSetConfig;
|
||||||
using AdminModule::handleSetModuleConfig;
|
using AdminModule::handleSetModuleConfig;
|
||||||
|
using AdminModule::handleSetOwner;
|
||||||
using AdminModule::responseIsSolicited; // request/response pairing gate
|
using AdminModule::responseIsSolicited; // request/response pairing gate
|
||||||
using AdminModule::setPassKey;
|
using AdminModule::setPassKey;
|
||||||
|
|
||||||
@@ -21,6 +22,7 @@ class AdminModuleTestShim : public AdminModule
|
|||||||
|
|
||||||
// With an "open edit transaction" saveChanges() is a pure no-op: no reloadConfig/saveToDisk/reboot.
|
// With an "open edit transaction" saveChanges() is a pure no-op: no reloadConfig/saveToDisk/reboot.
|
||||||
void deferSaves() { hasOpenEditTransaction = true; }
|
void deferSaves() { hasOpenEditTransaction = true; }
|
||||||
|
int savedSegments() const { return lastSaveWhatForTest; }
|
||||||
|
|
||||||
// Setters may allocate an error reply from packetPool; drain it each iteration or the pool leaks.
|
// Setters may allocate an error reply from packetPool; drain it each iteration or the pool leaks.
|
||||||
void drainReply()
|
void drainReply()
|
||||||
|
|||||||
@@ -6,5 +6,11 @@
|
|||||||
class MockMeshService : public MeshService
|
class MockMeshService : public MeshService
|
||||||
{
|
{
|
||||||
public:
|
public:
|
||||||
void sendClientNotification(meshtastic_ClientNotification *n) override { releaseClientNotificationToPool(n); }
|
void sendClientNotification(meshtastic_ClientNotification *n) override
|
||||||
|
{
|
||||||
|
notificationCount++;
|
||||||
|
releaseClientNotificationToPool(n);
|
||||||
|
}
|
||||||
|
|
||||||
|
uint32_t notificationCount = 0;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -11,7 +11,9 @@
|
|||||||
* 6. Channel spacing calculation (placeholder for future protobuf changes)
|
* 6. Channel spacing calculation (placeholder for future protobuf changes)
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
#include "Channels.h"
|
||||||
#include "DisplayFormatters.h"
|
#include "DisplayFormatters.h"
|
||||||
|
#include "FSCommon.h"
|
||||||
#include "MeshRadio.h"
|
#include "MeshRadio.h"
|
||||||
#include "MeshService.h"
|
#include "MeshService.h"
|
||||||
#include "NodeDB.h"
|
#include "NodeDB.h"
|
||||||
@@ -19,6 +21,9 @@
|
|||||||
#include "TestUtil.h"
|
#include "TestUtil.h"
|
||||||
#include "mesh/Channels.h"
|
#include "mesh/Channels.h"
|
||||||
#include "modules/AdminModule.h"
|
#include "modules/AdminModule.h"
|
||||||
|
#include "modules/NodeInfoModule.h"
|
||||||
|
#include <pb_decode.h>
|
||||||
|
#include <pb_encode.h>
|
||||||
#include <string>
|
#include <string>
|
||||||
#include <unity.h>
|
#include <unity.h>
|
||||||
#include <vector>
|
#include <vector>
|
||||||
@@ -949,6 +954,138 @@ static void test_channelSpacingCalculation_placeholder()
|
|||||||
|
|
||||||
// AdminModuleTestShim comes from test/support - the friend seam AdminModule.h declares.
|
// AdminModuleTestShim comes from test/support - the friend seam AdminModule.h declares.
|
||||||
static AdminModuleTestShim *testAdmin;
|
static AdminModuleTestShim *testAdmin;
|
||||||
|
static bool adminRadioGlobalsActive;
|
||||||
|
static NodeDB *savedNodeDB;
|
||||||
|
static NodeDB *replacementNodeDB;
|
||||||
|
static NodeInfoModule *savedNodeInfoModule;
|
||||||
|
static meshtastic_DeviceState savedDeviceState;
|
||||||
|
static meshtastic_User savedOwner;
|
||||||
|
static meshtastic_LocalConfig savedConfig;
|
||||||
|
static meshtastic_ChannelFile savedChannelFile;
|
||||||
|
|
||||||
|
static void replaceAdminRadioGlobals()
|
||||||
|
{
|
||||||
|
savedNodeDB = nodeDB;
|
||||||
|
savedNodeInfoModule = nodeInfoModule;
|
||||||
|
savedDeviceState = devicestate;
|
||||||
|
savedOwner = owner;
|
||||||
|
savedConfig = config;
|
||||||
|
savedChannelFile = channelFile;
|
||||||
|
replacementNodeDB = new NodeDB();
|
||||||
|
nodeDB = replacementNodeDB;
|
||||||
|
adminRadioGlobalsActive = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
static void restoreAdminRadioGlobals()
|
||||||
|
{
|
||||||
|
if (!adminRadioGlobalsActive)
|
||||||
|
return;
|
||||||
|
nodeInfoModule = savedNodeInfoModule;
|
||||||
|
nodeDB = savedNodeDB;
|
||||||
|
delete replacementNodeDB;
|
||||||
|
replacementNodeDB = nullptr;
|
||||||
|
devicestate = savedDeviceState;
|
||||||
|
owner = savedOwner;
|
||||||
|
config = savedConfig;
|
||||||
|
channelFile = savedChannelFile;
|
||||||
|
initRegion();
|
||||||
|
adminRadioGlobalsActive = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
static void installEncryptedAndAdminChannels()
|
||||||
|
{
|
||||||
|
channels.initDefaults();
|
||||||
|
meshtastic_Channel admin = meshtastic_Channel_init_zero;
|
||||||
|
admin.index = 1;
|
||||||
|
admin.role = meshtastic_Channel_Role_SECONDARY;
|
||||||
|
admin.has_settings = true;
|
||||||
|
strncpy(admin.settings.name, Channels::adminChannel, sizeof(admin.settings.name));
|
||||||
|
admin.settings.psk.size = 16;
|
||||||
|
memset(admin.settings.psk.bytes, 0xA5, admin.settings.psk.size);
|
||||||
|
channels.setChannel(admin);
|
||||||
|
|
||||||
|
meshtastic_Channel secondary = meshtastic_Channel_init_zero;
|
||||||
|
secondary.index = 2;
|
||||||
|
secondary.role = meshtastic_Channel_Role_SECONDARY;
|
||||||
|
secondary.has_settings = true;
|
||||||
|
strncpy(secondary.settings.name, "private", sizeof(secondary.settings.name));
|
||||||
|
secondary.settings.psk.size = 32;
|
||||||
|
memset(secondary.settings.psk.bytes, 0x5A, secondary.settings.psk.size);
|
||||||
|
channels.setChannel(secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
static void assertLicensedChannelsSanitized()
|
||||||
|
{
|
||||||
|
TEST_ASSERT_EQUAL(0, channels.getByIndex(0).settings.psk.size);
|
||||||
|
TEST_ASSERT_EQUAL(meshtastic_Channel_Role_DISABLED, channels.getByIndex(1).role);
|
||||||
|
TEST_ASSERT_EQUAL(0, channels.getByIndex(1).settings.psk.size);
|
||||||
|
TEST_ASSERT_EQUAL(0, channels.getByIndex(2).settings.psk.size);
|
||||||
|
}
|
||||||
|
|
||||||
|
static void test_handleSetOwner_persistsLicensedChannelSanitation()
|
||||||
|
{
|
||||||
|
replaceAdminRadioGlobals();
|
||||||
|
owner = meshtastic_User_init_zero;
|
||||||
|
config.lora.region = meshtastic_Config_LoRaConfig_RegionCode_UNSET;
|
||||||
|
installEncryptedAndAdminChannels();
|
||||||
|
|
||||||
|
meshtastic_User licensed = meshtastic_User_init_zero;
|
||||||
|
licensed.is_licensed = true;
|
||||||
|
testAdmin->deferSaves();
|
||||||
|
nodeInfoModule = reinterpret_cast<NodeInfoModule *>(1); // reloadOwner(false) only checks presence
|
||||||
|
testAdmin->handleSetOwner(licensed);
|
||||||
|
|
||||||
|
TEST_ASSERT_TRUE(testAdmin->savedSegments() & SEGMENT_CHANNELS);
|
||||||
|
assertLicensedChannelsSanitized();
|
||||||
|
|
||||||
|
uint8_t encoded[meshtastic_ChannelFile_size];
|
||||||
|
const size_t encodedSize = pb_encode_to_bytes(encoded, sizeof(encoded), &meshtastic_ChannelFile_msg, &channelFile);
|
||||||
|
TEST_ASSERT_GREATER_THAN(0, encodedSize);
|
||||||
|
meshtastic_ChannelFile reloaded = meshtastic_ChannelFile_init_zero;
|
||||||
|
TEST_ASSERT_TRUE(pb_decode_from_bytes(encoded, encodedSize, &meshtastic_ChannelFile_msg, &reloaded));
|
||||||
|
channelFile = reloaded;
|
||||||
|
assertLicensedChannelsSanitized();
|
||||||
|
TEST_ASSERT_FALSE_MESSAGE(channels.ensureLicensedOperation(), "sanitized reload must not trigger another persistence write");
|
||||||
|
}
|
||||||
|
|
||||||
|
static void test_bootDefense_sanitizesStaleLicensedChannelsOnce()
|
||||||
|
{
|
||||||
|
owner = meshtastic_User_init_zero;
|
||||||
|
owner.is_licensed = true;
|
||||||
|
installEncryptedAndAdminChannels();
|
||||||
|
|
||||||
|
TEST_ASSERT_TRUE(channels.ensureLicensedOperation());
|
||||||
|
assertLicensedChannelsSanitized();
|
||||||
|
TEST_ASSERT_FALSE_MESSAGE(channels.ensureLicensedOperation(), "boot sanitation must be idempotent");
|
||||||
|
}
|
||||||
|
|
||||||
|
static void test_restorePreferences_sanitizesLicensedBackupBeforeReturn()
|
||||||
|
{
|
||||||
|
NodeDB *savedNodeDB = nodeDB;
|
||||||
|
nodeDB = new NodeDB();
|
||||||
|
const meshtastic_DeviceState savedDeviceState = devicestate;
|
||||||
|
const meshtastic_ChannelFile savedChannelFile = channelFile;
|
||||||
|
|
||||||
|
owner = meshtastic_User_init_zero;
|
||||||
|
owner.is_licensed = true;
|
||||||
|
installEncryptedAndAdminChannels();
|
||||||
|
TEST_ASSERT_TRUE(nodeDB->backupPreferences(meshtastic_AdminMessage_BackupLocation_FLASH));
|
||||||
|
|
||||||
|
owner.is_licensed = false;
|
||||||
|
channels.initDefaults();
|
||||||
|
TEST_ASSERT_TRUE(
|
||||||
|
nodeDB->restorePreferences(meshtastic_AdminMessage_BackupLocation_FLASH, SEGMENT_DEVICESTATE | SEGMENT_CHANNELS));
|
||||||
|
TEST_ASSERT_TRUE(owner.is_licensed);
|
||||||
|
assertLicensedChannelsSanitized();
|
||||||
|
TEST_ASSERT_FALSE_MESSAGE(channels.ensureLicensedOperation(), "restored licensed channels must remain sanitized");
|
||||||
|
|
||||||
|
devicestate = savedDeviceState;
|
||||||
|
channelFile = savedChannelFile;
|
||||||
|
nodeDB->saveToDisk(SEGMENT_DEVICESTATE | SEGMENT_CHANNELS);
|
||||||
|
FSCom.remove(backupFileName);
|
||||||
|
delete nodeDB;
|
||||||
|
nodeDB = savedNodeDB;
|
||||||
|
}
|
||||||
|
|
||||||
static meshtastic_Config makeLoraSetConfig(meshtastic_Config_LoRaConfig_RegionCode region, bool usePreset,
|
static meshtastic_Config makeLoraSetConfig(meshtastic_Config_LoRaConfig_RegionCode region, bool usePreset,
|
||||||
meshtastic_Config_LoRaConfig_ModemPreset preset)
|
meshtastic_Config_LoRaConfig_ModemPreset preset)
|
||||||
@@ -961,6 +1098,27 @@ static meshtastic_Config makeLoraSetConfig(meshtastic_Config_LoRaConfig_RegionCo
|
|||||||
return c;
|
return c;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static void test_handleSetConfig_persistsLicensedFirstRegionIdentity()
|
||||||
|
{
|
||||||
|
replaceAdminRadioGlobals();
|
||||||
|
owner = meshtastic_User_init_zero;
|
||||||
|
owner.is_licensed = true;
|
||||||
|
config.security = meshtastic_Config_SecurityConfig_init_zero;
|
||||||
|
config.lora = meshtastic_Config_LoRaConfig_init_zero;
|
||||||
|
config.lora.region = meshtastic_Config_LoRaConfig_RegionCode_UNSET;
|
||||||
|
initRegion();
|
||||||
|
|
||||||
|
testAdmin->deferSaves();
|
||||||
|
const meshtastic_Config c =
|
||||||
|
makeLoraSetConfig(meshtastic_Config_LoRaConfig_RegionCode_US, true, meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST);
|
||||||
|
testAdmin->handleSetConfig(c, false);
|
||||||
|
|
||||||
|
const int expectedSegments = SEGMENT_CONFIG | SEGMENT_MODULECONFIG | SEGMENT_DEVICESTATE | SEGMENT_NODEDATABASE;
|
||||||
|
TEST_ASSERT_EQUAL_INT(expectedSegments, testAdmin->savedSegments());
|
||||||
|
TEST_ASSERT_EQUAL(32, config.security.private_key.size);
|
||||||
|
TEST_ASSERT_EQUAL(32, owner.public_key.size);
|
||||||
|
}
|
||||||
|
|
||||||
static void test_handleSetConfig_fromOthers_invalidPresetRejected()
|
static void test_handleSetConfig_fromOthers_invalidPresetRejected()
|
||||||
{
|
{
|
||||||
// Set up a known-good baseline in the global config
|
// Set up a known-good baseline in the global config
|
||||||
@@ -1295,6 +1453,16 @@ static void test_checkConfigRegion_quietCheckReportsReason()
|
|||||||
TEST_ASSERT_TRUE_MESSAGE(strlen(err) > 0, "Expected a failure reason in errBuf");
|
TEST_ASSERT_TRUE_MESSAGE(strlen(err) > 0, "Expected a failure reason in errBuf");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static void test_checkConfigRegion_allowsProspectiveLicensedOwner()
|
||||||
|
{
|
||||||
|
meshtastic_Config_LoRaConfig cfg = meshtastic_Config_LoRaConfig_init_zero;
|
||||||
|
cfg.region = meshtastic_Config_LoRaConfig_RegionCode_ITU1_2M;
|
||||||
|
devicestate.owner.is_licensed = false;
|
||||||
|
|
||||||
|
TEST_ASSERT_FALSE(RadioInterface::checkConfigRegion(cfg));
|
||||||
|
TEST_ASSERT_TRUE(RadioInterface::checkConfigRegion(cfg, nullptr, 0, true));
|
||||||
|
}
|
||||||
|
|
||||||
static void test_handleSetConfig_fromOthers_siblingLockedPresetSwapsRegion()
|
static void test_handleSetConfig_fromOthers_siblingLockedPresetSwapsRegion()
|
||||||
{
|
{
|
||||||
// Baseline: EU_866 (LITE profile)
|
// Baseline: EU_866 (LITE profile)
|
||||||
@@ -1515,6 +1683,7 @@ void setUp(void)
|
|||||||
}
|
}
|
||||||
void tearDown(void)
|
void tearDown(void)
|
||||||
{
|
{
|
||||||
|
restoreAdminRadioGlobals();
|
||||||
service = nullptr;
|
service = nullptr;
|
||||||
delete mockMeshService;
|
delete mockMeshService;
|
||||||
mockMeshService = nullptr;
|
mockMeshService = nullptr;
|
||||||
@@ -1532,6 +1701,10 @@ void setup()
|
|||||||
UNITY_BEGIN();
|
UNITY_BEGIN();
|
||||||
|
|
||||||
// getRegion()
|
// getRegion()
|
||||||
|
RUN_TEST(test_handleSetOwner_persistsLicensedChannelSanitation);
|
||||||
|
RUN_TEST(test_handleSetConfig_persistsLicensedFirstRegionIdentity);
|
||||||
|
RUN_TEST(test_bootDefense_sanitizesStaleLicensedChannelsOnce);
|
||||||
|
RUN_TEST(test_restorePreferences_sanitizesLicensedBackupBeforeReturn);
|
||||||
RUN_TEST(test_getRegion_returnsCorrectRegion_US);
|
RUN_TEST(test_getRegion_returnsCorrectRegion_US);
|
||||||
RUN_TEST(test_getRegion_returnsCorrectRegion_EU868);
|
RUN_TEST(test_getRegion_returnsCorrectRegion_EU868);
|
||||||
RUN_TEST(test_getRegion_returnsCorrectRegion_LORA24);
|
RUN_TEST(test_getRegion_returnsCorrectRegion_LORA24);
|
||||||
@@ -1618,6 +1791,7 @@ void setup()
|
|||||||
RUN_TEST(test_handleSetConfig_security_clearsAdminKeysWhenKeypairUnchanged);
|
RUN_TEST(test_handleSetConfig_security_clearsAdminKeysWhenKeypairUnchanged);
|
||||||
RUN_TEST(test_regionInfo_supportsPreset);
|
RUN_TEST(test_regionInfo_supportsPreset);
|
||||||
RUN_TEST(test_checkConfigRegion_quietCheckReportsReason);
|
RUN_TEST(test_checkConfigRegion_quietCheckReportsReason);
|
||||||
|
RUN_TEST(test_checkConfigRegion_allowsProspectiveLicensedOwner);
|
||||||
RUN_TEST(test_handleSetConfig_fromOthers_siblingLockedPresetSwapsRegion);
|
RUN_TEST(test_handleSetConfig_fromOthers_siblingLockedPresetSwapsRegion);
|
||||||
RUN_TEST(test_handleSetConfig_fromOthers_lockedPresetFromNonTrioRegionRejected);
|
RUN_TEST(test_handleSetConfig_fromOthers_lockedPresetFromNonTrioRegionRejected);
|
||||||
|
|
||||||
|
|||||||
@@ -12,7 +12,10 @@
|
|||||||
// Group E decoded-ingress policy (checkXeddsaReceivePolicy, the plaintext-MQTT trust boundary)
|
// Group E decoded-ingress policy (checkXeddsaReceivePolicy, the plaintext-MQTT trust boundary)
|
||||||
|
|
||||||
#include "MeshTypes.h" // include BEFORE TestUtil.h
|
#include "MeshTypes.h" // include BEFORE TestUtil.h
|
||||||
|
#include "NodeStatus.h"
|
||||||
#include "TestUtil.h"
|
#include "TestUtil.h"
|
||||||
|
#include "airtime.h"
|
||||||
|
#include "support/MockMeshService.h"
|
||||||
#include <unity.h>
|
#include <unity.h>
|
||||||
|
|
||||||
// The whole suite exercises XEdDSA sign/verify and checkXeddsaReceivePolicy, all of which are
|
// The whole suite exercises XEdDSA sign/verify and checkXeddsaReceivePolicy, all of which are
|
||||||
@@ -34,6 +37,7 @@
|
|||||||
#include <cstdio>
|
#include <cstdio>
|
||||||
#include <cstring>
|
#include <cstring>
|
||||||
#include <memory>
|
#include <memory>
|
||||||
|
#include <pb_decode.h>
|
||||||
#include <pb_encode.h>
|
#include <pb_encode.h>
|
||||||
#include <vector>
|
#include <vector>
|
||||||
|
|
||||||
@@ -55,6 +59,8 @@ static constexpr size_t OVERSIZED_PAYLOAD = 180;
|
|||||||
class MockNodeDB : public NodeDB
|
class MockNodeDB : public NodeDB
|
||||||
{
|
{
|
||||||
public:
|
public:
|
||||||
|
void installDefaultsPreservingIdentity() { installDefaultConfig(true); }
|
||||||
|
|
||||||
void clearTestNodes()
|
void clearTestNodes()
|
||||||
{
|
{
|
||||||
testNodes.clear();
|
testNodes.clear();
|
||||||
@@ -364,6 +370,8 @@ static meshtastic_MeshPacket makeBroadcastWithUnknownFields()
|
|||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
void setUp(void)
|
void setUp(void)
|
||||||
{
|
{
|
||||||
|
service = pipelineService;
|
||||||
|
|
||||||
// Construct the mock FIRST: the NodeDB constructor can reload persisted state from the
|
// Construct the mock FIRST: the NodeDB constructor can reload persisted state from the
|
||||||
// host filesystem (portduino VFS) and repopulate the globals - a saved private key
|
// host filesystem (portduino VFS) and repopulate the globals - a saved private key
|
||||||
// re-enables the PKI encrypt path and fails the unicast tests on hosts with leftover prefs.
|
// re-enables the PKI encrypt path and fails the unicast tests on hosts with leftover prefs.
|
||||||
@@ -923,6 +931,134 @@ void test_B7_infrastructure_port_signing_matrix(void)
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void test_B8_licensed_broadcast_and_unicast_are_signed(void)
|
||||||
|
{
|
||||||
|
uint8_t pub[32], priv[32];
|
||||||
|
crypto->generateKeyPair(pub, priv);
|
||||||
|
mockNodeDB->addNode(LOCAL_NODE);
|
||||||
|
mockNodeDB->setPublicKey(LOCAL_NODE, pub);
|
||||||
|
owner.is_licensed = true;
|
||||||
|
channels.ensureLicensedOperation();
|
||||||
|
|
||||||
|
meshtastic_MeshPacket broadcast =
|
||||||
|
makeDecoded(LOCAL_NODE, NODENUM_BROADCAST, meshtastic_PortNum_TEXT_MESSAGE_APP, SMALL_PAYLOAD);
|
||||||
|
TEST_ASSERT_EQUAL(DECODE_SUCCESS, roundTrip(&broadcast));
|
||||||
|
TEST_ASSERT_EQUAL(XEDDSA_SIGNATURE_SIZE, broadcast.decoded.xeddsa_signature.size);
|
||||||
|
TEST_ASSERT_TRUE(broadcast.xeddsa_signed);
|
||||||
|
|
||||||
|
meshtastic_MeshPacket direct = makeDecoded(LOCAL_NODE, REMOTE_NODE, meshtastic_PortNum_TEXT_MESSAGE_APP, SMALL_PAYLOAD);
|
||||||
|
TEST_ASSERT_EQUAL(DECODE_SUCCESS, roundTrip(&direct));
|
||||||
|
TEST_ASSERT_EQUAL(XEDDSA_SIGNATURE_SIZE, direct.decoded.xeddsa_signature.size);
|
||||||
|
TEST_ASSERT_TRUE(direct.xeddsa_signed);
|
||||||
|
}
|
||||||
|
|
||||||
|
void test_B9_licensed_unicast_never_uses_pki_encryption(void)
|
||||||
|
{
|
||||||
|
uint8_t localPub[32], localPriv[32], remotePub[32], remotePriv[32];
|
||||||
|
crypto->generateKeyPair(localPub, localPriv);
|
||||||
|
memcpy(config.security.private_key.bytes, localPriv, sizeof(localPriv));
|
||||||
|
config.security.private_key.size = sizeof(localPriv);
|
||||||
|
mockNodeDB->addNode(LOCAL_NODE);
|
||||||
|
mockNodeDB->setPublicKey(LOCAL_NODE, localPub);
|
||||||
|
mockNodeDB->addNode(REMOTE_NODE);
|
||||||
|
crypto->generateKeyPair(remotePub, remotePriv);
|
||||||
|
mockNodeDB->setPublicKey(REMOTE_NODE, remotePub);
|
||||||
|
crypto->setDHPrivateKey(localPriv);
|
||||||
|
|
||||||
|
owner.is_licensed = true;
|
||||||
|
channels.ensureLicensedOperation();
|
||||||
|
meshtastic_MeshPacket p = makeDecoded(LOCAL_NODE, REMOTE_NODE, meshtastic_PortNum_TEXT_MESSAGE_APP, SMALL_PAYLOAD);
|
||||||
|
|
||||||
|
TEST_ASSERT_EQUAL(meshtastic_Routing_Error_NONE, perhapsEncode(&p));
|
||||||
|
TEST_ASSERT_FALSE(p.pki_encrypted);
|
||||||
|
meshtastic_Data plaintext = meshtastic_Data_init_zero;
|
||||||
|
TEST_ASSERT_TRUE(pb_decode_from_bytes(p.encrypted.bytes, p.encrypted.size, &meshtastic_Data_msg, &plaintext));
|
||||||
|
TEST_ASSERT_EQUAL(XEDDSA_SIGNATURE_SIZE, plaintext.xeddsa_signature.size);
|
||||||
|
}
|
||||||
|
|
||||||
|
void test_B10_licensed_oversized_unicast_remains_unsigned(void)
|
||||||
|
{
|
||||||
|
owner.is_licensed = true;
|
||||||
|
channels.ensureLicensedOperation();
|
||||||
|
meshtastic_MeshPacket p = makeDecoded(LOCAL_NODE, REMOTE_NODE, meshtastic_PortNum_TEXT_MESSAGE_APP, OVERSIZED_PAYLOAD);
|
||||||
|
|
||||||
|
TEST_ASSERT_EQUAL(DECODE_SUCCESS, roundTrip(&p));
|
||||||
|
TEST_ASSERT_EQUAL(0, p.decoded.xeddsa_signature.size);
|
||||||
|
}
|
||||||
|
|
||||||
|
void test_B11_normal_unicast_still_uses_pki(void)
|
||||||
|
{
|
||||||
|
uint8_t localPub[32], localPriv[32], remotePub[32], remotePriv[32];
|
||||||
|
crypto->generateKeyPair(localPub, localPriv);
|
||||||
|
crypto->generateKeyPair(remotePub, remotePriv);
|
||||||
|
mockNodeDB->addNode(LOCAL_NODE);
|
||||||
|
mockNodeDB->setPublicKey(LOCAL_NODE, localPub);
|
||||||
|
mockNodeDB->addNode(REMOTE_NODE);
|
||||||
|
mockNodeDB->setPublicKey(REMOTE_NODE, remotePub);
|
||||||
|
memcpy(config.security.private_key.bytes, localPriv, sizeof(localPriv));
|
||||||
|
config.security.private_key.size = sizeof(localPriv);
|
||||||
|
crypto->setDHPrivateKey(localPriv);
|
||||||
|
|
||||||
|
meshtastic_MeshPacket p = makeDecoded(LOCAL_NODE, REMOTE_NODE, meshtastic_PortNum_TEXT_MESSAGE_APP, SMALL_PAYLOAD);
|
||||||
|
TEST_ASSERT_EQUAL(meshtastic_Routing_Error_NONE, perhapsEncode(&p));
|
||||||
|
TEST_ASSERT_TRUE(p.pki_encrypted);
|
||||||
|
|
||||||
|
myNodeInfo.my_node_num = REMOTE_NODE;
|
||||||
|
crypto->setDHPrivateKey(remotePriv);
|
||||||
|
TEST_ASSERT_EQUAL(DECODE_SUCCESS, perhapsDecode(&p));
|
||||||
|
TEST_ASSERT_TRUE(p.pki_encrypted);
|
||||||
|
TEST_ASSERT_EQUAL(0, p.decoded.xeddsa_signature.size);
|
||||||
|
}
|
||||||
|
|
||||||
|
void test_B12_licensed_receiver_does_not_decrypt_pki(void)
|
||||||
|
{
|
||||||
|
uint8_t localPub[32], localPriv[32], remotePub[32], remotePriv[32];
|
||||||
|
crypto->generateKeyPair(localPub, localPriv);
|
||||||
|
crypto->generateKeyPair(remotePub, remotePriv);
|
||||||
|
mockNodeDB->addNode(LOCAL_NODE);
|
||||||
|
mockNodeDB->setPublicKey(LOCAL_NODE, localPub);
|
||||||
|
mockNodeDB->addNode(REMOTE_NODE);
|
||||||
|
mockNodeDB->setPublicKey(REMOTE_NODE, remotePub);
|
||||||
|
memcpy(config.security.private_key.bytes, localPriv, sizeof(localPriv));
|
||||||
|
config.security.private_key.size = sizeof(localPriv);
|
||||||
|
crypto->setDHPrivateKey(localPriv);
|
||||||
|
|
||||||
|
meshtastic_MeshPacket p = makeDecoded(LOCAL_NODE, REMOTE_NODE, meshtastic_PortNum_TEXT_MESSAGE_APP, SMALL_PAYLOAD);
|
||||||
|
TEST_ASSERT_EQUAL(meshtastic_Routing_Error_NONE, perhapsEncode(&p));
|
||||||
|
TEST_ASSERT_TRUE(p.pki_encrypted);
|
||||||
|
|
||||||
|
owner.is_licensed = true;
|
||||||
|
channels.ensureLicensedOperation();
|
||||||
|
myNodeInfo.my_node_num = REMOTE_NODE;
|
||||||
|
crypto->setDHPrivateKey(remotePriv);
|
||||||
|
TEST_ASSERT_EQUAL(DECODE_FAILURE, perhapsDecode(&p));
|
||||||
|
}
|
||||||
|
|
||||||
|
void test_B13_licensed_port_and_destination_signing_matrix(void)
|
||||||
|
{
|
||||||
|
uint8_t pub[32], priv[32];
|
||||||
|
crypto->generateKeyPair(pub, priv);
|
||||||
|
mockNodeDB->addNode(LOCAL_NODE);
|
||||||
|
mockNodeDB->setPublicKey(LOCAL_NODE, pub);
|
||||||
|
owner.is_licensed = true;
|
||||||
|
channels.ensureLicensedOperation();
|
||||||
|
|
||||||
|
const meshtastic_PortNum ports[] = {
|
||||||
|
meshtastic_PortNum_TEXT_MESSAGE_APP, meshtastic_PortNum_POSITION_APP, meshtastic_PortNum_TELEMETRY_APP,
|
||||||
|
meshtastic_PortNum_ROUTING_APP, meshtastic_PortNum_NODEINFO_APP,
|
||||||
|
};
|
||||||
|
const NodeNum destinations[] = {NODENUM_BROADCAST, REMOTE_NODE};
|
||||||
|
for (const auto port : ports) {
|
||||||
|
for (const auto destination : destinations) {
|
||||||
|
meshtastic_MeshPacket packet = makeDecoded(LOCAL_NODE, destination, port, SMALL_PAYLOAD);
|
||||||
|
TEST_ASSERT_EQUAL(DECODE_SUCCESS, roundTrip(&packet));
|
||||||
|
TEST_ASSERT_EQUAL(XEDDSA_SIGNATURE_SIZE, packet.decoded.xeddsa_signature.size);
|
||||||
|
TEST_ASSERT_TRUE(packet.xeddsa_signed);
|
||||||
|
TEST_ASSERT_FALSE(packet.pki_encrypted);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ===========================================================================
|
// ===========================================================================
|
||||||
// Group C - routing pipeline and NodeInfo authentication ordering
|
// Group C - routing pipeline and NodeInfo authentication ordering
|
||||||
// ===========================================================================
|
// ===========================================================================
|
||||||
@@ -930,6 +1066,7 @@ void test_B7_infrastructure_port_signing_matrix(void)
|
|||||||
class NodeInfoTestShim : public NodeInfoModule
|
class NodeInfoTestShim : public NodeInfoModule
|
||||||
{
|
{
|
||||||
public:
|
public:
|
||||||
|
using NodeInfoModule::allocReply;
|
||||||
using NodeInfoModule::handleReceivedProtobuf;
|
using NodeInfoModule::handleReceivedProtobuf;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -1365,6 +1502,98 @@ void test_N7_unsigned_unicast_nodeinfo_from_nonsigner_changes_name(void)
|
|||||||
"non-signer identity learning must be unaffected");
|
"non-signer identity learning must be unaffected");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void test_L1_licensed_nodeinfo_publishes_public_key(void)
|
||||||
|
{
|
||||||
|
owner.is_licensed = true;
|
||||||
|
owner.public_key.size = 32;
|
||||||
|
memset(owner.public_key.bytes, 0x5A, owner.public_key.size);
|
||||||
|
|
||||||
|
NodeInfoTestShim shim;
|
||||||
|
meshtastic_MeshPacket *reply = shim.allocReply();
|
||||||
|
TEST_ASSERT_NOT_NULL(reply);
|
||||||
|
meshtastic_User published = meshtastic_User_init_zero;
|
||||||
|
TEST_ASSERT_TRUE(
|
||||||
|
pb_decode_from_bytes(reply->decoded.payload.bytes, reply->decoded.payload.size, &meshtastic_User_msg, &published));
|
||||||
|
TEST_ASSERT_TRUE(published.is_licensed);
|
||||||
|
TEST_ASSERT_EQUAL(32, published.public_key.size);
|
||||||
|
TEST_ASSERT_EQUAL_UINT8_ARRAY(owner.public_key.bytes, published.public_key.bytes, 32);
|
||||||
|
packetPool.release(reply);
|
||||||
|
}
|
||||||
|
|
||||||
|
void test_L2_licensed_identity_key_is_generated_and_preserved(void)
|
||||||
|
{
|
||||||
|
owner.is_licensed = true;
|
||||||
|
config.lora.region = meshtastic_Config_LoRaConfig_RegionCode_US;
|
||||||
|
config.security = meshtastic_Config_SecurityConfig_init_zero;
|
||||||
|
|
||||||
|
TEST_ASSERT_TRUE(mockNodeDB->generateCryptoKeyPair());
|
||||||
|
uint8_t privateKey[32], publicKey[32];
|
||||||
|
memcpy(privateKey, config.security.private_key.bytes, sizeof(privateKey));
|
||||||
|
memcpy(publicKey, config.security.public_key.bytes, sizeof(publicKey));
|
||||||
|
const NodeNum migratedNodeNum = myNodeInfo.my_node_num;
|
||||||
|
TEST_ASSERT_NOT_EQUAL(LOCAL_NODE, migratedNodeNum);
|
||||||
|
TEST_ASSERT_TRUE(mockNodeDB->licensedIdentityMigrationPending);
|
||||||
|
|
||||||
|
MockMeshService mockService;
|
||||||
|
service = &mockService;
|
||||||
|
TEST_ASSERT_TRUE(mockNodeDB->notifyPendingLicensedIdentityMigration());
|
||||||
|
TEST_ASSERT_EQUAL(1, mockService.notificationCount);
|
||||||
|
TEST_ASSERT_FALSE(mockNodeDB->licensedIdentityMigrationPending);
|
||||||
|
TEST_ASSERT_FALSE(mockNodeDB->notifyPendingLicensedIdentityMigration());
|
||||||
|
TEST_ASSERT_EQUAL(1, mockService.notificationCount);
|
||||||
|
service = pipelineService;
|
||||||
|
|
||||||
|
config.security.public_key.size = 0;
|
||||||
|
owner.public_key.size = 0;
|
||||||
|
TEST_ASSERT_TRUE(mockNodeDB->generateCryptoKeyPair());
|
||||||
|
TEST_ASSERT_EQUAL(migratedNodeNum, myNodeInfo.my_node_num);
|
||||||
|
TEST_ASSERT_EQUAL_UINT8_ARRAY(privateKey, config.security.private_key.bytes, sizeof(privateKey));
|
||||||
|
TEST_ASSERT_EQUAL_UINT8_ARRAY(publicKey, config.security.public_key.bytes, sizeof(publicKey));
|
||||||
|
TEST_ASSERT_EQUAL_UINT8_ARRAY(publicKey, owner.public_key.bytes, sizeof(publicKey));
|
||||||
|
}
|
||||||
|
|
||||||
|
void test_L3_factory_config_reset_preserves_valid_identity_private_key(void)
|
||||||
|
{
|
||||||
|
uint8_t publicKey[32], privateKey[32];
|
||||||
|
crypto->generateKeyPair(publicKey, privateKey);
|
||||||
|
config.has_security = true;
|
||||||
|
config.security.private_key.size = 32;
|
||||||
|
memcpy(config.security.private_key.bytes, privateKey, sizeof(privateKey));
|
||||||
|
|
||||||
|
mockNodeDB->installDefaultsPreservingIdentity();
|
||||||
|
TEST_ASSERT_EQUAL(32, config.security.private_key.size);
|
||||||
|
TEST_ASSERT_EQUAL_UINT8_ARRAY(privateKey, config.security.private_key.bytes, sizeof(privateKey));
|
||||||
|
TEST_ASSERT_EQUAL(0, config.security.public_key.size);
|
||||||
|
|
||||||
|
owner.is_licensed = true;
|
||||||
|
config.lora.region = meshtastic_Config_LoRaConfig_RegionCode_US;
|
||||||
|
TEST_ASSERT_TRUE(mockNodeDB->generateCryptoKeyPair());
|
||||||
|
TEST_ASSERT_EQUAL_UINT8_ARRAY(privateKey, config.security.private_key.bytes, sizeof(privateKey));
|
||||||
|
TEST_ASSERT_EQUAL_UINT8_ARRAY(publicKey, config.security.public_key.bytes, sizeof(publicKey));
|
||||||
|
}
|
||||||
|
|
||||||
|
void test_L4_licensed_low_entropy_identity_is_regenerated(void)
|
||||||
|
{
|
||||||
|
static const uint8_t compromisedPublicKey[32] = {
|
||||||
|
0xac, 0xaf, 0x8c, 0x1c, 0x3c, 0x1c, 0x37, 0xac, 0x4f, 0x03, 0xa1, 0xe9, 0xfc, 0x37, 0x23, 0x29,
|
||||||
|
0xc8, 0xa3, 0x5d, 0x7f, 0x05, 0x26, 0xeb, 0x00, 0xbd, 0x26, 0xb8, 0x2e, 0xb1, 0x94, 0x7d, 0x24,
|
||||||
|
};
|
||||||
|
owner.is_licensed = true;
|
||||||
|
config.lora.region = meshtastic_Config_LoRaConfig_RegionCode_US;
|
||||||
|
config.security.private_key.size = 32;
|
||||||
|
memset(config.security.private_key.bytes, 0xA5, 32);
|
||||||
|
config.security.public_key.size = 32;
|
||||||
|
memcpy(config.security.public_key.bytes, compromisedPublicKey, sizeof(compromisedPublicKey));
|
||||||
|
TEST_ASSERT_TRUE(mockNodeDB->checkLowEntropyPublicKey(config.security.public_key));
|
||||||
|
|
||||||
|
uint8_t oldPrivateKey[32];
|
||||||
|
memcpy(oldPrivateKey, config.security.private_key.bytes, sizeof(oldPrivateKey));
|
||||||
|
TEST_ASSERT_TRUE(mockNodeDB->generateCryptoKeyPair());
|
||||||
|
TEST_ASSERT_TRUE(mockNodeDB->keyIsLowEntropy);
|
||||||
|
TEST_ASSERT_FALSE(mockNodeDB->checkLowEntropyPublicKey(config.security.public_key));
|
||||||
|
TEST_ASSERT_FALSE(memcmp(oldPrivateKey, config.security.private_key.bytes, sizeof(oldPrivateKey)) == 0);
|
||||||
|
}
|
||||||
|
|
||||||
// ===========================================================================
|
// ===========================================================================
|
||||||
// Group D - encoding invariants the routing gates depend on
|
// Group D - encoding invariants the routing gates depend on
|
||||||
// ===========================================================================
|
// ===========================================================================
|
||||||
@@ -1605,6 +1834,12 @@ void test_E13_decoded_unsigned_nodeinfo_padded_inside_payload_dropped(void)
|
|||||||
void setup()
|
void setup()
|
||||||
{
|
{
|
||||||
initializeTestEnvironment();
|
initializeTestEnvironment();
|
||||||
|
AirTime *savedAirTime = airTime;
|
||||||
|
meshtastic::NodeStatus *savedNodeStatus = nodeStatus;
|
||||||
|
AirTime testAirTime;
|
||||||
|
meshtastic::NodeStatus testNodeStatus;
|
||||||
|
airTime = &testAirTime;
|
||||||
|
nodeStatus = &testNodeStatus;
|
||||||
|
|
||||||
config.lora.region = meshtastic_Config_LoRaConfig_RegionCode_US;
|
config.lora.region = meshtastic_Config_LoRaConfig_RegionCode_US;
|
||||||
initRegion();
|
initRegion();
|
||||||
@@ -1652,6 +1887,12 @@ void setup()
|
|||||||
RUN_TEST(test_B5_preset_signature_on_local_packet_cleared);
|
RUN_TEST(test_B5_preset_signature_on_local_packet_cleared);
|
||||||
RUN_TEST(test_B6_rich_shape_sweep_no_deadband);
|
RUN_TEST(test_B6_rich_shape_sweep_no_deadband);
|
||||||
RUN_TEST(test_B7_infrastructure_port_signing_matrix);
|
RUN_TEST(test_B7_infrastructure_port_signing_matrix);
|
||||||
|
RUN_TEST(test_B8_licensed_broadcast_and_unicast_are_signed);
|
||||||
|
RUN_TEST(test_B9_licensed_unicast_never_uses_pki_encryption);
|
||||||
|
RUN_TEST(test_B10_licensed_oversized_unicast_remains_unsigned);
|
||||||
|
RUN_TEST(test_B11_normal_unicast_still_uses_pki);
|
||||||
|
RUN_TEST(test_B12_licensed_receiver_does_not_decrypt_pki);
|
||||||
|
RUN_TEST(test_B13_licensed_port_and_destination_signing_matrix);
|
||||||
|
|
||||||
printf("\n=== Group C: routing pipeline authentication ordering ===\n");
|
printf("\n=== Group C: routing pipeline authentication ordering ===\n");
|
||||||
RUN_TEST(test_C1_invalid_first_copy_does_not_poison_valid_same_id);
|
RUN_TEST(test_C1_invalid_first_copy_does_not_poison_valid_same_id);
|
||||||
@@ -1675,6 +1916,12 @@ void setup()
|
|||||||
RUN_TEST(test_N6_signed_unicast_nodeinfo_from_signer_changes_name);
|
RUN_TEST(test_N6_signed_unicast_nodeinfo_from_signer_changes_name);
|
||||||
RUN_TEST(test_N7_unsigned_unicast_nodeinfo_from_nonsigner_changes_name);
|
RUN_TEST(test_N7_unsigned_unicast_nodeinfo_from_nonsigner_changes_name);
|
||||||
|
|
||||||
|
printf("\n=== Group L: licensed identity and plaintext signing ===\n");
|
||||||
|
RUN_TEST(test_L1_licensed_nodeinfo_publishes_public_key);
|
||||||
|
RUN_TEST(test_L2_licensed_identity_key_is_generated_and_preserved);
|
||||||
|
RUN_TEST(test_L3_factory_config_reset_preserves_valid_identity_private_key);
|
||||||
|
RUN_TEST(test_L4_licensed_low_entropy_identity_is_regenerated);
|
||||||
|
|
||||||
printf("\n=== Group D: encoding invariants ===\n");
|
printf("\n=== Group D: encoding invariants ===\n");
|
||||||
RUN_TEST(test_D1_signature_field_overhead_exact);
|
RUN_TEST(test_D1_signature_field_overhead_exact);
|
||||||
|
|
||||||
@@ -1692,7 +1939,10 @@ void setup()
|
|||||||
RUN_TEST(test_E12_decoded_unsigned_waypoint_padded_inside_payload_dropped);
|
RUN_TEST(test_E12_decoded_unsigned_waypoint_padded_inside_payload_dropped);
|
||||||
RUN_TEST(test_E13_decoded_unsigned_nodeinfo_padded_inside_payload_dropped);
|
RUN_TEST(test_E13_decoded_unsigned_nodeinfo_padded_inside_payload_dropped);
|
||||||
|
|
||||||
exit(UNITY_END());
|
const int result = UNITY_END();
|
||||||
|
airTime = savedAirTime;
|
||||||
|
nodeStatus = savedNodeStatus;
|
||||||
|
exit(result);
|
||||||
}
|
}
|
||||||
|
|
||||||
void loop() {}
|
void loop() {}
|
||||||
|
|||||||
Reference in New Issue
Block a user