Skip to content

Commit 0cc5437

Browse files
BenWestgateclaude
andcommitted
wallet: Require the recorded fingerprint before import
BitcoinCore.initialize now takes a required expected_fingerprint and checks it before any wallet is listed, created, unlocked or imported into, so the GUI and CLI share one gate. The operator types the value from the wallet record; a new wallet shows it once and asks for it back. Without a record, the backup identifier must derive from the seed (codex32 fingerprint or legacy Bails RIPEMD-160 identifier). Fixes #26. Fixes #30. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1 parent 78f4a3b commit 0cc5437

13 files changed

Lines changed: 513 additions & 49 deletions

File tree

‎docs/developer/api.md‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -640,7 +640,11 @@ fingerprint consistency, and network xpub/tpub versions, constructs only the
640640
fixed descriptor templates, and asks `getdescriptorinfo` to validate and expand
641641
their external/internal branches. The adapter then compares the exact eight
642642
active public descriptors against `listdescriptors`. It relocks wallets Core
643-
reports as encrypted. Master-fingerprint display is likewise delegated to Core:
643+
reports as encrypted. Before any of this, `initialize` calls `verify_identity`
644+
with the required `expected_fingerprint`: bytes typed from the wallet record
645+
(read with `parse_fingerprint`), or `None` when there is no record, which accepts
646+
only a seed-derived backup identifier. A mismatch raises `FingerprintMismatch`
647+
before any wallet RPC. Master-fingerprint display is likewise delegated to Core:
644648
a stateless root P2PKH descriptor is normalized, `deriveaddresses` derives its
645649
address, and `validateaddress` returns the script hash whose first four bytes are
646650
the BIP32 fingerprint.

‎docs/security/invariants.md‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,10 @@ and evidence.
1111
3. Shared creation uses a separate OS-CSPRNG call for each random initial share,
1212
gated by confirmation. Input cannot replace entropy or the original secret.
1313
4. Wallet setup uses the original ceremony result or a validated recovered seed.
14+
`BitcoinCore.initialize` requires the master fingerprint typed from the
15+
wallet record and refuses a mismatch before any wallet is listed, created,
16+
unlocked, or imported into. Without a record, the backup identifier must be
17+
derived from the recovered seed.
1418
5. Correction shares one mass bound and deadline across target lengths. The
1519
public API fails closed on incomplete required work; CLI searches may return
1620
one primary-best-so-far eligible candidate at the deadline. Incomplete

‎docs/security/model.md‎

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -45,8 +45,9 @@ The operator must:
4545
balances or history;
4646
- protect recovery cards and store shared cards in different trusted places;
4747
- confirm every newly recorded secret or share;
48-
- keep wallet records separate from shares and compare recovered fingerprints,
49-
addresses, account, policy, and history with those records;
48+
- keep wallet records separate from shares, type the master fingerprint from
49+
the record before import, and compare addresses, account, policy, and history
50+
with those records;
5051
- compare every correction suggestion with the original codex32 string and stop
5152
when recovered information and wallet records disagree; and
5253
- never put recovery text in command arguments or transfer a master seed,
@@ -268,6 +269,19 @@ only while more than one answers. On screen a wallet is chosen by the position o
268269
its row, never by the text of its label, and Core's text is rendered without
269270
Pango markup, so a wallet name cannot hide or impersonate another.
270271

272+
Every import, in the window and on the command line, goes through
273+
`BitcoinCore.initialize`, which requires the master fingerprint the operator
274+
typed from the wallet record. Core derives the recovered fingerprint
275+
statelessly, and a mismatch raises `FingerprintMismatch` before any wallet is
276+
listed, created, unlocked, or imported into. The prompt does not show the
277+
recovered value, so the operator compares by typing rather than by glancing. A
278+
new wallet shows its fingerprint once, then asks for it back from the written
279+
record. An operator without a record may continue only when the backup identifier is
280+
derived from the recovered seed: the codex32 fingerprint identifier or legacy
281+
Bails' RIPEMD-160 seed identifier. Both checks catch mistakes such as wrong or
282+
mixed cards; 32 bits, and 20 bits without a record, do not stop deliberately
283+
replaced cards.
284+
271285
The program draws no entropy, opens no socket, starts no process of its own, and
272286
writes no file: no settings, no recent list, no log, and no clipboard write of
273287
recovery text. Entered recovery text is cleared when its screen is left, subject

