diff --git a/contrib/build-linux/appimage/Dockerfile b/contrib/build-linux/appimage/Dockerfile index 32c2912d704b..be84531dcabc 100644 --- a/contrib/build-linux/appimage/Dockerfile +++ b/contrib/build-linux/appimage/Dockerfile @@ -24,6 +24,8 @@ RUN apt-get update -q && \ libncurses5-dev=6.1-1ubuntu1.18.04 \ libncurses5=6.1-1ubuntu1.18.04 \ libtinfo-dev=6.1-1ubuntu1.18.04 \ + libpcsclite-dev=1.8.23-1 \ + swig=3.0.12-1 \ libtinfo5=6.1-1ubuntu1.18.04 \ libsqlite3-dev=3.22.0-1ubuntu0.4 \ libusb-1.0-0-dev=2:1.0.21-2 \ diff --git a/contrib/build-wine/deterministic.spec b/contrib/build-wine/deterministic.spec index b7896f95f921..c3b68a3d054f 100644 --- a/contrib/build-wine/deterministic.spec +++ b/contrib/build-wine/deterministic.spec @@ -22,6 +22,7 @@ hiddenimports += collect_submodules('keepkeylib') hiddenimports += collect_submodules('websocket') hiddenimports += collect_submodules('ckcc') hiddenimports += collect_submodules('bitbox02') +hiddenimports += collect_submodules('smartcard') # satochip hiddenimports += ['PyQt5.QtPrintSupport'] # needed by Revealer @@ -49,6 +50,7 @@ datas += collect_data_files('btchip') datas += collect_data_files('keepkeylib') datas += collect_data_files('ckcc') datas += collect_data_files('bitbox02') +datas += collect_data_files('pysatochip') # We don't put these files in to actually include them in the script but to make the Analysis method scan them for imports a = Analysis([home+'run_electrum', @@ -69,6 +71,7 @@ a = Analysis([home+'run_electrum', home+'electrum/plugins/keepkey/qt.py', home+'electrum/plugins/ledger/qt.py', home+'electrum/plugins/coldcard/qt.py', + home+'electrum/plugins/satochip/qt.py', #home+'packages/requests/utils.py' ], binaries=binaries, diff --git a/contrib/deterministic-build/requirements-hw.txt b/contrib/deterministic-build/requirements-hw.txt index 8d5ec2b40a79..091b431c53d1 100644 --- a/contrib/deterministic-build/requirements-hw.txt +++ b/contrib/deterministic-build/requirements-hw.txt @@ -180,6 +180,21 @@ pyaes==1.6.1 \ pycparser==2.21 \ --hash=sha256:8ee45429555515e1f6b185e78100aea234072576aa43ab53aefcae078162fca9 \ --hash=sha256:e644fdec12f7872f86c58ff790da456218b10f863970249516d60a5eaca77206 +pyopenssl==20.0.0 \ + --hash=sha256:898aefbde331ba718570244c3b01dcddb1b31a3b336613436a45e52e27d9a82d \ + --hash=sha256:92f08eccbd73701cf744e8ffd6989aa7842d48cbe3fea8a7c031c5647f590ac5 +pysatochip==0.12.4 \ + --hash=sha256:805156d252ae9b4bc618ab4167933b60508a878dcf08a3eaca32a4c825811b0d \ + --hash=sha256:871b8f7ef80eac3c52f1363b5aee7f3047612b760259f4c5db8c054959557c2c +pyscard==2.0.0 \ + --hash=sha256:2abc34387ce5d1567a1052edc47797c1288739b51b664468d34ca77c9a3b05c2 \ + --hash=sha256:59cb506e8e793c397f3014f0933752df8d00c97c9f3a3385698e77213423962f \ + --hash=sha256:60dbc52f00da90e3428e987679723588598579a7c3c757e9937cecd6e381ddd2 \ + --hash=sha256:6d6ddcf57f97b0899b952c1c0746177c4b4b52af2ca47eb4d6bd0b9096530181 \ + --hash=sha256:73945cd5a8c6e2e4982ba24d70fdba9e1b4ca68b82169337f4733823f7b49fb6 \ + --hash=sha256:852a4e354bb82cc1f68afb204349ca68ea6c5332242644a80651a5c62bb1ab5f \ + --hash=sha256:b364d9d9186e793c1c4709eb72a4d29e09067d36ca463b2c2abd995bd1055779 \ + --hash=sha256:e162f9af64b49beb435e6543819f604e45534c822eb77fd100773f359fbcb6d8 requests==2.27.1 \ --hash=sha256:68d7c56fd5a8999887728ef304a6d12edc7be74f1cfa47714fc8b414525c9a61 \ --hash=sha256:f22fa1e554c9ddfd16e6e41ac79759e17be9e492b3587efa038054674760e72d diff --git a/contrib/osx/make_osx b/contrib/osx/make_osx index 16ac04983ffd..b324ba079f95 100755 --- a/contrib/osx/make_osx +++ b/contrib/osx/make_osx @@ -127,7 +127,7 @@ info "generating locale" info "Installing some build-time deps for compilation..." -brew install autoconf automake libtool gettext coreutils pkgconfig +brew install autoconf automake libtool gettext coreutils pkgconfig swig if [ ! -f "$PROJECT_ROOT"/electrum/libsecp256k1.0.dylib ]; then info "Building libsecp256k1 dylib..." diff --git a/contrib/osx/osx.spec b/contrib/osx/osx.spec index 700ed44322fa..5c22824319a4 100644 --- a/contrib/osx/osx.spec +++ b/contrib/osx/osx.spec @@ -30,6 +30,7 @@ hiddenimports += collect_submodules('keepkeylib') hiddenimports += collect_submodules('websocket') hiddenimports += collect_submodules('ckcc') hiddenimports += collect_submodules('bitbox02') +hiddenimports += collect_submodules('smartcard') # Satochip hiddenimports += ['PyQt5.QtPrintSupport'] # needed by Revealer datas = [ @@ -47,6 +48,7 @@ datas += collect_data_files('btchip') datas += collect_data_files('keepkeylib') datas += collect_data_files('ckcc') datas += collect_data_files('bitbox02') +datas += collect_data_files('pysatochip') # Add libusb so Trezor and Safe-T mini will work binaries = [(electrum + "contrib/osx/libusb-1.0.dylib", ".")] @@ -75,6 +77,7 @@ a = Analysis([electrum+ MAIN_SCRIPT, electrum+'electrum/plugins/keepkey/qt.py', electrum+'electrum/plugins/ledger/qt.py', electrum+'electrum/plugins/coldcard/qt.py', + electrum+'electrum/plugins/satochip/qt.py', ], binaries=binaries, datas=datas, diff --git a/contrib/requirements/requirements-hw.txt b/contrib/requirements/requirements-hw.txt index 307a6adccbb2..ddbfc927beeb 100644 --- a/contrib/requirements/requirements-hw.txt +++ b/contrib/requirements/requirements-hw.txt @@ -5,3 +5,5 @@ keepkey>=6.3.1 btchip-python>=0.1.32 ckcc-protocol>=0.7.7 bitbox02>=5.2.0 +pyscard>=1.9.9 +pysatochip==0.12.4 diff --git a/electrum/gui/icons/satochip.png b/electrum/gui/icons/satochip.png new file mode 100644 index 000000000000..ee89895e572d Binary files /dev/null and b/electrum/gui/icons/satochip.png differ diff --git a/electrum/gui/icons/satochip_unpaired.png b/electrum/gui/icons/satochip_unpaired.png new file mode 100644 index 000000000000..592d5daf71e5 Binary files /dev/null and b/electrum/gui/icons/satochip_unpaired.png differ diff --git a/electrum/plugins/satochip/README.rst b/electrum/plugins/satochip/README.rst new file mode 100644 index 000000000000..2a6127782aae --- /dev/null +++ b/electrum/plugins/satochip/README.rst @@ -0,0 +1,87 @@ +Satochip plugin for electrum +================================================================================= + +:: + + Licence: MIT Licence + Author: Toporin + Language: Python (>= 3.6) + Homepage: https://github.com/Toporin/electrum-satochip + +Introduction +============ + +This plugin allows to integrate the Satochip Hardware Wallet with Electrum. To use it, you need a device with the Satochip javacard applet installed (see https://github.com/Toporin/SatochipApplet). +If the wallet is not intialized yet, Electrum will perform the setup (you only need to do this once). During setup, a seed is created: this seed allows you to recover your wallet at anytime, so make sure to BACKUP THE SEED SECURELY! During setup, a PIN code is also created: this PIN allows to unlock th device to access your funds. If you try too many wrong PIN, your device will be locked indefinitely (it is 'bricked'). If you loose your PIN or brick your device, you can only recover your funds with the seed backup. + +The Satochip wallet is currently in Beta, use with caution!You can use the software on the Bitcoin testnet using the --testnet option. +This software is provided 'as-is', without any express or implied warranty. In no event will the authors be held liable for any damages arising from the use of this software. + +Rem: Electrum uses Python 3.x. In case of error, check first that you are not trying to run Electrum with Python 2.x or with Python 2.x libraries. + +Development version (Windows 64bits) +===================================== + +Install the latest python 3.6 release from https://www.python.org (https://www.python.org/downloads/release/python-368/) +(Caution: installing another release than 3.6 may cause incompatibility issues with pyscard) + +Clone or download the code from GitHub. + +Open a PowerShell command line in the electrum folder + +In PowerShell, install the electrum dependencies:: + + python -m pip install . + +You may also ned to install Python3-pyqt5:: + + python -m pip install pyqt5 + +Install pyscard from https://pyscard.sourceforge.io/ +Pyscard is required to connect to the smartcard:: + + python -m pip install pyscard + +In case of error message, you may also install pyscard from the installer: +Download the .whl files from https://sourceforge.net/projects/pyscard/files/pyscard/pyscard%201.9.7/ and run:: + + python -m pip install pyscard-1.9.7-cp36-cp36m-win_amd64.whl + +In PowerShell, run electrum on the testnet (-v allows for verbose output):: + + python .\run_electrum -v --testnet + + +Development version (Ubuntu) +============================== +(Electrum requires Python 3.6, which should be installed by default on Ubuntu) +(If necessary, install pip: sudo apt-get install python3-pip) + +Electrum is a pure python application. To use the +Qt interface, install the Qt dependencies:: + + sudo apt-get install python3-pyqt5 + +Check out the code from GitHub:: + + git clone git://github.com/Toporin/electrum.git + cd electrum + +In the electrum folder: + +Run install (this should install dependencies):: + + python3 -m pip install . + +Install pyscard (https://pyscard.sourceforge.io/) +Pyscard is required to connect to the smartcard:: + sudo apt-get install pcscd + sudo apt-get install python3-pyscard +(For alternatives, see https://github.com/LudovicRousseau/pyscard/blob/master/INSTALL.md for more detailed installation instructions) + + +To run Electrum use:: + python3 electrum -v --testnet + + + diff --git a/electrum/plugins/satochip/__init__.py b/electrum/plugins/satochip/__init__.py new file mode 100644 index 000000000000..91f8ccdcc419 --- /dev/null +++ b/electrum/plugins/satochip/__init__.py @@ -0,0 +1,9 @@ +#from electrum.i18n import _ + +fullname = 'Satochip Wallet' +description = 'Provides support for Satochip hardware wallet' +requires = [('satochip', 'github.com/Toporin/pysatochip')] +registers_keystore = ('hardware', 'satochip', "Satochip wallet") +#registers_keystore = ('hardware', 'satochip', _("Satochip wallet")) +#available_for = ['qt', 'cmdline'] #+kivy? +available_for = ['qt'] diff --git a/electrum/plugins/satochip/qt.py b/electrum/plugins/satochip/qt.py new file mode 100644 index 000000000000..bb112f311d14 --- /dev/null +++ b/electrum/plugins/satochip/qt.py @@ -0,0 +1,495 @@ +from electrum.i18n import _ +from electrum.logging import get_logger +from electrum.simple_config import SimpleConfig +from electrum.gui.qt.util import (EnterButton, Buttons, CloseButton, OkButton, CancelButton, WindowModalDialog, WWLabel) +from electrum.gui.qt.qrcodewidget import QRCodeWidget, QRDialog +from PyQt5.QtCore import Qt, pyqtSignal +from PyQt5.QtWidgets import (QPushButton, QLabel, QVBoxLayout, QWidget, QGridLayout, QLineEdit, QCheckBox) +from functools import partial +from os import urandom + +#satochip +from .satochip import SatochipPlugin +from ..hw_wallet.qt import QtHandlerBase, QtPluginBase + +#pysatochip +from pysatochip.CardConnector import CardConnector, UnexpectedSW12Error, CardError, CardNotPresentError +from pysatochip.Satochip2FA import Satochip2FA +from pysatochip.version import SATOCHIP_PROTOCOL_MAJOR_VERSION, SATOCHIP_PROTOCOL_MINOR_VERSION + +_logger = get_logger(__name__) + +MSG_USE_2FA= _("Do you want to use 2-Factor-Authentication (2FA)?\n\nWith 2FA, any transaction must be confirmed on a second device such as your smartphone. First you have to install the Satochip-2FA android app on google play. Then you have to pair your 2FA device with your Satochip by scanning the qr-code on the next screen. \n\nWARNING: be sure to backup a copy of the qr-code in a safe place, in case you have to reinstall the app!") + +class Plugin(SatochipPlugin, QtPluginBase): + icon_unpaired = "satochip_unpaired.png" + icon_paired = "satochip.png" + #icon_unpaired = ":icons/satochip_unpaired.png" + #icon_paired = ":icons/satochip.png" + + #def __init__(self, parent, config, name): + # BasePlugin.__init__(self, parent, config, name) + + def create_handler(self, window): + return Satochip_Handler(window) + + def requires_settings(self): + # Return True to add a Settings button. + return True + + def settings_widget(self, window): + # Return a button that when pressed presents a settings dialog. + return EnterButton(_('Settings'), partial(self.settings_dialog, window)) + + def settings_dialog(self, window): + # Return a settings dialog. + d = WindowModalDialog(window, _("Email settings")) + vbox = QVBoxLayout(d) + + d.setMinimumSize(500, 200) + vbox.addStretch() + vbox.addLayout(Buttons(CloseButton(d), OkButton(d))) + d.show() + + def show_settings_dialog(self, window, keystore): + # When they click on the icon for Satochip we come here. + # device_id = self.choose_device(window, keystore) + # if device_id: + # SatochipSettingsDialog(window, self, keystore, device_id).exec_() + def connect(): + device_id = self.choose_device(window, keystore) + return device_id + def show_dialog(device_id): + if device_id: + SatochipSettingsDialog(window, self, keystore, device_id).exec_() + keystore.thread.add(connect, on_success=show_dialog) + +class Satochip_Handler(QtHandlerBase): + + def __init__(self, win): + super(Satochip_Handler, self).__init__(win, 'Satochip') + + #TODO: something? + +class SatochipSettingsDialog(WindowModalDialog): + '''This dialog doesn't require a device be paired with a wallet. + + We want users to be able to wipe a device even if they've forgotten + their PIN.''' + + def __init__(self, window, plugin, keystore, device_id): + title = _("{} Settings").format(plugin.device) + super(SatochipSettingsDialog, self).__init__(window, title) + self.setMaximumWidth(540) + + devmgr = plugin.device_manager() + config = devmgr.config + handler = keystore.handler + self.thread = thread = keystore.thread + + def connect_and_doit(): + client = devmgr.client_by_id(device_id) + if not client: + raise RuntimeError("Device not connected") + return client + + body = QWidget() + body_layout = QVBoxLayout(body) + grid = QGridLayout() + grid.setColumnStretch(3, 1) + + # see + title = QLabel('''
+Satochip Wallet +
satochip.io''') + title.setTextInteractionFlags(Qt.LinksAccessibleByMouse) + + grid.addWidget(title, 0, 0, 1, 2, Qt.AlignHCenter) + y = 3 + + rows = [ + ('fw_version', _("Firmware Version")), + ('sw_version', _("Electrum Support")), + ('is_seeded', _("Wallet seeded")), + ('needs_2FA', _("Requires 2FA")), + ('needs_SC', _("Secure Channel")), + ('card_label', _("Card label")), + ] + for row_num, (member_name, label) in enumerate(rows): + widget = QLabel('') + widget.setTextInteractionFlags(Qt.TextSelectableByMouse | Qt.TextSelectableByKeyboard) + + grid.addWidget(QLabel(label), y, 0, 1,1, Qt.AlignRight) + grid.addWidget(widget, y, 1, 1, 1, Qt.AlignLeft) + setattr(self, member_name, widget) + y += 1 + + body_layout.addLayout(grid) + + pin_btn = QPushButton('Change PIN') + def _change_pin(): + thread.add(connect_and_doit, on_success=self.change_pin) + pin_btn.clicked.connect(_change_pin) + + seed_btn = QPushButton('Reset seed') + def _reset_seed(): + thread.add(connect_and_doit, on_success=self.reset_seed) + thread.add(connect_and_doit, on_success=self.show_values) + seed_btn.clicked.connect(_reset_seed) + + set_2FA_btn = QPushButton('Enable 2FA') + def _set_2FA(): + thread.add(connect_and_doit, on_success=self.set_2FA) + thread.add(connect_and_doit, on_success=self.show_values) + set_2FA_btn.clicked.connect(_set_2FA) + + reset_2FA_btn = QPushButton('Disable 2FA') + def _reset_2FA(): + thread.add(connect_and_doit, on_success=self.reset_2FA) + thread.add(connect_and_doit, on_success=self.show_values) + reset_2FA_btn.clicked.connect(_reset_2FA) + + verify_card_btn = QPushButton('Verify card') + def _verify_card(): + thread.add(connect_and_doit, on_success=self.verify_card) + verify_card_btn.clicked.connect(_verify_card) + + change_card_label_btn = QPushButton('Change label') + def _change_card_label(): + thread.add(connect_and_doit, on_success=self.change_card_label) + change_card_label_btn.clicked.connect(_change_card_label) + + + y += 3 + grid.addWidget(pin_btn, y, 0, 1, 2, Qt.AlignHCenter) + y += 2 + grid.addWidget(seed_btn, y, 0, 1, 2, Qt.AlignHCenter) + y += 2 + grid.addWidget(set_2FA_btn, y, 0, 1, 2, Qt.AlignHCenter) + y += 2 + grid.addWidget(reset_2FA_btn, y, 0, 1, 2, Qt.AlignHCenter) + y += 2 + grid.addWidget(verify_card_btn, y, 0, 1, 2, Qt.AlignHCenter) + y += 2 + grid.addWidget(change_card_label_btn, y, 0, 1, 2, Qt.AlignHCenter) + y += 2 + grid.addWidget(CloseButton(self), y, 0, 1, 2, Qt.AlignHCenter) + + dialog_vbox = QVBoxLayout(self) + dialog_vbox.addWidget(body) + + # Fetch values and show them + thread.add(connect_and_doit, on_success=self.show_values) + + + def show_values(self, client): + _logger.info("Show value!") + sw_rel= 'v' + str(SATOCHIP_PROTOCOL_MAJOR_VERSION) + '.' + str(SATOCHIP_PROTOCOL_MINOR_VERSION) + self.sw_version.setText('%s' % sw_rel) + + (response, sw1, sw2, d)=client.cc.card_get_status() + if (sw1==0x90 and sw2==0x00): + #fw_rel= 'v' + str(d["protocol_major_version"]) + '.' + str(d["protocol_minor_version"]) + fw_rel= 'v' + str(d["protocol_major_version"]) + '.' + str(d["protocol_minor_version"]) +'-'+ str(d["applet_major_version"]) +'.'+ str(d["applet_minor_version"]) + self.fw_version.setText('%s' % fw_rel) + + #is_seeded? + if len(response) >=10: + self.is_seeded.setText('%s' % "yes") if d["is_seeded"] else self.is_seeded.setText('%s' % "no") + else: #for earlier versions + try: + client.cc.card_bip32_get_authentikey() + self.is_seeded.setText('%s' % "yes") + except Exception: + self.is_seeded.setText('%s' % "no") + + # needs2FA? + if d["needs2FA"]: + self.needs_2FA.setText('%s' % "yes") + else: + self.needs_2FA.setText('%s' % "no") + + # needs secure channel + if d["needs_secure_channel"]: + self.needs_SC.setText('%s' % "yes") + else: + self.needs_SC.setText('%s' % "no") + + # card label + (response, sw1, sw2, label)= client.cc.card_get_label() + if (label==""): + label= "(none)" + self.card_label.setText('%s' % label) + + else: + fw_rel= "(unitialized)" + self.fw_version.setText('%s' % fw_rel) + self.needs_2FA.setText('%s' % "(unitialized)") + self.is_seeded.setText('%s' % "no") + self.needs_SC.setText('%s' % "(unknown)") + self.card_label.setText('%s' % "(none)") + + + def change_pin(self, client): + _logger.info("In change_pin") + msg_oldpin = _("Enter the current PIN for your Satochip:") + msg_newpin = _("Enter a new PIN for your Satochip:") + msg_confirm = _("Please confirm the new PIN for your Satochip:") + msg_error= _("The PIN values do not match! Please type PIN again!") + msg_cancel= _("PIN Change cancelled!") + (is_pin, oldpin, newpin) = client.PIN_change_dialog(msg_oldpin, msg_newpin, msg_confirm, msg_error, msg_cancel) + if (not is_pin): + return + + oldpin= list(oldpin) + newpin= list(newpin) + (response, sw1, sw2)= client.cc.card_change_PIN(0, oldpin, newpin) + if (sw1==0x90 and sw2==0x00): + msg= _("PIN changed successfully!") + client.handler.show_message(msg) + else: + msg= _("Failed to change PIN!") + client.handler.show_error(msg) + + def reset_seed(self, client): + _logger.info("In reset_seed") + # pin + msg = ''.join([ + _("WARNING!\n"), + _("You are about to reset the seed of your Satochip. This process is irreversible!\n"), + _("Please be sure that your wallet is empty and that you have a backup of the seed as a precaution.\n\n"), + _("To proceed, enter the PIN for your Satochip:") + ]) + password = self.reset_seed_dialog(msg) + if (password is None): + return + pin = password.encode('utf8') + pin= list(pin) + + # if 2FA is enabled, get challenge-response + hmac=[] + if (client.cc.needs_2FA==None): + (response, sw1, sw2, d)=client.cc.card_get_status() + if client.cc.needs_2FA: + # challenge based on authentikey + authentikeyx= bytearray(client.cc.parser.authentikey_coordx).hex() + + # format & encrypt msg + import json + msg= {'action':"reset_seed", 'authentikeyx':authentikeyx} + msg= json.dumps(msg) + (id_2FA, msg_out)= client.cc.card_crypt_transaction_2FA(msg, True) + d={} + d['msg_encrypt']= msg_out + d['id_2FA']= id_2FA + # _logger.info("encrypted message: "+msg_out) + + #do challenge-response with 2FA device... + client.handler.show_message('2FA request sent! Approve or reject request on your second device.') + Satochip2FA.do_challenge_response(d) + # decrypt and parse reply to extract challenge response + try: + reply_encrypt= d['reply_encrypt'] + except Exception as e: + self.give_error("No response received from 2FA.\nPlease ensure that the Satochip-2FA plugin is enabled in Tools>Optional Features", True) + reply_decrypt= client.cc.card_crypt_transaction_2FA(reply_encrypt, False) + _logger.info("challenge:response= "+ reply_decrypt) + reply_decrypt= reply_decrypt.split(":") + chalresponse=reply_decrypt[1] + hmac= list(bytes.fromhex(chalresponse)) + + # send request + (response, sw1, sw2) = client.cc.card_reset_seed(pin, hmac) + if (sw1==0x90 and sw2==0x00): + msg= _("Seed reset successfully!\nYou should close this wallet and launch the wizard to generate a new wallet.") + client.handler.show_message(msg) + #to do: close client? + elif (sw1==0x9c and sw2==0x0b): + msg= _(f"Failed to reset seed: request rejected by 2FA device (error code: {hex(256*sw1+sw2)})") + client.handler.show_message(msg) + #to do: close client? + else: + msg= _(f"Failed to reset seed with error code: {hex(256*sw1+sw2)}") + client.handler.show_error(msg) + + def reset_seed_dialog(self, msg): + _logger.info("In reset_seed_dialog") + parent = self.top_level_window() + d = WindowModalDialog(parent, _("Enter PIN")) + pw = QLineEdit() + pw.setEchoMode(2) + pw.setMinimumWidth(200) + + vbox = QVBoxLayout() + vbox.addWidget(WWLabel(msg)) + vbox.addWidget(pw) + vbox.addLayout(Buttons(CancelButton(d), OkButton(d))) + d.setLayout(vbox) + + passphrase = pw.text() if d.exec_() else None + return passphrase + + def set_2FA(self, client): + if not client.cc.needs_2FA: + use_2FA=client.handler.yes_no_question(MSG_USE_2FA) + if (use_2FA): + secret_2FA= urandom(20) + secret_2FA_hex=secret_2FA.hex() + # the secret must be shared with the second factor app (eg on a smartphone) + try: + config = SimpleConfig() + help_txt="Scan the QR-code with your Satochip-2FA app and make a backup of the following secret: "+ secret_2FA_hex + d = QRDialog(data=secret_2FA_hex, parent=None, title="Secret_2FA", show_text=False, help_text=help_txt, show_copy_text_btn=True, config=config) + d.exec_() + except Exception as e: + _logger.info("SatochipPlugin: setup 2FA error: "+str(e)) + return + # further communications will require an id and an encryption key (for privacy). + # Both are derived from the secret_2FA using a one-way function inside the Satochip + amount_limit= 0 # i.e. always use + (response, sw1, sw2)=client.cc.card_set_2FA_key(secret_2FA, amount_limit) + if sw1!=0x90 or sw2!=0x00: + _logger.info(f"Unable to set 2FA with error code:= {hex(256*sw1+sw2)}")#debugSatochip + raise RuntimeError(f'Unable to setup 2FA with error code: {hex(256*sw1+sw2)}') + else: + client.handler.show_message("2FA enabled successfully!") + + def reset_2FA(self, client): + if client.cc.needs_2FA: + # challenge based on ID_2FA + # format & encrypt msg + import json + msg= {'action':"reset_2FA"} + msg= json.dumps(msg) + (id_2FA, msg_out)= client.cc.card_crypt_transaction_2FA(msg, True) + d={} + d['msg_encrypt']= msg_out + d['id_2FA']= id_2FA + # _logger.info("encrypted message: "+msg_out) + + #do challenge-response with 2FA device... + client.handler.show_message('2FA request sent! Approve or reject request on your second device.') + Satochip2FA.do_challenge_response(d) + # decrypt and parse reply to extract challenge response + try: + reply_encrypt= d['reply_encrypt'] + except Exception as e: + self.give_error("No response received from 2FA!", True) + reply_decrypt= client.cc.card_crypt_transaction_2FA(reply_encrypt, False) + _logger.info("challenge:response= "+ reply_decrypt) + reply_decrypt= reply_decrypt.split(":") + chalresponse=reply_decrypt[1] + hmac= list(bytes.fromhex(chalresponse)) + + # send request + (response, sw1, sw2) = client.cc.card_reset_2FA_key(hmac) + if (sw1==0x90 and sw2==0x00): + msg= _("2FA reset successfully!") + client.cc.needs_2FA= False + client.handler.show_message(msg) + elif (sw1==0x9c and sw2==0x17): + msg= _(f"Failed to reset 2FA: \nyou must reset the seed first (error code {hex(256*sw1+sw2)})") + client.handler.show_error(msg) + else: + msg= _(f"Failed to reset 2FA with error code: {hex(256*sw1+sw2)}") + client.handler.show_error(msg) + else: + msg= _(f"2FA is already disabled!") + client.handler.show_error(msg) + + def verify_card(self, client): + is_authentic, txt_ca, txt_subca, txt_device, txt_error = self.card_verify_authenticity(client) + + text_cert_chain= 4*"="+" Root CA certificate: "+4*"="+"\n" + text_cert_chain+= txt_ca + text_cert_chain+= "\n"+4*"="+" Sub CA certificate: "+4*"="+"\n" + text_cert_chain+= txt_subca + text_cert_chain+= "\n"+4*"="+" Device certificate: "+4*"="+"\n" + text_cert_chain+= txt_device + + if is_authentic: + txt_result= 'Device authenticated successfully!' + txt_result+= '\n\n' + text_cert_chain + txt_color= 'green' + client.handler.show_message(txt_result) + else: + txt_result= ''.join(['Error: could not authenticate the issuer of this card! \n', + 'Reason: ', txt_error , '\n\n', + 'If you did not load the card yourself, be extremely careful! \n', + 'Contact support(at)satochip.io to report a suspicious device.']) + txt_result+= '\n\n' + text_cert_chain + txt_color= 'red' + client.handler.show_error(txt_result) + + def card_verify_authenticity(self, client): #todo: add this function in pysatochip + cert_pem=txt_error="" + try: + cert_pem=client.cc.card_export_perso_certificate() + _logger.info('Cert PEM: '+ str(cert_pem)) + except CardError as ex: + txt_error= ''.join(["Unable to get device certificate: feature unsupported! \n", + "Authenticity validation is only available starting with Satochip v0.12 and higher"]) + except CardNotPresentError as ex: + txt_error= "No card found! Please insert card." + except UnexpectedSW12Error as ex: + txt_error= "Exception during device certificate export: " + str(ex) + + if cert_pem=="(empty)": + txt_error= "Device certificate is empty: the card has not been personalized!" + + if txt_error!="": + return False, "(empty)", "(empty)", "(empty)", txt_error + + # check the certificate chain from root CA to device + from pysatochip.certificate_validator import CertificateValidator + validator= CertificateValidator() + is_valid_chain, device_pubkey, txt_ca, txt_subca, txt_device, txt_error= validator.validate_certificate_chain(cert_pem, client.cc.card_type) + if not is_valid_chain: + return False, txt_ca, txt_subca, txt_device, txt_error + + # perform challenge-response with the card to ensure that the key is correctly loaded in the device + is_valid_chalresp, txt_error = client.cc.card_challenge_response_pki(device_pubkey) + + return is_valid_chalresp, txt_ca, txt_subca, txt_device, txt_error + + def change_card_label(self, client): + msg = ''.join([ + _("You can optionnaly add a label to your Satochip.\n"), + _("This label must be less than 64 chars long."), + ]) + label = self.change_card_label_dialog(client, msg) + if label is None: + client.handler.show_message(_("Operation aborted by user!")) + return + (response, sw1, sw2)= client.cc.card_set_label(label) + if (sw1==0x90 and sw2==0x00): + client.handler.show_message(_("Card label changed successfully!")) + elif (sw1==0x6D and sw2==0x00): + client.handler.show_error(_("Error: card does not support label!")) # starts with satochip v0.12 + else: + client.handler.show_error(f"Error while changing label: sw12={hex(sw1)} {hex(sw2)}") + + def change_card_label_dialog(self, client, msg): + _logger.info("In change_card_label_dialog") + while (True): + parent = self.top_level_window() + d = WindowModalDialog(parent, _("Enter Label")) + pw = QLineEdit() + pw.setEchoMode(0) + pw.setMinimumWidth(200) + + vbox = QVBoxLayout() + vbox.addWidget(WWLabel(msg)) + vbox.addWidget(pw) + vbox.addLayout(Buttons(CancelButton(d), OkButton(d))) + d.setLayout(vbox) + + label = pw.text() if d.exec_() else None + if label is None or len(label.encode('utf-8'))<=64: + return label + else: + client.handler.show_error(_("Card label should not be longer than 64 chars!")) + + + + \ No newline at end of file diff --git a/electrum/plugins/satochip/satochip.py b/electrum/plugins/satochip/satochip.py new file mode 100644 index 000000000000..8d08280f04df --- /dev/null +++ b/electrum/plugins/satochip/satochip.py @@ -0,0 +1,772 @@ +from os import urandom +import hashlib + +#electrum +from electrum import mnemonic +from electrum import constants +from electrum.bitcoin import TYPE_ADDRESS, int_to_hex, var_int +from electrum.i18n import _ +from electrum.plugin import BasePlugin, Device +from electrum.keystore import Hardware_KeyStore, bip39_to_seed +from electrum.transaction import Transaction +from electrum.wallet import Standard_Wallet +from electrum.util import bfh, bh2u, versiontuple +from electrum.base_wizard import ScriptTypeNotSupported +from electrum.crypto import hash_160, sha256d +from electrum.ecc import CURVE_ORDER, der_sig_from_r_and_s, get_r_and_s_from_der_sig, ECPubkey +from electrum.bip32 import BIP32Node, convert_bip32_path_to_list_of_uint32, convert_bip32_intpath_to_strpath +from electrum.logging import get_logger +from electrum.gui.qt.qrcodewidget import QRCodeWidget, QRDialog + +from ..hw_wallet import HW_PluginBase, HardwareClientBase + +#pysatochip +from pysatochip.CardConnector import CardConnector, UninitializedSeedError +from pysatochip.JCconstants import JCconstants +from pysatochip.TxParser import TxParser +from pysatochip.Satochip2FA import Satochip2FA +from pysatochip.version import SATOCHIP_PROTOCOL_MAJOR_VERSION, SATOCHIP_PROTOCOL_MINOR_VERSION, SATOCHIP_PROTOCOL_VERSION + +#pyscard +from smartcard.sw.SWExceptions import SWException +from smartcard.Exceptions import CardConnectionException, CardRequestTimeoutException +from smartcard.CardType import AnyCardType +from smartcard.CardRequest import CardRequest + +_logger = get_logger(__name__) + +# version history for the plugin +SATOCHIP_PLUGIN_REVISION= 'lib0.11.a-plugin0.1' + +# debug: smartcard reader ids +SATOCHIP_VID= 0 #0x096E +SATOCHIP_PID= 0 #0x0503 + +MSG_USE_2FA= _("Do you want to use 2-Factor-Authentication (2FA)?\n\nWith 2FA, any transaction must be confirmed on a second device such as your smartphone. First you have to install the Satochip-2FA android app on google play. Then you have to pair your 2FA device with your Satochip by scanning the qr-code on the next screen. \n\nWARNING: be sure to backup a copy of the qr-code in a safe place, in case you have to reinstall the app!") + +# def bip32path2bytes(bip32path:str) -> (int, bytes): + # splitPath = bip32path.split('/') + # splitPath=[x for x in splitPath if x] # removes empty values + # if splitPath[0] == 'm': + # splitPath = splitPath[1:] + + # bytePath=b'' + # depth= len(splitPath) + # for index in splitPath: + # if index.endswith("'"): + # bytePath+= pack( ">I", int(index.rstrip("'"))+0x80000000 ) + # else: + # bytePath+=pack( ">I", int(index) ) + + # return (depth, bytePath) + +def bip32path2bytes(bip32path:str) -> (int, bytes): + intPath= convert_bip32_path_to_list_of_uint32(bip32path) + depth= len(intPath) + bytePath=b'' + for index in intPath: + bytePath+= index.to_bytes(4, byteorder='big', signed=False) + return (depth, bytePath) + +class SatochipClient(HardwareClientBase): + def __init__(self, plugin: HW_PluginBase, handler): + HardwareClientBase.__init__(self, plugin=plugin) + _logger.info(f"[SatochipClient] __init__()")#debugSatochip + self._soft_device_id = None + self.device = plugin.device + self.handler = handler + #self.parser= CardDataParser() + self.cc= CardConnector(self, _logger.getEffectiveLevel()) + + def __repr__(self): + return '' + + def is_pairable(self): + return True + + def close(self): + _logger.info(f"close()") + self.cc.card_disconnect() + self.cc.cardmonitor.deleteObserver(self.cc.cardobserver) + + def timeout(self, cutoff): + pass + + def is_initialized(self): + # TODO - currently set to true #debugSatochip + return True + + def get_soft_device_id(self): + return self._soft_device_id + + def label(self): + # TODO - currently empty #debugSatochip + return "" + + def device_model_name(self): + return "Satochip" + + def has_usable_connection_with_device(self): + _logger.info(f"has_usable_connection_with_device()")#debugSatochip + try: + atr= self.cc.card_get_ATR() # (response, sw1, sw2)= self.cc.card_select() #TODO: something else? get ATR? + _logger.info("Card ATR: " + bytes(atr).hex() ) + except Exception as e: #except SWException as e: + _logger.exception(f"Exception in has_usable_connection_with_device: {str(e)}") + return False + return True + + def get_xpub(self, bip32_path, xtype): + assert xtype in SatochipPlugin.SUPPORTED_XTYPES + + # needs PIN + self.cc.card_verify_PIN() + + # bip32_path is of the form 44'/0'/1' + _logger.info(f"[SatochipClient] get_xpub(): bip32_path={bip32_path}")#debugSatochip + (depth, bytepath)= bip32path2bytes(bip32_path) + (childkey, childchaincode)= self.cc.card_bip32_get_extendedkey(bytepath) + if depth == 0: #masterkey + fingerprint= bytes([0,0,0,0]) + child_number= bytes([0,0,0,0]) + else: #get parent info + (parentkey, parentchaincode)= self.cc.card_bip32_get_extendedkey(bytepath[0:-4]) + fingerprint= hash_160(parentkey.get_public_key_bytes(compressed=True))[0:4] + child_number= bytepath[-4:] + xpub= BIP32Node(xtype=xtype, + eckey=childkey, + chaincode=childchaincode, + depth=depth, + fingerprint=fingerprint, + child_number=child_number).to_xpub() + _logger.info(f"[SatochipClient] get_xpub(): xpub={str(xpub)}")#debugSatochip + return xpub + + def ping_check(self): + #check connection is working + try: + print('ping_check')#debug + #atr= self.cc.card_get_ATR() + except Exception as e: + _logger.exception(f"Exception: {str(e)}") + raise RuntimeError("Communication issue with Satochip") + + def request(self, request_type, *args): + _logger.info('[SatochipClient] client request: '+ str(request_type))#debugSatochip + + if self.handler is not None: + if (request_type=='update_status'): + reply = self.handler.update_status(*args) + return reply + elif (request_type=='show_error'): + reply = self.handler.show_error(*args) + return reply + elif (request_type=='show_message'): + reply = self.handler.show_message(*args) + return reply + else: + reply = self.handler.show_error('Unknown request: '+str(request_type)) + return reply + else: + _logger.info('[SatochipClient] self.handler is None! ')#debugSatochip + return None + # try: + # method_to_call = getattr(self.handler, request_type) + # print('Type of method_to_call: '+ str(type(method_to_call))) + # print('method_to_call: '+ str(method_to_call)) + # reply = method_to_call(*args) + # return reply + # except Exception as e: + # _logger.exception(f"Exception: {str(e)}") + # raise RuntimeError("GUI exception") + + def PIN_dialog(self, msg): + while True: + password = self.handler.get_passphrase(msg, False) + if password is None: + return False, None + if len(password) < 4: + msg = _("PIN must have at least 4 characters.") + \ + "\n\n" + _("Enter PIN:") + elif len(password) > 16: + msg = _("PIN must have less than 16 characters.") + \ + "\n\n" + _("Enter PIN:") + else: + password = password.encode('utf8') + return True, password + + def PIN_setup_dialog(self, msg, msg_confirm, msg_error): + while(True): + (is_PIN, pin)= self.PIN_dialog(msg) + if not is_PIN: + #return (False, None) + raise RuntimeError(('A PIN code is required to initialize the Satochip!')) + (is_PIN, pin_confirm)= self.PIN_dialog(msg_confirm) + if not is_PIN: + #return (False, None) + raise RuntimeError(('A PIN confirmation is required to initialize the Satochip!')) + if (pin != pin_confirm): + self.request('show_error', msg_error) + else: + return (is_PIN, pin) + + def PIN_change_dialog(self, msg_oldpin, msg_newpin, msg_confirm, msg_error, msg_cancel): + #old pin + (is_PIN, oldpin)= self.PIN_dialog(msg_oldpin) + if (not is_PIN): + self.request('show_message', msg_cancel) + return (False, None, None) + + # new pin + while (True): + (is_PIN, newpin)= self.PIN_dialog(msg_newpin) + if (not is_PIN): + self.request('show_message', msg_cancel) + return (False, None, None) + (is_PIN, pin_confirm)= self.PIN_dialog(msg_confirm) + if (not is_PIN): + self.request('show_message', msg_cancel) + return (False, None, None) + if (newpin != pin_confirm): + self.request('show_error', msg_error) + else: + return (True, oldpin, newpin) + +class Satochip_KeyStore(Hardware_KeyStore): + hw_type = 'satochip' + device = 'Satochip' + plugin: 'SatochipPlugin' + + def __init__(self, d): + Hardware_KeyStore.__init__(self, d) + #_logger.info(f"[Satochip_KeyStore] __init__(): xpub:{str(d.get('xpub'))}")#debugSatochip + #_logger.info(f"[Satochip_KeyStore] __init__(): derivation:{str(d.get('derivation'))}")#debugSatochip + self.force_watching_only = False + self.ux_busy = False + + def dump(self): + # our additions to the stored data about keystore -- only during creation? + d = Hardware_KeyStore.dump(self) + return d + + def get_derivation(self): + return self.derivation + + + def get_client(self): + # called when user tries to do something like view address, sign something. + # - not called during probing/setup + rv = self.plugin.get_client(self) + return rv + + def give_error(self, message, clear_client=False): + _logger.info(message) + if not self.ux_busy: + self.handler.show_error(message) + else: + self.ux_busy = False + if clear_client: + self.client = None + raise Exception(message) + def decrypt_message(self, pubkey, message, password): + raise RuntimeError(_('Encryption and decryption are currently not supported for {}').format(self.device)) + + def sign_message(self, sequence, message, password, *, script_type=None): + message_byte = message.encode('utf8') + message_hash = hashlib.sha256(message_byte).hexdigest().upper() + client = self.get_client() + address_path = self.get_derivation_prefix() + "/%d/%d"%sequence #self.get_derivation()[2:] + "/%d/%d"%sequence + _logger.info(f"[Satochip_KeyStore] sign_message: path: {address_path}") + self.handler.show_message("Signing message ...\r\nMessage hash: "+message_hash) + # check if 2FA is required + hmac=b'' + if (client.cc.needs_2FA==None): + (response, sw1, sw2, d)=client.cc.card_get_status() + if client.cc.needs_2FA: + # challenge based on sha256(btcheader+msg) + # format & encrypt msg + import json + msg= {'action':"sign_msg", 'msg':message} + msg= json.dumps(msg) + (id_2FA, msg_out)= client.cc.card_crypt_transaction_2FA(msg, True) + + d={} + d['msg_encrypt']= msg_out + d['id_2FA']= id_2FA + # _logger.info("encrypted message: "+msg_out) + _logger.info("id_2FA: "+id_2FA) + + #do challenge-response with 2FA device... + self.handler.show_message('2FA request sent! Approve or reject request on your second device.') + Satochip2FA.do_challenge_response(d) + # decrypt and parse reply to extract challenge response + try: + reply_encrypt= d['reply_encrypt'] + except Exception as e: + self.give_error("No response received from 2FA.\nPlease ensure that the Satochip-2FA plugin is enabled in Tools>Optional Features", True) + reply_decrypt= client.cc.card_crypt_transaction_2FA(reply_encrypt, False) + _logger.info("challenge:response= "+ reply_decrypt) + reply_decrypt= reply_decrypt.split(":") + chalresponse=reply_decrypt[1] + hmac= bytes.fromhex(chalresponse) + try: + keynbr= 0xFF #for extended key + (depth, bytepath)= bip32path2bytes(address_path) + (pubkey, chaincode)=client.cc.card_bip32_get_extendedkey(bytepath) + (response2, sw1, sw2, compsig) = client.cc.card_sign_message(keynbr, pubkey, message_byte, hmac) + if (compsig==b''): + self.handler.show_error(_("Wrong signature!\nThe 2FA device may have rejected the action.")) + + except Exception as e: + self.give_error(e, True) + finally: + self.handler.finished() + return compsig + + def sign_transaction(self, tx, password): + _logger.info(f"In sign_transaction(): tx: {str(tx)}") #debugSatochip + client = self.get_client() + segwitTransaction = False + + # outputs + txOutputs = var_int(len(tx.outputs())) + for o in tx.outputs(): + txOutputs += int_to_hex(o.value, 8) + script = o.scriptpubkey.hex() + txOutputs += var_int(len(script)//2) + txOutputs += script + #txOutputs = bfh(txOutputs) + hashOutputs = bh2u(sha256d(bfh(txOutputs))) + _logger.info(f"In sign_transaction(): hashOutputs= {hashOutputs}") #debugSatochip + _logger.info(f"In sign_transaction(): outputs= {txOutputs}") #debugSatochip + + # Fetch inputs of the transaction to sign + for i,txin in enumerate(tx.inputs()): + + if tx.is_complete(): + break + + _logger.info(f"In sign_transaction(): input= {str(i)} - input[type]: {txin.script_type}") #debugSatochip + if txin.is_coinbase_input(): + self.give_error("Coinbase not supported") # should never happen + + if txin.script_type in ['p2sh']: + p2shTransaction = True + + if txin.script_type in ['p2wpkh', 'p2wsh', 'p2wpkh-p2sh', 'p2wsh-p2sh']: + segwitTransaction = True + + my_pubkey, inputPath = self.find_my_pubkey_in_txinout(txin) + # _logger.info(f"In sign_transaction(): txin.json: {str(txin.to_json())}") #type:PartialTxInput #debugSatochip + # _logger.info(f"In sign_transaction(): my_pubkey: {str(my_pubkey)} - inputPath: {str(inputPath)}") #debugSatochip + if not inputPath: + self.give_error("No matching pubkey for sign_transaction") # should never happen + inputPath = convert_bip32_intpath_to_strpath(inputPath) #[2:] + inputHash = sha256d(bfh(tx.serialize_preimage(i))) + + # get corresponing extended key + (depth, bytepath)= bip32path2bytes(inputPath) + (key, chaincode)=client.cc.card_bip32_get_extendedkey(bytepath) + + # parse tx + pre_tx_hex= tx.serialize_preimage(i) + pre_tx= bytes.fromhex(pre_tx_hex)# hex representation => converted to bytes + pre_hash = sha256d(bfh(pre_tx_hex)) + pre_hash_hex= pre_hash.hex() + _logger.info(f"[Satochip_KeyStore] sign_transaction(): pre_tx_hex= {pre_tx_hex}") #debugSatochip + _logger.info(f"[Satochip_KeyStore] sign_transaction(): pre_hash= {pre_hash_hex}") #debugSatochip + (response, sw1, sw2, tx_hash, needs_2fa) = client.cc.card_parse_transaction(pre_tx, segwitTransaction) + tx_hash_hex= bytearray(tx_hash).hex() + if pre_hash_hex!= tx_hash_hex: + raise RuntimeError("[Satochip_KeyStore] Tx preimage mismatch: {pre_hash_hex} vs {tx_hash_hex}") + + #2FA + keynbr= 0xFF #for extended key + if needs_2fa: + # format & encrypt msg + import json + coin_type= 1 if constants.net.TESTNET else 0 + if segwitTransaction: + msg= {'tx':pre_tx_hex, 'ct':coin_type, 'sw':segwitTransaction, 'txo':txOutputs, 'ty':txin.script_type} + else: + msg= {'tx':pre_tx_hex, 'ct':coin_type, 'sw':segwitTransaction} + msg= json.dumps(msg) + (id_2FA, msg_out)= client.cc.card_crypt_transaction_2FA(msg, True) + d={} + d['msg_encrypt']= msg_out + d['id_2FA']= id_2FA + #_logger.info(f"encrypted message: {msg_out}") + #_logger.info(f"id_2FA: {id_2FA}") + + #do challenge-response with 2FA device... + client.handler.show_message('2FA request sent! Approve or reject request on your second device.') + Satochip2FA.do_challenge_response(d) + # decrypt and parse reply to extract challenge response + try: + reply_encrypt= d['reply_encrypt'] + except Exception as e: + self.give_error("No response received from 2FA.\nPlease ensure that the Satochip-2FA plugin is enabled in Tools>Optional Features", True) + if reply_encrypt is None: + #todo: abort tx + _logger.info("Abort transaction: no reply received from 2FA!") + break + reply_decrypt= client.cc.card_crypt_transaction_2FA(reply_encrypt, False) + _logger.info(f"[Satochip_KeyStore] sign_transaction(): challenge:response= {reply_decrypt}") + reply_decrypt= reply_decrypt.split(":") + rep_pre_hash_hex= reply_decrypt[0][0:64] + if rep_pre_hash_hex!= pre_hash_hex: + #todo: abort tx or retry? + _logger.info("Abort transaction: tx mismatch: "+rep_pre_hash_hex+" != "+pre_hash_hex) + break + chalresponse=reply_decrypt[1] + if chalresponse=="00"*20: + #todo: abort tx + _logger.info("Abort transaction: rejected by 2FA!") + break + chalresponse= list(bytes.fromhex(chalresponse)) + else: + chalresponse= None + + # sign tx + (tx_sig, sw1, sw2) = client.cc.card_sign_transaction(keynbr, tx_hash, chalresponse) + #_logger.info(f"sign_transaction(): sig= {bytearray(tx_sig).hex()}") #debugSatochip + #todo: check sw1sw2 for error (0x9c0b if wrong challenge-response) + # enforce low-S signature (BIP 62) + tx_sig = bytes(tx_sig) #bytearray(tx_sig) + r,s= get_r_and_s_from_der_sig(tx_sig) + if s > CURVE_ORDER//2: + s = CURVE_ORDER - s + tx_sig=der_sig_from_r_and_s(r, s) + #update tx with signature + tx_sig = tx_sig.hex()+'01' + #tx.add_signature_to_txin(i,j,tx_sig) + tx.add_signature_to_txin(txin_idx=i, + signing_pubkey=my_pubkey.hex(), + sig=tx_sig) + # end of for loop + + _logger.info(f"Tx is complete: {str(tx.is_complete())}") + tx.raw = tx.serialize() + return + + def show_address(self, sequence, txin_type): + _logger.info(f'[Satochip_KeyStore] show_address(): todo!') + return + + +class SatochipPlugin(HW_PluginBase): + libraries_available= True + minimum_library = (0, 0, 0) + keystore_class= Satochip_KeyStore + DEVICE_IDS= [ + (SATOCHIP_VID, SATOCHIP_PID) + ] + SUPPORTED_XTYPES = ('standard', 'p2wpkh-p2sh', 'p2wpkh', 'p2wsh-p2sh', 'p2wsh') + + def __init__(self, parent, config, name): + + _logger.info(f"[SatochipPlugin] init()")#debugSatochip + HW_PluginBase.__init__(self, parent, config, name) + + self.device_manager().register_enumerate_func(self.detect_smartcard_reader) + + def get_library_version(self): + return '0.0.1' + + def detect_smartcard_reader(self): + _logger.info(f"[SatochipPlugin] detect_smartcard_reader")#debugSatochip + self.cardtype = AnyCardType() + try: + cardrequest = CardRequest(timeout=0.1, cardType=self.cardtype) + cardservice = cardrequest.waitforcard() + return [Device(path="/satochip", + interface_number=-1, + id_="/satochip", + product_key=(SATOCHIP_VID,SATOCHIP_PID), + usage_page=0, + transport_ui_string='ccid')] + except CardRequestTimeoutException: + _logger.info(f'time-out: no card found') + return [] + except Exception as exc: + _logger.info(f"Error during connection:{str(exc)}") + return [] + return [] + + + def create_client(self, device, handler): + _logger.info(f"[SatochipPlugin] create_client()")#debugSatochip + + if handler: + self.handler = handler + + try: + rv = SatochipClient(self, handler) + return rv + except Exception as e: + _logger.exception(f"[SatochipPlugin] create_client() exception: {str(e)}") + return None + + def setup_device(self, device_info, wizard, purpose): + _logger.info(f"[SatochipPlugin] setup_device()")#debugSatochip + + #TODO: use scan_and_create_client_for_device? + devmgr = self.device_manager() + device_id = device_info.device.id_ + client = devmgr.client_by_id(device_id) + if client is None: + raise Exception(_('Failed to create a client for this device.') + '\n' + + _('Make sure it is in the correct state.')) + client.handler = self.create_handler(wizard) + + # check setup + while(client.cc.card_present): + + # check that card is indeed a Satochip + if (client.cc.card_type != "Satochip"): + raise Exception(_('Failed to create a client for this device.') + '\n' + + _('Inserted card is not a Satochip!')) + + (response, sw1, sw2, d)=client.cc.card_get_status() + + # check version + if (client.cc.setup_done): + v_supported= SATOCHIP_PROTOCOL_VERSION + v_applet= d["protocol_version"] + _logger.info(f"[SatochipPlugin] setup_device(): Satochip version={hex(v_applet)} Electrum supported version= {hex(v_supported)}")#debugSatochip + if (v_supported not multisig, must be bip32 + if type(wallet) is not Standard_Wallet: + keystore.handler.show_error(_('This function is only available for standard wallets when using {}.').format(self.device)) + return + + sequence = wallet.get_address_index(address) + txin_type = wallet.get_txin_type(address) + keystore.show_address(sequence, txin_type) + + # create/restore seed during satochip initialization + def choose_seed(self, wizard): + title = _('Create or restore') + message = _('Do you want to create a new seed, or to restore a wallet using an existing seed?') + choices = [ + ('create_seed', _('Create a new BIP39 seed')), + ('restore_from_seed', _('I already have a BIP39 seed')), + ] + wizard.choice_dialog(title=title, message=message, choices=choices, run_next=wizard.run) + #create seed + def create_seed(self, wizard): + wizard.seed_type = 'bip39' + wizard.opt_bip39 = True + wizard.opt_slip39 = False + #seed = mnemonic.Mnemonic('en').make_seed(wizard.seed_type) # Electrum seed + seed= self.to_bip39_mnemonic(128) + f = lambda x: self.request_passphrase(wizard, seed, x) + wizard.show_seed_dialog(run_next=f, seed_text=seed) + + def request_passphrase(self, wizard, seed, opt_passphrase): + if opt_passphrase: + f = lambda x: self.confirm_seed(wizard, seed, x) + wizard.passphrase_dialog(run_next=f) + else: + wizard.run('confirm_seed', seed, '') + + def confirm_seed(self, wizard, seed, passphrase): + f = lambda x: self.confirm_passphrase(wizard, seed, passphrase) + wizard.confirm_seed_dialog(run_next=f, seed='', test=lambda x: x==seed) + + def confirm_passphrase(self, wizard, seed, passphrase): + f = lambda x: self.derive_bip39_seed(seed, x) #f = lambda x: self.derive_bip32_seed(seed, x) + if passphrase: + title = _('Confirm Seed Extension') + message = '\n'.join([ + _('Your seed extension must be saved together with your seed.'), + _('Please type it here.'), + ]) + wizard.line_dialog(run_next=f, title=title, message=message, default='', test=lambda x: x==passphrase) + else: + f('') + + #restore from seed + def restore_from_seed(self, wizard): + wizard.opt_bip39 = True + wizard.opt_slip39 = False + wizard.opt_ext = True + test = mnemonic.is_seed + f= lambda seed, seed_type, is_ext: self.on_restore_seed(wizard, seed, seed_type, is_ext) + wizard.restore_seed_dialog(run_next=f, test=test) + + + def on_restore_seed(self, wizard, seed, seed_type, is_ext): + wizard.seed_type = seed_type + + if wizard.seed_type == 'bip39': + f = lambda passphrase: self.derive_bip39_seed(seed, passphrase) + wizard.passphrase_dialog(run_next=f, is_restoring=True) if is_ext else f('') + elif wizard.seed_type== 'electrum': # in ['standard', 'segwit']: + # warning message as Electrum seed on hardware is not standard and incompatible with other hw + message= ' '.join([ + _("You are trying to import an Electrum seed to a Satochip hardware wallet."), + _("\n\nElectrum seeds are not compatible with the BIP39 seeds typically used in hardware wallets."), + _("This means you may have difficulty to import this seed in another wallet in the future."), + _("\n\nProceed with caution! If you are not sure, click on 'Back', enable BIP39 in 'Options' and introduce a BIP39 seed instead."), + _("You can also generate a new random BIP39 seed by clicking on 'Back' twice.") + ]) + wizard.confirm_dialog('Warning', message, run_next=lambda x: None) + f = lambda passphrase: self.derive_bip32_seed(seed, passphrase) + wizard.passphrase_dialog(run_next=f, is_restoring=True) if is_ext else f('') + elif wizard.seed_type == 'old': + raise Exception('Unsupported seed type', wizard.seed_type) + elif mnemonic.is_any_2fa_seed_type(wizard.seed_type): + raise Exception('Unsupported seed type', wizard.seed_type) + else: + raise Exception('Unknown seed type', wizard.seed_type) + + def derive_bip32_seed(self, seed, passphrase): + self.bip32_seed= mnemonic.Mnemonic('en').mnemonic_to_seed(seed, passphrase) + + def derive_bip39_seed(self, seed, passphrase): + self.bip32_seed=bip39_to_seed(seed, passphrase) + + # based on https://github.com/trezor/python-mnemonic/blob/master/mnemonic/mnemonic.py + def to_bip39_mnemonic(self, strength: int) -> str: + wordlist = mnemonic.Wordlist.from_file("english.txt") + data= urandom(strength // 8) + if len(data) not in [16, 20, 24, 28, 32]: + raise ValueError( + "Data length should be one of the following: [16, 20, 24, 28, 32], but it is not (%d)." + % len(data) + ) + h = hashlib.sha256(data).hexdigest() + b = ( + bin(int.from_bytes(data, byteorder="big"))[2:].zfill(len(data) * 8) + + bin(int(h, 16))[2:].zfill(256)[: len(data) * 8 // 32] + ) + result = [] + for i in range(len(b) // 11): + idx = int(b[i * 11 : (i + 1) * 11], 2) + result.append(wordlist[idx]) + result_phrase = " ".join(result) + return result_phrase + +