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
32 changes: 19 additions & 13 deletions docs/developer/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -100,7 +100,7 @@ generic parse-length failure.
hidden state.

Private Python names are convention rather than access control. The supported
surface is the 25-name package `__all__`; direct use of private helpers is
surface is the 23-name package `__all__`; direct use of private helpers is
unsupported but remains in the review scope.

### Size budget
Expand Down 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 @@ -578,27 +580,24 @@ Public wallet operations accept only a validated `MasterSeed`. `wallet.py` is
stateless and never accepts shares, Core Lightning secrets, BIP39 migration
artifacts, or raw bytes.

The public adapter has two functions:

- `master_xprv(secret, testnet=False)` returns the BIP32 root extended private
key.
- `core_descriptors(...)` returns fixed BIP44, BIP49, BIP84, and BIP86 Bitcoin
Core `importdescriptors` records. Private records use stdlib-only root xprv
serialization; public records require an explicit wallet integration and the
Core wallet whose imported root key will perform hardened derivation.
The supported package surface exposes one wallet primitive:
`master_xprv(secret, testnet=False)`, which returns the BIP32 root extended
private key. Bitcoin Core descriptor-record construction is an internal
test/reference detail rather than a supported package API.

No installed Python dependency performs secp256k1 operations. The private
Bitcoin Core adapter gives Core the root xprv over stdin and asks Core to
create the four standard account-0 descriptor types. Public descriptor
derivation remains available through the explicit integration API.
create the four standard account-0 descriptor types. Descriptor normalization
and public derivation stay behind that private Core boundary.

Public descriptors contain account xpubs. Private descriptors intentionally
follow Bitcoin Core's root-key form: they contain the root xprv followed by the
complete derivation path. They therefore grant authority over the entire root,
not only the selected account. The CLI warns before printing them.

Account, private/public mode, network serialization, and timestamp are explicit
API inputs. The `ms32 wallet` CLI takes `--account 0` and `--timestamp`; the
Account and timestamp remain explicit at the Core boundary, while network
serialization is explicit for `master_xprv`. The `ms32 wallet` CLI takes
`--account 0` and `--timestamp`; the
selected Bitcoin Core chain is authoritative and there is no wallet
`--testnet` flag. `ms32 xprv --testnet` remains explicit because it directly
selects xprv versus tprv serialization. The timestamp defaults to `0` so
Expand All @@ -624,6 +623,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
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 matched a
seed-derived rule or its standard Bails check was unavailable. Fresh
`ms32 create` ceremonies 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
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. For `create --existing`, the wallet-record decision also precedes generation or display of any new card. 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. If RIPEMD-160 is unavailable, the standard Bails identifier check is reported as inconclusive rather than a mismatch; the Bails-alpha SHA-256 rule remains checkable. 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
35 changes: 27 additions & 8 deletions docs/user/guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,14 @@ 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 Bitcoin Core being unavailable
when `ms32 correct` needs it to rank or fingerprint a master-seed suggestion.
The generic `codex32 correct` command does not need Core.

Choose the setup that fits you:

- **Recommended: dedicated online spending wallet — easiest.** A normally
Expand Down Expand Up @@ -115,7 +123,11 @@ Already have a complete codex32 `ms` secret? Run `ms32 create --existing` to
write and confirm its recovery card and initialize a Bitcoin Core wallet.
The existing secret is preserved unchanged. To split it into three cards
requiring any two, use `ms32 create 2 --existing` instead. Enter the secret
only when prompted. Bitcoin Core also scans for prior transactions.
only when prompted. Immediately afterward, type the master fingerprint from
the separate wallet record; a mismatch must be resolved before any new card
is shown. If you have no record, the explicit recordless-restore choice and
visual fingerprint check happen at this same point. Bitcoin Core also scans
for prior transactions.

### 3. Make a Bitcoin Core wallet

Expand Down Expand Up @@ -163,9 +175,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 @@ -226,16 +241,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
1 change: 0 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,6 @@ dev = [
"twine>=5,<7",
]


[tool.setuptools.packages.find]
where = ["src"]

Expand Down
3 changes: 1 addition & 2 deletions src/codex32/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@
from .profiles.bip39 import Bip39Secret
from .profiles.cl32 import CoreLightningSecret
from .profiles.ms32 import MasterSeed
from .wallet import core_descriptors, master_xprv
from .wallet import master_xprv

__all__ = [
"Bip39Secret",
Expand All @@ -45,7 +45,6 @@
"Secret",
"Share",
"WorksheetCorrection",
"core_descriptors",
"correct",
"correct_worksheet_residue",
"derive_share",
Expand Down
76 changes: 76 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,63 @@ 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 == "Bails check unavailable":
return (
"The standard Bails identifier could not be checked because RIPEMD-160 is unavailable. "
"This does not prove the cards are wrong or mixed up. Compare the fingerprint and any "
"other wallet record you have before restoring."
)
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"
ripemd_unavailable = False
for name, digest in (("Bails", "ripemd160"), ("Bails alpha", "sha256")):
try:
hashed = hashlib.new(digest, secret.seed_bytes).digest()
except ValueError:
if name == "Bails":
ripemd_unavailable = True
continue
derived = convertbits(hashed, 8, 5, pad=True)
if identifier[:3] == _u5_to_chars(tuple(derived[:3])):
return name
return "Bails check unavailable" if ripemd_unavailable else None


@dataclass(frozen=True)
class BitcoinCore:
executable: str
Expand Down Expand Up @@ -154,6 +219,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 +448,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
Loading
Loading