Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 51 additions & 2 deletions docs/developer/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
4 changes: 2 additions & 2 deletions src/codex32/_bitcoin_core.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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

Expand Down
11 changes: 3 additions & 8 deletions src/codex32/_cli_input.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
)
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down
26 changes: 17 additions & 9 deletions src/codex32/bech32.py
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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}"
Expand Down Expand Up @@ -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")
Expand All @@ -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):
Expand Down
43 changes: 24 additions & 19 deletions src/codex32/bip93.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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}."
Expand All @@ -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):
Expand Down Expand Up @@ -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)

Expand Down Expand Up @@ -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)


Expand Down
20 changes: 11 additions & 9 deletions src/codex32/checksums.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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),
)


Expand Down
2 changes: 1 addition & 1 deletion src/codex32/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,6 @@
from codex32._cli_input import (
CorrectionDeclined,
InteractiveConfirmationRequired,
_ascii_lower,
_card_text,
_case_interpretation,
_confirm_correction,
Expand All @@ -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,
Expand Down
Loading
Loading