Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions manifests/mockui.py
Original file line number Diff line number Diff line change
@@ -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')
2 changes: 1 addition & 1 deletion pytest.ini
Original file line number Diff line number Diff line change
@@ -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_*
50 changes: 44 additions & 6 deletions scenarios/MockUI/src/MockUI/basic/locked_menu.py
Original file line number Diff line number Diff line change
@@ -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."""

Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -83,7 +121,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):
Expand Down
4 changes: 2 additions & 2 deletions scenarios/MockUI/src/MockUI/basic/ui_consts.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
172 changes: 172 additions & 0 deletions scenarios/MockUI/tests/test_locked_menu.py
Original file line number Diff line number Diff line change
@@ -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"
Loading
Loading