‎docs/user/gui.md‎

Lines changed: 14 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -67,7 +67,11 @@ the paper with the original off the screen. That catches a slip of the pen now
6767
rather than years from now. If a group does not match, the window says which one;
6868
correct that group and try again, as many times as you like.
6969

70-
When every card is confirmed, choose the Bitcoin Core wallet that will hold the
70+
When every card is confirmed, the window shows the master fingerprint. Write it
71+
on your [wallet record](wallet-verification-record.html), then type it back from
72+
what you wrote. That catches a writing mistake while it can still be fixed.
73+
74+
Next, choose the Bitcoin Core wallet that will hold the
7175
keys. Only empty wallets are offered, so no wallet you already use can be
7276
overwritten. If you have none, the window can ask Bitcoin Core to create one:
7377
give it a name and a passphrase, and codex32 fills it in and locks it again.
@@ -142,10 +146,15 @@ wallet**, which would make a different backup. If the wallet was part-filled
142146
before it failed, it is no longer empty, so it will not be offered again: create
143147
another one, or ask Bitcoin Core for a fresh blank wallet.
144148

145-
When you restore, the window asks you to **check** the wallet details against
146-
your record rather than copy them onto it. That comparison — the master
147-
fingerprint above all — is the only thing that proves the cards you just typed
148-
belong to that wallet. It shows no creation date on that screen, because the
149+
When you restore, the window first asks you to type the master fingerprint from
150+
your wallet record. If it does not match, nothing is written to Bitcoin Core:
151+
check what you typed, and if it still does not match, these cards are not that
152+
wallet. If you have no record, **I have no wallet record** checks only that the
153+
backup identifier comes from the recovered seed. That works for backups made by
154+
Bails, but it cannot catch cards someone replaced on purpose.
155+
156+
After the restore, **check** the remaining wallet details against your record
157+
rather than copy them onto it. It shows no creation date on that screen, because the
149158
real one is already on your record and today's would replace it.
150159

151160
The window always uses account 0, which is what it writes onto your wallet

‎docs/user/guide.md‎

Lines changed: 11 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -168,9 +168,10 @@ wallet should be trusted until initialization completes.
168168

169169
### 4. Complete the record and store the cards
170170

171-
Copy the displayed backup identifier, wallet name, Bitcoin Core version,
172-
master fingerprint, derivation standards, and account number to the wallet
173-
record. Add the approximate
171+
Before the wallet is filled, write the displayed master fingerprint on the
172+
wallet record and type it back from what you wrote. Then copy the displayed
173+
backup identifier, wallet name, Bitcoin Core version, derivation standards, and
174+
account number to the wallet record. Add the approximate
174175
creation / earliest-use date. Do not put a descriptor timestamp on a recovery
175176
card; Core's public descriptor export preserves its stored timestamps.
176177

