diff --git a/docs/developer/api.md b/docs/developer/api.md index c13420c..2589128 100644 --- a/docs/developer/api.md +++ b/docs/developer/api.md @@ -18,8 +18,8 @@ The format layer first validates ASCII, case, separator, characters, and the absolute size bound. The application parser selects the checksum type from the generic encoded length and validates the common header before verifying the outer checksum. Only afterward does an `ms` HRP add the BIP93 master-seed -length rule and the `MasterSeed` type for S. Every other HRP crosses the -boundary as a base `Secret` or `Share`, with normalized `hrp` and no invented +length rule and the `MasterSeed` type for S. Every other string, including +`ms` shares, crosses the boundary as a `Codex32String`, with normalized `hrp` and no invented payload meaning. Applications with their own HRP add their rules on top of these generic artifacts. @@ -51,7 +51,8 @@ generic parse-length failure. ### Boundaries -- An artifact stores only its validated string; its header, HRP and payload are decoded from it. +- A `Codex32String` stores only its validated string `s`; `hrp`, `k`, `ident`, + `share_idx`, `payload` and `checksum` are slices of it. S has index `s`. - `bech32.py` keeps the recognizable BIP173 reference names and accumulator structure. Encoding takes an explicit checksum specification. Decoding without one returns the HRP and complete data part after checking only the @@ -60,10 +61,10 @@ generic parse-length failure. codex32 headers, or decode application payloads. - The library knows only BIP93's `ms` application. Other HRPs stay generic; there is no profile registry. -- Headers and artifacts are immutable; shares expose symbols, not bytes. +- Artifacts are immutable; only `MasterSeed` exposes bytes (`data`). - Artifact `str()` and `repr()` render redacted placeholders. Code that - intentionally serializes a validated `Share` or `Secret` must use its explicit - `.text` attribute. + intentionally serializes a validated `Codex32String` must use its explicit + `.s` attribute. - Sharing interpolates payload and checksum together, explicitly constructs the target header, and reparses the result. - `generation.py` is the only entropy owner; it generates and shares only `ms`. @@ -118,7 +119,7 @@ its own lengths or payload checks applies them to those artifacts itself. |---|---:|---:| | parse S/share, recover, derive shares | yes | yes | | fixed BCH and bounded structural correction | yes | yes | -| semantic S bytes (`MasterSeed.seed_bytes`) | no | 16, 20, 24, 28, 32, or 64 | +| semantic S bytes (`MasterSeed.data`) | no | 16, 20, 24, 28, 32, or 64 | | generation, ceremonies, existing-S splitting | no | six supported sizes | | wallet API and `ms32` | no | yes | @@ -251,12 +252,12 @@ shape; registered semantics are reapplied when every output is reparsed. ### Public operations ```python -recover_secret(shares: Sequence[Share]) -> Secret +recover_secret(shares: Sequence[Codex32String]) -> Codex32String derive_share( - basis: Sequence[Share | Secret], + basis: Sequence[Codex32String], fresh_index: str, -) -> Share +) -> Codex32String ``` Recovery accepts exactly `k` ordinary shares. Derivation accepts exactly `k` diff --git a/docs/security/invariants.md b/docs/security/invariants.md index 6b559e7..000fd74 100644 --- a/docs/security/invariants.md +++ b/docs/security/invariants.md @@ -3,7 +3,7 @@ These rules are mandatory. The [security model](model.md) defines their limits and evidence. -1. Only the parser and `MasterSeed.from_seed` validate text. Generic container +1. Constructing a `Codex32String` or `MasterSeed` validates its text. Generic container and header checks precede the checksum; the `ms` length rule follows it. Other HRPs remain generic and never gain invented application semantics. 2. APIs accept only immutable validated artifacts. Outputs are reparsed; diff --git a/docs/security/model.md b/docs/security/model.md index 585dba2..7e22a7b 100644 --- a/docs/security/model.md +++ b/docs/security/model.md @@ -21,7 +21,7 @@ fingerprints, and wallet history cannot spend funds but remain privacy-sensitive | Boundary | Security requirement | |---|---| -| Untrusted text | Text remains untrusted until the parser or `MasterSeed.from_seed` returns a validated artifact. | +| Untrusted text | Text remains untrusted until a `Codex32String` or `MasterSeed` constructor accepts it. | | Package API | Recovery, derivation, sharing, and wallet operations accept validated artifacts, not unchecked text. | | Python process | Secret objects remain in process memory; normal output must not reveal them unintentionally. | | Operator and paper | The operator controls transcription, physical recovery cards, wallet records, and acceptance of correction suggestions. | @@ -91,9 +91,9 @@ meaning. Artifacts are immutable and store only their validated string; the header, HRP and payload are decoded from it, so they cannot disagree. A share has symbol semantics and cannot be converted to bytes. Default `str()` and `repr()` rendering of artifacts is redacted; callers must -use `.text` explicitly when they intentionally need the serialized recovery +use `.s` explicitly when they intentionally need the serialized recovery string. This reduces accidental disclosure through logs and interpolation but -does not make `.text` safe to expose. +does not make `.s` safe to expose. Generic parsing, recovery, derivation, and correction work for any HRP. Wallet and generation APIs accept only `MasterSeed`, never other HRPs' artifacts or raw strings. diff --git a/src/codex32/__init__.py b/src/codex32/__init__.py index 3613d98..e6c397f 100644 --- a/src/codex32/__init__.py +++ b/src/codex32/__init__.py @@ -1,10 +1,8 @@ """Small, typed BIP93 and codex32 reference API.""" from .bip93 import ( - Header, + Codex32String, MasterSeed, - Secret, - Share, derive_share, parse_codex32, recover_secret, @@ -26,17 +24,15 @@ from .wallet import master_xprv __all__ = [ + "Codex32String", "CodexError", "ConfirmationResult", "CorrectionCandidate", "CorrectionContext", "CorrectionEdit", "CreationCeremony", - "Header", "InvalidCorrectionInput", "MasterSeed", - "Secret", - "Share", "WorksheetCorrection", "correct", "correct_worksheet_residue", diff --git a/src/codex32/_bitcoin_core.py b/src/codex32/_bitcoin_core.py index 5b3485b..db53740 100644 --- a/src/codex32/_bitcoin_core.py +++ b/src/codex32/_bitcoin_core.py @@ -171,7 +171,7 @@ def fingerprint(self, secret: MasterSeed) -> bytes: """Return the BIP32 master fingerprint for a validated master seed.""" if not isinstance(secret, MasterSeed): raise TypeError("wallet operations accept only MasterSeed") - return self.fingerprint_seed(secret.seed_bytes) + return self.fingerprint_seed(secret.data) def verify_identity(self, secret: MasterSeed, expected_fingerprint: bytes | None) -> None: """Refuse a recovered seed that is not the recorded wallet before any wallet is touched.""" @@ -200,7 +200,7 @@ def _target(self, name: str) -> tuple[bool, bool] | None: return (unlocked is not None, unlocked == 0) if eligible else None def _create_account_zero(self, secret: MasterSeed, wallet: str) -> None: - root = _master_xprv_from_seed(secret.seed_bytes, testnet=self.chain != "main") + root = _master_xprv_from_seed(secret.data, testnet=self.chain != "main") added = self._rpc("addhdkey", wallet=wallet, stdin=root + "\n") xpub = added.get("xpub") if isinstance(added, dict) else None if not isinstance(xpub, str) or not xpub.startswith("xpub" if self.chain == "main" else "tpub"): diff --git a/src/codex32/_cli_input.py b/src/codex32/_cli_input.py index b259656..24e2a0b 100644 --- a/src/codex32/_cli_input.py +++ b/src/codex32/_cli_input.py @@ -7,13 +7,12 @@ import os import sys from collections.abc import Callable, Iterator, Sequence -from typing import Any, Literal, cast +from typing import Any, Literal from codex32.bip93 import ( MS, + Codex32String, MasterSeed, - Secret, - Share, _text_length, _validate_ms_length, _validate_recovery_prefix, @@ -31,7 +30,6 @@ InvalidLength, ) -Artifact = Share | Secret _MAX_INPUT = 9 * 1025 _line_editor: Any @@ -176,7 +174,7 @@ def _card_text(text: str, highlight: bool = True, observed: str = "") -> str: def _confirm_correction( candidate: CorrectionCandidate, - accepted: list[Artifact], + accepted: list[Codex32String], basis: bool, fingerprint: Callable[[MasterSeed], bytes] | None = None, ) -> bool | None: @@ -186,12 +184,12 @@ def _confirm_correction( preview = artifact try: if ( - isinstance(artifact, Share) + artifact.share_idx != "s" and artifact.hrp == MS and not basis - and (len(accepted) + 1 == artifact.header.threshold) + and (len(accepted) + 1 == int(artifact.k)) ): - preview = recover_secret(cast(list[Share], [*accepted, artifact])) + preview = recover_secret([*accepted, artifact]) fingerprint_text = ( f"Master fingerprint: {fingerprint(preview).hex().upper()}\n\n" if isinstance(preview, MasterSeed) and fingerprint is not None @@ -200,7 +198,7 @@ def _confirm_correction( except CodexError: _stderr("Rejected: Could not recover a valid Bitcoin master seed using this correction.") return None - _stderr(f"Possible correction:\n\n{fingerprint_text}{_card_text(artifact.text, sys.stderr.isatty())}\n") + _stderr(f"Possible correction:\n\n{fingerprint_text}{_card_text(artifact.s, sys.stderr.isatty())}\n") return _confirmation_input( "Does this entire string exactly match your recovery card? [y/N]: " ).strip().lower() in ("y", "yes") @@ -226,7 +224,7 @@ def _render_groups( ).rstrip() -def _parse(value: str, hrp: str | None) -> Artifact: +def _parse(value: str, hrp: str | None) -> Codex32String: try: artifact = parse_codex32(value) except InvalidChecksum as error: @@ -267,10 +265,10 @@ def _prefix_for_suffix(prefix: str, suffix: str) -> str: return prefix -def _accepted_prefix(accepted: list[Artifact]) -> str: +def _accepted_prefix(accepted: list[Codex32String]) -> str: first = accepted[0] - prefix = f"{first.hrp}1{first.header.threshold}{first.header.identifier}" - return prefix.upper() if all(artifact.text.isupper() for artifact in accepted) else prefix + prefix = f"{first.hrp}1{first.k}{first.ident}" + return prefix.upper() if all(artifact.s.isupper() for artifact in accepted) else prefix def _display_prefix(prefix: str, grouped: bool) -> str: @@ -292,10 +290,10 @@ def _fingerprint_matcher( def matches(candidate: CorrectionCandidate) -> bool | None: artifact = candidate.artifact - if not isinstance(artifact, MasterSeed) or artifact.header.threshold: + if not isinstance(artifact, MasterSeed) or artifact.k != "0": return None try: - return _fingerprint_identifier(fingerprint(artifact)) == artifact.header.identifier + return _fingerprint_identifier(fingerprint(artifact)) == artifact.ident except CodexError: return None @@ -306,7 +304,7 @@ def _suggestions( value: str, prefix: str, required_hrp: str | None, - accepted: Sequence[Artifact], + accepted: Sequence[Codex32String], *, allowed: Callable[[CorrectionCandidate], bool] | None = None, fingerprint: Callable[[MasterSeed], bytes] | None = None, @@ -327,7 +325,7 @@ def _corrections( value: str, prefix: str, required_hrp: str | None, - accepted: Sequence[Artifact], + accepted: Sequence[Codex32String], *, allowed: Callable[[CorrectionCandidate], bool] | None = None, fingerprint: Callable[[MasterSeed], bytes] | None = None, @@ -338,11 +336,12 @@ def _corrections( hrp = value[: max(separator, 0)].lower() if separator <= 0 or required_hrp not in (None, hrp): return (), True - target = len(accepted[0].text) if accepted else None + target = len(accepted[0].s) if accepted else None if isinstance(byte_length, int): target = _text_length(byte_length) immutable = prefix if prefix and value.lower().startswith(prefix.lower()) else value[: separator + 1] - excluded = tuple(artifact.header.index for artifact in accepted) + # A derivation basis may hold the secret S, which is never a correction candidate's index. + excluded = tuple(artifact.share_idx for artifact in accepted if artifact.share_idx != "s") context = CorrectionContext(hrp, target, _prefix_for_suffix(immutable, value), excluded) try: candidates, complete = _correct_complete(context, value, allowed=allowed, competitors=True) @@ -355,17 +354,17 @@ def _corrections( def _validate_operational_artifact( - artifact: Artifact, - accepted: list[Artifact], + artifact: Codex32String, + accepted: list[Codex32String], *, basis: bool, one: bool, excluded_index: str | None, ) -> None: - if basis and artifact.header.index == excluded_index: + if basis and artifact.share_idx == excluded_index: raise ExistingTargetIndex("That index was requested for the additional share.") - if not one and (accepted or isinstance(artifact, Share) or basis): - recovering = not basis and isinstance(artifact, Share) + if not one and (accepted or artifact.share_idx != "s" or basis): + recovering = not basis and artifact.share_idx != "s" if recovering: _validate_recovery_prefix([*accepted, artifact]) else: @@ -379,13 +378,13 @@ def _redirected( one: bool, excluded_index: str | None, fingerprint: Callable[[MasterSeed], bytes] | None, -) -> list[Artifact]: +) -> list[Codex32String]: tokens = _stdin().split() if not tokens: raise InputError("No input was provided.") if len(tokens) > 9: raise InputError("At most nine codex32 strings may be provided at once.") - accepted: list[Artifact] = [] + accepted: list[Codex32String] = [] for token in tokens: try: artifact = _parse(token, hrp) @@ -438,12 +437,12 @@ def _interactive( hrp: str | None, initial_prefix: str, fingerprint: Callable[[MasterSeed], bytes] | None, -) -> list[Artifact]: - accepted: list[Artifact] = [] +) -> list[Codex32String]: + accepted: list[Codex32String] = [] prefix = initial_prefix prefill, required, grouped_prefix = "", 1, False - def validate(artifact: Artifact) -> None: + def validate(artifact: Codex32String) -> None: _validate_operational_artifact( artifact, accepted, @@ -511,10 +510,10 @@ def allowed(candidate: CorrectionCandidate) -> bool: prefill = "" if one: return [artifact] - if isinstance(artifact, Secret) and not basis: + if artifact.share_idx == "s" and not basis: return [artifact] if not accepted: - required = artifact.header.threshold + required = int(artifact.k) accepted.append(artifact) prefix = _accepted_prefix(accepted) grouped_prefix = len(entered.split()) > 1 @@ -531,7 +530,7 @@ def read_artifacts( hrp: str | None = None, initial_prefix: str = "", fingerprint: Callable[[MasterSeed], bytes] | None = None, -) -> list[Artifact]: +) -> list[Codex32String]: if not sys.stdin.isatty(): return _redirected( hrp, diff --git a/src/codex32/_competitors.py b/src/codex32/_competitors.py index b5895a9..5c99619 100644 --- a/src/codex32/_competitors.py +++ b/src/codex32/_competitors.py @@ -56,11 +56,10 @@ def _covered_candidates( # Fixed BCH was completed first, including a failed/ineligible result. return () if fixed is None else (fixed,) for candidate in results: - if candidate.artifact.hrp != state.context.hrp or len(candidate.artifact.text) != state.target: + if candidate.artifact.hrp != state.context.hrp or len(candidate.artifact.s) != state.target: continue known_distance = sum( - a in CHARSET and a != b - for a, b in zip(observed, candidate.artifact.text[state.immutable :].lower()) + a in CHARSET and a != b for a, b in zip(observed, candidate.artifact.s[state.immutable :].lower()) ) if explicit + known_distance + changed < 9: return (candidate,) @@ -76,7 +75,7 @@ def _known_swaps( ) -> Iterator[_View]: """Enumerate only swap scripts that could explain this known artifact.""" source = tuple(CHARSET.index(c.lower()) for c in state.text[state.base :]) - target = tuple(CHARSET.index(c.lower()) for c in candidate.artifact.text[state.base :]) + target = tuple(CHARSET.index(c.lower()) for c in candidate.artifact.s[state.base :]) boundary = state.immutable - state.base initial = _View(source, ((0, len(source)),), len(source)) seen: set[tuple[tuple[int, ...], tuple[str, ...]]] = set() @@ -138,14 +137,14 @@ def _competitor_views( distance = sum( a in CHARSET and a != b for a, b in zip( - state.text[state.immutable :].lower(), candidate.artifact.text[state.immutable :].lower() + state.text[state.immutable :].lower(), candidate.artifact.s[state.immutable :].lower() ) ) if distance > shape.unit * (2 * (shape.adjacent + shape.distant) + shape.corrupted) + substitutions: return iter(()) if not shape.corrupted: observed = Counter(c for c in state.text[state.immutable :].lower() if c in CHARSET) - target = Counter(candidate.artifact.text[state.immutable :].lower()) + target = Counter(candidate.artifact.s[state.immutable :].lower()) if sum((observed - target).values()) > substitutions: return iter(()) if shape.unit == 1 and not (shape.inserted or shape.omitted) and erasures == 0: @@ -177,7 +176,7 @@ def _search_competitors( seed_candidates: Sequence[CorrectionCandidate] = (), optional_only: bool = False, ) -> tuple[tuple[CorrectionCandidate, ...], bool]: - results = {candidate.artifact.text.lower(): candidate for candidate in seed_candidates} + results = {candidate.artifact.s.lower(): candidate for candidate in seed_candidates} fixed: dict[int, CorrectionCandidate | None] = {} completed: set[_Layer] = set() try: diff --git a/src/codex32/bip93.py b/src/codex32/bip93.py index 321f5e5..0e080a7 100644 --- a/src/codex32/bip93.py +++ b/src/codex32/bip93.py @@ -22,7 +22,6 @@ InvalidIdentifier, InvalidLength, InvalidShareIndex, - InvalidShareSet, InvalidTargetIndex, InvalidThreshold, MismatchedHrp, @@ -61,50 +60,21 @@ def _validate_ms_length(hrp: str, text_length: int) -> None: ) -@dataclass(frozen=True, slots=True) -class Header: - """Normalized immutable BIP93 header.""" - - threshold: int - identifier: str - index: str - - def __post_init__(self) -> None: - if ( - isinstance(self.threshold, bool) - or not isinstance(self.threshold, int) - or self.threshold not in (0, *range(2, 10)) - ): - raise InvalidThreshold("threshold must be 0 or an integer from 2 through 9") - if not isinstance(self.identifier, str): - raise InvalidIdentifier("identifier must be str") - identifier = self.identifier.lower() - if len(identifier) != 4 or any(character not in CHARSET for character in identifier): - raise InvalidIdentifier("identifier must be exactly four Bech32 symbols") - if not isinstance(self.index, str): - raise InvalidShareIndex("share index must be str") - index = self.index.lower() - if len(index) != 1 or index not in CHARSET: - raise InvalidShareIndex("share index must be one Bech32 symbol") - if self.threshold == 0 and index != "s": - raise InvalidShareIndex("An unshared secret (threshold 0) must use S as its index.") - object.__setattr__(self, "identifier", identifier) - object.__setattr__(self, "index", index) - - @classmethod - 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) - 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}." - ) - return cls(int(text[0]), text[1:5], text[5]) - - @property - def _symbols(self) -> tuple[int, ...]: - return tuple(_chars_to_u5(f"{self.threshold}{self.identifier}{self.index}")) +def _header(threshold: object, identifier: object, index: object) -> str: + """Return a normalized six-character BIP93 header, or raise why it is invalid.""" + if isinstance(threshold, bool) or not isinstance(threshold, int) or threshold not in (0, *range(2, 10)): + raise InvalidThreshold("threshold must be 0 or an integer from 2 through 9") + if ( + not isinstance(identifier, str) + or len(identifier) != 4 + or any(c not in CHARSET for c in identifier.lower()) + ): + raise InvalidIdentifier("identifier must be exactly four Bech32 symbols") + if not isinstance(index, str) or len(index) != 1 or index.lower() not in CHARSET: + raise InvalidShareIndex("share index must be one Bech32 symbol") + if threshold == 0 and index.lower() != "s": + raise InvalidShareIndex("An unshared secret (threshold 0) must use S as its index.") + return f"{threshold}{identifier}{index}".lower() def _checksum_for_encoded_length(hrp: str, encoded_length: int) -> _Checksum: @@ -124,7 +94,12 @@ def _decode_codex32(text: str) -> tuple[str, tuple[int, ...], _Checksum]: raise InvalidLength("codex32 string must contain at least 21 characters") checksum = _checksum_for_encoded_length(hrp, len(encoded)) body = tuple(encoded[: -checksum.length]) - Header._from_symbols(body[:6]) + if len(body) < 6: + raise InvalidLength("codex32 header must contain six symbols") + header = _u5_to_chars(body[:6]) + if header[0] not in "023456789": + raise InvalidThreshold(f"The threshold must be 0 or a number from 2 through 9; found {header[0]!r}.") + _header(int(header[0]), header[1:5], header[5]) if not bech32_verify_checksum(hrp, encoded, checksum): raise InvalidChecksum("The checksum does not match.") _validate_ms_length(hrp, len(text)) @@ -132,26 +107,46 @@ def _decode_codex32(text: str) -> tuple[str, tuple[int, ...], _Checksum]: @dataclass(frozen=True, slots=True) -class _Artifact: - """A validated codex32 string; its other attributes are decoded from it, so they cannot disagree.""" +class Codex32String: + """A validated codex32 share or secret; ``.s`` is the explicit serialized value. + + Every other attribute is read from ``s``. ``str()`` and ``repr()`` are + deliberately redacted so accidental logging or interpolation does not + disclose recovery material. + """ - text: str + s: str def __post_init__(self) -> None: - if _artifact_type(self.text) is not type(self): - raise TypeError(f"this codex32 string is not a {type(self).__name__}") + _decode_codex32(self.s) @property def hrp(self) -> str: - return _decode_codex32(self.text)[0] + return self.s.rpartition("1")[0].lower() + + @property + def _data_part(self) -> str: + return self.s.rpartition("1")[2].lower() + + @property + def k(self) -> str: + return self._data_part[0] + + @property + def ident(self) -> str: + return self._data_part[1:5] @property - def header(self) -> Header: - return Header._from_symbols(_body(self.text)[:6]) + def share_idx(self) -> str: + return self._data_part[5] @property - def payload_symbols(self) -> tuple[int, ...]: - return _body(self.text)[6:] + def payload(self) -> str: + return self._data_part[6 : -len(self.checksum)] + + @property + def checksum(self) -> str: + return self._data_part[-_checksum_for_encoded_length(self.hrp, len(self._data_part)).length :] def __str__(self) -> str: return f"<{type(self).__name__}: redacted>" @@ -160,78 +155,51 @@ def __repr__(self) -> str: return f"{type(self).__name__}()" def __len__(self) -> int: - return len(self.text) - - -class Share(_Artifact): - """Validated codex32 share; use ``.text`` for the explicit serialized value. - - ``str(share)`` and ``repr(share)`` are deliberately redacted so accidental - logging or interpolation does not disclose recovery material. - """ - - __slots__ = () - + return len(self.s) -class Secret(_Artifact): - """Validated codex32 secret; use ``.text`` for the explicit serialized value. - ``str(secret)`` and ``repr(secret)`` are deliberately redacted so accidental - logging or interpolation does not disclose recovery material. - """ +class MasterSeed(Codex32String): + """A validated BIP93 master seed: an ``ms`` secret with index S.""" __slots__ = () - -class MasterSeed(Secret): - """A validated BIP93 ``ms`` secret.""" - - __slots__ = () + def __post_init__(self) -> None: + Codex32String.__post_init__(self) + if self.hrp != MS or self.share_idx != "s": + raise TypeError("a master seed must be an ms secret with share index S") @property - def seed_bytes(self) -> bytes: - """Return the BIP32 master-seed bytes represented by S.""" - return bytes(convertbits(self.payload_symbols, 5, 8, pad=False, accept_any_padding=True)) + def data(self) -> bytes: + """Return the BIP32 master-seed bytes.""" + return bytes(convertbits(_chars_to_u5(self.payload), 5, 8, pad=False, accept_any_padding=True)) @classmethod - def from_seed(cls, seed_bytes: bytes, *, identifier: str, threshold: int = 0) -> "MasterSeed": - """Encode a supported BIP93 master seed as S with generation-only CRC padding.""" - if not isinstance(seed_bytes, bytes): - raise TypeError("seed_bytes must be bytes") - if len(seed_bytes) not in SEED_BYTE_LENGTHS: + def from_seed(cls, data: bytes, *, identifier: str, threshold: int = 0) -> "MasterSeed": + """Encode a supported BIP93 master seed with generation-only CRC padding.""" + if not isinstance(data, bytes): + raise TypeError("data must be bytes") + if len(data) not in SEED_BYTE_LENGTHS: raise InvalidLength("master seed must contain 16, 20, 24, 28, 32, or 64 bytes") - payload = tuple(convertbits(seed_bytes, 8, 5, pad=True, pad_value=_crc_pad(seed_bytes))) - artifact = _from_parts(MS, Header(threshold, identifier, "s"), payload) - assert isinstance(artifact, MasterSeed) - return artifact + payload = tuple(convertbits(data, 8, 5, pad=True, pad_value=_crc_pad(data))) + return cls(_from_parts(MS, _header(threshold, identifier, "s"), payload).s) def _has_generation_padding(secret: MasterSeed) -> bool: - padding_bits = (len(secret.payload_symbols) * 5) % 8 - padding = secret.payload_symbols[-1] & ((1 << padding_bits) - 1) if padding_bits else 0 - return padding == _crc_pad(secret.seed_bytes) - - -def _body(text: str) -> tuple[int, ...]: - _hrp, encoded, checksum = _decode_codex32(text) - return encoded[: -checksum.length] - - -def _artifact_type(text: str) -> type[Share | Secret]: - hrp, encoded, _checksum = _decode_codex32(text) - return Share if encoded[5] != CHARSET.index("s") else MasterSeed if hrp == MS else Secret + payload = _chars_to_u5(secret.payload) + padding_bits = (len(payload) * 5) % 8 + padding = payload[-1] & ((1 << padding_bits) - 1) if padding_bits else 0 + return padding == _crc_pad(secret.data) -def parse_codex32(text: str) -> Share | Secret: - """Validate one codex32 string and return an immutable artifact.""" - return _artifact_type(text)(text) +def parse_codex32(text: str) -> Codex32String: + """Validate one codex32 string; an ms secret is returned as a MasterSeed.""" + secret = Codex32String(text) + return MasterSeed(text) if secret.hrp == MS and secret.share_idx == "s" else secret -def _from_parts( - hrp: str, header: Header, payload: tuple[int, ...], *, uppercase: bool = False -) -> Share | Secret: +def _from_parts(hrp: str, header: str, payload: Sequence[int], *, uppercase: bool = False) -> Codex32String: normalized_hrp = hrp.lower() - body = [*header._symbols, *payload] + body = [*_chars_to_u5(header), *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. @@ -255,7 +223,7 @@ def _lagrange_weights(points: tuple[int, ...], target: int) -> tuple[int, ...]: @dataclass(frozen=True, slots=True) class _ShareSet: - artifacts: tuple[Share | Secret, ...] + artifacts: tuple[Codex32String, ...] hrp: str threshold: int identifier: str @@ -264,58 +232,58 @@ class _ShareSet: @property def indices(self) -> tuple[str, ...]: - return tuple(item.header.index for item in self.artifacts) + return tuple(item.share_idx for item in self.artifacts) -def _artifact_tail(artifact: Share | Secret) -> tuple[int, ...]: +def _artifact_tail(artifact: Codex32String) -> tuple[int, ...]: """Return the payload and checksum symbols of already-validated text.""" - return tuple(_chars_to_u5(artifact.text[len(artifact.hrp) + 7 :].lower())) + return tuple(_chars_to_u5(artifact.payload + artifact.checksum)) -def _validate_share_set(artifacts: Sequence[Share | Secret], *, require_exact: bool = True) -> _ShareSet: +def _validate_share_set(artifacts: Sequence[Codex32String], *, require_exact: bool = True) -> _ShareSet: copied = tuple(islice(artifacts, 10)) # enough to reject an oversized set - if not all(isinstance(item, _Artifact) for item in copied): + if not all(isinstance(item, Codex32String) for item in copied): raise TypeError("all share inputs must be validated codex32 artifacts") if not 1 <= len(copied) <= 9: raise WrongShareCount("a share set must contain between 1 and 9 artifacts") first = copied[0] - threshold = first.header.threshold + threshold = int(first.k) if threshold not in range(2, 10): raise MismatchedThreshold("Only strings with a threshold of 2 through 9 can be combined.") if len(copied) > threshold or (require_exact and len(copied) != threshold): raise WrongShareCount(f"threshold is {threshold}, but {len(copied)} artifacts were supplied") tails = [_artifact_tail(first)] - indices = [first.header.index] + indices = [first.share_idx] for item in copied[1:]: if item.hrp != first.hrp: raise MismatchedHrp("These strings are for different applications.") - if item.header.threshold != threshold: + if int(item.k) != threshold: raise MismatchedThreshold("These strings require different numbers of shares.") - if item.header.identifier != first.header.identifier: + if item.ident != first.ident: raise MismatchedIdentifier("These strings have different identifiers.") - if len(item.text) != len(first.text): + if len(item.s) != len(first.s): raise MismatchedPayloadLength("These strings have different lengths.") tails.append(_artifact_tail(item)) - indices.append(item.header.index) + indices.append(item.share_idx) if len(set(indices)) != len(indices): raise DuplicateShareIndex("That share index was already entered.") return _ShareSet( copied, first.hrp, threshold, - first.header.identifier, + first.ident, tuple(tails), - all(item.text.isupper() for item in copied), + all(item.s.isupper() for item in copied), ) -def _validate_recovery_prefix(shares: Sequence[Share | Secret]) -> None: +def _validate_recovery_prefix(shares: Sequence[Codex32String]) -> None: share_set = _validate_share_set(shares, require_exact=False) - if any(isinstance(item, Secret) for item in share_set.artifacts): + if "s" in share_set.indices: raise SecretInRecoverySet("Enter ordinary shares rather than the shared secret.") -def _interpolate_tail(share_set: _ShareSet, target: str) -> Share | Secret: +def _interpolate_tail(share_set: _ShareSet, target: str) -> Codex32String: points = tuple(CHARSET.index(index) for index in share_set.indices) weights = _lagrange_weights(points, CHARSET.index(target)) result: list[int] = [] @@ -324,20 +292,16 @@ def _interpolate_tail(share_set: _ShareSet, target: str) -> Share | Secret: for row, weight in zip(share_set.tails, weights, strict=True): 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{share_set.threshold}{share_set.identifier}{target}{_u5_to_chars(result)}" return parse_codex32(text.upper() if share_set.uppercase else text) -def recover_secret(shares: Sequence[Share]) -> Secret: +def recover_secret(shares: Sequence[Codex32String]) -> Codex32String: """Recover S from exactly k compatible, distinct ordinary shares.""" share_set = _validate_share_set(shares) - if any(isinstance(item, Secret) for item in share_set.artifacts): + if "s" in share_set.indices: raise SecretInRecoverySet("Enter ordinary shares rather than the shared secret.") - recovered = _interpolate_tail(share_set, "s") - if not isinstance(recovered, Secret): - raise InvalidShareSet("interpolation did not produce a secret") - return recovered + return _interpolate_tail(share_set, "s") def _normalize_target(value: object, *, label: str) -> str: @@ -349,13 +313,10 @@ def _normalize_target(value: object, *, label: str) -> str: return normalized -def derive_share(basis: Sequence[Share | Secret], fresh_index: str) -> Share: +def derive_share(basis: Sequence[Codex32String], fresh_index: str) -> Codex32String: """Interpolate one additional ordinary share at a previously unused index.""" share_set = _validate_share_set(basis) target = _normalize_target(fresh_index, label="fresh_index") if target in share_set.indices: raise ExistingTargetIndex(f"Choose a different share index; {target.upper()} was already supplied.") - derived = _interpolate_tail(share_set, target) - if not isinstance(derived, Share): - raise InvalidShareSet("interpolation did not produce an ordinary share") - return derived + return _interpolate_tail(share_set, target) diff --git a/src/codex32/cli.py b/src/codex32/cli.py index b06d01f..f3a1fba 100644 --- a/src/codex32/cli.py +++ b/src/codex32/cli.py @@ -34,10 +34,9 @@ from codex32.bip93 import ( IDX_SORT, MS, - Header, + Codex32String, MasterSeed, - Secret, - Share, + _header, _normalize_target, _text_length, derive_share, @@ -59,8 +58,6 @@ ) from codex32.wallet import master_xprv -Artifact = Share | Secret - class _CliContext(NamedTuple): prog: str @@ -92,13 +89,13 @@ def _print(text: str, *, err: bool = False, danger: bool = False) -> None: print(text, file=sys.stderr if err else sys.stdout) -def _secret(artifacts: list[Artifact]) -> Secret: - if len(artifacts) == 1 and isinstance(artifacts[0], Secret): +def _secret(artifacts: list[Codex32String]) -> Codex32String: + if len(artifacts) == 1 and artifacts[0].share_idx == "s": return artifacts[0] - if not all(isinstance(artifact, Share) for artifact in artifacts): + if any(artifact.share_idx == "s" for artifact in artifacts): raise _UsageError("Recovery accepts ordinary shares or one complete secret.") try: - return recover_secret(cast(list[Share], artifacts)) + return recover_secret(artifacts) except CodexError as error: raise _UsageError(str(error)) from error @@ -112,49 +109,49 @@ def _master_seed(fingerprint: Callable[[MasterSeed], bytes] | None = None) -> Ma raise _UsageError("Wallet commands accept only Bitcoin master-seed secrets.") -def _summary(artifact: Artifact, *, valid: bool = False) -> list[str]: - header = artifact.header +def _summary(artifact: Codex32String, *, valid: bool = False) -> list[str]: + k, secret = int(artifact.k), artifact.share_idx == "s" if artifact.hrp != MS: heading = ( - f"codex32 share {header.index.upper()}" - if isinstance(artifact, Share) - else f"{'Unshared' if header.threshold == 0 else 'Shared'} codex32 secret" + f"codex32 share {artifact.share_idx.upper()}" + if not secret + else f"{'Unshared' if k == 0 else 'Shared'} codex32 secret" ) - if valid and isinstance(artifact, Secret): + if valid and secret: heading = heading[0].lower() + heading[1:] lines = [f"{'Valid ' if valid else ''}{heading}.", f"HRP: {artifact.hrp.upper()}"] else: name = "Bitcoin master seed" - if isinstance(artifact, Share): + if not secret: name = name.replace("master seed", "master-seed") - heading = f"{name} share {header.index.upper()}" + heading = f"{name} share {artifact.share_idx.upper()}" else: - heading = f"{'Unshared' if header.threshold == 0 else 'Shared'} {name}" - if valid and isinstance(artifact, Secret): + heading = f"{'Unshared' if k == 0 else 'Shared'} {name}" + if valid and secret: heading = heading[0].lower() + heading[1:] lines = [f"{'Valid ' if valid else ''}{heading}."] - lines.append(f"Backup identifier: {header.identifier.upper()}") - if header.threshold: - lines.append(f"Shares needed for recovery: {header.threshold}") + lines.append(f"Backup identifier: {artifact.ident.upper()}") + if k: + lines.append(f"Shares needed for recovery: {k}") return lines def _render( - artifact: Artifact, + artifact: Codex32String, pretty: bool, observed: str = "", fingerprint: Callable[[MasterSeed], bytes] | None = None, ) -> str: if not pretty: - return artifact.text + return artifact.s lines = _summary(artifact) if isinstance(artifact, MasterSeed) and fingerprint is not None: lines.append(f"Master fingerprint: {fingerprint(artifact).hex().upper()}") - return "\n".join((*lines, "", _card_text(artifact.text, observed=observed))) + return "\n".join((*lines, "", _card_text(artifact.s, observed=observed))) def _emit( - artifact: Artifact, + artifact: Codex32String, plain: bool, *, err: bool = False, @@ -214,13 +211,13 @@ def _creation_header(value: str | None) -> tuple[int | None, str | None]: if len(header) == 1: return threshold, None try: - identifier = Header(threshold, header[1:], "s").identifier + identifier = _header(threshold, header[1:], "s")[1:5] except CodexError as error: raise _UsageError(f"Invalid backup header: {error}") from error return threshold, identifier -def _creation_source() -> bytes | Artifact: +def _creation_source() -> bytes | Codex32String: prefill = "" while True: value = _text( @@ -259,8 +256,10 @@ def _creation_source() -> bytes | Artifact: ) -def _confirm_card(artifact: Artifact, confirm: Callable[[str], ConfirmationResult] | None = None) -> None: - kind = "share" if isinstance(artifact, Share) else "secret" +def _confirm_card( + artifact: Codex32String, confirm: Callable[[str], ConfirmationResult] | None = None +) -> None: + kind = "secret" if artifact.share_idx == "s" else "share" _print("", err=True) _text( f"Write this {kind} on a new recovery card, then press Enter", @@ -273,7 +272,7 @@ def _confirm_card(artifact: Artifact, confirm: Callable[[str], ConfirmationResul prompt_end=":\n> ", preserve_groups=True, ) - expected = artifact.text + expected = artifact.s confirm = confirm or partial(_confirmation, expected=expected) result = confirm(entered) groups, changed = list(result.entered_groups), {group - 1 for group in result.mismatched_groups} @@ -322,9 +321,9 @@ def _show_fingerprint(core: BitcoinCore, secret: MasterSeed, action: str) -> Non def _without_record(core: BitcoinCore, secret: MasterSeed) -> bool: fingerprint = core.fingerprint(secret) _print(f"\nMaster fingerprint: {fingerprint.hex().upper()}", err=True) - if secret.header.threshold == 0: + if secret.k == "0": # New unshared codex32 backups take their identifier from the fingerprint. - if secret.header.identifier == _fingerprint_identifier(fingerprint): + if secret.ident == _fingerprint_identifier(fingerprint): _print( "The backup identifier matches this fingerprint, so the card is consistent with itself. " "That does not show it is the wallet you expect.", @@ -393,7 +392,7 @@ def _initialize_wallet( f"\n{'Record these wallet details' if fresh else 'Wallet details'}:", err=True, ) - _print(f"Backup identifier: {secret.header.identifier.upper()}", err=True) + _print(f"Backup identifier: {secret.ident.upper()}", err=True) _print( f"Wallet name: {json.dumps(name)}; Bitcoin Core version: {version}", err=True, @@ -459,12 +458,12 @@ def _create( raise _UsageError("For thresholds 4 through 9, choose --shares or --indices.") core = _connected_core() source = _creation_source() if existing else None - if isinstance(source, (Share, Secret)) and not isinstance(source, MasterSeed): + if isinstance(source, Codex32String) and not isinstance(source, MasterSeed): raise _UsageError("Enter one Bitcoin master seed, not a share or another backup type.") try: if threshold == 0: if isinstance(source, MasterSeed): - if identifier is not None and identifier != source.header.identifier: + if identifier is not None and identifier != source.ident: raise _UsageError( "To change the existing secret's identifier, choose a sharing threshold from 2 through 9." ) diff --git a/src/codex32/correction.py b/src/codex32/correction.py index c446995..b1d830c 100644 --- a/src/codex32/correction.py +++ b/src/codex32/correction.py @@ -38,12 +38,11 @@ ) from codex32.bip93 import ( IDX_SORT, - Header, + Codex32String, MasterSeed, - Secret, - Share, _checksum_for_encoded_length, _has_generation_padding, + _header, _validate_ms_length, parse_codex32, ) @@ -92,7 +91,7 @@ class CorrectionEdit: @dataclass(frozen=True, slots=True) class CorrectionCandidate: - artifact: Share | Secret + artifact: Codex32String edits: tuple[CorrectionEdit, ...] capture_volume: int erasures_filled: int @@ -589,7 +588,7 @@ def observed(index: int) -> str: "substitution" if values[-index - 1] >= 0 else "erasure", index, observed(index), - artifact.text[-index - 1], + artifact.s[-index - 1], ) for index, _value in corrections ) @@ -720,7 +719,7 @@ def _validate_context(context: CorrectionContext) -> None: raise ValueError("immutable_prefix must be the HRP prefix with an optional header") if len(prefix) > len(base): header = prefix[len(base) :] - Header(int(header[0]), header[1:], "s") + _header(int(header[0]), header[1:], "s") if length is not None and len(prefix) >= length: raise ValueError("immutable_prefix must be shorter than expected_length") indices = context.excluded_indices @@ -736,18 +735,14 @@ def _validate_context(context: CorrectionContext) -> None: def _allowed(context: CorrectionContext, candidate: CorrectionCandidate) -> bool: - artifact = candidate.artifact - return not ( - isinstance(artifact, Share) - and artifact.header.index in (index.lower() for index in context.excluded_indices) - ) + return candidate.artifact.share_idx not in (index.lower() for index in context.excluded_indices) def _candidate_order(candidate: CorrectionCandidate) -> tuple[object, ...]: return ( candidate.addend_hamming_weight, candidate.crc_padding_match is not True, - candidate.artifact.text.lower(), + candidate.artifact.s.lower(), tuple((edit.reverse_index, edit.kind, edit.observed, edit.replacement) for edit in candidate.edits), ) @@ -794,8 +789,8 @@ def _best( tied = list(_primary(candidates)) if not tied: return () - if prefer_common and any(len(item.artifact.text) in (48, 74) for item in tied): - tied = [item for item in tied if len(item.artifact.text) in (48, 74)] + if prefer_common and any(len(item.artifact.s) in (48, 74) for item in tied): + tied = [item for item in tied if len(item.artifact.s) 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),) diff --git a/src/codex32/generation.py b/src/codex32/generation.py index 88cd0c9..b88d55d 100644 --- a/src/codex32/generation.py +++ b/src/codex32/generation.py @@ -6,21 +6,20 @@ from collections.abc import Callable, Sequence from collections.abc import Set as AbstractSet from dataclasses import dataclass -from typing import NoReturn, SupportsIndex, cast +from typing import NoReturn, SupportsIndex from codex32._bip32 import _valid_root -from codex32.bech32 import _u5_to_chars, convertbits +from codex32.bech32 import _chars_to_u5, _u5_to_chars, convertbits from codex32.bip93 import ( DEFAULT_SEED_BYTES, IDX_SORT, MS, SEED_BYTE_LENGTHS, - Header, + Codex32String, MasterSeed, - Secret, - Share, _from_parts, _has_generation_padding, + _header, _payload_length, derive_share, recover_secret, @@ -117,11 +116,11 @@ def _entered_groups(observed: str, expected: str) -> tuple[list[str], set[int]]: return groups, changed -def _shared_header(threshold: int, identifier: str) -> Header: - """Validate a new share set's header fields once, through Header.""" +def _shared_header(threshold: int, identifier: str) -> tuple[int, str]: + """Validate a new share set's threshold and identifier once.""" if threshold == 0: raise InvalidThreshold("A shared backup needs a threshold of 2 through 9.") - return Header(threshold, identifier, "a") + return threshold, _header(threshold, identifier, "a")[1:5] def _index(value: object) -> str: @@ -173,11 +172,9 @@ def _fingerprint_identifier(fingerprint: bytes) -> str: return _u5_to_chars(tuple(convertbits(fingerprint, 8, 5, pad=True)[:4])) -def _random_share(threshold: int, identifier: str, index: str, length: int) -> Share: +def _random_share(threshold: int, identifier: str, index: str, length: int) -> Codex32String: symbols = tuple(value & 31 for value in secrets.token_bytes(length)) - artifact = _from_parts(MS, Header(threshold, identifier, index), symbols) - assert isinstance(artifact, Share) - return artifact + return _from_parts(MS, _header(threshold, identifier, index), symbols) def _seed_input(seed_bytes: bytes | None, byte_length: int | None) -> tuple[bytes | None, int]: @@ -213,7 +210,7 @@ def generate_master_seed( raise ValueError( "fresh unshared master-seed generation requires a fingerprint provider or identifier" ) - selected_identifier = Header(0, identifier, "s").identifier if identifier is not None else None + selected_identifier = _header(0, identifier, "s")[1:5] if identifier is not None else None while True: fresh = secrets.token_bytes(length) if not _valid_root(fresh): @@ -227,12 +224,12 @@ def generate_master_seed( class CreationCeremony: """Generate and confirm one shared-backup card at a time.""" - _basis: list[Secret | Share] + _basis: list[Codex32String] _direct_count: int _finished: bool _indices: tuple[str, ...] _payload_length: int - _pending: Share | None + _pending: Codex32String | None _position: int _secret: MasterSeed | None _set_identifier: str @@ -262,8 +259,9 @@ def _start( if secret is None: self._secret, self._basis, self._direct_count = None, [], threshold else: - reheadered = _from_parts(MS, Header(threshold, identifier, "s"), secret.payload_symbols) - assert isinstance(reheadered, MasterSeed) + reheadered = MasterSeed( + _from_parts(MS, _header(threshold, identifier, "s"), _chars_to_u5(secret.payload)).s + ) self._secret, self._basis, self._direct_count = ( reheadered, [reheadered], @@ -282,16 +280,11 @@ def master_seed( identifier: str | None = None, ) -> CreationCeremony: """Start a ceremony for a fresh shared Bitcoin master seed.""" - header = _shared_header(threshold, _random_identifier() if identifier is None else identifier) - _supplied, byte_length = _seed_input(None, byte_length) - return cls._start( - _payload_length(byte_length), - header.threshold, - share_count, - indices, - header.identifier, - None, + threshold, identifier = _shared_header( + threshold, _random_identifier() if identifier is None else identifier ) + _supplied, byte_length = _seed_input(None, byte_length) + return cls._start(_payload_length(byte_length), threshold, share_count, indices, identifier, None) @classmethod def from_secret( @@ -307,28 +300,19 @@ def from_secret( if not isinstance(secret, MasterSeed): raise TypeError("from_secret accepts only MasterSeed") random_identifier = identifier is None - header = _shared_header(threshold, _random_identifier() if identifier is None else identifier) - threshold, identifier = header.threshold, header.identifier - while (threshold, identifier) == ( - secret.header.threshold, - secret.header.identifier, - ): + threshold, identifier = _shared_header( + threshold, _random_identifier() if identifier is None else identifier + ) + while (str(threshold), identifier) == (secret.k, secret.ident): if not random_identifier: raise HeaderCollision("new share set must use a different set header") identifier = _random_identifier() - return cls._start( - len(secret.payload_symbols), - threshold, - share_count, - indices, - identifier, - secret, - ) + return cls._start(len(secret.payload), threshold, share_count, indices, identifier, secret) def _candidate_is_accepted(self, secret: MasterSeed) -> bool: - return _valid_root(secret.seed_bytes) and _has_generation_padding(secret) + return _valid_root(secret.data) and _has_generation_padding(secret) - def next_share(self) -> Share: + def next_share(self) -> Codex32String: """Generate or derive the next card after the previous card is confirmed.""" if self._finished: raise CeremonyStateError("this creation ceremony is finished") @@ -347,7 +331,7 @@ def next_share(self) -> Share: ) if self._position + 1 < self._direct_count or self._secret is not None: break - candidate = recover_secret((*cast(list[Share], self._basis), pending)) + candidate = recover_secret((*self._basis, pending)) assert isinstance(candidate, MasterSeed) if self._candidate_is_accepted(candidate): self._secret = candidate @@ -365,7 +349,7 @@ def confirm(self, text: str) -> ConfirmationResult: raise CeremonyStateError("request a card before confirming it") if not isinstance(text, str): raise TypeError("confirmation text must be str") - result = _confirmation(text, self._pending.text) + result = _confirmation(text, self._pending.s) if not result.accepted: return result if self._position < self._direct_count: diff --git a/src/codex32/indel.py b/src/codex32/indel.py index a28f982..1eae454 100644 --- a/src/codex32/indel.py +++ b/src/codex32/indel.py @@ -413,7 +413,7 @@ def _normalize(context: CorrectionContext, damaged_text: str) -> tuple[str, int] def _keep(results: dict[str, CorrectionCandidate], candidate: CorrectionCandidate) -> None: - key = candidate.artifact.text.lower() + key = candidate.artifact.s.lower() current = results.get(key) rank = candidate.capture_volume, candidate.addend_hamming_weight if current is None or rank < ( @@ -537,7 +537,7 @@ def _search_target( "transposition", len(view) - offset - i - 1, state.observed_text[state.base + observed_position], - candidate.artifact.text[state.base + offset + i], + candidate.artifact.s[state.base + offset + i], ) ) offset += size @@ -614,7 +614,7 @@ def finish( optional_only=optional_only, ) return finish(*result) - results = {candidate.artifact.text.lower(): candidate for candidate in seed_candidates} + results = {candidate.artifact.s.lower(): candidate for candidate in seed_candidates} # One global admission ledger, then fixed, required, and optional work. # The minimum supported public sphere is A<=2 / G<=2; deeper cutoffs # require completed worst-case public-profile benchmark evidence. diff --git a/src/codex32/wallet.py b/src/codex32/wallet.py index 6c2b43c..f5feb10 100644 --- a/src/codex32/wallet.py +++ b/src/codex32/wallet.py @@ -8,4 +8,4 @@ def master_xprv(secret: MasterSeed, *, testnet: bool = False) -> str: """Return the root BIP32 extended private key with authority over all children.""" if not isinstance(secret, MasterSeed): raise TypeError("wallet operations accept only MasterSeed") - return _master_xprv_from_seed(secret.seed_bytes, testnet=testnet) + return _master_xprv_from_seed(secret.data, testnet=testnet) diff --git a/tests/test_alignment.py b/tests/test_alignment.py index 94db14c..7b336fd 100644 --- a/tests/test_alignment.py +++ b/tests/test_alignment.py @@ -191,7 +191,7 @@ def test_structural_alignment_leaves_bch_capacity_for_substitutions(shape_args, from codex32.bech32 import CHARSET from codex32.indel import _CLASSES, _frontier, _prepare, _search_target, _StructuralClass - source = MasterSeed.from_seed(bytes(range(16)), identifier="test").text + source = MasterSeed.from_seed(bytes(range(16)), identifier="test").s chars = list(source) width = shape_args.get("unit", 1) for p, q in swaps: @@ -226,9 +226,9 @@ def search(state, frontier, results, deadline): if _FIXED in state.counts: return True required = all(s.unit == 4 or s.distance <= 2 for s in state.counts) - results[first.text] = candidate + results[first.s] = candidate if tie: - results[second.text] = CorrectionCandidate(second, (), 1 << 60, 0, 2, None) + results[second.s] = CorrectionCandidate(second, (), 1 << 60, 0, 2, None) return required and not interrupt_required with patch("codex32.indel._search_target", side_effect=search): @@ -273,10 +273,10 @@ def test_global_frontier_admits_exact_ceiling_but_rejects_larger_batch(): def test_full_consecutive_erasure_capacity_at_inclusive_shared_ceiling(byte_length, erasures, known): from codex32 import CorrectionContext, MasterSeed, correct - source = MasterSeed.from_seed(bytes(range(byte_length)), identifier="test").text + source = MasterSeed.from_seed(bytes(range(byte_length)), identifier="test").s damaged = source[:10] + "?" * erasures + source[10 + erasures :] result = correct(CorrectionContext("ms", len(source) if known else None), damaged) - assert len(result) == 1 and result[0].artifact.text == source + assert len(result) == 1 and result[0].artifact.s == source assert result[0].search_complete assert result[0].capture_volume == 32**erasures diff --git a/tests/test_bip93.py b/tests/test_bip93.py index 26e60c9..56d049e 100644 --- a/tests/test_bip93.py +++ b/tests/test_bip93.py @@ -21,71 +21,70 @@ ) from codex32 import ( + Codex32String, MasterSeed, - Secret, - Share, derive_share, parse_codex32, recover_secret, ) -from codex32.bech32 import convertbits +from codex32.bech32 import _chars_to_u5, convertbits from codex32.errors import CodexError, InvalidChecksum def test_vector_1_parts_and_seed() -> None: secret = parse_codex32(VECTOR_1["secret_s"]) assert isinstance(secret, MasterSeed) - assert secret.header.threshold == 0 - assert secret.header.identifier == VECTOR_1["identifier"] - assert secret.header.index == "s" - assert secret.seed_bytes.hex() == VECTOR_1["secret_hex"] - assert secret.text == VECTOR_1["secret_s"] + assert secret.k == "0" + assert secret.ident == VECTOR_1["identifier"] + assert secret.share_idx == "s" + assert secret.data.hex() == VECTOR_1["secret_hex"] + assert secret.s == VECTOR_1["secret_s"] def test_vector_2_public_api_derives_and_recovers() -> None: a = parse_codex32(VECTOR_2["share_A"]) c = parse_codex32(VECTOR_2["share_C"]) - assert isinstance(a, Share) and isinstance(c, Share) - assert derive_share([a, c], "D").text == VECTOR_2["derived_D"] + assert a.share_idx != "s" and c.share_idx != "s" + assert derive_share([a, c], "D").s == VECTOR_2["derived_D"] secret = recover_secret([a, c]) assert isinstance(secret, MasterSeed) - assert secret.text == VECTOR_2["secret_S"] - assert secret.seed_bytes.hex() == VECTOR_2["secret_hex"] + assert secret.s == VECTOR_2["secret_S"] + assert secret.data.hex() == VECTOR_2["secret_hex"] 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"] + assert secret.s == VECTOR_3["secret_s"] basis = [ secret, parse_codex32(VECTOR_3["share_a"]), parse_codex32(VECTOR_3["share_c"]), ] for index in "def": - assert derive_share(basis, index).text == VECTOR_3[f"derived_{index}"] + assert derive_share(basis, index).s == VECTOR_3[f"derived_{index}"] for pad_value in range(4): alternate = parse_codex32(VECTOR_3[f"secret_s_alternate_{pad_value}"]) assert isinstance(alternate, MasterSeed) - assert alternate.seed_bytes == seed + assert alternate.data == seed 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.s == VECTOR_4["secret_s_alternate_8"] for pad_value in range(16): alternate = parse_codex32(VECTOR_4[f"secret_s_alternate_{pad_value}"]) assert isinstance(alternate, MasterSeed) - assert alternate.seed_bytes == seed + assert alternate.data == seed def test_vector_5_long_uppercase_and_completion() -> None: secret = parse_codex32(VECTOR_5["secret_s"]) assert isinstance(secret, MasterSeed) - assert secret.text == VECTOR_5["secret_s"] - assert secret.seed_bytes.hex() == VECTOR_5["secret_hex"] - assert parse_codex32(secret.text).text.isupper() + assert secret.s == VECTOR_5["secret_s"] + assert secret.data.hex() == VECTOR_5["secret_hex"] + assert parse_codex32(secret.s).s.isupper() def test_vector_6_core_lightning_examples_are_generic_immutable_secrets() -> None: @@ -93,17 +92,18 @@ def test_vector_6_core_lightning_examples_are_generic_immutable_secrets() -> Non cln2 = parse_codex32(VECTOR_6["codex32_cln2"]) peev = parse_codex32(VECTOR_6["codex32_peev"]) for value in (luea, cln2, peev): - assert type(value) is Secret and value.hrp == "cl" + assert type(value) is Codex32String and value.hrp == "cl" - def payload(secret: Secret) -> str: - return bytes(convertbits(secret.payload_symbols, 5, 8, pad=False, accept_any_padding=True)).hex() + def payload(secret: Codex32String) -> str: + return bytes( + convertbits(_chars_to_u5(secret.payload), 5, 8, pad=False, accept_any_padding=True) + ).hex() assert payload(luea) == "6c696768746e696e672d31330000000000000000000000000000000000000000" assert payload(peev) == "5eb00bbddcf069084889a8ab9155568165f5c453ccb85e70811aaed6f6da5fc1" - # Before Python 3.13, slotted frozen dataclasses raise TypeError here for subclasses. - with pytest.raises((FrozenInstanceError, AttributeError, TypeError)): - luea.header = cln2.header # type: ignore[misc] - assert luea.header.identifier == "luea" + with pytest.raises(FrozenInstanceError): + luea.s = cln2.s # type: ignore[misc] + assert luea.ident == "luea" @pytest.mark.parametrize("value", BAD_CHECKSUMS) @@ -138,7 +138,7 @@ def test_official_wrong_application_example_is_a_generic_secret() -> None: # BIP93 lists this as invalid when an ms decoder was explicitly expected; # without an ms prefix it is an ordinary codex32 secret. artifact = parse_codex32(INVALID_MASTER_SEED[0]) - assert type(artifact) is Secret and artifact.hrp != "ms" + assert type(artifact) is Codex32String and artifact.hrp != "ms" @pytest.mark.parametrize("value", INVALID_PREFIX_OR_SEPARATOR[-2:]) diff --git a/tests/test_bitcoin_core.py b/tests/test_bitcoin_core.py index 05e70f3..fc967fd 100644 --- a/tests/test_bitcoin_core.py +++ b/tests/test_bitcoin_core.py @@ -482,7 +482,7 @@ def rpc( monkeypatch.setattr(BitcoinCore, "_rpc", rpc) - assert BitcoinCore("bitcoin-cli", "main", 320000).fingerprint_seed(_SEED.seed_bytes) == _FINGERPRINT + assert BitcoinCore("bitcoin-cli", "main", 320000).fingerprint_seed(_SEED.data) == _FINGERPRINT assert calls == [ (("getdescriptorinfo",), None), (("deriveaddresses",), None), diff --git a/tests/test_cli.py b/tests/test_cli.py index 2d30fe0..f703ca5 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -19,12 +19,11 @@ from data.sharing_vectors import SHARING_VECTORS from codex32 import ( + Codex32String, ConfirmationResult, CorrectionCandidate, CorrectionContext, MasterSeed, - Secret, - Share, derive_share, parse_codex32, recover_secret, @@ -97,7 +96,7 @@ def fingerprint_seed(self, seed: bytes) -> bytes: return stub_fingerprint(seed) def fingerprint(self, secret: MasterSeed) -> bytes: - return self.fingerprint_seed(secret.seed_bytes) + return self.fingerprint_seed(secret.data) def verify_identity(self, secret: MasterSeed, expected_fingerprint: bytes | None) -> None: BitcoinCore.verify_identity(self, secret, expected_fingerprint) # type: ignore[arg-type] @@ -176,11 +175,11 @@ def connect(*_args: object, **_kwargs: object) -> _FakeBitcoinCore: return selected_core def confirm_card( - artifact: Share | Secret, + artifact: Codex32String, confirm: Callable[[str], ConfirmationResult] | None = None, ) -> None: if confirm is not None: - result = confirm(artifact.text) + result = confirm(artifact.s) assert result.accepted with ( @@ -224,7 +223,7 @@ def _card_text(output: str) -> str: return "".join(re.sub(r"\x1b\[[0-9;]*m", "", output.strip().splitlines()[-1]).split()) -def _output_artifacts(result: _Result, profile: str = "ms") -> list[Share | Secret]: +def _output_artifacts(result: _Result, profile: str = "ms") -> list[Codex32String]: lines = (re.sub(r"\x1b\[[0-9;]*m", "", line) for line in result.stdout.splitlines()) return [parse_codex32("".join(line.split())) for line in lines if line.lower().startswith(profile + "1")] @@ -542,7 +541,7 @@ def test_embedded_correction_schedules_both_mixed_case_interpretations_before_al candidates = input_module._suggestions(damaged, "", "ms", []) assert len(candidates) == 1 - assert candidates[0].artifact.text == source + assert candidates[0].artifact.s == source def test_xprv_groups_the_next_prefix_after_spaced_correction(monkeypatch, capsys) -> None: @@ -1110,6 +1109,16 @@ def answer(prompt: str) -> str: assert "String 3 of 3 accepted." not in captured.err +def test_correction_after_secret_in_derivation_basis() -> None: + input_module = importlib.import_module("codex32._cli_input") + share_c = VECTOR_2["share_C"] + damaged = share_c[:-1] + ("Q" if share_c[-1] != "Q" else "P") + + candidates, _ = input_module._corrections(damaged, "", "ms", [parse_codex32(VECTOR_2["secret_S"])]) + + assert [candidate.artifact.s for candidate in candidates] == [share_c] + + @pytest.mark.parametrize(("exception", "status"), ((EOFError(), 2), (KeyboardInterrupt(), 130))) def test_tty_interrupts_have_stable_statuses( monkeypatch: pytest.MonkeyPatch, @@ -1218,9 +1227,9 @@ def test_create_defaults_to_an_unshared_128_bit_master_seed() -> None: assert result.exit_code == 0 assert len(artifacts) == 1 secret = artifacts[0] - assert isinstance(secret, MasterSeed) and len(secret.seed_bytes) == 16 - assert secret.header.threshold == 0 - assert secret.header.identifier == _fingerprint_identifier(stub_fingerprint(secret.seed_bytes)) + assert isinstance(secret, MasterSeed) and len(secret.data) == 16 + assert secret.k == "0" + assert secret.ident == _fingerprint_identifier(stub_fingerprint(secret.data)) def test_fresh_bitcoin_terminal_and_core_preflight_precede_entropy() -> None: @@ -1267,7 +1276,7 @@ def test_confirmation_cannot_replace_the_original_seed_used_for_core( assert isinstance(secret, MasterSeed) core = _FakeBitcoinCore() stdout, stderr = _TTYOutput(), io.StringIO() - monkeypatch.setattr(builtins, "input", lambda _prompt: secret.text.upper()) + monkeypatch.setattr(builtins, "input", lambda _prompt: secret.s.upper()) with ( patch.object(sys, "stdin", _TTYInput()), @@ -1288,12 +1297,12 @@ def test_existing_creation_preflights_then_initializes_from_the_recovered_seed() result = _invoke_confirmed_create( ["create", "2", "--indices", "ac", "--existing"], - source.text, + source.s, core=core, ) assert result.exit_code == 0 - assert core.imported is not None and core.imported.seed_bytes == source.seed_bytes + assert core.imported is not None and core.imported.data == source.data assert core.timestamp == 0 @@ -1304,7 +1313,7 @@ def test_fresh_cli_generation_supports_bip93_sizes(byte_length: int) -> None: assert result.exit_code == 0 assert isinstance(artifact, MasterSeed) - assert len(artifact.seed_bytes) == byte_length + assert len(artifact.data) == byte_length def test_fresh_cli_generation_rejects_every_other_16_through_64_byte_size() -> None: @@ -1325,7 +1334,7 @@ def test_cli_import_preserves_all_bip93_master_seed_sizes(byte_length: int) -> N assert result.exit_code == 0 assert isinstance(artifact, MasterSeed) - assert artifact.seed_bytes == raw + assert artifact.data == raw def test_cli_import_rejects_every_other_16_through_64_byte_size() -> None: @@ -1356,7 +1365,7 @@ def answer(prompt: str) -> str: assert ms_main(["create"]) == 0 artifact = parse_codex32(emitted[0]) assert isinstance(artifact, MasterSeed) - assert artifact.header.identifier == _fingerprint_identifier(stub_fingerprint(artifact.seed_bytes)) + assert artifact.ident == _fingerprint_identifier(stub_fingerprint(artifact.data)) def test_fresh_shared_create_confirms_each_card_on_a_terminal( @@ -1378,7 +1387,7 @@ def answer(prompt: str) -> str: with patch("codex32.cli.BitcoinCore.connect", return_value=_FakeBitcoinCore()): assert ms_main(["create", "2", "--indices", "ac"]) == 0 assert len(emitted) == 2 - assert all(isinstance(parse_codex32(text), Share) for text in emitted) + assert all(parse_codex32(text).share_idx != "s" for text in emitted) def test_shared_create_refuses_redirected_input() -> None: @@ -1394,8 +1403,8 @@ def test_creation_confirmation_highlights_groups_without_correction( ) -> None: cli_module = importlib.import_module("codex32.cli") artifact = parse_codex32(VECTOR_2["share_A"]) - damaged = artifact.text[:4] + ("q" if artifact.text[4] != "q" else "p") + artifact.text[5:] - answers = iter(("", damaged, artifact.text[4:8].upper())) + damaged = artifact.s[:4] + ("q" if artifact.s[4] != "q" else "p") + artifact.s[5:] + answers = iter(("", damaged, artifact.s[4:8].upper())) prompts: list[tuple[str, dict[str, object]]] = [] output = _TTYOutput() @@ -1412,7 +1421,7 @@ def answer(prompt: str, **options: object) -> str: assert message.startswith("\n") assert "\x1b[1;7;31m" in message assert "group(s):" not in message - assert artifact.text[4:8].upper() not in message + assert artifact.s[4:8].upper() not in message assert damaged[4:8].upper() in message assert "\x1b[1;7;31m" in message assert "\x1b[1m" in message and "\x1b[22m" in message @@ -1554,19 +1563,17 @@ def test_create_accepts_positional_headers_and_preserves_index_order() -> None: custom = _output_artifacts(custom_count) shares = _output_artifacts(shared) assert isinstance(fingerprinted_secret, MasterSeed) - assert fingerprinted_secret.header.identifier == _fingerprint_identifier( - stub_fingerprint(fingerprinted_secret.seed_bytes) - ) - assert unshared_secret.header.identifier == "test" + assert fingerprinted_secret.ident == _fingerprint_identifier(stub_fingerprint(fingerprinted_secret.data)) + assert unshared_secret.ident == "test" assert len(automatic) == 3 - assert all(share.header.threshold == 2 for share in automatic) - assert len(automatic[0].header.identifier) == 4 + assert all(share.k == "2" for share in automatic) + assert len(automatic[0].ident) == 4 assert len(custom) == 5 - assert all(share.header.threshold == 3 and share.header.identifier == "cash" for share in custom) - assert "".join(share.header.index for share in shares) == "7cad" - assert all(isinstance(share, Share) for share in shares) - basis = [share for share in shares[:3] if isinstance(share, Share)] - assert recover_secret(basis).header.identifier == "cash" + assert all(share.k == "3" and share.ident == "cash" for share in custom) + assert "".join(share.share_idx for share in shares) == "7cad" + assert all(share.share_idx != "s" for share in shares) + basis = [share for share in shares[:3] if share.share_idx != "s"] + assert recover_secret(basis).ident == "cash" @pytest.mark.parametrize(("threshold", "count"), ((2, 3), (3, 5))) @@ -1576,8 +1583,8 @@ def test_create_has_reviewed_share_count_presets(threshold: int, count: int) -> assert result.exit_code == 0 assert len(shares) == count - assert all(isinstance(share, Share) for share in shares) - assert len({share.header.index for share in shares}) == count + assert all(share.share_idx != "s" for share in shares) + assert len({share.share_idx for share in shares}) == count assert "Master-seed backup confirmed." in result.stderr @@ -1607,29 +1614,27 @@ def test_create_existing_preserves_secrets_and_uses_explicit_thresholds_for_resh random_raw = _invoke_confirmed_create(["create", "--existing"], raw.hex()) accepted_raw = _invoke_confirmed_create(["create", "0test", "--existing"], raw.hex()) source = parse_codex32(VECTOR_4["secret_s"]) - rejected_split = _invoke(["create"], source.text) - unchanged_backup = _invoke_confirmed_create(["create", "--existing"], source.text) - random_split = _invoke_confirmed_create(["create", "2", "--indices", "ac", "--existing"], source.text) - accepted_split = _invoke_confirmed_create( - ["create", "2name", "--indices", "ac", "--existing"], source.text - ) + rejected_split = _invoke(["create"], source.s) + unchanged_backup = _invoke_confirmed_create(["create", "--existing"], source.s) + random_split = _invoke_confirmed_create(["create", "2", "--indices", "ac", "--existing"], source.s) + accepted_split = _invoke_confirmed_create(["create", "2name", "--indices", "ac", "--existing"], source.s) assert rejected_raw.exit_code != 0 assert rejected_split.exit_code != 0 assert "interactive terminal" in rejected_raw.stderr assert "interactive terminal" in rejected_split.stderr random_secret = _output_artifacts(random_raw)[0] - assert isinstance(random_secret, MasterSeed) and random_secret.seed_bytes == raw + assert isinstance(random_secret, MasterSeed) and random_secret.data == raw assert unchanged_backup.exit_code == 0 - assert _output_artifacts(unchanged_backup)[0].text == source.text + assert _output_artifacts(unchanged_backup)[0].s == source.s secret = _output_artifacts(accepted_raw)[0] - assert isinstance(secret, MasterSeed) and secret.seed_bytes == raw + assert isinstance(secret, MasterSeed) and secret.data == raw assert isinstance(source, MasterSeed) for result in (random_split, accepted_split): - basis = [share for share in _output_artifacts(result) if isinstance(share, Share)] + basis = [share for share in _output_artifacts(result) if share.share_idx != "s"] recovered = recover_secret(basis) assert isinstance(recovered, MasterSeed) - assert recovered.seed_bytes == source.seed_bytes + assert recovered.data == source.data @pytest.mark.parametrize( @@ -1750,7 +1755,7 @@ def test_correct_rejects_malformed_immutable_hrp_as_usage() -> None: def test_correction_accepts_inferred_explicit_and_unknown_lengths( byte_length: int, options: list[str] ) -> None: - original = MasterSeed.from_seed(bytes(range(byte_length)), identifier="test").text + original = MasterSeed.from_seed(bytes(range(byte_length)), identifier="test").s damaged = original[:19] + original[20:] valid = _invoke(["correct"], original) result = _invoke(["correct", *options], damaged) @@ -1982,20 +1987,20 @@ def test_wallet_network_selection_does_not_change_recovery_material() -> None: def test_wallet_network_selection_preserves_share_compatibility() -> None: a = parse_codex32(VECTOR_2["share_A"]) c = parse_codex32(VECTOR_2["share_C"]) - assert isinstance(a, Share) and isinstance(c, Share) + assert a.share_idx != "s" and c.share_idx != "s" recovered = recover_secret((a, c)) assert isinstance(recovered, MasterSeed) for chain in ("main", "regtest"): result, core = _invoke_initialized_wallet( ["wallet"], - recovered.text, + recovered.s, core=_FakeBitcoinCore(chain=chain), ) assert result.exit_code == 0 assert core.imported == recovered assert recover_secret((a, c)) == recovered - assert derive_share((a, c), "d").text == VECTOR_2["derived_D"] + assert derive_share((a, c), "d").s == VECTOR_2["derived_D"] def test_wallet_private_warning_precedes_recovery_input( @@ -2118,7 +2123,7 @@ def test_confirmation_region_splits_and_confines_retries( input_module = importlib.import_module("codex32._cli_input") artifact = parse_codex32(VECTOR_2["share_A"]) # Three adjacent substitutions; correcting the middle splits the run. - damaged = artifact.text[:4] + " qAME b320 qYXW " + artifact.text[16:] + damaged = artifact.s[:4] + " qAME b320 qYXW " + artifact.s[16:] answers = iter(("", damaged, "qAME a320 qYXW", "", "qAME", "NAMEa320", "nAmE", "zYxW")) editor = _FakeLineEditor() snapshots: list[str] = [] @@ -2145,7 +2150,7 @@ def answer(prompt: str) -> str: def confirm(text: str) -> ConfirmationResult: confirmed.append(text) - return _confirmation(text, artifact.text) + return _confirmation(text, artifact.s) with contextlib.redirect_stderr(output): cli_module._confirm_card(artifact, confirm) @@ -2155,10 +2160,10 @@ def confirm(text: str) -> ConfirmationResult: for shown in snapshots[1:]: assert "\x1b[1mA320\x1b[0m" in shown assert "\x1b[1;7;31mA320" not in shown - assert artifact.text[16:20] in shown + assert artifact.s[16:20] in shown assert len(re.findall(r"\x1b\[(?:1|22);7;31m", shown)) == 1 assert snapshots[1] == snapshots[2] == snapshots[3] - assert "".join(confirmed[-1].split()).lower() == artifact.text.lower() + assert "".join(confirmed[-1].split()).lower() == artifact.s.lower() if readline: assert editor.inserted == prefills @@ -2170,7 +2175,7 @@ def test_confirmation_suffix_extra_freezes_without_shifting_neighbor( # A display-only stand-in isolates the reported card suffix regression. class Card: - text = "ABCDSF6EJ24C" + s, share_idx = "ABCDSF6EJ24C", "s" answers = iter(("", "ABCDSF6EJ24CJ24X", "J24C")) prefills: list[str] = [] @@ -2275,7 +2280,7 @@ def test_confirmation_full_string_retry_preserves_progress( input_module = importlib.import_module("codex32._cli_input") class Card: - text = "MS10ABCDEFGH" + s, share_idx = "MS10ABCDEFGH", "s" final = " ms10 ABCD eFgH " if full_retry else "abcd" answers = iter(("", "ms10 ABC XEFGH", "ms10 abcd efgX", "", "ABC XEFGH", "abC eFgH", final)) @@ -2299,7 +2304,7 @@ def answer(prompt: str) -> str: def confirm(value: str) -> ConfirmationResult: confirmed.append(value) - return _confirmation(value, Card.text) + return _confirmation(value, Card.s) monkeypatch.setattr(cli_module, "_text", read) monkeypatch.setattr(input_module.sys, "stdin", _TTYInput()) @@ -2329,7 +2334,7 @@ def test_confirmation_prefill_uses_typed_spacing_or_display_boundaries( cli_module = importlib.import_module("codex32.cli") class Card: - text = "MS10ABCDEFGH" + s, share_idx = "MS10ABCDEFGH", "s" answers = iter(("", observed, "ABCDEFGH")) prefills: list[str] = [] @@ -2353,9 +2358,9 @@ def test_create_existing_secret_confirms_original_before_initializing( assert isinstance(secret, MasterSeed) core = _FakeBitcoinCore() output = _TTYOutput() - damaged = secret.text[:-1] + ("q" if secret.text[-1].lower() != "q" else "p") - width = len(secret.text) % 4 or 4 - answers = iter(("", damaged, secret.text[-width:].upper())) + damaged = secret.s[:-1] + ("q" if secret.s[-1].lower() != "q" else "p") + width = len(secret.s) % 4 or 4 + answers = iter(("", damaged, secret.s[-width:].upper())) prefills: list[str] = [] def answer(prompt: str, **options: object) -> str: @@ -2376,7 +2381,7 @@ def answer(prompt: str, **options: object) -> str: ): assert ms_main(["create", "--existing"]) == 0 assert prefills == [damaged[-width:]] - assert _card_text(output.getvalue()).lower() == secret.text.lower() + assert _card_text(output.getvalue()).lower() == secret.s.lower() assert core.imported is secret assert core.timestamp == 0 generate.assert_not_called() @@ -2394,7 +2399,7 @@ def test_create_existing_accepts_complete_ms_secrets_in_either_case(source: str) result = _invoke_confirmed_create(["create", "--existing"], source) assert result.exit_code == 0 - assert _output_artifacts(result)[0].text == source + assert _output_artifacts(result)[0].s == source def test_create_existing_interruption_cannot_initialize_a_wallet() -> None: @@ -2426,7 +2431,7 @@ def test_operational_candidate_whole_card_confirmation(monkeypatch, capsys, resp assert module._confirm_correction(candidate, [], False, _FakeBitcoinCore().fingerprint) is accepted output = capsys.readouterr().err assert "Master fingerprint: 3F3521A6\n\n" in output - assert module._card_text(artifact.text) in output + assert module._card_text(artifact.s) in output assert "> " not in output assert "\x1b[1;31m" not in output and "\x1b[1;31;7m" not in output assert "\x1b[1m" in output and "\x1b[22m" in output @@ -2512,7 +2517,7 @@ def confirm(artifact): monkeypatch.setattr(cli, "derive_share", derive) monkeypatch.setattr(cli, "_confirm_card", confirm) assert main(["share", "d"]) == 0 - assert len(derived) == 1 and derived[0].text.lower() == expected.lower() + assert len(derived) == 1 and derived[0].s.lower() == expected.lower() assert "Recovery card confirmed." in stderr.getvalue() card = module._card_text(expected) assert stdout.getvalue().count(card) == 1 @@ -2614,7 +2619,7 @@ def answer(prompt, prefill=""): def derive(artifacts, index): events.append("derive") - assert artifacts[1].text.lower() == second.lower() + assert artifacts[1].s.lower() == second.lower() return original(artifacts, index) monkeypatch.setattr(module, "_editable_input", answer) @@ -2768,7 +2773,7 @@ def test_corrected_creation_source_identity_and_acceptance_boundary(monkeypatch) SimpleNamespace(artifact=secret, search_complete=True, low_checksum_discrimination=False), ), ) - answers = iter(("ms1invalid", "n", "ms1invalid", "yes", "", secret.text)) + answers = iter(("ms1invalid", "n", "ms1invalid", "yes", "", secret.s)) confirmations = [] def answer(prompt, prefill=""): @@ -2852,7 +2857,7 @@ def test_restore_without_a_record_shows_what_the_cards_say_and_asks( assert prompts[1] == "Restore without a wallet record? [y/N]" assert "does not match this fingerprint" in capsys.readouterr().err - derived = MasterSeed.from_seed(secret.seed_bytes, identifier=_fingerprint_identifier(fingerprint)) + derived = MasterSeed.from_seed(secret.data, identifier=_fingerprint_identifier(fingerprint)) _record_answers(monkeypatch, "", "yes") assert _RECORDED_FINGERPRINT(core, derived) is None shown = capsys.readouterr().err @@ -2917,12 +2922,12 @@ def source(): def record(_core, recovered): assert core.imported is None - checked.append(recovered.seed_bytes) + checked.append(recovered.data) return core.fingerprint(recovered) def confirm(artifact, accept=None): if accept: - accept(artifact.text) + accept(artifact.s) def emit(_artifact, _plain, **kwargs): emitted_fingerprints.append(kwargs.get("fingerprint")) @@ -2936,5 +2941,5 @@ def emit(_artifact, _plain, **kwargs): monkeypatch.setattr(cli.BitcoinCore, "connect", lambda *args: core) assert ms_main(["create", "--existing", *([header] if header else [])]) == 0 assert emitted_fingerprints and all(fingerprint is None for fingerprint in emitted_fingerprints) - assert checked == [secret.seed_bytes] + assert checked == [secret.data] assert core.expected == core.fingerprint(secret) diff --git a/tests/test_competitors.py b/tests/test_competitors.py index 303fa2e..2cfb453 100644 --- a/tests/test_competitors.py +++ b/tests/test_competitors.py @@ -6,13 +6,13 @@ import pytest -from codex32 import CorrectionContext, MasterSeed, Share, indel +from codex32 import CorrectionContext, MasterSeed, indel from codex32 import _competitors as search from codex32.bech32 import CHARSET from codex32.bip93 import TEXT_LENGTHS from codex32.correction import _correct_fixed -SOURCE = MasterSeed.from_seed(bytes(range(16)), identifier="test").text +SOURCE = MasterSeed.from_seed(bytes(range(16)), identifier="test").s REPORTED = "ms12namedll4f8jkh4e5vdvuldlfxu2jhdnlsm97xcenrxeg" INTENDED = "ms12namedll4f8jlh4e5vdvuldlfxu2jhdnlsm97xvenrxeg" @@ -49,7 +49,7 @@ def views(text, target, shape, immutable, base): candidates, complete = _run(REPORTED) assert complete and candidates[0].search_complete - assert candidates[0].artifact.text == INTENDED + assert candidates[0].artifact.s == INTENDED assert candidates[0].capture_volume == 951_390 assert (48, ("I", "O"), 1) in searched assert (48, ("AT",), 4) in searched @@ -72,7 +72,7 @@ def test_known_swap_classification_retains_the_lower_capture_explanation(pairs, damaged[left], damaged[right] = damaged[right], damaged[left] candidates, complete = _run("".join(damaged), target=48) assert complete - assert candidates[0].artifact.text == SOURCE + assert candidates[0].artifact.s == SOURCE assert candidates[0].capture_volume == volume assert candidates[0].addend_hamming_weight == 0 assert any(edit.kind == "transposition" for edit in candidates[0].edits) @@ -84,15 +84,15 @@ def test_directed_swap_views_match_exhaustive_alignment_scoring(substitutions, s # A small mutable suffix permits exhaustive comparison, including duplicate # characters, overlapping swaps, temporary disturbances, and residual errors. artifact = MasterSeed.from_seed(seed, identifier="test") - damaged = list(artifact.text) + damaged = list(artifact.s) damaged[-6], damaged[-5] = damaged[-5], damaged[-6] for position in (-1, -2)[:substitutions]: damaged[position] = CHARSET[CHARSET.index(damaged[position]) ^ 1] text = "".join(damaged) state = replace(_state(text), immutable=len(text) - 6) - candidate = _correct_fixed(artifact.text, hrp="ms") + candidate = _correct_fixed(artifact.s, hrp="ms") assert candidate is not None - target = tuple(CHARSET.index(c) for c in artifact.text[3:]) + target = tuple(CHARSET.index(c) for c in artifact.s[3:]) matches = 0 for shape in indel._CHARACTER_CLASSES: if shape.inserted or shape.omitted: @@ -197,7 +197,7 @@ def test_group_duplicate_proofs_match_exhaustive_alignment_values(): @pytest.mark.parametrize("count", (1, 2, 3, 4)) @pytest.mark.parametrize("length", (16, 32, 64)) def test_deadline_returns_usable_fixed_candidate_with_incomplete_metadata(count, length): - source = MasterSeed.from_seed(bytes(range(length)), identifier="test").text + source = MasterSeed.from_seed(bytes(range(length)), identifier="test").s damaged = _substitute(source, count) clock = [0.0] original = search._search_fixed @@ -214,7 +214,7 @@ def fixed(*args): candidates, complete = _run(damaged, target=len(source), deadline=10.0) assert not complete and len(candidates) == 1 assert not candidates[0].search_complete - assert candidates[0].artifact.text == source + assert candidates[0].artifact.s == source def test_ineligible_fixed_candidate_cannot_prune_other_layers(): @@ -227,7 +227,7 @@ def layer(state, frontier, results, deadline, *, allowed, views): return False with patch.object(search, "_search_target", side_effect=layer): - candidates, complete = _run(REPORTED, allowed=lambda c: not isinstance(c.artifact, Share)) + candidates, complete = _run(REPORTED, allowed=lambda c: c.artifact.share_idx == "s") assert not complete and not candidates and calls @@ -247,7 +247,7 @@ def test_structural_candidate_filter_is_applied_before_collection(): {(48, shape, 0, 0): frontier[48, shape, 0, 0]}, results, None, - allowed=lambda c: not isinstance(c.artifact, Share), + allowed=lambda c: c.artifact.share_idx == "s", views=views, ) assert tuple(results) == (SOURCE,) @@ -268,7 +268,7 @@ def test_alignment_discovered_candidate_matches_unpruned_search(target, change): candidates, complete = _run(damaged, target=target) assert complete and reference_complete assert len(candidates) == len(reference) == 1 - assert candidates[0].artifact.text == reference[0].artifact.text == SOURCE + assert candidates[0].artifact.s == reference[0].artifact.s == SOURCE assert candidates[0].capture_volume == reference[0].capture_volume assert candidates[0].addend_hamming_weight == reference[0].addend_hamming_weight @@ -295,15 +295,15 @@ def layer(state, frontier, results, deadline, **kwargs): def test_incomplete_primary_ties_are_suppressed(): first = _correct_fixed(SOURCE, hrp="ms") - second = _correct_fixed(MasterSeed.from_seed(bytes(16), identifier="test").text, hrp="ms") + second = _correct_fixed(MasterSeed.from_seed(bytes(16), identifier="test").s, hrp="ms") assert first is not None and second is not None calls = [] def layer(state, frontier, results, deadline, **kwargs): volume = next(iter(frontier.values())) calls.append(volume) - results[first.artifact.text] = replace(first, capture_volume=volume) - results[second.artifact.text] = replace(second, capture_volume=volume) + results[first.artifact.s] = replace(first, capture_volume=volume) + results[second.artifact.s] = replace(second, capture_volume=volume) return False with patch.object(search, "_search_target", side_effect=layer): @@ -322,8 +322,8 @@ def test_equal_or_better_competitor_at_another_length_is_not_pruned(later_volume def layer(state, frontier, results, deadline, **kwargs): calls.append(state.target) artifact = MasterSeed.from_seed(bytes(16 if state.target == 48 else 20), identifier="test") - candidate = _correct_fixed(artifact.text, hrp="ms") - results[artifact.text] = replace(candidate, capture_volume=next(iter(frontier.values()))) + candidate = _correct_fixed(artifact.s, hrp="ms") + results[artifact.s] = replace(candidate, capture_volume=next(iter(frontier.values()))) return True with patch.object(search, "_search_target", side_effect=layer): diff --git a/tests/test_correction_bch.py b/tests/test_correction_bch.py index d0cc223..ed4f14b 100644 --- a/tests/test_correction_bch.py +++ b/tests/test_correction_bch.py @@ -79,7 +79,7 @@ def test_frozen_p70_differential_corpus() -> None: assert result is None else: assert isinstance(result, CorrectionCandidate) - assert result.artifact.text == case["expected"] + assert result.artifact.s == case["expected"] _DISTRIBUTIONS = tuple( @@ -101,7 +101,7 @@ def test_every_bch_error_erasure_distribution( positions = [3 + ((index * 11 + errors * 3 + erasures) % (len(source) - 3)) for index in range(count)] assert len(set(positions)) == count result = _success(_change(source, positions, erasures=erasures)) - assert result.artifact.text == source + assert result.artifact.s == source @given( @@ -126,7 +126,7 @@ def test_bch_positions_and_addends_property( ) ) result = _success(_change(source, positions, erasures=erasures)) - assert result.artifact.text == source + assert result.artifact.s == source @pytest.mark.parametrize( @@ -141,7 +141,7 @@ def test_every_consecutive_erasure_burst( for start in range(prefix_length, len(source) - degree + 1): positions = list(range(start, start + degree)) result = _success(_change(source, positions, erasures=degree)) - assert result.artifact.text == source + assert result.artifact.s == source @pytest.mark.parametrize( @@ -161,7 +161,7 @@ def test_ms_and_generic_hrps_use_public_correction_api( prefix_length = len(hrp) + 1 positions = [prefix_length + offset for offset in (2, 7, 12, 17)] result = correct(CorrectionContext(hrp, len(source)), _change(source, positions)) - assert len(result) == 1 and result[0].artifact.text == source + assert len(result) == 1 and result[0].artifact.s == source def test_public_candidate_reports_fixed_edits_and_ranking_inputs() -> None: @@ -200,7 +200,7 @@ def test_public_context_constrains_length_prefix_and_used_indices() -> None: ) candidate = correct(valid, source) - assert len(candidate) == 1 and candidate[0].artifact.text == source + assert len(candidate) == 1 and candidate[0].artifact.s == source assert correct(CorrectionContext("ms", expected_length=74), source) == () assert correct(CorrectionContext("ms", immutable_prefix="ms12cash"), source) == () assert correct(CorrectionContext("ms", excluded_indices=("A",)), source) == () @@ -250,7 +250,7 @@ def test_uppercase_input_preserves_case_and_reverse_addends() -> None: position = 10 damaged = _change(source, [position]) result = _success(damaged) - assert result.artifact.text == source + assert result.artifact.s == source assert result.edits == ( CorrectionEdit( "substitution", @@ -274,7 +274,7 @@ def test_public_correction_interprets_mixed_case_by_majority(uppercase: bool) -> result = correct(CorrectionContext("ms", expected_length=len(source)), mixed) assert len(result) == 1 - assert result[0].artifact.text == source + assert result[0].artifact.s == source def test_grouped_mixed_case_keeps_the_recorded_header_immutable() -> None: @@ -290,7 +290,7 @@ def test_grouped_mixed_case_keeps_the_recorded_header_immutable() -> None: result = correct(context, grouped) assert len(result) == 1 - assert result[0].artifact.text == source + assert result[0].artifact.s == source assert {edit.observed for edit in result[0].edits} == {"p"} @@ -310,7 +310,7 @@ def test_public_correction_searches_identical_case_interpretation_once() -> None result = correct(CorrectionContext("ms", expected_length=len(source)), damaged) assert len(result) == 1 - assert result[0].artifact.text == source + assert result[0].artifact.s == source assert result[0].cumulative_capture_volume == 1 @@ -372,7 +372,7 @@ def test_mixed_case_required_work_is_not_starved_by_erasure_alignment() -> None: result = correct(CorrectionContext("ms", expected_length=len(source)), damaged) assert len(result) == 1 - assert result[0].artifact.text == source + assert result[0].artifact.s == source def test_post_preflight_optional_expiry_preserves_seed(monkeypatch: pytest.MonkeyPatch) -> None: @@ -418,7 +418,7 @@ def test_mixed_case_correction_edits_preserve_the_entered_character( result = correct(CorrectionContext("ms", expected_length=len(source)), damaged) assert len(result) == 1 - assert result[0].artifact.text == source + assert result[0].artifact.s == source assert tuple( (edit.kind, edit.reverse_index, edit.observed, edit.replacement) for edit in result[0].edits ) == ((kind, len(source) - position - 1, observed, source[position]),) @@ -433,7 +433,7 @@ def test_mixed_case_does_not_reclassify_unrelated_structural_erasures() -> None: result = correct(CorrectionContext("ms", expected_length=len(source)), mixed) assert len(plain) == len(result) == 1 - assert result[0].artifact.text == source + assert result[0].artifact.s == source assert tuple(edit.kind for edit in plain[0].edits) == ("erasure",) * 4 assert tuple(edit.kind for edit in result[0].edits) == ("erasure",) * 4 assert tuple((edit.reverse_index, edit.observed, edit.replacement) for edit in result[0].edits) == tuple( @@ -448,7 +448,7 @@ def test_mixed_case_structural_edits_preserve_the_entered_character() -> None: result = correct(CorrectionContext("ms", expected_length=len(source)), damaged) assert len(result) == 1 - assert result[0].artifact.text == source + assert result[0].artifact.s == source assert {edit.kind for edit in result[0].edits} == {"insertion", "substitution"} assert next(edit for edit in result[0].edits if edit.kind == "substitution").observed == "P" diff --git a/tests/test_correction_disclosure.py b/tests/test_correction_disclosure.py index c8cdb9f..f02dc9c 100644 --- a/tests/test_correction_disclosure.py +++ b/tests/test_correction_disclosure.py @@ -88,7 +88,7 @@ def test_scheduler_annotates_from_admission_even_when_no_work_finished(monkeypat def test_public_api_full_checksum_erasure_completion_carries_risk(source, degree): hrp = source.rsplit("1", 1)[0] candidates = correct(CorrectionContext(hrp, len(source)), source[:-degree] + "?" * degree) - assert len(candidates) == 1 and candidates[0].artifact.text == source + assert len(candidates) == 1 and candidates[0].artifact.s == source assert candidates[0].capture_space_bits == 5 * degree assert candidates[0].cumulative_capture_volume == 1 << (5 * degree) assert candidates[0].low_checksum_discrimination diff --git a/tests/test_correction_indel.py b/tests/test_correction_indel.py index 4a8e430..589f551 100644 --- a/tests/test_correction_indel.py +++ b/tests/test_correction_indel.py @@ -80,7 +80,7 @@ def _group_damage(source: str, inserted: int, omitted: int) -> str: def test_complete_character_family_recovers_source(inserted: int, omitted: int) -> None: damaged = _character_damage(SOURCE, inserted, omitted) - assert [candidate.artifact.text for candidate in correct(CONTEXT, damaged)] == [SOURCE] + assert [candidate.artifact.s for candidate in correct(CONTEXT, damaged)] == [SOURCE] def test_character_class_set_is_exact() -> None: @@ -110,7 +110,7 @@ def test_complete_group_family_recovers_ungrouped_source( ) -> None: damaged = _group_damage(SOURCE, inserted, omitted) - assert [candidate.artifact.text for candidate in correct(CONTEXT, damaged)] == [SOURCE] + assert [candidate.artifact.s for candidate in correct(CONTEXT, damaged)] == [SOURCE] # Exercise each family across intermediate lengths; the frontier arithmetic @@ -124,7 +124,7 @@ def test_optional_three_character_indels_recover_intermediate_lengths( inserted: int, omitted: int, ) -> None: - source = MasterSeed.from_seed(bytes(range(byte_length)), identifier="test").text + source = MasterSeed.from_seed(bytes(range(byte_length)), identifier="test").s damaged = _character_damage(source, inserted, omitted) states = tuple( state @@ -152,7 +152,7 @@ def test_automatic_secondary_search_recovers_two_group_indels( inserted: int, omitted: int, ) -> None: - source = MasterSeed.from_seed(bytes(range(byte_length)), identifier="test").text + source = MasterSeed.from_seed(bytes(range(byte_length)), identifier="test").s damaged = _group_damage(source, inserted, omitted) contexts = tuple(CorrectionContext("ms", target, "ms1") for target in TEXT_LENGTHS) @@ -164,7 +164,7 @@ def test_automatic_secondary_search_recovers_two_group_indels( ) assert complete - assert [candidate.artifact.text for candidate in candidates] == [source] + assert [candidate.artifact.s for candidate in candidates] == [source] def test_group_class_set_and_first_share_counts_are_exact() -> None: @@ -205,8 +205,8 @@ def test_group_search_does_not_depend_on_spaces() -> None: plain = correct(CONTEXT, ungrouped) presented = correct(CONTEXT, grouped) - assert [candidate.artifact.text for candidate in plain] == [SOURCE] - assert [candidate.artifact.text for candidate in presented] == [SOURCE] + assert [candidate.artifact.s for candidate in plain] == [SOURCE] + assert [candidate.artifact.s for candidate in presented] == [SOURCE] assert plain[0].capture_volume == presented[0].capture_volume == 11 * 32**4 @@ -218,7 +218,7 @@ def test_immutable_confirmed_header_reduces_domains_and_cannot_be_repaired() -> assert _alignment_count(character, 48, 48, len(prefix)) == comb(40, 2) ** 2 assert _alignment_count(two_groups, 40, 48, len(prefix)) == 45 - assert correct(context, _character_damage(SOURCE, 1, 1))[0].artifact.text == SOURCE + assert correct(context, _character_damage(SOURCE, 1, 1))[0].artifact.s == SOURCE changed = ("2" if prefix[3] != "2" else "3") + SOURCE[4:] assert correct(context, SOURCE[:3] + changed) == () @@ -242,7 +242,7 @@ def test_explicit_unknown_can_be_retained_or_deleted() -> None: retained_candidate = correct(CONTEXT, retained)[0] deleted_candidate = correct(CONTEXT, extra)[0] - assert retained_candidate.artifact.text == deleted_candidate.artifact.text == SOURCE + assert retained_candidate.artifact.s == deleted_candidate.artifact.s == SOURCE assert [edit.kind for edit in retained_candidate.edits] == ["erasure"] assert [edit.kind for edit in deleted_candidate.edits] == ["deletion"] @@ -265,7 +265,7 @@ def test_structural_erasure_preserves_the_observed_character() -> None: candidate = correct(CONTEXT, damaged)[0] - assert candidate.artifact.text == SOURCE + assert candidate.artifact.s == SOURCE assert {(edit.kind, edit.observed) for edit in candidate.edits} == { ("insertion", ""), ("erasure", "!"), @@ -295,7 +295,7 @@ def test_structural_capacity_does_not_borrow_linear_erasure_recovery() -> None: consecutive_text = "".join("?" if index in consecutive else value for index, value in enumerate(SOURCE)) separated_text = "".join("?" if index in separated else value for index, value in enumerate(SOURCE)) - assert correct(CONTEXT, consecutive_text)[0].artifact.text == SOURCE + assert correct(CONTEXT, consecutive_text)[0].artifact.s == SOURCE assert correct(CONTEXT, separated_text) == () @@ -306,7 +306,7 @@ def test_expected_length_search_admits_safe_consecutive_erasure_recovery(count: candidate = correct(CONTEXT, damaged)[0] - assert candidate.artifact.text == source + assert candidate.artifact.s == source assert candidate.erasures_filled == count @@ -323,8 +323,8 @@ def test_fixed_volume_retains_every_bch_substitution_layer() -> None: def test_unknown_target_search_recovers_reachable_valid_length() -> None: damaged = SOURCE[:19] + SOURCE[20:] - assert correct(CorrectionContext("ms"), damaged)[0].artifact.text == SOURCE - assert correct(CONTEXT, damaged)[0].artifact.text == SOURCE + assert correct(CorrectionContext("ms"), damaged)[0].artifact.s == SOURCE + assert correct(CONTEXT, damaged)[0].artifact.s == SOURCE @pytest.mark.parametrize( @@ -336,11 +336,11 @@ def test_structural_search_supports_ms_and_generic_hrps(hrp: str, source: str) - result = correct(CorrectionContext(hrp, expected_length=len(source)), damaged) - assert [candidate.artifact.text for candidate in result] == [source] + assert [candidate.artifact.s for candidate in result] == [source] def test_two_each_with_two_substitutions_is_admitted_by_shared_bound() -> None: - source = MasterSeed.from_seed(bytes(range(16)), identifier="test").text + source = MasterSeed.from_seed(bytes(range(16)), identifier="test").s damaged = list(source) damaged.pop(34) damaged.pop(16) @@ -358,7 +358,7 @@ def test_two_each_with_two_substitutions_is_admitted_by_shared_bound() -> None: def test_two_each_with_one_substitution_uses_the_generic_fixed_core() -> None: - source = MasterSeed.from_seed(bytes(range(16)), identifier="test").text + source = MasterSeed.from_seed(bytes(range(16)), identifier="test").s damaged = list(source) for position in reversed((18, 32)): damaged.pop(position) @@ -406,7 +406,7 @@ def test_cli_tie_breaks_follow_hamming_crc_then_fingerprint() -> None: fingerprint_match = CorrectionCandidate(fingerprint, (), 10, 0, 2, True) assert len(_primary((high_hamming, crc, fingerprint_match))) == 3 - matcher = _fingerprint_matcher(lambda secret: core_fingerprint(secret.seed_bytes)) + matcher = _fingerprint_matcher(lambda secret: core_fingerprint(secret.data)) assert _best((high_hamming, crc, fingerprint_match), fingerprint_match=matcher) == (fingerprint_match,) @@ -429,12 +429,12 @@ def test_unknown_length_preference_is_secondary_to_capture_volume() -> None: assert _best((common, uncommon), prefer_common=True) == (uncommon,) assert { - item.artifact.text + item.artifact.s for item in _best( (common, other_common, tied_uncommon), prefer_common=True, ) - } == {short.text, medium.text} + } == {short.s, medium.s} def test_production_cross_length_frontier_matches_independent_arithmetic() -> None: @@ -489,7 +489,7 @@ def run(rank: int) -> list[int]: def search(state, _frontier, results, _deadline): # type: ignore[no-untyped-def] calls.append(state.target) if state.target == 48: - results[artifact.text] = CorrectionCandidate(artifact, (), rank, 0, 0, None) + results[artifact.s] = CorrectionCandidate(artifact, (), rank, 0, 0, None) return True with patch("codex32.indel._search_target", side_effect=search): @@ -551,8 +551,8 @@ def test_full_checksum_burst_is_admitted_at_the_shared_mass_ceiling(): from codex32.correction import _correct_fixed damaged = SOURCE[:8] + "?" * 13 + SOURCE[21:] - assert _correct_fixed(damaged, hrp="ms").artifact.text == SOURCE - assert correct(CONTEXT, damaged)[0].artifact.text == SOURCE + assert _correct_fixed(damaged, hrp="ms").artifact.s == SOURCE + assert correct(CONTEXT, damaged)[0].artifact.s == SOURCE def test_public_correction_fails_closed_when_required_work_expires() -> None: @@ -572,7 +572,7 @@ def expire_after_fixed(state, *args, **kwargs): # type: ignore[no-untyped-def] return real(state, *args, **kwargs) return False - assert any(candidate.artifact.text == source for candidate in correct(context, swapped)) + assert any(candidate.artifact.s == source for candidate in correct(context, swapped)) with ( patch.object(indel_module, "_search_target", expire_after_fixed), patch.object(competitors_module, "_search_target", expire_after_fixed), @@ -580,4 +580,4 @@ def expire_after_fixed(state, *args, **kwargs): # type: ignore[no-untyped-def] assert correct(context, swapped) == () cli_candidates, complete = _correct_complete(context, swapped, competitors=True) assert not complete - assert [(c.artifact.text, c.search_complete) for c in cli_candidates] == [(source, False)] + assert [(c.artifact.s, c.search_complete) for c in cli_candidates] == [(source, False)] diff --git a/tests/test_crc.py b/tests/test_crc.py index 84f590b..415b656 100644 --- a/tests/test_crc.py +++ b/tests/test_crc.py @@ -6,7 +6,7 @@ from data.bip93_vectors import VECTOR_2, VECTOR_4 import codex32 -from codex32 import MasterSeed, Share, parse_codex32 +from codex32 import MasterSeed, parse_codex32 from codex32.bip93 import SEED_BYTE_LENGTHS, _has_generation_padding from codex32.checksums import _CRC, _crc_pad from codex32.gf32 import _multiply as _gf32_multiply @@ -51,9 +51,9 @@ def test_parsed_master_seed_validity_is_independent_of_crc() -> None: generated = MasterSeed.from_seed(bytes.fromhex(VECTOR_4["secret_hex"]), identifier="leet") parsed = [parse_codex32(VECTOR_4[f"secret_s_alternate_{padding}"]) for padding in range(16)] assert all(isinstance(secret, MasterSeed) for secret in parsed) - assert len({secret.payload_symbols for secret in parsed}) == 16 + assert len({secret.payload for secret in parsed}) == 16 assert sum(_has_generation_padding(secret) for secret in parsed) == 1 - assert generated.payload_symbols in {secret.payload_symbols for secret in parsed} + assert generated.payload in {secret.payload for secret in parsed} def test_padding_acceptance_is_balanced_for_every_nonzero_lagrange_weight() -> None: @@ -72,7 +72,7 @@ def test_padding_acceptance_is_balanced_for_every_nonzero_lagrange_weight() -> N def test_crc_never_becomes_share_or_public_api_semantics() -> None: share = parse_codex32(VECTOR_2["share_A"]) - assert isinstance(share, Share) + assert share.share_idx != "s" assert not hasattr(share, "seed_bytes") assert not hasattr(share, "padding") assert not hasattr(share, "crc") diff --git a/tests/test_generation.py b/tests/test_generation.py index 3c7113c..e80929f 100644 --- a/tests/test_generation.py +++ b/tests/test_generation.py @@ -12,9 +12,9 @@ import codex32.generation as generation_module from codex32 import ( + Codex32String, CreationCeremony, MasterSeed, - Share, generate_master_seed, parse_codex32, recover_secret, @@ -32,7 +32,7 @@ from tools._wallet_test_vectors import FIXTURE_SEED, core_fingerprint, stub_fingerprint -def _complete(ceremony: CreationCeremony) -> tuple[MasterSeed, tuple[Share, ...]]: +def _complete(ceremony: CreationCeremony) -> tuple[MasterSeed, tuple[Codex32String, ...]]: shares = [] while True: try: @@ -41,12 +41,12 @@ def _complete(ceremony: CreationCeremony) -> tuple[MasterSeed, tuple[Share, ...] assert "all cards are confirmed" in str(error) break shares.append(share) - assert ceremony.confirm(share.text.upper()).accepted + assert ceremony.confirm(share.s.upper()).accepted return ceremony.finish(), tuple(shares) -def _indices(shares: tuple[Share, ...]) -> str: - return "".join(share.header.index for share in shares) +def _indices(shares: tuple[Codex32String, ...]) -> str: + return "".join(share.share_idx for share in shares) def _seed(byte_length: int) -> bytes: @@ -56,9 +56,9 @@ def _seed(byte_length: int) -> bytes: def test_fresh_unshared_ms_supports_every_bip93_size() -> None: for byte_length in SEED_BYTE_LENGTHS: secret = generate_master_seed(byte_length=byte_length, fingerprint=stub_fingerprint) - assert len(secret.seed_bytes) == byte_length - assert secret.header.threshold == 0 - assert secret.header.identifier == _fingerprint_identifier(stub_fingerprint(secret.seed_bytes)) + assert len(secret.data) == byte_length + assert secret.k == "0" + assert secret.ident == _fingerprint_identifier(stub_fingerprint(secret.data)) assert _has_generation_padding(secret) @@ -86,7 +86,7 @@ def test_fresh_shared_ms_supports_every_bip93_size() -> None: @pytest.mark.parametrize("threshold", range(2, 10)) def test_fresh_generation_recovers_at_every_threshold(threshold: int) -> None: secret, shares = _complete(CreationCeremony.master_seed(threshold=threshold, share_count=threshold)) - assert len({share.header.index for share in shares}) == threshold + assert len({share.share_idx for share in shares}) == threshold assert recover_secret(shares) == secret @@ -99,7 +99,7 @@ def test_generated_subsets_recover_across_sizes_and_threshold_boundaries( ) secret, shares = _complete(ceremony) - assert {recover_secret(subset).text for subset in combinations(shares, threshold)} == {secret.text} + assert {recover_secret(subset).s for subset in combinations(shares, threshold)} == {secret.s} @given( @@ -114,7 +114,7 @@ def test_supplied_seed_round_trip(byte_length: int, threshold: int) -> None: source, threshold=threshold, indices=ORDINARY_INDICES[:threshold], identifier="name" ) ) - assert isinstance(secret, MasterSeed) and secret.seed_bytes == _seed(byte_length) + assert isinstance(secret, MasterSeed) and secret.data == _seed(byte_length) assert recover_secret(shares) == secret @@ -134,8 +134,8 @@ def test_raw_bytes_accept_random_or_explicit_identifiers() -> None: generate_master_seed(raw, byte_length=16, identifier="test") random_secret = generate_master_seed(raw) secret = generate_master_seed(raw, identifier="TEST") - assert len(random_secret.header.identifier) == 4 - assert secret.header.identifier == "test" + assert len(random_secret.ident) == 4 + assert secret.ident == "test" def test_supplied_seed_must_form_a_valid_bip32_root(monkeypatch: pytest.MonkeyPatch) -> None: @@ -226,29 +226,29 @@ def recorded(length: int) -> bytes: assert calls == [26] with pytest.raises(CeremonyStateError, match="pending"): ceremony.next_share() - damaged = first.text[:4] + ("q" if first.text[4] != "q" else "p") + first.text[5:] + damaged = first.s[:4] + ("q" if first.s[4] != "q" else "p") + first.s[5:] result = ceremony.confirm(damaged) assert not result.accepted and result.mismatched_groups == (2,) - dropped = ceremony.confirm(first.text[:9] + first.text[10:]) + dropped = ceremony.confirm(first.s[:9] + first.s[10:]) # A dropped character marks only its own group, not every later group. assert not dropped.accepted and dropped.mismatched_groups == (3,) - assert "".join(dropped.entered_groups) == first.text[:9] + first.text[10:] + assert "".join(dropped.entered_groups) == first.s[:9] + first.s[10:] assert calls == [26] - assert ceremony.confirm(" \n".join(first.text.upper())).accepted + assert ceremony.confirm(" \n".join(first.s.upper())).accepted second = ceremony.next_share() assert len(calls) >= 2 and all(length == 26 for length in calls) - assert ceremony.confirm(second.text).accepted + assert ceremony.confirm(second.s).accepted calls_after_basis = len(calls) derived = ceremony.next_share() assert len(calls) == calls_after_basis - assert ceremony.confirm(derived.text).accepted + assert ceremony.confirm(derived.s).accepted ceremony.finish() def test_final_share_rejection_retains_confirmed_prefix(monkeypatch: pytest.MonkeyPatch) -> None: ceremony = CreationCeremony.master_seed(threshold=2, indices="ac", identifier="test") first = ceremony.next_share() - assert ceremony.confirm(first.text).accepted + assert ceremony.confirm(first.s).accepted original = ceremony._candidate_is_accepted checks = 0 @@ -265,7 +265,7 @@ def reject_once(_self: CreationCeremony, secret: object) -> bool: calls = 0 original_random = generation_module._random_share - def recorded(*args: object, **kwargs: object) -> Share: + def recorded(*args: object, **kwargs: object) -> Codex32String: nonlocal calls calls += 1 return original_random(*args, **kwargs) # type: ignore[arg-type] @@ -273,7 +273,7 @@ def recorded(*args: object, **kwargs: object) -> Share: monkeypatch.setattr(generation_module, "_random_share", recorded) second = ceremony.next_share() assert calls >= 2 - assert ceremony.confirm(second.text).accepted + assert ceremony.confirm(second.s).accepted secret = ceremony.finish() assert recover_secret((first, second)) == secret @@ -281,7 +281,7 @@ def recorded(*args: object, **kwargs: object) -> Share: def test_final_ms_share_retries_a_bip32_invalid_root(monkeypatch: pytest.MonkeyPatch) -> None: ceremony = CreationCeremony.master_seed(threshold=2, indices="ac", identifier="test") first = ceremony.next_share() - assert ceremony.confirm(first.text).accepted + assert ceremony.confirm(first.s).accepted original = generation_module._valid_root checks = 0 @@ -294,7 +294,7 @@ def reject_once(seed: bytes) -> bool: monkeypatch.setattr(generation_module, "_valid_root", reject_once) second = ceremony.next_share() - assert checks >= 2 and ceremony.confirm(second.text).accepted + assert checks >= 2 and ceremony.confirm(second.s).accepted assert recover_secret((first, second)) == ceremony.finish() @@ -307,7 +307,7 @@ def test_ceremony_state_is_fail_closed() -> None: shares = [] for _ in range(2): shares.append(ceremony.next_share()) - assert ceremony.confirm(shares[-1].text).accepted + assert ceremony.confirm(shares[-1].s).accepted secret = ceremony.finish() assert recover_secret(shares) == secret assert ceremony._basis == [] and ceremony._indices == () and ceremony._secret is None @@ -320,7 +320,7 @@ def test_ceremony_state_is_fail_closed() -> None: def test_ceremony_cannot_be_copied_or_serialized() -> None: ceremony = CreationCeremony.master_seed(threshold=2, indices="ac", identifier="test") pending = ceremony.next_share() - assert ceremony.confirm(pending.text).accepted + assert ceremony.confirm(pending.s).accepted for operation in ( lambda: copy.copy(ceremony), @@ -337,12 +337,12 @@ def test_resharing_preserves_padding_and_requires_a_new_header() -> None: secret, shares = _complete( CreationCeremony.from_secret(source, threshold=2, indices="ac", identifier="name") ) - assert secret.payload_symbols == source.payload_symbols - assert recover_secret(shares).payload_symbols == source.payload_symbols + assert secret.payload == source.payload + assert recover_secret(shares).payload == source.payload with pytest.raises(HeaderCollision): CreationCeremony.from_secret(secret, threshold=2, indices="ac", identifier="name") randomized = CreationCeremony.from_secret(secret, threshold=2, indices="ac") - assert randomized.next_share().header.identifier != secret.header.identifier + assert randomized.next_share().ident != secret.ident def test_from_secret_rejects_non_secret_artifacts() -> None: diff --git a/tests/test_generic_hrp.py b/tests/test_generic_hrp.py index 9529f4d..f5452a3 100644 --- a/tests/test_generic_hrp.py +++ b/tests/test_generic_hrp.py @@ -9,10 +9,9 @@ from _codex32_oracle import oracle_encode from codex32 import ( + Codex32String, CorrectionContext, MasterSeed, - Secret, - Share, correct, derive_share, parse_codex32, @@ -43,13 +42,13 @@ def test_opaque_hrp_parse_complete_recover_and_derive(vector: dict[str, str]) -> secret = parse_codex32(vector["S"].upper()) a = parse_codex32(vector["A"].upper()) c = parse_codex32(vector["C"].upper()) - assert isinstance(secret, Secret) and type(secret) is Secret - assert isinstance(a, Share) and isinstance(c, Share) + assert type(secret) is Codex32String and secret.share_idx == "s" + assert a.share_idx != "s" and c.share_idx != "s" assert secret.hrp == a.hrp == c.hrp == "zz" assert not isinstance(secret, MasterSeed) - assert secret.text.isupper() - assert recover_secret([a, c]).text == vector["S"].upper() - assert derive_share([secret, a], "d").text == vector["D"].upper() + assert secret.s.isupper() + assert recover_secret([a, c]).s == vector["S"].upper() + assert derive_share([secret, a], "d").s == vector["D"].upper() def test_sharing_compares_normalized_hrp() -> None: @@ -69,7 +68,7 @@ def test_unknown_hrp_correction_freezes_namespace_and_repairs_structure() -> Non group_extra = source[:20] + "qpzr" + source[20:] for damaged in (substituted, omitted, erased, group_omitted, extra, group_extra): candidates = correct(CorrectionContext("ZZ"), damaged) - assert candidates and candidates[0].artifact.text == source + assert candidates and candidates[0].artifact.s == source assert correct(CorrectionContext("zz"), "yy1" + source[3:]) == () @@ -103,7 +102,7 @@ def test_cli_split_and_unknown_neutral_summary() -> None: assert _invoke(main, ["create"])[0] == 2 assert _invoke(ms_main, ["check"], UNKNOWN["short"]["C"])[0] == 2 unshared = parse_codex32(oracle_encode("zz", "0tests" + "q" * 26)) - assert _invoke(main, ["check"], unshared.text)[1] == ( + assert _invoke(main, ["check"], unshared.s)[1] == ( "Valid unshared codex32 secret.\nHRP: ZZ\nBackup identifier: TEST\n" ) diff --git a/tests/test_lengths.py b/tests/test_lengths.py index 802c0eb..fe07949 100644 --- a/tests/test_lengths.py +++ b/tests/test_lengths.py @@ -47,24 +47,24 @@ def test_every_supported_ms_size_and_legal_parsed_padding() -> None: text = _oracle_encode("ms", "0tests" + _payload(data, padding)) secret = parse_codex32(text) assert isinstance(secret, MasterSeed) - assert secret.seed_bytes == data + assert secret.data == data 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 tuple(len(artifact.s) 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.s)[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].s)[1])) is _CODEX32_LONG @pytest.mark.parametrize(("seed_hex", "text"), BIP93_ADDITIONAL_MASTER_SEEDS) def test_pr2258_supported_size_vectors(seed_hex: str, text: str) -> None: artifact = parse_codex32(text) assert isinstance(artifact, MasterSeed) - assert artifact.seed_bytes == bytes.fromhex(seed_hex) + assert artifact.data == bytes.fromhex(seed_hex) @pytest.mark.parametrize("text", FORMERLY_VALID_UNSUPPORTED_MS) @@ -187,3 +187,9 @@ def test_official_invalid_generic_checksum_vectors( return expanded_length = len(bech32_hrp_expand(hrp)) + len(encoded) assert not (expanded_length <= maximum and checksum.verify(bech32_hrp_expand(hrp) + encoded)) + + +@pytest.mark.parametrize("value", ("abcdefgh1qqqqqqqqqqqqq", "a" * 20 + "1")) +def test_long_hrp_without_a_full_header_is_rejected(value: str) -> None: + with pytest.raises(InvalidLength, match="six symbols"): + parse_codex32(value) diff --git a/tests/test_public_api.py b/tests/test_public_api.py index 71104e2..57e7231 100644 --- a/tests/test_public_api.py +++ b/tests/test_public_api.py @@ -12,15 +12,15 @@ import codex32 from codex32 import ( + Codex32String, CorrectionCandidate, CorrectionContext, - Header, MasterSeed, - Share, correct, correct_worksheet_residue, parse_codex32, ) +from codex32.bip93 import _header from codex32.errors import CodexError, InvalidIdentifier, InvalidShareIndex, InvalidThreshold @@ -32,13 +32,13 @@ def test_backup_workflows_need_only_the_standard_library() -> None: shares = [] for _ in range(2): share = ceremony.next_share() - assert ceremony.confirm(share.text).accepted + assert ceremony.confirm(share.s).accepted shares.append(share) secret = ceremony.finish() -assert parse_codex32(secret.text) == secret +assert parse_codex32(secret.s) == secret assert recover_secret(shares) == secret -assert derive_share(shares, "d").header.index == "d" -damaged = shares[0].text[:-1] + "?" +assert derive_share(shares, "d").share_idx == "d" +damaged = shares[0].s[:-1] + "?" assert correct(CorrectionContext("ms"), damaged) """ environment = os.environ.copy() @@ -70,8 +70,8 @@ def test_checksum_completion_is_not_public_api() -> None: def test_share_has_symbols_but_no_byte_or_padding_api() -> None: share = parse_codex32(VECTOR_2["share_A"]) - assert isinstance(share, Share) - assert isinstance(share.payload_symbols, tuple) + assert share.share_idx != "s" + assert isinstance(share.payload, str) for name in ( "data", "seed_bytes", @@ -86,28 +86,28 @@ def test_share_has_symbols_but_no_byte_or_padding_api() -> None: def test_artifacts_are_validated_strings_and_cannot_be_mutated() -> None: share = parse_codex32(VECTOR_2["share_A"]) secret = parse_codex32(VECTOR_1["secret_s"]) - assert Share(share.text) == share and MasterSeed(secret.text) == secret + assert Codex32String(share.s) == share and MasterSeed(secret.s) == secret with pytest.raises(TypeError): - MasterSeed(share.text) - with pytest.raises(TypeError): - Share(secret.text) + MasterSeed(share.s) with pytest.raises(CodexError): - MasterSeed(secret.text[:-1] + ("q" if secret.text[-1] != "q" else "p")) - with pytest.raises((FrozenInstanceError, AttributeError)): - share.text = "changed" # type: ignore[misc] + MasterSeed(secret.s[:-1] + ("q" if secret.s[-1] != "q" else "p")) with pytest.raises((FrozenInstanceError, AttributeError)): - share.header.identifier = "leak" # type: ignore[misc] + share.s = "changed" # type: ignore[misc] + # Before Python 3.13, slotted frozen dataclasses raise TypeError for non-field names. + with pytest.raises((FrozenInstanceError, AttributeError, TypeError)): + share.ident = "leak" # type: ignore[misc] + assert share.ident == "name" def test_artifact_default_rendering_does_not_disclose_recovery_text() -> None: share = parse_codex32(VECTOR_2["share_A"]) candidate = CorrectionCandidate(share, (), 1, 0, 0, None) - assert share.text not in str(share) - assert share.text not in repr(share) - assert share.text not in repr(candidate) - assert str(share) == "" - assert repr(share) == "Share()" + assert share.s not in str(share) + assert share.s not in repr(share) + assert share.s not in repr(candidate) + assert str(share) == "" + assert repr(share) == "Codex32String()" def test_correction_edits_do_not_render_recovery_characters() -> None: @@ -126,17 +126,17 @@ def test_correction_edits_do_not_render_recovery_characters() -> None: def test_master_seed_default_rendering_does_not_disclose_seed_material() -> None: secret = MasterSeed.from_seed(bytes(range(16)), identifier="test") - assert secret.text not in str(secret) - assert secret.text not in repr(secret) - assert "payload_symbols" not in repr(secret) + assert secret.s not in str(secret) + assert secret.s not in repr(secret) + assert "payload" not in repr(secret) assert str(secret) == "" assert repr(secret) == "MasterSeed()" def test_master_seed_factory_can_only_construct_index_s() -> None: secret = MasterSeed.from_seed(bytes(16), identifier="test", threshold=2) - assert secret.header.index == "s" - assert secret.seed_bytes == bytes(16) + assert secret.share_idx == "s" + assert secret.data == bytes(16) @pytest.mark.parametrize( @@ -150,4 +150,4 @@ def test_master_seed_factory_can_only_construct_index_s() -> None: ) def test_header_invariants(arguments: tuple[object, ...], error: type[Exception]) -> None: with pytest.raises(error): - Header(*arguments) # type: ignore[arg-type] + _header(*arguments) diff --git a/tests/test_sharing.py b/tests/test_sharing.py index a63e6a9..022b63a 100644 --- a/tests/test_sharing.py +++ b/tests/test_sharing.py @@ -10,13 +10,13 @@ from hypothesis import strategies as st from codex32 import ( + Codex32String, MasterSeed, - Share, derive_share, parse_codex32, recover_secret, ) -from codex32.bech32 import CHARSET, _u5_to_chars +from codex32.bech32 import CHARSET from codex32.bip93 import IDX_SORT, SEED_BYTE_LENGTHS from codex32.checksums import _Checksum from codex32.errors import ( @@ -32,8 +32,8 @@ ) -def _payload_text(artifact: Share | MasterSeed) -> str: - return _u5_to_chars(artifact.payload_symbols) +def _payload_text(artifact: Codex32String) -> str: + return artifact.payload def _ms_basis(byte_length: int = 16, threshold: int = 2): @@ -41,15 +41,15 @@ def _ms_basis(byte_length: int = 16, threshold: int = 2): secret = MasterSeed.from_seed(seed, identifier="test", threshold=threshold) masks = [ parse_codex32( - oracle_encode("ms", f"{threshold}test{index}" + CHARSET[offset + 1] * len(secret.payload_symbols)) + oracle_encode("ms", f"{threshold}test{index}" + CHARSET[offset + 1] * len(secret.payload)) ) for offset, index in enumerate(IDX_SORT[1:threshold]) ] - assert all(isinstance(mask, Share) for mask in masks) + assert all(mask.share_idx != "s" for mask in masks) return secret, masks -def _ordinary_set(byte_length: int = 16, threshold: int = 2) -> tuple[Share, ...]: +def _ordinary_set(byte_length: int = 16, threshold: int = 2) -> tuple[Codex32String, ...]: secret, masks = _ms_basis(byte_length, threshold) basis = [secret, *masks] fresh = derive_share(basis, IDX_SORT[threshold]) @@ -59,10 +59,10 @@ def _ordinary_set(byte_length: int = 16, threshold: int = 2) -> tuple[Share, ... def test_official_vector_2_recovers_derives_and_preserves_uppercase() -> None: a = parse_codex32(VECTOR_2["share_A"]) c = parse_codex32(VECTOR_2["share_C"]) - assert isinstance(a, Share) and isinstance(c, Share) - assert recover_secret([a, c]).text == VECTOR_2["secret_S"] - assert derive_share([a, c], "D").text == VECTOR_2["derived_D"] - assert derive_share([c, a], "D").text == VECTOR_2["derived_D"] + assert a.share_idx != "s" and c.share_idx != "s" + assert recover_secret([a, c]).s == VECTOR_2["secret_S"] + assert derive_share([a, c], "D").s == VECTOR_2["derived_D"] + assert derive_share([c, a], "D").s == VECTOR_2["derived_D"] def test_official_vector_3_recovers_and_derives() -> None: @@ -70,10 +70,10 @@ def test_official_vector_3_recovers_and_derives() -> None: a = parse_codex32(VECTOR_3["share_a"]) c = parse_codex32(VECTOR_3["share_c"]) d = parse_codex32(VECTOR_3["derived_d"]) - assert all(isinstance(item, Share) for item in (a, c, d)) - assert recover_secret([a, c, d]).text == VECTOR_3["secret_s"] + assert all(item.share_idx != "s" for item in (a, c, d)) + assert recover_secret([a, c, d]).s == VECTOR_3["secret_s"] for index in "def": - assert derive_share([secret, a, c], index).text == VECTOR_3[f"derived_{index}"] + assert derive_share([secret, a, c], index).s == VECTOR_3[f"derived_{index}"] def test_interpolation_does_not_create_a_checksum( @@ -88,8 +88,8 @@ def fail_create(*_args: object, **_kwargs: object) -> tuple[int, ...]: 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 - assert parse_codex32(derived.text) == derived + assert parse_codex32(recovered.s) == recovered + assert parse_codex32(derived.s) == derived @pytest.mark.parametrize("threshold", range(2, 10)) @@ -97,7 +97,7 @@ def test_every_threshold_recovers_exactly_k_shares(threshold: int) -> None: shares = _ordinary_set(threshold=threshold) secret = recover_secret(shares) assert isinstance(secret, MasterSeed) - assert secret.seed_bytes == _ms_basis(threshold=threshold)[0].seed_bytes + assert secret.data == _ms_basis(threshold=threshold)[0].data with pytest.raises(WrongShareCount): recover_secret(shares[:-1]) if threshold < 9: @@ -110,20 +110,20 @@ def test_every_threshold_recovers_exactly_k_shares(threshold: int) -> None: def test_share_set_mismatches_have_distinct_errors() -> None: ms_secret, ms_masks = _ms_basis() - opaque = parse_codex32(oracle_encode("xy", "2testa" + "q" * len(ms_secret.payload_symbols))) + opaque = parse_codex32(oracle_encode("xy", "2testa" + "q" * len(ms_secret.payload))) with pytest.raises(MismatchedHrp): derive_share([ms_secret, opaque], "c") # type: ignore[list-item] - threshold_three = parse_codex32(oracle_encode("ms", "3testc" + "q" * len(ms_secret.payload_symbols))) + threshold_three = parse_codex32(oracle_encode("ms", "3testc" + "q" * len(ms_secret.payload))) with pytest.raises(MismatchedThreshold): derive_share([ms_secret, threshold_three], "d") # type: ignore[list-item] - other_id = parse_codex32(oracle_encode("ms", "2namec" + "q" * len(ms_secret.payload_symbols))) + other_id = parse_codex32(oracle_encode("ms", "2namec" + "q" * len(ms_secret.payload))) with pytest.raises(MismatchedIdentifier): derive_share([ms_secret, other_id], "d") # type: ignore[list-item] longer = MasterSeed.from_seed(bytes(20), identifier="test", threshold=2) - longer_mask = parse_codex32(oracle_encode("ms", "2testc" + "q" * len(longer.payload_symbols))) + longer_mask = parse_codex32(oracle_encode("ms", "2testc" + "q" * len(longer.payload))) with pytest.raises(MismatchedPayloadLength): derive_share([ms_secret, longer_mask], "d") # type: ignore[list-item] @@ -158,8 +158,8 @@ def test_every_ms_size_recovers_with_short_and_long_checksums() -> None: recovered = recover_secret(shares) expected = _ms_basis(byte_length)[0] assert isinstance(recovered, MasterSeed) - assert recovered.seed_bytes == expected.seed_bytes - assert len(recovered.payload_symbols) == len(expected.payload_symbols) + assert recovered.data == expected.data + assert len(recovered.payload) == len(expected.payload) def test_every_ordinary_index_can_be_a_fresh_target() -> None: @@ -167,9 +167,9 @@ def test_every_ordinary_index_can_be_a_fresh_target() -> None: ordinary = tuple(character for character in CHARSET if character != "s") for target in ordinary: mask_index = next(index for index in ordinary if index != target) - mask = parse_codex32(oracle_encode("ms", f"2test{mask_index}" + "p" * len(secret.payload_symbols))) + mask = parse_codex32(oracle_encode("ms", f"2test{mask_index}" + "p" * len(secret.payload))) derived = derive_share([secret, mask], target) # type: ignore[list-item] - assert derived.header.index == target + assert derived.share_idx == target @pytest.mark.parametrize("profile", ("bip39_12w", "bip39_24w")) @@ -178,36 +178,36 @@ def test_frozen_profile_sharing_vectors(profile: str) -> None: secret = parse_codex32(vector["S"]) a = parse_codex32(vector["A"]) c = parse_codex32(vector["C"]) - assert isinstance(a, Share) and isinstance(c, Share) - assert recover_secret([a, c]).text == vector["S"] - assert derive_share([secret, a], "c").text == vector["C"] # type: ignore[list-item] - assert derive_share([a, c], "d").text == vector["D"] + assert a.share_idx != "s" and c.share_idx != "s" + assert recover_secret([a, c]).s == vector["S"] + assert derive_share([secret, a], "c").s == vector["C"] # type: ignore[list-item] + assert derive_share([a, c], "d").s == vector["D"] @given(st.sampled_from(SEED_BYTE_LENGTHS), st.booleans()) @settings(max_examples=35, deadline=None) def test_order_and_case_properties(byte_length: int, uppercase: bool) -> None: shares = _ordinary_set(byte_length) - rendered = [parse_codex32(item.text.upper() if uppercase else item.text) for item in shares] - outputs = [recover_secret(list(order)).text for order in permutations(rendered)] + rendered = [parse_codex32(item.s.upper() if uppercase else item.s) for item in shares] + outputs = [recover_secret(list(order)).s for order in permutations(rendered)] assert len(set(outputs)) == 1 assert outputs[0].isupper() is uppercase def test_mixed_case_sets_emit_lowercase_and_replacement_preserves_secret() -> None: secret, masks = _ms_basis(threshold=3) - mixed_basis = [parse_codex32(secret.text.upper()), masks[0], masks[1]] + mixed_basis = [parse_codex32(secret.s.upper()), masks[0], masks[1]] derived = derive_share(mixed_basis, "d") - assert derived.text.islower() + assert derived.s.islower() recovered_before = recover_secret([masks[0], masks[1], derived]) replacement = derive_share([secret, masks[0], derived], "e") recovered_after = recover_secret([masks[0], derived, replacement]) - assert recovered_before.text == recovered_after.text == secret.text + assert recovered_before.s == recovered_after.s == secret.s def test_any_exact_threshold_subset_recovers_the_same_secret() -> None: secret, masks = _ms_basis(threshold=3) basis = [secret, *masks] ordinary = [*masks, *(derive_share(basis, index) for index in "def")] - recovered = {recover_secret(list(subset)).text for subset in combinations(ordinary, 3)} - assert recovered == {secret.text} + recovered = {recover_secret(list(subset)).s for subset in combinations(ordinary, 3)} + assert recovered == {secret.s} diff --git a/tools/bitcoin_core_regtest.py b/tools/bitcoin_core_regtest.py index bb5bc6d..bb7de47 100644 --- a/tools/bitcoin_core_regtest.py +++ b/tools/bitcoin_core_regtest.py @@ -131,7 +131,7 @@ def rpc(*rpc_arguments: str, wallet: str | None = None, stdin: str | None = None for seed, expected_fingerprint in CORE_FINGERPRINTS.items(): if client.fingerprint_seed(seed) != expected_fingerprint: raise RuntimeError("Bitcoin Core fingerprint fixture mismatch") - expected_fingerprint = CORE_FINGERPRINTS[secret.seed_bytes] + expected_fingerprint = CORE_FINGERPRINTS[secret.data] answers = iter(("yes",)) if ( client.initialize( diff --git a/tools/differential_correction.py b/tools/differential_correction.py index 8c0dd26..527f1a9 100644 --- a/tools/differential_correction.py +++ b/tools/differential_correction.py @@ -37,7 +37,7 @@ def main() -> None: if expected is None: if result is not None: raise SystemExit(f"case {checked}: expected no correction") - elif not (isinstance(result, CorrectionCandidate) and result.artifact.text == expected): + elif not (isinstance(result, CorrectionCandidate) and result.artifact.s == expected): raise SystemExit(f"case {checked}: differential mismatch") checked += 1 print(f"verified {checked} frozen PR #70 correction cases")