From 6b96d14add3116b77bb0c7ba36437e1dc7b138c4 Mon Sep 17 00:00:00 2001 From: Ben Westgate Date: Fri, 9 Oct 2026 21:20:05 +0000 Subject: [PATCH] cli: Restore ms32 checksum as a separate generation command Restore the Codex32 Book checksum worksheet command removed in 1a14000, following BlockstreamResearch/codex32 discussion #78: checksum generation is acceptable when it is clearly separate from validation and correction and is never used on completed shares. Compared with the removed command: - it accepts only the Book worksheet's 32 squares (a 128-bit ms secret or share); input of any complete ms string length is refused with a pointer to check, so a damaged complete string can no longer be checksummed as a longer unchecksummed payload; - the warning also forbids replacing the checksum of an existing backup, and help text names check and correct for existing backups; - it stays ms32-only, interactive-only, needs two matching entries with the screen cleared between them, and gives no format hints. The library keeps no public completion API; the command builds the string with the existing private _from_parts. Claude-Session: https://claude.ai/code/session_01C8Ur3dhqpcWU3gEYsweXNM --- docs/security/model.md | 17 ++++- docs/user/guide.md | 14 +++- src/codex32/_cli_parser.py | 8 +++ src/codex32/cli.py | 45 +++++++++++++ tests/test_cli.py | 134 ++++++++++++++++++++++++++++++++++++- tests/test_generic_hrp.py | 4 +- 6 files changed, 215 insertions(+), 7 deletions(-) diff --git a/docs/security/model.md b/docs/security/model.md index 6eabc340..989a9678 100644 --- a/docs/security/model.md +++ b/docs/security/model.md @@ -115,6 +115,19 @@ complete explicit `ms1` string. ## Creation, sharing, and recovery controls +`ms32 checksum` generates a checksum only for new 128-bit 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 cannot be mistaken for a longer +unchecksummed payload. It requires two independently entered worksheet inputs +before releasing the completed string. Between entries, the terminal and +scrollback are cleared where supported; the second entry has no editable +prefill. Comparison ignores case, whitespace, and the optional MS1 prefix. +A mismatch, EOF, or interruption releases no completed string. Format errors +give no detail. `--plain` and redirecting output do not skip confirmation; +noninteractive input is rejected before it is read. Agreement cannot establish +how the worksheet data was generated or detect an error already present on the +worksheet. Terminal clearing remains best effort. + 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 @@ -179,8 +192,8 @@ reallocate the ledger's mass to additional hypotheses. `codex32` checks and corrects unshared CL secrets, and checks, corrects, recovers, and derives shares for compatible BIP39 and opaque-HRP sets. `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` +applies an `ms` input filter and alone owns Bitcoin creation, checksum +generation, xprv, and wallet commands. Both façades offer worksheet-residue correction; only `ms32 correct` offers `--bytes`. ### Low-discrimination correction disclosure diff --git a/docs/user/guide.md b/docs/user/guide.md index b875ab04..aef0aec1 100644 --- a/docs/user/guide.md +++ b/docs/user/guide.md @@ -279,14 +279,24 @@ 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 128-bit 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 worksheet data twice and clears the screen between entries. + ### 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_parser.py b/src/codex32/_cli_parser.py index c2472109..8b3f45bb 100644 --- a/src/codex32/_cli_parser.py +++ b/src/codex32/_cli_parser.py @@ -183,6 +183,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 check or correct. " + "Requires an interactive terminal and two matching entries." + ) + _terminal_output(checksum) + wallet = _command(commands, "wallet", "restore a Bitcoin Core wallet") _wallet_options(wallet) diff --git a/src/codex32/cli.py b/src/codex32/cli.py index 1ffd1f13..82721cea 100644 --- a/src/codex32/cli.py +++ b/src/codex32/cli.py @@ -34,11 +34,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 bech32_decode from codex32.bip93 import ( IDX_SORT, Header, Secret, Share, + _from_parts, _normalize_target, derive_share, parse_codex32, @@ -57,6 +59,7 @@ generate_master_seed, ) from codex32.profiles import Profile, _profile_rules +from codex32.profiles.ms32 import TEXT_LENGTHS as _MS_TEXT_LENGTHS from codex32.profiles.ms32 import MasterSeed from codex32.profiles.ms32 import ( _text_length as _ms_text_length, @@ -553,6 +556,46 @@ def _create( return 0 +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 _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 check or correct.\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 check to check one.") + try: + hrp, body = bech32_decode(text) + # The Book's worksheet has 32 non-pink squares; only a 128-bit secret fits it. + if hrp != Profile.MS.value or len(text) + 13 != _ms_text_length(16): + raise ValueError + artifact = _from_parts( + Profile.MS, Header._from_symbols(tuple(body[:6])), tuple(body[6:]), uppercase=text.isupper() + ) + 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 _worksheet_text(clear + "Re-enter the worksheet non-pink bold squares").lower() != text.lower(): + raise _CommandError("Entries do not match. Restart and re-enter the worksheet.") + _emit(artifact, plain) + return 0 + + def _correct( residue: bool, erasures: tuple[int, ...], @@ -715,6 +758,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/tests/test_cli.py b/tests/test_cli.py index 07124c18..85314d25 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -147,7 +147,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()) @@ -3044,3 +3046,133 @@ def emit(_artifact, _plain, **kwargs): assert emitted_fingerprints and all(fingerprint is None for fingerprint in emitted_fingerprints) assert checked == [secret.seed_bytes] 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_matching_reentry( + 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, 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", + ] + + +@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 + "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 check to check one.\n" + ) + assert len(prompts) == 1 + + +@pytest.mark.parametrize("ending", ["match", "mismatch", "eof", "interrupt"]) +def test_checksum_requires_independent_reentry_before_output(monkeypatch, ending): + cli = importlib.import_module("codex32.cli") + original = VECTOR_1["secret_s"] + calls = [] + stdout, stderr = io.StringIO(), _TTYOutput() + + def read(prompt, *, prompt_end): + assert stdout.getvalue() == "" + calls.append((prompt, prompt_end)) + if len(calls) == 1: + return original[3:-13] + assert prompt.startswith("\x1b[3J\x1b[2J\x1b[H") + if ending == "eof": + raise EOFError + if ending == "interrupt": + raise KeyboardInterrupt + if ending == "mismatch": + return original[:-14] + "q" + return 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 + assert status == {"match": 0, "mismatch": 1, "eof": 2, "interrupt": 130}[ending] + assert stdout.getvalue() == (original + "\n" if ending == "match" else "") + assert original not in stderr.getvalue() + + +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() diff --git a/tests/test_generic_hrp.py b/tests/test_generic_hrp.py index 9111a7d1..172136f4 100644 --- a/tests/test_generic_hrp.py +++ b/tests/test_generic_hrp.py @@ -122,6 +122,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" @@ -129,9 +130,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"