From 7d2c2cb8b036795f71fefcc67eb36e3db4915708 Mon Sep 17 00:00:00 2001 From: Codex Agent Date: Wed, 30 Sep 2026 17:33:22 -0500 Subject: [PATCH 1/5] wallet: Check existing seed before sharing An existing hex seed or codex32 master secret previously reached the wallet-record fingerprint check only after new recovery cards had been generated and confirmed. Check the typed record immediately after parsing the source, before any card output or ceremony. Preserve the explicit recordless path at the same early decision point, and pass the checked result through to wallet initialization so it is not prompted twice. Cover matching, mismatching, and recordless flows for both source encodings. Refs #30. --- docs/security/model.md | 2 +- docs/user/guide.md | 6 ++- src/codex32/cli.py | 69 ++++++++++++++--------------- tests/test_cli.py | 99 ++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 140 insertions(+), 36 deletions(-) diff --git a/docs/security/model.md b/docs/security/model.md index da86118..e025fb3 100644 --- a/docs/security/model.md +++ b/docs/security/model.md @@ -231,7 +231,7 @@ signing setup belong to Bitcoin Core's maintained v32 workflow. | Control | Required behavior | |---|---| | Preflight | Before entropy or recovery input, explicit chain arguments probe the five standard local networks for Bitcoin Core 32 or newer. One response is selected automatically; multiple responses require operator selection. | -| Recovery identity | `ms32 wallet` and `ms32 create --existing` authenticate a recovered seed before any wallet is listed. Core derives the recovered fingerprint statelessly, and a mismatch raises `FingerprintMismatch` before any wallet RPC. The restore prompt does not show the recovered value, so the operator compares by typing the fingerprint from the wallet record. Without a record, the operator is shown the recovered fingerprint, whether the backup identifier was derived from the seed (the codex32 fingerprint rule, Bails' RIPEMD-160 rule, or its mid-2023 alpha's SHA-256 rule), and a warning, and then chooses. If RIPEMD-160 is unavailable, the standard Bails identifier check is reported as inconclusive rather than a mismatch; the Bails-alpha SHA-256 rule remains checkable. Fresh `ms32 create` has no pre-existing wallet to authenticate: it shows the newly created seed's fingerprint and requires the operator to acknowledge recording it. These checks catch mistakes such as wrong or mixed cards; anyone able to replace a threshold of cards could already read them. | +| Recovery identity | `ms32 wallet` and `ms32 create --existing` authenticate a recovered seed before any wallet is listed. For `create --existing`, the wallet-record decision also precedes generation or display of any new card. Core derives the recovered fingerprint statelessly, and a mismatch raises `FingerprintMismatch` before any wallet RPC. The restore prompt does not show the recovered value, so the operator compares by typing the fingerprint from the wallet record. Without a record, the operator is shown the recovered fingerprint, whether the backup identifier was derived from the seed (the codex32 fingerprint rule, Bails' RIPEMD-160 rule, or its mid-2023 alpha's SHA-256 rule), and a warning, and then chooses. If RIPEMD-160 is unavailable, the standard Bails identifier check is reported as inconclusive rather than a mismatch; the Bails-alpha SHA-256 rule remains checkable. Fresh `ms32 create` has no pre-existing wallet to authenticate: it shows the newly created seed's fingerprint and requires the operator to acknowledge recording it. These checks catch mistakes such as wrong or mixed cards; anyone able to replace a threshold of cards could already read them. | | Process boundary | codex32 invokes the reviewed `bitcoin-cli` from `PATH` as a child without a shell, direct RPC socket, wallet database, or wallet-creation operation. Every call uses loopback and the selected chain. | | Destination | Only an empty descriptor wallet with private keys enabled, no external signer, transactions, descriptors, keypool entries, or active scan is eligible. One eligible wallet is offered directly; multiple wallets are selected by number. New wallets are detected by polling, and rejection returns to every eligible wallet. The escaped name is confirmed exactly. | | Seed source | The original ceremony result or validated recovered master seed supplies a root xprv for Core's reported chain. Core v32 creates BIP44, BIP49, BIP84, and BIP86 account-0 descriptors from that key. | diff --git a/docs/user/guide.md b/docs/user/guide.md index 332d77a..422ec99 100644 --- a/docs/user/guide.md +++ b/docs/user/guide.md @@ -123,7 +123,11 @@ Already have a complete codex32 `ms` secret? Run `ms32 create --existing` to write and confirm its recovery card and initialize a Bitcoin Core wallet. The existing secret is preserved unchanged. To split it into three cards requiring any two, use `ms32 create 2 --existing` instead. Enter the secret -only when prompted. Bitcoin Core also scans for prior transactions. +only when prompted. Immediately afterward, type the master fingerprint from +the separate wallet record; a mismatch must be resolved before any new card +is shown. If you have no record, the explicit recordless-restore choice and +visual fingerprint check happen at this same point. Bitcoin Core also scans +for prior transactions. ### 3. Make a Bitcoin Core wallet diff --git a/src/codex32/cli.py b/src/codex32/cli.py index 9ceae95..95791d5 100644 --- a/src/codex32/cli.py +++ b/src/codex32/cli.py @@ -394,12 +394,16 @@ def _initialize_wallet( fresh: bool = True, restore: bool = False, confirmed: bool = True, + identity_checked: bool = False, + expected_fingerprint: bytes | None = None, ) -> int: assert isinstance(secret, MasterSeed) try: if confirmed: _print("Master-seed backup confirmed.\n", err=True) - expected = _recorded_fingerprint(core, secret) if restore else None + expected = expected_fingerprint + if restore and not identity_checked: + expected = _recorded_fingerprint(core, secret) if not restore: _show_fingerprint(core, secret, "Write it on the wallet record") name = core.initialize( @@ -489,37 +493,39 @@ def _create( if isinstance(source, (Share, Secret)) and not isinstance(source, MasterSeed): raise _UsageError(f"Enter one {_profile_rules(profile).label}, not a share or another backup type.") try: - if threshold == 0: - if isinstance(source, MasterSeed): - if identifier is not None and identifier != source.header.identifier: - raise _UsageError( - "To change the existing secret's identifier, choose a sharing threshold from 2 through 9." - ) - secret = source - else: - secret = _generated_secret(source, byte_length, identifier, core.fingerprint_seed) - _emit(secret, False, fingerprint=None if existing else core.fingerprint) - if sys.stdin.isatty(): - _confirm_card(secret) - return ( - _initialize_wallet( - core, secret, timestamp=0 if existing else "now", fresh=not existing, restore=existing + if isinstance(source, MasterSeed): + if threshold == 0 and identifier is not None and identifier != source.header.identifier: + raise _UsageError( + "To change the existing secret's identifier, choose a sharing threshold from 2 through 9." ) - if core is not None - else 0 + existing_secret = source + elif source is not None: + existing_secret = _generated_secret(source, None, identifier, core.fingerprint_seed) + else: + existing_secret = None + expected = _recorded_fingerprint(core, existing_secret) if existing_secret is not None else None + + def finish_wallet(seed: MasterSeed) -> int: + return _initialize_wallet( + core, + seed, + timestamp=0 if existing else "now", + fresh=not existing, + restore=existing, + identity_checked=existing, + expected_fingerprint=expected, ) - if isinstance(source, MasterSeed): - ceremony = CreationCeremony.from_secret( - source, - threshold=threshold, - identifier=identifier, - share_count=shares, - indices=indices, + + if threshold == 0: + secret = existing_secret or _generated_secret( + None, byte_length, identifier, core.fingerprint_seed ) - elif source is not None: - source_secret = _generated_secret(source, None, identifier, core.fingerprint_seed) + _emit(secret, False, fingerprint=None if existing else core.fingerprint) + _confirm_card(secret) + return finish_wallet(secret) + if existing_secret is not None: ceremony = CreationCeremony.from_secret( - source_secret, + existing_secret, threshold=threshold, identifier=identifier, share_count=shares, @@ -545,12 +551,7 @@ def _create( _print(f"Recovery card {position + 1} of {output_count} confirmed.", err=True) finished = ceremony.finish() assert isinstance(finished, MasterSeed) - if core is not None: - return _initialize_wallet( - core, finished, timestamp=0 if existing else "now", fresh=not existing, restore=existing - ) - _print("\nEvery recovery card was confirmed from its re-entered text.", err=True) - return 0 + return finish_wallet(finished) def _correct( diff --git a/tests/test_cli.py b/tests/test_cli.py index 82f3cfe..2b52fe4 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -3062,3 +3062,102 @@ def emit(_artifact, _plain, **kwargs): assert emitted_fingerprints and all(fingerprint is None for fingerprint in emitted_fingerprints) assert checked == [secret.seed_bytes] assert core.expected == core.fingerprint(secret) + + +@pytest.mark.parametrize("encoding", ("hex", "codex32")) +@pytest.mark.parametrize("shared", (False, True)) +def test_create_existing_checks_record_before_card_output( + monkeypatch: pytest.MonkeyPatch, encoding: str, shared: bool +) -> None: + cli = importlib.import_module("codex32.cli") + secret = parse_codex32(VECTOR_1["secret_s"]) + assert isinstance(secret, MasterSeed) + core = _FakeBitcoinCore() + source = secret.seed_bytes.hex() if encoding == "hex" else secret.text + answers = iter((source, core.fingerprint(secret).hex().upper())) + events: list[str] = [] + + def check_record(selected: _FakeBitcoinCore, supplied: MasterSeed) -> bytes | None: + assert events == [] + checked = _RECORDED_FINGERPRINT(selected, supplied) + events.append("record") + return checked + + def confirm_card( + artifact: Share | Secret, + confirm: Callable[[str], ConfirmationResult] | None = None, + ) -> None: + if confirm is not None: + assert confirm(artifact.text).accepted + + monkeypatch.setattr(sys, "stdin", _TTYInput()) + monkeypatch.setattr(sys, "stdout", _TTYOutput()) + monkeypatch.setattr(cli, "_text", lambda _prompt, **_options: next(answers)) + monkeypatch.setattr(cli, "_recorded_fingerprint", check_record) + monkeypatch.setattr(cli, "_emit", lambda *_args, **_kwargs: events.append("card")) + monkeypatch.setattr(cli, "_confirm_card", confirm_card) + monkeypatch.setattr(cli.BitcoinCore, "connect", lambda *args: core) + + args = ["create", "2", "--indices", "ac", "--existing"] if shared else ["create", "--existing"] + assert ms_main(args) == 0 + assert events == (["record", "card", "card"] if shared else ["record", "card"]) + assert core.expected == core.fingerprint(secret) + assert core.imported is not None and core.imported.seed_bytes == secret.seed_bytes + + +@pytest.mark.parametrize("encoding", ("hex", "codex32")) +def test_create_existing_rejects_wrong_record_before_sharing( + monkeypatch: pytest.MonkeyPatch, encoding: str +) -> None: + cli = importlib.import_module("codex32.cli") + secret = parse_codex32(VECTOR_1["secret_s"]) + assert isinstance(secret, MasterSeed) + core = _FakeBitcoinCore() + source = secret.seed_bytes.hex() if encoding == "hex" else secret.text + right = core.fingerprint(secret) + wrong = bytes([right[0] ^ 1]) + right[1:] + answers = iter((source, wrong.hex(), "", "n")) + + monkeypatch.setattr(sys, "stdin", _TTYInput()) + monkeypatch.setattr(sys, "stdout", _TTYOutput()) + monkeypatch.setattr(cli, "_text", lambda _prompt, **_options: next(answers)) + monkeypatch.setattr(cli, "_recorded_fingerprint", _RECORDED_FINGERPRINT) + monkeypatch.setattr(cli.BitcoinCore, "connect", lambda *args: core) + with ( + patch("codex32.cli.CreationCeremony.from_secret") as split, + patch("codex32.cli._emit") as emit, + ): + assert ms_main(["create", "2", "--indices", "ac", "--existing"]) == 130 + split.assert_not_called() + emit.assert_not_called() + assert core.imported is None + + +def test_create_existing_recordless_choice_precedes_sharing(monkeypatch: pytest.MonkeyPatch) -> None: + cli = importlib.import_module("codex32.cli") + secret = parse_codex32(VECTOR_1["secret_s"]) + assert isinstance(secret, MasterSeed) + core = _FakeBitcoinCore() + answers = iter((secret.seed_bytes.hex(), "", "y")) + events: list[str] = [] + + def confirm_card(artifact: Share | Secret, confirm=None) -> None: + if confirm is not None: + assert confirm(artifact.text).accepted + + def answer(_prompt: str, **_options: object) -> str: + assert events == [] + return next(answers) + + monkeypatch.setattr(sys, "stdin", _TTYInput()) + monkeypatch.setattr(sys, "stdout", _TTYOutput()) + monkeypatch.setattr(cli, "_text", answer) + monkeypatch.setattr(cli, "_recorded_fingerprint", _RECORDED_FINGERPRINT) + monkeypatch.setattr(cli, "_emit", lambda *_args, **_kwargs: events.append("card")) + monkeypatch.setattr(cli, "_confirm_card", confirm_card) + monkeypatch.setattr(cli.BitcoinCore, "connect", lambda *args: core) + + assert ms_main(["create", "2", "--indices", "ac", "--existing"]) == 0 + assert events == ["card", "card"] + assert core.expected is None + assert core.imported is not None and core.imported.seed_bytes == secret.seed_bytes From 76f324d7abf31a48f9057cf559c1cfe2c0bad1f0 Mon Sep 17 00:00:00 2001 From: Ben Westgate Date: Wed, 30 Sep 2026 18:10:03 -0500 Subject: [PATCH 2/5] wallet: Preserve existing cards on interrupt Translate Ctrl-C or EOF at the early wallet-record gate for ms32 create --existing into the existing wallet-setup interruption path. This keeps an operator from being told to invalidate a pre-existing recovery card before any new share ceremony has started. Add a focused regression proving the interruption occurs before share creation or output and preserves the valid-backup message. --- src/codex32/cli.py | 5 ++++- tests/test_cli.py | 31 +++++++++++++++++++++++++++++++ 2 files changed, 35 insertions(+), 1 deletion(-) diff --git a/src/codex32/cli.py b/src/codex32/cli.py index 95791d5..84c829f 100644 --- a/src/codex32/cli.py +++ b/src/codex32/cli.py @@ -503,7 +503,10 @@ def _create( existing_secret = _generated_secret(source, None, identifier, core.fingerprint_seed) else: existing_secret = None - expected = _recorded_fingerprint(core, existing_secret) if existing_secret is not None else None + try: + expected = _recorded_fingerprint(core, existing_secret) if existing_secret is not None else None + except (EOFError, KeyboardInterrupt) as error: + raise _WalletSetupInterrupted from error def finish_wallet(seed: MasterSeed) -> int: return _initialize_wallet( diff --git a/tests/test_cli.py b/tests/test_cli.py index 2b52fe4..0557f97 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -3133,6 +3133,37 @@ def test_create_existing_rejects_wrong_record_before_sharing( assert core.imported is None +def test_create_existing_record_gate_interruption_keeps_existing_backup_valid( + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], +) -> None: + cli = importlib.import_module("codex32.cli") + secret = parse_codex32(VECTOR_1["secret_s"]) + assert isinstance(secret, MasterSeed) + core = _FakeBitcoinCore() + + def interrupt_record(*_args: object) -> bytes | None: + raise KeyboardInterrupt + + monkeypatch.setattr(sys, "stdin", _TTYInput()) + monkeypatch.setattr(sys, "stdout", _TTYOutput()) + monkeypatch.setattr(cli, "_creation_source", lambda _profile: secret) + monkeypatch.setattr(cli, "_recorded_fingerprint", interrupt_record) + monkeypatch.setattr(cli.BitcoinCore, "connect", lambda *args: core) + with ( + patch("codex32.cli.CreationCeremony.from_secret") as split, + patch("codex32.cli._emit") as emit, + ): + assert ms_main(["create", "2", "--indices", "ac", "--existing"]) == 130 + + split.assert_not_called() + emit.assert_not_called() + message = capsys.readouterr().err + assert "recovery cards are valid" in message + assert "Mark every card" not in message + assert core.imported is None + + def test_create_existing_recordless_choice_precedes_sharing(monkeypatch: pytest.MonkeyPatch) -> None: cli = importlib.import_module("codex32.cli") secret = parse_codex32(VECTOR_1["secret_s"]) From 48d204bee1aa6c15db45bbb848503e6fec942d48 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 30 Sep 2026 23:56:04 +0000 Subject: [PATCH 3/5] cli: Trim two lines from the early record gate Reassign the expected_fingerprint argument instead of copying it into a local, and give existing_secret its None default before the source checks instead of in an else branch. Behavior is unchanged. The installed package drops from 5161 to 5159 logical review lines, which keeps the integrated #7/#42/#57/#46/#80/#81 tip under the <5200 budget. Security: the record gate still runs before any card is generated or shown, and interrupts at that gate still raise _WalletSetupInterrupted. Validation: ruff check, ruff format --check, mypy src/codex32, and pytest (918 passed, with and without -O). Refs #81, #38. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_018az69UX4773mYohXAtE8kD --- src/codex32/cli.py | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/src/codex32/cli.py b/src/codex32/cli.py index 84c829f..e88fa8f 100644 --- a/src/codex32/cli.py +++ b/src/codex32/cli.py @@ -401,16 +401,15 @@ def _initialize_wallet( try: if confirmed: _print("Master-seed backup confirmed.\n", err=True) - expected = expected_fingerprint if restore and not identity_checked: - expected = _recorded_fingerprint(core, secret) + expected_fingerprint = _recorded_fingerprint(core, secret) if not restore: _show_fingerprint(core, secret, "Write it on the wallet record") name = core.initialize( secret, lambda prompt: _text(prompt, optional=True), lambda message: _print(message, err=True), - expected_fingerprint=expected, + expected_fingerprint=expected_fingerprint, account=account, timestamp=timestamp, ) @@ -493,6 +492,7 @@ def _create( if isinstance(source, (Share, Secret)) and not isinstance(source, MasterSeed): raise _UsageError(f"Enter one {_profile_rules(profile).label}, not a share or another backup type.") try: + existing_secret: MasterSeed | None = None if isinstance(source, MasterSeed): if threshold == 0 and identifier is not None and identifier != source.header.identifier: raise _UsageError( @@ -501,8 +501,6 @@ def _create( existing_secret = source elif source is not None: existing_secret = _generated_secret(source, None, identifier, core.fingerprint_seed) - else: - existing_secret = None try: expected = _recorded_fingerprint(core, existing_secret) if existing_secret is not None else None except (EOFError, KeyboardInterrupt) as error: From a42b6ce7df3b249ee6410265744e1c519c7fa5a7 Mon Sep 17 00:00:00 2001 From: Codex Agent Date: Wed, 30 Sep 2026 20:54:41 -0500 Subject: [PATCH 4/5] cli: Reuse raw-seed backup identifier A raw seed imported with create --existing was assigned a temporary random identifier for the no-record safety screen, then assigned a different random identifier when the new share set was created. Reuse the first identifier as the share-set identifier so the safety screen describes the backup that will actually be produced.\n\nExtend the recordless-creation regression to require the displayed, emitted, and imported identifiers to agree.\n\nRefs #30 --- src/codex32/cli.py | 2 ++ tests/test_cli.py | 15 +++++++++++++-- 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/src/codex32/cli.py b/src/codex32/cli.py index e88fa8f..a210e98 100644 --- a/src/codex32/cli.py +++ b/src/codex32/cli.py @@ -501,6 +501,8 @@ def _create( existing_secret = source elif source is not None: existing_secret = _generated_secret(source, None, identifier, core.fingerprint_seed) + if threshold and identifier is None: + identifier = existing_secret.header.identifier try: expected = _recorded_fingerprint(core, existing_secret) if existing_secret is not None else None except (EOFError, KeyboardInterrupt) as error: diff --git a/tests/test_cli.py b/tests/test_cli.py index 0557f97..2978174 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -3164,13 +3164,16 @@ def interrupt_record(*_args: object) -> bytes | None: assert core.imported is None -def test_create_existing_recordless_choice_precedes_sharing(monkeypatch: pytest.MonkeyPatch) -> None: +def test_create_existing_recordless_choice_precedes_sharing( + monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] +) -> None: cli = importlib.import_module("codex32.cli") secret = parse_codex32(VECTOR_1["secret_s"]) assert isinstance(secret, MasterSeed) core = _FakeBitcoinCore() answers = iter((secret.seed_bytes.hex(), "", "y")) events: list[str] = [] + emitted: list[Share | Secret] = [] def confirm_card(artifact: Share | Secret, confirm=None) -> None: if confirm is not None: @@ -3180,15 +3183,23 @@ def answer(_prompt: str, **_options: object) -> str: assert events == [] return next(answers) + def emit(artifact: Share | Secret, *_args: object, **_kwargs: object) -> None: + events.append("card") + emitted.append(artifact) + monkeypatch.setattr(sys, "stdin", _TTYInput()) monkeypatch.setattr(sys, "stdout", _TTYOutput()) monkeypatch.setattr(cli, "_text", answer) monkeypatch.setattr(cli, "_recorded_fingerprint", _RECORDED_FINGERPRINT) - monkeypatch.setattr(cli, "_emit", lambda *_args, **_kwargs: events.append("card")) + monkeypatch.setattr(cli, "_emit", emit) monkeypatch.setattr(cli, "_confirm_card", confirm_card) monkeypatch.setattr(cli.BitcoinCore, "connect", lambda *args: core) assert ms_main(["create", "2", "--indices", "ac", "--existing"]) == 0 assert events == ["card", "card"] + identifier = emitted[0].header.identifier + assert all(artifact.header.identifier == identifier for artifact in emitted) assert core.expected is None assert core.imported is not None and core.imported.seed_bytes == secret.seed_bytes + assert core.imported.header.identifier == identifier + assert capsys.readouterr().err.count(f"Backup identifier: {identifier.upper()}") >= 2 From 9f88b2195fbb3e36e1d8ddf808fb97cc028923ad Mon Sep 17 00:00:00 2001 From: Codex Agent Date: Thu, 1 Oct 2026 01:35:25 -0500 Subject: [PATCH 5/5] 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: