From 3f7735268a935c27aa60d3abedeb740ef1721c25 Mon Sep 17 00:00:00 2001 From: Ben Westgate Date: Mon, 21 Sep 2026 02:03:33 -0500 Subject: [PATCH 1/4] Reject non-ASCII normalized input Reject non-ASCII identifiers, indices, and raw CLI creation headers before lowercasing so Unicode lookalikes cannot normalize into valid Bech32 symbols. Make whole-card confirmation fold ASCII case only. Non-ASCII lookalikes now stay visibly mismatched and enter the normal retry flow for shared, unshared, and derived cards instead of being accepted or reaching the ceremony callback as an apparent match. Document the ASCII-only confirmation boundary in the API and security model. Validation: 867 tests passed normally and under python -O; Ruff check and format passed; strict mypy passed; git diff --check passed. --- docs/developer/api.md | 5 +++-- docs/security/model.md | 2 +- src/codex32/_cli_input.py | 17 ++++++++++++----- src/codex32/bip93.py | 6 ++++++ src/codex32/cli.py | 9 ++++++--- src/codex32/generation.py | 8 +++++++- tests/test_cli.py | 35 +++++++++++++++++++++++++++++++++++ tests/test_generation.py | 15 +++++++++++++++ tests/test_public_api.py | 2 ++ tests/test_sharing.py | 2 +- 10 files changed, 88 insertions(+), 13 deletions(-) diff --git a/docs/developer/api.md b/docs/developer/api.md index 9dbdc32a..328597d5 100644 --- a/docs/developer/api.md +++ b/docs/developer/api.md @@ -77,8 +77,9 @@ generic parse-length failure. - `_cli_input.py` retains at most nine artifacts and delegates partial-set compatibility to `bip93.py`. Card confirmation clears the terminal and saved scrollback where supported, then displays only entered text after a mismatch. - Canonical text removes whitespace for comparison; presentation state retains - entered spacing and case. Grouped alignment preserves entered ownership; + Canonical text removes whitespace and folds ASCII case for comparison; + non-ASCII lookalikes remain mismatches. Presentation state retains entered + spacing and case. Grouped alignment preserves entered ownership; unspaced alignment minimizes character edits before disturbed groups. Codex32 entry uses a separate `> ` line; correction candidates use ordinary card formatting without a prompt marker; fixed prefixes follow that marker. The `xprv` and wallet recovery paths begin diff --git a/docs/security/model.md b/docs/security/model.md index e025fb38..2c75ca3c 100644 --- a/docs/security/model.md +++ b/docs/security/model.md @@ -126,7 +126,7 @@ available through `codex32`. Creation retries show only entered text in contiguous regions: bold red means review the card, with reverse video added for the active region. Original card formatting is display-only; editable prefills retain entered case and spacing. Complete matching canonical groups freeze; local alignment preserves entered group ownership before edit minimization and proceeds without crossing frozen boundaries (see the API alignment rules). -Empty retries fail; retries are unlimited. Correct full-string retries confirm; incorrect recognizable full-string retries preserve progress and clarify the active region. Only complete case/whitespace-normalized equality confirms, with no expected characters, error classifications, prescribed edits, or repairs. +Empty retries fail; retries are unlimited. Correct full-string retries confirm; incorrect recognizable full-string retries preserve progress and clarify the active region. Only complete ASCII-case/whitespace-normalized equality confirms; non-ASCII lookalikes remain mismatches and must be re-entered. No expected characters, error classifications, prescribed edits, or repairs are supplied. Progressive group-level correctness feedback is explicitly accepted and does not change the confirmation boundary. Confirmation shows that the operator can produce the correct recovery string during setup. It cannot prove that the physical backup was corrected rather than reconstructed using confirmation feedback. diff --git a/src/codex32/_cli_input.py b/src/codex32/_cli_input.py index eb13b4f5..6090352b 100644 --- a/src/codex32/_cli_input.py +++ b/src/codex32/_cli_input.py @@ -66,6 +66,11 @@ class InteractiveConfirmationRequired(Exception): pass +def _ascii_lower(value: str) -> str: + """Lowercase ASCII letters without normalizing Unicode lookalikes.""" + return "".join(character.lower() if character.isascii() else character for character in value) + + def _confirmation_input(prompt: str) -> str: if sys.stdin.isatty(): return _editable_input(prompt) @@ -239,13 +244,13 @@ def _entered_groups(observed: str, expected: str) -> tuple[list[str], set[int]]: compact = "".join(observed.split()) expected = "".join(expected.split()).lower() tokens = observed.split() - grouped = len(tokens) > 1 and compact.lower() != expected + grouped = len(tokens) > 1 and _ascii_lower(compact) != 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 ( + if expected[offset : offset + len(token)] == _ascii_lower(token) 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)) @@ -261,7 +266,7 @@ def _entered_groups(observed: str, expected: str) -> tuple[list[str], set[int]]: if end > start: current = [(end - start, b"\x02" * (end - start))] for j, char in enumerate(canonical, 1): - edit = compact[end - 1].lower() != char + edit = _ascii_lower(compact[end - 1]) != char current.append( min( (row[j - 1][0] + edit, row[j - 1][1] + bytes([edit])), @@ -274,7 +279,7 @@ def _entered_groups(observed: str, expected: str) -> tuple[list[str], set[int]]: continue score = ( edits + row[-1][0], - disturbed + (not grouped and compact[start:end].lower() != canonical), + disturbed + (not grouped and _ascii_lower(compact[start:end]) != canonical), trace + row[-1][1], (*ends, end), ) @@ -288,7 +293,9 @@ def _entered_groups(observed: str, expected: str) -> tuple[list[str], set[int]]: 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] + i + for i, value in enumerate(groups) + if _ascii_lower("".join(value.split())) != expected[i * 4 : i * 4 + 4] } return groups, changed diff --git a/src/codex32/bip93.py b/src/codex32/bip93.py index 9e3757c3..05924340 100644 --- a/src/codex32/bip93.py +++ b/src/codex32/bip93.py @@ -55,11 +55,15 @@ def __post_init__(self) -> None: raise InvalidThreshold("threshold must be 0 or an integer from 2 through 9") if not isinstance(self.identifier, str): raise InvalidIdentifier("identifier must be str") + if not self.identifier.isascii(): + raise InvalidIdentifier("identifier must contain only ASCII Bech32 symbols") identifier = self.identifier.lower() if len(identifier) != 4 or any(character not in CHARSET for character in identifier): raise InvalidIdentifier("identifier must be exactly four Bech32 symbols") if not isinstance(self.index, str): raise InvalidShareIndex("share index must be str") + if not self.index.isascii(): + raise InvalidShareIndex("share index must be an ASCII Bech32 symbol") index = self.index.lower() if len(index) != 1 or index not in CHARSET: raise InvalidShareIndex("share index must be one Bech32 symbol") @@ -352,6 +356,8 @@ def recover_secret(shares: Sequence[Share]) -> Secret: def _normalize_target(value: object, *, label: str) -> str: if not isinstance(value, str): raise InvalidTargetIndex(f"{label} must be one Bech32 symbol") + if not value.isascii(): + raise InvalidTargetIndex(f"{label} must be an ASCII Bech32 symbol") normalized = value.lower() if len(normalized) != 1 or normalized not in CHARSET or normalized == "s": raise InvalidTargetIndex(f"{label} must be one of {IDX_SORT[1:].upper()}") diff --git a/src/codex32/cli.py b/src/codex32/cli.py index dc6d3be1..68ca4f12 100644 --- a/src/codex32/cli.py +++ b/src/codex32/cli.py @@ -20,6 +20,7 @@ from codex32._cli_input import ( CorrectionDeclined, InteractiveConfirmationRequired, + _ascii_lower, _card_text, _case_interpretation, _confirm_correction, @@ -203,6 +204,8 @@ def _share_command(index: str, plain: bool, context: _CliContext, core: BitcoinC def _creation_header(value: str | None) -> tuple[Profile, int | None, str | None]: if value is None: return Profile.MS, None, None + if not value.isascii(): + raise _UsageError("The set header must contain only ASCII characters.") lowered = value.lower() if lowered != value and value.upper() != value: raise _UsageError("The set header must use either uppercase or lowercase.") @@ -310,10 +313,10 @@ def _confirm_card(artifact: Artifact, confirm: Callable[[str], ConfirmationResul prefill=prefill, ) compact = "".join(replacement.split()) - if compact.lower() == expected.lower(): + if _ascii_lower(compact) == expected.lower(): groups = [replacement] break - if len(compact) > len(expected[start * 4 : end * 4]) and compact.lower().startswith( + if len(compact) > len(expected[start * 4 : end * 4]) and _ascii_lower(compact).startswith( (expected.split("1", 1)[0] + "1").lower() ): _print( @@ -327,7 +330,7 @@ def _confirm_card(artifact: Artifact, confirm: Callable[[str], ConfirmationResul changed.difference_update(range(start, end)) changed.update(start + i for i in remaining) entered = "".join(groups) - if "".join(entered.split()).lower() != expected.lower(): + if _ascii_lower("".join(entered.split())) != expected.lower(): raise RuntimeError("Confirmation mismatch.") if confirm is not None and not confirm(entered).accepted: raise RuntimeError("Confirmation rejected.") diff --git a/src/codex32/generation.py b/src/codex32/generation.py index f135d191..4cd94694 100644 --- a/src/codex32/generation.py +++ b/src/codex32/generation.py @@ -69,6 +69,8 @@ def _threshold(value: object, *, allow_zero: bool = True) -> int: def _identifier(value: object) -> str: if not isinstance(value, str): raise InvalidIdentifier("identifier must be str") + if not value.isascii(): + raise InvalidIdentifier("identifier must contain only ASCII Bech32 symbols") value = value.lower() if len(value) != 4 or any(character not in CHARSET for character in value): raise InvalidIdentifier("identifier must be four Bech32 symbols") @@ -78,6 +80,8 @@ def _identifier(value: object) -> str: def _index(value: object) -> str: if not isinstance(value, str) or len(value) != 1: raise InvalidShareSelection("each output index must be one Bech32 symbol") + if not value.isascii(): + raise InvalidShareSelection("each output index must be an ASCII Bech32 symbol") value = value.lower() if value not in ORDINARY_INDICES: raise InvalidShareSelection("output indices must be ordinary non-S symbols") @@ -355,7 +359,9 @@ 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() + observed = "".join(text.split()) + if observed.isascii(): + observed = observed.lower() expected = self._pending.text.lower() mismatched = tuple( group + 1 diff --git a/tests/test_cli.py b/tests/test_cli.py index b1ba660a..7ae0dc42 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -1467,6 +1467,34 @@ def answer(prompt: str, **options: object) -> str: assert "\x1b[3J\x1b[2J\x1b[H" in prompts[1][0] +def test_creation_confirmation_retries_unicode_aliases_before_accepting( + monkeypatch: pytest.MonkeyPatch, +) -> None: + cli_module = importlib.import_module("codex32.cli") + + class Card: + text = "MS10TESK" + + answers = iter(("", "MS10TESK", "TESK")) + prefills: list[str] = [] + confirmed: list[str] = [] + + def answer(_prompt: str, **options: object) -> str: + if "prefill" in options: + prefills.append(str(options["prefill"])) + return next(answers) + + def confirm(value: str) -> ConfirmationResult: + confirmed.append(value) + return ConfirmationResult(True) + + monkeypatch.setattr(cli_module, "_text", answer) + cli_module._confirm_card(Card(), confirm) + + assert prefills == ["TESK"] + assert confirmed == [Card.text] + + @pytest.mark.parametrize( ("observed", "shown", "red"), ( @@ -1596,6 +1624,13 @@ def test_create_accepts_positional_headers_and_preserves_index_order() -> None: assert recover_secret(basis).header.identifier == "cash" +def test_create_rejects_unicode_header_aliases_before_normalizing() -> None: + result = _invoke(["create", "MS12TESK"]) + + assert result.exit_code == 2 + assert "ASCII" in result.stderr + + @pytest.mark.parametrize(("threshold", "count"), ((2, 3), (3, 5))) def test_create_has_reviewed_share_count_presets(threshold: int, count: int) -> None: result = _invoke_confirmed_create(["create", f"{threshold}test"]) diff --git a/tests/test_generation.py b/tests/test_generation.py index 878e795b..f54dc425 100644 --- a/tests/test_generation.py +++ b/tests/test_generation.py @@ -147,6 +147,20 @@ def test_raw_bytes_accept_random_or_explicit_identifiers() -> None: secret = generate_master_seed(raw, identifier="TEST") assert len(random_secret.header.identifier) == 4 assert secret.header.identifier == "test" + with pytest.raises(InvalidIdentifier): + generate_master_seed(raw, identifier="tesK") + + +def test_confirmation_rejects_unicode_that_lowercases_to_bech32( + monkeypatch: pytest.MonkeyPatch, +) -> None: + value = bytes([generation_module.CHARSET.index("k")]) * 26 + monkeypatch.setattr(generation_module.secrets, "token_bytes", lambda _length: value) + ceremony = CreationCeremony.master_seed(threshold=2, indices="ac", identifier="test") + pending = ceremony.next_share() + invalid = pending.text.replace("k", "K", 1) + assert invalid != pending.text + assert not ceremony.confirm(invalid).accepted def test_supplied_seed_must_form_a_valid_bip32_root(monkeypatch: pytest.MonkeyPatch) -> None: @@ -178,6 +192,7 @@ def test_explicit_and_random_output_order_contracts() -> None: {"threshold": 2, "share_count": 32}, {"threshold": 2, "indices": "a"}, {"threshold": 2, "indices": "aa"}, + {"threshold": 2, "indices": "aK"}, {"threshold": 2, "indices": "sa"}, {"threshold": 2, "indices": "ia"}, ), diff --git a/tests/test_public_api.py b/tests/test_public_api.py index 91fcd892..6bf14a1b 100644 --- a/tests/test_public_api.py +++ b/tests/test_public_api.py @@ -122,7 +122,9 @@ def test_master_seed_factory_can_only_construct_index_s() -> None: ((1, "test", "s"), InvalidThreshold), ((2.0, "test", "a"), InvalidThreshold), ((2, "bad", "a"), InvalidIdentifier), + ((2, "tesK", "a"), InvalidIdentifier), ((0, "test", "a"), InvalidShareIndex), + ((2, "test", "K"), InvalidShareIndex), ), ) def test_header_invariants(arguments: tuple[object, ...], error: type[Exception]) -> None: diff --git a/tests/test_sharing.py b/tests/test_sharing.py index 6d54c9ba..567342bc 100644 --- a/tests/test_sharing.py +++ b/tests/test_sharing.py @@ -153,7 +153,7 @@ def __getitem__(self, _position: int) -> Share: recover_secret(OversizedSequence()) # type: ignore[arg-type] -@pytest.mark.parametrize("target", ("s", "i", "b", "?", "aa", "", 3)) +@pytest.mark.parametrize("target", ("s", "i", "b", "?", "aa", "", "K", 3)) def test_invalid_targets_are_rejected(target: object) -> None: secret, masks = _ms_basis() with pytest.raises(InvalidTargetIndex): From 6f3dac85ade3758bd941504537a491919404e832 Mon Sep 17 00:00:00 2001 From: Ben Westgate Date: Wed, 23 Sep 2026 11:03:14 -0500 Subject: [PATCH 2/4] generation: Fold ASCII case per character --- src/codex32/generation.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/codex32/generation.py b/src/codex32/generation.py index 4cd94694..6da02033 100644 --- a/src/codex32/generation.py +++ b/src/codex32/generation.py @@ -360,8 +360,7 @@ def confirm(self, text: str) -> ConfirmationResult: if not isinstance(text, str): raise TypeError("confirmation text must be str") observed = "".join(text.split()) - if observed.isascii(): - observed = observed.lower() + observed = "".join(char.lower() if char.isascii() else char for char in observed) expected = self._pending.text.lower() mismatched = tuple( group + 1 From 58ea30d11956d61f1d2fa17cbbe32f9511de23ec Mon Sep 17 00:00:00 2001 From: Ben Westgate Date: Sat, 24 Oct 2026 02:22:00 -0500 Subject: [PATCH 3/4] cli: Reject Unicode before input normalization Reject non-ASCII input before whitespace splitting or lowercasing, including nonbreaking spaces. Preserve existing interactive read-back spacing and fail confirmation on Unicode rather than silently dropping it. Validation: 72 focused tests normally and under optimized Python, Ruff, mypy and diff checks. refs #13 --- src/codex32/_cli_input.py | 18 +++++++++++++++--- src/codex32/cli.py | 3 +++ src/codex32/generation.py | 3 ++- tests/test_cli.py | 15 +++++++++++++++ tests/test_generation.py | 12 ++++++++++++ 5 files changed, 47 insertions(+), 4 deletions(-) diff --git a/src/codex32/_cli_input.py b/src/codex32/_cli_input.py index 6090352b..4b8e4174 100644 --- a/src/codex32/_cli_input.py +++ b/src/codex32/_cli_input.py @@ -169,11 +169,16 @@ def read_text( prefill: str = "", prompt_end: str = ": ", ) -> str: - if sys.stdin.isatty(): + interactive = sys.stdin.isatty() + if interactive: value = _editable_input(prompt + prompt_end, prefill) _stderr("") else: - value = _stdin().strip() + value = _stdin() + if not value.isascii(): + raise InputError("Only ASCII characters are allowed.") + if not interactive: + value = value.strip() if not preserve_groups: value = "".join(value.split()) if not "".join(value.split()) and not optional: @@ -629,7 +634,10 @@ def _redirected( excluded_index: str | None, fingerprint: Callable[[MasterSeed], bytes] | None, ) -> list[Artifact]: - tokens = _stdin().split() + raw = _stdin() + if not raw.isascii(): + raise InputError("Only ASCII characters are allowed.") + tokens = raw.split() if not tokens: raise InputError("No input was provided.") if len(tokens) > 9: @@ -761,6 +769,10 @@ def allowed(candidate: CorrectionCandidate) -> bool: else (f"Enter {'string' if basis else 'share'} {len(accepted) + 1} of {required}") ) entered = _editable_input(f"{label}:\n> {_display_prefix(prefix, grouped_prefix)}", prefill) + if not entered.isascii(): + _stderr("Rejected: Only ASCII characters are allowed.\n") + prefill = "" + continue value = "".join(entered.split()) supplied_prefix = bool(prefix) and "1" not in value complete_value = value if not supplied_prefix else _prefix_for_suffix(prefix, value) + value diff --git a/src/codex32/cli.py b/src/codex32/cli.py index 68ca4f12..48595195 100644 --- a/src/codex32/cli.py +++ b/src/codex32/cli.py @@ -312,6 +312,9 @@ def _confirm_card(artifact: Artifact, confirm: Callable[[str], ConfirmationResul optional=True, prefill=prefill, ) + if not replacement.isascii(): + _print("Only ASCII characters are allowed. Re-enter the highlighted region.", err=True) + continue compact = "".join(replacement.split()) if _ascii_lower(compact) == expected.lower(): groups = [replacement] diff --git a/src/codex32/generation.py b/src/codex32/generation.py index 6da02033..72b8bd11 100644 --- a/src/codex32/generation.py +++ b/src/codex32/generation.py @@ -359,7 +359,8 @@ 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()) + # Strip only ASCII whitespace; Unicode whitespace must never vanish before comparison. + observed = "".join(char for char in text if char not in " \t\n\r\v\f") observed = "".join(char.lower() if char.isascii() else char for char in observed) expected = self._pending.text.lower() mismatched = tuple( diff --git a/tests/test_cli.py b/tests/test_cli.py index 7ae0dc42..960b69d5 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -1495,6 +1495,21 @@ def confirm(value: str) -> ConfirmationResult: assert confirmed == [Card.text] +@pytest.mark.parametrize("interactive", (False, True)) +def test_text_input_rejects_unicode_whitespace_before_normalization( + monkeypatch: pytest.MonkeyPatch, interactive: bool +) -> None: + input_module = importlib.import_module("codex32._cli_input") + value = "ms10 test\u00a0abcd" + if interactive: + monkeypatch.setattr(input_module.sys, "stdin", _TTYInput()) + monkeypatch.setattr(input_module, "_editable_input", lambda *_args: value) + else: + monkeypatch.setattr(input_module.sys, "stdin", io.StringIO(value)) + with pytest.raises(input_module.InputError, match="Only ASCII"): + input_module.read_text("Enter a card", preserve_groups=True) + + @pytest.mark.parametrize( ("observed", "shown", "red"), ( diff --git a/tests/test_generation.py b/tests/test_generation.py index f54dc425..a75c162c 100644 --- a/tests/test_generation.py +++ b/tests/test_generation.py @@ -163,6 +163,18 @@ def test_confirmation_rejects_unicode_that_lowercases_to_bech32( assert not ceremony.confirm(invalid).accepted +def test_confirmation_keeps_unicode_whitespace_visible_as_mismatch() -> None: + ceremony = CreationCeremony.master_seed(threshold=2, indices="ac", identifier="test") + pending = ceremony.next_share() + # ASCII case and grouping remain tolerant; a non-ASCII space must not vanish. + position = 8 + invalid = pending.text[:position].upper() + "\u00a0" + pending.text[position:].upper() + result = ceremony.confirm(invalid) + assert not result.accepted + assert result.mismatched_groups + assert ceremony.confirm(" \n".join(pending.text.upper())).accepted + + def test_supplied_seed_must_form_a_valid_bip32_root(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setattr(generation_module, "_valid_root", lambda _seed: False) with pytest.raises(CodexError, match="master seed does not form a valid BIP32 root"): From 32db2108f38ec87eae24265285e3e3af68506e1d Mon Sep 17 00:00:00 2001 From: Ben Westgate Date: Sat, 24 Oct 2026 04:20:21 -0500 Subject: [PATCH 4/4] cli: Abort on non-ASCII recovery input Reject non-ASCII text before whitespace removal or case folding. End the command instead of retaining Unicode in transcription retry state. Remove ASCII-only folding and its special retry handling. The library confirmation boundary also rejects non-ASCII rather than returning group feedback for it. Ordinary ASCII grouping and case remain accepted. Validation: 356 affected tests pass normally and with Python -O; Ruff lint/format, mypy and diff checks pass. refs #13 --- docs/developer/api.md | 6 +++--- docs/security/model.md | 2 +- src/codex32/_cli_input.py | 21 ++++++------------- src/codex32/cli.py | 10 +++------ src/codex32/generation.py | 6 +++--- tests/test_cli.py | 44 ++++++++++----------------------------- tests/test_generation.py | 23 ++++---------------- 7 files changed, 31 insertions(+), 81 deletions(-) diff --git a/docs/developer/api.md b/docs/developer/api.md index 328597d5..2b950291 100644 --- a/docs/developer/api.md +++ b/docs/developer/api.md @@ -77,9 +77,9 @@ generic parse-length failure. - `_cli_input.py` retains at most nine artifacts and delegates partial-set compatibility to `bip93.py`. Card confirmation clears the terminal and saved scrollback where supported, then displays only entered text after a mismatch. - Canonical text removes whitespace and folds ASCII case for comparison; - non-ASCII lookalikes remain mismatches. Presentation state retains entered - spacing and case. Grouped alignment preserves entered ownership; + Non-ASCII input aborts before normalization. Canonical text removes whitespace + and folds case for comparison; presentation state retains entered spacing and + case. Grouped alignment preserves entered ownership; unspaced alignment minimizes character edits before disturbed groups. Codex32 entry uses a separate `> ` line; correction candidates use ordinary card formatting without a prompt marker; fixed prefixes follow that marker. The `xprv` and wallet recovery paths begin diff --git a/docs/security/model.md b/docs/security/model.md index 2c75ca3c..de77db4d 100644 --- a/docs/security/model.md +++ b/docs/security/model.md @@ -126,7 +126,7 @@ available through `codex32`. Creation retries show only entered text in contiguous regions: bold red means review the card, with reverse video added for the active region. Original card formatting is display-only; editable prefills retain entered case and spacing. Complete matching canonical groups freeze; local alignment preserves entered group ownership before edit minimization and proceeds without crossing frozen boundaries (see the API alignment rules). -Empty retries fail; retries are unlimited. Correct full-string retries confirm; incorrect recognizable full-string retries preserve progress and clarify the active region. Only complete ASCII-case/whitespace-normalized equality confirms; non-ASCII lookalikes remain mismatches and must be re-entered. No expected characters, error classifications, prescribed edits, or repairs are supplied. +Empty retries fail; retries are unlimited. Correct full-string retries confirm; incorrect recognizable full-string retries preserve progress and clarify the active region. Non-ASCII input aborts before normalization. Only complete case/whitespace-normalized equality confirms, with no expected characters, error classifications, prescribed edits, or repairs. Progressive group-level correctness feedback is explicitly accepted and does not change the confirmation boundary. Confirmation shows that the operator can produce the correct recovery string during setup. It cannot prove that the physical backup was corrected rather than reconstructed using confirmation feedback. diff --git a/src/codex32/_cli_input.py b/src/codex32/_cli_input.py index 4b8e4174..98b1128f 100644 --- a/src/codex32/_cli_input.py +++ b/src/codex32/_cli_input.py @@ -66,11 +66,6 @@ class InteractiveConfirmationRequired(Exception): pass -def _ascii_lower(value: str) -> str: - """Lowercase ASCII letters without normalizing Unicode lookalikes.""" - return "".join(character.lower() if character.isascii() else character for character in value) - - def _confirmation_input(prompt: str) -> str: if sys.stdin.isatty(): return _editable_input(prompt) @@ -249,13 +244,13 @@ def _entered_groups(observed: str, expected: str) -> tuple[list[str], set[int]]: compact = "".join(observed.split()) expected = "".join(expected.split()).lower() tokens = observed.split() - grouped = len(tokens) > 1 and _ascii_lower(compact) != expected + 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)] == _ascii_lower(token) and ( + 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)) @@ -271,7 +266,7 @@ def _entered_groups(observed: str, expected: str) -> tuple[list[str], set[int]]: if end > start: current = [(end - start, b"\x02" * (end - start))] for j, char in enumerate(canonical, 1): - edit = _ascii_lower(compact[end - 1]) != char + edit = compact[end - 1].lower() != char current.append( min( (row[j - 1][0] + edit, row[j - 1][1] + bytes([edit])), @@ -284,7 +279,7 @@ def _entered_groups(observed: str, expected: str) -> tuple[list[str], set[int]]: continue score = ( edits + row[-1][0], - disturbed + (not grouped and _ascii_lower(compact[start:end]) != canonical), + disturbed + (not grouped and compact[start:end].lower() != canonical), trace + row[-1][1], (*ends, end), ) @@ -298,9 +293,7 @@ def _entered_groups(observed: str, expected: str) -> tuple[list[str], set[int]]: groups.append(observed[start:stop]) start = stop changed = { - i - for i, value in enumerate(groups) - if _ascii_lower("".join(value.split())) != expected[i * 4 : i * 4 + 4] + i for i, value in enumerate(groups) if "".join(value.split()).lower() != expected[i * 4 : i * 4 + 4] } return groups, changed @@ -770,9 +763,7 @@ def allowed(candidate: CorrectionCandidate) -> bool: ) entered = _editable_input(f"{label}:\n> {_display_prefix(prefix, grouped_prefix)}", prefill) if not entered.isascii(): - _stderr("Rejected: Only ASCII characters are allowed.\n") - prefill = "" - continue + raise InputError("Only ASCII characters are allowed.") value = "".join(entered.split()) supplied_prefix = bool(prefix) and "1" not in value complete_value = value if not supplied_prefix else _prefix_for_suffix(prefix, value) + value diff --git a/src/codex32/cli.py b/src/codex32/cli.py index 48595195..96545e65 100644 --- a/src/codex32/cli.py +++ b/src/codex32/cli.py @@ -20,7 +20,6 @@ from codex32._cli_input import ( CorrectionDeclined, InteractiveConfirmationRequired, - _ascii_lower, _card_text, _case_interpretation, _confirm_correction, @@ -312,14 +311,11 @@ def _confirm_card(artifact: Artifact, confirm: Callable[[str], ConfirmationResul optional=True, prefill=prefill, ) - if not replacement.isascii(): - _print("Only ASCII characters are allowed. Re-enter the highlighted region.", err=True) - continue compact = "".join(replacement.split()) - if _ascii_lower(compact) == expected.lower(): + if compact.lower() == expected.lower(): groups = [replacement] break - if len(compact) > len(expected[start * 4 : end * 4]) and _ascii_lower(compact).startswith( + if len(compact) > len(expected[start * 4 : end * 4]) and compact.lower().startswith( (expected.split("1", 1)[0] + "1").lower() ): _print( @@ -333,7 +329,7 @@ def _confirm_card(artifact: Artifact, confirm: Callable[[str], ConfirmationResul changed.difference_update(range(start, end)) changed.update(start + i for i in remaining) entered = "".join(groups) - if _ascii_lower("".join(entered.split())) != expected.lower(): + 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.") diff --git a/src/codex32/generation.py b/src/codex32/generation.py index 72b8bd11..234020c3 100644 --- a/src/codex32/generation.py +++ b/src/codex32/generation.py @@ -359,9 +359,9 @@ 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") - # Strip only ASCII whitespace; Unicode whitespace must never vanish before comparison. - observed = "".join(char for char in text if char not in " \t\n\r\v\f") - observed = "".join(char.lower() if char.isascii() else char for char in observed) + if not text.isascii(): + raise CodexError("Only ASCII characters are allowed.") + observed = "".join(text.split()).lower() expected = self._pending.text.lower() mismatched = tuple( group + 1 diff --git a/tests/test_cli.py b/tests/test_cli.py index 960b69d5..38345d66 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -1467,47 +1467,24 @@ def answer(prompt: str, **options: object) -> str: assert "\x1b[3J\x1b[2J\x1b[H" in prompts[1][0] -def test_creation_confirmation_retries_unicode_aliases_before_accepting( - monkeypatch: pytest.MonkeyPatch, -) -> None: - cli_module = importlib.import_module("codex32.cli") - - class Card: - text = "MS10TESK" - - answers = iter(("", "MS10TESK", "TESK")) - prefills: list[str] = [] - confirmed: list[str] = [] - - def answer(_prompt: str, **options: object) -> str: - if "prefill" in options: - prefills.append(str(options["prefill"])) - return next(answers) - - def confirm(value: str) -> ConfirmationResult: - confirmed.append(value) - return ConfirmationResult(True) - - monkeypatch.setattr(cli_module, "_text", answer) - cli_module._confirm_card(Card(), confirm) - - assert prefills == ["TESK"] - assert confirmed == [Card.text] - - @pytest.mark.parametrize("interactive", (False, True)) -def test_text_input_rejects_unicode_whitespace_before_normalization( - monkeypatch: pytest.MonkeyPatch, interactive: bool +@pytest.mark.parametrize("character", ("K", "\u00a0")) +@pytest.mark.parametrize("artifacts", (False, True)) +def test_input_rejects_unicode_before_normalization( + monkeypatch: pytest.MonkeyPatch, interactive: bool, character: str, artifacts: bool ) -> None: input_module = importlib.import_module("codex32._cli_input") - value = "ms10 test\u00a0abcd" + value = "ms10 test" + character + "abcd" if interactive: monkeypatch.setattr(input_module.sys, "stdin", _TTYInput()) monkeypatch.setattr(input_module, "_editable_input", lambda *_args: value) else: monkeypatch.setattr(input_module.sys, "stdin", io.StringIO(value)) with pytest.raises(input_module.InputError, match="Only ASCII"): - input_module.read_text("Enter a card", preserve_groups=True) + if artifacts: + input_module.read_artifacts() + else: + input_module.read_text("Enter a card", preserve_groups=True) @pytest.mark.parametrize( @@ -1817,7 +1794,8 @@ def test_correct_rejects_malformed_immutable_hrp_as_usage() -> None: result = _invoke(["correct"], damaged) assert result.exit_code == 2 - assert "application prefix" in result.stderr + assert "Only ASCII" in result.stderr + assert result.stdout == "" @pytest.mark.parametrize( diff --git a/tests/test_generation.py b/tests/test_generation.py index a75c162c..ba9e35ea 100644 --- a/tests/test_generation.py +++ b/tests/test_generation.py @@ -151,27 +151,12 @@ def test_raw_bytes_accept_random_or_explicit_identifiers() -> None: generate_master_seed(raw, identifier="tesK") -def test_confirmation_rejects_unicode_that_lowercases_to_bech32( - monkeypatch: pytest.MonkeyPatch, -) -> None: - value = bytes([generation_module.CHARSET.index("k")]) * 26 - monkeypatch.setattr(generation_module.secrets, "token_bytes", lambda _length: value) - ceremony = CreationCeremony.master_seed(threshold=2, indices="ac", identifier="test") - pending = ceremony.next_share() - invalid = pending.text.replace("k", "K", 1) - assert invalid != pending.text - assert not ceremony.confirm(invalid).accepted - - -def test_confirmation_keeps_unicode_whitespace_visible_as_mismatch() -> None: +@pytest.mark.parametrize("character", ("K", "\u00a0")) +def test_confirmation_rejects_non_ascii(character: str) -> None: ceremony = CreationCeremony.master_seed(threshold=2, indices="ac", identifier="test") pending = ceremony.next_share() - # ASCII case and grouping remain tolerant; a non-ASCII space must not vanish. - position = 8 - invalid = pending.text[:position].upper() + "\u00a0" + pending.text[position:].upper() - result = ceremony.confirm(invalid) - assert not result.accepted - assert result.mismatched_groups + with pytest.raises(CodexError, match="Only ASCII"): + ceremony.confirm(pending.text + character) assert ceremony.confirm(" \n".join(pending.text.upper())).accepted