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
10 changes: 6 additions & 4 deletions docs/developer/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -640,10 +640,12 @@ 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:
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.
reports as encrypted. Master-fingerprint and graphical recovery-commitment
derivation are likewise delegated to Core. A stateless root P2PKH descriptor is
normalized to its public root xpub. SHA-256 over the domain-separated canonical
xpub supplies the 256-bit recovery commitment; `deriveaddresses` and
`validateaddress` return the script hash whose first four bytes are the BIP32
fingerprint. Neither operation opens or mutates a wallet.

The Core calls are fixed: `getnetworkinfo`, `getblockchaininfo`, `listwallets`,
`getwalletinfo`, `listdescriptors`, `getdescriptorinfo`, `deriveaddresses`,
Expand Down
6 changes: 4 additions & 2 deletions docs/security/invariants.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,10 @@ and evidence.
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 confirm it against the separately stored wallet record before
any recovered key material may mutate a Bitcoin Core wallet.
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
21 changes: 14 additions & 7 deletions docs/security/model.md
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,10 @@ The operator must:
guarantee new physical entropy between calls.
- A checksum, generation-padding hint, fingerprint, or correction candidate
does not authenticate a backup or prove the operator's intent.
- The graphical recovery commitment is a domain-separated SHA-256 digest of
the canonical root xpub returned by Bitcoin Core. It is public metadata and
authenticates only against the separately stored record; it is not a secret,
a MAC, or proof that the record itself is trustworthy.
- Creation feedback identifies correct groups but does not prove the recovery card was corrected.
- A fresh unshared master seed exposes a public 20-bit BIP32 fingerprint in its
default identifier; fingerprints are metadata, not secrets.
Expand Down Expand Up @@ -269,13 +273,16 @@ 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 master fingerprint through Bitcoin Core
before destination selection and displays it with the backup identifier. The
operator must explicitly confirm that fingerprint against the separately stored
wallet record before the program lists, creates, unlocks, or imports into a
destination wallet. A mismatch can therefore stop recovery without mutating a
Bitcoin Core wallet. The fingerprint remains only a short diagnostic identifier;
its authentication-strength limitation above still applies.
Restore derives the recovered seed's canonical root xpub and master fingerprint
through Bitcoin Core before destination selection. It computes 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
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.

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
Expand Down
21 changes: 15 additions & 6 deletions docs/user/gui.md
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,10 @@ the seed. It protects the wallet on this computer.

Finally, copy the wallet details onto your
[wallet record](wallet-verification-record.html) and keep it apart from every
card. The window shows exactly the fields that record asks for.
card. The window shows exactly the fields that record asks for, including a
long recovery commitment. That commitment is public, but it must stay separate
from the cards because the restore screen uses it to reject the wrong recovered
seed before Bitcoin Core is changed.

A card never contains **B**, **I**, **O** or **1**: those four are left out of
the alphabet precisely because handwriting confuses them with 8, J, L and 0. If
Expand Down Expand Up @@ -142,11 +145,17 @@ 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
real one is already on your record and today's would replace it.
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.

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
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
record. If you are restoring a wallet whose record shows a different account
Expand Down
12 changes: 7 additions & 5 deletions docs/user/guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -168,11 +168,13 @@ 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
creation / earliest-use date. Do not put a descriptor timestamp on a recovery
card; Core's public descriptor export preserves its stored timestamps.
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.

Store each card securely. For a shared backup, use different trusted places.
Keep the wallet record separately from all cards.
Expand Down
7 changes: 5 additions & 2 deletions docs/user/wallet-verification-record.html
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ <h2>Wallet identity</h2>
<div class="field">Bitcoin Core version:</div>
<div class="field">Approximate creation / earliest-use date:</div>
<div class="field">Master fingerprint:</div>
<div class="field wide">Recovery commitment:</div>
<div class="field">Derivation standards:</div>
<div class="field">Account number:</div>
<div class="field wide">Descriptor or policy archive location:</div>
Expand All @@ -56,6 +57,7 @@ <h2>Initial wallet setup</h2>

