// Author: cirakg & claude ai // Summary: The stored byte format of one entry: readable TLV header plus a SealedBox with the sensitive fields. // // Record format, version 1 (one ByteArray under the key "e."): // // v (1 byte, = 1) | kr (1 byte) | hlen (2 bytes, big-endian) | header (hlen bytes) | box // // kr = 0: sealed with the device key, 1: sealed with the locked master key // header = Codec TLV of the header fields (tags 1-12), readable without a key // box = SealedBox of the Codec TLV of the sensitive fields (tags 20-28) // aad = UTF-8 id || v || kr || hlen || header // // Because the aad holds the ID and the whole header, the box's MAC covers // them: changing the HOTP counter, the locked flag or a name, or copying a // record under another ID, makes open() fail (README 7.3). Reading only the // header (for the list) doesn't check the MAC; the header is not secret and // the device key, which would be needed to forge it, is stored next to it // anyway (README 7.4). // // With "Encrypt names" the name, account and group move from the header // into the box (tags 26-28). // // Decoding never throws: malformed input gives null and the vault reports // the entry as damaged. import Toybox.Lang; module Record { const VERSION = 1; const KEY_DEVICE = 0; const KEY_MASTER = 1; const PREFIX_BYTES = 4; // Application.Storage allows 8 KB per value; stay clearly below it. const MAX_BYTES = 8000; // Header tags. const H_ID = 1; const H_KIND = 2; const H_TYPE = 3; const H_NAME = 4; const H_ACCOUNT = 5; const H_GROUP = 6; const H_LOCKED = 7; const H_SOURCE = 8; const H_FAVORITE = 9; const H_COUNTER = 10; const H_CREATED = 11; const H_NAMES_ENCRYPTED = 12; // Sensitive tags (inside the box). const S_ALGORITHM = 20; const S_DIGITS = 21; const S_PERIOD = 22; const S_SECRET = 23; const S_PAYLOAD = 24; const S_OPTIONS = 25; const S_NAME = 26; const S_ACCOUNT = 27; const S_GROUP = 28; // Seal an entry into record bytes. keyRef is KEY_DEVICE or KEY_MASTER and // must match `keys`. The plaintext TLV is wiped before returning. function seal(entry as Entry, keyRef as Number, keys as SealedBox.Keys) as ByteArray { var header = encodeHeader(entry); var record = [VERSION, keyRef, (header.size() >> 8) & 0xFF, header.size() & 0xFF]b; record.addAll(header); var plain = encodeSecret(entry); var box = SealedBox.seal(keys, aad(entry.id, record), plain); Bytes.wipe(plain); record.addAll(box); return record; } // The key reference (KEY_DEVICE or KEY_MASTER), or null when the bytes // aren't a version-1 record. function keyRef(record as ByteArray) as Number? { var headerEnd = headerEnd(record); if (headerEnd == null) { return null; } return record[1]; } // The header fields only (no key needed, MAC not checked), or null when // malformed. The returned entry has no secret or payload. function decodeHeader(record as ByteArray) as Entry? { var end = headerEnd(record); if (end == null) { return null; } var map = Codec.decode(record.slice(PREFIX_BYTES, end)); if (map == null) { return null; } var f = new Fields(map); var entry = new Entry(); entry.id = f.text(H_ID, true); entry.kind = f.number(H_KIND, Entry.KIND_OTP, Entry.KIND_CODE, true); entry.type = f.number(H_TYPE, 0, Entry.MAX_SYMBOLOGY, true); entry.name = f.text(H_NAME, false); entry.account = f.text(H_ACCOUNT, false); entry.group = f.text(H_GROUP, false); entry.locked = f.flag(H_LOCKED); entry.source = f.number(H_SOURCE, Entry.SOURCE_MANUAL, Entry.SOURCE_IMPORT, false); entry.favorite = f.flag(H_FAVORITE); entry.counter = f.long(H_COUNTER); entry.created = f.long(H_CREATED); entry.namesEncrypted = f.flag(H_NAMES_ENCRYPTED); if (!f.ok || !Entry.isValidId(entry.id) || entry.counter < 0) { return null; } return entry; } // The full entry, or null when the record is malformed, sealed with other // keys, stored under another ID, or changed in any way. The decrypted // TLV is wiped; the caller must call entry.wipeSecrets() when done. function open(record as ByteArray, id as String, keys as SealedBox.Keys) as Entry? { var entry = decodeHeader(record); var end = headerEnd(record); if (entry == null || end == null || !entry.id.equals(id)) { return null; } var plain = SealedBox.open(keys, aad(id, record.slice(0, end)), record.slice(end, record.size())); if (plain == null) { return null; } var map = Codec.decode(plain); Bytes.wipe(plain); if (map == null) { return null; } var f = new Fields(map); if (entry.kind == Entry.KIND_OTP) { entry.algorithm = f.number(S_ALGORITHM, Hashes.SHA1, Hashes.SHA512, true); entry.digits = f.number(S_DIGITS, 0, 255, true); entry.period = f.number(S_PERIOD, 0, 65535, true); entry.secret = f.bytes(S_SECRET); } else { entry.payload = f.bytes(S_PAYLOAD); entry.options = f.number(S_OPTIONS, 0, 0x7FFFFFFF, true); } if (entry.namesEncrypted) { entry.name = f.text(S_NAME, false); entry.account = f.text(S_ACCOUNT, false); entry.group = f.text(S_GROUP, false); } if (!f.ok || entry.validate() != null) { entry.wipeSecrets(); return null; } return entry; } // Header TLV, in tag order. Names are left out when they are encrypted. function encodeHeader(entry as Entry) as ByteArray { var w = new Codec.Writer(); w.addText(H_ID, entry.id); w.addInt(H_KIND, entry.kind); w.addInt(H_TYPE, entry.type); if (!entry.namesEncrypted) { w.addText(H_NAME, entry.name); w.addText(H_ACCOUNT, entry.account); w.addText(H_GROUP, entry.group); } w.addBool(H_LOCKED, entry.locked); w.addInt(H_SOURCE, entry.source); w.addBool(H_FAVORITE, entry.favorite); w.addInt(H_COUNTER, entry.counter); w.addInt(H_CREATED, entry.created); w.addBool(H_NAMES_ENCRYPTED, entry.namesEncrypted); return w.bytes; } // Sensitive TLV, in tag order. The caller wipes the result. function encodeSecret(entry as Entry) as ByteArray { var w = new Codec.Writer(); if (entry.kind == Entry.KIND_OTP) { w.addInt(S_ALGORITHM, entry.algorithm); w.addInt(S_DIGITS, entry.digits); w.addInt(S_PERIOD, entry.period); w.addBytes(S_SECRET, entry.secret as ByteArray); } else { w.addBytes(S_PAYLOAD, entry.payload as ByteArray); w.addInt(S_OPTIONS, entry.options); } if (entry.namesEncrypted) { w.addText(S_NAME, entry.name); w.addText(S_ACCOUNT, entry.account); w.addText(S_GROUP, entry.group); } return w.bytes; } // The authenticated data: the ID and every byte before the box. function aad(id as String, prefixAndHeader as ByteArray) as ByteArray { var out = Bytes.fromUtf8(id); out.addAll(prefixAndHeader); return out; } // Index just past the header, or null when the prefix is invalid. function headerEnd(record as ByteArray) as Number? { if (record.size() < PREFIX_BYTES + SealedBox.MIN_SIZE || record[0] != VERSION || (record[1] != KEY_DEVICE && record[1] != KEY_MASTER)) { return null; } var end = PREFIX_BYTES + ((record[2] << 8) | record[3]); if (end + SealedBox.MIN_SIZE > record.size()) { return null; } return end; } // Typed access to decoded TLV fields. A missing required field or a // field of the wrong type or range clears `ok` and returns a default, // so decoding reads straight through and checks `ok` once at the end. class Fields { var ok as Boolean = true; private var _map as Dictionary; function initialize(map as Dictionary) { _map = map; } function text(tag as Number, required as Boolean) as String { var v = _map[tag]; if (v instanceof String) { return v; } if (v != null || required) { ok = false; } return ""; } function number(tag as Number, min as Number, max as Number, required as Boolean) as Number { var v = _map[tag]; if (v instanceof Long && v >= min && v <= max) { return v.toNumber(); } if (v != null || required) { ok = false; } return min; } function long(tag as Number) as Long { var v = _map[tag]; if (v instanceof Long) { return v; } if (v != null) { ok = false; } return 0l; } function flag(tag as Number) as Boolean { var v = _map[tag]; if (v instanceof Boolean) { return v; } if (v != null) { ok = false; } return false; } function bytes(tag as Number) as ByteArray? { var v = _map[tag]; if (v instanceof ByteArray) { return v; } if (v != null) { ok = false; } return null; } } }