feat(mesh,ui): per-message transport pill (Mesh/FIPS/Tor) + fix E2E pill

Adds a per-message transport badge to archy↔archy mesh chats and fixes the
long-broken E2E badge — both meshcore and meshtastic, styled like the existing
E2E pill.

Transport pill:
- New `MeshMessage.transport` ("lora"/"fips"/"tor"), surfaced in the UI beside
  the E2E badge (Mesh.vue transportLabel() → Mesh/FIPS/Tor, mesh-styles.css).
- Sent LoRa → "lora"; sent federation → finalized to the real leg ("fips"/"tor")
  once the background send resolves (req.send_json transport), via an id-keyed
  store update.
- Received: a post-dispatch stamp on handle_typed_envelope_direct's output
  (monotonic ids) tags both transports without threading through all 20 typed-
  dispatch sites — radio wrapper stamps "lora", federation injector stamps the
  peer's last_transport ("fips"/"tor", default tor; the inbound HTTP carries no
  FIPS-vs-Tor signal).
- Plain native/channel LoRa frames → "lora"; channel broadcasts stay non-E2E.

E2E pill fix:
- `encrypted` was hardcoded false at every MeshMessage construction site, so the
  UI badge (Mesh.vue `v-if="msg.encrypted"`) never showed. Now: federation
  envelopes are E2E (identity-signed over an encrypted transport); the meshcore
  native-DM receive path already had a real `encrypted` flag (now also tagged
  with transport). meshtastic-PKI radio E2E flag threading is a noted follow-up.

