diff --git a/docs/developer/api.md b/docs/developer/api.md index 150af16f..25891285 100644 --- a/docs/developer/api.md +++ b/docs/developer/api.md @@ -322,15 +322,6 @@ 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, diff --git a/docs/security/invariants.md b/docs/security/invariants.md index 000fd740..8e54166e 100644 --- a/docs/security/invariants.md +++ b/docs/security/invariants.md @@ -3,9 +3,11 @@ These rules are mandatory. The [security model](model.md) defines their limits and evidence. -1. Constructing a `Codex32String` or `MasterSeed` validates its text. Generic container - and header checks precede the checksum; the `ms` length rule follows it. - Other HRPs remain generic and never gain invented application semantics. +1. Constructing a `Codex32String` or `MasterSeed` validates its text; + `MasterSeed.from_seed` and the private `_from_unchecksummed_string()` + worksheet helper build text only to construct one. Generic container and + header checks precede the checksum; the `ms` length rule follows it. Other + HRPs remain generic and never gain invented application semantics. 2. APIs accept only immutable validated artifacts. Outputs are reparsed; recovery and derivation reject sets with mismatched normalized HRPs or shapes. 3. Shared creation uses a separate OS-CSPRNG call for each random initial share, diff --git a/docs/security/model.md b/docs/security/model.md index 2af16fd0..6ab0a17b 100644 --- a/docs/security/model.md +++ b/docs/security/model.md @@ -114,6 +114,31 @@ complete explicit `ms1` string. ## Creation, sharing, and recovery controls +`ms32 checksum` generates a checksum only for new Codex32 Book worksheet +data. It is separate from `check` and `correct` and refuses input of any complete +`ms` string length, so a damaged complete string of a valid length is not taken +for a longer unchecksummed payload. This narrows the ambiguity without removing +it: a complete string that has lost or gained characters can still reach the +accepted length, and nothing can tell it from new data. The non-pink worksheet +data is entered twice, with the terminal cleared before the second entry, and +the entries must match before anything is displayed. Each entry must be ASCII +and entirely uppercase or lowercase. The full completed string is then displayed so the +operator can copy its checksum into the pink squares. After the operator presses Enter, +the terminal and scrollback are cleared where supported and only the pink +checksum squares must be re-entered, without editable prefill. These entries +catch typing errors; they cannot detect an error already written on the +worksheet. A mismatch, EOF, or interruption releases no completed string to +stdout, although the preview has already been displayed on stderr. Format errors +give no detail. `--plain` preserves the first entry's case; default terminal +output uses the normal card formatting. Plain or redirected output does not skip +checksum confirmation; noninteractive input is rejected before it is read. +Agreement cannot establish how the worksheet data was generated or detect an +error already present in its non-pink data. Terminal clearing remains best effort. +The private `_from_unchecksummed_string()` helper in `bip93.py` is the only +checksum construction from worksheet data. It applies the same parsing rules +for any HRP, plus the `ms` payload rules for `ms`, but has none of these +interactive safeguards, so every façade that uses it must provide them. + Fresh shared creation generates *k* random initial shares. Each uses a separate full-payload OS-CSPRNG request. The current share string must be re-entered exactly, ignoring case and whitespace, before the next request. Confirmation @@ -174,10 +199,10 @@ cancelled operations are removed only with a coverage proof. Pruning does not 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 and a known -`--prefix`; only `ms32 correct` offers `--bytes`, and only `codex32 correct` -offers `--length`. +applies an `ms` input filter and alone owns Bitcoin creation, checksum +generation, xprv, and wallet 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 874b311f..3496a2af 100644 --- a/docs/user/guide.md +++ b/docs/user/guide.md @@ -281,14 +281,31 @@ follow Bitcoin Core v32's maintained confirmed.” `--plain` skips card confirmation. Redirected damaged input can still require an interactive correction confirmation. +### Advanced: Codex32 Book checksum worksheet + +`ms32 checksum` computes the checksum for new data from the +[Codex32 Book](https://secretcodex32.com) checksum worksheet, instead of +computing it by hand. To verify a hand-computed checksum, enter the complete +string in `ms32 check`. `ms32 checksum` is not a validation or repair tool: it refuses +complete strings, and you must never use it to replace the checksum of an +existing backup. A checksum cannot make weak input secure, so use only data +from the Book's dice procedure. The command needs an interactive terminal, +asks for the non-pink worksheet data twice, clearing the screen between +entries, and then shows the full completed string. Copy the checksum into the +pink squares and press Enter; the screen clears, then you type back only the +pink checksum squares. The repeated entries catch typing errors, not errors +already on the worksheet, so check the worksheet carefully first. Each entry +must be entirely uppercase or lowercase. `--plain` preserves the first entry's +case; the default terminal output uses the usual uppercase card layout. +The completed string reaches stdout only after the checksum is confirmed. + ### Advanced: completing missing trailing characters The correction command can fill trailing erasures when the final characters are actually unknown. For a normal codex32 string, replace the final 13 missing characters with `?`; a long-checksum string uses 15. Thirteen genuinely unreadable characters at the end of an existing backup are therefore a valid -recovery case. The same mechanism can complete newly generated worksheet data -whose final 13 squares have not yet been filled. +recovery case. To checksum new Codex32 Book data instead, use `ms32 checksum`. Never intentionally delete or replace the existing final characters from a backup that merely fails validation. Doing so can hide a transcription or diff --git a/src/codex32/_cli_input.py b/src/codex32/_cli_input.py index 948b6b89..848b9ab2 100644 --- a/src/codex32/_cli_input.py +++ b/src/codex32/_cli_input.py @@ -341,13 +341,9 @@ 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 diff --git a/src/codex32/_cli_parser.py b/src/codex32/_cli_parser.py index 444e0c9b..261bfee2 100644 --- a/src/codex32/_cli_parser.py +++ b/src/codex32/_cli_parser.py @@ -195,6 +195,14 @@ def parser(prog: str = "codex32", *, master_seed: bool = False) -> argparse.Argu help="use an existing Bitcoin codex32 secret or hexadecimal seed", ) + checksum = _command(commands, "checksum", "generate the checksum for a new Codex32 Book worksheet") + checksum.description = ( + "Generate the checksum for new data on a Codex32 Book checksum worksheet. " + "Never use it on an existing backup: use 'ms32 check' or 'ms32 correct'. " + "Requires an interactive terminal and re-entry of the pink checksum squares." + ) + _terminal_output(checksum) + wallet = _command(commands, "wallet", "restore a Bitcoin Core wallet") _wallet_options(wallet) diff --git a/src/codex32/bip93.py b/src/codex32/bip93.py index bbeb35f2..282a08c2 100644 --- a/src/codex32/bip93.py +++ b/src/codex32/bip93.py @@ -206,6 +206,22 @@ def _from_parts(hrp: str, header: str, payload: Sequence[int], *, uppercase: boo return parse_codex32(text.upper() if uppercase else text) +def _from_unchecksummed_string(unchecksummed_text: str) -> Codex32String: + """Add the checksum to new worksheet data, such as a Codex32 Book checksum worksheet. + + Never use this on an existing backup: it turns transcription errors into a + valid string. A valid checksum does not establish safe entropy. + """ + hrp, body = bech32_decode(unchecksummed_text) + header = _u5_to_chars(body[:6]) + if len(header) != 6: + raise InvalidLength("codex32 header must contain six symbols") + if header[0] not in "023456789": + raise InvalidThreshold(f"The threshold must be 0 or a number from 2 through 9; found {header[0]!r}.") + header = _header(int(header[0]), header[1:5], header[5]) + return _from_parts(hrp, header, tuple(body[6:]), uppercase=unchecksummed_text.isupper()) + + def _lagrange_weights(points: tuple[int, ...], target: int) -> tuple[int, ...]: weights: list[int] = [] for position, point in enumerate(points): diff --git a/src/codex32/cli.py b/src/codex32/cli.py index 185385a5..870e70bb 100644 --- a/src/codex32/cli.py +++ b/src/codex32/cli.py @@ -31,11 +31,13 @@ from codex32._cli_input import read_artifacts as _artifacts from codex32._cli_input import read_text as _text from codex32._cli_parser import parser as _parser +from codex32.bech32 import _validate_single_case_ascii from codex32.bip93 import ( IDX_SORT, MS, Codex32String, MasterSeed, + _from_unchecksummed_string, _header, _normalize_target, _text_length, @@ -43,6 +45,9 @@ parse_codex32, recover_secret, ) +from codex32.bip93 import ( + TEXT_LENGTHS as _MS_TEXT_LENGTHS, +) from codex32.correction import ( CorrectionContext, _best, @@ -522,6 +527,63 @@ def _create( ) +def _worksheet_text(prompt: str) -> str: + value = _text(prompt, prompt_end=":\n\n> MS1") + return value if "1" in value else ("MS1" if value.isupper() else "ms1") + value + + +def _same_entry(entered: str, expected: str) -> bool: + try: + _validate_single_case_ascii(entered) + except CodexError: + return False + return entered.lower() == expected.lower() + + +def _checksum(plain: bool) -> int: + # Generation only: complete strings belong to check and correct (codex32 discussion #78). + if not sys.stdin.isatty(): + raise _UsageError("Checksum generation requires an interactive terminal.") + warning = ( + "DANGER: Incorrect input can cause permanent loss of funds.\n" + "Use this only for new data from the Codex32 Book and its Dice De-biasing Worksheet.\n" + "Never use it to replace the checksum of an existing backup. Use 'ms32 check' or 'ms32 correct'.\n" + "Check every non-pink square on the worksheet; errors written there cannot be detected here.\n" + "This command cannot verify how your data was generated.\n" + "Do not enter raw dice rolls, seed words, hexadecimal seeds, or passwords.\n" + ) + _print(warning, err=True, danger=True) + text = _worksheet_text("Checksum worksheet non-pink bold squares") + if len(text) in _MS_TEXT_LENGTHS: + raise _UsageError("This command does not accept complete strings. Use 'ms32 check' to check one.") + try: + # The Book's checksum worksheet has 32 non-pink squares after the ms prefix. + if len(text) != 35: + raise ValueError + artifact = _from_unchecksummed_string(text) + if artifact.hrp != MS: + raise ValueError + except (CodexError, ValueError) as error: + raise _UsageError( + "The worksheet input is not in the expected format.\n" + "Consult the Codex32 Book and check the non-pink bold squares." + ) from error + clear = "\x1b[3J\x1b[2J\x1b[H" if sys.stderr.isatty() else "" + if not _same_entry(_worksheet_text(clear + "Re-enter the worksheet non-pink bold squares"), text): + raise _CommandError("Entries do not match. Restart and re-enter the worksheet.") + _emit(artifact, plain, err=True) + _text( + "Copy the checksum into the worksheet's pink squares, then press Enter", + optional=True, + prompt_end=". ", + ) + entered = _text(clear + "Re-enter only the pink checksum squares", prompt_end=":\n> ") + if not _same_entry(entered, artifact.checksum): + raise _CommandError("Checksum not confirmed. Check the pink squares before using this backup.") + _emit(artifact, plain) + return 0 + + def _correct( residue: bool, erasures: tuple[int, ...], @@ -687,6 +749,8 @@ def _dispatch(arguments: argparse.Namespace, context: _CliContext) -> int: cast(str | None, arguments.indices), bool(arguments.existing), ) + if command == "checksum": + return _checksum(plain) if command == "correct": return _correct( bool(arguments.residue), diff --git a/src/codex32/correction.py b/src/codex32/correction.py index 86f8f5eb..b1d830c3 100644 --- a/src/codex32/correction.py +++ b/src/codex32/correction.py @@ -24,7 +24,6 @@ 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 @@ -45,7 +44,6 @@ _has_generation_padding, _header, _validate_ms_length, - _validate_recovery_prefix, parse_codex32, ) from codex32.checksums import _CODEX32, _CODEX32_LONG, _Checksum @@ -82,47 +80,6 @@ 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)) - 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: diff --git a/tests/data/README.md b/tests/data/README.md index 50e4de43..e089b193 100644 --- a/tests/data/README.md +++ b/tests/data/README.md @@ -7,7 +7,7 @@ than `ms`. - `p70_correction_vectors.json` is the frozen PR #70 correction corpus. - `malformed_inputs.json` freezes independent rejection cases for parsing, - interpolation, correction, and CLI tokenization. + checksum completion, interpolation, correction, and CLI tokenization. - `wallet_fingerprints.json` freezes BIP32 master fingerprints derived by real Bitcoin Core. `tools/_wallet_test_vectors.py` owns lookup/stub behavior, while `tools/bitcoin_core_regtest.py` independently verifies every frozen value. diff --git a/tests/data/malformed_inputs.json b/tests/data/malformed_inputs.json index 8c0f616f..a374a663 100644 --- a/tests/data/malformed_inputs.json +++ b/tests/data/malformed_inputs.json @@ -8,6 +8,13 @@ {"id": "non-ascii", "text": "ms10testsxxxxxxxxxxxxxxxxxxxxxxxxxx4nzvca9cmczlé"}, {"id": "embedded-newline", "text": "ms10testsxxxxxxxxxxxxxxxx\nxxxxxxxxxx4nzvca9cmczlw"} ], + "checksum_completion": [ + {"id": "invalid-symbol", "text": "ms10testsxxxxxxxxxxxxxxxxxxxxxxxxx!"}, + {"id": "mixed-case", "text": "Ms10testsxxxxxxxxxxxxxxxxxxxxxxxxxx"}, + {"id": "short-ms", "text": "ms10testsqq"}, + {"id": "invalid-threshold", "text": "zz1xtestsqqqqqqqqqqqqqq"}, + {"id": "short-header", "text": "zz10tes"} + ], "interpolation": [ { "id": "duplicate-index", diff --git a/tests/test_bip93.py b/tests/test_bip93.py index 56d049e9..b98368d3 100644 --- a/tests/test_bip93.py +++ b/tests/test_bip93.py @@ -28,6 +28,7 @@ recover_secret, ) from codex32.bech32 import _chars_to_u5, convertbits +from codex32.bip93 import _from_unchecksummed_string from codex32.errors import CodexError, InvalidChecksum @@ -146,3 +147,18 @@ def test_generic_parser_accepts_checksum_valid_non_ms_hrps(value: str) -> None: artifact = parse_codex32(value) assert artifact.hrp in {"m", "s"} assert not isinstance(artifact, MasterSeed) + + +@pytest.mark.parametrize( + ("text", "checksum_length"), + [ + (VECTOR_1["secret_s"], 13), + (VECTOR_2["share_A"], 13), + (VECTOR_3["share_a"], 13), + (VECTOR_4["secret_s"], 13), + (VECTOR_5["secret_s"], 15), + (VECTOR_6["codex32_luea"], 13), + ], +) +def test_from_unchecksummed_string_restores_vector_checksums(text: str, checksum_length: int) -> None: + assert _from_unchecksummed_string(text[:-checksum_length]).s == text diff --git a/tests/test_cli.py b/tests/test_cli.py index ed9d1b19..dbcfe9a6 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -144,7 +144,9 @@ def _invoke(args: list[str], *lines: str) -> _Result: contextlib.redirect_stdout(stdout), contextlib.redirect_stderr(stderr), ): - entrypoint = ms_main if args[0] in {"create", "xprv", "wallet"} or "--bytes" in args else main + entrypoint = ( + ms_main if args[0] in {"create", "checksum", "xprv", "wallet"} or "--bytes" in args else main + ) status = entrypoint(args) return _Result(status, stdout.getvalue(), stderr.getvalue()) @@ -2954,3 +2956,177 @@ def emit(_artifact, _plain, **kwargs): assert emitted_fingerprints and all(fingerprint is None for fingerprint in emitted_fingerprints) assert checked == [secret.data] assert core.expected == core.fingerprint(secret) + + +def _invoke_checksum(*entries: str, args: tuple[str, ...] = ()) -> tuple[_Result, list[str]]: + prompts: list[str] = [] + answers = iter(entries) + + def answer(prompt: str) -> str: + prompts.append(prompt) + return next(answers) + + stdout, stderr = io.StringIO(), io.StringIO() + with ( + patch.object(sys, "stdin", _TTYInput()), + patch("builtins.input", answer), + patch("codex32._cli_input._line_editor", None), + contextlib.redirect_stdout(stdout), + contextlib.redirect_stderr(stderr), + ): + status = ms_main(["checksum", *args]) + return _Result(status, stdout.getvalue(), stderr.getvalue()), prompts + + +@pytest.mark.parametrize("plain", [False, True]) +def test_checksum_rejects_noninteractive_input_before_reading(plain: bool) -> None: + with patch("codex32.cli._text") as read: + result = _invoke(["checksum", *(["--plain"] if plain else [])], VECTOR_1["secret_s"][:-13]) + assert result.exit_code == 2 + assert result.stdout == "" + assert result.stderr == "ms32 checksum: Checksum generation requires an interactive terminal.\n" + read.assert_not_called() + + +@pytest.mark.parametrize("uppercase", [False, True]) +@pytest.mark.parametrize("include_prefix", [False, True]) +def test_checksum_completes_the_book_worksheet_after_checksum_confirmation( + uppercase: bool, include_prefix: bool +) -> None: + expected = VECTOR_1["secret_s"].upper() if uppercase else VECTOR_1["secret_s"] + entered = expected[:-13] if include_prefix else expected[3:-13] + result, prompts = _invoke_checksum(entered, entered, "", expected[-13:], args=("--plain",)) + assert result.exit_code == 0 + assert result.stdout == expected + "\n" + assert result.stderr.startswith("DANGER: Incorrect input can cause permanent loss of funds.") + assert "Never use it to replace the checksum of an existing backup." in result.stderr + assert prompts == [ + "Checksum worksheet non-pink bold squares:\n\n> MS1", + "Re-enter the worksheet non-pink bold squares:\n\n> MS1", + "Copy the checksum into the worksheet's pink squares, then press Enter. ", + "Re-enter only the pink checksum squares:\n> ", + ] + + +@pytest.mark.parametrize( + "entered", + [ + VECTOR_1["secret_s"][3:-14], # one square short + VECTOR_1["secret_s"][3:-13] + "x", # one square extra + "0tests" + "x" * 32, # a 20-byte secret does not fit the Book worksheet + "Ms10tests" + "x" * 26, # mixed case + "ms10testsxxxxbxxxxxxxxxxxxxxxxx", # b is not a bech32 character + "cl10tests" + "x" * 26, # only ms + "ms10tests10tests" + "x" * 19, # a later 1 makes the HRP ms10tests + "xtests" + "x" * 26, # invalid threshold + ], +) +def test_checksum_rejects_other_input_without_hints(entered: str) -> None: + result, prompts = _invoke_checksum(entered) + assert result.exit_code == 2 + assert result.stdout == "" + assert result.stderr.endswith( + "ms32 checksum: The worksheet input is not in the expected format.\n" + "Consult the Codex32 Book and check the non-pink bold squares.\n" + ) + assert len(prompts) == 1 + + +@pytest.mark.parametrize( + "entered", + [ + VECTOR_1["secret_s"], + VECTOR_1["secret_s"][3:], + # A damaged 16-byte string is as long as an unchecksummed 24-byte one. + VECTOR_1["secret_s"][:-1] + "q", + VECTOR_2["share_A"], + ], +) +def test_checksum_refuses_complete_strings(entered: str) -> None: + result, prompts = _invoke_checksum(entered) + assert result.exit_code == 2 + assert result.stdout == "" + assert result.stderr.endswith( + "ms32 checksum: This command does not accept complete strings. Use 'ms32 check' to check one.\n" + ) + assert len(prompts) == 1 + + +@pytest.mark.parametrize("ending", ["match", "retyped", "mismatch", "eof", "interrupt"]) +def test_checksum_confirms_both_entries_before_stdout(monkeypatch, ending): + cli = importlib.import_module("codex32.cli") + original = VECTOR_1["secret_s"] + calls = [] + stdout, stderr = io.StringIO(), _TTYOutput() + + def read(prompt, *, prompt_end, optional=False): + assert stdout.getvalue() == "" + calls.append((prompt, prompt_end)) + if len(calls) == 1: + return original[3:-13] + if len(calls) == 2: + assert prompt == "\x1b[3J\x1b[2J\x1b[HRe-enter the worksheet non-pink bold squares" + assert original not in stderr.getvalue() + return original[3:-14] + ("q" if ending == "retyped" else original[-14]) + if len(calls) == 3: + assert optional + assert original in stderr.getvalue() + return "" + assert prompt == "\x1b[3J\x1b[2J\x1b[HRe-enter only the pink checksum squares" + if ending == "eof": + raise EOFError + if ending == "interrupt": + raise KeyboardInterrupt + return "q" * 13 if ending == "mismatch" else original[-13:].upper() + + monkeypatch.setattr(cli.sys, "stdin", _TTYInput()) + monkeypatch.setattr(cli, "_text", read) + with contextlib.redirect_stdout(stdout), contextlib.redirect_stderr(stderr): + status = ms_main(["checksum", "--plain"]) + assert len(calls) == (2 if ending == "retyped" else 4) + assert status == {"match": 0, "retyped": 1, "mismatch": 1, "eof": 2, "interrupt": 130}[ending] + assert stdout.getvalue() == (original + "\n" if ending == "match" else "") + + +def test_checksum_rejects_positional_input_before_reading() -> None: + with patch("codex32.cli._text") as read: + result = _invoke(["checksum", "2testa"]) + assert result.exit_code == 2 + assert "Remove or correct these arguments: 2testa" in result.stderr + read.assert_not_called() + + +@pytest.mark.parametrize("entered", ["xrpp870hKkqrm", "xrpp870hKkqrm", "XRPP870HKKQRM", "xrpp870hkkqrmx", ""]) +def test_checksum_confirmation_rejects_invalid_pink_squares(entered: str) -> None: + original = VECTOR_2["share_A"] + result, prompts = _invoke_checksum(original[:-13], original[:-13], "", entered, args=("--plain",)) + assert result.exit_code != 0 + assert result.stdout == "" + assert len(prompts) == 4 + + +@pytest.mark.parametrize("uppercase", [False, True]) +@pytest.mark.parametrize("plain", [False, True]) +def test_checksum_terminal_output_preserves_plain_case_and_pretty_default( + uppercase: bool, plain: bool +) -> None: + original = VECTOR_2["share_A"] if uppercase else VECTOR_2["share_A"].lower() + stdout, stderr = _TTYOutput(), io.StringIO() + with ( + patch.object(sys, "stdin", _TTYInput()), + patch( + "builtins.input", + side_effect=[original[:-13], original[:-13].swapcase(), "", original[-13:].swapcase()], + ), + patch("codex32._cli_input._line_editor", None), + contextlib.redirect_stdout(stdout), + contextlib.redirect_stderr(stderr), + ): + status = ms_main(["checksum", *(["--plain"] if plain else [])]) + assert status == 0 + if plain: + assert stdout.getvalue() == original + "\n" + else: + assert " ".join(original.upper()[i : i + 4] for i in range(0, len(original), 4)) in " ".join( + re.sub(r"\x1b\[[0-9;]*m", "", stdout.getvalue()).split() + ) diff --git a/tests/test_generic_hrp.py b/tests/test_generic_hrp.py index f5452a3e..092a622a 100644 --- a/tests/test_generic_hrp.py +++ b/tests/test_generic_hrp.py @@ -121,6 +121,7 @@ def test_cli_split_and_unknown_neutral_summary() -> None: " share derive a share from codex32 strings\n" " correct suggest repairs for a damaged codex32 string\n" " create create or confirm a backup, or split an existing secret\n" + " checksum generate the checksum for a new Codex32 Book worksheet\n" " wallet restore a Bitcoin Core wallet\n" " xprv export the root extended private key\n\n" "Never include a secret or share in command arguments.\n" @@ -128,9 +129,8 @@ def test_cli_split_and_unknown_neutral_summary() -> None: ) for command in ("check", "correct", "secret", "share"): assert command in generic_help and command in ms_help - for command in ("create", "xprv", "wallet"): + for command in ("create", "checksum", "xprv", "wallet"): assert command not in generic_help and command in ms_help - assert "checksum" not in generic_help and "checksum" not in ms_help correct_help = _invoke(main, ["correct", "--help"])[1] assert ( "Suggest repairs for wrong, unreadable, missing, extra, or swapped characters\n" diff --git a/tests/test_malformed_corpus.py b/tests/test_malformed_corpus.py index 828c3aa1..52a9e511 100644 --- a/tests/test_malformed_corpus.py +++ b/tests/test_malformed_corpus.py @@ -7,6 +7,7 @@ from codex32 import CorrectionContext, correct, parse_codex32, recover_secret from codex32._cli_parser import parser +from codex32.bip93 import _from_unchecksummed_string from codex32.errors import CodexError _DOCUMENT = json.loads((Path(__file__).parent / "data" / "malformed_inputs.json").read_text()) @@ -19,6 +20,12 @@ def test_malformed_parse_corpus(case: dict[str, str]) -> None: parse_codex32(case["text"]) +@pytest.mark.parametrize("case", _DOCUMENT["checksum_completion"], ids=lambda case: case["id"]) +def test_malformed_completion_corpus(case: dict[str, str]) -> None: + with pytest.raises(CodexError): + _from_unchecksummed_string(case["text"]) + + @pytest.mark.parametrize("case", _DOCUMENT["interpolation"], ids=lambda case: case["id"]) def test_malformed_interpolation_corpus(case: dict[str, object]) -> None: artifacts = [parse_codex32(text) for text in case["artifacts"]] # type: ignore[union-attr] diff --git a/tests/test_public_api.py b/tests/test_public_api.py index 1bf6813e..57e72316 100644 --- a/tests/test_public_api.py +++ b/tests/test_public_api.py @@ -40,7 +40,6 @@ 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") @@ -55,42 +54,6 @@ 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())