gh-130149: cleanup refactorization of test_hmac.py (#131318)

New features:

* refactor `hashlib_helper.requires_hashdigest` in prevision of a future
  `hashlib_helper.requires_builtin_hashdigest` for built-in hashes only
* add `hashlib_helper.requires_openssl_hashdigest` to request OpenSSL
   hashes, assuming that `_hashlib` exists.

Refactoring:

* split hmac.copy() test by implementation
* update how algorithms are discovered for RFC test cases
* simplify how OpenSSL hash digests are requested
* refactor hexdigest tests for RFC test vectors
* typo fix: `assert_hmac_hexdigest_by_new` -> `assert_hmac_hexdigest_by_name`

Improvements:

* strengthen contract on `hmac_new_by_name` and `hmac_digest_by_name`
* rename mixin classes to better match their responsibility
This commit is contained in:
Bénédikt Tran 2025-03-17 11:10:03 +01:00 committed by GitHub
parent 85c04f80fd
commit de8890f5ab
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 263 additions and 147 deletions

View file

@ -65,6 +65,7 @@ class HMAC:
def _init_hmac(self, key, msg, digestmod):
self._hmac = _hashopenssl.hmac_new(key, msg, digestmod=digestmod)
self._inner = self._outer = None # because the slots are defined
self.digest_size = self._hmac.digest_size
self.block_size = self._hmac.block_size

View file

@ -1,6 +1,7 @@
import functools
import hashlib
import unittest
from test.support.import_helper import import_module
try:
import _hashlib
@ -12,44 +13,81 @@ def requires_hashlib():
return unittest.skipIf(_hashlib is None, "requires _hashlib")
def requires_hashdigest(digestname, openssl=None, usedforsecurity=True):
"""Decorator raising SkipTest if a hashing algorithm is not available
def _decorate_func_or_class(func_or_class, decorator_func):
if not isinstance(func_or_class, type):
return decorator_func(func_or_class)
The hashing algorithm could be missing or blocked by a strict crypto
policy.
decorated_class = func_or_class
setUpClass = decorated_class.__dict__.get('setUpClass')
if setUpClass is None:
def setUpClass(cls):
super(decorated_class, cls).setUpClass()
setUpClass.__qualname__ = decorated_class.__qualname__ + '.setUpClass'
setUpClass.__module__ = decorated_class.__module__
else:
setUpClass = setUpClass.__func__
setUpClass = classmethod(decorator_func(setUpClass))
decorated_class.setUpClass = setUpClass
return decorated_class
def requires_hashdigest(digestname, openssl=None, usedforsecurity=True):
"""Decorator raising SkipTest if a hashing algorithm is not available.
The hashing algorithm may be missing, blocked by a strict crypto policy,
or Python may be configured with `--with-builtin-hashlib-hashes=no`.
If 'openssl' is True, then the decorator checks that OpenSSL provides
the algorithm. Otherwise the check falls back to built-in
implementations. The usedforsecurity flag is passed to the constructor.
the algorithm. Otherwise the check falls back to (optional) built-in
HACL* implementations.
The usedforsecurity flag is passed to the constructor but has no effect
on HACL* implementations.
Examples of exceptions being suppressed:
ValueError: [digital envelope routines: EVP_DigestInit_ex] disabled for FIPS
ValueError: unsupported hash type md4
"""
def decorator(func_or_class):
if isinstance(func_or_class, type):
setUpClass = func_or_class.__dict__.get('setUpClass')
if setUpClass is None:
def setUpClass(cls):
super(func_or_class, cls).setUpClass()
setUpClass.__qualname__ = func_or_class.__qualname__ + '.setUpClass'
setUpClass.__module__ = func_or_class.__module__
else:
setUpClass = setUpClass.__func__
setUpClass = classmethod(decorator(setUpClass))
func_or_class.setUpClass = setUpClass
return func_or_class
if openssl and _hashlib is not None:
def test_availability():
_hashlib.new(digestname, usedforsecurity=usedforsecurity)
else:
def test_availability():
hashlib.new(digestname, usedforsecurity=usedforsecurity)
@functools.wraps(func_or_class)
def decorator_func(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
try:
if openssl and _hashlib is not None:
_hashlib.new(digestname, usedforsecurity=usedforsecurity)
else:
hashlib.new(digestname, usedforsecurity=usedforsecurity)
except ValueError:
raise unittest.SkipTest(
f"hash digest {digestname!r} is not available."
)
return func_or_class(*args, **kwargs)
test_availability()
except ValueError as exc:
msg = f"missing hash algorithm: {digestname!r}"
raise unittest.SkipTest(msg) from exc
return func(*args, **kwargs)
return wrapper
def decorator(func_or_class):
return _decorate_func_or_class(func_or_class, decorator_func)
return decorator
def requires_openssl_hashdigest(digestname, *, usedforsecurity=True):
"""Decorator raising SkipTest if an OpenSSL hashing algorithm is missing.
The hashing algorithm may be missing or blocked by a strict crypto policy.
"""
def decorator_func(func):
@requires_hashlib()
@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)
return func(*args, **kwargs)
return wrapper
def decorator(func_or_class):
return _decorate_func_or_class(func_or_class, decorator_func)
return decorator

View file

@ -61,7 +61,11 @@ class CreatorMixin:
"""Mixin exposing a method creating a HMAC object."""
def hmac_new(self, key, msg=None, digestmod=None):
"""Create a new HMAC object."""
"""Create a new HMAC object.
Implementations should accept arbitrary 'digestmod' as this
method can be used to test which exceptions are being raised.
"""
raise NotImplementedError
def bind_hmac_new(self, digestmod):
@ -73,7 +77,11 @@ class DigestMixin:
"""Mixin exposing a method computing a HMAC digest."""
def hmac_digest(self, key, msg=None, digestmod=None):
"""Compute a HMAC digest."""
"""Compute a HMAC digest.
Implementations should accept arbitrary 'digestmod' as this
method can be used to test which exceptions are being raised.
"""
raise NotImplementedError
def bind_hmac_digest(self, digestmod):
@ -120,7 +128,7 @@ class ThroughOpenSSLAPIMixin(CreatorMixin, DigestMixin):
return _hashlib.hmac_digest(key, msg, digest=digestmod)
class CheckerMixin:
class ObjectCheckerMixin:
"""Mixin for checking HMAC objects (pure Python, OpenSSL or built-in)."""
def check_object(self, h, hexdigest, hashname, digest_size, block_size):
@ -141,10 +149,10 @@ class CheckerMixin:
self.assertEqual(h.hexdigest().upper(), hexdigest.upper())
class TestVectorsMixin(CreatorMixin, DigestMixin, CheckerMixin):
"""Mixin class for all test vectors test cases."""
class AssertersMixin(CreatorMixin, DigestMixin, ObjectCheckerMixin):
"""Mixin class for common tests."""
def hmac_new_by_name(self, key, msg=None, hashname=None):
def hmac_new_by_name(self, key, msg=None, *, hashname):
"""Alternative implementation of hmac_new().
This is typically useful when one needs to test against an HMAC
@ -152,13 +160,22 @@ class TestVectorsMixin(CreatorMixin, DigestMixin, CheckerMixin):
by their name (all HMAC implementations must at least recognize
hash functions by their names but some may use aliases such as
`hashlib.sha1` instead of "sha1").
Unlike hmac_new(), this method may assert the type of 'hashname'
as it should only be used in tests that are expected to create
a HMAC object.
"""
self.assertIsInstance(hashname, str | None)
self.assertIsInstance(hashname, str)
return self.hmac_new(key, msg, digestmod=hashname)
def hmac_digest_by_name(self, key, msg=None, hashname=None):
"""Alternative implementation of hmac_digest()."""
self.assertIsInstance(hashname, str | None)
def hmac_digest_by_name(self, key, msg=None, *, hashname):
"""Alternative implementation of hmac_digest().
Unlike hmac_digest(), this method may assert the type of 'hashname'
as it should only be used in tests that are expected to compute a
HMAC digest.
"""
self.assertIsInstance(hashname, str)
return self.hmac_digest(key, msg, digestmod=hashname)
def assert_hmac(
@ -196,7 +213,7 @@ class TestVectorsMixin(CreatorMixin, DigestMixin, CheckerMixin):
self.assert_hmac_new_by_name(
key, msg, hexdigest, hashname, digest_size, block_size
)
self.assert_hmac_hexdigest_by_new(
self.assert_hmac_hexdigest_by_name(
key, msg, hexdigest, hashname, digest_size
)
@ -255,16 +272,28 @@ class TestVectorsMixin(CreatorMixin, DigestMixin, CheckerMixin):
self, key, msg, hexdigest, digestmod, digest_size,
):
"""Check a HMAC digest computed by hmac_digest()."""
d = self.hmac_digest(key, msg, digestmod=digestmod)
self.assertEqual(len(d), digest_size)
self.assertEqual(d, binascii.unhexlify(hexdigest))
self._check_hmac_hexdigest(
key, msg, hexdigest, digest_size,
hmac_digest_func=self.hmac_digest,
hmac_digest_kwds={'digestmod': digestmod},
)
def assert_hmac_hexdigest_by_new(
def assert_hmac_hexdigest_by_name(
self, key, msg, hexdigest, hashname, digest_size
):
"""Check a HMAC digest computed by hmac_digest_by_name()."""
self.assertIsInstance(hashname, str | None)
d = self.hmac_digest_by_name(key, msg, hashname=hashname)
self.assertIsInstance(hashname, str)
self._check_hmac_hexdigest(
key, msg, hexdigest, digest_size,
hmac_digest_func=self.hmac_digest_by_name,
hmac_digest_kwds={'hashname': hashname},
)
def _check_hmac_hexdigest(
self, key, msg, hexdigest, digest_size,
hmac_digest_func, hmac_digest_kwds,
):
d = hmac_digest_func(key, msg, **hmac_digest_kwds)
self.assertEqual(len(d), digest_size)
self.assertEqual(d, binascii.unhexlify(hexdigest))
@ -279,7 +308,7 @@ class TestVectorsMixin(CreatorMixin, DigestMixin, CheckerMixin):
self.check_object(h1, hexdigest, hashname, digest_size, block_size)
class PyTestVectorsMixin(PyModuleMixin, TestVectorsMixin):
class PyAssertersMixin(PyModuleMixin, AssertersMixin):
def assert_hmac_extra_cases(
self, key, msg, hexdigest, digestmod, hashname, digest_size, block_size
@ -293,46 +322,62 @@ class PyTestVectorsMixin(PyModuleMixin, TestVectorsMixin):
self.check_object(h, hexdigest, hashname, digest_size, block_size)
class OpenSSLTestVectorsMixin(TestVectorsMixin):
class OpenSSLAssertersMixin(ThroughOpenSSLAPIMixin, AssertersMixin):
def hmac_new(self, key, msg=None, digestmod=None):
return _hashlib.hmac_new(key, msg, digestmod=digestmod)
def hmac_digest(self, key, msg=None, digestmod=None):
return _hashlib.hmac_digest(key, msg, digest=digestmod)
def hmac_new_by_name(self, key, msg=None, hashname=None):
# ignore 'digestmod' and use the exact openssl function
def hmac_new_by_name(self, key, msg=None, *, hashname):
self.assertIsInstance(hashname, str)
openssl_func = getattr(_hashlib, f"openssl_{hashname}")
return self.hmac_new(key, msg, digestmod=openssl_func)
def hmac_digest_by_name(self, key, msg=None, hashname=None):
def hmac_digest_by_name(self, key, msg=None, *, hashname):
self.assertIsInstance(hashname, str)
openssl_func = getattr(_hashlib, f"openssl_{hashname}")
return self.hmac_digest(key, msg, digestmod=openssl_func)
class RFCTestCasesMixin(TestVectorsMixin):
"""Test HMAC implementations against test vectors from the RFC.
Subclasses must override the 'md5' and other 'sha*' attributes
to test the implementations. Their value can be a string, a callable,
or a PEP-257 module.
"""
class HashFunctionsTrait:
"""Trait class for 'hashfunc' in hmac_new() and hmac_digest()."""
ALGORITHMS = [
'md5', 'sha1',
'sha224', 'sha256', 'sha384', 'sha512',
]
# Those will be automatically set to non-None on subclasses
# as they are set by __init_subclass()__.
md5 = sha1 = sha224 = sha256 = sha384 = sha512 = None
# By default, a missing algorithm skips the test that uses it.
md5 = sha1 = sha224 = sha256 = sha384 = sha512 = property(
lambda self: self.skipTest("missing hash function")
)
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()
def __init_subclass__(cls, *args, **kwargs):
super().__init_subclass__(*args, **kwargs)
for name in cls.ALGORITHMS:
setattr(cls, name, name)
class RFCTestCaseMixin(HashFunctionsTrait):
"""Test HMAC implementations against test vectors from the RFC."""
def test_md5(self):
def md5test(key, msg, hexdigest):
self.assert_hmac(key, msg, hexdigest, self.md5, "md5", 16, 64)
@ -412,7 +457,6 @@ class RFCTestCasesMixin(TestVectorsMixin):
self._test_sha2_rfc4231(self.sha512, 'sha512', 64, 128)
def _test_sha2_rfc4231(self, hashfunc, hashname, digest_size, block_size):
def hmactest(key, data, hexdigests):
hexdigest = hexdigests[hashname]
@ -531,57 +575,9 @@ class RFCTestCasesMixin(TestVectorsMixin):
'134676fb6de0446065c97440fa8c6a58',
})
@hashlib_helper.requires_hashdigest('sha256')
def test_legacy_block_size_warnings(self):
class MockCrazyHash(object):
"""Ain't no block_size attribute here."""
def __init__(self, *args):
self._x = hashlib.sha256(*args)
self.digest_size = self._x.digest_size
def update(self, v):
self._x.update(v)
def digest(self):
return self._x.digest()
with warnings.catch_warnings():
warnings.simplefilter('error', RuntimeWarning)
with self.assertRaises(RuntimeWarning):
hmac.HMAC(b'a', b'b', digestmod=MockCrazyHash)
self.fail('Expected warning about missing block_size')
MockCrazyHash.block_size = 1
with self.assertRaises(RuntimeWarning):
hmac.HMAC(b'a', b'b', digestmod=MockCrazyHash)
self.fail('Expected warning about small block_size')
def test_with_fallback(self):
cache = getattr(hashlib, '__builtin_constructor_cache')
try:
cache['foo'] = hashlib.sha256
hexdigest = hmac.digest(b'key', b'message', 'foo').hex()
expected = ('6e9ef29b75fffc5b7abae527d58fdadb'
'2fe42e7219011976917343065f58ed4a')
self.assertEqual(hexdigest, expected)
finally:
cache.pop('foo')
class RFCWithOpenSSLHashFunctionTestCasesMixin(RFCTestCasesMixin):
def __init_subclass__(cls, *args, **kwargs):
super().__init_subclass__(*args, **kwargs)
for name in cls.ALGORITHMS:
@property
@hashlib_helper.requires_hashlib()
@hashlib_helper.requires_hashdigest(name, openssl=True)
def func(self, *, __name=name): # __name needed to bind 'name'
return getattr(_hashlib, f'openssl_{__name}')
setattr(cls, name, func)
class PyRFCTestCase(PyTestVectorsMixin, ThroughObjectMixin,
RFCWithOpenSSLHashFunctionTestCasesMixin,
class PyRFCTestCase(ThroughObjectMixin, PyAssertersMixin,
WithOpenSSLHashFunctions, RFCTestCaseMixin,
unittest.TestCase):
"""Python implementation of HMAC using hmac.HMAC().
@ -589,8 +585,8 @@ class PyRFCTestCase(PyTestVectorsMixin, ThroughObjectMixin,
"""
class PyDotNewRFCTestCase(PyTestVectorsMixin, ThroughModuleAPIMixin,
RFCWithOpenSSLHashFunctionTestCasesMixin,
class PyDotNewRFCTestCase(ThroughModuleAPIMixin, PyAssertersMixin,
WithOpenSSLHashFunctions, RFCTestCaseMixin,
unittest.TestCase):
"""Python implementation of HMAC using hmac.new().
@ -598,12 +594,13 @@ class PyDotNewRFCTestCase(PyTestVectorsMixin, ThroughModuleAPIMixin,
"""
class OpenSSLRFCTestCase(OpenSSLTestVectorsMixin,
RFCWithOpenSSLHashFunctionTestCasesMixin,
class OpenSSLRFCTestCase(OpenSSLAssertersMixin,
WithOpenSSLHashFunctions, RFCTestCaseMixin,
unittest.TestCase):
"""OpenSSL implementation of HMAC.
The underlying hash functions are also OpenSSL-based."""
The underlying hash functions are also OpenSSL-based.
"""
# TODO(picnixz): once we have a HACL* HMAC, we should also test the Python
@ -668,7 +665,7 @@ class DigestModTestCaseMixin(CreatorMixin, DigestMixin):
return cases
class ConstructorTestCaseMixin(CreatorMixin, DigestMixin, CheckerMixin):
class ConstructorTestCaseMixin(CreatorMixin, DigestMixin, ObjectCheckerMixin):
"""HMAC constructor tests based on HMAC-SHA-2/256."""
key = b"key"
@ -847,7 +844,7 @@ class PySanityTestCase(ThroughObjectMixin, PyModuleMixin, SanityTestCaseMixin,
self.assertStartsWith(repr(h), "<hmac.HMAC object at")
@hashlib_helper.requires_hashdigest('sha256', openssl=True)
@hashlib_helper.requires_openssl_hashdigest('sha256')
class OpenSSLSanityTestCase(ThroughOpenSSLAPIMixin, SanityTestCaseMixin,
unittest.TestCase):
@ -899,45 +896,50 @@ class PyUpdateTestCase(UpdateTestCaseMixin, unittest.TestCase):
return self.hmac.HMAC(key, msg, digestmod='sha256')
@hashlib_helper.requires_hashlib()
@hashlib_helper.requires_hashdigest('sha256', openssl=True)
@hashlib_helper.requires_openssl_hashdigest('sha256')
class OpenSSLUpdateTestCase(UpdateTestCaseMixin, unittest.TestCase):
def HMAC(self, key, msg=None):
return hmac.new(key, msg, digestmod='sha256')
@hashlib_helper.requires_hashdigest('sha256')
class CopyTestCase(unittest.TestCase):
class CopyBaseTestCase:
def test_attributes_old(self):
def test_attributes(self):
raise NotImplementedError
def test_realcopy(self):
raise NotImplementedError
@hashlib_helper.requires_hashdigest('sha256')
class PythonCopyTestCase(CopyBaseTestCase, unittest.TestCase):
def test_attributes(self):
# Testing if attributes are of same type.
h1 = hmac.HMAC.__new__(hmac.HMAC)
h1._init_old(b"key", b"msg", digestmod="sha256")
h2 = h1.copy()
self.assertEqual(type(h1._inner), type(h2._inner))
self.assertEqual(type(h1._outer), type(h2._outer))
def test_realcopy_old(self):
# Testing if the copy method created a real copy.
h1 = hmac.HMAC.__new__(hmac.HMAC)
h1._init_old(b"key", b"msg", digestmod="sha256")
self.assertIsNone(h1._hmac)
self.assertIsNotNone(h1._inner)
self.assertIsNotNone(h1._outer)
h2 = h1.copy()
self.assertIsNone(h2._hmac)
self.assertIsNotNone(h2._inner)
self.assertIsNotNone(h2._outer)
self.assertEqual(type(h1._inner), type(h2._inner))
self.assertEqual(type(h1._outer), type(h2._outer))
def test_realcopy(self):
# Testing if the copy method created a real copy.
h1 = hmac.HMAC.__new__(hmac.HMAC)
h1._init_old(b"key", b"msg", digestmod="sha256")
h2 = h1.copy()
# Using id() in case somebody has overridden __eq__/__ne__.
self.assertNotEqual(id(h1), id(h2))
self.assertNotEqual(id(h1._inner), id(h2._inner))
self.assertNotEqual(id(h1._outer), id(h2._outer))
@hashlib_helper.requires_hashlib()
def test_realcopy_hmac(self):
h1 = hmac.HMAC.__new__(hmac.HMAC)
h1._init_hmac(b"key", b"msg", digestmod="sha256")
h2 = h1.copy()
self.assertNotEqual(id(h1._hmac), id(h2._hmac))
def test_equality(self):
# Testing if the copy has the same digests.
h1 = hmac.HMAC(b"key", digestmod="sha256")
@ -951,11 +953,47 @@ class CopyTestCase(unittest.TestCase):
h1 = hmac.new(b"key", digestmod="sha256")
h1.update(b"some random text")
h2 = h1.copy()
# Using id() in case somebody has overridden __eq__/__ne__.
self.assertNotEqual(id(h1), id(h2))
self.assertEqual(h1.digest(), h2.digest())
self.assertEqual(h1.hexdigest(), h2.hexdigest())
class ExtensionCopyTestCase(CopyBaseTestCase):
def init(self, h):
"""Call the dedicate init() method to test."""
raise NotImplementedError
def test_attributes(self):
# Testing if attributes are of same type.
h1 = hmac.HMAC.__new__(hmac.HMAC)
self.init(h1)
self.assertIsNotNone(h1._hmac)
self.assertIsNone(h1._inner)
self.assertIsNone(h1._outer)
h2 = h1.copy()
self.assertIsNotNone(h2._hmac)
self.assertIsNone(h2._inner)
self.assertIsNone(h2._outer)
def test_realcopy(self):
h1 = hmac.HMAC.__new__(hmac.HMAC)
self.init(h1)
h2 = h1.copy()
# Using id() in case somebody has overridden __eq__/__ne__.
self.assertNotEqual(id(h1._hmac), id(h2._hmac))
@hashlib_helper.requires_openssl_hashdigest('sha256')
class OpenSSLCopyTestCase(ExtensionCopyTestCase, unittest.TestCase):
def init(self, h):
h._init_hmac(b"key", b"msg", digestmod="sha256")
class CompareDigestMixin:
@staticmethod
@ -1087,5 +1125,44 @@ class OperatorCompareDigestTestCase(CompareDigestMixin, unittest.TestCase):
compare_digest = operator_compare_digest
class PyMiscellaneousTests(unittest.TestCase):
"""Miscellaneous tests for the pure Python HMAC module."""
@hashlib_helper.requires_hashdigest('sha256')
def test_legacy_block_size_warnings(self):
class MockCrazyHash(object):
"""Ain't no block_size attribute here."""
def __init__(self, *args):
self._x = hashlib.sha256(*args)
self.digest_size = self._x.digest_size
def update(self, v):
self._x.update(v)
def digest(self):
return self._x.digest()
with warnings.catch_warnings():
warnings.simplefilter('error', RuntimeWarning)
with self.assertRaises(RuntimeWarning):
hmac.HMAC(b'a', b'b', digestmod=MockCrazyHash)
self.fail('Expected warning about missing block_size')
MockCrazyHash.block_size = 1
with self.assertRaises(RuntimeWarning):
hmac.HMAC(b'a', b'b', digestmod=MockCrazyHash)
self.fail('Expected warning about small block_size')
@hashlib_helper.requires_hashdigest('sha256')
def test_with_fallback(self):
cache = getattr(hashlib, '__builtin_constructor_cache')
try:
cache['foo'] = hashlib.sha256
hexdigest = hmac.digest(b'key', b'message', 'foo').hex()
expected = ('6e9ef29b75fffc5b7abae527d58fdadb'
'2fe42e7219011976917343065f58ed4a')
self.assertEqual(hexdigest, expected)
finally:
cache.pop('foo')
if __name__ == "__main__":
unittest.main()