Skip to main content

session_guard/
checks.rs

1//! Risk checks, their weights and the score thresholds.
2
3use std::{fmt, str::FromStr};
4
5use serde::{Deserialize, Serialize};
6
7/// A risk signal compared between a session's baseline and the current request.
8#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
9#[serde(rename_all = "snake_case")]
10#[non_exhaustive]
11pub enum Check {
12    /// The operating system family changed (Android↔Linux and iOS↔macOS are compatible).
13    OsChanged,
14    /// The browser family changed.
15    BrowserChanged,
16    /// The `Sec-CH-UA-Platform` client hint changed to an incompatible platform.
17    PlatformHintChanged,
18    /// The platform hint contradicts the operating system in the User-Agent.
19    InconsistentHints,
20    /// Same browser, lower major version.
21    BrowserDowngraded,
22    /// The User-Agent disappeared or became unparseable.
23    UserAgentMissing,
24    /// `Sec-CH-UA-Mobile` flipped.
25    MobileHintChanged,
26    /// The IP moved onto a Tor exit node or an open proxy (needs an [`IpIntel`](crate::IpIntel)
27    /// source that reports [`Anonymity`](crate::Anonymity)).
28    TorOrProxyEntered,
29    /// The IP moved to another country (needs an [`IpIntel`](crate::IpIntel) source).
30    CountryChanged,
31    /// Client hints the session used to send disappeared.
32    ClientHintsMissing,
33    /// The TLS fingerprint changed while the browser version stayed the same.
34    TlsChanged,
35    /// The IP moved from a direct connection onto a VPN, hosting provider or residential proxy
36    /// (needs an [`IpIntel`](crate::IpIntel) source that reports
37    /// [`Anonymity`](crate::Anonymity)).
38    AnonymizerEntered,
39    /// The IP returned to the previous network shortly after leaving it.
40    IpPingPong,
41    /// The presented token was superseded and its successor never presented: a lost rotation
42    /// response, or a replayed stolen token.
43    OrphanedToken,
44    /// The IP moved to another autonomous system (needs an [`IpIntel`](crate::IpIntel) source).
45    AsnChanged,
46    /// The preferred languages changed.
47    AcceptLanguageChanged,
48    /// The IP left its /24 (IPv4) or /48 (IPv6) network.
49    IpPrefixChanged,
50}
51
52impl Check {
53    /// Every check, in declaration order.
54    pub const ALL: [Check; 17] = [
55        Check::OsChanged,
56        Check::BrowserChanged,
57        Check::PlatformHintChanged,
58        Check::InconsistentHints,
59        Check::BrowserDowngraded,
60        Check::UserAgentMissing,
61        Check::MobileHintChanged,
62        Check::TorOrProxyEntered,
63        Check::CountryChanged,
64        Check::ClientHintsMissing,
65        Check::TlsChanged,
66        Check::AnonymizerEntered,
67        Check::IpPingPong,
68        Check::OrphanedToken,
69        Check::AsnChanged,
70        Check::AcceptLanguageChanged,
71        Check::IpPrefixChanged,
72    ];
73
74    /// The weight used by [`Checks::default`].
75    pub const fn default_weight(self) -> u32 {
76        match self {
77            Check::OsChanged | Check::BrowserChanged | Check::PlatformHintChanged => 80,
78            Check::InconsistentHints => 60,
79            Check::BrowserDowngraded
80            | Check::UserAgentMissing
81            | Check::MobileHintChanged
82            | Check::TorOrProxyEntered => 50,
83            Check::CountryChanged => 40,
84            Check::ClientHintsMissing | Check::TlsChanged | Check::AnonymizerEntered => 30,
85            Check::IpPingPong | Check::OrphanedToken => 25,
86            Check::AsnChanged => 20,
87            Check::AcceptLanguageChanged => 15,
88            Check::IpPrefixChanged => 10,
89        }
90    }
91
92    /// Stable `snake_case` name, used in events, metrics and logs.
93    pub const fn as_str(self) -> &'static str {
94        match self {
95            Check::OsChanged => "os_changed",
96            Check::BrowserChanged => "browser_changed",
97            Check::PlatformHintChanged => "platform_hint_changed",
98            Check::InconsistentHints => "inconsistent_hints",
99            Check::BrowserDowngraded => "browser_downgraded",
100            Check::UserAgentMissing => "user_agent_missing",
101            Check::MobileHintChanged => "mobile_hint_changed",
102            Check::TorOrProxyEntered => "tor_or_proxy_entered",
103            Check::CountryChanged => "country_changed",
104            Check::ClientHintsMissing => "client_hints_missing",
105            Check::TlsChanged => "tls_changed",
106            Check::AnonymizerEntered => "anonymizer_entered",
107            Check::IpPingPong => "ip_ping_pong",
108            Check::OrphanedToken => "orphaned_token",
109            Check::AsnChanged => "asn_changed",
110            Check::AcceptLanguageChanged => "accept_language_changed",
111            Check::IpPrefixChanged => "ip_prefix_changed",
112        }
113    }
114
115    const fn index(self) -> usize {
116        self as usize
117    }
118}
119
120impl fmt::Display for Check {
121    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
122        f.write_str(self.as_str())
123    }
124}
125
126/// Returned when parsing an unknown check name.
127#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
128#[error("unknown check {0:?}")]
129pub struct UnknownCheck(pub String);
130
131impl FromStr for Check {
132    type Err = UnknownCheck;
133
134    fn from_str(s: &str) -> Result<Self, Self::Err> {
135        Check::ALL
136            .into_iter()
137            .find(|c| c.as_str() == s)
138            .ok_or_else(|| UnknownCheck(s.to_owned()))
139    }
140}
141
142/// Which checks run and how much each contributes to the score.
143///
144/// A disabled check is not computed at all.
145#[derive(Clone, Copy, Debug, PartialEq, Eq)]
146pub struct Checks {
147    weights: [Option<u32>; 17],
148}
149
150impl Default for Checks {
151    /// Every check enabled at its [default weight](Check::default_weight).
152    fn default() -> Self {
153        Self {
154            weights: Check::ALL.map(|c| Some(c.default_weight())),
155        }
156    }
157}
158
159impl Checks {
160    /// No checks: only token rotation and reuse detection protect the session.
161    pub const fn none() -> Self {
162        Self { weights: [None; 17] }
163    }
164
165    /// Default weights with network and environment signals raised, for sensitive routes:
166    /// IP prefix, ASN, language, TLS, ping-pong, an orphaned token and entering an anonymizer
167    /// weigh 40 (step-up on their own) and a country change or entering Tor or an open proxy
168    /// weighs 80 (revoke).
169    pub fn strict() -> Self {
170        Self::default()
171            .weight(Check::IpPrefixChanged, 40)
172            .weight(Check::AsnChanged, 40)
173            .weight(Check::AcceptLanguageChanged, 40)
174            .weight(Check::TlsChanged, 40)
175            .weight(Check::IpPingPong, 40)
176            .weight(Check::OrphanedToken, 40)
177            .weight(Check::AnonymizerEntered, 40)
178            .weight(Check::CountryChanged, 80)
179            .weight(Check::TorOrProxyEntered, 80)
180    }
181
182    /// Turns `check` off.
183    #[must_use]
184    pub const fn disable(mut self, check: Check) -> Self {
185        self.weights[check.index()] = None;
186        self
187    }
188
189    /// Turns `check` on at its default weight (keeps a custom weight if already enabled).
190    #[must_use]
191    pub const fn enable(mut self, check: Check) -> Self {
192        if self.weights[check.index()].is_none() {
193            self.weights[check.index()] = Some(check.default_weight());
194        }
195        self
196    }
197
198    /// Enables `check` with a custom weight.
199    #[must_use]
200    pub const fn weight(mut self, check: Check, weight: u32) -> Self {
201        self.weights[check.index()] = Some(weight);
202        self
203    }
204
205    /// Whether `check` runs.
206    pub const fn is_enabled(&self, check: Check) -> bool {
207        self.weights[check.index()].is_some()
208    }
209
210    /// The weight of `check`, or `None` if disabled.
211    pub const fn weight_of(&self, check: Check) -> Option<u32> {
212        self.weights[check.index()]
213    }
214
215    /// Whether `check` runs with a weight above zero.
216    pub(crate) const fn scores(&self, check: Check) -> bool {
217        matches!(self.weights[check.index()], Some(w) if w > 0)
218    }
219}
220
221/// What the score means.
222#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
223pub enum Decision {
224    /// Below the step-up threshold.
225    Allow,
226    /// At or above step-up, below revoke.
227    StepUp,
228    /// At or above revoke.
229    Revoke,
230}
231
232impl Decision {
233    /// Stable `snake_case` name.
234    pub const fn as_str(self) -> &'static str {
235        match self {
236            Decision::Allow => "allow",
237            Decision::StepUp => "step_up",
238            Decision::Revoke => "revoke",
239        }
240    }
241}
242
243/// Score thresholds. Defaults: step-up at 40, revoke at 80.
244#[derive(Clone, Copy, Debug, PartialEq, Eq)]
245pub struct Thresholds {
246    /// Scores at or above this need step-up.
247    pub step_up: u32,
248    /// Scores at or above this revoke the session.
249    pub revoke: u32,
250}
251
252impl Default for Thresholds {
253    fn default() -> Self {
254        Self {
255            step_up: 40,
256            revoke: 80,
257        }
258    }
259}
260
261impl Thresholds {
262    /// Maps a score to a decision.
263    pub const fn decide(&self, score: u32) -> Decision {
264        if score >= self.revoke {
265            Decision::Revoke
266        } else if score >= self.step_up {
267            Decision::StepUp
268        } else {
269            Decision::Allow
270        }
271    }
272
273    pub(crate) const fn is_valid(self) -> bool {
274        self.step_up > 0 && self.step_up <= self.revoke
275    }
276}
277
278#[cfg(test)]
279mod tests {
280    use super::*;
281
282    #[test]
283    fn default_weights_match_the_spec() {
284        let c = Checks::default();
285        let expected = [80, 80, 80, 60, 50, 50, 50, 50, 40, 30, 30, 30, 25, 25, 20, 15, 10];
286        assert_eq!(Check::ALL.len(), expected.len());
287        for (check, w) in Check::ALL.into_iter().zip(expected) {
288            assert_eq!(c.weight_of(check), Some(w), "{check}");
289        }
290    }
291
292    #[test]
293    fn names_round_trip_and_match_serde() {
294        for check in Check::ALL {
295            assert_eq!(check.as_str().parse::<Check>(), Ok(check));
296            assert_eq!(
297                serde_json::to_string(&check).unwrap(),
298                format!("\"{}\"", check.as_str())
299            );
300        }
301        assert_eq!("orphaned_token".parse::<Check>(), Ok(Check::OrphanedToken));
302        assert_eq!("anonymizer_entered".parse::<Check>(), Ok(Check::AnonymizerEntered));
303        assert_eq!("tor_or_proxy_entered".parse::<Check>(), Ok(Check::TorOrProxyEntered));
304        assert!("nope".parse::<Check>().is_err());
305    }
306
307    #[test]
308    fn enable_disable_and_weight() {
309        let c = Checks::default().disable(Check::AsnChanged);
310        assert!(!c.is_enabled(Check::AsnChanged));
311        assert_eq!(c.enable(Check::AsnChanged).weight_of(Check::AsnChanged), Some(20));
312        let c = Checks::none().weight(Check::OsChanged, 5).enable(Check::OsChanged);
313        assert_eq!(c.weight_of(Check::OsChanged), Some(5));
314        assert!(Check::ALL.into_iter().skip(1).all(|ch| !c.is_enabled(ch)));
315    }
316
317    #[test]
318    fn strict_raises_soft_signals() {
319        let s = Checks::strict();
320        assert_eq!(s.weight_of(Check::IpPrefixChanged), Some(40));
321        assert_eq!(s.weight_of(Check::OrphanedToken), Some(40));
322        assert_eq!(s.weight_of(Check::CountryChanged), Some(80));
323        assert_eq!(s.weight_of(Check::AnonymizerEntered), Some(40));
324        assert_eq!(s.weight_of(Check::TorOrProxyEntered), Some(80));
325        assert_eq!(s.weight_of(Check::OsChanged), Some(80));
326    }
327
328    #[test]
329    fn thresholds_decide() {
330        let t = Thresholds::default();
331        assert_eq!(t.decide(0), Decision::Allow);
332        assert_eq!(t.decide(39), Decision::Allow);
333        assert_eq!(t.decide(40), Decision::StepUp);
334        assert_eq!(t.decide(79), Decision::StepUp);
335        assert_eq!(t.decide(80), Decision::Revoke);
336        assert!(t.is_valid());
337        assert!(!Thresholds { step_up: 0, revoke: 10 }.is_valid());
338        assert!(
339            !Thresholds {
340                step_up: 50,
341                revoke: 10
342            }
343            .is_valid()
344        );
345    }
346}