fix: stop unexpected NodeNum regeneration from PKI key loss (#10808)

* fix: stop unexpected NodeNum regeneration from PKI key loss

A node's NodeNum is derived from its PKI public key
(my_node_num == crc32(public_key)), so it changes only when the keypair is
regenerated -- which happens only when a keygen path runs while
config.security.private_key.size != 32. Two paths could trigger that without
the user intending a new identity:

1. Boot: loadFromDisk() collapsed every non-success loadProto() result for the
   config file into installDefaultConfig() (preserveKey=false), which memset()s
   config and wipes the private key. loadProto() distinguishes DECODE_FAILED
   (file present but undecodable/undecryptable) from OTHER_FAILURE (absent), so
   a transient/corrupt read silently minted a new identity. A new
   configDecodeFailed flag now gates the boot keygen and the boot config-save
   directly (not via region, so it survives the userprefs/region overrides that
   run later in loadFromDisk): identity is frozen, the unreadable file is left
   intact for a later clean boot to recover, and the node boots radio-silent
   (region UNSET, TX off). A genuinely-absent config still gets defaults + keygen.

2. AdminModule security SET: config.security = c.payload_variant.security
   wholesale-clobbered the keypair, then regenerated whenever the incoming
   private_key.size != 32. A client editing an unrelated security field without
   round-tripping the private key would re-mint identity. The existing keypair
   is now preserved when the incoming config omits it; first provisioning and
   key import are unchanged. Intentional reset goes through factory_reset.

Native PlatformIO unit suite (Docker): 499/499 test cases pass.

* test: cover security keypair preservation; clarify load-failure comment

Addresses PR review feedback:
- Add native unit tests (test_admin_radio) asserting handleSetConfig preserves the
  existing identity keypair when a security SET omits the private key, and applies a
  full supplied keypair (key import). Guards against reintroduced NodeNum/keypair
  regeneration. AdminModuleTestShim (now a friend) defers saves so the lightweight
  harness doesn't saveToDisk an uninitialized node database.
- Clarify the non-DECODE_FAILED config-load comment: OTHER_FAILURE / NO_FILESYSTEM
  cover an absent or unopenable file, not just first boot.
This commit is contained in:
Ben Meadors
2026-06-28 18:48:24 -05:00
committed by GitHub
co-authored by GitHub
parent 9cef69a9d3
commit 0488a46a3c
5 changed files with 114 additions and 18 deletions
+63
View File
@@ -933,6 +933,9 @@ class AdminModuleTestShim : public AdminModule
{
public:
using AdminModule::handleSetConfig;
// Defer persistence so handleSetConfig() exercises the in-RAM config logic without saveToDisk() touching
// the (uninitialized) node database in this lightweight harness.
void deferSaves() { hasOpenEditTransaction = true; }
};
static AdminModuleTestShim *testAdmin;
@@ -1028,6 +1031,64 @@ static void test_handleSetConfig_fromOthers_invalidChannelNumFullyRejected()
TEST_ASSERT_EQUAL_UINT32(0, config.lora.channel_num);
}
// A security-config SET that omits the private key (partial/legacy client editing some other security field)
// must NOT regenerate our keypair: our NodeNum is crc32(public_key), so a new keypair would silently change
// our identity. The existing keypair has to be preserved.
static void test_handleSetConfig_security_preservesKeypairWhenPrivateOmitted()
{
config.security = meshtastic_Config_SecurityConfig_init_zero;
config.security.private_key.size = 32;
memset(config.security.private_key.bytes, 0x11, 32);
config.security.public_key.size = 32;
memset(config.security.public_key.bytes, 0x22, 32);
// Incoming SET carries no private/public key, just another security field.
meshtastic_Config c = meshtastic_Config_init_zero;
c.which_payload_variant = meshtastic_Config_security_tag;
c.payload_variant.security.serial_enabled = true;
testAdmin->deferSaves();
testAdmin->handleSetConfig(c, false);
uint8_t expectedPriv[32];
memset(expectedPriv, 0x11, 32);
uint8_t expectedPub[32];
memset(expectedPub, 0x22, 32);
TEST_ASSERT_EQUAL_UINT(32, config.security.private_key.size);
TEST_ASSERT_EQUAL_MEMORY(expectedPriv, config.security.private_key.bytes, 32);
TEST_ASSERT_EQUAL_UINT(32, config.security.public_key.size);
TEST_ASSERT_EQUAL_MEMORY(expectedPub, config.security.public_key.bytes, 32);
// The non-key field still applies.
TEST_ASSERT_TRUE(config.security.serial_enabled);
}
// A SET that DOES supply a full 32-byte keypair (legitimate key import) must apply it, not preserve the old one.
static void test_handleSetConfig_security_acceptsSuppliedKeypair()
{
config.security = meshtastic_Config_SecurityConfig_init_zero;
config.security.private_key.size = 32;
memset(config.security.private_key.bytes, 0x11, 32);
config.security.public_key.size = 32;
memset(config.security.public_key.bytes, 0x22, 32);
meshtastic_Config c = meshtastic_Config_init_zero;
c.which_payload_variant = meshtastic_Config_security_tag;
c.payload_variant.security.private_key.size = 32;
memset(c.payload_variant.security.private_key.bytes, 0x33, 32);
c.payload_variant.security.public_key.size = 32;
memset(c.payload_variant.security.public_key.bytes, 0x44, 32);
testAdmin->deferSaves();
testAdmin->handleSetConfig(c, false);
uint8_t expectedPriv[32];
memset(expectedPriv, 0x33, 32);
uint8_t expectedPub[32];
memset(expectedPub, 0x44, 32);
TEST_ASSERT_EQUAL_MEMORY(expectedPriv, config.security.private_key.bytes, 32);
TEST_ASSERT_EQUAL_MEMORY(expectedPub, config.security.public_key.bytes, 32);
}
static void test_regionInfo_supportsPreset()
{
const RegionInfo *eu868 = getRegion(meshtastic_Config_LoRaConfig_RegionCode_EU_868);
@@ -1197,6 +1258,8 @@ void setup()
RUN_TEST(test_handleSetConfig_fromLocal_invalidPresetClamped);
RUN_TEST(test_handleSetConfig_fromOthers_validPresetAccepted);
RUN_TEST(test_handleSetConfig_fromOthers_invalidChannelNumFullyRejected);
RUN_TEST(test_handleSetConfig_security_preservesKeypairWhenPrivateOmitted);
RUN_TEST(test_handleSetConfig_security_acceptsSuppliedKeypair);
RUN_TEST(test_regionInfo_supportsPreset);
RUN_TEST(test_checkConfigRegion_quietCheckReportsReason);
RUN_TEST(test_handleSetConfig_fromOthers_siblingLockedPresetSwapsRegion);