// Author: cirakg & claude ai // Summary: Runs an import as a resumable job: detect the format, parse, replace old imports, find duplicates, store entry by entry. // // README 11. Phases, each done in small steps (one timer event each): // PARSE otpauth URIs (one per step) or Aegis JSON (Json/Aegis token // steps). Incomplete JSON is kept in the ImportBuffer and the // import ends with "waiting for more". // REPLACE in Replace mode, delete earlier imported entries (locked ones // only while unlocked; others are kept and reported). // INDEX read every header once (a few per step) into a fingerprint // index (duplicates) and a name index (possible replacements). // STORE one item per step: skip, report, or add to the vault. A crash // leaves only whole entries. // The report is complete when step() returns true. // // Memory: while JSON is parsed the pasted text is held once, as the // tokenizer's bytes, and parsed items are kept packed (ImportItem.pack); // the M8 numbers are in docs/PERFORMANCE.md. import Toybox.Lang; class Importer extends Jobs.Job { static const MODE_APPEND = 0; static const MODE_REPLACE = 1; static const MODE_CANCEL = 2; static const P_PARSE = 0; static const P_REPLACE = 1; static const P_INDEX = 2; static const P_STORE = 3; static const P_DONE = 4; static const HEADERS_PER_STEP = 4; static const URIS_PER_STEP = 1; var report as ImportReport = new ImportReport(); var phase as Number = P_PARSE; private var _vault as Vault; private var _mode as Number; private var _locked as Boolean; private var _now as Long; private var _text as String = ""; private var _uris as Array? = null; private var _aegis as AegisReader? = null; private var _items as Array = [] as Array; private var _groups as Dictionary = {} as Dictionary; private var _cursor as Number = 0; private var _ids as Array = [] as Array; private var _fingerprints as Dictionary = {} as Dictionary; private var _names as Dictionary = {} as Dictionary; // How items are named in messages: "URI 3" or "entry 7 'GitHub'". private var _kind as String = "entry"; // text: the joined import fields. A waiting earlier part is prepended. function initialize(vault as Vault, text as String, mode as Number, locked as Boolean, now as Long) { Job.initialize(); _vault = vault; _mode = mode; _locked = locked; _now = now; var keys = vault.keyManager.deviceKeys(); var earlier = ImportBuffer.load(vault.storage(), keys, now); _text = earlier.length() > 0 ? earlier + text : text; detect(); } // Progress for the UI, 0-100. function percent() as Number { if (phase == P_PARSE) { var a = _aegis; if (a != null) { return 40 * a.position() / (a.size() > 0 ? a.size() : 1); } var u = _uris; return u != null && u.size() > 0 ? 40 * _cursor / u.size() : 0; } if (phase == P_STORE) { return _items.size() > 0 ? 50 + 50 * _cursor / _items.size() : 100; } return phase == P_DONE ? 100 : 45; } private function detect() as Void { var trimmed = Text.trim(_text); if (trimmed.length() == 0) { finishWith("the import fields are empty"); return; } if (trimmed.find("{") == 0) { // The untrimmed text: an incomplete part is saved exactly as // received (a cut can fall on a space inside a string). _aegis = new AegisReader(Bytes.fromUtf8(_text)); _text = ""; return; } if (Text.findIgnoreCase(trimmed, OtpAuth.MIGRATION_PREFIX, 0) >= 0) { finishWith("Google Authenticator exports are not supported yet. Export from Aegis, or paste otpauth:// links"); return; } var first = Text.findIgnoreCase(trimmed, OtpAuth.PREFIX, 0); if (first < 0) { finishWith("not recognised. Paste otpauth:// links or an unencrypted Aegis JSON export"); return; } if (first > 0) { report.addDetail("Ignored " + first + " characters before the first otpauth:// link"); } _uris = OtpAuth.splitUris(trimmed); _kind = "URI"; _text = ""; } private function finishWith(failure as String) as Void { report.failure = failure; ImportBuffer.clear(_vault.storage()); phase = P_DONE; } function step(budget as Number) as Boolean { Diag.sampleMemory(); if (phase == P_PARSE) { parseStep(); } else if (phase == P_REPLACE) { replaceStep(); } else if (phase == P_INDEX) { indexStep(); } else if (phase == P_STORE) { storeStep(); } return phase == P_DONE; } private function parseStep() as Void { var aegis = _aegis; if (aegis != null) { var status = aegis.step(); if (status == AegisReader.RUNNING) { return; } if (status == AegisReader.INCOMPLETE) { var data = aegis.data(); _aegis = null; if (ImportBuffer.save(_vault.storage(), _vault.keyManager.deviceKeys(), data, _now)) { var text = Bytes.toUtf8(data); report.waitingChars = text != null ? text.length() : data.size(); phase = P_DONE; } else { finishWith("the JSON is incomplete and too long to wait for more (" + data.size() + " bytes, at most " + ImportBuffer.MAX_TEXT_BYTES + ")"); } return; } if (status == AegisReader.FAILED) { _aegis = null; finishWith(aegis.error as String); return; } _items = aegis.items; _groups = aegis.groups; _aegis = null; startReplace(); return; } var uris = _uris as Array; for (var n = 0; n < URIS_PER_STEP && _cursor < uris.size(); n++) { _items.add(OtpAuth.parse(uris[_cursor], _cursor + 1).pack()); _cursor++; } if (_cursor >= uris.size()) { _uris = null; startReplace(); } } private function startReplace() as Void { ImportBuffer.clear(_vault.storage()); _ids = _vault.ids(); _ids.addAll(_vault.pendingIds()); _cursor = 0; phase = _mode == MODE_REPLACE ? P_REPLACE : P_INDEX; } // Replace mode: delete one earlier imported entry per step. private function replaceStep() as Void { var kept = 0; while (_cursor < _ids.size()) { var id = _ids[_cursor]; _cursor++; var header = _vault.anyHeader(id); if (header == null || header.source != Entry.SOURCE_IMPORT) { continue; } if (_vault.pendingIds().indexOf(id) >= 0 || (header.locked && !_vault.session.isUnlocked())) { kept++; report.addDetail("Kept locked imported entry " + id + ": unlock first to replace it"); continue; } try { _vault.remove(id); report.removed++; } catch (e instanceof VaultError) { report.addDetail("Could not remove " + id); } return; // one deletion per step } _ids = _vault.ids(); _ids.addAll(_vault.pendingIds()); _cursor = 0; phase = P_INDEX; } // Read a few headers into the duplicate indexes. private function indexStep() as Void { for (var n = 0; n < HEADERS_PER_STEP && _cursor < _ids.size(); n++) { var id = _ids[_cursor]; _cursor++; var header = _vault.anyHeader(id); if (header != null) { remember(header, id); } } if (_cursor >= _ids.size()) { _cursor = 0; phase = P_STORE; } } private function remember(header as Entry, id as String) as Void { var fp = header.fingerprint; if (fp != null) { _fingerprints[Bytes.toHex(fp)] = id; } if (header.kind == Entry.KIND_OTP && (header.name.length() > 0 || header.account.length() > 0)) { _names[nameKey(header)] = id; } } private function nameKey(e as Entry) as String { return e.name.toLower() + "\n" + e.account.toLower(); } // Store (or report) one item. private function storeStep() as Void { if (_cursor >= _items.size()) { phase = P_DONE; return; } var packed = _items[_cursor]; _items[_cursor] = []b; var item = ImportItem.unpack(packed, _groups); Bytes.wipe(packed); // the parsed secret; about 110 bytes _cursor++; var kind = _kind; var note = item.note; if (note != null) { report.addDetail(item.describe(kind) + ": " + note); } var skipped = item.skipped; if (skipped != null) { report.skipped++; report.addDetail(item.describe(kind) + ": skipped, " + skipped); return; } var problem = item.problem; var e = item.entry; if (problem != null || e == null) { report.errors++; report.addDetail(item.describe(kind) + ": " + (problem != null ? problem : "invalid")); return; } var fp = Bytes.toHex(_vault.keyManager.fingerprint(e)); var existing = _fingerprints[fp]; if (existing != null) { report.duplicates++; report.addDetail(item.describe(kind) + ": duplicate of " + existing); e.wipeSecrets(); return; } var sameName = _names[nameKey(e)]; e.locked = _locked; e.created = _now; try { var id = _vault.add(e); report.imported++; if (_vault.pendingIds().indexOf(id) >= 0) { report.pendingLocked++; } if (sameName != null) { report.addDetail(item.describe(kind) + ": added as " + id + ", possible replacement for " + sameName + ". Delete the old one with Manage entry"); } remember(e, id); } catch (err instanceof VaultError) { report.errors++; report.addDetail(item.describe(kind) + ": not stored (" + err.getErrorMessage() + ")"); if (err.code == VaultError.FULL || err.code == VaultError.LIMIT) { report.addDetail("Stopped: " + (_items.size() - _cursor) + " more entries not imported"); report.errors += _items.size() - _cursor; phase = P_DONE; } } e.wipeSecrets(); } }