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
6 changes: 3 additions & 3 deletions docs/security/invariants.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,9 @@ 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 matches this
project's fingerprint-derived default. This check is not authentication. Fresh
`ms32 create` ceremonies 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
4 changes: 2 additions & 2 deletions docs/security/model.md
Original file line number Diff line number Diff line change
Expand Up @@ -233,8 +233,8 @@ 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. |
| 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. |
| 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 identifier matches this project's 20-bit fingerprint-derived default, and a warning, and then chooses. Matching is useful evidence for an unshared default-identifier secret, not wallet authentication; a custom identifier or shared backup may differ without indicating a recovery error. Fresh `ms32 create` shows the newly created seed's fingerprint and requires the operator to acknowledge recording it. Anyone able to replace a threshold of cards could already read them. |
| Process boundary | codex32 invokes `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. |
| Secret channel | The master xprv is sent only through the child's standard input. A timestamped rescan obtains one private descriptor from Core's captured stdout and returns it through stdin; neither value is printed or passed in arguments or diagnostics. codex32 has no passphrase channel and suppresses raw Core errors. |
Expand Down
28 changes: 7 additions & 21 deletions src/codex32/_bitcoin_core.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
from __future__ import annotations

import hashlib
import json
import shutil
import string
Expand All @@ -11,7 +10,6 @@
from typing import Literal

from codex32._bip32 import _master_xprv_from_seed
from codex32.bech32 import _u5_to_chars, convertbits
from codex32.generation import _fingerprint_identifier
from codex32.profiles.ms32 import MasterSeed

Expand Down Expand Up @@ -55,33 +53,21 @@ 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.
# A 20-bit default-identifier match is useful evidence, not authentication.
if origin is None:
return (
"The backup identifier was not made from this seed. That can be normal for codex32 backups "
"made from split shares, supplied seed bytes or an explicit identifier. Bails made every "
"identifier from its seed, so for a Bails backup these are the wrong or mixed-up cards."
"The backup identifier does not match the fingerprint-derived default. "
"Custom identifiers and shared backups can still be correct; this is not proof of a mismatch."
)
return (
f"The backup identifier matches this seed ({origin} rule). That rules out most mixed-up cards, "
"but not cards replaced on purpose."
"The backup identifier matches the fingerprint-derived default (20 bits). "
"This supports an unshared default-identifier recovery, but does not authenticate the wallet."
)


def identifier_origin(secret: MasterSeed, fingerprint: bytes) -> str | None:
"""Check codex32's fingerprint or Bails' three-character seed-digest identifier."""
identifier = secret.header.identifier
if identifier == _fingerprint_identifier(fingerprint):
return "codex32"
for name, digest in (("Bails", "ripemd160"), ("Bails alpha", "sha256")):
try:
hashed = hashlib.new(digest, secret.seed_bytes).digest()
except ValueError:
continue
derived = convertbits(hashed, 8, 5, pad=True)
if identifier[:3] == _u5_to_chars(tuple(derived[:3])):
return name
return None
"""Recognize only this project's fingerprint-derived default identifier."""
return "codex32" if secret.header.identifier == _fingerprint_identifier(fingerprint) else None


@dataclass(frozen=True)
Expand Down
35 changes: 7 additions & 28 deletions tests/test_bitcoin_core.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@

from __future__ import annotations

import hashlib
import json
import re
import subprocess
Expand Down Expand Up @@ -826,42 +825,22 @@ def unused(_client: BitcoinCore, _secret: MasterSeed) -> bytes:
BitcoinCore("bitcoin-cli", "main", 300000).verify_identity(_SEED, None)


# Frozen from Bails' own ms32.seed_identifier for this seed: master (RIPEMD-160) and the
# June 2023 alpha (SHA-256). Bails checked three characters and kept the fourth for re-sharing.
_BAILS_SEED = bytes(range(16))


@pytest.mark.parametrize(
("identifier", "origin"),
(
(_fingerprint_identifier(_FINGERPRINT), "codex32"),
("d9k8", "Bails"),
("d9kq", "Bails"),
("hezu", "Bails alpha"),
("d9k8", None),
("test", None),
),
)
def test_identifier_origin_names_the_rule_that_made_it(identifier: str, origin: str | None) -> None:
secret = MasterSeed.from_seed(_BAILS_SEED, identifier=identifier)
def test_identifier_origin_only_recognizes_the_default(identifier: str, origin: str | None) -> None:
secret = MasterSeed.from_seed(bytes(range(16)), identifier=identifier)
assert identifier_origin(secret, _FINGERPRINT) == origin
assert ("matches this seed" in identifier_note(origin)) is (origin is not None)


def test_identifier_origin_still_checks_alpha_without_ripemd160(monkeypatch: pytest.MonkeyPatch) -> None:
original_new = hashlib.new

def without_ripemd160(name: str, data: bytes = b"") -> object:
if name == "ripemd160":
raise ValueError("unsupported hash type ripemd160")
return original_new(name, data)

monkeypatch.setattr(hashlib, "new", without_ripemd160)
secret = MasterSeed.from_seed(_BAILS_SEED, identifier="hezu")
assert identifier_origin(secret, _FINGERPRINT) == "Bails alpha"
assert ("matches the fingerprint-derived default" in identifier_note(origin)) is (origin is not None)


def test_identifier_note_allows_supported_nonderived_codex32_identifiers() -> None:
note = identifier_note(None)
assert "split shares" in note
assert "supplied seed bytes" in note
assert "explicit identifier" in note
assert "Custom identifiers" in note
assert "shared backups" in note
assert "not proof of a mismatch" in note
4 changes: 2 additions & 2 deletions tests/test_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -3010,7 +3010,7 @@ def test_restore_without_a_record_shows_what_the_cards_say_and_asks(
assert prompts[1] == "Restore without a wallet record? [y/N]"
shown = capsys.readouterr().err
assert shown.count(f"Master fingerprint: {fingerprint.hex().upper()}") == 1
assert "was not made from this seed" in shown and "nothing can prove" in shown
assert "does not match the fingerprint-derived default" in shown and "nothing can prove" in shown

prompts = _record_answers(monkeypatch, "", "y")
assert _RECORDED_FINGERPRINT(core, secret) is None
Expand All @@ -3019,7 +3019,7 @@ def test_restore_without_a_record_shows_what_the_cards_say_and_asks(
derived = MasterSeed.from_seed(secret.seed_bytes, identifier=_fingerprint_identifier(fingerprint))
_record_answers(monkeypatch, "", "yes")
assert _RECORDED_FINGERPRINT(core, derived) is None
assert "matches this seed (codex32 rule)" in capsys.readouterr().err
assert "matches the fingerprint-derived default (20 bits)" in capsys.readouterr().err


def test_wallet_restore_hides_fingerprint_until_the_record_gate(monkeypatch: pytest.MonkeyPatch) -> None:
Expand Down
Loading