Skip to content
Merged
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
10 changes: 6 additions & 4 deletions docs/security/model.md
Original file line number Diff line number Diff line change
Expand Up @@ -199,10 +199,12 @@ before printing any candidate text, metadata, fingerprint, or residue addends.
It then prints a conspicuous warning covering both deliberate completion of
newly transcribed data and recovery with many missing characters. Literal
uppercase `YES` is required before disclosure; other case variants, blank input,
or EOF terminate the command with status 1. Redirected damaged data may still
reach this gate, but disclosure requires an interactive terminal channel. If no
such channel is available, the sole message is `codex32: interactive confirmation
required` (or `ms32:`). Output formatting and `--plain` cannot bypass the gate.
or EOF terminate standalone `correct` with status 3 and correction embedded in
another workflow with status 1. Redirected damaged data may still reach this
gate, but disclosure requires an interactive terminal channel. If no such
channel is available, the sole message is `codex32: interactive confirmation
required` (or `ms32:`), with the same command-specific status. Output formatting
and `--plain` cannot bypass the gate.
Existing whole-card `[y/N]` acceptance remains required after disclosure when a
workflow will consume the corrected artifact. `correct` only displays the
suggestion, so it has no second acceptance prompt. The gate does not verify the
Expand Down
8 changes: 8 additions & 0 deletions docs/user/guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,14 @@ disclosure, workflows that consume the repaired artifact ask the usual `[y/N]`
whole-card confirmation. `correct` only reports a suggestion, so it does not ask
that second question. A checksum cannot make weak input secure.

The `correct` exit status distinguishes outcomes for scripts: `0` means the
input is already valid, `1` means a suggestion was emitted, `2` means the
command or input syntax was invalid, and `3` means no usable suggestion was
emitted. Status `3` includes incomplete searches with no usable suggestion,
ambiguous searches, declined disclosure, and Bitcoin Core being unavailable
when `ms32 correct` needs it to rank or fingerprint a master-seed suggestion.
The generic `codex32 correct` command does not need Core.

Choose the setup that fits you:

- **Recommended: dedicated online spending wallet — easiest.** A normally
Expand Down
18 changes: 9 additions & 9 deletions src/codex32/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -539,14 +539,16 @@ def _correct(
f"Add {correction.addend} at position "
f"{correction.reverse_index + 1}, counting backward from the end."
)
return 0
return 1 if result else 0
if erasures:
raise _UsageError("--erasure can be used only with --residue.")
normalized = "".join(value.split())
separator = normalized.lower().rfind("1")
if separator <= 0:
raise _UsageError("Enter a complete application prefix followed by the separator 1.")
hrp = normalized[:separator].lower()
if len(raw_hrp := normalized[:separator]) > 83 or not all("!" <= c <= "~" for c in raw_hrp):
raise _UsageError("The application prefix must be at most 83 printable ASCII characters.")
hrp = raw_hrp.lower()
if context.master_seed and hrp != Profile.MS.value:
raise _UsageError("This command accepts only Bitcoin master-seed input beginning with ms1.")
try:
Expand Down Expand Up @@ -746,22 +748,20 @@ def _main(context: _CliContext, argv: Sequence[str] | None = None) -> int:
except SystemExit as error:
return error.code if isinstance(error.code, int) else 1
scope = f"{context.prog} {arguments.command}"
correction_failed = 3 if arguments.command == "correct" else 1
try:
return _dispatch(arguments, context)
except CorrectionDeclined:
return 1
return correction_failed
Comment thread
BenWestgate marked this conversation as resolved.
except InteractiveConfirmationRequired:
_print(f"{context.prog}: interactive confirmation required", err=True)
return 1
return correction_failed
except _UsageError as error:
_print(f"{scope}: {error}", err=True)
return 2
except (_CommandError, CodexError) as error:
_print(f"{scope}: {error}", err=True)
return 1
except BitcoinCoreError as error:
except (_CommandError, CodexError, BitcoinCoreError) as error:
_print(f"{scope}: {error}", err=True)
return 1
return correction_failed
except EOFError:
_print(f"{scope}: Input ended before recovery completed.", err=True)
return 2
Expand Down
25 changes: 17 additions & 8 deletions tests/test_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -1713,7 +1713,7 @@ def test_cli_rejects_statistically_inadmissible_structural_burst() -> None:

result = _invoke(["correct"], damaged)

assert result.exit_code == 1
assert result.exit_code == 3
assert "No valid correction found" in result.stderr


Expand All @@ -1724,10 +1724,19 @@ def test_cli_rejects_sixteen_consecutive_erasures_as_outside_regular_bound() ->

assert len("".join(damaged.split())) == 48
assert damaged.count("?") == 16
assert result.exit_code == 1
assert result.exit_code == 3
assert "No valid correction found" in result.stderr


def test_correct_rejects_malformed_immutable_hrp_as_usage() -> None:
damaged = "é" + VECTOR_1["secret_s"][1:]

