// 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 = [] as Array; 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 : [] as Array; 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 { var out = [] as Array; 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 { var actions = load(vault); var out = [] as Array; 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; 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; 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 { var lines = _lockLines; _lockLines = [] as Array; 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); } 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 = [] as Array; private var _vault as Vault; private var _actions as Array; 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; } }