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_RELAY_IDLE_TIMEOUT: Duration = Duration::from_millis(100);
|
||||||
const MASK_BUFFER_SIZE: usize = 8192;
|
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
|
where
|
||||||
R: AsyncRead + Unpin,
|
R: AsyncRead + Unpin,
|
||||||
W: AsyncWrite + Unpin,
|
W: AsyncWrite + Unpin,
|
||||||
{
|
{
|
||||||
let mut buf = [0u8; MASK_BUFFER_SIZE];
|
let mut buf = [0u8; MASK_BUFFER_SIZE];
|
||||||
let mut total = 0usize;
|
let mut total = 0usize;
|
||||||
|
let mut ended_by_eof = false;
|
||||||
loop {
|
loop {
|
||||||
let read_res = timeout(MASK_RELAY_IDLE_TIMEOUT, reader.read(&mut buf)).await;
|
let read_res = timeout(MASK_RELAY_IDLE_TIMEOUT, reader.read(&mut buf)).await;
|
||||||
let n = match read_res {
|
let n = match read_res {
|
||||||
|
|
@ -45,6 +51,7 @@ where
|
||||||
Ok(Err(_)) | Err(_) => break,
|
Ok(Err(_)) | Err(_) => break,
|
||||||
};
|
};
|
||||||
if n == 0 {
|
if n == 0 {
|
||||||
|
ended_by_eof = true;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
total = total.saturating_add(n);
|
total = total.saturating_add(n);
|
||||||
|
|
@ -55,7 +62,10 @@ where
|
||||||
Ok(Err(_)) | Err(_) => break,
|
Ok(Err(_)) | Err(_) => break,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
total
|
CopyOutcome {
|
||||||
|
total,
|
||||||
|
ended_by_eof,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn next_mask_shape_bucket(total: usize, floor: usize, cap: usize) -> usize {
|
fn next_mask_shape_bucket(total: usize, floor: usize, cap: usize) -> usize {
|
||||||
|
|
@ -443,11 +453,16 @@ where
|
||||||
let _ = tokio::join!(
|
let _ = tokio::join!(
|
||||||
async {
|
async {
|
||||||
let copied = copy_with_idle_timeout(&mut reader, &mut mask_write).await;
|
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(
|
maybe_write_shape_padding(
|
||||||
&mut mask_write,
|
&mut mask_write,
|
||||||
total_sent,
|
total_sent,
|
||||||
shape_hardening_enabled,
|
should_shape,
|
||||||
shape_bucket_floor_bytes,
|
shape_bucket_floor_bytes,
|
||||||
shape_bucket_cap_bytes,
|
shape_bucket_cap_bytes,
|
||||||
shape_above_cap_blur,
|
shape_above_cap_blur,
|
||||||
|
|
@ -497,6 +512,18 @@ mod masking_timing_normalization_security_tests;
|
||||||
#[path = "tests/masking_ab_envelope_blur_integration_security_tests.rs"]
|
#[path = "tests/masking_ab_envelope_blur_integration_security_tests.rs"]
|
||||||
mod masking_ab_envelope_blur_integration_security_tests;
|
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)]
|
#[cfg(test)]
|
||||||
#[path = "tests/masking_timing_sidechannel_redteam_expected_fail_tests.rs"]
|
#[path = "tests/masking_timing_sidechannel_redteam_expected_fail_tests.rs"]
|
||||||
mod masking_timing_sidechannel_redteam_expected_fail_tests;
|
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_unix_sock = None;
|
||||||
cfg.censorship.mask_host = Some("127.0.0.1".to_string());
|
cfg.censorship.mask_host = Some("127.0.0.1".to_string());
|
||||||
cfg.censorship.mask_port = backend_addr.port();
|
cfg.censorship.mask_port = backend_addr.port();
|
||||||
|
cfg.censorship.mask_shape_hardening = false;
|
||||||
cfg.timeouts.client_handshake = 1;
|
cfg.timeouts.client_handshake = 1;
|
||||||
|
|
||||||
let accept_task = tokio::spawn(async move {
|
let accept_task = tokio::spawn(async move {
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,52 @@ fn mean_ms(samples: &[u128]) -> f64 {
|
||||||
sum as f64 / samples.len() as 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 {
|
async fn measure_masking_duration_ms(path: PathClass, timing_norm_enabled: bool) -> u128 {
|
||||||
let mut config = ProxyConfig::default();
|
let mut config = ProxyConfig::default();
|
||||||
config.general.beobachten = false;
|
config.general.beobachten = false;
|
||||||
|
|
@ -239,3 +285,233 @@ async fn integration_ab_harness_envelope_and_blur_improve_obfuscation_vs_baselin
|
||||||
hardened_overlap
|
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