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
@@ -13,6 +13,7 @@ class AdminModuleTestShim : public AdminModule
|
||||
using AdminModule::handleReceivedProtobuf;
|
||||
using AdminModule::handleSetConfig;
|
||||
using AdminModule::handleSetModuleConfig;
|
||||
using AdminModule::handleSetOwner;
|
||||
using AdminModule::responseIsSolicited; // request/response pairing gate
|
||||
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.
|
||||
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.
|
||||
void drainReply()
|
||||
|
||||
@@ -6,5 +6,11 @@
|
||||
class MockMeshService : public MeshService
|
||||
{
|
||||
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)
|
||||
*/
|
||||
|
||||
#include "Channels.h"
|
||||
#include "DisplayFormatters.h"
|
||||
#include "FSCommon.h"
|
||||
#include "MeshRadio.h"
|
||||
#include "MeshService.h"
|
||||
#include "NodeDB.h"
|
||||
@@ -19,6 +21,9 @@
|
||||
#include "TestUtil.h"
|
||||
#include "mesh/Channels.h"
|
||||
#include "modules/AdminModule.h"
|
||||
#include "modules/NodeInfoModule.h"
|
||||
#include <pb_decode.h>
|
||||
#include <pb_encode.h>
|
||||
#include <string>
|
||||
#include <unity.h>
|
||||
#include <vector>
|
||||
@@ -949,6 +954,138 @@ static void test_channelSpacingCalculation_placeholder()
|
||||
|
||||
// AdminModuleTestShim comes from test/support - the friend seam AdminModule.h declares.
|
||||
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,
|
||||
meshtastic_Config_LoRaConfig_ModemPreset preset)
|
||||
@@ -961,6 +1098,27 @@ static meshtastic_Config makeLoraSetConfig(meshtastic_Config_LoRaConfig_RegionCo
|
||||
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()
|
||||
{
|
||||
// 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");
|
||||
}
|
||||
|
||||
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()
|
||||
{
|
||||
// Baseline: EU_866 (LITE profile)
|
||||
@@ -1515,6 +1683,7 @@ void setUp(void)
|
||||
}
|
||||
void tearDown(void)
|
||||
{
|
||||
restoreAdminRadioGlobals();
|
||||
service = nullptr;
|
||||
delete mockMeshService;
|
||||
mockMeshService = nullptr;
|
||||
@@ -1532,6 +1701,10 @@ void setup()
|
||||
UNITY_BEGIN();
|
||||
|
||||
// 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_EU868);
|
||||
RUN_TEST(test_getRegion_returnsCorrectRegion_LORA24);
|
||||
@@ -1618,6 +1791,7 @@ void setup()
|
||||
RUN_TEST(test_handleSetConfig_security_clearsAdminKeysWhenKeypairUnchanged);
|
||||
RUN_TEST(test_regionInfo_supportsPreset);
|
||||
RUN_TEST(test_checkConfigRegion_quietCheckReportsReason);
|
||||
RUN_TEST(test_checkConfigRegion_allowsProspectiveLicensedOwner);
|
||||
RUN_TEST(test_handleSetConfig_fromOthers_siblingLockedPresetSwapsRegion);
|
||||
RUN_TEST(test_handleSetConfig_fromOthers_lockedPresetFromNonTrioRegionRejected);
|
||||
|
||||
|
||||
@@ -12,7 +12,10 @@
|
||||
// Group E decoded-ingress policy (checkXeddsaReceivePolicy, the plaintext-MQTT trust boundary)
|
||||
|
||||
#include "MeshTypes.h" // include BEFORE TestUtil.h
|
||||
#include "NodeStatus.h"
|
||||
#include "TestUtil.h"
|
||||
#include "airtime.h"
|
||||
#include "support/MockMeshService.h"
|
||||
#include <unity.h>
|
||||
|
||||
// The whole suite exercises XEdDSA sign/verify and checkXeddsaReceivePolicy, all of which are
|
||||
@@ -34,6 +37,7 @@
|
||||
#include <cstdio>
|
||||
#include <cstring>
|
||||
#include <memory>
|
||||
#include <pb_decode.h>
|
||||
#include <pb_encode.h>
|
||||
#include <vector>
|
||||
|
||||
@@ -55,6 +59,8 @@ static constexpr size_t OVERSIZED_PAYLOAD = 180;
|
||||
class MockNodeDB : public NodeDB
|
||||
{
|
||||
public:
|
||||
void installDefaultsPreservingIdentity() { installDefaultConfig(true); }
|
||||
|
||||
void clearTestNodes()
|
||||
{
|
||||
testNodes.clear();
|
||||
@@ -364,6 +370,8 @@ static meshtastic_MeshPacket makeBroadcastWithUnknownFields()
|
||||
// ---------------------------------------------------------------------------
|
||||
void setUp(void)
|
||||
{
|
||||
service = pipelineService;
|
||||
|
||||
// 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
|
||||
// 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
|
||||
// ===========================================================================
|
||||
@@ -930,6 +1066,7 @@ void test_B7_infrastructure_port_signing_matrix(void)
|
||||
class NodeInfoTestShim : public NodeInfoModule
|
||||
{
|
||||
public:
|
||||
using NodeInfoModule::allocReply;
|
||||
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");
|
||||
}
|
||||
|
||||
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
|
||||
// ===========================================================================
|
||||
@@ -1605,6 +1834,12 @@ void test_E13_decoded_unsigned_nodeinfo_padded_inside_payload_dropped(void)
|
||||
void setup()
|
||||
{
|
||||
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;
|
||||
initRegion();
|
||||
@@ -1652,6 +1887,12 @@ void setup()
|
||||
RUN_TEST(test_B5_preset_signature_on_local_packet_cleared);
|
||||
RUN_TEST(test_B6_rich_shape_sweep_no_deadband);
|
||||
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");
|
||||
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_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");
|
||||
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_E13_decoded_unsigned_nodeinfo_padded_inside_payload_dropped);
|
||||
|
||||
exit(UNITY_END());
|
||||
const int result = UNITY_END();
|
||||
airTime = savedAirTime;
|
||||
nodeStatus = savedNodeStatus;
|
||||
exit(result);
|
||||
}
|
||||
|
||||
void loop() {}
|
||||
|
||||
Reference in New Issue
Block a user