feat: add traffic analysis resistance (Phase 7.7 + F8)
- Core: add pad_uniform/unpad_uniform for configurable boundary padding and generate_decoy for indistinguishable decoy messages - Server: add traffic_resistance module with payload padding, timing jitter, and background decoy traffic generator (feature-gated) - P2P: add mesh traffic_resistance module with padded envelopes and mesh decoy injection (feature-gated) - All gated behind --features traffic-resistance - 22 new tests across core (8), server (4), and P2P (5)
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
249
crates/quicproquo-server/src/domain/traffic_resistance.rs
Normal file
249
crates/quicproquo-server/src/domain/traffic_resistance.rs
Normal file
@@ -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<u8> {
|
||||
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<DeliveryService>,
|
||||
recipient_keys: Vec<Vec<u8>>,
|
||||
channel_id: Vec<u8>,
|
||||
config: TrafficResistanceConfig,
|
||||
shutdown: Arc<Notify>,
|
||||
) -> 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<DeliveryService>) {
|
||||
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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user