Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 16 additions & 15 deletions docs/developer/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Comment thread
BenWestgate marked this conversation as resolved.

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
Expand All @@ -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
Expand All @@ -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.
Expand Down Expand Up @@ -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.

Expand Down
6 changes: 4 additions & 2 deletions docs/security/invariants.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
16 changes: 9 additions & 7 deletions docs/security/model.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -116,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 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
Expand All @@ -132,9 +134,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
candidates whose generation padding is invalid. Master-seed creation also
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
multiplication. The original ceremony result, not re-entered text, remains the
Expand Down
5 changes: 5 additions & 0 deletions src/codex32/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand Down Expand Up @@ -344,6 +345,7 @@ def _generated_secret(
byte_length=byte_length,
identifier=identifier,
fingerprint=fingerprint,
entropy=token_bytes,
)


Expand Down Expand Up @@ -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)
Expand All @@ -527,6 +530,7 @@ def _create(
identifier=identifier,
share_count=shares,
indices=indices,
entropy=token_bytes,
)
else:
ceremony = CreationCeremony.master_seed(
Expand All @@ -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
Expand Down
66 changes: 51 additions & 15 deletions src/codex32/generation.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -40,6 +39,7 @@
)

ORDINARY_INDICES = tuple(IDX_SORT[1:])
Entropy = Callable[[int], bytes]


@dataclass(frozen=True, slots=True)
Expand Down Expand Up @@ -88,24 +88,52 @@ 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:
if isinstance(share_count, bool) or not isinstance(share_count, int):
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:
raise InvalidShareSelection(f"at least {threshold} indices are required")
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:
Expand All @@ -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

Expand All @@ -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:
Expand All @@ -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
Expand All @@ -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
Expand All @@ -223,18 +254,20 @@ 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,
share_count,
indices,
identifier,
None,
entropy,
)

@classmethod
Expand All @@ -246,27 +279,29 @@ 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,
share_count,
indices,
identifier,
secret,
entropy,
)

def _candidate_is_accepted(self, secret: MasterSeed) -> bool:
Expand All @@ -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,
Expand Down
Loading
Loading