mirror of https://github.com/telemt/telemt.git
Add masking shape classifier and guard tests for adversarial resistance
- Implemented tests for masking shape classifier resistance against threshold attacks, ensuring that blurring reduces accuracy and increases overlap between classes. - Added tests for masking shape guard functionality, verifying that it maintains expected behavior under various conditions, including timeout paths and clean EOF scenarios. - Introduced helper functions for calculating accuracy and handling timing samples to support the new tests. - Ensured that the masking shape hardening configuration is properly utilized in tests to validate its effectiveness.
This commit is contained in:
parent
f2335c211c
commit
8188fedf6a
|
|
@ -1,34 +0,0 @@
|
|||
name: Build telemt for OpenBSD aarch64
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Compile in OpenBSD VM
|
||||
uses: vmactions/openbsd-vm@v1
|
||||
with:
|
||||
release: "7.8"
|
||||
arch: aarch64
|
||||
usesh: true
|
||||
sync: sshfs
|
||||
envs: 'RUSTFLAGS'
|
||||
prepare: |
|
||||
pkg_add rust
|
||||
run: |
|
||||
cargo build --release
|
||||
env:
|
||||
RUSTFLAGS: "-C target-cpu=cortex-a53 -C target-feature=+aes,+pmull,+sha2,+sha1,+crc -C opt-level=3"
|
||||
|
||||
- name: Upload artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: telemt-openbsd-aarch64
|
||||
path: target/release/telemt
|
||||
retention-days: 7
|
||||
|
|
@ -31,13 +31,19 @@ const MASK_RELAY_IDLE_TIMEOUT: Duration = Duration::from_secs(5);
|
|||
const MASK_RELAY_IDLE_TIMEOUT: Duration = Duration::from_millis(100);
|
||||
const MASK_BUFFER_SIZE: usize = 8192;
|
||||
|
||||
async fn copy_with_idle_timeout<R, W>(reader: &mut R, writer: &mut W) -> usize
|
||||
struct CopyOutcome {
|
||||
total: usize,
|
||||
ended_by_eof: bool,
|
||||
}
|
||||
|
||||
async fn copy_with_idle_timeout<R, W>(reader: &mut R, writer: &mut W) -> CopyOutcome
|
||||
where
|
||||
R: AsyncRead + Unpin,
|
||||
W: AsyncWrite + Unpin,
|
||||
{
|
||||
let mut buf = [0u8; MASK_BUFFER_SIZE];
|
||||
let mut total = 0usize;
|
||||
let mut ended_by_eof = false;
|
||||
loop {
|
||||
let read_res = timeout(MASK_RELAY_IDLE_TIMEOUT, reader.read(&mut buf)).await;
|
||||
let n = match read_res {
|
||||
|
|
@ -45,6 +51,7 @@ where
|
|||
Ok(Err(_)) | Err(_) => break,
|
||||
};
|
||||
if n == 0 {
|
||||
ended_by_eof = true;
|
||||
break;
|
||||
}
|
||||
total = total.saturating_add(n);
|
||||
|
|
@ -55,7 +62,10 @@ where
|
|||
Ok(Err(_)) | Err(_) => break,
|
||||
}
|
||||
}
|
||||
total
|
||||
CopyOutcome {
|
||||
total,
|
||||
ended_by_eof,
|
||||
}
|
||||
}
|
||||
|
||||
fn next_mask_shape_bucket(total: usize, floor: usize, cap: usize) -> usize {
|
||||
|
|
@ -443,11 +453,16 @@ where
|
|||
let _ = tokio::join!(
|
||||
async {
|
||||
let copied = copy_with_idle_timeout(&mut reader, &mut mask_write).await;
|
||||
let total_sent = initial_data.len().saturating_add(copied);
|
||||
let total_sent = initial_data.len().saturating_add(copied.total);
|
||||
|
||||
let should_shape = shape_hardening_enabled
|
||||
&& copied.ended_by_eof
|
||||
&& !initial_data.is_empty();
|
||||
|
||||
maybe_write_shape_padding(
|
||||
&mut mask_write,
|
||||
total_sent,
|
||||
shape_hardening_enabled,
|
||||
should_shape,
|
||||
shape_bucket_floor_bytes,
|
||||
shape_bucket_cap_bytes,
|
||||
shape_above_cap_blur,
|
||||
|
|
@ -497,6 +512,18 @@ mod masking_timing_normalization_security_tests;
|
|||
#[path = "tests/masking_ab_envelope_blur_integration_security_tests.rs"]
|
||||
mod masking_ab_envelope_blur_integration_security_tests;
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "tests/masking_shape_guard_security_tests.rs"]
|
||||
mod masking_shape_guard_security_tests;
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "tests/masking_shape_guard_adversarial_tests.rs"]
|
||||
mod masking_shape_guard_adversarial_tests;
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "tests/masking_shape_classifier_resistance_adversarial_tests.rs"]
|
||||
mod masking_shape_classifier_resistance_adversarial_tests;
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "tests/masking_timing_sidechannel_redteam_expected_fail_tests.rs"]
|
||||
mod masking_timing_sidechannel_redteam_expected_fail_tests;
|
||||
|
|
|
|||
|
|
@ -95,6 +95,7 @@ async fn capture_forwarded_len(body_sent: usize) -> usize {
|
|||
cfg.censorship.mask_unix_sock = None;
|
||||
cfg.censorship.mask_host = Some("127.0.0.1".to_string());
|
||||
cfg.censorship.mask_port = backend_addr.port();
|
||||
cfg.censorship.mask_shape_hardening = false;
|
||||
cfg.timeouts.client_handshake = 1;
|
||||
|
||||
let accept_task = tokio::spawn(async move {
|
||||
|
|
|
|||
|
|
@ -19,6 +19,52 @@ fn mean_ms(samples: &[u128]) -> f64 {
|
|||
sum as f64 / samples.len() as f64
|
||||
}
|
||||
|
||||
fn percentile_ms(mut values: Vec<u128>, p_num: usize, p_den: usize) -> u128 {
|
||||
values.sort_unstable();
|
||||
if values.is_empty() {
|
||||
return 0;
|
||||
}
|
||||
let idx = ((values.len() - 1) * p_num) / p_den;
|
||||
values[idx]
|
||||
}
|
||||
|
||||
fn bucketize_ms(values: &[u128], bucket_ms: u128) -> Vec<u128> {
|
||||
values.iter().map(|v| *v / bucket_ms).collect()
|
||||
}
|
||||
|
||||
fn best_threshold_accuracy_u128(a: &[u128], b: &[u128]) -> f64 {
|
||||
let min_v = *a.iter().chain(b.iter()).min().unwrap();
|
||||
let max_v = *a.iter().chain(b.iter()).max().unwrap();
|
||||
|
||||
let mut best = 0.0f64;
|
||||
for t in min_v..=max_v {
|
||||
let correct_a = a.iter().filter(|&&x| x <= t).count();
|
||||
let correct_b = b.iter().filter(|&&x| x > t).count();
|
||||
let acc = (correct_a + correct_b) as f64 / (a.len() + b.len()) as f64;
|
||||
if acc > best {
|
||||
best = acc;
|
||||
}
|
||||
}
|
||||
best
|
||||
}
|
||||
|
||||
fn spread_u128(values: &[u128]) -> u128 {
|
||||
if values.is_empty() {
|
||||
return 0;
|
||||
}
|
||||
let min_v = *values.iter().min().unwrap();
|
||||
let max_v = *values.iter().max().unwrap();
|
||||
max_v - min_v
|
||||
}
|
||||
|
||||
async fn collect_timing_samples(path: PathClass, timing_norm_enabled: bool, n: usize) -> Vec<u128> {
|
||||
let mut out = Vec::with_capacity(n);
|
||||
for _ in 0..n {
|
||||
out.push(measure_masking_duration_ms(path, timing_norm_enabled).await);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
async fn measure_masking_duration_ms(path: PathClass, timing_norm_enabled: bool) -> u128 {
|
||||
let mut config = ProxyConfig::default();
|
||||
config.general.beobachten = false;
|
||||
|
|
@ -239,3 +285,233 @@ async fn integration_ab_harness_envelope_and_blur_improve_obfuscation_vs_baselin
|
|||
hardened_overlap
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn timing_classifier_helper_bucketize_is_stable() {
|
||||
let values = vec![219u128, 220, 239, 240, 259, 260];
|
||||
let got = bucketize_ms(&values, 20);
|
||||
assert_eq!(got, vec![10, 11, 11, 12, 12, 13]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn timing_classifier_helper_percentile_is_monotonic() {
|
||||
let samples = vec![210u128, 220, 230, 240, 250, 260, 270, 280];
|
||||
let p50 = percentile_ms(samples.clone(), 50, 100);
|
||||
let p95 = percentile_ms(samples.clone(), 95, 100);
|
||||
assert!(p95 >= p50);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn timing_classifier_helper_threshold_accuracy_is_perfect_for_disjoint_sets() {
|
||||
let a = vec![10u128, 11, 12, 13, 14];
|
||||
let b = vec![20u128, 21, 22, 23, 24];
|
||||
let acc = best_threshold_accuracy_u128(&a, &b);
|
||||
assert!(acc >= 0.99);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn timing_classifier_helper_threshold_accuracy_drops_for_identical_sets() {
|
||||
let a = vec![10u128, 11, 12, 13, 14];
|
||||
let b = vec![10u128, 11, 12, 13, 14];
|
||||
let acc = best_threshold_accuracy_u128(&a, &b);
|
||||
assert!(acc <= 0.6, "identical sets should not be strongly separable");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn timing_classifier_helper_bucketed_threshold_reduces_resolution() {
|
||||
let raw_a = vec![221u128, 223, 225, 227, 229];
|
||||
let raw_b = vec![231u128, 233, 235, 237, 239];
|
||||
let raw_acc = best_threshold_accuracy_u128(&raw_a, &raw_b);
|
||||
|
||||
let bucketed_a = bucketize_ms(&raw_a, 20);
|
||||
let bucketed_b = bucketize_ms(&raw_b, 20);
|
||||
let bucketed_acc = best_threshold_accuracy_u128(&bucketed_a, &bucketed_b);
|
||||
|
||||
assert!(raw_acc >= bucketed_acc);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn timing_classifier_baseline_connect_fail_vs_slow_backend_is_highly_separable() {
|
||||
let fail = collect_timing_samples(PathClass::ConnectFail, false, 8).await;
|
||||
let slow = collect_timing_samples(PathClass::SlowBackend, false, 8).await;
|
||||
|
||||
let acc = best_threshold_accuracy_u128(&fail, &slow);
|
||||
assert!(acc >= 0.80, "baseline timing classes should be separable enough");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn timing_classifier_normalized_connect_fail_vs_slow_backend_reduces_separability() {
|
||||
let baseline_fail = collect_timing_samples(PathClass::ConnectFail, false, 8).await;
|
||||
let baseline_slow = collect_timing_samples(PathClass::SlowBackend, false, 8).await;
|
||||
let hardened_fail = collect_timing_samples(PathClass::ConnectFail, true, 8).await;
|
||||
let hardened_slow = collect_timing_samples(PathClass::SlowBackend, true, 8).await;
|
||||
|
||||
let baseline_acc = best_threshold_accuracy_u128(&baseline_fail, &baseline_slow);
|
||||
let hardened_acc = best_threshold_accuracy_u128(&hardened_fail, &hardened_slow);
|
||||
|
||||
assert!(
|
||||
hardened_acc <= baseline_acc,
|
||||
"normalization should not increase timing separability"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn timing_classifier_bucketed_normalized_connect_fail_vs_slow_backend_is_bounded() {
|
||||
let baseline_fail = collect_timing_samples(PathClass::ConnectFail, false, 10).await;
|
||||
let baseline_slow = collect_timing_samples(PathClass::SlowBackend, false, 10).await;
|
||||
let hardened_fail = collect_timing_samples(PathClass::ConnectFail, true, 10).await;
|
||||
let hardened_slow = collect_timing_samples(PathClass::SlowBackend, true, 10).await;
|
||||
|
||||
let baseline_acc = best_threshold_accuracy_u128(
|
||||
&bucketize_ms(&baseline_fail, 20),
|
||||
&bucketize_ms(&baseline_slow, 20),
|
||||
);
|
||||
let hardened_acc = best_threshold_accuracy_u128(
|
||||
&bucketize_ms(&hardened_fail, 20),
|
||||
&bucketize_ms(&hardened_slow, 20),
|
||||
);
|
||||
|
||||
assert!(
|
||||
hardened_acc <= baseline_acc,
|
||||
"normalized bucketed classifier should not outperform baseline: baseline={baseline_acc:.3} hardened={hardened_acc:.3}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn timing_classifier_normalized_connect_fail_samples_stay_in_sane_bounds() {
|
||||
let samples = collect_timing_samples(PathClass::ConnectFail, true, 6).await;
|
||||
for s in samples {
|
||||
assert!((150..=1200).contains(&s), "sample out of sane bounds: {s}");
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn timing_classifier_normalized_connect_success_samples_stay_in_sane_bounds() {
|
||||
let samples = collect_timing_samples(PathClass::ConnectSuccess, true, 6).await;
|
||||
for s in samples {
|
||||
assert!((150..=1200).contains(&s), "sample out of sane bounds: {s}");
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn timing_classifier_normalized_slow_backend_samples_stay_in_sane_bounds() {
|
||||
let samples = collect_timing_samples(PathClass::SlowBackend, true, 6).await;
|
||||
for s in samples {
|
||||
assert!((150..=1400).contains(&s), "sample out of sane bounds: {s}");
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn timing_classifier_normalized_mean_bucket_delta_connect_fail_vs_connect_success_is_small() {
|
||||
let fail = collect_timing_samples(PathClass::ConnectFail, true, 8).await;
|
||||
let success = collect_timing_samples(PathClass::ConnectSuccess, true, 8).await;
|
||||
let fail_mean = mean_ms(&fail);
|
||||
let success_mean = mean_ms(&success);
|
||||
let delta_bucket = ((fail_mean as i128 - success_mean as i128).abs()) / 20;
|
||||
assert!(delta_bucket <= 3, "mean bucket delta too large: {delta_bucket}");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn timing_classifier_normalized_p95_bucket_delta_connect_success_vs_slow_is_small() {
|
||||
let success = collect_timing_samples(PathClass::ConnectSuccess, true, 10).await;
|
||||
let slow = collect_timing_samples(PathClass::SlowBackend, true, 10).await;
|
||||
let p95_success = percentile_ms(success, 95, 100);
|
||||
let p95_slow = percentile_ms(slow, 95, 100);
|
||||
let delta_bucket = ((p95_success as i128 - p95_slow as i128).abs()) / 20;
|
||||
assert!(delta_bucket <= 4, "p95 bucket delta too large: {delta_bucket}");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn timing_classifier_normalized_spread_is_not_worse_than_baseline_for_connect_fail() {
|
||||
let baseline = collect_timing_samples(PathClass::ConnectFail, false, 8).await;
|
||||
let hardened = collect_timing_samples(PathClass::ConnectFail, true, 8).await;
|
||||
let baseline_spread = spread_u128(&baseline);
|
||||
let hardened_spread = spread_u128(&hardened);
|
||||
assert!(
|
||||
hardened_spread <= baseline_spread.saturating_add(600),
|
||||
"normalized spread exploded unexpectedly: baseline={baseline_spread} hardened={hardened_spread}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn timing_classifier_light_fuzz_pairwise_bucketed_accuracy_stays_bounded_under_normalization() {
|
||||
let pairs = [
|
||||
(PathClass::ConnectFail, PathClass::ConnectSuccess),
|
||||
(PathClass::ConnectFail, PathClass::SlowBackend),
|
||||
(PathClass::ConnectSuccess, PathClass::SlowBackend),
|
||||
];
|
||||
|
||||
let mut meaningful_improvement_seen = false;
|
||||
let mut baseline_sum = 0.0f64;
|
||||
let mut hardened_sum = 0.0f64;
|
||||
let mut pair_count = 0usize;
|
||||
|
||||
for (a, b) in pairs {
|
||||
let baseline_a = collect_timing_samples(a, false, 6).await;
|
||||
let baseline_b = collect_timing_samples(b, false, 6).await;
|
||||
let hardened_a = collect_timing_samples(a, true, 6).await;
|
||||
let hardened_b = collect_timing_samples(b, true, 6).await;
|
||||
|
||||
let baseline_acc = best_threshold_accuracy_u128(
|
||||
&bucketize_ms(&baseline_a, 20),
|
||||
&bucketize_ms(&baseline_b, 20),
|
||||
);
|
||||
let hardened_acc = best_threshold_accuracy_u128(
|
||||
&bucketize_ms(&hardened_a, 20),
|
||||
&bucketize_ms(&hardened_b, 20),
|
||||
);
|
||||
|
||||
// When baseline separability is near-random, tiny sample jitter can make
|
||||
// hardened appear "worse" without indicating a real side-channel regression.
|
||||
// Guard hard only on informative baseline pairs.
|
||||
if baseline_acc >= 0.75 {
|
||||
assert!(
|
||||
hardened_acc <= baseline_acc + 0.05,
|
||||
"normalization should not materially worsen informative pair: baseline={baseline_acc:.3} hardened={hardened_acc:.3}"
|
||||
);
|
||||
}
|
||||
|
||||
if hardened_acc + 0.05 <= baseline_acc {
|
||||
meaningful_improvement_seen = true;
|
||||
}
|
||||
|
||||
baseline_sum += baseline_acc;
|
||||
hardened_sum += hardened_acc;
|
||||
pair_count += 1;
|
||||
}
|
||||
|
||||
let baseline_avg = baseline_sum / pair_count as f64;
|
||||
let hardened_avg = hardened_sum / pair_count as f64;
|
||||
|
||||
assert!(
|
||||
hardened_avg <= baseline_avg + 0.08,
|
||||
"normalization should not materially increase average pairwise separability: baseline_avg={baseline_avg:.3} hardened_avg={hardened_avg:.3}"
|
||||
);
|
||||
|
||||
// Optional signal only: do not require improvement on every run because
|
||||
// noisy CI schedulers can flatten pairwise differences at low sample counts.
|
||||
let _ = meaningful_improvement_seen;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn timing_classifier_stress_parallel_sampling_finishes_and_stays_bounded() {
|
||||
let mut tasks = Vec::new();
|
||||
for i in 0..24usize {
|
||||
tasks.push(tokio::spawn(async move {
|
||||
let class = match i % 3 {
|
||||
0 => PathClass::ConnectFail,
|
||||
1 => PathClass::ConnectSuccess,
|
||||
_ => PathClass::SlowBackend,
|
||||
};
|
||||
let sample = measure_masking_duration_ms(class, true).await;
|
||||
assert!((100..=1600).contains(&sample), "stress sample out of bounds: {sample}");
|
||||
}));
|
||||
}
|
||||
|
||||
for task in tasks {
|
||||
tokio::time::timeout(Duration::from_secs(4), task)
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,324 @@
|
|||
use super::*;
|
||||
use tokio::io::{duplex, AsyncReadExt, AsyncWriteExt};
|
||||
use tokio::net::TcpListener;
|
||||
use tokio::time::Duration;
|
||||
|
||||
async fn capture_forwarded_len(
|
||||
body_sent: usize,
|
||||
shape_hardening: bool,
|
||||
above_cap_blur: bool,
|
||||
above_cap_blur_max_bytes: usize,
|
||||
) -> usize {
|
||||
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let backend_addr = listener.local_addr().unwrap();
|
||||
|
||||
let mut config = ProxyConfig::default();
|
||||
config.general.beobachten = false;
|
||||
config.censorship.mask = true;
|
||||
config.censorship.mask_host = Some("127.0.0.1".to_string());
|
||||
config.censorship.mask_port = backend_addr.port();
|
||||
config.censorship.mask_shape_hardening = shape_hardening;
|
||||
config.censorship.mask_shape_bucket_floor_bytes = 512;
|
||||
config.censorship.mask_shape_bucket_cap_bytes = 4096;
|
||||
config.censorship.mask_shape_above_cap_blur = above_cap_blur;
|
||||
config.censorship.mask_shape_above_cap_blur_max_bytes = above_cap_blur_max_bytes;
|
||||
|
||||
let accept_task = tokio::spawn(async move {
|
||||
let (mut stream, _) = listener.accept().await.unwrap();
|
||||
let mut got = Vec::new();
|
||||
let _ = tokio::time::timeout(Duration::from_secs(2), stream.read_to_end(&mut got)).await;
|
||||
got.len()
|
||||
});
|
||||
|
||||
let (client_reader, mut client_writer) = duplex(64 * 1024);
|
||||
let (_client_visible_reader, client_visible_writer) = duplex(64 * 1024);
|
||||
|
||||
let mut initial = vec![0u8; 5 + body_sent];
|
||||
initial[0] = 0x16;
|
||||
initial[1] = 0x03;
|
||||
initial[2] = 0x01;
|
||||
initial[3..5].copy_from_slice(&7000u16.to_be_bytes());
|
||||
initial[5..].fill(0x5A);
|
||||
|
||||
let peer: SocketAddr = "198.51.100.250:57450".parse().unwrap();
|
||||
let local: SocketAddr = "127.0.0.1:443".parse().unwrap();
|
||||
let beobachten = BeobachtenStore::new();
|
||||
|
||||
let fallback = tokio::spawn(async move {
|
||||
handle_bad_client(
|
||||
client_reader,
|
||||
client_visible_writer,
|
||||
&initial,
|
||||
peer,
|
||||
local,
|
||||
&config,
|
||||
&beobachten,
|
||||
)
|
||||
.await;
|
||||
});
|
||||
|
||||
client_writer.shutdown().await.unwrap();
|
||||
let _ = tokio::time::timeout(Duration::from_secs(3), fallback)
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
|
||||
tokio::time::timeout(Duration::from_secs(3), accept_task)
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
fn best_threshold_accuracy(a: &[usize], b: &[usize]) -> f64 {
|
||||
let min_v = *a.iter().chain(b.iter()).min().unwrap();
|
||||
let max_v = *a.iter().chain(b.iter()).max().unwrap();
|
||||
|
||||
let mut best = 0.0f64;
|
||||
for t in min_v..=max_v {
|
||||
let correct_a = a.iter().filter(|&&x| x <= t).count();
|
||||
let correct_b = b.iter().filter(|&&x| x > t).count();
|
||||
let acc = (correct_a + correct_b) as f64 / (a.len() + b.len()) as f64;
|
||||
if acc > best {
|
||||
best = acc;
|
||||
}
|
||||
}
|
||||
best
|
||||
}
|
||||
|
||||
fn nearest_centroid_classifier_accuracy(
|
||||
samples_a: &[usize],
|
||||
samples_b: &[usize],
|
||||
samples_c: &[usize],
|
||||
) -> f64 {
|
||||
let mean = |xs: &[usize]| -> f64 {
|
||||
xs.iter().copied().sum::<usize>() as f64 / xs.len() as f64
|
||||
};
|
||||
|
||||
let ca = mean(samples_a);
|
||||
let cb = mean(samples_b);
|
||||
let cc = mean(samples_c);
|
||||
|
||||
let mut correct = 0usize;
|
||||
let mut total = 0usize;
|
||||
|
||||
for &x in samples_a {
|
||||
total += 1;
|
||||
let xf = x as f64;
|
||||
let d = [
|
||||
(xf - ca).abs(),
|
||||
(xf - cb).abs(),
|
||||
(xf - cc).abs(),
|
||||
];
|
||||
if d[0] <= d[1] && d[0] <= d[2] {
|
||||
correct += 1;
|
||||
}
|
||||
}
|
||||
|
||||
for &x in samples_b {
|
||||
total += 1;
|
||||
let xf = x as f64;
|
||||
let d = [
|
||||
(xf - ca).abs(),
|
||||
(xf - cb).abs(),
|
||||
(xf - cc).abs(),
|
||||
];
|
||||
if d[1] <= d[0] && d[1] <= d[2] {
|
||||
correct += 1;
|
||||
}
|
||||
}
|
||||
|
||||
for &x in samples_c {
|
||||
total += 1;
|
||||
let xf = x as f64;
|
||||
let d = [
|
||||
(xf - ca).abs(),
|
||||
(xf - cb).abs(),
|
||||
(xf - cc).abs(),
|
||||
];
|
||||
if d[2] <= d[0] && d[2] <= d[1] {
|
||||
correct += 1;
|
||||
}
|
||||
}
|
||||
|
||||
correct as f64 / total as f64
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn masking_shape_classifier_resistance_blur_reduces_threshold_attack_accuracy() {
|
||||
const SAMPLES: usize = 120;
|
||||
const MAX_EXTRA: usize = 96;
|
||||
const CLASS_A_BODY: usize = 5000;
|
||||
const CLASS_B_BODY: usize = 5040;
|
||||
|
||||
let mut baseline_a = Vec::with_capacity(SAMPLES);
|
||||
let mut baseline_b = Vec::with_capacity(SAMPLES);
|
||||
let mut hardened_a = Vec::with_capacity(SAMPLES);
|
||||
let mut hardened_b = Vec::with_capacity(SAMPLES);
|
||||
|
||||
for _ in 0..SAMPLES {
|
||||
baseline_a.push(capture_forwarded_len(CLASS_A_BODY, true, false, 0).await);
|
||||
baseline_b.push(capture_forwarded_len(CLASS_B_BODY, true, false, 0).await);
|
||||
hardened_a.push(capture_forwarded_len(CLASS_A_BODY, true, true, MAX_EXTRA).await);
|
||||
hardened_b.push(capture_forwarded_len(CLASS_B_BODY, true, true, MAX_EXTRA).await);
|
||||
}
|
||||
|
||||
let baseline_acc = best_threshold_accuracy(&baseline_a, &baseline_b);
|
||||
let hardened_acc = best_threshold_accuracy(&hardened_a, &hardened_b);
|
||||
|
||||
// Baseline classes are deterministic/non-overlapping -> near-perfect threshold attack.
|
||||
assert!(baseline_acc >= 0.99, "baseline separability unexpectedly low: {baseline_acc:.3}");
|
||||
// Blur must materially reduce the best one-dimensional length classifier.
|
||||
assert!(
|
||||
hardened_acc <= 0.90,
|
||||
"blur should degrade threshold attack accuracy, got {hardened_acc:.3}"
|
||||
);
|
||||
assert!(
|
||||
hardened_acc <= baseline_acc - 0.08,
|
||||
"blur must reduce threshold accuracy by a meaningful margin: baseline={baseline_acc:.3}, hardened={hardened_acc:.3}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn masking_shape_classifier_resistance_blur_increases_cross_class_overlap() {
|
||||
const SAMPLES: usize = 96;
|
||||
const MAX_EXTRA: usize = 96;
|
||||
const CLASS_A_BODY: usize = 5000;
|
||||
const CLASS_B_BODY: usize = 5040;
|
||||
|
||||
let mut baseline_a = std::collections::BTreeSet::new();
|
||||
let mut baseline_b = std::collections::BTreeSet::new();
|
||||
let mut hardened_a = std::collections::BTreeSet::new();
|
||||
let mut hardened_b = std::collections::BTreeSet::new();
|
||||
|
||||
for _ in 0..SAMPLES {
|
||||
baseline_a.insert(capture_forwarded_len(CLASS_A_BODY, true, false, 0).await);
|
||||
baseline_b.insert(capture_forwarded_len(CLASS_B_BODY, true, false, 0).await);
|
||||
hardened_a.insert(capture_forwarded_len(CLASS_A_BODY, true, true, MAX_EXTRA).await);
|
||||
hardened_b.insert(capture_forwarded_len(CLASS_B_BODY, true, true, MAX_EXTRA).await);
|
||||
}
|
||||
|
||||
let baseline_overlap = baseline_a.intersection(&baseline_b).count();
|
||||
let hardened_overlap = hardened_a.intersection(&hardened_b).count();
|
||||
|
||||
assert_eq!(baseline_overlap, 0, "baseline classes should not overlap");
|
||||
assert!(
|
||||
hardened_overlap >= 8,
|
||||
"blur should create meaningful overlap between classes, got overlap={hardened_overlap}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn masking_shape_classifier_resistance_parallel_probe_campaign_keeps_blur_bounds() {
|
||||
const MAX_EXTRA: usize = 128;
|
||||
|
||||
let mut tasks = Vec::new();
|
||||
for i in 0..64usize {
|
||||
tasks.push(tokio::spawn(async move {
|
||||
let body = 4300 + (i % 700);
|
||||
let observed = capture_forwarded_len(body, true, true, MAX_EXTRA).await;
|
||||
let base = 5 + body;
|
||||
assert!(
|
||||
observed >= base && observed <= base + MAX_EXTRA,
|
||||
"campaign bounds violated for i={i}: observed={observed} base={base}"
|
||||
);
|
||||
}));
|
||||
}
|
||||
|
||||
for task in tasks {
|
||||
tokio::time::timeout(Duration::from_secs(3), task)
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn masking_shape_classifier_resistance_edge_max_extra_one_has_two_point_support() {
|
||||
const BODY: usize = 5000;
|
||||
const BASE: usize = 5 + BODY;
|
||||
|
||||
let mut seen = std::collections::BTreeSet::new();
|
||||
for _ in 0..64 {
|
||||
let observed = capture_forwarded_len(BODY, true, true, 1).await;
|
||||
assert!(
|
||||
observed == BASE || observed == BASE + 1,
|
||||
"max_extra=1 must only produce two-point support"
|
||||
);
|
||||
seen.insert(observed);
|
||||
}
|
||||
|
||||
assert_eq!(seen.len(), 2, "both support points should appear under repeated sampling");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn masking_shape_classifier_resistance_negative_blur_without_shape_hardening_is_noop() {
|
||||
const BODY_A: usize = 5000;
|
||||
const BODY_B: usize = 5040;
|
||||
|
||||
let mut as_observed = std::collections::BTreeSet::new();
|
||||
let mut bs_observed = std::collections::BTreeSet::new();
|
||||
for _ in 0..48 {
|
||||
as_observed.insert(capture_forwarded_len(BODY_A, false, true, 96).await);
|
||||
bs_observed.insert(capture_forwarded_len(BODY_B, false, true, 96).await);
|
||||
}
|
||||
|
||||
assert_eq!(as_observed.len(), 1, "without shape hardening class A must stay deterministic");
|
||||
assert_eq!(bs_observed.len(), 1, "without shape hardening class B must stay deterministic");
|
||||
assert_ne!(as_observed, bs_observed, "distinct classes should remain separable without shaping");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn masking_shape_classifier_resistance_adversarial_three_class_centroid_attack_degrades_with_blur() {
|
||||
const SAMPLES: usize = 80;
|
||||
const MAX_EXTRA: usize = 96;
|
||||
const C1: usize = 5000;
|
||||
const C2: usize = 5040;
|
||||
const C3: usize = 5080;
|
||||
|
||||
let mut base1 = Vec::with_capacity(SAMPLES);
|
||||
let mut base2 = Vec::with_capacity(SAMPLES);
|
||||
let mut base3 = Vec::with_capacity(SAMPLES);
|
||||
let mut hard1 = Vec::with_capacity(SAMPLES);
|
||||
let mut hard2 = Vec::with_capacity(SAMPLES);
|
||||
let mut hard3 = Vec::with_capacity(SAMPLES);
|
||||
|
||||
for _ in 0..SAMPLES {
|
||||
base1.push(capture_forwarded_len(C1, true, false, 0).await);
|
||||
base2.push(capture_forwarded_len(C2, true, false, 0).await);
|
||||
base3.push(capture_forwarded_len(C3, true, false, 0).await);
|
||||
|
||||
hard1.push(capture_forwarded_len(C1, true, true, MAX_EXTRA).await);
|
||||
hard2.push(capture_forwarded_len(C2, true, true, MAX_EXTRA).await);
|
||||
hard3.push(capture_forwarded_len(C3, true, true, MAX_EXTRA).await);
|
||||
}
|
||||
|
||||
let base_acc = nearest_centroid_classifier_accuracy(&base1, &base2, &base3);
|
||||
let hard_acc = nearest_centroid_classifier_accuracy(&hard1, &hard2, &hard3);
|
||||
|
||||
assert!(base_acc >= 0.99, "baseline centroid separability should be near-perfect");
|
||||
assert!(hard_acc <= 0.88, "blur should materially degrade 3-class centroid attack");
|
||||
assert!(hard_acc <= base_acc - 0.1, "accuracy drop should be meaningful");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn masking_shape_classifier_resistance_light_fuzz_bounds_hold_for_randomized_above_cap_campaign() {
|
||||
let mut s: u64 = 0xDEAD_BEEF_CAFE_BABE;
|
||||
for _ in 0..96 {
|
||||
s ^= s << 7;
|
||||
s ^= s >> 9;
|
||||
s ^= s << 8;
|
||||
let body = 4097 + (s as usize % 2048);
|
||||
|
||||
s ^= s << 7;
|
||||
s ^= s >> 9;
|
||||
s ^= s << 8;
|
||||
let max_extra = 1 + (s as usize % 128);
|
||||
|
||||
let observed = capture_forwarded_len(body, true, true, max_extra).await;
|
||||
let base = 5 + body;
|
||||
assert!(
|
||||
observed >= base && observed <= base + max_extra,
|
||||
"fuzz bounds violated: body={body} observed={observed} max_extra={max_extra}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,371 @@
|
|||
use super::*;
|
||||
use tokio::io::{duplex, empty, sink, AsyncReadExt, AsyncWriteExt};
|
||||
use tokio::time::{sleep, timeout, Duration};
|
||||
|
||||
fn oracle_len(
|
||||
total_sent: usize,
|
||||
shape_enabled: bool,
|
||||
ended_by_eof: bool,
|
||||
initial_len: usize,
|
||||
floor: usize,
|
||||
cap: usize,
|
||||
) -> usize {
|
||||
if shape_enabled && ended_by_eof && initial_len > 0 {
|
||||
next_mask_shape_bucket(total_sent, floor, cap)
|
||||
} else {
|
||||
total_sent
|
||||
}
|
||||
}
|
||||
|
||||
async fn run_relay_case(
|
||||
initial: Vec<u8>,
|
||||
extra: Vec<u8>,
|
||||
close_client: bool,
|
||||
shape_enabled: bool,
|
||||
floor: usize,
|
||||
cap: usize,
|
||||
above_cap_blur: bool,
|
||||
above_cap_blur_max_bytes: usize,
|
||||
) -> Vec<u8> {
|
||||
let (client_reader, mut client_writer) = duplex(8192);
|
||||
let (mut mask_observer, mask_writer) = duplex(8192);
|
||||
|
||||
let relay = tokio::spawn(async move {
|
||||
relay_to_mask(
|
||||
client_reader,
|
||||
sink(),
|
||||
empty(),
|
||||
mask_writer,
|
||||
&initial,
|
||||
shape_enabled,
|
||||
floor,
|
||||
cap,
|
||||
above_cap_blur,
|
||||
above_cap_blur_max_bytes,
|
||||
)
|
||||
.await;
|
||||
});
|
||||
|
||||
if !extra.is_empty() {
|
||||
client_writer.write_all(&extra).await.unwrap();
|
||||
}
|
||||
|
||||
if close_client {
|
||||
client_writer.shutdown().await.unwrap();
|
||||
}
|
||||
|
||||
timeout(Duration::from_secs(2), relay).await.unwrap().unwrap();
|
||||
|
||||
if !close_client {
|
||||
drop(client_writer);
|
||||
}
|
||||
|
||||
let mut observed = Vec::new();
|
||||
timeout(Duration::from_secs(2), mask_observer.read_to_end(&mut observed))
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
observed
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn masking_shape_guard_negative_timeout_path_never_shapes_even_with_blur_enabled() {
|
||||
let initial = b"GET /timeout-path HTTP/1.1\r\n".to_vec();
|
||||
let extra = vec![0xCC; 700];
|
||||
let total = initial.len() + extra.len();
|
||||
|
||||
let observed = run_relay_case(
|
||||
initial.clone(),
|
||||
extra.clone(),
|
||||
false,
|
||||
true,
|
||||
512,
|
||||
4096,
|
||||
true,
|
||||
1024,
|
||||
)
|
||||
.await;
|
||||
|
||||
assert_eq!(observed.len(), total, "timeout path must stay unshaped");
|
||||
assert_eq!(&observed[..initial.len()], initial.as_slice());
|
||||
assert_eq!(&observed[initial.len()..], extra.as_slice());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn masking_shape_guard_positive_clean_eof_path_shapes_and_preserves_prefix() {
|
||||
let initial = b"GET /ok HTTP/1.1\r\n".to_vec();
|
||||
let extra = vec![0x55; 300];
|
||||
let total = initial.len() + extra.len();
|
||||
|
||||
let observed = run_relay_case(initial.clone(), extra.clone(), true, true, 512, 4096, false, 0).await;
|
||||
|
||||
let expected_len = oracle_len(total, true, true, initial.len(), 512, 4096);
|
||||
assert_eq!(observed.len(), expected_len, "clean EOF path must be bucket-shaped");
|
||||
assert_eq!(&observed[..initial.len()], initial.as_slice());
|
||||
assert_eq!(&observed[initial.len()..(initial.len() + extra.len())], extra.as_slice());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn masking_shape_guard_edge_empty_initial_remains_transparent_under_clean_eof() {
|
||||
let initial = Vec::new();
|
||||
let extra = vec![0xA1; 257];
|
||||
|
||||
let observed = run_relay_case(initial, extra.clone(), true, true, 512, 4096, false, 0).await;
|
||||
|
||||
assert_eq!(observed.len(), extra.len(), "empty initial_data must never trigger shaping");
|
||||
assert_eq!(observed, extra);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn masking_shape_guard_light_fuzz_oracle_matches_for_eof_and_timeout_variants() {
|
||||
let floor = 512usize;
|
||||
let cap = 4096usize;
|
||||
|
||||
// Deterministic xorshift to keep this fuzz test stable in CI.
|
||||
let mut s: u64 = 0x9E37_79B9_7F4A_7C15;
|
||||
for _ in 0..96 {
|
||||
s ^= s << 7;
|
||||
s ^= s >> 9;
|
||||
s ^= s << 8;
|
||||
let initial_len = (s as usize) % 48;
|
||||
|
||||
s ^= s << 7;
|
||||
s ^= s >> 9;
|
||||
s ^= s << 8;
|
||||
let extra_len = (s as usize) % 1800;
|
||||
|
||||
s ^= s << 7;
|
||||
s ^= s >> 9;
|
||||
s ^= s << 8;
|
||||
let close_client = (s & 1) == 0;
|
||||
|
||||
let initial = vec![0x42; initial_len];
|
||||
let extra = vec![0x99; extra_len];
|
||||
let total = initial_len + extra_len;
|
||||
|
||||
let observed = run_relay_case(
|
||||
initial.clone(),
|
||||
extra.clone(),
|
||||
close_client,
|
||||
true,
|
||||
floor,
|
||||
cap,
|
||||
false,
|
||||
0,
|
||||
)
|
||||
.await;
|
||||
|
||||
let expected = oracle_len(total, true, close_client, initial_len, floor, cap);
|
||||
assert_eq!(
|
||||
observed.len(),
|
||||
expected,
|
||||
"oracle mismatch: initial_len={initial_len} extra_len={extra_len} close_client={close_client}"
|
||||
);
|
||||
|
||||
if initial_len > 0 {
|
||||
assert_eq!(&observed[..initial_len], initial.as_slice());
|
||||
}
|
||||
if extra_len > 0 {
|
||||
assert_eq!(
|
||||
&observed[initial_len..(initial_len + extra_len)],
|
||||
extra.as_slice(),
|
||||
"payload prefix must remain byte-for-byte before any optional shaping tail"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn masking_shape_guard_stress_parallel_mixed_sessions_keep_oracle_and_no_hangs() {
|
||||
let mut tasks = Vec::new();
|
||||
|
||||
for i in 0..48usize {
|
||||
tasks.push(tokio::spawn(async move {
|
||||
let initial_len = if i % 3 == 0 { 0 } else { 5 + (i % 19) };
|
||||
let extra_len = 64 + (i * 37 % 1300);
|
||||
let close_client = i % 2 == 0;
|
||||
|
||||
let initial = vec![i as u8; initial_len];
|
||||
let extra = vec![0xE0 | ((i as u8) & 0x0F); extra_len];
|
||||
let total = initial_len + extra_len;
|
||||
|
||||
let observed = run_relay_case(
|
||||
initial.clone(),
|
||||
extra.clone(),
|
||||
close_client,
|
||||
true,
|
||||
512,
|
||||
4096,
|
||||
false,
|
||||
0,
|
||||
)
|
||||
.await;
|
||||
|
||||
let expected = oracle_len(total, true, close_client, initial_len, 512, 4096);
|
||||
assert_eq!(
|
||||
observed.len(),
|
||||
expected,
|
||||
"stress oracle mismatch for worker={i} close_client={close_client}"
|
||||
);
|
||||
|
||||
if initial_len > 0 {
|
||||
assert_eq!(&observed[..initial_len], initial.as_slice());
|
||||
}
|
||||
if extra_len > 0 {
|
||||
assert_eq!(&observed[initial_len..(initial_len + extra_len)], extra.as_slice());
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
for task in tasks {
|
||||
timeout(Duration::from_secs(3), task).await.unwrap().unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn masking_shape_guard_integration_slow_drip_timeout_is_cut_without_tail_leak() {
|
||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let backend_addr = listener.local_addr().unwrap();
|
||||
let initial = b"GET /drip-guard HTTP/1.1\r\nHost: front.example\r\n\r\n".to_vec();
|
||||
|
||||
let accept_task = tokio::spawn({
|
||||
let initial = initial.clone();
|
||||
async move {
|
||||
let (mut stream, _) = listener.accept().await.unwrap();
|
||||
let mut observed = vec![0u8; initial.len()];
|
||||
stream.read_exact(&mut observed).await.unwrap();
|
||||
assert_eq!(observed, initial);
|
||||
|
||||
let mut one = [0u8; 1];
|
||||
let r = timeout(Duration::from_millis(220), stream.read_exact(&mut one)).await;
|
||||
assert!(r.is_err() || r.unwrap().is_err(), "no post-timeout drip/tail may reach backend");
|
||||
}
|
||||
});
|
||||
|
||||
let mut config = ProxyConfig::default();
|
||||
config.general.beobachten = false;
|
||||
config.censorship.mask = true;
|
||||
config.censorship.mask_host = Some("127.0.0.1".to_string());
|
||||
config.censorship.mask_port = backend_addr.port();
|
||||
config.censorship.mask_shape_hardening = true;
|
||||
config.censorship.mask_shape_bucket_floor_bytes = 512;
|
||||
config.censorship.mask_shape_bucket_cap_bytes = 4096;
|
||||
|
||||
let peer: SocketAddr = "198.51.100.245:53101".parse().unwrap();
|
||||
let local: SocketAddr = "127.0.0.1:443".parse().unwrap();
|
||||
|
||||
let (mut client_writer, client_reader) = duplex(1024);
|
||||
let (_client_visible_reader, client_visible_writer) = duplex(1024);
|
||||
let beobachten = BeobachtenStore::new();
|
||||
|
||||
let relay = tokio::spawn(async move {
|
||||
handle_bad_client(
|
||||
client_reader,
|
||||
client_visible_writer,
|
||||
&initial,
|
||||
peer,
|
||||
local,
|
||||
&config,
|
||||
&beobachten,
|
||||
)
|
||||
.await;
|
||||
});
|
||||
|
||||
sleep(Duration::from_millis(160)).await;
|
||||
let _ = client_writer.write_all(b"X").await;
|
||||
|
||||
timeout(Duration::from_secs(2), relay).await.unwrap().unwrap();
|
||||
timeout(Duration::from_secs(2), accept_task).await.unwrap().unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn masking_shape_guard_above_cap_blur_statistical_quality_and_bounds() {
|
||||
let base_len = 5005usize; // 5-byte header + 5000 payload
|
||||
let max_extra = 64usize;
|
||||
let mut extras = Vec::new();
|
||||
|
||||
for _ in 0..192 {
|
||||
let observed = run_relay_case(
|
||||
vec![0x16, 0x03, 0x01, 0x1B, 0x58],
|
||||
vec![0xAA; 5000],
|
||||
true,
|
||||
true,
|
||||
512,
|
||||
4096,
|
||||
true,
|
||||
max_extra,
|
||||
)
|
||||
.await;
|
||||
|
||||
assert!(
|
||||
observed.len() >= base_len && observed.len() <= base_len + max_extra,
|
||||
"above-cap blur length must stay in bounded window"
|
||||
);
|
||||
extras.push(observed.len() - base_len);
|
||||
}
|
||||
|
||||
let unique: std::collections::BTreeSet<_> = extras.iter().copied().collect();
|
||||
let mean = extras.iter().copied().sum::<usize>() as f64 / extras.len() as f64;
|
||||
|
||||
// For uniform [0..=64], mean is ~32. Keep wide bounds to avoid CI flakiness.
|
||||
assert!(
|
||||
(20.0..=44.0).contains(&mean),
|
||||
"blur mean drifted too far from expected center, mean={mean:.2}"
|
||||
);
|
||||
assert!(
|
||||
unique.len() >= 16,
|
||||
"blur distribution appears too low-entropy, unique_extras={}",
|
||||
unique.len()
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn masking_shape_guard_above_cap_blur_parallel_stress_keeps_bounds() {
|
||||
let max_extra = 96usize;
|
||||
let mut tasks = Vec::new();
|
||||
|
||||
for i in 0..64usize {
|
||||
tasks.push(tokio::spawn(async move {
|
||||
let body_len = 4500 + (i % 256);
|
||||
let base_len = 5 + body_len;
|
||||
|
||||
let observed = run_relay_case(
|
||||
vec![0x16, 0x03, 0x01, 0x1B, 0x58],
|
||||
vec![0xA0 | ((i as u8) & 0x0F); body_len],
|
||||
true,
|
||||
true,
|
||||
512,
|
||||
4096,
|
||||
true,
|
||||
max_extra,
|
||||
)
|
||||
.await;
|
||||
|
||||
assert!(
|
||||
observed.len() >= base_len && observed.len() <= base_len + max_extra,
|
||||
"parallel blur bounds violated for worker={i}: observed_len={} base_len={} max_extra={}",
|
||||
observed.len(),
|
||||
base_len,
|
||||
max_extra
|
||||
);
|
||||
}));
|
||||
}
|
||||
|
||||
for task in tasks {
|
||||
timeout(Duration::from_secs(3), task).await.unwrap().unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn masking_shape_guard_above_cap_blur_disabled_keeps_exact_length_even_on_clean_eof() {
|
||||
let initial = vec![0x16, 0x03, 0x01, 0x1B, 0x58];
|
||||
let body = vec![0x77; 5200];
|
||||
let expected = initial.len() + body.len();
|
||||
|
||||
let observed = run_relay_case(initial, body, true, true, 512, 4096, false, 0).await;
|
||||
assert_eq!(
|
||||
observed.len(),
|
||||
expected,
|
||||
"without above-cap blur the output must remain exact even on clean EOF"
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,167 @@
|
|||
use super::*;
|
||||
use tokio::io::{duplex, AsyncReadExt, AsyncWriteExt};
|
||||
use tokio::net::TcpListener;
|
||||
use tokio::time::{timeout, Duration};
|
||||
|
||||
#[tokio::test]
|
||||
async fn shape_guard_empty_initial_data_keeps_transparent_length_on_clean_eof() {
|
||||
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let backend_addr = listener.local_addr().unwrap();
|
||||
let client_payload = vec![0x7A; 64];
|
||||
|
||||
let accept_task = tokio::spawn({
|
||||
let expected = client_payload.clone();
|
||||
async move {
|
||||
let (mut stream, _) = listener.accept().await.unwrap();
|
||||
let mut got = Vec::new();
|
||||
stream.read_to_end(&mut got).await.unwrap();
|
||||
assert_eq!(got, expected, "empty initial_data path must not inject shape padding");
|
||||
}
|
||||
});
|
||||
|
||||
let mut config = ProxyConfig::default();
|
||||
config.general.beobachten = false;
|
||||
config.censorship.mask = true;
|
||||
config.censorship.mask_host = Some("127.0.0.1".to_string());
|
||||
config.censorship.mask_port = backend_addr.port();
|
||||
config.censorship.mask_shape_hardening = true;
|
||||
config.censorship.mask_shape_bucket_floor_bytes = 512;
|
||||
config.censorship.mask_shape_bucket_cap_bytes = 4096;
|
||||
|
||||
let peer: SocketAddr = "203.0.113.90:52001".parse().unwrap();
|
||||
let local: SocketAddr = "127.0.0.1:443".parse().unwrap();
|
||||
let beobachten = BeobachtenStore::new();
|
||||
|
||||
let (mut client_writer, client_reader) = duplex(2048);
|
||||
let (_client_visible_reader, client_visible_writer) = duplex(2048);
|
||||
|
||||
let relay_task = tokio::spawn(async move {
|
||||
handle_bad_client(
|
||||
client_reader,
|
||||
client_visible_writer,
|
||||
b"",
|
||||
peer,
|
||||
local,
|
||||
&config,
|
||||
&beobachten,
|
||||
)
|
||||
.await;
|
||||
});
|
||||
|
||||
client_writer.write_all(&client_payload).await.unwrap();
|
||||
client_writer.shutdown().await.unwrap();
|
||||
|
||||
timeout(Duration::from_secs(2), relay_task).await.unwrap().unwrap();
|
||||
timeout(Duration::from_secs(2), accept_task).await.unwrap().unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn shape_guard_timeout_exit_does_not_append_padding_after_initial_probe() {
|
||||
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let backend_addr = listener.local_addr().unwrap();
|
||||
let initial = b"GET /timeout-shape-guard HTTP/1.1\r\nHost: front.example\r\n\r\n".to_vec();
|
||||
|
||||
let accept_task = tokio::spawn({
|
||||
let initial = initial.clone();
|
||||
async move {
|
||||
let (mut stream, _) = listener.accept().await.unwrap();
|
||||
let mut observed = vec![0u8; initial.len()];
|
||||
stream.read_exact(&mut observed).await.unwrap();
|
||||
assert_eq!(observed, initial);
|
||||
|
||||
let mut one = [0u8; 1];
|
||||
let read_res = timeout(Duration::from_millis(220), stream.read_exact(&mut one)).await;
|
||||
assert!(
|
||||
read_res.is_err() || read_res.unwrap().is_err(),
|
||||
"idle-timeout path must not append shape padding after initial probe"
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
let mut config = ProxyConfig::default();
|
||||
config.general.beobachten = false;
|
||||
config.censorship.mask = true;
|
||||
config.censorship.mask_host = Some("127.0.0.1".to_string());
|
||||
config.censorship.mask_port = backend_addr.port();
|
||||
config.censorship.mask_shape_hardening = true;
|
||||
config.censorship.mask_shape_bucket_floor_bytes = 512;
|
||||
config.censorship.mask_shape_bucket_cap_bytes = 4096;
|
||||
|
||||
let peer: SocketAddr = "203.0.113.91:52002".parse().unwrap();
|
||||
let local: SocketAddr = "127.0.0.1:443".parse().unwrap();
|
||||
let beobachten = BeobachtenStore::new();
|
||||
|
||||
let (_client_reader_side, client_reader) = duplex(2048);
|
||||
let (_client_visible_reader, client_visible_writer) = duplex(2048);
|
||||
|
||||
handle_bad_client(
|
||||
client_reader,
|
||||
client_visible_writer,
|
||||
&initial,
|
||||
peer,
|
||||
local,
|
||||
&config,
|
||||
&beobachten,
|
||||
)
|
||||
.await;
|
||||
|
||||
timeout(Duration::from_secs(2), accept_task).await.unwrap().unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn shape_guard_clean_eof_with_nonempty_initial_still_applies_bucket_padding() {
|
||||
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let backend_addr = listener.local_addr().unwrap();
|
||||
let initial = b"GET /shape-bucket HTTP/1.1\r\n".to_vec();
|
||||
let extra = vec![0x41; 31];
|
||||
|
||||
let accept_task = tokio::spawn({
|
||||
let initial = initial.clone();
|
||||
let extra = extra.clone();
|
||||
async move {
|
||||
let (mut stream, _) = listener.accept().await.unwrap();
|
||||
let mut got = Vec::new();
|
||||
stream.read_to_end(&mut got).await.unwrap();
|
||||
|
||||
let expected_prefix_len = initial.len() + extra.len();
|
||||
assert_eq!(&got[..initial.len()], initial.as_slice());
|
||||
assert_eq!(&got[initial.len()..expected_prefix_len], extra.as_slice());
|
||||
assert_eq!(got.len(), 512, "clean EOF path should still shape to floor bucket");
|
||||
}
|
||||
});
|
||||
|
||||
let mut config = ProxyConfig::default();
|
||||
config.general.beobachten = false;
|
||||
config.censorship.mask = true;
|
||||
config.censorship.mask_host = Some("127.0.0.1".to_string());
|
||||
config.censorship.mask_port = backend_addr.port();
|
||||
config.censorship.mask_shape_hardening = true;
|
||||
config.censorship.mask_shape_bucket_floor_bytes = 512;
|
||||
config.censorship.mask_shape_bucket_cap_bytes = 4096;
|
||||
|
||||
let peer: SocketAddr = "203.0.113.92:52003".parse().unwrap();
|
||||
let local: SocketAddr = "127.0.0.1:443".parse().unwrap();
|
||||
let beobachten = BeobachtenStore::new();
|
||||
|
||||
let (mut client_writer, client_reader) = duplex(4096);
|
||||
let (_client_visible_reader, client_visible_writer) = duplex(4096);
|
||||
|
||||
let relay_task = tokio::spawn(async move {
|
||||
handle_bad_client(
|
||||
client_reader,
|
||||
client_visible_writer,
|
||||
&initial,
|
||||
peer,
|
||||
local,
|
||||
&config,
|
||||
&beobachten,
|
||||
)
|
||||
.await;
|
||||
});
|
||||
|
||||
client_writer.write_all(&extra).await.unwrap();
|
||||
client_writer.shutdown().await.unwrap();
|
||||
|
||||
timeout(Duration::from_secs(2), relay_task).await.unwrap().unwrap();
|
||||
timeout(Duration::from_secs(2), accept_task).await.unwrap().unwrap();
|
||||
}
|
||||
Loading…
Reference in New Issue