aboutsummaryrefslogtreecommitdiff
path: root/test/storage/PinTest.mc
blob: 7dc677debd65765a45e4d600799728fe2af7e4e9 (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
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
// Author:  cirakg & claude ai
// Summary: Tests for PINs: create, unlock, wrong PIN, the lockout schedule on a fake clock, erase, change, reset, auto-lock.

import Toybox.Lang;
import Toybox.Test;

(:test)
function pinCreateUnlockAndWrongPin(logger as Test.Logger) as Boolean {
    var store = new MemoryBackend();
    var vault = new Vault(store, new FakeTime());
    if (vault.hasPin() || vault.session.isUnlocked()) {
        logger.error("fresh vault state wrong");
        return false;
    }
    StorageFixtures.setPin(vault, StorageFixtures.PIN);
    if (!vault.hasPin() || !vault.session.isUnlocked()) {
        logger.error("setting the first PIN should leave the session unlocked");
        return false;
    }
    vault.lock();
    // A new Vault (app restart) on the same store.
    var reopened = new Vault(store, new FakeTime());
    var wrong = StorageFixtures.unlock(reopened, StorageFixtures.OTHER_PIN);
    var failuresAfterWrong = reopened.lockout.failures();
    var right = StorageFixtures.unlock(reopened, StorageFixtures.PIN);
    return !wrong && failuresAfterWrong == 1 && right && reopened.session.isUnlocked()
        && reopened.lockout.failures() == 0 && store.getValue(Lockout.STORAGE_KEY) == null;
}

(:test)
function pinRejectsMalformedPins(logger as Test.Logger) as Boolean {
    var bad = ["123", "12345678901", "12a4", ""];
    for (var i = 0; i < bad.size(); i++) {
        if (KeyManager.isValidPin(Bytes.fromUtf8(bad[i]))) {
            logger.error("accepted PIN '" + bad[i] + "'");
            return false;
        }
    }
    return KeyManager.isValidPin(Bytes.fromUtf8("0000")) && KeyManager.isValidPin(Bytes.fromUtf8("0123456789"));
}

(:test)
function lockoutDelaySchedule(logger as Test.Logger) as Boolean {
    var expected = [0, 0, 0, 0, 0, 30, 60, 120, 240, 480, 960, 1920, 3600, 3600, 3600];
    for (var f = 0; f < expected.size(); f++) {
        if (Lockout.delayFor(f) != expected[f]) {
            logger.error("delay after " + f + " failures: " + Lockout.delayFor(f));
            return false;
        }
    }
    return Lockout.delayFor(1000) == Lockout.MAX_DELAY_S;
}

// Four free attempts, then the 5th failure starts a 30 s wait; an attempt
// during the wait is refused without being counted; the wait survives an
// app restart.
(:test)
function lockoutBlocksAndSurvivesRestart(logger as Test.Logger) as Boolean {
    var store = new MemoryBackend();
    var time = new FakeTime();
    var vault = new Vault(store, time);
    StorageFixtures.setPin(vault, StorageFixtures.PIN);
    vault.lock();
    for (var i = 0; i < 5; i++) {
        if (StorageFixtures.unlock(vault, StorageFixtures.OTHER_PIN)) {
            return false;
        }
    }
    if (vault.lockout.remainingSeconds() != 30) {
        logger.error("expected 30 s after 5 failures, got " + vault.lockout.remainingSeconds());
        return false;
    }
    var refused = 0;
    try {
        vault.beginUnlock(StorageFixtures.pin(StorageFixtures.PIN));
    } catch (err instanceof VaultError) {
        refused = err.code;
    }
    time.advance(10);
    var restarted = new Vault(store, time);
    var remainingAfterRestart = restarted.lockout.remainingSeconds();
    time.advance(20);
    var openAgain = restarted.lockout.remainingSeconds() == 0;
    var right = StorageFixtures.unlock(restarted, StorageFixtures.PIN);
    return refused == VaultError.LOCKED_OUT && restarted.lockout.failures() == 0 && remainingAfterRestart == 20
        && openAgain && right;
}

// Without a synced trusted clock the delay counts uptime and restarts in
// full when the app restarts (never shorter, sometimes longer).
(:test)
function lockoutUsesUptimeWithoutTrustedClock(logger as Test.Logger) as Boolean {
    var store = new MemoryBackend();
    var time = new FakeTime();
    time.trusted = null;
    var vault = new Vault(store, time);
    StorageFixtures.setPin(vault, StorageFixtures.PIN);
    vault.lock();
    for (var i = 0; i < 6; i++) {
        StorageFixtures.unlock(vault, StorageFixtures.OTHER_PIN);
        time.advance(Lockout.MAX_DELAY_S);
    }
    // 6 failures: 60 s delay, which has passed on the uptime clock above.
    StorageFixtures.unlock(vault, StorageFixtures.OTHER_PIN); // 7th failure: 120 s
    time.advance(100);
    var before = vault.lockout.remainingSeconds();
    var restarted = new Vault(store, time);
    var after = restarted.lockout.remainingSeconds();
    // A system timer that goes backwards (watch restart) also restarts the delay.
    time.uptime = 5;
    var afterReboot = restarted.lockout.remainingSeconds();
    return before == 20 && after == 120 && afterReboot == 120;
}

// A damaged lockout record must not reset the count.
(:test)
function lockoutTreatsDamagedStateConservatively(logger as Test.Logger) as Boolean {
    var store = new MemoryBackend();
    store.setValue(Lockout.STORAGE_KEY, {"f" => "garbage"});
    var lockout = new Lockout(store, new FakeTime());
    return lockout.failures() == Lockout.FREE_ATTEMPTS && Lockout.delayFor(lockout.failures() + 1) == Lockout.BASE_DELAY_S;
}

(:test)
function pinEraseAfterTenFailures(logger as Test.Logger) as Boolean {
    var store = new MemoryBackend();
    var time = new FakeTime();
    var vault = new Vault(store, time);
    vault.eraseAfterFailures = true;
    StorageFixtures.setPin(vault, StorageFixtures.PIN);
    var open = vault.add(StorageFixtures.totp("Unlocked"));
    var locked = StorageFixtures.totp("Locked");
    locked.locked = true;
    var lockedId = vault.add(locked);
    vault.lock();
    var pending = StorageFixtures.code("Pending");
    pending.locked = true;
    vault.add(pending);
    for (var i = 0; i < Lockout.ERASE_AFTER; i++) {
        if (i == Lockout.ERASE_AFTER - 1 && (!vault.hasPin() || vault.ids().size() != 2)) {
            logger.error("erased too early");
            return false;
        }
        StorageFixtures.unlock(vault, StorageFixtures.OTHER_PIN);
        time.advance(Lockout.MAX_DELAY_S);
    }
    var ids = vault.ids();
    return !vault.hasPin() && ids.size() == 1 && ids[0].equals(open) && vault.pendingIds().size() == 0
        && store.getValue("e." + lockedId) == null && vault.lockout.failures() == 0;
}

(:test)
function pinChangeKeepsEntries(logger as Test.Logger) as Boolean {
    var store = new MemoryBackend();
    var vault = new Vault(store, new FakeTime());
    StorageFixtures.setPin(vault, StorageFixtures.PIN);
    var e = StorageFixtures.totp("Locked");
    e.locked = true;
    var id = vault.add(e);
    var recordBefore = store.getValue("e." + id) as ByteArray;
    StorageFixtures.setPin(vault, StorageFixtures.OTHER_PIN); // while unlocked: re-wrap
    vault.lock();
    var oldPinFails = !StorageFixtures.unlock(vault, StorageFixtures.PIN);
    var newPinWorks = StorageFixtures.unlock(vault, StorageFixtures.OTHER_PIN);
    var opened = vault.open(id);
    var ok = StorageFixtures.same(logger, opened, e);
    opened.wipeSecrets();
    // Entries aren't re-encrypted by a PIN change.
    var unchanged = Bytes.equal(recordBefore, store.getValue("e." + id) as ByteArray);
    vault.lock();
    var lockedCode = 0;
    try {
        vault.beginSetPin(StorageFixtures.pin("999999"), StorageFixtures.ITERATIONS);
    } catch (err instanceof VaultError) {
        lockedCode = err.code;
    }
    return oldPinFails && newPinWorks && ok && unchanged && lockedCode == VaultError.LOCKED;
}

(:test)
function pinResetErasesOnlyLockedData(logger as Test.Logger) as Boolean {
    var store = new MemoryBackend();
    var vault = new Vault(store, new FakeTime());
    StorageFixtures.setPin(vault, StorageFixtures.PIN);
    var keep = vault.add(StorageFixtures.totp("Keep"));
    var gone = StorageFixtures.totp("Gone");
    gone.locked = true;
    vault.add(gone);
    vault.lock();
    vault.resetPin();
    StorageFixtures.setPin(vault, StorageFixtures.OTHER_PIN);
    var ids = vault.ids();
    var opened = vault.open(keep);
    opened.wipeSecrets();
    return ids.size() == 1 && ids[0].equals(keep) && vault.session.isUnlocked();
}

// A tampered iteration count or salt fails like a wrong PIN (or is refused
// before deriving when the count is out of range).
(:test)
function pinWrapIsAuthenticated(logger as Test.Logger) as Boolean {
    var store = new MemoryBackend();
    var vault = new Vault(store, new FakeTime());
    StorageFixtures.setPin(vault, StorageFixtures.PIN);
    vault.lock();
    var wrap = store.raw(KeyManager.WRAP_KEY) as ByteArray;
    wrap[0] = wrap[0] ^ 1; // salt
    var saltTampered = !StorageFixtures.unlock(vault, StorageFixtures.PIN);
    wrap[0] = wrap[0] ^ 1;
    wrap[KeyManager.SALT_BYTES] = 0x7F; // iteration count far above the maximum
    var code = 0;
    try {
        vault.beginUnlock(StorageFixtures.pin(StorageFixtures.PIN));
    } catch (err instanceof VaultError) {
        code = err.code;
    }
    return saltTampered && code == VaultError.DAMAGED;
}

(:test)
function sessionAutoLocksAndWipesKeys(logger as Test.Logger) as Boolean {
    var time = new FakeTime();
    var vault = new Vault(new MemoryBackend(), time);
    StorageFixtures.setPin(vault, StorageFixtures.PIN);
    var session = vault.session;
    var masterKey = session.masterKey();
    var keys = session.keys();
    session.autoLockMs = 30000;
    time.advance(29);
    var stillOpen = !session.checkExpiry();
    session.touch();
    time.advance(29);
    var stillOpenAfterTouch = !session.checkExpiry();
    time.advance(1);
    var lockedNow = session.checkExpiry();
    return stillOpen && stillOpenAfterTouch && lockedNow && !session.isUnlocked()
        && StorageFixtures.allZero(masterKey) && StorageFixtures.allZero(keys.encKey) && StorageFixtures.allZero(keys.macKey);
}

(:test)
function pinKeyBuffersAreWiped(logger as Test.Logger) as Boolean {
    var pbkdf2 = new Kdf.Pbkdf2(StorageFixtures.pin(StorageFixtures.PIN), new [16]b, StorageFixtures.ITERATIONS, KeyManager.KEY_BYTES);
    Jobs.runToCompletion(pbkdf2, Kdf.ITERATIONS_PER_STEP);
    var derived = !StorageFixtures.allZero(pbkdf2.result());
    pbkdf2.wipe();
    var wiped = StorageFixtures.allZero(pbkdf2.result());
    var keys = new SealedBox.Keys(new [32]b);
    keys.wipe();
    return derived && wiped && StorageFixtures.allZero(keys.encKey) && StorageFixtures.allZero(keys.macKey);
}