summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorEli Collins <elic@assurancetechnologies.com>2020-05-02 14:14:25 -0400
committerEli Collins <elic@assurancetechnologies.com>2020-05-02 14:14:25 -0400
commit18aa5a99271908054b8fc65d79c91c8404d486af (patch)
tree9750869367a4290a26857c7500f2ca271deda112
parent61f4f3ba5d520dc5b35415be7c9dae49911429db (diff)
downloadpasslib-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.rst19
-rw-r--r--passlib/crypto/digest.py160
-rw-r--r--passlib/exc.py20
-rw-r--r--passlib/handlers/digests.py30
-rw-r--r--passlib/tests/test_crypto_digest.py44
-rw-r--r--passlib/tests/test_handlers.py34
-rw-r--r--passlib/utils/compat/__init__.py9
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
#=============================================================================