From d5b671a5c24bd94b1a0340d54fa6922f01979571 Mon Sep 17 00:00:00 2001 From: Codex Date: Sun, 4 Oct 2026 12:12:33 -0500 Subject: [PATCH] gui: Gate restores on wallet identity MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A checksum-valid recovered seed may not be the operator’s wallet. Ask for the independent record fingerprint before listing wallets, then carry it through existing and newly created destinations so a mismatch stops before unlock, create, or import. Keep the explicit no-record visual fallback, but do not allow its revealed fingerprint back into the recorded route in the same attempt. Raise the separately enforced GUI review cap to the authorized 2,250 lines for this gate. Keep the combined Python 3.10-3.15 package compatible with the GUI code and passphrase encoding check. Exercise the focused boundary and display walkthrough with synthetic data. Refs #26 and #28. --- docs/developer/api.md | 2 +- docs/developer/gui.md | 2 +- docs/security/model.md | 10 +++ docs/user/gui.md | 17 ++-- src/codex32_gui/pages.py | 143 ++++++++++++++++++++++++++++---- src/codex32_gui/wallet_setup.py | 40 +++++++-- src/codex32_gui/work.py | 8 +- tests/test_gui_boundaries.py | 40 ++++++++- tests/test_gui_wallet_setup.py | 27 +++++- tools/gui_walkthrough.py | 28 ++++++- 10 files changed, 282 insertions(+), 35 deletions(-) diff --git a/docs/developer/api.md b/docs/developer/api.md index 0b08640..2935a52 100644 --- a/docs/developer/api.md +++ b/docs/developer/api.md @@ -117,7 +117,7 @@ documentation and enforcement update. installed as `codex32[gui]` and started by `codex32-gui`. It is a client of the surface above and of the private Core adapter; nothing in `src/codex32/` imports it, and the base install keeps its property of having no third-party runtime -dependency. It carries its own budget of 2,050 logical review lines, separate +dependency. It carries its own budget of 2,250 logical review lines, separate from the 5,200 above. Its own boundaries are documented in [`gui.md`](gui.md) and enforced by `tests/test_gui_boundaries.py`. diff --git a/docs/developer/gui.md b/docs/developer/gui.md index 413ea47..06cc7f2 100644 --- a/docs/developer/gui.md +++ b/docs/developer/gui.md @@ -19,7 +19,7 @@ cryptography, entropy source, socket, or file storage. Review `reading.py`, `wallet_setup.py`, and `work.py` first. Their behavior is covered without a display. `tools/gui_walkthrough.py` exercises the real GTK -screens under Xvfb. `tests/test_gui_boundaries.py` enforces a separate 2,050 +screens under Xvfb. `tests/test_gui_boundaries.py` enforces a separate 2,250 logical-line GUI budget. ## Security boundaries diff --git a/docs/security/model.md b/docs/security/model.md index 402e054..12455ab 100644 --- a/docs/security/model.md +++ b/docs/security/model.md @@ -259,6 +259,16 @@ initialization. `codex32_gui/wallet_setup.py`, the only module in that package that imports the Core adapter. +Before listing wallets on restore, the GUI asks for the master fingerprint from +the separate wallet record without showing the recovered value. A mismatch +stops the attempt. The explicit no-record route reveals the recovered +fingerprint and backup-identifier assessment, then requires **Restore anyway**; +after that disclosure, this attempt cannot return to the record-entry route. +The chosen expected fingerprint is checked again before unlocking or creating +a destination and at the shared library import boundary. Fresh creation instead +shows its new fingerprint for the operator to record; there is no earlier +wallet identity to compare. + | Departure | Required behavior | |---|---| | Passphrase | The operator may supply a Bitcoin Core wallet passphrase. It reaches `bitcoin-cli` through `-stdinwalletpassphrase`, never through an argument, so it is absent from `/proc` and process listings. It is not stored, not logged, and not written to disk, and a passphrase containing a line break is refused rather than truncated. A passphrase this computer's locale would encode as something other than what Bitcoin-Qt sends is refused, so no half-encoded secret reaches a screen or a traceback. The screen keeps the command line's behavior as an alternative: the operator may unlock in Bitcoin-Qt instead, and the program then only rechecks wallet state. | diff --git a/docs/user/gui.md b/docs/user/gui.md index 3fe3ce4..a393b23 100644 --- a/docs/user/gui.md +++ b/docs/user/gui.md @@ -66,9 +66,11 @@ Copy each card to paper, hide the on-screen original, then type the paper copy back. Read-back starts completely empty, including `MS1`. A mismatch highlights only the groups you typed differently; the expected text stays hidden. -After all cards are confirmed, choose an empty Bitcoin Core wallet or create a -new blank one. A passphrase protects the wallet on this computer; the recovery -cards still recover the seed if that passphrase is lost. +After all cards are confirmed, write the displayed master fingerprint on your +wallet record and acknowledge that you have recorded it. Then choose an empty +Bitcoin Core wallet or create a new blank one. A passphrase protects the wallet +on this computer; the recovery cards still recover the seed if that passphrase +is lost. Copy the final wallet details to the [wallet record](wallet-verification-record.html) and store it separately from the @@ -103,8 +105,13 @@ exist. The GUI can say “any 2 cards recover the wallet”; it cannot infer “ 3”. `ms32 share` can add another card at any time. A valid checksum shows that a card is internally consistent. It does not prove -that the card belongs to your wallet. Restore and compare the master fingerprint -with your wallet record. +that the card belongs to your wallet. Before restoring into Bitcoin Core, type +the master fingerprint from your separate wallet record; a mismatch stops before +any wallet is opened or created. If you have no record, the GUI instead shows the +recovered fingerprint and what the backup identifier says about the seed, then +requires a separate **Restore anyway** choice. This fallback detects some +mistakes but does not authenticate the intended wallet; check its history and +addresses before sending funds. ## Secret handling diff --git a/src/codex32_gui/pages.py b/src/codex32_gui/pages.py index 2ff0c35..6b64302 100644 --- a/src/codex32_gui/pages.py +++ b/src/codex32_gui/pages.py @@ -10,7 +10,7 @@ import time from collections.abc import Callable, Sequence from dataclasses import dataclass -from typing import Literal +from typing import Literal, TypeVar from gi.repository import Adw, GLib, Gtk @@ -35,6 +35,7 @@ Artifact = Share | Secret Accept = Callable[[Artifact], None] Timestamp = int | Literal["now"] +Result = TypeVar("Result") LEVELS = ("dim-label", "error", "warning", "success") DONE_ICON = "object-select-symbolic" @@ -294,7 +295,7 @@ def _working(view: Adw.NavigationView, title: str, message: str) -> Adw.Navigati return page -def _then[Result]( +def _then( view: Adw.NavigationView, page: Adw.NavigationPage, follow: Callable[[Result], Adw.NavigationPage | None], @@ -687,7 +688,7 @@ def _unshared_page(view: Adw.NavigationView, core: BitcoinCore, secret: MasterSe position=0, count=1, confirm=lambda text: _compare(secret.text, text), - after=lambda: _wallets(view, core, secret, "now"), + after=lambda: _identity(view, core, secret), cancel=lambda page: _abandon(view, page), ) @@ -731,7 +732,7 @@ def follow(secret: MasterSeed | CoreLightningSecret) -> None: if not isinstance(secret, MasterSeed): _failure(view, "That ceremony did not produce a Bitcoin master seed.") return - _wallets(view, core, secret, "now") + _identity(view, core, secret) deliver = _then(view, page, follow, CARDS_SAFE) work.run(view, page, ceremony.finish, deliver) @@ -745,6 +746,7 @@ def _wallets( core: BitcoinCore, secret: MasterSeed, timestamp: Timestamp, + expected: bytes | None, *, restoring: bool = False, ) -> None: @@ -756,7 +758,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, ), ) @@ -768,6 +770,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.""" @@ -781,13 +784,13 @@ def go() -> None: if index < 0: return if index == len(current): - view.push(_new_wallet_page(view, core, secret, timestamp, restoring)) + view.push(_new_wallet_page(view, core, secret, timestamp, expected, restoring)) return chosen = current[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) continue_button = _button("Continue", go, style="suggested-action") @@ -876,6 +879,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.""" @@ -893,8 +897,10 @@ def make(passphrase: str) -> None: page = _working(view, "Bitcoin Core", "Creating the wallet and writing your keys into it…") def job() -> Record: + if restoring: + wallet_setup.verify(core, secret, expected) wallet_setup.create(core, chosen, passphrase) - return _record(core, secret, chosen, timestamp, passphrase) + return _record(core, secret, chosen, timestamp, expected, passphrase) work.run( view, @@ -948,6 +954,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.""" @@ -976,7 +983,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, @@ -986,7 +993,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( @@ -1013,9 +1020,14 @@ def go() -> None: def _record( - core: BitcoinCore, secret: MasterSeed, name: str, timestamp: Timestamp, passphrase: str = "" + core: BitcoinCore, + secret: MasterSeed, + name: str, + timestamp: Timestamp, + expected: bytes | None, + passphrase: str = "", ) -> Record: - final = wallet_setup.fill(core, secret, name, passphrase, timestamp=timestamp) + final = wallet_setup.fill(core, secret, name, passphrase, expected=expected, timestamp=timestamp) return Record( secret.header.identifier.upper(), final, @@ -1025,6 +1037,106 @@ def _record( ) +def _identity( + view: Adw.NavigationView, core: BitcoinCore, secret: MasterSeed, restoring: bool = False +) -> None: + """Show a fresh identity for recording, or a no-record restore for confirmation.""" + page = _working(view, "Wallet record", "Asking Bitcoin Core for the master fingerprint…") + + def follow(identity: tuple[str, str]) -> Adw.NavigationPage: + fingerprint, note = identity + shown = (("Backup identifier", secret.header.identifier.upper()), ("Master fingerprint", fingerprint)) + if not restoring: + return _page( + "Wallet record", + _column( + _title("Write this on your wallet record", "Keep the record apart from your cards."), + _rows("Identity", shown), + ), + actions=_actions( + _button( + "I wrote it down", + lambda: _wallets(view, core, secret, "now", None), + style="suggested-action", + ) + ), + can_pop=False, + ) + content = _column( + _title("Restore without a wallet record?", "Nothing here can prove these cards are your wallet."), + _rows("What the cards say", shown), + _note(note, "warning"), + _note(wallet_setup.NO_RECORD_WARNING, "warning"), + ) + stop = _button("Stop", lambda: view.replace([home(view)])) + anyway = _button( + "Restore anyway", + lambda: _wallets(view, core, secret, 0, None, restoring=True), + style="destructive-action", + ) + return _page("No wallet record", content, actions=_actions(stop, anyway), can_pop=False) + + work.run(view, page, lambda: wallet_setup.identity(core, secret), _then(view, page, follow, CARDS_SAFE)) + + +def _fingerprint_page( + view: Adw.NavigationView, + core: BitcoinCore, + secret: MasterSeed, + timestamp: Timestamp, + *, + restoring: bool = False, + problem: str = "", +) -> Adw.NavigationPage: + """Ask for the record's fingerprint before any restore wallet is listed.""" + entered = Adw.EntryRow(title="Master fingerprint from your wallet record") + group = Adw.PreferencesGroup() + group.add(entered) + status = _note(problem, "error" if problem else "") + + def go() -> None: + try: + expected = wallet_setup.parse_fingerprint(entered.get_text()) + except ValueError as error: + _say(status, str(error), "error") + return + page = _working(view, "Wallet record", "Checking the wallet record…") + + def job() -> str: + try: + wallet_setup.verify(core, secret, expected) + except wallet_setup.FingerprintMismatch as error: + return str(error) + return "" + + def follow(mismatch: str) -> Adw.NavigationPage | None: + if mismatch: + return _fingerprint_page(view, core, secret, timestamp, restoring=restoring, problem=mismatch) + _wallets(view, core, secret, timestamp, expected, restoring=restoring) + return None + + work.run(view, page, job, _then(view, page, follow, CARDS_SAFE)) + + buttons = [_button("Stop", lambda: view.replace([home(view)]))] + if restoring: + buttons.append( + _button("I have no wallet record", lambda: _identity(view, core, secret, restoring=True)) + ) + 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.", + "warning", + ), + ) + return _page("Wallet record", content, actions=_actions(*buttons), can_pop=False) + + def _import( view: Adw.NavigationView, core: BitcoinCore, @@ -1032,13 +1144,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), ) @@ -1536,7 +1649,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..7a745f1 100644 --- a/src/codex32_gui/wallet_setup.py +++ b/src/codex32_gui/wallet_setup.py @@ -24,12 +24,23 @@ from typing import Literal from codex32 import MasterSeed -from codex32._bitcoin_core import _CHAINS, BitcoinCore, BitcoinCoreError +from codex32._bitcoin_core import ( + _CHAINS, + NO_RECORD_WARNING, + BitcoinCore, + BitcoinCoreError, + FingerprintMismatch, + identifier_note, + identifier_origin, + parse_fingerprint, +) __all__ = [ + "NO_RECORD_WARNING", "UNLOCK_SECONDS", "BitcoinCore", "BitcoinCoreError", + "FingerprintMismatch", "Offer", "Wallet", "connect", @@ -38,11 +49,14 @@ "fill", "fingerprint", "fingerprint_provider", + "identity", "initialize", "network", + "parse_fingerprint", "relock", "require_unlocked", "unlock", + "verify", "version_text", ] @@ -140,6 +154,17 @@ def fingerprint(core: BitcoinCore, secret: MasterSeed) -> str: return core.fingerprint(secret).hex() +def identity(core: BitcoinCore, secret: MasterSeed) -> tuple[str, str]: + """Return the recovered fingerprint and the backup identifier's origin.""" + derived = core.fingerprint(secret) + return derived.hex(), identifier_note(identifier_origin(secret, derived)) + + +def verify(core: BitcoinCore, secret: MasterSeed, expected: bytes | None) -> None: + """Refuse a wrong recovered seed before listing or mutating any wallet.""" + 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 @@ -167,7 +192,7 @@ def _transferable(text: str, subject: str) -> None: Bitcoin-Qt sends UTF-8. Where those differ, a passphrase set or checked here would not be the one Bitcoin Core's own window sets or checks. """ - if not text.isascii() and codecs.lookup(locale.getencoding()).name != "utf-8": + if not text.isascii() and codecs.lookup(locale.getpreferredencoding(False)).name != "utf-8": raise BitcoinCoreError( f"This computer's text is not stored as UTF-8, so Bitcoin Core would receive a different " f"{subject} than the one you typed. Use unaccented letters, digits and punctuation." @@ -265,12 +290,15 @@ def initialize( secret: MasterSeed, name: str, *, + expected: bytes | None, account: int = 0, timestamp: int | Literal["now"] = "now", ) -> str: """Hand the library the wallet the operator named, and let it do the import.""" answer = _Answer(name, quoted=True) - return core.initialize(secret, answer.ask, answer.tell, account=account, timestamp=timestamp) + return core.initialize( + secret, answer.ask, answer.tell, expected_fingerprint=expected, account=account, timestamp=timestamp + ) def fill( @@ -279,6 +307,7 @@ def fill( name: str, passphrase: str, *, + expected: bytes | None, account: int = 0, timestamp: int | Literal["now"] = "now", ) -> str: @@ -290,9 +319,10 @@ def fill( covers the whole sequence; locking an already locked wallet is harmless. """ if not passphrase: - return initialize(core, secret, name, account=account, timestamp=timestamp) + return initialize(core, secret, name, expected=expected, account=account, timestamp=timestamp) + verify(core, secret, expected) unlock(core, name, passphrase) try: - return initialize(core, secret, name, account=account, timestamp=timestamp) + return initialize(core, secret, name, expected=expected, account=account, timestamp=timestamp) finally: relock(core, name) diff --git a/src/codex32_gui/work.py b/src/codex32_gui/work.py index 474dd54..bdfc1e6 100644 --- a/src/codex32_gui/work.py +++ b/src/codex32_gui/work.py @@ -4,6 +4,7 @@ import threading from collections.abc import Callable +from typing import TypeVar from gi.repository import Adw, GLib @@ -15,6 +16,7 @@ "Core, check there what state the wallet is in before trying again." ) _gate = threading.Lock() +Result = TypeVar("Result") def showing(view: Adw.NavigationView, page: Adw.NavigationPage) -> bool: @@ -28,7 +30,7 @@ def showing(view: Adw.NavigationView, page: Adw.NavigationPage) -> bool: return any(stack.get_item(position) is page for position in range(stack.get_n_items())) -def run[Result]( +def run( view: Adw.NavigationView, page: Adw.NavigationPage, work: Callable[[], Result], @@ -49,7 +51,7 @@ def run[Result]( _start(view, page, work, done, claimed=False, daemon=False) -def poll[Result]( +def poll( view: Adw.NavigationView, page: Adw.NavigationPage, work: Callable[[], Result], @@ -62,7 +64,7 @@ def poll[Result]( return True -def _start[Result]( +def _start( view: Adw.NavigationView, page: Adw.NavigationPage, work: Callable[[], Result], diff --git a/tests/test_gui_boundaries.py b/tests/test_gui_boundaries.py index bfad256..d0986b5 100644 --- a/tests/test_gui_boundaries.py +++ b/tests/test_gui_boundaries.py @@ -33,7 +33,7 @@ } ) CORE_ADAPTER = "codex32._bitcoin_core" -BUDGET = 2050 +BUDGET = 2250 def _package() -> Path: @@ -56,6 +56,19 @@ def _imports(tree: ast.AST) -> set[str]: return found +def _callers(tree: ast.Module, name: str) -> set[str]: + """Find top-level functions that call a named page, including callbacks.""" + 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(encoding="utf-8"))) @@ -117,6 +130,31 @@ def test_the_gui_keeps_its_own_size_budget() -> None: assert sum(counts.values()) < BUDGET, counts +def test_restore_reaches_wallet_selection_only_after_identity_choice() -> None: + tree = ast.parse((_package() / "pages.py").read_text(encoding="utf-8")) + assert _callers(tree, "_wallets") == {"_identity", "_fingerprint_page"} + assert _callers(tree, "_fingerprint_page") == {"_restore", "_fingerprint_page"} + assert _callers(tree, "_identity") == {"_unshared_page", "_card_confirmed", "_fingerprint_page"} + + +def test_restore_checks_identity_before_creating_a_destination() -> None: + tree = ast.parse((_package() / "pages.py").read_text(encoding="utf-8")) + new_wallet = next( + node for node in tree.body if isinstance(node, ast.FunctionDef) and node.name == "_new_wallet_page" + ) + job = next( + node for node in ast.walk(new_wallet) if isinstance(node, ast.FunctionDef) and node.name == "job" + ) + guard = job.body[0] + assert isinstance(guard, ast.If) and isinstance(guard.test, ast.Name) and guard.test.id == "restoring" + verify = guard.body[0] + assert isinstance(verify, ast.Expr) and isinstance(verify.value, ast.Call) + assert isinstance(verify.value.func, ast.Attribute) and verify.value.func.attr == "verify" + create = job.body[1] + assert isinstance(create, ast.Expr) and isinstance(create.value, ast.Call) + assert isinstance(create.value.func, ast.Attribute) and create.value.func.attr == "create" + + def test_read_only_poll_threads_do_not_keep_the_process_alive() -> None: source = (_package() / "work.py").read_text(encoding="utf-8") tree = ast.parse(source) diff --git a/tests/test_gui_wallet_setup.py b/tests/test_gui_wallet_setup.py index 86ef8c2..1db240b 100644 --- a/tests/test_gui_wallet_setup.py +++ b/tests/test_gui_wallet_setup.py @@ -10,7 +10,7 @@ import pytest from codex32 import MasterSeed, parse_codex32 -from codex32._bitcoin_core import BitcoinCore, BitcoinCoreError +from codex32._bitcoin_core import BitcoinCore, BitcoinCoreError, FingerprintMismatch from codex32_gui import wallet_setup @@ -219,6 +219,14 @@ def test_a_passphrase_that_cannot_survive_the_channel_is_refused( assert fake.runs == [] +def test_non_utf8_locale_refuses_accented_passphrase_before_core(monkeypatch: pytest.MonkeyPatch) -> None: + core, fake = _client(monkeypatch, {"fresh": _Wallet(True, True)}) + monkeypatch.setattr(wallet_setup.locale, "getpreferredencoding", lambda _do_setlocale: "cp1252") + with pytest.raises(BitcoinCoreError, match="UTF-8"): + wallet_setup.unlock(core, "fresh", "café") + assert fake.runs == [] + + def test_an_unlock_that_leaves_the_wallet_locked_is_reported(monkeypatch: pytest.MonkeyPatch) -> None: core, fake = _client(monkeypatch, {"fresh": _Wallet(True, True)}) original = _Core._reply @@ -260,7 +268,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=None) assert fake.called("walletlock") assert fake.wallets["fresh"].locked @@ -270,11 +278,24 @@ 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=None) == "fresh" assert not fake.called("walletpassphrase") assert not fake.called("walletlock") +def test_mismatched_record_stops_before_gui_unlock(monkeypatch: pytest.MonkeyPatch) -> None: + core, fake = _client(monkeypatch, {"fresh": _Wallet(True, True)}) + + def mismatch(_core: BitcoinCore, _secret: MasterSeed, _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"wrong") + assert not fake.called("walletpassphrase") + assert fake.wallets["fresh"].locked + + def test_the_chain_the_operator_chose_is_the_one_that_is_used( monkeypatch: pytest.MonkeyPatch, ) -> None: diff --git a/tools/gui_walkthrough.py b/tools/gui_walkthrough.py index 8c68ba9..e4ac8cc 100644 --- a/tools/gui_walkthrough.py +++ b/tools/gui_walkthrough.py @@ -150,6 +150,8 @@ def do_activate(self) -> None: self.wallet_poll_retries, self.wallet_poll_disappears, self.wallet_poll_stops, + self.restore_record_gate, + self.restore_no_record, self.letters, self.basis, self.second_card, @@ -512,7 +514,7 @@ def eligible(_core: Any) -> tuple[wallet_setup.Wallet, ...]: wallet_setup.eligible = eligible # type: ignore[assignment] wallet_setup.version_text = lambda _core: "32.0.0" # type: ignore[assignment] wallet_setup.network = lambda _core: "signet" # type: ignore[assignment] - page = pages._wallet_page(self.view, _Stub(), seed, (self.wallet_zeta,), 0, False) + page = pages._wallet_page(self.view, _Stub(), seed, (self.wallet_zeta,), 0, None, False) self.view.replace([pages.home(self.view), page]) return True @@ -575,6 +577,30 @@ def wallet_poll_stops(self) -> bool: check("wallet polling stops after leaving the page", self.wallet_polls == self.wallet_poll_count) return True + def restore_record_gate(self) -> bool: + seed = parse_codex32(SECRET_S) + if not isinstance(seed, MasterSeed): + return True + self.restore_poll_count = self.wallet_polls + wallet_setup.identity = lambda _core, _secret: ("00112233", "identifier note") # type: ignore[assignment] + pages._restore(self.view, _Stub(), seed) + page = self.page() + check("restore asks for an unseen record fingerprint", page.get_title() == "Wallet record") + check("the recovered fingerprint stays hidden", "00112233" not in " ".join(labels(page))) + press(page, "I have no wallet record") + return True + + def restore_no_record(self) -> bool: + page = self.page() + if page.get_title() != "No wallet record": + return False + check("no-record path reveals the recovered fingerprint", "00112233" in " ".join(labels(page))) + check("no-record path requires explicit confirmation", button(page, "Restore anyway") is not None) + check("revealed fingerprint cannot be typed back in this attempt", button(page, "Go back") is None) + press(page, "Stop") + check("stopping did not list a wallet", self.wallet_polls == self.restore_poll_count) + return True + def letters(self) -> bool: page = self.page() if page.get_title() != "codex32":