From 40d9a1d5c3e85486aea964c1446402a3be6ec05a Mon Sep 17 00:00:00 2001 From: cirakg Date: Tue, 20 Oct 2026 01:51:05 +0200 Subject: feat(import): parse otpauth URIs and unencrypted Aegis JSON in resumable steps Streaming JSON tokenizer with depth and length limits and native skipping of long strings; Aegis reader for database versions 1-3 with groups and encrypted-export detection; otpauth parser with ';' separators and Steam; import job with Replace mode, duplicate and replacement checks, a sealed multi-part buffer and a report. Google Authenticator exports deferred. Co-Authored-By: Claude Opus 5.5 --- source/import/Aegis.mc | 276 +++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 276 insertions(+) create mode 100644 source/import/Aegis.mc (limited to 'source/import/Aegis.mc') diff --git a/source/import/Aegis.mc b/source/import/Aegis.mc new file mode 100644 index 0000000..05905e9 --- /dev/null +++ b/source/import/Aegis.mc @@ -0,0 +1,276 @@ +// 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 and the group names, never the document. +// +// * 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."; + + var items as Array = [] as Array; + 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 _groups as Dictionary = {} as Dictionary; + 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 (for progress). + function position() as Number { + return _tok.pos; + } + + // 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); + } + _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)"); + } + // Resolve group references now that db.groups has been read. + for (var i = 0; i < items.size(); i++) { + var item = items[i]; + var ref = item.groupRef; + var e = item.entry; + if (ref == null || e == null) { + continue; + } + var name = ref.find("uuid:") == 0 ? _groups[ref.substring(5, ref.length()) as String] : ref.substring(5, ref.length()); + if (name != null) { + e.group = fitBytes(Text.trim(name as String), Entry.MAX_GROUP_BYTES); + } + } + 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; + } +} -- cgit v1.2.3