diff options
| author | cirakg <cirakg@cirakg.xyz> | 2026-10-19 15:27:37 +0200 |
|---|---|---|
| committer | cirakg <cirakg@cirakg.xyz> | 2026-10-19 15:27:37 +0200 |
| commit | 8ac04a0c2f5cd949a45866f938bd1aa72fbe56e2 (patch) | |
| tree | 80642928d00ab7ee463032db331f41ffcc7ccb11 /test/storage/RecordTest.mc | |
| parent | dd5d0f68726fe56ee4d199867d414aea81905cc4 (diff) | |
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 <noreply@anthropic.com>
Diffstat (limited to 'test/storage/RecordTest.mc')
| -rw-r--r-- | test/storage/RecordTest.mc | 174 |
1 files changed, 174 insertions, 0 deletions
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; +} |
