1use std::{fmt, str::FromStr};
4
5use serde::{Deserialize, Serialize};
6
7#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
9#[serde(rename_all = "snake_case")]
10#[non_exhaustive]
11pub enum Check {
12 OsChanged,
14 BrowserChanged,
16 PlatformHintChanged,
18 InconsistentHints,
20 BrowserDowngraded,
22 UserAgentMissing,
24 MobileHintChanged,
26 TorOrProxyEntered,
29 CountryChanged,
31 ClientHintsMissing,
33 TlsChanged,
35 AnonymizerEntered,
39 IpPingPong,
41 OrphanedToken,
44 AsnChanged,
46 AcceptLanguageChanged,
48 IpPrefixChanged,
50}
51
52impl Check {
53 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 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 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#[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#[derive(Clone, Copy, Debug, PartialEq, Eq)]
146pub struct Checks {
147 weights: [Option<u32>; 17],
148}
149
150impl Default for Checks {
151 fn default() -> Self {
153 Self {
154 weights: Check::ALL.map(|c| Some(c.default_weight())),
155 }
156 }
157}
158
159impl Checks {
160 pub const fn none() -> Self {
162 Self { weights: [None; 17] }
163 }
164
165 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 #[must_use]
184 pub const fn disable(mut self, check: Check) -> Self {
185 self.weights[check.index()] = None;
186 self
187 }
188
189 #[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 #[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 pub const fn is_enabled(&self, check: Check) -> bool {
207 self.weights[check.index()].is_some()
208 }
209
210 pub const fn weight_of(&self, check: Check) -> Option<u32> {
212 self.weights[check.index()]
213 }
214
215 pub(crate) const fn scores(&self, check: Check) -> bool {
217 matches!(self.weights[check.index()], Some(w) if w > 0)
218 }
219}
220
221#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
223pub enum Decision {
224 Allow,
226 StepUp,
228 Revoke,
230}
231
232impl Decision {
233 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#[derive(Clone, Copy, Debug, PartialEq, Eq)]
245pub struct Thresholds {
246 pub step_up: u32,
248 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 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}