Skip to content
Draft
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
11 changes: 10 additions & 1 deletion docs/developer/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -202,6 +202,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 @@ -640,7 +642,14 @@ fingerprint consistency, and network xpub/tpub versions, constructs only the
fixed descriptor templates, and asks `getdescriptorinfo` to validate and expand
their external/internal branches. The adapter then compares the exact eight
active public descriptors against `listdescriptors`. It relocks wallets Core
reports as encrypted. Master-fingerprint display is likewise delegated to Core:
reports as encrypted. Before any of this, `initialize` calls `verify_identity`
with `expected_fingerprint`. Restore callers normally supply bytes typed from
the wallet record (read with `parse_fingerprint`); `None` means either a fresh
creation, where there is no pre-existing wallet identity to authenticate, or
the operator's explicit choice to restore without a record after seeing the
fingerprint and `identifier_origin`. A mismatch raises `FingerprintMismatch`
before any wallet RPC.
Master-fingerprint display is likewise delegated to Core:
a stateless root P2PKH descriptor is normalized, `deriveaddresses` derives its
address, and `validateaddress` returns the script hash whose first four bytes are
the BIP32 fingerprint.
Expand Down
7 changes: 7 additions & 0 deletions docs/security/invariants.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,13 @@ 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 creation ceremonies, in `ms32 create` or the GUI, do not
authenticate against a 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
12 changes: 10 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 @@ -227,6 +228,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 | Restore 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. Fresh creation (`ms32 create` or GUI setup) 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. |
| 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 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. |
Expand Down Expand Up @@ -268,6 +270,12 @@ only while more than one answers. On screen a wallet is chosen by the position o
its row, never by the text of its label, and Core's text is rendered without
Pango markup, so a wallet name cannot hide or impersonate another.

The window uses the same recovery-identity gate on restore. Its restore page
asks for the fingerprint without showing it, while fresh setup only shows the
new fingerprint and requires **I wrote it down** because there is no pre-existing
wallet identity to authenticate. **I have no wallet record** shows the recovered
fingerprint, the identifier result, and the warning before the operator chooses.

The program draws no entropy, opens no socket, starts no process of its own, and
writes no file: no settings, no recent list, no log, and no clipboard write of
recovery text. Entered recovery text is cleared when its screen is left, subject
Expand Down
21 changes: 16 additions & 5 deletions docs/user/gui.md
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,12 @@ the paper with the original off the screen. That catches a slip of the pen now
rather than years from now. If a group does not match, the window says which one;
correct that group and try again, as many times as you like.

When every card is confirmed, choose the Bitcoin Core wallet that will hold the
When every card is confirmed, the window shows the master fingerprint. Write it
on your [wallet record](wallet-verification-record.html), then press **I wrote it
down**. This is a new wallet ceremony, so there is no pre-existing fingerprint
or descriptor to authenticate against.

Next, choose the Bitcoin Core wallet that will hold the
keys. Only empty wallets are offered, so no wallet you already use can be
overwritten. If you have none, the window can ask Bitcoin Core to create one:
give it a name and a passphrase, and codex32 fills it in and locks it again.
Expand Down Expand Up @@ -142,10 +147,16 @@ wallet**, which would make a different backup. If the wallet was part-filled
before it failed, it is no longer empty, so it will not be offered again: create
another one, or ask Bitcoin Core for a fresh blank wallet.

When you restore, the window asks you to **check** the wallet details against
your record rather than copy them onto it. That comparison — the master
fingerprint above all — is the only thing that proves the cards you just typed
belong to that wallet. It shows no creation date on that screen, because the
When you restore, the window first asks you to type the master fingerprint from
your wallet record. If it does not match, nothing is written to Bitcoin Core:
check what you typed, and if it still does not match, these cards are not that
wallet. If you have no record, **I have no wallet record** shows the master
fingerprint and whether the backup identifier was made from the recovered seed,
explains what that can and cannot prove, and restores only if you still choose
to. Check the balance and history before you send money to that wallet.

After the restore, **check** the remaining wallet details against your record
rather than copy them onto it. It shows no creation date on that screen, because the
real one is already on your record and today's would replace it.

The window always uses account 0, which is what it writes onto your wallet
Expand Down
20 changes: 13 additions & 7 deletions docs/user/guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -168,9 +168,11 @@ 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 the wallet is filled, write the displayed master fingerprint on the
wallet record and confirm that you wrote it down. Creation is not a restore, so
there is no pre-existing fingerprint or descriptor to authenticate here. 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 @@ -228,16 +230,20 @@ its public wallet data with the separate wallet record.
ms32 wallet --timestamp 0
```

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 imports the private descriptors, verifies the public set, 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
72 changes: 72 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, core_descriptors

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 @@ -31,6 +39,54 @@ class BitcoinCoreError(Exception):
_PURPOSES = (44, 49, 84, 86)


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 @@ -153,6 +209,19 @@ 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.

`None` is the operator's explicit choice to restore without a record; nothing is checked.
"""
if expected_fingerprint is None:
return
if 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 @@ -299,9 +368,12 @@ def initialize(
ask: Callable[[str], str],
tell: Callable[[str], None],
*,
expected_fingerprint: bytes | None,
account: int = 0,
timestamp: int | Literal["now"] = "now",
) -> str:
"""Optionally check recovery identity, then import into one empty wallet the operator chooses."""
self.verify_identity(secret, expected_fingerprint)
while True:
name = self._select(ask, tell)
state = self._target(name)
Expand Down
64 changes: 61 additions & 3 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 @@ -333,23 +341,68 @@ 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
continue
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)
if restore:
expected = _recorded_fingerprint(core, secret)
else:
_show_fingerprint(core, secret, "Write it on the wallet record")
expected = None
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 @@ -447,7 +500,9 @@ def _create(
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
)
if core is not None
else 0
)
Expand Down Expand Up @@ -489,7 +544,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 @@ -597,6 +654,7 @@ def _bitcoin_core(account: int, timestamp: int | Literal["now"]) -> int:
account=account,
timestamp=timestamp,
fresh=False,
restore=True,
confirmed=False,
)

Expand Down
Loading
Loading