Accomodate basic trezor signer URI scheme; Fixturize mocked trezorlib components

pull/2220/head
Kieran Prasch 2020-09-04 10:10:09 -07:00
parent 9bf1e995c9
commit 6a0e1eeb17
No known key found for this signature in database
GPG Key ID: 199AB839D4125A62
5 changed files with 302 additions and 7 deletions

View File

@ -50,7 +50,7 @@ class Signer(ABC):
@classmethod
def from_signer_uri(cls, uri: str) -> 'Signer':
parsed = urlparse(uri)
scheme = parsed.scheme
scheme = parsed.scheme if parsed.scheme else parsed.path
try:
signer_class = cls.SIGNERS[scheme]
except KeyError:

View File

@ -160,9 +160,8 @@ class TrezorSigner(Signer):
@classmethod
def from_signer_uri(cls, uri: str) -> 'TrezorSigner':
"""Return a trezor signer from URI string i.e. trezor:///my/trezor/path """
decoded_uri = urlparse(uri)
if decoded_uri.scheme != cls.URI_SCHEME or decoded_uri.netloc:
raise cls.InvalidSignerURI(uri)
if uri != cls.URI_SCHEME:
raise cls.InvalidSignerURI(f'{uri} is not a valid trezor URI scheme')
return cls()
def is_device(self, account: str) -> bool:

View File

@ -257,7 +257,11 @@ class KeystoreSigner(Signer):
else:
raise self.InvalidSignerURI(f'Invalid keystore file or directory "{path}"')
except FileNotFoundError:
raise self.InvalidSignerURI(f'No such keystore file or directory "{path}"')
if not path:
message = 'Blank signer URI - No keystore path provided'
else:
message = f'No such keystore file or directory "{path}"'
raise self.InvalidSignerURI(message)
except OSError as exc:
raise self.InvalidSignerURI(f'Error accessing keystore file or directory "{path}": {exc}')
for path in paths:
@ -318,7 +322,7 @@ class KeystoreSigner(Signer):
def from_signer_uri(cls, uri: str) -> 'Signer':
"""Return a keystore signer from URI string i.e. keystore:///my/path/keystore """
decoded_uri = urlparse(uri)
if decoded_uri.scheme != cls.URI_SCHEME or not decoded_uri.netloc:
if decoded_uri.scheme != cls.URI_SCHEME or decoded_uri.netloc:
raise cls.InvalidSignerURI(uri)
return cls(path=decoded_uri.path)

View File

