summaryrefslogtreecommitdiff
path: root/source/import/Importer.mc
blob: 825221b48295314a0e4c1508dda84677998f89f0 (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
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
// Author:  cirakg & claude ai
// Summary: Runs an import as a resumable job: detect the format, parse, replace old imports, find duplicates, store entry by entry.
//
// README 11. Phases, each done in small steps (one timer event each):
//   PARSE    otpauth URIs (one per step) or Aegis JSON (Json/Aegis token
//            steps). Incomplete JSON is kept in the ImportBuffer and the
//            import ends with "waiting for more".
//   REPLACE  in Replace mode, delete earlier imported entries (locked ones
//            only while unlocked; others are kept and reported).
//   INDEX    read every header once (a few per step) into a fingerprint
//            index (duplicates) and a name index (possible replacements).
//   STORE    one item per step: skip, report, or add to the vault. A crash
//            leaves only whole entries.
// The report is complete when step() returns true.
//
// Memory: while JSON is parsed the pasted text is held once, as the
// tokenizer's bytes, and parsed items are kept packed (ImportItem.pack);
// the M8 numbers are in docs/PERFORMANCE.md.

import Toybox.Lang;

class Importer extends Jobs.Job {

    static const MODE_APPEND = 0;
    static const MODE_REPLACE = 1;
    static const MODE_CANCEL = 2;

    static const P_PARSE = 0;
    static const P_REPLACE = 1;
    static const P_INDEX = 2;
    static const P_STORE = 3;
    static const P_DONE = 4;

    static const HEADERS_PER_STEP = 4;
    static const URIS_PER_STEP = 1;

    var report as ImportReport = new ImportReport();
    var phase as Number = P_PARSE;

    private var _vault as Vault;
    private var _mode as Number;
    private var _locked as Boolean;
    private var _now as Long;
    private var _text as String = "";
    private var _uris as Array<String>? = null;
    private var _aegis as AegisReader? = null;
    private var _items as Array<ByteArray> = [] as Array<ByteArray>;
    private var _groups as Dictionary<String, String> = {} as Dictionary<String, String>;
    private var _cursor as Number = 0;
    private var _ids as Array<String> = [] as Array<String>;
    private var _fingerprints as Dictionary<String, String> = {} as Dictionary<String, String>;
    private var _names as Dictionary<String, String> = {} as Dictionary<String, String>;
    // How items are named in messages: "URI 3" or "entry 7 'GitHub'".
    private var _kind as String = "entry";

    // text: the joined import fields. A waiting earlier part is prepended.
    function initialize(vault as Vault, text as String, mode as Number, locked as Boolean, now as Long) {
        Job.initialize();
        _vault = vault;
        _mode = mode;
        _locked = locked;
        _now = now;
        var keys = vault.keyManager.deviceKeys();
        var earlier = ImportBuffer.load(vault.storage(), keys, now);
        _text = earlier.length() > 0 ? earlier + text : text;
        detect();
    }

    // Progress for the UI, 0-100.
    function percent() as Number {
        if (phase == P_PARSE) {
            var a = _aegis;
            if (a != null) {
                return 40 * a.position() / (a.size() > 0 ? a.size() : 1);
            }
            var u = _uris;
            return u != null && u.size() > 0 ? 40 * _cursor / u.size() : 0;
        }
        if (phase == P_STORE) {
            return _items.size() > 0 ? 50 + 50 * _cursor / _items.size() : 100;
        }
        return phase == P_DONE ? 100 : 45;
    }

    private function detect() as Void {
        var trimmed = Text.trim(_text);
        if (trimmed.length() == 0) {
            finishWith("the import fields are empty");
            return;
        }
        if (trimmed.find("{") == 0) {
            // The untrimmed text: an incomplete part is saved exactly as
            // received (a cut can fall on a space inside a string).
            _aegis = new AegisReader(Bytes.fromUtf8(_text));
            _text = "";
            return;
        }
        if (Text.findIgnoreCase(trimmed, OtpAuth.MIGRATION_PREFIX, 0) >= 0) {
            finishWith("Google Authenticator exports are not supported yet. Export from Aegis, or paste otpauth:// links");
            return;
        }
        var first = Text.findIgnoreCase(trimmed, OtpAuth.PREFIX, 0);
        if (first < 0) {
            finishWith("not recognised. Paste otpauth:// links or an unencrypted Aegis JSON export");
            return;
        }
        if (first > 0) {
            report.addDetail("Ignored " + first + " characters before the first otpauth:// link");
        }
        _uris = OtpAuth.splitUris(trimmed);
        _kind = "URI";
        _text = "";
    }

    private function finishWith(failure as String) as Void {
        report.failure = failure;
        ImportBuffer.clear(_vault.storage());
        phase = P_DONE;
    }

    function step(budget as Number) as Boolean {
        Diag.sampleMemory();
        if (phase == P_PARSE) {
            parseStep();
        } else if (phase == P_REPLACE) {
            replaceStep();
        } else if (phase == P_INDEX) {
            indexStep();
        } else if (phase == P_STORE) {
            storeStep();
        }
        return phase == P_DONE;
    }

    private function parseStep() as Void {
        var aegis = _aegis;
        if (aegis != null) {
            var status = aegis.step();
            if (status == AegisReader.RUNNING) {
                return;
            }
            if (status == AegisReader.INCOMPLETE) {
                var data = aegis.data();
                _aegis = null;
                if (ImportBuffer.save(_vault.storage(), _vault.keyManager.deviceKeys(), data, _now)) {
                    var text = Bytes.toUtf8(data);
                    report.waitingChars = text != null ? text.length() : data.size();
                    phase = P_DONE;
                } else {
                    finishWith("the JSON is incomplete and too long to wait for more (" + data.size() + " bytes, at most "
                        + ImportBuffer.MAX_TEXT_BYTES + ")");
                }
                return;
            }
            if (status == AegisReader.FAILED) {
                _aegis = null;
                finishWith(aegis.error as String);
                return;
            }
            _items = aegis.items;
            _groups = aegis.groups;
            _aegis = null;
            startReplace();
            return;
        }
        var uris = _uris as Array<String>;
        for (var n = 0; n < URIS_PER_STEP && _cursor < uris.size(); n++) {
            _items.add(OtpAuth.parse(uris[_cursor], _cursor + 1).pack());
            _cursor++;
        }
        if (_cursor >= uris.size()) {
            _uris = null;
            startReplace();
        }
    }

    private function startReplace() as Void {
        ImportBuffer.clear(_vault.storage());
        _ids = _vault.ids();
        _ids.addAll(_vault.pendingIds());
        _cursor = 0;
        phase = _mode == MODE_REPLACE ? P_REPLACE : P_INDEX;
    }

    // Replace mode: delete one earlier imported entry per step.
    private function replaceStep() as Void {
        var kept = 0;
        while (_cursor < _ids.size()) {
            var id = _ids[_cursor];
            _cursor++;
            var header = _vault.anyHeader(id);
            if (header == null || header.source != Entry.SOURCE_IMPORT) {
                continue;
            }
            if (_vault.pendingIds().indexOf(id) >= 0 || (header.locked && !_vault.session.isUnlocked())) {
                kept++;
                report.addDetail("Kept locked imported entry " + id + ": unlock first to replace it");
                continue;
            }
            try {
                _vault.remove(id);
                report.removed++;
            } catch (e instanceof VaultError) {
                report.addDetail("Could not remove " + id);
            }
            return; // one deletion per step
        }
        _ids = _vault.ids();
        _ids.addAll(_vault.pendingIds());
        _cursor = 0;
        phase = P_INDEX;
    }

    // Read a few headers into the duplicate indexes.
    private function indexStep() as Void {
        for (var n = 0; n < HEADERS_PER_STEP && _cursor < _ids.size(); n++) {
            var id = _ids[_cursor];
            _cursor++;
            var header = _vault.anyHeader(id);
            if (header != null) {
                remember(header, id);
            }
        }
        if (_cursor >= _ids.size()) {
            _cursor = 0;
            phase = P_STORE;
        }
    }

    private function remember(header as Entry, id as String) as Void {
        var fp = header.fingerprint;
        if (fp != null) {
            _fingerprints[Bytes.toHex(fp)] = id;
        }
        if (header.kind == Entry.KIND_OTP && (header.name.length() > 0 || header.account.length() > 0)) {
            _names[nameKey(header)] = id;
        }
    }

    private function nameKey(e as Entry) as String {
        return e.name.toLower() + "\n" + e.account.toLower();
    }

    // Store (or report) one item.
    private function storeStep() as Void {
        if (_cursor >= _items.size()) {
            phase = P_DONE;
            return;
        }
        var packed = _items[_cursor];
        _items[_cursor] = []b;
        var item = ImportItem.unpack(packed, _groups);
        Bytes.wipe(packed); // the parsed secret; about 110 bytes
        _cursor++;
        var kind = _kind;
        var note = item.note;
        if (note != null) {
            report.addDetail(item.describe(kind) + ": " + note);
        }
        var skipped = item.skipped;
        if (skipped != null) {
            report.skipped++;
            report.addDetail(item.describe(kind) + ": skipped, " + skipped);
            return;
        }
        var problem = item.problem;
        var e = item.entry;
        if (problem != null || e == null) {
            report.errors++;
            report.addDetail(item.describe(kind) + ": " + (problem != null ? problem : "invalid"));
            return;
        }
        var fp = Bytes.toHex(_vault.keyManager.fingerprint(e));
        var existing = _fingerprints[fp];
        if (existing != null) {
            report.duplicates++;
            report.addDetail(item.describe(kind) + ": duplicate of " + existing);
            e.wipeSecrets();
            return;
        }
        var sameName = _names[nameKey(e)];
        e.locked = _locked;
        e.created = _now;
        try {
            var id = _vault.add(e);
            report.imported++;
            if (_vault.pendingIds().indexOf(id) >= 0) {
                report.pendingLocked++;
            }
            if (sameName != null) {
                report.addDetail(item.describe(kind) + ": added as " + id + ", possible replacement for " + sameName
                    + ". Delete the old one with Manage entry");
            }
            remember(e, id);
        } catch (err instanceof VaultError) {
            report.errors++;
            report.addDetail(item.describe(kind) + ": not stored (" + err.getErrorMessage() + ")");
            if (err.code == VaultError.FULL || err.code == VaultError.LIMIT) {
                report.addDetail("Stopped: " + (_items.size() - _cursor) + " more entries not imported");
                report.errors += _items.size() - _cursor;
                phase = P_DONE;
            }
        }
        e.wipeSecrets();
    }
}