https://github.com/python/cpython/commit/de8890f5ab1c1e767029d46c20f513beefc47b18 commit: de8890f5ab1c1e767029d46c20f513beefc47b18 branch: main author: Bénédikt Tran <10796600+picn...@users.noreply.github.com> committer: picnixz <10796600+picn...@users.noreply.github.com> date: 2025-03-17T11:10:03+01:00 summary:
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 files: M Lib/hmac.py M Lib/test/support/hashlib_helper.py M Lib/test/test_hmac.py diff --git a/Lib/hmac.py b/Lib/hmac.py index 8b4eb2fe741e60..30b6b478734dfc 100644 --- a/Lib/hmac.py +++ b/Lib/hmac.py @@ -65,6 +65,7 @@ def __init__(self, key, msg=None, digestmod=''): 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 diff --git a/Lib/test/support/hashlib_helper.py b/Lib/test/support/hashlib_helper.py index 477e0f110eabba..bed3d696cb384d 100644 --- a/Lib/test/support/hashlib_helper.py +++ b/Lib/test/support/hashlib_helper.py @@ -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 _decorate_func_or_class(func_or_class, decorator_func): + if not isinstance(func_or_class, type): + return decorator_func(func_or_class) + + 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 + """Decorator raising SkipTest if a hashing algorithm is not available. - The hashing algorithm could be missing or blocked by a strict crypto - policy. + 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 """ + if openssl and _hashlib is not None: + def test_availability(): + _hashlib.new(digestname, usedforsecurity=usedforsecurity) + else: + def test_availability(): + hashlib.new(digestname, usedforsecurity=usedforsecurity) + + def decorator_func(func): + @functools.wraps(func) + def wrapper(*args, **kwargs): + try: + 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): - 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 - - @functools.wraps(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: - if openssl and _hashlib is not None: - _hashlib.new(digestname, usedforsecurity=usedforsecurity) - else: - hashlib.new(digestname, usedforsecurity=usedforsecurity) + _hashlib.new(digestname, usedforsecurity=usedforsecurity) except ValueError: - raise unittest.SkipTest( - f"hash digest {digestname!r} is not available." - ) - return func_or_class(*args, **kwargs) + 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 diff --git a/Lib/test/test_hmac.py b/Lib/test/test_hmac.py index c8c806dc1baf8b..ed96af2b2d874b 100644 --- a/Lib/test/test_hmac.py +++ b/Lib/test/test_hmac.py @@ -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 @@ def hmac_digest(self, key, msg=None, digestmod=None): 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 @@ def check_hexdigest(self, h, hexdigest, digest_size): 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 @@ def hmac_new_by_name(self, key, msg=None, hashname=None): 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 @@ def assert_hmac( 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 @@ def assert_hmac_hexdigest( 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 @@ def assert_hmac_extra_cases( 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 @@ def assert_hmac_extra_cases( self.check_object(h, hexdigest, hashname, digest_size, block_size) -class OpenSSLTestVectorsMixin(TestVectorsMixin): - - 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) +class OpenSSLAssertersMixin(ThroughOpenSSLAPIMixin, AssertersMixin): - 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 @@ def test_sha2_512_rfc4231(self): 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 @@ def hmactest(key, data, hexdigests): '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 @@ def _invalid_digestmod_cases(self, func, key, msg, choices): 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 @@ def test_repr(self): 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 @@ def HMAC(self, key, msg=None): 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') +class CopyBaseTestCase: + + def test_attributes(self): + raise NotImplementedError + + def test_realcopy(self): + raise NotImplementedError + + @hashlib_helper.requires_hashdigest('sha256') -class CopyTestCase(unittest.TestCase): +class PythonCopyTestCase(CopyBaseTestCase, unittest.TestCase): - def test_attributes_old(self): + 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") + 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_old(self): + 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") - self.assertIsNone(h1._hmac) - h2 = h1.copy() - self.assertIsNone(h2._hmac) # 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 @@ def test_equality_new(self): 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() _______________________________________________ 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