diff --git a/src/ape/api/accounts.py b/src/ape/api/accounts.py index 69a3442bf3..36ad4c8fa3 100644 --- a/src/ape/api/accounts.py +++ b/src/ape/api/accounts.py @@ -6,7 +6,7 @@ from eip712.messages import SignableMessage as EIP712SignableMessage from eth_account import Account from eth_account.messages import encode_defunct -from hexbytes import HexBytes +from eth_pydantic_types import HexBytes from ape.api.address import BaseAddress from ape.api.transactions import ReceiptAPI, TransactionAPI @@ -57,6 +57,23 @@ def alias(self) -> Optional[str]: """ return None + def sign_raw_msghash(self, msghash: HexBytes) -> Optional[MessageSignature]: + """ + Sign a raw message hash. + + Args: + msghash (:class:`~eth_pydantic_types.HexBytes`): + The message hash to sign. Plugins may or may not support this operation. + Default implementation is to raise ``NotImplementedError``. + + Returns: + :class:`~ape.types.signatures.MessageSignature` (optional): + The signature corresponding to the message. + """ + raise NotImplementedError( + f"Raw message signing is not supported by '{self.__class__.__name__}'" + ) + @abstractmethod def sign_message(self, msg: Any, **signer_options) -> Optional[MessageSignature]: """ @@ -295,8 +312,9 @@ def declare(self, contract: "ContractContainer", *args, **kwargs) -> ReceiptAPI: def check_signature( self, - data: Union[SignableMessage, TransactionAPI, str, EIP712Message, int], + data: Union[SignableMessage, TransactionAPI, str, EIP712Message, int, bytes], signature: Optional[MessageSignature] = None, # TransactionAPI doesn't need it + recover_using_eip191: bool = True, ) -> bool: """ Verify a message or transaction was signed by this account. @@ -307,6 +325,10 @@ def check_signature( signature (Optional[:class:`~ape.types.signatures.MessageSignature`]): The signature to check. Defaults to ``None`` and is not needed when the first argument is a transaction class. + recover_using_eip191 (bool): + Perform recovery using EIP-191 signed message check. If set False, then will attempt + recovery as raw hash. `data`` must be a 32 byte hash if this is set False. + Defaults to ``True``. Returns: bool: ``True`` if the data was signed by this account. ``False`` otherwise. @@ -315,6 +337,8 @@ def check_signature( data = encode_defunct(text=data) elif isinstance(data, int): data = encode_defunct(hexstr=HexBytes(data).hex()) + elif isinstance(data, bytes) and (len(data) != 32 or recover_using_eip191): + data = encode_defunct(data) elif isinstance(data, EIP712Message): data = data.signable_message if isinstance(data, (SignableMessage, EIP712SignableMessage)): @@ -329,6 +353,9 @@ def check_signature( elif isinstance(data, TransactionAPI): return self.address == Account.recover_transaction(data.serialize_transaction()) + elif isinstance(data, bytes) and len(data) == 32 and not recover_using_eip191: + return self.address == Account._recover_hash(data, vrs=signature) + else: raise AccountsError(f"Unsupported message type: {type(data)}.") diff --git a/src/ape/types/signatures.py b/src/ape/types/signatures.py index 852f91685f..8ffefcfdb6 100644 --- a/src/ape/types/signatures.py +++ b/src/ape/types/signatures.py @@ -2,8 +2,8 @@ from eth_account import Account from eth_account.messages import SignableMessage +from eth_pydantic_types import HexBytes from eth_utils import to_bytes, to_hex -from hexbytes import HexBytes from pydantic.dataclasses import dataclass try: diff --git a/src/ape_accounts/accounts.py b/src/ape_accounts/accounts.py index e0e2451b4b..49ae0d2a3e 100644 --- a/src/ape_accounts/accounts.py +++ b/src/ape_accounts/accounts.py @@ -163,14 +163,17 @@ def delete(self): def sign_message(self, msg: Any, **signer_options) -> Optional[MessageSignature]: if isinstance(msg, str): - user_approves = self.__autosign or click.confirm(f"Message: {msg}\n\nSign: ") + display_msg = f"Signing raw string: '{msg}'" msg = encode_defunct(text=msg) + elif isinstance(msg, int): - user_approves = self.__autosign or click.confirm(f"Message: {msg}\n\nSign: ") + display_msg = f"Signing raw integer: {msg}" msg = encode_defunct(hexstr=HexBytes(msg).hex()) + elif isinstance(msg, bytes): - user_approves = self.__autosign or click.confirm(f"Message: {msg.hex()}\n\nSign: ") + display_msg = f"Signing raw bytes: '{msg.hex()}'" msg = encode_defunct(primitive=msg) + elif isinstance(msg, EIP712Message): # Display message data to user display_msg = "Signing EIP712 Message\n" @@ -193,20 +196,22 @@ def sign_message(self, msg: Any, **signer_options) -> Optional[MessageSignature] for field, value in msg._body_["message"].items(): display_msg += f"\t{field}: {value}\n" - user_approves = self.__autosign or click.confirm(f"{display_msg}\nSign: ") - # Convert EIP712Message to SignableMessage for handling below msg = msg.signable_message + elif isinstance(msg, SignableMessage): - user_approves = self.__autosign or click.confirm(f"{msg}\n\nSign: ") + display_msg = str(msg) + else: logger.warning("Unsupported message type, (type=%r, msg=%r)", type(msg), msg) return None - if not user_approves: + if self.__autosign or click.confirm(f"{display_msg}\n\nSign: "): + signed_msg = EthAccount.sign_message(msg, self.__key) + + else: return None - signed_msg = EthAccount.sign_message(msg, self.__key) return MessageSignature( v=signed_msg.v, r=to_bytes(signed_msg.r), @@ -229,6 +234,24 @@ def sign_transaction(self, txn: TransactionAPI, **signer_options) -> Optional[Tr return txn + def sign_raw_msghash(self, msghash: HexBytes) -> Optional[MessageSignature]: + logger.warning( + "Signing a raw hash directly is a dangerous action which could risk " + "substantial losses! Only confirm if you are 100% sure of the origin!" + ) + + # NOTE: Signing a raw hash is so dangerous, we don't want to allow autosigning it + if not click.confirm("Please confirm you wish to sign using `EthAccount.signHash`"): + return None + + signed_msg = EthAccount.signHash(msghash, self.__key) + + return MessageSignature( + v=signed_msg.v, + r=to_bytes(signed_msg.r), + s=to_bytes(signed_msg.s), + ) + def set_autosign(self, enabled: bool, passphrase: Optional[str] = None): """ Allow this account to automatically sign messages and transactions. diff --git a/src/ape_test/accounts.py b/src/ape_test/accounts.py index 1b12a44764..788df75963 100644 --- a/src/ape_test/accounts.py +++ b/src/ape_test/accounts.py @@ -3,8 +3,8 @@ from eip712.messages import EIP712Message from eth_account import Account as EthAccount from eth_account.messages import SignableMessage, encode_defunct +from eth_pydantic_types import HexBytes from eth_utils import to_bytes -from hexbytes import HexBytes from ape.api import TestAccountAPI, TestAccountContainerAPI, TransactionAPI from ape.exceptions import SignatureError @@ -142,3 +142,12 @@ def sign_transaction(self, txn: TransactionAPI, **signer_options) -> Optional[Tr ) return txn + + def sign_raw_msghash(self, msghash: HexBytes) -> MessageSignature: + signed_msg = EthAccount.signHash(msghash, self.private_key) + + return MessageSignature( + v=signed_msg.v, + r=to_bytes(signed_msg.r), + s=to_bytes(signed_msg.s), + ) diff --git a/tests/functional/test_accounts.py b/tests/functional/test_accounts.py index 2d32cc5e16..c8e433c382 100644 --- a/tests/functional/test_accounts.py +++ b/tests/functional/test_accounts.py @@ -116,6 +116,21 @@ def test_sign_message_with_prompts(runner, keyfile_account, message): assert start_nonce == end_nonce +def test_sign_raw_hash(runner, keyfile_account): + # NOTE: `message` is a 32 byte raw hash, which is treated specially + message = b"\xAB" * 32 + + # "y\na\ny": yes sign raw hash, password, yes keep unlocked + with runner.isolation(input=f"y\n{PASSPHRASE}\ny"): + signature = keyfile_account.sign_raw_msghash(message) + assert keyfile_account.check_signature(message, signature, recover_using_eip191=False) + + # "n\nn": no sign raw hash: don't sign + with runner.isolation(input="n"): + signature = keyfile_account.sign_message(message) + assert signature is None + + def test_transfer(sender, receiver, eth_tester_provider, convert): initial_receiver_balance = receiver.balance initial_sender_balance = sender.balance