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, + )