From ef601c6d2638a330b3d90e24a50af8a63141d265 Mon Sep 17 00:00:00 2001 From: archipelago Date: Wed, 17 Jun 2026 18:20:12 -0400 Subject: [PATCH] feat(mesh): wire ARCHY identity broadcast for trust over both radios (#50) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The ARCHY:2 identity broadcast (DID + ed25519 + x25519) was unwired dead code on both send and receive. Wiring it lets a radio peer prove its archipelago identity, so the assistant's trusted-only gate (and encrypted DMs) work over meshcore AND Meshtastic — the latter otherwise only exposes synthetic node keys. - session.rs: broadcast ARCHY:2 as channel text at startup + each advert tick - frames.rs: parse inbound ARCHY:2 on the channel path, dedupe-keyed by archipelago pubkey (federation_peer_contact_id) so it MERGES with the federation-seeded peer instead of duplicating; self-echo guarded - threads our_x25519_secret into handle_channel_payload (was reserved) Reuses the existing handle_identity_received verifier (ed/x25519 consistency check + shared-secret derivation). Compiles clean. Needs a live 2-radio test before trusting trusted-only over radio. Co-Authored-By: Claude Opus 4.8 (1M context) --- core/archipelago/src/mesh/listener/frames.rs | 48 ++++++++++++++++--- core/archipelago/src/mesh/listener/session.rs | 22 ++++++++- 2 files changed, 62 insertions(+), 8 deletions(-) diff --git a/core/archipelago/src/mesh/listener/frames.rs b/core/archipelago/src/mesh/listener/frames.rs index 82658208..78fbba36 100644 --- a/core/archipelago/src/mesh/listener/frames.rs +++ b/core/archipelago/src/mesh/listener/frames.rs @@ -3,8 +3,8 @@ use super::super::message_types::TypedEnvelope; use super::super::protocol; use super::decode::{ - is_mc_chunk_frame, resolve_peer, store_plain_message, try_base64_typed, try_chunk_reassemble, - try_decrypt_base64, try_decrypt_ratchet_base64, + handle_identity_received, is_mc_chunk_frame, resolve_peer, store_plain_message, + try_base64_typed, try_chunk_reassemble, try_decrypt_base64, try_decrypt_ratchet_base64, }; use super::dispatch::handle_typed_message; use super::MeshState; @@ -18,7 +18,6 @@ pub(super) async fn handle_frame( state: &Arc, our_x25519_secret: &[u8; 32], ) -> bool { - let _ = our_x25519_secret; // reserved for future per-frame decryption match frame.code { protocol::PUSH_NEW_CONTACT | protocol::PUSH_CONTACT_ADVERT => { info!( @@ -109,7 +108,8 @@ pub(super) async fn handle_frame( match protocol::parse_channel_msg_v3_raw(&frame.data) { Ok((channel_idx, payload)) => { if !payload.is_empty() { - handle_channel_payload(state, channel_idx, &payload).await; + handle_channel_payload(state, channel_idx, &payload, our_x25519_secret) + .await; } } Err(e) => warn!("Failed to parse v3 channel message: {}", e), @@ -121,7 +121,8 @@ pub(super) async fn handle_frame( match protocol::parse_channel_msg_v1_raw(&frame.data) { Ok((channel_idx, payload)) => { if !payload.is_empty() { - handle_channel_payload(state, channel_idx, &payload).await; + handle_channel_payload(state, channel_idx, &payload, our_x25519_secret) + .await; } } Err(e) => warn!("Failed to parse channel message: {}", e), @@ -146,7 +147,12 @@ pub(super) async fn handle_frame( /// local mesh peer pubkeys (or we can't tell), the inner payload is /// dispatched through the direct-message path so it lands in the right /// chat. Otherwise it's handled as a normal channel text/typed message. -async fn handle_channel_payload(state: &Arc, channel_idx: u8, payload: &[u8]) { +async fn handle_channel_payload( + state: &Arc, + channel_idx: u8, + payload: &[u8], + our_x25519_secret: &[u8; 32], +) { // DM-via-channel wrapper (text form): the channel text carries an // ASCII "@DM:" token somewhere in the body. We locate the // marker anywhere in the payload (the firmware auto-prepends the @@ -326,6 +332,36 @@ async fn handle_channel_payload(state: &Arc, channel_idx: u8, payload return; } + // Archipelago identity broadcast (`ARCHY:`): upsert the sender's real + // archipelago identity (DID + ed25519 + x25519) so trust-gating and + // encrypted DMs work over BOTH meshcore and Meshtastic — the latter + // otherwise only exposes synthetic node keys. Keyed by the archipelago + // pubkey (federation_peer_contact_id) so it MERGES with the federation- + // seeded peer instead of creating a duplicate chat thread. Not stored as + // a chat message. + if let Ok(text) = std::str::from_utf8(payload) { + if let Some((did, ed_hex, x_hex)) = + super::super::protocol::parse_identity_broadcast(text) + { + // Ignore our own identity echoed back by the radio/channel. + if ed_hex.eq_ignore_ascii_case(&state.our_ed_pubkey_hex) { + return; + } + let contact_id = super::super::federation_peer_contact_id(&ed_hex); + handle_identity_received( + contact_id, + 0, + &did, + &ed_hex, + &x_hex, + state, + our_x25519_secret, + ) + .await; + return; + } + } + // Regular channel broadcast (not DM-wrapped) let chan_contact_id = u32::MAX - (channel_idx as u32); let chan_name = format!("Channel {}", channel_idx); diff --git a/core/archipelago/src/mesh/listener/session.rs b/core/archipelago/src/mesh/listener/session.rs index 27786857..6e5448d4 100644 --- a/core/archipelago/src/mesh/listener/session.rs +++ b/core/archipelago/src/mesh/listener/session.rs @@ -363,9 +363,9 @@ pub(super) async fn run_mesh_session( state: &Arc, preferred_path: Option<&str>, our_did: &str, - _our_ed_pubkey_hex: &str, + our_ed_pubkey_hex: &str, our_x25519_secret: &[u8; 32], - _our_x25519_pubkey_hex: &str, + our_x25519_pubkey_hex: &str, server_name: Option<&str>, shutdown: &mut tokio::sync::watch::Receiver, cmd_rx: &mut mpsc::Receiver, @@ -424,6 +424,19 @@ pub(super) async fn run_mesh_session( warn!("Failed to send initial advert: {}", e); } + // Archipelago identity advert (`ARCHY:2:{ed}:{x25519}`): broadcast as channel + // text so peers can bind our radio presence to our DID + keys. The firmware + // advert alone carries the meshcore key (and nothing on Meshtastic), so this + // is what makes trust-gating + encrypted DMs work across BOTH transports. + let identity_advert = super::super::protocol::encode_identity_broadcast( + our_did, + our_ed_pubkey_hex, + our_x25519_pubkey_hex, + ); + if let Err(e) = device.send_channel_text(0, identity_advert.as_bytes()).await { + warn!("Failed to broadcast archipelago identity: {}", e); + } + // Fetch existing contacts from the device refresh_contacts(&mut device, state).await; @@ -491,6 +504,11 @@ pub(super) async fn run_mesh_session( } else { consecutive_write_failures = 0; } + // Re-broadcast archipelago identity so peers that joined since + // startup (or missed it) can bind our DID/keys. + if let Err(e) = device.send_channel_text(0, identity_advert.as_bytes()).await { + warn!("Failed to re-broadcast archipelago identity: {}", e); + } refresh_contacts(&mut device, state).await; }