Skip to content
Merged
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
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@ avoid comments or tests that restate the implementation. Add or update concise
docstrings when changing public behavior. Write codex32 in lowercase except
when referring to the Codex32 Book.

Keep the installed package below 5,000 logical review lines, as enforced by the
Keep the installed package below 5,025 logical review lines, as enforced by the
existing test. New dependencies, public API signature or return-shape changes,
and lint suppressions require user authorization; an explicit request can
already provide that authorization.
Expand Down
22 changes: 17 additions & 5 deletions docs/developer/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -102,7 +102,7 @@ unsupported but remains in the review scope.

### Size budget

V1 keeps the installed package below 5,000 logical review lines, excluding
V1 keeps the installed package below 5,025 logical review lines, excluding
blank and comment-only lines while counting subpackages recursively. Changing
the budget requires explicit review and authorization together with the matching
documentation and enforcement update.
Comment thread
BenWestgate marked this conversation as resolved.
Expand All @@ -114,7 +114,7 @@ installed as `codex32[gui]` and started by `codex32-gui`. It is a client of the
surface above and of the private Core adapter; nothing in `src/codex32/` imports
it, and the base install keeps its property of having no third-party runtime
dependency. It carries its own budget of 2,050 logical review lines, separate
from the 5,000 above. Its own boundaries are documented in
from the 5,025 above. Its own boundaries are documented in
[`gui.md`](gui.md) and enforced by `tests/test_gui_boundaries.py`.

## Profile and opaque-HRP capabilities
Expand Down Expand Up @@ -200,8 +200,9 @@ emitted and confirmed unchanged, and the original validated artifact initializes
the wallet. This neutral source prompt tries raw hexadecimal first and otherwise
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.
the generation path. Existing imports ask when the seed was first used and
rescan from a day before that date; a blank answer uses timestamp zero to
include all prior history. Changing a supplied secret's identifier requires a sharing threshold.
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 @@ -652,7 +653,18 @@ 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.

The Core calls are fixed: `getnetworkinfo`, `getblockchaininfo`, `listwallets`,
Immediately before `importdescriptors`, `check_history` refuses a dated rescan
Core could not finish: a start within a day of the pruned node's oldest kept
block (`getblockstats`), where a future timestamp counts as the tip's median
time because Core scans from there, or any dated import while `getchainstates`
shows an AssumeUTXO snapshot still being validated in the background. Unclear
answers are refused too. A `"now"` import is never refused: a fresh seed has no
history, and Core shows when its wallets are still catching up. The GUI runs
the same check before `createwallet` for a restore, and `ms32 create
--existing` asks when the seed was first used instead of rescanning from 0.
Comment thread
BenWestgate marked this conversation as resolved.

The Core calls are fixed: `getnetworkinfo`, `getblockchaininfo`,
`getblockstats`, `getchainstates`, `listwallets`,
`getwalletinfo`, `listdescriptors`, `getdescriptorinfo`, `deriveaddresses`,
`validateaddress`, `importdescriptors`, `gethdkeys`, `derivehdkey`, and
`walletlock`. Bitcoin Core alone creates wallets, selects encryption, handles
Expand Down
10 changes: 7 additions & 3 deletions docs/developer/gui.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ real widgets through every task under a throwaway X server and is the cheapest
way to see the screens without a desktop.

The package carries its own budget of 2,050 logical lines, separate from the
5,000 the installed library keeps, and `tests/test_gui_boundaries.py` enforces
5,025 the installed library keeps, and `tests/test_gui_boundaries.py` enforces
it. The plan proposed 1,000 before the screens were written and the budget was
1,800 before the security review of 2026-09-19; that review's remediations are
about 250 lines, and the rest of the difference is user-facing wording in
Expand Down Expand Up @@ -102,8 +102,12 @@ Bitcoin Core's own 180-second timeout. `unlock` carries the same obligation over
its own post-check. Both are exercised in `tests/test_gui_wallet_setup.py`.

