diff --git a/docs/developer/api.md b/docs/developer/api.md index 9dbdc32..328597d 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 e025fb3..2c75ca3 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 eb13b4f..6090352 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 9e3757c..0592434 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 dc6d3be..68ca4f1 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 f135d19..6da0203 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,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()).lower() + observed = "".join(text.split()) + observed = "".join(char.lower() if char.isascii() else char for char in observed) expected = self._pending.text.lower() mismatched = tuple( group + 1 diff --git a/tests/test_cli.py b/tests/test_cli.py index b1ba660..7ae0dc4 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 878e795..f54dc42 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 91fcd89..6bf14a1 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 6d54c9b..567342b 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):