From 6a4f8a245d19f0e2c533dfb752ada69355ba2c76 Mon Sep 17 00:00:00 2001 From: Doctor Date: Thu, 12 Sep 2024 20:56:25 +0300 Subject: [PATCH] refactor: remove dependency on built-in crypt, use legacycrypt instead --- passlib/utils/__init__.py | 136 +++++++++++++------------------------- pdm.lock | 21 ++++-- pyproject.toml | 4 +- tests/utils.py | 6 +- 4 files changed, 67 insertions(+), 100 deletions(-) diff --git a/passlib/utils/__init__.py b/passlib/utils/__init__.py index ed18be8f..b7c2fbc0 100644 --- a/passlib/utils/__init__.py +++ b/passlib/utils/__init__.py @@ -43,7 +43,6 @@ add_doc, unicode_or_bytes, get_method_function, - PYPY, ) from passlib.utils.decor import ( # [remove these aliases in 2.0] @@ -752,103 +751,60 @@ def is_safe_crypt_input(value): return False -try: - from crypt import crypt as _crypt -except ImportError: # pragma: no cover - _crypt = None - has_crypt = False - crypt_accepts_bytes = False - crypt_needs_lock = False - _safe_crypt_lock = None +_NULL = "\x00" +crypt_accepts_bytes = False +# some crypt() variants will return various constant strings when +# an invalid/unrecognized config string is passed in; instead of +# returning NULL / None. examples include ":", ":0", "*0", etc. +# safe_crypt() returns None for any string starting with one of the +# chars in this string... +_invalid_prefixes = "*:!" +_safe_crypt_lock = threading.Lock() +try: + import legacycrypt +except ImportError: + # ImportError: libcrypt / libxcrypt missing def safe_crypt(secret, hash): return None + + has_crypt = False else: has_crypt = True - _NULL = "\x00" - - # XXX: replace this with lazy-evaluated bug detection? - if threading and PYPY and (7, 2, 0) <= sys.pypy_version_info <= (7, 3, 3): - #: internal lock used to wrap crypt() calls. - #: WARNING: if non-passlib code invokes crypt(), this lock won't be enough! - _safe_crypt_lock = threading.Lock() - - #: detect if crypt.crypt() needs a thread lock around calls. - crypt_needs_lock = True - else: - from passlib.utils.compat import nullcontext - - _safe_crypt_lock = nullcontext() - crypt_needs_lock = False - - # some crypt() variants will return various constant strings when - # an invalid/unrecognized config string is passed in; instead of - # returning NULL / None. examples include ":", ":0", "*0", etc. - # safe_crypt() returns None for any string starting with one of the - # chars in this string... - _invalid_prefixes = "*:!" - - if True: # legacy block from PY3 compat - # * pypy3 (as of v7.3.1) has a crypt which accepts bytes, or ASCII-only unicode. - # * whereas CPython3 (as of v3.9) has a crypt which doesn't take bytes, - # but accepts ANY unicode (which it always encodes to UTF8). - crypt_accepts_bytes = True - try: - _crypt(b"\xee", "xx") - except TypeError: - # CPython will throw TypeError - crypt_accepts_bytes = False - except Exception: # no pragma - # don't care about other errors this might throw, - # just want to see if we get past initial type-coercion step. - pass - - def safe_crypt(secret, hash): - if crypt_accepts_bytes: - # PyPy3 -- all bytes accepted, but unicode encoded to ASCII, - # so handling that ourselves. - if isinstance(secret, str): - secret = secret.encode("utf-8") - if _BNULL in secret: - raise ValueError("null character in secret") - if isinstance(hash, str): - hash = hash.encode("ascii") - else: - # CPython3's crypt() doesn't take bytes, only unicode; unicode which is then - # encoding using utf-8 before passing to the C-level crypt(). - # so we have to decode the secret. - if isinstance(secret, bytes): - orig = secret - try: - secret = secret.decode("utf-8") - except UnicodeDecodeError: - return None - # sanity check it encodes back to original byte string, - # otherwise when crypt() does it's encoding, it'll hash the wrong bytes! - assert ( - secret.encode("utf-8") == orig - ), "utf-8 spec says this can't happen!" - if _NULL in secret: - raise ValueError("null character in secret") - if isinstance(hash, bytes): - hash = hash.decode("ascii") + def safe_crypt(secret, hash): + # CPython3's crypt() doesn't take bytes, only unicode; unicode which is then + # encoding using utf-8 before passing to the C-level crypt(). + # so we have to decode the secret. + if isinstance(secret, bytes): + orig = secret try: - with _safe_crypt_lock: - result = _crypt(secret, hash) - except OSError: - # new in py39 -- per https://bugs.python.org/issue39289, - # crypt() now throws OSError for various things, mainly unknown hash formats - # translating that to None for now (may revise safe_crypt behavior in future) - return None - # NOTE: per issue 113, crypt() may return bytes in some odd cases. - # assuming it should still return an ASCII hash though, - # or there's a bigger issue at hand. - if isinstance(result, bytes): - result = result.decode("ascii") - if not result or result[0] in _invalid_prefixes: + secret = secret.decode("utf-8") + except UnicodeDecodeError: return None - return result + # sanity check it encodes back to original byte string, + # otherwise when crypt() does it's encoding, it'll hash the wrong bytes! + assert secret.encode("utf-8") == orig, "utf-8 spec says this can't happen!" + if _NULL in secret: + raise ValueError("null character in secret") + if isinstance(hash, bytes): + hash = hash.decode("ascii") + try: + with _safe_crypt_lock: + result = legacycrypt.crypt(secret, hash) + except OSError: + # new in py39 -- per https://bugs.python.org/issue39289, + # crypt() now throws OSError for various things, mainly unknown hash formats + # translating that to None for now (may revise safe_crypt behavior in future) + return None + # NOTE: per issue 113, crypt() may return bytes in some odd cases. + # assuming it should still return an ASCII hash though, + # or there's a bigger issue at hand. + if isinstance(result, bytes): + result = result.decode("ascii") + if not result or result[0] in _invalid_prefixes: + return None + return result add_doc( diff --git a/pdm.lock b/pdm.lock index b39b680c..41247a7a 100644 --- a/pdm.lock +++ b/pdm.lock @@ -5,7 +5,7 @@ groups = ["default", "argon2", "bcrypt", "django", "linters", "sphinx-docs", "testing", "totp", "types"] strategy = ["inherit_metadata"] lock_version = "4.5.0" -content_hash = "sha256:74f3d8a63def596420688b95f72b7e5548afcb4e6b075b3977d31252870185cb" +content_hash = "sha256:01b3fd07e6c0884bb7227f587ea4aec733ca3255f581967f0c48558e4c51af87" [[metadata.targets]] requires_python = ">=3.9" @@ -550,18 +550,18 @@ files = [ [[package]] name = "importlib-metadata" -version = "8.5.0" +version = "8.4.0" requires_python = ">=3.8" summary = "Read metadata from Python packages" groups = ["sphinx-docs", "testing"] marker = "python_version < \"3.10\"" dependencies = [ "typing-extensions>=3.6.4; python_version < \"3.8\"", - "zipp>=3.20", + "zipp>=0.5", ] files = [ - {file = "importlib_metadata-8.5.0-py3-none-any.whl", hash = "sha256:45e54197d28b7a7f1559e60b95e7c567032b602131fbd588f1497f47880aa68b"}, - {file = "importlib_metadata-8.5.0.tar.gz", hash = "sha256:71522656f0abace1d072b9e5481a48f07c138e00f079c38c8f883823f9c26bd7"}, + {file = "importlib_metadata-8.4.0-py3-none-any.whl", hash = "sha256:66f342cc6ac9818fc6ff340576acd24d65ba0b3efabb2b4ac08b598965a4a2f1"}, + {file = "importlib_metadata-8.4.0.tar.gz", hash = "sha256:9a547d3bc3608b025f93d403fdd1aae741c24fbb8314df4b155675742ce303c5"}, ] [[package]] @@ -589,6 +589,17 @@ files = [ {file = "jinja2-3.1.4.tar.gz", hash = "sha256:4a3aee7acbbe7303aede8e9648d13b8bf88a429282aa6122a993f0ac800cb369"}, ] +[[package]] +name = "legacycrypt" +version = "0.3" +requires_python = ">=3.5" +summary = "Wrapper to the POSIX crypt library call and associated functionality." +groups = ["default"] +files = [ + {file = "legacycrypt-0.3-py3-none-any.whl", hash = "sha256:b5e373506ccb442f8d715e29fa75f53a11bbec3ca0d7b63445f4dbb656555218"}, + {file = "legacycrypt-0.3.tar.gz", hash = "sha256:e76e7fd25666a451428b20d5afbbecf3654565b2e11511b53226be955c4d2292"}, +] + [[package]] name = "markupsafe" version = "2.1.5" diff --git a/pyproject.toml b/pyproject.toml index 3b3fe549..e3e2d87b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,7 +6,9 @@ authors = [ { name = "Doctor", email = "thirvondukr@gmail.com" }, { name = "Eli Collins", email = "elic@assurancetechnologies.com" }, ] -dependencies = [] +dependencies = [ + "legacycrypt>=0.3", +] requires-python = ">=3.9" readme = "README.md" license = { text = "BSD" } diff --git a/tests/utils.py b/tests/utils.py index 6a95831c..778d5b40 100644 --- a/tests/utils.py +++ b/tests/utils.py @@ -3520,9 +3520,7 @@ def crypt_stub(secret, hash): assert isinstance(hash, str) return hash - import passlib.utils as mod - - self.patchAttr(mod, "_crypt", crypt_stub) + # self.patchAttr(mod, "_crypt", crypt_stub) self.using_patched_crypt = True @classmethod @@ -3679,7 +3677,7 @@ def fuzz_verifier_crypt(self): return None # create a wrapper for fuzzy verified to use - from crypt import crypt + from legacycrypt import crypt from passlib.utils import _safe_crypt_lock encoding = self.FuzzHashGenerator.password_encoding