**Creating the blank wallet.** `createwallet` is issued with one fixed shape:
`wallet_name`, `disable_private_keys=false`, `blank=true`, and a `passphrase`
line only when one was given. No other option is ever sent. Bitcoin Core's
`wallet_name`, `disable_private_keys=false`, `blank=true`,
`load_on_startup=true`, and a `passphrase` line only when one was given. No
other option is ever sent. `load_on_startup` has Bitcoin Core record the wallet
in its own settings, so Core loads it at every start and a pruned node never
prunes past it; when Core warns that it could not save that setting, `create`
refuses the wallet. Bitcoin Core's
`CreateWallet` guards its unlock branch with `if (!create_blank)`, so a blank
encrypted wallet is created locked; the window already holds the passphrase, so
it unlocks immediately and the separate unlock screen appears only for a wallet
Expand Down
6 changes: 4 additions & 2 deletions docs/security/invariants.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,8 +42,10 @@ and evidence.
`codex32_gui/wallet_setup.py`, which is also the only module there that
speaks to Bitcoin Core: it may send an operator-supplied passphrase to
`bitcoin-cli` on standard input to unlock a wallet, may create one blank
descriptor wallet with a fixed set of arguments and no options, and may
request `walletlock`. It stores no passphrase and writes nothing to disk.
descriptor wallet with a fixed set of arguments and no options (one of them,
`load_on_startup=true`, has Bitcoin Core itself keep the wallet loaded at
every start), and may request `walletlock`. It stores no passphrase and
writes nothing to disk.
An unlocked encrypted signer is relocked and verified on every exit path: by
the library, unchanged, and additionally by a `finally`-protected obligation
covering every wallet the graphical program itself unlocked. No window may
Expand Down
2 changes: 1 addition & 1 deletion docs/security/model.md
Original file line number Diff line number Diff line change
Expand Up @@ -256,7 +256,7 @@ Core adapter.
| Departure | Required behavior |
|---|---|
| Passphrase | The operator may supply a Bitcoin Core wallet passphrase. It reaches `bitcoin-cli` through `-stdinwalletpassphrase`, never through an argument, so it is absent from `/proc` and process listings. It is not stored, not logged, and not written to disk, and a passphrase containing a line break is refused rather than truncated. A passphrase this computer's locale would encode as something other than what Bitcoin-Qt sends is refused, so no half-encoded secret reaches a screen or a traceback. The screen keeps the command line's behavior as an alternative: the operator may unlock in Bitcoin-Qt instead, and the program then only rechecks wallet state. |
| Wallet creation | `createwallet` may be issued once, with `wallet_name`, `disable_private_keys=false`, `blank=true`, and a `passphrase` only when one was given. No other option is sent, and the resulting wallet must pass the same eligibility test as any other destination before it is used. Names are restricted to printable text without leading or trailing spaces, and may not contain a slash or be `.` or `..`, so a name can neither span the one-argument-per-line channel nor describe a path. |
| Wallet creation | `createwallet` may be issued once, with `wallet_name`, `disable_private_keys=false`, `blank=true`, `load_on_startup=true`, and a `passphrase` only when one was given. No other option is sent. `load_on_startup` makes Bitcoin Core, not codex32, record the wallet in its own settings so it is loaded at every start and a pruned node never prunes past it; if Core reports that it could not save that setting, the wallet is refused, and the resulting wallet must pass the same eligibility test as any other destination before it is used. Names are restricted to printable text without leading or trailing spaces, and may not contain a slash or be `.` or `..`, so a name can neither span the one-argument-per-line channel nor describe a path. |
| Relocking | Every wallet this program unlocks carries a `finally`-protected obligation of its own, in `wallet_setup.fill`, that requests `walletlock` and verifies `unlocked_until` is zero. The library's obligation is armed only after it has chosen a wallet, so a refusal raised before that point would otherwise leave an unlocked wallet open until Bitcoin Core's own timeout. Worker threads are not daemons, so closing the window during an import runs both obligations rather than skipping them. |

Destination selection is unchanged and is not delegated to prompt wording. The
Expand Down
13 changes: 13 additions & 0 deletions docs/user/gui.md
Original file line number Diff line number Diff line change
Expand Up @@ -167,6 +167,19 @@ 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.

The same page asks for the approximate creation date from your wallet record.
Bitcoin Core searches for the wallet's transactions from a day before that date,
so a pruned node, which keeps only recent blocks, can still find them. Leave it
blank to search all history. If the node has already pruned blocks the search
would need, the window says so before it changes anything in Bitcoin Core; a
date after the pruned blocks works, and otherwise the node must download the
blockchain again. A node started from an AssumeUTXO snapshot lacks older blocks
until it finishes checking them, so the window asks you to restore after that.
A new wallet has no history to search, so it is made and loaded at once;
Bitcoin Core itself shows that it is still catching up.
New wallets made here are loaded each time Bitcoin Core
starts, so a pruned node keeps them up to date instead of pruning past them.

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.
Expand Down
5 changes: 4 additions & 1 deletion docs/user/guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -123,7 +123,10 @@ 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. codex32 then asks for the date the seed was first used:
Bitcoin Core scans for prior transactions from a day before it, so a pruned
node can still find them. Leave it blank to scan all history, or enter
today's date if the seed has never received bitcoin.

