src/
Coverage:
low: ≥ 0%
medium: ≥ 75.0%
high: ≥ 90.0%
Lines:
42 / 42
100.0%
Branches:
47 / 50
94.0%
Functions:
8 / 8
100.0%

LoginThrottle.h
Line Branch Exec Source
1 #ifndef loginthrottle_h
2 #define loginthrottle_h
3 #include <stdint.h>
4
5 // Failed /login attempts per client IP (SECURITY_PLAN.md, PR C). The first
6 // LOGIN_FREE_FAILURES failures cost nothing, and the last of them locks the
7 // IP out for LOGIN_FIRST_LOCKOUT_MS. After that every failure locks it out
8 // again, for twice as long as the lockout before, up to LOGIN_MAX_LOCKOUT_MS.
9 // A success forgets the IP, and so does LOGIN_FORGET_MS without a failure.
10 //
11 // It keeps LOGIN_THROTTLE_SLOTS IPs. Once each holds a recent failure, every
12 // other IP shares one more slot, so an attacker with many addresses can't
13 // get the free failures on each: while the table is full, an IP it doesn't
14 // hold may have to wait even with the right credentials.
15 //
16 // The time is passed in, in ms on a 64-bit clock (esp_timer_get_time() /
17 // 1000), so native tests cover it and a slot left alone for 49.7 days isn't
18 // woken by millis() wrapping. Only async_tcp's task calls it: both web
19 // servers run their handlers there.
20
21 #define LOGIN_FREE_FAILURES 5
22 #define LOGIN_FIRST_LOCKOUT_MS 30000UL
23 #define LOGIN_MAX_LOCKOUT_MS 900000UL
24 #define LOGIN_FORGET_MS 3600000UL
25 #define LOGIN_THROTTLE_SLOTS 8
26
27 struct login_attempts_t {
28 bool used = false;
29 uint32_t ip = 0;
30 uint16_t failures = 0;
31 uint64_t lastFailure = 0; // the time of the last failure
32 uint32_t lockoutMs = 0; // the lockout that failure began, 0 for none
33 };
34
35 class LoginThrottle {
36 public:
37 // How long ip must wait before it may try again, in ms; 0 when it may now.
38 17 uint32_t retryAfter(uint32_t ip, uint64_t now) const {
39 17 const login_attempts_t *a = this->find(ip, now);
40
2/2
✓ Branch 0 taken 11 times.
✓ Branch 1 taken 6 times.
17 return a ? remaining(*a, now) : 0;
41 }
42 // A wrong PIN or password from ip. Returns the lockout it begins, in ms,
43 // or 0 while ip still has free failures.
44 134 uint32_t failed(uint32_t ip, uint64_t now) {
45 134 login_attempts_t *a = this->claim(ip, now);
46
1/2
✓ Branch 0 taken 134 times.
✗ Branch 1 not taken.
134 if(a->failures < UINT16_MAX) a->failures++;
47 134 a->lastFailure = now;
48 134 a->lockoutMs = lockoutFor(a->failures);
49 134 return a->lockoutMs;
50 }
51 // The right credentials from ip. The shared slot is left as it is: one
52 // user's success says nothing about the other addresses in it.
53 3 void succeeded(uint32_t ip) {
54
2/2
✓ Branch 0 taken 24 times.
✓ Branch 1 taken 3 times.
27 for(auto &a : this->slots)
55
4/4
✓ Branch 0 taken 10 times.
✓ Branch 1 taken 14 times.
✓ Branch 2 taken 1 time.
✓ Branch 3 taken 9 times.
24 if(a.used && a.ip == ip) a = login_attempts_t();
56 3 }
57 // The lockout the failures-th failure in a row begins.
58 147 static uint32_t lockoutFor(uint16_t failures) {
59
2/2
✓ Branch 0 taken 108 times.
✓ Branch 1 taken 39 times.
147 if(failures < LOGIN_FREE_FAILURES) return 0;
60 39 uint32_t ms = LOGIN_FIRST_LOCKOUT_MS;
61
4/4
✓ Branch 0 taken 52 times.
✓ Branch 1 taken 37 times.
✓ Branch 2 taken 50 times.
✓ Branch 3 taken 2 times.
89 for(uint16_t i = LOGIN_FREE_FAILURES; i < failures && ms < LOGIN_MAX_LOCKOUT_MS; i++) ms *= 2;
62 39 return ms < LOGIN_MAX_LOCKOUT_MS ? ms : LOGIN_MAX_LOCKOUT_MS;
63 }
64 private:
65 login_attempts_t slots[LOGIN_THROTTLE_SLOTS];
66 login_attempts_t shared;
67
68 11 static uint32_t remaining(const login_attempts_t &a, uint64_t now) {
69
3/4
✓ Branch 0 taken 11 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 2 times.
✓ Branch 3 taken 9 times.
11 if(!a.used || a.lockoutMs == 0) return 0;
70 9 uint64_t elapsed = now - a.lastFailure;
71
2/2
✓ Branch 0 taken 7 times.
✓ Branch 1 taken 2 times.
9 return elapsed < a.lockoutMs ? (uint32_t)(a.lockoutMs - elapsed) : 0;
72 }
73 // Unused, or its last failure is old enough to forget.
74 705 static bool vacant(const login_attempts_t &a, uint64_t now) {
75
4/4
✓ Branch 0 taken 429 times.
✓ Branch 1 taken 276 times.
✓ Branch 2 taken 29 times.
✓ Branch 3 taken 400 times.
705 return !a.used || now - a.lastFailure >= LOGIN_FORGET_MS;
76 }
77 // ip's record: its own slot, nothing while a slot is vacant, else the
78 // shared one.
79 17 const login_attempts_t *find(uint32_t ip, uint64_t now) const {
80 17 bool anyVacant = false;
81
2/2
✓ Branch 0 taken 73 times.
✓ Branch 1 taken 8 times.
81 for(const auto &a : this->slots) {
82
2/2
✓ Branch 1 taken 46 times.
✓ Branch 2 taken 27 times.
73 if(vacant(a, now)) anyVacant = true;
83
2/2
✓ Branch 0 taken 9 times.
✓ Branch 1 taken 18 times.
27 else if(a.ip == ip) return &a;
84 }
85
5/6
✓ Branch 0 taken 2 times.
✓ Branch 1 taken 6 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 2 times.
✓ Branch 5 taken 6 times.
✓ Branch 6 taken 2 times.
8 if(anyVacant || vacant(this->shared, now)) return nullptr;
86 2 return &this->shared;
87 }
88 // ip's record to count a failure in, taking a vacant slot when it has none.
89 134 login_attempts_t *claim(uint32_t ip, uint64_t now) {
90 134 login_attempts_t *vacantSlot = nullptr;
91
2/2
✓ Branch 0 taken 582 times.
✓ Branch 1 taken 48 times.
630 for(auto &a : this->slots) {
92
4/4
✓ Branch 1 taken 219 times.
✓ Branch 2 taken 363 times.
✓ Branch 3 taken 38 times.
✓ Branch 4 taken 181 times.
582 if(vacant(a, now)) { if(!vacantSlot) vacantSlot = &a; }
93
2/2
✓ Branch 0 taken 86 times.
✓ Branch 1 taken 277 times.
363 else if(a.ip == ip) return &a;
94 }
95
2/2
✓ Branch 0 taken 10 times.
✓ Branch 1 taken 38 times.
48 login_attempts_t *a = vacantSlot ? vacantSlot : &this->shared;
96
2/2
✓ Branch 1 taken 40 times.
✓ Branch 2 taken 8 times.
48 if(vacant(*a, now)) {
97 40 *a = login_attempts_t();
98 40 a->used = true;
99 40 a->ip = ip;
100 }
101 48 return a;
102 }
103 };
104 #endif
105