Skip to content

Commit

Permalink
mnemonic.make_seed: add "extra_entropy" arg, and expose it to CLI/RPC
Browse files Browse the repository at this point in the history
This is useful for the following threat-model:
> The randomness generated by the CPU/OS is assumed weak, but otherwise the user trusts the CPU/OS to execute code as-is.
> The user can inspect the source code to see what they provide as custom entropy is used in a sane way.

As the extra entropy is simply XOR-ed in, into the OS-generated randomness, it cannot be used as a footgun.

Note this significantly differs from the old custom_entropy option [0] that existed between version ~2.0 and 3.1.2 [1]:
- the old option *replaced* the OS-entropy with the user-provided entropy
  - e.g. if the user provided what looked like 64 bits of entropy, and by default we wanted to create a 132 bit seed,
    the resulting seed only used 132-64=68 bits of os.urandom()
  - hence think the old option was a footgun -- it required expert knowledge to use properly
  - instead, the new option mixes the user-provided entropy with os.urandom(), in a way that can never make the
    result have less entropy
- with the old option, the "custom_entropy" arg was an int, of which every "bit" was used as-is
  - for example, if the user wanted to provide some dice rolls,
    e.g. "6 3 5 2 6 6 2", and they passed the int "6352662" (base 10), they lost a lot of entropy by not using high decimal digits
    - i.e. the user was required to know *how* to convert their entropy to the expected format
    (e.g. dice rolls as base6, and then convert to base10)
  - instead, the new option takes an arbitrary string, which is then hashed internally, hence it is not possible to misuse the API
    - e.g. it is safe to provide dice rolls as a string, e.g. "6 3 5 2 6 6 2" or "6352662" or in any imaginable way
- the old option exposed a "check_seed" CLI command, that could be used to verify the user-provided entropy was used
  as part of the seed-generation. This is not possible with the new option.

related #523 (comment)

[0]: https://github.com/spesmilo/electrum/blame/883f9be7d15cf1aba16895a0848f0d7af99f2ff3/lib/mnemonic.py#L149
[1]: 5e5134b
  • Loading branch information
SomberNight committed Jan 22, 2024
1 parent def1abf commit ef1dbbb
Show file tree
Hide file tree
Showing 3 changed files with 39 additions and 4 deletions.
7 changes: 5 additions & 2 deletions electrum/commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -352,10 +352,12 @@ async def setconfig(self, key, value):
cv.set(value)

@command('')
async def make_seed(self, nbits=None, language=None, seed_type=None):
async def make_seed(self, nbits=None, language=None, seed_type=None, extra_entropy: str = None):
"""Create a seed"""
if extra_entropy is not None:
extra_entropy = extra_entropy.encode("utf-8")
from .mnemonic import Mnemonic
s = Mnemonic(language).make_seed(seed_type=seed_type, num_bits=nbits)
s = Mnemonic(language).make_seed(seed_type=seed_type, num_bits=nbits, extra_entropy=extra_entropy)
return s

@command('n')
Expand Down Expand Up @@ -1433,6 +1435,7 @@ def eval_bool(x: str) -> bool:
'from_coins': (None, "Source coins (must be in wallet; use sweep to spend from non-wallet address)."),
'change_addr': ("-c", "Change address. Default is a spare address, or the source address if it's not in the wallet"),
'nbits': (None, "Number of bits of entropy"),
'extra_entropy': (None, "Arbitrary string used as additional entropy"),
'seed_type': (None, "The type of seed to create, e.g. 'standard' or 'segwit'"),
'language': ("-L", "Default language for wordlist"),
'passphrase': (None, "Seed extension"),
Expand Down
13 changes: 11 additions & 2 deletions electrum/mnemonic.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@
from types import MappingProxyType

from .util import resource_path, bfh, randrange
from .crypto import hmac_oneshot
from .crypto import hmac_oneshot, sha256
from . import version
from .logging import Logger

Expand Down Expand Up @@ -198,7 +198,7 @@ def mnemonic_decode(self, seed: str) -> int:
i = i*n + k
return i

def make_seed(self, *, seed_type: str = None, num_bits: int = None) -> str:
def make_seed(self, *, seed_type: str = None, num_bits: int = None, extra_entropy: bytes = None) -> str:
from .keystore import bip39_is_checksum_valid
if seed_type is None:
seed_type = 'segwit'
Expand All @@ -209,10 +209,19 @@ def make_seed(self, *, seed_type: str = None, num_bits: int = None) -> str:
bpw = math.log(len(self.wordlist), 2)
num_bits = int(math.ceil(num_bits/bpw) * bpw)
self.logger.info(f"make_seed. prefix: '{prefix}', entropy: {num_bits} bits")
# prepare user-provided additional entropy
if extra_entropy:
assert isinstance(extra_entropy, bytes), type(extra_entropy)
extra_entropy = sha256(extra_entropy)
extra_entropy_int = int.from_bytes(extra_entropy, byteorder="big", signed=False)
extra_entropy_int &= ((1 << num_bits) - 1) # limit length to "num_bits"
else:
extra_entropy_int = 0
# 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 ^= extra_entropy_int # mix-in provided additional entropy, if any
# brute-force seed that has correct "version number"
nonce = 0
while True:
Expand Down
23 changes: 23 additions & 0 deletions electrum/tests/test_mnemonic.py
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,29 @@ def test_random_seeds(self):
self.assertTrue(12 <= len(seed.split()) <= 13)
self.assertEqual(iters, len(pool))

def test_extra_entropy(self):
pool = set()
num_pool = 0
extra_entropies = (
b"asd",
UNICODE_HORROR.encode("utf-8"),
(2**4096-1).to_bytes(length=512, byteorder="big"),
)
m = mnemonic.Mnemonic(lang='en')
for ee in extra_entropies:
seed = m.make_seed(seed_type="standard", extra_entropy=ee, num_bits=128)
pool.add(seed)
num_pool += 1
with self.subTest(seed=seed, msg="num-words"):
self.assertTrue(12 <= len(seed.split()) <= 13)
for ee in extra_entropies:
seed = m.make_seed(seed_type="standard", extra_entropy=ee, num_bits=256)
pool.add(seed)
num_pool += 1
with self.subTest(seed=seed, msg="num-words"):
self.assertTrue(24 <= len(seed.split()) <= 25)
self.assertEqual(num_pool, len(pool))


class Test_OldMnemonic(ElectrumTestCase):

Expand Down

0 comments on commit ef1dbbb

Please sign in to comment.