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