diff --git a/docs/developer/api.md b/docs/developer/api.md index 328597d..614a5a1 100644 --- a/docs/developer/api.md +++ b/docs/developer/api.md @@ -101,8 +101,57 @@ generic parse-length failure. hidden state. Private Python names are convention rather than access control. The supported -surface is the 23-name package `__all__`; direct use of private helpers is -unsupported but remains in the review scope. +top-level surface is the 23-name package `__all__`; direct use of private helpers +is unsupported but remains in the review scope. + +### Reference-vector construction API + +Reference-vector authors may use the following module-level interfaces without +depending on implementation-private names: + +- `codex32.bech32`: `CHARSET`, `bech32_decode`, `bech32_encode`, + `bech32_hrp_expand`, `chars_to_u5`, and `u5_to_chars`; +- `codex32.checksums`: `Checksum`, `CODEX32`, and `CODEX32_LONG`; +- `codex32.bip93`: `checksum_for_body_length` and + `checksum_for_encoded_length`. + +For example, a generic codex32 vector can be constructed without importing an +underscore-prefixed name: + +```python +from codex32.bech32 import bech32_encode, chars_to_u5 +from codex32.bip93 import checksum_for_body_length + +hrp = "zz" +body = chars_to_u5("0tests" + "q" * 26) +checksum = checksum_for_body_length(hrp, len(body)) +text = bech32_encode(hrp, body, checksum) +``` + +These are supported module interfaces for codec and vector work; they are not +added to the package-level `codex32.__all__`, whose backup/recovery surface stays +deliberately narrow. + +The remaining production cross-module private imports are intentional internal +couplings rather than user-facing APIs: + +- `_alignment.py`, `_competitors.py`, `correction.py`, and `indel.py` form one + bounded correction engine. Their underscored search state, views, and pruning + helpers are exchanged only inside that engine. +- `bip93.py` and `correction.py` use the shared private GF(32) arithmetic in + `gf32.py`; profile factories use `_from_parts` and profile-rule helpers to + preserve one artifact-validation path without publishing construction hooks. +- `generation.py`, `wallet.py`, and `_bitcoin_core.py` share the private BIP32 + root primitives. `_bitcoin_core.py` also consumes wallet descriptor records + while remaining the sole process/state adapter. +- `_cli_input.py` and `cli.py` share private correction/profile orchestration, + input state, and parser plumbing. Those names exist to compose the CLI, not as + a second domain API. + +Internal benchmark and verification tools may import those implementation names +when they are explicitly testing the implementation itself. Tools or examples +that model external vector construction use the supported module interfaces +above. ### Size budget diff --git a/src/codex32/_bitcoin_core.py b/src/codex32/_bitcoin_core.py index 4fc85e5..dba61ed 100644 --- a/src/codex32/_bitcoin_core.py +++ b/src/codex32/_bitcoin_core.py @@ -12,7 +12,7 @@ from typing import Literal from codex32._bip32 import _master_xprv_from_seed -from codex32.bech32 import _u5_to_chars, convertbits +from codex32.bech32 import convertbits, u5_to_chars from codex32.generation import _fingerprint_identifier from codex32.profiles.ms32 import MasterSeed from codex32.wallet import _descriptor_records @@ -92,7 +92,7 @@ def identifier_origin(secret: MasterSeed, fingerprint: bytes) -> str | None: ripemd_unavailable = True continue derived = convertbits(hashed, 8, 5, pad=True) - if identifier[:3] == _u5_to_chars(tuple(derived[:3])): + if identifier[:3] == u5_to_chars(tuple(derived[:3])): return name return "Bails check unavailable" if ripemd_unavailable else None diff --git a/src/codex32/_cli_input.py b/src/codex32/_cli_input.py index 6090352..72fbbb2 100644 --- a/src/codex32/_cli_input.py +++ b/src/codex32/_cli_input.py @@ -12,13 +12,13 @@ from time import monotonic from typing import Any, Literal, cast -from codex32.bech32 import interpret_mixed_case +from codex32.bech32 import _ascii_lower, interpret_mixed_case from codex32.bip93 import ( Secret, Share, - _checksum_for_encoded_length, _validate_basis_prefix, _validate_recovery_prefix, + checksum_for_encoded_length, parse_codex32, recover_secret, ) @@ -66,11 +66,6 @@ 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) @@ -402,7 +397,7 @@ def _case_interpretation( candidate = None else: bits = ( - 5 * _checksum_for_encoded_length(artifact.hrp, len(artifact.text) - len(artifact.hrp) - 1).length + 5 * checksum_for_encoded_length(artifact.hrp, len(artifact.text) - len(artifact.hrp) - 1).length ) proposed = CorrectionCandidate(artifact, (), 1, 0, 0, None, capture_space_bits=bits) candidate = proposed if allowed is None or allowed(proposed) else None diff --git a/src/codex32/bech32.py b/src/codex32/bech32.py index 3cb14a8..9ee83ba 100644 --- a/src/codex32/bech32.py +++ b/src/codex32/bech32.py @@ -1,6 +1,6 @@ """Bech32 character, container, and bit-conversion helpers.""" -from codex32.checksums import _Checksum +from codex32.checksums import Checksum from codex32.errors import ( InvalidCase, InvalidCharacter, @@ -19,16 +19,24 @@ def bech32_hrp_expand(hrp: str) -> list[int]: return [ord(x) >> 5 for x in hrp] + [0] + [ord(x) & 31 for x in hrp] -def _u5_to_chars(values: list[int] | tuple[int, ...]) -> str: +def _ascii_lower(value: str) -> str: + return "".join(character.lower() if character.isascii() else character for character in value) + + +def u5_to_chars(values: list[int] | tuple[int, ...]) -> str: + """Convert 5-bit values to Bech32 characters.""" + for index, value in enumerate(values): if not 0 <= value < 32: raise InvalidCharacter(f"u5 value {value} at index {index} is outside 0..31") return "".join(CHARSET[value] for value in values) -def _chars_to_u5(value: str, first_position: int = 1) -> list[int]: +def chars_to_u5(value: str, first_position: int = 1) -> list[int]: + """Convert Bech32 characters to 5-bit values.""" + result: list[int] = [] - for index, character in enumerate(value.lower()): + for index, character in enumerate(_ascii_lower(value)): position = CHARSET.find(character) if position < 0: label = "Apostrophe (')" if character == "'" else f"The character {character!r}" @@ -69,18 +77,18 @@ def interpret_mixed_case(value: str, immutable_length: int) -> tuple[str, str, b return normalized, erased, uppercase -def bech32_encode(hrp: str, data: list[int], spec: _Checksum) -> str: +def bech32_encode(hrp: str, data: list[int], spec: Checksum) -> str: """Compute a Bech32 string given HRP and data values.""" checksum = spec.create(bech32_hrp_expand(hrp) + list(data)) - return f"{hrp}1{_u5_to_chars([*data, *checksum])}" + return f"{hrp}1{u5_to_chars([*data, *checksum])}" -def bech32_verify_checksum(hrp: str, data: list[int], spec: _Checksum) -> bool: +def bech32_verify_checksum(hrp: str, data: list[int], spec: Checksum) -> bool: """Verify the checksum selected by the calling application.""" return spec.verify(bech32_hrp_expand(hrp) + list(data)) -def bech32_decode(value: str, spec: _Checksum | None = None) -> tuple[str, list[int]]: +def bech32_decode(value: str, spec: Checksum | None = None) -> tuple[str, list[int]]: """Validate a Bech32 string, optionally including its checksum.""" _validate_single_case_ascii(value) separator = value.rfind("1") @@ -92,7 +100,7 @@ def bech32_decode(value: str, spec: _Checksum | None = None) -> tuple[str, list[ 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) + data = chars_to_u5(lowered[separator + 1 :], separator + 2) if spec is None: return hrp, data if len(data) < spec.length or not bech32_verify_checksum(hrp, data, spec): diff --git a/src/codex32/bip93.py b/src/codex32/bip93.py index 0592434..bd743eb 100644 --- a/src/codex32/bip93.py +++ b/src/codex32/bip93.py @@ -6,13 +6,13 @@ from codex32.bech32 import ( CHARSET, - _chars_to_u5, - _u5_to_chars, bech32_decode, bech32_encode, bech32_verify_checksum, + chars_to_u5, + u5_to_chars, ) -from codex32.checksums import _CODEX32, _CODEX32_LONG, _Checksum +from codex32.checksums import CODEX32, CODEX32_LONG, Checksum from codex32.errors import ( DuplicateShareIndex, ExistingTargetIndex, @@ -76,7 +76,7 @@ def __post_init__(self) -> None: def _from_symbols(cls, symbols: tuple[int, ...]) -> "Header": if len(symbols) != 6: raise InvalidLength("codex32 header must contain six symbols") - text = _u5_to_chars(symbols) + text = u5_to_chars(symbols) if text[0] not in "023456789": raise InvalidThreshold( f"The threshold must be 0 or a number from 2 through 9; found {text[0]!r}." @@ -85,25 +85,33 @@ def _from_symbols(cls, symbols: tuple[int, ...]) -> "Header": @property def _symbols(self) -> tuple[int, ...]: - return tuple(_chars_to_u5(f"{self.threshold}{self.identifier}{self.index}")) + return tuple(chars_to_u5(f"{self.threshold}{self.identifier}{self.index}")) -def _checksum_for_encoded_length(hrp: str, encoded_length: int) -> _Checksum: +def checksum_for_encoded_length(hrp: str, encoded_length: int) -> Checksum: + """Return the codex32 checksum required by an encoded HRP/data length.""" + expanded_length = 2 * len(hrp) + 1 + encoded_length - if expanded_length <= 93: - return _CODEX32 - if expanded_length < 96: + if 94 <= expanded_length <= 95: raise InvalidLength("expanded codex32 lengths 94 and 95 are invalid") - if expanded_length <= 1023: - return _CODEX32_LONG - raise InvalidLength("expanded codex32 codeword exceeds 1023 symbols") + if expanded_length > 1023: + raise InvalidLength("expanded codex32 codeword exceeds 1023 symbols") + return CODEX32 if expanded_length <= 93 else CODEX32_LONG + + +def checksum_for_body_length(hrp: str, body_length: int) -> Checksum: + """Return the checksum required when constructing a codex32 body.""" + + checksum = CODEX32 if 2 * len(hrp) + 1 + body_length <= 80 else CODEX32_LONG + checksum_for_encoded_length(hrp, body_length + checksum.length) + return checksum -def _decode_codex32(text: str) -> tuple[str, _ProfileRules | None, tuple[int, ...], _Checksum]: +def _decode_codex32(text: str) -> tuple[str, _ProfileRules | None, tuple[int, ...], Checksum]: hrp, encoded = bech32_decode(text) 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)) + checksum = checksum_for_encoded_length(hrp, len(encoded)) body = tuple(encoded[: -checksum.length]) Header._from_symbols(body[:6]) if not bech32_verify_checksum(hrp, encoded, checksum): @@ -213,10 +221,7 @@ def _from_parts( if rules is not None: _validate_payload(rules.profile, header, payload) body = [*header._symbols, *payload] - expanded_body_length = 2 * len(normalized_hrp) + 1 + len(body) - checksum = _CODEX32 if expanded_body_length <= 80 else _CODEX32_LONG - # Validate the completed generic codeword, including the 94/95 gap. - _checksum_for_encoded_length(normalized_hrp, len(body) + checksum.length) + checksum = checksum_for_body_length(normalized_hrp, len(body)) text = bech32_encode(normalized_hrp, body, checksum) return parse_codex32(text.upper() if uppercase else text) @@ -338,7 +343,7 @@ def _interpolate_tail(share_set: _ShareSet, target: str) -> Share | Secret: value ^= _gf32_multiply(weight, row[column]) result.append(value) header = Header(share_set.threshold, share_set.identifier, target) - text = f"{share_set.hrp}1{_u5_to_chars((*header._symbols, *result))}" + text = f"{share_set.hrp}1{u5_to_chars((*header._symbols, *result))}" return parse_codex32(text.upper() if share_set.uppercase else text) diff --git a/src/codex32/checksums.py b/src/codex32/checksums.py index 19262d1..75c0452 100644 --- a/src/codex32/checksums.py +++ b/src/codex32/checksums.py @@ -20,7 +20,9 @@ @dataclass(frozen=True, slots=True) -class _Checksum: +class Checksum: + """Immutable checksum specification for reference-vector construction.""" + kind: str generators: tuple[int, ...] length: int @@ -49,21 +51,21 @@ def create(self, values: list[int] | tuple[int, ...]) -> list[int]: return [(residue >> (width * (self.length - 1 - index))) & mask for index in range(self.length)] -_CODEX32 = _Checksum("codex32", _CODEX32_GEN, 13, 0x10CE0795C2FD1E62A, 93) -_CODEX32_LONG = _Checksum("Long codex32", _CODEX32_LONG_GEN, 15, 0x43381E570BF4798AB26, 1023) +CODEX32 = Checksum("codex32", _CODEX32_GEN, 13, 0x10CE0795C2FD1E62A, 93) +CODEX32_LONG = Checksum("Long codex32", _CODEX32_LONG_GEN, 15, 0x43381E570BF4798AB26, 1023) # Descriptor checksum remains an independently specified, non-codex32 helper. -DESCSUM = _Checksum("Descriptor", _DESCSUM_GEN, 8, 1) +DESCSUM = Checksum("Descriptor", _DESCSUM_GEN, 8, 1) _CRC = ( None, - # ``_Checksum`` consumes input bits most-significant bit first. With its + # ``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. - _Checksum("CRC1", (1,), 1, 0), - _Checksum("CRC2", (3,), 2, 0), - _Checksum("CRC3", (3,), 3, 0), - _Checksum("CRC4", (3,), 4, 0), + Checksum("CRC1", (1,), 1, 0), + Checksum("CRC2", (3,), 2, 0), + Checksum("CRC3", (3,), 3, 0), + Checksum("CRC4", (3,), 4, 0), ) diff --git a/src/codex32/cli.py b/src/codex32/cli.py index 68ca4f1..38b030d 100644 --- a/src/codex32/cli.py +++ b/src/codex32/cli.py @@ -20,7 +20,6 @@ from codex32._cli_input import ( CorrectionDeclined, InteractiveConfirmationRequired, - _ascii_lower, _card_text, _case_interpretation, _confirm_correction, @@ -35,6 +34,7 @@ from codex32._cli_input import read_artifacts as _artifacts from codex32._cli_input import read_text as _text from codex32._cli_parser import parser as _parser +from codex32.bech32 import _ascii_lower from codex32.bip93 import ( IDX_SORT, Header, diff --git a/src/codex32/correction.py b/src/codex32/correction.py index 63fe348..9a902e7 100644 --- a/src/codex32/correction.py +++ b/src/codex32/correction.py @@ -30,21 +30,21 @@ from codex32.bech32 import ( CHARSET, - _chars_to_u5, - _u5_to_chars, _validate_single_case_ascii, bech32_hrp_expand, + chars_to_u5, interpret_mixed_case, + u5_to_chars, ) from codex32.bip93 import ( IDX_SORT, Header, Secret, Share, - _checksum_for_encoded_length, + checksum_for_encoded_length, parse_codex32, ) -from codex32.checksums import _CODEX32, _CODEX32_LONG, _Checksum +from codex32.checksums import CODEX32, CODEX32_LONG, Checksum from codex32.errors import CodexError, InvalidCorrectionInput from codex32.gf32 import _inverse as _gf32_inverse from codex32.gf32 import _multiply as _gf32_multiply @@ -256,10 +256,10 @@ class _Spec: _ALIGNMENT_CACHE_SIZE = 11 -def _spec_for_checksum(checksum: _Checksum) -> _Spec: - if checksum is _CODEX32: +def _spec_for_checksum(checksum: Checksum) -> _Spec: + if checksum is CODEX32: return _SHORT_SPEC - if checksum is _CODEX32_LONG: + if checksum is CODEX32_LONG: return _LONG_SPEC raise AssertionError("registered profile selected a non-codex32 checksum") @@ -545,7 +545,7 @@ def __init__( ) -> None: normalized_hrp = hrp.value if isinstance(hrp, Profile) else hrp.lower() profile_rules = _optional_profile_rules(normalized_hrp) - checksum = _checksum_for_encoded_length(normalized_hrp, body_length) + checksum = checksum_for_encoded_length(normalized_hrp, body_length) if profile_rules is not None: profile_rules.validate_payload_length(body_length - checksum.length - 6) self.hrp = normalized_hrp @@ -599,7 +599,7 @@ def correct( return None for index, addend in result: corrected_reversed[index] ^= addend - corrected = self.prefix + _u5_to_chars(list(reversed(corrected_reversed))) + corrected = self.prefix + u5_to_chars(list(reversed(corrected_reversed))) corrected = corrected.upper() if self.uppercase else corrected try: artifact = parse_codex32(corrected) @@ -777,7 +777,7 @@ def _validate_context(context: CorrectionContext) -> None: if length < 21: raise ValueError("expected_length must permit the generic codex32 minimum length") body_length = length - len(context.hrp) - 1 - checksum = _checksum_for_encoded_length(context.hrp, body_length) + checksum = checksum_for_encoded_length(context.hrp, body_length) if body_length < checksum.length + 6: raise ValueError("expected_length must contain a header and checksum") rules = _optional_profile_rules(context.hrp) @@ -1007,7 +1007,7 @@ def correct_worksheet_residue( raise InvalidCorrectionInput("codex32 input exceeds 15 characters") try: _validate_single_case_ascii(residue) - values = list(reversed(_chars_to_u5(residue))) + values = list(reversed(chars_to_u5(residue))) except TypeError: raise except CodexError as error: diff --git a/src/codex32/generation.py b/src/codex32/generation.py index 6da0203..a99d0ac 100644 --- a/src/codex32/generation.py +++ b/src/codex32/generation.py @@ -9,7 +9,7 @@ from typing import NoReturn, SupportsIndex, cast from codex32._bip32 import _valid_root -from codex32.bech32 import CHARSET, _u5_to_chars, convertbits +from codex32.bech32 import CHARSET, convertbits, u5_to_chars from codex32.bip93 import ( IDX_SORT, Header, @@ -117,7 +117,7 @@ def _selection(threshold: int, share_count: object, indices: Sequence[str] | str def _random_identifier() -> str: - return _u5_to_chars(tuple(value & 31 for value in secrets.token_bytes(4))) + return u5_to_chars(tuple(value & 31 for value in secrets.token_bytes(4))) def _fingerprint_identifier(fingerprint: bytes) -> str: @@ -125,7 +125,7 @@ def _fingerprint_identifier(fingerprint: bytes) -> str: raise TypeError("fingerprint must be bytes") if len(fingerprint) != 4: raise ValueError("fingerprint must contain four bytes") - return _u5_to_chars(tuple(convertbits(fingerprint, 8, 5, pad=True)[:4])) + return u5_to_chars(tuple(convertbits(fingerprint, 8, 5, pad=True)[:4])) def _random_share(profile: Profile, threshold: int, identifier: str, index: str, length: int) -> Share: diff --git a/src/codex32/indel.py b/src/codex32/indel.py index 98a6369..72d3459 100644 --- a/src/codex32/indel.py +++ b/src/codex32/indel.py @@ -8,7 +8,7 @@ from codex32._alignment import _IncrementalSyndromes, _View from codex32.bech32 import CHARSET, _validate_single_case_ascii -from codex32.bip93 import _checksum_for_encoded_length +from codex32.bip93 import checksum_for_encoded_length from codex32.correction import ( CorrectionCandidate, CorrectionContext, @@ -277,7 +277,7 @@ def _prepare( for shape in shapes } base = len(context.hrp) + 1 - degree = _checksum_for_encoded_length(context.hrp, target - base).length + degree = checksum_for_encoded_length(context.hrp, target - base).length return _Target(context, text, observed, immutable, target, base, degree, counts) diff --git a/src/codex32/wallet.py b/src/codex32/wallet.py index 91cdb1f..7eec10e 100644 --- a/src/codex32/wallet.py +++ b/src/codex32/wallet.py @@ -3,7 +3,7 @@ from typing import Literal from codex32._bip32 import _master_xprv_from_seed -from codex32.bech32 import _u5_to_chars +from codex32.bech32 import u5_to_chars from codex32.checksums import DESCSUM from codex32.profiles.ms32 import MasterSeed @@ -48,7 +48,7 @@ def _descriptor_symbols(text: str) -> list[int]: def _with_checksum(descriptor: str) -> str: - return descriptor + "#" + _u5_to_chars(DESCSUM.create(_descriptor_symbols(descriptor))) + return descriptor + "#" + u5_to_chars(DESCSUM.create(_descriptor_symbols(descriptor))) def _descriptor_records( diff --git a/tests/test_bech32.py b/tests/test_bech32.py index 6ac28b4..7087348 100644 --- a/tests/test_bech32.py +++ b/tests/test_bech32.py @@ -4,14 +4,14 @@ from codex32.bech32 import ( CHARSET, - _chars_to_u5, - _u5_to_chars, bech32_decode, bech32_encode, bech32_hrp_expand, + chars_to_u5, convertbits, + u5_to_chars, ) -from codex32.checksums import _CODEX32 +from codex32.checksums import CODEX32 from codex32.errors import ( InvalidCase, InvalidCharacter, @@ -24,33 +24,38 @@ def test_u5_character_round_trip() -> None: values = list(range(32)) - assert _u5_to_chars(values) == CHARSET - assert _chars_to_u5(CHARSET.upper()) == values + assert u5_to_chars(values) == CHARSET + assert chars_to_u5(CHARSET.upper()) == values + + +def test_u5_character_conversion_does_not_fold_unicode_lookalikes() -> None: + with pytest.raises(InvalidCharacter, match="K"): + chars_to_u5("K") @pytest.mark.parametrize("value", (-1, 32)) def test_u5_rejects_out_of_range_values(value: int) -> None: with pytest.raises(InvalidCharacter): - _u5_to_chars([value]) + u5_to_chars([value]) def test_lexical_parser_preserves_no_semantics() -> None: - assert bech32_decode("MS10TESTS") == ("ms", _chars_to_u5("0tests")) + assert bech32_decode("MS10TESTS") == ("ms", chars_to_u5("0tests")) def test_decoder_optionally_verifies_and_removes_checksum() -> None: - data = _chars_to_u5("0tests") - encoded = bech32_encode("ms", data, _CODEX32) + data = chars_to_u5("0tests") + encoded = bech32_encode("ms", data, CODEX32) - assert bech32_decode(encoded) == ("ms", data + _CODEX32.create(bech32_hrp_expand("ms") + data)) - assert bech32_decode(encoded, _CODEX32) == ("ms", data) + assert bech32_decode(encoded) == ("ms", data + CODEX32.create(bech32_hrp_expand("ms") + data)) + assert bech32_decode(encoded, CODEX32) == ("ms", data) damaged = encoded[:-1] + ("q" if encoded[-1] != "q" else "p") with pytest.raises(InvalidChecksum, match="invalid codex32 checksum"): - bech32_decode(damaged, _CODEX32) + bech32_decode(damaged, CODEX32) with pytest.raises(InvalidChecksum, match="invalid codex32 checksum"): - bech32_decode("ms1q", _CODEX32) + bech32_decode("ms1q", CODEX32) def test_invalid_data_character_uses_complete_one_based_position() -> None: diff --git a/tests/test_cli.py b/tests/test_cli.py index 7ae0dc4..6377ab1 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -33,8 +33,8 @@ recover_secret, ) from codex32._bitcoin_core import BitcoinCore -from codex32.bech32 import _chars_to_u5, bech32_encode -from codex32.checksums import _CODEX32, _CODEX32_LONG +from codex32.bech32 import bech32_encode, chars_to_u5 +from codex32.checksums import CODEX32, CODEX32_LONG from codex32.cli import main, ms_main from codex32.generation import _fingerprint_identifier from codex32.profiles.ms32 import SEED_BYTE_LENGTHS @@ -336,9 +336,9 @@ def forbidden(_seed: bytes) -> bytes: ), ) def test_check_reports_profile_lengths_for_people(hrp: str, payload_length: int, message: str) -> None: - body = _chars_to_u5("0tests" + "q" * payload_length) + body = chars_to_u5("0tests" + "q" * payload_length) expanded_body_length = 2 * len(hrp) + 1 + len(body) - checksum = _CODEX32 if expanded_body_length <= 80 else _CODEX32_LONG + checksum = CODEX32 if expanded_body_length <= 80 else CODEX32_LONG result = _invoke(["check"], bech32_encode(hrp, body, checksum)) assert result.exit_code == 2 diff --git a/tests/test_correction_bch.py b/tests/test_correction_bch.py index cd09961..61a76eb 100644 --- a/tests/test_correction_bch.py +++ b/tests/test_correction_bch.py @@ -14,7 +14,7 @@ import codex32 from codex32 import CorrectionCandidate, CorrectionContext, CorrectionEdit, Profile, correct, indel from codex32.bech32 import CHARSET -from codex32.checksums import _CODEX32, _CODEX32_LONG +from codex32.checksums import CODEX32, CODEX32_LONG from codex32.correction import ( _LONG_SPEC, _SHORT_SPEC, @@ -59,8 +59,8 @@ def _pack(values: tuple[int, ...]) -> int: def test_p70_target_constants_match_checksum_layer() -> None: - assert _pack(_SHORT_SPEC.target) == _CODEX32.constant - assert _pack(_LONG_SPEC.target) == _CODEX32_LONG.constant + assert _pack(_SHORT_SPEC.target) == CODEX32.constant + assert _pack(_LONG_SPEC.target) == CODEX32_LONG.constant def test_frozen_bch_constants_are_reproducible() -> None: diff --git a/tests/test_generation.py b/tests/test_generation.py index f54dc42..2ce8ab3 100644 --- a/tests/test_generation.py +++ b/tests/test_generation.py @@ -22,7 +22,7 @@ parse_codex32, recover_secret, ) -from codex32.bech32 import _u5_to_chars +from codex32.bech32 import u5_to_chars from codex32.errors import ( CeremonyStateError, CodexError, @@ -392,7 +392,7 @@ def test_resharing_preserves_nonzero_core_lightning_padding() -> None: source = parse_codex32(VECTOR_6["codex32_peev"]) assert isinstance(source, CoreLightningSecret) payload = (*source.payload_symbols[:-1], source.payload_symbols[-1] | 15) - nonzero = parse_codex32(oracle_encode("cl", "0peevs" + _u5_to_chars(payload))) + nonzero = parse_codex32(oracle_encode("cl", "0peevs" + u5_to_chars(payload))) assert isinstance(nonzero, CoreLightningSecret) secret, shares = _complete( CreationCeremony.from_secret(nonzero, threshold=2, indices="ac", identifier="name") diff --git a/tests/test_profiles.py b/tests/test_profiles.py index 3fbaca9..e59474a 100644 --- a/tests/test_profiles.py +++ b/tests/test_profiles.py @@ -20,12 +20,13 @@ ) from codex32.bech32 import ( CHARSET, - _chars_to_u5, bech32_decode, + bech32_encode, bech32_hrp_expand, + chars_to_u5, ) -from codex32.bip93 import _checksum_for_encoded_length -from codex32.checksums import _CODEX32, _CODEX32_LONG, _Checksum +from codex32.bip93 import checksum_for_body_length, checksum_for_encoded_length +from codex32.checksums import CODEX32, CODEX32_LONG, Checksum from codex32.errors import ( InvalidCase, InvalidCharacter, @@ -45,6 +46,17 @@ def _payload(data: bytes, padding: int) -> str: return "".join(CHARSET[(combined >> (5 * (count - 1 - index))) & 31] for index in range(count)) +def test_supported_reference_vector_api_uses_public_module_names() -> None: + hrp = "zz" + body = chars_to_u5("0tests" + "q" * 26) + checksum = checksum_for_body_length(hrp, len(body)) + text = bech32_encode(hrp, body, checksum) + + artifact = parse_codex32(text) + assert artifact.hrp == hrp + assert artifact.text == text + + def test_every_supported_ms_size_and_legal_parsed_padding() -> None: for byte_length in SEED_BYTE_LENGTHS: data = bytes((index * 29 + byte_length) % 256 for index in range(byte_length)) @@ -60,10 +72,10 @@ def test_supported_factories_use_exact_bip93_lengths() -> None: artifacts = tuple(MasterSeed.from_seed(bytes(size), identifier="test") for size in SEED_BYTE_LENGTHS) assert tuple(len(artifact.text) for artifact in artifacts) == TEXT_LENGTHS assert all( - _checksum_for_encoded_length("ms", len(bech32_decode(artifact.text)[1])) is _CODEX32 + checksum_for_encoded_length("ms", len(bech32_decode(artifact.text)[1])) is CODEX32 for artifact in artifacts[:-1] ) - assert _checksum_for_encoded_length("ms", len(bech32_decode(artifacts[-1].text)[1])) is _CODEX32_LONG + assert checksum_for_encoded_length("ms", len(bech32_decode(artifacts[-1].text)[1])) is CODEX32_LONG @pytest.mark.parametrize(("seed_hex", "text"), BIP93_ADDITIONAL_MASTER_SEEDS) @@ -95,27 +107,27 @@ def test_expanded_codeword_boundaries() -> None: first_long = _oracle_encode("ms", header + "q" * 70) assert len(bech32_hrp_expand("ms")) + len(bech32_decode(max_regular)[1]) == 93 assert len(bech32_hrp_expand("ms")) + len(bech32_decode(first_long)[1]) == 96 - assert _checksum_for_encoded_length("ms", len(bech32_decode(max_regular)[1])) is _CODEX32 - assert _checksum_for_encoded_length("ms", len(bech32_decode(first_long)[1])) is _CODEX32_LONG + assert checksum_for_encoded_length("ms", len(bech32_decode(max_regular)[1])) is CODEX32 + assert checksum_for_encoded_length("ms", len(bech32_decode(first_long)[1])) is CODEX32_LONG for body_length in (74, 75): - body = _chars_to_u5(header + "q" * (body_length - len(header))) - checksum = _CODEX32_LONG.create(bech32_hrp_expand("ms") + body) + body = chars_to_u5(header + "q" * (body_length - len(header))) + checksum = CODEX32_LONG.create(bech32_hrp_expand("ms") + body) invalid = "ms1" + "".join(CHARSET[value] for value in body + checksum) with pytest.raises(InvalidLength): - _checksum_for_encoded_length("ms", len(bech32_decode(invalid)[1])) + checksum_for_encoded_length("ms", len(bech32_decode(invalid)[1])) def test_expanded_codeword_upper_bound() -> None: - max_body = _chars_to_u5("0tests" + "q" * 997) + max_body = chars_to_u5("0tests" + "q" * 997) max_text = _oracle_encode("ms", "".join(CHARSET[value] for value in max_body)) assert len(bech32_hrp_expand("ms")) + len(bech32_decode(max_text)[1]) == 1023 - assert _checksum_for_encoded_length("ms", len(bech32_decode(max_text)[1])) is _CODEX32_LONG + assert checksum_for_encoded_length("ms", len(bech32_decode(max_text)[1])) is CODEX32_LONG - oversized_body = _chars_to_u5("0tests" + "q" * 998) + oversized_body = chars_to_u5("0tests" + "q" * 998) oversized = _oracle_encode("ms", "".join(CHARSET[value] for value in oversized_body)) with pytest.raises(InvalidLength): - _checksum_for_encoded_length("ms", len(bech32_decode(oversized)[1])) + checksum_for_encoded_length("ms", len(bech32_decode(oversized)[1])) def test_checksum_is_verified_before_unknown_hrp_dispatch() -> None: @@ -172,12 +184,12 @@ def test_core_lightning_constructor_and_parsed_padding() -> None: @pytest.mark.parametrize( ("value", "checksum", "minimum", "maximum"), [ - *((value, _CODEX32, 0, 80) for value in VALID_CODEX32), - *((value, _CODEX32_LONG, 81, 1008) for value in VALID_CODEX32_LONG), + *((value, CODEX32, 0, 80) for value in VALID_CODEX32), + *((value, CODEX32_LONG, 81, 1008) for value in VALID_CODEX32_LONG), ], ) def test_official_generic_checksum_vectors_at_codec_level( - value: str, checksum: _Checksum, minimum: int, maximum: int + value: str, checksum: Checksum, minimum: int, maximum: int ) -> None: hrp, encoded = bech32_decode(value) expanded_length = len(bech32_hrp_expand(hrp)) + len(encoded) @@ -190,12 +202,12 @@ def test_official_generic_checksum_vectors_at_codec_level( @pytest.mark.parametrize( ("value", "checksum", "minimum", "maximum"), [ - *((value, _CODEX32, 0, 80) for value in INVALID_CODEX32), - *((value, _CODEX32_LONG, 81, 1008) for value in INVALID_CODEX32_LONG), + *((value, CODEX32, 0, 80) for value in INVALID_CODEX32), + *((value, CODEX32_LONG, 81, 1008) for value in INVALID_CODEX32_LONG), ], ) def test_official_invalid_generic_checksum_vectors( - value: str, checksum: _Checksum, minimum: int, maximum: int + value: str, checksum: Checksum, minimum: int, maximum: int ) -> None: try: hrp, encoded = bech32_decode(value) diff --git a/tests/test_sharing.py b/tests/test_sharing.py index 567342b..73ec737 100644 --- a/tests/test_sharing.py +++ b/tests/test_sharing.py @@ -17,9 +17,9 @@ parse_codex32, recover_secret, ) -from codex32.bech32 import CHARSET, _u5_to_chars +from codex32.bech32 import CHARSET, u5_to_chars from codex32.bip93 import IDX_SORT -from codex32.checksums import _Checksum +from codex32.checksums import Checksum from codex32.errors import ( DuplicateShareIndex, ExistingTargetIndex, @@ -37,7 +37,7 @@ def _payload_text(artifact: Share | MasterSeed | CoreLightningSecret) -> str: - return _u5_to_chars(artifact.payload_symbols) + return u5_to_chars(artifact.payload_symbols) def _ms_basis(byte_length: int = 16, threshold: int = 2): @@ -89,7 +89,7 @@ def test_interpolation_does_not_create_a_checksum( def fail_create(*_args: object, **_kwargs: object) -> tuple[int, ...]: raise AssertionError("sharing must interpolate the existing checksum") - monkeypatch.setattr(_Checksum, "create", fail_create) + monkeypatch.setattr(Checksum, "create", fail_create) recovered = recover_secret([a, c]) # type: ignore[list-item] derived = derive_share([a, c], "d") # type: ignore[list-item] assert parse_codex32(recovered.text) == recovered diff --git a/tools/alignment_benchmark.py b/tools/alignment_benchmark.py index 4004c9a..002569e 100644 --- a/tools/alignment_benchmark.py +++ b/tools/alignment_benchmark.py @@ -17,7 +17,7 @@ from codex32._alignment import _IncrementalSyndromes from codex32.bech32 import CHARSET, bech32_hrp_expand -from codex32.bip93 import _checksum_for_encoded_length +from codex32.bip93 import checksum_for_encoded_length from codex32.correction import ( _LONG_SPEC, _SHORT_SPEC, @@ -98,7 +98,7 @@ def public_case( "observed_body_length": len(text) - len(hrp + "1"), "target_body_lengths": {str(c.expected_length): c.expected_length - len(hrp + "1") for c in contexts}, "expanded_length": len(bech32_hrp_expand(hrp)) + body, - "checksum_symbols": _checksum_for_encoded_length(hrp, body).length, + "checksum_symbols": checksum_for_encoded_length(hrp, body).length, "unknown": unknown, "delta": delta, "explicit_erasures": erasures, diff --git a/tools/verify_correction_constants.py b/tools/verify_correction_constants.py index c1665ea..44bd679 100644 --- a/tools/verify_correction_constants.py +++ b/tools/verify_correction_constants.py @@ -2,7 +2,7 @@ from functools import reduce -from codex32.bech32 import CHARSET, _chars_to_u5 +from codex32.bech32 import CHARSET, chars_to_u5 from codex32.correction import ( _LONG_SPEC, _SHORT_SPEC, @@ -42,7 +42,7 @@ def _derive(base: int, first_root: int, target: str) -> _Spec: return _Spec( base, first_root, - tuple(_chars_to_u5(target)), + tuple(chars_to_u5(target)), roots, reduce(_monic_mul, map(_minimal_poly, roots)), _order(base),