From fb94895b4483d5f29ad6ca3bc0aea7bd3eda4e82 Mon Sep 17 00:00:00 2001 From: Andrew Kozlik Date: Sat, 10 Oct 2020 17:54:51 +0200 Subject: [PATCH] [electrum] Implement SLIP-0039 wallet recovery. Summary: This implements recovery of a wallet from a Shamir's secret-sharing scheme. Current limitations: - we can only recover a wallet from an existing set of mnemonic shares, we don't generate new ones - the mnemonic shares are not saved to storage (wallet file), so users can't see their "seed" via the "Wallet > Show" seed menu (the action is disabled in the GUI) - we don't autodetect the seed type, the user needs to click the option button and select "slip39" explicitely This is a backport of [[https://github.com/spesmilo/electrum/pull/6917 | electrum#6917]] and [[https://github.com/spesmilo/electrum/pull/9059 | electrum#9059]] Depends on D17576 Test Plan: `python test_runner.py` Import a wallet from a SLIP39 share, test that it works (receive a send a transaction), close it and reopen it and test again (make sure the wallet file has all the info needed to open the wallet). Test the derivation path scanner. Reviewers: #bitcoin_abc, Fabien Reviewed By: #bitcoin_abc, Fabien Differential Revision: https://reviews.bitcoinabc.org/D17577 --- .../contrib/build-wine/deterministic.spec | 1 + electrum/contrib/osx/osx.spec | 1 + electrum/electrumabc/base_wizard.py | 33 +- electrum/electrumabc/keystore.py | 14 +- electrum/electrumabc/slip39.py | 700 +++++++++++ .../electrumabc/tests/slip39-vectors.json | 406 +++++++ electrum/electrumabc/tests/test_mnemonic.py | 34 +- .../electrumabc/tests/test_wallet_vertical.py | 88 +- electrum/electrumabc/wordlist/slip39.txt | 1024 +++++++++++++++++ electrum/electrumabc_gui/qt/installwizard.py | 6 +- electrum/electrumabc_gui/qt/seed_dialog.py | 208 +++- 11 files changed, 2459 insertions(+), 56 deletions(-) create mode 100644 electrum/electrumabc/slip39.py create mode 100644 electrum/electrumabc/tests/slip39-vectors.json create mode 100644 electrum/electrumabc/wordlist/slip39.txt diff --git a/electrum/contrib/build-wine/deterministic.spec b/electrum/contrib/build-wine/deterministic.spec index 5db78e40eb..12895c4d01 100644 --- a/electrum/contrib/build-wine/deterministic.spec +++ b/electrum/contrib/build-wine/deterministic.spec @@ -51,6 +51,7 @@ datas = [ (home+'electrumabc/servers_testnet.json', 'electrumabc'), (home+'electrumabc/servers_regtest.json', 'electrumabc'), (home+'electrumabc/wordlist/english.txt', 'electrumabc/wordlist'), + (home+'electrumabc/wordlist/slip39.txt', 'electrumabc/wordlist'), (home+'electrumabc/locale', 'electrumabc/locale'), (home+'electrumabc_gui/qt/data/ecsupplemental_win.ttf', 'electrumabc_gui/qt/data'), (home+'electrumabc_plugins', 'electrumabc_plugins'), diff --git a/electrum/contrib/osx/osx.spec b/electrum/contrib/osx/osx.spec index af4d287afb..543baab5f1 100644 --- a/electrum/contrib/osx/osx.spec +++ b/electrum/contrib/osx/osx.spec @@ -34,6 +34,7 @@ datas = [ (home+'electrumabc/servers_testnet.json', PYPKG), (home+'electrumabc/servers_regtest.json', PYPKG), (home+'electrumabc/wordlist/english.txt', PYPKG + '/wordlist'), + (home+'electrumabc/wordlist/slip39.txt', PYPKG + '/wordlist'), (home+'electrumabc/locale', PYPKG + '/locale'), (home+'electrumabc_plugins', PYPKG + '_plugins'), ] diff --git a/electrum/electrumabc/base_wizard.py b/electrum/electrumabc/base_wizard.py index 929da05c01..b2e090ef4f 100644 --- a/electrum/electrumabc/base_wizard.py +++ b/electrum/electrumabc/base_wizard.py @@ -33,7 +33,7 @@ from electrumabc_plugins.hw_wallet import HWPluginBase -from . import bitcoin, keystore, mnemo, util +from . import bitcoin, keystore, mnemo, slip39, util from .address import Address from .bip32 import is_bip32_derivation, xpub_type from .constants import CURRENCY, PROJECT_NAME, REPOSITORY_URL @@ -558,20 +558,27 @@ def passphrase_dialog(self, run_next): def restore_from_seed(self): self.opt_bip39 = True + self.opt_slip39 = True self.opt_ext = True # TODO FIX #bitcoin.is_seed if self.wallet_type == 'standard' else bitcoin.is_new_seed test = mnemo.is_seed self.restore_seed_dialog(run_next=self.on_restore_seed, test=test) - def on_restore_seed(self, seed, is_bip39, is_ext): - # NB: seed_type_name here may also auto-detect 'bip39' - self.seed_type = "bip39" if is_bip39 else mnemo.seed_type_name(seed) + def on_restore_seed(self, seed: str | slip39.EncryptedSeed, seed_type, is_ext): + self.seed_type = seed_type if self.seed_type == "bip39": def f(passphrase): self.on_restore_bip39(seed, passphrase) self.passphrase_dialog(run_next=f) if is_ext else f("") + elif self.seed_type == "slip39": + + def f(passphrase): + self.on_restore_slip39(seed, passphrase) + + self.passphrase_dialog(run_next=f) if is_ext else f("") + elif self.seed_type in ["standard", "electrum"]: def f(passphrase): @@ -583,10 +590,18 @@ def f(passphrase): else: raise RuntimeError("Unknown seed type", self.seed_type) - def on_restore_bip39(self, seed, passphrase): + def on_restore_bip39(self, seed: str, passphrase): bip32_seed = mnemo.bip39_mnemonic_to_seed(seed, passphrase or "") self.derivation_dialog( - lambda x: self.run("on_bip44", seed, passphrase, str(x)), + lambda x: self.run("on_bip44", seed, passphrase, "bip39", str(x)), + keystore.bip44_derivation_xec(0), + bip32_seed=bip32_seed, + ) + + def on_restore_slip39(self, seed: slip39.EncryptedSeed, passphrase: str): + bip32_seed = seed.decrypt(passphrase) + self.derivation_dialog( + lambda x: self.run("on_bip44", seed, passphrase, "slip39", str(x)), keystore.bip44_derivation_xec(0), bip32_seed=bip32_seed, ) @@ -597,10 +612,9 @@ def create_keystore(self, seed, passphrase): k = keystore.from_seed(seed, passphrase) self.on_keystore(k) - def on_bip44(self, seed, passphrase, derivation): - # BIP39 + def on_bip44(self, seed, passphrase, seed_type, derivation): k = keystore.from_seed( - seed, passphrase, derivation=derivation, seed_type="bip39" + seed, passphrase, seed_type=seed_type, derivation=derivation ) self.on_keystore(k) @@ -756,6 +770,7 @@ def create_seed(self, seed_type): # This should never happen. raise ValueError("Cannot make seed for unknown seed type " + str(seed_type)) self.opt_bip39 = False + self.opt_slip39 = False self.show_seed_dialog( run_next=lambda x: self.request_passphrase(seed, x), seed_text=seed ) diff --git a/electrum/electrumabc/keystore.py b/electrum/electrumabc/keystore.py index 8646ebc0b3..2a1575bc51 100644 --- a/electrum/electrumabc/keystore.py +++ b/electrum/electrumabc/keystore.py @@ -31,7 +31,7 @@ from mnemonic import Mnemonic -from . import bitcoin, mnemo, networks +from . import bitcoin, mnemo, networks, slip39 from .address import Address, PublicKey from .bip32 import ( CKD_pub, @@ -840,7 +840,9 @@ def from_bip32_seed_and_derivation(bip32_seed: bytes, derivation: str) -> BIP32K return keystore -def from_seed(seed, passphrase, *, seed_type="", derivation=None) -> KeyStore: +def from_seed( + seed: str | slip39.EncryptedSeed, passphrase, *, seed_type="", derivation=None +) -> KeyStore: if not seed_type: seed_type = mnemo.seed_type_name(seed) # auto-detect if seed_type == "old": @@ -858,6 +860,14 @@ def from_seed(seed, passphrase, *, seed_type="", derivation=None) -> KeyStore: keystore = from_bip32_seed_and_derivation(bip32_seed, derivation) keystore.add_seed(seed, seed_type=seed_type) keystore.passphrase = passphrase + elif seed_type == "slip39": + derivation = derivation or bip44_derivation_xec(0) + bip32_seed = seed.decrypt(passphrase) + keystore = from_bip32_seed_and_derivation(bip32_seed, derivation) + keystore.seed_type = "slip39" + # We don't save the "seed" (the shares) and passphrase to disk for now. + # Users won't be able to display the "seed" (the shares used to restore the + # wallet). else: raise InvalidSeed() return keystore diff --git a/electrum/electrumabc/slip39.py b/electrum/electrumabc/slip39.py new file mode 100644 index 0000000000..3249f24bca --- /dev/null +++ b/electrum/electrumabc/slip39.py @@ -0,0 +1,700 @@ +# 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 .i18n import _ +from .wordlist 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 = 4 +"""The length of the iteration exponent in bits.""" + +_EXTENDABLE_BACKUP_FLAG_LENGTH_BITS = 1 +"""The length of the extendable backup flag in bits.""" + +_ID_EXP_LENGTH_WORDS = _bits_to_words( + _ID_LENGTH_BITS + _EXTENDABLE_BACKUP_FLAG_LENGTH_BITS + _ITERATION_EXP_LENGTH_BITS +) +"""The length of the random identifier, extendable backup flag and iteration exponent in words.""" + +_INDEX_LENGTH_BITS = 4 +"""The length of the group index, group threshold, group count, and member index in bits.""" + +_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_NON_EXTENDABLE = b"shamir" +"""The customization string used in the RS1024 checksum and in the PBKDF2 salt when extendable backup flag is not set.""" + +_CUSTOMIZATION_STRING_EXTENDABLE = b"shamir_extendable" +"""The customization string used in the RS1024 checksum when extendable backup flag is set.""" + +_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, + extendable_backup_flag: bool, + 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.extendable_backup_flag = extendable_backup_flag + 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.extendable_backup_flag, + self.iteration_exponent, + self.group_threshold, + self.group_count, + ) + + +class EncryptedSeed: + """ + Represents the encrypted master seed for BIP-32. + """ + + def __init__( + self, + identifier: int, + extendable_backup_flag: bool, + iteration_exponent: int, + encrypted_master_secret: bytes, + ): + self.identifier = identifier + self.extendable_backup_flag = extendable_backup_flag + 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) + ll = self.encrypted_master_secret[: ems_len // 2] + rr = self.encrypted_master_secret[ems_len // 2 :] + salt = _get_salt(self.identifier, self.extendable_backup_flag) + for i in reversed(range(_ROUND_COUNT)): + (ll, rr) = ( + rr, + _xor( + ll, + _round_function(i, passphrase, self.iteration_exponent, salt, rr), + ), + ) + return rr + ll + + +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, + extendable_backup_flag, + 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. " + f"Expected {group_threshold} full groups, but {len(groups)} were provided." + ) + + 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, extendable_backup_flag, 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.")) + + idExpExtInt = _int_from_indices(mnemonic_data[:_ID_EXP_LENGTH_WORDS]) + identifier = idExpExtInt >> ( + _EXTENDABLE_BACKUP_FLAG_LENGTH_BITS + _ITERATION_EXP_LENGTH_BITS + ) + extendable_backup_flag = bool( + (idExpExtInt >> _ITERATION_EXP_LENGTH_BITS) + & ((1 << _EXTENDABLE_BACKUP_FLAG_LENGTH_BITS) - 1) + ) + iteration_exponent = idExpExtInt & ((1 << _ITERATION_EXP_LENGTH_BITS) - 1) + + if not _rs1024_verify_checksum(mnemonic_data, extendable_backup_flag): + raise Slip39Error(_("Invalid mnemonic checksum.")) + + 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, _INDEX_LENGTH_BITS) + 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, + extendable_backup_flag, + 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, " + f"but it contains {len(wordlist)} words." + ) + + return wordlist + + +def process_mnemonics(mnemonics: List[str]) -> Tuple[Optional[EncryptedSeed], 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 #{} is not part of the current set.").format( + share.index + ) + return None, _ERROR_STYLE.format(error_text) + for other in groups[share.group_index]: + if share.member_index == other.member_index: + error_text = _("Share #{} is a duplicate of share #{}.").format( + share.index, other.index + ) + return None, _ERROR_STYLE.format(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 + extendable_backup_flag = shares[0].extendable_backup_flag + 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 {} of {} groups needed").format( + f"{groups_completed}", f"{group_threshold}" + ) + status += ":
" + + for group_index in range(group_count): + group_prefix = _make_group_prefix( + identifier, + extendable_backup_flag, + 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.format(_("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.format(str(e)) + return encrypted_seed, status + + return None, status + + +# Group status helpers + +_FINISHED = '' +_EMPTY = '' +_INPROGRESS = '' +_ERROR_STYLE = ( + '' + _("Error") + ": {}" +) + + +def _make_group_prefix( + identifier, + extendable_backup_flag, + iteration_exponent, + group_index, + group_threshold, + group_count, +): + wordlist = get_wordlist() + val = identifier + val <<= _EXTENDABLE_BACKUP_FLAG_LENGTH_BITS + val += int(extendable_backup_flag) + val <<= _ITERATION_EXP_LENGTH_BITS + val += iteration_exponent + val <<= _INDEX_LENGTH_BITS + val += group_index + val <<= _INDEX_LENGTH_BITS + val += group_threshold - 1 + val <<= _INDEX_LENGTH_BITS + 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 + + _("{} shares from group {}").format("0 ", f"{group_prefix}") + + ".
" + ) + share = next(iter(group)) + icon = _FINISHED if len(group) >= share.member_threshold else _INPROGRESS + return ( + icon + + _("{} of {} shares needed from group {}").format( + f"{len(group)}", + f"{share.member_threshold}", + f"{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 _get_customization_string(extendable_backup_flag: bool) -> bytes: + if extendable_backup_flag: + return _CUSTOMIZATION_STRING_EXTENDABLE + else: + return _CUSTOMIZATION_STRING_NON_EXTENDABLE + + +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, extendable_backup_flag: bool) -> bool: + """ + Verifies a checksum of the given mnemonic, which was already parsed into Indices. + """ + return ( + _rs1024_polymod(tuple(_get_customization_string(extendable_backup_flag)) + 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 = {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 = {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, extendable_backup_flag: bool) -> bytes: + if extendable_backup_flag: + return bytes() + else: + return _CUSTOMIZATION_STRING_NON_EXTENDABLE + 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, bool, int, int, int, MnemonicGroups]: + identifiers = set() + extendable_backup_flags = 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) + extendable_backup_flags.add(share.extendable_backup_flag) + 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(extendable_backup_flags) != 1 + or len(iteration_exponents) != 1 + ): + raise Slip39Error( + "Invalid set of mnemonics. All mnemonics must begin with the same " + f"{_ID_EXP_LENGTH_WORDS} 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({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(), + extendable_backup_flags.pop(), + iteration_exponents.pop(), + group_thresholds.pop(), + group_counts.pop(), + groups, + ) diff --git a/electrum/electrumabc/tests/slip39-vectors.json b/electrum/electrumabc/tests/slip39-vectors.json new file mode 100644 index 0000000000..2e6da291bd --- /dev/null +++ b/electrum/electrumabc/tests/slip39-vectors.json @@ -0,0 +1,406 @@ +[ + [ + "1. Valid mnemonic without sharing (128 bits)", + [ + "duckling enlarge academic academic agency result length solution fridge kidney coal piece deal husband erode duke ajar critical decision keyboard" + ], + "bb54aac4b89dc868ba37d9cc21b2cece", + "xprv9s21ZrQH143K4QViKpwKCpS2zVbz8GrZgpEchMDg6KME9HZtjfL7iThE9w5muQA4YPHKN1u5VM1w8D4pvnjxa2BmpGMfXr7hnRrRHZ93awZ" + ], + [ + "2. Mnemonic with invalid checksum (128 bits)", + [ + "duckling enlarge academic academic agency result length solution fridge kidney coal piece deal husband erode duke ajar critical decision kidney" + ], + "", + "" + ], + [ + "3. Mnemonic with invalid padding (128 bits)", + [ + "duckling enlarge academic academic email result length solution fridge kidney coal piece deal husband erode duke ajar music cargo fitness" + ], + "", + "" + ], + [ + "4. Basic sharing 2-of-3 (128 bits)", + [ + "shadow pistol academic always adequate wildlife fancy gross oasis cylinder mustang wrist rescue view short owner flip making coding armed", + "shadow pistol academic acid actress prayer class unknown daughter sweater depict flip twice unkind craft early superior advocate guest smoking" + ], + "b43ceb7e57a0ea8766221624d01b0864", + "xprv9s21ZrQH143K2nNuAbfWPHBtfiSCS14XQgb3otW4pX655q58EEZeC8zmjEUwucBu9dPnxdpbZLCn57yx45RBkwJHnwHFjZK4XPJ8SyeYjYg" + ], + [ + "5. Basic sharing 2-of-3 (128 bits)", + [ + "shadow pistol academic always adequate wildlife fancy gross oasis cylinder mustang wrist rescue view short owner flip making coding armed" + ], + "", + "" + ], + [ + "6. Mnemonics with different identifiers (128 bits)", + [ + "adequate smoking academic acid debut wine petition glen cluster slow rhyme slow simple epidemic rumor junk tracks treat olympic tolerate", + "adequate stay academic agency agency formal party ting frequent learn upstairs remember smear leaf damage anatomy ladle market hush corner" + ], + "", + "" + ], + [ + "7. Mnemonics with different iteration exponents (128 bits)", + [ + "peasant leaves academic acid desert exact olympic math alive axle trial tackle drug deny decent smear dominant desert bucket remind", + "peasant leader academic agency cultural blessing percent network envelope medal junk primary human pumps jacket fragment payroll ticket evoke voice" + ], + "", + "" + ], + [ + "8. Mnemonics with mismatching group thresholds (128 bits)", + [ + "liberty category beard echo animal fawn temple briefing math username various wolf aviation fancy visual holy thunder yelp helpful payment", + "liberty category beard email beyond should fancy romp founder easel pink holy hairy romp loyalty material victim owner toxic custody", + "liberty category academic easy being hazard crush diminish oral lizard reaction cluster force dilemma deploy force club veteran expect photo" + ], + "", + "" + ], + [ + "9. Mnemonics with mismatching group counts (128 bits)", + [ + "average senior academic leaf broken teacher expect surface hour capture obesity desire negative dynamic dominant pistol mineral mailman iris aide", + "average senior academic agency curious pants blimp spew clothes slice script dress wrap firm shaft regular slavery negative theater roster" + ], + "", + "" + ], + [ + "10. Mnemonics with greater group threshold than group counts (128 bits)", + [ + "music husband acrobat acid artist finance center either graduate swimming object bike medical clothes station aspect spider maiden bulb welcome", + "music husband acrobat agency advance hunting bike corner density careful material civil evil tactics remind hawk discuss hobo voice rainbow", + "music husband beard academic black tricycle clock mayor estimate level photo episode exclude ecology papa source amazing salt verify divorce" + ], + "", + "" + ], + [ + "11. Mnemonics with duplicate member indices (128 bits)", + [ + "device stay academic always dive coal antenna adult black exceed stadium herald advance soldier busy dryer daughter evaluate minister laser", + "device stay academic always dwarf afraid robin gravity crunch adjust soul branch walnut coastal dream costume scholar mortgage mountain pumps" + ], + "", + "" + ], + [ + "12. Mnemonics with mismatching member thresholds (128 bits)", + [ + "hour painting academic academic device formal evoke guitar random modern justice filter withdraw trouble identify mailman insect general cover oven", + "hour painting academic agency artist again daisy capital beaver fiber much enjoy suitable symbolic identify photo editor romp float echo" + ], + "", + "" + ], + [ + "13. Mnemonics giving an invalid digest (128 bits)", + [ + "guilt walnut academic acid deliver remove equip listen vampire tactics nylon rhythm failure husband fatigue alive blind enemy teaspoon rebound", + "guilt walnut academic agency brave hamster hobo declare herd taste alpha slim criminal mild arcade formal romp branch pink ambition" + ], + "", + "" + ], + [ + "14. Insufficient number of groups (128 bits, case 1)", + [ + "eraser senior beard romp adorn nuclear spill corner cradle style ancient family general leader ambition exchange unusual garlic promise voice" + ], + "", + "" + ], + [ + "15. Insufficient number of groups (128 bits, case 2)", + [ + "eraser senior decision scared cargo theory device idea deliver modify curly include pancake both news skin realize vitamins away join", + "eraser senior decision roster beard treat identify grumpy salt index fake aviation theater cubic bike cause research dragon emphasis counter" + ], + "", + "" + ], + [ + "16. Threshold number of groups, but insufficient number of members in one group (128 bits)", + [ + "eraser senior decision shadow artist work morning estate greatest pipeline plan ting petition forget hormone flexible general goat admit surface", + "eraser senior beard romp adorn nuclear spill corner cradle style ancient family general leader ambition exchange unusual garlic promise voice" + ], + "", + "" + ], + [ + "17. Threshold number of groups and members in each group (128 bits, case 1)", + [ + "eraser senior decision roster beard treat identify grumpy salt index fake aviation theater cubic bike cause research dragon emphasis counter", + "eraser senior ceramic snake clay various huge numb argue hesitate auction category timber browser greatest hanger petition script leaf pickup", + "eraser senior ceramic shaft dynamic become junior wrist silver peasant force math alto coal amazing segment yelp velvet image paces", + "eraser senior ceramic round column hawk trust auction smug shame alive greatest sheriff living perfect corner chest sled fumes adequate", + "eraser senior decision smug corner ruin rescue cubic angel tackle skin skunk program roster trash rumor slush angel flea amazing" + ], + "7c3397a292a5941682d7a4ae2d898d11", + "xprv9s21ZrQH143K3dzDLfeY3cMp23u5vDeFYftu5RPYZPucKc99mNEddU4w99GxdgUGcSfMpVDxhnR1XpJzZNXRN1m6xNgnzFS5MwMP6QyBRKV" + ], + [ + "18. Threshold number of groups and members in each group (128 bits, case 2)", + [ + "eraser senior decision smug corner ruin rescue cubic angel tackle skin skunk program roster trash rumor slush angel flea amazing", + "eraser senior beard romp adorn nuclear spill corner cradle style ancient family general leader ambition exchange unusual garlic promise voice", + "eraser senior decision scared cargo theory device idea deliver modify curly include pancake both news skin realize vitamins away join" + ], + "7c3397a292a5941682d7a4ae2d898d11", + "xprv9s21ZrQH143K3dzDLfeY3cMp23u5vDeFYftu5RPYZPucKc99mNEddU4w99GxdgUGcSfMpVDxhnR1XpJzZNXRN1m6xNgnzFS5MwMP6QyBRKV" + ], + [ + "19. Threshold number of groups and members in each group (128 bits, case 3)", + [ + "eraser senior beard romp adorn nuclear spill corner cradle style ancient family general leader ambition exchange unusual garlic promise voice", + "eraser senior acrobat romp bishop medical gesture pumps secret alive ultimate quarter priest subject class dictate spew material endless market" + ], + "7c3397a292a5941682d7a4ae2d898d11", + "xprv9s21ZrQH143K3dzDLfeY3cMp23u5vDeFYftu5RPYZPucKc99mNEddU4w99GxdgUGcSfMpVDxhnR1XpJzZNXRN1m6xNgnzFS5MwMP6QyBRKV" + ], + [ + "20. Valid mnemonic without sharing (256 bits)", + [ + "theory painting academic academic armed sweater year military elder discuss acne wildlife boring employer fused large satoshi bundle carbon diagnose anatomy hamster leaves tracks paces beyond phantom capital marvel lips brave detect luck" + ], + "989baf9dcaad5b10ca33dfd8cc75e42477025dce88ae83e75a230086a0e00e92", + "xprv9s21ZrQH143K41mrxxMT2FpiheQ9MFNmWVK4tvX2s28KLZAhuXWskJCKVRQprq9TnjzzzEYePpt764csiCxTt22xwGPiRmUjYUUdjaut8RM" + ], + [ + "21. Mnemonic with invalid checksum (256 bits)", + [ + "theory painting academic academic armed sweater year military elder discuss acne wildlife boring employer fused large satoshi bundle carbon diagnose anatomy hamster leaves tracks paces beyond phantom capital marvel lips brave detect lunar" + ], + "", + "" + ], + [ + "22. Mnemonic with invalid padding (256 bits)", + [ + "theory painting academic academic campus sweater year military elder discuss acne wildlife boring employer fused large satoshi bundle carbon diagnose anatomy hamster leaves tracks paces beyond phantom capital marvel lips facility obtain sister" + ], + "", + "" + ], + [ + "23. Basic sharing 2-of-3 (256 bits)", + [ + "humidity disease academic always aluminum jewelry energy woman receiver strategy amuse duckling lying evidence network walnut tactics forget hairy rebound impulse brother survive clothes stadium mailman rival ocean reward venture always armed unwrap", + "humidity disease academic agency actress jacket gross physics cylinder solution fake mortgage benefit public busy prepare sharp friar change work slow purchase ruler again tricycle involve viral wireless mixture anatomy desert cargo upgrade" + ], + "c938b319067687e990e05e0da0ecce1278f75ff58d9853f19dcaeed5de104aae", + "xprv9s21ZrQH143K3a4GRMgK8WnawupkwkP6gyHxRsXnMsYPTPH21fWwNcAytijtfyftqNfiaY8LgQVdBQvHZ9FBvtwdjC7LCYxjYruJFuLzyMQ" + ], + [ + "24. Basic sharing 2-of-3 (256 bits)", + [ + "humidity disease academic always aluminum jewelry energy woman receiver strategy amuse duckling lying evidence network walnut tactics forget hairy rebound impulse brother survive clothes stadium mailman rival ocean reward venture always armed unwrap" + ], + "", + "" + ], + [ + "25. Mnemonics with different identifiers (256 bits)", + [ + "smear husband academic acid deadline scene venture distance dive overall parking bracelet elevator justice echo burning oven chest duke nylon", + "smear isolate academic agency alpha mandate decorate burden recover guard exercise fatal force syndrome fumes thank guest drift dramatic mule" + ], + "", + "" + ], + [ + "26. Mnemonics with different iteration exponents (256 bits)", + [ + "finger trash academic acid average priority dish revenue academic hospital spirit western ocean fact calcium syndrome greatest plan losing dictate", + "finger traffic academic agency building lilac deny paces subject threaten diploma eclipse window unknown health slim piece dragon focus smirk" + ], + "", + "" + ], + [ + "27. Mnemonics with mismatching group thresholds (256 bits)", + [ + "flavor pink beard echo depart forbid retreat become frost helpful juice unwrap reunion credit math burning spine black capital lair", + "flavor pink beard email diet teaspoon freshman identify document rebound cricket prune headset loyalty smell emission skin often square rebound", + "flavor pink academic easy credit cage raisin crazy closet lobe mobile become drink human tactics valuable hand capture sympathy finger" + ], + "", + "" + ], + [ + "28. Mnemonics with mismatching group counts (256 bits)", + [ + "column flea academic leaf debut extra surface slow timber husky lawsuit game behavior husky swimming already paper episode tricycle scroll", + "column flea academic agency blessing garbage party software stadium verify silent umbrella therapy decorate chemical erode dramatic eclipse replace apart" + ], + "", + "" + ], + [ + "29. Mnemonics with greater group threshold than group counts (256 bits)", + [ + "smirk pink acrobat acid auction wireless impulse spine sprinkle fortune clogs elbow guest hush loyalty crush dictate tracks airport talent", + "smirk pink acrobat agency dwarf emperor ajar organize legs slice harvest plastic dynamic style mobile float bulb health coding credit", + "smirk pink beard academic alto strategy carve shame language rapids ruin smart location spray training acquire eraser endorse submit peaceful" + ], + "", + "" + ], + [ + "30. Mnemonics with duplicate member indices (256 bits)", + [ + "fishing recover academic always device craft trend snapshot gums skin downtown watch device sniff hour clock public maximum garlic born", + "fishing recover academic always aircraft view software cradle fangs amazing package plastic evaluate intend penalty epidemic anatomy quarter cage apart" + ], + "", + "" + ], + [ + "31. Mnemonics with mismatching member thresholds (256 bits)", + [ + "evoke garden academic academic answer wolf scandal modern warmth station devote emerald market physics surface formal amazing aquatic gesture medical", + "evoke garden academic agency deal revenue knit reunion decrease magazine flexible company goat repair alarm military facility clogs aide mandate" + ], + "", + "" + ], + [ + "32. Mnemonics giving an invalid digest (256 bits)", + [ + "river deal academic acid average forbid pistol peanut custody bike class aunt hairy merit valid flexible learn ajar very easel", + "river deal academic agency camera amuse lungs numb isolate display smear piece traffic worthy year patrol crush fact fancy emission" + ], + "", + "" + ], + [ + "33. Insufficient number of groups (256 bits, case 1)", + [ + "wildlife deal beard romp alcohol space mild usual clothes union nuclear testify course research heat listen task location thank hospital slice smell failure fawn helpful priest ambition average recover lecture process dough stadium" + ], + "", + "" + ], + [ + "34. Insufficient number of groups (256 bits, case 2)", + [ + "wildlife deal decision scared acne fatal snake paces obtain election dryer dominant romp tactics railroad marvel trust helpful flip peanut theory theater photo luck install entrance taxi step oven network dictate intimate listen", + "wildlife deal decision smug ancestor genuine move huge cubic strategy smell game costume extend swimming false desire fake traffic vegan senior twice timber submit leader payroll fraction apart exact forward pulse tidy install" + ], + "", + "" + ], + [ + "35. Threshold number of groups, but insufficient number of members in one group (256 bits)", + [ + "wildlife deal decision shadow analysis adjust bulb skunk muscle mandate obesity total guitar coal gravity carve slim jacket ruin rebuild ancestor numerous hour mortgage require herd maiden public ceiling pecan pickup shadow club", + "wildlife deal beard romp alcohol space mild usual clothes union nuclear testify course research heat listen task location thank hospital slice smell failure fawn helpful priest ambition average recover lecture process dough stadium" + ], + "", + "" + ], + [ + "36. Threshold number of groups and members in each group (256 bits, case 1)", + [ + "wildlife deal ceramic round aluminum pitch goat racism employer miracle percent math decision episode dramatic editor lily prospect program scene rebuild display sympathy have single mustang junction relate often chemical society wits estate", + "wildlife deal decision scared acne fatal snake paces obtain election dryer dominant romp tactics railroad marvel trust helpful flip peanut theory theater photo luck install entrance taxi step oven network dictate intimate listen", + "wildlife deal ceramic scatter argue equip vampire together ruin reject literary rival distance aquatic agency teammate rebound false argue miracle stay again blessing peaceful unknown cover beard acid island language debris industry idle", + "wildlife deal ceramic snake agree voter main lecture axis kitchen physics arcade velvet spine idea scroll promise platform firm sharp patrol divorce ancestor fantasy forbid goat ajar believe swimming cowboy symbolic plastic spelling", + "wildlife deal decision shadow analysis adjust bulb skunk muscle mandate obesity total guitar coal gravity carve slim jacket ruin rebuild ancestor numerous hour mortgage require herd maiden public ceiling pecan pickup shadow club" + ], + "5385577c8cfc6c1a8aa0f7f10ecde0a3318493262591e78b8c14c6686167123b", + "xprv9s21ZrQH143K2UspC9FRPfQC9NcDB4HPkx1XG9UEtuceYtpcCZ6ypNZWdgfxQ9dAFVeD1F4Zg4roY7nZm2LB7THPD6kaCege3M7EuS8v85c" + ], + [ + "37. Threshold number of groups and members in each group (256 bits, case 2)", + [ + "wildlife deal decision scared acne fatal snake paces obtain election dryer dominant romp tactics railroad marvel trust helpful flip peanut theory theater photo luck install entrance taxi step oven network dictate intimate listen", + "wildlife deal beard romp alcohol space mild usual clothes union nuclear testify course research heat listen task location thank hospital slice smell failure fawn helpful priest ambition average recover lecture process dough stadium", + "wildlife deal decision smug ancestor genuine move huge cubic strategy smell game costume extend swimming false desire fake traffic vegan senior twice timber submit leader payroll fraction apart exact forward pulse tidy install" + ], + "5385577c8cfc6c1a8aa0f7f10ecde0a3318493262591e78b8c14c6686167123b", + "xprv9s21ZrQH143K2UspC9FRPfQC9NcDB4HPkx1XG9UEtuceYtpcCZ6ypNZWdgfxQ9dAFVeD1F4Zg4roY7nZm2LB7THPD6kaCege3M7EuS8v85c" + ], + [ + "38. Threshold number of groups and members in each group (256 bits, case 3)", + [ + "wildlife deal beard romp alcohol space mild usual clothes union nuclear testify course research heat listen task location thank hospital slice smell failure fawn helpful priest ambition average recover lecture process dough stadium", + "wildlife deal acrobat romp anxiety axis starting require metric flexible geology game drove editor edge screw helpful have huge holy making pitch unknown carve holiday numb glasses survive already tenant adapt goat fangs" + ], + "5385577c8cfc6c1a8aa0f7f10ecde0a3318493262591e78b8c14c6686167123b", + "xprv9s21ZrQH143K2UspC9FRPfQC9NcDB4HPkx1XG9UEtuceYtpcCZ6ypNZWdgfxQ9dAFVeD1F4Zg4roY7nZm2LB7THPD6kaCege3M7EuS8v85c" + ], + [ + "39. Mnemonic with insufficient length", + [ + "junk necklace academic academic acne isolate join hesitate lunar roster dough calcium chemical ladybug amount mobile glasses verify cylinder" + ], + "", + "" + ], + [ + "40. Mnemonic with invalid master secret length", + [ + "fraction necklace academic academic award teammate mouse regular testify coding building member verdict purchase blind camera duration email prepare spirit quarter" + ], + "", + "" + ], + [ + "41. Valid mnemonics which can detect some errors in modular arithmetic", + [ + "herald flea academic cage avoid space trend estate dryer hairy evoke eyebrow improve airline artwork garlic premium duration prevent oven", + "herald flea academic client blue skunk class goat luxury deny presence impulse graduate clay join blanket bulge survive dish necklace", + "herald flea academic acne advance fused brother frozen broken game ranked ajar already believe check install theory angry exercise adult" + ], + "ad6f2ad8b59bbbaa01369b9006208d9a", + "xprv9s21ZrQH143K2R4HJxcG1eUsudvHM753BZ9vaGkpYCoeEhCQx147C5qEcupPHxcXYfdYMwJmsKXrHDhtEwutxTTvFzdDCZVQwHneeQH8ioH" + ], + [ + "42. Valid extendable mnemonic without sharing (128 bits)", + [ + "testify swimming academic academic column loyalty smear include exotic bedroom exotic wrist lobe cover grief golden smart junior estimate learn" + ], + "1679b4516e0ee5954351d288a838f45e", + "xprv9s21ZrQH143K2w6eTpQnB73CU8Qrhg6gN3D66Jr16n5uorwoV7CwxQ5DofRPyok5DyRg4Q3BfHfCgJFk3boNRPPt1vEW1ENj2QckzVLQFXu" + ], + [ + "43. Extendable basic sharing 2-of-3 (128 bits)", + [ + "enemy favorite academic acid cowboy phrase havoc level response walnut budget painting inside trash adjust froth kitchen learn tidy punish", + "enemy favorite academic always academic sniff script carpet romp kind promise scatter center unfair training emphasis evening belong fake enforce" + ], + "48b1a4b80b8c209ad42c33672bdaa428", + "xprv9s21ZrQH143K4FS1qQdXYAFVAHiSAnjj21YAKGh2CqUPJ2yQhMmYGT4e5a2tyGLiVsRgTEvajXkxhg92zJ8zmWZas9LguQWz7WZShfJg6RS" + ], + [ + "44. Valid extendable mnemonic without sharing (256 bits)", + [ + "impulse calcium academic academic alcohol sugar lyrics pajamas column facility finance tension extend space birthday rainbow swimming purple syndrome facility trial warn duration snapshot shadow hormone rhyme public spine counter easy hawk album" + ], + "8340611602fe91af634a5f4608377b5235fa2d757c51d720c0c7656249a3035f", + "xprv9s21ZrQH143K2yJ7S8bXMiGqp1fySH8RLeFQKQmqfmmLTRwWmAYkpUcWz6M42oGoFMJRENmvsGQmunWTdizsi8v8fku8gpbVvYSiCYJTF1Y" + ], + [ + "45. Extendable basic sharing 2-of-3 (256 bits)", + [ + "western apart academic always artist resident briefing sugar woman oven coding club ajar merit pecan answer prisoner artist fraction amount desktop mild false necklace muscle photo wealthy alpha category unwrap spew losing making", + "western apart academic acid answer ancient auction flip image penalty oasis beaver multiple thunder problem switch alive heat inherit superior teaspoon explain blanket pencil numb lend punish endless aunt garlic humidity kidney observe" + ], + "8dc652d6d6cd370d8c963141f6d79ba440300f25c467302c1d966bff8f62300d", + "xprv9s21ZrQH143K2eFW2zmu3aayWWd6MJZBG7RebW35fiKcoCZ6jFi6U5gzffB9McDdiKTecUtRqJH9GzueCXiQK1LaQXdgthS8DgWfC8Uu3z7" + ] +] diff --git a/electrum/electrumabc/tests/test_mnemonic.py b/electrum/electrumabc/tests/test_mnemonic.py index 9b3519aab4..cf3f3b5a16 100644 --- a/electrum/electrumabc/tests/test_mnemonic.py +++ b/electrum/electrumabc/tests/test_mnemonic.py @@ -1,8 +1,10 @@ +import json +import os import unittest import mnemonic -from .. import mnemo, old_mnemonic +from .. import mnemo, old_mnemonic, slip39 from ..util import bh2u @@ -157,5 +159,35 @@ def test_seed_type(self): self.assertEqual(_type, mnemo.seed_type_name(seed_words), msg=seed_words) +class TestSlip39(unittest.TestCase): + """Test SLIP39 test vectors.""" + + def test_slip39_vectors(self): + test_vector_file = os.path.join( + os.path.dirname(__file__), "slip39-vectors.json" + ) + with open(test_vector_file, "r", encoding="utf-8") as f: + vectors = json.load(f) + for description, mnemonics, expected_secret, extended_private_key in vectors: + if expected_secret: + encrypted_seed = slip39.recover_ems(mnemonics) + assert bytes.fromhex(expected_secret) == encrypted_seed.decrypt( + "TREZOR" + ), 'Incorrect secret for test vector "{}".'.format(description) + else: + with self.assertRaises(slip39.Slip39Error): + slip39.recover_ems(mnemonics) + self.fail( + 'Failed to raise exception for test vector "{}".'.format( + description + ) + ) + + def test_make_group_prefix(self): + self.assertEqual( + slip39._make_group_prefix(5, 0, 4, 3, 2, 1), "academic cover decision" + ) + + if __name__ == "__main__": unittest.main() diff --git a/electrum/electrumabc/tests/test_wallet_vertical.py b/electrum/electrumabc/tests/test_wallet_vertical.py index a516b0a3f7..10022a7e9d 100644 --- a/electrum/electrumabc/tests/test_wallet_vertical.py +++ b/electrum/electrumabc/tests/test_wallet_vertical.py @@ -1,7 +1,7 @@ import unittest from unittest import mock -from .. import bitcoin, keystore, mnemo, storage, wallet +from .. import bitcoin, keystore, mnemo, slip39, storage, wallet from ..address import Address, PublicKey from ..bitcoin import ScriptType from ..storage import StorageKeys @@ -365,6 +365,92 @@ def test_bip39_multisig_seed_bip45_standard(self, mock_write): self.assertIsNotNone(txin.signatures[0]) self.assertIsNone(txin.signatures[1]) + @mock.patch.object(storage.WalletStorage, "_write") + def test_slip39_non_extendable_basic_3of6_bip44_standard(self, mock_write): + """ + BIP32 Root Key for passphrase "TREZOR": + xprv9s21ZrQH143K2pMWi8jrTawHaj16uKk4CSbvo4Zt61tcrmuUDMx2o1Byzcr3saXNGNvHP8zZgXVdJHsXVdzYFPavxvCyaGyGr1WkAYG83ce + """ + mnemonics = [ + "extra extend academic bishop cricket bundle tofu goat apart victim enlarge program behavior permit course armed jerky faint language modern", + "extra extend academic acne away best indicate impact square oasis prospect painting voting guest either argue username racism enemy eclipse", + "extra extend academic arcade born dive legal hush gross briefing talent drug much home firefly toxic analysis idea umbrella slice", + ] + passphrase = "TREZOR" + derivation = "m/44'/0'/0'" + encrypted_seed = slip39.recover_ems(mnemonics) + root_seed = encrypted_seed.decrypt(passphrase) + + ks0 = keystore.from_seed( + encrypted_seed, passphrase, seed_type="slip39", derivation=derivation + ) + self.assertEqual(ks0.seed_type, "slip39") + ks1 = keystore.from_bip32_seed_and_derivation(root_seed, derivation) + self.assertEqual(ks1.seed_type, None) + + for ks in ks0, ks1: + self.assertIsInstance(ks, keystore.BIP32KeyStore) + self.assertEqual( + ks.xprv, + "xprv9yELEwkzJkSUHXz4hX6iv1SkhKeEhNtgoRDqm8whrymd3f3W2Abdpx6MjRmdEAERNeGauGx1u5djsExCT8qE6e4fGNeetfWtp45rSJu7kNW", + ) + self.assertEqual( + ks.xpub, + "xpub6CDgeTHt97zmW24XoYdjH9PVFMUj6qcYAe9SZXMKRKJbvTNeZhutNkQqajLyZrQ9DCqdnGenKhBD6UTrT1nHnoLCfFHkdeX8hDsZx1je6b2", + ) + self.assertEqual(ks.derivation, "m/44'/0'/0'") + + w = self._create_standard_wallet(ks1) + self.assertEqual( + w.get_receiving_addresses()[0], + Address.from_string("1NomKAUNnbASwbPuGHmkSVmnrJS5tZeVce"), + ) + self.assertEqual( + w.get_change_addresses()[0], + Address.from_string("1Aw4wpXsAyEHSgMZqPdyewoAtJqH9Jaso3"), + ) + + @mock.patch.object(storage.WalletStorage, "_write") + def test_slip39_extendable_basic_3of6_bip44_standard(self, mock_write): + """ + BIP32 Root Key for passphrase "TREZOR": + xprv9yba7duYBT5g7SbaN1oCX43xeDtjKXNUZ2uSmJ3efHsWYaLkqzdjg2bjLYYzQ9rmXdNzDHYWXv5m9aBCqbFbZzAoGcAceH1K8cPYVDpsJLH + """ + mnemonics = [ + "judicial dramatic academic agree craft physics memory born prize academic black listen elder station premium dance sympathy flip always kitchen", + "judicial dramatic academic arcade clogs timber taught recover burning judicial desktop square ecology budget nervous overall tidy knife fused knit", + "judicial dramatic academic axle destroy justice username elegant filter seafood device ranked behavior pecan infant lunar answer identify hour enjoy", + ] + + encrypted_seed = slip39.recover_ems(mnemonics) + root_seed = encrypted_seed.decrypt("TREZOR") + self.assertEqual("255415e2b20ad13cef7adca1e336eaec", root_seed.hex()) + ks = keystore.from_bip32_seed_and_derivation( + root_seed, derivation="m/44'/0'/0'" + ) + + self.assertIsInstance(ks, keystore.BIP32KeyStore) + self.assertEqual(ks.derivation, "m/44'/0'/0'") + + self.assertEqual( + ks.xprv, + "xprv9yba7duYBT5g7SbaN1oCX43xeDtjKXNUZ2uSmJ3efHsWYaLkqzdjg2bjLYYzQ9rmXdNzDHYWXv5m9aBCqbFbZzAoGcAceH1K8cPYVDpsJLH", + ) + self.assertEqual( + ks.xpub, + "xpub6CavX9SS1pdyKvg3U3LCtBzhCFjDiz6KvFq3ZgTGDdQVRNfuPXwzDpvDBqbg1kEsDgEeHo6uWeYsZWALRejoJMVCq4rprrHkbw8Jyu3uaMb", + ) + + w = self._create_standard_wallet(ks) + self.assertEqual( + w.get_receiving_addresses()[0], + Address.from_string("1N4hqJRTVqUbwT5WCbbsQSwKRPPPzG1TSo"), + ) + self.assertEqual( + w.get_change_addresses()[0], + Address.from_string("1FW3QQzbYRSUoNDDYGWPvSCoom8fBhPC9k"), + ) + if __name__ == "__main__": unittest.main() diff --git a/electrum/electrumabc/wordlist/slip39.txt b/electrum/electrumabc/wordlist/slip39.txt new file mode 100644 index 0000000000..5673e7ca7f --- /dev/null +++ b/electrum/electrumabc/wordlist/slip39.txt @@ -0,0 +1,1024 @@ +academic +acid +acne +acquire +acrobat +activity +actress +adapt +adequate +adjust +admit +adorn +adult +advance +advocate +afraid +again +agency +agree +aide +aircraft +airline +airport +ajar +alarm +album +alcohol +alien +alive +alpha +already +alto +aluminum +always +amazing +ambition +amount +amuse +analysis +anatomy +ancestor +ancient +angel +angry +animal +answer +antenna +anxiety +apart +aquatic +arcade +arena +argue +armed +artist +artwork +aspect +auction +august +aunt +average +aviation +avoid +award +away +axis +axle +beam +beard +beaver +become +bedroom +behavior +being +believe +belong +benefit +best +beyond +bike +biology +birthday +bishop +black +blanket +blessing +blimp +blind +blue +body +bolt +boring +born +both +boundary +bracelet +branch +brave +breathe +briefing +broken +brother +browser +bucket +budget +building +bulb +bulge +bumpy +bundle +burden +burning +busy +buyer +cage +calcium +camera +campus +canyon +capacity +capital +capture +carbon +cards +careful +cargo +carpet +carve +category +cause +ceiling +center +ceramic +champion +change +charity +check +chemical +chest +chew +chubby +cinema +civil +class +clay +cleanup +client +climate +clinic +clock +clogs +closet +clothes +club +cluster +coal +coastal +coding +column +company +corner +costume +counter +course +cover +cowboy +cradle +craft +crazy +credit +cricket +criminal +crisis +critical +crowd +crucial +crunch +crush +crystal +cubic +cultural +curious +curly +custody +cylinder +daisy +damage +dance +darkness +database +daughter +deadline +deal +debris +debut +decent +decision +declare +decorate +decrease +deliver +demand +density +deny +depart +depend +depict +deploy +describe +desert +desire +desktop +destroy +detailed +detect +device +devote +diagnose +dictate +diet +dilemma +diminish +dining +diploma +disaster +discuss +disease +dish +dismiss +display +distance +dive +divorce +document +domain +domestic +dominant +dough +downtown +dragon +dramatic +dream +dress +drift +drink +drove +drug +dryer +duckling +duke +duration +dwarf +dynamic +early +earth +easel +easy +echo +eclipse +ecology +edge +editor +educate +either +elbow +elder +election +elegant +element +elephant +elevator +elite +else +email +emerald +emission +emperor +emphasis +employer +empty +ending +endless +endorse +enemy +energy +enforce +engage +enjoy +enlarge +entrance +envelope +envy +epidemic +episode +equation +equip +eraser +erode +escape +estate +estimate +evaluate +evening +evidence +evil +evoke +exact +example +exceed +exchange +exclude +excuse +execute +exercise +exhaust +exotic +expand +expect +explain +express +extend +extra +eyebrow +facility +fact +failure +faint +fake +false +family +famous +fancy +fangs +fantasy +fatal +fatigue +favorite +fawn +fiber +fiction +filter +finance +findings +finger +firefly +firm +fiscal +fishing +fitness +flame +flash +flavor +flea +flexible +flip +float +floral +fluff +focus +forbid +force +forecast +forget +formal +fortune +forward +founder +fraction +fragment +frequent +freshman +friar +fridge +friendly +frost +froth +frozen +fumes +funding +furl +fused +galaxy +game +garbage +garden +garlic +gasoline +gather +general +genius +genre +genuine +geology +gesture +glad +glance +glasses +glen +glimpse +goat +golden +graduate +grant +grasp +gravity +gray +greatest +grief +grill +grin +grocery +gross +group +grownup +grumpy +guard +guest +guilt +guitar +gums +hairy +hamster +hand +hanger +harvest +have +havoc +hawk +hazard +headset +health +hearing +heat +helpful +herald +herd +hesitate +hobo +holiday +holy +home +hormone +hospital +hour +huge +human +humidity +hunting +husband +hush +husky +hybrid +idea +identify +idle +image +impact +imply +improve +impulse +include +income +increase +index +indicate +industry +infant +inform +inherit +injury +inmate +insect +inside +install +intend +intimate +invasion +involve +iris +island +isolate +item +ivory +jacket +jerky +jewelry +join +judicial +juice +jump +junction +junior +junk +jury +justice +kernel +keyboard +kidney +kind +kitchen +knife +knit +laden +ladle +ladybug +lair +lamp +language +large +laser +laundry +lawsuit +leader +leaf +learn +leaves +lecture +legal +legend +legs +lend +length +level +liberty +library +license +lift +likely +lilac +lily +lips +liquid +listen +literary +living +lizard +loan +lobe +location +losing +loud +loyalty +luck +lunar +lunch +lungs +luxury +lying +lyrics +machine +magazine +maiden +mailman +main +makeup +making +mama +manager +mandate +mansion +manual +marathon +march +market +marvel +mason +material +math +maximum +mayor +meaning +medal +medical +member +memory +mental +merchant +merit +method +metric +midst +mild +military +mineral +minister +miracle +mixed +mixture +mobile +modern +modify +moisture +moment +morning +mortgage +mother +mountain +mouse +move +much +mule +multiple +muscle +museum +music +mustang +nail +national +necklace +negative +nervous +network +news +nuclear +numb +numerous +nylon +oasis +obesity +object +observe +obtain +ocean +often +olympic +omit +oral +orange +orbit +order +ordinary +organize +ounce +oven +overall +owner +paces +pacific +package +paid +painting +pajamas +pancake +pants +papa +paper +parcel +parking +party +patent +patrol +payment +payroll +peaceful +peanut +peasant +pecan +penalty +pencil +percent +perfect +permit +petition +phantom +pharmacy +photo +phrase +physics +pickup +picture +piece +pile +pink +pipeline +pistol +pitch +plains +plan +plastic +platform +playoff +pleasure +plot +plunge +practice +prayer +preach +predator +pregnant +premium +prepare +presence +prevent +priest +primary +priority +prisoner +privacy +prize +problem +process +profile +program +promise +prospect +provide +prune +public +pulse +pumps +punish +puny +pupal +purchase +purple +python +quantity +quarter +quick +quiet +race +racism +radar +railroad +rainbow +raisin +random +ranked +rapids +raspy +reaction +realize +rebound +rebuild +recall +receiver +recover +regret +regular +reject +relate +remember +remind +remove +render +repair +repeat +replace +require +rescue +research +resident +response +result +retailer +retreat +reunion +revenue +review +reward +rhyme +rhythm +rich +rival +river +robin +rocky +romantic +romp +roster +round +royal +ruin +ruler +rumor +sack +safari +salary +salon +salt +satisfy +satoshi +saver +says +scandal +scared +scatter +scene +scholar +science +scout +scramble +screw +script +scroll +seafood +season +secret +security +segment +senior +shadow +shaft +shame +shaped +sharp +shelter +sheriff +short +should +shrimp +sidewalk +silent +silver +similar +simple +single +sister +skin +skunk +slap +slavery +sled +slice +slim +slow +slush +smart +smear +smell +smirk +smith +smoking +smug +snake +snapshot +sniff +society +software +soldier +solution +soul +source +space +spark +speak +species +spelling +spend +spew +spider +spill +spine +spirit +spit +spray +sprinkle +square +squeeze +stadium +staff +standard +starting +station +stay +steady +step +stick +stilt +story +strategy +strike +style +subject +submit +sugar +suitable +sunlight +superior +surface +surprise +survive +sweater +swimming +swing +switch +symbolic +sympathy +syndrome +system +tackle +tactics +tadpole +talent +task +taste +taught +taxi +teacher +teammate +teaspoon +temple +tenant +tendency +tension +terminal +testify +texture +thank +that +theater +theory +therapy +thorn +threaten +thumb +thunder +ticket +tidy +timber +timely +ting +tofu +together +tolerate +total +toxic +tracks +traffic +training +transfer +trash +traveler +treat +trend +trial +tricycle +trip +triumph +trouble +true +trust +twice +twin +type +typical +ugly +ultimate +umbrella +uncover +undergo +unfair +unfold +unhappy +union +universe +unkind +unknown +unusual +unwrap +upgrade +upstairs +username +usher +usual +valid +valuable +vampire +vanish +various +vegan +velvet +venture +verdict +verify +very +veteran +vexed +victim +video +view +vintage +violence +viral +visitor +visual +vitamins +vocal +voice +volume +voter +voting +walnut +warmth +warn +watch +wavy +wealthy +weapon +webcam +welcome +welfare +western +width +wildlife +window +wine +wireless +wisdom +withdraw +wits +wolf +woman +work +worthy +wrap +wrist +writing +wrote +year +yelp +yield +yoga +zero diff --git a/electrum/electrumabc_gui/qt/installwizard.py b/electrum/electrumabc_gui/qt/installwizard.py index b35bd0a40a..54c9c4d46b 100644 --- a/electrum/electrumabc_gui/qt/installwizard.py +++ b/electrum/electrumabc_gui/qt/installwizard.py @@ -475,7 +475,7 @@ def seed_input(self, title, message, is_seed, options): title=message, is_seed=is_seed, options=options, parent=self, editable=True ) 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 def bip38_prompt_for_pw(self, bip38_keys): """Reimplemented from basewizard superclass. Expected to return the pw @@ -510,6 +510,8 @@ def restore_seed_dialog(self, run_next, test): 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) @@ -528,7 +530,7 @@ def confirm_seed_dialog(self, run_next, test): ), ] ) - 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/electrumabc_gui/qt/seed_dialog.py b/electrum/electrumabc_gui/qt/seed_dialog.py index 38bc34182d..b8f8782c60 100644 --- a/electrum/electrumabc_gui/qt/seed_dialog.py +++ b/electrum/electrumabc_gui/qt/seed_dialog.py @@ -27,7 +27,7 @@ from PyQt5.QtCore import Qt from PyQt5.QtGui import QIcon -from electrumabc import mnemo, old_mnemonic +from electrumabc import mnemo, old_mnemonic, slip39 from electrumabc.constants import PROJECT_NAME from electrumabc.i18n import _ @@ -35,6 +35,7 @@ from .qrtextedit import ScanQRTextEdit from .util import ( Buttons, + ChoicesLayout, CloseButton, ColorScheme, EnterButton, @@ -81,34 +82,61 @@ def seed_warning_msg(seed, has_der=False, has_ext=False): class SeedLayout(QtWidgets.QVBoxLayout): # options - is_bip39 = False is_ext = False def seed_options(self): dialog = QtWidgets.QDialog() + dialog.setWindowTitle(_("Seed Options")) vbox = QtWidgets.QVBoxLayout(dialog) + + seed_types = [ + (value, title) + for value, title in ( + ("bip39", _("BIP39 seed")), + ("slip39", _("SLIP39 seed")), + ("electrum", _("Legacy Electrum")), + ) + 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 = QtWidgets.QCheckBox( _("Extend this seed with custom words") + " " + _("(aka 'passphrase')") ) cb_ext.setChecked(self.is_ext) vbox.addWidget(cb_ext) - if "bip39" in self.options: + if len(seed_types) >= 2: - def f(b): - self.is_seed = (lambda x: bool(x)) if b else self.saved_is_seed - self.is_bip39 = b + 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() + self.update_share_buttons() + self.initialize_completer() + + 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()) - cb_bip39 = QtWidgets.QCheckBox(_("Force BIP39 interpretation of this seed")) - cb_bip39.toggled.connect(f) - cb_bip39.setChecked(self.is_bip39) - vbox.addWidget(cb_bip39) 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 "bip39" + ) def __init__( self, @@ -127,6 +155,7 @@ def __init__( QtWidgets.QVBoxLayout.__init__(self) self.parent = parent self.options = options or () + self.seed_type = "bip39" if title: self.addWidget(WWLabel(title)) self.seed_e = CompletionTextEdit() @@ -191,7 +220,26 @@ def __init__( grid_row += 1 if grid_row > 0: # only if above actually added widgets self.addLayout(grid_maybe) + + # slip39 shares + self.slip39_mnemonic_index = 0 + self.slip39_mnemonics = [""] + self.slip39_seed = None + self.slip39_current_mnemonic_invalid = None + hbox = QtWidgets.QHBoxLayout() + hbox.addStretch(1) + self.prev_share_btn = QtWidgets.QPushButton(_("Previous share")) + self.prev_share_btn.clicked.connect(self.on_prev_share) + hbox.addWidget(self.prev_share_btn) + self.next_share_btn = QtWidgets.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("") self.has_warning_message = bool(msg) if self.has_warning_message: @@ -201,34 +249,45 @@ def __init__( self.addWidget(self.seed_warning) def initialize_completer(self): - # Note that the wordlist for Electrum seeds is identical to the BIP39 wordlist - bip39_list = mnemonic.Mnemonic("english").wordlist - old_list = old_mnemonic.wordlist - only_old_list = set(old_list) - set(bip39_list) - self.wordlist = bip39_list + list(only_old_list) - self.wordlist.sort() - - class CompleterDelegate(QtWidgets.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": + # Note that the wordlist for Electrum seeds is identical to the BIP39 wordlist + bip39_list = mnemonic.Mnemonic("english").wordlist + old_list = old_mnemonic.wordlist + only_old_list = set(old_list) - set(bip39_list) + self.wordlist = bip39_list + list(only_old_list) + self.wordlist.sort() + + class CompleterDelegate(QtWidgets.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 = QtWidgets.QCompleter(self.wordlist) - delegate = CompleterDelegate(self.seed_e) - self.completer.popup().setItemDelegate(delegate) + if delegate is not None: + 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 _mnem = None @@ -238,9 +297,39 @@ def on_edit(self): # cache the lang wordlist so it doesn't need to get loaded each time. # This speeds up seed_type_name and Mnemonic.check self._mnem = mnemonic.Mnemonic("english") - words = self.get_seed() + words = " ".join(self.get_seed_words()) b = self.is_seed(words) - if not self.is_bip39: + if self.seed_type == "bip39": + is_valid = self._mnem.check(words) + status = "valid" if is_valid else "invalid" + label = f"BIP39 ({status})" + elif self.seed_type == "slip39": + self.slip39_mnemonics[self.slip39_mnemonic_index] = words + try: + slip39.decode_mnemonic(words) + except slip39.Slip39Error as e: + share_status = str(e) + current_mnemonic_invalid = True + else: + share_status = _("Valid.") + current_mnemonic_invalid = False + + label = ( + _("SLIP39 share") + + f" #{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 = mnemo.format_seed_type_name_for_ui(mnemo.seed_type_name(words)) label = _("Seed Type") + ": " + t if t else "" if t and may_clear_warning and "bip39" in self.options: @@ -259,10 +348,6 @@ def on_edit(self): " of this seed." ) ) - else: - is_valid = self._mnem.check(words) - status = "valid" if is_valid else "invalid" - label = f"BIP39 ({status})" self.seed_type_label.setText(label) self.parent.next_button.setEnabled(b) if may_clear_warning: @@ -270,12 +355,53 @@ def on_edit(self): # Stop autocompletion if a previous word is not in the known list. # The seed phrase must be a different language than english. - 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( + # already pressed "prev" and undoing that: + self.slip39_mnemonic_index < len(self.slip39_mnemonics) - 1 + # finished entering latest share and starting new one: + or ( + bool(self.seed_e.text().strip()) + and not self.slip39_current_mnemonic_invalid + 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(QtWidgets.QVBoxLayout): def __init__(self, parent=None, title=None, is_valid=None, allow_multi=False):