Skip to content

Commit 054e8d9

Browse files
committed
wallet: Require the recorded fingerprint before import
Gate restore and existing-seed wallet initialization on the independently recorded BIP32 master fingerprint before any Bitcoin Core wallet mutation. Keep the correction path from disclosing or reusing a fingerprint derived from the candidate being authenticated. Fixes #30.
1 parent 128bda4 commit 054e8d9

10 files changed

Lines changed: 472 additions & 31 deletions

File tree

‎docs/developer/api.md‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -195,6 +195,8 @@ requires a complete explicit `ms1` string; it never infers or corrects a missing
195195
HRP or separator. No entropy is drawn for this path; raw hexadecimal seeds retain
196196
the generation path. Existing imports use timestamp zero to include prior
197197
history. Changing a supplied secret's identifier requires a sharing threshold.
198+
Existing-seed creation uses the same recorded-fingerprint or explicit no-record
199+
confirmation as wallet restoration before import, including after re-sharing.
198200
Shared creation
199201
uses an explicit threshold or full backup header. Without an explicit share
200202
count or indices, thresholds 2 and 3 produce the reviewed 2-of-3 and 3-of-5
@@ -624,6 +626,13 @@ supplies the root xprv. Confirmation text is never reparsed into this source.
624626
The key is sent only through `bitcoin-cli -stdin`; raw Core errors are suppressed,
625627
and no passphrase interface exists.
626628

629+
For wallet restoration and `ms32 create --existing`, callers make the wallet-record
630+
decision before initialization. `BitcoinCore.initialize()` calls `verify_identity()`
631+
before `_select()` or any wallet mutation. A supplied fingerprint must match the
632+
recovered master seed; `None` is reserved for fresh creation or the operator's
633+
explicit no-record fallback. A mismatch stops before a destination wallet is
634+
selected or changed.
635+
627636
Core v32 accepts the key with `addhdkey` and creates external and internal
628637
account-0 descriptors for BIP44/49/84/86 with `createwalletdescriptor`. Python
629638
checks each call's result but trusts Core to derive and store the wallet policy.

‎docs/security/invariants.md‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,12 @@ 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+
Restore authenticates the recovered seed before any wallet is listed,
15+
unlocked, or imported into: normally with the master fingerprint typed from
16+
the wallet record, or by an explicit no-record choice made after seeing the
17+
recovered fingerprint and whether the backup identifier was derived from the
18+
seed. Fresh `ms32 create` ceremonies do not authenticate against a
19+
pre-existing wallet; they require the operator to record the new fingerprint.
1420
5. Correction shares one mass bound and deadline across target lengths. The
1521
public API fails closed on incomplete required work; CLI searches may return
1622
one primary-best-so-far eligible candidate at the deadline. Incomplete

‎docs/security/model.md‎

Lines changed: 4 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 a restore import, and compare addresses, account, policy,
50+
and history 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,
@@ -230,6 +231,7 @@ signing setup belong to Bitcoin Core's maintained v32 workflow.
230231
| Control | Required behavior |
231232
|---|---|
232233
| 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. |
234+
| Recovery identity | `ms32 wallet` and `ms32 create --existing` authenticate 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 `ms32 create` has no pre-existing wallet to authenticate: 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. |
233235
| 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. |
234236
| 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. |
235237
| Seed source | The original ceremony result or validated recovered master seed supplies a root xprv for Core's reported chain. Core v32 creates BIP44, BIP49, BIP84, and BIP86 account-0 descriptors from that key. |

‎docs/user/guide.md‎

Lines changed: 14 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -171,9 +171,12 @@ wallet should be trusted until initialization completes.
171171

172172
### 4. Complete the record and store the cards
173173

174-
Copy the displayed backup identifier, wallet name, Bitcoin Core version,
175-
master fingerprint, derivation standards, and account number to the wallet
176-
record. Add the approximate
174+
Before a freshly created wallet is filled, write the displayed master fingerprint on the
175+
wallet record and confirm that you wrote it down. Fresh creation has no pre-existing
176+
fingerprint or descriptor to authenticate; `ms32 create --existing` instead uses the
177+
restore identity gate. Then copy the displayed
178+
backup identifier, wallet name, Bitcoin Core version, derivation standards, and
179+
account number to the wallet record. Add the approximate
177180
creation / earliest-use date. Do not put a descriptor timestamp on a recovery
178181
card; Core's public descriptor export preserves its stored timestamps.
179182

