fix(mesh): native Meshtastic unicast DMs + driver-level E2E status
Meshtastic DMs were falling back to a channel broadcast, so every node on the LoRa channel saw a "direct" message. Send a directed MeshPacket (to = node num, decoded from the synthetic pubkey's node-id bytes) instead — the Meshtastic analog of the meshcore CMD_SEND_TXT_MSG fix. DMs now reach only the recipient; firmware auto-PKC-encrypts them end-to-end once NodeInfo keys are exchanged. Capture E2E status at the driver level (no shared-type/UI change): - learn each peer's real Curve25519 key from User.public_key (field 8) and inbound MeshPacket.public_key (16), kept in a side-map separate from the synthetic routing key so unicast routing is untouched - detect inbound MeshPacket.pki_encrypted (17) to tell a true E2E DM from a channel-PSK fallback - peer_is_pkc_capable() seam for a future mesh-tab E2E badge Hot-swap preserved: no dispatched MeshRadioDevice signature or the shared ParsedContact changed, so meshcore and meshtastic stay interchangeable behind the listener. Adds tests/multinode/meshtastic.sh, a two/three-radio on-air parity harness (detect, discover, DM round-trip, DM privacy, channel broadcast, typed envelope, reachability). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
f636c5d505
commit
298595069d
@@ -42,6 +42,14 @@ pub struct MeshtasticDevice {
|
||||
long_name: Option<String>,
|
||||
short_name: Option<String>,
|
||||
contacts: HashMap<u32, ParsedContact>,
|
||||
/// Real Curve25519 public keys, keyed by node-num, as learned from NodeInfo
|
||||
/// (`User.public_key`) or PKC-encrypted inbound packets (`MeshPacket
|
||||
/// .public_key`). Kept SEPARATE from `contacts[*].public_key_hex`, which is
|
||||
/// the synthetic node-num-derived routing key that `send_text_msg` relies
|
||||
/// on — we must not overwrite that or unicast routing breaks. This map only
|
||||
/// records which peers are PKC-capable, so we can tell a true end-to-end
|
||||
/// (PKI) DM from a channel-PSK fallback.
|
||||
peer_pubkeys: HashMap<u32, Vec<u8>>,
|
||||
device_path: String,
|
||||
}
|
||||
|
||||
@@ -68,6 +76,7 @@ impl MeshtasticDevice {
|
||||
long_name: None,
|
||||
short_name: None,
|
||||
contacts: HashMap::new(),
|
||||
peer_pubkeys: HashMap::new(),
|
||||
device_path: path.to_string(),
|
||||
})
|
||||
}
|
||||
@@ -150,12 +159,32 @@ impl MeshtasticDevice {
|
||||
.await
|
||||
}
|
||||
|
||||
/// Meshtastic addresses by numeric node-id, not a meshcore pubkey prefix,
|
||||
/// so there's no direct unicast mapping here. Best-effort fallback to a
|
||||
/// channel send keeps the device interface uniform; native unicast is only
|
||||
/// meaningful on the Meshcore transport.
|
||||
pub async fn send_text_msg(&mut self, _dest_pubkey_prefix: &[u8; 6], msg: &[u8]) -> Result<()> {
|
||||
self.send_channel_text(0, msg).await
|
||||
/// Native Meshtastic unicast DM. Our synthetic Meshtastic pubkeys carry the
|
||||
/// numeric node-id in their first 4 bytes (little-endian, see
|
||||
/// `synthetic_pubkey`), so `dest_pubkey_prefix` directly yields the
|
||||
/// destination node number. We send a directed MeshPacket (`to` = node num)
|
||||
/// rather than a `BROADCAST_NUM` channel blast — this is the Meshtastic
|
||||
/// analog of the meshcore `CMD_SEND_TXT_MSG` fix: the message is delivered
|
||||
/// as a real DM (only the recipient's client surfaces it) instead of
|
||||
/// polluting the shared primary channel where every node would see it.
|
||||
///
|
||||
/// If the prefix decodes to node 0 / broadcast (e.g. a non-Meshtastic
|
||||
/// synthetic key routed here by mistake), fall back to a channel send so the
|
||||
/// device interface stays uniform and the message still goes out.
|
||||
pub async fn send_text_msg(&mut self, dest_pubkey_prefix: &[u8; 6], msg: &[u8]) -> Result<()> {
|
||||
let node_num = u32::from_le_bytes([
|
||||
dest_pubkey_prefix[0],
|
||||
dest_pubkey_prefix[1],
|
||||
dest_pubkey_prefix[2],
|
||||
dest_pubkey_prefix[3],
|
||||
]);
|
||||
if node_num == 0 || node_num == BROADCAST_NUM {
|
||||
return self.send_channel_text(0, msg).await;
|
||||
}
|
||||
let text = String::from_utf8_lossy(msg);
|
||||
let packet = encode_mesh_packet(node_num, TEXT_MESSAGE_APP, text.as_bytes());
|
||||
self.send_to_radio(&encode_to_radio_variant(TO_RADIO_PACKET, &packet))
|
||||
.await
|
||||
}
|
||||
|
||||
/// Meshtastic has no meshcore-style contact table; these are no-ops so the
|
||||
@@ -214,6 +243,19 @@ impl MeshtasticDevice {
|
||||
Ok(self.handle_from_radio(&frame))
|
||||
}
|
||||
|
||||
/// Whether we've learned `node_num`'s real PKI (Curve25519) key — from a
|
||||
/// NodeInfo `public_key` or an inbound PKC DM — meaning the firmware can
|
||||
/// deliver DMs to/from it end-to-end encrypted instead of falling back to
|
||||
/// the channel PSK. Driver-internal for now; lets a future mesh-tab badge
|
||||
/// distinguish a true E2E DM from a channel-encrypted one without changing
|
||||
/// the shared device interface (which would break meshcore hot-swap).
|
||||
#[allow(dead_code)] // seam: consumed when the mesh-tab E2E badge lands
|
||||
pub fn peer_is_pkc_capable(&self, node_num: u32) -> bool {
|
||||
self.peer_pubkeys
|
||||
.get(&node_num)
|
||||
.is_some_and(|k| !k.is_empty())
|
||||
}
|
||||
|
||||
pub fn advert_name(&self) -> Option<String> {
|
||||
self.long_name
|
||||
.clone()
|
||||
@@ -286,6 +328,15 @@ impl MeshtasticDevice {
|
||||
|
||||
fn update_node_info(&mut self, data: &[u8]) {
|
||||
if let Some(node) = parse_node_info(data) {
|
||||
if let Some(pk) = node.public_key.as_ref() {
|
||||
if self.peer_pubkeys.insert(node.num, pk.clone()).is_none() {
|
||||
debug!(
|
||||
node = node.num,
|
||||
key_len = pk.len(),
|
||||
"Meshtastic peer is PKC-capable (NodeInfo public_key)"
|
||||
);
|
||||
}
|
||||
}
|
||||
let key = synthetic_pubkey(node.num);
|
||||
let name = node
|
||||
.long_name
|
||||
@@ -318,6 +369,18 @@ impl MeshtasticDevice {
|
||||
if Some(from) == self.node_num {
|
||||
return None;
|
||||
}
|
||||
// Record E2E status: a `pki_encrypted` packet (or one carrying the
|
||||
// sender's `public_key`) proves this DM arrived end-to-end encrypted via
|
||||
// the PKI, not the shared channel PSK. We learn the sender's key here too
|
||||
// — but keep it OUT of the routing `public_key_hex` (synthetic) so the
|
||||
// device interface stays identical to meshcore's and the two remain
|
||||
// hot-swappable behind the mesh listener.
|
||||
if let Some(pk) = packet.public_key.as_ref() {
|
||||
self.peer_pubkeys.entry(from).or_insert_with(|| pk.clone());
|
||||
}
|
||||
if packet.pki_encrypted {
|
||||
debug!(node = from, "Meshtastic DM received end-to-end encrypted (PKI)");
|
||||
}
|
||||
let from_key = synthetic_pubkey(from);
|
||||
self.contacts.entry(from).or_insert_with(|| ParsedContact {
|
||||
public_key_hex: hex::encode(synthetic_pubkey(from)),
|
||||
@@ -444,6 +507,7 @@ struct ParsedNode {
|
||||
long_name: Option<String>,
|
||||
short_name: Option<String>,
|
||||
last_heard: Option<u32>,
|
||||
public_key: Option<Vec<u8>>,
|
||||
}
|
||||
|
||||
fn parse_node_info(data: &[u8]) -> Option<ParsedNode> {
|
||||
@@ -454,6 +518,7 @@ fn parse_node_info(data: &[u8]) -> Option<ParsedNode> {
|
||||
long_name: None,
|
||||
short_name: None,
|
||||
last_heard: None,
|
||||
public_key: None,
|
||||
};
|
||||
while idx < data.len() {
|
||||
let (field, value, next) = next_field(data, idx)?;
|
||||
@@ -466,6 +531,7 @@ fn parse_node_info(data: &[u8]) -> Option<ParsedNode> {
|
||||
node.id = user.id;
|
||||
node.long_name = user.long_name;
|
||||
node.short_name = user.short_name;
|
||||
node.public_key = user.public_key;
|
||||
}
|
||||
}
|
||||
(5, FieldValue::Fixed32(v)) => node.last_heard = Some(v),
|
||||
@@ -483,6 +549,7 @@ struct ParsedUser {
|
||||
id: Option<String>,
|
||||
long_name: Option<String>,
|
||||
short_name: Option<String>,
|
||||
public_key: Option<Vec<u8>>,
|
||||
}
|
||||
|
||||
fn parse_user(data: &[u8]) -> Option<ParsedUser> {
|
||||
@@ -491,6 +558,7 @@ fn parse_user(data: &[u8]) -> Option<ParsedUser> {
|
||||
id: None,
|
||||
long_name: None,
|
||||
short_name: None,
|
||||
public_key: None,
|
||||
};
|
||||
while idx < data.len() {
|
||||
let (field, value, next) = next_field(data, idx)?;
|
||||
@@ -499,6 +567,9 @@ fn parse_user(data: &[u8]) -> Option<ParsedUser> {
|
||||
(1, FieldValue::Bytes(b)) => user.id = string_field(b),
|
||||
(2, FieldValue::Bytes(b)) => user.long_name = string_field(b),
|
||||
(3, FieldValue::Bytes(b)) => user.short_name = string_field(b),
|
||||
// User.public_key (field 8): the peer's Curve25519 key. Its presence
|
||||
// means the radio can PKC-encrypt DMs to this node end-to-end.
|
||||
(8, FieldValue::Bytes(b)) if !b.is_empty() => user.public_key = Some(b.to_vec()),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
@@ -509,18 +580,28 @@ struct ParsedPacket {
|
||||
from: Option<u32>,
|
||||
portnum: u32,
|
||||
payload: Vec<u8>,
|
||||
/// MeshPacket.pki_encrypted (field 17): the firmware decrypted this packet
|
||||
/// with the PKI (Curve25519) key, i.e. it arrived end-to-end encrypted
|
||||
/// rather than via the shared channel PSK.
|
||||
pki_encrypted: bool,
|
||||
/// MeshPacket.public_key (field 16): the sender's key, carried on PKC DMs.
|
||||
public_key: Option<Vec<u8>>,
|
||||
}
|
||||
|
||||
fn parse_mesh_packet(data: &[u8]) -> Option<ParsedPacket> {
|
||||
let mut idx = 0;
|
||||
let mut from = None;
|
||||
let mut decoded = None;
|
||||
let mut pki_encrypted = false;
|
||||
let mut public_key = None;
|
||||
while idx < data.len() {
|
||||
let (field, value, next) = next_field(data, idx)?;
|
||||
idx = next;
|
||||
match (field, value) {
|
||||
(1, FieldValue::Fixed32(v)) => from = Some(v),
|
||||
(4, FieldValue::Bytes(b)) => decoded = Some(b),
|
||||
(16, FieldValue::Bytes(b)) if !b.is_empty() => public_key = Some(b.to_vec()),
|
||||
(17, FieldValue::Varint(v)) => pki_encrypted = v != 0,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
@@ -541,6 +622,8 @@ fn parse_mesh_packet(data: &[u8]) -> Option<ParsedPacket> {
|
||||
from,
|
||||
portnum,
|
||||
payload,
|
||||
pki_encrypted,
|
||||
public_key,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user