diff --git a/Lib/hashlib.py b/Lib/hashlib.py index 6c73eb9f31f8e4..82b552a42c6308 100644 --- a/Lib/hashlib.py +++ b/Lib/hashlib.py @@ -79,6 +79,23 @@ 'blake2b', 'blake2s', } +# Wrapper that only allows usage when usedforsecurity=False +# (effectively unapproved service indicator) +def __usedforsecurity_check(md, name, *args, **kwargs): + if kwargs.get("usedforsecurity", True): + raise ValueError(name + " is blocked when usedforsecurity=True") + return md(*args, **kwargs) + +# If the _hashlib OpenSSL wrapper is in FIPS mode, wrap other implementations +# to check the usedforsecurity kwarg. All builtin implementations are treated +# as only available for useforsecurity=False purposes in the presence of such +# a configured and linked OpenSSL. +def __get_wrapped_builtin(md, name): + if __openssl_fips_mode != 0: + from functools import partial + return partial(__usedforsecurity_check, md, name) + return md + def __get_builtin_constructor(name): if not isinstance(name, str): # Since this function is only used by new(), we use the same @@ -92,32 +109,32 @@ def __get_builtin_constructor(name): try: if name in {'SHA1', 'sha1'}: import _sha1 - cache['SHA1'] = cache['sha1'] = _sha1.sha1 + cache['SHA1'] = cache['sha1'] = __get_wrapped_builtin(_sha1.sha1, name) elif name in {'MD5', 'md5'}: import _md5 - cache['MD5'] = cache['md5'] = _md5.md5 + cache['MD5'] = cache['md5'] = __get_wrapped_builtin(_md5.md5, name) elif name in {'SHA256', 'sha256', 'SHA224', 'sha224'}: import _sha2 - cache['SHA224'] = cache['sha224'] = _sha2.sha224 - cache['SHA256'] = cache['sha256'] = _sha2.sha256 + cache['SHA224'] = cache['sha224'] = __get_wrapped_builtin(_sha2.sha224, name) + cache['SHA256'] = cache['sha256'] = __get_wrapped_builtin(_sha2.sha256, name) elif name in {'SHA512', 'sha512', 'SHA384', 'sha384'}: import _sha2 - cache['SHA384'] = cache['sha384'] = _sha2.sha384 - cache['SHA512'] = cache['sha512'] = _sha2.sha512 + cache['SHA384'] = cache['sha384'] = __get_wrapped_builtin(_sha2.sha384, name) + cache['SHA512'] = cache['sha512'] = __get_wrapped_builtin(_sha2.sha512, name) elif name in {'blake2b', 'blake2s'}: import _blake2 - cache['blake2b'] = _blake2.blake2b - cache['blake2s'] = _blake2.blake2s + cache['blake2b'] = __get_wrapped_builtin(_blake2.blake2b, name) + cache['blake2s'] = __get_wrapped_builtin(_blake2.blake2s, name) elif name in {'sha3_224', 'sha3_256', 'sha3_384', 'sha3_512'}: import _sha3 - cache['sha3_224'] = _sha3.sha3_224 - cache['sha3_256'] = _sha3.sha3_256 - cache['sha3_384'] = _sha3.sha3_384 - cache['sha3_512'] = _sha3.sha3_512 + cache['sha3_224'] = __get_wrapped_builtin(_sha3.sha3_224, name) + cache['sha3_256'] = __get_wrapped_builtin(_sha3.sha3_256, name) + cache['sha3_384'] = __get_wrapped_builtin(_sha3.sha3_384, name) + cache['sha3_512'] = __get_wrapped_builtin(_sha3.sha3_512, name) elif name in {'shake_128', 'shake_256'}: import _sha3 - cache['shake_128'] = _sha3.shake_128 - cache['shake_256'] = _sha3.shake_256 + cache['shake_128'] = __get_wrapped_builtin(_sha3.shake_128, name) + cache['shake_256'] = __get_wrapped_builtin(_sha3.shake_256, name) except ImportError: pass # no extension module, this hash is unsupported. @@ -192,10 +209,15 @@ def __hash_new(name, *args, **kwargs): __get_hash = __get_openssl_constructor algorithms_available = algorithms_available.union( _hashlib.openssl_md_meth_names) + try: + __openssl_fips_mode = _hashlib.get_fips_mode() + except ValueError: + __openssl_fips_mode = 0 except ImportError: _hashlib = None new = __py_new __get_hash = __get_builtin_constructor + __openssl_fips_mode = 0 try: # OpenSSL's PKCS5_PBKDF2_HMAC requires OpenSSL 1.0+ with HMAC and SHA diff --git a/Lib/test/_test_hashlib_fips.py b/Lib/test/_test_hashlib_fips.py new file mode 100644 index 00000000000000..9253724595446d --- /dev/null +++ b/Lib/test/_test_hashlib_fips.py @@ -0,0 +1,64 @@ +# Test the hashlib module usedforsecurity wrappers under fips. +# +# Copyright (C) 2024 Dimitri John Ledkov (dimitri.ledkov@surgut.co.uk) +# Licensed to PSF under a Contributor Agreement. +# + +"""Primarily executed by test_hashlib.py. It can run stand alone by humans.""" + +import os +import unittest + +OPENSSL_CONF_BACKUP = os.environ.get("OPENSSL_CONF") + + +class HashLibFIPSTestCase(unittest.TestCase): + @classmethod + def setUpClass(cls): + # This openssl.cnf mocks FIPS mode without any digest + # loaded. It means all digests must raise ValueError when + # usedforsecurity=True via either openssl or builtin + # constructors + OPENSSL_CONF = os.path.join(os.path.dirname(__file__), "hashlibdata", "openssl.cnf") + os.environ["OPENSSL_CONF"] = OPENSSL_CONF + # Ensure hashlib is loading a fresh libcrypto with openssl + # context affected by the above config file. Check if this can + # be folded into test_hashlib.py, specifically if + # import_fresh_module() results in a fresh library context + import hashlib + + def setUp(self): + try: + from _hashlib import get_fips_mode + except ImportError: + self.skipTest('_hashlib not available') + + if get_fips_mode() != 1: + self.skipTest('mocking fips mode failed') + + @classmethod + def tearDownClass(cls): + if OPENSSL_CONF_BACKUP is not None: + os.environ["OPENSSL_CONF"] = OPENSSL_CONF_BACKUP + else: + os.environ.pop("OPENSSL_CONF", None) + + def test_algorithms_available(self): + import hashlib + self.assertTrue(set(hashlib.algorithms_guaranteed). + issubset(hashlib.algorithms_available)) + # all available algorithms must be loadable, bpo-47101 + self.assertNotIn("undefined", hashlib.algorithms_available) + for name in hashlib.algorithms_available: + with self.subTest(name): + digest = hashlib.new(name, usedforsecurity=False) + + def test_usedforsecurity_true(self): + import hashlib + for name in hashlib.algorithms_available: + with self.subTest(name): + with self.assertRaises(ValueError): + digest = hashlib.new(name, usedforsecurity=True) + +if __name__ == "__main__": + unittest.main() diff --git a/Lib/test/hashlibdata/openssl.cnf b/Lib/test/hashlibdata/openssl.cnf new file mode 100644 index 00000000000000..022f1771a46b1e --- /dev/null +++ b/Lib/test/hashlibdata/openssl.cnf @@ -0,0 +1,19 @@ +# Activate base provider only, with default properties fips=yes. It +# means that fips mode is on, and no digest implementations are +# available. Perfect for mock testing builtin FIPS wrappers. + +config_diagnostics = 1 +openssl_conf = openssl_init + +[openssl_init] +providers = provider_sect +alg_section = algorithm_sect + +[provider_sect] +null = null_sect + +[null_sect] +activate = 1 + +[algorithm_sect] +default_properties = fips=yes diff --git a/Lib/test/support/script_helper.py b/Lib/test/support/script_helper.py index 46ce950433ddf6..7b83c8e06326d3 100644 --- a/Lib/test/support/script_helper.py +++ b/Lib/test/support/script_helper.py @@ -303,7 +303,14 @@ def make_zip_pkg(zip_dir, zip_basename, pkg_name, script_basename, @support.requires_subprocess() -def run_test_script(script): +def run_test_script(script, **kwargs): + """Run the file *script* in a child interpreter. + + Keyword arguments are passed on to subprocess.run() within. + + Asserts if the child exits non-zero. Prints child output after + execution when run in verbose mode. + """ # use -u to try to get the full output if the test hangs or crash if support.verbose: def title(text): @@ -315,7 +322,7 @@ def title(text): # In verbose mode, the child process inherit stdout and stdout, # to see output in realtime and reduce the risk of losing output. args = [sys.executable, "-E", "-X", "faulthandler", "-u", script, "-v"] - proc = subprocess.run(args) + proc = subprocess.run(args, **kwargs) print(title(f"{name} completed: exit code {proc.returncode}"), flush=True) if proc.returncode: diff --git a/Lib/test/test_hashlib.py b/Lib/test/test_hashlib.py index 33845d8a9e2651..8551616fe1e1fc 100644 --- a/Lib/test/test_hashlib.py +++ b/Lib/test/test_hashlib.py @@ -23,6 +23,7 @@ from test.support import hashlib_helper from test.support.import_helper import import_fresh_module from test.support import requires_resource +from test.support import script_helper from test.support import threading_helper from http.client import HTTPException @@ -1387,6 +1388,18 @@ def scrypt(password=b"password", /, **kwargs): MAX_DKLEN = ((1 << 32) - 1) * 32 # see RFC 7914 self.assertRaises(numeric_exc_types, scrypt, dklen=MAX_DKLEN + 1) + def test_builtins_in_openssl_fips_mode(self): + try: + from _hashlib import get_fips_mode + except ImportError: + self.skipTest('OpenSSL _hashlib not available') + from test import _test_hashlib_fips + child_test_path = _test_hashlib_fips.__file__ + env = os.environ.copy() + # A config to mock FIPS mode, see _test_hashlib_fips.py. + env["OPENSSL_CONF"] = os.path.join(os.path.dirname(__file__), "hashlibdata", "openssl.cnf") + script_helper.run_test_script(child_test_path, env=env) + if __name__ == "__main__": unittest.main() diff --git a/Makefile.pre.in b/Makefile.pre.in index edc6c5aa812e27..a14dcd06e3d62e 100644 --- a/Makefile.pre.in +++ b/Makefile.pre.in @@ -2600,6 +2600,7 @@ TESTSUBDIRS= idlelib/idle_test \ test/decimaltestdata \ test/dtracedata \ test/encoded_modules \ + test/hashlibdata \ test/leakers \ test/libregrtest \ test/mathdata \ diff --git a/Misc/NEWS.d/next/Library/2024-11-26-16-31-40.gh-issue-127298.jqYJvn.rst b/Misc/NEWS.d/next/Library/2024-11-26-16-31-40.gh-issue-127298.jqYJvn.rst new file mode 100644 index 00000000000000..e555661a195073 --- /dev/null +++ b/Misc/NEWS.d/next/Library/2024-11-26-16-31-40.gh-issue-127298.jqYJvn.rst @@ -0,0 +1,8 @@ +:mod:`hashlib`'s builtin hash implementations now check ``usedforsecurity=False``, +when the OpenSSL library default provider is in OpenSSL's FIPS mode. This helps +ensure that only US FIPS approved implementations are in use by default on systems +configured as such. + +This is only active when :mod:`hashlib` has been built with OpenSSL implementation +support and said OpenSSL library includes the FIPS mode feature. Not all variants +do, and OpenSSL is not a *required* build time dependency of ``hashlib``.