From 8ac04a0c2f5cd949a45866f938bd1aa72fbe56e2 Mon Sep 17 00:00:00 2001 From: cirakg Date: Mon, 19 Oct 2026 15:27:37 +0200 Subject: test(storage): cover records, vault, PINs, lockout and migrations 34 new tests (65 per device): round trips per entry type, every tampered byte, swapped records, the pending queue, the entry cap, a full store at 67 fill levels, the lockout schedule on a fake clock including restarts, erase, PIN change and reset, auto-lock wiping, and v0 to v1 migration. Adds an in-memory store with a size limit and storage benchmarks. Co-Authored-By: Claude Opus 5.5 --- test/storage/PinTest.mc | 250 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 250 insertions(+) create mode 100644 test/storage/PinTest.mc (limited to 'test/storage/PinTest.mc') 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); +} -- cgit v1.2.3