From ef1dbbbb5bfbaf7133fdb7002ac3fa3717bb9b82 Mon Sep 17 00:00:00 2001 From: SomberNight Date: Mon, 22 Jan 2024 03:25:52 +0000 Subject: [PATCH] mnemonic.make_seed: add "extra_entropy" arg, and expose it to CLI/RPC 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 https://github.com/spesmilo/electrum/issues/523#issuecomment-1902078679 [0]: https://github.com/spesmilo/electrum/blame/883f9be7d15cf1aba16895a0848f0d7af99f2ff3/lib/mnemonic.py#L149 [1]: 5e5134b76ffec8ed9702f5661a1d16e9e52a24c8 --- electrum/commands.py | 7 +++++-- electrum/mnemonic.py | 13 +++++++++++-- electrum/tests/test_mnemonic.py | 23 +++++++++++++++++++++++ 3 files changed, 39 insertions(+), 4 deletions(-) diff --git a/electrum/commands.py b/electrum/commands.py index d16d04cad071..f689d58c623a 100644 --- a/electrum/commands.py +++ b/electrum/commands.py @@ -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') @@ -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"), diff --git a/electrum/mnemonic.py b/electrum/mnemonic.py index d458c68c7213..11be6fead569 100644 --- a/electrum/mnemonic.py +++ b/electrum/mnemonic.py @@ -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 @@ -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' @@ -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: diff --git a/electrum/tests/test_mnemonic.py b/electrum/tests/test_mnemonic.py index b330909ad9ff..6ba4772ab1b3 100644 --- a/electrum/tests/test_mnemonic.py +++ b/electrum/tests/test_mnemonic.py @@ -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):