// Author: cirakg & claude ai // Summary: One parsed import candidate (an entry, an error or a skip), its packed form, and the shared field validation of both formats. // // Both parsers (otpauth URIs and Aegis JSON) turn text fields into an Entry // through ImportFields.toEntry, so the same rules (README 10.4) and the same // messages apply to both. Messages name the field and the problem, never the // secret itself. // // Memory: an item with its Entry takes about 760 bytes as objects (measured // on the Forerunner 255S in M8), so 150 parsed items would take 110 KB. The // parsers keep each finished item packed into one ByteArray (Codec TLV, // about 110 bytes) instead, and the importer unpacks one item per step. // The packed form holds the secret in clear, like the Entry it replaces; // the importer wipes it as soon as the item is unpacked. import Toybox.Lang; class ImportItem { // 1-based position in the import (URI number or Aegis entry number). var index as Number; // A short name for messages: the issuer, else the account, else "". var label as String = ""; var entry as Entry? = null; // Why the item can't be imported (shown as an error), or null. var problem as String? = null; // Why the item was deliberately skipped (unsupported type), or null. var skipped as String? = null; // A remark that doesn't stop the import (issuer mismatch). var note as String? = null; // Aegis groups: a group UUID (database version 3) or a name, resolved by // the reader once the whole document is read. var groupRef as String? = null; // Packed field tags. private static const P_INDEX = 1; private static const P_LABEL = 2; private static const P_PROBLEM = 3; private static const P_SKIPPED = 4; private static const P_NOTE = 5; private static const P_GROUP_REF = 6; private static const P_TYPE = 10; private static const P_NAME = 11; private static const P_ACCOUNT = 12; private static const P_FAVORITE = 13; private static const P_ALGORITHM = 14; private static const P_DIGITS = 15; private static const P_PERIOD = 16; private static const P_COUNTER = 17; private static const P_SECRET = 18; function initialize(position as Number) { index = position; } // This item as one ByteArray. Wipes the entry's secret: the packed copy // is the only one left. function pack() as ByteArray { var w = new Codec.Writer(); w.addInt(P_INDEX, index); w.addText(P_LABEL, label); addText(w, P_PROBLEM, problem); addText(w, P_SKIPPED, skipped); addText(w, P_NOTE, note); addText(w, P_GROUP_REF, groupRef); var e = entry; if (e != null && e.secret != null) { w.addInt(P_TYPE, e.type); w.addText(P_NAME, e.name); w.addText(P_ACCOUNT, e.account); w.addBool(P_FAVORITE, e.favorite); w.addInt(P_ALGORITHM, e.algorithm); w.addInt(P_DIGITS, e.digits); w.addInt(P_PERIOD, e.period); w.addInt(P_COUNTER, e.counter); w.addBytes(P_SECRET, e.secret as ByteArray); e.wipeSecrets(); } return w.bytes; } private static function addText(w as Codec.Writer, tag as Number, value as String?) as Void { if (value != null) { w.addText(tag, value); } } // The item packed by pack(), with its group resolved: groupRef is // "name:" or "uuid:" looked up in groups (Aegis db.groups). // The caller wipes `packed`. static function unpack(packed as ByteArray, groups as Dictionary) as ImportItem { var map = Codec.decode(packed); var item = new ImportItem(0); if (map == null) { item.problem = "invalid"; // only if memory was corrupted return item; } item.index = (map[P_INDEX] as Long).toNumber(); item.label = map[P_LABEL] as String; item.problem = map[P_PROBLEM] as String?; item.skipped = map[P_SKIPPED] as String?; item.note = map[P_NOTE] as String?; var secret = map[P_SECRET]; if (secret instanceof ByteArray) { var e = new Entry(); e.kind = Entry.KIND_OTP; e.source = Entry.SOURCE_IMPORT; e.type = (map[P_TYPE] as Long).toNumber(); e.name = map[P_NAME] as String; e.account = map[P_ACCOUNT] as String; e.favorite = map[P_FAVORITE] as Boolean; e.algorithm = (map[P_ALGORITHM] as Long).toNumber() as Hashes.Algorithm; e.digits = (map[P_DIGITS] as Long).toNumber(); e.period = (map[P_PERIOD] as Long).toNumber(); e.counter = map[P_COUNTER] as Long; e.secret = secret; e.group = groupName(map[P_GROUP_REF] as String?, groups); item.entry = e; } return item; } private static function groupName(ref as String?, groups as Dictionary) as String { if (ref == null) { return ""; } var key = ref.substring(5, ref.length()) as String; var name = ref.find("uuid:") == 0 ? groups[key] : key; return name != null ? AegisReader.fitBytes(Text.trim(name), Entry.MAX_GROUP_BYTES) : ""; } // "entry 7 'GitHub'" or "URI 3", for messages. function describe(kind as String) as String { return label.length() > 0 ? kind + " " + index + " '" + label + "'" : kind + " " + index; } } // The raw fields one source item provides, as text, and their conversion. class ImportFields { var type as String = "totp"; var issuer as String = ""; var account as String = ""; var secret as String? = null; var algorithm as String = "SHA1"; var digits as Long = 6l; var period as Long = 30l; var counter as Long? = null; var favorite as Boolean = false; function initialize() { } // Fill item.entry, or set item.problem / item.skipped. function toEntry(item as ImportItem) as Void { item.label = issuer.length() > 0 ? issuer : account; var t = type.toLower(); var otpType = -1; if (t.equals("totp")) { otpType = Entry.OTP_TOTP; } else if (t.equals("hotp")) { otpType = Entry.OTP_HOTP; } else if (t.equals("steam")) { otpType = Entry.OTP_STEAM; } else if (t.equals("motp")) { item.skipped = "mOTP is not supported"; return; } else if (t.equals("yandex")) { item.skipped = "Yandex is not supported"; return; } else { item.skipped = "type '" + Text.clip(t, 12) + "' is not supported"; return; } if (issuer.length() == 0 && account.length() == 0) { item.problem = "needs an issuer or an account name"; return; } if (Bytes.fromUtf8(issuer).size() > Entry.MAX_NAME_BYTES || Bytes.fromUtf8(account).size() > Entry.MAX_NAME_BYTES) { item.problem = "issuer or account longer than " + Entry.MAX_NAME_BYTES + " bytes"; return; } var e = new Entry(); e.kind = Entry.KIND_OTP; e.type = otpType; e.name = issuer; e.account = account; e.source = Entry.SOURCE_IMPORT; e.favorite = favorite; if (otpType == Entry.OTP_STEAM) { // Steam always uses SHA-1, 30 s and 5 characters. e.algorithm = Hashes.SHA1; e.digits = Otp.MIN_DIGITS; e.period = Otp.STEAM_PERIOD; } else { var a = algorithm.toUpper(); if (a.equals("SHA1")) { e.algorithm = Hashes.SHA1; } else if (a.equals("SHA256")) { e.algorithm = Hashes.SHA256; } else if (a.equals("SHA512")) { e.algorithm = Hashes.SHA512; } else { item.problem = "algorithm '" + Text.clip(algorithm, 10) + "' is not supported (SHA1, SHA256, SHA512)"; return; } if (digits < Otp.MIN_DIGITS || digits > Otp.MAX_DIGITS) { item.problem = digits + " digits not supported (6-8)"; return; } e.digits = digits.toNumber(); if (period < Otp.MIN_PERIOD || period > Otp.MAX_PERIOD) { item.problem = "period " + period + " s not supported (1-300)"; return; } e.period = period.toNumber(); } if (otpType == Entry.OTP_HOTP) { var c = counter; if (c == null) { item.problem = "HOTP needs a counter"; return; } if (c < 0) { item.problem = "counter can't be negative"; return; } e.counter = c; } var s = secret; if (s == null || s.length() == 0) { item.problem = "secret missing"; return; } var decoded = Base32.decode(s); if (decoded instanceof Base32.DecodeError) { item.problem = "secret: " + decoded.message + (decoded.position > 0 ? " (position " + decoded.position + ")" : ""); return; } if (decoded.size() == 0 || decoded.size() > Entry.MAX_SECRET_BYTES) { item.problem = "secret must be 1 to " + Entry.MAX_SECRET_BYTES + " bytes"; Bytes.wipe(decoded); return; } e.secret = decoded; item.entry = e; } }