https://github.com/python/cpython/commit/73d71a416fb05b64c2b43fade5d781a1fa0cb2cd
commit: 73d71a416fb05b64c2b43fade5d781a1fa0cb2cd
branch: main
author: Bénédikt Tran <10796600+picn...@users.noreply.github.com>
committer: picnixz <10796600+picn...@users.noreply.github.com>
date: 2025-05-16T14:00:01+02:00
summary:

gh-132388: test HACL* and OpenSSL hash functions in pure Python HMAC (#134051)

files:
M Lib/test/support/hashlib_helper.py
M Lib/test/test_hmac.py

diff --git a/Lib/test/support/hashlib_helper.py 
b/Lib/test/support/hashlib_helper.py
index 5043f08dd93de6..7032257b06877a 100644
--- a/Lib/test/support/hashlib_helper.py
+++ b/Lib/test/support/hashlib_helper.py
@@ -23,6 +23,22 @@ def requires_builtin_hmac():
     return unittest.skipIf(_hmac is None, "requires _hmac")
 
 
+def _missing_hash(digestname, implementation=None, *, exc=None):
+    parts = ["missing", implementation, f"hash algorithm: {digestname!r}"]
+    msg = " ".join(filter(None, parts))
+    raise unittest.SkipTest(msg) from exc
+
+
+def _openssl_availabillity(digestname, *, usedforsecurity):
+    try:
+        _hashlib.new(digestname, usedforsecurity=usedforsecurity)
+    except AttributeError:
+        assert _hashlib is None
+        _missing_hash(digestname, "OpenSSL")
+    except ValueError as exc:
+        _missing_hash(digestname, "OpenSSL", exc=exc)
+
+
 def _decorate_func_or_class(func_or_class, decorator_func):
     if not isinstance(func_or_class, type):
         return decorator_func(func_or_class)
@@ -71,8 +87,7 @@ def wrapper(*args, **kwargs):
             try:
                 test_availability()
             except ValueError as exc:
-                msg = f"missing hash algorithm: {digestname!r}"
-                raise unittest.SkipTest(msg) from exc
+                _missing_hash(digestname, exc=exc)
             return func(*args, **kwargs)
         return wrapper
 
@@ -87,14 +102,44 @@ def requires_openssl_hashdigest(digestname, *, 
usedforsecurity=True):
     The hashing algorithm may be missing or blocked by a strict crypto policy.
     """
     def decorator_func(func):
-        @requires_hashlib()
+        @requires_hashlib()  # avoid checking at each call
         @functools.wraps(func)
         def wrapper(*args, **kwargs):
+            _openssl_availabillity(digestname, usedforsecurity=usedforsecurity)
+            return func(*args, **kwargs)
+        return wrapper
+
+    def decorator(func_or_class):
+        return _decorate_func_or_class(func_or_class, decorator_func)
+    return decorator
+
+
+def find_openssl_hashdigest_constructor(digestname, *, usedforsecurity=True):
+    """Find the OpenSSL hash function constructor by its name."""
+    assert isinstance(digestname, str), digestname
+    _openssl_availabillity(digestname, usedforsecurity=usedforsecurity)
+    # This returns a function of the form _hashlib.openssl_<name> and
+    # not a lambda function as it is rejected by _hashlib.hmac_new().
+    return getattr(_hashlib, f"openssl_{digestname}")
+
+
+def requires_builtin_hashdigest(
+    module_name, digestname, *, usedforsecurity=True
+):
+    """Decorator raising SkipTest if a HACL* hashing algorithm is missing.
+
+    - The *module_name* is the C extension module name based on HACL*.
+    - The *digestname* is one of its member, e.g., 'md5'.
+    """
+    def decorator_func(func):
+        @functools.wraps(func)
+        def wrapper(*args, **kwargs):
+            module = import_module(module_name)
             try:
-                _hashlib.new(digestname, usedforsecurity=usedforsecurity)
-            except ValueError:
-                msg = f"missing OpenSSL hash algorithm: {digestname!r}"
-                raise unittest.SkipTest(msg)
+                getattr(module, digestname)
+            except AttributeError:
+                fullname = f'{module_name}.{digestname}'
+                _missing_hash(fullname, implementation="HACL")
             return func(*args, **kwargs)
         return wrapper
 
@@ -103,6 +148,168 @@ def decorator(func_or_class):
     return decorator
 
 
+def find_builtin_hashdigest_constructor(
+    module_name, digestname, *, usedforsecurity=True
+):
+    """Find the HACL* hash function constructor.
+
+    - The *module_name* is the C extension module name based on HACL*.
+    - The *digestname* is one of its member, e.g., 'md5'.
+    """
+    module = import_module(module_name)
+    try:
+        constructor = getattr(module, digestname)
+        constructor(b'', usedforsecurity=usedforsecurity)
+    except (AttributeError, TypeError, ValueError):
+        _missing_hash(f'{module_name}.{digestname}', implementation="HACL")
+    return constructor
+
+
+class HashFunctionsTrait:
+    """Mixin trait class containing hash functions.
+
+    This class is assumed to have all unitest.TestCase methods but should
+    not directly inherit from it to prevent the test suite being run on it.
+
+    Subclasses should implement the hash functions by returning an object
+    that can be recognized as a valid digestmod parameter for both hashlib
+    and HMAC. In particular, it cannot be a lambda function as it will not
+    be recognized by hashlib (it will still be accepted by the pure Python
+    implementation of HMAC).
+    """
+
+    ALGORITHMS = [
+        'md5', 'sha1',
+        'sha224', 'sha256', 'sha384', 'sha512',
+        'sha3_224', 'sha3_256', 'sha3_384', 'sha3_512',
+    ]
+
+    # Default 'usedforsecurity' to use when looking up a hash function.
+    usedforsecurity = True
+
+    def _find_constructor(self, name):
+        # By default, a missing algorithm skips the test that uses it.
+        self.assertIn(name, self.ALGORITHMS)
+        self.skipTest(f"missing hash function: {name}")
+
+    @property
+    def md5(self):
+        return self._find_constructor("md5")
+
+    @property
+    def sha1(self):
+        return self._find_constructor("sha1")
+
+    @property
+    def sha224(self):
+        return self._find_constructor("sha224")
+
+    @property
+    def sha256(self):
+        return self._find_constructor("sha256")
+
+    @property
+    def sha384(self):
+        return self._find_constructor("sha384")
+
+    @property
+    def sha512(self):
+        return self._find_constructor("sha512")
+
+    @property
+    def sha3_224(self):
+        return self._find_constructor("sha3_224")
+
+    @property
+    def sha3_256(self):
+        return self._find_constructor("sha3_256")
+
+    @property
+    def sha3_384(self):
+        return self._find_constructor("sha3_384")
+
+    @property
+    def sha3_512(self):
+        return self._find_constructor("sha3_512")
+
+
+class NamedHashFunctionsTrait(HashFunctionsTrait):
+    """Trait containing named hash functions.
+
+    Hash functions are available if and only if they are available in hashlib.
+    """
+
+    def _find_constructor(self, name):
+        self.assertIn(name, self.ALGORITHMS)
+        return name
+
+
+class OpenSSLHashFunctionsTrait(HashFunctionsTrait):
+    """Trait containing OpenSSL hash functions.
+
+    Hash functions are available if and only if they are available in _hashlib.
+    """
+
+    def _find_constructor(self, name):
+        self.assertIn(name, self.ALGORITHMS)
+        return find_openssl_hashdigest_constructor(
+            name, usedforsecurity=self.usedforsecurity
+        )
+
+
+class BuiltinHashFunctionsTrait(HashFunctionsTrait):
+    """Trait containing HACL* hash functions.
+
+    Hash functions are available if and only if they are available in C.
+    In particular, HACL* HMAC-MD5 may be available even though HACL* md5
+    is not since the former is unconditionally built.
+    """
+
+    def _find_constructor_in(self, module, name):
+        self.assertIn(name, self.ALGORITHMS)
+        return find_builtin_hashdigest_constructor(module, name)
+
+    @property
+    def md5(self):
+        return self._find_constructor_in("_md5", "md5")
+
+    @property
+    def sha1(self):
+        return self._find_constructor_in("_sha1", "sha1")
+
+    @property
+    def sha224(self):
+        return self._find_constructor_in("_sha2", "sha224")
+
+    @property
+    def sha256(self):
+        return self._find_constructor_in("_sha2", "sha256")
+
+    @property
+    def sha384(self):
+        return self._find_constructor_in("_sha2", "sha384")
+
+    @property
+    def sha512(self):
+        return self._find_constructor_in("_sha2", "sha512")
+
+    @property
+    def sha3_224(self):
+        return self._find_constructor_in("_sha3", "sha3_224")
+
+    @property
+    def sha3_256(self):
+        return self._find_constructor_in("_sha3","sha3_256")
+
+    @property
+    def sha3_384(self):
+        return self._find_constructor_in("_sha3","sha3_384")
+
+    @property
+    def sha3_512(self):
+        return self._find_constructor_in("_sha3","sha3_512")
+
+
 def find_gil_minsize(modules_names, default=2048):
     """Get the largest GIL_MINSIZE value for the given cryptographic modules.
 
diff --git a/Lib/test/test_hmac.py b/Lib/test/test_hmac.py
index 70c7943772249e..e898644dd8a552 100644
--- a/Lib/test/test_hmac.py
+++ b/Lib/test/test_hmac.py
@@ -1,8 +1,27 @@
+"""Test suite for HMAC.
+
+Python provides three different implementations of HMAC:
+
+- OpenSSL HMAC using OpenSSL hash functions.
+- HACL* HMAC using HACL* hash functions.
+- Generic Python HMAC using user-defined hash functions.
+
+The generic Python HMAC implementation is able to use OpenSSL
+callables or names, HACL* named hash functions or arbitrary
+objects implementing PEP 247 interface.
+
+In the two first cases, Python HMAC wraps a C HMAC object (either OpenSSL
+or HACL*-based). As a last resort, HMAC is re-implemented in pure Python.
+It is however interesting to test the pure Python implementation against
+the OpenSSL and HACL* hash functions.
+"""
+
 import binascii
 import functools
 import hmac
 import hashlib
 import random
+import test.support
 import test.support.hashlib_helper as hashlib_helper
 import types
 import unittest
@@ -10,6 +29,12 @@
 import warnings
 from _operator import _compare_digest as operator_compare_digest
 from test.support import check_disallow_instantiation
+from test.support.hashlib_helper import (
+    BuiltinHashFunctionsTrait,
+    HashFunctionsTrait,
+    NamedHashFunctionsTrait,
+    OpenSSLHashFunctionsTrait,
+)
 from test.support.import_helper import import_fresh_module, import_module
 
 try:
@@ -382,50 +407,7 @@ class BuiltinAssertersMixin(ThroughBuiltinAPIMixin, 
AssertersMixin):
     pass
 
 
-class HashFunctionsTrait:
-    """Trait class for 'hashfunc' in hmac_new() and hmac_digest()."""
-
-    ALGORITHMS = [
-        'md5', 'sha1',
-        'sha224', 'sha256', 'sha384', 'sha512',
-        'sha3_224', 'sha3_256', 'sha3_384', 'sha3_512',
-    ]
-
-    # By default, a missing algorithm skips the test that uses it.
-    _ = property(lambda self: self.skipTest("missing hash function"))
-    md5 = sha1 = _
-    sha224 = sha256 = sha384 = sha512 = _
-    sha3_224 = sha3_256 = sha3_384 = sha3_512 = _
-    del _
-
-
-class WithOpenSSLHashFunctions(HashFunctionsTrait):
-    """Test a HMAC implementation with an OpenSSL-based callable 'hashfunc'."""
-
-    @classmethod
-    def setUpClass(cls):
-        super().setUpClass()
-
-        for name in cls.ALGORITHMS:
-            @property
-            @hashlib_helper.requires_openssl_hashdigest(name)
-            def func(self, *, __name=name):  # __name needed to bind 'name'
-                return getattr(_hashlib, f'openssl_{__name}')
-            setattr(cls, name, func)
-
-
-class WithNamedHashFunctions(HashFunctionsTrait):
-    """Test a HMAC implementation with a named 'hashfunc'."""
-
-    @classmethod
-    def setUpClass(cls):
-        super().setUpClass()
-
-        for name in cls.ALGORITHMS:
-            setattr(cls, name, name)
-
-
-class RFCTestCaseMixin(AssertersMixin, HashFunctionsTrait):
+class RFCTestCaseMixin(HashFunctionsTrait, AssertersMixin):
     """Test HMAC implementations against RFC 2202/4231 and NIST test vectors.
 
     - Test vectors for MD5 and SHA-1 are taken from RFC 2202.
@@ -739,26 +721,83 @@ def test_sha3_512_nist(self):
             )
 
 
-class PyRFCTestCase(ThroughObjectMixin, PyAssertersMixin,
-                    WithOpenSSLHashFunctions, RFCTestCaseMixin,
-                    unittest.TestCase):
+class PurePythonInitHMAC(PyModuleMixin, HashFunctionsTrait):
+
+    @classmethod
+    def setUpClass(cls):
+        super().setUpClass()
+        for meth in ['_init_openssl_hmac', '_init_builtin_hmac']:
+            fn = getattr(cls.hmac.HMAC, meth)
+            cm = mock.patch.object(cls.hmac.HMAC, meth, autospec=True, 
wraps=fn)
+            cls.enterClassContext(cm)
+
+    @classmethod
+    def tearDownClass(cls):
+        cls.hmac.HMAC._init_openssl_hmac.assert_not_called()
+        cls.hmac.HMAC._init_builtin_hmac.assert_not_called()
+        # Do not assert that HMAC._init_old() has been called as it's tricky
+        # to determine whether a test for a specific hash function has been
+        # executed or not. On regular builds, it will be called but if a
+        # hash function is not available, it's hard to detect for which
+        # test we should checj HMAC._init_old() or not.
+        super().tearDownClass()
+
+
+class PyRFCOpenSSLTestCase(ThroughObjectMixin,
+                           PyAssertersMixin,
+                           OpenSSLHashFunctionsTrait,
+                           RFCTestCaseMixin,
+                           PurePythonInitHMAC,
+                           unittest.TestCase):
     """Python implementation of HMAC using hmac.HMAC().
 
-    The underlying hash functions are OpenSSL-based.
+    The underlying hash functions are OpenSSL-based but
+    _init_old() is used instead of _init_openssl_hmac().
     """
 
 
-class PyDotNewRFCTestCase(ThroughModuleAPIMixin, PyAssertersMixin,
-                          WithOpenSSLHashFunctions, RFCTestCaseMixin,
-                          unittest.TestCase):
+class PyRFCBuiltinTestCase(ThroughObjectMixin,
+                           PyAssertersMixin,
+                           BuiltinHashFunctionsTrait,
+                           RFCTestCaseMixin,
+                           PurePythonInitHMAC,
+                           unittest.TestCase):
+    """Python implementation of HMAC using hmac.HMAC().
+
+    The underlying hash functions are HACL*-based but
+    _init_old() is used instead of _init_builtin_hmac().
+    """
+
+
+class PyDotNewOpenSSLRFCTestCase(ThroughModuleAPIMixin,
+                                 PyAssertersMixin,
+                                 OpenSSLHashFunctionsTrait,
+                                 RFCTestCaseMixin,
+                                 PurePythonInitHMAC,
+                                 unittest.TestCase):
+    """Python implementation of HMAC using hmac.new().
+
+    The underlying hash functions are OpenSSL-based but
+    _init_old() is used instead of _init_openssl_hmac().
+    """
+
+
+class PyDotNewBuiltinRFCTestCase(ThroughModuleAPIMixin,
+                                 PyAssertersMixin,
+                                 BuiltinHashFunctionsTrait,
+                                 RFCTestCaseMixin,
+                                 PurePythonInitHMAC,
+                                 unittest.TestCase):
     """Python implementation of HMAC using hmac.new().
 
-    The underlying hash functions are OpenSSL-based.
+    The underlying hash functions are HACL-based but
+    _init_old() is used instead of _init_openssl_hmac().
     """
 
 
 class OpenSSLRFCTestCase(OpenSSLAssertersMixin,
-                         WithOpenSSLHashFunctions, RFCTestCaseMixin,
+                         OpenSSLHashFunctionsTrait,
+                         RFCTestCaseMixin,
                          unittest.TestCase):
     """OpenSSL implementation of HMAC.
 
@@ -767,7 +806,8 @@ class OpenSSLRFCTestCase(OpenSSLAssertersMixin,
 
 
 class BuiltinRFCTestCase(BuiltinAssertersMixin,
-                         WithNamedHashFunctions, RFCTestCaseMixin,
+                         NamedHashFunctionsTrait,
+                         RFCTestCaseMixin,
                          unittest.TestCase):
     """Built-in HACL* implementation of HMAC.
 
@@ -784,12 +824,6 @@ def assert_hmac_extra_cases(
             self.check_hmac_hexdigest(key, msg, hexdigest, digest_size, func)
 
 
-# TODO(picnixz): once we have a HACL* HMAC, we should also test the Python
-# implementation of HMAC with a HACL*-based hash function. For now, we only
-# test it partially via the '_sha2' module, but for completeness we could
-# also test the RFC test vectors against all possible implementations.
-
-
 class DigestModTestCaseMixin(CreatorMixin, DigestMixin):
     """Tests for the 'digestmod' parameter for hmac_new() and hmac_digest()."""
 

_______________________________________________
Python-checkins mailing list -- python-checkins@python.org
To unsubscribe send an email to python-checkins-le...@python.org
https://mail.python.org/mailman3/lists/python-checkins.python.org/
Member address: arch...@mail-archive.com

Reply via email to