From 375ae2c6b9f6c9ad6c86fdd7c2178eb41cc30919 Mon Sep 17 00:00:00 2001 From: maggo83 Date: Thu, 12 Mar 2026 08:25:36 +0100 Subject: [PATCH 1/3] increase button and font size --- scenarios/MockUI/src/MockUI/basic/locked_menu.py | 2 +- scenarios/MockUI/src/MockUI/basic/ui_consts.py | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/scenarios/MockUI/src/MockUI/basic/locked_menu.py b/scenarios/MockUI/src/MockUI/basic/locked_menu.py index 75b8b647..4ee299f5 100644 --- a/scenarios/MockUI/src/MockUI/basic/locked_menu.py +++ b/scenarios/MockUI/src/MockUI/basic/locked_menu.py @@ -83,7 +83,7 @@ def __init__(self, parent): lb = lv.label(b) lb.center() lb.set_text(k) - lb.set_style_text_font(lv.font_montserrat_22, 0) + lb.set_style_text_font(lv.font_montserrat_28, 0) b.add_event_cb(lambda e, d=k: self._on_digit(e, d), lv.EVENT.CLICKED, None) def _update_mask(self): diff --git a/scenarios/MockUI/src/MockUI/basic/ui_consts.py b/scenarios/MockUI/src/MockUI/basic/ui_consts.py index dee0925f..c601e282 100644 --- a/scenarios/MockUI/src/MockUI/basic/ui_consts.py +++ b/scenarios/MockUI/src/MockUI/basic/ui_consts.py @@ -4,8 +4,8 @@ # --- Menu / button sizes (1.5× scaled for 800×480 touch target) --- BTN_HEIGHT = const(75) # menu button height (px) BTN_WIDTH = const(100) # menu button width (percent of screen width) -PIN_BTN_HEIGHT = const(75) # lock screen PIN keypad button height (px) -PIN_BTN_WIDTH = const(100) # lock screen PIN keypad button width (px) +PIN_BTN_HEIGHT = const(85) # lock screen PIN keypad button height (px) +PIN_BTN_WIDTH = const(115) # lock screen PIN keypad button width (px) BACK_BTN_HEIGHT = const(70) # back button height (px) BACK_BTN_WIDTH = const(48) # back button width (px) MENU_PCT = const(100) From 7ad11df30202f98428ab53f872d59f672e1a29b6 Mon Sep 17 00:00:00 2001 From: maggo83 Date: Thu, 12 Mar 2026 08:28:10 +0100 Subject: [PATCH 2/3] Made lockscreen PIN button order random in order for evil maids not to be able to infer the pin from fingerprint patterns on the touchscreen --- manifests/mockui.py | 4 +- .../MockUI/src/MockUI/basic/locked_menu.py | 48 +++++++++++++++++-- 2 files changed, 45 insertions(+), 7 deletions(-) diff --git a/manifests/mockui.py b/manifests/mockui.py index ba57498e..c48b8dc0 100644 --- a/manifests/mockui.py +++ b/manifests/mockui.py @@ -1,7 +1,7 @@ # MockUI firmware manifest (hardware — STM32F469 Discovery) include('../f469-disco/manifests/disco.py') include('mockui-shared.py') -# platform.py and config_default.py needed for SDRAM init -freeze('../src', ('platform.py', 'config_default.py')) +# platform.py + config_default.py: SDRAM init; rng.py: PIN keypad shuffle +freeze('../src', ('platform.py', 'config_default.py', 'rng.py')) # boot.py and main.py entry points freeze('../scenarios/mockui_fw') diff --git a/scenarios/MockUI/src/MockUI/basic/locked_menu.py b/scenarios/MockUI/src/MockUI/basic/locked_menu.py index 4ee299f5..5c29e388 100644 --- a/scenarios/MockUI/src/MockUI/basic/locked_menu.py +++ b/scenarios/MockUI/src/MockUI/basic/locked_menu.py @@ -1,9 +1,44 @@ import lvgl as lv +import rng # TODO: clarify if this should be encapsulated in a general HW/GUI interface from .titled_screen import TitledScreen from .symbol_lib import BTC_ICONS from .ui_consts import PIN_BTN_WIDTH, PIN_BTN_HEIGHT +def _shuffle(items_or_count): + """Shuffle items using the hardware RNG. + + If *items_or_count* is an ``int`` *n*, returns a list of *n* shuffled + indices (a permutation of ``range(n)``). + + If *items_or_count* is a ``list``, shuffles it **in place** and returns + the list of source indices (a permutation of ``range(len(list))``) so + the caller can reconstruct the mapping if needed. The caller is + responsible for making a copy beforehand if the original order must be + retained — this avoids a forced allocation on memory-constrained devices. + """ + is_int = isinstance(items_or_count, int) + is_list = isinstance(items_or_count, list) + if is_int: + n = items_or_count + elif is_list: + items = items_or_count # mutate in place — caller copies beforehand if needed + n = len(items) + else: + raise TypeError("_shuffle expects int or list, got " + str(type(items_or_count))) + + idx_pool = list(range(n)) + result_idx = [0] * n + rand_bytes = rng.get_random_bytes(n) + + for i in range(n): + result_idx[i] = idx_pool.pop( rand_bytes[i] % len(idx_pool) ) + + if is_list: + items[:] = [items_or_count[i] for i in result_idx] + + return result_idx + class LockedMenu(TitledScreen): """Simple lock screen that accepts a numeric PIN to unlock the device.""" @@ -42,12 +77,15 @@ def __init__(self, parent): self.mask_lbl.set_style_text_align(lv.TEXT_ALIGN.CENTER, 0) self.mask_lbl.set_style_text_font(lv.font_montserrat_28, 0) - # keypad layout (3x4): 1..9, Del, 0, OK + # keypad layout (3x4): digits in randomised order, Del, and OK + chars = list("0123456789") + _shuffle(chars) # shuffles in place + keys = [ - ["1", "2", "3"], - ["4", "5", "6"], - ["7", "8", "9"], - ["Del", "0", "OK"], + [chars[0], chars[1], chars[2]], + [chars[3], chars[4], chars[5]], + [chars[6], chars[7], chars[8]], + ["Del", chars[9], "OK"], ] for row in keys: From a27356ba84aafe7d7ed4f2f2ec5527b1df1d5f44 Mon Sep 17 00:00:00 2001 From: maggo83 Date: Thu, 12 Mar 2026 13:14:40 +0100 Subject: [PATCH 3/3] Added unit tests and device tests for lock screen / PIN screen --- pytest.ini | 2 +- scenarios/MockUI/tests/test_locked_menu.py | 172 ++++++++++ .../tests_device/test_locked_menu_device.py | 300 ++++++++++++++++++ 3 files changed, 473 insertions(+), 1 deletion(-) create mode 100644 scenarios/MockUI/tests/test_locked_menu.py create mode 100644 scenarios/MockUI/tests_device/test_locked_menu_device.py diff --git a/pytest.ini b/pytest.ini index e5d11aff..f8fb2b63 100644 --- a/pytest.ini +++ b/pytest.ini @@ -1,6 +1,6 @@ [pytest] testpaths = scenarios/MockUI/tests -pythonpath = scenarios/MockUI/src +pythonpath = scenarios/MockUI/src src python_files = test_*.py python_classes = Test* python_functions = test_* diff --git a/scenarios/MockUI/tests/test_locked_menu.py b/scenarios/MockUI/tests/test_locked_menu.py new file mode 100644 index 00000000..1cf83870 --- /dev/null +++ b/scenarios/MockUI/tests/test_locked_menu.py @@ -0,0 +1,172 @@ +"""Unit tests for the locked menu's shuffle helper and SpecterState lock/unlock logic. + +All tests run entirely in the host Python environment — no device or LVGL +runtime required. The ``_shuffle`` function lives in ``locked_menu`` which +imports ``rng`` (from ``src/rng.py``). We add ``src/`` to ``sys.path`` +before importing so that ``rng`` resolves without hardware. +""" +import os +import sys + +import pytest + +# Make src/rng.py available to the import chain of locked_menu.py +_SRC_DIR = os.path.abspath( + os.path.join(os.path.dirname(__file__), "..", "..", "..", "src") +) +if _SRC_DIR not in sys.path: + sys.path.insert(0, _SRC_DIR) + +# Now we can safely import _shuffle (locked_menu's top-level ``import rng`` +# will resolve to src/rng.py which uses os.urandom on the host). +from MockUI.basic.locked_menu import _shuffle +from MockUI.stubs.device_state import SpecterState + +# A non-trivial PIN used throughout the lock/unlock tests. +# Must not be None (which would bypass the PIN check in SpecterState.unlock). +_TEST_PIN = "42" + +# Max attempts for randomness checks — probability of all producing natural +# order: (1/10!)^3 ≈ 10^-21 (scalar) and (1/4!)^3 ≈ 10^-8 (list). +_MAX_SHUFFLE_RETRIES = 3 + + +# --------------------------------------------------------------------------- +# _shuffle: scalar input +# --------------------------------------------------------------------------- + + +def test_shuffle_scalar_10_digits_covers_all(): + """_shuffle(10) must be a permutation of 0..9 and must differ from natural + order in at least one of _MAX_SHUFFLE_RETRIES attempts. + + Probability that all _MAX_SHUFFLE_RETRIES produce the identity permutation ≈ (1/10!)^_MAX_SHUFFLE_RETRIES + """ + natural = list(range(10)) + results = [_shuffle(10) for _ in range(_MAX_SHUFFLE_RETRIES)] + for result in results: + assert sorted(result) == natural, ( + f"not a permutation of range(10): {result}" + ) + assert any(r != natural for r in results), ( + f"All {_MAX_SHUFFLE_RETRIES} shuffles returned natural order [0..9] — shuffle appears broken." + ) + + +# --------------------------------------------------------------------------- +# _shuffle: list input +# --------------------------------------------------------------------------- + +def test_shuffle_list_returns_permutation_of_items(): + """_shuffle(list) shuffles in place, result is a permutation of original, + and at least one of _MAX_SHUFFLE_RETRIES attempts must actually change the order. + + Probability that all _MAX_SHUFFLE_RETRIES leave 6 items unchanged ≈ (1/6!)^_MAX_SHUFFLE_RETRIES. + """ + natural = ["a", "b", "c", "d", "e", "f"] + changed = False + for _ in range(_MAX_SHUFFLE_RETRIES): + items = natural[:] + _shuffle(items) + assert sorted(items) == sorted(natural), ( + f"shuffled items are not a permutation: {items}" + ) + assert len(items) == len(natural) + if items != natural: + changed = True + assert changed, ( + f"All {_MAX_SHUFFLE_RETRIES} list shuffles left the order unchanged — shuffle appears broken." + ) + + +def test_shuffle_list_indices_consistent(): + """items[i] after shuffle must equal snapshot[indices[i]] for all i.""" + items = list("0123456789") + snapshot = items[:] + indices = _shuffle(items) + reconstructed = [snapshot[i] for i in indices] + assert reconstructed == items, ( + f"indices inconsistent with in-place shuffle.\n" + f" items after: {items}\n" + f" reconstructed: {reconstructed}" + ) + + +def test_shuffle_list_mutates_in_place(): + """_shuffle must modify the list in place (not return a new one).""" + original = list("0123456789") + same_ref = original + _shuffle(original) + assert original is same_ref, "_shuffle must operate on the same list object" + + +def test_shuffle_list_indices_are_permutation(): + """Returned indices must be a permutation of range(len(input)).""" + items = ["x", "y", "z"] + indices = _shuffle(items) + assert sorted(indices) == list(range(len(items))), ( + f"indices are not a permutation of range({len(items)}): {indices}" + ) + + +# --------------------------------------------------------------------------- +# _shuffle: wrong type +# --------------------------------------------------------------------------- + +def test_shuffle_wrong_type_raises(): + """_shuffle with a non-int, non-list argument must raise TypeError.""" + with pytest.raises(TypeError): + _shuffle(3.14) + with pytest.raises(TypeError): + _shuffle("hello") + with pytest.raises(TypeError): + _shuffle((1, 2, 3)) + + +# --------------------------------------------------------------------------- +# _shuffle: randomness +# --------------------------------------------------------------------------- + +def test_shuffle_varies_with_different_calls(): + """Repeated calls must produce different results (RNG is live, not seeded). + + Probability of all 20 results being identical ≈ (1/10!)^19 ≈ 10^-133. + Test failure would indicate a fundamentally broken RNG. + """ + results = [tuple(_shuffle(10)) for _ in range(20)] + assert len(set(results)) > 1, ( + "All 20 shuffles produced the same permutation — RNG appears broken." + ) + + +# --------------------------------------------------------------------------- +# SpecterState: lock / unlock +# --------------------------------------------------------------------------- + +def test_lock_sets_is_locked(): + state = SpecterState() + assert not state.is_locked, "fresh state should not be locked" + state.lock() + assert state.is_locked, "state.lock() must set is_locked=True" + + +def test_unlock_correct_pin_returns_true_and_clears_lock(specter_state): + specter_state.pin = _TEST_PIN + specter_state.lock() + assert specter_state.is_locked + + result = specter_state.unlock(_TEST_PIN) + + assert result is True, "unlock with correct PIN must return True" + assert not specter_state.is_locked, "is_locked must be False after correct unlock" + + +def test_unlock_wrong_pin_returns_false_stays_locked(specter_state): + specter_state.pin = _TEST_PIN + specter_state.lock() + assert specter_state.is_locked + + result = specter_state.unlock("wrong") + + assert result is False, "unlock with wrong PIN must return False" + assert specter_state.is_locked, "is_locked must remain True after wrong unlock" diff --git a/scenarios/MockUI/tests_device/test_locked_menu_device.py b/scenarios/MockUI/tests_device/test_locked_menu_device.py new file mode 100644 index 00000000..b47d2e1a --- /dev/null +++ b/scenarios/MockUI/tests_device/test_locked_menu_device.py @@ -0,0 +1,300 @@ +"""On-device integration tests for the locked menu (PIN screen). + +Single scenario test covering all 8 checkpoints in one sequential run +to minimise board interactions and expensive setup/teardown. + +LockedMenu tree layout (for REPL navigation): + scr # SpecterGui (lv.screen) + └─ .content (child 2) + └─ .current_screen # LockedMenu (child 0 of content) + ├─ title_bar (child 0) + └─ body (child 1) + ├─ instr label (child 0) + ├─ mask_lbl (child 1) + ├─ row 0 (child 2) [digit, digit, digit] + ├─ row 1 (child 3) [digit, digit, digit] + ├─ row 2 (child 4) [digit, digit, digit] + └─ last row (child 5) [Del (icon), digit, OK (icon)] + +DeviceBar tree layout (for lock-button discovery): + screen[0] DeviceBar + ├─ [0] left_container + │ └─ [0] lock_btn ← target + ├─ [1] center_container + └─ [2] right_container + +Checkpoints (in order): + CP1 – lock button activates the PIN screen + CP2 – key order is shuffled (not "0123456789") [retry once on collision] + CP3 – entering a digit shows * in the mask (not the digit in clear) + CP4 – wrong PIN (empty buffer → OK) keeps device locked + CP5 – Del removes the last digit + CP6 – correct PIN unlocks the device + CP7/8 – re-locking gives a different key order [retry once on collision] +""" +import time + +import pytest + +from conftest import ( + _load_label, + _supported_lang_codes, + click_by_index, + disco_run, + ensure_main_menu, + find_labels, + screen_tree, + walk_with_path, +) + + +# --------------------------------------------------------------------------- +# Module-local helpers +# --------------------------------------------------------------------------- + +def _get_digit_order() -> list[str]: + """Return the 10 PIN-keypad digits in visual (tree BFS) order. + + Uses a local tree walk that includes single-character labels — the shared + ``find_labels()`` helper filters those out (len > 1). + """ + digits = [] + for _path, node in walk_with_path(screen_tree()): + text = node.get("text", "") + if len(text) == 1 and text in "0123456789": + digits.append(text) + return digits + + +def _get_mask_text() -> str: + """Read the current PIN mask label text directly from the device. + + Navigates the LVGL widget tree via get_child() indices to avoid relying on + the ``scr.current_screen`` Python attribute, which MicroPython's GC can + collect while the underlying LVGL C object survives. + + Path: scr.get_child(2) = content + .get_child(0) = current_screen (LockedMenu) + .get_child(1) = body + .get_child(1) = mask_lbl + """ + return disco_run( + "repl", "exec", + "print(scr.get_child(2).get_child(0).get_child(1).get_child(1).get_text())", + ) + + +def _get_device_pin() -> str: + """Read the configured PIN from live device state — never hardcoded.""" + return disco_run("repl", "exec", "print(specter_state.pin)") + +def _set_device_pin(pin: str) -> None: + """Set the configured PIN on the live device.""" + disco_run("repl", "exec", f"specter_state.pin = '{pin}'") + + +def _find_lock_btn_index() -> str: + """Return the tree index of the lock button (DeviceBar → left → lock_btn). + + Navigates children[0][0][0] of the screen tree dynamically so the test + is resilient to future layout refactors. + """ + tree = screen_tree() + try: + lock_btn = tree[0]["children"][0]["children"][0] + assert lock_btn.get("type", "") == "button", ( + f"Expected button at 0.0.0, got: {lock_btn.get('type')}" + ) + return "0.0.0" + except (IndexError, KeyError): + pass + + # Fallback: BFS search for first button in subtree 0.0 + for path, node in walk_with_path(screen_tree()): + if path.startswith("0.0") and node.get("type", "") == "button": + return path + + raise AssertionError("Could not locate lock button in screen tree") + + +def _click_digit(d: str, delay: float = 0.8) -> None: + """Click a PIN pad digit by its text label. + + Bypasses ``click_by_label``'s len > 1 filter — digits are single chars. + """ + disco_run("ui", "click", d) + time.sleep(delay) + + +def _click_del(delay: float = 0.8) -> None: + """Trigger the Del button via REPL send_event (icon-only, no text label). + + Del is child 0 of the last row (body.child(5).child(0)). + """ + out = disco_run( + "repl", "exec", + "import lvgl as lv; " + "body=scr.get_child(2).get_child(0).get_child(1); " + "row=body.get_child(5); " + "row.get_child(0).send_event(lv.EVENT.CLICKED, None); " + "print('OK')", + ) + assert out.strip().splitlines()[-1] == "OK", f"_click_del failed: {out!r}" + time.sleep(delay) + + +def _click_ok(delay: float = 1.2) -> None: + """Trigger the OK button via REPL send_event (icon-only, no text label). + + OK is child 2 of the last row (body.child(5).child(2)). + """ + out = disco_run( + "repl", "exec", + "import lvgl as lv; " + "body=scr.get_child(2).get_child(0).get_child(1); " + "row=body.get_child(5); " + "row.get_child(2).send_event(lv.EVENT.CLICKED, None); " + "print('OK')", + ) + assert out.strip().splitlines()[-1] == "OK", f"_click_ok failed: {out!r}" + time.sleep(delay) + + +def _lock_device() -> bool: + lock_idx = _find_lock_btn_index() + click_by_index(lock_idx) + return _locked_screen_visible() + +def _unlock_device() -> bool: + pin = _get_device_pin() + for d in pin: + _click_digit(d) + _click_ok() + return _main_menu_visible() + + +def _locked_screen_visible() -> bool: + labels = find_labels() + return any( + t in labels + for t in _load_label("LOCKED_MENU_TITLE", *_supported_lang_codes()) + ) + + +def _main_menu_visible() -> bool: + labels = find_labels() + return any( + t in labels + for t in _load_label("MAIN_MENU_TITLE", *_supported_lang_codes()) + ) + +# --------------------------------------------------------------------------- +# Module fixture — runs once before the tests in this module +# --------------------------------------------------------------------------- + +@pytest.fixture(scope="module", autouse=True) +def _setup_locked_menu_test(): + """Ensure device is on the main menu and unlocked before the scenario.""" + ensure_main_menu() + # Guarantee unlocked state in case a previous run left the device locked. + disco_run( + "repl", "exec", + "specter_state.is_locked = False; scr.show_menu(None); print('OK')", + ) + _set_device_pin("42") # set a known PIN for the tests + time.sleep(1.0) + ensure_main_menu() + yield + + +# --------------------------------------------------------------------------- +# The scenario +# --------------------------------------------------------------------------- + +def test_locked_menu_scenario(): + """Full PIN-screen user journey — 8 sequential checkpoints in one run.""" + did_retry = False + + pin = _get_device_pin() + assert len(pin) >= 2, f"Device PIN too short for device tests: {pin!r}" + + # ── CP1: lock button activates the PIN screen ──────────────────────────── + assert _lock_device(), ( + "CP1: PIN screen not shown after clicking lock button" + ) + + order1 = _get_digit_order() + + # ── CP2: key order is shuffled (not "0123456789"); allow one retry ─────── + if order1 == list("0123456789"): + #Re-Try, verify other CPs along the way to not waste time + assert _unlock_device(), ( + f"CP2.1: device not unlocked after entering correct PIN {pin!r}" + ) + assert _lock_device(), ( + "CP2.2: failed to re-lock device on retry after natural order detected" + ) + + order1 = _get_digit_order() + assert order1 != list("0123456789"), ( + "CP2.2: digits appeared in natural order on two consecutive lock-screen activations — hardware RNG suspect" + ) + did_retry = True + + # ── CP3: entering one digit shows * in mask, not the digit in clear ────── + _click_digit(pin[0]) + mask = _get_mask_text() + assert mask == "*", ( + f"CP3: expected mask='*' after entering one digit, got {mask!r}" + ) + assert mask != pin[0], ( + f"CP3: digit {pin[0]!r} shown in cleartext in mask label" + ) + + # ── CP4: Del removes the last digit ────────────────────────────────────── + _click_del() + assert _get_mask_text() == "", ( + "CP4: Del did not remove the digit from the mask" + ) + + # ── CP5: wrong PIN → OK: keeps device locked ─────────────── + # pin_buf currently empty; Pin is at least two digits; so enter one digit then OK = wrong PIN + _click_digit(pin[0]) + _click_ok() + assert _locked_screen_visible(), ( + "CP5: device unlocked on wrong PIN — unlock check is broken" + ) + assert _get_mask_text() == "", ( + "CP5: pin buffer not cleared after failed unlock attempt" + ) + + + # ── CP6: correct PIN unlocks the device ────────────────────────────────── + if not did_retry: + assert _unlock_device(), ( + f"CP6: device not unlocked after entering correct PIN {pin!r}" + ) + + # ── CP7: re-locking produces a different digit order; allow one retry ── + if not did_retry: + assert _lock_device(), ( + "CP7: failed to lock device for second time to check for different key order" + ) + order2 = _get_digit_order() + if order2 == order1: + assert _unlock_device(), ( + "CP7.1: device not unlocked after entering correct PIN — unlock check is broken" + ) + assert _lock_device(), ( + "CP7.2: failed to re-lock device on retry after identical key order detected" + ) + order2 = _get_digit_order() + + assert order2 != order1, ( + "CP7: key order unchanged after re-locking twice — hardware RNG suspect" + ) + + # return to initial state for next test + assert _unlock_device(), ( + f"Final step: device not unlocked after entering correct PIN {pin!r}" + )