aboutsummaryrefslogtreecommitdiff
path: root/source/storage/Lockout.mc
blob: 939b91bdf9491967f415c1a54b9930d109607823 (plain) (blame)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
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);
    }
}