Implement SLIP-0039 wallet recovery.
This commit is contained in:
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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([
|
||||
'<b>' + _('Warning') + ':</b> ',
|
||||
_('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([
|
||||
'<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:
|
||||
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()
|
||||
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)
|
||||
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,
|
||||
|
||||
@@ -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):
|
||||
|
||||
612
electrum/slip39.py
Normal file
612
electrum/slip39.py
Normal file
@@ -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;">✔</span>'
|
||||
_EMPTY = '<span style="color:red;">✕</span>'
|
||||
_INPROGRESS = '<span style="color:orange;">⚫</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,
|
||||
)
|
||||
Reference in New Issue
Block a user