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
+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,