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
282 lines
9.5 KiB
Rust
282 lines
9.5 KiB
Rust
//! 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());
|
|
}
|
|
}
|