chore: rename quicproquo → quicprochat in Rust workspace
Rename all crate directories, package names, binary names, proto package/module paths, ALPN strings, env var prefixes, config filenames, mDNS service names, and plugin ABI symbols from quicproquo/qpq to quicprochat/qpc.
This commit is contained in:
232
crates/quicprochat-p2p/src/broadcast.rs
Normal file
232
crates/quicprochat-p2p/src/broadcast.rs
Normal file
@@ -0,0 +1,232 @@
|
||||
//! Lightweight pub/sub broadcast channels for mesh announcements.
|
||||
//!
|
||||
//! Each [`BroadcastChannel`] holds a ChaCha20-Poly1305 symmetric key used to
|
||||
//! encrypt and decrypt messages on that topic. Peers that know the key can
|
||||
//! subscribe; the key itself is exchanged out-of-band.
|
||||
//!
|
||||
//! [`BroadcastManager`] collects channels by topic and provides convenience
|
||||
//! methods for encrypt/decrypt without exposing raw keys.
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use chacha20poly1305::aead::{Aead, AeadCore, KeyInit};
|
||||
use chacha20poly1305::ChaCha20Poly1305;
|
||||
use rand::rngs::OsRng;
|
||||
use zeroize::{Zeroize, ZeroizeOnDrop};
|
||||
|
||||
/// A single broadcast channel identified by topic, secured with a symmetric key.
|
||||
pub struct BroadcastChannel {
|
||||
topic: String,
|
||||
key: [u8; 32],
|
||||
}
|
||||
|
||||
impl Drop for BroadcastChannel {
|
||||
fn drop(&mut self) {
|
||||
self.key.zeroize();
|
||||
}
|
||||
}
|
||||
|
||||
impl ZeroizeOnDrop for BroadcastChannel {}
|
||||
|
||||
impl BroadcastChannel {
|
||||
/// Create a new channel with a random ChaCha20-Poly1305 key.
|
||||
pub fn new(topic: &str) -> Self {
|
||||
let mut key = [0u8; 32];
|
||||
rand::RngCore::fill_bytes(&mut OsRng, &mut key);
|
||||
Self {
|
||||
topic: topic.to_string(),
|
||||
key,
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a channel with a pre-shared key (e.g. received from another peer).
|
||||
pub fn with_key(topic: &str, key: [u8; 32]) -> Self {
|
||||
Self {
|
||||
topic: topic.to_string(),
|
||||
key,
|
||||
}
|
||||
}
|
||||
|
||||
/// Encrypt `plaintext`, returning `nonce || ciphertext`.
|
||||
pub fn encrypt(&self, plaintext: &[u8]) -> anyhow::Result<Vec<u8>> {
|
||||
let cipher = ChaCha20Poly1305::new((&self.key).into());
|
||||
let nonce = ChaCha20Poly1305::generate_nonce(&mut OsRng);
|
||||
let ciphertext = cipher
|
||||
.encrypt(&nonce, plaintext)
|
||||
.map_err(|_| anyhow::anyhow!("ChaCha20Poly1305 encryption failed"))?;
|
||||
let mut out = Vec::with_capacity(nonce.len() + ciphertext.len());
|
||||
out.extend_from_slice(&nonce);
|
||||
out.extend_from_slice(&ciphertext);
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Decrypt data produced by [`encrypt`](Self::encrypt).
|
||||
///
|
||||
/// Expects `nonce (12 bytes) || ciphertext`.
|
||||
pub fn decrypt(&self, data: &[u8]) -> anyhow::Result<Vec<u8>> {
|
||||
if data.len() < 12 {
|
||||
anyhow::bail!("broadcast ciphertext too short (need at least 12-byte nonce)");
|
||||
}
|
||||
let (nonce_bytes, ciphertext) = data.split_at(12);
|
||||
let nonce = chacha20poly1305::Nonce::from_slice(nonce_bytes);
|
||||
let cipher = ChaCha20Poly1305::new((&self.key).into());
|
||||
cipher
|
||||
.decrypt(nonce, ciphertext)
|
||||
.map_err(|_| anyhow::anyhow!("broadcast decryption failed (wrong key or corrupted)"))
|
||||
}
|
||||
|
||||
/// The topic name for this channel.
|
||||
pub fn topic(&self) -> &str {
|
||||
&self.topic
|
||||
}
|
||||
|
||||
/// The raw 32-byte symmetric key (for sharing with peers out-of-band).
|
||||
pub fn key(&self) -> &[u8; 32] {
|
||||
&self.key
|
||||
}
|
||||
}
|
||||
|
||||
/// Manages a set of broadcast channels keyed by topic.
|
||||
pub struct BroadcastManager {
|
||||
channels: HashMap<String, BroadcastChannel>,
|
||||
}
|
||||
|
||||
impl BroadcastManager {
|
||||
/// Create an empty manager.
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
channels: HashMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Subscribe to a topic with a pre-shared key.
|
||||
pub fn subscribe(&mut self, topic: &str, key: [u8; 32]) {
|
||||
self.channels
|
||||
.insert(topic.to_string(), BroadcastChannel::with_key(topic, key));
|
||||
}
|
||||
|
||||
/// Unsubscribe from a topic.
|
||||
pub fn unsubscribe(&mut self, topic: &str) {
|
||||
self.channels.remove(topic);
|
||||
}
|
||||
|
||||
/// Create a new broadcast channel with a random key and return a reference.
|
||||
pub fn create_channel(&mut self, topic: &str) -> &BroadcastChannel {
|
||||
self.channels
|
||||
.insert(topic.to_string(), BroadcastChannel::new(topic));
|
||||
self.channels
|
||||
.get(topic)
|
||||
.expect("just inserted")
|
||||
}
|
||||
|
||||
/// Look up a channel by topic.
|
||||
pub fn get(&self, topic: &str) -> Option<&BroadcastChannel> {
|
||||
self.channels.get(topic)
|
||||
}
|
||||
|
||||
/// List all subscribed topics.
|
||||
pub fn topics(&self) -> Vec<String> {
|
||||
self.channels.keys().cloned().collect()
|
||||
}
|
||||
|
||||
/// Encrypt a message on the given topic. Returns `None` if not subscribed.
|
||||
pub fn encrypt(&self, topic: &str, plaintext: &[u8]) -> Option<anyhow::Result<Vec<u8>>> {
|
||||
self.channels.get(topic).map(|ch| ch.encrypt(plaintext))
|
||||
}
|
||||
|
||||
/// Decrypt a message on the given topic. Returns `None` if not subscribed.
|
||||
pub fn decrypt(&self, topic: &str, data: &[u8]) -> Option<Vec<u8>> {
|
||||
self.channels
|
||||
.get(topic)
|
||||
.and_then(|ch| ch.decrypt(data).ok())
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for BroadcastManager {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn encrypt_decrypt_roundtrip() {
|
||||
let ch = BroadcastChannel::new("test-topic");
|
||||
let plaintext = b"hello broadcast";
|
||||
let encrypted = ch.encrypt(plaintext).expect("encrypt");
|
||||
let decrypted = ch.decrypt(&encrypted).expect("decrypt");
|
||||
assert_eq!(decrypted, plaintext);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wrong_key_fails_decrypt() {
|
||||
let ch1 = BroadcastChannel::new("topic");
|
||||
let ch2 = BroadcastChannel::new("topic"); // different random key
|
||||
let encrypted = ch1.encrypt(b"secret").expect("encrypt");
|
||||
let result = ch2.decrypt(&encrypted);
|
||||
assert!(result.is_err(), "wrong key should fail decryption");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn with_key_roundtrip() {
|
||||
let key = [42u8; 32];
|
||||
let ch = BroadcastChannel::with_key("shared", key);
|
||||
let ct = ch.encrypt(b"data").expect("encrypt");
|
||||
let ch2 = BroadcastChannel::with_key("shared", key);
|
||||
let pt = ch2.decrypt(&ct).expect("same key should decrypt");
|
||||
assert_eq!(pt, b"data");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn manager_subscribe_unsubscribe() {
|
||||
let mut mgr = BroadcastManager::new();
|
||||
assert!(mgr.topics().is_empty());
|
||||
|
||||
let key = [1u8; 32];
|
||||
mgr.subscribe("alerts", key);
|
||||
assert_eq!(mgr.topics().len(), 1);
|
||||
assert!(mgr.get("alerts").is_some());
|
||||
|
||||
mgr.unsubscribe("alerts");
|
||||
assert!(mgr.topics().is_empty());
|
||||
assert!(mgr.get("alerts").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn manager_create_channel() {
|
||||
let mut mgr = BroadcastManager::new();
|
||||
let ch = mgr.create_channel("news");
|
||||
let key = *ch.key();
|
||||
assert_eq!(ch.topic(), "news");
|
||||
|
||||
// Encrypt via manager, decrypt manually with the same key.
|
||||
let ct = mgr.encrypt("news", b"headline").expect("subscribed").expect("encrypt");
|
||||
let ch2 = BroadcastChannel::with_key("news", key);
|
||||
let pt = ch2.decrypt(&ct).expect("decrypt");
|
||||
assert_eq!(pt, b"headline");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn manager_encrypt_decrypt() {
|
||||
let mut mgr = BroadcastManager::new();
|
||||
mgr.subscribe("ch1", [7u8; 32]);
|
||||
|
||||
let ct = mgr.encrypt("ch1", b"round-trip").expect("subscribed").expect("encrypt");
|
||||
let pt = mgr.decrypt("ch1", &ct).expect("decrypt");
|
||||
assert_eq!(pt, b"round-trip");
|
||||
|
||||
// Unknown topic returns None.
|
||||
assert!(mgr.encrypt("unknown", b"x").is_none());
|
||||
assert!(mgr.decrypt("unknown", b"x").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn short_ciphertext_rejected() {
|
||||
let ch = BroadcastChannel::new("t");
|
||||
let result = ch.decrypt(&[0u8; 5]); // less than 12-byte nonce
|
||||
assert!(result.is_err());
|
||||
}
|
||||
}
|
||||
296
crates/quicprochat-p2p/src/envelope.rs
Normal file
296
crates/quicprochat-p2p/src/envelope.rs
Normal file
@@ -0,0 +1,296 @@
|
||||
//! Store-and-forward message envelope for mesh routing.
|
||||
//!
|
||||
//! A [`MeshEnvelope`] wraps an encrypted payload with routing metadata
|
||||
//! (sender/recipient keys, TTL, hop count) and an Ed25519 signature for
|
||||
//! integrity. Envelopes are deduplicated by a SHA-256 content ID.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
use crate::identity::MeshIdentity;
|
||||
|
||||
/// Default maximum hops for mesh forwarding.
|
||||
const DEFAULT_MAX_HOPS: u8 = 5;
|
||||
|
||||
/// A signed, routable message envelope for mesh store-and-forward.
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub struct MeshEnvelope {
|
||||
/// SHA-256 content ID (for deduplication).
|
||||
pub id: [u8; 32],
|
||||
/// 32-byte Ed25519 public key of the sender.
|
||||
pub sender_key: Vec<u8>,
|
||||
/// 32-byte Ed25519 public key of the recipient (empty for broadcast).
|
||||
pub recipient_key: Vec<u8>,
|
||||
/// Encrypted message body (opaque to the mesh layer).
|
||||
pub payload: Vec<u8>,
|
||||
/// Time-to-live in seconds from `timestamp`.
|
||||
pub ttl_secs: u32,
|
||||
/// Current hop count (incremented on each forward).
|
||||
pub hop_count: u8,
|
||||
/// Maximum allowed hops before the envelope is dropped.
|
||||
pub max_hops: u8,
|
||||
/// Unix timestamp (seconds) of creation.
|
||||
pub timestamp: u64,
|
||||
/// Ed25519 signature over all fields except `signature` itself.
|
||||
pub signature: Vec<u8>,
|
||||
}
|
||||
|
||||
impl MeshEnvelope {
|
||||
/// Create and sign a new mesh envelope.
|
||||
pub fn new(
|
||||
identity: &MeshIdentity,
|
||||
recipient_key: &[u8],
|
||||
payload: Vec<u8>,
|
||||
ttl_secs: u32,
|
||||
max_hops: u8,
|
||||
) -> Self {
|
||||
let sender_key = identity.public_key().to_vec();
|
||||
let recipient_key = recipient_key.to_vec();
|
||||
let hop_count = 0u8;
|
||||
let max_hops = if max_hops == 0 {
|
||||
DEFAULT_MAX_HOPS
|
||||
} else {
|
||||
max_hops
|
||||
};
|
||||
let timestamp = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_secs();
|
||||
|
||||
let id = Self::compute_id(
|
||||
&sender_key,
|
||||
&recipient_key,
|
||||
&payload,
|
||||
ttl_secs,
|
||||
max_hops,
|
||||
timestamp,
|
||||
);
|
||||
|
||||
let signable = Self::signable_bytes(&id, &sender_key, &recipient_key, &payload, ttl_secs, max_hops, timestamp);
|
||||
let signature = identity.sign(&signable).to_vec();
|
||||
|
||||
Self {
|
||||
id,
|
||||
sender_key,
|
||||
recipient_key,
|
||||
payload,
|
||||
ttl_secs,
|
||||
hop_count,
|
||||
max_hops,
|
||||
timestamp,
|
||||
signature,
|
||||
}
|
||||
}
|
||||
|
||||
/// Compute the content ID from the immutable envelope fields.
|
||||
pub fn compute_id(
|
||||
sender_key: &[u8],
|
||||
recipient_key: &[u8],
|
||||
payload: &[u8],
|
||||
ttl_secs: u32,
|
||||
max_hops: u8,
|
||||
timestamp: u64,
|
||||
) -> [u8; 32] {
|
||||
let mut hasher = Sha256::new();
|
||||
hasher.update(sender_key);
|
||||
hasher.update(recipient_key);
|
||||
hasher.update(payload);
|
||||
hasher.update(ttl_secs.to_le_bytes());
|
||||
hasher.update([max_hops]);
|
||||
hasher.update(timestamp.to_le_bytes());
|
||||
hasher.finalize().into()
|
||||
}
|
||||
|
||||
/// Assemble the byte string that is signed / verified.
|
||||
///
|
||||
/// `hop_count` is intentionally excluded: forwarding nodes increment it
|
||||
/// without re-signing, so including it would invalidate the sender's
|
||||
/// original signature on every hop.
|
||||
fn signable_bytes(
|
||||
id: &[u8; 32],
|
||||
sender_key: &[u8],
|
||||
recipient_key: &[u8],
|
||||
payload: &[u8],
|
||||
ttl_secs: u32,
|
||||
max_hops: u8,
|
||||
timestamp: u64,
|
||||
) -> Vec<u8> {
|
||||
let mut buf = Vec::with_capacity(32 + sender_key.len() + recipient_key.len() + payload.len() + 13);
|
||||
buf.extend_from_slice(id);
|
||||
buf.extend_from_slice(sender_key);
|
||||
buf.extend_from_slice(recipient_key);
|
||||
buf.extend_from_slice(payload);
|
||||
buf.extend_from_slice(&ttl_secs.to_le_bytes());
|
||||
buf.push(max_hops);
|
||||
buf.extend_from_slice(×tamp.to_le_bytes());
|
||||
buf
|
||||
}
|
||||
|
||||
/// Verify the envelope's Ed25519 signature.
|
||||
///
|
||||
/// Returns `true` if the signature is valid and the sender key is a valid
|
||||
/// Ed25519 public key.
|
||||
pub fn verify(&self) -> bool {
|
||||
let sender_key: [u8; 32] = match self.sender_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(
|
||||
&self.id,
|
||||
&self.sender_key,
|
||||
&self.recipient_key,
|
||||
&self.payload,
|
||||
self.ttl_secs,
|
||||
self.max_hops,
|
||||
self.timestamp,
|
||||
);
|
||||
quicprochat_core::IdentityKeypair::verify_raw(&sender_key, &signable, &sig).is_ok()
|
||||
}
|
||||
|
||||
/// Check whether this envelope has expired (TTL elapsed since timestamp).
|
||||
pub fn is_expired(&self) -> bool {
|
||||
let now = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_secs();
|
||||
now.saturating_sub(self.timestamp) > self.ttl_secs as u64
|
||||
}
|
||||
|
||||
/// Whether this envelope can be forwarded (not expired and under hop limit).
|
||||
pub fn can_forward(&self) -> bool {
|
||||
self.hop_count < self.max_hops && !self.is_expired()
|
||||
}
|
||||
|
||||
/// Create a forwarded copy with `hop_count` incremented by one.
|
||||
///
|
||||
/// The signature remains the sender's original signature — 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
|
||||
}
|
||||
|
||||
/// Serialize to bytes (JSON).
|
||||
pub fn to_bytes(&self) -> Vec<u8> {
|
||||
// serde_json::to_vec should not fail on a well-formed envelope.
|
||||
serde_json::to_vec(self).expect("envelope serialization should not fail")
|
||||
}
|
||||
|
||||
/// Deserialize from bytes (JSON).
|
||||
pub fn from_bytes(bytes: &[u8]) -> anyhow::Result<Self> {
|
||||
let env: Self = serde_json::from_slice(bytes)?;
|
||||
Ok(env)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn test_identity() -> MeshIdentity {
|
||||
MeshIdentity::generate()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn create_and_verify() {
|
||||
let id = test_identity();
|
||||
let recipient = [0xBBu8; 32];
|
||||
let env = MeshEnvelope::new(&id, &recipient, b"hello mesh".to_vec(), 3600, 5);
|
||||
|
||||
assert!(env.verify(), "freshly created envelope must verify");
|
||||
assert!(!env.is_expired());
|
||||
assert!(env.can_forward());
|
||||
assert_eq!(env.hop_count, 0);
|
||||
assert_eq!(env.sender_key, id.public_key().to_vec());
|
||||
assert_eq!(env.recipient_key, recipient.to_vec());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tampered_payload_fails_verify() {
|
||||
let id = test_identity();
|
||||
let mut env = MeshEnvelope::new(&id, &[0xCC; 32], b"original".to_vec(), 60, 3);
|
||||
env.payload = b"tampered".to_vec();
|
||||
assert!(!env.verify(), "tampered envelope must fail verification");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn expired_envelope() {
|
||||
let id = test_identity();
|
||||
let mut env = MeshEnvelope::new(&id, &[0xDD; 32], b"old".to_vec(), 0, 5);
|
||||
// Set timestamp to the past so TTL of 0 guarantees expiry.
|
||||
env.timestamp = 0;
|
||||
assert!(env.is_expired());
|
||||
assert!(!env.can_forward());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn forward_increments_hop() {
|
||||
let id = test_identity();
|
||||
let env = MeshEnvelope::new(&id, &[0xEE; 32], b"hop".to_vec(), 3600, 2);
|
||||
assert_eq!(env.hop_count, 0);
|
||||
|
||||
let fwd1 = env.forwarded();
|
||||
assert_eq!(fwd1.hop_count, 1);
|
||||
assert!(fwd1.can_forward());
|
||||
|
||||
let fwd2 = fwd1.forwarded();
|
||||
assert_eq!(fwd2.hop_count, 2);
|
||||
assert!(!fwd2.can_forward()); // hop_count == max_hops
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn forwarded_envelope_still_verifies() {
|
||||
let id = test_identity();
|
||||
let env = MeshEnvelope::new(&id, &[0xAA; 32], b"fwd-verify".to_vec(), 3600, 5);
|
||||
assert!(env.verify(), "original must verify");
|
||||
|
||||
let fwd = env.forwarded();
|
||||
assert_eq!(fwd.hop_count, 1);
|
||||
assert!(fwd.verify(), "forwarded envelope must still verify (hop_count excluded from signature)");
|
||||
|
||||
let fwd2 = fwd.forwarded();
|
||||
assert!(fwd2.verify(), "double-forwarded must still verify");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn verify_with_wrong_key_fails() {
|
||||
let id = test_identity();
|
||||
let mut env = MeshEnvelope::new(&id, &[0xBB; 32], b"wrong-key".to_vec(), 3600, 5);
|
||||
// Replace sender_key with a different key
|
||||
let other = test_identity();
|
||||
env.sender_key = other.public_key().to_vec();
|
||||
assert!(!env.verify(), "wrong sender key must fail verification");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn serialization_roundtrip() {
|
||||
let id = test_identity();
|
||||
let env = MeshEnvelope::new(&id, &[0xFF; 32], b"roundtrip".to_vec(), 300, 4);
|
||||
let bytes = env.to_bytes();
|
||||
let restored = MeshEnvelope::from_bytes(&bytes).expect("deserialize");
|
||||
assert_eq!(env.id, restored.id);
|
||||
assert_eq!(env.payload, restored.payload);
|
||||
assert!(restored.verify());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_max_hops_when_zero() {
|
||||
let id = test_identity();
|
||||
let env = MeshEnvelope::new(&id, &[0x11; 32], b"defaults".to_vec(), 60, 0);
|
||||
assert_eq!(env.max_hops, 5); // DEFAULT_MAX_HOPS
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn broadcast_envelope_empty_recipient() {
|
||||
let id = test_identity();
|
||||
let env = MeshEnvelope::new(&id, &[], b"broadcast".to_vec(), 60, 3);
|
||||
assert!(env.recipient_key.is_empty());
|
||||
assert!(env.verify());
|
||||
}
|
||||
}
|
||||
187
crates/quicprochat-p2p/src/identity.rs
Normal file
187
crates/quicprochat-p2p/src/identity.rs
Normal file
@@ -0,0 +1,187 @@
|
||||
//! Self-sovereign mesh identity backed by quicprochat-core Ed25519 keypairs.
|
||||
//!
|
||||
//! A [`MeshIdentity`] wraps an [`IdentityKeypair`] with a peer directory,
|
||||
//! enabling P2P nodes to persist identity and track known peers across
|
||||
//! restarts.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::path::Path;
|
||||
|
||||
use quicprochat_core::IdentityKeypair;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[cfg(unix)]
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
|
||||
/// Information about a known peer in the mesh network.
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub struct PeerInfo {
|
||||
/// Raw Ed25519 public key (32 bytes).
|
||||
pub public_key: Vec<u8>,
|
||||
/// Unix timestamp of last observed activity.
|
||||
pub last_seen: u64,
|
||||
/// Known network addresses (e.g. iroh `NodeAddr` serializations).
|
||||
pub addresses: Vec<String>,
|
||||
}
|
||||
|
||||
/// Persisted form of a mesh identity (JSON on disk).
|
||||
#[derive(Serialize, Deserialize)]
|
||||
struct IdentityFile {
|
||||
/// Hex-encoded 32-byte Ed25519 seed.
|
||||
seed: String,
|
||||
/// Known peers, keyed by hex-encoded peer public key.
|
||||
peers: HashMap<String, PeerInfo>,
|
||||
}
|
||||
|
||||
/// A self-sovereign mesh identity: an Ed25519 keypair + a known-peers directory.
|
||||
pub struct MeshIdentity {
|
||||
keypair: IdentityKeypair,
|
||||
known_peers: HashMap<String, PeerInfo>,
|
||||
}
|
||||
|
||||
impl MeshIdentity {
|
||||
/// Generate a fresh random mesh identity.
|
||||
pub fn generate() -> Self {
|
||||
Self {
|
||||
keypair: IdentityKeypair::generate(),
|
||||
known_peers: HashMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Recreate a mesh identity from a 32-byte Ed25519 seed.
|
||||
pub fn from_seed(seed: [u8; 32]) -> Self {
|
||||
Self {
|
||||
keypair: IdentityKeypair::from_seed(seed),
|
||||
known_peers: HashMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Load a mesh identity from a JSON file.
|
||||
pub fn load(path: &Path) -> anyhow::Result<Self> {
|
||||
let data = std::fs::read_to_string(path)?;
|
||||
let file: IdentityFile = serde_json::from_str(&data)?;
|
||||
let seed_bytes = hex::decode(&file.seed)?;
|
||||
let seed: [u8; 32] = seed_bytes
|
||||
.as_slice()
|
||||
.try_into()
|
||||
.map_err(|_| anyhow::anyhow!("seed must be 32 bytes"))?;
|
||||
Ok(Self {
|
||||
keypair: IdentityKeypair::from_seed(seed),
|
||||
known_peers: file.peers,
|
||||
})
|
||||
}
|
||||
|
||||
/// Save this mesh identity to a JSON file with restrictive permissions.
|
||||
///
|
||||
/// On Unix, the file is set to `0o600` (owner read/write only) since it
|
||||
/// contains the Ed25519 seed in the clear.
|
||||
pub fn save(&self, path: &Path) -> anyhow::Result<()> {
|
||||
let file = IdentityFile {
|
||||
seed: hex::encode(self.keypair.seed_bytes()),
|
||||
peers: self.known_peers.clone(),
|
||||
};
|
||||
let json = serde_json::to_string_pretty(&file)?;
|
||||
std::fs::write(path, json)?;
|
||||
|
||||
// Restrict permissions to owner-only on Unix.
|
||||
#[cfg(unix)]
|
||||
{
|
||||
let perms = std::fs::Permissions::from_mode(0o600);
|
||||
std::fs::set_permissions(path, perms)?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Return the raw 32-byte Ed25519 public key.
|
||||
pub fn public_key(&self) -> [u8; 32] {
|
||||
self.keypair.public_key_bytes()
|
||||
}
|
||||
|
||||
/// Sign arbitrary bytes, returning a 64-byte Ed25519 signature.
|
||||
pub fn sign(&self, message: &[u8]) -> [u8; 64] {
|
||||
self.keypair.sign_raw(message)
|
||||
}
|
||||
|
||||
/// Return the underlying seed (for deriving iroh `SecretKey`, etc.).
|
||||
pub fn seed_bytes(&self) -> [u8; 32] {
|
||||
*self.keypair.seed_bytes()
|
||||
}
|
||||
|
||||
/// Register or update a known peer.
|
||||
pub fn add_peer(&mut self, id: String, info: PeerInfo) {
|
||||
self.known_peers.insert(id, info);
|
||||
}
|
||||
|
||||
/// Immutable view of the known-peers directory.
|
||||
pub fn known_peers(&self) -> &HashMap<String, PeerInfo> {
|
||||
&self.known_peers
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
#[test]
|
||||
fn generate_and_sign_verify() {
|
||||
let id = MeshIdentity::generate();
|
||||
let msg = b"test message";
|
||||
let sig = id.sign(msg);
|
||||
|
||||
// Verify through quicprochat_core
|
||||
let pk = id.public_key();
|
||||
IdentityKeypair::verify_raw(&pk, msg, &sig).expect("valid signature");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn from_seed_deterministic() {
|
||||
let seed = [42u8; 32];
|
||||
let a = MeshIdentity::from_seed(seed);
|
||||
let b = MeshIdentity::from_seed(seed);
|
||||
assert_eq!(a.public_key(), b.public_key());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn save_and_load_roundtrip() {
|
||||
let dir = tempfile::tempdir().expect("tmp dir");
|
||||
let path = dir.path().join("mesh_id.json");
|
||||
|
||||
let mut original = MeshIdentity::generate();
|
||||
original.add_peer(
|
||||
"deadbeef".into(),
|
||||
PeerInfo {
|
||||
public_key: vec![0xde, 0xad],
|
||||
last_seen: SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.expect("time")
|
||||
.as_secs(),
|
||||
addresses: vec!["127.0.0.1:4433".into()],
|
||||
},
|
||||
);
|
||||
original.save(&path).expect("save");
|
||||
|
||||
let loaded = MeshIdentity::load(&path).expect("load");
|
||||
assert_eq!(original.public_key(), loaded.public_key());
|
||||
assert_eq!(loaded.known_peers().len(), 1);
|
||||
assert!(loaded.known_peers().contains_key("deadbeef"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn add_and_query_peers() {
|
||||
let mut id = MeshIdentity::generate();
|
||||
assert!(id.known_peers().is_empty());
|
||||
|
||||
id.add_peer(
|
||||
"peer1".into(),
|
||||
PeerInfo {
|
||||
public_key: vec![1; 32],
|
||||
last_seen: 0,
|
||||
addresses: vec![],
|
||||
},
|
||||
);
|
||||
assert_eq!(id.known_peers().len(), 1);
|
||||
assert_eq!(id.known_peers()["peer1"].public_key, vec![1; 32]);
|
||||
}
|
||||
}
|
||||
421
crates/quicprochat-p2p/src/lib.rs
Normal file
421
crates/quicprochat-p2p/src/lib.rs
Normal file
@@ -0,0 +1,421 @@
|
||||
//! P2P transport layer for quicprochat using iroh.
|
||||
//!
|
||||
//! Provides direct peer-to-peer QUIC connections with NAT traversal via iroh
|
||||
//! relay servers. When both peers are online, messages bypass the central
|
||||
//! server entirely.
|
||||
//!
|
||||
//! # Architecture
|
||||
//!
|
||||
//! ```text
|
||||
//! Client A ── iroh direct (QUIC) ── Client B (preferred: low latency)
|
||||
//! │ │
|
||||
//! └── QUIC/TLS ── Server ── QUIC/TLS ┘ (fallback: store-and-forward)
|
||||
//! ```
|
||||
|
||||
pub mod broadcast;
|
||||
pub mod envelope;
|
||||
pub mod identity;
|
||||
pub mod routing;
|
||||
pub mod store;
|
||||
#[cfg(feature = "traffic-resistance")]
|
||||
pub mod traffic_resistance;
|
||||
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use iroh::{Endpoint, EndpointAddr, PublicKey, SecretKey};
|
||||
|
||||
use crate::broadcast::BroadcastManager;
|
||||
use crate::envelope::MeshEnvelope;
|
||||
use crate::identity::MeshIdentity;
|
||||
use crate::store::MeshStore;
|
||||
|
||||
/// ALPN protocol identifier for quicprochat P2P messaging.
|
||||
/// Updated from the original project name "quicnprotochat" to "quicprochat" (breaking wire change;
|
||||
/// all peers must be on the same version to connect).
|
||||
const P2P_ALPN: &[u8] = b"quicprochat/p2p/1";
|
||||
|
||||
/// A P2P node backed by an iroh endpoint.
|
||||
///
|
||||
/// Manages direct QUIC connections to peers with automatic NAT traversal.
|
||||
pub struct P2pNode {
|
||||
endpoint: Endpoint,
|
||||
/// Optional self-sovereign mesh identity for store-and-forward messaging.
|
||||
mesh_identity: Option<MeshIdentity>,
|
||||
/// Shared store-and-forward queue.
|
||||
mesh_store: Arc<Mutex<MeshStore>>,
|
||||
/// Broadcast channel manager for pub/sub mesh announcements.
|
||||
broadcast_mgr: Arc<Mutex<BroadcastManager>>,
|
||||
}
|
||||
|
||||
/// Received P2P message with sender information.
|
||||
pub struct P2pMessage {
|
||||
pub sender: PublicKey,
|
||||
pub payload: Vec<u8>,
|
||||
}
|
||||
|
||||
impl P2pNode {
|
||||
/// Start a new P2P node.
|
||||
///
|
||||
/// Generates a fresh identity or reuses a provided secret key.
|
||||
pub async fn start(secret_key: Option<SecretKey>) -> anyhow::Result<Self> {
|
||||
let mut builder = Endpoint::builder();
|
||||
if let Some(sk) = secret_key {
|
||||
builder = builder.secret_key(sk);
|
||||
}
|
||||
builder = builder.alpns(vec![P2P_ALPN.to_vec()]);
|
||||
|
||||
let endpoint = builder.bind().await?;
|
||||
|
||||
tracing::info!(
|
||||
node_id = %endpoint.id().fmt_short(),
|
||||
"P2P node started"
|
||||
);
|
||||
|
||||
Ok(Self {
|
||||
endpoint,
|
||||
mesh_identity: None,
|
||||
mesh_store: Arc::new(Mutex::new(MeshStore::new(0))),
|
||||
broadcast_mgr: Arc::new(Mutex::new(BroadcastManager::new())),
|
||||
})
|
||||
}
|
||||
|
||||
/// Start a new P2P node with a mesh identity and store-and-forward enabled.
|
||||
pub async fn start_with_mesh(
|
||||
secret_key: Option<SecretKey>,
|
||||
mesh_identity: MeshIdentity,
|
||||
max_stored: usize,
|
||||
) -> anyhow::Result<Self> {
|
||||
let mut node = Self::start(secret_key).await?;
|
||||
node.mesh_identity = Some(mesh_identity);
|
||||
node.mesh_store = Arc::new(Mutex::new(MeshStore::new(max_stored)));
|
||||
Ok(node)
|
||||
}
|
||||
|
||||
/// This node's public key (used as node ID for peer discovery).
|
||||
pub fn node_id(&self) -> PublicKey {
|
||||
self.endpoint.id()
|
||||
}
|
||||
|
||||
/// This node's secret key (for persistence across restarts).
|
||||
pub fn secret_key(&self) -> SecretKey {
|
||||
self.endpoint.secret_key().clone()
|
||||
}
|
||||
|
||||
/// Get the node's network address information for publishing to discovery.
|
||||
pub fn endpoint_addr(&self) -> EndpointAddr {
|
||||
self.endpoint.addr()
|
||||
}
|
||||
|
||||
/// Return a reference to the mesh identity, if set.
|
||||
pub fn mesh_identity(&self) -> Option<&MeshIdentity> {
|
||||
self.mesh_identity.as_ref()
|
||||
}
|
||||
|
||||
/// Return a clone of the shared mesh store handle.
|
||||
pub fn mesh_store(&self) -> Arc<Mutex<MeshStore>> {
|
||||
Arc::clone(&self.mesh_store)
|
||||
}
|
||||
|
||||
/// Send a payload directly to a peer via P2P QUIC.
|
||||
pub async fn send(&self, peer: impl Into<EndpointAddr>, payload: &[u8]) -> anyhow::Result<()> {
|
||||
let peer = peer.into();
|
||||
let conn = self.endpoint.connect(peer, P2P_ALPN).await?;
|
||||
|
||||
let mut send = conn.open_uni().await.map_err(|e| anyhow::anyhow!("{e}"))?;
|
||||
|
||||
// Simple framing: 4-byte length prefix + payload.
|
||||
let len = (payload.len() as u32).to_be_bytes();
|
||||
send.write_all(&len)
|
||||
.await
|
||||
.map_err(|e| anyhow::anyhow!("{e}"))?;
|
||||
send.write_all(payload)
|
||||
.await
|
||||
.map_err(|e| anyhow::anyhow!("{e}"))?;
|
||||
send.finish().map_err(|e| anyhow::anyhow!("{e}"))?;
|
||||
// Wait until the peer has consumed the stream before dropping.
|
||||
send.stopped().await.map_err(|e| anyhow::anyhow!("{e}"))?;
|
||||
|
||||
tracing::debug!(
|
||||
peer = %conn.remote_id().fmt_short(),
|
||||
bytes = payload.len(),
|
||||
"P2P message sent"
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Accept a single incoming P2P message.
|
||||
///
|
||||
/// Blocks until a peer connects and sends data.
|
||||
pub async fn recv(&self) -> anyhow::Result<P2pMessage> {
|
||||
let incoming = self
|
||||
.endpoint
|
||||
.accept()
|
||||
.await
|
||||
.ok_or_else(|| anyhow::anyhow!("no more incoming connections"))?;
|
||||
|
||||
let conn = incoming.await.map_err(|e| anyhow::anyhow!("{e}"))?;
|
||||
let sender = conn.remote_id();
|
||||
|
||||
let mut recv = conn
|
||||
.accept_uni()
|
||||
.await
|
||||
.map_err(|e| anyhow::anyhow!("{e}"))?;
|
||||
|
||||
// Read length-prefixed payload.
|
||||
let mut len_buf = [0u8; 4];
|
||||
recv.read_exact(&mut len_buf)
|
||||
.await
|
||||
.map_err(|e| anyhow::anyhow!("{e}"))?;
|
||||
let len = u32::from_be_bytes(len_buf) as usize;
|
||||
|
||||
if len > 5 * 1024 * 1024 {
|
||||
anyhow::bail!("P2P payload too large: {len} bytes");
|
||||
}
|
||||
|
||||
let mut payload = vec![0u8; len];
|
||||
recv.read_exact(&mut payload)
|
||||
.await
|
||||
.map_err(|e| anyhow::anyhow!("{e}"))?;
|
||||
|
||||
tracing::debug!(
|
||||
peer = %sender.fmt_short(),
|
||||
bytes = len,
|
||||
"P2P message received"
|
||||
);
|
||||
|
||||
Ok(P2pMessage { sender, payload })
|
||||
}
|
||||
|
||||
/// Create a [`MeshEnvelope`] and send it to a peer, or store it for later forwarding.
|
||||
///
|
||||
/// If `peer_addr` is `Some`, the envelope is sent immediately via P2P.
|
||||
/// Otherwise it is queued in the mesh store for future forwarding.
|
||||
pub async fn send_mesh(
|
||||
&self,
|
||||
peer_addr: Option<impl Into<EndpointAddr>>,
|
||||
recipient_key: &[u8],
|
||||
payload: Vec<u8>,
|
||||
ttl_secs: u32,
|
||||
) -> anyhow::Result<()> {
|
||||
let identity = self
|
||||
.mesh_identity
|
||||
.as_ref()
|
||||
.ok_or_else(|| anyhow::anyhow!("mesh identity not configured"))?;
|
||||
|
||||
let envelope = MeshEnvelope::new(identity, recipient_key, payload, ttl_secs, 0);
|
||||
let bytes = envelope.to_bytes();
|
||||
|
||||
if let Some(addr) = peer_addr {
|
||||
self.send(addr, &bytes).await?;
|
||||
tracing::debug!("mesh envelope sent directly");
|
||||
} else {
|
||||
let mut store = self
|
||||
.mesh_store
|
||||
.lock()
|
||||
.map_err(|e| anyhow::anyhow!("mesh store lock poisoned: {e}"))?;
|
||||
if !store.store(envelope) {
|
||||
anyhow::bail!("mesh store rejected envelope (duplicate or at capacity)");
|
||||
}
|
||||
tracing::debug!("mesh envelope queued for forwarding");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Fetch all stored mesh envelopes addressed to this node's identity.
|
||||
pub fn receive_mesh(&self) -> anyhow::Result<Vec<MeshEnvelope>> {
|
||||
let identity = self
|
||||
.mesh_identity
|
||||
.as_ref()
|
||||
.ok_or_else(|| anyhow::anyhow!("mesh identity not configured"))?;
|
||||
|
||||
let pk = identity.public_key();
|
||||
let mut store = self
|
||||
.mesh_store
|
||||
.lock()
|
||||
.map_err(|e| anyhow::anyhow!("mesh store lock poisoned: {e}"))?;
|
||||
Ok(store.fetch(&pk))
|
||||
}
|
||||
|
||||
/// Forward stored envelopes to a connected peer.
|
||||
///
|
||||
/// Sends all forwardable envelopes that match `recipient_key` to `peer_addr`.
|
||||
pub async fn forward_stored(
|
||||
&self,
|
||||
peer_addr: impl Into<EndpointAddr> + Clone,
|
||||
recipient_key: &[u8],
|
||||
) -> anyhow::Result<usize> {
|
||||
let envelopes = {
|
||||
let mut store = self
|
||||
.mesh_store
|
||||
.lock()
|
||||
.map_err(|e| anyhow::anyhow!("mesh store lock poisoned: {e}"))?;
|
||||
store.fetch(recipient_key)
|
||||
};
|
||||
|
||||
let mut forwarded = 0;
|
||||
for env in envelopes {
|
||||
if env.can_forward() {
|
||||
let fwd = env.forwarded();
|
||||
let bytes = fwd.to_bytes();
|
||||
self.send(peer_addr.clone(), &bytes).await?;
|
||||
forwarded += 1;
|
||||
}
|
||||
}
|
||||
|
||||
if forwarded > 0 {
|
||||
tracing::debug!(count = forwarded, "forwarded stored mesh envelopes");
|
||||
}
|
||||
|
||||
Ok(forwarded)
|
||||
}
|
||||
|
||||
/// Return a clone of the shared broadcast manager handle.
|
||||
pub fn broadcast_mgr(&self) -> Arc<Mutex<BroadcastManager>> {
|
||||
Arc::clone(&self.broadcast_mgr)
|
||||
}
|
||||
|
||||
/// Subscribe to a broadcast channel with a pre-shared key.
|
||||
pub fn subscribe(&self, topic: &str, key: [u8; 32]) -> anyhow::Result<()> {
|
||||
let mut mgr = self
|
||||
.broadcast_mgr
|
||||
.lock()
|
||||
.map_err(|e| anyhow::anyhow!("broadcast manager lock poisoned: {e}"))?;
|
||||
mgr.subscribe(topic, key);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Create a new broadcast channel with a random key. Returns the key for sharing.
|
||||
pub fn create_broadcast(&self, topic: &str) -> anyhow::Result<[u8; 32]> {
|
||||
let mut mgr = self
|
||||
.broadcast_mgr
|
||||
.lock()
|
||||
.map_err(|e| anyhow::anyhow!("broadcast manager lock poisoned: {e}"))?;
|
||||
let ch = mgr.create_channel(topic);
|
||||
Ok(*ch.key())
|
||||
}
|
||||
|
||||
/// Encrypt a payload on a broadcast topic and flood it to all connected peers
|
||||
/// as a MeshEnvelope with an empty recipient key (broadcast).
|
||||
pub async fn broadcast(
|
||||
&self,
|
||||
topic: &str,
|
||||
payload: &[u8],
|
||||
) -> anyhow::Result<()> {
|
||||
let identity = self
|
||||
.mesh_identity
|
||||
.as_ref()
|
||||
.ok_or_else(|| anyhow::anyhow!("mesh identity not configured"))?;
|
||||
|
||||
let encrypted = {
|
||||
let mgr = self
|
||||
.broadcast_mgr
|
||||
.lock()
|
||||
.map_err(|e| anyhow::anyhow!("broadcast manager lock poisoned: {e}"))?;
|
||||
mgr.encrypt(topic, payload)
|
||||
.ok_or_else(|| anyhow::anyhow!("not subscribed to topic: {topic}"))??
|
||||
};
|
||||
|
||||
// Create a broadcast envelope (empty recipient_key signals broadcast).
|
||||
let envelope = MeshEnvelope::new(identity, &[], encrypted, 300, 0);
|
||||
let bytes = envelope.to_bytes();
|
||||
|
||||
// Store in the mesh store for flood-forwarding.
|
||||
let mut store = self
|
||||
.mesh_store
|
||||
.lock()
|
||||
.map_err(|e| anyhow::anyhow!("mesh store lock poisoned: {e}"))?;
|
||||
if !store.store(envelope) {
|
||||
tracing::debug!("broadcast envelope dedup or at capacity, skipping store");
|
||||
}
|
||||
drop(store);
|
||||
|
||||
tracing::debug!(topic = topic, bytes = bytes.len(), "broadcast envelope queued");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// List all subscribed broadcast topics.
|
||||
pub fn topics(&self) -> anyhow::Result<Vec<String>> {
|
||||
let mgr = self
|
||||
.broadcast_mgr
|
||||
.lock()
|
||||
.map_err(|e| anyhow::anyhow!("broadcast manager lock poisoned: {e}"))?;
|
||||
Ok(mgr.topics())
|
||||
}
|
||||
|
||||
/// Gracefully shut down the P2P node.
|
||||
pub async fn close(self) {
|
||||
self.endpoint.close().await;
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use iroh::RelayMode;
|
||||
|
||||
/// Create a local-only P2P node with relays disabled (for testing).
|
||||
async fn local_node() -> P2pNode {
|
||||
let endpoint = Endpoint::builder()
|
||||
.alpns(vec![P2P_ALPN.to_vec()])
|
||||
.relay_mode(RelayMode::Disabled)
|
||||
.bind()
|
||||
.await
|
||||
.expect("bind local endpoint");
|
||||
P2pNode {
|
||||
endpoint,
|
||||
mesh_identity: None,
|
||||
mesh_store: Arc::new(Mutex::new(MeshStore::new(0))),
|
||||
broadcast_mgr: Arc::new(Mutex::new(BroadcastManager::new())),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn p2p_round_trip() {
|
||||
let sender = local_node().await;
|
||||
let receiver = local_node().await;
|
||||
|
||||
let receiver_addr = receiver.endpoint_addr();
|
||||
let sender_id = sender.node_id();
|
||||
let payload = b"hello via P2P";
|
||||
|
||||
let recv_handle = tokio::spawn(async move {
|
||||
let msg = receiver.recv().await.expect("receive message");
|
||||
assert_eq!(msg.payload, payload.to_vec());
|
||||
assert_eq!(msg.sender, sender_id);
|
||||
});
|
||||
|
||||
tokio::time::sleep(std::time::Duration::from_millis(200)).await;
|
||||
|
||||
sender.send(receiver_addr, payload).await.expect("send message");
|
||||
|
||||
recv_handle.await.expect("recv task");
|
||||
|
||||
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
|
||||
sender.close().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn mesh_store_and_receive() {
|
||||
let id = MeshIdentity::generate();
|
||||
let pk = id.public_key();
|
||||
|
||||
let node = P2pNode::start_with_mesh(None, id, 100)
|
||||
.await
|
||||
.expect("start mesh node");
|
||||
|
||||
// Queue a message for ourselves via the store.
|
||||
{
|
||||
let sender_id = MeshIdentity::generate();
|
||||
let env = MeshEnvelope::new(&sender_id, &pk, b"stored msg".to_vec(), 3600, 5);
|
||||
let mut store = node.mesh_store.lock().expect("lock");
|
||||
assert!(store.store(env));
|
||||
}
|
||||
|
||||
let msgs = node.receive_mesh().expect("receive_mesh");
|
||||
assert_eq!(msgs.len(), 1);
|
||||
assert_eq!(msgs[0].payload, b"stored msg");
|
||||
|
||||
node.close().await;
|
||||
}
|
||||
}
|
||||
425
crates/quicprochat-p2p/src/routing.rs
Normal file
425
crates/quicprochat-p2p/src/routing.rs
Normal file
@@ -0,0 +1,425 @@
|
||||
//! Hybrid routing: direct P2P first, server relay fallback.
|
||||
//!
|
||||
//! The [`HybridRouter`] attempts to deliver messages directly to peers via
|
||||
//! iroh QUIC connections (with automatic NAT traversal). If direct delivery
|
||||
//! fails or the peer is unreachable, messages are relayed through the
|
||||
//! central server as a fallback.
|
||||
//!
|
||||
//! # Routing strategy
|
||||
//!
|
||||
//! ```text
|
||||
//! send_message(peer, payload)
|
||||
//! ├─ try direct P2P via iroh (QUIC + relay hole-punching)
|
||||
//! │ ├─ success → record DirectSuccess metric
|
||||
//! │ └─ failure → fall through
|
||||
//! └─ relay through server
|
||||
//! ├─ success → record RelayFallback metric
|
||||
//! └─ failure → return error
|
||||
//! ```
|
||||
//!
|
||||
//! # Connection quality
|
||||
//!
|
||||
//! Per-peer [`ConnectionStats`] are tracked, recording direct vs relayed
|
||||
//! delivery counts and latency measurements. The router exposes these
|
||||
//! stats for UI display and adaptive routing decisions.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use iroh::{EndpointAddr, PublicKey};
|
||||
|
||||
use crate::P2pNode;
|
||||
|
||||
/// Callback trait for relaying messages through the server when direct P2P fails.
|
||||
///
|
||||
/// Implementations typically call the SDK's enqueue RPC to relay through
|
||||
/// the server's store-and-forward path.
|
||||
pub trait ServerRelay: Send + Sync {
|
||||
/// Relay a message to `recipient` through the server.
|
||||
///
|
||||
/// Returns `Ok(())` on successful relay, or an error if the server
|
||||
/// is unreachable or rejects the message.
|
||||
fn relay(
|
||||
&self,
|
||||
recipient: &[u8],
|
||||
payload: &[u8],
|
||||
) -> std::pin::Pin<Box<dyn std::future::Future<Output = anyhow::Result<()>> + Send + '_>>;
|
||||
}
|
||||
|
||||
/// How a message was delivered.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum DeliveryMethod {
|
||||
/// Delivered directly via P2P QUIC connection.
|
||||
Direct,
|
||||
/// Relayed through the central server.
|
||||
Relayed,
|
||||
}
|
||||
|
||||
/// Per-peer connection quality statistics.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ConnectionStats {
|
||||
/// Number of messages delivered directly via P2P.
|
||||
pub direct_count: u64,
|
||||
/// Number of messages relayed through the server.
|
||||
pub relayed_count: u64,
|
||||
/// Average direct delivery latency (if any direct deliveries occurred).
|
||||
pub avg_direct_latency: Option<Duration>,
|
||||
/// Average relay delivery latency (if any relay deliveries occurred).
|
||||
pub avg_relay_latency: Option<Duration>,
|
||||
/// Last successful direct delivery time.
|
||||
pub last_direct: Option<Instant>,
|
||||
/// Last successful relay delivery time.
|
||||
pub last_relay: Option<Instant>,
|
||||
}
|
||||
|
||||
impl ConnectionStats {
|
||||
fn new() -> Self {
|
||||
Self {
|
||||
direct_count: 0,
|
||||
relayed_count: 0,
|
||||
avg_direct_latency: None,
|
||||
avg_relay_latency: None,
|
||||
last_direct: None,
|
||||
last_relay: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn record_direct(&mut self, latency: Duration) {
|
||||
self.direct_count += 1;
|
||||
self.last_direct = Some(Instant::now());
|
||||
self.avg_direct_latency = Some(match self.avg_direct_latency {
|
||||
Some(prev) => {
|
||||
let n = self.direct_count as u32;
|
||||
// Exponential moving average with 1/n weight for first few, then ~1/8.
|
||||
let alpha = if n < 8 { n } else { 8 };
|
||||
(prev * (alpha - 1) + latency) / alpha
|
||||
}
|
||||
None => latency,
|
||||
});
|
||||
}
|
||||
|
||||
fn record_relay(&mut self, latency: Duration) {
|
||||
self.relayed_count += 1;
|
||||
self.last_relay = Some(Instant::now());
|
||||
self.avg_relay_latency = Some(match self.avg_relay_latency {
|
||||
Some(prev) => {
|
||||
let n = self.relayed_count as u32;
|
||||
let alpha = if n < 8 { n } else { 8 };
|
||||
(prev * (alpha - 1) + latency) / alpha
|
||||
}
|
||||
None => latency,
|
||||
});
|
||||
}
|
||||
|
||||
/// Fraction of messages delivered directly (0.0 to 1.0).
|
||||
pub fn direct_ratio(&self) -> f64 {
|
||||
let total = self.direct_count + self.relayed_count;
|
||||
if total == 0 {
|
||||
return 0.0;
|
||||
}
|
||||
self.direct_count as f64 / total as f64
|
||||
}
|
||||
}
|
||||
|
||||
/// Known peer address information for routing decisions.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct PeerRoute {
|
||||
/// iroh endpoint address for direct P2P delivery.
|
||||
pub endpoint_addr: EndpointAddr,
|
||||
/// Recipient identity key (for server relay addressing).
|
||||
pub identity_key: Vec<u8>,
|
||||
}
|
||||
|
||||
/// Hybrid message router: tries direct P2P, falls back to server relay.
|
||||
pub struct HybridRouter {
|
||||
node: Arc<P2pNode>,
|
||||
relay: Arc<dyn ServerRelay>,
|
||||
/// Known peer routes, keyed by iroh PublicKey.
|
||||
peers: Mutex<HashMap<PublicKey, PeerRoute>>,
|
||||
/// Per-peer connection quality statistics.
|
||||
stats: Mutex<HashMap<PublicKey, ConnectionStats>>,
|
||||
/// Timeout for direct P2P delivery attempts before falling back.
|
||||
direct_timeout: Duration,
|
||||
}
|
||||
|
||||
impl HybridRouter {
|
||||
/// Create a new hybrid router.
|
||||
///
|
||||
/// `direct_timeout` controls how long to wait for a direct P2P delivery
|
||||
/// before falling back to the server relay.
|
||||
pub fn new(
|
||||
node: Arc<P2pNode>,
|
||||
relay: Arc<dyn ServerRelay>,
|
||||
direct_timeout: Duration,
|
||||
) -> Self {
|
||||
Self {
|
||||
node,
|
||||
relay,
|
||||
peers: Mutex::new(HashMap::new()),
|
||||
stats: Mutex::new(HashMap::new()),
|
||||
direct_timeout,
|
||||
}
|
||||
}
|
||||
|
||||
/// Register a peer's routing information.
|
||||
pub fn add_peer(&self, peer_id: PublicKey, route: PeerRoute) {
|
||||
if let Ok(mut peers) = self.peers.lock() {
|
||||
peers.insert(peer_id, route);
|
||||
}
|
||||
}
|
||||
|
||||
/// Remove a peer's routing information.
|
||||
pub fn remove_peer(&self, peer_id: &PublicKey) {
|
||||
if let Ok(mut peers) = self.peers.lock() {
|
||||
peers.remove(peer_id);
|
||||
}
|
||||
}
|
||||
|
||||
/// Send a message using hybrid routing: direct P2P first, server relay fallback.
|
||||
///
|
||||
/// Returns the delivery method used on success.
|
||||
pub async fn send(
|
||||
&self,
|
||||
peer_id: &PublicKey,
|
||||
payload: &[u8],
|
||||
) -> anyhow::Result<DeliveryMethod> {
|
||||
let route = {
|
||||
let peers = self
|
||||
.peers
|
||||
.lock()
|
||||
.map_err(|e| anyhow::anyhow!("peers lock: {e}"))?;
|
||||
peers.get(peer_id).cloned()
|
||||
};
|
||||
|
||||
let route = route
|
||||
.ok_or_else(|| anyhow::anyhow!("no route known for peer {}", peer_id.fmt_short()))?;
|
||||
|
||||
// Try direct P2P first.
|
||||
let start = Instant::now();
|
||||
let direct_result = tokio::time::timeout(
|
||||
self.direct_timeout,
|
||||
self.node.send(route.endpoint_addr.clone(), payload),
|
||||
)
|
||||
.await;
|
||||
|
||||
match direct_result {
|
||||
Ok(Ok(())) => {
|
||||
let latency = start.elapsed();
|
||||
tracing::debug!(
|
||||
peer = %peer_id.fmt_short(),
|
||||
latency_ms = latency.as_millis(),
|
||||
"direct P2P delivery succeeded"
|
||||
);
|
||||
if let Ok(mut stats) = self.stats.lock() {
|
||||
stats
|
||||
.entry(*peer_id)
|
||||
.or_insert_with(ConnectionStats::new)
|
||||
.record_direct(latency);
|
||||
}
|
||||
return Ok(DeliveryMethod::Direct);
|
||||
}
|
||||
Ok(Err(e)) => {
|
||||
tracing::debug!(
|
||||
peer = %peer_id.fmt_short(),
|
||||
error = %e,
|
||||
"direct P2P delivery failed, falling back to relay"
|
||||
);
|
||||
}
|
||||
Err(_) => {
|
||||
tracing::debug!(
|
||||
peer = %peer_id.fmt_short(),
|
||||
timeout_ms = self.direct_timeout.as_millis(),
|
||||
"direct P2P delivery timed out, falling back to relay"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Fall back to server relay.
|
||||
let start = Instant::now();
|
||||
self.relay.relay(&route.identity_key, payload).await?;
|
||||
let latency = start.elapsed();
|
||||
|
||||
tracing::debug!(
|
||||
peer = %peer_id.fmt_short(),
|
||||
latency_ms = latency.as_millis(),
|
||||
"server relay delivery succeeded"
|
||||
);
|
||||
|
||||
if let Ok(mut stats) = self.stats.lock() {
|
||||
stats
|
||||
.entry(*peer_id)
|
||||
.or_insert_with(ConnectionStats::new)
|
||||
.record_relay(latency);
|
||||
}
|
||||
|
||||
Ok(DeliveryMethod::Relayed)
|
||||
}
|
||||
|
||||
/// Get connection statistics for a specific peer.
|
||||
pub fn peer_stats(&self, peer_id: &PublicKey) -> Option<ConnectionStats> {
|
||||
self.stats
|
||||
.lock()
|
||||
.ok()
|
||||
.and_then(|s| s.get(peer_id).cloned())
|
||||
}
|
||||
|
||||
/// Get connection statistics for all known peers.
|
||||
pub fn all_stats(&self) -> HashMap<PublicKey, ConnectionStats> {
|
||||
self.stats
|
||||
.lock()
|
||||
.map(|s| s.clone())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Get the aggregate direct delivery ratio across all peers.
|
||||
pub fn overall_direct_ratio(&self) -> f64 {
|
||||
let stats = match self.stats.lock() {
|
||||
Ok(s) => s,
|
||||
Err(_) => return 0.0,
|
||||
};
|
||||
let (direct, relayed) = stats
|
||||
.values()
|
||||
.fold((0u64, 0u64), |(d, r), s| (d + s.direct_count, r + s.relayed_count));
|
||||
let total = direct + relayed;
|
||||
if total == 0 {
|
||||
return 0.0;
|
||||
}
|
||||
direct as f64 / total as f64
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// A mock server relay that records calls and succeeds.
|
||||
struct MockRelay {
|
||||
calls: Mutex<Vec<(Vec<u8>, Vec<u8>)>>,
|
||||
}
|
||||
|
||||
impl MockRelay {
|
||||
fn new() -> Self {
|
||||
Self {
|
||||
calls: Mutex::new(Vec::new()),
|
||||
}
|
||||
}
|
||||
|
||||
fn call_count(&self) -> usize {
|
||||
self.calls.lock().map(|c| c.len()).unwrap_or(0)
|
||||
}
|
||||
}
|
||||
|
||||
impl ServerRelay for MockRelay {
|
||||
fn relay(
|
||||
&self,
|
||||
recipient: &[u8],
|
||||
payload: &[u8],
|
||||
) -> std::pin::Pin<Box<dyn std::future::Future<Output = anyhow::Result<()>> + Send + '_>>
|
||||
{
|
||||
if let Ok(mut calls) = self.calls.lock() {
|
||||
calls.push((recipient.to_vec(), payload.to_vec()));
|
||||
}
|
||||
Box::pin(async { Ok(()) })
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn connection_stats_direct_ratio() {
|
||||
let mut stats = ConnectionStats::new();
|
||||
assert_eq!(stats.direct_ratio(), 0.0);
|
||||
|
||||
stats.record_direct(Duration::from_millis(10));
|
||||
assert_eq!(stats.direct_ratio(), 1.0);
|
||||
|
||||
stats.record_relay(Duration::from_millis(50));
|
||||
assert_eq!(stats.direct_ratio(), 0.5);
|
||||
|
||||
stats.record_direct(Duration::from_millis(15));
|
||||
// 2 direct, 1 relayed = 2/3
|
||||
assert!((stats.direct_ratio() - 2.0 / 3.0).abs() < 0.001);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn connection_stats_latency_tracking() {
|
||||
let mut stats = ConnectionStats::new();
|
||||
stats.record_direct(Duration::from_millis(10));
|
||||
assert!(stats.avg_direct_latency.is_some());
|
||||
assert!(stats.last_direct.is_some());
|
||||
assert!(stats.avg_relay_latency.is_none());
|
||||
|
||||
stats.record_relay(Duration::from_millis(100));
|
||||
assert!(stats.avg_relay_latency.is_some());
|
||||
assert!(stats.last_relay.is_some());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn hybrid_router_falls_back_to_relay() {
|
||||
// Create a node with relay disabled — direct sends to a bogus address will fail.
|
||||
let node = P2pNode::start(None).await.expect("start node");
|
||||
let node = Arc::new(node);
|
||||
let relay = Arc::new(MockRelay::new());
|
||||
|
||||
let router = HybridRouter::new(
|
||||
Arc::clone(&node),
|
||||
Arc::clone(&relay) as Arc<dyn ServerRelay>,
|
||||
Duration::from_millis(500),
|
||||
);
|
||||
|
||||
// Register a peer with a bogus address that will fail direct delivery.
|
||||
let peer_key = iroh::SecretKey::from_bytes(&rand::random());
|
||||
let peer_id = peer_key.public();
|
||||
let route = PeerRoute {
|
||||
endpoint_addr: EndpointAddr::from(peer_id),
|
||||
identity_key: vec![0xAA; 32],
|
||||
};
|
||||
router.add_peer(peer_id, route);
|
||||
|
||||
// Send — should fall back to relay.
|
||||
let method = router
|
||||
.send(&peer_id, b"hello hybrid")
|
||||
.await
|
||||
.expect("send should succeed via relay");
|
||||
assert_eq!(method, DeliveryMethod::Relayed);
|
||||
assert_eq!(relay.call_count(), 1);
|
||||
|
||||
// Stats should reflect one relayed delivery.
|
||||
let stats = router.peer_stats(&peer_id).expect("stats should exist");
|
||||
assert_eq!(stats.relayed_count, 1);
|
||||
assert_eq!(stats.direct_count, 0);
|
||||
assert_eq!(stats.direct_ratio(), 0.0);
|
||||
|
||||
drop(router);
|
||||
Arc::try_unwrap(node).ok().expect("sole owner").close().await;
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn add_remove_peer() {
|
||||
let rt = tokio::runtime::Builder::new_current_thread()
|
||||
.enable_all()
|
||||
.build()
|
||||
.expect("runtime");
|
||||
|
||||
rt.block_on(async {
|
||||
let node = Arc::new(P2pNode::start(None).await.expect("start"));
|
||||
let relay = Arc::new(MockRelay::new());
|
||||
let router = HybridRouter::new(node.clone(), relay, Duration::from_secs(1));
|
||||
|
||||
let sk = iroh::SecretKey::from_bytes(&rand::random());
|
||||
let pk = sk.public();
|
||||
let route = PeerRoute {
|
||||
endpoint_addr: EndpointAddr::from(pk),
|
||||
identity_key: vec![0xBB; 32],
|
||||
};
|
||||
|
||||
router.add_peer(pk, route);
|
||||
assert!(router.peers.lock().unwrap().contains_key(&pk));
|
||||
|
||||
router.remove_peer(&pk);
|
||||
assert!(!router.peers.lock().unwrap().contains_key(&pk));
|
||||
|
||||
drop(router);
|
||||
Arc::try_unwrap(node).ok().expect("sole owner").close().await;
|
||||
});
|
||||
}
|
||||
}
|
||||
218
crates/quicprochat-p2p/src/store.rs
Normal file
218
crates/quicprochat-p2p/src/store.rs
Normal file
@@ -0,0 +1,218 @@
|
||||
//! In-memory store-and-forward message queue for mesh nodes.
|
||||
//!
|
||||
//! [`MeshStore`] buffers [`MeshEnvelope`]s for offline recipients and
|
||||
//! provides deduplication and automatic garbage collection of expired messages.
|
||||
|
||||
use std::collections::{HashMap, HashSet, VecDeque};
|
||||
|
||||
use crate::envelope::MeshEnvelope;
|
||||
|
||||
/// Default maximum messages stored per recipient.
|
||||
const DEFAULT_MAX_STORED: usize = 1000;
|
||||
|
||||
/// Maximum number of envelope IDs retained in the seen set for deduplication.
|
||||
/// Once exceeded, the oldest IDs are evicted to bound memory growth.
|
||||
const MAX_SEEN_IDS: usize = 100_000;
|
||||
|
||||
/// In-memory store-and-forward queue keyed by recipient public key.
|
||||
pub struct MeshStore {
|
||||
/// Recipient public key -> queued envelopes.
|
||||
inbox: HashMap<Vec<u8>, Vec<MeshEnvelope>>,
|
||||
/// Set of envelope IDs already processed (deduplication).
|
||||
seen: HashSet<[u8; 32]>,
|
||||
/// Insertion-ordered queue of seen IDs for bounded eviction.
|
||||
seen_order: VecDeque<[u8; 32]>,
|
||||
/// Maximum envelopes held per recipient.
|
||||
max_stored: usize,
|
||||
}
|
||||
|
||||
impl MeshStore {
|
||||
/// Create a new store with the given per-recipient capacity.
|
||||
///
|
||||
/// A `max_stored` of 0 uses [`DEFAULT_MAX_STORED`].
|
||||
pub fn new(max_stored: usize) -> Self {
|
||||
Self {
|
||||
inbox: HashMap::new(),
|
||||
seen: HashSet::new(),
|
||||
seen_order: VecDeque::new(),
|
||||
max_stored: if max_stored == 0 {
|
||||
DEFAULT_MAX_STORED
|
||||
} else {
|
||||
max_stored
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// Store an envelope for later delivery.
|
||||
///
|
||||
/// Returns `false` (without storing) if:
|
||||
/// - the envelope ID has already been seen (dedup), or
|
||||
/// - the recipient's inbox is at capacity.
|
||||
pub fn store(&mut self, envelope: MeshEnvelope) -> bool {
|
||||
if self.seen.contains(&envelope.id) {
|
||||
return false;
|
||||
}
|
||||
let queue = self.inbox.entry(envelope.recipient_key.clone()).or_default();
|
||||
if queue.len() >= self.max_stored {
|
||||
return false;
|
||||
}
|
||||
self.seen.insert(envelope.id);
|
||||
self.seen_order.push_back(envelope.id);
|
||||
|
||||
// Evict oldest seen IDs if the set exceeds the bound.
|
||||
while self.seen_order.len() > MAX_SEEN_IDS {
|
||||
if let Some(old_id) = self.seen_order.pop_front() {
|
||||
self.seen.remove(&old_id);
|
||||
}
|
||||
}
|
||||
|
||||
queue.push(envelope);
|
||||
true
|
||||
}
|
||||
|
||||
/// Drain and return all queued messages for `recipient_key`.
|
||||
pub fn fetch(&mut self, recipient_key: &[u8]) -> Vec<MeshEnvelope> {
|
||||
self.inbox.remove(recipient_key).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Peek at queued messages for `recipient_key` without draining.
|
||||
pub fn peek(&self, recipient_key: &[u8]) -> &[MeshEnvelope] {
|
||||
self.inbox
|
||||
.get(recipient_key)
|
||||
.map(|v| v.as_slice())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Remove all expired envelopes from every inbox and return the count removed.
|
||||
pub fn gc_expired(&mut self) -> usize {
|
||||
let mut removed = 0;
|
||||
self.inbox.retain(|_key, queue| {
|
||||
let before = queue.len();
|
||||
queue.retain(|env| !env.is_expired());
|
||||
removed += before - queue.len();
|
||||
!queue.is_empty()
|
||||
});
|
||||
removed
|
||||
}
|
||||
|
||||
/// Check whether an envelope ID has already been processed.
|
||||
pub fn seen(&self, id: &[u8; 32]) -> bool {
|
||||
self.seen.contains(id)
|
||||
}
|
||||
|
||||
/// Return `(total_messages, unique_recipients)`.
|
||||
pub fn stats(&self) -> (usize, usize) {
|
||||
let total: usize = self.inbox.values().map(|q| q.len()).sum();
|
||||
let recipients = self.inbox.len();
|
||||
(total, recipients)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::identity::MeshIdentity;
|
||||
|
||||
fn make_envelope(recipient: &[u8], payload: &[u8], ttl: u32) -> MeshEnvelope {
|
||||
let id = MeshIdentity::generate();
|
||||
MeshEnvelope::new(&id, recipient, payload.to_vec(), ttl, 5)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn store_and_fetch() {
|
||||
let mut store = MeshStore::new(10);
|
||||
let recip = [0xAAu8; 32];
|
||||
let env = make_envelope(&recip, b"hello", 3600);
|
||||
|
||||
assert!(store.store(env));
|
||||
assert_eq!(store.stats(), (1, 1));
|
||||
|
||||
let msgs = store.fetch(&recip);
|
||||
assert_eq!(msgs.len(), 1);
|
||||
assert_eq!(msgs[0].payload, b"hello");
|
||||
|
||||
// After fetch, inbox is drained.
|
||||
assert_eq!(store.stats(), (0, 0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deduplication() {
|
||||
let mut store = MeshStore::new(10);
|
||||
let recip = [0xBBu8; 32];
|
||||
let env = make_envelope(&recip, b"dup", 3600);
|
||||
let env2 = env.clone();
|
||||
|
||||
assert!(store.store(env));
|
||||
assert!(!store.store(env2), "duplicate should be rejected");
|
||||
assert_eq!(store.stats(), (1, 1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn capacity_limit() {
|
||||
let mut store = MeshStore::new(2);
|
||||
let recip = [0xCCu8; 32];
|
||||
|
||||
assert!(store.store(make_envelope(&recip, b"1", 3600)));
|
||||
assert!(store.store(make_envelope(&recip, b"2", 3600)));
|
||||
assert!(
|
||||
!store.store(make_envelope(&recip, b"3", 3600)),
|
||||
"should reject when at capacity"
|
||||
);
|
||||
assert_eq!(store.stats(), (2, 1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gc_expired_messages() {
|
||||
let mut store = MeshStore::new(10);
|
||||
let recip = [0xDDu8; 32];
|
||||
|
||||
// Create an already-expired envelope (TTL=0, timestamp in the past).
|
||||
let id = MeshIdentity::generate();
|
||||
let mut env = MeshEnvelope::new(&id, &recip, b"old".to_vec(), 0, 5);
|
||||
env.timestamp = 0; // far in the past
|
||||
store.store(env);
|
||||
|
||||
// And a fresh one.
|
||||
store.store(make_envelope(&recip, b"fresh", 3600));
|
||||
|
||||
assert_eq!(store.stats(), (2, 1));
|
||||
let removed = store.gc_expired();
|
||||
assert_eq!(removed, 1);
|
||||
assert_eq!(store.stats(), (1, 1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn peek_does_not_drain() {
|
||||
let mut store = MeshStore::new(10);
|
||||
let recip = [0xEEu8; 32];
|
||||
store.store(make_envelope(&recip, b"peek", 3600));
|
||||
|
||||
assert_eq!(store.peek(&recip).len(), 1);
|
||||
assert_eq!(store.peek(&recip).len(), 1); // still there
|
||||
assert_eq!(store.stats(), (1, 1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn seen_tracks_processed_ids() {
|
||||
let mut store = MeshStore::new(10);
|
||||
let env = make_envelope(&[0xFF; 32], b"track", 3600);
|
||||
let id = env.id;
|
||||
|
||||
assert!(!store.seen(&id));
|
||||
store.store(env);
|
||||
assert!(store.seen(&id));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fetch_empty_inbox() {
|
||||
let mut store = MeshStore::new(10);
|
||||
let msgs = store.fetch(&[0x00; 32]);
|
||||
assert!(msgs.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn peek_empty_inbox() {
|
||||
let store = MeshStore::new(10);
|
||||
assert!(store.peek(&[0x00; 32]).is_empty());
|
||||
}
|
||||
}
|
||||
204
crates/quicprochat-p2p/src/traffic_resistance.rs
Normal file
204
crates/quicprochat-p2p/src/traffic_resistance.rs
Normal file
@@ -0,0 +1,204 @@
|
||||
//! Mesh traffic analysis resistance — uniform envelope padding and decoy injection.
|
||||
//!
|
||||
//! When the `traffic-resistance` feature is enabled:
|
||||
//!
|
||||
//! 1. [`MeshEnvelope`] payloads are padded to a configurable boundary (default 256 bytes)
|
||||
//! before signing, so all envelopes on the wire have uniform-sized payloads.
|
||||
//! 2. A background decoy injector periodically creates fake mesh envelopes and stores
|
||||
//! them in the mesh store, making real vs. decoy traffic indistinguishable.
|
||||
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use rand::Rng;
|
||||
use tokio::sync::Notify;
|
||||
|
||||
use crate::envelope::MeshEnvelope;
|
||||
use crate::identity::MeshIdentity;
|
||||
use crate::store::MeshStore;
|
||||
|
||||
/// Configuration for mesh traffic analysis resistance.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct MeshTrafficConfig {
|
||||
/// Padding boundary in bytes for envelope payloads.
|
||||
pub padding_boundary: usize,
|
||||
|
||||
/// Mean interval in milliseconds between decoy envelope injections.
|
||||
/// Set to 0 to disable.
|
||||
pub decoy_interval_ms: u64,
|
||||
}
|
||||
|
||||
impl Default for MeshTrafficConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
padding_boundary: quicprochat_core::padding::DEFAULT_PADDING_BOUNDARY,
|
||||
decoy_interval_ms: 5000,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Pad a mesh payload to the nearest boundary before wrapping in a [`MeshEnvelope`].
|
||||
pub fn pad_mesh_payload(payload: &[u8], boundary: usize) -> Vec<u8> {
|
||||
quicprochat_core::padding::pad_uniform(payload, boundary)
|
||||
}
|
||||
|
||||
/// Create a [`MeshEnvelope`] with a uniformly padded payload.
|
||||
pub fn padded_envelope(
|
||||
identity: &MeshIdentity,
|
||||
recipient_key: &[u8],
|
||||
payload: Vec<u8>,
|
||||
ttl_secs: u32,
|
||||
max_hops: u8,
|
||||
boundary: usize,
|
||||
) -> MeshEnvelope {
|
||||
let padded = pad_mesh_payload(&payload, boundary);
|
||||
MeshEnvelope::new(identity, recipient_key, padded, ttl_secs, max_hops)
|
||||
}
|
||||
|
||||
/// Spawn a background task that injects decoy mesh envelopes into the store.
|
||||
///
|
||||
/// Decoy envelopes have random recipient keys and empty (padded) payloads.
|
||||
/// They are indistinguishable from real padded envelopes on the wire.
|
||||
pub fn spawn_mesh_decoy_generator(
|
||||
identity: MeshIdentity,
|
||||
store: Arc<Mutex<MeshStore>>,
|
||||
config: MeshTrafficConfig,
|
||||
shutdown: Arc<Notify>,
|
||||
) -> tokio::task::JoinHandle<()> {
|
||||
tokio::spawn(async move {
|
||||
if config.decoy_interval_ms == 0 {
|
||||
shutdown.notified().await;
|
||||
return;
|
||||
}
|
||||
|
||||
let base_interval = std::time::Duration::from_millis(config.decoy_interval_ms);
|
||||
|
||||
loop {
|
||||
let jitter_factor: f64 = rand::thread_rng().gen_range(0.5..1.5);
|
||||
let interval = base_interval.mul_f64(jitter_factor);
|
||||
|
||||
tokio::select! {
|
||||
() = tokio::time::sleep(interval) => {}
|
||||
() = shutdown.notified() => {
|
||||
tracing::debug!("mesh decoy generator shutting down");
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Generate a decoy: padded empty payload with a random recipient.
|
||||
let decoy_payload = quicprochat_core::padding::generate_decoy(config.padding_boundary);
|
||||
let mut fake_recipient = [0u8; 32];
|
||||
rand::thread_rng().fill(&mut fake_recipient);
|
||||
|
||||
let envelope = MeshEnvelope::new(
|
||||
&identity,
|
||||
&fake_recipient,
|
||||
decoy_payload,
|
||||
60, // Short TTL.
|
||||
0,
|
||||
);
|
||||
|
||||
match store.lock() {
|
||||
Ok(mut s) => {
|
||||
let _ = s.store(envelope);
|
||||
tracing::trace!("mesh decoy envelope injected");
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!(error = %e, "mesh store lock poisoned in decoy generator");
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[allow(clippy::unwrap_used)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn pad_mesh_payload_boundary_aligned() {
|
||||
let payload = b"hello mesh";
|
||||
let padded = pad_mesh_payload(payload, 256);
|
||||
assert_eq!(padded.len() % 256, 0);
|
||||
|
||||
let unpadded = quicprochat_core::padding::unpad_uniform(&padded).unwrap();
|
||||
assert_eq!(unpadded, payload);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn padded_envelope_has_uniform_payload() {
|
||||
let id = MeshIdentity::generate();
|
||||
let recipient = [0xAA; 32];
|
||||
let payload = b"short".to_vec();
|
||||
|
||||
let env = padded_envelope(&id, &recipient, payload, 3600, 5, 256);
|
||||
assert_eq!(env.payload.len() % 256, 0);
|
||||
assert!(env.verify());
|
||||
|
||||
// The inner payload should unpad correctly.
|
||||
let unpadded = quicprochat_core::padding::unpad_uniform(&env.payload).unwrap();
|
||||
assert_eq!(unpadded, b"short");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn padded_envelope_large_payload() {
|
||||
let id = MeshIdentity::generate();
|
||||
let payload = vec![0xBB; 500];
|
||||
|
||||
let env = padded_envelope(&id, &[0xCC; 32], payload.clone(), 60, 3, 256);
|
||||
assert_eq!(env.payload.len() % 256, 0);
|
||||
assert_eq!(env.payload.len(), 512); // 500 + 4 = 504, rounds to 512
|
||||
|
||||
let unpadded = quicprochat_core::padding::unpad_uniform(&env.payload).unwrap();
|
||||
assert_eq!(unpadded, payload);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn mesh_decoy_generator_injects_envelopes() {
|
||||
let id = MeshIdentity::generate();
|
||||
let store = Arc::new(Mutex::new(MeshStore::new(100)));
|
||||
let shutdown = Arc::new(Notify::new());
|
||||
|
||||
let config = MeshTrafficConfig {
|
||||
padding_boundary: 256,
|
||||
decoy_interval_ms: 50,
|
||||
};
|
||||
|
||||
let handle = spawn_mesh_decoy_generator(
|
||||
id,
|
||||
Arc::clone(&store),
|
||||
config,
|
||||
Arc::clone(&shutdown),
|
||||
);
|
||||
|
||||
tokio::time::sleep(std::time::Duration::from_millis(200)).await;
|
||||
shutdown.notify_one();
|
||||
handle.await.unwrap();
|
||||
|
||||
let s = store.lock().unwrap();
|
||||
let (total, _) = s.stats();
|
||||
assert!(total > 0, "decoy generator should have stored at least one envelope");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn mesh_decoy_generator_disabled_when_zero() {
|
||||
let id = MeshIdentity::generate();
|
||||
let store = Arc::new(Mutex::new(MeshStore::new(100)));
|
||||
let shutdown = Arc::new(Notify::new());
|
||||
|
||||
let config = MeshTrafficConfig {
|
||||
padding_boundary: 256,
|
||||
decoy_interval_ms: 0,
|
||||
};
|
||||
|
||||
let handle = spawn_mesh_decoy_generator(
|
||||
id,
|
||||
store,
|
||||
config,
|
||||
Arc::clone(&shutdown),
|
||||
);
|
||||
|
||||
shutdown.notify_one();
|
||||
handle.await.unwrap();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user