@@ -228,16 +229,19 @@ its public wallet data with the separate wallet record.
228229
ms32 wallet --timestamp 0
229230
```
230231

231-
5. Select and confirm that wallet. If it is locked, follow the displayed
232+
5. Type the master fingerprint from the wallet record. A mismatch stops before
233+
Bitcoin Core is changed. Press Enter with nothing typed only if there is no
234+
record; then only a seed-derived backup identifier is accepted.
235+
6. Select and confirm that wallet. If it is locked, follow the displayed
232236
Bitcoin-Qt Console instructions; codex32 waits and continues automatically.
233237
It imports the private descriptors, verifies the public set, and relocks an
234238
encrypted wallet.
235-
6. If you need an online watch-only counterpart, keep the restored signer
239+
7. If you need an online watch-only counterpart, keep the restored signer
236240
offline and follow Bitcoin Core v32's
237241
[offline-signing tutorial](https://github.com/bitcoin/bitcoin/blob/v32.0rc1/doc/offline-signing-tutorial.md)
238242
to export and restore the watch-only wallet. Let the online node synchronize,
239-
then compare the recovered fingerprint, account, policy, addresses, balance,
240-
and transaction history with the wallet record.
243+
then compare the account, policy, addresses, balance, and transaction
244+
history with the wallet record.
241245

242246
A timestamp of zero safely scans all history and may take time; it belongs in
243247
the recovery command, not on a paper card. During an emergency recovery, move

‎src/codex32/_bitcoin_core.py‎

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,19 @@
11
from __future__ import annotations
22

3+
import hashlib
34
import json
45
import re
56
import shutil
7+
import string
68
import subprocess
79
from collections.abc import Callable
810
from dataclasses import dataclass
911
from time import sleep
1012
from typing import Literal
1113

1214
from codex32._bip32 import _master_xprv_from_seed
15+
from codex32.bech32 import _u5_to_chars, convertbits
16+
from codex32.generation import _fingerprint_identifier
1317
from codex32.profiles.ms32 import MasterSeed
1418
from codex32.wallet import _descriptor_records, core_descriptors
1519

@@ -18,6 +22,10 @@ class BitcoinCoreError(Exception):
1822
pass
1923

2024

25+
class FingerprintMismatch(BitcoinCoreError):
26+
"""The recovered seed is not the wallet the operator's record describes."""
27+
28+
2129
_CHAINS = (
2230
("main", "mainnet"),
2331
("test", "testnet3"),
@@ -31,6 +39,25 @@ class BitcoinCoreError(Exception):
3139
_PURPOSES = (44, 49, 84, 86)
3240

3341

42+
def parse_fingerprint(text: str) -> bytes:
43+
"""Read a master fingerprint as written on a wallet record: 8 hex digits, any case or spacing."""
44+
compact = "".join(text.split())
45+
if len(compact) != 8 or not all(character in string.hexdigits for character in compact):
46+
raise ValueError("A master fingerprint is 8 characters, each 0-9 or A-F.")
47+
return bytes.fromhex(compact)
48+
49+
50+
def _seed_identifiers(seed: bytes, fingerprint: bytes) -> tuple[str, str]:
51+
"""Return the backup identifiers that only this seed produces.
52+
53+
codex32 uses the first 20 bits of the BIP32 fingerprint. Bails' legacy
54+
bails-wallet used the first 20 bits of RIPEMD-160 of the seed and offered
55+
no way to change it.
56+
"""
57+
legacy = hashlib.new("ripemd160", seed).digest()
58+
return _fingerprint_identifier(fingerprint), _u5_to_chars(tuple(convertbits(legacy, 8, 5, pad=True)[:4]))
59+
60+
3461
@dataclass(frozen=True)
3562
class BitcoinCore:
3663
executable: str
@@ -153,6 +180,26 @@ def fingerprint(self, secret: MasterSeed) -> bytes:
153180
raise TypeError("wallet operations accept only MasterSeed")
154181
return self.fingerprint_seed(secret.seed_bytes)
155182

183+
def verify_identity(self, secret: MasterSeed, expected_fingerprint: bytes | None) -> None:
184+
"""Refuse a recovered seed that is not the recorded wallet, before any wallet is touched.
185+
186+
`expected_fingerprint` is what the operator typed from the wallet record.
187+
`None` means there is no record: the backup identifier must then be one
188+
derived from this seed, which catches mistakes but not replaced cards.
189+
"""
190+
fingerprint = self.fingerprint(secret)
191+
if expected_fingerprint is None:
192+
if secret.header.identifier not in _seed_identifiers(secret.seed_bytes, fingerprint):
193+
raise FingerprintMismatch(
194+
"This backup's identifier does not come from the recovered seed, so it cannot be "
195+
"checked without the wallet record. Bitcoin Core was not changed."
196+
)
197+
elif fingerprint != expected_fingerprint:
198+
raise FingerprintMismatch(
199+
"The recovered master fingerprint does not match the one from the wallet record. "
200+
"Bitcoin Core was not changed."
201+
)
202+
156203
def _root_xpub(self, wallet: str) -> str:
157204
result = self._rpc("gethdkeys", wallet=wallet)
158205
if not isinstance(result, list) or len(result) != 1 or not isinstance(result[0], dict):
@@ -299,9 +346,16 @@ def initialize(
299346
ask: Callable[[str], str],
300347
tell: Callable[[str], None],
301348
*,
349+
expected_fingerprint: bytes | None,
302350
account: int = 0,
303351
timestamp: int | Literal["now"] = "now",
304352
) -> str:
353+
"""Import the recovered keys into one empty wallet the operator chooses.
354+
355+
The wallet record is checked first, so a wrong seed is refused before any
356+
wallet is listed, created, unlocked or imported into.
357+
"""
358+
self.verify_identity(secret, expected_fingerprint)
305359
while True:
306360
name = self._select(ask, tell)
307361
state = self._target(name)

‎src/codex32/cli.py‎

Lines changed: 40 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
from collections.abc import Callable, Sequence
99
from typing import Literal, NamedTuple, cast
1010

11-
from codex32._bitcoin_core import BitcoinCore, BitcoinCoreError
11+
from codex32._bitcoin_core import BitcoinCore, BitcoinCoreError, FingerprintMismatch, parse_fingerprint
1212
from codex32._cli_input import (
1313
CorrectionDeclined,
1414
InteractiveConfirmationRequired,
@@ -333,6 +333,43 @@ def _generated_secret(
333333
)
334334

335335

336+
def _show_fingerprint(core: BitcoinCore, secret: MasterSeed, action: str) -> None:
337+
_print(f"\nMaster fingerprint: {core.fingerprint(secret).hex().upper()}", err=True)
338+
_text(f"{action}, then press Enter", optional=True, prompt_end=". ")
339+
if sys.stderr.isatty():
340+
_print("\x1b[3J\x1b[2J\x1b[H", err=True)
341+
342+
343+
def _recorded_fingerprint(core: BitcoinCore, secret: MasterSeed, fresh: bool) -> bytes | None:
344+
"""Take the master fingerprint from the wallet record until the library accepts it."""
345+
if fresh:
346+
_show_fingerprint(core, secret, "Write it on the wallet record")
347+
prompt = "Type the master fingerprint from your wallet record" + ("" if fresh else " (Enter if none)")
348+
while True:
349+
text = _text(prompt, optional=True)
350+
expected: bytes | None = None
351+
if text or fresh:
352+
try:
353+
expected = parse_fingerprint(text)
354+
except ValueError as error:
355+
_print(str(error), err=True)
356+
continue
357+
elif _text(
358+
"Without the record, only the backup identifier can be checked. Continue? [y/N]", optional=True
359+
).lower() not in ("y", "yes"):
360+
continue
361+
try:
362+
core.verify_identity(secret, expected)
363+
except FingerprintMismatch as error:
364+
if expected is None:
365+
raise
366+
_print(str(error), err=True)
367+
if fresh:
368+
_show_fingerprint(core, secret, "Check the wallet record against it")
369+
continue
370+
return expected
371+
372+
336373
def _initialize_wallet(
337374
core: BitcoinCore,
338375
secret: MasterSeed,
@@ -346,10 +383,12 @@ def _initialize_wallet(
346383
try:
347384
if confirmed:
348385
_print("Master-seed backup confirmed.\n", err=True)
386+
expected = _recorded_fingerprint(core, secret, fresh)
349387
name = core.initialize(
350388
secret,
351389
lambda prompt: _text(prompt, optional=True),
352390
lambda message: _print(message, err=True),
391+
expected_fingerprint=expected,
353392
account=account,
354393
timestamp=timestamp,
355394
)

0 commit comments

Comments
 (0)