diff --git a/docs/developer/api.md b/docs/developer/api.md index 51c9fb5..c9a4228 100644 --- a/docs/developer/api.md +++ b/docs/developer/api.md @@ -37,7 +37,8 @@ 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` | +| masks, identifiers, output indices from caller entropy | `generation.py` | `test_generation.py` | +| Bitcoin Core randomness for `ms32 create` | `_bitcoin_core.py` | `test_bitcoin_core.py`, regtest fixture | | 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 +68,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` alone turns entropy into seeds, shares, identifiers, and + indices; every byte comes from the caller's `entropy` source. It 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,8 +155,10 @@ 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 turns entropy into secrets. It generates +and splits BIP93 master seeds only. Every random byte comes from the caller's +`entropy` callable, which must return exactly the requested number of bytes; +the CLI passes Bitcoin Core's (see [Bitcoin Core randomness](#bitcoin-core-randomness)). 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 +174,20 @@ 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 +`entropy`. Supplied seed bytes with an `identifier` need no entropy. 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,10 +214,10 @@ 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 +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 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. @@ -258,9 +266,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 draws +indices from the 31 ordinary ones by unbiased 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. @@ -613,7 +621,7 @@ Immediately before adding the key, every target property is checked again. The original `CreationCeremony.finish()` result or validated recovered master seed supplies the root xprv. Confirmation text is never reparsed into this source. The key is sent only through `bitcoin-cli -stdin`; raw Core errors are suppressed, -and no passphrase interface exists. +and codex32 never asks for or handles the operator's wallet passphrase. For wallet restoration and `ms32 create --existing`, callers make the wallet-record decision before initialization. `BitcoinCore.initialize()` calls `verify_identity()` @@ -637,7 +645,24 @@ the BIP32 fingerprint. The Core calls are fixed: `getnetworkinfo`, `getblockchaininfo`, `listwallets`, `getwalletinfo`, `listdescriptors`, `getdescriptorinfo`, `deriveaddresses`, `validateaddress`, `addhdkey`, -`createwalletdescriptor`, `importdescriptors`, and `walletlock`. +`createwalletdescriptor`, `importdescriptors`, and `walletlock`, plus +`createwallet`, `walletpassphrase`, `gethdkeys`, and `unloadwallet` for the +temporary randomness wallet. + +### Bitcoin Core randomness + +`ms32 create` takes every random byte from Bitcoin Core, whose +`GetStrongRandBytes` mixes the OS generator with CPU RDRAND/RDSEED where +available, timestamps, and accumulated state. On the first draw `CoreEntropy` +creates a blank wallet in a new folder under `/dev/shm`, or the system +temporary folder where that does not exist. The wallet is encrypted from birth +with a 256-bit passphrase from Python `secrets` that only `bitcoin-cli -stdin` +receives. Each draw unlocks it, adds one fresh HD key per 32 bytes with +`addhdkey`, reads it with `gethdkeys private=true`, uses the SHA-256 of the +xprv, and relocks. Creation unloads and deletes the wallet when it ends, fails, +or is interrupted. If Core cannot create the wallet or draw from it, `create` +stops; there is no fallback. A killed process leaves the encrypted folder until +it is deleted, which under `/dev/shm` happens at the latest on shutdown. Bitcoin Core alone creates wallets, selects encryption, handles passphrases, stores keys, and provides normal wallet behavior. diff --git a/docs/security/invariants.md b/docs/security/invariants.md index 42adce5..a86397f 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 entropy call for each random initial share, + gated by confirmation. `ms32 create` draws only from Bitcoin Core, 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, and the temporary + randomness wallet is relocked after each draw and deleted after creation. 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 6eabc34..89aa5f1 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 | Bitcoin Core's random generator, which mixes the operating-system cryptographically secure random-number generator (OS-CSPRNG) with other sources, supplies every random byte of `ms32 create`; the Python API trusts its caller's `entropy` source. 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. It + protects only an encrypted wallet that is deleted after creation, and outside + `/dev/shm` that wallet briefly exists on 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,7 +119,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 entropy source, Bitcoin Core 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 +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 takes 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,11 @@ 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. It creates only the temporary randomness wallet. Every call uses loopback and the selected chain. | +| Randomness | `ms32 create` draws only from fresh Core HD keys in a temporary wallet that is encrypted from birth, relocked after each draw, and unloaded and deleted on every exit path. If Core cannot supply randomness, creation stops. | | 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; the temporary wallet's own passphrase travels only through stdin. Raw Core errors are suppressed. | | 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 b875ab0..8472d07 100644 --- a/docs/user/guide.md +++ b/docs/user/guide.md @@ -73,7 +73,10 @@ 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 +Run Bitcoin Core before starting. `create` takes all of its randomness from +Core and stops if Core cannot supply it. While cards are being made, Bitcoin-Qt +may list a temporary encrypted wallet in a `codex32-` folder; codex32 deletes it +before wallet setup. 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. @@ -156,7 +159,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 606594f..570c187 100644 --- a/src/codex32/_bitcoin_core.py +++ b/src/codex32/_bitcoin_core.py @@ -2,10 +2,14 @@ import hashlib import json +import os +import secrets import shutil import string import subprocess +import tempfile from collections.abc import Callable +from contextlib import suppress from dataclasses import dataclass from time import sleep from typing import Literal @@ -430,3 +434,64 @@ def initialize( if waited: tell("") return name + + +class CoreEntropy: + """Draw randomness from fresh Bitcoin Core HD keys in a temporary encrypted wallet.""" + + def __init__(self, core: BitcoinCore) -> None: + self._core, self._folder, self._passphrase = core, "", secrets.token_hex(32) + + def __call__(self, length: int) -> bytes: + try: + return self._draw(length) + except BitcoinCoreError as error: + raise BitcoinCoreError( + f"{error} New cards take their randomness only from Bitcoin Core, 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.") + data += hashlib.sha256(xprvs[0].encode()).digest() + finally: + self._core._rpc("walletlock", wallet=wallet) + return data[:length] + + def _wallet(self) -> str: + if not self._folder: + ram = "/dev/shm" + self._folder = tempfile.mkdtemp(prefix="codex32-", dir=ram if os.path.isdir(ram) else None) + self._core._rpc( + "-named", + "createwallet", + f"wallet_name={os.path.join(self._folder, 'w')}", + "blank=true", + "load_on_startup=false", + stdin=f"passphrase={self._passphrase}\n", + ) + return os.path.join(self._folder, "w") + + def close(self) -> None: + """Unload and delete the temporary wallet, if one was created.""" + folder, self._folder = self._folder, "" + if not folder: + return + with suppress(BitcoinCoreError): + self._core._rpc("unloadwallet", os.path.join(folder, "w")) + shutil.rmtree(folder, ignore_errors=True) + if os.path.exists(folder): + raise BitcoinCoreError(f"Delete the temporary Bitcoin Core wallet folder {folder}.") diff --git a/src/codex32/cli.py b/src/codex32/cli.py index 1ffd1f1..155b005 100644 --- a/src/codex32/cli.py +++ b/src/codex32/cli.py @@ -12,6 +12,7 @@ NO_RECORD_WARNING, BitcoinCore, BitcoinCoreError, + CoreEntropy, FingerprintMismatch, identifier_note, identifier_origin, @@ -338,12 +339,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=entropy, ) @@ -488,6 +491,7 @@ 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) try: if threshold == 0: if isinstance(source, MasterSeed): @@ -497,7 +501,8 @@ def _create( ) secret = source else: - secret = _generated_secret(source, byte_length, identifier, core.fingerprint_seed) + secret = _generated_secret(source, byte_length, identifier, core.fingerprint_seed, entropy) + entropy.close() _emit(secret, False, fingerprint=None if existing else core.fingerprint) if sys.stdin.isatty(): _confirm_card(secret) @@ -515,15 +520,17 @@ def _create( identifier=identifier, share_count=shares, indices=indices, + 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=entropy, ) else: ceremony = CreationCeremony.master_seed( @@ -532,18 +539,21 @@ def _create( identifier=identifier, share_count=shares, indices=indices, + entropy=entropy, ) + 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: + entropy.close() assert isinstance(finished, MasterSeed) if core is not None: return _initialize_wallet( diff --git a/src/codex32/generation.py b/src/codex32/generation.py index 7ad8c2e..21640b4 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,8 @@ 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_identifier(entropy: Entropy) -> str: + return _u5_to_chars(tuple(value & 31 for value in _draw(entropy, 4))) def _fingerprint_identifier(fingerprint: bytes) -> str: @@ -116,8 +140,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)) +def _random_share(entropy: Entropy, threshold: int, identifier: str, index: str, length: int) -> Share: + symbols = tuple(value & 31 for value in _draw(entropy, length)) artifact = _from_parts(Profile.MS, Header(threshold, identifier, index), symbols) assert isinstance(artifact, Share) return artifact @@ -144,21 +168,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 +202,7 @@ class CreationCeremony: _indices: tuple[str, ...] _payload_length: int _pending: Share | None + _entropy: Entropy _position: int _secret: MasterSeed | None _set_identifier: str @@ -196,11 +223,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 +251,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 +264,7 @@ def master_seed( indices, identifier, None, + entropy, ) @classmethod @@ -246,20 +276,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 +298,7 @@ def from_secret( indices, identifier, secret, + entropy, ) def _candidate_is_accepted(self, secret: MasterSeed) -> bool: @@ -284,6 +316,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_bitcoin_core.py b/tests/test_bitcoin_core.py index 838c542..dc78862 100644 --- a/tests/test_bitcoin_core.py +++ b/tests/test_bitcoin_core.py @@ -4,6 +4,7 @@ import hashlib import json +import os import re import subprocess from dataclasses import dataclass, field @@ -13,6 +14,7 @@ from codex32._bitcoin_core import ( BitcoinCore, BitcoinCoreError, + CoreEntropy, FingerprintMismatch, identifier_note, identifier_origin, @@ -858,3 +860,72 @@ 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 to a temporary wallet folder.""" + + def __init__(self, fail: str = "") -> None: + self.calls: list[tuple[tuple[str, ...], str | None, str | None]] = [] + self.keys: list[dict[str, str]] = [] + self.fail = fail + + def __call__(self, *arguments: str, wallet: str | None = None, stdin: str | None = None) -> object: + self.calls.append((arguments, wallet, stdin)) + method = next(argument for argument in arguments if not argument.startswith("-")) + if method == self.fail: + raise BitcoinCoreError("Bitcoin Core rejected the requested wallet operation.") + if method == "createwallet": + path = arguments[2].removeprefix("wallet_name=") + assert arguments[:2] == ("-named", "createwallet") and os.path.isabs(path) + open(path, "w").close() + if method == "addhdkey": + self.keys.append({"xpub": f"xpub{len(self.keys)}", "xprv": f"xprv{len(self.keys)}"}) + return {"xpub": self.keys[-1]["xpub"]} + return list(self.keys) if method == "gethdkeys" else None + + +def test_core_entropy_hashes_fresh_core_keys_from_a_deleted_encrypted_wallet( + monkeypatch: pytest.MonkeyPatch, +) -> None: + rpc = _EntropyRPC() + monkeypatch.setattr(BitcoinCore, "_rpc", rpc) + entropy = CoreEntropy(BitcoinCore("bitcoin-cli", "main", 320000)) + + first, second = entropy(40), entropy(4) + digests = [hashlib.sha256(f"xprv{number}".encode()).digest() for number in range(3)] + assert (first, second) == (digests[0] + digests[1][:8], digests[2][:4]) + methods = [next(a for a in arguments if not a.startswith("-")) for arguments, _w, _s in rpc.calls] + draw = ["walletpassphrase", "addhdkey", "gethdkeys"] + assert methods == ["createwallet", *draw, "addhdkey", "gethdkeys", "walletlock", *draw, "walletlock"] + wallet = rpc.calls[0][0][2].removeprefix("wallet_name=") + folder = os.path.dirname(wallet) + assert os.path.isabs(wallet) and os.path.exists(wallet) + assert folder.startswith("/dev/shm/") or not os.path.isdir("/dev/shm") + 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) + assert all(called_wallet == wallet for _a, called_wallet, _s in rpc.calls[1:]) + + entropy.close() + assert rpc.calls[-1] == (("unloadwallet", wallet), None, None) and not os.path.exists(folder) + calls = len(rpc.calls) + entropy.close() + assert len(rpc.calls) == calls + + +@pytest.mark.parametrize("failure", ("createwallet", "walletpassphrase", "addhdkey", "gethdkeys")) +def test_core_entropy_failure_stops_creation_and_relocks( + monkeypatch: pytest.MonkeyPatch, failure: str +) -> None: + rpc = _EntropyRPC(fail=failure) + monkeypatch.setattr(BitcoinCore, "_rpc", rpc) + entropy = CoreEntropy(BitcoinCore("bitcoin-cli", "main", 320000)) + + with pytest.raises(BitcoinCoreError, match="randomness only from Bitcoin Core"): + entropy(16) + if failure in ("addhdkey", "gethdkeys"): + assert rpc.calls[-1][0] == ("walletlock",) + folder = os.path.dirname(rpc.calls[0][0][2].removeprefix("wallet_name=")) + rpc.fail = "unloadwallet" + entropy.close() + assert not os.path.exists(folder) diff --git a/tests/test_cli.py b/tests/test_cli.py index 07124c1..77d32ec 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -11,6 +11,7 @@ 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 @@ -31,7 +32,7 @@ parse_codex32, recover_secret, ) -from codex32._bitcoin_core import BitcoinCore +from codex32._bitcoin_core import BitcoinCore, BitcoinCoreError from codex32.bech32 import _chars_to_u5, bech32_encode from codex32.checksums import _CODEX32, _CODEX32_LONG from codex32.cli import main, ms_main @@ -122,9 +123,26 @@ def initialize( return "test-wallet" +class _FakeEntropy: + """Stand in for Bitcoin Core's temporary randomness wallet.""" + + def __init__(self, _core: object = None, failure: str = "") -> None: + self.closed, self.failure = False, failure + + def __call__(self, length: int) -> bytes: + assert not self.closed + if self.failure: + raise BitcoinCoreError(self.failure) + return token_bytes(length) + + def close(self) -> None: + self.closed = True + + @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 @@ -1489,20 +1507,31 @@ def test_confirmation_alignment_shows_only_entered_text( def test_interrupted_creation_marks_partial_cards_void() -> None: - stdout, stderr = _TTYOutput(), io.StringIO() + stdout, stderr, entropy = _TTYOutput(), io.StringIO(), _FakeEntropy() with ( patch.object(sys, "stdin", _TTYInput()), patch("codex32.cli._confirm_card", side_effect=KeyboardInterrupt), 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.closed 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 entropy.closed and result.stdout == "" + assert "Core supplied no randomness." in result.stderr + + def test_interruption_after_confirmation_keeps_cards_valid() -> None: core = _FakeBitcoinCore() stdout, stderr = _TTYOutput(), io.StringIO() diff --git a/tests/test_generation.py b/tests/test_generation.py index 9d20ff4..3c2a957 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,24 +230,21 @@ 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] @@ -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 70106c5..44e6933 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/bitcoin_core_regtest.py b/tools/bitcoin_core_regtest.py index 9e125c8..ab8e583 100644 --- a/tools/bitcoin_core_regtest.py +++ b/tools/bitcoin_core_regtest.py @@ -15,8 +15,9 @@ 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 +133,25 @@ 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") + + entropy = CoreEntropy(client) + ceremony = CreationCeremony.master_seed(threshold=2, indices="ac", entropy=entropy) + shares = [] + for _ in range(2): + shares.append(ceremony.next_share()) + if not ceremony.confirm(shares[-1].text).accepted: + raise RuntimeError("a Core-random share failed confirmation") + folder = entropy._folder + temporary_wallet = os.path.join(folder, "w") + if rpc("getwalletinfo", wallet=temporary_wallet)["unlocked_until"] != 0: + raise RuntimeError("the temporary randomness wallet was not encrypted and relocked") + if len(rpc("gethdkeys", wallet=temporary_wallet)) < 3: + raise RuntimeError("Core did not supply a fresh key for each draw") + entropy.close() + if os.path.exists(folder) or temporary_wallet in rpc("listwallets"): + raise RuntimeError("the temporary randomness wallet was not deleted") + if recover_secret(shares) != ceremony.finish(): + raise RuntimeError("Core-random shares did not recover their seed") expected_fingerprint = CORE_FINGERPRINTS[secret.seed_bytes] answers = iter(("yes",)) if ( diff --git a/tools/verify_installed_wheel.py b/tools/verify_installed_wheel.py index 0a4fbef..f20e919 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()