From 63c50a6e1edf1d1a554d59bda2d4284ed5dce4b5 Mon Sep 17 00:00:00 2001 From: Ben Westgate Date: Sat, 24 Oct 2026 06:14:32 -0500 Subject: [PATCH 1/4] 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 f28a7d21112b54a34c5f39ac1896ffae4772824d Mon Sep 17 00:00:00 2001 From: Ben Westgate Date: Mon, 26 Oct 2026 04:08:48 -0500 Subject: [PATCH 2/4] security: Specify entropy caller obligations --- docs/security/invariants.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/docs/security/invariants.md b/docs/security/invariants.md index 42adce52..4c424ccf 100644 --- a/docs/security/invariants.md +++ b/docs/security/invariants.md @@ -8,8 +8,10 @@ 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 draw from a cryptographically secure + entropy source for each random initial share, gated by confirmation. The CLI + uses the OS-CSPRNG; Python API callers must provide a trusted source. 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 From 13dfcb1ed1cd1a093cc07b2d3c1fa2aa104e4386 Mon Sep 17 00:00:00 2001 From: Ben Westgate Date: Mon, 26 Oct 2026 04:08:50 -0500 Subject: [PATCH 3/4] security: Document entropy trust boundary --- docs/security/model.md | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/docs/security/model.md b/docs/security/model.md index 6eabc340..39f7c709 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 | CLI generation trusts the OS cryptographically secure random-number generator (OS-CSPRNG). Python API generation trusts the caller's `entropy(length)` source to supply unpredictable cryptographic bytes; type and length checks cannot establish entropy quality. 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,9 @@ 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 - guarantee new physical entropy between calls. +- Separate entropy-source calls offer fresh draws but cannot guarantee new + physical entropy or independence between calls. API callers must use a + cryptographically secure source; the CLI uses the OS-CSPRNG. - 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,7 +117,7 @@ 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 the supplied entropy source (the OS-CSPRNG in the CLI). 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 complete Bitcoin master seeds are confirmed unchanged and initialize the wallet @@ -132,8 +133,9 @@ 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 requires caller-provided cryptographic entropy for random draws and +accepts no caller-supplied 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 From 4be4063395873f786b7b69b9917e3008a4c71c7a Mon Sep 17 00:00:00 2001 From: Ben Westgate Date: Mon, 26 Oct 2026 04:09:21 -0500 Subject: [PATCH 4/4] docs: Wrap entropy contract wording --- docs/security/model.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/security/model.md b/docs/security/model.md index 39f7c709..53381c9d 100644 --- a/docs/security/model.md +++ b/docs/security/model.md @@ -117,7 +117,8 @@ complete explicit `ms1` string. ## Creation, sharing, and recovery controls Fresh shared creation generates *k* random initial shares. Each uses a separate -full-payload request to the supplied entropy source (the OS-CSPRNG in the CLI). The current share string must be re-entered +full-payload request to the supplied entropy source (the OS-CSPRNG in the +CLI). 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 complete Bitcoin master seeds are confirmed unchanged and initialize the wallet @@ -135,8 +136,7 @@ Confirmation shows that the operator can produce the correct recovery string dur The API requires caller-provided cryptographic entropy for random draws and accepts no caller-supplied padding values, partial bases, or resumable ceremony -state. Fresh creation rejects final-share -candidates whose generation padding is invalid. Master-seed creation also +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 multiplication. The original ceremony result, not re-entered text, remains the