diff --git a/docs/developer/api.md b/docs/developer/api.md index 10a8602..424a7b2 100644 --- a/docs/developer/api.md +++ b/docs/developer/api.md @@ -662,10 +662,14 @@ 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 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 `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 f83d615..5cc562f 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. 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. 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 4742365..fb10284 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.""" @@ -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) @@ -481,8 +481,17 @@ 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. + 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/src/codex32/_cli_input.py b/src/codex32/_cli_input.py index 53ba5b9..9de5082 100644 --- a/src/codex32/_cli_input.py +++ b/src/codex32/_cli_input.py @@ -4,8 +4,10 @@ import contextlib import difflib +import getpass import os import sys +import warnings from collections.abc import Callable, Iterator, Sequence from dataclasses import replace from functools import partial @@ -163,9 +165,19 @@ 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. + 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/src/codex32/_cli_parser.py b/src/codex32/_cli_parser.py index c247210..28a4b85 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 fa2742c..68f4429 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 f4f150f..6621cfb 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) + 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).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 == [ diff --git a/tests/test_cli.py b/tests/test_cli.py index c3b6ab5..ce88f23 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,61 @@ 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_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]() + 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: "] + + +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))