@@ -234,16 +237,20 @@ its public wallet data with the separate wallet record.
234237
If you know when the wallet was first used, an earlier Unix timestamp can
235238
shorten the rescan; `0` remains the safest choice when unsure.
236239

237-
5. Select and confirm that wallet. If it is locked, follow the displayed
240+
5. Type the master fingerprint from the wallet record. A mismatch stops before
241+
Bitcoin Core is changed. Press Enter with nothing typed only if there is no
242+
record; codex32 then shows the recovered fingerprint and what the backup
243+
identifier says, and asks before restoring.
244+
6. Select and confirm that wallet. If it is locked, follow the displayed
238245
Bitcoin-Qt Console instructions; codex32 waits and continues automatically.
239246
It gives Core the master private key, asks Core to create the standard
240247
account-0 descriptors, scans history, and relocks an encrypted wallet.
241-
6. If you need an online watch-only counterpart, keep the restored signer
248+
7. If you need an online watch-only counterpart, keep the restored signer
242249
offline and follow Bitcoin Core v32's
243250
[offline-signing tutorial](https://github.com/bitcoin/bitcoin/blob/v32.0rc1/doc/offline-signing-tutorial.md)
244251
to export and restore the watch-only wallet. Let the online node synchronize,
245-
then compare the recovered fingerprint, account, policy, addresses, balance,
246-
and transaction history with the wallet record.
252+
then compare the account, policy, addresses, balance, and transaction
253+
history with the wallet record.
247254

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

‎src/codex32/_bitcoin_core.py‎

Lines changed: 67 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
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"),
@@ -32,6 +40,54 @@ class BitcoinCoreError(Exception):
3240
_OUTPUT_TYPES = ("legacy", "p2sh-segwit", "bech32", "bech32m")
3341

3442

43+
def parse_fingerprint(text: str) -> bytes:
44+
"""Read a master fingerprint as written on a wallet record: 8 hex digits, any case or spacing."""
45+
compact = "".join(text.split())
46+
if len(compact) != 8 or not all(character in string.hexdigits for character in compact):
47+
raise ValueError("A master fingerprint is 8 characters, each 0-9 or A-F.")
48+
return bytes.fromhex(compact)
49+
50+
51+
NO_RECORD_WARNING = (
52+
"Without the wallet record, nothing can prove these cards are the wallet you expect. Compare the "
53+
"fingerprint with any other copy, such as another wallet app, a hardware wallet or a descriptor backup. "
54+
"After restoring, let Bitcoin Core finish scanning and check that the balance, past payments and "
55+
"addresses are ones you recognise before sending money here. Replaced cards can come with a history "
56+
"too: if you do not know what this wallet should hold, have someone you trust check it. Once you are "
57+
"sure, write the fingerprint on a new wallet record."
58+
)
59+
60+
61+
def identifier_note(origin: str | None) -> str:
62+
# Say what `identifier_origin` found, for an operator restoring without a record.
63+
if origin is None:
64+
return (
65+
"The backup identifier was not made from this seed. That can be normal for codex32 backups "
66+
"made from split shares, supplied seed bytes or an explicit identifier. Bails made every "
67+
"identifier from its seed, so for a Bails backup these are the wrong or mixed-up cards."
68+
)
69+
return (
70+
f"The backup identifier matches this seed ({origin} rule). That rules out most mixed-up cards, "
71+
"but not cards replaced on purpose."
72+
)
73+
74+
75+
def identifier_origin(secret: MasterSeed, fingerprint: bytes) -> str | None:
76+
"""Check codex32's fingerprint or Bails' three-character seed-digest identifier."""
77+
identifier = secret.header.identifier
78+
if identifier == _fingerprint_identifier(fingerprint):
79+
return "codex32"
80+
for name, digest in (("Bails", "ripemd160"), ("Bails alpha", "sha256")):
81+
try:
82+
hashed = hashlib.new(digest, secret.seed_bytes).digest()
83+
except ValueError:
84+
continue
85+
derived = convertbits(hashed, 8, 5, pad=True)
86+
if identifier[:3] == _u5_to_chars(tuple(derived[:3])):
87+
return name
88+
return None
89+
90+
3591
@dataclass(frozen=True)
3692
class BitcoinCore:
3793
executable: str
@@ -154,6 +210,14 @@ def fingerprint(self, secret: MasterSeed) -> bytes:
154210
raise TypeError("wallet operations accept only MasterSeed")
155211
return self.fingerprint_seed(secret.seed_bytes)
156212

213+
def verify_identity(self, secret: MasterSeed, expected_fingerprint: bytes | None) -> None:
214+
"""Refuse a recovered seed that is not the recorded wallet before any wallet is touched."""
215+
if expected_fingerprint is not None and self.fingerprint(secret) != expected_fingerprint:
216+
raise FingerprintMismatch(
217+
"The recovered master fingerprint does not match the one from the wallet record. "
218+
"Bitcoin Core was not changed."
219+
)
220+
157221
def _root_xpub(self, wallet: str) -> str:
158222
result = self._rpc("gethdkeys", wallet=wallet)
159223
if not isinstance(result, list) or len(result) != 1 or not isinstance(result[0], dict):
@@ -375,15 +439,18 @@ def initialize(
375439
ask: Callable[[str], str],
376440
tell: Callable[[str], None],
377441
*,
442+
expected_fingerprint: bytes | None,
378443
account: int = 0,
379444
timestamp: int | Literal["now"] = "now",
380445
) -> str:
446+
"""Validate input and identity before selecting or changing a wallet."""
381447
if not isinstance(secret, MasterSeed):
382448
raise TypeError("wallet operations accept only MasterSeed")
383449
if type(account) is not int or account != 0:
384450
raise ValueError("Bitcoin Core wallet initialization currently supports only account 0")
385451
if timestamp != "now" and (type(timestamp) is not int or timestamp < 0):
386452
raise ValueError("timestamp must be a nonnegative integer or 'now'")
453+
self.verify_identity(secret, expected_fingerprint)
387454
while True:
388455
name = self._select(ask, tell)
389456
state = self._target(name)

‎src/codex32/cli.py‎

Lines changed: 64 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,15 @@
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 (
12+
NO_RECORD_WARNING,
13+
BitcoinCore,
14+
BitcoinCoreError,
15+
FingerprintMismatch,
16+
identifier_note,
17+
identifier_origin,
18+
parse_fingerprint,
19+
)
1220
from codex32._cli_input import (
1321
CorrectionDeclined,
1422
InteractiveConfirmationRequired,
@@ -339,23 +347,66 @@ def _generated_secret(
339347
)
340348

341349

350+
def _show_fingerprint(core: BitcoinCore, secret: MasterSeed, action: str) -> None:
351+
_print(f"\nMaster fingerprint: {core.fingerprint(secret).hex().upper()}", err=True)
352+
_text(f"{action}, then press Enter", optional=True, prompt_end=". ")
353+
if sys.stderr.isatty():
354+
_print("\x1b[3J\x1b[2J\x1b[H", err=True)
355+
356+
357+
def _without_record(core: BitcoinCore, secret: MasterSeed) -> bool:
358+
fingerprint = core.fingerprint(secret)
359+
_print(f"\nMaster fingerprint: {fingerprint.hex().upper()}", err=True)
360+
_print(f"Backup identifier: {secret.header.identifier.upper()}", err=True)
361+
_print(identifier_note(identifier_origin(secret, fingerprint)), err=True)
362+
_print(NO_RECORD_WARNING, err=True)
363+
return _text("Restore without a wallet record? [y/N]", optional=True).lower() in ("y", "yes")
364+
365+
366+
def _recorded_fingerprint(core: BitcoinCore, secret: MasterSeed) -> bytes | None:
367+
# Take the master fingerprint from a recovery record until the library accepts it.
368+
prompt = "Type the master fingerprint from your wallet record (Enter if none)"
369+
while True:
370+
text = _text(prompt, optional=True)
371+
if not text:
372+
if _without_record(core, secret):
373+
return None
374+
raise _WalletSetupInterrupted
375+
try:
376+
expected = parse_fingerprint(text)
377+
except ValueError as error:
378+
_print(str(error), err=True)
379+
continue
380+
try:
381+
core.verify_identity(secret, expected)
382+
except FingerprintMismatch as error:
383+
_print(str(error), err=True)
384+
continue
385+
return expected
386+
387+
342388
def _initialize_wallet(
343389
core: BitcoinCore,
344390
secret: MasterSeed,
345391
*,
346392
account: int = 0,
347393
timestamp: int | Literal["now"] = "now",
348394
fresh: bool = True,
395+
restore: bool = False,
349396
confirmed: bool = True,
350397
) -> int:
351398
assert isinstance(secret, MasterSeed)
352399
try:
353400
if confirmed:
354401
_print("Master-seed backup confirmed.\n", err=True)
402+
expected = _recorded_fingerprint(core, secret) if restore else None
403+
if not restore:
404+
_show_fingerprint(core, secret, "Write it on the wallet record")
355405
name = core.initialize(
356406
secret,
357407
lambda prompt: _text(prompt, optional=True),
358408
lambda message: _print(message, err=True),
409+
expected_fingerprint=expected,
359410
account=account,
360411
timestamp=timestamp,
361412
)
@@ -432,7 +483,7 @@ def _create(
432483
else:
433484
raise _UsageError("For thresholds 4 through 9, choose --shares or --indices.")
434485
core = _connected_core()
435-
source = _creation_source(profile, core.fingerprint) if existing else None
486+
source = _creation_source(profile) if existing else None
436487
if not existing and not sys.stdin.isatty() and _text("", optional=True):
437488
raise _UsageError("Use --existing when supplying a seed or secret.")
438489
if isinstance(source, (Share, Secret)) and not isinstance(source, MasterSeed):
@@ -447,11 +498,13 @@ def _create(
447498
secret = source
448499
else:
449500
secret = _generated_secret(source, byte_length, identifier, core.fingerprint_seed)
450-
_emit(secret, False, fingerprint=core.fingerprint)
501+
_emit(secret, False, fingerprint=None if existing else core.fingerprint)
451502
if sys.stdin.isatty():
452503
_confirm_card(secret)
453504
return (
454-
_initialize_wallet(core, secret, timestamp=0 if existing else "now", fresh=not existing)
505+
_initialize_wallet(
506+
core, secret, timestamp=0 if existing else "now", fresh=not existing, restore=existing
507+
)
455508
if core is not None
456509
else 0
457510
)
@@ -493,7 +546,9 @@ def _create(
493546
finished = ceremony.finish()
494547
assert isinstance(finished, MasterSeed)
495548
if core is not None:
496-
return _initialize_wallet(core, finished, timestamp=0 if existing else "now", fresh=not existing)
549+
return _initialize_wallet(
550+
core, finished, timestamp=0 if existing else "now", fresh=not existing, restore=existing
551+
)
497552
_print("\nEvery recovery card was confirmed from its re-entered text.", err=True)
498553
return 0
499554

@@ -609,13 +664,16 @@ def _bitcoin_core(account: int, timestamp: int | Literal["now"]) -> int:
609664
danger=True,
610665
)
611666
core = _connected_core()
612-
secret = _master_seed(core.fingerprint)
667+
# Keep the recovered fingerprint hidden until the operator has supplied
668+
# independent wallet-record evidence or explicitly chosen recordless restore.
669+
secret = _master_seed()
613670
return _initialize_wallet(
614671
core,
615672
secret,
616673
account=account,
617674
timestamp=timestamp,
618675
fresh=False,
676+
restore=True,
619677
confirmed=False,
620678
)
621679

0 commit comments

Comments
 (0)