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":