Skip to main content

sidecheck_core/
stats.rs

1//! Statistical core of sidecheck.
2//!
3//! Methodology based on Crosby, Wallach, Riedi, "Opportunities and Limits
4//! of Remote Timing Attacks" (ACM TISSEC, 2009): the network can only add
5//! delay, never remove it, so the low percentiles of a sample carry far
6//! less noise than the mean or even the raw minimum. This is what the
7//! "box test" builds on — comparing the low percentiles of two samples.
8
9/// Number of bootstrap resampling iterations for the confidence interval.
10/// Pulled out into a constant so it can be reported honestly — not just
11/// "confidence: 95%", but explicitly "bootstrap confidence over N
12/// iterations".
13pub const BOOTSTRAP_ITERATIONS: usize = 2000;
14
15use rand::Rng;
16
17/// Returns the p-th percentile of a sorted sample (p in [0.0, 100.0]).
18pub fn percentile(sorted: &[f64], p: f64) -> f64 {
19    assert!(!sorted.is_empty(), "empty sample");
20    let idx = (p / 100.0 * (sorted.len() - 1) as f64).round() as usize;
21    sorted[idx.min(sorted.len() - 1)]
22}
23
24fn sorted_copy(data: &[f64]) -> Vec<f64> {
25    let mut v = data.to_vec();
26    v.sort_by(|a, b| a.partial_cmp(b).unwrap());
27    v
28}
29
30/// Result of a box test: the difference between the low percentiles of two
31/// samples, plus a confidence interval obtained via bootstrap (no
32/// assumption of normally-distributed network latency).
33#[derive(Debug, Clone)]
34pub struct BoxTestResult {
35    pub class_a_low_percentile: f64,
36    pub class_b_low_percentile: f64,
37    /// class_b - class_a, in the same units as the input data (seconds)
38    pub estimated_leak: f64,
39    pub ci_low: f64,
40    pub ci_high: f64,
41    pub confidence: f64,
42}
43
44impl BoxTestResult {
45    /// The leak is considered statistically significant if the confidence
46    /// interval of the difference does not contain zero.
47    pub fn is_significant(&self) -> bool {
48        self.ci_low > 0.0 || self.ci_high < 0.0
49    }
50}
51
52/// Box test per the Crosby-Wallach methodology: compares the low
53/// percentile (p10 by default) of two response-time samples; the
54/// confidence interval is built via bootstrap resampling.
55pub fn box_test(
56    class_a: &[f64],
57    class_b: &[f64],
58    low_percentile: f64,
59    confidence: f64,
60) -> BoxTestResult {
61    let a_sorted = sorted_copy(class_a);
62    let b_sorted = sorted_copy(class_b);
63
64    let a_p = percentile(&a_sorted, low_percentile);
65    let b_p = percentile(&b_sorted, low_percentile);
66    let leak = b_p - a_p;
67
68    let (ci_low, ci_high) = bootstrap_ci(
69        class_a,
70        class_b,
71        low_percentile,
72        confidence,
73        BOOTSTRAP_ITERATIONS,
74    );
75
76    BoxTestResult {
77        class_a_low_percentile: a_p,
78        class_b_low_percentile: b_p,
79        estimated_leak: leak,
80        ci_low,
81        ci_high,
82        confidence,
83    }
84}
85
86/// Bootstrap confidence interval for the difference between low
87/// percentiles. Doesn't rely on normality — resamples the raw data with
88/// replacement and builds the empirical distribution of the difference.
89fn bootstrap_ci(
90    class_a: &[f64],
91    class_b: &[f64],
92    p: f64,
93    confidence: f64,
94    iterations: usize,
95) -> (f64, f64) {
96    let mut rng = rand::thread_rng();
97    let mut diffs = Vec::with_capacity(iterations);
98
99    for _ in 0..iterations {
100        let resample_a = resample(class_a, &mut rng);
101        let resample_b = resample(class_b, &mut rng);
102        let pa = percentile(&sorted_copy(&resample_a), p);
103        let pb = percentile(&sorted_copy(&resample_b), p);
104        diffs.push(pb - pa);
105    }
106
107    diffs.sort_by(|a, b| a.partial_cmp(b).unwrap());
108    let alpha = 1.0 - confidence;
109    let lo_idx = ((alpha / 2.0) * diffs.len() as f64) as usize;
110    let hi_idx = (((1.0 - alpha / 2.0) * diffs.len() as f64) as usize).min(diffs.len() - 1);
111    (diffs[lo_idx], diffs[hi_idx])
112}
113
114fn resample(data: &[f64], rng: &mut impl Rng) -> Vec<f64> {
115    (0..data.len())
116        .map(|_| data[rng.gen_range(0..data.len())])
117        .collect()
118}
119
120/// Estimates network jitter from a pilot sample. Used to be computed as
121/// the standard deviation around the low percentile — but variance
122/// (squared deviations) is extremely sensitive to single outliers (the
123/// first request after connection setup, a GC pause, OS scheduling): one
124/// slow request out of three hundred could inflate the estimate several
125/// times over, which is why two independent measurements of the same
126/// channel could disagree wildly.
127///
128/// MAD (median absolute deviation from the median) is nearly insensitive
129/// to single outliers: shifting the median requires corrupting more than
130/// half the sample, not one request. The 1.4826 multiplier is the
131/// standard factor that makes MAD a consistent estimator of standard
132/// deviation for normally distributed data.
133pub fn estimate_jitter(pilot: &[f64]) -> f64 {
134    if pilot.is_empty() {
135        return 0.0;
136    }
137    let sorted = sorted_copy(pilot);
138    let median = percentile(&sorted, 50.0);
139    let mut abs_deviations: Vec<f64> = pilot.iter().map(|x| (x - median).abs()).collect();
140    abs_deviations.sort_by(|a, b| a.partial_cmp(b).unwrap());
141    let mad = percentile(&abs_deviations, 50.0);
142    mad * 1.4826
143}
144
145/// Estimates the minimum number of requests per class needed to detect a
146/// leak of the given size at the given network noise level. Formula from
147/// power analysis for comparing means:
148/// n ≈ 2 * (z_alpha/2 + z_beta)^2 * sigma^2 / delta^2
149pub fn required_samples(jitter: f64, expected_leak_seconds: f64, confidence: f64) -> u64 {
150    if expected_leak_seconds <= 0.0 {
151        return u64::MAX;
152    }
153    // z-values for a two-sided test at `confidence` with 80% power (z_beta ≈ 0.84)
154    let z_alpha = inverse_normal_cdf(1.0 - (1.0 - confidence) / 2.0);
155    let z_beta = 0.84;
156    let n = 2.0 * (z_alpha + z_beta).powi(2) * jitter.powi(2) / expected_leak_seconds.powi(2);
157    n.ceil() as u64
158}
159
160/// Approximation of the inverse normal CDF (Beasley-Springer-Moro).
161/// Accurate enough for estimating the required sample size.
162fn inverse_normal_cdf(p: f64) -> f64 {
163    // Rational approximation, maximum error ~1.15e-9
164    let a = [
165        -3.969683028665376e+01,
166        2.209460984245205e+02,
167        -2.759285104469687e+02,
168        1.383_577_518_672_69e2,
169        -3.066479806614716e+01,
170        2.506628277459239e+00,
171    ];
172    let b = [
173        -5.447609879822406e+01,
174        1.615858368580409e+02,
175        -1.556989798598866e+02,
176        6.680131188771972e+01,
177        -1.328068155288572e+01,
178    ];
179    let c = [
180        -7.784894002430293e-03,
181        -3.223964580411365e-01,
182        -2.400758277161838e+00,
183        -2.549732539343734e+00,
184        4.374664141464968e+00,
185        2.938163982698783e+00,
186    ];
187    let d = [
188        7.784695709041462e-03,
189        3.224671290700398e-01,
190        2.445134137142996e+00,
191        3.754408661907416e+00,
192    ];
193    let p_low = 0.02425;
194    let p_high = 1.0 - p_low;
195
196    if p < p_low {
197        let q = (-2.0 * p.ln()).sqrt();
198        (((((c[0] * q + c[1]) * q + c[2]) * q + c[3]) * q + c[4]) * q + c[5])
199            / ((((d[0] * q + d[1]) * q + d[2]) * q + d[3]) * q + 1.0)
200    } else if p <= p_high {
201        let q = p - 0.5;
202        let r = q * q;
203        (((((a[0] * r + a[1]) * r + a[2]) * r + a[3]) * r + a[4]) * r + a[5]) * q
204            / (((((b[0] * r + b[1]) * r + b[2]) * r + b[3]) * r + b[4]) * r + 1.0)
205    } else {
206        let q = (-2.0 * (1.0 - p).ln()).sqrt();
207        -(((((c[0] * q + c[1]) * q + c[2]) * q + c[3]) * q + c[4]) * q + c[5])
208            / ((((d[0] * q + d[1]) * q + d[2]) * q + d[3]) * q + 1.0)
209    }
210}
211
212#[cfg(test)]
213mod tests {
214    use super::*;
215
216    #[test]
217    fn percentile_basic() {
218        let data = vec![1.0, 2.0, 3.0, 4.0, 5.0];
219        assert_eq!(percentile(&data, 0.0), 1.0);
220        assert_eq!(percentile(&data, 100.0), 5.0);
221        assert_eq!(percentile(&data, 50.0), 3.0);
222    }
223
224    #[test]
225    fn jitter_estimate_is_robust_to_a_single_outlier() {
226        // Regression for a real bug found in the wild: two independent
227        // measurements of the same stable channel (doctor vs check pilot)
228        // disagreed by 5x, because the old (variance-based) jitter
229        // estimate was disproportionately sensitive to a single slow
230        // request (e.g. the first one after TCP connection setup).
231        let mut stable: Vec<f64> = (0..300)
232            .map(|i| 0.0002 + (i as f64 % 5.0) * 0.00001)
233            .collect();
234        let jitter_without_outlier = estimate_jitter(&stable);
235
236        // a single request suddenly took 50ms instead of ~0.2ms
237        stable[0] = 0.050;
238        let jitter_with_outlier = estimate_jitter(&stable);
239
240        // MAD should not spike several times over from one outlier out of
241        // 300 samples — the old variance-based implementation blew up by
242        // tens of times here
243        assert!(
244            jitter_with_outlier < jitter_without_outlier * 3.0,
245            "a single outlier out of 300 samples should not blow up the jitter \
246             estimate this much: {jitter_without_outlier} -> {jitter_with_outlier}"
247        );
248    }
249
250    #[test]
251    fn box_test_detects_no_difference() {
252        let a: Vec<f64> = (0..1000)
253            .map(|i| 0.010 + (i as f64 % 7.0) * 0.0001)
254            .collect();
255        let b = a.clone();
256        let result = box_test(&a, &b, 10.0, 0.95);
257        assert!(
258            !result.is_significant(),
259            "identical samples must not be significant"
260        );
261    }
262
263    #[test]
264    fn box_test_detects_real_difference() {
265        let a: Vec<f64> = (0..2000)
266            .map(|i| 0.010 + (i as f64 % 11.0) * 0.0002)
267            .collect();
268        let b: Vec<f64> = (0..2000)
269            .map(|i| 0.010 + 0.0005 + (i as f64 % 11.0) * 0.0002)
270            .collect();
271        let result = box_test(&a, &b, 10.0, 0.95);
272        assert!(
273            result.is_significant(),
274            "clear 0.5ms shift must be detected"
275        );
276        assert!(result.estimated_leak > 0.0);
277    }
278}