Skip to content

Commit 53cd58b

Browse files
committed
wallet: Require the recorded fingerprint before import
Check the recorded master fingerprint before any wallet selection, unlock, creation, or descriptor import. Apply the same restore gate to `ms32 wallet` and `ms32 create --existing`, including re-sharing; fresh creation only records the newly created identity. Without a wallet record, show the recovered fingerprint, backup identifier, supported codex32/Bails identifier evidence, and the explicit no-record warning before mutation. Security: this is the release-gate accident-safety boundary for wrong or mixed recovery material. Malicious replacement resistance remains the separate authenticated-descriptor work in #55. Validation on the identical pre-rewrite tree: full Python package matrix green; focused mismatch tests prove no Core RPC mutation occurs before identity verification. Fixes #30. Refs #26.
1 parent 92d75a7 commit 53cd58b

10 files changed

Lines changed: 426 additions & 26 deletions

File tree

‎docs/developer/api.md‎

Lines changed: 10 additions & 1 deletion
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
@@ -633,7 +635,14 @@ fingerprint consistency, and network xpub/tpub versions, constructs only the
633635
fixed descriptor templates, and asks `getdescriptorinfo` to validate and expand
634636
their external/internal branches. The adapter then compares the exact eight
635637
active public descriptors against `listdescriptors`. It relocks wallets Core
636-
reports as encrypted. Master-fingerprint display is likewise delegated to Core:
638+
reports as encrypted. Before any of this, `initialize` calls `verify_identity`
639+
with `expected_fingerprint`. Restore callers normally supply bytes typed from
640+
the wallet record (read with `parse_fingerprint`); `None` means either a fresh
641+
creation, where there is no pre-existing wallet identity to authenticate, or
642+
the operator's explicit choice to restore without a record after seeing the
643+
fingerprint and `identifier_origin`. A mismatch raises `FingerprintMismatch`
644+
before any wallet RPC.
645+
Master-fingerprint display is likewise delegated to Core:
637646
a stateless root P2PKH descriptor is normalized, `deriveaddresses` derives its
638647
address, and `validateaddress` returns the script hash whose first four bytes are
639648
the BIP32 fingerprint.

‎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,
@@ -228,6 +229,7 @@ signing setup belong to Bitcoin Core's maintained v32 workflow.
228229
| Control | Required behavior |
229230
|---|---|
230231
| 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. |
232+
| Recovery identity | `ms32 wallet` 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. `ms32 create` 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. |
231233
| 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. |
232234
| 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. |
233235
| 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. |

‎docs/user/guide.md‎

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

164164
### 4. Complete the record and store the cards
165165

166-
Copy the displayed backup identifier, wallet name, Bitcoin Core version,
167-
master fingerprint, derivation standards, and account number to the wallet
168-
record. Add the approximate
166+
Before the wallet is filled, write the displayed master fingerprint on the
167+
wallet record and confirm that you wrote it down. Creation is not a restore, so
168+
there is no pre-existing fingerprint or descriptor to authenticate here. Then copy the displayed
169+
backup identifier, wallet name, Bitcoin Core version, derivation standards, and
170+
account number to the wallet record. Add the approximate
169171
creation / earliest-use date. Do not put a descriptor timestamp on a recovery
170172
card; Core's public descriptor export preserves its stored timestamps.
171173

