From 0cc543764f4327489e5d326b27e3a653f7b2009f Mon Sep 17 00:00:00 2001 From: Ben Westgate Date: Thu, 24 Sep 2026 06:51:28 -0500 Subject: [PATCH 1/8] wallet: Require the recorded fingerprint before import BitcoinCore.initialize now takes a required expected_fingerprint and checks it before any wallet is listed, created, unlocked or imported into, so the GUI and CLI share one gate. The operator types the value from the wallet record; a new wallet shows it once and asks for it back. Without a record, the backup identifier must derive from the seed (codex32 fingerprint or legacy Bails RIPEMD-160 identifier). Fixes #26. Fixes #30. Co-Authored-By: Claude Opus 5.5 --- docs/developer/api.md | 6 +- docs/security/invariants.md | 4 + docs/security/model.md | 18 +++- docs/user/gui.md | 19 ++-- docs/user/guide.md | 18 ++-- src/codex32/_bitcoin_core.py | 54 ++++++++++++ src/codex32/cli.py | 41 ++++++++- src/codex32_gui/pages.py | 150 +++++++++++++++++++++++++++++--- src/codex32_gui/wallet_setup.py | 45 +++++++++- tests/test_bitcoin_core.py | 98 ++++++++++++++++++--- tests/test_cli.py | 84 ++++++++++++++++++ tests/test_gui_boundaries.py | 21 +++++ tests/test_gui_wallet_setup.py | 4 +- 13 files changed, 513 insertions(+), 49 deletions(-) diff --git a/docs/developer/api.md b/docs/developer/api.md index 50f3250..54fbc39 100644 --- a/docs/developer/api.md +++ b/docs/developer/api.md @@ -640,7 +640,11 @@ 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 the required `expected_fingerprint`: bytes typed from the wallet record +(read with `parse_fingerprint`), or `None` when there is no record, which accepts +only a seed-derived backup identifier. 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..249b23b 100644 --- a/docs/security/invariants.md +++ b/docs/security/invariants.md @@ -11,6 +11,10 @@ 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. + `BitcoinCore.initialize` requires the master fingerprint typed from the + wallet record and refuses a mismatch before any wallet is listed, created, + unlocked, or imported into. Without a record, the backup identifier must be + derived from the recovered seed. 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..94c51ba 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 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, @@ -268,6 +269,19 @@ 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. +Every import, in the window and on the command line, goes through +`BitcoinCore.initialize`, which requires the master fingerprint the operator +typed from the wallet record. Core derives the recovered fingerprint +statelessly, and a mismatch raises `FingerprintMismatch` before any wallet is +listed, created, unlocked, or imported into. The prompt does not show the +recovered value, so the operator compares by typing rather than by glancing. A +new wallet shows its fingerprint once, then asks for it back from the written +record. An operator without a record may continue only when the backup identifier is +derived from the recovered seed: the codex32 fingerprint identifier or legacy +Bails' RIPEMD-160 seed identifier. Both checks catch mistakes such as wrong or +mixed cards; 32 bits, and 20 bits without a record, do not stop deliberately +replaced cards. + 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..107b8bd 100644 --- a/docs/user/gui.md +++ b/docs/user/gui.md @@ -67,7 +67,11 @@ 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 type it back from +what you wrote. That catches a writing mistake while it can still be fixed. + +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 +146,15 @@ 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** checks only that the +backup identifier comes from the recovered seed. That works for backups made by +Bails, but it cannot catch cards someone replaced on purpose. + +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..0c39669 100644 --- a/docs/user/guide.md +++ b/docs/user/guide.md @@ -168,9 +168,10 @@ 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 type it back from what you wrote. 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 +229,19 @@ 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; then only a seed-derived backup identifier is accepted. +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..6327399 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,25 @@ 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) + + +def _seed_identifiers(seed: bytes, fingerprint: bytes) -> tuple[str, str]: + """Return the backup identifiers that only this seed produces. + + codex32 uses the first 20 bits of the BIP32 fingerprint. Bails' legacy + bails-wallet used the first 20 bits of RIPEMD-160 of the seed and offered + no way to change it. + """ + legacy = hashlib.new("ripemd160", seed).digest() + return _fingerprint_identifier(fingerprint), _u5_to_chars(tuple(convertbits(legacy, 8, 5, pad=True)[:4])) + + @dataclass(frozen=True) class BitcoinCore: executable: str @@ -153,6 +180,26 @@ 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. + + `expected_fingerprint` is what the operator typed from the wallet record. + `None` means there is no record: the backup identifier must then be one + derived from this seed, which catches mistakes but not replaced cards. + """ + fingerprint = self.fingerprint(secret) + if expected_fingerprint is None: + if secret.header.identifier not in _seed_identifiers(secret.seed_bytes, fingerprint): + raise FingerprintMismatch( + "This backup's identifier does not come from the recovered seed, so it cannot be " + "checked without the wallet record. Bitcoin Core was not changed." + ) + elif fingerprint != 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 +346,16 @@ def initialize( ask: Callable[[str], str], tell: Callable[[str], None], *, + expected_fingerprint: bytes | None, account: int = 0, timestamp: int | Literal["now"] = "now", ) -> str: + """Import the recovered keys into one empty wallet the operator chooses. + + The wallet record is checked first, so a wrong seed is refused before any + wallet is listed, created, unlocked or imported into. + """ + 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..0c86ac0 100644 --- a/src/codex32/cli.py +++ b/src/codex32/cli.py @@ -8,7 +8,7 @@ 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, FingerprintMismatch, parse_fingerprint from codex32._cli_input import ( CorrectionDeclined, InteractiveConfirmationRequired, @@ -333,6 +333,43 @@ 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 _recorded_fingerprint(core: BitcoinCore, secret: MasterSeed, fresh: bool) -> bytes | None: + """Take the master fingerprint from the wallet record until the library accepts it.""" + if fresh: + _show_fingerprint(core, secret, "Write it on the wallet record") + prompt = "Type the master fingerprint from your wallet record" + ("" if fresh else " (Enter if none)") + while True: + text = _text(prompt, optional=True) + expected: bytes | None = None + if text or fresh: + try: + expected = parse_fingerprint(text) + except ValueError as error: + _print(str(error), err=True) + continue + elif _text( + "Without the record, only the backup identifier can be checked. Continue? [y/N]", optional=True + ).lower() not in ("y", "yes"): + continue + try: + core.verify_identity(secret, expected) + except FingerprintMismatch as error: + if expected is None: + raise + _print(str(error), err=True) + if fresh: + _show_fingerprint(core, secret, "Check the wallet record against it") + continue + return expected + + def _initialize_wallet( core: BitcoinCore, secret: MasterSeed, @@ -346,10 +383,12 @@ def _initialize_wallet( try: if confirmed: _print("Master-seed backup confirmed.\n", err=True) + expected = _recorded_fingerprint(core, secret, fresh) name = core.initialize( secret, lambda prompt: _text(prompt, optional=True), lambda message: _print(message, err=True), + expected_fingerprint=expected, account=account, timestamp=timestamp, ) diff --git a/src/codex32_gui/pages.py b/src/codex32_gui/pages.py index b529356..6a7feac 100644 --- a/src/codex32_gui/pages.py +++ b/src/codex32_gui/pages.py @@ -76,6 +76,11 @@ "the beginning, so your balance and history are not complete until it has finished." ), ) +NO_RECORD = ( + "Without the record, codex32 can only check that the backup identifier comes from this seed. " + "That works for backups made by Bails and catches wrong or mixed-up cards, but not cards " + "someone replaced on purpose." +) CARDS_SAFE = ( "Your cards are unharmed and still recover this wallet. Nothing was written onto them and " "nothing about them changed. When Bitcoin Core is ready, choose \u201cRestore my wallet\u201d " @@ -647,7 +652,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: _record_fingerprint(view, core, secret), cancel=lambda page: _abandon(view, page), ) @@ -691,7 +696,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") + _record_fingerprint(view, core, secret) deliver = _then(view, page, follow, CARDS_SAFE) work.run(view, page, ceremony.finish, deliver) @@ -705,6 +710,7 @@ def _wallets( core: BitcoinCore, secret: MasterSeed, timestamp: Timestamp, + expected: bytes | None, *, restoring: bool = False, ) -> None: @@ -716,7 +722,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 +734,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 +747,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 +781,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 +794,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.""" @@ -803,7 +813,7 @@ def make(passphrase: str) -> None: def job() -> Record: 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 +866,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 +895,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 +905,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 +932,16 @@ 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_fingerprint=expected, timestamp=timestamp + ) return Record( secret.header.identifier.upper(), final, @@ -933,6 +951,109 @@ def _record( ) +def _record_fingerprint(view: Adw.NavigationView, core: BitcoinCore, secret: MasterSeed) -> None: + """Have a new wallet's master fingerprint written on the wallet record, then typed back from it.""" + page = _working(view, "Wallet record", "Asking Bitcoin Core for the master fingerprint…") + + def follow(fingerprint: str) -> Adw.NavigationPage: + content = _column( + _title( + "Write this on your wallet record", + "Keep the record apart from every card. Restoring your wallet asks you to type this from it.", + ), + _rows( + "Wallet identity", + ( + ("Backup identifier", secret.header.identifier.upper()), + ("Master fingerprint", fingerprint), + ), + ), + ) + written = _button( + "I wrote it down", + lambda: _replace(view, _fingerprint_page(view, core, secret, "now")), + style="suggested-action", + ) + return _page("Wallet record", content, actions=_actions(written), can_pop=False) + + work.run( + view, page, lambda: wallet_setup.fingerprint(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 check(expected: bytes | None) -> None: + 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)) + + def go() -> None: + try: + expected = wallet_setup.parse_fingerprint(entered.get_text()) + except ValueError as error: + _say(status, str(error), "error") + return + check(expected) + + def without_record() -> None: + dialog = Adw.AlertDialog(heading="Restore without a wallet record?", body=NO_RECORD) + dialog.add_response("back", "Go back") + dialog.add_response("anyway", "Restore without it") + dialog.set_response_appearance("anyway", Adw.ResponseAppearance.DESTRUCTIVE) + dialog.set_default_response("back") + dialog.connect("response", lambda _dialog, answer: check(None) if answer == "anyway" else None) + dialog.present(view) + + buttons = [_button("Stop", lambda: view.replace([home(view)]))] + if restoring: + buttons.append(_button("I have no wallet record", without_record)) + elif problem: + buttons.append(_button("Show it again", lambda: _record_fingerprint(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 +1061,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 +1532,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..f94993f 100644 --- a/src/codex32_gui/wallet_setup.py +++ b/src/codex32_gui/wallet_setup.py @@ -24,12 +24,19 @@ from typing import Literal from codex32 import MasterSeed -from codex32._bitcoin_core import _CHAINS, BitcoinCore, BitcoinCoreError +from codex32._bitcoin_core import ( + _CHAINS, + BitcoinCore, + BitcoinCoreError, + FingerprintMismatch, + parse_fingerprint, +) __all__ = [ "UNLOCK_SECONDS", "BitcoinCore", "BitcoinCoreError", + "FingerprintMismatch", "Offer", "Wallet", "connect", @@ -40,9 +47,11 @@ "fingerprint_provider", "initialize", "network", + "parse_fingerprint", "relock", "require_unlocked", "unlock", + "verify", "version_text", ] @@ -140,6 +149,11 @@ def fingerprint(core: BitcoinCore, secret: MasterSeed) -> str: return core.fingerprint(secret).hex() +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 +279,20 @@ def initialize( secret: MasterSeed, name: str, *, + expected_fingerprint: 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_fingerprint, + account=account, + timestamp=timestamp, + ) def fill( @@ -279,6 +301,7 @@ def fill( name: str, passphrase: str, *, + expected_fingerprint: bytes | None, account: int = 0, timestamp: int | Literal["now"] = "now", ) -> str: @@ -290,9 +313,23 @@ 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_fingerprint=expected_fingerprint, + account=account, + timestamp=timestamp, + ) unlock(core, name, passphrase) try: - return initialize(core, secret, name, account=account, timestamp=timestamp) + return initialize( + core, + secret, + name, + expected_fingerprint=expected_fingerprint, + account=account, + timestamp=timestamp, + ) finally: relock(core, name) diff --git a/tests/test_bitcoin_core.py b/tests/test_bitcoin_core.py index 7ea5c05..44946d7 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,10 @@ import pytest -from codex32._bitcoin_core import BitcoinCore, BitcoinCoreError +from codex32._bitcoin_core import BitcoinCore, BitcoinCoreError, FingerprintMismatch, parse_fingerprint +from codex32.bech32 import _u5_to_chars, convertbits 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 +40,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 +390,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 +433,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 +461,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 +493,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 +517,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 +538,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 +567,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 +592,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 +621,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 +647,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 +665,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 +693,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 +716,44 @@ 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) + + +@pytest.mark.parametrize("expected", (bytes.fromhex("3f3521a7"), None)) +def test_identity_mismatch_stops_before_any_wallet_call( + expected: bytes | None, + 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=expected + ) + assert rpc.calls == [] + + +def test_without_a_record_only_seed_derived_identifiers_pass() -> None: + client = BitcoinCore("bitcoin-cli", "main", 300000) + seed = _SEED.seed_bytes + legacy = _u5_to_chars(tuple(convertbits(hashlib.new("ripemd160", seed).digest(), 8, 5, pad=True)[:4])) + + for identifier in (_fingerprint_identifier(_FINGERPRINT), legacy): + client.verify_identity(MasterSeed.from_seed(seed, identifier=identifier), None) + with pytest.raises(FingerprintMismatch, match="without the wallet record"): + client.verify_identity(_SEED, None) diff --git a/tests/test_cli.py b/tests/test_cli.py index 5a2157e..2c3c5e1 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, FingerprintMismatch 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 + + +@pytest.fixture(autouse=True) +def _matching_record(monkeypatch): + """Answer the wallet-record prompt correctly; its own behavior is tested directly below.""" + monkeypatch.setattr( + "codex32.cli._recorded_fingerprint", lambda core, secret, _fresh: core.fingerprint(secret) + ) + + 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,67 @@ 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, False) == 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_needs_a_seed_derived_identifier(monkeypatch: pytest.MonkeyPatch) -> None: + core, secret = _FakeBitcoinCore(), parse_codex32(VECTOR_1["secret_s"]) + assert isinstance(secret, MasterSeed) + derived = MasterSeed.from_seed( + secret.seed_bytes, identifier=_fingerprint_identifier(core.fingerprint(secret)) + ) + + _record_answers(monkeypatch, "", "n", "", "y") + assert _RECORDED_FINGERPRINT(core, derived, False) is None + _record_answers(monkeypatch, "", "yes") + with pytest.raises(FingerprintMismatch, match="without the wallet record"): + _RECORDED_FINGERPRINT(core, secret, False) + + +def test_fresh_record_is_typed_back_and_shown_again_after_a_mismatch( + 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, "", "", wrong.hex(), "", right.hex()) + + assert _RECORDED_FINGERPRINT(core, secret, True) == right + assert prompts == [ + "Write it on the wallet record, then press Enter", + "Type the master fingerprint from your wallet record", + "Type the master fingerprint from your wallet record", + "Check the wallet record against it, then press Enter", + "Type the master fingerprint from your wallet record", + ] + errors = capsys.readouterr().err + assert errors.count(f"Master fingerprint: {right.hex().upper()}") == 2 + assert "8 characters" in errors and "does not match" in errors diff --git a/tests/test_gui_boundaries.py b/tests/test_gui_boundaries.py index cc7c5eb..8cc1ad5 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,11 @@ def test_the_gui_keeps_its_own_size_budget() -> None: for path in _modules() } assert sum(counts.values()) < BUDGET, counts + + +def test_every_way_to_a_wallet_passes_the_wallet_record_check() -> None: + tree = ast.parse((_package() / "pages.py").read_text()) + + assert _callers(tree, "_wallets") == {"_fingerprint_page", "_wallet_page"} + assert _callers(tree, "_fingerprint_page") == {"_record_fingerprint", "_restore", "_fingerprint_page"} + assert _callers(tree, "_record_fingerprint") == {"_unshared_page", "_card_confirmed", "_fingerprint_page"} diff --git a/tests/test_gui_wallet_setup.py b/tests/test_gui_wallet_setup.py index 713d145..c78fe1f 100644 --- a/tests/test_gui_wallet_setup.py +++ b/tests/test_gui_wallet_setup.py @@ -253,7 +253,7 @@ 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_fingerprint=None) assert fake.called("walletlock") assert fake.wallets["fresh"].locked @@ -263,7 +263,7 @@ def test_an_unlock_is_not_attempted_when_no_passphrase_was_given( ) -> 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_fingerprint=None) == "fresh" assert not fake.called("walletpassphrase") assert not fake.called("walletlock") From a3ca26122a204b80c513cd8ef51af65d8a9973fd Mon Sep 17 00:00:00 2001 From: Ben Westgate Date: Thu, 24 Sep 2026 16:20:42 -0500 Subject: [PATCH 2/8] Update Core integration callers Pass the recorded fingerprint through the real Bitcoin Core regtest and smoke harnesses after initialize() made identity verification mandatory.\n\nValidation: Ruff; mypy on both tools; 7 focused identity/CLI tests; git diff --check. The branch also passed 978 normal and 978 optimized tests before this tool-only fix.\n\nrefs #26 --- tools/bitcoin_core_main_smoke.py | 3 +++ tools/bitcoin_core_regtest.py | 4 ++++ 2 files changed, 7 insertions(+) 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", ) From 819bd468a9f81ce6b0f84fc408bfdc3cab3f8934 Mon Sep 17 00:00:00 2001 From: Ben Westgate Date: Thu, 24 Sep 2026 17:15:20 -0500 Subject: [PATCH 3/8] wallet: Show and confirm a restore without a record Restoring without a wallet record no longer requires a seed-derived backup identifier. The operator sees the recovered fingerprint, whether the identifier was made from the seed (codex32 fingerprint, Bails RIPEMD-160, or its mid-2023 SHA-256 alpha, first three characters for Bails), and a shared warning, then chooses. Split codex32 backups have random identifiers and were otherwise unrecoverable without a record. Co-Authored-By: Claude Opus 5.5 --- docs/developer/api.md | 6 +- docs/security/invariants.md | 5 +- docs/security/model.md | 11 ++-- docs/user/gui.md | 7 ++- docs/user/guide.md | 3 +- src/codex32/_bitcoin_core.py | 60 +++++++++++++----- src/codex32/cli.py | 39 ++++++++---- src/codex32_gui/pages.py | 104 +++++++++++++++----------------- src/codex32_gui/wallet_setup.py | 40 +++++------- tests/test_bitcoin_core.py | 56 +++++++++++------ tests/test_cli.py | 24 +++++--- tests/test_gui_boundaries.py | 6 +- tests/test_gui_wallet_setup.py | 4 +- 13 files changed, 213 insertions(+), 152 deletions(-) diff --git a/docs/developer/api.md b/docs/developer/api.md index 54fbc39..9f9a6eb 100644 --- a/docs/developer/api.md +++ b/docs/developer/api.md @@ -642,9 +642,9 @@ their external/internal branches. The adapter then compares the exact eight active public descriptors against `listdescriptors`. It relocks wallets Core reports as encrypted. Before any of this, `initialize` calls `verify_identity` with the required `expected_fingerprint`: bytes typed from the wallet record -(read with `parse_fingerprint`), or `None` when there is no record, which accepts -only a seed-derived backup identifier. A mismatch raises `FingerprintMismatch` -before any wallet RPC. Master-fingerprint display is likewise delegated to Core: +(read with `parse_fingerprint`), or `None`, 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 249b23b..f616055 100644 --- a/docs/security/invariants.md +++ b/docs/security/invariants.md @@ -13,8 +13,9 @@ and evidence. 4. Wallet setup uses the original ceremony result or a validated recovered seed. `BitcoinCore.initialize` requires the master fingerprint typed from the wallet record and refuses a mismatch before any wallet is listed, created, - unlocked, or imported into. Without a record, the backup identifier must be - derived from the recovered seed. + unlocked, or imported into. Restoring without a record is an explicit + operator choice, made after seeing the recovered fingerprint and whether the + backup identifier was derived from the seed. 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 94c51ba..efbc978 100644 --- a/docs/security/model.md +++ b/docs/security/model.md @@ -276,11 +276,12 @@ statelessly, and a mismatch raises `FingerprintMismatch` before any wallet is listed, created, unlocked, or imported into. The prompt does not show the recovered value, so the operator compares by typing rather than by glancing. A new wallet shows its fingerprint once, then asks for it back from the written -record. An operator without a record may continue only when the backup identifier is -derived from the recovered seed: the codex32 fingerprint identifier or legacy -Bails' RIPEMD-160 seed identifier. Both checks catch mistakes such as wrong or -mixed cards; 32 bits, and 20 bits without a record, do not stop deliberately -replaced cards. +record. An operator without a record 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 +may then choose to restore anyway. The typed fingerprint catches mistakes such as +wrong or mixed cards. Neither it nor the identifier stops deliberately replaced +cards, but anyone able to replace a threshold of cards could already read them. 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 diff --git a/docs/user/gui.md b/docs/user/gui.md index 107b8bd..6eb22be 100644 --- a/docs/user/gui.md +++ b/docs/user/gui.md @@ -149,9 +149,10 @@ another one, or ask Bitcoin Core for a fresh blank wallet. 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** checks only that the -backup identifier comes from the recovered seed. That works for backups made by -Bails, but it cannot catch cards someone replaced on purpose. +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 diff --git a/docs/user/guide.md b/docs/user/guide.md index 0c39669..28af36b 100644 --- a/docs/user/guide.md +++ b/docs/user/guide.md @@ -231,7 +231,8 @@ its public wallet data with the separate wallet record. 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; then only a seed-derived backup identifier is accepted. + 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 diff --git a/src/codex32/_bitcoin_core.py b/src/codex32/_bitcoin_core.py index 6327399..90fbd59 100644 --- a/src/codex32/_bitcoin_core.py +++ b/src/codex32/_bitcoin_core.py @@ -47,15 +47,47 @@ def parse_fingerprint(text: str) -> bytes: return bytes.fromhex(compact) -def _seed_identifiers(seed: bytes, fingerprint: bytes) -> tuple[str, str]: - """Return the backup identifiers that only this seed produces. +NO_RECORD_WARNING = ( + "Without the wallet record, nothing can prove these cards are the wallet you expect. Compare the " + "fingerprint with any other place it was kept, 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 to this wallet. Someone who " + "replaced the cards can give their wallet a history too, so if you do not know what this wallet " + "should hold, have someone you trust check it first. 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 is normal for split backups made by " + "codex32, but 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." + ) - codex32 uses the first 20 bits of the BIP32 fingerprint. Bails' legacy - bails-wallet used the first 20 bits of RIPEMD-160 of the seed and offered - no way to change it. + +def identifier_origin(secret: MasterSeed, fingerprint: bytes) -> str | None: + """Name the program whose rule derived this backup's identifier from its seed, if any. + + codex32 uses the first 20 bits of the BIP32 fingerprint. Bails used the first + 20 bits of RIPEMD-160 of the seed (SHA-256 in its mid-2023 alpha) and checked + only the first three characters, leaving the fourth free for re-sharing. A + match catches mixed-up cards; it cannot catch cards replaced on purpose. """ - legacy = hashlib.new("ripemd160", seed).digest() - return _fingerprint_identifier(fingerprint), _u5_to_chars(tuple(convertbits(legacy, 8, 5, pad=True)[:4])) + identifier = secret.header.identifier + if identifier == _fingerprint_identifier(fingerprint): + return "codex32" + for name, digest in (("Bails", "ripemd160"), ("Bails alpha", "sha256")): + derived = convertbits(hashlib.new(digest, secret.seed_bytes).digest(), 8, 5, pad=True) + if identifier[:3] == _u5_to_chars(tuple(derived[:3])): + return name + return None @dataclass(frozen=True) @@ -184,17 +216,13 @@ def verify_identity(self, secret: MasterSeed, expected_fingerprint: bytes | None """Refuse a recovered seed that is not the recorded wallet, before any wallet is touched. `expected_fingerprint` is what the operator typed from the wallet record. - `None` means there is no record: the backup identifier must then be one - derived from this seed, which catches mistakes but not replaced cards. + `None` is the operator's explicit choice to restore without a record, + made after being shown the recovered fingerprint and `identifier_origin`; + nothing is checked then. """ - fingerprint = self.fingerprint(secret) if expected_fingerprint is None: - if secret.header.identifier not in _seed_identifiers(secret.seed_bytes, fingerprint): - raise FingerprintMismatch( - "This backup's identifier does not come from the recovered seed, so it cannot be " - "checked without the wallet record. Bitcoin Core was not changed." - ) - elif fingerprint != expected_fingerprint: + 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." diff --git a/src/codex32/cli.py b/src/codex32/cli.py index 0c86ac0..1a10d08 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, FingerprintMismatch, parse_fingerprint +from codex32._bitcoin_core import ( + NO_RECORD_WARNING, + BitcoinCore, + BitcoinCoreError, + FingerprintMismatch, + identifier_note, + identifier_origin, + parse_fingerprint, +) from codex32._cli_input import ( CorrectionDeclined, InteractiveConfirmationRequired, @@ -340,6 +348,15 @@ def _show_fingerprint(core: BitcoinCore, secret: MasterSeed, action: str) -> Non _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, fresh: bool) -> bytes | None: """Take the master fingerprint from the wallet record until the library accepts it.""" if fresh: @@ -347,22 +364,18 @@ def _recorded_fingerprint(core: BitcoinCore, secret: MasterSeed, fresh: bool) -> prompt = "Type the master fingerprint from your wallet record" + ("" if fresh else " (Enter if none)") while True: text = _text(prompt, optional=True) - expected: bytes | None = None - if text or fresh: - try: - expected = parse_fingerprint(text) - except ValueError as error: - _print(str(error), err=True) - continue - elif _text( - "Without the record, only the backup identifier can be checked. Continue? [y/N]", optional=True - ).lower() not in ("y", "yes"): + if not text and not fresh: + 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: - if expected is None: - raise _print(str(error), err=True) if fresh: _show_fingerprint(core, secret, "Check the wallet record against it") diff --git a/src/codex32_gui/pages.py b/src/codex32_gui/pages.py index 6a7feac..9a3b47c 100644 --- a/src/codex32_gui/pages.py +++ b/src/codex32_gui/pages.py @@ -76,11 +76,6 @@ "the beginning, so your balance and history are not complete until it has finished." ), ) -NO_RECORD = ( - "Without the record, codex32 can only check that the backup identifier comes from this seed. " - "That works for backups made by Bails and catches wrong or mixed-up cards, but not cards " - "someone replaced on purpose." -) CARDS_SAFE = ( "Your cards are unharmed and still recover this wallet. Nothing was written onto them and " "nothing about them changed. When Bitcoin Core is ready, choose \u201cRestore my wallet\u201d " @@ -652,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: _record_fingerprint(view, core, secret), + after=lambda: _identity(view, core, secret), cancel=lambda page: _abandon(view, page), ) @@ -696,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 - _record_fingerprint(view, core, secret) + _identity(view, core, secret) deliver = _then(view, page, follow, CARDS_SAFE) work.run(view, page, ceremony.finish, deliver) @@ -939,9 +934,7 @@ def _record( expected: bytes | None, passphrase: str = "", ) -> Record: - final = wallet_setup.fill( - core, secret, name, passphrase, expected_fingerprint=expected, timestamp=timestamp - ) + final = wallet_setup.fill(core, secret, name, passphrase, expected=expected, timestamp=timestamp) return Record( secret.header.identifier.upper(), final, @@ -951,34 +944,48 @@ def _record( ) -def _record_fingerprint(view: Adw.NavigationView, core: BitcoinCore, secret: MasterSeed) -> None: - """Have a new wallet's master fingerprint written on the wallet record, then typed back from it.""" +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(fingerprint: str) -> Adw.NavigationPage: - content = _column( - _title( - "Write this on your wallet record", - "Keep the record apart from every card. Restoring your wallet asks you to type this from it.", - ), - _rows( - "Wallet identity", - ( - ("Backup identifier", secret.header.identifier.upper()), - ("Master fingerprint", 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", "Next, type it back."), + _rows("Identity", shown), ), - ), + actions=_actions( + _button( + "I wrote it down", + lambda: _replace(view, _fingerprint_page(view, core, secret, "now")), + 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)) ) - written = _button( - "I wrote it down", - lambda: _replace(view, _fingerprint_page(view, core, secret, "now")), - style="suggested-action", + anyway = _button( + "Restore anyway", + lambda: _wallets(view, core, secret, 0, None, restoring=True), + style="destructive-action", ) - return _page("Wallet record", content, actions=_actions(written), can_pop=False) + return _page("No wallet record", content, actions=_actions(back, anyway), can_pop=False) - work.run( - view, page, lambda: wallet_setup.fingerprint(core, secret), _then(view, page, follow, CARDS_SAFE) - ) + work.run(view, page, lambda: wallet_setup.identity(core, secret), _then(view, page, follow, CARDS_SAFE)) def _fingerprint_page( @@ -996,7 +1003,12 @@ def _fingerprint_page( group.add(entered) status = _note(problem, "error" if problem else "") - def check(expected: bytes | None) -> None: + 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: @@ -1014,33 +1026,17 @@ def follow(mismatch: str) -> Adw.NavigationPage | None: work.run(view, page, job, _then(view, page, follow, CARDS_SAFE)) - def go() -> None: - try: - expected = wallet_setup.parse_fingerprint(entered.get_text()) - except ValueError as error: - _say(status, str(error), "error") - return - check(expected) - - def without_record() -> None: - dialog = Adw.AlertDialog(heading="Restore without a wallet record?", body=NO_RECORD) - dialog.add_response("back", "Go back") - dialog.add_response("anyway", "Restore without it") - dialog.set_response_appearance("anyway", Adw.ResponseAppearance.DESTRUCTIVE) - dialog.set_default_response("back") - dialog.connect("response", lambda _dialog, answer: check(None) if answer == "anyway" else None) - dialog.present(view) - buttons = [_button("Stop", lambda: view.replace([home(view)]))] if restoring: - buttons.append(_button("I have no wallet record", without_record)) + buttons.append( + _button("I have no wallet record", lambda: _identity(view, core, secret, restoring=True)) + ) elif problem: - buttons.append(_button("Show it again", lambda: _record_fingerprint(view, core, secret))) + 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.", + "Type the master fingerprint", "Copy it from the wallet record you keep apart from the cards." ), group, status, diff --git a/src/codex32_gui/wallet_setup.py b/src/codex32_gui/wallet_setup.py index f94993f..784a291 100644 --- a/src/codex32_gui/wallet_setup.py +++ b/src/codex32_gui/wallet_setup.py @@ -26,13 +26,17 @@ from codex32 import MasterSeed 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", @@ -45,6 +49,7 @@ "fill", "fingerprint", "fingerprint_provider", + "identity", "initialize", "network", "parse_fingerprint", @@ -149,6 +154,12 @@ 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) @@ -279,19 +290,14 @@ def initialize( secret: MasterSeed, name: str, *, - expected_fingerprint: bytes | None, + 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, - expected_fingerprint=expected_fingerprint, - account=account, - timestamp=timestamp, + secret, answer.ask, answer.tell, expected_fingerprint=expected, account=account, timestamp=timestamp ) @@ -301,7 +307,7 @@ def fill( name: str, passphrase: str, *, - expected_fingerprint: bytes | None, + expected: bytes | None, account: int = 0, timestamp: int | Literal["now"] = "now", ) -> str: @@ -313,23 +319,9 @@ def fill( covers the whole sequence; locking an already locked wallet is harmless. """ if not passphrase: - return initialize( - core, - secret, - name, - expected_fingerprint=expected_fingerprint, - account=account, - timestamp=timestamp, - ) + return initialize(core, secret, name, expected=expected, account=account, timestamp=timestamp) unlock(core, name, passphrase) try: - return initialize( - core, - secret, - name, - expected_fingerprint=expected_fingerprint, - 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 44946d7..4383709 100644 --- a/tests/test_bitcoin_core.py +++ b/tests/test_bitcoin_core.py @@ -2,7 +2,6 @@ from __future__ import annotations -import hashlib import json import re import subprocess @@ -10,8 +9,14 @@ import pytest -from codex32._bitcoin_core import BitcoinCore, BitcoinCoreError, FingerprintMismatch, parse_fingerprint -from codex32.bech32 import _u5_to_chars, convertbits +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 @@ -729,11 +734,7 @@ def test_parse_fingerprint_rejects_other_text(text: str) -> None: parse_fingerprint(text) -@pytest.mark.parametrize("expected", (bytes.fromhex("3f3521a7"), None)) -def test_identity_mismatch_stops_before_any_wallet_call( - expected: bytes | None, - monkeypatch: pytest.MonkeyPatch, -) -> None: +def test_identity_mismatch_stops_before_any_wallet_call(monkeypatch: pytest.MonkeyPatch) -> None: rpc = _ImportRPC(locked=False) monkeypatch.setattr( BitcoinCore, @@ -743,17 +744,38 @@ def test_identity_mismatch_stops_before_any_wallet_call( 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=expected + _SEED, + lambda _prompt: "yes", + lambda _message: None, + expected_fingerprint=bytes.fromhex("3f3521a7"), ) assert rpc.calls == [] -def test_without_a_record_only_seed_derived_identifiers_pass() -> None: - client = BitcoinCore("bitcoin-cli", "main", 300000) - seed = _SEED.seed_bytes - legacy = _u5_to_chars(tuple(convertbits(hashlib.new("ripemd160", seed).digest(), 8, 5, pad=True)[:4])) +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) + - for identifier in (_fingerprint_identifier(_FINGERPRINT), legacy): - client.verify_identity(MasterSeed.from_seed(seed, identifier=identifier), None) - with pytest.raises(FingerprintMismatch, match="without the wallet record"): - client.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) diff --git a/tests/test_cli.py b/tests/test_cli.py index 2c3c5e1..00e21f1 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -32,7 +32,7 @@ parse_codex32, recover_secret, ) -from codex32._bitcoin_core import BitcoinCore, FingerprintMismatch +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 @@ -2798,18 +2798,24 @@ def test_restore_record_prompt_retries_until_the_library_accepts( assert right.hex() not in errors.lower() -def test_restore_without_a_record_needs_a_seed_derived_identifier(monkeypatch: pytest.MonkeyPatch) -> None: +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) - derived = MasterSeed.from_seed( - secret.seed_bytes, identifier=_fingerprint_identifier(core.fingerprint(secret)) - ) + fingerprint = core.fingerprint(secret) - _record_answers(monkeypatch, "", "n", "", "y") - assert _RECORDED_FINGERPRINT(core, derived, False) is None + prompts = _record_answers(monkeypatch, "", "n", "", "y") + assert _RECORDED_FINGERPRINT(core, secret, False) 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") - with pytest.raises(FingerprintMismatch, match="without the wallet record"): - _RECORDED_FINGERPRINT(core, secret, False) + assert _RECORDED_FINGERPRINT(core, derived, False) is None + assert "matches this seed (codex32 rule)" in capsys.readouterr().err def test_fresh_record_is_typed_back_and_shown_again_after_a_mismatch( diff --git a/tests/test_gui_boundaries.py b/tests/test_gui_boundaries.py index 8cc1ad5..0b97bfc 100644 --- a/tests/test_gui_boundaries.py +++ b/tests/test_gui_boundaries.py @@ -132,6 +132,6 @@ def test_the_gui_keeps_its_own_size_budget() -> None: def test_every_way_to_a_wallet_passes_the_wallet_record_check() -> None: tree = ast.parse((_package() / "pages.py").read_text()) - assert _callers(tree, "_wallets") == {"_fingerprint_page", "_wallet_page"} - assert _callers(tree, "_fingerprint_page") == {"_record_fingerprint", "_restore", "_fingerprint_page"} - assert _callers(tree, "_record_fingerprint") == {"_unshared_page", "_card_confirmed", "_fingerprint_page"} + 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"} diff --git a/tests/test_gui_wallet_setup.py b/tests/test_gui_wallet_setup.py index c78fe1f..b048d53 100644 --- a/tests/test_gui_wallet_setup.py +++ b/tests/test_gui_wallet_setup.py @@ -253,7 +253,7 @@ 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, expected_fingerprint=None) + wallet_setup.fill(core, _SEED, "fresh", PASSPHRASE, expected=None) assert fake.called("walletlock") assert fake.wallets["fresh"].locked @@ -263,7 +263,7 @@ def test_an_unlock_is_not_attempted_when_no_passphrase_was_given( ) -> None: core, fake = _client(monkeypatch, {"fresh": _Wallet()}) monkeypatch.setattr(wallet_setup, "initialize", lambda *_a, **_k: "fresh") - assert wallet_setup.fill(core, _SEED, "fresh", "", expected_fingerprint=None) == "fresh" + assert wallet_setup.fill(core, _SEED, "fresh", "", expected=None) == "fresh" assert not fake.called("walletpassphrase") assert not fake.called("walletlock") From 64e6b96d197f46f998c2a40dc96b58242278dd16 Mon Sep 17 00:00:00 2001 From: Ben Westgate Date: Thu, 24 Sep 2026 17:27:57 -0500 Subject: [PATCH 4/8] docs: Match the library gate's control row Use the same recovery-identity row in the Bitcoin Core controls table as the reviewability-v1 change, and keep only the window's specifics in the graphical section. Trim the gate's docstrings to the library size budget. Co-Authored-By: Claude Opus 5.5 --- docs/developer/api.md | 3 ++- docs/security/model.md | 18 +++++------------- src/codex32/_bitcoin_core.py | 30 ++++++++++-------------------- 3 files changed, 17 insertions(+), 34 deletions(-) diff --git a/docs/developer/api.md b/docs/developer/api.md index 9f9a6eb..23b4b78 100644 --- a/docs/developer/api.md +++ b/docs/developer/api.md @@ -644,7 +644,8 @@ reports as encrypted. Before any of this, `initialize` calls `verify_identity` with the required `expected_fingerprint`: bytes typed from the wallet record (read with `parse_fingerprint`), or `None`, 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 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/model.md b/docs/security/model.md index efbc978..58e1dbd 100644 --- a/docs/security/model.md +++ b/docs/security/model.md @@ -228,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 | Before any wallet is listed, `initialize` requires the master fingerprint typed from the wallet record. Core derives the recovered fingerprint statelessly, and a mismatch raises `FingerprintMismatch` before any wallet RPC. The prompt does not show the recovered value, so the operator compares by typing. A new wallet's fingerprint is shown once and typed back from the written 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. 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. | @@ -269,19 +270,10 @@ 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. -Every import, in the window and on the command line, goes through -`BitcoinCore.initialize`, which requires the master fingerprint the operator -typed from the wallet record. Core derives the recovered fingerprint -statelessly, and a mismatch raises `FingerprintMismatch` before any wallet is -listed, created, unlocked, or imported into. The prompt does not show the -recovered value, so the operator compares by typing rather than by glancing. A -new wallet shows its fingerprint once, then asks for it back from the written -record. An operator without a record 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 -may then choose to restore anyway. The typed fingerprint catches mistakes such as -wrong or mixed cards. Neither it nor the identifier stops deliberately replaced -cards, but anyone able to replace a threshold of cards could already read them. +The window uses the same recovery-identity gate. Its restore page asks for the +fingerprint without showing it, a new wallet's fingerprint is shown once and then +typed back, and **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 diff --git a/src/codex32/_bitcoin_core.py b/src/codex32/_bitcoin_core.py index 90fbd59..b88d769 100644 --- a/src/codex32/_bitcoin_core.py +++ b/src/codex32/_bitcoin_core.py @@ -49,12 +49,11 @@ def parse_fingerprint(text: str) -> bytes: NO_RECORD_WARNING = ( "Without the wallet record, nothing can prove these cards are the wallet you expect. Compare the " - "fingerprint with any other place it was kept, 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 to this wallet. Someone who " - "replaced the cards can give their wallet a history too, so if you do not know what this wallet " - "should hold, have someone you trust check it first. Once you are sure, write the fingerprint on a " - "new wallet record." + "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." ) @@ -73,12 +72,10 @@ def identifier_note(origin: str | None) -> str: def identifier_origin(secret: MasterSeed, fingerprint: bytes) -> str | None: - """Name the program whose rule derived this backup's identifier from its seed, if any. + """Name the rule that derived this backup's identifier from its seed, if any. - codex32 uses the first 20 bits of the BIP32 fingerprint. Bails used the first - 20 bits of RIPEMD-160 of the seed (SHA-256 in its mid-2023 alpha) and checked - only the first three characters, leaving the fourth free for re-sharing. A - match catches mixed-up cards; it cannot catch cards replaced on purpose. + codex32 uses the BIP32 fingerprint. Bails used RIPEMD-160 of the seed (SHA-256 + in its mid-2023 alpha) and checked three characters, keeping the fourth for re-sharing. """ identifier = secret.header.identifier if identifier == _fingerprint_identifier(fingerprint): @@ -215,10 +212,7 @@ def fingerprint(self, secret: MasterSeed) -> 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. - `expected_fingerprint` is what the operator typed from the wallet record. - `None` is the operator's explicit choice to restore without a record, - made after being shown the recovered fingerprint and `identifier_origin`; - nothing is checked then. + `None` is the operator's explicit choice to restore without a record; nothing is checked. """ if expected_fingerprint is None: return @@ -378,11 +372,7 @@ def initialize( account: int = 0, timestamp: int | Literal["now"] = "now", ) -> str: - """Import the recovered keys into one empty wallet the operator chooses. - - The wallet record is checked first, so a wrong seed is refused before any - wallet is listed, created, unlocked or imported into. - """ + """Check the wallet record, then import the keys into one empty wallet the operator chooses.""" self.verify_identity(secret, expected_fingerprint) while True: name = self._select(ask, tell) From e1180b765dc84e24e296424f98523b92fde54807 Mon Sep 17 00:00:00 2001 From: Ben Westgate Date: Fri, 25 Sep 2026 08:15:47 -0500 Subject: [PATCH 5/8] gui: Verify identity before wallet changes Repeat the recovery identity check immediately before GUI unlock and wallet creation so a stale or inconsistent Core response cannot mutate wallet state first. Treat unavailable RIPEMD-160 as a legacy-rule miss and keep recordless identifier guidance accurate.\n\nSecurity: enforces the verify-before-mutate wallet invariant across GUI-only passphrase and creation paths.\n\nValidation: 986 pytest tests in normal and optimized modes; focused GUI/identifier tests; Ruff; format check; mypy; git diff --check. --- src/codex32/_bitcoin_core.py | 12 ++++++++---- src/codex32_gui/pages.py | 1 + src/codex32_gui/wallet_setup.py | 1 + tests/test_bitcoin_core.py | 21 +++++++++++++++++++++ tests/test_gui_boundaries.py | 16 ++++++++++++++++ tests/test_gui_wallet_setup.py | 15 ++++++++++++++- 6 files changed, 61 insertions(+), 5 deletions(-) diff --git a/src/codex32/_bitcoin_core.py b/src/codex32/_bitcoin_core.py index b88d769..3c0097a 100644 --- a/src/codex32/_bitcoin_core.py +++ b/src/codex32/_bitcoin_core.py @@ -61,9 +61,9 @@ 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 is normal for split backups made by " - "codex32, but Bails made every identifier from its seed, so for a Bails backup these are the " - "wrong or mixed-up cards." + "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, " @@ -81,7 +81,11 @@ def identifier_origin(secret: MasterSeed, fingerprint: bytes) -> str | None: if identifier == _fingerprint_identifier(fingerprint): return "codex32" for name, digest in (("Bails", "ripemd160"), ("Bails alpha", "sha256")): - derived = convertbits(hashlib.new(digest, secret.seed_bytes).digest(), 8, 5, pad=True) + 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 diff --git a/src/codex32_gui/pages.py b/src/codex32_gui/pages.py index 9a3b47c..a3dee38 100644 --- a/src/codex32_gui/pages.py +++ b/src/codex32_gui/pages.py @@ -807,6 +807,7 @@ def make(passphrase: str) -> None: page = _working(view, "Bitcoin Core", "Creating the wallet and writing your keys into it…") def job() -> Record: + wallet_setup.verify(core, secret, expected) wallet_setup.create(core, chosen, passphrase) return _record(core, secret, chosen, timestamp, expected, passphrase) diff --git a/src/codex32_gui/wallet_setup.py b/src/codex32_gui/wallet_setup.py index 784a291..11abc2c 100644 --- a/src/codex32_gui/wallet_setup.py +++ b/src/codex32_gui/wallet_setup.py @@ -320,6 +320,7 @@ def fill( """ if not passphrase: 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, expected=expected, account=account, timestamp=timestamp) diff --git a/tests/test_bitcoin_core.py b/tests/test_bitcoin_core.py index 4383709..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 @@ -779,3 +780,23 @@ def test_identifier_origin_names_the_rule_that_made_it(identifier: str, origin: 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_gui_boundaries.py b/tests/test_gui_boundaries.py index 0b97bfc..2c47226 100644 --- a/tests/test_gui_boundaries.py +++ b/tests/test_gui_boundaries.py @@ -135,3 +135,19 @@ def test_every_way_to_a_wallet_passes_the_wallet_record_check() -> None: 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_new_wallet_is_verified_before_creation() -> 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" + ) + calls = [ + f"{node.func.value.id}.{node.func.attr}" + for node in ast.walk(new_wallet) + if isinstance(node, ast.Call) + and isinstance(node.func, ast.Attribute) + and isinstance(node.func.value, ast.Name) + and node.func.value.id == "wallet_setup" + ] + assert calls.index("wallet_setup.verify") < calls.index("wallet_setup.create") diff --git a/tests/test_gui_wallet_setup.py b/tests/test_gui_wallet_setup.py index b048d53..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") @@ -258,6 +258,19 @@ def refuse(*_arguments: object, **_keywords: object) -> str: 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: From 9ded11f9847fdcc9117ff0583c2a037a49a79c61 Mon Sep 17 00:00:00 2001 From: Ben Westgate Date: Fri, 25 Sep 2026 08:39:31 -0500 Subject: [PATCH 6/8] wallet: Authenticate restore, not creation Keep the wallet-record fingerprint gate on ms32 wallet restores, while ms32 create only requires the operator to record the new fingerprint. Fresh creation has no pre-existing wallet identity to authenticate.\n\nValidation: 886 tests in normal and optimized modes; Ruff; format check; mypy; git diff --check. --- docs/developer/api.md | 10 +++++--- docs/security/invariants.md | 11 ++++---- docs/security/model.md | 6 ++--- docs/user/guide.md | 3 ++- src/codex32/_bitcoin_core.py | 2 +- src/codex32/cli.py | 20 ++++++++------- tests/test_cli.py | 50 +++++++++++++++++++++--------------- 7 files changed, 58 insertions(+), 44 deletions(-) diff --git a/docs/developer/api.md b/docs/developer/api.md index 23b4b78..6c7c801 100644 --- a/docs/developer/api.md +++ b/docs/developer/api.md @@ -641,10 +641,12 @@ 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. Before any of this, `initialize` calls `verify_identity` -with the required `expected_fingerprint`: bytes typed from the wallet record -(read with `parse_fingerprint`), or `None`, 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. +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 diff --git a/docs/security/invariants.md b/docs/security/invariants.md index f616055..d565050 100644 --- a/docs/security/invariants.md +++ b/docs/security/invariants.md @@ -11,11 +11,12 @@ 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. - `BitcoinCore.initialize` requires the master fingerprint typed from the - wallet record and refuses a mismatch before any wallet is listed, created, - unlocked, or imported into. Restoring without a record is an explicit - operator choice, made after seeing the recovered fingerprint and whether the - backup identifier was derived from the 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 `ms32 create` ceremonies do not authenticate against a + pre-existing wallet; they require the operator to record the new fingerprint. 5. Correction shares one mass bound and deadline across target lengths. The public API fails closed on incomplete required work; CLI searches may return one primary-best-so-far eligible candidate at the deadline. Incomplete diff --git a/docs/security/model.md b/docs/security/model.md index 58e1dbd..5b21614 100644 --- a/docs/security/model.md +++ b/docs/security/model.md @@ -46,8 +46,8 @@ The operator must: - protect recovery cards and store shared cards in different trusted places; - confirm every newly recorded secret or share; - keep wallet records separate from shares, type the master fingerprint from - the record before import, and compare addresses, account, policy, and history - with those records; + 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, @@ -228,7 +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 | Before any wallet is listed, `initialize` requires the master fingerprint typed from the wallet record. Core derives the recovered fingerprint statelessly, and a mismatch raises `FingerprintMismatch` before any wallet RPC. The prompt does not show the recovered value, so the operator compares by typing. A new wallet's fingerprint is shown once and typed back from the written 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. 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` 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. `ms32 create` 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. | diff --git a/docs/user/guide.md b/docs/user/guide.md index 28af36b..0ccdc21 100644 --- a/docs/user/guide.md +++ b/docs/user/guide.md @@ -169,7 +169,8 @@ wallet should be trusted until initialization completes. ### 4. Complete the record and store the cards Before the wallet is filled, write the displayed master fingerprint on the -wallet record and type it back from what you wrote. Then copy the displayed +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 diff --git a/src/codex32/_bitcoin_core.py b/src/codex32/_bitcoin_core.py index 3c0097a..f7cf279 100644 --- a/src/codex32/_bitcoin_core.py +++ b/src/codex32/_bitcoin_core.py @@ -376,7 +376,7 @@ def initialize( account: int = 0, timestamp: int | Literal["now"] = "now", ) -> str: - """Check the wallet record, then import the keys into one empty wallet the operator chooses.""" + """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) diff --git a/src/codex32/cli.py b/src/codex32/cli.py index 1a10d08..27e58a9 100644 --- a/src/codex32/cli.py +++ b/src/codex32/cli.py @@ -357,14 +357,12 @@ def _without_record(core: BitcoinCore, secret: MasterSeed) -> bool: return _text("Restore without a wallet record? [y/N]", optional=True).lower() in ("y", "yes") -def _recorded_fingerprint(core: BitcoinCore, secret: MasterSeed, fresh: bool) -> bytes | None: - """Take the master fingerprint from the wallet record until the library accepts it.""" - if fresh: - _show_fingerprint(core, secret, "Write it on the wallet record") - prompt = "Type the master fingerprint from your wallet record" + ("" if fresh else " (Enter if none)") +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 and not fresh: + if not text: if _without_record(core, secret): return None continue @@ -377,8 +375,6 @@ def _recorded_fingerprint(core: BitcoinCore, secret: MasterSeed, fresh: bool) -> core.verify_identity(secret, expected) except FingerprintMismatch as error: _print(str(error), err=True) - if fresh: - _show_fingerprint(core, secret, "Check the wallet record against it") continue return expected @@ -390,13 +386,18 @@ 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) - expected = _recorded_fingerprint(core, secret, fresh) + 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), @@ -649,6 +650,7 @@ def _bitcoin_core(account: int, timestamp: int | Literal["now"]) -> int: account=account, timestamp=timestamp, fresh=False, + restore=True, confirmed=False, ) diff --git a/tests/test_cli.py b/tests/test_cli.py index 00e21f1..f3680c8 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -132,14 +132,14 @@ def _offline_core(monkeypatch): _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): - """Answer the wallet-record prompt correctly; its own behavior is tested directly below.""" - monkeypatch.setattr( - "codex32.cli._recorded_fingerprint", lambda core, secret, _fresh: core.fingerprint(secret) - ) + """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: @@ -2791,7 +2791,7 @@ def test_restore_record_prompt_retries_until_the_library_accepts( wrong = bytes([right[0] ^ 1]) + right[1:] prompts = _record_answers(monkeypatch, "not hex", wrong.hex(), right.hex().upper()) - assert _RECORDED_FINGERPRINT(core, secret, False) == right + 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 @@ -2806,7 +2806,7 @@ def test_restore_without_a_record_shows_what_the_cards_say_and_asks( fingerprint = core.fingerprint(secret) prompts = _record_answers(monkeypatch, "", "n", "", "y") - assert _RECORDED_FINGERPRINT(core, secret, False) is None + 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 @@ -2814,27 +2814,35 @@ def test_restore_without_a_record_shows_what_the_cards_say_and_asks( derived = MasterSeed.from_seed(secret.seed_bytes, identifier=_fingerprint_identifier(fingerprint)) _record_answers(monkeypatch, "", "yes") - assert _RECORDED_FINGERPRINT(core, derived, False) is None + assert _RECORDED_FINGERPRINT(core, derived) is None assert "matches this seed (codex32 rule)" in capsys.readouterr().err -def test_fresh_record_is_typed_back_and_shown_again_after_a_mismatch( +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) - wrong = bytes([right[0] ^ 1]) + right[1:] - prompts = _record_answers(monkeypatch, "", "", wrong.hex(), "", right.hex()) + prompts = _record_answers(monkeypatch, "") - assert _RECORDED_FINGERPRINT(core, secret, True) == right - assert prompts == [ - "Write it on the wallet record, then press Enter", - "Type the master fingerprint from your wallet record", - "Type the master fingerprint from your wallet record", - "Check the wallet record against it, then press Enter", - "Type the master fingerprint from your wallet record", - ] - errors = capsys.readouterr().err - assert errors.count(f"Master fingerprint: {right.hex().upper()}") == 2 - assert "8 characters" in errors and "does not match" in errors + _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 From c5f89931a58657d316644636f720f73da7314472 Mon Sep 17 00:00:00 2001 From: Ben Westgate Date: Fri, 25 Sep 2026 08:49:39 -0500 Subject: [PATCH 7/8] gui: Authenticate restore, not fresh setup Fresh GUI setup now records the new fingerprint without treating it as evidence for a pre-existing wallet. Restore still verifies recorded identity immediately before creating a destination wallet.\n\nValidation: 987 tests in normal and optimized modes; focused GUI tests; Ruff; format check; mypy; git diff --check. --- docs/security/invariants.md | 5 +++-- docs/security/model.md | 11 ++++++----- docs/user/gui.md | 5 +++-- src/codex32_gui/pages.py | 7 ++++--- tests/test_gui_boundaries.py | 24 +++++++++++++----------- 5 files changed, 29 insertions(+), 23 deletions(-) diff --git a/docs/security/invariants.md b/docs/security/invariants.md index d565050..ffd83ee 100644 --- a/docs/security/invariants.md +++ b/docs/security/invariants.md @@ -15,8 +15,9 @@ and evidence. unlocked, or imported into: normally with the master fingerprint typed from the wallet record, or by an explicit no-record choice made after seeing the recovered fingerprint and whether the backup identifier was derived from the - seed. Fresh `ms32 create` ceremonies do not authenticate against a - pre-existing wallet; they require the operator to record the new fingerprint. + 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 5b21614..c14f9fd 100644 --- a/docs/security/model.md +++ b/docs/security/model.md @@ -228,7 +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 | `ms32 wallet` 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. `ms32 create` 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. | +| 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. | @@ -270,10 +270,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. -The window uses the same recovery-identity gate. Its restore page asks for the -fingerprint without showing it, a new wallet's fingerprint is shown once and then -typed back, and **I have no wallet record** shows the recovered fingerprint, the -identifier result, and the warning before the operator chooses. +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 diff --git a/docs/user/gui.md b/docs/user/gui.md index 6eb22be..019ebb4 100644 --- a/docs/user/gui.md +++ b/docs/user/gui.md @@ -68,8 +68,9 @@ 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, the window shows the master fingerprint. Write it -on your [wallet record](wallet-verification-record.html), then type it back from -what you wrote. That catches a writing mistake while it can still be fixed. +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 diff --git a/src/codex32_gui/pages.py b/src/codex32_gui/pages.py index a3dee38..67c2c2e 100644 --- a/src/codex32_gui/pages.py +++ b/src/codex32_gui/pages.py @@ -807,7 +807,8 @@ def make(passphrase: str) -> None: page = _working(view, "Bitcoin Core", "Creating the wallet and writing your keys into it…") def job() -> Record: - wallet_setup.verify(core, secret, expected) + if restoring: + wallet_setup.verify(core, secret, expected) wallet_setup.create(core, chosen, passphrase) return _record(core, secret, chosen, timestamp, expected, passphrase) @@ -958,13 +959,13 @@ def follow(identity: tuple[str, str]) -> Adw.NavigationPage: return _page( "Wallet record", _column( - _title("Write this on your wallet record", "Next, type it back."), + _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: _replace(view, _fingerprint_page(view, core, secret, "now")), + lambda: _wallets(view, core, secret, "now", None), style="suggested-action", ) ), diff --git a/tests/test_gui_boundaries.py b/tests/test_gui_boundaries.py index 2c47226..2687a49 100644 --- a/tests/test_gui_boundaries.py +++ b/tests/test_gui_boundaries.py @@ -129,7 +129,7 @@ def test_the_gui_keeps_its_own_size_budget() -> None: assert sum(counts.values()) < BUDGET, counts -def test_every_way_to_a_wallet_passes_the_wallet_record_check() -> None: +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"} @@ -137,17 +137,19 @@ def test_every_way_to_a_wallet_passes_the_wallet_record_check() -> None: assert _callers(tree, "_identity") == {"_unshared_page", "_card_confirmed", "_fingerprint_page"} -def test_new_wallet_is_verified_before_creation() -> None: +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" ) - calls = [ - f"{node.func.value.id}.{node.func.attr}" - for node in ast.walk(new_wallet) - if isinstance(node, ast.Call) - and isinstance(node.func, ast.Attribute) - and isinstance(node.func.value, ast.Name) - and node.func.value.id == "wallet_setup" - ] - assert calls.index("wallet_setup.verify") < calls.index("wallet_setup.create") + 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" From be8c243ef1c1190ffd3b821d78bf32759501a70a Mon Sep 17 00:00:00 2001 From: Ben Westgate Date: Sat, 26 Sep 2026 19:55:49 -0500 Subject: [PATCH 8/8] wallet: Check identity for existing-seed setup Apply the restore gate before importing an existing seed, whether it is confirmed unchanged or re-shared. Keep fresh setup as record-only confirmation and share the library identity diagnostics with the CLI. --- docs/developer/api.md | 2 ++ src/codex32/_bitcoin_core.py | 6 +----- src/codex32/cli.py | 8 ++++++-- tests/test_cli.py | 27 +++++++++++++++++++++++++++ 4 files changed, 36 insertions(+), 7 deletions(-) diff --git a/docs/developer/api.md b/docs/developer/api.md index 6c7c801..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 diff --git a/src/codex32/_bitcoin_core.py b/src/codex32/_bitcoin_core.py index f7cf279..4bfbed0 100644 --- a/src/codex32/_bitcoin_core.py +++ b/src/codex32/_bitcoin_core.py @@ -72,11 +72,7 @@ def identifier_note(origin: str | None) -> str: def identifier_origin(secret: MasterSeed, fingerprint: bytes) -> str | None: - """Name the rule that derived this backup's identifier from its seed, if any. - - codex32 uses the BIP32 fingerprint. Bails used RIPEMD-160 of the seed (SHA-256 - in its mid-2023 alpha) and checked three characters, keeping the fourth for re-sharing. - """ + """Check codex32's fingerprint or Bails' three-character seed-digest identifier.""" identifier = secret.header.identifier if identifier == _fingerprint_identifier(fingerprint): return "codex32" diff --git a/src/codex32/cli.py b/src/codex32/cli.py index 27e58a9..ac2149b 100644 --- a/src/codex32/cli.py +++ b/src/codex32/cli.py @@ -500,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 ) @@ -542,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 diff --git a/tests/test_cli.py b/tests/test_cli.py index f3680c8..8741929 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -2846,3 +2846,30 @@ def test_create_initialization_does_not_authenticate_against_a_preexisting_walle 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)