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
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
|
// 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). 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).
//
// Each item is [action code, box] or, for a REPLACE, [action code, box,
// material number]: one ManageAction sealed on its own with the device key
// (aad "OTPW-Q-2" and the code byte, so a changed code doesn't open), so a
// damaged item costs only itself. Counting reads only the codes. The new
// secret or contents of a REPLACE (checked before queueing, up to 4 KB) are
// a storage value of their own, "qm.<n>", sealed with an aad naming the
// number and the entry, opened only when that action is applied, and
// deleted with its item. Items from 0.9.0 are a bare box (aad "OTPW-Q-1",
// material inside) and stay readable; they count as "to confirm".
//
// "Require PIN" waits here only while the watch is locked (it needs the
// master key). It only adds protection, so LockJob applies it at the next
// correct (or newly set) PIN, without "Confirm all", the way pending
// entries are promoted. Every walk that opens items is a job, one item per
// step (the watchdog: opening an item is a few byte loops).
import Toybox.Application;
import Toybox.Lang;
module ChangeQueue {
const KEY = "q";
const MAX_ITEMS = 16;
const AAD = "OTPW-Q-2";
// Items queued by 0.9.0.
const AAD_V1 = "OTPW-Q-1";
const MATERIAL_KEY = "qm.";
const MATERIAL_AAD = "OTPW-QM-1";
// What LockJob did at this PIN, for the results of a "Confirm all" that
// follows it (ChangeQueueJob). A PIN's first LockJob starts them afresh,
// a legacy PIN's second one (the new PIN) adds to them, and the PIN
// screen takes them when it closes, however it closes (they are in the
// results by then).
var _lockLines as Array<String> = [] as Array<String>;
// countToConfirm()'s cached result and the vault it is for.
var _toConfirm as Number? = null;
var _toConfirmFor as Vault? = null;
// The stored items (not opened).
function items(vault as Vault) as Array<Application.Storage.ValueType> {
var stored = vault.storage().getValue(KEY);
return stored instanceof Array ? stored as Array<Application.Storage.ValueType> : [] as Array<Application.Storage.ValueType>;
}
function count(vault as Vault) as Number {
return items(vault).size();
}
// Append an action (a REPLACE's material goes to a value of its own).
// Throws VaultError LIMIT (too many waiting) or FULL.
function add(vault as Vault, action as ManageAction) as Void {
addWith(vault, action, null);
}
// As add(); the copy sealing makes of up to 4 KB of material goes to
// `wiper` when given (ManageRun wipes it in steps).
function addWith(vault as Vault, action as ManageAction, wiper as Jobs.Wiper?) as Void {
var stored = items(vault);
if (stored.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 keys = vault.keyManager.deviceKeys();
var material = action.material;
var ref = material != null ? freeRef(stored) : -1;
if (material != null) {
try {
var leftovers = [] as Array<ByteArray>;
vault.storage().setValue(MATERIAL_KEY + ref, SealedBox.sealKeep(keys, materialAad(ref, action.id), material,
wiper != null ? leftovers : null));
if (wiper != null) {
wiper.addAll(leftovers);
}
} catch (e instanceof Lang.Exception) {
throw new VaultError(VaultError.FULL, "storage full");
}
}
action.material = null;
action.ref = ref;
var plain = action.toBytes();
action.material = material;
action.ref = -1;
var code = action.action;
var item = [code, SealedBox.seal(keys, itemAad(code), plain)] as Array<Application.Storage.ValueType>;
if (ref >= 0) {
item.add(ref);
}
Bytes.wipe(plain);
stored.add(item as Application.Storage.ValueType);
changed();
try {
vault.storage().setValue(KEY, stored);
} catch (e instanceof Lang.Exception) {
if (ref >= 0) {
vault.storage().deleteValue(MATERIAL_KEY + ref);
}
throw new VaultError(VaultError.FULL, "storage full");
}
}
// The action code of a stored item, read without opening it; -1 for a
// 0.9.0 item or a malformed one.
function codeOf(item as Application.Storage.ValueType) as Number {
if (item instanceof Array && item.size() >= 2 && item[0] instanceof Number) {
return item[0] as Number;
}
return -1;
}
// The material number of a stored item, -1 for none.
function refOf(item as Application.Storage.ValueType) as Number {
if (item instanceof Array && item.size() >= 3 && item[2] instanceof Number) {
return item[2] as Number;
}
return -1;
}
// Open one stored item: the action, or null when it doesn't verify.
// With `material`, a REPLACE's material is opened too (null when it
// doesn't verify); without, it isn't (descriptions, locks). A 0.9.0 item
// always has its material inside (release() hands it to the wiper).
// Kilobyte buffers to zero go to `wiper`, for the caller's next steps.
function open(vault as Vault, item as Application.Storage.ValueType, wiper as Jobs.Wiper, material as Boolean) as ManageAction? {
var keys = vault.keyManager.deviceKeys();
var leftovers = [] as Array<ByteArray>;
if (item instanceof ByteArray) {
var old = SealedBox.openKeep(keys, Bytes.fromUtf8(AAD_V1), item, leftovers);
wiper.addAll(leftovers);
if (old == null) {
return null;
}
var a = ManageAction.fromBytes(old);
wiper.add(old);
return a;
}
var code = codeOf(item);
var box = code >= 0 ? (item as Array<Application.Storage.ValueType>)[1] : null;
var plain = box instanceof ByteArray ? SealedBox.open(keys, itemAad(code), box) : null;
if (plain == null) {
return null;
}
var action = ManageAction.fromBytes(plain);
Bytes.wipe(plain); // small: names and an ID
if (action == null || action.action != code || action.ref != refOf(item)) {
return null;
}
if (action.ref >= 0 && material) {
var stored = vault.storage().getValue(MATERIAL_KEY + action.ref);
var opened = stored instanceof ByteArray ? SealedBox.openKeep(keys, materialAad(action.ref, action.id), stored, leftovers)
: null;
wiper.addAll(leftovers);
if (opened == null) {
return null;
}
action.material = opened;
}
return action;
}
// Done with an action: its material goes to the wiper.
function release(action as ManageAction, wiper as Jobs.Wiper) as Void {
wiper.add(action.material);
action.material = null;
action.text = null;
}
// An item's identity while it waits: its box's random IV, as hex ("-"
// for a malformed item). The menu, "Confirm all", "Reject all" and
// LockJob act on items by it, so changes that arrive meanwhile (or
// items another job removed) are never mixed up with the ones read.
function keyOf(item as Application.Storage.ValueType) as String {
var box = item instanceof ByteArray ? item : (codeOf(item) >= 0 ? (item as Array<Application.Storage.ValueType>)[1] : null);
if (!(box instanceof ByteArray) || box.size() < SealedBox.MIN_SIZE) {
return "-";
}
return Bytes.toHex(box.slice(1, 1 + SealedBox.IV_BYTES));
}
// Remove the items with these keys (and their material); the others
// keep waiting, in order. Returns how many are left.
function remove(vault as Vault, keys as Array<String>) as Number {
changed();
var stored = items(vault);
var kept = [] as Array<Application.Storage.ValueType>;
for (var i = 0; i < stored.size(); i++) {
if (keys.indexOf(keyOf(stored[i])) >= 0) {
deleteMaterial(vault, stored[i]);
} else {
kept.add(stored[i]);
}
}
if (kept.size() == stored.size()) {
return kept.size();
}
if (kept.size() == 0) {
vault.storage().deleteValue(KEY);
return 0;
}
try {
vault.storage().setValue(KEY, kept);
} catch (e instanceof Lang.Exception) {
// Can't happen when shrinking; if it did, the items stay (an
// applied one again is harmless: "already requires the PIN").
}
return kept.size();
}
// Short descriptions for the confirmation screen ("Delete: K7F2 Bank"),
// in one event: for tests (the menu runs DescribeJob).
function descriptions(vault as Vault) as Array<String> {
var job = new DescribeJob(vault);
Jobs.runToCompletion(job, 1);
return job.lines;
}
// The number of waiting changes that need "Confirm all": all but
// "Require PIN", which applies at the next PIN by itself. Cached until
// the queue changes, because the list asks at every draw.
function countToConfirm(vault as Vault) as Number {
var n = _toConfirm;
if (n == null || _toConfirmFor != vault) {
var total = count(vault);
n = total > 0 ? total - countLocks(vault) : 0;
_toConfirm = n;
_toConfirmFor = vault;
}
return n;
}
// The queue changed: count again next time.
function changed() as Void {
_toConfirm = null;
}
// The number of waiting "Require PIN" actions (applied at the next PIN),
// from the item codes alone.
function countLocks(vault as Vault) as Number {
var stored = items(vault);
var n = 0;
for (var i = 0; i < stored.size(); i++) {
if (codeOf(stored[i]) == ManageAction.LOCK) {
n++;
}
}
return n;
}
// LockJob's lines (added to those of the PIN's first job, for a legacy
// PIN's second); the queue changed.
function addLockLines(lines as Array<String>) as Void {
_lockLines.addAll(lines);
changed();
}
// The lines of the last LockJob, once (then forgotten).
function takeLockLines() as Array<String> {
var lines = _lockLines;
_lockLines = [] as Array<String>;
return lines;
}
function clear(vault as Vault) as Void {
changed();
var stored = items(vault);
for (var i = 0; i < stored.size(); i++) {
deleteMaterial(vault, stored[i]);
}
vault.storage().deleteValue(KEY);
}
function deleteMaterial(vault as Vault, item as Application.Storage.ValueType) as Void {
var ref = refOf(item);
if (ref >= 0) {
vault.storage().deleteValue(MATERIAL_KEY + ref);
}
}
// The lowest material number no stored item uses.
function freeRef(stored as Array<Application.Storage.ValueType>) as Number {
var ref = 0;
var used = true;
while (used) {
used = false;
for (var i = 0; i < stored.size(); i++) {
if (refOf(stored[i]) == ref) {
used = true;
ref++;
break;
}
}
}
return ref;
}
function itemAad(code as Number) as ByteArray {
var aad = Bytes.fromUtf8(AAD);
aad.add(code & 0xFF);
return aad;
}
function materialAad(ref as Number, id as String) as ByteArray {
return Bytes.fromUtf8(MATERIAL_AAD + "." + ref + "." + id);
}
}
// The queue's descriptions ("Delete: K7F2 Bank"), one item per step, and
// the keys of every item read (damaged ones too): "Confirm all" and
// "Reject all" act on exactly these.
class DescribeJob extends Jobs.Job {
var lines as Array<String> = [] as Array<String>;
var keys as Array<String> = [] as Array<String>;
private var _vault as Vault;
private var _items as Array<Application.Storage.ValueType>;
private var _at as Number = 0;
private var _wiper as Jobs.Wiper = new Jobs.Wiper();
function initialize(vault as Vault) {
Job.initialize();
_vault = vault;
_items = ChangeQueue.items(vault);
}
function step(budget as Number) as Boolean {
if (_wiper.left() > 0) {
// A 0.9.0 item's contents: zeroed in steps of their own.
return _wiper.step(Jobs.Wiper.STEP_BYTES) && _at >= _items.size();
}
if (_at < _items.size()) {
var item = _items[_at];
keys.add(ChangeQueue.keyOf(item));
var action = ChangeQueue.open(_vault, item, _wiper, false);
if (action != null) {
lines.add(action.describe());
ChangeQueue.release(action, _wiper);
}
_at++;
}
return _at >= _items.size() && _wiper.left() == 0;
}
}
// After a correct or new PIN (PinView, while it shows its busy state):
// promotes the pending locked entries (Vault.promoteNext, a few per step),
// then applies the waiting "Require PIN" actions, one re-seal (ManageRun,
// no code check) per item, and removes them from the queue; the other
// items keep waiting, in order, unopened. One that fails (the entry is gone
// or already locked) is dropped too, except for a full store: then it waits
// for the next PIN. The queue is written once at the end, so a stop midway
// re-applies the locks already done at the next PIN, reported as "already
// requires the PIN"; promotion saves after each entry.
class LockJob extends Jobs.Job {
// Record bytes promoted per step: one large code, or a few OTP entries
// (each is an open, a re-seal and the metadata).
static const PROMOTE_BYTES = 600;
// One line per lock applied or failed (also kept for takeLockLines).
var lines as Array<String> = [] as Array<String>;
private var _vault as Vault;
// The items when promotion ended; null before.
private var _items as Array<Application.Storage.ValueType>? = null;
// Keys of the items applied or dropped.
private var _done as Array<String> = [] as Array<String>;
private var _index as Number = 0;
private var _run as ManageRun? = null;
private var _action as ManageAction? = null;
private var _wiper as Jobs.Wiper = new Jobs.Wiper();
// first: the PIN's first job, which starts the lock lines afresh (a
// legacy PIN's second job, for the new PIN, adds to them).
function initialize(vault as Vault, first as Boolean) {
Job.initialize();
_vault = vault;
if (first) {
ChangeQueue.takeLockLines();
}
ChangeQueue.changed();
}
function step(budget as Number) as Boolean {
if (_wiper.left() > Jobs.Wiper.INLINE_BYTES) {
_wiper.step(Jobs.Wiper.STEP_BYTES);
return false;
}
_wiper.step(Jobs.Wiper.INLINE_BYTES);
var items = _items;
if (!_vault.session.isUnlocked()) {
// Locked meanwhile: what is left waits for the next PIN.
if (items != null) {
finish();
}
return true;
}
if (items == null) {
promote();
return false;
}
var run = _run;
if (run != null) {
if (!run.step(budget)) {
return false;
}
var action = _action as ManageAction;
lines.add(run.failed ? "Not applied: " + action.describe() + ". " + run.message : run.message);
if (!run.failed || !run.message.equals("storage full")) {
_done.add(ChangeQueue.keyOf(items[_index])); // a full store: it waits for the next PIN
}
ChangeQueue.release(action, _wiper);
_run = null;
_action = null;
_index++;
return false;
}
if (_index >= items.size()) {
finish();
return true;
}
var item = items[_index];
var code = ChangeQueue.codeOf(item);
// A 0.9.0 item must be opened to know what it is.
var action = code == ManageAction.LOCK || item instanceof ByteArray ? ChangeQueue.open(_vault, item, _wiper, false) : null;
if (action != null && action.action == ManageAction.LOCK) {
_action = action;
_run = new ManageRun(_vault, action, true);
return false;
}
if (action != null) {
ChangeQueue.release(action, _wiper);
}
_index++; // damaged items stay until confirmed or rejected
return false;
}
// Promote up to PROMOTE_BYTES of pending entries. A full store stops
// promotion (the rest wait for the next PIN) but not the locks.
private function promote() as Void {
var bytes = 0;
try {
while (bytes < PROMOTE_BYTES) {
var size = _vault.promoteNext(_wiper);
if (size == 0) {
_items = ChangeQueue.items(_vault);
return;
}
bytes += size > 0 ? size : 1;
}
} catch (e instanceof VaultError) {
var left = _vault.pendingIds().size();
lines.add((e.code == VaultError.FULL ? "Storage full: " : "Not promoted: ") + left
+ (left == 1 ? " locked entry still waits" : " locked entries still wait") + " for your PIN");
_items = ChangeQueue.items(_vault);
}
}
// Remove the locks just applied (and the failed ones) from the queue.
private function finish() as Void {
Diag.data("locks_applied", "count=" + lines.size()); // for UI scripts: no names
ChangeQueue.addLockLines(lines);
if (_done.size() > 0) {
ChangeQueue.remove(_vault, _done);
}
}
}
// Applies the waiting changes after the PIN was entered: per step, it opens
// one item or advances its ManageRun (each re-seals a record; a code may
// need its CodeCheck). Only the items the menu showed (their keys) are
// applied; changes that arrived meanwhile keep waiting. 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 _keys as Array<String>?;
private var _items as Array<Application.Storage.ValueType> = [] as Array<Application.Storage.ValueType>;
private var _done as Array<String> = [] as Array<String>;
private var _index as Number = 0;
private var _action as ManageAction? = null;
private var _run as ManageRun? = null;
private var _wiper as Jobs.Wiper = new Jobs.Wiper();
// keys: the items to apply (DescribeJob.keys), or null for all.
function initialize(vault as Vault, keys as Array<String>?) {
Job.initialize();
_vault = vault;
_keys = keys;
var stored = ChangeQueue.items(vault);
for (var i = 0; i < stored.size(); i++) {
if (keys == null || keys.indexOf(ChangeQueue.keyOf(stored[i])) >= 0) {
_items.add(stored[i]);
}
}
// "Require PIN" changes the PIN just applied (LockJob).
lines.addAll(ChangeQueue.takeLockLines());
}
function step(budget as Number) as Boolean {
if (_wiper.left() > Jobs.Wiper.INLINE_BYTES) {
_wiper.step(Jobs.Wiper.STEP_BYTES);
return false;
}
_wiper.step(Jobs.Wiper.INLINE_BYTES);
var run = _run;
if (run == null) {
if (_index >= _items.size()) {
var left = ChangeQueue.remove(_vault, _done);
if (_keys != null && left > 0) {
lines.add(left + (left == 1 ? " change" : " changes") + " arrived meanwhile and still "
+ (left == 1 ? "waits" : "wait") + " (menu, Phone changes)");
}
if (lines.size() == 0) {
lines.add("No changes were waiting.");
}
return true;
}
_done.add(ChangeQueue.keyOf(_items[_index]));
var action = ChangeQueue.open(_vault, _items[_index], _wiper, true);
if (action == null) {
lines.add("Not applied: a change was damaged");
_index++;
return false;
}
_action = action;
_run = new ManageRun(_vault, action, true);
return false;
}
if (run.step(budget)) {
var action = _action as ManageAction;
var problem = run.failed ? "Not applied: " + action.describe() + ". " : "";
lines.add(problem + run.message);
lines.addAll(run.notes);
ChangeQueue.release(action, _wiper);
_action = null;
_index++;
_run = null;
}
return false;
}
}
|