diff --git a/docs/developer/api.md b/docs/developer/api.md index 50f3250..474a24f 100644 --- a/docs/developer/api.md +++ b/docs/developer/api.md @@ -202,6 +202,8 @@ requires a complete explicit `ms1` string; it never infers or corrects a missing HRP or separator. No entropy is drawn for this path; raw hexadecimal seeds retain the generation path. Existing imports use timestamp zero to include prior history. Changing a supplied secret's identifier requires a sharing threshold. +Existing-seed creation uses the same recorded-fingerprint or explicit no-record +confirmation as wallet restoration before import, including after re-sharing. Shared creation uses an explicit threshold or full backup header. Without an explicit share count or indices, thresholds 2 and 3 produce the reviewed 2-of-3 and 3-of-5 @@ -640,7 +642,14 @@ fingerprint consistency, and network xpub/tpub versions, constructs only the fixed descriptor templates, and asks `getdescriptorinfo` to validate and expand their external/internal branches. The adapter then compares the exact eight active public descriptors against `listdescriptors`. It relocks wallets Core -reports as encrypted. Master-fingerprint display is likewise delegated to Core: +reports as encrypted. Before any of this, `initialize` calls `verify_identity` +with `expected_fingerprint`. Restore callers normally supply bytes typed from +the wallet record (read with `parse_fingerprint`); `None` means either a fresh +creation, where there is no pre-existing wallet identity to authenticate, or +the operator's explicit choice to restore without a record after seeing the +fingerprint and `identifier_origin`. A mismatch raises `FingerprintMismatch` +before any wallet RPC. +Master-fingerprint display is likewise delegated to Core: a stateless root P2PKH descriptor is normalized, `deriveaddresses` derives its address, and `validateaddress` returns the script hash whose first four bytes are the BIP32 fingerprint. diff --git a/docs/security/invariants.md b/docs/security/invariants.md index 83c3bfe..ffd83ee 100644 --- a/docs/security/invariants.md +++ b/docs/security/invariants.md @@ -11,6 +11,13 @@ 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. + 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 creation ceremonies, in `ms32 create` or the GUI, 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 ad61ae2..c14f9fd 100644 --- a/docs/security/model.md +++ b/docs/security/model.md @@ -45,8 +45,9 @@ The operator must: balances or history; - protect recovery cards and store shared cards in different trusted places; - confirm every newly recorded secret or share; -- keep wallet records separate from shares and compare recovered fingerprints, - addresses, account, policy, and history with those records; +- keep wallet records separate from shares, type the master fingerprint from + the record before a restore import, and compare addresses, account, policy, + and history with those records; - compare every correction suggestion with the original codex32 string and stop when recovered information and wallet records disagree; and - never put recovery text in command arguments or transfer a master seed, @@ -227,6 +228,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 | Restore authenticates 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 creation (`ms32 create` or GUI setup) does not authenticate against a pre-existing wallet: 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 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. | @@ -268,6 +270,12 @@ 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. +The window uses the same recovery-identity gate on restore. Its restore page +asks for the fingerprint without showing it, while fresh setup only shows the +new fingerprint and requires **I wrote it down** because there is no pre-existing +wallet identity to authenticate. **I have no wallet record** shows the recovered +fingerprint, the identifier result, and the warning before the operator chooses. + 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 3566a39..019ebb4 100644 --- a/docs/user/gui.md +++ b/docs/user/gui.md @@ -67,7 +67,12 @@ the paper with the original off the screen. That catches a slip of the pen now rather than years from now. If a group does not match, the window says which one; correct that group and try again, as many times as you like. -When every card is confirmed, choose the Bitcoin Core wallet that will hold the +When every card is confirmed, the window shows the master fingerprint. Write it +on your [wallet record](wallet-verification-record.html), then press **I wrote it +down**. This is a new wallet ceremony, so there is no pre-existing fingerprint +or descriptor to authenticate against. + +Next, choose the Bitcoin Core wallet that will hold the keys. Only empty wallets are offered, so no wallet you already use can be overwritten. If you have none, the window can ask Bitcoin Core to create one: give it a name and a passphrase, and codex32 fills it in and locks it again. @@ -142,10 +147,16 @@ wallet**, which would make a different backup. If the wallet was part-filled before it failed, it is no longer empty, so it will not be offered again: create another one, or ask Bitcoin Core for a fresh blank wallet. -When you restore, the window asks you to **check** the wallet details against -your record rather than copy them onto it. That comparison — the master -fingerprint above all — is the only thing that proves the cards you just typed -belong to that wallet. It shows no creation date on that screen, because the +When you restore, the window first asks you to type the master fingerprint from +your wallet record. If it does not match, nothing is written to Bitcoin Core: +check what you typed, and if it still does not match, these cards are not that +wallet. If you have no record, **I have no wallet record** shows the master +fingerprint and whether the backup identifier was made from the recovered seed, +explains what that can and cannot prove, and restores only if you still choose +to. Check the balance and history before you send money to that wallet. + +After the restore, **check** the remaining wallet details against your record +rather than copy them onto it. It shows no creation date on that screen, because the real one is already on your record and today's would replace it. The window always uses account 0, which is what it writes onto your wallet diff --git a/docs/user/guide.md b/docs/user/guide.md index 1150c66..0ccdc21 100644 --- a/docs/user/guide.md +++ b/docs/user/guide.md @@ -168,9 +168,11 @@ wallet should be trusted until initialization completes. ### 4. Complete the record and store the cards -Copy the displayed backup identifier, wallet name, Bitcoin Core version, -master fingerprint, derivation standards, and account number to the wallet -record. Add the approximate +Before the wallet is filled, write the displayed master fingerprint on the +wallet record and confirm that you wrote it down. Creation is not a restore, so +there is no pre-existing fingerprint or descriptor to authenticate here. Then copy the displayed +backup identifier, wallet name, Bitcoin Core version, 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. @@ -228,16 +230,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. 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. +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 account, policy, addresses, balance, and transaction + history with the wallet record. 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 ffa1c12..4bfbed0 100644 --- a/src/codex32/_bitcoin_core.py +++ b/src/codex32/_bitcoin_core.py @@ -1,8 +1,10 @@ from __future__ import annotations +import hashlib import json import re import shutil +import string import subprocess from collections.abc import Callable from dataclasses import dataclass @@ -10,6 +12,8 @@ from typing import Literal from codex32._bip32 import _master_xprv_from_seed +from codex32.bech32 import _u5_to_chars, convertbits +from codex32.generation import _fingerprint_identifier from codex32.profiles.ms32 import MasterSeed from codex32.wallet import _descriptor_records, core_descriptors @@ -18,6 +22,10 @@ class BitcoinCoreError(Exception): pass +class FingerprintMismatch(BitcoinCoreError): + """The recovered seed is not the wallet the operator's record describes.""" + + _CHAINS = ( ("main", "mainnet"), ("test", "testnet3"), @@ -31,6 +39,54 @@ class BitcoinCoreError(Exception): _PURPOSES = (44, 49, 84, 86) +def parse_fingerprint(text: str) -> bytes: + """Read a master fingerprint as written on a wallet record: 8 hex digits, any case or spacing.""" + compact = "".join(text.split()) + if len(compact) != 8 or not all(character in string.hexdigits for character in compact): + raise ValueError("A master fingerprint is 8 characters, each 0-9 or A-F.") + return bytes.fromhex(compact) + + +NO_RECORD_WARNING = ( + "Without the wallet record, nothing can prove these cards are the wallet you expect. Compare the " + "fingerprint with any other copy, such as another wallet app, a hardware wallet or a descriptor backup. " + "After restoring, let Bitcoin Core finish scanning and check that the balance, past payments and " + "addresses are ones you recognise before sending money here. Replaced cards can come with a history " + "too: if you do not know what this wallet should hold, have someone you trust check it. Once you are " + "sure, write the fingerprint on a new wallet record." +) + + +def identifier_note(origin: str | None) -> str: + """Say what `identifier_origin` found, for an operator restoring without a record.""" + if origin is None: + return ( + "The backup identifier was not made from this seed. That can be normal for codex32 backups " + "made from split shares, supplied seed bytes or an explicit identifier. Bails made every " + "identifier from its seed, so for a Bails backup these are the wrong or mixed-up cards." + ) + return ( + f"The backup identifier matches this seed ({origin} rule). That rules out most mixed-up cards, " + "but not cards replaced on purpose." + ) + + +def identifier_origin(secret: MasterSeed, fingerprint: bytes) -> str | None: + """Check codex32's fingerprint or Bails' three-character seed-digest identifier.""" + identifier = secret.header.identifier + if identifier == _fingerprint_identifier(fingerprint): + return "codex32" + for name, digest in (("Bails", "ripemd160"), ("Bails alpha", "sha256")): + try: + hashed = hashlib.new(digest, secret.seed_bytes).digest() + except ValueError: + continue + derived = convertbits(hashed, 8, 5, pad=True) + if identifier[:3] == _u5_to_chars(tuple(derived[:3])): + return name + return None + + @dataclass(frozen=True) class BitcoinCore: executable: str @@ -153,6 +209,19 @@ def fingerprint(self, secret: MasterSeed) -> bytes: raise TypeError("wallet operations accept only MasterSeed") return self.fingerprint_seed(secret.seed_bytes) + def verify_identity(self, secret: MasterSeed, expected_fingerprint: bytes | None) -> None: + """Refuse a recovered seed that is not the recorded wallet, before any wallet is touched. + + `None` is the operator's explicit choice to restore without a record; nothing is checked. + """ + if expected_fingerprint is None: + return + if self.fingerprint(secret) != expected_fingerprint: + raise FingerprintMismatch( + "The recovered master fingerprint does not match the one from the wallet record. " + "Bitcoin Core was not changed." + ) + 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): @@ -299,9 +368,12 @@ def initialize( ask: Callable[[str], str], tell: Callable[[str], None], *, + expected_fingerprint: bytes | None, account: int = 0, timestamp: int | Literal["now"] = "now", ) -> str: + """Optionally check recovery identity, then import into one empty wallet the operator chooses.""" + self.verify_identity(secret, expected_fingerprint) while True: name = self._select(ask, tell) state = self._target(name) diff --git a/src/codex32/cli.py b/src/codex32/cli.py index 506ad09..ac2149b 100644 --- a/src/codex32/cli.py +++ b/src/codex32/cli.py @@ -8,7 +8,15 @@ from collections.abc import Callable, Sequence from typing import Literal, NamedTuple, cast -from codex32._bitcoin_core import BitcoinCore, BitcoinCoreError +from codex32._bitcoin_core import ( + NO_RECORD_WARNING, + BitcoinCore, + BitcoinCoreError, + FingerprintMismatch, + identifier_note, + identifier_origin, + parse_fingerprint, +) from codex32._cli_input import ( CorrectionDeclined, InteractiveConfirmationRequired, @@ -333,6 +341,44 @@ def _generated_secret( ) +def _show_fingerprint(core: BitcoinCore, secret: MasterSeed, action: str) -> None: + _print(f"\nMaster fingerprint: {core.fingerprint(secret).hex().upper()}", err=True) + _text(f"{action}, then press Enter", optional=True, prompt_end=". ") + if sys.stderr.isatty(): + _print("\x1b[3J\x1b[2J\x1b[H", err=True) + + +def _without_record(core: BitcoinCore, secret: MasterSeed) -> bool: + fingerprint = core.fingerprint(secret) + _print(f"\nMaster fingerprint: {fingerprint.hex().upper()}", err=True) + _print(f"Backup identifier: {secret.header.identifier.upper()}", err=True) + _print(identifier_note(identifier_origin(secret, fingerprint)), err=True) + _print(NO_RECORD_WARNING, err=True) + 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 + continue + try: + expected = parse_fingerprint(text) + except ValueError as error: + _print(str(error), err=True) + continue + try: + core.verify_identity(secret, expected) + except FingerprintMismatch as error: + _print(str(error), err=True) + continue + return expected + + def _initialize_wallet( core: BitcoinCore, secret: MasterSeed, @@ -340,16 +386,23 @@ def _initialize_wallet( account: int = 0, timestamp: int | Literal["now"] = "now", fresh: bool = True, + restore: bool = False, confirmed: bool = True, ) -> int: assert isinstance(secret, MasterSeed) try: if confirmed: _print("Master-seed backup confirmed.\n", err=True) + if restore: + expected = _recorded_fingerprint(core, secret) + else: + _show_fingerprint(core, secret, "Write it on the wallet record") + expected = None name = core.initialize( secret, lambda prompt: _text(prompt, optional=True), lambda message: _print(message, err=True), + expected_fingerprint=expected, account=account, timestamp=timestamp, ) @@ -447,7 +500,9 @@ def _create( if sys.stdin.isatty(): _confirm_card(secret) return ( - _initialize_wallet(core, secret, timestamp=0 if existing else "now", fresh=not existing) + _initialize_wallet( + core, secret, timestamp=0 if existing else "now", fresh=not existing, restore=existing + ) if core is not None else 0 ) @@ -489,7 +544,9 @@ def _create( 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) + 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 @@ -597,6 +654,7 @@ def _bitcoin_core(account: int, timestamp: int | Literal["now"]) -> int: account=account, timestamp=timestamp, fresh=False, + restore=True, confirmed=False, ) diff --git a/src/codex32_gui/pages.py b/src/codex32_gui/pages.py index b529356..67c2c2e 100644 --- a/src/codex32_gui/pages.py +++ b/src/codex32_gui/pages.py @@ -647,7 +647,7 @@ def _unshared_page(view: Adw.NavigationView, core: BitcoinCore, secret: MasterSe position=0, count=1, confirm=lambda text: _compare(secret.text, text), - after=lambda: _wallets(view, core, secret, "now"), + after=lambda: _identity(view, core, secret), cancel=lambda page: _abandon(view, page), ) @@ -691,7 +691,7 @@ def follow(secret: MasterSeed | CoreLightningSecret) -> None: if not isinstance(secret, MasterSeed): _failure(view, "That ceremony did not produce a Bitcoin master seed.") return - _wallets(view, core, secret, "now") + _identity(view, core, secret) deliver = _then(view, page, follow, CARDS_SAFE) work.run(view, page, ceremony.finish, deliver) @@ -705,6 +705,7 @@ def _wallets( core: BitcoinCore, secret: MasterSeed, timestamp: Timestamp, + expected: bytes | None, *, restoring: bool = False, ) -> None: @@ -716,7 +717,7 @@ def _wallets( _then( view, page, - lambda found: _wallet_page(view, core, secret, found, timestamp, restoring), + lambda found: _wallet_page(view, core, secret, found, timestamp, expected, restoring), CARDS_SAFE, ), ) @@ -728,6 +729,7 @@ def _wallet_page( secret: MasterSeed, found: tuple[wallet_setup.Wallet, ...], timestamp: Timestamp, + expected: bytes | None, restoring: bool, ) -> Adw.NavigationPage: """Name the wallet that will hold the keys. The library confirms that name again.""" @@ -740,13 +742,13 @@ def go() -> None: # By position, so that a wallet named like the create row is still reachable. index = _selected(buttons) if index == len(found): - view.push(_new_wallet_page(view, core, secret, timestamp, restoring)) + view.push(_new_wallet_page(view, core, secret, timestamp, expected, restoring)) return chosen = found[index] if chosen.locked: - view.push(_unlock_page(view, core, secret, chosen, timestamp, restoring)) + view.push(_unlock_page(view, core, secret, chosen, timestamp, expected, restoring)) return - _import(view, core, secret, chosen.name, "", timestamp, restoring) + _import(view, core, secret, chosen.name, "", timestamp, expected, restoring) content = _column( _title( @@ -774,7 +776,9 @@ def go() -> None: "Wallet", content, actions=_actions( - _button("Check again", lambda: _wallets(view, core, secret, timestamp)), + _button( + "Check again", lambda: _wallets(view, core, secret, timestamp, expected, restoring=restoring) + ), _button("Continue", go, style="suggested-action"), ), ) @@ -785,6 +789,7 @@ def _new_wallet_page( core: BitcoinCore, secret: MasterSeed, timestamp: Timestamp, + expected: bytes | None, restoring: bool = False, ) -> Adw.NavigationPage: """Ask Bitcoin Core for one blank wallet, with a passphrase the operator chooses.""" @@ -802,8 +807,10 @@ def make(passphrase: str) -> None: page = _working(view, "Bitcoin Core", "Creating the wallet and writing your keys into it…") def job() -> Record: + if restoring: + wallet_setup.verify(core, secret, expected) wallet_setup.create(core, chosen, passphrase) - return _record(core, secret, chosen, timestamp, passphrase) + return _record(core, secret, chosen, timestamp, expected, passphrase) work.run( view, @@ -856,6 +863,7 @@ def _unlock_page( secret: MasterSeed, wallet: wallet_setup.Wallet, timestamp: Timestamp, + expected: bytes | None, restoring: bool = False, ) -> Adw.NavigationPage: """Unlock one already encrypted wallet, or step aside and let Bitcoin Core do it.""" @@ -884,7 +892,7 @@ def check() -> None: def job() -> Record: wallet_setup.require_unlocked(core, wallet.name) - return _record(core, secret, wallet.name, timestamp) + return _record(core, secret, wallet.name, timestamp, expected) work.run( view, @@ -894,7 +902,7 @@ def job() -> Record: ) def go() -> None: - _import(view, core, secret, wallet.name, field.get_text(), timestamp, restoring) + _import(view, core, secret, wallet.name, field.get_text(), timestamp, expected, restoring) content = _column( _title( @@ -921,9 +929,14 @@ def go() -> None: def _record( - core: BitcoinCore, secret: MasterSeed, name: str, timestamp: Timestamp, passphrase: str = "" + core: BitcoinCore, + secret: MasterSeed, + name: str, + timestamp: Timestamp, + expected: bytes | None, + passphrase: str = "", ) -> Record: - final = wallet_setup.fill(core, secret, name, passphrase, timestamp=timestamp) + final = wallet_setup.fill(core, secret, name, passphrase, expected=expected, timestamp=timestamp) return Record( secret.header.identifier.upper(), final, @@ -933,6 +946,112 @@ def _record( ) +def _identity( + view: Adw.NavigationView, core: BitcoinCore, secret: MasterSeed, restoring: bool = False +) -> None: + """Show the recovered identity: for a new wallet's record, or for a restore without one.""" + page = _working(view, "Wallet record", "Asking Bitcoin Core for the master fingerprint…") + + def follow(identity: tuple[str, str]) -> Adw.NavigationPage: + fingerprint, note = identity + shown = (("Backup identifier", secret.header.identifier.upper()), ("Master fingerprint", fingerprint)) + if not restoring: + return _page( + "Wallet record", + _column( + _title("Write this on your wallet record", "Keep the record apart from your cards."), + _rows("Identity", shown), + ), + actions=_actions( + _button( + "I wrote it down", + lambda: _wallets(view, core, secret, "now", None), + style="suggested-action", + ) + ), + can_pop=False, + ) + content = _column( + _title("Restore without a wallet record?", "Nothing here can prove these cards are your wallet."), + _rows("What the cards say", shown), + _note(note, "warning"), + _note(wallet_setup.NO_RECORD_WARNING, "warning"), + ) + back = _button( + "Go back", lambda: _replace(view, _fingerprint_page(view, core, secret, 0, restoring=True)) + ) + anyway = _button( + "Restore anyway", + lambda: _wallets(view, core, secret, 0, None, restoring=True), + style="destructive-action", + ) + return _page("No wallet record", content, actions=_actions(back, anyway), can_pop=False) + + work.run(view, page, lambda: wallet_setup.identity(core, secret), _then(view, page, follow, CARDS_SAFE)) + + +def _fingerprint_page( + view: Adw.NavigationView, + core: BitcoinCore, + secret: MasterSeed, + timestamp: Timestamp, + *, + restoring: bool = False, + problem: str = "", +) -> Adw.NavigationPage: + """Take the master fingerprint from the wallet record. The library refuses a mismatch.""" + entered = Adw.EntryRow(title="Master fingerprint from your wallet record") + group = Adw.PreferencesGroup() + group.add(entered) + status = _note(problem, "error" if problem else "") + + def go() -> None: + try: + expected = wallet_setup.parse_fingerprint(entered.get_text()) + except ValueError as error: + _say(status, str(error), "error") + return + page = _working(view, "Wallet record", "Checking the wallet record…") + + def job() -> str: + try: + wallet_setup.verify(core, secret, expected) + except wallet_setup.FingerprintMismatch as error: + return str(error) + return "" + + def follow(mismatch: str) -> Adw.NavigationPage | None: + if mismatch: + return _fingerprint_page(view, core, secret, timestamp, restoring=restoring, problem=mismatch) + _wallets(view, core, secret, timestamp, expected, restoring=restoring) + return None + + work.run(view, page, job, _then(view, page, follow, CARDS_SAFE)) + + buttons = [_button("Stop", lambda: view.replace([home(view)]))] + if restoring: + buttons.append( + _button("I have no wallet record", lambda: _identity(view, core, secret, restoring=True)) + ) + elif problem: + buttons.append(_button("Show it again", lambda: _identity(view, core, secret))) + buttons.append(_button("Check and continue", go, style="suggested-action")) + content = _column( + _title( + "Type the master fingerprint", "Copy it from the wallet record you keep apart from the cards." + ), + group, + status, + _note( + "If it does not match, stop: these cards are not that wallet. Bitcoin Core has not been changed." + if restoring + else "This checks that your wallet record is right while it can still be fixed.", + "warning" if restoring else "", + ), + ) + return _page("Wallet record", content, actions=_actions(*buttons), can_pop=False) + + def _import( view: Adw.NavigationView, core: BitcoinCore, @@ -940,13 +1059,14 @@ def _import( name: str, passphrase: str, timestamp: Timestamp, + expected: bytes | None, restoring: bool = False, ) -> None: page = _working(view, "Bitcoin Core", f"Writing your keys into {name}…") work.run( view, page, - lambda: _record(core, secret, name, timestamp, passphrase), + lambda: _record(core, secret, name, timestamp, expected, passphrase), _then(view, page, lambda record: _finished_page(view, record, restoring), CARDS_SAFE), ) @@ -1410,7 +1530,7 @@ def _restore(view: Adw.NavigationView, core: BitcoinCore, secret: Secret) -> Non if not isinstance(secret, MasterSeed): _failure(view, "Only a Bitcoin master-seed backup can restore a wallet.") return - _wallets(view, core, secret, 0, restoring=True) + _replace(view, _fingerprint_page(view, core, secret, 0, restoring=True)) def _start_restore(view: Adw.NavigationView) -> None: diff --git a/src/codex32_gui/wallet_setup.py b/src/codex32_gui/wallet_setup.py index ccdef0c..11abc2c 100644 --- a/src/codex32_gui/wallet_setup.py +++ b/src/codex32_gui/wallet_setup.py @@ -24,12 +24,23 @@ from typing import Literal from codex32 import MasterSeed -from codex32._bitcoin_core import _CHAINS, BitcoinCore, BitcoinCoreError +from codex32._bitcoin_core import ( + _CHAINS, + NO_RECORD_WARNING, + BitcoinCore, + BitcoinCoreError, + FingerprintMismatch, + identifier_note, + identifier_origin, + parse_fingerprint, +) __all__ = [ + "NO_RECORD_WARNING", "UNLOCK_SECONDS", "BitcoinCore", "BitcoinCoreError", + "FingerprintMismatch", "Offer", "Wallet", "connect", @@ -38,11 +49,14 @@ "fill", "fingerprint", "fingerprint_provider", + "identity", "initialize", "network", + "parse_fingerprint", "relock", "require_unlocked", "unlock", + "verify", "version_text", ] @@ -140,6 +154,17 @@ def fingerprint(core: BitcoinCore, secret: MasterSeed) -> str: return core.fingerprint(secret).hex() +def identity(core: BitcoinCore, secret: MasterSeed) -> tuple[str, str]: + """Return the recovered fingerprint and what the backup identifier says about the seed.""" + fingerprint = core.fingerprint(secret) + return fingerprint.hex(), identifier_note(identifier_origin(secret, fingerprint)) + + +def verify(core: BitcoinCore, secret: MasterSeed, expected: bytes | None) -> None: + """Refuse a seed that is not the recorded wallet before any wallet is listed or touched.""" + core.verify_identity(secret, expected) + + def fingerprint_provider(core: BitcoinCore) -> Callable[[bytes], bytes]: """Hand the library the same out-of-process derivation for a raw seed.""" return core.fingerprint_seed @@ -265,12 +290,15 @@ def initialize( secret: MasterSeed, name: str, *, + expected: bytes | None, account: int = 0, timestamp: int | Literal["now"] = "now", ) -> str: """Hand the library the wallet the operator named, and let it do the import.""" answer = _Answer(name, quoted=True) - return core.initialize(secret, answer.ask, answer.tell, account=account, timestamp=timestamp) + return core.initialize( + secret, answer.ask, answer.tell, expected_fingerprint=expected, account=account, timestamp=timestamp + ) def fill( @@ -279,6 +307,7 @@ def fill( name: str, passphrase: str, *, + expected: bytes | None, account: int = 0, timestamp: int | Literal["now"] = "now", ) -> str: @@ -290,9 +319,10 @@ def fill( covers the whole sequence; locking an already locked wallet is harmless. """ if not passphrase: - return initialize(core, secret, name, account=account, timestamp=timestamp) + return initialize(core, secret, name, expected=expected, account=account, timestamp=timestamp) + verify(core, secret, expected) unlock(core, name, passphrase) try: - return initialize(core, secret, name, account=account, timestamp=timestamp) + return initialize(core, secret, name, expected=expected, account=account, timestamp=timestamp) finally: relock(core, name) diff --git a/tests/test_bitcoin_core.py b/tests/test_bitcoin_core.py index 7ea5c05..84aad2c 100644 --- a/tests/test_bitcoin_core.py +++ b/tests/test_bitcoin_core.py @@ -2,6 +2,7 @@ from __future__ import annotations +import hashlib import json import re import subprocess @@ -9,8 +10,16 @@ import pytest -from codex32._bitcoin_core import BitcoinCore, BitcoinCoreError +from codex32._bitcoin_core import ( + BitcoinCore, + BitcoinCoreError, + FingerprintMismatch, + identifier_note, + identifier_origin, + parse_fingerprint, +) from codex32.bip93 import parse_codex32 +from codex32.generation import _fingerprint_identifier from codex32.profiles.ms32 import MasterSeed from codex32.wallet import _with_checksum @@ -37,9 +46,16 @@ ), } _ROOT_XPUB = "xpub-root-fixture" +_FINGERPRINT = bytes.fromhex("3f3521a6") _PRIVATE_ACCOUNT = re.compile(r"/(?P44|49|84|86)h/0h/0h/<0;1>/\*") +@pytest.fixture(autouse=True) +def _recorded_fingerprint(monkeypatch: pytest.MonkeyPatch) -> None: + """Answer the pre-import identity check without the address-derivation RPCs tested separately.""" + monkeypatch.setattr(BitcoinCore, "fingerprint", lambda _client, _secret: _FINGERPRINT) + + def _descriptor_info(descriptor: str) -> dict[str, object]: """Return frozen Core-like normalization for the synthetic seed fixture.""" raw = descriptor.strip().split("#", 1)[0] @@ -380,7 +396,10 @@ def unlock(seconds: int) -> None: monkeypatch.setattr("codex32._bitcoin_core.sleep", unlock) - assert client.initialize(_SEED, lambda _prompt: "yes", messages.append) == "signer" + assert ( + client.initialize(_SEED, lambda _prompt: "yes", messages.append, expected_fingerprint=_FINGERPRINT) + == "signer" + ) private_calls = [call for call in rpc.calls if "xprv" in (call[2] or "")] assert len(private_calls) == 1 arguments, wallet, private_stdin = private_calls[0] @@ -420,7 +439,9 @@ def fail( monkeypatch.setattr(BitcoinCore, "_rpc", fail) client = BitcoinCore("bitcoin-cli", "main", 300000) with pytest.raises(BitcoinCoreError, match="did not import every private descriptor"): - client.initialize(_SEED, lambda _prompt: "yes", lambda _message: None) + client.initialize( + _SEED, lambda _prompt: "yes", lambda _message: None, expected_fingerprint=_FINGERPRINT + ) assert rpc.locked assert any(arguments == ("walletlock",) for arguments, _wallet, _stdin in rpc.calls) @@ -446,7 +467,7 @@ def rpc( monkeypatch.setattr(BitcoinCore, "_rpc", rpc) - assert BitcoinCore("bitcoin-cli", "main", 320000).fingerprint(_SEED) == bytes.fromhex("3f3521a6") + assert BitcoinCore("bitcoin-cli", "main", 320000).fingerprint_seed(_SEED.seed_bytes) == _FINGERPRINT assert calls == [ (("getdescriptorinfo",), None), (("deriveaddresses",), None), @@ -478,7 +499,9 @@ def alter( monkeypatch.setattr(BitcoinCore, "_rpc", alter) client = BitcoinCore("bitcoin-cli", "main", 300000) with pytest.raises(BitcoinCoreError, match="accepted public descriptors did not match"): - client.initialize(_SEED, lambda _prompt: "yes", lambda _message: None) + client.initialize( + _SEED, lambda _prompt: "yes", lambda _message: None, expected_fingerprint=_FINGERPRINT + ) assert rpc.locked @@ -500,7 +523,9 @@ def fail( monkeypatch.setattr(BitcoinCore, "_rpc", fail) client = BitcoinCore("bitcoin-cli", "main", 300000) with pytest.raises(BitcoinCoreError, match="suppressed failure"): - client.initialize(_SEED, lambda _prompt: "yes", lambda _message: None) + client.initialize( + _SEED, lambda _prompt: "yes", lambda _message: None, expected_fingerprint=_FINGERPRINT + ) assert rpc.locked assert any(arguments == ("walletlock",) for arguments, _wallet, _stdin in rpc.calls) @@ -519,7 +544,9 @@ def interrupt( monkeypatch.setattr(BitcoinCore, "_rpc", interrupt) client = BitcoinCore("bitcoin-cli", "main", 300000) with pytest.raises(KeyboardInterrupt): - client.initialize(_SEED, lambda _prompt: "yes", lambda _message: None) + client.initialize( + _SEED, lambda _prompt: "yes", lambda _message: None, expected_fingerprint=_FINGERPRINT + ) assert rpc.locked @@ -546,7 +573,9 @@ def select(_client: BitcoinCore, _ask: object, _tell: object) -> str: ) with pytest.raises(KeyboardInterrupt): - BitcoinCore("bitcoin-cli", "main", 300000).initialize(_SEED, lambda _prompt: "yes", messages.append) + BitcoinCore("bitcoin-cli", "main", 300000).initialize( + _SEED, lambda _prompt: "yes", messages.append, expected_fingerprint=_FINGERPRINT + ) assert rpc.locked assert "That wallet is no longer eligible. Choose again." in messages assert any(arguments == ("walletlock",) for arguments, _wallet, _stdin in rpc.calls) @@ -569,7 +598,7 @@ def interrupt(_seconds: int) -> None: with pytest.raises(KeyboardInterrupt): BitcoinCore("bitcoin-cli", "main", 300000).initialize( - _SEED, lambda _prompt: "", lambda _message: None + _SEED, lambda _prompt: "", lambda _message: None, expected_fingerprint=_FINGERPRINT ) assert rpc.locked @@ -598,7 +627,7 @@ def target(*_args: object, **_options: object) -> tuple[bool, bool]: assert ( BitcoinCore("bitcoin-cli", "main", 300000).initialize( - _SEED, lambda _prompt: "", lambda _message: None + _SEED, lambda _prompt: "", lambda _message: None, expected_fingerprint=_FINGERPRINT ) == "signer" ) @@ -624,7 +653,12 @@ def interrupt_once( monkeypatch.setattr(BitcoinCore, "_rpc", interrupt_once) client = BitcoinCore("bitcoin-cli", "main", 300000) - assert client.initialize(_SEED, lambda _prompt: "yes", lambda _message: None) == "signer" + assert ( + client.initialize( + _SEED, lambda _prompt: "yes", lambda _message: None, expected_fingerprint=_FINGERPRINT + ) + == "signer" + ) assert rpc.locked and lock_calls == 2 @@ -637,7 +671,10 @@ def test_unencrypted_wallet_imports_without_a_lock_call(monkeypatch: pytest.Monk ) client = BitcoinCore("bitcoin-cli", "main", 300000) messages: list[str] = [] - assert client.initialize(_SEED, lambda _prompt: "yes", messages.append) == "signer" + assert ( + client.initialize(_SEED, lambda _prompt: "yes", messages.append, expected_fingerprint=_FINGERPRINT) + == "signer" + ) assert messages == [] assert not any(arguments == ("walletlock",) for arguments, _wallet, _stdin in rpc.calls) @@ -662,7 +699,7 @@ def test_immediate_revalidation_stops_before_private_import_and_relocks( ) with pytest.raises(BitcoinCoreError, match="changed before import"): - client.initialize(_SEED, lambda _prompt: "", lambda _message: None) + client.initialize(_SEED, lambda _prompt: "", lambda _message: None, expected_fingerprint=_FINGERPRINT) assert rpc.locked assert not any(arguments == ("importdescriptors",) for arguments, _wallet, _stdin in rpc.calls) @@ -685,3 +722,81 @@ def run(command: list[str], **options: object) -> subprocess.CompletedProcess[st with pytest.raises(BitcoinCoreError) as failure: client._rpc("importdescriptors", wallet="wallet", stdin=marker + "\n") assert marker not in str(failure.value) + + +@pytest.mark.parametrize("text", ("3f3521a6", "3F35 21A6", " 3f35\t21a6 ")) +def test_parse_fingerprint_accepts_record_spellings(text: str) -> None: + assert parse_fingerprint(text) == _FINGERPRINT + + +@pytest.mark.parametrize("text", ("", "3f3521a", "3f3521a6ff", "3f3521ag", "0x3f3521")) +def test_parse_fingerprint_rejects_other_text(text: str) -> None: + with pytest.raises(ValueError, match="8 characters"): + parse_fingerprint(text) + + +def test_identity_mismatch_stops_before_any_wallet_call(monkeypatch: pytest.MonkeyPatch) -> None: + rpc = _ImportRPC(locked=False) + monkeypatch.setattr( + BitcoinCore, + "_rpc", + lambda client, *args, wallet=None, stdin=None: rpc(client, *args, wallet=wallet, stdin=stdin), + ) + + with pytest.raises(FingerprintMismatch, match="Bitcoin Core was not changed"): + BitcoinCore("bitcoin-cli", "main", 300000).initialize( + _SEED, + lambda _prompt: "yes", + lambda _message: None, + expected_fingerprint=bytes.fromhex("3f3521a7"), + ) + assert rpc.calls == [] + + +def test_no_record_is_the_operators_choice_and_checks_nothing(monkeypatch: pytest.MonkeyPatch) -> None: + def unused(_client: BitcoinCore, _secret: MasterSeed) -> bytes: + raise AssertionError("no fingerprint is compared without a record") + + monkeypatch.setattr(BitcoinCore, "fingerprint", unused) + BitcoinCore("bitcoin-cli", "main", 300000).verify_identity(_SEED, None) + + +# Frozen from Bails' own ms32.seed_identifier for this seed: master (RIPEMD-160) and the +# June 2023 alpha (SHA-256). Bails checked three characters and kept the fourth for re-sharing. +_BAILS_SEED = bytes(range(16)) + + +@pytest.mark.parametrize( + ("identifier", "origin"), + ( + (_fingerprint_identifier(_FINGERPRINT), "codex32"), + ("d9k8", "Bails"), + ("d9kq", "Bails"), + ("hezu", "Bails alpha"), + ("test", None), + ), +) +def test_identifier_origin_names_the_rule_that_made_it(identifier: str, origin: str | None) -> None: + secret = MasterSeed.from_seed(_BAILS_SEED, identifier=identifier) + assert identifier_origin(secret, _FINGERPRINT) == 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: + original_new = hashlib.new + + def without_ripemd160(name: str, data: bytes = b"") -> object: + if name == "ripemd160": + raise ValueError("unsupported hash type ripemd160") + 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" + + +def test_identifier_note_allows_supported_nonderived_codex32_identifiers() -> None: + note = identifier_note(None) + assert "split shares" in note + assert "supplied seed bytes" in note + assert "explicit identifier" in note diff --git a/tests/test_cli.py b/tests/test_cli.py index 5a2157e..8741929 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -32,6 +32,7 @@ parse_codex32, recover_secret, ) +from codex32._bitcoin_core import BitcoinCore from codex32.bech32 import _chars_to_u5, bech32_encode from codex32.checksums import _CODEX32, _CODEX32_LONG from codex32.cli import main, ms_main @@ -96,6 +97,7 @@ class _FakeBitcoinCore: private: bool | None = None account: int | None = None timestamp: int | str | None = None + expected: bytes | None = None def fingerprint_seed(self, seed: bytes) -> bytes: return fingerprint_seed(seed) @@ -103,16 +105,22 @@ def fingerprint_seed(self, seed: bytes) -> bytes: def fingerprint(self, secret: MasterSeed) -> bytes: return self.fingerprint_seed(secret.seed_bytes) + def verify_identity(self, secret: MasterSeed, expected_fingerprint: bytes | None) -> None: + BitcoinCore.verify_identity(self, secret, expected_fingerprint) # type: ignore[arg-type] + def initialize( self, secret: MasterSeed, _ask: Callable[[str], str], _tell: Callable[[str], None], *, + expected_fingerprint: bytes | None, private: bool = True, account: int = 0, timestamp: int | str = "now", ) -> str: + self.verify_identity(secret, expected_fingerprint) + self.expected = expected_fingerprint self.imported = secret self.private, self.account, self.timestamp = private, account, timestamp return "test-wallet" @@ -123,6 +131,17 @@ def _offline_core(monkeypatch): monkeypatch.setattr("codex32.cli.BitcoinCore.connect", lambda *args, **kwargs: _FakeBitcoinCore()) +_RECORDED_FINGERPRINT = importlib.import_module("codex32.cli")._recorded_fingerprint +_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._show_fingerprint", lambda _core, _secret, _action: None) + + def _invoke(args: list[str], *lines: str) -> _Result: stdin = io.StringIO("\n".join(lines) + "\n") stdout = io.StringIO() @@ -1873,6 +1892,7 @@ def test_wallet_commands_initialize_selected_master_seed_destinations() -> None: assert xprv.stderr.endswith("Keep it secret.\n\n") assert private.stdout == "" assert private_core.imported == parse_codex32(VECTOR_1["secret_s"]) + assert private_core.expected == private_core.fingerprint(private_core.imported) assert private_core.private is True assert "Warning: This imports private descriptors that can spend funds." in private.stderr assert "Use only the intended encrypted wallet" not in private.stderr @@ -2748,3 +2768,108 @@ def test_incomplete_candidate_has_no_search_warning_and_is_never_accepted_automa assert "Search incomplete" not in result.stderr assert "may not be unique" not in result.stderr assert "only a correction suggestion" in result.stderr + + +def _record_answers(monkeypatch: pytest.MonkeyPatch, *answers: str) -> list[str]: + prompts: list[str] = [] + remaining = iter(answers) + + def answer(prompt: str, **_options: object) -> str: + prompts.append(prompt) + return next(remaining) + + monkeypatch.setattr(importlib.import_module("codex32.cli"), "_text", answer) + return prompts + + +def test_restore_record_prompt_retries_until_the_library_accepts( + monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] +) -> None: + core, secret = _FakeBitcoinCore(), parse_codex32(VECTOR_1["secret_s"]) + 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()) + + assert _RECORDED_FINGERPRINT(core, secret) == right + assert prompts == ["Type the master fingerprint from your wallet record (Enter if none)"] * 3 + errors = capsys.readouterr().err + assert "8 characters" in errors and "does not match" in errors + assert right.hex() not in errors.lower() + + +def test_restore_without_a_record_shows_what_the_cards_say_and_asks( + monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] +) -> None: + core, secret = _FakeBitcoinCore(), parse_codex32(VECTOR_1["secret_s"]) + assert isinstance(secret, MasterSeed) + fingerprint = core.fingerprint(secret) + + prompts = _record_answers(monkeypatch, "", "n", "", "y") + assert _RECORDED_FINGERPRINT(core, secret) is None + assert prompts[1] == prompts[3] == "Restore without a wallet record? [y/N]" + shown = capsys.readouterr().err + assert shown.count(f"Master fingerprint: {fingerprint.hex().upper()}") == 2 + assert "was not made from this seed" in shown and "nothing can prove" in shown + + derived = MasterSeed.from_seed(secret.seed_bytes, identifier=_fingerprint_identifier(fingerprint)) + _record_answers(monkeypatch, "", "yes") + assert _RECORDED_FINGERPRINT(core, derived) is None + assert "matches this seed (codex32 rule)" in capsys.readouterr().err + + +def test_create_only_requires_acknowledging_that_the_fingerprint_was_recorded( + monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] +) -> None: + core, secret = _FakeBitcoinCore(), parse_codex32(VECTOR_1["secret_s"]) + assert isinstance(secret, MasterSeed) + right = core.fingerprint(secret) + prompts = _record_answers(monkeypatch, "") + + _SHOW_FINGERPRINT(core, secret, "Write it on the wallet record") + assert prompts[0] == "Write it on the wallet record, then press Enter" + shown = capsys.readouterr().err + assert f"Master fingerprint: {right.hex().upper()}" in shown + + +def test_create_initialization_does_not_authenticate_against_a_preexisting_wallet( + monkeypatch: pytest.MonkeyPatch, +) -> None: + core, secret = _FakeBitcoinCore(), parse_codex32(VECTOR_1["secret_s"]) + assert isinstance(secret, MasterSeed) + shown: list[str] = [] + monkeypatch.setattr( + "codex32.cli._show_fingerprint", + lambda _core, _secret, action: shown.append(action), + ) + + assert importlib.import_module("codex32.cli")._initialize_wallet(core, secret, confirmed=False) == 0 + assert shown == ["Write it on the wallet record"] + assert core.expected is None + + +@pytest.mark.parametrize("header", (None, "2")) +def test_create_existing_checks_the_record_before_import(monkeypatch: pytest.MonkeyPatch, header: str | None): + cli = importlib.import_module("codex32.cli") + secret = parse_codex32(VECTOR_1["secret_s"]) + core = _FakeBitcoinCore() + checked = [] + + def record(_core, recovered): + assert core.imported is None + checked.append(recovered.seed_bytes) + return core.fingerprint(recovered) + + def confirm(artifact, accept=None): + if accept: + accept(artifact.text) + + monkeypatch.setattr(sys, "stdin", _TTYInput()) + monkeypatch.setattr(sys, "stdout", _TTYOutput()) + monkeypatch.setattr(cli, "_creation_source", lambda *args: secret) + monkeypatch.setattr(cli, "_confirm_card", confirm) + monkeypatch.setattr(cli, "_recorded_fingerprint", record) + monkeypatch.setattr(cli.BitcoinCore, "connect", lambda *args: core) + assert ms_main(["create", "--existing", *([header] if header else [])]) == 0 + assert checked == [secret.seed_bytes] + assert core.expected == core.fingerprint(secret) diff --git a/tests/test_gui_boundaries.py b/tests/test_gui_boundaries.py index cc7c5eb..2687a49 100644 --- a/tests/test_gui_boundaries.py +++ b/tests/test_gui_boundaries.py @@ -56,6 +56,19 @@ def _imports(tree: ast.AST) -> set[str]: return found +def _callers(tree: ast.Module, name: str) -> set[str]: + """Name the top-level functions whose bodies, callbacks included, call `name`.""" + return { + function.name + for function in tree.body + if isinstance(function, ast.FunctionDef) + and any( + isinstance(node, ast.Call) and isinstance(node.func, ast.Name) and node.func.id == name + for node in ast.walk(function) + ) + } + + @pytest.mark.parametrize("path", _modules(), ids=lambda path: path.name) def test_the_gui_draws_no_entropy_opens_no_socket_and_touches_no_file(path: Path) -> None: imported = _imports(ast.parse(path.read_text())) @@ -114,3 +127,29 @@ def test_the_gui_keeps_its_own_size_budget() -> None: for path in _modules() } assert sum(counts.values()) < BUDGET, counts + + +def test_every_restore_way_to_a_wallet_passes_the_identity_choice() -> None: + tree = ast.parse((_package() / "pages.py").read_text()) + + assert _callers(tree, "_wallets") == {"_fingerprint_page", "_identity", "_wallet_page"} + assert _callers(tree, "_fingerprint_page") == {"_identity", "_restore", "_fingerprint_page"} + assert _callers(tree, "_identity") == {"_unshared_page", "_card_confirmed", "_fingerprint_page"} + + +def test_restore_verifies_identity_before_creating_a_destination_wallet() -> None: + tree = ast.parse((_package() / "pages.py").read_text()) + new_wallet = next( + node for node in tree.body if isinstance(node, ast.FunctionDef) and node.name == "_new_wallet_page" + ) + job = next( + node for node in ast.walk(new_wallet) if isinstance(node, ast.FunctionDef) and node.name == "job" + ) + guard = job.body[0] + assert isinstance(guard, ast.If) and isinstance(guard.test, ast.Name) and guard.test.id == "restoring" + verify = guard.body[0] + assert isinstance(verify, ast.Expr) and isinstance(verify.value, ast.Call) + assert isinstance(verify.value.func, ast.Attribute) and verify.value.func.attr == "verify" + create = job.body[1] + assert isinstance(create, ast.Expr) and isinstance(create.value, ast.Call) + assert isinstance(create.value.func, ast.Attribute) and create.value.func.attr == "create" diff --git a/tests/test_gui_wallet_setup.py b/tests/test_gui_wallet_setup.py index 713d145..4b48081 100644 --- a/tests/test_gui_wallet_setup.py +++ b/tests/test_gui_wallet_setup.py @@ -10,7 +10,7 @@ import pytest from codex32 import parse_codex32 -from codex32._bitcoin_core import BitcoinCore, BitcoinCoreError +from codex32._bitcoin_core import BitcoinCore, BitcoinCoreError, FingerprintMismatch from codex32_gui import wallet_setup _SEED = parse_codex32("MS12NAMES6XQGUZTTXKEQNJSJZV4JV3NZ5K3KWGSPHUH6EVW") @@ -253,17 +253,30 @@ def refuse(*_arguments: object, **_keywords: object) -> str: monkeypatch.setattr(wallet_setup, "initialize", refuse) with pytest.raises(BitcoinCoreError, match="waiting"): - wallet_setup.fill(core, _SEED, "fresh", PASSPHRASE) + wallet_setup.fill(core, _SEED, "fresh", PASSPHRASE, expected=None) assert fake.called("walletlock") assert fake.wallets["fresh"].locked +def test_identity_mismatch_is_refused_before_unlock(monkeypatch: pytest.MonkeyPatch) -> None: + core, fake = _client(monkeypatch, {"fresh": _Wallet(True, True)}) + + def mismatch(_core: BitcoinCore, _secret: object, _expected: bytes | None) -> None: + raise FingerprintMismatch("wrong wallet") + + monkeypatch.setattr(wallet_setup, "verify", mismatch) + with pytest.raises(FingerprintMismatch, match="wrong wallet"): + wallet_setup.fill(core, _SEED, "fresh", PASSPHRASE, expected=b"expected") + assert not fake.called("walletpassphrase") + assert fake.wallets["fresh"].locked + + def test_an_unlock_is_not_attempted_when_no_passphrase_was_given( monkeypatch: pytest.MonkeyPatch, ) -> None: core, fake = _client(monkeypatch, {"fresh": _Wallet()}) monkeypatch.setattr(wallet_setup, "initialize", lambda *_a, **_k: "fresh") - assert wallet_setup.fill(core, _SEED, "fresh", "") == "fresh" + assert wallet_setup.fill(core, _SEED, "fresh", "", expected=None) == "fresh" assert not fake.called("walletpassphrase") assert not fake.called("walletlock") diff --git a/tools/bitcoin_core_main_smoke.py b/tools/bitcoin_core_main_smoke.py index 5ce027b..56f36df 100644 --- a/tools/bitcoin_core_main_smoke.py +++ b/tools/bitcoin_core_main_smoke.py @@ -115,12 +115,14 @@ def rpc(*rpc_arguments: str, wallet: str | None = None, stdin: str | None = None if not isinstance(secret, MasterSeed): raise TypeError("synthetic fixture was not a master seed") client = BitcoinCore.connect() + expected_fingerprint = client.fingerprint(secret) answers = iter(("yes",)) if ( client.initialize( secret, lambda _prompt: next(answers), lambda _message: None, + expected_fingerprint=expected_fingerprint, account=0, timestamp=0, ) @@ -145,6 +147,7 @@ def rpc(*rpc_arguments: str, wallet: str | None = None, stdin: str | None = None secret, lambda _prompt: next(account_answers), lambda _message: None, + expected_fingerprint=expected_fingerprint, account=7, timestamp="now", ) diff --git a/tools/bitcoin_core_regtest.py b/tools/bitcoin_core_regtest.py index 4057e0e..448c268 100644 --- a/tools/bitcoin_core_regtest.py +++ b/tools/bitcoin_core_regtest.py @@ -123,12 +123,14 @@ def rpc(*rpc_arguments: str, wallet: str | None = None, stdin: str | None = None if not isinstance(secret, MasterSeed): raise TypeError("synthetic fixture was not a master seed") client = BitcoinCore.connect() + expected_fingerprint = client.fingerprint(secret) answers = iter(("yes",)) if ( client.initialize( secret, lambda _prompt: next(answers), lambda _message: None, + expected_fingerprint=expected_fingerprint, account=0, timestamp=0, ) @@ -165,6 +167,7 @@ def rpc(*rpc_arguments: str, wallet: str | None = None, stdin: str | None = None secret, lambda _prompt: next(restore_answers), lambda _message: None, + expected_fingerprint=expected_fingerprint, account=0, timestamp=0, ) @@ -196,6 +199,7 @@ def rpc(*rpc_arguments: str, wallet: str | None = None, stdin: str | None = None secret, lambda _prompt: next(account_answers), lambda _message: None, + expected_fingerprint=expected_fingerprint, account=7, timestamp="now", )