From b8748cebaaa32b3b9223bc3d7f418c946f34c764 Mon Sep 17 00:00:00 2001 From: Ben Westgate Date: Tue, 22 Sep 2026 14:40:21 -0500 Subject: [PATCH] cli: Verify recovery before wallet import --- docs/developer/api.md | 12 ++++ docs/security/invariants.md | 10 +-- docs/security/model.md | 17 ++++- docs/user/gui.md | 6 +- docs/user/guide.md | 41 +++++++---- src/codex32/_bitcoin_core.py | 66 +++++++++++++++-- src/codex32/_cli_parser.py | 5 ++ src/codex32/cli.py | 49 ++++++++++++- src/codex32_gui/wallet_setup.py | 15 ++-- tests/test_bitcoin_core.py | 68 +++++++++++++++++- tests/test_cli.py | 122 +++++++++++++++++++++++++++++--- tests/test_generic_hrp.py | 4 +- 12 files changed, 367 insertions(+), 48 deletions(-) diff --git a/docs/developer/api.md b/docs/developer/api.md index d031230..39bb67e 100644 --- a/docs/developer/api.md +++ b/docs/developer/api.md @@ -665,6 +665,18 @@ through `bitcoin-cli -stdin`, verifies the exact accepted public descriptor set, and relocks an encrypted destination after success, failure, or interruption. It never handles a passphrase. +Legacy wallet records can be upgraded before recovery is needed with: + +```text +ms32 wallet --enroll +``` + +This mode accepts no recovery material. It asks the operator to choose a loaded +established private wallet, reads its single root xpub with `gethdkeys`, derives +the same public fingerprint and recovery commitment, and displays those values +for comparison and copying into the old record. It does not select an empty +destination, unlock, import, or mutate a wallet. + For offline signing/watch-only and multisig workflows, use Bitcoin Core v32's maintained procedures. Until the final v32 release, see the versioned [offline-signing tutorial](https://github.com/bitcoin/bitcoin/blob/v32.0rc1/doc/offline-signing-tutorial.md) diff --git a/docs/security/invariants.md b/docs/security/invariants.md index c2f448b..34fd9b2 100644 --- a/docs/security/invariants.md +++ b/docs/security/invariants.md @@ -11,11 +11,11 @@ and evidence. 3. Shared creation uses a separate OS-CSPRNG call for each random initial share, gated by confirmation. Input cannot replace entropy or the original secret. 4. Wallet setup uses the original ceremony result or a validated recovered seed. - Graphical recovery derives public wallet identity first and requires the - operator to enter a separately stored SHA-256 commitment to the canonical - root xpub before any recovered key material may mutate a Bitcoin Core wallet. - The 32-bit BIP32 fingerprint is diagnostic metadata, never the authorization - value for this transition. + Graphical and command-line recovery derive public wallet identity first and + require the operator to enter a separately stored SHA-256 commitment to the + canonical root xpub before any recovered key material may mutate a Bitcoin + Core wallet. The 32-bit BIP32 fingerprint is diagnostic metadata, never the + authorization value for this transition. 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 14ef9f7..d8e4552 100644 --- a/docs/security/model.md +++ b/docs/security/model.md @@ -234,6 +234,8 @@ signing setup belong to Bitcoin Core's maintained v32 workflow. | 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. | | 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. | +| Recovery authorization | Before CLI restoration or resharing of an existing seed reaches wallet initialization, codex32 derives the public recovery identity without importing descriptors. The operator must type the separately stored 256-bit recovery commitment; a mismatch stops before wallet mutation. The BIP32 fingerprint is diagnostic only. | +| Legacy record enrollment | `ms32 wallet --enroll` accepts no recovery material and performs no wallet mutation. It reads the canonical root xpub from one operator-selected, loaded established private wallet, derives the same public fingerprint and 256-bit commitment, and displays them so an older record can be upgraded before recovery is needed. The operator compares the wallet name and fingerprint with that existing record before copying the commitment. | | Seed source | The original ceremony result or validated recovered master seed supplies root-xprv private descriptors for Core's reported chain. After import, Core v32's wallet HD-key RPCs derive the requested BIP44, BIP49, BIP84, and BIP86 account xpubs. | | Secret channel | Private descriptor JSON is sent only through the child's standard input. It is absent from arguments, ordinary output, and diagnostics. The library and the command-line programs have no passphrase channel, and raw Core errors are suppressed. | | Revalidation | Every destination property is checked again immediately before import. Every private import must succeed before public verification begins. `gethdkeys` must expose one private wallet root; `derivehdkey` must return the requested hardened account paths with one consistent fingerprint and the correct network xpub/tpub version. `getdescriptorinfo` then validates and expands the fixed public templates, and the exact eight active descriptors must match Core's accepted set. | @@ -273,10 +275,11 @@ only while more than one answers. On screen a wallet is chosen by the position o its row, never by the text of its label, and Core's text is rendered without Pango markup, so a wallet name cannot hide or impersonate another. -Restore derives the recovered seed's canonical root xpub and master fingerprint -through Bitcoin Core before destination selection. It computes the recovery +Graphical and command-line restore derive the recovered seed's canonical root +xpub and master fingerprint through Bitcoin Core before destination selection. +They compute the recovery commitment as `SHA256(domain_tag || root_xpub)`, where `domain_tag` is the -ASCII text `codex32 recovery commitment` followed by one NUL byte. It asks the +ASCII text `codex32 recovery commitment` followed by one NUL byte. They ask the operator to enter the 256-bit value from the separately stored wallet record. The expected commitment is not displayed before comparison. Only a match permits the program to list, create, unlock, or import into a destination @@ -284,6 +287,14 @@ wallet, so a mismatch can stop recovery without mutating one. The fingerprint is still displayed as a short diagnostic identifier but is not used to authorize the transition. +Legacy records that predate the commitment field are enrolled separately while +their established Core wallet is still available. `ms32 wallet --enroll` reads +the selected loaded wallet's one private HD root, converts only its xpub into the +same public recovery identity, and displays the wallet name, fingerprint, and +commitment. It never reads recovery cards, imports descriptors, unlocks a wallet, +or writes to Core. A record without that independently enrolled value does not +gain a weaker fingerprint-only restore bypass. + The program draws no entropy, opens no socket, starts no process of its own, and writes no file: no settings, no recent list, no log, and no clipboard write of recovery text. Entered recovery text is cleared when its screen is left, subject diff --git a/docs/user/gui.md b/docs/user/gui.md index 540065e..4569a20 100644 --- a/docs/user/gui.md +++ b/docs/user/gui.md @@ -149,8 +149,10 @@ When you restore, the window first asks you to type the recovery commitment from the separate wallet record. It deliberately does not show the value it expects. If the value does not match, it does not list, create, unlock, or fill a Bitcoin Core wallet. Do not substitute the shorter master fingerprint. Older wallet -records without a recovery commitment need to be updated before relying on this -pre-import check. +records without a recovery commitment need to be updated while the established +wallet is still available: load it in Bitcoin Core, run `ms32 wallet --enroll`, +compare the displayed wallet name and master fingerprint with the old record, +and then copy the displayed commitment into that record. After a successful restore, the window asks you to check the remaining wallet details against the record rather than copy them onto it. It shows no creation diff --git a/docs/user/guide.md b/docs/user/guide.md index a6059a9..65f08f0 100644 --- a/docs/user/guide.md +++ b/docs/user/guide.md @@ -168,17 +168,22 @@ wallet should be trusted until initialization completes. ### 4. Complete the record and store the cards -For graphical setup, copy the displayed backup identifier, wallet name, Bitcoin -Core version, master fingerprint, recovery commitment, derivation standards, -and account number to the wallet record. The command-line workflow does not yet -display the recovery commitment, so do not rely on a CLI-created record for -graphical recovery. Add the approximate creation / earliest-use date. Do not put -a descriptor timestamp on a recovery card; Core's public descriptor export -preserves its stored timestamps. +Copy the displayed backup identifier, wallet name, Bitcoin Core version, +master fingerprint, recovery commitment, derivation standards, and account +number to the wallet record. Add the approximate creation / earliest-use date. +Do not put a descriptor timestamp on a recovery card; Core's public descriptor +export preserves its stored timestamps. Store each card securely. For a shared backup, use different trusted places. Keep the wallet record separately from all cards. +If an older wallet record predates the recovery-commitment field, update it +while the established spending wallet is still available. Load that wallet in +Bitcoin Core and run `ms32 wallet --enroll`. codex32 reads only the wallet's +public root identity and does not read recovery cards or change the wallet. +Compare the displayed wallet name and master fingerprint with the old record; +only after they match, copy the displayed recovery commitment into the record. + ### 5. Receive and spend normally Reconnect if needed and let the normally networked Bitcoin Core node finish @@ -213,12 +218,16 @@ descriptor-transfer procedure in place of that maintained workflow. ## Recover an existing or inherited wallet An existing wallet has records and history that can identify a wrong recovery. -Restore it on the intended offline or otherwise trusted signer before comparing -its public wallet data with the separate wallet record. +The recovered wallet must match the separately stored recovery commitment before +private descriptors are imported. The four-byte BIP32 fingerprint remains a +diagnostic check, not authorization to restore. 1. Collect the required cards with matching identifiers and text lengths. 2. Find the separately stored wallet record and the original wallet - instructions. + instructions. If the record has no recovery commitment and the established + wallet still exists, stop and enroll it first with `ms32 wallet --enroll` as + described above. Do not invent a commitment from the recovery cards during + an emergency restore. 3. On Tails or another reviewed offline computer, check each card with `ms32 check`. If validation fails, recheck what you typed before assuming the paper is wrong. @@ -230,16 +239,20 @@ its public wallet data with the separate wallet record. ms32 wallet --timestamp 0 ``` -5. Select and confirm that wallet. If it is locked, follow the displayed +5. codex32 shows the recovered master fingerprint for diagnosis, then asks for + the recovery commitment from the separately stored wallet record. Type the + complete commitment. A mismatch stops before any descriptor import; do not + substitute the fingerprint for this check. +6. Select and confirm that wallet. If it is locked, follow the displayed Bitcoin-Qt Console instructions; codex32 waits and continues automatically. It imports the private descriptors, verifies the public set, and relocks an encrypted wallet. -6. If you need an online watch-only counterpart, keep the restored signer +7. If you need an online watch-only counterpart, keep the restored signer offline and follow Bitcoin Core v32's [offline-signing tutorial](https://github.com/bitcoin/bitcoin/blob/v32.0rc1/doc/offline-signing-tutorial.md) to export and restore the watch-only wallet. Let the online node synchronize, - then compare the recovered fingerprint, account, policy, addresses, balance, - and transaction history with the wallet record. + then compare the fingerprint, account, policy, addresses, balance, and + transaction history with the wallet record as secondary checks. A timestamp of zero safely scans all history and may take time; it belongs in the recovery command, not on a paper card. During an emergency recovery, move diff --git a/src/codex32/_bitcoin_core.py b/src/codex32/_bitcoin_core.py index 94c00a2..1ce32d8 100644 --- a/src/codex32/_bitcoin_core.py +++ b/src/codex32/_bitcoin_core.py @@ -33,6 +33,21 @@ class BitcoinCoreError(Exception): _RECOVERY_COMMITMENT_DOMAIN = b"codex32 recovery commitment\0" +def _recovery_commitment_text(commitment: bytes) -> str: + """Format a full recovery commitment for records and comparison.""" + if len(commitment) != 32: + raise ValueError("recovery commitments must be 32 bytes") + text = commitment.hex().upper() + return " ".join(text[start : start + 4] for start in range(0, len(text), 4)) + + +def _recovery_commitment_matches(expected: str, entered: str) -> bool: + """Compare commitment text, ignoring only whitespace and case.""" + expected_text = "".join(expected.split()).upper() + entered_text = "".join(entered.split()).upper() + return len(expected_text) == len(entered_text) == 64 and entered_text == expected_text + + @dataclass(frozen=True) class BitcoinCore: executable: str @@ -128,10 +143,8 @@ def _normalized_descriptor(self, descriptor: str) -> str: raise BitcoinCoreError("Bitcoin Core did not return the expected public descriptor.") return normalized - def recovery_identity_seed(self, seed: bytes) -> tuple[bytes, bytes]: - """Return the BIP32 fingerprint and a SHA-256 commitment to the root xpub.""" - xprv = _master_xprv_from_seed(seed, testnet=self.chain != "main") - descriptor = self._normalized_descriptor(f"pkh({xprv})") + def _recovery_identity_descriptor(self, descriptor: str) -> tuple[bytes, bytes]: + """Return recovery identity from one normalized root P2PKH descriptor.""" if not descriptor.startswith("pkh(") or ")#" not in descriptor: raise BitcoinCoreError("Bitcoin Core did not return the expected root public descriptor.") root_xpub, separator, checksum = descriptor[4:].partition(")#") @@ -159,6 +172,19 @@ def recovery_identity_seed(self, seed: bytes) -> tuple[bytes, bytes]: except ValueError as error: raise BitcoinCoreError("Bitcoin Core returned an invalid master-key script.") from error + def _recovery_identity_xpub(self, root_xpub: str) -> tuple[bytes, bytes]: + """Return recovery identity from an established wallet's public root key.""" + descriptor = self._normalized_descriptor(f"pkh({root_xpub})") + normalized_xpub = descriptor[4:].partition(")#")[0] if descriptor.startswith("pkh(") else "" + if normalized_xpub != root_xpub: + raise BitcoinCoreError("Bitcoin Core changed the established wallet root public key.") + return self._recovery_identity_descriptor(descriptor) + + def recovery_identity_seed(self, seed: bytes) -> tuple[bytes, bytes]: + """Return the BIP32 fingerprint and a SHA-256 commitment to the root xpub.""" + xprv = _master_xprv_from_seed(seed, testnet=self.chain != "main") + return self._recovery_identity_descriptor(self._normalized_descriptor(f"pkh({xprv})")) + def fingerprint_seed(self, seed: bytes) -> bytes: """Return the BIP32 master fingerprint using Bitcoin Core out of process.""" return self.recovery_identity_seed(seed)[0] @@ -175,6 +201,38 @@ def recovery_identity(self, secret: MasterSeed) -> tuple[bytes, bytes]: raise TypeError("wallet operations accept only MasterSeed") return self.recovery_identity_seed(secret.seed_bytes) + def enrollment_identity( + self, + ask: Callable[[str], str], + tell: Callable[[str], None], + ) -> tuple[str, bytes, bytes]: + """Choose an established loaded wallet and return its public recovery identity.""" + choices: list[tuple[str, bytes, bytes]] = [] + for name in sorted(self._names()): + try: + fingerprint, commitment = self._recovery_identity_xpub(self._root_xpub(name)) + except BitcoinCoreError: + continue + choices.append((name, fingerprint, commitment)) + if not choices: + raise BitcoinCoreError( + "No loaded Bitcoin Core wallet exposes one private HD root. Load the established spending wallet first." + ) + while True: + tell("Loaded Bitcoin Core wallets that can enroll a recovery commitment:") + for number, (name, _fingerprint, _commitment) in enumerate(choices, 1): + tell(f" {number}. {json.dumps(name)}") + answer = ask("Choose the established wallet number") + if not answer.isdecimal() or not 1 <= int(answer) <= len(choices): + tell("Enter one of the displayed numbers.") + continue + selected = choices[int(answer) - 1] + if ask(f"Read public recovery identity from {json.dumps(selected[0])}? [y/N]").lower() in ( + "y", + "yes", + ): + return selected + def _root_xpub(self, wallet: str) -> str: result = self._rpc("gethdkeys", wallet=wallet) if not isinstance(result, list) or len(result) != 1 or not isinstance(result[0], dict): diff --git a/src/codex32/_cli_parser.py b/src/codex32/_cli_parser.py index fe273a4..97e06dc 100644 --- a/src/codex32/_cli_parser.py +++ b/src/codex32/_cli_parser.py @@ -185,6 +185,11 @@ def parser(prog: str = "codex32", *, master_seed: bool = False) -> argparse.Argu wallet = _command(commands, "wallet", "restore a Bitcoin Core wallet") _wallet_options(wallet) + wallet.add_argument( + "--enroll", + action="store_true", + help="record a recovery commitment from an established loaded wallet without reading recovery cards", + ) xprv = _command(commands, "xprv", "export the root extended private key") xprv.add_argument("--testnet", action="store_true", help="use a testnet key") diff --git a/src/codex32/cli.py b/src/codex32/cli.py index 506ad09..65c621f 100644 --- a/src/codex32/cli.py +++ b/src/codex32/cli.py @@ -8,7 +8,12 @@ from collections.abc import Callable, Sequence from typing import Literal, NamedTuple, cast -from codex32._bitcoin_core import BitcoinCore, BitcoinCoreError +from codex32._bitcoin_core import ( + BitcoinCore, + BitcoinCoreError, + _recovery_commitment_matches, + _recovery_commitment_text, +) from codex32._cli_input import ( CorrectionDeclined, InteractiveConfirmationRequired, @@ -346,6 +351,18 @@ def _initialize_wallet( try: if confirmed: _print("Master-seed backup confirmed.\n", err=True) + fingerprint, commitment = core.recovery_identity(secret) + if not fresh: + _print( + f"Recovered master fingerprint: {fingerprint.hex().upper()}", + err=True, + ) + entered = _text("Recovery commitment from wallet record") + if not _recovery_commitment_matches(_recovery_commitment_text(commitment), entered): + raise _CommandError( + "That recovery commitment does not match this recovered wallet. " + "Bitcoin Core was not changed." + ) name = core.initialize( secret, lambda prompt: _text(prompt, optional=True), @@ -365,9 +382,10 @@ def _initialize_wallet( err=True, ) _print( - f"Master fingerprint: {core.fingerprint(secret).hex().upper()}", + f"Master fingerprint: {fingerprint.hex().upper()}", err=True, ) + _print(f"Recovery commitment: {_recovery_commitment_text(commitment)}", err=True) _print("Derivation standards: BIP44, BIP49, BIP84, and BIP86", err=True) _print(f"Account number: {account}", err=True) if fresh: @@ -601,6 +619,29 @@ def _bitcoin_core(account: int, timestamp: int | Literal["now"]) -> int: ) +def _enroll_wallet_record() -> int: + if not sys.stdin.isatty(): + raise _UsageError("Wallet-record enrollment requires an interactive terminal.") + core = _connected_core() + name, fingerprint, commitment = core.enrollment_identity( + lambda prompt: _text(prompt, optional=True), + lambda message: _print(message, err=True), + ) + _print( + "Recovery-record enrollment reads public wallet identity only; Bitcoin Core was not changed.", + err=True, + ) + _print(f"Wallet name: {json.dumps(name)}", err=True) + _print(f"Master fingerprint: {fingerprint.hex().upper()}", err=True) + _print(f"Recovery commitment: {_recovery_commitment_text(commitment)}", err=True) + _print( + "Compare the wallet name and master fingerprint with the existing wallet record. " + "If they match, copy the recovery commitment to that record before attempting recovery.", + err=True, + ) + return 0 + + def _dispatch(arguments: argparse.Namespace, context: _CliContext) -> int: command = cast(str, arguments.command) plain = bool(getattr(arguments, "plain", False)) or (command == "correct" and not sys.stdin.isatty()) @@ -658,6 +699,10 @@ def _dispatch(arguments: argparse.Namespace, context: _CliContext) -> int: _print(master_xprv(secret, testnet=bool(arguments.testnet))) return 0 if command == "wallet": + if bool(arguments.enroll): + if int(arguments.account) != 0 or cast(int | Literal["now"], arguments.timestamp) != 0: + raise _UsageError("--enroll cannot be combined with a non-default --account or --timestamp.") + return _enroll_wallet_record() return _bitcoin_core( int(arguments.account), cast(int | Literal["now"], arguments.timestamp), diff --git a/src/codex32_gui/wallet_setup.py b/src/codex32_gui/wallet_setup.py index 85f4677..2b991fd 100644 --- a/src/codex32_gui/wallet_setup.py +++ b/src/codex32_gui/wallet_setup.py @@ -24,7 +24,13 @@ from typing import Literal from codex32 import MasterSeed -from codex32._bitcoin_core import _CHAINS, BitcoinCore, BitcoinCoreError +from codex32._bitcoin_core import ( + _CHAINS, + BitcoinCore, + BitcoinCoreError, + _recovery_commitment_matches, + _recovery_commitment_text, +) __all__ = [ "UNLOCK_SECONDS", @@ -145,15 +151,12 @@ def fingerprint(core: BitcoinCore, secret: MasterSeed) -> str: def identity(core: BitcoinCore, secret: MasterSeed) -> tuple[str, str]: """Return the short fingerprint and strong public recovery commitment.""" fingerprint_bytes, commitment = core.recovery_identity(secret) - text = commitment.hex().upper() - return fingerprint_bytes.hex(), " ".join(text[start : start + 4] for start in range(0, len(text), 4)) + return fingerprint_bytes.hex(), _recovery_commitment_text(commitment) def commitment_matches(expected: str, entered: str) -> bool: """Compare a copied recovery commitment, ignoring only whitespace and case.""" - expected_text = "".join(expected.split()).upper() - entered_text = "".join(entered.split()).upper() - return len(entered_text) == 64 and entered_text == expected_text + return _recovery_commitment_matches(expected, entered) def fingerprint_provider(core: BitcoinCore) -> Callable[[bytes], bytes]: diff --git a/tests/test_bitcoin_core.py b/tests/test_bitcoin_core.py index 5831fdd..199882f 100644 --- a/tests/test_bitcoin_core.py +++ b/tests/test_bitcoin_core.py @@ -9,7 +9,12 @@ import pytest -from codex32._bitcoin_core import BitcoinCore, BitcoinCoreError +from codex32._bitcoin_core import ( + BitcoinCore, + BitcoinCoreError, + _recovery_commitment_matches, + _recovery_commitment_text, +) from codex32.bip93 import parse_codex32 from codex32.profiles.ms32 import MasterSeed from codex32.wallet import _with_checksum @@ -456,6 +461,67 @@ def rpc( (("deriveaddresses",), None), (("validateaddress", "1synthetic"), None), ] + calls.clear() + assert BitcoinCore("bitcoin-cli", "main", 320000)._recovery_identity_xpub("xpub-root") == identity + assert calls == [ + (("getdescriptorinfo",), None), + (("deriveaddresses",), None), + (("validateaddress", "1synthetic"), None), + ] + + +def test_recovery_commitment_text_has_one_shared_canonical_format() -> None: + expected = "0001 0203 0405 0607 0809 0A0B 0C0D 0E0F 1011 1213 1415 1617 1819 1A1B 1C1D 1E1F" + assert _recovery_commitment_text(bytes(range(32))) == expected + assert _recovery_commitment_matches( + expected, "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f" + ) + assert not _recovery_commitment_matches(expected, "00" * 31) + + +def test_enrollment_identity_comes_from_an_established_loaded_wallet(monkeypatch: pytest.MonkeyPatch) -> None: + client = BitcoinCore("bitcoin-cli", "main", 320000) + identity = (bytes.fromhex("3f3521a6"), bytes(range(32))) + messages: list[str] = [] + prompts: list[str] = [] + answers = iter(("1", "yes")) + + monkeypatch.setattr(BitcoinCore, "_names", lambda _self: ("blank", "legacy")) + + def root(name: str) -> str: + if name == "blank": + raise BitcoinCoreError("no root") + return _ROOT_XPUB + + monkeypatch.setattr(BitcoinCore, "_root_xpub", lambda _self, name: root(name)) + monkeypatch.setattr(BitcoinCore, "_recovery_identity_xpub", lambda _self, _root: identity) + + def ask(prompt: str) -> str: + prompts.append(prompt) + return next(answers) + + assert client.enrollment_identity(ask, messages.append) == ("legacy", *identity) + assert messages == [ + "Loaded Bitcoin Core wallets that can enroll a recovery commitment:", + ' 1. "legacy"', + ] + assert prompts == [ + "Choose the established wallet number", + 'Read public recovery identity from "legacy"? [y/N]', + ] + + +def test_enrollment_identity_fails_without_an_established_wallet(monkeypatch: pytest.MonkeyPatch) -> None: + client = BitcoinCore("bitcoin-cli", "main", 320000) + monkeypatch.setattr(BitcoinCore, "_names", lambda _self: ("blank",)) + monkeypatch.setattr( + BitcoinCore, + "_root_xpub", + lambda _self, _name: (_ for _ in ()).throw(BitcoinCoreError("no root")), + ) + + with pytest.raises(BitcoinCoreError, match="Load the established spending wallet"): + client.enrollment_identity(lambda _prompt: "", lambda _message: None) def test_fingerprint_keeps_the_short_legacy_view(monkeypatch: pytest.MonkeyPatch) -> None: diff --git a/tests/test_cli.py b/tests/test_cli.py index 5a2157e..9870572 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -39,6 +39,8 @@ from codex32.profiles.ms32 import SEED_BYTE_LENGTHS from tools._wallet_reference import fingerprint_seed +_TEST_RECOVERY_COMMITMENT = bytes(range(32)) + @dataclass(frozen=True) class _Result: @@ -103,6 +105,16 @@ def fingerprint_seed(self, seed: bytes) -> bytes: def fingerprint(self, secret: MasterSeed) -> bytes: return self.fingerprint_seed(secret.seed_bytes) + def recovery_identity(self, secret: MasterSeed) -> tuple[bytes, bytes]: + return self.fingerprint(secret), _TEST_RECOVERY_COMMITMENT + + def enrollment_identity( + self, + _ask: Callable[[str], str], + _tell: Callable[[str], None], + ) -> tuple[str, bytes, bytes]: + return "legacy-wallet", bytes.fromhex("3f3521a6"), _TEST_RECOVERY_COMMITMENT + def initialize( self, secret: MasterSeed, @@ -154,7 +166,8 @@ def _invoke_confirmed_create( terminal_output: bool = False, core: _FakeBitcoinCore | None = None, ) -> _Result: - stdin = _TTYInput("\n".join(lines) + "\n") + entered = (*lines, _TEST_RECOVERY_COMMITMENT.hex()) if "--existing" in args else lines + stdin = _TTYInput("\n".join(entered) + "\n") stdout = _CreationOutput(pretty=terminal_output) stderr = io.StringIO() @@ -181,10 +194,12 @@ def _invoke_initialized_wallet( args: list[str], *lines: str, core: _FakeBitcoinCore | None = None, + commitment: str | None = _TEST_RECOVERY_COMMITMENT.hex(), ) -> tuple[_Result, _FakeBitcoinCore]: selected = _FakeBitcoinCore() if core is None else core + entered = lines if commitment is None else (*lines, commitment) stdin, stdout, stderr = ( - _TTYInput("\n".join(lines) + "\n"), + _TTYInput("\n".join(entered) + "\n"), io.StringIO(), io.StringIO(), ) @@ -476,7 +491,15 @@ def test_tty_wallet_commands_retry_silently_after_declining_correction( original = VECTOR_1["secret_s"] damaged = original[:20] + ("q" if original[20] != "q" else "p") + original[21:] - answers = iter((damaged[3:], "n", damaged[3:], "yes")) + answers = iter( + ( + damaged[3:], + "n", + damaged[3:], + "yes", + *((_TEST_RECOVERY_COMMITMENT.hex(),) if command[0] == "wallet" else ()), + ) + ) prompts: list[str] = [] prefills: list[str] = [] @@ -500,8 +523,13 @@ def answer(prompt: str, prefill: str = "") -> str: assert "> " not in captured.err assert prompts[0] == "Enter a codex32 string:\n> MS1" assert prompts[2] == "Enter a codex32 string:\n> ms1" - assert prompts[-1] == "Does this entire string exactly match your recovery card? [y/N]: " - assert prefills == ["", "", damaged[3:], ""] + confirmation_prompt = "Does this entire string exactly match your recovery card? [y/N]: " + if command[0] == "wallet": + assert prompts[-2:] == [confirmation_prompt, "Recovery commitment from wallet record: "] + assert prefills == ["", "", damaged[3:], "", ""] + else: + assert prompts[-1] == confirmation_prompt + assert prefills == ["", "", damaged[3:], ""] assert "Rejected:" not in captured.err @@ -691,6 +719,8 @@ def test_wallet_paths_accept_a_suffix_after_frozen_ms1( def answer(prompt: str, _prefill: str = "") -> str: prompts.append(prompt) + if prompt == "Recovery commitment from wallet record: ": + return _TEST_RECOVERY_COMMITMENT.hex() return VECTOR_1["secret_s"][3:] monkeypatch.setattr(input_module.sys, "stdin", _TTYInput()) @@ -714,7 +744,13 @@ def test_wallet_recovery_recases_later_header_from_suffix( ) -> None: input_module = importlib.import_module("codex32._cli_input") prefix = "ms12name" - answers = iter((VECTOR_2["share_A"].lower(), VECTOR_2["share_C"][len(prefix) :].upper())) + answers = iter( + ( + VECTOR_2["share_A"].lower(), + VECTOR_2["share_C"][len(prefix) :].upper(), + *((_TEST_RECOVERY_COMMITMENT.hex(),) if command[0] == "wallet" else ()), + ) + ) prompts: list[str] = [] def answer(prompt: str, _prefill: str = "") -> str: @@ -1044,7 +1080,14 @@ def test_tty_recovery_accepts_secret_after_compatible_shares( ) -> None: input_module = importlib.import_module("codex32._cli_input") - answers = iter((VECTOR_3["derived_f"], VECTOR_3["share_c"], VECTOR_3["secret_s"])) + answers = iter( + ( + VECTOR_3["derived_f"], + VECTOR_3["share_c"], + VECTOR_3["secret_s"], + *((_TEST_RECOVERY_COMMITMENT.hex(),) if command[0] == "wallet" else ()), + ) + ) prompts: list[str] = [] def answer(prompt: str) -> str: @@ -1065,11 +1108,14 @@ def answer(prompt: str) -> str: first_prompt = ( "Enter a codex32 string:\n> " if command[0] == "secret" else "Enter a codex32 string:\n> MS1" ) - assert prompts == [ + expected_prompts = [ first_prompt, "Enter share 2 of 3:\n> ms13cash", "Enter share 3 of 3:\n> ms13cash", ] + if command[0] == "wallet": + expected_prompts.append("Recovery commitment from wallet record: ") + assert prompts == expected_prompts def test_tty_share_collects_secret_and_exact_basis( @@ -1512,6 +1558,8 @@ def answer(prompt: str) -> str: prompts.append(prompt) if len(prompts) == 1: return VECTOR_4["secret_s"] + if prompt.startswith("Recovery commitment from wallet record"): + return _TEST_RECOVERY_COMMITMENT.hex() if prompt.startswith("Write this share"): emitted.append(_card_text(capsys.readouterr().out)) return "" @@ -1531,6 +1579,7 @@ def answer(prompt: str) -> str: "Re-enter the share from the recovery card:\n> ", "Write this share on a new recovery card, then press Enter. ", "Re-enter the share from the recovery card:\n> ", + "Recovery commitment from wallet record: ", ] assert capsys.readouterr().err.startswith("\n") @@ -1878,6 +1927,49 @@ def test_wallet_commands_initialize_selected_master_seed_destinations() -> None: assert "Use only the intended encrypted wallet" not in private.stderr assert "\x1b[" not in private.stderr + private.stdout assert "spending wallet initialized" in private.stderr + assert "Recovery commitment: 0001 0203 0405 0607" in private.stderr + + +def test_wallet_restore_rejects_wrong_commitment_before_import() -> None: + result, core = _invoke_initialized_wallet( + ["wallet"], + VECTOR_1["secret_s"], + commitment="00" * 32, + ) + + assert result.exit_code != 0 + assert core.imported is None + assert "does not match this recovered wallet" in result.stderr + assert "Bitcoin Core was not changed" in result.stderr + assert "Recovery commitment: 0001 0203" not in result.stderr + + +def test_wallet_enroll_reads_established_public_identity_without_recovery() -> None: + core = _FakeBitcoinCore() + stdin, stdout, stderr = _TTYInput(), io.StringIO(), io.StringIO() + with ( + patch.object(sys, "stdin", stdin), + patch("codex32.cli.BitcoinCore.connect", return_value=core), + contextlib.redirect_stdout(stdout), + contextlib.redirect_stderr(stderr), + ): + status = ms_main(["wallet", "--enroll"]) + + assert status == 0 + assert core.imported is None + assert stdout.getvalue() == "" + assert "Warning: This imports private descriptors" not in stderr.getvalue() + assert 'Wallet name: "legacy-wallet"' in stderr.getvalue() + assert "Master fingerprint: 3F3521A6" in stderr.getvalue() + assert "Recovery commitment: 0001 0203 0405 0607" in stderr.getvalue() + assert "Bitcoin Core was not changed" in stderr.getvalue() + + +def test_wallet_enroll_requires_an_interactive_terminal() -> None: + result = _invoke(["wallet", "--enroll"]) + + assert result.exit_code == 2 + assert "Wallet-record enrollment requires an interactive terminal" in result.stderr def test_bitcoin_core_cli_accepts_now_timestamp() -> None: @@ -2292,7 +2384,7 @@ def test_create_existing_secret_confirms_original_before_initializing( output = _TTYOutput() damaged = secret.text[:-1] + ("q" if secret.text[-1].lower() != "q" else "p") width = len(secret.text) % 4 or 4 - answers = iter(("", damaged, secret.text[-width:].upper())) + answers = iter(("", damaged, secret.text[-width:].upper(), _TEST_RECOVERY_COMMITMENT.hex())) prefills: list[str] = [] def answer(prompt: str, **options: object) -> str: @@ -2715,7 +2807,17 @@ def test_corrected_creation_source_identity_and_acceptance_boundary(monkeypatch) SimpleNamespace(artifact=secret, search_complete=True, low_checksum_discrimination=False), ), ) - answers = iter(("ms1invalid", "n", "ms1invalid", "yes", "", secret.text)) + answers = iter( + ( + "ms1invalid", + "n", + "ms1invalid", + "yes", + "", + secret.text, + _TEST_RECOVERY_COMMITMENT.hex(), + ) + ) confirmations = [] def answer(prompt, prefill=""): diff --git a/tests/test_generic_hrp.py b/tests/test_generic_hrp.py index 341e504..293db5e 100644 --- a/tests/test_generic_hrp.py +++ b/tests/test_generic_hrp.py @@ -153,7 +153,7 @@ def test_cli_split_and_unknown_neutral_summary() -> None: ) in share_help wallet_help = _invoke(ms_main, ["wallet", "--help"])[1] assert wallet_help == ( - "usage: ms32 wallet [-h] [--account ACCOUNT] [--timestamp TIMESTAMP]\n\n" + "usage: ms32 wallet [-h] [--account ACCOUNT] [--timestamp TIMESTAMP] [--enroll]\n\n" "Restore a Bitcoin Core wallet.\n\n" "options:\n" " -h, --help show this help message and exit\n" @@ -161,6 +161,8 @@ def test_cli_split_and_unknown_neutral_summary() -> None: " --timestamp TIMESTAMP\n" " search for transactions since this Unix timestamp; use\n" " 0 for all history or now for a new wallet\n" + " --enroll record a recovery commitment from an established\n" + " loaded wallet without reading recovery cards\n" ) assert _invoke(main, ["--version"])[1].startswith("codex32 ") assert _invoke(ms_main, ["--version"])[1].startswith("ms32 ")