Skip to content
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

[Feature] Add SLIP-39 Shamir's secret sharing import support for SeedSigner #636

Draft
wants to merge 11 commits into
base: dev
Choose a base branch
from
218 changes: 218 additions & 0 deletions src/seedsigner/gui/screens/seed_screens.py
Original file line number Diff line number Diff line change
Expand Up @@ -1631,3 +1631,221 @@ def __post_init__(self):
screen_y=derivation_path_display.screen_y + derivation_path_display.height + 2*GUIConstants.COMPONENT_PADDING,
)
self.components.append(address_display)



@dataclass
class NumericEntryScreen(BaseTopNavScreen):
title: str = ""
entered_number: str = ""

def __post_init__(self):
super().__post_init__()

keys_number = "0123456789"

# Set up the keyboard params
self.right_panel_buttons_width = 56

text_entry_display_y = self.top_nav.height
text_entry_display_height = 30

keyboard_start_y = text_entry_display_y + text_entry_display_height + GUIConstants.COMPONENT_PADDING

self.keyboard_digits = Keyboard(
draw=self.renderer.draw,
charset=keys_number,
rows=3,
cols=5,
rect=(
GUIConstants.COMPONENT_PADDING,
keyboard_start_y,
self.canvas_width - GUIConstants.COMPONENT_PADDING - self.right_panel_buttons_width,
self.canvas_height - GUIConstants.EDGE_PADDING
),
additional_keys=[
Keyboard.KEY_CURSOR_LEFT,
Keyboard.KEY_CURSOR_RIGHT,
Keyboard.KEY_BACKSPACE
],
auto_wrap=[Keyboard.WRAP_LEFT, Keyboard.WRAP_RIGHT]
)

self.text_entry_display = TextEntryDisplay(
canvas=self.renderer.canvas,
rect=(
GUIConstants.EDGE_PADDING,
text_entry_display_y,
self.canvas_width - self.right_panel_buttons_width,
text_entry_display_y + text_entry_display_height
),
cursor_mode=TextEntryDisplay.CURSOR_MODE__BAR,
is_centered=False,
cur_text=''.join(self.entered_number)
)

# Nudge the buttons off the right edge w/padding
hw_button_x = self.canvas_width - self.right_panel_buttons_width + GUIConstants.COMPONENT_PADDING

# Calc center button position first
hw_button_y = int((self.canvas_height - GUIConstants.BUTTON_HEIGHT)/2)

self.hw_button3 = IconButton(
icon_name=SeedSignerIconConstants.CHECK,
icon_color=GUIConstants.SUCCESS_COLOR,
width=self.right_panel_buttons_width,
screen_x=hw_button_x,
screen_y=hw_button_y + 3*GUIConstants.COMPONENT_PADDING + GUIConstants.BUTTON_HEIGHT,
)


def _render(self):
super()._render()

self.text_entry_display.render()
self.hw_button3.render()
self.keyboard_digits.render_keys()

self.renderer.show_image()

def _run(self):
cursor_position = len(self.entered_number)

cur_keyboard = self.keyboard_digits

# Start the interactive update loop
while True:
input = self.hw_inputs.wait_for(
HardwareButtonsConstants.ALL_KEYS,
check_release=True,
release_keys=[HardwareButtonsConstants.KEY_PRESS, HardwareButtonsConstants.KEY1, HardwareButtonsConstants.KEY2, HardwareButtonsConstants.KEY3]
)

keyboard_swap = False

# Check our two possible exit conditions
# TODO: note the unusual return value, consider refactoring to a Response object in the future
if input == HardwareButtonsConstants.KEY3:
# Save!
# First light up key3
self.hw_button3.is_selected = True
self.hw_button3.render()
self.renderer.show_image()
return dict(entered_number=self.entered_number)

elif input == HardwareButtonsConstants.KEY_PRESS and self.top_nav.is_selected:
# Back button clicked
return dict(entered_number=self.entered_number, is_back_button=True)




# Process normal input
if input in [HardwareButtonsConstants.KEY_UP, HardwareButtonsConstants.KEY_DOWN] and self.top_nav.is_selected:
# We're navigating off the previous button
self.top_nav.is_selected = False
self.top_nav.render_buttons()

# Override the actual input w/an ENTER signal for the Keyboard
if input == HardwareButtonsConstants.KEY_DOWN:
input = Keyboard.ENTER_TOP
else:
input = Keyboard.ENTER_BOTTOM
elif input in [HardwareButtonsConstants.KEY_LEFT, HardwareButtonsConstants.KEY_RIGHT] and self.top_nav.is_selected:
# ignore
continue

