Skip to content

Commit 364e3d5

Browse files
committed
Reject non-ASCII normalized input
Reject non-ASCII identifiers and indices before lowercasing so Unicode characters cannot normalize into valid Bech32 symbols. Keep ASCII case-insensitive behavior and whitespace-tolerant card confirmation unchanged. Security: closes a validation and confirmation boundary where a non-parseable recovery card could alias valid ASCII text. Validation: python -m pytest -q; python -O -m pytest -q; Ruff check and format; strict mypy.
1 parent c118a83 commit 364e3d5

5 files changed

Lines changed: 30 additions & 2 deletions

File tree

‎src/codex32/bip93.py‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,11 +51,15 @@ def __post_init__(self) -> None:
5151
raise InvalidThreshold("threshold must be 0 or an integer from 2 through 9")
5252
if not isinstance(self.identifier, str):
5353
raise InvalidIdentifier("identifier must be str")
54+
if not self.identifier.isascii():
55+
raise InvalidIdentifier("identifier must contain only ASCII Bech32 symbols")
5456
identifier = self.identifier.lower()
5557
if len(identifier) != 4 or any(character not in CHARSET for character in identifier):
5658
raise InvalidIdentifier("identifier must be exactly four Bech32 symbols")
5759
if not isinstance(self.index, str):
5860
raise InvalidShareIndex("share index must be str")
61+
if not self.index.isascii():
62+
raise InvalidShareIndex("share index must be an ASCII Bech32 symbol")
5963
index = self.index.lower()
6064
if len(index) != 1 or index not in CHARSET:
6165
raise InvalidShareIndex("share index must be one Bech32 symbol")
@@ -333,6 +337,8 @@ def recover_secret(shares: Sequence[Share]) -> Secret:
333337
def _normalize_target(value: object, *, label: str) -> str:
334338
if not isinstance(value, str):
335339
raise InvalidTargetIndex(f"{label} must be one Bech32 symbol")
340+
if not value.isascii():
341+
raise InvalidTargetIndex(f"{label} must be an ASCII Bech32 symbol")
336342
normalized = value.lower()
337343
if len(normalized) != 1 or normalized not in CHARSET or normalized == "s":
338344
raise InvalidTargetIndex(f"{label} must be one of {IDX_SORT[1:].upper()}")

‎src/codex32/generation.py‎

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,8 @@ def _threshold(value: object, *, allow_zero: bool = True) -> int:
6868
def _identifier(value: object) -> str:
6969
if not isinstance(value, str):
7070
raise InvalidIdentifier("identifier must be str")
71+
if not value.isascii():
72+
raise InvalidIdentifier("identifier must contain only ASCII Bech32 symbols")
7173
value = value.lower()
7274
if len(value) != 4 or any(character not in CHARSET for character in value):
7375
raise InvalidIdentifier("identifier must be four Bech32 symbols")
@@ -77,6 +79,8 @@ def _identifier(value: object) -> str:
7779
def _index(value: object) -> str:
7880
if not isinstance(value, str) or len(value) != 1:
7981
raise InvalidShareSelection("each output index must be one Bech32 symbol")
82+
if not value.isascii():
83+
raise InvalidShareSelection("each output index must be an ASCII Bech32 symbol")
8084
value = value.lower()
8185
if value not in ORDINARY_INDICES:
8286
raise InvalidShareSelection("output indices must be ordinary non-S symbols")
@@ -352,7 +356,9 @@ def confirm(self, text: str) -> ConfirmationResult:
352356
raise CeremonyStateError("request a card before confirming it")
353357
if not isinstance(text, str):
354358
raise TypeError("confirmation text must be str")
355-
observed = "".join(text.split()).lower()
359+
observed = "".join(text.split())
360+
if observed.isascii():
361+
observed = observed.lower()
356362
expected = self._pending.text.lower()
357363
mismatched = tuple(
358364
group + 1

‎tests/test_generation.py‎

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -137,6 +137,20 @@ def test_raw_bytes_accept_random_or_explicit_identifiers() -> None:
137137
secret = generate_master_seed(raw, identifier="TEST")
138138
assert len(random_secret.header.identifier) == 4
139139
assert secret.header.identifier == "test"
140+
with pytest.raises(InvalidIdentifier):
141+
generate_master_seed(raw, identifier="tesK")
142+
143+
144+
def test_confirmation_rejects_unicode_that_lowercases_to_bech32(
145+
monkeypatch: pytest.MonkeyPatch,
146+
) -> None:
147+
value = bytes([generation_module.CHARSET.index("k")]) * 26
148+
monkeypatch.setattr(generation_module.secrets, "token_bytes", lambda _length: value)
149+
ceremony = CreationCeremony.master_seed(threshold=2, indices="ac", identifier="test")
150+
pending = ceremony.next_share()
151+
invalid = pending.text.replace("k", "K", 1)
152+
assert invalid != pending.text
153+
assert not ceremony.confirm(invalid).accepted
140154

141155

142156
def test_explicit_and_random_output_order_contracts() -> None:

‎tests/test_public_api.py‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,7 +95,9 @@ def test_master_seed_factory_can_only_construct_index_s() -> None:
9595
(
9696
((1, "test", "s"), InvalidThreshold),
9797
((2, "bad", "a"), InvalidIdentifier),
98+
((2, "tesK", "a"), InvalidIdentifier),
9899
((0, "test", "a"), InvalidShareIndex),
100+
((2, "test", "K"), InvalidShareIndex),
99101
),
100102
)
101103
def test_header_invariants(arguments: tuple[object, ...], error: type[Exception]) -> None:

‎tests/test_sharing.py‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -153,7 +153,7 @@ def __getitem__(self, _position: int) -> Share:
153153
recover_secret(OversizedSequence()) # type: ignore[arg-type]
154154

155155

156-
@pytest.mark.parametrize("target", ("s", "i", "b", "?", "aa", "", 3))
156+
@pytest.mark.parametrize("target", ("s", "i", "b", "?", "aa", "", "K", 3))
157157
def test_invalid_targets_are_rejected(target: object) -> None:
158158
secret, masks = _ms_basis()
159159
with pytest.raises(InvalidTargetIndex):

0 commit comments

Comments
 (0)