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
11 changes: 10 additions & 1 deletion 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 @@ -633,7 +635,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
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
16 changes: 10 additions & 6 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 @@ -199,10 +200,12 @@ before printing any candidate text, metadata, fingerprint, or residue addends.
It then prints a conspicuous warning covering both deliberate completion of
newly transcribed data and recovery with many missing characters. Literal
uppercase `YES` is required before disclosure; other case variants, blank input,
or EOF terminate the command with status 1. Redirected damaged data may still
reach this gate, but disclosure requires an interactive terminal channel. If no
such channel is available, the sole message is `codex32: interactive confirmation
required` (or `ms32:`). Output formatting and `--plain` cannot bypass the gate.
or EOF terminate standalone `correct` with status 3 and correction embedded in
another workflow with status 1. Redirected damaged data may still reach this
gate, but disclosure requires an interactive terminal channel. If no such
channel is available, the sole message is `codex32: interactive confirmation
required` (or `ms32:`), with the same command-specific status. Output formatting
and `--plain` cannot bypass the gate.
Existing whole-card `[y/N]` acceptance remains required after disclosure when a
workflow will consume the corrected artifact. `correct` only displays the
suggestion, so it has no second acceptance prompt. The gate does not verify the
Expand All @@ -228,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 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
28 changes: 21 additions & 7 deletions docs/user/guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,13 @@ disclosure, workflows that consume the repaired artifact ask the usual `[y/N]`
whole-card confirmation. `correct` only reports a suggestion, so it does not ask
that second question. A checksum cannot make weak input secure.

The `correct` exit status distinguishes outcomes for scripts: `0` means the
input is already valid, `1` means a suggestion was emitted, `2` means the
command or input syntax was invalid, and `3` means no usable suggestion was
emitted. Status `3` includes incomplete searches with no usable suggestion,
ambiguous searches, declined disclosure, and an unavailable dependency needed
to present a suggestion.

Choose the setup that fits you:

- **Recommended: dedicated online spending wallet — easiest.** A normally
Expand Down Expand Up @@ -163,9 +170,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 @@ -223,16 +233,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
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, 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,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 @@ -299,9 +363,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
Loading
Loading