Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions docs/developer/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -195,6 +195,8 @@ requires a complete explicit `ms1` string; it never infers or corrects a missing
HRP or separator. No entropy is drawn for this path; raw hexadecimal seeds retain
the generation path. Existing imports use timestamp zero to include prior
history. Changing a supplied secret's identifier requires a sharing threshold.
Existing-seed creation uses the same recorded-fingerprint or explicit no-record
confirmation as wallet restoration before import, including after re-sharing.
Shared creation
uses an explicit threshold or full backup header. Without an explicit share
count or indices, thresholds 2 and 3 produce the reviewed 2-of-3 and 3-of-5
Expand Down Expand Up @@ -624,6 +626,13 @@ supplies the root xprv. Confirmation text is never reparsed into this source.
The key is sent only through `bitcoin-cli -stdin`; raw Core errors are suppressed,
and no passphrase interface exists.

For wallet restoration and `ms32 create --existing`, callers make the wallet-record
decision before initialization. `BitcoinCore.initialize()` calls `verify_identity()`
before `_select()` or any wallet mutation. A supplied fingerprint must match the
recovered master seed; `None` is reserved for fresh creation or the operator's
explicit no-record fallback. A mismatch stops before a destination wallet is
selected or changed.

Core v32 accepts the key with `addhdkey` and creates external and internal
account-0 descriptors for BIP44/49/84/86 with `createwalletdescriptor`. Python
checks each call's result but trusts Core to derive and store the wallet policy.
Expand Down
6 changes: 6 additions & 0 deletions docs/security/invariants.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,12 @@ and evidence.
3. Shared creation uses a separate OS-CSPRNG call for each random initial share,
gated by confirmation. Input cannot replace entropy or the original secret.
4. Wallet setup uses the original ceremony result or a validated recovered seed.
Restore authenticates the recovered seed before any wallet is listed,
unlocked, or imported into: normally with the master fingerprint typed from
the wallet record, or by an explicit no-record choice made after seeing the
recovered fingerprint and whether the backup identifier was derived from the
seed. Fresh `ms32 create` ceremonies do not authenticate against a
Comment thread
BenWestgate marked this conversation as resolved.
pre-existing wallet; they require the operator to record the new fingerprint.
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
Expand Down
6 changes: 4 additions & 2 deletions docs/security/model.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,8 +45,9 @@ The operator must:
balances or history;
- protect recovery cards and store shared cards in different trusted places;
- confirm every newly recorded secret or share;
- keep wallet records separate from shares and compare recovered fingerprints,
addresses, account, policy, and history with those records;
- keep wallet records separate from shares, type the master fingerprint from
the record before a restore import, and compare addresses, account, policy,
and history with those records;
- compare every correction suggestion with the original codex32 string and stop
when recovered information and wallet records disagree; and
- never put recovery text in command arguments or transfer a master seed,
Expand Down Expand Up @@ -230,6 +231,7 @@ signing setup belong to Bitcoin Core's maintained v32 workflow.
| Control | Required behavior |
|---|---|
| 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. |
| 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. |
| 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. |
| 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. |
| 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. |
Expand Down
21 changes: 14 additions & 7 deletions docs/user/guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -171,9 +171,12 @@ 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
Before a freshly created wallet is filled, write the displayed master fingerprint on the
wallet record and confirm that you wrote it down. Fresh creation has no pre-existing
fingerprint or descriptor to authenticate; `ms32 create --existing` instead uses the
restore identity gate. Then copy the displayed
backup identifier, wallet name, Bitcoin Core version, 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.

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

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

A timestamp of zero safely scans all history and may take time; it belongs in
the recovery command, not on a paper card. During an emergency recovery, move
Expand Down
67 changes: 67 additions & 0 deletions src/codex32/_bitcoin_core.py
Original file line number Diff line number Diff line change
@@ -1,15 +1,19 @@
from __future__ import annotations

import hashlib
import json
import re
import shutil
import string
import subprocess
from collections.abc import Callable
from dataclasses import dataclass
from time import sleep
from typing import Literal

from codex32._bip32 import _master_xprv_from_seed
from codex32.bech32 import _u5_to_chars, convertbits
from codex32.generation import _fingerprint_identifier
from codex32.profiles.ms32 import MasterSeed
from codex32.wallet import _descriptor_records

Expand All @@ -18,6 +22,10 @@ class BitcoinCoreError(Exception):
pass


class FingerprintMismatch(BitcoinCoreError):
"""The recovered seed is not the wallet the operator's record describes."""