result = _invoke(["correct"], damaged)

assert result.exit_code == 2
assert "application prefix" in result.stderr


@pytest.mark.parametrize(
("byte_length", "options"),
((16, []), (64, []), (20, ["--bytes", "20"]), (24, ["--bytes", "?"])),
Expand Down Expand Up @@ -1766,7 +1775,7 @@ def test_cli_never_accepts_an_incomplete_structural_search() -> None:
with patch("codex32.cli._correction_candidates", return_value=((), False, 0.0, False)):
result = _invoke(["correct"], damaged)

assert result.exit_code != 0
assert result.exit_code == 3
assert result.stdout == ""
assert "did not complete" in result.stderr and original not in result.stderr

Expand All @@ -1788,7 +1797,7 @@ def test_correction_options_control_lengths_deadline_and_search_envelope(
with patch("codex32.indel._search_many", return_value=((), True)) as search:
result = _invoke(["correct", *options], damaged)

assert result.exit_code == 1 and result.stdout == ""
assert result.exit_code == 3 and result.stdout == ""
assert search.call_count == 1
contexts, observed = search.call_args.args
assert observed == damaged
Expand All @@ -1815,7 +1824,7 @@ def test_fixed_correction_repairs_legacy_cl_header_and_residue_reverse_positions
residue = _invoke(["correct", "--residue"], "2ppjkw73qdjvc")

assert fixed.exit_code == 1 and original in fixed.stderr
assert residue.exit_code == 0
assert residue.exit_code == 1
assert "Add x at position 38, counting backward from the end." in residue.stdout


Expand Down Expand Up @@ -1848,7 +1857,7 @@ def test_correction_infers_prefix_and_marks_invalid_data_as_erasures() -> None:
bip39 = _invoke(["correct"], BIP39_12W_ZERO)
assert removed.exit_code == 2
assert "Remove or correct these arguments: --prefix" in removed.stderr
assert damaged_prefix.exit_code == 1
assert damaged_prefix.exit_code == 3
assert bip39.exit_code == 0 and "already valid" in bip39.stdout


Expand Down Expand Up @@ -1975,14 +1984,14 @@ def incomplete_first(value, *_args, **kwargs):
assert len(full_searches) == 2
assert full_searches[0].count("?") == len(positions)
assert "?" not in full_searches[1]
assert result.exit_code == 1
assert result.exit_code == 3
assert "interactive confirmation required" in result.stderr


def test_correction_hides_internal_candidate_reparse_failures() -> None:
result = _invoke(["correct"], "ms12auxxxxxxxxxxxxxxxxxxxxxxxxxxxxxda3kr3s0s2swg")

assert result.exit_code != 0
assert result.exit_code == 3
assert result.stdout == ""
assert result.stderr.strip() in {
"codex32 correct: No valid correction found. Check the original backup.",
Expand Down
9 changes: 5 additions & 4 deletions tests/test_correction_disclosure.py
Original file line number Diff line number Diff line change
Expand Up @@ -104,7 +104,7 @@ def test_noninteractive_gate_emits_only_operational_error(entrypoint, plain):
):
status = entrypoint(["correct", *(["--plain"] if plain else [])])
prog = "codex32" if entrypoint is cli.main else "ms32"
assert status == 1 and stdout.getvalue() == ""
assert status == 3 and stdout.getvalue() == ""
assert stderr.getvalue() == f"{prog}: interactive confirmation required\n"


Expand Down Expand Up @@ -140,7 +140,8 @@ def respond(prompt, prefill=""):
contextlib.redirect_stderr(stderr),
):
status = (cli.ms_main if command == "create" else cli.main)(args)
assert status == 1 and stdout.getvalue() == ""
expected_status = 3 if command == "correct" else 1
assert status == expected_status and stdout.getvalue() == ""
assert len(prompts) == 2 and core.imported is None
warning = stderr.getvalue()
assert "\x1b[1;31mWarning:\x1b[0m If you are generating new data" in warning
Expand Down Expand Up @@ -189,7 +190,7 @@ def test_redirected_stderr_blocks_low_discrimination_disclosure(monkeypatch):
contextlib.redirect_stdout(stdout),
contextlib.redirect_stderr(stderr),
):
assert cli.main(["correct", "--plain"]) == 1
assert cli.main(["correct", "--plain"]) == 3
assert stdout.getvalue() == ""
assert stderr.getvalue().strip() == "codex32: interactive confirmation required"

Expand Down Expand Up @@ -292,7 +293,7 @@ def test_residue_completion_is_gated_but_ordinary_repair_is_not(degree):
contextlib.redirect_stdout(stdout),
contextlib.redirect_stderr(stderr),
):
assert cli.main(args) == 1
assert cli.main(args) == 3
assert stdout.getvalue() == ""
assert stderr.getvalue() == "codex32: interactive confirmation required\n"

Expand Down
Loading