Skip to content
Draft
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
14 changes: 13 additions & 1 deletion docs/developer/gui.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,8 @@ cryptography, entropy source, socket, or file storage.
Review `reading.py`, `wallet_setup.py`, and `work.py` first. Their behavior is
covered without a display. `tools/gui_walkthrough.py` exercises the real GTK
screens under Xvfb. `tests/test_gui_boundaries.py` enforces a separate 2,250
logical-line GUI budget.
logical-line GUI budget, including the Bails restore-identity checks and fixed
read-back boxes. The library retains its existing budget.

## Security boundaries

Expand Down Expand Up @@ -54,6 +55,17 @@ The entry widget has three internal modes:
- **Read-back:** starts empty, normalizes only spacing and ASCII case, and never
reveals expected text after a mismatch.

Read-back uses `codex32._confirmation._entered_groups`, shared with the CLI.
After a mismatch, matching groups lock; only the entered text is shown. Arrows
cross editable-group boundaries, Ctrl+arrows skip groups, Tab cycles, and
Home/End reach the first/last editable group. Paste and drop are disabled.
Invalid characters and missing groups are compared directly. Input over twice
the card's length marks every group wrong without aligning it. While typing,
each correct group locks once the next group is typed, and the field turns red
when its first group still mismatches a further group later.
`tests/test_gui_read_back.py` exercises these behaviors under GTK. Review
`entry.py` for keyboard and clipboard handling.

Enter activates the enabled primary action. Card displays use four
four-character groups per row, matching `docs/user/recovery-card.html`.
Ordinary repair suggestions are unmarked; explicit correction may mark changed
Expand Down
7 changes: 5 additions & 2 deletions docs/user/gui.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,8 +63,11 @@ The recommended layout is three cards where any two recover the wallet. One card
can be lost, and one card alone reveals nothing.

Copy each card to paper, hide the on-screen original, then type the paper copy
back. Read-back starts completely empty, including `MS1`. A mismatch highlights
only the groups you typed differently; the expected text stays hidden.
back. Read-back starts empty, including `MS1`, and does not accept paste. Correct
groups lock as you type, and the box turns red when its first group is wrong.
After a mismatch, correct the highlighted boxes from your paper card; matching
groups are locked. Arrows, Ctrl+arrows, Tab, Home and End move between editable boxes.
The expected text stays hidden. Press Enter to confirm.

After all cards are confirmed, write the displayed master fingerprint on your
wallet record, then re-enter it in the GUI. Wallet selection stays unavailable
Expand Down
65 changes: 55 additions & 10 deletions src/codex32_gui/entry.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,9 @@
from __future__ import annotations

from dataclasses import replace
from typing import Literal, cast
from typing import Literal

from gi.repository import GLib, Gtk
from gi.repository import Gdk, GLib, Gtk

from codex32.profiles.ms32 import TEXT_LENGTHS
from codex32_gui.reading import (
Expand All @@ -19,7 +19,6 @@
normalize,
normalize_readback,
read,
readback,
)

