Skip to content

Commit 8f1001f

Browse files
committed
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 BlockstreamResearch/codex32#78 Validation: 34 GUI-reading tests passed; ruff check passed; ruff format --check passed; git diff --check passed.
1 parent 78f4a3b commit 8f1001f

5 files changed

Lines changed: 104 additions & 9 deletions

File tree

‎docs/developer/gui.md‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -141,6 +141,14 @@ library's.
141141
- The account number is fixed at 0, which is the command line's default. The
142142
restore screen says so, and points anyone whose wallet record shows another
143143
number at `ms32 wallet --account N`.
144+
- The window offers no checksum completer, and does not tell the operator that
145+
13 or 15 trailing `?`, depending on card length, would be one. It is aimed at
146+
someone whose seed comes from the operating system, for whom a Book worksheet
147+
never arises, so advertising the route there would be all cost. The route is
148+
reachable anyway, so `_guess_gate_page` speaks to a person completing new data
149+
as well as to a person recovering a damaged card, and forbids replacing a
150+
checksum outright. `docs/user/guide.md` documents the route for the command
151+
line, which is where the worksheet audience already is.
144152
- Restoring asks the operator to *check* the wallet-identity fields against
145153
their record rather than copy them onto it, and shows no creation date. A
146154
restore is the only moment the program can show that the cards entered belong

‎docs/user/gui.md‎

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -93,8 +93,19 @@ compare it character by character before you accept it.
9393

9494
If too much is missing, the window stops and asks you to type `YES` in capitals
9595
first. That is not a formality: with that little checksum left, a repair can look
96-
correct without being correct, and any earlier mistake gets locked in with
97-
nothing left to detect it. If the funds matter, stop there and get help.
96+
correct without being correct. The screen says so twice over, because two
97+
different people reach it. If you are filling in the last squares of a backup you
98+
are making by hand, it tells you to check every character against what you wrote,
99+
since completing the squares locks any earlier mistake in for good. If you are
100+
recovering a damaged card, it tells you the answer may simply be wrong, and that
101+
you may have to try likely misreadings one at a time. If the funds matter, stop
102+
there and get help.
103+
104+
**Never erase a card's last characters to make it check out.** A card that fails
105+
its check is telling you something is wrong. Replacing the ending hides that
106+
mistake inside a result that now looks valid, and you lose the one signal that
107+
would have found it. Type what the card actually says, `?` included, and let the
108+
window work from that.
98109

99110
If more than one repair fits, the window shows none of them. Check the card again.
100111

‎src/codex32_gui/pages.py‎

