1use chrono::{DateTime, TimeDelta, Utc};
4
5use crate::{
6 checks::{Check, Checks, Decision, Thresholds},
7 fingerprint::{Baseline, Fingerprint, anonymity_change, 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 if !returned
114 && let (Some(was), Some(now)) = (known.anonymity, current.anonymity)
115 && let Some(check) = anonymity_change(was, now)
116 {
117 fired.push(check);
118 }
119 }
120
121 let version_changed = matches!((known.browser_major, current.browser_major), (Some(a), Some(b)) if a != b);
122 if !version_changed && differs(known.tls.as_ref(), current.tls.as_ref()) {
123 fired.push(Check::TlsChanged);
124 }
125
126 if differs(known.accept_language.as_ref(), current.accept_language.as_ref()) {
127 fired.push(Check::AcceptLanguageChanged);
128 }
129
130 let mut signals = Vec::with_capacity(fired.len());
131 let mut score = 0u32;
132 for check in Check::ALL {
133 if fired.contains(&check)
134 && let Some(weight) = checks.weight_of(check)
135 {
136 signals.push(check);
137 score = score.saturating_add(weight);
138 }
139 }
140 Assessment { score, signals }
141}
142
143fn differs<T: PartialEq>(known: Option<&T>, current: Option<&T>) -> bool {
145 match (known, current) {
146 (Some(a), Some(b)) => a != b,
147 (Some(_), None) => true,
148 (None, _) => false,
149 }
150}
151
152fn lost<T>(known: Option<&T>, current: Option<&T>) -> bool {
153 known.is_some() && current.is_none()
154}
155
156#[cfg(test)]
157mod tests {
158 use proptest::prelude::*;
159
160 use super::*;
161 use crate::intel::Anonymity;
162
163 fn chrome_on_windows() -> Fingerprint {
164 Fingerprint {
165 ip_prefix: "203.0.113.0/24".to_owned(),
166 country: Some("BE".to_owned()),
167 asn: Some(5432),
168 browser: Some("Chrome".to_owned()),
169 browser_major: Some(141),
170 os: Some("Windows".to_owned()),
171 ch_platform: Some("Windows".to_owned()),
172 ch_mobile: Some(false),
173 accept_language: Some("nl-be,nl,en".to_owned()),
174 tls: Some("t13d1516h2_8daaf6152771_02713d6af862".to_owned()),
175 anonymity: Some(Anonymity::Direct),
176 }
177 }
178
179 fn via(anonymity: Anonymity) -> Fingerprint {
180 let mut fp = chrome_on_windows();
181 fp.ip_prefix = "198.51.100.0/24".to_owned();
182 fp.asn = Some(9009);
183 fp.anonymity = Some(anonymity);
184 fp
185 }
186
187 fn assess_now(baseline: &Baseline, current: &Fingerprint) -> Assessment {
188 assess(baseline, current, Utc::now(), TimeDelta::minutes(5), &Checks::default())
189 }
190
191 fn decide(current: &Fingerprint) -> (Decision, Vec<Check>) {
192 let a = assess_now(&Baseline::new(chrome_on_windows()), current);
193 (a.decision(&Thresholds::default()), a.signals)
194 }
195
196 #[test]
197 fn added_signals_keep_check_order_and_respect_weights() {
198 let mut current = chrome_on_windows();
199 current.ip_prefix = "198.51.100.0/24".to_owned();
200 current.asn = Some(6848);
201 current.accept_language = Some("en".to_owned());
202 let mut a = assess_now(&Baseline::new(chrome_on_windows()), ¤t);
203 a.add(Check::OrphanedToken, &Checks::default());
204 assert_eq!(
205 a.signals,
206 vec![Check::OrphanedToken, Check::AsnChanged, Check::AcceptLanguageChanged]
207 );
208 assert_eq!(a.score, 25 + 20 + 15);
209
210 let mut a = Assessment::default();
211 a.add(Check::OrphanedToken, &Checks::none());
212 assert_eq!(a, Assessment::default());
213 }
214
215 #[test]
216 fn identical_request_is_allowed() {
217 assert_eq!(decide(&chrome_on_windows()), (Decision::Allow, vec![]));
218 }
219
220 #[test]
223 fn network_switch_within_same_provider_is_allowed() {
224 let mut current = chrome_on_windows();
225 current.ip_prefix = "203.0.200.0/24".to_owned();
226 assert_eq!(decide(¤t), (Decision::Allow, vec![Check::IpPrefixChanged]));
227 }
228
229 #[test]
230 fn wifi_to_mobile_data_is_allowed() {
231 let mut current = chrome_on_windows();
232 current.ip_prefix = "198.51.100.0/24".to_owned();
233 current.asn = Some(6848);
234 assert_eq!(decide(¤t), (Decision::Allow, vec![Check::AsnChanged]));
235 }
236
237 #[test]
238 fn browser_update_is_allowed_even_though_tls_changes() {
239 let mut current = chrome_on_windows();
240 current.browser_major = Some(142);
241 current.tls = Some("t13d1517h2_8daaf6152771_b0da82dd1658".to_owned());
242 assert_eq!(decide(¤t), (Decision::Allow, vec![]));
243 }
244
245 #[test]
246 fn language_tweak_is_allowed() {
247 let mut current = chrome_on_windows();
248 current.accept_language = Some("en".to_owned());
249 assert_eq!(decide(¤t).0, Decision::Allow);
250 }
251
252 #[test]
253 fn android_desktop_mode_toggle_steps_up_not_revokes() {
254 let mut fp = chrome_on_windows();
255 fp.os = Some("Android".to_owned());
256 fp.ch_platform = Some("Android".to_owned());
257 fp.ch_mobile = Some(true);
258 let mut current = fp.clone();
259 current.os = Some("Linux".to_owned());
260 current.ch_mobile = Some(false);
261 let a = assess_now(&Baseline::new(fp), ¤t);
262 assert_eq!(a.signals, vec![Check::MobileHintChanged]);
263 assert_eq!(a.decision(&Thresholds::default()), Decision::StepUp);
264 }
265
266 #[test]
267 fn travel_abroad_steps_up_not_revokes() {
268 let mut current = chrome_on_windows();
269 current.ip_prefix = "192.0.2.0/24".to_owned();
270 current.country = Some("FR".to_owned());
271 current.asn = Some(3215);
272 assert_eq!(decide(¤t), (Decision::StepUp, vec![Check::CountryChanged]));
273 }
274
275 #[test]
276 fn baseline_without_hints_ignores_hints() {
277 let mut firefox = chrome_on_windows();
278 firefox.browser = Some("Firefox".to_owned());
279 firefox.ch_platform = None;
280 firefox.ch_mobile = None;
281 let a = assess_now(&Baseline::new(firefox.clone()), &firefox);
282 assert_eq!(a.score, 0);
283 }
284
285 #[test]
288 fn different_operating_system_is_revoked() {
289 let mut current = chrome_on_windows();
290 current.os = Some("macOS".to_owned());
291 current.ch_platform = Some("macOS".to_owned());
292 assert_eq!(decide(¤t).0, Decision::Revoke);
293 }
294
295 #[test]
296 fn different_browser_is_revoked() {
297 let mut current = chrome_on_windows();
298 current.browser = Some("Firefox".to_owned());
299 current.ch_platform = None;
300 current.ch_mobile = None;
301 assert_eq!(decide(¤t).0, Decision::Revoke);
302 }
303
304 #[test]
305 fn empty_user_agent_cannot_bypass_checks() {
306 let mut current = chrome_on_windows();
307 current.browser = None;
308 current.browser_major = None;
309 current.os = None;
310 current.ch_platform = None;
311 current.ch_mobile = None;
312 let (decision, signals) = decide(¤t);
313 assert!(signals.contains(&Check::UserAgentMissing));
314 assert!(signals.contains(&Check::ClientHintsMissing));
315 assert_eq!(decision, Decision::Revoke);
316 }
317
318 #[test]
319 fn dropping_only_the_mobile_hint_is_noticed() {
320 let mut current = chrome_on_windows();
321 current.ch_mobile = None;
322 assert_eq!(decide(¤t).1, vec![Check::ClientHintsMissing]);
323 }
324
325 #[test]
326 fn stripping_soft_headers_never_lowers_the_score() {
327 let mut current = chrome_on_windows();
328 current.accept_language = None;
329 current.tls = None;
330 assert_eq!(
331 decide(¤t).1,
332 vec![Check::TlsChanged, Check::AcceptLanguageChanged]
333 );
334
335 let mut current = chrome_on_windows();
336 current.ip_prefix = "198.51.100.0/24".to_owned();
337 current.country = None;
338 assert_eq!(decide(¤t).1, vec![Check::CountryChanged]);
339 }
340
341 #[test]
342 fn copied_user_agent_from_script_in_same_country_is_revoked() {
343 let mut current = chrome_on_windows();
344 current.ip_prefix = "198.51.100.0/24".to_owned();
345 current.asn = Some(9999);
346 current.ch_platform = None;
347 current.ch_mobile = None;
348 current.tls = Some("t13d311200_e8f1e7e78f70_d41ae481755e".to_owned());
349 current.accept_language = None;
350 assert_eq!(decide(¤t).0, Decision::Revoke);
351 }
352
353 #[test]
354 fn spoofed_ua_contradicting_client_hints_is_flagged() {
355 let mut current = chrome_on_windows();
356 current.ch_platform = Some("Linux".to_owned());
357 let (decision, signals) = decide(¤t);
358 assert_eq!(signals, vec![Check::PlatformHintChanged, Check::InconsistentHints]);
359 assert_eq!(decision, Decision::Revoke);
360 }
361
362 #[test]
363 fn alternating_between_two_networks_adds_ping_pong() {
364 let now = Utc::now();
365 let mut baseline = Baseline::new(chrome_on_windows());
366 let mut elsewhere = chrome_on_windows();
367 elsewhere.ip_prefix = "198.51.100.0/24".to_owned();
368 elsewhere.asn = Some(9999);
369 baseline.absorb(&elsewhere, now, &Checks::default());
370
371 let back = chrome_on_windows();
372 let soon = assess(
373 &baseline,
374 &back,
375 now + TimeDelta::seconds(20),
376 TimeDelta::minutes(5),
377 &Checks::default(),
378 );
379 assert_eq!(soon.signals, vec![Check::IpPingPong, Check::AsnChanged]);
380 assert_eq!(soon.decision(&Thresholds::default()), Decision::StepUp);
382
383 let later = assess(
384 &baseline,
385 &back,
386 now + TimeDelta::minutes(10),
387 TimeDelta::minutes(5),
388 &Checks::default(),
389 );
390 assert_eq!(later.signals, vec![Check::AsnChanged]);
391 }
392
393 #[test]
394 fn switching_on_a_vpn_steps_up() {
395 for anonymizer in [Anonymity::Vpn, Anonymity::HostingProvider, Anonymity::ResidentialProxy] {
396 assert_eq!(
397 decide(&via(anonymizer)),
398 (Decision::StepUp, vec![Check::AnonymizerEntered, Check::AsnChanged]),
399 "{anonymizer:?}"
400 );
401 }
402 let mut abroad = via(Anonymity::Vpn);
403 abroad.country = Some("NL".to_owned());
404 assert_eq!(
405 decide(&abroad),
406 (Decision::StepUp, vec![Check::CountryChanged, Check::AnonymizerEntered])
407 );
408 }
409
410 #[test]
411 fn tor_steps_up_and_tor_abroad_revokes() {
412 for anonymizer in [Anonymity::TorExitNode, Anonymity::PublicProxy] {
413 assert_eq!(
414 decide(&via(anonymizer)),
415 (Decision::StepUp, vec![Check::TorOrProxyEntered, Check::AsnChanged]),
416 "{anonymizer:?}"
417 );
418 }
419 let mut abroad = via(Anonymity::TorExitNode);
420 abroad.country = Some("DE".to_owned());
421 assert_eq!(
422 decide(&abroad),
423 (Decision::Revoke, vec![Check::TorOrProxyEntered, Check::CountryChanged])
424 );
425 }
426
427 #[test]
428 fn anonymity_only_scores_moves_onto_a_worse_network() {
429 let assess_from = |was: Option<Anonymity>, now: Option<Anonymity>| {
430 let mut known = via(Anonymity::Direct);
431 known.anonymity = was;
432 let mut current = chrome_on_windows();
433 current.anonymity = now;
434 assess_now(&Baseline::new(known), ¤t).signals
435 };
436 let vpn = Some(Anonymity::Vpn);
437 let tor = Some(Anonymity::TorExitNode);
438 let direct = Some(Anonymity::Direct);
439 assert_eq!(assess_from(vpn, direct), vec![Check::AsnChanged], "leaving a VPN");
440 assert_eq!(
441 assess_from(vpn, Some(Anonymity::HostingProvider)),
442 vec![Check::AsnChanged]
443 );
444 assert_eq!(assess_from(tor, vpn), vec![Check::AsnChanged], "Tor to VPN");
445 assert_eq!(assess_from(tor, Some(Anonymity::PublicProxy)), vec![Check::AsnChanged]);
446 assert_eq!(assess_from(vpn, tor), vec![Check::TorOrProxyEntered, Check::AsnChanged]);
447 assert_eq!(assess_from(None, tor), vec![Check::AsnChanged], "unknown baseline");
448 assert_eq!(assess_from(direct, None), vec![Check::AsnChanged], "lookup failed");
449 }
450
451 #[test]
452 fn anonymity_needs_a_network_move() {
453 let mut current = chrome_on_windows();
454 current.anonymity = Some(Anonymity::Vpn);
455 assert_eq!(decide(¤t), (Decision::Allow, vec![]));
456 }
457
458 #[test]
459 fn toggling_a_vpn_back_on_does_not_revoke() {
460 let now = Utc::now();
461 let mut baseline = Baseline::new(chrome_on_windows());
462 baseline.absorb(&via(Anonymity::Vpn), now, &Checks::default());
463 baseline.absorb(&chrome_on_windows(), now + TimeDelta::seconds(10), &Checks::default());
464
465 let a = assess(
466 &baseline,
467 &via(Anonymity::Vpn),
468 now + TimeDelta::seconds(20),
469 TimeDelta::minutes(5),
470 &Checks::default(),
471 );
472 assert_eq!(a.signals, vec![Check::IpPingPong, Check::AsnChanged]);
474 assert_eq!(a.decision(&Thresholds::default()), Decision::StepUp);
475 }
476
477 #[test]
478 fn downgrade_is_flagged() {
479 let mut current = chrome_on_windows();
480 current.browser_major = Some(120);
481 assert_eq!(decide(¤t), (Decision::StepUp, vec![Check::BrowserDowngraded]));
482 }
483
484 #[test]
485 fn disabled_checks_and_custom_weights() {
486 let mut current = chrome_on_windows();
487 current.os = Some("macOS".to_owned());
488 let checks = Checks::default()
489 .disable(Check::OsChanged)
490 .weight(Check::InconsistentHints, 5);
491 let a = assess(
492 &Baseline::new(chrome_on_windows()),
493 ¤t,
494 Utc::now(),
495 TimeDelta::minutes(5),
496 &checks,
497 );
498 assert_eq!(a.signals, vec![Check::InconsistentHints]);
499 assert_eq!(a.score, 5);
500 let none = assess(
501 &Baseline::new(chrome_on_windows()),
502 ¤t,
503 Utc::now(),
504 TimeDelta::minutes(5),
505 &Checks::none(),
506 );
507 assert_eq!(none, Assessment::default());
508 }
509
510 fn routine_change() -> impl Strategy<Value = (u8, u32)> {
512 (0u8..9, any::<u32>())
513 }
514
515 fn apply_routine(fp: &mut Fingerprint, kind: u8, n: u32) {
516 match kind {
517 0 => fp.ip_prefix = format!("198.51.{}.0/24", n % 256),
518 1 => {
519 fp.ip_prefix = format!("100.64.{}.0/24", n % 256);
520 fp.asn = Some(n);
521 }
522 2 => {
523 fp.ip_prefix = format!("192.0.{}.0/24", n % 256);
524 fp.country = Some(["FR", "DE", "US", "JP"][(n % 4) as usize].to_owned());
525 fp.asn = Some(n);
526 }
527 3 => {
528 fp.browser_major = fp.browser_major.map(|m| m + 1 + n % 5);
529 fp.tls = Some(format!("tls-{n}"));
530 }
531 4 => fp.accept_language = Some(["en", "fr,en", "de-de,de"][(n % 3) as usize].to_owned()),
532 5 => fp.tls = Some(format!("tls-{n}")),
533 6 => fp.ch_mobile = fp.ch_mobile.map(|m| !m),
534 7 => {
535 fp.ip_prefix = format!("185.{}.0.0/24", n % 256);
536 fp.country = Some(["BE", "NL", "CH", "US"][(n % 4) as usize].to_owned());
537 fp.asn = Some(n);
538 fp.anonymity = Some(Anonymity::Vpn);
539 }
540 _ => fp.os = Some("Linux".to_owned()),
541 }
542 }
543
544 proptest! {
545 #[test]
546 fn no_single_routine_change_revokes(
547 (kind, n) in routine_change(),
548 android in any::<bool>(),
549 previous in any::<bool>(),
550 secs in 0i64..600,
551 ) {
552 let mut fp = chrome_on_windows();
553 if android {
554 fp.os = Some("Android".into());
555 fp.ch_platform = Some("Android".into());
556 fp.ch_mobile = Some(true);
557 }
558 if !android && kind == 8 {
559 return Ok(());
561 }
562 let now = Utc::now();
563 let mut baseline = Baseline::new(fp.clone());
564 let mut current = fp.clone();
565 apply_routine(&mut current, kind, n);
566 if previous {
567 baseline.previous_ip_prefix = Some(current.ip_prefix.clone());
568 baseline.ip_changed_at = Some(now - TimeDelta::seconds(secs));
569 }
570 let a = assess(&baseline, ¤t, now, TimeDelta::minutes(5), &Checks::default());
571 prop_assert_ne!(a.decision(&Thresholds::default()), Decision::Revoke, "{:?}", a);
572 }
573
574 #[test]
575 fn score_is_sum_of_signal_weights(
576 os in proptest::option::of("[A-Za-z]{1,8}"),
577 browser in proptest::option::of("[A-Za-z]{1,8}"),
578 major in proptest::option::of(0u32..300),
579 platform in proptest::option::of("[A-Za-z]{1,8}"),
580 mobile in proptest::option::of(any::<bool>()),
581 lang in proptest::option::of("[a-z]{2}"),
582 ) {
583 let current = Fingerprint {
584 os, browser, browser_major: major, ch_platform: platform, ch_mobile: mobile,
585 accept_language: lang, ..chrome_on_windows()
586 };
587 let a = assess(&Baseline::new(chrome_on_windows()), ¤t, Utc::now(), TimeDelta::minutes(5), &Checks::default());
588 let sum: u32 = a.signals.iter().map(|c| c.default_weight()).sum();
589 prop_assert_eq!(a.score, sum);
590 }
591 }
592}