From 0006b34bc4b102fd1ac6a5797ac8f16f7d2665ae Mon Sep 17 00:00:00 2001 From: Ben Westgate Date: Tue, 29 Sep 2026 09:36:19 -0500 Subject: [PATCH 01/16] correct: Interpret mixed-case damage Apply the established majority-case interpretation to standalone correction while preserving immutable context, entered edit semantics, and disclosure accounting. Account the normalized retry frontier even when the first optional search reaches its deadline after finding a candidate, so cumulative capture mass remains fail-closed. Normalize ordinary grouping spaces before locating an immutable prefix in the public API, so grouped input cannot shift the mixed-case boundary into a locked header. Report truncation from the shared mixed-case schedule: combine both full passes' completeness, and mark returned candidates search_complete=False when either pass truncated. The deadline regressions cover a string only the erasure reading corrects (five minority-case P) and one only case normalization corrects (fifteen minority-case X, one mistyped); both recover within ten seconds while the normalized exhaustive optional search may truncate. Fixes #37. --- src/codex32/_cli_input.py | 112 +++++++++++----- src/codex32/_competitors.py | 4 +- src/codex32/bech32.py | 16 +++ src/codex32/cli.py | 35 +++-- src/codex32/correction.py | 134 ++++++++++++++++--- src/codex32/indel.py | 81 ++++++++++-- tests/data/malformed_inputs.json | 1 - tests/test_cli.py | 142 +++++++++++++++++++- tests/test_correction_bch.py | 194 +++++++++++++++++++++++++++- tests/test_correction_disclosure.py | 25 +++- tests/test_mixed_case_deadline.py | 82 ++++++++++++ 11 files changed, 743 insertions(+), 83 deletions(-) create mode 100644 tests/test_mixed_case_deadline.py diff --git a/src/codex32/_cli_input.py b/src/codex32/_cli_input.py index c6e65de..e711ce7 100644 --- a/src/codex32/_cli_input.py +++ b/src/codex32/_cli_input.py @@ -6,10 +6,13 @@ import difflib import os import sys -from collections.abc import Callable, Iterator +from collections.abc import Callable, Iterator, Sequence +from dataclasses import replace +from functools import partial from time import monotonic from typing import Any, Literal, cast +from codex32.bech32 import interpret_mixed_case from codex32.bip93 import ( Secret, Share, @@ -19,7 +22,7 @@ parse_codex32, recover_secret, ) -from codex32.correction import CorrectionCandidate, CorrectionContext, _best +from codex32.correction import CorrectionCandidate, CorrectionContext, _best, _capture_mass from codex32.errors import ( CodexError, DuplicateShareIndex, @@ -377,15 +380,14 @@ def _case_interpretation( profiles: tuple[Profile, ...] | None, allowed: Callable[[CorrectionCandidate], bool] | None, ) -> tuple[CorrectionCandidate | None, str, str, str] | None: - """Normalize likely casing and mark contrary-case data as erasures.""" - if value.upper() == value or value.lower() == value: - return None + # Normalize likely casing and mark contrary-case data as erasures. separator = value.find("1") base_length = separator + 1 if separator >= 0 else 0 immutable_length = len(prefix) if prefix and value.lower().startswith(prefix.lower()) else base_length - letters = [character for character in value[immutable_length:] if character.lower() != character.upper()] - uppercase = sum(character.isupper() for character in letters) > len(letters) / 2 - corrected = value.upper() if uppercase else value.lower() + interpretation = interpret_mixed_case(value, immutable_length) + if interpretation is None: + return None + corrected, erased, uppercase = interpretation corrected_prefix = prefix.upper() if uppercase else prefix.lower() try: artifact = _parse(corrected, profiles) @@ -397,14 +399,6 @@ def _case_interpretation( ) proposed = CorrectionCandidate(artifact, (), 1, 0, 0, None, capture_space_bits=bits) candidate = proposed if allowed is None or allowed(proposed) else None - erased = "".join( - corrected[index] - if index < immutable_length - or character.lower() == character.upper() - or character.isupper() == uppercase - else "?" - for index, character in enumerate(value) - ) return candidate, corrected, erased, corrected_prefix @@ -460,7 +454,9 @@ def _correction_candidates( deadline: float | None = None, capture_layers: list[tuple[int, int]] | None = None, fingerprint_match: Callable[[CorrectionCandidate], bool | None] | None = None, -) -> tuple[tuple[CorrectionCandidate, ...], bool, float | None, bool]: + seed_candidates: Sequence[CorrectionCandidate] = (), + required_only: bool = False, +) -> tuple[tuple[CorrectionCandidate, ...], bool, float, bool]: count = len(value.replace(" ", "")) targets, primary, reduced, _timed = _correction_plan(profile, byte_length, count, target) deadline = monotonic() + 10 if deadline is None else deadline @@ -476,6 +472,8 @@ def _correction_candidates( competitors=True, allowed=allowed, capture_layers=capture_layers, + seed_candidates=seed_candidates, + required_only=required_only, ) if allowed is not None: candidates = tuple(candidate for candidate in candidates if allowed(candidate)) @@ -489,6 +487,66 @@ def _correction_candidates( return results, complete, deadline, False +def _scheduled_candidates( + value: str, + erased: str, + profile: str | Profile, + byte_length: int | Literal["?"] | None, + immutable: str, + excluded: tuple[str, ...] = (), + *, + target: int | None = None, + allowed: Callable[[CorrectionCandidate], bool] | None = None, + deadline: float | None = None, + fingerprint_match: Callable[[CorrectionCandidate], bool | None] | None = None, +) -> tuple[tuple[CorrectionCandidate, ...], bool, bool]: + """Search both case interpretations under one deadline and capture ledger.""" + search = partial( + _correction_candidates, + profile=profile, + byte_length=byte_length, + immutable=immutable, + excluded=excluded, + target=target, + allowed=allowed, + fingerprint_match=fingerprint_match, + ) + first, retry = (erased, value) if erased != value else (value, None) + seeded: tuple[CorrectionCandidate, ...] = () + if retry is not None: + # Find required candidates for both case interpretations before either + # full search can spend the shared deadline on optional alignment. + # These discovery passes deliberately do not charge capture_layers; + # the full searches below account each admitted frontier once. + for required_value in (first, retry): + seeded, complete, deadline, _ = search( + required_value, deadline=deadline, seed_candidates=seeded, required_only=True + ) + if not complete: + return (), False, False + capture_layers: list[tuple[int, int]] = [] + candidates, complete, deadline, ambiguous = search( + first, deadline=deadline, capture_layers=capture_layers, seed_candidates=seeded + ) + if retry is None: + return candidates, complete, ambiguous + retry_candidates, retry_complete, _deadline, retry_ambiguous = search( + retry, deadline=deadline, capture_layers=capture_layers, seed_candidates=(*seeded, *candidates) + ) + complete = complete and retry_complete + annotated = [] + for item in (*candidates, *retry_candidates): + volume, bits = _capture_mass(capture_layers, item.capture_volume) + annotated.append(replace(item, cumulative_capture_volume=volume, capture_space_bits=bits)) + unique: dict[str, CorrectionCandidate] = {} + for item in _best(annotated, prefer_common=byte_length == "?", fingerprint_match=fingerprint_match): + # A copy from a completed earlier pass must not hide later truncation. + unique.setdefault( + item.artifact.text.lower(), item if complete else replace(item, search_complete=False) + ) + return tuple(unique.values()), complete, ambiguous or retry_ambiguous + + def _fingerprint_matcher( fingerprint: Callable[[MasterSeed], bytes] | None, ) -> Callable[[CorrectionCandidate], bool | None] | None: @@ -538,23 +596,8 @@ def _suggestions( if prefix and value.lower().startswith(prefix.lower()) else prefix or value[: separator + 1] ) - deadline = monotonic() + 10 - capture_layers: list[tuple[int, int]] = [] - candidates = _correction_candidates( + return _scheduled_candidates( value, - hrp, - None, - immutable, - excluded, - target=target, - allowed=allowed, - deadline=deadline, - capture_layers=capture_layers, - fingerprint_match=fingerprint_match, - )[0] - if candidates or erased == value: - return candidates - return _correction_candidates( erased, hrp, None, @@ -562,8 +605,7 @@ def _suggestions( excluded, target=target, allowed=allowed, - deadline=deadline, - capture_layers=capture_layers, + deadline=monotonic() + 10, fingerprint_match=fingerprint_match, )[0] diff --git a/src/codex32/_competitors.py b/src/codex32/_competitors.py index 7cf5539..50ccf78 100644 --- a/src/codex32/_competitors.py +++ b/src/codex32/_competitors.py @@ -173,8 +173,10 @@ def _search_competitors( frontier: dict[_Layer, int], deadline: float, allowed: Callable[[CorrectionCandidate], bool] | None, + *, + seed_candidates: Sequence[CorrectionCandidate] = (), ) -> tuple[tuple[CorrectionCandidate, ...], bool]: - results: dict[str, CorrectionCandidate] = {} + results = {candidate.artifact.text.lower(): candidate for candidate in seed_candidates} fixed: dict[int, CorrectionCandidate | None] = {} completed: set[_Layer] = set() try: diff --git a/src/codex32/bech32.py b/src/codex32/bech32.py index d36cf04..59befc7 100644 --- a/src/codex32/bech32.py +++ b/src/codex32/bech32.py @@ -53,6 +53,22 @@ def _validate_single_case_ascii(value: str) -> bool: return value.isupper() +def interpret_mixed_case(value: str, immutable_length: int) -> tuple[str, str, bool] | None: + # Return majority-cased and minority-erased interpretations of mixed-case text. + if not value.isascii() or value.upper() == value or value.lower() == value: + return None + letters = [character for character in value[immutable_length:] if character.isalpha()] + uppercase = sum(character.isupper() for character in letters) > len(letters) / 2 + normalized = value.upper() if uppercase else value.lower() + erased = "".join( + normalized[index] + if index < immutable_length or not character.isalpha() or character.isupper() == uppercase + else "?" + for index, character in enumerate(value) + ) + return normalized, erased, uppercase + + def bech32_encode(hrp: str, data: list[int], spec: _Checksum) -> str: """Compute a Bech32 string given HRP and data values.""" checksum = spec.create(bech32_hrp_expand(hrp) + list(data)) diff --git a/src/codex32/cli.py b/src/codex32/cli.py index 585a6dc..e0007b7 100644 --- a/src/codex32/cli.py +++ b/src/codex32/cli.py @@ -13,12 +13,13 @@ CorrectionDeclined, InteractiveConfirmationRequired, _card_text, + _case_interpretation, _confirm_correction, - _correction_candidates, _entered_groups, _fingerprint_matcher, _render_groups, _require_correction_confirmation, + _scheduled_candidates, _suggestions, ) from codex32._cli_input import InputError as _UsageError @@ -35,7 +36,12 @@ parse_codex32, recover_secret, ) -from codex32.correction import _best, _residue_low_discrimination, correct_worksheet_residue +from codex32.correction import ( + CorrectionCandidate, + _best, + _residue_low_discrimination, + correct_worksheet_residue, +) from codex32.errors import CodexError, HeaderCollision, InvalidCorrectionInput from codex32.generation import ( ConfirmationResult, @@ -550,12 +556,25 @@ def _correct( raise _UsageError("--bytes does not match the valid master-seed backup length.") _print("The codex32 string is already valid.") return 0 - candidates, complete, _deadline, ambiguous = _correction_candidates( - value, - hrp, - byte_length, - value[: separator + 1], - ) + search_value, erased, immutable = normalized, normalized, normalized[: separator + 1] + interpreted = _case_interpretation(normalized, immutable, context.profiles, None) + if interpreted is not None: + candidate, search_value, erased, immutable = interpreted + if ( + candidate is not None + and isinstance(byte_length, int) + and len(candidate.artifact.text) != _ms_text_length(byte_length) + ): + raise _UsageError("--bytes does not match the corrected master-seed backup length.") + else: + candidate = None + if candidate is not None: + candidates: tuple[CorrectionCandidate, ...] = (candidate,) + complete, ambiguous = True, False + else: + candidates, complete, ambiguous = _scheduled_candidates( + search_value, erased, hrp, byte_length, immutable + ) if not complete and not candidates: raise _CommandError("The correction search did not complete within ten seconds.") if ambiguous: diff --git a/src/codex32/correction.py b/src/codex32/correction.py index c30ce7c..9abd345 100644 --- a/src/codex32/correction.py +++ b/src/codex32/correction.py @@ -34,6 +34,7 @@ _u5_to_chars, _validate_single_case_ascii, bech32_hrp_expand, + interpret_mixed_case, ) from codex32.bip93 import ( IDX_SORT, @@ -839,6 +840,24 @@ def _primary( ) +def _restore_case_edits( + candidates: tuple[CorrectionCandidate, ...], *, uppercase: bool +) -> tuple[CorrectionCandidate, ...]: + # Hide erasures synthesized only to search minority-case symbols. + def restore(edit: CorrectionEdit) -> CorrectionEdit: + minority_case = edit.observed.isalpha() and edit.observed.isupper() != uppercase + kind = ( + "substitution" + if edit.kind == "erasure" and minority_case and edit.observed.lower() in CHARSET + else edit.kind + ) + return replace(edit, kind=kind) if kind != edit.kind else edit + + return tuple( + replace(candidate, edits=tuple(restore(edit) for edit in candidate.edits)) for candidate in candidates + ) + + def _best( candidates: Sequence[CorrectionCandidate], *, @@ -874,30 +893,103 @@ def _correct_complete( # displayed strings are no longer than the largest expanded codeword. if len(damaged_text) > 2 * (_LONG_SPEC.period + 8): return (), True - from codex32.indel import _search_many - deadline = monotonic() + 10 if deadline is None else deadline - contexts: tuple[CorrectionContext, ...] - if context.expected_length is not None: - contexts = (context,) + base = f"{context.hrp}1" + locked = context.immutable_prefix or base + # The search strips grouping spaces, so locate the immutable boundary in + # that same coordinate system before classifying minority-case symbols. + compacted = damaged_text.replace(" ", "") + immutable_length = len(locked) if compacted.lower().startswith(locked.lower()) else len(base) + interpretation = interpret_mixed_case(compacted, immutable_length) + inputs: tuple[tuple[CorrectionContext, str], ...] + if interpretation is None: + inputs = ((context, damaged_text),) else: - # Only lengths reachable by either disjoint family are eligible. - observed = len(damaged_text.replace(" ", "")) - contexts_list = [] - for target in sorted({observed + delta for delta in (*range(-4, 5), -8, 8)}): - candidate_context = replace(context, expected_length=target) - try: - _validate_context(candidate_context) - except InvalidCorrectionInput: - continue - contexts_list.append(candidate_context) - contexts = tuple(contexts_list) - return _search_many( - contexts, - damaged_text, - primary=frozenset(c.expected_length for c in contexts if c.expected_length is not None), - deadline=deadline, + normalized, erased, uppercase = interpretation + normalized_prefix = locked.upper() if uppercase else locked.lower() + normalized_context = replace( + context, immutable_prefix=normalized_prefix if context.immutable_prefix is not None else None + ) + # Minority-case symbols are explicit erasures, so search that stronger + # interpretation before optional alignment work on the normalized text + # can consume the shared correction deadline. + inputs = ( + ((normalized_context, erased),) + if erased == normalized + else ((normalized_context, erased), (normalized_context, normalized)) + ) + + from codex32.indel import _search_many + + capture_layers: list[tuple[int, int]] = [] + candidates: tuple[CorrectionCandidate, ...] = () + complete = True + if interpretation is not None: + # Establish both interpretations' fixed/required candidates before + # either interpretation can spend the shared deadline on optional + # alignment work. These discovery passes use a private accounting + # ledger; the full searches below account every admitted layer once. + for input_context, value in inputs: + preflight_contexts: tuple[CorrectionContext, ...] + if input_context.expected_length is not None: + preflight_contexts = (input_context,) + else: + observed = len(value.replace(" ", "")) + contexts_list = [] + for target in sorted({observed + delta for delta in (*range(-4, 5), -8, 8)}): + candidate_context = replace(input_context, expected_length=target) + try: + _validate_context(candidate_context) + except InvalidCorrectionInput: + continue + contexts_list.append(candidate_context) + preflight_contexts = tuple(contexts_list) + candidates, current_complete = _search_many( + preflight_contexts, + value, + primary=frozenset( + c.expected_length for c in preflight_contexts if c.expected_length is not None + ), + deadline=deadline, + observed_text=damaged_text, + seed_candidates=candidates, + required_only=True, + ) + if not current_complete: + return (), False + for input_context, value in inputs: + contexts: tuple[CorrectionContext, ...] + if input_context.expected_length is not None: + contexts = (input_context,) + else: + # Only lengths reachable by either disjoint family are eligible. + observed = len(value.replace(" ", "")) + contexts_list = [] + for target in sorted({observed + delta for delta in (*range(-4, 5), -8, 8)}): + candidate_context = replace(input_context, expected_length=target) + try: + _validate_context(candidate_context) + except InvalidCorrectionInput: + continue + contexts_list.append(candidate_context) + contexts = tuple(contexts_list) + candidates, current_complete = _search_many( + contexts, + value, + primary=frozenset(c.expected_length for c in contexts if c.expected_length is not None), + deadline=deadline, + capture_layers=capture_layers, + observed_text=damaged_text, + seed_candidates=candidates, + optional_only=interpretation is not None, + ) + complete &= current_complete + if not current_complete and not candidates: + return (), False + candidates = ( + _restore_case_edits(candidates, uppercase=uppercase) if interpretation is not None else candidates ) + return candidates, complete def correct(context: CorrectionContext, damaged_text: str) -> tuple[CorrectionCandidate, ...]: diff --git a/src/codex32/indel.py b/src/codex32/indel.py index 9561321..ca55bea 100644 --- a/src/codex32/indel.py +++ b/src/codex32/indel.py @@ -284,6 +284,7 @@ def _capacities(erasures: int, _degree: int) -> range: class _Target: context: CorrectionContext text: str + observed_text: str immutable: int target: int base: int @@ -292,12 +293,18 @@ class _Target: def _prepare( - context: CorrectionContext, damaged_text: str, classes: Sequence[_StructuralClass] + context: CorrectionContext, + damaged_text: str, + classes: Sequence[_StructuralClass], + observed_text: str | None = None, ) -> _Target | None: normalized = _normalize(context, damaged_text) if normalized is None: return None text, immutable = normalized + observed = text if observed_text is None else observed_text.replace(" ", "") + if len(observed) != len(text): + raise ValueError("observed text must preserve the searched text length") target = context.expected_length assert target is not None shapes = tuple(shape for shape in classes if shape.delta == len(text) - target) @@ -311,7 +318,31 @@ def _prepare( } base = len(context.hrp) + 1 degree = _checksum_for_encoded_length(context.hrp, target - base).length - return _Target(context, text, immutable, target, base, degree, counts) + return _Target(context, text, observed, immutable, target, base, degree, counts) + + +def _source(candidate: CorrectionCandidate, state: _Target, view: _View | None = None) -> CorrectionCandidate: + # Restore diagnostic characters transformed only to make mixed-case text searchable. + + def source_position(position: int) -> int | None: + if view is None: + return position + offset = 0 + for start, size in view.spans: + if position < offset + size: + return None if start < 0 else start + position - offset + offset += size + return None + + restored = [] + body_length = state.target - state.base + for edit in candidate.edits: + position = body_length - edit.reverse_index - 1 + source = source_position(position) + if edit.observed and source is not None and 0 <= source < len(state.observed_text) - state.base: + edit = replace(edit, observed=state.observed_text[state.base + source]) + restored.append(edit) + return replace(candidate, edits=tuple(restored)) def _layers( @@ -443,6 +474,8 @@ def _search_fixed( suspected_profile=state.context.hrp, immutable_prefix=state.context.immutable_prefix, ) + if fixed is not None: + fixed = _source(fixed, state) if fixed is None or not _allowed(state.context, fixed) or allowed is not None and not allowed(fixed): return None substitutions = sum(edit.kind == "substitution" for edit in fixed.edits) @@ -508,10 +541,12 @@ def _search_target( erasures = tuple(sorted(len(view) - p - 1 for p, _ in unknown)) fixed = solver.correct( view, - tuple((p, text[state.base + source]) for p, source in unknown if source >= 0), + tuple((p, state.observed_text[state.base + source]) for p, source in unknown if source >= 0), erasures, incremental.packed(view), ) + if fixed is not None: + fixed = _source(fixed, state, view) if fixed is None or not _allowed(context, fixed) or allowed is not None and not allowed(fixed): continue substitutions = sum(edit.kind == "substitution" for edit in fixed.edits) @@ -520,7 +555,7 @@ def _search_target( continue candidate = _adapt( fixed, - _view_variant(view, text, state.base), + _view_variant(view, state.observed_text, state.base), state.counts[shape][remaining], len(text), state.target, @@ -541,7 +576,7 @@ def _search_target( CorrectionEdit( "transposition", len(view) - offset - i - 1, - text[state.base + observed_position], + state.observed_text[state.base + observed_position], candidate.artifact.text[state.base + offset + i], ) ) @@ -563,6 +598,10 @@ def _search_many( competitors: bool = False, allowed: Callable[[CorrectionCandidate], bool] | None = None, capture_layers: list[tuple[int, int]] | None = None, + observed_text: str | None = None, + seed_candidates: Sequence[CorrectionCandidate] = (), + required_only: bool = False, + optional_only: bool = False, ) -> tuple[tuple[CorrectionCandidate, ...], bool]: deadline = monotonic() + 10 if deadline is None else deadline states = tuple( @@ -573,10 +612,23 @@ def _search_many( context, damaged_text, _CLASSES, + observed_text, ) ) is not None ) + if required_only: + states = tuple( + replace( + state, + counts={ + shape: values + for shape, values in state.counts.items() + if shape == _FIXED or shape.unit == 4 or shape.distance <= 2 + }, + ) + for state in states + ) frontier = _frontier(states, primary) layers_accounted = [] if capture_layers is None else capture_layers widths = {state.target: 5 * state.degree for state in states} @@ -594,12 +646,25 @@ def finish( if competitors: from codex32._competitors import _search_competitors - return finish(*_search_competitors(states, frontier, deadline, allowed)) - results: dict[str, CorrectionCandidate] = {} + result = ( + _search_competitors( + states, + frontier, + deadline, + allowed, + seed_candidates=seed_candidates, + ) + if seed_candidates + else _search_competitors(states, frontier, deadline, allowed) + ) + return finish(*result) + results = {candidate.artifact.text.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. - for phase in (0, 1, 2): + # A mixed-case preflight may already have completed fixed/required work; + # optional_only skips only that duplicate work, not frontier admission. + for phase in (2,) if optional_only else (0, 1, 2): for original in states: counts = { shape: values diff --git a/tests/data/malformed_inputs.json b/tests/data/malformed_inputs.json index 7f38719..b904864 100644 --- a/tests/data/malformed_inputs.json +++ b/tests/data/malformed_inputs.json @@ -45,7 +45,6 @@ "correction": [ {"id": "damaged-prefix", "profile": "ms", "text": "cl10testsxxxxxxxxxxxxxxxxxxxxxxxxxx4nzvca9cmczlw"}, {"id": "damaged-separator", "profile": "ms", "text": "msx0testsxxxxxxxxxxxxxxxxxxxxxxxxxx4nzvca9cmczlw"}, - {"id": "mixed-case", "profile": "ms", "text": "Ms10testsxxxxxxxxxxxxxxxxxxxxxxxxxx4nzvca9cmczlw"}, {"id": "too-many-errors", "profile": "ms", "text": "ms12test5xxyxxuxxxxxxxxxpxxxxxxxxxx4nzvca9cmczlw"}, {"id": "non-printable", "profile": "ms", "text": "ms10testsxxxxxxxxxxxxxxxx\nxxxxxxxxxx4nzvca9cmczlw"} ], diff --git a/tests/test_cli.py b/tests/test_cli.py index b9fa03b..93769e7 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -541,6 +541,17 @@ def test_xprv_suggests_mixed_case_input_with_symbol_errors(monkeypatch, capsys) assert captured.out.strip() == VECTOR_2["xprv"] +def test_embedded_correction_schedules_both_mixed_case_interpretations_before_alignment() -> None: + input_module = importlib.import_module("codex32._cli_input") + source = "ms10testsxxxxxxxxxxxxxxxxxxxxxxxxxx4nzvca9cmczlw" + damaged = "ms10testsxPxxxxxPxxxxxPxxxxxPxxxxxP4nzvca9cmczlw" + + candidates = input_module._suggestions(damaged, "", (Profile.MS,), []) + + assert len(candidates) == 1 + assert candidates[0].artifact.text == source + + def test_xprv_groups_the_next_prefix_after_spaced_correction(monkeypatch, capsys) -> None: input_module = importlib.import_module("codex32._cli_input") damaged = "NAME DLL4 F8JL H4E5 VDVU LDLF XU2J HDNL SM97 XVEN r" @@ -1765,7 +1776,7 @@ def test_correction_bytes_rejects_an_unsupported_ms_size() -> None: def test_cli_never_accepts_an_incomplete_structural_search() -> None: original = VECTOR_1["secret_s"] damaged = original[:19] + original[20:] - with patch("codex32.cli._correction_candidates", return_value=((), False, 0.0, False)): + with patch("codex32._cli_input._correction_candidates", return_value=((), False, 0.0, False)): result = _invoke(["correct"], damaged) assert result.exit_code != 0 @@ -1854,6 +1865,133 @@ def test_correction_infers_prefix_and_marks_invalid_data_as_erasures() -> None: assert bip39.exit_code == 0 and "already valid" in bip39.stdout +def test_correct_suggests_the_majority_case_for_mixed_case_damage() -> None: + source = VECTOR_1["secret_s"] + position = next( + index for index, character in enumerate(source[3:], 3) if character.lower() != character.upper() + ) + mixed = source[:position] + source[position].upper() + source[position + 1 :] + + result = _invoke(["correct"], mixed) + wrong_length = _invoke(["correct", "--bytes", "32"], mixed) + + assert result.exit_code == 1 + assert source in result.stderr + assert "No valid correction found" not in result.stderr + assert wrong_length.exit_code == 2 + assert "--bytes does not match" in wrong_length.stderr + assert source not in wrong_length.stderr + + +def test_correct_grouped_mixed_case_recognizes_case_only_repair() -> None: + source = VECTOR_1["secret_s"] + positions = [ + index for index, character in enumerate(source[3:], 3) if character.lower() != character.upper() + ][:13] + mixed = "".join( + character.upper() if index in positions else character for index, character in enumerate(source) + ) + grouped = " ".join(mixed[index : index + 4] for index in range(0, len(mixed), 4)) + + result = _invoke(["correct"], grouped) + + assert result.exit_code == 1 and source in result.stderr + assert "interactive confirmation required" not in result.stderr + + +def test_correct_searches_mixed_case_erasures_before_normalized_alignment(monkeypatch) -> None: + source = VECTOR_1["secret_s"] + letter_positions = [ + index + for index, character in enumerate(source[3:], 3) + if index >= 9 and character.lower() != character.upper() + ] + positions = letter_positions[1:26:6] + damaged = "".join( + ("P" if character.lower() != "p" else "Q") if index in positions else character + for index, character in enumerate(source) + ) + searched: list[str] = [] + + def stop_after_first(value, *_args, **_kwargs): + searched.append(value) + return (), False, None, False + + monkeypatch.setattr("codex32._cli_input._correction_candidates", stop_after_first) + + result = _invoke(["correct"], damaged) + assert result.exit_code != 0 + assert len(searched) == 1 + assert searched[0].count("?") == len(positions) + + +def test_correct_reranks_mixed_case_erasure_and_normalized_interpretations() -> None: + source = "ms10testsxxxxxxxxxxxxxxxxxxxxxxxxxx4nzvca9cmczlw" + start = source.index("x") + positions = range(start, start + 13) + damaged = "".join( + ("P" if index == start + 6 else character.upper()) if index in positions else character + for index, character in enumerate(source) + ) + + result = _invoke(["correct"], damaged) + + assert result.exit_code == 1 + assert source in result.stderr + assert "interactive confirmation required" not in result.stderr + + +def test_correct_required_work_is_not_starved_by_erasure_alignment() -> None: + source = "ms10testsxxxxxxxxxxxxxxxxxxxxxxxxxx4nzvca9cmczlw" + damaged = "ms10TpstsxXxxxxxXxxxxxXxxxxXxxxxxXx4nzvcA9cmczlW" + + result = _invoke(["correct"], damaged) + + assert result.exit_code == 1 + assert source in result.stderr + assert "did not complete within ten seconds" not in result.stderr + assert "interactive confirmation required" not in result.stderr + + +def test_correct_accounts_retry_frontier_after_incomplete_first_search(monkeypatch) -> None: + source = "ms10testsxxxxxxxxxxxxxxxxxxxxxxxxxx4nzvca9cmczlw" + start = source.index("x") + positions = range(start, start + 13) + damaged = "".join( + ("P" if index == start + 6 else character.upper()) if index in positions else character + for index, character in enumerate(source) + ) + candidate = CorrectionCandidate( + parse_codex32(source), + (), + 1, + 0, + 0, + None, + search_complete=False, + ) + full_searches: list[str] = [] + + def incomplete_first(value, *_args, **kwargs): + if kwargs.get("required_only"): + return (), True, 0.0, False + full_searches.append(value) + kwargs["capture_layers"].append((1, 5)) + if len(full_searches) == 1: + return (candidate,), False, 0.0, False + return (), False, 0.0, False + + monkeypatch.setattr("codex32._cli_input._correction_candidates", incomplete_first) + + result = _invoke(["correct"], damaged) + + assert len(full_searches) == 2 + assert full_searches[0].count("?") == len(positions) + assert "?" not in full_searches[1] + assert result.exit_code == 1 + assert "interactive confirmation required" in result.stderr + + def test_correction_hides_internal_candidate_reparse_failures() -> None: result = _invoke(["correct"], "ms12auxxxxxxxxxxxxxxxxxxxxxxxxxxxxxda3kr3s0s2swg") @@ -2753,7 +2891,7 @@ def test_incomplete_candidate_has_no_search_warning_and_is_never_accepted_automa candidate = _correct_fixed(source, suspected_profile=Profile.MS) assert candidate is not None candidate = replace(candidate, search_complete=False) - with patch("codex32.cli._correction_candidates", return_value=((candidate,), False, 0.0, False)): + with patch("codex32._cli_input._correction_candidates", return_value=((candidate,), False, 0.0, False)): result = _invoke(["correct"], source[:-1] + "?") assert result.exit_code == 1 and result.stdout == "" assert "Search incomplete" not in result.stderr diff --git a/tests/test_correction_bch.py b/tests/test_correction_bch.py index 49b8800..a8edc80 100644 --- a/tests/test_correction_bch.py +++ b/tests/test_correction_bch.py @@ -1,7 +1,7 @@ """Independent BCH vectors, recovery bounds, and worksheet correction.""" import json -from dataclasses import FrozenInstanceError +from dataclasses import FrozenInstanceError, replace from pathlib import Path import pytest @@ -12,7 +12,7 @@ from test_profiles import _oracle_encode import codex32 -from codex32 import CorrectionCandidate, CorrectionContext, CorrectionEdit, Profile, correct +from codex32 import CorrectionCandidate, CorrectionContext, CorrectionEdit, Profile, correct, indel from codex32.bech32 import CHARSET from codex32.checksums import _CODEX32, _CODEX32_LONG from codex32.correction import ( @@ -264,6 +264,196 @@ def test_uppercase_input_preserves_case_and_reverse_addends() -> None: assert result.addend_hamming_weight == addend.bit_count() +@pytest.mark.parametrize("uppercase", (False, True)) +def test_public_correction_interprets_mixed_case_by_majority(uppercase: bool) -> None: + source = VECTOR_1["secret_s"].upper() if uppercase else VECTOR_1["secret_s"] + position = next( + index for index, character in enumerate(source[3:], 3) if character.lower() != character.upper() + ) + mixed = source[:position] + source[position].swapcase() + source[position + 1 :] + + result = correct(CorrectionContext(Profile.MS, expected_length=len(source)), mixed) + + assert len(result) == 1 + assert result[0].artifact.text == source + + +def test_grouped_mixed_case_keeps_the_recorded_header_immutable() -> None: + source = VECTOR_1["secret_s"].upper() + positions = (10, 16, 22, 28, 34) + damaged = "".join( + "p" if index in positions else character.lower() if index < 8 else character + for index, character in enumerate(source) + ) + grouped = " ".join(damaged[index : index + 4] for index in range(0, len(damaged), 4)) + context = CorrectionContext(Profile.MS, expected_length=len(source), immutable_prefix=source[:8].lower()) + + result = correct(context, grouped) + + assert len(result) == 1 + assert result[0].artifact.text == source + assert {edit.observed for edit in result[0].edits} == {"p"} + + +def test_public_correction_does_not_casefold_non_ascii() -> None: + source = VECTOR_1["secret_s"].upper() + damaged = source[:10] + "ß" + source[11:] + + result = correct(CorrectionContext(Profile.MS, expected_length=len(source)), damaged) + + assert result == () + + +def test_public_correction_searches_identical_case_interpretation_once() -> None: + source = VECTOR_1["secret_s"] + damaged = source[0].upper() + source[1:] + + result = correct(CorrectionContext(Profile.MS, expected_length=len(source)), damaged) + + assert len(result) == 1 + assert result[0].artifact.text == source + assert result[0].cumulative_capture_volume == 1 + + +def test_mixed_case_erasure_search_precedes_normalized_alignment(monkeypatch: pytest.MonkeyPatch) -> None: + source = VECTOR_1["secret_s"] + letter_positions = [ + index + for index, character in enumerate(source[3:], 3) + if index >= 9 and character.lower() != character.upper() + ] + positions = letter_positions[1:26:6] + damaged = "".join( + ("P" if character.lower() != "p" else "Q") if index in positions else character + for index, character in enumerate(source) + ) + searched: list[str] = [] + + def stop_after_first(_contexts, value, **_kwargs): + searched.append(value) + return (), False + + monkeypatch.setattr("codex32.indel._search_many", stop_after_first) + + assert correct(CorrectionContext(Profile.MS, expected_length=len(source)), damaged) == () + assert len(searched) == 1 + assert searched[0].count("?") == len(positions) + + +def test_mixed_case_candidate_still_searches_normalized_competitors(monkeypatch: pytest.MonkeyPatch) -> None: + source = VECTOR_1["secret_s"] + position = next( + index for index, character in enumerate(source[3:], 3) if character.lower() != character.upper() + ) + damaged = source[:position] + source[position].upper() + source[position + 1 :] + candidate = CorrectionCandidate(codex32.parse_codex32(source), (), 1, 0, 0, None) + searched: list[tuple[str, tuple[CorrectionCandidate, ...], bool, bool]] = [] + + def search(_contexts, value, **kwargs): # type: ignore[no-untyped-def] + seeded = kwargs["seed_candidates"] + searched.append( + (value, seeded, kwargs.get("required_only", False), kwargs.get("optional_only", False)) + ) + return ((candidate,), True) if len(searched) == 1 else (seeded, True) + + monkeypatch.setattr("codex32.indel._search_many", search) + + assert correct(CorrectionContext(Profile.MS, expected_length=len(source)), damaged) == (candidate,) + assert len(searched) == 4 + assert ["?" in value for value, _seeded, _required, _optional in searched] == [True, False, True, False] + assert [required for _value, _seeded, required, _optional in searched] == [True, True, False, False] + assert [optional for _value, _seeded, _required, optional in searched] == [False, False, True, True] + assert all(seeded == (candidate,) for _value, seeded, _required, _optional in searched[1:]) + + +def test_mixed_case_required_work_is_not_starved_by_erasure_alignment() -> None: + source = "ms10testsxxxxxxxxxxxxxxxxxxxxxxxxxx4nzvca9cmczlw" + damaged = "ms10TpstsxXxxxxxXxxxxxXxxxxXxxxxxXx4nzvcA9cmczlW" + + result = correct(CorrectionContext(Profile.MS, expected_length=len(source)), damaged) + + assert len(result) == 1 + assert result[0].artifact.text == source + + +def test_post_preflight_optional_expiry_preserves_seed(monkeypatch: pytest.MonkeyPatch) -> None: + candidate = replace( + CorrectionCandidate(codex32.parse_codex32(VECTOR_1["secret_s"]), (), 1, 0, 0, None), + capture_volume=1 << 200, + ) + searched = [] + + def expire(state, *_args, **_kwargs): # type: ignore[no-untyped-def] + searched.extend(state.counts) + return False + + monkeypatch.setattr(indel, "_search_target", expire) + result, complete = indel._search_many( + (CorrectionContext("ms", 48),), + VECTOR_1["secret_s"], + primary=frozenset((48,)), + seed_candidates=(candidate,), + optional_only=True, + ) + + assert searched + assert all(shape != indel._FIXED and shape.unit != 4 and shape.distance > 2 for shape in searched) + assert not complete + assert len(result) == 1 + assert result[0].artifact == candidate.artifact + assert not result[0].search_complete + + +@pytest.mark.parametrize("uppercase", (False, True)) +@pytest.mark.parametrize(("entered", "kind"), (("P", "substitution"), ("B", "erasure"))) +def test_mixed_case_correction_edits_preserve_the_entered_character( + uppercase: bool, + entered: str, + kind: str, +) -> None: + source = VECTOR_1["secret_s"].upper() if uppercase else VECTOR_1["secret_s"] + position = 3 + observed = entered.lower() if uppercase else entered + damaged = source[:position] + observed + source[position + 1 :] + + result = correct(CorrectionContext(Profile.MS, expected_length=len(source)), damaged) + + assert len(result) == 1 + assert result[0].artifact.text == 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]),) + + +def test_mixed_case_does_not_reclassify_unrelated_structural_erasures() -> None: + source = "ms10testsxxxxxxxxxxxxxxxxxxxxxxxxxx4nzvca9cmczlw" + damaged = source[:8] + "qqqq" + source[12:] + mixed = damaged[:12] + damaged[12].upper() + damaged[13:] + + plain = correct(CorrectionContext(Profile.MS, expected_length=len(source)), damaged) + result = correct(CorrectionContext(Profile.MS, expected_length=len(source)), mixed) + + assert len(plain) == len(result) == 1 + assert result[0].artifact.text == 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( + (edit.reverse_index, edit.observed, edit.replacement) for edit in plain[0].edits + ) + + +def test_mixed_case_structural_edits_preserve_the_entered_character() -> None: + source = VECTOR_1["secret_s"] + damaged = source[:3] + "P" + source[4:20] + source[21:] + + result = correct(CorrectionContext(Profile.MS, expected_length=len(source)), damaged) + + assert len(result) == 1 + assert result[0].artifact.text == 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" + + def test_fixed_failures_are_fail_closed() -> None: mixed = "M" + VECTOR_1["secret_s"][1:] damaged = list(VECTOR_1["secret_s"]) diff --git a/tests/test_correction_disclosure.py b/tests/test_correction_disclosure.py index ffaa152..da29d2e 100644 --- a/tests/test_correction_disclosure.py +++ b/tests/test_correction_disclosure.py @@ -100,7 +100,7 @@ def test_noninteractive_gate_emits_only_operational_error(entrypoint, plain): patch.object(sys, "stdin", io.StringIO(VECTOR_1["secret_s"][:-1] + "?")), contextlib.redirect_stdout(stdout), contextlib.redirect_stderr(stderr), - patch.object(cli, "_correction_candidates", return_value=((_candidate(),), True, None, False)), + patch.object(_cli_input, "_correction_candidates", return_value=((_candidate(),), True, None, False)), ): status = entrypoint(["correct", *(["--plain"] if plain else [])]) prog = "codex32" if entrypoint is cli.main else "ms32" @@ -128,7 +128,7 @@ def respond(prompt, prefill=""): monkeypatch.setattr(_cli_input, "_suggestions", lambda *args, **kwargs: (candidate,)) monkeypatch.setattr(cli, "_suggestions", lambda *args, **kwargs: (candidate,)) monkeypatch.setattr( - cli, "_correction_candidates", lambda *args, **kwargs: ((candidate,), True, None, False) + _cli_input, "_correction_candidates", lambda *args, **kwargs: ((candidate,), True, None, False) ) core = _FakeBitcoinCore() monkeypatch.setattr(cli.BitcoinCore, "connect", lambda *args: core) @@ -163,7 +163,7 @@ def respond(prompt, prefill=""): monkeypatch.setattr(_cli_input, "_editable_input", respond) monkeypatch.setattr( - cli, "_correction_candidates", lambda *args, **kwargs: ((candidate,), True, None, False) + _cli_input, "_correction_candidates", lambda *args, **kwargs: ((candidate,), True, None, False) ) with ( patch.object(sys, "stdin", _TTYInput()), @@ -181,7 +181,7 @@ def test_redirected_stderr_blocks_low_discrimination_disclosure(monkeypatch): responses = iter((source[:-1] + "?",)) monkeypatch.setattr(_cli_input, "_editable_input", lambda *args, **kwargs: next(responses)) monkeypatch.setattr( - cli, "_correction_candidates", lambda *args, **kwargs: ((candidate,), True, None, False) + _cli_input, "_correction_candidates", lambda *args, **kwargs: ((candidate,), True, None, False) ) stdout, stderr = io.StringIO(), io.StringIO() with ( @@ -218,7 +218,7 @@ def confirm(prompt): monkeypatch.setattr(_cli_input, "_confirmation_input", confirm) monkeypatch.setattr( - cli, "_correction_candidates", lambda *args, **kwargs: ((candidate,), True, None, False) + _cli_input, "_correction_candidates", lambda *args, **kwargs: ((candidate,), True, None, False) ) stdout, stderr = io.StringIO(), _TTYOutput() with ( @@ -321,6 +321,21 @@ def test_previous_case_interpretation_search_is_charged_even_without_a_candidate assert result[0].low_checksum_discrimination +def test_seed_candidate_is_reannotated_after_later_search_admission(monkeypatch): + candidate = replace(_candidate(), capture_volume=10) + monkeypatch.setattr(indel, "_frontier", lambda *args: {(48, indel._FIXED, 0, 0): 10}) + monkeypatch.setattr(indel, "_search_target", lambda *args: True) + result, complete = indel._search_many( + (CorrectionContext("ms", 48),), + VECTOR_1["secret_s"], + primary=frozenset((48,)), + capture_layers=[(5, 65)], + seed_candidates=(candidate,), + ) + assert complete and result[0].cumulative_capture_volume == 15 + assert result[0].capture_space_bits == 65 + + @pytest.mark.parametrize("profile", ("bip39_12w", "bip39_24w")) def test_cli_derives_hand_produced_bip39_set_but_rejects_invalid_implied_secret(profile): from test_generic_hrp import _invoke diff --git a/tests/test_mixed_case_deadline.py b/tests/test_mixed_case_deadline.py new file mode 100644 index 0000000..f62e93e --- /dev/null +++ b/tests/test_mixed_case_deadline.py @@ -0,0 +1,82 @@ +"""Deadline regression for mixed-case embedded recovery.""" + +from dataclasses import replace +from time import monotonic + +import pytest + +from codex32 import Profile +from codex32._cli_input import _case_interpretation, _correction_candidates, _scheduled_candidates + +SOURCE = "ms10testsxxxxxxxxxxxxxxxxxxxxxxxxxx4nzvca9cmczlw" + + +@pytest.mark.parametrize( + ("damaged", "erasure_fixes", "normalized_fixes"), + [ + # Five minority-case P: five erasures are correctable, five substitutions are not. + ("ms10testsxPxxxxxPxxxxxPxxxxxPxxxxxP4nzvca9cmczlw", True, False), + # Fifteen minority-case X, one mistyped: fifteen erasures are not correctable, + # one substitution after case normalization is. + ("ms10testsXXXXXXXPXXXXXXXxxxxxxxxxxx4nzvca9cmczlw", False, True), + ], +) +def test_embedded_mixed_case_recovers_either_sole_interpretation_within_deadline( + damaged: str, erasure_fixes: bool, normalized_fixes: bool +) -> None: + interpretation = _case_interpretation(damaged, "", (Profile.MS,), None) + + assert interpretation is not None + direct, normalized, erased, _prefix = interpretation + assert direct is None + immutable = normalized[: normalized.rfind("1") + 1] + for value, fixes in ((erased, erasure_fixes), (normalized, normalized_fixes)): + alone, alone_complete, _deadline, _ambiguous = _correction_candidates( + value, Profile.MS, None, immutable, deadline=monotonic() + 10, required_only=True + ) + assert alone_complete + assert [candidate.artifact.text for candidate in alone] == ([SOURCE] if fixes else []) + + candidates, complete, _ambiguous = _scheduled_candidates( + normalized, + erased, + Profile.MS, + None, + immutable, + deadline=monotonic() + 10, + ) + + # The exhaustive optional search may truncate at the deadline; the + # candidate must still be found and must report that truncation. + assert [candidate.artifact.text for candidate in candidates] == [SOURCE] + assert candidates[0].search_complete is complete + + +def test_scheduled_truncation_survives_a_complete_later_pass(monkeypatch: pytest.MonkeyPatch) -> None: + damaged = "ms10testsxPxxxxxPxxxxxPxxxxxPxxxxxP4nzvca9cmczlw" + interpretation = _case_interpretation(damaged, "", (Profile.MS,), None) + assert interpretation is not None + _direct, normalized, erased, _prefix = interpretation + immutable = normalized[: normalized.rfind("1") + 1] + found, _complete, _deadline, _ambiguous = _correction_candidates( + erased, Profile.MS, None, immutable, deadline=monotonic() + 10, required_only=True + ) + full_searches: list[str] = [] + + def truncated_first(value: str, *_args: object, **kwargs: object) -> tuple[object, bool, float, bool]: + if kwargs.get("required_only"): + return (), True, 0.0, False + full_searches.append(value) + if len(full_searches) == 1: + return (replace(found[0], search_complete=False),), False, 0.0, False + return (), True, 0.0, False + + monkeypatch.setattr("codex32._cli_input._correction_candidates", truncated_first) + + candidates, complete, _ambiguous = _scheduled_candidates(normalized, erased, Profile.MS, None, immutable) + + assert full_searches == [erased, normalized] + assert not complete + assert [(candidate.artifact.text, candidate.search_complete) for candidate in candidates] == [ + (SOURCE, False) + ] From 606bbac3ec8697ca7f7fb98d11ce2f1381c07eec Mon Sep 17 00:00:00 2001 From: Ben Westgate Date: Tue, 29 Sep 2026 09:39:16 -0500 Subject: [PATCH 02/16] correct: Define correction exit statuses Give standalone correction a stable status contract: 0 for already-valid input, 1 when a suggestion is emitted, 2 for command or input syntax errors, and 3 when no usable suggestion is emitted. Keep incomplete best-effort suggestions at status 1 and document status 3 only for incomplete searches without a usable suggestion. Fixes #39. --- docs/security/model.md | 10 ++++++---- docs/user/guide.md | 8 ++++++++ src/codex32/cli.py | 18 +++++++++--------- tests/test_cli.py | 25 +++++++++++++++++-------- tests/test_correction_disclosure.py | 9 +++++---- 5 files changed, 45 insertions(+), 25 deletions(-) diff --git a/docs/security/model.md b/docs/security/model.md index 7a7de88..2cd177f 100644 --- a/docs/security/model.md +++ b/docs/security/model.md @@ -199,10 +199,12 @@ before printing any candidate text, metadata, fingerprint, or residue addends. It then prints a conspicuous warning covering both deliberate completion of newly transcribed data and recovery with many missing characters. Literal uppercase `YES` is required before disclosure; other case variants, blank input, -or EOF terminate the command with status 1. Redirected damaged data may still -reach this gate, but disclosure requires an interactive terminal channel. If no -such channel is available, the sole message is `codex32: interactive confirmation -required` (or `ms32:`). Output formatting and `--plain` cannot bypass the gate. +or EOF terminate standalone `correct` with status 3 and correction embedded in +another workflow with status 1. Redirected damaged data may still reach this +gate, but disclosure requires an interactive terminal channel. If no such +channel is available, the sole message is `codex32: interactive confirmation +required` (or `ms32:`), with the same command-specific status. Output formatting +and `--plain` cannot bypass the gate. Existing whole-card `[y/N]` acceptance remains required after disclosure when a workflow will consume the corrected artifact. `correct` only displays the suggestion, so it has no second acceptance prompt. The gate does not verify the diff --git a/docs/user/guide.md b/docs/user/guide.md index 7be6bcf..b2d78bb 100644 --- a/docs/user/guide.md +++ b/docs/user/guide.md @@ -32,6 +32,14 @@ disclosure, workflows that consume the repaired artifact ask the usual `[y/N]` whole-card confirmation. `correct` only reports a suggestion, so it does not ask that second question. A checksum cannot make weak input secure. +The `correct` exit status distinguishes outcomes for scripts: `0` means the +input is already valid, `1` means a suggestion was emitted, `2` means the +command or input syntax was invalid, and `3` means no usable suggestion was +emitted. Status `3` includes incomplete searches with no usable suggestion, +ambiguous searches, declined disclosure, and Bitcoin Core being unavailable +when `ms32 correct` needs it to rank or fingerprint a master-seed suggestion. +The generic `codex32 correct` command does not need Core. + Choose the setup that fits you: - **Recommended: dedicated online spending wallet — easiest.** A normally diff --git a/src/codex32/cli.py b/src/codex32/cli.py index e0007b7..fe72bce 100644 --- a/src/codex32/cli.py +++ b/src/codex32/cli.py @@ -537,14 +537,16 @@ def _correct( f"Add {correction.addend} at position " f"{correction.reverse_index + 1}, counting backward from the end." ) - return 0 + return 1 if result else 0 if erasures: raise _UsageError("--erasure can be used only with --residue.") normalized = "".join(value.split()) separator = normalized.lower().rfind("1") if separator <= 0: raise _UsageError("Enter a complete application prefix followed by the separator 1.") - hrp = normalized[:separator].lower() + if len(raw_hrp := normalized[:separator]) > 83 or not all("!" <= c <= "~" for c in raw_hrp): + raise _UsageError("The application prefix must be at most 83 printable ASCII characters.") + hrp = raw_hrp.lower() if context.master_seed and hrp != Profile.MS.value: raise _UsageError("This command accepts only Bitcoin master-seed input beginning with ms1.") try: @@ -698,22 +700,20 @@ def _main(context: _CliContext, argv: Sequence[str] | None = None) -> int: except SystemExit as error: return error.code if isinstance(error.code, int) else 1 scope = f"{context.prog} {arguments.command}" + correction_failed = 3 if arguments.command == "correct" else 1 try: return _dispatch(arguments, context) except CorrectionDeclined: - return 1 + return correction_failed except InteractiveConfirmationRequired: _print(f"{context.prog}: interactive confirmation required", err=True) - return 1 + return correction_failed except _UsageError as error: _print(f"{scope}: {error}", err=True) return 2 - except (_CommandError, CodexError) as error: - _print(f"{scope}: {error}", err=True) - return 1 - except BitcoinCoreError as error: + except (_CommandError, CodexError, BitcoinCoreError) as error: _print(f"{scope}: {error}", err=True) - return 1 + return correction_failed except EOFError: _print(f"{scope}: Input ended before recovery completed.", err=True) return 2 diff --git a/tests/test_cli.py b/tests/test_cli.py index 93769e7..d89e317 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -1726,7 +1726,7 @@ def test_cli_rejects_statistically_inadmissible_structural_burst() -> None: result = _invoke(["correct"], damaged) - assert result.exit_code == 1 + assert result.exit_code == 3 assert "No valid correction found" in result.stderr @@ -1737,10 +1737,19 @@ def test_cli_rejects_sixteen_consecutive_erasures_as_outside_regular_bound() -> assert len("".join(damaged.split())) == 48 assert damaged.count("?") == 16 - assert result.exit_code == 1 + assert result.exit_code == 3 assert "No valid correction found" in result.stderr +def test_correct_rejects_malformed_immutable_hrp_as_usage() -> None: + damaged = "é" + VECTOR_1["secret_s"][1:] + + result = _invoke(["correct"], damaged) + + assert result.exit_code == 2 + assert "application prefix" in result.stderr + + @pytest.mark.parametrize( ("byte_length", "options"), ((16, []), (64, []), (20, ["--bytes", "20"]), (24, ["--bytes", "?"])), @@ -1779,7 +1788,7 @@ def test_cli_never_accepts_an_incomplete_structural_search() -> None: with patch("codex32._cli_input._correction_candidates", return_value=((), False, 0.0, False)): result = _invoke(["correct"], damaged) - assert result.exit_code != 0 + assert result.exit_code == 3 assert result.stdout == "" assert "did not complete" in result.stderr and original not in result.stderr @@ -1801,7 +1810,7 @@ def test_correction_options_control_lengths_deadline_and_search_envelope( with patch("codex32.indel._search_many", return_value=((), True)) as search: result = _invoke(["correct", *options], damaged) - assert result.exit_code == 1 and result.stdout == "" + assert result.exit_code == 3 and result.stdout == "" assert search.call_count == 1 contexts, observed = search.call_args.args assert observed == damaged @@ -1828,7 +1837,7 @@ def test_fixed_correction_repairs_legacy_cl_header_and_residue_reverse_positions residue = _invoke(["correct", "--residue"], "2ppjkw73qdjvc") assert fixed.exit_code == 1 and original in fixed.stderr - assert residue.exit_code == 0 + assert residue.exit_code == 1 assert "Add x at position 38, counting backward from the end." in residue.stdout @@ -1861,7 +1870,7 @@ def test_correction_infers_prefix_and_marks_invalid_data_as_erasures() -> None: bip39 = _invoke(["correct"], BIP39_12W_ZERO) assert removed.exit_code == 2 assert "Remove or correct these arguments: --prefix" in removed.stderr - assert damaged_prefix.exit_code == 1 + assert damaged_prefix.exit_code == 3 assert bip39.exit_code == 0 and "already valid" in bip39.stdout @@ -1988,14 +1997,14 @@ def incomplete_first(value, *_args, **kwargs): assert len(full_searches) == 2 assert full_searches[0].count("?") == len(positions) assert "?" not in full_searches[1] - assert result.exit_code == 1 + assert result.exit_code == 3 assert "interactive confirmation required" in result.stderr def test_correction_hides_internal_candidate_reparse_failures() -> None: result = _invoke(["correct"], "ms12auxxxxxxxxxxxxxxxxxxxxxxxxxxxxxda3kr3s0s2swg") - assert result.exit_code != 0 + assert result.exit_code == 3 assert result.stdout == "" assert result.stderr.strip() in { "codex32 correct: No valid correction found. Check the original backup.", diff --git a/tests/test_correction_disclosure.py b/tests/test_correction_disclosure.py index da29d2e..6c9b262 100644 --- a/tests/test_correction_disclosure.py +++ b/tests/test_correction_disclosure.py @@ -104,7 +104,7 @@ def test_noninteractive_gate_emits_only_operational_error(entrypoint, plain): ): status = entrypoint(["correct", *(["--plain"] if plain else [])]) prog = "codex32" if entrypoint is cli.main else "ms32" - assert status == 1 and stdout.getvalue() == "" + assert status == 3 and stdout.getvalue() == "" assert stderr.getvalue() == f"{prog}: interactive confirmation required\n" @@ -140,7 +140,8 @@ def respond(prompt, prefill=""): contextlib.redirect_stderr(stderr), ): status = (cli.ms_main if command == "create" else cli.main)(args) - assert status == 1 and stdout.getvalue() == "" + expected_status = 3 if command == "correct" else 1 + assert status == expected_status and stdout.getvalue() == "" assert len(prompts) == 2 and core.imported is None warning = stderr.getvalue() assert "\x1b[1;31mWarning:\x1b[0m If you are generating new data" in warning @@ -189,7 +190,7 @@ def test_redirected_stderr_blocks_low_discrimination_disclosure(monkeypatch): contextlib.redirect_stdout(stdout), contextlib.redirect_stderr(stderr), ): - assert cli.main(["correct", "--plain"]) == 1 + assert cli.main(["correct", "--plain"]) == 3 assert stdout.getvalue() == "" assert stderr.getvalue().strip() == "codex32: interactive confirmation required" @@ -292,7 +293,7 @@ def test_residue_completion_is_gated_but_ordinary_repair_is_not(degree): contextlib.redirect_stdout(stdout), contextlib.redirect_stderr(stderr), ): - assert cli.main(args) == 1 + assert cli.main(args) == 3 assert stdout.getvalue() == "" assert stderr.getvalue() == "codex32: interactive confirmation required\n" From 14355608f41a4619fcb69e8191e4809c955e7b78 Mon Sep 17 00:00:00 2001 From: Ben Westgate Date: Thu, 1 Oct 2026 01:59:13 -0500 Subject: [PATCH 03/16] cli: Remove pre-review dead code (#46) Remove unreachable creation guards and the permanently false correction ambiguity field, align the CLI test Core stub with production, and move reference-only correction helpers out of the installed package. Security: fail-closed correction and wallet behavior are unchanged. Refs #38. --- pyproject.toml | 1 - src/codex32/_cli_input.py | 18 +++++----- src/codex32/cli.py | 10 ++---- src/codex32/indel.py | 40 ---------------------- tests/test_cli.py | 16 ++++----- tests/test_correction_disclosure.py | 10 +++--- tests/test_correction_indel.py | 4 +-- tests/test_mixed_case_deadline.py | 16 ++++----- tools/correction_benchmark.py | 3 +- tools/correction_reference.py | 53 +++++++++++++++++++++++++++++ 10 files changed, 86 insertions(+), 85 deletions(-) create mode 100644 tools/correction_reference.py diff --git a/pyproject.toml b/pyproject.toml index ce4a9fc..369196a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -48,7 +48,6 @@ dev = [ "twine>=5,<7", ] - [tool.setuptools.packages.find] where = ["src"] diff --git a/src/codex32/_cli_input.py b/src/codex32/_cli_input.py index e711ce7..acb4ab0 100644 --- a/src/codex32/_cli_input.py +++ b/src/codex32/_cli_input.py @@ -456,7 +456,7 @@ def _correction_candidates( fingerprint_match: Callable[[CorrectionCandidate], bool | None] | None = None, seed_candidates: Sequence[CorrectionCandidate] = (), required_only: bool = False, -) -> tuple[tuple[CorrectionCandidate, ...], bool, float, bool]: +) -> tuple[tuple[CorrectionCandidate, ...], bool, float]: count = len(value.replace(" ", "")) targets, primary, reduced, _timed = _correction_plan(profile, byte_length, count, target) deadline = monotonic() + 10 if deadline is None else deadline @@ -484,7 +484,7 @@ def _correction_candidates( if len(candidates) == 1 and not candidates[0].search_complete else () ) - return results, complete, deadline, False + return results, complete, deadline def _scheduled_candidates( @@ -499,7 +499,7 @@ def _scheduled_candidates( allowed: Callable[[CorrectionCandidate], bool] | None = None, deadline: float | None = None, fingerprint_match: Callable[[CorrectionCandidate], bool | None] | None = None, -) -> tuple[tuple[CorrectionCandidate, ...], bool, bool]: +) -> tuple[tuple[CorrectionCandidate, ...], bool]: """Search both case interpretations under one deadline and capture ledger.""" search = partial( _correction_candidates, @@ -519,18 +519,18 @@ def _scheduled_candidates( # These discovery passes deliberately do not charge capture_layers; # the full searches below account each admitted frontier once. for required_value in (first, retry): - seeded, complete, deadline, _ = search( + seeded, complete, deadline = search( required_value, deadline=deadline, seed_candidates=seeded, required_only=True ) if not complete: - return (), False, False + return (), False capture_layers: list[tuple[int, int]] = [] - candidates, complete, deadline, ambiguous = search( + candidates, complete, deadline = search( first, deadline=deadline, capture_layers=capture_layers, seed_candidates=seeded ) if retry is None: - return candidates, complete, ambiguous - retry_candidates, retry_complete, _deadline, retry_ambiguous = search( + return candidates, complete + retry_candidates, retry_complete, _deadline = search( retry, deadline=deadline, capture_layers=capture_layers, seed_candidates=(*seeded, *candidates) ) complete = complete and retry_complete @@ -544,7 +544,7 @@ def _scheduled_candidates( unique.setdefault( item.artifact.text.lower(), item if complete else replace(item, search_complete=False) ) - return tuple(unique.values()), complete, ambiguous or retry_ambiguous + return tuple(unique.values()), complete def _fingerprint_matcher( diff --git a/src/codex32/cli.py b/src/codex32/cli.py index fe72bce..b20bddd 100644 --- a/src/codex32/cli.py +++ b/src/codex32/cli.py @@ -426,8 +426,6 @@ def _create( raise _UsageError("--bytes applies only to a new random seed.") if not (sys.stdin.isatty() and sys.stdout.isatty()): raise _UsageError("Bitcoin backup creation requires an interactive terminal.") - if threshold and not sys.stdin.isatty(): - raise _UsageError("Shared creation requires an interactive terminal.") if threshold and shares is None and indices is None: if threshold in (2, 3): shares = {2: 3, 3: 5}[threshold] @@ -572,15 +570,11 @@ def _correct( candidate = None if candidate is not None: candidates: tuple[CorrectionCandidate, ...] = (candidate,) - complete, ambiguous = True, False + complete = True else: - candidates, complete, ambiguous = _scheduled_candidates( - search_value, erased, hrp, byte_length, immutable - ) + candidates, complete = _scheduled_candidates(search_value, erased, hrp, byte_length, immutable) if not complete and not candidates: raise _CommandError("The correction search did not complete within ten seconds.") - if ambiguous: - raise _CommandError("More than one correction is possible; none was selected.") if not candidates: raise _CommandError("No valid correction found. Check the original backup.") if context.master_seed and len(candidates) > 1: diff --git a/src/codex32/indel.py b/src/codex32/indel.py index ca55bea..c71f319 100644 --- a/src/codex32/indel.py +++ b/src/codex32/indel.py @@ -88,8 +88,6 @@ def _classes(unit: int, depth: int) -> tuple[_StructuralClass, ...]: _CHARACTER_CLASSES = _classes(1, 4) _GROUP_CLASSES = _classes(4, 2) _CLASSES = (_FIXED, *_CHARACTER_CLASSES, *_GROUP_CLASSES) -# Compatibility for older offline tools; public search uses the full class set. -_REDUCED_CLASSES = tuple(shape for shape in _CLASSES if shape.unit == 4 or shape.distance <= 3) def _group_boundary(immutable_length: int) -> int: @@ -161,44 +159,6 @@ def _alignment_counts( return counts -def _alignment_count( - shape: _StructuralClass, - observed_length: int, - target_length: int, - immutable_length: int, -) -> int: - return sum(_alignment_counts(shape, "q" * observed_length, target_length, immutable_length).values()) - - -def _reductions( - values: tuple[int, ...], - characters: str, - count: int, - offset: int, -) -> Iterator[tuple[tuple[int, ...], tuple[tuple[int, str], ...]]]: - for deleted in combinations(range(len(values)), count): - removed = frozenset(deleted) - kept = tuple(index for index in range(len(values)) if index not in removed) - retained = tuple(values[index] for index in kept) - position = 0 - for kept_index in kept: - while values[position] != values[kept_index]: - position += 1 - if position != kept_index: - break - position += 1 - else: - edits = tuple((offset + index, characters[index]) for index in deleted) - yield retained, edits - - -def _variants( - text: str, target: int, shape: _StructuralClass, immutable: int, prefix_length: int = 3 -) -> Iterator[_Variant]: - for view in _views(text, target, shape, immutable, prefix_length): - yield _view_variant(view, text, prefix_length) - - def _views(text: str, target: int, shape: _StructuralClass, immutable: int, base: int) -> Iterator[_View]: source = tuple(CHARSET.find(char.lower()) for char in text[base:]) initial = _View(source, ((0, len(source)),), len(source)) diff --git a/tests/test_cli.py b/tests/test_cli.py index d89e317..02e74c3 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -91,7 +91,6 @@ class _FakeBitcoinCore: chain: str = "main" version: int = 320000 imported: MasterSeed | None = None - private: bool | None = None account: int | None = None timestamp: int | str | None = None @@ -112,7 +111,7 @@ def initialize( timestamp: int | str = "now", ) -> str: self.imported = secret - self.private, self.account, self.timestamp = private, account, timestamp + self.account, self.timestamp = account, timestamp return "test-wallet" @@ -1785,7 +1784,7 @@ def test_correction_bytes_rejects_an_unsupported_ms_size() -> None: def test_cli_never_accepts_an_incomplete_structural_search() -> None: original = VECTOR_1["secret_s"] damaged = original[:19] + original[20:] - with patch("codex32._cli_input._correction_candidates", return_value=((), False, 0.0, False)): + with patch("codex32._cli_input._correction_candidates", return_value=((), False, 0.0)): result = _invoke(["correct"], damaged) assert result.exit_code == 3 @@ -1924,7 +1923,7 @@ def test_correct_searches_mixed_case_erasures_before_normalized_alignment(monkey def stop_after_first(value, *_args, **_kwargs): searched.append(value) - return (), False, None, False + return (), False, None monkeypatch.setattr("codex32._cli_input._correction_candidates", stop_after_first) @@ -1983,12 +1982,12 @@ def test_correct_accounts_retry_frontier_after_incomplete_first_search(monkeypat def incomplete_first(value, *_args, **kwargs): if kwargs.get("required_only"): - return (), True, 0.0, False + return (), True, 0.0 full_searches.append(value) kwargs["capture_layers"].append((1, 5)) if len(full_searches) == 1: - return (candidate,), False, 0.0, False - return (), False, 0.0, False + return (candidate,), False, 0.0 + return (), False, 0.0 monkeypatch.setattr("codex32._cli_input._correction_candidates", incomplete_first) @@ -2022,7 +2021,6 @@ def test_wallet_commands_initialize_selected_master_seed_destinations() -> None: assert xprv.stderr.endswith("Keep it secret.\n\n") assert private.stdout == "" assert private_core.imported == parse_codex32(VECTOR_1["secret_s"]) - assert private_core.private is True assert "Warning: This gives Bitcoin Core the master private key, which can spend funds." in private.stderr assert "Use only the intended encrypted wallet" not in private.stderr assert "\x1b[" not in private.stderr + private.stdout @@ -2900,7 +2898,7 @@ def test_incomplete_candidate_has_no_search_warning_and_is_never_accepted_automa candidate = _correct_fixed(source, suspected_profile=Profile.MS) assert candidate is not None candidate = replace(candidate, search_complete=False) - with patch("codex32._cli_input._correction_candidates", return_value=((candidate,), False, 0.0, False)): + with patch("codex32._cli_input._correction_candidates", return_value=((candidate,), False, 0.0)): result = _invoke(["correct"], source[:-1] + "?") assert result.exit_code == 1 and result.stdout == "" assert "Search incomplete" not in result.stderr diff --git a/tests/test_correction_disclosure.py b/tests/test_correction_disclosure.py index 6c9b262..abcac50 100644 --- a/tests/test_correction_disclosure.py +++ b/tests/test_correction_disclosure.py @@ -100,7 +100,7 @@ def test_noninteractive_gate_emits_only_operational_error(entrypoint, plain): patch.object(sys, "stdin", io.StringIO(VECTOR_1["secret_s"][:-1] + "?")), contextlib.redirect_stdout(stdout), contextlib.redirect_stderr(stderr), - patch.object(_cli_input, "_correction_candidates", return_value=((_candidate(),), True, None, False)), + patch.object(_cli_input, "_correction_candidates", return_value=((_candidate(),), True, None)), ): status = entrypoint(["correct", *(["--plain"] if plain else [])]) prog = "codex32" if entrypoint is cli.main else "ms32" @@ -128,7 +128,7 @@ def respond(prompt, prefill=""): monkeypatch.setattr(_cli_input, "_suggestions", lambda *args, **kwargs: (candidate,)) monkeypatch.setattr(cli, "_suggestions", lambda *args, **kwargs: (candidate,)) monkeypatch.setattr( - _cli_input, "_correction_candidates", lambda *args, **kwargs: ((candidate,), True, None, False) + _cli_input, "_correction_candidates", lambda *args, **kwargs: ((candidate,), True, None) ) core = _FakeBitcoinCore() monkeypatch.setattr(cli.BitcoinCore, "connect", lambda *args: core) @@ -164,7 +164,7 @@ def respond(prompt, prefill=""): monkeypatch.setattr(_cli_input, "_editable_input", respond) monkeypatch.setattr( - _cli_input, "_correction_candidates", lambda *args, **kwargs: ((candidate,), True, None, False) + _cli_input, "_correction_candidates", lambda *args, **kwargs: ((candidate,), True, None) ) with ( patch.object(sys, "stdin", _TTYInput()), @@ -182,7 +182,7 @@ def test_redirected_stderr_blocks_low_discrimination_disclosure(monkeypatch): responses = iter((source[:-1] + "?",)) monkeypatch.setattr(_cli_input, "_editable_input", lambda *args, **kwargs: next(responses)) monkeypatch.setattr( - _cli_input, "_correction_candidates", lambda *args, **kwargs: ((candidate,), True, None, False) + _cli_input, "_correction_candidates", lambda *args, **kwargs: ((candidate,), True, None) ) stdout, stderr = io.StringIO(), io.StringIO() with ( @@ -219,7 +219,7 @@ def confirm(prompt): monkeypatch.setattr(_cli_input, "_confirmation_input", confirm) monkeypatch.setattr( - _cli_input, "_correction_candidates", lambda *args, **kwargs: ((candidate,), True, None, False) + _cli_input, "_correction_candidates", lambda *args, **kwargs: ((candidate,), True, None) ) stdout, stderr = io.StringIO(), _TTYOutput() with ( diff --git a/tests/test_correction_indel.py b/tests/test_correction_indel.py index 05d6d97..646e8b5 100644 --- a/tests/test_correction_indel.py +++ b/tests/test_correction_indel.py @@ -19,14 +19,11 @@ _CLASSES, _FIXED, _GROUP_CLASSES, - _REDUCED_CLASSES, - _alignment_count, _alignment_counts, _capacities, _frontier, _keep, _prepare, - _reductions, _required_header_substitutions, _search_many, _search_target, @@ -34,6 +31,7 @@ from codex32.profiles.ms32 import TEXT_LENGTHS from tools._wallet_test_vectors import FIXTURE_SEED, core_fingerprint from tools.correction_capture import cross_length_classes +from tools.correction_reference import _REDUCED_CLASSES, _alignment_count, _reductions SOURCE = VECTOR_1["secret_s"] CONTEXT = CorrectionContext(Profile.MS, expected_length=len(SOURCE)) diff --git a/tests/test_mixed_case_deadline.py b/tests/test_mixed_case_deadline.py index f62e93e..9a5ab7a 100644 --- a/tests/test_mixed_case_deadline.py +++ b/tests/test_mixed_case_deadline.py @@ -31,13 +31,13 @@ def test_embedded_mixed_case_recovers_either_sole_interpretation_within_deadline assert direct is None immutable = normalized[: normalized.rfind("1") + 1] for value, fixes in ((erased, erasure_fixes), (normalized, normalized_fixes)): - alone, alone_complete, _deadline, _ambiguous = _correction_candidates( + alone, alone_complete, _deadline = _correction_candidates( value, Profile.MS, None, immutable, deadline=monotonic() + 10, required_only=True ) assert alone_complete assert [candidate.artifact.text for candidate in alone] == ([SOURCE] if fixes else []) - candidates, complete, _ambiguous = _scheduled_candidates( + candidates, complete = _scheduled_candidates( normalized, erased, Profile.MS, @@ -58,22 +58,22 @@ def test_scheduled_truncation_survives_a_complete_later_pass(monkeypatch: pytest assert interpretation is not None _direct, normalized, erased, _prefix = interpretation immutable = normalized[: normalized.rfind("1") + 1] - found, _complete, _deadline, _ambiguous = _correction_candidates( + found, _complete, _deadline = _correction_candidates( erased, Profile.MS, None, immutable, deadline=monotonic() + 10, required_only=True ) full_searches: list[str] = [] - def truncated_first(value: str, *_args: object, **kwargs: object) -> tuple[object, bool, float, bool]: + def truncated_first(value: str, *_args: object, **kwargs: object) -> tuple[object, bool, float]: if kwargs.get("required_only"): - return (), True, 0.0, False + return (), True, 0.0 full_searches.append(value) if len(full_searches) == 1: - return (replace(found[0], search_complete=False),), False, 0.0, False - return (), True, 0.0, False + return (replace(found[0], search_complete=False),), False, 0.0 + return (), True, 0.0 monkeypatch.setattr("codex32._cli_input._correction_candidates", truncated_first) - candidates, complete, _ambiguous = _scheduled_candidates(normalized, erased, Profile.MS, None, immutable) + candidates, complete = _scheduled_candidates(normalized, erased, Profile.MS, None, immutable) assert full_searches == [erased, normalized] assert not complete diff --git a/tools/correction_benchmark.py b/tools/correction_benchmark.py index f0e74eb..922632d 100644 --- a/tools/correction_benchmark.py +++ b/tools/correction_benchmark.py @@ -15,13 +15,12 @@ from codex32.correction import CorrectionContext from codex32.indel import ( _CHARACTER_CLASSES, - _alignment_count, _required_header_substitutions, _search_many, - _variants, ) from codex32.profiles import Profile from codex32.profiles.ms32 import TEXT_LENGTHS +from tools.correction_reference import _alignment_count, _variants DELTAS = (-8, -4, -3, -2, -1, 0, 1, 2, 3, 4, 8) AUTOMATIC_48_COUNTS = (40, *range(44, 53), 56) diff --git a/tools/correction_reference.py b/tools/correction_reference.py new file mode 100644 index 0000000..b55bd40 --- /dev/null +++ b/tools/correction_reference.py @@ -0,0 +1,53 @@ +"""Reference-only structural helpers used by tests and correction benchmarks.""" + +from collections.abc import Iterator +from itertools import combinations + +from codex32.indel import ( + _CLASSES, + _alignment_counts, + _StructuralClass, + _Variant, + _view_variant, + _views, +) + +_REDUCED_CLASSES = tuple(shape for shape in _CLASSES if shape.unit == 4 or shape.distance <= 3) + + +def _alignment_count( + shape: _StructuralClass, + observed_length: int, + target_length: int, + immutable_length: int, +) -> int: + return sum(_alignment_counts(shape, "q" * observed_length, target_length, immutable_length).values()) + + +def _reductions( + values: tuple[int, ...], + characters: str, + count: int, + offset: int, +) -> Iterator[tuple[tuple[int, ...], tuple[tuple[int, str], ...]]]: + for deleted in combinations(range(len(values)), count): + removed = frozenset(deleted) + kept = tuple(index for index in range(len(values)) if index not in removed) + retained = tuple(values[index] for index in kept) + position = 0 + for kept_index in kept: + while values[position] != values[kept_index]: + position += 1 + if position != kept_index: + break + position += 1 + else: + edits = tuple((offset + index, characters[index]) for index in deleted) + yield retained, edits + + +def _variants( + text: str, target: int, shape: _StructuralClass, immutable: int, prefix_length: int = 3 +) -> Iterator[_Variant]: + for view in _views(text, target, shape, immutable, prefix_length): + yield _view_variant(view, text, prefix_length) From 128bda4f3071fd7bed0d783d003b77a8fa8cafc3 Mon Sep 17 00:00:00 2001 From: Ben Westgate Date: Thu, 1 Oct 2026 16:22:51 -0500 Subject: [PATCH 04/16] correct: Preserve mixed-case preflight seeds After both mixed-case interpretations complete their required preflight, the CLI competitor scheduler must not rerun that same required work under the already-consumed shared deadline. Restrict only the executed follow-up work to optional character classes while retaining the full admitted frontier for cumulative disclosure accounting. This preserves a required-pass candidate if optional work reaches the deadline and keeps the public capture-mass calculation unchanged. The regression asserts that both full mixed-case follow-up searches enter optional-only mode. Refs #37. --- src/codex32/_cli_input.py | 11 ++++++----- src/codex32/_competitors.py | 9 +++++++-- src/codex32/indel.py | 17 +++++++---------- tests/test_correction_disclosure.py | 8 ++++++-- tests/test_mixed_case_deadline.py | 1 + 5 files changed, 27 insertions(+), 19 deletions(-) diff --git a/src/codex32/_cli_input.py b/src/codex32/_cli_input.py index acb4ab0..cf5d25d 100644 --- a/src/codex32/_cli_input.py +++ b/src/codex32/_cli_input.py @@ -456,6 +456,7 @@ def _correction_candidates( fingerprint_match: Callable[[CorrectionCandidate], bool | None] | None = None, seed_candidates: Sequence[CorrectionCandidate] = (), required_only: bool = False, + optional_only: bool = False, ) -> tuple[tuple[CorrectionCandidate, ...], bool, float]: count = len(value.replace(" ", "")) targets, primary, reduced, _timed = _correction_plan(profile, byte_length, count, target) @@ -474,6 +475,7 @@ def _correction_candidates( capture_layers=capture_layers, seed_candidates=seed_candidates, required_only=required_only, + optional_only=optional_only, ) if allowed is not None: candidates = tuple(candidate for candidate in candidates if allowed(candidate)) @@ -525,13 +527,12 @@ def _scheduled_candidates( if not complete: return (), False capture_layers: list[tuple[int, int]] = [] - candidates, complete, deadline = search( - first, deadline=deadline, capture_layers=capture_layers, seed_candidates=seeded - ) + full_search = partial(search, capture_layers=capture_layers, optional_only=retry is not None) + candidates, complete, deadline = full_search(first, deadline=deadline, seed_candidates=seeded) if retry is None: return candidates, complete - retry_candidates, retry_complete, _deadline = search( - retry, deadline=deadline, capture_layers=capture_layers, seed_candidates=(*seeded, *candidates) + retry_candidates, retry_complete, _deadline = full_search( + retry, deadline=deadline, seed_candidates=(*seeded, *candidates) ) complete = complete and retry_complete annotated = [] diff --git a/src/codex32/_competitors.py b/src/codex32/_competitors.py index 50ccf78..b5895a9 100644 --- a/src/codex32/_competitors.py +++ b/src/codex32/_competitors.py @@ -175,18 +175,23 @@ def _search_competitors( allowed: Callable[[CorrectionCandidate], bool] | None, *, seed_candidates: Sequence[CorrectionCandidate] = (), + optional_only: bool = False, ) -> tuple[tuple[CorrectionCandidate, ...], bool]: results = {candidate.artifact.text.lower(): candidate for candidate in seed_candidates} fixed: dict[int, CorrectionCandidate | None] = {} completed: set[_Layer] = set() try: for state in states: - if _FIXED in state.counts: + if not optional_only and _FIXED in state.counts: _check_deadline(deadline) fixed[state.target] = _search_fixed(state, frontier, results, allowed) targets = {state.target: state for state in states} layers = sorted( - (key for key in frontier if key[1] != _FIXED), + ( + key + for key in frontier + if key[1] != _FIXED and (not optional_only or key[1].unit != 4 and key[1].distance > 2) + ), key=lambda key: (_tier(key[1]), frontier[key]), ) for key in layers: diff --git a/src/codex32/indel.py b/src/codex32/indel.py index c71f319..26c0071 100644 --- a/src/codex32/indel.py +++ b/src/codex32/indel.py @@ -606,16 +606,13 @@ def finish( if competitors: from codex32._competitors import _search_competitors - result = ( - _search_competitors( - states, - frontier, - deadline, - allowed, - seed_candidates=seed_candidates, - ) - if seed_candidates - else _search_competitors(states, frontier, deadline, allowed) + result = _search_competitors( + states, + frontier, + deadline, + allowed, + seed_candidates=seed_candidates, + optional_only=optional_only, ) return finish(*result) results = {candidate.artifact.text.lower(): candidate for candidate in seed_candidates} diff --git a/tests/test_correction_disclosure.py b/tests/test_correction_disclosure.py index abcac50..08592ee 100644 --- a/tests/test_correction_disclosure.py +++ b/tests/test_correction_disclosure.py @@ -60,7 +60,9 @@ def test_scheduler_annotates_from_admission_even_when_no_work_finished(monkeypat (48, indel._StructuralClass(0, 0, adjacent=2), 0, 0): rank + 1, } monkeypatch.setattr(indel, "_frontier", lambda *args: frontier) - monkeypatch.setattr("codex32._competitors._search_competitors", lambda *args: ((candidate,), complete)) + monkeypatch.setattr( + "codex32._competitors._search_competitors", lambda *args, **kwargs: ((candidate,), complete) + ) result, finished = indel._search_many( (CorrectionContext("ms", 48),), source, @@ -309,7 +311,9 @@ def test_residue_exactly_five_bits_is_not_gated_even_with_zero_addends(residue): def test_previous_case_interpretation_search_is_charged_even_without_a_candidate(monkeypatch): candidate = replace(_candidate(), capture_volume=(1 << 60) + 1) monkeypatch.setattr(indel, "_frontier", lambda *args: {(48, indel._FIXED, 0, 0): 1}) - monkeypatch.setattr("codex32._competitors._search_competitors", lambda *args: ((candidate,), True)) + monkeypatch.setattr( + "codex32._competitors._search_competitors", lambda *args, **kwargs: ((candidate,), True) + ) previous = [(1 << 60, 65)] result, _ = indel._search_many( (CorrectionContext("ms", 48),), diff --git a/tests/test_mixed_case_deadline.py b/tests/test_mixed_case_deadline.py index 9a5ab7a..f812096 100644 --- a/tests/test_mixed_case_deadline.py +++ b/tests/test_mixed_case_deadline.py @@ -66,6 +66,7 @@ def test_scheduled_truncation_survives_a_complete_later_pass(monkeypatch: pytest def truncated_first(value: str, *_args: object, **kwargs: object) -> tuple[object, bool, float]: if kwargs.get("required_only"): return (), True, 0.0 + assert kwargs.get("optional_only") is True full_searches.append(value) if len(full_searches) == 1: return (replace(found[0], search_complete=False),), False, 0.0 From 054e8d9b3ffdae835e325e4dd1f3135296359525 Mon Sep 17 00:00:00 2001 From: Ben Westgate Date: Tue, 29 Sep 2026 19:04:33 -0500 Subject: [PATCH 05/16] wallet: Require the recorded fingerprint before import Gate restore and existing-seed wallet initialization on the independently recorded BIP32 master fingerprint before any Bitcoin Core wallet mutation. Keep the correction path from disclosing or reusing a fingerprint derived from the candidate being authenticated. Fixes #30. --- docs/developer/api.md | 9 ++ docs/security/invariants.md | 6 ++ docs/security/model.md | 6 +- docs/user/guide.md | 21 ++-- src/codex32/_bitcoin_core.py | 67 +++++++++++++ src/codex32/cli.py | 70 ++++++++++++-- tests/test_bitcoin_core.py | 157 +++++++++++++++++++++++++++--- tests/test_cli.py | 161 ++++++++++++++++++++++++++++++- tools/bitcoin_core_main_smoke.py | 2 + tools/bitcoin_core_regtest.py | 4 + 10 files changed, 472 insertions(+), 31 deletions(-) diff --git a/docs/developer/api.md b/docs/developer/api.md index 4a290d8..4e6cd06 100644 --- a/docs/developer/api.md +++ b/docs/developer/api.md @@ -195,6 +195,8 @@ requires a complete explicit `ms1` string; it never infers or corrects a missing HRP or separator. No entropy is drawn for this path; raw hexadecimal seeds retain the generation path. Existing imports use timestamp zero to include prior history. Changing a supplied secret's identifier requires a sharing threshold. +Existing-seed creation uses the same recorded-fingerprint or explicit no-record +confirmation as wallet restoration before import, including after re-sharing. Shared creation uses an explicit threshold or full backup header. Without an explicit share count or indices, thresholds 2 and 3 produce the reviewed 2-of-3 and 3-of-5 @@ -624,6 +626,13 @@ supplies the root xprv. Confirmation text is never reparsed into this source. The key is sent only through `bitcoin-cli -stdin`; raw Core errors are suppressed, and no passphrase interface exists. +For wallet restoration and `ms32 create --existing`, callers make the wallet-record +decision before initialization. `BitcoinCore.initialize()` calls `verify_identity()` +before `_select()` or any wallet mutation. A supplied fingerprint must match the +recovered master seed; `None` is reserved for fresh creation or the operator's +explicit no-record fallback. A mismatch stops before a destination wallet is +selected or changed. + Core v32 accepts the key with `addhdkey` and creates external and internal account-0 descriptors for BIP44/49/84/86 with `createwalletdescriptor`. Python checks each call's result but trusts Core to derive and store the wallet policy. diff --git a/docs/security/invariants.md b/docs/security/invariants.md index 98a35ef..42adce5 100644 --- a/docs/security/invariants.md +++ b/docs/security/invariants.md @@ -11,6 +11,12 @@ and evidence. 3. Shared creation uses a separate OS-CSPRNG call for each random initial share, gated by confirmation. Input cannot replace entropy or the original secret. 4. Wallet setup uses the original ceremony result or a validated recovered seed. + Restore authenticates the recovered seed before any wallet is listed, + unlocked, or imported into: normally with the master fingerprint typed from + the wallet record, or by an explicit no-record choice made after seeing the + recovered fingerprint and whether the backup identifier was derived from the + seed. Fresh `ms32 create` ceremonies do not authenticate against a + pre-existing wallet; they require the operator to record the new fingerprint. 5. Correction shares one mass bound and deadline across target lengths. The public API fails closed on incomplete required work; CLI searches may return one primary-best-so-far eligible candidate at the deadline. Incomplete diff --git a/docs/security/model.md b/docs/security/model.md index 2cd177f..2d3e18b 100644 --- a/docs/security/model.md +++ b/docs/security/model.md @@ -45,8 +45,9 @@ The operator must: balances or history; - protect recovery cards and store shared cards in different trusted places; - confirm every newly recorded secret or share; -- keep wallet records separate from shares and compare recovered fingerprints, - addresses, account, policy, and history with those records; +- keep wallet records separate from shares, type the master fingerprint from + the record before a restore import, and compare addresses, account, policy, + and history with those records; - compare every correction suggestion with the original codex32 string and stop when recovered information and wallet records disagree; and - never put recovery text in command arguments or transfer a master seed, @@ -230,6 +231,7 @@ signing setup belong to Bitcoin Core's maintained v32 workflow. | Control | Required behavior | |---|---| | Preflight | Before entropy or recovery input, explicit chain arguments probe the five standard local networks for Bitcoin Core 32 or newer. One response is selected automatically; multiple responses require operator selection. | +| Recovery identity | `ms32 wallet` and `ms32 create --existing` authenticate a recovered seed before any wallet is listed. Core derives the recovered fingerprint statelessly, and a mismatch raises `FingerprintMismatch` before any wallet RPC. The restore prompt does not show the recovered value, so the operator compares by typing the fingerprint from the wallet record. Without a record, the operator is shown the recovered fingerprint, whether the backup identifier was derived from the seed (the codex32 fingerprint rule, Bails' RIPEMD-160 rule, or its mid-2023 alpha's SHA-256 rule), and a warning, and then chooses. Fresh `ms32 create` has no pre-existing wallet to authenticate: it shows the newly created seed's fingerprint and requires the operator to acknowledge recording it. These checks catch mistakes such as wrong or mixed cards; anyone able to replace a threshold of cards could already read them. | | Process boundary | codex32 invokes the reviewed `bitcoin-cli` from `PATH` as a child without a shell, direct RPC socket, wallet database, or wallet-creation operation. Every call uses loopback and the selected chain. | | Destination | Only an empty descriptor wallet with private keys enabled, no external signer, transactions, descriptors, keypool entries, or active scan is eligible. One eligible wallet is offered directly; multiple wallets are selected by number. New wallets are detected by polling, and rejection returns to every eligible wallet. The escaped name is confirmed exactly. | | Seed source | The original ceremony result or validated recovered master seed supplies a root xprv for Core's reported chain. Core v32 creates BIP44, BIP49, BIP84, and BIP86 account-0 descriptors from that key. | diff --git a/docs/user/guide.md b/docs/user/guide.md index b2d78bb..332d77a 100644 --- a/docs/user/guide.md +++ b/docs/user/guide.md @@ -171,9 +171,12 @@ wallet should be trusted until initialization completes. ### 4. Complete the record and store the cards -Copy the displayed backup identifier, wallet name, Bitcoin Core version, -master fingerprint, derivation standards, and account number to the wallet -record. Add the approximate +Before a freshly created wallet is filled, write the displayed master fingerprint on the +wallet record and confirm that you wrote it down. Fresh creation has no pre-existing +fingerprint or descriptor to authenticate; `ms32 create --existing` instead uses the +restore identity gate. Then copy the displayed +backup identifier, wallet name, Bitcoin Core version, derivation standards, and +account number to the wallet record. Add the approximate creation / earliest-use date. Do not put a descriptor timestamp on a recovery card; Core's public descriptor export preserves its stored timestamps. @@ -234,16 +237,20 @@ its public wallet data with the separate wallet record. If you know when the wallet was first used, an earlier Unix timestamp can shorten the rescan; `0` remains the safest choice when unsure. -5. Select and confirm that wallet. If it is locked, follow the displayed +5. Type the master fingerprint from the wallet record. A mismatch stops before + Bitcoin Core is changed. Press Enter with nothing typed only if there is no + record; codex32 then shows the recovered fingerprint and what the backup + identifier says, and asks before restoring. +6. Select and confirm that wallet. If it is locked, follow the displayed Bitcoin-Qt Console instructions; codex32 waits and continues automatically. It gives Core the master private key, asks Core to create the standard account-0 descriptors, scans history, and relocks an encrypted wallet. -6. If you need an online watch-only counterpart, keep the restored signer +7. If you need an online watch-only counterpart, keep the restored signer offline and follow Bitcoin Core v32's [offline-signing tutorial](https://github.com/bitcoin/bitcoin/blob/v32.0rc1/doc/offline-signing-tutorial.md) to export and restore the watch-only wallet. Let the online node synchronize, - then compare the recovered fingerprint, account, policy, addresses, balance, - and transaction history with the wallet record. + then compare the account, policy, addresses, balance, and transaction + history with the wallet record. A timestamp of zero safely scans all history and may take time; it belongs in the recovery command, not on a paper card. During an emergency recovery, move diff --git a/src/codex32/_bitcoin_core.py b/src/codex32/_bitcoin_core.py index cfec702..5b78a74 100644 --- a/src/codex32/_bitcoin_core.py +++ b/src/codex32/_bitcoin_core.py @@ -1,8 +1,10 @@ from __future__ import annotations +import hashlib import json import re import shutil +import string import subprocess from collections.abc import Callable from dataclasses import dataclass @@ -10,6 +12,8 @@ from typing import Literal from codex32._bip32 import _master_xprv_from_seed +from codex32.bech32 import _u5_to_chars, convertbits +from codex32.generation import _fingerprint_identifier from codex32.profiles.ms32 import MasterSeed from codex32.wallet import _descriptor_records @@ -18,6 +22,10 @@ class BitcoinCoreError(Exception): pass +class FingerprintMismatch(BitcoinCoreError): + """The recovered seed is not the wallet the operator's record describes.""" + + _CHAINS = ( ("main", "mainnet"), ("test", "testnet3"), @@ -32,6 +40,54 @@ class BitcoinCoreError(Exception): _OUTPUT_TYPES = ("legacy", "p2sh-segwit", "bech32", "bech32m") +def parse_fingerprint(text: str) -> bytes: + """Read a master fingerprint as written on a wallet record: 8 hex digits, any case or spacing.""" + compact = "".join(text.split()) + if len(compact) != 8 or not all(character in string.hexdigits for character in compact): + raise ValueError("A master fingerprint is 8 characters, each 0-9 or A-F.") + return bytes.fromhex(compact) + + +NO_RECORD_WARNING = ( + "Without the wallet record, nothing can prove these cards are the wallet you expect. Compare the " + "fingerprint with any other copy, such as another wallet app, a hardware wallet or a descriptor backup. " + "After restoring, let Bitcoin Core finish scanning and check that the balance, past payments and " + "addresses are ones you recognise before sending money here. Replaced cards can come with a history " + "too: if you do not know what this wallet should hold, have someone you trust check it. Once you are " + "sure, write the fingerprint on a new wallet record." +) + + +def identifier_note(origin: str | None) -> str: + # Say what `identifier_origin` found, for an operator restoring without a record. + if origin is None: + return ( + "The backup identifier was not made from this seed. That can be normal for codex32 backups " + "made from split shares, supplied seed bytes or an explicit identifier. Bails made every " + "identifier from its seed, so for a Bails backup these are the wrong or mixed-up cards." + ) + return ( + f"The backup identifier matches this seed ({origin} rule). That rules out most mixed-up cards, " + "but not cards replaced on purpose." + ) + + +def identifier_origin(secret: MasterSeed, fingerprint: bytes) -> str | None: + """Check codex32's fingerprint or Bails' three-character seed-digest identifier.""" + identifier = secret.header.identifier + if identifier == _fingerprint_identifier(fingerprint): + return "codex32" + for name, digest in (("Bails", "ripemd160"), ("Bails alpha", "sha256")): + try: + hashed = hashlib.new(digest, secret.seed_bytes).digest() + except ValueError: + continue + derived = convertbits(hashed, 8, 5, pad=True) + if identifier[:3] == _u5_to_chars(tuple(derived[:3])): + return name + return None + + @dataclass(frozen=True) class BitcoinCore: executable: str @@ -154,6 +210,14 @@ def fingerprint(self, secret: MasterSeed) -> bytes: raise TypeError("wallet operations accept only MasterSeed") return self.fingerprint_seed(secret.seed_bytes) + 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.""" + if expected_fingerprint is not None and self.fingerprint(secret) != expected_fingerprint: + raise FingerprintMismatch( + "The recovered master fingerprint does not match the one from the wallet record. " + "Bitcoin Core was not changed." + ) + def _root_xpub(self, wallet: str) -> str: result = self._rpc("gethdkeys", wallet=wallet) if not isinstance(result, list) or len(result) != 1 or not isinstance(result[0], dict): @@ -375,15 +439,18 @@ def initialize( ask: Callable[[str], str], tell: Callable[[str], None], *, + expected_fingerprint: bytes | None, account: int = 0, timestamp: int | Literal["now"] = "now", ) -> str: + """Validate input and identity before selecting or changing a wallet.""" if not isinstance(secret, MasterSeed): raise TypeError("wallet operations accept only MasterSeed") if type(account) is not int or account != 0: raise ValueError("Bitcoin Core wallet initialization currently supports only account 0") if timestamp != "now" and (type(timestamp) is not int or timestamp < 0): raise ValueError("timestamp must be a nonnegative integer or 'now'") + self.verify_identity(secret, expected_fingerprint) while True: name = self._select(ask, tell) state = self._target(name) diff --git a/src/codex32/cli.py b/src/codex32/cli.py index b20bddd..6e81e43 100644 --- a/src/codex32/cli.py +++ b/src/codex32/cli.py @@ -8,7 +8,15 @@ from collections.abc import Callable, Sequence from typing import Literal, NamedTuple, cast -from codex32._bitcoin_core import BitcoinCore, BitcoinCoreError +from codex32._bitcoin_core import ( + NO_RECORD_WARNING, + BitcoinCore, + BitcoinCoreError, + FingerprintMismatch, + identifier_note, + identifier_origin, + parse_fingerprint, +) from codex32._cli_input import ( CorrectionDeclined, InteractiveConfirmationRequired, @@ -339,6 +347,44 @@ def _generated_secret( ) +def _show_fingerprint(core: BitcoinCore, secret: MasterSeed, action: str) -> None: + _print(f"\nMaster fingerprint: {core.fingerprint(secret).hex().upper()}", err=True) + _text(f"{action}, then press Enter", optional=True, prompt_end=". ") + if sys.stderr.isatty(): + _print("\x1b[3J\x1b[2J\x1b[H", err=True) + + +def _without_record(core: BitcoinCore, secret: MasterSeed) -> bool: + fingerprint = core.fingerprint(secret) + _print(f"\nMaster fingerprint: {fingerprint.hex().upper()}", err=True) + _print(f"Backup identifier: {secret.header.identifier.upper()}", err=True) + _print(identifier_note(identifier_origin(secret, fingerprint)), err=True) + _print(NO_RECORD_WARNING, err=True) + return _text("Restore without a wallet record? [y/N]", optional=True).lower() in ("y", "yes") + + +def _recorded_fingerprint(core: BitcoinCore, secret: MasterSeed) -> bytes | None: + # Take the master fingerprint from a recovery record until the library accepts it. + prompt = "Type the master fingerprint from your wallet record (Enter if none)" + while True: + text = _text(prompt, optional=True) + if not text: + if _without_record(core, secret): + return None + raise _WalletSetupInterrupted + try: + expected = parse_fingerprint(text) + except ValueError as error: + _print(str(error), err=True) + continue + try: + core.verify_identity(secret, expected) + except FingerprintMismatch as error: + _print(str(error), err=True) + continue + return expected + + def _initialize_wallet( core: BitcoinCore, secret: MasterSeed, @@ -346,16 +392,21 @@ def _initialize_wallet( account: int = 0, timestamp: int | Literal["now"] = "now", fresh: bool = True, + restore: bool = False, confirmed: bool = True, ) -> int: assert isinstance(secret, MasterSeed) try: if confirmed: _print("Master-seed backup confirmed.\n", err=True) + expected = _recorded_fingerprint(core, secret) if restore else None + if not restore: + _show_fingerprint(core, secret, "Write it on the wallet record") name = core.initialize( secret, lambda prompt: _text(prompt, optional=True), lambda message: _print(message, err=True), + expected_fingerprint=expected, account=account, timestamp=timestamp, ) @@ -432,7 +483,7 @@ def _create( else: raise _UsageError("For thresholds 4 through 9, choose --shares or --indices.") core = _connected_core() - source = _creation_source(profile, core.fingerprint) if existing else None + source = _creation_source(profile) if existing else None if not existing and not sys.stdin.isatty() and _text("", optional=True): raise _UsageError("Use --existing when supplying a seed or secret.") if isinstance(source, (Share, Secret)) and not isinstance(source, MasterSeed): @@ -447,11 +498,13 @@ def _create( secret = source else: secret = _generated_secret(source, byte_length, identifier, core.fingerprint_seed) - _emit(secret, False, fingerprint=core.fingerprint) + _emit(secret, False, fingerprint=None if existing else core.fingerprint) if sys.stdin.isatty(): _confirm_card(secret) return ( - _initialize_wallet(core, secret, timestamp=0 if existing else "now", fresh=not existing) + _initialize_wallet( + core, secret, timestamp=0 if existing else "now", fresh=not existing, restore=existing + ) if core is not None else 0 ) @@ -493,7 +546,9 @@ def _create( finished = ceremony.finish() assert isinstance(finished, MasterSeed) if core is not None: - return _initialize_wallet(core, finished, timestamp=0 if existing else "now", fresh=not existing) + return _initialize_wallet( + core, finished, timestamp=0 if existing else "now", fresh=not existing, restore=existing + ) _print("\nEvery recovery card was confirmed from its re-entered text.", err=True) return 0 @@ -609,13 +664,16 @@ def _bitcoin_core(account: int, timestamp: int | Literal["now"]) -> int: danger=True, ) core = _connected_core() - secret = _master_seed(core.fingerprint) + # Keep the recovered fingerprint hidden until the operator has supplied + # independent wallet-record evidence or explicitly chosen recordless restore. + secret = _master_seed() return _initialize_wallet( core, secret, account=account, timestamp=timestamp, fresh=False, + restore=True, confirmed=False, ) diff --git a/tests/test_bitcoin_core.py b/tests/test_bitcoin_core.py index 72d6676..f6323c9 100644 --- a/tests/test_bitcoin_core.py +++ b/tests/test_bitcoin_core.py @@ -2,6 +2,7 @@ from __future__ import annotations +import hashlib import json import re import subprocess @@ -9,8 +10,16 @@ import pytest -from codex32._bitcoin_core import BitcoinCore, BitcoinCoreError +from codex32._bitcoin_core import ( + BitcoinCore, + BitcoinCoreError, + FingerprintMismatch, + identifier_note, + identifier_origin, + parse_fingerprint, +) from codex32.bip93 import parse_codex32 +from codex32.generation import _fingerprint_identifier from codex32.profiles.ms32 import MasterSeed from codex32.wallet import _with_checksum @@ -37,9 +46,16 @@ ), } _ROOT_XPUB = "xpub-root-fixture" +_FINGERPRINT = bytes.fromhex("3f3521a6") _PRIVATE_ACCOUNT = re.compile(r"/(?P44|49|84|86)h/0h/0h/<0;1>/\*") +@pytest.fixture(autouse=True) +def _recorded_fingerprint(monkeypatch: pytest.MonkeyPatch) -> None: + """Answer the pre-import identity check without the address-derivation RPCs tested separately.""" + monkeypatch.setattr(BitcoinCore, "fingerprint", lambda _client, _secret: _FINGERPRINT) + + def _descriptor_info(descriptor: str) -> dict[str, object]: """Return frozen Core-like normalization for the synthetic seed fixture.""" raw = descriptor.strip().split("#", 1)[0] @@ -386,7 +402,10 @@ def unlock(seconds: int) -> None: monkeypatch.setattr("codex32._bitcoin_core.sleep", unlock) - assert client.initialize(_SEED, lambda _prompt: "yes", messages.append) == "signer" + assert ( + client.initialize(_SEED, lambda _prompt: "yes", messages.append, expected_fingerprint=_FINGERPRINT) + == "signer" + ) private_calls = [call for call in rpc.calls if "xprv" in (call[2] or "")] assert len(private_calls) == 1 arguments, wallet, private_stdin = private_calls[0] @@ -418,7 +437,11 @@ def test_nonzero_or_noninteger_account_is_rejected_before_wallet_selection( monkeypatch.setattr(BitcoinCore, "_select", lambda *_args: pytest.fail("selected a wallet")) with pytest.raises(ValueError, match="only account 0"): BitcoinCore("bitcoin-cli", "main", 320000).initialize( - _SEED, lambda _prompt: "yes", lambda _message: None, account=account + _SEED, + lambda _prompt: "yes", + lambda _message: None, + expected_fingerprint=_FINGERPRINT, + account=account, ) @@ -439,7 +462,9 @@ def fail( monkeypatch.setattr(BitcoinCore, "_rpc", fail) client = BitcoinCore("bitcoin-cli", "main", 300000) with pytest.raises(BitcoinCoreError, match="did not create both wallet descriptors"): - client.initialize(_SEED, lambda _prompt: "yes", lambda _message: None) + client.initialize( + _SEED, lambda _prompt: "yes", lambda _message: None, expected_fingerprint=_FINGERPRINT + ) assert rpc.locked assert any(arguments == ("walletlock",) for arguments, _wallet, _stdin in rpc.calls) @@ -465,7 +490,7 @@ def rpc( monkeypatch.setattr(BitcoinCore, "_rpc", rpc) - assert BitcoinCore("bitcoin-cli", "main", 320000).fingerprint(_SEED) == bytes.fromhex("3f3521a6") + assert BitcoinCore("bitcoin-cli", "main", 320000).fingerprint_seed(_SEED.seed_bytes) == _FINGERPRINT assert calls == [ (("getdescriptorinfo",), None), (("deriveaddresses",), None), @@ -485,7 +510,13 @@ def test_numeric_timestamp_rescans_history(monkeypatch: pytest.MonkeyPatch, time ) client = BitcoinCore("bitcoin-cli", "main", 300000) assert ( - client.initialize(_SEED, lambda _prompt: "yes", lambda _message: None, timestamp=timestamp) + client.initialize( + _SEED, + lambda _prompt: "yes", + lambda _message: None, + expected_fingerprint=_FINGERPRINT, + timestamp=timestamp, + ) == "signer" ) calls = [(args, data) for args, _wallet, data in rpc.calls if args == ("importdescriptors",)] @@ -519,7 +550,11 @@ def test_failed_timestamped_rescan_relocks(monkeypatch: pytest.MonkeyPatch) -> N ) with pytest.raises(BitcoinCoreError, match="did not complete the timestamped wallet rescan"): BitcoinCore("bitcoin-cli", "main", 300000).initialize( - _SEED, lambda _prompt: "yes", lambda _message: None, timestamp=123 + _SEED, + lambda _prompt: "yes", + lambda _message: None, + expected_fingerprint=_FINGERPRINT, + timestamp=123, ) assert rpc.locked @@ -544,7 +579,9 @@ def fail( monkeypatch.setattr(BitcoinCore, "_rpc", fail) client = BitcoinCore("bitcoin-cli", "main", 300000) with pytest.raises(BitcoinCoreError, match="suppressed failure"): - client.initialize(_SEED, lambda _prompt: "yes", lambda _message: None) + client.initialize( + _SEED, lambda _prompt: "yes", lambda _message: None, expected_fingerprint=_FINGERPRINT + ) assert rpc.locked assert any(arguments == ("walletlock",) for arguments, _wallet, _stdin in rpc.calls) @@ -563,7 +600,9 @@ def interrupt( monkeypatch.setattr(BitcoinCore, "_rpc", interrupt) client = BitcoinCore("bitcoin-cli", "main", 300000) with pytest.raises(KeyboardInterrupt): - client.initialize(_SEED, lambda _prompt: "yes", lambda _message: None) + client.initialize( + _SEED, lambda _prompt: "yes", lambda _message: None, expected_fingerprint=_FINGERPRINT + ) assert rpc.locked @@ -590,7 +629,9 @@ def select(_client: BitcoinCore, _ask: object, _tell: object) -> str: ) with pytest.raises(KeyboardInterrupt): - BitcoinCore("bitcoin-cli", "main", 300000).initialize(_SEED, lambda _prompt: "yes", messages.append) + BitcoinCore("bitcoin-cli", "main", 300000).initialize( + _SEED, lambda _prompt: "yes", messages.append, expected_fingerprint=_FINGERPRINT + ) assert rpc.locked assert "That wallet is no longer eligible. Choose again." in messages assert any(arguments == ("walletlock",) for arguments, _wallet, _stdin in rpc.calls) @@ -613,7 +654,7 @@ def interrupt(_seconds: int) -> None: with pytest.raises(KeyboardInterrupt): BitcoinCore("bitcoin-cli", "main", 300000).initialize( - _SEED, lambda _prompt: "", lambda _message: None + _SEED, lambda _prompt: "", lambda _message: None, expected_fingerprint=_FINGERPRINT ) assert rpc.locked @@ -642,7 +683,7 @@ def target(*_args: object, **_options: object) -> tuple[bool, bool]: assert ( BitcoinCore("bitcoin-cli", "main", 300000).initialize( - _SEED, lambda _prompt: "", lambda _message: None + _SEED, lambda _prompt: "", lambda _message: None, expected_fingerprint=_FINGERPRINT ) == "signer" ) @@ -671,7 +712,12 @@ def interrupt_once( monkeypatch.setattr(BitcoinCore, "_rpc", interrupt_once) client = BitcoinCore("bitcoin-cli", "main", 300000) - assert client.initialize(_SEED, lambda _prompt: "yes", lambda _message: None) == "signer" + assert ( + client.initialize( + _SEED, lambda _prompt: "yes", lambda _message: None, expected_fingerprint=_FINGERPRINT + ) + == "signer" + ) assert rpc.locked and lock_calls == 2 @@ -686,7 +732,10 @@ def test_unencrypted_wallet_imports_without_a_lock_call(monkeypatch: pytest.Monk ) client = BitcoinCore("bitcoin-cli", "main", 300000) messages: list[str] = [] - assert client.initialize(_SEED, lambda _prompt: "yes", messages.append) == "signer" + assert ( + client.initialize(_SEED, lambda _prompt: "yes", messages.append, expected_fingerprint=_FINGERPRINT) + == "signer" + ) assert messages == [] assert not any(arguments == ("walletlock",) for arguments, _wallet, _stdin in rpc.calls) @@ -711,7 +760,7 @@ def test_immediate_revalidation_stops_before_private_import_and_relocks( ) with pytest.raises(BitcoinCoreError, match="changed before import"): - client.initialize(_SEED, lambda _prompt: "", lambda _message: None) + client.initialize(_SEED, lambda _prompt: "", lambda _message: None, expected_fingerprint=_FINGERPRINT) assert rpc.locked assert not any(arguments == ("addhdkey",) for arguments, _wallet, _stdin in rpc.calls) @@ -734,3 +783,81 @@ def run(command: list[str], **options: object) -> subprocess.CompletedProcess[st with pytest.raises(BitcoinCoreError) as failure: client._rpc("addhdkey", wallet="wallet", stdin=marker + "\n") assert marker not in str(failure.value) + + +@pytest.mark.parametrize("text", ("3f3521a6", "3F35 21A6", " 3f35\t21a6 ")) +def test_parse_fingerprint_accepts_record_spellings(text: str) -> None: + assert parse_fingerprint(text) == _FINGERPRINT + + +@pytest.mark.parametrize("text", ("", "3f3521a", "3f3521a6ff", "3f3521ag", "0x3f3521")) +def test_parse_fingerprint_rejects_other_text(text: str) -> None: + with pytest.raises(ValueError, match="8 characters"): + parse_fingerprint(text) + + +def test_identity_mismatch_stops_before_any_wallet_call(monkeypatch: pytest.MonkeyPatch) -> None: + rpc = _ImportRPC(locked=False) + monkeypatch.setattr( + BitcoinCore, + "_rpc", + lambda client, *args, wallet=None, stdin=None: rpc(client, *args, wallet=wallet, stdin=stdin), + ) + + with pytest.raises(FingerprintMismatch, match="Bitcoin Core was not changed"): + BitcoinCore("bitcoin-cli", "main", 300000).initialize( + _SEED, + lambda _prompt: "yes", + lambda _message: None, + expected_fingerprint=bytes.fromhex("3f3521a7"), + ) + assert rpc.calls == [] + + +def test_no_record_is_the_operators_choice_and_checks_nothing(monkeypatch: pytest.MonkeyPatch) -> None: + def unused(_client: BitcoinCore, _secret: MasterSeed) -> bytes: + raise AssertionError("no fingerprint is compared without a record") + + monkeypatch.setattr(BitcoinCore, "fingerprint", unused) + BitcoinCore("bitcoin-cli", "main", 300000).verify_identity(_SEED, None) + + +# Frozen from Bails' own ms32.seed_identifier for this seed: master (RIPEMD-160) and the +# June 2023 alpha (SHA-256). Bails checked three characters and kept the fourth for re-sharing. +_BAILS_SEED = bytes(range(16)) + + +@pytest.mark.parametrize( + ("identifier", "origin"), + ( + (_fingerprint_identifier(_FINGERPRINT), "codex32"), + ("d9k8", "Bails"), + ("d9kq", "Bails"), + ("hezu", "Bails alpha"), + ("test", None), + ), +) +def test_identifier_origin_names_the_rule_that_made_it(identifier: str, origin: str | None) -> None: + secret = MasterSeed.from_seed(_BAILS_SEED, identifier=identifier) + assert identifier_origin(secret, _FINGERPRINT) == origin + assert ("matches this seed" in identifier_note(origin)) is (origin is not None) + + +def test_identifier_origin_still_checks_alpha_without_ripemd160(monkeypatch: pytest.MonkeyPatch) -> None: + original_new = hashlib.new + + def without_ripemd160(name: str, data: bytes = b"") -> object: + if name == "ripemd160": + raise ValueError("unsupported hash type ripemd160") + return original_new(name, data) + + monkeypatch.setattr(hashlib, "new", without_ripemd160) + secret = MasterSeed.from_seed(_BAILS_SEED, identifier="hezu") + assert identifier_origin(secret, _FINGERPRINT) == "Bails alpha" + + +def test_identifier_note_allows_supported_nonderived_codex32_identifiers() -> None: + note = identifier_note(None) + assert "split shares" in note + assert "supplied seed bytes" in note + assert "explicit identifier" in note diff --git a/tests/test_cli.py b/tests/test_cli.py index 02e74c3..889b175 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -32,6 +32,7 @@ parse_codex32, recover_secret, ) +from codex32._bitcoin_core import BitcoinCore from codex32.bech32 import _chars_to_u5, bech32_encode from codex32.checksums import _CODEX32, _CODEX32_LONG from codex32.cli import main, ms_main @@ -93,6 +94,7 @@ class _FakeBitcoinCore: imported: MasterSeed | None = None account: int | None = None timestamp: int | str | None = None + expected: bytes | None = None def fingerprint_seed(self, seed: bytes) -> bytes: return stub_fingerprint(seed) @@ -100,16 +102,22 @@ def fingerprint_seed(self, seed: bytes) -> bytes: def fingerprint(self, secret: MasterSeed) -> bytes: return self.fingerprint_seed(secret.seed_bytes) + def verify_identity(self, secret: MasterSeed, expected_fingerprint: bytes | None) -> None: + BitcoinCore.verify_identity(self, secret, expected_fingerprint) # type: ignore[arg-type] + def initialize( self, secret: MasterSeed, _ask: Callable[[str], str], _tell: Callable[[str], None], *, + expected_fingerprint: bytes | None, private: bool = True, account: int = 0, timestamp: int | str = "now", ) -> str: + self.verify_identity(secret, expected_fingerprint) + self.expected = expected_fingerprint self.imported = secret self.account, self.timestamp = account, timestamp return "test-wallet" @@ -120,6 +128,17 @@ def _offline_core(monkeypatch): monkeypatch.setattr("codex32.cli.BitcoinCore.connect", lambda *args, **kwargs: _FakeBitcoinCore()) +_RECORDED_FINGERPRINT = importlib.import_module("codex32.cli")._recorded_fingerprint +_SHOW_FINGERPRINT = importlib.import_module("codex32.cli")._show_fingerprint + + +@pytest.fixture(autouse=True) +def _matching_record(monkeypatch): + """Keep unrelated CLI tests independent of wallet-record interaction.""" + monkeypatch.setattr("codex32.cli._recorded_fingerprint", lambda core, secret: core.fingerprint(secret)) + monkeypatch.setattr("codex32.cli._show_fingerprint", lambda _core, _secret, _action: None) + + def _invoke(args: list[str], *lines: str) -> _Result: stdin = io.StringIO("\n".join(lines) + "\n") stdout = io.StringIO() @@ -494,7 +513,8 @@ def answer(prompt: str, prefill: str = "") -> str: captured = capsys.readouterr() assert captured.out.strip().startswith(expected) if command[0] == "wallet": - assert "Possible correction:\n\nMaster fingerprint: 3F3521A6\n\n" in captured.err + assert "Possible correction:\n\nMaster fingerprint:" not in captured.err + assert "Master fingerprint: 3F3521A6" in captured.err else: assert "Master fingerprint:" not in captured.err assert input_module._card_text(original, False) in captured.err @@ -2021,6 +2041,7 @@ def test_wallet_commands_initialize_selected_master_seed_destinations() -> None: assert xprv.stderr.endswith("Keep it secret.\n\n") assert private.stdout == "" assert private_core.imported == parse_codex32(VECTOR_1["secret_s"]) + assert private_core.expected == private_core.fingerprint(private_core.imported) assert "Warning: This gives Bitcoin Core the master private key, which can spend funds." in private.stderr assert "Use only the intended encrypted wallet" not in private.stderr assert "\x1b[" not in private.stderr + private.stdout @@ -2904,3 +2925,141 @@ def test_incomplete_candidate_has_no_search_warning_and_is_never_accepted_automa assert "Search incomplete" not in result.stderr assert "may not be unique" not in result.stderr assert "only a correction suggestion" in result.stderr + + +def _record_answers(monkeypatch: pytest.MonkeyPatch, *answers: str) -> list[str]: + prompts: list[str] = [] + remaining = iter(answers) + + def answer(prompt: str, **_options: object) -> str: + prompts.append(prompt) + return next(remaining) + + monkeypatch.setattr(importlib.import_module("codex32.cli"), "_text", answer) + return prompts + + +def test_restore_record_prompt_retries_until_the_library_accepts( + monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] +) -> None: + core, secret = _FakeBitcoinCore(), parse_codex32(VECTOR_1["secret_s"]) + assert isinstance(secret, MasterSeed) + right = core.fingerprint(secret) + wrong = bytes([right[0] ^ 1]) + right[1:] + prompts = _record_answers(monkeypatch, "not hex", wrong.hex(), right.hex().upper()) + + assert _RECORDED_FINGERPRINT(core, secret) == right + assert prompts == ["Type the master fingerprint from your wallet record (Enter if none)"] * 3 + errors = capsys.readouterr().err + assert "8 characters" in errors and "does not match" in errors + assert right.hex() not in errors.lower() + + +def test_restore_without_a_record_shows_what_the_cards_say_and_asks( + monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] +) -> None: + core, secret = _FakeBitcoinCore(), parse_codex32(VECTOR_1["secret_s"]) + assert isinstance(secret, MasterSeed) + fingerprint = core.fingerprint(secret) + + prompts = _record_answers(monkeypatch, "", "n") + interrupted = importlib.import_module("codex32.cli")._WalletSetupInterrupted + with pytest.raises(interrupted): + _RECORDED_FINGERPRINT(core, secret) + assert prompts[1] == "Restore without a wallet record? [y/N]" + shown = capsys.readouterr().err + assert shown.count(f"Master fingerprint: {fingerprint.hex().upper()}") == 1 + assert "was not made from this seed" in shown and "nothing can prove" in shown + + prompts = _record_answers(monkeypatch, "", "y") + assert _RECORDED_FINGERPRINT(core, secret) is None + assert prompts[1] == "Restore without a wallet record? [y/N]" + + derived = MasterSeed.from_seed(secret.seed_bytes, identifier=_fingerprint_identifier(fingerprint)) + _record_answers(monkeypatch, "", "yes") + assert _RECORDED_FINGERPRINT(core, derived) is None + assert "matches this seed (codex32 rule)" in capsys.readouterr().err + + +def test_wallet_restore_hides_fingerprint_until_the_record_gate(monkeypatch: pytest.MonkeyPatch) -> None: + cli = importlib.import_module("codex32.cli") + core, secret = _FakeBitcoinCore(), parse_codex32(VECTOR_1["secret_s"]) + assert isinstance(secret, MasterSeed) + seen: list[object] = [] + + monkeypatch.setattr(cli.sys, "stdin", _TTYInput()) + monkeypatch.setattr(cli, "_connected_core", lambda: core) + monkeypatch.setattr(cli, "_master_seed", lambda fingerprint=None: seen.append(fingerprint) or secret) + monkeypatch.setattr(cli, "_initialize_wallet", lambda *_args, **_kwargs: 0) + + assert cli._bitcoin_core(0, "now") == 0 + assert seen == [None] + + +def test_create_only_requires_acknowledging_that_the_fingerprint_was_recorded( + monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] +) -> None: + core, secret = _FakeBitcoinCore(), parse_codex32(VECTOR_1["secret_s"]) + assert isinstance(secret, MasterSeed) + right = core.fingerprint(secret) + prompts = _record_answers(monkeypatch, "") + + _SHOW_FINGERPRINT(core, secret, "Write it on the wallet record") + assert prompts[0] == "Write it on the wallet record, then press Enter" + shown = capsys.readouterr().err + assert f"Master fingerprint: {right.hex().upper()}" in shown + + +def test_create_initialization_does_not_authenticate_against_a_preexisting_wallet( + monkeypatch: pytest.MonkeyPatch, +) -> None: + core, secret = _FakeBitcoinCore(), parse_codex32(VECTOR_1["secret_s"]) + assert isinstance(secret, MasterSeed) + shown: list[str] = [] + monkeypatch.setattr( + "codex32.cli._show_fingerprint", + lambda _core, _secret, action: shown.append(action), + ) + + assert importlib.import_module("codex32.cli")._initialize_wallet(core, secret, confirmed=False) == 0 + assert shown == ["Write it on the wallet record"] + assert core.expected is None + + +@pytest.mark.parametrize("header", (None, "2")) +def test_create_existing_checks_the_record_before_import(monkeypatch: pytest.MonkeyPatch, header: str | None): + cli = importlib.import_module("codex32.cli") + secret = parse_codex32(VECTOR_1["secret_s"]) + core = _FakeBitcoinCore() + checked = [] + source_fingerprints = [] + emitted_fingerprints = [] + + def source(_profile, fingerprint=None): + source_fingerprints.append(fingerprint) + return secret + + def record(_core, recovered): + assert core.imported is None + checked.append(recovered.seed_bytes) + return core.fingerprint(recovered) + + def confirm(artifact, accept=None): + if accept: + accept(artifact.text) + + def emit(_artifact, _plain, **kwargs): + emitted_fingerprints.append(kwargs.get("fingerprint")) + + monkeypatch.setattr(sys, "stdin", _TTYInput()) + monkeypatch.setattr(sys, "stdout", _TTYOutput()) + monkeypatch.setattr(cli, "_creation_source", source) + monkeypatch.setattr(cli, "_emit", emit) + monkeypatch.setattr(cli, "_confirm_card", confirm) + monkeypatch.setattr(cli, "_recorded_fingerprint", record) + monkeypatch.setattr(cli.BitcoinCore, "connect", lambda *args: core) + assert ms_main(["create", "--existing", *([header] if header else [])]) == 0 + assert source_fingerprints == [None] + assert emitted_fingerprints and all(fingerprint is None for fingerprint in emitted_fingerprints) + assert checked == [secret.seed_bytes] + assert core.expected == core.fingerprint(secret) diff --git a/tools/bitcoin_core_main_smoke.py b/tools/bitcoin_core_main_smoke.py index 34775fd..6314720 100644 --- a/tools/bitcoin_core_main_smoke.py +++ b/tools/bitcoin_core_main_smoke.py @@ -115,12 +115,14 @@ def rpc(*rpc_arguments: str, wallet: str | None = None, stdin: str | None = None if not isinstance(secret, MasterSeed): raise TypeError("synthetic fixture was not a master seed") client = BitcoinCore.connect() + expected_fingerprint = client.fingerprint(secret) answers = iter(("yes",)) if ( client.initialize( secret, lambda _prompt: next(answers), lambda _message: None, + expected_fingerprint=expected_fingerprint, account=0, timestamp=0, ) diff --git a/tools/bitcoin_core_regtest.py b/tools/bitcoin_core_regtest.py index e7c8e3e..56c9742 100644 --- a/tools/bitcoin_core_regtest.py +++ b/tools/bitcoin_core_regtest.py @@ -132,12 +132,14 @@ 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] answers = iter(("yes",)) if ( client.initialize( secret, lambda _prompt: next(answers), lambda _message: None, + expected_fingerprint=expected_fingerprint, account=0, timestamp=0, ) @@ -177,6 +179,7 @@ def rpc(*rpc_arguments: str, wallet: str | None = None, stdin: str | None = None secret, lambda _prompt: next(restore_answers), lambda _message: None, + expected_fingerprint=expected_fingerprint, account=0, timestamp=0, ) @@ -204,6 +207,7 @@ def rpc(*rpc_arguments: str, wallet: str | None = None, stdin: str | None = None lambda _prompt: "yes", lambda _message: None, account=0, + expected_fingerprint=expected_fingerprint, timestamp=recent_timestamp, ) != "restore_recent" From f41ae17b0c024873b93b8e6b9ad2794e4f88645f Mon Sep 17 00:00:00 2001 From: Codex Agent Date: Thu, 1 Oct 2026 02:01:23 -0500 Subject: [PATCH 06/16] correction: Drop unused search plan fields Every correction plan returned its target set, that same set as primary, an empty reduced set, and a true timed flag. Only the targets and primary set were consumed. Derive primary from targets at the call site and remove the other fields. The search engine also accepted reduced without reading it, so remove that argument and update its test and benchmark callers. Search order and capture accounting remain unchanged. Refs #46. --- src/codex32/_cli_input.py | 33 ++++++++++----------------------- src/codex32/indel.py | 1 - tests/test_cli.py | 3 +-- tests/test_correction_indel.py | 3 +-- tools/correction_benchmark.py | 1 - 5 files changed, 12 insertions(+), 29 deletions(-) diff --git a/src/codex32/_cli_input.py b/src/codex32/_cli_input.py index cf5d25d..53ba5b9 100644 --- a/src/codex32/_cli_input.py +++ b/src/codex32/_cli_input.py @@ -410,36 +410,24 @@ def _correction_plan( byte_length: int | Literal["?"] | None, count: int, target: int | None, -) -> tuple[tuple[int, ...], frozenset[int], frozenset[int], bool]: +) -> tuple[int, ...]: if target is not None: - return ( - (target,), - frozenset((target,)), - frozenset(), - True, - ) + return (target,) normalized_hrp = hrp.value if isinstance(hrp, Profile) else hrp.lower() if normalized_hrp == Profile.CL.value: - return (74,), frozenset((74,)), frozenset(), True + return (74,) if isinstance(byte_length, int): - return ( - ((length := _text_length(byte_length)),), - frozenset((length,)), - frozenset(), - True, - ) + return (_text_length(byte_length),) if byte_length == "?": - return TEXT_LENGTHS, frozenset(TEXT_LENGTHS), frozenset(), True + return TEXT_LENGTHS if normalized_hrp == Profile.MS.value: nearest = min(_PRIMARY_MS, key=lambda length: abs(count - length)) targets = (nearest, *(length for length in TEXT_LENGTHS if length != nearest)) - return targets, frozenset(targets), frozenset(), True + return targets rules = _optional_profile_rules(normalized_hrp) if rules is not None and hasattr(rules, "text_length"): - targets = (rules.text_length,) - return targets, frozenset(targets), frozenset(), True - targets = tuple(sorted({count + delta for delta in (*range(-4, 5), -8, 8)})) - return targets, frozenset(targets), frozenset(), True + return (rules.text_length,) + return tuple(sorted({count + delta for delta in (*range(-4, 5), -8, 8)})) def _correction_candidates( @@ -459,7 +447,7 @@ def _correction_candidates( optional_only: bool = False, ) -> tuple[tuple[CorrectionCandidate, ...], bool, float]: count = len(value.replace(" ", "")) - targets, primary, reduced, _timed = _correction_plan(profile, byte_length, count, target) + targets = _correction_plan(profile, byte_length, count, target) deadline = monotonic() + 10 if deadline is None else deadline contexts = tuple(CorrectionContext(profile, length, immutable, excluded) for length in targets) from codex32.indel import _search_many @@ -467,8 +455,7 @@ def _correction_candidates( candidates, complete = _search_many( contexts, value, - primary=primary, - reduced=reduced, + primary=frozenset(targets), deadline=deadline, competitors=True, allowed=allowed, diff --git a/src/codex32/indel.py b/src/codex32/indel.py index 26c0071..98a6369 100644 --- a/src/codex32/indel.py +++ b/src/codex32/indel.py @@ -552,7 +552,6 @@ def _search_many( damaged_text: str, *, primary: frozenset[int], - reduced: frozenset[int] = frozenset(), deadline: float | None = None, max_character_depth: int = 4, competitors: bool = False, diff --git a/tests/test_cli.py b/tests/test_cli.py index 889b175..82f3cfe 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -1835,14 +1835,13 @@ def test_correction_options_control_lengths_deadline_and_search_envelope( assert observed == damaged assert tuple(context.expected_length for context in contexts) == lengths assert (search.call_args.kwargs["deadline"] is not None) is bounded - assert search.call_args.kwargs["reduced"] == frozenset() def test_automatic_target_selection_covers_midpoints_and_supported_lengths() -> None: from codex32._cli_input import _correction_plan for observed in range(40, 136): - targets = _correction_plan(Profile.MS, None, observed, None)[0] + targets = _correction_plan(Profile.MS, None, observed, None) expected = 48 if observed <= 61 else 74 if observed <= 100 else 127 assert targets[0] == expected diff --git a/tests/test_correction_indel.py b/tests/test_correction_indel.py index 646e8b5..2c860cf 100644 --- a/tests/test_correction_indel.py +++ b/tests/test_correction_indel.py @@ -158,7 +158,7 @@ def test_automatic_secondary_search_recovers_two_group_indels( damaged = _group_damage(source, inserted, omitted) contexts = tuple( CorrectionContext(Profile.MS, target, "ms1") - for target in _correction_plan(Profile.MS, None, len(damaged), None)[0] + for target in _correction_plan(Profile.MS, None, len(damaged), None) ) candidates, complete = _search_many( @@ -507,7 +507,6 @@ def search(state, _frontier, results, _deadline): # type: ignore[no-untyped-def contexts, damaged, primary=frozenset((48, 74, 127)), - reduced=frozenset((54, 61, 67)), ) return calls diff --git a/tools/correction_benchmark.py b/tools/correction_benchmark.py index 922632d..c8d4842 100644 --- a/tools/correction_benchmark.py +++ b/tools/correction_benchmark.py @@ -108,7 +108,6 @@ def benchmark_cross_length(observed_length: int, *, unknown: bool = False) -> di contexts, damaged, primary=frozenset((48, 74, 127)), - reduced=frozenset() if unknown else frozenset((54, 61, 67)), ) seconds = perf_counter() - started if not complete or candidates: From 52c23b30a6c9ddb1a756ddbf47772358298a1723 Mon Sep 17 00:00:00 2001 From: Codex Agent Date: Thu, 1 Oct 2026 02:01:33 -0500 Subject: [PATCH 07/16] cli: Skip redundant share filtering The preceding all-isinstance check rejects every non-share, so the list-comprehension predicate in recovery could never discard an item. Pass the validated list directly, using a type cast to express the established invariant to mypy. Recovery still copies and validates the sequence internally. Refs #46. --- src/codex32/cli.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/codex32/cli.py b/src/codex32/cli.py index 6e81e43..9ceae95 100644 --- a/src/codex32/cli.py +++ b/src/codex32/cli.py @@ -102,7 +102,7 @@ def _secret(artifacts: list[Artifact]) -> Secret: if not all(isinstance(artifact, Share) for artifact in artifacts): raise _UsageError("Recovery accepts ordinary shares or one complete secret.") try: - return recover_secret([artifact for artifact in artifacts if isinstance(artifact, Share)]) + return recover_secret(cast(list[Share], artifacts)) except CodexError as error: raise _UsageError(str(error)) from error From 74febacabedecc39ac2fc24dd84037d24734758a Mon Sep 17 00:00:00 2001 From: Codex Agent Date: Wed, 30 Sep 2026 12:25:04 -0500 Subject: [PATCH 08/16] wallet: Mark unavailable Bails check inconclusive When RIPEMD-160 is unavailable, a valid standard Bails identifier cannot be checked. Preserve the Bails-alpha SHA-256 result and distinguish that inconclusive state from a completed identifier mismatch, so the no-record restore prompt does not claim the cards are wrong. Keep the independent fingerprint and explicit operator-confirmation boundary unchanged. Refs #79 --- src/codex32/_bitcoin_core.py | 11 ++++++++++- tests/test_bitcoin_core.py | 15 ++++++++++++--- 2 files changed, 22 insertions(+), 4 deletions(-) diff --git a/src/codex32/_bitcoin_core.py b/src/codex32/_bitcoin_core.py index 5b78a74..51712e4 100644 --- a/src/codex32/_bitcoin_core.py +++ b/src/codex32/_bitcoin_core.py @@ -60,6 +60,12 @@ def parse_fingerprint(text: str) -> bytes: def identifier_note(origin: str | None) -> str: # Say what `identifier_origin` found, for an operator restoring without a record. + if origin == "Bails check unavailable": + return ( + "The standard Bails identifier could not be checked because RIPEMD-160 is unavailable. " + "This does not prove the cards are wrong or mixed up. Compare the fingerprint and any " + "other wallet record you have before restoring." + ) if origin is None: return ( "The backup identifier was not made from this seed. That can be normal for codex32 backups " @@ -77,15 +83,18 @@ def identifier_origin(secret: MasterSeed, fingerprint: bytes) -> str | None: identifier = secret.header.identifier if identifier == _fingerprint_identifier(fingerprint): return "codex32" + ripemd_unavailable = False for name, digest in (("Bails", "ripemd160"), ("Bails alpha", "sha256")): try: hashed = hashlib.new(digest, secret.seed_bytes).digest() except ValueError: + if name == "Bails": + ripemd_unavailable = True continue derived = convertbits(hashed, 8, 5, pad=True) if identifier[:3] == _u5_to_chars(tuple(derived[:3])): return name - return None + return "Bails check unavailable" if ripemd_unavailable else None @dataclass(frozen=True) diff --git a/tests/test_bitcoin_core.py b/tests/test_bitcoin_core.py index f6323c9..9737203 100644 --- a/tests/test_bitcoin_core.py +++ b/tests/test_bitcoin_core.py @@ -843,7 +843,13 @@ def test_identifier_origin_names_the_rule_that_made_it(identifier: str, origin: assert ("matches this seed" in identifier_note(origin)) is (origin is not None) -def test_identifier_origin_still_checks_alpha_without_ripemd160(monkeypatch: pytest.MonkeyPatch) -> None: +@pytest.mark.parametrize( + ("identifier", "expected"), + (("hezu", "Bails alpha"), ("d9k8", "Bails check unavailable")), +) +def test_identifier_origin_without_ripemd160( + monkeypatch: pytest.MonkeyPatch, identifier: str, expected: str +) -> None: original_new = hashlib.new def without_ripemd160(name: str, data: bytes = b"") -> object: @@ -852,8 +858,11 @@ def without_ripemd160(name: str, data: bytes = b"") -> object: return original_new(name, data) monkeypatch.setattr(hashlib, "new", without_ripemd160) - secret = MasterSeed.from_seed(_BAILS_SEED, identifier="hezu") - assert identifier_origin(secret, _FINGERPRINT) == "Bails alpha" + secret = MasterSeed.from_seed(_BAILS_SEED, identifier=identifier) + assert identifier_origin(secret, _FINGERPRINT) == expected + if expected == "Bails check unavailable": + assert "could not be checked" in identifier_note(expected) + assert "does not prove" in identifier_note(expected) def test_identifier_note_allows_supported_nonderived_codex32_identifiers() -> None: From 23a2c059c877712544d066e6fb63b20ed40e55a7 Mon Sep 17 00:00:00 2001 From: Codex Agent Date: Wed, 30 Sep 2026 12:42:13 -0500 Subject: [PATCH 09/16] docs: Define inconclusive Bails identity result The no-record restore flow can no longer claim a standard Bails identifier mismatch when RIPEMD-160 is unavailable. Record that platform-dependent inconclusive outcome in both the security model and invariant so reviewers can distinguish it from a completed comparison. Refs #79 --- docs/security/invariants.md | 7 ++++--- docs/security/model.md | 2 +- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/docs/security/invariants.md b/docs/security/invariants.md index 42adce5..be5ed48 100644 --- a/docs/security/invariants.md +++ b/docs/security/invariants.md @@ -14,9 +14,10 @@ and evidence. Restore authenticates the recovered seed before any wallet is listed, unlocked, or imported into: normally with the master fingerprint typed from the wallet record, or by an explicit no-record choice made after seeing the - recovered fingerprint and whether the backup identifier was derived from the - seed. Fresh `ms32 create` ceremonies do not authenticate against a - pre-existing wallet; they require the operator to record the new fingerprint. + recovered fingerprint and whether the backup identifier matched a + seed-derived rule or its standard Bails check was unavailable. Fresh + `ms32 create` ceremonies do not authenticate against a pre-existing wallet; + they require the operator to record the new fingerprint. 5. Correction shares one mass bound and deadline across target lengths. The public API fails closed on incomplete required work; CLI searches may return one primary-best-so-far eligible candidate at the deadline. Incomplete diff --git a/docs/security/model.md b/docs/security/model.md index 2d3e18b..da86118 100644 --- a/docs/security/model.md +++ b/docs/security/model.md @@ -231,7 +231,7 @@ signing setup belong to Bitcoin Core's maintained v32 workflow. | Control | Required behavior | |---|---| | Preflight | Before entropy or recovery input, explicit chain arguments probe the five standard local networks for Bitcoin Core 32 or newer. One response is selected automatically; multiple responses require operator selection. | -| Recovery identity | `ms32 wallet` and `ms32 create --existing` authenticate a recovered seed before any wallet is listed. Core derives the recovered fingerprint statelessly, and a mismatch raises `FingerprintMismatch` before any wallet RPC. The restore prompt does not show the recovered value, so the operator compares by typing the fingerprint from the wallet record. Without a record, the operator is shown the recovered fingerprint, whether the backup identifier was derived from the seed (the codex32 fingerprint rule, Bails' RIPEMD-160 rule, or its mid-2023 alpha's SHA-256 rule), and a warning, and then chooses. Fresh `ms32 create` has no pre-existing wallet to authenticate: it shows the newly created seed's fingerprint and requires the operator to acknowledge recording it. These checks catch mistakes such as wrong or mixed cards; anyone able to replace a threshold of cards could already read them. | +| Recovery identity | `ms32 wallet` and `ms32 create --existing` authenticate a recovered seed before any wallet is listed. Core derives the recovered fingerprint statelessly, and a mismatch raises `FingerprintMismatch` before any wallet RPC. The restore prompt does not show the recovered value, so the operator compares by typing the fingerprint from the wallet record. Without a record, the operator is shown the recovered fingerprint, whether the backup identifier was derived from the seed (the codex32 fingerprint rule, Bails' RIPEMD-160 rule, or its mid-2023 alpha's SHA-256 rule), and a warning, and then chooses. If RIPEMD-160 is unavailable, the standard Bails identifier check is reported as inconclusive rather than a mismatch; the Bails-alpha SHA-256 rule remains checkable. Fresh `ms32 create` has no pre-existing wallet to authenticate: it shows the newly created seed's fingerprint and requires the operator to acknowledge recording it. These checks catch mistakes such as wrong or mixed cards; anyone able to replace a threshold of cards could already read them. | | Process boundary | codex32 invokes the reviewed `bitcoin-cli` from `PATH` as a child without a shell, direct RPC socket, wallet database, or wallet-creation operation. Every call uses loopback and the selected chain. | | Destination | Only an empty descriptor wallet with private keys enabled, no external signer, transactions, descriptors, keypool entries, or active scan is eligible. One eligible wallet is offered directly; multiple wallets are selected by number. New wallets are detected by polling, and rejection returns to every eligible wallet. The escaped name is confirmed exactly. | | Seed source | The original ceremony result or validated recovered master seed supplies a root xprv for Core's reported chain. Core v32 creates BIP44, BIP49, BIP84, and BIP86 account-0 descriptors from that key. | From 7d2c2cb8b036795f71fefcc67eb36e3db4915708 Mon Sep 17 00:00:00 2001 From: Codex Agent Date: Wed, 30 Sep 2026 17:33:22 -0500 Subject: [PATCH 10/16] wallet: Check existing seed before sharing An existing hex seed or codex32 master secret previously reached the wallet-record fingerprint check only after new recovery cards had been generated and confirmed. Check the typed record immediately after parsing the source, before any card output or ceremony. Preserve the explicit recordless path at the same early decision point, and pass the checked result through to wallet initialization so it is not prompted twice. Cover matching, mismatching, and recordless flows for both source encodings. Refs #30. --- docs/security/model.md | 2 +- docs/user/guide.md | 6 ++- src/codex32/cli.py | 69 ++++++++++++++--------------- tests/test_cli.py | 99 ++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 140 insertions(+), 36 deletions(-) diff --git a/docs/security/model.md b/docs/security/model.md index da86118..e025fb3 100644 --- a/docs/security/model.md +++ b/docs/security/model.md @@ -231,7 +231,7 @@ signing setup belong to Bitcoin Core's maintained v32 workflow. | Control | Required behavior | |---|---| | Preflight | Before entropy or recovery input, explicit chain arguments probe the five standard local networks for Bitcoin Core 32 or newer. One response is selected automatically; multiple responses require operator selection. | -| Recovery identity | `ms32 wallet` and `ms32 create --existing` authenticate a recovered seed before any wallet is listed. Core derives the recovered fingerprint statelessly, and a mismatch raises `FingerprintMismatch` before any wallet RPC. The restore prompt does not show the recovered value, so the operator compares by typing the fingerprint from the wallet record. Without a record, the operator is shown the recovered fingerprint, whether the backup identifier was derived from the seed (the codex32 fingerprint rule, Bails' RIPEMD-160 rule, or its mid-2023 alpha's SHA-256 rule), and a warning, and then chooses. If RIPEMD-160 is unavailable, the standard Bails identifier check is reported as inconclusive rather than a mismatch; the Bails-alpha SHA-256 rule remains checkable. Fresh `ms32 create` has no pre-existing wallet to authenticate: it shows the newly created seed's fingerprint and requires the operator to acknowledge recording it. These checks catch mistakes such as wrong or mixed cards; anyone able to replace a threshold of cards could already read them. | +| Recovery identity | `ms32 wallet` and `ms32 create --existing` authenticate a recovered seed before any wallet is listed. For `create --existing`, the wallet-record decision also precedes generation or display of any new card. Core derives the recovered fingerprint statelessly, and a mismatch raises `FingerprintMismatch` before any wallet RPC. The restore prompt does not show the recovered value, so the operator compares by typing the fingerprint from the wallet record. Without a record, the operator is shown the recovered fingerprint, whether the backup identifier was derived from the seed (the codex32 fingerprint rule, Bails' RIPEMD-160 rule, or its mid-2023 alpha's SHA-256 rule), and a warning, and then chooses. If RIPEMD-160 is unavailable, the standard Bails identifier check is reported as inconclusive rather than a mismatch; the Bails-alpha SHA-256 rule remains checkable. Fresh `ms32 create` has no pre-existing wallet to authenticate: it shows the newly created seed's fingerprint and requires the operator to acknowledge recording it. These checks catch mistakes such as wrong or mixed cards; anyone able to replace a threshold of cards could already read them. | | Process boundary | codex32 invokes the reviewed `bitcoin-cli` from `PATH` as a child without a shell, direct RPC socket, wallet database, or wallet-creation operation. Every call uses loopback and the selected chain. | | Destination | Only an empty descriptor wallet with private keys enabled, no external signer, transactions, descriptors, keypool entries, or active scan is eligible. One eligible wallet is offered directly; multiple wallets are selected by number. New wallets are detected by polling, and rejection returns to every eligible wallet. The escaped name is confirmed exactly. | | Seed source | The original ceremony result or validated recovered master seed supplies a root xprv for Core's reported chain. Core v32 creates BIP44, BIP49, BIP84, and BIP86 account-0 descriptors from that key. | diff --git a/docs/user/guide.md b/docs/user/guide.md index 332d77a..422ec99 100644 --- a/docs/user/guide.md +++ b/docs/user/guide.md @@ -123,7 +123,11 @@ Already have a complete codex32 `ms` secret? Run `ms32 create --existing` to write and confirm its recovery card and initialize a Bitcoin Core wallet. The existing secret is preserved unchanged. To split it into three cards requiring any two, use `ms32 create 2 --existing` instead. Enter the secret -only when prompted. Bitcoin Core also scans for prior transactions. +only when prompted. Immediately afterward, type the master fingerprint from +the separate wallet record; a mismatch must be resolved before any new card +is shown. If you have no record, the explicit recordless-restore choice and +visual fingerprint check happen at this same point. Bitcoin Core also scans +for prior transactions. ### 3. Make a Bitcoin Core wallet diff --git a/src/codex32/cli.py b/src/codex32/cli.py index 9ceae95..95791d5 100644 --- a/src/codex32/cli.py +++ b/src/codex32/cli.py @@ -394,12 +394,16 @@ def _initialize_wallet( fresh: bool = True, restore: bool = False, confirmed: bool = True, + identity_checked: bool = False, + expected_fingerprint: bytes | None = None, ) -> int: assert isinstance(secret, MasterSeed) try: if confirmed: _print("Master-seed backup confirmed.\n", err=True) - expected = _recorded_fingerprint(core, secret) if restore else None + expected = expected_fingerprint + if restore and not identity_checked: + expected = _recorded_fingerprint(core, secret) if not restore: _show_fingerprint(core, secret, "Write it on the wallet record") name = core.initialize( @@ -489,37 +493,39 @@ def _create( if isinstance(source, (Share, Secret)) and not isinstance(source, MasterSeed): raise _UsageError(f"Enter one {_profile_rules(profile).label}, 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: - raise _UsageError( - "To change the existing secret's identifier, choose a sharing threshold from 2 through 9." - ) - secret = source - else: - secret = _generated_secret(source, byte_length, identifier, core.fingerprint_seed) - _emit(secret, False, fingerprint=None if existing else core.fingerprint) - if sys.stdin.isatty(): - _confirm_card(secret) - return ( - _initialize_wallet( - core, secret, timestamp=0 if existing else "now", fresh=not existing, restore=existing + if isinstance(source, MasterSeed): + if threshold == 0 and identifier is not None and identifier != source.header.identifier: + raise _UsageError( + "To change the existing secret's identifier, choose a sharing threshold from 2 through 9." ) - if core is not None - else 0 + existing_secret = source + elif source is not None: + existing_secret = _generated_secret(source, None, identifier, core.fingerprint_seed) + else: + existing_secret = None + expected = _recorded_fingerprint(core, existing_secret) if existing_secret is not None else None + + def finish_wallet(seed: MasterSeed) -> int: + return _initialize_wallet( + core, + seed, + timestamp=0 if existing else "now", + fresh=not existing, + restore=existing, + identity_checked=existing, + expected_fingerprint=expected, ) - if isinstance(source, MasterSeed): - ceremony = CreationCeremony.from_secret( - source, - threshold=threshold, - identifier=identifier, - share_count=shares, - indices=indices, + + if threshold == 0: + secret = existing_secret or _generated_secret( + None, byte_length, identifier, core.fingerprint_seed ) - elif source is not None: - source_secret = _generated_secret(source, None, identifier, core.fingerprint_seed) + _emit(secret, False, fingerprint=None if existing else core.fingerprint) + _confirm_card(secret) + return finish_wallet(secret) + if existing_secret is not None: ceremony = CreationCeremony.from_secret( - source_secret, + existing_secret, threshold=threshold, identifier=identifier, share_count=shares, @@ -545,12 +551,7 @@ def _create( _print(f"Recovery card {position + 1} of {output_count} confirmed.", err=True) finished = ceremony.finish() assert isinstance(finished, MasterSeed) - if core is not None: - return _initialize_wallet( - core, finished, timestamp=0 if existing else "now", fresh=not existing, restore=existing - ) - _print("\nEvery recovery card was confirmed from its re-entered text.", err=True) - return 0 + return finish_wallet(finished) def _correct( diff --git a/tests/test_cli.py b/tests/test_cli.py index 82f3cfe..2b52fe4 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -3062,3 +3062,102 @@ def emit(_artifact, _plain, **kwargs): assert emitted_fingerprints and all(fingerprint is None for fingerprint in emitted_fingerprints) assert checked == [secret.seed_bytes] assert core.expected == core.fingerprint(secret) + + +@pytest.mark.parametrize("encoding", ("hex", "codex32")) +@pytest.mark.parametrize("shared", (False, True)) +def test_create_existing_checks_record_before_card_output( + monkeypatch: pytest.MonkeyPatch, encoding: str, shared: bool +) -> None: + cli = importlib.import_module("codex32.cli") + secret = parse_codex32(VECTOR_1["secret_s"]) + assert isinstance(secret, MasterSeed) + core = _FakeBitcoinCore() + source = secret.seed_bytes.hex() if encoding == "hex" else secret.text + answers = iter((source, core.fingerprint(secret).hex().upper())) + events: list[str] = [] + + def check_record(selected: _FakeBitcoinCore, supplied: MasterSeed) -> bytes | None: + assert events == [] + checked = _RECORDED_FINGERPRINT(selected, supplied) + events.append("record") + return checked + + def confirm_card( + artifact: Share | Secret, + confirm: Callable[[str], ConfirmationResult] | None = None, + ) -> None: + if confirm is not None: + assert confirm(artifact.text).accepted + + monkeypatch.setattr(sys, "stdin", _TTYInput()) + monkeypatch.setattr(sys, "stdout", _TTYOutput()) + monkeypatch.setattr(cli, "_text", lambda _prompt, **_options: next(answers)) + monkeypatch.setattr(cli, "_recorded_fingerprint", check_record) + monkeypatch.setattr(cli, "_emit", lambda *_args, **_kwargs: events.append("card")) + monkeypatch.setattr(cli, "_confirm_card", confirm_card) + monkeypatch.setattr(cli.BitcoinCore, "connect", lambda *args: core) + + args = ["create", "2", "--indices", "ac", "--existing"] if shared else ["create", "--existing"] + assert ms_main(args) == 0 + assert events == (["record", "card", "card"] if shared else ["record", "card"]) + assert core.expected == core.fingerprint(secret) + assert core.imported is not None and core.imported.seed_bytes == secret.seed_bytes + + +@pytest.mark.parametrize("encoding", ("hex", "codex32")) +def test_create_existing_rejects_wrong_record_before_sharing( + monkeypatch: pytest.MonkeyPatch, encoding: str +) -> None: + cli = importlib.import_module("codex32.cli") + secret = parse_codex32(VECTOR_1["secret_s"]) + assert isinstance(secret, MasterSeed) + core = _FakeBitcoinCore() + source = secret.seed_bytes.hex() if encoding == "hex" else secret.text + right = core.fingerprint(secret) + wrong = bytes([right[0] ^ 1]) + right[1:] + answers = iter((source, wrong.hex(), "", "n")) + + monkeypatch.setattr(sys, "stdin", _TTYInput()) + monkeypatch.setattr(sys, "stdout", _TTYOutput()) + monkeypatch.setattr(cli, "_text", lambda _prompt, **_options: next(answers)) + monkeypatch.setattr(cli, "_recorded_fingerprint", _RECORDED_FINGERPRINT) + monkeypatch.setattr(cli.BitcoinCore, "connect", lambda *args: core) + with ( + patch("codex32.cli.CreationCeremony.from_secret") as split, + patch("codex32.cli._emit") as emit, + ): + assert ms_main(["create", "2", "--indices", "ac", "--existing"]) == 130 + split.assert_not_called() + emit.assert_not_called() + assert core.imported is None + + +def test_create_existing_recordless_choice_precedes_sharing(monkeypatch: pytest.MonkeyPatch) -> None: + cli = importlib.import_module("codex32.cli") + secret = parse_codex32(VECTOR_1["secret_s"]) + assert isinstance(secret, MasterSeed) + core = _FakeBitcoinCore() + answers = iter((secret.seed_bytes.hex(), "", "y")) + events: list[str] = [] + + def confirm_card(artifact: Share | Secret, confirm=None) -> None: + if confirm is not None: + assert confirm(artifact.text).accepted + + def answer(_prompt: str, **_options: object) -> str: + assert events == [] + return next(answers) + + monkeypatch.setattr(sys, "stdin", _TTYInput()) + monkeypatch.setattr(sys, "stdout", _TTYOutput()) + monkeypatch.setattr(cli, "_text", answer) + monkeypatch.setattr(cli, "_recorded_fingerprint", _RECORDED_FINGERPRINT) + monkeypatch.setattr(cli, "_emit", lambda *_args, **_kwargs: events.append("card")) + monkeypatch.setattr(cli, "_confirm_card", confirm_card) + monkeypatch.setattr(cli.BitcoinCore, "connect", lambda *args: core) + + assert ms_main(["create", "2", "--indices", "ac", "--existing"]) == 0 + assert events == ["card", "card"] + assert core.expected is None + assert core.imported is not None and core.imported.seed_bytes == secret.seed_bytes From 76f324d7abf31a48f9057cf559c1cfe2c0bad1f0 Mon Sep 17 00:00:00 2001 From: Ben Westgate Date: Wed, 30 Sep 2026 18:10:03 -0500 Subject: [PATCH 11/16] wallet: Preserve existing cards on interrupt Translate Ctrl-C or EOF at the early wallet-record gate for ms32 create --existing into the existing wallet-setup interruption path. This keeps an operator from being told to invalidate a pre-existing recovery card before any new share ceremony has started. Add a focused regression proving the interruption occurs before share creation or output and preserves the valid-backup message. --- src/codex32/cli.py | 5 ++++- tests/test_cli.py | 31 +++++++++++++++++++++++++++++++ 2 files changed, 35 insertions(+), 1 deletion(-) diff --git a/src/codex32/cli.py b/src/codex32/cli.py index 95791d5..84c829f 100644 --- a/src/codex32/cli.py +++ b/src/codex32/cli.py @@ -503,7 +503,10 @@ def _create( existing_secret = _generated_secret(source, None, identifier, core.fingerprint_seed) else: existing_secret = None - expected = _recorded_fingerprint(core, existing_secret) if existing_secret is not None else None + try: + expected = _recorded_fingerprint(core, existing_secret) if existing_secret is not None else None + except (EOFError, KeyboardInterrupt) as error: + raise _WalletSetupInterrupted from error def finish_wallet(seed: MasterSeed) -> int: return _initialize_wallet( diff --git a/tests/test_cli.py b/tests/test_cli.py index 2b52fe4..0557f97 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -3133,6 +3133,37 @@ def test_create_existing_rejects_wrong_record_before_sharing( assert core.imported is None +def test_create_existing_record_gate_interruption_keeps_existing_backup_valid( + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], +) -> None: + cli = importlib.import_module("codex32.cli") + secret = parse_codex32(VECTOR_1["secret_s"]) + assert isinstance(secret, MasterSeed) + core = _FakeBitcoinCore() + + def interrupt_record(*_args: object) -> bytes | None: + raise KeyboardInterrupt + + monkeypatch.setattr(sys, "stdin", _TTYInput()) + monkeypatch.setattr(sys, "stdout", _TTYOutput()) + monkeypatch.setattr(cli, "_creation_source", lambda _profile: secret) + monkeypatch.setattr(cli, "_recorded_fingerprint", interrupt_record) + monkeypatch.setattr(cli.BitcoinCore, "connect", lambda *args: core) + with ( + patch("codex32.cli.CreationCeremony.from_secret") as split, + patch("codex32.cli._emit") as emit, + ): + assert ms_main(["create", "2", "--indices", "ac", "--existing"]) == 130 + + split.assert_not_called() + emit.assert_not_called() + message = capsys.readouterr().err + assert "recovery cards are valid" in message + assert "Mark every card" not in message + assert core.imported is None + + def test_create_existing_recordless_choice_precedes_sharing(monkeypatch: pytest.MonkeyPatch) -> None: cli = importlib.import_module("codex32.cli") secret = parse_codex32(VECTOR_1["secret_s"]) From 48d204bee1aa6c15db45bbb848503e6fec942d48 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 30 Sep 2026 23:56:04 +0000 Subject: [PATCH 12/16] cli: Trim two lines from the early record gate Reassign the expected_fingerprint argument instead of copying it into a local, and give existing_secret its None default before the source checks instead of in an else branch. Behavior is unchanged. The installed package drops from 5161 to 5159 logical review lines, which keeps the integrated #7/#42/#57/#46/#80/#81 tip under the <5200 budget. Security: the record gate still runs before any card is generated or shown, and interrupts at that gate still raise _WalletSetupInterrupted. Validation: ruff check, ruff format --check, mypy src/codex32, and pytest (918 passed, with and without -O). Refs #81, #38. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_018az69UX4773mYohXAtE8kD --- src/codex32/cli.py | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/src/codex32/cli.py b/src/codex32/cli.py index 84c829f..e88fa8f 100644 --- a/src/codex32/cli.py +++ b/src/codex32/cli.py @@ -401,16 +401,15 @@ def _initialize_wallet( try: if confirmed: _print("Master-seed backup confirmed.\n", err=True) - expected = expected_fingerprint if restore and not identity_checked: - expected = _recorded_fingerprint(core, secret) + expected_fingerprint = _recorded_fingerprint(core, secret) if not restore: _show_fingerprint(core, secret, "Write it on the wallet record") name = core.initialize( secret, lambda prompt: _text(prompt, optional=True), lambda message: _print(message, err=True), - expected_fingerprint=expected, + expected_fingerprint=expected_fingerprint, account=account, timestamp=timestamp, ) @@ -493,6 +492,7 @@ def _create( if isinstance(source, (Share, Secret)) and not isinstance(source, MasterSeed): raise _UsageError(f"Enter one {_profile_rules(profile).label}, not a share or another backup type.") try: + existing_secret: MasterSeed | None = None if isinstance(source, MasterSeed): if threshold == 0 and identifier is not None and identifier != source.header.identifier: raise _UsageError( @@ -501,8 +501,6 @@ def _create( existing_secret = source elif source is not None: existing_secret = _generated_secret(source, None, identifier, core.fingerprint_seed) - else: - existing_secret = None try: expected = _recorded_fingerprint(core, existing_secret) if existing_secret is not None else None except (EOFError, KeyboardInterrupt) as error: From a42b6ce7df3b249ee6410265744e1c519c7fa5a7 Mon Sep 17 00:00:00 2001 From: Codex Agent Date: Wed, 30 Sep 2026 20:54:41 -0500 Subject: [PATCH 13/16] cli: Reuse raw-seed backup identifier A raw seed imported with create --existing was assigned a temporary random identifier for the no-record safety screen, then assigned a different random identifier when the new share set was created. Reuse the first identifier as the share-set identifier so the safety screen describes the backup that will actually be produced.\n\nExtend the recordless-creation regression to require the displayed, emitted, and imported identifiers to agree.\n\nRefs #30 --- src/codex32/cli.py | 2 ++ tests/test_cli.py | 15 +++++++++++++-- 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/src/codex32/cli.py b/src/codex32/cli.py index e88fa8f..a210e98 100644 --- a/src/codex32/cli.py +++ b/src/codex32/cli.py @@ -501,6 +501,8 @@ def _create( existing_secret = source elif source is not None: existing_secret = _generated_secret(source, None, identifier, core.fingerprint_seed) + if threshold and identifier is None: + identifier = existing_secret.header.identifier try: expected = _recorded_fingerprint(core, existing_secret) if existing_secret is not None else None except (EOFError, KeyboardInterrupt) as error: diff --git a/tests/test_cli.py b/tests/test_cli.py index 0557f97..2978174 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -3164,13 +3164,16 @@ def interrupt_record(*_args: object) -> bytes | None: assert core.imported is None -def test_create_existing_recordless_choice_precedes_sharing(monkeypatch: pytest.MonkeyPatch) -> None: +def test_create_existing_recordless_choice_precedes_sharing( + monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] +) -> None: cli = importlib.import_module("codex32.cli") secret = parse_codex32(VECTOR_1["secret_s"]) assert isinstance(secret, MasterSeed) core = _FakeBitcoinCore() answers = iter((secret.seed_bytes.hex(), "", "y")) events: list[str] = [] + emitted: list[Share | Secret] = [] def confirm_card(artifact: Share | Secret, confirm=None) -> None: if confirm is not None: @@ -3180,15 +3183,23 @@ def answer(_prompt: str, **_options: object) -> str: assert events == [] return next(answers) + def emit(artifact: Share | Secret, *_args: object, **_kwargs: object) -> None: + events.append("card") + emitted.append(artifact) + monkeypatch.setattr(sys, "stdin", _TTYInput()) monkeypatch.setattr(sys, "stdout", _TTYOutput()) monkeypatch.setattr(cli, "_text", answer) monkeypatch.setattr(cli, "_recorded_fingerprint", _RECORDED_FINGERPRINT) - monkeypatch.setattr(cli, "_emit", lambda *_args, **_kwargs: events.append("card")) + monkeypatch.setattr(cli, "_emit", emit) monkeypatch.setattr(cli, "_confirm_card", confirm_card) monkeypatch.setattr(cli.BitcoinCore, "connect", lambda *args: core) assert ms_main(["create", "2", "--indices", "ac", "--existing"]) == 0 assert events == ["card", "card"] + identifier = emitted[0].header.identifier + assert all(artifact.header.identifier == identifier for artifact in emitted) assert core.expected is None assert core.imported is not None and core.imported.seed_bytes == secret.seed_bytes + assert core.imported.header.identifier == identifier + assert capsys.readouterr().err.count(f"Backup identifier: {identifier.upper()}") >= 2 From 9f88b2195fbb3e36e1d8ddf808fb97cc028923ad Mon Sep 17 00:00:00 2001 From: Codex Agent Date: Thu, 1 Oct 2026 01:35:25 -0500 Subject: [PATCH 14/16] cli: Remove unreachable input guard Backup creation rejects a noninteractive terminal before this branch, so the later stdin.isatty() rejection can never run. Removing it preserves the interactive behavior and leaves the integrated source under its strict review line budget. Refs #46 and #81. --- src/codex32/cli.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/codex32/cli.py b/src/codex32/cli.py index a210e98..dc6d3be 100644 --- a/src/codex32/cli.py +++ b/src/codex32/cli.py @@ -487,8 +487,6 @@ def _create( raise _UsageError("For thresholds 4 through 9, choose --shares or --indices.") core = _connected_core() source = _creation_source(profile) if existing else None - if not existing and not sys.stdin.isatty() and _text("", optional=True): - raise _UsageError("Use --existing when supplying a seed or secret.") if isinstance(source, (Share, Secret)) and not isinstance(source, MasterSeed): raise _UsageError(f"Enter one {_profile_rules(profile).label}, not a share or another backup type.") try: From 4ea72bbbe76d7544a44bda50db0354c776d222b0 Mon Sep 17 00:00:00 2001 From: Codex Date: Wed, 30 Sep 2026 23:26:45 -0500 Subject: [PATCH 15/16] cli: Announce recovery secret switch Entering a complete valid secret during interactive share recovery intentionally supersedes the partial share set. Previously that mode switch happened silently, which made correct behavior look like discarded input. Emit one explicit notice only when shares were already accepted, and pin the behavior in the existing interactive recovery regression. Refs #38 --- src/codex32/_cli_input.py | 2 ++ tests/test_cli.py | 1 + 2 files changed, 3 insertions(+) diff --git a/src/codex32/_cli_input.py b/src/codex32/_cli_input.py index 53ba5b9..eb13b4f 100644 --- a/src/codex32/_cli_input.py +++ b/src/codex32/_cli_input.py @@ -805,6 +805,8 @@ def allowed(candidate: CorrectionCandidate) -> bool: if one: return [artifact] if isinstance(artifact, Secret) and not basis: + if accepted: + _stderr("Complete secret supplied; using it instead of the accepted shares.") return [artifact] if not accepted: required = artifact.header.threshold diff --git a/tests/test_cli.py b/tests/test_cli.py index 2978174..b1ba660 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -1094,6 +1094,7 @@ def answer(prompt: str) -> str: assert "Rejected:" not in captured.err assert "Share 1 of 3 accepted." in captured.err assert "Share 2 of 3 accepted." in captured.err + assert "Complete secret supplied; using it instead of the accepted shares." in captured.err first_prompt = ( "Enter a codex32 string:\n> " if command[0] == "secret" else "Enter a codex32 string:\n> MS1" ) From eb29499638b6b80f9278b8f84ba25dbc694b5ac9 Mon Sep 17 00:00:00 2001 From: Ben Westgate Date: Mon, 28 Sep 2026 13:33:02 -0500 Subject: [PATCH 16/16] wallet: Privatize Core descriptor records The package-level core_descriptors adapter exposes import-record construction that runtime callers no longer need. Bitcoin Core already owns public derivation, while private descriptor construction is only an implementation detail of the Core adapter. Remove the unused public-deriver protocol and branch, keep the record builder private, and make installed/public-API checks enforce that boundary. Internal tests and verification tools continue to exercise the same fixed descriptor templates and arbitrary account handling. Fixes #63 --- docs/developer/api.md | 23 ++++----- src/codex32/__init__.py | 3 +- src/codex32/wallet.py | 41 ++------------- tests/test_public_api.py | 5 ++ tests/test_wallet.py | 89 +++------------------------------ tools/bitcoin_core_regtest.py | 6 +-- tools/verify_installed_wheel.py | 7 ++- 7 files changed, 32 insertions(+), 142 deletions(-) diff --git a/docs/developer/api.md b/docs/developer/api.md index 4e6cd06..9dbdc32 100644 --- a/docs/developer/api.md +++ b/docs/developer/api.md @@ -100,7 +100,7 @@ generic parse-length failure. hidden state. Private Python names are convention rather than access control. The supported -surface is the 25-name package `__all__`; direct use of private helpers is +surface is the 23-name package `__all__`; direct use of private helpers is unsupported but remains in the review scope. ### Size budget @@ -580,27 +580,24 @@ Public wallet operations accept only a validated `MasterSeed`. `wallet.py` is stateless and never accepts shares, Core Lightning secrets, BIP39 migration artifacts, or raw bytes. -The public adapter has two functions: - -- `master_xprv(secret, testnet=False)` returns the BIP32 root extended private - key. -- `core_descriptors(...)` returns fixed BIP44, BIP49, BIP84, and BIP86 Bitcoin - Core `importdescriptors` records. Private records use stdlib-only root xprv - serialization; public records require an explicit wallet integration and the - Core wallet whose imported root key will perform hardened derivation. +The supported package surface exposes one wallet primitive: +`master_xprv(secret, testnet=False)`, which returns the BIP32 root extended +private key. Bitcoin Core descriptor-record construction is an internal +test/reference detail rather than a supported package API. No installed Python dependency performs secp256k1 operations. The private Bitcoin Core adapter gives Core the root xprv over stdin and asks Core to -create the four standard account-0 descriptor types. Public descriptor -derivation remains available through the explicit integration API. +create the four standard account-0 descriptor types. Descriptor normalization +and public derivation stay behind that private Core boundary. Public descriptors contain account xpubs. Private descriptors intentionally follow Bitcoin Core's root-key form: they contain the root xprv followed by the complete derivation path. They therefore grant authority over the entire root, not only the selected account. The CLI warns before printing them. -Account, private/public mode, network serialization, and timestamp are explicit -API inputs. The `ms32 wallet` CLI takes `--account 0` and `--timestamp`; the +Account and timestamp remain explicit at the Core boundary, while network +serialization is explicit for `master_xprv`. The `ms32 wallet` CLI takes +`--account 0` and `--timestamp`; the selected Bitcoin Core chain is authoritative and there is no wallet `--testnet` flag. `ms32 xprv --testnet` remains explicit because it directly selects xprv versus tprv serialization. The timestamp defaults to `0` so diff --git a/src/codex32/__init__.py b/src/codex32/__init__.py index 78eda94..8e882f1 100644 --- a/src/codex32/__init__.py +++ b/src/codex32/__init__.py @@ -27,7 +27,7 @@ from .profiles.bip39 import Bip39Secret from .profiles.cl32 import CoreLightningSecret from .profiles.ms32 import MasterSeed -from .wallet import core_descriptors, master_xprv +from .wallet import master_xprv __all__ = [ "Bip39Secret", @@ -45,7 +45,6 @@ "Secret", "Share", "WorksheetCorrection", - "core_descriptors", "correct", "correct_worksheet_residue", "derive_share", diff --git a/src/codex32/wallet.py b/src/codex32/wallet.py index 06b9817..91cdb1f 100644 --- a/src/codex32/wallet.py +++ b/src/codex32/wallet.py @@ -1,6 +1,6 @@ """Bitcoin wallet interoperability for validated master seeds.""" -from typing import Literal, Protocol +from typing import Literal from codex32._bip32 import _master_xprv_from_seed from codex32.bech32 import _u5_to_chars @@ -18,21 +18,6 @@ ) -class WalletPublicDeriver(Protocol): - """Out-of-process provider for EC-dependent BIP32 public derivation.""" - - def fingerprint(self, secret: MasterSeed) -> bytes: ... - - def public_descriptors( - self, - secret: MasterSeed, - *, - wallet: str, - account: int = 0, - timestamp: int | Literal["now"] = 0, - ) -> tuple[dict[str, object], ...]: ... - - def _master(secret: MasterSeed) -> MasterSeed: if not isinstance(secret, MasterSeed): raise TypeError("wallet operations accept only MasterSeed") @@ -86,42 +71,22 @@ def master_xprv(secret: MasterSeed, *, testnet: bool = False) -> str: return _master_xprv_from_seed(_master(secret).seed_bytes, testnet=testnet) -def core_descriptors( +def _core_descriptors( secret: MasterSeed, *, - integration: WalletPublicDeriver | None = None, - wallet: str | None = None, account: int = 0, testnet: bool = False, - private: bool = False, timestamp: int | Literal["now"] = 0, ) -> tuple[dict[str, object], ...]: - """Return fixed Bitcoin Core records. - - Private records are constructed with stdlib-only root xprv serialization. - Public records require an explicit out-of-process integration provider. - """ + """Return fixed private descriptor records for verification tooling.""" _master(secret) account = _account(account) if not isinstance(testnet, bool): raise TypeError("testnet must be bool") - if not isinstance(private, bool): - raise TypeError("private must be bool") if timestamp != "now" and ( isinstance(timestamp, bool) or not isinstance(timestamp, int) or timestamp < 0 ): raise ValueError("timestamp must be a nonnegative integer or 'now'") - if not private: - if integration is None: - raise TypeError("public descriptors require a wallet integration") - if wallet is None: - raise TypeError("public descriptors require a Bitcoin Core wallet name") - return integration.public_descriptors( - secret, - wallet=wallet, - account=account, - timestamp=timestamp, - ) coin_type = int(testnet) xprv = master_xprv(secret, testnet=testnet) keys = [] diff --git a/tests/test_public_api.py b/tests/test_public_api.py index 09d66d4..91fcd89 100644 --- a/tests/test_public_api.py +++ b/tests/test_public_api.py @@ -59,6 +59,11 @@ def test_checksum_completion_is_not_public_api() -> None: assert not hasattr(codex32, "complete_checksum") +def test_bitcoin_core_descriptor_records_are_not_public_api() -> None: + assert "core_descriptors" not in codex32.__all__ + assert not hasattr(codex32, "core_descriptors") + + def test_share_has_symbols_but_no_byte_or_padding_api() -> None: share = parse_codex32(VECTOR_2["share_A"]) assert isinstance(share, Share) diff --git a/tests/test_wallet.py b/tests/test_wallet.py index 7809862..4b7d379 100644 --- a/tests/test_wallet.py +++ b/tests/test_wallet.py @@ -4,57 +4,8 @@ from data.bip93_vectors import VECTOR_1, VECTOR_2, VECTOR_3, VECTOR_4, VECTOR_5 from data.sharing_vectors import SHARING_VECTORS -from codex32 import ( - MasterSeed, - core_descriptors, - master_xprv, - parse_codex32, -) -from codex32.wallet import _with_checksum - -_MAIN_DESCRIPTORS = ( - ( - "pkh([3f3521a6/44h/0h/0h]" - "xpub6CeZ5XxHp6rXSwi2GCi7UT25rswWQtoPvj36MbzRBr3QEoEmBFNGgnMy329ZMk" - "fjRKBZHtKKpYfpkrPWohTjHZZn7y1NR9EHnojaGLKdMAR/<0;1>/*)#smv8ra2a" - ), - ( - "sh(wpkh([3f3521a6/49h/0h/0h]" - "xpub6D9YUddFXuNKQvNrT9RQh8ueiTvHwF3RzdgU6uTEri73WTnBpKaDCGhTUiPBTy" - "VJxtR5u2atDmCHE7tw369ahXddCNqJBxFpseud3j7pjX8/<0;1>/*))#gylcnnd3" - ), - ( - "wpkh([3f3521a6/84h/0h/0h]" - "xpub6CNhWVRpA49Bz3LSaBibGqfBV4qa5NH1CStbQfsxWKScwrws5jioMunWKj2uM2" - "rrfdJSroNuJBNDUmmdYXQw5LwVro39pH5nqEgAqrzTPyc/<0;1>/*)#zy06y40v" - ), - ( - "tr([3f3521a6/86h/0h/0h]" - "xpub6C5pT77VWNhWvrB3TqSEbpm7NCpMYEzbJreYbB68RCUoAMkT7rdhafinmdKL4M5" - "275TyDqNAWCnssYnDNaPoXMiAg3sWvCgAiYqY8dHk1k4/<0;1>/*)#r8r04qrm" - ), -) - - -class _FakePublicDeriver: - def fingerprint(self, secret: MasterSeed) -> bytes: - del secret - return bytes.fromhex("3f3521a6") - - def public_descriptors( - self, - secret: MasterSeed, - *, - wallet: str, - account: int = 0, - timestamp: int | str = 0, - ) -> tuple[dict[str, object], ...]: - del secret - if wallet != "signer": - raise AssertionError("unexpected wallet") - if account != 0: - raise AssertionError("unexpected frozen descriptor request") - return tuple({"desc": desc, "active": True, "timestamp": timestamp} for desc in _MAIN_DESCRIPTORS) +from codex32 import MasterSeed, master_xprv, parse_codex32 +from codex32.wallet import _core_descriptors, _with_checksum def _master() -> MasterSeed: @@ -71,29 +22,8 @@ def test_master_xprv_matches_bip93_vectors(vector: dict[str, str]) -> None: assert master_xprv(secret) == vector["xprv"] -def test_public_core_descriptors_are_fixed_and_private_free() -> None: - records = core_descriptors(_master(), integration=_FakePublicDeriver(), wallet="signer") - - assert len(records) == 4 - assert [record["desc"].split("(", 1)[0] for record in records] == [ - "pkh", - "sh", - "wpkh", - "tr", - ] - assert all(record["active"] is True for record in records) - assert all(record["timestamp"] == 0 for record in records) - assert all("xpub" in str(record["desc"]) for record in records) - assert all("xprv" not in str(record["desc"]) for record in records) - assert records[0]["desc"] == ( - "pkh([3f3521a6/44h/0h/0h]" - "xpub6CeZ5XxHp6rXSwi2GCi7UT25rswWQtoPvj36MbzRBr3QEoEmBFNGgnMy329ZMk" - "fjRKBZHtKKpYfpkrPWohTjHZZn7y1NR9EHnojaGLKdMAR/<0;1>/*)#smv8ra2a" - ) - - def test_private_core_descriptors_use_root_xprv_and_explicit_inputs() -> None: - records = core_descriptors(_master(), account=3, testnet=True, private=True, timestamp=123) + records = _core_descriptors(_master(), account=3, testnet=True, timestamp=123) assert all(record["timestamp"] == 123 for record in records) for purpose, record in zip((44, 49, 84, 86), records, strict=True): @@ -103,12 +33,7 @@ def test_private_core_descriptors_use_root_xprv_and_explicit_inputs() -> None: def test_core_descriptors_accept_bitcoin_core_now_timestamp() -> None: - assert all( - record["timestamp"] == "now" - for record in core_descriptors( - _master(), timestamp="now", integration=_FakePublicDeriver(), wallet="signer" - ) - ) + assert all(record["timestamp"] == "now" for record in _core_descriptors(_master(), timestamp="now")) def test_descriptor_checksum_matches_published_example() -> None: @@ -125,7 +50,7 @@ def test_descriptor_checksum_matches_published_example() -> None: ), ) def test_wallet_boundary_rejects_every_non_master_seed(invalid: object) -> None: - for function in (master_xprv, core_descriptors): + for function in (master_xprv, _core_descriptors): with pytest.raises(TypeError, match="only MasterSeed"): function(invalid) # type: ignore[arg-type] @@ -133,10 +58,10 @@ def test_wallet_boundary_rejects_every_non_master_seed(invalid: object) -> None: @pytest.mark.parametrize("account", (-1, 2**31, True, "0")) def test_account_is_explicitly_bounded(account: object) -> None: with pytest.raises((TypeError, ValueError)): - core_descriptors(_master(), account=account, integration=_FakePublicDeriver(), wallet="signer") # type: ignore[arg-type] + _core_descriptors(_master(), account=account) # type: ignore[arg-type] @pytest.mark.parametrize("timestamp", (-1, True, "yesterday")) def test_timestamp_is_a_supported_core_value(timestamp: object) -> None: with pytest.raises((TypeError, ValueError)): - core_descriptors(_master(), timestamp=timestamp) # type: ignore[arg-type] + _core_descriptors(_master(), timestamp=timestamp) # type: ignore[arg-type] diff --git a/tools/bitcoin_core_regtest.py b/tools/bitcoin_core_regtest.py index 56c9742..3bf76ea 100644 --- a/tools/bitcoin_core_regtest.py +++ b/tools/bitcoin_core_regtest.py @@ -18,7 +18,7 @@ from codex32._bitcoin_core import BitcoinCore from codex32.bip93 import parse_codex32 from codex32.profiles.ms32 import MasterSeed -from codex32.wallet import core_descriptors +from codex32.wallet import _core_descriptors # Frozen public BIP93 vector material; it has never controlled a funded wallet. _SEED = "ms10testsxxxxxxxxxxxxxxxxxxxxxxxxxx4nzvca9cmczlw" @@ -225,8 +225,8 @@ def rpc(*rpc_arguments: str, wallet: str | None = None, stdin: str | None = None if rpc("gettransaction", spend, wallet="restore")["confirmations"] < 1: raise RuntimeError("recovered wallet did not sign and broadcast") - main_private = core_descriptors(secret, private=True, timestamp=0) - test_private = core_descriptors(secret, testnet=True, private=True, timestamp=0) + main_private = _core_descriptors(secret, timestamp=0) + test_private = _core_descriptors(secret, testnet=True, timestamp=0) if "xprv" not in json.dumps(main_private) or "tprv" not in json.dumps(test_private): raise RuntimeError("mainnet/test-network root serialization was not separated") diff --git a/tools/verify_installed_wheel.py b/tools/verify_installed_wheel.py index 3d276ed..373b532 100644 --- a/tools/verify_installed_wheel.py +++ b/tools/verify_installed_wheel.py @@ -5,12 +5,12 @@ import importlib.util import sys +import codex32 from codex32 import ( CorrectionContext, CreationCeremony, MasterSeed, Profile, - core_descriptors, correct, derive_share, master_xprv, @@ -45,9 +45,8 @@ def main() -> None: secret = parse_codex32(_SECRET) assert isinstance(secret, MasterSeed) assert master_xprv(secret) == _XPRV - private = core_descriptors(secret, private=True) - assert len(private) == 4 - assert all("xprv" in record["desc"] for record in private) + assert "core_descriptors" not in codex32.__all__ + assert not hasattr(codex32, "core_descriptors") assert "bip32" not in sys.modules assert "coincurve" not in sys.modules