_CHAINS = (
("main", "mainnet"),
("test", "testnet3"),
Expand All @@ -32,6 +40,54 @@ class BitcoinCoreError(Exception):
_OUTPUT_TYPES = ("legacy", "p2sh-segwit", "bech32", "bech32m")


def parse_fingerprint(text: str) -> bytes:
"""Read a master fingerprint as written on a wallet record: 8 hex digits, any case or spacing."""
compact = "".join(text.split())
if len(compact) != 8 or not all(character in string.hexdigits for character in compact):
raise ValueError("A master fingerprint is 8 characters, each 0-9 or A-F.")
return bytes.fromhex(compact)


NO_RECORD_WARNING = (
"Without the wallet record, nothing can prove these cards are the wallet you expect. Compare the "
"fingerprint with any other copy, such as another wallet app, a hardware wallet or a descriptor backup. "
"After restoring, let Bitcoin Core finish scanning and check that the balance, past payments and "
"addresses are ones you recognise before sending money here. Replaced cards can come with a history "
"too: if you do not know what this wallet should hold, have someone you trust check it. Once you are "
"sure, write the fingerprint on a new wallet record."
)


def identifier_note(origin: str | None) -> str:
# Say what `identifier_origin` found, for an operator restoring without a record.
if origin is None:
return (
"The backup identifier was not made from this seed. That can be normal for codex32 backups "
"made from split shares, supplied seed bytes or an explicit identifier. Bails made every "
"identifier from its seed, so for a Bails backup these are the wrong or mixed-up cards."
)
return (
f"The backup identifier matches this seed ({origin} rule). That rules out most mixed-up cards, "
"but not cards replaced on purpose."
)


def identifier_origin(secret: MasterSeed, fingerprint: bytes) -> str | None:
"""Check codex32's fingerprint or Bails' three-character seed-digest identifier."""
identifier = secret.header.identifier
if identifier == _fingerprint_identifier(fingerprint):
return "codex32"
for name, digest in (("Bails", "ripemd160"), ("Bails alpha", "sha256")):
try:
hashed = hashlib.new(digest, secret.seed_bytes).digest()
except ValueError:
continue
derived = convertbits(hashed, 8, 5, pad=True)
if identifier[:3] == _u5_to_chars(tuple(derived[:3])):
return name
return None


@dataclass(frozen=True)
class BitcoinCore:
executable: str
Expand Down Expand Up @@ -154,6 +210,14 @@ def fingerprint(self, secret: MasterSeed) -> bytes:
raise TypeError("wallet operations accept only MasterSeed")
return self.fingerprint_seed(secret.seed_bytes)

def verify_identity(self, secret: MasterSeed, expected_fingerprint: bytes | None) -> None:
"""Refuse a recovered seed that is not the recorded wallet before any wallet is touched."""
if expected_fingerprint is not None and self.fingerprint(secret) != expected_fingerprint:
raise FingerprintMismatch(
"The recovered master fingerprint does not match the one from the wallet record. "
"Bitcoin Core was not changed."
)

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):
Expand Down Expand Up @@ -375,15 +439,18 @@ def initialize(
ask: Callable[[str], str],
tell: Callable[[str], None],
*,
expected_fingerprint: bytes | None,
account: int = 0,
timestamp: int | Literal["now"] = "now",
) -> str:
"""Validate input and identity before selecting or changing a wallet."""
if not isinstance(secret, MasterSeed):
raise TypeError("wallet operations accept only MasterSeed")
if type(account) is not int or account != 0:
raise ValueError("Bitcoin Core wallet initialization currently supports only account 0")
if timestamp != "now" and (type(timestamp) is not int or timestamp < 0):
raise ValueError("timestamp must be a nonnegative integer or 'now'")
self.verify_identity(secret, expected_fingerprint)
while True:
name = self._select(ask, tell)
state = self._target(name)
Expand Down
70 changes: 64 additions & 6 deletions src/codex32/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,15 @@
from collections.abc import Callable, Sequence
from typing import Literal, NamedTuple, cast

