diff --git a/graphenecommon/aio/message.py b/graphenecommon/aio/message.py new file mode 100644 index 00000000..9d9e6d06 --- /dev/null +++ b/graphenecommon/aio/message.py @@ -0,0 +1,318 @@ +# -*- coding: utf-8 -*- +import json +import logging +import re +import inspect + +from binascii import hexlify, unhexlify +from datetime import datetime +from asyncinit import asyncinit + +from graphenebase.ecdsa import sign_message, verify_message + +from ..exceptions import ( + AccountDoesNotExistsException, + InvalidMemoKeyException, + InvalidMessageSignature, + WrongMemoKey, +) +from ..message import ( + MessageV1 as SyncMessageV1, + MessageV2 as SyncMessageV2, + Message as SyncMessage, +) + + +log = logging.getLogger(__name__) + + +class MessageV1(SyncMessageV1): + """ Allow to sign and verify Messages that are sigend with a private key + """ + + async def sign(self, account=None, **kwargs): + """ Sign a message with an account's memo key + + :param str account: (optional) the account that owns the bet + (defaults to ``default_account``) + :raises ValueError: If not account for signing is provided + + :returns: the signed message encapsulated in a known format + """ + if not account: + if "default_account" in self.blockchain.config: + account = self.blockchain.config["default_account"] + if not account: + raise ValueError("You need to provide an account") + + # Data for message + account = await self.account_class(account, blockchain_instance=self.blockchain) + info = await self.blockchain.info() + meta = dict( + timestamp=info["time"], + block=info["head_block_number"], + memokey=account["options"]["memo_key"], + account=account["name"], + ) + + # wif key + wif = self.blockchain.wallet.getPrivateKeyForPublicKey( + account["options"]["memo_key"] + ) + + # We strip the message here so we know for sure there are no trailing + # whitespaces or returns + message = self.message.strip() + + enc_message = self.SIGNED_MESSAGE_META.format(**locals()) + + # signature + signature = hexlify(sign_message(enc_message, wif)).decode("ascii") + + self.signed_by_account = account + self.signed_by_name = account["name"] + self.meta = meta + self.plain_message = message + + return self.SIGNED_MESSAGE_ENCAPSULATED.format( + MESSAGE_SPLIT=self.MESSAGE_SPLIT, **locals() + ) + + async def verify(self, **kwargs): + """ Verify a message with an account's memo key + + :param str account: (optional) the account that owns the bet + (defaults to ``default_account``) + + :returns: True if the message is verified successfully + :raises InvalidMessageSignature if the signature is not ok + """ + # Split message into its parts + parts = re.split("|".join(self.MESSAGE_SPLIT), self.message) + parts = [x for x in parts if x.strip()] + + assert len(parts) > 2, "Incorrect number of message parts" + + # Strip away all whitespaces before and after the message + message = parts[0].strip() + signature = parts[2].strip() + # Parse the meta data + meta = dict(re.findall(r"(\S+)=(.*)", parts[1])) + + log.info("Message is: {}".format(message)) + log.info("Meta is: {}".format(json.dumps(meta))) + log.info("Signature is: {}".format(signature)) + + # Ensure we have all the data in meta + assert "account" in meta, "No 'account' could be found in meta data" + assert "memokey" in meta, "No 'memokey' could be found in meta data" + assert "block" in meta, "No 'block' could be found in meta data" + assert "timestamp" in meta, "No 'timestamp' could be found in meta data" + + account_name = meta.get("account").strip() + memo_key = meta["memokey"].strip() + + try: + self.publickey_class(memo_key, prefix=self.blockchain.prefix) + except Exception: + raise InvalidMemoKeyException("The memo key in the message is invalid") + + # Load account from blockchain + try: + account = await self.account_class( + account_name, blockchain_instance=self.blockchain + ) + except AccountDoesNotExistsException: + raise AccountDoesNotExistsException( + "Could not find account {}. Are you connected to the right chain?".format( + account_name + ) + ) + + # Test if memo key is the same as on the blockchain + if not account["options"]["memo_key"] == memo_key: + raise WrongMemoKey( + "Memo Key of account {} on the Blockchain ".format(account["name"]) + + "differs from memo key in the message: {} != {}".format( + account["options"]["memo_key"], memo_key + ) + ) + + # Reformat message + enc_message = self.SIGNED_MESSAGE_META.format(**locals()) + + # Verify Signature + pubkey = verify_message(enc_message, unhexlify(signature)) + + # Verify pubky + pk = self.publickey_class( + hexlify(pubkey).decode("ascii"), prefix=self.blockchain.prefix + ) + if format(pk, self.blockchain.prefix) != memo_key: + raise InvalidMessageSignature("The signature doesn't match the memo key") + + self.signed_by_account = account + self.signed_by_name = account["name"] + self.meta = meta + self.plain_message = message + + return True + + +class MessageV2(SyncMessageV2): + """ Allow to sign and verify Messages that are sigend with a private key + """ + + async def sign(self, account=None, **kwargs): + """ Sign a message with an account's memo key + + :param str account: (optional) the account that owns the bet + (defaults to ``default_account``) + :raises ValueError: If not account for signing is provided + + :returns: the signed message encapsulated in a known format + """ + if not account: + if "default_account" in self.blockchain.config: + account = self.blockchain.config["default_account"] + if not account: + raise ValueError("You need to provide an account") + + # Data for message + account = await self.account_class(account, blockchain_instance=self.blockchain) + + # wif key + wif = self.blockchain.wallet.getPrivateKeyForPublicKey( + account["options"]["memo_key"] + ) + + payload = [ + "from", + account["name"], + "key", + account["options"]["memo_key"], + "time", + str(datetime.utcnow()), + "text", + self.message, + ] + enc_message = json.dumps(payload, separators=(",", ":")) + + # signature + signature = hexlify(sign_message(enc_message, wif)).decode("ascii") + + return dict(signed=enc_message, payload=payload, signature=signature) + + async def verify(self, **kwargs): + """ Verify a message with an account's memo key + + :param str account: (optional) the account that owns the bet + (defaults to ``default_account``) + + :returns: True if the message is verified successfully + :raises InvalidMessageSignature if the signature is not ok + """ + assert isinstance(self.message, dict), "Message must be dictionary" + + payload = self.message.get("payload") + assert payload, "Missing payload" + payload_dict = {k[0]: k[1] for k in zip(payload[::2], payload[1::2])} + signature = self.message.get("signature") + + account_name = payload_dict.get("from").strip() + memo_key = payload_dict.get("key").strip() + + assert account_name, "Missing account name 'from'" + assert memo_key, "missing 'key'" + + try: + self.publickey_class(memo_key, prefix=self.blockchain.prefix) + except Exception: + raise InvalidMemoKeyException("The memo key in the message is invalid") + + # Load account from blockchain + try: + account = await self.account_class( + account_name, blockchain_instance=self.blockchain + ) + except AccountDoesNotExistsException: + raise AccountDoesNotExistsException( + "Could not find account {}. Are you connected to the right chain?".format( + account_name + ) + ) + + # Test if memo key is the same as on the blockchain + if not account["options"]["memo_key"] == memo_key: + raise WrongMemoKey( + "Memo Key of account {} on the Blockchain ".format(account["name"]) + + "differs from memo key in the message: {} != {}".format( + account["options"]["memo_key"], memo_key + ) + ) + + # Ensure payload and signed match + signed_target = json.dumps(self.message.get("payload"), separators=(",", ":")) + signed_actual = self.message.get("signed") + assert ( + signed_target == signed_actual + ), "payload doesn't match signed message: \n{}\n{}".format( + signed_target, signed_actual + ) + + # Reformat message + enc_message = self.message.get("signed") + + # Verify Signature + pubkey = verify_message(enc_message, unhexlify(signature)) + + # Verify pubky + pk = self.publickey_class( + hexlify(pubkey).decode("ascii"), prefix=self.blockchain.prefix + ) + if format(pk, self.blockchain.prefix) != memo_key: + raise InvalidMessageSignature("The signature doesn't match the memo key") + + self.signed_by_account = account + self.signed_by_name = account["name"] + self.plain_message = payload_dict.get("text") + + return True + + +class Message(SyncMessage, MessageV1, MessageV2): + supported_formats = (MessageV1, MessageV2) + valid_exceptions = ( + AccountDoesNotExistsException, + InvalidMessageSignature, + WrongMemoKey, + InvalidMemoKeyException, + ) + + async def verify(self, **kwargs): + for _format in self.supported_formats: + try: + return await _format.verify(self, **kwargs) + except self.valid_exceptions as e: + raise e + except Exception as e: + log.warning( + "{}: Couldn't verify: {}: {}".format( + _format.__name__, e.__class__.__name__, str(e) + ) + ) + raise ValueError("No Decoder accepted the message") + + async def sign(self, *args, **kwargs): + for _format in self.supported_formats: + try: + return await _format.sign(self, *args, **kwargs) + except self.valid_exceptions as e: + raise e + except Exception as e: + log.warning( + "{}: Couldn't sign: {}: {}".format( + _format.__name__, e.__class__.__name__, str(e) + ) + ) + raise ValueError("No Decoder accepted the message") diff --git a/tests/fixtures_aio.py b/tests/fixtures_aio.py index 3014f1e9..58a877c3 100644 --- a/tests/fixtures_aio.py +++ b/tests/fixtures_aio.py @@ -84,7 +84,7 @@ from graphenecommon.aio.committee import Committee as GCommittee from graphenecommon.aio.block import Block as GBlock, BlockHeader as GBlockHeader from graphenecommon.blockchain import Blockchain as GBLockchain -from graphenecommon.message import ( +from graphenecommon.aio.message import ( Message as GMessage, MessageV1 as GMessageV1, MessageV2 as GMessageV2, @@ -128,7 +128,7 @@ def is_connected(self): def wallet(self): return Wallet(keys=["5KQwrPbwdL6PhXujxW37FSSQZ1JiwsST4cqQzDeyXtP79zkvFD3"]) - def info(self): + async def info(self): # returns demo data return { "accounts_registered_this_interval": 18, @@ -283,21 +283,21 @@ def define_classes(self): pass -@BlockchainInstance.inject +@SyncBlockchainInstance.inject class Message(GMessage): def define_classes(self): self.account_class = Account self.publickey_class = PublicKey -@BlockchainInstance.inject +@SyncBlockchainInstance.inject class MessageV1(GMessageV1): def define_classes(self): self.account_class = Account self.publickey_class = PublicKey -@BlockchainInstance.inject +@SyncBlockchainInstance.inject class MessageV2(GMessageV2): def define_classes(self): self.account_class = Account diff --git a/tests/test_message_aio.py b/tests/test_message_aio.py new file mode 100644 index 00000000..cd1dc029 --- /dev/null +++ b/tests/test_message_aio.py @@ -0,0 +1,64 @@ +# -*- coding: utf-8 -*- +import aiounittest +from .fixtures_aio import fixture_data, Message, MessageV1, MessageV2 +from pprint import pprint +from graphenecommon.exceptions import InvalidMessageSignature + + +class Testcases(aiounittest.AsyncTestCase): + def setUp(self): + fixture_data() + + async def test_sign_message(self): + m = Message("message foobar") + p = await m.sign(account="init0") + m2 = Message(p) + await m2.verify() + + async def test_verify_message(self): + m = Message( + """ +-----BEGIN GRAPHENE SIGNED MESSAGE----- +message foobar +-----BEGIN META----- +account=init0 +memokey=GPH6MRyAjQq8ud7hVNYcfnVPJqcVpscN5So8BhtHuGYqET5GDW5CV +block=33041174 +timestamp=2018-12-12T10:44:15 +-----BEGIN SIGNATURE----- +1f3745b4bf1a835623698a94fafea35fa654b7a554cdde4f4f591d6acc2a5f5cec664ce4d18ddf26495ecf12ee701e7321c12c178c8e1d248d5c3d794c658e4a8b +-----END GRAPHENE SIGNED MESSAGE-----""" + ) + await m.verify() + + with self.assertRaises(InvalidMessageSignature): + m = Message( + "-----BEGIN GRAPHENE SIGNED MESSAGE-----" + "message foobar\n" + "-----BEGIN META-----" + "account=init0\n" + "memokey=GPH6MRyAjQq8ud7hVNYcfnVPJqcVpscN5So8BhtHuGYqET5GDW5CV\n" + "block=23814223\n" + "timestamp=2018-01-24T11:42:33" + "-----BEGIN SIGNATURE-----" + "2034f601e175a25cf9f60a828650301f57c9efab53929b6a82fb413feb8a786fcb3ba4238dd8bece03aee38526ee363324d43944d4a3f9dc624fbe53ef5f0c9a5e\n" + "-----END GRAPHENE SIGNED MESSAGE-----" + ) + await m.verify() + + async def test_v2_enc(self): + m = MessageV2("foobar") + c = await m.sign(account="init0") + v = MessageV2(c) + await v.verify() + + async def test_v2andv1_enc(self): + m = MessageV2("foobar") + c = await m.sign(account="init0") + v = Message(c) + await v.verify() + + m = MessageV1("foobar") + c = await m.sign(account="init0") + v = Message(c) + await v.verify()