diff --git a/docs/developer/api.md b/docs/developer/api.md index e4c20bf4..d8b0ec0a 100644 --- a/docs/developer/api.md +++ b/docs/developer/api.md @@ -643,7 +643,14 @@ fingerprint consistency, and network xpub/tpub versions, constructs only the fixed descriptor templates, and asks `getdescriptorinfo` to validate and expand their external/internal branches. The adapter then compares the exact eight active public descriptors against `listdescriptors`. It relocks wallets Core -reports as encrypted. Master-fingerprint display is likewise delegated to Core: +reports as encrypted. Before any of this, `initialize` calls `verify_identity` +with `expected_fingerprint`. Restore callers normally supply bytes typed from +the wallet record (read with `parse_fingerprint`); `None` means either a fresh +creation, where there is no pre-existing wallet identity to authenticate, or +the operator's explicit choice to restore without a record after seeing the +fingerprint and `identifier_origin`. A mismatch raises `FingerprintMismatch` +before any wallet RPC. +Master-fingerprint display is likewise delegated to Core: a stateless root P2PKH descriptor is normalized, `deriveaddresses` derives its address, and `validateaddress` returns the script hash whose first four bytes are the BIP32 fingerprint. diff --git a/docs/developer/gui.md b/docs/developer/gui.md index b0b06927..0916228e 100644 --- a/docs/developer/gui.md +++ b/docs/developer/gui.md @@ -19,8 +19,9 @@ 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,000 -logical-line GUI budget. +screens under Xvfb. `tests/test_gui_boundaries.py` enforces a separate 2,250 +logical-line GUI budget, including the Bails restore-identity checks and fixed +read-back boxes. The library retains its existing budget. ## Security boundaries @@ -54,6 +55,16 @@ The entry widget has three internal modes: - **Read-back:** starts empty, normalizes only spacing and ASCII case, and never reveals expected text after a mismatch. +Read-back uses `codex32._confirmation._entered_groups`, shared with the CLI. +After a mismatch, matching groups lock; only the entered text is shown. Arrows +cross editable-group boundaries, Ctrl+arrows skip groups, Tab cycles, and +Home/End reach the first/last editable group. Paste and drop are disabled. +Invalid characters and missing groups are compared directly. The former +eight-character tolerance is gone; comparison accepts up to 1,024 characters +and preserves longer input with an explicit size message. +`tests/test_gui_read_back.py` exercises these behaviors under GTK. Review +`entry.py` for keyboard and clipboard handling. + Enter activates the enabled primary action. Card displays use four four-character groups per row, matching `docs/user/recovery-card.html`. Ordinary repair suggestions are unmarked; explicit correction may mark changed diff --git a/docs/security/invariants.md b/docs/security/invariants.md index 83c3bfe2..ffd83ee0 100644 --- a/docs/security/invariants.md +++ b/docs/security/invariants.md @@ -11,6 +11,13 @@ and evidence. 3. Shared creation uses a separate OS-CSPRNG call for each random initial share, gated by confirmation. Input cannot replace entropy or the original secret. 4. Wallet setup uses the original ceremony result or a validated recovered seed. + Restore authenticates the recovered seed before any wallet is listed, + unlocked, or imported into: normally with the master fingerprint typed from + the wallet record, or by an explicit no-record choice made after seeing the + recovered fingerprint and whether the backup identifier was derived from the + seed. Fresh creation ceremonies, in `ms32 create` or the GUI, do not + authenticate against a pre-existing wallet; they require the operator to + record the new fingerprint. 5. Correction shares one mass bound and deadline across target lengths. The public API fails closed on incomplete required work; CLI searches may return one primary-best-so-far eligible candidate at the deadline. Incomplete diff --git a/docs/security/model.md b/docs/security/model.md index 533bbd1d..78b24a87 100644 --- a/docs/security/model.md +++ b/docs/security/model.md @@ -45,8 +45,9 @@ The operator must: balances or history; - protect recovery cards and store shared cards in different trusted places; - confirm every newly recorded secret or share; -- keep wallet records separate from shares and compare recovered fingerprints, - addresses, account, policy, and history with those records; +- keep wallet records separate from shares, type the master fingerprint from + the record before a restore import, and compare addresses, account, policy, + and history with those records; - compare every correction suggestion with the original codex32 string and stop when recovered information and wallet records disagree; and - never put recovery text in command arguments or transfer a master seed, @@ -231,6 +232,7 @@ signing setup belong to Bitcoin Core's maintained v32 workflow. | Control | Required behavior | |---|---| | Preflight | Before entropy or recovery input, explicit chain arguments probe the five standard local networks for Bitcoin Core 32 or newer. One response is selected automatically; multiple responses require operator selection. | +| Recovery identity | Restore authenticates a recovered seed before any wallet is listed. Core derives the recovered fingerprint statelessly, and a mismatch raises `FingerprintMismatch` before any wallet RPC. The restore prompt does not show the recovered value, so the operator compares by typing the fingerprint from the wallet record. Without a record, the operator is shown the recovered fingerprint, whether the backup identifier was derived from the seed (the codex32 fingerprint rule, Bails' RIPEMD-160 rule, or its mid-2023 alpha's SHA-256 rule), and a warning, and then chooses. Fresh creation (`ms32 create` or GUI setup) does not authenticate against a pre-existing wallet: it shows the newly created seed's fingerprint and requires the operator to acknowledge recording it. These checks catch mistakes such as wrong or mixed cards; anyone able to replace a threshold of cards could already read them. | | Process boundary | codex32 invokes the reviewed `bitcoin-cli` from `PATH` as a child without a shell, direct RPC socket, wallet database, or wallet-creation operation. Every call uses loopback and the selected chain. | | Destination | Only an empty descriptor wallet with private keys enabled, no external signer, transactions, descriptors, keypool entries, or active scan is eligible. One eligible wallet is offered directly; multiple wallets are selected by number. New wallets are detected by polling, and rejection returns to every eligible wallet. The escaped name is confirmed exactly. | | Seed source | The original ceremony result or validated recovered master seed supplies root-xprv private descriptors for Core's reported chain. After import, Core v32's wallet HD-key RPCs derive the requested BIP44, BIP49, BIP84, and BIP86 account xpubs. | @@ -272,6 +274,12 @@ only while more than one answers. On screen a wallet is chosen by the position o its row, never by the text of its label, and Core's text is rendered without Pango markup, so a wallet name cannot hide or impersonate another. +The window uses the same recovery-identity gate on restore. Its restore page +asks for the fingerprint without showing it, while fresh setup only shows the +new fingerprint and requires **I wrote it down** because there is no pre-existing +wallet identity to authenticate. **I have no wallet record** shows the recovered +fingerprint, the identifier result, and the warning before the operator chooses. + The program draws no entropy, opens no socket, starts no process of its own, and writes no file: no settings, no recent list, no log, and no clipboard write of recovery text. Entered recovery text is cleared when its screen is left, subject diff --git a/docs/user/gui.md b/docs/user/gui.md index 3fe3ce4a..513f7368 100644 --- a/docs/user/gui.md +++ b/docs/user/gui.md @@ -63,12 +63,20 @@ The recommended layout is three cards where any two recover the wallet. One card can be lost, and one card alone reveals nothing. 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. +back. Read-back starts empty, including `MS1`, and does not accept paste. After +a mismatch, correct the highlighted boxes from your paper card; matching groups +are locked. Arrows, Ctrl+arrows, Tab, Home and End move between editable boxes. +The expected text stays hidden. Press Enter to confirm. -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. +When every card is confirmed, the window shows the master fingerprint. Write it +on your [wallet record](wallet-verification-record.html), then press **I wrote it +down**. This is a new wallet ceremony, so there is no pre-existing fingerprint +or descriptor to authenticate against. + +Next, choose the Bitcoin Core wallet that will hold the +keys. Only empty wallets are offered, so no wallet you already use can be +overwritten. If you have none, the window can ask Bitcoin Core to create one: +give it a name and a passphrase, and codex32 fills it in and locks it again. Copy the final wallet details to the [wallet record](wallet-verification-record.html) and store it separately from the @@ -125,6 +133,21 @@ no correction. GUI failure does not alter a paper card. Fix Bitcoin Core, then use **Restore my wallet** with the existing cards; do not create a new backup. -Restore asks you to compare wallet identity, especially the master fingerprint, -with your wallet record. The GUI always uses account 0. For another account, use -`ms32 wallet --account N`. +When you restore, the window first asks you to type the master fingerprint from +your wallet record. If it does not match, nothing is written to Bitcoin Core: +check what you typed, and if it still does not match, these cards are not that +wallet. If you have no record, **I have no wallet record** shows the master +fingerprint and whether the backup identifier was made from the recovered seed, +explains what that can and cannot prove, and restores only if you still choose +to. Check the balance and history before you send money to that wallet. + +After the restore, **check** the remaining wallet details against your record +rather than copy them onto it. It shows no creation date on that screen, because the +real one is already on your record and today's would replace it. + +The window always uses account 0, which is what it writes onto your wallet +record. If you are restoring a wallet whose record shows a different account +number, use `ms32 wallet --account N` instead. + +The command line remains the fuller tool: `ms32 --help` lists everything, +including the parts this window leaves out. diff --git a/docs/user/guide.md b/docs/user/guide.md index 132f9b3f..95553ce0 100644 --- a/docs/user/guide.md +++ b/docs/user/guide.md @@ -167,9 +167,11 @@ wallet should be trusted until initialization completes. ### 4. Complete the record and store the cards -Copy the displayed backup identifier, wallet name, Bitcoin Core version, -master fingerprint, derivation standards, and account number to the wallet -record. Add the approximate +Before the wallet is filled, write the displayed master fingerprint on the +wallet record and confirm that you wrote it down. Creation is not a restore, so +there is no pre-existing fingerprint or descriptor to authenticate here. Then copy the displayed +backup identifier, wallet name, Bitcoin Core version, derivation standards, and +account number to the wallet record. Add the approximate creation / earliest-use date. Do not put a descriptor timestamp on a recovery card; Core's public descriptor export preserves its stored timestamps. @@ -227,16 +229,20 @@ its public wallet data with the separate wallet record. ms32 wallet --timestamp 0 ``` -5. Select and confirm that wallet. If it is locked, follow the displayed +5. Type the master fingerprint from the wallet record. A mismatch stops before + Bitcoin Core is changed. Press Enter with nothing typed only if there is no + record; codex32 then shows the recovered fingerprint and what the backup + identifier says, and asks before restoring. +6. Select and confirm that wallet. If it is locked, follow the displayed Bitcoin-Qt Console instructions; codex32 waits and continues automatically. It imports the private descriptors, verifies the public set, and relocks an encrypted wallet. -6. If you need an online watch-only counterpart, keep the restored signer +7. If you need an online watch-only counterpart, keep the restored signer offline and follow Bitcoin Core v32's [offline-signing tutorial](https://github.com/bitcoin/bitcoin/blob/v32.0rc1/doc/offline-signing-tutorial.md) to export and restore the watch-only wallet. Let the online node synchronize, - then compare the recovered fingerprint, account, policy, addresses, balance, - and transaction history with the wallet record. + then compare the account, policy, addresses, balance, and transaction + history with the wallet record. A timestamp of zero safely scans all history and may take time; it belongs in the recovery command, not on a paper card. During an emergency recovery, move diff --git a/src/codex32/_bitcoin_core.py b/src/codex32/_bitcoin_core.py index ffa1c124..2241591a 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,61 @@ class BitcoinCoreError(Exception): _PURPOSES = (44, 49, 84, 86) +def parse_fingerprint(text: str) -> bytes: + """Read a master fingerprint as written on a wallet record: 8 hex digits, any case or spacing.""" + compact = "".join(text.split()) + if len(compact) != 8 or not all(character in string.hexdigits for character in compact): + raise ValueError("A master fingerprint is 8 characters, each 0-9 or A-F.") + return bytes.fromhex(compact) + + +NO_RECORD_WARNING = ( + "Without the wallet record, nothing can prove these cards are the wallet you expect. Compare the " + "fingerprint with any other 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 can be normal for codex32 backups " + "made from split shares, supplied seed bytes or an explicit identifier. Bails made every " + "identifier from its seed, so for a Bails backup these are the wrong or mixed-up cards." + ) + return ( + f"The backup identifier matches this seed ({origin} rule). That rules out most mixed-up cards, " + "but not cards replaced on purpose." + ) + + +def identifier_origin(secret: MasterSeed, fingerprint: bytes) -> str | None: + """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. + """ + identifier = secret.header.identifier + if identifier == _fingerprint_identifier(fingerprint): + return "codex32" + for name, digest in (("Bails", "ripemd160"), ("Bails alpha", "sha256")): + try: + hashed = hashlib.new(digest, secret.seed_bytes).digest() + except ValueError: + continue + derived = convertbits(hashed, 8, 5, pad=True) + if identifier[:3] == _u5_to_chars(tuple(derived[:3])): + return name + return None + + @dataclass(frozen=True) class BitcoinCore: executable: str @@ -153,6 +216,22 @@ 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` 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. + """ + if expected_fingerprint is None: + return + if self.fingerprint(secret) != expected_fingerprint: + raise FingerprintMismatch( + "The recovered master fingerprint does not match the one from the wallet record. " + "Bitcoin Core was not changed." + ) + def _root_xpub(self, wallet: str) -> str: result = self._rpc("gethdkeys", wallet=wallet) if not isinstance(result, list) or len(result) != 1 or not isinstance(result[0], dict): @@ -299,9 +378,12 @@ def initialize( ask: Callable[[str], str], tell: Callable[[str], None], *, + expected_fingerprint: bytes | None, account: int = 0, timestamp: int | Literal["now"] = "now", ) -> str: + """Optionally check recovery identity, then import into one empty wallet the operator chooses.""" + self.verify_identity(secret, expected_fingerprint) while True: name = self._select(ask, tell) state = self._target(name) diff --git a/src/codex32/_cli_input.py b/src/codex32/_cli_input.py index c6e65dec..6b92e20b 100644 --- a/src/codex32/_cli_input.py +++ b/src/codex32/_cli_input.py @@ -229,67 +229,6 @@ def _confirm_correction( ).strip().lower() in ("y", "yes") -def _entered_groups(observed: str, expected: str) -> tuple[list[str], set[int]]: - # Keep entered tokens whole unless they exactly span complete groups. - # Unspaced input retains edit/group minimization and edit-order ties. - positions = [i for i, char in enumerate(observed) if not char.isspace()] - compact = "".join(observed.split()) - expected = "".join(expected.split()).lower() - tokens = observed.split() - grouped = len(tokens) > 1 and compact.lower() != expected - boundaries = {0} - splits: set[tuple[int, int]] = set() - position = 0 - for token in tokens: - for offset in range(0, len(expected), 4): - if expected[offset : offset + len(token)] == token.lower() and ( - len(token) % 4 == 0 or offset + len(token) == len(expected) - ): - splits.update((position + i, offset + i) for i in range(4, len(token), 4)) - position += len(token) - boundaries.add(position) - scores = {0: (0, 0, b"", ())} # type: dict[int, tuple[int, int, bytes, tuple[int, ...]]] - for offset in range(0, len(expected), 4): - canonical = expected[offset : offset + 4] - following: dict[int, tuple[int, int, bytes, tuple[int, ...]]] = {} - for start, (edits, disturbed, trace, ends) in scores.items(): - row = [(j, b"\x03" * j) for j in range(len(canonical) + 1)] - for end in range(start, len(compact) + 1): - if end > start: - current = [(end - start, b"\x02" * (end - start))] - for j, char in enumerate(canonical, 1): - edit = compact[end - 1].lower() != char - current.append( - min( - (row[j - 1][0] + edit, row[j - 1][1] + bytes([edit])), - (row[j][0] + 1, row[j][1] + b"\x02"), - (current[j - 1][0] + 1, current[j - 1][1] + b"\x03"), - ) - ) - row = current - if grouped and end not in boundaries and (end, offset + len(canonical)) not in splits: - continue - score = ( - edits + row[-1][0], - disturbed + (not grouped and compact[start:end].lower() != canonical), - trace + row[-1][1], - (*ends, end), - ) - if end not in following or score < following[end]: - following[end] = score - scores = following - groups = [] - start = 0 - for end in scores[len(compact)][3]: - stop = positions[end] if end < len(positions) else len(observed) - groups.append(observed[start:stop]) - start = stop - changed = { - i for i, value in enumerate(groups) if "".join(value.split()).lower() != expected[i * 4 : i * 4 + 4] - } - return groups, changed - - def _render_groups( groups: list[str], changed: set[int], diff --git a/src/codex32/_confirmation.py b/src/codex32/_confirmation.py new file mode 100644 index 00000000..ae2f7e57 --- /dev/null +++ b/src/codex32/_confirmation.py @@ -0,0 +1,66 @@ +"""Shared alignment of independently transcribed recovery cards.""" + +_ASCII_LOWER = str.maketrans("ABCDEFGHIJKLMNOPQRSTUVWXYZ", "abcdefghijklmnopqrstuvwxyz") + + +def _compact(text: str) -> str: + """Ignore whitespace and ASCII case without interpreting other characters.""" + return "".join(text.split()).translate(_ASCII_LOWER) + + +def _entered_groups(observed: str, expected: str) -> tuple[list[str], set[int]]: + """Align entered groups without supplying any expected characters.""" + positions = [i for i, char in enumerate(observed) if not char.isspace()] + compact = _compact(observed) + expected = _compact(expected) + tokens = observed.split() + grouped = len(tokens) > 1 and compact != expected + boundaries = {0} + splits: set[tuple[int, int]] = set() + position = 0 + for token in tokens: + for offset in range(0, len(expected), 4): + if expected[offset : offset + len(token)] == _compact(token) and ( + len(token) % 4 == 0 or offset + len(token) == len(expected) + ): + splits.update((position + i, offset + i) for i in range(4, len(token), 4)) + position += len(token) + boundaries.add(position) + scores = {0: (0, 0, b"", ())} # type: dict[int, tuple[int, int, bytes, tuple[int, ...]]] + for offset in range(0, len(expected), 4): + canonical = expected[offset : offset + 4] + following: dict[int, tuple[int, int, bytes, tuple[int, ...]]] = {} + for start, (edits, disturbed, trace, ends) in scores.items(): + row = [(j, b"\x03" * j) for j in range(len(canonical) + 1)] + for end in range(start, len(compact) + 1): + if end > start: + current = [(end - start, b"\x02" * (end - start))] + for j, char in enumerate(canonical, 1): + edit = compact[end - 1] != char + current.append( + min( + (row[j - 1][0] + edit, row[j - 1][1] + bytes([edit])), + (row[j][0] + 1, row[j][1] + b"\x02"), + (current[j - 1][0] + 1, current[j - 1][1] + b"\x03"), + ) + ) + row = current + if grouped and end not in boundaries and (end, offset + len(canonical)) not in splits: + continue + score = ( + edits + row[-1][0], + disturbed + (not grouped and compact[start:end] != canonical), + trace + row[-1][1], + (*ends, end), + ) + if end not in following or score < following[end]: + following[end] = score + scores = following + groups = [] + start = 0 + for end in scores[len(compact)][3]: + stop = positions[end] if end < len(positions) else len(observed) + groups.append(observed[start:stop]) + start = stop + changed = {i for i, value in enumerate(groups) if _compact(value) != expected[i * 4 : i * 4 + 4]} + return groups, changed diff --git a/src/codex32/cli.py b/src/codex32/cli.py index 506ad091..79dd5cd2 100644 --- a/src/codex32/cli.py +++ b/src/codex32/cli.py @@ -8,14 +8,21 @@ from collections.abc import Callable, Sequence from typing import Literal, NamedTuple, cast -from codex32._bitcoin_core import BitcoinCore, BitcoinCoreError +from codex32._bitcoin_core import ( + NO_RECORD_WARNING, + BitcoinCore, + BitcoinCoreError, + FingerprintMismatch, + identifier_note, + identifier_origin, + parse_fingerprint, +) from codex32._cli_input import ( CorrectionDeclined, InteractiveConfirmationRequired, _card_text, _confirm_correction, _correction_candidates, - _entered_groups, _fingerprint_matcher, _render_groups, _require_correction_confirmation, @@ -25,6 +32,7 @@ from codex32._cli_input import read_artifacts as _artifacts from codex32._cli_input import read_text as _text from codex32._cli_parser import parser as _parser +from codex32._confirmation import _compact, _entered_groups from codex32.bip93 import ( IDX_SORT, Header, @@ -296,10 +304,10 @@ def _confirm_card(artifact: Artifact, confirm: Callable[[str], ConfirmationResul prefill=prefill, ) compact = "".join(replacement.split()) - if compact.lower() == expected.lower(): + if _compact(compact) == expected.lower(): groups = [replacement] break - if len(compact) > len(expected[start * 4 : end * 4]) and compact.lower().startswith( + if len(compact) > len(expected[start * 4 : end * 4]) and _compact(compact).startswith( (expected.split("1", 1)[0] + "1").lower() ): _print( @@ -313,7 +321,7 @@ def _confirm_card(artifact: Artifact, confirm: Callable[[str], ConfirmationResul changed.difference_update(range(start, end)) changed.update(start + i for i in remaining) entered = "".join(groups) - if "".join(entered.split()).lower() != expected.lower(): + if _compact(entered) != expected.lower(): raise RuntimeError("Confirmation mismatch.") if confirm is not None and not confirm(entered).accepted: raise RuntimeError("Confirmation rejected.") @@ -333,6 +341,44 @@ def _generated_secret( ) +def _show_fingerprint(core: BitcoinCore, secret: MasterSeed, action: str) -> None: + _print(f"\nMaster fingerprint: {core.fingerprint(secret).hex().upper()}", err=True) + _text(f"{action}, then press Enter", optional=True, prompt_end=". ") + if sys.stderr.isatty(): + _print("\x1b[3J\x1b[2J\x1b[H", err=True) + + +def _without_record(core: BitcoinCore, secret: MasterSeed) -> bool: + fingerprint = core.fingerprint(secret) + _print(f"\nMaster fingerprint: {fingerprint.hex().upper()}", err=True) + _print(f"Backup identifier: {secret.header.identifier.upper()}", err=True) + _print(identifier_note(identifier_origin(secret, fingerprint)), err=True) + _print(NO_RECORD_WARNING, err=True) + return _text("Restore without a wallet record? [y/N]", optional=True).lower() in ("y", "yes") + + +def _recorded_fingerprint(core: BitcoinCore, secret: MasterSeed) -> bytes | None: + """Take the master fingerprint from a recovery record until the library accepts it.""" + prompt = "Type the master fingerprint from your wallet record (Enter if none)" + while True: + text = _text(prompt, optional=True) + if not text: + if _without_record(core, secret): + return None + continue + try: + expected = parse_fingerprint(text) + except ValueError as error: + _print(str(error), err=True) + continue + try: + core.verify_identity(secret, expected) + except FingerprintMismatch as error: + _print(str(error), err=True) + continue + return expected + + def _initialize_wallet( core: BitcoinCore, secret: MasterSeed, @@ -340,16 +386,23 @@ def _initialize_wallet( account: int = 0, timestamp: int | Literal["now"] = "now", fresh: bool = True, + restore: bool = False, confirmed: bool = True, ) -> int: assert isinstance(secret, MasterSeed) try: if confirmed: _print("Master-seed backup confirmed.\n", err=True) + if restore: + expected = _recorded_fingerprint(core, secret) + else: + _show_fingerprint(core, secret, "Write it on the wallet record") + expected = None name = core.initialize( secret, lambda prompt: _text(prompt, optional=True), lambda message: _print(message, err=True), + expected_fingerprint=expected, account=account, timestamp=timestamp, ) @@ -597,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/src/codex32/generation.py b/src/codex32/generation.py index db1843f1..ac6660f0 100644 --- a/src/codex32/generation.py +++ b/src/codex32/generation.py @@ -9,6 +9,7 @@ from typing import Never, SupportsIndex, cast from codex32._bip32 import _valid_root +from codex32._confirmation import _compact from codex32.bech32 import CHARSET, _u5_to_chars, convertbits from codex32.bip93 import ( IDX_SORT, @@ -355,7 +356,7 @@ def confirm(self, text: str) -> ConfirmationResult: raise CeremonyStateError("request a card before confirming it") if not isinstance(text, str): raise TypeError("confirmation text must be str") - observed = "".join(text.split()).lower() + observed = _compact(text) expected = self._pending.text.lower() mismatched = tuple( group + 1 diff --git a/src/codex32_gui/entry.py b/src/codex32_gui/entry.py index 5285eaa5..44e5fbff 100644 --- a/src/codex32_gui/entry.py +++ b/src/codex32_gui/entry.py @@ -5,7 +5,7 @@ from dataclasses import replace from typing import Literal, cast -from gi.repository import GLib, Gtk +from gi.repository import Gdk, GLib, Gtk from codex32.profiles.ms32 import TEXT_LENGTHS from codex32_gui.reading import ( @@ -25,7 +25,34 @@ EntryMode = Literal["import", "correct", "readback"] -class Codex32Entry(Gtk.Entry): +class PrivateEntry(Gtk.Entry): + """An entry that releases its primary selection after GTK publishes it.""" + + def __init__(self, **properties: object) -> None: + super().__init__(**properties) + self._watching = 0 + self.connect("realize", lambda _entry: self._watch(True)) + self.connect("unrealize", lambda _entry: self._watch(False)) + + def _watch(self, start: bool) -> None: + primary = self.get_primary_clipboard() + if start: + self._watching = primary.connect("changed", self._unpublish) + elif self._watching: + primary.disconnect(self._watching) + self._watching = 0 + + def _unpublish(self, primary: Gdk.Clipboard) -> None: + if primary.is_local() and primary.get_content() is not None and self.get_selection_bounds(): + GLib.idle_add(self._drop, primary) + + @staticmethod + def _drop(primary: Gdk.Clipboard) -> bool: + primary.set_content(None) + return False + + +class Codex32Entry(PrivateEntry): """One grouped field with explicit import, correction, and read-back behavior.""" __gtype_name__ = "Codex32Entry" @@ -49,7 +76,7 @@ def __init__( 0, ) self._limit = (length or TEXT_LENGTHS[-1]) + SLACK - self._dropped, self._rewriting, self._unpublishing = "", False, 0 + self._dropped, self._rewriting = "", False self._last_header_fault = "" self._stable = "" if mode == "readback" else PREFIX self.set_hexpand(True) @@ -58,8 +85,8 @@ def __init__( self.set_text(self._stable) self.set_position(-1) self.connect("changed", self._schedule) - self.connect("notify::selection-bound", self._unpublish) - self.connect("notify::cursor-position", self._unpublish) + if mode == "readback": + refuse_paste(self) GLib.idle_add(self.set_position, -1) def prefill(self, text: str) -> None: @@ -98,21 +125,6 @@ def _header_fault(self, raw: str | None = None) -> str: return "? cannot be used in this header. Type the character printed on the card." return header_fault(text, self._accepted, self._expected_header) - def _unpublish(self, *_arguments: object) -> None: - # Selecting text in a Gtk.Entry hands it to the primary selection, where a - # clipboard manager would copy the card into a history file on disk. The - # selection still works; only the copy that leaves the program is taken - # back, once GTK has finished publishing it. - if self.get_selection_bounds() and not self._unpublishing: - self._unpublishing = GLib.idle_add(self._drop_primary) - - def _drop_primary(self) -> bool: - self._unpublishing = 0 - display = self.get_display() - if display is not None: - display.get_primary_clipboard().set_content(None) - return False - def _schedule(self, _entry: Gtk.Entry) -> None: # One edit reaches the buffer as a deletion and then an insertion. Reformat # once the whole edit has settled, so no intermediate state is rewritten, @@ -132,7 +144,9 @@ def _reformat(self) -> bool: return False duplicated_prefix = self._mode == "import" and compact.startswith(PREFIX + PREFIX) source = compact[len(PREFIX) :] if duplicated_prefix else raw - canonical = normalizer(source)[: self._limit] + canonical = normalizer(source) + if self._mode != "readback": + canonical = canonical[: self._limit] shown = grouped(canonical) self._dropped = "" if self._mode == "readback" else lookalike_fault(raw) fault = self._header_fault(canonical) if self._mode == "import" else "" @@ -162,3 +176,57 @@ def _rewrite(self, shown: str, position: int) -> None: self.set_text(shown) self._rewriting = False self.set_position(position) + + +class GroupEntry(Codex32Entry): + """Only the operator's transcription of one mismatched group.""" + + def __init__(self, text: str) -> None: + super().__init__(length=GROUP, mode="readback") + self.set_width_chars(5) + self.set_max_width_chars(5) + self.set_hexpand(False) + self.set_text(text) + + +def refuse_paste(entry: Gtk.Entry) -> None: + """Require read-back from paper, including in correction boxes.""" + text = entry.get_delegate() + text.connect("paste-clipboard", lambda text: text.stop_emission_by_name("paste-clipboard")) + middle = Gtk.GestureClick(button=2, propagation_phase=Gtk.PropagationPhase.CAPTURE) + middle.connect("pressed", lambda gesture, *_: gesture.set_state(Gtk.EventSequenceState.CLAIMED)) + entry.add_controller(middle) + controllers = text.observe_controllers() + for controller in [controllers.get_item(i) for i in range(controllers.get_n_items())]: + if isinstance(controller, Gtk.DropTarget): + text.remove_controller(controller) + + +def navigate_groups(entries: list[GroupEntry]) -> None: + """Navigate editable groups while leaving character movement to GTK.""" + + def key(_controller: Gtk.EventControllerKey, value: int, _code: int, state: Gdk.ModifierType) -> bool: + current = entries.index(_controller.get_widget()) + entry = entries[current] + backwards = value in (Gdk.KEY_Left, Gdk.KEY_KP_Left, Gdk.KEY_ISO_Left_Tab) + tab = value in (Gdk.KEY_Tab, Gdk.KEY_ISO_Left_Tab) + edge = entry.get_position() == (0 if backwards else len(entry.get_text())) + if value in (Gdk.KEY_Home, Gdk.KEY_End): + target = 0 if value == Gdk.KEY_Home else len(entries) - 1 + elif tab or value in (Gdk.KEY_Left, Gdk.KEY_Right, Gdk.KEY_KP_Left, Gdk.KEY_KP_Right): + if not tab and not edge and not state & Gdk.ModifierType.CONTROL_MASK: + return False + target = current + (-1 if backwards or tab and state & Gdk.ModifierType.SHIFT_MASK else 1) + target = target % len(entries) if tab else min(max(target, 0), len(entries) - 1) + if not tab and target == current: + return False + else: + return False + entries[target].grab_focus() + entries[target].set_position(-1 if backwards or value == Gdk.KEY_End else 0) + return True + + for entry in entries: + controller = Gtk.EventControllerKey(propagation_phase=Gtk.PropagationPhase.CAPTURE) + controller.connect("key-pressed", key) + entry.add_controller(controller) diff --git a/src/codex32_gui/pages.py b/src/codex32_gui/pages.py index 46219125..98b66e72 100644 --- a/src/codex32_gui/pages.py +++ b/src/codex32_gui/pages.py @@ -26,10 +26,11 @@ generate_master_seed, recover_secret, ) +from codex32._confirmation import _compact, _entered_groups from codex32.errors import CodexError from codex32.generation import ORDINARY_INDICES from codex32_gui import ARTWORK, reading, wallet_setup, work -from codex32_gui.entry import Codex32Entry, EntryMode +from codex32_gui.entry import Codex32Entry, EntryMode, GroupEntry, navigate_groups from codex32_gui.wallet_setup import BitcoinCore Artifact = Share | Secret @@ -307,7 +308,7 @@ def _describe(artifact: Artifact) -> str: def _compare(expected: str, entered: str) -> ConfirmationResult: """Report which four-character groups do not match, by the library's own rule.""" - observed, wanted = "".join(entered.split()).lower(), expected.lower() + observed, wanted = _compact(entered), expected.lower() span = (max(len(observed), len(wanted)) + reading.GROUP - 1) // reading.GROUP mismatched = tuple( group + 1 @@ -483,68 +484,91 @@ def _read_back_page( confirm: Callable[[str], ConfirmationResult], after: Callable[[], None], ) -> Adw.NavigationPage: - """Read the card back from the paper, with the original off the screen.""" + """Read from paper; compare with the CLI's library alignment.""" field = Codex32Entry(length=len(card.text), mode="readback") - status = _note("") - comparison = Gtk.Box(orientation=Gtk.Orientation.VERTICAL) - accept = _button("Confirm card", lambda: None, style="suggested-action") + boxes: list[Gtk.Label | GroupEntry] = [] + status, comparison = _note(""), Adw.Bin() + accept = _button("Confirm card", lambda: check(), style="suggested-action") + + def clear_boxes() -> None: + previous = boxes.copy() + boxes.clear() + comparison.set_child(None) + field.set_visible(True) + for box in previous: + box.clear() if isinstance(box, GroupEntry) else box.set_text("") - def clear_comparison() -> None: - while (child := comparison.get_first_child()) is not None: - comparison.remove(child) + def entered() -> str: + return "".join(box.get_text() for box in boxes) if boxes else field.get_text() def update(*_arguments: object) -> None: - clear_comparison() - state = field.reading() - accept.set_sensitive(state.complete) - _say(status, state.message, state.level) + if field._rewriting: + return + if not field.get_text(): + clear_boxes() + text = reading.normalize_readback(entered()) + accept.set_sensitive(0 < len(text) <= 1024) + message = f"{len(text)} of {len(card.text)} characters" + _say(status, "Compare at most 1,024 characters at a time." if len(text) > 1024 else message) def check() -> None: - try: - state = field.reading() - result = confirm(state.text) - except CodexError as error: - _failure(view, error, CARDS_SAFE) - return - if not result.accepted: - groups = frozenset(group - 1 for group in result.mismatched_groups) - clear_comparison() - comparison.append(_card(state.text, groups, highlight_class="mismatch")) - plural = "s" if len(groups) != 1 else "" - message = f"Highlighted group{plural} {'do' if plural else 'does'} not match. Re-read the card." - _say(status, message, "error") - return - field.clear() - after() + text = reading.normalize_readback(entered()) + if text == card.text.upper(): + try: + result = confirm(text) + except CodexError as error: + _failure(view, error, CARDS_SAFE) + return + if result.accepted: + field.clear() + after() + return + if boxes: + pieces = [reading.normalize_readback(box.get_text()) for box in boxes] + changed = { + index + for index, piece in enumerate(pieces) + if piece != card.text[index * 4 : index * 4 + 4].upper() + } + else: + pieces, changed = _entered_groups(text, card.text) + clear_boxes() + grid = Gtk.Grid(column_spacing=8, row_spacing=8, halign=Gtk.Align.CENTER) + for index, piece in enumerate(pieces): + piece = reading.normalize_readback(piece) + box = GroupEntry(piece) if index in changed else Gtk.Label(label=piece) + box.add_css_class("card-group") + if isinstance(box, GroupEntry): + box.add_css_class("mismatch") + box.connect("changed", update) + box.connect("activate", lambda _box: check() if accept.get_sensitive() else None) + boxes.append(box) + grid.attach(box, index % 4, index // 4, 1, 1) + editable = [box for box in boxes if isinstance(box, GroupEntry)] + navigate_groups(editable) + comparison.set_child(grid) + field.set_visible(False) + if editable: + editable[0].grab_focus() + _say(status, "Re-read the highlighted groups from your card.", "error") - accept.connect("clicked", lambda _button: check()) field.connect("activate", lambda _entry: check() if accept.get_sensitive() else None) field.connect("changed", update) - content = _column( - _title("Now type it back from the card", _counted(card.header.index.upper(), position, count)), - _note( - "The original is no longer on screen. Read from the card you just wrote, so a slip of the " - "pen is caught now rather than years from now." - ), - field, - status, - comparison, - _note( - "Spaces and capitals do not matter. After a mismatch, only what you typed is shown; the " - "original stays hidden. Re-read the highlighted groups from your card and try again." - ), - ) page = _page( _counted(card.header.index.upper(), position, count), - content, + _column( + _title("Now type it back from the card", _counted(card.header.index.upper(), position, count)), + _note("Read every character from your paper card, including MS1. Pasting is turned off."), + field, + status, + comparison, + _note( + "Only highlighted groups can be changed. Use arrows, Ctrl+arrows, Tab, Home or End to move." + ), + ), actions=_actions(_button("Show the card again", view.pop), accept), ) - - def clear_page() -> None: - field.clear() - clear_comparison() - - _forget_when_gone(view, page, clear_page) + _forget_when_gone(view, page, field.clear) update() return page @@ -667,7 +691,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), ) @@ -711,7 +735,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) @@ -725,6 +749,7 @@ def _wallets( core: BitcoinCore, secret: MasterSeed, timestamp: Timestamp, + expected: bytes | None, *, restoring: bool = False, ) -> None: @@ -736,7 +761,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, ), ) @@ -748,6 +773,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.""" @@ -760,13 +786,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( @@ -794,7 +820,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"), ), ) @@ -805,6 +833,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.""" @@ -822,8 +851,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, @@ -876,6 +907,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.""" @@ -904,7 +936,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, @@ -914,7 +946,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( @@ -941,9 +973,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, @@ -953,6 +990,112 @@ def _record( ) +def _identity( + view: Adw.NavigationView, core: BitcoinCore, secret: MasterSeed, restoring: bool = False +) -> None: + """Show the recovered identity: for a new wallet's record, or for a restore without one.""" + page = _working(view, "Wallet record", "Asking Bitcoin Core for the master fingerprint…") + + def follow(identity: tuple[str, str]) -> Adw.NavigationPage: + fingerprint, note = identity + shown = (("Backup identifier", secret.header.identifier.upper()), ("Master fingerprint", fingerprint)) + if not restoring: + return _page( + "Wallet record", + _column( + _title("Write this on your wallet record", "Keep the record apart from your cards."), + _rows("Identity", shown), + ), + actions=_actions( + _button( + "I wrote it down", + lambda: _wallets(view, core, secret, "now", None), + style="suggested-action", + ) + ), + can_pop=False, + ) + content = _column( + _title("Restore without a wallet record?", "Nothing here can prove these cards are your wallet."), + _rows("What the cards say", shown), + _note(note, "warning"), + _note(wallet_setup.NO_RECORD_WARNING, "warning"), + ) + back = _button( + "Go back", lambda: _replace(view, _fingerprint_page(view, core, secret, 0, restoring=True)) + ) + anyway = _button( + "Restore anyway", + lambda: _wallets(view, core, secret, 0, None, restoring=True), + style="destructive-action", + ) + return _page("No wallet record", content, actions=_actions(back, anyway), can_pop=False) + + work.run(view, page, lambda: wallet_setup.identity(core, secret), _then(view, page, follow, CARDS_SAFE)) + + +def _fingerprint_page( + view: Adw.NavigationView, + core: BitcoinCore, + secret: MasterSeed, + timestamp: Timestamp, + *, + restoring: bool = False, + problem: str = "", +) -> Adw.NavigationPage: + """Take the master fingerprint from the wallet record. The library refuses a mismatch.""" + entered = Adw.EntryRow(title="Master fingerprint from your wallet record") + group = Adw.PreferencesGroup() + group.add(entered) + status = _note(problem, "error" if problem else "") + + def go() -> None: + try: + expected = wallet_setup.parse_fingerprint(entered.get_text()) + except ValueError as error: + _say(status, str(error), "error") + return + page = _working(view, "Wallet record", "Checking the wallet record…") + + def job() -> str: + try: + wallet_setup.verify(core, secret, expected) + except wallet_setup.FingerprintMismatch as error: + return str(error) + return "" + + def follow(mismatch: str) -> Adw.NavigationPage | None: + if mismatch: + return _fingerprint_page(view, core, secret, timestamp, restoring=restoring, problem=mismatch) + _wallets(view, core, secret, timestamp, expected, restoring=restoring) + return None + + work.run(view, page, job, _then(view, page, follow, CARDS_SAFE)) + + buttons = [_button("Stop", lambda: view.replace([home(view)]))] + if restoring: + buttons.append( + _button("I have no wallet record", lambda: _identity(view, core, secret, restoring=True)) + ) + elif problem: + buttons.append(_button("Show it again", lambda: _identity(view, core, secret))) + buttons.append(_button("Check and continue", go, style="suggested-action")) + content = _column( + _title( + "Type the master fingerprint", "Copy it from the wallet record you keep apart from the cards." + ), + group, + status, + _note( + "If it does not match, stop: these cards are not that wallet. Bitcoin Core has not been changed." + if restoring + else "This checks that your wallet record is right while it can still be fixed.", + "warning" if restoring else "", + ), + ) + return _page("Wallet record", content, actions=_actions(*buttons), can_pop=False) + + def _import( view: Adw.NavigationView, core: BitcoinCore, @@ -960,13 +1103,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), ) @@ -1464,7 +1608,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 ccdef0cc..11abc2c5 100644 --- a/src/codex32_gui/wallet_setup.py +++ b/src/codex32_gui/wallet_setup.py @@ -24,12 +24,23 @@ from typing import Literal from codex32 import MasterSeed -from codex32._bitcoin_core import _CHAINS, BitcoinCore, BitcoinCoreError +from codex32._bitcoin_core import ( + _CHAINS, + NO_RECORD_WARNING, + BitcoinCore, + BitcoinCoreError, + FingerprintMismatch, + identifier_note, + identifier_origin, + parse_fingerprint, +) __all__ = [ + "NO_RECORD_WARNING", "UNLOCK_SECONDS", "BitcoinCore", "BitcoinCoreError", + "FingerprintMismatch", "Offer", "Wallet", "connect", @@ -38,11 +49,14 @@ "fill", "fingerprint", "fingerprint_provider", + "identity", "initialize", "network", + "parse_fingerprint", "relock", "require_unlocked", "unlock", + "verify", "version_text", ] @@ -140,6 +154,17 @@ def fingerprint(core: BitcoinCore, secret: MasterSeed) -> str: return core.fingerprint(secret).hex() +def identity(core: BitcoinCore, secret: MasterSeed) -> tuple[str, str]: + """Return the recovered fingerprint and what the backup identifier says about the seed.""" + fingerprint = core.fingerprint(secret) + return fingerprint.hex(), identifier_note(identifier_origin(secret, fingerprint)) + + +def verify(core: BitcoinCore, secret: MasterSeed, expected: bytes | None) -> None: + """Refuse a seed that is not the recorded wallet before any wallet is listed or touched.""" + core.verify_identity(secret, expected) + + def fingerprint_provider(core: BitcoinCore) -> Callable[[bytes], bytes]: """Hand the library the same out-of-process derivation for a raw seed.""" return core.fingerprint_seed @@ -265,12 +290,15 @@ def initialize( secret: MasterSeed, name: str, *, + expected: bytes | None, account: int = 0, timestamp: int | Literal["now"] = "now", ) -> str: """Hand the library the wallet the operator named, and let it do the import.""" answer = _Answer(name, quoted=True) - return core.initialize(secret, answer.ask, answer.tell, account=account, timestamp=timestamp) + return core.initialize( + secret, answer.ask, answer.tell, expected_fingerprint=expected, account=account, timestamp=timestamp + ) def fill( @@ -279,6 +307,7 @@ def fill( name: str, passphrase: str, *, + expected: bytes | None, account: int = 0, timestamp: int | Literal["now"] = "now", ) -> str: @@ -290,9 +319,10 @@ def fill( covers the whole sequence; locking an already locked wallet is harmless. """ if not passphrase: - return initialize(core, secret, name, account=account, timestamp=timestamp) + return initialize(core, secret, name, expected=expected, account=account, timestamp=timestamp) + verify(core, secret, expected) unlock(core, name, passphrase) try: - return initialize(core, secret, name, account=account, timestamp=timestamp) + return initialize(core, secret, name, expected=expected, account=account, timestamp=timestamp) finally: relock(core, name) diff --git a/tests/test_bitcoin_core.py b/tests/test_bitcoin_core.py index 7ea5c056..84aad2c5 100644 --- a/tests/test_bitcoin_core.py +++ b/tests/test_bitcoin_core.py @@ -2,6 +2,7 @@ from __future__ import annotations +import hashlib import json import re import subprocess @@ -9,8 +10,16 @@ import pytest -from codex32._bitcoin_core import BitcoinCore, BitcoinCoreError +from codex32._bitcoin_core import ( + BitcoinCore, + BitcoinCoreError, + FingerprintMismatch, + identifier_note, + identifier_origin, + parse_fingerprint, +) from codex32.bip93 import parse_codex32 +from codex32.generation import _fingerprint_identifier from codex32.profiles.ms32 import MasterSeed from codex32.wallet import _with_checksum @@ -37,9 +46,16 @@ ), } _ROOT_XPUB = "xpub-root-fixture" +_FINGERPRINT = bytes.fromhex("3f3521a6") _PRIVATE_ACCOUNT = re.compile(r"/(?P44|49|84|86)h/0h/0h/<0;1>/\*") +@pytest.fixture(autouse=True) +def _recorded_fingerprint(monkeypatch: pytest.MonkeyPatch) -> None: + """Answer the pre-import identity check without the address-derivation RPCs tested separately.""" + monkeypatch.setattr(BitcoinCore, "fingerprint", lambda _client, _secret: _FINGERPRINT) + + def _descriptor_info(descriptor: str) -> dict[str, object]: """Return frozen Core-like normalization for the synthetic seed fixture.""" raw = descriptor.strip().split("#", 1)[0] @@ -380,7 +396,10 @@ def unlock(seconds: int) -> None: monkeypatch.setattr("codex32._bitcoin_core.sleep", unlock) - assert client.initialize(_SEED, lambda _prompt: "yes", messages.append) == "signer" + assert ( + client.initialize(_SEED, lambda _prompt: "yes", messages.append, expected_fingerprint=_FINGERPRINT) + == "signer" + ) private_calls = [call for call in rpc.calls if "xprv" in (call[2] or "")] assert len(private_calls) == 1 arguments, wallet, private_stdin = private_calls[0] @@ -420,7 +439,9 @@ def fail( monkeypatch.setattr(BitcoinCore, "_rpc", fail) client = BitcoinCore("bitcoin-cli", "main", 300000) with pytest.raises(BitcoinCoreError, match="did not import every private descriptor"): - client.initialize(_SEED, lambda _prompt: "yes", lambda _message: None) + client.initialize( + _SEED, lambda _prompt: "yes", lambda _message: None, expected_fingerprint=_FINGERPRINT + ) assert rpc.locked assert any(arguments == ("walletlock",) for arguments, _wallet, _stdin in rpc.calls) @@ -446,7 +467,7 @@ def rpc( monkeypatch.setattr(BitcoinCore, "_rpc", rpc) - assert BitcoinCore("bitcoin-cli", "main", 320000).fingerprint(_SEED) == bytes.fromhex("3f3521a6") + assert BitcoinCore("bitcoin-cli", "main", 320000).fingerprint_seed(_SEED.seed_bytes) == _FINGERPRINT assert calls == [ (("getdescriptorinfo",), None), (("deriveaddresses",), None), @@ -478,7 +499,9 @@ def alter( monkeypatch.setattr(BitcoinCore, "_rpc", alter) client = BitcoinCore("bitcoin-cli", "main", 300000) with pytest.raises(BitcoinCoreError, match="accepted public descriptors did not match"): - client.initialize(_SEED, lambda _prompt: "yes", lambda _message: None) + client.initialize( + _SEED, lambda _prompt: "yes", lambda _message: None, expected_fingerprint=_FINGERPRINT + ) assert rpc.locked @@ -500,7 +523,9 @@ def fail( monkeypatch.setattr(BitcoinCore, "_rpc", fail) client = BitcoinCore("bitcoin-cli", "main", 300000) with pytest.raises(BitcoinCoreError, match="suppressed failure"): - client.initialize(_SEED, lambda _prompt: "yes", lambda _message: None) + client.initialize( + _SEED, lambda _prompt: "yes", lambda _message: None, expected_fingerprint=_FINGERPRINT + ) assert rpc.locked assert any(arguments == ("walletlock",) for arguments, _wallet, _stdin in rpc.calls) @@ -519,7 +544,9 @@ def interrupt( monkeypatch.setattr(BitcoinCore, "_rpc", interrupt) client = BitcoinCore("bitcoin-cli", "main", 300000) with pytest.raises(KeyboardInterrupt): - client.initialize(_SEED, lambda _prompt: "yes", lambda _message: None) + client.initialize( + _SEED, lambda _prompt: "yes", lambda _message: None, expected_fingerprint=_FINGERPRINT + ) assert rpc.locked @@ -546,7 +573,9 @@ def select(_client: BitcoinCore, _ask: object, _tell: object) -> str: ) with pytest.raises(KeyboardInterrupt): - BitcoinCore("bitcoin-cli", "main", 300000).initialize(_SEED, lambda _prompt: "yes", messages.append) + BitcoinCore("bitcoin-cli", "main", 300000).initialize( + _SEED, lambda _prompt: "yes", messages.append, expected_fingerprint=_FINGERPRINT + ) assert rpc.locked assert "That wallet is no longer eligible. Choose again." in messages assert any(arguments == ("walletlock",) for arguments, _wallet, _stdin in rpc.calls) @@ -569,7 +598,7 @@ def interrupt(_seconds: int) -> None: with pytest.raises(KeyboardInterrupt): BitcoinCore("bitcoin-cli", "main", 300000).initialize( - _SEED, lambda _prompt: "", lambda _message: None + _SEED, lambda _prompt: "", lambda _message: None, expected_fingerprint=_FINGERPRINT ) assert rpc.locked @@ -598,7 +627,7 @@ def target(*_args: object, **_options: object) -> tuple[bool, bool]: assert ( BitcoinCore("bitcoin-cli", "main", 300000).initialize( - _SEED, lambda _prompt: "", lambda _message: None + _SEED, lambda _prompt: "", lambda _message: None, expected_fingerprint=_FINGERPRINT ) == "signer" ) @@ -624,7 +653,12 @@ def interrupt_once( monkeypatch.setattr(BitcoinCore, "_rpc", interrupt_once) client = BitcoinCore("bitcoin-cli", "main", 300000) - assert client.initialize(_SEED, lambda _prompt: "yes", lambda _message: None) == "signer" + assert ( + client.initialize( + _SEED, lambda _prompt: "yes", lambda _message: None, expected_fingerprint=_FINGERPRINT + ) + == "signer" + ) assert rpc.locked and lock_calls == 2 @@ -637,7 +671,10 @@ def test_unencrypted_wallet_imports_without_a_lock_call(monkeypatch: pytest.Monk ) client = BitcoinCore("bitcoin-cli", "main", 300000) messages: list[str] = [] - assert client.initialize(_SEED, lambda _prompt: "yes", messages.append) == "signer" + assert ( + client.initialize(_SEED, lambda _prompt: "yes", messages.append, expected_fingerprint=_FINGERPRINT) + == "signer" + ) assert messages == [] assert not any(arguments == ("walletlock",) for arguments, _wallet, _stdin in rpc.calls) @@ -662,7 +699,7 @@ def test_immediate_revalidation_stops_before_private_import_and_relocks( ) with pytest.raises(BitcoinCoreError, match="changed before import"): - client.initialize(_SEED, lambda _prompt: "", lambda _message: None) + client.initialize(_SEED, lambda _prompt: "", lambda _message: None, expected_fingerprint=_FINGERPRINT) assert rpc.locked assert not any(arguments == ("importdescriptors",) for arguments, _wallet, _stdin in rpc.calls) @@ -685,3 +722,81 @@ def run(command: list[str], **options: object) -> subprocess.CompletedProcess[st with pytest.raises(BitcoinCoreError) as failure: client._rpc("importdescriptors", wallet="wallet", stdin=marker + "\n") assert marker not in str(failure.value) + + +@pytest.mark.parametrize("text", ("3f3521a6", "3F35 21A6", " 3f35\t21a6 ")) +def test_parse_fingerprint_accepts_record_spellings(text: str) -> None: + assert parse_fingerprint(text) == _FINGERPRINT + + +@pytest.mark.parametrize("text", ("", "3f3521a", "3f3521a6ff", "3f3521ag", "0x3f3521")) +def test_parse_fingerprint_rejects_other_text(text: str) -> None: + with pytest.raises(ValueError, match="8 characters"): + parse_fingerprint(text) + + +def test_identity_mismatch_stops_before_any_wallet_call(monkeypatch: pytest.MonkeyPatch) -> None: + rpc = _ImportRPC(locked=False) + monkeypatch.setattr( + BitcoinCore, + "_rpc", + lambda client, *args, wallet=None, stdin=None: rpc(client, *args, wallet=wallet, stdin=stdin), + ) + + with pytest.raises(FingerprintMismatch, match="Bitcoin Core was not changed"): + BitcoinCore("bitcoin-cli", "main", 300000).initialize( + _SEED, + lambda _prompt: "yes", + lambda _message: None, + expected_fingerprint=bytes.fromhex("3f3521a7"), + ) + assert rpc.calls == [] + + +def test_no_record_is_the_operators_choice_and_checks_nothing(monkeypatch: pytest.MonkeyPatch) -> None: + def unused(_client: BitcoinCore, _secret: MasterSeed) -> bytes: + raise AssertionError("no fingerprint is compared without a record") + + monkeypatch.setattr(BitcoinCore, "fingerprint", unused) + BitcoinCore("bitcoin-cli", "main", 300000).verify_identity(_SEED, None) + + +# Frozen from Bails' own ms32.seed_identifier for this seed: master (RIPEMD-160) and the +# June 2023 alpha (SHA-256). Bails checked three characters and kept the fourth for re-sharing. +_BAILS_SEED = bytes(range(16)) + + +@pytest.mark.parametrize( + ("identifier", "origin"), + ( + (_fingerprint_identifier(_FINGERPRINT), "codex32"), + ("d9k8", "Bails"), + ("d9kq", "Bails"), + ("hezu", "Bails alpha"), + ("test", None), + ), +) +def test_identifier_origin_names_the_rule_that_made_it(identifier: str, origin: str | None) -> None: + secret = MasterSeed.from_seed(_BAILS_SEED, identifier=identifier) + assert identifier_origin(secret, _FINGERPRINT) == origin + assert ("matches this seed" in identifier_note(origin)) is (origin is not None) + + +def test_identifier_origin_still_checks_alpha_without_ripemd160(monkeypatch: pytest.MonkeyPatch) -> None: + original_new = hashlib.new + + def without_ripemd160(name: str, data: bytes = b"") -> object: + if name == "ripemd160": + raise ValueError("unsupported hash type ripemd160") + return original_new(name, data) + + monkeypatch.setattr(hashlib, "new", without_ripemd160) + secret = MasterSeed.from_seed(_BAILS_SEED, identifier="hezu") + assert identifier_origin(secret, _FINGERPRINT) == "Bails alpha" + + +def test_identifier_note_allows_supported_nonderived_codex32_identifiers() -> None: + note = identifier_note(None) + assert "split shares" in note + assert "supplied seed bytes" in note + assert "explicit identifier" in note diff --git a/tests/test_cli.py b/tests/test_cli.py index 6d50f4fb..762fe0b5 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -32,6 +32,7 @@ parse_codex32, recover_secret, ) +from codex32._bitcoin_core import BitcoinCore from codex32.bech32 import _chars_to_u5, bech32_encode from codex32.checksums import _CODEX32, _CODEX32_LONG from codex32.cli import main, ms_main @@ -96,6 +97,7 @@ class _FakeBitcoinCore: private: bool | None = None account: int | None = None timestamp: int | str | None = None + expected: bytes | None = None def fingerprint_seed(self, seed: bytes) -> bytes: return fingerprint_seed(seed) @@ -103,16 +105,22 @@ def fingerprint_seed(self, seed: bytes) -> bytes: def fingerprint(self, secret: MasterSeed) -> bytes: return self.fingerprint_seed(secret.seed_bytes) + def verify_identity(self, secret: MasterSeed, expected_fingerprint: bytes | None) -> None: + BitcoinCore.verify_identity(self, secret, expected_fingerprint) # type: ignore[arg-type] + def initialize( self, secret: MasterSeed, _ask: Callable[[str], str], _tell: Callable[[str], None], *, + expected_fingerprint: bytes | None, private: bool = True, account: int = 0, timestamp: int | str = "now", ) -> str: + self.verify_identity(secret, expected_fingerprint) + self.expected = expected_fingerprint self.imported = secret self.private, self.account, self.timestamp = private, account, timestamp return "test-wallet" @@ -123,6 +131,17 @@ def _offline_core(monkeypatch): monkeypatch.setattr("codex32.cli.BitcoinCore.connect", lambda *args, **kwargs: _FakeBitcoinCore()) +_RECORDED_FINGERPRINT = importlib.import_module("codex32.cli")._recorded_fingerprint +_SHOW_FINGERPRINT = importlib.import_module("codex32.cli")._show_fingerprint + + +@pytest.fixture(autouse=True) +def _matching_record(monkeypatch): + """Keep unrelated CLI tests independent of wallet-record interaction.""" + monkeypatch.setattr("codex32.cli._recorded_fingerprint", lambda core, secret: core.fingerprint(secret)) + monkeypatch.setattr("codex32.cli._show_fingerprint", lambda _core, _secret, _action: None) + + def _invoke(args: list[str], *lines: str) -> _Result: stdin = io.StringIO("\n".join(lines) + "\n") stdout = io.StringIO() @@ -1457,7 +1476,9 @@ def test_confirmation_alignment_shows_only_entered_text( expected = "abcdefghWXYZijklmnop" if observed == "abcdefghijklmnop" else "abcdefghijklmnop" if observed == "aaaaaaa": expected = "aaaaaaaa" - groups, changed = input_module._entered_groups(observed, expected) + from codex32._confirmation import _entered_groups + + groups, changed = _entered_groups(observed, expected) actual = input_module._render_groups(groups, changed, len(expected)) plain = re.sub(r"\x1b\[[0-9;]*m", "", actual) @@ -1873,6 +1894,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 @@ -2039,7 +2061,7 @@ def test_production_size_budgets_are_enforced() -> None: def test_confirmation_alignment_assigns_complete_canonical_groups( observed: str, expected: str, groups: list[str], changed: set[int] ) -> None: - from codex32._cli_input import _entered_groups + from codex32._confirmation import _entered_groups assert _entered_groups(observed, expected) == (groups, changed) assert "".join(groups) == observed @@ -2196,7 +2218,7 @@ def test_confirmation_placeholders_only_replace_wholly_omitted_groups( def test_confirmation_grouped_alignment_preserves_token_ownership( observed: str, expected: str, groups: list[str], changed: set[int] ) -> None: - from codex32._cli_input import _entered_groups + from codex32._confirmation import _entered_groups assert _entered_groups(observed, expected) == (groups, changed) assert "".join(groups) == observed @@ -2748,3 +2770,81 @@ def test_incomplete_candidate_has_no_search_warning_and_is_never_accepted_automa assert "Search incomplete" not in result.stderr assert "may not be unique" not in result.stderr assert "only a correction suggestion" in result.stderr + + +def _record_answers(monkeypatch: pytest.MonkeyPatch, *answers: str) -> list[str]: + prompts: list[str] = [] + remaining = iter(answers) + + def answer(prompt: str, **_options: object) -> str: + prompts.append(prompt) + return next(remaining) + + monkeypatch.setattr(importlib.import_module("codex32.cli"), "_text", answer) + return prompts + + +def test_restore_record_prompt_retries_until_the_library_accepts( + monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] +) -> None: + core, secret = _FakeBitcoinCore(), parse_codex32(VECTOR_1["secret_s"]) + assert isinstance(secret, MasterSeed) + right = core.fingerprint(secret) + wrong = bytes([right[0] ^ 1]) + right[1:] + prompts = _record_answers(monkeypatch, "not hex", wrong.hex(), right.hex().upper()) + + assert _RECORDED_FINGERPRINT(core, secret) == right + assert prompts == ["Type the master fingerprint from your wallet record (Enter if none)"] * 3 + errors = capsys.readouterr().err + assert "8 characters" in errors and "does not match" in errors + assert right.hex() not in errors.lower() + + +def test_restore_without_a_record_shows_what_the_cards_say_and_asks( + monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] +) -> None: + core, secret = _FakeBitcoinCore(), parse_codex32(VECTOR_1["secret_s"]) + assert isinstance(secret, MasterSeed) + fingerprint = core.fingerprint(secret) + + prompts = _record_answers(monkeypatch, "", "n", "", "y") + assert _RECORDED_FINGERPRINT(core, secret) is None + assert prompts[1] == prompts[3] == "Restore without a wallet record? [y/N]" + shown = capsys.readouterr().err + assert shown.count(f"Master fingerprint: {fingerprint.hex().upper()}") == 2 + assert "was not made from this seed" in shown and "nothing can prove" in shown + + derived = MasterSeed.from_seed(secret.seed_bytes, identifier=_fingerprint_identifier(fingerprint)) + _record_answers(monkeypatch, "", "yes") + assert _RECORDED_FINGERPRINT(core, derived) is None + assert "matches this seed (codex32 rule)" in capsys.readouterr().err + + +def test_create_only_requires_acknowledging_that_the_fingerprint_was_recorded( + monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] +) -> None: + core, secret = _FakeBitcoinCore(), parse_codex32(VECTOR_1["secret_s"]) + assert isinstance(secret, MasterSeed) + right = core.fingerprint(secret) + prompts = _record_answers(monkeypatch, "") + + _SHOW_FINGERPRINT(core, secret, "Write it on the wallet record") + assert prompts[0] == "Write it on the wallet record, then press Enter" + shown = capsys.readouterr().err + assert f"Master fingerprint: {right.hex().upper()}" in shown + + +def test_create_initialization_does_not_authenticate_against_a_preexisting_wallet( + monkeypatch: pytest.MonkeyPatch, +) -> None: + core, secret = _FakeBitcoinCore(), parse_codex32(VECTOR_1["secret_s"]) + assert isinstance(secret, MasterSeed) + shown: list[str] = [] + monkeypatch.setattr( + "codex32.cli._show_fingerprint", + lambda _core, _secret, action: shown.append(action), + ) + + assert importlib.import_module("codex32.cli")._initialize_wallet(core, secret, confirmed=False) == 0 + assert shown == ["Write it on the wallet record"] + assert core.expected is None diff --git a/tests/test_confirmation.py b/tests/test_confirmation.py new file mode 100644 index 00000000..e4c1a0e8 --- /dev/null +++ b/tests/test_confirmation.py @@ -0,0 +1,30 @@ +"""Both interfaces align transcriptions without completing or interpreting them.""" + +import pytest +from hypothesis import given +from hypothesis import strategies as st + +from codex32._confirmation import _compact, _entered_groups + + +@pytest.mark.parametrize( + ("expected", "observed", "groups", "changed"), + [ + ("ABCDEFGH", "ABCDXEFGH", ["ABCD", "XEFGH"], {1}), + ("ABCDDEFG", "ABCDEFG", ["ABCD", "EFG"], {1}), + ("ABCDEFGHJKLMNPQR", "ABCDFGHJKLMNXPQR", ["ABCD", "FGH", "JKLM", "NXPQR"], {1, 3}), + ("ABCDEFGH", "ABCD B1!O", ["ABCD ", "B1!O"], {1}), + ("ABCDEFKG", "ABCDEFKG", ["ABCD", "EFKG"], {1}), + ], +) +def test_alignment_preserves_leading_matches_and_invalid_characters( + expected: str, observed: str, groups: list[str], changed: set[int] +) -> None: + assert _entered_groups(observed, expected) == (groups, changed) + + +@given(st.text(alphabet="ABCD!? 1K", max_size=20)) +def test_alignment_only_returns_characters_the_operator_entered(observed: str) -> None: + groups, changed = _entered_groups(observed, "ABCDABCD") + assert _compact("".join(groups)) == _compact(observed) + assert bool(changed) == (_compact(observed) != "abcdabcd") diff --git a/tests/test_gui_boundaries.py b/tests/test_gui_boundaries.py index cc7c5ebd..39210381 100644 --- a/tests/test_gui_boundaries.py +++ b/tests/test_gui_boundaries.py @@ -33,7 +33,7 @@ } ) CORE_ADAPTER = "codex32._bitcoin_core" -BUDGET = 2000 +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]: + """Name the top-level functions whose bodies, callbacks included, call `name`.""" + return { + function.name + for function in tree.body + if isinstance(function, ast.FunctionDef) + and any( + isinstance(node, ast.Call) and isinstance(node.func, ast.Name) and node.func.id == name + for node in ast.walk(function) + ) + } + + @pytest.mark.parametrize("path", _modules(), ids=lambda path: path.name) def test_the_gui_draws_no_entropy_opens_no_socket_and_touches_no_file(path: Path) -> None: imported = _imports(ast.parse(path.read_text())) @@ -114,3 +127,29 @@ def test_the_gui_keeps_its_own_size_budget() -> None: for path in _modules() } assert sum(counts.values()) < BUDGET, counts + + +def test_every_restore_way_to_a_wallet_passes_the_identity_choice() -> None: + tree = ast.parse((_package() / "pages.py").read_text()) + + assert _callers(tree, "_wallets") == {"_fingerprint_page", "_identity", "_wallet_page"} + assert _callers(tree, "_fingerprint_page") == {"_identity", "_restore", "_fingerprint_page"} + assert _callers(tree, "_identity") == {"_unshared_page", "_card_confirmed", "_fingerprint_page"} + + +def test_restore_verifies_identity_before_creating_a_destination_wallet() -> None: + tree = ast.parse((_package() / "pages.py").read_text()) + new_wallet = next( + node for node in tree.body if isinstance(node, ast.FunctionDef) and node.name == "_new_wallet_page" + ) + job = next( + node for node in ast.walk(new_wallet) if isinstance(node, ast.FunctionDef) and node.name == "job" + ) + guard = job.body[0] + assert isinstance(guard, ast.If) and isinstance(guard.test, ast.Name) and guard.test.id == "restoring" + verify = guard.body[0] + assert isinstance(verify, ast.Expr) and isinstance(verify.value, ast.Call) + assert isinstance(verify.value.func, ast.Attribute) and verify.value.func.attr == "verify" + create = job.body[1] + assert isinstance(create, ast.Expr) and isinstance(create.value, ast.Call) + assert isinstance(create.value.func, ast.Attribute) and create.value.func.attr == "create" diff --git a/tests/test_gui_read_back.py b/tests/test_gui_read_back.py new file mode 100644 index 00000000..f463bf61 --- /dev/null +++ b/tests/test_gui_read_back.py @@ -0,0 +1,300 @@ +"""The card read-back page: Enter submits, and a mismatch opens only the wrong groups.""" + +from __future__ import annotations + +from collections.abc import Iterator + +import pytest + +gi = pytest.importorskip("gi") +try: + gi.require_version("Gtk", "4.0") + gi.require_version("Adw", "1") + gi.require_version("Gdk", "4.0") + from gi.repository import Adw, Gdk, GLib, Gtk +except (ImportError, ValueError): + pytest.skip("GTK 4 and libadwaita are unavailable", allow_module_level=True) +if not Gtk.init_check() or Gdk.Display.get_default() is None: + pytest.skip("no display for GTK", allow_module_level=True) +Adw.init() + +from data.bip93_vectors import VECTOR_2 + +from codex32 import parse_codex32 +from codex32.generation import ConfirmationResult +from codex32_gui import pages, reading +from codex32_gui.entry import GroupEntry + +SHARE = VECTOR_2["share_A"] + + +def _widgets(root: Gtk.Widget, kind: type) -> Iterator[Gtk.Widget]: + child = root.get_first_child() + while child is not None: + if isinstance(child, kind): + yield child + yield from _widgets(child, kind) + child = child.get_next_sibling() + + +def _read_back( + result: ConfirmationResult | None = None, +) -> tuple[Gtk.Widget, pages.Codex32Entry, list[str], list[bool]]: + typed: list[str] = [] + finished: list[bool] = [] + + def confirm(text: str) -> ConfirmationResult: + typed.append(text) + return result or pages._compare(SHARE, text) + + view = Adw.NavigationView() + page = pages._read_back_page(view, parse_codex32(SHARE), 1, 3, confirm, lambda: finished.append(True)) + view.push(page) + return page, next(_widgets(page, pages.Codex32Entry)), typed, finished + + +def _settle() -> None: + while GLib.MainContext.default().iteration(False): + pass + + +def _boxes(page: Gtk.Widget) -> list[Gtk.Widget]: + [grid] = list(_widgets(page, Gtk.Grid)) + return [box for box in _widgets(grid, Gtk.Widget) if isinstance(box, Gtk.Label | GroupEntry)] + + +def _submit(text: str) -> tuple[Gtk.Widget, pages.Codex32Entry, list[Gtk.Widget], list[bool]]: + page, field, _typed, finished = _read_back() + field.set_text(text) + _settle() + field.emit("activate") + return page, field, _boxes(page), finished + + +def _groups(text: str) -> list[str]: + return [text[start : start + reading.GROUP] for start in range(0, len(text), reading.GROUP)] + + +CARD = _groups(SHARE.upper()) + + +def test_readback_starts_empty_and_enter_confirms_the_whole_card() -> None: + _page, field, typed, finished = _read_back(ConfirmationResult(True)) + + assert field.get_text() == "" + field.set_text(SHARE) + field.emit("activate") + assert typed == [SHARE.upper()] and finished == [True] + + +def test_invalid_characters_are_compared_and_kept_in_their_group() -> None: + wrong = CARD.copy() + wrong[7] = "B1!O" + _page, _field, boxes, finished = _submit("".join(wrong)) + assert not finished + assert isinstance(boxes[7], GroupEntry) + assert boxes[7].get_text() == "B1!O" + assert [index for index, box in enumerate(boxes) if isinstance(box, GroupEntry)] == [7] + + +def test_a_mismatch_locks_the_right_groups_and_opens_only_the_wrong_ones() -> None: + wrong = CARD.copy() + wrong[1], wrong[11] = "QQQQ", "PPPP" + _page, field, boxes, finished = _submit("".join(wrong)) + + assert finished == [] and not field.get_visible() + assert [box.get_text() for box in boxes] == wrong + assert [i for i, box in enumerate(boxes) if isinstance(box, GroupEntry)] == [1, 11] + + +def test_a_missing_and_an_extra_character_open_only_their_own_groups() -> None: + slipped = CARD.copy() + slipped[2], slipped[6] = CARD[2][1:], CARD[6][:2] + "Q" + CARD[6][2:] + _page, _field, boxes, _finished = _submit("".join(slipped)) + + assert [i for i, box in enumerate(boxes) if isinstance(box, GroupEntry)] == [2, 6] + assert [box.get_text() for box in boxes] == slipped + + +def test_correcting_the_open_boxes_confirms_the_card() -> None: + wrong = CARD.copy() + wrong[3] = "QQQQ" + page, field, boxes, finished = _submit("".join(wrong)) + + boxes[3].set_text(CARD[3].lower()) + _settle() + boxes[3].emit("activate") + assert finished == [True] and field.get_text() == "" + assert list(_widgets(page, Gtk.Grid)) == [] and field.get_visible() + + +def test_tab_moves_only_between_the_wrong_groups() -> None: + wrong = CARD.copy() + wrong[2], wrong[9] = "QQQQ", "PPPP" + page, _field, boxes, _finished = _submit("".join(wrong)) + window = Gtk.Window(child=page.get_parent()) + window.present() + _settle() + + boxes[2].grab_focus() + controllers = boxes[2].observe_controllers() + controller = next( + controllers.get_item(index) + for index in range(controllers.get_n_items()) + if isinstance(controllers.get_item(index), Gtk.EventControllerKey) + ) + assert controller.emit("key-pressed", Gdk.KEY_Tab, 0, Gdk.ModifierType(0)) + assert window.get_focus().get_parent() is boxes[9] + window.destroy() + + +def test_the_read_back_refuses_pasted_and_dropped_text() -> None: + wrong = CARD.copy() + wrong[5] = "QQQQ" + _page, field, boxes, _finished = _submit("".join(wrong)) + field.get_clipboard().set("PPPP") + _settle() + + for entry in (field, boxes[5]): + before, text = entry.get_text(), entry.get_delegate() + text.emit("paste-clipboard") + _settle() + assert entry.get_text() == before + controllers = text.observe_controllers() + assert not any( + isinstance(controllers.get_item(i), Gtk.DropTarget) for i in range(controllers.get_n_items()) + ) + + +def test_clearing_puts_the_single_field_back() -> None: + wrong = CARD.copy() + wrong[0] = "MS1Q" + page, field, _boxes, _finished = _submit("".join(wrong)) + field.clear() + assert list(_widgets(page, Gtk.Grid)) == [] and field.get_visible() + field.set_text(SHARE[:20]) + _settle() + assert reading.normalize(field.get_text()) == SHARE[:20].upper() + + +def test_a_retry_keeps_every_box_where_it_is() -> None: + wrong = CARD.copy() + wrong[0], wrong[2] = "MS1Q", "QQQQ" + page, _field, boxes, _finished = _submit("".join(wrong)) + + boxes[0].set_text("MS12N") # One character too many, next to the locked group + boxes[0].emit("activate") + boxes = _boxes(page) + assert [i for i, box in enumerate(boxes) if isinstance(box, GroupEntry)] == [0, 2] + assert [box.get_text() for box in boxes[:2]] == ["MS12N", CARD[1]] + + +def test_a_retry_ignores_spaces_typed_into_a_box() -> None: + wrong = CARD.copy() + wrong[3], wrong[8] = "QQQQ", "PPPP" + page, _field, boxes, _finished = _submit("".join(wrong)) + + boxes[3].set_text(CARD[3][:2] + " " + CARD[3][2:]) + boxes[3].emit("activate") + assert [i for i, box in enumerate(_boxes(page)) if isinstance(box, GroupEntry)] == [8] + + +@pytest.mark.parametrize( + ("key", "modifiers", "position", "target", "cursor"), + [ + (Gdk.KEY_Right, 0, 4, 9, 0), + (Gdk.KEY_Left, 0, 0, 2, 4), + (Gdk.KEY_Right, Gdk.ModifierType.CONTROL_MASK, 1, 9, 0), + (Gdk.KEY_Left, Gdk.ModifierType.CONTROL_MASK, 1, 2, 4), + (Gdk.KEY_Home, 0, 2, 2, 0), + (Gdk.KEY_End, 0, 2, 9, 4), + (Gdk.KEY_Tab, 0, 2, 9, 0), + (Gdk.KEY_ISO_Left_Tab, Gdk.ModifierType.SHIFT_MASK, 2, 2, 4), + ], +) +def test_keyboard_navigation_skips_locked_groups( + key: int, modifiers: int, position: int, target: int, cursor: int +) -> None: + wrong = CARD.copy() + wrong[2], wrong[9] = "QQQQ", "PPPP" + page, _field, boxes, _finished = _submit("".join(wrong)) + window = Gtk.Window(child=page.get_parent()) + window.present() + _settle() + start = 9 if key in (Gdk.KEY_Left, Gdk.KEY_ISO_Left_Tab, Gdk.KEY_Home) else 2 + boxes[start].grab_focus() + boxes[start].set_position(position) + controllers = boxes[start].observe_controllers() + controller = next( + controllers.get_item(index) + for index in range(controllers.get_n_items()) + if isinstance(controllers.get_item(index), Gtk.EventControllerKey) + ) + assert controller.emit("key-pressed", key, 0, Gdk.ModifierType(modifiers)) + assert window.get_focus().get_parent() is boxes[target] + assert boxes[target].get_position() == cursor + window.destroy() + + +def test_more_than_eight_missing_characters_can_be_compared() -> None: + _page, _field, boxes, finished = _submit(SHARE[:-12]) + assert not finished + assert len(boxes) == len(CARD) + assert any(isinstance(box, GroupEntry) and box.get_text() == "" for box in boxes) + + +def test_clearing_also_empties_retained_correction_widgets() -> None: + wrong = CARD.copy() + wrong[3] = "QQQQ" + _page, field, boxes, _finished = _submit("".join(wrong)) + field.clear() + assert all(box.get_text() == "" for box in boxes) + assert field._stable == "" + _settle() + assert field.get_text() == "" + + +def test_readback_formatting_preserves_oversized_input() -> None: + page, field, typed, finished = _read_back() + field.set_text("Q" * 1025) + _settle() + assert reading.normalize_readback(field.get_text()) == "Q" * 1025 + field.emit("activate") + assert not typed and not finished + assert any("at most 1,024" in label.get_label() for label in _widgets(page, Gtk.Label)) + + +def test_selecting_a_correction_box_releases_the_primary_selection() -> None: + wrong = CARD.copy() + wrong[3] = "QQQQ" + page, _field, boxes, _finished = _submit("".join(wrong)) + window = Gtk.Window(child=page.get_parent()) + window.present() + _settle() + boxes[3].grab_focus() + boxes[3].select_region(0, -1) + _settle() + assert window.get_display().get_primary_clipboard().get_content() is None + window.destroy() + + +@pytest.mark.parametrize(("key", "cursor"), [(Gdk.KEY_Left, 0), (Gdk.KEY_Right, 4)]) +def test_arrows_do_not_wrap_when_no_other_group_is_editable(key: int, cursor: int) -> None: + wrong = CARD.copy() + wrong[3] = "QQQQ" + page, _field, boxes, _finished = _submit("".join(wrong)) + window = Gtk.Window(child=page.get_parent()) + window.present() + _settle() + box = boxes[3] + box.grab_focus() + box.set_position(cursor) + controllers = box.observe_controllers() + controller = next( + controllers.get_item(index) + for index in range(controllers.get_n_items()) + if isinstance(controllers.get_item(index), Gtk.EventControllerKey) + ) + assert not controller.emit("key-pressed", key, 0, Gdk.ModifierType(0)) + assert box.get_position() == cursor + window.destroy() diff --git a/tests/test_gui_wallet_setup.py b/tests/test_gui_wallet_setup.py index 86ef8c26..f39ce079 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 @@ -260,17 +260,30 @@ def refuse(*_arguments: object, **_keywords: object) -> str: monkeypatch.setattr(wallet_setup, "initialize", refuse) with pytest.raises(BitcoinCoreError, match="waiting"): - wallet_setup.fill(core, _SEED, "fresh", PASSPHRASE) + wallet_setup.fill(core, _SEED, "fresh", PASSPHRASE, expected=None) assert fake.called("walletlock") assert fake.wallets["fresh"].locked +def test_identity_mismatch_is_refused_before_unlock(monkeypatch: pytest.MonkeyPatch) -> None: + core, fake = _client(monkeypatch, {"fresh": _Wallet(True, True)}) + + def mismatch(_core: BitcoinCore, _secret: object, _expected: bytes | None) -> None: + raise FingerprintMismatch("wrong wallet") + + monkeypatch.setattr(wallet_setup, "verify", mismatch) + with pytest.raises(FingerprintMismatch, match="wrong wallet"): + wallet_setup.fill(core, _SEED, "fresh", PASSPHRASE, expected=b"expected") + assert not fake.called("walletpassphrase") + assert fake.wallets["fresh"].locked + + def test_an_unlock_is_not_attempted_when_no_passphrase_was_given( monkeypatch: pytest.MonkeyPatch, ) -> None: core, fake = _client(monkeypatch, {"fresh": _Wallet()}) monkeypatch.setattr(wallet_setup, "initialize", lambda *_a, **_k: "fresh") - assert wallet_setup.fill(core, _SEED, "fresh", "") == "fresh" + assert wallet_setup.fill(core, _SEED, "fresh", "", expected=None) == "fresh" assert not fake.called("walletpassphrase") assert not fake.called("walletlock") diff --git a/tools/bitcoin_core_main_smoke.py b/tools/bitcoin_core_main_smoke.py index 5ce027b5..56f36dff 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 4057e0ee..448c268d 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", ) diff --git a/tools/gui_walkthrough.py b/tools/gui_walkthrough.py index a9bc3b6b..92b7844c 100644 --- a/tools/gui_walkthrough.py +++ b/tools/gui_walkthrough.py @@ -24,6 +24,7 @@ from gi.repository import Adw, Gdk, GLib, Gtk from codex32_gui import app, pages, reading, wallet_setup +from codex32_gui.entry import GroupEntry SHARE_A = "MS12NAMEA320ZYXWVUTSRQPNMLKJHGFEDCAXRPP870HKKQRM" SHARE_C = "MS12NAMECACDEFGHJKLMNPQRSTUVWXYZ023FTR2GDZMPY6PN" @@ -65,7 +66,11 @@ def walk(widget: Any) -> Iterator[Any]: def card(page: Any) -> str: - return "".join(item.get_label() for item in walk(page) if "card-group" in item.get_css_classes()) + return "".join( + item.get_text() if isinstance(item, GroupEntry) else item.get_label() + for item in walk(page) + if "card-group" in item.get_css_classes() + ) def button(page: Any, label: str) -> Any: @@ -587,7 +592,7 @@ def read_back(self) -> bool: settle() press(page, "Confirm card") mismatches = [ - item.get_label() + item.get_text() if isinstance(item, GroupEntry) else item.get_label() for item in walk(page) if "card-group" in item.get_css_classes() and "mismatch" in item.get_css_classes() ] @@ -601,9 +606,10 @@ def read_back(self) -> bool: card(page) == self.made[:-4] + "QQQQ", card(page), ) - field.set_text(self.made.lower()) + editable = next(item for item in walk(page) if isinstance(item, GroupEntry)) + editable.set_text(self.made[-4:].lower()) settle() - field.emit("activate") + editable.emit("activate") return True def new_card_done(self) -> bool: