Merge pull request #10933 from SomberNight/202609_crandom_prep
What changed, and why it matters
This commit is a cleanup change: it creates a single helper module for random-number generation and replaces scattered calls to os.urandom, secrets.token_bytes, and an old randrange helper with the new helpers. The new helpers still use the same Python standard-library secure random sources (os.urandom and secrets.randbelow), so the change does not introduce a security weakness. It is preparation work for a future improvement, not a fix for an active vulnerability.
No immediate action required. Treat as routine refactoring. If auditing, verify that future commits using crandom do not weaken the underlying source or add non-cryptographic fallbacks.
Security signals we found
No change to entropy source: still os.urandom / secrets.randbelow
No new dependencies or native code
No change to key-derivation logic or secret handling
Commit title and message describe the change as a trivial refactor/prep, not a security fix
One API semantic change (randrange lower bound) is visible in the diff and handled by callers
Evidence from the diff
The patch adds electrum/crandom.py with get_rand_bytes(nbytes) wrapping os.urandom and get_rand_below(upper_bound) wrapping secrets.randbelow. It then mechanically replaces direct os.urandom/secrets.token_bytes calls and util.randrange usage across 18 files with these helpers. One functional side effect: the old util.randrange returned values in [1, bound), while the new crandom.get_rand_below returns [0, upper_bound). Callers were adjusted where necessary (e.g., daemon.py now passes pow(2, bits) directly; test_bitcoin.py adds the +1 offset). The commit message explicitly calls this a ‘trivial refactor to centralise our RNG code’ and a ‘prep’ step. No cryptographic algorithm, key generation, or entropy source is changed.
Changed components
electrum/crandom.py (new)electrum/commands.pyelectrum/crypto.pyelectrum/daemon.pyelectrum/gui/qml/qebiometrics.pyelectrum/lnutil.pyelectrum/lnworker.pyelectrum/mnemonic.pyelectrum/onion_message.pyelectrum/plugin.pyelectrum/plugins/digitalbitbox/digitalbitbox.pyelectrum/plugins/jade/jade.pyelectrum/plugins/revealer/revealer.pyelectrum/submarine_swaps.pyelectrum/trampoline.pyelectrum/util.pyelectrum/wallet.pytests/test_bitcoin.pyInspect captured patch +71 / −46
### electrum/commands.py
@@ -79,6 +79,7 @@
from . import crypto
from . import constants
from . import descriptor
+from . import crandom
if TYPE_CHECKING:
from .network import Network
@@ -2329,7 +2330,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:
### electrum/crandom.py
@@ -0,0 +1,21 @@
+# 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.
+
+import os
+import secrets
+
+
+def get_rand_bytes(nbytes: int) -> bytes:
+ """Returns uniformly distributed bytes, of length nbytes."""
+ assert nbytes >= 0, nbytes
+ return os.urandom(nbytes)
+
+
+def get_rand_below(upper_bound: int) -> int:
+ """Return a uniformly distributed int in the range [0, upper_bound)."""
+ assert upper_bound > 0, upper_bound
+ return secrets.randbelow(upper_bound)
+
### 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__)
@@ -179,7 +180,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
### electrum/daemon.py
@@ -40,11 +40,12 @@
from aiohttp import web, client_exceptions
from aiorpcx import ignore_after
+from . import crandom
from . import util
from .network import Network
from .util import (
json_decode, to_bytes, to_string, profiler, standardize_path, constant_time_compare, InvalidPassword,
- log_exceptions, randrange, OldTaskGroup, UserFacingException, JsonRPCError, os_chmod
+ log_exceptions, OldTaskGroup, UserFacingException, JsonRPCError, os_chmod
)
from .wallet import Wallet, Abstract_Wallet
from .storage import WalletStorage
@@ -186,7 +187,7 @@ def get_rpc_credentials(config: SimpleConfig) -> Tuple[str, str]:
rpc_user = 'user'
bits = 128
nbytes = bits // 8 + (bits % 8 > 0)
- pw_int = randrange(pow(2, bits))
+ pw_int = crandom.get_rand_below(pow(2, bits))
pw_b64 = b64encode(
pw_int.to_bytes(nbytes, 'big'), b'-_')
rpc_password = to_string(pw_b64, 'ascii')
### 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, QMetaObject, Qt
+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,
### electrum/lnutil.py
@@ -25,6 +25,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 _
@@ -2007,8 +2008,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)
### 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,
@@ -664,7 +665,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)
@@ -1695,7 +1696,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
@@ -1752,7 +1753,7 @@ def make_local_config_for_new_channel(
channel_seed: bytes | None = 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
@@ -2508,7 +2509,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]}')
@@ -2776,7 +2777,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)
@@ -4175,7 +4176,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"]
@@ -4332,7 +4333,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')
### electrum/mnemonic.py
@@ -30,7 +30,8 @@
from typing import Sequence, Dict, Iterator, Optional
from types import MappingProxyType
-from .util import resource_path, bfh, randrange
+from . import crandom
+from .util import resource_path, bfh
from .crypto import hmac_oneshot
from . import version
from .logging import Logger
@@ -212,7 +213,7 @@ def make_seed(self, *, seed_type: str | None = None, num_bits: int | None = None
# generate random
entropy = 1
while entropy < pow(2, num_bits - bpw): # try again if seed would not contain enough words
- entropy = randrange(pow(2, num_bits))
+ entropy = crandom.get_rand_below(pow(2, num_bits))
# brute-force seed that has correct "version number"
nonce = 0
while True:
### 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
) -> 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=}')
### 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
### 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'])
### 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):
### 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(),
### 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,
@@ -760,7 +761,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:
@@ -874,12 +875,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,
@@ -1021,7 +1022,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 = {
@@ -1224,9 +1225,9 @@ async def reverse_swap(
assert self.lnwatcher
self._sanity_check_swap_costs(incoming_sat=expected_onchain_amount_sat, outgoing_sat=lightning_amount_sat)
self._sanity_check_prepayment(prepayment_sat=prepayment_sat, lightning_amount_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",
### electrum/trampoline.py
@@ -29,6 +29,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
@@ -444,7 +445,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,
### electrum/util.py
@@ -52,7 +52,6 @@
import ipaddress
from ipaddress import IPv4Address, IPv6Address
import random
-import secrets
import functools
from functools import partial
from abc import abstractmethod, ABC
@@ -72,6 +71,7 @@
from .i18n import _
from .logging import get_logger, Logger
+from . import crandom
if TYPE_CHECKING:
from .network import Network, ProxySettings
@@ -2005,16 +2005,6 @@ def dict_from_srv_record(srv):
return [dict_from_srv_record(srv) for srv in srv_records]
-def randrange(bound: int) -> int:
- """Return a random integer k such that 1 <= k < bound, uniformly
- distributed across that range.
- This is guaranteed to be cryptographically strong.
- """
- # secrets.randbelow(bound) returns a random int: 0 <= r < bound,
- # hence transformations:
- return secrets.randbelow(bound - 1) + 1
-
-
class CallbackManager(Logger):
# callbacks set by the GUI or any thread
# guarantee: the callbacks will always get triggered from the asyncio thread.
### 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
@@ -578,7 +579,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)
### tests/test_bitcoin.py
@@ -19,6 +19,7 @@
taproot_tweak_pubkey, taproot_tweak_seckey, taproot_output_script,
control_block_for_taproot_script_spend)
from electrum import bip32
+from electrum import crandom
from electrum import segwit_addr
from electrum.segwit_addr import DecodedBech32
from electrum.bip32 import (BIP32Node, convert_bip32_intpath_to_strpath,
@@ -27,7 +28,7 @@
normalize_bip32_derivation, is_all_public_derivation)
from electrum.crypto import sha256d, SUPPORTED_PW_HASH_VERSIONS
from electrum import crypto, constants
-from electrum.util import bfh, InvalidPassword, randrange
+from electrum.util import bfh, InvalidPassword
from electrum.storage import WalletStorage
from electrum.keystore import xtype_from_derivation
@@ -172,7 +173,7 @@ def test_crypto(self):
def _do_test_crypto(self, message: bytes):
G = ecc.GENERATOR
_r = G.order()
- pvk = randrange(_r)
+ pvk = crandom.get_rand_below(_r - 1) + 1
Pub = pvk*G
pubkey_c = Pub.get_public_key_bytes(True)Why this scored 20/100
Community notes
Notes can correct, qualify, or add evidence to the AI analysis. Every note shown here has been validated by a human moderator.
The AI analysis stands alone for now. Submit a note if you can add evidence or important context.