Skip to content

fix(node): reconcile stale identity replays after renumbering#6259

Draft
jeremiah-k wants to merge 2 commits into
meshtastic:mainfrom
jeremiah-k:bugfix/node-identity-replay-reconciliation
Draft

fix(node): reconcile stale identity replays after renumbering#6259
jeremiah-k wants to merge 2 commits into
meshtastic:mainfrom
jeremiah-k:bugfix/node-identity-replay-reconciliation

Conversation

@jeremiah-k

@jeremiah-k jeremiah-k commented Jul 14, 2026

Copy link
Copy Markdown
Contributor

Overview

This makes live node-identity reconciliation deterministic after firmware 2.8 node renumbering while preserving the trusted boundary for durable identity migration.

Firmware 2.8 derives the canonical node number from the public key. A restored node database or delayed User packet can still present the same identity under an older node number, causing stale entries to compete with the canonical node, reappear after reload, or generate duplicate new-node notifications.

This separates two responsibilities:

  • trusted configuration installation from the directly connected radio remains responsible for durable database migration;
  • live remote mesh packets provide in-memory correlation hints only.

The in-memory reducer reconciles those packets without allowing an unauthenticated remote key to delete, renumber, or rewrite durable identity data.

Builds on: #6228
Depends on: #6256, #6255

Changes

  • Added stable public-key validation and canonical node-number resolution.
  • Added deterministic outcomes for canonical, stale, conflicting, ambiguous, placeholder, retired, reused, and locally authoritative identities.
  • Kept durable migration in the trusted configuration-install path, preserving notes, favorites, verification state, power-channel labels, and DM contact identity.
  • Treated public keys received from remote User packets as in-memory correlation hints only.
  • Applied node-state changes through an atomic compare-and-set retry loop.
  • Added reverse indexes so node-number, user-ID, and public-key candidate lookups remain consistent without full-map scans on packet hot paths.
  • Added fixed-size persistence lanes that serialize writes for the same node without retaining an unbounded mutex registry.
  • Added awaited update-and-persist operations where transport authority must remain held through completion.
  • Added a private node-database generation so a snapshot started before clear() cannot republish stale state afterward.
  • Kept transport-session generation separate from node-cache generation.
  • Preserved normal updates when duplicate or ambiguous historical identities exist rather than permanently dropping future packets.
  • Moved notification dispatch outside retryable state transformations and revalidated identity after asynchronous title formatting.
  • Suppressed new-node notifications for stale replays, conflicts, ambiguous matches, migrations, and other reconciliation-only outcomes.
  • Limited notification dispatch to genuine first sightings with complete metadata.

Validation

Added coverage for:

  • canonical and stale identities after firmware 2.8 renumbering;
  • trusted durable migration versus remote-packet trust boundaries;
  • preservation of notes, favorites, verification state, labels, and DM identity;
  • placeholder, locally authoritative, conflicting, ambiguous, retired, and legitimately reused identities;
  • malformed, missing, rotated, and duplicate public keys;
  • stale snapshot suppression across clear and reload races;
  • consistent node-number, user-ID, and public-key indexes;
  • concurrent state updates and ordered per-node persistence;
  • awaited persistence remaining inside transport-session authority;
  • genuine new-node notifications and stale-replay suppression;
  • notification revalidation across asynchronous formatting;
  • database reloads not resurrecting stale in-memory representatives.

Hardware validation completed configuration against firmware 2.8 over TCP and firmware 2.7 over BLE. After trusted migration of a stale identity, normal packet and telemetry processing continued without repeated new-node notifications, durable local-state loss, or a stale-replay loop.

@coderabbitai

coderabbitai Bot commented Jul 14, 2026

Copy link
Copy Markdown
Contributor

Important

Review skipped

Draft detected.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 663af1c2-7ea4-4ce7-914b-147b23418d9d

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@jamesarich jamesarich left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The problem this targets is real — stale identity replays after 2.8 renumbering absolutely need handling, and the reducer + CAS structure with this level of test coverage is a solid foundation. But I don't think this can merge in its current shape: the durable side of the design crosses a trust boundary we deliberately drew, and the identity keying is on the wrong identifier. Concretely:

