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
+15 -6
View File
@@ -1037,16 +1037,24 @@ void AdminModule::handleSetConfig(const meshtastic_Config &c, bool fromOthers)
config.has_bluetooth = true;
config.bluetooth = c.payload_variant.bluetooth;
break;
case meshtastic_Config_security_tag:
case meshtastic_Config_security_tag: {
LOG_INFO("Set config: Security");
config.security = c.payload_variant.security;
meshtastic_Config_SecurityConfig incoming = c.payload_variant.security;
// Preserve our keypair when a SET omits the private key but we already hold one: regenerating would
// change our NodeNum (== crc32(public_key)) and orphan us on the mesh. A SET without the key is a
// partial/legacy client, not an identity reset (that goes through factory_reset). Done outside the
// PKI guard so non-PKI builds keep their key bytes too.
if (incoming.private_key.size != 32 && config.security.private_key.size == 32) {
LOG_WARN("Security set omitted private key; preserving existing identity keypair");
incoming.private_key = config.security.private_key;
incoming.public_key = config.security.public_key;
}
config.security = incoming;
#if !(MESHTASTIC_EXCLUDE_PKI_KEYGEN) && !(MESHTASTIC_EXCLUDE_PKI)
// Only regenerate keys if the private key is not 32 bytes
// First provisioning (no key) generates one; a private key supplied without its public key derives it.
if (config.security.private_key.size != 32) {
nodeDB->generateCryptoKeyPair();
}
// If user provided a private key of correct size but no public key, generate the public key from private key
else if (config.security.private_key.size == 32 && config.security.public_key.size == 0) {
} else if (config.security.public_key.size == 0) {
nodeDB->generateCryptoKeyPair(config.security.private_key.bytes);
}
#endif
@@ -1063,6 +1071,7 @@ void AdminModule::handleSetConfig(const meshtastic_Config &c, bool fromOthers)
requiresReboot = true;
break;
}
case meshtastic_Config_device_ui_tag:
// NOOP! This is handled by handleStoreDeviceUIConfig
break;