diff --git a/electrum/base_wizard.py b/electrum/base_wizard.py
index fbf168bf8..e54e8aea3 100644
--- a/electrum/base_wizard.py
+++ b/electrum/base_wizard.py
@@ -508,19 +508,25 @@ class BaseWizard(Logger):
def restore_from_seed(self):
self.opt_bip39 = True
+ self.opt_slip39 = True
self.opt_ext = True
is_cosigning_seed = lambda x: mnemonic.seed_type(x) in ['standard', 'segwit']
test = mnemonic.is_seed if self.wallet_type == 'standard' else is_cosigning_seed
f = lambda *args: self.run('on_restore_seed', *args)
self.restore_seed_dialog(run_next=f, test=test)
- def on_restore_seed(self, seed, is_bip39, is_ext):
- self.seed_type = 'bip39' if is_bip39 else mnemonic.seed_type(seed)
+ def on_restore_seed(self, seed, seed_type, is_ext):
+ self.seed_type = seed_type if seed_type != 'electrum' else mnemonic.seed_type(seed)
if self.seed_type == 'bip39':
def f(passphrase):
root_seed = bip39_to_seed(seed, passphrase)
self.on_restore_bip43(root_seed)
self.passphrase_dialog(run_next=f, is_restoring=True) if is_ext else f('')
+ elif self.seed_type == 'slip39':
+ def f(passphrase):
+ root_seed = seed.decrypt(passphrase)
+ self.on_restore_bip43(root_seed)
+ self.passphrase_dialog(run_next=f, is_restoring=True) if is_ext else f('')
elif self.seed_type in ['standard', 'segwit']:
f = lambda passphrase: self.run('create_keystore', seed, passphrase)
self.passphrase_dialog(run_next=f, is_restoring=True) if is_ext else f('')
@@ -700,6 +706,7 @@ class BaseWizard(Logger):
seed = mnemonic.Mnemonic('en').make_seed(seed_type=self.seed_type)
self.opt_bip39 = False
self.opt_ext = True
+ self.opt_slip39 = False
f = lambda x: self.request_passphrase(seed, x)
self.show_seed_dialog(run_next=f, seed_text=seed)
diff --git a/electrum/gui/qt/installwizard.py b/electrum/gui/qt/installwizard.py
index 98f81bdd6..088aa239e 100644
--- a/electrum/gui/qt/installwizard.py
+++ b/electrum/gui/qt/installwizard.py
@@ -465,7 +465,7 @@ class InstallWizard(QDialog, MessageBoxMixin, BaseWizard):
config=self.config,
)
self.exec_layout(slayout, title, next_enabled=False)
- return slayout.get_seed(), slayout.is_bip39, slayout.is_ext
+ return slayout.get_seed(), slayout.seed_type, slayout.is_ext
@wizard_dialog
def add_xpub_dialog(self, title, message, is_valid, run_next, allow_multi=False, show_wif_help=False):
@@ -493,6 +493,8 @@ class InstallWizard(QDialog, MessageBoxMixin, BaseWizard):
options.append('ext')
if self.opt_bip39:
options.append('bip39')
+ if self.opt_slip39:
+ options.append('slip39')
title = _('Enter Seed')
message = _('Please enter your seed phrase in order to restore your wallet.')
return self.seed_input(title, message, test, options)
@@ -506,7 +508,7 @@ class InstallWizard(QDialog, MessageBoxMixin, BaseWizard):
_('If you lose your seed, your money will be permanently lost.'),
_('To make sure that you have properly saved your seed, please retype it here.')
])
- seed, is_bip39, is_ext = self.seed_input(title, message, test, None)
+ seed, seed_type, is_ext = self.seed_input(title, message, test, None)
return seed
@wizard_dialog
diff --git a/electrum/gui/qt/seed_dialog.py b/electrum/gui/qt/seed_dialog.py
index 0baadab2d..131fb5841 100644
--- a/electrum/gui/qt/seed_dialog.py
+++ b/electrum/gui/qt/seed_dialog.py
@@ -28,14 +28,17 @@ from typing import TYPE_CHECKING
from PyQt5.QtCore import Qt
from PyQt5.QtGui import QPixmap
from PyQt5.QtWidgets import (QVBoxLayout, QCheckBox, QHBoxLayout, QLineEdit,
- QLabel, QCompleter, QDialog, QStyledItemDelegate)
+ QLabel, QCompleter, QDialog, QStyledItemDelegate,
+ QScrollArea, QWidget, QPushButton)
from electrum.i18n import _
from electrum.mnemonic import Mnemonic, seed_type
from electrum import old_mnemonic
+from electrum import slip39
from .util import (Buttons, OkButton, WWLabel, ButtonsTextEdit, icon_path,
- EnterButton, CloseButton, WindowModalDialog, ColorScheme)
+ EnterButton, CloseButton, WindowModalDialog, ColorScheme,
+ ChoicesLayout)
from .qrtextedit import ShowQRTextEdit, ScanQRTextEdit
from .completion_text_edit import CompletionTextEdit
@@ -64,16 +67,29 @@ class SeedLayout(QVBoxLayout):
def seed_options(self):
dialog = QDialog()
vbox = QVBoxLayout(dialog)
+
+ seed_types = [
+ (value, title) for value, title in (
+ ('electrum', _('Electrum')),
+ ('bip39', _('BIP39 seed')),
+ ('slip39', _('SLIP39 seed')),
+ )
+ if value in self.options or value == 'electrum'
+ ]
+ seed_type_values = [t[0] for t in seed_types]
+
if 'ext' in self.options:
cb_ext = QCheckBox(_('Extend this seed with custom words'))
cb_ext.setChecked(self.is_ext)
vbox.addWidget(cb_ext)
- if 'bip39' in self.options:
- def f(b):
- self.is_seed = (lambda x: bool(x)) if b else self.saved_is_seed
- self.is_bip39 = b
+ if len(seed_types) >= 2:
+ def f(choices_layout):
+ self.seed_type = seed_type_values[choices_layout.selected_index()]
+ self.is_seed = (lambda x: bool(x)) if self.seed_type != 'electrum' else self.saved_is_seed
+ self.slip39_current_mnemonic_invalid = None
+ self.seed_status.setText('')
self.on_edit()
- if b:
+ if self.seed_type == 'bip39':
msg = ' '.join([
'' + _('Warning') + ': ',
_('BIP39 seeds can be imported in Electrum, so that users can access funds locked in other wallets.'),
@@ -81,18 +97,28 @@ class SeedLayout(QVBoxLayout):
_('BIP39 seeds do not include a version number, which compromises compatibility with future software.'),
_('We do not guarantee that BIP39 imports will always be supported in Electrum.'),
])
+ elif self.seed_type == 'slip39':
+ msg = ' '.join([
+ '' + _('Warning') + ': ',
+ _('SLIP39 seeds can be imported in Electrum, so that users can access funds locked in other wallets.'),
+ _('However, we do not generate SLIP39 seeds.'),
+ ])
else:
msg = ''
+ self.update_share_buttons()
+ self.initialize_completer()
self.seed_warning.setText(msg)
- cb_bip39 = QCheckBox(_('BIP39 seed'))
- cb_bip39.toggled.connect(f)
- cb_bip39.setChecked(self.is_bip39)
- vbox.addWidget(cb_bip39)
+
+ checked_index = seed_type_values.index(self.seed_type)
+ titles = [t[1] for t in seed_types]
+ clayout = ChoicesLayout(_('Seed type'), titles, on_clicked=f, checked_index=checked_index)
+ vbox.addLayout(clayout.layout())
+
vbox.addLayout(Buttons(OkButton(dialog)))
if not dialog.exec_():
return None
self.is_ext = cb_ext.isChecked() if 'ext' in self.options else False
- self.is_bip39 = cb_bip39.isChecked() if 'bip39' in self.options else False
+ self.seed_type = seed_type_values[clayout.selected_index()] if len(seed_types) >= 2 else 'electrum'
def __init__(
self,
@@ -112,6 +138,7 @@ class SeedLayout(QVBoxLayout):
self.parent = parent
self.options = options
self.config = config
+ self.seed_type = 'electrum'
if title:
self.addWidget(WWLabel(title))
if seed: # "read only", we already have the text
@@ -146,7 +173,6 @@ class SeedLayout(QVBoxLayout):
hbox.addWidget(self.seed_type_label)
# options
- self.is_bip39 = False
self.is_ext = False
if options:
opt_button = EnterButton(_('Options'), self.seed_options)
@@ -160,60 +186,145 @@ class SeedLayout(QVBoxLayout):
hbox.addWidget(QLabel(_("Your seed extension is") + ':'))
hbox.addWidget(passphrase_e)
self.addLayout(hbox)
+
+ # slip39 shares
+ self.slip39_mnemonic_index = 0
+ self.slip39_mnemonics = [""]
+ self.slip39_seed = None
+ self.slip39_current_mnemonic_invalid = None
+ hbox = QHBoxLayout()
+ hbox.addStretch(1)
+ self.prev_share_btn = QPushButton(_("Previous share"))
+ self.prev_share_btn.clicked.connect(self.on_prev_share)
+ hbox.addWidget(self.prev_share_btn)
+ self.next_share_btn = QPushButton(_("Next share"))
+ self.next_share_btn.clicked.connect(self.on_next_share)
+ hbox.addWidget(self.next_share_btn)
+ self.update_share_buttons()
+ self.addLayout(hbox)
+
self.addStretch(1)
+ self.seed_status = WWLabel('')
+ self.addWidget(self.seed_status)
self.seed_warning = WWLabel('')
if msg:
self.seed_warning.setText(seed_warning_msg(seed))
self.addWidget(self.seed_warning)
def initialize_completer(self):
- bip39_english_list = Mnemonic('en').wordlist
- old_list = old_mnemonic.wordlist
- only_old_list = set(old_list) - set(bip39_english_list)
- self.wordlist = list(bip39_english_list) + list(only_old_list) # concat both lists
- self.wordlist.sort()
-
- class CompleterDelegate(QStyledItemDelegate):
- def initStyleOption(self, option, index):
- super().initStyleOption(option, index)
- # Some people complained that due to merging the two word lists,
- # it is difficult to restore from a metal backup, as they planned
- # to rely on the "4 letter prefixes are unique in bip39 word list" property.
- # So we color words that are only in old list.
- if option.text in only_old_list:
- # yellow bg looks ~ok on both light/dark theme, regardless if (un)selected
- option.backgroundBrush = ColorScheme.YELLOW.as_color(background=True)
+ if self.seed_type != 'slip39':
+ bip39_english_list = Mnemonic('en').wordlist
+ old_list = old_mnemonic.wordlist
+ only_old_list = set(old_list) - set(bip39_english_list)
+ self.wordlist = list(bip39_english_list) + list(only_old_list) # concat both lists
+ self.wordlist.sort()
+
+ class CompleterDelegate(QStyledItemDelegate):
+ def initStyleOption(self, option, index):
+ super().initStyleOption(option, index)
+ # Some people complained that due to merging the two word lists,
+ # it is difficult to restore from a metal backup, as they planned
+ # to rely on the "4 letter prefixes are unique in bip39 word list" property.
+ # So we color words that are only in old list.
+ if option.text in only_old_list:
+ # yellow bg looks ~ok on both light/dark theme, regardless if (un)selected
+ option.backgroundBrush = ColorScheme.YELLOW.as_color(background=True)
+
+ delegate = CompleterDelegate(self.seed_e)
+ else:
+ self.wordlist = list(slip39.get_wordlist())
+ delegate = None
self.completer = QCompleter(self.wordlist)
- delegate = CompleterDelegate(self.seed_e)
- self.completer.popup().setItemDelegate(delegate)
+ if delegate:
+ self.completer.popup().setItemDelegate(delegate)
self.seed_e.set_completer(self.completer)
+ def get_seed_words(self):
+ return self.seed_e.text().split()
+
def get_seed(self):
- text = self.seed_e.text()
- return ' '.join(text.split())
+ if self.seed_type != 'slip39':
+ return ' '.join(self.get_seed_words())
+ else:
+ return self.slip39_seed
def on_edit(self):
- s = self.get_seed()
+ s = ' '.join(self.get_seed_words())
b = self.is_seed(s)
- if not self.is_bip39:
- t = seed_type(s)
- label = _('Seed Type') + ': ' + t if t else ''
- else:
+ if self.seed_type == 'bip39':
from electrum.keystore import bip39_is_checksum_valid
is_checksum, is_wordlist = bip39_is_checksum_valid(s)
status = ('checksum: ' + ('ok' if is_checksum else 'failed')) if is_wordlist else 'unknown wordlist'
label = 'BIP39' + ' (%s)'%status
+ elif self.seed_type == 'slip39':
+ self.slip39_mnemonics[self.slip39_mnemonic_index] = s
+ try:
+ slip39.decode_mnemonic(s)
+ except slip39.Slip39Error as e:
+ share_status = str(e)
+ current_mnemonic_invalid = True
+ else:
+ share_status = _('Valid.')
+ current_mnemonic_invalid = False
+
+ label = _('SLIP39 share') + ' #%d: %s' % (self.slip39_mnemonic_index + 1, share_status)
+
+ # No need to process mnemonics if the current mnemonic remains invalid after editing.
+ if not (self.slip39_current_mnemonic_invalid and current_mnemonic_invalid):
+ self.slip39_seed, seed_status = slip39.process_mnemonics(self.slip39_mnemonics)
+ self.seed_status.setText(seed_status)
+ self.slip39_current_mnemonic_invalid = current_mnemonic_invalid
+
+ b = self.slip39_seed is not None
+ self.update_share_buttons()
+ else:
+ t = seed_type(s)
+ label = _('Seed Type') + ': ' + t if t else ''
+
self.seed_type_label.setText(label)
self.parent.next_button.setEnabled(b)
# disable suggestions if user already typed an unknown word
- for word in self.get_seed().split(" ")[:-1]:
+ for word in self.get_seed_words()[:-1]:
if word not in self.wordlist:
self.seed_e.disable_suggestions()
return
self.seed_e.enable_suggestions()
+ def update_share_buttons(self):
+ if self.seed_type != 'slip39':
+ self.prev_share_btn.hide()
+ self.next_share_btn.hide()
+ return
+
+ finished = self.slip39_seed is not None
+ self.prev_share_btn.show()
+ self.next_share_btn.show()
+ self.prev_share_btn.setEnabled(self.slip39_mnemonic_index != 0)
+ self.next_share_btn.setEnabled(self.slip39_mnemonic_index < len(self.slip39_mnemonics) - 1 or (bool(self.seed_e.text().strip()) and not finished))
+
+ def on_prev_share(self):
+ if not self.slip39_mnemonics[self.slip39_mnemonic_index]:
+ del self.slip39_mnemonics[self.slip39_mnemonic_index]
+
+ self.slip39_mnemonic_index -= 1
+ self.seed_e.setText(self.slip39_mnemonics[self.slip39_mnemonic_index])
+ self.slip39_current_mnemonic_invalid = None
+
+ def on_next_share(self):
+ if not self.slip39_mnemonics[self.slip39_mnemonic_index]:
+ del self.slip39_mnemonics[self.slip39_mnemonic_index]
+ else:
+ self.slip39_mnemonic_index += 1
+
+ if len(self.slip39_mnemonics) <= self.slip39_mnemonic_index:
+ self.slip39_mnemonics.append("")
+ self.seed_e.setFocus()
+ self.seed_e.setText(self.slip39_mnemonics[self.slip39_mnemonic_index])
+ self.slip39_current_mnemonic_invalid = None
+
+
class KeysLayout(QVBoxLayout):
def __init__(
self,
diff --git a/electrum/plugins/trustedcoin/trustedcoin.py b/electrum/plugins/trustedcoin/trustedcoin.py
index 6e31c46f8..40c8c9096 100644
--- a/electrum/plugins/trustedcoin/trustedcoin.py
+++ b/electrum/plugins/trustedcoin/trustedcoin.py
@@ -617,9 +617,10 @@ class TrustedCoinPlugin(BasePlugin):
def restore_wallet(self, wizard):
wizard.opt_bip39 = False
+ wizard.opt_slip39 = False
wizard.opt_ext = True
title = _("Restore two-factor Wallet")
- f = lambda seed, is_bip39, is_ext: wizard.run('on_restore_seed', seed, is_ext)
+ f = lambda seed, seed_type, is_ext: wizard.run('on_restore_seed', seed, is_ext)
wizard.restore_seed_dialog(run_next=f, test=self.is_valid_seed)
def on_restore_seed(self, wizard, seed, is_ext):
@@ -710,8 +711,9 @@ class TrustedCoinPlugin(BasePlugin):
self.do_auth(wizard, short_id, otp, xpub3)
elif reset:
wizard.opt_bip39 = False
+ wizard.opt_slip39 = False
wizard.opt_ext = True
- f = lambda seed, is_bip39, is_ext: wizard.run('on_reset_seed', short_id, seed, is_ext, xpub3)
+ f = lambda seed, seed_type, is_ext: wizard.run('on_reset_seed', short_id, seed, is_ext, xpub3)
wizard.restore_seed_dialog(run_next=f, test=self.is_valid_seed)
def on_reset_seed(self, wizard, short_id, seed, is_ext, xpub3):
diff --git a/electrum/slip39.py b/electrum/slip39.py
new file mode 100644
index 000000000..933fa3ae8
--- /dev/null
+++ b/electrum/slip39.py
@@ -0,0 +1,612 @@
+# Copyright (c) 2018 Andrew R. Kozlik
+#
+# Permission is hereby granted, free of charge, to any person obtaining a copy of
+# this software and associated documentation files (the "Software"), to deal in
+# the Software without restriction, including without limitation the rights to
+# use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
+# of the Software, and to permit persons to whom the Software is furnished to do
+# so, subject to the following conditions:
+#
+# The above copyright notice and this permission notice shall be included in all
+# copies or substantial portions of the Software.
+#
+# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
+# WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
+# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+#
+
+"""
+This implements the high-level functions for SLIP-39, also called "Shamir Backup".
+
+See https://github.com/satoshilabs/slips/blob/master/slip-0039.md.
+"""
+
+import hmac
+from collections import defaultdict
+from hashlib import pbkdf2_hmac
+from typing import Dict, Iterable, List, Optional, Set, Tuple
+from electrum.i18n import _
+
+from .mnemonic import Wordlist
+
+Indices = Tuple[int, ...]
+MnemonicGroups = Dict[int, Tuple[int, Set[Tuple[int, bytes]]]]
+
+
+"""
+## Simple helpers
+"""
+
+_RADIX_BITS = 10
+"""The length of the radix in bits."""
+
+
+def _bits_to_bytes(n: int) -> int:
+ return (n + 7) // 8
+
+
+def _bits_to_words(n: int) -> int:
+ return (n + _RADIX_BITS - 1) // _RADIX_BITS
+
+
+def _xor(a: bytes, b: bytes) -> bytes:
+ return bytes(x ^ y for x, y in zip(a, b))
+
+
+"""
+## Constants
+"""
+
+_ID_LENGTH_BITS = 15
+"""The length of the random identifier in bits."""
+
+_ITERATION_EXP_LENGTH_BITS = 5
+"""The length of the iteration exponent in bits."""
+
+_ID_EXP_LENGTH_WORDS = _bits_to_words(_ID_LENGTH_BITS + _ITERATION_EXP_LENGTH_BITS)
+"""The length of the random identifier and iteration exponent in words."""
+
+_CHECKSUM_LENGTH_WORDS = 3
+"""The length of the RS1024 checksum in words."""
+
+_DIGEST_LENGTH_BYTES = 4
+"""The length of the digest of the shared secret in bytes."""
+
+_CUSTOMIZATION_STRING = b"shamir"
+"""The customization string used in the RS1024 checksum and in the PBKDF2 salt."""
+
+_GROUP_PREFIX_LENGTH_WORDS = _ID_EXP_LENGTH_WORDS + 1
+"""The length of the prefix of the mnemonic that is common to a share group."""
+
+_METADATA_LENGTH_WORDS = _ID_EXP_LENGTH_WORDS + 2 + _CHECKSUM_LENGTH_WORDS
+"""The length of the mnemonic in words without the share value."""
+
+_MIN_STRENGTH_BITS = 128
+"""The minimum allowed entropy of the master secret."""
+
+_MIN_MNEMONIC_LENGTH_WORDS = _METADATA_LENGTH_WORDS + _bits_to_words(_MIN_STRENGTH_BITS)
+"""The minimum allowed length of the mnemonic in words."""
+
+_BASE_ITERATION_COUNT = 10000
+"""The minimum number of iterations to use in PBKDF2."""
+
+_ROUND_COUNT = 4
+"""The number of rounds to use in the Feistel cipher."""
+
+_SECRET_INDEX = 255
+"""The index of the share containing the shared secret."""
+
+_DIGEST_INDEX = 254
+"""The index of the share containing the digest of the shared secret."""
+
+
+"""
+# External API
+"""
+
+
+class Slip39Error(RuntimeError):
+ pass
+
+
+class Share:
+ """
+ Represents a single mnemonic and offers its parsed metadata.
+ """
+
+ def __init__(
+ self,
+ identifier: int,
+ iteration_exponent: int,
+ group_index: int,
+ group_threshold: int,
+ group_count: int,
+ member_index: int,
+ member_threshold: int,
+ share_value: bytes,
+ ):
+ self.index = None
+ self.identifier = identifier
+ self.iteration_exponent = iteration_exponent
+ self.group_index = group_index
+ self.group_threshold = group_threshold
+ self.group_count = group_count
+ self.member_index = member_index
+ self.member_threshold = member_threshold
+ self.share_value = share_value
+
+ def common_parameters(self) -> tuple:
+ """Return the values that uniquely identify a matching set of shares."""
+ return (
+ self.identifier,
+ self.iteration_exponent,
+ self.group_threshold,
+ self.group_count,
+ )
+
+
+class EncryptedSeed:
+ """
+ Represents the encrypted master seed for BIP-32.
+ """
+
+ def __init__(self, identifier: int, iteration_exponent: int, encrypted_master_secret: bytes):
+ self.identifier = identifier
+ self.iteration_exponent = iteration_exponent
+ self.encrypted_master_secret = encrypted_master_secret
+
+ def decrypt(self, passphrase: str) -> bytes:
+ """
+ Converts the Encrypted Master Secret to a Master Secret by applying the passphrase.
+ This is analogous to BIP-39 passphrase derivation. We do not use the term "derive"
+ here, because passphrase function is symmetric in SLIP-39. We are using the terms
+ "encrypt" and "decrypt" instead.
+ """
+ passphrase = (passphrase or '').encode('utf-8')
+ ems_len = len(self.encrypted_master_secret)
+ l = self.encrypted_master_secret[: ems_len // 2]
+ r = self.encrypted_master_secret[ems_len // 2 :]
+ salt = _get_salt(self.identifier)
+ for i in reversed(range(_ROUND_COUNT)):
+ (l, r) = (
+ r,
+ _xor(l, _round_function(i, passphrase, self.iteration_exponent, salt, r)),
+ )
+ return r + l
+
+
+def recover_ems(mnemonics: List[str]) -> EncryptedSeed:
+ """
+ Combines mnemonic shares to obtain the encrypted master secret which was previously
+ split using Shamir's secret sharing scheme.
+ Returns identifier, iteration exponent and the encrypted master secret.
+ """
+
+ if not mnemonics:
+ raise Slip39Error("The list of mnemonics is empty.")
+
+ (
+ identifier,
+ iteration_exponent,
+ group_threshold,
+ group_count,
+ groups,
+ ) = _decode_mnemonics(mnemonics)
+
+ # Use only groups that have at least the threshold number of shares.
+ groups = {group_index: group for group_index, group in groups.items() if len(group[1]) >= group[0]}
+
+ if len(groups) < group_threshold:
+ raise Slip39Error(
+ "Insufficient number of mnemonic groups. Expected {} full groups, but {} were provided.".format(
+ group_threshold, len(groups)
+ )
+ )
+
+ group_shares = [
+ (group_index, _recover_secret(group[0], list(group[1])))
+ for group_index, group in groups.items()
+ ]
+
+ encrypted_master_secret = _recover_secret(group_threshold, group_shares)
+ return EncryptedSeed(identifier, iteration_exponent, encrypted_master_secret)
+
+
+def decode_mnemonic(mnemonic: str) -> Share:
+ """Converts a share mnemonic to share data."""
+
+ mnemonic_data = tuple(_mnemonic_to_indices(mnemonic))
+
+ if len(mnemonic_data) < _MIN_MNEMONIC_LENGTH_WORDS:
+ raise Slip39Error(_('Too short.'))
+
+ padding_len = (_RADIX_BITS * (len(mnemonic_data) - _METADATA_LENGTH_WORDS)) % 16
+ if padding_len > 8:
+ raise Slip39Error(_('Invalid length.'))
+
+ if not _rs1024_verify_checksum(mnemonic_data):
+ raise Slip39Error(_('Invalid mnemonic checksum.'))
+
+ id_exp_int = _int_from_indices(mnemonic_data[:_ID_EXP_LENGTH_WORDS])
+ identifier = id_exp_int >> _ITERATION_EXP_LENGTH_BITS
+ iteration_exponent = id_exp_int & ((1 << _ITERATION_EXP_LENGTH_BITS) - 1)
+ tmp = _int_from_indices(
+ mnemonic_data[_ID_EXP_LENGTH_WORDS : _ID_EXP_LENGTH_WORDS + 2]
+ )
+ (
+ group_index,
+ group_threshold,
+ group_count,
+ member_index,
+ member_threshold,
+ ) = _int_to_indices(tmp, 5, 4)
+ value_data = mnemonic_data[_ID_EXP_LENGTH_WORDS + 2 : -_CHECKSUM_LENGTH_WORDS]
+
+ if group_count < group_threshold:
+ raise Slip39Error(_('Invalid mnemonic group threshold.'))
+
+ value_byte_count = _bits_to_bytes(_RADIX_BITS * len(value_data) - padding_len)
+ value_int = _int_from_indices(value_data)
+ if value_data[0] >= 1 << (_RADIX_BITS - padding_len):
+ raise Slip39Error(_('Invalid mnemonic padding.'))
+ value = value_int.to_bytes(value_byte_count, "big")
+
+ return Share(
+ identifier,
+ iteration_exponent,
+ group_index,
+ group_threshold + 1,
+ group_count + 1,
+ member_index,
+ member_threshold + 1,
+ value,
+ )
+
+
+def get_wordlist() -> Wordlist:
+ wordlist = Wordlist.from_file('slip39.txt')
+
+ required_words = 2**_RADIX_BITS
+ if len(wordlist) != required_words:
+ raise Slip39Error(
+ f"The wordlist should contain {required_words} words, but it contains {len(wordlist)} words."
+ )
+
+ return wordlist
+
+
+def process_mnemonics(mnemonics: List[str]) -> Tuple[bool, str]:
+ # Collect valid shares.
+ shares = []
+ for i, mnemonic in enumerate(mnemonics):
+ try:
+ share = decode_mnemonic(mnemonic)
+ share.index = i + 1
+ shares.append(share)
+ except Slip39Error:
+ pass
+
+ if not shares:
+ return None, _('No valid shares.')
+
+ # Sort shares into groups.
+ groups: Dict[int, Set[Share]] = defaultdict(set) # group idx : shares
+ common_params = shares[0].common_parameters()
+ for share in shares:
+ if share.common_parameters() != common_params:
+ error_text = _("Share") + ' #%d ' % share.index + _("is not part of the current set.")
+ return None, _ERROR_STYLE % error_text
+ for other in groups[share.group_index]:
+ if share.member_index == other.member_index:
+ error_text = _("Share") + ' #%d ' % share.index + _("is a duplicate of share") + ' #%d.' % other.index
+ return None, _ERROR_STYLE % error_text
+ groups[share.group_index].add(share)
+
+ # Compile information about groups.
+ groups_completed = 0
+ for i, group in groups.items():
+ if group:
+ member_threshold = next(iter(group)).member_threshold
+ if len(group) >= member_threshold:
+ groups_completed += 1
+
+ identifier = shares[0].identifier
+ iteration_exponent = shares[0].iteration_exponent
+ group_threshold = shares[0].group_threshold
+ group_count = shares[0].group_count
+ status = ''
+ if group_count > 1:
+ status += _('Completed') + ' %d ' % groups_completed + _('of') + ' %d ' % group_threshold + _('groups needed:
')
+
+ for group_index in range(group_count):
+ group_prefix = _make_group_prefix(identifier, iteration_exponent, group_index, group_threshold, group_count)
+ status += _group_status(groups[group_index], group_prefix)
+
+ if groups_completed >= group_threshold:
+ if len(mnemonics) > len(shares):
+ status += _ERROR_STYLE % _('Some shares are invalid.')
+ else:
+ try:
+ encrypted_seed = recover_ems(mnemonics)
+ status += '' + _('The set is complete!') + ''
+ except Slip39Error as e:
+ encrypted_seed = None
+ status = _ERROR_STYLE % str(e)
+ return encrypted_seed, status
+
+ return None, status
+
+
+"""
+## Group status helpers
+"""
+
+_FINISHED = '✔'
+_EMPTY = '✕'
+_INPROGRESS = '⚫'
+_ERROR_STYLE = '' + _('Error') + ': %s'
+
+def _make_group_prefix(identifier, iteration_exponent, group_index, group_threshold, group_count):
+ wordlist = get_wordlist()
+ val = identifier
+ val <<= _ITERATION_EXP_LENGTH_BITS
+ val += iteration_exponent
+ val <<= 4
+ val += group_index
+ val <<= 4
+ val += group_threshold - 1
+ val <<= 4
+ val += group_count - 1
+ val >>= 2
+ prefix = ' '.join(wordlist[idx] for idx in _int_to_indices(val, _GROUP_PREFIX_LENGTH_WORDS, _RADIX_BITS))
+ return prefix
+
+
+def _group_status(group: Set[Share], group_prefix) -> str:
+ len(group)
+ if not group:
+ return _EMPTY + '0 ' + _('shares from group') + ' ' + group_prefix + '.
'
+ else:
+ share = next(iter(group))
+ icon = _FINISHED if len(group) >= share.member_threshold else _INPROGRESS
+ return icon + '%d ' % len(group) + _('of') + ' %d ' % share.member_threshold + _('shares needed from group') + ' %s.
' % group_prefix
+
+
+"""
+## Convert mnemonics or integers to indices and back
+"""
+
+
+def _int_from_indices(indices: Indices) -> int:
+ """Converts a list of base 1024 indices in big endian order to an integer value."""
+ value = 0
+ for index in indices:
+ value = (value << _RADIX_BITS) + index
+ return value
+
+
+def _int_to_indices(value: int, output_length: int, bits: int) -> Iterable[int]:
+ """Converts an integer value to indices in big endian order."""
+ mask = (1 << bits) - 1
+ return ((value >> (i * bits)) & mask for i in reversed(range(output_length)))
+
+
+def _mnemonic_to_indices(mnemonic: str) -> List[int]:
+ wordlist = get_wordlist()
+ indices = []
+ for word in mnemonic.split():
+ try:
+ indices.append(wordlist.index(word.lower()))
+ except ValueError:
+ if len(word) > 8:
+ word = word[:8] + '...'
+ raise Slip39Error(_('Invalid mnemonic word') + ' "%s".' % word) from None
+ return indices
+
+
+"""
+## Checksum functions
+"""
+
+
+def _rs1024_polymod(values: Indices) -> int:
+ GEN = (
+ 0xE0E040,
+ 0x1C1C080,
+ 0x3838100,
+ 0x7070200,
+ 0xE0E0009,
+ 0x1C0C2412,
+ 0x38086C24,
+ 0x3090FC48,
+ 0x21B1F890,
+ 0x3F3F120,
+ )
+ chk = 1
+ for v in values:
+ b = chk >> 20
+ chk = (chk & 0xFFFFF) << 10 ^ v
+ for i in range(10):
+ chk ^= GEN[i] if ((b >> i) & 1) else 0
+ return chk
+
+
+def _rs1024_verify_checksum(data: Indices) -> bool:
+ """
+ Verifies a checksum of the given mnemonic, which was already parsed into Indices.
+ """
+ return _rs1024_polymod(tuple(_CUSTOMIZATION_STRING) + data) == 1
+
+
+"""
+## Internal functions
+"""
+
+
+def _precompute_exp_log() -> Tuple[List[int], List[int]]:
+ exp = [0 for i in range(255)]
+ log = [0 for i in range(256)]
+
+ poly = 1
+ for i in range(255):
+ exp[i] = poly
+ log[poly] = i
+
+ # Multiply poly by the polynomial x + 1.
+ poly = (poly << 1) ^ poly
+
+ # Reduce poly by x^8 + x^4 + x^3 + x + 1.
+ if poly & 0x100:
+ poly ^= 0x11B
+
+ return exp, log
+
+
+_EXP_TABLE, _LOG_TABLE = _precompute_exp_log()
+
+
+def _interpolate(shares, x) -> bytes:
+ """
+ Returns f(x) given the Shamir shares (x_1, f(x_1)), ... , (x_k, f(x_k)).
+ :param shares: The Shamir shares.
+ :type shares: A list of pairs (x_i, y_i), where x_i is an integer and y_i is an array of
+ bytes representing the evaluations of the polynomials in x_i.
+ :param int x: The x coordinate of the result.
+ :return: Evaluations of the polynomials in x.
+ :rtype: Array of bytes.
+ """
+
+ x_coordinates = set(share[0] for share in shares)
+
+ if len(x_coordinates) != len(shares):
+ raise Slip39Error("Invalid set of shares. Share indices must be unique.")
+
+ share_value_lengths = set(len(share[1]) for share in shares)
+ if len(share_value_lengths) != 1:
+ raise Slip39Error(
+ "Invalid set of shares. All share values must have the same length."
+ )
+
+ if x in x_coordinates:
+ for share in shares:
+ if share[0] == x:
+ return share[1]
+
+ # Logarithm of the product of (x_i - x) for i = 1, ... , k.
+ log_prod = sum(_LOG_TABLE[share[0] ^ x] for share in shares)
+
+ result = bytes(share_value_lengths.pop())
+ for share in shares:
+ # The logarithm of the Lagrange basis polynomial evaluated at x.
+ log_basis_eval = (
+ log_prod
+ - _LOG_TABLE[share[0] ^ x]
+ - sum(_LOG_TABLE[share[0] ^ other[0]] for other in shares)
+ ) % 255
+
+ result = bytes(
+ intermediate_sum
+ ^ (
+ _EXP_TABLE[(_LOG_TABLE[share_val] + log_basis_eval) % 255]
+ if share_val != 0
+ else 0
+ )
+ for share_val, intermediate_sum in zip(share[1], result)
+ )
+
+ return result
+
+
+def _round_function(i: int, passphrase: bytes, e: int, salt: bytes, r: bytes) -> bytes:
+ """The round function used internally by the Feistel cipher."""
+ return pbkdf2_hmac(
+ "sha256",
+ bytes([i]) + passphrase,
+ salt + r,
+ (_BASE_ITERATION_COUNT << e) // _ROUND_COUNT,
+ dklen=len(r),
+ )
+
+
+def _get_salt(identifier: int) -> bytes:
+ return _CUSTOMIZATION_STRING + identifier.to_bytes(
+ _bits_to_bytes(_ID_LENGTH_BITS), "big"
+ )
+
+
+def _create_digest(random_data: bytes, shared_secret: bytes) -> bytes:
+ return hmac.new(random_data, shared_secret, "sha256").digest()[:_DIGEST_LENGTH_BYTES]
+
+
+def _recover_secret(threshold: int, shares: List[Tuple[int, bytes]]) -> bytes:
+ # If the threshold is 1, then the digest of the shared secret is not used.
+ if threshold == 1:
+ return shares[0][1]
+
+ shared_secret = _interpolate(shares, _SECRET_INDEX)
+ digest_share = _interpolate(shares, _DIGEST_INDEX)
+ digest = digest_share[:_DIGEST_LENGTH_BYTES]
+ random_part = digest_share[_DIGEST_LENGTH_BYTES:]
+
+ if digest != _create_digest(random_part, shared_secret):
+ raise Slip39Error("Invalid digest of the shared secret.")
+
+ return shared_secret
+
+
+def _decode_mnemonics(
+ mnemonics: List[str],
+) -> Tuple[int, int, int, int, MnemonicGroups]:
+ identifiers = set()
+ iteration_exponents = set()
+ group_thresholds = set()
+ group_counts = set()
+
+ # { group_index : [threshold, set_of_member_shares] }
+ groups = {} # type: MnemonicGroups
+ for mnemonic in mnemonics:
+ share = decode_mnemonic(mnemonic)
+ identifiers.add(share.identifier)
+ iteration_exponents.add(share.iteration_exponent)
+ group_thresholds.add(share.group_threshold)
+ group_counts.add(share.group_count)
+ group = groups.setdefault(share.group_index, (share.member_threshold, set()))
+ if group[0] != share.member_threshold:
+ raise Slip39Error(
+ "Invalid set of mnemonics. All mnemonics in a group must have the same member threshold."
+ )
+ group[1].add((share.member_index, share.share_value))
+
+ if len(identifiers) != 1 or len(iteration_exponents) != 1:
+ raise Slip39Error(
+ "Invalid set of mnemonics. All mnemonics must begin with the same {} words.".format(
+ _ID_EXP_LENGTH_WORDS
+ )
+ )
+
+ if len(group_thresholds) != 1:
+ raise Slip39Error(
+ "Invalid set of mnemonics. All mnemonics must have the same group threshold."
+ )
+
+ if len(group_counts) != 1:
+ raise Slip39Error(
+ "Invalid set of mnemonics. All mnemonics must have the same group count."
+ )
+
+ for group_index, group in groups.items():
+ if len(set(share[0] for share in group[1])) != len(group[1]):
+ raise Slip39Error(
+ "Invalid set of shares. Member indices in each group must be unique."
+ )
+
+ return (
+ identifiers.pop(),
+ iteration_exponents.pop(),
+ group_thresholds.pop(),
+ group_counts.pop(),
+ groups,
+ )