From 7753247afb5f5915e405509bfdf2b934d669d6a1 Mon Sep 17 00:00:00 2001 From: nolash Date: Sun, 10 Oct 2021 18:14:34 +0200 Subject: [PATCH] Initial commit --- MANIFEST.in | 1 + config/config.ini | 3 + config/database.ini | 6 ++ funga/eth/__init__.py | 0 funga/eth/cli/__init__.py | 0 funga/eth/cli/handle.py | 115 ++++++++++++++++++++ funga/eth/cli/http.py | 85 +++++++++++++++ funga/eth/cli/jsonrpc.py | 30 ++++++ funga/eth/cli/socket.py | 67 ++++++++++++ funga/eth/encoding.py | 91 ++++++++++++++++ funga/eth/helper/__init__.py | 1 + funga/eth/helper/tx.py | 58 ++++++++++ funga/eth/keystore/__init__.py | 8 ++ funga/eth/keystore/dict.py | 45 ++++++++ funga/eth/keystore/interface.py | 50 +++++++++ funga/eth/keystore/keyfile.py | 173 ++++++++++++++++++++++++++++++ funga/eth/keystore/sql.py | 108 +++++++++++++++++++ funga/eth/runnable/keyfile.py | 87 +++++++++++++++ funga/eth/runnable/signer.py | 122 +++++++++++++++++++++ funga/eth/signer/__init__.py | 1 + funga/eth/signer/defaultsigner.py | 79 ++++++++++++++ funga/eth/transaction.py | 172 +++++++++++++++++++++++++++++ funga/eth/web3ext/__init__.py | 29 +++++ funga/eth/web3ext/middleware.py | 116 ++++++++++++++++++++ requirements.txt | 9 ++ setup.cfg | 11 ++ setup.py | 61 +++++++++++ sql_requirements.txt | 2 + test_requirements.txt | 0 tests/test_cli.py | 88 +++++++++++++++ tests/test_keystore_dict.py | 63 +++++++++++ tests/test_keystore_reference.py | 64 +++++++++++ tests/test_sign.py | 99 +++++++++++++++++ tests/test_socket.py | 15 +++ 34 files changed, 1859 insertions(+) create mode 100644 MANIFEST.in create mode 100644 config/config.ini create mode 100644 config/database.ini create mode 100644 funga/eth/__init__.py create mode 100644 funga/eth/cli/__init__.py create mode 100644 funga/eth/cli/handle.py create mode 100644 funga/eth/cli/http.py create mode 100644 funga/eth/cli/jsonrpc.py create mode 100644 funga/eth/cli/socket.py create mode 100644 funga/eth/encoding.py create mode 100644 funga/eth/helper/__init__.py create mode 100644 funga/eth/helper/tx.py create mode 100644 funga/eth/keystore/__init__.py create mode 100644 funga/eth/keystore/dict.py create mode 100644 funga/eth/keystore/interface.py create mode 100644 funga/eth/keystore/keyfile.py create mode 100644 funga/eth/keystore/sql.py create mode 100644 funga/eth/runnable/keyfile.py create mode 100755 funga/eth/runnable/signer.py create mode 100644 funga/eth/signer/__init__.py create mode 100644 funga/eth/signer/defaultsigner.py create mode 100644 funga/eth/transaction.py create mode 100644 funga/eth/web3ext/__init__.py create mode 100644 funga/eth/web3ext/middleware.py create mode 100644 requirements.txt create mode 100644 setup.cfg create mode 100644 setup.py create mode 100644 sql_requirements.txt create mode 100644 test_requirements.txt create mode 100644 tests/test_cli.py create mode 100644 tests/test_keystore_dict.py create mode 100644 tests/test_keystore_reference.py create mode 100644 tests/test_sign.py create mode 100644 tests/test_socket.py diff --git a/MANIFEST.in b/MANIFEST.in new file mode 100644 index 0000000..ea97a42 --- /dev/null +++ b/MANIFEST.in @@ -0,0 +1 @@ +include *requirements* diff --git a/config/config.ini b/config/config.ini new file mode 100644 index 0000000..779a2bf --- /dev/null +++ b/config/config.ini @@ -0,0 +1,3 @@ +[signer] +secret = deadbeef +socket_path = ipc:///tmp/crypto-dev-signer/jsonrpc.ipc diff --git a/config/database.ini b/config/database.ini new file mode 100644 index 0000000..3f06eb8 --- /dev/null +++ b/config/database.ini @@ -0,0 +1,6 @@ +[database] +NAME=cic-signer +USER=postgres +PASSWORD= +HOST=localhost +PORT=5432 diff --git a/funga/eth/__init__.py b/funga/eth/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/funga/eth/cli/__init__.py b/funga/eth/cli/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/funga/eth/cli/handle.py b/funga/eth/cli/handle.py new file mode 100644 index 0000000..fc6ca2a --- /dev/null +++ b/funga/eth/cli/handle.py @@ -0,0 +1,115 @@ +# standard imports +import json +import logging + +# external imports +from jsonrpc.exceptions import ( + JSONRPCServerError, + JSONRPCParseError, + JSONRPCInvalidParams, + ) +from hexathon import add_0x + +# local imports +from funga.eth.transaction import EIP155Transaction +from funga.error import ( + UnknownAccountError, + SignerError, + ) +from funga.eth.cli.jsonrpc import jsonrpc_ok +from .jsonrpc import ( + jsonrpc_error, + is_valid_json, + ) + +logg = logging.getLogger(__name__) + + +class SignRequestHandler: + + keystore = None + signer = None + + def process_input(self, j): + rpc_id = j['id'] + m = j['method'] + p = j['params'] + return (rpc_id, getattr(self, m)(p)) + + + def handle_jsonrpc(self, d): + j = None + try: + j = json.loads(d) + is_valid_json(j) + logg.debug('{}'.format(d.decode('utf-8'))) + except Exception as e: + logg.exception('input error {}'.format(e)) + j = json.dumps(jsonrpc_error(None, JSONRPCParseError)).encode('utf-8') + raise SignerError(j) + + try: + (rpc_id, r) = self.process_input(j) + r = jsonrpc_ok(rpc_id, r) + j = json.dumps(r).encode('utf-8') + except ValueError as e: + # TODO: handle cases to give better error context to caller + logg.exception('process error {}'.format(e)) + j = json.dumps(jsonrpc_error(j['id'], JSONRPCServerError)).encode('utf-8') + raise SignerError(j) + except UnknownAccountError as e: + logg.exception('process unknown account error {}'.format(e)) + j = json.dumps(jsonrpc_error(j['id'], JSONRPCServerError)).encode('utf-8') + raise SignerError(j) + + return j + + + def personal_newAccount(self, p): + password = p + if p.__class__.__name__ != 'str': + if p.__class__.__name__ != 'list': + e = JSONRPCInvalidParams() + e.data = 'parameter must be list containing one string' + raise ValueError(e) + logg.error('foo {}'.format(p)) + if len(p) != 1: + e = JSONRPCInvalidParams() + e.data = 'parameter must be list containing one string' + raise ValueError(e) + if p[0].__class__.__name__ != 'str': + e = JSONRPCInvalidParams() + e.data = 'parameter must be list containing one string' + raise ValueError(e) + password = p[0] + + r = self.keystore.new(password) + + return add_0x(r) + + + # TODO: move to translation module ("personal" rpc namespace is node-specific) + def personal_signTransaction(self, p): + logg.debug('got {} to sign'.format(p[0])) + t = EIP155Transaction(p[0], p[0]['nonce'], p[0]['chainId']) + raw_signed_tx = self.signer.sign_transaction_to_rlp(t, p[1]) + o = { + 'raw': '0x' + raw_signed_tx.hex(), + 'tx': t.serialize(), + } + return o + + + def eth_signTransaction(self, tx): + o = self.personal_signTransaction([tx[0], '']) + return o['raw'] + + + def eth_sign(self, p): + logg.debug('got message {} to sign'.format(p[1])) + message_type = type(p[1]).__name__ + if message_type != 'str': + raise ValueError('invalid message format, must be {}, not {}'.format(message_type)) + z = self.signer.sign_ethereum_message(p[0], p[1][2:]) + return add_0x(z.hex()) + diff --git a/funga/eth/cli/http.py b/funga/eth/cli/http.py new file mode 100644 index 0000000..1ae2d72 --- /dev/null +++ b/funga/eth/cli/http.py @@ -0,0 +1,85 @@ +# standard imports +import logging + +# external imports +from http.server import ( + HTTPServer, + BaseHTTPRequestHandler, + ) + +# local imports +from .handle import SignRequestHandler +from crypto_dev_signer.error import SignerError + +logg = logging.getLogger(__name__) + + +def start_server_http(spec): + httpd = HTTPServer(spec, HTTPSignRequestHandler) + logg.debug('starting http server {}'.format(spec)) + httpd.serve_forever() + + +class HTTPSignRequestHandler(SignRequestHandler, BaseHTTPRequestHandler): + + def do_POST(self): + if self.headers.get('Content-Type') != 'application/json': + self.send_response(400, 'me read json only') + self.end_headers() + return + + try: + if 'application/json' not in self.headers.get('Accept').split(','): + self.send_response(400, 'me json only speak') + self.end_headers() + return + except AttributeError: + pass + + l = self.headers.get('Content-Length') + try: + l = int(l) + except ValueError: + self.send_response(400, 'content length must be integer') + self.end_headers() + return + if l > 4096: + self.send_response(400, 'too much information') + self.end_headers() + return + if l < 0: + self.send_response(400, 'you are too negative') + self.end_headers() + return + + b = b'' + c = 0 + while c < l: + d = self.rfile.read(l-c) + if d == None: + break + b += d + c += len(d) + if c > 4096: + self.send_response(413, 'i should slap you around for lying about your size') + self.end_headers() + return + + try: + r = self.handle_jsonrpc(d) + except SignerError as e: + r = e.to_jsonrpc() + + l = len(r) + self.send_response(200, 'You are the Keymaster') + self.send_header('Content-Length', str(l)) + self.send_header('Cache-Control', 'no-cache') + self.send_header('Content-Type', 'application/json') + self.end_headers() + + c = 0 + while c < l: + n = self.wfile.write(r[c:]) + c += n + + diff --git a/funga/eth/cli/jsonrpc.py b/funga/eth/cli/jsonrpc.py new file mode 100644 index 0000000..384a3c8 --- /dev/null +++ b/funga/eth/cli/jsonrpc.py @@ -0,0 +1,30 @@ +# local imports +from funga.error import UnknownAccountError + + +def jsonrpc_error(rpc_id, err): + return { + 'jsonrpc': '2.0', + 'id': rpc_id, + 'error': { + 'code': err.CODE, + 'message': err.MESSAGE, + }, + } + + +def jsonrpc_ok(rpc_id, response): + return { + 'jsonrpc': '2.0', + 'id': rpc_id, + 'result': response, + } + + +def is_valid_json(j): + if j.get('id') == 'None': + raise ValueError('id missing') + return True + + + diff --git a/funga/eth/cli/socket.py b/funga/eth/cli/socket.py new file mode 100644 index 0000000..abf43c0 --- /dev/null +++ b/funga/eth/cli/socket.py @@ -0,0 +1,67 @@ +# standard imports +import os +import logging +import socket +import stat + +# local imports +from crypto_dev_signer.error import SignerError +from .handle import SignRequestHandler + +logg = logging.getLogger(__name__) + + +class SocketHandler: + + def __init__(self): + self.handler = SignRequestHandler() + + + def process(self, csock): + d = csock.recv(4096) + + r = None + try: + r = self.handler.handle_jsonrpc(d) + except SignerError as e: + r = e.to_jsonrpc() + + csock.send(r) + + +def start_server_socket(s): + s.listen(10) + logg.debug('server started') + handler = SocketHandler() + while True: + (csock, caddr) = s.accept() + handler.process(csock) + csock.close() + s.close() + os.unlink(socket_path) + + +def start_server_tcp(spec): + s = socket.socket(family=socket.AF_INET, type=socket.SOCK_STREAM) + s.bind(spec) + logg.debug('created tcp socket {}'.format(spec)) + start_server_socket(s) + + +def start_server_unix(socket_path): + socket_dir = os.path.dirname(socket_path) + try: + fi = os.stat(socket_dir) + if not stat.S_ISDIR: + RuntimeError('socket path {} is not a directory'.format(socket_dir)) + except FileNotFoundError: + os.mkdir(socket_dir) + + try: + os.unlink(socket_path) + except FileNotFoundError: + pass + s = socket.socket(family = socket.AF_UNIX, type = socket.SOCK_STREAM) + s.bind(socket_path) + logg.debug('created unix ipc socket {}'.format(socket_path)) + start_server_socket(s) diff --git a/funga/eth/encoding.py b/funga/eth/encoding.py new file mode 100644 index 0000000..db69a21 --- /dev/null +++ b/funga/eth/encoding.py @@ -0,0 +1,91 @@ +# standard imports +import logging + +# external imports +import coincurve +import sha3 +from hexathon import ( + strip_0x, + uniform, + ) + +logg = logging.getLogger(__name__) + + +def private_key_from_bytes(b): + return coincurve.PrivateKey(secret=b) + + +def public_key_bytes_to_address(pubk_bytes, result_format='hex'): + h = sha3.keccak_256() + logg.debug('public key bytes {}'.format(pubk_bytes.hex())) + h.update(pubk_bytes[1:]) + z = h.digest()[12:] + if result_format == 'hex': + return to_checksum_address(z[:20].hex()) + elif result_format == 'bytes': + return z[:20] + raise ValueError('invalid result format "{}"'.format(result_format)) + + +def public_key_to_address(pubk, result_format='hex'): + pubk_bytes = pubk.format(compressed=False) + return public_key_bytes_to_address(pubk_bytes, result_format='hex') + + +def private_key_to_address(pk, result_format='hex'): + pubk = coincurve.PublicKey.from_secret(pk.secret) + #logg.debug('secret {} '.format(pk.secret.hex())) + return public_key_to_address(pubk, result_format) + + +def is_address(address_hex): + try: + address_hex = strip_0x(address_hex) + except ValueError: + return False + return len(address_hex) == 40 + + +def is_checksum_address(address_hex): + hx = None + try: + hx = to_checksum(address_hex) + except ValueError: + return False + return hx == strip_0x(address_hex) + + +def to_checksum_address(address_hex): + address_hex = strip_0x(address_hex) + address_hex = uniform(address_hex) + if len(address_hex) != 40: + raise ValueError('Invalid address length') + h = sha3.keccak_256() + h.update(address_hex.encode('utf-8')) + z = h.digest() + + #checksum_address_hex = '0x' + checksum_address_hex = '' + + for (i, c) in enumerate(address_hex): + if c in '1234567890': + checksum_address_hex += c + elif c in 'abcdef': + if z[int(i / 2)] & (0x80 >> ((i % 2) * 4)) > 1: + checksum_address_hex += c.upper() + else: + checksum_address_hex += c + + return checksum_address_hex + +to_checksum = to_checksum_address + +ethereum_recid_modifier = 35 + +def chain_id_to_v(chain_id, signature): + v = signature[64] + return (chain_id * 2) + ethereum_recid_modifier + v + +def chainv_to_v(chain_id, v): + return v - ethereum_recid_modifier - (chain_id * 2) diff --git a/funga/eth/helper/__init__.py b/funga/eth/helper/__init__.py new file mode 100644 index 0000000..e41e515 --- /dev/null +++ b/funga/eth/helper/__init__.py @@ -0,0 +1 @@ +from .tx import EthTxExecutor diff --git a/funga/eth/helper/tx.py b/funga/eth/helper/tx.py new file mode 100644 index 0000000..9e6441b --- /dev/null +++ b/funga/eth/helper/tx.py @@ -0,0 +1,58 @@ +# standard imports +import logging + +# local imports +from crypto_dev_signer.helper import TxExecutor +from crypto_dev_signer.error import NetworkError + +logg = logging.getLogger() +logging.getLogger('web3').setLevel(logging.CRITICAL) +logging.getLogger('urllib3').setLevel(logging.CRITICAL) + + +class EthTxExecutor(TxExecutor): + + def __init__(self, w3, sender, signer, chain_id, verifier=None, block=False): + self.w3 = w3 + nonce = self.w3.eth.getTransactionCount(sender, 'pending') + super(EthTxExecutor, self).__init__(sender, signer, self.translator, self.dispatcher, self.reporter, nonce, chain_id, self.fee_helper, self.fee_price_helper, verifier, block) + + + def fee_helper(self, tx): + estimate = self.w3.eth.estimateGas(tx) + if estimate < 21000: + estimate = 21000 + logg.debug('estimate {} {}'.format(tx, estimate)) + return estimate + + + def fee_price_helper(self): + return self.w3.eth.gasPrice + + + def dispatcher(self, tx): + error_object = None + try: + tx_hash = self.w3.eth.sendRawTransaction(tx) + except ValueError as e: + error_object = e.args[0] + logg.error('node could not intepret rlp {}'.format(tx)) + if error_object != None: + raise NetworkError(error_object) + return tx_hash + + + def reporter(self, tx): + return self.w3.eth.getTransactionReceipt(tx) + + + def translator(self, tx): + if tx.get('feePrice') != None: + tx['gasPrice'] = tx['feePrice'] + del tx['feePrice'] + + if tx.get('feeUnits') != None: + tx['gas'] = tx['feeUnits'] + del tx['feeUnits'] + + return tx diff --git a/funga/eth/keystore/__init__.py b/funga/eth/keystore/__init__.py new file mode 100644 index 0000000..743986a --- /dev/null +++ b/funga/eth/keystore/__init__.py @@ -0,0 +1,8 @@ +# third-party imports +#from eth_keys import KeyAPI +#from eth_keys.backends import NativeECCBackend + +#keyapi = KeyAPI(NativeECCBackend) + +#from .postgres import ReferenceKeystore +#from .dict import DictKeystore diff --git a/funga/eth/keystore/dict.py b/funga/eth/keystore/dict.py new file mode 100644 index 0000000..b37da11 --- /dev/null +++ b/funga/eth/keystore/dict.py @@ -0,0 +1,45 @@ +# standard imports +import logging + +# external imports +from hexathon import ( + strip_0x, + add_0x, + ) + +# local imports +#from . import keyapi +from funga.error import UnknownAccountError +from .interface import EthKeystore +from funga.eth.encoding import private_key_to_address + +logg = logging.getLogger(__name__) + + +class DictKeystore(EthKeystore): + + def __init__(self): + super(DictKeystore, self).__init__() + self.keys = {} + + + def get(self, address, password=None): + address_key = strip_0x(address).lower() + if password != None: + logg.debug('password ignored as dictkeystore doesnt do encryption') + try: + return self.keys[address_key] + except KeyError: + raise UnknownAccountError(address_key) + + + def list(self): + return list(self.keys.keys()) + + + def import_key(self, pk, password=None): + address_hex = private_key_to_address(pk) + address_hex_clean = strip_0x(address_hex).lower() + self.keys[address_hex_clean] = pk.secret + logg.debug('added key {}'.format(address_hex)) + return add_0x(address_hex) diff --git a/funga/eth/keystore/interface.py b/funga/eth/keystore/interface.py new file mode 100644 index 0000000..e5fd3ea --- /dev/null +++ b/funga/eth/keystore/interface.py @@ -0,0 +1,50 @@ +# standard imports +import os +import json +import logging + +# local imports +from funga.eth.keystore import keyfile +from funga.eth.encoding import private_key_from_bytes +from funga.keystore import Keystore + +logg = logging.getLogger(__name__) + + +def native_keygen(*args, **kwargs): + return os.urandom(32) + + +class EthKeystore(Keystore): + + def __init__(self, private_key_generator=native_keygen): + super(EthKeystore, self).__init__(private_key_generator, private_key_from_bytes, keyfile.from_some) + + + def new(self, password=None): + b = self.private_key_generator() + return self.import_raw_key(b, password=password) + + + def import_raw_key(self, b, password=None): + pk = private_key_from_bytes(b) + return self.import_key(pk, password) + + + def import_key(self, pk, password=None): + raise NotImplementedError + + + def import_keystore_data(self, keystore_content, password=''): + if type(keystore_content).__name__ == 'str': + keystore_content = json.loads(keystore_content) + elif type(keystore_content).__name__ == 'bytes': + logg.debug('bytes {}'.format(keystore_content)) + keystore_content = json.loads(keystore_content.decode('utf-8')) + private_key = keyfile.from_dict(keystore_content, password.encode('utf-8')) + return self.import_raw_key(private_key, password) + + + def import_keystore_file(self, keystore_file, password=''): + private_key = keyfile.from_file(keystore_file, password) + return self.import_raw_key(private_key) diff --git a/funga/eth/keystore/keyfile.py b/funga/eth/keystore/keyfile.py new file mode 100644 index 0000000..a20061b --- /dev/null +++ b/funga/eth/keystore/keyfile.py @@ -0,0 +1,173 @@ +# standard imports +import os +import hashlib +import logging +import json +import uuid + +# external imports +import coincurve +from Crypto.Cipher import AES +from Crypto.Util import Counter +import sha3 + +# local imports +from funga.error import ( + DecryptError, + KeyfileError, + ) +from funga.eth.encoding import private_key_to_address + +logg = logging.getLogger(__name__) + +algo_keywords = [ + 'aes-128-ctr', + ] +hash_keywords = [ + 'scrypt' + ] + +default_kdfparams = { + 'dklen': 32, + 'n': 1 << 18, + 'p': 1, + 'r': 8, + 'salt': os.urandom(32).hex(), + } + + +def to_mac(mac_key, ciphertext_bytes): + h = sha3.keccak_256() + h.update(mac_key) + h.update(ciphertext_bytes) + return h.digest() + + +class Hashes: + + @staticmethod + def from_scrypt(kdfparams=default_kdfparams, passphrase=''): + dklen = int(kdfparams['dklen']) + n = int(kdfparams['n']) + p = int(kdfparams['p']) + r = int(kdfparams['r']) + salt = bytes.fromhex(kdfparams['salt']) + + return hashlib.scrypt(passphrase.encode('utf-8'), salt=salt,n=n, p=p, r=r, maxmem=1024*1024*1024, dklen=dklen) + + +class Ciphers: + + aes_128_block_size = 1 << 7 + aes_iv_len = 16 + + @staticmethod + def decrypt_aes_128_ctr(ciphertext, decryption_key, iv): + ctr = Counter.new(Ciphers.aes_128_block_size, initial_value=iv) + cipher = AES.new(decryption_key, AES.MODE_CTR, counter=ctr) + plaintext = cipher.decrypt(ciphertext) + return plaintext + + + @staticmethod + def encrypt_aes_128_ctr(plaintext, encryption_key, iv): + ctr = Counter.new(Ciphers.aes_128_block_size, initial_value=iv) + cipher = AES.new(encryption_key, AES.MODE_CTR, counter=ctr) + ciphertext = cipher.encrypt(plaintext) + return ciphertext + + +def to_dict(private_key_bytes, passphrase=''): + + private_key = coincurve.PrivateKey(secret=private_key_bytes) + + encryption_key = Hashes.from_scrypt(passphrase=passphrase) + + address_hex = private_key_to_address(private_key) + iv_bytes = os.urandom(Ciphers.aes_iv_len) + iv = int.from_bytes(iv_bytes, 'big') + ciphertext_bytes = Ciphers.encrypt_aes_128_ctr(private_key.secret, encryption_key[:16], iv) + + mac = to_mac(encryption_key[16:], ciphertext_bytes) + + crypto_dict = { + 'cipher': 'aes-128-ctr', + 'ciphertext': ciphertext_bytes.hex(), + 'cipherparams': { + 'iv': iv_bytes.hex(), + }, + 'kdf': 'scrypt', + 'kdfparams': default_kdfparams, + 'mac': mac.hex(), + } + + uu = uuid.uuid1() + o = { + 'address': address_hex, + 'version': 3, + 'crypto': crypto_dict, + 'id': str(uu), + } + return o + + +def from_dict(o, passphrase=''): + + cipher = o['crypto']['cipher'] + if cipher not in algo_keywords: + raise NotImplementedError('cipher "{}" not implemented'.format(cipher)) + + kdf = o['crypto']['kdf'] + if kdf not in hash_keywords: + raise NotImplementedError('kdf "{}" not implemented'.format(kdf)) + + m = getattr(Hashes, 'from_{}'.format(kdf.replace('-', '_'))) + decryption_key = m(o['crypto']['kdfparams'], passphrase) + + control_mac = bytes.fromhex(o['crypto']['mac']) + iv_bytes = bytes.fromhex(o['crypto']['cipherparams']['iv']) + iv = int.from_bytes(iv_bytes, "big") + ciphertext_bytes = bytes.fromhex(o['crypto']['ciphertext']) + + # check mac + calculated_mac = to_mac(decryption_key[16:], ciphertext_bytes) + if control_mac != calculated_mac: + raise DecryptError('mac mismatch when decrypting passphrase') + + m = getattr(Ciphers, 'decrypt_{}'.format(cipher.replace('-', '_'))) + + try: + pk = m(ciphertext_bytes, decryption_key[:16], iv) + except AssertionError as e: + raise DecryptError('could not decrypt keyfile: {}'.format(e)) + logg.debug('bar') + + return pk + + +def from_file(filepath, passphrase=''): + + f = open(filepath, 'r') + try: + o = json.load(f) + except json.decoder.JSONDecodeError as e: + f.close() + raise KeyfileError(e) + f.close() + + return from_dict(o, passphrase) + + +def from_some(v, passphrase=''): + if isinstance(v, bytes): + v = v.decode('utf-8') + + if isinstance(v, str): + try: + return from_file(v, passphrase) + except Exception: + logg.debug('keyfile parse as file fail') + pass + v = json.loads(v) + + return from_dict(v, passphrase) diff --git a/funga/eth/keystore/sql.py b/funga/eth/keystore/sql.py new file mode 100644 index 0000000..8fc4f56 --- /dev/null +++ b/funga/eth/keystore/sql.py @@ -0,0 +1,108 @@ +# standard imports +import logging +import base64 + +# external imports +from cryptography.fernet import Fernet +#import psycopg2 +from sqlalchemy import create_engine, text +from sqlalchemy.orm import sessionmaker +import sha3 +from hexathon import ( + strip_0x, + add_0x, + ) + +# local imports +from .interface import EthKeystore +#from . import keyapi +from funga.error import UnknownAccountError +from funga.eth.encoding import private_key_to_address + +logg = logging.getLogger(__name__) + + +def to_bytes(x): + return x.encode('utf-8') + + +class SQLKeystore(EthKeystore): + + schema = [ + """CREATE TABLE IF NOT EXISTS ethereum ( + id SERIAL NOT NULL PRIMARY KEY, + key_ciphertext VARCHAR(256) NOT NULL, + wallet_address_hex CHAR(40) NOT NULL + ); +""", + """CREATE UNIQUE INDEX IF NOT EXISTS ethereum_address_idx ON ethereum ( wallet_address_hex ); +""", + ] + + def __init__(self, dsn, **kwargs): + super(SQLKeystore, self).__init__() + logg.debug('starting db session with dsn {}'.format(dsn)) + self.db_engine = create_engine(dsn) + self.db_session = sessionmaker(bind=self.db_engine)() + for s in self.schema: + self.db_session.execute(s) + self.db_session.commit() + self.symmetric_key = kwargs.get('symmetric_key') + + + def __del__(self): + logg.debug('closing db session') + self.db_session.close() + + + def get(self, address, password=None): + safe_address = strip_0x(address).lower() + s = text('SELECT key_ciphertext FROM ethereum WHERE wallet_address_hex = :a') + r = self.db_session.execute(s, { + 'a': safe_address, + }, + ) + try: + k = r.first()[0] + except TypeError: + self.db_session.rollback() + raise UnknownAccountError(safe_address) + self.db_session.commit() + a = self._decrypt(k, password) + return a + + + def import_key(self, pk, password=None): + address_hex = private_key_to_address(pk) + address_hex_clean = strip_0x(address_hex).lower() + + c = self._encrypt(pk.secret, password) + s = text('INSERT INTO ethereum (wallet_address_hex, key_ciphertext) VALUES (:a, :c)') #%s, %s)') + self.db_session.execute(s, { + 'a': address_hex_clean, + 'c': c.decode('utf-8'), + }, + ) + self.db_session.commit() + logg.info('added private key for address {}'.format(address_hex_clean)) + return add_0x(address_hex) + + + def _encrypt(self, private_key, password): + f = self._generate_encryption_engine(password) + return f.encrypt(private_key) + + + def _generate_encryption_engine(self, password): + h = sha3.keccak_256() + h.update(self.symmetric_key) + if password != None: + password_bytes = to_bytes(password) + h.update(password_bytes) + g = h.digest() + return Fernet(base64.b64encode(g)) + + + def _decrypt(self, c, password): + f = self._generate_encryption_engine(password) + return f.decrypt(c.encode('utf-8')) diff --git a/funga/eth/runnable/keyfile.py b/funga/eth/runnable/keyfile.py new file mode 100644 index 0000000..327f771 --- /dev/null +++ b/funga/eth/runnable/keyfile.py @@ -0,0 +1,87 @@ +# standard imports +import os +import logging +import sys +import json +import argparse +import getpass + +# external impors +import coincurve +from hexathon import strip_0x + +# local imports +from funga.error import DecryptError +from funga.eth.keystore.keyfile import ( + from_file, + to_dict, + ) +from funga.eth.encoding import ( + private_key_to_address, + private_key_from_bytes, + ) + + +logging.basicConfig(level=logging.WARNING) +logg = logging.getLogger() + +argparser = argparse.ArgumentParser() +argparser.add_argument('-d', '--decrypt', dest='d', type=str, help='decrypt file') +argparser.add_argument('--private-key', dest='private_key', action='store_true', help='output private key instead of address') +argparser.add_argument('-z', action='store_true', help='zero-length password') +argparser.add_argument('-k', type=str, help='load key from file') +argparser.add_argument('-v', action='store_true', help='be verbose') +args = argparser.parse_args() + +if args.v: + logg.setLevel(logging.DEBUG) + +mode = 'create' +secret = False +if args.d: + mode = 'decrypt' + if args.private_key: + secret = True + +pk_hex = os.environ.get('PRIVATE_KEY') +if args.k != None: + f = open(args.k, 'r') + pk_hex = f.read(66) + f.close() + +def main(): + global pk_hex + + passphrase = os.environ.get('PASSPHRASE') + if args.z: + passphrase = '' + r = None + if mode == 'decrypt': + if passphrase == None: + passphrase = getpass.getpass('decryption phrase: ') + try: + r = from_file(args.d, passphrase).hex() + except DecryptError: + sys.stderr.write('Invalid passphrase\n') + sys.exit(1) + if not secret: + pk = private_key_from_bytes(bytes.fromhex(r)) + r = private_key_to_address(pk) + elif mode == 'create': + if passphrase == None: + passphrase = getpass.getpass('encryption phrase: ') + pk_bytes = None + if pk_hex != None: + pk_hex = strip_0x(pk_hex) + pk_bytes = bytes.fromhex(pk_hex) + else: + pk_bytes = os.urandom(32) + pk = coincurve.PrivateKey(secret=pk_bytes) + o = to_dict(pk_bytes, passphrase) + r = json.dumps(o) + + print(r) + + +if __name__ == '__main__': + main() diff --git a/funga/eth/runnable/signer.py b/funga/eth/runnable/signer.py new file mode 100755 index 0000000..86918c5 --- /dev/null +++ b/funga/eth/runnable/signer.py @@ -0,0 +1,122 @@ +# standard imports +import re +import os +import sys +import json +import logging +import argparse +from urllib.parse import urlparse + +# external imports +import confini +from jsonrpc.exceptions import * + +# local imports +from crypto_dev_signer.eth.signer import ReferenceSigner +from crypto_dev_signer.keystore.reference import ReferenceKeystore +from crypto_dev_signer.cli.handle import SignRequestHandler + +logging.basicConfig(level=logging.WARNING) +logg = logging.getLogger() + +config_dir = '.' + +db = None +signer = None +session = None +chainId = 8995 +socket_path = '/run/crypto-dev-signer/jsonrpc.ipc' + +argparser = argparse.ArgumentParser() +argparser.add_argument('-c', type=str, default=config_dir, help='config file') +argparser.add_argument('--env-prefix', default=os.environ.get('CONFINI_ENV_PREFIX'), dest='env_prefix', type=str, help='environment prefix for variables to overwrite configuration') +argparser.add_argument('-i', type=int, help='default chain id for EIP155') +argparser.add_argument('-s', type=str, help='socket path') +argparser.add_argument('-v', action='store_true', help='be verbose') +argparser.add_argument('-vv', action='store_true', help='be more verbose') +args = argparser.parse_args() + +if args.vv: + logging.getLogger().setLevel(logging.DEBUG) +elif args.v: + logging.getLogger().setLevel(logging.INFO) + +config = confini.Config(args.c, args.env_prefix) +config.process() +config.censor('PASSWORD', 'DATABASE') +config.censor('SECRET', 'SIGNER') +logg.debug('config loaded from {}:\n{}'.format(config_dir, config)) + +if args.i: + chainId = args.i +if args.s: + socket_url = urlparse(args.s) +elif config.get('SIGNER_SOCKET_PATH'): + socket_url = urlparse(config.get('SIGNER_SOCKET_PATH')) + + +# connect to database +dsn = 'postgresql://{}:{}@{}:{}/{}'.format( + config.get('DATABASE_USER'), + config.get('DATABASE_PASSWORD'), + config.get('DATABASE_HOST'), + config.get('DATABASE_PORT'), + config.get('DATABASE_NAME'), + ) + +logg.info('using dsn {}'.format(dsn)) +logg.info('using socket {}'.format(config.get('SIGNER_SOCKET_PATH'))) + +re_http = r'^http' +re_tcp = r'^tcp' +re_unix = r'^ipc' + +class MissingSecretError(Exception): + pass + + +def main(): + + secret_hex = config.get('SIGNER_SECRET') + if secret_hex == None: + raise MissingSecretError('please provide a valid hex value for the SIGNER_SECRET configuration variable') + + secret = bytes.fromhex(secret_hex) + kw = { + 'symmetric_key': secret, + } + SignRequestHandler.keystore = ReferenceKeystore(dsn, **kw) + SignRequestHandler.signer = ReferenceSigner(SignRequestHandler.keystore) + + arg = None + try: + arg = json.loads(sys.argv[1]) + except: + logg.info('no json rpc command detected, starting socket server {}'.format(socket_url)) + scheme = 'ipc' + if socket_url.scheme != '': + scheme = socket_url.scheme + if re.match(re_tcp, socket_url.scheme): + from crypto_dev_signer.cli.socket import start_server_tcp + socket_spec = socket_url.netloc.split(':') + host = socket_spec[0] + port = int(socket_spec[1]) + start_server_tcp((host, port)) + elif re.match(re_http, socket_url.scheme): + from crypto_dev_signer.cli.http import start_server_http + socket_spec = socket_url.netloc.split(':') + host = socket_spec[0] + port = int(socket_spec[1]) + start_server_http((host, port)) + else: + from crypto_dev_signer.cli.socket import start_server_unix + start_server_unix(socket_url.path) + sys.exit(0) + + (rpc_id, response) = process_input(arg) + r = jsonrpc_ok(rpc_id, response) + sys.stdout.write(json.dumps(r)) + + +if __name__ == '__main__': + main() diff --git a/funga/eth/signer/__init__.py b/funga/eth/signer/__init__.py new file mode 100644 index 0000000..95ec342 --- /dev/null +++ b/funga/eth/signer/__init__.py @@ -0,0 +1 @@ +from funga.eth.signer.defaultsigner import EIP155Signer diff --git a/funga/eth/signer/defaultsigner.py b/funga/eth/signer/defaultsigner.py new file mode 100644 index 0000000..8c6f19d --- /dev/null +++ b/funga/eth/signer/defaultsigner.py @@ -0,0 +1,79 @@ +# standard imports +import logging + +# external imports +import sha3 +import coincurve +from hexathon import int_to_minbytes + +# local imports +from funga.signer import Signer +from funga.eth.encoding import chain_id_to_v + +logg = logging.getLogger(__name__) + + +class EIP155Signer(Signer): + + def __init__(self, keyGetter): + super(EIP155Signer, self).__init__(keyGetter) + + + def sign_transaction(self, tx, password=None): + s = tx.rlp_serialize() + h = sha3.keccak_256() + h.update(s) + message_to_sign = h.digest() + z = self.sign_pure(tx.sender, message_to_sign, password) + + return z + + + def sign_transaction_to_rlp(self, tx, password=None): + chain_id = int.from_bytes(tx.v, byteorder='big') + sig = self.sign_transaction(tx, password) + tx.apply_signature(chain_id, sig) + return tx.rlp_serialize() + + + def sign_transaction_to_wire(self, tx, password=None): + return self.sign_transaction_to_rlp(tx, password=password) + + + def sign_ethereum_message(self, address, message, password=None): + + #k = keys.PrivateKey(self.keyGetter.get(address, password)) + #z = keys.ecdsa_sign(message_hash=g, private_key=k) + if type(message).__name__ == 'str': + logg.debug('signing message in "str" format: {}'.format(message)) + #z = k.sign_msg(bytes.fromhex(message)) + message = bytes.fromhex(message) + elif type(message).__name__ == 'bytes': + logg.debug('signing message in "bytes" format: {}'.format(message.hex())) + #z = k.sign_msg(message) + else: + logg.debug('unhandled format {}'.format(type(message).__name__)) + raise ValueError('message must be type str or bytes, received {}'.format(type(message).__name__)) + + ethereumed_message_header = b'\x19' + 'Ethereum Signed Message:\n{}'.format(len(message)).encode('utf-8') + h = sha3.keccak_256() + h.update(ethereumed_message_header + message) + message_to_sign = h.digest() + + z = self.sign_pure(address, message_to_sign, password) + return z + + + # TODO: generic sign should be moved to non-eth context + def sign_pure(self, address, message, password=None): + pk = coincurve.PrivateKey(secret=self.keyGetter.get(address, password)) + z = pk.sign_recoverable(hasher=None, message=message) + return z + + + def sign_message(self, address, message, password=None, dialect='eth'): + if dialect == None: + return self.sign_pure(address, message, password=password) + elif dialect == 'eth': + return self.sign_ethereum_message(address, message, password=password) + raise ValueError('Unknown message sign dialect "{}"'.format(dialect)) diff --git a/funga/eth/transaction.py b/funga/eth/transaction.py new file mode 100644 index 0000000..105df3a --- /dev/null +++ b/funga/eth/transaction.py @@ -0,0 +1,172 @@ +# standard imports +import logging +import binascii +import re + +# external imports +#from rlp import encode as rlp_encode +from hexathon import ( + strip_0x, + add_0x, + int_to_minbytes, + ) + +# local imports +from funga.eth.encoding import chain_id_to_v +#from crypto_dev_signer.eth.rlp import rlp_encode +import rlp + +logg = logging.getLogger(__name__) + +rlp_encode = rlp.encode + +class Transaction: + + def rlp_serialize(self): + raise NotImplementedError + + def serialize(self): + raise NotImplementedError + + +class EIP155Transaction: + + def __init__(self, tx, nonce_in, chainId_in=1): + to = b'' + data = b'' + if tx.get('to') != None: + to = bytes.fromhex(strip_0x(tx['to'], allow_empty=True)) + if tx.get('data') != None: + data = bytes.fromhex(strip_0x(tx['data'], allow_empty=True)) + + gas_price = None + start_gas = None + value = None + nonce = None + chainId = None + + # TODO: go directly from hex to bytes + try: + gas_price = int(tx['gasPrice']) + byts = ((gas_price.bit_length()-1)/8)+1 + gas_price = gas_price.to_bytes(int(byts), 'big') + except ValueError: + gas_price = bytes.fromhex(strip_0x(tx['gasPrice'], allow_empty=True)) + + try: + start_gas = int(tx['gas']) + byts = ((start_gas.bit_length()-1)/8)+1 + start_gas = start_gas.to_bytes(int(byts), 'big') + except ValueError: + start_gas = bytes.fromhex(strip_0x(tx['gas'], allow_empty=True)) + + try: + value = int(tx['value']) + byts = ((value.bit_length()-1)/8)+1 + value = value.to_bytes(int(byts), 'big') + except ValueError: + value = bytes.fromhex(strip_0x(tx['value'], allow_empty=True)) + + try: + nonce = int(nonce_in) + byts = ((nonce.bit_length()-1)/8)+1 + nonce = nonce.to_bytes(int(byts), 'big') + except ValueError: + nonce = bytes.fromhex(strip_0x(nonce_in, allow_empty=True)) + + try: + chainId = int(chainId_in) + byts = ((chainId.bit_length()-1)/8)+1 + chainId = chainId.to_bytes(int(byts), 'big') + except ValueError: + chainId = bytes.fromhex(strip_0x(chainId_in, allow_empty=True)) + + self.nonce = nonce + self.gas_price = gas_price + self.start_gas = start_gas + self.to = to + self.value = value + self.data = data + self.v = chainId + self.r = b'' + self.s = b'' + self.sender = strip_0x(tx['from']) + + + def canonical_order(self): + s = [ + self.nonce, + self.gas_price, + self.start_gas, + self.to, + self.value, + self.data, + self.v, + self.r, + self.s, + ] + + return s + + + def bytes_serialize(self): + s = self.canonical_order() + b = b'' + for e in s: + b += e + return b + + + def rlp_serialize(self): + s = self.canonical_order() + return rlp_encode(s) + + + def serialize(self): + tx = { + 'nonce': add_0x(self.nonce.hex(), allow_empty=True), + 'gasPrice': add_0x(self.gas_price.hex()), + 'gas': add_0x(self.start_gas.hex()), + 'value': add_0x(self.value.hex(), allow_empty=True), + 'data': add_0x(self.data.hex(), allow_empty=True), + 'v': add_0x(self.v.hex(), allow_empty=True), + 'r': add_0x(self.r.hex(), allow_empty=True), + 's': add_0x(self.s.hex(), allow_empty=True), + } + if self.to == None or len(self.to) == 0: + tx['to'] = None + else: + tx['to'] = add_0x(self.to.hex()) + + if tx['data'] == '': + tx['data'] = '0x' + + if tx['value'] == '': + tx['value'] = '0x00' + + if tx['nonce'] == '': + tx['nonce'] = '0x00' + + return tx + + + def apply_signature(self, chain_id, signature, v=None): + if len(self.r + self.s) > 0: + raise AttributeError('signature already set') + if len(signature) < 65: + raise ValueError('invalid signature length') + if v == None: + v = chain_id_to_v(chain_id, signature) + self.v = int_to_minbytes(v) + self.r = signature[:32] + self.s = signature[32:64] + + for i in range(len(self.r)): + if self.r[i] > 0: + self.r = self.r[i:] + break + + for i in range(len(self.s)): + if self.s[i] > 0: + self.s = self.s[i:] + break diff --git a/funga/eth/web3ext/__init__.py b/funga/eth/web3ext/__init__.py new file mode 100644 index 0000000..f031592 --- /dev/null +++ b/funga/eth/web3ext/__init__.py @@ -0,0 +1,29 @@ +import logging +import re + +from web3 import Web3 as Web3super +from web3 import WebsocketProvider, HTTPProvider +from .middleware import PlatformMiddleware + +re_websocket = re.compile('^wss?://') +re_http = re.compile('^https?://') + +logg = logging.getLogger(__file__) + + +def create_middleware(ipcpath): + PlatformMiddleware.ipcaddr = ipcpath + return PlatformMiddleware + + +# overrides the original Web3 constructor +#def Web3(blockchain_provider='ws://localhost:8546', ipcpath=None): +def Web3(provider, ipcpath=None): + w3 = Web3super(provider) + + if ipcpath != None: + logg.info('using signer middleware with ipc {}'.format(ipcpath)) + w3.middleware_onion.add(create_middleware(ipcpath)) + + w3.eth.personal = w3.geth.personal + return w3 diff --git a/funga/eth/web3ext/middleware.py b/funga/eth/web3ext/middleware.py new file mode 100644 index 0000000..5f72ba9 --- /dev/null +++ b/funga/eth/web3ext/middleware.py @@ -0,0 +1,116 @@ +# standard imports +import logging +import re +import socket +import uuid +import json + +logg = logging.getLogger(__file__) + + +def jsonrpc_request(method, params): + uu = uuid.uuid4() + return { + "jsonrpc": "2.0", + "id": str(uu), + "method": method, + "params": params, + } + +class PlatformMiddleware: + + # id for the request is not available, meaning we cannot easily short-circuit + # hack workaround + id_seq = -1 + re_personal = re.compile('^personal_.*') + ipcaddr = None + + + def __init__(self, make_request, w3): + self.w3 = w3 + self.make_request = make_request + if self.ipcaddr == None: + raise AttributeError('ipcaddr not set') + + + # TODO: understand what format input params come in + # single entry input gives a tuple on params, wtf... + # dict input comes as [{}] and fails if not passed on as an array + @staticmethod + def _translate_params(params): + #if params.__class__.__name__ == 'tuple': + # r = [] + # for p in params: + # r.append(p) + # return r + + if params.__class__.__name__ == 'list' and len(params) > 0: + return params[0] + + return params + + + # TODO: DRY + def __call__(self, method, suspect_params): + + self.id_seq += 1 + logg.debug('in middleware method {} params {} ipcpath {}'.format(method, suspect_params, self.ipcaddr)) + + if self.re_personal.match(method) != None: + params = PlatformMiddleware._translate_params(suspect_params) + # multiple providers is removed in web3.py 5.12.0 + # https://github.com/ethereum/web3.py/issues/1701 + # thus we need a workaround to use the same web3 instance + s = socket.socket(family=socket.AF_UNIX, type=socket.SOCK_STREAM, proto=0) + ipc_provider_workaround = s.connect(self.ipcaddr) + + logg.info('redirecting method {} params {} original params {}'.format(method, params, suspect_params)) + o = jsonrpc_request(method, params[0]) + j = json.dumps(o) + logg.debug('send {}'.format(j)) + s.send(j.encode('utf-8')) + r = s.recv(4096) + s.close() + logg.debug('got recv {}'.format(str(r))) + jr = json.loads(r) + jr['id'] = self.id_seq + #return str(json.dumps(jr)) + return jr + + elif method == 'eth_signTransaction': + params = PlatformMiddleware._translate_params(suspect_params) + s = socket.socket(family=socket.AF_UNIX, type=socket.SOCK_STREAM, proto=0) + ipc_provider_workaround = s.connect(self.ipcaddr) + logg.info('redirecting method {} params {} original params {}'.format(method, params, suspect_params)) + o = jsonrpc_request(method, params[0]) + j = json.dumps(o) + logg.debug('send {}'.format(j)) + s.send(j.encode('utf-8')) + r = s.recv(4096) + s.close() + logg.debug('got recv {}'.format(str(r))) + jr = json.loads(r) + jr['id'] = self.id_seq + #return str(json.dumps(jr)) + return jr + + elif method == 'eth_sign': + params = PlatformMiddleware._translate_params(suspect_params) + s = socket.socket(family=socket.AF_UNIX, type=socket.SOCK_STREAM, proto=0) + ipc_provider_workaround = s.connect(self.ipcaddr) + logg.info('redirecting method {} params {} original params {}'.format(method, params, suspect_params)) + o = jsonrpc_request(method, params) + j = json.dumps(o) + logg.debug('send {}'.format(j)) + s.send(j.encode('utf-8')) + r = s.recv(4096) + s.close() + logg.debug('got recv {}'.format(str(r))) + jr = json.loads(r) + jr['id'] = self.id_seq + return jr + + + + r = self.make_request(method, suspect_params) + return r diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..0b70081 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,9 @@ +cryptography==3.2.1 +pysha3==1.0.2 +rlp==2.0.1 +json-rpc==1.13.0 +confini>=0.3.6rc3,<0.5.0 +coincurve==15.0.0 +hexathon~=0.0.1a7 +pycryptodome==3.10.1 +funga>=0.5.1a1,<0.6.0 diff --git a/setup.cfg b/setup.cfg new file mode 100644 index 0000000..b802f72 --- /dev/null +++ b/setup.cfg @@ -0,0 +1,11 @@ +[metadata] +classifiers = + Programming Language :: Python :: 3 + Operating System :: OS Independent + Development Status :: 3 - Alpha + Intended Audience :: Developers + Topic :: Software Development :: Libraries + License :: OSI Approved :: GNU General Public License v3 or later (GPLv3+) +license = GPLv3 +license_files = + LICENSE.txt diff --git a/setup.py b/setup.py new file mode 100644 index 0000000..76c3f04 --- /dev/null +++ b/setup.py @@ -0,0 +1,61 @@ +from setuptools import setup + +f = open('README.md', 'r') +long_description = f.read() +f.close() + +requirements = [] +f = open('requirements.txt', 'r') +while True: + l = f.readline() + if l == '': + break + requirements.append(l.rstrip()) +f.close() + +sql_requirements = [] +f = open('sql_requirements.txt', 'r') +while True: + l = f.readline() + if l == '': + break + sql_requirements.append(l.rstrip()) +f.close() + +test_requirements = [] +f = open('test_requirements.txt', 'r') +while True: + l = f.readline() + if l == '': + break + test_requirements.append(l.rstrip()) +f.close() + +setup( + name="funga-eth", + version="0.5.1a1", + description="Ethereum implementation of the funga keystore and signer", + author="Louis Holbrook", + author_email="dev@holbrook.no", + packages=[ + 'funga.eth.signer', + 'funga.eth', + 'funga.eth.cli', + 'funga.eth.keystore', + 'funga.eth.runnable', + ], + install_requires=requirements, + extras_require={ + 'sql': sql_requirements, + }, + tests_require=test_requirements, + long_description=long_description, + long_description_content_type='text/markdown', + entry_points = { + 'console_scripts': [ + 'funga-eth=funga.eth.runnable.signer:main', + 'eth-keyfile=funga.eth.runnable.keyfile:main', + ], + }, + url='https://gitlab.com/chaintool/funga-eth', + ) diff --git a/sql_requirements.txt b/sql_requirements.txt new file mode 100644 index 0000000..93ce618 --- /dev/null +++ b/sql_requirements.txt @@ -0,0 +1,2 @@ +psycopg2==2.8.6 +sqlalchemy==1.3.20 diff --git a/test_requirements.txt b/test_requirements.txt new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_cli.py b/tests/test_cli.py new file mode 100644 index 0000000..d7a1898 --- /dev/null +++ b/tests/test_cli.py @@ -0,0 +1,88 @@ +# standard imports +import unittest +import logging +import os + +# external imports +from hexathon import strip_0x + +# local imports +from funga.eth.signer import EIP155Signer +from funga.eth.keystore.dict import DictKeystore +from funga.eth.cli.handle import SignRequestHandler +from funga.eth.transaction import EIP155Transaction + +logging.basicConfig(level=logging.DEBUG) +logg = logging.getLogger() + +script_dir = os.path.dirname(os.path.realpath(__file__)) +data_dir = os.path.join(script_dir, 'testdata') + +class TestCli(unittest.TestCase): + + def setUp(self): + #pk = bytes.fromhex('5087503f0a9cc35b38665955eb830c63f778453dd11b8fa5bd04bc41fd2cc6d6') + #pk_getter = pkGetter(pk) + self.keystore = DictKeystore() + SignRequestHandler.keystore = self.keystore + self.signer = EIP155Signer(self.keystore) + SignRequestHandler.signer = self.signer + self.handler = SignRequestHandler() + + + def test_new_account(self): + q = { + 'id': 0, + 'method': 'personal_newAccount', + 'params': [''], + } + (rpc_id, result) = self.handler.process_input(q) + self.assertTrue(self.keystore.get(result)) + + + def test_sign_tx(self): + keystore_file = os.path.join(data_dir, 'UTC--2021-01-08T18-37-01.187235289Z--00a329c0648769a73afac7f9381e08fb43dbea72') + sender = self.keystore.import_keystore_file(keystore_file) + tx_hexs = { + 'nonce': '0x', + 'from': sender, + 'gasPrice': "0x04a817c800", + 'gas': "0x5208", + 'to': '0x3535353535353535353535353535353535353535', + 'value': "0x03e8", + 'data': "0xdeadbeef", + 'chainId': 8995, + } + tx = EIP155Transaction(tx_hexs, 42, 8995) + tx_s = tx.serialize() + + # TODO: move to serialization wrapper for tests + tx_s['chainId'] = tx_s['v'] + tx_s['from'] = sender + + # eth_signTransaction wraps personal_signTransaction, so here we test both already + q = { + 'id': 0, + 'method': 'eth_signTransaction', + 'params': [tx_s], + } + (rpc_id, result) = self.handler.process_input(q) + logg.debug('result {}'.format(result)) + + self.assertEqual(strip_0x(result), 'f86c2a8504a817c8008252089435353535353535353535353535353535353535358203e884deadbeef82466aa0b7c1bbf52f736ada30fe253c7484176f44d6fd097a9720dc85ae5bbc7f060e54a07afee2563b0cf6d00333df51cc62b0d13c63108b2bce54ce2ad24e26ce7b4f25') + + def test_sign_msg(self): + keystore_file = os.path.join(data_dir, 'UTC--2021-01-08T18-37-01.187235289Z--00a329c0648769a73afac7f9381e08fb43dbea72') + sender = self.keystore.import_keystore_file(keystore_file) + q = { + 'id': 0, + 'method': 'eth_sign', + 'params': [sender, '0xdeadbeef'], + } + (rpc_id, result) = self.handler.process_input(q) + logg.debug('result msg {}'.format(result)) + self.assertEqual(strip_0x(result), '50320dda75190a121b7b5979de66edadafd02bdfbe4f6d49552e79c01410d2464aae35e385c0e5b61663ff7b44ef65fa0ac7ad8a57472cf405db399b9dba3e1600') + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/test_keystore_dict.py b/tests/test_keystore_dict.py new file mode 100644 index 0000000..7d32ef6 --- /dev/null +++ b/tests/test_keystore_dict.py @@ -0,0 +1,63 @@ +#!/usr/bin/python + +# standard imports +import unittest +import logging +import base64 +import os + +# external imports +from hexathon import ( + strip_0x, + add_0x, + ) + +# local imports +from funga.error import UnknownAccountError +from funga.eth.keystore.dict import DictKeystore +from funga.eth.signer import EIP155Signer + +logging.basicConfig(level=logging.DEBUG) +logg = logging.getLogger() + +script_dir = os.path.realpath(os.path.dirname(__file__)) + + +class TestDict(unittest.TestCase): + + address_hex = None + db = None + + def setUp(self): + self.db = DictKeystore() + + keystore_filepath = os.path.join(script_dir, 'testdata', 'UTC--2021-01-08T18-37-01.187235289Z--00a329c0648769a73afac7f9381e08fb43dbea72') + + address_hex = self.db.import_keystore_file(keystore_filepath, '') + self.address_hex = add_0x(address_hex) + + + def tearDown(self): + pass + + + def test_get_key(self): + logg.debug('getting {}'.format(strip_0x(self.address_hex))) + pk = self.db.get(strip_0x(self.address_hex), '') + + self.assertEqual(self.address_hex.lower(), '0x00a329c0648769a73afac7f9381e08fb43dbea72') + + bogus_account = os.urandom(20).hex() + with self.assertRaises(UnknownAccountError): + self.db.get(bogus_account, '') + + + def test_sign_message(self): + s = EIP155Signer(self.db) + z = s.sign_ethereum_message(strip_0x(self.address_hex), b'foo') + logg.debug('zzz {}'.format(str(z))) + + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/test_keystore_reference.py b/tests/test_keystore_reference.py new file mode 100644 index 0000000..46e1653 --- /dev/null +++ b/tests/test_keystore_reference.py @@ -0,0 +1,64 @@ +#!/usr/bin/python + +# standard imports +import unittest +import logging +import base64 +import os + +# external imports +import psycopg2 +from psycopg2 import sql +from cryptography.fernet import Fernet, InvalidToken + +# local imports +from funga.eth.keystore.sql import SQLKeystore +from funga.error import UnknownAccountError + +logging.basicConfig(level=logging.DEBUG) +logg = logging.getLogger() + + +class TestDatabase(unittest.TestCase): + + conn = None + cur = None + symkey = None + address_hex = None + db = None + + def setUp(self): + logg.debug('setup') + # arbitrary value + symkey_hex = 'E92431CAEE69313A7BE9E443C4ABEED9BF8157E9A13553B4D5D6E7D51B5021D9' + self.symkey = bytes.fromhex(symkey_hex) + self.address_hex = '9FA61f0E52A5C51b43f0d32404625BC436bb7041' + + kw = { + 'symmetric_key': self.symkey, + } + self.db = SQLKeystore('postgres+psycopg2://postgres@localhost:5432/signer_test', **kw) + self.address_hex = self.db.new('foo') + #self.address_hex = add_0x(address_hex) + + + def tearDown(self): + self.db.db_session.execute('DROP INDEX ethereum_address_idx;') + self.db.db_session.execute('DROP TABLE ethereum;') + self.db.db_session.commit() + + + + def test_get_key(self): + logg.debug('getting {}'.format(self.address_hex)) + self.db.get(self.address_hex, 'foo') + with self.assertRaises(InvalidToken): + self.db.get(self.address_hex, 'bar') + + bogus_account = '0x' + os.urandom(20).hex() + with self.assertRaises(UnknownAccountError): + self.db.get(bogus_account, 'bar') + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/test_sign.py b/tests/test_sign.py new file mode 100644 index 0000000..dd81844 --- /dev/null +++ b/tests/test_sign.py @@ -0,0 +1,99 @@ +# standard imports +import unittest +import logging +import json + +from rlp import encode as rlp_encode + +from funga.eth.signer import EIP155Signer +from funga.eth.transaction import EIP155Transaction + +logging.basicConfig(level=logging.DEBUG) +logg = logging.getLogger() + + +tx_ints = { + 'nonce': 0, + 'from': "0xEB014f8c8B418Db6b45774c326A0E64C78914dC0", + 'gasPrice': "20000000000", + 'gas': "21000", + 'to': '0x3535353535353535353535353535353535353535', + 'value': "1000", + 'data': "deadbeef", +} + +tx_hexs = { + 'nonce': '0x0', + 'from': "0xEB014f8c8B418Db6b45774c326A0E64C78914dC0", + 'gasPrice': "0x4a817c800", + 'gas': "0x5208", + 'to': '0x3535353535353535353535353535353535353535', + 'value': "0x3e8", + 'data': "deadbeef", +} + +class pkGetter: + + def __init__(self, pk): + self.pk = pk + + def get(self, address, password=None): + return self.pk + + +class TestSign(unittest.TestCase): + + pk = None + nonce = -1 + pk_getter = None + + + def getNonce(self): + self.nonce += 1 + return self.nonce + + + def setUp(self): + self.pk = bytes.fromhex('5087503f0a9cc35b38665955eb830c63f778453dd11b8fa5bd04bc41fd2cc6d6') + self.pk_getter = pkGetter(self.pk) + + + def tearDown(self): + logg.info('teardown empty') + + + + # TODO: verify rlp tx output + def test_serialize_transaction(self): + t = EIP155Transaction(tx_ints, 0) + self.assertRegex(t.__class__.__name__, "Transaction") + s = t.serialize() + self.assertDictEqual(s, {'nonce': '0x', 'gasPrice': '0x04a817c800', 'gas': '0x5208', 'to': '0x3535353535353535353535353535353535353535', 'value': '0x03e8', 'data': '0xdeadbeef', 'v': '0x01', 'r': '0x', 's': '0x'}) + r = t.rlp_serialize() + self.assertEqual(r.hex(), 'ea808504a817c8008252089435353535353535353535353535353535353535358203e884deadbeef018080') + + t = EIP155Transaction(tx_hexs, 0) + self.assertRegex(t.__class__.__name__, "Transaction") + s = t.serialize() + #o = json.loads(s) + self.assertDictEqual(s, {'nonce': '0x', 'gasPrice': '0x04a817c800', 'gas': '0x5208', 'to': '0x3535353535353535353535353535353535353535', 'value': '0x03e8', 'data': '0xdeadbeef', 'v': '0x01', 'r': '0x', 's': '0x'}) + r = t.rlp_serialize() + self.assertEqual(r.hex(), 'ea808504a817c8008252089435353535353535353535353535353535353535358203e884deadbeef018080') + + + + def test_sign_transaction(self): + t = EIP155Transaction(tx_ints, 461, 8995) + s = EIP155Signer(self.pk_getter) + z = s.sign_transaction(t) + + + def test_sign_message(self): + s = EIP155Signer(self.pk_getter) + z = s.sign_ethereum_message(tx_ints['from'], '666f6f') + z = s.sign_ethereum_message(tx_ints['from'], b'foo') + logg.debug('zzz {}'.format(str(z))) + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/test_socket.py b/tests/test_socket.py new file mode 100644 index 0000000..cb99d66 --- /dev/null +++ b/tests/test_socket.py @@ -0,0 +1,15 @@ +# standard imports +import unittest +import logging + +logg = logging.getLogger(__name__) + + +class SocketTest(unittest.TestCase): + + def test_placeholder_warning(self): + logg.warning('socket tests are missing! :/') + + +if __name__ == '__main__': + unittest.main()