From 579d26e1b2f21bc3c9153cfda6b8b1ad4073973e Mon Sep 17 00:00:00 2001 From: Clive Blackledge Date: Wed, 12 Aug 2026 05:22:53 -0700 Subject: [PATCH] fix(NodeDB): don't let an empty contact key erase a stored public key (#11432) Clients send `add_contact` before every text-message DM, because a phone often holds a larger contact database (with public keys) than the radio can keep. That makes `addFromContact()` the highest-volume key-write path on the device - and it had no protection against key erasure. Its only key guard covered the manually-verified case: if the local entry was marked manually verified and the incoming contact was not, a key mismatch aborted the update. Every ordinary entry fell straight through to `CopyUserToNodeInfoLite()`, which assigns `public_key` unconditionally. So a SharedContact with `has_user` set and an empty `public_key` overwrote a peer's stored, XEdDSA-proven key with zeros - and `addFromContact()` calls `saveNodeDatabaseToDisk()`, so the erasure survived a reboot. Subsequent DMs to that peer then failed with PKI_SEND_FAIL_PUBLIC_KEY, with no way to recover until the peer's NodeInfo was re-exchanged. `public_key` is a singular (non-optional) bytes field, so "absent" and "empty" both decode to size 0; a client that simply has no key for a contact is indistinguishable on the wire from one asking to clear it. The fix is deliberately narrow: keep the stored key when the entry already holds a full 32-byte key and the incoming contact does not. A well-formed 32-byte contact key still updates the entry exactly as before. Deliberately NOT changed here: - `updateUser()`'s first-key-wins pin is not applied to this path. Clients legitimately use add_contact to supply keys the radio never had and to update them (QR-code contact sharing); a blanket pin would break that documented flow. Only erasure is refused. - `CopyUserToNodeInfoLite()` itself is untouched - it has many other callers (self-record refresh, updateUser, warm-tier rehydration), so the guard lives at this call site. - The manually-verified branch is unchanged. - Node-number validation (reserved/broadcast/self) on this path remains open and is tracked separately. Co-authored-by: Claude Opus 5 --- src/mesh/NodeDB.cpp | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/mesh/NodeDB.cpp b/src/mesh/NodeDB.cpp index e6c1ac67e..d24473125 100644 --- a/src/mesh/NodeDB.cpp +++ b/src/mesh/NodeDB.cpp @@ -3482,7 +3482,16 @@ void NodeDB::addFromContact(meshtastic_SharedContact contact) } } info->num = contact.node_num; + // CopyUserToNodeInfoLite assigns public_key unconditionally, and clients send add_contact before every + // DM - often from an entry that carries no key at all. A contact may still supply or update a full + // 32-byte key (that's what add_contact is for), but it must never *erase* a key we already hold, which + // would be persisted below and break subsequent DMs with PKI_SEND_FAIL_PUBLIC_KEY. + const meshtastic_NodeInfoLite_public_key_t storedKey = info->public_key; TypeConversions::CopyUserToNodeInfoLite(info, contact.user); + if (storedKey.size == 32 && info->public_key.size != 32) { + LOG_INFO("Contact 0x%08x has no key, keep the stored one", contact.node_num); + info->public_key = storedKey; + } if (contact.should_ignore) { // Block the contact and drop its rich satellite data, but keep the // public key copied above - an ignored peer keeps a usable identity