### 3. Make a Bitcoin Core wallet

Expand Down
63 changes: 54 additions & 9 deletions src/codex32/_bitcoin_core.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
from __future__ import annotations

import calendar
import hashlib
import json
import re
import shutil
import string
import subprocess
import time
from collections.abc import Callable
from dataclasses import dataclass
from time import sleep
Expand All @@ -26,12 +28,12 @@ class FingerprintMismatch(BitcoinCoreError):
"""The recovered seed is not the wallet the operator's record describes."""


_CHAINS = (
("main", "mainnet"),
("test", "testnet3"),
("testnet4", "testnet4"),
("signet", "signet"),
("regtest", "regtest"),
_CHAINS = ( # Name, label, and genesis block time: no block on that chain is older.
("main", "mainnet", 1231006505),
("test", "testnet3", 1296688602),
("testnet4", "testnet4", 1714777860),
("signet", "signet", 1598918400),
("regtest", "regtest", 1296688602),
)

_ORIGIN = re.compile(r"\[(?P<fingerprint>[0-9a-f]{8})(?P<path>(?:/[0-9]+[h']?)*)\]")
Expand All @@ -57,6 +59,18 @@ def parse_fingerprint(text: str) -> bytes:
)


def parse_creation_date(text: str) -> int:
"""Turn the record's approximate creation date into a rescan start; blank searches all history."""
try:
midnight = calendar.timegm(time.strptime(text.strip(), "%Y-%m-%d")) if text.strip() else 0
except ValueError:
raise ValueError("Type the creation date as YYYY-MM-DD, or leave it blank.") from None
if midnight > time.time() + 14 * 3600: # Still in the future at UTC+14, the earliest time zone.
raise ValueError("That creation date is in the future.")
# A day early covers whatever time zone the record's date was written in.
return max(0, midnight - 86400)