Lines changed: 26 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1132,17 +1132,39 @@ def following() -> Adw.NavigationPage:
11321132
def _guess_gate_page(
11331133
view: Adw.NavigationView, following: Callable[[], Adw.NavigationPage]
11341134
) -> Adw.NavigationPage:
1135-
"""Invariant 5: disclose nothing about the candidate until literal YES is typed."""
1135+
"""Invariant 5: disclose nothing about the candidate until literal YES is typed.
1136+
1137+
Thirteen or fifteen unreadable characters at the end of a card are the whole
1138+
checksum, depending on card length, so this screen is also what someone
1139+
filling in the last squares of a hand-made backup reaches. It has to speak to
1140+
both of them: a person recovering a damaged card, who may be shown something
1141+
simply wrong, and a person completing new data, whose earlier mistakes this
1142+
would set in stone.
1143+
"""
11361144
field = Gtk.Entry(placeholder_text="YES")
11371145
show = _button("Show the guess", lambda: view.push(following()), style="destructive-action")
11381146
show.set_sensitive(False)
11391147
field.connect("changed", lambda _entry: show.set_sensitive(field.get_text().strip() == "YES"))
11401148
content = _column(
11411149
_title("This repair would be a guess"),
11421150
_note(
1143-
"So much of this card is unreadable that codex32 can fill in the blanks in a way that looks "
1144-
"correct without being correct. If any earlier character is also wrong, that mistake gets "
1145-
"locked in and the card becomes wrong forever, with nothing left to detect it.",
1151+
"So much of this card is unreadable that codex32 can fill in the blanks in a way that "
1152+
"looks correct without being correct.",
1153+
"warning",
1154+
),
1155+
_note(
1156+
"If you are filling in the last squares of a backup you are making by hand, check every "
1157+
"character you have typed against what you wrote down before you go on. Nothing can "
1158+
"detect a mistake made earlier: filling in the squares locks it in for good."
1159+
),
1160+
_note(
1161+
"If you are recovering a damaged card, the answer may simply be wrong. If it does not "
1162+
"restore your wallet, you may have to try likely misreadings one at a time."
1163+
),
1164+
_note(
1165+
"Never erase a card's last characters to make it check out. A card that fails its check "
1166+
"is telling you something is wrong, and replacing the ending hides that mistake instead "
1167+
"of finding it.",
11461168
"warning",
11471169
),
11481170
_note("If the funds matter, stop here and get help instead."),

‎tests/test_gui_reading.py‎

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
"""What the graphical entry field makes of typed text, without a display."""
22

33
import pytest
4-
from data.bip93_vectors import VECTOR_2, VECTOR_3
4+
from data.bip93_vectors import VECTOR_2, VECTOR_3, VECTOR_5
55

66
from codex32_gui.reading import (
77
PREFIX,
@@ -127,10 +127,11 @@ def test_a_repair_that_repeats_an_accepted_card_is_not_offered() -> None:
127127
assert isinstance(repair(SHARE_A[:-1] + "Q", 48, ("a",)), str)
128128

129129

130-
def test_a_card_with_too_little_checksum_left_demands_the_warning() -> None:
130+
@pytest.mark.parametrize(("card", "checksum_length"), ((SHARE_C, 13), (VECTOR_5["secret_s"], 15)))
131+
def test_a_card_with_too_little_checksum_left_demands_the_warning(card: str, checksum_length: int) -> None:
131132
from codex32 import CorrectionCandidate
132133

133-
found = repair(SHARE_C[:-13] + "?" * 13, None)
134+
found = repair(card[:-checksum_length] + "?" * checksum_length, None)
134135
assert isinstance(found, CorrectionCandidate)
135136
assert found.low_checksum_discrimination
136137

‎tools/gui_walkthrough.py‎

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -137,6 +137,8 @@ def do_activate(self) -> None:
137137
self.candidate,
138138
self.back_to_entry,
139139
self.accepted_repair,
140+
self.completion_entry,
141+
self.completion_gate,
140142
self.preflight,
141143
self.network,
142144
self.letters,
@@ -278,6 +280,57 @@ def accepted_repair(self) -> bool:
278280
press(page, "Done")
279281
return True
280282

283+
def completion_entry(self) -> bool:
284+
"""Thirteen unreadable characters at the end are a whole checksum."""
285+
page = self.page()
286+
if page.get_title() != "codex32":
287+
return False
288+
rows(page)[3].emit("activated")
289+
page = self.page()
290+
field = field_of(page)
291+
field.set_text(SHARE_C[:-13] + "?" * 13)
292+
settle()
293+
fix = button(page, "Suggest a repair")
294+
check("completing a checksum is offered as a repair", fix.get_sensitive())
295+
fix.emit("clicked")
296+
return True
297+
298+
def completion_gate(self) -> bool:
299+
page = self.page()
300+
if page.get_title() != "Warning" or button(page, "Show the guess") is None:
301+
return False
302+
shown = labels(page)
303+
check("nothing about the guess is disclosed yet", card(page) == "", card(page))
304+
check(
305+
"the gate addresses someone completing new data",
306+
any("making by hand" in text and "locks it in" in text for text in shown),
307+
[text for text in shown if "hand" in text],
308+
)
309+
check(
310+
"and someone recovering a damaged card",
311+
any("may simply be wrong" in text for text in shown),
312+
[text for text in shown if "wrong" in text],
313+
)
314+
check(
315+
"and forbids replacing a checksum outright",
316+
any("Never erase" in text for text in shown),
317+
[text for text in shown if "Never" in text],
318+
)
319+
show = button(page, "Show the guess")
320+
check("the guess stays hidden until YES is typed", not show.get_sensitive())
321+
entry = next(item for item in walk(page) if isinstance(item, Gtk.Entry))
322+
for typed in ("yes", "Yes", "YES please", "Y"):
323+
entry.set_text(typed)
324+
settle()
325+
check(f"{typed!r} does not open the gate", not show.get_sensitive())
326+
entry.set_text("YES")
327+
settle()
328+
check("literal YES opens it", show.get_sensitive())
329+
press(page, "Cancel")
330+
settle()
331+
self.view.replace([pages.home(self.view)])
332+
return True
333+
281334
def preflight(self) -> bool:
282335
page = self.page()
283336
if page.get_title() != "codex32":

0 commit comments

Comments
 (0)