Files
quicproquo/crates/quicprochat-p2p/src/link.rs
Christian Nennemann f9ac921a0c 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
2026-03-30 21:19:12 +02:00

493 lines
17 KiB
Rust

//! 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");
}
}