diff --git a/buidl/mnemonic.py b/buidl/mnemonic.py index a95fc4f..ab88034 100644 --- a/buidl/mnemonic.py +++ b/buidl/mnemonic.py @@ -1,6 +1,7 @@ from os import path from secrets import randbits from time import time +from math import ceil from buidl.helper import big_endian_to_int, int_to_big_endian, sha256 @@ -98,6 +99,40 @@ def bytes_to_mnemonic(b, num_bits): return " ".join(mnemonic) +def dice_rolls_to_mnemonic(dice_rolls, num_words=24, allow_low_entropy=False): + """ + returns a mnemonic from a string of 6-sided dice rolls + (>=100 rolls for 24 words recommended) + (>=50 rolls for 12 words recommended) + """ + # check that the number of words provided is 12, 15, 18, 21, or 24 + if num_words not in (12, 15, 18, 21, 24): + raise InvalidBIP39Length( + f"{num_words} words requested (must be 12, 15, 18, 21, or 24 words)" + ) + # check that valid dice rolls have been provided + if not isinstance(dice_rolls, str): + raise ValueError("Dice rolls must be provided as a string") + if ( + len([roll for roll in dice_rolls if roll not in ("1", "2", "3", "4", "5", "6")]) + > 0 + ): + raise ValueError("Dice roll string contained invalid dice numbers") + # entropy (in bits) per 6-sided dice roll (i.e. log2(6)=2.585) + entropy_per_roll = 2.585 + mnemonic_entropy = {12: 128, 15: 160, 18: 192, 21: 224, 24: 256} + # check that the appropriate amount of entropy had been provided + min_rolls_needed = ceil(mnemonic_entropy[num_words] / entropy_per_roll) + if not allow_low_entropy and (len(dice_rolls) < min_rolls_needed): + raise ValueError( + f"Received {len(dice_rolls)} rolls but need at least {min_rolls_needed}" + f" rolls (for {mnemonic_entropy[num_words]} bits of entropy)" + ) + kept_bytes = mnemonic_entropy[num_words] // 8 + rolls_hash = sha256(dice_rolls.encode())[:kept_bytes] + return bytes_to_mnemonic(rolls_hash, kept_bytes * 8) + + class WordList: def __init__(self, filename, num_words): word_file = path.join(path.dirname(__file__), filename) diff --git a/buidl/test/test_mnemonic.py b/buidl/test/test_mnemonic.py index 96db653..c25eede 100644 --- a/buidl/test/test_mnemonic.py +++ b/buidl/test/test_mnemonic.py @@ -1,6 +1,10 @@ from unittest import TestCase -from buidl.mnemonic import secure_mnemonic +from buidl.mnemonic import ( + secure_mnemonic, + dice_rolls_to_mnemonic, + InvalidBIP39Length, +) from buidl.hd import HDPrivateKey @@ -45,3 +49,128 @@ def test_secure_mnemonic_extra_entropy(self): secure_mnemonic(extra_entropy="not an int") with self.assertRaises(ValueError): secure_mnemonic(extra_entropy=-1) + + def test_dice_to_mnemonic(self): + tests = [ # dice_rolls, num_words, allow_low_entropy, expected_mnemonic, expected_exception + [ + "", + 24, + True, + "together mail awful cradle scrub apart hip leader silk slice unusual embark kit can muscle nature nation gown century cram resource citizen throw produce", + None, + ], + [ + "123456", + 24, + True, + "mirror reject rookie talk pudding throw happy era myth already payment own sentence push head sting video explain letter bomb casual hotel rather garment", + None, + ], + [ + "123456123456123456123456123456123456123456123456123456123456123456123456123456123456123456123456123456", + 24, + False, + "more matter caught bind tip twin indicate visa rifle angle defense lizard stock cave cradle injury always mule photo horse range opinion affair garlic", + None, + ], + [ + "523365252662366", + 24, + True, + "dilemma rural physical exhaust divorce escape nut umbrella lawn midnight prosper prevent employ caught mercy student arctic umbrella feed super mad magic crawl fiscal", + None, + ], + [ + "", + 12, + True, + "together mail awful cradle scrub apart hip leader silk slice unusual embark", + None, + ], + [ + "123456", + 12, + True, + "mirror reject rookie talk pudding throw happy era myth already payment owner", + None, + ], + [ + "12345612345612345612345612345612345612345612345612", + 12, + False, + "unveil nice picture region tragic fault cream strike tourist control recipe tourist", + None, + ], + [ + "12345612345612345612345612345612345612345612345612345612345612", + 15, + False, + "end spider topple cliff tomorrow process dismiss produce athlete film monster team vacant ill silk", + None, + ], + [ + "123456123456123456123456123456123456123456123456123456123456123456123456123", + 18, + False, + "melt churn alley retreat flip once enough gather project prosper cannon nasty furnace isolate cost laundry lottery slice", + None, + ], + [ + "123456123456123456123456123456123456123456123456123456123456123456123456123456123456123", + 21, + False, + "start insane amazing fall kite punch owner refuse bone trigger spirit luggage slide sound reopen broom remember nose limb swallow kitten", + None, + ], + [ # low entropy not allowed + "123456", + 24, + False, + "", + ValueError( + "Received 6 rolls but need at least 100 rolls (for 256 bits of entropy)" + ), + ], + [ # Invalid num_words + "123456", + 23, + False, + "", + InvalidBIP39Length( + "23 words requested (must be 12, 15, 18, 21, or 24 words)" + ), + ], + [ # non-string dice rolls + b"123456", + 24, + False, + "", + ValueError("Dice rolls must be provided as a string"), + ], + [ # string containing non-dice values + "1234567", + 24, + False, + "", + ValueError("Dice roll string contained invalid dice numbers"), + ], + ] + + for ( + dice_rolls, + num_words, + allow_low_entropy, + expected_mnemonic, + expected_exception, + ) in tests: + if expected_exception is None: + received_mnemonic = dice_rolls_to_mnemonic( + dice_rolls, num_words, allow_low_entropy + ) + self.assertEqual(received_mnemonic, expected_mnemonic) + else: + with self.assertRaises(type(expected_exception)) as exception_context: + dice_rolls_to_mnemonic(dice_rolls, num_words, allow_low_entropy) + self.assertEqual( + str(exception_context.exception), str(expected_exception) + ) diff --git a/multiwallet.py b/multiwallet.py index 96edc6f..e0c8c32 100755 --- a/multiwallet.py +++ b/multiwallet.py @@ -21,7 +21,7 @@ DEFAULT_P2WSH_PATH, ) from buidl.libsec_status import is_libsec_enabled -from buidl.mnemonic import BIP39 +from buidl.mnemonic import BIP39, dice_rolls_to_mnemonic from buidl.shamir import ShareSet from buidl.psbt import MixedNetwork, PSBT @@ -246,6 +246,35 @@ def _get_bip39_firstwords(): return fw +##################################################################### +# Dice rolls +##################################################################### + + +def _get_num_words(): + num_words = _get_int( + prompt="How many mnemonic words should be generated?", + default=24, + minimum=12, + maximum=24, + ) + if num_words not in (12, 15, 18, 21, 24): + raise ValueError( + "Provided number of mnemonic words must be 12, 15, 18, 21, or 24 words" + ) + return num_words + + +def _get_dice_values(): + dice_vals = input( + blue_fg( + "Enter the numbers from the 6-sided dice rolls" + '\n(e.g., "14625363...", >= 100 values recommended): ' + ) + ).strip() + return dice_vals + + ##################################################################### # PSBT Signer ##################################################################### @@ -487,6 +516,55 @@ def do_generate_seed(self, arg): print_yellow("Copy-paste this into Specter-Desktop:") print_green(key_record) + def do_generate_seed_from_dice(self, arg): + """Calculate bitcoin public and private key information from BIP39 words created from dice rolls""" + + blue_fg("Generates a mnemonic from 6-sided dice rolls.") + num_words = _get_num_words() + dice_vals = _get_dice_values() + + if self.ADVANCED_MODE: + password = _get_password() + else: + password = "" + + if _get_bool(prompt="Use Mainnet?", default=False): + network = "mainnet" + else: + network = "testnet" + + if self.ADVANCED_MODE: + path_to_use = _get_path(network=network) + use_slip132_version_byte = _get_bool( + prompt="Encode with SLIP132 version byte?", default=True + ) + else: + path_to_use = None # buidl will use default path + use_slip132_version_byte = True + + words = dice_rolls_to_mnemonic(dice_vals, num_words) + hd_priv = HDPrivateKey.from_mnemonic( + mnemonic=words, + password=password.encode(), + network=network, + ) + + key_record = hd_priv.generate_p2wsh_key_record( + bip32_path=path_to_use, use_slip132_version_byte=use_slip132_version_byte + ) + + print(yellow_fg("SECRET INFO") + red_fg(" (guard this VERY carefully)")) + print_green( + f"Dice rolls used: {dice_vals}" + f"\nFull ({len(words.split())} word) mnemonic (including last word): {words}" + ) + if password: + print_green(f"Passphrase: {password}") + + print_yellow(f"\nPUBLIC KEY INFO ({network})") + print_yellow("Copy-paste this into Specter-Desktop:") + print_green(key_record) + def do_create_output_descriptors(self, arg): """Combine m-of-n public key records into a multisig output descriptor (account map)""" diff --git a/test_multiwallet.py b/test_multiwallet.py index c247fbc..a87ca61 100644 --- a/test_multiwallet.py +++ b/test_multiwallet.py @@ -72,6 +72,59 @@ def test_seedpicker_basic(self): self.expect("Last word: bacon") self.expect(expected_key_record) + def test_dice_basic(self): + dice_tests = [ # num_words, dice_rolls, is_mainnet, expected_mnemonic_str, expected_key_record + [ + "24", + "123456123456123456123456123456123456123456123456123456123456123456123456123456123456123456123456123456", + "Y", + "Full (24 word) mnemonic (including last word): more matter caught bind tip twin indicate visa rifle angle defense lizard stock cave cradle injury always mule photo horse range opinion affair garlic", + "[cd34af7b/48h/0h/0h/2h]Zpub75DH7vGKCEESq3UW3cL6fQe2VuF2LgZsteVsMvvjC2as9f2wuR2UxhYH9WV5xeNvgeHPhaZQuniaCxc6TP1tqMPNjMbsfnLkDf1S3dXAuHj", + ], + [ + "24", + "123456123456123456123456123456123456123456123456123456123456123456123456123456123456123456123456123456", + "N", + "Full (24 word) mnemonic (including last word): more matter caught bind tip twin indicate visa rifle angle defense lizard stock cave cradle injury always mule photo horse range opinion affair garlic", + "[cd34af7b/48h/1h/0h/2h]Vpub5mBykPsR2S12QVvWiC9eXr7zsLJHNfLbdjF8k5BE85Tk5moEH2ZuBGfn5XZmePouE62PG76GGR4Bu3dD9zs3KfGWCjA8hQYaU9WMqX1Ywgc", + ], + [ + "12", + "12345612345612345612345612345612345612345612345612", + "Y", + "Full (12 word) mnemonic (including last word): unveil nice picture region tragic fault cream strike tourist control recipe tourist", + "85480bc5/48h/0h/0h/2h]Zpub75si7yux5xBVmPDFifgnh8WDUFqYLD9u1HNZXJ7zB14yBJCV1dXkLWokUkSXiYB3zaivyXnw57vFFXoPyXjEjhczEFHk8GtaU3s98wBxebV", + ], + [ + "12", + "12345612345612345612345612345612345612345612345612", + "N", + "Full (12 word) mnemonic (including last word): unveil nice picture region tragic fault cream strike tourist control recipe tourist", + "85480bc5/48h/1h/0h/2h]Vpub5nVGWRi82pLmKvPbos31HGTJ6vtrUHB4p8h54qVBRmmph8KwpGmYwRAyz4GLMSf7TETRXZKm5u4ybtY2u7KYFevt5tqgYBZynJptbfa36QA", + ], + ] + for ( + num_words, + dice_rolls, + is_mainnet, + expected_mnemonic_str, + expected_key_record, + ) in dice_tests: + self.child.sendline("generate_seed_from_dice") + + self.expect("How many mnemonic words should be generated?") + self.child.sendline(num_words) + + self.expect("100 values recommended") + self.child.sendline(dice_rolls) + + self.expect("Use Mainnet?") + self.child.sendline(is_mainnet) + + self.expect("Dice rolls used: " + dice_rolls) + self.expect(expected_mnemonic_str) + self.expect(expected_key_record) + def test_create_output_descriptors_blinded(self): # Blinded example from https://github.com/mflaxman/blind-xpub/blob/90af581695ef4ab1b7c40324c4cd7f2ce70e3403/README.md#create-output-descriptors