Skip to content

Commit

Permalink
feat(AccountAPI): sign raw hash32 (#1966)
Browse files Browse the repository at this point in the history
* feat(AccountAPI): allow signing raw 32 byte hash

* test: add test for raw hash signing

* fix(typing): use HexBytes type from `eth-pydantic-types` consistently

* docs: add docstring for `AccountAPI.sign_raw_msghash`
  • Loading branch information
fubuloubu committed Mar 21, 2024
1 parent c169c9d commit af5689c
Show file tree
Hide file tree
Showing 5 changed files with 86 additions and 12 deletions.
31 changes: 29 additions & 2 deletions src/ape/api/accounts.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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]:
"""
Expand Down Expand Up @@ -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.
Expand All @@ -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.
Expand All @@ -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)):
Expand All @@ -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)}.")

Expand Down
2 changes: 1 addition & 1 deletion src/ape/types/signatures.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
39 changes: 31 additions & 8 deletions src/ape_accounts/accounts.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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),
Expand All @@ -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.
Expand Down
11 changes: 10 additions & 1 deletion src/ape_test/accounts.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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),
)
15 changes: 15 additions & 0 deletions tests/functional/test_accounts.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down

0 comments on commit af5689c

Please sign in to comment.