ret_val = cur_keyboard.update_from_input(input)

# Now process the result from the keyboard
if ret_val in Keyboard.EXIT_DIRECTIONS:
self.top_nav.is_selected = True
self.top_nav.render_buttons()

elif ret_val in Keyboard.ADDITIONAL_KEYS and input == HardwareButtonsConstants.KEY_PRESS:
if ret_val == Keyboard.KEY_BACKSPACE["code"]:
if cursor_position == 0:
pass
elif cursor_position == len(self.entered_number):
self.entered_number = self.entered_number[:-1]
else:
self.entered_number = self.entered_number[:cursor_position - 1] + self.entered_number[cursor_position:]

cursor_position -= 1

elif ret_val == Keyboard.KEY_CURSOR_LEFT["code"]:
cursor_position -= 1
if cursor_position < 0:
cursor_position = 0

elif ret_val == Keyboard.KEY_CURSOR_RIGHT["code"]:
cursor_position += 1
if cursor_position > len(self.entered_number):
cursor_position = len(self.entered_number)

elif ret_val == Keyboard.KEY_SPACE["code"]:
if cursor_position == len(self.entered_number):
self.entered_number += " "
else:
self.entered_number = self.entered_number[:cursor_position] + " " + self.entered_number[cursor_position:]
cursor_position += 1

# Update the text entry display and cursor
self.text_entry_display.render(self.entered_number, cursor_position)

elif input == HardwareButtonsConstants.KEY_PRESS and ret_val not in Keyboard.ADDITIONAL_KEYS:
# User has locked in the current letter
if cursor_position == len(self.entered_number):
self.entered_number += ret_val
else:
self.entered_number = self.entered_number[:cursor_position] + ret_val + self.entered_number[cursor_position:]
cursor_position += 1

# Update the text entry display and cursor
self.text_entry_display.render(self.entered_number, cursor_position)

elif input in HardwareButtonsConstants.KEYS__LEFT_RIGHT_UP_DOWN or keyboard_swap:
# Live joystick movement; haven't locked this new letter in yet.
# Leave current spot blank for now. Only update the active keyboard keys
# when a selection has been locked in (KEY_PRESS) or removed ("del").
pass


self.renderer.show_image()



@dataclass
class SeedEntryShamirThresholdScreen(NumericEntryScreen):
title: str = "SLIP-39 Threshold"



@dataclass
class SeedEntryShamirShareCountScreen(NumericEntryScreen):
title: str = "SLIP-39 Share Count"



@dataclass
class ShamirFinalizeScreen(ButtonListScreen):
value_text: str = None
is_bottom_list: bool = True
button_data: list = None

def __post_init__(self):
self.show_back_button = False
self.title = _("Finalize Shamir Share")
super().__post_init__()

self.fingerprint_icontl = IconTextLine(
icon_name=SeedSignerIconConstants.FINGERPRINT,
icon_color=GUIConstants.INFO_COLOR,
icon_size=GUIConstants.ICON_FONT_SIZE + 12,
label_text=_("SLIP-39"),
value_text=self.value_text,
font_size=GUIConstants.get_body_font_size() + 2,
is_text_centered=True,
screen_y=self.top_nav.height + int((self.buttons[0].screen_y - self.top_nav.height) / 2) - 30
)
self.components.append(self.fingerprint_icontl)
32 changes: 31 additions & 1 deletion src/seedsigner/models/seed.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
import hmac

from binascii import hexlify
from embit import bip39, bip32, bip85
from embit import bip39, bip32, bip85, slip39
from embit.networks import NETWORKS
from typing import List

