diff options
| -rw-r--r-- | test/BenchTest.mc | 43 | ||||
| -rw-r--r-- | test/storage/MigrationTest.mc | 69 | ||||
| -rw-r--r-- | test/storage/PinTest.mc | 250 | ||||
| -rw-r--r-- | test/storage/RecordTest.mc | 174 | ||||
| -rw-r--r-- | test/storage/StorageSupport.mc | 250 | ||||
| -rw-r--r-- | test/storage/VaultTest.mc | 250 | ||||
| -rw-r--r-- | test/util/UtilTest.mc | 30 |
7 files changed, 1065 insertions, 1 deletions
diff --git a/test/BenchTest.mc b/test/BenchTest.mc index 7e1f612..df12db1 100644 --- a/test/BenchTest.mc +++ b/test/BenchTest.mc @@ -1,5 +1,5 @@ // Author: cirakg & claude ai -// Summary: Benchmarks of the crypto and OTP primitives; logs BENCH lines for docs/PERFORMANCE.md, never fails. +// Summary: Benchmarks of the crypto, OTP and storage code; logs BENCH lines for docs/PERFORMANCE.md, never fails. // // Simulator timings run on the PC's CPU, so they only compare primitives with // each other (for example how much SHA-512 costs against native SHA-1). @@ -55,6 +55,38 @@ module Bench { SelfTest.run(); } } + + // A vault with 20 OTP entries, for list and open timings. + class VaultCases { + var vault as Vault; + var ids as Array<String>; + + function initialize() { + vault = new Vault(new MemoryBackend(), new FakeTime()); + for (var i = 0; i < 20; i++) { + vault.add(StorageFixtures.totp("Entry " + i)); + } + ids = vault.ids(); + } + + // What the list does on start-up: every header. + function readAllHeaders() as Void { + for (var i = 0; i < ids.size(); i++) { + vault.header(ids[i]); + } + } + + // What showing one code does: decrypt one entry. + function openOne() as Void { + vault.open(ids[0]).wipeSecrets(); + } + + function addOne() as Void { + var e = StorageFixtures.totp("Bench"); + vault.add(e); + vault.remove(e.id); + } + } } (:test) @@ -68,3 +100,12 @@ function benchmarkPrimitives(logger as Test.Logger) as Boolean { Bench.measure(logger, "self_test", 3, c.method(:selfTest)); return true; } + +(:test) +function benchmarkVault(logger as Test.Logger) as Boolean { + var v = new Bench.VaultCases(); + Bench.measure(logger, "vault_20_headers", 5, v.method(:readAllHeaders)); + Bench.measure(logger, "vault_open_entry", 20, v.method(:openOne)); + Bench.measure(logger, "vault_add_remove", 10, v.method(:addOne)); + return true; +} 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; +} diff --git a/test/util/UtilTest.mc b/test/util/UtilTest.mc index ea689a6..c19d6d0 100644 --- a/test/util/UtilTest.mc +++ b/test/util/UtilTest.mc @@ -64,3 +64,33 @@ function jobsRunToCompletion(logger as Test.Logger) as Boolean { Jobs.runToCompletion(job, 3); return job.calls == 4 && job.done == 12; } + +(:test) +function bytesUtf8Validation(logger as Test.Logger) as Boolean { + var valid = [[0x41]b, [0xC3, 0xA9]b, [0xE2, 0x82, 0xAC]b, [0xF0, 0x9F, 0x98, 0x80]b, [0xF4, 0x8F, 0xBF, 0xBF]b]; + var invalid = [ + [0x00]b, // NUL + [0xC0, 0x80]b, // overlong NUL + [0xE0, 0x80, 0x80]b, // overlong + [0xED, 0xA0, 0x80]b, // surrogate + [0xF4, 0x90, 0x80, 0x80]b, // above U+10FFFF + [0xF5, 0x80, 0x80, 0x80]b, // invalid lead byte + [0xE2, 0x82]b, // truncated + [0x80]b, // lone continuation + [0xC3, 0x28]b // bad continuation + ]; + for (var i = 0; i < valid.size(); i++) { + var text = Bytes.toUtf8(valid[i] as ByteArray); + if (text == null || !Bytes.equal(Bytes.fromUtf8(text), valid[i] as ByteArray)) { + logger.error("valid UTF-8 case " + i + " rejected or changed"); + return false; + } + } + for (var i = 0; i < invalid.size(); i++) { + if (Bytes.toUtf8(invalid[i] as ByteArray) != null) { + logger.error("invalid UTF-8 case " + i + " accepted"); + return false; + } + } + return true; +} |
