summaryrefslogtreecommitdiff
path: root/source/import/Aegis.mc
blob: 0d515cd12156c3ddad65b89390c7173e488fb427 (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
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
// 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 (packed, see ImportItem) and the group names; the
// document's bytes stay with the tokenizer.
//
//   * 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.";

    // Parsed items, packed (ImportItem.pack); unpack with item(i).
    var items as Array<ByteArray> = [] as Array<ByteArray>;
    // Group UUID -> name, from db.groups.
    var groups as Dictionary<String, String> = {} as Dictionary<String, String>;
    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 _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, and in all (for progress).
    function position() as Number {
        return _tok.pos;
    }

    function size() as Number {
        return _tok.size();
    }

    // The document's bytes (saved when it is incomplete).
    function data() as ByteArray {
        return _tok.data();
    }

    // Parsed item i, unpacked, with its group name resolved.
    function item(i as Number) as ImportItem {
        return ImportItem.unpack(items[i], groups);
    }

    // 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.pack());
            }
            _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)");
        }
        // Group references are resolved when items are unpacked (db.groups
        // may come after the entries).
        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;
    }
}