From 605131826a9f2735c457b55a9aa12c0e47bd133a Mon Sep 17 00:00:00 2001 From: Ben Westgate Date: Tue, 22 Sep 2026 15:08:29 -0500 Subject: [PATCH 1/2] bip93: reject HRPs longer than 83 characters BIP-0173 limits the human-readable part to 1-83 US-ASCII characters. bech32_decode enforces the character set and case rules but not this length bound, and codex32's own checksum-period limits (93/1023 expanded symbols) don't cover it either, so an overlong HRP with a short enough data part was accepted. Add the check in _decode_codex32 rather than bech32_decode: the latter is exercised directly by the generic BIP-0173 checksum-period test vectors, which use HRPs far longer than 83 characters to test the long checksum's own 1023-symbol period independent of any one application's rules. Ran the full test suite (861 passed), ruff check, ruff format --check, and mypy on the changed files. fixes #32 --- src/codex32/bip93.py | 2 ++ tests/test_generic_hrp.py | 10 +++++++++- 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/src/codex32/bip93.py b/src/codex32/bip93.py index 02afad7..0b77fc7 100644 --- a/src/codex32/bip93.py +++ b/src/codex32/bip93.py @@ -93,6 +93,8 @@ def _checksum_for_encoded_length(hrp: str, encoded_length: int) -> _Checksum: def _decode_codex32(text: str) -> tuple[str, _ProfileRules | None, tuple[int, ...], _Checksum]: hrp, encoded = bech32_decode(text) + if len(hrp) > 83: + raise InvalidLength(f"human-readable part exceeds 83 characters ({len(hrp)})") if len(hrp) + 1 + len(encoded) < 21: raise InvalidLength("codex32 string must contain at least 21 characters") checksum = _checksum_for_encoded_length(hrp, len(encoded)) diff --git a/tests/test_generic_hrp.py b/tests/test_generic_hrp.py index 341e504..9245cb1 100644 --- a/tests/test_generic_hrp.py +++ b/tests/test_generic_hrp.py @@ -18,7 +18,7 @@ recover_secret, ) from codex32.cli import main, ms_main -from codex32.errors import MismatchedHrp, MismatchedProfile +from codex32.errors import InvalidLength, MismatchedHrp, MismatchedProfile UNKNOWN = { "short": { @@ -50,6 +50,14 @@ def test_opaque_hrp_parse_complete_recover_and_derive(vector: dict[str, str]) -> assert derive_share([secret, a], "d").text == vector["D"].upper() +def test_hrp_over_83_characters_is_rejected_at_the_bip173_limit() -> None: + within_limit = oracle_encode("z" * 83, "0testsq") + over_limit = oracle_encode("z" * 84, "0testsq") + assert parse_codex32(within_limit).hrp == "z" * 83 + with pytest.raises(InvalidLength): + parse_codex32(over_limit) + + def test_sharing_compares_normalized_hrp_and_keeps_compatibility_error_name() -> None: assert MismatchedProfile is MismatchedHrp zz = parse_codex32(UNKNOWN["short"]["S"]) From c31833f22030c295f9c1591b57eead805add6843 Mon Sep 17 00:00:00 2001 From: Ben Westgate Date: Tue, 22 Sep 2026 15:29:17 -0500 Subject: [PATCH 2/2] bip93: validate correction HRP length Reject correction contexts whose human-readable part exceeds the BIP173 83-character limit before correction preparation starts. Preserve 83-character opaque HRPs and cover the invalid context path.\n\nrefs #32 --- src/codex32/correction.py | 2 ++ tests/test_correction_bch.py | 1 + 2 files changed, 3 insertions(+) diff --git a/src/codex32/correction.py b/src/codex32/correction.py index 4a1a561..111d5b0 100644 --- a/src/codex32/correction.py +++ b/src/codex32/correction.py @@ -763,6 +763,8 @@ def _validate_context(context: CorrectionContext) -> None: _validate_single_case_ascii(context.hrp) if context.hrp.lower() != context.hrp: raise ValueError("hrp must be a normalized application prefix") + if len(context.hrp) > 83: + raise ValueError(f"human-readable part exceeds 83 characters ({len(context.hrp)})") length = context.expected_length if length is not None: if isinstance(length, bool) or not isinstance(length, int): diff --git a/tests/test_correction_bch.py b/tests/test_correction_bch.py index 49b8800..9e47b6c 100644 --- a/tests/test_correction_bch.py +++ b/tests/test_correction_bch.py @@ -217,6 +217,7 @@ def test_public_context_constrains_length_prefix_and_used_indices() -> None: CorrectionContext(Profile.MS, excluded_indices=["a"]), # type: ignore[arg-type] CorrectionContext(Profile.MS, excluded_indices=("s",)), CorrectionContext(Profile.MS, excluded_indices=("a", "A")), + CorrectionContext("z" * 84, expected_length=106), ), ) def test_malformed_public_context_is_rejected(context: CorrectionContext) -> None: