diff --git a/jmclient/jmclient/wallet.py b/jmclient/jmclient/wallet.py index 7f89780..a48d6af 100644 --- a/jmclient/jmclient/wallet.py +++ b/jmclient/jmclient/wallet.py @@ -153,7 +153,7 @@ class Wallet(AbstractWallet): self.unspent = {} self.spent_utxos = [] self.imported_privkeys = {} - self.seed = self.entropy_to_seed( + self.seed = self.wallet_data_to_seed( self.read_wallet_file_data(seedarg, pwd, wallet_dir=wallet_dir)) if not self.seed: raise WalletError("Failed to decrypt wallet") @@ -189,7 +189,7 @@ class Wallet(AbstractWallet): def get_root_path(self): return "m/0" - def entropy_to_seed(self, entropy): + def wallet_data_to_seed(self, entropy): """for base/legacy wallet type, this is a passthrough. for bip39 style wallets, this will convert from one to the other """ @@ -255,19 +255,41 @@ class Wallet(AbstractWallet): self.index_cache += [[0,0]] * ( self.max_mix_depth - len(self.index_cache)) password_key = btc.bin_dbl_sha256(pwd) - encrypted_seed = walletdata['encrypted_seed'] + if 'encrypted_seed' in walletdata: #accept old field name + encrypted_entropy = walletdata['encrypted_seed'] + elif 'encrypted_entropy' in walletdata: + encrypted_entropy = walletdata['encrypted_entropy'] try: - decrypted_seed = decryptData( + decrypted_entropy = decryptData( password_key, - encrypted_seed.decode('hex')).encode('hex') + encrypted_entropy.decode('hex')).encode('hex') # there is a small probability of getting a valid PKCS7 # padding by chance from a wrong password; sanity check the # seed length - if len(decrypted_seed) != 32: + if len(decrypted_entropy) != 32: raise ValueError except ValueError: log.info('Incorrect password') return None + + if 'encrypted_mnemonic_extension' in walletdata: + try: + cleartext = decryptData(password_key, + walletdata['encrypted_mnemonic_extension'].decode('hex')) + #theres a small chance of not getting a ValueError from the wrong + # password so also check the sum + if cleartext[-9] != '\xff': + raise ValueError + chunks = cleartext.split('\xff') + if len(chunks) < 3 or cleartext[-8:] != btc.dbl_sha256(chunks[1])[:8]: + raise ValueError + mnemonic_extension = chunks[1] + except ValueError: + log.info('incorrect password') + return None + else: + mnemonic_extension = None + if self.storepassword: self.password_key = password_key self.walletdata = walletdata @@ -288,7 +310,11 @@ class Wallet(AbstractWallet): privkey, magicbyte=get_p2pk_vbyte())] = (epk_m['mixdepth'], -1, len(self.imported_privkeys[epk_m['mixdepth']])) self.imported_privkeys[epk_m['mixdepth']].append(privkey) - return decrypted_seed + + if mnemonic_extension: + return (decrypted_entropy, mnemonic_extension) + else: + return decrypted_entropy def update_cache_index(self): if not self.path: @@ -398,15 +424,21 @@ class Bip39Wallet(Wallet): BIP39, English only: https://github.com/bitcoin/bips/blob/master/bip-0039.mediawiki """ - def entropy_to_seed(self, entropy): - if entropy is None: + def wallet_data_to_seed(self, data): + if data is None: return None + self.mnemonic_extension = None + if isinstance(data, tuple): + entropy, self.mnemonic_extension = data + else: + entropy = data if get_network() == "testnet": if entropy.startswith("FAKESEED"): return entropy[8:] self.entropy = entropy.decode('hex') m = Mnemonic("english") - return m.to_seed(m.to_mnemonic(self.entropy)).encode('hex') + return m.to_seed(m.to_mnemonic(self.entropy), + '' if not self.mnemonic_extension else self.mnemonic_extension).encode('hex') class SegwitWallet(Bip39Wallet): diff --git a/jmclient/jmclient/wallet_utils.py b/jmclient/jmclient/wallet_utils.py index 3b16e96..9ca47e3 100644 --- a/jmclient/jmclient/wallet_utils.py +++ b/jmclient/jmclient/wallet_utils.py @@ -353,31 +353,49 @@ def wallet_display(wallet, gaplimit, showprivkey, displayall=False, else: return walletview -def cli_password_check(): - password = get_password('Enter wallet encryption passphrase: ') - password2 = get_password('Reenter wallet encryption passphrase: ') +def cli_get_wallet_passphrase_check(): + password = get_password('Enter wallet file encryption passphrase: ') + password2 = get_password('Reenter wallet file encryption passphrase: ') if password != password2: print('ERROR. Passwords did not match') - return False, False - password_key = btc.bin_dbl_sha256(password) - return password, password_key + return False + return password -def cli_get_walletname(): +def cli_get_wallet_file_name(): return raw_input('Input wallet file name (default: wallet.json): ') -def cli_user_words(words): - print('Write down this wallet recovery seed\n\n' + words +'\n') - -def cli_user_words_entry(): - return raw_input("Input 12 word recovery seed: ") - -def persist_walletfile(walletspath, default_wallet_name, encrypted_seed, - callbacks=(cli_get_walletname,)): +def cli_display_user_words(words, mnemonic_extension): + text = 'Write down this wallet recovery mnemonic\n\n' + words +'\n' + if mnemonic_extension: + text += '\nAnd this mnemonic extension: ' + mnemonic_extension + '\n' + print(text) + +def cli_user_mnemonic_entry(): + mnemonic_phrase = raw_input("Input 12 word mnemonic recovery phrase: ") + mnemonic_extension = raw_input("Input mnemonic extension, leave blank if there isnt one: ") + if len(mnemonic_extension.strip()) == 0: + mnemonic_extension = None + return (mnemonic_phrase, mnemonic_extension) + +def cli_get_mnemonic_extension(): + uin = raw_input('Would you like to use a two-factor mnemonic recovery' + + ' phrase? write \'n\' if you don\'t know what this is (y/n): ') + if len(uin) == 0 or uin[0] != 'y': + print('Not using mnemonic extension') + return None #no mnemonic extension + return raw_input('Enter mnemonic extension: ') + +def persist_walletfile(walletspath, default_wallet_name, encrypted_entropy, + encrypted_mnemonic_extension=None, + callbacks=(cli_get_wallet_file_name,)): timestamp = datetime.datetime.now().strftime("%Y/%m/%d %H:%M:%S") - walletfile = json.dumps({'creator': 'joinmarket project', - 'creation_time': timestamp, - 'encrypted_seed': encrypted_seed.encode('hex'), - 'network': get_network()}) + walletjson = {'creator': 'joinmarket project', + 'creation_time': timestamp, + 'encrypted_entropy': encrypted_entropy.encode('hex'), + 'network': get_network()} + if encrypted_mnemonic_extension: + walletjson['encrypted_mnemonic_extension'] = encrypted_mnemonic_extension.encode('hex') + walletfile = json.dumps(walletjson) walletname = callbacks[0]() if len(walletname) == 0: walletname = default_wallet_name @@ -394,31 +412,55 @@ def persist_walletfile(walletspath, default_wallet_name, encrypted_seed, return True def wallet_generate_recover_bip39(method, walletspath, default_wallet_name, - callbacks=(cli_user_words, - cli_user_words_entry, - cli_password_check, - cli_get_walletname)): + callbacks=(cli_display_user_words, + cli_user_mnemonic_entry, + cli_get_wallet_passphrase_check, + cli_get_wallet_file_name, + cli_get_mnemonic_extension)): """Optionally provide callbacks: 0 - display seed 1 - enter seed (for recovery) - 2 - enter password - 3 - enter wallet name + 2 - enter wallet password + 3 - enter wallet file name + 4 - enter mnemonic extension The defaults are for terminal entry. """ #using 128 bit entropy, 12 words, mnemonic module m = Mnemonic("english") if method == "generate": + mnemonic_extension = callbacks[4]() words = m.generate() - callbacks[0](words) + callbacks[0](words, mnemonic_extension) elif method == 'recover': - words = callbacks[1]() + words, mnemonic_extension = callbacks[1]() + if not words: + return False entropy = str(m.to_entropy(words)) - password, password_key = callbacks[2]() + password = callbacks[2]() if not password: return False + password_key = btc.bin_dbl_sha256(password) encrypted_entropy = encryptData(password_key, entropy) + encrypted_mnemonic_extension = None + if mnemonic_extension: + mnemonic_extension = mnemonic_extension.strip() + #check all ascii printable + if not all([a > '\x19' and a < '\x7f' for a in mnemonic_extension]): + return False + #padding to stop an adversary easily telling how long the mn extension is + #padding at the start because of how aes blocks are combined + #checksum in order to tell whether the decryption was successful + cleartext_length = 79 + padding_length = cleartext_length - 10 - len(mnemonic_extension) + if padding_length > 0: + padding = os.urandom(padding_length).replace('\xff', '\xfe') + else: + padding = '' + cleartext = (padding + '\xff' + mnemonic_extension + '\xff' + + btc.dbl_sha256(mnemonic_extension)[:8]) + encrypted_mnemonic_extension = encryptData(password_key, cleartext) return persist_walletfile(walletspath, default_wallet_name, encrypted_entropy, - callbacks=(callbacks[3],)) + encrypted_mnemonic_extension, callbacks=(callbacks[3],)) def wallet_generate_recover(method, walletspath, default_wallet_name='wallet.json'): @@ -439,9 +481,10 @@ def wallet_generate_recover(method, walletspath, return False seed = mn_decode(words) print(seed) - password, password_key = cli_password_check() + password = cli_get_wallet_passphrase_check() if not password: return False + password_key = btc.bin_dbl_sha256(password) encrypted_seed = encryptData(password_key, seed.decode('hex')) return persist_walletfile(walletspath, default_wallet_name, encrypted_seed) @@ -653,15 +696,18 @@ def wallet_showseed(wallet): if not wallet.entropy: return "Entropy is not initialized." m = Mnemonic("english") - return "Wallet recovery seed\n\n" + m.to_mnemonic(wallet.entropy) + "\n" + text = "Wallet mnemonic recovery phrase:\n\n" + m.to_mnemonic(wallet.entropy) + "\n" + if wallet.mnemonic_extension: + text += '\nWallet mnemonic extension: ' + wallet.mnemonic_extension + '\n' + return text hexseed = wallet.seed print("hexseed = " + hexseed) words = mn_encode(hexseed) - return "Wallet recovery seed\n\n" + " ".join(words) + "\n" + return "Wallet mnemonic seed phrase:\n\n" + " ".join(words) + "\n" def wallet_importprivkey(wallet, mixdepth): print('WARNING: This imported key will not be recoverable with your 12 ' + - 'word mnemonic seed. Make sure you have backups.') + 'word mnemonic phrase. Make sure you have backups.') print('WARNING: Handling of raw ECDSA bitcoin private keys can lead to ' 'non-intuitive behaviour and loss of funds.\n Recommended instead ' 'is to use the \'sweep\' feature of sendpayment.py ') diff --git a/scripts/joinmarket-qt.py b/scripts/joinmarket-qt.py index 8be9df4..8879caf 100644 --- a/scripts/joinmarket-qt.py +++ b/scripts/joinmarket-qt.py @@ -1281,11 +1281,22 @@ class JMMainWindow(QMainWindow): def seedEntry(self): d = QDialog(self) d.setModal(1) - d.setWindowTitle('Recover from seed') + d.setWindowTitle('Recover from mnemonic phrase') layout = QGridLayout(d) message_e = QTextEdit() layout.addWidget(QLabel('Enter 12 words'), 0, 0) layout.addWidget(message_e, 1, 0) + + pp_hbox = QHBoxLayout() + pp_field = QLineEdit() + pp_field.setEnabled(False) + use_pp = QCheckBox('Input Mnemonic Extension', self) + use_pp.setCheckState(False) + use_pp.stateChanged.connect(lambda state: pp_field.setEnabled(state + == QtCore.Qt.Checked)) + pp_hbox.addWidget(use_pp) + pp_hbox.addWidget(pp_field) + hbox = QHBoxLayout() buttonBox = QDialogButtonBox(self) buttonBox.setStandardButtons(QDialogButtonBox.Ok | @@ -1293,11 +1304,15 @@ class JMMainWindow(QMainWindow): buttonBox.button(QDialogButtonBox.Ok).clicked.connect(d.accept) buttonBox.button(QDialogButtonBox.Cancel).clicked.connect(d.reject) hbox.addWidget(buttonBox) - layout.addLayout(hbox, 3, 0) + layout.addLayout(hbox, 4, 0) + layout.addLayout(pp_hbox, 3, 0) result = d.exec_() if result != QDialog.Accepted: - return None - return str(message_e.toPlainText()) + return None, None + mn_extension = None + if use_pp.checkState() == QtCore.Qt.Checked: + mn_extension = str(pp_field.text()) + return str(message_e.toPlainText()), mn_extension def restartForScan(self, msg): JMQtMessageBox(self, msg, mbtype='info', @@ -1308,8 +1323,8 @@ class JMMainWindow(QMainWindow): success = wallet_generate_recover_bip39("recover", "wallets", "wallet.json", callbacks=(None, self.seedEntry, - self.getPasswordKey, - self.getWalletName)) + self.getPassword, + self.getWalletFileName)) if not success: JMQtMessageBox(self, "Failed to recover wallet.", @@ -1453,13 +1468,9 @@ class JMMainWindow(QMainWindow): continue break self.textpassword = str(pd.new_pw.text()) + return self.textpassword - def getPasswordKey(self): - self.getPassword() - password_key = btc.bin_dbl_sha256(self.textpassword) - return (self.textpassword, password_key) - - def getWalletName(self): + def getWalletFileName(self): walletname, ok = QInputDialog.getText(self, 'Choose wallet name', 'Enter wallet file name:', QLineEdit.Normal, "wallet.json") @@ -1469,7 +1480,7 @@ class JMMainWindow(QMainWindow): self.walletname = str(walletname) return self.walletname - def displayWords(self, words): + def displayWords(self, words, mnemonic_extension): mb = QMessageBox() seed_recovery_warning = [ "WRITE DOWN THIS WALLET RECOVERY SEED.", @@ -1477,10 +1488,27 @@ class JMMainWindow(QMainWindow): "at risk. Do NOT ignore this step!!!" ] mb.setText("\n".join(seed_recovery_warning)) - mb.setInformativeText(words) + text = words + if mnemonic_extension: + text += '\n\nMnemonic extension: ' + mnemonic_extension + mb.setInformativeText(text) mb.setStandardButtons(QMessageBox.Ok) ret = mb.exec_() + def promptMnemonicExtension(self): + msg = "Would you like to use a two-factor mnemonic recovery phrase?\nIf you don\'t know what this is press No." + reply = QMessageBox.question(self, 'Use mnemonic extension?', + msg, QMessageBox.Yes, QMessageBox.No) + if reply == QMessageBox.No: + return None + mnemonic_extension, ok = QInputDialog.getText(self, + 'Input Mnemonic Extension', + 'Enter mnemonic Extension:', + QLineEdit.Normal, "") + if not ok: + return None + return str(mnemonic_extension) + def initWallet(self, seed=None, restart_cb=None): '''Creates a new wallet if seed not provided. Initializes by syncing. @@ -1491,8 +1519,9 @@ class JMMainWindow(QMainWindow): "wallet.json", callbacks=(self.displayWords, None, - self.getPasswordKey, - self.getWalletName)) + self.getPassword, + self.getWalletFileName, + self.promptMnemonicExtension)) if not success: JMQtMessageBox(self, "Failed to create new wallet file.", title="Error", mbtype="warn")