diff --git a/electrum/__init__.py b/electrum/__init__.py index e94ac78ac9d9..58323075bbe8 100644 --- a/electrum/__init__.py +++ b/electrum/__init__.py @@ -29,6 +29,7 @@ class GuiImportError(ImportError): from .plugin import BasePlugin from .commands import Commands, known_commands from .logging import get_logger +from . import crandom # this initializes our RNG state and checks os.urandom is not trivially broken __version__ = ELECTRUM_VERSION @@ -45,11 +46,3 @@ class GuiImportError(ImportError): pass else: raise ImportError("Running with asserts disabled. Refusing to continue. Exiting...") - - -# Check that os.urandom works -import zlib -length = len(zlib.compress(os.urandom(1000))) -if length <= 900: - raise ImportError("Broken PRNG. Refusing to continue. Exiting...") - diff --git a/electrum/commands.py b/electrum/commands.py index 0cdf45769221..079f2fb46197 100644 --- a/electrum/commands.py +++ b/electrum/commands.py @@ -80,6 +80,7 @@ from . import crypto from . import constants from . import descriptor +from . import crandom if TYPE_CHECKING: from .network import Network @@ -2311,7 +2312,7 @@ async def get_blinded_path_via(self, node_id: str, dummy_hops: int = 0, wallet: assert peer, 'node_id not a peer' path = [pubkey, wallet.lnworker.node_keypair.pubkey] - session_key = os.urandom(32) + session_key = crandom.get_rand_bytes(32) blinded_path = create_blinded_path(session_key, path=path, final_recipient_data={}, dummy_hops=dummy_hops) with io.BytesIO() as blinded_path_fd: diff --git a/electrum/crandom.py b/electrum/crandom.py new file mode 100644 index 000000000000..7f83a03b59b1 --- /dev/null +++ b/electrum/crandom.py @@ -0,0 +1,153 @@ +# Copyright (C) 2026 The Electrum developers +# Distributed under the MIT software license, see the accompanying +# file LICENCE or http://www.opensource.org/licenses/mit-license.php +# +# Cryptographically secure RNG. +# +# This mostly uses os.urandom, and extreme care should be taken here not to make things worse +# compared to just directly using that. +# We check os.urandom is not trivially broken (passes the zlib test), in which case we panic and runtime exit. +# However, os.urandom could still be subtly "broken" (undetected by us) and produce bad quality output. +# That's the motivation of all this code. We expect os.urandom to work well, BUT if it undetectably does not, +# hopefully mixing in other sources of entropy mitigates the situation somewhat. +# +# inspired by https://github.com/bitcoin/bitcoin/blob/67efced1fc83a0b7215cc1513e7c4754fee0f12f/src/random.h#L25 +# +# The logic is split across two modules: crandom.py and crandom_env.py. +# - The core sensitive logic (RNG mixing, extracting random bytes) is in this module (crandom.py), +# which is absolutely security critical and is kept concise to ease review. +# - crandom_env.py contains secondary sources of entropy and potentially platform-specific code. +# Even if all the entropy sources listed in crandom_env.py are broken, assuming os.urandom() +# produces high quality random, this module should never produce low-quality random output. + +import hashlib +import os +import threading +from typing import Callable +import zlib + +from . import crandom_env + + +# Check that os.urandom works +length = len(zlib.compress(os.urandom(1000))) +if length <= 900: + raise ImportError("Broken PRNG. Refusing to continue. Exiting...") + + +def sha512(x: bytes) -> bytes: + assert isinstance(x, bytes) + return hashlib.sha512(x).digest() + + +CRANDOM_FEEDER_API = Callable[[bytes | str | int], None] + + +class RNGState: + + def __init__(self): + self.lock = threading.Lock() + self._state = os.urandom(32) # secret! access needs lock. + # gather ghetto-entropy: + self.rand_add_refresh() # clock + crandom_env.rand_add_static_env(self.feed_entropy) + self.rand_add_refresh() # clock again + + def rand_add_refresh(self) -> None: + """Gather dynamic environment data that changes over time and mix it in. + This includes a high-precision clock. + + Never raises. + """ + crandom_env.rand_add_dynamic_env(self.feed_entropy) + + def feed_entropy(self, data: bytes | str | int) -> None: + """Mix in some data into our internal RNG state, in hopes of increasing entropy. + + We MUST be robust for given 'data' not to contain any randomness, it could even be static. + Assuming our internal hash function is cryptographically secure, our internal state + MUST not be left with less entropy than before the call. + + Never raises (assuming input type-checks). + Matches CRANDOM_FEEDER_API. + """ + if not data: + return + if isinstance(data, int): + data = hex(data) + if isinstance(data, str): + # we must not raise UnicodeError, hence "backslashreplace" + data = data.encode("utf-8", errors='backslashreplace') + with self.lock: + self._state = sha512(data + self._state)[0:32] + assert len(self._state) == 32 + + def _mix_extract(self) -> bytes: + """Return 32 bytes of secure randomness. + + Mix in some new entropy from os.urandom first, and then extract 32 bytes. + When mixing in new entropy, H = SHA512(new_entropy || old_rng_state) is computed, and + the first 32 bytes of H are produced as output, while the last 32 bytes + become the new RNG state. + + Never raises. + """ + with self.lock: + fresh_entropy = os.urandom(32) + h = sha512(fresh_entropy + self._state) + out, self._state = h[0:32], h[32:64] + assert len(out) == 32 + assert len(self._state) == 32 + return out + + def get_rand_bytes(self, nbytes: int) -> bytes: + """Returns uniformly distributed bytes, of length nbytes. + + Never raises. + """ + assert nbytes >= 0, nbytes + out = b"" + while len(out) < nbytes: + out += self._mix_extract() + return out[:nbytes] + + def _get_rand_bits(self, nbits: int) -> int: + """Return a uniformly distributed int in the range [0, 2**nbits). + + Never raises. + """ + assert nbits >= 0, nbits + nbytes = nbits // 8 + (1 if nbits % 8 else 0) + rb = self.get_rand_bytes(nbytes) + ri = int.from_bytes(rb, byteorder="big", signed=False) + # strip excess bits (we got up to 7 more than we asked for) + extra_bits = 8 * nbytes - nbits + assert 0 <= extra_bits < 8 + ri = ri >> extra_bits + return ri + + def get_rand_below(self, upper_bound: int) -> int: + """Return a uniformly distributed int in the range [0, upper_bound). + + Never raises. + """ + assert upper_bound > 0, upper_bound + nbits = upper_bound.bit_length() + ri = upper_bound + 1 + # Keep generating random ints until we get one inside the requested range. + # On average, we expect around 2 iterations. + while ri >= upper_bound: + ri = self._get_rand_bits(nbits) + return ri + + +_rng = RNGState() + + +######################################## +# External API (thread-safe): + +get_rand_bytes = _rng.get_rand_bytes +get_rand_below = _rng.get_rand_below +feed_entropy = _rng.feed_entropy +rand_add_refresh = _rng.rand_add_refresh diff --git a/electrum/crandom_env.py b/electrum/crandom_env.py new file mode 100644 index 000000000000..f669c1bfed36 --- /dev/null +++ b/electrum/crandom_env.py @@ -0,0 +1,91 @@ +# Copyright (C) 2026 The Electrum developers +# Distributed under the MIT software license, see the accompanying +# file LICENCE or http://www.opensource.org/licenses/mit-license.php +# +# This module is a companion to crandom.py and is only intended to be accessed from there. + +import os +import platform +import socket +import ssl +import sys +import threading +import time +from typing import TYPE_CHECKING + +from .logging import get_logger + +if TYPE_CHECKING: + from .crandom import CRANDOM_FEEDER_API + + +_logger = get_logger(__name__) + + +def rand_add_static_env(feed: 'CRANDOM_FEEDER_API') -> None: + """Gather non-cryptographic environment data that does not change over time + and feed it into feed(). + """ + # os + feed(str(os.environ)) + feed(getattr(os, "ctermid", lambda: "")()) + feed(os.getcwd()) + feed(str(os.get_exec_path())) + feed(str(os.getgroups())) + try: + feed(os.getlogin()) + except (AttributeError, OSError): + pass + feed(getattr(os, "getpgrp", lambda: "")()) + feed(os.getpid()) + feed(getattr(os, "getppid", lambda: "")()) + feed(str(getattr(os, "getresuid", lambda: "")())) + feed(str(getattr(os, "getresgid", lambda: "")())) + feed(str(getattr(os, "uname", lambda: "")())) + # timezone + feed(time.timezone) + feed(str(time.tzname)) + # system locale + import locale + feed(str(locale.getlocale())) + # mac address + from uuid import getnode as get_mac_address + feed(get_mac_address()) + # hostname + feed(getattr(socket, "gethostname", lambda: "")()) + # threading + feed(getattr(threading, "get_native_id", lambda: "")()) + feed(str(threading.enumerate())) + # platform + from .logging import describe_os_version + feed(sys.version) + feed(platform.platform()) + feed(describe_os_version()) + # version of electrum + from . import ELECTRUM_VERSION + from .logging import get_git_version + feed(ELECTRUM_VERSION) + feed(get_git_version() or "") + # path to this file + feed(__file__) + # memory locations + feed(id(__file__)) + feed(id(id)) + feed(id(os)) + feed(id(feed)) + feed(id(ELECTRUM_VERSION)) + feed(id(_logger)) + feed(id("longish_string_literal")) + feed(id(0)) + +def rand_add_dynamic_env(feed: 'CRANDOM_FEEDER_API') -> None: + """Gather non-cryptographic environment data that changes over time and feed it into feed().""" + # time + feed(time.time_ns()) + feed(time.process_time_ns()) + feed(time.perf_counter_ns()) + # openssl + try: + feed(ssl.RAND_bytes(32)) + except ssl.SSLError as e: + _logger.info(f"failed to get randomness from ssl: {e!r}") diff --git a/electrum/crypto.py b/electrum/crypto.py index ad46e38b3783..70023473c02b 100644 --- a/electrum/crypto.py +++ b/electrum/crypto.py @@ -36,6 +36,7 @@ from .util import assert_bytes, InvalidPassword, to_bytes, to_string, WalletFileException, versiontuple from .i18n import _ from .logging import get_logger +from . import crandom _logger = get_logger(__name__) @@ -175,7 +176,7 @@ def aes_decrypt_with_iv(key: bytes, iv: bytes, data: bytes) -> bytes: def EncodeAES_bytes(secret: bytes, msg: bytes) -> bytes: assert_bytes(msg) - iv = bytes(os.urandom(16)) + iv = crandom.get_rand_bytes(16) ct = aes_encrypt_with_iv(secret, iv, msg) return iv + ct diff --git a/electrum/gui/__init__.py b/electrum/gui/__init__.py index dfd309998928..5854f28dc827 100644 --- a/electrum/gui/__init__.py +++ b/electrum/gui/__init__.py @@ -6,6 +6,9 @@ from typing import TYPE_CHECKING, Mapping, Optional +from electrum import crandom +from electrum.crandom import CRANDOM_FEEDER_API + if TYPE_CHECKING: from . import qt from electrum.simple_config import SimpleConfig @@ -20,7 +23,13 @@ def __init__(self, *, config: 'SimpleConfig', daemon: 'Daemon', plugins: 'Plugin self.plugins = plugins def main(self) -> None: - raise NotImplementedError() + """Main entry point to GUI. Normally this launches a GUI event loop and 'blocks' this thread. + The application will start to gracefully exit after this returns. + """ + # Feed clock into crandom (again). This measures how long it took to create the GUI object. + crandom.rand_add_refresh() + # collect some GUI state as well: + self.rand_add_gui_static_env(crandom.feed_entropy) def stop(self) -> None: """Stops the GUI. @@ -31,3 +40,9 @@ def stop(self) -> None: @classmethod def version_info(cls) -> Mapping[str, Optional[str]]: return {} + + def rand_add_gui_static_env(self, feed: CRANDOM_FEEDER_API) -> None: + """Gather non-cryptographic environment data, specific to the GUI, and feed that into crandom. + Never raises. + """ + pass diff --git a/electrum/gui/qml/__init__.py b/electrum/gui/qml/__init__.py index 8f7b6689d000..62539d8800f9 100644 --- a/electrum/gui/qml/__init__.py +++ b/electrum/gui/qml/__init__.py @@ -92,6 +92,7 @@ def close(self): self.app.quit() def main(self): + BaseElectrumGui.main(self) if not self.app._valid: return diff --git a/electrum/gui/qml/qebiometrics.py b/electrum/gui/qml/qebiometrics.py index b2d896bd17b5..18e2fcf9afc5 100644 --- a/electrum/gui/qml/qebiometrics.py +++ b/electrum/gui/qml/qebiometrics.py @@ -1,10 +1,10 @@ import os -import secrets from enum import Enum from typing import Optional, TYPE_CHECKING from PyQt6.QtCore import QObject, pyqtSignal, pyqtSlot, pyqtProperty +from electrum import crandom from electrum.i18n import _ from electrum.logging import get_logger from electrum.base_crash_reporter import send_exception_to_crash_reporter @@ -81,7 +81,7 @@ def enable(self, unified_wallet_password: str): The encryption key for the wrap_key is stored in the AndroidKeyStore. This way the wallet password doesn't have to leave the process. """ - wrap_key, iv = secrets.token_bytes(32), secrets.token_bytes(16) + wrap_key, iv = crandom.get_rand_bytes(32), crandom.get_rand_bytes(16) wrapped_wallet_password = aes_encrypt_with_iv( key=wrap_key, iv=iv, diff --git a/electrum/gui/qt/__init__.py b/electrum/gui/qt/__init__.py index 32ac61d18cbb..b0348264d0a1 100644 --- a/electrum/gui/qt/__init__.py +++ b/electrum/gui/qt/__init__.py @@ -79,6 +79,7 @@ from electrum.keystore import load_keystore from electrum.bip32 import is_xprv from electrum import constants +from electrum import crandom from electrum.gui.common_qt.i18n import ElectrumTranslator from electrum.gui.messages import TERMS_OF_USE_LATEST_VERSION @@ -567,6 +568,7 @@ def init_network(self): self.daemon.start_network() def main(self): + BaseElectrumGui.main(self) # setup Ctrl-C handling and tear-down code first, so that user can easily exit whenever self.app.setQuitOnLastWindowClosed(False) # so _we_ can decide whether to quit self.app.lastWindowClosed.connect(self._maybe_quit_if_no_windows_open) @@ -618,6 +620,16 @@ def do_copy(self, text: str, *, title: str = None) -> None: # tooltip cannot be displayed immediately when called from a menu; wait 200ms QTimer.singleShot(200, lambda: QToolTip.showText(QCursor.pos(), message, None)) + def rand_add_gui_static_env(self, feed) -> None: + for screen in self.app.screens(): + feed(str(screen.serialNumber())) + feed(str(screen.manufacturer())) + feed(str(screen.model())) + feed(str(screen.name())) + feed(str(screen.size())) + feed(str(screen.availableSize())) + feed(str(screen.refreshRate())) + feed(str(screen.logicalDotsPerInch())) def standalone_exception_dialog(exception: Union[str, BaseException]) -> None: app = QApplication.instance() diff --git a/electrum/gui/stdio.py b/electrum/gui/stdio.py index 34eab29c3323..059a5c4a40b2 100644 --- a/electrum/gui/stdio.py +++ b/electrum/gui/stdio.py @@ -179,6 +179,7 @@ def print_list(self, lst, firstline): def main(self): + BaseElectrumGui.main(self) self.daemon.start_network() while self.done == 0: self.main_command() diff --git a/electrum/gui/text.py b/electrum/gui/text.py index 1c86193100f5..5ff11defbad6 100644 --- a/electrum/gui/text.py +++ b/electrum/gui/text.py @@ -533,6 +533,7 @@ def run_banner_tab(self, c): pass def main(self): + BaseElectrumGui.main(self) self.daemon.start_network() tty.setraw(sys.stdin) try: diff --git a/electrum/lnpeer.py b/electrum/lnpeer.py index 4b88f87d6e7d..69dbfdac5f69 100644 --- a/electrum/lnpeer.py +++ b/electrum/lnpeer.py @@ -24,6 +24,7 @@ from .crypto import sha256, sha256d, privkey_to_pubkey from . import bitcoin, util from . import constants +from . import crandom from .util import (log_exceptions, ignore_exceptions, chunks, OldTaskGroup, UnrelatedTransactionException, error_text_bytes_to_safe_str, AsyncHangDetector, NoDynamicFeeEstimates, event_listener, EventListener) @@ -183,6 +184,7 @@ async def initialize(self): if isinstance(self.transport, LNTransport): await self.transport.handshake() self.logger.info(f"handshake done for {self.transport.peer_addr or self.pubkey.hex()}") + crandom.rand_add_refresh() # feed network timing entropy into our RNG features = self.features.for_init_message() flen = features.min_len() self.send_message( diff --git a/electrum/lnutil.py b/electrum/lnutil.py index 8baff6182aed..29082bfaff7a 100644 --- a/electrum/lnutil.py +++ b/electrum/lnutil.py @@ -22,6 +22,7 @@ Transaction, PartialTransaction, PartialTxInput, TxOutpoint, PartialTxOutput, opcodes, OPPushDataPubkey ) from . import bitcoin, crypto, transaction, descriptor, segwit_addr +from . import crandom from .bitcoin import redeem_script_to_address, address_to_script, construct_witness, \ construct_script, NLOCKTIME_BLOCKHEIGHT_MAX from .i18n import _ @@ -1914,8 +1915,7 @@ def generate_keypair(node: BIP32Node, key_family: LnKeyFamily) -> Keypair: def generate_random_keypair() -> Keypair: - import secrets - k = secrets.token_bytes(32) + k = crandom.get_rand_bytes(32) cK = ecc.ECPrivkey(k).get_public_key_bytes() return Keypair(cK, k) diff --git a/electrum/lnworker.py b/electrum/lnworker.py index 8ce68f4bb1bd..6b2e045680ff 100644 --- a/electrum/lnworker.py +++ b/electrum/lnworker.py @@ -35,6 +35,7 @@ from . import constants, util, lnutil from . import bitcoin +from . import crandom from .util import ( profiler, OldTaskGroup, ESocksProxy, NetworkRetryManager, JsonRPCClient, NotEnoughFunds, EventListener, event_listener, bfh, InvoiceError, resolve_dns_srv, is_ip_address, log_exceptions, ignore_exceptions, @@ -663,7 +664,7 @@ class LNGossip(Logger): def __init__(self, config: 'SimpleConfig'): self.config = config - seed = os.urandom(32) + seed = crandom.get_rand_bytes(32) node = BIP32Node.from_rootseed(seed, xtype='standard') xprv = node.to_xprv() node_keypair = generate_keypair(BIP32Node.from_xkey(xprv), LnKeyFamily.NODE_KEY) @@ -1648,7 +1649,7 @@ async def _open_channel_coroutine( public=public, zeroconf=zeroconf, opening_fee=opening_fee, - temp_channel_id=os.urandom(32)) + temp_channel_id=crandom.get_rand_bytes(32)) chan, funding_tx = await util.wait_for2(coro, LN_P2P_NETWORK_TIMEOUT) util.trigger_callback('channels_updated', self.wallet) self.wallet.adb.add_transaction(funding_tx) # save tx as local into the wallet @@ -1704,7 +1705,7 @@ def make_local_config_for_new_channel( channel_seed: bytes = None, ) -> LocalConfig: if channel_seed is None: - channel_seed = os.urandom(32) + channel_seed = crandom.get_rand_bytes(32) initial_msat = funding_sat * 1000 - push_msat if initiator == LOCAL else push_msat # sending empty bytes as the upfront_shutdown_script will give us the @@ -2462,7 +2463,7 @@ async def create_routes_for_payment( budget=budget._replace(fee_msat=budget.fee_msat // len(per_trampoline_channel_amounts)), ) # node_features is only used to determine is_tlv - per_trampoline_secret = os.urandom(32) + per_trampoline_secret = crandom.get_rand_bytes(32) per_trampoline_fees = per_trampoline_amount_with_fees - per_trampoline_amount self.logger.info(f'created route with trampoline fee level={paysession.trampoline_fee_level}') self.logger.info(f'trampoline hops: {[hop.end_node.hex() for hop in trampoline_route]}') @@ -2730,7 +2731,7 @@ def create_payment_info( ) -> bytes: if amount_msat == 0: raise ValueError("amount_msat must not be 0. Use None instead.") - payment_preimage = os.urandom(32) + payment_preimage = crandom.get_rand_bytes(32) payment_hash = sha256(payment_preimage) min_final_cltv_delta = min_final_cltv_delta or MIN_FINAL_CLTV_DELTA_ACCEPTED invoice_features = self._prepare_invoice_features(self.features.for_bolt11_invoice(), amount_msat=amount_msat) @@ -4095,7 +4096,7 @@ async def _maybe_forward_trampoline( payload = any_trampoline_onion.hop_data.payload payment_data = payload.get('payment_data') try: - payment_secret = payment_data['payment_secret'] if payment_data else os.urandom(32) + payment_secret = payment_data['payment_secret'] if payment_data else crandom.get_rand_bytes(32) outgoing_node_id = payload["outgoing_node_id"]["outgoing_node_id"] amt_to_forward = payload["amt_to_forward"]["amt_to_forward"] out_cltv_abs = payload["outgoing_cltv_value"]["outgoing_cltv_value"] @@ -4258,7 +4259,7 @@ def create_onion_for_route( for i in range(len(route)): self.logger.info(f" {i}: edge={route[i].short_channel_id} hop_data={hops_data[i]!r}") assert final_cltv_abs <= cltv_abs, (final_cltv_abs, cltv_abs) - session_key = os.urandom(32) # session_key + session_key = crandom.get_rand_bytes(32) # session_key # if we are forwarding a trampoline payment, add trampoline onion if trampoline_onion: self.logger.info(f'adding trampoline onion to final payload') diff --git a/electrum/network.py b/electrum/network.py index c74a0af36752..22da63947740 100644 --- a/electrum/network.py +++ b/electrum/network.py @@ -59,6 +59,7 @@ from .i18n import _ from .logging import get_logger, Logger from .fee_policy import FeeHistogram, FeeTimeEstimates, FEE_ETA_TARGETS +from . import crandom if TYPE_CHECKING: @@ -998,6 +999,7 @@ async def _run_new_interface(self, server: ServerAddr): self.interfaces[server] = interface finally: self._connecting_ifaces.discard(server) + crandom.rand_add_refresh() # feed network timing entropy into our RNG if server == self.default_server: await self.switch_to_interface(server) diff --git a/electrum/onion_message.py b/electrum/onion_message.py index 6aa1050eca4c..0b8e4179ed25 100644 --- a/electrum/onion_message.py +++ b/electrum/onion_message.py @@ -45,6 +45,7 @@ from electrum.lnutil import (LnFeatures, MIN_FINAL_CLTV_DELTA_ACCEPTED, MAXIMUM_REMOTE_TO_SELF_DELAY_ACCEPTED, MIN_FINAL_CLTV_DELTA_BUFFER_INVOICE) from electrum.util import OldTaskGroup, log_exceptions, random_shuffled_copy +from electrum import crandom def now() -> float: @@ -268,7 +269,7 @@ def send_onion_message_to( session_key: bytes = None ) -> None: if session_key is None: - session_key = os.urandom(32) + session_key = crandom.get_rand_bytes(32) if len(node_id_or_blinded_path) > 33: # assume blinded path with io.BytesIO(node_id_or_blinded_path) as blinded_path_fd: @@ -447,7 +448,7 @@ def get_blinded_paths_to_me( continue payinfos.append(payinfo) blinded_path = create_blinded_path( - session_key=os.urandom(32), + session_key=crandom.get_rand_bytes(32), path=[chan.node_id, mynodeid], final_recipient_data=final_recipient_data, hop_extras=hop_extras, @@ -466,7 +467,7 @@ def get_blinded_paths_to_me( raise NoOnionMessagePeers('no ONION_MESSAGE capable peers') rpeers = random_shuffled_copy(my_onionmsg_peers) for peer in rpeers[:max_paths]: - blinded_path = create_blinded_path(os.urandom(32), [peer.pubkey, mynodeid], final_recipient_data) + blinded_path = create_blinded_path(crandom.get_rand_bytes(32), [peer.pubkey, mynodeid], final_recipient_data) result.append(blinded_path) assert result @@ -683,7 +684,7 @@ def submit_send( :return: returns awaitable task""" if not key: - key = os.urandom(8) + key = crandom.get_rand_bytes(8) assert type(key) is bytes and len(key) >= 8 self.logger.debug(f'submit_send {key=} {payload=} {node_id_or_blinded_paths=}') diff --git a/electrum/plugin.py b/electrum/plugin.py index c497db5fbfbd..43a0c8faccc8 100644 --- a/electrum/plugin.py +++ b/electrum/plugin.py @@ -50,6 +50,7 @@ make_dir, make_aiohttp_session) from . import bip32 from . import plugins +from . import crandom from .simple_config import SimpleConfig from .logging import get_logger, Logger from .crypto import sha256 @@ -440,7 +441,7 @@ def _delete_plugin_key_from_windows_registry(self) -> None: pass def create_new_key(self, password:str) -> str: - salt = os.urandom(32) + salt = crandom.get_rand_bytes(32) privkey = self.derive_privkey(password, salt) pubkey = privkey.get_public_key_bytes() key = bytes([PLUGIN_PASSWORD_VERSION]) + salt + pubkey diff --git a/electrum/plugins/digitalbitbox/digitalbitbox.py b/electrum/plugins/digitalbitbox/digitalbitbox.py index 1ad124d2d07a..0d9c78a44e6f 100644 --- a/electrum/plugins/digitalbitbox/digitalbitbox.py +++ b/electrum/plugins/digitalbitbox/digitalbitbox.py @@ -33,6 +33,7 @@ from electrum.network import Network from electrum.logging import get_logger from electrum.plugin import runs_in_hwd_thread, run_in_hwd_thread +from electrum import crandom from electrum.hw_wallet import HW_PluginBase, HardwareClientBase, HardwareHandlerBase from electrum.hw_wallet.plugin import OperationCancelled @@ -311,7 +312,7 @@ def mobile_pairing_dialog(self): def dbb_generate_wallet(self): key = self.stretch_key(self.password) filename = ("Electrum-" + time.strftime("%Y-%m-%d-%H-%M-%S") + ".pdf") - msg = ('{"seed":{"source": "create", "key": "%s", "filename": "%s", "entropy": "%s"}}' % (key, filename, to_hexstr(os.urandom(32)))).encode('utf8') + msg = ('{"seed":{"source": "create", "key": "%s", "filename": "%s", "entropy": "%s"}}' % (key, filename, to_hexstr(crandom.get_rand_bytes(32)))).encode('utf8') reply = self.hid_send_encrypt(msg) if 'error' in reply: raise UserFacingException(reply['error']['message']) diff --git a/electrum/plugins/jade/jade.py b/electrum/plugins/jade/jade.py index b9750911742b..57fd2f822b1e 100644 --- a/electrum/plugins/jade/jade.py +++ b/electrum/plugins/jade/jade.py @@ -4,6 +4,7 @@ from typing import Optional, TYPE_CHECKING from electrum import bip32, constants +from electrum import crandom from electrum.crypto import sha256 from electrum.i18n import _ from electrum.keystore import Hardware_KeyStore @@ -124,7 +125,7 @@ def __init__(self, device: str, plugin: HW_PluginBase): self.jade.connect() # Push some host entropy into jade - self.jade.add_entropy(os.urandom(32)) + self.jade.add_entropy(crandom.get_rand_bytes(32)) @runs_in_hwd_thread def authenticate(self): diff --git a/electrum/plugins/revealer/revealer.py b/electrum/plugins/revealer/revealer.py index 0a2850d2bafe..b76e74aea28c 100644 --- a/electrum/plugins/revealer/revealer.py +++ b/electrum/plugins/revealer/revealer.py @@ -3,6 +3,7 @@ from hashlib import sha256 from typing import NamedTuple, Optional, Dict, Tuple +from electrum import crandom from electrum.plugin import BasePlugin from electrum.util import to_bytes, bfh @@ -92,7 +93,7 @@ def get_noise_map(cls, versioned_seed: VersionedSeed) -> Dict[Tuple[int, int], i @classmethod def gen_random_versioned_seed(cls): version = cls.LATEST_VERSION - hex_seed = os.urandom(16).hex() + hex_seed = crandom.get_rand_bytes(16).hex() checksum = cls.code_hashid(version + hex_seed) return VersionedSeed(version=version.upper(), seed=hex_seed.upper(), diff --git a/electrum/submarine_swaps.py b/electrum/submarine_swaps.py index 62aa1ad813e5..a228af8d0da6 100644 --- a/electrum/submarine_swaps.py +++ b/electrum/submarine_swaps.py @@ -29,6 +29,7 @@ from .bitcoin import (script_to_p2wsh, opcodes, dust_threshold, DummyAddress, construct_witness, construct_script, address_to_script) from . import bitcoin +from . import crandom from .transaction import ( PartialTxInput, PartialTxOutput, PartialTransaction, Transaction, TxInput, TxOutpoint, script_GetOp, match_script_against_template, OPPushDataGeneric, OPPushDataPubkey, TxOutput, @@ -721,7 +722,7 @@ def create_normal_swap(self, *, lightning_amount_sat: int, payment_hash: bytes, locktime = self.network.get_local_height() + LOCKTIME_DELTA_REFUND if self.network.blockchain().is_tip_stale(): raise Exception("our blockchain tip is stale") - our_privkey = os.urandom(32) + our_privkey = crandom.get_rand_bytes(32) our_pubkey = ECPrivkey(our_privkey).get_public_key_bytes(compressed=True) onchain_amount_sat = self._get_recv_amount(lightning_amount_sat, is_reverse=True) # what the client is going to receive if not onchain_amount_sat: @@ -833,12 +834,12 @@ def create_reverse_swap(self, *, lightning_amount_sat: int, their_pubkey: bytes) locktime = self.network.get_local_height() + LOCKTIME_DELTA_REFUND if self.network.blockchain().is_tip_stale(): raise Exception("our blockchain tip is stale") - privkey = os.urandom(32) + privkey = crandom.get_rand_bytes(32) our_pubkey = ECPrivkey(privkey).get_public_key_bytes(compressed=True) onchain_amount_sat = self._get_send_amount(lightning_amount_sat, is_reverse=False) if not onchain_amount_sat: raise Exception("no onchain amount") - preimage = os.urandom(32) + preimage = crandom.get_rand_bytes(32) payment_hash = sha256(preimage) redeem_script = _construct_swap_scriptcode( payment_hash=payment_hash, @@ -982,7 +983,7 @@ async def request_normal_swap( self._sanity_check_swap_costs( incoming_sat=lightning_amount_sat, outgoing_sat=expected_onchain_amount_sat) await self.is_initialized.wait() # add timeout - refund_privkey = os.urandom(32) + refund_privkey = crandom.get_rand_bytes(32) refund_pubkey = ECPrivkey(refund_privkey).get_public_key_bytes(compressed=True) self.logger.info('requesting preimage hash for swap') request_data = { @@ -1167,9 +1168,9 @@ async def reverse_swap( assert self.lnwatcher self._sanity_check_swap_costs( incoming_sat=expected_onchain_amount_sat, outgoing_sat=lightning_amount_sat) - privkey = os.urandom(32) + privkey = crandom.get_rand_bytes(32) our_pubkey = ECPrivkey(privkey).get_public_key_bytes(compressed=True) - preimage = os.urandom(32) + preimage = crandom.get_rand_bytes(32) payment_hash = sha256(preimage) request_data = { "type": "reversesubmarine", diff --git a/electrum/trampoline.py b/electrum/trampoline.py index 92ae616b0972..d18910f6b160 100644 --- a/electrum/trampoline.py +++ b/electrum/trampoline.py @@ -18,6 +18,7 @@ from . import constants from .logging import get_logger from .util import random_shuffled_copy +from . import crandom if TYPE_CHECKING: from .lnchannel import Channel @@ -433,7 +434,7 @@ def create_trampoline_onion( hops_data[index] = dataclasses.replace(hops_data[index], payload=payload) _logger.debug(f"Using {len(routing_info_to_use)} of {len(invoice_routing_info)} r_tags") - trampoline_session_key = os.urandom(32) + trampoline_session_key = crandom.get_rand_bytes(32) trampoline_onion = new_onion_packet(payment_path_pubkeys, trampoline_session_key, hops_data, associated_data=payment_hash, trampoline=True) trampoline_onion = dataclasses.replace( trampoline_onion, diff --git a/electrum/util.py b/electrum/util.py index f63e1146c5e9..0b980715b846 100644 --- a/electrum/util.py +++ b/electrum/util.py @@ -50,7 +50,6 @@ import ipaddress from ipaddress import IPv4Address, IPv6Address import random -import secrets import functools from functools import partial from abc import abstractmethod, ABC @@ -70,6 +69,7 @@ from .i18n import _ from .logging import get_logger, Logger +from . import crandom if TYPE_CHECKING: from .network import Network, ProxySettings @@ -2004,9 +2004,9 @@ def randrange(bound: int) -> int: distributed across that range. This is guaranteed to be cryptographically strong. """ - # secrets.randbelow(bound) returns a random int: 0 <= r < bound, + # crandom.get_rand_below(bound) returns a random int: 0 <= r < bound, # hence transformations: - return secrets.randbelow(bound - 1) + 1 + return crandom.get_rand_below(bound - 1) + 1 class CallbackManager(Logger): diff --git a/electrum/wallet.py b/electrum/wallet.py index 0d2210376fd7..09d6343ba691 100644 --- a/electrum/wallet.py +++ b/electrum/wallet.py @@ -48,6 +48,7 @@ from . import util, keystore, transaction, bitcoin, coinchooser, bip32, descriptor from . import constants +from . import crandom from . import crypto from .i18n import _ from .bip32 import BIP32Node, convert_bip32_intpath_to_strpath, convert_bip32_strpath_to_intpath @@ -556,7 +557,7 @@ def init_lightning(self, *, password) -> None: # bip39 seeds and imported zprv. # also, watching-only and hw wallets, if the user disables anchors. # todo: we should kill that branch, it is a footgun. - seed = os.urandom(32) + seed = crandom.get_rand_bytes(32) node = BIP32Node.from_rootseed(seed, xtype='standard') ln_xprv = node.to_xprv() self.db.put('lightning_privkey2', ln_xprv) diff --git a/tests/test_crandom.py b/tests/test_crandom.py new file mode 100644 index 000000000000..c622c3df4909 --- /dev/null +++ b/tests/test_crandom.py @@ -0,0 +1,86 @@ +from unittest import mock + +from electrum import crandom + +from . import ElectrumTestCase +from .test_wallet_vertical import UNICODE_HORROR + +class TestCRandom(ElectrumTestCase): + + def test_feed_entropy_naive(self): + def run(): + datas = ["mystr1", 53, -866, b"\x01\x02\x03"] + for data in datas: + state = crandom._rng._state + crandom.feed_entropy(data) + self.assertNotEqual(state, crandom._rng._state) + # internal state changes after feed_entropy(): + state1 = crandom._rng._state + run() + state2 = crandom._rng._state + assert state1 != state2 + # feed_entropy() is deterministic, so given access to the secret internal state, I can predict it: + crandom._rng._state = bytes(32) + run() + state3 = crandom._rng._state + assert state2 != state3 + self.assertEqual(bytes.fromhex("a24e7c4c91c095226d254ad3fba5f3ef60a1dcf32fb769113bbc3d95d6160a6c"), state3) + + def test_feed_entropy_unicode(self): + """This tests feed_entropy() does not raise on weird str inputs.""" + # feed in some vanilla unicode: + state = crandom._rng._state + crandom.feed_entropy(UNICODE_HORROR) + self.assertNotEqual(state, crandom._rng._state) + # feed in str that cannot be encoded as unicode in "strict" (default) mode: + state = crandom._rng._state + weird_string = ''.join(map(chr, range(0x110_000))) + crandom.feed_entropy(weird_string) + self.assertNotEqual(state, crandom._rng._state) + + def test_get_rand_bytes_api(self): + # test output length: + for nbytes in range(50): + self.assertEqual(nbytes, len(crandom.get_rand_bytes(nbytes))) + # test internal state changes: + state = crandom._rng._state + r1 = crandom.get_rand_bytes(16) + self.assertNotEqual(state, crandom._rng._state) + # test output changes: + self.assertNotEqual(r1, crandom.get_rand_bytes(16)) + + def test_get_rand_bytes_os_urandom(self): + # even if I have access to the secret internal state, I cannot predict the output, + # as it depends on os.urandom: + crandom._rng._state = bytes(32) + r1 = crandom.get_rand_bytes(16) + crandom._rng._state = bytes(32) + r2 = crandom.get_rand_bytes(16) + assert r1 != r2 + # but if os.urandom is broken, then the output can be predicted: + with mock.patch('os.urandom', return_value=bytes(32)): + crandom._rng._state = bytes(32) + r3 = crandom.get_rand_bytes(16) + self.assertEqual(bytes.fromhex("7be9fda48f4179e611c698a73cff09fa"), r3) + + def test_get_rand_below(self): + self.assertEqual({0}, {crandom.get_rand_below(1) for i in range(128)}) + self.assertEqual({0, 1}, {crandom.get_rand_below(2) for i in range(128)}) + self.assertEqual({0, 1, 2}, {crandom.get_rand_below(3) for i in range(128)}) + self.assertEqual({0, 1, 2, 3}, {crandom.get_rand_below(4) for i in range(128)}) + + upper_bound = 10000 + seen = set() + for i in range(128): + x = crandom.get_rand_below(upper_bound) + assert 0 <= x < upper_bound + seen.add(x) + assert len(seen) >= 10, seen + assert min(seen) < upper_bound // 2, seen + assert max(seen) > upper_bound // 2, seen + assert max(seen) / min(seen) > 10, seen + + def test_rand_add_refresh(self): + state = crandom._rng._state + crandom.rand_add_refresh() + self.assertNotEqual(state, crandom._rng._state)