diff options
Diffstat (limited to 'source/util')
| -rw-r--r-- | source/util/Bytes.mc | 33 | ||||
| -rw-r--r-- | source/util/Jobs.mc | 63 | ||||
| -rw-r--r-- | source/util/StressTest.mc | 410 |
3 files changed, 494 insertions, 12 deletions
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++) { |
