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
4 changes: 2 additions & 2 deletions docs/developer/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -723,7 +723,7 @@ export, descriptor construction, or wallet initialization before confirmation.

Interactive `create --existing` offers bounded correction for a mistyped codex32
secret of the selected profile. Compare the entire proposed string with the
original recovery card before answering yes. Bitcoin candidates show their master
fingerprint above the text. Declining or finding no usable correction returns to
original recovery card before answering yes. The fingerprint stays hidden until
the wallet-record check. Declining or finding no usable correction returns to
source entry; hexadecimal seeds are never corrected. Confirmation of newly
created cards is a separate step after accepting the source.
12 changes: 7 additions & 5 deletions src/codex32/_cli_input.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,8 @@
Secret,
Share,
_checksum_for_encoded_length,
_validate_basis_prefix,
_validate_recovery_prefix,
_validate_share_set,
parse_codex32,
recover_secret,
)
Expand All @@ -30,9 +30,9 @@
InvalidChecksum,
InvalidLength,
InvalidThreshold,
MismatchedHrp,
MismatchedIdentifier,
MismatchedPayloadLength,
MismatchedProfile,
MismatchedThreshold,
SecretInRecoverySet,
)
Expand Down Expand Up @@ -597,8 +597,10 @@ def _validate_operational_artifact(
raise ExistingTargetIndex("That index was requested for the additional share.")
if not one and (accepted or isinstance(artifact, Share) or basis):
recovering = not basis and isinstance(artifact, Share)
validator = _validate_recovery_prefix if recovering else _validate_basis_prefix
validator([*accepted, artifact])
if recovering:
_validate_recovery_prefix([*accepted, artifact])
else:
_validate_share_set([*accepted, artifact], require_exact=False)


def _redirected(
Expand Down Expand Up @@ -661,7 +663,7 @@ def allowed(candidate: CorrectionCandidate) -> bool:

_FRIENDLY_SET_ERRORS: dict[type[Exception], str] = {
InvalidChecksum: "The checksum does not match.",
MismatchedProfile: "These strings are for different applications.",
MismatchedHrp: "These strings are for different applications.",
MismatchedThreshold: "These strings require different numbers of shares.",
MismatchedIdentifier: "These strings have different identifiers.",
MismatchedPayloadLength: "These strings have different lengths.",
Expand Down
4 changes: 0 additions & 4 deletions src/codex32/bip93.py
Original file line number Diff line number Diff line change
Expand Up @@ -320,10 +320,6 @@ def _validate_recovery_prefix(shares: Sequence[Share | Secret]) -> None:
raise SecretInRecoverySet("recovery accepts ordinary shares only")


def _validate_basis_prefix(basis: Sequence[Share | Secret]) -> None:
_validate_share_set(basis, require_exact=False)


def _interpolate_tail(share_set: _ShareSet, target: str) -> Share | Secret:
points = tuple(CHARSET.index(index) for index in share_set.indices)
weights = _lagrange_weights(points, CHARSET.index(target))
Expand Down
91 changes: 27 additions & 64 deletions src/codex32/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -200,38 +200,31 @@ def _share_command(index: str, plain: bool, context: _CliContext, core: BitcoinC
return 0


def _creation_header(value: str | None) -> tuple[Profile, int | None, str | None]:
def _creation_header(value: str | None) -> tuple[int | None, str | None]:
if value is None:
return Profile.MS, None, None
return None, None
lowered = value.lower()
if lowered != value and value.upper() != value:
raise _UsageError("The set header must use either uppercase or lowercase.")
if "1" in lowered:
hrp, header = lowered.rsplit("1", 1)
if hrp != Profile.MS.value:
raise _UsageError("The set header must begin with ms1.")
profile = Profile.MS
else:
profile, header = Profile.MS, lowered
hrp, _separator, header = lowered.rpartition("1")
if _separator and hrp != Profile.MS.value:
raise _UsageError("The set header must begin with ms1.")
if len(header) not in (1, 5) or header[0] not in "023456789":
raise _UsageError(
"Enter a threshold alone or a complete backup header containing "
"a threshold and four-character identifier."
)
threshold = int(header[0])
if len(header) == 1:
return profile, threshold, None
return threshold, None
try:
identifier = Header(threshold, header[1:], "s").identifier
except CodexError as error:
raise _UsageError(f"Invalid backup header: {error}") from error
return profile, threshold, identifier
return threshold, identifier


def _creation_source(
profile: Profile,
fingerprint: Callable[[MasterSeed], bytes] | None = None,
) -> bytes | Artifact:
def _creation_source() -> bytes | Artifact:
prefill = ""
while True:
value = _text(
Expand All @@ -249,22 +242,15 @@ def _creation_source(
try:
return parse_codex32(value)
except CodexError as error:
if not sys.stdin.isatty():
raise _UsageError(str(error)) from error
candidates = _suggestions(
value,
"",
(profile,),
(Profile.MS,),
[],
allowed=lambda item: isinstance(item.artifact, Secret) and item.artifact.profile is profile,
fingerprint=fingerprint,
allowed=lambda item: isinstance(item.artifact, MasterSeed),
)
if (
len(candidates) == 1
and isinstance(candidate := candidates[0].artifact, Secret)
and candidate.profile is profile
):
confirmation = _confirm_correction(candidates[0], [], False, fingerprint)
if len(candidates) == 1 and isinstance(candidate := candidates[0].artifact, MasterSeed):
confirmation = _confirm_correction(candidates[0], [], False)
if confirmation is True:
return candidate
if confirmation is False:
Expand Down Expand Up @@ -333,20 +319,6 @@ def _confirm_card(artifact: Artifact, confirm: Callable[[str], ConfirmationResul
raise RuntimeError("Confirmation rejected.")


def _generated_secret(
source: bytes | None,
byte_length: int | None,
identifier: str | None,
fingerprint: Callable[[bytes], bytes] | None = None,
) -> MasterSeed:
return generate_master_seed(
source,
byte_length=byte_length,
identifier=identifier,
fingerprint=fingerprint,
)


def _show_fingerprint(core: BitcoinCore, secret: MasterSeed, action: str) -> None:
_print(f"\nMaster fingerprint: {core.fingerprint(secret).hex().upper()}", err=True)
_text(f"{action}, then press Enter", optional=True, prompt_end=". ")
Expand Down Expand Up @@ -470,10 +442,8 @@ def _create(
indices: str | None,
existing: bool,
) -> int:
profile, selected_threshold, identifier = _creation_header(header)
selected_threshold, identifier = _creation_header(header)
threshold = 0 if selected_threshold is None else selected_threshold
if profile is not Profile.MS:
raise _UsageError("Only Bitcoin master-seed backups can be created.")
if shares is not None and indices is not None:
raise _UsageError("Choose either --shares or --indices, not both.")
if byte_length is not None and existing:
Expand All @@ -486,11 +456,9 @@ 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
if not existing and not sys.stdin.isatty() and _text("", optional=True):
raise _UsageError("Use --existing when supplying a seed or secret.")
source = _creation_source() 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.")
raise _UsageError("Enter one Bitcoin master seed, not a share or another backup type.")
try:
if threshold == 0:
if isinstance(source, MasterSeed):
Expand All @@ -500,16 +468,13 @@ def _create(
)
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
secret = generate_master_seed(
source, byte_length=byte_length, identifier=identifier, fingerprint=core.fingerprint_seed
)
if core is not None
else 0
_emit(secret, False, fingerprint=None if existing else core.fingerprint)
_confirm_card(secret)
return _initialize_wallet(
core, secret, timestamp=0 if existing else "now", fresh=not existing, restore=existing
)
if isinstance(source, MasterSeed):
ceremony = CreationCeremony.from_secret(
Expand All @@ -520,7 +485,9 @@ def _create(
indices=indices,
)
elif source is not None:
source_secret = _generated_secret(source, None, identifier, core.fingerprint_seed)
source_secret = generate_master_seed(
source, identifier=identifier, fingerprint=core.fingerprint_seed
)
ceremony = CreationCeremony.from_secret(
source_secret,
threshold=threshold,
Expand All @@ -547,13 +514,9 @@ def _create(
_confirm_card(artifact, ceremony.confirm)
_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 _initialize_wallet(
core, finished, timestamp=0 if existing else "now", fresh=not existing, restore=existing
)


def _correct(
Expand Down
2 changes: 0 additions & 2 deletions src/codex32/errors.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,8 +28,6 @@ def _error(name: str, base: type[CodexError], doc: str) -> type[CodexError]:
InvalidShareSet = _error("InvalidShareSet", CodexError, "Artifacts cannot form an interpolation set.")
WrongShareCount = _error("WrongShareCount", InvalidShareSet, "The share count differs from its threshold.")
MismatchedHrp = _error("MismatchedHrp", InvalidShareSet, "Application prefixes differ.")
# Compatibility name retained across the 1.0 boundary.
MismatchedProfile = MismatchedHrp
MismatchedThreshold = _error("MismatchedThreshold", InvalidShareSet, "Thresholds differ.")
Comment thread
BenWestgate marked this conversation as resolved.
MismatchedIdentifier = _error("MismatchedIdentifier", InvalidShareSet, "Identifiers differ.")
MismatchedPayloadLength = _error("MismatchedPayloadLength", InvalidShareSet, "Payload lengths differ.")
Expand Down
44 changes: 20 additions & 24 deletions tests/test_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -1645,6 +1645,15 @@ def test_create_accepts_positional_headers_and_preserves_index_order() -> None:
assert recover_secret(basis).header.identifier == "cash"


@pytest.mark.parametrize("header", ("10", "12", "13cash"))
def test_create_rejects_separator_without_ms_prefix(header: str) -> None:
result = _invoke_confirmed_create(["create", header])

assert result.exit_code == 2
assert "must begin with ms1" in result.stderr
assert result.stdout == ""


@pytest.mark.parametrize(("threshold", "count"), ((2, 3), (3, 5)))
def test_create_has_reviewed_share_count_presets(threshold: int, count: int) -> None:
result = _invoke_confirmed_create(["create", f"{threshold}test"])
Expand Down Expand Up @@ -2523,7 +2532,7 @@ def answer(prompt: str, **options: object) -> str:
with (
patch.object(sys, "stdin", _TTYInput()),
patch("codex32.cli._creation_source", return_value=secret),
patch("codex32.cli._generated_secret") as generate,
patch("codex32.cli.generate_master_seed") as generate,
patch("codex32.cli.CreationCeremony.from_secret") as split,
patch("codex32.cli.BitcoinCore.connect", return_value=core),
contextlib.redirect_stdout(output),
Expand Down Expand Up @@ -2798,10 +2807,10 @@ def answer(prompt, prefill=""):

monkeypatch.setattr(sys, "stdin", _TTYInput())
monkeypatch.setattr(module, "_editable_input", answer)
result = cli._creation_source(Profile.MS, _FakeBitcoinCore().fingerprint)
result = cli._creation_source()
assert result == (parse_codex32(expected) if response == "yes" else bytes(16))
output = capsys.readouterr().err
assert "Master fingerprint: 3F3521A6\n\n" in output
assert "Master fingerprint:" not in output
assert module._card_text(expected, False) in output
assert "31m" not in output
assert "Does this entire string exactly match your recovery card? [y/N]: " in prompts
Expand All @@ -2810,12 +2819,10 @@ def answer(prompt, prefill=""):
assert "Rejected:" not in output


@pytest.mark.parametrize("kind", ["none", "ambiguous", "share", "wrong_profile", "fingerprint"])
@pytest.mark.parametrize("kind", ["none", "ambiguous", "share", "wrong_profile"])
def test_creation_source_unusable_candidates_retry(monkeypatch, capsys, kind):
from types import SimpleNamespace

from codex32.errors import CodexError

cli = importlib.import_module("codex32.cli")
module = importlib.import_module("codex32._cli_input")
artifact = parse_codex32(VECTOR_1["secret_s"])
Expand All @@ -2829,12 +2836,7 @@ def test_creation_source_unusable_candidates_retry(monkeypatch, capsys, kind):
answers = iter(("ms1invalid", "00" * 16))
monkeypatch.setattr(sys, "stdin", _TTYInput())
monkeypatch.setattr(module, "_editable_input", lambda *args: next(answers))

def fail(seed):
raise CodexError("invalid seed")

fingerprint = fail if kind == "fingerprint" else _FakeBitcoinCore().fingerprint
assert cli._creation_source(Profile.MS, fingerprint) == bytes(16)
assert cli._creation_source() == bytes(16)
output = capsys.readouterr().err
assert "Possible correction:" not in output
assert "Rejected:" in output
Expand All @@ -2858,7 +2860,7 @@ def suggestions(*args, **kwargs):
monkeypatch.setattr(module, "_editable_input", lambda *args: next(answers))
monkeypatch.setattr(cli, "_suggestions", suggestions)

assert cli._creation_source(Profile.MS) == bytes(16)
assert cli._creation_source() == bytes(16)
output = capsys.readouterr().err
assert "Search incomplete:" not in output
assert "Possible correction:" not in output
Expand All @@ -2881,17 +2883,14 @@ def answer(*args):
monkeypatch.setattr(module, "_editable_input", answer)
monkeypatch.setattr(cli, "_suggestions", lambda *args, **kwargs: ())
with pytest.raises(exception):
cli._creation_source(Profile.MS)
cli._creation_source()


def test_creation_source_hex_and_noninteractive_never_search(monkeypatch):
def test_creation_source_hex_never_searches(monkeypatch):
cli = importlib.import_module("codex32.cli")
monkeypatch.setattr(cli, "_suggestions", lambda *args, **kwargs: pytest.fail("unexpected search"))
monkeypatch.setattr(sys, "stdin", io.StringIO("00" * 16))
assert cli._creation_source(Profile.MS) == bytes(16)
monkeypatch.setattr(sys, "stdin", io.StringIO("ms1invalid"))
with pytest.raises(cli._UsageError):
cli._creation_source(Profile.MS)
assert cli._creation_source() == bytes(16)


def test_creation_source_is_neutral_and_does_not_infer_ms1(monkeypatch, capsys):
Expand All @@ -2908,7 +2907,7 @@ def answer(prompt: str, _prefill: str = "") -> str:
monkeypatch.setattr(module, "_editable_input", answer)
monkeypatch.setattr("codex32.indel._search_many", lambda *args, **kwargs: pytest.fail("searched"))

assert cli._creation_source(Profile.MS) == bytes(16)
assert cli._creation_source() == bytes(16)
assert prompts == ["Enter an existing Bitcoin codex32 secret or hexadecimal seed:\n> "] * 2
output = capsys.readouterr().err
assert "Rejected:" in output
Expand Down Expand Up @@ -3073,11 +3072,9 @@ def test_create_existing_checks_the_record_before_import(monkeypatch: pytest.Mon
secret = parse_codex32(VECTOR_1["secret_s"])
core = _FakeBitcoinCore()
checked = []
source_fingerprints = []
emitted_fingerprints = []

def source(_profile, fingerprint=None):
source_fingerprints.append(fingerprint)
def source():
return secret

def record(_core, recovered):
Expand All @@ -3100,7 +3097,6 @@ def emit(_artifact, _plain, **kwargs):
monkeypatch.setattr(cli, "_recorded_fingerprint", record)
monkeypatch.setattr(cli.BitcoinCore, "connect", lambda *args: core)
assert ms_main(["create", "--existing", *([header] if header else [])]) == 0
assert source_fingerprints == [None]
assert emitted_fingerprints and all(fingerprint is None for fingerprint in emitted_fingerprints)
assert checked == [secret.seed_bytes]
assert core.expected == core.fingerprint(secret)
5 changes: 2 additions & 3 deletions tests/test_generic_hrp.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@
recover_secret,
)
from codex32.cli import main, ms_main
from codex32.errors import MismatchedHrp, MismatchedProfile
from codex32.errors import MismatchedHrp
from tools._wallet_test_vectors import STUB_FINGERPRINT

UNKNOWN = {
Expand Down Expand Up @@ -51,8 +51,7 @@ def test_opaque_hrp_parse_complete_recover_and_derive(vector: dict[str, str]) ->
assert derive_share([secret, a], "d").text == vector["D"].upper()


def test_sharing_compares_normalized_hrp_and_keeps_compatibility_error_name() -> None:
assert MismatchedProfile is MismatchedHrp
def test_sharing_compares_normalized_hrp() -> None:
zz = parse_codex32(UNKNOWN["short"]["S"])
yy = parse_codex32(oracle_encode("yy", "2testa" + "p" * 26))
with pytest.raises(MismatchedHrp):
Expand Down
Loading
Loading