// 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.", 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 = [] as Array; // 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 { var stored = vault.storage().getValue(KEY); return stored instanceof Array ? stored as Array : [] as Array; } 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; 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; 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; 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)[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)[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) as Number { changed(); var stored = items(vault); var kept = [] as Array; 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 { 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) as Void { _lockLines.addAll(lines); changed(); } // The lines of the last LockJob, once (then forgotten). function takeLockLines() as Array { var lines = _lockLines; _lockLines = [] as Array; 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) 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 = [] as Array; var keys as Array = [] as Array; private var _vault as Vault; private var _items as Array; 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 = [] as Array; private var _vault as Vault; // The items when promotion ended; null before. private var _items as Array? = null; // Keys of the items applied or dropped. private var _done as Array = [] as Array; 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 = [] as Array; private var _vault as Vault; private var _keys as Array?; private var _items as Array = [] as Array; private var _done as Array = [] as Array; 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?) { 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; } }