aboutsummaryrefslogtreecommitdiff
path: root/test/storage/PinTest.mc
diff options
context:
space:
mode:
Diffstat (limited to 'test/storage/PinTest.mc')
-rw-r--r--test/storage/PinTest.mc250
1 files changed, 250 insertions, 0 deletions
diff --git a/test/storage/PinTest.mc b/test/storage/PinTest.mc
new file mode 100644
index 0000000..7dc677d
--- /dev/null
+++ b/test/storage/PinTest.mc
@@ -0,0 +1,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);
+}