summaryrefslogtreecommitdiff
path: root/test/storage/RecordTest.mc
diff options
context:
space:
mode:
authorcirakg <cirakg@cirakg.xyz>2026-10-19 15:27:37 +0200
committercirakg <cirakg@cirakg.xyz>2026-10-19 15:27:37 +0200
commit8ac04a0c2f5cd949a45866f938bd1aa72fbe56e2 (patch)
tree80642928d00ab7ee463032db331f41ffcc7ccb11 /test/storage/RecordTest.mc
parentdd5d0f68726fe56ee4d199867d414aea81905cc4 (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.mc174
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;
+}