diff --git a/pdm.lock b/pdm.lock index 2bba6b7..47ea03d 100644 --- a/pdm.lock +++ b/pdm.lock @@ -5,7 +5,7 @@ groups = ["default", "lint", "test"] strategy = ["cross_platform"] lock_version = "4.4" -content_hash = "sha256:ff0ec6fa3a1627e6f7beadb662b2e85cd18c5191c7edfc9fbfb1fbc453940433" +content_hash = "sha256:99adc1f0cf56c09b8915fce928f8c0d0b74715804a117b792f401b1e675bf0bb" [[package]] name = "aiohttp" @@ -796,7 +796,7 @@ files = [ [[package]] name = "eth-ape" -version = "0.6.19" +version = "0.6.25" requires_python = ">=3.8,<4" summary = "Ape Ethereum Framework" dependencies = [ @@ -809,7 +809,7 @@ dependencies = [ "eth-account<0.9,>=0.8", "eth-typing<4,>=3.4", "eth-utils<3,>=2.2.0", - "ethpm-types<0.6,>=0.5.3", + "ethpm-types<0.6,>=0.5.10", "evm-trace>=0.1.0a23", "hexbytes<1,>=0.2.3", "ijson<4,>=3.1.4", @@ -820,19 +820,20 @@ dependencies = [ "pandas<2,>=1.3.0", "pluggy<2,>=1.3", "py-geth<4,>=3.13.0", - "pydantic<2,>=1.10.8", + "pydantic<3,>=1.10.8", "pytest<8.0,>=6.0", "python-dateutil<3,>=2.8.2", "requests<3,>=2.28.1", "rich<13,>=12.5.1", "tqdm<5.0,>=4.62.3", "traitlets>=5.3.0", + "urllib3<2,>=1.26.16", "watchdog<4,>=3.0", "web3[tester]<7,>=6.7.0", ] files = [ - {file = "eth-ape-0.6.19.tar.gz", hash = "sha256:78001209dfdf8c7973c649b8cbba73d3399cd649aeee4223d0b29078ae997201"}, - {file = "eth_ape-0.6.19-py3-none-any.whl", hash = "sha256:f6c5137a10edcc2a37a8f8736882e412b2fb3b326d00d8128538e73dc031f89b"}, + {file = "eth-ape-0.6.25.tar.gz", hash = "sha256:c6b0331bb36d1f1e7e7973b36698edb17491d6abb5e183330481bcee5626d5e3"}, + {file = "eth_ape-0.6.25-py3-none-any.whl", hash = "sha256:940458827f171dfeff8cfb96f4059e336b2271458b9c75602a3d79130f09d062"}, ] [[package]] @@ -1628,20 +1629,6 @@ files = [ {file = "mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba"}, ] -[[package]] -name = "merkly" -version = "0.8.1" -requires_python = ">=3.8,<4.0" -summary = "🌳 The simple and easy implementation of Merkle Tree" -dependencies = [ - "pycryptodome<4.0.0,>=3.19.0", - "pydantic<2.0.0,>=1.10.2", -] -files = [ - {file = "merkly-0.8.1-py3-none-any.whl", hash = "sha256:2d6ca95eda234d16f10043dfc9f5f7b5264b478f37bef56ff86ad26a2c35cf71"}, - {file = "merkly-0.8.1.tar.gz", hash = "sha256:de14f322bbedb5ef299e7bb2932584e444d305a592656192e69ce8307c741450"}, -] - [[package]] name = "morphys" version = "1.0" @@ -2997,15 +2984,15 @@ files = [ [[package]] name = "types-requests" -version = "2.31.0.10" +version = "2.31.0.6" requires_python = ">=3.7" summary = "Typing stubs for requests" dependencies = [ - "urllib3>=2", + "types-urllib3", ] files = [ - {file = "types-requests-2.31.0.10.tar.gz", hash = "sha256:dc5852a76f1eaf60eafa81a2e50aefa3d1f015c34cf0cba130930866b1b22a92"}, - {file = "types_requests-2.31.0.10-py3-none-any.whl", hash = "sha256:b32b9a86beffa876c0c3ac99a4cd3b8b51e973fb8e3bd4e0a6bb32c7efad80fc"}, + {file = "types-requests-2.31.0.6.tar.gz", hash = "sha256:cd74ce3b53c461f1228a9b783929ac73a666658f223e28ed29753771477b3bd0"}, + {file = "types_requests-2.31.0.6-py3-none-any.whl", hash = "sha256:a2db9cb228a81da8348b49ad6db3f5519452dd20a9c1e1a868c83c5fe88fd1a9"}, ] [[package]] @@ -3018,6 +3005,15 @@ files = [ {file = "types_setuptools-68.2.0.1-py3-none-any.whl", hash = "sha256:e9c649559743e9f98c924bec91eae97f3ba208a70686182c3658fd7e81778d37"}, ] +[[package]] +name = "types-urllib3" +version = "1.26.25.14" +summary = "Typing stubs for urllib3" +files = [ + {file = "types-urllib3-1.26.25.14.tar.gz", hash = "sha256:229b7f577c951b8c1b92c1bc2b2fdb0b49847bd2af6d1cc2a2e3dd340f3bda8f"}, + {file = "types_urllib3-1.26.25.14-py3-none-any.whl", hash = "sha256:9683bbb7fb72e32bfe9d2be6e04875fbe1b3eeec3cbb4ea231435aa7fd6b4f0e"}, +] + [[package]] name = "typing-extensions" version = "4.8.0" @@ -3030,12 +3026,12 @@ files = [ [[package]] name = "urllib3" -version = "2.0.7" -requires_python = ">=3.7" +version = "1.26.18" +requires_python = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, !=3.5.*" summary = "HTTP library with thread-safe connection pooling, file post, and more." files = [ - {file = "urllib3-2.0.7-py3-none-any.whl", hash = "sha256:fdb6d215c776278489906c2f8916e6e7d4f5a9b602ccbcfdf7f016fc8da0596e"}, - {file = "urllib3-2.0.7.tar.gz", hash = "sha256:c97dfde1f7bd43a71c8d2a58e369e9b2bf692d1334ea9f9cae55add7d0dd0f84"}, + {file = "urllib3-1.26.18-py2.py3-none-any.whl", hash = "sha256:34b97092d7e0a3a8cf7cd10e386f401b3737364026c45e622aa02903dffe0f07"}, + {file = "urllib3-1.26.18.tar.gz", hash = "sha256:f8ecc1bba5667413457c529ab955bf8c67b45db799d159066261719e328580a0"}, ] [[package]] diff --git a/pyproject.toml b/pyproject.toml index 5fa68a9..d11bcf0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,7 +5,7 @@ version = "0.1.0" description = "Python client library for connecting to Bee decentralised storage" authors = [{ name = "SAIKAT KARMAKAR", email = "saikickkarma@protonmail.com" }] dependencies = [ - "eth-ape>=0.6.19", + "eth-ape>=0.6.25", ] requires-python = ">=3.9" readme = "README.md" @@ -50,7 +50,7 @@ lint = [ "ruff>=0.1.5", # Auto-formatter and linter "mypy>=1.7.0", # Static type analyzer "types-PyYAML>=6.0.12.12", # Needed due to mypy typeshed - "types-requests>=2.31.0.10", # Needed due to mypy typeshed + "types-requests", # Needed due to mypy typeshed "types-setuptools>=68.2.0.1", # Needed due to mypy typeshed "flake8>=6.1.0", # Style linter "flake8-breakpoint>=1.1.0", # detect breakpoints left in code @@ -156,3 +156,11 @@ tests = ["tests", "*/bee-py/tests"] [tool.coverage.report] exclude_lines = ["no cov", "if __name__ == .__main__.:", "if TYPE_CHECKING:"] + + +[tool.pytest.ini_options] +norecursedirs = "projects" +addopts = "-p no:ape_test" # NOTE: Prevents the ape plugin from activating on our tests +python_files = "test_*.py" +testpaths = "tests" +markers = "fuzzing: Run Hypothesis fuzz test suite" \ No newline at end of file diff --git a/src/bee_py/chunk/signer.py b/src/bee_py/chunk/signer.py new file mode 100644 index 0000000..336b433 --- /dev/null +++ b/src/bee_py/chunk/signer.py @@ -0,0 +1,101 @@ +from typing import Optional, Union + +from ape.managers.accounts import AccountAPI +from ape.types import AddressType +from ape.types.signatures import MessageSignature, recover_signer +from eth_account.messages import SignableMessage, encode_defunct +from eth_utils import keccak +from hexbytes import HexBytes + +from bee_py.utils.hash import keccak256_hash + +# Variables +UNCOMPRESSED_RECOVERY_ID = 27 + + +def hash_with_ethereum_prefix(data: Union[bytes, bytearray]) -> bytes: + """ + Calculates the Keccak-256 hash of the provided data, prefixed with the Ethereum signed message prefix. + + Args: + data (bytes): The data to be hashed. + + Returns: + bytes: The Keccak-256 hash of the prefixed data. + """ + + ethereum_signed_message_prefix = f"\x19Ethereum Signed Message:\n{len(data)}" + prefix_bytes = ethereum_signed_message_prefix.encode("utf-8") + + return keccak256_hash(prefix_bytes, data) + + +# TODO: Update the implementation when this PR is merged https://github.com/ApeWorX/ape/pull/1734 +def sign( + data: Union[str, bytes, bytearray], account: AccountAPI, auto_sign: Optional[bool] = False # noqa: FBT002 +) -> HexBytes: + """ + Calculates the signature of the provided data using the given private key. + + Args: + data(str, bytes, bytearray): The data to be signed. + account: ape account + auto_sign(Optional[bool]): Whether to enable auto-signing for the account + + Returns: + HexBytes -> The signature of the data as HexBytes + + N.B. It is not recoomened to pass private key here & there that's why I + thought to use ape's account container which is much more secure that just + passing the public key while calling this function. + """ + + if isinstance(data, str): + data = encode_defunct(text=data) + + # you have to set password as env variable + # more info here: https://docs.apeworx.io/ape/stable/userguides/accounts.html#keyfile-passphrase-environment-variable-more-secure + if auto_sign: + account.set_autosign(True) + + signature = account.sign_message(data) + + # return the signature encoded in vrs format + return HexBytes(signature.encode_vrs()) + + +# for more info ckeckout my gist: https://gist.github.com/Aviksaikat/fd5dfaef4c69e23116148b4b7c0377b6 +def public_key_to_address(pub_key: Union[str, bytes, HexBytes]) -> HexBytes[AddressType]: + """ + Converts an elliptic curve public key into its corresponding Ethereum address. + + Args: + pub_key (str | bytes | HexBytes): The elliptic curve public key. + + Returns: + EthAddress(HexBytes): The Ethereum address derived from the public key. + """ + if isinstance(pub_key, HexBytes): + pub_key = pub_key.hex() + hash_of_public_key = keccak(pub_key) + + # Extract the last 20 bytes (40 characters) from the keccak digest as the address + address = hash_of_public_key[24:] + return HexBytes(address) + + +def recover_addres(message: SignableMessage, signature: MessageSignature) -> AddressType: + """ + Recovers the Ethereum address from a given signature and message digest. + + This function can be used to verify the authenticity of a message by comparing + the recovered address with the actual address of the signer. + + Args: + message (SignableMessage): The signature generated by the signer. + signature (MessageSignature): The message digest of the data to be verified. + + Returns: + AddressType: The recovered Ethereum address. + """ + return recover_signer(message, signature) diff --git a/src/bee_py/types/type.py b/src/bee_py/types/type.py index 19db5af..ec24755 100644 --- a/src/bee_py/types/type.py +++ b/src/bee_py/types/type.py @@ -32,6 +32,9 @@ REFERENCE_BYTES_LENGTH = 32 ENCRYPTED_REFERENCE_BYTES_LENGTH = 64 +SIGNATURE_HEX_LENGTH = 130 +SIGNATURE_BYTES_LENGTH = 65 + # Minimal depth that can be used for creation of postage batch STAMPS_DEPTH_MIN = 17 diff --git a/src/bee_py/utils/bytes.py b/src/bee_py/utils/bytes.py index 651ff83..04214bc 100644 --- a/src/bee_py/utils/bytes.py +++ b/src/bee_py/utils/bytes.py @@ -51,10 +51,10 @@ def is_valid_flex_bytes(flex_bytes: FlexBytes) -> bool: """Checks if a byte array is within the specified length range. Args: - flex_bytes: The byte array to check. + flex_bytes: The byte array to check. Returns: - True if the byte array is valid, False otherwise. + True if the byte array is valid, False otherwise. """ return flex_bytes.minimum <= len(flex_bytes) <= flex_bytes.maximum @@ -64,11 +64,11 @@ def is_bytes(b: Any, length: int) -> TypeGuard[bytes]: """Type guard for the `bytes` type. Args: - b: The value to check. - length: The length of the byte array. + b: The value to check. + length: The length of the byte array. Returns: - True if the value is a byte array of the specified length, False otherwise. + True if the value is a byte array of the specified length, False otherwise. """ return isinstance(b, bytes) and len(b) == length @@ -78,12 +78,12 @@ def has_bytes_at_offset(data: bytes, offset: int, length: int) -> bool: """Checks if the specified byte array is contained in the given data at the specified offset. Args: - data: The data to check. - offset: The offset to check at. - length: The length of the byte array to check. + data: The data to check. + offset: The offset to check at. + length: The length of the byte array to check. Returns: - True if the byte array is contained in the data at the specified offset, False otherwise. + True if the byte array is contained in the data at the specified offset, False otherwise. """ if not isinstance(data, bytes): @@ -102,11 +102,11 @@ def is_flex_bytes(b: Any, flex_bytes: FlexBytes) -> TypeGuard[bytes]: """Type guard for the `FlexBytes` type. Args: - b: The value to check. - flex_bytes: A `FlexBytes` object. + b: The value to check. + flex_bytes: A `FlexBytes` object. Returns: - True if the value is a byte array within the specified length range, False otherwise. + True if the value is a byte array within the specified length range, False otherwise. """ return isinstance(b, bytes) and flex_bytes.min_length <= len(b) <= flex_bytes.max_length @@ -116,11 +116,11 @@ def assert_bytes_length(b: bytes, length: int): """Asserts that the length of the given byte array is equal to the specified length. Args: - b: The byte array to check. - length: The specified length. + b: The byte array to check. + length: The specified length. Raises: - TypeError: If the length of the byte array is not equal to the specified length. + TypeError: If the length of the byte array is not equal to the specified length. """ if not is_bytes(b, length): @@ -132,15 +132,15 @@ def flex_bytes_at_offset(data: bytes, offset: int, length: int) -> bytes: """Returns the specified number of bytes from the given data starting at the specified offset. Args: - data: The data to extract the bytes from. - offset: The offset to start from. - length: The number of bytes to extract. + data: The data to extract the bytes from. + offset: The offset to start from. + length: The number of bytes to extract. Returns: - A byte array containing the extracted bytes. + A byte array containing the extracted bytes. Raises: - ValueError: If the offset or length is out of bounds. + ValueError: If the offset or length is out of bounds. """ if not (0 <= offset <= len(data) - length): @@ -159,11 +159,11 @@ def bytes_equal(a: bytes, b: bytes) -> bool: """Returns True if the two byte arrays are equal, False otherwise. Args: - a: The first byte array to compare. - b: The second byte array to compare. + a: The first byte array to compare. + b: The second byte array to compare. Returns: - True if the two byte arrays are equal, False otherwise. + True if the two byte arrays are equal, False otherwise. """ if len(a) != len(b): @@ -176,10 +176,10 @@ def make_bytes(length: int) -> bytes: """Returns a new byte array filled with zeroes with the specified length. Args: - length: The length of the byte array. + length: The length of the byte array. Returns: - A byte array filled with zeroes with the specified length. + A byte array filled with zeroes with the specified length. """ return bytes(b"\x00" * length) @@ -189,10 +189,10 @@ def wrap_bytes_with_helpers(data: bytes) -> Data: """Wraps the given byte array with helper methods for text, JSON, and hex encoding. Args: - data: The byte array to wrap. + data: The byte array to wrap. Returns: - A byte array wrapped with helper methods for text, JSON, and hex encoding. + A byte array wrapped with helper methods for text, JSON, and hex encoding. """ if not isinstance(data, bytes): msg = "Data must be a byte array." diff --git a/src/bee_py/utils/eth.py b/src/bee_py/utils/eth.py index f93cc29..409610c 100644 --- a/src/bee_py/utils/eth.py +++ b/src/bee_py/utils/eth.py @@ -217,7 +217,7 @@ def make_ethereum_wallet_signer( account: Optional[AccountAPI], address: Optional[Union[str, HexBytes, AddressType]], auto_sign: Optional[bool] = False, # noqa: FBT002 -): +) -> EthereumSigner: """Creates a Signer instance that uses the `personal_sign` method to sign requested data. Args: diff --git a/src/bee_py/utils/hash.py b/src/bee_py/utils/hash.py index 049572d..9a955fa 100644 --- a/src/bee_py/utils/hash.py +++ b/src/bee_py/utils/hash.py @@ -1,28 +1,28 @@ from typing import Union from eth_utils import keccak -from hexbytes import HexBytes -#! Don't need this +from bee_py.utils.hex import bytes_to_hex -def keccak256_hash(*messages: Union[str, bytes, bytearray]) -> HexBytes: +def keccak256_hash(*messages: Union[bytes, bytearray]) -> str: """ Helper function for calculating the keccak256 hash with - @param messages Any number of messages (strings, byte arrays etc.) + @param messages Any number of messages (bytes, byte arrays) """ - - hashes = [] + combined = bytearray() for message in messages: - if isinstance(message, str): - hashes.append(keccak(text=message)) - elif isinstance(message, bytes): - hashes.append(keccak(hexstr=message.hex())) - elif not isinstance(message, bytearray): - msg = "Input should be either a string or bytes" + if not isinstance(message, bytearray) and not isinstance(message, bytes): + msg = f"Input should be either a string, bytes or bytearray: got {type(message)}." raise ValueError(msg) + combined += message + + return bytes_to_hex(keccak(combined)) + - # Concatenate the bytes objects - result = b"".join(hashes) - return HexBytes(keccak(result)) +if __name__ == "__main__": + l = bytes([1, 2, 3]) # noqa: E741 + p = bytes([4, 5, 6]) + _hash = keccak256_hash(l, p) + print(_hash) # noqa: T201 diff --git a/src/bee_py/utils/hex.py b/src/bee_py/utils/hex.py index 6bf09b7..2d9d3ea 100644 --- a/src/bee_py/utils/hex.py +++ b/src/bee_py/utils/hex.py @@ -98,7 +98,7 @@ def int_to_hex(inp: int, length: Optional[int] = None) -> str: return hex_string -def is_hex_string(s: Union[str, HexBytes, bytes], length: Optional[int] = None) -> bool: # noqa: ARG001 +def is_hex_string(s: Union[str, HexBytes, bytes], length: Optional[int] = None) -> bool: """Type guard for HexStrings. Requires no 0x prefix! diff --git a/tests/data/test_account.json b/tests/data/test_account.json new file mode 100644 index 0000000..dc43c08 --- /dev/null +++ b/tests/data/test_account.json @@ -0,0 +1,5 @@ +{ + "private_key": "634fb5a872396d9693e5c9f9d7233cfa93f395c093371017ff44aa9ae6564cdd", + "public_key": "03c32bb011339667a487b6c1c35061f15f7edc36aa9a0f8648aba07a4b8bd741b4", + "address": "8d3766440f0d7b949a5e32995d09619a7f86e632" +} diff --git a/tests/unit/chunk/conftest.py b/tests/unit/chunk/conftest.py index 4560c7c..58503e4 100644 --- a/tests/unit/chunk/conftest.py +++ b/tests/unit/chunk/conftest.py @@ -1,12 +1,21 @@ +import json +from pathlib import Path + import pytest from bee_py.chunk.bmt import bmt_hash from bee_py.chunk.serialize import serialize_bytes from bee_py.chunk.span import make_span +from bee_py.types.type import Data +from bee_py.utils.bytes import wrap_bytes_with_helpers +from bee_py.utils.hex import hex_to_bytes -# test cac +PROJECT_PATH = Path(__file__).parent +DATA_FOLDER = PROJECT_PATH / "data" +ACCOUNTS_FILE = DATA_FOLDER / "test_account.json" +# test cac @pytest.fixture def payload() -> bytes: return bytes([1, 2, 3]) @@ -30,3 +39,29 @@ def valid_address(content_hash): @pytest.fixture def data(payload): return serialize_bytes(make_span(len(payload)), payload) + + +# test signer +@pytest.fixture +def test_account_from_file() -> dict: + return json.loads(open(ACCOUNTS_FILE)) + + +@pytest.fixture +def data_to_sign_bytes() -> bytes: + return hex_to_bytes("2c26b46b68ffc68ff99b453c1d30413413422d706483bfa0f98a5e886266e7ae") + + +@pytest.fixture +def data_to_sign_with_helper(data_to_sign_bytes) -> Data: + return wrap_bytes_with_helpers(data_to_sign_bytes()) + + +@pytest.fixture +def expected_signature_hex() -> str: + return "336d24afef78c5883b96ad9a62552a8db3d236105cb059ddd04dc49680869dc16234f6852c277087f025d4114c4fac6b40295ecffd1194a84cdb91bd571769491b" # noqa: E501 + + +@pytest.fixture +def expected_signature_bytes(expected_signature_hex) -> bytes: + return hex_to_bytes(expected_signature_hex) diff --git a/tests/unit/chunk/test_signer.py b/tests/unit/chunk/test_signer.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/unit/utils/test_stamps.py b/tests/unit/utils/test_stamps.py index 311ee4e..cba0f8a 100644 --- a/tests/unit/utils/test_stamps.py +++ b/tests/unit/utils/test_stamps.py @@ -49,5 +49,5 @@ def test_get_stamp_cost_in_bzz(stamp_cost_in_bzz, depth, amount, expected): # n (20, 20_000_000_000, 20971520000000000), ], ) -def test_get_stamp_cost_in_plur(stamp_cost_in_plur, depth, amount, expected): +def test_get_stamp_cost_in_plur(stamp_cost_in_plur, depth, amount, expected): # noqa: ARG001 assert stamp_cost_in_plur == expected