Skip to main content

sidecheck_core/
stats.rs

1//! Статистическое ядро sidecheck.
2//!
3//! Методология основана на Crosby, Wallach, Riedi, "Opportunities and Limits
4//! of Remote Timing Attacks" (ACM TISSEC, 2009): сеть может только добавлять
5//! задержку, никогда не убирать её, поэтому нижние перцентили выборки несут
6//! значительно меньше шума, чем среднее или даже минимум по сырым данным.
7//! На этом строится "box test" — сравнение низких перцентилей двух выборок.
8
9/// Число итераций bootstrap resampling для доверительного интервала.
10/// Вынесено в константу, чтобы её можно было честно указать в отчёте —
11/// не просто "confidence: 95%", а явно "bootstrap confidence на N итерациях".
12pub const BOOTSTRAP_ITERATIONS: usize = 2000;
13
14use rand::Rng;
15
16/// Возвращает значение p-го перцентиля отсортированной выборки (p в [0.0, 100.0]).
17pub fn percentile(sorted: &[f64], p: f64) -> f64 {
18    assert!(!sorted.is_empty(), "empty sample");
19    let idx = (p / 100.0 * (sorted.len() - 1) as f64).round() as usize;
20    sorted[idx.min(sorted.len() - 1)]
21}
22
23fn sorted_copy(data: &[f64]) -> Vec<f64> {
24    let mut v = data.to_vec();
25    v.sort_by(|a, b| a.partial_cmp(b).unwrap());
26    v
27}
28
29/// Результат box test: разница между низкими перцентилями двух выборок
30/// плюс доверительный интервал, полученный бутстрэпом (без предположений
31/// о нормальности распределения сетевых задержек).
32#[derive(Debug, Clone)]
33pub struct BoxTestResult {
34    pub class_a_low_percentile: f64,
35    pub class_b_low_percentile: f64,
36    /// class_b - class_a, в тех же единицах, что и входные данные (секунды)
37    pub estimated_leak: f64,
38    pub ci_low: f64,
39    pub ci_high: f64,
40    pub confidence: f64,
41}
42
43impl BoxTestResult {
44    /// Утечка считается статистически значимой, если доверительный интервал
45    /// разницы не содержит нуля.
46    pub fn is_significant(&self) -> bool {
47        self.ci_low > 0.0 || self.ci_high < 0.0
48    }
49}
50
51/// Box test по методологии Crosby-Wallach: сравнивает низкий перцентиль
52/// (по умолчанию p10) двух выборок времени отклика, доверительный интервал
53/// строится через bootstrap resampling.
54pub fn box_test(
55    class_a: &[f64],
56    class_b: &[f64],
57    low_percentile: f64,
58    confidence: f64,
59) -> BoxTestResult {
60    let a_sorted = sorted_copy(class_a);
61    let b_sorted = sorted_copy(class_b);
62
63    let a_p = percentile(&a_sorted, low_percentile);
64    let b_p = percentile(&b_sorted, low_percentile);
65    let leak = b_p - a_p;
66
67    let (ci_low, ci_high) = bootstrap_ci(
68        class_a,
69        class_b,
70        low_percentile,
71        confidence,
72        BOOTSTRAP_ITERATIONS,
73    );
74
75    BoxTestResult {
76        class_a_low_percentile: a_p,
77        class_b_low_percentile: b_p,
78        estimated_leak: leak,
79        ci_low,
80        ci_high,
81        confidence,
82    }
83}
84
85/// Bootstrap-доверительный интервал для разницы низких перцентилей.
86/// Не полагается на нормальность — пересэмплирует исходные данные с
87/// возвращением и считает эмпирическое распределение разницы.
88fn bootstrap_ci(
89    class_a: &[f64],
90    class_b: &[f64],
91    p: f64,
92    confidence: f64,
93    iterations: usize,
94) -> (f64, f64) {
95    let mut rng = rand::thread_rng();
96    let mut diffs = Vec::with_capacity(iterations);
97
98    for _ in 0..iterations {
99        let resample_a = resample(class_a, &mut rng);
100        let resample_b = resample(class_b, &mut rng);
101        let pa = percentile(&sorted_copy(&resample_a), p);
102        let pb = percentile(&sorted_copy(&resample_b), p);
103        diffs.push(pb - pa);
104    }
105
106    diffs.sort_by(|a, b| a.partial_cmp(b).unwrap());
107    let alpha = 1.0 - confidence;
108    let lo_idx = ((alpha / 2.0) * diffs.len() as f64) as usize;
109    let hi_idx = (((1.0 - alpha / 2.0) * diffs.len() as f64) as usize).min(diffs.len() - 1);
110    (diffs[lo_idx], diffs[hi_idx])
111}
112
113fn resample(data: &[f64], rng: &mut impl Rng) -> Vec<f64> {
114    (0..data.len())
115        .map(|_| data[rng.gen_range(0..data.len())])
116        .collect()
117}
118
119/// Оценка джиттера сети по пилотной выборке. Раньше считалась как
120/// стандартное отклонение вокруг низкого перцентиля — но дисперсия
121/// (квадраты отклонений) крайне чувствительна к единичным выбросам
122/// (первый запрос после установки соединения, GC-пауза, шедулинг ОС):
123/// один медленный запрос из трёхсот мог задрать оценку в разы, из-за чего
124/// два независимых замера одного и того же канала расходились в разы.
125///
126/// MAD (median absolute deviation — медиана абсолютных отклонений от
127/// медианы) почти нечувствительна к единичным выбросам: чтобы сдвинуть
128/// медиану, нужно испортить больше половины выборки, а не один запрос.
129/// Множитель 1.4826 — стандартный коэффициент, делающий MAD согласованной
130/// оценкой стандартного отклонения для нормально распределённых данных.
131pub fn estimate_jitter(pilot: &[f64]) -> f64 {
132    if pilot.is_empty() {
133        return 0.0;
134    }
135    let sorted = sorted_copy(pilot);
136    let median = percentile(&sorted, 50.0);
137    let mut abs_deviations: Vec<f64> = pilot.iter().map(|x| (x - median).abs()).collect();
138    abs_deviations.sort_by(|a, b| a.partial_cmp(b).unwrap());
139    let mad = percentile(&abs_deviations, 50.0);
140    mad * 1.4826
141}
142
143/// Оценка минимального числа запросов на класс, необходимого для
144/// обнаружения утечки заданного размера при данном уровне шума сети.
145/// Формула из мощностного анализа (power analysis) для сравнения средних:
146/// n ≈ 2 * (z_alpha/2 + z_beta)^2 * sigma^2 / delta^2
147pub fn required_samples(jitter: f64, expected_leak_seconds: f64, confidence: f64) -> u64 {
148    if expected_leak_seconds <= 0.0 {
149        return u64::MAX;
150    }
151    // z-значения для двустороннего теста с confidence и мощностью 80% (z_beta ≈ 0.84)
152    let z_alpha = inverse_normal_cdf(1.0 - (1.0 - confidence) / 2.0);
153    let z_beta = 0.84;
154    let n = 2.0 * (z_alpha + z_beta).powi(2) * jitter.powi(2) / expected_leak_seconds.powi(2);
155    n.ceil() as u64
156}
157
158/// Приближение обратной функции нормального распределения (Beasley-Springer-Moro).
159/// Достаточно точное для оценки необходимого объёма выборки.
160fn inverse_normal_cdf(p: f64) -> f64 {
161    // Rational approximation, максимальная погрешность ~1.15e-9
162    let a = [
163        -3.969683028665376e+01,
164        2.209460984245205e+02,
165        -2.759285104469687e+02,
166        1.383_577_518_672_69e2,
167        -3.066479806614716e+01,
168        2.506628277459239e+00,
169    ];
170    let b = [
171        -5.447609879822406e+01,
172        1.615858368580409e+02,
173        -1.556989798598866e+02,
174        6.680131188771972e+01,
175        -1.328068155288572e+01,
176    ];
177    let c = [
178        -7.784894002430293e-03,
179        -3.223964580411365e-01,
180        -2.400758277161838e+00,
181        -2.549732539343734e+00,
182        4.374664141464968e+00,
183        2.938163982698783e+00,
184    ];
185    let d = [
186        7.784695709041462e-03,
187        3.224671290700398e-01,
188        2.445134137142996e+00,
189        3.754408661907416e+00,
190    ];
191    let p_low = 0.02425;
192    let p_high = 1.0 - p_low;
193
194    if p < p_low {
195        let q = (-2.0 * p.ln()).sqrt();
196        (((((c[0] * q + c[1]) * q + c[2]) * q + c[3]) * q + c[4]) * q + c[5])
197            / ((((d[0] * q + d[1]) * q + d[2]) * q + d[3]) * q + 1.0)
198    } else if p <= p_high {
199        let q = p - 0.5;
200        let r = q * q;
201        (((((a[0] * r + a[1]) * r + a[2]) * r + a[3]) * r + a[4]) * r + a[5]) * q
202            / (((((b[0] * r + b[1]) * r + b[2]) * r + b[3]) * r + b[4]) * r + 1.0)
203    } else {
204        let q = (-2.0 * (1.0 - p).ln()).sqrt();
205        -(((((c[0] * q + c[1]) * q + c[2]) * q + c[3]) * q + c[4]) * q + c[5])
206            / ((((d[0] * q + d[1]) * q + d[2]) * q + d[3]) * q + 1.0)
207    }
208}
209
210#[cfg(test)]
211mod tests {
212    use super::*;
213
214    #[test]
215    fn percentile_basic() {
216        let data = vec![1.0, 2.0, 3.0, 4.0, 5.0];
217        assert_eq!(percentile(&data, 0.0), 1.0);
218        assert_eq!(percentile(&data, 100.0), 5.0);
219        assert_eq!(percentile(&data, 50.0), 3.0);
220    }
221
222    #[test]
223    fn jitter_estimate_is_robust_to_a_single_outlier() {
224        // Регрессия на реальный найденный баг: два независимых замера одного
225        // и того же стабильного канала (doctor vs check pilot) разошлись в
226        // 5 раз, потому что старая (дисперсионная) оценка джиттера была
227        // непропорционально чувствительна к одному медленному запросу
228        // (например, первому после установки TCP-соединения).
229        let mut stable: Vec<f64> = (0..300)
230            .map(|i| 0.0002 + (i as f64 % 5.0) * 0.00001)
231            .collect();
232        let jitter_without_outlier = estimate_jitter(&stable);
233
234        // один-единственный запрос вдруг занял 50ms вместо ~0.2ms
235        stable[0] = 0.050;
236        let jitter_with_outlier = estimate_jitter(&stable);
237
238        // MAD не должна взлетать в разы от одного выброса на 300 сэмплов —
239        // старая дисперсионная реализация здесь давала рост в десятки раз
240        assert!(
241            jitter_with_outlier < jitter_without_outlier * 3.0,
242            "a single outlier out of 300 samples should not blow up the jitter \
243             estimate this much: {jitter_without_outlier} -> {jitter_with_outlier}"
244        );
245    }
246
247    #[test]
248    fn box_test_detects_no_difference() {
249        let a: Vec<f64> = (0..1000)
250            .map(|i| 0.010 + (i as f64 % 7.0) * 0.0001)
251            .collect();
252        let b = a.clone();
253        let result = box_test(&a, &b, 10.0, 0.95);
254        assert!(
255            !result.is_significant(),
256            "identical samples must not be significant"
257        );
258    }
259
260    #[test]
261    fn box_test_detects_real_difference() {
262        let a: Vec<f64> = (0..2000)
263            .map(|i| 0.010 + (i as f64 % 11.0) * 0.0002)
264            .collect();
265        let b: Vec<f64> = (0..2000)
266            .map(|i| 0.010 + 0.0005 + (i as f64 % 11.0) * 0.0002)
267            .collect();
268        let result = box_test(&a, &b, 10.0, 0.95);
269        assert!(
270            result.is_significant(),
271            "clear 0.5ms shift must be detected"
272        );
273        assert!(result.estimated_leak > 0.0);
274    }
275}