Backend cargo check + frontend vue-tsc build both green. Needs a live radio +
multi-transport pass on .116/.228 to confirm end-to-end (see
project_transport_pill / project_meshtastic_parity).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
archipelago
2026-06-29 04:29:25 -04:00
co-authored by Claude Opus 4.8
parent 169ff2e2cd
commit 11038cdcc9
7 changed files with 141 additions and 2 deletions
@@ -343,7 +343,10 @@ pub(super) async fn store_plain_message(
plaintext: text.to_string(),
timestamp: chrono::Utc::now().to_rfc3339(),
delivered: true,
// Plain native text only ever arrives over the radio; not E2E (it's a
// stock-client / channel plaintext frame).
encrypted: false,
transport: Some("lora".to_string()),
message_type: "text".to_string(),
typed_payload: None,
sender_pubkey: None,
@@ -576,6 +579,7 @@ pub(super) async fn handle_received_message(
timestamp: chrono::Utc::now().to_rfc3339(),
delivered: true,
encrypted,
transport: Some("lora".to_string()),
message_type: "text".to_string(),
typed_payload: None,
sender_pubkey: None,
@@ -34,7 +34,10 @@ async fn store_typed_message(
plaintext: display_text.to_string(),
timestamp: chrono::Utc::now().to_rfc3339(),
delivered: true,
// transport + E2E are stamped post-dispatch by
// handle_typed_envelope_direct, which alone knows the receive transport.
encrypted: false,
transport: None,
message_type: type_label.to_string(),
typed_payload,
sender_pubkey,
@@ -70,7 +73,53 @@ pub(super) async fn handle_typed_message(
return;
}
};
// Radio-delivered → "lora". Stamp after dispatch (see stamp helper).
let before = max_message_id(state).await;
handle_typed_envelope_direct(state, sender_contact_id, sender_name, envelope).await;
stamp_received_transport(state, sender_contact_id, before, "lora", false).await;
}
/// Highest stored message id right now. Paired with `stamp_received_transport`
/// to identify messages a dispatch call just stored (ids are monotonic).
pub(crate) async fn max_message_id(state: &Arc<MeshState>) -> u64 {
state
.messages
.read()
.await
.iter()
.map(|m| m.id)
.max()
.unwrap_or(0)
}
/// Stamp the per-message transport pill (and E2E flag) onto every RECEIVED
/// message from `sender_contact_id` stored since `after_id` — i.e. the ones the
/// just-completed `handle_typed_envelope_direct` produced. This is how both the
/// radio path ("lora") and the federation path ("fips"/"tor") tag inbound
/// messages without threading transport through all 20 typed-dispatch sites.
/// `encrypted` only ever sets the flag true (a federation envelope is E2E),
/// never clears a true set elsewhere.
pub(crate) async fn stamp_received_transport(
state: &Arc<MeshState>,
sender_contact_id: u32,
after_id: u64,
transport: &str,
encrypted: bool,
) {
let mut messages = state.messages.write().await;
for m in messages.iter_mut() {
if m.id > after_id
&& matches!(m.direction, MessageDirection::Received)
&& m.peer_contact_id == sender_contact_id
{
if m.transport.is_none() {
m.transport = Some(transport.to_string());
}
if encrypted {
m.encrypted = true;
}
}
}
}
/// Dispatch a pre-decoded TypedEnvelope. Shared between the radio receive
+62 -2
View File
@@ -1196,6 +1196,11 @@ impl MeshService {
display_text,
typed_payload,
sender_seq,
Some("lora".to_string()),
// Archy↔archy typed envelopes over LoRa are identity-signed; the
// radio E2E flag (meshtastic PKI / meshcore session) isn't
// threaded to the send side yet, so don't over-claim E2E here.
false,
)
.await)
}
@@ -1251,6 +1256,11 @@ impl MeshService {
display_text,
typed_payload,
sender_seq,
// Transport is finalized below once the background send resolves
// FIPS vs Tor; mark E2E now — a federation envelope is
// identity-signed and rides an encrypted transport.
None,
true,
)
.await;
@@ -1260,6 +1270,10 @@ impl MeshService {
// MeshMessage and the UI's delivery indicator tracks the receipt.
let peer_onion_owned = peer_onion.to_string();
let data_dir_owned = self.data_dir.clone();
// Finalize the Sent record's transport pill once we know which leg
// (FIPS/Tor) actually delivered it.
let state_for_transport = self.state.clone();
let sent_msg_id = msg.id;
tokio::spawn(async move {
let fips_npub =
crate::federation::fips_npub_for_onion(&data_dir_owned, &peer_onion_owned).await;
@@ -1280,6 +1294,12 @@ impl MeshService {
match req.send_json(&body).await {
Ok((resp, transport)) if resp.status().is_success() => {
tracing::debug!(contact_id, transport = %transport, "Federation envelope delivered");
// Tag the Sent bubble with the leg that delivered it (the
// transport pill: "fips" / "tor").
let mut messages = state_for_transport.messages.write().await;
if let Some(m) = messages.iter_mut().find(|m| m.id == sent_msg_id) {
m.transport = Some(transport.to_string());
}
}
Ok((resp, transport)) => warn!(
contact_id,
@@ -1344,6 +1364,22 @@ impl MeshService {
Some(&display_name),
)
.await;
// The inbound HTTP gives no FIPS-vs-Tor signal, so label the message
// with the leg most recently used with this peer (federation storage's
// `last_transport`), defaulting to Tor. Federation envelopes are E2E
// (identity-signed over an encrypted transport).
let transport_label = {
let nodes = crate::federation::load_nodes(&self.data_dir)
.await
.unwrap_or_default();
nodes
.iter()
.find(|n| n.pubkey == from_pubkey_hex)
.and_then(|n| n.last_transport.clone())
.filter(|t| t == "fips" || t == "tor")
.unwrap_or_else(|| "tor".to_string())
};
let before = listener::dispatch::max_message_id(&self.state).await;
listener::dispatch::handle_typed_envelope_direct(
&self.state,
contact_id,
@@ -1351,6 +1387,14 @@ impl MeshService {
envelope,
)
.await;
listener::dispatch::stamp_received_transport(
&self.state,
contact_id,
before,
&transport_label,
true,
)
.await;
Ok(())
}
@@ -1460,7 +1504,10 @@ impl MeshService {
plaintext: display_text.to_string(),
timestamp: chrono::Utc::now().to_rfc3339(),
delivered: false,
// Channel broadcasts use the shared channel PSK, not per-identity
// E2E — so not an E2E message, but it does travel over the radio.
encrypted: false,
transport: Some("lora".to_string()),
message_type: type_label.to_string(),
typed_payload,
sender_pubkey: Some(self.our_ed_pubkey_hex.clone()),
@@ -1496,7 +1543,15 @@ impl MeshService {
.await
.map_err(|_| anyhow::anyhow!("Mesh listener not running"))?;
return Ok(self
.record_sent_typed(contact_id, "text", text, None, seq)
.record_sent_typed(
contact_id,
"text",
text,
None,
seq,
Some("lora".to_string()),
false,
)
.await);
}
// Sign the envelope with our archipelago identity key so the receiver
@@ -1543,6 +1598,8 @@ impl MeshService {
display_text: &str,
typed_payload: Option<serde_json::Value>,
sender_seq: u64,
transport: Option<String>,
encrypted: bool,
) -> MeshMessage {
let msg_id = self.state.next_id().await;
let peer_name = self
@@ -1560,7 +1617,8 @@ impl MeshService {
plaintext: display_text.to_string(),
timestamp: chrono::Utc::now().to_rfc3339(),
delivered: false,
encrypted: false,
encrypted,
transport,
message_type: type_label.to_string(),
typed_payload,
sender_pubkey: Some(self.our_ed_pubkey_hex.clone()),
@@ -1611,7 +1669,9 @@ impl MeshService {
plaintext: text.to_string(),
timestamp: chrono::Utc::now().to_rfc3339(),
delivered: false,
// Plain channel broadcast over the radio (shared PSK, not E2E).
encrypted: false,
transport: Some("lora".to_string()),
message_type: "text".to_string(),
typed_payload: None,
sender_pubkey: None,
+6
View File
@@ -104,6 +104,12 @@ pub struct MeshMessage {
pub delivered: bool,
/// Whether the message was end-to-end encrypted.
pub encrypted: bool,
/// How this message actually traveled, for the per-message transport pill:
/// "lora" (mesh radio), "fips", or "tor". `None` until known (a Sent
/// federation message is finalized once the background send resolves the
/// transport). Surfaced in the UI beside the E2E badge.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub transport: Option<String>,
/// Typed-envelope label ("text", "invoice", "alert", "coordinate", ...).
#[serde(default = "default_message_type")]
pub message_type: String,