1use chrono::{DateTime, TimeDelta, Utc};
4
5use crate::{
6 checks::{Check, Checks, Decision, Thresholds},
7 fingerprint::{Baseline, Fingerprint, os_compatible},
8};
9
10#[derive(Clone, Debug, Default, PartialEq, Eq)]
12#[non_exhaustive]
13pub struct Assessment {
14 pub score: u32,
16 pub signals: Vec<Check>,
18}
19
20impl Assessment {
21 pub fn decision(&self, thresholds: &Thresholds) -> Decision {
23 thresholds.decide(self.score)
24 }
25
26 pub(crate) fn add(&mut self, check: Check, checks: &Checks) {
29 if let Some(weight) = checks.weight_of(check)
30 && !self.signals.contains(&check)
31 {
32 let at = self.signals.partition_point(|c| *c < check);
33 self.signals.insert(at, check);
34 self.score = self.score.saturating_add(weight);
35 }
36 }
37}
38
39pub fn assess(
44 baseline: &Baseline,
45 current: &Fingerprint,
46 now: DateTime<Utc>,
47 ping_pong_window: TimeDelta,
48 checks: &Checks,
49) -> Assessment {
50 let known = &baseline.fingerprint;
51 let mut fired = Vec::with_capacity(4);
52
53 if let (Some(a), Some(b)) = (&known.os, ¤t.os)
54 && !os_compatible(a, b)
55 {
56 fired.push(Check::OsChanged);
57 }
58
59 if let (Some(a), Some(b)) = (&known.browser, ¤t.browser) {
60 if a != b {
61 fired.push(Check::BrowserChanged);
62 } else if let (Some(a), Some(b)) = (known.browser_major, current.browser_major)
63 && b < a
64 {
65 fired.push(Check::BrowserDowngraded);
66 }
67 }
68
69 if lost(known.browser.as_ref(), current.browser.as_ref()) || lost(known.os.as_ref(), current.os.as_ref()) {
70 fired.push(Check::UserAgentMissing);
71 }
72
73 if let (Some(a), Some(b)) = (&known.ch_platform, ¤t.ch_platform)
74 && !os_compatible(a, b)
75 {
76 fired.push(Check::PlatformHintChanged);
77 }
78
79 if lost(known.ch_platform.as_ref(), current.ch_platform.as_ref())
80 || lost(known.ch_mobile.as_ref(), current.ch_mobile.as_ref())
81 {
82 fired.push(Check::ClientHintsMissing);
83 }
84
85 if matches!((known.ch_mobile, current.ch_mobile), (Some(a), Some(b)) if a != b) {
86 fired.push(Check::MobileHintChanged);
87 }
88
89 if let (Some(platform), Some(os)) = (¤t.ch_platform, ¤t.os)
90 && !os_compatible(platform, os)
91 {
92 fired.push(Check::InconsistentHints);
93 }
94
95 if known.ip_prefix != current.ip_prefix {
96 if differs(known.country.as_ref(), current.country.as_ref()) {
97 fired.push(Check::CountryChanged);
98 } else if differs(known.asn.as_ref(), current.asn.as_ref()) {
99 fired.push(Check::AsnChanged);
100 } else {
101 fired.push(Check::IpPrefixChanged);
102 }
103
104 let returned = baseline.previous_ip_prefix.as_deref() == Some(current.ip_prefix.as_str());
105 let recent = baseline
106 .ip_changed_at
107 .is_some_and(|at| now.signed_duration_since(at) <= ping_pong_window);
108 if returned && recent {
109 fired.push(Check::IpPingPong);
110 }
111 }
112
113 let version_changed = matches!((known.browser_major, current.browser_major), (Some(a), Some(b)) if a != b);
114 if !version_changed && differs(known.tls.as_ref(), current.tls.as_ref()) {
115 fired.push(Check::TlsChanged);
116 }
117
118 if differs(known.accept_language.as_ref(), current.accept_language.as_ref()) {
119 fired.push(Check::AcceptLanguageChanged);
120 }
121
122 let mut signals = Vec::with_capacity(fired.len());
123 let mut score = 0u32;
124 for check in Check::ALL {
125 if fired.contains(&check)
126 && let Some(weight) = checks.weight_of(check)
127 {
128 signals.push(check);
129 score = score.saturating_add(weight);
130 }
131 }
132 Assessment { score, signals }
133}
134
135fn differs<T: PartialEq>(known: Option<&T>, current: Option<&T>) -> bool {
137 match (known, current) {
138 (Some(a), Some(b)) => a != b,
139 (Some(_), None) => true,
140 (None, _) => false,
141 }
142}
143
144fn lost<T>(known: Option<&T>, current: Option<&T>) -> bool {
145 known.is_some() && current.is_none()
146}
147
148#[cfg(test)]
149mod tests {
150 use proptest::prelude::*;
151
152 use super::*;
153
154 fn chrome_on_windows() -> Fingerprint {
155 Fingerprint {
156 ip_prefix: "203.0.113.0/24".to_owned(),
157 country: Some("BE".to_owned()),
158 asn: Some(5432),
159 browser: Some("Chrome".to_owned()),
160 browser_major: Some(141),
161 os: Some("Windows".to_owned()),
162 ch_platform: Some("Windows".to_owned()),
163 ch_mobile: Some(false),
164 accept_language: Some("nl-be,nl,en".to_owned()),
165 tls: Some("t13d1516h2_8daaf6152771_02713d6af862".to_owned()),
166 }
167 }
168
169 fn assess_now(baseline: &Baseline, current: &Fingerprint) -> Assessment {
170 assess(baseline, current, Utc::now(), TimeDelta::minutes(5), &Checks::default())
171 }
172
173 fn decide(current: &Fingerprint) -> (Decision, Vec<Check>) {
174 let a = assess_now(&Baseline::new(chrome_on_windows()), current);
175 (a.decision(&Thresholds::default()), a.signals)
176 }
177
178 #[test]
179 fn added_signals_keep_check_order_and_respect_weights() {
180 let mut current = chrome_on_windows();
181 current.ip_prefix = "198.51.100.0/24".to_owned();
182 current.asn = Some(6848);
183 current.accept_language = Some("en".to_owned());
184 let mut a = assess_now(&Baseline::new(chrome_on_windows()), ¤t);
185 a.add(Check::OrphanedToken, &Checks::default());
186 assert_eq!(
187 a.signals,
188 vec![Check::OrphanedToken, Check::AsnChanged, Check::AcceptLanguageChanged]
189 );
190 assert_eq!(a.score, 25 + 20 + 15);
191
192 let mut a = Assessment::default();
193 a.add(Check::OrphanedToken, &Checks::none());
194 assert_eq!(a, Assessment::default());
195 }
196
197 #[test]
198 fn identical_request_is_allowed() {
199 assert_eq!(decide(&chrome_on_windows()), (Decision::Allow, vec![]));
200 }
201
202 #[test]
205 fn network_switch_within_same_provider_is_allowed() {
206 let mut current = chrome_on_windows();
207 current.ip_prefix = "203.0.200.0/24".to_owned();
208 assert_eq!(decide(¤t), (Decision::Allow, vec![Check::IpPrefixChanged]));
209 }
210
211 #[test]
212 fn wifi_to_mobile_data_is_allowed() {
213 let mut current = chrome_on_windows();
214 current.ip_prefix = "198.51.100.0/24".to_owned();
215 current.asn = Some(6848);
216 assert_eq!(decide(¤t), (Decision::Allow, vec![Check::AsnChanged]));
217 }
218
219 #[test]
220 fn browser_update_is_allowed_even_though_tls_changes() {
221 let mut current = chrome_on_windows();
222 current.browser_major = Some(142);
223 current.tls = Some("t13d1517h2_8daaf6152771_b0da82dd1658".to_owned());
224 assert_eq!(decide(¤t), (Decision::Allow, vec![]));
225 }
226
227 #[test]
228 fn language_tweak_is_allowed() {
229 let mut current = chrome_on_windows();
230 current.accept_language = Some("en".to_owned());
231 assert_eq!(decide(¤t).0, Decision::Allow);
232 }
233
234 #[test]
235 fn android_desktop_mode_toggle_steps_up_not_revokes() {
236 let mut fp = chrome_on_windows();
237 fp.os = Some("Android".to_owned());
238 fp.ch_platform = Some("Android".to_owned());
239 fp.ch_mobile = Some(true);
240 let mut current = fp.clone();
241 current.os = Some("Linux".to_owned());
242 current.ch_mobile = Some(false);
243 let a = assess_now(&Baseline::new(fp), ¤t);
244 assert_eq!(a.signals, vec![Check::MobileHintChanged]);
245 assert_eq!(a.decision(&Thresholds::default()), Decision::StepUp);
246 }
247
248 #[test]
249 fn travel_abroad_steps_up_not_revokes() {
250 let mut current = chrome_on_windows();
251 current.ip_prefix = "192.0.2.0/24".to_owned();
252 current.country = Some("FR".to_owned());
253 current.asn = Some(3215);
254 assert_eq!(decide(¤t), (Decision::StepUp, vec![Check::CountryChanged]));
255 }
256
257 #[test]
258 fn baseline_without_hints_ignores_hints() {
259 let mut firefox = chrome_on_windows();
260 firefox.browser = Some("Firefox".to_owned());
261 firefox.ch_platform = None;
262 firefox.ch_mobile = None;
263 let a = assess_now(&Baseline::new(firefox.clone()), &firefox);
264 assert_eq!(a.score, 0);
265 }
266
267 #[test]
270 fn different_operating_system_is_revoked() {
271 let mut current = chrome_on_windows();
272 current.os = Some("macOS".to_owned());
273 current.ch_platform = Some("macOS".to_owned());
274 assert_eq!(decide(¤t).0, Decision::Revoke);
275 }
276
277 #[test]
278 fn different_browser_is_revoked() {
279 let mut current = chrome_on_windows();
280 current.browser = Some("Firefox".to_owned());
281 current.ch_platform = None;
282 current.ch_mobile = None;
283 assert_eq!(decide(¤t).0, Decision::Revoke);
284 }
285
286 #[test]
287 fn empty_user_agent_cannot_bypass_checks() {
288 let mut current = chrome_on_windows();
289 current.browser = None;
290 current.browser_major = None;
291 current.os = None;
292 current.ch_platform = None;
293 current.ch_mobile = None;
294 let (decision, signals) = decide(¤t);
295 assert!(signals.contains(&Check::UserAgentMissing));
296 assert!(signals.contains(&Check::ClientHintsMissing));
297 assert_eq!(decision, Decision::Revoke);
298 }
299
300 #[test]
301 fn dropping_only_the_mobile_hint_is_noticed() {
302 let mut current = chrome_on_windows();
303 current.ch_mobile = None;
304 assert_eq!(decide(¤t).1, vec![Check::ClientHintsMissing]);
305 }
306
307 #[test]
308 fn stripping_soft_headers_never_lowers_the_score() {
309 let mut current = chrome_on_windows();
310 current.accept_language = None;
311 current.tls = None;
312 assert_eq!(
313 decide(¤t).1,
314 vec![Check::TlsChanged, Check::AcceptLanguageChanged]
315 );
316
317 let mut current = chrome_on_windows();
318 current.ip_prefix = "198.51.100.0/24".to_owned();
319 current.country = None;
320 assert_eq!(decide(¤t).1, vec![Check::CountryChanged]);
321 }
322
323 #[test]
324 fn copied_user_agent_from_script_in_same_country_is_revoked() {
325 let mut current = chrome_on_windows();
326 current.ip_prefix = "198.51.100.0/24".to_owned();
327 current.asn = Some(9999);
328 current.ch_platform = None;
329 current.ch_mobile = None;
330 current.tls = Some("t13d311200_e8f1e7e78f70_d41ae481755e".to_owned());
331 current.accept_language = None;
332 assert_eq!(decide(¤t).0, Decision::Revoke);
333 }
334
335 #[test]
336 fn spoofed_ua_contradicting_client_hints_is_flagged() {
337 let mut current = chrome_on_windows();
338 current.ch_platform = Some("Linux".to_owned());
339 let (decision, signals) = decide(¤t);
340 assert_eq!(signals, vec![Check::PlatformHintChanged, Check::InconsistentHints]);
341 assert_eq!(decision, Decision::Revoke);
342 }
343
344 #[test]
345 fn alternating_between_two_networks_adds_ping_pong() {
346 let now = Utc::now();
347 let mut baseline = Baseline::new(chrome_on_windows());
348 let mut elsewhere = chrome_on_windows();
349 elsewhere.ip_prefix = "198.51.100.0/24".to_owned();
350 elsewhere.asn = Some(9999);
351 baseline.absorb(&elsewhere, now, &Checks::default());
352
353 let back = chrome_on_windows();
354 let soon = assess(
355 &baseline,
356 &back,
357 now + TimeDelta::seconds(20),
358 TimeDelta::minutes(5),
359 &Checks::default(),
360 );
361 assert_eq!(soon.signals, vec![Check::IpPingPong, Check::AsnChanged]);
362 assert_eq!(soon.decision(&Thresholds::default()), Decision::StepUp);
364
365 let later = assess(
366 &baseline,
367 &back,
368 now + TimeDelta::minutes(10),
369 TimeDelta::minutes(5),
370 &Checks::default(),
371 );
372 assert_eq!(later.signals, vec![Check::AsnChanged]);
373 }
374
375 #[test]
376 fn downgrade_is_flagged() {
377 let mut current = chrome_on_windows();
378 current.browser_major = Some(120);
379 assert_eq!(decide(¤t), (Decision::StepUp, vec![Check::BrowserDowngraded]));
380 }
381
382 #[test]
383 fn disabled_checks_and_custom_weights() {
384 let mut current = chrome_on_windows();
385 current.os = Some("macOS".to_owned());
386 let checks = Checks::default()
387 .disable(Check::OsChanged)
388 .weight(Check::InconsistentHints, 5);
389 let a = assess(
390 &Baseline::new(chrome_on_windows()),
391 ¤t,
392 Utc::now(),
393 TimeDelta::minutes(5),
394 &checks,
395 );
396 assert_eq!(a.signals, vec![Check::InconsistentHints]);
397 assert_eq!(a.score, 5);
398 let none = assess(
399 &Baseline::new(chrome_on_windows()),
400 ¤t,
401 Utc::now(),
402 TimeDelta::minutes(5),
403 &Checks::none(),
404 );
405 assert_eq!(none, Assessment::default());
406 }
407
408 fn routine_change() -> impl Strategy<Value = (u8, u32)> {
410 (0u8..8, any::<u32>())
411 }
412
413 fn apply_routine(fp: &mut Fingerprint, kind: u8, n: u32) {
414 match kind {
415 0 => fp.ip_prefix = format!("198.51.{}.0/24", n % 256),
416 1 => {
417 fp.ip_prefix = format!("100.64.{}.0/24", n % 256);
418 fp.asn = Some(n);
419 }
420 2 => {
421 fp.ip_prefix = format!("192.0.{}.0/24", n % 256);
422 fp.country = Some(["FR", "DE", "US", "JP"][(n % 4) as usize].to_owned());
423 fp.asn = Some(n);
424 }
425 3 => {
426 fp.browser_major = fp.browser_major.map(|m| m + 1 + n % 5);
427 fp.tls = Some(format!("tls-{n}"));
428 }
429 4 => fp.accept_language = Some(["en", "fr,en", "de-de,de"][(n % 3) as usize].to_owned()),
430 5 => fp.tls = Some(format!("tls-{n}")),
431 6 => fp.ch_mobile = fp.ch_mobile.map(|m| !m),
432 _ => fp.os = Some("Linux".to_owned()),
433 }
434 }
435
436 proptest! {
437 #[test]
438 fn no_single_routine_change_revokes(
439 (kind, n) in routine_change(),
440 android in any::<bool>(),
441 previous in any::<bool>(),
442 secs in 0i64..600,
443 ) {
444 let mut fp = chrome_on_windows();
445 if android {
446 fp.os = Some("Android".into());
447 fp.ch_platform = Some("Android".into());
448 fp.ch_mobile = Some(true);
449 }
450 if !android && kind == 7 {
451 return Ok(());
453 }
454 let now = Utc::now();
455 let mut baseline = Baseline::new(fp.clone());
456 let mut current = fp.clone();
457 apply_routine(&mut current, kind, n);
458 if previous {
459 baseline.previous_ip_prefix = Some(current.ip_prefix.clone());
460 baseline.ip_changed_at = Some(now - TimeDelta::seconds(secs));
461 }
462 let a = assess(&baseline, ¤t, now, TimeDelta::minutes(5), &Checks::default());
463 prop_assert_ne!(a.decision(&Thresholds::default()), Decision::Revoke, "{:?}", a);
464 }
465
466 #[test]
467 fn score_is_sum_of_signal_weights(
468 os in proptest::option::of("[A-Za-z]{1,8}"),
469 browser in proptest::option::of("[A-Za-z]{1,8}"),
470 major in proptest::option::of(0u32..300),
471 platform in proptest::option::of("[A-Za-z]{1,8}"),
472 mobile in proptest::option::of(any::<bool>()),
473 lang in proptest::option::of("[a-z]{2}"),
474 ) {
475 let current = Fingerprint {
476 os, browser, browser_major: major, ch_platform: platform, ch_mobile: mobile,
477 accept_language: lang, ..chrome_on_windows()
478 };
479 let a = assess(&Baseline::new(chrome_on_windows()), ¤t, Utc::now(), TimeDelta::minutes(5), &Checks::default());
480 let sum: u32 = a.signals.iter().map(|c| c.default_weight()).sum();
481 prop_assert_eq!(a.score, sum);
482 }
483 }
484}