aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rwxr-xr-xdocker/scripts/screenshots.sh2
-rw-r--r--source/app/Fixtures.mc10
-rw-r--r--source/settings/ChangeQueue.mc76
-rw-r--r--source/settings/ManageForm.mc6
-rw-r--r--source/settings/SettingsProcessor.mc8
-rw-r--r--source/storage/KeyManager.mc41
-rw-r--r--source/storage/Vault.mc10
-rw-r--r--source/ui/Nav.mc20
-rw-r--r--source/ui/OtpView.mc11
-rw-r--r--source/ui/PinView.mc87
-rw-r--r--source/ui/QueueMenu.mc3
-rw-r--r--test/settings/ManageTest.mc40
-rw-r--r--test/storage/PinTest.mc62
-rw-r--r--test/storage/StorageSupport.mc21
-rw-r--r--tools/gen_fixture_wrap.py16
-rw-r--r--tools/lint_project.py2
-rw-r--r--tools/tests/test_gen_fixture_wrap.py8
-rw-r--r--tools/ui_flow.py15
18 files changed, 381 insertions, 57 deletions
diff --git a/docker/scripts/screenshots.sh b/docker/scripts/screenshots.sh
index 91477c4..c0d91d6 100755
--- a/docker/scripts/screenshots.sh
+++ b/docker/scripts/screenshots.sh
@@ -7,7 +7,7 @@
# plus <screen>-diff.png where a screen differs from
# test/golden/<DEVICE>/<screen>.png.
#
-# Uses the UI-test build (uitest.jungle): fake entries, PIN 123456 and a
+# Uses the UI-test build (uitest.jungle): fake entries, PIN 1234 and a
# frozen clock, so every screenshot is reproducible. tools/ui_flow.py
# drives the app and checks the screens. Then the app is launched again and
# its glance, which now shows the favourite's code (the first run sealed it),
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;
diff --git a/test/settings/ManageTest.mc b/test/settings/ManageTest.mc
index 0adf785..1f050c5 100644
--- a/test/settings/ManageTest.mc
+++ b/test/settings/ManageTest.mc
@@ -243,7 +243,9 @@ function manageLocksEntries(logger as Test.Logger) as Boolean {
vault.lock();
s = ManageFixtures.send(vault, ids[1], ManageAction.LOCK);
SettingsFixtures.run(vault, s);
- var waits = !ManageFixtures.isLocked(vault, ids[1]) && ChangeQueue.count(vault) == 1;
+ var waits = !ManageFixtures.isLocked(vault, ids[1]) && ChangeQueue.count(vault) == 1
+ && SettingsFixtures.contains(logger, s, ManageForm.STATUS,
+ "Locks when you next enter your PIN on the watch: Require PIN: " + ids[1]);
// In lower case: the same form again would just be "Already applied".
s = ManageFixtures.send(vault, ids[0].toLower(), ManageAction.LOCK);
SettingsFixtures.run(vault, s);
@@ -253,6 +255,42 @@ function manageLocksEntries(logger as Test.Logger) as Boolean {
return now && waits && already && ManageFixtures.isLocked(vault, ids[1]);
}
+// Waiting "Require PIN" changes apply at the next PIN without "Confirm
+// all" (the PIN screen calls applyLocks); other changes keep waiting, and
+// the results of a following "Confirm all" list the locks too.
+(:test)
+function manageQueuedLocksApplyAtNextPin(logger as Test.Logger) as Boolean {
+ var vault = ManageFixtures.vault();
+ var ids = vault.ids();
+ var bank = ManageFixtures.addLocked(vault, "Bank");
+ vault.lock();
+ var s = ManageFixtures.send(vault, ids[0], ManageAction.LOCK);
+ SettingsFixtures.run(vault, s);
+ s = ManageFixtures.send(vault, bank, ManageAction.DELETE);
+ SettingsFixtures.run(vault, s);
+ s = ManageFixtures.send(vault, ids[1], ManageAction.LOCK);
+ SettingsFixtures.run(vault, s);
+ var counted = ChangeQueue.count(vault) == 3 && ChangeQueue.countLocks(vault) == 2;
+ ChangeQueue.applyLocks(vault); // still locked: nothing happens
+ var untouched = ChangeQueue.count(vault) == 3 && !ManageFixtures.isLocked(vault, ids[0]);
+ StorageFixtures.unlock(vault, StorageFixtures.PIN);
+ ChangeQueue.applyLocks(vault);
+ var left = ChangeQueue.descriptions(vault);
+ var applied = ManageFixtures.isLocked(vault, ids[0]) && ManageFixtures.isLocked(vault, ids[1])
+ && left.size() == 1 && left[0].equals("Delete: " + bank + " Bank (alice@example.com)")
+ && ChangeQueue.countLocks(vault) == 0 && vault.ids().indexOf(bank) >= 0;
+ var job = ManageFixtures.confirm(vault);
+ var reported = job.lines.size() == 3 && job.lines[0].find(ids[0] + " ") == 0
+ && job.lines[0].find("now requires the PIN") != null && SettingsFixtures.hasLine(job.lines, "Deleted " + bank);
+ if (!reported) {
+ logger.error(job.lines.toString());
+ }
+ // The lines are reported once.
+ var again = ManageFixtures.confirm(vault);
+ return counted && untouched && applied && reported && vault.ids().indexOf(bank) < 0
+ && again.lines.size() == 1 && again.lines[0].equals("No changes were waiting.");
+}
+
// Changes that arrive while the queue is being applied stay queued.
(:test)
function manageQueueKeepsLateArrivals(logger as Test.Logger) as Boolean {
diff --git a/test/storage/PinTest.mc b/test/storage/PinTest.mc
index 0212936..55adaa2 100644
--- a/test/storage/PinTest.mc
+++ b/test/storage/PinTest.mc
@@ -1,5 +1,5 @@
// Author: cirakg & claude ai
-// Summary: Tests for PINs: create, unlock, wrong PIN, the lockout schedule on a fake clock, erase, change, reset, auto-lock.
+// Summary: Tests for PINs: create, unlock, wrong PIN, 4-digit rule, legacy PINs, the lockout schedule on a fake clock, erase, change, reset, auto-lock.
import Toybox.Lang;
import Toybox.Test;
@@ -39,6 +39,64 @@ function pinRejectsMalformedPins(logger as Test.Logger) as Boolean {
return KeyManager.isValidPin(Bytes.fromUtf8("0000")) && KeyManager.isValidPin(Bytes.fromUtf8("0123456789"));
}
+// A new PIN has exactly KeyManager.PIN_DIGITS digits; other lengths are
+// refused before any derivation.
+(:test)
+function pinSetRequiresFourDigits(logger as Test.Logger) as Boolean {
+ var bad = ["123", "12345", "123456", "0123456789", "12a4"];
+ for (var i = 0; i < bad.size(); i++) {
+ if (KeyManager.isValidNewPin(Bytes.fromUtf8(bad[i]))) {
+ logger.error("accepted new PIN '" + bad[i] + "'");
+ return false;
+ }
+ var code = 0;
+ try {
+ new Vault(new MemoryBackend(), new FakeTime()).beginSetPin(StorageFixtures.pin(bad[i]), StorageFixtures.ITERATIONS);
+ } catch (err instanceof VaultError) {
+ code = err.code;
+ }
+ if (code != VaultError.INVALID) {
+ logger.error("set PIN '" + bad[i] + "' gave code " + code);
+ return false;
+ }
+ }
+ return KeyManager.isValidNewPin(Bytes.fromUtf8("0000")) && KeyManager.isValidNewPin(Bytes.fromUtf8("9876"));
+}
+
+// Setting or changing the PIN stores the 4-digit marker; forgetting the
+// PIN removes it.
+(:test)
+function pinMarkerIsSetBySetAndChange(logger as Test.Logger) as Boolean {
+ var store = new MemoryBackend();
+ var vault = new Vault(store, new FakeTime());
+ StorageFixtures.setPin(vault, StorageFixtures.PIN);
+ var afterSet = StorageFixtures.pinMarked(store) && !vault.isLegacyPin();
+ store.deleteValue(KeyManager.PIN_LENGTH_KEY);
+ var legacyWithout = vault.isLegacyPin();
+ StorageFixtures.setPin(vault, StorageFixtures.OTHER_PIN); // change while unlocked
+ var afterChange = StorageFixtures.pinMarked(store) && !vault.isLegacyPin();
+ vault.resetPin();
+ return afterSet && legacyWithout && afterChange && store.getValue(KeyManager.PIN_LENGTH_KEY) == null
+ && !vault.isLegacyPin();
+}
+
+// A 6-digit PIN from before the 4-digit rule still unlocks (and a wrong
+// one fails without an exception); a new 4-digit PIN then replaces it.
+(:test)
+function pinLegacySixDigitWrapStillUnlocks(logger as Test.Logger) as Boolean {
+ var store = new MemoryBackend();
+ StorageFixtures.setLegacyPin(store, StorageFixtures.LEGACY_PIN);
+ var vault = new Vault(store, new FakeTime());
+ var legacy = vault.hasPin() && vault.isLegacyPin();
+ var wrong = !StorageFixtures.unlock(vault, "2468101");
+ var right = StorageFixtures.unlock(vault, StorageFixtures.LEGACY_PIN) && vault.session.isUnlocked();
+ StorageFixtures.setPin(vault, StorageFixtures.PIN);
+ vault.lock();
+ var oldFails = !StorageFixtures.unlock(vault, StorageFixtures.LEGACY_PIN);
+ var newWorks = StorageFixtures.unlock(vault, StorageFixtures.PIN);
+ return legacy && wrong && right && !vault.isLegacyPin() && oldFails && newWorks;
+}
+
(:test)
function lockoutDelaySchedule(logger as Test.Logger) as Boolean {
var expected = [0, 0, 0, 0, 0, 30, 60, 120, 240, 480, 960, 1920, 3600, 3600, 3600];
@@ -170,7 +228,7 @@ function pinChangeKeepsEntries(logger as Test.Logger) as Boolean {
vault.lock();
var lockedCode = 0;
try {
- vault.beginSetPin(StorageFixtures.pin("999999"), StorageFixtures.ITERATIONS);
+ vault.beginSetPin(StorageFixtures.pin("9999"), StorageFixtures.ITERATIONS);
} catch (err instanceof VaultError) {
lockedCode = err.code;
}
diff --git a/test/storage/StorageSupport.mc b/test/storage/StorageSupport.mc
index 51a0c7c..5f2ead3 100644
--- a/test/storage/StorageSupport.mc
+++ b/test/storage/StorageSupport.mc
@@ -143,8 +143,9 @@ class FakeTime extends TimeSource {
(:test)
module StorageFixtures {
- const PIN = "246810";
- const OTHER_PIN = "135799";
+ const PIN = "2468"; // KeyManager.PIN_DIGITS digits, like every new PIN
+ const OTHER_PIN = "1357";
+ const LEGACY_PIN = "246810"; // a 6-digit PIN from before the 4-digit rule
const ITERATIONS = 2000; // Kdf.MIN_ITERATIONS: the cheapest allowed
// A fake TOTP entry (the secret is the RFC 4226 test key, not a real one).
@@ -205,6 +206,22 @@ module StorageFixtures {
vault.finishSetPin(job);
}
+ // Store a wrap the way versions before the 4-digit rule did: any 4 to
+ // 10 digits and no PIN-length marker. The session stays locked.
+ function setLegacyPin(store as MemoryBackend, text as String) as Void {
+ var keyManager = new KeyManager(store);
+ var job = new PinJob(pin(text), new [KeyManager.SALT_BYTES]b, ITERATIONS);
+ Jobs.runToCompletion(job, Kdf.ITERATIONS_PER_STEP);
+ keyManager.storeWrap(job, keyManager.newMasterKey());
+ store.deleteValue(KeyManager.PIN_LENGTH_KEY);
+ }
+
+ // True when the store holds the 4-digit PIN marker.
+ function pinMarked(store as MemoryBackend) as Boolean {
+ var marker = store.getValue(KeyManager.PIN_LENGTH_KEY);
+ return marker instanceof Number && marker == KeyManager.PIN_DIGITS;
+ }
+
// One unlock attempt; true when the PIN was right.
function unlock(vault as Vault, text as String) as Boolean {
var job = vault.beginUnlock(pin(text));
diff --git a/tools/gen_fixture_wrap.py b/tools/gen_fixture_wrap.py
index c389fea..6eb207a 100644
--- a/tools/gen_fixture_wrap.py
+++ b/tools/gen_fixture_wrap.py
@@ -1,16 +1,19 @@
#!/usr/bin/env python3
# Author: cirakg & claude ai
-# Summary: Compute the UI-test fixture's PIN wrap (fake PIN 123456) so the watch needn't run PBKDF2 at start-up.
+# Summary: Compute the UI-test fixture's PIN wrap (fake PIN 1234) so the watch needn't run PBKDF2 at start-up.
"""Print the fake PIN wrap that source/app/Fixtures.mc stores in the UI-test build.
The wrap follows docs/SECURITY.md ("PIN wrap, version 1") with fixed, fake
-inputs: PIN 123456, salt 10..1f, IV 20..2f, master key 40..5f, 2000
+inputs: PIN 1234, salt 10..1f, IV 20..2f, master key 40..5f, 2000
PBKDF2 iterations. Deriving the PIN key on the watch in one event trips the
watchdog, and the UI tests need the store ready before the first screen, so
the fixture stores this precomputed value instead. The app's own unlock code
then checks it like any other wrap, which also cross-checks the format.
+The fixture also stores the 4-digit PIN marker (KeyManager.PIN_LENGTH_KEY =
+PIN_DIGITS), so the PIN isn't treated as a legacy 4-to-10-digit one.
-Usage: gen_fixture_wrap.py (prints FIXTURE_WRAP and FIXTURE_MASTER_KEY in hex)
+Usage: gen_fixture_wrap.py (prints FIXTURE_PIN, FIXTURE_WRAP and FIXTURE_MASTER_KEY in hex,
+ and the marker the fixture stores)
"""
from __future__ import annotations
@@ -20,7 +23,9 @@ import hmac
import struct
import subprocess
-PIN = b"123456"
+PIN = b"1234"
+PIN_DIGITS = 4 # source/storage/KeyManager.mc: every new PIN, and the marker value
+PIN_LENGTH_KEY = "wl" # source/storage/KeyManager.mc
ITERATIONS = 2000
SALT = bytes(range(0x10, 0x20))
IV = bytes(range(0x20, 0x30))
@@ -65,8 +70,11 @@ def fixture_wrap() -> bytes:
def main() -> None:
+ assert len(PIN) == PIN_DIGITS
+ print("FIXTURE_PIN =", PIN.decode())
print("FIXTURE_WRAP =", fixture_wrap().hex())
print("FIXTURE_MASTER_KEY =", MASTER_KEY.hex())
+ print(f"marker: {PIN_LENGTH_KEY} = {PIN_DIGITS}")
if __name__ == "__main__":
diff --git a/tools/lint_project.py b/tools/lint_project.py
index e02215b..04d525f 100644
--- a/tools/lint_project.py
+++ b/tools/lint_project.py
@@ -42,7 +42,7 @@ AUTHOR = "cirakg & claude ai"
HEADER_LINES = 15
DIAG_FILE = Path("source/util/Diag.mc")
# Folders that hold local tool state or generated output, never project files.
-SKIP_DIRS = {".git", ".cache", "build", "node_modules", "__pycache__", ".pytest_cache", ".mypy_cache", ".ruff_cache"}
+SKIP_DIRS = {".git", ".cache", ".claude", "build", "node_modules", "__pycache__", ".pytest_cache", ".mypy_cache", ".ruff_cache"}
# Written by a tool that would drop a hand-added header on the next run.
GENERATED = {Path("docker/docs/package-lock.json")}
diff --git a/tools/tests/test_gen_fixture_wrap.py b/tools/tests/test_gen_fixture_wrap.py
index 1c72e66..c7b11d5 100644
--- a/tools/tests/test_gen_fixture_wrap.py
+++ b/tools/tests/test_gen_fixture_wrap.py
@@ -31,3 +31,11 @@ def test_hkdf_matches_rfc5869_case_3() -> None:
def test_generated_wrap_matches_the_fixture() -> None:
assert gen_fixture_wrap.fixture_wrap().hex() == fixture_constant("FIXTURE_WRAP")
assert gen_fixture_wrap.MASTER_KEY.hex() == fixture_constant("FIXTURE_MASTER_KEY")
+
+
+def test_fixture_pin_has_the_new_pin_length() -> None:
+ assert gen_fixture_wrap.PIN.decode() == fixture_constant("FIXTURE_PIN")
+ assert len(gen_fixture_wrap.PIN) == gen_fixture_wrap.PIN_DIGITS
+ key_manager = (ROOT / "source" / "storage" / "KeyManager.mc").read_text(encoding="utf-8")
+ assert f"static const PIN_DIGITS = {gen_fixture_wrap.PIN_DIGITS};" in key_manager
+ assert f'static const PIN_LENGTH_KEY = "{gen_fixture_wrap.PIN_LENGTH_KEY}";' in key_manager
diff --git a/tools/ui_flow.py b/tools/ui_flow.py
index a17d435..8c08caf 100644
--- a/tools/ui_flow.py
+++ b/tools/ui_flow.py
@@ -14,7 +14,7 @@ the screen. Watches without a button for an action use touch instead (Venu
Sq 2 has no UP/DOWN and no select button).
The app (UI-test build, source/util/TestMode.mc) starts with fake entries,
-the PIN 123456 and a frozen clock, and prints "DIAG shown <screen>" after
+the PIN 1234 and a frozen clock, and prints "DIAG shown <screen>" after
drawing each screen, so every step waits for the screen it expects instead
of sleeping. Menus are native Menu2 views that print nothing; for those the
script waits a fixed time.
@@ -65,10 +65,10 @@ MENU_SETTLE_S = 1.5 # Menu2 has no "shown" line
SETTLE_S = 0.4 # after a "shown" line, before the screenshot
CLICK_PAUSE_S = 0.25
HOLD_S = 1.6 # long enough for a hold (menu) on every device
-FIXTURE_PIN = "123456" # source/app/Fixtures.mc
-WRONG_PIN = "000000"
+FIXTURE_PIN = "1234" # source/app/Fixtures.mc
+WRONG_PIN = "0000"
FAILURES_UNTIL_WAIT = 5 # source/storage/Lockout.mc: the 5th failure starts a delay
-SELECTOR_OK = 10 # source/ui/PinView.mc: selector positions 0-9, then OK
+SELECTOR_DIGITS = 10 # source/ui/PinView.mc: selector positions 0-9 (no OK; the 4th digit submits)
GYM_CARD = "5901234123457" # source/app/Fixtures.mc: EAN-13 with its check digit
MEMBER_QR = ( # source/app/Fixtures.mc: a version 6 QR
"https://example.com/member/fake-0042?name=Alice%20Example&card=0000111122223333&valid=2027-12-31&level=gold"
@@ -298,12 +298,11 @@ class Flow:
value, xy = item.split("=")
x, y = xy.split(",")
centres[int(value)] = (int(x), int(y))
- for digit in pin:
+ for digit in pin: # the 4th digit submits
self.sim.tap(*centres[int(digit)])
- self.sim.tap(*centres[-2]) # OK
return
- for position in [int(d) for d in pin] + [SELECTOR_OK]:
- steps = (position - self.selector) % (SELECTOR_OK + 1)
+ for position in [int(d) for d in pin]:
+ steps = (position - self.selector) % SELECTOR_DIGITS
for _ in range(steps):
self.sim.press("down")
self.selector = position