diff --git a/packages/testing/src/execution_testing/base_types/base_types.py b/packages/testing/src/execution_testing/base_types/base_types.py index 0bb63e69eca..b42fb315485 100644 --- a/packages/testing/src/execution_testing/base_types/base_types.py +++ b/packages/testing/src/execution_testing/base_types/base_types.py @@ -13,7 +13,7 @@ TypeVar, ) -from Crypto.Hash import keccak +from ethereum.crypto.hash import keccak256 as _keccak256 from pydantic import GetCoreSchemaHandler, StringConstraints from pydantic_core.core_schema import ( PlainValidatorFunctionSchema, @@ -201,8 +201,7 @@ def hex(self, *args: Any, **kwargs: Any) -> str: def keccak256(self) -> "Hash": """Return the keccak256 hash of the opcode byte representation.""" - k = keccak.new(digest_bits=256) - return Hash(k.update(bytes(self)).digest()) + return Hash(_keccak256(self)) def sha256(self) -> "Hash": """Return the sha256 hash of the opcode byte representation.""" diff --git a/packages/testing/src/execution_testing/base_types/tests/test_keccak_dispatch.py b/packages/testing/src/execution_testing/base_types/tests/test_keccak_dispatch.py new file mode 100644 index 00000000000..95bd341ba6d --- /dev/null +++ b/packages/testing/src/execution_testing/base_types/tests/test_keccak_dispatch.py @@ -0,0 +1,174 @@ +""" +Tests for the Keccak backend dispatch in `ethereum.crypto.hash`. + +The module decides at import time whether to use hashlib (linked OpenSSL) +or pycryptodome, depending on whether `hashlib.new("keccak-256", ...)` +succeeds. These tests verify: + +* both backends produce byte-identical output (cross-backend equivalence); +* the fallback engages cleanly when hashlib raises, simulated via + monkeypatch so we can exercise the path on a Python whose OpenSSL does + expose Keccak; +* on a Python where hashlib supports Keccak, the fast path is selected + (guards against a regression where `algorithms_available` lies and the + module silently forces every user onto pycryptodome). +""" + +import hashlib +import importlib +from collections.abc import Iterator +from typing import Any +from unittest.mock import patch + +import pytest + +# Pre-NIST Keccak vectors. Empty-input digests are widely published; the +# `hashme` vector was confirmed against a working hashlib build during +# PR #2370 review. +KECCAK256_VECTORS: list[tuple[bytes, str]] = [ + ( + b"", + "c5d2460186f7233c927e7db2dcc703c0e500b653ca82273b7bfad8045d85a470", + ), + ( + b"abc", + "4e03657aea45a94fc7d47ba826c8d667c0d1e6e33a64a036ec44f58fa12d6c45", + ), + ( + b"hashme", + "7f98885dc9cf152c0bb08eaf056668f99c47cabd8fe01b1276f9a305b1389646", + ), +] + +KECCAK512_VECTORS: list[tuple[bytes, str]] = [ + ( + b"", + "0eab42de4c3ceb9235fc91acffe746b29c29a8c366b7c60e4e67c466f36a4304" + "c00fa9caf9d87976ba469bcbe06713b435f091ef2769fb160cdab33d3670680e", + ), +] + + +def _hashlib_has_keccak() -> bool: + """Return True if `hashlib.new("keccak-256", ...)` succeeds here.""" + try: + hashlib.new("keccak-256") + except ValueError: + return False + return True + + +def _clean_reimport_hash() -> Any: + """Drop and reimport `ethereum.crypto.hash` for a fresh dispatch run.""" + mod = importlib.import_module("ethereum.crypto.hash") + return importlib.reload(mod) + + +@pytest.fixture +def restore_hash_module() -> Iterator[None]: + """Restore the natural-state `ethereum.crypto.hash` after each test.""" + yield + _clean_reimport_hash() + + +@pytest.mark.parametrize("buffer, expected_hex", KECCAK256_VECTORS) +def test_keccak256_known_vectors(buffer: bytes, expected_hex: str) -> None: + """Active backend produces published Keccak-256 digests.""" + from ethereum.crypto.hash import keccak256 + + assert keccak256(buffer).hex() == expected_hex + + +@pytest.mark.parametrize("buffer, expected_hex", KECCAK512_VECTORS) +def test_keccak512_known_vectors(buffer: bytes, expected_hex: str) -> None: + """Active backend produces published Keccak-512 digests.""" + from ethereum.crypto.hash import keccak512 + + assert keccak512(buffer).hex() == expected_hex + + +def test_both_backends_agree() -> None: + """Hashlib and pycryptodome produce byte-identical Keccak-256 output.""" + if not _hashlib_has_keccak(): + pytest.skip("hashlib lacks keccak-256 on this OpenSSL build") + + from Crypto.Hash import keccak as pyc_keccak + + inputs = [ + b"", + b"x", + b"\x00" * 64, + bytes(range(256)), + b"a" * 4096, + b"\xff" * 65536, + ] + for buf in inputs: + hl = hashlib.new("keccak-256", buf).digest() + pc = pyc_keccak.new(digest_bits=256).update(buf).digest() + assert hl == pc, f"backends disagree on input of length {len(buf)}" + + +def test_fallback_engages_when_hashlib_lacks_keccak( + restore_hash_module: None, +) -> None: + """If hashlib raises for Keccak, the module uses pycryptodome instead.""" + del restore_hash_module + real_new = hashlib.new + + def mocked_new(name: str, *args: Any, **kwargs: Any) -> Any: + if name in ("keccak-256", "keccak-512"): + raise ValueError(f"unsupported hash type {name}") + return real_new(name, *args, **kwargs) + + with patch.object(hashlib, "new", side_effect=mocked_new): + h = _clean_reimport_hash() + + assert h._USE_HASHLIB is False, ( + "module did not engage pycryptodome fallback" + ) + for buffer, expected_hex in KECCAK256_VECTORS: + assert h.keccak256(buffer).hex() == expected_hex + for buffer, expected_hex in KECCAK512_VECTORS: + assert h.keccak512(buffer).hex() == expected_hex + + +def test_native_path_used_when_hashlib_has_keccak( + restore_hash_module: None, +) -> None: + """ + Verify hashlib path is selected when keccak-256 is supported. + + Guards against a regression where a bogus availability check (e.g. + one that relied on `hashlib.algorithms_available`) would silently + force every user onto the slower pycryptodome path. + """ + del restore_hash_module + if not _hashlib_has_keccak(): + pytest.skip("hashlib lacks keccak-256 on this OpenSSL build") + + h = _clean_reimport_hash() + assert h._USE_HASHLIB is True, ( + "module engaged pycryptodome fallback despite hashlib having keccak" + ) + + +def test_eest_bytes_keccak256_matches_eels() -> None: + """`Bytes.keccak256()` returns the same digest as EELS `keccak256`.""" + from ethereum.crypto.hash import keccak256 + + from ..base_types import Bytes + + for buffer in (b"", b"hashme", bytes(range(256))): + from_eest = bytes(Bytes(buffer).keccak256()) + from_eels = bytes(keccak256(buffer)) + assert from_eest == from_eels + + +def test_eest_trie_keccak256_matches_eels() -> None: + """`trie.keccak256` and EELS `keccak256` return identical digests.""" + from ethereum.crypto.hash import keccak256 as eels + + from ...test_types.trie import keccak256 as trie + + for buffer in (b"", b"hashme", bytes(range(256))): + assert bytes(trie(buffer)) == bytes(eels(buffer)) diff --git a/packages/testing/src/execution_testing/test_types/trie.py b/packages/testing/src/execution_testing/test_types/trie.py index 4eb9d4288b2..16fd4d5709d 100644 --- a/packages/testing/src/execution_testing/test_types/trie.py +++ b/packages/testing/src/execution_testing/test_types/trie.py @@ -18,7 +18,7 @@ cast, ) -from Crypto.Hash import keccak +from ethereum.crypto.hash import keccak256 from ethereum_rlp import Extended, rlp from ethereum_types.bytes import Bytes, Bytes20, Bytes32 from ethereum_types.frozen import slotted_freezable @@ -36,12 +36,6 @@ class FrontierAccount: code: Bytes -def keccak256(buffer: Bytes) -> Bytes32: - """Compute the keccak256 hash of the input `buffer`.""" - k = keccak.new(digest_bits=256) - return Bytes32(k.update(buffer).digest()) - - def encode_account( raw_account_data: FrontierAccount, storage_root: Bytes ) -> Bytes: diff --git a/pyproject.toml b/pyproject.toml index b89580d7ac0..40fd1eef299 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -225,7 +225,7 @@ actionlint = [ "shellcheck-py>=0.10", ] doc = [ - "docc>=0.6.0,<0.7.0", + "docc>=0.6.1,<0.7.0", "fladrif>=0.2.0,<0.3.0", "mistletoe>=1.5.0,<2", ] diff --git a/src/ethereum/crypto/hash.py b/src/ethereum/crypto/hash.py index 67b9053f3a0..9439522a7ce 100644 --- a/src/ethereum/crypto/hash.py +++ b/src/ethereum/crypto/hash.py @@ -11,14 +11,55 @@ Cryptographic hashing functions. """ -from Crypto.Hash import keccak -from ethereum_types.bytes import Bytes, Bytes32, Bytes64 +import hashlib + +from Crypto.Hash import keccak as _pycryptodome_keccak +from ethereum_types.bytes import Bytes32, Bytes64 Hash32 = Bytes32 Hash64 = Bytes64 -def keccak256(buffer: Bytes | bytearray) -> Hash32: +def _hashlib_has_keccak() -> bool: + """Return `True` if `hashlib` can compute pre-NIST Keccak digests.""" + try: + hashlib.new("keccak-256", b"") + except ValueError: + return False + return True + + +# Decide once at import time whether to dispatch to hashlib (linked OpenSSL) +# or to pycryptodome's bundled implementation. OpenSSL only gained +# default-provider Keccak in 3.2.0 (Nov 2023); LTS 3.0.x and 3.1.x (still +# shipped by Debian 12, RHEL 9, etc.) do not provide it. We probe with +# `hashlib.new()` because `hashlib.algorithms_available` omits some +# OpenSSL-provider digests on common builds (e.g. python-build-standalone / +# uv-managed CPython on OpenSSL 3.5.x), yielding false negatives. +_USE_HASHLIB = _hashlib_has_keccak() + + +if _USE_HASHLIB: + + def _keccak256_digest(buffer: bytes | bytearray) -> bytes: + return hashlib.new("keccak-256", buffer).digest() + + def _keccak512_digest(buffer: bytes | bytearray) -> bytes: + return hashlib.new("keccak-512", buffer).digest() +else: + + def _keccak256_digest(buffer: bytes | bytearray) -> bytes: + return ( + _pycryptodome_keccak.new(digest_bits=256).update(buffer).digest() + ) + + def _keccak512_digest(buffer: bytes | bytearray) -> bytes: + return ( + _pycryptodome_keccak.new(digest_bits=512).update(buffer).digest() + ) + + +def keccak256(buffer: bytes | bytearray) -> Hash32: """ Computes the keccak256 hash of the input `buffer`. @@ -33,11 +74,10 @@ def keccak256(buffer: Bytes | bytearray) -> Hash32: Output of the hash function. """ - k = keccak.new(digest_bits=256) - return Hash32(k.update(buffer).digest()) + return Hash32(_keccak256_digest(buffer)) -def keccak512(buffer: Bytes | bytearray) -> Hash64: +def keccak512(buffer: bytes | bytearray) -> Hash64: """ Computes the keccak512 hash of the input `buffer`. @@ -52,5 +92,4 @@ def keccak512(buffer: Bytes | bytearray) -> Hash64: Output of the hash function. """ - k = keccak.new(digest_bits=512) - return Hash64(k.update(buffer).digest()) + return Hash64(_keccak512_digest(buffer)) diff --git a/uv.lock b/uv.lock index 9c6ba50bf78..65ccb609d2f 100644 --- a/uv.lock +++ b/uv.lock @@ -737,7 +737,7 @@ wheels = [ [[package]] name = "docc" -version = "0.6.0" +version = "0.6.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "importlib-resources" }, @@ -749,9 +749,9 @@ dependencies = [ { name = "tomli" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/be/37/cd2e718c92e26da0e1129700467f62efd8bd1f53dc1dae59b3e0e0402574/docc-0.6.0.tar.gz", hash = "sha256:d335be8f509884fefb507e75a184a3c9824e50e511dd5464be9089d1ad744912", size = 72398, upload-time = "2026-04-29T02:02:30.202Z" } +sdist = { url = "https://files.pythonhosted.org/packages/a6/e2/47252138c9978ca2ab7a3637bba1464f8da2d1f34e1886babaef0459c1b6/docc-0.6.1.tar.gz", hash = "sha256:c4cc94f542afa76e2d9b10ce5e43ec4cdddc87897dde52a2a0f07a1db0af9499", size = 74268, upload-time = "2026-05-14T23:51:53.862Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/6a/66/b81c58e0514531a26201f3d6b437b24375b996db5a8105d3e21420ccfc08/docc-0.6.0-py3-none-any.whl", hash = "sha256:bb4ed18f23926f1cea649a407c67d6794a92361826593e6ba374e80892fe98d1", size = 99176, upload-time = "2026-04-29T02:02:28.829Z" }, + { url = "https://files.pythonhosted.org/packages/89/0b/56f73ba7767f53796d43e86e2a3e77d04211b214f09b8fc96cbb5fb110c5/docc-0.6.1-py3-none-any.whl", hash = "sha256:c058a8207603270d437d4f1887eb850ddfc2519f336627d4f373d87d296a0198", size = 100269, upload-time = "2026-05-14T23:51:52.256Z" }, ] [[package]] @@ -965,7 +965,7 @@ dev = [ { name = "cairosvg", specifier = ">=2.7.0,<3" }, { name = "codespell", specifier = "==2.4.1" }, { name = "codespell", specifier = ">=2.4.1,<3" }, - { name = "docc", specifier = ">=0.6.0,<0.7.0" }, + { name = "docc", specifier = ">=0.6.1,<0.7.0" }, { name = "ethereum-execution", extras = ["optimized"] }, { name = "ethereum-execution-testing", editable = "packages/testing" }, { name = "filelock", specifier = ">=3.15.1,<4" }, @@ -1003,7 +1003,7 @@ dev = [ { name = "vulture", specifier = "==2.14.0" }, ] doc = [ - { name = "docc", specifier = ">=0.6.0,<0.7.0" }, + { name = "docc", specifier = ">=0.6.1,<0.7.0" }, { name = "fladrif", specifier = ">=0.2.0,<0.3.0" }, { name = "mistletoe", specifier = ">=1.5.0,<2" }, ]