aboutsummaryrefslogtreecommitdiff
path: root/test/storage/PinTest.mc
blob: 55adaa216586bdc613b5be3ab3a146ac575398b1 (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
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
// Author:  cirakg & claude ai
// Summary: Tests for PINs: create, unlock, wrong PIN, 4-digit rule, legacy PINs, 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"));
}

// A new PIN has exactly KeyManager.PIN_DIGITS digits; other lengths are
// refused before any derivation.
(:test)
function pinSetRequiresFourDigits(logger as Test.Logger) as Boolean {
    var bad = ["123", "12345", "123456", "0123456789", "12a4"];
    for (var i = 0; i < bad.size(); i++) {
        if (KeyManager.isValidNewPin(Bytes.fromUtf8(bad[i]))) {
            logger.error("accepted new PIN '" + bad[i] + "'");
            return false;
        }
        var code = 0;
        try {
            new Vault(new MemoryBackend(), new FakeTime()).beginSetPin(StorageFixtures.pin(bad[i]), StorageFixtures.ITERATIONS);
        } catch (err instanceof VaultError) {
            code = err.code;
        }
        if (code != VaultError.INVALID) {
            logger.error("set PIN '" + bad[i] + "' gave code " + code);
            return false;
        }
    }
    return KeyManager.isValidNewPin(Bytes.fromUtf8("0000")) && KeyManager.isValidNewPin(Bytes.fromUtf8("9876"));
}

// Setting or changing the PIN stores the 4-digit marker; forgetting the
// PIN removes it.
(:test)
function pinMarkerIsSetBySetAndChange(logger as Test.Logger) as Boolean {
    var store = new MemoryBackend();
    var vault = new Vault(store, new FakeTime());
    StorageFixtures.setPin(vault, StorageFixtures.PIN);
    var afterSet = StorageFixtures.pinMarked(store) && !vault.isLegacyPin();
    store.deleteValue(KeyManager.PIN_LENGTH_KEY);
    var legacyWithout = vault.isLegacyPin();
    StorageFixtures.setPin(vault, StorageFixtures.OTHER_PIN); // change while unlocked
    var afterChange = StorageFixtures.pinMarked(store) && !vault.isLegacyPin();
    vault.resetPin();
    return afterSet && legacyWithout && afterChange && store.getValue(KeyManager.PIN_LENGTH_KEY) == null
        && !vault.isLegacyPin();
}

// A 6-digit PIN from before the 4-digit rule still unlocks (and a wrong
// one fails without an exception); a new 4-digit PIN then replaces it.
(:test)
function pinLegacySixDigitWrapStillUnlocks(logger as Test.Logger) as Boolean {
    var store = new MemoryBackend();
    StorageFixtures.setLegacyPin(store, StorageFixtures.LEGACY_PIN);
    var vault = new Vault(store, new FakeTime());
    var legacy = vault.hasPin() && vault.isLegacyPin();
    var wrong = !StorageFixtures.unlock(vault, "2468101");
    var right = StorageFixtures.unlock(vault, StorageFixtures.LEGACY_PIN) && vault.session.isUnlocked();
    StorageFixtures.setPin(vault, StorageFixtures.PIN);
    vault.lock();
    var oldFails = !StorageFixtures.unlock(vault, StorageFixtures.LEGACY_PIN);
    var newWorks = StorageFixtures.unlock(vault, StorageFixtures.PIN);
    return legacy && wrong && right && !vault.isLegacyPin() && oldFails && newWorks;
}

(: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("9999"), 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);
}

// The UI-test fixture's wrap was computed in Python (tools/gen_fixture_wrap.py);
// the app's unlock code must accept it with the fixture PIN and recover the
// fixture master key, which cross-checks the wrap format.
(:test)
function pinOpensThePythonComputedFixtureWrap(logger as Test.Logger) as Boolean {
    var store = new MemoryBackend();
    store.setValue(KeyManager.WRAP_KEY, Bytes.fromHex(Fixtures.FIXTURE_WRAP));
    var vault = new Vault(store, new FakeTime());
    var wrongRejected = !StorageFixtures.unlock(vault, StorageFixtures.OTHER_PIN);
    var opened = StorageFixtures.unlock(vault, Fixtures.FIXTURE_PIN);
    return wrongRejected && opened && Bytes.equal(vault.session.masterKey(), Bytes.fromHex(Fixtures.FIXTURE_MASTER_KEY));
}