diff --git a/docs/dice_verification.md b/docs/dice_verification.md index 361bf14d..d5a23cb7 100644 --- a/docs/dice_verification.md +++ b/docs/dice_verification.md @@ -205,3 +205,57 @@ We double-checked in two different web tools implementing different methods for So congratulations if the fingerprints, zpubs and addresses all match up in your example so you can be much more confident that nothing is wrong with your generated seed. +### SeedSigner verification script +_(for more advanced/python-savvy users)_ + +Install the `embit` dependency: +``` +pip3 install embit +``` + +Then run the utility script with `-h` to view the usage instructions: +``` +cd src/seedsigner/helpers +python3 mnemonic_generation.py -h +``` + +``` + Verify SeedSigner's dice rolls and coin flip entropy-to-mnemonic conversion via this tool. + + Compare its results against iancoleman.io/bip39 and bitcoiner.guide/seed + + Usage: + # 50 dice rolls / 12-word mnemonic + python3 mnemonic_generation.py dice 5624433434... + + # 99 dice rolls / 24-word mnemonic + python3 mnemonic_generation.py dice 6151463561... + + # 50 dice rolls, entered as 0-5 / 12-word mnemonic + python3 mnemonic_generation.py --zero-indexed-dice dice 5135535514... + + # 128 coin flips / 12-word mnemonic + python3 mnemonic_generation.py coins 1111100111... + + # 256 coin flips / 24-word mnemonic + python mnemonic_generation.py coins 0010111010... + + # GENERATE 50 random dice rolls / 12-word mnemonic + python3 mnemonic_generation.py dice rand12 + + # GENERATE 99 random dice rolls / 24-word mnemonic + python3 mnemonic_generation.py dice rand24 + + # GENERATE 99 random dice rolls, entered as 0-5 / 24-word mnemonic + python3 mnemonic_generation.py --zero-indexed-dice dice rand24 + + # GENERATE 128 random coin flips / 12-word mnemonic + python3 mnemonic_generation.py coins rand12 + + # GENERATE 256 random coin flips / 24-word mnemonic + python3 mnemonic_generation.py coins rand24 +``` + + +### Epilogue +You can use these methods to do dry run time to time to verify that no one has changed the micro sdcard. But do not use the generated 24 words as a valid wallet, they need to be generated alone, only on the seedsigner! diff --git a/src/seedsigner/helpers/mnemonic_generation.py b/src/seedsigner/helpers/mnemonic_generation.py index a021a834..9a7caa63 100644 --- a/src/seedsigner/helpers/mnemonic_generation.py +++ b/src/seedsigner/helpers/mnemonic_generation.py @@ -1,22 +1,76 @@ import hashlib +import random import unicodedata from embit import bip39 from embit.bip39 import mnemonic_to_bytes, mnemonic_from_bytes -from typing import List +from embit.wordlists.bip39 import WORDLIST as WORDLIST__ENGLISH + +""" + This is SeedSigner's internal mnemonic generation utility but is also meant to + function as an independently-executable CLI to facilitate external verification of + SeedSigner's mnemonic generation for a given input entropy. + + Therefore its module imports should be kept to the bare minimum. + + ## Running as a standalone script + Install the `embit` library: + ``` + pip3 install embit + ``` + + And then run: + ``` + python3 mnemonic_generation.py -h + ``` +""" + + +# Hard-coded value from SettingsConstants to avoid dependencies +WORDLIST_LANGUAGE__ENGLISH = 'en' + +DICE__NUM_ROLLS__12WORD = 50 +DICE__NUM_ROLLS__24WORD = 99 + + +def _get_wordlist(wordlist_language_code) -> list[str]: + """ + Convenience method to fetch the wordlist for the given language code without + requiring any SeedSigner module dependencies for when this is run as a + standalone CLI. + """ + if wordlist_language_code == WORDLIST_LANGUAGE__ENGLISH: + return WORDLIST__ENGLISH + else: + # Nested import to avoid dependency on Seed model when running this script standalone + from seedsigner.models import Seed + return Seed.get_wordlist(wordlist_language_code) -def calculate_checksum(mnemonic: list, wordlist_language_code: str) -> List[str]: +def calculate_checksum(mnemonic: list | str, wordlist_language_code: str = WORDLIST_LANGUAGE__ENGLISH) -> list[str]: """ Provide 12- or 24-word mnemonic, returns complete mnemonic w/checksum as a list. + Mnemonic may be a list of words or a string of words separated by spaces or commas. + If 11- or 23-words are provided, append word `0000` to end of list as temp final word. """ - from seedsigner.models.seed import Seed + if type(mnemonic) == str: + import re + # split on commas or spaces + mnemonic = re.findall(r'[^,\s]+', mnemonic) + if len(mnemonic) in [11, 23]: - mnemonic.append(Seed.get_wordlist(wordlist_language_code)[0]) + if wordlist_language_code == WORDLIST_LANGUAGE__ENGLISH: + temp_final_word = "abandon" + else: + # Nested import to avoid dependency on Seed model when running this script standalone + from seedsigner.models import Seed + temp_final_word = Seed.get_wordlist(wordlist_language_code)[0] + + mnemonic.append(temp_final_word) if len(mnemonic) not in [12, 24]: raise Exception("Pass in a 12- or 24-word mnemonic") @@ -27,7 +81,7 @@ def calculate_checksum(mnemonic: list, wordlist_language_code: str) -> List[str] # Convert the resulting mnemonic to bytes, but we `ignore_checksum` validation # because we assume it's incorrect since we either let the user select their own # final word OR we injected the 0000 word from the wordlist. - mnemonic_bytes = bip39.mnemonic_to_bytes(unicodedata.normalize("NFKD", " ".join(mnemonic_copy)), ignore_checksum=True, wordlist=Seed.get_wordlist(wordlist_language_code)) + mnemonic_bytes = bip39.mnemonic_to_bytes(unicodedata.normalize("NFKD", " ".join(mnemonic_copy)), ignore_checksum=True, wordlist=_get_wordlist(wordlist_language_code)) # This function will convert the bytes back into a mnemonic, but it will also # calculate the proper checksum bits while doing so. For a 12-word seed it will just @@ -37,28 +91,234 @@ def calculate_checksum(mnemonic: list, wordlist_language_code: str) -> List[str] -def generate_mnemonic_from_bytes(entropy_bytes) -> List[str]: - return bip39.mnemonic_from_bytes(entropy_bytes).split() +def generate_mnemonic_from_bytes(entropy_bytes, wordlist_language_code: str = WORDLIST_LANGUAGE__ENGLISH) -> list[str]: + return bip39.mnemonic_from_bytes(entropy_bytes, wordlist=_get_wordlist(wordlist_language_code)).split() -def generate_mnemonic_from_dice(roll_data: str) -> List[str]: +def generate_mnemonic_from_dice(roll_data: str, wordlist_language_code: str = WORDLIST_LANGUAGE__ENGLISH) -> list[str]: + """ + Takes a string of 50 or 99 dice rolls and returns a 12- or 24-word mnemonic. + + Uses the iancoleman.io/bip39 and bitcoiner.guide/seed "Base 10" or "Hex" mode approach: + * dice rolls are treated as string data. + * hashed via SHA256. + + Important note: This method is NOT compatible with iancoleman's "Dice" mode. + """ entropy_bytes = hashlib.sha256(roll_data.encode()).digest() - if len(roll_data) == 50: + if len(roll_data) == DICE__NUM_ROLLS__12WORD: # 12-word mnemonic; only use 128bits / 16 bytes entropy_bytes = entropy_bytes[:16] # Return as a list - return bip39.mnemonic_from_bytes(entropy_bytes).split() + return bip39.mnemonic_from_bytes(entropy_bytes, wordlist=_get_wordlist(wordlist_language_code)).split() + + + +def generate_mnemonic_from_coin_flips(coin_flips: str, wordlist_language_code: str = WORDLIST_LANGUAGE__ENGLISH) -> list[str]: + """ + Takes a string of 128 or 256 0s and 1s and returns a 12- or 24-word mnemonic. + + Uses the iancoleman.io/bip39 and bitcoiner.guide/seed "Binary" mode approach: + * binary digit stream is treated as string data. + * hashed via SHA256. + """ + entropy_bytes = hashlib.sha256(coin_flips.encode()).digest() + + if len(coin_flips) == 128: + # 12-word mnemonic; only use 128bits / 16 bytes + entropy_bytes = entropy_bytes[:16] + + # Return as a list + return bip39.mnemonic_from_bytes(entropy_bytes, wordlist=_get_wordlist(wordlist_language_code)).split() + + + +def get_partial_final_word(coin_flips: str, wordlist_language_code: str = WORDLIST_LANGUAGE__ENGLISH) -> str: + """ Look up the partial final word for the given coin flips. + 7 coin flips: 0101010 + 0000 where the final 4 bits will be replaced with the checksum + 3 coin flips: 0101 + 0000000 where the final 8 bits will be replaced with the checksum + """ + binary_string = coin_flips + "0" * (11 - len(coin_flips)) + wordlist_index = int(binary_string, 2) + + if wordlist_language_code == WORDLIST_LANGUAGE__ENGLISH: + wordlist = WORDLIST__ENGLISH + else: + from seedsigner.models import Seed, SettingsConstants + wordlist = Seed.get_wordlist(wordlist_language_code) + + return wordlist[wordlist_index] + # Note: This currently isn't being used since we're now chaining hashed bytes for the # image-based entropy and aren't just ingesting a single image. -def generate_mnemonic_from_image(image) -> List[str]: +def generate_mnemonic_from_image(image, wordlist_language_code: str = WORDLIST_LANGUAGE__ENGLISH) -> list[str]: import hashlib hash = hashlib.sha256(image.tobytes()) # Return as a list - return bip39.mnemonic_from_bytes(hash.digest()).split() + return bip39.mnemonic_from_bytes(hash.digest(), wordlist=_get_wordlist(wordlist_language_code)).split() + + + +if __name__ == "__main__": + import argparse + + usage = f""" + Verify SeedSigner's dice rolls and coin flip entropy-to-mnemonic conversion via this tool. + + Compare its results against iancoleman.io/bip39 and bitcoiner.guide/seed + + Usage: + # {DICE__NUM_ROLLS__12WORD} dice rolls / 12-word mnemonic + python3 mnemonic_generation.py dice 5624433434... + + # {DICE__NUM_ROLLS__24WORD} dice rolls / 24-word mnemonic + python3 mnemonic_generation.py dice 6151463561... + + # {DICE__NUM_ROLLS__12WORD} dice rolls, entered as 0-5 / 12-word mnemonic + python3 mnemonic_generation.py --zero-indexed-dice dice 5135535514... + + # 128 coin flips / 12-word mnemonic + python3 mnemonic_generation.py coins 1111100111... + + # 256 coin flips / 24-word mnemonic + python mnemonic_generation.py coins 0010111010... + + # GENERATE {DICE__NUM_ROLLS__12WORD} random dice rolls / 12-word mnemonic + python3 mnemonic_generation.py dice rand12 + + # GENERATE {DICE__NUM_ROLLS__24WORD} random dice rolls / 24-word mnemonic + python3 mnemonic_generation.py dice rand24 + + # GENERATE {DICE__NUM_ROLLS__24WORD} random dice rolls, entered as 0-5 / 24-word mnemonic + python3 mnemonic_generation.py --zero-indexed-dice dice rand24 + + # GENERATE 128 random coin flips / 12-word mnemonic + python3 mnemonic_generation.py coins rand12 + + # GENERATE 256 random coin flips / 24-word mnemonic + python3 mnemonic_generation.py coins rand24 + """ + RAND_12 = "rand12" + RAND_24 = "rand24" + parser = argparse.ArgumentParser(description=f'SeedSigner entropy-to-mnemonic tool\n\n{usage}', formatter_class=argparse.RawTextHelpFormatter) + + # Required positional arguments + parser.add_argument('method', type=str, choices=['dice', 'coins', 'final_word'], help="Input entropy method") + parser.add_argument('entropy', type=str, help=f"""Entropy data. Enter "{ RAND_12 }" or "{ RAND_24 }" to create a random (not-secure) example seed.""") + + # Optional arguments + parser.add_argument('-z', '--zero-indexed-dice', + action="store_true", + default=False, + dest="zero_indexed_dice", + help="Enables dice entry as [0-5] instead of default [1-6]") + + args = parser.parse_args() + + method = args.method + entropy = args.entropy + zero_indexed_dice = args.zero_indexed_dice + + is_rand_seed = 'rand' in entropy + if is_rand_seed: + # Generate random data as our entropy + if entropy not in [RAND_12, RAND_24]: + print(f"""Invalid random entropy value: Must be either "{RAND_12}" or "{RAND_24}".""") + exit(1) + mnemonic_length = 12 if entropy == RAND_12 else 24 + + if method == 'dice': + if zero_indexed_dice: + entropy = ''.join([str(random.randint(0, 5)) for i in range(DICE__NUM_ROLLS__12WORD if mnemonic_length == 12 else DICE__NUM_ROLLS__24WORD)]) + else: + entropy = ''.join([str(random.randint(1, 6)) for i in range(DICE__NUM_ROLLS__12WORD if mnemonic_length == 12 else DICE__NUM_ROLLS__24WORD)]) + + elif method == 'coins': + entropy = ''.join([str(random.randint(0, 1)) for i in range(128 if mnemonic_length == 12 else 256)]) + + elif method == 'final_word': + random_dice_rolls = ''.join([str(random.randint(0, 1)) for i in range(128 if mnemonic_length == 12 else 256)]) + entropy = " ".join(generate_mnemonic_from_coin_flips(random_dice_rolls)[:-1]) + print(len(entropy.split()), entropy) + + if method == 'dice': + if not zero_indexed_dice and ('0' in entropy or '6' not in entropy): + print("Dice entry must be 1-6 unless --zero-indexed-dice is specified") + exit(1) + if len(entropy) not in [DICE__NUM_ROLLS__12WORD, DICE__NUM_ROLLS__24WORD]: + print(f"Dice entropy must be {DICE__NUM_ROLLS__12WORD} or {DICE__NUM_ROLLS__24WORD} rolls") + exit(1) + mnemonic = generate_mnemonic_from_dice(entropy) + + elif method == 'coins': + if len(entropy) not in [128, 256]: + print("Coin flip entropy must be 128 or 256 flips") + exit(1) + mnemonic = generate_mnemonic_from_coin_flips(entropy) + + elif method == 'final_word': + num_input_words = len(entropy.split()) + if num_input_words not in [11, 12, 23, 24]: + print(f"Final word entropy must be 11, 12, 23, or 24 words ({num_input_words} provided)") + exit(1) + + if num_input_words in [11, 23]: + # We need to fill the last bits of entropy + if num_input_words == 11: + # 7 final bits of entropy in the 12th word (7 + 4-bit checksum) + num_final_entropy_bits = 7 + else: + # 3 final bits of entropy in the 24th word (3 + 8-bit checksum) + num_final_entropy_bits = 3 + + final_entropy_method = None + while final_entropy_method not in ['1', '2', '3']: + final_entropy_method = input(f""" + How would you like to fill the final {num_final_entropy_bits} bits of entropy? + + 1.) {num_final_entropy_bits} coin flips + 2.) Select an additional word from the wordlist + 3.) Fill with zeros + + Type 1, 2, or 3: """) + + if final_entropy_method == '1': + coin_flips = input(f""" Enter {num_final_entropy_bits} coin flips as 0 or 1 (e.g. { "".join(str(random.randint(0,1)) for f in range(0, num_final_entropy_bits)) }): """) + if len(coin_flips) != num_final_entropy_bits: + print(f"Invalid number of coin flips: needed {num_final_entropy_bits}, got {len(coin_flips)}") + final_word = get_partial_final_word(coin_flips) + entropy += f" {final_word}" + + elif final_entropy_method == '2': + final_word = input(f""" Enter the final word: """) + if final_word not in WORDLIST__ENGLISH: + print(f"Invalid word: {final_word}") + exit(1) + entropy += f" {final_word}" + + elif final_entropy_method == '3': + # Nothing to do; just pass the 11 or 23 words straight to calculate_checksum + pass + + mnemonic = calculate_checksum(entropy) + + print("\n") + if is_rand_seed: + print("\t***** This is a demo seed. Do not use it to store funds!!! *****") + + print(f"""\t{" ".join(mnemonic)}\n""") + + if is_rand_seed: + print(f"\tEntropy: {entropy}\n") + + if method == "dice": + print(f"""\tVerify at iancoleman.io/bip39 or bitcoiner.guide/seed using "Base 10" or "Hex" mode.\n""") + elif method == "coins": + print(f"""\tVerify at iancoleman.io/bip39 or bitcoiner.guide/seed using "Binary" mode.\n""") \ No newline at end of file diff --git a/src/seedsigner/views/tools_views.py b/src/seedsigner/views/tools_views.py index 12a4493b..b94c9b46 100644 --- a/src/seedsigner/views/tools_views.py +++ b/src/seedsigner/views/tools_views.py @@ -188,8 +188,8 @@ class ToolsImageEntropyMnemonicLengthView(View): ****************************************************************************""" class ToolsDiceEntropyMnemonicLengthView(View): def run(self): - TWELVE = "12 words (50 rolls)" - TWENTY_FOUR = "24 words (99 rolls)" + TWELVE = f"12 words ({mnemonic_generation.DICE__NUM_ROLLS__12WORD} rolls)" + TWENTY_FOUR = f"24 words ({mnemonic_generation.DICE__NUM_ROLLS__24WORD} rolls)" button_data = [TWELVE, TWENTY_FOUR] selected_menu_num = ButtonListScreen( @@ -203,10 +203,10 @@ class ToolsDiceEntropyMnemonicLengthView(View): return Destination(BackStackView) elif button_data[selected_menu_num] == TWELVE: - return Destination(ToolsDiceEntropyEntryView, view_args=dict(total_rolls=50)) + return Destination(ToolsDiceEntropyEntryView, view_args=dict(total_rolls=mnemonic_generation.DICE__NUM_ROLLS__12WORD)) elif button_data[selected_menu_num] == TWENTY_FOUR: - return Destination(ToolsDiceEntropyEntryView, view_args=dict(total_rolls=99)) + return Destination(ToolsDiceEntropyEntryView, view_args=dict(total_rolls=mnemonic_generation.DICE__NUM_ROLLS__24WORD)) diff --git a/tests/test_mnemonic_generation.py b/tests/test_mnemonic_generation.py index 76298d34..000ce4d7 100644 --- a/tests/test_mnemonic_generation.py +++ b/tests/test_mnemonic_generation.py @@ -12,16 +12,17 @@ def test_dice_rolls(): dice_rolls = "" for i in range(0, 99): # Do not need truly rigorous random for this test - dice_rolls += str(random.randint(0, 5)) + dice_rolls += str(random.randint(1, 6)) mnemonic = mnemonic_generation.generate_mnemonic_from_dice(dice_rolls) + assert len(mnemonic) == 24 assert bip39.mnemonic_is_valid(" ".join(mnemonic)) dice_rolls = "" - for i in range(0, 50): + for i in range(0, mnemonic_generation.DICE__NUM_ROLLS__12WORD): # Do not need truly rigorous random for this test - dice_rolls += str(random.randint(0, 5)) + dice_rolls += str(random.randint(1, 6)) mnemonic = mnemonic_generation.generate_mnemonic_from_dice(dice_rolls) assert len(mnemonic) == 12 @@ -29,18 +30,40 @@ def test_dice_rolls(): -def test_calculate_checksum(): - """ Given an 11-word or 23-word mnemonic, the calculated checksum should yield a +def test_calculate_checksum_input_type(): + """ + Given an 11-word or 23-word mnemonic, the calculated checksum should yield a valid complete mnemonic. + + calculate_checksum should accept the mnemonic as: + * a list of strings + * string: "A B C", "A, B, C", "A,B,C" """ # Test mnemonics from https://iancoleman.io/bip39/ + def _try_all_input_formats(partial_mnemonic: str): + # List of strings + mnemonic = mnemonic_generation.calculate_checksum(partial_mnemonic.split(" ")) + assert bip39.mnemonic_is_valid(" ".join(mnemonic)) + + # Comma-separated string + mnemonic = mnemonic_generation.calculate_checksum(partial_mnemonic.replace(" ", ",")) + assert bip39.mnemonic_is_valid(" ".join(mnemonic)) + + # Comma-separated string w/space + mnemonic = mnemonic_generation.calculate_checksum(partial_mnemonic.replace(" ", ", ")) + assert bip39.mnemonic_is_valid(" ".join(mnemonic)) + + # Space-separated string + mnemonic = mnemonic_generation.calculate_checksum(partial_mnemonic) + assert bip39.mnemonic_is_valid(" ".join(mnemonic)) + partial_mnemonic = "crawl focus rescue cable view pledge rather dinner cousin unfair day" - mnemonic = mnemonic_generation.calculate_checksum(partial_mnemonic.split(" "), wordlist_language_code=SettingsConstants.WORDLIST_LANGUAGE__ENGLISH) - assert bip39.mnemonic_is_valid(" ".join(mnemonic)) + _try_all_input_formats(partial_mnemonic) partial_mnemonic = "bubble father debate ankle injury fence mesh evolve section wet coyote violin pyramid flower rent arrow round clutch myth safe base skin mobile" - mnemonic = mnemonic_generation.calculate_checksum(partial_mnemonic.split(" "), wordlist_language_code=SettingsConstants.WORDLIST_LANGUAGE__ENGLISH) - assert bip39.mnemonic_is_valid(" ".join(mnemonic)) + _try_all_input_formats(partial_mnemonic) + + def test_calculate_checksum_invalid_mnemonics(): @@ -50,25 +73,25 @@ def test_calculate_checksum_invalid_mnemonics(): with pytest.raises(Exception) as e: # Mnemonic is too short: 10 words instead of 11 partial_mnemonic = "abandon " * 9 + "about" - mnemonic_generation.calculate_checksum(partial_mnemonic.split(" "), wordlist_language_code=SettingsConstants.WORDLIST_LANGUAGE__ENGLISH) + mnemonic_generation.calculate_checksum(partial_mnemonic.split(" ")) assert "12- or 24-word" in str(e) with pytest.raises(Exception) as e: # Valid mnemonic but unsupported length mnemonic = "devote myth base logic dust horse nut collect buddy element eyebrow visit empty dress jungle" - mnemonic_generation.calculate_checksum(mnemonic.split(" "), wordlist_language_code=SettingsConstants.WORDLIST_LANGUAGE__ENGLISH) + mnemonic_generation.calculate_checksum(mnemonic.split(" ")) assert "12- or 24-word" in str(e) with pytest.raises(Exception) as e: # Mnemonic is too short: 22 words instead of 23 partial_mnemonic = "abandon " * 21 + "about" - mnemonic_generation.calculate_checksum(partial_mnemonic.split(" "), wordlist_language_code=SettingsConstants.WORDLIST_LANGUAGE__ENGLISH) + mnemonic_generation.calculate_checksum(partial_mnemonic.split(" ")) assert "12- or 24-word" in str(e) with pytest.raises(ValueError) as e: # Invalid BIP-39 word partial_mnemonic = "foobar " * 11 + "about" - mnemonic_generation.calculate_checksum(partial_mnemonic.split(" "), wordlist_language_code=SettingsConstants.WORDLIST_LANGUAGE__ENGLISH) + mnemonic_generation.calculate_checksum(partial_mnemonic.split(" ")) assert "not in the dictionary" in str(e) @@ -78,17 +101,17 @@ def test_calculate_checksum_with_default_final_word(): the mnemonic. """ partial_mnemonic = "crawl focus rescue cable view pledge rather dinner cousin unfair day" - mnemonic1 = mnemonic_generation.calculate_checksum(partial_mnemonic.split(" "), wordlist_language_code=SettingsConstants.WORDLIST_LANGUAGE__ENGLISH) + mnemonic1 = mnemonic_generation.calculate_checksum(partial_mnemonic.split(" ")) partial_mnemonic += " abandon" - mnemonic2 = mnemonic_generation.calculate_checksum(partial_mnemonic.split(" "), wordlist_language_code=SettingsConstants.WORDLIST_LANGUAGE__ENGLISH) + mnemonic2 = mnemonic_generation.calculate_checksum(partial_mnemonic.split(" ")) assert mnemonic1 == mnemonic2 partial_mnemonic = "bubble father debate ankle injury fence mesh evolve section wet coyote violin pyramid flower rent arrow round clutch myth safe base skin mobile" - mnemonic1 = mnemonic_generation.calculate_checksum(partial_mnemonic.split(" "), wordlist_language_code=SettingsConstants.WORDLIST_LANGUAGE__ENGLISH) + mnemonic1 = mnemonic_generation.calculate_checksum(partial_mnemonic.split(" ")) partial_mnemonic += " abandon" - mnemonic2 = mnemonic_generation.calculate_checksum(partial_mnemonic.split(" "), wordlist_language_code=SettingsConstants.WORDLIST_LANGUAGE__ENGLISH) + mnemonic2 = mnemonic_generation.calculate_checksum(partial_mnemonic.split(" ")) assert mnemonic1 == mnemonic2