// 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; var t = _trusted; if (t != null) { state["t"] = t; } _store.setValue(STORAGE_KEY, state); } }