diff options
Diffstat (limited to 'source/storage/Record.mc')
| -rw-r--r-- | source/storage/Record.mc | 282 |
1 files changed, 282 insertions, 0 deletions
diff --git a/source/storage/Record.mc b/source/storage/Record.mc new file mode 100644 index 0000000..01c3c53 --- /dev/null +++ b/source/storage/Record.mc @@ -0,0 +1,282 @@ +// 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.<id>"): +// +// 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<Number, ByteArray or String or Long or Boolean>; + + function initialize(map as Dictionary<Number, ByteArray or String or Long or Boolean>) { + _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; + } + } +} |
