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