Expand Down Expand Up @@ -35,6 +35,8 @@ def __init__(self,
self.seed_bytes: bytes = None
self._generate_seed()

self._shamir_share_sets: List[(int, int, List[str], str)] = []


@staticmethod
def get_wordlist(wordlist_language_code: str = SettingsConstants.WORDLIST_LANGUAGE__ENGLISH) -> List[str]:
Expand Down Expand Up @@ -164,6 +166,33 @@ def get_bip85_child_mnemonic(self, bip85_index: int, bip85_num_words: int, netwo
return bip85.derive_mnemonic(root, bip85_num_words, bip85_index)


# Shamir's Secret Sharing

@classmethod
def recover_from_shares(cls, shares: list[str], slip39_passphrase: str = ""):
mnemonic = slip39.ShareSet.recover_mnemonic(shares, slip39_passphrase.encode('utf-8'))
return cls(mnemonic.split(), wordlist_language_code = SettingsConstants.WORDLIST_LANGUAGE__ENGLISH)


@staticmethod
def get_slip39_wordlist(wordlist_language_code: str = SettingsConstants.WORDLIST_LANGUAGE__ENGLISH) -> List[str]:
if wordlist_language_code == SettingsConstants.WORDLIST_LANGUAGE__ENGLISH:
return slip39.SLIP39_WORDS
else:
raise Exception(f"Unrecognized wordlist_language_code {wordlist_language_code}")


@property
def slip39_wordlist(self) -> List[str]:
return Seed.get_slip39_wordlist(self.wordlist_language_code)


@property
def slip39_passphrase_label(self) -> str:
#return SettingsConstants.LABEL__BIP39_PASSPHRASE
return "SLIP-39 Passphrase"


### override operators
def __eq__(self, other):
if isinstance(other, Seed):
Expand Down Expand Up @@ -239,3 +268,4 @@ def seedqr_supported(self) -> bool:
@property
def bip85_supported(self) -> bool:
return False

47 changes: 47 additions & 0 deletions src/seedsigner/models/seed_storage.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ def __init__(self) -> None:
self.pending_seed: Seed = None
self._pending_mnemonic: List[str] = []
self._pending_is_electrum : bool = False
self._pending_shamir_share_set: List[str] = []


def set_pending_seed(self, seed: Seed):
Expand Down Expand Up @@ -103,3 +104,49 @@ def convert_pending_mnemonic_to_pending_seed(self):
def discard_pending_mnemonic(self):
self._pending_mnemonic = []
self._pending_is_electrum = False


# Shamir shares

def init_pending_shamir_share_set(self, num_words: int = 20, num_shares: int = 2, is_electrum: bool = False):
self._pending_mnemonic = [None] * num_words
self._pending_shamir_share_set = [None] * num_shares
self._pending_is_electrum = is_electrum


def update_pending_shamir_share_set(self, index: int):
"""
Replaces the nth share in the pending shamir share.

* may specify a negative `index` (e.g. -1 is the last word).
"""
if index >= len(self._pending_shamir_share_set):
raise Exception(f"index {index} is too high")
self._pending_shamir_share_set[index] = self._pending_mnemonic
if index < len(self._pending_shamir_share_set) - 1:
self.discard_pending_mnemonic()
self.init_pending_mnemonic(len(self._pending_shamir_share_set[index]))


def get_pending_shamir_share_set_share(self, index: int) -> List[str]:
if index < len(self._pending_shamir_share_set):
return self._pending_shamir_share_set[index]
return None


@property
def pending_shamir_share_set_length(self) -> int:
return len(self._pending_shamir_share_set)


def discard_pending_shamir_share_set(self):
self._pending_shamir_share_set = []


def convert_pending_shamir_share_set_to_pending_seed(self, passphrase: str = '', clean: bool = True):
share_set_formatted = [" ".join(share) for share in self._pending_shamir_share_set]
self.pending_seed = Seed.recover_from_shares(share_set_formatted, passphrase)
if clean:
self.discard_pending_mnemonic()
self.discard_pending_shamir_share_set()

9 changes: 9 additions & 0 deletions src/seedsigner/models/settings_definition.py
Original file line number Diff line number Diff line change
Expand Up @@ -193,6 +193,7 @@ def map_network_to_embit(cls, network) -> str:
SETTING__BIP85_CHILD_SEEDS = "bip85_child_seeds"
SETTING__ELECTRUM_SEEDS = "electrum_seeds"
SETTING__MESSAGE_SIGNING = "message_signing"
SETTING__SSS = "sss_seeds"
SETTING__PRIVACY_WARNINGS = "privacy_warnings"
SETTING__DIRE_WARNINGS = "dire_warnings"
SETTING__QR_BRIGHTNESS_TIPS = "qr_brightness_tips"
Expand Down Expand Up @@ -515,6 +516,14 @@ class SettingsDefinition:
visibility=SettingsConstants.VISIBILITY__ADVANCED,
default_value=SettingsConstants.OPTION__DISABLED),

SettingsEntry(category=SettingsConstants.CATEGORY__FEATURES,
attr_name=SettingsConstants.SETTING__SSS,
abbreviated_name="sss",
display_name="Shamir's Secret Sharing",
help_text="Shares import only",
visibility=SettingsConstants.VISIBILITY__ADVANCED,
default_value=SettingsConstants.OPTION__DISABLED),

SettingsEntry(category=SettingsConstants.CATEGORY__FEATURES,
attr_name=SettingsConstants.SETTING__PRIVACY_WARNINGS,
abbreviated_name="priv_warn",
Expand Down
Loading
Loading