summaryrefslogtreecommitdiff
path: root/test/storage
diff options
context:
space:
mode:
Diffstat (limited to 'test/storage')
-rw-r--r--test/storage/MigrationTest.mc69
-rw-r--r--test/storage/PinTest.mc250
-rw-r--r--test/storage/RecordTest.mc174
-rw-r--r--test/storage/StorageSupport.mc250
-rw-r--r--test/storage/VaultTest.mc250
5 files changed, 993 insertions, 0 deletions
diff --git a/test/storage/MigrationTest.mc b/test/storage/MigrationTest.mc
new file mode 100644
index 0000000..046ee04
--- /dev/null
+++ b/test/storage/MigrationTest.mc
@@ -0,0 +1,69 @@
+// Author: cirakg & claude ai
+// Summary: Tests for schema migration: fresh store, the v0 fixture to v1, idempotency, newer and damaged metadata.
+
+import Toybox.Application;
+import Toybox.Lang;
+import Toybox.Test;
+
+(:test)
+function migrationCreatesFreshMetadata(logger as Test.Logger) as Boolean {
+ var store = new MemoryBackend();
+ var vault = new Vault(store, new FakeTime());
+ var meta = store.getValue(Migration.META_KEY) as Dictionary<Application.Storage.KeyType, Application.Storage.ValueType>;
+ return vault.ids().size() == 0 && (meta["schema"] as Number) == Migration.CURRENT;
+}
+
+// The v0 fixture: entries written in the current record format, listed by
+// the old "order" key. After migration they open as before, and running the
+// migration again changes nothing.
+(:test)
+function migrationV0ToV1(logger as Test.Logger) as Boolean {
+ var store = new MemoryBackend();
+ var vault = new Vault(store, new FakeTime());
+ var a = vault.add(StorageFixtures.totp("A"));
+ var b = vault.add(StorageFixtures.code("B"));
+ store.setValue(Migration.META_KEY, {"order" => [b, a]});
+ var migrated = new Vault(store, new FakeTime());
+ var ids = migrated.ids();
+ var meta = store.getValue(Migration.META_KEY) as Dictionary<Application.Storage.KeyType, Application.Storage.ValueType>;
+ var opened = migrated.open(a);
+ opened.wipeSecrets();
+ var again = new Vault(store, new FakeTime());
+ return ids.size() == 2 && ids[0].equals(b) && ids[1].equals(a) && (meta["schema"] as Number) == 1
+ && !meta.hasKey("order") && again.ids().size() == 2 && again.pendingIds().size() == 0;
+}
+
+(:test)
+function migrationRefusesNewerAndDamagedMetadata(logger as Test.Logger) as Boolean {
+ var cases = [
+ {"schema" => Migration.CURRENT + 1, "ids" => [], "pending" => []},
+ "not a dictionary",
+ {"schema" => "1", "ids" => [], "pending" => []},
+ {"schema" => 1, "ids" => ["K7F2", "K7F2"], "pending" => []},
+ {"schema" => 1, "ids" => ["bad"], "pending" => []},
+ {"schema" => 1, "ids" => [], "pending" => 5}
+ ];
+ var expected = [VaultError.NEWER_SCHEMA, VaultError.DAMAGED, VaultError.DAMAGED, VaultError.DAMAGED,
+ VaultError.DAMAGED, VaultError.DAMAGED];
+ for (var i = 0; i < cases.size(); i++) {
+ var store = new MemoryBackend();
+ store.setValue(Migration.META_KEY, cases[i] as Application.Storage.ValueType);
+ var code = 0;
+ try {
+ new Vault(store, new FakeTime());
+ } catch (err instanceof VaultError) {
+ code = err.code;
+ }
+ if (code != expected[i]) {
+ logger.error("case " + i + ": got " + code);
+ return false;
+ }
+ // Newer or damaged data is never modified.
+ var after = store.getValue(Migration.META_KEY);
+ if (cases[i] instanceof String ? !(after as String).equals(cases[i] as String) : MemoryBackend.sizeOf(after) != MemoryBackend.sizeOf(cases[i] as Application.Storage.ValueType)) {
+ logger.error("case " + i + ": metadata was modified");
+ return false;
+ }
+ }
+ return true;
+}
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);
+}
diff --git a/test/storage/RecordTest.mc b/test/storage/RecordTest.mc
new file mode 100644
index 0000000..682f7a7
--- /dev/null
+++ b/test/storage/RecordTest.mc
@@ -0,0 +1,174 @@
+// Author: cirakg & claude ai
+// Summary: Tests for the TLV codec and the record format: round trips per entry type, strict decoding, tamper detection.
+
+import Toybox.Lang;
+import Toybox.Test;
+
+(:test)
+function codecRoundTripsEveryType(logger as Test.Logger) as Boolean {
+ var w = new Codec.Writer();
+ w.addBytes(1, [0, 255, 7]b);
+ w.addText(2, "Zürich ✓");
+ w.addText(3, "");
+ w.addInt(4, -2l);
+ w.addInt(5, 0x7FFFFFFFFFFFFFFFl);
+ w.addBool(6, true);
+ w.addBool(7, false);
+ var f = Codec.decode(w.bytes);
+ if (f == null) {
+ logger.error("decode failed");
+ return false;
+ }
+ return Bytes.equal(f[1] as ByteArray, [0, 255, 7]b)
+ && (f[2] as String).equals("Zürich ✓") && (f[3] as String).equals("")
+ && (f[4] as Long) == -2l && (f[5] as Long) == 0x7FFFFFFFFFFFFFFFl
+ && (f[6] as Boolean) == true && (f[7] as Boolean) == false;
+}
+
+(:test)
+function codecRejectsMalformedInput(logger as Test.Logger) as Boolean {
+ var bad = [
+ [1]b, // truncated field header
+ [1, 1, 0, 5, 1, 2]b, // value shorter than its length
+ [1, 9, 0, 0]b, // unknown type
+ [1, 3, 0, 4, 0, 0, 0, 0]b, // integer that isn't 8 bytes
+ [1, 4, 0, 1, 2]b, // boolean other than 0/1
+ [1, 1, 0, 0, 1, 1, 0, 0]b, // duplicate tag
+ [1, 2, 0, 2, 0xC3, 0x28]b // invalid UTF-8
+ ];
+ for (var i = 0; i < bad.size(); i++) {
+ if (Codec.decode(bad[i] as ByteArray) != null) {
+ logger.error("malformed input " + i + " was accepted");
+ return false;
+ }
+ }
+ var empty = Codec.decode([]b);
+ return empty != null && empty.size() == 0;
+}
+
+(:test)
+function recordRoundTripsEveryEntryType(logger as Test.Logger) as Boolean {
+ var keys = SealedBoxFixtures.keys(7);
+ var entries = [
+ StorageFixtures.totp("Example TOTP"),
+ StorageFixtures.hotp("Example HOTP", 4294967297l),
+ StorageFixtures.steam("Example Steam"),
+ StorageFixtures.code("Gym card")
+ ];
+ for (var i = 0; i < entries.size(); i++) {
+ for (var names = 0; names < 2; names++) {
+ var e = entries[i] as Entry;
+ e.id = "K7F" + (i + 2);
+ e.namesEncrypted = names == 1;
+ var record = Record.seal(e, Record.KEY_DEVICE, keys);
+ var back = Record.open(record, e.id, keys);
+ if (back == null || !StorageFixtures.same(logger, back, e)) {
+ logger.error("round trip failed for entry " + i + ", namesEncrypted " + e.namesEncrypted);
+ return false;
+ }
+ var header = Record.decodeHeader(record);
+ if (header == null || header.secret != null || header.payload != null
+ || header.name.equals(e.name) == e.namesEncrypted) {
+ logger.error("header of entry " + i + " exposes or loses data");
+ return false;
+ }
+ }
+ }
+ return true;
+}
+
+// The record's MAC covers the ID, the prefix and the header, so flipping any
+// single byte of a record must make open() fail.
+(:test)
+function recordDetectsEveryTamperedByte(logger as Test.Logger) as Boolean {
+ var keys = SealedBoxFixtures.keys(8);
+ var e = StorageFixtures.hotp("Tamper", 5l);
+ e.id = "ABCD";
+ var record = Record.seal(e, Record.KEY_DEVICE, keys);
+ for (var i = 0; i < record.size(); i++) {
+ var tampered = Bytes.copy(record);
+ tampered[i] = tampered[i] ^ 0x01;
+ var opened = Record.open(tampered, "ABCD", keys);
+ if (opened != null) {
+ logger.error("tampering byte " + i + " of " + record.size() + " was not detected");
+ return false;
+ }
+ }
+ return true;
+}
+
+(:test)
+function recordRejectsWrongIdKeyAndTruncation(logger as Test.Logger) as Boolean {
+ var keys = SealedBoxFixtures.keys(9);
+ var e = StorageFixtures.totp("Moved");
+ e.id = "WXYZ";
+ var record = Record.seal(e, Record.KEY_DEVICE, keys);
+ if (Record.open(record, "WXY2", keys) != null) {
+ logger.error("record opened under another ID");
+ return false;
+ }
+ if (Record.open(record, "WXYZ", SealedBoxFixtures.keys(10)) != null) {
+ logger.error("record opened with another key");
+ return false;
+ }
+ for (var cut = 0; cut < record.size(); cut += 3) {
+ if (Record.open(record.slice(0, cut), "WXYZ", keys) != null) {
+ logger.error("truncated record handled wrongly at " + cut);
+ return false;
+ }
+ }
+ return Record.keyRef(record) == Record.KEY_DEVICE;
+}
+
+(:test)
+function entryValidationRejectsBadFields(logger as Test.Logger) as Boolean {
+ var cases = [] as Array<Entry>;
+ var e = StorageFixtures.totp("x"); e.digits = 9; cases.add(e);
+ e = StorageFixtures.totp("x"); e.period = 0; cases.add(e);
+ e = StorageFixtures.totp("x"); e.algorithm = 7; cases.add(e);
+ e = StorageFixtures.totp("x"); e.secret = []b; cases.add(e);
+ e = StorageFixtures.totp("x"); e.id = "abcd"; cases.add(e);
+ e = StorageFixtures.totp("x"); e.id = "ABC0"; cases.add(e);
+ e = StorageFixtures.totp("x"); e.name = "0123456789012345678901234567890123456789012345678901234567890123456789"; cases.add(e);
+ e = StorageFixtures.totp("x"); e.type = 5; cases.add(e);
+ e = StorageFixtures.code("x"); e.payload = null; cases.add(e);
+ e = StorageFixtures.code("x"); e.kind = 4; cases.add(e);
+ for (var i = 0; i < cases.size(); i++) {
+ if (cases[i].validate() == null) {
+ logger.error("invalid case " + i + " passed validation");
+ return false;
+ }
+ }
+ return StorageFixtures.totp("ok").validate() == null && StorageFixtures.code("ok").validate() == null
+ && Entry.isValidId("K7F2") && !Entry.isValidId("K7F") && !Entry.isValidId("K7FI");
+}
+
+(:test)
+function entryWipeZeroesSecretBuffers(logger as Test.Logger) as Boolean {
+ var e = StorageFixtures.totp("wipe");
+ var secret = e.secret as ByteArray;
+ var c = StorageFixtures.code("wipe");
+ var payload = c.payload as ByteArray;
+ e.wipeSecrets();
+ c.wipeSecrets();
+ return StorageFixtures.allZero(secret) && StorageFixtures.allZero(payload) && e.secret == null && c.payload == null;
+}
+
+// Prints the record sizes that docs/SECURITY.md quotes and the entry cap is
+// based on.
+(:test)
+function recordSizesAreSmall(logger as Test.Logger) as Boolean {
+ var keys = SealedBoxFixtures.keys(11);
+ var e = StorageFixtures.totp("GitHub");
+ e.id = "K7F2";
+ var otpSize = Record.seal(e, Record.KEY_DEVICE, keys).size();
+ var c = StorageFixtures.code("Library card");
+ c.id = "K7F3";
+ var codeSize = Record.seal(c, Record.KEY_DEVICE, keys).size();
+ var big = StorageFixtures.code("Big");
+ big.id = "K7F4";
+ big.payload = new [Entry.MAX_PAYLOAD_BYTES]b;
+ var bigSize = Record.seal(big, Record.KEY_DEVICE, keys).size();
+ logger.debug("SIZE otp=" + otpSize + " code=" + codeSize + " max_payload=" + bigSize);
+ return otpSize < 300 && codeSize < 300 && bigSize < Record.MAX_BYTES;
+}
diff --git a/test/storage/StorageSupport.mc b/test/storage/StorageSupport.mc
new file mode 100644
index 0000000..51a0c7c
--- /dev/null
+++ b/test/storage/StorageSupport.mc
@@ -0,0 +1,250 @@
+// Author: cirakg & claude ai
+// Summary: Test doubles for storage: an in-memory store with a size limit, a hand-set clock, and fake entries.
+//
+// MemoryBackend copies values on the way in and out, as Application.Storage
+// does, so a test can't pass by keeping a reference to stored data. raw()
+// gives tests the stored object itself, for tampering.
+
+import Toybox.Application;
+import Toybox.Lang;
+import Toybox.Test;
+
+(:test)
+class MemoryBackend extends Persist.Backend {
+
+ // Total size limit in (approximate) bytes; setValue throws above it.
+ var capacity as Number = 1000000;
+ private var _data as Dictionary<Application.Storage.KeyType, Application.Storage.ValueType> = {} as Dictionary<Application.Storage.KeyType, Application.Storage.ValueType>;
+
+ function initialize() {
+ Backend.initialize();
+ }
+
+ function getValue(key as String) as Application.Storage.ValueType {
+ return MemoryBackend.copyOf(_data[key]);
+ }
+
+ function setValue(key as String, value as Application.Storage.ValueType) as Void {
+ var hadKey = _data.hasKey(key);
+ var old = _data[key];
+ _data[key] = MemoryBackend.copyOf(value);
+ if (usedBytes() > capacity) {
+ if (hadKey) {
+ _data[key] = old;
+ } else {
+ _data.remove(key);
+ }
+ throw new Lang.StorageFullException("Storage full");
+ }
+ }
+
+ function deleteValue(key as String) as Void {
+ _data.remove(key);
+ }
+
+ // The stored object itself (not a copy), for tampering in tests.
+ function raw(key as String) as Application.Storage.ValueType {
+ return _data[key];
+ }
+
+ function keys() as Array<String> {
+ return _data.keys() as Array<String>;
+ }
+
+ function usedBytes() as Number {
+ var total = 0;
+ var keys = _data.keys();
+ for (var i = 0; i < keys.size(); i++) {
+ var key = keys[i] as String;
+ total += key.length() + MemoryBackend.sizeOf(_data[key]);
+ }
+ return total;
+ }
+
+ // Rough serialized size, enough to make "store full" reachable.
+ static function sizeOf(value as Application.Storage.ValueType) as Number {
+ if (value instanceof ByteArray) {
+ return value.size();
+ }
+ if (value instanceof String) {
+ return value.length();
+ }
+ if (value instanceof Array) {
+ var total = 0;
+ for (var i = 0; i < value.size(); i++) {
+ total += sizeOf(value[i] as Application.Storage.ValueType);
+ }
+ return total;
+ }
+ if (value instanceof Dictionary) {
+ var total = 0;
+ var keys = value.keys();
+ for (var i = 0; i < keys.size(); i++) {
+ total += sizeOf(keys[i] as Application.Storage.ValueType) + sizeOf(value[keys[i]] as Application.Storage.ValueType);
+ }
+ return total;
+ }
+ return 8;
+ }
+
+ // Deep copy of arrays, dictionaries and byte arrays.
+ static function copyOf(value as Application.Storage.ValueType) as Application.Storage.ValueType {
+ if (value instanceof ByteArray) {
+ return Bytes.copy(value);
+ }
+ if (value instanceof Array) {
+ var out = [] as Array<Application.Storage.ValueType>;
+ for (var i = 0; i < value.size(); i++) {
+ out.add(copyOf(value[i] as Application.Storage.ValueType));
+ }
+ return out;
+ }
+ if (value instanceof Dictionary) {
+ var out = {} as Dictionary<Application.Storage.KeyType, Application.Storage.ValueType>;
+ var keys = value.keys();
+ for (var i = 0; i < keys.size(); i++) {
+ out[keys[i] as Application.Storage.KeyType] = copyOf(value[keys[i]] as Application.Storage.ValueType);
+ }
+ return out;
+ }
+ return value;
+ }
+}
+
+// A clock the test sets by hand.
+(:test)
+class FakeTime extends TimeSource {
+
+ var trusted as Long? = 1800000000l;
+ var uptime as Number = 1000;
+
+ function initialize() {
+ TimeSource.initialize();
+ }
+
+ function trustedUnix() as Long? {
+ return trusted;
+ }
+
+ function uptimeMs() as Number {
+ return uptime;
+ }
+
+ // Advance both clocks (the trusted one only when it is synced).
+ function advance(seconds as Number) as Void {
+ uptime += seconds * 1000;
+ var t = trusted;
+ if (t != null) {
+ trusted = t + seconds;
+ }
+ }
+}
+
+(:test)
+module StorageFixtures {
+
+ const PIN = "246810";
+ const OTHER_PIN = "135799";
+ const ITERATIONS = 2000; // Kdf.MIN_ITERATIONS: the cheapest allowed
+
+ // A fake TOTP entry (the secret is the RFC 4226 test key, not a real one).
+ function totp(name as String) as Entry {
+ var e = new Entry();
+ e.kind = Entry.KIND_OTP;
+ e.type = Entry.OTP_TOTP;
+ e.name = name;
+ e.account = "alice@example.com";
+ e.group = "Work";
+ e.algorithm = Hashes.SHA256;
+ e.digits = 8;
+ e.period = 60;
+ e.secret = Bytes.fromUtf8("12345678901234567890");
+ e.created = 1700000000l;
+ return e;
+ }
+
+ function hotp(name as String, counter as Long) as Entry {
+ var e = totp(name);
+ e.type = Entry.OTP_HOTP;
+ e.algorithm = Hashes.SHA1;
+ e.digits = 6;
+ e.period = 30;
+ e.counter = counter;
+ return e;
+ }
+
+ function steam(name as String) as Entry {
+ var e = totp(name);
+ e.type = Entry.OTP_STEAM;
+ e.algorithm = Hashes.SHA1;
+ e.digits = 6;
+ e.period = 30;
+ return e;
+ }
+
+ function code(name as String) as Entry {
+ var e = new Entry();
+ e.kind = Entry.KIND_CODE;
+ e.type = 3;
+ e.name = name;
+ e.payload = Bytes.fromUtf8("CARD-0000-1111-2222 éß");
+ e.options = 0x102;
+ e.source = Entry.SOURCE_IMPORT;
+ e.favorite = true;
+ return e;
+ }
+
+ function pin(text as String) as ByteArray {
+ return Bytes.fromUtf8(text);
+ }
+
+ // Set the first PIN (leaves the session unlocked).
+ function setPin(vault as Vault, text as String) as Void {
+ var job = vault.beginSetPin(pin(text), ITERATIONS);
+ Jobs.runToCompletion(job, Kdf.ITERATIONS_PER_STEP);
+ vault.finishSetPin(job);
+ }
+
+ // One unlock attempt; true when the PIN was right.
+ function unlock(vault as Vault, text as String) as Boolean {
+ var job = vault.beginUnlock(pin(text));
+ Jobs.runToCompletion(job, Kdf.ITERATIONS_PER_STEP);
+ return vault.finishUnlock(job);
+ }
+
+ // True when the two entries hold the same fields (secrets included).
+ function same(logger as Test.Logger, a as Entry, b as Entry) as Boolean {
+ var problems = [] as Array<String>;
+ if (!a.id.equals(b.id)) { problems.add("id"); }
+ if (a.kind != b.kind || a.type != b.type) { problems.add("kind/type"); }
+ if (!a.name.equals(b.name) || !a.account.equals(b.account) || !a.group.equals(b.group)) { problems.add("names"); }
+ if (a.locked != b.locked || a.source != b.source || a.favorite != b.favorite) { problems.add("flags"); }
+ if (a.counter != b.counter || a.created != b.created) { problems.add("counter/created"); }
+ if (a.namesEncrypted != b.namesEncrypted) { problems.add("namesEncrypted"); }
+ if (a.algorithm != b.algorithm || a.digits != b.digits || a.period != b.period || a.options != b.options) {
+ problems.add("parameters");
+ }
+ if (!sameOptional(a.secret, b.secret) || !sameOptional(a.payload, b.payload)) { problems.add("secret/payload"); }
+ if (problems.size() > 0) {
+ logger.error("entries differ in " + problems.toString());
+ return false;
+ }
+ return true;
+ }
+
+ function sameOptional(a as ByteArray?, b as ByteArray?) as Boolean {
+ if (a == null || b == null) {
+ return a == null && b == null;
+ }
+ return Bytes.equal(a, b);
+ }
+
+ function allZero(data as ByteArray) as Boolean {
+ for (var i = 0; i < data.size(); i++) {
+ if (data[i] != 0) {
+ return false;
+ }
+ }
+ return true;
+ }
+}
diff --git a/test/storage/VaultTest.mc b/test/storage/VaultTest.mc
new file mode 100644
index 0000000..b089596
--- /dev/null
+++ b/test/storage/VaultTest.mc
@@ -0,0 +1,250 @@
+// Author: cirakg & claude ai
+// Summary: Tests for the vault: add/open/update/move/remove, persistence, locked and pending entries, quotas, store full.
+
+import Toybox.Lang;
+import Toybox.Test;
+
+(:test)
+function vaultRoundTripsAndPersists(logger as Test.Logger) as Boolean {
+ var store = new MemoryBackend();
+ var time = new FakeTime();
+ var vault = new Vault(store, time);
+ var originals = [StorageFixtures.totp("A"), StorageFixtures.hotp("B", 3l), StorageFixtures.steam("C"), StorageFixtures.code("D")];
+ for (var i = 0; i < originals.size(); i++) {
+ vault.add(originals[i] as Entry);
+ }
+ // A new Vault on the same store is what the next app start sees.
+ var reopened = new Vault(store, time);
+ var ids = reopened.ids();
+ if (ids.size() != 4) {
+ logger.error("expected 4 ids, got " + ids.size());
+ return false;
+ }
+ for (var i = 0; i < ids.size(); i++) {
+ var expected = originals[i] as Entry;
+ if (!ids[i].equals(expected.id) || !Entry.isValidId(ids[i])) {
+ logger.error("order or id wrong at " + i);
+ return false;
+ }
+ var opened = reopened.open(ids[i]);
+ var same = StorageFixtures.same(logger, opened, expected);
+ opened.wipeSecrets();
+ var header = reopened.header(ids[i]);
+ if (!same || header == null || !header.name.equals(expected.name) || header.secret != null) {
+ return false;
+ }
+ }
+ return true;
+}
+
+(:test)
+function vaultUpdatesMovesAndRemoves(logger as Test.Logger) as Boolean {
+ var vault = new Vault(new MemoryBackend(), new FakeTime());
+ var a = vault.add(StorageFixtures.totp("A"));
+ var b = vault.add(StorageFixtures.totp("B"));
+ var c = vault.add(StorageFixtures.hotp("C", 0l));
+ vault.move(c, 0);
+ vault.move(a, 99);
+ var order = vault.ids();
+ if (!order[0].equals(c) || !order[1].equals(b) || !order[2].equals(a)) {
+ logger.error("move gave " + order.toString());
+ return false;
+ }
+ var e = vault.open(b);
+ e.name = "B renamed";
+ vault.update(e);
+ e.wipeSecrets();
+ vault.setCounter(c, 42l);
+ vault.remove(a);
+ var header = vault.header(b);
+ var hotp = vault.open(c);
+ var counter = hotp.counter;
+ hotp.wipeSecrets();
+ var notFound = 0;
+ try {
+ vault.open(a);
+ } catch (err instanceof VaultError) {
+ notFound = err.code;
+ }
+ return header != null && header.name.equals("B renamed") && counter == 42l
+ && vault.ids().size() == 2 && notFound == VaultError.NOT_FOUND;
+}
+
+(:test)
+function vaultRejectsInvalidEntries(logger as Test.Logger) as Boolean {
+ var vault = new Vault(new MemoryBackend(), new FakeTime());
+ var e = StorageFixtures.totp("bad");
+ e.digits = 4;
+ var code = 0;
+ try {
+ vault.add(e);
+ } catch (err instanceof VaultError) {
+ code = err.code;
+ }
+ return code == VaultError.INVALID && vault.ids().size() == 0;
+}
+
+// A damaged record shows as null from header(), DAMAGED from open(), and
+// can still be deleted.
+(:test)
+function vaultReportsDamagedRecords(logger as Test.Logger) as Boolean {
+ var store = new MemoryBackend();
+ var vault = new Vault(store, new FakeTime());
+ var id = vault.add(StorageFixtures.totp("A"));
+ var record = store.raw("e." + id) as ByteArray;
+ record[record.size() - 1] = record[record.size() - 1] ^ 0x80;
+ var code = 0;
+ try {
+ vault.open(id);
+ } catch (err instanceof VaultError) {
+ code = err.code;
+ }
+ // The header itself is intact, so the list can still show the name.
+ var headerStillReadable = vault.header(id) != null;
+ store.setValue("e." + id, "not a record");
+ var headerGone = vault.header(id) == null;
+ vault.remove(id);
+ return code == VaultError.DAMAGED && headerStillReadable && headerGone && vault.ids().size() == 0;
+}
+
+// Swapping two records' storage keys must not make either open.
+(:test)
+function vaultDetectsSwappedRecords(logger as Test.Logger) as Boolean {
+ var store = new MemoryBackend();
+ var vault = new Vault(store, new FakeTime());
+ var a = vault.add(StorageFixtures.totp("A"));
+ var b = vault.add(StorageFixtures.totp("B"));
+ var recordA = store.getValue("e." + a);
+ store.setValue("e." + a, store.getValue("e." + b));
+ store.setValue("e." + b, recordA);
+ var damaged = 0;
+ var ids = [a, b];
+ for (var i = 0; i < ids.size(); i++) {
+ try {
+ vault.open(ids[i]);
+ } catch (err instanceof VaultError) {
+ if (err.code == VaultError.DAMAGED) {
+ damaged++;
+ }
+ }
+ }
+ return damaged == 2;
+}
+
+(:test)
+function vaultLockedEntriesNeedTheSession(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 record = store.raw("e." + id) as ByteArray;
+ if (Record.keyRef(record) != Record.KEY_MASTER) {
+ logger.error("locked entry not sealed with the master key");
+ return false;
+ }
+ vault.lock();
+ var codes = [] as Array<Number>;
+ try { vault.open(id); } catch (err instanceof VaultError) { codes.add(err.code); }
+ try { vault.remove(id); } catch (err instanceof VaultError) { codes.add(err.code); }
+ try { vault.setCounter(id, 1l); } catch (err instanceof VaultError) { codes.add(err.code); }
+ var header = vault.header(id);
+ if (codes.size() != 3 || codes[0] != VaultError.LOCKED || codes[1] != VaultError.LOCKED || header == null || !header.locked) {
+ logger.error("locked entry accessible while locked: " + codes.toString());
+ return false;
+ }
+ if (!StorageFixtures.unlock(vault, StorageFixtures.PIN)) {
+ logger.error("unlock failed");
+ return false;
+ }
+ var opened = vault.open(id);
+ var ok = StorageFixtures.same(logger, opened, e);
+ opened.wipeSecrets();
+ return ok;
+}
+
+(:test)
+function vaultQueuesLockedEntriesWhileLocked(logger as Test.Logger) as Boolean {
+ var store = new MemoryBackend();
+ var vault = new Vault(store, new FakeTime());
+ // Before any PIN exists.
+ var first = StorageFixtures.totp("Pending 1");
+ first.locked = true;
+ var id1 = vault.add(first);
+ StorageFixtures.setPin(vault, StorageFixtures.PIN);
+ vault.lock();
+ // While locked.
+ var second = StorageFixtures.code("Pending 2");
+ second.locked = true;
+ var id2 = vault.add(second);
+ if (vault.ids().size() != 0 || vault.pendingIds().size() != 2 || store.getValue("p." + id1) == null) {
+ logger.error("pending entries not queued");
+ return false;
+ }
+ if (!StorageFixtures.unlock(vault, StorageFixtures.PIN) || vault.promotePending() != 2) {
+ logger.error("promotion failed");
+ return false;
+ }
+ var ids = vault.ids();
+ var record = store.raw("e." + id2) as ByteArray;
+ var opened = vault.open(id1);
+ var ok = StorageFixtures.same(logger, opened, first);
+ opened.wipeSecrets();
+ return ok && ids.size() == 2 && vault.pendingIds().size() == 0 && store.getValue("p." + id1) == null
+ && store.getValue("p." + id2) == null && Record.keyRef(record) == Record.KEY_MASTER;
+}
+
+(:test)
+function vaultEnforcesTheEntryCap(logger as Test.Logger) as Boolean {
+ var vault = new Vault(new MemoryBackend(), new FakeTime());
+ for (var i = 0; i < Vault.MAX_ENTRIES; i++) {
+ vault.add(StorageFixtures.totp("E" + i));
+ }
+ var code = 0;
+ try {
+ vault.add(StorageFixtures.totp("one too many"));
+ } catch (err instanceof VaultError) {
+ code = err.code;
+ }
+ return code == VaultError.LIMIT && vault.ids().size() == Vault.MAX_ENTRIES;
+}
+
+// A full store gives VaultError FULL and leaves no half-added entry behind,
+// whether the record write or the metadata write is the one that fails.
+(:test)
+function vaultHandlesAFullStore(logger as Test.Logger) as Boolean {
+ var sawFull = false;
+ var sawSuccess = false;
+ for (var extra = 0; extra < 600; extra += 9) {
+ var store = new MemoryBackend();
+ var vault = new Vault(store, new FakeTime());
+ vault.add(StorageFixtures.totp("fits"));
+ store.capacity = store.usedBytes() + extra;
+ var expected = 2;
+ try {
+ vault.add(StorageFixtures.totp("more"));
+ sawSuccess = true;
+ } catch (err instanceof VaultError) {
+ if (err.code != VaultError.FULL) {
+ logger.error("unexpected error " + err.code);
+ return false;
+ }
+ sawFull = true;
+ expected = 1;
+ }
+ var reopened = new Vault(store, new FakeTime());
+ var keys = store.keys();
+ var records = 0;
+ for (var i = 0; i < keys.size(); i++) {
+ if (keys[i].find("e.") == 0) {
+ records++;
+ }
+ }
+ if (reopened.ids().size() != expected || records != expected) {
+ logger.error("inconsistent store at extra=" + extra + ": ids " + reopened.ids().size() + ", records " + records);
+ return false;
+ }
+ }
+ return sawFull && sawSuccess;
+}