summaryrefslogtreecommitdiff
path: root/source/settings/ManageForm.mc
diff options
context:
space:
mode:
authorcirakg <cirakg@cirakg.xyz>2026-10-27 01:01:18 +0100
committercirakg <cirakg@cirakg.xyz>2026-10-27 01:01:18 +0100
commitbc4cc19df5f53519ca1aabd3cf161b6d78ffc16c (patch)
tree3242a63f603882d723bd0d718d043c30a9f11784 /source/settings/ManageForm.mc
parent34daecaa25e5bdd995179a2b4b9d922f8bbb3b54 (diff)
fix(pin): the work after a PIN and the change queue run in steps
After a correct or new PIN, promoting pending entries and applying waiting "Require PIN" changes ran in the event of the last PBKDF2 step; 16 waiting changes tripped the watchdog on the Forerunner 70. ChangeQueue.LockJob now does it in steps while the PIN screen shows "Checking...", saves what it did to the results and refreshes the glance after Set PIN too. The list title decrypted every waiting change at each start. Queue items are now [action, box] (aad OTPW-Q-2 + action code), so they are counted without decrypting; 0.9.0 items are still read. A replacement is checked when it arrives and its material is sealed apart (qm.<n>). Large buffers are wiped and UTF-8-checked in pieces (Jobs.Wiper, Bytes.utf8Prefix, SealedBox.openKeep/sealKeep). "Phone changes" reads behind "Reading...", and Confirm all / Reject all act only on the changes shown. Storage full no longer stops the app at the PIN. Found by the review of b8addc8/096b5a4 and a new make watchdog test; the diff was reviewed again and its findings fixed. fr70: make lint, make test (176), make watchdog (19 cases at two steps per event), make screenshots pass; list_locked and list_reset goldens show "Results ready (menu)". Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Diffstat (limited to 'source/settings/ManageForm.mc')
-rw-r--r--source/settings/ManageForm.mc265
1 files changed, 216 insertions, 49 deletions
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 {