from codex32._bitcoin_core import BitcoinCore, BitcoinCoreError
from codex32._bitcoin_core import (
NO_RECORD_WARNING,
BitcoinCore,
BitcoinCoreError,
FingerprintMismatch,
identifier_note,
identifier_origin,
parse_fingerprint,
)
from codex32._cli_input import (
CorrectionDeclined,
InteractiveConfirmationRequired,
Expand Down Expand Up @@ -339,23 +347,66 @@ def _generated_secret(
)


def _show_fingerprint(core: BitcoinCore, secret: MasterSeed, action: str) -> None:
_print(f"\nMaster fingerprint: {core.fingerprint(secret).hex().upper()}", err=True)
_text(f"{action}, then press Enter", optional=True, prompt_end=". ")
if sys.stderr.isatty():
_print("\x1b[3J\x1b[2J\x1b[H", err=True)


def _without_record(core: BitcoinCore, secret: MasterSeed) -> bool:
fingerprint = core.fingerprint(secret)
_print(f"\nMaster fingerprint: {fingerprint.hex().upper()}", err=True)
_print(f"Backup identifier: {secret.header.identifier.upper()}", err=True)
_print(identifier_note(identifier_origin(secret, fingerprint)), err=True)
_print(NO_RECORD_WARNING, err=True)
return _text("Restore without a wallet record? [y/N]", optional=True).lower() in ("y", "yes")


def _recorded_fingerprint(core: BitcoinCore, secret: MasterSeed) -> bytes | None:
# Take the master fingerprint from a recovery record until the library accepts it.
prompt = "Type the master fingerprint from your wallet record (Enter if none)"
while True:
text = _text(prompt, optional=True)
if not text:
if _without_record(core, secret):
return None
raise _WalletSetupInterrupted
try:
expected = parse_fingerprint(text)
except ValueError as error:
_print(str(error), err=True)
continue
try:
core.verify_identity(secret, expected)
except FingerprintMismatch as error:
_print(str(error), err=True)
continue
return expected


def _initialize_wallet(
core: BitcoinCore,
secret: MasterSeed,
*,
account: int = 0,
timestamp: int | Literal["now"] = "now",
fresh: bool = True,
restore: bool = False,
confirmed: bool = True,
) -> int:
assert isinstance(secret, MasterSeed)
try:
if confirmed:
_print("Master-seed backup confirmed.\n", err=True)
expected = _recorded_fingerprint(core, secret) if restore else None
if not restore:
_show_fingerprint(core, secret, "Write it on the wallet record")
name = core.initialize(
secret,
lambda prompt: _text(prompt, optional=True),
lambda message: _print(message, err=True),
expected_fingerprint=expected,
account=account,
timestamp=timestamp,
)
Expand Down Expand Up @@ -432,7 +483,7 @@ def _create(
else:
raise _UsageError("For thresholds 4 through 9, choose --shares or --indices.")
core = _connected_core()
source = _creation_source(profile, core.fingerprint) if existing else None
source = _creation_source(profile) if existing else None
if not existing and not sys.stdin.isatty() and _text("", optional=True):
raise _UsageError("Use --existing when supplying a seed or secret.")
if isinstance(source, (Share, Secret)) and not isinstance(source, MasterSeed):
Expand All @@ -447,11 +498,13 @@ def _create(
secret = source
else:
secret = _generated_secret(source, byte_length, identifier, core.fingerprint_seed)
_emit(secret, False, fingerprint=core.fingerprint)
_emit(secret, False, fingerprint=None if existing else core.fingerprint)
if sys.stdin.isatty():
_confirm_card(secret)
return (
_initialize_wallet(core, secret, timestamp=0 if existing else "now", fresh=not existing)
_initialize_wallet(
core, secret, timestamp=0 if existing else "now", fresh=not existing, restore=existing
Comment thread
BenWestgate marked this conversation as resolved.
)
if core is not None
else 0
)
Expand Down Expand Up @@ -493,7 +546,9 @@ def _create(
finished = ceremony.finish()
assert isinstance(finished, MasterSeed)
if core is not None:
return _initialize_wallet(core, finished, timestamp=0 if existing else "now", fresh=not existing)
return _initialize_wallet(
core, finished, timestamp=0 if existing else "now", fresh=not existing, restore=existing
)
_print("\nEvery recovery card was confirmed from its re-entered text.", err=True)
return 0

Expand Down Expand Up @@ -609,13 +664,16 @@ def _bitcoin_core(account: int, timestamp: int | Literal["now"]) -> int:
danger=True,
)
core = _connected_core()
secret = _master_seed(core.fingerprint)
# Keep the recovered fingerprint hidden until the operator has supplied
# independent wallet-record evidence or explicitly chosen recordless restore.
secret = _master_seed()
return _initialize_wallet(
core,
secret,
account=account,
timestamp=timestamp,
fresh=False,
restore=True,
confirmed=False,
)

Expand Down
Loading
Loading