aboutsummaryrefslogtreecommitdiff
path: root/source/settings/ChangeQueue.mc
blob: c5919f6e80dbaa9f194e3af9b0de10b0478ab69d (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
// Author:  cirakg & claude ai
// Summary: Changes from the phone that wait for the PIN on the watch (README 7.9), sealed with the device key under "q".
//
// Anyone who controls the phone can send settings, so edits, new secrets,
// "Don't require PIN" and deletes of locked entries are not applied when
// they arrive: they wait here until someone confirms them with the PIN on
// the watch (or rejects them). Each item is one ManageAction sealed on its
// own with the device key (aad "OTPW-Q-1"), so a damaged item costs only
// itself. A new secret in an item is no more exposed than it was in the
// settings it came from, as with pending entries (README 7.7).
//
// "Require PIN" waits here only while the watch is locked (it needs the
// master key). It only adds protection, so applyLocks() applies it at the
// next correct (or newly set) PIN, without "Confirm all", the way pending
// entries are promoted.

import Toybox.Application;
import Toybox.Lang;

module ChangeQueue {

    const KEY = "q";
    const MAX_ITEMS = 16;
    const AAD = "OTPW-Q-1";

    // What applyLocks() did at the last PIN, for the results of a "Confirm
    // all" that follows it (ChangeQueueJob). Replaced at every PIN.
    var _lockLines as Array<String> = [] as Array<String>;

    function count(vault as Vault) as Number {
        var stored = vault.storage().getValue(KEY);
        return stored instanceof Array ? stored.size() : 0;
    }

    // Append an action. Throws VaultError LIMIT (too many waiting) or FULL.
    function add(vault as Vault, action as ManageAction) as Void {
        var stored = vault.storage().getValue(KEY);
        var items = stored instanceof Array ? stored as Array<Application.Storage.ValueType> : [] as Array<Application.Storage.ValueType>;
        if (items.size() >= MAX_ITEMS) {
            throw new VaultError(VaultError.LIMIT, MAX_ITEMS + " changes already wait for your PIN. Confirm or reject them on the watch first");
        }
        var plain = action.toBytes();
        items.add(SealedBox.seal(vault.keyManager.deviceKeys(), Bytes.fromUtf8(AAD), plain));
        Bytes.wipe(plain);
        try {
            vault.storage().setValue(KEY, items);
        } catch (e instanceof Lang.Exception) {
            throw new VaultError(VaultError.FULL, "storage full");
        }
    }

    // The waiting actions in arrival order; items that don't verify are
    // left out (and dropped by the next clear()).
    function load(vault as Vault) as Array<ManageAction> {
        var out = [] as Array<ManageAction>;
        var stored = vault.storage().getValue(KEY);
        if (!(stored instanceof Array)) {
            return out;
        }
        var keys = vault.keyManager.deviceKeys();
        var aad = Bytes.fromUtf8(AAD);
        for (var i = 0; i < stored.size(); i++) {
            var box = stored[i];
            var plain = box instanceof ByteArray ? SealedBox.open(keys, aad, box) : null;
            if (plain != null) {
                var action = ManageAction.fromBytes(plain);
                Bytes.wipe(plain);
                if (action != null) {
                    out.add(action);
                }
            }
        }
        return out;
    }

    // Short descriptions for the confirmation screen ("Delete: K7F2 Bank").
    function descriptions(vault as Vault) as Array<String> {
        var actions = load(vault);
        var out = [] as Array<String>;
        for (var i = 0; i < actions.size(); i++) {
            out.add(actions[i].describe());
            actions[i].wipe();
        }
        return out;
    }

    // The number of waiting "Require PIN" actions (applied at the next PIN).
    function countLocks(vault as Vault) as Number {
        var actions = load(vault);
        var n = 0;
        for (var i = 0; i < actions.size(); i++) {
            if (actions[i].action == ManageAction.LOCK) {
                n++;
            }
            actions[i].wipe();
        }
        return n;
    }

    // After a correct or new PIN (session unlocked): apply the waiting
    // "Require PIN" actions and remove them from the queue; the other items
    // keep waiting, in order. Each is one re-seal (ManageRun, no code
    // check), like promotePending. One that fails (the entry is gone or
    // already locked) is dropped too.
    function applyLocks(vault as Vault) as Void {
        _lockLines = [] as Array<String>;
        var stored = vault.storage().getValue(KEY);
        if (!(stored instanceof Array) || !vault.session.isUnlocked()) {
            return;
        }
        var keys = vault.keyManager.deviceKeys();
        var aad = Bytes.fromUtf8(AAD);
        var kept = [] as Array<Application.Storage.ValueType>;
        for (var i = 0; i < stored.size(); i++) {
            var box = stored[i];
            var plain = box instanceof ByteArray ? SealedBox.open(keys, aad, box) : null;
            var action = plain != null ? ManageAction.fromBytes(plain) : null;
            Bytes.wipe(plain);
            if (action != null && action.action == ManageAction.LOCK) {
                var run = new ManageRun(vault, action, true);
                Jobs.runToCompletion(run, 1);
                _lockLines.add(run.failed ? "Not applied: " + action.describe() + ". " + run.message : run.message);
            } else {
                if (action != null) {
                    action.wipe();
                }
                kept.add(box as Application.Storage.ValueType); // damaged items are dropped by the next clear()
            }
        }
        if (kept.size() == stored.size()) {
            return;
        }
        if (kept.size() == 0) {
            clear(vault);
            return;
        }
        try {
            vault.storage().setValue(KEY, kept);
        } catch (e instanceof Lang.Exception) {
            // Can't happen when shrinking; if it did, the locks just apply
            // again at the next PIN ("already requires the PIN").
        }
    }

    // The lines of the last applyLocks(), once (then forgotten).
    function takeLockLines() as Array<String> {
        var lines = _lockLines;
        _lockLines = [] as Array<String>;
        return lines;
    }

    function clear(vault as Vault) as Void {
        vault.storage().deleteValue(KEY);
    }

    // Remove the first n items (the ones just applied), keeping any that
    // arrived from the phone meanwhile.
    function drop(vault as Vault, n as Number) as Void {
        var stored = vault.storage().getValue(KEY);
        if (!(stored instanceof Array) || stored.size() <= n) {
            clear(vault);
            return;
        }
        try {
            vault.storage().setValue(KEY, stored.slice(n, null) as Array<Application.Storage.ValueType>);
        } catch (e instanceof Lang.Exception) {
            clear(vault); // can't happen when shrinking; never re-apply
        }
    }
}

// Applies every waiting change after the PIN was entered, one per step
// (each re-seals a record; a code may need its CodeCheck). The applied
// items leave the queue only at the end, so a crash midway loses nothing;
// applying one again is harmless (a deleted entry is then just missing).
class ChangeQueueJob extends Jobs.Job {

    var lines as Array<String> = [] as Array<String>;

    private var _vault as Vault;
    private var _actions as Array<ManageAction>;
    private var _loaded as Number = 0;
    private var _index as Number = 0;
    private var _run as ManageRun? = null;

    function initialize(vault as Vault) {
        Job.initialize();
        _vault = vault;
        var stored = vault.storage().getValue(ChangeQueue.KEY);
        _loaded = stored instanceof Array ? stored.size() : 0;
        _actions = ChangeQueue.load(vault);
        // "Require PIN" changes the PIN just applied (ChangeQueue.applyLocks).
        lines.addAll(ChangeQueue.takeLockLines());
    }

    function step(budget as Number) as Boolean {
        var run = _run;
        if (run == null) {
            if (_index >= _actions.size()) {
                ChangeQueue.drop(_vault, _loaded);
                if (lines.size() == 0) {
                    lines.add("No changes were waiting.");
                }
                return true;
            }
            run = new ManageRun(_vault, _actions[_index], true);
            _run = run;
        }
        if (run.step(budget)) {
            var problem = run.failed ? "Not applied: " + _actions[_index].describe() + ". " : "";
            lines.add(problem + run.message);
            lines.addAll(run.notes);
            _actions[_index].wipe();
            _index++;
            _run = null;
        }
        return false;
    }
}