Skip to main content

sidecheck_core/
sampler.rs

1//! Collects raw response-time measurements.
2//!
3//! Key methodology requirement: request classes (e.g. "correct prefix" /
4//! "wrong prefix") must be interleaved in random order, not sent block by
5//! block ("all A, then all B") — otherwise server warm-up, background
6//! load, or network drift over time bias the result, not the leak itself.
7//! See the dudect / Crosby-Wallach methodology.
8
9use anyhow::{Context, Result};
10use rand::seq::SliceRandom;
11use rand::Rng;
12use std::time::Instant;
13
14/// Where the test value gets injected. Header is the most common case for
15/// API keys, Query for legacy endpoints with a token in the URL, JsonBody
16/// for typical JSON logins (POST /login {"password": "..."}).
17#[derive(Clone, Debug)]
18pub enum InjectionPoint {
19    Header(String),
20    Query(String),
21    /// Name of the field in the JSON request body, plus an optional
22    /// template for the rest of the fields (e.g. {"username": "admin"})
23    /// the backend needs to even reach the secret comparison. If no
24    /// template is given, the body is just this one field, as before.
25    JsonBody {
26        field: String,
27        template: Option<serde_json::Map<String, serde_json::Value>>,
28    },
29}
30
31impl InjectionPoint {
32    pub fn describe(&self) -> String {
33        match self {
34            InjectionPoint::Header(n) => format!("header {n}"),
35            InjectionPoint::Query(n) => format!("query param {n}"),
36            InjectionPoint::JsonBody { field, template } => match template {
37                Some(_) => format!("JSON field {field} (with body template)"),
38                None => format!("JSON field {field}"),
39            },
40        }
41    }
42}
43
44/// HTTP target: the URL, the point where the test value gets injected,
45/// and any static headers sent unchanged on every request (auth headers,
46/// CSRF tokens, session cookies — whatever the endpoint needs to reach
47/// the code path under test at all, distinct from the value being
48/// injected and measured).
49pub struct HttpTarget {
50    client: reqwest::blocking::Client,
51    url: String,
52    injection: InjectionPoint,
53    extra_headers: Vec<(String, String)>,
54}
55
56impl HttpTarget {
57    pub fn new(url: impl Into<String>, injection: InjectionPoint) -> Result<Self> {
58        Self::new_with_options(url, injection, false, Vec::new())
59    }
60
61    pub fn new_with_options(
62        url: impl Into<String>,
63        injection: InjectionPoint,
64        accept_invalid_certs: bool,
65        extra_headers: Vec<(String, String)>,
66    ) -> Result<Self> {
67        let client = reqwest::blocking::Client::builder()
68            .timeout(std::time::Duration::from_secs(10))
69            // important: keep the TCP keep-alive pool small, otherwise the
70            // first request in each class is slower due to connection setup
71            .pool_max_idle_per_host(4)
72            .danger_accept_invalid_certs(accept_invalid_certs)
73            .build()
74            .context("failed to build HTTP client")?;
75        Ok(Self {
76            client,
77            url: url.into(),
78            injection,
79            extra_headers,
80        })
81    }
82
83    /// One measurement: sends a request with the given value at the
84    /// configured injection point, returns the time to receive the full
85    /// response, in seconds.
86    pub fn measure(&self, value: &str) -> Result<f64> {
87        let start = Instant::now();
88
89        let builder = match &self.injection {
90            InjectionPoint::Header(name) => self.client.get(&self.url).header(name.as_str(), value),
91            InjectionPoint::Query(name) => {
92                self.client.get(&self.url).query(&[(name.as_str(), value)])
93            }
94            InjectionPoint::JsonBody { field, template } => {
95                let mut body = template.clone().unwrap_or_default();
96                body.insert(field.clone(), serde_json::Value::String(value.to_string()));
97                self.client
98                    .post(&self.url)
99                    .json(&serde_json::Value::Object(body))
100            }
101        };
102
103        let builder = self
104            .extra_headers
105            .iter()
106            .fold(builder, |b, (name, value)| b.header(name, value));
107
108        let resp = builder.send().context("request failed")?;
109
110        // important to read the body fully — otherwise the measurement
111        // doesn't include the full response time
112        let _ = resp.bytes().context("failed to read response body")?;
113        Ok(start.elapsed().as_secs_f64())
114    }
115}
116
117/// Generates a deliberately wrong value of the same length as the real
118/// secret — so payload length isn't a separate variable skewing the
119/// measurement (see the CLI warning about mismatched value_a/value_b
120/// length). Takes an external RNG so the whole run is reproducible from
121/// one seed.
122pub fn random_wrong_value(secret: &str, rng: &mut impl Rng) -> String {
123    const CHARSET: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
124    loop {
125        let candidate: String = (0..secret.len())
126            .map(|_| CHARSET[rng.gen_range(0..CHARSET.len())] as char)
127            .collect();
128        if candidate != secret {
129            return candidate;
130        }
131    }
132}
133
134#[derive(Debug, Default)]
135pub struct RawSamples {
136    pub class_a: Vec<f64>,
137    pub class_b: Vec<f64>,
138    /// number of requests that couldn't be completed (timeout, connection
139    /// reset, etc.) — not counted in the statistics, but if there are many
140    /// of them, the result can't be trusted
141    pub failures: usize,
142}
143
144/// Runs n_per_class measurements for each class, interleaving them in
145/// random blocks to average out drift over time. Isolated network
146/// failures don't abort the whole run — they're counted and reported
147/// separately, but if the failure ratio exceeds max_failure_ratio, the run
148/// stops: timings can't be trusted over such an unstable channel.
149pub fn run_interleaved(
150    target: &HttpTarget,
151    value_a: &str,
152    value_b: &str,
153    n_per_class: usize,
154    block_size: usize,
155    rng: &mut impl Rng,
156    mut on_progress: impl FnMut(usize, usize),
157) -> Result<RawSamples> {
158    const MAX_FAILURE_RATIO: f64 = 0.1;
159
160    let mut result = RawSamples::default();
161    let mut remaining_a = n_per_class;
162    let mut remaining_b = n_per_class;
163    let total = n_per_class * 2;
164    let mut done = 0;
165
166    while remaining_a > 0 || remaining_b > 0 {
167        let mut block: Vec<bool> = Vec::new(); // true = class A
168        block.extend(std::iter::repeat_n(true, block_size.min(remaining_a)));
169        block.extend(std::iter::repeat_n(false, block_size.min(remaining_b)));
170        block.shuffle(rng);
171
172        for is_a in block {
173            let measurement = if is_a {
174                remaining_a -= 1;
175                target.measure(value_a)
176            } else {
177                remaining_b -= 1;
178                target.measure(value_b)
179            };
180
181            match measurement {
182                Ok(elapsed) => {
183                    if is_a {
184                        result.class_a.push(elapsed);
185                    } else {
186                        result.class_b.push(elapsed);
187                    }
188                }
189                Err(_) => {
190                    result.failures += 1;
191                }
192            }
193
194            done += 1;
195            on_progress(done, total);
196
197            let attempted = result.class_a.len() + result.class_b.len() + result.failures;
198            if attempted > 100 && (result.failures as f64 / attempted as f64) > MAX_FAILURE_RATIO {
199                anyhow::bail!(
200                    "aborting: {} of {} requests failed ({}%). the target or network is too \
201                     unstable for a reliable measurement — fix connectivity first.",
202                    result.failures,
203                    attempted,
204                    (result.failures as f64 / attempted as f64 * 100.0) as u32
205                );
206            }
207        }
208    }
209
210    Ok(result)
211}
212
213/// Result of a plain measurement pass for `sidecheck doctor` — no class
214/// split, we only care about the shape of the RTT distribution to the
215/// target.
216#[derive(Debug, Default)]
217pub struct PlainSamples {
218    pub latencies: Vec<f64>,
219    pub failures: usize,
220}
221
222/// Collects n consecutive measurements of the same request. Unlike
223/// run_interleaved, it doesn't abort on packet loss — it just counts it:
224/// in doctor mode, the loss rate itself is part of the diagnosis, not a
225/// reason to stop.
226pub fn collect_plain(
227    target: &HttpTarget,
228    value: &str,
229    n: usize,
230    mut on_progress: impl FnMut(usize, usize),
231) -> PlainSamples {
232    let mut result = PlainSamples::default();
233    for i in 0..n {
234        match target.measure(value) {
235            Ok(elapsed) => result.latencies.push(elapsed),
236            Err(_) => result.failures += 1,
237        }
238        on_progress(i + 1, n);
239    }
240    result
241}