Skip to content
Open
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 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. 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. |
| 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
6 changes: 5 additions & 1 deletion docs/user/guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -123,7 +123,11 @@ 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. 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.

### 3. Make a Bitcoin Core wallet

Expand Down
76 changes: 39 additions & 37 deletions src/codex32/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -394,19 +394,22 @@ def _initialize_wallet(
fresh: bool = True,
restore: bool = False,
confirmed: bool = True,
identity_checked: bool = False,
expected_fingerprint: bytes | None = None,
) -> int:
assert isinstance(secret, MasterSeed)
try:
if confirmed:
_print("Master-seed backup confirmed.\n", err=True)
expected = _recorded_fingerprint(core, secret) if restore else None
if restore and not identity_checked:
expected_fingerprint = _recorded_fingerprint(core, secret)
if not restore:
_show_fingerprint(core, secret, "Write it on the wallet record")
name = core.initialize(
secret,
lambda prompt: _text(prompt, optional=True),
lambda message: _print(message, err=True),
expected_fingerprint=expected,
expected_fingerprint=expected_fingerprint,
account=account,
timestamp=timestamp,
)
Expand Down Expand Up @@ -484,42 +487,46 @@ def _create(
raise _UsageError("For thresholds 4 through 9, choose --shares or --indices.")
core = _connected_core()
source = _creation_source(profile) if existing else None
if not existing and not sys.stdin.isatty() and _text("", optional=True):
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.")
try:
if threshold == 0:
if isinstance(source, MasterSeed):
if identifier is not None and identifier != source.header.identifier:
raise _UsageError(
"To change the existing secret's identifier, choose a sharing threshold from 2 through 9."
)
secret = source
else:
secret = _generated_secret(source, byte_length, identifier, core.fingerprint_seed)
_emit(secret, False, fingerprint=None if existing else core.fingerprint)
if sys.stdin.isatty():
_confirm_card(secret)
return (
_initialize_wallet(
core, secret, timestamp=0 if existing else "now", fresh=not existing, restore=existing
existing_secret: MasterSeed | None = None
if isinstance(source, MasterSeed):
if threshold == 0 and identifier is not None and identifier != source.header.identifier:
raise _UsageError(
"To change the existing secret's identifier, choose a sharing threshold from 2 through 9."
)
if core is not None
else 0
existing_secret = source
elif source is not None:
existing_secret = _generated_secret(source, None, identifier, core.fingerprint_seed)
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
Comment thread
BenWestgate marked this conversation as resolved.
except (EOFError, KeyboardInterrupt) as error:
raise _WalletSetupInterrupted from error

def finish_wallet(seed: MasterSeed) -> int:
return _initialize_wallet(
core,
seed,
timestamp=0 if existing else "now",
fresh=not existing,
restore=existing,
identity_checked=existing,
expected_fingerprint=expected,
)
if isinstance(source, MasterSeed):
ceremony = CreationCeremony.from_secret(
source,
threshold=threshold,
identifier=identifier,
share_count=shares,
indices=indices,

if threshold == 0:
secret = existing_secret or _generated_secret(
None, byte_length, identifier, core.fingerprint_seed
)
elif source is not None:
source_secret = _generated_secret(source, None, identifier, core.fingerprint_seed)
_emit(secret, False, fingerprint=None if existing else core.fingerprint)
_confirm_card(secret)
return finish_wallet(secret)
if existing_secret is not None:
ceremony = CreationCeremony.from_secret(
source_secret,
existing_secret,
threshold=threshold,
identifier=identifier,
share_count=shares,
Expand All @@ -545,12 +552,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, restore=existing
)
_print("\nEvery recovery card was confirmed from its re-entered text.", err=True)
return 0
return finish_wallet(finished)


def _correct(
Expand Down
141 changes: 141 additions & 0 deletions tests/test_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -3062,3 +3062,144 @@ def emit(_artifact, _plain, **kwargs):
assert emitted_fingerprints and all(fingerprint is None for fingerprint in emitted_fingerprints)
assert checked == [secret.seed_bytes]
assert core.expected == core.fingerprint(secret)


@pytest.mark.parametrize("encoding", ("hex", "codex32"))
@pytest.mark.parametrize("shared", (False, True))
def test_create_existing_checks_record_before_card_output(
monkeypatch: pytest.MonkeyPatch, encoding: str, shared: bool
) -> None:
cli = importlib.import_module("codex32.cli")
secret = parse_codex32(VECTOR_1["secret_s"])
assert isinstance(secret, MasterSeed)
core = _FakeBitcoinCore()
source = secret.seed_bytes.hex() if encoding == "hex" else secret.text
answers = iter((source, core.fingerprint(secret).hex().upper()))
events: list[str] = []

def check_record(selected: _FakeBitcoinCore, supplied: MasterSeed) -> bytes | None:
assert events == []
checked = _RECORDED_FINGERPRINT(selected, supplied)
events.append("record")
return checked

def confirm_card(
artifact: Share | Secret,
confirm: Callable[[str], ConfirmationResult] | None = None,
) -> None:
if confirm is not None:
assert confirm(artifact.text).accepted

monkeypatch.setattr(sys, "stdin", _TTYInput())
monkeypatch.setattr(sys, "stdout", _TTYOutput())
monkeypatch.setattr(cli, "_text", lambda _prompt, **_options: next(answers))
monkeypatch.setattr(cli, "_recorded_fingerprint", check_record)
monkeypatch.setattr(cli, "_emit", lambda *_args, **_kwargs: events.append("card"))
monkeypatch.setattr(cli, "_confirm_card", confirm_card)
monkeypatch.setattr(cli.BitcoinCore, "connect", lambda *args: core)

args = ["create", "2", "--indices", "ac", "--existing"] if shared else ["create", "--existing"]
assert ms_main(args) == 0
assert events == (["record", "card", "card"] if shared else ["record", "card"])
assert core.expected == core.fingerprint(secret)
assert core.imported is not None and core.imported.seed_bytes == secret.seed_bytes


@pytest.mark.parametrize("encoding", ("hex", "codex32"))
def test_create_existing_rejects_wrong_record_before_sharing(
monkeypatch: pytest.MonkeyPatch, encoding: str
) -> None:
cli = importlib.import_module("codex32.cli")
secret = parse_codex32(VECTOR_1["secret_s"])
assert isinstance(secret, MasterSeed)
core = _FakeBitcoinCore()
source = secret.seed_bytes.hex() if encoding == "hex" else secret.text
right = core.fingerprint(secret)
wrong = bytes([right[0] ^ 1]) + right[1:]
answers = iter((source, wrong.hex(), "", "n"))

monkeypatch.setattr(sys, "stdin", _TTYInput())
monkeypatch.setattr(sys, "stdout", _TTYOutput())
monkeypatch.setattr(cli, "_text", lambda _prompt, **_options: next(answers))
monkeypatch.setattr(cli, "_recorded_fingerprint", _RECORDED_FINGERPRINT)
monkeypatch.setattr(cli.BitcoinCore, "connect", lambda *args: core)
with (
patch("codex32.cli.CreationCeremony.from_secret") as split,
patch("codex32.cli._emit") as emit,
):
assert ms_main(["create", "2", "--indices", "ac", "--existing"]) == 130
split.assert_not_called()
emit.assert_not_called()
assert core.imported is None


def test_create_existing_record_gate_interruption_keeps_existing_backup_valid(
monkeypatch: pytest.MonkeyPatch,
capsys: pytest.CaptureFixture[str],
) -> None:
cli = importlib.import_module("codex32.cli")
secret = parse_codex32(VECTOR_1["secret_s"])
assert isinstance(secret, MasterSeed)
core = _FakeBitcoinCore()

def interrupt_record(*_args: object) -> bytes | None:
raise KeyboardInterrupt

monkeypatch.setattr(sys, "stdin", _TTYInput())
monkeypatch.setattr(sys, "stdout", _TTYOutput())
monkeypatch.setattr(cli, "_creation_source", lambda _profile: secret)
monkeypatch.setattr(cli, "_recorded_fingerprint", interrupt_record)
monkeypatch.setattr(cli.BitcoinCore, "connect", lambda *args: core)
with (
patch("codex32.cli.CreationCeremony.from_secret") as split,
patch("codex32.cli._emit") as emit,
):
assert ms_main(["create", "2", "--indices", "ac", "--existing"]) == 130

split.assert_not_called()
emit.assert_not_called()
message = capsys.readouterr().err
assert "recovery cards are valid" in message
assert "Mark every card" not in message
assert core.imported is None


def test_create_existing_recordless_choice_precedes_sharing(
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
) -> None:
cli = importlib.import_module("codex32.cli")
secret = parse_codex32(VECTOR_1["secret_s"])
assert isinstance(secret, MasterSeed)
core = _FakeBitcoinCore()
answers = iter((secret.seed_bytes.hex(), "", "y"))
events: list[str] = []
emitted: list[Share | Secret] = []

def confirm_card(artifact: Share | Secret, confirm=None) -> None:
if confirm is not None:
assert confirm(artifact.text).accepted

def answer(_prompt: str, **_options: object) -> str:
assert events == []
return next(answers)

def emit(artifact: Share | Secret, *_args: object, **_kwargs: object) -> None:
events.append("card")
emitted.append(artifact)

monkeypatch.setattr(sys, "stdin", _TTYInput())
monkeypatch.setattr(sys, "stdout", _TTYOutput())
monkeypatch.setattr(cli, "_text", answer)
monkeypatch.setattr(cli, "_recorded_fingerprint", _RECORDED_FINGERPRINT)
monkeypatch.setattr(cli, "_emit", emit)
monkeypatch.setattr(cli, "_confirm_card", confirm_card)
monkeypatch.setattr(cli.BitcoinCore, "connect", lambda *args: core)

assert ms_main(["create", "2", "--indices", "ac", "--existing"]) == 0
assert events == ["card", "card"]
identifier = emitted[0].header.identifier
assert all(artifact.header.identifier == identifier for artifact in emitted)
assert core.expected is None
assert core.imported is not None and core.imported.seed_bytes == secret.seed_bytes
assert core.imported.header.identifier == identifier
assert capsys.readouterr().err.count(f"Backup identifier: {identifier.upper()}") >= 2
Loading