diff --git a/docs/developer/api.md b/docs/developer/api.md index 4a290d8..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 @@ -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 @@ -578,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 @@ -624,6 +623,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..be5ed48 100644 --- a/docs/security/invariants.md +++ b/docs/security/invariants.md @@ -11,6 +11,13 @@ 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 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 7a7de88..e025fb3 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, @@ -199,10 +200,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 @@ -228,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. 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 7be6bcf..422ec99 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 @@ -115,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 @@ -163,9 +175,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. @@ -226,16 +241,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/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/__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/_bitcoin_core.py b/src/codex32/_bitcoin_core.py index cfec702..51712e4 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,63 @@ 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 == "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 " + "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" + 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 "Bails check unavailable" if ripemd_unavailable else None + + @dataclass(frozen=True) class BitcoinCore: executable: str @@ -154,6 +219,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 +448,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_input.py b/src/codex32/_cli_input.py index c6e65de..eb13b4f 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 @@ -416,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( @@ -460,9 +442,12 @@ 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, + 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 @@ -470,12 +455,14 @@ def _correction_candidates( candidates, complete = _search_many( contexts, value, - primary=primary, - reduced=reduced, + primary=frozenset(targets), deadline=deadline, competitors=True, allowed=allowed, 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)) @@ -486,7 +473,66 @@ 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( + 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]: + """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 + capture_layers: list[tuple[int, int]] = [] + 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 = full_search( + retry, deadline=deadline, 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 def _fingerprint_matcher( @@ -538,23 +584,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 +593,7 @@ def _suggestions( excluded, target=target, allowed=allowed, - deadline=deadline, - capture_layers=capture_layers, + deadline=monotonic() + 10, fingerprint_match=fingerprint_match, )[0] @@ -775,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/src/codex32/_competitors.py b/src/codex32/_competitors.py index 7cf5539..b5895a9 100644 --- a/src/codex32/_competitors.py +++ b/src/codex32/_competitors.py @@ -173,18 +173,25 @@ def _search_competitors( frontier: dict[_Layer, int], deadline: float, allowed: Callable[[CorrectionCandidate], bool] | None, + *, + seed_candidates: Sequence[CorrectionCandidate] = (), + optional_only: bool = False, ) -> 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: 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/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..dc6d3be 100644 --- a/src/codex32/cli.py +++ b/src/codex32/cli.py @@ -8,17 +8,26 @@ 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, _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 +44,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, @@ -88,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 @@ -333,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, @@ -340,16 +392,24 @@ def _initialize_wallet( account: int = 0, timestamp: int | Literal["now"] = "now", 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) + if restore and not identity_checked: + 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_fingerprint, account=account, timestamp=timestamp, ) @@ -420,49 +480,53 @@ 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] 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 - if not existing and not sys.stdin.isatty() and _text("", optional=True): - raise _UsageError("Use --existing when supplying a seed or secret.") + source = _creation_source(profile) if existing else None 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=core.fingerprint) - if sys.stdin.isatty(): - _confirm_card(secret) - return ( - _initialize_wallet(core, secret, timestamp=0 if existing else "now", fresh=not existing) - if core is not None - else 0 - ) + existing_secret: MasterSeed | None = None if isinstance(source, MasterSeed): - ceremony = CreationCeremony.from_secret( - source, - threshold=threshold, - identifier=identifier, - share_count=shares, - indices=indices, - ) + 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." + ) + existing_secret = source elif source is not None: - source_secret = _generated_secret(source, None, identifier, core.fingerprint_seed) + 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: + raise _WalletSetupInterrupted from error + + 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 threshold == 0: + secret = existing_secret or _generated_secret( + None, byte_length, 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, @@ -488,10 +552,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) - _print("\nEvery recovery card was confirmed from its re-entered text.", err=True) - return 0 + return finish_wallet(finished) def _correct( @@ -531,14 +592,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: @@ -550,16 +613,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 = True + else: + 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: @@ -594,13 +666,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, ) @@ -679,22 +754,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/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..98a6369 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)) @@ -284,6 +244,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 +253,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 +278,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 +434,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 +501,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 +515,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 +536,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], ) ) @@ -557,12 +552,15 @@ 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, 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 +571,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 +605,22 @@ 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, + optional_only=optional_only, + ) + 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/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/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_bitcoin_core.py b/tests/test_bitcoin_core.py index 72d6676..9737203 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,90 @@ 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) + + +@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: + 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=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: + 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 b9fa03b..b1ba660 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 @@ -91,9 +92,9 @@ 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 + expected: bytes | None = None def fingerprint_seed(self, seed: bytes) -> bytes: return stub_fingerprint(seed) @@ -101,18 +102,24 @@ 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.private, self.account, self.timestamp = private, account, timestamp + self.account, self.timestamp = account, timestamp return "test-wallet" @@ -121,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() @@ -495,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 @@ -541,6 +560,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" @@ -1064,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" ) @@ -1715,7 +1746,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 @@ -1726,10 +1757,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", "?"])), @@ -1765,10 +1805,10 @@ 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)): 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 @@ -1790,20 +1830,19 @@ 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 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 @@ -1817,7 +1856,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 @@ -1850,14 +1889,141 @@ 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 +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 + + 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 + full_searches.append(value) + kwargs["capture_layers"].append((1, 5)) + if len(full_searches) == 1: + return (candidate,), False, 0.0 + return (), False, 0.0 + + 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 == 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.", @@ -1875,7 +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.private is True + 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 @@ -2753,9 +2919,288 @@ 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)): result = _invoke(["correct"], source[:-1] + "?") assert result.exit_code == 1 and result.stdout == "" 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) + + +@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_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, 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: + assert confirm(artifact.text).accepted + + 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", 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 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..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, @@ -100,11 +102,11 @@ 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)), ): 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" @@ -128,7 +130,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) ) core = _FakeBitcoinCore() monkeypatch.setattr(cli.BitcoinCore, "connect", lambda *args: core) @@ -140,7 +142,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 @@ -163,7 +166,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) ) with ( patch.object(sys, "stdin", _TTYInput()), @@ -181,7 +184,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) ) stdout, stderr = io.StringIO(), io.StringIO() with ( @@ -189,7 +192,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" @@ -218,7 +221,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) ) stdout, stderr = io.StringIO(), _TTYOutput() with ( @@ -292,7 +295,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" @@ -308,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),), @@ -321,6 +326,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_correction_indel.py b/tests/test_correction_indel.py index 05d6d97..2c860cf 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)) @@ -160,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( @@ -509,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/tests/test_mixed_case_deadline.py b/tests/test_mixed_case_deadline.py new file mode 100644 index 0000000..f812096 --- /dev/null +++ b/tests/test_mixed_case_deadline.py @@ -0,0 +1,83 @@ +"""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 = _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 = _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 = _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]: + 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 + return (), True, 0.0 + + monkeypatch.setattr("codex32._cli_input._correction_candidates", truncated_first) + + candidates, complete = _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) + ] 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_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..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" @@ -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" @@ -221,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/correction_benchmark.py b/tools/correction_benchmark.py index f0e74eb..c8d4842 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) @@ -109,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: 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) 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