diff options
| author | cirakg <cirakg@cirakg.xyz> | 2026-10-20 01:51:05 +0200 |
|---|---|---|
| committer | cirakg <cirakg@cirakg.xyz> | 2026-10-20 01:51:05 +0200 |
| commit | 40d9a1d5c3e85486aea964c1446402a3be6ec05a (patch) | |
| tree | 7af82c46a459afc99156e5a6022946b43e35a3de /source/import/OtpAuth.mc | |
| parent | bc228753bfb4a2faea540cddb20fc39ebd265db5 (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.mc | 186 |
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; + } +} |
