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

@@ -11,11 +11,20 @@
//! //!
//! The total padded output is always one of the bucket sizes: 256, 1024, 4096, 16384 bytes. //! 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. //! 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 rand::RngCore;
use crate::error::CoreError; 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 /// Bucket sizes in bytes. The smallest (256) accommodates a sealed sender
/// envelope (99 bytes overhead) plus a short message. /// envelope (99 bytes overhead) plus a short message.
const BUCKETS: &[usize] = &[256, 1024, 4096, 16384]; const BUCKETS: &[usize] = &[256, 1024, 4096, 16384];
@@ -61,6 +70,46 @@ pub fn unpad(padded: &[u8]) -> Result<Vec<u8>, CoreError> {
Ok(padded[4..4 + real_len].to_vec()) 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<u8> {
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<Vec<u8>, 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<u8> {
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)] #[cfg(test)]
#[allow(clippy::unwrap_used)] #[allow(clippy::unwrap_used)]
mod tests { mod tests {
@@ -142,4 +191,75 @@ mod tests {
bad.extend_from_slice(&[0u8; 10]); bad.extend_from_slice(&[0u8; 10]);
assert!(unpad(&bad).is_err()); 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);
}
} }

View File

@@ -5,6 +5,9 @@ edition = "2021"
description = "P2P transport layer for quicproquo using iroh." description = "P2P transport layer for quicproquo using iroh."
license = "MIT" license = "MIT"
[features]
traffic-resistance = []
[lints] [lints]
workspace = true workspace = true

View File

@@ -16,6 +16,8 @@ pub mod broadcast;
pub mod envelope; pub mod envelope;
pub mod identity; pub mod identity;
pub mod store; pub mod store;
#[cfg(feature = "traffic-resistance")]
pub mod traffic_resistance;
use std::sync::{Arc, Mutex}; use std::sync::{Arc, Mutex};

View 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: 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<u8> {
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<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 = 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();
}
}

View File

@@ -5,6 +5,10 @@ edition = "2021"
description = "Delivery Service and Authentication Service for quicproquo." description = "Delivery Service and Authentication Service for quicproquo."
license = "MIT" license = "MIT"
[features]
traffic-resistance = []
webtransport = ["dep:h3", "dep:h3-quinn", "dep:h3-webtransport", "dep:http"]
[[bin]] [[bin]]
name = "qpq-server" name = "qpq-server"
path = "src/main.rs" path = "src/main.rs"
@@ -73,6 +77,12 @@ metrics-exporter-prometheus = "0.15"
# mDNS service announcement for local mesh / Freifunk node discovery. # mDNS service announcement for local mesh / Freifunk node discovery.
mdns-sd = "0.12" 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] [lints]
workspace = true workspace = true

View File

@@ -20,3 +20,5 @@ pub mod moderation;
pub mod notification; pub mod notification;
pub mod rate_limit; pub mod rate_limit;
pub mod recovery; 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();
}
}