Merge pull request #197 from kdmukai/final_word_v2

Updated "Final Word" calculation flow
This commit is contained in:
Nick Klockenga
2022-06-16 09:16:09 -04:00
committed by GitHub
6 changed files with 380 additions and 36 deletions
+3 -1
View File
@@ -289,7 +289,9 @@ class TextArea(BaseComponent):
# fits in its bounding rect (plus accounting for edge padding) using its given
# font.
# Measure from left baseline ("ls")
# getbbox() seems to ignore "\n" so won't affect height calcs
# TODO: getbbox() seems to ignore "\n" so isn't properly factored into height
# calcs and yields incorrect full_text_width. For now must specify self.height to
# render properly. Centering will be wrong.
(left, top, full_text_width, bottom) = self.font.getbbox(self.text, anchor="ls")
self.text_font_height = -1 * top
self.bbox_height = self.text_font_height + bottom
+189 -1
View File
@@ -151,7 +151,195 @@ class ToolsDiceEntropyEntryScreen(KeyboardScreen):
@dataclass
class ToolsCalcFinalWordShowFinalWordScreen(ButtonListScreen):
class ToolsCalcFinalWordFinalizePromptScreen(ButtonListScreen):
mnemonic_length: int = None
num_entropy_bits: int = None
def __post_init__(self):
self.title = "Build Final Word"
self.is_bottom_list = True
self.is_button_text_centered = True
super().__post_init__()
self.components.append(TextArea(
text=f"The {self.mnemonic_length}th word is built from {self.num_entropy_bits} more entropy bits plus auto-calculated checksum.",
screen_y=self.top_nav.height + GUIConstants.COMPONENT_PADDING,
))
@dataclass
class ToolsCoinFlipEntryScreen(KeyboardScreen):
def __post_init__(self):
# Override values set by the parent class
self.title = f"Coin Flip 1/{self.return_after_n_chars}"
# Specify the keys in the keyboard
self.rows = 1
self.cols = 4
self.key_height = GUIConstants.TOP_NAV_TITLE_FONT_SIZE + 2 + 2*GUIConstants.EDGE_PADDING
self.keys_charset = "10"
# Now initialize the parent class
super().__post_init__()
self.components.append(TextArea(
text="Heads = 1",
screen_y = self.keyboard.rect[3] + 4*GUIConstants.COMPONENT_PADDING,
))
self.components.append(TextArea(
text="Tails = 0",
screen_y = self.components[-1].screen_y + self.components[-1].height + GUIConstants.COMPONENT_PADDING,
))
def update_title(self) -> bool:
self.title = f"Coin Flip {self.cursor_position + 1}/{self.return_after_n_chars}"
return True
@dataclass
class ToolsCalcFinalWordScreen(ButtonListScreen):
selected_final_word: str = None
selected_final_bits: str = None
checksum_bits: str = None
actual_final_word: str = None
def __post_init__(self):
self.is_bottom_list = True
super().__post_init__()
# First what's the total bit display width and where do the checksum bits start?
bit_font_size = GUIConstants.BUTTON_FONT_SIZE + 2
font = Fonts.get_font(GUIConstants.FIXED_WIDTH_EMPHASIS_FONT_NAME, bit_font_size)
(left, top, bit_display_width, bit_font_height) = font.getbbox("0" * 11, anchor="lt")
(left, top, checksum_x, bottom) = font.getbbox("0" * (11 - len(self.checksum_bits)), anchor="lt")
bit_display_x = int((self.canvas_width - bit_display_width)/2)
checksum_x += bit_display_x
# Display the user's additional entropy input
if self.selected_final_word:
selection_text = self.selected_final_word
keeper_selected_bits = self.selected_final_bits[:11 - len(self.checksum_bits)]
# The word's least significant bits will be rendered differently to convey
# the fact that they're being discarded.
discard_selected_bits = self.selected_final_bits[-1*len(self.checksum_bits):]
else:
# User entered coin flips or all zeros
selection_text = self.selected_final_bits
keeper_selected_bits = self.selected_final_bits
# We'll append spacer chars to preserve the vertical alignment (most
# significant n bits always rendered in same column)
discard_selected_bits = "_" * (len(self.checksum_bits))
self.components.append(TextArea(
text=f"""Your input: \"{selection_text}\"""",
screen_y=self.top_nav.height,
))
# ...and that entropy's associated 11 bits
screen_y=self.components[-1].screen_y + self.components[-1].height + GUIConstants.COMPONENT_PADDING
self.components.append(TextArea(
text=keeper_selected_bits,
font_name=GUIConstants.FIXED_WIDTH_EMPHASIS_FONT_NAME,
font_size=bit_font_size,
edge_padding=0,
screen_x=bit_display_x,
screen_y=screen_y,
height=bit_font_height,
is_text_centered=False,
))
# Render the least significant bits that will be replaced by the checksum in a
# de-emphasized font color.
self.components.append(TextArea(
text=discard_selected_bits,
font_name=GUIConstants.FIXED_WIDTH_EMPHASIS_FONT_NAME,
font_color=GUIConstants.LABEL_FONT_COLOR,
font_size=bit_font_size,
edge_padding=0,
screen_x=checksum_x,
screen_y=screen_y,
height=bit_font_height,
is_text_centered=False,
))
# Show the checksum..
self.components.append(TextArea(
text="Checksum",
edge_padding=0,
screen_y=self.components[-1].screen_y + self.components[-1].height + 2*GUIConstants.COMPONENT_PADDING,
))
# ...and its actual bits. Prepend spacers to keep vertical alignment
checksum_spacer = "_" * (11 - len(self.checksum_bits))
screen_y = self.components[-1].screen_y + self.components[-1].height + GUIConstants.COMPONENT_PADDING
# This time we de-emphasize the prepended spacers that are irrelevant
self.components.append(TextArea(
text=checksum_spacer,
font_name=GUIConstants.FIXED_WIDTH_EMPHASIS_FONT_NAME,
font_color=GUIConstants.LABEL_FONT_COLOR,
font_size=bit_font_size,
edge_padding=0,
screen_x=bit_display_x,
screen_y=screen_y,
height=bit_font_height,
is_text_centered=False,
))
# And especially highlight (orange!) the actual checksum bits
self.components.append(TextArea(
text=self.checksum_bits,
font_name=GUIConstants.FIXED_WIDTH_EMPHASIS_FONT_NAME,
font_size=bit_font_size,
font_color=GUIConstants.ACCENT_COLOR,
edge_padding=0,
screen_x=checksum_x,
screen_y=screen_y,
is_text_centered=False,
))
# And now the *actual* final word after merging the bit data
self.components.append(TextArea(
text=f"""Final Word: \"{self.actual_final_word}\"""",
screen_y=self.components[-1].screen_y + self.components[-1].height + 2*GUIConstants.COMPONENT_PADDING,
))
# Once again show the bits that came from the user's entropy...
num_checksum_bits = len(self.checksum_bits)
user_component = self.selected_final_bits[:11 - num_checksum_bits]
screen_y = self.components[-1].screen_y + self.components[-1].height + GUIConstants.COMPONENT_PADDING
self.components.append(TextArea(
text=user_component,
font_name=GUIConstants.FIXED_WIDTH_EMPHASIS_FONT_NAME,
font_size=bit_font_size,
edge_padding=0,
screen_x=bit_display_x,
screen_y=screen_y,
is_text_centered=False,
))
# ...and append the checksum's bits, still highlighted in orange
self.components.append(TextArea(
text=self.checksum_bits,
font_name=GUIConstants.FIXED_WIDTH_EMPHASIS_FONT_NAME,
font_color=GUIConstants.ACCENT_COLOR,
font_size=bit_font_size,
edge_padding=0,
screen_x=checksum_x,
screen_y=screen_y,
is_text_centered=False,
))
@dataclass
class ToolsCalcFinalWordDoneScreen(ButtonListScreen):
final_word: str = None
mnemonic_word_length: int = 12
fingerprint: str = None
+15 -13
View File
@@ -8,23 +8,25 @@ from typing import List
from seedsigner.models.seed import Seed
def calculate_checksum(partial_mnemonic: list, wordlist_language_code: str) -> List[str]:
""" Provide 11- or 23-word mnemonic, returns complete mnemonic w/checksum as a list """
if len(partial_mnemonic) not in [11, 23]:
raise Exception("Pass in a 11- or 23-word mnemonic")
def calculate_checksum(mnemonic: list, wordlist_language_code: str) -> List[str]:
"""
Provide 12- or 24-word mnemonic, returns complete mnemonic w/checksum as a list.
If 11- or 23-words are provided, append word `0000` to end of list as temp final
word.
"""
if len(mnemonic) in [11, 23]:
mnemonic.append(Seed.get_wordlist(wordlist_language_code)[0])
if len(mnemonic) not in [12, 24]:
raise Exception("Pass in a 12- or 24-word mnemonic")
# Work on a copy of the input list
mnemonic_copy = partial_mnemonic.copy()
# 12-word seeds contribute 7 bits of entropy to the final word; 24-word seeds
# contribute 3 bits. But we don't have any partial entropy bits to use to help us
# create the final word. So just default to filling those missing values with zeroes
# ("abandon" is word 0000, so effectively inserts zeroes).
mnemonic_copy.append("abandon")
mnemonic_copy = mnemonic.copy()
# Convert the resulting mnemonic to bytes, but we `ignore_checksum` validation
# because we have to assume it's incorrect since we just hard-coded it above; we'll
# fix that next.
# 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))
# This function will convert the bytes back into a mnemonic, but it will also
+16 -17
View File
@@ -127,13 +127,14 @@ class SeedMnemonicEntryView(View):
if ret == RET_CODE__BACK_BUTTON:
if self.cur_word_index > 0:
return Destination(
SeedMnemonicEntryView,
view_args={
"cur_word_index": self.cur_word_index - 1,
"is_calc_final_word": self.is_calc_final_word
}
)
return Destination(BackStackView)
# return Destination(
# SeedMnemonicEntryView,
# view_args={
# "cur_word_index": self.cur_word_index - 1,
# "is_calc_final_word": self.is_calc_final_word
# }
# )
else:
self.controller.storage.discard_pending_mnemonic()
return Destination(MainMenuView)
@@ -142,17 +143,15 @@ class SeedMnemonicEntryView(View):
self.controller.storage.update_pending_mnemonic(ret, self.cur_word_index)
if self.is_calc_final_word and self.cur_word_index == self.controller.storage.pending_mnemonic_length - 2:
# Time to calculate the last word
# TODO: Option to add missing entropy for the last word:
# * 3 bits for a 24-word seed
# * 7 bits for a 12-word seed
from seedsigner.helpers import mnemonic_generation
# Time to calculate the last word. User must decide how they want to specify
# the last bits of entropy for the final word.
from seedsigner.views.tools_views import ToolsCalcFinalWordFinalizePromptView
return Destination(ToolsCalcFinalWordFinalizePromptView)
if self.is_calc_final_word and self.cur_word_index == self.controller.storage.pending_mnemonic_length - 1:
# Time to calculate the last word. User must either select a final word to
# contribute entropy to the checksum word OR we assume 0 ("abandon").
from seedsigner.views.tools_views import ToolsCalcFinalWordShowFinalWordView
full_mnemonic = mnemonic_generation.calculate_checksum(
self.controller.storage.pending_mnemonic[:-1], # Must omit the last word's empty value
wordlist_language_code=self.settings.get_value(SettingsConstants.SETTING__WORDLIST_LANGUAGE)
)
self.controller.storage.update_pending_mnemonic(full_mnemonic[-1], self.cur_word_index+1)
return Destination(ToolsCalcFinalWordShowFinalWordView)
if self.cur_word_index < self.controller.storage.pending_mnemonic_length - 1:
+137 -4
View File
@@ -8,7 +8,7 @@ from PIL.ImageOps import autocontrast
from seedsigner.hardware.camera import Camera
from seedsigner.gui.components import FontAwesomeIconConstants
from seedsigner.gui.screens import (RET_CODE__BACK_BUTTON, ButtonListScreen)
from seedsigner.gui.screens.tools_screens import ToolsDiceEntropyEntryScreen, ToolsImageEntropyFinalImageScreen, ToolsImageEntropyLivePreviewScreen, ToolsCalcFinalWordShowFinalWordScreen
from seedsigner.gui.screens.tools_screens import ToolsCalcFinalWordFinalizePromptScreen, ToolsCalcFinalWordScreen, ToolsCoinFlipEntryScreen, ToolsDiceEntropyEntryScreen, ToolsImageEntropyFinalImageScreen, ToolsImageEntropyLivePreviewScreen, ToolsCalcFinalWordDoneScreen
from seedsigner.helpers import mnemonic_generation
from seedsigner.models.seed import Seed
from seedsigner.models.settings_definition import SettingsConstants
@@ -240,15 +240,148 @@ class ToolsCalcFinalWordNumWordsView(View):
elif button_data[selected_menu_num] == TWELVE:
self.controller.storage.init_pending_mnemonic(12)
return Destination(SeedMnemonicEntryView, view_args={"is_calc_final_word": True})
# return Destination(SeedMnemonicEntryView, view_args=dict(is_calc_final_word=True))
return Destination(SeedMnemonicEntryView, view_args=dict(is_calc_final_word=True))
elif button_data[selected_menu_num] == TWENTY_FOUR:
self.controller.storage.init_pending_mnemonic(24)
return Destination(SeedMnemonicEntryView, view_args={"is_calc_final_word": True})
# return Destination(SeedMnemonicEntryView, view_args=dict(is_calc_final_word=True))
return Destination(SeedMnemonicEntryView, view_args=dict(is_calc_final_word=True))
class ToolsCalcFinalWordFinalizePromptView(View):
def run(self):
mnemonic = self.controller.storage.pending_mnemonic
mnemonic_length = len(mnemonic)
if mnemonic_length == 12:
num_entropy_bits = 7
else:
num_entropy_bits = 3
COIN_FLIPS = "Coin flip entropy"
SELECT_WORD = f"Word selection entropy"
ZEROS = "Finalize with zeros"
button_data = [COIN_FLIPS, SELECT_WORD, ZEROS]
selected_menu_num = ToolsCalcFinalWordFinalizePromptScreen(
mnemonic_length=mnemonic_length,
num_entropy_bits=num_entropy_bits,
button_data=button_data,
).display()
if selected_menu_num == RET_CODE__BACK_BUTTON:
return Destination(BackStackView)
elif button_data[selected_menu_num] == COIN_FLIPS:
return Destination(ToolsCalcFinalWordCoinFlipsView)
elif button_data[selected_menu_num] == SELECT_WORD:
# Clear the final word slot, just in case we're returning via BACK button
self.controller.storage.update_pending_mnemonic(None, mnemonic_length - 1)
return Destination(SeedMnemonicEntryView, view_args=dict(is_calc_final_word=True, cur_word_index=mnemonic_length - 1))
elif button_data[selected_menu_num] == ZEROS:
# User skipped the option to select a final word to provide last bits of
# entropy. We'll insert all zeros and piggy-back on the coin flip attr
wordlist_language_code = self.settings.get_value(SettingsConstants.SETTING__WORDLIST_LANGUAGE)
self.controller.storage.update_pending_mnemonic(Seed.get_wordlist(wordlist_language_code)[0], mnemonic_length - 1)
return Destination(ToolsCalcFinalWordShowFinalWordView, view_args=dict(coin_flips="0" * num_entropy_bits))
class ToolsCalcFinalWordCoinFlipsView(View):
def run(self):
mnemonic = self.controller.storage.pending_mnemonic
mnemonic_length = len(mnemonic)
if mnemonic_length == 12:
total_flips = 7
else:
total_flips = 3
ret_val = ToolsCoinFlipEntryScreen(
return_after_n_chars=total_flips,
).display()
if ret_val == RET_CODE__BACK_BUTTON:
return Destination(BackStackView)
else:
print(ret_val)
binary_string = ret_val + "0" * (11 - total_flips)
wordlist_index = int(binary_string, 2)
wordlist = Seed.get_wordlist(self.controller.settings.get_value(SettingsConstants.SETTING__WORDLIST_LANGUAGE))
word = wordlist[wordlist_index]
self.controller.storage.update_pending_mnemonic(word, mnemonic_length - 1)
return Destination(ToolsCalcFinalWordShowFinalWordView, view_args=dict(coin_flips=ret_val))
class ToolsCalcFinalWordShowFinalWordView(View):
def __init__(self, coin_flips=None):
super().__init__()
self.coin_flips = coin_flips
def run(self):
# Construct the actual final word. The user's selected_final_word
# contributes:
# * 3 bits to a 24-word seed (plus 8-bit checksum)
# * 7 bits to a 12-word seed (plus 4-bit checksum)
from seedsigner.helpers import mnemonic_generation
mnemonic = self.controller.storage.pending_mnemonic
mnemonic_length = len(mnemonic)
wordlist_language_code = self.settings.get_value(SettingsConstants.SETTING__WORDLIST_LANGUAGE)
wordlist = Seed.get_wordlist(wordlist_language_code)
final_mnemonic = mnemonic_generation.calculate_checksum(
mnemonic=self.controller.storage.pending_mnemonic,
wordlist_language_code=wordlist_language_code,
)
self.controller.storage.update_pending_mnemonic(final_mnemonic[-1], mnemonic_length - 1)
# Prep the user's selected word (if there was one) and the actual final word for
# the display.
if self.coin_flips:
selected_final_word = None
selected_final_bits = self.coin_flips
else:
# Convert the user's final word selection into its binary index equivalent
selected_final_word = mnemonic[-1]
selected_final_bits = format(wordlist.index(selected_final_word), '011b')
# And grab the actual final word's checksum bits
actual_final_word = self.controller.storage.pending_mnemonic[-1]
if mnemonic_length == 12:
checksum_bits = format(wordlist.index(actual_final_word), '011b')[-4:]
else:
checksum_bits = format(wordlist.index(actual_final_word), '011b')[-8:]
NEXT = "Next"
button_data = [NEXT]
selected_menu_num = ToolsCalcFinalWordScreen(
title="Final Word Calc",
button_data=button_data,
selected_final_word=selected_final_word,
selected_final_bits=selected_final_bits,
checksum_bits=checksum_bits,
actual_final_word=actual_final_word,
).display()
if selected_menu_num == RET_CODE__BACK_BUTTON:
return Destination(BackStackView)
elif button_data[selected_menu_num] == NEXT:
return Destination(ToolsCalcFinalWordDoneView)
class ToolsCalcFinalWordDoneView(View):
def run(self):
mnemonic = self.controller.storage.pending_mnemonic
mnemonic_word_length = len(mnemonic)
@@ -258,7 +391,7 @@ class ToolsCalcFinalWordShowFinalWordView(View):
DISCARD = ("Discard", None, None, "red")
button_data = [LOAD, DISCARD]
selected_menu_num = ToolsCalcFinalWordShowFinalWordScreen(
selected_menu_num = ToolsCalcFinalWordDoneScreen(
final_word=final_word,
mnemonic_word_length=mnemonic_word_length,
fingerprint=self.controller.storage.get_pending_mnemonic_fingerprint(self.settings.get_value(SettingsConstants.SETTING__NETWORK)),
+20
View File
@@ -43,6 +43,26 @@ def test_calculate_checksum():
def test_calculate_checksum_with_default_final_word():
""" 11-word and 23-word mnemonics use word `0000` as a temp final word to complete
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)
partial_mnemonic += " abandon"
mnemonic2 = mnemonic_generation.calculate_checksum(partial_mnemonic.split(" "), wordlist_language_code=SettingsConstants.WORDLIST_LANGUAGE__ENGLISH)
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)
partial_mnemonic += " abandon"
mnemonic2 = mnemonic_generation.calculate_checksum(partial_mnemonic.split(" "), wordlist_language_code=SettingsConstants.WORDLIST_LANGUAGE__ENGLISH)
assert mnemonic1 == mnemonic2
def test_verify_against_coldcard_sample():
""" https://coldcard.com/docs/verifying-dice-roll-math """
dice_rolls = "123456"