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 | 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 | 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 | 27 | for(auto &a : this->slots) | |
| 55 | 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 | 147 | if(failures < LOGIN_FREE_FAILURES) return 0; | |
| 60 | 39 | uint32_t ms = LOGIN_FIRST_LOCKOUT_MS; | |
| 61 | 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 | 11 | if(!a.used || a.lockoutMs == 0) return 0; | |
| 70 | 9 | uint64_t elapsed = now - a.lastFailure; | |
| 71 | 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 | 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 | 81 | for(const auto &a : this->slots) { | |
| 82 | 73 | if(vacant(a, now)) anyVacant = true; | |
| 83 | 27 | else if(a.ip == ip) return &a; | |
| 84 | } | ||
| 85 | 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 | 630 | for(auto &a : this->slots) { | |
| 92 | 582 | if(vacant(a, now)) { if(!vacantSlot) vacantSlot = &a; } | |
| 93 | 363 | else if(a.ip == ip) return &a; | |
| 94 | } | ||
| 95 | 48 | login_attempts_t *a = vacantSlot ? vacantSlot : &this->shared; | |
| 96 | 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 |