From af6bae3cd4ab9ec3729368f99880c086d017335e Mon Sep 17 00:00:00 2001 From: SomberNight Date: Mon, 3 Aug 2026 14:56:35 +0000 Subject: [PATCH 1/2] add crandom.py, for our RNG, where we mix stuff with os.urandom Currently we rely on `os.urandom()` ~everywhere for cryptographically secure randomness. Existing code already checks at runtime that the output of `os.urandom()` looks at least somewhat sane (see if it compresses with zlib) and hard-fails if it does not. However, `os.urandom` could still be subtly "broken" (undetected by us) and produce bad quality output. That's the motivation of the new code here. We expect `os.urandom` to work well, BUT if it undetectably does not, mixing in other sources of entropy mitigates the situation somewhat. This PR introduces a new module `crandom.py` that manages the RNG, and that our other code should call. Extreme care should be taken here not to make things worse than the status quo by "rolling our own" thing. The logic is split across two modules: `crandom.py` and `crandom_env.py`. - The core sensitive logic (RNG mixing, extracting random bytes) is in `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. It is just a companion to `crandom.py` and is only intended to be accessed from there. Even if all the entropy sources listed in `crandom_env.py` are broken, assuming `os.urandom()` produces high quality random, `crandom.py` should never produce low-quality random output. Hence `crandom_env.py` is much less critical tro review in depth. This is inspired by https://github.com/bitcoin/bitcoin/blob/67efced1fc83a0b7215cc1513e7c4754fee0f12f/src/random.h#L25, which is significantly more advanced. But I wanted to (1) lessen our dependence on `os.urandom()`, (2) while keeping it simple. --- electrum/__init__.py | 9 +- electrum/commands.py | 3 +- electrum/crandom.py | 153 ++++++++++++++++++ electrum/crandom_env.py | 88 ++++++++++ electrum/crypto.py | 3 +- electrum/gui/__init__.py | 17 +- electrum/gui/qml/__init__.py | 1 + electrum/gui/qml/qebiometrics.py | 4 +- electrum/gui/qt/__init__.py | 12 ++ electrum/gui/stdio.py | 1 + electrum/gui/text.py | 1 + electrum/lnpeer.py | 2 + electrum/lnutil.py | 4 +- electrum/lnworker.py | 15 +- electrum/network.py | 2 + electrum/onion_message.py | 9 +- electrum/plugin.py | 3 +- .../plugins/digitalbitbox/digitalbitbox.py | 3 +- electrum/plugins/jade/jade.py | 3 +- electrum/plugins/revealer/revealer.py | 3 +- electrum/submarine_swaps.py | 13 +- electrum/trampoline.py | 3 +- electrum/util.py | 6 +- electrum/wallet.py | 3 +- tests/test_crandom.py | 86 ++++++++++ 25 files changed, 406 insertions(+), 41 deletions(-) create mode 100644 electrum/crandom.py create mode 100644 electrum/crandom_env.py create mode 100644 tests/test_crandom.py 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..b00b1e240976 --- /dev/null +++ b/electrum/crandom_env.py @@ -0,0 +1,88 @@ +# 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())) + feed(getattr(os, "getlogin", lambda: "")()) + 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) From 57237d7edeeecbbfe1a63cb940f47fdc365b4a1a Mon Sep 17 00:00:00 2001 From: SomberNight Date: Mon, 3 Aug 2026 20:32:28 +0000 Subject: [PATCH 2/2] tmp1 --- electrum/crandom_env.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/electrum/crandom_env.py b/electrum/crandom_env.py index b00b1e240976..f669c1bfed36 100644 --- a/electrum/crandom_env.py +++ b/electrum/crandom_env.py @@ -32,7 +32,10 @@ def rand_add_static_env(feed: 'CRANDOM_FEEDER_API') -> None: feed(os.getcwd()) feed(str(os.get_exec_path())) feed(str(os.getgroups())) - feed(getattr(os, "getlogin", lambda: "")()) + try: + feed(os.getlogin()) + except (AttributeError, OSError): + pass feed(getattr(os, "getpgrp", lambda: "")()) feed(os.getpid()) feed(getattr(os, "getppid", lambda: "")())