diff options
Diffstat (limited to 'source/settings/ChangeQueue.mc')
| -rw-r--r-- | source/settings/ChangeQueue.mc | 553 |
1 files changed, 430 insertions, 123 deletions
diff --git a/source/settings/ChangeQueue.mc b/source/settings/ChangeQueue.mc index 5de74e0..dc6facd 100644 --- a/source/settings/ChangeQueue.mc +++ b/source/settings/ChangeQueue.mc @@ -4,15 +4,25 @@ // 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). +// 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 applyLocks() applies it at the -// next correct (or newly set) PIN, without "Confirm all", the way pending -// entries are promoted. +// 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; @@ -21,78 +31,202 @@ module ChangeQueue { const KEY = "q"; const MAX_ITEMS = 16; - const AAD = "OTPW-Q-1"; + 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 applyLocks() did at the last PIN, for the results of a "Confirm - // all" that follows it (ChangeQueueJob). Replaced at every PIN. + // 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; - function count(vault as Vault) as Number { + // 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.size() : 0; + 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. Throws VaultError LIMIT (too many waiting) or FULL. + // 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 { - 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) { + 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(); - items.add(SealedBox.seal(vault.keyManager.deviceKeys(), Bytes.fromUtf8(AAD), plain)); + 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, items); + 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 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; + // 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 aad = Bytes.fromUtf8(AAD); + 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++) { - 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); - } + if (keys.indexOf(keyOf(stored[i])) >= 0) { + deleteMaterial(vault, stored[i]); + } else { + kept.add(stored[i]); } } - return out; + 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"). + // 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 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; + 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. For the list's - // title and the menu; cached until the queue changes, because the list - // asks at every draw and counting opens every item. + // "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) { @@ -109,67 +243,27 @@ module ChangeQueue { _toConfirm = null; } - // The number of waiting "Require PIN" actions (applied at the next PIN). + // 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 actions = load(vault); + var stored = items(vault); var n = 0; - for (var i = 0; i < actions.size(); i++) { - if (actions[i].action == ManageAction.LOCK) { + for (var i = 0; i < stored.size(); i++) { + if (codeOf(stored[i]) == 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>; + // 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(); - 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() - } - } - Diag.data("locks_applied", "count=" + _lockLines.size()); // for UI scripts: no names - 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). + // The lines of the last LockJob, once (then forgotten). function takeLockLines() as Array<String> { var lines = _lockLines; _lockLines = [] as Array<String>; @@ -178,68 +272,281 @@ module ChangeQueue { 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); } - // 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 { - changed(); - var stored = vault.storage().getValue(KEY); - if (!(stored instanceof Array) || stored.size() <= n) { - clear(vault); - return; + 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 { - 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 + 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 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; +// 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 _actions as Array<ManageAction>; - private var _loaded as Number = 0; + 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(); - function initialize(vault as Vault) { + // keys: the items to apply (DescribeJob.keys), or null for all. + function initialize(vault as Vault, keys as Array<String>?) { 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). + _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 >= _actions.size()) { - ChangeQueue.drop(_vault, _loaded); + 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; } - run = new ManageRun(_vault, _actions[_index], true); - _run = run; + _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 problem = run.failed ? "Not applied: " + _actions[_index].describe() + ". " : ""; + var action = _action as ManageAction; + var problem = run.failed ? "Not applied: " + action.describe() + ". " : ""; lines.add(problem + run.message); lines.addAll(run.notes); - _actions[_index].wipe(); + ChangeQueue.release(action, _wiper); + _action = null; _index++; _run = null; } |
