Skip to main content

session_guard/
fingerprint.rs

1//! Normalised client signals and the per-session baseline they are compared against.
2
3use std::net::IpAddr;
4
5use chrono::{DateTime, Utc};
6use ipnet::IpNet;
7use quick_cache::sync::Cache;
8use serde::{Deserialize, Serialize};
9
10use crate::{
11    checks::{Check, Checks},
12    client::ClientContext,
13    intel::{Anonymity, IpInfo},
14};
15
16/// Normalised client signals. Stored server-side only, never sent to the client.
17#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
18#[serde(default)]
19#[non_exhaustive]
20pub struct Fingerprint {
21    /// The client's /24 (IPv4) or /48 (IPv6) network, e.g. `203.0.113.0/24`.
22    pub ip_prefix: String,
23    /// Country code from the [`IpIntel`](crate::IpIntel) source.
24    pub country: Option<String>,
25    /// Autonomous system number from the [`IpIntel`](crate::IpIntel) source.
26    pub asn: Option<u32>,
27    /// Browser family parsed from the User-Agent.
28    pub browser: Option<String>,
29    /// Browser major version.
30    pub browser_major: Option<u32>,
31    /// Operating system family parsed from the User-Agent.
32    pub os: Option<String>,
33    /// Platform family from `Sec-CH-UA-Platform`.
34    pub ch_platform: Option<String>,
35    /// `Sec-CH-UA-Mobile`.
36    pub ch_mobile: Option<bool>,
37    /// Up to three lowercase language tags, comma separated.
38    pub accept_language: Option<String>,
39    /// TLS fingerprint (for example JA4) passed by the proxy.
40    pub tls: Option<String>,
41    /// Anonymizer the network belongs to, from the [`IpIntel`](crate::IpIntel) source.
42    pub anonymity: Option<Anonymity>,
43}
44
45impl Fingerprint {
46    /// Normalises a request context. `info` comes from the [`IpIntel`](crate::IpIntel) source.
47    pub fn capture(ctx: &ClientContext, info: &IpInfo) -> Self {
48        Self::build(
49            ctx,
50            info,
51            ctx.user_agent.as_deref().map(parse_user_agent).unwrap_or_default(),
52        )
53    }
54
55    pub(crate) fn capture_cached(ctx: &ClientContext, info: &IpInfo, cache: &UaCache) -> Self {
56        Self::build(
57            ctx,
58            info,
59            ctx.user_agent.as_deref().map(|ua| cache.parse(ua)).unwrap_or_default(),
60        )
61    }
62
63    fn build(ctx: &ClientContext, info: &IpInfo, ua: ParsedUa) -> Self {
64        Self {
65            ip_prefix: ip_prefix(ctx.ip),
66            country: info.country.clone().filter(|c| !c.is_empty()),
67            asn: info.asn,
68            browser: ua.browser,
69            browser_major: ua.major,
70            os: ua.os,
71            ch_platform: ctx.ch_platform.as_deref().and_then(platform_family),
72            ch_mobile: ctx.ch_mobile,
73            accept_language: ctx.accept_language.as_deref().and_then(normalise_languages),
74            tls: ctx.tls_fingerprint.clone().filter(|t| !t.is_empty()),
75            anonymity: info.anonymity,
76        }
77    }
78}
79
80/// What a session is compared against.
81///
82/// Hard identity (browser family, OS, client hints) is fixed at login or step-up. Soft signals
83/// (network, country, ASN, anonymity, languages, TLS fingerprint, browser version) follow the
84/// client as trusted requests come in.
85#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
86#[serde(default)]
87#[non_exhaustive]
88pub struct Baseline {
89    /// The reference fingerprint.
90    pub fingerprint: Fingerprint,
91    /// The network the client was on before the last move.
92    pub previous_ip_prefix: Option<String>,
93    /// When the client last moved network.
94    pub ip_changed_at: Option<DateTime<Utc>>,
95}
96
97impl Baseline {
98    /// A fresh baseline, as set at login and after a completed step-up.
99    pub fn new(fingerprint: Fingerprint) -> Self {
100        Self {
101            fingerprint,
102            previous_ip_prefix: None,
103            ip_changed_at: None,
104        }
105    }
106
107    /// Learns soft signals from a request that was let through. Only signals that `checks`
108    /// scored are learned, so a route with looser checks cannot slip a change past stricter
109    /// ones. Hard identity is never learned and missing values never erase known ones. Returns
110    /// whether anything changed.
111    pub fn absorb(&mut self, current: &Fingerprint, now: DateTime<Utc>, checks: &Checks) -> bool {
112        let before = self.clone();
113        let fp = &mut self.fingerprint;
114        let ip_scored = checks.scores(Check::IpPrefixChanged) || checks.scores(Check::IpPingPong);
115        if ip_scored && fp.ip_prefix != current.ip_prefix && !current.ip_prefix.is_empty() {
116            self.previous_ip_prefix = Some(std::mem::replace(&mut fp.ip_prefix, current.ip_prefix.clone()));
117            self.ip_changed_at = Some(now);
118        }
119        if checks.scores(Check::CountryChanged) {
120            keep_known(&mut fp.country, current.country.as_ref());
121        }
122        if checks.scores(Check::AsnChanged) {
123            keep_known(&mut fp.asn, current.asn.as_ref());
124        }
125        if let (Some(was), Some(now)) = (fp.anonymity, current.anonymity)
126            && anonymity_change(was, now).is_some_and(|check| !checks.scores(check))
127        {
128            // Keep the stricter baseline: this route did not score the move.
129        } else {
130            keep_known(&mut fp.anonymity, current.anonymity.as_ref());
131        }
132        if checks.scores(Check::AcceptLanguageChanged) {
133            keep_known(&mut fp.accept_language, current.accept_language.as_ref());
134        }
135        if checks.scores(Check::TlsChanged) {
136            keep_known(&mut fp.tls, current.tls.as_ref());
137        }
138        if checks.scores(Check::BrowserDowngraded) {
139            fp.browser_major = fp.browser_major.max(current.browser_major);
140        }
141        *self != before
142    }
143}
144
145fn keep_known<T: Clone>(slot: &mut Option<T>, value: Option<&T>) {
146    if let Some(v) = value {
147        *slot = Some(v.clone());
148    }
149}
150
151/// The check a move from `was` to `now` fires: onto Tor or an open proxy from anywhere else, or
152/// onto another anonymizer from a direct connection. Leaving or switching between anonymizers
153/// fires nothing.
154pub(crate) fn anonymity_change(was: Anonymity, now: Anonymity) -> Option<Check> {
155    use Anonymity::{Direct, HostingProvider, PublicProxy, ResidentialProxy, TorExitNode, Vpn};
156    match (was, now) {
157        (TorExitNode | PublicProxy, _) => None,
158        (_, TorExitNode | PublicProxy) => Some(Check::TorOrProxyEntered),
159        (Direct, Vpn | HostingProvider | ResidentialProxy) => Some(Check::AnonymizerEntered),
160        _ => None,
161    }
162}
163
164/// The client's /24 (IPv4) or /48 (IPv6) network.
165pub fn ip_prefix(ip: IpAddr) -> String {
166    let len = if ip.is_ipv4() { 24 } else { 48 };
167    IpNet::new(ip, len).map_or_else(|_| ip.to_string(), |net| net.trunc().to_string())
168}
169
170/// Android↔Linux and iOS↔macOS flip with desktop-mode toggles, so they are compatible.
171pub(crate) fn os_compatible(a: &str, b: &str) -> bool {
172    a == b
173        || matches!(
174            (a, b),
175            ("Android", "Linux") | ("Linux", "Android") | ("iOS", "macOS") | ("macOS", "iOS")
176        )
177}
178
179#[derive(Clone, Debug, Default, PartialEq, Eq)]
180pub(crate) struct ParsedUa {
181    browser: Option<String>,
182    major: Option<u32>,
183    os: Option<String>,
184}
185
186fn parse_user_agent(ua: &str) -> ParsedUa {
187    let Some(parsed) = woothee::parser::Parser::new().parse(ua) else {
188        return ParsedUa::default();
189    };
190    let known = |v: &str| (!v.is_empty() && v != woothee::woothee::VALUE_UNKNOWN).then(|| v.to_owned());
191    let browser = known(parsed.name);
192    ParsedUa {
193        major: browser
194            .as_ref()
195            .and(parsed.version.split('.').next())
196            .and_then(|m| m.parse().ok()),
197        os: known(parsed.os).map(|os| os_family(&os)),
198        browser,
199    }
200}
201
202/// Bounded LRU-ish cache of parsed User-Agents; most traffic repeats a handful of strings.
203pub(crate) struct UaCache(Cache<String, ParsedUa>);
204
205impl UaCache {
206    pub(crate) fn new(capacity: usize) -> Self {
207        Self(Cache::new(capacity.max(1)))
208    }
209
210    fn parse(&self, ua: &str) -> ParsedUa {
211        if let Some(hit) = self.0.get(ua) {
212            return hit;
213        }
214        let parsed = parse_user_agent(ua);
215        self.0.insert(ua.to_owned(), parsed.clone());
216        parsed
217    }
218}
219
220impl std::fmt::Debug for UaCache {
221    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
222        f.debug_struct("UaCache").field("len", &self.0.len()).finish()
223    }
224}
225
226fn os_family(os: &str) -> String {
227    match os {
228        os if os.starts_with("Windows") => "Windows",
229        "Mac OSX" | "Mac OS Classic" => "macOS",
230        "iPhone" | "iPad" | "iPod" | "iOS" => "iOS",
231        "ChromeOS" => "ChromeOS",
232        other => other,
233    }
234    .to_owned()
235}
236
237fn platform_family(platform: &str) -> Option<String> {
238    match platform.trim() {
239        "Unknown" | "" => None,
240        "Chrome OS" | "Chromium OS" => Some("ChromeOS".to_owned()),
241        other => Some(other.to_owned()),
242    }
243}
244
245fn normalise_languages(header: &str) -> Option<String> {
246    let tags: Vec<String> = header
247        .split(',')
248        .filter_map(|part| part.split(';').next())
249        .map(|tag| tag.trim().to_ascii_lowercase())
250        .filter(|tag| !tag.is_empty() && tag != "*")
251        .take(3)
252        .collect();
253    (!tags.is_empty()).then(|| tags.join(","))
254}
255
256#[cfg(test)]
257pub(crate) mod tests {
258    use super::*;
259    use crate::intel::Anonymity;
260
261    pub(crate) const CHROME_WIN: &str = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/141.0.0.0 Safari/537.36";
262
263    fn ctx(ua: Option<&str>) -> ClientContext {
264        let mut ctx = ClientContext::new("203.0.113.77".parse().unwrap())
265            .with_ch_platform("\"Windows\"")
266            .with_ch_mobile(false)
267            .with_accept_language("nl-BE,nl;q=0.9,en-US;q=0.8,en;q=0.7");
268        ctx.user_agent = ua.map(str::to_owned);
269        ctx
270    }
271
272    #[test]
273    fn captures_normalised_signals() {
274        let fp = Fingerprint::capture(&ctx(Some(CHROME_WIN)), &IpInfo::new(Some("BE".into()), Some(5432)));
275        assert_eq!(fp.ip_prefix, "203.0.113.0/24");
276        assert_eq!(fp.country.as_deref(), Some("BE"));
277        assert_eq!(fp.asn, Some(5432));
278        assert_eq!(fp.browser.as_deref(), Some("Chrome"));
279        assert_eq!(fp.browser_major, Some(141));
280        assert_eq!(fp.os.as_deref(), Some("Windows"));
281        assert_eq!(fp.ch_platform.as_deref(), Some("Windows"));
282        assert_eq!(fp.accept_language.as_deref(), Some("nl-be,nl,en-us"));
283        assert_eq!(fp.anonymity, None);
284
285        let info = IpInfo::default().with_anonymity(Anonymity::Vpn);
286        let fp = Fingerprint::capture(&ctx(Some(CHROME_WIN)), &info);
287        assert_eq!(fp.anonymity, Some(Anonymity::Vpn));
288    }
289
290    #[test]
291    fn cached_capture_matches_uncached() {
292        let cache = UaCache::new(8);
293        let a = Fingerprint::capture(&ctx(Some(CHROME_WIN)), &IpInfo::default());
294        let b = Fingerprint::capture_cached(&ctx(Some(CHROME_WIN)), &IpInfo::default(), &cache);
295        let c = Fingerprint::capture_cached(&ctx(Some(CHROME_WIN)), &IpInfo::default(), &cache);
296        assert_eq!(a, b);
297        assert_eq!(b, c);
298    }
299
300    #[test]
301    fn garbage_user_agent_yields_unknowns() {
302        let fp = Fingerprint::capture(&ctx(Some("definitely not a browser")), &IpInfo::default());
303        assert_eq!((fp.browser, fp.browser_major, fp.os), (None, None, None));
304        let fp = Fingerprint::capture(&ctx(None), &IpInfo::default());
305        assert_eq!(fp.browser, None);
306    }
307
308    #[test]
309    fn os_families_and_platforms() {
310        let mac = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0 Safari/605.1.15";
311        let iphone = "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0 Mobile/15E148 Safari/604.1";
312        assert_eq!(
313            Fingerprint::capture(&ctx(Some(mac)), &IpInfo::default()).os.as_deref(),
314            Some("macOS")
315        );
316        assert_eq!(
317            Fingerprint::capture(&ctx(Some(iphone)), &IpInfo::default())
318                .os
319                .as_deref(),
320            Some("iOS")
321        );
322        assert_eq!(platform_family("Chrome OS").as_deref(), Some("ChromeOS"));
323        assert_eq!(platform_family("Unknown"), None);
324        assert!(os_compatible("Android", "Linux") && os_compatible("macOS", "iOS"));
325        assert!(!os_compatible("Windows", "macOS"));
326    }
327
328    #[test]
329    fn ip_prefixes() {
330        assert_eq!(ip_prefix("2001:db8:abcd:12::1".parse().unwrap()), "2001:db8:abcd::/48");
331        assert_eq!(ip_prefix("10.1.2.3".parse().unwrap()), "10.1.2.0/24");
332    }
333
334    #[test]
335    fn languages_are_normalised() {
336        assert_eq!(normalise_languages("*"), None);
337        assert_eq!(normalise_languages(" EN ; q=1, fr").as_deref(), Some("en,fr"));
338    }
339
340    fn base() -> Fingerprint {
341        Fingerprint::capture(&ctx(Some(CHROME_WIN)), &IpInfo::new(Some("BE".into()), Some(5432)))
342    }
343
344    #[test]
345    fn absorb_tracks_moves_and_never_downgrades_version() {
346        let now = Utc::now();
347        let original = base();
348        let mut baseline = Baseline::new(original.clone());
349
350        let mut moved = original.clone();
351        moved.ip_prefix = "198.51.100.0/24".to_owned();
352        moved.asn = Some(6848);
353        moved.country = None;
354        moved.browser_major = Some(140);
355        moved.accept_language = None;
356        assert!(baseline.absorb(&moved, now, &Checks::default()));
357
358        let fp = &baseline.fingerprint;
359        assert_eq!(fp.ip_prefix, "198.51.100.0/24");
360        assert_eq!(baseline.previous_ip_prefix.as_deref(), Some("203.0.113.0/24"));
361        assert_eq!(baseline.ip_changed_at, Some(now));
362        assert_eq!(fp.asn, Some(6848));
363        assert_eq!(fp.country.as_deref(), Some("BE"), "missing country never erases");
364        assert_eq!(fp.browser_major, Some(141));
365        assert_eq!(fp.accept_language, original.accept_language);
366    }
367
368    #[test]
369    fn absorb_never_learns_hard_identity() {
370        let mut baseline = Baseline::new(base());
371        let mut other = base();
372        other.browser = Some("Firefox".into());
373        other.os = Some("Linux".into());
374        other.ch_platform = Some("Linux".into());
375        other.ch_mobile = Some(true);
376        assert!(!baseline.absorb(&other, Utc::now(), &Checks::default()));
377        assert_eq!(baseline.fingerprint, base());
378    }
379
380    #[test]
381    fn absorb_learns_only_scored_signals() {
382        let original = base();
383        let mut baseline = Baseline::new(original.clone());
384        let mut moved = original.clone();
385        moved.ip_prefix = "198.51.100.0/24".to_owned();
386        moved.asn = Some(6848);
387        moved.accept_language = Some("de-DE".into());
388        let checks = Checks::default()
389            .disable(Check::AsnChanged)
390            .disable(Check::IpPrefixChanged)
391            .disable(Check::IpPingPong)
392            .weight(Check::AcceptLanguageChanged, 0);
393        assert!(!baseline.absorb(&moved, Utc::now(), &checks));
394        assert_eq!(baseline.fingerprint, original);
395
396        let ping_pong_only = Checks::none().enable(Check::IpPingPong);
397        assert!(baseline.absorb(&moved, Utc::now(), &ping_pong_only));
398        assert_eq!(baseline.fingerprint.ip_prefix, "198.51.100.0/24");
399        assert_eq!(baseline.fingerprint.asn, original.asn);
400    }
401
402    #[test]
403    fn absorb_learns_anonymity_only_when_its_check_scored() {
404        let direct = || {
405            let mut fp = base();
406            fp.anonymity = Some(Anonymity::Direct);
407            fp
408        };
409        let with = |anonymity| {
410            let mut fp = direct();
411            fp.ip_prefix = "198.51.100.0/24".to_owned();
412            fp.anonymity = anonymity;
413            fp
414        };
415        let now = Utc::now();
416
417        let mut baseline = Baseline::new(direct());
418        let loose = Checks::default().disable(Check::AnonymizerEntered);
419        baseline.absorb(&with(Some(Anonymity::Vpn)), now, &loose);
420        assert_eq!(baseline.fingerprint.anonymity, Some(Anonymity::Direct), "unscored move");
421        baseline.absorb(&with(Some(Anonymity::Vpn)), now, &Checks::default());
422        assert_eq!(baseline.fingerprint.anonymity, Some(Anonymity::Vpn));
423
424        // Leaving or switching anonymizers fires nothing, so any route learns it.
425        baseline.absorb(&with(None), now, &Checks::none());
426        assert_eq!(
427            baseline.fingerprint.anonymity,
428            Some(Anonymity::Vpn),
429            "missing never erases"
430        );
431        baseline.absorb(&with(Some(Anonymity::Direct)), now, &Checks::none());
432        assert_eq!(baseline.fingerprint.anonymity, Some(Anonymity::Direct));
433
434        let mut unknown = Baseline::new(base());
435        unknown.absorb(&with(Some(Anonymity::TorExitNode)), now, &Checks::none());
436        assert_eq!(unknown.fingerprint.anonymity, Some(Anonymity::TorExitNode));
437    }
438
439    #[test]
440    fn absorb_reports_no_change_for_identical_request() {
441        let mut baseline = Baseline::new(base());
442        assert!(!baseline.absorb(&base(), Utc::now(), &Checks::default()));
443    }
444
445    #[test]
446    fn baseline_round_trips_json_and_tolerates_missing_fields() {
447        let b = Baseline::new(base());
448        let json = serde_json::to_string(&b).unwrap();
449        assert_eq!(serde_json::from_str::<Baseline>(&json).unwrap(), b);
450        assert_eq!(serde_json::from_str::<Baseline>("{}").unwrap(), Baseline::default());
451
452        let mut fp = base();
453        fp.anonymity = Some(Anonymity::ResidentialProxy);
454        let json = serde_json::to_value(&fp).unwrap();
455        assert_eq!(json["anonymity"], "residential_proxy");
456        assert_eq!(serde_json::from_value::<Fingerprint>(json).unwrap(), fp);
457    }
458}