@ -54,7 +54,7 @@ def test_ursula_init_with_local_keystore_signer(click_runner,
custom_filepath = tmp_path
custom_config_filepath = tmp_path / UrsulaConfiguration.generate_filename()
worker_account, password, mock_keystore_path = mock_account_password_keystore
mock_signer_uri = f'keystore:{mock_keystore_path}'
mock_signer_uri = f'keystore://{mock_keystore_path}'
# Good signer...
pre_config_signer = KeystoreSigner.from_signer_uri(uri=mock_signer_uri)

View File

@ -0,0 +1,292 @@
"""
This file is part of nucypher.
nucypher is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
nucypher is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with nucypher. If not, see <https://www.gnu.org/licenses/>.
"""
import json
import shutil
from pathlib import Path
import pytest
from cytoolz.dicttoolz import assoc
from eth_account import Account
from eth_account._utils.transactions import Transaction
from eth_utils import to_checksum_address
from hexbytes import HexBytes
from trezorlib.messages import EthereumGetAddress
from web3 import Web3
from nucypher.blockchain.eth import signers
from nucypher.blockchain.eth.constants import LENGTH_ECDSA_SIGNATURE_WITH_RECOVERY
from nucypher.blockchain.eth.signers import KeystoreSigner, Signer
from nucypher.blockchain.eth.signers import TrezorSigner
from tests.constants import INSECURE_DEVELOPMENT_PASSWORD
# Example keystore filename
MOCK_KEYFILE_NAME = 'UTC--2019-12-04T05-39-04.006429310Z--0xdeadbeef'
MOCK_KEYFILE = {'address': '0x13978aee95f38490e9769C39B2773Ed763d9cd5F', 'version': 3}
TRANSACTION_DICT = {
"chainId": None,
"nonce": 0,
"gasPrice": 1000000000000,
"gas": 10000,
"to": "0x13978aee95f38490e9769C39B2773Ed763d9cd5F",
"value": 10000000000000000,
"data": ""
}
@pytest.fixture(scope='module')
def mock_key():
test_key = Account.create(extra_entropy='M*A*S*H* DIWOKNECNECENOE#@!')
return test_key
@pytest.fixture(scope='module')
def mock_account(mock_key):
account = Account.from_key(private_key=mock_key.privateKey)
return account
@pytest.fixture(scope='module')
def mock_keystore(mock_account, tmp_path_factory):
keystore = tmp_path_factory.mktemp('keystore')
json.dump(
mock_account.encrypt(INSECURE_DEVELOPMENT_PASSWORD),
open(keystore / MOCK_KEYFILE_NAME, 'x+t')
)
return keystore
@pytest.fixture(scope='function')
def good_signer(mocker, mock_account, mock_keystore):
# Return a "real" account address from the keyfile
mock_keystore_path = mock_keystore
mock_keystore_uri = f'keystore:{mock_keystore}'
signer = Signer.from_signer_uri(uri=mock_keystore_uri) # type: KeystoreSigner
# unlock
signer.unlock_account(account=mock_account.address, password=INSECURE_DEVELOPMENT_PASSWORD)
return signer
@pytest.fixture(scope='module')
def unknown_address():
address = Account.create().address
return address
def test_blank_keystore_uri():
with pytest.raises(Signer.InvalidSignerURI, match='Blank signer URI - No keystore path provided') as error:
Signer.from_signer_uri(uri='keystore://') # it's blank!
def test_invalid_keystore(mocker, tmp_path):
with pytest.raises(Signer.InvalidSignerURI) as e:
Signer.from_signer_uri(uri=f'keystore:{tmp_path/"nonexistent"}')
empty_path = tmp_path / 'empty_file'
open(empty_path, 'x+t').close()
with pytest.raises(KeystoreSigner.InvalidKeyfile, match=
'Invalid JSON in keyfile at') as e:
Signer.from_signer_uri(uri=f'keystore:{empty_path}')
empty_json = tmp_path / 'empty_json'
json.dump({}, open(empty_json, 'x+t'))
with pytest.raises(KeystoreSigner.InvalidKeyfile, match=
'Keyfile does not contain address field at') as e:
Signer.from_signer_uri(uri=f'keystore:{empty_json}')
bad_address = tmp_path / 'bad_address'
json.dump({'address':''}, open(bad_address, 'x+t'))
with pytest.raises(KeystoreSigner.InvalidKeyfile, match=
'does not contain a valid ethereum address') as e:
Signer.from_signer_uri(uri=f'keystore:{bad_address}')
def test_signer_reads_keystore_from_disk(mock_account, mock_key, tmpdir):
# Test reading a keyfile from the disk via KeystoreSigner since
# it is mocked for the rest of this test module
fake_ethereum = Path(tmpdir) / '.fake-ethereum'
try:
fake_ethereum.mkdir()
tmp_keystore = Path(tmpdir) / '.fake-ethereum' / 'keystore'
tmp_keystore.mkdir()
mock_keyfile_path = tmp_keystore / MOCK_KEYFILE_NAME
mock_keyfile_path.touch(exist_ok=True)
with open(mock_keyfile_path, 'w') as fake_keyfile:
fake_keyfile.write(json.dumps(MOCK_KEYFILE))
mock_keystore_uri = f'keystore://{tmp_keystore}'
signer = Signer.from_signer_uri(uri=mock_keystore_uri)
assert signer.path == str(tmp_keystore)
assert len(signer.accounts) == 1
assert MOCK_KEYFILE['address'] in signer.accounts
finally:
if fake_ethereum.exists():
shutil.rmtree(fake_ethereum, ignore_errors=True)
def test_create_signer_from_keystore_directory(mocker, mock_account, mock_keystore):
mock_keystore_path = mock_keystore
mock_keystore_uri = f'keystore:{mock_keystore_path}'
# Return a "real" account address from the keyfile
signer = Signer.from_signer_uri(uri=mock_keystore_uri) # type: KeystoreSigner
assert signer.path == str(mock_keystore_path)
assert len(signer.accounts) == 1
assert mock_account.address in signer.accounts
def test_create_signer_from_keystore_file(mocker, mock_account, mock_keystore):
mock_keystore_path = mock_keystore / MOCK_KEYFILE_NAME
mock_keystore_uri = f'keystore:{mock_keystore_path}'
# Return a "real" account address from the keyfile
signer = Signer.from_signer_uri(uri=mock_keystore_uri) # type: KeystoreSigner
assert signer.path == str(mock_keystore_path)
assert len(signer.accounts) == 1
assert mock_account.address in signer.accounts
def test_keystore_locking(mocker, mock_account, good_signer, unknown_address):
#
# Unlock
#
with pytest.raises(Signer.UnknownAccount):
good_signer.unlock_account(account=unknown_address, password=INSECURE_DEVELOPMENT_PASSWORD)
successful_unlock = good_signer.unlock_account(account=mock_account.address, password=INSECURE_DEVELOPMENT_PASSWORD)
assert successful_unlock
#
# Lock
#
with pytest.raises(Signer.UnknownAccount):
good_signer.lock_account(account=unknown_address)
successful_lock = good_signer.lock_account(account=mock_account.address)
assert successful_lock
def test_list_keystore_accounts(good_signer, mock_account):
tracked_accounts = good_signer.accounts
assert mock_account.address in tracked_accounts
assert len(tracked_accounts) == 1
def test_keystore_sign_message(mocker, good_signer, mock_account, mock_key):
# unlock
mock_decrypt = mocker.patch.object(Account, 'decrypt', autospec=True)
mock_decrypt.return_value = mock_key.privateKey
successful_unlock = good_signer.unlock_account(account=mock_account.address, password=INSECURE_DEVELOPMENT_PASSWORD)
assert successful_unlock
# sign message
message = b'A million tiny bubbles exploding'
signature = good_signer.sign_message(account=mock_account.address, message=message)
assert len(signature) == LENGTH_ECDSA_SIGNATURE_WITH_RECOVERY
def test_keystore_sign_transaction(good_signer, mock_account):
transaction_dict = assoc(TRANSACTION_DICT, 'from', value=mock_account.address)
signed_transaction = good_signer.sign_transaction(transaction_dict=transaction_dict)
assert isinstance(signed_transaction, HexBytes)
# assert valid transaction
transaction = Transaction.from_bytes(signed_transaction)
assert to_checksum_address(transaction.to) == transaction_dict['to']
def test_trezor_transaction_format():
web3_transaction_dict = {
'chainId': 1,
'nonce': 2,
'gasPrice': 2000000000000,
'gas': 314159,
'to': '0xd3CdA913deB6f67967B99D67aCDFa1712C293601',
'value': 12345,
'data': b'in that metric, kman is above reproach',
}
trezor_transaction = TrezorSigner._format_transaction(web3_transaction_dict)
assert trezor_transaction['chain_id'] == web3_transaction_dict['chainId']
assert trezor_transaction['nonce'] == web3_transaction_dict['nonce']
assert trezor_transaction['gas_price'] == web3_transaction_dict['gasPrice']
assert trezor_transaction['gas_limit'] == web3_transaction_dict['gas']
assert trezor_transaction['to'] == web3_transaction_dict['to']
assert trezor_transaction['value'] == web3_transaction_dict['value']
assert trezor_transaction['data'] == Web3.toBytes(HexBytes(web3_transaction_dict['data']))
@pytest.fixture(scope='function')
def mock_trezor(mocker, mock_account):
class FakeTrezorClient:
# fake out
v = 38
r = b"!\xab\x18\xb2\x9e\xa0\xe6\xa7$\x11\x8fA`\x15\xe1\xad\x1dt\xefL\xc5\\\xec:\x88'\xa7\xe3\xcb\xb6\xfc\xb3"
s = b"\xc4\xc2O\xda\x06o\x83\x03r\x9e[K\xc1\xcd\xd8\x12\xbc.l\xbb\x8cdl\xaf\xba=p\xeco\xe9\x9e\x89"
faked_vrs = v, r, s
def call(self, message):
if isinstance(message, EthereumGetAddress):
return mock_account.address
def get_device_id(self, *args, **kwargs):
return '1'
def get_address(self, *args, **kwargs):
return mock_account.address
mocker.patch.object(signers.hardware, 'get_default_client', return_value=FakeTrezorClient())
mocker.patch.object(TrezorSigner, '_TrezorSigner__get_address', return_value=mock_account.address)
mocker.patch.object(TrezorSigner, '_TrezorSigner__sign_transaction', return_value=FakeTrezorClient.faked_vrs)
def test_trezor_signer_uri(mock_trezor):
signer = Signer.from_signer_uri(uri='trezor')
assert isinstance(signer, TrezorSigner)
assert signer.DERIVATION_ROOT == "44'/60'/0'/0"
assert len(signer.accounts) == 1
def test_trezor_sign_transaction(mock_trezor, mock_account):
trezor_signer = TrezorSigner()
transaction_dict = assoc(TRANSACTION_DICT, 'from', value=mock_account.address)
signed_transaction = trezor_signer.sign_transaction(transaction_dict=transaction_dict)
assert isinstance(signed_transaction, HexBytes)
# assert valid transaction
transaction = Transaction.from_bytes(signed_transaction)
assert transaction.to == transaction_dict['to']