Skip to content
Draft
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 @@ -61,7 +61,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,200 logical review lines, as enforced by the
Keep the installed package below 5,250 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
2 changes: 1 addition & 1 deletion docs/developer/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -105,7 +105,7 @@ unsupported but remains in the review scope.

### Size budget

V1 keeps the installed package below 5,200 logical review lines, excluding
V1 keeps the installed package below 5,250 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.
Expand Down
23 changes: 13 additions & 10 deletions docs/user/guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -122,12 +122,12 @@ easier.
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. 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.
requiring any two, use `ms32 create 2 --existing` instead. First type the
master fingerprint from the separate wallet record, then enter the secret
when prompted; a mismatch must be resolved before any new card is shown. If
you have no record, press Enter; the explicit recordless-restore choice and
visual fingerprint check happen right after the secret. Bitcoin Core also
scans for prior transactions.

### 3. Make a Bitcoin Core wallet

Expand Down Expand Up @@ -241,10 +241,13 @@ 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. 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.
5. Type the master fingerprint from the wallet record, then enter the cards.
A suggested correction says whether it matches the record without showing
the fingerprint, and the record picks between equally likely corrections.
A mismatch stops before Bitcoin Core is changed. Press Enter with nothing
typed only if there is no record; after the cards, 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
Expand Down
51 changes: 35 additions & 16 deletions src/codex32/_cli_input.py
Original file line number Diff line number Diff line change
Expand Up @@ -200,28 +200,36 @@ def _card_text(text: str, highlight: bool = True, observed: str = "") -> str:
return rendered if changed else rendered.replace("\x1b[0m ", " ")


def _completed(artifact: Artifact, accepted: Sequence[Artifact]) -> Artifact:
# Provisional recovery is exclusively for fingerprint previews and record checks.
if (
isinstance(artifact, Share)
and artifact.profile is Profile.MS
and len(accepted) + 1 == artifact.header.threshold
):
return recover_secret(cast(list[Share], [*accepted, artifact]))
return artifact


