Skip to content
Open
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
12 changes: 8 additions & 4 deletions docs/developer/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 5 additions & 0 deletions docs/user/guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
19 changes: 14 additions & 5 deletions src/codex32/_bitcoin_core.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""
Expand All @@ -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)
Expand All @@ -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]
Expand Down
14 changes: 13 additions & 1 deletion src/codex32/_cli_input.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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()
Expand Down
5 changes: 5 additions & 0 deletions src/codex32/_cli_parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
11 changes: 10 additions & 1 deletion src/codex32/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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}
Expand Down Expand Up @@ -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(
Expand Down
25 changes: 20 additions & 5 deletions tests/test_bitcoin_core.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 == [
Expand Down
61 changes: 59 additions & 2 deletions tests/test_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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))
Expand Down
Loading