Add comprehensive security tests for masking and shape hardening features

- Introduced red-team expected-fail tests for client masking shape hardening.
- Added integration tests for masking AB envelope blur to improve obfuscation.
- Implemented masking security tests to validate the behavior of masking under various conditions.
- Created tests for masking shape above-cap blur to ensure proper functionality.
- Developed adversarial tests for masking shape hardening to evaluate robustness against attacks.
- Added timing normalization security tests to assess the effectiveness of timing obfuscation.
- Implemented red-team expected-fail tests for timing side-channel vulnerabilities.
This commit is contained in:
David Osipov
2026-03-21 00:30:51 +04:00
parent 8814854ae4
commit bb355e916f
19 changed files with 1937 additions and 27 deletions

View File

@@ -3,6 +3,7 @@
use std::str;
use std::net::SocketAddr;
use std::time::Duration;
use rand::{Rng, RngCore};
use tokio::net::TcpStream;
#[cfg(unix)]
use tokio::net::UnixStream;
@@ -73,7 +74,7 @@ fn next_mask_shape_bucket(total: usize, floor: usize, cap: usize) -> usize {
None => return total,
}
if bucket > cap {
return total;
return cap;
}
}
bucket
@@ -85,6 +86,8 @@ async fn maybe_write_shape_padding<W>(
enabled: bool,
floor: usize,
cap: usize,
above_cap_blur: bool,
above_cap_blur_max_bytes: usize,
)
where
W: AsyncWrite + Unpin,
@@ -93,15 +96,47 @@ where
return;
}
let bucket = next_mask_shape_bucket(total_sent, floor, cap);
if bucket <= total_sent {
let target_total = if total_sent >= cap && above_cap_blur && above_cap_blur_max_bytes > 0 {
let mut rng = rand::rng();
let extra = rng.random_range(0..=above_cap_blur_max_bytes);
total_sent.saturating_add(extra)
} else {
next_mask_shape_bucket(total_sent, floor, cap)
};
if target_total <= total_sent {
return;
}
let pad_len = bucket - total_sent;
let pad = vec![0u8; pad_len];
let _ = timeout(MASK_TIMEOUT, mask_write.write_all(&pad)).await;
let _ = timeout(MASK_TIMEOUT, mask_write.flush()).await;
let mut remaining = target_total - total_sent;
let mut pad_chunk = [0u8; 1024];
let deadline = Instant::now() + MASK_TIMEOUT;
while remaining > 0 {
let now = Instant::now();
if now >= deadline {
return;
}
let write_len = remaining.min(pad_chunk.len());
{
let mut rng = rand::rng();
rng.fill_bytes(&mut pad_chunk[..write_len]);
}
let write_budget = deadline.saturating_duration_since(now);
match timeout(write_budget, mask_write.write_all(&pad_chunk[..write_len])).await {
Ok(Ok(())) => {}
Ok(Err(_)) | Err(_) => return,
}
remaining -= write_len;
}
let now = Instant::now();
if now >= deadline {
return;
}
let flush_budget = deadline.saturating_duration_since(now);
let _ = timeout(flush_budget, mask_write.flush()).await;
}
async fn write_proxy_header_with_timeout<W>(mask_write: &mut W, header: &[u8]) -> bool
@@ -134,10 +169,33 @@ async fn wait_mask_connect_budget(started: Instant) {
}
}
async fn wait_mask_outcome_budget(started: Instant) {
fn mask_outcome_target_budget(config: &ProxyConfig) -> Duration {
if config.censorship.mask_timing_normalization_enabled {
let floor = config.censorship.mask_timing_normalization_floor_ms;
let ceiling = config.censorship.mask_timing_normalization_ceiling_ms;
if ceiling > floor {
let mut rng = rand::rng();
return Duration::from_millis(rng.random_range(floor..=ceiling));
}
return Duration::from_millis(floor);
}
MASK_TIMEOUT
}
async fn wait_mask_connect_budget_if_needed(started: Instant, config: &ProxyConfig) {
if config.censorship.mask_timing_normalization_enabled {
return;
}
wait_mask_connect_budget(started).await;
}
async fn wait_mask_outcome_budget(started: Instant, config: &ProxyConfig) {
let target = mask_outcome_target_budget(config);
let elapsed = started.elapsed();
if elapsed < MASK_TIMEOUT {
tokio::time::sleep(MASK_TIMEOUT - elapsed).await;
if elapsed < target {
tokio::time::sleep(target - elapsed).await;
}
}
@@ -247,7 +305,7 @@ where
build_mask_proxy_header(config.censorship.mask_proxy_protocol, peer, local_addr);
if let Some(header) = proxy_header {
if !write_proxy_header_with_timeout(&mut mask_write, &header).await {
wait_mask_outcome_budget(outcome_started).await;
wait_mask_outcome_budget(outcome_started, config).await;
return;
}
}
@@ -262,6 +320,8 @@ where
config.censorship.mask_shape_hardening,
config.censorship.mask_shape_bucket_floor_bytes,
config.censorship.mask_shape_bucket_cap_bytes,
config.censorship.mask_shape_above_cap_blur,
config.censorship.mask_shape_above_cap_blur_max_bytes,
),
)
.await
@@ -269,18 +329,18 @@ where
{
debug!("Mask relay timed out (unix socket)");
}
wait_mask_outcome_budget(outcome_started).await;
wait_mask_outcome_budget(outcome_started, config).await;
}
Ok(Err(e)) => {
wait_mask_connect_budget(connect_started).await;
wait_mask_connect_budget_if_needed(connect_started, config).await;
debug!(error = %e, "Failed to connect to mask unix socket");
consume_client_data_with_timeout(reader).await;
wait_mask_outcome_budget(outcome_started).await;
wait_mask_outcome_budget(outcome_started, config).await;
}
Err(_) => {
debug!("Timeout connecting to mask unix socket");
consume_client_data_with_timeout(reader).await;
wait_mask_outcome_budget(outcome_started).await;
wait_mask_outcome_budget(outcome_started, config).await;
}
}
return;
@@ -313,7 +373,7 @@ where
let (mask_read, mut mask_write) = stream.into_split();
if let Some(header) = proxy_header {
if !write_proxy_header_with_timeout(&mut mask_write, &header).await {
wait_mask_outcome_budget(outcome_started).await;
wait_mask_outcome_budget(outcome_started, config).await;
return;
}
}
@@ -328,6 +388,8 @@ where
config.censorship.mask_shape_hardening,
config.censorship.mask_shape_bucket_floor_bytes,
config.censorship.mask_shape_bucket_cap_bytes,
config.censorship.mask_shape_above_cap_blur,
config.censorship.mask_shape_above_cap_blur_max_bytes,
),
)
.await
@@ -335,18 +397,18 @@ where
{
debug!("Mask relay timed out");
}
wait_mask_outcome_budget(outcome_started).await;
wait_mask_outcome_budget(outcome_started, config).await;
}
Ok(Err(e)) => {
wait_mask_connect_budget(connect_started).await;
wait_mask_connect_budget_if_needed(connect_started, config).await;
debug!(error = %e, "Failed to connect to mask host");
consume_client_data_with_timeout(reader).await;
wait_mask_outcome_budget(outcome_started).await;
wait_mask_outcome_budget(outcome_started, config).await;
}
Err(_) => {
debug!("Timeout connecting to mask host");
consume_client_data_with_timeout(reader).await;
wait_mask_outcome_budget(outcome_started).await;
wait_mask_outcome_budget(outcome_started, config).await;
}
}
}
@@ -361,6 +423,8 @@ async fn relay_to_mask<R, W, MR, MW>(
shape_hardening_enabled: bool,
shape_bucket_floor_bytes: usize,
shape_bucket_cap_bytes: usize,
shape_above_cap_blur: bool,
shape_above_cap_blur_max_bytes: usize,
)
where
R: AsyncRead + Unpin + Send + 'static,
@@ -386,6 +450,8 @@ where
shape_hardening_enabled,
shape_bucket_floor_bytes,
shape_bucket_cap_bytes,
shape_above_cap_blur,
shape_above_cap_blur_max_bytes,
)
.await;
let _ = mask_write.shutdown().await;
@@ -414,3 +480,23 @@ mod security_tests;
#[cfg(test)]
#[path = "tests/masking_adversarial_tests.rs"]
mod adversarial_tests;
#[cfg(test)]
#[path = "tests/masking_shape_hardening_adversarial_tests.rs"]
mod masking_shape_hardening_adversarial_tests;
#[cfg(test)]
#[path = "tests/masking_shape_above_cap_blur_security_tests.rs"]
mod masking_shape_above_cap_blur_security_tests;
#[cfg(test)]
#[path = "tests/masking_timing_normalization_security_tests.rs"]
mod masking_timing_normalization_security_tests;
#[cfg(test)]
#[path = "tests/masking_ab_envelope_blur_integration_security_tests.rs"]
mod masking_ab_envelope_blur_integration_security_tests;
#[cfg(test)]
#[path = "tests/masking_timing_sidechannel_redteam_expected_fail_tests.rs"]
mod masking_timing_sidechannel_redteam_expected_fail_tests;