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
5 changes: 3 additions & 2 deletions docs/security/model.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
6 changes: 5 additions & 1 deletion docs/user/guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 2 additions & 1 deletion src/codex32/_cli_input.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
12 changes: 12 additions & 0 deletions src/codex32/_cli_parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
31 changes: 27 additions & 4 deletions src/codex32/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand All @@ -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)
Expand Down Expand Up @@ -569,16 +573,33 @@ 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:
pass
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:
Expand Down Expand Up @@ -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,
Expand Down
23 changes: 22 additions & 1 deletion tests/test_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
Loading