Skip to content
Draft
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
5 changes: 4 additions & 1 deletion docs/user/guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
19 changes: 6 additions & 13 deletions src/codex32/_cli_input.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,6 @@
from codex32.errors import (
CodexError,
DuplicateShareIndex,
ExistingTargetIndex,
InvalidChecksum,
InvalidLength,
InvalidThreshold,
Expand Down Expand Up @@ -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
Expand All @@ -619,7 +615,6 @@ def _redirected(
*,
basis: bool,
one: bool,
excluded_index: str | None,
fingerprint: Callable[[MasterSeed], bytes] | None,
) -> list[Artifact]:
tokens = _stdin().split()
Expand All @@ -642,7 +637,6 @@ def allowed(candidate: CorrectionCandidate) -> bool:
accepted,
basis=basis,
one=one,
excluded_index=excluded_index,
)
except CodexError:
return False
Expand All @@ -666,7 +660,6 @@ def allowed(candidate: CorrectionCandidate) -> bool:
accepted,
basis=basis,
one=one,
excluded_index=excluded_index,
)
accepted.append(artifact)
return accepted
Expand Down Expand Up @@ -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,
Expand All @@ -737,7 +730,6 @@ def validate(artifact: Artifact) -> None:
accepted,
basis=basis,
one=one,
excluded_index=excluded_index,
)

def allowed(candidate: CorrectionCandidate) -> bool:
Expand Down Expand Up @@ -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 = ""
Expand All @@ -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:
Expand All @@ -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,
Expand All @@ -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,
Expand Down
16 changes: 8 additions & 8 deletions src/codex32/_cli_parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -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: ")
Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -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",
Expand Down
38 changes: 22 additions & 16 deletions src/codex32/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,6 @@
Header,
Secret,
Share,
_normalize_target,
derive_share,
parse_codex32,
recover_secret,
Expand All @@ -54,6 +53,7 @@
from codex32.generation import (
ConfirmationResult,
CreationCeremony,
_indices,
generate_master_seed,
)
from codex32.profiles import Profile, _profile_rules
Expand Down Expand Up @@ -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


Expand Down
46 changes: 37 additions & 9 deletions tests/test_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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],
Expand All @@ -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"
)


Expand All @@ -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:
Expand All @@ -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)
Expand Down
Loading