diff --git a/README.md b/README.md index 3d2aea9c..c7c961dc 100644 --- a/README.md +++ b/README.md @@ -115,3 +115,5 @@ Configure `hardware.ledger.enable = true`. See https://github.com/LedgerHQ/udev-rules +xx +xx diff --git a/pyproject.toml b/pyproject.toml index bd4ce56c..ad81381b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -29,7 +29,6 @@ dependencies = [ "eth_abi>=4.0.0; python_version>=\"3.11\"", "eth_account>=0.4.0,<0.11.0", "python-magic", - "typer", "typing_extensions", ] @@ -54,7 +53,7 @@ mqtt = [ nuls2 = [ "aleph-nuls2", ] -polkadot = [ +substrate = [ "py-sr25519-bindings", "substrate-interface", ] @@ -112,7 +111,7 @@ features = [ "dns", "ledger", "nuls2", - "polkadot", + "substrate", "solana", "tezos", "encryption", diff --git a/src/aleph/__init__.py b/src/aleph/__init__.py index e69de29b..8b137891 100644 --- a/src/aleph/__init__.py +++ b/src/aleph/__init__.py @@ -0,0 +1 @@ + diff --git a/src/aleph/sdk/chains/sol.py b/src/aleph/sdk/chains/sol.py index ff870a4d..5ce9d0ca 100644 --- a/src/aleph/sdk/chains/sol.py +++ b/src/aleph/sdk/chains/sol.py @@ -1,93 +1,8 @@ -import json -from pathlib import Path -from typing import Dict, Optional, Union - -import base58 -from nacl.exceptions import BadSignatureError as NaclBadSignatureError -from nacl.public import PrivateKey, SealedBox -from nacl.signing import SigningKey, VerifyKey - -from ..exceptions import BadSignatureError -from .common import BaseAccount, get_fallback_private_key, get_verification_buffer - - -def encode(item): - return base58.b58encode(bytes(item)).decode("ascii") - - -class SOLAccount(BaseAccount): - CHAIN = "SOL" - CURVE = "curve25519" - _signing_key: SigningKey - _private_key: PrivateKey - - def __init__(self, private_key: bytes): - self.private_key = private_key - self._signing_key = SigningKey(self.private_key) - self._private_key = self._signing_key.to_curve25519_private_key() - - async def sign_message(self, message: Dict) -> Dict: - """Sign a message inplace.""" - message = self._setup_sender(message) - verif = get_verification_buffer(message) - signature = await self.sign_raw(verif) - sig = { - "publicKey": self.get_address(), - "signature": encode(signature), - } - message["signature"] = json.dumps(sig) - return message - - async def sign_raw(self, buffer: bytes) -> bytes: - """Sign a raw buffer.""" - sig = self._signing_key.sign(buffer) - return sig.signature - - def get_address(self) -> str: - return encode(self._signing_key.verify_key) - - def get_public_key(self) -> str: - return bytes(self._signing_key.verify_key.to_curve25519_public_key()).hex() - - async def encrypt(self, content) -> bytes: - value: bytes = bytes(SealedBox(self._private_key.public_key).encrypt(content)) - return value - - async def decrypt(self, content) -> bytes: - value: bytes = SealedBox(self._private_key).decrypt(content) - return value - - -def get_fallback_account(path: Optional[Path] = None) -> SOLAccount: - return SOLAccount(private_key=get_fallback_private_key(path=path)) - - -def generate_key() -> bytes: - privkey = bytes(SigningKey.generate()) - return privkey - - -def verify_signature( - signature: Union[bytes, str], - public_key: Union[bytes, str], - message: Union[bytes, str], -): - """ - Verifies a signature. - Args: - signature: The signature to verify. Can be a base58 encoded string or bytes. - public_key: The public key to use for verification. Can be a base58 encoded string or bytes. - message: The message to verify. Can be an utf-8 string or bytes. - Raises: - BadSignatureError: If the signature is invalid. - """ - if isinstance(signature, str): - signature = base58.b58decode(signature) - if isinstance(message, str): - message = message.encode("utf-8") - if isinstance(public_key, str): - public_key = base58.b58decode(public_key) - try: - VerifyKey(public_key).verify(message, signature) - except NaclBadSignatureError as e: - raise BadSignatureError from e +from warnings import warn + +warn( + "Module aleph.sdk.chains.sol is deprecated in favor of aleph.sdk.chains.solana", + DeprecationWarning, + stacklevel=2, +) +from aleph.sdk.chains.solana import * diff --git a/src/aleph/sdk/chains/solana.py b/src/aleph/sdk/chains/solana.py new file mode 100644 index 00000000..ff870a4d --- /dev/null +++ b/src/aleph/sdk/chains/solana.py @@ -0,0 +1,93 @@ +import json +from pathlib import Path +from typing import Dict, Optional, Union + +import base58 +from nacl.exceptions import BadSignatureError as NaclBadSignatureError +from nacl.public import PrivateKey, SealedBox +from nacl.signing import SigningKey, VerifyKey + +from ..exceptions import BadSignatureError +from .common import BaseAccount, get_fallback_private_key, get_verification_buffer + + +def encode(item): + return base58.b58encode(bytes(item)).decode("ascii") + + +class SOLAccount(BaseAccount): + CHAIN = "SOL" + CURVE = "curve25519" + _signing_key: SigningKey + _private_key: PrivateKey + + def __init__(self, private_key: bytes): + self.private_key = private_key + self._signing_key = SigningKey(self.private_key) + self._private_key = self._signing_key.to_curve25519_private_key() + + async def sign_message(self, message: Dict) -> Dict: + """Sign a message inplace.""" + message = self._setup_sender(message) + verif = get_verification_buffer(message) + signature = await self.sign_raw(verif) + sig = { + "publicKey": self.get_address(), + "signature": encode(signature), + } + message["signature"] = json.dumps(sig) + return message + + async def sign_raw(self, buffer: bytes) -> bytes: + """Sign a raw buffer.""" + sig = self._signing_key.sign(buffer) + return sig.signature + + def get_address(self) -> str: + return encode(self._signing_key.verify_key) + + def get_public_key(self) -> str: + return bytes(self._signing_key.verify_key.to_curve25519_public_key()).hex() + + async def encrypt(self, content) -> bytes: + value: bytes = bytes(SealedBox(self._private_key.public_key).encrypt(content)) + return value + + async def decrypt(self, content) -> bytes: + value: bytes = SealedBox(self._private_key).decrypt(content) + return value + + +def get_fallback_account(path: Optional[Path] = None) -> SOLAccount: + return SOLAccount(private_key=get_fallback_private_key(path=path)) + + +def generate_key() -> bytes: + privkey = bytes(SigningKey.generate()) + return privkey + + +def verify_signature( + signature: Union[bytes, str], + public_key: Union[bytes, str], + message: Union[bytes, str], +): + """ + Verifies a signature. + Args: + signature: The signature to verify. Can be a base58 encoded string or bytes. + public_key: The public key to use for verification. Can be a base58 encoded string or bytes. + message: The message to verify. Can be an utf-8 string or bytes. + Raises: + BadSignatureError: If the signature is invalid. + """ + if isinstance(signature, str): + signature = base58.b58decode(signature) + if isinstance(message, str): + message = message.encode("utf-8") + if isinstance(public_key, str): + public_key = base58.b58decode(public_key) + try: + VerifyKey(public_key).verify(message, signature) + except NaclBadSignatureError as e: + raise BadSignatureError from e diff --git a/src/aleph/sdk/client/abstract.py b/src/aleph/sdk/client/abstract.py index 9fce5469..301388ec 100644 --- a/src/aleph/sdk/client/abstract.py +++ b/src/aleph/sdk/client/abstract.py @@ -74,6 +74,7 @@ async def get_posts( post_filter: Optional[PostFilter] = None, ignore_invalid_messages: Optional[bool] = True, invalid_messages_log_level: Optional[int] = logging.NOTSET, + verify_signatures: bool = False, ) -> PostsResponse: """ Fetch a list of posts from the network. @@ -83,18 +84,25 @@ async def get_posts( :param post_filter: Filter to apply to the posts (Default: None) :param ignore_invalid_messages: Ignore invalid messages (Default: True) :param invalid_messages_log_level: Log level to use for invalid messages (Default: logging.NOTSET) + :param verify_signatures: Verify the signatures of the messages (Default: False) """ raise NotImplementedError("Did you mean to import `AlephHttpClient`?") async def get_posts_iterator( self, post_filter: Optional[PostFilter] = None, + ignore_invalid_messages: Optional[bool] = True, + invalid_messages_log_level: Optional[int] = logging.NOTSET, + verify_signatures: bool = False, ) -> AsyncIterable[PostMessage]: """ Fetch all filtered posts, returning an async iterator and fetching them page by page. Might return duplicates but will always return all posts. :param post_filter: Filter to apply to the posts (Default: None) + :param ignore_invalid_messages: Ignore invalid messages (Default: True) + :param invalid_messages_log_level: Log level to use for invalid messages (Default: logging.NOTSET) + :param verify_signatures: Verify the signatures of the messages (Default: False) """ page = 1 resp = None @@ -102,6 +110,9 @@ async def get_posts_iterator( resp = await self.get_posts( page=page, post_filter=post_filter, + ignore_invalid_messages=ignore_invalid_messages, + invalid_messages_log_level=invalid_messages_log_level, + verify_signatures=verify_signatures, ) page += 1 for post in resp.posts: @@ -178,6 +189,7 @@ async def get_messages( message_filter: Optional[MessageFilter] = None, ignore_invalid_messages: Optional[bool] = True, invalid_messages_log_level: Optional[int] = logging.NOTSET, + verify_signatures: bool = False, ) -> MessagesResponse: """ Fetch a list of messages from the network. @@ -187,18 +199,25 @@ async def get_messages( :param message_filter: Filter to apply to the messages :param ignore_invalid_messages: Ignore invalid messages (Default: True) :param invalid_messages_log_level: Log level to use for invalid messages (Default: logging.NOTSET) + :param verify_signatures: Verify the signatures of the messages (Default: False) """ raise NotImplementedError("Did you mean to import `AlephHttpClient`?") async def get_messages_iterator( self, message_filter: Optional[MessageFilter] = None, + ignore_invalid_messages: Optional[bool] = True, + invalid_messages_log_level: Optional[int] = logging.NOTSET, + verify_signatures: bool = False, ) -> AsyncIterable[AlephMessage]: """ Fetch all filtered messages, returning an async iterator and fetching them page by page. Might return duplicates but will always return all messages. :param message_filter: Filter to apply to the messages + :param ignore_invalid_messages: Ignore invalid messages (Default: True) + :param invalid_messages_log_level: Log level to use for invalid messages (Default: logging.NOTSET) + :param verify_signatures: Whether to verify the signatures of the messages (Default: False) """ page = 1 resp = None @@ -206,6 +225,9 @@ async def get_messages_iterator( resp = await self.get_messages( page=page, message_filter=message_filter, + ignore_invalid_messages=ignore_invalid_messages, + invalid_messages_log_level=invalid_messages_log_level, + verify_signatures=verify_signatures, ) page += 1 for message in resp.messages: @@ -216,12 +238,14 @@ async def get_message( self, item_hash: str, message_type: Optional[Type[GenericMessage]] = None, + verify_signature: bool = False, ) -> GenericMessage: """ Get a single message from its `item_hash` and perform some basic validation. :param item_hash: Hash of the message to fetch :param message_type: Type of message to fetch + :param verify_signature: Whether to verify the signature of the message (Default: False) """ raise NotImplementedError("Did you mean to import `AlephHttpClient`?") @@ -229,11 +253,13 @@ async def get_message( def watch_messages( self, message_filter: Optional[MessageFilter] = None, + verify_signatures: bool = False, ) -> AsyncIterable[AlephMessage]: """ Iterate over current and future matching messages asynchronously. :param message_filter: Filter to apply to the messages + :param verify_signatures: Whether to verify the signatures of the messages (Default: False) """ raise NotImplementedError("Did you mean to import `AlephHttpClient`?") diff --git a/src/aleph/sdk/client/http.py b/src/aleph/sdk/client/http.py index ae98b0d1..9ded2bcd 100644 --- a/src/aleph/sdk/client/http.py +++ b/src/aleph/sdk/client/http.py @@ -15,6 +15,7 @@ from ..exceptions import FileTooLarge, ForgottenMessageError, MessageNotFoundError from ..query.filters import MessageFilter, PostFilter from ..query.responses import MessagesResponse, Post, PostsResponse +from ..security import verify_message_signature from ..types import GenericMessage from ..utils import ( Writable, @@ -117,6 +118,7 @@ async def get_posts( post_filter: Optional[PostFilter] = None, ignore_invalid_messages: Optional[bool] = True, invalid_messages_log_level: Optional[int] = logging.NOTSET, + verify_signatures: bool = False, ) -> PostsResponse: ignore_invalid_messages = ( True if ignore_invalid_messages is None else ignore_invalid_messages @@ -145,12 +147,15 @@ async def get_posts( posts: List[Post] = [] for post_raw in posts_raw: try: - posts.append(Post.parse_obj(post_raw)) + post = Post.parse_obj(post_raw) + posts.append(post) except ValidationError as e: if not ignore_invalid_messages: raise e if invalid_messages_log_level: logger.log(level=invalid_messages_log_level, msg=e) + if verify_signatures: + verify_message_signature(post) return PostsResponse( posts=posts, pagination_page=response_json["pagination_page"], @@ -266,6 +271,7 @@ async def get_messages( message_filter: Optional[MessageFilter] = None, ignore_invalid_messages: Optional[bool] = True, invalid_messages_log_level: Optional[int] = logging.NOTSET, + verify_signatures: bool = False, ) -> MessagesResponse: ignore_invalid_messages = ( True if ignore_invalid_messages is None else ignore_invalid_messages @@ -312,6 +318,8 @@ async def get_messages( raise e if invalid_messages_log_level: logger.log(level=invalid_messages_log_level, msg=e) + if verify_signatures: + verify_message_signature(message) return MessagesResponse( messages=messages, @@ -325,6 +333,7 @@ async def get_message( self, item_hash: str, message_type: Optional[Type[GenericMessage]] = None, + verify_signature: bool = False, ) -> GenericMessage: async with self.http_session.get(f"/api/v0/messages/{item_hash}") as resp: try: @@ -339,6 +348,8 @@ async def get_message( f"The requested message {message_raw['item_hash']} has been forgotten by {', '.join(message_raw['forgotten_by'])}" ) message = parse_message(message_raw["message"]) + if verify_signature: + verify_message_signature(message) if message_type: expected_type = get_message_type_value(message_type) if message.type != expected_type: @@ -374,6 +385,7 @@ async def get_message_error( async def watch_messages( self, message_filter: Optional[MessageFilter] = None, + verify_signatures: bool = False, ) -> AsyncIterable[AlephMessage]: message_filter = message_filter or MessageFilter() params = message_filter.as_http_params() @@ -389,6 +401,9 @@ async def watch_messages( break else: data = json.loads(msg.data) - yield parse_message(data) + message = parse_message(data) + if verify_signatures: + verify_message_signature(message) + yield message elif msg.type == aiohttp.WSMsgType.ERROR: break diff --git a/src/aleph/sdk/security.py b/src/aleph/sdk/security.py new file mode 100644 index 00000000..4d8f9a81 --- /dev/null +++ b/src/aleph/sdk/security.py @@ -0,0 +1,54 @@ +from importlib import import_module +from typing import Any, Union + +from aleph_message.models import AlephMessage, Chain + +from aleph.sdk.chains.common import get_verification_buffer +from aleph.sdk.query.responses import Post + +validator_chains_map = { + # TODO: Add AVAX + Chain.ETH: "ethereum", + Chain.SOL: "sol", + Chain.CSDK: "cosmos", + Chain.DOT: "substrate", + Chain.NULS2: "nuls2", + Chain.TEZOS: "tezos", +} + + +def try_import_verify_signature(chain: str) -> Any: + """Try to import a chain signature validator.""" + try: + return import_module(f"aleph.sdk.chains.{chain}").verify_signature + except (ImportError, AttributeError): + return None + + +validators = { + key: try_import_verify_signature(value) + for key, value in validator_chains_map.items() +} +""" +This is a dict containing all currently available signature validators, indexed by their Chain abbreviation. + +Ex.: validators["SOL"] -> aleph.sdk.chains.solana.verify_signature() +""" + + +def verify_message_signature(message: Union[AlephMessage, Post]) -> None: + """Verify the signature of a message, raise an error if invalid or unsupported. + A BadSignatureError is raised when the signature is incorrect. + A ValueError is raised when the chain is not supported or required dependencies are missing. + """ + if message.chain not in validators: + raise ValueError(f"Chain {message.chain} is not supported.") + validator = validators[message.chain] + if validator is None: + raise ValueError( + f"Chain {message.chain} is not installed. Install it with `aleph-sdk-python[{message.chain}]`." + ) + signature = message.signature + public_key = message.sender + message = get_verification_buffer(message.dict()) + validator(signature, public_key, message) diff --git a/tests/unit/conftest.py b/tests/unit/conftest.py index 4b06c243..3c5c1fe8 100644 --- a/tests/unit/conftest.py +++ b/tests/unit/conftest.py @@ -11,7 +11,7 @@ from aleph_message.models import AggregateMessage, AlephMessage, PostMessage import aleph.sdk.chains.ethereum as ethereum -import aleph.sdk.chains.sol as solana +import aleph.sdk.chains.solana as solana import aleph.sdk.chains.substrate as substrate import aleph.sdk.chains.tezos as tezos from aleph.sdk import AlephHttpClient, AuthenticatedAlephHttpClient diff --git a/tests/unit/test_chain_solana.py b/tests/unit/test_chain_solana.py index 07b67602..ed2fff78 100644 --- a/tests/unit/test_chain_solana.py +++ b/tests/unit/test_chain_solana.py @@ -8,7 +8,7 @@ from nacl.signing import VerifyKey from aleph.sdk.chains.common import get_verification_buffer -from aleph.sdk.chains.sol import SOLAccount, get_fallback_account, verify_signature +from aleph.sdk.chains.solana import SOLAccount, get_fallback_account, verify_signature from aleph.sdk.exceptions import BadSignatureError diff --git a/tests/unit/test_security.py b/tests/unit/test_security.py new file mode 100644 index 00000000..ed3ca32c --- /dev/null +++ b/tests/unit/test_security.py @@ -0,0 +1,4 @@ +def test_validators_loaded(): + import aleph.sdk.security as security + + assert any([validator is not None for validator in security.validators.values()])