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:
parent
87d0d53205
commit
ef601c6d26
@ -3,8 +3,8 @@
|
|||||||
use super::super::message_types::TypedEnvelope;
|
use super::super::message_types::TypedEnvelope;
|
||||||
use super::super::protocol;
|
use super::super::protocol;
|
||||||
use super::decode::{
|
use super::decode::{
|
||||||
is_mc_chunk_frame, resolve_peer, store_plain_message, try_base64_typed, try_chunk_reassemble,
|
handle_identity_received, is_mc_chunk_frame, resolve_peer, store_plain_message,
|
||||||
try_decrypt_base64, try_decrypt_ratchet_base64,
|
try_base64_typed, try_chunk_reassemble, try_decrypt_base64, try_decrypt_ratchet_base64,
|
||||||
};
|
};
|
||||||
use super::dispatch::handle_typed_message;
|
use super::dispatch::handle_typed_message;
|
||||||
use super::MeshState;
|
use super::MeshState;
|
||||||
@ -18,7 +18,6 @@ pub(super) async fn handle_frame(
|
|||||||
state: &Arc<MeshState>,
|
state: &Arc<MeshState>,
|
||||||
our_x25519_secret: &[u8; 32],
|
our_x25519_secret: &[u8; 32],
|
||||||
) -> bool {
|
) -> bool {
|
||||||
let _ = our_x25519_secret; // reserved for future per-frame decryption
|
|
||||||
match frame.code {
|
match frame.code {
|
||||||
protocol::PUSH_NEW_CONTACT | protocol::PUSH_CONTACT_ADVERT => {
|
protocol::PUSH_NEW_CONTACT | protocol::PUSH_CONTACT_ADVERT => {
|
||||||
info!(
|
info!(
|
||||||
@ -109,7 +108,8 @@ pub(super) async fn handle_frame(
|
|||||||
match protocol::parse_channel_msg_v3_raw(&frame.data) {
|
match protocol::parse_channel_msg_v3_raw(&frame.data) {
|
||||||
Ok((channel_idx, payload)) => {
|
Ok((channel_idx, payload)) => {
|
||||||
if !payload.is_empty() {
|
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),
|
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) {
|
match protocol::parse_channel_msg_v1_raw(&frame.data) {
|
||||||
Ok((channel_idx, payload)) => {
|
Ok((channel_idx, payload)) => {
|
||||||
if !payload.is_empty() {
|
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),
|
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
|
/// 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
|
/// dispatched through the direct-message path so it lands in the right
|
||||||
/// chat. Otherwise it's handled as a normal channel text/typed message.
|
/// 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
|
// DM-via-channel wrapper (text form): the channel text carries an
|
||||||
// ASCII "@DM:<base64>" token somewhere in the body. We locate the
|
// ASCII "@DM:<base64>" token somewhere in the body. We locate the
|
||||||
// marker anywhere in the payload (the firmware auto-prepends 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;
|
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)
|
// Regular channel broadcast (not DM-wrapped)
|
||||||
let chan_contact_id = u32::MAX - (channel_idx as u32);
|
let chan_contact_id = u32::MAX - (channel_idx as u32);
|
||||||
let chan_name = format!("Channel {}", channel_idx);
|
let chan_name = format!("Channel {}", channel_idx);
|
||||||
|
|||||||
@ -363,9 +363,9 @@ pub(super) async fn run_mesh_session(
|
|||||||
state: &Arc<MeshState>,
|
state: &Arc<MeshState>,
|
||||||
preferred_path: Option<&str>,
|
preferred_path: Option<&str>,
|
||||||
our_did: &str,
|
our_did: &str,
|
||||||
_our_ed_pubkey_hex: &str,
|
our_ed_pubkey_hex: &str,
|
||||||
our_x25519_secret: &[u8; 32],
|
our_x25519_secret: &[u8; 32],
|
||||||
_our_x25519_pubkey_hex: &str,
|
our_x25519_pubkey_hex: &str,
|
||||||
server_name: Option<&str>,
|
server_name: Option<&str>,
|
||||||
shutdown: &mut tokio::sync::watch::Receiver<bool>,
|
shutdown: &mut tokio::sync::watch::Receiver<bool>,
|
||||||
cmd_rx: &mut mpsc::Receiver<MeshCommand>,
|
cmd_rx: &mut mpsc::Receiver<MeshCommand>,
|
||||||
@ -424,6 +424,19 @@ pub(super) async fn run_mesh_session(
|
|||||||
warn!("Failed to send initial advert: {}", e);
|
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
|
// Fetch existing contacts from the device
|
||||||
refresh_contacts(&mut device, state).await;
|
refresh_contacts(&mut device, state).await;
|
||||||
|
|
||||||
@ -491,6 +504,11 @@ pub(super) async fn run_mesh_session(
|
|||||||
} else {
|
} else {
|
||||||
consecutive_write_failures = 0;
|
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;
|
refresh_contacts(&mut device, state).await;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user