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

This commit is contained in:
Bénédikt Tran 2025-05-16 14:00:01 +02:00 committed by GitHub
parent 1566c34dc7
commit 73d71a416f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 309 additions and 68 deletions

View file

@ -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 requires_hashdigest(digestname, openssl=None, usedforsecurity=True):
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,10 @@ 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):
try:
_hashlib.new(digestname, usedforsecurity=usedforsecurity)
except ValueError:
msg = f"missing OpenSSL hash algorithm: {digestname!r}"
raise unittest.SkipTest(msg)
_openssl_availabillity(digestname, usedforsecurity=usedforsecurity)
return func(*args, **kwargs)
return wrapper
@ -103,6 +114,202 @@ def requires_openssl_hashdigest(digestname, *, usedforsecurity=True):
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:
getattr(module, digestname)
except AttributeError:
fullname = f'{module_name}.{digestname}'
_missing_hash(fullname, implementation="HACL")
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_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.

View file

@ -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 unittest.mock as mock
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 @@ class RFCTestCaseMixin(AssertersMixin, HashFunctionsTrait):
)
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.
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 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 @@ class BuiltinRFCTestCase(BuiltinAssertersMixin,
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()."""