diff options
author | Eli Collins <elic@assurancetechnologies.com> | 2020-05-02 14:14:25 -0400 |
---|---|---|
committer | Eli Collins <elic@assurancetechnologies.com> | 2020-05-02 14:14:25 -0400 |
commit | 18aa5a99271908054b8fc65d79c91c8404d486af (patch) | |
tree | 9750869367a4290a26857c7500f2ca271deda112 | |
parent | 61f4f3ba5d520dc5b35415be7c9dae49911429db (diff) | |
download | passlib-18aa5a99271908054b8fc65d79c91c8404d486af.tar.gz |
reworked lookup_hash() and create_hex_digest() internals to work better
on FIPS systems (issue 116).
* lookup_hash():
- moved all hash consturctor error checks / handling into HashInfo object,
which simplifies lookup_hash() internals
- [minor] added "required" kwd, inverse of the now-deprecated "return_unknown" kwd
- [minor] now caches unknown/unsupported HashInfo records.
* HashInfo:
- now catches ValueErrors thrown by hash constructor,
and special-cased detection of "disabled for fips" errors.
- stub instances now have constructor that throws UnknownHashError,
instead of being None. calling code should detect stub instances via
the new "not info.supported" instead of testing for "info.const is None".
* create_hex_digest() now defaults to creating handlers w/ mock hash func
when it's not present (e.g. due to FIPS). this should let them be imported;
and defer the errors until they're actually used.
* added _set_mock_fips_mode() and some helps to make lookup_hash()
fake a FIPS mode system (per traceback provided in issue comments).
used this to make some preliminary UTs for the digest & hasher changes above.
-rw-r--r-- | docs/history/1.7.rst | 19 | ||||
-rw-r--r-- | passlib/crypto/digest.py | 160 | ||||
-rw-r--r-- | passlib/exc.py | 20 | ||||
-rw-r--r-- | passlib/handlers/digests.py | 30 | ||||
-rw-r--r-- | passlib/tests/test_crypto_digest.py | 44 | ||||
-rw-r--r-- | passlib/tests/test_handlers.py | 34 | ||||
-rw-r--r-- | passlib/utils/compat/__init__.py | 9 |
7 files changed, 276 insertions, 40 deletions
diff --git a/docs/history/1.7.rst b/docs/history/1.7.rst index 0d25fb2..3cee9a9 100644 --- a/docs/history/1.7.rst +++ b/docs/history/1.7.rst @@ -26,6 +26,25 @@ Bugfixes * :mod:`passlib.ext.django`: fixed lru_cache import (django 3 compatibility) +Other Changes +------------- + +* Modified some internals to help run on FIPS systems (:issue:`116`): + + In particular, when MD5 hash is not available, :class:`~passlib.hash.hex_md5` + will now return a dummy hasher which throws an error if used; rather than throwing + an uncaught :exc:`!ValueError` when an application attempts to import it. (Similar behavior + added for the other unsalted digest hashes). + + .. py:currentmodule:: passlib.crypto.digest + + Also, :func:`lookup_hash`'s ``required=False`` kwd was modified to report unsupported hashes + via the :attr:`HashInfo.supported` attribute; rather than letting ValueErrors through uncaught. + + This should allow CryptContext instances to be created on FIPS systems without having + a load-time error (though they will still receive an error if an attempt is made to actually + *use* a FIPS-disabled hash). + **1.7.2** (2019-11-22) ====================== diff --git a/passlib/crypto/digest.py b/passlib/crypto/digest.py index 25f1be4..c811fd2 100644 --- a/passlib/crypto/digest.py +++ b/passlib/crypto/digest.py @@ -32,8 +32,8 @@ except ImportError: # pkg from passlib import exc from passlib.utils import join_bytes, to_native_str, join_byte_values, to_bytes, \ - SequenceMixin -from passlib.utils.compat import irange, int_types, unicode_or_bytes_types, PY3 + SequenceMixin, as_bool +from passlib.utils.compat import irange, int_types, unicode_or_bytes_types, PY3, error_from from passlib.utils.decor import memoized_property # local __all__ = [ @@ -210,7 +210,10 @@ def _get_hash_const(name): return None -def lookup_hash(digest, return_unknown=False): + +# XXX: rename "return_unknown" to "required"? +def lookup_hash(digest, # *, + return_unknown=False, required=True): """ Returns a :class:`HashInfo` record containing information about a given hash function. Can be used to look up a hash constructor by name, normalize hash name representation, etc. @@ -225,10 +228,20 @@ def lookup_hash(digest, return_unknown=False): Case is ignored, underscores are converted to hyphens, and various other cleanups are made. + :param required: + By default (True), this function will throw an :exc:`~passlib.exc.UnknownHashError` if no hash constructor + can be found, or if the hash is not actually available. + + If this flag is False, it will instead return a dummy :class:`!HashInfo` record + which will defer throwing the error until it's constructor function is called. + This is mainly used by :func:`norm_hash_name`. + :param return_unknown: - By default, this function will throw an :exc:`~passlib.exc.UnknownHashError` if no hash constructor - can be found. However, if this flag is False, it will instead return a dummy record - without a constructor function. This is mainly used by :func:`norm_hash_name`. + + .. deprecated:: 1.7.3 + + deprecated, and will be removed in passlib 2.0. + this acts like inverse of **required**. :returns HashInfo: :class:`HashInfo` instance containing information about specified digest. @@ -244,6 +257,10 @@ def lookup_hash(digest, return_unknown=False): # NOTE: TypeError is to catch 'TypeError: unhashable type' (e.g. HashInfo) pass + # legacy alias + if return_unknown: + required = False + # resolve ``digest`` to ``const`` & ``name_record`` cache_by_name = True if isinstance(digest, unicode_or_bytes_types): @@ -255,22 +272,19 @@ def lookup_hash(digest, return_unknown=False): # if name wasn't normalized to hashlib format, # get info for normalized name and reuse it. if name != digest: - info = lookup_hash(name, return_unknown=return_unknown) - if info.const is None: - # pass through dummy record - assert return_unknown - return info + info = lookup_hash(name, required=required) cache[digest] = info return info # else look up constructor + # NOTE: may return None, which is handled by HashInfo constructor const = _get_hash_const(name) - if const is None: - if return_unknown: - # return a dummy record (but don't cache it, so normal lookup still returns error) - return HashInfo(None, name_list) - else: - raise exc.UnknownHashError(name) + + # if mock fips mode is enabled, replace with dummy constructor + # (to replicate how it would behave on a real fips system). + if const and mock_fips_mode and name not in _fips_algorithms: + def const(source=b""): + raise ValueError("%r disabled for fips by passlib set_mock_fips_mode()" % name) elif isinstance(digest, HashInfo): # handle border case where HashInfo is passed in. @@ -303,10 +317,11 @@ def lookup_hash(digest, return_unknown=False): raise exc.ExpectedTypeError(digest, "digest name or constructor", "digest") # create new instance - info = HashInfo(const, name_list) + info = HashInfo(const=const, names=name_list, required=required) # populate cache - cache[const] = info + if const is not None: + cache[const] = info if cache_by_name: for name in name_list: if name: # (skips iana name if it's empty) @@ -342,9 +357,9 @@ def norm_hash_name(name, format="hashlib"): :returns: Hash name, returned as native :class:`!str`. """ - info = lookup_hash(name, return_unknown=True) - if not info.const: - warn("norm_hash_name(): unknown hash: %r" % (name,), exc.PasslibRuntimeWarning) + info = lookup_hash(name, required=False) + if info.error_text: + warn("norm_hash_name(): " + info.error_text, exc.PasslibRuntimeWarning) if format == "hashlib": return info.name elif format == "iana": @@ -365,6 +380,7 @@ class HashInfo(SequenceMixin): .. autoattribute:: name .. autoattribute:: iana_name .. autoattribute:: aliases + .. autoattribute:: supported This object can also be treated a 3-element sequence containing ``(const, digest_size, block_size)``. @@ -391,7 +407,16 @@ class HashInfo(SequenceMixin): #: Hash's block size block_size = None - def __init__(self, const, names): + #: set when hash isn't available, will be filled in with string containing error text + #: that const() will raise. + error_text = None + + #========================================================================= + # init + #========================================================================= + + def __init__(self, # *, + const, names, required=True): """ initialize new instance. :arg const: @@ -400,15 +425,46 @@ class HashInfo(SequenceMixin): list of 2+ names. should be list of ``(name, iana_name, ... 0+ aliases)``. names must be lower-case. only iana name may be None. """ - self.name = names[0] + # init names + name = self.name = names[0] self.iana_name = names[1] self.aliases = names[2:] - self.const = const + def set_unknown_const(msg): + def const(source=b""): + raise exc.UnknownHashError(name, message=msg) + if required: + const() + self.error_text = msg + self.const = const + + # handle "constructor not available" case if const is None: + if names in _known_hash_names: + msg = "unsupported hash: %r" % name + else: + msg = "unknown hash: %r" % name + set_unknown_const(msg) + # TODO: load in preset digest size info for known hashes. return - hash = const() + # create hash instance to inspect + try: + hash = const() + except ValueError as err: + # per issue 116, FIPS compliant systems will have a constructor; + # but it will throw a ValueError with this message. As of 1.7.3, + # translating this into DisabledHashError. + # "ValueError: error:060800A3:digital envelope routines:EVP_DigestInit_ex:disabled for fips" + if "disabled for fips" in str(err).lower(): + msg = "%r hash disabled for fips" % name + else: + msg = "internal error in %r constructor\n(%s: %s)" % (name, type(err).__name__, err) + set_unknown_const(msg) + return + + # store stats about hash + self.const = const self.digest_size = hash.digest_size self.block_size = hash.block_size @@ -432,6 +488,14 @@ class HashInfo(SequenceMixin): return self.const, self.digest_size, self.block_size @memoized_property + def supported(self): + """ + whether hash is available for use + (if False, constructor will throw UnknownHashError if called) + """ + return self.error_text is None + + @memoized_property def supported_by_fastpbkdf2(self): """helper to detect if hash is supported by fastpbkdf2()""" if not _fast_pbkdf2_hmac: @@ -459,6 +523,50 @@ class HashInfo(SequenceMixin): # eoc #========================================================================= + +#--------------------------------------------------------------------- +# mock fips mode monkeypatch +#--------------------------------------------------------------------- + +#: flag for detecting if mock fips mode is enabled. +mock_fips_mode = False + + +#: algorithms allowed under FIPS mode (subset of hashlib.algorithms_available); +#: per https://csrc.nist.gov/Projects/Hash-Functions FIPS 202 list. +_fips_algorithms = { + # FIPS 180-4 and FIPS 202 + 'sha1', + 'sha224', + 'sha256', + 'sha384', + 'sha512', + # 'sha512/224', + # 'sha512/256', + + # FIPS 202 only + 'sha3_224', + 'sha3_256', + 'sha3_384', + 'sha3_512', + 'shake_128', + 'shake_256', +} + + +def _set_mock_fips_mode(enable=True): + """ + UT helper which monkeypatches lookup_hash() internals to replicate FIPS mode. + """ + global mock_fips_mode + mock_fips_mode = enable + lookup_hash.clear_cache() + + +# helper for UTs +if as_bool(os.environ.get("PASSLIB_MOCK_FIPS_MODE")): + _set_mock_fips_mode() + #============================================================================= # hmac utils #============================================================================= diff --git a/passlib/exc.py b/passlib/exc.py index c4b78b4..335fe91 100644 --- a/passlib/exc.py +++ b/passlib/exc.py @@ -155,14 +155,28 @@ class UsedTokenError(TokenError): class UnknownHashError(ValueError): - """Error raised by :class:`~passlib.crypto.lookup_hash` if hash name is not recognized. + """ + Error raised by :class:`~passlib.crypto.lookup_hash` if hash name is not recognized. This exception derives from :exc:`!ValueError`. + As of version 1.7.3, this may also be raised if hash algorithm is known, + but has been disabled due to FIPS mode (message will include phrase "disabled for fips"). + .. versionadded:: 1.7 + + .. versionchanged: 1.7.3 + added 'message' argument. """ - def __init__(self, name): + def __init__(self, name, message=None): self.name = name - ValueError.__init__(self, "unknown hash algorithm: %r" % name) + if message is None: + message = "unknown hash algorithm: %r" % name + self.message = message + ValueError.__init__(self, name, message) + + def __str__(self): + return self.message + #============================================================================= # warnings diff --git a/passlib/handlers/digests.py b/passlib/handlers/digests.py index 3761051..5e0da48 100644 --- a/passlib/handlers/digests.py +++ b/passlib/handlers/digests.py @@ -34,6 +34,9 @@ class HexDigestHash(uh.StaticHandler): checksum_size = None # filled in by create_hex_hash() checksum_chars = uh.HEX_CHARS + #: special for detecting if _hash_func is just a stub method. + supported = True + #=================================================================== # methods #=================================================================== @@ -50,11 +53,21 @@ class HexDigestHash(uh.StaticHandler): # eoc #=================================================================== -def create_hex_hash(digest, module=__name__): - # NOTE: could set digest_name=hash.name for cpython, but not for some other platforms. - info = lookup_hash(digest) +def create_hex_hash(digest, module=__name__, django_name=None, required=False): + """ + create hex-encoded unsalted hasher for specified digest algorithm. + + .. versionchanged:: 1.7.3 + If called with unknown/supported digest, won't throw error immediately, + but instead return a dummy hasher that will throw error when called. + + set ``required=True`` to restore old behavior. + """ + info = lookup_hash(digest, required=required) name = "hex_" + info.name - return type(name, (HexDigestHash,), dict( + if not info.supported: + info.digest_size = 0 + hasher = type(name, (HexDigestHash,), dict( name=name, __module__=module, # so ABCMeta won't clobber it _hash_func=staticmethod(info.const), # sometimes it's a function, sometimes not. so wrap it. @@ -64,13 +77,18 @@ def create_hex_hash(digest, module=__name__): It supports no optional or contextual keywords. """ % (info.name,) )) + if not info.supported: + hasher.supported = False + if django_name: + hasher.django_name = django_name + return hasher #============================================================================= # predefined handlers #============================================================================= + hex_md4 = create_hex_hash("md4") -hex_md5 = create_hex_hash("md5") -hex_md5.django_name = "unsalted_md5" +hex_md5 = create_hex_hash("md5", django_name="unsalted_md5") hex_sha1 = create_hex_hash("sha1") hex_sha256 = create_hex_hash("sha256") hex_sha512 = create_hex_hash("sha512") diff --git a/passlib/tests/test_crypto_digest.py b/passlib/tests/test_crypto_digest.py index 5070a40..f948e39 100644 --- a/passlib/tests/test_crypto_digest.py +++ b/passlib/tests/test_crypto_digest.py @@ -10,6 +10,7 @@ import warnings # site # pkg # module +from passlib.exc import UnknownHashError from passlib.utils.compat import PY3, u, JYTHON from passlib.tests.utils import TestCase, TEST_MODE, skipUnless, hb @@ -52,6 +53,7 @@ class HashInfoTest(TestCase): ctx.__enter__() self.addCleanup(ctx.__exit__) warnings.filterwarnings("ignore", '.*unknown hash') + warnings.filterwarnings("ignore", '.*unsupported hash') # test string types self.assertEqual(norm_hash_name(u("MD4")), "md4") @@ -111,12 +113,48 @@ class HashInfoTest(TestCase): self.assertEqual(hexlify(const(b"abc").digest()), b"a448017aaf21d8525fc10ae87aa6729d") - # 4. unknown names should be rejected - self.assertRaises(ValueError, lookup_hash, "xxx256") - # should memoize records self.assertIs(lookup_hash("md5"), lookup_hash("md5")) + def test_lookup_hash_w_unknown_name(self): + """lookup_hash() -- unknown hash name""" + from passlib.crypto.digest import lookup_hash + + # unknown names should be rejected by default + self.assertRaises(UnknownHashError, lookup_hash, "xxx256") + + # required=False should return stub record instead + info = lookup_hash("xxx256", required=False) + self.assertFalse(info.supported) + self.assertRaisesRegex(UnknownHashError, "unknown hash: 'xxx256'", info.const) + self.assertEqual(info.name, "xxx256") + self.assertEqual(info.digest_size, None) + + # should cache stub records + info2 = lookup_hash("xxx256", required=False) + self.assertIs(info2, info) + + def test_mock_fips_mode(self): + """ + lookup_hash() -- test set_mock_fips_mode() + """ + from passlib.crypto.digest import lookup_hash, _set_mock_fips_mode + + # check if md5 is available so we can test mock helper + if not lookup_hash("md5", required=False).supported: + raise self.skipTest("md5 not supported") + + # enable monkeypatch to mock up fips mode + _set_mock_fips_mode() + self.addCleanup(_set_mock_fips_mode, False) + + pat = "'md5' hash disabled for fips" + self.assertRaisesRegex(UnknownHashError, pat, lookup_hash, "md5") + + info = lookup_hash("md5", required=False) + self.assertRegex(info.error_text, pat) + self.assertRaisesRegex(UnknownHashError, pat, info.const) + def test_lookup_hash_metadata(self): """lookup_hash() -- metadata""" diff --git a/passlib/tests/test_handlers.py b/passlib/tests/test_handlers.py index 20a1c54..0f12506 100644 --- a/passlib/tests/test_handlers.py +++ b/passlib/tests/test_handlers.py @@ -383,6 +383,40 @@ class hex_md5_test(HandlerCase): (UPASS_TABLE, '05473f8a19f66815e737b33264a0d0b0'), ] + # XXX: should test this for ALL the create_hex_md5() hashers. + def test_mock_fips_mode(self): + """ + if md5 isn't available, a dummy instance should be created. + (helps on FIPS systems). + """ + from passlib.exc import UnknownHashError + from passlib.crypto.digest import lookup_hash, _set_mock_fips_mode + + # check if md5 is available so we can test mock helper + supported = lookup_hash("md5", required=False).supported + self.assertEqual(self.handler.supported, supported) + if supported: + _set_mock_fips_mode() + self.addCleanup(_set_mock_fips_mode, False) + + # HACK: have to recreate hasher, since underlying digest is new. + # could reload module and re-import, but this should be good enough. + from passlib.handlers.digests import create_hex_hash + hasher = create_hex_hash("md5") + self.assertFalse(hasher.supported) + + # can identify hashes even if disabled + ref1 = '5f4dcc3b5aa765d61d8327deb882cf99' + ref2 = 'xxx' + self.assertTrue(hasher.identify(ref1)) + self.assertFalse(hasher.identify(ref2)) + + # throw error if try to use it + pat = "'md5' hash disabled for fips" + self.assertRaisesRegex(UnknownHashError, pat, hasher.hash, "password") + self.assertRaisesRegex(UnknownHashError, pat, hasher.verify, "password", ref1) + + class hex_sha1_test(HandlerCase): handler = hash.hex_sha1 known_correct_hashes = [ diff --git a/passlib/utils/compat/__init__.py b/passlib/utils/compat/__init__.py index fe3b906..1d6cf4d 100644 --- a/passlib/utils/compat/__init__.py +++ b/passlib/utils/compat/__init__.py @@ -288,15 +288,20 @@ def get_unbound_method_function(func): """given unbound method, return underlying function""" return func if PY3 else func.__func__ -def suppress_cause(exc): +def error_from(exc, # *, + cause=None): """ backward compat hack to suppress exception cause in python3.3+ one python < 3.3 support is dropped, can replace all uses with "raise exc from None" """ - exc.__cause__ = None + exc.__cause__ = cause + exc.__suppress_context__ = True return exc +# legacy alias +suppress_cause = error_from + #============================================================================= # input/output #============================================================================= |