aboutsummaryrefslogtreecommitdiff
path: root/source
diff options
context:
space:
mode:
Diffstat (limited to 'source')
-rw-r--r--source/crypto/Aes.mc23
-rw-r--r--source/crypto/SealedBox.mc21
-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
-rw-r--r--source/storage/KeyManager.mc24
-rw-r--r--source/storage/Record.mc33
-rw-r--r--source/storage/Vault.mc91
-rw-r--r--source/ui/MainMenu.mc2
-rw-r--r--source/ui/PinView.mc62
-rw-r--r--source/ui/QueueMenu.mc78
-rw-r--r--source/util/Bytes.mc33
-rw-r--r--source/util/Jobs.mc63
-rw-r--r--source/util/StressTest.mc410
15 files changed, 1424 insertions, 257 deletions
diff --git a/source/crypto/Aes.mc b/source/crypto/Aes.mc
index 242448b..9e4f320 100644
--- a/source/crypto/Aes.mc
+++ b/source/crypto/Aes.mc
@@ -42,19 +42,34 @@ module Aes {
// Encrypt with PKCS#7 padding: 1 to 16 bytes, each holding the pad length.
function cbcEncrypt(key as ByteArray, iv as ByteArray, plaintext as ByteArray) as ByteArray {
+ return cbcEncryptKeep(key, iv, plaintext, null);
+ }
+
+ // As cbcEncrypt(); with `leftovers`, the padded copy is added there for
+ // the caller to wipe (in steps, for kilobytes) instead of wiped here.
+ function cbcEncryptKeep(key as ByteArray, iv as ByteArray, plaintext as ByteArray, leftovers as Array<ByteArray>?) as ByteArray {
var padLength = BLOCK_BYTES - (plaintext.size() % BLOCK_BYTES);
var padded = Bytes.copy(plaintext);
for (var i = 0; i < padLength; i++) {
padded.add(padLength);
}
var out = cbcRaw(key, iv, padded, true);
- Bytes.wipe(padded);
+ if (leftovers != null) {
+ leftovers.add(padded);
+ } else {
+ Bytes.wipe(padded);
+ }
return out;
}
// Decrypt and remove PKCS#7 padding; null when the length or the padding is
// wrong.
function cbcDecrypt(key as ByteArray, iv as ByteArray, ciphertext as ByteArray) as ByteArray? {
+ return cbcDecryptKeep(key, iv, ciphertext, null);
+ }
+
+ // As cbcDecrypt(); with `leftovers`, the padded plaintext is added there.
+ function cbcDecryptKeep(key as ByteArray, iv as ByteArray, ciphertext as ByteArray, leftovers as Array<ByteArray>?) as ByteArray? {
var size = ciphertext.size();
if (size == 0 || size % BLOCK_BYTES != 0) {
return null;
@@ -68,7 +83,11 @@ module Aes {
}
}
var plaintext = valid ? padded.slice(0, size - padLength) : null;
- Bytes.wipe(padded);
+ if (leftovers != null) {
+ leftovers.add(padded);
+ } else {
+ Bytes.wipe(padded);
+ }
return plaintext;
}
}
diff --git a/source/crypto/SealedBox.mc b/source/crypto/SealedBox.mc
index 765a1d8..3877e1a 100644
--- a/source/crypto/SealedBox.mc
+++ b/source/crypto/SealedBox.mc
@@ -55,7 +55,19 @@ module SealedBox {
// Seal with a given IV. Only tests call this directly (fixed IVs make the
// output reproducible); reusing an IV in real use would be a mistake.
function sealWithIv(keys as Keys, aad as ByteArray, plaintext as ByteArray, iv as ByteArray) as ByteArray {
- var ciphertext = Aes.cbcEncrypt(keys.encKey, iv, plaintext);
+ return sealKeepWithIv(keys, aad, plaintext, iv, null);
+ }
+
+ // As seal(); with `leftovers`, the padded plaintext copy is added there
+ // for the caller to wipe in steps (zeroing 4 KB is a large part of a
+ // watchdog step) instead of wiped here.
+ function sealKeep(keys as Keys, aad as ByteArray, plaintext as ByteArray, leftovers as Array<ByteArray>?) as ByteArray {
+ return sealKeepWithIv(keys, aad, plaintext, Cryptography.randomBytes(IV_BYTES), leftovers);
+ }
+
+ function sealKeepWithIv(keys as Keys, aad as ByteArray, plaintext as ByteArray, iv as ByteArray,
+ leftovers as Array<ByteArray>?) as ByteArray {
+ var ciphertext = Aes.cbcEncryptKeep(keys.encKey, iv, plaintext, leftovers);
var box = [VERSION]b;
box.addAll(iv);
box.addAll(ciphertext);
@@ -66,6 +78,11 @@ module SealedBox {
// The plaintext, or null if the box is malformed, was sealed with another
// key or aad, or was changed in any way.
function open(keys as Keys, aad as ByteArray, box as ByteArray) as ByteArray? {
+ return openKeep(keys, aad, box, null);
+ }
+
+ // As open(); with `leftovers`, the padded plaintext is added there.
+ function openKeep(keys as Keys, aad as ByteArray, box as ByteArray, leftovers as Array<ByteArray>?) as ByteArray? {
var size = box.size();
if (size < MIN_SIZE || (size - 1 - IV_BYTES - TAG_BYTES) % Aes.BLOCK_BYTES != 0 || box[0] != VERSION) {
return null;
@@ -77,7 +94,7 @@ module SealedBox {
if (!valid) {
return null;
}
- return Aes.cbcDecrypt(keys.encKey, box.slice(1, 1 + IV_BYTES), box.slice(1 + IV_BYTES, size - TAG_BYTES));
+ return Aes.cbcDecryptKeep(keys.encKey, box.slice(1, 1 + IV_BYTES), box.slice(1 + IV_BYTES, size - TAG_BYTES), leftovers);
}
// HMAC over the label, the aad (with its length) and the box body.
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);
diff --git a/source/storage/KeyManager.mc b/source/storage/KeyManager.mc
index 2dd3712..ae2c5e8 100644
--- a/source/storage/KeyManager.mc
+++ b/source/storage/KeyManager.mc
@@ -81,17 +81,23 @@ class KeyManager {
// entries while the watch is locked (docs/SECURITY.md).
function fingerprint(entry as Entry) as ByteArray {
deviceKeys();
- var input = Bytes.fromUtf8("OTPW-FP-1");
- input.add(entry.kind);
- input.add(entry.type);
+ // The secret or contents go to the native HMAC as they are: a copy
+ // would need a byte-loop wipe (4 KB of contents, the watchdog).
+ var mac = new Cryptography.HashBasedMessageAuthenticationCode({
+ :algorithm => Cryptography.HASH_SHA256,
+ :key => _fingerprintKey as ByteArray
+ });
+ var prefix = Bytes.fromUtf8("OTPW-FP-1");
+ prefix.add(entry.kind);
+ prefix.add(entry.type);
+ mac.update(prefix);
var material = entry.kind == Entry.KIND_OTP ? entry.secret : entry.payload;
- if (material != null) {
- input.addAll(material);
+ if (material != null && material.size() > 0) {
+ mac.update(material);
}
- var mac = Hmac.nativeSha256(_fingerprintKey as ByteArray, input);
- Bytes.wipe(input);
- var out = mac.slice(0, FINGERPRINT_BYTES);
- Bytes.wipe(mac);
+ var digest = mac.digest();
+ var out = digest.slice(0, FINGERPRINT_BYTES);
+ Bytes.wipe(digest);
return out;
}
diff --git a/source/storage/Record.mc b/source/storage/Record.mc
index b26a4ba..2cfc046 100644
--- a/source/storage/Record.mc
+++ b/source/storage/Record.mc
@@ -63,12 +63,24 @@ module Record {
// Seal an entry into record bytes. keyRef is KEY_DEVICE or KEY_MASTER and
// must match `keys`. The plaintext TLV is wiped before returning.
function seal(entry as Entry, keyRef as Number, keys as SealedBox.Keys) as ByteArray {
+ return sealWith(entry, keyRef, keys, null);
+ }
+
+ // As seal(), but the plaintext TLV goes to `wiper` when given: wiping a
+ // large code's contents is a step of its own (the watchdog).
+ function sealWith(entry as Entry, keyRef as Number, keys as SealedBox.Keys, wiper as Jobs.Wiper?) as ByteArray {
var header = encodeHeader(entry);
var record = [VERSION, keyRef, (header.size() >> 8) & 0xFF, header.size() & 0xFF]b;
record.addAll(header);
var plain = encodeSecret(entry);
- var box = SealedBox.seal(keys, aad(entry.id, record), plain);
- Bytes.wipe(plain);
+ var leftovers = wiper != null ? [] as Array<ByteArray> : null;
+ var box = SealedBox.sealKeep(keys, aad(entry.id, record), plain, leftovers);
+ if (wiper != null) {
+ wiper.add(plain);
+ wiper.addAll(leftovers as Array<ByteArray>);
+ } else {
+ Bytes.wipe(plain);
+ }
record.addAll(box);
return record;
}
@@ -119,17 +131,30 @@ module Record {
// keys, stored under another ID, or changed in any way. The decrypted
// TLV is wiped; the caller must call entry.wipeSecrets() when done.
function open(record as ByteArray, id as String, keys as SealedBox.Keys) as Entry? {
+ return openWith(record, id, keys, null);
+ }
+
+ // As open(), but the decrypted TLV goes to `wiper` when given.
+ function openWith(record as ByteArray, id as String, keys as SealedBox.Keys, wiper as Jobs.Wiper?) as Entry? {
var entry = decodeHeader(record);
var end = headerEnd(record);
if (entry == null || end == null || !entry.id.equals(id)) {
return null;
}
- var plain = SealedBox.open(keys, aad(id, record.slice(0, end)), record.slice(end, record.size()));
+ var leftovers = wiper != null ? [] as Array<ByteArray> : null;
+ var plain = SealedBox.openKeep(keys, aad(id, record.slice(0, end)), record.slice(end, record.size()), leftovers);
+ if (wiper != null) {
+ wiper.addAll(leftovers as Array<ByteArray>);
+ }
if (plain == null) {
return null;
}
var map = Codec.decode(plain);
- Bytes.wipe(plain);
+ if (wiper != null) {
+ wiper.add(plain);
+ } else {
+ Bytes.wipe(plain);
+ }
if (map == null) {
return null;
}
diff --git a/source/storage/Vault.mc b/source/storage/Vault.mc
index 2890477..ef240e0 100644
--- a/source/storage/Vault.mc
+++ b/source/storage/Vault.mc
@@ -7,7 +7,7 @@
// header says locked; anything else is reported as damaged.
// * A locked entry added while the session is locked (or before a PIN
// exists) is sealed with the device key under "p.<id>" and hidden until
-// promotePending() moves it under the master key (README 7.7).
+// promotePending() / promoteNext() moves it under the master key (README 7.7).
// * Writes go record first, metadata second, so a crash between the two
// leaves at most an unreferenced record, never an index entry without
// its record. Deletes go the other way round.
@@ -108,12 +108,18 @@ class Vault {
// The full entry with its secret. Throws NOT_FOUND, LOCKED (locked entry
// while the session is locked) or DAMAGED. Call wipeSecrets() when done.
function open(id as String) as Entry {
+ return openWith(id, null);
+ }
+
+ // As open(), but the decrypted record TLV goes to `wiper` when given
+ // (Record.openWith): for a large code, wiping is a step of its own.
+ function openWith(id as String, wiper as Jobs.Wiper?) as Entry {
var record = recordFor(id);
var header = record != null ? Record.decodeHeader(record) : null;
if (record == null || header == null || !consistent(header, record)) {
throw new VaultError(VaultError.DAMAGED, "damaged entry " + id);
}
- var entry = Record.open(record, id, keysFor(header.locked));
+ var entry = Record.openWith(record, id, keysFor(header.locked), wiper);
if (entry == null) {
throw new VaultError(VaultError.DAMAGED, "damaged entry " + id);
}
@@ -138,7 +144,7 @@ class Vault {
entry.created = Clock.nowUnix(0);
}
if (entry.locked && !session.isUnlocked()) {
- write("p." + entry.id, sealChecked(entry, Record.KEY_DEVICE, keyManager.deviceKeys()));
+ write("p." + entry.id, sealChecked(entry, Record.KEY_DEVICE, keyManager.deviceKeys(), null));
_pending.add(entry.id);
saveMetaOrUndo("p." + entry.id, _pending, entry.id);
} else {
@@ -153,13 +159,19 @@ class Vault {
// entry, or locking one, needs an unlocked session. Throws NOT_FOUND,
// INVALID, LOCKED, LIMIT or FULL.
function update(entry as Entry) as Void {
+ updateWith(entry, null);
+ }
+
+ // As update(), but the plaintext TLV goes to `wiper` when given.
+ function updateWith(entry as Entry, wiper as Jobs.Wiper?) as Void {
var record = recordFor(entry.id);
checkStorable(entry);
entry.fingerprint = keyManager.fingerprint(entry);
if ((record != null && Record.keyRef(record) == Record.KEY_MASTER) || entry.locked) {
session.keys(); // throws LOCKED
}
- write("e." + entry.id, seal(entry));
+ write("e." + entry.id, sealChecked(entry, entry.locked ? Record.KEY_MASTER : Record.KEY_DEVICE, keysFor(entry.locked),
+ wiper));
_store.deleteValue("x." + entry.id); // the cached matrix may be stale
}
@@ -184,12 +196,21 @@ class Vault {
// key (device key, or the master key for locked entries) and bound to the
// ID. A full store just means no cache. Throws NOT_FOUND or LOCKED.
function saveCodeCache(id as String, data as ByteArray) as Void {
+ saveCodeCacheWith(id, data, null);
+ }
+
+ // As saveCodeCache(); the padded copy goes to `wiper` when given.
+ function saveCodeCacheWith(id as String, data as ByteArray, wiper as Jobs.Wiper?) as Void {
var keys = keysFor(isLockedRecord(id));
+ var leftovers = [] as Array<ByteArray>;
try {
- _store.setValue("x." + id, SealedBox.seal(keys, cacheAad(id), data));
+ _store.setValue("x." + id, SealedBox.sealKeep(keys, cacheAad(id), data, wiper != null ? leftovers : null));
} catch (e instanceof Lang.Exception) {
// Storage full: the code is simply encoded again next time.
}
+ if (wiper != null) {
+ wiper.addAll(leftovers);
+ }
}
// The cached matrix, or null when there is none or it doesn't verify.
@@ -257,30 +278,54 @@ class Vault {
// Move pending locked entries under the master key and into the list.
// Needs an unlocked session. Damaged pending items are dropped. Returns
- // the number of entries promoted.
+ // the number of entries promoted. All in one event: for tests and small
+ // queues; after a PIN the app promotes in steps (promoteNext, LockJob).
function promotePending() as Number {
- var keys = session.keys(); // throws LOCKED
+ session.keys(); // throws LOCKED
var promoted = 0;
while (_pending.size() > 0) {
- var id = _pending[0];
- var stored = _store.getValue("p." + id);
- var entry = stored instanceof ByteArray ? Record.open(stored, id, keyManager.deviceKeys()) : null;
- if (entry != null) {
- entry.locked = true;
- write("e." + id, sealChecked(entry, Record.KEY_MASTER, keys));
- entry.wipeSecrets();
- if (_ids.indexOf(id) < 0) {
- _ids.add(id);
- }
+ if (promoteNext(null) > 0) {
promoted++;
}
- _pending.remove(id);
- saveMeta();
- _store.deleteValue("p." + id);
}
return promoted;
}
+ // Promote the first pending entry (one record, then the metadata, so a
+ // crash in between at most promotes it again). Returns the record's
+ // size when it was promoted, -1 when it was damaged (dropped), 0 when
+ // none waits. The entry's secrets go to `wiper` when given (wiping a
+ // large code's contents is a step of its own), else are wiped here.
+ // Needs an unlocked session.
+ function promoteNext(wiper as Jobs.Wiper?) as Number {
+ var keys = session.keys(); // throws LOCKED
+ if (_pending.size() == 0) {
+ return 0;
+ }
+ var id = _pending[0];
+ var stored = _store.getValue("p." + id);
+ var entry = stored instanceof ByteArray ? Record.openWith(stored, id, keyManager.deviceKeys(), wiper) : null;
+ if (entry != null) {
+ entry.locked = true;
+ write("e." + id, sealChecked(entry, Record.KEY_MASTER, keys, wiper));
+ if (wiper != null) {
+ wiper.add(entry.secret);
+ wiper.add(entry.payload);
+ entry.secret = null;
+ entry.payload = null;
+ } else {
+ entry.wipeSecrets();
+ }
+ if (_ids.indexOf(id) < 0) {
+ _ids.add(id);
+ }
+ }
+ _pending.remove(id);
+ saveMeta();
+ _store.deleteValue("p." + id);
+ return entry != null ? (stored as ByteArray).size() : -1;
+ }
+
// ---- PIN and session -----------------------------------------------
function hasPin() as Boolean {
@@ -409,12 +454,12 @@ class Vault {
}
private function seal(entry as Entry) as ByteArray {
- return sealChecked(entry, entry.locked ? Record.KEY_MASTER : Record.KEY_DEVICE, keysFor(entry.locked));
+ return sealChecked(entry, entry.locked ? Record.KEY_MASTER : Record.KEY_DEVICE, keysFor(entry.locked), null);
}
// Seal and enforce the per-record size limit.
- private function sealChecked(entry as Entry, keyRef as Number, keys as SealedBox.Keys) as ByteArray {
- var record = Record.seal(entry, keyRef, keys);
+ private function sealChecked(entry as Entry, keyRef as Number, keys as SealedBox.Keys, wiper as Jobs.Wiper?) as ByteArray {
+ var record = Record.sealWith(entry, keyRef, keys, wiper);
if (record.size() > Record.MAX_BYTES) {
throw new VaultError(VaultError.LIMIT, "entry too big");
}
diff --git a/source/ui/MainMenu.mc b/source/ui/MainMenu.mc
index 21f427c..a4a57da 100644
--- a/source/ui/MainMenu.mc
+++ b/source/ui/MainMenu.mc
@@ -133,7 +133,7 @@ class MainMenuDelegate extends WatchUi.Menu2InputDelegate {
WatchUi.SLIDE_LEFT);
Results.markSeen();
} else if (id == :queue) {
- WatchUi.switchToView(QueueMenu.build(), new QueueMenuDelegate(), WatchUi.SLIDE_LEFT);
+ QueueMenu.open();
} else if (id == :ids) {
var screen = new EntryIdsScreen();
MainMenu.idsScreen = screen;
diff --git a/source/ui/PinView.mc b/source/ui/PinView.mc
index af18e2b..da032ad 100644
--- a/source/ui/PinView.mc
+++ b/source/ui/PinView.mc
@@ -16,7 +16,9 @@
// * Entered digits show as dots. A wrong PIN vibrates and shows any wait.
// * Creating or changing the PIN asks twice.
// * Key derivation (about 1 s) runs as a job on a timer, so the screen
-// shows "Checking..." and the watchdog never trips.
+// shows "Checking..." and the watchdog never trips. So does the work
+// after a correct or new PIN (LockJob: pending entries are promoted,
+// waiting "Require PIN" changes applied); its lines go to the results.
// The typed digits are a ByteArray that is wiped after every attempt.
import Toybox.Attention;
@@ -58,6 +60,10 @@ class PinView extends WatchUi.View {
private var _jobPin as ByteArray? = null;
// The running derivation's timer driver (kept so it isn't collected).
var runner as Jobs.JobRunner? = null;
+ private var _lockJob as LockJob? = null;
+ // A LockJob ran for this screen's PIN (a legacy PIN's new PIN runs a
+ // second one, which adds to the first one's lines).
+ private var _lockJobRan as Boolean = false;
private var _ticker as Timer.Timer? = null;
private var _keys as Array<Array<Number>> = [] as Array<Array<Number>>; // [x, y, w, h, key]
private var _shownLogged as Boolean = false;
@@ -79,6 +85,11 @@ class PinView extends WatchUi.View {
}
function onHide() as Void {
+ if (_lockJobRan && _lockJob == null) {
+ // Closed (or replaced): a "Confirm all" that follows has taken
+ // the lock lines already; else they are in the results.
+ ChangeQueue.takeLockLines();
+ }
var ticker = _ticker;
if (ticker != null) {
ticker.stop();
@@ -234,24 +245,16 @@ class PinView extends WatchUi.View {
}
function onUnlockDone() as Void {
- busy = false;
- _shownLogged = false;
Bytes.wipe(_jobPin);
_jobPin = null;
var vault = AppState.theVault();
if (vault.finishUnlock(_job as PinJob)) {
_job = null;
- vault.promotePending();
- ChangeQueue.applyLocks(vault);
- // Unlocked: "Encrypt names" can now reach locked entries.
- AppState.processSettings();
- if (legacy) {
- askForNewPin();
- return;
- }
- succeed();
+ startLockJob();
return;
}
+ busy = false;
+ _shownLogged = false;
_job = null;
buzz();
if (!vault.hasPin()) {
@@ -264,14 +267,39 @@ class PinView extends WatchUi.View {
}
function onSetDone() as Void {
- busy = false;
Bytes.wipe(_jobPin);
_jobPin = null;
- var vault = AppState.theVault();
- vault.finishSetPin(_job as PinJob);
+ AppState.theVault().finishSetPin(_job as PinJob);
_job = null;
- vault.promotePending();
- ChangeQueue.applyLocks(vault);
+ startLockJob();
+ }
+
+ // Unlocked: promote the pending entries and apply waiting "Require
+ // PIN" changes in steps, still showing the busy state (BACK waits).
+ private function startLockJob() as Void {
+ var job = new LockJob(AppState.theVault(), !_lockJobRan);
+ _lockJobRan = true;
+ _lockJob = job;
+ var r = new Jobs.JobRunner(job, 1, method(:onLockJobDone));
+ r.start();
+ runner = r;
+ }
+
+ function onLockJobDone() as Void {
+ busy = false;
+ _shownLogged = false;
+ var job = _lockJob as LockJob;
+ _lockJob = null;
+ if (job.lines.size() > 0) {
+ Results.add(job.lines); // the list says "Results ready (menu)"
+ }
+ // "Encrypt names" can now reach locked entries, and a lock just
+ // applied changes what the glance may show.
+ AppState.processSettings();
+ if (legacy) {
+ askForNewPin();
+ return;
+ }
succeed();
}
diff --git a/source/ui/QueueMenu.mc b/source/ui/QueueMenu.mc
index 3f9161d..0ec9daf 100644
--- a/source/ui/QueueMenu.mc
+++ b/source/ui/QueueMenu.mc
@@ -5,20 +5,32 @@
// the point is that a person at the watch agrees, not that a key is
// available. (Before any PIN exists only "Require PIN" can wait here, and
// confirming creates the PIN. "Require PIN" items are applied by the PIN
-// screen itself, ChangeQueue.applyLocks, and listed in the results.) The changes are then applied by a
-// ChangeQueueJob on a timer and the results screen shows what happened.
+// screen itself, LockJob, and listed in the results.) The changes are then
+// applied by a ChangeQueueJob on a timer and the results screen shows what
+// happened. The menu is built from DescribeJob's lines, read behind a short
+// "Reading…" screen (opening 16 items in one event trips the watchdog).
import Toybox.Lang;
import Toybox.WatchUi;
module QueueMenu {
- function build() as WatchUi.Menu2 {
+ // The "Reading…" screen while it runs (kept so the runner isn't collected).
+ var screen as QueueMenuScreen? = null;
+
+ // Open the menu (replacing the current view).
+ function open() as Void {
+ var s = new QueueMenuScreen();
+ screen = s;
+ s.start();
+ }
+
+ // The menu for these descriptions (DescribeJob.lines).
+ function build(items as Array<String>) as WatchUi.Menu2 {
var vault = AppState.theVault();
var menu = new WatchUi.Menu2({:title => "Phone changes"});
menu.addItem(new WatchUi.MenuItem("Confirm all", vault.hasPin() ? "Asks for your PIN" : "Sets a PIN first", :confirm, null));
menu.addItem(new WatchUi.MenuItem("Reject all", null, :reject, null));
- var items = ChangeQueue.descriptions(vault);
for (var i = 0; i < items.size(); i++) {
menu.addItem(new WatchUi.MenuItem(items[i], null, i, null));
}
@@ -26,10 +38,44 @@ module QueueMenu {
}
}
-class QueueMenuDelegate extends WatchUi.Menu2InputDelegate {
+// Shows "Reading…", runs DescribeJob, then replaces that screen with the
+// menu, unless the user left it meanwhile.
+class QueueMenuScreen {
+
+ var runner as Jobs.JobRunner? = null;
+ private var _job as DescribeJob = new DescribeJob(AppState.theVault());
+ private var _reading as InfoView = new InfoView("Phone changes", ["Reading…"] as Array<String>, "queue_reading");
function initialize() {
+ }
+
+ function start() as Void {
+ WatchUi.switchToView(_reading, new InfoDelegate(), WatchUi.SLIDE_LEFT);
+ var r = new Jobs.JobRunner(_job, 1, method(:done));
+ runner = r;
+ r.start();
+ }
+
+ function done() as Void {
+ runner = null;
+ QueueMenu.screen = null;
+ if (WatchUi.getCurrentView()[0] == _reading) {
+ WatchUi.switchToView(QueueMenu.build(_job.lines), new QueueMenuDelegate(_job.lines, _job.keys), WatchUi.SLIDE_IMMEDIATE);
+ }
+ }
+}
+
+class QueueMenuDelegate extends WatchUi.Menu2InputDelegate {
+
+ // The descriptions the menu shows, and the keys of the items read
+ // (DescribeJob): confirming or rejecting acts on exactly those.
+ private var _items as Array<String>;
+ private var _keys as Array<String>;
+
+ function initialize(items as Array<String>, keys as Array<String>) {
Menu2InputDelegate.initialize();
+ _items = items;
+ _keys = keys;
}
function onSelect(item as WatchUi.MenuItem) as Void {
@@ -37,22 +83,25 @@ class QueueMenuDelegate extends WatchUi.Menu2InputDelegate {
var id = item.getId();
var vault = AppState.theVault();
if (id == :confirm) {
- var then = new ApplyQueueAfterPin();
+ var then = new ApplyQueueAfterPin(_keys);
var mode = vault.hasPin() ? PinView.MODE_UNLOCK : PinView.MODE_CREATE;
WatchUi.switchToView(new PinView(mode, then.method(:run)), new PinDelegate(), WatchUi.SLIDE_LEFT);
} else if (id == :reject) {
var lines = ["Rejected these changes from the phone:"] as Array<String>;
- lines.addAll(ChangeQueue.descriptions(vault));
- ChangeQueue.clear(vault);
+ lines.addAll(_items);
+ var late = ChangeQueue.remove(vault, _keys);
+ if (late > 0) {
+ lines.add(late + (late == 1 ? " change" : " changes") + " arrived meanwhile and still "
+ + (late == 1 ? "waits" : "wait") + " (menu, Phone changes)");
+ }
Results.save(lines);
WatchUi.switchToView(new InfoView("Results", lines, "results"), new InfoDelegate(), WatchUi.SLIDE_LEFT);
Results.markSeen();
AppState.processSettings(); // the About fields mention waiting changes
} else if (id instanceof Number) {
// The full text of one change (menu labels are cut short).
- var items = ChangeQueue.descriptions(vault);
- if (id < items.size()) {
- WatchUi.pushView(new InfoView("Change", [items[id]] as Array<String>, "change"), new InfoDelegate(),
+ if (id < _items.size()) {
+ WatchUi.pushView(new InfoView("Change", [_items[id]] as Array<String>, "change"), new InfoDelegate(),
WatchUi.SLIDE_LEFT);
}
}
@@ -65,12 +114,15 @@ class ApplyQueueAfterPin {
// The job's timer driver (kept so it isn't collected while it runs).
var runner as Jobs.JobRunner? = null;
private var _job as ChangeQueueJob? = null;
+ private var _keys as Array<String>;
- function initialize() {
+ // keys: the items the menu showed (DescribeJob.keys).
+ function initialize(keys as Array<String>) {
+ _keys = keys;
}
function run() as Void {
- var job = new ChangeQueueJob(AppState.theVault());
+ var job = new ChangeQueueJob(AppState.theVault(), _keys);
_job = job;
WatchUi.switchToView(new InfoView("Results", ["Applying the changes…"] as Array<String>, "applying"), new InfoDelegate(),
WatchUi.SLIDE_LEFT);
diff --git a/source/util/Bytes.mc b/source/util/Bytes.mc
index aa5a28b..5fd7a96 100644
--- a/source/util/Bytes.mc
+++ b/source/util/Bytes.mc
@@ -60,6 +60,14 @@ module Bytes {
if (!isValidUtf8(data)) {
return null;
}
+ return toUtf8Checked(data);
+ }
+
+ // Bytes that utf8Prefix() found well-formed to the end, as a string.
+ function toUtf8Checked(data as ByteArray) as String {
+ if (data.size() == 0) {
+ return "";
+ }
return StringUtil.convertEncodedString(data, {
:fromRepresentation => StringUtil.REPRESENTATION_BYTE_ARRAY,
:toRepresentation => StringUtil.REPRESENTATION_STRING_PLAIN_TEXT,
@@ -70,15 +78,24 @@ module Bytes {
// True for well-formed UTF-8 (RFC 3629) without NUL bytes: no overlong
// forms, no surrogates (U+D800-DFFF), nothing above U+10FFFF.
function isValidUtf8(data as ByteArray) as Boolean {
- var i = 0;
+ return utf8Prefix(data, 0, data.size()) >= 0;
+ }
+
+ // Checks the sequences that start in [from, to) (to <= size) as
+ // isValidUtf8() does:
+ // the index where the next sequence starts (`to` or up to 3 past it),
+ // or -1 when one is malformed. A large array is checked in steps this
+ // way (the check is a byte loop in the VM).
+ function utf8Prefix(data as ByteArray, from as Number, to as Number) as Number {
+ var i = from;
var n = data.size();
- while (i < n) {
+ while (i < to) {
var b = data[i];
var more = 0;
var min = 0;
var cp = 0;
if (b == 0) {
- return false;
+ return -1;
} else if (b < 0x80) {
i++;
continue;
@@ -89,24 +106,24 @@ module Bytes {
} else if (b >= 0xF0 && b <= 0xF4) {
more = 3; min = 0x10000; cp = b & 0x07;
} else {
- return false;
+ return -1;
}
if (i + more >= n) {
- return false;
+ return -1;
}
for (var k = 1; k <= more; k++) {
var c = data[i + k];
if ((c & 0xC0) != 0x80) {
- return false;
+ return -1;
}
cp = (cp << 6) | (c & 0x3F);
}
if (cp < min || cp > 0x10FFFF || (cp >= 0xD800 && cp <= 0xDFFF)) {
- return false;
+ return -1;
}
i += more + 1;
}
- return true;
+ return i;
}
// A new array holding a followed by b; neither input is changed.
diff --git a/source/util/Jobs.mc b/source/util/Jobs.mc
index 595562e..009e16c 100644
--- a/source/util/Jobs.mc
+++ b/source/util/Jobs.mc
@@ -86,6 +86,69 @@ module Jobs {
}
}
+ // Wipes buffers in pieces of at most `max` bytes per step(): zeroing is
+ // a byte loop in the VM (12 to 15 counted bytecodes per byte, measured
+ // in `make watchdog`), so a few kilobytes take a step of their own.
+ class Wiper {
+
+ // Bytes per step: about 30,000 bytecodes, a quarter of the 255S's
+ // 120,000 per event (measured: 16 KB in one event pass on the
+ // 240,000-bytecode Forerunner 70).
+ static const STEP_BYTES = 2048;
+ // Up to this many bytes are wiped at once, without a step of their own.
+ static const INLINE_BYTES = 512;
+
+ private var _buffers as Array<ByteArray> = [] as Array<ByteArray>;
+ private var _at as Number = 0;
+ private var _left as Number = 0;
+
+ function initialize() {
+ }
+
+ // Queue a buffer (null is ignored).
+ function add(data as ByteArray?) as Void {
+ if (data != null && data.size() > 0) {
+ _buffers.add(data);
+ _left += data.size();
+ }
+ }
+
+ function addAll(buffers as Array<ByteArray>) as Void {
+ for (var i = 0; i < buffers.size(); i++) {
+ add(buffers[i]);
+ }
+ }
+
+ // Bytes still to wipe.
+ function left() as Number {
+ return _left - _at;
+ }
+
+ // Wipe at most `max` bytes; true when everything is wiped.
+ function step(max as Number) as Boolean {
+ var done = 0;
+ while (_buffers.size() > 0 && done < max) {
+ var b = _buffers[0];
+ var end = _at + (max - done);
+ if (end > b.size()) {
+ end = b.size();
+ }
+ for (var i = _at; i < end; i++) {
+ b[i] = 0;
+ }
+ done += end - _at;
+ if (end == b.size()) {
+ _left -= b.size();
+ _at = 0;
+ _buffers = _buffers.slice(1, null) as Array<ByteArray>;
+ } else {
+ _at = end;
+ }
+ }
+ return _buffers.size() == 0;
+ }
+ }
+
// App memory above this means a 240,000-bytecode watchdog.
const LARGE_WATCH_MEMORY = 600000;
var _stepsPerTick as Number = 0;
diff --git a/source/util/StressTest.mc b/source/util/StressTest.mc
index d1f99ea..a9d9293 100644
--- a/source/util/StressTest.mc
+++ b/source/util/StressTest.mc
@@ -1,5 +1,5 @@
// Author: cirakg & claude ai
-// Summary: Watchdog stress build: the largest code of every 2D symbology, encoded, packed, turned into runs and drawn, at twice the normal steps per event.
+// Summary: Watchdog stress build: the largest code of every 2D symbology, encoded, packed, turned into runs and drawn, at twice the normal steps per event; the PIN and change-queue work at the same margin.
//
// Two versions of this module exist; each build keeps exactly one:
// * (:no_stress), in every normal build: ON is false.
@@ -14,8 +14,13 @@
// where the runner already does two steps per tick). Codes that fit the
// screen are then drawn the way CodeView draws them (RunsPainter: straight
// from the runs, or from a bitmap painted in steps), in one onUpdate.
+// Before the last code, VaultCase items measure the vault work after a PIN
+// and on the change queue (LockJob, ChangeQueueJob, ManageRun replacing
+// 4 KB of contents or a long secret, counting and describing the queue,
+// 0.9.0 items) at the same margin.
// docker/scripts/app-diag.sh watchdog reads the DIAG stress lines.
+import Toybox.Application;
import Toybox.Graphics;
import Toybox.Lang;
import Toybox.System;
@@ -70,6 +75,362 @@ module StressTest {
}
}
+ // A RAM store for the vault cases. Values are kept as they are, without
+ // the copies Application.Storage (or the tests' MemoryBackend) makes, so
+ // the store adds no VM loops to what is measured, and two full vaults
+ // fit without the storage limits.
+ class RamBackend extends Persist.Backend {
+ var values as Dictionary<String, Application.Storage.ValueType> = {} as Dictionary<String, Application.Storage.ValueType>;
+
+ function initialize() {
+ Backend.initialize();
+ }
+
+ function getValue(key as String) as Application.Storage.ValueType {
+ return values[key] as Application.Storage.ValueType;
+ }
+
+ function setValue(key as String, value as Application.Storage.ValueType) as Void {
+ values[key] = value;
+ }
+
+ function deleteValue(key as String) as Void {
+ values.remove(key);
+ }
+ }
+
+ // One case of vault work after a PIN or on a queue change. Setup builds
+ // the vaults in small steps (one entry, queue item or KDF step each).
+ // The measured run then does the work at MARGIN: what runs in one event
+ // runs twice in it, and jobs run on two identical vaults (A and B), one
+ // step of each per step of this job, in stages (one job per vault each).
+ // * UNLOCK: each vault has a PIN, LOCKS unlocked entries with a queued
+ // "Require PIN" each, `pending` pending locked entries and a locked
+ // session. B's unlock derivation finishes during setup; A's runs in
+ // the measured run. Its last step finishes both unlocks, as
+ // PinView.onUnlockDone does; then the LockJobs run.
+ // * COUNT / DESCRIBE: one locked vault. COUNT's queue holds a REPLACE
+ // of a locked code with `material` bytes of checked contents and
+ // LOCKS - 1 LOCKs, and the work is ChangeQueue.changed() +
+ // countToConfirm(). DESCRIBE's holds LOCKS edits with names of the
+ // largest size, and the work is DescribeJob (QueueMenu), stepped on
+ // the one vault MARGIN times per step.
+ // * REPLACE_QUEUED: a locked code entry, a REPLACE with `material`
+ // digits queued by ManageRun (as the settings queue it) during
+ // setup; the measured run applies it with ChangeQueueJob.
+ // * REPLACE_DIRECT: an unlocked code entry, and the same REPLACE
+ // applied at once (ManageRun, not confirmed, as SettingsJob runs it).
+ // * LEGACY: a locked code entry and a 0.9.0 queue item (a bare box,
+ // aad "OTPW-Q-1") replacing it with `material` raw digits; locked,
+ // with both unlock derivations done in setup. Stages: DescribeJob
+ // (the menu), the unlock and LockJob, ChangeQueueJob ("Confirm all"
+ // with the menu's keys).
+ // * SECRET: a locked OTP entry, and a REPLACE with a valid Base32
+ // secret padded with spaces to ManageRun.MAX_SECRET_TEXT characters:
+ // queued (ManageRun), then confirmed (ChangeQueueJob).
+ // * SPACES: an unlocked code entry and a REPLACE of `material` spaces
+ // (refused: "Contents: missing").
+ class VaultCase extends Jobs.Job {
+ static const UNLOCK = 0;
+ static const COUNT = 1;
+ static const DESCRIBE = 2;
+ static const REPLACE_QUEUED = 3;
+ static const REPLACE_DIRECT = 4;
+ static const LEGACY = 5;
+ static const SECRET = 6;
+ static const SPACES = 7;
+ // Unlocked entries, one queued item each (ChangeQueue.MAX_ITEMS).
+ static const LOCKS = 16;
+ static const PIN = "1234";
+
+ var name as String;
+ var kind as Number;
+ var pending as Number;
+ var material as Number;
+ // False during setup, true in the measured run.
+ var measuring as Boolean = false;
+ var steps as Number = 0;
+ var measured as Number = 0;
+ var peak as Number = 0;
+ var result as String = "";
+
+ private var _vaults as Array<Vault> = [] as Array<Vault>;
+ private var _pins as Array<PinJob> = [] as Array<PinJob>;
+ private var _targets as Array<String> = [] as Array<String>;
+ private var _phase as Number = 0;
+ private var _n as Number = 0;
+ private var _job as PinJob? = null;
+ private var _setupRun as ManageRun? = null;
+ // The measured stage, its jobs (one per vault) and which are done.
+ private var _stage as Number = -1;
+ private var _jobs as Array<Jobs.Job> = [] as Array<Jobs.Job>;
+ private var _done as Array<Boolean> = [] as Array<Boolean>;
+ // LEGACY: the keys each vault's menu read.
+ private var _keys as Array<Array<String>> = [] as Array<Array<String>>;
+
+ function initialize(label as String, type as Number, pendingCount as Number, materialBytes as Number) {
+ Job.initialize();
+ name = label;
+ kind = type;
+ pending = pendingCount;
+ material = materialBytes;
+ }
+
+ function step(budget as Number) as Boolean {
+ var used = System.getSystemStats().usedMemory;
+ peak = used > peak ? used : peak;
+ if (measuring) {
+ measured++;
+ return measure(budget);
+ }
+ steps++;
+ return setup(budget);
+ }
+
+ private function vaultCount() as Number {
+ return kind == COUNT || kind == DESCRIBE ? 1 : 2;
+ }
+
+ // Entries per vault.
+ private function entryCount() as Number {
+ return kind >= REPLACE_QUEUED ? 1 : LOCKS;
+ }
+
+ // One bounded setup step; true when every vault is ready.
+ private function setup(budget as Number) as Boolean {
+ var v = _vaults.size() > 0 ? _vaults[_vaults.size() - 1] : null;
+ if (_phase == 0) {
+ v = new Vault(new RamBackend(), new TimeSource());
+ _vaults.add(v);
+ _targets = [] as Array<String>;
+ _job = v.beginSetPin(Bytes.fromUtf8(PIN), Kdf.MIN_ITERATIONS);
+ _phase = 1;
+ } else if (_phase == 1) {
+ if ((_job as PinJob).step(budget)) {
+ (v as Vault).finishSetPin(_job as PinJob);
+ _job = null;
+ _phase = 2;
+ _n = 0;
+ }
+ } else if (_phase == 2) {
+ _targets.add((v as Vault).add(newEntry()));
+ _n++;
+ if (_n == entryCount()) {
+ if (kind <= DESCRIBE || kind == LEGACY) {
+ (v as Vault).lock();
+ }
+ _phase = 3;
+ _n = 0;
+ }
+ } else if (_phase == 3) {
+ // Locked entries added while locked wait under "p." (pending).
+ if (_n < pending) {
+ (v as Vault).add(Fixtures.otp("Pending " + _n, "acct", "Stress", Entry.OTP_TOTP, Hashes.SHA1, 6, 30,
+ "12345678901234567890", true));
+ _n++;
+ } else {
+ _phase = 4;
+ _n = 0;
+ }
+ } else if (_phase == 4) {
+ if (queueStep(v as Vault)) {
+ if (kind != UNLOCK && kind != LEGACY) {
+ _phase = 0;
+ return _vaults.size() == vaultCount();
+ }
+ _pins.add((v as Vault).beginUnlock(Bytes.fromUtf8(PIN)));
+ // UNLOCK: the last vault (A) is unlocked in the measured run.
+ if (kind == UNLOCK && _vaults.size() == vaultCount()) {
+ return true;
+ }
+ _phase = 5;
+ }
+ } else if (_pins[_pins.size() - 1].step(budget)) {
+ _phase = 0;
+ return _vaults.size() == vaultCount();
+ }
+ return false;
+ }
+
+ private function newEntry() as Entry {
+ if (kind == SECRET) {
+ return Fixtures.otp("Bank", "acct", "Stress", Entry.OTP_TOTP, Hashes.SHA1, 6, 30, "12345678901234567890", true);
+ }
+ if (kind >= REPLACE_QUEUED || (kind != UNLOCK && _n == 0)) {
+ // The code a REPLACE targets; locked unless replaced directly.
+ var e = Fixtures.code("Card", Codes.QR, "1234567890");
+ e.locked = kind != REPLACE_DIRECT && kind != SPACES;
+ return e;
+ }
+ return Fixtures.otp("Stress " + _n, "acct", "Stress", Entry.OTP_TOTP, Hashes.SHA1, 6, 30, "12345678901234567890", false);
+ }
+
+ // The REPLACE of the replace cases, as ManageForm.read gives it.
+ private function replaceAction(v as Vault) as ManageAction {
+ var a = new ManageAction();
+ a.id = _targets[0];
+ a.action = ManageAction.REPLACE;
+ if (kind == SECRET) {
+ // 200 Base32 characters (125 bytes) in groups, spaces around.
+ var group = repeat(" ", 16) + "AAAA";
+ var groups = 200 / 4;
+ a.text = repeat(" ", ManageRun.MAX_SECRET_TEXT - groups * group.length()) + repeat(group, groups);
+ } else if (kind == SPACES) {
+ a.text = repeat(" ", material);
+ } else {
+ a.text = repeat("0123456789", (material + 9) / 10).substring(0, material) as String;
+ }
+ return a;
+ }
+
+ // One step of queueing; true when the vault's queue is complete.
+ private function queueStep(v as Vault) as Boolean {
+ if (kind == REPLACE_DIRECT || kind == SECRET || kind == SPACES) {
+ return true;
+ }
+ if (kind == LEGACY) {
+ // As 0.9.0 queued it: raw contents inside one box.
+ var old = new ManageAction();
+ old.id = _targets[0];
+ old.action = ManageAction.REPLACE;
+ old.label = "Card";
+ old.material = Bytes.fromUtf8(replaceAction(v).text as String);
+ var stored = ChangeQueue.items(v);
+ stored.add(SealedBox.sealKeep(v.keyManager.deviceKeys(), Bytes.fromUtf8(ChangeQueue.AAD_V1), old.toBytes(),
+ [] as Array<ByteArray>)); // setup: nothing to wipe
+ v.storage().setValue(ChangeQueue.KEY, stored);
+ ChangeQueue.changed();
+ return true;
+ }
+ if (kind == REPLACE_QUEUED) {
+ var run = _setupRun;
+ if (run == null) {
+ _setupRun = new ManageRun(v, replaceAction(v), false);
+ return false;
+ }
+ if (!run.step(1)) {
+ return false;
+ }
+ _setupRun = null;
+ if (!run.queued) {
+ result = " setup_problem=" + run.message;
+ }
+ return true;
+ }
+ var a = new ManageAction();
+ a.id = _targets[_n];
+ a.label = "Stress " + _n;
+ a.action = ManageAction.LOCK;
+ if (kind == DESCRIBE) {
+ a.action = ManageAction.EDIT;
+ a.name = repeat("N", Entry.MAX_NAME_BYTES);
+ a.account = repeat("A", Entry.MAX_NAME_BYTES);
+ a.group = repeat("G", Entry.MAX_GROUP_BYTES);
+ a.label = Forms.label(a.name, a.account);
+ } else if (kind == COUNT && _n == 0) {
+ // Checked contents, as ManageRun queues them.
+ a.action = ManageAction.REPLACE;
+ a.material = Bytes.fromUtf8(repeat("abcdefghij", (material + 9) / 10).substring(0, material) as String);
+ a.decoded = true;
+ }
+ ChangeQueue.add(v, a);
+ _n++;
+ return _n == LOCKS;
+ }
+
+ // The measured run.
+ private function measure(budget as Number) as Boolean {
+ if (kind == COUNT) {
+ for (var i = 0; i < MARGIN; i++) {
+ ChangeQueue.changed();
+ result = " to_confirm=" + ChangeQueue.countToConfirm(_vaults[0]);
+ }
+ return true;
+ }
+ if (_stage < 0) {
+ if (kind == UNLOCK) {
+ // A's derivation; its last step finishes both unlocks.
+ if (!_pins[_vaults.size() - 1].step(budget)) {
+ return false;
+ }
+ Diag.data("stress", "kdf_done name=" + name);
+ }
+ return nextStage();
+ }
+ var all = true;
+ for (var i = 0; i < _jobs.size(); i++) {
+ if (!_done[i]) {
+ _done[i] = _jobs[i].step(1);
+ all = all && _done[i];
+ }
+ }
+ if (!all) {
+ return false;
+ }
+ summary();
+ return nextStage();
+ }
+
+ // Start the next stage's jobs (A first); true when there is none.
+ private function nextStage() as Boolean {
+ _stage++;
+ var stages = kind == LEGACY ? 3 : (kind == SECRET ? 2 : 1);
+ if (_stage >= stages) {
+ return true;
+ }
+ var previous = _jobs;
+ _jobs = [] as Array<Jobs.Job>;
+ _done = [] as Array<Boolean>;
+ var last = _vaults.size() - 1;
+ for (var i = last; i >= 0; i--) {
+ var v = _vaults[i];
+ var job = new Jobs.Job(); // replaced below
+ if (kind == DESCRIBE) {
+ _jobs.add(new DescribeJob(v));
+ _done.add(false);
+ job = new DescribeJob(v);
+ } else if (kind == UNLOCK) {
+ result += " v" + i + "_unlocked=" + v.finishUnlock(_pins[i]);
+ job = new LockJob(v, true);
+ } else if (kind == REPLACE_QUEUED || (kind == SECRET && _stage == 1)) {
+ job = new ChangeQueueJob(v, null);
+ } else if (kind == LEGACY) {
+ if (_stage == 0) {
+ job = new DescribeJob(v);
+ } else if (_stage == 1) {
+ _keys.add((previous[last - i] as DescribeJob).keys);
+ result += " v" + i + "_unlocked=" + v.finishUnlock(_pins[i]);
+ job = new LockJob(v, true);
+ } else {
+ job = new ChangeQueueJob(v, _keys[last - i]);
+ }
+ } else {
+ job = new ManageRun(v, replaceAction(v), false);
+ }
+ _jobs.add(job as Jobs.Job);
+ _done.add(false);
+ }
+ return false;
+ }
+
+ private function summary() as Void {
+ var job = _jobs[0];
+ var v = _vaults[_vaults.size() - 1];
+ result += " [" + _stage + "]";
+ if (job instanceof LockJob) {
+ ChangeQueue.takeLockLines(); // as the PIN screen does when it closes
+ result += " lock_lines=" + job.lines.size() + " pending_left=" + v.pendingIds().size() + " ids=" + v.ids().size()
+ + " queue_left=" + ChangeQueue.count(v);
+ } else if (job instanceof ChangeQueueJob) {
+ result += " lines=" + job.lines.size() + " first='" + job.lines[0] + "' queue_left=" + ChangeQueue.count(v);
+ } else if (job instanceof DescribeJob) {
+ result += " described=" + job.lines.size();
+ } else if (job instanceof ManageRun) {
+ result += " failed=" + job.failed + " queued=" + job.queued + " message='" + job.message + "'";
+ }
+ }
+ }
+
// The screen: progress while encoding, the runs once an item is done.
class StressView extends WatchUi.View {
var title as String = "Stress test";
@@ -101,8 +462,9 @@ module StressTest {
}
var view as StressView? = null;
- var items as Array<Item> = [] as Array<Item>;
+ var items as Array<Item or VaultCase> = [] as Array<Item or VaultCase>;
var current as Item? = null;
+ var currentCase as VaultCase? = null;
var runner as Jobs.JobRunner? = null;
function initialView() as [WatchUi.Views] or [WatchUi.Views, WatchUi.InputDelegates] {
@@ -136,9 +498,19 @@ module StressTest {
new Item("pdf417_digits", Codes.PDF417, new Pdf417.Pdf417Job(digits.substring(0, 400) as String, 1, w, h, round)),
new Item("dm_fit", Codes.DATAMATRIX, new DataMatrix.DataMatrixJob(letters.substring(0, largestDataMatrix(w, h, round))
as String)),
+ // Single-event vault work (VaultCase).
+ new VaultCase("queue_count_big", VaultCase.COUNT, 0, Entry.MAX_PAYLOAD_BYTES),
+ new VaultCase("queue_describe", VaultCase.DESCRIBE, 0, 0),
+ new VaultCase("unlock_locks", VaultCase.UNLOCK, 0, 0),
+ new VaultCase("unlock_pending", VaultCase.UNLOCK, Vault.MAX_ENTRIES - VaultCase.LOCKS, 0),
+ new VaultCase("replace_code_4k", VaultCase.REPLACE_QUEUED, 0, Entry.MAX_PAYLOAD_BYTES),
+ new VaultCase("replace_code_4k_direct", VaultCase.REPLACE_DIRECT, 0, Entry.MAX_PAYLOAD_BYTES),
+ new VaultCase("queue_legacy_4k", VaultCase.LEGACY, 0, Entry.MAX_PAYLOAD_BYTES),
+ new VaultCase("replace_secret_1k", VaultCase.SECRET, 0, 0),
+ new VaultCase("replace_spaces_4k", VaultCase.SPACES, 0, Entry.MAX_PAYLOAD_BYTES),
// Last, so it stays on the screen: app-diag.sh decodes it.
new Item("qr_fit", Codes.QR, new Qr.QrJob(SCREEN_TEXT, 1, largestQrVersion(w, h, round), -1))
- ] as Array<Item>;
+ ] as Array<Item or VaultCase>;
Diag.data("stress", "start used_kb=" + System.getSystemStats().usedMemory / 1024);
next();
}
@@ -149,7 +521,16 @@ module StressTest {
return;
}
var item = items[0];
- items = items.slice(1, null) as Array<Item>;
+ items = items.slice(1, null) as Array<Item or VaultCase>;
+ if (item instanceof VaultCase) {
+ currentCase = item;
+ (view as StressView).title = item.name;
+ WatchUi.requestUpdate();
+ var r = new Jobs.JobRunner(item, Kdf.ITERATIONS_PER_STEP, new Lang.Method(StressTest, :onCaseReady));
+ runner = r;
+ r.start();
+ return;
+ }
current = item;
(view as StressView).title = item.name;
WatchUi.requestUpdate();
@@ -172,6 +553,27 @@ module StressTest {
r.start();
}
+ // Setup done: the measured run starts with a fresh runner, so its first
+ // event holds no setup step.
+ function onCaseReady() as Void {
+ var c = currentCase as VaultCase;
+ c.measuring = true;
+ Diag.data("stress", "start name=" + c.name + " setup_steps=" + c.steps + " used_kb=" + System.getSystemStats().usedMemory / 1024);
+ var r = new Jobs.JobRunner(c, Kdf.ITERATIONS_PER_STEP, new Lang.Method(StressTest, :onCaseDone));
+ runner = r;
+ r.start();
+ }
+
+ function onCaseDone() as Void {
+ var c = currentCase as VaultCase;
+ Diag.data("stress", "done name=" + c.name + " setup_steps=" + c.steps + " measured_steps=" + c.measured + " peak_kb=" + c.peak / 1024
+ + " total_kb=" + System.getSystemStats().totalMemory / 1024 + c.result);
+ currentCase = null;
+ var r = new Jobs.JobRunner(new Jobs.Job(), 1, new Lang.Method(StressTest, :next));
+ runner = r;
+ r.start();
+ }
+
function repeat(text as String, times as Number) as String {
var out = "";
for (var i = 0; i < times; i++) {