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:
2026-03-04 20:50:19 +01:00
parent c401caec60
commit f4621b3425
7 changed files with 590 additions and 0 deletions

View File

@@ -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

View File

@@ -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;

View 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();
}
}