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
9 changes: 9 additions & 0 deletions docs/developer/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -322,6 +322,15 @@ other HRP. `hrp` is a case-insensitive string stored in lowercase. The context f
- `immutable_prefix`, program-supplied text outside the correction domain; and
- `excluded_indices`, ordinary share indices already accepted in a recovery.

Applications restoring a share set can call
`CorrectionContext.from_shares(accepted, hrp=None, expected_length=None, immutable_prefix=None)`.
It derives the fixed length, complete header prefix, and excluded indices from
compatible, validated ordinary shares. An explicit length, prefix, or HRP may
only agree with those shares. With no accepted shares, `hrp` is required and
optional length and prefix hints have the same meaning as the direct constructor.
The CLI uses this factory for interactive recovery and standalone correction;
graphical and other clients can share that constraint logic.

With or without `expected_length`, `ms` correction searches only valid
master-seed lengths reachable by a supported structural family. Another HRP
considers character offsets -4 through +4 and group offsets -8/-4/0/+4/+8,
Expand Down
8 changes: 6 additions & 2 deletions src/codex32/_cli_input.py
Original file line number Diff line number Diff line change
Expand Up @@ -341,9 +341,13 @@ def _corrections(
if isinstance(byte_length, int):
target = _text_length(byte_length)
immutable = prefix if prefix and value.lower().startswith(prefix.lower()) else value[: separator + 1]
excluded = tuple(artifact.share_idx for artifact in accepted)
context = CorrectionContext(hrp, target, _prefix_for_suffix(immutable, value), excluded)
try:
context = CorrectionContext.from_shares(
accepted,
hrp=hrp,
expected_length=target,
immutable_prefix=_prefix_for_suffix(immutable, value),
)
candidates, complete = _correct_complete(context, value, allowed=allowed, competitors=True)
except InvalidCorrectionInput:
return (), True
Expand Down
43 changes: 43 additions & 0 deletions src/codex32/correction.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
from collections.abc import Callable, Sequence
from dataclasses import dataclass, field, replace
from functools import cache, lru_cache
from itertools import islice
from math import comb
from time import monotonic
from typing import Literal
Expand All @@ -44,6 +45,7 @@
_has_generation_padding,
_header,
_validate_ms_length,
_validate_recovery_prefix,
parse_codex32,
)
from codex32.checksums import _CODEX32, _CODEX32_LONG, _Checksum
Expand Down Expand Up @@ -80,6 +82,47 @@ def __init__(
object.__setattr__(self, "immutable_prefix", immutable_prefix)
object.__setattr__(self, "excluded_indices", excluded_indices)

@classmethod
def from_shares(
cls,
accepted: Sequence[Codex32String],
*,
hrp: str | None = None,
expected_length: int | None = None,
immutable_prefix: str | None = None,
) -> "CorrectionContext":
"""Constrain a correction using compatible, previously accepted ordinary shares.

When no shares have been accepted, ``hrp`` identifies the application.
Supplied length and prefix hints may narrow, but never contradict, a set.
"""
shares = tuple(islice(accepted, 10))
if shares:
try:
_validate_recovery_prefix(shares)
except (CodexError, TypeError, ValueError) as error:
raise InvalidCorrectionInput("accepted shares must be compatible ordinary shares") from error
first = shares[0]
if hrp is not None and (not isinstance(hrp, str) or hrp.lower() != first.hrp):
raise InvalidCorrectionInput("hrp conflicts with accepted shares")
if expected_length is not None and expected_length != len(first.s):
raise InvalidCorrectionInput("expected_length conflicts with accepted shares")
header_prefix = f"{first.hrp}1{first.k}{first.ident}"
if immutable_prefix is not None and (
not isinstance(immutable_prefix, str)
or not header_prefix.startswith(immutable_prefix.lower())
):
raise InvalidCorrectionInput("immutable_prefix conflicts with accepted shares")
if immutable_prefix is not None and immutable_prefix.isupper():
header_prefix = header_prefix.upper()
context = cls(first.hrp, len(first.s), header_prefix, tuple(share.share_idx for share in shares))
Comment on lines +116 to +118

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve accepted-share case when deriving the default prefix

When accepted shares and the next damaged card are uppercase, calling CorrectionContext.from_shares(accepted) without an explicit prefix creates a lowercase immutable header. Correction checks that header case-sensitively, and uniformly uppercase input bypasses mixed-case normalization, so even a single checksum erasure returns no candidates. This is reproducible with the uppercase BIP93 VECTOR_2 shares A and C; supplying immutable_prefix="MS1" restores the expected correction. Derive the default header case from the accepted shares, or reconcile it with the damaged input, so clients using the factory's default arguments can correct uppercase cards.

Useful? React with 👍 / 👎.

else:
if hrp is None:
raise InvalidCorrectionInput("hrp is required without accepted shares")
context = cls(hrp, expected_length, immutable_prefix)
_validate_context(context)
return context


@dataclass(frozen=True, slots=True)
class CorrectionEdit:
Expand Down
37 changes: 37 additions & 0 deletions tests/test_public_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ def test_backup_workflows_need_only_the_standard_library() -> None:
assert derive_share(shares, "d").share_idx == "d"
damaged = shares[0].s[:-1] + "?"
assert correct(CorrectionContext("ms"), damaged)
assert CorrectionContext.from_shares(shares[:1]).excluded_indices == (shares[0].share_idx,)
"""
environment = os.environ.copy()
environment["PYTHONPATH"] = str(Path(__file__).parents[1] / "src")
Expand All @@ -54,6 +55,42 @@ def test_backup_workflows_need_only_the_standard_library() -> None:
assert result.returncode == 0, result.stderr


def test_correction_context_from_accepted_shares() -> None:
first = parse_codex32(VECTOR_2["share_A"])
second = parse_codex32(VECTOR_2["share_C"])
context = CorrectionContext.from_shares([first, second], hrp="MS", immutable_prefix="MS1")

assert context == CorrectionContext("ms", len(first.s), "MS12NAME", ("a", "c"))
assert correct(context, first.s) == () # An accepted index cannot be suggested again.
assert CorrectionContext.from_shares([], hrp="ms", expected_length=len(first.s)) == CorrectionContext(
"ms", len(first.s)
)


@pytest.mark.parametrize(
"kwargs",
(
{"hrp": "cl"},
{"expected_length": 74},
{"immutable_prefix": "ms12cash"},
),
)
def test_correction_context_rejects_conflicting_hints(kwargs: dict[str, object]) -> None:
first = parse_codex32(VECTOR_2["share_A"])
with pytest.raises(codex32.InvalidCorrectionInput):
CorrectionContext.from_shares([first], **kwargs) # type: ignore[arg-type]


def test_correction_context_rejects_incompatible_shares() -> None:
first = parse_codex32(VECTOR_2["share_A"])
with pytest.raises(codex32.InvalidCorrectionInput):
CorrectionContext.from_shares([first, first])
with pytest.raises(codex32.InvalidCorrectionInput):
CorrectionContext.from_shares([parse_codex32(VECTOR_1["secret_s"])])
with pytest.raises(codex32.InvalidCorrectionInput):
CorrectionContext.from_shares([])


def test_python_bip32_boundary_stops_at_the_root() -> None:
module = Path(__file__).parents[1] / "src" / "codex32" / "_bip32.py"
tree = ast.parse(module.read_text())
Expand Down
Loading