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
17 changes: 15 additions & 2 deletions docs/security/model.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
14 changes: 12 additions & 2 deletions docs/user/guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
8 changes: 8 additions & 0 deletions src/codex32/_cli_parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
45 changes: 45 additions & 0 deletions src/codex32/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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,
Expand Down Expand Up @@ -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, ...],
Expand Down Expand Up @@ -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),
Expand Down
134 changes: 133 additions & 1 deletion tests/test_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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())

Expand Down Expand Up @@ -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()
4 changes: 2 additions & 2 deletions tests/test_generic_hrp.py
Original file line number Diff line number Diff line change
Expand Up @@ -122,16 +122,16 @@ 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"
"Enter it when prompted. Some commands also accept piped input.\n"
)
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"
Expand Down
Loading