EntryMode = Literal["import", "correct", "readback"]
Expand All @@ -40,8 +39,6 @@ def __init__(
allow_header_erasures: bool = False,
) -> None:
super().__init__()
if mode == "readback" and length is None:
raise ValueError("read-back entry requires the card length")
self._accepted, self._length, self._mode = accepted, length, mode
self._expected_header, self._allow_header_erasures, self._pending = (
expected_header,
Expand All @@ -60,6 +57,8 @@ def __init__(
self.connect("changed", self._schedule)
self.connect("notify::selection-bound", self._unpublish)
self.connect("notify::cursor-position", self._unpublish)
if mode == "readback":
refuse_paste(self)
GLib.idle_add(self.set_position, -1)

def prefill(self, text: str) -> None:
Expand All @@ -72,8 +71,6 @@ def prefill(self, text: str) -> None:

def reading(self) -> Reading:
"""Return the current reading of this field."""
if self._mode == "readback":
return readback(self.get_text(), cast(int, self._length))
state = read(normalize(self.get_text()), accepted=self._accepted, length=self._length)
if fault := self._header_fault():
state = replace(state, artifact=None, message=fault, level="error")
Expand All @@ -89,8 +86,6 @@ def clear(self) -> None:
self.set_position(-1)

def _header_fault(self, raw: str | None = None) -> str:
if self._mode == "readback":
return ""
value = self.get_text() if raw is None else raw
compact = "".join(value.split()).upper()
text = compact if compact.startswith(PREFIX) else normalize(value)
Expand Down Expand Up @@ -132,7 +127,7 @@ def _reformat(self) -> bool:
return False
duplicated_prefix = self._mode == "import" and compact.startswith(PREFIX + PREFIX)
source = compact[len(PREFIX) :] if duplicated_prefix else raw
canonical = normalizer(source)[: self._limit]
canonical = normalizer(source)[: None if self._mode == "readback" else self._limit]
shown = grouped(canonical)
self._dropped = "" if self._mode == "readback" else lookalike_fault(raw)
fault = self._header_fault(canonical) if self._mode == "import" else ""
Expand Down Expand Up @@ -162,3 +157,53 @@ def _rewrite(self, shown: str, position: int) -> None:
self.set_text(shown)
self._rewriting = False
self.set_position(position)


class GroupEntry(Codex32Entry):
"""Only the operator's transcription of one mismatched group."""

def __init__(self, text: str) -> None:
super().__init__(length=GROUP, mode="readback")
self.set_properties(width_chars=5, max_width_chars=5, hexpand=False, text=text)


def refuse_paste(entry: Gtk.Entry) -> None:
"""Require read-back from paper, including in correction boxes."""
text = entry.get_delegate()
text.connect("paste-clipboard", lambda text: text.stop_emission_by_name("paste-clipboard"))
middle = Gtk.GestureClick(button=2, propagation_phase=Gtk.PropagationPhase.CAPTURE)
middle.connect("pressed", lambda gesture, *_: gesture.set_state(Gtk.EventSequenceState.CLAIMED))
entry.add_controller(middle)
controllers = text.observe_controllers()
for controller in [controllers.get_item(i) for i in range(controllers.get_n_items())]:
if isinstance(controller, Gtk.DropTarget):
text.remove_controller(controller)


def navigate_groups(entries: list[GroupEntry]) -> None:
"""Navigate editable groups while leaving character movement to GTK."""

def key(controller: Gtk.EventControllerKey, value: int, _code: int, state: Gdk.ModifierType) -> bool:
entry = controller.get_widget()
current, back = entries.index(entry), value in (Gdk.KEY_Left, Gdk.KEY_KP_Left, Gdk.KEY_ISO_Left_Tab)
if value in (Gdk.KEY_Tab, Gdk.KEY_ISO_Left_Tab):
target = (current + (-1 if back or state & Gdk.ModifierType.SHIFT_MASK else 1)) % len(entries)
elif value in (Gdk.KEY_Home, Gdk.KEY_End):
target = 0 if value == Gdk.KEY_Home else len(entries) - 1
elif value in (Gdk.KEY_Left, Gdk.KEY_Right, Gdk.KEY_KP_Left, Gdk.KEY_KP_Right) and (
state & Gdk.ModifierType.CONTROL_MASK
or entry.get_position() == (0 if back else len(entry.get_text()))
):
target = current + (-1 if back else 1)
if not 0 <= target < len(entries):
return False
else:
return False
entries[target].grab_focus()
entries[target].set_position(-1 if back or value == Gdk.KEY_End else 0)
return True

for entry in entries:
controller = Gtk.EventControllerKey(propagation_phase=Gtk.PropagationPhase.CAPTURE)
controller.connect("key-pressed", key)
entry.add_controller(controller)
158 changes: 94 additions & 64 deletions src/codex32_gui/pages.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,10 +26,11 @@
generate_master_seed,
recover_secret,
)
from codex32._confirmation import _entered_groups
from codex32.errors import CodexError
from codex32.generation import ORDINARY_INDICES
from codex32_gui import ARTWORK, reading, wallet_setup, work
from codex32_gui.entry import Codex32Entry, EntryMode
from codex32_gui.entry import Codex32Entry, EntryMode, GroupEntry, navigate_groups
from codex32_gui.wallet_setup import BitcoinCore

Artifact = Share | Secret
Expand Down Expand Up @@ -326,19 +327,6 @@ def _describe(artifact: Artifact) -> str:
)


