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