def _confirm_correction(
candidate: CorrectionCandidate,
accepted: list[Artifact],
basis: bool,
fingerprint: Callable[[MasterSeed], bytes] | None = None,
record: bytes | None = None,
) -> bool | None:
_require_correction_confirmation(candidate.low_checksum_discrimination)
artifact = candidate.artifact
# Provisional recovery is exclusively for this fingerprint preview.
preview = artifact
try:
if (
isinstance(artifact, Share)
and artifact.profile is Profile.MS
and not basis
and (len(accepted) + 1 == artifact.header.threshold)
):
preview = recover_secret(cast(list[Share], [*accepted, artifact]))
preview = artifact if basis else _completed(artifact, accepted)
# A typed wallet record is checked without showing the recovered value.
fingerprint_text = (
f"Master fingerprint: {fingerprint(preview).hex().upper()}\n\n"
if isinstance(preview, MasterSeed) and fingerprint is not None
else ""
""
if not isinstance(preview, MasterSeed) or fingerprint is None
else f"Master fingerprint: {fingerprint(preview).hex().upper()}\n\n"
if record is None
else f"Master fingerprint {'matches' if fingerprint(preview) == record else 'does not match'} "
"your wallet record.\n\n"
)
except CodexError:
_stderr("Rejected: Could not recover a valid Bitcoin master seed using this correction.")
Expand Down Expand Up @@ -537,16 +545,22 @@ def _scheduled_candidates(

def _fingerprint_matcher(
fingerprint: Callable[[MasterSeed], bytes] | None,
record: bytes | None = None,
accepted: Sequence[Artifact] = (),
) -> Callable[[CorrectionCandidate], bool | None] | None:
if fingerprint is None:
return None
from codex32.generation import _fingerprint_identifier

def matches(candidate: CorrectionCandidate) -> bool | None:
artifact = candidate.artifact
if not isinstance(artifact, MasterSeed) or artifact.header.threshold:
return None
try:
if record is not None:
# Prefer corrections whose secret, or completed share set, matches the record.
seed = _completed(artifact, accepted)
return fingerprint(seed) == record if isinstance(seed, MasterSeed) else None
if not isinstance(artifact, MasterSeed) or artifact.header.threshold:
return None
return _fingerprint_identifier(fingerprint(artifact)) == artifact.header.identifier
except CodexError:
return None
Expand All @@ -562,8 +576,9 @@ def _suggestions(
*,
allowed: Callable[[CorrectionCandidate], bool] | None = None,
fingerprint: Callable[[MasterSeed], bytes] | None = None,
record: bytes | None = None,
) -> tuple[CorrectionCandidate, ...]:
fingerprint_match = _fingerprint_matcher(fingerprint)
fingerprint_match = _fingerprint_matcher(fingerprint, record, accepted)
erased = value
if interpretation := _case_interpretation(value, prefix, profiles, allowed):
candidate, value, erased, prefix = interpretation
Expand Down Expand Up @@ -726,6 +741,7 @@ def _interactive(
profiles: tuple[Profile, ...] | None,
initial_prefix: str,
fingerprint: Callable[[MasterSeed], bytes] | None,
record: bytes | None,
) -> list[Artifact]:
accepted: list[Artifact] = []
prefix = initial_prefix
Expand Down Expand Up @@ -770,10 +786,11 @@ def allowed(candidate: CorrectionCandidate) -> bool:
accepted,
allowed=allowed,
fingerprint=fingerprint,
record=record,
)
)
confirmation = (
_confirm_correction(candidates[0], accepted, basis, fingerprint)
_confirm_correction(candidates[0], accepted, basis, fingerprint, record)
if len(candidates) == 1
else None
)
Expand Down Expand Up @@ -824,6 +841,7 @@ def read_artifacts(
profiles: tuple[Profile, ...] | None = None,
initial_prefix: str = "",
fingerprint: Callable[[MasterSeed], bytes] | None = None,
record: bytes | None = None,
) -> list[Artifact]:
if not sys.stdin.isatty():
return _redirected(
Expand All @@ -840,6 +858,7 @@ def read_artifacts(
profiles=profiles,
initial_prefix=initial_prefix,
fingerprint=fingerprint,
record=record,
)
_stderr("")
return result
60 changes: 37 additions & 23 deletions src/codex32/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -107,11 +107,13 @@ def _secret(artifacts: list[Artifact]) -> Secret:
raise _UsageError(str(error)) from error


def _master_seed(fingerprint: Callable[[MasterSeed], bytes] | None = None) -> MasterSeed:
if isinstance(
value := _secret(_artifacts(profiles=(Profile.MS,), initial_prefix="MS1", fingerprint=fingerprint)),
MasterSeed,
):
def _master_seed(
fingerprint: Callable[[MasterSeed], bytes] | None = None, record: bytes | None = None
) -> MasterSeed:
artifacts = _artifacts(
profiles=(Profile.MS,), initial_prefix="MS1", fingerprint=fingerprint, record=record
)
if isinstance(value := _secret(artifacts), MasterSeed):
return value
raise _UsageError("Wallet commands accept only Bitcoin master-seed secrets.")

Expand Down Expand Up @@ -231,6 +233,7 @@ def _creation_header(value: str | None) -> tuple[Profile, int | None, str | None
def _creation_source(
profile: Profile,
fingerprint: Callable[[MasterSeed], bytes] | None = None,
record: bytes | None = None,
) -> bytes | Artifact:
prefill = ""
while True:
Expand Down Expand Up @@ -258,13 +261,14 @@ def _creation_source(
[],
allowed=lambda item: isinstance(item.artifact, Secret) and item.artifact.profile is profile,
fingerprint=fingerprint,
record=record,
)
if (
len(candidates) == 1
and isinstance(candidate := candidates[0].artifact, Secret)
and candidate.profile is profile
):
confirmation = _confirm_correction(candidates[0], [], False, fingerprint)
confirmation = _confirm_correction(candidates[0], [], False, fingerprint, record)
if confirmation is True:
return candidate
if confirmation is False:
Expand Down Expand Up @@ -363,26 +367,28 @@ def _without_record(core: BitcoinCore, secret: MasterSeed) -> bool:
return _text("Restore without a wallet record? [y/N]", optional=True).lower() in ("y", "yes")


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)"
while True:
text = _text(prompt, optional=True)
if not text:
if _without_record(core, secret):
return None
raise _WalletSetupInterrupted
def _record() -> bytes | None:
# Asked before the cards, so corrections can be checked without showing the fingerprint.
while text := _text("Type the master fingerprint from your wallet record (Enter if none)", optional=True):
try:
expected = parse_fingerprint(text)
return parse_fingerprint(text)
except ValueError as error:
_print(str(error), err=True)
continue
return None


def _recorded_fingerprint(core: BitcoinCore, secret: MasterSeed, expected: bytes | None) -> bytes | None:
# Re-ask for the record until the library accepts it, or the operator has none.
while expected is not None:
try:
core.verify_identity(secret, expected)
return expected
except FingerprintMismatch as error:
_print(str(error), err=True)
continue
return expected
expected = _record()
if _without_record(core, secret):
return None
raise _WalletSetupInterrupted


def _initialize_wallet(
Expand All @@ -402,7 +408,7 @@ def _initialize_wallet(
if confirmed:
_print("Master-seed backup confirmed.\n", err=True)
if restore and not identity_checked:
expected_fingerprint = _recorded_fingerprint(core, secret)
expected_fingerprint = _recorded_fingerprint(core, secret, expected_fingerprint)
if not restore:
_show_fingerprint(core, secret, "Write it on the wallet record")
name = core.initialize(
Expand Down Expand Up @@ -486,7 +492,11 @@ def _create(
else:
raise _UsageError("For thresholds 4 through 9, choose --shares or --indices.")
core = _connected_core()
source = _creation_source(profile) if existing else None
try:
record = _record() if existing else None
except KeyboardInterrupt as error:
raise _WalletSetupInterrupted from error
source = _creation_source(profile, core.fingerprint if record else None, record) if existing else None
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.")
try:
Expand All @@ -502,7 +512,9 @@ def _create(
if threshold and identifier is None:
identifier = existing_secret.header.identifier
try:
expected = _recorded_fingerprint(core, existing_secret) if existing_secret is not None else None
expected = (
_recorded_fingerprint(core, existing_secret, record) if existing_secret is not None else None
)
except (EOFError, KeyboardInterrupt) as error:
raise _WalletSetupInterrupted from error

Expand Down Expand Up @@ -668,7 +680,8 @@ def _bitcoin_core(account: int, timestamp: int | Literal["now"]) -> int:
core = _connected_core()
# Keep the recovered fingerprint hidden until the operator has supplied
# independent wallet-record evidence or explicitly chosen recordless restore.
secret = _master_seed()
record = _record()
secret = _master_seed(core.fingerprint if record else None, record)
return _initialize_wallet(
core,
secret,
Expand All @@ -677,6 +690,7 @@ def _bitcoin_core(account: int, timestamp: int | Literal["now"]) -> int:
fresh=False,
restore=True,
confirmed=False,
expected_fingerprint=record,
)


Expand Down
Loading
Loading