fix(mesh): bind Reticulum peer identity directly, not via name-matching

Two MeshPeer rows were being created for one physical Reticulum node: a
radio twin (keyed by the RNS dest_hash, arch_pubkey_hex always None) and a
pseudo-federation twin (keyed by the archy ed25519 pubkey, created via the
generic identity-broadcast path meant for Meshcore/Meshtastic). The generic
path relies on bind_federation_twins matching both twins' advert_name, but
the Reticulum radio twin's display_name is deliberately never the identity
text — so the two rows could never merge, and the generic send path (keyed
off whichever twin the caller resolves) ended up looking up the archy
pubkey's prefix in ReticulumLink's `prefix_to_hash` map, which is only ever
populated with RNS dest_hash prefixes. Every send failed with "Unknown
Reticulum prefix ... peer hasn't announced yet", confirmed live between two
real nodes (archy-x250-exp / archy-x250-pa) that could see each other's
adverts but never exchange a message.

Unlike Meshcore/Meshtastic, Reticulum's ARCHY identity blob arrives in the
same announce event as the destination hash, so there's no ambiguity about
which peer it belongs to — bind it directly onto the RNS-hash-keyed radio
peer instead of relying on name-matching. Threaded through a new
`ParsedContact::arch_pubkey_hex` (None for Meshcore/Meshtastic, unchanged
behavior there) so `refresh_contacts` can set it on the correct peer row
without touching `bind_federation_twins`/`group_peer_twins`, which already
know how to collapse twins once they share an arch_pubkey_hex.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
ssmithx 2026-07-04 06:18:08 +00:00
parent 893508b700
commit 458b9fbbb7
4 changed files with 50 additions and 18 deletions

View File

@ -679,11 +679,19 @@ async fn refresh_contacts(device: &mut MeshRadioDevice, state: &Arc<MeshState>)
advert_name: contact.advert_name.clone(), advert_name: contact.advert_name.clone(),
did: existing.and_then(|p| p.did.clone()), did: existing.and_then(|p| p.did.clone()),
pubkey_hex: Some(contact.public_key_hex.clone()), pubkey_hex: Some(contact.public_key_hex.clone()),
// Preserve any archipelago identity bound by an earlier // Reticulum carries the archipelago identity in-band with
// identity advert — NEVER overwrite it with the firmware // the contact snapshot itself (see `ParsedContact::
// contact key, or a signed `!ai` query from this peer would // arch_pubkey_hex`'s doc comment) — prefer it when this
// fail authentication after the next contact refresh. // refresh's snapshot has one. Otherwise preserve whatever
arch_pubkey_hex: existing.and_then(|p| p.arch_pubkey_hex.clone()), // was bound by an earlier identity advert (Meshcore/
// Meshtastic's `bind_federation_twins` path): NEVER
// overwrite a known identity with the firmware contact
// key, or a signed `!ai` query from this peer would fail
// authentication after the next contact refresh.
arch_pubkey_hex: contact
.arch_pubkey_hex
.clone()
.or_else(|| existing.and_then(|p| p.arch_pubkey_hex.clone())),
x25519_pubkey: existing.and_then(|p| p.x25519_pubkey), x25519_pubkey: existing.and_then(|p| p.x25519_pubkey),
// Meshtastic-only today (see ParsedContact) — falls back to // Meshtastic-only today (see ParsedContact) — falls back to
// whatever was already known if this refresh's contact // whatever was already known if this refresh's contact

View File

@ -982,6 +982,7 @@ impl MeshtasticDevice {
snr, snr,
lat, lat,
lon, lon,
arch_pubkey_hex: None,
}, },
); );
} }
@ -1054,6 +1055,7 @@ fn packet_to_inbound_frame(
snr: None, snr: None,
lat: None, lat: None,
lon: None, lon: None,
arch_pubkey_hex: None,
}); });
if packet.rx_rssi.is_some() { if packet.rx_rssi.is_some() {
contact.rssi = packet.rx_rssi.map(|v| v as i16); contact.rssi = packet.rx_rssi.map(|v| v as i16);

View File

@ -406,6 +406,15 @@ pub struct ParsedContact {
/// contact has shared one. /// contact has shared one.
pub lat: Option<f64>, pub lat: Option<f64>,
pub lon: Option<f64>, pub lon: Option<f64>,
/// Archipelago ed25519 identity hex, when this transport carried it
/// in-band with the contact announce itself (Reticulum only today — the
/// RNS announce's app_data can embed an `ARCHY:n:` identity blob
/// alongside the destination hash in the same event, so there's no
/// ambiguity about which physical peer it belongs to). Meshcore/
/// Meshtastic identity adverts go out on a separate channel and are
/// correlated after the fact by `bind_federation_twins`'s advert_name
/// matching instead, so they always leave this `None`.
pub arch_pubkey_hex: Option<String>,
} }
/// Parse RESP_CONTACT (0x03) response. /// Parse RESP_CONTACT (0x03) response.
@ -457,6 +466,7 @@ pub fn parse_contact(data: &[u8]) -> Result<ParsedContact> {
snr: None, snr: None,
lat: None, lat: None,
lon: None, lon: None,
arch_pubkey_hex: None,
}) })
} }

