11from __future__ import annotations
22
3+ import hashlib
34import json
45import re
56import shutil
7+ import string
68import subprocess
79from collections .abc import Callable
810from dataclasses import dataclass
911from time import sleep
1012from typing import Literal
1113
1214from codex32 ._bip32 import _master_xprv_from_seed
15+ from codex32 .bech32 import _u5_to_chars , convertbits
16+ from codex32 .generation import _fingerprint_identifier
1317from codex32 .profiles .ms32 import MasterSeed
1418from 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 )
3562class 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 )
0 commit comments