Skip to content

Multiwallet Feature: Generate Seed from Dice Rolls #147

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 2 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 35 additions & 0 deletions buidl/mnemonic.py
Original file line number Diff line number Diff line change
@@ -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

Expand Down Expand Up @@ -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")])
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: this is fine, but can be made simpler (and with a clearer error message) without the list comprehension.

valid_dice_rolls = set(["1", "2", "3", "4", "5", "6"])
for roll in dice_rolls:
    if roll not in valid_dice_rolls:
        raise ValueError(f"Invalid dice roll: {roll}")

> 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)
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

While technically correct (and great to see the code on how it's done), this would be easier to read for developers who are not cryptographically inclined if you included the calculation results in the mnemonic_entropy dict above. Otherwise, if you get an error for an where num_words is 12 you don't know how many rolls are needed for another entry where num_words is 24.

Something like

entropy_calcs_dict = {
    # num_words: [bits_of_entropy, min_rolls_needed],
    12: [128, 50],
    15: [160, 62],
    18: [192, 75],
    21: [224, 87],
    24: [256, 100],
}
# min_rolls_needed is calculated as ceil(num_words / entropy_per_roll), where entropy_per_roll is log2(6)=2.585

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)
Expand Down
131 changes: 130 additions & 1 deletion buidl/test/test_mnemonic.py
Original file line number Diff line number Diff line change
@@ -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


Expand Down Expand Up @@ -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)
)
80 changes: 79 additions & 1 deletion multiwallet.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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(
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This entire method needs a while True block, otherwise providing an invalid entry will break (good), but the UI of that for the end-user starts them over (bad).

"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
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This needs to handle bad entries (like someone entering a letter, space, a number not 1-6, etc) gracefully



#####################################################################
# PSBT Signer
#####################################################################
Expand Down Expand Up @@ -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)

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This has a lot of overlap with do_generate_seed(), what do you think about refactoring the code so that the shared code only lives in one place? For example, I noticed that yours doesn't include the footgun warning that is in do_generate_seed. DRYing it out would seem to make more sense.

def do_create_output_descriptors(self, arg):
"""Combine m-of-n public key records into a multisig output descriptor (account map)"""

Expand Down
53 changes: 53 additions & 0 deletions test_multiwallet.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down