diff --git a/docs/security/model.md b/docs/security/model.md index 7e22a7b..2af16fd 100644 --- a/docs/security/model.md +++ b/docs/security/model.md @@ -175,8 +175,9 @@ reallocate the ledger's mass to additional hypotheses. `codex32` checks, corrects, recovers, and derives shares for any HRP. `ms32` applies an `ms` input filter and alone owns Bitcoin creation, xprv, and wallet -commands. Both façades offer worksheet-residue correction; only `ms32 correct` -offers `--bytes`. +commands. Both façades offer worksheet-residue correction and a known +`--prefix`; only `ms32 correct` offers `--bytes`, and only `codex32 correct` +offers `--length`. ### Low-discrimination correction disclosure diff --git a/docs/user/guide.md b/docs/user/guide.md index 616e42b..874b311 100644 --- a/docs/user/guide.md +++ b/docs/user/guide.md @@ -269,7 +269,11 @@ follow Bitcoin Core v32's maintained - `ms32 check` validates a card's format and checksum. A valid result does 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. + character with the physical backup before confirming it. If you know the + start of the string is right, such as the `ms12name` header shared by a set, + pass `--prefix ms12name` so correction keeps it. `codex32 correct --length N` + limits the search to the original's known length, which helps when + characters were inserted or dropped (`ms32 correct` uses `--bytes`). - `ms32 share d` derives a replacement at unused index `d`. 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 diff --git a/src/codex32/_cli_input.py b/src/codex32/_cli_input.py index 822b539..848b9ab 100644 --- a/src/codex32/_cli_input.py +++ b/src/codex32/_cli_input.py @@ -330,13 +330,14 @@ def _corrections( allowed: Callable[[CorrectionCandidate], bool] | None = None, fingerprint: Callable[[MasterSeed], bytes] | None = None, byte_length: int | Literal["?"] | None = None, + length: int | None = None, ) -> tuple[tuple[CorrectionCandidate, ...], bool]: """Run the library search, then the CLI's Hamming, CRC, and fingerprint tie breakers.""" separator = value.rfind("1") hrp = value[: max(separator, 0)].lower() if separator <= 0 or required_hrp not in (None, hrp): return (), True - target = len(accepted[0].s) if accepted else None + target = len(accepted[0].s) if accepted else length if isinstance(byte_length, int): target = _text_length(byte_length) immutable = prefix if prefix and value.lower().startswith(prefix.lower()) else value[: separator + 1] diff --git a/src/codex32/_cli_parser.py b/src/codex32/_cli_parser.py index 6542684..444e0c9 100644 --- a/src/codex32/_cli_parser.py +++ b/src/codex32/_cli_parser.py @@ -141,6 +141,18 @@ def parser(prog: str = "codex32", *, master_seed: bool = False) -> argparse.Argu metavar="POSITION", help="one-based position counted backward from the end; repeat as needed", ) + correct.add_argument( + "--prefix", + metavar="PREFIX", + help="known start that correction keeps, such as ms1 or the ms12name header shared by a set", + ) + if not master_seed: + correct.add_argument( + "--length", + type=_integer("length", 21), + metavar="LENGTH", + help="known total length of the damaged string's original", + ) if master_seed: correct.add_argument( "--bytes", diff --git a/src/codex32/cli.py b/src/codex32/cli.py index f3a1fba..185385a 100644 --- a/src/codex32/cli.py +++ b/src/codex32/cli.py @@ -44,8 +44,10 @@ recover_secret, ) from codex32.correction import ( + CorrectionContext, _best, _residue_low_discrimination, + _validate_context, correct_worksheet_residue, ) from codex32.errors import CodexError, HeaderCollision, InvalidCorrectionInput @@ -524,6 +526,8 @@ def _correct( residue: bool, erasures: tuple[int, ...], byte_length: int | Literal["?"] | None, + length: int | None, + prefix: str, plain: bool, context: _CliContext, core: BitcoinCore | None = None, @@ -532,12 +536,12 @@ def _correct( value = _text( prompt, preserve_groups=not residue, - prefill=context.initial_prefix if not residue else "", + prefill="" if residue else prefix or context.initial_prefix, prompt_end=":\n> ", ) if residue: - if byte_length is not None: - raise _UsageError("--bytes cannot be used with --residue.") + if byte_length is not None or length is not None or prefix: + raise _UsageError("--bytes, --length and --prefix cannot be used with --residue.") try: result = correct_worksheet_residue( value, erasure_indices=tuple(position - 1 for position in erasures) @@ -569,6 +573,19 @@ def _correct( hrp = raw_hrp.lower() if context.master_seed and hrp != MS: raise _UsageError("This command accepts only Bitcoin master-seed input beginning with ms1.") + if prefix and not normalized.lower().startswith(prefix.lower()): + raise _UsageError("The damaged string must begin with the --prefix text.") + for option, option_context in ( + ("--length must be a valid codex32 length for this prefix.", CorrectionContext(hrp, length)), + ( + f"--prefix must be {hrp}1, optionally followed by the threshold and identifier, as in {hrp}12name.", + CorrectionContext(hrp, None, prefix.lower() or None), + ), + ): + try: + _validate_context(option_context) + except InvalidCorrectionInput as error: + raise _UsageError(option) from error try: parse_codex32(normalized) except CodexError: @@ -576,9 +593,13 @@ def _correct( else: if isinstance(byte_length, int) and len(normalized) != _text_length(byte_length): raise _UsageError("--bytes does not match the valid master-seed backup length.") + if length is not None and len(normalized) != length: + raise _UsageError("--length does not match the valid string's length.") _print("The codex32 string is already valid.") return 0 - candidates, complete = _corrections(normalized, "", context.hrp, [], byte_length=byte_length) + candidates, complete = _corrections( + normalized, prefix, context.hrp, [], byte_length=byte_length, length=length + ) if not complete and not candidates: raise _CommandError("The correction search did not complete within ten seconds.") if not candidates: @@ -671,6 +692,8 @@ def _dispatch(arguments: argparse.Namespace, context: _CliContext) -> int: bool(arguments.residue), tuple(cast(list[int], arguments.erasures)), cast(int | Literal["?"] | None, getattr(arguments, "byte_length", None)), + cast(int | None, getattr(arguments, "length", None)), + cast(str, arguments.prefix or ""), plain, context, fingerprint_core, diff --git a/tests/test_cli.py b/tests/test_cli.py index db027a0..ed9d1b1 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -1837,11 +1837,32 @@ def test_correction_infers_prefix_and_marks_invalid_data_as_erasures() -> None: damaged_prefix = _invoke(["correct"], "?" + original[1:]) bip39 = _invoke(["correct"], SHARING_VECTORS["bip39_12w"]["S"]) assert removed.exit_code == 2 - assert "Remove or correct these arguments: --prefix" in removed.stderr + assert "--prefix must be ms1, optionally followed by" in removed.stderr assert damaged_prefix.exit_code == 3 assert bip39.exit_code == 0 and "already valid" in bip39.stdout +def test_correct_takes_a_known_length_and_prefix() -> None: + original = VECTOR_1["secret_s"] + deleted = original[:20] + original[21:] + + for args in (["--length", "48"], ["--prefix", "ms10test"], ["--prefix", "MS1"]): + result = _invoke(["correct", *args], deleted) + assert result.exit_code == 1 and original in result.stderr + wrong_length = _invoke(["correct", "--length", "54"], deleted) + assert wrong_length.exit_code == 3 and original not in wrong_length.stderr + + for args, text, message in ( + (["--length", "50"], deleted, "--length must be a valid codex32 length"), + (["--length", "54"], original, "--length does not match the valid string's length."), + (["--prefix", "ms10fake"], deleted, "must begin with the --prefix text."), + (["--prefix", "ms10te"], deleted, "--prefix must be ms1, optionally followed by"), + (["--residue", "--prefix", "ms1"], deleted, "cannot be used with --residue."), + ): + result = _invoke(["correct", *args], text) + assert result.exit_code == 2 and message in result.stderr + + def test_correct_suggests_the_majority_case_for_mixed_case_damage() -> None: source = VECTOR_1["secret_s"] position = next(