From f018373815587dee4e411d3cc9cdb9dca123a3f7 Mon Sep 17 00:00:00 2001 From: Ben Westgate Date: Fri, 9 Oct 2026 21:01:28 +0000 Subject: [PATCH 1/3] cleanup: Remove dead and unused code Drop code that never runs or only aliases other code: - create: Bitcoin Core is always connected and the terminal is always interactive, so the `core is None` and non-TTY branches are dead. The header is always ms, and `_generated_secret` only wrapped `generate_master_seed`. - create --existing: the correction preview never received a fingerprint provider, so drop the parameter. - correction: `_FixedCorrector` always set `erasure_products` to None, so the two-erasure product-table path never ran. - tools: nothing ran the two dependency-free fuzz targets. - errors: drop the pre-1.0 `MismatchedProfile` alias. - bip93: drop the one-line `_validate_basis_prefix` wrapper. No behaviour change. Claude-Session: https://claude.ai/code/session_01XGMpowzJwwgyHMEd63ijDe --- docs/developer/api.md | 4 +- src/codex32/_cli_input.py | 12 ++-- src/codex32/bip93.py | 4 -- src/codex32/cli.py | 91 +++++++++--------------------- src/codex32/errors.py | 2 - tests/test_cli.py | 35 ++++-------- tests/test_generic_hrp.py | 5 +- tests/test_sharing.py | 4 +- tools/fuzz_correction_context.py | 47 --------------- tools/fuzz_untrusted_boundaries.py | 45 --------------- 10 files changed, 51 insertions(+), 198 deletions(-) delete mode 100644 tools/fuzz_correction_context.py delete mode 100644 tools/fuzz_untrusted_boundaries.py diff --git a/docs/developer/api.md b/docs/developer/api.md index 5511db3f..85b70c49 100644 --- a/docs/developer/api.md +++ b/docs/developer/api.md @@ -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. diff --git a/src/codex32/_cli_input.py b/src/codex32/_cli_input.py index 0d2a41e4..0f8430f7 100644 --- a/src/codex32/_cli_input.py +++ b/src/codex32/_cli_input.py @@ -17,8 +17,8 @@ Secret, Share, _checksum_for_encoded_length, - _validate_basis_prefix, _validate_recovery_prefix, + _validate_share_set, parse_codex32, recover_secret, ) @@ -30,9 +30,9 @@ InvalidChecksum, InvalidLength, InvalidThreshold, + MismatchedHrp, MismatchedIdentifier, MismatchedPayloadLength, - MismatchedProfile, MismatchedThreshold, SecretInRecoverySet, ) @@ -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( @@ -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.", diff --git a/src/codex32/bip93.py b/src/codex32/bip93.py index 238f7dd2..705ceb65 100644 --- a/src/codex32/bip93.py +++ b/src/codex32/bip93.py @@ -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)) diff --git a/src/codex32/cli.py b/src/codex32/cli.py index 9b830137..b7f501b3 100644 --- a/src/codex32/cli.py +++ b/src/codex32/cli.py @@ -200,19 +200,15 @@ 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 hrp not in ("", 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 " @@ -220,18 +216,15 @@ def _creation_header(value: str | None) -> tuple[Profile, int | None, str | None ) 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( @@ -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: @@ -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=". ") @@ -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: @@ -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): @@ -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( @@ -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, @@ -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( diff --git a/src/codex32/errors.py b/src/codex32/errors.py index f0c5cb9b..34e9fcb3 100644 --- a/src/codex32/errors.py +++ b/src/codex32/errors.py @@ -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.") MismatchedIdentifier = _error("MismatchedIdentifier", InvalidShareSet, "Identifiers differ.") MismatchedPayloadLength = _error("MismatchedPayloadLength", InvalidShareSet, "Payload lengths differ.") diff --git a/tests/test_cli.py b/tests/test_cli.py index 57bf50d1..b4d92237 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -2523,7 +2523,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), @@ -2798,10 +2798,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 @@ -2810,12 +2810,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"]) @@ -2829,12 +2827,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 @@ -2858,7 +2851,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 @@ -2881,17 +2874,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): @@ -2908,7 +2898,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 @@ -3073,11 +3063,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): @@ -3100,7 +3088,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) diff --git a/tests/test_generic_hrp.py b/tests/test_generic_hrp.py index 9111a7d1..3efa24a3 100644 --- a/tests/test_generic_hrp.py +++ b/tests/test_generic_hrp.py @@ -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 = { @@ -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): diff --git a/tests/test_sharing.py b/tests/test_sharing.py index ab6e7742..bbc827d4 100644 --- a/tests/test_sharing.py +++ b/tests/test_sharing.py @@ -25,9 +25,9 @@ InvalidBip39Checksum, InvalidPadding, InvalidTargetIndex, + MismatchedHrp, MismatchedIdentifier, MismatchedPayloadLength, - MismatchedProfile, MismatchedThreshold, SecretInRecoverySet, WrongShareCount, @@ -112,7 +112,7 @@ def test_every_threshold_recovers_exactly_k_shares(threshold: int) -> None: def test_share_set_mismatches_have_distinct_errors() -> None: ms_secret, ms_masks = _ms_basis() opaque = parse_codex32(oracle_encode("xy", "2testa" + "q" * len(ms_secret.payload_symbols))) - with pytest.raises(MismatchedProfile): + with pytest.raises(MismatchedHrp): derive_share([ms_secret, opaque], "c") # type: ignore[list-item] threshold_three = parse_codex32(oracle_encode("ms", "3testc" + "q" * len(ms_secret.payload_symbols))) diff --git a/tools/fuzz_correction_context.py b/tools/fuzz_correction_context.py deleted file mode 100644 index 2fda4c8e..00000000 --- a/tools/fuzz_correction_context.py +++ /dev/null @@ -1,47 +0,0 @@ -"""Dependency-free structured fuzz target for full-string correction context.""" - -from __future__ import annotations - -import sys - -from codex32 import CorrectionContext, Profile, correct -from codex32.errors import CodexError - -MAX_INPUT = 4096 -_PROFILES = tuple(Profile) -_TARGETS = { - Profile.MS: (48, 74, 127), - Profile.CL: (74,), - Profile.BIP39_12W: (56,), - Profile.BIP39_24W: (82,), -} - - -def LLVMFuzzerTestOneInput(data: bytes) -> int: - if not data or len(data) > MAX_INPUT: - return 0 - selector, payload = data[0], data[1:] - profile = _PROFILES[selector % len(_PROFILES)] - text = payload.decode("utf-8", "surrogateescape") - targets = _TARGETS[profile] - expected_length = None if selector & 4 else targets[selector % len(targets)] - base = f"{profile.value}1" - immutable_prefix = None if selector & 8 else text[: len(base) + 5] - excluded_indices = () if selector & 16 else tuple(text[5:37]) - try: - correct( - CorrectionContext( - profile, - expected_length, - immutable_prefix, - excluded_indices, - ), - text, - ) - except CodexError: - pass - return 0 - - -if __name__ == "__main__": - raise SystemExit(LLVMFuzzerTestOneInput(sys.stdin.buffer.read(MAX_INPUT + 1))) diff --git a/tools/fuzz_untrusted_boundaries.py b/tools/fuzz_untrusted_boundaries.py deleted file mode 100644 index 9f642765..00000000 --- a/tools/fuzz_untrusted_boundaries.py +++ /dev/null @@ -1,45 +0,0 @@ -"""Dependency-free structured fuzz target for bounded untrusted inputs.""" - -from __future__ import annotations - -import contextlib -import io -import shlex -import sys - -from codex32 import CorrectionContext, Profile, correct, parse_codex32, recover_secret -from codex32._cli_parser import parser -from codex32.errors import CodexError - -MAX_INPUT = 4096 -_PROFILES = tuple(Profile) - - -def LLVMFuzzerTestOneInput(data: bytes) -> int: - """Exercise one selected boundary; validation failures are expected.""" - if not data or len(data) > MAX_INPUT: - return 0 - mode, payload = data[0] % 4, data[1:] - text = payload.decode("utf-8", "surrogateescape") - try: - if mode == 0: - parse_codex32(text) - elif mode == 1: - profile = _PROFILES[payload[0] % len(_PROFILES)] if payload else Profile.MS - target = {Profile.MS: 48, Profile.CL: 74, Profile.BIP39_12W: 56, Profile.BIP39_24W: 82} - correct(CorrectionContext(profile, target[profile]), text) - elif mode == 2: - artifacts = [parse_codex32(token) for token in text.split()[:10]] - recover_secret(artifacts) - else: - tokens = shlex.split(text) - if len(tokens) <= 16 and all(len(token) <= 128 for token in tokens): - with contextlib.redirect_stdout(io.StringIO()), contextlib.redirect_stderr(io.StringIO()): - parser().parse_args(tokens) - except (CodexError, SystemExit, ValueError): - pass - return 0 - - -if __name__ == "__main__": - raise SystemExit(LLVMFuzzerTestOneInput(sys.stdin.buffer.read(MAX_INPUT + 1))) From c560bdd5e7760abbb78ccfecd06cf7780fb3d8f2 Mon Sep 17 00:00:00 2001 From: Ben Westgate Date: Sat, 24 Oct 2026 00:48:51 -0500 Subject: [PATCH 2/3] cli: Reject unprefixed creation separators A literal 1 separates the application prefix from a backup header. Treating 12 or 10 as standalone thresholds silently changes the creation policy. Preserve bare threshold and ms1 header support. --- src/codex32/cli.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/codex32/cli.py b/src/codex32/cli.py index b7f501b3..51895c92 100644 --- a/src/codex32/cli.py +++ b/src/codex32/cli.py @@ -207,7 +207,7 @@ def _creation_header(value: str | None) -> tuple[int | None, str | None]: if lowered != value and value.upper() != value: raise _UsageError("The set header must use either uppercase or lowercase.") hrp, _separator, header = lowered.rpartition("1") - if hrp not in ("", Profile.MS.value): + 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( From 16d6d0af94404a168d2bef1b6c80418eda9fa2bf Mon Sep 17 00:00:00 2001 From: Ben Westgate Date: Sat, 24 Oct 2026 00:48:54 -0500 Subject: [PATCH 3/3] test: Cover unsafe creation headers --- tests/test_cli.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/tests/test_cli.py b/tests/test_cli.py index b4d92237..eb53f887 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -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"])