summaryrefslogtreecommitdiff
path: root/source/import/OtpAuth.mc
blob: f5c487b8e8ab3eef4ee6a5bbaee1433930fff62c (plain) (blame)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
// 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. Runs in one event, so
    // it uses only native searches: a VM loop over every character of 100
    // pasted links tripped the watchdog.
    function splitUris(text as String) as Array<String> {
        var uris = [] as Array<String>;
        var lower = text.toLower();
        var at = lower.find(PREFIX);
        while (at != null) {
            var from = at + PREFIX.length();
            var found = (lower.substring(from, lower.length()) as String).find(PREFIX);
            var next = found != null ? from + found : null;
            uris.add(firstWord(text.substring(at, next != null ? next : text.length()) as String));
            at = next;
        }
        return uris;
    }

    // Text up to the first whitespace (a pasted URI never contains any).
    // Native searches, no loop over the characters (see splitUris).
    function firstWord(text as String) as String {
        var end = text.length();
        var spaces = [" ", "\t", "\n", "\r"];
        for (var i = 0; i < spaces.size(); i++) {
            var found = text.find(spaces[i]);
            if (found != null && found < end) {
                end = found;
            }
        }
        return end == text.length() ? text : text.substring(0, end) as String;
    }

    // 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;
    }
}