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:
492
crates/quicprochat-p2p/src/link.rs
Normal file
492
crates/quicprochat-p2p/src/link.rs
Normal file
@@ -0,0 +1,492 @@
|
||||
//! Lightweight encrypted mesh link for constrained transports.
|
||||
//!
|
||||
//! On high-bandwidth transports (QUIC/TCP), we use TLS 1.3. On constrained
|
||||
//! transports (LoRa, Serial), the full TLS handshake is too expensive
|
||||
//! (~2-4 KB). This module provides a minimal 3-packet handshake that
|
||||
//! establishes a ChaCha20-Poly1305 encrypted session in ~240 bytes total.
|
||||
//!
|
||||
//! # Handshake Protocol
|
||||
//!
|
||||
//! ```text
|
||||
//! Packet 1: Initiator -> Responder (80 bytes)
|
||||
//! [initiator_addr: 16][eph_x25519_pub: 32][nonce: 24][flags: 8]
|
||||
//!
|
||||
//! Packet 2: Responder -> Initiator (96 bytes)
|
||||
//! [responder_addr: 16][eph_x25519_pub: 32][encrypted_proof: 32][tag: 16]
|
||||
//!
|
||||
//! Packet 3: Initiator -> Responder (48 bytes)
|
||||
//! [encrypted_proof: 32][tag: 16]
|
||||
//!
|
||||
//! Total: 224 bytes
|
||||
//!
|
||||
//! Shared secret: HKDF-SHA256(ikm = X25519(eph_a, eph_b), info = "qpc-mesh-link-v1")
|
||||
//! ```
|
||||
|
||||
use chacha20poly1305::aead::{Aead, KeyInit};
|
||||
use chacha20poly1305::{ChaCha20Poly1305, Nonce};
|
||||
use hkdf::Hkdf;
|
||||
use rand::rngs::OsRng;
|
||||
use rand::RngCore;
|
||||
use sha2::Sha256;
|
||||
use x25519_dalek::{EphemeralSecret, PublicKey as X25519Public};
|
||||
use zeroize::Zeroize;
|
||||
|
||||
use crate::address::MeshAddress;
|
||||
|
||||
/// Errors that can occur during link handshake or encryption.
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum LinkError {
|
||||
/// Received packet has wrong length.
|
||||
#[error("invalid packet length: expected {expected}, got {got}")]
|
||||
InvalidLength { expected: usize, got: usize },
|
||||
|
||||
/// AEAD decryption failed (wrong key or tampered data).
|
||||
#[error("decryption failed: invalid ciphertext or authentication tag")]
|
||||
DecryptionFailed,
|
||||
|
||||
/// The proof inside a handshake packet did not match the expected address.
|
||||
#[error("handshake proof mismatch: peer address does not match encrypted proof")]
|
||||
ProofMismatch,
|
||||
}
|
||||
|
||||
/// Packet sizes for the 3-packet handshake.
|
||||
pub const PACKET1_LEN: usize = 80; // 16 + 32 + 24 + 8
|
||||
pub const PACKET2_LEN: usize = 96; // 16 + 32 + 16 + 16 + 16 (addr + pub + encrypted_addr + tag)
|
||||
pub const PACKET3_LEN: usize = 48; // 16 + 16 + 16 (encrypted_addr + tag)
|
||||
|
||||
/// Derive a 32-byte session key from a shared secret and nonce via HKDF-SHA256.
|
||||
fn derive_session_key(shared_secret: &[u8], salt: &[u8]) -> [u8; 32] {
|
||||
let hk = Hkdf::<Sha256>::new(Some(salt), shared_secret);
|
||||
let mut key = [0u8; 32];
|
||||
hk.expand(b"qpc-mesh-link-v1", &mut key)
|
||||
.expect("HKDF expand to 32 bytes should never fail");
|
||||
key
|
||||
}
|
||||
|
||||
/// Build a ChaCha20Poly1305 nonce from a u64 counter (zero-padded, little-endian).
|
||||
fn counter_nonce(counter: u64) -> Nonce {
|
||||
let mut nonce_bytes = [0u8; 12];
|
||||
nonce_bytes[..8].copy_from_slice(&counter.to_le_bytes());
|
||||
*Nonce::from_slice(&nonce_bytes)
|
||||
}
|
||||
|
||||
/// An established encrypted mesh link session.
|
||||
pub struct MeshLink {
|
||||
/// Derived symmetric key for ChaCha20-Poly1305.
|
||||
session_key: [u8; 32],
|
||||
/// Remote peer's mesh address.
|
||||
remote_address: MeshAddress,
|
||||
/// Message counter for nonce derivation (send direction).
|
||||
send_counter: u64,
|
||||
/// Message counter for nonce derivation (receive direction).
|
||||
recv_counter: u64,
|
||||
}
|
||||
|
||||
impl Drop for MeshLink {
|
||||
fn drop(&mut self) {
|
||||
self.session_key.zeroize();
|
||||
}
|
||||
}
|
||||
|
||||
impl MeshLink {
|
||||
/// Encrypt a message using the session key.
|
||||
///
|
||||
/// Returns the ciphertext (plaintext + 16-byte Poly1305 tag).
|
||||
pub fn encrypt(&mut self, plaintext: &[u8]) -> Result<Vec<u8>, LinkError> {
|
||||
// Nonces for encrypt start at offset 256 to avoid collision with handshake nonces.
|
||||
let nonce = counter_nonce(256 + self.send_counter);
|
||||
let cipher = ChaCha20Poly1305::new((&self.session_key).into());
|
||||
let ciphertext = cipher
|
||||
.encrypt(&nonce, plaintext)
|
||||
.map_err(|_| LinkError::DecryptionFailed)?;
|
||||
self.send_counter += 1;
|
||||
Ok(ciphertext)
|
||||
}
|
||||
|
||||
/// Decrypt a message using the session key.
|
||||
pub fn decrypt(&mut self, ciphertext: &[u8]) -> Result<Vec<u8>, LinkError> {
|
||||
let nonce = counter_nonce(256 + self.recv_counter);
|
||||
let cipher = ChaCha20Poly1305::new((&self.session_key).into());
|
||||
let plaintext = cipher
|
||||
.decrypt(&nonce, ciphertext)
|
||||
.map_err(|_| LinkError::DecryptionFailed)?;
|
||||
self.recv_counter += 1;
|
||||
Ok(plaintext)
|
||||
}
|
||||
|
||||
/// Remote peer's address.
|
||||
pub fn remote_address(&self) -> MeshAddress {
|
||||
self.remote_address
|
||||
}
|
||||
|
||||
/// Number of messages sent on this link.
|
||||
pub fn messages_sent(&self) -> u64 {
|
||||
self.send_counter
|
||||
}
|
||||
|
||||
/// Number of messages received on this link.
|
||||
pub fn messages_received(&self) -> u64 {
|
||||
self.recv_counter
|
||||
}
|
||||
|
||||
/// Access the session key (for testing only).
|
||||
#[cfg(test)]
|
||||
fn session_key(&self) -> &[u8; 32] {
|
||||
&self.session_key
|
||||
}
|
||||
}
|
||||
|
||||
/// Handshake state for the initiator side of a mesh link.
|
||||
pub struct LinkInitiator {
|
||||
local_address: MeshAddress,
|
||||
eph_secret: EphemeralSecret,
|
||||
nonce: [u8; 24],
|
||||
}
|
||||
|
||||
/// Handshake state for the responder side of a mesh link.
|
||||
pub struct LinkResponder {
|
||||
remote_address: MeshAddress,
|
||||
session_key: [u8; 32],
|
||||
}
|
||||
|
||||
impl Drop for LinkResponder {
|
||||
fn drop(&mut self) {
|
||||
self.session_key.zeroize();
|
||||
}
|
||||
}
|
||||
|
||||
impl LinkInitiator {
|
||||
/// Create initiator state and generate Packet 1.
|
||||
///
|
||||
/// Packet 1 layout (80 bytes):
|
||||
/// `[initiator_addr: 16][eph_pub: 32][nonce: 24][flags: 8]`
|
||||
pub fn new(local_address: MeshAddress) -> (Self, Vec<u8>) {
|
||||
let eph_secret = EphemeralSecret::random_from_rng(OsRng);
|
||||
let eph_public = X25519Public::from(&eph_secret);
|
||||
|
||||
let mut nonce = [0u8; 24];
|
||||
OsRng.fill_bytes(&mut nonce);
|
||||
|
||||
let mut packet = Vec::with_capacity(PACKET1_LEN);
|
||||
packet.extend_from_slice(local_address.as_bytes());
|
||||
packet.extend_from_slice(eph_public.as_bytes());
|
||||
packet.extend_from_slice(&nonce);
|
||||
packet.extend_from_slice(&[0u8; 8]); // flags: reserved
|
||||
|
||||
let initiator = Self {
|
||||
local_address,
|
||||
eph_secret,
|
||||
nonce,
|
||||
};
|
||||
|
||||
(initiator, packet)
|
||||
}
|
||||
|
||||
/// Process Packet 2 from responder, generate Packet 3, return completed link.
|
||||
///
|
||||
/// Packet 2 layout (96 bytes):
|
||||
/// `[responder_addr: 16][eph_pub: 32][encrypted_responder_addr: 16+16]`
|
||||
///
|
||||
/// Packet 3 layout (48 bytes):
|
||||
/// `[encrypted_initiator_addr: 16+16][padding: 16]`
|
||||
pub fn process_response(self, packet2: &[u8]) -> Result<(MeshLink, Vec<u8>), LinkError> {
|
||||
if packet2.len() != PACKET2_LEN {
|
||||
return Err(LinkError::InvalidLength {
|
||||
expected: PACKET2_LEN,
|
||||
got: packet2.len(),
|
||||
});
|
||||
}
|
||||
|
||||
// Parse Packet 2.
|
||||
let mut responder_addr_bytes = [0u8; 16];
|
||||
responder_addr_bytes.copy_from_slice(&packet2[..16]);
|
||||
let responder_address = MeshAddress::from_bytes(responder_addr_bytes);
|
||||
|
||||
let mut responder_eph_pub_bytes = [0u8; 32];
|
||||
responder_eph_pub_bytes.copy_from_slice(&packet2[16..48]);
|
||||
let responder_eph_pub = X25519Public::from(responder_eph_pub_bytes);
|
||||
|
||||
let encrypted_proof = &packet2[48..80]; // 16-byte ciphertext + 16-byte Poly1305 tag = 32 bytes
|
||||
|
||||
// Compute shared secret (consumes eph_secret).
|
||||
let shared_secret = self.eph_secret.diffie_hellman(&responder_eph_pub);
|
||||
|
||||
// Derive session key.
|
||||
let session_key = derive_session_key(shared_secret.as_bytes(), &self.nonce);
|
||||
|
||||
// Verify responder's proof: decrypt and check it matches responder_addr.
|
||||
let cipher = ChaCha20Poly1305::new((&session_key).into());
|
||||
let proof_nonce = counter_nonce(0);
|
||||
let decrypted_proof = cipher
|
||||
.decrypt(&proof_nonce, encrypted_proof)
|
||||
.map_err(|_| LinkError::DecryptionFailed)?;
|
||||
|
||||
if decrypted_proof.as_slice() != responder_addr_bytes.as_slice() {
|
||||
return Err(LinkError::ProofMismatch);
|
||||
}
|
||||
|
||||
// Build Packet 3: encrypt our address as proof.
|
||||
let proof_nonce_3 = counter_nonce(1);
|
||||
let encrypted_initiator_addr = cipher
|
||||
.encrypt(&proof_nonce_3, self.local_address.as_bytes().as_slice())
|
||||
.map_err(|_| LinkError::DecryptionFailed)?;
|
||||
|
||||
let mut packet3 = Vec::with_capacity(PACKET3_LEN);
|
||||
packet3.extend_from_slice(&encrypted_initiator_addr);
|
||||
// Pad to 48 bytes.
|
||||
packet3.resize(PACKET3_LEN, 0);
|
||||
|
||||
let link = MeshLink {
|
||||
session_key,
|
||||
remote_address: responder_address,
|
||||
send_counter: 0,
|
||||
recv_counter: 0,
|
||||
};
|
||||
|
||||
Ok((link, packet3))
|
||||
}
|
||||
}
|
||||
|
||||
impl LinkResponder {
|
||||
/// Process Packet 1 from initiator, generate Packet 2.
|
||||
///
|
||||
/// Packet 1 layout (80 bytes):
|
||||
/// `[initiator_addr: 16][eph_pub: 32][nonce: 24][flags: 8]`
|
||||
///
|
||||
/// Packet 2 layout (96 bytes):
|
||||
/// `[responder_addr: 16][eph_pub: 32][encrypted_responder_addr: 16+16]`
|
||||
pub fn new(
|
||||
local_address: MeshAddress,
|
||||
packet1: &[u8],
|
||||
) -> Result<(Self, Vec<u8>), LinkError> {
|
||||
if packet1.len() != PACKET1_LEN {
|
||||
return Err(LinkError::InvalidLength {
|
||||
expected: PACKET1_LEN,
|
||||
got: packet1.len(),
|
||||
});
|
||||
}
|
||||
|
||||
// Parse Packet 1.
|
||||
let mut initiator_addr_bytes = [0u8; 16];
|
||||
initiator_addr_bytes.copy_from_slice(&packet1[..16]);
|
||||
let remote_address = MeshAddress::from_bytes(initiator_addr_bytes);
|
||||
|
||||
let mut initiator_eph_pub_bytes = [0u8; 32];
|
||||
initiator_eph_pub_bytes.copy_from_slice(&packet1[16..48]);
|
||||
let initiator_eph_pub = X25519Public::from(initiator_eph_pub_bytes);
|
||||
|
||||
let mut nonce = [0u8; 24];
|
||||
nonce.copy_from_slice(&packet1[48..72]);
|
||||
// flags at [72..80] — reserved, ignored.
|
||||
|
||||
// Generate our ephemeral keypair.
|
||||
let eph_secret = EphemeralSecret::random_from_rng(OsRng);
|
||||
let eph_public = X25519Public::from(&eph_secret);
|
||||
|
||||
// Compute shared secret (consumes eph_secret).
|
||||
let shared_secret = eph_secret.diffie_hellman(&initiator_eph_pub);
|
||||
|
||||
// Derive session key.
|
||||
let session_key = derive_session_key(shared_secret.as_bytes(), &nonce);
|
||||
|
||||
// Build Packet 2: our address + our eph_pub + encrypted proof of our address.
|
||||
let cipher = ChaCha20Poly1305::new((&session_key).into());
|
||||
let proof_nonce = counter_nonce(0);
|
||||
let encrypted_proof = cipher
|
||||
.encrypt(&proof_nonce, local_address.as_bytes().as_slice())
|
||||
.map_err(|_| LinkError::DecryptionFailed)?;
|
||||
|
||||
let mut packet2 = Vec::with_capacity(PACKET2_LEN);
|
||||
packet2.extend_from_slice(local_address.as_bytes());
|
||||
packet2.extend_from_slice(eph_public.as_bytes());
|
||||
packet2.extend_from_slice(&encrypted_proof);
|
||||
// Pad to PACKET2_LEN for fixed-size framing on constrained transports.
|
||||
packet2.resize(PACKET2_LEN, 0);
|
||||
|
||||
let responder = Self {
|
||||
remote_address,
|
||||
session_key,
|
||||
};
|
||||
|
||||
Ok((responder, packet2))
|
||||
}
|
||||
|
||||
/// Process Packet 3 from initiator, return completed link.
|
||||
///
|
||||
/// Packet 3 layout (48 bytes):
|
||||
/// `[encrypted_initiator_addr: 16+16][padding: 16]`
|
||||
pub fn complete(self, packet3: &[u8]) -> Result<MeshLink, LinkError> {
|
||||
if packet3.len() != PACKET3_LEN {
|
||||
return Err(LinkError::InvalidLength {
|
||||
expected: PACKET3_LEN,
|
||||
got: packet3.len(),
|
||||
});
|
||||
}
|
||||
|
||||
// The encrypted proof is the first 32 bytes (16 plaintext + 16 tag).
|
||||
let encrypted_proof = &packet3[..32];
|
||||
|
||||
let cipher = ChaCha20Poly1305::new((&self.session_key).into());
|
||||
let proof_nonce = counter_nonce(1);
|
||||
let decrypted_proof = cipher
|
||||
.decrypt(&proof_nonce, encrypted_proof)
|
||||
.map_err(|_| LinkError::DecryptionFailed)?;
|
||||
|
||||
let mut expected_addr = [0u8; 16];
|
||||
expected_addr.copy_from_slice(self.remote_address.as_bytes());
|
||||
|
||||
if decrypted_proof.as_slice() != expected_addr.as_slice() {
|
||||
return Err(LinkError::ProofMismatch);
|
||||
}
|
||||
|
||||
Ok(MeshLink {
|
||||
session_key: self.session_key,
|
||||
remote_address: self.remote_address,
|
||||
send_counter: 0,
|
||||
recv_counter: 0,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn test_address(byte: u8) -> MeshAddress {
|
||||
MeshAddress::from_public_key(&[byte; 32])
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn full_handshake_roundtrip() {
|
||||
let addr_a = test_address(1);
|
||||
let addr_b = test_address(2);
|
||||
|
||||
// Initiator creates Packet 1.
|
||||
let (initiator, packet1) = LinkInitiator::new(addr_a);
|
||||
assert_eq!(packet1.len(), PACKET1_LEN);
|
||||
|
||||
// Responder processes Packet 1, creates Packet 2.
|
||||
let (responder, packet2) = LinkResponder::new(addr_b, &packet1).expect("responder::new");
|
||||
assert_eq!(packet2.len(), PACKET2_LEN);
|
||||
|
||||
// Initiator processes Packet 2, creates Packet 3, gets link.
|
||||
let (link_a, packet3) = initiator
|
||||
.process_response(&packet2)
|
||||
.expect("initiator::process_response");
|
||||
assert_eq!(packet3.len(), PACKET3_LEN);
|
||||
|
||||
// Responder processes Packet 3, gets link.
|
||||
let link_b = responder.complete(&packet3).expect("responder::complete");
|
||||
|
||||
// Both sides should have the same session key.
|
||||
assert_eq!(link_a.session_key(), link_b.session_key());
|
||||
|
||||
// Check remote addresses.
|
||||
assert_eq!(link_a.remote_address(), addr_b);
|
||||
assert_eq!(link_b.remote_address(), addr_a);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn encrypt_decrypt_roundtrip() {
|
||||
let addr_a = test_address(10);
|
||||
let addr_b = test_address(20);
|
||||
|
||||
let (initiator, packet1) = LinkInitiator::new(addr_a);
|
||||
let (responder, packet2) = LinkResponder::new(addr_b, &packet1).expect("responder");
|
||||
let (mut link_a, packet3) = initiator.process_response(&packet2).expect("initiator");
|
||||
let mut link_b = responder.complete(&packet3).expect("complete");
|
||||
|
||||
let plaintext = b"hello constrained mesh";
|
||||
let ciphertext = link_a.encrypt(plaintext).expect("encrypt");
|
||||
let decrypted = link_b.decrypt(&ciphertext).expect("decrypt");
|
||||
assert_eq!(decrypted, plaintext);
|
||||
|
||||
// Reverse direction.
|
||||
let plaintext2 = b"hello back";
|
||||
let ciphertext2 = link_b.encrypt(plaintext2).expect("encrypt");
|
||||
let decrypted2 = link_a.decrypt(&ciphertext2).expect("decrypt");
|
||||
assert_eq!(decrypted2, plaintext2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wrong_key_fails_decrypt() {
|
||||
let addr_a = test_address(30);
|
||||
let addr_b = test_address(40);
|
||||
|
||||
let (initiator, packet1) = LinkInitiator::new(addr_a);
|
||||
let (responder, packet2) = LinkResponder::new(addr_b, &packet1).expect("responder");
|
||||
let (mut link_a, packet3) = initiator.process_response(&packet2).expect("initiator");
|
||||
let _link_b = responder.complete(&packet3).expect("complete");
|
||||
|
||||
let ciphertext = link_a.encrypt(b"secret").expect("encrypt");
|
||||
|
||||
// Create a link with a different session key.
|
||||
let mut fake_link = MeshLink {
|
||||
session_key: [0xFFu8; 32],
|
||||
remote_address: addr_a,
|
||||
send_counter: 0,
|
||||
recv_counter: 0,
|
||||
};
|
||||
|
||||
let result = fake_link.decrypt(&ciphertext);
|
||||
assert!(result.is_err(), "decryption with wrong key must fail");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn counter_increments() {
|
||||
let addr_a = test_address(50);
|
||||
let addr_b = test_address(60);
|
||||
|
||||
let (initiator, packet1) = LinkInitiator::new(addr_a);
|
||||
let (responder, packet2) = LinkResponder::new(addr_b, &packet1).expect("responder");
|
||||
let (mut link_a, packet3) = initiator.process_response(&packet2).expect("initiator");
|
||||
let mut link_b = responder.complete(&packet3).expect("complete");
|
||||
|
||||
assert_eq!(link_a.messages_sent(), 0);
|
||||
assert_eq!(link_b.messages_received(), 0);
|
||||
|
||||
link_a.encrypt(b"msg1").expect("encrypt");
|
||||
assert_eq!(link_a.messages_sent(), 1);
|
||||
|
||||
link_a.encrypt(b"msg2").expect("encrypt");
|
||||
assert_eq!(link_a.messages_sent(), 2);
|
||||
|
||||
// Decrypt two messages on the other side.
|
||||
// We need fresh ciphertexts — re-do with proper counter tracking.
|
||||
let addr_c = test_address(70);
|
||||
let addr_d = test_address(80);
|
||||
let (init2, p1) = LinkInitiator::new(addr_c);
|
||||
let (resp2, p2) = LinkResponder::new(addr_d, &p1).expect("responder");
|
||||
let (mut la, p3) = init2.process_response(&p2).expect("initiator");
|
||||
let mut lb = resp2.complete(&p3).expect("complete");
|
||||
|
||||
let ct1 = la.encrypt(b"msg1").expect("encrypt");
|
||||
let ct2 = la.encrypt(b"msg2").expect("encrypt");
|
||||
|
||||
lb.decrypt(&ct1).expect("decrypt");
|
||||
assert_eq!(lb.messages_received(), 1);
|
||||
|
||||
lb.decrypt(&ct2).expect("decrypt");
|
||||
assert_eq!(lb.messages_received(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn packet_sizes() {
|
||||
let addr = test_address(90);
|
||||
|
||||
let (_initiator, packet1) = LinkInitiator::new(addr);
|
||||
assert_eq!(packet1.len(), 80, "packet 1 must be 80 bytes");
|
||||
|
||||
// Complete a handshake to check packet 2 and 3 sizes.
|
||||
let addr_b = test_address(91);
|
||||
let (init, p1) = LinkInitiator::new(addr);
|
||||
let (resp, p2) = LinkResponder::new(addr_b, &p1).expect("responder");
|
||||
assert_eq!(p2.len(), 96, "packet 2 must be 96 bytes");
|
||||
|
||||
let (_link, p3) = init.process_response(&p2).expect("initiator");
|
||||
assert_eq!(p3.len(), 48, "packet 3 must be 48 bytes");
|
||||
|
||||
// Verify responder can complete.
|
||||
resp.complete(&p3).expect("complete");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user