summaryrefslogtreecommitdiff
path: root/source/storage/Record.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
commitdd5d0f68726fe56ee4d199867d414aea81905cc4 (patch)
tree1cb40b024ac13f454633198ad50cbc55f7283f39 /source/storage/Record.mc
parent94181d58cf66c5b7b263fd435a344f1cd8872cff (diff)
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 <noreply@anthropic.com>
Diffstat (limited to 'source/storage/Record.mc')
-rw-r--r--source/storage/Record.mc282
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;
+ }
+ }
+}