// 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); } // 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)); }