Skip to content
Open
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;
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
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. 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.
Expand Down
17 changes: 12 additions & 5 deletions src/codex32/_cli_input.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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))
Expand All @@ -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])),
Expand All @@ -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),
)
Expand All @@ -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

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
9 changes: 6 additions & 3 deletions src/codex32/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
from codex32._cli_input import (
CorrectionDeclined,
InteractiveConfirmationRequired,
_ascii_lower,
_card_text,
_case_interpretation,
_confirm_correction,
Expand Down Expand Up @@ -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.")
Expand Down Expand Up @@ -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(
Expand All @@ -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.")
Expand Down
7 changes: 6 additions & 1 deletion 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,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)
Comment thread
BenWestgate marked this conversation as resolved.
expected = self._pending.text.lower()
mismatched = tuple(
group + 1
Expand Down
35 changes: 35 additions & 0 deletions tests/test_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
(
Expand Down Expand Up @@ -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"])
Expand Down
15 changes: 15 additions & 0 deletions tests/test_generation.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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"},
),
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