summaryrefslogtreecommitdiff
path: root/source/import/Importer.mc
diff options
context:
space:
mode:
Diffstat (limited to 'source/import/Importer.mc')
-rw-r--r--source/import/Importer.mc296
1 files changed, 296 insertions, 0 deletions
diff --git a/source/import/Importer.mc b/source/import/Importer.mc
new file mode 100644
index 0000000..9513358
--- /dev/null
+++ b/source/import/Importer.mc
@@ -0,0 +1,296 @@
+// 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.
+
+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<String>? = null;
+ private var _aegis as AegisReader? = null;
+ private var _items as Array<ImportItem> = [] as Array<ImportItem>;
+ private var _cursor as Number = 0;
+ private var _ids as Array<String> = [] as Array<String>;
+ private var _fingerprints as Dictionary<String, String> = {} as Dictionary<String, String>;
+ private var _names as Dictionary<String, String> = {} as Dictionary<String, String>;
+ // 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 + text;
+ detect();
+ }
+
+ // Progress for the UI, 0-100.
+ function percent() as Number {
+ if (phase == P_PARSE) {
+ var a = _aegis;
+ var size = _text.length() > 0 ? _text.length() : 1;
+ if (a != null) {
+ return 40 * a.position() / (size > 0 ? 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) {
+ _aegis = new AegisReader(Bytes.fromUtf8(trimmed));
+ 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 size = Bytes.fromUtf8(_text).size();
+ if (ImportBuffer.save(_vault.storage(), _vault.keyManager.deviceKeys(), _text, _now)) {
+ report.waitingChars = _text.length();
+ phase = P_DONE;
+ } else {
+ finishWith("the JSON is incomplete and too long to wait for more (" + size + " bytes, at most "
+ + ImportBuffer.MAX_TEXT_BYTES + ")");
+ }
+ _text = "";
+ return;
+ }
+ if (status == AegisReader.FAILED) {
+ finishWith(aegis.error as String);
+ _text = "";
+ return;
+ }
+ _items = aegis.items;
+ _aegis = null;
+ _text = "";
+ startReplace();
+ return;
+ }
+ var uris = _uris as Array<String>;
+ for (var n = 0; n < URIS_PER_STEP && _cursor < uris.size(); n++) {
+ _items.add(OtpAuth.parse(uris[_cursor], _cursor + 1));
+ _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 item = _items[_cursor];
+ _items[_cursor] = new ImportItem(item.index); // drop the parsed secret
+ _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();
+ }
+}