aboutsummaryrefslogtreecommitdiff
path: root/source/import/OtpAuth.mc
diff options
context:
space:
mode:
authorcirakg <cirakg@cirakg.xyz>2026-10-20 01:51:05 +0200
committercirakg <cirakg@cirakg.xyz>2026-10-20 01:51:05 +0200
commit40d9a1d5c3e85486aea964c1446402a3be6ec05a (patch)
tree7af82c46a459afc99156e5a6022946b43e35a3de /source/import/OtpAuth.mc
parentbc228753bfb4a2faea540cddb20fc39ebd265db5 (diff)
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 <noreply@anthropic.com>
Diffstat (limited to 'source/import/OtpAuth.mc')
-rw-r--r--source/import/OtpAuth.mc186
1 files changed, 186 insertions, 0 deletions
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;
+ }
+}