// 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; 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; }