From 9404991eaa78efe5cddcea4afcea9d0fc296aa45 Mon Sep 17 00:00:00 2001 From: Ben Westgate Date: Thu, 22 Oct 2026 04:50:50 -0500 Subject: [PATCH] errors: Make recovery diagnostics consistent Move friendly recovery and checksum diagnostics from the CLI exception map to the underlying library errors. Keep all exception types and validation decisions unchanged so callers of the public API and interactive commands see the same message. Distinguish an invalid threshold-zero recovery input from two valid shares with mismatched thresholds, and assert the messages in the existing malformed-checksum and share-set tests. Refs #156 --- src/codex32/_cli_input.py | 20 ++------------------ src/codex32/bip93.py | 18 +++++++++--------- tests/test_bip93.py | 3 ++- tests/test_sharing.py | 21 ++++++++++++++------- 4 files changed, 27 insertions(+), 35 deletions(-) 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/bip93.py b/src/codex32/bip93.py index 238f7dd2..632d81eb 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,7 +278,7 @@ 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("Only strings with a threshold of 2 through 9 can be combined.") 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) @@ -286,11 +286,11 @@ def _validate_share_set(artifacts: Sequence[Share | Secret], *, require_exact: b 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") + raise MismatchedHrp("These strings are for different applications.") if item.header.threshold != threshold: - raise MismatchedThreshold("share thresholds do not match") + raise MismatchedThreshold("These strings require different numbers of shares.") if item.header.identifier != first.header.identifier: - raise MismatchedIdentifier("share identifiers do not match") + raise MismatchedIdentifier("These strings have different identifiers.") tail, item_checksum_length, item_encoded_length = _artifact_tail(item) item_shape = ( len(item.payload_symbols), @@ -298,11 +298,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("These strings 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, @@ -317,7 +317,7 @@ 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) 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.") def _validate_basis_prefix(basis: Sequence[Share | Secret]) -> None: @@ -342,7 +342,7 @@ 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") + raise SecretInRecoverySet("Enter ordinary shares rather than the shared secret.") recovered = _interpolate_tail(share_set, "s") if not isinstance(recovered, Secret): raise InvalidShareSet("interpolation did not produce a secret") diff --git a/tests/test_bip93.py b/tests/test_bip93.py index a57b1f05..314e4b62 100644 --- a/tests/test_bip93.py +++ b/tests/test_bip93.py @@ -106,8 +106,9 @@ def test_vector_6_core_lightning_examples_are_immutable() -> None: @pytest.mark.parametrize("value", BAD_CHECKSUMS) def test_bad_checksums_are_rejected(value: str) -> None: - with pytest.raises(InvalidChecksum): + with pytest.raises(InvalidChecksum) as error: parse_codex32(value) + assert str(error.value) == "The checksum does not match." @pytest.mark.parametrize("value", BAD_CASES) diff --git a/tests/test_sharing.py b/tests/test_sharing.py index ab6e7742..da6566fe 100644 --- a/tests/test_sharing.py +++ b/tests/test_sharing.py @@ -112,28 +112,35 @@ 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(MismatchedProfile) as error: derive_share([ms_secret, opaque], "c") # type: ignore[list-item] + assert str(error.value) == "These strings are for different applications." threshold_three = parse_codex32(oracle_encode("ms", "3testc" + "q" * len(ms_secret.payload_symbols))) - with pytest.raises(MismatchedThreshold): + with pytest.raises(MismatchedThreshold) as error: derive_share([ms_secret, threshold_three], "d") # type: ignore[list-item] + assert str(error.value) == "These strings require different numbers of shares." other_id = parse_codex32(oracle_encode("ms", "2namec" + "q" * len(ms_secret.payload_symbols))) - with pytest.raises(MismatchedIdentifier): + with pytest.raises(MismatchedIdentifier) as error: derive_share([ms_secret, other_id], "d") # type: ignore[list-item] + assert str(error.value) == "These strings have different identifiers." longer = MasterSeed.from_seed(bytes(20), identifier="test", threshold=2) longer_mask = parse_codex32(oracle_encode("ms", "2testc" + "q" * len(longer.payload_symbols))) - with pytest.raises(MismatchedPayloadLength): + with pytest.raises(MismatchedPayloadLength) as error: derive_share([ms_secret, longer_mask], "d") # type: ignore[list-item] + assert str(error.value) == "These strings have different lengths." - with pytest.raises(DuplicateShareIndex): + with pytest.raises(DuplicateShareIndex) as error: recover_secret([ms_masks[0], ms_masks[0]]) # type: ignore[list-item] - with pytest.raises(SecretInRecoverySet): + assert str(error.value) == "That share index was already entered." + with pytest.raises(SecretInRecoverySet) as error: recover_secret([ms_secret, ms_masks[0]]) # type: ignore[list-item] - with pytest.raises(MismatchedThreshold): + assert str(error.value) == "Enter ordinary shares rather than the shared secret." + with pytest.raises(MismatchedThreshold) as error: recover_secret([MasterSeed.from_seed(bytes(16), identifier="test")]) # type: ignore[list-item] + assert str(error.value) == "Only strings with a threshold of 2 through 9 can be combined." with pytest.raises(TypeError): recover_secret([VECTOR_2["share_A"], VECTOR_2["share_C"]]) # type: ignore[list-item] with pytest.raises(TypeError):