From 5c805340774506e840925782ec4c3e7a60614a8b Mon Sep 17 00:00:00 2001 From: Ben Westgate Date: Sat, 24 Oct 2026 04:24:20 -0500 Subject: [PATCH] cli: Write several additional cards per run Read the input cards once for a batch of requested indices. Entered indices produce copies, including copy-only runs below threshold; new indices still require a complete validated basis. Resolve every output before printing any card, and confirm each interactive copy. Extract the remaining CLI behavior from the oversized original PR. The creation-copy change is reviewed separately in PR 197. Retain the current library contract and avoid carrying old wallet changes. Validation: 292 CLI and generation tests pass normally and with Python -O; Ruff lint/format, mypy and diff checks pass. refs #103 refs #87 --- docs/user/guide.md | 5 ++++- src/codex32/_cli_input.py | 19 +++++----------- src/codex32/_cli_parser.py | 16 ++++++------- src/codex32/cli.py | 38 ++++++++++++++++++------------- tests/test_cli.py | 46 ++++++++++++++++++++++++++++++-------- 5 files changed, 77 insertions(+), 47 deletions(-) diff --git a/docs/user/guide.md b/docs/user/guide.md index b875ab04..d02d16cc 100644 --- a/docs/user/guide.md +++ b/docs/user/guide.md @@ -272,7 +272,10 @@ follow Bitcoin Core v32's maintained not prove that it belongs to this wallet. - `ms32 correct` suggests a repair. Compare any suggestion character by character with the physical backup before confirming it. -- `ms32 share d` derives a replacement at unused index `d`. Confirm and +- `ms32 share d` derives a replacement at unused index `d`; `ms32 share cdf` + writes several cards after entering the existing cards once. Entered or + repeated indices make identical copies, which do not add distinct shares. + Copying only entered cards does not require a full threshold. Confirm and store the new card before retiring an old one. Interactive use displays the share, then asks you to write and re-enter it. Matching groups stay confirmed while you recheck highlighted regions. Success prints “Recovery card diff --git a/src/codex32/_cli_input.py b/src/codex32/_cli_input.py index 53ba5b9c..95c9cd70 100644 --- a/src/codex32/_cli_input.py +++ b/src/codex32/_cli_input.py @@ -26,7 +26,6 @@ from codex32.errors import ( CodexError, DuplicateShareIndex, - ExistingTargetIndex, InvalidChecksum, InvalidLength, InvalidThreshold, @@ -604,10 +603,7 @@ def _validate_operational_artifact( *, basis: bool, one: bool, - excluded_index: str | None, ) -> None: - if basis and artifact.header.index == excluded_index: - 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 @@ -619,7 +615,6 @@ def _redirected( *, basis: bool, one: bool, - excluded_index: str | None, fingerprint: Callable[[MasterSeed], bytes] | None, ) -> list[Artifact]: tokens = _stdin().split() @@ -642,7 +637,6 @@ def allowed(candidate: CorrectionCandidate) -> bool: accepted, basis=basis, one=one, - excluded_index=excluded_index, ) except CodexError: return False @@ -666,7 +660,6 @@ def allowed(candidate: CorrectionCandidate) -> bool: accepted, basis=basis, one=one, - excluded_index=excluded_index, ) accepted.append(artifact) return accepted @@ -722,7 +715,7 @@ def _interactive( *, basis: bool, one: bool, - excluded_index: str | None, + requested: str, profiles: tuple[Profile, ...] | None, initial_prefix: str, fingerprint: Callable[[MasterSeed], bytes] | None, @@ -737,7 +730,6 @@ def validate(artifact: Artifact) -> None: accepted, basis=basis, one=one, - excluded_index=excluded_index, ) def allowed(candidate: CorrectionCandidate) -> bool: @@ -798,7 +790,7 @@ def allowed(candidate: CorrectionCandidate) -> bool: validate(artifact) except CodexError as error: _stderr(f"Rejected: {_FRIENDLY_SET_ERRORS.get(type(error), str(error))}\n") - duplicate = isinstance(error, (DuplicateShareIndex, ExistingTargetIndex)) + duplicate = isinstance(error, DuplicateShareIndex) prefill = "" if duplicate else _retry_text(entered, prefix) continue prefill = "" @@ -809,6 +801,8 @@ def allowed(candidate: CorrectionCandidate) -> bool: if not accepted: required = artifact.header.threshold accepted.append(artifact) + if requested and set(requested) <= {item.header.index for item in accepted}: + break # Copying entered cards needs no threshold. prefix = _accepted_prefix(accepted) grouped_prefix = len(entered.split()) > 1 if len(accepted) < required: @@ -820,7 +814,7 @@ def read_artifacts( *, basis: bool = False, one: bool = False, - excluded_index: str | None = None, + requested: str = "", profiles: tuple[Profile, ...] | None = None, initial_prefix: str = "", fingerprint: Callable[[MasterSeed], bytes] | None = None, @@ -830,13 +824,12 @@ def read_artifacts( profiles, basis=basis, one=one, - excluded_index=excluded_index, fingerprint=fingerprint, ) result = _interactive( basis=basis, one=one, - excluded_index=excluded_index, + requested=requested, profiles=profiles, initial_prefix=initial_prefix, fingerprint=fingerprint, diff --git a/src/codex32/_cli_parser.py b/src/codex32/_cli_parser.py index c2472109..e32a8f26 100644 --- a/src/codex32/_cli_parser.py +++ b/src/codex32/_cli_parser.py @@ -15,9 +15,7 @@ class _Parser(argparse.ArgumentParser): def error(self, message: str) -> NoReturn: if message.startswith("the following arguments are required: "): message = ( - "Choose an index for the additional share." - if message.endswith("INDEX") - else "Choose a command." + "Choose indices for the new shares." if message.endswith("INDICES") else "Choose a command." ) elif message.startswith("unrecognized arguments: "): message = "Remove or correct these arguments: " + message.removeprefix("unrecognized arguments: ") @@ -113,11 +111,11 @@ def parser(prog: str = "codex32", *, master_seed: bool = False) -> argparse.Argu "derive a share from codex32 strings", ) share.description = ( - "Derive a share at INDEX using exactly the threshold number of codex32 strings from the same set. " - "Use different input indices; one input may be the secret. " - "INDEX must differ from S and the input indices." + "Derive a share at each of INDICES using the threshold number of codex32 strings from the same set. " + "Use different input indices; one input may be the secret. An entered or repeated index makes a copy; " + "copies need only the entered cards. INDICES cannot include S." ) - share.add_argument("index", metavar="INDEX", help="index for the derived share") + share.add_argument("index", metavar="INDICES", help="indices for the new shares, such as d or cdf") share.add_argument("--plain", action="store_true", help="print without formatting or card confirmation") correct = _command(commands, "correct", "suggest repairs for a damaged codex32 string") @@ -176,7 +174,9 @@ def parser(prog: str = "codex32", *, master_seed: bool = False) -> argparse.Argu metavar="COUNT", help="number of shares to output (defaults: 3 for threshold 2; 5 for threshold 3)", ) - create.add_argument("--indices", metavar="INDICES", help="exact share indices, in output order") + create.add_argument( + "--indices", metavar="INDICES", help="exact share indices; repeats make copies, output last" + ) create.add_argument( "--existing", action="store_true", diff --git a/src/codex32/cli.py b/src/codex32/cli.py index 9b830137..51bf93ba 100644 --- a/src/codex32/cli.py +++ b/src/codex32/cli.py @@ -39,7 +39,6 @@ Header, Secret, Share, - _normalize_target, derive_share, parse_codex32, recover_secret, @@ -54,6 +53,7 @@ from codex32.generation import ( ConfirmationResult, CreationCeremony, + _indices, generate_master_seed, ) from codex32.profiles import Profile, _profile_rules @@ -173,30 +173,36 @@ def _emit( ) -def _share_command(index: str, plain: bool, context: _CliContext, core: BitcoinCore | None = None) -> int: +def _share_command(indices: str, plain: bool, context: _CliContext, core: BitcoinCore | None = None) -> int: try: - index = _normalize_target(index, label="share index") - except CodexError as error: - raise _UsageError(f"Choose one share index from {IDX_SORT[1:].upper()}.") from error + requested = _indices(indices) + except CodexError: + requested = () + if not requested: + raise _UsageError(f"Choose share indices from {IDX_SORT[1:].upper()}.") artifacts = _artifacts( basis=True, - excluded_index=index, + requested="".join(requested), profiles=context.profiles, initial_prefix=context.initial_prefix, fingerprint=core.fingerprint if core is not None else None, ) - try: - derived = derive_share(artifacts, index) + entered = {artifact.header.index: artifact for artifact in artifacts} + try: # Resolve every card before printing any, so a failure prints none. + shares = [ + entered[index] if index in entered else derive_share(artifacts, index) for index in requested + ] except CodexError as error: raise _CommandError(str(error)) from error - _emit(derived, plain) - if sys.stdin.isatty() and sys.stdout.isatty() and not plain: - try: - _confirm_card(derived) - except (EOFError, KeyboardInterrupt) as error: - _print("Recovery card not confirmed.", err=True) - return 130 if isinstance(error, KeyboardInterrupt) else 2 - _print("Recovery card confirmed.", err=True) + for share in shares: + _emit(share, plain) + if sys.stdin.isatty() and sys.stdout.isatty() and not plain: + try: + _confirm_card(share) + except (EOFError, KeyboardInterrupt) as error: + _print("Recovery card not confirmed.", err=True) + return 130 if isinstance(error, KeyboardInterrupt) else 2 + _print("Recovery card confirmed.", err=True) return 0 diff --git a/tests/test_cli.py b/tests/test_cli.py index 57bf50d1..12030312 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -1244,7 +1244,7 @@ def test_share_supports_ms_and_bip39() -> None: assert bip39.stdout.strip() == SHARING_VECTORS["bip39_12w"]["D"] -@pytest.mark.parametrize("index", ("b", "i", "1", "s", "aa")) +@pytest.mark.parametrize("index", ("b", "i", "1", "s", "ds", "")) def test_share_rejects_invalid_target_before_prompting( monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], @@ -1260,7 +1260,7 @@ def forbidden(_prompt: str) -> str: assert main(["share", index]) == 2 assert capsys.readouterr().err == ( - "codex32 share: Choose one share index from ACDEFGHJKLMNPQRTUVWXYZ023456789.\n" + "codex32 share: Choose share indices from ACDEFGHJKLMNPQRTUVWXYZ023456789.\n" ) @@ -1269,17 +1269,16 @@ def test_share_argument_errors_are_actionable() -> None: assert missing.exit_code == 2 assert missing.stderr == ( - "usage: codex32 share [-h] [--plain] INDEX\n" - "codex32 share: Choose an index for the additional share.\n" + "usage: codex32 share [-h] [--plain] INDICES\ncodex32 share: Choose indices for the new shares.\n" ) -def test_tty_share_rejects_target_index_as_soon_as_entered( +def test_tty_share_copies_an_entered_card_without_the_threshold( monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] ) -> None: input_module = importlib.import_module("codex32._cli_input") - answers = iter((VECTOR_2["derived_D"], VECTOR_2["share_A"], VECTOR_2["share_C"])) + answers = iter((VECTOR_2["derived_D"],)) editor = _FakeLineEditor() def answer(_prompt: str) -> str: @@ -1290,13 +1289,42 @@ def answer(_prompt: str) -> str: monkeypatch.setattr(input_module, "_line_editor", editor) monkeypatch.setattr(builtins, "input", answer) - assert main(["share", "d"]) == 0 + assert main(["share", "dd"]) == 0 captured = capsys.readouterr() - assert captured.out.strip() == VECTOR_2["derived_D"] - assert "Rejected: That index was requested for the additional share." in captured.err + assert captured.out.split() == [VECTOR_2["derived_D"]] * 2 + assert "Rejected" not in captured.err assert editor.inserted == [] +def test_share_derives_several_indices_and_copies_entered_ones() -> None: + result = _invoke(["share", "dc"], VECTOR_2["share_A"], VECTOR_2["share_C"]) + + assert result.exit_code == 0 + assert result.stdout.split() == [VECTOR_2["derived_D"], VECTOR_2["share_C"]] + + +def test_each_share_copy_requires_terminal_confirmation( + monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] +) -> None: + card = VECTOR_2["derived_D"] + answers = iter((card, "", card, "", card)) + monkeypatch.setattr(sys, "stdin", _TTYInput()) + monkeypatch.setattr(sys, "stdout", _TTYOutput()) + monkeypatch.setattr(builtins, "input", lambda _prompt: next(answers)) + + assert main(["share", "dd"]) == 0 + assert capsys.readouterr().err.count("Recovery card confirmed.") == 2 + assert list(answers) == [] + + +def test_share_prints_nothing_when_any_requested_card_fails() -> None: + result = _invoke(["share", "ac"], VECTOR_2["share_A"]) + + assert result.exit_code == 1 + assert result.stdout == "" + assert result.stderr == "codex32 share: threshold is 2, but 1 artifacts were supplied\n" + + def test_create_defaults_to_an_unshared_128_bit_master_seed() -> None: result = _invoke_confirmed_create(["create"]) artifacts = _output_artifacts(result)