Skip to main content

hpx_browser/stealth/
behavior.rs

1//! Behavioral emulation — mouse trajectory (sigma-lognormal), keyboard
2//! dynamics, and scroll simulation.
3
4use serde::{Deserialize, Serialize};
5
6// ── Behavioral enums and profile ─────────────────────────────────────
7
8/// Right-handers overshoot bottom-right; left-handers bottom-left.
9#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
10pub enum Handedness {
11    Right,
12    Left,
13}
14
15/// Trackpad momentum vs discrete mouse-wheel notches.
16#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
17pub enum ScrollStyle {
18    Trackpad,
19    Wheel,
20}
21
22/// Per-session behavioral parameters. Different sessions should sample
23/// fresh seeds so mouse/keyboard patterns don't repeat across visits.
24#[derive(Debug, Clone, Serialize, Deserialize)]
25pub struct BehaviorProfile {
26    #[serde(default = "default_behavior_seed")]
27    pub seed: u64,
28    #[serde(default = "default_handedness")]
29    pub handedness: Handedness,
30    #[serde(default = "default_mouse_dpi")]
31    pub mouse_dpi: u16,
32    #[serde(default = "default_typing_wpm_mean")]
33    pub typing_wpm_mean: f32,
34    #[serde(default = "default_typing_wpm_sigma")]
35    pub typing_wpm_sigma: f32,
36    #[serde(default = "default_scroll_style")]
37    pub scroll_style: ScrollStyle,
38    #[serde(default = "default_fitts_b")]
39    pub fitts_b: f32,
40}
41
42fn default_behavior_seed() -> u64 {
43    rand::random::<u64>()
44}
45fn default_handedness() -> Handedness {
46    Handedness::Right
47}
48fn default_mouse_dpi() -> u16 {
49    1600
50}
51fn default_typing_wpm_mean() -> f32 {
52    50.0
53}
54fn default_typing_wpm_sigma() -> f32 {
55    15.0
56}
57fn default_scroll_style() -> ScrollStyle {
58    ScrollStyle::Trackpad
59}
60fn default_fitts_b() -> f32 {
61    166.0
62}
63
64impl Default for BehaviorProfile {
65    fn default() -> Self {
66        Self {
67            seed: default_behavior_seed(),
68            handedness: default_handedness(),
69            mouse_dpi: default_mouse_dpi(),
70            typing_wpm_mean: default_typing_wpm_mean(),
71            typing_wpm_sigma: default_typing_wpm_sigma(),
72            scroll_style: default_scroll_style(),
73            fitts_b: default_fitts_b(),
74        }
75    }
76}
77
78impl BehaviorProfile {
79    /// Derive a deterministic sub-RNG for a specific call site.
80    pub fn rng_for(&self, salt: u64) -> rand_chacha::ChaCha20Rng {
81        use rand_chacha::rand_core::SeedableRng;
82        let combined = self
83            .seed
84            .wrapping_mul(0x9E3779B97F4A7C15)
85            .wrapping_add(salt);
86        rand_chacha::ChaCha20Rng::seed_from_u64(combined)
87    }
88}
89
90/// One sample point on a humanized mouse trajectory.
91#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
92pub struct MousePoint {
93    pub t_ms: f32,
94    pub x: f32,
95    pub y: f32,
96}
97
98/// Keystroke timing for one character.
99#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
100pub struct KeystrokeTiming {
101    pub ch: char,
102    pub dwell_ms: f32,
103    pub flight_ms: f32,
104}
105
106/// A single scroll wheel tick.
107#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
108pub struct WheelTick {
109    pub t_ms: f32,
110    pub delta_y: f32,
111    pub mode: u32,
112}
113
114// ── Mouse trajectory (Sigma-Lognormal — Plamondon 1995) ─────────────
115
116struct Stroke {
117    amplitude: f32,
118    sigma: f32,
119    mu: f32,
120    t0: f32,
121    theta: f32,
122}
123
124fn integrate_x(strokes: &[Stroke], t: f32) -> f32 {
125    strokes
126        .iter()
127        .map(|s| {
128            let dt = t - s.t0;
129            if dt <= 0.0 {
130                return 0.0;
131            }
132            let z = (dt.ln() - s.mu) / (s.sigma * std::f32::consts::SQRT_2);
133            let cdf = 0.5 * (1.0 + erf(z));
134            s.amplitude * cdf * s.theta.cos()
135        })
136        .sum()
137}
138
139fn integrate_y(strokes: &[Stroke], t: f32) -> f32 {
140    strokes
141        .iter()
142        .map(|s| {
143            let dt = t - s.t0;
144            if dt <= 0.0 {
145                return 0.0;
146            }
147            let z = (dt.ln() - s.mu) / (s.sigma * std::f32::consts::SQRT_2);
148            let cdf = 0.5 * (1.0 + erf(z));
149            s.amplitude * cdf * s.theta.sin()
150        })
151        .sum()
152}
153
154/// Abramowitz-Stegun 7.1.26 erf approximation (|err| < 1.5e-7).
155fn erf(x: f32) -> f32 {
156    let sign = x.signum();
157    let x = x.abs();
158    let a1 = 0.254_829_6;
159    let a2 = -0.284_496_72;
160    let a3 = 1.421_413_8;
161    let a4 = -1.453_152_1;
162    let a5 = 1.061_405_4;
163    let p = 0.3275911;
164    let t = 1.0 / (1.0 + p * x);
165    let y = 1.0 - (((((a5 * t + a4) * t) + a3) * t + a2) * t + a1) * t * (-x * x).exp();
166    sign * y
167}
168
169/// Generate a humanlike mouse trajectory from `from` to `to`.
170pub fn mouse_trajectory(
171    from: (f32, f32),
172    to: (f32, f32),
173    target_w: f32,
174    profile: &BehaviorProfile,
175) -> Vec<MousePoint> {
176    let mut rng = profile
177        .rng_for(((from.0 as u64) << 32) | (from.1 as u64) ^ ((to.0 as u64) << 16) ^ (to.1 as u64));
178    mouse_trajectory_with_rng(from, to, target_w, profile, &mut rng)
179}
180
181/// Same as `mouse_trajectory` but takes an explicit RNG for testing.
182pub fn mouse_trajectory_with_rng<R: rand::Rng>(
183    from: (f32, f32),
184    to: (f32, f32),
185    target_w: f32,
186    profile: &BehaviorProfile,
187    rng: &mut R,
188) -> Vec<MousePoint> {
189    use rand_distr::{Distribution, LogNormal, Normal};
190
191    let dx = to.0 - from.0;
192    let dy = to.1 - from.1;
193    let distance = (dx * dx + dy * dy).sqrt().max(1.0);
194    let target_w = target_w.max(1.0);
195
196    let id_bits = ((distance / target_w) + 1.0).log2();
197    let n_strokes = ((1.3 * id_bits).round() as usize).clamp(2, 7);
198
199    let total_ms = 230.0 + profile.fitts_b * id_bits;
200
201    let mut amplitudes: Vec<f32> = Vec::with_capacity(n_strokes);
202    let primary = 0.85 * distance;
203    amplitudes.push(primary);
204    let remaining = distance - primary;
205    let per_corrective = remaining / (n_strokes - 1).max(1) as f32;
206    for _ in 1..n_strokes {
207        let jitter: f32 = Normal::new(0.0_f32, per_corrective * 0.15)
208            .ok()
209            .map_or(0.0, |d| d.sample(rng));
210        amplitudes.push((per_corrective + jitter).max(1.0));
211    }
212
213    let sigma_dist = Normal::new(0.25_f32, 0.05).ok();
214    let mu_dist = Normal::new(-1.6_f32, 0.2).ok();
215    let onset_dist = LogNormal::new(90.0_f32.ln(), 0.3).ok();
216    let theta_dist = Normal::new(0.0_f32, 8.0_f32.to_radians()).ok();
217
218    let target_angle = dy.atan2(dx);
219    let mut strokes: Vec<Stroke> = Vec::with_capacity(n_strokes);
220    let mut t0 = 0.0_f32;
221    for (i, amp) in amplitudes.iter().enumerate() {
222        let sigma = sigma_dist
223            .as_ref()
224            .map_or(0.25, |d| d.sample(rng).clamp(0.15, 0.40));
225        let mu = mu_dist.as_ref().map_or(-1.6, |d| d.sample(rng));
226        let jitter = theta_dist.as_ref().map_or(0.0, |d| d.sample(rng));
227        let theta = if i == 0 {
228            target_angle + jitter
229        } else {
230            target_angle + jitter * 1.5
231        };
232        strokes.push(Stroke {
233            amplitude: *amp,
234            sigma,
235            mu,
236            t0,
237            theta,
238        });
239        t0 += onset_dist.as_ref().map_or(90.0, |d| d.sample(rng));
240    }
241
242    let dt_ms = 8.0_f32;
243    let n_samples = (total_ms / dt_ms).ceil() as usize + 1;
244    let mut points: Vec<MousePoint> = Vec::with_capacity(n_samples);
245
246    let tremor_dist = Normal::new(0.0_f32, 1.5).ok();
247    let mut tremor_x = 0.0_f32;
248    let mut tremor_y = 0.0_f32;
249    let tremor_alpha = 0.3_f32;
250
251    for i in 0..n_samples {
252        let t = (i as f32) * dt_ms;
253
254        let tx = tremor_dist.as_ref().map_or(0.0, |d| d.sample(rng));
255        let ty = tremor_dist.as_ref().map_or(0.0, |d| d.sample(rng));
256        tremor_x = tremor_alpha * tremor_x + (1.0 - tremor_alpha) * tx;
257        tremor_y = tremor_alpha * tremor_y + (1.0 - tremor_alpha) * ty;
258
259        let x = from.0 + integrate_x(&strokes, t) + tremor_x;
260        let y = from.1 + integrate_y(&strokes, t) + tremor_y;
261        points.push(MousePoint { t_ms: t, x, y });
262    }
263
264    // Smooth endpoint correction via smoothstep tail.
265    if points.len() >= 2 {
266        let n = points.len();
267        let last = &points[n - 1];
268        let res_x = to.0 - last.x;
269        let res_y = to.1 - last.y;
270        let tail = 15.min(n - 1);
271        let start = n - tail - 1;
272        for (k, p) in points.iter_mut().enumerate().skip(start) {
273            let u = (k - start) as f32 / tail as f32;
274            let s = u * u * (3.0 - 2.0 * u);
275            p.x += res_x * s;
276            p.y += res_y * s;
277        }
278        if let Some(last) = points.last_mut() {
279            last.x = to.0;
280            last.y = to.1;
281        }
282    } else if let Some(last) = points.last_mut() {
283        last.x = to.0;
284        last.y = to.1;
285    }
286    points
287}
288
289// ── Keystroke dynamics ──────────────────────────────────────────────
290
291fn bigram_ratio(prev: char, cur: char) -> f32 {
292    let key = (
293        prev.to_ascii_lowercase() as u8,
294        cur.to_ascii_lowercase() as u8,
295    );
296    match key {
297        (b't', b'h')
298        | (b'h', b'e')
299        | (b'i', b'n')
300        | (b'a', b'n')
301        | (b'o', b'n')
302        | (b'a', b't')
303        | (b'i', b's')
304        | (b'i', b't')
305        | (b'o', b'r')
306        | (b'o', b'f') => 0.7,
307        (b'e', b'd')
308        | (b'u', b'n')
309        | (b'r', b'e')
310        | (b'e', b'r')
311        | (b'e', b'n')
312        | (b'n', b'd')
313        | (b'e', b's')
314        | (b't', b'e')
315        | (b'a', b'l')
316        | (b'a', b'r') => 1.4,
317        (a, b) if a == b => 2.0,
318        _ => 1.0,
319    }
320}
321
322/// Generate keystroke timings for a string.
323pub fn keystroke_timings(text: &str, profile: &BehaviorProfile) -> Vec<KeystrokeTiming> {
324    let mut rng = profile.rng_for(0xCAFEBABE ^ text.len() as u64);
325    keystroke_timings_with_rng(text, profile, &mut rng)
326}
327
328/// Same as `keystroke_timings` but takes an explicit RNG for testing.
329pub fn keystroke_timings_with_rng<R: rand::Rng>(
330    text: &str,
331    profile: &BehaviorProfile,
332    rng: &mut R,
333) -> Vec<KeystrokeTiming> {
334    use rand_distr::{Distribution, LogNormal};
335
336    let ms_per_char = 60_000.0 / (profile.typing_wpm_mean * 5.0);
337    let flight_median = (ms_per_char - 95.0).max(40.0);
338    let flight_dist = LogNormal::new(flight_median.ln(), 0.55).ok();
339    let dwell_dist = LogNormal::new(95.0_f32.ln(), 0.30).ok();
340
341    let mut out = Vec::with_capacity(text.len());
342    let mut prev_ch: Option<char> = None;
343    for ch in text.chars() {
344        let dwell = dwell_dist
345            .as_ref()
346            .map_or(95.0, |d| d.sample(rng).clamp(40.0, 400.0));
347        let flight = if let Some(p) = prev_ch {
348            let ratio = bigram_ratio(p, ch);
349            flight_dist
350                .as_ref()
351                .map_or(130.0, |d| (d.sample(rng) * ratio).clamp(20.0, 1000.0))
352        } else {
353            0.0
354        };
355        out.push(KeystrokeTiming {
356            ch,
357            dwell_ms: dwell,
358            flight_ms: flight,
359        });
360        prev_ch = Some(ch);
361    }
362    out
363}
364
365// ── Scroll bursts ───────────────────────────────────────────────────
366
367/// Generate a humanlike scroll burst totaling ~`target_dy` pixels.
368pub fn wheel_burst(target_dy: f32, profile: &BehaviorProfile) -> Vec<WheelTick> {
369    let mut rng = profile.rng_for(0xDEAD_BEEF ^ target_dy.to_bits() as u64);
370    wheel_burst_with_rng(target_dy, profile, &mut rng)
371}
372
373/// Same as `wheel_burst` but takes an explicit RNG for testing.
374pub fn wheel_burst_with_rng<R: rand::RngExt>(
375    target_dy: f32,
376    profile: &BehaviorProfile,
377    rng: &mut R,
378) -> Vec<WheelTick> {
379    use rand_distr::{Distribution, LogNormal};
380
381    let dir = if target_dy >= 0.0 { 1.0 } else { -1.0 };
382    let abs_dy = target_dy.abs().max(1.0);
383
384    match profile.scroll_style {
385        ScrollStyle::Trackpad => {
386            let v0 = LogNormal::new((abs_dy / 8.0).ln(), 0.3)
387                .ok()
388                .map_or(abs_dy / 8.0, |d| d.sample(rng));
389            let decay = 0.94 + rng.random_range(0.0_f32..0.04);
390            let mut t = 0.0_f32;
391            let mut v = v0;
392            let mut ticks = Vec::new();
393            let mut accumulated = 0.0_f32;
394            while v > 0.5 && accumulated < abs_dy * 1.1 {
395                let step = (v.min(abs_dy - accumulated)).max(0.5);
396                ticks.push(WheelTick {
397                    t_ms: t,
398                    delta_y: step * dir,
399                    mode: 0,
400                });
401                accumulated += step;
402                t += 16.0;
403                v *= decay;
404            }
405            ticks
406        }
407        ScrollStyle::Wheel => {
408            let notches = ((abs_dy / 100.0).round() as u32).max(1);
409            let interval_dist = LogNormal::new(180.0_f32.ln(), 0.4).ok();
410            let mut t = 0.0_f32;
411            let mut ticks = Vec::with_capacity(notches as usize);
412            for _ in 0..notches {
413                ticks.push(WheelTick {
414                    t_ms: t,
415                    delta_y: 100.0 * dir,
416                    mode: 0,
417                });
418                t += interval_dist.as_ref().map_or(180.0, |d| d.sample(rng));
419            }
420            ticks
421        }
422    }
423}