// 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 = {} as Dictionary; 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 { return _data.keys() as Array; } 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; 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; 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 = "2468"; // KeyManager.PIN_DIGITS digits, like every new PIN const OTHER_PIN = "1357"; const LEGACY_PIN = "246810"; // a 6-digit PIN from before the 4-digit rule 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); } // Store a wrap the way versions before the 4-digit rule did: any 4 to // 10 digits and no PIN-length marker. The session stays locked. function setLegacyPin(store as MemoryBackend, text as String) as Void { var keyManager = new KeyManager(store); var job = new PinJob(pin(text), new [KeyManager.SALT_BYTES]b, ITERATIONS); Jobs.runToCompletion(job, Kdf.ITERATIONS_PER_STEP); keyManager.storeWrap(job, keyManager.newMasterKey()); store.deleteValue(KeyManager.PIN_LENGTH_KEY); } // True when the store holds the 4-digit PIN marker. function pinMarked(store as MemoryBackend) as Boolean { var marker = store.getValue(KeyManager.PIN_LENGTH_KEY); return marker instanceof Number && marker == KeyManager.PIN_DIGITS; } // 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; 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; } }