summaryrefslogtreecommitdiff
path: root/source/import
diff options
context:
space:
mode:
Diffstat (limited to 'source/import')
-rw-r--r--source/import/Aegis.mc276
-rw-r--r--source/import/ImportBuffer.mc90
-rw-r--r--source/import/ImportItem.mc148
-rw-r--r--source/import/ImportReport.mc71
-rw-r--r--source/import/Importer.mc296
-rw-r--r--source/import/Json.mc486
-rw-r--r--source/import/OtpAuth.mc186
7 files changed, 1553 insertions, 0 deletions
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<ImportItem> = [] as Array<ImportItem>;
+ var error as String? = null;
+
+ private var _tok as Json.Tokenizer;
+ private var _path as Array<String> = [] as Array<String>;
+ private var _key as String? = null;
+ private var _item as ImportItem? = null;
+ private var _fields as ImportFields? = null;
+ private var _seen as Dictionary<String, Boolean> = {} as Dictionary<String, Boolean>;
+ private var _group as Dictionary<String, String>? = null;
+ private var _groups as Dictionary<String, String> = {} as Dictionary<String, String>;
+ 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<String>;
+ 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<String, Boolean>;
+ } else if (p.equals("/db/groups/[]") && isObject) {
+ _group = {} as Dictionary<String, String>;
+ }
+ }
+
+ 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;
+ }
+}
diff --git a/source/import/ImportBuffer.mc b/source/import/ImportBuffer.mc
new file mode 100644
index 0000000..0aca548
--- /dev/null
+++ b/source/import/ImportBuffer.mc
@@ -0,0 +1,90 @@
+// Author: cirakg & claude ai
+// Summary: Keeps the text of an unfinished multi-part import between syncs, sealed with the device key, capped and expiring.
+//
+// README 11.2: when pasted JSON is still incomplete, the watch keeps what it
+// got and waits for the next part. The text is sealed (SealedBox, device
+// key, aad "OTPW-IMP-1"), because it holds secrets, and split into chunks
+// below Application.Storage's 8 KB per value:
+// "imp" {"n" => chunk count, "t" => unix time saved}
+// "imp.<i>" chunk i of the sealed box
+// Chunks are written before "imp", and "imp" is deleted first, so a torn
+// write leaves no readable half-buffer. Buffers older than MAX_AGE_S are
+// discarded.
+
+import Toybox.Application;
+import Toybox.Lang;
+
+module ImportBuffer {
+
+ const META_KEY = "imp";
+ const CHUNK_BYTES = 7000;
+ const MAX_TEXT_BYTES = 32768;
+ const MAX_AGE_S = 86400;
+ const AAD = "OTPW-IMP-1";
+
+ // The waiting text, or "" when there is none (or it expired or is damaged,
+ // in which case it is deleted).
+ function load(store as Persist.Backend, keys as SealedBox.Keys, now as Long) as String {
+ var meta = store.getValue(META_KEY);
+ if (!(meta instanceof Dictionary)) {
+ return "";
+ }
+ var n = meta["n"];
+ var t = meta["t"];
+ if (!(n instanceof Number) || !(t instanceof Long) || n < 1 || n > 16 || now - t > MAX_AGE_S || now < t) {
+ clear(store);
+ return "";
+ }
+ var box = []b;
+ for (var i = 0; i < n; i++) {
+ var chunk = store.getValue(META_KEY + "." + i);
+ if (!(chunk instanceof ByteArray)) {
+ clear(store);
+ return "";
+ }
+ box.addAll(chunk);
+ }
+ var plain = SealedBox.open(keys, Bytes.fromUtf8(AAD), box);
+ var text = plain != null ? Bytes.toUtf8(plain) : null;
+ Bytes.wipe(plain);
+ if (text == null) {
+ clear(store);
+ return "";
+ }
+ return text;
+ }
+
+ // Save text as the waiting import. Returns false (and saves nothing)
+ // when it is over MAX_TEXT_BYTES.
+ function save(store as Persist.Backend, keys as SealedBox.Keys, text as String, now as Long) as Boolean {
+ var plain = Bytes.fromUtf8(text);
+ if (plain.size() > MAX_TEXT_BYTES) {
+ Bytes.wipe(plain);
+ return false;
+ }
+ clear(store);
+ var box = SealedBox.seal(keys, Bytes.fromUtf8(AAD), plain);
+ Bytes.wipe(plain);
+ var n = 0;
+ for (var at = 0; at < box.size(); at += CHUNK_BYTES) {
+ store.setValue(META_KEY + "." + n, box.slice(at, at + CHUNK_BYTES < box.size() ? at + CHUNK_BYTES : box.size()));
+ n++;
+ }
+ store.setValue(META_KEY, {"n" => n, "t" => now} as Dictionary<Application.Storage.KeyType, Application.Storage.ValueType>);
+ return true;
+ }
+
+ function hasPending(store as Persist.Backend) as Boolean {
+ return store.getValue(META_KEY) != null;
+ }
+
+ // Delete the waiting import (after it completed, failed or was cancelled).
+ function clear(store as Persist.Backend) as Void {
+ var meta = store.getValue(META_KEY);
+ store.deleteValue(META_KEY);
+ var n = meta instanceof Dictionary && meta["n"] instanceof Number ? meta["n"] as Number : 16;
+ for (var i = 0; i < n && i < 16; i++) {
+ store.deleteValue(META_KEY + "." + i);
+ }
+ }
+}
diff --git a/source/import/ImportItem.mc b/source/import/ImportItem.mc
new file mode 100644
index 0000000..a242aa0
--- /dev/null
+++ b/source/import/ImportItem.mc
@@ -0,0 +1,148 @@
+// Author: cirakg & claude ai
+// Summary: One parsed import candidate (an entry, an error or a skip) and the shared field validation of both formats.
+//
+// Both parsers (otpauth URIs and Aegis JSON) turn text fields into an Entry
+// through ImportFields.toEntry, so the same rules (README 10.4) and the same
+// messages apply to both. Messages name the field and the problem, never the
+// secret itself.
+
+import Toybox.Lang;
+
+class ImportItem {
+
+ // 1-based position in the import (URI number or Aegis entry number).
+ var index as Number;
+ // A short name for messages: the issuer, else the account, else "".
+ var label as String = "";
+ var entry as Entry? = null;
+ // Why the item can't be imported (shown as an error), or null.
+ var problem as String? = null;
+ // Why the item was deliberately skipped (unsupported type), or null.
+ var skipped as String? = null;
+ // A remark that doesn't stop the import (issuer mismatch).
+ var note as String? = null;
+ // Aegis groups: a group UUID (database version 3) or a name, resolved by
+ // the reader once the whole document is read.
+ var groupRef as String? = null;
+
+ function initialize(position as Number) {
+ index = position;
+ }
+
+ // "entry 7 'GitHub'" or "URI 3", for messages.
+ function describe(kind as String) as String {
+ return label.length() > 0 ? kind + " " + index + " '" + label + "'" : kind + " " + index;
+ }
+}
+
+// The raw fields one source item provides, as text, and their conversion.
+class ImportFields {
+
+ var type as String = "totp";
+ var issuer as String = "";
+ var account as String = "";
+ var secret as String? = null;
+ var algorithm as String = "SHA1";
+ var digits as Long = 6l;
+ var period as Long = 30l;
+ var counter as Long? = null;
+ var favorite as Boolean = false;
+
+ function initialize() {
+ }
+
+ // Fill item.entry, or set item.problem / item.skipped.
+ function toEntry(item as ImportItem) as Void {
+ item.label = issuer.length() > 0 ? issuer : account;
+ var t = type.toLower();
+ var otpType = -1;
+ if (t.equals("totp")) {
+ otpType = Entry.OTP_TOTP;
+ } else if (t.equals("hotp")) {
+ otpType = Entry.OTP_HOTP;
+ } else if (t.equals("steam")) {
+ otpType = Entry.OTP_STEAM;
+ } else if (t.equals("motp")) {
+ item.skipped = "mOTP is not supported";
+ return;
+ } else if (t.equals("yandex")) {
+ item.skipped = "Yandex is not supported";
+ return;
+ } else {
+ item.skipped = "type '" + Text.clip(t, 12) + "' is not supported";
+ return;
+ }
+ if (issuer.length() == 0 && account.length() == 0) {
+ item.problem = "needs an issuer or an account name";
+ return;
+ }
+ if (Bytes.fromUtf8(issuer).size() > Entry.MAX_NAME_BYTES || Bytes.fromUtf8(account).size() > Entry.MAX_NAME_BYTES) {
+ item.problem = "issuer or account longer than " + Entry.MAX_NAME_BYTES + " bytes";
+ return;
+ }
+ var e = new Entry();
+ e.kind = Entry.KIND_OTP;
+ e.type = otpType;
+ e.name = issuer;
+ e.account = account;
+ e.source = Entry.SOURCE_IMPORT;
+ e.favorite = favorite;
+ if (otpType == Entry.OTP_STEAM) {
+ // Steam always uses SHA-1, 30 s and 5 characters.
+ e.algorithm = Hashes.SHA1;
+ e.digits = Otp.MIN_DIGITS;
+ e.period = Otp.STEAM_PERIOD;
+ } else {
+ var a = algorithm.toUpper();
+ if (a.equals("SHA1")) {
+ e.algorithm = Hashes.SHA1;
+ } else if (a.equals("SHA256")) {
+ e.algorithm = Hashes.SHA256;
+ } else if (a.equals("SHA512")) {
+ e.algorithm = Hashes.SHA512;
+ } else {
+ item.problem = "algorithm '" + Text.clip(algorithm, 10) + "' is not supported (SHA1, SHA256, SHA512)";
+ return;
+ }
+ if (digits < Otp.MIN_DIGITS || digits > Otp.MAX_DIGITS) {
+ item.problem = digits + " digits not supported (6-8)";
+ return;
+ }
+ e.digits = digits.toNumber();
+ if (period < Otp.MIN_PERIOD || period > Otp.MAX_PERIOD) {
+ item.problem = "period " + period + " s not supported (1-300)";
+ return;
+ }
+ e.period = period.toNumber();
+ }
+ if (otpType == Entry.OTP_HOTP) {
+ var c = counter;
+ if (c == null) {
+ item.problem = "HOTP needs a counter";
+ return;
+ }
+ if (c < 0) {
+ item.problem = "counter can't be negative";
+ return;
+ }
+ e.counter = c;
+ }
+ var s = secret;
+ if (s == null || s.length() == 0) {
+ item.problem = "secret missing";
+ return;
+ }
+ var decoded = Base32.decode(s);
+ if (decoded instanceof Base32.DecodeError) {
+ item.problem = "secret: " + decoded.message + (decoded.position > 0 ? " (position " + decoded.position + ")" : "");
+ return;
+ }
+ if (decoded.size() == 0 || decoded.size() > Entry.MAX_SECRET_BYTES) {
+ item.problem = "secret must be 1 to " + Entry.MAX_SECRET_BYTES + " bytes";
+ Bytes.wipe(decoded);
+ return;
+ }
+ e.secret = decoded;
+ item.entry = e;
+ }
+}
diff --git a/source/import/ImportReport.mc b/source/import/ImportReport.mc
new file mode 100644
index 0000000..b2a986a
--- /dev/null
+++ b/source/import/ImportReport.mc
@@ -0,0 +1,71 @@
+// Author: cirakg & claude ai
+// Summary: The outcome of one import: counts, per-item errors and skips, notes, and the texts for the status field and results screen.
+
+import Toybox.Lang;
+
+class ImportReport {
+
+ // At most this many detail lines are kept (the results screen scrolls).
+ static const MAX_DETAILS = 40;
+
+ var imported as Number = 0;
+ var pendingLocked as Number = 0;
+ var duplicates as Number = 0;
+ var errors as Number = 0;
+ var skipped as Number = 0;
+ var removed as Number = 0;
+ // Set when the whole import failed (not JSON, encrypted, storage full
+ // before anything was stored...).
+ var failure as String? = null;
+ // Set while a multi-part import waits for its next part.
+ var waitingChars as Number = -1;
+ var details as Array<String> = [] as Array<String>;
+
+ function initialize() {
+ }
+
+ function addDetail(line as String) as Void {
+ if (details.size() < MAX_DETAILS) {
+ details.add(line);
+ } else if (details.size() == MAX_DETAILS) {
+ details.add("…");
+ }
+ }
+
+ // One line for the phone's Import status field, for example
+ // "Imported 12 · 2 duplicates skipped · 1 error · 1 skipped".
+ function summary() as String {
+ var f = failure;
+ if (f != null) {
+ return "Not imported: " + f;
+ }
+ if (waitingChars >= 0) {
+ return "Waiting for more: received " + waitingChars
+ + " characters. Paste the next part into the import fields and turn 'Import now' on again.";
+ }
+ var text = "Imported " + imported;
+ if (pendingLocked > 0) {
+ text += " (" + pendingLocked + " locked, waiting for your PIN)";
+ }
+ if (removed > 0) {
+ text += " · " + removed + " old imported removed";
+ }
+ if (duplicates > 0) {
+ text += " · " + duplicates + (duplicates == 1 ? " duplicate skipped" : " duplicates skipped");
+ }
+ if (errors > 0) {
+ text += " · " + errors + (errors == 1 ? " error" : " errors");
+ }
+ if (skipped > 0) {
+ text += " · " + skipped + " skipped";
+ }
+ return text;
+ }
+
+ // The summary followed by the details, for the results screen.
+ function lines() as Array<String> {
+ var out = [summary()] as Array<String>;
+ out.addAll(details);
+ return out;
+ }
+}
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();
+ }
+}
diff --git a/source/import/Json.mc b/source/import/Json.mc
new file mode 100644
index 0000000..c19150e
--- /dev/null
+++ b/source/import/Json.mc
@@ -0,0 +1,486 @@
+// Author: cirakg & claude ai
+// Summary: A streaming JSON tokenizer over UTF-8 bytes: one token per call, strict grammar, depth and length limits.
+//
+// README 11.3: the Aegis reader must stream, never build large objects, and
+// pause between steps (watchdog). This tokenizer keeps only its position, a
+// small container stack and the current token, so a reader can stop after any
+// token and continue in the next event.
+//
+// Limits and guarantees:
+// * Strict RFC 8259 grammar; anything else is T_ERROR with a message.
+// * Nesting deeper than MAX_DEPTH is an error.
+// * Strings longer than MAX_STRING_BYTES, or with more than STRING_WINDOW
+// input bytes (icons in Aegis exports), are not decoded: they come back as T_LONG_STRING and are skipped with native
+// searches, so a 50 KB icon costs a few calls, not 50,000 loop rounds.
+// * Escapes, including \u with surrogate pairs, are decoded; the result
+// must be valid UTF-8 without NUL (Bytes.toUtf8).
+// * Input that ends inside the document gives T_INCOMPLETE (not an error),
+// so a multi-part import can wait for the rest.
+// * Numbers are returned as Long when they are integers that fit;
+// numberIsInteger is false otherwise (Aegis only needs integers).
+// * Work per call is bounded: at most STRING_WINDOW bytes are looked at
+// in Monkey C per token; searches over longer stretches are native.
+
+import Toybox.Lang;
+
+module Json {
+
+ const T_END = 0; // the document is complete
+ const T_BEGIN_OBJECT = 1;
+ const T_END_OBJECT = 2;
+ const T_BEGIN_ARRAY = 3;
+ const T_END_ARRAY = 4;
+ const T_KEY = 5; // text holds the key
+ const T_STRING = 6; // text holds the value
+ const T_NUMBER = 7; // number / numberIsInteger
+ const T_TRUE = 8;
+ const T_FALSE = 9;
+ const T_NULL = 10;
+ const T_LONG_STRING = 11; // a string value over MAX_STRING_BYTES, skipped
+ const T_INCOMPLETE = 12; // the input ended inside the document
+ const T_ERROR = 13; // error holds the reason
+
+ const MAX_DEPTH = 16;
+ // Longest decoded string; values the importer needs (names, secrets,
+ // group IDs) are far shorter.
+ const MAX_STRING_BYTES = 512;
+ // Input bytes examined for one string: escapes may double its size.
+ const STRING_WINDOW = 2 * MAX_STRING_BYTES + 2;
+ // Chunk size for native searches when skipping long strings.
+ const SKIP_CHUNK = 4096;
+
+ // Parser states.
+ const S_VALUE = 0;
+ const S_VALUE_OR_END = 1; // after '['
+ const S_KEY_OR_END = 2; // after '{'
+ const S_KEY = 3; // after ',' in an object
+ const S_COLON = 4;
+ const S_COMMA_OR_END = 5;
+ const S_DONE = 6;
+ const S_FAILED = 7;
+
+ const QUOTE = 0x22;
+ const BACKSLASH = 0x5C;
+
+ class Tokenizer {
+
+ var pos as Number = 0;
+ var depth as Number = 0;
+ var text as String = "";
+ var number as Long = 0l;
+ var numberIsInteger as Boolean = false;
+ var error as String = "";
+
+ private var _data as ByteArray;
+ private var _stack as ByteArray = []b; // 1 = object, 2 = array
+ private var _state as Number = S_VALUE;
+
+ function initialize(data as ByteArray) {
+ _data = data;
+ }
+
+ // Bytes of input.
+ function size() as Number {
+ return _data.size();
+ }
+
+ // The next token (T_*). After T_END, T_INCOMPLETE or T_ERROR every
+ // further call returns the same token.
+ function next() as Number {
+ while (true) {
+ if (_state == S_FAILED) {
+ return T_ERROR;
+ }
+ skipWhitespace();
+ if (pos >= _data.size()) {
+ return _state == S_DONE ? T_END : T_INCOMPLETE;
+ }
+ var b = _data[pos];
+ if (_state == S_DONE) {
+ return fail("unexpected data after the end of the JSON document");
+ }
+ if (_state == S_COLON) {
+ if (b != 0x3A) {
+ return fail("':' expected");
+ }
+ pos++;
+ _state = S_VALUE;
+ continue;
+ }
+ if (_state == S_COMMA_OR_END) {
+ var top = _stack[_stack.size() - 1];
+ if (b == 0x2C) {
+ pos++;
+ _state = top == 1 ? S_KEY : S_VALUE;
+ continue;
+ }
+ if ((top == 1 && b == 0x7D) || (top == 2 && b == 0x5D)) {
+ return close(top);
+ }
+ return fail(top == 1 ? "',' or '}' expected" : "',' or ']' expected");
+ }
+ if (_state == S_KEY_OR_END && b == 0x7D) {
+ return close(1);
+ }
+ if (_state == S_KEY_OR_END || _state == S_KEY) {
+ if (b != QUOTE) {
+ return fail("a quoted key expected");
+ }
+ var key = readString(false);
+ if (key != T_STRING) {
+ return key;
+ }
+ _state = S_COLON;
+ return T_KEY;
+ }
+ if (_state == S_VALUE_OR_END && b == 0x5D) {
+ return close(2);
+ }
+ return value(b);
+ }
+ return T_ERROR; // not reached
+ }
+
+ private function value(b as Number) as Number {
+ if (b == 0x7B || b == 0x5B) {
+ if (depth >= MAX_DEPTH) {
+ return fail("nested deeper than " + MAX_DEPTH + " levels");
+ }
+ pos++;
+ depth++;
+ _stack.add(b == 0x7B ? 1 : 2);
+ _state = b == 0x7B ? S_KEY_OR_END : S_VALUE_OR_END;
+ return b == 0x7B ? T_BEGIN_OBJECT : T_BEGIN_ARRAY;
+ }
+ var token = T_ERROR;
+ if (b == QUOTE) {
+ token = readString(true);
+ } else if (b == 0x74) {
+ token = literal("true", T_TRUE);
+ } else if (b == 0x66) {
+ token = literal("false", T_FALSE);
+ } else if (b == 0x6E) {
+ token = literal("null", T_NULL);
+ } else if (b == 0x2D || (b >= 0x30 && b <= 0x39)) {
+ token = readNumber();
+ } else {
+ return fail("a value expected");
+ }
+ if (token != T_INCOMPLETE && token != T_ERROR) {
+ _state = depth == 0 ? S_DONE : S_COMMA_OR_END;
+ }
+ return token;
+ }
+
+ private function close(kind as Number) as Number {
+ pos++;
+ depth--;
+ _stack = _stack.slice(0, _stack.size() - 1);
+ _state = depth == 0 ? S_DONE : S_COMMA_OR_END;
+ return kind == 1 ? T_END_OBJECT : T_END_ARRAY;
+ }
+
+ private function fail(message as String) as Number {
+ error = message + " at byte " + pos;
+ _state = S_FAILED;
+ return T_ERROR;
+ }
+
+ private function skipWhitespace() as Void {
+ var data = _data;
+ var n = data.size();
+ while (pos < n) {
+ var b = data[pos];
+ if (b != 0x20 && b != 0x0A && b != 0x0D && b != 0x09) {
+ return;
+ }
+ pos++;
+ }
+ }
+
+ private function literal(word as String, token as Number) as Number {
+ var bytes = Bytes.fromUtf8(word);
+ for (var i = 0; i < bytes.size(); i++) {
+ if (pos + i >= _data.size()) {
+ return T_INCOMPLETE;
+ }
+ if (_data[pos + i] != bytes[i]) {
+ return fail("invalid literal");
+ }
+ }
+ pos += bytes.size();
+ return token;
+ }
+
+ // -?(0|[1-9][0-9]*)(.[0-9]+)?([eE][+-]?[0-9]+)?
+ private function readNumber() as Number {
+ var data = _data;
+ var n = data.size();
+ var start = pos;
+ var i = pos;
+ var negative = false;
+ if (data[i] == 0x2D) {
+ negative = true;
+ i++;
+ }
+ var intStart = i;
+ var value = 0l;
+ var overflow = false;
+ while (i < n && data[i] >= 0x30 && data[i] <= 0x39) {
+ if (i - intStart >= 18) {
+ overflow = true;
+ } else {
+ value = value * 10 + (data[i] - 0x30);
+ }
+ i++;
+ }
+ if (i == intStart) {
+ return i >= n ? T_INCOMPLETE : fail("invalid number");
+ }
+ if (i - intStart > 1 && data[intStart] == 0x30) {
+ return fail("leading zero in a number");
+ }
+ var integer = !overflow;
+ if (i < n && data[i] == 0x2E) {
+ integer = false;
+ i++;
+ var fracStart = i;
+ while (i < n && data[i] >= 0x30 && data[i] <= 0x39) {
+ i++;
+ }
+ if (i == fracStart) {
+ return i >= n ? T_INCOMPLETE : fail("invalid number");
+ }
+ }
+ if (i < n && (data[i] == 0x65 || data[i] == 0x45)) {
+ integer = false;
+ i++;
+ if (i < n && (data[i] == 0x2B || data[i] == 0x2D)) {
+ i++;
+ }
+ var expStart = i;
+ while (i < n && data[i] >= 0x30 && data[i] <= 0x39) {
+ i++;
+ }
+ if (i == expStart) {
+ return i >= n ? T_INCOMPLETE : fail("invalid number");
+ }
+ }
+ if (i >= n && depth > 0) {
+ // The number may continue in the next part.
+ pos = start;
+ return T_INCOMPLETE;
+ }
+ pos = i;
+ numberIsInteger = integer;
+ number = negative ? -value : value;
+ return T_NUMBER;
+ }
+
+ // Reads a string starting at the opening quote. Values over
+ // MAX_STRING_BYTES are skipped (T_LONG_STRING); keys that long are an
+ // error. Returns T_STRING with `text` set, or another token.
+ private function readString(isValue as Boolean) as Number {
+ var data = _data;
+ var n = data.size();
+ var start = pos + 1;
+ // Find the closing quote within the window, with native searches.
+ var window = data.slice(start, start + STRING_WINDOW < n ? start + STRING_WINDOW : n);
+ var end = closingQuote(window);
+ if (end < 0) {
+ if (window.size() < STRING_WINDOW) {
+ return T_INCOMPLETE; // the input ends inside the string
+ }
+ return isValue ? skipLongString(start) : fail("key too long");
+ }
+ if (end > MAX_STRING_BYTES && window.slice(0, end).indexOf(BACKSLASH) < 0) {
+ // Without escapes the decoded text is as long as the input.
+ pos = start + end + 1;
+ return isValue ? T_LONG_STRING : fail("key too long");
+ }
+ var out = []b;
+ var i = 0;
+ while (i < end) {
+ var b = window[i];
+ if (b < 0x20) {
+ pos = start + i;
+ return fail("control character in a string");
+ }
+ if (b != BACKSLASH) {
+ out.add(b);
+ i++;
+ continue;
+ }
+ var e = window[i + 1];
+ if (e == 0x75) {
+ var consumed = unicodeEscape(window, i, end, out);
+ if (consumed < 0) {
+ pos = start + i;
+ return fail("invalid \\u escape");
+ }
+ i += consumed;
+ continue;
+ }
+ var mapped = escapeByte(e);
+ if (mapped < 0) {
+ pos = start + i;
+ return fail("invalid escape");
+ }
+ out.add(mapped);
+ i += 2;
+ }
+ if (out.size() > MAX_STRING_BYTES) {
+ pos = start + end + 1;
+ if (!isValue) {
+ return fail("key too long");
+ }
+ return T_LONG_STRING;
+ }
+ var decoded = Bytes.toUtf8(out);
+ if (decoded == null) {
+ pos = start;
+ return fail("invalid UTF-8 or NUL in a string");
+ }
+ text = decoded;
+ pos = start + end + 1;
+ return T_STRING;
+ }
+
+ // Index of the first unescaped quote in bytes, or -1. Quotes are
+ // found natively; one preceded by an odd number of backslashes is
+ // escaped.
+ private function closingQuote(bytes as ByteArray) as Number {
+ var offset = 0;
+ while (offset < bytes.size()) {
+ var rel = (offset == 0 ? bytes : bytes.slice(offset, null)).indexOf(QUOTE);
+ if (rel < 0) {
+ return -1;
+ }
+ var q = offset + rel;
+ var backslashes = 0;
+ while (q - 1 - backslashes >= 0 && bytes[q - 1 - backslashes] == BACKSLASH) {
+ backslashes++;
+ }
+ if (backslashes % 2 == 0) {
+ return q;
+ }
+ offset = q + 1;
+ }
+ return -1;
+ }
+
+ // Skips a long string value from `start` (just after its opening
+ // quote) using native searches; escaped quotes are recognised by the
+ // number of backslashes before them.
+ private function skipLongString(start as Number) as Number {
+ var data = _data;
+ var n = data.size();
+ var p = start;
+ while (p < n) {
+ var chunkEnd = p + SKIP_CHUNK < n ? p + SKIP_CHUNK : n;
+ var found = data.slice(p, chunkEnd).indexOf(QUOTE);
+ if (found < 0) {
+ p = chunkEnd;
+ continue;
+ }
+ var q = p + found;
+ var backslashes = 0;
+ while (q - 1 - backslashes >= start && data[q - 1 - backslashes] == BACKSLASH) {
+ backslashes++;
+ }
+ if (backslashes % 2 == 0) {
+ pos = q + 1;
+ return T_LONG_STRING;
+ }
+ p = q + 1;
+ }
+ return T_INCOMPLETE;
+ }
+
+ // The byte for a one-character escape, or -1.
+ private function escapeByte(e as Number) as Number {
+ if (e == QUOTE || e == BACKSLASH || e == 0x2F) {
+ return e;
+ }
+ if (e == 0x62) {
+ return 0x08;
+ }
+ if (e == 0x66) {
+ return 0x0C;
+ }
+ if (e == 0x6E) {
+ return 0x0A;
+ }
+ if (e == 0x72) {
+ return 0x0D;
+ }
+ if (e == 0x74) {
+ return 0x09;
+ }
+ return -1;
+ }
+
+ // Decodes \uXXXX (and a following low surrogate) at bytes[i] into
+ // UTF-8. Returns the number of input bytes used, or -1.
+ private function unicodeEscape(bytes as ByteArray, i as Number, end as Number, out as ByteArray) as Number {
+ var cp = hex4(bytes, i + 2, end);
+ if (cp < 0) {
+ return -1;
+ }
+ var used = 6;
+ if (cp >= 0xD800 && cp <= 0xDBFF) {
+ if (i + 11 >= end + 1 || bytes[i + 6] != BACKSLASH || bytes[i + 7] != 0x75) {
+ return -1;
+ }
+ var low = hex4(bytes, i + 8, end);
+ if (low < 0xDC00 || low > 0xDFFF) {
+ return -1;
+ }
+ cp = 0x10000 + ((cp - 0xD800) << 10) + (low - 0xDC00);
+ used = 12;
+ } else if (cp >= 0xDC00 && cp <= 0xDFFF) {
+ return -1;
+ }
+ if (cp < 0x80) {
+ out.add(cp);
+ } else if (cp < 0x800) {
+ out.add(0xC0 | (cp >> 6));
+ out.add(0x80 | (cp & 0x3F));
+ } else if (cp < 0x10000) {
+ out.add(0xE0 | (cp >> 12));
+ out.add(0x80 | ((cp >> 6) & 0x3F));
+ out.add(0x80 | (cp & 0x3F));
+ } else {
+ out.add(0xF0 | (cp >> 18));
+ out.add(0x80 | ((cp >> 12) & 0x3F));
+ out.add(0x80 | ((cp >> 6) & 0x3F));
+ out.add(0x80 | (cp & 0x3F));
+ }
+ return used;
+ }
+
+ // Four hex digits at bytes[at..at+3] (all before `end`), or -1.
+ private function hex4(bytes as ByteArray, at as Number, end as Number) as Number {
+ if (at + 4 > end) {
+ return -1;
+ }
+ var v = 0;
+ for (var k = 0; k < 4; k++) {
+ var c = bytes[at + k];
+ var d = -1;
+ if (c >= 0x30 && c <= 0x39) {
+ d = c - 0x30;
+ } else if (c >= 0x41 && c <= 0x46) {
+ d = c - 0x37;
+ } else if (c >= 0x61 && c <= 0x66) {
+ d = c - 0x57;
+ }
+ if (d < 0) {
+ return -1;
+ }
+ v = (v << 4) | d;
+ }
+ return v;
+ }
+ }
+}
diff --git a/source/import/OtpAuth.mc b/source/import/OtpAuth.mc
new file mode 100644
index 0000000..cdf0f9c
--- /dev/null
+++ b/source/import/OtpAuth.mc
@@ -0,0 +1,186 @@
+// Author: cirakg & claude ai
+// Summary: Parses otpauth:// URIs (totp, hotp, steam) from pasted text into import items (README 11.3).
+//
+// Rules:
+// * Text may hold many URIs, separated by spaces, line breaks or nothing:
+// a new URI starts at every "otpauth://" (any case).
+// * The label is percent-decoded and split at the first ':' into issuer
+// and account (spaces trimmed). An issuer parameter wins over the label;
+// a mismatch becomes a note.
+// * Parameters are separated by '&' or ';' (the Garmin Connect iPhone app
+// may reject '&'), keys are case-insensitive, values are percent-decoded
+// with '+' meaning a space. Unknown parameters are ignored.
+// * "steam" as the type, or encoder=steam, means Steam.
+// * Everything is validated by ImportFields.toEntry.
+
+import Toybox.Lang;
+
+module OtpAuth {
+
+ const PREFIX = "otpauth://";
+ const MIGRATION_PREFIX = "otpauth-migration://";
+
+ // The URIs in text, split at every "otpauth://". Text before the first
+ // one is ignored here; the importer reports it.
+ function splitUris(text as String) as Array<String> {
+ var uris = [] as Array<String>;
+ var at = Text.findIgnoreCase(text, PREFIX, 0);
+ while (at >= 0) {
+ var next = Text.findIgnoreCase(text, PREFIX, at + PREFIX.length());
+ var end = next >= 0 ? next : text.length();
+ uris.add(firstWord(text.substring(at, end) as String));
+ at = next;
+ }
+ return uris;
+ }
+
+ // Text up to the first whitespace (a pasted URI never contains any).
+ function firstWord(text as String) as String {
+ var chars = text.toCharArray();
+ for (var i = 0; i < chars.size(); i++) {
+ if (Text.isSpace(chars[i])) {
+ return text.substring(0, i) as String;
+ }
+ }
+ return text;
+ }
+
+ // One URI as an import item (entry, problem or skip).
+ function parse(uri as String, index as Number) as ImportItem {
+ var item = new ImportItem(index);
+ var fields = new ImportFields();
+ var rest = uri.substring(PREFIX.length(), uri.length()) as String;
+ var slash = rest.find("/");
+ if (slash == null) {
+ item.problem = "no '/' after the type";
+ return item;
+ }
+ fields.type = (rest.substring(0, slash) as String).toLower();
+ rest = rest.substring(slash + 1, rest.length()) as String;
+ var question = rest.find("?");
+ var labelText = question == null ? rest : rest.substring(0, question) as String;
+ var query = question == null ? "" : rest.substring(question + 1, rest.length()) as String;
+
+ var label = percentDecode(labelText, false);
+ if (label == null) {
+ item.problem = "the label has an invalid % escape";
+ return item;
+ }
+ var colon = label.find(":");
+ var labelIssuer = "";
+ if (colon != null) {
+ labelIssuer = Text.trim(label.substring(0, colon) as String);
+ fields.account = Text.trim(label.substring(colon + 1, label.length()) as String);
+ } else {
+ fields.account = Text.trim(label);
+ }
+ fields.issuer = labelIssuer;
+
+ var issuerParam = null;
+ var encoder = "";
+ var pairs = splitParams(query);
+ for (var i = 0; i < pairs.size(); i++) {
+ var pair = pairs[i];
+ if (pair.length() == 0) {
+ continue;
+ }
+ var eq = pair.find("=");
+ var key = (eq == null ? pair : pair.substring(0, eq) as String).toLower();
+ var value = percentDecode(eq == null ? "" : pair.substring(eq + 1, pair.length()) as String, true);
+ if (value == null) {
+ item.problem = "parameter '" + Text.clip(key, 12) + "' has an invalid % escape";
+ return item;
+ }
+ if (key.equals("secret")) {
+ fields.secret = value;
+ } else if (key.equals("issuer")) {
+ issuerParam = Text.trim(value);
+ } else if (key.equals("algorithm")) {
+ fields.algorithm = value;
+ } else if (key.equals("digits") || key.equals("period") || key.equals("counter")) {
+ var number = Text.parseLong(value);
+ if (number == null) {
+ item.problem = key + "=" + Text.clip(value, 10) + " is not a whole number";
+ return item;
+ }
+ if (key.equals("digits")) {
+ fields.digits = number;
+ } else if (key.equals("period")) {
+ fields.period = number;
+ } else {
+ fields.counter = number;
+ }
+ } else if (key.equals("encoder")) {
+ encoder = value.toLower();
+ }
+ }
+ if (issuerParam != null && issuerParam.length() > 0) {
+ if (labelIssuer.length() > 0 && !labelIssuer.equals(issuerParam)) {
+ item.note = "issuer '" + Text.clip(issuerParam, 20) + "' differs from the label's '" + Text.clip(labelIssuer, 20) + "'";
+ }
+ fields.issuer = issuerParam;
+ }
+ if (encoder.equals("steam")) {
+ fields.type = "steam";
+ }
+ fields.toEntry(item);
+ return item;
+ }
+
+ // Parameters separated by '&' or ';'.
+ function splitParams(query as String) as Array<String> {
+ var parts = [] as Array<String>;
+ var chars = query.toCharArray();
+ var start = 0;
+ for (var i = 0; i <= chars.size(); i++) {
+ if (i == chars.size() || chars[i] == '&' || chars[i] == ';') {
+ parts.add(query.substring(start, i) as String);
+ start = i + 1;
+ }
+ }
+ return parts;
+ }
+
+ // Percent-decoding to UTF-8 text; '+' becomes a space when plusIsSpace.
+ // Null for a bad escape or invalid UTF-8.
+ function percentDecode(text as String, plusIsSpace as Boolean) as String? {
+ if (text.find("%") == null && (!plusIsSpace || text.find("+") == null)) {
+ return text;
+ }
+ var bytes = Bytes.fromUtf8(text);
+ var out = []b;
+ var i = 0;
+ while (i < bytes.size()) {
+ var b = bytes[i];
+ if (b == 0x25) {
+ if (i + 2 >= bytes.size()) {
+ return null;
+ }
+ var hi = hexValue(bytes[i + 1]);
+ var lo = hexValue(bytes[i + 2]);
+ if (hi < 0 || lo < 0) {
+ return null;
+ }
+ out.add(hi * 16 + lo);
+ i += 3;
+ } else {
+ out.add(plusIsSpace && b == 0x2B ? 0x20 : b);
+ i++;
+ }
+ }
+ return Bytes.toUtf8(out);
+ }
+
+ function hexValue(c as Number) as Number {
+ if (c >= 0x30 && c <= 0x39) {
+ return c - 0x30;
+ }
+ if (c >= 0x41 && c <= 0x46) {
+ return c - 0x37;
+ }
+ if (c >= 0x61 && c <= 0x66) {
+ return c - 0x57;
+ }
+ return -1;
+ }
+}