aboutsummaryrefslogtreecommitdiff
path: root/source/settings
diff options
context:
space:
mode:
authorcirakg <cirakg@cirakg.xyz>2026-10-27 01:01:18 +0100
committercirakg <cirakg@cirakg.xyz>2026-10-27 01:01:18 +0100
commitbc4cc19df5f53519ca1aabd3cf161b6d78ffc16c (patch)
tree3242a63f603882d723bd0d718d043c30a9f11784 /source/settings
parent34daecaa25e5bdd995179a2b4b9d922f8bbb3b54 (diff)
fix(pin): the work after a PIN and the change queue run in steps
After a correct or new PIN, promoting pending entries and applying waiting "Require PIN" changes ran in the event of the last PBKDF2 step; 16 waiting changes tripped the watchdog on the Forerunner 70. ChangeQueue.LockJob now does it in steps while the PIN screen shows "Checking...", saves what it did to the results and refreshes the glance after Set PIN too. The list title decrypted every waiting change at each start. Queue items are now [action, box] (aad OTPW-Q-2 + action code), so they are counted without decrypting; 0.9.0 items are still read. A replacement is checked when it arrives and its material is sealed apart (qm.<n>). Large buffers are wiped and UTF-8-checked in pieces (Jobs.Wiper, Bytes.utf8Prefix, SealedBox.openKeep/sealKeep). "Phone changes" reads behind "Reading...", and Confirm all / Reject all act only on the changes shown. Storage full no longer stops the app at the PIN. Found by the review of b8addc8/096b5a4 and a new make watchdog test; the diff was reviewed again and its findings fixed. fr70: make lint, make test (176), make watchdog (19 cases at two steps per event), make screenshots pass; list_locked and list_reset goldens show "Results ready (menu)". Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Diffstat (limited to 'source/settings')
-rw-r--r--source/settings/ChangeQueue.mc553
-rw-r--r--source/settings/CodeForm.mc10
-rw-r--r--source/settings/ManageForm.mc265
-rw-r--r--source/settings/Results.mc13
4 files changed, 667 insertions, 174 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;
}
diff --git a/source/settings/CodeForm.mc b/source/settings/CodeForm.mc
index 810cd6e..0c3470c 100644
--- a/source/settings/CodeForm.mc
+++ b/source/settings/CodeForm.mc
@@ -89,8 +89,14 @@ module CodeForm {
// The contents as stored (trimmed for linear codes, whose digits are
// often typed with spaces around), or the problem.
function contents(text as String, symbology as Number) as ByteArray or String {
- var value = Codes.isLinear(symbology) ? Text.trim(text) : text;
- if (Text.trim(value).length() == 0) {
+ return contentsOf(text, Text.trim(text), symbology);
+ }
+
+ // As contents(), with the text already trimmed (ManageRun trims
+ // kilobytes in steps).
+ function contentsOf(text as String, trimmed as String, symbology as Number) as ByteArray or String {
+ var value = Codes.isLinear(symbology) ? trimmed : text;
+ if (trimmed.length() == 0) {
return "Contents: missing";
}
var bytes = Bytes.fromUtf8(value);
diff --git a/source/settings/ManageForm.mc b/source/settings/ManageForm.mc
index 70d707f..ba2d45f 100644
--- a/source/settings/ManageForm.mc
+++ b/source/settings/ManageForm.mc
@@ -14,7 +14,11 @@
// move twice can't hit another entry.
// Replacing a code's contents runs CodeCheck with the entry's own options
// (a sub-job, so a large 2D code can't trip the watchdog). For a locked
-// entry the options are encrypted, so that check runs when the PIN confirms.
+// entry the options are encrypted, so that check runs when the PIN confirms;
+// everything else about a REPLACE (Base32, the contents' size) is checked
+// before it is queued. With 4 KB of contents every byte loop (UTF-8 check,
+// opening and re-sealing the record, wiping) gets a step of its own, and
+// the wipes at the end run in pieces (Jobs.Wiper).
import Toybox.Lang;
@@ -46,6 +50,7 @@ class ManageAction {
static const T_COUNTER = 8;
static const T_LABEL = 9;
static const T_DECODED = 10;
+ static const T_REF = 11;
// In "Edit details", this value clears a field; empty keeps it.
static const CLEAR = "-";
@@ -57,10 +62,18 @@ class ManageAction {
var group as String = "";
var position as Number = 0;
// REPLACE: the decoded OTP secret or the new code contents, once
- // checked against the entry (raw text before). Wiped after use.
+ // checked against the entry (a 0.9.0 queue item may hold the raw text
+ // of a code's contents instead). Wiped after use. Queued, it is a
+ // storage value of its own (ChangeQueue, `ref`).
var material as ByteArray? = null;
+ // REPLACE from the form: the raw field text until ManageRun checks it.
+ // Never queued.
+ var text as String? = null;
+ // A queued REPLACE: the number of its material record, -1 for none.
+ var ref as Number = -1;
var counter as Long = 0l;
- // True once an OTP secret in `material` is decoded from Base32.
+ // True once `material` holds the checked secret (decoded from Base32)
+ // or contents (CodeForm.contents), not raw text.
var decoded as Boolean = false;
// "Bank (carol)", kept so the queue can be shown while locked.
var label as String = "";
@@ -84,6 +97,7 @@ class ManageAction {
function wipe() as Void {
Bytes.wipe(material);
material = null;
+ text = null;
}
function toBytes() as ByteArray {
@@ -101,6 +115,9 @@ class ManageAction {
w.addInt(T_COUNTER, counter);
w.addText(T_LABEL, label);
w.addBool(T_DECODED, decoded);
+ if (ref >= 0) {
+ w.addInt(T_REF, ref);
+ }
return w.bytes;
}
@@ -133,6 +150,8 @@ class ManageAction {
a.material = m instanceof ByteArray ? m : null;
var decoded = map[T_DECODED];
a.decoded = decoded instanceof Boolean && decoded;
+ var ref = map[T_REF];
+ a.ref = ref instanceof Long && ref >= 0 && ref < 0x10000 ? ref.toNumber() : -1;
return a;
}
@@ -205,10 +224,10 @@ module ManageForm {
a.position = position;
} else if (action == ManageAction.REPLACE) {
var secret = Forms.rawText(store, SECRET);
- if (Text.trim(secret).length() == 0) {
- return "Replace: enter the new secret or contents";
+ if (secret.length() == 0) {
+ return "Replace: enter the new secret or contents"; // only spaces: ManageRun says so, in steps
}
- a.material = Bytes.fromUtf8(secret);
+ a.text = secret; // checked against the entry by ManageRun
} else if (action == ManageAction.COUNTER) {
var counter = Forms.long(store, "mgCounter");
if (counter == null || counter < 0) {
@@ -249,6 +268,35 @@ class ManageRun extends Jobs.Job {
private var _header as Entry? = null;
private var _check as CodeCheck? = null;
private var _cache as ByteArray? = null;
+ // Buffers to wipe once the run ends; large ones take steps of their own.
+ private var _wiper as Jobs.Wiper = new Jobs.Wiper();
+ private var _wiping as Boolean = false;
+ // A code REPLACE: CodeCheck is still to start, with this payload text
+ // (null: from the queue, still to be read from `material`).
+ private var _needsCheck as Boolean = false;
+ private var _payload as String? = null;
+ // How far queued contents are checked as UTF-8.
+ private var _utf8At as Number = 0;
+ // A REPLACE's raw text, prepared in steps before it is checked
+ // (prepareText): a 0.9.0 item's raw bytes checked as UTF-8, then the
+ // spaces around it found, in pieces (kilobytes of either are a byte or
+ // character loop).
+ private var _rawAt as Number = 0;
+ private var _chars as Array<Char>? = null;
+ private var _trimStart as Number = 0;
+ private var _trimEnd as Number = -1;
+ private var _trimmed as String? = null;
+ // A REPLACE: the entry opened in the step before it is re-sealed.
+ private var _entry as Entry? = null;
+
+ // Raw OTP secrets longer than this are refused before Base32.decode's
+ // character loop (128 bytes are 205 Base32 characters; this leaves room
+ // for spaces).
+ static const MAX_SECRET_TEXT = 1024;
+ // Contents checked as UTF-8 per step (up to 30 bytecodes a byte).
+ static const UTF8_STEP_BYTES = 1024;
+ // Characters looked at per step when trimming the raw text.
+ static const TRIM_STEP_CHARS = 512;
// confirmed: the PIN was just entered for this action (from the queue),
// so it is applied, never queued again.
@@ -260,6 +308,9 @@ class ManageRun extends Jobs.Job {
}
function step(budget as Number) as Boolean {
+ if (_wiping) {
+ return _wiper.step(Jobs.Wiper.STEP_BYTES);
+ }
var check = _check;
if (check != null) {
if (!check.step(budget)) {
@@ -277,12 +328,22 @@ class ManageRun extends Jobs.Job {
return false;
}
if (_header == null) {
+ if (_a.action == ManageAction.REPLACE && !_a.decoded && _trimmed == null) {
+ return prepareText();
+ }
try {
return resolve();
} catch (e instanceof VaultError) {
return fail(problemText(e));
}
}
+ if (_needsCheck) {
+ try {
+ return prepareCheck();
+ } catch (e instanceof VaultError) {
+ return fail(problemText(e));
+ }
+ }
return finish(_cache);
}
@@ -317,7 +378,8 @@ class ManageRun extends Jobs.Job {
return queue();
}
if (a.action == ManageAction.REPLACE && header.kind == Entry.KIND_CODE) {
- return startCodeCheck(header);
+ _needsCheck = true; // CodeCheck with the entry's options (prepareCheck)
+ return false;
}
return false;
}
@@ -344,12 +406,26 @@ class ManageRun extends Jobs.Job {
if (a.action == ManageAction.UNFAVORITE && !header.favorite) {
return a.id + " is not a favourite";
}
- if (a.action == ManageAction.REPLACE && isOtp && !a.decoded) {
- var raw = a.material as ByteArray;
- var text = Bytes.toUtf8(raw);
- Bytes.wipe(raw);
- a.material = null;
- var decoded = OtpForm.decodeSecret(text != null ? text : "?");
+ if (a.action == ManageAction.REPLACE && !a.decoded) {
+ // Checked before queueing, so a locked entry's queue item holds
+ // only the decoded secret or the checked contents.
+ var text = a.text as String;
+ var trimmed = _trimmed as String;
+ a.text = null;
+ if (!isOtp) {
+ var contents = CodeForm.contentsOf(text, trimmed, header.type);
+ if (contents instanceof String) {
+ return contents;
+ }
+ a.material = contents;
+ a.decoded = true;
+ _payload = Codes.isLinear(header.type) ? trimmed : text;
+ return null;
+ }
+ if (text.length() > MAX_SECRET_TEXT) {
+ return "Secret: too long";
+ }
+ var decoded = OtpForm.decodeSecret(trimmed); // trimmed: no character loop there
if (decoded instanceof String) {
return decoded;
}
@@ -379,45 +455,105 @@ class ManageRun extends Jobs.Job {
private function queue() as Boolean {
try {
- ChangeQueue.add(_vault, _a);
+ ChangeQueue.addWith(_vault, _a, _wiper);
} catch (e instanceof VaultError) {
return fail(e.code == VaultError.LIMIT ? e.getErrorMessage() as String : "storage full");
}
queued = true;
message = (_a.action == ManageAction.LOCK ? "Locks when you next enter your PIN on the watch: "
: "Waiting for your PIN on the watch: ") + _a.describe();
- _a.wipe();
- return true;
+ wipeAction();
+ return ending();
}
- // Replacing a code's contents: validate them with the entry's options.
- private function startCodeCheck(header as Entry) as Boolean {
+ // One step of preparing a REPLACE's raw text: a 0.9.0 item's raw bytes
+ // become text (checked as UTF-8 in pieces); text longer than any valid
+ // input is refused; then the spaces around it are found in pieces.
+ private function prepareText() as Boolean {
var a = _a;
- var raw = a.material as ByteArray;
- var text = Bytes.toUtf8(raw);
- Bytes.wipe(raw);
- a.material = null;
- var contents = CodeForm.contents(text != null ? text : "", header.type);
- if (contents instanceof String) {
- return fail(contents);
- }
- a.material = contents;
- var options = Codes.DEFAULT_OPTIONS;
- try {
- var entry = _vault.open(a.id);
- options = entry.options;
- entry.wipeSecrets();
- } catch (e instanceof VaultError) {
- return fail(problemText(e));
+ var text = a.text;
+ if (text == null) {
+ var raw = a.material;
+ if (raw == null) {
+ return fail("Replace: enter the new secret or contents");
+ }
+ if (_rawAt < raw.size()) {
+ var end = _rawAt + UTF8_STEP_BYTES < raw.size() ? _rawAt + UTF8_STEP_BYTES : raw.size();
+ _rawAt = Bytes.utf8Prefix(raw, _rawAt, end);
+ return _rawAt < 0 ? fail("Replace: the new secret or contents are damaged") : false;
+ }
+ a.text = Bytes.toUtf8Checked(raw);
+ a.material = null;
+ _wiper.add(raw);
+ return false;
+ }
+ var chars = _chars;
+ if (chars == null) {
+ if (text.length() > Entry.MAX_PAYLOAD_BYTES) {
+ return fail("Replace: too long (" + text.length() + " characters, at most " + Entry.MAX_PAYLOAD_BYTES + ")");
+ }
+ chars = text.toCharArray();
+ _chars = chars;
+ _trimEnd = chars.size();
+ return false;
+ }
+ var budget = TRIM_STEP_CHARS;
+ while (budget > 0 && _trimStart < _trimEnd && Text.isSpace(chars[_trimStart])) {
+ _trimStart++;
+ budget--;
+ }
+ if (budget == 0) {
+ return false;
}
- var payload = Bytes.toUtf8(contents);
- _check = new CodeCheck(header.type, payload != null ? payload : "", options);
+ while (budget > 0 && _trimEnd > _trimStart && Text.isSpace(chars[_trimEnd - 1])) {
+ _trimEnd--;
+ budget--;
+ }
+ if (budget == 0) {
+ return false;
+ }
+ _trimmed = text.substring(_trimStart, _trimEnd);
+ _chars = null;
+ return false;
+ }
+
+ // One step towards CodeCheck: queued contents are read as text first
+ // (the UTF-8 check is a byte loop, done in pieces), then the entry is opened for its
+ // options.
+ private function prepareCheck() as Boolean {
+ var a = _a;
+ var payload = _payload;
+ if (payload == null) {
+ var m = a.material as ByteArray;
+ if (_utf8At < m.size()) {
+ var end = _utf8At + UTF8_STEP_BYTES < m.size() ? _utf8At + UTF8_STEP_BYTES : m.size();
+ _utf8At = Bytes.utf8Prefix(m, _utf8At, end);
+ return _utf8At < 0 ? fail("the new contents are damaged") : false;
+ }
+ _payload = Bytes.toUtf8Checked(m);
+ return false;
+ }
+ var entry = _vault.openWith(a.id, _wiper);
+ var options = entry.options;
+ wipeLater(entry);
+ _needsCheck = false;
+ _payload = null;
+ _check = new CodeCheck((_header as Entry).type, payload, options);
return false;
}
// Apply the action; cache: a checked code's packed matrix, or null.
private function finish(cache as ByteArray?) as Boolean {
var a = _a;
+ if (a.action == ManageAction.REPLACE && _entry == null) {
+ // Opening and re-sealing a large code are a step each.
+ try {
+ _entry = _vault.openWith(a.id, _wiper);
+ } catch (e instanceof VaultError) {
+ return fail(problemText(e));
+ }
+ return false;
+ }
var target = a.id + (a.label.length() > 0 ? " " + a.label : "");
try {
if (a.action == ManageAction.MOVE) {
@@ -435,15 +571,14 @@ class ManageRun extends Jobs.Job {
return fail(problem);
}
if (cache != null) {
- _vault.saveCodeCache(a.id, cache);
+ _vault.saveCodeCacheWith(a.id, cache, _wiper);
}
}
} catch (e instanceof VaultError) {
- a.wipe();
return fail(problemText(e));
}
- a.wipe();
- return true;
+ wipeAction();
+ return ending();
}
// Edit, lock, unlock, (un)favourite or replace: open, change, re-seal.
@@ -451,13 +586,15 @@ class ManageRun extends Jobs.Job {
private function changeEntry() as String? {
var a = _a;
var target = a.id + (a.label.length() > 0 ? " " + a.label : "");
- var entry = _vault.open(a.id);
+ var opened = _entry;
+ _entry = null;
+ var entry = opened != null ? opened : _vault.openWith(a.id, _wiper);
if (a.action == ManageAction.EDIT) {
entry.name = edited(entry.name, a.name);
entry.account = edited(entry.account, a.account);
entry.group = edited(entry.group, a.group);
if (entry.name.length() == 0 && (entry.kind == Entry.KIND_CODE || entry.account.length() == 0)) {
- entry.wipeSecrets();
+ wipeLater(entry);
return entry.kind == Entry.KIND_CODE ? "Name: a barcode needs a name" : "An OTP entry needs an issuer or an account";
}
message = "Edited " + a.id + ": " + Forms.label(entry.name, entry.account)
@@ -471,22 +608,22 @@ class ManageRun extends Jobs.Job {
} else {
var material = a.material as ByteArray;
if (entry.kind == Entry.KIND_OTP) {
- Bytes.wipe(entry.secret);
+ _wiper.add(entry.secret);
entry.secret = Bytes.copy(material);
message = "Replaced the secret of " + target;
} else {
- Bytes.wipe(entry.payload);
+ _wiper.add(entry.payload);
entry.payload = Bytes.copy(material);
message = "Replaced the contents of " + target;
}
}
try {
- _vault.update(entry);
+ _vault.updateWith(entry, _wiper);
} catch (e instanceof VaultError) {
- entry.wipeSecrets();
+ wipeLater(entry);
throw e;
}
- entry.wipeSecrets();
+ wipeLater(entry);
return null;
}
@@ -501,8 +638,38 @@ class ManageRun extends Jobs.Job {
private function fail(problem as String) as Boolean {
failed = true;
message = problem;
- _a.wipe();
- return true;
+ var entry = _entry;
+ if (entry != null) {
+ wipeLater(entry);
+ _entry = null;
+ }
+ wipeAction();
+ return ending();
+ }
+
+ // The action's material goes to the wiper.
+ private function wipeAction() as Void {
+ _wiper.add(_a.material);
+ _a.material = null;
+ _a.text = null;
+ }
+
+ private function wipeLater(entry as Entry) as Void {
+ _wiper.add(entry.secret);
+ _wiper.add(entry.payload);
+ entry.secret = null;
+ entry.payload = null;
+ }
+
+ // The run is over once its buffers are wiped: small ones now, large
+ // ones in the next steps.
+ private function ending() as Boolean {
+ if (_wiper.left() <= Jobs.Wiper.INLINE_BYTES) {
+ _wiper.step(Jobs.Wiper.INLINE_BYTES);
+ return true;
+ }
+ _wiping = true;
+ return false;
}
static function problemText(e as VaultError) as String {
diff --git a/source/settings/Results.mc b/source/settings/Results.mc
index 37c932f..f5800a5 100644
--- a/source/settings/Results.mc
+++ b/source/settings/Results.mc
@@ -15,6 +15,19 @@ module Results {
Application.Storage.setValue(KEY, {"l" => kept, "u" => true} as Dictionary<Application.Storage.KeyType, Application.Storage.ValueType>);
}
+ // Add lines to results not seen yet (an import's, say), or replace seen
+ // ones (marked unseen).
+ function add(lines as Array<String>) as Void {
+ if (!unseen()) {
+ save(lines);
+ return;
+ }
+ var all = [] as Array<String>;
+ all.addAll(Results.lines());
+ all.addAll(lines);
+ save(all);
+ }
+
// The stored lines, or a short explanation when there are none.
function lines() as Array<String> {
var stored = Application.Storage.getValue(KEY);