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/Importer.mc | 296 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 296 insertions(+) create mode 100644 source/import/Importer.mc (limited to 'source/import/Importer.mc') 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? = null; + private var _aegis as AegisReader? = null; + private var _items as Array = [] as Array; + 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 + 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; + 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(); + } +} -- cgit v1.2.3