Browse Source

Implement SLIP-0039 wallet recovery.

patch-4
Andrew Kozlik 4 years ago
parent
commit
0dce13a1dd
  1. 11
      electrum/base_wizard.py
  2. 6
      electrum/gui/qt/installwizard.py
  3. 189
      electrum/gui/qt/seed_dialog.py
  4. 6
      electrum/plugins/trustedcoin/trustedcoin.py
  5. 612
      electrum/slip39.py

11
electrum/base_wizard.py

@ -508,19 +508,25 @@ class BaseWizard(Logger):
def restore_from_seed(self): def restore_from_seed(self):
self.opt_bip39 = True self.opt_bip39 = True
self.opt_slip39 = True
self.opt_ext = True self.opt_ext = True
is_cosigning_seed = lambda x: mnemonic.seed_type(x) in ['standard', 'segwit'] 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 test = mnemonic.is_seed if self.wallet_type == 'standard' else is_cosigning_seed
f = lambda *args: self.run('on_restore_seed', *args) f = lambda *args: self.run('on_restore_seed', *args)
self.restore_seed_dialog(run_next=f, test=test) self.restore_seed_dialog(run_next=f, test=test)
def on_restore_seed(self, seed, is_bip39, is_ext): def on_restore_seed(self, seed, seed_type, is_ext):
self.seed_type = 'bip39' if is_bip39 else mnemonic.seed_type(seed) self.seed_type = seed_type if seed_type != 'electrum' else mnemonic.seed_type(seed)
if self.seed_type == 'bip39': if self.seed_type == 'bip39':
def f(passphrase): def f(passphrase):
root_seed = bip39_to_seed(seed, passphrase) root_seed = bip39_to_seed(seed, passphrase)
self.on_restore_bip43(root_seed) self.on_restore_bip43(root_seed)
self.passphrase_dialog(run_next=f, is_restoring=True) if is_ext else f('') 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']: elif self.seed_type in ['standard', 'segwit']:
f = lambda passphrase: self.run('create_keystore', seed, passphrase) f = lambda passphrase: self.run('create_keystore', seed, passphrase)
self.passphrase_dialog(run_next=f, is_restoring=True) if is_ext else f('') 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) seed = mnemonic.Mnemonic('en').make_seed(seed_type=self.seed_type)
self.opt_bip39 = False self.opt_bip39 = False
self.opt_ext = True self.opt_ext = True
self.opt_slip39 = False
f = lambda x: self.request_passphrase(seed, x) f = lambda x: self.request_passphrase(seed, x)
self.show_seed_dialog(run_next=f, seed_text=seed) self.show_seed_dialog(run_next=f, seed_text=seed)

6
electrum/gui/qt/installwizard.py

@ -465,7 +465,7 @@ class InstallWizard(QDialog, MessageBoxMixin, BaseWizard):
config=self.config, config=self.config,
) )
self.exec_layout(slayout, title, next_enabled=False) 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 @wizard_dialog
def add_xpub_dialog(self, title, message, is_valid, run_next, allow_multi=False, show_wif_help=False): 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') options.append('ext')
if self.opt_bip39: if self.opt_bip39:
options.append('bip39') options.append('bip39')
if self.opt_slip39:
options.append('slip39')
title = _('Enter Seed') title = _('Enter Seed')
message = _('Please enter your seed phrase in order to restore your wallet.') message = _('Please enter your seed phrase in order to restore your wallet.')
return self.seed_input(title, message, test, options) 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.'), _('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.') _('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 return seed
@wizard_dialog @wizard_dialog

189
electrum/gui/qt/seed_dialog.py

