From a3d729607c21ba19171050e25fd89b60a3da0665 Mon Sep 17 00:00:00 2001 From: Ben Westgate Date: Wed, 4 Nov 2026 13:12:01 +0000 Subject: [PATCH 1/6] checksums: XOR CRC2-CRC4 padding with all ones Match the spare-bit CRC of the codex32 key-expressions draft. Without the XOR, a 24-byte seed extended with zero bits to 64 bytes keeps valid CRC3 padding; with it, that extension never matches, since 320 bits is not a multiple of CRC3's period of 7. CRC1 keeps no XOR, because one would give every all-zero 28-byte seed valid padding. Correction tie-breaks prefer the new padding, then the padding written before this change, then neither. BIP93 vectors 3 and 4 now generate their alternate 3 and 7 strings. pytest: 913 passed. Ruff check and format, mypy: passed. Claude-Session: https://claude.ai/code/session_019DuBKzoVE7oBFRnzoGH2ps --- docs/developer/api.md | 13 +++++++++-- src/codex32/checksums.py | 8 +++---- src/codex32/correction.py | 13 +++++++++-- src/codex32/profiles/ms32.py | 7 ++++++ tests/test_bip93.py | 8 +++---- tests/test_crc.py | 43 +++++++++++++++++++++++++++--------- 6 files changed, 70 insertions(+), 22 deletions(-) diff --git a/docs/developer/api.md b/docs/developer/api.md index 51c9fb5c..aa6eb874 100644 --- a/docs/developer/api.md +++ b/docs/developer/api.md @@ -554,12 +554,21 @@ The reproducible bit convention matters as much as the polynomial name: - the generator integers encode the lower polynomial coefficients (`1` for CRC1 and `0b11` for CRC2–CRC4); the leading `x^p` coefficient is implicit; - `p` zero bits are appended before reading the result; -- the final XOR/residue constant is zero; +- the final XOR is all ones for CRC2–CRC4 and zero for CRC1; - the `p` result bits are emitted register-most-significant bit first and become the otherwise discarded payload bits. Representative all-zero seed outputs for lengths 16 through 20 bytes are -`2, 6, 1, 2, 0`, corresponding to `2, 4, 1, 3, 0` padding bits. +`1, 9, 1, 5, 0`, corresponding to `2, 4, 1, 3, 0` padding bits. + +The all-ones XOR matches the spare-bit CRC of the codex32 key-expressions draft. +Without it, a 24-byte seed extended with zero bits to 64 bytes would keep valid +padding, since both sizes use CRC3. With it, the extended padding never +matches, because the 320 added bits are not a multiple of CRC3's period of 7. +CRC1 stays without an XOR because an XOR there would give every all-zero +28-byte seed valid padding. Strings generated before this XOR have the old +padding. Correction tie-breaks prefer the current padding, then that old +padding, then neither. The polynomials appear in the [Koopman CRC catalogue](https://users.ece.cmu.edu/~koopman/crc/index.html), including its [CRC-3](https://users.ece.cmu.edu/~koopman/crc/crc3.html) and diff --git a/src/codex32/checksums.py b/src/codex32/checksums.py index 6061d1e7..07121919 100644 --- a/src/codex32/checksums.py +++ b/src/codex32/checksums.py @@ -84,11 +84,11 @@ def create(self, values: list[int] | tuple[int, ...]) -> list[int]: None, # ``_Checksum`` consumes input bits most-significant bit first. With its # implicit leading term, these generator values spell x+1, x^2+x+1, - # x^3+x+1, and x^4+x+1 respectively. + # x^3+x+1, and x^4+x+1 respectively. CRC2-CRC4 end with an all-ones XOR. _Checksum("CRC1", (1,), 1, 0), - _Checksum("CRC2", (3,), 2, 0), - _Checksum("CRC3", (3,), 3, 0), - _Checksum("CRC4", (3,), 4, 0), + _Checksum("CRC2", (3,), 2, 0b11), + _Checksum("CRC3", (3,), 3, 0b111), + _Checksum("CRC4", (3,), 4, 0b1111), ) diff --git a/src/codex32/correction.py b/src/codex32/correction.py index 39fc2627..85bd946b 100644 --- a/src/codex32/correction.py +++ b/src/codex32/correction.py @@ -49,7 +49,7 @@ from codex32.gf32 import _inverse as _gf32_inverse from codex32.gf32 import _multiply as _gf32_multiply from codex32.profiles import Profile, _optional_profile_rules -from codex32.profiles.ms32 import MasterSeed, _has_generation_padding +from codex32.profiles.ms32 import MasterSeed, _has_generation_padding, _has_legacy_generation_padding @dataclass(frozen=True, slots=True) @@ -747,10 +747,16 @@ def _allowed(context: CorrectionContext, candidate: CorrectionCandidate) -> bool ) +def _legacy_padding_match(candidate: CorrectionCandidate) -> bool | None: + artifact = candidate.artifact + return _has_legacy_generation_padding(artifact) if isinstance(artifact, MasterSeed) else None + + def _candidate_order(candidate: CorrectionCandidate) -> tuple[object, ...]: return ( candidate.addend_hamming_weight, candidate.crc_padding_match is not True, + _legacy_padding_match(candidate) is not True, candidate.artifact.text.lower(), tuple((edit.reverse_index, edit.kind, edit.observed, edit.replacement) for edit in candidate.edits), ) @@ -802,7 +808,10 @@ def _best( tied = [item for item in tied if len(item.artifact.text) in (48, 74)] hamming = min(item.addend_hamming_weight for item in tied) tied = [item for item in tied if item.addend_hamming_weight == hamming] - hints: tuple[Callable[[CorrectionCandidate], bool | None], ...] = ((lambda item: item.crc_padding_match),) + hints: tuple[Callable[[CorrectionCandidate], bool | None], ...] = ( + (lambda item: item.crc_padding_match), + _legacy_padding_match, + ) if fingerprint_match is not None: hints += (fingerprint_match,) for hint in hints: diff --git a/src/codex32/profiles/ms32.py b/src/codex32/profiles/ms32.py index ac662ee7..a725f3b7 100644 --- a/src/codex32/profiles/ms32.py +++ b/src/codex32/profiles/ms32.py @@ -34,6 +34,13 @@ def _has_generation_padding(secret: MasterSeed) -> bool: return _payload_padding(secret) == _crc_pad(secret.seed_bytes) +def _has_legacy_generation_padding(secret: MasterSeed) -> bool: + """Match the padding generated before CRC2-CRC4 gained their all-ones XOR.""" + padding_bits = (len(secret.payload_symbols) * 5) % 8 + final_xor = (1 << padding_bits) - 1 if padding_bits > 1 else 0 + return _payload_padding(secret) == _crc_pad(secret.seed_bytes) ^ final_xor + + class MasterSeed(Secret): """A validated BIP93 ``ms`` secret.""" diff --git a/tests/test_bip93.py b/tests/test_bip93.py index a57b1f05..65ae35fc 100644 --- a/tests/test_bip93.py +++ b/tests/test_bip93.py @@ -60,10 +60,10 @@ def test_vector_2_public_api_derives_and_recovers() -> None: def test_vector_3_factory_and_public_derivation() -> None: seed = bytes.fromhex(VECTOR_3["secret_hex"]) - secret = MasterSeed.from_seed(seed, identifier="cash", threshold=3) - assert secret.text == VECTOR_3["secret_s"] + generated = MasterSeed.from_seed(seed, identifier="cash", threshold=3) + assert generated.text == VECTOR_3["secret_s_alternate_3"] basis = [ - secret, + parse_codex32(VECTOR_3["secret_s"]), parse_codex32(VECTOR_3["share_a"]), parse_codex32(VECTOR_3["share_c"]), ] @@ -78,7 +78,7 @@ def test_vector_3_factory_and_public_derivation() -> None: def test_vector_4_all_arbitrary_parsed_padding() -> None: seed = bytes.fromhex(VECTOR_4["secret_hex"]) generated = MasterSeed.from_seed(seed, identifier="leet") - assert generated.text == VECTOR_4["secret_s_alternate_8"] + assert generated.text == VECTOR_4["secret_s_alternate_7"] for pad_value in range(16): alternate = parse_codex32(VECTOR_4[f"secret_s_alternate_{pad_value}"]) assert isinstance(alternate, MasterSeed) diff --git a/tests/test_crc.py b/tests/test_crc.py index cd0ace8b..3c31e59a 100644 --- a/tests/test_crc.py +++ b/tests/test_crc.py @@ -6,10 +6,16 @@ from data.bip93_vectors import VECTOR_2, VECTOR_4 import codex32 -from codex32 import MasterSeed, Share, parse_codex32 +from codex32 import CorrectionCandidate, MasterSeed, Share, parse_codex32 from codex32.checksums import _CRC, _crc_pad +from codex32.correction import _best from codex32.gf32 import _multiply as _gf32_multiply -from codex32.profiles.ms32 import SEED_BYTE_LENGTHS, _has_generation_padding, _payload_padding +from codex32.profiles.ms32 import ( + SEED_BYTE_LENGTHS, + _has_generation_padding, + _has_legacy_generation_padding, + _payload_padding, +) @pytest.mark.parametrize( @@ -17,9 +23,9 @@ ( (bytes(range(20)), 0, 0), (bytes(range(18)), 1, 0), - (bytes(range(16)), 2, 2), - (bytes(range(19)), 3, 7), - (bytes(range(17)), 4, 11), + (bytes(range(16)), 2, 1), + (bytes(range(19)), 3, 0), + (bytes(range(17)), 4, 4), ), ) def test_frozen_crc_padding_values(data: bytes, padding_bits: int, expected: int) -> None: @@ -31,12 +37,14 @@ def test_frozen_crc_padding_values(data: bytes, padding_bits: int, expected: int def test_compact_crc_definitions_are_unchanged() -> None: assert _CRC[0] is None assert tuple( - (checksum.kind, checksum.generators, checksum.length) for checksum in _CRC[1:] if checksum is not None + (checksum.kind, checksum.generators, checksum.length, checksum.constant) + for checksum in _CRC[1:] + if checksum is not None ) == ( - ("CRC1", (1,), 1), - ("CRC2", (3,), 2), - ("CRC3", (3,), 3), - ("CRC4", (3,), 4), + ("CRC1", (1,), 1, 0), + ("CRC2", (3,), 2, 0b11), + ("CRC3", (3,), 3, 0b111), + ("CRC4", (3,), 4, 0b1111), ) @@ -57,6 +65,21 @@ def test_parsed_master_seed_validity_is_independent_of_crc() -> None: assert generated.payload_symbols in {secret.payload_symbols for secret in parsed} +def test_tie_break_prefers_current_then_legacy_crc_padding() -> None: + parsed = [parse_codex32(VECTOR_4[f"secret_s_alternate_{padding}"]) for padding in range(16)] + assert all(isinstance(secret, MasterSeed) for secret in parsed) + current = next(secret for secret in parsed if _has_generation_padding(secret)) + legacy = next(secret for secret in parsed if _has_legacy_generation_padding(secret)) + other = next(secret for secret in parsed if secret not in (current, legacy)) + + def best(*secrets: MasterSeed) -> tuple[MasterSeed, ...]: + candidates = [CorrectionCandidate(s, (), 1, 0, 0, _has_generation_padding(s)) for s in secrets] + return tuple(candidate.artifact for candidate in _best(candidates)) + + assert best(other, legacy, current) == (current,) + assert best(other, legacy) == (legacy,) + + def test_padding_acceptance_is_balanced_for_every_nonzero_lagrange_weight() -> None: """A free GF(32) symbol leaves each permitted padding suffix equally likely.""" for padding_bits in range(1, 5): From 024efb2018135d10a679bece356a4036e2af395c Mon Sep 17 00:00:00 2001 From: Ben Westgate Date: Tue, 10 Nov 2026 15:10:23 -0600 Subject: [PATCH 2/6] ms32: remove legacy CRC padding recognition --- src/codex32/profiles/ms32.py | 7 ------- 1 file changed, 7 deletions(-) diff --git a/src/codex32/profiles/ms32.py b/src/codex32/profiles/ms32.py index a725f3b7..ac662ee7 100644 --- a/src/codex32/profiles/ms32.py +++ b/src/codex32/profiles/ms32.py @@ -34,13 +34,6 @@ def _has_generation_padding(secret: MasterSeed) -> bool: return _payload_padding(secret) == _crc_pad(secret.seed_bytes) -def _has_legacy_generation_padding(secret: MasterSeed) -> bool: - """Match the padding generated before CRC2-CRC4 gained their all-ones XOR.""" - padding_bits = (len(secret.payload_symbols) * 5) % 8 - final_xor = (1 << padding_bits) - 1 if padding_bits > 1 else 0 - return _payload_padding(secret) == _crc_pad(secret.seed_bytes) ^ final_xor - - class MasterSeed(Secret): """A validated BIP93 ``ms`` secret.""" From 87633785f85cf349e2cc3af489f0c2b35047ee00 Mon Sep 17 00:00:00 2001 From: Ben Westgate Date: Tue, 10 Nov 2026 15:10:25 -0600 Subject: [PATCH 3/6] correction: rank only current CRC padding --- src/codex32/correction.py | 13 ++----------- 1 file changed, 2 insertions(+), 11 deletions(-) diff --git a/src/codex32/correction.py b/src/codex32/correction.py index 85bd946b..13e5fc97 100644 --- a/src/codex32/correction.py +++ b/src/codex32/correction.py @@ -49,7 +49,7 @@ from codex32.gf32 import _inverse as _gf32_inverse from codex32.gf32 import _multiply as _gf32_multiply from codex32.profiles import Profile, _optional_profile_rules -from codex32.profiles.ms32 import MasterSeed, _has_generation_padding, _has_legacy_generation_padding +from codex32.profiles.ms32 import MasterSeed, _has_generation_padding @dataclass(frozen=True, slots=True) @@ -747,16 +747,10 @@ def _allowed(context: CorrectionContext, candidate: CorrectionCandidate) -> bool ) -def _legacy_padding_match(candidate: CorrectionCandidate) -> bool | None: - artifact = candidate.artifact - return _has_legacy_generation_padding(artifact) if isinstance(artifact, MasterSeed) else None - - def _candidate_order(candidate: CorrectionCandidate) -> tuple[object, ...]: return ( candidate.addend_hamming_weight, candidate.crc_padding_match is not True, - _legacy_padding_match(candidate) is not True, candidate.artifact.text.lower(), tuple((edit.reverse_index, edit.kind, edit.observed, edit.replacement) for edit in candidate.edits), ) @@ -808,10 +802,7 @@ def _best( tied = [item for item in tied if len(item.artifact.text) in (48, 74)] hamming = min(item.addend_hamming_weight for item in tied) tied = [item for item in tied if item.addend_hamming_weight == hamming] - hints: tuple[Callable[[CorrectionCandidate], bool | None], ...] = ( - (lambda item: item.crc_padding_match), - _legacy_padding_match, - ) + hints: tuple[Callable[[CorrectionCandidate], bool | None], ...] = (lambda item: item.crc_padding_match,) if fingerprint_match is not None: hints += (fingerprint_match,) for hint in hints: From cf5eb0f1fb34e857687d21edfedcc6c19d9dbe8a Mon Sep 17 00:00:00 2001 From: Ben Westgate Date: Tue, 10 Nov 2026 15:10:28 -0600 Subject: [PATCH 4/6] tests: ensure historical padding does not win correction ties --- tests/test_crc.py | 32 ++++++++++++++++---------------- 1 file changed, 16 insertions(+), 16 deletions(-) diff --git a/tests/test_crc.py b/tests/test_crc.py index 3c31e59a..33d3d0f1 100644 --- a/tests/test_crc.py +++ b/tests/test_crc.py @@ -10,12 +10,7 @@ from codex32.checksums import _CRC, _crc_pad from codex32.correction import _best from codex32.gf32 import _multiply as _gf32_multiply -from codex32.profiles.ms32 import ( - SEED_BYTE_LENGTHS, - _has_generation_padding, - _has_legacy_generation_padding, - _payload_padding, -) +from codex32.profiles.ms32 import SEED_BYTE_LENGTHS, _has_generation_padding, _payload_padding @pytest.mark.parametrize( @@ -65,19 +60,24 @@ def test_parsed_master_seed_validity_is_independent_of_crc() -> None: assert generated.payload_symbols in {secret.payload_symbols for secret in parsed} -def test_tie_break_prefers_current_then_legacy_crc_padding() -> None: +def test_tie_break_prefers_only_current_crc_padding() -> None: parsed = [parse_codex32(VECTOR_4[f"secret_s_alternate_{padding}"]) for padding in range(16)] assert all(isinstance(secret, MasterSeed) for secret in parsed) current = next(secret for secret in parsed if _has_generation_padding(secret)) - legacy = next(secret for secret in parsed if _has_legacy_generation_padding(secret)) - other = next(secret for secret in parsed if secret not in (current, legacy)) - - def best(*secrets: MasterSeed) -> tuple[MasterSeed, ...]: - candidates = [CorrectionCandidate(s, (), 1, 0, 0, _has_generation_padding(s)) for s in secrets] - return tuple(candidate.artifact for candidate in _best(candidates)) - - assert best(other, legacy, current) == (current,) - assert best(other, legacy) == (legacy,) + previous = parsed[8] # Pad suffix produced before the final XOR was added. + other = parsed[0] + assert current not in (previous, other) + + def candidates(*secrets: MasterSeed) -> list[CorrectionCandidate]: + return [CorrectionCandidate(s, (), 1, 0, 0, _has_generation_padding(s)) for s in secrets] + + assert tuple(item.artifact for item in _best(candidates(other, previous, current))) == (current,) + # Without a current CRC match, neither historical nor arbitrary padding wins. + tied = tuple(item.artifact for item in _best(candidates(other, previous))) + assert tied == tuple(sorted((other, previous), key=lambda secret: secret.text.lower())) + # The wallet hint can decide the tie without being overridden by historical padding. + hinted = _best(candidates(other, previous), fingerprint_match=lambda item: item.artifact == other) + assert tuple(item.artifact for item in hinted) == (other,) def test_padding_acceptance_is_balanced_for_every_nonzero_lagrange_weight() -> None: From 91fcf6d766a8819a3b2d567084525d80a67dd924 Mon Sep 17 00:00:00 2001 From: Ben Westgate Date: Tue, 10 Nov 2026 15:10:30 -0600 Subject: [PATCH 5/6] docs: remove historical CRC tie-break policy --- docs/developer/api.md | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/docs/developer/api.md b/docs/developer/api.md index aa6eb874..83a2fd68 100644 --- a/docs/developer/api.md +++ b/docs/developer/api.md @@ -566,9 +566,8 @@ Without it, a 24-byte seed extended with zero bits to 64 bytes would keep valid padding, since both sizes use CRC3. With it, the extended padding never matches, because the 320 added bits are not a multiple of CRC3's period of 7. CRC1 stays without an XOR because an XOR there would give every all-zero -28-byte seed valid padding. Strings generated before this XOR have the old -padding. Correction tie-breaks prefer the current padding, then that old -padding, then neither. +28-byte seed valid padding. Correction tie-breaks consider only the current +generation CRC padding. Historical padding formats receive no special preference. The polynomials appear in the [Koopman CRC catalogue](https://users.ece.cmu.edu/~koopman/crc/index.html), including its [CRC-3](https://users.ece.cmu.edu/~koopman/crc/crc3.html) and From 236630bf7b5326f173e8e19e2f489c5eddc88f69 Mon Sep 17 00:00:00 2001 From: Ben Westgate Date: Wed, 11 Nov 2026 08:41:53 +0000 Subject: [PATCH 6/6] tests: Drop historical CRC padding tie-break test The legacy padding check is gone, so nothing should model the padding written before the all-ones XOR. test_cli_tie_breaks_follow_hamming_crc_then_fingerprint already covers the Hamming, CRC, fingerprint order. Drop the API doc line about historical padding for the same reason. pytest on a squash of this branch: 912 passed, also with -O. Ruff check and format: passed. Claude-Session: https://claude.ai/code/session_019DuBKzoVE7oBFRnzoGH2ps --- docs/developer/api.md | 3 +-- tests/test_crc.py | 23 +---------------------- 2 files changed, 2 insertions(+), 24 deletions(-) diff --git a/docs/developer/api.md b/docs/developer/api.md index 83a2fd68..0289bd65 100644 --- a/docs/developer/api.md +++ b/docs/developer/api.md @@ -566,8 +566,7 @@ Without it, a 24-byte seed extended with zero bits to 64 bytes would keep valid padding, since both sizes use CRC3. With it, the extended padding never matches, because the 320 added bits are not a multiple of CRC3's period of 7. CRC1 stays without an XOR because an XOR there would give every all-zero -28-byte seed valid padding. Correction tie-breaks consider only the current -generation CRC padding. Historical padding formats receive no special preference. +28-byte seed valid padding. The polynomials appear in the [Koopman CRC catalogue](https://users.ece.cmu.edu/~koopman/crc/index.html), including its [CRC-3](https://users.ece.cmu.edu/~koopman/crc/crc3.html) and diff --git a/tests/test_crc.py b/tests/test_crc.py index 33d3d0f1..2de7f149 100644 --- a/tests/test_crc.py +++ b/tests/test_crc.py @@ -6,9 +6,8 @@ from data.bip93_vectors import VECTOR_2, VECTOR_4 import codex32 -from codex32 import CorrectionCandidate, MasterSeed, Share, parse_codex32 +from codex32 import MasterSeed, Share, parse_codex32 from codex32.checksums import _CRC, _crc_pad -from codex32.correction import _best from codex32.gf32 import _multiply as _gf32_multiply from codex32.profiles.ms32 import SEED_BYTE_LENGTHS, _has_generation_padding, _payload_padding @@ -60,26 +59,6 @@ def test_parsed_master_seed_validity_is_independent_of_crc() -> None: assert generated.payload_symbols in {secret.payload_symbols for secret in parsed} -def test_tie_break_prefers_only_current_crc_padding() -> None: - parsed = [parse_codex32(VECTOR_4[f"secret_s_alternate_{padding}"]) for padding in range(16)] - assert all(isinstance(secret, MasterSeed) for secret in parsed) - current = next(secret for secret in parsed if _has_generation_padding(secret)) - previous = parsed[8] # Pad suffix produced before the final XOR was added. - other = parsed[0] - assert current not in (previous, other) - - def candidates(*secrets: MasterSeed) -> list[CorrectionCandidate]: - return [CorrectionCandidate(s, (), 1, 0, 0, _has_generation_padding(s)) for s in secrets] - - assert tuple(item.artifact for item in _best(candidates(other, previous, current))) == (current,) - # Without a current CRC match, neither historical nor arbitrary padding wins. - tied = tuple(item.artifact for item in _best(candidates(other, previous))) - assert tied == tuple(sorted((other, previous), key=lambda secret: secret.text.lower())) - # The wallet hint can decide the tie without being overridden by historical padding. - hinted = _best(candidates(other, previous), fingerprint_match=lambda item: item.artifact == other) - assert tuple(item.artifact for item in hinted) == (other,) - - def test_padding_acceptance_is_balanced_for_every_nonzero_lagrange_weight() -> None: """A free GF(32) symbol leaves each permitted padding suffix equally likely.""" for padding_bits in range(1, 5):