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
7 changes: 4 additions & 3 deletions docs/security/invariants.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,10 @@ and evidence.
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 was derived from the
seed. Fresh `ms32 create` ceremonies do not authenticate against a
pre-existing wallet; they require the operator to record the new fingerprint.
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
2 changes: 1 addition & 1 deletion docs/security/model.md
Original file line number Diff line number Diff line change
Expand Up @@ -231,7 +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. 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. 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. |
| 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
19 changes: 13 additions & 6 deletions docs/user/guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -122,8 +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. 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 @@ -237,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
11 changes: 10 additions & 1 deletion src/codex32/_bitcoin_core.py
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,12 @@ def parse_fingerprint(text: str) -> bytes:

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 "
Expand All @@ -77,15 +83,18 @@ def identifier_origin(secret: MasterSeed, fingerprint: bytes) -> str | None:
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 None
return "Bails check unavailable" if ripemd_unavailable else None


@dataclass(frozen=True)
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
Loading
Loading