def identifier_note(origin: str | None) -> str:
"""Say what `identifier_origin` found, for an operator restoring without a record."""
if origin is None:
Expand Down Expand Up @@ -107,7 +121,7 @@ def connect(
if executable is None:
raise BitcoinCoreError("Install a reviewed bitcoin-cli before creating a backup.")
choices: list[BitcoinCore] = []
for chain, _label in _CHAINS:
for chain, _label, _genesis in _CHAINS:
client = cls(executable, chain, 0)
try:
network = client._rpc("getnetworkinfo", timeout=5)
Expand All @@ -130,15 +144,15 @@ def connect(
raise BitcoinCoreError("More than one local Bitcoin Core network is running.")
tell("Local Bitcoin Core networks:")
for number, choice in enumerate(choices, 1):
tell(f" {number}. {dict(_CHAINS)[choice.chain]}")
tell(f" {number}. {dict(c[:2] for c in _CHAINS)[choice.chain]}")
while not (
(answer := ask("Choose a network number")).isdecimal() and 1 <= int(answer) <= len(choices)
):
tell("Enter one of the displayed numbers.")
choices = [choices[int(answer) - 1]]
client = choices[0]
if tell is not None:
tell(f"Using Bitcoin Core on {dict(_CHAINS)[client.chain]}.")
tell(f"Using Bitcoin Core on {dict(c[:2] for c in _CHAINS)[client.chain]}.")
return client

def _rpc(
Expand Down Expand Up @@ -226,6 +240,35 @@ def verify_identity(self, secret: MasterSeed, expected_fingerprint: bytes | None
"Bitcoin Core was not changed."
)

def check_history(self, timestamp: int | Literal["now"]) -> None:
"""Refuse a rescan that needs blocks Core lacks: Core would import the keys and lose the history."""
if timestamp == "now": # A fresh seed has no history; Core itself warns while its wallets catch up.
return
Comment thread
BenWestgate marked this conversation as resolved.
genesis = next(born for chain, _label, born in _CHAINS if chain == self.chain)
info = c if isinstance(c := self._rpc("getblockchaininfo"), dict) else {}
height = info.get("pruneheight") if (p := info.get("pruned")) is True else 0 if p is False else None
# A real median time lies between the chain's genesis and Core's two-hour limit on future blocks.
tip = t if type(t := info.get("mediantime")) is int and genesis <= t < time.time() + 7200 else None
if type(height) is not int or height < 0 or type(tip) is not int:
raise BitcoinCoreError("Bitcoin Core did not say which blocks it still keeps.")
start = min(tip, timestamp) # Core scans a future timestamp from the tip, with the same window.
# Until background validation ends, an AssumeUTXO node lacks the blocks below its snapshot.
cs = g.get("chainstates") or None if isinstance(g := self._rpc("getchainstates"), dict) else None
if type(cs) is not list or not all(type(c) is dict and c.get("validated") is True for c in cs):
raise BitcoinCoreError("Bitcoin Core is still downloading older blocks. Try again later.")
if height > 0:
Comment thread
BenWestgate marked this conversation as resolved.
Comment thread
BenWestgate marked this conversation as resolved.
b = self._rpc("getblockstats", str(height), '["time"]')
# Core starts two hours early, at the first block whose highest time so far reaches the
# timestamp. Block times are not monotonic, so a whole day of margin covers an earlier block
# that ran ahead of this one; dates are only day-accurate anyway. No block predates genesis.
kept = t if isinstance(b, dict) and type(t := b.get("time")) is int and t >= genesis else 0
if start < kept + 86400 or not kept:
since = f" from before {time.strftime('%Y-%m-%d', time.gmtime(kept))}" if kept else ""
raise BitcoinCoreError(
f"This pruned node no longer has the blocks{since} that this wallet's history may be in. "
"Enter a later creation date if your wallet record has one. 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 @@ -415,6 +458,8 @@ def initialize(
if state is None:
tell("That wallet is no longer eligible. Choose again.")
continue
# Checked last, after any wait above: a pruning node keeps pruning while it waits.
self.check_history(timestamp)
records = core_descriptors(
secret,
account=account,
Expand Down
23 changes: 14 additions & 9 deletions src/codex32/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
FingerprintMismatch,
identifier_note,
identifier_origin,
parse_creation_date,
parse_fingerprint,
)
from codex32._cli_input import (
Expand Down Expand Up @@ -69,6 +70,7 @@ class _CliContext(NamedTuple):

_GENERIC = _CliContext("codex32", False, None, "")
_MASTER_SEED = _CliContext("ms32", True, (Profile.MS,), "MS1")
_FIRST_USED = "Date this seed was first used, as YYYY-MM-DD (Enter: all history; unused: today)"


class _CommandError(Exception):
Expand Down Expand Up @@ -357,6 +359,15 @@ def _without_record(core: BitcoinCore, secret: MasterSeed) -> bool:
return _text("Restore without a wallet record? [y/N]", optional=True).lower() in ("y", "yes")


def _creation_start() -> int:
"""Ask when an existing seed was first used, so a pruned node rescans only what it still has."""
while True:
try:
return parse_creation_date(_text(_FIRST_USED, optional=True))
except ValueError as error:
_print(str(error), err=True)


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)"
Expand Down Expand Up @@ -486,6 +497,7 @@ def _create(
raise _UsageError("Use --existing when supplying a seed or secret.")
if isinstance(source, (Share, Secret)) and not isinstance(source, MasterSeed):
raise _UsageError(f"Enter one {_profile_rules(profile).label}, not a share or another backup type.")
start: int | Literal["now"] = _creation_start() if existing else "now"
try:
if threshold == 0:
if isinstance(source, MasterSeed):
Expand All @@ -499,11 +511,7 @@ def _create(
_emit(secret, False, fingerprint=core.fingerprint)
if sys.stdin.isatty():
_confirm_card(secret)
return (
_initialize_wallet(core, secret, timestamp=0 if existing else "now", fresh=not existing)
if core is not None
else 0
)
return _initialize_wallet(core, secret, timestamp=start, fresh=not existing)
if isinstance(source, MasterSeed):
ceremony = CreationCeremony.from_secret(
source,
Expand Down Expand Up @@ -541,10 +549,7 @@ def _create(
_print(f"Recovery card {position + 1} of {output_count} confirmed.", err=True)
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)
_print("\nEvery recovery card was confirmed from its re-entered text.", err=True)
return 0
return _initialize_wallet(core, finished, timestamp=start, fresh=not existing)


def _correct(
Expand Down
Loading
Loading