1. Durable deletes are triggered by unauthenticated mesh packets. On main, NodeInfoDao restricts identity migration to installConfig specifically because those entries "come from the connected device's own vetted node DB over the local link" — anyone can broadcast a NodeInfo. This PR's applyReceivedUserEffects dispatches nodeRepository.reconcileIdentity(deleteNums, upsert) — durable row deletion — from any received User packet. Since public keys are broadcast, an attacker can send User(victim_pubkey) from num = crc32(victim_pubkey): the app durably deletes the victim's real row and accepts attacker-chosen names into the canonical slot (the placeholder base means hasPKC is false, so the incoming key is accepted). Durable identity mutation needs to stay gated on the trusted local link.

2. reconcileIdentity drops app-local state that migrateNodeIdentity exists to preserve. The new DAO method is a bare delete + upsert: notes, isFavorite/isMuted/isIgnored, manuallyVerified, and powerChannelLabels are lost, and remapDmContactKey never runs — so the DM thread stays orphaned under the old !id. A favorited contact upgrading to 2.8 loses your notes and detaches the conversation on their first canonical packet. Any durable path here should go through (or replicate) migrateNodeIdentity semantics.

3. Public key is the wrong canonical identity for durable decisions. Pubkeys change on every firmware flash, erase, and re-key — that's the very event that causes renumbering. device_id (see DeviceIdentity.kt) is the factory-burned identity we treat as canonical, which is why #6228 keyed the install migration on it. Keying durable deletes on crc32(pubkey) means (a) re-keys can never be correlated (the old entry lingers as a ghost and fires a new-node notification anyway), and (b) the machinery entrenches pubkey-as-identity in exactly the layer where we decided it isn't. As a wire-level correlation hint for in-memory dedup, pubkey is fine; as the key for row deletion, it isn't.

4. Two behavior regressions vs main:

  • When the same key sits at two non-canonical nums (which installNodeInfo can create — it writes the device nodedb verbatim without reconciliation), the reducer returns an empty plan and drops the packet: every subsequent User packet from either num is ignored indefinitely, so renames and key updates never apply. main always applied the update in-memory.
  • isGeneratedPlaceholder matches any real node known only from position/telemetry (no key, UNSET hw, default names), so the stale-replay path can durably delete such a node's row — position history included — when a same-key replay arrives at its num.

5. Hot-path cost. NodeIndex.put/remove now full-scan byNum.entries per affected user-id, and put runs on every position/telemetry/status packet via updateNode — O(N) per packet on a 1000-node DB where main was O(1). The reducer also scans all entries per keyed User packet with no fast-path exit when fromNum already holds the key canonically. This needs a reverse index or a fast path before it sits on the packet pipeline.

