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:
@@ -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<Vec<u8>, 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<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)]
|
||||
#[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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,9 @@ edition = "2021"
|
||||
description = "P2P transport layer for quicproquo using iroh."
|
||||
license = "MIT"
|
||||
|
||||
[features]
|
||||
traffic-resistance = []
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
|
||||
@@ -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};
|
||||
|
||||
|
||||
204
crates/quicproquo-p2p/src/traffic_resistance.rs
Normal file
204
crates/quicproquo-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: 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();
|
||||
}
|
||||
}
|
||||
@@ -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