diff --git a/docs/developer/api.md b/docs/developer/api.md index 50f3250..d031230 100644 --- a/docs/developer/api.md +++ b/docs/developer/api.md @@ -640,10 +640,12 @@ 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: -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. +reports as encrypted. Master-fingerprint and graphical recovery-commitment +derivation are likewise delegated to Core. A stateless root P2PKH descriptor is +normalized to its public root xpub. SHA-256 over the domain-separated canonical +xpub supplies the 256-bit recovery commitment; `deriveaddresses` and +`validateaddress` return the script hash whose first four bytes are the BIP32 +fingerprint. Neither operation opens or mutates a wallet. The Core calls are fixed: `getnetworkinfo`, `getblockchaininfo`, `listwallets`, `getwalletinfo`, `listdescriptors`, `getdescriptorinfo`, `deriveaddresses`, diff --git a/docs/security/invariants.md b/docs/security/invariants.md index d82e39e..c2f448b 100644 --- a/docs/security/invariants.md +++ b/docs/security/invariants.md @@ -12,8 +12,10 @@ and evidence. gated by confirmation. Input cannot replace entropy or the original secret. 4. Wallet setup uses the original ceremony result or a validated recovered seed. Graphical recovery derives public wallet identity first and requires the - operator to confirm it against the separately stored wallet record before - any recovered key material may mutate a Bitcoin Core wallet. + operator to enter a separately stored SHA-256 commitment to the canonical + root xpub before any recovered key material may mutate a Bitcoin Core wallet. + The 32-bit BIP32 fingerprint is diagnostic metadata, never the authorization + value for this transition. 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 03a474c..14ef9f7 100644 --- a/docs/security/model.md +++ b/docs/security/model.md @@ -68,6 +68,10 @@ The operator must: guarantee new physical entropy between calls. - A checksum, generation-padding hint, fingerprint, or correction candidate does not authenticate a backup or prove the operator's intent. +- The graphical recovery commitment is a domain-separated SHA-256 digest of + the canonical root xpub returned by Bitcoin Core. It is public metadata and + authenticates only against the separately stored record; it is not a secret, + a MAC, or proof that the record itself is trustworthy. - Creation feedback identifies correct groups but does not prove the recovery card was corrected. - A fresh unshared master seed exposes a public 20-bit BIP32 fingerprint in its default identifier; fingerprints are metadata, not secrets. @@ -269,13 +273,16 @@ 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. -Restore derives the recovered seed's master fingerprint through Bitcoin Core -before destination selection and displays it with the backup identifier. The -operator must explicitly confirm that fingerprint against the separately stored -wallet record before the program lists, creates, unlocks, or imports into a -destination wallet. A mismatch can therefore stop recovery without mutating a -Bitcoin Core wallet. The fingerprint remains only a short diagnostic identifier; -its authentication-strength limitation above still applies. +Restore derives the recovered seed's canonical root xpub and master fingerprint +through Bitcoin Core before destination selection. It computes the recovery +commitment as `SHA256(domain_tag || root_xpub)`, where `domain_tag` is the +ASCII text `codex32 recovery commitment` followed by one NUL byte. It asks the +operator to enter the 256-bit value from the separately stored wallet +record. The expected commitment is not displayed before comparison. Only a +match permits the program to list, create, unlock, or import into a destination +wallet, so a mismatch can stop recovery without mutating one. The fingerprint +is still displayed as a short diagnostic identifier but is not used to authorize +the transition. The program draws no entropy, opens no socket, starts no process of its own, and writes no file: no settings, no recent list, no log, and no clipboard write of diff --git a/docs/user/gui.md b/docs/user/gui.md index 3566a39..540065e 100644 --- a/docs/user/gui.md +++ b/docs/user/gui.md @@ -77,7 +77,10 @@ the seed. It protects the wallet on this computer. Finally, copy the wallet details onto your [wallet record](wallet-verification-record.html) and keep it apart from every -card. The window shows exactly the fields that record asks for. +card. The window shows exactly the fields that record asks for, including a +long recovery commitment. That commitment is public, but it must stay separate +from the cards because the restore screen uses it to reject the wrong recovered +seed before Bitcoin Core is changed. A card never contains **B**, **I**, **O** or **1**: those four are left out of the alphabet precisely because handwriting confuses them with 8, J, L and 0. If @@ -142,11 +145,17 @@ wallet**, which would make a different backup. If the wallet was part-filled before it failed, it is no longer empty, so it will not be offered again: create another one, or ask Bitcoin Core for a fresh blank wallet. -When you restore, the window asks you to **check** the wallet details against -your record rather than copy them onto it. That comparison — the master -fingerprint above all — is the only thing that proves the cards you just typed -belong to that wallet. It shows no creation date on that screen, because the -real one is already on your record and today's would replace it. +When you restore, the window first asks you to type the recovery commitment from +the separate wallet record. It deliberately does not show the value it expects. +If the value does not match, it does not list, create, unlock, or fill a Bitcoin +Core wallet. Do not substitute the shorter master fingerprint. Older wallet +records without a recovery commitment need to be updated before relying on this +pre-import check. + +After a successful restore, the window asks you to check the remaining wallet +details against the 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 diff --git a/docs/user/guide.md b/docs/user/guide.md index 1150c66..a6059a9 100644 --- a/docs/user/guide.md +++ b/docs/user/guide.md @@ -168,11 +168,13 @@ 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 -creation / earliest-use date. Do not put a descriptor timestamp on a recovery -card; Core's public descriptor export preserves its stored timestamps. +For graphical setup, copy the displayed backup identifier, wallet name, Bitcoin +Core version, master fingerprint, recovery commitment, derivation standards, +and account number to the wallet record. The command-line workflow does not yet +display the recovery commitment, so do not rely on a CLI-created record for +graphical recovery. 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. Store each card securely. For a shared backup, use different trusted places. Keep the wallet record separately from all cards. diff --git a/docs/user/wallet-verification-record.html b/docs/user/wallet-verification-record.html index a70ccc2..a60f7db 100644 --- a/docs/user/wallet-verification-record.html +++ b/docs/user/wallet-verification-record.html @@ -33,6 +33,7 @@

