From 8f1001f61983cf60023120af4b01bbc99d6f3a84 Mon Sep 17 00:00:00 2001 From: Ben Westgate Date: Sat, 19 Sep 2026 23:49:55 -0500 Subject: [PATCH 1/2] gui: Warn checksum completers before guessing A full Codex32 checksum is 13 symbols on short cards and 15 on long cards. Entering question marks for that entire suffix reaches the same low-discrimination gate as a badly damaged card, so the warning must also address someone completing a hand-written backup. Explain that earlier transcription mistakes become undetectable once the checksum is completed, and explicitly forbid replacing a failing checksum to make a card validate. Keep the route undiscoverable in the GUI, where worksheet completion is not the target workflow. Cover both 13-symbol and 15-symbol checksum-completion routes. Refs https://github.com/BlockstreamResearch/codex32/discussions/78 Validation: 34 GUI-reading tests passed; ruff check passed; ruff format --check passed; git diff --check passed. --- docs/developer/gui.md | 8 ++++++ docs/user/gui.md | 15 +++++++++-- src/codex32_gui/pages.py | 30 +++++++++++++++++++--- tests/test_gui_reading.py | 7 +++--- tools/gui_walkthrough.py | 53 +++++++++++++++++++++++++++++++++++++++ 5 files changed, 104 insertions(+), 9 deletions(-) diff --git a/docs/developer/gui.md b/docs/developer/gui.md index c922ce7..4b5c9a9 100644 --- a/docs/developer/gui.md +++ b/docs/developer/gui.md @@ -141,6 +141,14 @@ library's. - The account number is fixed at 0, which is the command line's default. The restore screen says so, and points anyone whose wallet record shows another number at `ms32 wallet --account N`. +- The window offers no checksum completer, and does not tell the operator that + 13 or 15 trailing `?`, depending on card length, would be one. It is aimed at + someone whose seed comes from the operating system, for whom a Book worksheet + never arises, so advertising the route there would be all cost. The route is + reachable anyway, so `_guess_gate_page` speaks to a person completing new data + as well as to a person recovering a damaged card, and forbids replacing a + checksum outright. `docs/user/guide.md` documents the route for the command + line, which is where the worksheet audience already is. - Restoring asks the operator to *check* the wallet-identity fields against their record rather than copy them onto it, and shows no creation date. A restore is the only moment the program can show that the cards entered belong diff --git a/docs/user/gui.md b/docs/user/gui.md index 3566a39..31e9035 100644 --- a/docs/user/gui.md +++ b/docs/user/gui.md @@ -93,8 +93,19 @@ compare it character by character before you accept it. If too much is missing, the window stops and asks you to type `YES` in capitals first. That is not a formality: with that little checksum left, a repair can look -correct without being correct, and any earlier mistake gets locked in with -nothing left to detect it. If the funds matter, stop there and get help. +correct without being correct. The screen says so twice over, because two +different people reach it. If you are filling in the last squares of a backup you +are making by hand, it tells you to check every character against what you wrote, +since completing the squares locks any earlier mistake in for good. If you are +recovering a damaged card, it tells you the answer may simply be wrong, and that +you may have to try likely misreadings one at a time. If the funds matter, stop +there and get help. + +**Never erase a card's last characters to make it check out.** A card that fails +its check is telling you something is wrong. Replacing the ending hides that +mistake inside a result that now looks valid, and you lose the one signal that +would have found it. Type what the card actually says, `?` included, and let the +window work from that. If more than one repair fits, the window shows none of them. Check the card again. diff --git a/src/codex32_gui/pages.py b/src/codex32_gui/pages.py index b529356..1c6d3d4 100644 --- a/src/codex32_gui/pages.py +++ b/src/codex32_gui/pages.py @@ -1132,7 +1132,15 @@ def following() -> Adw.NavigationPage: def _guess_gate_page( view: Adw.NavigationView, following: Callable[[], Adw.NavigationPage] ) -> Adw.NavigationPage: - """Invariant 5: disclose nothing about the candidate until literal YES is typed.""" + """Invariant 5: disclose nothing about the candidate until literal YES is typed. + + Thirteen or fifteen unreadable characters at the end of a card are the whole + checksum, depending on card length, so this screen is also what someone + filling in the last squares of a hand-made backup reaches. It has to speak to + both of them: a person recovering a damaged card, who may be shown something + simply wrong, and a person completing new data, whose earlier mistakes this + would set in stone. + """ field = Gtk.Entry(placeholder_text="YES") show = _button("Show the guess", lambda: view.push(following()), style="destructive-action") show.set_sensitive(False) @@ -1140,9 +1148,23 @@ def _guess_gate_page( content = _column( _title("This repair would be a guess"), _note( - "So much of this card is unreadable that codex32 can fill in the blanks in a way that looks " - "correct without being correct. If any earlier character is also wrong, that mistake gets " - "locked in and the card becomes wrong forever, with nothing left to detect it.", + "So much of this card is unreadable that codex32 can fill in the blanks in a way that " + "looks correct without being correct.", + "warning", + ), + _note( + "If you are filling in the last squares of a backup you are making by hand, check every " + "character you have typed against what you wrote down before you go on. Nothing can " + "detect a mistake made earlier: filling in the squares locks it in for good." + ), + _note( + "If you are recovering a damaged card, the answer may simply be wrong. If it does not " + "restore your wallet, you may have to try likely misreadings one at a time." + ), + _note( + "Never erase a card's last characters to make it check out. A card that fails its check " + "is telling you something is wrong, and replacing the ending hides that mistake instead " + "of finding it.", "warning", ), _note("If the funds matter, stop here and get help instead."), diff --git a/tests/test_gui_reading.py b/tests/test_gui_reading.py index 1641922..36c56f6 100644 --- a/tests/test_gui_reading.py +++ b/tests/test_gui_reading.py @@ -1,7 +1,7 @@ """What the graphical entry field makes of typed text, without a display.""" import pytest -from data.bip93_vectors import VECTOR_2, VECTOR_3 +from data.bip93_vectors import VECTOR_2, VECTOR_3, VECTOR_5 from codex32_gui.reading import ( PREFIX, @@ -127,10 +127,11 @@ def test_a_repair_that_repeats_an_accepted_card_is_not_offered() -> None: assert isinstance(repair(SHARE_A[:-1] + "Q", 48, ("a",)), str) -def test_a_card_with_too_little_checksum_left_demands_the_warning() -> None: +@pytest.mark.parametrize(("card", "checksum_length"), ((SHARE_C, 13), (VECTOR_5["secret_s"], 15))) +def test_a_card_with_too_little_checksum_left_demands_the_warning(card: str, checksum_length: int) -> None: from codex32 import CorrectionCandidate - found = repair(SHARE_C[:-13] + "?" * 13, None) + found = repair(card[:-checksum_length] + "?" * checksum_length, None) assert isinstance(found, CorrectionCandidate) assert found.low_checksum_discrimination diff --git a/tools/gui_walkthrough.py b/tools/gui_walkthrough.py index b6b4a44..a1a7b43 100644 --- a/tools/gui_walkthrough.py +++ b/tools/gui_walkthrough.py @@ -137,6 +137,8 @@ def do_activate(self) -> None: self.candidate, self.back_to_entry, self.accepted_repair, + self.completion_entry, + self.completion_gate, self.preflight, self.network, self.letters, @@ -278,6 +280,57 @@ def accepted_repair(self) -> bool: press(page, "Done") return True + def completion_entry(self) -> bool: + """Thirteen unreadable characters at the end are a whole checksum.""" + page = self.page() + if page.get_title() != "codex32": + return False + rows(page)[3].emit("activated") + page = self.page() + field = field_of(page) + field.set_text(SHARE_C[:-13] + "?" * 13) + settle() + fix = button(page, "Suggest a repair") + check("completing a checksum is offered as a repair", fix.get_sensitive()) + fix.emit("clicked") + return True + + def completion_gate(self) -> bool: + page = self.page() + if page.get_title() != "Warning" or button(page, "Show the guess") is None: + return False + shown = labels(page) + check("nothing about the guess is disclosed yet", card(page) == "", card(page)) + check( + "the gate addresses someone completing new data", + any("making by hand" in text and "locks it in" in text for text in shown), + [text for text in shown if "hand" in text], + ) + check( + "and someone recovering a damaged card", + any("may simply be wrong" in text for text in shown), + [text for text in shown if "wrong" in text], + ) + check( + "and forbids replacing a checksum outright", + any("Never erase" in text for text in shown), + [text for text in shown if "Never" in text], + ) + show = button(page, "Show the guess") + check("the guess stays hidden until YES is typed", not show.get_sensitive()) + entry = next(item for item in walk(page) if isinstance(item, Gtk.Entry)) + for typed in ("yes", "Yes", "YES please", "Y"): + entry.set_text(typed) + settle() + check(f"{typed!r} does not open the gate", not show.get_sensitive()) + entry.set_text("YES") + settle() + check("literal YES opens it", show.get_sensitive()) + press(page, "Cancel") + settle() + self.view.replace([pages.home(self.view)]) + return True + def preflight(self) -> bool: page = self.page() if page.get_title() != "codex32": From 3d6483ef7e1a8b21777b0eb178295ef4b0290462 Mon Sep 17 00:00:00 2001 From: Ben Westgate Date: Sun, 27 Sep 2026 11:31:27 -0500 Subject: [PATCH 2/2] gui: Keep warning within size budget Compress the gate docstring without changing behavior. This leaves the combined #10 + #28 GUI at 1,997 non-comment lines, below the enforced 2,000-line budget. --- src/codex32_gui/pages.py | 10 +--------- 1 file changed, 1 insertion(+), 9 deletions(-) diff --git a/src/codex32_gui/pages.py b/src/codex32_gui/pages.py index 1c6d3d4..9adf143 100644 --- a/src/codex32_gui/pages.py +++ b/src/codex32_gui/pages.py @@ -1132,15 +1132,7 @@ def following() -> Adw.NavigationPage: def _guess_gate_page( view: Adw.NavigationView, following: Callable[[], Adw.NavigationPage] ) -> Adw.NavigationPage: - """Invariant 5: disclose nothing about the candidate until literal YES is typed. - - Thirteen or fifteen unreadable characters at the end of a card are the whole - checksum, depending on card length, so this screen is also what someone - filling in the last squares of a hand-made backup reaches. It has to speak to - both of them: a person recovering a damaged card, who may be shown something - simply wrong, and a person completing new data, whose earlier mistakes this - would set in stone. - """ + """Invariant 5: disclose nothing until YES; whole-checksum completion reaches this gate too.""" field = Gtk.Entry(placeholder_text="YES") show = _button("Show the guess", lambda: view.push(following()), style="destructive-action") show.set_sensitive(False)