Dorian 6c23360522 feat: add per-peer ratchet session manager with disk persistence
- Create mesh/session.rs: SessionManager for Double Ratchet state lifecycle
  - Lazy-loads sessions from disk on first message
  - Saves after every encrypt/decrypt (chain key advancement)
  - Per-DID storage at {data_dir}/ratchet/{sha256(did)}.json
  - Session info API for RPC status reporting
  - Zeroize on drop for all key material
- Tests: store+load roundtrip, encrypt/decrypt through manager, session removal

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 01:54:26 +00:00

408 lines
13 KiB
Rust

//! Mesh networking: Meshcore LoRa radio integration for offline peer discovery
//! and encrypted messaging between Archipelago nodes.
//!
//! Supports Meshcore firmware on Heltec V3, T-Beam, RAK WisBlock, Station G2,
//! and other ESP32/nRF52-based LoRa boards via USB serial (Companion USB mode).
#[allow(dead_code)]
pub mod crypto;
#[allow(dead_code)]
pub mod listener;
#[allow(dead_code)]
pub mod protocol;
#[allow(dead_code)]
pub mod serial;
#[allow(dead_code)]
pub mod types;
#[allow(dead_code)]
pub mod ratchet;
#[allow(dead_code)]
pub mod session;
#[allow(dead_code)]
pub mod x3dh;
pub use types::*;
use anyhow::{Context, Result};
use ed25519_dalek::SigningKey;
use listener::MeshState;
use serde::{Deserialize, Serialize};
use std::path::{Path, PathBuf};
use std::sync::Arc;
use tokio::fs;
use tokio::sync::{broadcast, watch};
use tracing::info;
const MESH_CONFIG_FILE: &str = "mesh-config.json";
/// Mesh configuration (persisted to disk).
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MeshConfig {
pub enabled: bool,
/// Specific device path, or None for auto-detection.
#[serde(default)]
pub device_path: Option<String>,
/// Channel name for broadcasts.
#[serde(default)]
pub channel_name: Option<String>,
/// Whether to periodically broadcast our identity.
#[serde(default)]
pub broadcast_identity: bool,
/// Custom advertised name on the mesh network.
#[serde(default)]
pub advert_name: Option<String>,
/// Off-grid mode: disable Tor/internet, route everything via mesh only.
#[serde(default)]
pub mesh_only_mode: Option<bool>,
}
impl Default for MeshConfig {
fn default() -> Self {
Self {
enabled: false,
device_path: None,
channel_name: Some("archipelago".to_string()),
broadcast_identity: true,
advert_name: None,
mesh_only_mode: None,
}
}
}
pub async fn load_config(data_dir: &Path) -> Result<MeshConfig> {
let path = data_dir.join(MESH_CONFIG_FILE);
if !path.exists() {
return Ok(MeshConfig::default());
}
let content = fs::read_to_string(&path)
.await
.context("Failed to read mesh config")?;
let config: MeshConfig = serde_json::from_str(&content).unwrap_or_default();
Ok(config)
}
pub async fn save_config(data_dir: &Path, config: &MeshConfig) -> Result<()> {
fs::create_dir_all(data_dir)
.await
.context("Failed to create data dir")?;
let content =
serde_json::to_string_pretty(config).context("Failed to serialize mesh config")?;
fs::write(data_dir.join(MESH_CONFIG_FILE), content)
.await
.context("Failed to write mesh config")?;
Ok(())
}
/// Detect serial devices that could be mesh radios.
/// Checks both Meshcore (via probe) and legacy Meshtastic paths.
pub async fn detect_devices() -> Vec<String> {
serial::detect_serial_devices().await
}
// ─── MeshService ────────────────────────────────────────────────────────
/// Top-level mesh networking service.
/// Manages the background listener, exposes APIs for RPC handlers.
pub struct MeshService {
state: Arc<MeshState>,
config: MeshConfig,
data_dir: PathBuf,
shutdown_tx: Option<watch::Sender<bool>>,
listener_handle: Option<tokio::task::JoinHandle<()>>,
cmd_rx: Option<tokio::sync::mpsc::Receiver<listener::MeshCommand>>,
// Crypto identity for this node
our_did: String,
our_ed_pubkey_hex: String,
our_x25519_secret: [u8; 32],
our_x25519_pubkey_hex: String,
}
#[allow(dead_code)]
impl MeshService {
/// Create a new MeshService. Does not start the listener yet.
pub async fn new(
data_dir: &Path,
signing_key: &SigningKey,
did: &str,
ed_pubkey_hex: &str,
) -> Result<Self> {
let config = load_config(data_dir).await?;
let channel_name = config
.channel_name
.clone()
.unwrap_or_else(|| "archipelago".to_string());
let (state, _rx, cmd_rx) = MeshState::new(&channel_name);
// Derive X25519 keys from Ed25519 identity
let x25519_secret = crypto::ed25519_secret_to_x25519(signing_key);
let x25519_pubkey = crypto::ed25519_pubkey_to_x25519(
&signing_key.verifying_key().to_bytes(),
)?;
let x25519_pubkey_hex = hex::encode(x25519_pubkey);
Ok(Self {
state,
config,
data_dir: data_dir.to_path_buf(),
shutdown_tx: None,
listener_handle: None,
cmd_rx: Some(cmd_rx),
our_did: did.to_string(),
our_ed_pubkey_hex: ed_pubkey_hex.to_string(),
our_x25519_secret: x25519_secret,
our_x25519_pubkey_hex: x25519_pubkey_hex,
})
}
/// Start the background mesh listener.
pub fn start(&mut self) -> Result<()> {
if self.listener_handle.is_some() {
anyhow::bail!("Mesh listener already running");
}
let (shutdown_tx, shutdown_rx) = watch::channel(false);
self.shutdown_tx = Some(shutdown_tx);
let cmd_rx = self.cmd_rx.take()
.ok_or_else(|| anyhow::anyhow!("Command channel already consumed"))?;
let handle = listener::spawn_mesh_listener(
Arc::clone(&self.state),
self.config.device_path.clone(),
self.our_did.clone(),
self.our_ed_pubkey_hex.clone(),
self.our_x25519_secret,
self.our_x25519_pubkey_hex.clone(),
shutdown_rx,
cmd_rx,
);
self.listener_handle = Some(handle);
info!("Mesh service started");
Ok(())
}
/// Stop the background listener.
pub async fn stop(&mut self) {
if let Some(tx) = self.shutdown_tx.take() {
let _ = tx.send(true);
}
if let Some(handle) = self.listener_handle.take() {
let _ = handle.await;
}
info!("Mesh service stopped");
}
/// Get current mesh status.
pub async fn status(&self) -> MeshStatus {
self.state.status.read().await.clone()
}
/// Get list of discovered peers.
pub async fn peers(&self) -> Vec<MeshPeer> {
self.state.peers.read().await.values().cloned().collect()
}
/// Get message history.
pub async fn messages(&self, limit: Option<usize>) -> Vec<MeshMessage> {
let messages = self.state.messages.read().await;
let limit = limit.unwrap_or(MAX_MESSAGES_DEFAULT);
// Return in chronological order (oldest first) — take last N items
let len = messages.len();
let skip = if len > limit { len - limit } else { 0 };
messages.iter().skip(skip).cloned().collect()
}
/// Send a message to a peer by contact_id.
/// Routes through the background listener which owns the serial port.
pub async fn send_message(&self, contact_id: u32, text: &str) -> Result<MeshMessage> {
let status = self.state.status.read().await;
if !status.device_connected {
anyhow::bail!("No mesh device connected");
}
drop(status);
// Look up the peer's public key to get the 6-byte prefix for addressing
let peers = self.state.peers.read().await;
let peer = peers
.get(&contact_id)
.ok_or_else(|| anyhow::anyhow!("Peer not found"))?;
let pubkey_hex = peer
.pubkey_hex
.as_ref()
.ok_or_else(|| anyhow::anyhow!("Peer has no public key"))?;
let pubkey_bytes = hex::decode(pubkey_hex)
.map_err(|_| anyhow::anyhow!("Invalid peer public key"))?;
if pubkey_bytes.len() < 6 {
anyhow::bail!("Peer public key too short");
}
let mut dest_prefix = [0u8; 6];
dest_prefix.copy_from_slice(&pubkey_bytes[..6]);
drop(peers);
let payload = text.as_bytes().to_vec();
let encrypted = false;
if payload.len() > protocol::MAX_MESSAGE_LEN {
anyhow::bail!(
"Message too large for LoRa: {} bytes (max {})",
payload.len(),
protocol::MAX_MESSAGE_LEN
);
}
// Send through the listener's command channel
self.state
.cmd_tx
.send(listener::MeshCommand::SendText {
dest_pubkey_prefix: dest_prefix,
payload,
})
.await
.map_err(|_| anyhow::anyhow!("Mesh listener not running"))?;
let msg_id = self.state.next_id().await;
let peer_name = self
.state
.peers
.read()
.await
.get(&contact_id)
.map(|p| p.advert_name.clone());
let msg = MeshMessage {
id: msg_id,
direction: MessageDirection::Sent,
peer_contact_id: contact_id,
peer_name,
plaintext: text.to_string(),
timestamp: chrono::Utc::now().to_rfc3339(),
delivered: false,
encrypted,
};
self.state.store_message(msg.clone()).await;
{
let mut status = self.state.status.write().await;
status.messages_sent += 1;
}
Ok(msg)
}
/// Broadcast our advertisement over mesh so other nodes can discover us.
/// Sends an immediate advert via the listener's command channel.
pub async fn broadcast_identity(&self) -> Result<()> {
let status = self.state.status.read().await;
if !status.device_connected {
anyhow::bail!("No mesh device connected. Check USB connection.");
}
drop(status);
self.state
.cmd_tx
.send(listener::MeshCommand::SendAdvert)
.await
.map_err(|_| anyhow::anyhow!("Mesh listener not running"))?;
info!("Mesh self-advert broadcast triggered");
Ok(())
}
/// Update mesh configuration.
pub async fn configure(&mut self, config: MeshConfig) -> Result<()> {
save_config(&self.data_dir, &config).await?;
let was_enabled = self.config.enabled;
self.config = config.clone();
// Update the status to reflect new config
{
let mut status = self.state.status.write().await;
status.enabled = config.enabled;
status.channel_name = config.channel_name.clone().unwrap_or_else(|| "archipelago".to_string());
}
// If enabled state changed, start/stop the listener
if config.enabled && !was_enabled {
self.start()?;
} else if !config.enabled && was_enabled {
self.stop().await;
// Clear connected state
let mut status = self.state.status.write().await;
status.device_connected = false;
status.device_path = None;
status.firmware_version = None;
status.self_node_id = None;
status.peer_count = 0;
}
Ok(())
}
/// Subscribe to mesh events.
pub fn subscribe(&self) -> broadcast::Receiver<MeshEvent> {
self.state.event_tx.subscribe()
}
/// Get a reference to shared state (for RPC handlers).
pub fn shared_state(&self) -> Arc<MeshState> {
Arc::clone(&self.state)
}
}
const MAX_MESSAGES_DEFAULT: usize = 100;
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_mesh_config_default() {
let config = MeshConfig::default();
assert!(!config.enabled);
assert_eq!(config.channel_name, Some("archipelago".to_string()));
assert!(config.broadcast_identity);
}
#[test]
fn test_mesh_config_serialization() {
let config = MeshConfig {
enabled: true,
device_path: Some("/dev/ttyUSB0".to_string()),
channel_name: Some("test".to_string()),
broadcast_identity: false,
advert_name: Some("MyNode".to_string()),
};
let json = serde_json::to_string(&config).unwrap();
let parsed: MeshConfig = serde_json::from_str(&json).unwrap();
assert!(parsed.enabled);
assert_eq!(parsed.device_path, Some("/dev/ttyUSB0".to_string()));
assert_eq!(parsed.advert_name, Some("MyNode".to_string()));
}
#[tokio::test]
async fn test_load_config_default_when_no_file() {
let dir = tempfile::tempdir().unwrap();
let config = load_config(dir.path()).await.unwrap();
assert!(!config.enabled);
}
#[tokio::test]
async fn test_save_and_load_config_roundtrip() {
let dir = tempfile::tempdir().unwrap();
let config = MeshConfig {
enabled: true,
device_path: Some("/dev/ttyUSB0".to_string()),
channel_name: Some("archy".to_string()),
broadcast_identity: true,
advert_name: None,
};
save_config(dir.path(), &config).await.unwrap();
let loaded = load_config(dir.path()).await.unwrap();
assert!(loaded.enabled);
assert_eq!(loaded.device_path, Some("/dev/ttyUSB0".to_string()));
}
}