// 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 { var uris = [] as Array; 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 { var parts = [] as Array; 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; } }