From 83686a333988fb7db2b0c309c469b167b604d3f9 Mon Sep 17 00:00:00 2001 From: Codex Agent Date: Wed, 30 Sep 2026 12:25:04 -0500 Subject: [PATCH 1/9] wallet: Mark unavailable Bails check inconclusive When RIPEMD-160 is unavailable, a valid standard Bails identifier cannot be checked. Preserve the Bails-alpha SHA-256 result and distinguish that inconclusive state from a completed identifier mismatch, so the no-record restore prompt does not claim the cards are wrong. Keep the independent fingerprint and explicit operator-confirmation boundary unchanged. Refs #79 --- src/codex32/_bitcoin_core.py | 11 ++++++++++- tests/test_bitcoin_core.py | 15 ++++++++++++--- 2 files changed, 22 insertions(+), 4 deletions(-) diff --git a/src/codex32/_bitcoin_core.py b/src/codex32/_bitcoin_core.py index 5b78a74..51712e4 100644 --- a/src/codex32/_bitcoin_core.py +++ b/src/codex32/_bitcoin_core.py @@ -60,6 +60,12 @@ def parse_fingerprint(text: str) -> bytes: def identifier_note(origin: str | None) -> str: # Say what `identifier_origin` found, for an operator restoring without a record. + if origin == "Bails check unavailable": + return ( + "The standard Bails identifier could not be checked because RIPEMD-160 is unavailable. " + "This does not prove the cards are wrong or mixed up. Compare the fingerprint and any " + "other wallet record you have before restoring." + ) if origin is None: return ( "The backup identifier was not made from this seed. That can be normal for codex32 backups " @@ -77,15 +83,18 @@ def identifier_origin(secret: MasterSeed, fingerprint: bytes) -> str | None: identifier = secret.header.identifier if identifier == _fingerprint_identifier(fingerprint): return "codex32" + ripemd_unavailable = False for name, digest in (("Bails", "ripemd160"), ("Bails alpha", "sha256")): try: hashed = hashlib.new(digest, secret.seed_bytes).digest() except ValueError: + if name == "Bails": + ripemd_unavailable = True continue derived = convertbits(hashed, 8, 5, pad=True) if identifier[:3] == _u5_to_chars(tuple(derived[:3])): return name - return None + return "Bails check unavailable" if ripemd_unavailable else None @dataclass(frozen=True) diff --git a/tests/test_bitcoin_core.py b/tests/test_bitcoin_core.py index f6323c9..9737203 100644 --- a/tests/test_bitcoin_core.py +++ b/tests/test_bitcoin_core.py @@ -843,7 +843,13 @@ def test_identifier_origin_names_the_rule_that_made_it(identifier: str, origin: assert ("matches this seed" in identifier_note(origin)) is (origin is not None) -def test_identifier_origin_still_checks_alpha_without_ripemd160(monkeypatch: pytest.MonkeyPatch) -> None: +@pytest.mark.parametrize( + ("identifier", "expected"), + (("hezu", "Bails alpha"), ("d9k8", "Bails check unavailable")), +) +def test_identifier_origin_without_ripemd160( + monkeypatch: pytest.MonkeyPatch, identifier: str, expected: str +) -> None: original_new = hashlib.new def without_ripemd160(name: str, data: bytes = b"") -> object: @@ -852,8 +858,11 @@ def without_ripemd160(name: str, data: bytes = b"") -> object: return original_new(name, data) monkeypatch.setattr(hashlib, "new", without_ripemd160) - secret = MasterSeed.from_seed(_BAILS_SEED, identifier="hezu") - assert identifier_origin(secret, _FINGERPRINT) == "Bails alpha" + secret = MasterSeed.from_seed(_BAILS_SEED, identifier=identifier) + assert identifier_origin(secret, _FINGERPRINT) == expected + if expected == "Bails check unavailable": + assert "could not be checked" in identifier_note(expected) + assert "does not prove" in identifier_note(expected) def test_identifier_note_allows_supported_nonderived_codex32_identifiers() -> None: From a0ab769e332f2ea888ac9120bb5b6a17eab42f71 Mon Sep 17 00:00:00 2001 From: Codex Agent Date: Wed, 30 Sep 2026 12:42:13 -0500 Subject: [PATCH 2/9] docs: Define inconclusive Bails identity result The no-record restore flow can no longer claim a standard Bails identifier mismatch when RIPEMD-160 is unavailable. Record that platform-dependent inconclusive outcome in both the security model and invariant so reviewers can distinguish it from a completed comparison. Refs #79 --- docs/security/invariants.md | 7 ++++--- docs/security/model.md | 2 +- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/docs/security/invariants.md b/docs/security/invariants.md index 42adce5..be5ed48 100644 --- a/docs/security/invariants.md +++ b/docs/security/invariants.md @@ -14,9 +14,10 @@ and evidence. Restore authenticates the recovered seed before any wallet is listed, unlocked, or imported into: normally with the master fingerprint typed from the wallet record, or by an explicit no-record choice made after seeing the - recovered fingerprint and whether the backup identifier was derived from the - seed. Fresh `ms32 create` ceremonies do not authenticate against a - pre-existing wallet; they require the operator to record the new fingerprint. + recovered fingerprint and whether the backup identifier matched a + seed-derived rule or its standard Bails check was unavailable. Fresh + `ms32 create` ceremonies do not authenticate against a pre-existing wallet; + they require the operator to record the new fingerprint. 5. Correction shares one mass bound and deadline across target lengths. The public API fails closed on incomplete required work; CLI searches may return one primary-best-so-far eligible candidate at the deadline. Incomplete diff --git a/docs/security/model.md b/docs/security/model.md index 2d3e18b..da86118 100644 --- a/docs/security/model.md +++ b/docs/security/model.md @@ -231,7 +231,7 @@ signing setup belong to Bitcoin Core's maintained v32 workflow. | Control | Required behavior | |---|---| | Preflight | Before entropy or recovery input, explicit chain arguments probe the five standard local networks for Bitcoin Core 32 or newer. One response is selected automatically; multiple responses require operator selection. | -| Recovery identity | `ms32 wallet` and `ms32 create --existing` authenticate a recovered seed before any wallet is listed. Core derives the recovered fingerprint statelessly, and a mismatch raises `FingerprintMismatch` before any wallet RPC. The restore prompt does not show the recovered value, so the operator compares by typing the fingerprint from the wallet record. Without a record, the operator is shown the recovered fingerprint, whether the backup identifier was derived from the seed (the codex32 fingerprint rule, Bails' RIPEMD-160 rule, or its mid-2023 alpha's SHA-256 rule), and a warning, and then chooses. Fresh `ms32 create` has no pre-existing wallet to authenticate: it shows the newly created seed's fingerprint and requires the operator to acknowledge recording it. These checks catch mistakes such as wrong or mixed cards; anyone able to replace a threshold of cards could already read them. | +| Recovery identity | `ms32 wallet` and `ms32 create --existing` authenticate a recovered seed before any wallet is listed. Core derives the recovered fingerprint statelessly, and a mismatch raises `FingerprintMismatch` before any wallet RPC. The restore prompt does not show the recovered value, so the operator compares by typing the fingerprint from the wallet record. Without a record, the operator is shown the recovered fingerprint, whether the backup identifier was derived from the seed (the codex32 fingerprint rule, Bails' RIPEMD-160 rule, or its mid-2023 alpha's SHA-256 rule), and a warning, and then chooses. If RIPEMD-160 is unavailable, the standard Bails identifier check is reported as inconclusive rather than a mismatch; the Bails-alpha SHA-256 rule remains checkable. Fresh `ms32 create` has no pre-existing wallet to authenticate: it shows the newly created seed's fingerprint and requires the operator to acknowledge recording it. These checks catch mistakes such as wrong or mixed cards; anyone able to replace a threshold of cards could already read them. | | Process boundary | codex32 invokes the reviewed `bitcoin-cli` from `PATH` as a child without a shell, direct RPC socket, wallet database, or wallet-creation operation. Every call uses loopback and the selected chain. | | Destination | Only an empty descriptor wallet with private keys enabled, no external signer, transactions, descriptors, keypool entries, or active scan is eligible. One eligible wallet is offered directly; multiple wallets are selected by number. New wallets are detected by polling, and rejection returns to every eligible wallet. The escaped name is confirmed exactly. | | Seed source | The original ceremony result or validated recovered master seed supplies a root xprv for Core's reported chain. Core v32 creates BIP44, BIP49, BIP84, and BIP86 account-0 descriptors from that key. | From 8008b6e9f18522d07de7c6eba3f2fad16dc667a7 Mon Sep 17 00:00:00 2001 From: Codex Agent Date: Wed, 30 Sep 2026 17:33:22 -0500 Subject: [PATCH 3/9] wallet: Check existing seed before sharing An existing hex seed or codex32 master secret previously reached the wallet-record fingerprint check only after new recovery cards had been generated and confirmed. Check the typed record immediately after parsing the source, before any card output or ceremony. Preserve the explicit recordless path at the same early decision point, and pass the checked result through to wallet initialization so it is not prompted twice. Cover matching, mismatching, and recordless flows for both source encodings. Refs #30. --- docs/security/model.md | 2 +- docs/user/guide.md | 6 ++- src/codex32/cli.py | 69 ++++++++++++++--------------- tests/test_cli.py | 99 ++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 140 insertions(+), 36 deletions(-) diff --git a/docs/security/model.md b/docs/security/model.md index da86118..e025fb3 100644 --- a/docs/security/model.md +++ b/docs/security/model.md @@ -231,7 +231,7 @@ signing setup belong to Bitcoin Core's maintained v32 workflow. | Control | Required behavior | |---|---| | Preflight | Before entropy or recovery input, explicit chain arguments probe the five standard local networks for Bitcoin Core 32 or newer. One response is selected automatically; multiple responses require operator selection. | -| Recovery identity | `ms32 wallet` and `ms32 create --existing` authenticate a recovered seed before any wallet is listed. Core derives the recovered fingerprint statelessly, and a mismatch raises `FingerprintMismatch` before any wallet RPC. The restore prompt does not show the recovered value, so the operator compares by typing the fingerprint from the wallet record. Without a record, the operator is shown the recovered fingerprint, whether the backup identifier was derived from the seed (the codex32 fingerprint rule, Bails' RIPEMD-160 rule, or its mid-2023 alpha's SHA-256 rule), and a warning, and then chooses. If RIPEMD-160 is unavailable, the standard Bails identifier check is reported as inconclusive rather than a mismatch; the Bails-alpha SHA-256 rule remains checkable. Fresh `ms32 create` has no pre-existing wallet to authenticate: it shows the newly created seed's fingerprint and requires the operator to acknowledge recording it. These checks catch mistakes such as wrong or mixed cards; anyone able to replace a threshold of cards could already read them. | +| Recovery identity | `ms32 wallet` and `ms32 create --existing` authenticate a recovered seed before any wallet is listed. For `create --existing`, the wallet-record decision also precedes generation or display of any new card. Core derives the recovered fingerprint statelessly, and a mismatch raises `FingerprintMismatch` before any wallet RPC. The restore prompt does not show the recovered value, so the operator compares by typing the fingerprint from the wallet record. Without a record, the operator is shown the recovered fingerprint, whether the backup identifier was derived from the seed (the codex32 fingerprint rule, Bails' RIPEMD-160 rule, or its mid-2023 alpha's SHA-256 rule), and a warning, and then chooses. If RIPEMD-160 is unavailable, the standard Bails identifier check is reported as inconclusive rather than a mismatch; the Bails-alpha SHA-256 rule remains checkable. Fresh `ms32 create` has no pre-existing wallet to authenticate: it shows the newly created seed's fingerprint and requires the operator to acknowledge recording it. These checks catch mistakes such as wrong or mixed cards; anyone able to replace a threshold of cards could already read them. | | Process boundary | codex32 invokes the reviewed `bitcoin-cli` from `PATH` as a child without a shell, direct RPC socket, wallet database, or wallet-creation operation. Every call uses loopback and the selected chain. | | Destination | Only an empty descriptor wallet with private keys enabled, no external signer, transactions, descriptors, keypool entries, or active scan is eligible. One eligible wallet is offered directly; multiple wallets are selected by number. New wallets are detected by polling, and rejection returns to every eligible wallet. The escaped name is confirmed exactly. | | Seed source | The original ceremony result or validated recovered master seed supplies a root xprv for Core's reported chain. Core v32 creates BIP44, BIP49, BIP84, and BIP86 account-0 descriptors from that key. | diff --git a/docs/user/guide.md b/docs/user/guide.md index 332d77a..422ec99 100644 --- a/docs/user/guide.md +++ b/docs/user/guide.md @@ -123,7 +123,11 @@ Already have a complete codex32 `ms` secret? Run `ms32 create --existing` to write and confirm its recovery card and initialize a Bitcoin Core wallet. The existing secret is preserved unchanged. To split it into three cards requiring any two, use `ms32 create 2 --existing` instead. Enter the secret -only when prompted. Bitcoin Core also scans for prior transactions. +only when prompted. Immediately afterward, type the master fingerprint from +the separate wallet record; a mismatch must be resolved before any new card +is shown. If you have no record, the explicit recordless-restore choice and +visual fingerprint check happen at this same point. Bitcoin Core also scans +for prior transactions. ### 3. Make a Bitcoin Core wallet diff --git a/src/codex32/cli.py b/src/codex32/cli.py index 9ceae95..95791d5 100644 --- a/src/codex32/cli.py +++ b/src/codex32/cli.py @@ -394,12 +394,16 @@ def _initialize_wallet( fresh: bool = True, restore: bool = False, confirmed: bool = True, + identity_checked: bool = False, + expected_fingerprint: bytes | None = None, ) -> int: assert isinstance(secret, MasterSeed) try: if confirmed: _print("Master-seed backup confirmed.\n", err=True) - expected = _recorded_fingerprint(core, secret) if restore else None + expected = expected_fingerprint + if restore and not identity_checked: + expected = _recorded_fingerprint(core, secret) if not restore: _show_fingerprint(core, secret, "Write it on the wallet record") name = core.initialize( @@ -489,37 +493,39 @@ def _create( if isinstance(source, (Share, Secret)) and not isinstance(source, MasterSeed): raise _UsageError(f"Enter one {_profile_rules(profile).label}, not a share or another backup type.") try: - if threshold == 0: - if isinstance(source, MasterSeed): - if identifier is not None and identifier != source.header.identifier: - raise _UsageError( - "To change the existing secret's identifier, choose a sharing threshold from 2 through 9." - ) - secret = source - else: - secret = _generated_secret(source, byte_length, identifier, core.fingerprint_seed) - _emit(secret, False, fingerprint=None if existing else core.fingerprint) - if sys.stdin.isatty(): - _confirm_card(secret) - return ( - _initialize_wallet( - core, secret, timestamp=0 if existing else "now", fresh=not existing, restore=existing + if isinstance(source, MasterSeed): + if threshold == 0 and identifier is not None and identifier != source.header.identifier: + raise _UsageError( + "To change the existing secret's identifier, choose a sharing threshold from 2 through 9." ) - if core is not None - else 0 + existing_secret = source + elif source is not None: + existing_secret = _generated_secret(source, None, identifier, core.fingerprint_seed) + else: + existing_secret = None + expected = _recorded_fingerprint(core, existing_secret) if existing_secret is not None else None + + def finish_wallet(seed: MasterSeed) -> int: + return _initialize_wallet( + core, + seed, + timestamp=0 if existing else "now", + fresh=not existing, + restore=existing, + identity_checked=existing, + expected_fingerprint=expected, ) - if isinstance(source, MasterSeed): - ceremony = CreationCeremony.from_secret( - source, - threshold=threshold, - identifier=identifier, - share_count=shares, - indices=indices, + + if threshold == 0: + secret = existing_secret or _generated_secret( + None, byte_length, identifier, core.fingerprint_seed ) - elif source is not None: - source_secret = _generated_secret(source, None, identifier, core.fingerprint_seed) + _emit(secret, False, fingerprint=None if existing else core.fingerprint) + _confirm_card(secret) + return finish_wallet(secret) + if existing_secret is not None: ceremony = CreationCeremony.from_secret( - source_secret, + existing_secret, threshold=threshold, identifier=identifier, share_count=shares, @@ -545,12 +551,7 @@ def _create( _print(f"Recovery card {position + 1} of {output_count} confirmed.", err=True) finished = ceremony.finish() assert isinstance(finished, MasterSeed) - if core is not None: - return _initialize_wallet( - core, finished, timestamp=0 if existing else "now", fresh=not existing, restore=existing - ) - _print("\nEvery recovery card was confirmed from its re-entered text.", err=True) - return 0 + return finish_wallet(finished) def _correct( diff --git a/tests/test_cli.py b/tests/test_cli.py index 82f3cfe..2b52fe4 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -3062,3 +3062,102 @@ def emit(_artifact, _plain, **kwargs): assert emitted_fingerprints and all(fingerprint is None for fingerprint in emitted_fingerprints) assert checked == [secret.seed_bytes] assert core.expected == core.fingerprint(secret) + + +@pytest.mark.parametrize("encoding", ("hex", "codex32")) +@pytest.mark.parametrize("shared", (False, True)) +def test_create_existing_checks_record_before_card_output( + monkeypatch: pytest.MonkeyPatch, encoding: str, shared: bool +) -> None: + cli = importlib.import_module("codex32.cli") + secret = parse_codex32(VECTOR_1["secret_s"]) + assert isinstance(secret, MasterSeed) + core = _FakeBitcoinCore() + source = secret.seed_bytes.hex() if encoding == "hex" else secret.text + answers = iter((source, core.fingerprint(secret).hex().upper())) + events: list[str] = [] + + def check_record(selected: _FakeBitcoinCore, supplied: MasterSeed) -> bytes | None: + assert events == [] + checked = _RECORDED_FINGERPRINT(selected, supplied) + events.append("record") + return checked + + def confirm_card( + artifact: Share | Secret, + confirm: Callable[[str], ConfirmationResult] | None = None, + ) -> None: + if confirm is not None: + assert confirm(artifact.text).accepted + + monkeypatch.setattr(sys, "stdin", _TTYInput()) + monkeypatch.setattr(sys, "stdout", _TTYOutput()) + monkeypatch.setattr(cli, "_text", lambda _prompt, **_options: next(answers)) + monkeypatch.setattr(cli, "_recorded_fingerprint", check_record) + monkeypatch.setattr(cli, "_emit", lambda *_args, **_kwargs: events.append("card")) + monkeypatch.setattr(cli, "_confirm_card", confirm_card) + monkeypatch.setattr(cli.BitcoinCore, "connect", lambda *args: core) + + args = ["create", "2", "--indices", "ac", "--existing"] if shared else ["create", "--existing"] + assert ms_main(args) == 0 + assert events == (["record", "card", "card"] if shared else ["record", "card"]) + assert core.expected == core.fingerprint(secret) + assert core.imported is not None and core.imported.seed_bytes == secret.seed_bytes + + +@pytest.mark.parametrize("encoding", ("hex", "codex32")) +def test_create_existing_rejects_wrong_record_before_sharing( + monkeypatch: pytest.MonkeyPatch, encoding: str +) -> None: + cli = importlib.import_module("codex32.cli") + secret = parse_codex32(VECTOR_1["secret_s"]) + assert isinstance(secret, MasterSeed) + core = _FakeBitcoinCore() + source = secret.seed_bytes.hex() if encoding == "hex" else secret.text + right = core.fingerprint(secret) + wrong = bytes([right[0] ^ 1]) + right[1:] + answers = iter((source, wrong.hex(), "", "n")) + + monkeypatch.setattr(sys, "stdin", _TTYInput()) + monkeypatch.setattr(sys, "stdout", _TTYOutput()) + monkeypatch.setattr(cli, "_text", lambda _prompt, **_options: next(answers)) + monkeypatch.setattr(cli, "_recorded_fingerprint", _RECORDED_FINGERPRINT) + monkeypatch.setattr(cli.BitcoinCore, "connect", lambda *args: core) + with ( + patch("codex32.cli.CreationCeremony.from_secret") as split, + patch("codex32.cli._emit") as emit, + ): + assert ms_main(["create", "2", "--indices", "ac", "--existing"]) == 130 + split.assert_not_called() + emit.assert_not_called() + assert core.imported is None + + +def test_create_existing_recordless_choice_precedes_sharing(monkeypatch: pytest.MonkeyPatch) -> None: + cli = importlib.import_module("codex32.cli") + secret = parse_codex32(VECTOR_1["secret_s"]) + assert isinstance(secret, MasterSeed) + core = _FakeBitcoinCore() + answers = iter((secret.seed_bytes.hex(), "", "y")) + events: list[str] = [] + + def confirm_card(artifact: Share | Secret, confirm=None) -> None: + if confirm is not None: + assert confirm(artifact.text).accepted + + def answer(_prompt: str, **_options: object) -> str: + assert events == [] + return next(answers) + + monkeypatch.setattr(sys, "stdin", _TTYInput()) + monkeypatch.setattr(sys, "stdout", _TTYOutput()) + monkeypatch.setattr(cli, "_text", answer) + monkeypatch.setattr(cli, "_recorded_fingerprint", _RECORDED_FINGERPRINT) + monkeypatch.setattr(cli, "_emit", lambda *_args, **_kwargs: events.append("card")) + monkeypatch.setattr(cli, "_confirm_card", confirm_card) + monkeypatch.setattr(cli.BitcoinCore, "connect", lambda *args: core) + + assert ms_main(["create", "2", "--indices", "ac", "--existing"]) == 0 + assert events == ["card", "card"] + assert core.expected is None + assert core.imported is not None and core.imported.seed_bytes == secret.seed_bytes From 7abc245e56f3a4cdaec23b137be1157d456c7343 Mon Sep 17 00:00:00 2001 From: Ben Westgate Date: Wed, 30 Sep 2026 18:10:03 -0500 Subject: [PATCH 4/9] wallet: Preserve existing cards on interrupt Translate Ctrl-C or EOF at the early wallet-record gate for ms32 create --existing into the existing wallet-setup interruption path. This keeps an operator from being told to invalidate a pre-existing recovery card before any new share ceremony has started. Add a focused regression proving the interruption occurs before share creation or output and preserves the valid-backup message. --- src/codex32/cli.py | 5 ++++- tests/test_cli.py | 31 +++++++++++++++++++++++++++++++ 2 files changed, 35 insertions(+), 1 deletion(-) diff --git a/src/codex32/cli.py b/src/codex32/cli.py index 95791d5..84c829f 100644 --- a/src/codex32/cli.py +++ b/src/codex32/cli.py @@ -503,7 +503,10 @@ def _create( existing_secret = _generated_secret(source, None, identifier, core.fingerprint_seed) else: existing_secret = None - expected = _recorded_fingerprint(core, existing_secret) if existing_secret is not None else None + try: + expected = _recorded_fingerprint(core, existing_secret) if existing_secret is not None else None + except (EOFError, KeyboardInterrupt) as error: + raise _WalletSetupInterrupted from error def finish_wallet(seed: MasterSeed) -> int: return _initialize_wallet( diff --git a/tests/test_cli.py b/tests/test_cli.py index 2b52fe4..0557f97 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -3133,6 +3133,37 @@ def test_create_existing_rejects_wrong_record_before_sharing( assert core.imported is None +def test_create_existing_record_gate_interruption_keeps_existing_backup_valid( + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], +) -> None: + cli = importlib.import_module("codex32.cli") + secret = parse_codex32(VECTOR_1["secret_s"]) + assert isinstance(secret, MasterSeed) + core = _FakeBitcoinCore() + + def interrupt_record(*_args: object) -> bytes | None: + raise KeyboardInterrupt + + monkeypatch.setattr(sys, "stdin", _TTYInput()) + monkeypatch.setattr(sys, "stdout", _TTYOutput()) + monkeypatch.setattr(cli, "_creation_source", lambda _profile: secret) + monkeypatch.setattr(cli, "_recorded_fingerprint", interrupt_record) + monkeypatch.setattr(cli.BitcoinCore, "connect", lambda *args: core) + with ( + patch("codex32.cli.CreationCeremony.from_secret") as split, + patch("codex32.cli._emit") as emit, + ): + assert ms_main(["create", "2", "--indices", "ac", "--existing"]) == 130 + + split.assert_not_called() + emit.assert_not_called() + message = capsys.readouterr().err + assert "recovery cards are valid" in message + assert "Mark every card" not in message + assert core.imported is None + + def test_create_existing_recordless_choice_precedes_sharing(monkeypatch: pytest.MonkeyPatch) -> None: cli = importlib.import_module("codex32.cli") secret = parse_codex32(VECTOR_1["secret_s"]) From 348beeb5ed1820ef3e24271c60b7f2623e9e1ac0 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 30 Sep 2026 23:56:04 +0000 Subject: [PATCH 5/9] cli: Trim two lines from the early record gate Reassign the expected_fingerprint argument instead of copying it into a local, and give existing_secret its None default before the source checks instead of in an else branch. Behavior is unchanged. The installed package drops from 5161 to 5159 logical review lines, which keeps the integrated #7/#42/#57/#46/#80/#81 tip under the <5200 budget. Security: the record gate still runs before any card is generated or shown, and interrupts at that gate still raise _WalletSetupInterrupted. Validation: ruff check, ruff format --check, mypy src/codex32, and pytest (918 passed, with and without -O). Refs #81, #38. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_018az69UX4773mYohXAtE8kD --- src/codex32/cli.py | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/src/codex32/cli.py b/src/codex32/cli.py index 84c829f..e88fa8f 100644 --- a/src/codex32/cli.py +++ b/src/codex32/cli.py @@ -401,16 +401,15 @@ def _initialize_wallet( try: if confirmed: _print("Master-seed backup confirmed.\n", err=True) - expected = expected_fingerprint if restore and not identity_checked: - expected = _recorded_fingerprint(core, secret) + expected_fingerprint = _recorded_fingerprint(core, secret) if not restore: _show_fingerprint(core, secret, "Write it on the wallet record") name = core.initialize( secret, lambda prompt: _text(prompt, optional=True), lambda message: _print(message, err=True), - expected_fingerprint=expected, + expected_fingerprint=expected_fingerprint, account=account, timestamp=timestamp, ) @@ -493,6 +492,7 @@ def _create( if isinstance(source, (Share, Secret)) and not isinstance(source, MasterSeed): raise _UsageError(f"Enter one {_profile_rules(profile).label}, not a share or another backup type.") try: + existing_secret: MasterSeed | None = None if isinstance(source, MasterSeed): if threshold == 0 and identifier is not None and identifier != source.header.identifier: raise _UsageError( @@ -501,8 +501,6 @@ def _create( existing_secret = source elif source is not None: existing_secret = _generated_secret(source, None, identifier, core.fingerprint_seed) - else: - existing_secret = None try: expected = _recorded_fingerprint(core, existing_secret) if existing_secret is not None else None except (EOFError, KeyboardInterrupt) as error: From d113f5b0383532fe42a3d03c312b48286d0c37a7 Mon Sep 17 00:00:00 2001 From: Codex Agent Date: Wed, 30 Sep 2026 20:54:41 -0500 Subject: [PATCH 6/9] cli: Reuse raw-seed backup identifier A raw seed imported with create --existing was assigned a temporary random identifier for the no-record safety screen, then assigned a different random identifier when the new share set was created. Reuse the first identifier as the share-set identifier so the safety screen describes the backup that will actually be produced.\n\nExtend the recordless-creation regression to require the displayed, emitted, and imported identifiers to agree.\n\nRefs #30 --- src/codex32/cli.py | 2 ++ tests/test_cli.py | 15 +++++++++++++-- 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/src/codex32/cli.py b/src/codex32/cli.py index e88fa8f..a210e98 100644 --- a/src/codex32/cli.py +++ b/src/codex32/cli.py @@ -501,6 +501,8 @@ def _create( existing_secret = source elif source is not None: existing_secret = _generated_secret(source, None, identifier, core.fingerprint_seed) + if threshold and identifier is None: + identifier = existing_secret.header.identifier try: expected = _recorded_fingerprint(core, existing_secret) if existing_secret is not None else None except (EOFError, KeyboardInterrupt) as error: diff --git a/tests/test_cli.py b/tests/test_cli.py index 0557f97..2978174 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -3164,13 +3164,16 @@ def interrupt_record(*_args: object) -> bytes | None: assert core.imported is None -def test_create_existing_recordless_choice_precedes_sharing(monkeypatch: pytest.MonkeyPatch) -> None: +def test_create_existing_recordless_choice_precedes_sharing( + monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] +) -> None: cli = importlib.import_module("codex32.cli") secret = parse_codex32(VECTOR_1["secret_s"]) assert isinstance(secret, MasterSeed) core = _FakeBitcoinCore() answers = iter((secret.seed_bytes.hex(), "", "y")) events: list[str] = [] + emitted: list[Share | Secret] = [] def confirm_card(artifact: Share | Secret, confirm=None) -> None: if confirm is not None: @@ -3180,15 +3183,23 @@ def answer(_prompt: str, **_options: object) -> str: assert events == [] return next(answers) + def emit(artifact: Share | Secret, *_args: object, **_kwargs: object) -> None: + events.append("card") + emitted.append(artifact) + monkeypatch.setattr(sys, "stdin", _TTYInput()) monkeypatch.setattr(sys, "stdout", _TTYOutput()) monkeypatch.setattr(cli, "_text", answer) monkeypatch.setattr(cli, "_recorded_fingerprint", _RECORDED_FINGERPRINT) - monkeypatch.setattr(cli, "_emit", lambda *_args, **_kwargs: events.append("card")) + monkeypatch.setattr(cli, "_emit", emit) monkeypatch.setattr(cli, "_confirm_card", confirm_card) monkeypatch.setattr(cli.BitcoinCore, "connect", lambda *args: core) assert ms_main(["create", "2", "--indices", "ac", "--existing"]) == 0 assert events == ["card", "card"] + identifier = emitted[0].header.identifier + assert all(artifact.header.identifier == identifier for artifact in emitted) assert core.expected is None assert core.imported is not None and core.imported.seed_bytes == secret.seed_bytes + assert core.imported.header.identifier == identifier + assert capsys.readouterr().err.count(f"Backup identifier: {identifier.upper()}") >= 2 From 1d5b6f5445c625625178ac1f118270554fc04fc7 Mon Sep 17 00:00:00 2001 From: Codex Agent Date: Thu, 1 Oct 2026 01:35:25 -0500 Subject: [PATCH 7/9] cli: Remove unreachable input guard Backup creation rejects a noninteractive terminal before this branch, so the later stdin.isatty() rejection can never run. Removing it preserves the interactive behavior and leaves the integrated source under its strict review line budget. Refs #46 and #81. --- src/codex32/cli.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/codex32/cli.py b/src/codex32/cli.py index a210e98..dc6d3be 100644 --- a/src/codex32/cli.py +++ b/src/codex32/cli.py @@ -487,8 +487,6 @@ def _create( raise _UsageError("For thresholds 4 through 9, choose --shares or --indices.") core = _connected_core() source = _creation_source(profile) if existing else None - if not existing and not sys.stdin.isatty() and _text("", optional=True): - raise _UsageError("Use --existing when supplying a seed or secret.") if isinstance(source, (Share, Secret)) and not isinstance(source, MasterSeed): raise _UsageError(f"Enter one {_profile_rules(profile).label}, not a share or another backup type.") try: From 095484ff2e1dd510409f3bccb2b06fa1fef70006 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 1 Oct 2026 09:34:05 +0000 Subject: [PATCH 8/9] test: Raise the size budget to 5,250 lines Ben authorized raising the budget so #91 fits. The stack tip with the open fix PRs was at 5,197 of 5,200, and #91 adds 26 lines. Update the enforcing test and both places that document the number. Claude-Session: https://claude.ai/code/session_015CuLXqAvAovfoVcUmmogwa --- AGENTS.md | 2 +- docs/developer/api.md | 2 +- tests/test_cli.py | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) 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/tests/test_cli.py b/tests/test_cli.py index 2978174..fd3b9ff 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -2192,7 +2192,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( From e8b84c593ef22a64bbc3e658cd0bf37913f8fa6f Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 1 Oct 2026 09:34:12 +0000 Subject: [PATCH 9/9] wallet: Ask for the record fingerprint before the cards `ms32 wallet` and `ms32 create --existing` hid the recovered master fingerprint while confirming a correction (#57), so a wrong correction was caught only after the operator accepted it and typed the record. Ask for the record first: before the shares in `ms32 wallet` and before the seed in `ms32 create --existing`. A correction that completes the secret then says whether it matches the record, without showing the fingerprint, and the record picks between equally likely corrections. The final identity check, the retry on mismatch and the Enter path for no record work as before; without a record nothing is shown until the recordless gate. Ctrl-C at the moved prompt still says the existing cards are valid. Closes #91 Claude-Session: https://claude.ai/code/session_015CuLXqAvAovfoVcUmmogwa --- docs/user/guide.md | 23 ++--- src/codex32/_cli_input.py | 51 +++++++---- src/codex32/cli.py | 60 ++++++++----- tests/test_cli.py | 128 ++++++++++++++++++++++------ tests/test_correction_disclosure.py | 2 + 5 files changed, 189 insertions(+), 75 deletions(-) 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 fd3b9ff..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 @@ -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] + "?"