View File

@ -169,9 +169,12 @@ fn daemon_command(
struct ReticulumPeer { struct ReticulumPeer {
dest_hash: [u8; 16], dest_hash: [u8; 16],
display_name: String, display_name: String,
/// Archy ed25519 identity hex, once carried in a verified announce /// Archy ed25519 identity hex, once carried in this peer's own announce
/// app-data blob. Not yet wired (TODO, lands with the signed-announce /// app-data blob (`ARCHY:n:...`) — see `handle_event`'s "announce" arm.
/// work) — present so `get_contacts` has a stable shape to extend into. /// Unlike Meshcore/Meshtastic, this arrives in-band with the same event
/// as the destination hash, so it can be bound directly onto this
/// RNS-hash-keyed peer with no name-matching ambiguity (contrast
/// `bind_federation_twins`, which those two transports rely on instead).
arch_pubkey_hex: Option<String>, arch_pubkey_hex: Option<String>,
reachable: bool, reachable: bool,
} }
@ -645,6 +648,7 @@ impl ReticulumLink {
snr: None, snr: None,
lat: None, lat: None,
lon: None, lon: None,
arch_pubkey_hex: p.arch_pubkey_hex.clone(),
}) })
.collect()) .collect())
} }
@ -723,17 +727,21 @@ impl ReticulumLink {
.filter(|s| !s.is_empty()); .filter(|s| !s.is_empty());
// If the announce app_data is an ARCHY:n: identity blob (see // If the announce app_data is an ARCHY:n: identity blob (see
// daemon_command's doc comment), bind it onto this peer AND // daemon_command's doc comment), bind the ed25519 hex directly
// surface it through the SAME channel-text path // onto this RNS-hash-keyed peer below (unambiguous — it came
// meshcore/Meshtastic identity adverts use // in the same event as `hash`) AND surface it through the same
// channel-text path meshcore/Meshtastic identity adverts use
// (frames::handle_channel_payload -> parse_identity_broadcast // (frames::handle_channel_payload -> parse_identity_broadcast
// -> handle_identity_received -> bind_federation_twins), so a // -> handle_identity_received), so it also lands on that
// Reticulum-carried identity merges into the same conversation // transport's federation-twin peer for UI/contact purposes.
// as that node's other-transport twins — zero new bind logic. // `group_peer_twins` can then collapse the two rows since both
let is_identity_blob = app_data_text // now carry the same `arch_pubkey_hex`, instead of relying on
// `bind_federation_twins`'s advert_name matching, which never
// matches here — see `display_name` below.
let parsed_identity = app_data_text
.as_deref() .as_deref()
.map(|t| protocol::parse_identity_broadcast(t).is_some()) .and_then(protocol::parse_identity_broadcast);
.unwrap_or(false); let is_identity_blob = parsed_identity.is_some();
if is_identity_blob { if is_identity_blob {
let text = app_data_text.clone().unwrap(); let text = app_data_text.clone().unwrap();
let mut data = Vec::with_capacity(7 + text.len()); let mut data = Vec::with_capacity(7 + text.len());
@ -746,6 +754,7 @@ impl ReticulumLink {
bytes_consumed: 0, bytes_consumed: 0,
}); });
} }
let arch_pubkey_hex = parsed_identity.map(|(_did, ed_pubkey, _x25519)| ed_pubkey);
let display_name = app_data_text let display_name = app_data_text
.filter(|_| !is_identity_blob) .filter(|_| !is_identity_blob)
@ -755,11 +764,14 @@ impl ReticulumLink {
.and_modify(|p| { .and_modify(|p| {
p.display_name = display_name.clone(); p.display_name = display_name.clone();
p.reachable = true; p.reachable = true;
if arch_pubkey_hex.is_some() {
p.arch_pubkey_hex = arch_pubkey_hex.clone();
}
}) })
.or_insert(ReticulumPeer { .or_insert(ReticulumPeer {
dest_hash: hash, dest_hash: hash,
display_name, display_name,
arch_pubkey_hex: None, arch_pubkey_hex,
reachable: true, reachable: true,
}); });
self.persist_peers(); self.persist_peers();