diff --git a/docs/developer/api.md b/docs/developer/api.md index 9dbdc32a..2b950291 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; + 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 e025fb38..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 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. 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 eb13b4f5..98b1128f 100644 --- a/src/codex32/_cli_input.py +++ b/src/codex32/_cli_input.py @@ -164,11 +164,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: @@ -622,7 +627,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: @@ -754,6 +762,8 @@ 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(): + 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/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..96545e65 100644 --- a/src/codex32/cli.py +++ b/src/codex32/cli.py @@ -203,6 +203,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.") diff --git a/src/codex32/generation.py b/src/codex32/generation.py index f135d191..234020c3 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,6 +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") + if not text.isascii(): + raise CodexError("Only ASCII characters are allowed.") observed = "".join(text.split()).lower() expected = self._pending.text.lower() mismatched = tuple( diff --git a/tests/test_cli.py b/tests/test_cli.py index b1ba660a..38345d66 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -1467,6 +1467,26 @@ def answer(prompt: str, **options: object) -> str: assert "\x1b[3J\x1b[2J\x1b[H" in prompts[1][0] +@pytest.mark.parametrize("interactive", (False, True)) +@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" + 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"): + if artifacts: + input_module.read_artifacts() + else: + input_module.read_text("Enter a card", preserve_groups=True) + + @pytest.mark.parametrize( ("observed", "shown", "red"), ( @@ -1596,6 +1616,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"]) @@ -1767,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 878e795b..ba9e35ea 100644 --- a/tests/test_generation.py +++ b/tests/test_generation.py @@ -147,6 +147,17 @@ 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") + + +@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() + with pytest.raises(CodexError, match="Only ASCII"): + ceremony.confirm(pending.text + character) + assert ceremony.confirm(" \n".join(pending.text.upper())).accepted def test_supplied_seed_must_form_a_valid_bip32_root(monkeypatch: pytest.MonkeyPatch) -> None: @@ -178,6 +189,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):