diff --git a/src/codex32/_cli_input.py b/src/codex32/_cli_input.py index 74a4364d..d25e5309 100644 --- a/src/codex32/_cli_input.py +++ b/src/codex32/_cli_input.py @@ -29,11 +29,6 @@ ExistingTargetIndex, InvalidChecksum, InvalidLength, - MismatchedIdentifier, - MismatchedPayloadLength, - MismatchedProfile, - MismatchedThreshold, - SecretInRecoverySet, ) from codex32.profiles import Profile, _optional_profile_rules, _profile_rules from codex32.profiles.ms32 import ( @@ -318,7 +313,7 @@ def _parse(value: str, profiles: tuple[Profile, ...] | None) -> Artifact: try: artifact = parse_codex32(value) except CodexError as error: - message = _FRIENDLY_SET_ERRORS.get(type(error), str(error)) + message = str(error) if isinstance(error, InvalidChecksum): # Parsing has already checked the container and generic length. # Explain an unsupported profile length without validating input @@ -688,17 +683,6 @@ def allowed(candidate: CorrectionCandidate) -> bool: return accepted -_FRIENDLY_SET_ERRORS: dict[type[Exception], str] = { - InvalidChecksum: "The checksum does not match.", - MismatchedProfile: "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.", - DuplicateShareIndex: "That share index was already entered.", - SecretInRecoverySet: "Enter ordinary shares rather than the shared secret.", -} - - def _interactive( *, basis: bool, @@ -774,7 +758,7 @@ def allowed(candidate: CorrectionCandidate) -> bool: try: validate(artifact) except CodexError as error: - _stderr(f"Rejected: {_FRIENDLY_SET_ERRORS.get(type(error), str(error))}\n") + _stderr(f"Rejected: {error}\n") duplicate = isinstance(error, (DuplicateShareIndex, ExistingTargetIndex)) prefill = "" if duplicate else _retry_text(entered, prefix) continue diff --git a/src/codex32/bech32.py b/src/codex32/bech32.py index 7704f012..7c44f456 100644 --- a/src/codex32/bech32.py +++ b/src/codex32/bech32.py @@ -119,7 +119,7 @@ def bech32_decode(value: str, spec: _Checksum | None = None) -> tuple[str, list[ if spec is None: return hrp, data if len(data) < spec.length or not bech32_verify_checksum(hrp, data, spec): - raise InvalidChecksum(f"invalid {spec.kind} checksum") + raise InvalidChecksum("The checksum does not match.") return hrp, data[: -spec.length] diff --git a/src/codex32/bip93.py b/src/codex32/bip93.py index 238f7dd2..7746d75c 100644 --- a/src/codex32/bip93.py +++ b/src/codex32/bip93.py @@ -103,7 +103,7 @@ def _decode_codex32(text: str) -> tuple[str, _ProfileRules | None, tuple[int, .. body = tuple(encoded[: -checksum.length]) Header._from_symbols(body[:6]) if not bech32_verify_checksum(hrp, encoded, checksum): - raise InvalidChecksum(f"invalid {checksum.kind} checksum") + raise InvalidChecksum("The checksum does not match.") profile_rules = _optional_profile_rules(hrp) if profile_rules is not None: profile_rules.validate_text_length(len(text)) @@ -278,19 +278,20 @@ def _validate_share_set(artifacts: Sequence[Share | Secret], *, require_exact: b first = copied[0] threshold = first.header.threshold if threshold not in range(2, 10): - raise MismatchedThreshold("linear sharing requires threshold 2 through 9") + raise MismatchedThreshold("An unshared secret (threshold 0) was never split into shares.") if len(copied) > threshold or (require_exact and len(copied) != threshold): raise WrongShareCount(f"threshold is {threshold}, but {len(copied)} artifacts were supplied") first_tail, checksum_length, encoded_length = _artifact_tail(first) + noun = "strings" if any(isinstance(item, Secret) for item in copied) else "shares" tails = [first_tail] indices = [first.header.index] for item in copied[1:]: if item.hrp != first.hrp: raise MismatchedHrp(f"{first.hrp} and {item.hrp} cannot be combined") if item.header.threshold != threshold: - raise MismatchedThreshold("share thresholds do not match") + raise MismatchedThreshold(f"These {noun} have different thresholds.") if item.header.identifier != first.header.identifier: - raise MismatchedIdentifier("share identifiers do not match") + raise MismatchedIdentifier(f"These {noun} have different identifiers.") tail, item_checksum_length, item_encoded_length = _artifact_tail(item) item_shape = ( len(item.payload_symbols), @@ -298,11 +299,11 @@ def _validate_share_set(artifacts: Sequence[Share | Secret], *, require_exact: b item_encoded_length, ) if item_shape != (len(first.payload_symbols), checksum_length, encoded_length): - raise MismatchedPayloadLength("share payload/checksum lengths do not match") + raise MismatchedPayloadLength(f"These {noun} have different lengths.") tails.append(tail) indices.append(item.header.index) if len(set(indices)) != len(indices): - raise DuplicateShareIndex("share indices must be distinct") + raise DuplicateShareIndex("That share index was already entered.") return _ShareSet( copied, first.hrp, @@ -314,10 +315,11 @@ def _validate_share_set(artifacts: Sequence[Share | Secret], *, require_exact: b ) -def _validate_recovery_prefix(shares: Sequence[Share | Secret]) -> None: - share_set = _validate_share_set(shares, require_exact=False) +def _validate_recovery_prefix(shares: Sequence[Share | Secret], *, require_exact: bool = False) -> _ShareSet: + share_set = _validate_share_set(shares, require_exact=require_exact) if any(isinstance(item, Secret) for item in share_set.artifacts): - raise SecretInRecoverySet("recovery accepts ordinary shares only") + raise SecretInRecoverySet("Enter ordinary shares rather than the shared secret.") + return share_set def _validate_basis_prefix(basis: Sequence[Share | Secret]) -> None: @@ -340,9 +342,7 @@ def _interpolate_tail(share_set: _ShareSet, target: str) -> Share | Secret: def recover_secret(shares: Sequence[Share]) -> Secret: """Recover S from exactly k compatible, distinct ordinary shares.""" - share_set = _validate_share_set(shares) - if any(isinstance(item, Secret) for item in share_set.artifacts): - raise SecretInRecoverySet("recovery accepts ordinary shares only") + share_set = _validate_recovery_prefix(shares, require_exact=True) recovered = _interpolate_tail(share_set, "s") if not isinstance(recovered, Secret): raise InvalidShareSet("interpolation did not produce a secret") diff --git a/tests/test_bech32.py b/tests/test_bech32.py index 6ac28b4b..99a1187e 100644 --- a/tests/test_bech32.py +++ b/tests/test_bech32.py @@ -46,10 +46,10 @@ def test_decoder_optionally_verifies_and_removes_checksum() -> None: assert bech32_decode(encoded, _CODEX32) == ("ms", data) damaged = encoded[:-1] + ("q" if encoded[-1] != "q" else "p") - with pytest.raises(InvalidChecksum, match="invalid codex32 checksum"): + with pytest.raises(InvalidChecksum, match="The checksum does not match"): bech32_decode(damaged, _CODEX32) - with pytest.raises(InvalidChecksum, match="invalid codex32 checksum"): + with pytest.raises(InvalidChecksum, match="The checksum does not match"): bech32_decode("ms1q", _CODEX32) diff --git a/tests/test_cli.py b/tests/test_cli.py index 1944b007..aa8f0800 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -797,6 +797,13 @@ def test_redirected_check_uses_friendly_checksum_message() -> None: assert result.stderr == "codex32 check: The checksum does not match.\n" +def test_share_from_unshared_secret_says_it_was_never_split() -> None: + result = _invoke(["share", "a"], VECTOR_1["secret_s"]) + + assert result.exit_code == 1 + assert result.stderr == "codex32 share: An unshared secret (threshold 0) was never split into shares.\n" + + def test_tty_retry_without_line_editor_uses_an_empty_prompt( monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] ) -> None: @@ -1003,7 +1010,7 @@ def answer(prompt: str) -> str: "Enter share 2 of 2:\n> MS12NAME", "Enter share 2 of 2:\n> MS12NAME", ] - assert "Rejected: These strings are for different applications." in captured.err + assert "Rejected: ms and bip39_12w cannot be combined" in captured.err assert "Rejected: That share index was already entered." in captured.err assert first not in captured.err and mismatch not in captured.err assert editor.inserted == [] diff --git a/tests/test_sharing.py b/tests/test_sharing.py index ab6e7742..ba85077b 100644 --- a/tests/test_sharing.py +++ b/tests/test_sharing.py @@ -120,8 +120,10 @@ def test_share_set_mismatches_have_distinct_errors() -> None: derive_share([ms_secret, threshold_three], "d") # type: ignore[list-item] other_id = parse_codex32(oracle_encode("ms", "2namec" + "q" * len(ms_secret.payload_symbols))) - with pytest.raises(MismatchedIdentifier): + with pytest.raises(MismatchedIdentifier, match="These strings have different identifiers"): derive_share([ms_secret, other_id], "d") # type: ignore[list-item] + with pytest.raises(MismatchedIdentifier, match="These shares have different identifiers"): + recover_secret([ms_masks[0], other_id]) # type: ignore[list-item] longer = MasterSeed.from_seed(bytes(20), identifier="test", threshold=2) longer_mask = parse_codex32(oracle_encode("ms", "2testc" + "q" * len(longer.payload_symbols))) @@ -132,7 +134,7 @@ def test_share_set_mismatches_have_distinct_errors() -> None: recover_secret([ms_masks[0], ms_masks[0]]) # type: ignore[list-item] with pytest.raises(SecretInRecoverySet): recover_secret([ms_secret, ms_masks[0]]) # type: ignore[list-item] - with pytest.raises(MismatchedThreshold): + with pytest.raises(MismatchedThreshold, match=r"unshared secret \(threshold 0\) was never split"): recover_secret([MasterSeed.from_seed(bytes(16), identifier="test")]) # type: ignore[list-item] with pytest.raises(TypeError): recover_secret([VECTOR_2["share_A"], VECTOR_2["share_C"]]) # type: ignore[list-item]