def _compare(expected: str, entered: str) -> ConfirmationResult:
"""Report which four-character groups do not match, by the library's own rule."""
observed, wanted = "".join(entered.split()).lower(), expected.lower()
span = (max(len(observed), len(wanted)) + reading.GROUP - 1) // reading.GROUP
mismatched = tuple(
group + 1
for group in range(span)
if observed[group * reading.GROUP : (group + 1) * reading.GROUP]
!= wanted[group * reading.GROUP : (group + 1) * reading.GROUP]
)
return ConfirmationResult(not mismatched, mismatched)


def _guessed(observed: str, corrected: str) -> frozenset[int]:
"""Mark the four-character windows a correction changed, so they can be compared."""
changed: set[int] = set()
Expand Down Expand Up @@ -504,67 +492,109 @@ def _read_back_page(
confirm: Callable[[str], ConfirmationResult],
after: Callable[[], None],
) -> Adw.NavigationPage:
"""Read the card back from the paper, with the original off the screen."""
"""Read from paper with the CLI's alignment; `confirm` sees only an exact match."""
field = Codex32Entry(length=len(card.text), mode="readback")
status = _note("")
comparison = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
accept = _button("Confirm card", lambda: None, style="suggested-action")

def clear_comparison() -> None:
while (child := comparison.get_first_child()) is not None:
comparison.remove(child)
boxes: list[Gtk.Label | GroupEntry] = []
status, comparison, wanted = _note(""), Adw.Bin(), card.text.upper()
accept = _button("Confirm card", lambda: check(), style="suggested-action")

def clear_boxes() -> None:
previous = boxes.copy()
boxes.clear()
comparison.set_child(None)
field.set_visible(True)
for box in previous:
box.clear() if isinstance(box, GroupEntry) else box.set_text("")

