From 096b5a4d17c85fa5f87db3e72364e9c3506d1e64 Mon Sep 17 00:00:00 2001 From: cirakg Date: Sun, 25 Oct 2026 12:46:42 +0100 Subject: feat(pin): exactly 4 digits, entered on the 4th digit The keypad has no OK key; the 4th digit submits, so a tap that also arrives as START can no longer submit a partial PIN (the likely cause of "Wrong PIN" before the code opened). PINs set by earlier versions still unlock and are then replaced by a 4-digit one. Opening an entry ignores a second request from the same tap. A Require PIN sent from the phone while the watch is locked now applies at the next PIN instead of waiting for Confirm all. The linter skips .claude. Co-Authored-By: Claude Opus 5.5 --- source/settings/ChangeQueue.mc | 76 ++++++++++++++++++++++++++++++++++++ source/settings/ManageForm.mc | 6 ++- source/settings/SettingsProcessor.mc | 8 +++- 3 files changed, 87 insertions(+), 3 deletions(-) (limited to 'source/settings') diff --git a/source/settings/ChangeQueue.mc b/source/settings/ChangeQueue.mc index 12f88f7..c5919f6 100644 --- a/source/settings/ChangeQueue.mc +++ b/source/settings/ChangeQueue.mc @@ -8,6 +8,11 @@ // 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). +// +// "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. import Toybox.Application; import Toybox.Lang; @@ -18,6 +23,10 @@ module ChangeQueue { const MAX_ITEMS = 16; const AAD = "OTPW-Q-1"; + // What applyLocks() did at the last PIN, for the results of a "Confirm + // all" that follows it (ChangeQueueJob). Replaced at every PIN. + var _lockLines as Array = [] as Array; + function count(vault as Vault) as Number { var stored = vault.storage().getValue(KEY); return stored instanceof Array ? stored.size() : 0; @@ -75,6 +84,71 @@ module ChangeQueue { return out; } + // The number of waiting "Require PIN" actions (applied at the next PIN). + function countLocks(vault as Vault) as Number { + var actions = load(vault); + var n = 0; + for (var i = 0; i < actions.size(); i++) { + if (actions[i].action == 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; + 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; + 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() + } + } + 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). + function takeLockLines() as Array { + var lines = _lockLines; + _lockLines = [] as Array; + return lines; + } + function clear(vault as Vault) as Void { vault.storage().deleteValue(KEY); } @@ -115,6 +189,8 @@ class ChangeQueueJob extends Jobs.Job { 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). + lines.addAll(ChangeQueue.takeLockLines()); } function step(budget as Number) as Boolean { diff --git a/source/settings/ManageForm.mc b/source/settings/ManageForm.mc index a075880..70d707f 100644 --- a/source/settings/ManageForm.mc +++ b/source/settings/ManageForm.mc @@ -8,7 +8,8 @@ // applies it, or queues it (ChangeQueue) when it touches a locked // entry: edit, replace, counter, "Don't require PIN", the favourite // mark and delete wait until the PIN is entered on the watch. "Require PIN" waits too while -// the watch is locked (it needs the master key). Moving never waits: +// the watch is locked (it needs the master key), but is applied at the +// next PIN without confirming (ChangeQueue.applyLocks). Moving never waits: // the order isn't protected and a position is absolute, so applying a // move twice can't hit another entry. // Replacing a code's contents runs CodeCheck with the entry's own options @@ -383,7 +384,8 @@ class ManageRun extends Jobs.Job { return fail(e.code == VaultError.LIMIT ? e.getErrorMessage() as String : "storage full"); } queued = true; - message = "Waiting for your PIN on the watch: " + _a.describe(); + 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; } diff --git a/source/settings/SettingsProcessor.mc b/source/settings/SettingsProcessor.mc index cf7684b..57fe411 100644 --- a/source/settings/SettingsProcessor.mc +++ b/source/settings/SettingsProcessor.mc @@ -65,11 +65,17 @@ class SettingsProcessor { } if (job.forms > 0) { var lines = job.lines; - var waiting = ChangeQueue.count(AppState.theVault()); + // "Require PIN" items apply by themselves at the next PIN. + var vault = AppState.theVault(); + var locks = ChangeQueue.countLocks(vault); + var waiting = ChangeQueue.count(vault) - locks; if (waiting > 0) { lines.add(waiting + (waiting == 1 ? " change waits" : " changes wait") + " for your PIN: menu, Phone changes."); } + if (locks > 0) { + lines.add(locks + (locks == 1 ? " entry locks" : " entries lock") + " when you next enter your PIN."); + } Results.save(lines); WatchUi.pushView(new InfoView("Results", lines, "results"), new InfoDelegate(), WatchUi.SLIDE_UP); Results.markSeen(); -- cgit v1.2.3