Wallet identity

Bitcoin Core version:
Approximate creation / earliest-use date:
Master fingerprint:
+
Recovery commitment:
Derivation standards:
Account number:
Descriptor or policy archive location:
@@ -56,6 +57,7 @@

Initial wallet setup

Later wallet verification for recovery

Followed the documented Bitcoin Core recovery workflow.

+

Entered the recovery commitment before any recovered keys were imported.

Matched the expected master fingerprint.

Matched account, complete policy, history, and balance.

If available, a trusted person reviewed the wallet setup and recovery plan.

@@ -67,8 +69,9 @@

Private restoration record

Offline device and destination wallet notes:

- This record cannot authenticate a replaced backup by itself. Treat it as privacy-sensitive: - it can reveal wallet structure, balances, and transaction history even though it cannot spend. + The recovery commitment binds a recovered single-key seed to this separately stored record. + It does not authenticate a multisig policy, descriptor archive, history, or balance. Treat this + record as privacy-sensitive even though it cannot spend.

diff --git a/src/codex32/_bitcoin_core.py b/src/codex32/_bitcoin_core.py index ffa1c12..94c00a2 100644 --- a/src/codex32/_bitcoin_core.py +++ b/src/codex32/_bitcoin_core.py @@ -1,5 +1,6 @@ from __future__ import annotations +import hashlib import json import re import shutil @@ -29,6 +30,7 @@ class BitcoinCoreError(Exception): _ORIGIN = re.compile(r"\[(?P[0-9a-f]{8})(?P(?:/[0-9]+[h']?)*)\]") _PRIVATE_MARKERS = ("xprv", "tprv") _PURPOSES = (44, 49, 84, 86) +_RECOVERY_COMMITMENT_DOMAIN = b"codex32 recovery commitment\0" @dataclass(frozen=True) @@ -126,10 +128,20 @@ def _normalized_descriptor(self, descriptor: str) -> str: raise BitcoinCoreError("Bitcoin Core did not return the expected public descriptor.") return normalized - def fingerprint_seed(self, seed: bytes) -> bytes: - """Return the BIP32 master fingerprint using Bitcoin Core out of process.""" + def recovery_identity_seed(self, seed: bytes) -> tuple[bytes, bytes]: + """Return the BIP32 fingerprint and a SHA-256 commitment to the root xpub.""" xprv = _master_xprv_from_seed(seed, testnet=self.chain != "main") descriptor = self._normalized_descriptor(f"pkh({xprv})") + if not descriptor.startswith("pkh(") or ")#" not in descriptor: + raise BitcoinCoreError("Bitcoin Core did not return the expected root public descriptor.") + root_xpub, separator, checksum = descriptor[4:].partition(")#") + prefix = "xpub" if self.chain == "main" else "tpub" + if separator != ")#" or not checksum or not root_xpub.startswith(prefix): + raise BitcoinCoreError("Bitcoin Core did not return the expected root public key.") + try: + commitment = hashlib.sha256(_RECOVERY_COMMITMENT_DOMAIN + root_xpub.encode("ascii")).digest() + except UnicodeEncodeError as error: + raise BitcoinCoreError("Bitcoin Core returned an invalid root public key.") from error addresses = self._rpc("deriveaddresses", stdin=descriptor + "\n") if not isinstance(addresses, list) or len(addresses) != 1 or not isinstance(addresses[0], str): raise BitcoinCoreError("Bitcoin Core did not derive the expected master-key address.") @@ -143,16 +155,26 @@ def fingerprint_seed(self, seed: bytes) -> bytes: ): raise BitcoinCoreError("Bitcoin Core did not return the expected master-key script.") try: - return bytes.fromhex(script[6:14]) + return bytes.fromhex(script[6:14]), commitment except ValueError as error: raise BitcoinCoreError("Bitcoin Core returned an invalid master-key script.") from error + def fingerprint_seed(self, seed: bytes) -> bytes: + """Return the BIP32 master fingerprint using Bitcoin Core out of process.""" + return self.recovery_identity_seed(seed)[0] + def fingerprint(self, secret: MasterSeed) -> bytes: """Return the BIP32 master fingerprint for a validated master seed.""" if not isinstance(secret, MasterSeed): raise TypeError("wallet operations accept only MasterSeed") return self.fingerprint_seed(secret.seed_bytes) + def recovery_identity(self, secret: MasterSeed) -> tuple[bytes, bytes]: + """Return public recovery identity for a validated master seed.""" + if not isinstance(secret, MasterSeed): + raise TypeError("wallet operations accept only MasterSeed") + return self.recovery_identity_seed(secret.seed_bytes) + 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): diff --git a/src/codex32_gui/pages.py b/src/codex32_gui/pages.py index de603ca..3ea22c4 100644 --- a/src/codex32_gui/pages.py +++ b/src/codex32_gui/pages.py @@ -91,6 +91,7 @@ class Record: wallet: str version: str fingerprint: str + commitment: str account: int @@ -924,11 +925,13 @@ def _record( core: BitcoinCore, secret: MasterSeed, name: str, timestamp: Timestamp, passphrase: str = "" ) -> Record: final = wallet_setup.fill(core, secret, name, passphrase, timestamp=timestamp) + fingerprint, commitment = wallet_setup.identity(core, secret) return Record( secret.header.identifier.upper(), final, wallet_setup.version_text(core), - wallet_setup.fingerprint(core, secret), + fingerprint, + commitment, 0, ) @@ -938,38 +941,52 @@ def _restore_identity_page( core: BitcoinCore, secret: MasterSeed, fingerprint: str, + commitment: str, ) -> Adw.NavigationPage: - """Require the wallet record to match before any recovered key is imported.""" + """Require a separately stored strong commitment before importing recovered keys.""" + entered = Adw.EntryRow(title="Recovery commitment from wallet record") + group = Adw.PreferencesGroup() + group.add(entered) + status = _note("") def continue_restore() -> None: + if not wallet_setup.commitment_matches(commitment, entered.get_text()): + _say(status, "That commitment does not match this recovered wallet. Stop here.", "error") + return + _empty(entered) _wallets(view, core, secret, 0, restoring=True) content = _column( _title( "Check the wallet before restoring it", - "Compare this master fingerprint with the wallet record you stored separately from the cards.", + "Type the recovery commitment from the wallet record stored separately from the cards.", ), _rows( - "Recovered wallet identity", + "Diagnostic identity", ( ("Backup identifier", secret.header.identifier.upper()), ("Master fingerprint", fingerprint), ), ), + group, + status, _note( - "If the fingerprint does not match your wallet record, stop. Bitcoin Core has not been changed.", + "The expected commitment is deliberately not shown here. If your record has no recovery " + "commitment, stop; do not substitute the master fingerprint. Bitcoin Core has not been changed.", "warning", ), ) - return _page( + page = _page( "Verify wallet", content, actions=_actions( _button("Stop", lambda: view.replace([home(view)])), - _button("Matches my record", continue_restore, style="suggested-action"), + _button("Verify and continue", continue_restore, style="suggested-action"), ), can_pop=False, ) + _forget_when_gone(view, page, lambda: _empty(entered)) + return page def _verify_restore(view: Adw.NavigationView, core: BitcoinCore, secret: MasterSeed) -> None: @@ -978,11 +995,11 @@ def _verify_restore(view: Adw.NavigationView, core: BitcoinCore, secret: MasterS work.run( view, page, - lambda: wallet_setup.fingerprint(core, secret), + lambda: wallet_setup.identity(core, secret), _then( view, page, - lambda fingerprint: _restore_identity_page(view, core, secret, fingerprint), + lambda identity: _restore_identity_page(view, core, secret, *identity), CARDS_SAFE, ), ) @@ -1026,6 +1043,7 @@ def _finished_page(view: Adw.NavigationView, record: Record, restoring: bool = F ("Bitcoin Core version", record.version), *dated, ("Master fingerprint", record.fingerprint), + ("Recovery commitment", record.commitment), ("Derivation standards", "BIP 44, 49, 84 and 86"), ("Account number", str(record.account)), ), diff --git a/src/codex32_gui/wallet_setup.py b/src/codex32_gui/wallet_setup.py index ccdef0c..85f4677 100644 --- a/src/codex32_gui/wallet_setup.py +++ b/src/codex32_gui/wallet_setup.py @@ -32,12 +32,14 @@ "BitcoinCoreError", "Offer", "Wallet", + "commitment_matches", "connect", "create", "eligible", "fill", "fingerprint", "fingerprint_provider", + "identity", "initialize", "network", "relock", @@ -140,6 +142,20 @@ def fingerprint(core: BitcoinCore, secret: MasterSeed) -> str: return core.fingerprint(secret).hex() +def identity(core: BitcoinCore, secret: MasterSeed) -> tuple[str, str]: + """Return the short fingerprint and strong public recovery commitment.""" + fingerprint_bytes, commitment = core.recovery_identity(secret) + text = commitment.hex().upper() + return fingerprint_bytes.hex(), " ".join(text[start : start + 4] for start in range(0, len(text), 4)) + + +def commitment_matches(expected: str, entered: str) -> bool: + """Compare a copied recovery commitment, ignoring only whitespace and case.""" + expected_text = "".join(expected.split()).upper() + entered_text = "".join(entered.split()).upper() + return len(entered_text) == 64 and entered_text == expected_text + + 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 diff --git a/tests/test_bitcoin_core.py b/tests/test_bitcoin_core.py index 7ea5c05..5831fdd 100644 --- a/tests/test_bitcoin_core.py +++ b/tests/test_bitcoin_core.py @@ -425,7 +425,7 @@ def fail( assert any(arguments == ("walletlock",) for arguments, _wallet, _stdin in rpc.calls) -def test_fingerprint_uses_stateless_core_address_derivation(monkeypatch: pytest.MonkeyPatch) -> None: +def test_recovery_identity_uses_stateless_public_derivation(monkeypatch: pytest.MonkeyPatch) -> None: calls: list[tuple[tuple[str, ...], str | None]] = [] def rpc( @@ -446,7 +446,11 @@ def rpc( monkeypatch.setattr(BitcoinCore, "_rpc", rpc) - assert BitcoinCore("bitcoin-cli", "main", 320000).fingerprint(_SEED) == bytes.fromhex("3f3521a6") + identity = BitcoinCore("bitcoin-cli", "main", 320000).recovery_identity(_SEED) + assert identity == ( + bytes.fromhex("3f3521a6"), + bytes.fromhex("db007d748326b0be6c27e2dada55a4116cc6c286cd76d11671b2f6af8fb8ae9f"), + ) assert calls == [ (("getdescriptorinfo",), None), (("deriveaddresses",), None), @@ -454,6 +458,32 @@ def rpc( ] +def test_fingerprint_keeps_the_short_legacy_view(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr( + BitcoinCore, + "recovery_identity_seed", + lambda _self, _seed: (bytes.fromhex("3f3521a6"), bytes(32)), + ) + assert BitcoinCore("bitcoin-cli", "main", 320000).fingerprint(_SEED) == bytes.fromhex("3f3521a6") + + +@pytest.mark.parametrize( + "descriptor", + ( + "wpkh(xpub-root)#checksum", + "pkh(tpub-root)#checksum", + "pkh(not-a-public-key)#checksum", + "pkh(xpub-root)", + ), +) +def test_recovery_identity_rejects_unexpected_root_public_descriptor( + descriptor: str, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setattr(BitcoinCore, "_normalized_descriptor", lambda _self, _descriptor: descriptor) + with pytest.raises(BitcoinCoreError, match="root public"): + BitcoinCore("bitcoin-cli", "main", 320000).recovery_identity(_SEED) + + @pytest.mark.parametrize("change", ("missing", "extra")) def test_exact_public_descriptor_verification_rejects_missing_or_extra_records( change: str, diff --git a/tests/test_gui_wallet_setup.py b/tests/test_gui_wallet_setup.py index 713d145..e26ca7e 100644 --- a/tests/test_gui_wallet_setup.py +++ b/tests/test_gui_wallet_setup.py @@ -285,3 +285,22 @@ def test_the_chain_the_operator_chose_is_the_one_that_is_used( def test_the_version_is_reported_the_way_bitcoin_core_reports_it() -> None: assert wallet_setup.version_text(BitcoinCore("bitcoin-cli", "main", 320100)) == "32.1.0" + + +def test_recovery_identity_formats_a_full_sha256_commitment(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr( + BitcoinCore, + "recovery_identity", + lambda _self, _secret: (bytes.fromhex("3f3521a6"), bytes(range(32))), + ) + fingerprint, commitment = wallet_setup.identity(BitcoinCore("bitcoin-cli", "main", 320000), _SEED) + assert fingerprint == "3f3521a6" + assert commitment == ("0001 0203 0405 0607 0809 0A0B 0C0D 0E0F 1011 1213 1415 1617 1819 1A1B 1C1D 1E1F") + + +def test_recovery_commitment_match_ignores_only_case_and_whitespace() -> None: + expected = "0123 4567 89AB CDEF" * 4 + assert wallet_setup.commitment_matches(expected, "0123456789abcdef" * 4) + assert wallet_setup.commitment_matches(expected, " 0123 4567 89ab cdef " * 4) + assert not wallet_setup.commitment_matches(expected, "0123456789abcdef" * 3) + assert not wallet_setup.commitment_matches(expected, "f123456789abcdef" + "0123456789abcdef" * 3)