From a55c50929c9e50c34085efdf6ee7f35958e09cc0 Mon Sep 17 00:00:00 2001 From: Ben Westgate Date: Thu, 22 Oct 2026 09:18:42 +0000 Subject: [PATCH 1/4] create: Mix optional typed keystrokes into Core randomness `ms32 create --keystrokes` asks for random keys without showing them or keeping them in line history, and hashes them in last on every draw: sha256(xprv + OS bytes + typed keys). They never reach the wallet, so a broken OS generator plus a leaked wallet file now rests on how unpredictable the typing was instead of being a total loss. Claude-Session: https://claude.ai/code/session_01T233rKgZqE5wzDm3EVTHL1 --- docs/developer/api.md | 10 ++++++---- docs/user/guide.md | 5 +++++ src/codex32/_bitcoin_core.py | 9 +++++---- src/codex32/_cli_input.py | 7 ++++++- src/codex32/_cli_parser.py | 5 +++++ src/codex32/cli.py | 11 ++++++++++- tests/test_bitcoin_core.py | 4 ++-- tests/test_cli.py | 35 +++++++++++++++++++++++++++++++++-- 8 files changed, 72 insertions(+), 14 deletions(-) diff --git a/docs/developer/api.md b/docs/developer/api.md index 10a8602c..82e23b63 100644 --- a/docs/developer/api.md +++ b/docs/developer/api.md @@ -662,10 +662,12 @@ guesses of a share. The OS bytes stop a leaked file's xpubs from capping each key at about 128-bit security, Bitcoin's own ECDLP bound, so a working OS generator alone keeps full strength and a broken one leaves Core's. If the OS generator is broken and the file leaks, the passphrase is weak too, and only -RAM keeps the file safe. Each draw removes its wallet even when it fails or is -interrupted by Ctrl-C, SIGHUP, or SIGTERM, so callers have nothing to close. -If the wallet can't be removed, creation stops before the next card. These -extra calls are `createwallet`, `walletpassphrase`, `gethdkeys`, and +RAM keeps the file safe, unless `--keystrokes` was used: the typed text is +hashed in last and never reaches the wallet, so that case then rests on how +unpredictable the typing was. Each draw removes its wallet even when it fails +or is interrupted by Ctrl-C, SIGHUP, or SIGTERM, so callers have nothing to +close. If the wallet can't be removed, creation stops before the next card. +These extra calls are `createwallet`, `walletpassphrase`, `gethdkeys`, and `unloadwallet`. The temporary wallet only works around Core having no call that returns diff --git a/docs/user/guide.md b/docs/user/guide.md index f83d6158..49b3dc50 100644 --- a/docs/user/guide.md +++ b/docs/user/guide.md @@ -123,6 +123,11 @@ Choose one command: More required shares make theft harder; fewer required shares make recovery easier. +Add `--keystrokes` to type some random keys before the cards are made. They +are not shown, are mixed into every card's randomness, and are never saved, so +they help only if this computer's random generator is broken and Core's +temporary wallet file leaks. + Already have a complete codex32 `ms` secret? Run `ms32 create --existing` to write and confirm its recovery card and initialize a Bitcoin Core wallet. The existing secret is preserved unchanged. To split it into three cards diff --git a/src/codex32/_bitcoin_core.py b/src/codex32/_bitcoin_core.py index 4742365c..c574a15b 100644 --- a/src/codex32/_bitcoin_core.py +++ b/src/codex32/_bitcoin_core.py @@ -442,9 +442,9 @@ def initialize( class CoreEntropy: """Draw randomness from fresh Bitcoin Core HD keys, in a new temporary encrypted wallet per draw.""" - def __init__(self, core: BitcoinCore, tell: Callable[[str], None]) -> None: + def __init__(self, core: BitcoinCore, tell: Callable[[str], None], typed: str = "") -> None: self._core, self._tell, self._folder, self._disk, self._loaded = core, tell, "", "", "" - self._passphrase, self._warned = secrets.token_hex(32), False + self._passphrase, self._warned, self._typed = secrets.token_hex(32), False, typed.encode() def __call__(self, length: int) -> bytes: """Return ``length`` bytes; the draw's wallet is gone before they are used, even if it fails.""" @@ -481,8 +481,9 @@ def _draw(self, length: int) -> bytes: if not isinstance(xpub, str) or len(xprvs) != 1 or not isinstance(xprvs[0], str): raise BitcoinCoreError("Bitcoin Core did not return a fresh HD key.") # Hashing keeps the plaintext xpub in the wallet file from checking guesses of a share. The - # OS bytes keep that xpub from capping the key near 128 bits while the OS generator works. - data += hashlib.sha256(xprvs[0].encode() + secrets.token_bytes(32)).digest() + # OS bytes keep that xpub from capping the key near 128 bits while the OS generator works, + # and typed keys, which never reach the wallet, cover a broken OS generator and a leaked file. + data += hashlib.sha256(xprvs[0].encode() + secrets.token_bytes(32) + self._typed).digest() finally: self._core._rpc("walletlock", wallet=wallet) return data[:length] diff --git a/src/codex32/_cli_input.py b/src/codex32/_cli_input.py index 53ba5b9c..e3d54379 100644 --- a/src/codex32/_cli_input.py +++ b/src/codex32/_cli_input.py @@ -4,6 +4,7 @@ import contextlib import difflib +import getpass import os import sys from collections.abc import Callable, Iterator, Sequence @@ -163,9 +164,13 @@ def read_text( preserve_groups: bool = False, prefill: str = "", prompt_end: str = ": ", + hidden: bool = False, ) -> str: if sys.stdin.isatty(): - value = _editable_input(prompt + prompt_end, prefill) + # Hidden text is neither shown nor kept in the line editor's history. + value = ( + getpass.getpass(prompt + prompt_end) if hidden else _editable_input(prompt + prompt_end, prefill) + ) _stderr("") else: value = _stdin().strip() diff --git a/src/codex32/_cli_parser.py b/src/codex32/_cli_parser.py index c2472109..28a4b85d 100644 --- a/src/codex32/_cli_parser.py +++ b/src/codex32/_cli_parser.py @@ -182,6 +182,11 @@ def parser(prog: str = "codex32", *, master_seed: bool = False) -> argparse.Argu action="store_true", help="use an existing Bitcoin codex32 secret or hexadecimal seed", ) + create.add_argument( + "--keystrokes", + action="store_true", + help="also mix random keys you type into the randomness of new cards", + ) 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 fa2742c0..68f4429d 100644 --- a/src/codex32/cli.py +++ b/src/codex32/cli.py @@ -473,6 +473,7 @@ def _create( shares: int | None, indices: str | None, existing: bool, + keystrokes: bool = False, ) -> int: profile, selected_threshold, identifier = _creation_header(header) threshold = 0 if selected_threshold is None else selected_threshold @@ -495,7 +496,14 @@ def _create( raise _UsageError("Use --existing when supplying a seed or secret.") if isinstance(source, (Share, Secret)) and not isinstance(source, MasterSeed): raise _UsageError(f"Enter one {_profile_rules(profile).label}, not a share or another backup type.") - entropy = CoreEntropy(core, lambda message: _print(message, err=True)) + if keystrokes and existing and threshold == 0: + raise _UsageError("--keystrokes applies only when new cards are drawn.") + typed = ( + _text("Type random keys (not shown), then press Enter", preserve_groups=True, hidden=True) + if keystrokes + else "" + ) + entropy = CoreEntropy(core, lambda message: _print(message, err=True), typed=typed) # Closing the terminal or a plain kill becomes Ctrl-C, so a draw under way still removes its wallet. hangups = [getattr(signal, name) for name in ("SIGHUP", "SIGTERM", "SIGBREAK") if hasattr(signal, name)] previous = {number: signal.signal(number, signal.default_int_handler) for number in hangups} @@ -725,6 +733,7 @@ def _dispatch(arguments: argparse.Namespace, context: _CliContext) -> int: cast(int | None, arguments.shares), cast(str | None, arguments.indices), bool(arguments.existing), + bool(arguments.keystrokes), ) if command == "correct": return _correct( diff --git a/tests/test_bitcoin_core.py b/tests/test_bitcoin_core.py index f4f150f8..da4c1955 100644 --- a/tests/test_bitcoin_core.py +++ b/tests/test_bitcoin_core.py @@ -908,7 +908,7 @@ def test_core_entropy_hashes_fresh_keys_from_a_wallet_removed_after_each_draw( ) -> None: rpc, told = _EntropyRPC(), list[str]() monkeypatch.setattr(BitcoinCore, "_rpc", rpc) - entropy = CoreEntropy(BitcoinCore("bitcoin-cli", "main", 320000), told.append) + entropy = CoreEntropy(BitcoinCore("bitcoin-cli", "main", 320000), told.append, typed="qwer tyui") drawn = list[bytes]() # every OS draw stays random; macOS volume names use them too monkeypatch.setattr( @@ -918,7 +918,7 @@ def test_core_entropy_hashes_fresh_keys_from_a_wallet_removed_after_each_draw( data = entropy(40), entropy(4) mixed = [value for value in drawn if len(value) == 32] digests = [ - hashlib.sha256(f"xprv{number}".encode() + os_bytes).digest() + hashlib.sha256(f"xprv{number}".encode() + os_bytes + b"qwer tyui").digest() for number, os_bytes in zip((1, 2, 3), mixed) ] assert len(mixed) == 3 and data == (digests[0] + digests[1][:8], digests[2][:4]) diff --git a/tests/test_cli.py b/tests/test_cli.py index c3b6ab51..5e49ed1a 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -131,8 +131,10 @@ def initialize( class _FakeEntropy: """Stand in for Bitcoin Core's randomness, which fails after ``draws`` draws if given a failure.""" - def __init__(self, _core: object = None, _tell: object = None, failure: str = "", draws: int = 0) -> None: - self.failure, self.draws, self.guarded = failure, draws, True + def __init__( + self, _core: object = None, _tell: object = None, failure: str = "", draws: int = 0, typed: str = "" + ) -> None: + self.failure, self.draws, self.guarded, self.typed = failure, draws, True, typed def __call__(self, length: int) -> bytes: self.guarded &= signal.getsignal(signal.SIGTERM) is signal.default_int_handler @@ -1418,6 +1420,35 @@ def test_fresh_cli_generation_rejects_every_other_16_through_64_byte_size() -> N assert "--bytes" in result.stderr and "invalid choice" in result.stderr +def test_create_mixes_typed_keystrokes_only_when_new_cards_are_drawn() -> None: + created = list[_FakeEntropy]() + + def entropy(*arguments: object, typed: str = "") -> _FakeEntropy: + created.append(_FakeEntropy(*arguments, typed=typed)) + return created[-1] + + with ( + patch("codex32.cli.CoreEntropy", side_effect=entropy), + patch("codex32._cli_input.getpass.getpass", return_value="asdf jkl;"), + ): + drawn = _invoke_confirmed_create(["create", "2", "--indices", "ac", "--keystrokes"]) + kept = _invoke_confirmed_create(["create", "--existing", "--keystrokes"], bytes(16).hex()) + + assert drawn.exit_code == 0 and [entropy.typed for entropy in created] == ["asdf jkl;"] + assert kept.exit_code == 2 and "--keystrokes applies only when new cards are drawn." in kept.stderr + + +def test_hidden_text_is_read_without_echo_or_line_history(monkeypatch) -> None: + input_module = importlib.import_module("codex32._cli_input") + prompts = list[str]() + monkeypatch.setattr(input_module.sys, "stdin", _TTYInput()) + monkeypatch.setattr(input_module, "_editable_input", lambda *_: pytest.fail("hidden text was echoed")) + monkeypatch.setattr(input_module.getpass, "getpass", lambda prompt: prompts.append(prompt) or "qwer tyui") + + assert input_module.read_text("Type random keys", preserve_groups=True, hidden=True) == "qwer tyui" + assert prompts == ["Type random keys: "] + + @pytest.mark.parametrize("byte_length", SEED_BYTE_LENGTHS) def test_cli_import_preserves_all_bip93_master_seed_sizes(byte_length: int) -> None: raw = bytes(range(byte_length)) From fc64ea20059cd37dfafa926e6464317f40fa4ef4 Mon Sep 17 00:00:00 2001 From: Ben Westgate Date: Thu, 22 Oct 2026 10:52:07 +0000 Subject: [PATCH 2/4] tests: Refuse piped keystrokes before asking for them `create` already refuses non-terminal stdin before any prompt, so keys can only be typed, never piped or read from a file. Pin that for `--keystrokes`. Claude-Session: https://claude.ai/code/session_01T233rKgZqE5wzDm3EVTHL1 --- tests/test_cli.py | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/tests/test_cli.py b/tests/test_cli.py index 5e49ed1a..81d11171 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -1438,6 +1438,17 @@ def entropy(*arguments: object, typed: str = "") -> _FakeEntropy: assert kept.exit_code == 2 and "--keystrokes applies only when new cards are drawn." in kept.stderr +def test_create_refuses_piped_keystrokes_before_asking_for_them() -> None: + # Piped or file-supplied keys could be saved elsewhere, so only a terminal may type them. + with ( + patch("codex32._cli_input.getpass.getpass", side_effect=AssertionError("keys were requested")), + patch("codex32.cli.CoreEntropy", side_effect=AssertionError("randomness was drawn")), + ): + result = _invoke_terminal(["create", "2", "--indices", "ac", "--keystrokes"], "asdf jkl;") + + assert result.exit_code == 2 and "requires an interactive terminal" in result.stderr + + def test_hidden_text_is_read_without_echo_or_line_history(monkeypatch) -> None: input_module = importlib.import_module("codex32._cli_input") prompts = list[str]() From befead5549d0875352d42b60c9f73f3e4a9eaf40 Mon Sep 17 00:00:00 2001 From: Ben Westgate Date: Thu, 22 Oct 2026 15:42:07 +0000 Subject: [PATCH 3/4] create: Stretch typed keystrokes with scrypt People type far fewer unpredictable bits than they think, so when both generators fail and Core's wallet file leaks, raw typed keys can be guessed quickly. Each draw now runs the typed text through scrypt at OWASP's minimum (N=2^17, r=8, p=1, 128 MiB, maxmem capped at 256 MiB) before hashing it in, salted with a domain tag and the draw's first xprv and OS bytes. It adds no entropy, but every guess now costs one scrypt. Draws without typed keys skip scrypt and are unchanged. A 3-of-5 creation makes 9 draws, so about 10 seconds in all. Claude-Session: https://claude.ai/code/session_01T233rKgZqE5wzDm3EVTHL1 --- docs/developer/api.md | 8 +++++--- docs/user/guide.md | 2 +- src/codex32/_bitcoin_core.py | 12 ++++++++++-- tests/test_bitcoin_core.py | 25 ++++++++++++++++++++----- 4 files changed, 36 insertions(+), 11 deletions(-) diff --git a/docs/developer/api.md b/docs/developer/api.md index 82e23b63..424a7b20 100644 --- a/docs/developer/api.md +++ b/docs/developer/api.md @@ -662,9 +662,11 @@ guesses of a share. The OS bytes stop a leaked file's xpubs from capping each key at about 128-bit security, Bitcoin's own ECDLP bound, so a working OS generator alone keeps full strength and a broken one leaves Core's. If the OS generator is broken and the file leaks, the passphrase is weak too, and only -RAM keeps the file safe, unless `--keystrokes` was used: the typed text is -hashed in last and never reaches the wallet, so that case then rests on how -unpredictable the typing was. Each draw removes its wallet even when it fails +RAM keeps the file safe, unless `--keystrokes` was used: the typed text never +reaches the wallet and is hashed in last, after scrypt (N=2^17, r=8, p=1, +128 MiB, about a second per draw) salted with the draw's first xprv and OS +bytes. That case then rests on how unpredictable the typing was, and each +guess at it costs one scrypt. Each draw removes its wallet even when it fails or is interrupted by Ctrl-C, SIGHUP, or SIGTERM, so callers have nothing to close. If the wallet can't be removed, creation stops before the next card. These extra calls are `createwallet`, `walletpassphrase`, `gethdkeys`, and diff --git a/docs/user/guide.md b/docs/user/guide.md index 49b3dc50..5cc562f1 100644 --- a/docs/user/guide.md +++ b/docs/user/guide.md @@ -126,7 +126,7 @@ easier. Add `--keystrokes` to type some random keys before the cards are made. They are not shown, are mixed into every card's randomness, and are never saved, so they help only if this computer's random generator is broken and Core's -temporary wallet file leaks. +temporary wallet file leaks. Each card then takes about a second longer. Already have a complete codex32 `ms` secret? Run `ms32 create --existing` to write and confirm its recovery card and initialize a Bitcoin Core wallet. diff --git a/src/codex32/_bitcoin_core.py b/src/codex32/_bitcoin_core.py index c574a15b..fb102843 100644 --- a/src/codex32/_bitcoin_core.py +++ b/src/codex32/_bitcoin_core.py @@ -468,7 +468,7 @@ def __call__(self, length: int) -> bytes: def _draw(self, length: int) -> bytes: wallet = self._wallet() self._core._rpc("walletpassphrase", wallet=wallet, stdin=f"{self._passphrase}\n60\n") - data = b"" + data = stretched = b"" try: while len(data) < length: added = self._core._rpc("addhdkey", wallet=wallet) @@ -483,7 +483,15 @@ def _draw(self, length: int) -> bytes: # Hashing keeps the plaintext xpub in the wallet file from checking guesses of a share. The # OS bytes keep that xpub from capping the key near 128 bits while the OS generator works, # and typed keys, which never reach the wallet, cover a broken OS generator and a leaked file. - data += hashlib.sha256(xprvs[0].encode() + secrets.token_bytes(32) + self._typed).digest() + key, os_bytes = xprvs[0].encode(), secrets.token_bytes(32) + if self._typed and not stretched: + # Typed keys hold fewer bits than they seem, so each guess costs OWASP's minimum scrypt + # (128 MiB, about a second), salted once per draw. + salt = b"codex32 keystrokes\0" + key + os_bytes + stretched = hashlib.scrypt( + self._typed, salt=salt, n=2**17, r=8, p=1, maxmem=2**28, dklen=32 + ) + data += hashlib.sha256(key + os_bytes + stretched).digest() finally: self._core._rpc("walletlock", wallet=wallet) return data[:length] diff --git a/tests/test_bitcoin_core.py b/tests/test_bitcoin_core.py index da4c1955..6621cfb9 100644 --- a/tests/test_bitcoin_core.py +++ b/tests/test_bitcoin_core.py @@ -903,25 +903,40 @@ def __call__(self, *arguments: str, wallet: str | None = None, stdin: str | None return keys if method == "gethdkeys" else None +@pytest.mark.parametrize("typed", ("", "qwer tyui")) def test_core_entropy_hashes_fresh_keys_from_a_wallet_removed_after_each_draw( - monkeypatch: pytest.MonkeyPatch, + monkeypatch: pytest.MonkeyPatch, typed: str ) -> None: - rpc, told = _EntropyRPC(), list[str]() + rpc, told, stretched = _EntropyRPC(), list[str](), list[bytes]() monkeypatch.setattr(BitcoinCore, "_rpc", rpc) - entropy = CoreEntropy(BitcoinCore("bitcoin-cli", "main", 320000), told.append, typed="qwer tyui") + entropy = CoreEntropy(BitcoinCore("bitcoin-cli", "main", 320000), told.append, typed=typed) drawn = list[bytes]() # every OS draw stays random; macOS volume names use them too monkeypatch.setattr( "codex32._bitcoin_core.secrets.token_bytes", lambda length: drawn.append(os.urandom(length)) or drawn[-1], ) + scrypt = hashlib.scrypt + monkeypatch.setattr( + hashlib, + "scrypt", + lambda *args, **options: stretched.append(scrypt(*args, **options)) or stretched[-1], + ) data = entropy(40), entropy(4) mixed = [value for value in drawn if len(value) == 32] + + def stretch(key: bytes, os_bytes: bytes) -> bytes: + salt = b"codex32 keystrokes\0" + key + os_bytes + return scrypt(typed.encode(), salt=salt, n=2**17, r=8, p=1, maxmem=2**28, dklen=32) if typed else b"" + + # Typed keys are stretched once per draw, salted by its first key; without them nothing is stretched. + first, last = stretch(b"xprv1", mixed[0]), stretch(b"xprv3", mixed[2]) digests = [ - hashlib.sha256(f"xprv{number}".encode() + os_bytes + b"qwer tyui").digest() - for number, os_bytes in zip((1, 2, 3), mixed) + hashlib.sha256(f"xprv{number}".encode() + os_bytes + extra).digest() + for number, os_bytes, extra in zip((1, 2, 3), mixed, (first, first, last)) ] assert len(mixed) == 3 and data == (digests[0] + digests[1][:8], digests[2][:4]) + assert stretched == ([first, last] if typed else []) draw = ["createwallet", "walletpassphrase", "addhdkey", "gethdkeys"] methods = [arguments[arguments[0] == "-named"] for arguments, _wallet, _stdin in rpc.calls] assert methods == [ From 69fef3d5f20fa62a53ca8ba4e9a35bffd8b896af Mon Sep 17 00:00:00 2001 From: Ben Westgate Date: Mon, 26 Oct 2026 11:03:19 -0500 Subject: [PATCH 4/4] cli: Refuse echoed keystrokes Turn a getpass echo-control warning into an input failure before the fallback can read or echo typed entropy. Cover the warning path with a regression test. --- src/codex32/_cli_input.py | 13 ++++++++++--- tests/test_cli.py | 15 +++++++++++++++ 2 files changed, 25 insertions(+), 3 deletions(-) diff --git a/src/codex32/_cli_input.py b/src/codex32/_cli_input.py index e3d54379..9de50829 100644 --- a/src/codex32/_cli_input.py +++ b/src/codex32/_cli_input.py @@ -7,6 +7,7 @@ import getpass import os import sys +import warnings from collections.abc import Callable, Iterator, Sequence from dataclasses import replace from functools import partial @@ -168,9 +169,15 @@ def read_text( ) -> str: if sys.stdin.isatty(): # Hidden text is neither shown nor kept in the line editor's history. - value = ( - getpass.getpass(prompt + prompt_end) if hidden else _editable_input(prompt + prompt_end, prefill) - ) + if hidden: + with warnings.catch_warnings(): + warnings.simplefilter("error", getpass.GetPassWarning) + try: + value = getpass.getpass(prompt + prompt_end) + except getpass.GetPassWarning: + raise InputError("Cannot hide typed keys on this terminal.") from None + else: + value = _editable_input(prompt + prompt_end, prefill) _stderr("") else: value = _stdin().strip() diff --git a/tests/test_cli.py b/tests/test_cli.py index 81d11171..ce88f23f 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -1460,6 +1460,21 @@ def test_hidden_text_is_read_without_echo_or_line_history(monkeypatch) -> None: assert prompts == ["Type random keys: "] +def test_hidden_text_refuses_getpass_echo_fallback(monkeypatch) -> None: + import warnings + + input_module = importlib.import_module("codex32._cli_input") + monkeypatch.setattr(input_module.sys, "stdin", _TTYInput()) + + def exposed_input(_prompt: str) -> str: + warnings.warn("terminal cannot disable echo", input_module.getpass.GetPassWarning) + pytest.fail("hidden keys were read with terminal echo enabled") + + monkeypatch.setattr(input_module.getpass, "getpass", exposed_input) + with pytest.raises(input_module.InputError, match="Cannot hide typed keys"): + input_module.read_text("Type random keys", hidden=True) + + @pytest.mark.parametrize("byte_length", SEED_BYTE_LENGTHS) def test_cli_import_preserves_all_bip93_master_seed_sizes(byte_length: int) -> None: raw = bytes(range(byte_length))