From 458b9fbbb734da506f249df8f20bc391c57f1594 Mon Sep 17 00:00:00 2001 From: ssmithx Date: Sat, 4 Jul 2026 06:18:08 +0000 Subject: [PATCH] fix(mesh): bind Reticulum peer identity directly, not via name-matching MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- core/archipelago/src/mesh/listener/session.rs | 18 ++++++--- core/archipelago/src/mesh/meshtastic.rs | 2 + core/archipelago/src/mesh/protocol.rs | 10 +++++ core/archipelago/src/mesh/reticulum.rs | 38 ++++++++++++------- 4 files changed, 50 insertions(+), 18 deletions(-) diff --git a/core/archipelago/src/mesh/listener/session.rs b/core/archipelago/src/mesh/listener/session.rs index c1312dbc..04f89bb1 100644 --- a/core/archipelago/src/mesh/listener/session.rs +++ b/core/archipelago/src/mesh/listener/session.rs @@ -679,11 +679,19 @@ async fn refresh_contacts(device: &mut MeshRadioDevice, state: &Arc) advert_name: contact.advert_name.clone(), did: existing.and_then(|p| p.did.clone()), pubkey_hex: Some(contact.public_key_hex.clone()), - // Preserve any archipelago identity bound by an earlier - // identity advert — NEVER overwrite it with the firmware - // contact key, or a signed `!ai` query from this peer would - // fail authentication after the next contact refresh. - arch_pubkey_hex: existing.and_then(|p| p.arch_pubkey_hex.clone()), + // Reticulum carries the archipelago identity in-band with + // the contact snapshot itself (see `ParsedContact:: + // arch_pubkey_hex`'s doc comment) — prefer it when this + // refresh's snapshot has one. Otherwise preserve whatever + // 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), // Meshtastic-only today (see ParsedContact) — falls back to // whatever was already known if this refresh's contact diff --git a/core/archipelago/src/mesh/meshtastic.rs b/core/archipelago/src/mesh/meshtastic.rs index 1245e5fe..bebb461b 100644 --- a/core/archipelago/src/mesh/meshtastic.rs +++ b/core/archipelago/src/mesh/meshtastic.rs @@ -982,6 +982,7 @@ impl MeshtasticDevice { snr, lat, lon, + arch_pubkey_hex: None, }, ); } @@ -1054,6 +1055,7 @@ fn packet_to_inbound_frame( snr: None, lat: None, lon: None, + arch_pubkey_hex: None, }); if packet.rx_rssi.is_some() { contact.rssi = packet.rx_rssi.map(|v| v as i16); diff --git a/core/archipelago/src/mesh/protocol.rs b/core/archipelago/src/mesh/protocol.rs index 7835d95f..807a07d1 100644 --- a/core/archipelago/src/mesh/protocol.rs +++ b/core/archipelago/src/mesh/protocol.rs @@ -406,6 +406,15 @@ pub struct ParsedContact { /// contact has shared one. pub lat: Option, pub lon: Option, + /// 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, } /// Parse RESP_CONTACT (0x03) response. @@ -457,6 +466,7 @@ pub fn parse_contact(data: &[u8]) -> Result { snr: None, lat: None, lon: None, + arch_pubkey_hex: None, }) } diff --git a/core/archipelago/src/mesh/reticulum.rs b/core/archipelago/src/mesh/reticulum.rs index d2b89085..d6c557d2 100644 --- a/core/archipelago/src/mesh/reticulum.rs +++ b/core/archipelago/src/mesh/reticulum.rs @@ -169,9 +169,12 @@ fn daemon_command( struct ReticulumPeer { dest_hash: [u8; 16], display_name: String, - /// Archy ed25519 identity hex, once carried in a verified announce - /// app-data blob. Not yet wired (TODO, lands with the signed-announce - /// work) — present so `get_contacts` has a stable shape to extend into. + /// Archy ed25519 identity hex, once carried in this peer's own announce + /// app-data blob (`ARCHY:n:...`) — see `handle_event`'s "announce" arm. + /// 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, reachable: bool, } @@ -645,6 +648,7 @@ impl ReticulumLink { snr: None, lat: None, lon: None, + arch_pubkey_hex: p.arch_pubkey_hex.clone(), }) .collect()) } @@ -723,17 +727,21 @@ impl ReticulumLink { .filter(|s| !s.is_empty()); // If the announce app_data is an ARCHY:n: identity blob (see - // daemon_command's doc comment), bind it onto this peer AND - // surface it through the SAME channel-text path - // meshcore/Meshtastic identity adverts use + // daemon_command's doc comment), bind the ed25519 hex directly + // onto this RNS-hash-keyed peer below (unambiguous — it came + // 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 - // -> handle_identity_received -> bind_federation_twins), so a - // Reticulum-carried identity merges into the same conversation - // as that node's other-transport twins — zero new bind logic. - let is_identity_blob = app_data_text + // -> handle_identity_received), so it also lands on that + // transport's federation-twin peer for UI/contact purposes. + // `group_peer_twins` can then collapse the two rows since both + // 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() - .map(|t| protocol::parse_identity_broadcast(t).is_some()) - .unwrap_or(false); + .and_then(protocol::parse_identity_broadcast); + let is_identity_blob = parsed_identity.is_some(); if is_identity_blob { let text = app_data_text.clone().unwrap(); let mut data = Vec::with_capacity(7 + text.len()); @@ -746,6 +754,7 @@ impl ReticulumLink { bytes_consumed: 0, }); } + let arch_pubkey_hex = parsed_identity.map(|(_did, ed_pubkey, _x25519)| ed_pubkey); let display_name = app_data_text .filter(|_| !is_identity_blob) @@ -755,11 +764,14 @@ impl ReticulumLink { .and_modify(|p| { p.display_name = display_name.clone(); p.reachable = true; + if arch_pubkey_hex.is_some() { + p.arch_pubkey_hex = arch_pubkey_hex.clone(); + } }) .or_insert(ReticulumPeer { dest_hash: hash, display_name, - arch_pubkey_hex: None, + arch_pubkey_hex, reachable: true, }); self.persist_peers();