From dd5d0f68726fe56ee4d199867d414aea81905cc4 Mon Sep 17 00:00:00 2001 From: cirakg Date: Mon, 19 Oct 2026 15:27:37 +0200 Subject: feat(storage): add the encrypted vault, PIN keys, lockout, session and migrations Records are one ByteArray each: a readable TLV header plus an encrypt-then-MAC box whose aad binds the entry ID and the header. The device key seals unlocked entries and pending items; a random master key, wrapped under a PBKDF2 PIN key, seals locked ones. Wrong PINs are counted before the check, with delays on the trusted clock and an uptime fallback. Bytes.toUtf8 validates UTF-8 first, because the native conversion crashes on invalid input. Co-Authored-By: Claude Opus 5.5 --- source/storage/Codec.mc | 101 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 101 insertions(+) create mode 100644 source/storage/Codec.mc (limited to 'source/storage/Codec.mc') diff --git a/source/storage/Codec.mc b/source/storage/Codec.mc new file mode 100644 index 0000000..469ba82 --- /dev/null +++ b/source/storage/Codec.mc @@ -0,0 +1,101 @@ +// Author: cirakg & claude ai +// Summary: Deterministic TLV byte encoding of record fields, so the same fields always give the same MAC input. +// +// A field is: tag (1 byte) | type (1 byte) | length (2 bytes, big-endian) | value. +// Types: bytes, UTF-8 text, 64-bit integer (8 bytes big-endian), boolean +// (1 byte). Fields are written in the order the caller gives, and Record.mc +// always gives them in tag order, so encoding is canonical. +// +// decode() is strict: a truncated field, an unknown type, invalid UTF-8 or a +// duplicate tag gives null, never a partial result. + +import Toybox.Lang; + +module Codec { + + const TYPE_BYTES = 1; + const TYPE_TEXT = 2; + const TYPE_INT = 3; + const TYPE_BOOL = 4; + const MAX_VALUE_BYTES = 65535; + + // Builds an encoding field by field. + class Writer { + var bytes as ByteArray = []b; + + function initialize() { + } + + function header(tag as Number, type as Number, length as Number) as Void { + if (length > MAX_VALUE_BYTES) { + throw new Lang.InvalidValueException("Codec: field too long"); + } + bytes.add(tag); + bytes.add(type); + bytes.add((length >> 8) & 0xFF); + bytes.add(length & 0xFF); + } + + function addBytes(tag as Number, value as ByteArray) as Void { + header(tag, TYPE_BYTES, value.size()); + bytes.addAll(value); + } + + function addText(tag as Number, value as String) as Void { + var utf8 = Bytes.fromUtf8(value); + header(tag, TYPE_TEXT, utf8.size()); + bytes.addAll(utf8); + } + + function addInt(tag as Number, value as Number or Long) as Void { + header(tag, TYPE_INT, 8); + bytes.addAll(Bytes.uint64BigEndian(value.toLong())); + } + + function addBool(tag as Number, value as Boolean) as Void { + header(tag, TYPE_BOOL, 1); + bytes.add(value ? 1 : 0); + } + } + + // Tag -> value (ByteArray, String, Long or Boolean), or null when the + // encoding is malformed. + function decode(data as ByteArray) as Dictionary? { + var fields = {} as Dictionary; + var i = 0; + while (i < data.size()) { + if (i + 4 > data.size()) { + return null; + } + var tag = data[i]; + var type = data[i + 1]; + var length = (data[i + 2] << 8) | data[i + 3]; + i += 4; + if (i + length > data.size() || fields.hasKey(tag)) { + return null; + } + var value = data.slice(i, i + length); + i += length; + if (type == TYPE_BYTES) { + fields[tag] = value; + } else if (type == TYPE_TEXT) { + var text = Bytes.toUtf8(value); + if (text == null) { + return null; + } + fields[tag] = text; + } else if (type == TYPE_INT && length == 8) { + var v = 0l; + for (var k = 0; k < 8; k++) { + v = (v << 8) | value[k].toLong(); + } + fields[tag] = v; + } else if (type == TYPE_BOOL && length == 1 && value[0] <= 1) { + fields[tag] = value[0] == 1; + } else { + return null; + } + } + return fields; + } +} -- cgit v1.2.3