diff --git a/AGENTS.md b/AGENTS.md index fd47d95..cf7df8c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -61,7 +61,7 @@ avoid comments or tests that restate the implementation. Add or update concise docstrings when changing public behavior. Write codex32 in lowercase except when referring to the Codex32 Book. -Keep the installed package below 5,200 logical review lines, as enforced by the +Keep the installed package below 5,250 logical review lines, as enforced by the existing test. New dependencies, public API signature or return-shape changes, and lint suppressions require user authorization; an explicit request can already provide that authorization. diff --git a/docs/developer/api.md b/docs/developer/api.md index 4e6cd06..dadf99f 100644 --- a/docs/developer/api.md +++ b/docs/developer/api.md @@ -105,7 +105,7 @@ unsupported but remains in the review scope. ### Size budget -V1 keeps the installed package below 5,200 logical review lines, excluding +V1 keeps the installed package below 5,250 logical review lines, excluding blank and comment-only lines while counting subpackages recursively. Changing the budget requires explicit review and authorization together with the matching documentation and enforcement update. diff --git a/docs/security/invariants.md b/docs/security/invariants.md index 42adce5..be5ed48 100644 --- a/docs/security/invariants.md +++ b/docs/security/invariants.md @@ -14,9 +14,10 @@ and evidence. Restore authenticates the recovered seed before any wallet is listed, unlocked, or imported into: normally with the master fingerprint typed from the wallet record, or by an explicit no-record choice made after seeing the - recovered fingerprint and whether the backup identifier was derived from the - seed. Fresh `ms32 create` ceremonies do not authenticate against a - pre-existing wallet; they require the operator to record the new fingerprint. + recovered fingerprint and whether the backup identifier matched a + seed-derived rule or its standard Bails check was unavailable. Fresh + `ms32 create` ceremonies do not authenticate against a pre-existing wallet; + they require the operator to record the new fingerprint. 5. Correction shares one mass bound and deadline across target lengths. The public API fails closed on incomplete required work; CLI searches may return one primary-best-so-far eligible candidate at the deadline. Incomplete diff --git a/docs/security/model.md b/docs/security/model.md index 2d3e18b..e025fb3 100644 --- a/docs/security/model.md +++ b/docs/security/model.md @@ -231,7 +231,7 @@ signing setup belong to Bitcoin Core's maintained v32 workflow. | Control | Required behavior | |---|---| | Preflight | Before entropy or recovery input, explicit chain arguments probe the five standard local networks for Bitcoin Core 32 or newer. One response is selected automatically; multiple responses require operator selection. | -| Recovery identity | `ms32 wallet` and `ms32 create --existing` authenticate a recovered seed before any wallet is listed. Core derives the recovered fingerprint statelessly, and a mismatch raises `FingerprintMismatch` before any wallet RPC. The restore prompt does not show the recovered value, so the operator compares by typing the fingerprint from the wallet record. Without a record, the operator is shown the recovered fingerprint, whether the backup identifier was derived from the seed (the codex32 fingerprint rule, Bails' RIPEMD-160 rule, or its mid-2023 alpha's SHA-256 rule), and a warning, and then chooses. Fresh `ms32 create` has no pre-existing wallet to authenticate: it shows the newly created seed's fingerprint and requires the operator to acknowledge recording it. These checks catch mistakes such as wrong or mixed cards; anyone able to replace a threshold of cards could already read them. | +| Recovery identity | `ms32 wallet` and `ms32 create --existing` authenticate a recovered seed before any wallet is listed. For `create --existing`, the wallet-record decision also precedes generation or display of any new card. Core derives the recovered fingerprint statelessly, and a mismatch raises `FingerprintMismatch` before any wallet RPC. The restore prompt does not show the recovered value, so the operator compares by typing the fingerprint from the wallet record. Without a record, the operator is shown the recovered fingerprint, whether the backup identifier was derived from the seed (the codex32 fingerprint rule, Bails' RIPEMD-160 rule, or its mid-2023 alpha's SHA-256 rule), and a warning, and then chooses. If RIPEMD-160 is unavailable, the standard Bails identifier check is reported as inconclusive rather than a mismatch; the Bails-alpha SHA-256 rule remains checkable. Fresh `ms32 create` has no pre-existing wallet to authenticate: it shows the newly created seed's fingerprint and requires the operator to acknowledge recording it. These checks catch mistakes such as wrong or mixed cards; anyone able to replace a threshold of cards could already read them. | | Process boundary | codex32 invokes the reviewed `bitcoin-cli` from `PATH` as a child without a shell, direct RPC socket, wallet database, or wallet-creation operation. Every call uses loopback and the selected chain. | | Destination | Only an empty descriptor wallet with private keys enabled, no external signer, transactions, descriptors, keypool entries, or active scan is eligible. One eligible wallet is offered directly; multiple wallets are selected by number. New wallets are detected by polling, and rejection returns to every eligible wallet. The escaped name is confirmed exactly. | | Seed source | The original ceremony result or validated recovered master seed supplies a root xprv for Core's reported chain. Core v32 creates BIP44, BIP49, BIP84, and BIP86 account-0 descriptors from that key. | diff --git a/docs/user/guide.md b/docs/user/guide.md index 332d77a..05225cb 100644 --- a/docs/user/guide.md +++ b/docs/user/guide.md @@ -122,8 +122,12 @@ easier. 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. +requiring any two, use `ms32 create 2 --existing` instead. First type the +master fingerprint from the separate wallet record, then enter the secret +when prompted; a mismatch must be resolved before any new card is shown. If +you have no record, press Enter; the explicit recordless-restore choice and +visual fingerprint check happen right after the secret. Bitcoin Core also +scans for prior transactions. ### 3. Make a Bitcoin Core wallet @@ -237,10 +241,13 @@ 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. 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. +5. Type the master fingerprint from the wallet record, then enter the cards. + A suggested correction says whether it matches the record without showing + the fingerprint, and the record picks between equally likely corrections. + A mismatch stops before Bitcoin Core is changed. Press Enter with nothing + typed only if there is no record; after the cards, 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 diff --git a/src/codex32/_bitcoin_core.py b/src/codex32/_bitcoin_core.py index 5b78a74..51712e4 100644 --- a/src/codex32/_bitcoin_core.py +++ b/src/codex32/_bitcoin_core.py @@ -60,6 +60,12 @@ def parse_fingerprint(text: str) -> bytes: def identifier_note(origin: str | None) -> str: # Say what `identifier_origin` found, for an operator restoring without a record. + if origin == "Bails check unavailable": + return ( + "The standard Bails identifier could not be checked because RIPEMD-160 is unavailable. " + "This does not prove the cards are wrong or mixed up. Compare the fingerprint and any " + "other wallet record you have before restoring." + ) if origin is None: return ( "The backup identifier was not made from this seed. That can be normal for codex32 backups " @@ -77,15 +83,18 @@ def identifier_origin(secret: MasterSeed, fingerprint: bytes) -> str | None: identifier = secret.header.identifier if identifier == _fingerprint_identifier(fingerprint): return "codex32" + ripemd_unavailable = False for name, digest in (("Bails", "ripemd160"), ("Bails alpha", "sha256")): try: hashed = hashlib.new(digest, secret.seed_bytes).digest() except ValueError: + if name == "Bails": + ripemd_unavailable = True continue derived = convertbits(hashed, 8, 5, pad=True) if identifier[:3] == _u5_to_chars(tuple(derived[:3])): return name - return None + return "Bails check unavailable" if ripemd_unavailable else None @dataclass(frozen=True) diff --git a/src/codex32/_cli_input.py b/src/codex32/_cli_input.py index 53ba5b9..d72fbd4 100644 --- a/src/codex32/_cli_input.py +++ b/src/codex32/_cli_input.py @@ -200,28 +200,36 @@ def _card_text(text: str, highlight: bool = True, observed: str = "") -> str: return rendered if changed else rendered.replace("\x1b[0m ", " ") +def _completed(artifact: Artifact, accepted: Sequence[Artifact]) -> Artifact: + # Provisional recovery is exclusively for fingerprint previews and record checks. + if ( + isinstance(artifact, Share) + and artifact.profile is Profile.MS + and len(accepted) + 1 == artifact.header.threshold + ): + return recover_secret(cast(list[Share], [*accepted, artifact])) + return artifact + + def _confirm_correction( candidate: CorrectionCandidate, accepted: list[Artifact], basis: bool, fingerprint: Callable[[MasterSeed], bytes] | None = None, + record: bytes | None = None, ) -> bool | None: _require_correction_confirmation(candidate.low_checksum_discrimination) artifact = candidate.artifact - # Provisional recovery is exclusively for this fingerprint preview. - preview = artifact try: - if ( - isinstance(artifact, Share) - and artifact.profile is Profile.MS - and not basis - and (len(accepted) + 1 == artifact.header.threshold) - ): - preview = recover_secret(cast(list[Share], [*accepted, artifact])) + preview = artifact if basis else _completed(artifact, accepted) + # A typed wallet record is checked without showing the recovered value. fingerprint_text = ( - f"Master fingerprint: {fingerprint(preview).hex().upper()}\n\n" - if isinstance(preview, MasterSeed) and fingerprint is not None - else "" + "" + if not isinstance(preview, MasterSeed) or fingerprint is None + else f"Master fingerprint: {fingerprint(preview).hex().upper()}\n\n" + if record is None + else f"Master fingerprint {'matches' if fingerprint(preview) == record else 'does not match'} " + "your wallet record.\n\n" ) except CodexError: _stderr("Rejected: Could not recover a valid Bitcoin master seed using this correction.") @@ -537,6 +545,8 @@ def _scheduled_candidates( def _fingerprint_matcher( fingerprint: Callable[[MasterSeed], bytes] | None, + record: bytes | None = None, + accepted: Sequence[Artifact] = (), ) -> Callable[[CorrectionCandidate], bool | None] | None: if fingerprint is None: return None @@ -544,9 +554,13 @@ def _fingerprint_matcher( def matches(candidate: CorrectionCandidate) -> bool | None: artifact = candidate.artifact - if not isinstance(artifact, MasterSeed) or artifact.header.threshold: - return None try: + if record is not None: + # Prefer corrections whose secret, or completed share set, matches the record. + seed = _completed(artifact, accepted) + return fingerprint(seed) == record if isinstance(seed, MasterSeed) else None + if not isinstance(artifact, MasterSeed) or artifact.header.threshold: + return None return _fingerprint_identifier(fingerprint(artifact)) == artifact.header.identifier except CodexError: return None @@ -562,8 +576,9 @@ def _suggestions( *, allowed: Callable[[CorrectionCandidate], bool] | None = None, fingerprint: Callable[[MasterSeed], bytes] | None = None, + record: bytes | None = None, ) -> tuple[CorrectionCandidate, ...]: - fingerprint_match = _fingerprint_matcher(fingerprint) + fingerprint_match = _fingerprint_matcher(fingerprint, record, accepted) erased = value if interpretation := _case_interpretation(value, prefix, profiles, allowed): candidate, value, erased, prefix = interpretation @@ -726,6 +741,7 @@ def _interactive( profiles: tuple[Profile, ...] | None, initial_prefix: str, fingerprint: Callable[[MasterSeed], bytes] | None, + record: bytes | None, ) -> list[Artifact]: accepted: list[Artifact] = [] prefix = initial_prefix @@ -770,10 +786,11 @@ def allowed(candidate: CorrectionCandidate) -> bool: accepted, allowed=allowed, fingerprint=fingerprint, + record=record, ) ) confirmation = ( - _confirm_correction(candidates[0], accepted, basis, fingerprint) + _confirm_correction(candidates[0], accepted, basis, fingerprint, record) if len(candidates) == 1 else None ) @@ -824,6 +841,7 @@ def read_artifacts( profiles: tuple[Profile, ...] | None = None, initial_prefix: str = "", fingerprint: Callable[[MasterSeed], bytes] | None = None, + record: bytes | None = None, ) -> list[Artifact]: if not sys.stdin.isatty(): return _redirected( @@ -840,6 +858,7 @@ def read_artifacts( profiles=profiles, initial_prefix=initial_prefix, fingerprint=fingerprint, + record=record, ) _stderr("") return result diff --git a/src/codex32/cli.py b/src/codex32/cli.py index 9ceae95..2b653b2 100644 --- a/src/codex32/cli.py +++ b/src/codex32/cli.py @@ -107,11 +107,13 @@ def _secret(artifacts: list[Artifact]) -> Secret: raise _UsageError(str(error)) from error -def _master_seed(fingerprint: Callable[[MasterSeed], bytes] | None = None) -> MasterSeed: - if isinstance( - value := _secret(_artifacts(profiles=(Profile.MS,), initial_prefix="MS1", fingerprint=fingerprint)), - MasterSeed, - ): +def _master_seed( + fingerprint: Callable[[MasterSeed], bytes] | None = None, record: bytes | None = None +) -> MasterSeed: + artifacts = _artifacts( + profiles=(Profile.MS,), initial_prefix="MS1", fingerprint=fingerprint, record=record + ) + if isinstance(value := _secret(artifacts), MasterSeed): return value raise _UsageError("Wallet commands accept only Bitcoin master-seed secrets.") @@ -231,6 +233,7 @@ def _creation_header(value: str | None) -> tuple[Profile, int | None, str | None def _creation_source( profile: Profile, fingerprint: Callable[[MasterSeed], bytes] | None = None, + record: bytes | None = None, ) -> bytes | Artifact: prefill = "" while True: @@ -258,13 +261,14 @@ def _creation_source( [], allowed=lambda item: isinstance(item.artifact, Secret) and item.artifact.profile is profile, fingerprint=fingerprint, + record=record, ) if ( len(candidates) == 1 and isinstance(candidate := candidates[0].artifact, Secret) and candidate.profile is profile ): - confirmation = _confirm_correction(candidates[0], [], False, fingerprint) + confirmation = _confirm_correction(candidates[0], [], False, fingerprint, record) if confirmation is True: return candidate if confirmation is False: @@ -363,26 +367,28 @@ def _without_record(core: BitcoinCore, secret: MasterSeed) -> bool: 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 +def _record() -> bytes | None: + # Asked before the cards, so corrections can be checked without showing the fingerprint. + while text := _text("Type the master fingerprint from your wallet record (Enter if none)", optional=True): try: - expected = parse_fingerprint(text) + return parse_fingerprint(text) except ValueError as error: _print(str(error), err=True) - continue + return None + + +def _recorded_fingerprint(core: BitcoinCore, secret: MasterSeed, expected: bytes | None) -> bytes | None: + # Re-ask for the record until the library accepts it, or the operator has none. + while expected is not None: try: core.verify_identity(secret, expected) + return expected except FingerprintMismatch as error: _print(str(error), err=True) - continue - return expected + expected = _record() + if _without_record(core, secret): + return None + raise _WalletSetupInterrupted def _initialize_wallet( @@ -394,19 +400,22 @@ def _initialize_wallet( fresh: bool = True, restore: bool = False, confirmed: bool = True, + identity_checked: bool = False, + expected_fingerprint: bytes | None = None, ) -> int: assert isinstance(secret, MasterSeed) try: if confirmed: _print("Master-seed backup confirmed.\n", err=True) - expected = _recorded_fingerprint(core, secret) if restore else None + if restore and not identity_checked: + expected_fingerprint = _recorded_fingerprint(core, secret, expected_fingerprint) if not restore: _show_fingerprint(core, secret, "Write it on the wallet record") name = core.initialize( secret, lambda prompt: _text(prompt, optional=True), lambda message: _print(message, err=True), - expected_fingerprint=expected, + expected_fingerprint=expected_fingerprint, account=account, timestamp=timestamp, ) @@ -483,43 +492,53 @@ def _create( else: raise _UsageError("For thresholds 4 through 9, choose --shares or --indices.") core = _connected_core() - source = _creation_source(profile) if existing else None - if not existing and not sys.stdin.isatty() and _text("", optional=True): - raise _UsageError("Use --existing when supplying a seed or secret.") + try: + record = _record() if existing else None + except KeyboardInterrupt as error: + raise _WalletSetupInterrupted from error + source = _creation_source(profile, core.fingerprint if record else None, record) 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=None if existing else core.fingerprint) - if sys.stdin.isatty(): - _confirm_card(secret) - return ( - _initialize_wallet( - core, secret, timestamp=0 if existing else "now", fresh=not existing, restore=existing + existing_secret: MasterSeed | None = None + if isinstance(source, MasterSeed): + if threshold == 0 and identifier is not None and identifier != source.header.identifier: + raise _UsageError( + "To change the existing secret's identifier, choose a sharing threshold from 2 through 9." ) - if core is not None - else 0 + existing_secret = source + elif source is not None: + existing_secret = _generated_secret(source, None, identifier, core.fingerprint_seed) + if threshold and identifier is None: + identifier = existing_secret.header.identifier + try: + expected = ( + _recorded_fingerprint(core, existing_secret, record) if existing_secret is not None else None ) - if isinstance(source, MasterSeed): - ceremony = CreationCeremony.from_secret( - source, - threshold=threshold, - identifier=identifier, - share_count=shares, - indices=indices, + 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, ) - elif source is not None: - source_secret = _generated_secret(source, None, identifier, core.fingerprint_seed) + + 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, @@ -545,12 +564,7 @@ def _create( _print(f"Recovery card {position + 1} of {output_count} confirmed.", err=True) finished = ceremony.finish() assert isinstance(finished, MasterSeed) - if core is not None: - return _initialize_wallet( - core, finished, timestamp=0 if existing else "now", fresh=not existing, restore=existing - ) - _print("\nEvery recovery card was confirmed from its re-entered text.", err=True) - return 0 + return finish_wallet(finished) def _correct( @@ -666,7 +680,8 @@ def _bitcoin_core(account: int, timestamp: int | Literal["now"]) -> int: core = _connected_core() # Keep the recovered fingerprint hidden until the operator has supplied # independent wallet-record evidence or explicitly chosen recordless restore. - secret = _master_seed() + record = _record() + secret = _master_seed(core.fingerprint if record else None, record) return _initialize_wallet( core, secret, @@ -675,6 +690,7 @@ def _bitcoin_core(account: int, timestamp: int | Literal["now"]) -> int: fresh=False, restore=True, confirmed=False, + expected_fingerprint=record, ) diff --git a/tests/test_bitcoin_core.py b/tests/test_bitcoin_core.py index f6323c9..9737203 100644 --- a/tests/test_bitcoin_core.py +++ b/tests/test_bitcoin_core.py @@ -843,7 +843,13 @@ def test_identifier_origin_names_the_rule_that_made_it(identifier: str, origin: assert ("matches this seed" in identifier_note(origin)) is (origin is not None) -def test_identifier_origin_still_checks_alpha_without_ripemd160(monkeypatch: pytest.MonkeyPatch) -> None: +@pytest.mark.parametrize( + ("identifier", "expected"), + (("hezu", "Bails alpha"), ("d9k8", "Bails check unavailable")), +) +def test_identifier_origin_without_ripemd160( + monkeypatch: pytest.MonkeyPatch, identifier: str, expected: str +) -> None: original_new = hashlib.new def without_ripemd160(name: str, data: bytes = b"") -> object: @@ -852,8 +858,11 @@ def without_ripemd160(name: str, data: bytes = b"") -> object: return original_new(name, data) monkeypatch.setattr(hashlib, "new", without_ripemd160) - secret = MasterSeed.from_seed(_BAILS_SEED, identifier="hezu") - assert identifier_origin(secret, _FINGERPRINT) == "Bails alpha" + secret = MasterSeed.from_seed(_BAILS_SEED, identifier=identifier) + assert identifier_origin(secret, _FINGERPRINT) == expected + if expected == "Bails check unavailable": + assert "could not be checked" in identifier_note(expected) + assert "does not prove" in identifier_note(expected) def test_identifier_note_allows_supported_nonderived_codex32_identifiers() -> None: diff --git a/tests/test_cli.py b/tests/test_cli.py index 82f3cfe..a008e91 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -129,13 +129,17 @@ def _offline_core(monkeypatch): _RECORDED_FINGERPRINT = importlib.import_module("codex32.cli")._recorded_fingerprint +_RECORD = importlib.import_module("codex32.cli")._record _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._record", lambda: None) + monkeypatch.setattr( + "codex32.cli._recorded_fingerprint", lambda core, secret, _expected: core.fingerprint(secret) + ) monkeypatch.setattr("codex32.cli._show_fingerprint", lambda _core, _secret, _action: None) @@ -2114,7 +2118,7 @@ def test_wallet_private_warning_precedes_recovery_input( ) -> None: cli_module = importlib.import_module("codex32.cli") - def stop_before_input(_fingerprint=None) -> MasterSeed: + def stop_before_input(_fingerprint=None, _record=None) -> MasterSeed: assert ( "Warning: This gives Bitcoin Core the master private key, which can spend funds." in capsys.readouterr().err @@ -2192,7 +2196,7 @@ def test_production_size_budgets_are_enforced() -> None: for path in package.rglob("*.py") } - assert sum(counts.values()) < 5200, counts + assert sum(counts.values()) < 5250, counts @pytest.mark.parametrize( @@ -2564,6 +2568,45 @@ def test_final_share_preview_is_isolated_and_basis_has_no_preview(monkeypatch, c assert "Master fingerprint" not in capsys.readouterr().err +@pytest.mark.parametrize("matches", (True, False)) +def test_record_preview_and_ranking_never_show_the_fingerprint(monkeypatch, capsys, matches): + module = importlib.import_module("codex32._cli_input") + fingerprint = _FakeBitcoinCore().fingerprint + first = parse_codex32(VECTOR_2["share_A"]) + candidate = CorrectionCandidate( + parse_codex32(VECTOR_2["share_C"]), (), 1, 0, 0, None, capture_space_bits=65 + ) + right = bytes.fromhex("FAB6868A") + record = right if matches else bytes([right[0] ^ 1]) + right[1:] + monkeypatch.setattr(module.sys, "stdin", _TTYInput()) + monkeypatch.setattr(module, "_editable_input", lambda prompt: "n") + + assert not module._confirm_correction(candidate, [first], False, fingerprint, record) + shown = capsys.readouterr().err + verdict = "matches" if matches else "does not match" + assert f"Master fingerprint {verdict} your wallet record.\n\n" in shown + assert "FAB6868A" not in shown.upper() + matcher = module._fingerprint_matcher(fingerprint, record, [first]) + assert matcher(candidate) is matches + assert module._fingerprint_matcher(fingerprint, record, [])(candidate) is None + + +def test_wallet_checks_a_corrected_final_share_against_the_typed_record(monkeypatch) -> None: + cli = importlib.import_module("codex32.cli") + monkeypatch.setattr(cli, "_record", _RECORD) + monkeypatch.setattr(cli, "_recorded_fingerprint", _RECORDED_FINGERPRINT) + share_c = VECTOR_2["share_C"] + damaged = share_c[:20] + ("q" if share_c[20] != "q" else "p") + share_c[21:] + + result, core = _invoke_initialized_wallet(["wallet"], "fab6868a", VECTOR_2["share_A"], damaged, "y") + + assert result.exit_code == 0 + preview = result.stderr.split("Possible correction:", 1)[1].split("Bitcoin Core spending wallet", 1)[0] + assert "Master fingerprint matches your wallet record." in preview + assert "FAB6868A" not in preview.upper() + assert core.expected == bytes.fromhex("FAB6868A") + + def test_failed_fingerprint_never_offers_confirmation(monkeypatch, capsys): module = importlib.import_module("codex32._cli_input") from codex32.errors import CodexError @@ -2945,10 +2988,11 @@ def test_restore_record_prompt_retries_until_the_library_accepts( 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()) + monkeypatch.setattr(importlib.import_module("codex32.cli"), "_record", _RECORD) + prompts = _record_answers(monkeypatch, "not hex", right.hex().upper()) - assert _RECORDED_FINGERPRINT(core, secret) == right - assert prompts == ["Type the master fingerprint from your wallet record (Enter if none)"] * 3 + assert _RECORDED_FINGERPRINT(core, secret, wrong) == right + assert prompts == ["Type the master fingerprint from your wallet record (Enter if none)"] * 2 errors = capsys.readouterr().err assert "8 characters" in errors and "does not match" in errors assert right.hex() not in errors.lower() @@ -2961,38 +3005,50 @@ def test_restore_without_a_record_shows_what_the_cards_say_and_asks( assert isinstance(secret, MasterSeed) fingerprint = core.fingerprint(secret) - prompts = _record_answers(monkeypatch, "", "n") + 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]" + _RECORDED_FINGERPRINT(core, secret, None) + assert prompts == ["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]" + prompts = _record_answers(monkeypatch, "y") + assert _RECORDED_FINGERPRINT(core, secret, None) is None + assert prompts == ["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 + _record_answers(monkeypatch, "yes") + assert _RECORDED_FINGERPRINT(core, derived, None) 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: +@pytest.mark.parametrize("typed", (False, True)) +def test_wallet_restore_asks_for_the_record_before_the_shares( + monkeypatch: pytest.MonkeyPatch, typed: bool +) -> None: cli = importlib.import_module("codex32.cli") core, secret = _FakeBitcoinCore(), parse_codex32(VECTOR_1["secret_s"]) assert isinstance(secret, MasterSeed) + record = core.fingerprint(secret) if typed else None seen: list[object] = [] + def master_seed(fingerprint=None, recorded=None): + seen.append((fingerprint, recorded)) + return secret + 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) + monkeypatch.setattr(cli, "_record", lambda: seen.append("record") or record) + monkeypatch.setattr(cli, "_master_seed", master_seed) + monkeypatch.setattr( + cli, "_initialize_wallet", lambda *_args, **kwargs: seen.append(kwargs["expected_fingerprint"]) + ) - assert cli._bitcoin_core(0, "now") == 0 - assert seen == [None] + cli._bitcoin_core(0, "now") + # Without a record the recovered fingerprint stays hidden until the recordless gate. + assert seen == ["record", (core.fingerprint if typed else None, record), record] def test_create_only_requires_acknowledging_that_the_fingerprint_was_recorded( @@ -3034,11 +3090,11 @@ def test_create_existing_checks_the_record_before_import(monkeypatch: pytest.Mon source_fingerprints = [] emitted_fingerprints = [] - def source(_profile, fingerprint=None): + def source(_profile, fingerprint=None, _record=None): source_fingerprints.append(fingerprint) return secret - def record(_core, recovered): + def record(_core, recovered, _expected): assert core.imported is None checked.append(recovered.seed_bytes) return core.fingerprint(recovered) @@ -3062,3 +3118,164 @@ def emit(_artifact, _plain, **kwargs): assert emitted_fingerprints and all(fingerprint is None for fingerprint in emitted_fingerprints) assert checked == [secret.seed_bytes] assert core.expected == core.fingerprint(secret) + + +@pytest.mark.parametrize("encoding", ("hex", "codex32")) +@pytest.mark.parametrize("shared", (False, True)) +def test_create_existing_checks_record_before_card_output( + monkeypatch: pytest.MonkeyPatch, encoding: str, shared: bool +) -> None: + cli = importlib.import_module("codex32.cli") + secret = parse_codex32(VECTOR_1["secret_s"]) + assert isinstance(secret, MasterSeed) + core = _FakeBitcoinCore() + source = secret.seed_bytes.hex() if encoding == "hex" else secret.text + answers = iter((core.fingerprint(secret).hex().upper(), source)) + events: list[str] = [] + + def check_record( + selected: _FakeBitcoinCore, supplied: MasterSeed, expected: bytes | None + ) -> bytes | None: + assert events == [] + checked = _RECORDED_FINGERPRINT(selected, supplied, expected) + 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, "_record", _RECORD) + 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((wrong.hex(), source, "", "n")) + + monkeypatch.setattr(sys, "stdin", _TTYInput()) + monkeypatch.setattr(sys, "stdout", _TTYOutput()) + monkeypatch.setattr(cli, "_text", lambda _prompt, **_options: next(answers)) + monkeypatch.setattr(cli, "_record", _RECORD) + 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 *_args: 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_interrupt_at_the_record_keeps_existing_backup_valid(monkeypatch, capsys) -> None: + cli = importlib.import_module("codex32.cli") + + def interrupt(*_args: object, **_kwargs: object) -> str: + raise KeyboardInterrupt + + monkeypatch.setattr(sys, "stdin", _TTYInput()) + monkeypatch.setattr(sys, "stdout", _TTYOutput()) + monkeypatch.setattr(cli, "_text", interrupt) + monkeypatch.setattr(cli, "_record", _RECORD) + assert ms_main(["create", "2", "--indices", "ac", "--existing"]) == 130 + message = capsys.readouterr().err + assert "recovery cards are valid" in message and "Mark every card" not in message + + +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, "_record", _RECORD) + 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_disclosure.py b/tests/test_correction_disclosure.py index 08592ee..cbbf80c 100644 --- a/tests/test_correction_disclosure.py +++ b/tests/test_correction_disclosure.py @@ -118,6 +118,8 @@ def test_declining_gate_aborts_every_flow_without_metadata(monkeypatch, answer, prompts = [] def respond(prompt, prefill=""): + if prompt.startswith("Type the master fingerprint"): + return "" # create --existing asks for the wallet record first; there is none here. prompts.append(prompt) if len(prompts) == 1: return source[:-1] + "?"