<h2>Later wallet verification for recovery</h2>
<p><span class="check"></span> Followed the documented Bitcoin Core recovery workflow.</p>
<p><span class="check"></span> Entered the recovery commitment before any recovered keys were imported.</p>
<p><span class="check"></span> Matched the expected master fingerprint.</p>
<p><span class="check"></span> Matched account, complete policy, history, and balance.</p>
<p><span class="check"></span> If available, a trusted person reviewed the wallet setup and recovery plan.</p>
Expand All @@ -67,8 +69,9 @@ <h2>Private restoration record</h2>
<div class="field wide">Offline device and destination wallet notes:</div>
</div>
<p class="small">
This record cannot authenticate a replaced backup by itself. Treat it as privacy-sensitive:
it can reveal wallet structure, balances, and transaction history even though it cannot spend.
The recovery commitment binds a recovered single-key seed to this separately stored record.
It does not authenticate a multisig policy, descriptor archive, history, or balance. Treat this
record as privacy-sensitive even though it cannot spend.
</p>
</body>
</html>
28 changes: 25 additions & 3 deletions src/codex32/_bitcoin_core.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
from __future__ import annotations

import hashlib
import json
import re
import shutil
Expand Down Expand Up @@ -29,6 +30,7 @@ class BitcoinCoreError(Exception):
_ORIGIN = re.compile(r"\[(?P<fingerprint>[0-9a-f]{8})(?P<path>(?:/[0-9]+[h']?)*)\]")
_PRIVATE_MARKERS = ("xprv", "tprv")
_PURPOSES = (44, 49, 84, 86)
_RECOVERY_COMMITMENT_DOMAIN = b"codex32 recovery commitment\0"


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

def fingerprint_seed(self, seed: bytes) -> bytes:
"""Return the BIP32 master fingerprint using Bitcoin Core out of process."""
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})")
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(")#")
prefix = "xpub" if self.chain == "main" else "tpub"
if separator != ")#" or not checksum or not root_xpub.startswith(prefix):
raise BitcoinCoreError("Bitcoin Core did not return the expected root public key.")
try:
commitment = hashlib.sha256(_RECOVERY_COMMITMENT_DOMAIN + root_xpub.encode("ascii")).digest()
except UnicodeEncodeError as error:
raise BitcoinCoreError("Bitcoin Core returned an invalid root public key.") from error
addresses = self._rpc("deriveaddresses", stdin=descriptor + "\n")
if not isinstance(addresses, list) or len(addresses) != 1 or not isinstance(addresses[0], str):
raise BitcoinCoreError("Bitcoin Core did not derive the expected master-key address.")
Expand All @@ -143,16 +155,26 @@ def fingerprint_seed(self, seed: bytes) -> bytes:
):
raise BitcoinCoreError("Bitcoin Core did not return the expected master-key script.")
try:
return bytes.fromhex(script[6:14])
return bytes.fromhex(script[6:14]), commitment
except ValueError as error:
raise BitcoinCoreError("Bitcoin Core returned an invalid master-key script.") from error

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]

def fingerprint(self, secret: MasterSeed) -> bytes:
"""Return the BIP32 master fingerprint for a validated master seed."""
if not isinstance(secret, MasterSeed):
raise TypeError("wallet operations accept only MasterSeed")
return self.fingerprint_seed(secret.seed_bytes)

def recovery_identity(self, secret: MasterSeed) -> tuple[bytes, bytes]:
"""Return public recovery identity for a validated master seed."""
if not isinstance(secret, MasterSeed):
raise TypeError("wallet operations accept only MasterSeed")
return self.recovery_identity_seed(secret.seed_bytes)

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
36 changes: 27 additions & 9 deletions src/codex32_gui/pages.py
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,7 @@ class Record:
wallet: str
version: str
fingerprint: str
commitment: str
account: int


Expand Down Expand Up @@ -924,11 +925,13 @@ def _record(
core: BitcoinCore, secret: MasterSeed, name: str, timestamp: Timestamp, passphrase: str = ""
) -> Record:
final = wallet_setup.fill(core, secret, name, passphrase, timestamp=timestamp)
fingerprint, commitment = wallet_setup.identity(core, secret)
return Record(
secret.header.identifier.upper(),
final,
wallet_setup.version_text(core),
wallet_setup.fingerprint(core, secret),
fingerprint,
commitment,
0,
)

