diff --git a/contrib/build-linux/appimage/Dockerfile b/contrib/build-linux/appimage/Dockerfile index c93f73513f87..0d5e5361fbf6 100644 --- a/contrib/build-linux/appimage/Dockerfile +++ b/contrib/build-linux/appimage/Dockerfile @@ -33,6 +33,8 @@ RUN apt-get update -q && \ zlib1g-dev \ libffi-dev \ libncurses5-dev \ + libpcsclite-dev \ + swig \ libncurses5 \ libtinfo-dev \ libtinfo5 \ diff --git a/contrib/build-wine/build-electrum-git.sh b/contrib/build-wine/build-electrum-git.sh index d772ffc12c5f..becc173e5691 100755 --- a/contrib/build-wine/build-electrum-git.sh +++ b/contrib/build-wine/build-electrum-git.sh @@ -43,7 +43,7 @@ $WINE_PYTHON -m pip install --no-build-isolation --no-dependencies --no-warn-scr --cache-dir "$WINE_PIP_CACHE_DIR" -r "$CONTRIB"/deterministic-build/requirements-binaries.txt info "Installing hardware wallet requirements..." $WINE_PYTHON -m pip install --no-build-isolation --no-dependencies --no-warn-script-location \ - --no-binary :all: --only-binary cffi,cryptography,hidapi \ + --no-binary :all: --only-binary cffi,cryptography,hidapi,pyscard \ --cache-dir "$WINE_PIP_CACHE_DIR" -r "$CONTRIB"/deterministic-build/requirements-hw.txt pushd $WINEPREFIX/drive_c/electrum diff --git a/contrib/build-wine/deterministic.spec b/contrib/build-wine/deterministic.spec index c133de4e6181..d5e6c8da8d30 100644 --- a/contrib/build-wine/deterministic.spec +++ b/contrib/build-wine/deterministic.spec @@ -21,6 +21,7 @@ hiddenimports += collect_submodules('keepkeylib') hiddenimports += collect_submodules('websocket') hiddenimports += collect_submodules('ckcc') hiddenimports += collect_submodules('bitbox02') +hiddenimports += collect_submodules('smartcard') # satochip hiddenimports += ['electrum.plugins.jade.jade'] hiddenimports += ['electrum.plugins.jade.jadepy.jade'] hiddenimports += ['PyQt5.QtPrintSupport'] # needed by Revealer @@ -50,6 +51,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+'electrum/plugins/jade/qt.py', #home+'packages/requests/utils.py' ], diff --git a/contrib/deterministic-build/requirements-hw.txt b/contrib/deterministic-build/requirements-hw.txt index 88695d80906a..04e71d26986b 100644 --- a/contrib/deterministic-build/requirements-hw.txt +++ b/contrib/deterministic-build/requirements-hw.txt @@ -220,6 +220,22 @@ pyaes==1.6.1 \ pycparser==2.21 \ --hash=sha256:8ee45429555515e1f6b185e78100aea234072576aa43ab53aefcae078162fca9 \ --hash=sha256:e644fdec12f7872f86c58ff790da456218b10f863970249516d60a5eaca77206 +pysatochip==0.12.5 \ + --hash=sha256:7adc031bbf4f0f6498ad7a2fddc20c9fac8b2e0531556574a32b7ad49042ea05 \ + --hash=sha256:ce028b718b7a860ed873df0b49122f11bdc66b1437b424ced0fa34b277a4facb +pyscard==2.0.7 \ + --hash=sha256:278054525fa75fbe8b10460d87edcd03a70ad94d688b11345e4739987f85c1bf \ + --hash=sha256:8e37b697327e8dc4848c481428d1cbd10b7ae2ce037bc799e5b8bbd2fc3ab5ed \ + --hash=sha256:beacdcdc3d1516e195f7a38ec3966c5d4df7390c8f036cb41f6fef72bc5cc646 \ + --hash=sha256:a2266345bd387854298153264bff8b74f494581880a76e3e8679460c1b090fab \ + --hash=sha256:06666a597e1293421fa90e0d4fc2418add447b10b7dc85f49b3cafc23480f046 \ + --hash=sha256:5a5865675be294c8d91f22dc91e7d897c4138881e5295fb6b2cd821f7c0389d9 \ + --hash=sha256:39e030c47878b37ae08038a917959357be6468da52e8b144e84ffc659f50e6e2 \ + --hash=sha256:2d4bdc1f4e0e6c46e417ac1bc9d5990f7cfb24a080e890d453781405f7bd29dc \ + --hash=sha256:da70aa5b7be5868b88cdb6d4a419d2791b6165beeb90cd01d2748033302a0f43 \ + --hash=sha256:59a466ab7ae20188dd197664b9ca1ea9524d115a5aa5b16b575a6b772cdcb73c \ + --hash=sha256:f704ad40dc40306e1c0981941789518ab16aa1f84443b1d52ec0264884092b3b \ + --hash=sha256:a0c5edbedafba62c68160884f878d9f53996d7219a3fc11b1cea6bab59c7f34a pyserial==3.5 \ --hash=sha256:3c77e014170dfffbd816e6ffc205e9842efb10be9f58ec16d3e8675b4925cddb \ --hash=sha256:c4451db6ba391ca6ca299fb3ec7bae67a5c55dde170964c7a14ceefec02f2cf0 diff --git a/contrib/osx/make_osx.sh b/contrib/osx/make_osx.sh index 254eb3ccb409..2ae535164ebd 100755 --- a/contrib/osx/make_osx.sh +++ b/contrib/osx/make_osx.sh @@ -118,7 +118,7 @@ python3 -m pip install --no-build-isolation --no-dependencies --no-binary :all: || fail "Could not install build dependencies (mac)" 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 info "Building PyInstaller." PYINSTALLER_REPO="https://github.com/pyinstaller/pyinstaller.git" diff --git a/contrib/osx/osx.spec b/contrib/osx/osx.spec index b4fb53df9e53..bd01e900e8de 100644 --- a/contrib/osx/osx.spec +++ b/contrib/osx/osx.spec @@ -28,6 +28,7 @@ hiddenimports += collect_submodules('keepkeylib') hiddenimports += collect_submodules('websocket') hiddenimports += collect_submodules('ckcc') hiddenimports += collect_submodules('bitbox02') +hiddenimports += collect_submodules('smartcard') # Satochip hiddenimports += ['electrum.plugins.jade.jade'] hiddenimports += ['electrum.plugins.jade.jadepy.jade'] hiddenimports += ['PyQt5.QtPrintSupport'] # needed by Revealer @@ -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 + "electrum/libusb-1.0.dylib", ".")] @@ -74,6 +76,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', electrum+'electrum/plugins/jade/qt.py', ], binaries=binaries, diff --git a/contrib/requirements/requirements-hw.txt b/contrib/requirements/requirements-hw.txt index e0c0efbf2fea..28e7231a843b 100644 --- a/contrib/requirements/requirements-hw.txt +++ b/contrib/requirements/requirements-hw.txt @@ -21,6 +21,10 @@ ckcc-protocol>=0.7.7 # device plugin: bitbox02 bitbox02>=6.2.0 +# device plugin: satochip +pyscard>=1.9.9 +pysatochip==0.12.5 + # device plugin: jade cbor>=1.0.0,<2.0.0 pyserial>=3.5.0,<4.0.0 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/gui/qt/qrcodewidget.py b/electrum/gui/qt/qrcodewidget.py index 80c1f4364887..26c35a0e85de 100644 --- a/electrum/gui/qt/qrcodewidget.py +++ b/electrum/gui/qt/qrcodewidget.py @@ -54,13 +54,12 @@ def setData(self, data): self.update() - def paintEvent(self, e): if not self.data: return black = QColor(0, 0, 0, 255) - grey = QColor(196, 196, 196, 255) + grey = QColor(196, 196, 196, 255) white = QColor(255, 255, 255, 255) black_pen = QPen(black) if self.isEnabled() else QPen(grey) black_pen.setJoinStyle(Qt.MiterJoin) @@ -128,6 +127,7 @@ def __init__( show_text=False, help_text=None, show_copy_text_btn=False, + show_cancel_btn=False, config: SimpleConfig, ): WindowModalDialog.__init__(self, parent, title) @@ -182,10 +182,21 @@ def copy_text_to_clipboard(): hbox.addWidget(b) b.clicked.connect(print_qr) - b = QPushButton(_("Close")) - hbox.addWidget(b) - b.clicked.connect(self.accept) - b.setDefault(True) + if show_cancel_btn: + b = QPushButton(_("Ok")) + hbox.addWidget(b) + b.clicked.connect(self.accept) + b.setDefault(True) + + b = QPushButton(_("Cancel")) + hbox.addWidget(b) + b.clicked.connect(self.reject) + b.setDefault(True) + else: + b = QPushButton(_("Close")) + hbox.addWidget(b) + b.clicked.connect(self.accept) + b.setDefault(True) vbox.addLayout(hbox) self.setLayout(vbox) diff --git a/electrum/plugins/satochip/README.rst b/electrum/plugins/satochip/README.rst new file mode 100644 index 000000000000..2e2cb51e0b70 --- /dev/null +++ b/electrum/plugins/satochip/README.rst @@ -0,0 +1,83 @@ +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 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..05d909105193 --- /dev/null +++ b/electrum/plugins/satochip/__init__.py @@ -0,0 +1,6 @@ + +fullname = 'Satochip Wallet' +description = 'Provides support for Satochip hardware wallet' +requires = [('satochip', 'github.com/Toporin/pysatochip')] +registers_keystore = ('hardware', 'satochip', "Satochip wallet") +available_for = ['qt'] diff --git a/electrum/plugins/satochip/qt.py b/electrum/plugins/satochip/qt.py new file mode 100644 index 000000000000..47b15c7220a2 --- /dev/null +++ b/electrum/plugins/satochip/qt.py @@ -0,0 +1,631 @@ +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, QHBoxLayout, QWidget, QGridLayout, QComboBox, QLineEdit, QCheckBox, QTabWidget) +from functools import partial +from os import urandom +import textwrap + +#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, SERVER_LIST +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" + + 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. + 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') + +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() + self.config = devmgr.config + handler = keystore.handler + self.thread = thread = keystore.thread + self.window = window + + 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) + + change_2FA_server_btn = QPushButton('Select 2FA server') + def _change_2FA_server(): + thread.add(connect_and_doit, on_success=self.change_2FA_server) + change_2FA_server_btn.clicked.connect(_change_2FA_server) + + 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(change_2FA_server_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!") + self.window.show_message(msg) + else: + msg= _("Failed to change PIN!") + self.window.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 is 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 + + #do challenge-response with 2FA device... + self.window.show_message('2FA request sent! Approve or reject request on your second device.') + server_2FA = self.config.get("satochip_2FA_server", default= SERVER_LIST[0]) + Satochip2FA.do_challenge_response(d, server_name= server_2FA) + # 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.") + self.window.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)})") + self.window.show_message(msg) + #to do: close client? + else: + msg= _(f"Failed to reset seed with error code: {hex(256*sw1+sw2)}") + self.window.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: + 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, show_cancel_btn=True, config=self.config) + result=d.exec_() # result should be 0 or 1 + if (result==1): + # 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)}") + self.window.show_error(f'Unable to setup 2FA with error code: {hex(256*sw1+sw2)}') + else: + self.window.show_message("2FA enabled successfully!") + else: + self.window.show_message("2FA cancelled by user!") + return + except Exception as e: + _logger.info(f"SatochipPlugin: setup 2FA error: {e}") + self.window.show_error(f'Unable to setup 2FA with error code: {e}') + return + + 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 + + #do challenge-response with 2FA device... + self.window.show_message('2FA request sent! Approve or reject request on your second device.') + server_2FA = self.config.get("satochip_2FA_server", default= SERVER_LIST[0]) + Satochip2FA.do_challenge_response(d, server_name= server_2FA) + # 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 + self.window.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)})") + self.window.show_error(msg) + else: + msg= _(f"Failed to reset 2FA with error code: {hex(256*sw1+sw2)}") + self.window.show_error(msg) + else: + msg= _(f"2FA is already disabled!") + self.window.show_error(msg) + + def change_2FA_server(self, client): + _logger.info("in change_2FA_server") + help_txt="Select 2FA server in the list:" + option_name= "satochip_2FA_server" + options= SERVER_LIST #["server1", "server2", "server3"] + title= "Select 2FA server" + d = SelectOptionsDialog(option_name = option_name, options = options, parent=None, title=title, help_text=help_txt, config=self.config) + result=d.exec_() # result should be 0 or 1 + + def verify_card(self, client): + is_authentic, txt_ca, txt_subca, txt_device, txt_error = self.card_verify_authenticity(client) + + # wrap data for better display + tmp = "" + for line in txt_ca.splitlines(): + tmp += textwrap.fill(line, 120, subsequent_indent="\t") + "\n" + txt_ca = tmp + tmp = "" + for line in txt_subca.splitlines(): + tmp += textwrap.fill(line, 120, subsequent_indent="\t") + "\n" + txt_subca = tmp + tmp = "" + for line in txt_device.splitlines(): + tmp += textwrap.fill(line, 120, subsequent_indent="\t") + "\n" + txt_device = tmp + + if is_authentic: + txt_result= 'Device authenticated successfully!' + 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.']) + d = DeviceCertificateDialog( + parent=None, + title= "Satochip certificate chain", + is_authentic = is_authentic, + txt_summary = txt_result, + txt_ca = txt_ca, + txt_subca = txt_subca, + txt_device = txt_device, + ) + result=d.exec_() + + + 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: + self.window.show_message(_("Operation aborted by user!")) + return + (response, sw1, sw2)= client.cc.card_set_label(label) + if (sw1==0x90 and sw2==0x00): + self.window.show_message(_("Card label changed successfully!")) + elif (sw1==0x6D and sw2==0x00): + self.window.show_error(_("Error: card does not support label!")) # starts with satochip v0.12 + else: + self.window.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: + self.window.show_error(_("Card label should not be longer than 64 chars!")) + + +class SelectOptionsDialog(WindowModalDialog): + + def __init__( + self, + *, + option_name, + options=None, + parent=None, + title="", + help_text=None, + config: SimpleConfig, + ): + WindowModalDialog.__init__(self, parent, title) + self.config = config + + vbox = QVBoxLayout() + if help_text: + text_label = WWLabel() + text_label.setText(help_text) + vbox.addWidget(text_label) + + def set_option(): + _logger.info(f"New 2FA server: {options_combo.currentText()}") + # save in config + config.set_key(option_name, options_combo.currentText(), save=True) + _logger.info("config changed!") + + default= config.get(option_name, default= SERVER_LIST[0]) + options_combo = QComboBox() + options_combo.addItems(options) + options_combo.setCurrentText(default) + options_combo.currentIndexChanged.connect(set_option) + vbox.addWidget(options_combo) + + hbox = QHBoxLayout() + hbox.addStretch(1) + + b = QPushButton(_("Ok")) + hbox.addWidget(b) + b.clicked.connect(self.accept) + b.setDefault(True) + + vbox.addLayout(hbox) + self.setLayout(vbox) + + # note: the word-wrap on the text_label is causing layout sizing issues. + # see https://stackoverflow.com/a/25661985 and https://bugreports.qt.io/browse/QTBUG-37673 + # workaround: + self.setMinimumSize(self.sizeHint()) + +class DeviceCertificateDialog(WindowModalDialog): + + def __init__( + self, + *, + parent=None, + title="", + is_authentic, + txt_summary = "", + txt_ca = "", + txt_subca = "", + txt_device = "", + ): + WindowModalDialog.__init__(self, parent, title) + + + #super(QWidget, self).__init__(parent) + self.layout = QVBoxLayout(self) + + # add summary text + self.summary = QLabel(txt_summary) + if is_authentic: + self.summary.setStyleSheet('color: green') + else: + self.summary.setStyleSheet('color: red') + self.summary.setWordWrap(True) + self.layout.addWidget(self.summary) + + # Initialize tab screen + self.tabs = QTabWidget() + self.tab1 = QWidget() + self.tab2 = QWidget() + self.tab3 = QWidget() + self.tabs.resize(300,200) + + # Add tabs + self.tabs.addTab(self.tab1,"RootCA") + self.tabs.addTab(self.tab2,"SubCA") + self.tabs.addTab(self.tab3,"Device") + + # Create first tab + self.tab1.layout = QVBoxLayout(self) + self.cert1 = QLabel(txt_ca) + self.cert1.setWordWrap(True) + self.tab1.layout.addWidget(self.cert1) + self.tab1.setLayout(self.tab1.layout) + + # Create second tab + self.tab2.layout = QVBoxLayout(self) + self.cert2 = QLabel(txt_subca) + self.cert2.setWordWrap(True) + self.tab2.layout.addWidget(self.cert2) + self.tab2.setLayout(self.tab2.layout) + + # Create third tab + self.tab3.layout = QVBoxLayout(self) + self.cert3 = QLabel(txt_device) + self.cert3.setWordWrap(True) + self.tab3.layout.addWidget(self.cert3) + self.tab3.setLayout(self.tab3.layout) + + # Add tabs to widget + self.layout.addWidget(self.tabs) + self.setLayout(self.layout) diff --git a/electrum/plugins/satochip/satochip.py b/electrum/plugins/satochip/satochip.py new file mode 100644 index 000000000000..4a4073e2090c --- /dev/null +++ b/electrum/plugins/satochip/satochip.py @@ -0,0 +1,713 @@ +from os import urandom +import hashlib + +#electrum +from electrum import mnemonic +from electrum import constants +from electrum import descriptor +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, bip39_is_checksum_valid +from electrum.transaction import Transaction +from electrum.wallet import Standard_Wallet +from electrum.util import bfh, 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_strpath_to_intpath, convert_bip32_intpath_to_strpath +from electrum.logging import get_logger +from electrum.simple_config import SimpleConfig +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, SERVER_LIST +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): + intPath= convert_bip32_strpath_to_intpath(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 + + 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) + 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 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 is 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) + #do challenge-response with 2FA device... + hmac= self.do_challenge_response(msg) + hmac= bytes.fromhex(hmac) + 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 = sha256d(bfh(txOutputs)).hex() + _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 + + desc = txin.script_descriptor + assert desc + script_type = desc.to_legacy_electrum_script_type() + + _logger.info(f"In sign_transaction(): input= {str(i)} - input[type]: {script_type}") #debugSatochip + if txin.is_coinbase_input(): + self.give_error("Coinbase not supported") # should never happen + + if script_type in ['p2sh']: + p2shTransaction = True + + if script_type in ['p2wpkh', 'p2wsh', 'p2wpkh-p2sh', 'p2wsh-p2sh']: + segwitTransaction = True + + my_pubkey, inputPath = self.find_my_pubkey_in_txinout(txin) + 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':script_type} + else: + msg= {'tx':pre_tx_hex, 'ct':coin_type, 'sw':segwitTransaction} + msg= json.dumps(msg) + + #do challenge-response with 2FA device... + hmac= self.do_challenge_response(msg) + hmac= list(bytes.fromhex(hmac)) + else: + hmac= None + + # sign tx + (tx_sig, sw1, sw2) = client.cc.card_sign_transaction(keynbr, tx_hash, hmac) + # check sw1sw2 for error (0x9c0b if wrong challenge-response) + if sw1 != 0x90 or sw2 != 0x00: + self.give_error(f"Satochip failed to sign transaction with code {hex(256*sw1+sw2)}") + + # 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 + + def do_challenge_response(self, msg): + client = self.get_client() + (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("id_2FA: "+id_2FA) + + reply_encrypt= None + hmac= 20*"00" # default response (reject) + status_msg="" + + # get server_2FA from config from existing object + server_2FA = self.plugin.config.get("satochip_2FA_server", default= SERVER_LIST[0]) + status_msg += f"2FA request sent to '{server_2FA}' \nApprove or reject request on your second device." + self.handler.show_message(status_msg) + try: + Satochip2FA.do_challenge_response(d, server_name= server_2FA) + # decrypt and parse reply to extract challenge response + reply_encrypt= d['reply_encrypt'] + except Exception as e: + status_msg += f"\nFailed to contact cosigner! \n=> Select another 2FA server in Satochip settings\n\n" + self.handler.show_message(status_msg) + if reply_encrypt is not None: + reply_decrypt= client.cc.card_crypt_transaction_2FA(reply_encrypt, False) + _logger.info("challenge:response= "+ reply_decrypt) + reply_decrypt= reply_decrypt.split(":") + hmac= reply_decrypt[1] + return hmac # return a hexstring + + +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()") + 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 = _('restore from BIP39 seed') + message = _('You can restore a Satochip wallet using an existing BIP39 seed. \nIt is not possible to generate a new BIP39 seed from Electrum. \nTo import a new seed in Satochip, use the Satochip-Bridge application instead then restart Electrum.') + 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) + + # def choose_seed_legacy(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): + (is_checksum_valid, is_wordlist_valid) = bip39_is_checksum_valid(seed) + if is_checksum_valid: + self.bip32_seed=bip39_to_seed(seed, passphrase) + else: + raise Exception('Wrong BIP39 mnemonic format!') + + # # 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 + +