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 CountryChanged,
28 ClientHintsMissing,
30 TlsChanged,
32 IpPingPong,
34 OrphanedToken,
37 AsnChanged,
39 AcceptLanguageChanged,
41 IpPrefixChanged,
43}
44
45impl Check {
46 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 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 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#[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#[derive(Clone, Copy, Debug, PartialEq, Eq)]
132pub struct Checks {
133 weights: [Option<u32>; 15],
134}
135
136impl Default for Checks {
137 fn default() -> Self {
139 Self {
140 weights: Check::ALL.map(|c| Some(c.default_weight())),
141 }
142 }
143}
144
145impl Checks {
146 pub const fn none() -> Self {
148 Self { weights: [None; 15] }
149 }
150
151 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 #[must_use]
167 pub const fn disable(mut self, check: Check) -> Self {
168 self.weights[check.index()] = None;
169 self
170 }
171
172 #[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 #[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 pub const fn is_enabled(&self, check: Check) -> bool {
190 self.weights[check.index()].is_some()
191 }
192
193 pub const fn weight_of(&self, check: Check) -> Option<u32> {
195 self.weights[check.index()]
196 }
197
198 pub(crate) const fn scores(&self, check: Check) -> bool {
200 matches!(self.weights[check.index()], Some(w) if w > 0)
201 }
202}
203
204#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
206pub enum Decision {
207 Allow,
209 StepUp,
211 Revoke,
213}
214
215impl Decision {
216 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#[derive(Clone, Copy, Debug, PartialEq, Eq)]
228pub struct Thresholds {
229 pub step_up: u32,
231 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 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}