Skip to content
Closed
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
12 changes: 12 additions & 0 deletions docs/developer/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -665,6 +665,18 @@ through `bitcoin-cli -stdin`, verifies the exact accepted public descriptor
set, and relocks an encrypted destination after success, failure, or
interruption. It never handles a passphrase.

Legacy wallet records can be upgraded before recovery is needed with:

```text
ms32 wallet --enroll
```

This mode accepts no recovery material. It asks the operator to choose a loaded
established private wallet, reads its single root xpub with `gethdkeys`, derives
the same public fingerprint and recovery commitment, and displays those values
for comparison and copying into the old record. It does not select an empty
destination, unlock, import, or mutate a wallet.

For offline signing/watch-only and multisig workflows, use Bitcoin Core v32's
maintained procedures. Until the final v32 release, see the versioned
[offline-signing tutorial](https://github.com/bitcoin/bitcoin/blob/v32.0rc1/doc/offline-signing-tutorial.md)
Expand Down
10 changes: 5 additions & 5 deletions docs/security/invariants.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,11 +11,11 @@ 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.
Graphical recovery derives public wallet identity first and requires the
operator to enter a separately stored SHA-256 commitment to the canonical
root xpub before any recovered key material may mutate a Bitcoin Core wallet.
The 32-bit BIP32 fingerprint is diagnostic metadata, never the authorization
value for this transition.
Graphical and command-line recovery derive public wallet identity first and
require the operator to enter a separately stored SHA-256 commitment to the
canonical root xpub before any recovered key material may mutate a Bitcoin
Core wallet. The 32-bit BIP32 fingerprint is diagnostic metadata, never the
authorization value for this transition.
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
17 changes: 14 additions & 3 deletions docs/security/model.md
Original file line number Diff line number Diff line change
Expand Up @@ -234,6 +234,8 @@ signing setup belong to Bitcoin Core's maintained v32 workflow.
| 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. |
| 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. |
| Recovery authorization | Before CLI restoration or resharing of an existing seed reaches wallet initialization, codex32 derives the public recovery identity without importing descriptors. The operator must type the separately stored 256-bit recovery commitment; a mismatch stops before wallet mutation. The BIP32 fingerprint is diagnostic only. |
| Legacy record enrollment | `ms32 wallet --enroll` accepts no recovery material and performs no wallet mutation. It reads the canonical root xpub from one operator-selected, loaded established private wallet, derives the same public fingerprint and 256-bit commitment, and displays them so an older record can be upgraded before recovery is needed. The operator compares the wallet name and fingerprint with that existing record before copying the commitment. |
| 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. |
| Secret channel | Private descriptor JSON is sent only through the child's standard input. It is absent from arguments, ordinary output, and diagnostics. The library and the command-line programs have no passphrase channel, and raw Core errors are suppressed. |
| Revalidation | Every destination property is checked again immediately before import. Every private import must succeed before public verification begins. `gethdkeys` must expose one private wallet root; `derivehdkey` must return the requested hardened account paths with one consistent fingerprint and the correct network xpub/tpub version. `getdescriptorinfo` then validates and expands the fixed public templates, and the exact eight active descriptors must match Core's accepted set. |
Expand Down Expand Up @@ -273,17 +275,26 @@ 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.

Restore derives the recovered seed's canonical root xpub and master fingerprint
through Bitcoin Core before destination selection. It computes the recovery
Graphical and command-line restore derive the recovered seed's canonical root
xpub and master fingerprint through Bitcoin Core before destination selection.
They compute the recovery
commitment as `SHA256(domain_tag || root_xpub)`, where `domain_tag` is the
ASCII text `codex32 recovery commitment` followed by one NUL byte. It asks the
ASCII text `codex32 recovery commitment` followed by one NUL byte. They ask the
operator to enter the 256-bit value from the separately stored wallet
record. The expected commitment is not displayed before comparison. Only a
match permits the program to list, create, unlock, or import into a destination
wallet, so a mismatch can stop recovery without mutating one. The fingerprint
is still displayed as a short diagnostic identifier but is not used to authorize
the transition.

Legacy records that predate the commitment field are enrolled separately while
their established Core wallet is still available. `ms32 wallet --enroll` reads
the selected loaded wallet's one private HD root, converts only its xpub into the
same public recovery identity, and displays the wallet name, fingerprint, and
commitment. It never reads recovery cards, imports descriptors, unlocks a wallet,
or writes to Core. A record without that independently enrolled value does not
gain a weaker fingerprint-only restore bypass.

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
6 changes: 4 additions & 2 deletions docs/user/gui.md
Original file line number Diff line number Diff line change
Expand Up @@ -149,8 +149,10 @@ When you restore, the window first asks you to type the recovery commitment from
the separate wallet record. It deliberately does not show the value it expects.
If the value does not match, it does not list, create, unlock, or fill a Bitcoin
Core wallet. Do not substitute the shorter master fingerprint. Older wallet
records without a recovery commitment need to be updated before relying on this
pre-import check.
records without a recovery commitment need to be updated while the established
wallet is still available: load it in Bitcoin Core, run `ms32 wallet --enroll`,
compare the displayed wallet name and master fingerprint with the old record,
and then copy the displayed commitment into that record.

After a successful restore, the window asks you to check the remaining wallet
details against the record rather than copy them onto it. It shows no creation
Expand Down
41 changes: 27 additions & 14 deletions docs/user/guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -168,17 +168,22 @@ wallet should be trusted until initialization completes.

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

For graphical setup, copy the displayed backup identifier, wallet name, Bitcoin
Core version, master fingerprint, recovery commitment, derivation standards,
and account number to the wallet record. The command-line workflow does not yet
display the recovery commitment, so do not rely on a CLI-created record for
graphical recovery. 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.
Copy the displayed backup identifier, wallet name, Bitcoin Core version,
master fingerprint, recovery commitment, 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.

Store each card securely. For a shared backup, use different trusted places.
Keep the wallet record separately from all cards.

If an older wallet record predates the recovery-commitment field, update it
while the established spending wallet is still available. Load that wallet in
Bitcoin Core and run `ms32 wallet --enroll`. codex32 reads only the wallet's
public root identity and does not read recovery cards or change the wallet.
Compare the displayed wallet name and master fingerprint with the old record;
only after they match, copy the displayed recovery commitment into the record.

### 5. Receive and spend normally

Reconnect if needed and let the normally networked Bitcoin Core node finish
Expand Down Expand Up @@ -213,12 +218,16 @@ descriptor-transfer procedure in place of that maintained workflow.
## Recover an existing or inherited wallet

An existing wallet has records and history that can identify a wrong recovery.
Restore it on the intended offline or otherwise trusted signer before comparing
its public wallet data with the separate wallet record.
The recovered wallet must match the separately stored recovery commitment before
private descriptors are imported. The four-byte BIP32 fingerprint remains a
diagnostic check, not authorization to restore.

1. Collect the required cards with matching identifiers and text lengths.
2. Find the separately stored wallet record and the original wallet
instructions.
instructions. If the record has no recovery commitment and the established
wallet still exists, stop and enroll it first with `ms32 wallet --enroll` as
described above. Do not invent a commitment from the recovery cards during
an emergency restore.
3. On Tails or another reviewed offline computer, check each card with
`ms32 check`. If validation fails, recheck what you typed before assuming
the paper is wrong.
Expand All @@ -230,16 +239,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. codex32 shows the recovered master fingerprint for diagnosis, then asks for
the recovery commitment from the separately stored wallet record. Type the
complete commitment. A mismatch stops before any descriptor import; do not
substitute the fingerprint for this check.
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 fingerprint, account, policy, addresses, balance, and
transaction history with the wallet record as secondary checks.

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
66 changes: 62 additions & 4 deletions src/codex32/_bitcoin_core.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,21 @@ class BitcoinCoreError(Exception):
_RECOVERY_COMMITMENT_DOMAIN = b"codex32 recovery commitment\0"


def _recovery_commitment_text(commitment: bytes) -> str:
"""Format a full recovery commitment for records and comparison."""
if len(commitment) != 32:
raise ValueError("recovery commitments must be 32 bytes")
text = commitment.hex().upper()
return " ".join(text[start : start + 4] for start in range(0, len(text), 4))


def _recovery_commitment_matches(expected: str, entered: str) -> bool:
"""Compare commitment text, ignoring only whitespace and case."""
expected_text = "".join(expected.split()).upper()
entered_text = "".join(entered.split()).upper()
return len(expected_text) == len(entered_text) == 64 and entered_text == expected_text


@dataclass(frozen=True)
class BitcoinCore:
executable: str
Expand Down Expand Up @@ -128,10 +143,8 @@ def _normalized_descriptor(self, descriptor: str) -> str:
raise BitcoinCoreError("Bitcoin Core did not return the expected public descriptor.")
return normalized

def recovery_identity_seed(self, seed: bytes) -> tuple[bytes, bytes]:
"""Return the BIP32 fingerprint and a SHA-256 commitment to the root xpub."""
xprv = _master_xprv_from_seed(seed, testnet=self.chain != "main")
descriptor = self._normalized_descriptor(f"pkh({xprv})")
def _recovery_identity_descriptor(self, descriptor: str) -> tuple[bytes, bytes]:
"""Return recovery identity from one normalized root P2PKH descriptor."""
if not descriptor.startswith("pkh(") or ")#" not in descriptor:
raise BitcoinCoreError("Bitcoin Core did not return the expected root public descriptor.")
root_xpub, separator, checksum = descriptor[4:].partition(")#")
Expand Down Expand Up @@ -159,6 +172,19 @@ def recovery_identity_seed(self, seed: bytes) -> tuple[bytes, bytes]:
except ValueError as error:
raise BitcoinCoreError("Bitcoin Core returned an invalid master-key script.") from error

def _recovery_identity_xpub(self, root_xpub: str) -> tuple[bytes, bytes]:
"""Return recovery identity from an established wallet's public root key."""
descriptor = self._normalized_descriptor(f"pkh({root_xpub})")
normalized_xpub = descriptor[4:].partition(")#")[0] if descriptor.startswith("pkh(") else ""
if normalized_xpub != root_xpub:
raise BitcoinCoreError("Bitcoin Core changed the established wallet root public key.")
return self._recovery_identity_descriptor(descriptor)

def recovery_identity_seed(self, seed: bytes) -> tuple[bytes, bytes]:
"""Return the BIP32 fingerprint and a SHA-256 commitment to the root xpub."""
xprv = _master_xprv_from_seed(seed, testnet=self.chain != "main")
return self._recovery_identity_descriptor(self._normalized_descriptor(f"pkh({xprv})"))

def fingerprint_seed(self, seed: bytes) -> bytes:
"""Return the BIP32 master fingerprint using Bitcoin Core out of process."""
return self.recovery_identity_seed(seed)[0]
Expand All @@ -175,6 +201,38 @@ def recovery_identity(self, secret: MasterSeed) -> tuple[bytes, bytes]:
raise TypeError("wallet operations accept only MasterSeed")
return self.recovery_identity_seed(secret.seed_bytes)

def enrollment_identity(
self,
ask: Callable[[str], str],
tell: Callable[[str], None],
) -> tuple[str, bytes, bytes]:
"""Choose an established loaded wallet and return its public recovery identity."""
choices: list[tuple[str, bytes, bytes]] = []
for name in sorted(self._names()):
try:
fingerprint, commitment = self._recovery_identity_xpub(self._root_xpub(name))
except BitcoinCoreError:
continue
choices.append((name, fingerprint, commitment))
if not choices:
raise BitcoinCoreError(
"No loaded Bitcoin Core wallet exposes one private HD root. Load the established spending wallet first."
)
while True:
tell("Loaded Bitcoin Core wallets that can enroll a recovery commitment:")
for number, (name, _fingerprint, _commitment) in enumerate(choices, 1):
tell(f" {number}. {json.dumps(name)}")
answer = ask("Choose the established wallet number")
if not answer.isdecimal() or not 1 <= int(answer) <= len(choices):
tell("Enter one of the displayed numbers.")
continue
selected = choices[int(answer) - 1]
if ask(f"Read public recovery identity from {json.dumps(selected[0])}? [y/N]").lower() in (
"y",
"yes",
):
return selected

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
5 changes: 5 additions & 0 deletions src/codex32/_cli_parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -185,6 +185,11 @@ def parser(prog: str = "codex32", *, master_seed: bool = False) -> argparse.Argu

wallet = _command(commands, "wallet", "restore a Bitcoin Core wallet")
_wallet_options(wallet)
wallet.add_argument(
"--enroll",
action="store_true",
help="record a recovery commitment from an established loaded wallet without reading recovery cards",
)

xprv = _command(commands, "xprv", "export the root extended private key")
xprv.add_argument("--testnet", action="store_true", help="use a testnet key")
Expand Down
Loading
Loading