diff options
Diffstat (limited to 'source/storage/Lockout.mc')
| -rw-r--r-- | source/storage/Lockout.mc | 138 |
1 files changed, 138 insertions, 0 deletions
diff --git a/source/storage/Lockout.mc b/source/storage/Lockout.mc new file mode 100644 index 0000000..939b91b --- /dev/null +++ b/source/storage/Lockout.mc @@ -0,0 +1,138 @@ +// Author: cirakg & claude ai +// Summary: Wrong-PIN policy: persistent failure count, growing delays on a trusted clock, and the erase threshold. +// +// Rules (README 7.6): +// * Every attempt is counted and written to storage BEFORE the PIN is +// checked (recordAttempt), and the count is cleared on success. Killing +// the app mid-check therefore costs the attacker an attempt. +// * Four free attempts. After the 5th failure the next attempt waits 30 s, +// then 1 min, 2 min, 4 min and so on, capped at 1 hour. +// * Delays are timed with the trusted clock when it is synced. Otherwise +// they count system uptime from the moment this app instance started +// (or from the attempt, if later), so restarting the app restarts the +// delay in full: never shorter, sometimes longer. +// * failures() >= ERASE_AFTER is the point where the optional "erase locked +// entries" setting applies; the vault does the erasing. +// +// Stored under "lk" as a dictionary: +// "f" failures (attempts not followed by a success), "d" delay in seconds +// before the next attempt, "t" trusted unix time of the last attempt (only +// when the trusted clock was synced). The uptime anchor is kept in memory +// only: every app start sets it anew. + +import Toybox.Application; +import Toybox.Lang; + +class Lockout { + + static const STORAGE_KEY = "lk"; + static const FREE_ATTEMPTS = 4; + static const BASE_DELAY_S = 30; + static const MAX_DELAY_S = 3600; + static const ERASE_AFTER = 10; + + private var _store as Persist.Backend; + private var _time as TimeSource; + private var _failures as Number = 0; + private var _delay as Number = 0; + private var _trusted as Long? = null; + private var _uptime as Number = 0; + + // Loads the state. A running delay restarts its uptime count here, which + // is what makes an app restart unable to shorten it. + function initialize(store as Persist.Backend, time as TimeSource) { + _store = store; + _time = time; + var state = store.getValue(STORAGE_KEY); + if (state instanceof Dictionary) { + var f = state["f"]; + var d = state["d"]; + var t = state["t"]; + if (f instanceof Number && f >= 0 && d instanceof Number && d >= 0) { + _failures = f; + _delay = d > MAX_DELAY_S ? MAX_DELAY_S : d; + _trusted = t instanceof Long ? t : null; + } else { + // Unreadable state is not a reset: no free attempts are left, + // so the next failure starts the delays. + _failures = FREE_ATTEMPTS; + } + } + _uptime = time.uptimeMs(); + } + + // The delay after `failures` consecutive wrong PINs, in seconds. + static function delayFor(failures as Number) as Number { + if (failures <= FREE_ATTEMPTS) { + return 0; + } + var doublings = failures - FREE_ATTEMPTS - 1; + if (doublings >= 7) { // 30 s * 2^7 is already over the cap + return MAX_DELAY_S; + } + var delay = BASE_DELAY_S << doublings; + return delay > MAX_DELAY_S ? MAX_DELAY_S : delay; + } + + function failures() as Number { + return _failures; + } + + // Seconds until the next attempt is allowed; 0 when it is allowed now. + function remainingSeconds() as Number { + if (_delay == 0) { + return 0; + } + var elapsed = 0; + var trustedNow = _time.trustedUnix(); + var trustedThen = _trusted; + if (trustedNow != null && trustedThen != null && trustedNow >= trustedThen) { + elapsed = (trustedNow - trustedThen).toNumber(); + } else { + var now = _time.uptimeMs(); + if (now < _uptime) { + // The timer restarted or wrapped: count from now. + _uptime = now; + } + elapsed = (now - _uptime) / 1000; + } + var remaining = _delay - elapsed; + return remaining > 0 ? remaining : 0; + } + + // Count an attempt before the PIN is checked. Throws VaultError + // LOCKED_OUT, without counting, while a delay runs. + function recordAttempt() as Void { + var remaining = remainingSeconds(); + if (remaining > 0) { + throw new VaultError(VaultError.LOCKED_OUT, "wait " + remaining + " s"); + } + _failures++; + _delay = delayFor(_failures); + _trusted = _time.trustedUnix(); + _uptime = _time.uptimeMs(); + save(); + } + + // The PIN was right: forget all failures. + function recordSuccess() as Void { + clear(); + } + + // Forget all failures (after success, a PIN reset or an erase). + function clear() as Void { + _failures = 0; + _delay = 0; + _trusted = null; + _store.deleteValue(STORAGE_KEY); + } + + private function save() as Void { + var state = {"f" => _failures, "d" => _delay} as Dictionary<Application.Storage.KeyType, Application.Storage.ValueType>; + var t = _trusted; + if (t != null) { + state["t"] = t; + } + _store.setValue(STORAGE_KEY, state); + } +} |
