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::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}
42
43impl Fingerprint {
44    /// Normalises a request context. `info` comes from the [`IpIntel`](crate::IpIntel) source.
45    pub fn capture(ctx: &ClientContext, info: IpInfo) -> Self {
46        Self::build(
47            ctx,
48            info,
49            ctx.user_agent.as_deref().map(parse_user_agent).unwrap_or_default(),
50        )
51    }
52
53    pub(crate) fn capture_cached(ctx: &ClientContext, info: IpInfo, cache: &UaCache) -> Self {
54        Self::build(
55            ctx,
56            info,
57            ctx.user_agent.as_deref().map(|ua| cache.parse(ua)).unwrap_or_default(),
58        )
59    }
60
61    fn build(ctx: &ClientContext, info: IpInfo, ua: ParsedUa) -> Self {
62        Self {
63            ip_prefix: ip_prefix(ctx.ip),
64            country: info.country.filter(|c| !c.is_empty()),
65            asn: info.asn,
66            browser: ua.browser,
67            browser_major: ua.major,
68            os: ua.os,
69            ch_platform: ctx.ch_platform.as_deref().and_then(platform_family),
70            ch_mobile: ctx.ch_mobile,
71            accept_language: ctx.accept_language.as_deref().and_then(normalise_languages),
72            tls: ctx.tls_fingerprint.clone().filter(|t| !t.is_empty()),
73        }
74    }
75}
76
77/// What a session is compared against.
78///
79/// Hard identity (browser family, OS, client hints) is fixed at login or step-up. Soft signals
80/// (network, country, ASN, languages, TLS fingerprint, browser version) follow the client as
81/// trusted requests come in.
82#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
83#[serde(default)]
84#[non_exhaustive]
85pub struct Baseline {
86    /// The reference fingerprint.
87    pub fingerprint: Fingerprint,
88    /// The network the client was on before the last move.
89    pub previous_ip_prefix: Option<String>,
90    /// When the client last moved network.
91    pub ip_changed_at: Option<DateTime<Utc>>,
92}
93
94impl Baseline {
95    /// A fresh baseline, as set at login and after a completed step-up.
96    pub fn new(fingerprint: Fingerprint) -> Self {
97        Self {
98            fingerprint,
99            previous_ip_prefix: None,
100            ip_changed_at: None,
101        }
102    }
103
104    /// Learns soft signals from a request that was let through. Only signals that `checks`
105    /// scored are learned, so a route with looser checks cannot slip a change past stricter
106    /// ones. Hard identity is never learned and missing values never erase known ones. Returns
107    /// whether anything changed.
108    pub fn absorb(&mut self, current: &Fingerprint, now: DateTime<Utc>, checks: &Checks) -> bool {
109        let before = self.clone();
110        let fp = &mut self.fingerprint;
111        let ip_scored = checks.scores(Check::IpPrefixChanged) || checks.scores(Check::IpPingPong);
112        if ip_scored && fp.ip_prefix != current.ip_prefix && !current.ip_prefix.is_empty() {
113            self.previous_ip_prefix = Some(std::mem::replace(&mut fp.ip_prefix, current.ip_prefix.clone()));
114            self.ip_changed_at = Some(now);
115        }
116        if checks.scores(Check::CountryChanged) {
117            keep_known(&mut fp.country, current.country.as_ref());
118        }
119        if checks.scores(Check::AsnChanged) {
120            keep_known(&mut fp.asn, current.asn.as_ref());
121        }
122        if checks.scores(Check::AcceptLanguageChanged) {
123            keep_known(&mut fp.accept_language, current.accept_language.as_ref());
124        }
125        if checks.scores(Check::TlsChanged) {
126            keep_known(&mut fp.tls, current.tls.as_ref());
127        }
128        if checks.scores(Check::BrowserDowngraded) {
129            fp.browser_major = fp.browser_major.max(current.browser_major);
130        }
131        *self != before
132    }
133}
134
135fn keep_known<T: Clone>(slot: &mut Option<T>, value: Option<&T>) {
136    if let Some(v) = value {
137        *slot = Some(v.clone());
138    }
139}
140
141/// The client's /24 (IPv4) or /48 (IPv6) network.
142pub fn ip_prefix(ip: IpAddr) -> String {
143    let len = if ip.is_ipv4() { 24 } else { 48 };
144    IpNet::new(ip, len).map_or_else(|_| ip.to_string(), |net| net.trunc().to_string())
145}
146
147/// Android↔Linux and iOS↔macOS flip with desktop-mode toggles, so they are compatible.
148pub(crate) fn os_compatible(a: &str, b: &str) -> bool {
149    a == b
150        || matches!(
151            (a, b),
152            ("Android", "Linux") | ("Linux", "Android") | ("iOS", "macOS") | ("macOS", "iOS")
153        )
154}
155
156#[derive(Clone, Debug, Default, PartialEq, Eq)]
157pub(crate) struct ParsedUa {
158    browser: Option<String>,
159    major: Option<u32>,
160    os: Option<String>,
161}
162
163fn parse_user_agent(ua: &str) -> ParsedUa {
164    let Some(parsed) = woothee::parser::Parser::new().parse(ua) else {
165        return ParsedUa::default();
166    };
167    let known = |v: &str| (!v.is_empty() && v != woothee::woothee::VALUE_UNKNOWN).then(|| v.to_owned());
168    let browser = known(parsed.name);
169    ParsedUa {
170        major: browser
171            .as_ref()
172            .and(parsed.version.split('.').next())
173            .and_then(|m| m.parse().ok()),
174        os: known(parsed.os).map(|os| os_family(&os)),
175        browser,
176    }
177}
178
179/// Bounded LRU-ish cache of parsed User-Agents; most traffic repeats a handful of strings.
180pub(crate) struct UaCache(Cache<String, ParsedUa>);
181
182impl UaCache {
183    pub(crate) fn new(capacity: usize) -> Self {
184        Self(Cache::new(capacity.max(1)))
185    }
186
187    fn parse(&self, ua: &str) -> ParsedUa {
188        if let Some(hit) = self.0.get(ua) {
189            return hit;
190        }
191        let parsed = parse_user_agent(ua);
192        self.0.insert(ua.to_owned(), parsed.clone());
193        parsed
194    }
195}
196
197impl std::fmt::Debug for UaCache {
198    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
199        f.debug_struct("UaCache").field("len", &self.0.len()).finish()
200    }
201}
202
203fn os_family(os: &str) -> String {
204    match os {
205        os if os.starts_with("Windows") => "Windows",
206        "Mac OSX" | "Mac OS Classic" => "macOS",
207        "iPhone" | "iPad" | "iPod" | "iOS" => "iOS",
208        "ChromeOS" => "ChromeOS",
209        other => other,
210    }
211    .to_owned()
212}
213
214fn platform_family(platform: &str) -> Option<String> {
215    match platform.trim() {
216        "Unknown" | "" => None,
217        "Chrome OS" | "Chromium OS" => Some("ChromeOS".to_owned()),
218        other => Some(other.to_owned()),
219    }
220}
221
222fn normalise_languages(header: &str) -> Option<String> {
223    let tags: Vec<String> = header
224        .split(',')
225        .filter_map(|part| part.split(';').next())
226        .map(|tag| tag.trim().to_ascii_lowercase())
227        .filter(|tag| !tag.is_empty() && tag != "*")
228        .take(3)
229        .collect();
230    (!tags.is_empty()).then(|| tags.join(","))
231}
232
233#[cfg(test)]
234pub(crate) mod tests {
235    use super::*;
236
237    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";
238
239    fn ctx(ua: Option<&str>) -> ClientContext {
240        let mut ctx = ClientContext::new("203.0.113.77".parse().unwrap())
241            .with_ch_platform("\"Windows\"")
242            .with_ch_mobile(false)
243            .with_accept_language("nl-BE,nl;q=0.9,en-US;q=0.8,en;q=0.7");
244        ctx.user_agent = ua.map(str::to_owned);
245        ctx
246    }
247
248    #[test]
249    fn captures_normalised_signals() {
250        let fp = Fingerprint::capture(&ctx(Some(CHROME_WIN)), IpInfo::new(Some("BE".into()), Some(5432)));
251        assert_eq!(fp.ip_prefix, "203.0.113.0/24");
252        assert_eq!(fp.country.as_deref(), Some("BE"));
253        assert_eq!(fp.asn, Some(5432));
254        assert_eq!(fp.browser.as_deref(), Some("Chrome"));
255        assert_eq!(fp.browser_major, Some(141));
256        assert_eq!(fp.os.as_deref(), Some("Windows"));
257        assert_eq!(fp.ch_platform.as_deref(), Some("Windows"));
258        assert_eq!(fp.accept_language.as_deref(), Some("nl-be,nl,en-us"));
259    }
260
261    #[test]
262    fn cached_capture_matches_uncached() {
263        let cache = UaCache::new(8);
264        let a = Fingerprint::capture(&ctx(Some(CHROME_WIN)), IpInfo::default());
265        let b = Fingerprint::capture_cached(&ctx(Some(CHROME_WIN)), IpInfo::default(), &cache);
266        let c = Fingerprint::capture_cached(&ctx(Some(CHROME_WIN)), IpInfo::default(), &cache);
267        assert_eq!(a, b);
268        assert_eq!(b, c);
269    }
270
271    #[test]
272    fn garbage_user_agent_yields_unknowns() {
273        let fp = Fingerprint::capture(&ctx(Some("definitely not a browser")), IpInfo::default());
274        assert_eq!((fp.browser, fp.browser_major, fp.os), (None, None, None));
275        let fp = Fingerprint::capture(&ctx(None), IpInfo::default());
276        assert_eq!(fp.browser, None);
277    }
278
279    #[test]
280    fn os_families_and_platforms() {
281        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";
282        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";
283        assert_eq!(
284            Fingerprint::capture(&ctx(Some(mac)), IpInfo::default()).os.as_deref(),
285            Some("macOS")
286        );
287        assert_eq!(
288            Fingerprint::capture(&ctx(Some(iphone)), IpInfo::default())
289                .os
290                .as_deref(),
291            Some("iOS")
292        );
293        assert_eq!(platform_family("Chrome OS").as_deref(), Some("ChromeOS"));
294        assert_eq!(platform_family("Unknown"), None);
295        assert!(os_compatible("Android", "Linux") && os_compatible("macOS", "iOS"));
296        assert!(!os_compatible("Windows", "macOS"));
297    }
298
299    #[test]
300    fn ip_prefixes() {
301        assert_eq!(ip_prefix("2001:db8:abcd:12::1".parse().unwrap()), "2001:db8:abcd::/48");
302        assert_eq!(ip_prefix("10.1.2.3".parse().unwrap()), "10.1.2.0/24");
303    }
304
305    #[test]
306    fn languages_are_normalised() {
307        assert_eq!(normalise_languages("*"), None);
308        assert_eq!(normalise_languages(" EN ; q=1, fr").as_deref(), Some("en,fr"));
309    }
310
311    fn base() -> Fingerprint {
312        Fingerprint::capture(&ctx(Some(CHROME_WIN)), IpInfo::new(Some("BE".into()), Some(5432)))
313    }
314
315    #[test]
316    fn absorb_tracks_moves_and_never_downgrades_version() {
317        let now = Utc::now();
318        let original = base();
319        let mut baseline = Baseline::new(original.clone());
320
321        let mut moved = original.clone();
322        moved.ip_prefix = "198.51.100.0/24".to_owned();
323        moved.asn = Some(6848);
324        moved.country = None;
325        moved.browser_major = Some(140);
326        moved.accept_language = None;
327        assert!(baseline.absorb(&moved, now, &Checks::default()));
328
329        let fp = &baseline.fingerprint;
330        assert_eq!(fp.ip_prefix, "198.51.100.0/24");
331        assert_eq!(baseline.previous_ip_prefix.as_deref(), Some("203.0.113.0/24"));
332        assert_eq!(baseline.ip_changed_at, Some(now));
333        assert_eq!(fp.asn, Some(6848));
334        assert_eq!(fp.country.as_deref(), Some("BE"), "missing country never erases");
335        assert_eq!(fp.browser_major, Some(141));
336        assert_eq!(fp.accept_language, original.accept_language);
337    }
338
339    #[test]
340    fn absorb_never_learns_hard_identity() {
341        let mut baseline = Baseline::new(base());
342        let mut other = base();
343        other.browser = Some("Firefox".into());
344        other.os = Some("Linux".into());
345        other.ch_platform = Some("Linux".into());
346        other.ch_mobile = Some(true);
347        assert!(!baseline.absorb(&other, Utc::now(), &Checks::default()));
348        assert_eq!(baseline.fingerprint, base());
349    }
350
351    #[test]
352    fn absorb_learns_only_scored_signals() {
353        let original = base();
354        let mut baseline = Baseline::new(original.clone());
355        let mut moved = original.clone();
356        moved.ip_prefix = "198.51.100.0/24".to_owned();
357        moved.asn = Some(6848);
358        moved.accept_language = Some("de-DE".into());
359        let checks = Checks::default()
360            .disable(Check::AsnChanged)
361            .disable(Check::IpPrefixChanged)
362            .disable(Check::IpPingPong)
363            .weight(Check::AcceptLanguageChanged, 0);
364        assert!(!baseline.absorb(&moved, Utc::now(), &checks));
365        assert_eq!(baseline.fingerprint, original);
366
367        let ping_pong_only = Checks::none().enable(Check::IpPingPong);
368        assert!(baseline.absorb(&moved, Utc::now(), &ping_pong_only));
369        assert_eq!(baseline.fingerprint.ip_prefix, "198.51.100.0/24");
370        assert_eq!(baseline.fingerprint.asn, original.asn);
371    }
372
373    #[test]
374    fn absorb_reports_no_change_for_identical_request() {
375        let mut baseline = Baseline::new(base());
376        assert!(!baseline.absorb(&base(), Utc::now(), &Checks::default()));
377    }
378
379    #[test]
380    fn baseline_round_trips_json_and_tolerates_missing_fields() {
381        let b = Baseline::new(base());
382        let json = serde_json::to_string(&b).unwrap();
383        assert_eq!(serde_json::from_str::<Baseline>(&json).unwrap(), b);
384        assert_eq!(serde_json::from_str::<Baseline>("{}").unwrap(), Baseline::default());
385    }
386}