diff --git a/contrib/build-linux/appimage/Dockerfile b/contrib/build-linux/appimage/Dockerfile index cfedc1782bd1..685afc6c0710 100644 --- a/contrib/build-linux/appimage/Dockerfile +++ b/contrib/build-linux/appimage/Dockerfile @@ -34,6 +34,8 @@ RUN apt-get update -q && \ libffi-dev \ libncurses5-dev \ libncurses5 \ + libpcsclite-dev \ + swig \ libtinfo-dev \ libtinfo5 \ libsqlite3-dev \ diff --git a/contrib/build-wine/build-electrum-git.sh b/contrib/build-wine/build-electrum-git.sh index 10c1ac1c6e08..0acdfc72f673 100755 --- a/contrib/build-wine/build-electrum-git.sh +++ b/contrib/build-wine/build-electrum-git.sh @@ -45,7 +45,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 "$PROJECT_ROOT" diff --git a/contrib/build-wine/pyinstaller.spec b/contrib/build-wine/pyinstaller.spec index 09f1655fd748..983b12da57fb 100644 --- a/contrib/build-wine/pyinstaller.spec +++ b/contrib/build-wine/pyinstaller.spec @@ -48,6 +48,7 @@ datas += collect_data_files('trezorlib') # TODO is this needed? and same questi datas += collect_data_files('safetlib') datas += collect_data_files('ckcc') datas += collect_data_files('bitbox02') +datas += collect_data_files('pysatochip') # some deps rely on importlib metadata datas += copy_metadata('slip10') # from trezor->slip10 diff --git a/contrib/deterministic-build/requirements-hw.txt b/contrib/deterministic-build/requirements-hw.txt index 7b9c9fe4a677..0cc02d845c30 100644 --- a/contrib/deterministic-build/requirements-hw.txt +++ b/contrib/deterministic-build/requirements-hw.txt @@ -400,6 +400,28 @@ pyaes==1.6.1 \ pycparser==2.22 \ --hash=sha256:491c8be9c040f5390f5bf44a5b07752bd07f56edf992381b05c701439eec10f6 \ --hash=sha256:c3702b6d3dd8c7abc1afa565d7e63d53a1d0bd86cdc24edd75470f4de499cfcc +pysatochip==0.12.6 \ + --hash=sha256:f751ae93ea784dc3ef77508da56df6222a195d8f7ead53f87d29ea84c7bc8f90 \ + --hash=sha256:d1255c5126c0c76b86f5eb1289b1cdc0b14a6f1265c82a63ce07074f6ccb2903 +pyscard==2.2.2 \ + --hash=sha256:e658f240276c12f836c28159120da499b0593cf6178469095e09a4ada869b9c6 \ + --hash=sha256:81e3d3ae40cf47725b5835970433197c4a6a9030af9f08ca74b4ddaf08c339c4 \ + --hash=sha256:8f545e55918f6e44eb3dbc6abb6c290efa3f992872e94a4af1ee43f6ecbd160d \ + --hash=sha256:44eaafcec0b0bab0344be5209b1456bc2f34a15303e0ae584e2fe6abbe67f777 \ + --hash=sha256:77577e6c847c253c642b880087616b08223b74e3942f07d3a6019f7067fe2369 \ + --hash=sha256:4e3642fc5b800b4e7ff88742eef01cbb8bd3b3d0f951f56f3fa4fca51ab41bf2 \ + --hash=sha256:88e277d809fce5fc29e737ecd135f3871a0813f7d8b27fad5537ed9fa4442a20 \ + --hash=sha256:535d03d04477ef0cb9812ca0fad4b71fee6984b30ad72f05f644b6e4e743ccd5 \ + --hash=sha256:427164199171d26c565db0d2a577c491253dfdf160408dcd605bd0e8f4f01060 \ + --hash=sha256:9ca0f5f3e38b753539f3c65335536dea8a20ca4c660b320f87368070e7febdbc \ + --hash=sha256:1480fc9e760487e4fe18b668647cd88bb4d0fd94e075bba6a00a582a93b6def7 \ + --hash=sha256:ab1a875666330880ddecacbadc8193dc5c6eb799329e3d6e99281a1de113a4cd \ + --hash=sha256:48e8e2e004ef105b488422c9943eca5f6f38648a8e377a94017fa07203d05b4a \ + --hash=sha256:b634d762de8058a039cf013dff3946e5eed6ee5d06fd18fe100ecd6af3eb6c35 \ + --hash=sha256:ac9eaf1988c9c563a5bf5d54b8c6058ef267a1ad5755d353b9d5a68ec5b2210b \ + --hash=sha256:17b50d6aba530e9ef9a1a87d2761d52f378453bbb1735fc0bbdcaadc9597fef1 \ + --hash=sha256:3975dc4527996552d9317cc8b3e6a9e7e98c85616c9dc4a34152f622c3c0ffd3 \ + --hash=sha256:c77481fb86f4a17bc441d7b36551c1d36a9d3a48c4bb30ab8118886e6f275081 pyserial==3.5 \ --hash=sha256:3c77e014170dfffbd816e6ffc205e9842efb10be9f58ec16d3e8675b4925cddb \ --hash=sha256:c4451db6ba391ca6ca299fb3ec7bae67a5c55dde170964c7a14ceefec02f2cf0 @@ -435,4 +457,4 @@ urllib3==1.26.20 \ --hash=sha256:40c2dc0c681e47eb8f90e7e27bf6ff7df2e677421fd46756da1161c39ca70d32 wheel==0.45.1 \ --hash=sha256:661e1abd9198507b1409a20c02106d9670b2576e916d58f520316666abca6729 \ - --hash=sha256:708e7481cc80179af0e556bbf0cc00b8444c7321e2700b8d8580231d13017248 \ No newline at end of file + --hash=sha256:708e7481cc80179af0e556bbf0cc00b8444c7321e2700b8d8580231d13017248 diff --git a/contrib/osx/make_osx.sh b/contrib/osx/make_osx.sh index 80dd707fe857..ff9d57e6a778 100755 --- a/contrib/osx/make_osx.sh +++ b/contrib/osx/make_osx.sh @@ -85,7 +85,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/pyinstaller.spec b/contrib/osx/pyinstaller.spec index 5cd612eda437..24d95001a1e2 100644 --- a/contrib/osx/pyinstaller.spec +++ b/contrib/osx/pyinstaller.spec @@ -51,6 +51,7 @@ datas += collect_data_files('trezorlib') # TODO is this needed? and same questi datas += collect_data_files('safetlib') datas += collect_data_files('ckcc') datas += collect_data_files('bitbox02') +datas += collect_data_files('pysatochip') # some deps rely on importlib metadata datas += copy_metadata('slip10') # from trezor->slip10 diff --git a/contrib/requirements/requirements-hw.txt b/contrib/requirements/requirements-hw.txt index 5a117147d35f..8a93fcea1a0c 100644 --- a/contrib/requirements/requirements-hw.txt +++ b/contrib/requirements/requirements-hw.txt @@ -23,6 +23,10 @@ ckcc-protocol>=0.7.7 # device plugin: bitbox02 bitbox02>=6.2.0 +# device plugin: satochip +pyscard>=2.0.7 +pysatochip==0.12.6 + # device plugin: jade cbor2>=5.4.6,<6.0.0 pyserial>=3.5.0,<4.0.0 diff --git a/electrum/gui/qt/qrcodewidget.py b/electrum/gui/qt/qrcodewidget.py index 823a9d44b643..55d8f64a0d8e 100644 --- a/electrum/gui/qt/qrcodewidget.py +++ b/electrum/gui/qt/qrcodewidget.py @@ -85,6 +85,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) @@ -138,10 +139,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/payserver/www b/electrum/plugins/payserver/www index bcb6d9ecf75b..bde9d3b5fbf3 160000 --- a/electrum/plugins/payserver/www +++ b/electrum/plugins/payserver/www @@ -1 +1 @@ -Subproject commit bcb6d9ecf75b6785451402068651cfa785b709fa +Subproject commit bde9d3b5fbf34623ca04c14eb6b0db6676c5ec52 diff --git a/electrum/plugins/satochip/README.rst b/electrum/plugins/satochip/README.rst new file mode 100644 index 000000000000..cc14bb9929f9 --- /dev/null +++ b/electrum/plugins/satochip/README.rst @@ -0,0 +1,82 @@ +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..e69de29bb2d1 diff --git a/electrum/plugins/satochip/manifest.json b/electrum/plugins/satochip/manifest.json new file mode 100644 index 000000000000..bf2924397b5f --- /dev/null +++ b/electrum/plugins/satochip/manifest.json @@ -0,0 +1,9 @@ +{ + "name": "satochip", + "fullname": "Satochip Wallet", + "description": "Provides support for Satochip hardware wallet", + "requires": [["pysatochip","pypi.org/project/pysatochip/"]], + "registers_keystore": ["hardware", "satochip", "Satochip wallet"], + "icon":"satochip.png", + "available_for": ["qt"] +} diff --git a/electrum/plugins/satochip/qt.py b/electrum/plugins/satochip/qt.py new file mode 100644 index 000000000000..29af81c095ee --- /dev/null +++ b/electrum/plugins/satochip/qt.py @@ -0,0 +1,1017 @@ +from functools import partial +from os import urandom +import textwrap +import threading + +from PyQt6.QtGui import QPixmap +from PyQt6.QtCore import Qt, pyqtSignal +from PyQt6.QtWidgets import (QPushButton, QLabel, QVBoxLayout, QHBoxLayout, + QWidget, QGridLayout, QComboBox, QLineEdit, QTabWidget) + +from electrum.i18n import _ +from electrum.logging import get_logger +from electrum.util import UserFacingException +from electrum.simple_config import SimpleConfig +from electrum.gui.qt.util import (EnterButton, Buttons, CloseButton, icon_path, + OkButton, CancelButton, WindowModalDialog, WWLabel, PasswordLineEdit) +from electrum.gui.qt.qrcodewidget import QRDialog +from electrum.gui.qt.wizard.wallet import (WCHaveSeed, WCEnterExt, WCScriptAndDerivation, + WCHWUnlock, WCHWXPub, WalletWizardComponent, QENewWalletWizard) +from electrum.plugin import hook +from electrum.hw_wallet.qt import QtHandlerBase, QtPluginBase + +# satochip +from .satochip import SatochipPlugin + +# pysatochip +from pysatochip.CardConnector import UnexpectedSW12Error, CardError, CardNotPresentError, WrongPinError +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!" +) + +MSG_SEED_IMPORT = [ + _("Your Satochip is currently unseeded. "), + _("To use it, you need to import a BIP39 Seed. "), + _("To do so, select BIP39 in the options in the next screen. "), + _("Note that Electrum seeds are not supported by hardware wallets. "), + " ", + _("Optionally, you can also enable a passphrase in the options. "), + _("A passphrase is an optional feature that allows you to extend your seed with additional entropy. "), + _("A passphrase is not a PIN. "), + _("If set, you will need your passphrase along with your BIP39 seed to restore your wallet from a backup. "), +] + + +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) + + @hook + def init_wallet_wizard(self, wizard: 'QENewWalletWizard'): + self.extend_wizard(wizard) + + # insert satochip pages in new wallet wizard + def extend_wizard(self, wizard: 'QENewWalletWizard'): + super().extend_wizard(wizard) + views = { + 'satochip_start': {'gui': WCScriptAndDerivation}, + 'satochip_xpub': {'gui': WCHWXPub}, + 'satochip_not_setup': {'gui': WCSatochipSetupParams}, + 'satochip_do_setup': {'gui': WCSatochipSetup}, + 'satochip_not_seeded': { + 'gui': WCSeedMessage, + 'next': 'satochip_have_seed' + }, + 'satochip_have_seed': { + 'gui': WCHaveSeed, + 'next': lambda d: 'satochip_have_ext' if wizard.wants_ext(d) else 'satochip_import_seed', + 'params': {'seed_options': ['ext', 'bip39']} + }, + 'satochip_have_ext': { + 'gui': WCEnterExt, + 'next': 'satochip_import_seed', + }, + 'satochip_import_seed': { + 'gui': WCSatochipImportSeed, + 'next': 'satochip_success_seed', + }, + 'satochip_success_seed': { + 'gui': WCSeedSuccess, + }, + 'satochip_unlock': {'gui': WCHWUnlock} + } + wizard.navmap_merge(views) + + +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.TextInteractionFlag.LinksAccessibleByMouse) + + grid.addWidget(title, 0, 0, 1, 2, Qt.AlignmentFlag.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.TextInteractionFlag.TextSelectableByMouse | Qt.TextInteractionFlag.TextSelectableByKeyboard) + + grid.addWidget(QLabel(label), y, 0, 1, 1, Qt.AlignmentFlag.AlignRight) + grid.addWidget(widget, y, 1, 1, 1, Qt.AlignmentFlag.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.AlignmentFlag.AlignHCenter) + y += 2 + grid.addWidget(seed_btn, y, 0, 1, 2, Qt.AlignmentFlag.AlignHCenter) + y += 2 + grid.addWidget(set_2FA_btn, y, 0, 1, 2, Qt.AlignmentFlag.AlignHCenter) + y += 2 + grid.addWidget(reset_2FA_btn, y, 0, 1, 2, Qt.AlignmentFlag.AlignHCenter) + y += 2 + grid.addWidget(change_2FA_server_btn, y, 0, 1, 2, Qt.AlignmentFlag.AlignHCenter) + y += 2 + grid.addWidget(verify_card_btn, y, 0, 1, 2, Qt.AlignmentFlag.AlignHCenter) + y += 2 + grid.addWidget(change_card_label_btn, y, 0, 1, 2, Qt.AlignmentFlag.AlignHCenter) + y += 2 + grid.addWidget(CloseButton(self), y, 0, 1, 2, Qt.AlignmentFlag.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!") + try: + is_ok = client.verify_PIN() + if not is_ok: + msg = f"action cancelled by user" + self.window.show_error(msg) + return + except UserFacingException as e: + self.window.show_error(str(e)) + return + + 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) + try: + (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) + except WrongPinError as ex: + msg = ( + f"Failed to change PIN. Wrong PIN! {ex.pin_left} tries remaining!") + self.window.show_error(msg) + except Exception as ex: + self.window.show_error(str(ex)) + + 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: + 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_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(QLineEdit.EchoMode.Password) + 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: + # verify PIN + is_ok = client.verify_PIN() + if not is_ok: + msg = f"action cancelled by user" + self.window.show_error(msg) + return + + 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: + # verify pin + is_ok = client.verify_PIN() + if not is_ok: + msg = f"action cancelled by user" + self.window.show_error(msg) + return + + # 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: + 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): + # verify pin + is_ok = client.verify_PIN() + if not is_ok: + return + + # verify authenticity + 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() + + # todo: add this function in pysatochip + def card_verify_authenticity(self, client): + + cert_pem = txt_error = "" + try: + cert_pem = client.cc.card_export_perso_certificate() + _logger.info('Cert PEM: ' + str(cert_pem)) + except CardError: + 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: + 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 optionaly add a label to your Satochip.\n"), + _("This label must be less than 64 chars long."), + ]) + + # verify pin + is_ok = client.verify_PIN() + if not is_ok: + # msg= f"action cancelled by user" + # self.window.show_error(msg) + return + + # label dialog + label = self.change_card_label_dialog(client, msg) + if label is None: + self.window.show_message(_("Operation aborted by user!")) + return + + # set new label + (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: + # starts with satochip v0.12 + self.window.show_error(_("Error: card does not support label!")) + 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.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) + + +########################## +# Setup PIN wizard # +########################## + +def clean_text(widget): + text = widget.toPlainText().strip() + return ' '.join(text.split()) + + +class SatochipSetupLayout(QVBoxLayout): + validChanged = pyqtSignal([bool], arguments=['valid']) + + def __init__(self, device): + _logger.info("[SatochipSetupLayout] __init__()") + QVBoxLayout.__init__(self) + + vbox = QVBoxLayout() + + # intro + msg_setup = WWLabel( + _("Please take a moment to set up your Satochip. This must be done only once.")) + vbox.addWidget(msg_setup) + + self.pw = PasswordLineEdit() + self.pw.setMinimumWidth(32) + vbox.addWidget(WWLabel("Enter new PIN:")) + vbox.addWidget(self.pw) + self.addLayout(vbox) + + self.pw2 = PasswordLineEdit() + self.pw2.setMinimumWidth(32) + vbox2 = QVBoxLayout() + vbox2.addWidget(WWLabel("Confirm new PIN:")) + vbox2.addWidget(self.pw2) + self.addLayout(vbox2) + + # PIN validation + if self.pw.text() == "" or self.pw.text() is None: + self.validChanged.emit(False) + + def set_enabled(): + is_valid = True + if self.pw.text() != self.pw2.text(): + is_valid = False + + pw_bytes = self.pw.text().encode("utf-8") + if len(pw_bytes) < 4 or len(pw_bytes) > 16: + is_valid = False + + pw2_bytes = self.pw2.text().encode("utf-8") + if len(pw2_bytes) < 4 or len(pw2_bytes) > 16: + is_valid = False + + self.validChanged.emit(is_valid) + + self.pw2.textChanged.connect(set_enabled) + self.pw.textChanged.connect(set_enabled) + + def get_settings(self): + _logger.info("[SatochipSetupLayout] get_settings()") + return self.pw.text() + + +class WCSatochipSetupParams(WalletWizardComponent): + def __init__(self, parent, wizard): + _logger.info("[WCSatochipSetupParams] __init__()") + WalletWizardComponent.__init__( + self, parent, wizard, title=_('Satochip Setup')) + self.plugins = wizard.plugins + self._busy = True + + def on_ready(self): + _logger.info("[WCSatochipSetupParams] on_ready()") + current_cosigner = self.wizard.current_cosigner(self.wizard_data) + _name, _info = current_cosigner['hardware_device'] + self.settings_layout = SatochipSetupLayout(_info.device.id_) + self.settings_layout.validChanged.connect( + self.on_settings_valid_changed) + self.layout().addLayout(self.settings_layout) + self.layout().addStretch(1) + + self.valid = True # debug + self.busy = False + + def on_settings_valid_changed(self, is_valid: bool): + _logger.info( + f"[WCSatochipSetupParams] on_settings_valid_changed() is_valid: {is_valid}") + self.valid = is_valid + + def apply(self): + _logger.info("[WCSatochipSetupParams] apply()") + current_cosigner = self.wizard.current_cosigner(self.wizard_data) + current_cosigner['satochip_setup_settings'] = self.settings_layout.get_settings( + ) + + +class WCSatochipSetup(WalletWizardComponent): + def __init__(self, parent, wizard): + WalletWizardComponent.__init__( + self, parent, wizard, title=_('Satochip Setup')) + _logger.info('[WCSatochipSetup] __init__()') # debugsatochip + + self.plugins = wizard.plugins + self.plugin = self.plugins.get_plugin('satochip') + + self.layout().addWidget(WWLabel('Done')) + + self._busy = True + + def on_ready(self): + _logger.info('[WCSatochipSetup] on_ready()') # debugsatochip + current_cosigner = self.wizard.current_cosigner(self.wizard_data) + settings = current_cosigner['satochip_setup_settings'] + # method = current_cosigner['satochip_init'] + _name, _info = current_cosigner['hardware_device'] + device_id = _info.device.id_ + # debugsatochip + _logger.info(f'[WCSatochipSetup] on_ready() device_id: {device_id}') + + client = self.plugins.device_manager.client_by_id( + device_id, scan_now=False) + client.handler = self.plugin.create_handler(self.wizard) + + def initialize_device_task(settings, device_id, client): + try: + # self.plugin._initialize_device(settings, method, device_id, handler) + self.plugin._setup_device(settings, device_id, client) + _logger.info( + '[WCSatochipSetup] initialize_device_task() Done initialize device') + self.valid = True + self.wizard.requestNext.emit() # triggers Next GUI thread from event loop + except Exception as e: + self.valid = False + self.error = repr(e) + _logger.exception(repr(e)) + finally: + self.busy = False + + t = threading.Thread( + target=initialize_device_task, + args=(settings, device_id, client), + daemon=True) + t.start() + + def apply(self): + pass + +########################## +# Import seed wizard # +########################## + + +class WCSeedMessage(WalletWizardComponent): + def __init__(self, parent, wizard): + WalletWizardComponent.__init__(self, parent, wizard, title=_('Satochip needs a seed')) + + self.layout().addWidget(WWLabel('\n'.join(MSG_SEED_IMPORT))) + self.layout().addStretch(1) + + self._valid = True + + def apply(self): + pass + + +class WCSeedSuccess(WalletWizardComponent): + def __init__(self, parent, wizard): + WalletWizardComponent.__init__(self, parent, wizard, title=_('Success!')) + + def on_ready(self): + w_icon = QLabel() + w_icon.setPixmap(QPixmap(icon_path('confirmed.png')).scaledToWidth(48, mode=Qt.TransformationMode.SmoothTransformation)) + w_icon.setAlignment(Qt.AlignmentFlag.AlignCenter) + label = WWLabel(_("Seed imported successfully!")) + label.setAlignment(Qt.AlignmentFlag.AlignCenter) + self.layout().addStretch(1) + self.layout().addWidget(w_icon) + self.layout().addWidget(label) + self.layout().addStretch(1) + # self.layout().addWidget(WWLabel("Seed imported successfully!") + # self.layout().addStretch(1) + self._valid = True + + def apply(self): + pass + + +class WCSatochipImportSeed(WalletWizardComponent): + def __init__(self, parent, wizard): + WalletWizardComponent.__init__( + self, parent, wizard, title=_('Satochip Setup')) + self.plugins = wizard.plugins + self.plugin = self.plugins.get_plugin('satochip') + + self.layout().addWidget(WWLabel('Done')) + + self._busy = True + + def on_ready(self): + current_cosigner = self.wizard.current_cosigner(self.wizard_data) + + settings = current_cosigner['seed_type'], current_cosigner['seed'], current_cosigner['seed_extra_words'] if current_cosigner['seed_extend'] else '' + + _name, _info = current_cosigner['hardware_device'] + device_id = _info.device.id_ + client = self.plugins.device_manager.client_by_id( + device_id, scan_now=False) + client.handler = self.plugin.create_handler(self.wizard) + + def initialize_device_task(settings, device_id, handler): + try: + self.plugin._import_seed(settings, device_id, handler) + _logger.info( + '[WCSatochipImportSeed] initialize_device_task() Done initialize device') + self.valid = True + self.wizard.requestNext.emit() # triggers Next GUI thread from event loop + except Exception as e: + self.valid = False + self.error = repr(e) + _logger.exception(repr(e)) + finally: + self.busy = False + + t = threading.Thread( + target=initialize_device_task, + args=(settings, device_id, client.handler), + daemon=True) + t.start() + + def apply(self): + pass diff --git a/electrum/plugins/satochip/satochip.png b/electrum/plugins/satochip/satochip.png new file mode 100644 index 000000000000..ee89895e572d Binary files /dev/null and b/electrum/plugins/satochip/satochip.png differ diff --git a/electrum/plugins/satochip/satochip.py b/electrum/plugins/satochip/satochip.py new file mode 100644 index 000000000000..c1a11219c6f7 --- /dev/null +++ b/electrum/plugins/satochip/satochip.py @@ -0,0 +1,747 @@ +from os import urandom +import hashlib +import time +import electrum_ecc as ecc + +# electrum +from electrum import constants +from electrum.bitcoin import var_int +from electrum.i18n import _ +from electrum.plugin import Device, DeviceInfo +from electrum.keystore import Hardware_KeyStore, bip39_to_seed, bip39_is_checksum_valid, ScriptTypeNotSupported +from electrum.transaction import Sighash +from electrum.wallet import Standard_Wallet +from electrum.wizard import NewWalletWizard +from electrum.util import UserFacingException +from electrum.crypto import hash_160, sha256d +from electrum.bip32 import BIP32Node, convert_bip32_strpath_to_intpath, convert_bip32_intpath_to_strpath +from electrum.logging import get_logger + +from electrum.hw_wallet import HW_PluginBase, HardwareClientBase + +# pysatochip +from pysatochip.CardConnector import CardConnector, UninitializedSeedError +from pysatochip.CardConnector import CardNotPresentError, UnexpectedSW12Error, WrongPinError, PinBlockedError, PinRequiredError +from pysatochip.Satochip2FA import Satochip2FA, SERVER_LIST + +# pyscard +from smartcard.Exceptions import 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__()") + 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): + _logger.info(f"SATOCHIP is_initialized()") + + time.sleep(0.3) # let some time to setup communication channel + (response, sw1, sw2, d) = self.cc.card_get_status() + + # if setup is not done, we return None + if not self.cc.setup_done: + _logger.info(f"SATOCHIP is_initialized() None (no setup)") + return None + # if not seeded, return False + if self.cc.setup_done and not self.cc.is_seeded: + _logger.info( + f"SATOCHIP is_initialized() False (PIN set but card not seeded)") + return False + # initialized if pin is set and device is seeded + if self.cc.setup_done and self.cc.is_seeded: + _logger.info( + f"SATOCHIP is_initialized() True (PIN set and card seeded)") + return True + + def get_soft_device_id(self): + return self._soft_device_id + + def label(self): + # TODO - currently empty + return "" + + def device_model_name(self): + return "Satochip" + + def has_usable_connection_with_device(self): + _logger.info(f"has_usable_connection_with_device()") + try: + # (response, sw1, sw2)= self.cc.card_select() #TODO: something else? get ATR? + atr = self.cc.card_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 verify_PIN(self, pin=None): + while True: + try: + # when pin is None, pysatochip use a cached pin if available + (response, sw1, sw2) = self.cc.card_verify_PIN_simple(pin) + return True + + # recoverable errors + except CardNotPresentError: + msg = f"No card found! \nPlease insert card, then enter your PIN:" + (is_PIN, pin) = self.PIN_dialog(msg) + if is_PIN is False: + return False + except PinRequiredError: + # no pin value cached in pysatochip + msg = f'Enter the PIN for your card:' + (is_PIN, pin) = self.PIN_dialog(msg) + if is_PIN is False: + return False + except WrongPinError as ex: + pin = None # reset pin + msg = f"Wrong PIN! {ex.pin_left} tries remaining! \n Enter the PIN for your card:" + (is_PIN, pin) = self.PIN_dialog(msg) + if is_PIN is False: + return False + + # unrecoverable errors + except PinBlockedError: + raise UserFacingException( + f"Too many failed attempts! Your device has been blocked! \n\nYou need to factory reset your card (error code 0x9C0C)") + except UnexpectedSW12Error as ex: + raise UserFacingException( + f"Unexpected error during PIN verification: {ex}") + except Exception as ex: + raise UserFacingException( + f"Unexpected error during PIN verification: {ex}") + + def get_xpub(self, bip32_path, xtype): + assert xtype in SatochipPlugin.SUPPORTED_XTYPES + + # needs PIN + self.verify_PIN() + + # bip32_path is of the form 44'/0'/1' + _logger.info(f"[SatochipClient] get_xpub(): bip32_path={bip32_path}") + (depth, bytepath) = bip32path2bytes(bip32_path) + try: + (childkey, childchaincode) = self.cc.card_bip32_get_extendedkey(bytepath) + except UninitializedSeedError as e: + raise UserFacingException(str(e)) + 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)}") + return xpub + + def request(self, request_type, *args): + _logger.info('[SatochipClient] client request: ' + str(request_type)) + + 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! ') + 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 give_error(self, message, clear_client=False): + _logger.error(f"[Satochip_KeyStore] give_error() {message}") + if not self.ux_busy: + self.handler.show_error(message) + else: + self.ux_busy = False + if clear_client: + self.client = None + raise UserFacingException(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() + is_ok = client.verify_PIN() + if not is_ok: + return b'' + + address_path = self.get_derivation_prefix() + "/%d/%d" % sequence + _logger.info(f"[Satochip_KeyStore] sign_message: path: {address_path}") + + # 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) + else: + self.handler.show_message( + "Signing message ...\r\nMessage hash: " + message_hash) + + 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.")) + return compsig + + except Exception as e: + _logger.info(f"[Satochip_KeyStore] sign_message: Exception {e}") + return b'' + finally: + _logger.info(f"[Satochip_KeyStore] sign_message: finally") + self.handler.finished() + + def sign_transaction(self, tx, password): + _logger.info(f"In sign_transaction(): tx: {str(tx)}") + client = self.get_client() + client.verify_PIN() + segwitTransaction = False + + # outputs (bytes format) + txOutputs = bytearray() + txOutputs += var_int(len(tx.outputs())) + for o in tx.outputs(): + txOutputs += int.to_bytes(o.value, length=8, + byteorder="little", signed=False) + script = o.scriptpubkey + txOutputs += var_int(len(script)) + txOutputs += script + txOutputs = bytes(txOutputs) + hashOutputs = sha256d(txOutputs).hex() + _logger.info(f"In sign_transaction(): hashOutputs= {hashOutputs}") + _logger.info(f"In sign_transaction(): outputs= {txOutputs.hex()}") + + # 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}") + if txin.is_coinbase_input(): + # should never happen + self.give_error("Coinbase not supported") + + 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: + # should never happen + self.give_error("No matching pubkey for sign_transaction") + inputPath = convert_bip32_intpath_to_strpath(inputPath) # [2:] + + # get corresponing extended key + (depth, bytepath) = bip32path2bytes(inputPath) + (key, chaincode) = client.cc.card_bip32_get_extendedkey(bytepath) + + # parse tx (bytes format) + pre_tx = tx.serialize_preimage(i) + pre_hash = sha256d(pre_tx) + _logger.info( + f"[Satochip_KeyStore] sign_transaction(): pre_tx= {pre_tx.hex()}") + _logger.info( + f"[Satochip_KeyStore] sign_transaction(): pre_hash= {pre_hash.hex()}") + (response, sw1, sw2, tx_hash_list, needs_2fa) = client.cc.card_parse_transaction( + pre_tx, segwitTransaction) + tx_hash = bytearray(tx_hash_list) + if pre_hash != tx_hash: + raise RuntimeError( + f"[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.hex(), '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_list, 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 = ecc.get_r_and_s_from_ecdsa_der_sig(tx_sig) + if s > ecc.CURVE_ORDER // 2: + s = ecc.CURVE_ORDER - s + tx_sig = ecc.ecdsa_der_sig_from_r_and_s(r, s) + # update tx with signature + tx_sig = tx_sig + Sighash.to_sigbytes(Sighash.ALL) + tx.add_signature_to_txin( + txin_idx=i, signing_pubkey=my_pubkey, 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." + try: + 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: + 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] + except Exception as ex: + _logger.info(f"do_challenge_response: exception with handler: {ex}") + finally: + _logger.info(f"[Satochip_KeyStore] do_challenge_response: finally") + self.handler.finished() + + 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") + 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 [] + + def create_client(self, device, handler): + _logger.info(f"[SatochipPlugin] create_client()") + + 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 get_xpub(self, device_id, derivation, xtype, wizard): + # this seems to be part of the pairing process only, not during normal ops? + # base_wizard:on_hw_derivation + _logger.info(f"[SatochipPlugin] get_xpub()") + if xtype not in self.SUPPORTED_XTYPES: + raise ScriptTypeNotSupported( + _('This type of script is not supported with {}.').format(self.device)) + devmgr = self.device_manager() + client = devmgr.client_by_id(device_id) + client.handler = self.create_handler(wizard) + + xpub = client.get_xpub(derivation, xtype) + return xpub + + def get_client(self, keystore, force_pair=True, *, devices=None, allow_user_interaction=True): + # All client interaction should not be in the main GUI thread + devmgr = self.device_manager() + handler = keystore.handler + client = devmgr.client_for_keystore(self, handler, keystore, force_pair, + devices=devices, + allow_user_interaction=allow_user_interaction) + # returns the client for a given keystore. can use xpub + return client + + def _setup_device(self, settings, device_id, handler): + _logger.info(f"[SatochipPlugin] _setup_device()") + + devmgr = self.device_manager() + client = devmgr.client_by_id(device_id) + if not client: + raise Exception(_("The device was disconnected.")) + + # 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!')) + + pin_0 = settings + pin_0 = list(pin_0.encode("utf-8")) + client.cc.set_pin(0, pin_0) # cache PIN value in client + pin_tries_0 = 0x05 + # PUK code can be used when PIN is unknown and the card is locked + # We use a random value as the PUK is not used currently in the electrum GUI + ublk_tries_0 = 0x01 + ublk_0 = list(urandom(16)) + # the second pin is not used currently, use random values + pin_tries_1 = 0x01 + ublk_tries_1 = 0x01 + pin_1 = list(urandom(16)) + ublk_1 = list(urandom(16)) + secmemsize = 32 # number of slot reserved in memory cache + memsize = 0x0000 # RFU + create_object_ACL = 0x01 # RFU + create_key_ACL = 0x01 # RFU + create_pin_ACL = 0x01 # RFU + + # setup + try: + (response, sw1, sw2) = client.cc.card_setup(pin_tries_0, ublk_tries_0, pin_0, ublk_0, + pin_tries_1, ublk_tries_1, pin_1, ublk_1, + secmemsize, memsize, + create_object_ACL, create_key_ACL, create_pin_ACL) + if sw1 == 0x90 and sw2 == 0x00: + _logger.info( + f"[SatochipPlugin] _setup_device(): setup applet successfully!") + client.handler.show_message( + f"Satochip setup performed successfully!") + elif sw1 == 0x9c and sw2 == 0x07: + _logger.error( + f"[SatochipPlugin] _setup_device(): error applet setup already done (code {hex(sw1*256+sw2)})") + client.handler.show_error( + f"Satochip error: applet setup already done (code {hex(sw1*256+sw2)})") + else: + _logger.error( + f"[SatochipPlugin] _setup_device(): unable to set up applet! sw12={hex(sw1)} {hex(sw2)}") + client.handler.show_error( + f"[SatochipPlugin] _setup_device(): unable to set up applet! sw12={hex(sw1)} {hex(sw2)}") + except Exception as ex: + _logger.error( + f"[SatochipPlugin] _setup_device(): exception during setup: {ex}") + client.handler.show_error( + f"[SatochipPlugin] _setup_device(): exception during setup: {ex}") + + # verify pin: + client.verify_PIN() + + def _import_seed(self, settings, device_id, handler): + _logger.info(f"[SatochipPlugin] _import_seed()") + + devmgr = self.device_manager() + client = devmgr.client_by_id(device_id) + if not client: + raise Exception(_("The device was disconnected.")) + + seed_type, seed, passphrase = settings + + # check seed type: + if seed_type != 'bip39': + _logger.error( + f"[SatochipPlugin] _import_seed() wrong seed type!") + raise Exception(f'Wrong seed type {seed_type}: only BIP39 is supported!') + + # check seed validity + (is_checksum_valid, is_wordlist_valid) = bip39_is_checksum_valid(seed) + if is_checksum_valid and is_wordlist_valid: + _logger.info( + f"[SatochipPlugin] _import_seed() seed format is valid!") + masterseed_bytes = bip39_to_seed(seed, passphrase=passphrase) + masterseed_list = list(masterseed_bytes) + else: + _logger.error( + f"[SatochipPlugin] _import_seed() wrong seed format!") + raise Exception('Wrong BIP39 mnemonic format!') + + # verify pin: + client.verify_PIN() + + # import seed + try: + authentikey = client.cc.card_bip32_import_seed(masterseed_list) + _logger.info( + f"[SatochipPlugin] _import_seed(): seed imported successfully!") + hex_authentikey = authentikey.get_public_key_hex(compressed=True) + _logger.info( + f"[SatochipPlugin] _import_seed(): authentikey={hex_authentikey}") + except Exception as ex: + _logger.error( + f"[SatochipPlugin] _import_seed(): exception during seed import: {ex}") + raise ex + + def wizard_entry_for_device(self, device_info: 'DeviceInfo', *, new_wallet=True) -> str: + _logger.info(f"[SatochipPlugin] wizard_entry_for_device()") + _logger.info( + f"[SatochipPlugin] wizard_entry_for_device() device_info: {device_info}") + _logger.info( + f"[SatochipPlugin] wizard_entry_for_device() new_wallet: {new_wallet}") + + device_state = device_info.initialized # can be None, False or True. + # None is used to distinguish a completely new card from a card where the seed has been reset, but the PIN is still set. + _logger.info( + f"[SatochipPlugin] wizard_entry_for_device() device_state: {device_state}") + if new_wallet: + if device_state is None: + return 'satochip_not_setup' + elif device_state is False: + return 'satochip_not_seeded' + else: + return 'satochip_start' + else: + # todo: assert is_setup & is_seeded + if device_state is not True: + # This can happen if you reset the seed of the Satochip for an existing wallet, then try to open that wallet file. + _logger.error( + f"[SatochipPlugin] wizard_entry_for_device() existing wallet with non-seeded Satochip!") + return 'satochip_unlock' + + # insert satochip pages in new wallet wizard + def extend_wizard(self, wizard: 'NewWalletWizard'): + _logger.info(f"[SatochipPlugin] extend_wizard()") + views = { + 'satochip_start': { + 'next': 'satochip_xpub', + }, + 'satochip_xpub': { + 'next': lambda d: wizard.wallet_password_view(d) if wizard.last_cosigner(d) else 'multisig_cosigner_keystore', + 'accept': wizard.maybe_master_pubkey, + 'last': lambda d: wizard.is_single_password() and wizard.last_cosigner(d) + }, + 'satochip_not_setup': { + 'next': 'satochip_do_setup', + }, + 'satochip_do_setup': { + 'next': 'satochip_not_seeded', + }, + 'satochip_not_seeded': { + 'next': 'satochip_have_seed', + }, + 'satochip_import_seed': { + 'next': 'satochip_success_seed', + }, + 'satochip_success_seed': { + 'next': 'satochip_start', + }, + 'satochip_unlock': { + 'last': True + }, + } + wizard.navmap_merge(views) + + def show_address(self, wallet, address, keystore=None): + if keystore is None: + keystore = wallet.get_keystore() + if not self.show_address_helper(wallet, address, keystore): + return + + # Standard_Wallet => 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) diff --git a/electrum/plugins/satochip/satochip_unpaired.png b/electrum/plugins/satochip/satochip_unpaired.png new file mode 100644 index 000000000000..592d5daf71e5 Binary files /dev/null and b/electrum/plugins/satochip/satochip_unpaired.png differ