feat(mesh): wire ARCHY identity broadcast for trust over both radios (#50)

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) <noreply@anthropic.com>
This commit is contained in:
archipelago 2026-06-17 18:20:12 -04:00
parent 87d0d53205
commit ef601c6d26
2 changed files with 62 additions and 8 deletions

View File

@ -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<MeshState>,
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<MeshState>, channel_idx: u8, payload: &[u8]) {
async fn handle_channel_payload(
state: &Arc<MeshState>,
channel_idx: u8,
payload: &[u8],
our_x25519_secret: &[u8; 32],
) {
// DM-via-channel wrapper (text form): the channel text carries an
// ASCII "@DM:<base64>" 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<MeshState>, 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);

View File

@ -363,9 +363,9 @@ pub(super) async fn run_mesh_session(
state: &Arc<MeshState>,
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<bool>,
cmd_rx: &mut mpsc::Receiver<MeshCommand>,
@ -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;
}