diff options
| author | cirakg <cirakg@cirakg.xyz> | 2026-10-25 12:46:42 +0100 |
|---|---|---|
| committer | cirakg <cirakg@cirakg.xyz> | 2026-10-25 12:46:42 +0100 |
| commit | 096b5a4d17c85fa5f87db3e72364e9c3506d1e64 (patch) | |
| tree | 9846544fe7d76d849df4a1ee2058f4847f674e7e /source | |
| parent | e0384274bbeab999279bdc35bdddbd8a3a451aeb (diff) | |
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 <noreply@anthropic.com>
Diffstat (limited to 'source')
| -rw-r--r-- | source/app/Fixtures.mc | 10 | ||||
| -rw-r--r-- | source/settings/ChangeQueue.mc | 76 | ||||
| -rw-r--r-- | source/settings/ManageForm.mc | 6 | ||||
| -rw-r--r-- | source/settings/SettingsProcessor.mc | 8 | ||||
| -rw-r--r-- | source/storage/KeyManager.mc | 41 | ||||
| -rw-r--r-- | source/storage/Vault.mc | 10 | ||||
| -rw-r--r-- | source/ui/Nav.mc | 20 | ||||
| -rw-r--r-- | source/ui/OtpView.mc | 11 | ||||
| -rw-r--r-- | source/ui/PinView.mc | 87 | ||||
| -rw-r--r-- | source/ui/QueueMenu.mc | 3 |
10 files changed, 234 insertions, 38 deletions
diff --git a/source/app/Fixtures.mc b/source/app/Fixtures.mc index 7c21d9a..01b15a8 100644 --- a/source/app/Fixtures.mc +++ b/source/app/Fixtures.mc @@ -9,8 +9,9 @@ // FIXTURE_WRAP is that PIN's wrap of FIXTURE_MASTER_KEY, precomputed by // tools/gen_fixture_wrap.py (fake salt, IV and key): deriving the PIN key on // the watch in one event trips the watchdog, so the fixture stores the -// result. Unlocking it with the app's own code also cross-checks the wrap -// format against the Python implementation (test/storage/PinTest.mc). +// result, with the 4-digit PIN marker (KeyManager.PIN_LENGTH_KEY). +// Unlocking it with the app's own code also cross-checks the wrap format +// against the Python implementation (test/storage/PinTest.mc). import Toybox.Application; import Toybox.Lang; @@ -18,8 +19,8 @@ import Toybox.Lang; (:debug) module Fixtures { - const FIXTURE_PIN = "123456"; - const FIXTURE_WRAP = "101112131415161718191a1b1c1d1e1f000007d001202122232425262728292a2b2c2d2e2f857b961d4854e06740f602627bfb952efaac5383e800768221bf3a7b292ee881e2ce2f76b9dd744b2ac32322473df351929bc6dfcbfc2941527ae8b90614a8a6a6bd0f72d33161bafd16b1039caa670c"; // lint: allow-literal (fake fixture) + const FIXTURE_PIN = "1234"; + const FIXTURE_WRAP = "101112131415161718191a1b1c1d1e1f000007d001202122232425262728292a2b2c2d2e2f133549536635b7a7ae3add45a59c42953d4c666538390a99305164dba9c99961e7148e1c561b6cfc70cfde7f9e3e59179464ce65bf91dd08807ff8a52027d77c90c2b692ca22fe4cbd3fb20ffb440137"; // lint: allow-literal (fake fixture) const FIXTURE_MASTER_KEY = "404142434445464748494a4b4c4d4e4f505152535455565758595a5b5c5d5e5f"; // lint: allow-literal (fake fixture) // Erase all app storage (UI-test build only). @@ -33,6 +34,7 @@ module Fixtures { function load(vault as Vault) as Void { if (TestMode.ON && !vault.hasPin()) { Application.Storage.setValue(KeyManager.WRAP_KEY, Bytes.fromHex(FIXTURE_WRAP)); + Application.Storage.setValue(KeyManager.PIN_LENGTH_KEY, KeyManager.PIN_DIGITS); vault.session.unlock(Bytes.fromHex(FIXTURE_MASTER_KEY)); } var rfcKey = "12345678901234567890"; // lint: allow-literal (RFC 4226 test key) 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<String> = [] as Array<String>; + 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<String>; + 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() + } + } + 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<String> { + var lines = _lockLines; + _lockLines = [] as Array<String>; + 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(); diff --git a/source/storage/KeyManager.mc b/source/storage/KeyManager.mc index 9b2b3c6..2dd3712 100644 --- a/source/storage/KeyManager.mc +++ b/source/storage/KeyManager.mc @@ -15,6 +15,12 @@ // and iteration count are in the aad, so changing either fails the MAC. // Changing the PIN re-wraps the same LMK; entries aren't re-encrypted. // +// A new PIN has exactly PIN_DIGITS (4) digits and is stored with the +// marker "wl" = PIN_DIGITS next to the wrap (the wrap format is unchanged). +// A wrap without the marker is a legacy PIN of 4 to 10 digits from an +// earlier version: it still unlocks, and the PIN screen then asks for a new +// 4-digit PIN (isLegacyPin). +// // PBKDF2 runs as a PinJob (a Jobs.Job) because one derivation is calibrated // to about a second, far beyond one event's watchdog budget. @@ -28,8 +34,13 @@ class KeyManager { static const KEY_BYTES = 32; static const SALT_BYTES = 16; static const WRAP_LABEL = "OTPW-WRAP-1"; + // Every new PIN has exactly this many digits. + static const PIN_DIGITS = 4; + // Legacy PINs (set before the 4-digit rule) have 4 to 10 digits. static const MIN_PIN_DIGITS = 4; static const MAX_PIN_DIGITS = 10; + // The marker of a PIN set with PIN_DIGITS digits (see the header). + static const PIN_LENGTH_KEY = "wl"; private var _store as Persist.Backend; private var _deviceKeys as SealedBox.Keys? = null; @@ -113,12 +124,20 @@ class KeyManager { return _store.getValue(WRAP_KEY) != null; } + // True when the stored PIN predates the 4-digit rule (no marker), so it + // may have 4 to 10 digits and should be replaced by a 4-digit one. + function isLegacyPin() as Boolean { + var marker = _store.getValue(PIN_LENGTH_KEY); + return hasPin() && !(marker instanceof Number && marker == PIN_DIGITS); + } + // A fresh random master key (when the first PIN is created). function newMasterKey() as ByteArray { return Cryptography.randomBytes(KEY_BYTES); } - // True for 4 to 10 ASCII digits. + // True for 4 to 10 ASCII digits: any PIN that can unlock, legacy + // lengths included. static function isValidPin(pin as ByteArray) as Boolean { if (pin.size() < MIN_PIN_DIGITS || pin.size() > MAX_PIN_DIGITS) { return false; @@ -131,6 +150,11 @@ class KeyManager { return true; } + // True for exactly PIN_DIGITS ASCII digits: what a new PIN must be. + static function isValidNewPin(pin as ByteArray) as Boolean { + return pin.size() == PIN_DIGITS && isValidPin(pin); + } + // The derivation for checking `pin` against the stored wrap. Throws // NO_PIN when no PIN is set, DAMAGED when the wrap is malformed. function unlockJob(pin as ByteArray) as PinJob { @@ -149,8 +173,12 @@ class KeyManager { return new PinJob(pin, wrap.slice(0, SALT_BYTES), iterations); } - // The derivation for a new PIN, with a fresh salt. + // The derivation for a new PIN, with a fresh salt. Throws VaultError + // INVALID unless the PIN has exactly PIN_DIGITS digits. function newPinJob(pin as ByteArray, iterations as Number) as PinJob { + if (!isValidNewPin(pin)) { + throw new VaultError(VaultError.INVALID, "PIN: " + PIN_DIGITS + " digits"); + } if (iterations < Kdf.MIN_ITERATIONS || iterations > Kdf.MAX_ITERATIONS) { throw new Lang.InvalidValueException("PIN: iteration count out of range"); } @@ -177,7 +205,8 @@ class KeyManager { } // After a finished newPinJob: wrap masterKey under the new PIN and store - // it, replacing any previous wrap. Wipes the PIN key, not masterKey. + // it, replacing any previous wrap, and mark the PIN as a 4-digit one. + // Wipes the PIN key, not masterKey. function storeWrap(job as PinJob, masterKey as ByteArray) as Void { var keys = job.keys(); var wrap = Bytes.copy(job.salt); @@ -186,11 +215,15 @@ class KeyManager { keys.wipe(); job.wipe(); _store.setValue(WRAP_KEY, wrap); + // After the wrap: a crash in between leaves the PIN unmarked, which + // only means being asked for a new PIN once more. + _store.setValue(PIN_LENGTH_KEY, PIN_DIGITS); } // Forget the PIN (reset or erase). The master key is then gone for good. function deleteWrap() as Void { _store.deleteValue(WRAP_KEY); + _store.deleteValue(PIN_LENGTH_KEY); } private function wrapAad(job as PinJob) as ByteArray { @@ -211,7 +244,7 @@ class PinJob extends Jobs.Job { function initialize(pin as ByteArray, salt as ByteArray, iterations as Number) { Job.initialize(); if (!KeyManager.isValidPin(pin)) { - throw new Lang.InvalidValueException("PIN: 4 to 10 digits"); + throw new Lang.InvalidValueException("PIN: " + KeyManager.MIN_PIN_DIGITS + " to " + KeyManager.MAX_PIN_DIGITS + " digits"); } self.salt = salt; self.iterations = iterations; diff --git a/source/storage/Vault.mc b/source/storage/Vault.mc index fcd973f..2890477 100644 --- a/source/storage/Vault.mc +++ b/source/storage/Vault.mc @@ -287,6 +287,11 @@ class Vault { return keyManager.hasPin(); } + // True when the PIN predates the 4-digit rule (KeyManager.isLegacyPin). + function isLegacyPin() as Boolean { + return keyManager.isLegacyPin(); + } + // Start checking a PIN: counts the attempt first (README 7.6), then // returns the derivation to run. Throws NO_PIN, LOCKED_OUT or DAMAGED. function beginUnlock(pin as ByteArray) as PinJob { @@ -313,8 +318,9 @@ class Vault { return false; } - // Start setting a PIN: the first PIN, or a new one while unlocked. - // Throws LOCKED when a PIN exists and the session is locked. + // Start setting a PIN (exactly KeyManager.PIN_DIGITS digits): the first + // PIN, or a new one while unlocked. Throws LOCKED when a PIN exists and + // the session is locked, INVALID for any other length. function beginSetPin(pin as ByteArray, iterations as Number) as PinJob { if (keyManager.hasPin() && !session.isUnlocked()) { throw new VaultError(VaultError.LOCKED, "locked"); diff --git a/source/ui/Nav.mc b/source/ui/Nav.mc index 222386a..af08d4a 100644 --- a/source/ui/Nav.mc +++ b/source/ui/Nav.mc @@ -2,13 +2,31 @@ // Summary: Navigation between screens (README 13.2): opening entries, asking for the PIN first when needed, the menu. import Toybox.Lang; +import Toybox.System; import Toybox.WatchUi; module Nav { + // A second open request within this time is the same tap delivered + // twice (as START in onKey and as onTap) and is ignored. + const OPEN_DEBOUNCE_MS = 500; + var _lastOpenMs as Number? = null; + // Open an entry from the list: its detail screen, or the PIN screen first - // when it is locked and the session is locked. + // when it is locked and the session is locked. Ignored while a PIN or + // detail screen is already on top, or right after another open, so one + // tap can't push two screens. function openEntry(id as String) as Void { + var top = WatchUi.getCurrentView()[0]; + if (top instanceof PinView || top instanceof OtpView || top instanceof CodeView) { + return; + } + var now = System.getTimer(); + var last = _lastOpenMs; + if (last != null && now - last >= 0 && now - last < OPEN_DEBOUNCE_MS) { + return; + } + _lastOpenMs = now; var vault = AppState.theVault(); var header = vault.header(id); if (header == null) { diff --git a/source/ui/OtpView.mc b/source/ui/OtpView.mc index faf94d0..1c0c0bd 100644 --- a/source/ui/OtpView.mc +++ b/source/ui/OtpView.mc @@ -258,6 +258,11 @@ class OtpView extends WatchUi.View { // Input on the OTP detail: START or a tap gives the next HOTP code; BACK closes. class OtpDelegate extends WatchUi.BehaviorDelegate { + // One tap can arrive twice (as START and as a tap); a second "next + // code" within this time is ignored so the counter moves only once. + static const NEXT_DEBOUNCE_MS = 500; + private var _lastNextMs as Number? = null; + function initialize() { BehaviorDelegate.initialize(); } @@ -294,6 +299,12 @@ class OtpDelegate extends WatchUi.BehaviorDelegate { if (v == null || !v.isHotp()) { return; } + var now = System.getTimer(); + var last = _lastNextMs; + if (last != null && now - last >= 0 && now - last < NEXT_DEBOUNCE_MS) { + return; + } + _lastNextMs = now; try { AppState.theVault().setCounter(v.header.id, v.header.counter + 1); } catch (e instanceof VaultError) { diff --git a/source/ui/PinView.mc b/source/ui/PinView.mc index 4502919..fa58bfe 100644 --- a/source/ui/PinView.mc +++ b/source/ui/PinView.mc @@ -2,11 +2,18 @@ // Summary: PIN entry for unlocking, creating and changing the PIN: touch keypad or button digit selector, lockout display. // // README 13.1 and 13.4: -// * Touch screens get a 3x4 keypad (digits, delete, OK). Watches without a -// touch screen get a digit selector: UP/DOWN change the digit, START -// adds it (or confirms on "OK"), BACK deletes. +// * A PIN has exactly 4 digits (KeyManager.PIN_DIGITS) and is submitted +// as soon as the 4th is entered; there is no OK key. +// * Touch screens get a keypad (1-9, delete, 0). The START button does +// nothing there: a tap can also arrive as START, which used to submit a +// PIN early. Watches without a touch screen get a digit selector: +// UP/DOWN change the digit, START adds it, BACK deletes. +// * A legacy PIN (4 to 10 digits, set before the 4-digit rule) is entered +// the old way, with an OK key (and START on touch screens, "OK" in the +// selector); after it unlocks, the screen asks for a new 4-digit PIN +// and then continues to what was opened. // * Entered digits show as dots. A wrong PIN vibrates and shows any wait. -// * Creating or changing the PIN asks twice and warns below 6 digits. +// * 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. // The typed digits are a ByteArray that is wiped after every attempt. @@ -23,18 +30,19 @@ class PinView extends WatchUi.View { static const MODE_CREATE = 1; static const MODE_CHANGE = 2; - // Selector positions 0-9 are digits; this one is "OK". + // Selector positions 0-9 are digits; this one is "OK" (legacy PINs only). static const SELECT_OK = 10; - // Keypad keys besides the digits. + // Keypad keys besides the digits (OK for legacy PINs only). static const KEY_DELETE = -1; static const KEY_OK = -2; - static const RECOMMENDED_DIGITS = 6; // Calibrate the PIN derivation to about this long on this watch. static const DERIVATION_TARGET_MS = 1000; static const CALIBRATION_SAMPLE = 200; var mode as Number; var busy as Boolean = false; + // Unlocking a legacy PIN: 4 to 10 digits, submitted with OK. + var legacy as Boolean = false; private var _onSuccess as Method() as Void or Null; private var _digits as ByteArray = []b; @@ -57,6 +65,7 @@ class PinView extends WatchUi.View { View.initialize(); mode = pinMode; _onSuccess = onSuccess; + legacy = pinMode == MODE_UNLOCK && AppState.theVault().isLegacyPin(); } function onShow() as Void { @@ -85,7 +94,8 @@ class PinView extends WatchUi.View { // ---- Input (called by PinDelegate) ---------------------------------- - // A keypad key: 0-9, KEY_DELETE or KEY_OK. + // A keypad key: 0-9, KEY_DELETE or KEY_OK (legacy PINs only). The + // PIN_DIGITS-th digit of a 4-digit PIN submits it. function press(key as Number) as Void { if (busy || waitSeconds() > 0) { return; @@ -93,17 +103,23 @@ class PinView extends WatchUi.View { if (key == KEY_DELETE) { deleteDigit(); } else if (key == KEY_OK) { - submit(); - } else if (_digits.size() < KeyManager.MAX_PIN_DIGITS) { + if (legacy) { + submit(); + } + } else if (_digits.size() < (legacy ? KeyManager.MAX_PIN_DIGITS : KeyManager.PIN_DIGITS)) { _digits.add(0x30 + key); _message = null; + if (!legacy && _digits.size() == KeyManager.PIN_DIGITS) { + submit(); + } } WatchUi.requestUpdate(); } - // Selector: UP/DOWN. + // Selector: UP/DOWN, over the digits (and "OK" for a legacy PIN). function cycle(delta as Number) as Void { - _selector = ((_selector + delta) % (SELECT_OK + 1) + SELECT_OK + 1) % (SELECT_OK + 1); + var positions = legacy ? SELECT_OK + 1 : SELECT_OK; + _selector = ((_selector + delta) % positions + positions) % positions; WatchUi.requestUpdate(); } @@ -146,19 +162,16 @@ class PinView extends WatchUi.View { // ---- Checking and setting the PIN ------------------------------------ + // A complete entry: check it (unlock) or keep it for the repeat (new PIN). private function submit() as Void { - if (!KeyManager.isValidPin(_digits)) { - showMessage("4 to 10 digits", true); - return; + if (!(legacy ? KeyManager.isValidPin(_digits) : KeyManager.isValidNewPin(_digits))) { + return; // a legacy OK with fewer than 4 digits } if (mode == MODE_UNLOCK) { startUnlock(); } else if (_first == null) { _first = _digits; _digits = []b; - if (_first.size() < RECOMMENDED_DIGITS) { - showMessage("6+ digits are safer", false); - } } else if (Bytes.equal(_first as ByteArray, _digits)) { startSetPin(); } else { @@ -181,6 +194,7 @@ class PinView extends WatchUi.View { clearDigits(); if (e.code == VaultError.NO_PIN) { mode = MODE_CREATE; + legacy = false; showMessage("Set a PIN first", false); } else if (e.code != VaultError.LOCKED_OUT) { showMessage("PIN data damaged", true); @@ -201,7 +215,7 @@ class PinView extends WatchUi.View { } catch (e instanceof VaultError) { Bytes.wipe(_jobPin); _jobPin = null; - showMessage("Unlock first", true); + showMessage(e.code == VaultError.LOCKED ? "Unlock first" : KeyManager.PIN_DIGITS + " digits", true); return; } startJob(method(:onSetDone)); @@ -225,8 +239,13 @@ class PinView extends WatchUi.View { 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(); return; } @@ -249,9 +268,23 @@ class PinView extends WatchUi.View { vault.finishSetPin(_job as PinJob); _job = null; vault.promotePending(); + ChangeQueue.applyLocks(vault); succeed(); } + // After a legacy PIN unlocked: the same screen asks for a new 4-digit + // PIN, and onSuccess runs once that is set. BACK leaves it unlocked + // with the old PIN, which asks again at the next unlock. + private function askForNewPin() as Void { + AppState.activity(); + legacy = false; + mode = MODE_CHANGE; + if (_selector == SELECT_OK) { + _selector = 0; + } + showMessage("Set a new " + KeyManager.PIN_DIGITS + "-digit PIN", false); + } + private function succeed() as Void { AppState.activity(); var next = _onSuccess; @@ -307,8 +340,8 @@ class PinView extends WatchUi.View { 2 * Ui.halfWidthAt(dc, y)), Graphics.TEXT_JUSTIFY_CENTER); y += dc.getFontHeight(Graphics.FONT_SMALL) + 4; - // Dots: entered digits filled, at least RECOMMENDED_DIGITS shown. - var slots = _digits.size() > RECOMMENDED_DIGITS ? _digits.size() : RECOMMENDED_DIGITS; + // Dots: entered digits filled, PIN_DIGITS slots (more for a long legacy PIN). + var slots = _digits.size() > KeyManager.PIN_DIGITS ? _digits.size() : KeyManager.PIN_DIGITS; var r = h / 60 > 3 ? h / 60 : 3; var step = r * 3; var x = cx - (slots - 1) * step / 2; @@ -367,6 +400,9 @@ class PinView extends WatchUi.View { _keys = [] as Array<Array<Number>>; var log = ""; for (var i = 0; i < 12; i++) { + if (values[i] == KEY_OK && !legacy) { + continue; // no OK key: the 4th digit submits; the slot stays empty + } var kx = left + (i % 3) * keyW; var ky = top + (i / 3) * keyH; _keys.add([kx, ky, keyW, keyH, values[i] as Number]); @@ -468,11 +504,16 @@ class PinDelegate extends WatchUi.BehaviorDelegate { return true; } + // START on a touch screen submits only a legacy PIN. Otherwise it does + // nothing: one tap can arrive both here and in onTap, and submitting + // the digits typed so far was a wrong PIN. function onKey(event as WatchUi.KeyEvent) as Boolean { var v = view(); if (v != null && Ui.isTouch() && event.getKey() == WatchUi.KEY_ENTER) { - v.markChanged(); - v.press(PinView.KEY_OK); + if (v.legacy) { + v.markChanged(); + v.press(PinView.KEY_OK); + } return true; } return false; diff --git a/source/ui/QueueMenu.mc b/source/ui/QueueMenu.mc index 880ca7c..3f9161d 100644 --- a/source/ui/QueueMenu.mc +++ b/source/ui/QueueMenu.mc @@ -4,7 +4,8 @@ // Confirming always asks for the PIN, even while the watch is unlocked: // 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.) The changes are then applied by a +// 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. import Toybox.Lang; |
