Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions docs/developer/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion docs/security/model.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
16 changes: 13 additions & 3 deletions src/codex32/_cli_input.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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
Expand Down
6 changes: 6 additions & 0 deletions src/codex32/bip93.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -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()}")
Expand Down
2 changes: 2 additions & 0 deletions src/codex32/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.")
Expand Down
6 changes: 6 additions & 0 deletions src/codex32/generation.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Comment thread
BenWestgate marked this conversation as resolved.
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")
Expand All @@ -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")
Expand Down Expand Up @@ -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(
Expand Down
30 changes: 29 additions & 1 deletion tests/test_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
(
Expand Down Expand Up @@ -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"])
Expand Down Expand Up @@ -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(
Expand Down
12 changes: 12 additions & 0 deletions tests/test_generation.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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"},
),
Expand Down
2 changes: 2 additions & 0 deletions tests/test_public_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
2 changes: 1 addition & 1 deletion tests/test_sharing.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
Loading