Expand All @@ -938,38 +941,52 @@ def _restore_identity_page(
core: BitcoinCore,
secret: MasterSeed,
fingerprint: str,
commitment: str,
) -> Adw.NavigationPage:
"""Require the wallet record to match before any recovered key is imported."""
"""Require a separately stored strong commitment before importing recovered keys."""
entered = Adw.EntryRow(title="Recovery commitment from wallet record")
group = Adw.PreferencesGroup()
group.add(entered)
status = _note("")

def continue_restore() -> None:
if not wallet_setup.commitment_matches(commitment, entered.get_text()):
_say(status, "That commitment does not match this recovered wallet. Stop here.", "error")
return
_empty(entered)
_wallets(view, core, secret, 0, restoring=True)

content = _column(
_title(
"Check the wallet before restoring it",
"Compare this master fingerprint with the wallet record you stored separately from the cards.",
"Type the recovery commitment from the wallet record stored separately from the cards.",
),
_rows(
"Recovered wallet identity",
"Diagnostic identity",
(
("Backup identifier", secret.header.identifier.upper()),
("Master fingerprint", fingerprint),
),
),
group,
status,
_note(
"If the fingerprint does not match your wallet record, stop. Bitcoin Core has not been changed.",
"The expected commitment is deliberately not shown here. If your record has no recovery "
"commitment, stop; do not substitute the master fingerprint. Bitcoin Core has not been changed.",
"warning",
),
)
return _page(
page = _page(
"Verify wallet",
content,
actions=_actions(
_button("Stop", lambda: view.replace([home(view)])),
_button("Matches my record", continue_restore, style="suggested-action"),
_button("Verify and continue", continue_restore, style="suggested-action"),
),
can_pop=False,
)
_forget_when_gone(view, page, lambda: _empty(entered))
return page


def _verify_restore(view: Adw.NavigationView, core: BitcoinCore, secret: MasterSeed) -> None:
Expand All @@ -978,11 +995,11 @@ def _verify_restore(view: Adw.NavigationView, core: BitcoinCore, secret: MasterS
work.run(
view,
page,
lambda: wallet_setup.fingerprint(core, secret),
lambda: wallet_setup.identity(core, secret),
_then(
view,
page,
lambda fingerprint: _restore_identity_page(view, core, secret, fingerprint),
lambda identity: _restore_identity_page(view, core, secret, *identity),
CARDS_SAFE,
),
)
Expand Down Expand Up @@ -1026,6 +1043,7 @@ def _finished_page(view: Adw.NavigationView, record: Record, restoring: bool = F
("Bitcoin Core version", record.version),
*dated,
("Master fingerprint", record.fingerprint),
("Recovery commitment", record.commitment),
("Derivation standards", "BIP 44, 49, 84 and 86"),
("Account number", str(record.account)),
),
Expand Down
16 changes: 16 additions & 0 deletions src/codex32_gui/wallet_setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,12 +32,14 @@
"BitcoinCoreError",
"Offer",
"Wallet",
"commitment_matches",
"connect",
"create",
"eligible",
"fill",
"fingerprint",
"fingerprint_provider",
"identity",
"initialize",
"network",
"relock",
Expand Down Expand Up @@ -140,6 +142,20 @@ def fingerprint(core: BitcoinCore, secret: MasterSeed) -> str:
return core.fingerprint(secret).hex()


def identity(core: BitcoinCore, secret: MasterSeed) -> tuple[str, str]:
"""Return the short fingerprint and strong public recovery commitment."""
fingerprint_bytes, commitment = core.recovery_identity(secret)
text = commitment.hex().upper()
return fingerprint_bytes.hex(), " ".join(text[start : start + 4] for start in range(0, len(text), 4))


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


def fingerprint_provider(core: BitcoinCore) -> Callable[[bytes], bytes]:
"""Hand the library the same out-of-process derivation for a raw seed."""
return core.fingerprint_seed
Expand Down
Loading
Loading