@ -28,14 +28,17 @@ from typing import TYPE_CHECKING
from PyQt5.QtCore import Qt from PyQt5.QtCore import Qt
from PyQt5.QtGui import QPixmap from PyQt5.QtGui import QPixmap
from PyQt5.QtWidgets import (QVBoxLayout, QCheckBox, QHBoxLayout, QLineEdit, 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.i18n import _
from electrum.mnemonic import Mnemonic, seed_type from electrum.mnemonic import Mnemonic, seed_type
from electrum import old_mnemonic from electrum import old_mnemonic
from electrum import slip39
from .util import (Buttons, OkButton, WWLabel, ButtonsTextEdit, icon_path, from .util import (Buttons, OkButton, WWLabel, ButtonsTextEdit, icon_path,
EnterButton, CloseButton, WindowModalDialog, ColorScheme) EnterButton, CloseButton, WindowModalDialog, ColorScheme,
ChoicesLayout)
from .qrtextedit import ShowQRTextEdit, ScanQRTextEdit from .qrtextedit import ShowQRTextEdit, ScanQRTextEdit
from .completion_text_edit import CompletionTextEdit from .completion_text_edit import CompletionTextEdit
@ -64,16 +67,29 @@ class SeedLayout(QVBoxLayout):
def seed_options(self): def seed_options(self):
dialog = QDialog() dialog = QDialog()
vbox = QVBoxLayout(dialog) 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: if 'ext' in self.options:
cb_ext = QCheckBox(_('Extend this seed with custom words')) cb_ext = QCheckBox(_('Extend this seed with custom words'))
cb_ext.setChecked(self.is_ext) cb_ext.setChecked(self.is_ext)
vbox.addWidget(cb_ext) vbox.addWidget(cb_ext)
if 'bip39' in self.options: if len(seed_types) >= 2:
def f(b): def f(choices_layout):
self.is_seed = (lambda x: bool(x)) if b else self.saved_is_seed self.seed_type = seed_type_values[choices_layout.selected_index()]
self.is_bip39 = b 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() self.on_edit()
if b: if self.seed_type == 'bip39':
msg = ' '.join([ msg = ' '.join([
'<b>' + _('Warning') + ':</b> ', '<b>' + _('Warning') + ':</b> ',
_('BIP39 seeds can be imported in Electrum, so that users can access funds locked in other wallets.'), _('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.'), _('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.'), _('We do not guarantee that BIP39 imports will always be supported in Electrum.'),
]) ])
elif self.seed_type == 'slip39':
msg = ' '.join([
'<b>' + _('Warning') + ':</b> ',
_('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: else:
msg = '' msg = ''
self.update_share_buttons()
self.initialize_completer()
self.seed_warning.setText(msg) self.seed_warning.setText(msg)
cb_bip39 = QCheckBox(_('BIP39 seed'))
cb_bip39.toggled.connect(f) checked_index = seed_type_values.index(self.seed_type)
cb_bip39.setChecked(self.is_bip39) titles = [t[1] for t in seed_types]
vbox.addWidget(cb_bip39) clayout = ChoicesLayout(_('Seed type'), titles, on_clicked=f, checked_index=checked_index)
vbox.addLayout(clayout.layout())
vbox.addLayout(Buttons(OkButton(dialog))) vbox.addLayout(Buttons(OkButton(dialog)))
if not dialog.exec_(): if not dialog.exec_():
return None return None
self.is_ext = cb_ext.isChecked() if 'ext' in self.options else False 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__( def __init__(
self, self,
@ -112,6 +138,7 @@ class SeedLayout(QVBoxLayout):
self.parent = parent self.parent = parent
self.options = options self.options = options
self.config = config self.config = config
self.seed_type = 'electrum'
if title: if title:
self.addWidget(WWLabel(title)) self.addWidget(WWLabel(title))
if seed: # "read only", we already have the text if seed: # "read only", we already have the text
@ -146,7 +173,6 @@ class SeedLayout(QVBoxLayout):
hbox.addWidget(self.seed_type_label) hbox.addWidget(self.seed_type_label)
# options # options
self.is_bip39 = False
self.is_ext = False self.is_ext = False
if options: if options:
opt_button = EnterButton(_('Options'), self.seed_options) opt_button = EnterButton(_('Options'), self.seed_options)
@ -160,60 +186,145 @@ class SeedLayout(QVBoxLayout):
hbox.addWidget(QLabel(_("Your seed extension is") + ':')) hbox.addWidget(QLabel(_("Your seed extension is") + ':'))
hbox.addWidget(passphrase_e) hbox.addWidget(passphrase_e)
self.addLayout(hbox) 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.addStretch(1)
self.seed_status = WWLabel('')
self.addWidget(self.seed_status)
self.seed_warning = WWLabel('') self.seed_warning = WWLabel('')
if msg: if msg:
self.seed_warning.setText(seed_warning_msg(seed)) self.seed_warning.setText(seed_warning_msg(seed))
self.addWidget(self.seed_warning) self.addWidget(self.seed_warning)
def initialize_completer(self): def initialize_completer(self):
bip39_english_list = Mnemonic('en').wordlist if self.seed_type != 'slip39':
old_list = old_mnemonic.wordlist bip39_english_list = Mnemonic('en').wordlist
only_old_list = set(old_list) - set(bip39_english_list) old_list = old_mnemonic.wordlist
self.wordlist = list(bip39_english_list) + list(only_old_list) # concat both lists only_old_list = set(old_list) - set(bip39_english_list)
self.wordlist.sort() self.wordlist = list(bip39_english_list) + list(only_old_list) # concat both lists
self.wordlist.sort()
class CompleterDelegate(QStyledItemDelegate):
def initStyleOption(self, option, index): class CompleterDelegate(QStyledItemDelegate):
super().initStyleOption(option, index) def initStyleOption(self, option, index):
# Some people complained that due to merging the two word lists, super().initStyleOption(option, index)
# it is difficult to restore from a metal backup, as they planned # Some people complained that due to merging the two word lists,
# to rely on the "4 letter prefixes are unique in bip39 word list" property. # it is difficult to restore from a metal backup, as they planned
# So we color words that are only in old list. # to rely on the "4 letter prefixes are unique in bip39 word list" property.
if option.text in only_old_list: # So we color words that are only in old list.
# yellow bg looks ~ok on both light/dark theme, regardless if (un)selected if option.text in only_old_list:
option.backgroundBrush = ColorScheme.YELLOW.as_color(background=True) # 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) self.completer = QCompleter(self.wordlist)
delegate = CompleterDelegate(self.seed_e) if delegate:
self.completer.popup().setItemDelegate(delegate) self.completer.popup().setItemDelegate(delegate)
self.seed_e.set_completer(self.completer) self.seed_e.set_completer(self.completer)
def get_seed_words(self):
return self.seed_e.text().split()
def get_seed(self): def get_seed(self):
text = self.seed_e.text() if self.seed_type != 'slip39':
return ' '.join(text.split()) return ' '.join(self.get_seed_words())
else:
return self.slip39_seed
def on_edit(self): def on_edit(self):
s = self.get_seed() s = ' '.join(self.get_seed_words())
b = self.is_seed(s) b = self.is_seed(s)
if not self.is_bip39: if self.seed_type == 'bip39':
t = seed_type(s)
label = _('Seed Type') + ': ' + t if t else ''
else:
from electrum.keystore import bip39_is_checksum_valid from electrum.keystore import bip39_is_checksum_valid
is_checksum, is_wordlist = bip39_is_checksum_valid(s) is_checksum, is_wordlist = bip39_is_checksum_valid(s)
status = ('checksum: ' + ('ok' if is_checksum else 'failed')) if is_wordlist else 'unknown wordlist' status = ('checksum: ' + ('ok' if is_checksum else 'failed')) if is_wordlist else 'unknown wordlist'
label = 'BIP39' + ' (%s)'%status 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.seed_type_label.setText(label)
self.parent.next_button.setEnabled(b) self.parent.next_button.setEnabled(b)
# disable suggestions if user already typed an unknown word # 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: if word not in self.wordlist:
self.seed_e.disable_suggestions() self.seed_e.disable_suggestions()
return return
self.seed_e.enable_suggestions() 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): class KeysLayout(QVBoxLayout):
def __init__( def __init__(
self, self,

6
electrum/plugins/trustedcoin/trustedcoin.py

@ -617,9 +617,10 @@ class TrustedCoinPlugin(BasePlugin):
def restore_wallet(self, wizard): def restore_wallet(self, wizard):
wizard.opt_bip39 = False wizard.opt_bip39 = False
wizard.opt_slip39 = False
wizard.opt_ext = True wizard.opt_ext = True
title = _("Restore two-factor Wallet") 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) wizard.restore_seed_dialog(run_next=f, test=self.is_valid_seed)
def on_restore_seed(self, wizard, seed, is_ext): def on_restore_seed(self, wizard, seed, is_ext):
@ -710,8 +711,9 @@ class TrustedCoinPlugin(BasePlugin):
self.do_auth(wizard, short_id, otp, xpub3) self.do_auth(wizard, short_id, otp, xpub3)
elif reset: elif reset:
wizard.opt_bip39 = False wizard.opt_bip39 = False
wizard.opt_slip39 = False
wizard.opt_ext = True 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) wizard.restore_seed_dialog(run_next=f, test=self.is_valid_seed)
def on_reset_seed(self, wizard, short_id, seed, is_ext, xpub3): def on_reset_seed(self, wizard, short_id, seed, is_ext, xpub3):

612
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') + ' <b>%d</b> ' % groups_completed + _('of') + ' <b>%d</b> ' % group_threshold + _('groups needed:<br/>')
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 += '<b>' + _('The set is complete!') + '</b>'
except Slip39Error as e:
encrypted_seed = None
status = _ERROR_STYLE % str(e)
return encrypted_seed, status
return None, status
"""
## Group status helpers
"""
_FINISHED = '<span style="color:green;">&#x2714;</span>'
_EMPTY = '<span style="color:red;">&#x2715;</span>'
_INPROGRESS = '<span style="color:orange;">&#x26ab;</span>'
_ERROR_STYLE = '<span style="color:red; font-weight:bold;">' + _('Error') + ': %s</span>'
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 + '<b>0</b> ' + _('shares from group') + ' <b>' + group_prefix + '</b>.<br/>'
else:
share = next(iter(group))
icon = _FINISHED if len(group) >= share.member_threshold else _INPROGRESS
return icon + '<b>%d</b> ' % len(group) + _('of') + ' <b>%d</b> ' % share.member_threshold + _('shares needed from group') + ' <b>%s</b>.<br/>' % 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,
)
Loading…
Cancel
Save