Smaller notes: the comment on the local-authoritative path says "no repository delete calls," but the branch returns PersistencePlan(deleteNums = otherSameKeyNums, ...) which does exactly that — one of the two needs to change (and given #1, I'd hope it's the code); the myNodeNum guard in the CAS loop is check-then-act (it can change between the read and the CAS, which only covers nodeIndex); and when isNodeDbReady is false, in-memory removals commit while persistence is skipped, so a restart resurrects the rows for packet-by-packet re-reconciliation.

Suggested shape: keep the reducer and CAS for in-memory dedup and notification suppression (that part is good and well-tested), but route all durable identity mutation through the existing installConfig/migrateNodeIdentity path on the trusted local link, keyed on device_id where available. Happy to talk through the design — the in-memory half of this is genuinely useful as-is.

@jeremiah-k

Copy link
Copy Markdown
Contributor Author

@jamesarich Thanks — I agree that the durable part currently crosses the wrong trust boundary.

I’ll remove the packet-triggered delete-before-upsert reconciliation API and keep public keys only as an in-memory correlation hint. Remote User packets will be able to reconcile duplicate presentation and suppress false new-node notifications in memory, but they will not delete, renumber, or remap persistent rows.

Durable identity migration will remain exclusively in the trusted installConfig / migrateNodeIdentity path, using device_id where available and preserving notes, favorites, mute/ignore state, verification, power-channel labels, metadata, and DM contact keys.

I’ll also replace the per-update full scans with incremental reverse indexes, add a canonical fast path, preserve updates when duplicate same-key entries are both noncanonical, and stop treating a telemetry-only placeholder as safe to remove because of an incoming remote key.

The local-node number will be included in the same atomic reconciliation state, rather than relying on the current check-then-act guard. The tests will be rewritten around in-memory packet reconciliation and trusted durable migration, with the new packet-driven repository deletion API removed entirely.

@jeremiah-k
jeremiah-k force-pushed the bugfix/node-identity-replay-reconciliation branch from c6e1827 to 8dc7ad6 Compare July 16, 2026 18:18
Reconcile node identities in memory when firmware 2.8 renumbering or delayed
packets present the same validated public key under multiple node numbers. Use
one atomic reducer state for the node map, local-node number, reverse ID index,
public-key candidates, and session-retired identities so readers never observe
an index that disagrees with the published nodes.

Treat public keys learned from remote User packets as correlation hints, not as
authority to delete or migrate persistent history. Durable number migration
remains owned by trusted configuration installation and the existing
migrateNodeIdentity repository path. Apply those trusted removals to the
in-memory reducer before cancelling old-number notifications, closing both the
already-posted and post-selection notification windows without running logging
or notification side effects inside a retryable CAS transform.

Retain validated old-key hints when trusted migration removes a known identity.
Suppress delayed field packets, keyless or invalid User packets, same-key
replays, and keys already represented elsewhere for the active database
session. Conservatively keep a retired slot closed when trusted migration had
no old-key evidence; permit different-key reuse only when a trusted old hint
exists and the incoming key is otherwise unrepresented.

When legitimate reuse is accepted, clear both the retired-number marker and its
old-key hint in the same successful state transition. Select canonical
representatives deterministically, preserve placeholders and conflicting
different-key identities, and reset session retirement when the node database
is cleared or reloaded.

Keep diagnostics privacy-safe by logging only a short SHA-256 fingerprint of a
validated public key. Never expose raw key prefixes, user identity fields,
device identifiers, or transport addresses while explaining reconciliation
decisions.
Exercise the identity reducer and trusted-migration boundary across canonical,
noncanonical, placeholder, conflict, and local-node presentations. Verify
deterministic representative selection and reverse-index consistency as node
IDs, numbers, and public keys arrive in different orders.

Cover delayed replay suppression for every mutable packet field handled by the
manager, including telemetry, position, paxcounter, status, NodeInfo, metadata,
keyless User, invalid-key User, and User packets carrying an already represented
key. Confirm that remote correlation remains in memory and does not invoke a
durable packet-triggered delete-before-upsert repository operation.

Use suspending title-format barriers to pause genuine notification candidates
after reducer commit but before dispatch. Change retirement, local-node, and
identity state while paused, then prove exact stale notifications are skipped.
Observe notification cancellation directly to verify trusted retirement is
already committed before the cancellation side effect runs.

Verify keyless trusted retirements remain closed even for an unrepresented
valid key, same-key replay stays suppressed without a canonical candidate, and
different-key reuse requires a captured trusted hint. Confirm accepted reuse
clears both retirement collections and emits one replacement notification,
while complete 2.7-to-2.8 migration suppresses early old-number replay.

Retain genuine unrelated first-sighting coverage, clear/reload session
isolation, trusted installConfig ordering, and a direct assertion that
diagnostic key values are one-way fingerprints rather than raw prefixes. Run
all coroutine fixtures through their existing TestScope so JVM and Android-host
test execution share a valid scheduler and exception handler.
@jeremiah-k
jeremiah-k force-pushed the bugfix/node-identity-replay-reconciliation branch from 8dc7ad6 to d7d7a52 Compare July 18, 2026 22:54
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bugfix PR tag

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants