// Author: cirakg & claude ai // Summary: Reads unencrypted Aegis JSON exports (vault version 1, database 1-3) into import items, a few tokens per step. // // README 11.3. The reader consumes the token stream of Json.Tokenizer and // tracks the path of the current value ("/db/entries/[]/info/secret"), so it // can stop after any token and continue in the next event. It keeps only // the parsed items (packed, see ImportItem) and the group names; the // document's bytes stay with the tokenizer. // // * Encrypted exports are refused: header.slots is not null, or db is a // string. // * totp, hotp and steam entries are imported; motp, yandex and unknown // types are skipped and reported. // * Used: name (the account), issuer, favorite, info.secret/algo/digits/ // period/counter, and the group: the older "group" string, or the first // UUID of "groups" resolved through db.groups (database version 3, which // may list the groups after the entries, so names are resolved at the // end). // * Ignored: icons (long strings are skipped unread), notes, UUIDs. // * A field that appears twice in one entry makes that entry an error, so // an ambiguous file can't smuggle in a second secret. import Toybox.Lang; class AegisReader { static const RUNNING = 0; static const DONE = 1; static const INCOMPLETE = 2; static const FAILED = 3; // Tokens per step; with Json's per-token limit this bounds the work of // one event. static const TOKENS_PER_STEP = 40; static const ENCRYPTED_MESSAGE = "This Aegis export is encrypted. In Aegis choose Export, then turn encryption off, and paste that file."; // Parsed items, packed (ImportItem.pack); unpack with item(i). var items as Array = [] as Array; // Group UUID -> name, from db.groups. var groups as Dictionary = {} as Dictionary; var error as String? = null; private var _tok as Json.Tokenizer; private var _path as Array = [] as Array; private var _key as String? = null; private var _item as ImportItem? = null; private var _fields as ImportFields? = null; private var _seen as Dictionary = {} as Dictionary; private var _group as Dictionary? = null; private var _encrypted as Boolean = false; private var _vaultVersion as Long = -1l; private var _sawEntries as Boolean = false; function initialize(data as ByteArray) { _tok = new Json.Tokenizer(data); } // Bytes read so far, and in all (for progress). function position() as Number { return _tok.pos; } function size() as Number { return _tok.size(); } // The document's bytes (saved when it is incomplete). function data() as ByteArray { return _tok.data(); } // Parsed item i, unpacked, with its group name resolved. function item(i as Number) as ImportItem { return ImportItem.unpack(items[i], groups); } // Process up to TOKENS_PER_STEP tokens. Returns RUNNING, DONE, // INCOMPLETE (the text ends early: wait for more) or FAILED (error set). function step() as Number { for (var n = 0; n < TOKENS_PER_STEP; n++) { var token = _tok.next(); if (token == Json.T_END) { return finish(); } if (token == Json.T_INCOMPLETE) { return _encrypted ? fail(ENCRYPTED_MESSAGE) : INCOMPLETE; } if (token == Json.T_ERROR) { return fail("not valid JSON: " + _tok.error); } if (token == Json.T_KEY) { _key = _tok.text; continue; } var name = nameOfValue(); if (token == Json.T_BEGIN_OBJECT || token == Json.T_BEGIN_ARRAY) { begin(name, token == Json.T_BEGIN_OBJECT); _path.add(name); } else if (token == Json.T_END_OBJECT || token == Json.T_END_ARRAY) { var closed = _path[_path.size() - 1]; _path = _path.slice(0, _path.size() - 1) as Array; end(closed); } else { value(name, token); } _key = null; } return RUNNING; } // The name of the value about to be read: its key in an object, "[]" // in an array, "" at the top. private function nameOfValue() as String { var key = _key; if (key != null) { return key; } return _path.size() > 0 ? "[]" : ""; } private function pathOf(name as String) as String { var p = ""; for (var i = 1; i < _path.size(); i++) { p += "/" + _path[i]; } return _path.size() == 0 ? name : p + "/" + name; } private function begin(name as String, isObject as Boolean) as Void { var p = pathOf(name); if (p.equals("/header/slots")) { _encrypted = true; } else if (p.equals("/db") && !isObject) { _encrypted = true; } else if (p.equals("/db/entries")) { _sawEntries = true; } else if (p.equals("/db/entries/[]") && isObject) { _item = new ImportItem(items.size() + 1); _fields = new ImportFields(); _seen = {} as Dictionary; } else if (p.equals("/db/groups/[]") && isObject) { _group = {} as Dictionary; } } private function end(closed as String) as Void { var p = pathOf(closed); if (p.equals("/db/entries/[]")) { var item = _item; var fields = _fields; if (item != null && fields != null) { if (item.problem == null) { fields.toEntry(item); } else { item.label = fields.issuer.length() > 0 ? fields.issuer : fields.account; } fields.secret = null; items.add(item.pack()); } _item = null; _fields = null; } else if (p.equals("/db/groups/[]")) { var g = _group; if (g != null && g.hasKey("uuid") && g.hasKey("name")) { groups[g["uuid"] as String] = g["name"] as String; } _group = null; } } private function value(name as String, token as Number) as Void { var p = pathOf(name); if (p.equals("/version")) { _vaultVersion = token == Json.T_NUMBER && _tok.numberIsInteger ? _tok.number : -2l; } else if (p.equals("/header/slots")) { if (token != Json.T_NULL) { _encrypted = true; } } else if (p.equals("/db")) { // An encrypted export stores db as a Base64 string. _encrypted = true; } else if (p.find("/db/entries/[]/") == 0) { entryValue(p.substring(15, p.length()) as String, token); } else if (p.find("/db/groups/[]/") == 0) { var g = _group; if (g != null && token == Json.T_STRING) { g[p.substring(14, p.length()) as String] = _tok.text; } } } // A value inside an entry; field is "issuer", "info/secret", "groups/[]". private function entryValue(field as String, token as Number) as Void { var item = _item; var fields = _fields; if (item == null || fields == null) { return; } if (field.equals("groups/[]")) { if (item.groupRef == null && token == Json.T_STRING) { item.groupRef = "uuid:" + _tok.text; } return; } var used = ["type", "name", "issuer", "favorite", "group", "info/secret", "info/algo", "info/digits", "info/period", "info/counter"]; if (used.indexOf(field) < 0) { return; // icons, notes, uuid, anything new } if (_seen.hasKey(field)) { item.problem = "field '" + field + "' appears twice"; return; } _seen[field] = true; if (field.equals("favorite")) { fields.favorite = token == Json.T_TRUE; return; } if (field.equals("info/digits") || field.equals("info/period") || field.equals("info/counter")) { if (token != Json.T_NUMBER || !_tok.numberIsInteger) { item.problem = field.substring(5, field.length()) + " is not a whole number"; return; } if (field.equals("info/digits")) { fields.digits = _tok.number; } else if (field.equals("info/period")) { fields.period = _tok.number; } else { fields.counter = _tok.number; } return; } if (token == Json.T_NULL && (field.equals("group") || field.equals("issuer"))) { return; } if (token != Json.T_STRING) { item.problem = "field '" + field + "' is not text" + (token == Json.T_LONG_STRING ? " (too long)" : ""); return; } var text = _tok.text; if (field.equals("type")) { fields.type = text; } else if (field.equals("name")) { fields.account = Text.trim(text); } else if (field.equals("issuer")) { fields.issuer = Text.trim(text); } else if (field.equals("group")) { item.groupRef = "name:" + text; } else if (field.equals("info/secret")) { fields.secret = text; } else if (field.equals("info/algo")) { fields.algorithm = text; } } private function finish() as Number { if (_encrypted) { return fail(ENCRYPTED_MESSAGE); } if (_vaultVersion != 1 || !_sawEntries) { return fail(_vaultVersion > 1 ? "Aegis export version " + _vaultVersion + " is not supported" : "not an Aegis export (no version 1 vault with db.entries)"); } // Group references are resolved when items are unpacked (db.groups // may come after the entries). return DONE; } // Text shortened (whole characters) to at most maxBytes UTF-8 bytes. static function fitBytes(text as String, maxBytes as Number) as String { var t = text; while (Bytes.fromUtf8(t).size() > maxBytes) { t = t.substring(0, t.length() - 1) as String; } return t; } private function fail(message as String) as Number { error = message; return FAILED; } }