From 9f42ecea6e4d644e1031209f6ac7126ab7be9c9c Mon Sep 17 00:00:00 2001 From: Ben Westgate Date: Wed, 23 Sep 2026 07:12:22 -0500 Subject: [PATCH 1/4] bip93: Enforce the BIP173 HRP limit Reject human-readable parts longer than 83 characters at the generic Bech32 decoding boundary and in correction contexts. Keep checksum-period coverage at the checksum layer so it does not depend on invalid Bech32 HRPs. Fixes #32. --- src/codex32/bech32.py | 2 ++ src/codex32/correction.py | 2 ++ tests/data/bip93_vectors.py | 5 +++-- tests/test_correction_bch.py | 1 + tests/test_generic_hrp.py | 17 ++++++++++++++++- tests/test_profiles.py | 11 +++++++++++ 6 files changed, 35 insertions(+), 3 deletions(-) diff --git a/src/codex32/bech32.py b/src/codex32/bech32.py index 59befc7..3cb14a8 100644 --- a/src/codex32/bech32.py +++ b/src/codex32/bech32.py @@ -88,6 +88,8 @@ def bech32_decode(value: str, spec: _Checksum | None = None) -> tuple[str, list[ raise MissingSeparator("No separator (1) was found.") if separator == 0: raise MissingSeparator("The application prefix before 1 is missing.") + if separator > 83: + raise InvalidLength(f"human-readable part exceeds 83 characters ({separator})") lowered = value.lower() hrp = lowered[:separator] data = _chars_to_u5(lowered[separator + 1 :], separator + 2) diff --git a/src/codex32/correction.py b/src/codex32/correction.py index 9abd345..63fe348 100644 --- a/src/codex32/correction.py +++ b/src/codex32/correction.py @@ -768,6 +768,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/data/bip93_vectors.py b/tests/data/bip93_vectors.py index d27d9b0..c944a42 100644 --- a/tests/data/bip93_vectors.py +++ b/tests/data/bip93_vectors.py @@ -93,7 +93,6 @@ VALID_CODEX32_LONG = [ "A12UEL5LQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQV3RR8ZLCK96GTC3", "a12uel5lqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqv3rr8zlck96gtc3", - "a1002characterlonghumanreadablepartthatcontainsthenumber1,theexcludedcharactersbio,andeveryus-asciicharacterin[33-126]!\"#$%&'()*+,-./0123456789:;<=>?@[\\]^_`abcdefghijklmnopqrstuvwxyz{|}~!\"#$%&'()*+,-./0123456789:;<=>?@[\\]^_`abcdefghijklmnopqrstuvwxyz{|}~!\"#$%&'()*+,-./0123456789:;<=>?@[\\]^_`abcdefghijklmnopqrstuvwxyz{|}~!\"#$%&'()*+,-./0123456789:;<=>?@[\\]^_`abcdefghijklmnopqrstuvwxyz{|}~!\"#$%&'()*+,-./0123456789:;<=>?@[\\]^_`abcdefghijklmnopqrstuvwxyz{|}~!\"#$%&'()*+,-./0123456789:;<=>?@[\\]^_`abcdefghijklmnopqrstuvwxyz{|}~!\"#$%&'()*+,-./0123456789:;<=>?@[\\]^_`abcdefghijklmnopqrstuvwxyz{|}~!\"#$%&'()*+,-./0123456789:;<=>?@[\\]^_`abcdefghijklmnopqrstuvwxyz{|}~!\"#$%&'()*+,-./0123456789:;<=>?@[\\]^_`abcdefghijklmnopqrstuvwxyz{|}~!\"#$%&'()*+,-./0123456789:;<=>?@[\\]^_`abcdefghijklmnopqrstuvwxyz{|}~!\"#$%&'()*+,-./0123456789:;<=>?@[\\]^_`abcdefghijklmnopqrstuvwxyz{|}~!\"#$%&'()*+,-./0123456789:;<=>?@[\\]^_`abcdefghijklmnopqrstuvwxyz{|}~!\"#$%&'()*+,-./0123456789:;<=>?@[\\]^_`abcdefghijklmnopqrstuvwxyz{|}~15ttgtscr3gvktxamm8mzt", "abcdef12l7aum6echk45nj3s0wdvt2fg8x9yrzpql7aum6echk45nj3s0wdvt2fg8x9yrzpql7aum6echk45nj3s0wdvt2fg8x9yrzpqp9evrmhc52umqew", "1177777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777fn0jxg9gc35xwa8", "split13checkupstagehandshakeupstreamerranterredcaperredscatteredsusurrantplunderedqsp5ws8r2klm66l", @@ -117,7 +116,9 @@ + "12fauxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx836hdd09mhkhkhx", # HRP character out of range "\x80" + "12fauxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxlhf5ywnkmk4r3tc", # HRP character out of range - # overall max length exceeded + # HRP exceeds 83 characters + "a1002characterlonghumanreadablepartthatcontainsthenumber1,theexcludedcharactersbio,andeveryus-asciicharacterin[33-126]!\"#$%&'()*+,-./0123456789:;<=>?@[\\]^_`abcdefghijklmnopqrstuvwxyz{|}~!\"#$%&'()*+,-./0123456789:;<=>?@[\\]^_`abcdefghijklmnopqrstuvwxyz{|}~!\"#$%&'()*+,-./0123456789:;<=>?@[\\]^_`abcdefghijklmnopqrstuvwxyz{|}~!\"#$%&'()*+,-./0123456789:;<=>?@[\\]^_`abcdefghijklmnopqrstuvwxyz{|}~!\"#$%&'()*+,-./0123456789:;<=>?@[\\]^_`abcdefghijklmnopqrstuvwxyz{|}~!\"#$%&'()*+,-./0123456789:;<=>?@[\\]^_`abcdefghijklmnopqrstuvwxyz{|}~!\"#$%&'()*+,-./0123456789:;<=>?@[\\]^_`abcdefghijklmnopqrstuvwxyz{|}~!\"#$%&'()*+,-./0123456789:;<=>?@[\\]^_`abcdefghijklmnopqrstuvwxyz{|}~!\"#$%&'()*+,-./0123456789:;<=>?@[\\]^_`abcdefghijklmnopqrstuvwxyz{|}~!\"#$%&'()*+,-./0123456789:;<=>?@[\\]^_`abcdefghijklmnopqrstuvwxyz{|}~!\"#$%&'()*+,-./0123456789:;<=>?@[\\]^_`abcdefghijklmnopqrstuvwxyz{|}~!\"#$%&'()*+,-./0123456789:;<=>?@[\\]^_`abcdefghijklmnopqrstuvwxyz{|}~!\"#$%&'()*+,-./0123456789:;<=>?@[\\]^_`abcdefghijklmnopqrstuvwxyz{|}~15ttgtscr3gvktxamm8mzt", + # HRP exceeds 83 characters (also exceeds the overall max length) "a1003characterslonghumanreadablepartthatcontainsthenumber1,theexcludedcharactersbio,andeveryus-asciicharacterin[33-126]!\"#$%&'()*+,-./0123456789:;<=>?@[\\]^_`abcdefghijklmnopqrstuvwxyz{|}~!\"#$%&'()*+,-./0123456789:;<=>?@[\\]^_`abcdefghijklmnopqrstuvwxyz{|}~!\"#$%&'()*+,-./0123456789:;<=>?@[\\]^_`abcdefghijklmnopqrstuvwxyz{|}~!\"#$%&'()*+,-./0123456789:;<=>?@[\\]^_`abcdefghijklmnopqrstuvwxyz{|}~!\"#$%&'()*+,-./0123456789:;<=>?@[\\]^_`abcdefghijklmnopqrstuvwxyz{|}~!\"#$%&'()*+,-./0123456789:;<=>?@[\\]^_`abcdefghijklmnopqrstuvwxyz{|}~!\"#$%&'()*+,-./0123456789:;<=>?@[\\]^_`abcdefghijklmnopqrstuvwxyz{|}~!\"#$%&'()*+,-./0123456789:;<=>?@[\\]^_`abcdefghijklmnopqrstuvwxyz{|}~!\"#$%&'()*+,-./0123456789:;<=>?@[\\]^_`abcdefghijklmnopqrstuvwxyz{|}~!\"#$%&'()*+,-./0123456789:;<=>?@[\\]^_`abcdefghijklmnopqrstuvwxyz{|}~!\"#$%&'()*+,-./0123456789:;<=>?@[\\]^_`abcdefghijklmnopqrstuvwxyz{|}~!\"#$%&'()*+,-./0123456789:;<=>?@[\\]^_`abcdefghijklmnopqrstuvwxyz{|}~!\"#$%&'()*+,-./0123456789:;<=>?@[\\]^_`abcdefghijklmnopqrstuvwxyz{|}~12fauxxru38cppmlpu0t6l", "y12bfauxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxt3y5fewy4gnw2hs", # Invalid data character "lt12ifauxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxjxd0ehq868vm3zl", # Invalid data character diff --git a/tests/test_correction_bch.py b/tests/test_correction_bch.py index a8edc80..cd09961 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: diff --git a/tests/test_generic_hrp.py b/tests/test_generic_hrp.py index 9111a7d..9a5b913 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 from tools._wallet_test_vectors import STUB_FINGERPRINT UNKNOWN = { @@ -178,3 +178,18 @@ def fingerprint(_secret: object) -> bytes: status, output, error = _invoke(main, ["share", "d", "--plain"], basis) assert (status, output.strip(), error) == (0, UNKNOWN["short"]["D"], "") assert _invoke(ms_main, ["share", "d", "--plain"], basis)[0] == 2 + + +def test_83_character_hrp_may_span_printable_ascii() -> None: + pool = [chr(code) for code in range(33, 127) if not chr(code).isupper()] + hrp = "".join(pool[index % len(pool)] for index in range(83)) + assert "1" in hrp # the separator is the last "1", so an HRP may contain one + assert parse_codex32(oracle_encode(hrp, "0testsq")).hrp == hrp + + +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) diff --git a/tests/test_profiles.py b/tests/test_profiles.py index 3fbaca9..953951d 100644 --- a/tests/test_profiles.py +++ b/tests/test_profiles.py @@ -118,6 +118,17 @@ def test_expanded_codeword_upper_bound() -> None: _checksum_for_encoded_length("ms", len(bech32_decode(oversized)[1])) +def test_long_checksum_residue_stays_correct_past_its_period() -> None: + # Probes the polynomial itself with u5 values, not a codex32 string: the + # arithmetic still closes beyond the code's 1023-symbol period, so length is + # what verify() has to enforce, not the residue. + body = bech32_hrp_expand("ms") + [0] * 1100 + codeword = body + _CODEX32_LONG.create(body) + assert len(codeword) > 1023 + assert _CODEX32_LONG.polymod(codeword) == _CODEX32_LONG.constant + assert _CODEX32_LONG.verify(codeword) is False + + def test_checksum_is_verified_before_unknown_hrp_dispatch() -> None: valid_generic = _oracle_encode("zz", "0tests" + "q" * 26) artifact = parse_codex32(valid_generic) From 9a05bb353cee2dc2ffb794d57a8085576e4df740 Mon Sep 17 00:00:00 2001 From: Ben Westgate Date: Wed, 23 Sep 2026 11:26:10 -0500 Subject: [PATCH 2/4] bip93: Drop oversized frozen vector --- tests/data/bip93_vectors.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/tests/data/bip93_vectors.py b/tests/data/bip93_vectors.py index c944a42..843a05a 100644 --- a/tests/data/bip93_vectors.py +++ b/tests/data/bip93_vectors.py @@ -116,8 +116,6 @@ + "12fauxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx836hdd09mhkhkhx", # HRP character out of range "\x80" + "12fauxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxlhf5ywnkmk4r3tc", # HRP character out of range - # HRP exceeds 83 characters - "a1002characterlonghumanreadablepartthatcontainsthenumber1,theexcludedcharactersbio,andeveryus-asciicharacterin[33-126]!\"#$%&'()*+,-./0123456789:;<=>?@[\\]^_`abcdefghijklmnopqrstuvwxyz{|}~!\"#$%&'()*+,-./0123456789:;<=>?@[\\]^_`abcdefghijklmnopqrstuvwxyz{|}~!\"#$%&'()*+,-./0123456789:;<=>?@[\\]^_`abcdefghijklmnopqrstuvwxyz{|}~!\"#$%&'()*+,-./0123456789:;<=>?@[\\]^_`abcdefghijklmnopqrstuvwxyz{|}~!\"#$%&'()*+,-./0123456789:;<=>?@[\\]^_`abcdefghijklmnopqrstuvwxyz{|}~!\"#$%&'()*+,-./0123456789:;<=>?@[\\]^_`abcdefghijklmnopqrstuvwxyz{|}~!\"#$%&'()*+,-./0123456789:;<=>?@[\\]^_`abcdefghijklmnopqrstuvwxyz{|}~!\"#$%&'()*+,-./0123456789:;<=>?@[\\]^_`abcdefghijklmnopqrstuvwxyz{|}~!\"#$%&'()*+,-./0123456789:;<=>?@[\\]^_`abcdefghijklmnopqrstuvwxyz{|}~!\"#$%&'()*+,-./0123456789:;<=>?@[\\]^_`abcdefghijklmnopqrstuvwxyz{|}~!\"#$%&'()*+,-./0123456789:;<=>?@[\\]^_`abcdefghijklmnopqrstuvwxyz{|}~!\"#$%&'()*+,-./0123456789:;<=>?@[\\]^_`abcdefghijklmnopqrstuvwxyz{|}~!\"#$%&'()*+,-./0123456789:;<=>?@[\\]^_`abcdefghijklmnopqrstuvwxyz{|}~15ttgtscr3gvktxamm8mzt", # HRP exceeds 83 characters (also exceeds the overall max length) "a1003characterslonghumanreadablepartthatcontainsthenumber1,theexcludedcharactersbio,andeveryus-asciicharacterin[33-126]!\"#$%&'()*+,-./0123456789:;<=>?@[\\]^_`abcdefghijklmnopqrstuvwxyz{|}~!\"#$%&'()*+,-./0123456789:;<=>?@[\\]^_`abcdefghijklmnopqrstuvwxyz{|}~!\"#$%&'()*+,-./0123456789:;<=>?@[\\]^_`abcdefghijklmnopqrstuvwxyz{|}~!\"#$%&'()*+,-./0123456789:;<=>?@[\\]^_`abcdefghijklmnopqrstuvwxyz{|}~!\"#$%&'()*+,-./0123456789:;<=>?@[\\]^_`abcdefghijklmnopqrstuvwxyz{|}~!\"#$%&'()*+,-./0123456789:;<=>?@[\\]^_`abcdefghijklmnopqrstuvwxyz{|}~!\"#$%&'()*+,-./0123456789:;<=>?@[\\]^_`abcdefghijklmnopqrstuvwxyz{|}~!\"#$%&'()*+,-./0123456789:;<=>?@[\\]^_`abcdefghijklmnopqrstuvwxyz{|}~!\"#$%&'()*+,-./0123456789:;<=>?@[\\]^_`abcdefghijklmnopqrstuvwxyz{|}~!\"#$%&'()*+,-./0123456789:;<=>?@[\\]^_`abcdefghijklmnopqrstuvwxyz{|}~!\"#$%&'()*+,-./0123456789:;<=>?@[\\]^_`abcdefghijklmnopqrstuvwxyz{|}~!\"#$%&'()*+,-./0123456789:;<=>?@[\\]^_`abcdefghijklmnopqrstuvwxyz{|}~!\"#$%&'()*+,-./0123456789:;<=>?@[\\]^_`abcdefghijklmnopqrstuvwxyz{|}~12fauxxru38cppmlpu0t6l", "y12bfauxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxt3y5fewy4gnw2hs", # Invalid data character From e397fccc85cf6853319a414e54ba2267134edd8f Mon Sep 17 00:00:00 2001 From: Ben Westgate Date: Thu, 24 Sep 2026 13:08:09 -0500 Subject: [PATCH 3/4] bip93: use generalized-HRP draft vectors --- tests/data/bip93_vectors.py | 27 ++++++++++++++++++++++++-- tests/test_generic_hrp.py | 38 ++++++++++++++++++++++++++----------- tests/test_profiles.py | 11 ----------- 3 files changed, 52 insertions(+), 24 deletions(-) diff --git a/tests/data/bip93_vectors.py b/tests/data/bip93_vectors.py index 843a05a..c24ce10 100644 --- a/tests/data/bip93_vectors.py +++ b/tests/data/bip93_vectors.py @@ -79,6 +79,31 @@ "codex32_peev": "cl10peevst6cqh0wu7p5ssjyf4z4ez42ks9jlt3zneju9uuypr2hddak6tlqsjhsks4laxts8q", } +# BIP-93 generalized-HRP draft, bitcoin/bips bip93-generalize-hrp @ 01374bf. +BIP93_GENERAL_HRP = { + "share_a": "test_vector12spana320zyxwvutsrqpnmlkjhgfedca320zyxwvutsrqpnmlkjhgfedca304ppsqh4l7v3dh", + "share_c": "test_vector12spancacdefghjklmnpqrstuvwxyz023acdefghjklmnpqrstuvwxyz023jxmjy7q9xl7d3ul", + "derived_d": "test_vector12spandll4f8jlh4e5vdvuldlfxu2jhdnll4f8jlh4e5vdvuldlfxu2jhdnx3pe3yqzdl7p30y", + "secret_s": "test_vector12spans6xqguzttxkeqnjsjzv4jv3nz5k6xqguzttxkeqnjsjzv4jv3nz5kdf37v5qm2l7l3az", + "valid_83": ( + "a83characterlongcodex32humanreadablepartforsecretsharewiththedigit1andthelettersbio" + "10keyss9mfjjk25y05e3nq" + ), + "invalid_gap_regular": ( + "test_vector12spans6xqguzttxkeqnjsjzv4jv3nz5k6xqguzttxkeqnjsjzv4jv3nz5kp0f0tlr2cxh0t" + ), + "invalid_gap_long": ( + "test_vector12spans6xqguzttxkeqnjsjzv4jv3nz5k6xqguzttxkeqnjsjzv4jv3nz5c2m7mxmk8s5qecp" + ), + "invalid_uppercase_hrp_checksum": ( + "CL10PEEVST6CQH0WU7P5SSJYF4Z4EZ42KS9JLT3ZNEJU9UUYPR2HDDAK6TLQS3J5AYX4Y08079" + ), + "invalid_84": ( + "a84characterslongcodex32humanreadablepartforsecretsharewiththedigit1andthelettersbio" + "10keyss9hqxu3pxsnkdkth" + ), +} + VALID_CODEX32 = [ "A12UEL5LLGCHJ4UJCQVHG", @@ -116,8 +141,6 @@ + "12fauxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx836hdd09mhkhkhx", # HRP character out of range "\x80" + "12fauxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxlhf5ywnkmk4r3tc", # HRP character out of range - # HRP exceeds 83 characters (also exceeds the overall max length) - "a1003characterslonghumanreadablepartthatcontainsthenumber1,theexcludedcharactersbio,andeveryus-asciicharacterin[33-126]!\"#$%&'()*+,-./0123456789:;<=>?@[\\]^_`abcdefghijklmnopqrstuvwxyz{|}~!\"#$%&'()*+,-./0123456789:;<=>?@[\\]^_`abcdefghijklmnopqrstuvwxyz{|}~!\"#$%&'()*+,-./0123456789:;<=>?@[\\]^_`abcdefghijklmnopqrstuvwxyz{|}~!\"#$%&'()*+,-./0123456789:;<=>?@[\\]^_`abcdefghijklmnopqrstuvwxyz{|}~!\"#$%&'()*+,-./0123456789:;<=>?@[\\]^_`abcdefghijklmnopqrstuvwxyz{|}~!\"#$%&'()*+,-./0123456789:;<=>?@[\\]^_`abcdefghijklmnopqrstuvwxyz{|}~!\"#$%&'()*+,-./0123456789:;<=>?@[\\]^_`abcdefghijklmnopqrstuvwxyz{|}~!\"#$%&'()*+,-./0123456789:;<=>?@[\\]^_`abcdefghijklmnopqrstuvwxyz{|}~!\"#$%&'()*+,-./0123456789:;<=>?@[\\]^_`abcdefghijklmnopqrstuvwxyz{|}~!\"#$%&'()*+,-./0123456789:;<=>?@[\\]^_`abcdefghijklmnopqrstuvwxyz{|}~!\"#$%&'()*+,-./0123456789:;<=>?@[\\]^_`abcdefghijklmnopqrstuvwxyz{|}~!\"#$%&'()*+,-./0123456789:;<=>?@[\\]^_`abcdefghijklmnopqrstuvwxyz{|}~!\"#$%&'()*+,-./0123456789:;<=>?@[\\]^_`abcdefghijklmnopqrstuvwxyz{|}~12fauxxru38cppmlpu0t6l", "y12bfauxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxt3y5fewy4gnw2hs", # Invalid data character "lt12ifauxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxjxd0ehq868vm3zl", # Invalid data character "in12fauxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxvgegljrsvs5w9q", # Too short checksum diff --git a/tests/test_generic_hrp.py b/tests/test_generic_hrp.py index 9a5b913..0cf4f80 100644 --- a/tests/test_generic_hrp.py +++ b/tests/test_generic_hrp.py @@ -7,6 +7,7 @@ import pytest from _codex32_oracle import oracle_encode +from data.bip93_vectors import BIP93_GENERAL_HRP from codex32 import ( CorrectionContext, @@ -18,7 +19,7 @@ recover_secret, ) from codex32.cli import main, ms_main -from codex32.errors import InvalidLength, MismatchedHrp, MismatchedProfile +from codex32.errors import InvalidChecksum, InvalidLength, MismatchedHrp, MismatchedProfile from tools._wallet_test_vectors import STUB_FINGERPRINT UNKNOWN = { @@ -180,16 +181,31 @@ def fingerprint(_secret: object) -> bytes: assert _invoke(ms_main, ["share", "d", "--plain"], basis)[0] == 2 -def test_83_character_hrp_may_span_printable_ascii() -> None: - pool = [chr(code) for code in range(33, 127) if not chr(code).isupper()] - hrp = "".join(pool[index % len(pool)] for index in range(83)) - assert "1" in hrp # the separator is the last "1", so an HRP may contain one - assert parse_codex32(oracle_encode(hrp, "0testsq")).hrp == hrp +def test_bip93_generalized_hrp_share_vectors() -> None: + share_a = parse_codex32(BIP93_GENERAL_HRP["share_a"]) + share_c = parse_codex32(BIP93_GENERAL_HRP["share_c"]) + assert share_a.hrp == share_c.hrp == "test_vector" + assert recover_secret([share_a, share_c]).text == BIP93_GENERAL_HRP["secret_s"] + assert derive_share([share_a, share_c], "d").text == BIP93_GENERAL_HRP["derived_d"] -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 +def test_bip93_generalized_hrp_boundary_vectors() -> None: + valid = BIP93_GENERAL_HRP["valid_83"] + invalid = BIP93_GENERAL_HRP["invalid_84"] + artifact = parse_codex32(valid) + + assert len(artifact.hrp) == 83 + assert artifact.hrp == valid.rsplit("1", 1)[0] + with pytest.raises(InvalidLength): + parse_codex32(invalid) + + +@pytest.mark.parametrize("key", ("invalid_gap_regular", "invalid_gap_long")) +def test_bip93_generalized_hrp_gap_vectors_are_invalid(key: str) -> None: with pytest.raises(InvalidLength): - parse_codex32(over_limit) + parse_codex32(BIP93_GENERAL_HRP[key]) + + +def test_bip93_generalized_hrp_checksum_uses_lowercase_hrp() -> None: + with pytest.raises(InvalidChecksum): + parse_codex32(BIP93_GENERAL_HRP["invalid_uppercase_hrp_checksum"]) diff --git a/tests/test_profiles.py b/tests/test_profiles.py index 953951d..3fbaca9 100644 --- a/tests/test_profiles.py +++ b/tests/test_profiles.py @@ -118,17 +118,6 @@ def test_expanded_codeword_upper_bound() -> None: _checksum_for_encoded_length("ms", len(bech32_decode(oversized)[1])) -def test_long_checksum_residue_stays_correct_past_its_period() -> None: - # Probes the polynomial itself with u5 values, not a codex32 string: the - # arithmetic still closes beyond the code's 1023-symbol period, so length is - # what verify() has to enforce, not the residue. - body = bech32_hrp_expand("ms") + [0] * 1100 - codeword = body + _CODEX32_LONG.create(body) - assert len(codeword) > 1023 - assert _CODEX32_LONG.polymod(codeword) == _CODEX32_LONG.constant - assert _CODEX32_LONG.verify(codeword) is False - - def test_checksum_is_verified_before_unknown_hrp_dispatch() -> None: valid_generic = _oracle_encode("zz", "0tests" + "q" * 26) artifact = parse_codex32(valid_generic) From b1b1b27d149c3adac3dae56ea2edee278dd84fcf Mon Sep 17 00:00:00 2001 From: Ben Westgate Date: Sun, 27 Sep 2026 09:47:02 -0500 Subject: [PATCH 4/4] tests: Correct HRP vector provenance Name BenWestgate/bips PR #2 as the source of the generalized-HRP draft vectors. No vector data or parser behavior changes. --- tests/data/bip93_vectors.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/data/bip93_vectors.py b/tests/data/bip93_vectors.py index c24ce10..0ce348f 100644 --- a/tests/data/bip93_vectors.py +++ b/tests/data/bip93_vectors.py @@ -79,7 +79,7 @@ "codex32_peev": "cl10peevst6cqh0wu7p5ssjyf4z4ez42ks9jlt3zneju9uuypr2hddak6tlqsjhsks4laxts8q", } -# BIP-93 generalized-HRP draft, bitcoin/bips bip93-generalize-hrp @ 01374bf. +# BIP-93 generalized-HRP draft, BenWestgate/bips PR #2 @ 01374bf. BIP93_GENERAL_HRP = { "share_a": "test_vector12spana320zyxwvutsrqpnmlkjhgfedca320zyxwvutsrqpnmlkjhgfedca304ppsqh4l7v3dh", "share_c": "test_vector12spancacdefghjklmnpqrstuvwxyz023acdefghjklmnpqrstuvwxyz023jxmjy7q9xl7d3ul",