summaryrefslogtreecommitdiff
path: root/source/settings
diff options
context:
space:
mode:
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);