From bc4cc19df5f53519ca1aabd3cf161b6d78ffc16c Mon Sep 17 00:00:00 2001 From: cirakg Date: Tue, 27 Oct 2026 01:01:18 +0100 Subject: fix(pin): the work after a PIN and the change queue run in steps After a correct or new PIN, promoting pending entries and applying waiting "Require PIN" changes ran in the event of the last PBKDF2 step; 16 waiting changes tripped the watchdog on the Forerunner 70. ChangeQueue.LockJob now does it in steps while the PIN screen shows "Checking...", saves what it did to the results and refreshes the glance after Set PIN too. The list title decrypted every waiting change at each start. Queue items are now [action, box] (aad OTPW-Q-2 + action code), so they are counted without decrypting; 0.9.0 items are still read. A replacement is checked when it arrives and its material is sealed apart (qm.). Large buffers are wiped and UTF-8-checked in pieces (Jobs.Wiper, Bytes.utf8Prefix, SealedBox.openKeep/sealKeep). "Phone changes" reads behind "Reading...", and Confirm all / Reject all act only on the changes shown. Storage full no longer stops the app at the PIN. Found by the review of b8addc8/096b5a4 and a new make watchdog test; the diff was reviewed again and its findings fixed. fr70: make lint, make test (176), make watchdog (19 cases at two steps per event), make screenshots pass; list_locked and list_reset goldens show "Results ready (menu)". Co-Authored-By: Claude Opus 5.5 --- source/util/Bytes.mc | 33 +++- source/util/Jobs.mc | 63 +++++++ source/util/StressTest.mc | 410 +++++++++++++++++++++++++++++++++++++++++++++- 3 files changed, 494 insertions(+), 12 deletions(-) (limited to 'source/util') 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 = [] as Array; + 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) 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; + } 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 = {} as Dictionary; + + 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 = [] as Array; + private var _pins as Array = [] as Array; + private var _targets as Array = [] as Array; + 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 = [] as Array; + private var _done as Array = [] as Array; + // LEGACY: the keys each vault's menu read. + private var _keys as Array> = [] as Array>; + + 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; + _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)); // 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; + _done = [] as Array; + 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 = [] as Array; + var items as Array = [] as Array; 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; + ] as Array; 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; + items = items.slice(1, null) as Array; + 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++) { -- cgit v1.2.3