OXYGENHUB
# a0a1601eb5a5 oxygenhub / crates / hub / src / throttle.rs
6.7 KiB · Rust · last changed Sign-in, 2FA codes, device codes and reset requests slow guessing 20 days ago source blame history raw
1
//! Slowing down guessing (oxygenhub#221): password sign-in, the 2FA code
2
//! step, password reset requests and device codes.
3
//!
4
//! Each key (an account or an address, within one action) counts its
5
//! failures. A few are free; after that each one doubles the wait before the
6
//! next try, up to a short lockout. A quiet hour forgets the count, and a
7
//! success clears the account's. Kept in memory: a restart forgets it, which
8
//! costs an attacker a restart they cannot cause.
9
10
use std::collections::HashMap;
11
12
/// Failures an account may have before waits start.
13
const ACCOUNT_FREE: u32 = 5;
14
/// Failures an address may have before waits start: higher, since many
15
/// people can share one address.
16
const ADDRESS_FREE: u32 = 20;
17
/// The first wait.
18
const FIRST_WAIT_MS: i64 = 2_000;
19
/// The longest wait: the lockout.
20
pub const LOCKOUT_MS: i64 = 15 * 60_000;
21
/// A key with no failure for this long starts again.
22
const FORGET_MS: i64 = 60 * 60_000;
23
/// Keys kept before stale ones are swept.
24
const SWEEP_AT: usize = 50_000;
25
26
#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug)]
27
pub enum Kind {
28
    Account,
29
    Address,
30
}
31
32
/// What is counted: an account or an address, within one action.
33
#[derive(Clone, PartialEq, Eq, Hash, Debug)]
34
pub struct Key {
35
    action: &'static str,
36
    kind: Kind,
37
    id: String,
38
}
39
40
impl Key {
41
    /// An account, by name; names compare without case.
42
    pub fn account(action: &'static str, name: &str) -> Key {
43
        Key { action, kind: Kind::Account, id: name.trim().to_lowercase() }
44
    }
45
46
    /// An address. An empty one (unknown) is never counted.
47
    pub fn address(action: &'static str, addr: &str) -> Key {
48
        Key { action, kind: Kind::Address, id: addr.to_string() }
49
    }
50
51
    fn free(&self) -> u32 {
52
        match self.kind {
53
            Kind::Account => ACCOUNT_FREE,
54
            Kind::Address => ADDRESS_FREE,
55
        }
56
    }
57
}
58
59
#[derive(Default)]
60
struct Count {
61
    failures: u32,
62
    last_ms: i64,
63
    until_ms: i64,
64
}
65
66
#[derive(Default)]
67
pub struct Throttle {
68
    counts: HashMap<Key, Count>,
69
}
70
71
impl Throttle {
72
    /// How long, in milliseconds, before any of these keys may try again;
73
    /// `None` when all may try now.
74
    pub fn wait(&self, keys: &[Key], now_ms: i64) -> Option<i64> {
75
        keys.iter()
76
            .filter_map(|k| self.counts.get(k))
77
            .filter(|c| now_ms - c.last_ms < FORGET_MS)
78
            .map(|c| c.until_ms - now_ms)
79
            .filter(|w| *w > 0)
80
            .max()
81
    }
82
83
    /// Count a failure (or, for an action with no failure, an attempt)
84
    /// against each key; the wait it leaves, if any.
85
    pub fn failed(&mut self, keys: &[Key], now_ms: i64) -> Option<i64> {
86
        if self.counts.len() >= SWEEP_AT {
87
            self.counts.retain(|_, c| now_ms - c.last_ms < FORGET_MS);
88
        }
89
        for key in keys.iter().filter(|k| !k.id.is_empty()) {
90
            let count = self.counts.entry(key.clone()).or_default();
91
            if now_ms - count.last_ms >= FORGET_MS {
92
                *count = Count::default();
93
            }
94
            count.failures += 1;
95
            count.last_ms = now_ms;
96
            if count.failures > key.free() {
97
                let doublings = (count.failures - key.free() - 1).min(20);
98
                count.until_ms = now_ms + (FIRST_WAIT_MS << doublings).min(LOCKOUT_MS);
99
            }
100
        }
101
        self.wait(keys, now_ms)
102
    }
103
104
    /// A success: the key starts again.
105
    pub fn cleared(&mut self, key: &Key) {
106
        self.counts.remove(key);
107
    }
108
}
109
110
/// "Too many tries: try again in 2 minutes." for a wait.
111
pub fn message(wait_ms: i64) -> String {
112
    let secs = (wait_ms + 999) / 1000;
113
    let when = match secs {
114
        ..=1 => "a second".to_string(),
115
        2..=59 => format!("{secs} seconds"),
116
        _ => {
117
            let mins = (secs + 59) / 60;
118
            if mins == 1 {
119
                "a minute".to_string()
120
            } else {
121
                format!("{mins} minutes")
122
            }
123
        }
124
    };
125
    format!("Too many tries: try again in {when}.")
126
}
127
128
#[cfg(test)]
129
mod tests {
130
    use super::*;
131
132
    const T0: i64 = 1_000_000_000;
133
134
    #[test]
135
    fn an_account_gets_a_few_free_failures_then_doubling_waits_up_to_the_lockout() {
136
        let mut t = Throttle::default();
137
        let k = [Key::account("login", "Alice")];
138
        for _ in 0..ACCOUNT_FREE {
139
            assert_eq!(t.wait(&k, T0), None);
140
            t.failed(&k, T0);
141
        }
142
        assert_eq!(t.wait(&k, T0), None, "the free failures cost nothing");
143
        assert_eq!(t.failed(&k, T0), Some(2_000), "the failure says the wait it starts");
144
        t.failed(&k, T0);
145
        assert_eq!(t.wait(&k, T0), Some(4_000));
146
        for _ in 0..20 {
147
            t.failed(&k, T0);
148
        }
149
        assert_eq!(t.wait(&k, T0), Some(LOCKOUT_MS), "capped at the lockout");
150
        assert_eq!(t.wait(&k, T0 + LOCKOUT_MS), None, "and it ends");
151
        assert_eq!(t.wait(&[Key::account("login", " alice ")], T0), Some(LOCKOUT_MS), "names compare without case");
152
    }
153
154
    #[test]
155
    fn a_success_clears_the_account_and_a_quiet_hour_forgets() {
156
        let mut t = Throttle::default();
157
        let k = [Key::account("login", "bob")];
158
        for _ in 0..10 {
159
            t.failed(&k, T0);
160
        }
161
        assert!(t.wait(&k, T0).is_some());
162
        t.cleared(&k[0]);
163
        assert_eq!(t.wait(&k, T0), None);
164
165
        for _ in 0..10 {
166
            t.failed(&k, T0);
167
        }
168
        let later = T0 + FORGET_MS;
169
        assert_eq!(t.wait(&k, later), None);
170
        t.failed(&k, later);
171
        assert_eq!(t.wait(&k, later), None, "the count started again");
172
    }
173
174
    #[test]
175
    fn addresses_get_more_room_and_actions_and_unknown_addresses_are_apart() {
176
        let mut t = Throttle::default();
177
        let addr = [Key::address("login", "203.0.113.9")];
178
        for _ in 0..ADDRESS_FREE {
179
            t.failed(&addr, T0);
180
        }
181
        assert_eq!(t.wait(&addr, T0), None);
182
        t.failed(&addr, T0);
183
        assert_eq!(t.wait(&addr, T0), Some(2_000));
184
        assert_eq!(t.wait(&[Key::address("reset", "203.0.113.9")], T0), None, "another action counts apart");
185
186
        let unknown = [Key::address("login", "")];
187
        for _ in 0..100 {
188
            t.failed(&unknown, T0);
189
        }
190
        assert_eq!(t.wait(&unknown, T0), None, "an unknown address is never counted");
191
192
        // The longest wait of the keys given is the one that counts.
193
        let both = [Key::account("login", "carol"), addr[0].clone()];
194
        assert_eq!(t.wait(&both, T0), Some(2_000));
195
    }
196
197
    #[test]
198
    fn the_message_says_when() {
199
        assert_eq!(message(900), "Too many tries: try again in a second.");
200
        assert_eq!(message(4_000), "Too many tries: try again in 4 seconds.");
201
        assert_eq!(message(61_000), "Too many tries: try again in 2 minutes.");
202
        assert_eq!(message(LOCKOUT_MS), "Too many tries: try again in 15 minutes.");
203
    }
204
}