feat(p2p): mesh stack, LoRa mock transport, and relay demo

Implement transport abstraction (TCP/iroh), announce and routing table,
multi-hop mesh router, truncated-address link layer, and LoRa mock
medium with fragmentation plus EU868-style duty-cycle accounting.
Add mesh_lora_relay_demo and scripts/mesh-demo.sh. Relax CBOR vs JSON
size assertion to match fixed-size cryptographic overhead. Extend
.gitignore for nested targets and node_modules.

Made-with: Cursor
This commit is contained in:
2026-03-30 21:19:12 +02:00
parent d469999c2a
commit f9ac921a0c
20 changed files with 4042 additions and 6 deletions

View File

@@ -0,0 +1,281 @@
//! Mesh announce protocol for self-organizing network discovery.
//!
//! Nodes periodically broadcast signed [`MeshAnnounce`] packets. These propagate
//! through the mesh, building each node's [`RoutingTable`](crate::routing_table::RoutingTable).
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use std::time::{SystemTime, UNIX_EPOCH};
use crate::identity::MeshIdentity;
/// Capability flag: node can relay messages for others.
pub const CAP_RELAY: u16 = 0x0001;
/// Capability flag: node has store-and-forward.
pub const CAP_STORE: u16 = 0x0002;
/// Capability flag: node is connected to Internet/server.
pub const CAP_GATEWAY: u16 = 0x0004;
/// Capability flag: node is on a low-bandwidth transport only.
pub const CAP_CONSTRAINED: u16 = 0x0008;
/// A signed mesh node announcement.
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct MeshAnnounce {
/// Ed25519 public key of the announcing node (32 bytes).
pub identity_key: Vec<u8>,
/// Truncated address: SHA-256(identity_key)[0..16] — used for routing.
pub address: [u8; 16],
/// Capability bitfield.
pub capabilities: u16,
/// Monotonically increasing sequence number (per node).
pub sequence: u64,
/// Unix timestamp of creation.
pub timestamp: u64,
/// Transports this node is reachable on: Vec<(transport_name, serialized_addr)>.
pub reachable_via: Vec<(String, Vec<u8>)>,
/// Current hop count (incremented on re-broadcast).
pub hop_count: u8,
/// Maximum propagation hops.
pub max_hops: u8,
/// Ed25519 signature over all fields except signature and hop_count.
pub signature: Vec<u8>,
}
/// Compute the 16-byte mesh address from an Ed25519 public key.
///
/// The address is the first 16 bytes of SHA-256(identity_key).
pub fn compute_address(identity_key: &[u8]) -> [u8; 16] {
let hash = Sha256::digest(identity_key);
let mut addr = [0u8; 16];
addr.copy_from_slice(&hash[..16]);
addr
}
impl MeshAnnounce {
/// Create and sign a new mesh announcement.
pub fn new(
identity: &MeshIdentity,
capabilities: u16,
reachable_via: Vec<(String, Vec<u8>)>,
max_hops: u8,
) -> Self {
let identity_key = identity.public_key().to_vec();
let address = compute_address(&identity_key);
let timestamp = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
let mut announce = Self {
identity_key,
address,
capabilities,
sequence: 0,
timestamp,
reachable_via,
hop_count: 0,
max_hops,
signature: Vec::new(),
};
let signable = announce.signable_bytes();
announce.signature = identity.sign(&signable).to_vec();
announce
}
/// Create and sign with a specific sequence number.
pub fn with_sequence(
identity: &MeshIdentity,
capabilities: u16,
reachable_via: Vec<(String, Vec<u8>)>,
max_hops: u8,
sequence: u64,
) -> Self {
let mut announce = Self::new(identity, capabilities, reachable_via, max_hops);
announce.sequence = sequence;
// Re-sign with the correct sequence number.
let signable = announce.signable_bytes();
announce.signature = identity.sign(&signable).to_vec();
announce
}
/// Assemble the byte string that is signed / verified.
///
/// `hop_count` and `signature` are excluded: forwarding nodes increment
/// hop_count without re-signing (same design as [`MeshEnvelope`]).
fn signable_bytes(&self) -> Vec<u8> {
let mut buf = Vec::with_capacity(
self.identity_key.len() + 16 + 2 + 8 + 8 + self.reachable_via.len() * 32 + 1,
);
buf.extend_from_slice(&self.identity_key);
buf.extend_from_slice(&self.address);
buf.extend_from_slice(&self.capabilities.to_le_bytes());
buf.extend_from_slice(&self.sequence.to_le_bytes());
buf.extend_from_slice(&self.timestamp.to_le_bytes());
for (name, addr) in &self.reachable_via {
buf.extend_from_slice(name.as_bytes());
buf.extend_from_slice(addr);
}
buf.push(self.max_hops);
buf
}
/// Verify the Ed25519 signature on this announcement.
pub fn verify(&self) -> bool {
let identity_key: [u8; 32] = match self.identity_key.as_slice().try_into() {
Ok(k) => k,
Err(_) => return false,
};
let sig: [u8; 64] = match self.signature.as_slice().try_into() {
Ok(s) => s,
Err(_) => return false,
};
let signable = self.signable_bytes();
quicprochat_core::IdentityKeypair::verify_raw(&identity_key, &signable, &sig).is_ok()
}
/// Check whether this announce has expired relative to a maximum age.
pub fn is_expired(&self, max_age_secs: u64) -> bool {
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
now.saturating_sub(self.timestamp) > max_age_secs
}
/// Create a forwarded copy with `hop_count` incremented by one.
///
/// The signature remains the original — forwarding nodes do not re-sign.
pub fn forwarded(&self) -> Self {
let mut copy = self.clone();
copy.hop_count = copy.hop_count.saturating_add(1);
copy
}
/// Whether this announce can still propagate (under hop limit and not expired).
///
/// Uses a generous default max age of 1800 seconds (30 minutes) for the
/// expiry check. Callers that need a different max age should check
/// [`is_expired`](Self::is_expired) separately.
pub fn can_propagate(&self) -> bool {
self.hop_count < self.max_hops && !self.is_expired(1800)
}
/// Serialize to compact CBOR binary format (for wire transmission).
pub fn to_wire(&self) -> Vec<u8> {
let mut buf = Vec::new();
ciborium::into_writer(self, &mut buf).expect("CBOR serialization should not fail");
buf
}
/// Deserialize from CBOR binary format.
pub fn from_wire(bytes: &[u8]) -> anyhow::Result<Self> {
let announce: Self = ciborium::from_reader(bytes)?;
Ok(announce)
}
}
#[cfg(test)]
mod tests {
use super::*;
fn test_identity() -> MeshIdentity {
MeshIdentity::generate()
}
#[test]
fn create_and_verify() {
let id = test_identity();
let announce = MeshAnnounce::new(
&id,
CAP_RELAY | CAP_STORE,
vec![("tcp".into(), b"127.0.0.1:9000".to_vec())],
8,
);
assert!(announce.verify(), "freshly created announce must verify");
assert_eq!(announce.hop_count, 0);
assert_eq!(announce.identity_key, id.public_key().to_vec());
assert_eq!(announce.capabilities, CAP_RELAY | CAP_STORE);
assert_eq!(announce.max_hops, 8);
}
#[test]
fn tampered_fails_verify() {
let id = test_identity();
let mut announce = MeshAnnounce::new(&id, CAP_RELAY, vec![], 4);
announce.capabilities = CAP_GATEWAY; // tamper
assert!(
!announce.verify(),
"tampered announce must fail verification"
);
}
#[test]
fn forwarded_still_verifies() {
let id = test_identity();
let announce = MeshAnnounce::new(&id, CAP_RELAY, vec![], 8);
assert!(announce.verify());
let fwd = announce.forwarded();
assert_eq!(fwd.hop_count, 1);
assert!(
fwd.verify(),
"forwarded announce must still verify (hop_count excluded from signature)"
);
let fwd2 = fwd.forwarded();
assert_eq!(fwd2.hop_count, 2);
assert!(fwd2.verify(), "double-forwarded must still verify");
}
#[test]
fn expired_announce() {
let id = test_identity();
let mut announce = MeshAnnounce::new(&id, 0, vec![], 4);
// Set timestamp far in the past.
announce.timestamp = 0;
assert!(announce.is_expired(60), "announce from epoch should be expired with 60s max age");
}
#[test]
fn address_from_key_deterministic() {
let key = [42u8; 32];
let addr1 = compute_address(&key);
let addr2 = compute_address(&key);
assert_eq!(addr1, addr2, "same key must produce same address");
// Different key produces different address.
let other_key = [99u8; 32];
let other_addr = compute_address(&other_key);
assert_ne!(addr1, other_addr);
}
#[test]
fn cbor_roundtrip() {
let id = test_identity();
let announce = MeshAnnounce::new(
&id,
CAP_RELAY | CAP_GATEWAY,
vec![
("tcp".into(), b"127.0.0.1:9000".to_vec()),
("lora".into(), vec![0x01, 0x02, 0x03, 0x04]),
],
6,
);
let wire = announce.to_wire();
let restored = MeshAnnounce::from_wire(&wire).expect("CBOR deserialize");
assert_eq!(announce.identity_key, restored.identity_key);
assert_eq!(announce.address, restored.address);
assert_eq!(announce.capabilities, restored.capabilities);
assert_eq!(announce.sequence, restored.sequence);
assert_eq!(announce.timestamp, restored.timestamp);
assert_eq!(announce.reachable_via, restored.reachable_via);
assert_eq!(announce.hop_count, restored.hop_count);
assert_eq!(announce.max_hops, restored.max_hops);
assert_eq!(announce.signature, restored.signature);
assert!(restored.verify());
}
}