Skip to content
Open
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
20 changes: 2 additions & 18 deletions src/codex32/_cli_input.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion src/codex32/bech32.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]


Expand Down
24 changes: 12 additions & 12 deletions src/codex32/bip93.py
Comment thread
BenWestgate marked this conversation as resolved.
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down Expand Up @@ -278,31 +278,32 @@ 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),
item_checksum_length,
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,
Expand All @@ -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:
Expand All @@ -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")
Expand Down
4 changes: 2 additions & 2 deletions tests/test_bech32.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)


Expand Down
9 changes: 8 additions & 1 deletion tests/test_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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 == []
Expand Down
6 changes: 4 additions & 2 deletions tests/test_sharing.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)))
Expand All @@ -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]
Expand Down
Loading