From 63c50a6e1edf1d1a554d59bda2d4284ed5dce4b5 Mon Sep 17 00:00:00 2001 From: Ben Westgate Date: Sat, 24 Oct 2026 06:14:32 -0500 Subject: [PATCH 1/2] generation: Require explicit entropy source Route every random draw through a caller-provided entropy function and validate its byte count. Keep current CLI behavior by passing the OS CSPRNG explicitly, so the Core-backed source can be reviewed separately. Update generation tests, the installed-wheel smoke test, and API documentation for the new boundary. --- docs/developer/api.md | 31 ++++++------ src/codex32/cli.py | 5 ++ src/codex32/generation.py | 66 +++++++++++++++++++------ tests/test_generation.py | 87 ++++++++++++++++++++++----------- tests/test_public_api.py | 4 +- tools/verify_installed_wheel.py | 3 +- 6 files changed, 135 insertions(+), 61 deletions(-) diff --git a/docs/developer/api.md b/docs/developer/api.md index 51c9fb5c..bf952849 100644 --- a/docs/developer/api.md +++ b/docs/developer/api.md @@ -152,8 +152,9 @@ accepts only `ms` artifacts. ## Secret generation -`generation.py` is the only module that draws entropy. It generates and -splits BIP93 master seeds only. +`generation.py` is the only module that draws entropy. It draws every random +byte from its caller's `entropy(length)` source, which must return exactly the +requested number of bytes, and generates and splits BIP93 master seeds only. Fresh unshared seeds default to 16 bytes and use the first 20 bits of their BIP32 fingerprint as public identifier metadata. Fresh shared sets use four @@ -169,17 +170,18 @@ boundary; there is no legacy decoder. The one-shot function creates only unshared secrets: ```python -generate_master_seed(seed_bytes=None, *, byte_length=None, identifier=None, fingerprint=None) +generate_master_seed(seed_bytes=None, *, byte_length=None, identifier=None, fingerprint=None, entropy=None) ``` A fresh seed with no `identifier` needs `fingerprint`: a callable that takes the seed bytes and returns their four-byte BIP32 master fingerprint. This package has no secp256k1 arithmetic, so the CLI asks Bitcoin Core. Without -either argument, fresh generation raises `ValueError`. +either argument, fresh generation raises `ValueError`, as does any draw without +an entropy source. Shared creation uses `CreationCeremony.master_seed(...)` or -`CreationCeremony.from_secret(...)`. Exactly one of `share_count` and `indices` -is required. `next_share()` returns one pending share, `confirm(text)` must +`CreationCeremony.from_secret(...)`; both require `entropy`. Exactly one of +`share_count` and `indices` is required. `next_share()` returns one pending share, `confirm(text)` must accept its independently re-entered string, and `finish()` returns the secret only after every requested share is confirmed. There is no public one-shot sharing or `split_secret` function. Fresh and existing Bitcoin CLI creation @@ -206,12 +208,11 @@ The ceremony follows the two BIP93 constructions: - a fresh set draws `k` independent complete u5 masks; - an existing S uses S plus `k-1` independent complete u5 masks. -Each direct share uses a separate `secrets.token_bytes` request and cannot be -followed by another draw until its string is confirmed. The pause gives the OS an -opportunity to mix fresh noise; Python cannot guarantee that new physical -entropy arrives between calls. User input is never treated as entropy. Each -byte is mapped with `value & 31`, which maps exactly eight byte values to each -u5 value. +Each direct share uses a separate `entropy` request and cannot be followed by +another draw until its string is confirmed. The pause gives the source an +opportunity to mix fresh noise; nothing guarantees new physical entropy between +calls. User input is never used as share bytes. Symbols are the drawn bytes +regrouped five bits at a time, so `n` symbols take `ceil(5n/8)` bytes. Creation confirmation preserves whitespace-separated entered groups as units, even when moving characters between groups would reduce edits or red groups. @@ -258,9 +259,9 @@ remain fixed. That padding rule is not BIP93 validity: parsed S strings may use any application-valid discarded bits, which re-sharing preserves exactly. CRC never applies to shares. -Explicit output indices preserve caller order. A share count uses -`SystemRandom.sample` over the 31 ordinary indices and preserves sample order. -There is no entropy injection, sorting, caller-supplied partial-basis +Explicit output indices preserve caller order. A share count picks from the 31 +ordinary indices by rejection sampling `entropy` bytes, in draw order. There is +no sorting, caller-supplied partial-basis completion, or BIP39 generation. Ceremonies reject copying and serialization; the CLI does not resume an interrupted ceremony. diff --git a/src/codex32/cli.py b/src/codex32/cli.py index 9b830137..edeffaf6 100644 --- a/src/codex32/cli.py +++ b/src/codex32/cli.py @@ -6,6 +6,7 @@ import json import sys from collections.abc import Callable, Sequence +from secrets import token_bytes from typing import Literal, NamedTuple, cast from codex32._bitcoin_core import ( @@ -344,6 +345,7 @@ def _generated_secret( byte_length=byte_length, identifier=identifier, fingerprint=fingerprint, + entropy=token_bytes, ) @@ -518,6 +520,7 @@ def _create( identifier=identifier, share_count=shares, indices=indices, + entropy=token_bytes, ) elif source is not None: source_secret = _generated_secret(source, None, identifier, core.fingerprint_seed) @@ -527,6 +530,7 @@ def _create( identifier=identifier, share_count=shares, indices=indices, + entropy=token_bytes, ) else: ceremony = CreationCeremony.master_seed( @@ -535,6 +539,7 @@ def _create( identifier=identifier, share_count=shares, indices=indices, + entropy=token_bytes, ) except HeaderCollision as error: raise _CommandError(f"{error}; choose another set header") from error diff --git a/src/codex32/generation.py b/src/codex32/generation.py index 7ad8c2e3..85852009 100644 --- a/src/codex32/generation.py +++ b/src/codex32/generation.py @@ -2,7 +2,6 @@ from __future__ import annotations -import secrets from collections.abc import Callable, Sequence from collections.abc import Set as AbstractSet from dataclasses import dataclass @@ -40,6 +39,7 @@ ) ORDINARY_INDICES = tuple(IDX_SORT[1:]) +Entropy = Callable[[int], bytes] @dataclass(frozen=True, slots=True) @@ -88,7 +88,31 @@ def _indices(values: Sequence[str] | str) -> tuple[str, ...]: return normalized -def _selection(threshold: int, share_count: object, indices: Sequence[str] | str | None) -> tuple[str, ...]: +def _source(entropy: Entropy | None) -> Entropy: + if entropy is None: + raise ValueError("drawing a seed, share, or identifier requires an entropy source") + return entropy + + +def _draw(entropy: Entropy, length: int) -> bytes: + data = entropy(length) + if not isinstance(data, bytes) or len(data) != length: + raise CodexError("the entropy source returned the wrong number of bytes") + return data + + +def _random_indices(entropy: Entropy, count: int) -> tuple[str, ...]: + pool, picked = list(ORDINARY_INDICES), list[str]() + while len(picked) < count: + for byte in _draw(entropy, 32): + if len(picked) < count and byte < 256 - 256 % len(pool): + picked.append(pool.pop(byte % len(pool))) + return tuple(picked) + + +def _selection( + threshold: int, share_count: object, indices: Sequence[str] | str | None, entropy: Entropy +) -> tuple[str, ...]: if (share_count is None) == (indices is None): raise InvalidShareSelection("choose exactly one of share_count or indices") if share_count is not None: @@ -96,7 +120,7 @@ def _selection(threshold: int, share_count: object, indices: Sequence[str] | str raise InvalidShareSelection("share_count must be an integer") if not threshold <= share_count <= 31: raise InvalidShareSelection(f"share_count must be from threshold {threshold} through 31") - return tuple(secrets.SystemRandom().sample(ORDINARY_INDICES, share_count)) + return _random_indices(entropy, share_count) assert indices is not None selected = _indices(indices) if len(selected) < threshold: @@ -104,8 +128,12 @@ def _selection(threshold: int, share_count: object, indices: Sequence[str] | str return selected -def _random_identifier() -> str: - return _u5_to_chars(tuple(value & 31 for value in secrets.token_bytes(4))) +def _random_symbols(entropy: Entropy, count: int) -> tuple[int, ...]: + return tuple(convertbits(_draw(entropy, (count * 5 + 7) // 8), 8, 5, pad=True)[:count]) + + +def _random_identifier(entropy: Entropy) -> str: + return _u5_to_chars(_random_symbols(entropy, 4)) def _fingerprint_identifier(fingerprint: bytes) -> str: @@ -116,9 +144,8 @@ def _fingerprint_identifier(fingerprint: bytes) -> str: return _u5_to_chars(tuple(convertbits(fingerprint, 8, 5, pad=True)[:4])) -def _random_share(threshold: int, identifier: str, index: str, length: int) -> Share: - symbols = tuple(value & 31 for value in secrets.token_bytes(length)) - artifact = _from_parts(Profile.MS, Header(threshold, identifier, index), symbols) +def _random_share(entropy: Entropy, threshold: int, identifier: str, index: str, length: int) -> Share: + artifact = _from_parts(Profile.MS, Header(threshold, identifier, index), _random_symbols(entropy, length)) assert isinstance(artifact, Share) return artifact @@ -144,21 +171,23 @@ def generate_master_seed( byte_length: int | None = None, identifier: str | None = None, fingerprint: Callable[[bytes], bytes] | None = None, + entropy: Entropy | None = None, ) -> MasterSeed: """Generate or encode one unshared ``ms`` secret.""" supplied, length = _seed_input(seed_bytes, byte_length) if supplied is not None: if not _valid_root(supplied): raise CodexError("master seed does not form a valid BIP32 root") - identifier = _random_identifier() if identifier is None else _identifier(identifier) + identifier = _random_identifier(_source(entropy)) if identifier is None else _identifier(identifier) return MasterSeed.from_seed(supplied, identifier=identifier) + source = _source(entropy) if identifier is None and fingerprint is None: raise ValueError( "fresh unshared master-seed generation requires a fingerprint provider or identifier" ) selected_identifier = _identifier(identifier) if identifier is not None else None while True: - fresh = secrets.token_bytes(length) + fresh = _draw(source, length) if not _valid_root(fresh): continue if selected_identifier is not None: @@ -176,6 +205,7 @@ class CreationCeremony: _indices: tuple[str, ...] _payload_length: int _pending: Share | None + _entropy: Entropy _position: int _secret: MasterSeed | None _set_identifier: str @@ -196,11 +226,12 @@ def _start( indices: Sequence[str] | str | None, identifier: str, secret: MasterSeed | None, + entropy: Entropy, ) -> CreationCeremony: self = object.__new__(cls) - self._payload_length = payload_length + self._payload_length, self._entropy = payload_length, entropy self._threshold, self._set_identifier = threshold, identifier - self._indices = _selection(threshold, share_count, indices) + self._indices = _selection(threshold, share_count, indices, entropy) self._position, self._pending, self._finished = 0, None, False if secret is None: self._secret, self._basis, self._direct_count = None, [], threshold @@ -223,11 +254,12 @@ def master_seed( share_count: int | None = None, indices: Sequence[str] | str | None = None, identifier: str | None = None, + entropy: Entropy, ) -> CreationCeremony: """Start a ceremony for a fresh shared Bitcoin master seed.""" threshold = _threshold(threshold, allow_zero=False) _supplied, byte_length = _seed_input(None, byte_length) - identifier = _random_identifier() if identifier is None else _identifier(identifier) + identifier = _random_identifier(entropy) if identifier is None else _identifier(identifier) return cls._start( _payload_length(byte_length), threshold, @@ -235,6 +267,7 @@ def master_seed( indices, identifier, None, + entropy, ) @classmethod @@ -246,20 +279,21 @@ def from_secret( share_count: int | None = None, indices: Sequence[str] | str | None = None, identifier: str | None = None, + entropy: Entropy, ) -> CreationCeremony: """Start a ceremony that shares an existing validated master seed.""" if not isinstance(secret, MasterSeed): raise TypeError("from_secret accepts only MasterSeed") threshold = _threshold(threshold, allow_zero=False) random_identifier = identifier is None - identifier = _random_identifier() if random_identifier else _identifier(identifier) + identifier = _random_identifier(entropy) if random_identifier else _identifier(identifier) while (threshold, identifier) == ( secret.header.threshold, secret.header.identifier, ): if not random_identifier: raise HeaderCollision("new share set must use a different set header") - identifier = _random_identifier() + identifier = _random_identifier(entropy) return cls._start( len(secret.payload_symbols), threshold, @@ -267,6 +301,7 @@ def from_secret( indices, identifier, secret, + entropy, ) def _candidate_is_accepted(self, secret: MasterSeed) -> bool: @@ -284,6 +319,7 @@ def next_share(self) -> Share: if self._position < self._direct_count: while True: pending = _random_share( + self._entropy, self._threshold, self._set_identifier, index, diff --git a/tests/test_generation.py b/tests/test_generation.py index 9d20ff4d..c1bb8e81 100644 --- a/tests/test_generation.py +++ b/tests/test_generation.py @@ -3,6 +3,7 @@ import copy import pickle from itertools import combinations +from secrets import token_bytes import pytest from data.bip93_vectors import VECTOR_2, VECTOR_4, VECTOR_6 @@ -55,7 +56,9 @@ def _seed(byte_length: int) -> bytes: def test_fresh_unshared_ms_supports_every_bip93_size() -> None: for byte_length in SEED_BYTE_LENGTHS: - secret = generate_master_seed(byte_length=byte_length, fingerprint=stub_fingerprint) + secret = generate_master_seed( + byte_length=byte_length, fingerprint=stub_fingerprint, entropy=token_bytes + ) assert len(secret.seed_bytes) == byte_length assert secret.header.threshold == 0 assert secret.header.identifier == _fingerprint_identifier(stub_fingerprint(secret.seed_bytes)) @@ -75,7 +78,7 @@ def test_fresh_shared_ms_supports_every_bip93_size() -> None: for byte_length in SEED_BYTE_LENGTHS: secret, shares = _complete( CreationCeremony.master_seed( - byte_length=byte_length, threshold=2, indices="ac", identifier="test" + entropy=token_bytes, byte_length=byte_length, threshold=2, indices="ac", identifier="test" ) ) assert _indices(shares) == "ac" @@ -85,7 +88,9 @@ def test_fresh_shared_ms_supports_every_bip93_size() -> None: @pytest.mark.parametrize("threshold", range(2, 10)) def test_fresh_generation_recovers_at_every_threshold(threshold: int) -> None: - secret, shares = _complete(CreationCeremony.master_seed(threshold=threshold, share_count=threshold)) + secret, shares = _complete( + CreationCeremony.master_seed(entropy=token_bytes, threshold=threshold, share_count=threshold) + ) assert len({share.header.index for share in shares}) == threshold assert recover_secret(shares) == secret @@ -95,7 +100,7 @@ def test_generated_subsets_recover_across_sizes_and_threshold_boundaries( threshold: int, byte_length: int ) -> None: ceremony = CreationCeremony.master_seed( - byte_length=byte_length, threshold=threshold, share_count=threshold + 2 + entropy=token_bytes, byte_length=byte_length, threshold=threshold, share_count=threshold + 2 ) secret, shares = _complete(ceremony) @@ -111,7 +116,11 @@ def test_supplied_seed_round_trip(byte_length: int, threshold: int) -> None: source = generate_master_seed(_seed(byte_length), identifier="test") secret, shares = _complete( CreationCeremony.from_secret( - source, threshold=threshold, indices=ORDINARY_INDICES[:threshold], identifier="name" + source, + entropy=token_bytes, + threshold=threshold, + indices=ORDINARY_INDICES[:threshold], + identifier="name", ) ) assert isinstance(secret, MasterSeed) and secret.seed_bytes == _seed(byte_length) @@ -125,19 +134,28 @@ def test_every_other_16_through_64_byte_generation_size_is_rejected() -> None: with pytest.raises(InvalidLength): generate_master_seed(byte_length=byte_length) with pytest.raises(InvalidLength): - CreationCeremony.master_seed(threshold=2, indices="ac", byte_length=byte_length) + CreationCeremony.master_seed( + entropy=token_bytes, threshold=2, indices="ac", byte_length=byte_length + ) def test_raw_bytes_accept_random_or_explicit_identifiers() -> None: raw = bytes(range(16)) with pytest.raises(InvalidLength): generate_master_seed(raw, byte_length=16, identifier="test") - random_secret = generate_master_seed(raw) + random_secret = generate_master_seed(raw, entropy=token_bytes) secret = generate_master_seed(raw, identifier="TEST") assert len(random_secret.header.identifier) == 4 assert secret.header.identifier == "test" +def test_draws_need_a_source_that_returns_exactly_the_requested_bytes() -> None: + with pytest.raises(ValueError, match="entropy source"): + generate_master_seed(bytes(range(16))) + with pytest.raises(CodexError, match="wrong number of bytes"): + CreationCeremony.master_seed(threshold=2, share_count=2, entropy=lambda length: bytes(length - 1)) + + def test_supplied_seed_must_form_a_valid_bip32_root(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setattr(generation_module, "_valid_root", lambda _seed: False) with pytest.raises(CodexError, match="master seed does not form a valid BIP32 root"): @@ -147,12 +165,16 @@ def test_supplied_seed_must_form_a_valid_bip32_root(monkeypatch: pytest.MonkeyPa def test_explicit_and_random_output_order_contracts() -> None: source = generate_master_seed(bytes(range(16)), identifier="test") _secret, shares = _complete( - CreationCeremony.from_secret(source, threshold=3, indices="7CaD", identifier="name") + CreationCeremony.from_secret( + source, entropy=token_bytes, threshold=3, indices="7CaD", identifier="name" + ) ) assert _indices(shares) == "7cad" _secret, all_shares = _complete( - CreationCeremony.from_secret(source, threshold=2, share_count=31, identifier="cash") + CreationCeremony.from_secret( + source, entropy=token_bytes, threshold=2, share_count=31, identifier="cash" + ) ) assert len(set(_indices(all_shares))) == 31 assert set(_indices(all_shares)) == set(ORDINARY_INDICES) @@ -173,7 +195,7 @@ def test_explicit_and_random_output_order_contracts() -> None: ) def test_invalid_share_selections(arguments: dict[str, object]) -> None: with pytest.raises(InvalidShareSelection): - CreationCeremony.master_seed(identifier="test", **arguments) # type: ignore[arg-type] + CreationCeremony.master_seed(entropy=token_bytes, identifier="test", **arguments) # type: ignore[arg-type] def test_oversized_index_strings_are_bounded_before_normalization( @@ -185,8 +207,12 @@ def unexpected_normalization(_value: object) -> str: monkeypatch.setattr(generation_module, "_index", unexpected_normalization) source = generate_master_seed(bytes(range(16)), identifier="test") operations = ( - lambda: CreationCeremony.master_seed(threshold=2, indices="a" * 32, identifier="test"), - lambda: CreationCeremony.from_secret(source, threshold=2, indices="a" * 32, identifier="name"), + lambda: CreationCeremony.master_seed( + entropy=token_bytes, threshold=2, indices="a" * 32, identifier="test" + ), + lambda: CreationCeremony.from_secret( + source, entropy=token_bytes, threshold=2, indices="a" * 32, identifier="name" + ), ) for operation in operations: with pytest.raises(InvalidShareSelection, match="at most 31"): @@ -196,6 +222,7 @@ def unexpected_normalization(_value: object) -> str: def test_unordered_indices_and_invalid_thresholds_are_rejected() -> None: with pytest.raises(TypeError): CreationCeremony.master_seed( + entropy=token_bytes, threshold=2, indices={"a", "c"}, identifier="test", # type: ignore[arg-type] @@ -203,36 +230,33 @@ def test_unordered_indices_and_invalid_thresholds_are_rejected() -> None: for threshold in (0, 1, 10, True, "2"): with pytest.raises(InvalidThreshold): CreationCeremony.master_seed( + entropy=token_bytes, threshold=threshold, share_count=2, identifier="test", # type: ignore[arg-type] ) -def test_confirmation_gates_each_entropy_draw_and_reports_groups( - monkeypatch: pytest.MonkeyPatch, -) -> None: +def test_confirmation_gates_each_entropy_draw_and_reports_groups() -> None: calls = [] - original = generation_module.secrets.token_bytes def recorded(length: int) -> bytes: calls.append(length) - return original(length) + return token_bytes(length) - monkeypatch.setattr(generation_module.secrets, "token_bytes", recorded) - ceremony = CreationCeremony.master_seed(threshold=2, indices="acd", identifier="test") + ceremony = CreationCeremony.master_seed(entropy=recorded, threshold=2, indices="acd", identifier="test") assert calls == [] first = ceremony.next_share() - assert calls == [26] + assert calls == [17] # 26 five-bit symbols with pytest.raises(CeremonyStateError, match="pending"): ceremony.next_share() damaged = first.text[:4] + ("q" if first.text[4] != "q" else "p") + first.text[5:] result = ceremony.confirm(damaged) assert not result.accepted and result.mismatched_groups == (2,) - assert calls == [26] + assert calls == [17] # 26 five-bit symbols assert ceremony.confirm(" \n".join(first.text.upper())).accepted second = ceremony.next_share() - assert len(calls) >= 2 and all(length == 26 for length in calls) + assert len(calls) >= 2 and all(length == 17 for length in calls) assert ceremony.confirm(second.text).accepted calls_after_basis = len(calls) derived = ceremony.next_share() @@ -242,7 +266,7 @@ def recorded(length: int) -> bytes: def test_final_share_rejection_retains_confirmed_prefix(monkeypatch: pytest.MonkeyPatch) -> None: - ceremony = CreationCeremony.master_seed(threshold=2, indices="ac", identifier="test") + ceremony = CreationCeremony.master_seed(entropy=token_bytes, threshold=2, indices="ac", identifier="test") first = ceremony.next_share() assert ceremony.confirm(first.text).accepted original = ceremony._candidate_is_accepted @@ -275,7 +299,7 @@ def recorded(*args: object, **kwargs: object) -> Share: def test_final_ms_share_retries_a_bip32_invalid_root(monkeypatch: pytest.MonkeyPatch) -> None: - ceremony = CreationCeremony.master_seed(threshold=2, indices="ac", identifier="test") + ceremony = CreationCeremony.master_seed(entropy=token_bytes, threshold=2, indices="ac", identifier="test") first = ceremony.next_share() assert ceremony.confirm(first.text).accepted original = generation_module._valid_root @@ -295,7 +319,7 @@ def reject_once(seed: bytes) -> bool: def test_ceremony_state_is_fail_closed() -> None: - ceremony = CreationCeremony.master_seed(threshold=2, indices="ac", identifier="test") + ceremony = CreationCeremony.master_seed(entropy=token_bytes, threshold=2, indices="ac", identifier="test") with pytest.raises(CeremonyStateError): ceremony.confirm("anything") with pytest.raises(CeremonyStateError): @@ -314,7 +338,7 @@ def test_ceremony_state_is_fail_closed() -> None: def test_ceremony_cannot_be_copied_or_serialized() -> None: - ceremony = CreationCeremony.master_seed(threshold=2, indices="ac", identifier="test") + ceremony = CreationCeremony.master_seed(entropy=token_bytes, threshold=2, indices="ac", identifier="test") pending = ceremony.next_share() assert ceremony.confirm(pending.text).accepted @@ -331,13 +355,17 @@ def test_resharing_preserves_padding_and_requires_a_new_header() -> None: source = parse_codex32(VECTOR_4["secret_s_alternate_0"]) assert isinstance(source, MasterSeed) secret, shares = _complete( - CreationCeremony.from_secret(source, threshold=2, indices="ac", identifier="name") + CreationCeremony.from_secret( + source, entropy=token_bytes, threshold=2, indices="ac", identifier="name" + ) ) assert secret.payload_symbols == source.payload_symbols assert recover_secret(shares).payload_symbols == source.payload_symbols with pytest.raises(HeaderCollision): - CreationCeremony.from_secret(secret, threshold=2, indices="ac", identifier="name") - randomized = CreationCeremony.from_secret(secret, threshold=2, indices="ac") + CreationCeremony.from_secret( + secret, entropy=token_bytes, threshold=2, indices="ac", identifier="name" + ) + randomized = CreationCeremony.from_secret(secret, entropy=token_bytes, threshold=2, indices="ac") assert randomized.next_share().header.identifier != secret.header.identifier @@ -352,6 +380,7 @@ def test_from_secret_rejects_non_secret_artifacts() -> None: with pytest.raises(TypeError): CreationCeremony.from_secret( artifact, + entropy=token_bytes, threshold=2, indices="ac", identifier="test", # type: ignore[arg-type] diff --git a/tests/test_public_api.py b/tests/test_public_api.py index 70106c53..44e6933e 100644 --- a/tests/test_public_api.py +++ b/tests/test_public_api.py @@ -27,9 +27,11 @@ def test_backup_workflows_need_only_the_standard_library() -> None: source = """ +from secrets import token_bytes + from codex32 import CreationCeremony, CorrectionContext, Profile, correct, derive_share, parse_codex32, recover_secret -ceremony = CreationCeremony.master_seed(threshold=2, indices="ac", identifier="test") +ceremony = CreationCeremony.master_seed(threshold=2, indices="ac", identifier="test", entropy=token_bytes) shares = [] for _ in range(2): share = ceremony.next_share() diff --git a/tools/verify_installed_wheel.py b/tools/verify_installed_wheel.py index 0a4fbef7..f20e9194 100644 --- a/tools/verify_installed_wheel.py +++ b/tools/verify_installed_wheel.py @@ -4,6 +4,7 @@ import importlib.util import sys +from secrets import token_bytes from codex32 import ( CorrectionContext, @@ -28,7 +29,7 @@ def main() -> None: assert importlib.util.find_spec("bip32") is None assert importlib.util.find_spec("coincurve") is None - ceremony = CreationCeremony.master_seed(threshold=2, indices="ac", identifier="test") + ceremony = CreationCeremony.master_seed(threshold=2, indices="ac", identifier="test", entropy=token_bytes) shares = [] for _ in range(2): share = ceremony.next_share() From 0d5df2128b200ff5c18a142a58d51bb2aeb7659d Mon Sep 17 00:00:00 2001 From: Ben Westgate Date: Sat, 24 Oct 2026 06:15:30 -0500 Subject: [PATCH 2/2] create: Draw randomness from Core and OS Use CoreEntropy for every fresh CLI draw, mixing a fresh Bitcoin Core xprv with independent OS bytes and failing closed when Core or cleanup fails. Keep temporary wallet keys encrypted, relock and unload each draw, convert termination signals into cleanup interruptions, and document the trust and disk-fallback boundaries. Add Core adapter, CLI, cleanup, interruption, and regtest coverage. --- docs/developer/api.md | 54 ++++++++++--- docs/security/invariants.md | 10 ++- docs/security/model.md | 19 +++-- docs/user/guide.md | 10 ++- src/codex32/_bitcoin_core.py | 140 ++++++++++++++++++++++++++++++++++ src/codex32/cli.py | 56 +++++++------- tests/test_bitcoin_core.py | 129 +++++++++++++++++++++++++++++++ tests/test_cli.py | 48 +++++++++++- tools/bitcoin_core_regtest.py | 29 ++++++- 9 files changed, 436 insertions(+), 59 deletions(-) diff --git a/docs/developer/api.md b/docs/developer/api.md index bf952849..10a8602c 100644 --- a/docs/developer/api.md +++ b/docs/developer/api.md @@ -37,7 +37,7 @@ generic parse-length failure. | checksum and CRC arithmetic | `checksums.py` | official vectors, `test_crc.py` | | optional application rules and S types | `profiles/ms32.py`, `cl32.py`, `bip39.py` | profile and BIP39 tests | | immutable base artifacts and interpolation | `bip93.py` | BIP93 vectors, `test_sharing.py` | -| entropy, masks, identifiers, output indices | `generation.py` | `test_generation.py` | +| entropy, masks, identifiers, output indices | `generation.py`, `CoreEntropy` | `test_generation.py`, `test_bitcoin_core.py` | | shared GF(32) arithmetic | `gf32.py` | sharing vectors and correction corpus | | fixed BCH and worksheet correction | `correction.py` | `test_correction_bch.py` | | structural alignment and admission | `indel.py` | `test_correction_indel.py`, `test_correction_capture.py` | @@ -67,7 +67,9 @@ generic parse-length failure. `.text` attribute. - Sharing interpolates payload and checksum together, explicitly constructs the target header, and reparses the result. -- `generation.py` is the only entropy owner; it generates and shares only `ms`. +- `generation.py` is the only entropy owner; it draws every byte from its + caller's `entropy` source (Bitcoin Core in the CLI) and generates and shares + only `ms`. - Correction never edits the HRP or separator and reparses every candidate. - `_bip32.py` stops at HMAC-SHA512 root derivation, scalar validity, and root xprv/tprv Base58Check serialization. It performs no child derivation or @@ -152,9 +154,10 @@ accepts only `ms` artifacts. ## Secret generation -`generation.py` is the only module that draws entropy. It draws every random -byte from its caller's `entropy(length)` source, which must return exactly the -requested number of bytes, and generates and splits BIP93 master seeds only. +`generation.py` is the only module that draws entropy, always by calling the +caller's `entropy(length)`, which must return exactly `length` bytes. The CLI +passes [Bitcoin Core's](#bitcoin-core-randomness). It generates and splits +BIP93 master seeds only. Fresh unshared seeds default to 16 bytes and use the first 20 bits of their BIP32 fingerprint as public identifier metadata. Fresh shared sets use four @@ -177,7 +180,7 @@ A fresh seed with no `identifier` needs `fingerprint`: a callable that takes the seed bytes and returns their four-byte BIP32 master fingerprint. This package has no secp256k1 arithmetic, so the CLI asks Bitcoin Core. Without either argument, fresh generation raises `ValueError`, as does any draw without -an entropy source. +`entropy`. Shared creation uses `CreationCeremony.master_seed(...)` or `CreationCeremony.from_secret(...)`; both require `entropy`. Exactly one of @@ -210,9 +213,10 @@ The ceremony follows the two BIP93 constructions: Each direct share uses a separate `entropy` request and cannot be followed by another draw until its string is confirmed. The pause gives the source an -opportunity to mix fresh noise; nothing guarantees new physical entropy between -calls. User input is never used as share bytes. Symbols are the drawn bytes -regrouped five bits at a time, so `n` symbols take `ceil(5n/8)` bytes. +opportunity to mix fresh noise; nothing guarantees that new physical entropy +arrives between calls. User input is never used as share bytes. Symbols are +the drawn bytes regrouped five bits at a time, so `n` symbols take +`ceil(5n/8)` bytes: 17 for a 128-bit share, 65 for a 512-bit one. Creation confirmation preserves whitespace-separated entered groups as units, even when moving characters between groups would reduce edits or red groups. @@ -259,9 +263,9 @@ remain fixed. That padding rule is not BIP93 validity: parsed S strings may use any application-valid discarded bits, which re-sharing preserves exactly. CRC never applies to shares. -Explicit output indices preserve caller order. A share count picks from the 31 -ordinary indices by rejection sampling `entropy` bytes, in draw order. There is -no sorting, caller-supplied partial-basis +Explicit output indices preserve caller order. A share count picks +from the 31 ordinary indices by rejection sampling of `entropy` bytes, in draw +order. There is no sorting, caller-supplied partial-basis completion, or BIP39 generation. Ceremonies reject copying and serialization; the CLI does not resume an interrupted ceremony. @@ -642,6 +646,32 @@ The Core calls are fixed: `getnetworkinfo`, `getblockchaininfo`, `listwallets`, Bitcoin Core alone creates wallets, selects encryption, handles passphrases, stores keys, and provides normal wallet behavior. +### Bitcoin Core randomness + +`ms32 create` draws every random byte from `CoreEntropy`, so it stops if Core +cannot supply them. Core's `GetStrongRandBytes` mixes the OS generator with +RDRAND/RDSEED where available, timestamps, and accumulated state. Each draw +creates a blank temporary wallet in RAM: `/dev/shm` on Linux, a RAM disk from +`hdiutil` on macOS, otherwise the temp folder on disk with one warning. It is +encrypted from birth with a 256-bit `secrets` passphrase sent only on stdin. +The draw unlocks it, runs `addhdkey` once per 32 bytes, takes the SHA-256 of +the xprv from `gethdkeys private=true` followed by 32 bytes from `secrets`, +relocks it, and unloads and deletes it before returning any bytes. Hashing +stops the xpub, which the wallet file stores unencrypted, from checking +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 +`unloadwallet`. + +The temporary wallet only works around Core having no call that returns +random bytes. If Core gains one, or can export a seed, only `CoreEntropy` +changes: `generation.py` and both front ends keep calling `entropy(length)`. + The wallet CLI is one leaf command: ```text diff --git a/docs/security/invariants.md b/docs/security/invariants.md index 42adce52..eebf1568 100644 --- a/docs/security/invariants.md +++ b/docs/security/invariants.md @@ -8,8 +8,9 @@ and evidence. HRPs remain opaque and never gain invented application semantics. 2. APIs accept only immutable validated artifacts. Outputs are reparsed; recovery and derivation reject sets with mismatched normalized HRPs or shapes. -3. Shared creation uses a separate OS-CSPRNG call for each random initial share, - gated by confirmation. Input cannot replace entropy or the original secret. +3. Shared creation uses a separate Bitcoin Core draw for each random initial + share, gated by confirmation, with no fallback. Input cannot replace entropy + or the original secret. 4. Wallet setup uses the original ceremony result or a validated recovered seed. Restore authenticates the recovered seed before any wallet is listed, unlocked, or imported into: normally with the master fingerprint typed from @@ -36,8 +37,9 @@ and evidence. operator confirms an eligible descriptor wallet by exact name. 8. Wallet state is revalidated before handing Core the master key. Core must accept that key and create every requested account-0 wallet descriptor. -9. codex32 has no passphrase channel. An unlocked encrypted signer is relocked - and verified on every exit path. +9. codex32 never handles the operator's wallet passphrase. An unlocked encrypted + signer is relocked and verified on every exit path; the temporary randomness + wallet is relocked after each draw. 10. External text, Core output, public wallet data, and PSBTs are untrusted. 11. Only Bitcoin Core descriptor wallets sign with codex32-derived keys. Sensitive operations use only codex32 or Core on malware-free computers diff --git a/docs/security/model.md b/docs/security/model.md index 6eabc340..ac577f11 100644 --- a/docs/security/model.md +++ b/docs/security/model.md @@ -26,7 +26,7 @@ fingerprints, and wallet history cannot spend funds but remain privacy-sensitive | Package API | Recovery, derivation, sharing, and wallet operations accept validated artifacts, not unchecked text. | | Python process | Secret objects remain in process memory; normal output must not reveal them unintentionally. | | Operator and paper | The operator controls transcription, physical recovery cards, wallet records, and acceptance of correction suggestions. | -| Entropy and root keys | The operating-system cryptographically secure random-number generator (OS-CSPRNG) and Python stdlib HMAC/SHA-512 primitives are trusted. codex32 performs only BIP32 root validation and root xprv/tprv serialization in process. | +| Entropy and root keys | Every random byte of `ms32 create` hashes a fresh Bitcoin Core HD key, from a generator that mixes in the operating-system cryptographically secure random-number generator (OS-CSPRNG), with 32 more OS-CSPRNG bytes; either source alone keeps the bytes unpredictable. Python stdlib HMAC/SHA-512 primitives are trusted. codex32 performs only BIP32 root validation and root xprv/tprv serialization in process. | | Bitcoin Core | The selected `bitcoin-cli`, its configuration, the selected local Core instance, and the destination computer are trusted for fingerprints, hardened/public-key derivation, and wallet initialization. | ## Operator assumptions @@ -64,8 +64,11 @@ The operator must: implementation. Elliptic-curve public-key derivation used for fingerprints, xpubs, and public descriptors is delegated to the separately running Bitcoin Core process. -- Separate OS-CSPRNG calls provide opportunities to mix fresh noise but cannot +- Separate Core draws provide opportunities to mix fresh noise but cannot guarantee new physical entropy between calls. +- The temporary randomness wallet's passphrase comes from the OS-CSPRNG. Where + no RAM folder exists, as on Windows, the encrypted wallet briefly sits on + disk. Swap, RAM disks and tmpfs can page memory, including secrets, to disk. - A checksum, generation-padding hint, fingerprint, or correction candidate does not authenticate a backup or prove the operator's intent. - Creation feedback identifies correct groups but does not prove the recovery card was corrected. @@ -116,9 +119,9 @@ complete explicit `ms1` string. ## Creation, sharing, and recovery controls 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 +full-payload request to Bitcoin Core. The current share string must be re-entered exactly, ignoring case and whitespace, before the next request. Confirmation -text is never reparsed as the source secret and contributes no entropy. Existing +text is never reparsed as the source secret or used as share bytes. Existing complete Bitcoin master seeds are confirmed unchanged and initialize the wallet without new entropy. The `ms32` façade rejects CL. The generic API parses, inspects, and corrects unshared CL secrets only: Core Lightning rejects shares @@ -132,8 +135,8 @@ Progressive group-level correctness feedback is explicitly accepted and does not Confirmation shows that the operator can produce the correct recovery string during setup. It cannot prove that the physical backup was corrected rather than reconstructed using confirmation feedback. -The API accepts neither caller-provided entropy nor padding values, partial -bases, or resumable ceremony state. Fresh creation rejects final-share +The API draws randomness only from its caller's `entropy` source and accepts no +padding values, partial bases, or resumable ceremony state. Fresh creation rejects final-share candidates whose generation padding is invalid. Master-seed creation also rejects candidates that cannot form a valid BIP32 root. This check uses only stdlib HMAC-SHA512 plus the secp256k1 scalar-order bound; it performs no point @@ -234,10 +237,10 @@ signing setup belong to Bitcoin Core's maintained v32 workflow. |---|---| | Preflight | Before entropy or recovery input, explicit chain arguments probe the five standard local networks for Bitcoin Core 32 or newer. One response is selected automatically; multiple responses require operator selection. | | Recovery identity | `ms32 wallet` and `ms32 create --existing` authenticate a recovered seed before any wallet is listed. Core derives the recovered fingerprint statelessly, and a mismatch raises `FingerprintMismatch` before any wallet RPC. The restore prompt does not show the recovered value, so the operator compares by typing the fingerprint from the wallet record. Without a record, the operator is shown the recovered fingerprint, whether the backup identifier was derived from the seed (the codex32 fingerprint rule, Bails' RIPEMD-160 rule, or its mid-2023 alpha's SHA-256 rule), and a warning, and then chooses. Fresh `ms32 create` has no pre-existing wallet to authenticate: it shows the newly created seed's fingerprint and requires the operator to acknowledge recording it. These checks catch mistakes such as wrong or mixed cards; anyone able to replace a threshold of cards could already read them. | -| Process boundary | codex32 invokes the reviewed `bitcoin-cli` from `PATH` as a child without a shell, direct RPC socket, wallet database, or wallet-creation operation. Every call uses loopback and the selected chain. | +| Process boundary | codex32 invokes the reviewed `bitcoin-cli` from `PATH` as a child without a shell, direct RPC socket, or wallet database; its only wallet creation is the temporary randomness wallet. Every call uses loopback and the selected chain. | | Destination | Only an empty descriptor wallet with private keys enabled, no external signer, transactions, descriptors, keypool entries, or active scan is eligible. One eligible wallet is offered directly; multiple wallets are selected by number. New wallets are detected by polling, and rejection returns to every eligible wallet. The escaped name is confirmed exactly. | | Seed source | The original ceremony result or validated recovered master seed supplies a root xprv for Core's reported chain. Core v32 creates BIP44, BIP49, BIP84, and BIP86 account-0 descriptors from that key. | -| Secret channel | The master xprv is sent only through the child's standard input. A timestamped rescan obtains one private descriptor from Core's captured stdout and returns it through stdin; neither value is printed or passed in arguments or diagnostics. codex32 has no passphrase channel and suppresses raw Core errors. | +| Secret channel | The master xprv is sent only through the child's standard input. A timestamped rescan obtains one private descriptor from Core's captured stdout and returns it through stdin; neither value is printed or passed in arguments or diagnostics. codex32 never handles the operator's wallet passphrase and suppresses raw Core errors. | | Revalidation | Every destination property is checked again immediately before adding the key. `addhdkey` must accept it and `createwalletdescriptor` must return two public descriptors for each requested address type. Numeric recovery timestamps trigger Core's time-based rescan (genesis for `0`), with no guessed block height. Core is trusted to derive and store the wallet policy. | | Relocking | Once Core reports an encrypted private-key wallet unlocked, a `finally`-protected obligation requests `walletlock` and verifies the locked state after success, failure, state change, or interruption. | diff --git a/docs/user/guide.md b/docs/user/guide.md index b875ab04..f83d6158 100644 --- a/docs/user/guide.md +++ b/docs/user/guide.md @@ -73,8 +73,12 @@ Before running `create` for a real wallet, have your blank cards, a pen, and wallet record ready, and choose separate trusted places for shared cards. If you want to try the process first, use the signet practice setup below. -Run Bitcoin Core before starting. If practical, disconnect the computer from -external networks while recovery text is on screen. codex32 talks only to the +Run Bitcoin Core before starting: `create` needs Core for its randomness. +For each card's randomness, Core makes a temporary encrypted wallet in RAM +(macOS briefly shows a small `codex32-` disk) and deletes it before the card +is shown. If +practical, disconnect the computer from external networks while recovery text +is on screen. codex32 talks only to the local Core instance at `127.0.0.1` during setup. For graphical practice on signet, start Bitcoin-Qt with: @@ -156,7 +160,7 @@ the selected encrypted wallet is locked, open **Window > Console** in Bitcoin-Qt, select the named wallet, and enter `walletpassphrase "YOUR PASSPHRASE" 5`. codex32 waits and continues automatically. It imports account 0, verifies the public descriptors Core -accepted, and relocks the wallet. It does not create wallets or choose +accepted, and relocks the wallet. It does not create your wallet or choose its encryption. You are done with this step when the terminal says: diff --git a/src/codex32/_bitcoin_core.py b/src/codex32/_bitcoin_core.py index d7d1be90..4742365c 100644 --- a/src/codex32/_bitcoin_core.py +++ b/src/codex32/_bitcoin_core.py @@ -2,9 +2,13 @@ import hashlib import json +import os +import secrets import shutil import string import subprocess +import sys +import tempfile from collections.abc import Callable from dataclasses import dataclass from time import sleep @@ -433,3 +437,139 @@ def initialize( if waited: tell("") return name + + +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: + self._core, self._tell, self._folder, self._disk, self._loaded = core, tell, "", "", "" + self._passphrase, self._warned = secrets.token_hex(32), False + + def __call__(self, length: int) -> bytes: + """Return ``length`` bytes; the draw's wallet is gone before they are used, even if it fails.""" + try: + try: + data = self._draw(length) + except BaseException: + try: + self._close() + except BitcoinCoreError as left: + self._tell(str(left)) # the error already on its way says what the cards are worth + raise + self._close() # A stuck wallet stops creation here, before the next card. + return data + except BitcoinCoreError as error: + raise BitcoinCoreError( + f"{error} New cards need Bitcoin Core's randomness, so creation stopped. " + "Mark any cards from it void." + ) from error + + def _draw(self, length: int) -> bytes: + wallet = self._wallet() + self._core._rpc("walletpassphrase", wallet=wallet, stdin=f"{self._passphrase}\n60\n") + data = b"" + try: + while len(data) < length: + added = self._core._rpc("addhdkey", wallet=wallet) + keys = self._core._rpc("-named", "gethdkeys", "private=true", wallet=wallet) + xpub = added.get("xpub") if isinstance(added, dict) else None + listed = keys if isinstance(keys, list) else [] + xprvs = [ + key.get("xprv") for key in listed if isinstance(key, dict) and key.get("xpub") == xpub + ] + 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() + finally: + self._core._rpc("walletlock", wallet=wallet) + return data[:length] + + def _wallet(self) -> str: + if not self._folder: + self._folder = self._ram_folder() + # Track the wallet first: Core may load it even if its answer never arrives. + wallet = self._loaded = os.path.join(self._folder, "w") + try: + self._core._rpc( + "-named", + "createwallet", + f"wallet_name={wallet}", + "blank=true", + "load_on_startup=false", + stdin=f"passphrase={self._passphrase}\n", + ) + except BitcoinCoreError as error: + raise BitcoinCoreError( + f"Bitcoin Core could not create a wallet in {self._folder}. " + "Run Core as your own user, outside any sandbox." + ) from error + return os.path.join(self._folder, "w") + + def _ram_folder(self) -> str: + if os.path.isdir("/dev/shm"): + return tempfile.mkdtemp(prefix="codex32-", dir="/dev/shm") + if sys.platform == "darwin": + # A RAM disk needs no admin rights on macOS. Bitcoin Core's test guide uses the same commands: + # https://github.com/bitcoin/bitcoin/blob/v29.0/test/README.md#speed-up-test-runs-with-a-ram-disk + volume = f"/Volumes/codex32-{secrets.token_hex(4)}" + attach = ("/usr/bin/hdiutil", "attach", "-nomount", "ram://32768") + self._disk = subprocess.run(attach, capture_output=True, text=True, check=False).stdout.strip() + erase = ("/usr/sbin/diskutil", "erasevolume", "HFS+", os.path.basename(volume), self._disk) + erased = self._disk and not subprocess.run(erase, capture_output=True, check=False).returncode + if erased and os.path.ismount(volume): + return volume + folder = tempfile.mkdtemp(prefix="codex32-") + if self._warned: + return folder + self._warned = True + self._tell( + f"No RAM folder was found, so Bitcoin Core's temporary encrypted wallets will be on disk in " + f"{os.path.dirname(folder)} while they draw. If this computer's own random generator is " + "broken, anyone who later recovers those deleted files could recover the new cards. To avoid " + "that, press Ctrl-C and use Tails, or point TMPDIR at a RAM drive and run create again. On " + r"Windows with WinFsp's Developer files, `net use R: \\memfs64\codex32` and `set TMPDIR=R:\` do " + "that without admin rights; `net use R: /delete` removes the drive afterwards." + ) + return folder + + def _close(self) -> None: + """Unload and delete the temporary wallet and any RAM disk made for it, or raise. + + Every step is tried, even after Ctrl-C, which is raised again once they have all run. + """ + interrupted = False + if self._loaded: + # A call that fails or is interrupted may still have reached Core, so ask what it has loaded. + try: + self._core._rpc("unloadwallet", self._loaded) + self._loaded = "" + except BitcoinCoreError: + pass + except KeyboardInterrupt: + interrupted = True + try: + if self._loaded and self._loaded not in self._core._names(): + self._loaded = "" + except BitcoinCoreError: + pass + except KeyboardInterrupt: + interrupted = True + if self._folder: + shutil.rmtree(self._folder, ignore_errors=True) + detach = ("/usr/bin/hdiutil", "detach", "-force", self._disk) + if self._disk and not subprocess.run(detach, capture_output=True, check=False).returncode: + self._disk = "" + if not os.path.exists(self._folder): + self._folder = "" + left = self._folder or self._disk + if self._loaded or left: + raise BitcoinCoreError( + "Bitcoin Core's temporary wallet was not removed." + + (" Restart Core to unload it." if self._loaded else "") + + (f" Delete or eject {left}." if left else "") + ) + if interrupted: + raise KeyboardInterrupt diff --git a/src/codex32/cli.py b/src/codex32/cli.py index edeffaf6..fa2742c0 100644 --- a/src/codex32/cli.py +++ b/src/codex32/cli.py @@ -4,15 +4,16 @@ import argparse import json +import signal import sys from collections.abc import Callable, Sequence -from secrets import token_bytes from typing import Literal, NamedTuple, cast from codex32._bitcoin_core import ( NO_RECORD_WARNING, BitcoinCore, BitcoinCoreError, + CoreEntropy, FingerprintMismatch, identifier_note, identifier_origin, @@ -339,13 +340,14 @@ def _generated_secret( byte_length: int | None, identifier: str | None, fingerprint: Callable[[bytes], bytes] | None = None, + entropy: Callable[[int], bytes] | None = None, ) -> MasterSeed: return generate_master_seed( source, byte_length=byte_length, identifier=identifier, fingerprint=fingerprint, - entropy=token_bytes, + entropy=entropy, ) @@ -493,6 +495,10 @@ 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)) + # 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} try: if threshold == 0: if isinstance(source, MasterSeed): @@ -500,37 +506,27 @@ def _create( raise _UsageError( "To change the existing secret's identifier, choose a sharing threshold from 2 through 9." ) - secret = source + finished = source else: - secret = _generated_secret(source, byte_length, identifier, core.fingerprint_seed) - _emit(secret, False, fingerprint=None if existing else core.fingerprint) - if sys.stdin.isatty(): - _confirm_card(secret) - return ( - _initialize_wallet( - core, secret, timestamp=0 if existing else "now", fresh=not existing, restore=existing - ) - if core is not None - else 0 - ) - if isinstance(source, MasterSeed): + finished = _generated_secret(source, byte_length, identifier, core.fingerprint_seed, entropy) + elif isinstance(source, MasterSeed): ceremony = CreationCeremony.from_secret( source, threshold=threshold, identifier=identifier, share_count=shares, indices=indices, - entropy=token_bytes, + entropy=entropy, ) elif source is not None: - source_secret = _generated_secret(source, None, identifier, core.fingerprint_seed) + source_secret = _generated_secret(source, None, identifier, core.fingerprint_seed, entropy) ceremony = CreationCeremony.from_secret( source_secret, threshold=threshold, identifier=identifier, share_count=shares, indices=indices, - entropy=token_bytes, + entropy=entropy, ) else: ceremony = CreationCeremony.master_seed( @@ -539,20 +535,28 @@ def _create( identifier=identifier, share_count=shares, indices=indices, - entropy=token_bytes, + entropy=entropy, ) + if threshold: + output_count = shares if shares is not None else len(indices or "") + for position in range(output_count): + artifact = ceremony.next_share() + _emit(artifact, False, gap=position > 0) + _confirm_card(artifact, ceremony.confirm) + _print(f"Recovery card {position + 1} of {output_count} confirmed.", err=True) + finished = ceremony.finish() except HeaderCollision as error: raise _CommandError(f"{error}; choose another set header") from error except CodexError as error: raise _CommandError(str(error)) from error - output_count = shares if shares is not None else len(indices or "") - for position in range(output_count): - artifact = ceremony.next_share() - _emit(artifact, False, gap=position > 0) - _confirm_card(artifact, ceremony.confirm) - _print(f"Recovery card {position + 1} of {output_count} confirmed.", err=True) - finished = ceremony.finish() + finally: + for number, handler in previous.items(): + signal.signal(number, handler) assert isinstance(finished, MasterSeed) + if threshold == 0: + _emit(finished, False, fingerprint=None if existing else core.fingerprint) + if sys.stdin.isatty(): + _confirm_card(finished) if core is not None: return _initialize_wallet( core, finished, timestamp=0 if existing else "now", fresh=not existing, restore=existing diff --git a/tests/test_bitcoin_core.py b/tests/test_bitcoin_core.py index 74204a00..f4f150f8 100644 --- a/tests/test_bitcoin_core.py +++ b/tests/test_bitcoin_core.py @@ -4,15 +4,21 @@ import hashlib import json +import os import re +import shutil import subprocess +import sys +import tempfile from dataclasses import dataclass, field +from pathlib import Path import pytest from codex32._bitcoin_core import ( BitcoinCore, BitcoinCoreError, + CoreEntropy, FingerprintMismatch, identifier_note, identifier_origin, @@ -865,3 +871,126 @@ def test_identifier_note_allows_supported_nonderived_codex32_identifiers() -> No assert "split shares" in note assert "supplied seed bytes" in note assert "explicit identifier" in note + + +class _EntropyRPC: + """Fake Core that adds one HD key per addhdkey call.""" + + def __init__(self, fail: str = "", lost: str = "") -> None: + self.calls: list[tuple[tuple[str, ...], str | None, str | None]] = [] + self.keys, self.fail, self.lost, self.loaded = 0, fail, lost, set[str]() + + def __call__(self, *arguments: str, wallet: str | None = None, stdin: str | None = None) -> object: + self.calls.append((arguments, wallet, stdin)) + method = arguments[arguments[0] == "-named"] + if method in self.fail.split(): + raise BitcoinCoreError("Bitcoin Core rejected the requested wallet operation.") + if method == "createwallet": + open(arguments[2].removeprefix("wallet_name="), "w").close() + self.loaded.add(arguments[2].removeprefix("wallet_name=")) + if method == "unloadwallet": + if arguments[1] not in self.loaded: + raise BitcoinCoreError("Requested wallet does not exist or is not loaded.") + self.loaded.remove(arguments[1]) + if method == self.lost: + raise KeyboardInterrupt # Core did it, but the answer never arrived. + if method == "listwallets": + return sorted(self.loaded) + if method == "addhdkey": + self.keys += 1 + return {"xpub": f"xpub{self.keys}"} + keys = [{"xpub": f"xpub{number}", "xprv": f"xprv{number}"} for number in range(1, self.keys + 1)] + return keys if method == "gethdkeys" else None + + +def test_core_entropy_hashes_fresh_keys_from_a_wallet_removed_after_each_draw( + monkeypatch: pytest.MonkeyPatch, +) -> None: + rpc, told = _EntropyRPC(), list[str]() + monkeypatch.setattr(BitcoinCore, "_rpc", rpc) + entropy = CoreEntropy(BitcoinCore("bitcoin-cli", "main", 320000), told.append) + + 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], + ) + 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() + for number, os_bytes in zip((1, 2, 3), mixed) + ] + assert len(mixed) == 3 and data == (digests[0] + digests[1][:8], digests[2][:4]) + draw = ["createwallet", "walletpassphrase", "addhdkey", "gethdkeys"] + methods = [arguments[arguments[0] == "-named"] for arguments, _wallet, _stdin in rpc.calls] + assert methods == [ + *draw, + "addhdkey", + "gethdkeys", + "walletlock", + "unloadwallet", + *draw, + "walletlock", + "unloadwallet", + ] + created = [ + arguments[2].removeprefix("wallet_name=") + for arguments, _w, _s in rpc.calls + if "createwallet" in arguments + ] + assert [arguments[1] for arguments, _w, _s in rpc.calls if arguments[0] == "unloadwallet"] == created + assert len(set(created)) == 2 and not rpc.loaded + assert not any(os.path.exists(os.path.dirname(wallet)) for wallet in created) + assert len(told) == (sys.platform not in ("linux", "darwin")) # RAM folder, else one warning + assert "blank=true" in rpc.calls[0][0] and rpc.calls[0][2] == f"passphrase={entropy._passphrase}\n" + assert all(entropy._passphrase not in " ".join(arguments) for arguments, _w, _s in rpc.calls) + + +@pytest.mark.parametrize("failure", ("createwallet", "addhdkey", "walletlock")) +def test_core_entropy_failure_stops_creation_relocks_and_cleans_up( + monkeypatch: pytest.MonkeyPatch, failure: str +) -> None: + rpc = _EntropyRPC(fail=failure) + monkeypatch.setattr(BitcoinCore, "_rpc", rpc) + entropy = CoreEntropy(BitcoinCore("bitcoin-cli", "main", 320000), lambda _message: None) + + with pytest.raises(BitcoinCoreError, match="need Bitcoin Core's randomness") as stopped: + entropy(16) + assert ("could not create a wallet" in str(stopped.value)) == (failure == "createwallet") + methods = [arguments[arguments[0] == "-named"] for arguments, _w, _s in rpc.calls] + lock = ["unloadwallet", "listwallets"] if failure == "createwallet" else ["walletlock", "unloadwallet"] + assert methods[-2:] == lock and not rpc.loaded + assert not os.path.exists(os.path.dirname(rpc.calls[0][0][2].removeprefix("wallet_name="))) + + +@pytest.mark.parametrize("fail", ("unloadwallet", "addhdkey unloadwallet")) +def test_core_entropy_stops_creation_when_a_draws_wallet_stays( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path, fail: str +) -> None: + rpc, told = _EntropyRPC(fail=fail), list[str]() + monkeypatch.setattr(BitcoinCore, "_rpc", rpc) + monkeypatch.setattr(CoreEntropy, "_ram_folder", lambda _self: tempfile.mkdtemp(dir=tmp_path)) + entropy = CoreEntropy(BitcoinCore("bitcoin-cli", "main", 320000), told.append) + + with monkeypatch.context() as stuck, pytest.raises(BitcoinCoreError, match="creation stopped") as stopped: + stuck.setattr(shutil, "rmtree", lambda *_args, **_kwargs: None) + entropy(1) + folder = os.path.dirname(rpc.calls[0][0][2].removeprefix("wallet_name=")) + left = f"Bitcoin Core's temporary wallet was not removed. Restart Core to unload it. Delete or eject {folder}." + # A failed draw tells what it left behind; a finished one raises it, so the next card is never made. + assert told == ([left] if "addhdkey" in fail else []) and (left in str(stopped.value)) == (not told) + + +@pytest.mark.parametrize("lost", ("createwallet", "unloadwallet")) +def test_core_entropy_removes_a_wallet_whose_call_never_answered_then_stops( + monkeypatch: pytest.MonkeyPatch, lost: str +) -> None: + rpc = _EntropyRPC(lost=lost) + monkeypatch.setattr(BitcoinCore, "_rpc", rpc) + entropy = CoreEntropy(BitcoinCore("bitcoin-cli", "main", 320000), lambda _message: None) + + with pytest.raises(KeyboardInterrupt): + entropy(16) + assert not rpc.loaded + assert not os.path.exists(os.path.dirname(rpc.calls[0][0][2].removeprefix("wallet_name="))) diff --git a/tests/test_cli.py b/tests/test_cli.py index 57bf50d1..c3b6ab51 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -6,12 +6,14 @@ import io import json import re +import signal import subprocess import sys import sysconfig from collections.abc import Callable from dataclasses import dataclass from pathlib import Path +from secrets import token_bytes from typing import cast from unittest.mock import patch @@ -126,9 +128,24 @@ def initialize( _REAL_CONNECT = BitcoinCore.connect +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 __call__(self, length: int) -> bytes: + self.guarded &= signal.getsignal(signal.SIGTERM) is signal.default_int_handler + self.draws -= 1 + if self.failure and self.draws < 0: + raise BitcoinCoreError(self.failure) + return token_bytes(length) + + @pytest.fixture(autouse=True) def _offline_core(monkeypatch): monkeypatch.setattr("codex32.cli.BitcoinCore.connect", lambda *args, **kwargs: _FakeBitcoinCore()) + monkeypatch.setattr("codex32.cli.CoreEntropy", _FakeEntropy) _RECORDED_FINGERPRINT = importlib.import_module("codex32.cli")._recorded_fingerprint @@ -1549,21 +1566,44 @@ def test_confirmation_alignment_shows_only_entered_text( assert "".join(plain.replace("_", "").split()).lower() == observed.lower() -def test_interrupted_creation_marks_partial_cards_void() -> None: - stdout, stderr = _TTYOutput(), io.StringIO() +@pytest.mark.parametrize("stop", (KeyboardInterrupt, lambda *_: signal.raise_signal(signal.SIGTERM))) +def test_interrupted_creation_marks_partial_cards_void(stop: object) -> None: + stdout, stderr, entropy = _TTYOutput(), io.StringIO(), _FakeEntropy() + terminate = signal.getsignal(signal.SIGTERM) with ( patch.object(sys, "stdin", _TTYInput()), - patch("codex32.cli._confirm_card", side_effect=KeyboardInterrupt), + patch("codex32.cli._confirm_card", side_effect=stop), patch("codex32.cli.BitcoinCore.connect", return_value=_FakeBitcoinCore()), + patch("codex32.cli.CoreEntropy", return_value=entropy), contextlib.redirect_stdout(stdout), contextlib.redirect_stderr(stderr), ): status = ms_main(["create", "2", "--indices", "ac"]) - assert status == 130 + assert status == 130 and entropy.guarded and signal.getsignal(signal.SIGTERM) is terminate assert "Mark every card from this incomplete creation void" in stderr.getvalue() +@pytest.mark.parametrize("header", ("0", "2")) +def test_creation_stops_without_bitcoin_core_randomness(header: str) -> None: + entropy = _FakeEntropy(failure="Core supplied no randomness.") + with patch("codex32.cli.CoreEntropy", return_value=entropy): + result = _invoke_confirmed_create(["create", header]) + + assert result.exit_code == 1 and result.stdout == "" + assert "Core supplied no randomness." in result.stderr + + +def test_creation_stops_before_the_next_card_when_core_keeps_a_draws_wallet() -> None: + left = "Bitcoin Core's temporary wallet was not removed. Restart Core to unload it." + core, entropy = _FakeBitcoinCore(), _FakeEntropy(failure=left, draws=2) # the identifier, then card 1 + with patch("codex32.cli.CoreEntropy", return_value=entropy): + result = _invoke_confirmed_create(["create", "2", "--indices", "ac"], core=core) + + assert result.exit_code == 1 and core.imported is None + assert len(_output_artifacts(result)) == 1 and result.stderr.count(left) == 1 + + def test_interruption_after_confirmation_keeps_cards_valid() -> None: core = _FakeBitcoinCore() stdout, stderr = _TTYOutput(), io.StringIO() diff --git a/tools/bitcoin_core_regtest.py b/tools/bitcoin_core_regtest.py index 9e125c87..3fe346a9 100644 --- a/tools/bitcoin_core_regtest.py +++ b/tools/bitcoin_core_regtest.py @@ -12,11 +12,13 @@ import time from pathlib import Path from typing import Any +from unittest.mock import patch from _wallet_test_vectors import CORE_FINGERPRINTS -from codex32._bitcoin_core import BitcoinCore -from codex32.bip93 import parse_codex32 +from codex32._bitcoin_core import BitcoinCore, CoreEntropy +from codex32.bip93 import parse_codex32, recover_secret +from codex32.generation import CreationCeremony from codex32.profiles.ms32 import MasterSeed from codex32.wallet import master_xprv @@ -132,6 +134,29 @@ def rpc(*rpc_arguments: str, wallet: str | None = None, stdin: str | None = None for seed, expected_fingerprint in CORE_FINGERPRINTS.items(): if client.fingerprint_seed(seed) != expected_fingerprint: raise RuntimeError("Bitcoin Core fingerprint fixture mismatch") + + unloaded, core_rpc = list[tuple[str, bool]](), BitcoinCore._rpc + + def watched(core: BitcoinCore, *arguments: str, **options: Any) -> Any: + if arguments[:1] == ("unloadwallet",): + locked = rpc("getwalletinfo", wallet=arguments[1])["unlocked_until"] == 0 + unloaded.append((arguments[1], locked and bool(rpc("gethdkeys", wallet=arguments[1])))) + return core_rpc(core, *arguments, **options) + + entropy, shares = CoreEntropy(client, print), [] + with patch.object(BitcoinCore, "_rpc", watched): + ceremony = CreationCeremony.master_seed(threshold=2, indices="ac", entropy=entropy) + for _ in range(2): + shares.append(ceremony.next_share()) + ceremony.confirm(shares[-1].text) + if len(unloaded) < 3 or not all(locked for _wallet, locked in unloaded): + raise RuntimeError("each draw did not take fresh keys from a relocked encrypted wallet") + if ( + any(os.path.exists(wallet) or wallet in rpc("listwallets") for wallet, _locked in unloaded) + or entropy._folder + or recover_secret(shares) != ceremony.finish() + ): + raise RuntimeError("a temporary wallet survived or the Core-random shares did not recover") expected_fingerprint = CORE_FINGERPRINTS[secret.seed_bytes] answers = iter(("yes",)) if (