diff --git a/src/codex32/_cli_input.py b/src/codex32/_cli_input.py index 739505f..27e2999 100644 --- a/src/codex32/_cli_input.py +++ b/src/codex32/_cli_input.py @@ -214,67 +214,6 @@ def _confirm_correction( ).strip().lower() in ("y", "yes") -def _entered_groups(observed: str, expected: str) -> tuple[list[str], set[int]]: - # Keep entered tokens whole unless they exactly span complete groups. - # Unspaced input retains edit/group minimization and edit-order ties. - positions = [i for i, char in enumerate(observed) if not char.isspace()] - compact = "".join(observed.split()) - expected = "".join(expected.split()).lower() - tokens = observed.split() - grouped = len(tokens) > 1 and compact.lower() != expected - boundaries = {0} - splits: set[tuple[int, int]] = set() - position = 0 - for token in tokens: - for offset in range(0, len(expected), 4): - if expected[offset : offset + len(token)] == token.lower() and ( - len(token) % 4 == 0 or offset + len(token) == len(expected) - ): - splits.update((position + i, offset + i) for i in range(4, len(token), 4)) - position += len(token) - boundaries.add(position) - scores = {0: (0, 0, b"", ())} # type: dict[int, tuple[int, int, bytes, tuple[int, ...]]] - for offset in range(0, len(expected), 4): - canonical = expected[offset : offset + 4] - following: dict[int, tuple[int, int, bytes, tuple[int, ...]]] = {} - for start, (edits, disturbed, trace, ends) in scores.items(): - row = [(j, b"\x03" * j) for j in range(len(canonical) + 1)] - for end in range(start, len(compact) + 1): - if end > start: - current = [(end - start, b"\x02" * (end - start))] - for j, char in enumerate(canonical, 1): - edit = compact[end - 1].lower() != char - current.append( - min( - (row[j - 1][0] + edit, row[j - 1][1] + bytes([edit])), - (row[j][0] + 1, row[j][1] + b"\x02"), - (current[j - 1][0] + 1, current[j - 1][1] + b"\x03"), - ) - ) - row = current - if grouped and end not in boundaries and (end, offset + len(canonical)) not in splits: - continue - score = ( - edits + row[-1][0], - disturbed + (not grouped and compact[start:end].lower() != canonical), - trace + row[-1][1], - (*ends, end), - ) - if end not in following or score < following[end]: - following[end] = score - scores = following - groups = [] - start = 0 - for end in scores[len(compact)][3]: - stop = positions[end] if end < len(positions) else len(observed) - groups.append(observed[start:stop]) - start = stop - changed = { - i for i, value in enumerate(groups) if "".join(value.split()).lower() != expected[i * 4 : i * 4 + 4] - } - return groups, changed - - def _render_groups( groups: list[str], changed: set[int], diff --git a/src/codex32/cli.py b/src/codex32/cli.py index 2223c05..f41e9a7 100644 --- a/src/codex32/cli.py +++ b/src/codex32/cli.py @@ -6,6 +6,7 @@ import json import sys from collections.abc import Callable, Sequence +from functools import partial from typing import Literal, NamedTuple, cast from codex32._bitcoin_core import ( @@ -23,7 +24,6 @@ _card_text, _confirm_correction, _corrections, - _entered_groups, _fingerprint_matcher, _render_groups, _require_correction_confirmation, @@ -55,6 +55,7 @@ from codex32.generation import ( ConfirmationResult, CreationCeremony, + _confirmation, generate_master_seed, ) from codex32.wallet import master_xprv @@ -274,10 +275,13 @@ def _confirm_card(artifact: Artifact, confirm: Callable[[str], ConfirmationResul preserve_groups=True, ) expected = artifact.text - groups, changed = _entered_groups(entered, expected) - while changed: - start = min(changed) - end = start + 1 + confirm = confirm or partial(_confirmation, expected=expected) + result = confirm(entered) + groups, changed = list(result.entered_groups), {group - 1 for group in result.mismatched_groups} + while not result.accepted: + if not changed: + raise RuntimeError("Confirmation rejected.") + start = end = min(changed) while end in changed: end += 1 _print(_render_groups(groups, changed, len(expected), range(start, end)), err=True) @@ -293,26 +297,20 @@ def _confirm_card(artifact: Artifact, confirm: Callable[[str], ConfirmationResul ) compact = "".join(replacement.split()) if compact.lower() == expected.lower(): - groups = [replacement] - break - if len(compact) > len(expected[start * 4 : end * 4]) and compact.lower().startswith( + groups, changed = [replacement], set() + elif len(compact) > len(expected[start * 4 : end * 4]) and compact.lower().startswith( (expected.split("1", 1)[0] + "1").lower() ): - _print( - "Please re-enter only the highlighted region that remains incorrect.", - err=True, - ) - continue - if not compact: + _print("Please re-enter only the highlighted region that remains incorrect.", err=True) continue - groups[start:end], remaining = _entered_groups(replacement, expected[start * 4 : end * 4]) - changed.difference_update(range(start, end)) - changed.update(start + i for i in remaining) - entered = "".join(groups) - if "".join(entered.split()).lower() != expected.lower(): - raise RuntimeError("Confirmation mismatch.") - if confirm is not None and not confirm(entered).accepted: - raise RuntimeError("Confirmation rejected.") + elif compact: + # Align a retry only within its region so confirmed neighbors never shift. + region = _confirmation(replacement, expected[start * 4 : end * 4]) + groups[start:end] = region.entered_groups + changed.difference_update(range(start, end)) + changed.update(start + group - 1 for group in region.mismatched_groups) + if not changed: + result = confirm("".join(groups)) def _show_fingerprint(core: BitcoinCore, secret: MasterSeed, action: str) -> None: diff --git a/src/codex32/generation.py b/src/codex32/generation.py index 3c4cec5..706a8c2 100644 --- a/src/codex32/generation.py +++ b/src/codex32/generation.py @@ -44,6 +44,78 @@ class ConfirmationResult: accepted: bool mismatched_groups: tuple[int, ...] = () + entered_groups: tuple[str, ...] = () + + +def _confirmation(text: str, expected: str) -> ConfirmationResult: + """Compare re-entered text with a card, group by group, allowing shifts.""" + count = -(-len("".join(expected.split())) // 4) + if len("".join(text.split())) > 4 * count + 16: + # Alignment is quadratic in the entry, so refuse text far longer than the card. + return ConfirmationResult(False, tuple(range(1, count + 1)), (text,) + ("",) * (count - 1)) + groups, changed = _entered_groups(text, expected) + return ConfirmationResult(not changed, tuple(index + 1 for index in sorted(changed)), tuple(groups)) + + +def _entered_groups(observed: str, expected: str) -> tuple[list[str], set[int]]: + # Keep entered tokens whole unless they exactly span complete groups. + # Unspaced input retains edit/group minimization and edit-order ties. + positions = [i for i, char in enumerate(observed) if not char.isspace()] + compact = "".join(observed.split()) + expected = "".join(expected.split()).lower() + tokens = observed.split() + grouped = len(tokens) > 1 and compact.lower() != expected + boundaries = {0} + splits: set[tuple[int, int]] = set() + position = 0 + for token in tokens: + for offset in range(0, len(expected), 4): + if expected[offset : offset + len(token)] == token.lower() and ( + len(token) % 4 == 0 or offset + len(token) == len(expected) + ): + splits.update((position + i, offset + i) for i in range(4, len(token), 4)) + position += len(token) + boundaries.add(position) + scores = {0: (0, 0, b"", ())} # type: dict[int, tuple[int, int, bytes, tuple[int, ...]]] + for offset in range(0, len(expected), 4): + canonical = expected[offset : offset + 4] + following: dict[int, tuple[int, int, bytes, tuple[int, ...]]] = {} + for start, (edits, disturbed, trace, ends) in scores.items(): + row = [(j, b"\x03" * j) for j in range(len(canonical) + 1)] + for end in range(start, len(compact) + 1): + if end > start: + current = [(end - start, b"\x02" * (end - start))] + for j, char in enumerate(canonical, 1): + edit = compact[end - 1].lower() != char + current.append( + min( + (row[j - 1][0] + edit, row[j - 1][1] + bytes([edit])), + (row[j][0] + 1, row[j][1] + b"\x02"), + (current[j - 1][0] + 1, current[j - 1][1] + b"\x03"), + ) + ) + row = current + if grouped and end not in boundaries and (end, offset + len(canonical)) not in splits: + continue + score = ( + edits + row[-1][0], + disturbed + (not grouped and compact[start:end].lower() != canonical), + trace + row[-1][1], + (*ends, end), + ) + if end not in following or score < following[end]: + following[end] = score + scores = following + groups = [] + start = 0 + for end in scores[len(compact)][3]: + stop = positions[end] if end < len(positions) else len(observed) + groups.append(observed[start:stop]) + start = stop + changed = { + i for i, value in enumerate(groups) if "".join(value.split()).lower() != expected[i * 4 : i * 4 + 4] + } + return groups, changed def _threshold(value: object, *, allow_zero: bool = True) -> int: @@ -305,20 +377,14 @@ def confirm(self, text: str) -> ConfirmationResult: raise CeremonyStateError("request a card before confirming it") if not isinstance(text, str): raise TypeError("confirmation text must be str") - observed = "".join(text.split()).lower() - expected = self._pending.text.lower() - mismatched = tuple( - group + 1 - for group in range((max(len(observed), len(expected)) + 3) // 4) - if observed[group * 4 : group * 4 + 4] != expected[group * 4 : group * 4 + 4] - ) - if mismatched: - return ConfirmationResult(False, mismatched) + result = _confirmation(text, self._pending.text) + if not result.accepted: + return result if self._position < self._direct_count: self._basis.append(self._pending) self._position += 1 self._pending = None - return ConfirmationResult(True) + return result def finish(self) -> MasterSeed: """Return the secret after every requested card is confirmed.""" diff --git a/tests/test_cli.py b/tests/test_cli.py index 86adf3b..7a2a6b8 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -34,7 +34,7 @@ from codex32.bip93 import SEED_BYTE_LENGTHS from codex32.checksums import _CODEX32, _CODEX32_LONG from codex32.cli import main, ms_main -from codex32.generation import _fingerprint_identifier +from codex32.generation import _confirmation, _fingerprint_identifier from tools._wallet_test_vectors import stub_fingerprint @@ -1457,7 +1457,7 @@ def test_confirmation_alignment_shows_only_entered_text( expected = "abcdefghWXYZijklmnop" if observed == "abcdefghijklmnop" else "abcdefghijklmnop" if observed == "aaaaaaa": expected = "aaaaaaaa" - groups, changed = input_module._entered_groups(observed, expected) + groups, changed = importlib.import_module("codex32.generation")._entered_groups(observed, expected) actual = input_module._render_groups(groups, changed, len(expected)) plain = re.sub(r"\x1b\[[0-9;]*m", "", actual) @@ -1467,6 +1467,16 @@ def test_confirmation_alignment_shows_only_entered_text( assert "".join(plain.replace("_", "").split()).lower() == observed.lower() +def test_confirmation_refuses_text_far_longer_than_the_card() -> None: + expected = VECTOR_1["secret_s"] + + result = _confirmation(expected * 50, expected) + + assert not result.accepted and result.mismatched_groups == tuple(range(1, 13)) + assert "".join(result.entered_groups) == expected * 50 + assert _confirmation(expected + "q" * 16, expected).mismatched_groups == (12,) + + def test_interrupted_creation_marks_partial_cards_void() -> None: stdout, stderr = _TTYOutput(), io.StringIO() with ( @@ -2098,7 +2108,7 @@ def test_production_size_budgets_are_enforced() -> None: def test_confirmation_alignment_assigns_complete_canonical_groups( observed: str, expected: str, groups: list[str], changed: set[int] ) -> None: - from codex32._cli_input import _entered_groups + from codex32.generation import _entered_groups assert _entered_groups(observed, expected) == (groups, changed) assert "".join(groups) == observed @@ -2139,7 +2149,7 @@ def answer(prompt: str) -> str: def confirm(text: str) -> ConfirmationResult: confirmed.append(text) - return ConfirmationResult("".join(text.split()).lower() == artifact.text.lower()) + return _confirmation(text, artifact.text) with contextlib.redirect_stderr(output): cli_module._confirm_card(artifact, confirm) @@ -2152,8 +2162,7 @@ def confirm(text: str) -> ConfirmationResult: assert artifact.text[16:20] in shown assert len(re.findall(r"\x1b\[(?:1|22);7;31m", shown)) == 1 assert snapshots[1] == snapshots[2] == snapshots[3] - assert len(confirmed) == 1 - assert "".join(confirmed[0].split()).lower() == artifact.text.lower() + assert "".join(confirmed[-1].split()).lower() == artifact.text.lower() if readline: assert editor.inserted == prefills @@ -2255,7 +2264,7 @@ def test_confirmation_placeholders_only_replace_wholly_omitted_groups( def test_confirmation_grouped_alignment_preserves_token_ownership( observed: str, expected: str, groups: list[str], changed: set[int] ) -> None: - from codex32._cli_input import _entered_groups + from codex32.generation import _entered_groups assert _entered_groups(observed, expected) == (groups, changed) assert "".join(groups) == observed @@ -2294,7 +2303,7 @@ def answer(prompt: str) -> str: def confirm(value: str) -> ConfirmationResult: confirmed.append(value) - return ConfirmationResult("".join(value.split()).upper() == Card.text) + return _confirmation(value, Card.text) monkeypatch.setattr(cli_module, "_text", read) monkeypatch.setattr(input_module.sys, "stdin", _TTYInput()) @@ -2308,9 +2317,8 @@ def confirm(value: str) -> ConfirmationResult: assert "\x1b[1mEFGH\x1b[0m" in snapshots[4] assert "\x1b[1;7;31mABC\x1b[0m" in snapshots[4] assert "Please re-enter only the highlighted region that remains incorrect." in output.getvalue() - assert len(confirmed) == 1 if full_retry: - assert confirmed == [final] + assert confirmed[-1] == final if readline: assert editor.inserted == prefills diff --git a/tests/test_generation.py b/tests/test_generation.py index de1ca7c..3c7113c 100644 --- a/tests/test_generation.py +++ b/tests/test_generation.py @@ -229,6 +229,10 @@ def recorded(length: int) -> bytes: damaged = first.text[:4] + ("q" if first.text[4] != "q" else "p") + first.text[5:] result = ceremony.confirm(damaged) assert not result.accepted and result.mismatched_groups == (2,) + dropped = ceremony.confirm(first.text[:9] + first.text[10:]) + # A dropped character marks only its own group, not every later group. + assert not dropped.accepted and dropped.mismatched_groups == (3,) + assert "".join(dropped.entered_groups) == first.text[:9] + first.text[10:] assert calls == [26] assert ceremony.confirm(" \n".join(first.text.upper())).accepted second = ceremony.next_share()