def place(box: Gtk.Label | GroupEntry) -> None:
if not isinstance(grid := comparison.get_child(), Gtk.Grid):
comparison.set_child(grid := Gtk.Grid(column_spacing=8, row_spacing=8, halign=Gtk.Align.CENTER))
box.add_css_class("card-group")
grid.attach(box, len(boxes) % 4, len(boxes) // 4, 1, 1)
boxes.append(box)

def entered() -> str:
return "".join(box.get_text() for box in boxes) + (field.get_text() if field.get_visible() else "")

def update(*_arguments: object) -> None:
clear_comparison()
state = field.reading()
accept.set_sensitive(state.complete)
_say(status, state.message, state.level)
if field._rewriting:
return
if not field.get_text() and not field.get_visible():
clear_boxes()
text = reading.normalize_readback(entered())
accept.set_sensitive(bool(text))
_say(status, f"{len(text)} of {len(card.text)} characters")
if field.get_visible():
GLib.idle_add(lock)

def lock() -> bool:
# Lock each correct group once the next is typed; flag a wrong one a group later.
text, done = reading.normalize_readback(field.get_text()), 4 * len(boxes)
taken = 0
while len(text) >= taken + 8 and text[taken : taken + 4] == wanted[done + taken : done + taken + 4]:
place(Gtk.Label(label=text[taken : taken + 4]))
taken += 4
if taken:
field._rewrite(reading.grouped(text[taken:]), max(field.get_position() - taken * 5 // 4, 0))
wrong = len(text) >= taken + 12 and text[taken : taken + 4] != wanted[done + taken : done + taken + 4]
(field.add_css_class if wrong else field.remove_css_class)("error")
if wrong:
_say(status, "The first group in the box does not match your card.", "error")
return False

def check() -> None:
try:
state = field.reading()
result = confirm(state.text)
except CodexError as error:
_failure(view, error, CARDS_SAFE)
return
if not result.accepted:
groups = frozenset(group - 1 for group in result.mismatched_groups)
clear_comparison()
comparison.append(_card(state.text, groups, highlight_class="mismatch"))
plural = "s" if len(groups) != 1 else ""
message = f"Highlighted group{plural} {'do' if plural else 'does'} not match. Re-read the card."
_say(status, message, "error")
return
text = reading.normalize_readback(entered())
if text == wanted:
try:
result = confirm(text)
except CodexError as error:
_failure(view, error, CARDS_SAFE)
return
if result.accepted:
clear_page()
after()
return
if field.get_visible():
pieces, changed = _entered_groups(text, card.text)
else:
pieces = [reading.normalize_readback(box.get_text()) for box in boxes]
changed = {
index for index, piece in enumerate(pieces) if piece != wanted[index * 4 : index * 4 + 4]
}
clear_boxes()
for index, piece in enumerate(pieces):
piece = reading.normalize_readback(piece)
box = GroupEntry(piece) if index in changed else Gtk.Label(label=piece)
if isinstance(box, GroupEntry):
box.add_css_class("mismatch")
box.connect("changed", update)
box.connect("activate", lambda _box: check() if accept.get_sensitive() else None)
place(box)
editable = [box for box in boxes if isinstance(box, GroupEntry)]
navigate_groups(editable)
field.set_visible(False)
if editable:
editable[0].grab_focus()
_say(status, "Re-read the highlighted groups from your card.", "error")

def clear_page() -> None:
field.clear()
after()
clear_boxes()

accept.connect("clicked", lambda _button: check())
field.connect("activate", lambda _entry: check() if accept.get_sensitive() else None)
field.connect("changed", update)
content = _column(
_title("Now type it back from the card", _counted(card.header.index.upper(), position, count)),
_note(
"The original is no longer on screen. Read from the card you just wrote, so a slip of the "
"pen is caught now rather than years from now."
),
field,
status,
comparison,
_note(
"Spaces and capitals do not matter. After a mismatch, only what you typed is shown; the "
"original stays hidden. Re-read the highlighted groups from your card and try again."
),
)
page = _page(
_counted(card.header.index.upper(), position, count),
content,
_column(
_title("Now type it back from the card", _counted(card.header.index.upper(), position, count)),
_note("Read every character from your paper card, including MS1. Pasting is turned off."),
comparison,
field,
status,
_note("Correct groups lock as you type. Use arrows, Ctrl+arrows, Tab, Home or End to move."),
),
actions=_actions(_button("Show the card again", view.pop), accept),
)

def clear_page() -> None:
field.clear()
clear_comparison()

_forget_when_gone(view, page, clear_page)
update()
return page
Expand Down Expand Up @@ -687,7 +717,7 @@ def _unshared_page(view: Adw.NavigationView, core: BitcoinCore, secret: MasterSe
card=secret,
position=0,
count=1,
confirm=lambda text: _compare(secret.text, text),
confirm=lambda _text: ConfirmationResult(True, ()),
after=lambda: _identity(view, core, secret),
cancel=lambda page: _abandon(view, page),
)
Expand Down Expand Up @@ -1618,7 +1648,7 @@ def _derive(view: Adw.NavigationView, found: tuple[Artifact, ...], letter: str)
card=share,
position=0,
count=None,
confirm=lambda text: _compare(share.text, text),
confirm=lambda _text: ConfirmationResult(True, ()),
after=lambda: _replace(view, _share_done_page(view, share)),
cancel=lambda _page: view.replace([home(view)]),
),
Expand Down
10 changes: 0 additions & 10 deletions src/codex32_gui/reading.py
Original file line number Diff line number Diff line change
Expand Up @@ -149,16 +149,6 @@ def read(text: str, *, accepted: tuple[str, ...] = (), length: int | None = None
return Reading(artifact.text, artifact, "Every character checks out.", "success", expected, 0, False)


def readback(raw: str, length: int) -> Reading:
"""Describe an independently re-entered card without interpreting or repairing it."""
text = normalize_readback(raw)
unreadable = text.count("?")
fault = lookalike_fault(text)
level = "error" if fault else ""
message = fault or f"{len(text)} of {length} characters"
return Reading(text, None, message, level, length, unreadable, False)


def repair(text: str, length: int | None, excluded: tuple[str, ...] = ()) -> CorrectionCandidate | str:
"""Ask the library for one repair, and offer nothing at all when it is unsure.

Expand Down
Loading
Loading