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/user/guide.md b/docs/user/guide.md index 422ec99..05225cb 100644 --- a/docs/user/guide.md +++ b/docs/user/guide.md @@ -122,12 +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. 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. +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 @@ -241,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/_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 dc6d3be..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( @@ -402,7 +408,7 @@ def _initialize_wallet( if confirmed: _print("Master-seed backup confirmed.\n", err=True) if restore and not identity_checked: - expected_fingerprint = _recorded_fingerprint(core, secret) + expected_fingerprint = _recorded_fingerprint(core, secret, expected_fingerprint) if not restore: _show_fingerprint(core, secret, "Write it on the wallet record") name = core.initialize( @@ -486,7 +492,11 @@ 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 + 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: @@ -502,7 +512,9 @@ def _create( 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 + expected = ( + _recorded_fingerprint(core, existing_secret, record) if existing_secret is not None else None + ) except (EOFError, KeyboardInterrupt) as error: raise _WalletSetupInterrupted from error @@ -668,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, @@ -677,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_cli.py b/tests/test_cli.py index 2978174..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) @@ -3074,12 +3130,14 @@ def test_create_existing_checks_record_before_card_output( 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())) + answers = iter((core.fingerprint(secret).hex().upper(), source)) events: list[str] = [] - def check_record(selected: _FakeBitcoinCore, supplied: MasterSeed) -> bytes | None: + def check_record( + selected: _FakeBitcoinCore, supplied: MasterSeed, expected: bytes | None + ) -> bytes | None: assert events == [] - checked = _RECORDED_FINGERPRINT(selected, supplied) + checked = _RECORDED_FINGERPRINT(selected, supplied, expected) events.append("record") return checked @@ -3093,6 +3151,7 @@ def confirm_card( 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) @@ -3116,11 +3175,12 @@ def test_create_existing_rejects_wrong_record_before_sharing( 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")) + 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 ( @@ -3147,7 +3207,7 @@ def interrupt_record(*_args: object) -> bytes | None: monkeypatch.setattr(sys, "stdin", _TTYInput()) monkeypatch.setattr(sys, "stdout", _TTYOutput()) - monkeypatch.setattr(cli, "_creation_source", lambda _profile: secret) + monkeypatch.setattr(cli, "_creation_source", lambda *_args: secret) monkeypatch.setattr(cli, "_recorded_fingerprint", interrupt_record) monkeypatch.setattr(cli.BitcoinCore, "connect", lambda *args: core) with ( @@ -3164,6 +3224,21 @@ def interrupt_record(*_args: object) -> bytes | None: 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: @@ -3171,7 +3246,7 @@ def test_create_existing_recordless_choice_precedes_sharing( secret = parse_codex32(VECTOR_1["secret_s"]) assert isinstance(secret, MasterSeed) core = _FakeBitcoinCore() - answers = iter((secret.seed_bytes.hex(), "", "y")) + answers = iter(("", secret.seed_bytes.hex(), "y")) events: list[str] = [] emitted: list[Share | Secret] = [] @@ -3190,6 +3265,7 @@ def emit(artifact: Share | Secret, *_args: object, **_kwargs: object) -> None: 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) 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] + "?"