@@ -223,16 +225,20 @@ its public wallet data with the separate wallet record.
223225
ms32 wallet --timestamp 0
224226
```
225227

226-
5. Select and confirm that wallet. If it is locked, follow the displayed
228+
5. Type the master fingerprint from the wallet record. A mismatch stops before
229+
Bitcoin Core is changed. Press Enter with nothing typed only if there is no
230+
record; codex32 then shows the recovered fingerprint and what the backup
231+
identifier says, and asks before restoring.
232+
6. Select and confirm that wallet. If it is locked, follow the displayed
227233
Bitcoin-Qt Console instructions; codex32 waits and continues automatically.
228234
It imports the private descriptors, verifies the public set, and relocks an
229235
encrypted wallet.
230-
6. If you need an online watch-only counterpart, keep the restored signer
236+
7. If you need an online watch-only counterpart, keep the restored signer
231237
offline and follow Bitcoin Core v32's
232238
[offline-signing tutorial](https://github.com/bitcoin/bitcoin/blob/v32.0rc1/doc/offline-signing-tutorial.md)
233239
to export and restore the watch-only wallet. Let the online node synchronize,
234-
then compare the recovered fingerprint, account, policy, addresses, balance,
235-
and transaction history with the wallet record.
240+
then compare the account, policy, addresses, balance, and transaction
241+
history with the wallet record.
236242

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

‎src/codex32/_bitcoin_core.py‎

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

212+
def verify_identity(self, secret: MasterSeed, expected_fingerprint: bytes | None) -> None:
213+
"""Refuse a recovered seed that is not the recorded wallet, before any wallet is touched.
214+
215+
`None` is the operator's explicit choice to restore without a record; nothing is checked.
216+
"""
217+
if expected_fingerprint is None:
218+
return
219+
if self.fingerprint(secret) != expected_fingerprint:
220+
raise FingerprintMismatch(
221+
"The recovered master fingerprint does not match the one from the wallet record. "
222+
"Bitcoin Core was not changed."
223+
)
224+
156225
def _root_xpub(self, wallet: str) -> str:
157226
result = self._rpc("gethdkeys", wallet=wallet)
158227
if not isinstance(result, list) or len(result) != 1 or not isinstance(result[0], dict):
@@ -299,9 +368,12 @@ def initialize(
299368
ask: Callable[[str], str],
300369
tell: Callable[[str], None],
301370
*,
371+
expected_fingerprint: bytes | None,
302372
account: int = 0,
303373
timestamp: int | Literal["now"] = "now",
304374
) -> str:
375+
"""Optionally check recovery identity, then import into one empty wallet the operator chooses."""
376+
self.verify_identity(secret, expected_fingerprint)
305377
while True:
306378
name = self._select(ask, tell)
307379
state = self._target(name)

‎src/codex32/cli.py‎

Lines changed: 61 additions & 3 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,
@@ -333,23 +341,68 @@ def _generated_secret(
333341
)
334342

335343

344+
def _show_fingerprint(core: BitcoinCore, secret: MasterSeed, action: str) -> None:
345+
_print(f"\nMaster fingerprint: {core.fingerprint(secret).hex().upper()}", err=True)
346+
_text(f"{action}, then press Enter", optional=True, prompt_end=". ")
347+
if sys.stderr.isatty():
348+
_print("\x1b[3J\x1b[2J\x1b[H", err=True)
349+
350+
351+
def _without_record(core: BitcoinCore, secret: MasterSeed) -> bool:
352+
fingerprint = core.fingerprint(secret)
353+
_print(f"\nMaster fingerprint: {fingerprint.hex().upper()}", err=True)
354+
_print(f"Backup identifier: {secret.header.identifier.upper()}", err=True)
355+
_print(identifier_note(identifier_origin(secret, fingerprint)), err=True)
356+
_print(NO_RECORD_WARNING, err=True)
357+
return _text("Restore without a wallet record? [y/N]", optional=True).lower() in ("y", "yes")
358+
359+
360+
def _recorded_fingerprint(core: BitcoinCore, secret: MasterSeed) -> bytes | None:
361+
"""Take the master fingerprint from a recovery record until the library accepts it."""
362+
prompt = "Type the master fingerprint from your wallet record (Enter if none)"
363+
while True:
364+
text = _text(prompt, optional=True)
365+
if not text:
366+
if _without_record(core, secret):
367+
return None
368+
continue
369+
try:
370+
expected = parse_fingerprint(text)
371+
except ValueError as error:
372+
_print(str(error), err=True)
373+
continue
374+
try:
375+
core.verify_identity(secret, expected)
376+
except FingerprintMismatch as error:
377+
_print(str(error), err=True)
378+
continue
379+
return expected
380+
381+
336382
def _initialize_wallet(
337383
core: BitcoinCore,
338384
secret: MasterSeed,
339385
*,
340386
account: int = 0,
341387
timestamp: int | Literal["now"] = "now",
342388
fresh: bool = True,
389+
restore: bool = False,
343390
confirmed: bool = True,
344391
) -> int:
345392
assert isinstance(secret, MasterSeed)
346393
try:
347394
if confirmed:
348395
_print("Master-seed backup confirmed.\n", err=True)
396+
if restore:
397+
expected = _recorded_fingerprint(core, secret)
398+
else:
399+
_show_fingerprint(core, secret, "Write it on the wallet record")
400+
expected = None
349401
name = core.initialize(
350402
secret,
351403
lambda prompt: _text(prompt, optional=True),
352404
lambda message: _print(message, err=True),
405+
expected_fingerprint=expected,
353406
account=account,
354407
timestamp=timestamp,
355408
)
@@ -447,7 +500,9 @@ def _create(
447500
if sys.stdin.isatty():
448501
_confirm_card(secret)
449502
return (
450-
_initialize_wallet(core, secret, timestamp=0 if existing else "now", fresh=not existing)
503+
_initialize_wallet(
504+
core, secret, timestamp=0 if existing else "now", fresh=not existing, restore=existing
505+
)
451506
if core is not None
452507
else 0
453508
)
@@ -489,7 +544,9 @@ def _create(
489544
finished = ceremony.finish()
490545
assert isinstance(finished, MasterSeed)
491546
if core is not None:
492-
return _initialize_wallet(core, finished, timestamp=0 if existing else "now", fresh=not existing)
547+
return _initialize_wallet(
548+
core, finished, timestamp=0 if existing else "now", fresh=not existing, restore=existing
549+
)
493550
_print("\nEvery recovery card was confirmed from its re-entered text.", err=True)
494551
return 0
495552

@@ -597,6 +654,7 @@ def _bitcoin_core(account: int, timestamp: int | Literal["now"]) -> int:
597654
account=account,
598655
timestamp=timestamp,
599656
fresh=False,
657+
restore=True,
600658
confirmed=False,
601659
)
602660

0 commit comments

Comments
 (0)