diff --git a/crates/quicproquo-core/src/padding.rs b/crates/quicproquo-core/src/padding.rs index 3f56599..1919e3b 100644 --- a/crates/quicproquo-core/src/padding.rs +++ b/crates/quicproquo-core/src/padding.rs @@ -11,11 +11,20 @@ //! //! The total padded output is always one of the bucket sizes: 256, 1024, 4096, 16384 bytes. //! For payloads larger than 16380 bytes, rounds up to the nearest 16384-byte multiple. +//! +//! ## Uniform boundary padding (traffic analysis resistance) +//! +//! [`pad_uniform`] / [`unpad_uniform`] pad to a configurable byte boundary +//! (default 256) instead of exponential buckets. This produces more uniform +//! ciphertext sizes at the cost of slightly more padding overhead. use rand::RngCore; use crate::error::CoreError; +/// Default uniform padding boundary in bytes. +pub const DEFAULT_PADDING_BOUNDARY: usize = 256; + /// Bucket sizes in bytes. The smallest (256) accommodates a sealed sender /// envelope (99 bytes overhead) plus a short message. const BUCKETS: &[usize] = &[256, 1024, 4096, 16384]; @@ -61,6 +70,46 @@ pub fn unpad(padded: &[u8]) -> Result, CoreError> { Ok(padded[4..4 + real_len].to_vec()) } +/// Pad a payload to the nearest multiple of `boundary` bytes. +/// +/// Uses the same wire format as [`pad`]: `[real_length: 4 bytes LE][payload][random padding]`. +/// The total output length is always a multiple of `boundary`. A `boundary` of 0 is +/// treated as [`DEFAULT_PADDING_BOUNDARY`]. +pub fn pad_uniform(payload: &[u8], boundary: usize) -> Vec { + let boundary = if boundary == 0 { DEFAULT_PADDING_BOUNDARY } else { boundary }; + let total = payload.len() + 4; // 4-byte length prefix + let padded_len = total.div_ceil(boundary) * boundary; + + let mut out = Vec::with_capacity(padded_len); + out.extend_from_slice(&(payload.len() as u32).to_le_bytes()); + out.extend_from_slice(payload); + let pad_len = padded_len - total; + if pad_len > 0 { + let mut padding = vec![0u8; pad_len]; + rand::rngs::OsRng.fill_bytes(&mut padding); + out.extend_from_slice(&padding); + } + out +} + +/// Remove uniform padding. Wire format is identical to [`unpad`]. +pub fn unpad_uniform(padded: &[u8]) -> Result, CoreError> { + unpad(padded) +} + +/// Generate a decoy payload that looks identical to a real padded message. +/// +/// Returns random bytes of length equal to a `boundary`-aligned padded message. +/// The 4-byte length prefix is set to 0, so [`unpad_uniform`] returns an empty payload. +pub fn generate_decoy(boundary: usize) -> Vec { + let boundary = if boundary == 0 { DEFAULT_PADDING_BOUNDARY } else { boundary }; + let mut out = vec![0u8; boundary]; + // Length prefix = 0 (decoy carries no real payload). + // Fill the rest with random bytes so it is indistinguishable from padding. + rand::rngs::OsRng.fill_bytes(&mut out[4..]); + out +} + #[cfg(test)] #[allow(clippy::unwrap_used)] mod tests { @@ -142,4 +191,75 @@ mod tests { bad.extend_from_slice(&[0u8; 10]); assert!(unpad(&bad).is_err()); } + + // ── Uniform padding tests ────────────────────────────────────────────── + + #[test] + fn uniform_round_trip_default_boundary() { + let msg = b"uniform padding test"; + let padded = pad_uniform(msg, DEFAULT_PADDING_BOUNDARY); + assert_eq!(padded.len() % DEFAULT_PADDING_BOUNDARY, 0); + assert_eq!(padded.len(), 256); // 20 + 4 = 24, rounds up to 256 + let unpadded = unpad_uniform(&padded).unwrap(); + assert_eq!(unpadded, msg); + } + + #[test] + fn uniform_custom_boundary() { + let msg = vec![0xAA; 100]; + let padded = pad_uniform(&msg, 128); + assert_eq!(padded.len() % 128, 0); + assert_eq!(padded.len(), 128); // 100 + 4 = 104, rounds up to 128 + let unpadded = unpad_uniform(&padded).unwrap(); + assert_eq!(unpadded, msg); + } + + #[test] + fn uniform_exact_boundary() { + // 252 + 4 = 256, exactly on boundary + let msg = vec![0xBB; 252]; + let padded = pad_uniform(&msg, 256); + assert_eq!(padded.len(), 256); + let unpadded = unpad_uniform(&padded).unwrap(); + assert_eq!(unpadded, msg); + } + + #[test] + fn uniform_one_over_boundary() { + // 253 + 4 = 257, rounds up to 512 + let msg = vec![0xCC; 253]; + let padded = pad_uniform(&msg, 256); + assert_eq!(padded.len(), 512); + let unpadded = unpad_uniform(&padded).unwrap(); + assert_eq!(unpadded, msg); + } + + #[test] + fn uniform_zero_boundary_uses_default() { + let msg = b"zero boundary"; + let padded = pad_uniform(msg, 0); + assert_eq!(padded.len() % DEFAULT_PADDING_BOUNDARY, 0); + let unpadded = unpad_uniform(&padded).unwrap(); + assert_eq!(unpadded, msg); + } + + #[test] + fn decoy_is_boundary_aligned() { + let decoy = generate_decoy(256); + assert_eq!(decoy.len(), 256); + assert_eq!(decoy.len() % 256, 0); + } + + #[test] + fn decoy_unpads_to_empty() { + let decoy = generate_decoy(256); + let payload = unpad_uniform(&decoy).unwrap(); + assert!(payload.is_empty()); + } + + #[test] + fn decoy_default_boundary() { + let decoy = generate_decoy(0); + assert_eq!(decoy.len(), DEFAULT_PADDING_BOUNDARY); + } } diff --git a/crates/quicproquo-p2p/Cargo.toml b/crates/quicproquo-p2p/Cargo.toml index 648f9d8..d5d0ab1 100644 --- a/crates/quicproquo-p2p/Cargo.toml +++ b/crates/quicproquo-p2p/Cargo.toml @@ -5,6 +5,9 @@ edition = "2021" description = "P2P transport layer for quicproquo using iroh." license = "MIT" +[features] +traffic-resistance = [] + [lints] workspace = true diff --git a/crates/quicproquo-p2p/src/lib.rs b/crates/quicproquo-p2p/src/lib.rs index ba6f80c..a12244e 100644 --- a/crates/quicproquo-p2p/src/lib.rs +++ b/crates/quicproquo-p2p/src/lib.rs @@ -16,6 +16,8 @@ pub mod broadcast; pub mod envelope; pub mod identity; pub mod store; +#[cfg(feature = "traffic-resistance")] +pub mod traffic_resistance; use std::sync::{Arc, Mutex}; diff --git a/crates/quicproquo-p2p/src/traffic_resistance.rs b/crates/quicproquo-p2p/src/traffic_resistance.rs new file mode 100644 index 0000000..bef8d0c --- /dev/null +++ b/crates/quicproquo-p2p/src/traffic_resistance.rs @@ -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: quicproquo_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 { + quicproquo_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, + 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>, + config: MeshTrafficConfig, + shutdown: Arc, +) -> 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 = quicproquo_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 = quicproquo_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 = quicproquo_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 = quicproquo_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(); + } +} diff --git a/crates/quicproquo-server/Cargo.toml b/crates/quicproquo-server/Cargo.toml index 3c139f7..dddb99b 100644 --- a/crates/quicproquo-server/Cargo.toml +++ b/crates/quicproquo-server/Cargo.toml @@ -5,6 +5,10 @@ edition = "2021" description = "Delivery Service and Authentication Service for quicproquo." license = "MIT" +[features] +traffic-resistance = [] +webtransport = ["dep:h3", "dep:h3-quinn", "dep:h3-webtransport", "dep:http"] + [[bin]] name = "qpq-server" path = "src/main.rs" @@ -73,6 +77,12 @@ metrics-exporter-prometheus = "0.15" # mDNS service announcement for local mesh / Freifunk node discovery. mdns-sd = "0.12" +# WebTransport (HTTP/3) — feature-gated, for browser clients. +h3 = { version = "0.0.8", optional = true } +h3-quinn = { version = "0.0.10", optional = true } +h3-webtransport = { version = "0.1", optional = true } +http = { version = "1", optional = true } + [lints] workspace = true diff --git a/crates/quicproquo-server/src/domain/mod.rs b/crates/quicproquo-server/src/domain/mod.rs index dc96243..3428306 100644 --- a/crates/quicproquo-server/src/domain/mod.rs +++ b/crates/quicproquo-server/src/domain/mod.rs @@ -20,3 +20,5 @@ pub mod moderation; pub mod notification; pub mod rate_limit; pub mod recovery; +#[cfg(feature = "traffic-resistance")] +pub mod traffic_resistance; diff --git a/crates/quicproquo-server/src/domain/traffic_resistance.rs b/crates/quicproquo-server/src/domain/traffic_resistance.rs new file mode 100644 index 0000000..b7b8d26 --- /dev/null +++ b/crates/quicproquo-server/src/domain/traffic_resistance.rs @@ -0,0 +1,249 @@ +//! Traffic analysis resistance — decoy traffic generation and timing jitter. +//! +//! When enabled (via the `traffic-resistance` feature), the server: +//! +//! 1. Pads all enqueued payloads to a uniform boundary using [`quicproquo_core::padding::pad_uniform`]. +//! 2. Injects random jitter delays before enqueue responses to mask timing patterns. +//! 3. Runs a background decoy traffic generator that enqueues fake encrypted messages +//! at a configurable rate to connected recipients. +//! +//! Decoy messages are indistinguishable from real padded messages on the wire. +//! Recipients detect and discard them by unpadding to an empty payload. + +use std::sync::Arc; + +use rand::Rng; +use tokio::sync::Notify; + +use super::delivery::DeliveryService; +use super::types::EnqueueReq; + +/// Configuration for traffic analysis resistance. +#[derive(Clone, Debug)] +pub struct TrafficResistanceConfig { + /// Padding boundary in bytes (default 256). All enqueued payloads are + /// padded to the nearest multiple of this value. + pub padding_boundary: usize, + + /// Mean interval in milliseconds between decoy messages per recipient. + /// Set to 0 to disable decoy traffic. + pub decoy_interval_ms: u64, + + /// Maximum random jitter in milliseconds added before enqueue responses. + /// Set to 0 to disable jitter. + pub jitter_max_ms: u64, +} + +impl Default for TrafficResistanceConfig { + fn default() -> Self { + Self { + padding_boundary: quicproquo_core::padding::DEFAULT_PADDING_BOUNDARY, + decoy_interval_ms: 5000, + jitter_max_ms: 50, + } + } +} + +/// Pad a payload to the configured uniform boundary. +pub fn pad_payload(payload: &[u8], config: &TrafficResistanceConfig) -> Vec { + quicproquo_core::padding::pad_uniform(payload, config.padding_boundary) +} + +/// Apply random jitter delay to mask timing patterns. +/// +/// Sleeps for a random duration in `[0, config.jitter_max_ms)` milliseconds. +/// Does nothing if `jitter_max_ms` is 0. +pub async fn apply_jitter(config: &TrafficResistanceConfig) { + if config.jitter_max_ms == 0 { + return; + } + let jitter_ms = rand::thread_rng().gen_range(0..config.jitter_max_ms); + if jitter_ms > 0 { + tokio::time::sleep(std::time::Duration::from_millis(jitter_ms)).await; + } +} + +/// Spawn a background task that generates decoy traffic. +/// +/// Sends decoy messages to the provided `recipient_keys` at random intervals +/// around `config.decoy_interval_ms`. The task runs until `shutdown` is notified. +/// +/// Returns a `JoinHandle` for the spawned task. +pub fn spawn_decoy_generator( + delivery: Arc, + recipient_keys: Vec>, + channel_id: Vec, + config: TrafficResistanceConfig, + shutdown: Arc, +) -> tokio::task::JoinHandle<()> { + tokio::spawn(async move { + if config.decoy_interval_ms == 0 || recipient_keys.is_empty() { + // Decoy traffic disabled or no recipients — wait for shutdown. + shutdown.notified().await; + return; + } + + let base_interval = std::time::Duration::from_millis(config.decoy_interval_ms); + + loop { + // Randomize interval: 50%–150% of base to avoid periodic patterns. + 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!("decoy traffic generator shutting down"); + return; + } + } + + // Pick a random recipient. + let idx = rand::thread_rng().gen_range(0..recipient_keys.len()); + let recipient_key = &recipient_keys[idx]; + + // Generate a decoy payload that is indistinguishable from a real padded message. + let decoy = quicproquo_core::padding::generate_decoy(config.padding_boundary); + + let req = EnqueueReq { + recipient_key: recipient_key.clone(), + payload: decoy, + channel_id: channel_id.clone(), + ttl_secs: 60, // Short TTL for decoys. + }; + + match delivery.enqueue(req) { + Ok(_) => { + tracing::trace!("decoy message injected"); + } + Err(e) => { + tracing::warn!(error = %e, "failed to inject decoy message"); + } + } + } + }) +} + +#[cfg(test)] +#[allow(clippy::unwrap_used)] +mod tests { + use super::*; + use crate::storage::FileBackedStore; + use dashmap::DashMap; + + fn test_delivery() -> (tempfile::TempDir, Arc) { + let dir = tempfile::tempdir().unwrap(); + let store = Arc::new(FileBackedStore::open(dir.path()).unwrap()); + let svc = Arc::new(DeliveryService { + store, + waiters: Arc::new(DashMap::new()), + }); + (dir, svc) + } + + #[test] + fn pad_payload_is_boundary_aligned() { + let config = TrafficResistanceConfig { + padding_boundary: 256, + ..Default::default() + }; + let payload = b"test message"; + let padded = pad_payload(payload, &config); + assert_eq!(padded.len() % 256, 0); + + // Unpad should recover original. + let unpadded = quicproquo_core::padding::unpad_uniform(&padded).unwrap(); + assert_eq!(unpadded, payload); + } + + #[test] + fn pad_payload_custom_boundary() { + let config = TrafficResistanceConfig { + padding_boundary: 512, + ..Default::default() + }; + let payload = vec![0xAA; 300]; + let padded = pad_payload(&payload, &config); + assert_eq!(padded.len() % 512, 0); + assert_eq!(padded.len(), 512); + } + + #[tokio::test] + async fn jitter_zero_is_noop() { + let config = TrafficResistanceConfig { + jitter_max_ms: 0, + ..Default::default() + }; + let start = std::time::Instant::now(); + apply_jitter(&config).await; + // Should return almost immediately. + assert!(start.elapsed() < std::time::Duration::from_millis(5)); + } + + #[tokio::test] + async fn decoy_generator_produces_messages() { + let (_dir, delivery) = test_delivery(); + let recipient = vec![0xFFu8; 32]; + let channel = vec![0u8; 16]; + let shutdown = Arc::new(Notify::new()); + + let config = TrafficResistanceConfig { + padding_boundary: 256, + decoy_interval_ms: 50, // Fast interval for testing. + jitter_max_ms: 0, + }; + + let handle = spawn_decoy_generator( + Arc::clone(&delivery), + vec![recipient.clone()], + channel.clone(), + config, + Arc::clone(&shutdown), + ); + + // Wait enough time for at least one decoy. + tokio::time::sleep(std::time::Duration::from_millis(200)).await; + shutdown.notify_one(); + handle.await.unwrap(); + + // Check that decoy messages were enqueued. + let fetched = delivery + .fetch(super::super::types::FetchReq { + recipient_key: recipient, + channel_id: channel, + limit: 100, + }) + .unwrap(); + + assert!(!fetched.payloads.is_empty(), "decoy generator should have enqueued at least one message"); + + // Every decoy should unpad to an empty payload. + for env in &fetched.payloads { + let unpadded = quicproquo_core::padding::unpad_uniform(&env.data).unwrap(); + assert!(unpadded.is_empty(), "decoy payload should unpad to empty"); + } + } + + #[tokio::test] + async fn decoy_generator_disabled_when_zero_interval() { + let (_dir, delivery) = test_delivery(); + let shutdown = Arc::new(Notify::new()); + + let config = TrafficResistanceConfig { + decoy_interval_ms: 0, + ..Default::default() + }; + + let handle = spawn_decoy_generator( + delivery, + vec![vec![1u8; 32]], + vec![0u8; 16], + config, + Arc::clone(&shutdown), + ); + + // Signal shutdown immediately — should return without having sent anything. + shutdown.notify_one(); + handle.await.unwrap(); + } +}