tplugin for TrustedCoin - electrum - Electrum Bitcoin wallet HTML git clone https://git.parazyd.org/electrum DIR Log DIR Files DIR Refs DIR Submodules --- DIR commit 95586643a4389d900e791aadcee7292a51373945 DIR parent 2b42f054a954aa7c10cb3f8a4c0cc399bc523104 HTML Author: ThomasV <thomasv@gitorious> Date: Tue, 2 Sep 2014 07:47:10 +0200 plugin for TrustedCoin Diffstat: A plugins/trustedcoin.py | 711 +++++++++++++++++++++++++++++++ 1 file changed, 711 insertions(+), 0 deletions(-) --- DIR diff --git a/plugins/trustedcoin.py b/plugins/trustedcoin.py t@@ -0,0 +1,711 @@ +#!/usr/bin/env python +# +# Electrum - Lightweight Bitcoin Client +# Copyright (C) 2015 Thomas Voegtlin +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see <http://www.gnu.org/licenses/>. + +import threading +import socket +import os +import re +import requests +import json +from hashlib import sha256 +from urlparse import urljoin +from urllib import quote + +from PyQt4.QtGui import * +from PyQt4.QtCore import * + +import electrum +from electrum import bitcoin +from electrum.bitcoin import * +from electrum.mnemonic import Mnemonic +from electrum import version +from electrum.wallet import Wallet_2of3 +from electrum.i18n import _ +from electrum.plugins import BasePlugin, run_hook, hook + +from electrum_gui.qt.util import text_dialog, EnterButton, WaitingDialog +from electrum_gui.qt.qrcodewidget import QRCodeWidget +from electrum_gui.qt import ok_cancel_buttons, ok_cancel_buttons2, close_button +from electrum_gui.qt.amountedit import AmountEdit +from electrum_gui.qt.main_window import StatusBarButton + +from decimal import Decimal + +# signing_xpub is hardcoded so that the wallet can be restored from seed, without TrustedCoin's server +signing_xpub = "xpub661MyMwAqRbcGnMkaTx2594P9EDuiEqMq25PM2aeG6UmwzaohgA6uDmNsvSUV8ubqwA3Wpste1hg69XHgjUuCD5HLcEp2QPzyV1HMrPppsL" +billing_xpub = "xpub6DTBdtBB8qUmH5c77v8qVGVoYk7WjJNpGvutqjLasNG1mbux6KsojaLrYf2sRhXAVU4NaFuHhbD9SvVPRt1MB1MaMooRuhHcAZH1yhQ1qDU" + +SEED_PREFIX = version.SEED_PREFIX_2FA + + +class TrustedCoinException(Exception): + def __init__(self, message, status_code=0): + Exception.__init__(self, message) + self.status_code = status_code + +class TrustedCoinCosignerClient(object): + def __init__(self, user_agent=None, base_url='https://api.trustedcoin.com/2/', debug=False): + self.base_url = base_url + self.debug = debug + self.user_agent = user_agent + + def send_request(self, method, relative_url, data=None): + kwargs = {'headers': {}} + if self.user_agent: + kwargs['headers']['user-agent'] = self.user_agent + if method == 'get' and data: + kwargs['params'] = data + elif method == 'post' and data: + kwargs['data'] = json.dumps(data) + kwargs['headers']['content-type'] = 'application/json' + url = urljoin(self.base_url, relative_url) + if self.debug: + print '%s %s %s' % (method, url, data) + response = requests.request(method, url, **kwargs) + if self.debug: + print response.text + print + if response.status_code != 200: + message = str(response.text) + if response.headers.get('content-type') == 'application/json': + r = response.json() + if 'message' in r: + message = r['message'] + raise TrustedCoinException(message, response.status_code) + if response.headers.get('content-type') == 'application/json': + return response.json() + else: + return response.text + + def get_terms_of_service(self, billing_plan='electrum-per-tx-otp'): + """ + Returns the TOS for the given billing plan as a plain/text unicode string. + :param billing_plan: the plan to return the terms for + """ + payload = {'billing_plan': billing_plan} + return self.send_request('get', 'tos', payload) + + def create(self, xpubkey1, xpubkey2, email, billing_plan='electrum-per-tx-otp'): + """ + Creates a new cosigner resource. + :param xpubkey1: a bip32 extended public key (customarily the hot key) + :param xpubkey2: a bip32 extended public key (customarily the cold key) + :param email: a contact email + :param billing_plan: the billing plan for the cosigner + """ + payload = { + 'email': email, + 'xpubkey1': xpubkey1, + 'xpubkey2': xpubkey2, + 'billing_plan': billing_plan, + } + return self.send_request('post', 'cosigner', payload) + + def auth(self, id, otp): + """ + Attempt to authenticate for a particular cosigner. + :param id: the id of the cosigner + :param otp: the one time password + """ + payload = {'otp': otp} + return self.send_request('post', 'cosigner/%s/auth' % quote(id), payload) + + def get(self, id): + """ + Attempt to authenticate for a particular cosigner. + :param id: the id of the cosigner + :param otp: the one time password + """ + return self.send_request('get', 'cosigner/%s' % quote(id)) + + def sign(self, id, transaction, otp): + """ + Attempt to authenticate for a particular cosigner. + :param id: the id of the cosigner + :param transaction: the hex encoded [partially signed] compact transaction to sign + :param otp: the one time password + """ + payload = { + 'otp': otp, + 'transaction': transaction + } + return self.send_request('post', 'cosigner/%s/sign' % quote(id), payload) + + def transfer_credit(self, id, recipient, otp, signature_callback): + """ + Tranfer a cosigner's credits to another cosigner. + :param id: the id of the sending cosigner + :param recipient: the id of the recipient cosigner + :param otp: the one time password (of the sender) + :param signature_callback: a callback that signs a text message using xpubkey1/0/0 returning a compact sig + """ + payload = { + 'otp': otp, + 'recipient': recipient, + 'timestamp': int(time.time()), + + } + relative_url = 'cosigner/%s/transfer' % quote(id) + full_url = urljoin(self.base_url, relative_url) + headers = { + 'x-signature': signature_callback(full_url + '\n' + json.dumps(payload)) + } + return self.send_request('post', relative_url, payload, headers) + + +server = TrustedCoinCosignerClient(user_agent="Electrum/" + version.ELECTRUM_VERSION) + + +class Wallet_2fa(Wallet_2of3): + + wallet_type = '2fa' + + def get_action(self): + xpub1 = self.master_public_keys.get("x1/") + xpub2 = self.master_public_keys.get("x2/") + xpub3 = self.master_public_keys.get("x3/") + if xpub2 is None and not self.storage.get('use_trustedcoin'): + return 'show_disclaimer' + if xpub2 is None: + return 'create_extended_seed' + if xpub3 is None: + return 'create_remote_key' + if not self.accounts: + return 'create_accounts' + + def make_seed(self): + return Mnemonic('english').make_seed(num_bits=256, prefix=SEED_PREFIX) + + def estimated_fee(self, tx): + fee = Wallet_2of3.estimated_fee(self, tx) + x = run_hook('extra_fee', tx) + if x: fee += x + return fee + + def get_tx_fee(self, tx): + fee = Wallet_2of3.get_tx_fee(self, tx) + x = run_hook('extra_fee', tx) + if x: fee += x + return fee + + + +class Plugin(BasePlugin): + + wallet = None + + def __init__(self, x, y): + BasePlugin.__init__(self, x, y) + electrum.wallet.wallet_types.append(('twofactor', '2fa', _("Wallet with two-factor authentication"), Wallet_2fa)) + self.seed_func = lambda x: bitcoin.is_new_seed(x, SEED_PREFIX) + self.billing_info = None + + def fullname(self): + return 'Two Factor Authentication' + + def description(self): + return _("This plugin adds two-factor authentication to your wallet.") + '<br/>'\ + + _("For more information, visit") + " <a href=\"https://api.trustedcoin.com/#/electrum-help\">https://api.trustedcoin.com/#/electrum-help</a>" + + def is_available(self): + if self.wallet is None: + return True + if self.wallet.storage.get('wallet_type') == '2fa': + return True + return False + + def requires_settings(self): + return True + + def set_enabled(self, enabled): + self.wallet.storage.put('use_' + self.name, enabled) + + def is_enabled(self): + if not self.is_available(): + return False + if not self.wallet: + return True + if self.wallet.storage.get('wallet_type') != '2fa': + return False + if self.wallet.master_private_keys.get('x2/'): + return False + return True + + def make_long_id(self, xpub_hot, xpub_cold): + return bitcoin.sha256(''.join(sorted([xpub_hot, xpub_cold]))) + + def get_user_id(self): + xpub_hot = self.wallet.master_public_keys["x1/"] + xpub_cold = self.wallet.master_public_keys["x2/"] + long_id = self.make_long_id(xpub_hot, xpub_cold) + short_id = hashlib.sha256(long_id).hexdigest() + return long_id, short_id + + def make_xpub(self, xpub, s): + _, _, _, c, cK = deserialize_xkey(xpub) + cK2, c2 = bitcoin._CKD_pub(cK, c, s) + xpub2 = ("0488B21E" + "00" + "00000000" + "00000000").decode("hex") + c2 + cK2 + return EncodeBase58Check(xpub2) + + def make_billing_address(self, num): + long_id, short_id = self.get_user_id() + xpub = self.make_xpub(billing_xpub, long_id) + _, _, _, c, cK = deserialize_xkey(xpub) + cK, c = bitcoin.CKD_pub(cK, c, num) + address = public_key_to_bc_address( cK ) + return address + + def enable(self): + if self.is_enabled(): + self.window.show_message('Error: Two-factor authentication is already activated on this wallet') + return + self.set_enabled(True) + self.window.show_message('Two-factor authentication is enabled.') + + def create_extended_seed(self, wallet, window): + seed = wallet.make_seed() + if not window.show_seed(seed, None): + return + + if not window.verify_seed(seed, None, self.seed_func): + return + + password = window.password_dialog() + wallet.storage.put('seed_version', wallet.seed_version, True) + wallet.storage.put('use_encryption', password is not None, True) + + words = seed.split() + n = len(words)/2 + wallet.add_cosigner_seed(' '.join(words[0:n]), 'x1/', password) + wallet.add_cosigner_xpub(' '.join(words[n:]), 'x2/') + + msg = [ + _('Your wallet file is:') + " %s"%os.path.abspath(wallet.storage.path), + _('You need to be online in order to complete the creation of your wallet.'), + _('If you generated your seed on an offline computer, click on "%s" to close this window, move your wallet file to an online computer and reopen it with Electrum.') % _('Close'), + _('If you are online, click on "%s" to continue.') % _('Next') + ] + return window.question('\n\n'.join(msg), no_label=_('Close'), yes_label=_('Next')) + + + def show_disclaimer(self, wallet, window): + msg = [ + _("Two-factor authentication is a service provided by TrustedCoin.") + ' ', + _("It uses a multi-signature wallet, where you own 2 of 3 keys.") + ' ', + _("The third key is stored on a remote server that signs transactions on your behalf.") + ' ', + _("To use this service, you will need a smartphone with Google Authenticator.") + '\n\n', + + _("A small fee will be charged on each transaction that uses the remote server.") + ' ', + _("You may check and modify your billing preferences once the installation is complete.") + '\n\n', + + _("Note that your coins are not locked in this service.") + ' ', + _("You may withdraw your funds at any time and at no cost, without the remote server, by using the 'restore wallet' option with your wallet seed.") + '\n\n', + + _('The next step will generate the seed of your wallet.') + ' ', + _('This seed will NOT be saved in your computer, and it must be stored on paper.') + ' ', + _('To be safe from malware, you may want to do this on an offline computer, and move your wallet later to an online computer.') + ] + icon = QPixmap(':icons/trustedcoin.png') + if not window.question(''.join(msg), icon=icon): + return False + self.wallet = wallet + self.set_enabled(True) + return True + + + def restore_third_key(self, wallet): + long_user_id, short_id = self.get_user_id() + xpub3 = self.make_xpub(signing_xpub, long_user_id) + wallet.add_master_public_key('x3/', xpub3) + + @hook + def init_qt(self, gui): + self.window = gui.main_window + self.is_billing = False + + @hook + def do_clear(self): + self.is_billing = False + + @hook + def load_wallet(self, wallet): + self.wallet = wallet + if self.is_enabled(): + self.trustedcoin_button = StatusBarButton( QIcon(":icons/trustedcoin.png"), _("Network"), self.settings_dialog) + self.window.statusBar().addPermanentWidget(self.trustedcoin_button) + self.xpub = self.wallet.master_public_keys.get('x1/') + self.user_id = self.get_user_id()[1] + t = threading.Thread(target=self.request_billing_info) + t.setDaemon(True) + t.start() + + @hook + def close_wallet(self): + self.window.statusBar().removeWidget(self.trustedcoin_button) + + @hook + def get_wizard_action(self, window, wallet, action): + if hasattr(self, action): + return getattr(self, action) + + @hook + def installwizard_restore(self, window, storage): + if storage.get('wallet_type') != '2fa': + return + + seed = window.enter_seed_dialog("Enter your seed", None, func=self.seed_func) + if not seed: + return + wallet = Wallet_2fa(storage) + self.wallet = wallet + password = window.password_dialog() + + wallet.add_seed(seed, password) + words = seed.split() + n = len(words)/2 + wallet.add_cosigner_seed(' '.join(words[0:n]), 'x1/', password) + wallet.add_cosigner_seed(' '.join(words[n:]), 'x2/', password) + + self.restore_third_key(wallet) + wallet.create_main_account(password) + # disable plugin + self.set_enabled(False) + return wallet + + + def create_remote_key(self, wallet, window): + self.wallet = wallet + self.window = window + + if wallet.storage.get('wallet_type') != '2fa': + raise + return + + email = self.accept_terms_of_use(window) + if not email: + return + + xpub_hot = wallet.master_public_keys["x1/"] + xpub_cold = wallet.master_public_keys["x2/"] + + # Generate third key deterministically. + long_user_id, self.user_id = self.get_user_id() + xpub3 = self.make_xpub(signing_xpub, long_user_id) + + # secret must be sent by the server + try: + r = server.create(xpub_hot, xpub_cold, email) + except socket.error: + self.window.show_message('Server not reachable, aborting') + return + + otp_secret = r.get('otp_secret') + if not otp_secret: + self.window.show_message(_('Error')) + return + + _xpub3 = r['xpubkey_cosigner'] + _id = r['id'] + try: + assert _id == self.user_id, ("user id error", _id, self.user_id) + assert xpub3 == _xpub3, ("xpub3 error", xpub3, _xpub3) + except Exception as e: + self.window.show_message(str(e)) + return + + if not self.setup_google_auth(self.window, _id, otp_secret): + return + + self.wallet.add_master_public_key('x3/', xpub3) + return True + + + + def need_server(self, tx): + from electrum.account import BIP32_Account + # Detect if the server is needed + long_id, short_id = self.get_user_id() + xpub3 = self.wallet.master_public_keys['x3/'] + for x in tx.inputs_to_sign(): + if x[0:2] == 'ff': + xpub, sequence = BIP32_Account.parse_xpubkey(x) + if xpub == xpub3: + return True + return False + + @hook + def send_tx(self, tx): + print_error("twofactor:send_tx") + if self.wallet.storage.get('wallet_type') != '2fa': + return + + if not self.need_server(tx): + print_error("twofactor: xpub3 not needed") + self.auth_code = None + return + + self.auth_code = self.auth_dialog() + + @hook + def before_send(self): + # request billing info before forming the transaction + self.billing_info = None + self.waiting_dialog = WaitingDialog(self.window, 'please wait...', self.request_billing_info) + self.waiting_dialog.start() + self.waiting_dialog.wait() + if self.billing_info is None: + self.window.show_message('Could not contact server') + return True + return False + + @hook + def extra_fee(self, tx): + if self.billing_info.get('tx_remaining'): + return 0 + if self.is_billing: + return 0 + # trustedcoin won't charge if the total inputs is lower than their fee + price = int(self.price_per_tx.get(1)) + if tx.input_value() < price: + print_error("not charging for this tx") + return 0 + return price + + @hook + def make_unsigned_transaction(self, tx): + price = self.extra_fee(tx) + if not price: + return + tx.outputs.append(('address', self.billing_info['billing_address'], price)) + + @hook + def sign_transaction(self, tx, password): + print_error("twofactor:sign") + if self.wallet.storage.get('wallet_type') != '2fa': + print_error("twofactor: aborting") + return + + self.long_user_id, self.user_id = self.get_user_id() + + if not self.auth_code: + return + + if tx.is_complete(): + return + + tx_dict = tx.as_dict() + raw_tx = tx_dict["hex"] + try: + r = server.sign(self.user_id, raw_tx, self.auth_code) + except Exception as e: + tx.error = str(e) + return + + print_error( "received answer", r) + if not r: + return + + raw_tx = r.get('transaction') + tx.update(raw_tx) + print_error("twofactor: is complete", tx.is_complete()) + + + def auth_dialog(self ): + d = QDialog(self.window) + d.setModal(1) + vbox = QVBoxLayout(d) + pw = AmountEdit(None, is_int = True) + msg = _('Please enter your Google Authenticator code') + vbox.addWidget(QLabel(msg)) + grid = QGridLayout() + grid.setSpacing(8) + grid.addWidget(QLabel(_('Code')), 1, 0) + grid.addWidget(pw, 1, 1) + vbox.addLayout(grid) + vbox.addLayout(ok_cancel_buttons(d)) + if not d.exec_(): + return + return pw.get_amount() + + def settings_widget(self, window): + return EnterButton(_('Settings'), self.settings_dialog) + + def settings_dialog(self): + self.waiting_dialog = WaitingDialog(self.window, 'please wait...', self.request_billing_info, self.show_settings_dialog) + self.waiting_dialog.start() + + def show_settings_dialog(self, success): + if not success: + self.window.show_message(_('Server not reachable.')) + return + + d = QDialog(self.window) + d.setWindowTitle("TrustedCoin Information") + d.setMinimumSize(500, 200) + vbox = QVBoxLayout(d) + hbox = QHBoxLayout() + + logo = QLabel() + logo.setPixmap(QPixmap(":icons/trustedcoin.png")) + msg = _('This wallet is protected by TrustedCoin\'s two-factor authentication.') + '<br/>'\ + + _("For more information, visit") + " <a href=\"https://api.trustedcoin.com/#/electrum-help\">https://api.trustedcoin.com/#/electrum-help</a>" + label = QLabel(msg) + label.setOpenExternalLinks(1) + + hbox.addStretch(10) + hbox.addWidget(logo) + hbox.addStretch(10) + hbox.addWidget(label) + hbox.addStretch(10) + + vbox.addLayout(hbox) + vbox.addStretch(10) + + msg = _('TrustedCoin charges a fee per co-signed transaction. You may pay on each transaction (an extra output will be added to your transaction), or you may purchase prepaid transaction using this dialog.') + '<br/>' + label = QLabel(msg) + label.setWordWrap(1) + vbox.addWidget(label) + + vbox.addStretch(10) + grid = QGridLayout() + vbox.addLayout(grid) + + v = self.price_per_tx.get(1) + grid.addWidget(QLabel(_("Price per transaction (not prepaid):")), 0, 0) + grid.addWidget(QLabel(self.window.format_amount(v) + ' ' + self.window.base_unit()), 0, 1) + + i = 1 + for k, v in sorted(self.price_per_tx.items()): + if k!=1: + grid.addWidget(QLabel("Price for %d prepaid transactions:"%k), i, 0) + grid.addWidget(QLabel(self.window.format_amount(v) + ' ' + self.window.base_unit()), i, 1) + b = QPushButton(_("Buy")) + grid.addWidget(b, i, 2) + def on_buy(): + d.close() + if self.window.pluginsdialog: + self.window.pluginsdialog.close() + uri = "bitcoin:" + self.billing_info['billing_address'] + "?message=TrustedCoin Prepaid Transactions&amount="+str(Decimal(v)/100000000) + self.is_billing = True + self.window.pay_from_URI(uri) + self.window.payto_e.setFrozen(True) + self.window.message_e.setFrozen(True) + self.window.amount_e.setFrozen(True) + b.clicked.connect(on_buy) + i += 1 + + n = self.billing_info.get('tx_remaining', 0) + grid.addWidget(QLabel(_("Your wallet has %d prepaid transactions.")%n), i, 0) + + # tranfer button + #def on_transfer(): + # server.transfer_credit(self.user_id, recipient, otp, signature_callback) + # pass + #b = QPushButton(_("Transfer")) + #b.clicked.connect(on_transfer) + #grid.addWidget(b, 1, 2) + + #grid.addWidget(QLabel(_("Next Billing Address:")), i, 0) + #grid.addWidget(QLabel(self.billing_info['billing_address']), i, 1) + vbox.addLayout(close_button(d)) + d.exec_() + + + def request_billing_info(self): + billing_info = server.get(self.user_id) + billing_address = self.make_billing_address(billing_info['billing_index']) + assert billing_address == billing_info['billing_address'] + self.billing_info = billing_info + self.price_per_tx = dict(self.billing_info['price_per_tx']) + return True + + def accept_terms_of_use(self, window): + vbox = QVBoxLayout() + window.set_layout(vbox) + vbox.addWidget(QLabel(_("Terms of Service"))) + + tos_e = QTextEdit() + tos_e.setReadOnly(True) + vbox.addWidget(tos_e) + + vbox.addWidget(QLabel(_("Please enter your e-mail address"))) + email_e = QLineEdit() + vbox.addWidget(email_e) + vbox.addStretch() + hbox, accept_button = ok_cancel_buttons2(window, _('Accept')) + accept_button.setEnabled(False) + vbox.addLayout(hbox) + + def request_TOS(): + tos = server.get_terms_of_service() + self.TOS = tos + window.emit(SIGNAL('twofactor:TOS')) + + def on_result(): + tos_e.setText(self.TOS) + + window.connect(window, SIGNAL('twofactor:TOS'), on_result) + t = threading.Thread(target=request_TOS) + t.setDaemon(True) + t.start() + + regexp = r"[^@]+@[^@]+\.[^@]+" + email_e.textChanged.connect(lambda: accept_button.setEnabled(re.match(regexp,email_e.text()) is not None)) + email_e.setFocus(True) + + if not window.exec_(): + return + + email = str(email_e.text()) + return email + + + def setup_google_auth(self, window, _id, otp_secret): + uri = "otpauth://totp/%s?secret=%s"%('trustedcoin.com', otp_secret) + vbox = QVBoxLayout() + window.set_layout(vbox) + vbox.addWidget(QLabel("Please scan this QR code in Google Authenticator.")) + qrw = QRCodeWidget(uri) + vbox.addWidget(qrw, 1) + #vbox.addWidget(QLabel(data), 0, Qt.AlignHCenter) + + hbox = QHBoxLayout() + msg = _('Then, enter your Google Authenticator code:') + hbox.addWidget(QLabel(msg)) + pw = AmountEdit(None, is_int = True) + pw.setFocus(True) + hbox.addWidget(pw) + hbox.addStretch(1) + vbox.addLayout(hbox) + + hbox, b = ok_cancel_buttons2(window, _('Next')) + b.setEnabled(False) + vbox.addLayout(hbox) + pw.textChanged.connect(lambda: b.setEnabled(len(pw.text())==6)) + + window.exec_() + otp = pw.get_amount() + try: + server.auth(_id, otp) + except: + self.window.show_message('Incorrect password, aborting') + return + + return True + +