diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..39c756a --- /dev/null +++ b/.gitignore @@ -0,0 +1,11 @@ +__pycache__/ +*.py[cod] +*$py.class +*.so + +build/ +dist/ +*.egg-info/ + +.tox/ +venv/ diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..a6448af --- /dev/null +++ b/LICENSE @@ -0,0 +1,5 @@ +Copyright © 2020 rexs.io + +Permission to use, copy, modify, and/or distribute this software for any purpose with or without fee is hereby granted, provided that the above copyright notice and this permission notice appear in all copies. + +THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..545edf9 --- /dev/null +++ b/README.md @@ -0,0 +1,80 @@ +# Blocksec2Go-Ethereum + +This repository contains the source code of `blocksec2go-ethereum` Python package which wraps the `blocksec2go` package to allow easier interaction with Ethereum blockchain. + +If you're unsure what Blockchain Security 2 Go is, [you can find more info here](https://github.com/Infineon/Blockchain). + +## Installation + +```bash +pip install blocksec2go-ethereum +``` + +## Usage + +After creating an instance of `Blocksec2Go` you can use it to generate signatures for transaction dicts. When passed raw tx, `sign_transaction()` will return a hex string of RLP-encoded signed transaction that can be directly consumed by `web3.eth.sendRawTransaction()`. + +### Transfer Ether + +Below you will find an example of signing a simple Ether transfer: + +```python +from blocksec2go_ethereum import Blocksec2GoSigner +from web3 import Web3 + +WEB3_ENDPOINT = 'YOUR_ENDPOINT_HERE' + +web3 = Web3(Web3.HTTPProvider(WEB3_ENDPOINT)) +chain_id = web3.eth.chainId + +signer = Blocksec2GoSigner(chain_id=chain_id, key_id=1) +address = signer.get_address() + +nonce = web3.eth.getTransactionCount(address) +raw_tx = { + 'to': '0xBaBC446aee039E99d624058b0875E519190C6758', + 'nonce': nonce, + 'value': Web3.toWei(0.00005, 'ether'), + 'gas': 21000, + 'gasPrice': Web3.toWei(5, 'gwei'), +} +signed_tx = signer.sign_transaction(raw_tx) + +tx_hash = web3.eth.sendRawTransaction(signed_tx) +print(f'Sent transaction with hash: {tx_hash.hex()}') +``` + +### Call a contract function + +You can also sign any contract calls/creation transactions by leveraging `buildTransaction()`. + +Please note that for some contracts `buildTransaction()` may require explicitly setting `from` field to properly estimate gas. + +```python +import json + +from blocksec2go_ethereum import Blocksec2GoSigner +from web3 import Web3 + +WEB3_ENDPOINT = 'YOUR_ENDPOINT_HERE' + +web3 = Web3(Web3.HTTPProvider(WEB3_ENDPOINT)) +chain_id = web3.eth.chainId + +signer = Blocksec2GoSigner(chain_id=chain_id, key_id=1) +address = signer.get_address() + +with open('artifact.json', 'r') as artifact_file: + artifact = json.loads(artifact_file.read()) + contract = web3.eth.contract(address=artifact['address'], abi=artifact['abi']) + +nonce = web3.eth.getTransactionCount(address) +raw_tx = contract.functions.setValue(42).buildTransaction({'nonce': nonce, 'from': address}) +signed_tx = signer.sign_transaction(raw_tx) + +tx_hash = web3.eth.sendRawTransaction(signed_tx) +print(f'Sent transaction with hash: {tx_hash.hex()}') +``` + +## License +ISC © 2020 rexs.io diff --git a/blocksec2go_ethereum/__init__.py b/blocksec2go_ethereum/__init__.py new file mode 100644 index 0000000..02e02b8 --- /dev/null +++ b/blocksec2go_ethereum/__init__.py @@ -0,0 +1 @@ +from ._signer import Blocksec2GoSigner # noqa F401 diff --git a/blocksec2go_ethereum/_signer.py b/blocksec2go_ethereum/_signer.py new file mode 100644 index 0000000..11062d6 --- /dev/null +++ b/blocksec2go_ethereum/_signer.py @@ -0,0 +1,92 @@ +import logging +import time +from typing import Tuple + +import blocksec2go +from blocksec2go.comm.pyscard import open_pyscard +from eth_account._utils.transactions import ( + encode_transaction, + serializable_unsigned_transaction_from_dict +) +from eth_typing import ChecksumAddress, HexStr +from web3.types import TxParams + +from . import _utils +from .exceptions import CardNotAvailable + +Signature = Tuple[int, int, int] + + +class Blocksec2GoSigner: + def __init__(self, key_id: int = 1, chain_id: int = 1, connect_retry_count: int = 5): + self._key_id = key_id + self._chain_id = chain_id + self._connect_retry_count = connect_retry_count + self._reader = None + self._pub_key: bytes = bytes(0) + + self._logger = logging.getLogger('security2go_ethereum') + self._init() + + def _init(self): + retries_left = self._connect_retry_count + + while not self._reader and retries_left >= 0: + try: + self._reader = open_pyscard() + except RuntimeError as details: + self._logger.debug(details) + + self._logger.info(f'Reader or card not found. {retries_left} retries left.') + retries_left = retries_left - 1 + time.sleep(1) + + if not self._reader: + self._logger.error('Exceeded connection retry count') + raise CardNotAvailable + + blocksec2go.select_app(self._reader) + self._pub_key = self._get_pub_key() + self._logger.debug(f'Using public key {self._pub_key.hex()}') + self._logger.info(f'Initialized for address {self.get_address()}') + + @property + def chain_id(self): + return self._chain_id + + @property + def key_id(self): + return self._key_id + + def get_address(self) -> ChecksumAddress: + return _utils.address_from_public_key(self._pub_key) + + def sign_transaction(self, raw_tx: TxParams) -> HexStr: + raw_tx.pop('from', None) + raw_tx['chainId'] = self._chain_id + + tx = serializable_unsigned_transaction_from_dict(raw_tx) + tx_hash = tx.hash() + + self._logger.debug(f'Signing transaction hash {tx_hash.hex()}') + sig = self._generate_signature(tx_hash) + signed_tx = encode_transaction(tx, vrs=sig) + + return HexStr(signed_tx.hex()) + + def _generate_signature(self, tx_hash: bytes) -> Signature: + _, _, signature_der = blocksec2go.generate_signature( + self._reader, self._key_id, tx_hash + ) + self._logger.debug('Generated signature') + + rs_sig = _utils.sigdecode_der(signature_der) + v = _utils.get_v(rs_sig, tx_hash, self._pub_key, self._chain_id) + r, s = rs_sig + + return v, r, s + + def _get_pub_key(self) -> bytes: + _, _, pub_key = blocksec2go.get_key_info(self._reader, self._key_id) + unprefixed_pub_key = pub_key[1:] + return unprefixed_pub_key diff --git a/blocksec2go_ethereum/_utils.py b/blocksec2go_ethereum/_utils.py new file mode 100644 index 0000000..104becf --- /dev/null +++ b/blocksec2go_ethereum/_utils.py @@ -0,0 +1,39 @@ +from typing import Tuple + +import ecdsa +from eth_typing import ChecksumAddress +from web3 import Web3 + +from .exceptions import InvalidSignature + +UnrecoverableSignature = Tuple[int, int] + + +def sigdecode_der(sig: bytes) -> UnrecoverableSignature: + return ecdsa.util.sigdecode_der(sig, 0) + + +def find_recovery_id(sig: UnrecoverableSignature, tx_hash: bytes, pub_key: bytes) -> int: + r, s = sig + vk = ecdsa.VerifyingKey.from_string(pub_key, curve=ecdsa.SECP256k1) + vk_point = vk.pubkey.point + hash_number = ecdsa.util.string_to_number(tx_hash) + + public_keys = ecdsa.ecdsa.Signature(r, s).recover_public_keys(hash_number, ecdsa.SECP256k1.generator) + if public_keys[0].point == vk_point: + return 0 + elif public_keys[1].point == vk_point: + return 1 + raise InvalidSignature + + +def address_from_public_key(public_key: bytes) -> ChecksumAddress: + pk_hash = Web3.keccak(public_key) + address_bytes = pk_hash[-20:] + address = address_bytes.hex() + return Web3.toChecksumAddress(address) + + +def get_v(sig: UnrecoverableSignature, tx_hash: bytes, pub_key: bytes, chain_id: int) -> int: + recovery_id = find_recovery_id(sig, tx_hash, pub_key) + return 35 + recovery_id + (chain_id * 2) diff --git a/blocksec2go_ethereum/exceptions.py b/blocksec2go_ethereum/exceptions.py new file mode 100644 index 0000000..d4843cb --- /dev/null +++ b/blocksec2go_ethereum/exceptions.py @@ -0,0 +1,6 @@ +class InvalidSignature(BaseException): + pass + + +class CardNotAvailable(BaseException): + pass diff --git a/examples/artifact.json b/examples/artifact.json new file mode 100644 index 0000000..85ee5b6 --- /dev/null +++ b/examples/artifact.json @@ -0,0 +1,37 @@ +{ + "address": "0xFb067a58851A386168411De892bD800F80649433", + "abi": [ + { + "constant": true, + "inputs": [], + "name": "value", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "payable": false, + "stateMutability": "view", + "type": "function", + "signature": "0x3fa4f245" + }, + { + "constant": false, + "inputs": [ + { + "internalType": "uint256", + "name": "_value", + "type": "uint256" + } + ], + "name": "setValue", + "outputs": [], + "payable": false, + "stateMutability": "nonpayable", + "type": "function", + "signature": "0x55241077" + } + ] +} \ No newline at end of file diff --git a/examples/call_contract.py b/examples/call_contract.py new file mode 100644 index 0000000..e058a01 --- /dev/null +++ b/examples/call_contract.py @@ -0,0 +1,23 @@ +import json + +from blocksec2go_ethereum import Blocksec2GoSigner +from web3 import Web3 + +WEB3_ENDPOINT = 'YOUR_ENDPOINT_HERE' + +web3 = Web3(Web3.HTTPProvider(WEB3_ENDPOINT)) +chain_id = web3.eth.chainId + +signer = Blocksec2GoSigner(chain_id=chain_id, key_id=1) +address = signer.get_address() + +with open('artifact.json', 'r') as artifact_file: + artifact = json.loads(artifact_file.read()) + contract = web3.eth.contract(address=artifact['address'], abi=artifact['abi']) + +nonce = web3.eth.getTransactionCount(address) +raw_tx = contract.functions.setValue(42).buildTransaction({'nonce': nonce, 'from': address}) +signed_tx = signer.sign_transaction(raw_tx) + +tx_hash = web3.eth.sendRawTransaction(signed_tx) +print(f'Sent transaction with hash: {tx_hash.hex()}') diff --git a/examples/send_ether.py b/examples/send_ether.py new file mode 100644 index 0000000..cb299a4 --- /dev/null +++ b/examples/send_ether.py @@ -0,0 +1,23 @@ +from blocksec2go_ethereum import Blocksec2GoSigner +from web3 import Web3 + +WEB3_ENDPOINT = 'YOUR_ENDPOINT_HERE' + +web3 = Web3(Web3.HTTPProvider(WEB3_ENDPOINT)) +chain_id = web3.eth.chainId + +signer = Blocksec2GoSigner(chain_id=chain_id, key_id=1) +address = signer.get_address() + +nonce = web3.eth.getTransactionCount(address) +raw_tx = { + 'to': '0xBaBC446aee039E99d624058b0875E519190C6758', + 'nonce': nonce, + 'value': Web3.toWei(0.00005, 'ether'), + 'gas': 21000, + 'gasPrice': Web3.toWei(5, 'gwei'), +} +signed_tx = signer.sign_transaction(raw_tx) + +tx_hash = web3.eth.sendRawTransaction(signed_tx) +print(f'Sent transaction with hash: {tx_hash.hex()}') diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..9440d14 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,57 @@ +appdirs==1.4.3 +attrdict==2.0.1 +attrs==19.3.0 +base58==2.0.0 +blocksec2go==1.2 +certifi==2019.11.28 +cffi==1.14.0 +chardet==3.0.4 +cryptography==2.8 +cytoolz==0.10.1 +distlib==0.3.0 +ecdsa==0.15 +entrypoints==0.3 +eth-abi==2.1.1 +eth-account==0.4.0 +eth-hash==0.2.0 +eth-keyfile==0.5.1 +eth-keys==0.2.4 +eth-rlp==0.1.2 +eth-typing==2.2.1 +eth-utils==1.8.4 +filelock==3.0.12 +flake8==3.7.9 +hexbytes==0.2.0 +idna==2.9 +importlib-metadata==1.5.0 +ipfshttpclient==0.4.12 +jsonschema==3.2.0 +lru-dict==1.1.6 +mccabe==0.6.1 +multiaddr==0.0.9 +netaddr==0.7.19 +packaging==20.1 +parsimonious==0.8.1 +pluggy==0.13.1 +protobuf==3.11.3 +py==1.8.1 +pycodestyle==2.5.0 +pycparser==2.19 +pycryptodome==3.9.7 +pyflakes==2.1.1 +pyparsing==2.4.6 +pyrsistent==0.15.7 +pyscard==1.9.9 +requests==2.23.0 +rlp==1.2.0 +six==1.14.0 +toml==0.10.0 +toolz==0.10.0 +tox==3.14.5 +typing-extensions==3.7.4.1 +urllib3==1.25.8 +varint==1.0.2 +virtualenv==20.0.7 +web3==5.6.0 +websockets==8.1 +zipp==3.0.0 diff --git a/setup.cfg b/setup.cfg new file mode 100644 index 0000000..d687712 --- /dev/null +++ b/setup.cfg @@ -0,0 +1,5 @@ +[metadata] +license_files = LICENSE + +[flake8] +ignore = E501 diff --git a/setup.py b/setup.py new file mode 100644 index 0000000..2096aaa --- /dev/null +++ b/setup.py @@ -0,0 +1,40 @@ +from setuptools import setup +from os import path + +here = path.abspath(path.dirname(__file__)) + +with open(path.join(here, 'README.md'), encoding='utf-8') as f: + long_description = f.read() + +setup( + name='blocksec2go-ethereum', + version='0.1.0', + description='Wrapper for blocksec2go allowing easy hardware-based signing of Ethereum transactions', + long_description=long_description, + long_description_content_type='text/markdown', + url='https://github.com/rexs-io/blocksec2go-ethereum', + author='rexs.io', + license='ISC', + author_email='dev@rexs.io', + classifiers=[ # Optional + 'Development Status :: 3 - Alpha', + 'Intended Audience :: Developers', + 'Topic :: Software Development :: Libraries', + 'License :: OSI Approved :: ISC License (ISCL)', + 'Programming Language :: Python :: 3', + ], + keywords='ethereum blocksec2go hardware-signing hardware wallet', + python_requires='>=3.0, <4', + install_requires=[ + 'blocksec2go==1.2', + 'ecdsa==0.15', + 'web3==5.6.0' + ], + packages=[ + 'blocksec2go_ethereum' + ], + project_urls={ + 'Bug Reports': 'https://github.com/rexs-io/blocksec2go-ethereum/issues', + 'Source': 'https://github.com/rexs-io/blocksec2go-ethereum', + }, +) \ No newline at end of file diff --git a/tests/test_utils.py b/tests/test_utils.py new file mode 100644 index 0000000..8863da7 --- /dev/null +++ b/tests/test_utils.py @@ -0,0 +1,30 @@ +from blocksec2go_ethereum._signer import Signature +from blocksec2go_ethereum._utils import get_v, address_from_public_key, sigdecode_der + +test_signature: Signature = (119, 13721599831162923456657927172105796262173750250904149654514422912453024879155, + 6849172825415158877547000581141514992709599875997948052677375877797118790017) +test_unrecoverable_signature = (test_signature[1], test_signature[2]) +test_der_signature = bytes.fromhex( + '304402201e562678e9038586672e801c521740e50474012bca0de8a8681c0117ee2b123302200f247e93b6258d981beaecdcc16c294d771a60a54481003f2a4af8b2d02cc981' +) +test_tx_hash = bytes.fromhex('86d4fe468d2b569c620da14500e7f455dbba5905cf8d8a74d34e55c9abfe454b') +test_pub_key = bytes.fromhex( + 'ab181ec96d7eebadc7e16fd67e8d91ea14f4671c87193aee9bd1c817803fe25fbda4fa44a502179b427b4e5d7c08c7de901e74c97aa1a1939f4a77ad7d3e4259' +) +test_address = '0x71A5fB76Ad2a284872b876D5B2e33AE83d48690b' +test_chain_id = 42 + + +def test_sigdecode_der(): + returned_unrecoverable_sig = sigdecode_der(test_der_signature) + assert test_unrecoverable_signature == returned_unrecoverable_sig + + +def test_address_from_public_key(): + returned_address = address_from_public_key(test_pub_key) + assert test_address == returned_address + + +def test_get_v(): + returned_v = get_v(test_unrecoverable_signature, test_tx_hash, test_pub_key, test_chain_id) + assert test_signature[0] == returned_v diff --git a/tox.ini b/tox.ini new file mode 100644 index 0000000..1d39c89 --- /dev/null +++ b/tox.ini @@ -0,0 +1,12 @@ +[tox] +envlist = py37 + +[testenv] +deps = + pytest + flake8 + +commands = + pytest + flake8 blocksec2go_ethereum + flake8 tests