diff --git a/src/gui/common.py b/src/gui/common.py index 753d32b6..b9b6b47b 100644 --- a/src/gui/common.py +++ b/src/gui/common.py @@ -176,7 +176,7 @@ def align_button_pair(btn1, btn2): btn2.set_x(HOR_RES // 2 + PADDING // 2) -def add_qrcode(text, y=QR_PADDING, scr=None, style=None, width=None): +def add_qrcode(text, y=QR_PADDING, scr=None, style=None, width=None, sensitive=False): """Helper functions that creates a title-styled label""" if scr is None: scr = lv.scr_act() @@ -185,6 +185,7 @@ def add_qrcode(text, y=QR_PADDING, scr=None, style=None, width=None): width = 350 qr = QRCode(scr) + qr.sensitive = sensitive qr.set_text(text) qr.set_size(width) qr.set_text(text) diff --git a/src/gui/components/qrcode.py b/src/gui/components/qrcode.py index 504a769d..efb413ed 100644 --- a/src/gui/components/qrcode.py +++ b/src/gui/components/qrcode.py @@ -38,6 +38,9 @@ def __init__(self, *args, **kwargs): self.encoder = None self._autoplay = True + # When True, this QR encodes secret material (mnemonic, SeedQR + # digits/bytes): never print its contents, even in the simulator. + self.sensitive = False self.qr = lvqr.QRCode(self) self._text = "Text" @@ -244,7 +247,7 @@ def update_note(self): self.check_controls() def set_text(self, text="Text", set_first_frame=False): - if platform.simulator and self._text != text: + if platform.simulator and not self.sensitive and self._text != text: print("QR on screen:", text) self.encoder = None self._text = text @@ -288,8 +291,8 @@ def check_controls(self): self.play.set_hidden((not self.is_fullscreen) or (self.idx is not None) or (self.encoder is None)) def _set_text(self, text): - # one bcur frame doesn't require checksum - print(text) + if platform.simulator and not self.sensitive: + print(text) self.set_style(qr_style) self.qr.set_text(text) self.qr.align(self, lv.ALIGN.CENTER, 0, -100 if self.is_fullscreen else 0) diff --git a/src/gui/screens/__init__.py b/src/gui/screens/__init__.py index 8cc59395..c9a8c503 100644 --- a/src/gui/screens/__init__.py +++ b/src/gui/screens/__init__.py @@ -8,3 +8,4 @@ from .mnemonic import MnemonicScreen, NewMnemonicScreen, RecoverMnemonicScreen from .transaction import TransactionScreen from .settings import DevSettings +from .seedqr import SeedQROverviewScreen, SeedQRZoomScreen, show_seedqr diff --git a/src/gui/screens/qralert.py b/src/gui/screens/qralert.py index 18777665..622fd65e 100644 --- a/src/gui/screens/qralert.py +++ b/src/gui/screens/qralert.py @@ -14,11 +14,12 @@ def __init__( button_text="Close", note=None, transcribe=False, + sensitive=False, ): if qr_message is None: qr_message = message super().__init__(title, message, button_text, note=note) - self.qr = add_qrcode(qr_message, scr=self, width=qr_width) + self.qr = add_qrcode(qr_message, scr=self, width=qr_width, sensitive=sensitive) self.qr.align(self.page, lv.ALIGN.IN_TOP_MID, 0, 20) self.message.align(self.qr, lv.ALIGN.OUT_BOTTOM_MID, 0, 20) if transcribe: diff --git a/src/gui/screens/seedqr.py b/src/gui/screens/seedqr.py new file mode 100644 index 00000000..b62d49aa --- /dev/null +++ b/src/gui/screens/seedqr.py @@ -0,0 +1,534 @@ +""" +Touch-friendly SeedQR transcription viewer. + +Shows the complete Standard/Compact SeedQR, split into labeled sections +(matching SeedSigner's SeedQR transcription workflow), so the user can tap +any section and view it greatly enlarged -- with crisp, individually +distinguishable modules -- while copying it onto paper or a metal plate. + +Both the overview and the zoomed section views render the *same* canonical +module matrix (see ``seedqr.generate_matrix``); nothing here re-encodes the +payload, so overview and zoom can never disagree about module data. + +This screen never displays the raw SeedQR digit string / Compact SeedQR +bytes / mnemonic as text, and never prints or persists the matrix or +payload (see the ``sensitive`` handling in gui.components.qrcode.QRCode and +the DELETE handlers below, which drop references to the secret matrix as +soon as a screen is torn down). +""" +import lvgl as lv + +import seedqr +from .screen import Screen +from .alert import Alert +from ..common import add_label, add_button, add_button_pair, HOR_RES +from ..decorators import on_release, cb_with_args, feed_touch + +MODULE_WHITE_COLOR = 0xFFFFFF +MODULE_BLACK_COLOR = 0x000000 +MODULE_OUTSIDE_COLOR = 0x313E50 # matches the app's muted "highlight" theme color +SECTION_LINE_COLOR = 0x2372B2 +ACTIVE_TEXT_COLOR = 0xFFFFFF # text color used on top of a solid-filled active header +#: module grid line used in the zoomed view, where it must read clearly for +#: accurate transcription +GRID_LINE_COLOR = 0x808080 +#: much subtler module grid line used only on the overview -- just enough +#: of a "graph paper" hint to see individual modules, without competing +#: with the bolder section-boundary overlay +OVERVIEW_GRID_LINE_COLOR = 0xB0B0B0 +OVERVIEW_GRID_LINE_OPA = 90 + + +def _new_style(): + """A fresh lv.style_t() seeded from the base theme, ready to customize.""" + style = lv.style_t() + lv.style_copy(style, lv.style_plain) + return style + + +def _flat_cell_style(color_hex, border_width=0, border_color=GRID_LINE_COLOR, border_opa=255): + style = _new_style() + style.body.main_color = lv.color_hex(color_hex) + style.body.grad_color = lv.color_hex(color_hex) + style.body.opa = 255 + style.body.radius = 0 + style.body.border.width = border_width + style.body.border.color = lv.color_hex(border_color) + style.body.border.opa = border_opa + style.body.shadow.width = 0 + return style + + +def _transparent_style(): + style = _new_style() + style.body.opa = 0 + style.body.border.width = 0 + style.body.shadow.width = 0 + return style + + +def _axis_label_style(color_hex, font): + style = _new_style() + style.body.opa = 0 + style.text.color = lv.color_hex(color_hex) + style.text.font = font + return style + + +def _axis_box_style(color, filled=False): + """ + Header-cell box style. `filled=False` (the default, non-active state) + gives a transparent box with just a thin border. `filled=True` (the + currently active row/column) gives a solid block of `color` -- much + stronger visual contrast than a border alone. + """ + style = _new_style() + style.body.radius = 0 + style.body.shadow.width = 0 + style.body.border.width = 1 + style.body.border.color = lv.color_hex(color) + if filled: + style.body.opa = 255 + style.body.main_color = lv.color_hex(color) + style.body.grad_color = lv.color_hex(color) + style.body.border.opa = 255 + else: + style.body.opa = 0 + style.body.border.opa = 200 + return style + + +def _module_color(value): + if value == seedqr.MODULE_BLACK: + return MODULE_BLACK_COLOR + if value == seedqr.MODULE_WHITE: + return MODULE_WHITE_COLOR + return MODULE_OUTSIDE_COLOR + + +def _build_grid(parent, rows, cols, module_px): + """ + Creates a `cols * module_px` x `rows * module_px` transparent container + on `parent` with one square, unstyled child cell per (row, col). + Returns (container, cells) where cells[y][x] are the per-module lv.obj; + call `_paint_cells` to actually color them in. + """ + container = lv.obj(parent) + container.set_click(False) + container.set_style(_transparent_style()) + container.set_size(cols * module_px, rows * module_px) + + cells = [] + for y in range(rows): + row_cells = [] + for x in range(cols): + cell = lv.obj(container) + cell.set_click(False) + cell.set_size(module_px, module_px) + cell.set_pos(x * module_px, y * module_px) + row_cells.append(cell) + cells.append(row_cells) + return container, cells + + +def _paint_cells(cells, matrix_rows, grid_lines, grid_color=GRID_LINE_COLOR, grid_opa=255): + border_width = 1 if grid_lines else 0 + style_cache = {} + for y, row in enumerate(matrix_rows): + for x, value in enumerate(row): + color = _module_color(value) + style = style_cache.get(color) + if style is None: + style = _flat_cell_style(color, border_width, grid_color, grid_opa) + style_cache[color] = style + cells[y][x].set_style(style) + + +def _build_header_cell(scr, x, y, w, h, text, box_style, text_style): + """ + One bordered/filled header cell (box + centered label), shared by every + row/column header this screen draws -- whether it's one of many + per-section cells (overview) or a single cell spanning the whole axis + (zoomed view). + + The label's width is capped to the box's shorter side so text can never + overflow past a narrow header strip; the real text is set *before* + centering, since aligning against a still-empty label bakes in a + position based on its (near-zero) placeholder size and a later + set_text() would not re-center it. + """ + box = lv.obj(scr) + box.set_click(False) + box.set_style(box_style) + box.set_size(w, h) + box.set_pos(x, y) + + lbl = lv.label(box) + lbl.set_long_mode(lv.label.LONG.BREAK) + lbl.set_style(0, text_style) + lbl.set_width(min(w, h)) + lbl.set_align(lv.label.ALIGN.CENTER) + lbl.set_text(text) + lbl.align(box, lv.ALIGN.CENTER, 0, 0) + return box, lbl + + +def _build_axis_labels(scr, grid_x, grid_y, size, module_px, axis_size, axis_gap, color, font): + """ + Builds a column-number header row above (1, 2, 3, ...) and a row-letter + header column to the left of (A, B, C, ...) a grid positioned at + (grid_x, grid_y) on `scr`, one bordered header cell per section -- + matching the section grid below/right of it, like a spreadsheet's + row/column headers. A partial trailing row/column (e.g. the 29x29 + Standard SeedQR's last row/column) gets a correspondingly smaller + header cell, matching its true, smaller extent, rather than a full-size + one that would overhang past the real modules it labels. The corner + where the two header strips meet is left empty; it labels nothing. + Returns (col_labels, row_labels), each a list of (box, label) pairs, so + callers can highlight them later. + """ + mps = seedqr.modules_per_section(size) + n_sections = seedqr.num_sections(size) + box_style = _axis_box_style(color) + text_style = _axis_label_style(color, font) + + col_labels = [] + x = grid_x + for col in range(n_sections): + w = min(mps, size - col * mps) * module_px + col_labels.append(_build_header_cell( + scr, x, grid_y - axis_size, w, axis_size, seedqr.COL_LABELS[col], box_style, text_style, + )) + x += w + + row_labels = [] + y = grid_y + for row in range(n_sections): + h = min(mps, size - row * mps) * module_px + row_labels.append(_build_header_cell( + scr, grid_x - axis_size - axis_gap, y, axis_size, h, seedqr.ROW_LABELS[row], box_style, text_style, + )) + y += h + + return col_labels, row_labels + + +def _highlight_axis_labels(col_labels, row_labels, active_col, active_row, dim_color, active_color, font): + dim_box = _axis_box_style(dim_color) + active_box = _axis_box_style(active_color, filled=True) + dim_text = _axis_label_style(dim_color, font) + active_text = _axis_label_style(ACTIVE_TEXT_COLOR, font) + for col, (box, lbl) in enumerate(col_labels): + is_active = col == active_col + box.set_style(active_box if is_active else dim_box) + lbl.set_style(0, active_text if is_active else dim_text) + for row, (box, lbl) in enumerate(row_labels): + is_active = row == active_row + box.set_style(active_box if is_active else dim_box) + lbl.set_style(0, active_text if is_active else dim_text) + + +class SeedQROverviewScreen(Screen): + """ + Shows the complete SeedQR matrix with subtle section boundary overlays. + Tapping a section returns its (row, col) coordinate; the Close/Back + button returns None. + """ + + #: largest square area (in px) the module grid is allowed to occupy + MAX_GRID_PX = 400 + #: width of the row-letter column / height of the column-number row + AXIS_SIZE = 30 + AXIS_GAP = 4 + LEFT_MARGIN = 20 + GRID_TOP = 140 + AXIS_COLOR = 0x808A9C + + def __init__(self, matrix, format_label, initial_section=None): + super().__init__() + self.matrix = matrix + self.size = len(matrix) + self.mps = seedqr.modules_per_section(self.size) + self.n_sections = seedqr.num_sections(self.size) + + grid_area = HOR_RES - self.LEFT_MARGIN - self.AXIS_SIZE - self.AXIS_GAP - self.LEFT_MARGIN + self.module_px = seedqr.fit_module_pixels(self.size, min(self.MAX_GRID_PX, grid_area)) + self.grid_x = self.LEFT_MARGIN + self.AXIS_SIZE + self.AXIS_GAP + + self.title = add_label(format_label, scr=self, style="title") + self.subtitle = add_label( + "%dx%d modules" % (self.size, self.size), + y=55, scr=self, style="hint", + ) + + self.grid, self.cells = _build_grid(self, self.size, self.size, self.module_px) + _paint_cells( + self.cells, self.matrix, grid_lines=True, + grid_color=OVERVIEW_GRID_LINE_COLOR, grid_opa=OVERVIEW_GRID_LINE_OPA, + ) + self.grid.set_pos(self.grid_x, self.GRID_TOP) + self.grid.set_click(True) + self.grid.set_event_cb(self.on_grid_touch) + + self.col_labels, self.row_labels = _build_axis_labels( + self, self.grid_x, self.GRID_TOP, self.size, self.module_px, + self.AXIS_SIZE, self.AXIS_GAP, self.AXIS_COLOR, lv.font_roboto_22, + ) + + self._draw_section_overlay() + + self.instruction = add_label( + "Tap a section to enlarge", y=0, scr=self, style="hint" + ) + self.instruction.align(self.grid, lv.ALIGN.OUT_BOTTOM_MID, 0, 20) + + self.close_button = add_button( + lv.SYMBOL.CLOSE + " Close", on_release(self.close), scr=self + ) + + self.set_event_cb(self.cb) + + if initial_section is not None: + self._outline_selected(initial_section) + + def cb(self, obj, event): + if event == lv.EVENT.DELETE: + # Drop references to the secret matrix as soon as this screen + # is torn down. + self.matrix = None + self.cells = None + + def _draw_section_overlay(self): + """Thin, subtle lines boxing in every section -- including the + outer edge of the whole grid, so edge sections are fully enclosed + too -- drawn on top of the module grid as separate overlay bars, + never altering the underlying module cells, so scannability is + unaffected.""" + line_style = _new_style() + line_style.body.main_color = lv.color_hex(SECTION_LINE_COLOR) + line_style.body.grad_color = lv.color_hex(SECTION_LINE_COLOR) + line_style.body.opa = 90 + line_style.body.border.width = 0 + + grid_px = self.size * self.module_px + # i=0 and i=n_sections are the grid's own outer edges: including + # them boxes in the first/last row and column of sections too, + # instead of leaving their outer side unmarked. + for i in range(self.n_sections + 1): + offset = min(i * self.mps * self.module_px, grid_px - 1) + vline = lv.obj(self.grid) + vline.set_click(False) + vline.set_style(line_style) + vline.set_size(1, grid_px) + vline.set_pos(offset, 0) + + hline = lv.obj(self.grid) + hline.set_click(False) + hline.set_style(line_style) + hline.set_size(grid_px, 1) + hline.set_pos(0, offset) + + def _outline_selected(self, section): + """Outline the section the user last zoomed into, for as long as + this overview screen is shown, so returning to the overview + visibly preserves their place.""" + row, col = section + try: + x0, y0, w, h = seedqr.section_bounds(self.size, row, col) + except ValueError: + return + marker = lv.obj(self.grid) + marker.set_click(False) + style = _new_style() + style.body.opa = 0 + style.body.border.width = 2 + style.body.border.color = lv.color_hex(SECTION_LINE_COLOR) + style.body.border.opa = 255 + marker.set_style(style) + marker.set_size(w * self.module_px, h * self.module_px) + marker.set_pos(x0 * self.module_px, y0 * self.module_px) + + _highlight_axis_labels( + self.col_labels, self.row_labels, col, row, + self.AXIS_COLOR, SECTION_LINE_COLOR, lv.font_roboto_22, + ) + + def on_grid_touch(self, obj, event): + if event == lv.EVENT.PRESSING: + feed_touch() + return + if event != lv.EVENT.RELEASED: + return + point = lv.point_t() + indev = lv.indev_get_act() + lv.indev_get_point(indev, point) + rel_x = point.x - self.grid.get_x() + rel_y = point.y - self.grid.get_y() + section = seedqr.coord_to_section(rel_x, rel_y, self.size, self.module_px) + if section is None: + return + self.set_value(section) + + def close(self): + self.set_value(None) + + +class SeedQRZoomScreen(Screen): + """ + Shows one section of the SeedQR matrix greatly enlarged, with crisp + square modules, visible grid lines, and Left/Right/Up/Down navigation + to neighboring sections. Returns ("close", section) or + ("overview", section) depending on which control the user pressed. + """ + + #: largest square area (in px) the module grid is allowed to occupy + MAX_GRID_PX = 400 + NAV_BTN_W = 100 + NAV_BTN_H = 60 + NAV_GAP = 20 + #: order matches how the four nav buttons are laid out left-to-right + NAV_ORDER = (("left", lv.SYMBOL.LEFT), ("up", lv.SYMBOL.UP), + ("down", lv.SYMBOL.DOWN), ("right", lv.SYMBOL.RIGHT)) + + #: width of the row-letter column / height of the column-number row + AXIS_SIZE = 36 + AXIS_GAP = 4 + LEFT_MARGIN = 20 + GRID_TOP = 140 + AXIS_ACTIVE_COLOR = SECTION_LINE_COLOR + + def __init__(self, matrix, row, col): + super().__init__() + self.matrix = matrix + self.size = len(matrix) + self.navigator = seedqr.ZoomNavigator(self.size, row, col) + self.n_sections = seedqr.num_sections(self.size) + self.mps = seedqr.modules_per_section(self.size) + + grid_area = HOR_RES - self.LEFT_MARGIN - self.AXIS_SIZE - self.AXIS_GAP - self.LEFT_MARGIN + self.module_px = seedqr.fit_module_pixels(self.mps, min(self.MAX_GRID_PX, grid_area)) + self.grid_x = self.LEFT_MARGIN + self.AXIS_SIZE + self.AXIS_GAP + + self.label = add_label(self.navigator.label, scr=self, style="title") + + # The grid and its axis header boxes are (re)built fresh every time + # the current section changes: most sections are a full mps x mps + # square, but the trailing row/column of a non-square-divisible + # SeedQR (e.g. 29x29) is smaller, and should render at its true, + # smaller size rather than padding it out with filler modules. + self.grid = self.cells = None + self.col_box = self.col_header = None + self.row_box = self.row_header = None + + self._build_nav_controls() + self._render_section() + + self.back_button, self.close_button = add_button_pair( + lv.SYMBOL.LEFT + " Overview", + on_release(self.back_to_overview), + lv.SYMBOL.CLOSE + " Close", + on_release(self.close), + scr=self, + ) + + self.set_event_cb(self.cb) + + def cb(self, obj, event): + if event == lv.EVENT.DELETE: + self.matrix = None + self.cells = None + + def _build_nav_controls(self): + """ + A single row of Left/Up/Down/Right buttons below the grid, at a + fixed position based on the largest a section can ever be (a full + mps x mps square) -- not the current section's possibly-smaller + size -- so the buttons stay put as the user pans between full and + partial (trailing row/column) sections. + """ + max_grid_px = self.mps * self.module_px + nav_y = self.GRID_TOP + max_grid_px + 20 + total_w = 4 * self.NAV_BTN_W + 3 * self.NAV_GAP + start_x = (HOR_RES - total_w) // 2 + self.nav_buttons = {} + for i, (direction, symbol) in enumerate(self.NAV_ORDER): + btn = add_button(symbol, on_release(cb_with_args(self._move, direction)), scr=self) + btn.set_size(self.NAV_BTN_W, self.NAV_BTN_H) + btn.set_pos(start_x + i * (self.NAV_BTN_W + self.NAV_GAP), nav_y) + self.nav_buttons[direction] = btn + + def _move(self, direction): + self.navigator.move(direction) + self._render_section() + + def _render_section(self): + row, col = self.navigator.section + _, _, w, h = seedqr.section_bounds(self.size, row, col) + real_section = seedqr.extract_real_section(self.matrix, row, col) + + for obj in (self.grid, self.col_box, self.row_box): + if obj is not None: + obj.del_async() + + self.grid, self.cells = _build_grid(self, h, w, self.module_px) + _paint_cells(self.cells, real_section, grid_lines=True) + self.grid.set_pos(self.grid_x, self.GRID_TOP) + + # A single header box above the grid showing the current section's + # column number, and one to its left showing the current row + # letter, each sized to match the (possibly smaller, for a partial + # trailing row/column) grid it labels. + w_px, h_px = w * self.module_px, h * self.module_px + box_style = _axis_box_style(self.AXIS_ACTIVE_COLOR, filled=True) + text_style = _axis_label_style(ACTIVE_TEXT_COLOR, lv.font_roboto_28) + self.col_box, self.col_header = _build_header_cell( + self, self.grid_x, self.GRID_TOP - self.AXIS_SIZE, w_px, self.AXIS_SIZE, + seedqr.COL_LABELS[col], box_style, text_style, + ) + self.row_box, self.row_header = _build_header_cell( + self, self.LEFT_MARGIN, self.GRID_TOP, self.AXIS_SIZE, h_px, + seedqr.ROW_LABELS[row], box_style, text_style, + ) + + self.label.set_text(self.navigator.label) + for direction, btn in self.nav_buttons.items(): + if self.navigator.can_move(direction): + btn.set_state(lv.btn.STATE.REL) + else: + btn.set_state(lv.btn.STATE.INA) + + def back_to_overview(self): + self.set_value(("overview", self.navigator.section)) + + def close(self): + self.set_value(("close", self.navigator.section)) + + +async def show_seedqr(show, payload, format_label): + """ + Drives the SeedQR transcription overview <-> zoom navigation loop. + + `show` is the app's async show_screen callable (e.g. RAMKeyStore.show): + takes a Screen instance, displays it, and returns its result. Returns + once the user closes the viewer (from either the overview or a zoomed + section). + """ + try: + matrix = seedqr.generate_matrix(payload) + except ValueError: + # Covers both seedqr.MatrixError (bad matrix shape) and a ValueError + # raised by the native QR encoder itself (e.g. encoding failure). + await show(Alert("Error", "Could not build a SeedQR from this recovery phrase.")) + return + + selected = None + while True: + overview = SeedQROverviewScreen(matrix, format_label, initial_section=selected) + section = await show(overview) + if section is None: + return + zoom = SeedQRZoomScreen(matrix, *section) + action, selected = await show(zoom) + if action == "close": + return + # action == "overview": loop back, reopening the overview at `selected` diff --git a/src/keystore/ram.py b/src/keystore/ram.py index 1806da5f..fc917624 100644 --- a/src/keystore/ram.py +++ b/src/keystore/ram.py @@ -8,9 +8,10 @@ from embit.transaction import SIGHASH from helpers import aead_encrypt, aead_decrypt, tagged_hash import secp256k1 -from gui.screens import Alert, PinScreen, Prompt, Menu, QRAlert +from gui.screens import Alert, PinScreen, Prompt, Menu, QRAlert, show_seedqr from gui.screens.mnemonic import ExportMnemonicScreen from binascii import hexlify +import seedqr class RAMKeyStore(KeyStore): """ @@ -384,16 +385,20 @@ async def show_mnemonic(self): if v == 255: return elif v == 1: - nums = [bip39.WORDLIST.index(w) for w in self.mnemonic.split()] - qr_msg = "".join([("000"+str(n))[-4:] for n in nums]) - msg = qr_msg + await show_seedqr( + self.show, seedqr.standard_payload(self.mnemonic), + "Standard SeedQR (digits)", + ) elif v == 2: - qr_msg = bip39.mnemonic_to_bytes(self.mnemonic) - msg = hexlify(qr_msg).decode() + await show_seedqr( + self.show, seedqr.compact_payload(self.mnemonic), + "Compact SeedQR (binary)", + ) elif v == 3: - qr_msg = self.mnemonic - msg = self.mnemonic - await self.show(QRAlert(title="Your mnemonic as QR code", message=msg, qr_message=qr_msg, transcribe=True)) + await self.show(QRAlert( + title="Your mnemonic as QR code", message=self.mnemonic, + qr_message=self.mnemonic, transcribe=True, sensitive=True, + )) elif v == ExportMnemonicScreen.SD: if not platform.sdcard.is_present: raise KeyStoreError("SD card is not present") diff --git a/src/seedqr.py b/src/seedqr.py new file mode 100644 index 00000000..6fe8ff7f --- /dev/null +++ b/src/seedqr.py @@ -0,0 +1,262 @@ +""" +Pure logic for the SeedQR transcription viewer: canonical QR module matrix +generation plus zone/section geometry. + +Deliberately free of any lvgl / hardware dependency (only ``math`` at import +time; ``qrcode`` -- the native usermod -- is imported lazily inside +``generate_matrix`` only) so this module can be unit-tested on a desktop +Python interpreter without a display or firmware build. + +Section geometry mirrors SeedSigner's SeedQR transcription workflow +(https://github.com/SeedSigner/seedsigner/blob/dev/docs/seed_qr/README.md): +a 21x21 QR is grouped into 7x7-module sections (a 3x3 section grid); every +larger SeedQR size is grouped into 5x5-module sections. +""" +import math + +# Row letters / column numbers used for section labels, e.g. "B-3". +ROW_LABELS = "ABCDEF" +COL_LABELS = "123456" + +# Module values. Real QR modules are 0 (white) or 1 (black/dark). OUTSIDE +# marks a cell that lies past the edge of the real QR matrix -- present only +# in the partial final row/column of sections on non-square-divisible sizes +# (e.g. the 29x29 Standard 24-word SeedQR) -- so callers never confuse "no +# module here" with a valid white module. +MODULE_WHITE = 0 +MODULE_BLACK = 1 +MODULE_OUTSIDE = 2 + + +class MatrixError(ValueError): + """Raised when a QR payload can't be turned into a valid module matrix.""" + + +def generate_matrix(payload): + """ + Build the canonical QR module matrix for a SeedQR payload. + + ``payload`` must be either: + - ``str``: Standard SeedQR digit string (numeric QR mode) + - ``bytes``: Compact SeedQR raw entropy (binary QR mode) + + Returns a tuple of ``bytes`` rows (one element per module, 0 or 1). This + is the single source of truth for both the overview and every zoomed + section: it must never be regenerated separately for those two views. + """ + import qrcode # native firmware module (qrcodegen-backed) + + raw = qrcode.encode_to_string(payload) + return parse_matrix(raw) + + +def parse_matrix(raw): + """ + Parse the newline-separated '0'/'1' grid produced by + ``qrcode.encode_to_string`` into a tuple of ``bytes`` rows. + """ + rows = raw.strip("\n").split("\n") + matrix = tuple(bytes(1 if c == "1" else 0 for c in row) for row in rows) + validate_matrix(matrix) + return matrix + + +def validate_matrix(matrix): + """ + Raise MatrixError if ``matrix`` isn't a square, standards-compliant QR + module grid (size = 21 + 4*(version-1), version 1..40). Returns the size. + """ + size = len(matrix) + if size == 0: + raise MatrixError("Empty QR matrix") + for row in matrix: + if len(row) != size: + raise MatrixError("QR matrix must be square") + if size < 21 or size > 177 or (size - 21) % 4 != 0: + raise MatrixError("Not a valid QR module count: %d" % size) + return size + + +def modules_per_section(size): + """Edge length (in modules) of one transcription section.""" + return 7 if size == 21 else 5 + + +def num_sections(size): + """Edge length (in sections) of the section grid, i.e. ceil(size / mps).""" + return math.ceil(size / modules_per_section(size)) + + +def section_label(row, col): + """0-indexed (row, col) section coordinate -> 'A-1'-style label.""" + if not (0 <= row < len(ROW_LABELS)) or not (0 <= col < len(COL_LABELS)): + raise ValueError("Section coordinate out of supported range: (%r, %r)" % (row, col)) + return "%s-%d" % (ROW_LABELS[row], col + 1) + + +def section_bounds(size, row, col): + """ + Module-space bounding box of section (row, col) as (x0, y0, w, h), where + x0/y0 are the top-left module coordinates and w/h are the number of REAL + modules covered (< modules_per_section on a partial trailing row/column). + """ + mps = modules_per_section(size) + n = num_sections(size) + if not (0 <= row < n) or not (0 <= col < n): + raise ValueError("Section coordinate out of range: (%r, %r)" % (row, col)) + x0 = col * mps + y0 = row * mps + w = min(mps, size - x0) + h = min(mps, size - y0) + return x0, y0, w, h + + +def extract_section(matrix, row, col): + """ + Extract section (row, col) from ``matrix`` as a tuple of + ``modules_per_section(size)`` rows, each of that same length. Cells past + the true QR matrix edge are filled with MODULE_OUTSIDE. + """ + size = len(matrix) + mps = modules_per_section(size) + x0, y0, w, h = section_bounds(size, row, col) + out = [] + for dy in range(mps): + if dy < h: + real = matrix[y0 + dy][x0:x0 + w] + if w < mps: + real = real + bytes([MODULE_OUTSIDE]) * (mps - w) + out.append(real) + else: + out.append(bytes([MODULE_OUTSIDE]) * mps) + return tuple(out) + + +def extract_real_section(matrix, row, col): + """ + Extract section (row, col) from ``matrix`` as exactly its real modules + -- h rows of w columns each, per ``section_bounds`` -- with no + MODULE_OUTSIDE padding. Use this to render only the modules that + actually exist (e.g. a partial trailing section shown at its true, + smaller size) instead of a fixed modules_per_section(size) square with + filler cells. + """ + size = len(matrix) + x0, y0, w, h = section_bounds(size, row, col) + return tuple(matrix[y0 + dy][x0:x0 + w] for dy in range(h)) + + +def neighbor_section(row, col, size, direction): + """ + Returns the (row, col) of the section adjacent to (row, col) in + ``direction`` ("up"/"down"/"left"/"right"), or None if that would fall + outside the section grid. + """ + n = num_sections(size) + if direction == "up": + row -= 1 + elif direction == "down": + row += 1 + elif direction == "left": + col -= 1 + elif direction == "right": + col += 1 + else: + raise ValueError("Unknown direction: %r" % direction) + if 0 <= row < n and 0 <= col < n: + return row, col + return None + + +def fit_module_pixels(count, available_px): + """ + Largest integer per-module pixel size such that ``count`` modules fit + within ``available_px``, so modules render as crisp whole-pixel squares + instead of being smoothed/interpolated to a fractional size. Always + returns at least 1. + """ + return max(1, int(available_px) // int(count)) + + +def coord_to_module(px, py, size, module_px): + """ + Map a touch point (px, py) -- given in whole pixels relative to the + top-left corner of the rendered module grid, with any quiet zone already + excluded by the caller -- to a (module_x, module_y) coordinate. + + ``module_px`` is the on-screen pixel edge length of a single module + (matrix assumed square, uniform module size). Returns None if the point + falls outside the matrix or entirely outside the [0, size) grid. + """ + if module_px <= 0: + raise ValueError("module_px must be positive") + matrix_px_size = module_px * size + if px < 0 or py < 0 or px >= matrix_px_size or py >= matrix_px_size: + return None + mx = min(px // module_px, size - 1) + my = min(py // module_px, size - 1) + return mx, my + + +def coord_to_section(px, py, size, module_px): + """Map a touch point straight to its (row, col) section, or None.""" + m = coord_to_module(px, py, size, module_px) + if m is None: + return None + mx, my = m + mps = modules_per_section(size) + return my // mps, mx // mps + + +def standard_payload(mnemonic): + """ + Standard SeedQR payload: each BIP-39 word's wordlist index, zero-padded + to 4 digits, concatenated with no separators. Equivalent to (and must + stay equivalent to) RAMKeyStore.show_mnemonic()'s original inline + computation -- this only relocates it so it can be unit-tested. + """ + from embit import bip39 + + words = mnemonic.split() + return "".join("%04d" % bip39.WORDLIST.index(w) for w in words) + + +def compact_payload(mnemonic): + """ + Compact SeedQR payload: the raw BIP-39 entropy bytes. Must never be + hex-encoded before being handed to the QR encoder. + """ + from embit import bip39 + + return bip39.mnemonic_to_bytes(mnemonic) + + +class ZoomNavigator: + """ + Tracks the section currently shown by the zoomed transcription view as + the user pans around. Pure state machine (no lvgl dependency) so + SeedQRZoomScreen can delegate all of its navigation bookkeeping here. + """ + + def __init__(self, size, row=0, col=0): + self.size = size + self.row = row + self.col = col + + @property + def section(self): + return self.row, self.col + + @property + def label(self): + return section_label(self.row, self.col) + + def can_move(self, direction): + return neighbor_section(self.row, self.col, self.size, direction) is not None + + def move(self, direction): + """Move if possible; always returns the (possibly unchanged) section.""" + nxt = neighbor_section(self.row, self.col, self.size, direction) + if nxt is not None: + self.row, self.col = nxt + return self.section diff --git a/test/integration/requirements.txt b/test/integration/requirements.txt index de676a13..4c433475 100644 --- a/test/integration/requirements.txt +++ b/test/integration/requirements.txt @@ -1,2 +1,6 @@ requests embit +# Pure-python QR reference encoder used only by native_support's `qrcode` +# stub (test/tests_native/test_seedqr.py) -- the real firmware `qrcode` +# module is a native C extension unavailable under CPython. +segno diff --git a/test/native_support.py b/test/native_support.py index c9f00fd5..b69fdbe2 100644 --- a/test/native_support.py +++ b/test/native_support.py @@ -103,10 +103,18 @@ def __getattr__(self, name): "RecoverMnemonicScreen", "Progress", "DevSettings", + "SeedQROverviewScreen", + "SeedQRZoomScreen", ]: if not hasattr(screens, _name): setattr(screens, _name, type(_name, (), {})) + if not hasattr(screens, "show_seedqr"): + async def _stub_show_seedqr(*args, **kwargs): + return None + + screens.show_seedqr = _stub_show_seedqr + _ensure_submodule("gui.screens", "mnemonic", { "ExportMnemonicScreen": type("ExportMnemonicScreen", (), {}), }) @@ -159,6 +167,35 @@ def decrypt(self, data): if not hasattr(bcur, "bcur_decode_stream"): bcur.bcur_decode_stream = lambda stream: stream + qrcode = _ensure_module("qrcode") + if not hasattr(qrcode, "encode_to_string"): + try: + import segno + except ModuleNotFoundError as exc: + raise ModuleNotFoundError( + "Native test suite requires the 'segno' package to stand in " + "for the native qrcodegen-backed 'qrcode' usermod (there is " + "no CPython build of it). Install it with " + "'pip install -r test/integration/requirements.txt'." + ) from exc + + def _encode_to_string(payload): + # Mirrors f469-disco/usermods/qrcode/qrcode.c: ECC LOW, boosted + # ECC, auto mask/version, bytes -> forced binary mode, str -> + # auto text mode (numeric/alphanumeric/byte). QR version (hence + # matrix size) is a deterministic function of payload length + + # mode + ECC policy, so this reference encoder produces the same + # module counts as the firmware encoder for the same payload, + # even though it is a different implementation. + if isinstance(payload, (bytes, bytearray)): + qr = segno.make(bytes(payload), error="l", boost_error=True, mode="byte") + else: + qr = segno.make(payload, error="l", boost_error=True) + rows = ("".join("1" if v else "0" for v in row) for row in qr.matrix) + return "\n".join(rows) + "\n" + + qrcode.encode_to_string = _encode_to_string + secp256k1 = _ensure_module("secp256k1") if not hasattr(secp256k1, "EC_UNCOMPRESSED"): secp256k1.EC_UNCOMPRESSED = 0 diff --git a/test/tests_native/__init__.py b/test/tests_native/__init__.py index 7cf516b6..c4d1164b 100644 --- a/test/tests_native/__init__.py +++ b/test/tests_native/__init__.py @@ -1 +1,2 @@ from .test_wallet_manager_parsing import * +from .test_seedqr import * diff --git a/test/tests_native/test_seedqr.py b/test/tests_native/test_seedqr.py new file mode 100644 index 00000000..669bc9fe --- /dev/null +++ b/test/tests_native/test_seedqr.py @@ -0,0 +1,415 @@ +import ast +import sys +from pathlib import Path + +if sys.implementation.name != 'micropython': + from native_support import setup_native_stubs + setup_native_stubs() + +from unittest import TestCase + +import seedqr + +SRC_DIR = Path(__file__).resolve().parent.parent.parent / "src" + +# SeedSigner docs/seed_qr/README.md worked example. +DOC_MNEMONIC = "vacuum bridge buddy supreme exclude milk consider tail expand wasp pattern nuclear" +DOC_STANDARD_PAYLOAD = "192402220235174306311124037817700641198012901210" + +# Fixed valid 24-word mnemonic (BIP-39 test vector, all-zero entropy). +MNEMONIC_24 = ( + "abandon abandon abandon abandon abandon abandon abandon abandon " + "abandon abandon abandon abandon abandon abandon abandon abandon " + "abandon abandon abandon abandon abandon abandon abandon art" +) + + +def _fake_matrix(size, fill=seedqr.MODULE_BLACK): + """A deterministic size x size matrix for pure geometry tests.""" + return tuple(bytes([fill]) * size for _ in range(size)) + + +def _identifiable_matrix(size): + """ + A size x size matrix where module (x, y) encodes its own coordinates + (as parity bits), so section extraction/recombination can be checked + against exact expected values rather than a uniform fill. + """ + return tuple(bytes(((x + y) % 2) for x in range(size)) for y in range(size)) + + +class EncodingTest(TestCase): + def test_standard_payload_matches_seedsigner_doc_example(self): + self.assertEqual(seedqr.standard_payload(DOC_MNEMONIC), DOC_STANDARD_PAYLOAD) + + def test_standard_12_word_matrix_is_25x25(self): + matrix = seedqr.generate_matrix(DOC_STANDARD_PAYLOAD) + self.assertEqual(len(matrix), 25) + self.assertTrue(all(len(row) == 25 for row in matrix)) + + def test_compact_12_word_matrix_is_21x21(self): + payload = seedqr.compact_payload(DOC_MNEMONIC) + self.assertIsInstance(payload, bytes) + matrix = seedqr.generate_matrix(payload) + self.assertEqual(len(matrix), 21) + + def test_standard_24_word_matrix_is_29x29(self): + payload = seedqr.standard_payload(MNEMONIC_24) + matrix = seedqr.generate_matrix(payload) + self.assertEqual(len(matrix), 29) + + def test_compact_24_word_matrix_is_25x25(self): + payload = seedqr.compact_payload(MNEMONIC_24) + matrix = seedqr.generate_matrix(payload) + self.assertEqual(len(matrix), 25) + + def test_compact_payload_is_raw_bytes_not_hex(self): + payload = seedqr.compact_payload(DOC_MNEMONIC) + self.assertIsInstance(payload, bytes) + # A hex string would be twice as long and be str, not bytes. + self.assertNotIsInstance(payload, str) + self.assertEqual(len(payload), 16) # 12 words -> 128 bits entropy + + def test_compact_qr_encoder_receives_bytes_object(self): + """ + The matrix generator must hand the encoder the exact bytes object, + never a hex-encoded string representation of it. + """ + import qrcode as qrcode_stub + + seen = {} + original = qrcode_stub.encode_to_string + + def spy(payload): + seen["payload"] = payload + return original(payload) + + qrcode_stub.encode_to_string = spy + try: + entropy = seedqr.compact_payload(DOC_MNEMONIC) + seedqr.generate_matrix(entropy) + finally: + qrcode_stub.encode_to_string = original + + self.assertIs(seen["payload"], entropy) + self.assertIsInstance(seen["payload"], bytes) + + +class ValidateMatrixTest(TestCase): + def test_rejects_empty_matrix(self): + with self.assertRaises(seedqr.MatrixError): + seedqr.validate_matrix(()) + + def test_rejects_non_square_matrix(self): + with self.assertRaises(seedqr.MatrixError): + seedqr.validate_matrix((bytes([0, 0]), bytes([0]))) + + def test_rejects_invalid_qr_module_count(self): + with self.assertRaises(seedqr.MatrixError): + seedqr.validate_matrix(_fake_matrix(22)) + + def test_accepts_valid_sizes(self): + for size in (21, 25, 29): + self.assertEqual(seedqr.validate_matrix(_fake_matrix(size)), size) + + +class SectionGeometryTest(TestCase): + def test_21x21_is_3x3_sections_of_7x7(self): + self.assertEqual(seedqr.modules_per_section(21), 7) + self.assertEqual(seedqr.num_sections(21), 3) + + def test_25x25_is_5x5_sections_of_5x5(self): + self.assertEqual(seedqr.modules_per_section(25), 5) + self.assertEqual(seedqr.num_sections(25), 5) + + def test_29x29_is_6x6_sections_with_partial_final_row_and_col(self): + self.assertEqual(seedqr.modules_per_section(29), 5) + self.assertEqual(seedqr.num_sections(29), 6) + # Last row/col section only has 29 - 5*5 = 4 real modules. + x0, y0, w, h = seedqr.section_bounds(29, 5, 5) + self.assertEqual((w, h), (4, 4)) + # A non-final section is fully real. + x0, y0, w, h = seedqr.section_bounds(29, 0, 0) + self.assertEqual((w, h), (5, 5)) + + def test_section_labels(self): + self.assertEqual(seedqr.section_label(0, 0), "A-1") + self.assertEqual(seedqr.section_label(1, 2), "B-3") + self.assertEqual(seedqr.section_label(5, 5), "F-6") + + def test_every_real_module_is_in_exactly_one_section(self): + for size in (21, 25, 29): + n = seedqr.num_sections(size) + mps = seedqr.modules_per_section(size) + covered = [[0] * size for _ in range(size)] + for row in range(n): + for col in range(n): + x0, y0, w, h = seedqr.section_bounds(size, row, col) + for dy in range(h): + for dx in range(w): + covered[y0 + dy][x0 + dx] += 1 + for y in range(size): + for x in range(size): + self.assertEqual( + covered[y][x], 1, + "module (%d, %d) in a %dx%d matrix covered %d times" % ( + x, y, size, size, covered[y][x], + ), + ) + + def test_recombining_sections_reproduces_original_matrix_exactly(self): + for size in (21, 25, 29): + matrix = _identifiable_matrix(size) + n = seedqr.num_sections(size) + mps = seedqr.modules_per_section(size) + rebuilt = [[None] * size for _ in range(size)] + for row in range(n): + for col in range(n): + section = seedqr.extract_section(matrix, row, col) + x0, y0, w, h = seedqr.section_bounds(size, row, col) + for dy in range(mps): + for dx in range(mps): + value = section[dy][dx] + in_bounds = dx < w and dy < h + if in_bounds: + self.assertNotEqual(value, seedqr.MODULE_OUTSIDE) + rebuilt[y0 + dy][x0 + dx] = value + else: + self.assertEqual(value, seedqr.MODULE_OUTSIDE) + for y in range(size): + for x in range(size): + self.assertEqual(rebuilt[y][x], matrix[y][x]) + + def test_partial_section_distinguishes_outside_from_white(self): + # 29x29 all-white matrix: real modules in the partial corner section + # must read as MODULE_WHITE, padding cells as MODULE_OUTSIDE. + matrix = _fake_matrix(29, fill=seedqr.MODULE_WHITE) + section = seedqr.extract_section(matrix, 5, 5) + for dy in range(5): + for dx in range(5): + if dx < 4 and dy < 4: + self.assertEqual(section[dy][dx], seedqr.MODULE_WHITE) + else: + self.assertEqual(section[dy][dx], seedqr.MODULE_OUTSIDE) + + def test_out_of_range_section_raises(self): + with self.assertRaises(ValueError): + seedqr.section_bounds(21, 3, 0) + with self.assertRaises(ValueError): + seedqr.extract_section(_fake_matrix(21), 0, 3) + + def test_extract_real_section_has_no_outside_padding(self): + # 29x29: partial trailing row/col sections must come back sized to + # their true (smaller) extent, never padded up to 5x5 with filler. + matrix = _identifiable_matrix(29) + full = seedqr.extract_real_section(matrix, 0, 0) + self.assertEqual((len(full), len(full[0])), (5, 5)) + + corner = seedqr.extract_real_section(matrix, 5, 5) # F-6: 4x4 real + self.assertEqual((len(corner), len(corner[0])), (4, 4)) + + edge_row = seedqr.extract_real_section(matrix, 5, 0) # F-1: 4 rows x 5 cols + self.assertEqual((len(edge_row), len(edge_row[0])), (4, 5)) + + edge_col = seedqr.extract_real_section(matrix, 0, 5) # A-6: 5 rows x 4 cols + self.assertEqual((len(edge_col), len(edge_col[0])), (5, 4)) + + for section in (full, corner, edge_row, edge_col): + for row in section: + self.assertNotIn(seedqr.MODULE_OUTSIDE, row) + + def test_extract_real_section_matches_true_matrix_values(self): + for size in (21, 25, 29): + matrix = _identifiable_matrix(size) + n = seedqr.num_sections(size) + for row in range(n): + for col in range(n): + x0, y0, w, h = seedqr.section_bounds(size, row, col) + section = seedqr.extract_real_section(matrix, row, col) + for dy in range(h): + for dx in range(w): + self.assertEqual(section[dy][dx], matrix[y0 + dy][x0 + dx]) + + +class TouchMappingTest(TestCase): + def test_top_left_tap_maps_to_a1(self): + section = seedqr.coord_to_section(0, 0, 21, module_px=10) + self.assertEqual(section, (0, 0)) + self.assertEqual(seedqr.section_label(*section), "A-1") + + def test_bottom_right_valid_module_maps_to_final_section(self): + size = 29 + module_px = 10 + last_px = size * module_px - 1 # inside the very last module + section = seedqr.coord_to_section(last_px, last_px, size, module_px) + self.assertEqual(section, (5, 5)) + self.assertEqual(seedqr.section_label(*section), "F-6") + + def test_taps_outside_matrix_bounds_are_ignored(self): + size, module_px = 21, 10 + matrix_px = size * module_px + self.assertIsNone(seedqr.coord_to_module(-1, 0, size, module_px)) + self.assertIsNone(seedqr.coord_to_module(0, -1, size, module_px)) + self.assertIsNone(seedqr.coord_to_module(matrix_px, 0, size, module_px)) + self.assertIsNone(seedqr.coord_to_module(0, matrix_px, size, module_px)) + + def test_taps_in_quiet_zone_are_ignored_by_caller_contract(self): + # coord_to_module takes coordinates with the quiet zone already + # excluded; anything the caller maps to a negative offset (i.e. a + # tap that landed in the quiet zone) must be rejected the same way + # as any other out-of-bounds tap. + size, module_px = 21, 10 + self.assertIsNone(seedqr.coord_to_module(-5, -5, size, module_px)) + + def test_taps_exactly_on_section_boundary_are_handled_consistently(self): + size, module_px = 25, 10 + mps = seedqr.modules_per_section(size) + boundary_px = mps * module_px # first pixel of the *next* section + # last pixel column of the first section + self.assertEqual( + seedqr.coord_to_section(boundary_px - 1, 0, size, module_px), + (0, 0), + ) + # first pixel column of the second section + self.assertEqual( + seedqr.coord_to_section(boundary_px, 0, size, module_px), + (0, 1), + ) + + def test_screen_offset_is_the_callers_responsibility_and_composes_linearly(self): + # Simulates a screen translating a raw touch point into + # matrix-relative coordinates before calling coord_to_section. + size, module_px = 21, 10 + origin_x, origin_y = 37, 84 # matrix top-left on screen + raw_x, raw_y = origin_x + 15, origin_y + 5 # inside module (1, 0) + rel_x, rel_y = raw_x - origin_x, raw_y - origin_y + self.assertEqual(seedqr.coord_to_module(rel_x, rel_y, size, module_px), (1, 0)) + + +class NavigationTest(TestCase): + def test_left_unavailable_in_column_1(self): + nav = seedqr.ZoomNavigator(size=25, row=2, col=0) + self.assertFalse(nav.can_move("left")) + self.assertEqual(nav.move("left"), (2, 0)) + + def test_up_unavailable_in_row_a(self): + nav = seedqr.ZoomNavigator(size=25, row=0, col=2) + self.assertFalse(nav.can_move("up")) + self.assertEqual(nav.move("up"), (0, 2)) + + def test_right_and_down_stop_at_correct_edge(self): + size = 25 + n = seedqr.num_sections(size) + nav = seedqr.ZoomNavigator(size=size, row=0, col=0) + for _ in range(n + 2): + nav.move("right") + self.assertEqual(nav.col, n - 1) + for _ in range(n + 2): + nav.move("down") + self.assertEqual(nav.row, n - 1) + + def test_navigation_into_partial_final_section_works(self): + size = 29 + n = seedqr.num_sections(size) + nav = seedqr.ZoomNavigator(size=size, row=0, col=0) + for _ in range(n - 1): + self.assertTrue(nav.can_move("right")) + nav.move("right") + for _ in range(n - 1): + self.assertTrue(nav.can_move("down")) + nav.move("down") + self.assertEqual(nav.section, (n - 1, n - 1)) + self.assertEqual(nav.label, "F-6") + x0, y0, w, h = seedqr.section_bounds(size, *nav.section) + self.assertEqual((w, h), (4, 4)) + + def test_returning_to_overview_preserves_current_section(self): + nav = seedqr.ZoomNavigator(size=25, row=0, col=0) + nav.move("right") + nav.move("down") + selected = nav.section + # Simulate closing the zoom view and reopening it at the section it + # was last on -- the overview screen just needs to remember the + # tuple and hand it back as the initial position. + reopened = seedqr.ZoomNavigator(size=25, row=selected[0], col=selected[1]) + self.assertEqual(reopened.section, selected) + + def test_unknown_direction_raises(self): + with self.assertRaises(ValueError): + seedqr.neighbor_section(0, 0, 25, "sideways") + + +class NoUnconditionalPrintTest(TestCase): + """ + Regression tests ensuring secret QR content (mnemonic, SeedQR digits, + Compact SeedQR bytes, the QR matrix) can't reach stdout/logs. + """ + + def _assert_no_unconditional_print(self, path, qualnames): + """ + For each dotted qualname (e.g. "QRCode._set_text"), assert the + function body contains no `print(...)` call at its own top level + (i.e. every print call, if any, is nested inside a conditional). + """ + tree = ast.parse(path.read_text(), filename=str(path)) + found = {} + + class ClassVisitor(ast.NodeVisitor): + def visit_ClassDef(self, node): + for item in node.body: + if isinstance(item, ast.FunctionDef): + found["%s.%s" % (node.name, item.name)] = item + self.generic_visit(node) + + ClassVisitor().visit(tree) + + for qualname in qualnames: + self.assertIn(qualname, found, "%s not found in %s" % (qualname, path)) + func = found[qualname] + for stmt in func.body: + is_print_expr = ( + isinstance(stmt, ast.Expr) + and isinstance(stmt.value, ast.Call) + and isinstance(stmt.value.func, ast.Name) + and stmt.value.func.id == "print" + ) + self.assertFalse( + is_print_expr, + "%s in %s contains an unconditional print() call" % (qualname, path), + ) + + def test_qrcode_component_has_no_unconditional_print(self): + path = SRC_DIR / "gui" / "components" / "qrcode.py" + self._assert_no_unconditional_print(path, ["QRCode._set_text", "QRCode.set_text"]) + + def test_seedqr_pure_functions_never_print(self): + import builtins + + calls = [] + original_print = builtins.print + builtins.print = lambda *a, **kw: calls.append((a, kw)) + try: + matrix = seedqr.generate_matrix(DOC_STANDARD_PAYLOAD) + for size in (21, 25, 29): + m = _identifiable_matrix(size) + n = seedqr.num_sections(size) + for row in range(n): + for col in range(n): + seedqr.extract_section(m, row, col) + nav = seedqr.ZoomNavigator(size=29, row=0, col=0) + for direction in ("right", "down", "left", "up"): + nav.move(direction) + seedqr.compact_payload(DOC_MNEMONIC) + finally: + builtins.print = original_print + self.assertEqual(calls, []) + + def test_ram_keystore_does_not_display_raw_seedqr_payload_as_text(self): + """ + Standard/Compact SeedQR selections must route to the dedicated + transcription viewer, not to QRAlert(message=), which + would render the digit string / hex underneath the QR code. + """ + path = SRC_DIR / "keystore" / "ram.py" + src = path.read_text() + self.assertNotIn("hexlify(qr_msg)", src)