diff options
author | Eli Collins <elic@assurancetechnologies.com> | 2020-10-06 12:14:04 -0400 |
---|---|---|
committer | Eli Collins <elic@assurancetechnologies.com> | 2020-10-06 12:14:04 -0400 |
commit | 0a25f664189515086d12aabacc0275b6b2ba209a (patch) | |
tree | 5e67f582d500d3f6fb7e57d8f6a58e78d3d99155 | |
parent | 15f0486c44428351ba45e57e541e3e5c04019e01 (diff) | |
parent | bf7afc81eff400c835fc43505ff6e2f91bf73fa9 (diff) | |
download | passlib-0a25f664189515086d12aabacc0275b6b2ba209a.tar.gz |
Merge from stable
50 files changed, 1954 insertions, 418 deletions
@@ -17,7 +17,7 @@ Passlib is (c) `Assurance Technologies <http://www.assurancetechnologies.com>`_, and is released under the `BSD license <http://www.opensource.org/licenses/bsd-license.php>`_:: Passlib - Copyright (c) 2008-2019 Assurance Technologies, LLC. + Copyright (c) 2008-2020 Assurance Technologies, LLC. All rights reserved. Redistribution and use in source and binary forms, with or without @@ -49,14 +49,13 @@ This example barely touches on the range of features available. Online Resources ================ -* Homepage - https://bitbucket.org/ecollins/passlib -* Documentation - https://passlib.readthedocs.io +* Latest Docs - https://passlib.readthedocs.io +* Latest News - https://foss.heptapod.net/python-libs/passlib/wikis/home * Mailing list - https://groups.google.com/group/passlib-users * Downloads - https://pypi.python.org/pypi/passlib -* Source - https://bitbucket.org/ecollins/passlib/src -* Issues - https://bitbucket.org/ecollins/passlib/issues -* Roadmap - https://bitbucket.org/ecollins/passlib/wiki/Roadmap +* Source - https://foss.heptapod.net/python-libs/passlib +* Issues - https://foss.heptapod.net/python-libs/passlib/issues Source ========= @@ -64,3 +63,10 @@ Passlib's source repository uses Mercurial. When building Passlib from an hg cl * ``default`` is the bleeding edge of the next major release. It may sometimes be of alpha quality. * ``stable`` is the latest released version plus any pending bugfixes, and should be safe to use in production. + +Hosting +======= +Thanks to the people at `Octobus <https://octobus.net/>`_ and `CleverCloud <https://clever-cloud.com/>`_ +for providing the repository / issue tracker hosting, as well as the development of `Heptapod <https://heptapod.net/>`_! + +Thanks to `ReadTheDocs <https://readthedocs.io>`_ for providing documentation hosting! diff --git a/docs/conf.py b/docs/conf.py index 7b61d34..894bd09 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -41,9 +41,9 @@ import datetime for _tag in os.environ.get("SPHINX_BUILD_TAGS", "").split(): tags.add(_tag) -# building the docs requires the Cloud Sphinx theme & extensions (>= v1.4), +# building the docs requires the Cloud Sphinx theme & extensions (>= v1.10.1), # which contains some sphinx extensions used by Passlib. -# (https://bitbucket.org/ecollins/cloud_sptheme) +# (https://foss.heptapod.net/doc-utils/cloud_sptheme) import cloud_sptheme as csp #============================================================================= @@ -172,7 +172,7 @@ intersphinx_mapping = { #============================================================================= todo_include_todos = True keep_warnings = True -issue_tracker_url = "bb:ecollins/passlib" +issue_tracker_url = "https://foss.heptapod.net/python-libs/passlib/issues/{issue}" #============================================================================= # Options for HTML output diff --git a/docs/dev-requirements.txt b/docs/dev-requirements.txt index f49e8f6..0906d2b 100644 --- a/docs/dev-requirements.txt +++ b/docs/dev-requirements.txt @@ -1 +1 @@ -hg+https://bitbucket.org/ecollins/cloud_sptheme +hg+https://foss.heptapod.net/doc-utils/cloud_sptheme diff --git a/docs/history/1.7.rst b/docs/history/1.7.rst index 5a5cf12..64fe51a 100644 --- a/docs/history/1.7.rst +++ b/docs/history/1.7.rst @@ -2,6 +2,110 @@ Passlib 1.7 =========== +.. rst-class:: without-title + +.. warning:: + + **Passlib 1.8 will drop support for Python 2.x, 3.3, and 3.4**; + and will require Python >= 3.5. The 1.7 series will be the last + to support Python 2.7. (See :issue:`119` for rationale). + +**1.7.3** (2020-10-06) +====================== + +This release rolls up assorted bug & compatibility fixes since 1.7.2. + +Administrative Changes +---------------------- + +.. rst-class:: without-title + +.. note:: + + **Passlib has moved to Heptapod!** + + Due to BitBucket deprecating Mercurial support, Passlib's public repository and issue tracker + has been relocated. It's now located at `<https://foss.heptapod.net/python-libs/passlib>`_, + and is powered by `Heptapod <https://heptapod.net/>`_. + + Hosting for this and other open-source projects graciously provided by the people at + `Octobus <https://octobus.net/>`_ and `CleverCloud <https://clever-cloud.com/>`_! + + The mailing list and documentation urls remain the same. + +New Features +------------ + +* .. py:currentmodule:: passlib.hash + + :class:`ldap_salted_sha512`: LDAP "salted hash" support added for SHA-256 and SHA-512 (:issue:`124`). + +Bugfixes +-------- + +* .. py:currentmodule:: passlib.hash + + :class:`bcrypt`: Under python 3, OS native backend wasn't being detected on BSD platforms. + This was due to a few internal issues in feature-detection code, which have been fixed. + +* :func:`passlib.utils.safe_crypt`: Support :func:`crypt.crypt` unexpectedly + returning bytes under Python 3 (:issue:`113`). + +* :func:`passlib.utils.safe_crypt`: Support :func:`crypt.crypt` throwing :exc:`OSError`, + which can happen as of Python 3.9 (:issue:`115`). + +* :mod:`passlib.ext.django`: fixed lru_cache import (django 3 compatibility) + +* :mod:`!passlib.tests`: fixed bug where :meth:`HandlerCase.test_82_crypt_support` wasn't + being run on systems lacking support for the hasher being tested. + This test now runs regardless of system support. + +Deprecations +------------ + +* Support for Python 2.x, 3.3, and 3.4 is deprecated; and will be dropped in Passlib 1.8. + +Other Changes +------------- + +* .. py:currentmodule:: passlib.hash + + :class:`bcrypt_sha256`: Internal algorithm has been changed to use HMAC-SHA256 instead of + plain SHA256. This should strengthen the hash against brute-force attempts which bypass + the intermediary hash by using known-sha256-digest lookup tables (:issue:`114`). + +* .. py:currentmodule:: passlib.hash + + :class:`bcrypt`: OS native backend ("os_crypt") now raises the new :exc:`~passlib.exc.PasswordValueError` + if password is provided as non-UTF8 bytes under python 3 + (These can't be passed through, due to limitation in stdlib's :func:`!crypt.crypt`). + Prior to this release, it confusingly raised :exc:`~passlib.exc.MissingBackendError` instead. + + Also improved legacy bcrypt format workarounds, to support a few more UTF8 edge cases than before. + +* 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). + +* Internal errors calling stdlib's :func:`crypt.crypt`, or third party libraries, + will now raise the new :exc:`~passlib.exc.InternalBackendError` (a RuntimeError); + where previously it would raise an :exc:`AssertionError`. + +* Various Python 3.9 compatibility fixes (including ``NotImplemented``-related warning, :issue:`125`) + + **1.7.2** (2019-11-22) ====================== @@ -54,10 +158,11 @@ Deprecations Due to lack of ``pip`` and ``venv`` support, Passlib is no longer fully tested on Python 2.6 & 3.3. There are no known issues, and bugfixes against these versions will still be accepted for the Passlib 1.7.x series. - However, **Passlib 1.8 will drop support for Python 2.6 & 3.3; and Passlib 2.0 will drop - support for Python 2.x entirely.** + However, **Passlib 1.8 will drop support for Python 2.x, 3.3, & 3.4,** and require Python >= 3.5. -* Support for Python 2.6 & 3.3 is deprecated; and will be dropped in Passlib 1.8. +* Support for Python 2.x, 3.3, and 3.4 is deprecated; and will be dropped in Passlib 1.8. + *(2020-10-06: Updated to include all of Python 2.x, 3.3, and 3.4; when 1.7.2 was released, + only Python 2.6 and 3.3 support was deprecated)* * .. py:currentmodule:: passlib.hash @@ -280,7 +385,7 @@ As part of a long-range plan to restructure and simplify both the API and the in a number of methods have been deprecated & replaced. The eventually goal is a large cleanup and overhaul as part of Passlib 2.0. There will be at least one more 1.x version before Passlib 2.0, to provide a final transitional release -(see the `Passlib Roadmap <https://bitbucket.org/ecollins/passlib/wiki/Roadmap>`_). +(see the `Project Roadmap <https://foss.heptapod.net/python-libs/passlib/wikis/roadmap>`_). Password Hash API Deprecations .............................. diff --git a/docs/history/index.rst b/docs/history/index.rst index 0637ca7..8439c5e 100644 --- a/docs/history/index.rst +++ b/docs/history/index.rst @@ -15,6 +15,14 @@ Release History 1.8 Series <1.8> +.. rst-class:: float-center without-title + +.. warning:: + + **Passlib 1.8 will drop support for Python 2.x, 3.3, and 3.4**; + and will require Python >= 3.5. The 1.7 series will be the last + to support Python 2.7. (See :issue:`119` for rationale). + .. toctree:: :maxdepth: 2 @@ -39,6 +47,6 @@ Release History .. seealso:: - See the `Project Roadmap <https://bitbucket.org/ecollins/passlib/wiki/Roadmap>`_ + See the `Project Roadmap <https://foss.heptapod.net/python-libs/passlib/wikis/roadmap>`_ for a list of future changes that may impact applications. diff --git a/docs/index.rst b/docs/index.rst index c3cad52..4452c63 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -19,6 +19,18 @@ Passlib |release| documentation For documentation of the latest stable version, see `<https://passlib.readthedocs.io>`_. +.. rst-class:: without-title + +.. note:: + + **2020-05-01: Passlib's public repository has moved to Heptapod!** + + Due to BitBucket deprecating Mercurial support, Passlib's public repository and issue tracker + has been relocated. It's now located at `<https://foss.heptapod.net/python-libs/passlib>`_, + and is powered by `Heptapod <https://heptapod.net/>`_. + Hosting is being graciously provided by the people at + `Octobus <https://octobus.net/>`_ and `CleverCloud <https://clever-cloud.com/>`_! + Welcome ======= Passlib is a password hashing library for Python 2 & 3, which provides @@ -52,6 +64,10 @@ Getting Started This documentation is organized into two main parts: a narrative walkthrough of Passlib, and a top-down API reference. +:doc:`install` + + See this page for system requirements & installation instructions. + :doc:`narr/index` New users in particular will want to visit the walkthrough, as it provides @@ -77,7 +93,16 @@ Online Resources =================== =================================================== Latest Docs: `<https://passlib.readthedocs.io>`_ - Project Home: `<https://bitbucket.org/ecollins/passlib>`_ - News & Discussion: `<https://groups.google.com/group/passlib-users>`_ + Latest News: `<https://foss.heptapod.net/python-libs/passlib/wikis/home>`_ + Public Repo: `<https://foss.heptapod.net/python-libs/passlib>`_ + Mailing List: `<https://groups.google.com/group/passlib-users>`_ Downloads @ PyPI: `<https://pypi.python.org/pypi/passlib>`_ =================== =================================================== + +Hosting +======= + +Thanks to the people at `Octobus <https://octobus.net/>`_ and `CleverCloud <https://clever-cloud.com/>`_ +for providing the repository / issue tracker hosting, as well as development of `Heptapod <https://heptapod.net/>`_! + +Thanks to `ReadTheDocs <https://readthedocs.io>`_ for providing documentation hosting! diff --git a/docs/install.rst b/docs/install.rst index 43d25fc..2ebe119 100644 --- a/docs/install.rst +++ b/docs/install.rst @@ -9,13 +9,13 @@ Supported Platforms Passlib requires Python 2 (>= 2.6) or Python 3 (>= 3.3). It is known to work with the following Python implementations: -.. rst-class:: float-right +.. rst-class:: float-right without-title .. warning:: - * Support for Python 2.6 and 3.3 will be dropped in Passlib 1.8 - - * Support for Python 2.x will be dropped in Passlib 2.0 + **Passlib 1.8 will drop support for Python 2.x, 3.3, and 3.4**; + and will require Python >= 3.5. The 1.7 series will be the + last to support Python 2. (See :issue:`119` for rationale). * CPython 2 -- v2.6 or newer. * CPython 3 -- v3.3 or newer. @@ -92,6 +92,14 @@ Optional Libraries Installation Instructions ========================= + +.. rst-class:: float-right without-title + +.. caution:: + + All PyPI releases are signed with the gpg key + `4D8592DF4CE1ED31 <http://pgp.mit.edu:11371/pks/lookup?op=get&search=0x4D8592DF4CE1ED31>`_. + To install from PyPi using :command:`pip`:: pip install passlib diff --git a/docs/lib/passlib.exc.rst b/docs/lib/passlib.exc.rst index 10647a3..90565c8 100644 --- a/docs/lib/passlib.exc.rst +++ b/docs/lib/passlib.exc.rst @@ -12,9 +12,13 @@ Exceptions ========== .. autoexception:: MissingBackendError +.. autoexception:: InternalBackendError + .. index:: pair: environmental variable; PASSLIB_MAX_PASSWORD_SIZE +.. autoexception:: PasswordValueError + .. autoexception:: PasswordSizeError .. autoexception:: PasswordTruncateError diff --git a/docs/lib/passlib.hash.argon2.rst b/docs/lib/passlib.hash.argon2.rst index fcc9508..4c1662b 100644 --- a/docs/lib/passlib.hash.argon2.rst +++ b/docs/lib/passlib.hash.argon2.rst @@ -56,14 +56,14 @@ Format & Algorithm ================== The Argon2 hash format is defined by the argon2 reference implementation. It's compatible with the :ref:`PHC Format <phc-format>` and :ref:`modular-crypt-format`, -and uses ``$argon2i$`` and ``$argon2d$`` -as it's identifying prefixes for all its strings. An example hash (of ``password``) is: +and uses ``$argon2i$``, ``$argon2d$``, or ``$argon2id$`` as the identifying prefixes +for all its strings. An example hash (of ``password``) is: ``$argon2i$v=19$m=512,t=3,p=2$c29tZXNhbHQ$SqlVijFGiPG+935vDSGEsA`` This string has the format :samp:`$argon2{X}$v={V}$m={M},t={T},p={P}${salt}${digest}`, where: -* :samp:`{X}` is either ``i`` or ``d``, depending on the argon2 variant +* :samp:`{X}` is either ``i``, ``d``, or ``id``; depending on the argon2 variant (``i`` in the example). * :samp:`{V}` is an integer representing the argon2 revision. @@ -114,9 +114,6 @@ before it. As of the release of Passlib 1.7, it has no known major security iss Deviations ========== -* While passlib supports verifying type "d" Argon2 hashes, it does not support generating - them. This is a deliberate choice, since type "d" is explicitly not designed for password hashing. - * This implementation currently encodes all unicode passwords using UTF-8 before hashing, other implementations may vary, or offer a configurable encoding; though UTF-8 is assumed to be the default. diff --git a/docs/lib/passlib.hash.bcrypt.rst b/docs/lib/passlib.hash.bcrypt.rst index a8c4625..436148c 100644 --- a/docs/lib/passlib.hash.bcrypt.rst +++ b/docs/lib/passlib.hash.bcrypt.rst @@ -104,6 +104,8 @@ Bcrypt hashes have the format :samp:`$2a${rounds}${salt}{checksum}`, where: * :samp:`{rounds}` is a cost parameter, encoded as 2 zero-padded decimal digits, which determines the number of iterations used via :samp:`{iterations}=2**{rounds}` (rounds is 12 in the example). * :samp:`{salt}` is a 22 character salt string, using the characters in the regexp range ``[./A-Za-z0-9]`` (``GhvMmNVjRW29ulnudl.Lbu`` in the example). + Note that due to padding bits within the encoding, the last character should always be one of ``[.Oeu]``: + under some bcrypt implementations, other final characters may result in false negatives when verifying. * :samp:`{checksum}` is a 31 character checksum, using the same characters as the salt (``AnUtN/LRfe1JsBm1Xu6LE3059z5Tr8m`` in the example). While BCrypt's basic algorithm is described in its design document [#f1]_, @@ -122,7 +124,7 @@ Security Issues and only the first 72 bytes of a password are hashed... all the rest are ignored. Furthermore, bytes 55-72 are not fully mixed into the resulting hash (citation needed!). To work around both these issues, many applications first run the password through a message - digest such as SHA2-256. Passlib offers the premade :doc:`passlib.hash.bcrypt_sha256` + digest such as (HMAC-) SHA2-256. Passlib offers the premade :doc:`passlib.hash.bcrypt_sha256` to take care of this issue. Deviations diff --git a/docs/lib/passlib.hash.bcrypt_sha256.rst b/docs/lib/passlib.hash.bcrypt_sha256.rst index 20ef5ab..a3035b1 100644 --- a/docs/lib/passlib.hash.bcrypt_sha256.rst +++ b/docs/lib/passlib.hash.bcrypt_sha256.rst @@ -10,7 +10,7 @@ BCrypt was developed to replace :class:`~passlib.hash.md5_crypt` for BSD systems It uses a modified version of the Blowfish stream cipher. It does, however, truncate passwords to 72 bytes, and some other minor quirks (see :ref:`BCrypt Password Truncation <bcrypt-password-truncation>` for details). -This class works around that issue by first running the password through SHA2-256. +This class works around that issue by first running the password through HMAC-SHA2-256. This class can be used directly as follows:: >>> from passlib.hash import bcrypt_sha256 @@ -18,11 +18,11 @@ This class can be used directly as follows:: >>> # generate new salt, hash password >>> h = bcrypt_sha256.hash("password") >>> h - '$bcrypt-sha256$2a,12$LrmaIX5x4TRtAwEfwJZa1.$2ehnw6LvuIUTM0iz4iz9hTxv21B6KFO' + '$bcrypt-sha256$v=2,t=2b,r=12$n79VH.0Q2TMWmt3Oqt9uku$Kq4Noyk3094Y2QlB8NdRT8SvGiI4ft2' >>> # the same, but with an explicit number of rounds >>> bcrypt_sha256.using(rounds=13).hash("password") - '$bcrypt-sha256$2b,13$Mant9jKTadXYyFh7xp1W5.$J8xpPZR/HxH7f1vRCNUjBI7Ev1al0hu' + '$bcrypt-sha256$v=2,t=2b,r=13$AmytCA45b12VeVg0YdDT3.$IZTbbJKgJlD5IJoCWhuDUqYjnJwNPlO' >>> # verify password >>> bcrypt_sha256.verify("password", h) @@ -46,25 +46,38 @@ Bcrypt-SHA256 is compatible with the :ref:`modular-crypt-format`, and uses ``$bc for all it's strings. An example hash (of ``password``) is: - ``$bcrypt-sha256$2a,12$LrmaIX5x4TRtAwEfwJZa1.$2ehnw6LvuIUTM0iz4iz9hTxv21B6KFO`` + ``$bcrypt-sha256$v=2,t=2b,r=12$n79VH.0Q2TMWmt3Oqt9uku$Kq4Noyk3094Y2QlB8NdRT8SvGiI4ft2`` -Bcrypt-SHA256 hashes have the format :samp:`$bcrypt-sha256${variant},{rounds}${salt}${checksum}`, where: +Version 1 of this format had the format :samp:`$bcrypt-sha256${type},{rounds}${salt}${digest}`. +Passlib 1.7.3 introduced version 2 of this format, which changed the algorithm slightly (see below), +and adjusted the format to indicate a version: :samp:`$bcrypt-sha256$v=2,t={type},r={rounds}${salt}${digest}`, where: -* :samp:`{variant}` is the BCrypt variant in use (usually, as in this case, ``2a``). +* :samp:`{type}` is the BCrypt variant in use (always ``2b`` under version 2; though ``2a`` was allowed under version 1). * :samp:`{rounds}` is a cost parameter, encoded as decimal integer, which determines the number of iterations used via :samp:`{iterations}=2**{rounds}` (rounds is 12 in the example). -* :samp:`{salt}` is a 22 character salt string, using the characters in the regexp range ``[./A-Za-z0-9]`` (``LrmaIX5x4TRtAwEfwJZa1.`` in the example). -* :samp:`{checksum}` is a 31 character checksum, using the same characters as the salt (``2ehnw6LvuIUTM0iz4iz9hTxv21B6KFO`` in the example). +* :samp:`{salt}` is a 22 character salt string, using the characters in the regexp range ``[./A-Za-z0-9]`` (``n79VH.0Q2TMWmt3Oqt9uku`` in the example). +* :samp:`{digest}` is a 31 character digest, using the same characters as the salt (``Kq4Noyk3094Y2QlB8NdRT8SvGiI4ft2`` in the example). Algorithm ========= The algorithm this hash uses is as follows: * first the password is encoded to ``UTF-8`` if not already encoded. -* then it's run through SHA2-256 to generate a 32 byte digest. -* this is encoded using base64, resulting in a 44-byte result - (including the trailing padding ``=``). For the example ``"password"``, - the output from this stage would be ``"XohImNooBHFR0OVvjcYpJ3NgPQ1qq73WKhHvch0VQtg="``. + +* the next step is to hash the password before handing it off to bcrypt: + + - Under version 2 of this algorithm (the default as of passlib 1.7.3), the password is run + through HMAC-SHA2-256, with the HMAC key set to the bcrypt salt (encoded as a 22 character ascii salt string). + + - Under the older version 1 of this algorithm, the password was instead run through plain SHA2-256. + + In either case, this generates a 32 byte digest. + +* this hash is then encoded using base64, resulting in a 44-byte result + (including the trailing padding ``=``). For the example ``"password"`` and the salt ``"n79VH.0Q2TMWmt3Oqt9uku"``, + the output from this stage would be ``b"7CwRr5rxo2JZcVmSDAi/2JPTkvkAdNy20Cz2LwYC0fw="`` (for version 2). + * this base64 string is then passed on to the underlying bcrypt algorithm as the new password to be hashed. See :doc:`passlib.hash.bcrypt` for details - on it's operation. + on it's operation. For the example in the prior line, the resulting + bcrypt digest component would be ``"Kq4Noyk3094Y2QlB8NdRT8SvGiI4ft2"``. diff --git a/docs/lib/passlib.hash.django_std.rst b/docs/lib/passlib.hash.django_std.rst index 5135f4f..e733b56 100644 --- a/docs/lib/passlib.hash.django_std.rst +++ b/docs/lib/passlib.hash.django_std.rst @@ -128,8 +128,6 @@ Interface .. versionadded:: 1.6 -.. autoclass:: django_bcrypt_sha256() - Format ------ An example :class:`!django_pbkdf2_sha256` hash (of ``password``) is: diff --git a/docs/lib/passlib.hash.ldap_std.rst b/docs/lib/passlib.hash.ldap_std.rst index d3788c1..1245749 100644 --- a/docs/lib/passlib.hash.ldap_std.rst +++ b/docs/lib/passlib.hash.ldap_std.rst @@ -62,6 +62,8 @@ Salted Hashes ============= .. autoclass:: ldap_salted_md5() .. autoclass:: ldap_salted_sha1() +.. autoclass:: ldap_salted_sha256() +.. autoclass:: ldap_salted_sha512() These hashes have the format :samp:`{prefix}{data}`. @@ -95,6 +97,9 @@ The LDAP salted hashes should not be considered very secure. which is only borderline sufficient to defeat rainbow tables, and cannot (portably) be increased. +* The SHA2 salted hashes (SSHA256, SSHA512) are only marginally better. + they use the newer SHA2 hash; and 64 bits of entropy in their salt. + Plaintext ========= .. autoclass:: ldap_plaintext() diff --git a/docs/lib/passlib.hash.rst b/docs/lib/passlib.hash.rst index 94580e8..0103a4f 100644 --- a/docs/lib/passlib.hash.rst +++ b/docs/lib/passlib.hash.rst @@ -38,7 +38,7 @@ Aside from "archaic" schemes such as :class:`!des_crypt`, most of the password hashes supported by modern Unix flavors adhere to the :ref:`modular crypt format <modular-crypt-format>`, allowing them to be easily distinguished when used within the same file. -The basic of format :samp:`${scheme}${hash}` has also been adopted for use +Variants of this format's basic :samp:`${scheme}${salt}${digest}` structure have also been adopted for use by other applications and password hash schemes. .. _standard-unix-hashes: @@ -114,8 +114,8 @@ of application-specific hash algorithms: Active Hashes ------------- -While most of these schemes generally require an application-specific -implementation, natively used by any Unix flavor to store user passwords, +While most of these schemes are generally application-specific, +and are not natively supported by any Unix OS, they can be used compatibly along side other modular crypt format hashes: .. toctree:: @@ -168,6 +168,8 @@ and are supported by OpenLDAP. * :class:`passlib.hash.ldap_sha1` - SHA1 digest * :class:`passlib.hash.ldap_salted_md5` - salted MD5 digest * :class:`passlib.hash.ldap_salted_sha1` - salted SHA1 digest +* :class:`passlib.hash.ldap_salted_sha256` - salted SHA256 digest +* :class:`passlib.hash.ldap_salted_sha512` - salted SHA512 digest .. toctree:: :maxdepth: 1 diff --git a/docs/lib/passlib.hash.sha256_crypt.rst b/docs/lib/passlib.hash.sha256_crypt.rst index 907ce7a..8d3e950 100644 --- a/docs/lib/passlib.hash.sha256_crypt.rst +++ b/docs/lib/passlib.hash.sha256_crypt.rst @@ -70,9 +70,9 @@ An sha256-crypt hash string has the format :samp:`$5$rounds={rounds}${salt}${che * :samp:`{checksum}` is 43 characters drawn from the same set, encoding a 256-bit checksum (``cKhJImk5mfuSKV9b3mumNzlbstFUplKtQXXMo4G6Ep5`` in the example). -There is also an alternate format :samp:`$5${salt}${checksum}`, -which can be used when the rounds parameter is equal to 5000 -(see the ``implicit_rounds`` parameter above). +The official implementation allows omitting the ``rounds`` section when it's set to 5000, +resulting in an alternate hash format: :samp:`$5${salt}${checksum}`. +(Passlib supports this via the ``implicit_rounds`` constructor parameter). The algorithm used by SHA256-Crypt is laid out in detail in the specification document linked to below [#f1]_. diff --git a/docs/requirements.txt b/docs/requirements.txt index 5afb30c..c2e29b9 100644 --- a/docs/requirements.txt +++ b/docs/requirements.txt @@ -1,2 +1,2 @@ sphinxcontrib-fulltoc -hg+https://bitbucket.org/ecollins/cloud_sptheme +cloud_sptheme diff --git a/passlib/apache.py b/passlib/apache.py index 1c191b9..7187cc3 100644 --- a/passlib/apache.py +++ b/passlib/apache.py @@ -80,7 +80,7 @@ class _CommonFile(object): :arg data: database to load, as single string. - :param \*\*kwds: + :param \\*\\*kwds: all other keywords are the same as in the class constructor """ if 'path' in kwds: @@ -97,7 +97,7 @@ class _CommonFile(object): :arg path: local filepath to load from - :param \*\*kwds: + :param \\*\\*kwds: all other keywords are the same as in the class constructor """ self = cls(**kwds) diff --git a/passlib/apps.py b/passlib/apps.py index 9217d40..38c3500 100644 --- a/passlib/apps.py +++ b/passlib/apps.py @@ -123,9 +123,17 @@ django_context = django110_context #============================================================================= # ldap #============================================================================= -std_ldap_schemes = ["ldap_salted_sha1", "ldap_salted_md5", - "ldap_sha1", "ldap_md5", - "ldap_plaintext" ] + +#: standard ldap schemes +std_ldap_schemes = [ + "ldap_salted_sha512", + "ldap_salted_sha256", + "ldap_salted_sha1", + "ldap_salted_md5", + "ldap_sha1", + "ldap_md5", + "ldap_plaintext", +] # create context with all std ldap schemes EXCEPT crypt ldap_nocrypt_context = LazyCryptContext(std_ldap_schemes) diff --git a/passlib/context.py b/passlib/context.py index dccc5c1..66cce79 100644 --- a/passlib/context.py +++ b/passlib/context.py @@ -962,8 +962,12 @@ class CryptContext(object): :type encoding: str :param encoding: - Encoding to use when decode bytes from string. - Defaults to ``"utf-8"``. Ignoring when loading from a dictionary. + Encoding to use when **source** is bytes. + Defaults to ``"utf-8"``. Ignored when loading from a dictionary. + + .. deprecated:: 1.8 + + This keyword, and support for bytes input, will be dropped in Passlib 2.0 :raises TypeError: * If the source cannot be identified. @@ -1640,7 +1644,7 @@ class CryptContext(object): be used when hashing the password (e.g. different default scheme, different default rounds values, etc). - :param \*\*kwds: + :param \\*\\*kwds: All other keyword options are passed to the selected algorithm's :meth:`PasswordHash.hash() <passlib.ifc.PasswordHash.hash>` method. @@ -1717,7 +1721,7 @@ class CryptContext(object): This is mainly used when generating new hashes, it has little effect when verifying; this keyword is mainly provided for symmetry. - :param \*\*kwds: + :param \\*\\*kwds: All additional keywords are passed to the appropriate handler, and should match its :attr:`~passlib.ifc.PasswordHash.context_kwds`. @@ -1797,7 +1801,7 @@ class CryptContext(object): If specified, this will cause any category-specific defaults to be used if the password has to be re-hashed. - :param \*\*kwds: + :param \\*\\*kwds: all additional keywords are passed to the appropriate handler, and should match that hash's :attr:`PasswordHash.context_kwds <passlib.ifc.PasswordHash.context_kwds>`. diff --git a/passlib/crypto/digest.py b/passlib/crypto/digest.py index d26f892..0a9b77a 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__ = [ @@ -68,9 +68,11 @@ MAX_UINT64 = (1 << 64) - 1 _known_hash_names = [ # format: (hashlib/ssl name, iana name or standin, other known aliases ...) + #---------------------------------------------------- # hashes with official IANA-assigned names # (as of 2012-03 - http://www.iana.org/assignments/hash-function-text-names) - ("md2", "md2"), + #---------------------------------------------------- + ("md2", "md2"), # NOTE: openssl dropped md2 support in v1.0.0 ("md5", "md5"), ("sha1", "sha-1"), ("sha224", "sha-224", "sha2-224"), @@ -80,14 +82,51 @@ _known_hash_names = [ # TODO: add sha3 to this table. + #---------------------------------------------------- # hashlib/ssl-supported hashes without official IANA names, # (hopefully-) compatible stand-ins have been chosen. + #---------------------------------------------------- + + ("blake2b", "blake-2b"), + ("blake2s", "blake-2s"), ("md4", "md4"), - ("sha", "sha-0", "sha0"), - ("ripemd", "ripemd"), - ("ripemd160", "ripemd-160"), + # NOTE: there was an older "ripemd" and "ripemd-128", + # but python 2.7+ resolves "ripemd" -> "ripemd160", + # so treating "ripemd" as alias here. + ("ripemd160", "ripemd-160", "ripemd"), ] + +#: dict mapping hashlib names to hardcoded digest info; +#: so this is available even when hashes aren't present. +_fallback_info = { + # name: (digest_size, block_size) + 'blake2b': (64, 128), + 'blake2s': (32, 64), + 'md4': (16, 64), + 'md5': (16, 64), + 'sha1': (20, 64), + 'sha224': (28, 64), + 'sha256': (32, 64), + 'sha384': (48, 128), + 'sha3_224': (28, 144), + 'sha3_256': (32, 136), + 'sha3_384': (48, 104), + 'sha3_512': (64, 72), + 'sha512': (64, 128), + 'shake128': (16, 168), + 'shake256': (32, 136), +} + + +def _gen_fallback_info(): + out = {} + for alg in sorted(hashlib.algorithms_available | {"md4"}): + info = lookup_hash(alg) + out[info.name] = (info.digest_size, info.block_size) + return out + + #: cache of hash info instances used by lookup_hash() _hash_info_cache = {} @@ -202,7 +241,9 @@ def _get_hash_const(name): return None -def lookup_hash(digest, return_unknown=False): + +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. @@ -217,10 +258,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. @@ -236,6 +287,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): @@ -247,22 +302,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. @@ -295,10 +347,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) @@ -334,9 +387,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.unknown: + warn("norm_hash_name(): " + info.error_text, exc.PasslibRuntimeWarning) if format == "hashlib": return info.name elif format == "iana": @@ -357,6 +410,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)``. @@ -383,7 +437,20 @@ 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 + + #: set when error_text is due to hash algorithm being completely unknown + #: (not just unavailable on current system) + unknown = False + + #========================================================================= + # init + #========================================================================= + + def __init__(self, # *, + const, names, required=True): """ initialize new instance. :arg const: @@ -392,15 +459,57 @@ 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 use_stub_const(msg): + """ + helper that installs stub constructor which throws specified error <msg>. + """ + def const(source=b""): + raise exc.UnknownHashError(name, message=msg) + if required: + # if caller only wants supported digests returned, + # just throw error immediately... + const() + assert "shouldn't get here" + self.error_text = msg + self.const = const + try: + self.digest_size, self.block_size = _fallback_info[name] + except KeyError: + pass + + # 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 + self.unknown = True + use_stub_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) + use_stub_const(msg) + return + + # store stats about hash + self.const = const self.digest_size = hash.digest_size self.block_size = hash.block_size @@ -424,6 +533,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: @@ -451,6 +568,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..4539c7d 100644 --- a/passlib/exc.py +++ b/passlib/exc.py @@ -15,6 +15,10 @@ class UnknownBackendError(ValueError): message = "%s: unknown backend: %r" % (hasher.name, backend) ValueError.__init__(self, message) + +# XXX: add a PasslibRuntimeError as base for Missing/Internal/Security runtime errors? + + class MissingBackendError(RuntimeError): """Error raised if multi-backend handler has no available backends; or if specifically requested backend is not available. @@ -27,18 +31,44 @@ class MissingBackendError(RuntimeError): :class:`~passlib.hash.bcrypt`). """ -class PasswordSizeError(ValueError): + +class InternalBackendError(RuntimeError): + """ + Error raised if something unrecoverable goes wrong with backend call; + such as if ``crypt.crypt()`` returning a malformed hash. + + .. versionadded:: 1.7.3 + """ + + +class PasswordValueError(ValueError): + """ + Error raised if a password can't be hashed / verified for various reasons. + This exception derives from the builtin :exc:`!ValueError`. + + May be thrown directly when password violates internal invariants of hasher + (e.g. some don't support NULL characters). Hashers may also throw more specific subclasses, + such as :exc:`!PasswordSizeError`. + + .. versionadded:: 1.7.3 + """ + pass + + +class PasswordSizeError(PasswordValueError): """ Error raised if a password exceeds the maximum size allowed by Passlib (by default, 4096 characters); or if password exceeds a hash-specific size limitation. + This exception derives from :exc:`PasswordValueError` (above). + Many password hash algorithms take proportionately larger amounts of time and/or memory depending on the size of the password provided. This could present a potential denial of service (DOS) situation if a maliciously large password is provided to an application. Because of this, Passlib enforces a maximum size limit, but one which should be *much* larger - than any legitimate password. :exc:`!PasswordSizeError` derives + than any legitimate password. :exc:`PasswordSizeError` derives from :exc:`!ValueError`. .. note:: @@ -59,7 +89,7 @@ class PasswordSizeError(ValueError): self.max_size = max_size if msg is None: msg = "password exceeds maximum allowed size" - ValueError.__init__(self, msg) + PasswordValueError.__init__(self, msg) # this also prevents a glibc crypt segfault issue, detailed here ... # http://www.openwall.com/lists/oss-security/2011/11/15/1 @@ -67,7 +97,7 @@ class PasswordSizeError(ValueError): class PasswordTruncateError(PasswordSizeError): """ Error raised if password would be truncated by hash. - This derives from :exc:`PasswordSizeError` and :exc:`ValueError`. + This derives from :exc:`PasswordSizeError` (above). Hashers such as :class:`~passlib.hash.bcrypt` can be configured to raises this error by setting ``truncate_error=True``. @@ -85,6 +115,7 @@ class PasswordTruncateError(PasswordSizeError): (cls.name, cls.truncate_size)) PasswordSizeError.__init__(self, cls.truncate_size, msg) + class PasslibSecurityError(RuntimeError): """ Error raised if critical security issue is detected @@ -155,14 +186,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 @@ -274,7 +319,7 @@ def MissingDigestError(handler=None): def NullPasswordError(handler=None): """raised by OS crypt() supporting hashes, which forbid NULLs in password""" name = _get_name(handler) - return ValueError("%s does not allow NULL bytes in password" % name) + return PasswordValueError("%s does not allow NULL bytes in password" % name) #------------------------------------------------------------------------ # errors when parsing hashes @@ -307,5 +352,40 @@ def ChecksumSizeError(handler, raw=False): return MalformedHashError(handler, reason) #============================================================================= +# sensitive info helpers +#============================================================================= + +#: global flag, set temporarily by UTs to allow debug_only_repr() to display sensitive values. +ENABLE_DEBUG_ONLY_REPR = False + + +def debug_only_repr(value, param="hash"): + """ + helper used to display sensitive data (hashes etc) within error messages. + currently returns placeholder test UNLESS unittests are running, + in which case the real value is displayed. + + mainly useful to prevent hashes / secrets from being exposed in production tracebacks; + while still being visible from test failures. + + NOTE: api subject to change, may formalize this more in the future. + """ + if ENABLE_DEBUG_ONLY_REPR or value is None or isinstance(value, bool): + return repr(value) + return "<%s %s value omitted>" % (param, type(value)) + + +def CryptBackendError(handler, config, hash, # * + source="crypt.crypt()"): + """ + helper to generate standard message when ``crypt.crypt()`` returns invalid result. + takes care of automatically masking contents of config & hash outside of UTs. + """ + name = _get_name(handler) + msg = "%s returned invalid %s hash: config=%s hash=%s" % \ + (source, name, debug_only_repr(config), debug_only_repr(hash)) + raise InternalBackendError(msg) + +#============================================================================= # eof #============================================================================= diff --git a/passlib/ext/django/utils.py b/passlib/ext/django/utils.py index 96daa68..626e48c 100644 --- a/passlib/ext/django/utils.py +++ b/passlib/ext/django/utils.py @@ -447,7 +447,10 @@ class DjangoContextAdapter(DjangoTranslator): self.get_user_category = get_user_category # install lru cache wrappers - from django.utils.lru_cache import lru_cache + try: + from functools import lru_cache # new py32 + except ImportError: + from django.utils.lru_cache import lru_cache # py2 compat, removed in django 3 (or earlier?) self.get_hashers = lru_cache()(self.get_hashers) # get copy of original make_password diff --git a/passlib/handlers/bcrypt.py b/passlib/handlers/bcrypt.py index a60b074..b6692a0 100644 --- a/passlib/handlers/bcrypt.py +++ b/passlib/handlers/bcrypt.py @@ -24,12 +24,14 @@ _pybcrypt = None # dynamically imported by _load_backend_pybcrypt() _bcryptor = None # dynamically imported by _load_backend_bcryptor() # pkg _builtin_bcrypt = None # dynamically imported by _load_backend_builtin() +from passlib.crypto.digest import compile_hmac from passlib.exc import PasslibHashWarning, PasslibSecurityWarning, PasslibSecurityError from passlib.utils import safe_crypt, repeat_string, to_bytes, parse_version, \ - rng, getrandstr, test_crypt, to_unicode + rng, getrandstr, test_crypt, to_unicode, \ + utf8_truncate, utf8_repeat_string, crypt_accepts_bytes from passlib.utils.binary import bcrypt64 from passlib.utils.compat import get_unbound_method_function -from passlib.utils.compat import uascii_to_str, unicode, str_to_uascii +from passlib.utils.compat import uascii_to_str, unicode, str_to_uascii, PY3, error_from import passlib.utils.handlers as uh # local @@ -48,7 +50,7 @@ IDENT_2B = u"$2b$" _BNULL = b'\x00' # reference hash of "test", used in various self-checks -TEST_HASH_2A = b"$2a$04$5BJqKfqMQvV7nS.yUguNcueVirQqDBGaLXSqj.rs.pZPlNR0UX/HK" +TEST_HASH_2A = "$2a$04$5BJqKfqMQvV7nS.yUguNcueVirQqDBGaLXSqj.rs.pZPlNR0UX/HK" def _detect_pybcrypt(): """ @@ -128,7 +130,9 @@ class _BcryptCommon(uh.SubclassBackendMixin, uh.TruncateMixin, uh.HasManyIdents, #-------------------- min_salt_size = max_salt_size = 22 salt_chars = bcrypt64.charmap - # NOTE: 22nd salt char must be in bcrypt64._padinfo2[1], not full charmap + + # NOTE: 22nd salt char must be in restricted set of ``final_salt_chars``, not full set above. + final_salt_chars = ".Oeu" # bcrypt64._padinfo2[1] #-------------------- # HasRounds @@ -155,6 +159,7 @@ class _BcryptCommon(uh.SubclassBackendMixin, uh.TruncateMixin, uh.HasManyIdents, _lacks_2y_support = False _lacks_2b_support = False _fallback_ident = IDENT_2A + _require_valid_utf8_bytes = False #=================================================================== # formatting @@ -195,10 +200,12 @@ class _BcryptCommon(uh.SubclassBackendMixin, uh.TruncateMixin, uh.HasManyIdents, @classmethod def needs_update(cls, hash, **kwds): + # NOTE: can't convert this to use _calc_needs_update() helper, + # since _norm_hash() will correct salt padding before we can read it here. # check for incorrect padding bits (passlib issue 25) if isinstance(hash, bytes): hash = hash.decode("ascii") - if hash.startswith(IDENT_2A) and hash[28] not in bcrypt64._padinfo2[1]: + if hash.startswith(IDENT_2A) and hash[28] not in cls.final_salt_chars: return True # TODO: try to detect incorrect 8bit/wraparound hashes using kwds.get("secret") @@ -286,7 +293,7 @@ class _BcryptCommon(uh.SubclassBackendMixin, uh.TruncateMixin, uh.HasManyIdents, verify = mixin_cls.verify - err_types = (ValueError,) + err_types = (ValueError, uh.exc.MissingBackendError) if _bcryptor: err_types += (_bcryptor.engine.SaltError,) @@ -297,11 +304,15 @@ class _BcryptCommon(uh.SubclassBackendMixin, uh.TruncateMixin, uh.HasManyIdents, except err_types: # backends without support for given ident will throw various # errors about unrecognized version: + # os_crypt -- internal code below throws + # - PasswordValueError if there's encoding issue w/ password. + # - InternalBackendError if crypt fails for unknown reason + # (trapped below so we can debug it) # pybcrypt, bcrypt -- raises ValueError # bcryptor -- raises bcryptor.engine.SaltError return NotImplemented - except AssertionError as err: - # _calc_checksum() code may also throw AssertionError + except uh.exc.InternalBackendError: + # _calc_checksum() code may also throw CryptBackendError # if correct hash isn't returned (e.g. 2y hash converted to 2b, # such as happens with bcrypt 3.0.0) log.debug("trapped unexpected response from %r backend: verify(%r, %r):", @@ -318,20 +329,35 @@ class _BcryptCommon(uh.SubclassBackendMixin, uh.TruncateMixin, uh.HasManyIdents, test cases from <http://cvsweb.openwall.com/cgi/cvsweb.cgi/Owl/packages/glibc/crypt_blowfish/wrapper.c.diff?r1=1.9;r2=1.10> reference hash is the incorrectly generated $2x$ hash taken from above url """ - secret = b"\xA3" - bug_hash = ident.encode("ascii") + b"05$/OK.fbVrR/bpIqNJ5ianF.CE5elHaaO4EbggVDjb8P19RukzXSM3e" + # NOTE: passlib 1.7.2 and earlier used the commented-out LATIN-1 test vector to detect + # this bug; but python3's crypt.crypt() only supports unicode inputs (and + # always encodes them as UTF8 before passing to crypt); so passlib 1.7.3 + # switched to the UTF8-compatible test vector below. This one's bug_hash value + # ("$2x$...rcAS") was drawn from the same openwall source (above); and the correct + # hash ("$2a$...X6eu") was generated by passing the raw bytes to python2's + # crypt.crypt() using OpenBSD 6.7 (hash confirmed as same for $2a$ & $2b$). + + # LATIN-1 test vector + # secret = b"\xA3" + # bug_hash = ident.encode("ascii") + b"05$/OK.fbVrR/bpIqNJ5ianF.CE5elHaaO4EbggVDjb8P19RukzXSM3e" + # correct_hash = ident.encode("ascii") + b"05$/OK.fbVrR/bpIqNJ5ianF.Sa7shbm4.OzKpvFnX1pQLmQW96oUlCq" + + # UTF-8 test vector + secret = b"\xd1\x91" # aka "\u0451" + bug_hash = ident.encode("ascii") + b"05$6bNw2HLQYeqHYyBfLMsv/OiwqTymGIGzFsA4hOTWebfehXHNprcAS" + correct_hash = ident.encode("ascii") + b"05$6bNw2HLQYeqHYyBfLMsv/OUcZd0LKP39b87nBw3.S2tVZSqiQX6eu" + if verify(secret, bug_hash): - # NOTE: this only EVER be observed in 2a hashes, - # 2y/2b hashes should have fixed the bug. + # NOTE: this only EVER be observed in (broken) 2a and (backward-compat) 2x hashes + # generated by crypt_blowfish library. 2y/2b hashes should not have the bug # (but we check w/ them anyways). raise PasslibSecurityError( "passlib.hash.bcrypt: Your installation of the %r backend is vulnerable to " - "the crypt_blowfish 8-bit bug (CVE-2011-2483), " - "and should be upgraded or replaced with another backend." % backend) + "the crypt_blowfish 8-bit bug (CVE-2011-2483) under %r hashes, " + "and should be upgraded or replaced with another backend" % (backend, ident)) - # if it doesn't have wraparound bug, make sure it *does* handle things - # correctly -- or we're in some weird third case. - correct_hash = ident.encode("ascii") + b"05$/OK.fbVrR/bpIqNJ5ianF.Sa7shbm4.OzKpvFnX1pQLmQW96oUlCq" + # it doesn't have wraparound bug, but make sure it *does* verify against the correct + # hash, or we're in some weird third case! if not verify(secret, correct_hash): raise RuntimeError("%s backend failed to verify %s 8bit hash" % (backend, ident)) @@ -375,41 +401,49 @@ class _BcryptCommon(uh.SubclassBackendMixin, uh.TruncateMixin, uh.HasManyIdents, #---------------------------------------------------------------- test_hash_20 = b"$2$04$5BJqKfqMQvV7nS.yUguNcuRfMMOXK0xPWavM7pOzjEi5ze5T1k8/S" result = safe_verify("test", test_hash_20) - if not result: - raise RuntimeError("%s incorrectly rejected $2$ hash" % backend) - elif result is NotImplemented: + if result is NotImplemented: mixin_cls._lacks_20_support = True log.debug("%r backend lacks $2$ support, enabling workaround", backend) + elif not result: + raise RuntimeError("%s incorrectly rejected $2$ hash" % backend) #---------------------------------------------------------------- # check for 2a support #---------------------------------------------------------------- result = safe_verify("test", TEST_HASH_2A) - if not result: - raise RuntimeError("%s incorrectly rejected $2a$ hash" % backend) - elif result is NotImplemented: + if result is NotImplemented: # 2a support is required, and should always be present raise RuntimeError("%s lacks support for $2a$ hashes" % backend) + elif not result: + raise RuntimeError("%s incorrectly rejected $2a$ hash" % backend) else: assert_lacks_8bit_bug(IDENT_2A) if detect_wrap_bug(IDENT_2A): - warn("passlib.hash.bcrypt: Your installation of the %r backend is vulnerable to " - "the bsd wraparound bug, " - "and should be upgraded or replaced with another backend " - "(enabling workaround for now)." % backend, - uh.exc.PasslibSecurityWarning) + if backend == "os_crypt": + # don't make this a warning for os crypt (e.g. openbsd); + # they'll have proper 2b implementation which will be used for new hashes. + # so even if we didn't have a workaround, this bug wouldn't be a concern. + log.debug("%r backend has $2a$ bsd wraparound bug, enabling workaround", backend) + else: + # installed library has the bug -- want to let users know, + # so they can upgrade it to something better (e.g. bcrypt cffi library) + warn("passlib.hash.bcrypt: Your installation of the %r backend is vulnerable to " + "the bsd wraparound bug, " + "and should be upgraded or replaced with another backend " + "(enabling workaround for now)." % backend, + uh.exc.PasslibSecurityWarning) mixin_cls._has_2a_wraparound_bug = True #---------------------------------------------------------------- # check for 2y support #---------------------------------------------------------------- - test_hash_2y = TEST_HASH_2A.replace(b"2a", b"2y") + test_hash_2y = TEST_HASH_2A.replace("2a", "2y") result = safe_verify("test", test_hash_2y) - if not result: - raise RuntimeError("%s incorrectly rejected $2y$ hash" % backend) - elif result is NotImplemented: + if result is NotImplemented: mixin_cls._lacks_2y_support = True log.debug("%r backend lacks $2y$ support, enabling workaround", backend) + elif not result: + raise RuntimeError("%s incorrectly rejected $2y$ hash" % backend) else: # NOTE: Not using this as fallback candidate, # lacks wide enough support across implementations. @@ -423,13 +457,13 @@ class _BcryptCommon(uh.SubclassBackendMixin, uh.TruncateMixin, uh.HasManyIdents, #---------------------------------------------------------------- # check for 2b support #---------------------------------------------------------------- - test_hash_2b = TEST_HASH_2A.replace(b"2a", b"2b") + test_hash_2b = TEST_HASH_2A.replace("2a", "2b") result = safe_verify("test", test_hash_2b) - if not result: - raise RuntimeError("%s incorrectly rejected $2b$ hash" % backend) - elif result is NotImplemented: + if result is NotImplemented: mixin_cls._lacks_2b_support = True log.debug("%r backend lacks $2b$ support, enabling workaround", backend) + elif not result: + raise RuntimeError("%s incorrectly rejected $2b$ hash" % backend) else: mixin_cls._fallback_ident = IDENT_2B assert_lacks_8bit_bug(IDENT_2B) @@ -455,8 +489,18 @@ class _BcryptCommon(uh.SubclassBackendMixin, uh.TruncateMixin, uh.HasManyIdents, @classmethod def _norm_digest_args(cls, secret, ident, new=False): # make sure secret is unicode + require_valid_utf8_bytes = cls._require_valid_utf8_bytes if isinstance(secret, unicode): secret = secret.encode("utf-8") + elif require_valid_utf8_bytes: + # if backend requires utf8 bytes (os_crypt); + # make sure input actually is utf8, or don't bother enabling utf-8 specific helpers. + try: + secret.decode("utf-8") + except UnicodeDecodeError: + # XXX: could just throw PasswordValueError here, backend will just do that + # when _calc_digest() is actually called. + require_valid_utf8_bytes = False # check max secret size uh.validate_secret(secret) @@ -477,7 +521,15 @@ class _BcryptCommon(uh.SubclassBackendMixin, uh.TruncateMixin, uh.HasManyIdents, # bcrypt only uses first 72 bytes anyways. # NOTE: not needed for 2y/2b, but might use 2a as fallback for them. if cls._has_2a_wraparound_bug and len(secret) >= 255: - secret = secret[:72] + if require_valid_utf8_bytes: + # backend requires valid utf8 bytes, so truncate secret to nearest valid segment. + # want to do this in constant time to not give away info about secret. + # NOTE: this only works because bcrypt will ignore everything past + # secret[71], so padding to include a full utf8 sequence + # won't break anything about the final output. + secret = utf8_truncate(secret, 72) + else: + secret = secret[:72] # special case handling for variants (ordered most common first) if ident == IDENT_2A: @@ -504,7 +556,13 @@ class _BcryptCommon(uh.SubclassBackendMixin, uh.TruncateMixin, uh.HasManyIdents, # we can fake $2$ behavior using the 2A/2Y/2B algorithm # by repeating the password until it's at least 72 chars in length. if secret: - secret = repeat_string(secret, 72) + if require_valid_utf8_bytes: + # NOTE: this only works because bcrypt will ignore everything past + # secret[71], so padding to include a full utf8 sequence + # won't break anything about the final output. + secret = utf8_repeat_string(secret, 72) + else: + secret = repeat_string(secret, 72) ident = cls._fallback_ident elif ident == IDENT_2X: @@ -595,9 +653,9 @@ class _BcryptBackend(_BcryptCommon): if isinstance(config, unicode): config = config.encode("ascii") hash = _bcrypt.hashpw(secret, config) - assert hash.startswith(config) and len(hash) == len(config)+31, \ - "config mismatch: %r => %r" % (config, hash) assert isinstance(hash, bytes) + if not hash.startswith(config) or len(hash) != len(config)+31: + raise uh.exc.CryptBackendError(self, config, hash, source="`bcrypt` package") return hash[-31:].decode("ascii") #----------------------------------------------------------------------- @@ -632,7 +690,8 @@ class _BcryptorBackend(_BcryptCommon): secret, ident = self._prepare_digest_args(secret) config = self._get_config(ident) hash = _bcryptor.engine.Engine(False).hash_key(secret, config) - assert hash.startswith(config) and len(hash) == len(config)+31 + if not hash.startswith(config) or len(hash) != len(config) + 31: + raise uh.exc.CryptBackendError(self, config, hash, source="bcryptor library") return str_to_uascii(hash[-31:]) #----------------------------------------------------------------------- @@ -701,7 +760,8 @@ class _PyBcryptBackend(_BcryptCommon): secret, ident = self._prepare_digest_args(secret) config = self._get_config(ident) hash = _pybcrypt.hashpw(secret, config) - assert hash.startswith(config) and len(hash) == len(config)+31 + if not hash.startswith(config) or len(hash) != len(config) + 31: + raise uh.exc.CryptBackendError(self, config, hash, source="pybcrypt library") return str_to_uascii(hash[-31:]) _calc_checksum = _calc_checksum_raw @@ -714,6 +774,10 @@ class _OsCryptBackend(_BcryptCommon): backend which uses :func:`crypt.crypt` """ + #: set flag to ensure _prepare_digest_args() doesn't create invalid utf8 string + #: when truncating bytes. + _require_valid_utf8_bytes = not crypt_accepts_bytes + @classmethod def _load_backend_mixin(mixin_cls, name, dryrun): if not test_crypt("test", TEST_HASH_2A): @@ -721,25 +785,61 @@ class _OsCryptBackend(_BcryptCommon): return mixin_cls._finalize_backend_mixin(name, dryrun) def _calc_checksum(self, secret): + # + # run secret through crypt.crypt(). + # if everything goes right, we'll get back a properly formed bcrypt hash. + # secret, ident = self._prepare_digest_args(secret) config = self._get_config(ident) hash = safe_crypt(secret, config) - if hash: - assert hash.startswith(config) and len(hash) == len(config)+31 + if hash is not None: + if not hash.startswith(config) or len(hash) != len(config) + 31: + raise uh.exc.CryptBackendError(self, config, hash) return hash[-31:] - else: - # NOTE: Have to raise this error because python3's crypt.crypt() only accepts unicode. - # This means it can't handle any passwords that aren't either unicode - # or utf-8 encoded bytes. However, hashing a password with an alternate - # encoding should be a pretty rare edge case; if user needs it, they can just - # install bcrypt backend. - # XXX: is this the right error type to raise? - # maybe have safe_crypt() not swallow UnicodeDecodeError, and have handlers - # like sha256_crypt trap it if they have alternate method of handling them? - raise uh.exc.MissingBackendError( - "non-utf8 encoded passwords can't be handled by crypt.crypt() under python3, " - "recommend running `pip install bcrypt`.", - ) + + # + # Check if this failed due to non-UTF8 bytes + # In detail: under py3, crypt.crypt() requires unicode inputs, which are then encoded to + # utf8 before passing them to os crypt() call. this is done according to the "s" format + # specifier for PyArg_ParseTuple (https://docs.python.org/3/c-api/arg.html). + # There appears no way to get around that to pass raw bytes; so we just throw error here + # to let user know they need to use another backend if they want raw bytes support. + # + # XXX: maybe just let safe_crypt() throw UnicodeDecodeError under passlib 2.0, + # and then catch it above? maybe have safe_crypt ALWAYS throw error + # instead of returning None? (would save re-detecting what went wrong) + # XXX: isn't secret ALWAYS bytes at this point? + # + if PY3 and isinstance(secret, bytes): + try: + secret.decode("utf-8") + except UnicodeDecodeError: + raise error_from(uh.exc.PasswordValueError( + "python3 crypt.crypt() ony supports bytes passwords using UTF8; " + "passlib recommends running `pip install bcrypt` for general bcrypt support.", + ), None) + + # + # else crypt() call failed for unknown reason. + # + # NOTE: getting here should be considered a bug in passlib -- + # if os_crypt backend detection said there's support, + # and we've already checked all known reasons above; + # want them to file bug so we can figure out what happened. + # in the meantime, users can avoid this by installing bcrypt-cffi backend; + # which won't have this (or utf8) edgecases. + # + # XXX: throw something more specific, like an "InternalBackendError"? + # NOTE: if do change this error, need to update test_81_crypt_fallback() expectations + # about what will be thrown; as well as safe_verify() above. + # + debug_only_repr = uh.exc.debug_only_repr + raise uh.exc.InternalBackendError( + "crypt.crypt() failed for unknown reason; " + "passlib recommends running `pip install bcrypt` for general bcrypt support." + # for debugging UTs -- + "(config=%s, secret=%s)" % (debug_only_repr(config), debug_only_repr(secret)), + ) #----------------------------------------------------------------------- # builtin backend @@ -905,7 +1005,9 @@ class _wrapped_bcrypt(bcrypt): #============================================================================= class bcrypt_sha256(_wrapped_bcrypt): - """This class implements a composition of BCrypt+SHA256, and follows the :ref:`password-hash-api`. + """ + This class implements a composition of BCrypt + HMAC_SHA256, + and follows the :ref:`password-hash-api`. It supports a fixed-length salt, and a variable number of rounds. @@ -916,7 +1018,13 @@ class bcrypt_sha256(_wrapped_bcrypt): .. versionchanged:: 1.7 - Now defaults to ``"2b"`` variant. + Now defaults to ``"2b"`` bcrypt variant; though supports older hashes + generated using the ``"2a"`` bcrypt variant. + + .. versionchanged:: 1.7.3 + + For increased security, updated to use HMAC-SHA256 instead of plain SHA256. + Now only supports the ``"2b"`` bcrypt variant. Hash format updated to "v=2". """ #=================================================================== # class attrs @@ -930,7 +1038,7 @@ class bcrypt_sha256(_wrapped_bcrypt): #-------------------- # GenericHandler #-------------------- - # this is locked at 2a/2b for now. + # this is locked at 2b for now (with 2a allowed only for legacy v1 format) ident_values = (IDENT_2A, IDENT_2B) # clone bcrypt's ident aliases so they can be used here as well... @@ -938,6 +1046,36 @@ class bcrypt_sha256(_wrapped_bcrypt): if item[1] in ident_values))(ident_values) default_ident = IDENT_2B + #-------------------- + # class specific + #-------------------- + + _supported_versions = {1, 2} + + #=================================================================== + # instance attrs + #=================================================================== + + #: wrapper version. + #: v1 -- used prior to passlib 1.7.3; performs ``bcrypt(sha256(secret), salt, cost)`` + #: v2 -- new in passlib 1.7.3; performs `bcrypt(sha256_hmac(salt, secret), salt, cost)`` + version = 2 + + #=================================================================== + # configuration + #=================================================================== + + @classmethod + def using(cls, version=None, **kwds): + subcls = super(bcrypt_sha256, cls).using(**kwds) + if version is not None: + subcls.version = subcls._norm_version(version) + ident = subcls.default_ident + if subcls.version > 1 and ident != IDENT_2B: + raise ValueError("bcrypt %r hashes not allowed for version %r" % + (ident, subcls.version)) + return subcls + #=================================================================== # formatting #=================================================================== @@ -957,15 +1095,28 @@ class bcrypt_sha256(_wrapped_bcrypt): # working around that via prefix. prefix = u'$bcrypt-sha256$' - _hash_re = re.compile(r""" + #: current version 2 hash format + _v2_hash_re = re.compile(r"""(?x) + ^ + [$]bcrypt-sha256[$] + v=(?P<version>\d+), + t=(?P<type>2b), + r=(?P<rounds>\d{1,2}) + [$](?P<salt>[^$]{22}) + (?:[$](?P<digest>[^$]{31}))? + $ + """) + + #: old version 1 hash format + _v1_hash_re = re.compile(r"""(?x) ^ - [$]bcrypt-sha256 - [$](?P<variant>2[ab]) - ,(?P<rounds>\d{1,2}) + [$]bcrypt-sha256[$] + (?P<type>2[ab]), + (?P<rounds>\d{1,2}) [$](?P<salt>[^$]{22}) - (?:[$](?P<digest>.{31}))? + (?:[$](?P<digest>[^$]{31}))? $ - """, re.X) + """) @classmethod def identify(cls, hash): @@ -979,28 +1130,62 @@ class bcrypt_sha256(_wrapped_bcrypt): hash = to_unicode(hash, "ascii", "hash") if not hash.startswith(cls.prefix): raise uh.exc.InvalidHashError(cls) - m = cls._hash_re.match(hash) - if not m: - raise uh.exc.MalformedHashError(cls) + m = cls._v2_hash_re.match(hash) + if m: + version = int(m.group("version")) + if version < 2: + raise uh.exc.MalformedHashError(cls) + else: + m = cls._v1_hash_re.match(hash) + if m: + version = 1 + else: + raise uh.exc.MalformedHashError(cls) rounds = m.group("rounds") if rounds.startswith(uh._UZERO) and rounds != uh._UZERO: raise uh.exc.ZeroPaddedRoundsError(cls) - return cls(ident=m.group("variant"), - rounds=int(rounds), - salt=m.group("salt"), - checksum=m.group("digest"), - ) + return cls( + version=version, + ident=m.group("type"), + rounds=int(rounds), + salt=m.group("salt"), + checksum=m.group("digest"), + ) - _template = u"$bcrypt-sha256$%s,%d$%s$%s" + _v2_template = u"$bcrypt-sha256$v=2,t=%s,r=%d$%s$%s" + _v1_template = u"$bcrypt-sha256$%s,%d$%s$%s" def to_string(self): - hash = self._template % (self.ident.strip(_UDOLLAR), - self.rounds, self.salt, self.checksum) + if self.version == 1: + template = self._v1_template + else: + template = self._v2_template + hash = template % (self.ident.strip(_UDOLLAR), self.rounds, self.salt, self.checksum) return uascii_to_str(hash) #=================================================================== + # init + #=================================================================== + + def __init__(self, version=None, **kwds): + if version is not None: + self.version = self._norm_version(version) + super(bcrypt_sha256, self).__init__(**kwds) + + #=================================================================== + # version + #=================================================================== + + @classmethod + def _norm_version(cls, version): + if version not in cls._supported_versions: + raise ValueError("%s: unknown or unsupported version: %r" % (cls.name, version)) + return version + + #=================================================================== # checksum #=================================================================== + def _calc_checksum(self, secret): # NOTE: can't use digest directly, since bcrypt stops at first NULL. # NOTE: bcrypt doesn't fully mix entropy for bytes 55-72 of password @@ -1011,9 +1196,31 @@ class bcrypt_sha256(_wrapped_bcrypt): if isinstance(secret, unicode): secret = secret.encode("utf-8") + if self.version == 1: + # version 1 -- old version just ran secret through sha256(), + # though this could be vulnerable to a breach attach + # (c.f. issue 114); which is why v2 switched to hmac wrapper. + digest = sha256(secret).digest() + else: + # version 2 -- running secret through HMAC keyed off salt. + # this prevents known secret -> sha256 password tables from being + # used to test against a bcrypt_sha256 hash. + # keying off salt (instead of constant string) should minimize chances of this + # colliding with existing table of hmac digest lookups as well. + # NOTE: salt in this case is the "bcrypt64"-encoded value, not the raw salt bytes, + # to make things easier for parallel implementations of this hash -- + # saving them the trouble of implementing a "bcrypt64" decoder. + salt = self.salt + if salt[-1] not in self.final_salt_chars: + # forbidding salts with padding bits set, because bcrypt implementations + # won't consistently hash them the same. since we control this format, + # just prevent these from even getting used. + raise ValueError("invalid salt string") + digest = compile_hmac("sha256", salt.encode("ascii"))(secret) + # NOTE: output of b64encode() uses "+/" altchars, "=" padding chars, # and no leading/trailing whitespace. - key = b64encode(sha256(secret).digest()) + key = b64encode(digest) # hand result off to normal bcrypt algorithm return super(bcrypt_sha256, self)._calc_checksum(key) @@ -1022,8 +1229,10 @@ class bcrypt_sha256(_wrapped_bcrypt): # other #=================================================================== - # XXX: have _needs_update() mark the $2a$ ones for upgrading? - # maybe do that after we switch to hex encoding? + def _calc_needs_update(self, **kwds): + if self.version < type(self).version: + return True + return super(bcrypt_sha256, self)._calc_needs_update(**kwds) #=================================================================== # eoc diff --git a/passlib/handlers/des_crypt.py b/passlib/handlers/des_crypt.py index 8630cbe..3c7bb7e 100644 --- a/passlib/handlers/des_crypt.py +++ b/passlib/handlers/des_crypt.py @@ -217,13 +217,13 @@ class des_crypt(uh.TruncateMixin, uh.HasManyBackends, uh.HasSalt, uh.GenericHand # NOTE: we let safe_crypt() encode unicode secret -> utf8; # no official policy since des-crypt predates unicode hash = safe_crypt(secret, self.salt) - if hash: - assert hash.startswith(self.salt) and len(hash) == 13 - return hash[2:] - else: + if hash is None: # py3's crypt.crypt() can't handle non-utf8 bytes. # fallback to builtin alg, which is always available. return self._calc_checksum_builtin(secret) + if not hash.startswith(self.salt) or len(hash) != 13: + raise uh.exc.CryptBackendError(self, self.salt, hash) + return hash[2:] #--------------------------------------------------------------- # builtin backend @@ -380,13 +380,13 @@ class bsdi_crypt(uh.HasManyBackends, uh.HasRounds, uh.HasSalt, uh.GenericHandler def _calc_checksum_os_crypt(self, secret): config = self.to_string() hash = safe_crypt(secret, config) - if hash: - assert hash.startswith(config[:9]) and len(hash) == 20 - return hash[-11:] - else: + if hash is None: # py3's crypt.crypt() can't handle non-utf8 bytes. # fallback to builtin alg, which is always available. return self._calc_checksum_builtin(secret) + if not hash.startswith(config[:9]) or len(hash) != 20: + raise uh.exc.CryptBackendError(self, config, hash) + return hash[-11:] #--------------------------------------------------------------- # builtin backend diff --git a/passlib/handlers/digests.py b/passlib/handlers/digests.py index 3761051..982155c 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=True): + """ + 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,14 +77,23 @@ 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_sha1 = create_hex_hash("sha1") + +# NOTE: some digests below are marked as "required=False", because these may not be present on +# FIPS systems (see issue 116). if missing, will return stub hasher that throws error +# if an attempt is made to actually use hash/verify with them. + +hex_md4 = create_hex_hash("md4", required=False) +hex_md5 = create_hex_hash("md5", django_name="unsalted_md5", required=False) +hex_sha1 = create_hex_hash("sha1", required=False) hex_sha256 = create_hex_hash("sha256") hex_sha512 = create_hex_hash("sha512") diff --git a/passlib/handlers/ldap_digests.py b/passlib/handlers/ldap_digests.py index e4a3596..8208c12 100644 --- a/passlib/handlers/ldap_digests.py +++ b/passlib/handlers/ldap_digests.py @@ -5,7 +5,7 @@ #============================================================================= # core from base64 import b64encode, b64decode -from hashlib import md5, sha1 +from hashlib import md5, sha1, sha256, sha512 import logging; log = logging.getLogger(__name__) import re # site @@ -22,6 +22,8 @@ __all__ = [ "ldap_sha1", "ldap_salted_md5", "ldap_salted_sha1", + "ldap_salted_sha256", + "ldap_salted_sha512", ##"get_active_ldap_crypt_schemes", "ldap_des_crypt", @@ -160,7 +162,9 @@ class ldap_salted_md5(_SaltedBase64DigestHelper): _hash_regex = re.compile(u(r"^\{SMD5\}(?P<tmp>[+/a-zA-Z0-9]{27,}={0,2})$")) class ldap_salted_sha1(_SaltedBase64DigestHelper): - """This class stores passwords using LDAP's salted SHA1 format, and follows the :ref:`password-hash-api`. + """ + This class stores passwords using LDAP's "Salted SHA1" format, + and follows the :ref:`password-hash-api`. It supports a 4-16 byte salt. @@ -196,8 +200,91 @@ class ldap_salted_sha1(_SaltedBase64DigestHelper): ident = u"{SSHA}" checksum_size = 20 _hash_func = sha1 + # NOTE: 32 = ceil((20 + 4) * 4/3) _hash_regex = re.compile(u(r"^\{SSHA\}(?P<tmp>[+/a-zA-Z0-9]{32,}={0,2})$")) + + +class ldap_salted_sha256(_SaltedBase64DigestHelper): + """ + This class stores passwords using LDAP's "Salted SHA2-256" format, + and follows the :ref:`password-hash-api`. + + It supports a 4-16 byte salt. + + The :meth:`~passlib.ifc.PasswordHash.using` method accepts the following optional keywords: + + :type salt: bytes + :param salt: + Optional salt string. + If not specified, one will be autogenerated (this is recommended). + If specified, it may be any 4-16 byte string. + + :type salt_size: int + :param salt_size: + Optional number of bytes to use when autogenerating new salts. + Defaults to 8 bytes for compatibility with the LDAP spec, + but Passlib supports any value between 4-16. + + :type relaxed: bool + :param relaxed: + By default, providing an invalid value for one of the other + keywords will result in a :exc:`ValueError`. If ``relaxed=True``, + and the error can be corrected, a :exc:`~passlib.exc.PasslibHashWarning` + will be issued instead. Correctable errors include + ``salt`` strings that are too long. + + .. versionadded:: 1.7.3 + """ + name = "ldap_salted_sha256" + ident = u("{SSHA256}") + checksum_size = 32 + default_salt_size = 8 + _hash_func = sha256 + # NOTE: 48 = ceil((32 + 4) * 4/3) + _hash_regex = re.compile(u(r"^\{SSHA256\}(?P<tmp>[+/a-zA-Z0-9]{48,}={0,2})$")) + + +class ldap_salted_sha512(_SaltedBase64DigestHelper): + """ + This class stores passwords using LDAP's "Salted SHA2-512" format, + and follows the :ref:`password-hash-api`. + + It supports a 4-16 byte salt. + + The :meth:`~passlib.ifc.PasswordHash.using` method accepts the following optional keywords: + + :type salt: bytes + :param salt: + Optional salt string. + If not specified, one will be autogenerated (this is recommended). + If specified, it may be any 4-16 byte string. + + :type salt_size: int + :param salt_size: + Optional number of bytes to use when autogenerating new salts. + Defaults to 8 bytes for compatibility with the LDAP spec, + but Passlib supports any value between 4-16. + + :type relaxed: bool + :param relaxed: + By default, providing an invalid value for one of the other + keywords will result in a :exc:`ValueError`. If ``relaxed=True``, + and the error can be corrected, a :exc:`~passlib.exc.PasslibHashWarning` + will be issued instead. Correctable errors include + ``salt`` strings that are too long. + + .. versionadded:: 1.7.3 + """ + name = "ldap_salted_sha512" + ident = u("{SSHA512}") + checksum_size = 64 + default_salt_size = 8 + _hash_func = sha512 + # NOTE: 91 = ceil((64 + 4) * 4/3) + _hash_regex = re.compile(u(r"^\{SSHA512\}(?P<tmp>[+/a-zA-Z0-9]{91,}={0,2})$")) + + class ldap_plaintext(plaintext): """This class stores passwords in plaintext, and follows the :ref:`password-hash-api`. diff --git a/passlib/handlers/md5_crypt.py b/passlib/handlers/md5_crypt.py index faaf889..b2dfea4 100644 --- a/passlib/handlers/md5_crypt.py +++ b/passlib/handlers/md5_crypt.py @@ -279,13 +279,13 @@ class md5_crypt(uh.HasManyBackends, _MD5_Common): def _calc_checksum_os_crypt(self, secret): config = self.ident + self.salt hash = safe_crypt(secret, config) - if hash: - assert hash.startswith(config) and len(hash) == len(config) + 23 - return hash[-22:] - else: + if hash is None: # py3's crypt.crypt() can't handle non-utf8 bytes. # fallback to builtin alg, which is always available. return self._calc_checksum_builtin(secret) + if not hash.startswith(config) or len(hash) != len(config) + 23: + raise uh.exc.CryptBackendError(self, config, hash) + return hash[-22:] #--------------------------------------------------------------- # builtin backend diff --git a/passlib/handlers/sha1_crypt.py b/passlib/handlers/sha1_crypt.py index 03265f3..ed1f1c0 100644 --- a/passlib/handlers/sha1_crypt.py +++ b/passlib/handlers/sha1_crypt.py @@ -109,13 +109,13 @@ class sha1_crypt(uh.HasManyBackends, uh.HasRounds, uh.HasSalt, uh.GenericHandler def _calc_checksum_os_crypt(self, secret): config = self.to_string(config=True) hash = safe_crypt(secret, config) - if hash: - assert hash.startswith(config) and len(hash) == len(config) + 29 - return hash[-28:] - else: + if hash is None: # py3's crypt.crypt() can't handle non-utf8 bytes. # fallback to builtin alg, which is always available. return self._calc_checksum_builtin(secret) + if not hash.startswith(config) or len(hash) != len(config) + 29: + raise uh.exc.CryptBackendError(self, config, hash) + return hash[-28:] #--------------------------------------------------------------- # builtin backend diff --git a/passlib/handlers/sha2_crypt.py b/passlib/handlers/sha2_crypt.py index b259586..ce5730b 100644 --- a/passlib/handlers/sha2_crypt.py +++ b/passlib/handlers/sha2_crypt.py @@ -367,17 +367,18 @@ class _SHA2_Common(uh.HasManyBackends, uh.HasRounds, uh.HasSalt, return False def _calc_checksum_os_crypt(self, secret): - hash = safe_crypt(secret, self.to_string()) - if hash: - # NOTE: avoiding full parsing routine via from_string().checksum, - # and just extracting the bit we need. - cs = self.checksum_size - assert hash.startswith(self.ident) and hash[-cs-1] == _UDOLLAR - return hash[-cs:] - else: + config = self.to_string() + hash = safe_crypt(secret, config) + if hash is None: # py3's crypt.crypt() can't handle non-utf8 bytes. # fallback to builtin alg, which is always available. return self._calc_checksum_builtin(secret) + # NOTE: avoiding full parsing routine via from_string().checksum, + # and just extracting the bit we need. + cs = self.checksum_size + if not hash.startswith(self.ident) or hash[-cs-1] != _UDOLLAR: + raise uh.exc.CryptBackendError(self, config, hash) + return hash[-cs:] #--------------------------------------------------------------- # builtin backend @@ -413,14 +414,9 @@ class sha256_crypt(_SHA2_Common): Optional number of rounds to use. Defaults to 535000, must be between 1000 and 999999999, inclusive. - :type implicit_rounds: bool - :param implicit_rounds: - this is an internal option which generally doesn't need to be touched. - - this flag determines whether the hash should omit the rounds parameter - when encoding it to a string; this is only permitted by the spec for rounds=5000, - and the flag is ignored otherwise. the spec requires the two different - encodings be preserved as they are, instead of normalizing them. + .. note:: + per the official specification, when the rounds parameter is set to 5000, + it may be omitted from the hash string. :type relaxed: bool :param relaxed: @@ -431,6 +427,18 @@ class sha256_crypt(_SHA2_Common): that are too small or too large, and ``salt`` strings that are too long. .. versionadded:: 1.6 + + .. + commented out, currently only supported by :meth:`hash`, and not via :meth:`using`: + + :type implicit_rounds: bool + :param implicit_rounds: + this is an internal option which generally doesn't need to be touched. + + this flag determines whether the hash should omit the rounds parameter + when encoding it to a string; this is only permitted by the spec for rounds=5000, + and the flag is ignored otherwise. the spec requires the two different + encodings be preserved as they are, instead of normalizing them. """ #=================================================================== # class attrs @@ -472,14 +480,9 @@ class sha512_crypt(_SHA2_Common): Optional number of rounds to use. Defaults to 656000, must be between 1000 and 999999999, inclusive. - :type implicit_rounds: bool - :param implicit_rounds: - this is an internal option which generally doesn't need to be touched. - - this flag determines whether the hash should omit the rounds parameter - when encoding it to a string; this is only permitted by the spec for rounds=5000, - and the flag is ignored otherwise. the spec requires the two different - encodings be preserved as they are, instead of normalizing them. + .. note:: + per the official specification, when the rounds parameter is set to 5000, + it may be omitted from the hash string. :type relaxed: bool :param relaxed: @@ -490,6 +493,18 @@ class sha512_crypt(_SHA2_Common): that are too small or too large, and ``salt`` strings that are too long. .. versionadded:: 1.6 + + .. + commented out, currently only supported by :meth:`hash`, and not via :meth:`using`: + + :type implicit_rounds: bool + :param implicit_rounds: + this is an internal option which generally doesn't need to be touched. + + this flag determines whether the hash should omit the rounds parameter + when encoding it to a string; this is only permitted by the spec for rounds=5000, + and the flag is ignored otherwise. the spec requires the two different + encodings be preserved as they are, instead of normalizing them. """ #=================================================================== diff --git a/passlib/hash.py b/passlib/hash.py index eecbab1..898e315 100644 --- a/passlib/hash.py +++ b/passlib/hash.py @@ -43,7 +43,7 @@ if False: from passlib.handlers.digests import hex_md4, hex_md5, hex_sha1, hex_sha256, hex_sha512, htdigest from passlib.handlers.django import django_bcrypt, django_bcrypt_sha256, django_des_crypt, django_disabled, django_pbkdf2_sha1, django_pbkdf2_sha256, django_salted_md5, django_salted_sha1 from passlib.handlers.fshp import fshp - from passlib.handlers.ldap_digests import ldap_bcrypt, ldap_bsdi_crypt, ldap_des_crypt, ldap_md5, ldap_md5_crypt, ldap_plaintext, ldap_salted_md5, ldap_salted_sha1, ldap_sha1, ldap_sha1_crypt, ldap_sha256_crypt, ldap_sha512_crypt + from passlib.handlers.ldap_digests import ldap_bcrypt, ldap_bsdi_crypt, ldap_des_crypt, ldap_md5, ldap_md5_crypt, ldap_plaintext, ldap_salted_md5, ldap_salted_sha1, ldap_salted_sha256, ldap_salted_sha512, ldap_sha1, ldap_sha1_crypt, ldap_sha256_crypt, ldap_sha512_crypt from passlib.handlers.md5_crypt import apr_md5_crypt, md5_crypt from passlib.handlers.misc import plaintext, unix_disabled from passlib.handlers.mssql import mssql2000, mssql2005 diff --git a/passlib/ifc.py b/passlib/ifc.py index 1a1aef2..559d256 100644 --- a/passlib/ifc.py +++ b/passlib/ifc.py @@ -120,7 +120,7 @@ class PasswordHash(object): Should handle generating salt, etc, and should return string containing identifier, salt & other configuration, as well as digest. - :param \*\*settings_kwds: + :param \\*\\*settings_kwds: Pass in settings to customize configuration of resulting hash. @@ -132,7 +132,7 @@ class PasswordHash(object): Support will be removed in Passlib 2.0. - :param \*\*context_kwds: + :param \\*\\*context_kwds: Specific algorithms may require context-specific information (such as the user login). """ diff --git a/passlib/pwd.py b/passlib/pwd.py index 12d6ecb..27ed228 100644 --- a/passlib/pwd.py +++ b/passlib/pwd.py @@ -357,7 +357,7 @@ class WordGenerator(SequenceGenerator): :param charset: predefined charset to draw from. - :param \*\*kwds: + :param \\*\\*kwds: all other keywords passed to the :class:`SequenceGenerator` parent class. Attributes @@ -614,7 +614,7 @@ class PhraseGenerator(SequenceGenerator): name of preset wordlist to use instead of ``wordset``. :param spaces: whether to insert spaces between words in output (defaults to ``True``). - :param \*\*kwds: + :param \\*\\*kwds: all other keywords passed to the :class:`SequenceGenerator` parent class. .. autoattribute:: wordset diff --git a/passlib/registry.py b/passlib/registry.py index 803fb39..e1161ad 100644 --- a/passlib/registry.py +++ b/passlib/registry.py @@ -125,6 +125,8 @@ _locations = dict( ldap_hex_sha1 = "passlib.handlers.roundup", ldap_salted_md5 = "passlib.handlers.ldap_digests", ldap_salted_sha1 = "passlib.handlers.ldap_digests", + ldap_salted_sha256 = "passlib.handlers.ldap_digests", + ldap_salted_sha512 = "passlib.handlers.ldap_digests", ldap_des_crypt = "passlib.handlers.ldap_digests", ldap_bsdi_crypt = "passlib.handlers.ldap_digests", ldap_md5_crypt = "passlib.handlers.ldap_digests", @@ -517,8 +519,11 @@ def get_supported_os_crypt_schemes(): if get_crypt_handler(name).has_backend(OS_CRYPT)) if not cache: # pragma: no cover -- sanity check # no idea what OS this could happen on... + import platform warn("crypt.crypt() function is present, but doesn't support any " - "formats known to passlib!", exc.PasslibRuntimeWarning) + "formats known to passlib! (system=%r release=%r)" % + (platform.system(), platform.release()), + exc.PasslibRuntimeWarning) return cache diff --git a/passlib/tests/test_crypto_digest.py b/passlib/tests/test_crypto_digest.py index a675ca8..9f3a5ff 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, JYTHON from passlib.tests.utils import TestCase, TEST_MODE, skipUnless, hb @@ -30,8 +31,10 @@ class HashInfoTest(TestCase): ("md5", "md5", "SCRAM-MD5-PLUS", "MD-5"), ("sha1", "sha-1", "SCRAM-SHA-1", "SHA1"), ("sha256", "sha-256", "SHA_256", "sha2-256"), - ("ripemd", "ripemd", "SCRAM-RIPEMD", "RIPEMD"), - ("ripemd160", "ripemd-160", "SCRAM-RIPEMD-160", "RIPEmd160"), + ("ripemd160", "ripemd-160", "SCRAM-RIPEMD-160", "RIPEmd160", + # NOTE: there was an older "RIPEMD" & "RIPEMD-128", but python treates "RIPEMD" + # as alias for "RIPEMD-160" + "ripemd", "SCRAM-RIPEMD"), # fake hashes (to check if fallback normalization behaves sanely) ("sha4_256", "sha4-256", "SHA4-256", "SHA-4-256"), @@ -50,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") @@ -109,12 +113,53 @@ 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) + self.assertEqual(info.block_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) + + # should use hardcoded fallback info + self.assertEqual(info.digest_size, 16) + self.assertEqual(info.block_size, 64) + def test_lookup_hash_metadata(self): """lookup_hash() -- metadata""" diff --git a/passlib/tests/test_crypto_scrypt.py b/passlib/tests/test_crypto_scrypt.py index a3c8eef..683ef2b 100644 --- a/passlib/tests/test_crypto_scrypt.py +++ b/passlib/tests/test_crypto_scrypt.py @@ -550,7 +550,7 @@ class _CommonScryptTest(TestCase): self.assertEqual(run_scrypt(1), 'da') # pick random value - ksize = rng.randint(0, 1 << 10) + ksize = rng.randint(1, 1 << 10) self.assertEqual(len(run_scrypt(ksize)), 2*ksize) # 2 hex chars per output # one more than upper bound diff --git a/passlib/tests/test_ext_django.py b/passlib/tests/test_ext_django.py index e0fa51a..6cef595 100644 --- a/passlib/tests/test_ext_django.py +++ b/passlib/tests/test_ext_django.py @@ -6,6 +6,7 @@ from __future__ import absolute_import, division, print_function import logging; log = logging.getLogger(__name__) import sys +import re # site # pkg from passlib import apps as _apps, exc, registry @@ -18,7 +19,7 @@ from passlib.utils.compat import iteritems, get_method_function from passlib.utils.decor import memoized_property # tests from passlib.tests.utils import TestCase, TEST_MODE, handler_derived_from -from passlib.tests.test_handlers import get_handler_case, conditionally_available_hashes +from passlib.tests.test_handlers import get_handler_case # local __all__ = [ "DjangoBehaviorTest", @@ -112,6 +113,22 @@ def create_mock_setter(): setter.popstate = popstate return setter + +def check_django_hasher_has_backend(name): + """ + check whether django hasher is available; + or if it should be skipped because django lacks third-party library. + """ + assert name + from django.contrib.auth.hashers import make_password + try: + make_password("", hasher=name) + return True + except ValueError as err: + if re.match("Couldn't load '.*?' algorithm .* No module named .*", str(err)): + return False + raise + #============================================================================= # work up stock django config #============================================================================= @@ -329,6 +346,7 @@ class DjangoBehaviorTest(_ExtensionTest): and run against the passlib extension, to verify it matches those assumptions. """ + log = self.getLogger() patched, config = self.patched, self.config # this tests the following methods: # User.set_password() @@ -441,6 +459,9 @@ class DjangoBehaviorTest(_ExtensionTest): # User.set_password() - n/a # User.check_password() - returns False + # FIXME: at some point past 1.8, some of these django started handler None differently; + # and/or throwing TypeError. need to investigate when that change occurred; + # update these tests, and maybe passlib.ext.django as well. user = FakeUser() user.password = None self.assertFalse(user.check_password(PASS1)) @@ -490,26 +511,38 @@ class DjangoBehaviorTest(_ExtensionTest): # testing various bits of per-scheme behavior. #======================================================= for scheme in ctx.schemes(): + + # + # TODO: break this loop up into separate parameterized tests. + # + #------------------------------------------------------- # setup constants & imports, pick a sample secret/hash combo #------------------------------------------------------- + handler = ctx.handler(scheme) + log.debug("testing scheme: %r => %r", scheme, handler) deprecated = ctx.handler(scheme).deprecated assert not deprecated or scheme != ctx.default_scheme() try: testcase = get_handler_case(scheme) except exc.MissingBackendError: - assert scheme in conditionally_available_hashes continue assert handler_derived_from(handler, testcase.handler) if handler.is_disabled: continue - if not registry.has_backend(handler): - # TODO: move this above get_handler_case(), - # and omit MissingBackendError check. + + # verify that django has a backend available + # (since our hasher may use different set of backends, + # get_handler_case() above may work, but django will have nothing) + if not patched and not check_django_hasher_has_backend(handler.django_name): assert scheme in ["django_bcrypt", "django_bcrypt_sha256", "django_argon2"], \ "%r scheme should always have active backend" % scheme + # TODO: make this a SkipTest() once this loop has been parameterized. + log.warn("skipping scheme %r due to missing django dependancy", scheme) continue + + # find a sample (secret, hash) pair to test with try: secret, hash = sample_hashes[scheme] except KeyError: @@ -520,7 +553,9 @@ class DjangoBehaviorTest(_ExtensionTest): break other = 'dontletmein' - # User.set_password() - n/a + #------------------------------------------------------- + # User.set_password() - not tested here + #------------------------------------------------------- #------------------------------------------------------- # User.check_password()+migration against known hash diff --git a/passlib/tests/test_handlers.py b/passlib/tests/test_handlers.py index 291170a..c8f9c1e 100644 --- a/passlib/tests/test_handlers.py +++ b/passlib/tests/test_handlers.py @@ -10,7 +10,7 @@ import sys import warnings # site # pkg -from passlib import hash +from passlib import exc, hash from passlib.utils import repeat_string from passlib.utils.compat import irange, PY3, get_method_function from passlib.tests.utils import TestCase, HandlerCase, skipUnless, \ @@ -43,12 +43,30 @@ _handler_test_modules = [ ] def get_handler_case(scheme): - """return HandlerCase instance for scheme, used by other tests""" + """ + return HandlerCase instance for scheme, used by other tests. + + :param scheme: name of hasher to locate test for (e.g. "bcrypt") + + :raises KeyError: + if scheme isn't known hasher. + + :raises MissingBackendError: + if hasher doesn't have any available backends. + + :returns: + HandlerCase subclass (which derives from TestCase) + """ from passlib.registry import get_crypt_handler handler = get_crypt_handler(scheme) if hasattr(handler, "backends") and scheme not in _omitted_backend_tests: - # NOTE: will throw MissingBackendError if none are installed. - backend = handler.get_backend() + # XXX: if no backends available, could proceed to pick first backend for test lookup; + # should investigate if that would be useful to callers. + try: + backend = handler.get_backend() + except exc.MissingBackendError: + assert scheme in conditionally_available_hashes + raise name = "%s_%s_test" % (scheme, backend) else: name = "%s_test" % scheme @@ -60,7 +78,9 @@ def get_handler_case(scheme): return getattr(mod, name) except AttributeError: pass - raise KeyError("test case %r not found" % name) + # every hasher should have test suite, so if we get here, means test is either missing, + # misnamed, or _handler_test_modules list is out of date. + raise RuntimeError("can't find test case named %r for %r" % (name, scheme)) #: hashes which there may not be a backend available for, #: and get_handler_case() may (correctly) throw a MissingBackendError @@ -175,9 +195,14 @@ class _bsdi_crypt_test(HandlerCase): ] platform_crypt_support = [ - ("freebsd|openbsd|netbsd|darwin", True), + # openbsd 5.8 dropped everything except bcrypt + ("openbsd[6789]", False), + ("openbsd5", None), + ("openbsd", True), + + ("freebsd|netbsd|darwin", True), ("solaris", False), - # linux - may be present in libxcrypt + ("linux", None), # may be present if libxcrypt is in use ] def test_77_fuzz_input(self, **kwds): @@ -277,7 +302,12 @@ class _des_crypt_test(HandlerCase): ] platform_crypt_support = [ - ("freebsd|openbsd|netbsd|linux|solaris|darwin", True), + # openbsd 5.8 dropped everything except bcrypt + ("openbsd[6789]", False), + ("openbsd5", None), + ("openbsd", True), + + ("freebsd|netbsd|linux|solaris|darwin", True), ] # create test cases for specific backends @@ -383,6 +413,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 HashInfo has changed. + # 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", required=False) + 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 = [ @@ -512,6 +576,67 @@ class ldap_salted_sha1_test(HandlerCase): '{SSHA}P90+qijSp8MJ1tN25j5o1PflUvlqjXHOGeOck===', ] + +class ldap_salted_sha256_test(HandlerCase): + handler = hash.ldap_salted_sha256 + known_correct_hashes = [ + # generated locally + # salt size = 8 + ("password", '{SSHA256}x1tymSTVjozxQ2PtT46ysrzhZxbcskK0o2f8hEFx7fAQQmhtDSEkJA=='), + ("test", '{SSHA256}xfqc9aOR6z15YaEk3/Ufd7UL9+JozB/1EPmCDTizL0GkdA7BuNda6w=='), + ("toomanysecrets", '{SSHA256}RrTKrg6HFXcjJ+eDAq4UtbODxOr9RLeG+I69FoJvutcbY0zpfU+p1Q=='), + (u('letm\xe8\xefn'), '{SSHA256}km7UjUTBZN8a+gf1ND2/qn15N7LsO/jmGYJXvyTfJKAbI0RoLWWslQ=='), + + # alternate salt sizes (4, 15, 16) + # generated locally + ('test', '{SSHA256}TFv2RpwyO0U9mA0Hk8FsXRa1I+4dNUtv27Qa8dzGVLinlDIm'), + ('test', '{SSHA256}J6MFQdkfjdmXz9UyUPb773kekJdm4dgSL4y8WQEQW11VipHSundOKaV0LsV4L6U='), + ('test', '{SSHA256}uBLazLaiBaPb6Cpnvq2XTYDkvXbYIuqRW1anMKk85d1/j1GqFQIgpHSOMUYIIcS4'), + ] + + known_malformed_hashes = [ + # salt too small (3) + '{SSHA256}Lpdyr1+lR+rtxgp3SpQnUuNw33ENivTl28nzF2ZI4Gm41/o=', + + # incorrect base64 encoding + '{SSHA256}TFv2RpwyO0U9mA0Hk8FsXRa1I+4dNUtv27Qa8dzGVLinlDI@', + '{SSHA256}TFv2RpwyO0U9mA0Hk8FsXRa1I+4dNUtv27Qa8dzGVLinlDI', + '{SSHA256}TFv2RpwyO0U9mA0Hk8FsXRa1I+4dNUtv27Qa8dzGVLinlDIm===', + ] + + + +class ldap_salted_sha512_test(HandlerCase): + handler = hash.ldap_salted_sha512 + known_correct_hashes = [ + # generated by testing ldap server web interface (see issue 124 comments) + # salt size = 8 + ("toomanysecrets", '{SSHA512}wExp4xjiCHS0zidJDC4UJq9EEeIebAQPJ1PWSwfhxWjfutI9XiiKuHm2AE41cEFfK+8HyI8bh+ztbczUGsvVFIgICWWPt7qu'), + (u('letm\xe8\xefn'), '{SSHA512}mpNUSmZc3TNx+RnPwkIAVMf7ocEKLPrIoQNsg4Eu8dHvyCeb2xzHp5A6n4tF7ntknSvfvRZaJII4ImvNJlYsgiwAm0FMqR+3'), + + # generated locally + # salt size = 8 + ("password", '{SSHA512}f/lFQskkl7PdMsTGJxHZq8LDt/l+UqRMm6/pj4pV7/xZkcOaKCgvQqp+KCeXc/Vd4RY6vEHWn4y0DnFcQ6wgyv9fyxk='), + ("test", '{SSHA512}Tgx/uhHnlM9/GgQvI31dN7cheDXg7WypZwaaIkyRsgV/BKIzBG3G/wUd9o1dpi06p3SYzMedg0lvTc3b6CtdO0Xo/f9/L+Uc'), + + # alternate salt sizes (4, 15, 16) + # generated locally + ('test', '{SSHA512}Yg9DQ2wURCFGwobu7R2O6cq7nVbnGMPrFCX0aPQ9kj/y1hd6k9PEzkgWCB5aXdPwPzNrVb0PkiHiBnG1CxFiT+B8L8U='), + ('test', '{SSHA512}5ecDGWs5RY4xLszUO6hAcl90W3wAozGQoI4Gqj8xSZdcfU1lVEM4aY8s+4xVeLitcn7BO8i7xkzMFWLoxas7SeHc23sP4dx77937PyeE0A=='), + ('test', '{SSHA512}6FQv5W47HGg2MFBFZofoiIbO8KRW75Pm51NKoInpthYQQ5ujazHGhVGzrj3JXgA7j0k+UNmkHdbJjdY5xcUHPzynFEII4fwfIySEcG5NKSU='), + ] + + known_malformed_hashes = [ + # salt too small (3) + '{SSHA512}zFnn4/8x8GveUaMqgrYWyIWqFQ0Irt6gADPtRk4Uv3nUC6uR5cD8+YdQni/0ZNij9etm6p17kSFuww3M6l+d6AbAeA==', + + # incorrect base64 encoding + '{SSHA512}Tgx/uhHnlM9/GgQvI31dN7cheDXg7WypZwaaIkyRsgV/BKIzBG3G/wUd9o1dpi06p3SYzMedg0lvTc3b6CtdO0Xo/f9/L+U', + '{SSHA512}Tgx/uhHnlM9/GgQvI31dN7cheDXg7WypZwaaIkyRsgV/BKIzBG3G/wUd9o1dpi06p3SYzMedg0lvTc3b6CtdO0Xo/f9/L+U@', + '{SSHA512}Tgx/uhHnlM9/GgQvI31dN7cheDXg7WypZwaaIkyRsgV/BKIzBG3G/wUd9o1dpi06p3SYzMedg0lvTc3b6CtdO0Xo/f9/L+U===', + ] + + class ldap_plaintext_test(HandlerCase): # TODO: integrate EncodingHandlerMixin handler = hash.ldap_plaintext @@ -583,7 +708,7 @@ class _ldap_sha1_crypt_test(HandlerCase): kwds.setdefault("rounds", 10) super(_ldap_sha1_crypt_test, self).populate_settings(kwds) - def test_77_fuzz_input(self): + def test_77_fuzz_input(self, **ignored): raise self.skipTest("unneeded") # create test cases for specific backends @@ -685,7 +810,12 @@ class _md5_crypt_test(HandlerCase): ] platform_crypt_support = [ - ("freebsd|openbsd|netbsd|linux|solaris", True), + # openbsd 5.8 dropped everything except bcrypt + ("openbsd[6789]", False), + ("openbsd5", None), + ("openbsd", True), + + ("freebsd|netbsd|linux|solaris", True), ("darwin", False), ] @@ -1255,7 +1385,7 @@ class _sha1_crypt_test(HandlerCase): platform_crypt_support = [ ("netbsd", True), ("freebsd|openbsd|solaris|darwin", False), - # linux - may be present in libxcrypt + ("linux", None), # may be present if libxcrypt is in use ] # create test cases for specific backends @@ -1391,9 +1521,9 @@ class _sha256_crypt_test(HandlerCase): platform_crypt_support = [ ("freebsd(9|1\d)|linux", True), - ("freebsd8", None), # added in freebsd 8.3 + ("freebsd8", None), # added in freebsd 8.3 ("freebsd|openbsd|netbsd|darwin", False), - # solaris - depends on policy + ("solaris", None), # depends on policy ] # create test cases for specific backends diff --git a/passlib/tests/test_handlers_bcrypt.py b/passlib/tests/test_handlers_bcrypt.py index 978b68b..64fc8bf 100644 --- a/passlib/tests/test_handlers_bcrypt.py +++ b/passlib/tests/test_handlers_bcrypt.py @@ -11,8 +11,8 @@ import warnings # pkg from passlib import hash from passlib.handlers.bcrypt import IDENT_2, IDENT_2X -from passlib.utils import repeat_string, to_bytes -from passlib.utils.compat import irange +from passlib.utils import repeat_string, to_bytes, is_safe_crypt_input +from passlib.utils.compat import irange, PY3 from passlib.tests.utils import HandlerCase, TEST_MODE from passlib.tests.test_handlers import UPASS_TABLE # module @@ -25,7 +25,6 @@ class _bcrypt_test(HandlerCase): handler = hash.bcrypt reduce_default_rounds = True fuzz_salts_need_bcrypt_repair = True - has_os_crypt_fallback = False known_correct_hashes = [ # @@ -69,6 +68,13 @@ class _bcrypt_test(HandlerCase): '$2y$05$/OK.fbVrR/bpIqNJ5ianF.Sa7shbm4.OzKpvFnX1pQLmQW96oUlCq'), # + # 8bit bug (fixed in 2y/2b) + # + + # NOTE: see assert_lacks_8bit_bug() for origins of this test vector. + (b"\xd1\x91", "$2y$05$6bNw2HLQYeqHYyBfLMsv/OUcZd0LKP39b87nBw3.S2tVZSqiQX6eu"), + + # # bsd wraparound bug (fixed in 2b) # @@ -155,8 +161,8 @@ class _bcrypt_test(HandlerCase): platform_crypt_support = [ ("freedbsd|openbsd|netbsd", True), ("darwin", False), - # linux - may be present via addon, e.g. debian's libpam-unix2 - # solaris - depends on policy + ("linux", None), # may be present via addon, e.g. debian's libpam-unix2 + ("solaris", None), # depends on system policy ] #=================================================================== @@ -172,7 +178,10 @@ class _bcrypt_test(HandlerCase): else: self.addCleanup(os.environ.__delitem__, key) os.environ[key] = "true" + super(_bcrypt_test, self).setUp() + + # silence this warning, will come up a bunch during testing of old 2a hashes. warnings.filterwarnings("ignore", ".*backend is vulnerable to the bsd wraparound bug.*") def populate_settings(self, kwds): @@ -402,7 +411,9 @@ class _bcrypt_test(HandlerCase): bcrypt = self.handler.using(rounds=4) # PASS1 = "test" - BAD1 = "$2a$04$yjDgE74RJkeqC0/1NheSScrvKeu9IbKDpcQf/Ox3qsrRS/Kw42qIS" + # bad contains invalid 'c' char at end of salt: + # \/ + BAD1 = "$2a$04$yjDgE74RJkeqC0/1NheSScrvKeu9IbKDpcQf/Ox3qsrRS/Kw42qIS" GOOD1 = "$2a$04$yjDgE74RJkeqC0/1NheSSOrvKeu9IbKDpcQf/Ox3qsrRS/Kw42qIS" self.assertTrue(bcrypt.needs_update(BAD1)) @@ -416,7 +427,16 @@ class _bcrypt_test(HandlerCase): bcrypt_bcrypt_test = _bcrypt_test.create_backend_case("bcrypt") bcrypt_pybcrypt_test = _bcrypt_test.create_backend_case("pybcrypt") bcrypt_bcryptor_test = _bcrypt_test.create_backend_case("bcryptor") -bcrypt_os_crypt_test = _bcrypt_test.create_backend_case("os_crypt") + +class bcrypt_os_crypt_test(_bcrypt_test.create_backend_case("os_crypt")): + + # os crypt doesn't support non-utf8 secret bytes + known_correct_hashes = [row for row in _bcrypt_test.known_correct_hashes + if is_safe_crypt_input(row[0])] + + # os crypt backend doesn't currently implement a per-call fallback if it fails + has_os_crypt_fallback = False + bcrypt_builtin_test = _bcrypt_test.create_backend_case("builtin") #============================================================================= @@ -428,13 +448,11 @@ class _bcrypt_sha256_test(HandlerCase): reduce_default_rounds = True forbidden_characters = None fuzz_salts_need_bcrypt_repair = True - alt_safe_crypt_handler = hash.bcrypt - has_os_crypt_fallback = True known_correct_hashes = [ - # - # custom test vectors - # + #------------------------------------------------------------------- + # custom test vectors for old v1 format + #------------------------------------------------------------------- # empty ("", @@ -458,20 +476,56 @@ class _bcrypt_sha256_test(HandlerCase): # test >72 chars is hashed correctly -- under bcrypt these hash the same. # NOTE: test_60_truncate_size() handles this already, this is just for overkill :) - (repeat_string("abc123",72), + (repeat_string("abc123", 72), '$bcrypt-sha256$2b,5$X1g1nh3g0v4h6970O68cxe$r/hyEtqJ0teqPEmfTLoZ83ciAI1Q74.'), - (repeat_string("abc123",72)+"qwr", + (repeat_string("abc123", 72) + "qwr", '$bcrypt-sha256$2b,5$X1g1nh3g0v4h6970O68cxe$021KLEif6epjot5yoxk0m8I0929ohEa'), - (repeat_string("abc123",72)+"xyz", + (repeat_string("abc123", 72) + "xyz", '$bcrypt-sha256$2b,5$X1g1nh3g0v4h6970O68cxe$7.1kgpHduMGEjvM3fX6e/QCvfn6OKja'), + + #------------------------------------------------------------------- + # custom test vectors for v2 format + # TODO: convert to v2 format + #------------------------------------------------------------------- + + # empty + ("", + '$bcrypt-sha256$v=2,t=2b,r=5$E/e/2AOhqM5W/KJTFQzLce$WFPIZKtDDTriqWwlmRFfHiOTeheAZWe'), + + # ascii + ("password", + '$bcrypt-sha256$v=2,t=2b,r=5$5Hg1DKFqPE8C2aflZ5vVoe$wOK1VFFtS8IGTrGa7.h5fs0u84qyPbS'), + + # unicode / utf8 + (UPASS_TABLE, + '$bcrypt-sha256$v=2,t=2b,r=5$.US1fQ4TQS.ZTz/uJ5Kyn.$pzzgp40k8reM1CuQb03PvE0IDPQSdV6'), + (UPASS_TABLE.encode("utf-8"), + '$bcrypt-sha256$v=2,t=2b,r=5$.US1fQ4TQS.ZTz/uJ5Kyn.$pzzgp40k8reM1CuQb03PvE0IDPQSdV6'), + + # test >72 chars is hashed correctly -- under bcrypt these hash the same. + # NOTE: test_60_truncate_size() handles this already, this is just for overkill :) + (repeat_string("abc123", 72), + '$bcrypt-sha256$v=2,t=2b,r=5$X1g1nh3g0v4h6970O68cxe$zu1cloESVFIOsUIo7fCEgkdHaI9SSue'), + (repeat_string("abc123", 72) + "qwr", + '$bcrypt-sha256$v=2,t=2b,r=5$X1g1nh3g0v4h6970O68cxe$CBF9csfEdW68xv3DwE6xSULXMtqEFP.'), + (repeat_string("abc123", 72) + "xyz", + '$bcrypt-sha256$v=2,t=2b,r=5$X1g1nh3g0v4h6970O68cxe$zC/1UDUG2ofEXB6Onr2vvyFzfhEOS3S'), ] known_correct_configs =[ + # v1 ('$bcrypt-sha256$2a,5$5Hg1DKFqPE8C2aflZ5vVoe', "password", '$bcrypt-sha256$2a,5$5Hg1DKFqPE8C2aflZ5vVoe$12BjNE0p7axMg55.Y/mHsYiVuFBDQyu'), + # v2 + ('$bcrypt-sha256$v=2,t=2b,r=5$5Hg1DKFqPE8C2aflZ5vVoe', + "password", '$bcrypt-sha256$v=2,t=2b,r=5$5Hg1DKFqPE8C2aflZ5vVoe$wOK1VFFtS8IGTrGa7.h5fs0u84qyPbS'), ] known_malformed_hashes = [ + #------------------------------------------------------------------- + # v1 format + #------------------------------------------------------------------- + # bad char in otherwise correct hash # \/ '$bcrypt-sha256$2a,5$5Hg1DKF!PE8C2aflZ5vVoe$12BjNE0p7axMg55.Y/mHsYiVuFBDQyu', @@ -487,6 +541,33 @@ class _bcrypt_sha256_test(HandlerCase): # config string w/ $ added '$bcrypt-sha256$2a,5$5Hg1DKFqPE8C2aflZ5vVoe$', + + #------------------------------------------------------------------- + # v2 format + #------------------------------------------------------------------- + + # bad char in otherwise correct hash + # \/ + '$bcrypt-sha256$v=2,t=2b,r=5$5Hg1DKF!PE8C2aflZ5vVoe$12BjNE0p7axMg55.Y/mHsYiVuFBDQyu', + + # unsupported version (for this format) + '$bcrypt-sha256$v=1,t=2b,r=5$5Hg1DKFqPE8C2aflZ5vVoe$12BjNE0p7axMg55.Y/mHsYiVuFBDQyu', + + # unrecognized version + '$bcrypt-sha256$v=3,t=2b,r=5$5Hg1DKFqPE8C2aflZ5vVoe$12BjNE0p7axMg55.Y/mHsYiVuFBDQyu', + + # unrecognized bcrypt variant + '$bcrypt-sha256$v=2,t=2c,r=5$5Hg1DKFqPE8C2aflZ5vVoe$12BjNE0p7axMg55.Y/mHsYiVuFBDQyu', + + # unsupported bcrypt variant + '$bcrypt-sha256$v=2,t=2a,r=5$5Hg1DKFqPE8C2aflZ5vVoe$12BjNE0p7axMg55.Y/mHsYiVuFBDQyu', + '$bcrypt-sha256$v=2,t=2x,r=5$5Hg1DKFqPE8C2aflZ5vVoe$12BjNE0p7axMg55.Y/mHsYiVuFBDQyu', + + # rounds zero-padded + '$bcrypt-sha256$v=2,t=2b,r=05$5Hg1DKFqPE8C2aflZ5vVoe$12BjNE0p7axMg55.Y/mHsYiVuFBDQyu', + + # config string w/ $ added + '$bcrypt-sha256$v=2,t=2b,r=5$5Hg1DKFqPE8C2aflZ5vVoe$', ] #=================================================================== @@ -514,11 +595,12 @@ class _bcrypt_sha256_test(HandlerCase): #=================================================================== # override ident tests for now #=================================================================== - def test_30_HasManyIdents(self): + + def require_many_idents(self): raise self.skipTest("multiple idents not supported") def test_30_HasOneIdent(self): - # forbidding ident keyword, we only support "2a" for now + # forbidding ident keyword, we only support "2b" for now handler = self.handler handler(use_defaults=True) self.assertRaises(ValueError, handler, ident="$2y$", use_defaults=True) @@ -526,17 +608,79 @@ class _bcrypt_sha256_test(HandlerCase): #=================================================================== # fuzz testing -- cloned from bcrypt #=================================================================== + class FuzzHashGenerator(HandlerCase.FuzzHashGenerator): def random_rounds(self): # decrease default rounds for fuzz testing to speed up volume. return self.randintgauss(5, 8, 6, 1) + def random_ident(self): + return "2b" + + #=================================================================== + # custom tests + #=================================================================== + + def test_using_version(self): + # default to v2 + handler = self.handler + self.assertEqual(handler.version, 2) + + # allow v1 explicitly + subcls = handler.using(version=1) + self.assertEqual(subcls.version, 1) + + # forbid unknown ver + self.assertRaises(ValueError, handler.using, version=999) + + # allow '2a' only for v1 + subcls = handler.using(version=1, ident="2a") + self.assertRaises(ValueError, handler.using, ident="2a") + + def test_calc_digest_v2(self): + """ + test digest calc v2 matches bcrypt() + """ + from passlib.hash import bcrypt + from passlib.crypto.digest import compile_hmac + from passlib.utils.binary import b64encode + + # manually calc intermediary digest + salt = "nyKYxTAvjmy6lMDYMl11Uu" + secret = "test" + temp_digest = compile_hmac("sha256", salt.encode("ascii"))(secret.encode("ascii")) + temp_digest = b64encode(temp_digest).decode("ascii") + self.assertEqual(temp_digest, "J5TlyIDm+IcSWmKiDJm+MeICndBkFVPn4kKdJW8f+xY=") + + # manually final hash from intermediary + # XXX: genhash() could be useful here + bcrypt_digest = bcrypt(ident="2b", salt=salt, rounds=12)._calc_checksum(temp_digest) + self.assertEqual(bcrypt_digest, "M0wE0Ov/9LXoQFCe.jRHu3MSHPF54Ta") + self.assertTrue(bcrypt.verify(temp_digest, "$2b$12$" + salt + bcrypt_digest)) + + # confirm handler outputs same thing. + # XXX: genhash() could be useful here + result = self.handler(ident="2b", salt=salt, rounds=12)._calc_checksum(secret) + self.assertEqual(result, bcrypt_digest) + + #=================================================================== + # eoc + #=================================================================== + # create test cases for specific backends bcrypt_sha256_bcrypt_test = _bcrypt_sha256_test.create_backend_case("bcrypt") bcrypt_sha256_pybcrypt_test = _bcrypt_sha256_test.create_backend_case("pybcrypt") bcrypt_sha256_bcryptor_test = _bcrypt_sha256_test.create_backend_case("bcryptor") -bcrypt_sha256_os_crypt_test = _bcrypt_sha256_test.create_backend_case("os_crypt") + +class bcrypt_sha256_os_crypt_test(_bcrypt_sha256_test.create_backend_case("os_crypt")): + + @classmethod + def _get_safe_crypt_handler_backend(cls): + return bcrypt_os_crypt_test._get_safe_crypt_handler_backend() + + has_os_crypt_fallback = False + bcrypt_sha256_builtin_test = _bcrypt_sha256_test.create_backend_case("builtin") #============================================================================= diff --git a/passlib/tests/test_handlers_django.py b/passlib/tests/test_handlers_django.py index 8159a24..3ea71cc 100644 --- a/passlib/tests/test_handlers_django.py +++ b/passlib/tests/test_handlers_django.py @@ -5,6 +5,7 @@ from __future__ import with_statement # core import logging; log = logging.getLogger(__name__) +import re import warnings # site # pkg @@ -12,7 +13,8 @@ from passlib import hash from passlib.utils import repeat_string from passlib.tests.utils import TestCase, HandlerCase, skipUnless, SkipTest from passlib.tests.test_handlers import UPASS_USD, UPASS_TABLE -from passlib.tests.test_ext_django import DJANGO_VERSION, MIN_DJANGO_VERSION +from passlib.tests.test_ext_django import DJANGO_VERSION, MIN_DJANGO_VERSION, \ + check_django_hasher_has_backend # module #============================================================================= @@ -26,6 +28,10 @@ def vstr(version): return ".".join(str(e) for e in version) class _DjangoHelper(TestCase): + """ + mixin for HandlerCase subclasses that are testing a hasher + which is also present in django. + """ __unittest_skip = True #: minimum django version where hash alg is present / that we support testing against @@ -39,10 +45,17 @@ class _DjangoHelper(TestCase): max_django_version = None def _require_django_support(self): + # make sure min django version if DJANGO_VERSION < self.min_django_version: raise self.skipTest("Django >= %s not installed" % vstr(self.min_django_version)) if self.max_django_version and DJANGO_VERSION > self.max_django_version: raise self.skipTest("Django <= %s not installed" % vstr(self.max_django_version)) + + # make sure django has a backend for specified hasher + name = self.handler.django_name + if not check_django_hasher_has_backend(name): + raise self.skipTest('django hasher %r not available' % name) + return True extra_fuzz_verifiers = HandlerCase.fuzz_verifiers + ( diff --git a/passlib/tests/test_registry.py b/passlib/tests/test_registry.py index 7540ee2..8cec48d 100644 --- a/passlib/tests/test_registry.py +++ b/passlib/tests/test_registry.py @@ -189,8 +189,8 @@ class RegistryTest(TestCase): self.assertIs(get_crypt_handler("DUMMY-0"), dummy_0) # check system & private names aren't returned - import passlib.hash # ensure module imported, so py3.3 sets __package__ - passlib.hash.__dict__["_fake"] = "dummy" # so behavior seen under py2x also + from passlib import hash + hash.__dict__["_fake"] = "dummy" for name in ["_fake", "__package__"]: self.assertRaises(KeyError, get_crypt_handler, name) self.assertIs(get_crypt_handler(name, None), None) @@ -200,8 +200,7 @@ class RegistryTest(TestCase): from passlib.registry import list_crypt_handlers # check system & private names aren't returned - import passlib.hash # ensure module imported, so py3.3 sets __package__ - passlib.hash.__dict__["_fake"] = "dummy" # so behavior seen under py2x also + hash.__dict__["_fake"] = "dummy" for name in list_crypt_handlers(): self.assertFalse(name.startswith("_"), "%r: " % name) unload_handler_name("_fake") diff --git a/passlib/tests/test_totp.py b/passlib/tests/test_totp.py index 1789d46..1d81f0b 100644 --- a/passlib/tests/test_totp.py +++ b/passlib/tests/test_totp.py @@ -372,6 +372,8 @@ class AppWalletTest(TestCase): wallet.encrypt_cost += 3 delta2, _ = time_call(partial(wallet.encrypt_key, KEY1_RAW), maxtime=0) + # TODO: rework timing test here to inject mock pbkdf2_hmac() function instead; + # and test that it's being invoked w/ proper options. self.assertAlmostEqual(delta2, delta*8, delta=(delta*8)*0.5) #============================================================================= diff --git a/passlib/tests/test_utils.py b/passlib/tests/test_utils.py index 59e7c01..36fcf43 100644 --- a/passlib/tests/test_utils.py +++ b/passlib/tests/test_utils.py @@ -9,7 +9,7 @@ import warnings # site # pkg # module -from passlib.utils import is_ascii_safe +from passlib.utils import is_ascii_safe, to_bytes from passlib.utils.compat import irange, PY2, PY3, unicode, join_bytes, PYPY from passlib.tests.utils import TestCase, hb, run_with_fixed_seeds @@ -169,41 +169,66 @@ class MiscTest(TestCase): def test_crypt(self): """test crypt.crypt() wrappers""" from passlib.utils import has_crypt, safe_crypt, test_crypt + from passlib.registry import get_supported_os_crypt_schemes, get_crypt_handler # test everything is disabled + supported = get_supported_os_crypt_schemes() if not has_crypt: + self.assertEqual(supported, ()) self.assertEqual(safe_crypt("test", "aa"), None) - self.assertFalse(test_crypt("test", "aaqPiZY5xR5l.")) + self.assertFalse(test_crypt("test", "aaqPiZY5xR5l.")) # des_crypt() hash of "test" raise self.skipTest("crypt.crypt() not available") - # XXX: this assumes *every* crypt() implementation supports des_crypt. - # if this fails for some platform, this test will need modifying. - - # test return type - self.assertIsInstance(safe_crypt(u"test", u"aa"), unicode) - - # test ascii password - h1 = u'aaqPiZY5xR5l.' - self.assertEqual(safe_crypt(u'test', u'aa'), h1) - self.assertEqual(safe_crypt(b'test', b'aa'), h1) + # expect there to be something supported, if crypt() is present + if not supported: + # NOTE: failures here should be investigated. usually means one of: + # 1) at least one of passlib's os_crypt detection routines is giving false negative + # 2) crypt() ONLY supports some hash alg which passlib doesn't know about + # 3) crypt() is present but completely disabled (never encountered this yet) + raise self.fail("crypt() present, but no supported schemes found!") + + # pick cheap alg if possible, with minimum rounds, to speed up this test. + # NOTE: trusting hasher class works properly (should have been verified using it's own UTs) + for scheme in ("md5_crypt", "sha256_crypt"): + if scheme in supported: + break + else: + scheme = supported[-1] + hasher = get_crypt_handler(scheme) + if getattr(hasher, "min_rounds", None): + hasher = hasher.using(rounds=hasher.min_rounds) + + # helpers to generate hashes & config strings to work with + def get_hash(secret): + assert isinstance(secret, unicode) + hash = hasher.hash(secret) + if isinstance(hash, bytes): # py2 + hash = hash.decode("utf-8") + assert isinstance(hash, unicode) + return hash + + # test ascii password & return type + s1 = u"test" + h1 = get_hash(s1) + result = safe_crypt(s1, h1) + self.assertIsInstance(result, unicode) + self.assertEqual(result, h1) + self.assertEqual(safe_crypt(to_bytes(s1), to_bytes(h1)), h1) + + # make sure crypt doesn't just blindly return h1 for whatever we pass in + h1x = h1[:-2] + 'xx' + self.assertEqual(safe_crypt(s1, h1x), h1) # test utf-8 / unicode password - h2 = u'aahWwbrUsKZk.' - self.assertEqual(safe_crypt(u'test\u1234', 'aa'), h2) - self.assertEqual(safe_crypt(b'test\xe1\x88\xb4', 'aa'), h2) - - # test latin-1 password - hash = safe_crypt(b'test\xff', 'aa') - if PY3: # py3 supports utf-8 bytes only. - self.assertEqual(hash, None) - else: # but py2 is fine. - self.assertEqual(hash, u'aaOx.5nbTU/.M') + s2 = u'test\u1234' + h2 = get_hash(s2) + self.assertEqual(safe_crypt(s2, h2), h2) + self.assertEqual(safe_crypt(to_bytes(s2), to_bytes(h2)), h2) # test rejects null chars in password - self.assertRaises(ValueError, safe_crypt, '\x00', 'aa') + self.assertRaises(ValueError, safe_crypt, '\x00', h1) # check test_crypt() - h1x = h1[:-1] + 'x' self.assertTrue(test_crypt("test", h1)) self.assertFalse(test_crypt("test", h1x)) @@ -213,13 +238,17 @@ class MiscTest(TestCase): import passlib.utils as mod orig = mod._crypt try: - fake = None - mod._crypt = lambda secret, hash: fake - for fake in [None, "", ":", ":0", "*0"]: - self.assertEqual(safe_crypt("test", "aa"), None) + retval = None + mod._crypt = lambda secret, hash: retval + + for retval in [None, "", ":", ":0", "*0"]: + self.assertEqual(safe_crypt("test", h1), None) self.assertFalse(test_crypt("test", h1)) - fake = 'xxx' - self.assertEqual(safe_crypt("test", "aa"), "xxx") + + retval = 'xxx' + self.assertEqual(safe_crypt("test", h1), "xxx") + self.assertFalse(test_crypt("test", h1)) + finally: mod._crypt = orig @@ -413,6 +442,125 @@ class MiscTest(TestCase): self.assertEqual(splitcomma(" a , b"), ['a', 'b']) self.assertEqual(splitcomma(" a, b, "), ['a', 'b']) + def test_utf8_truncate(self): + """ + utf8_truncate() + """ + from passlib.utils import utf8_truncate + + # + # run through a bunch of reference strings, + # and make sure they truncate properly across all possible indexes + # + for source in [ + # empty string + b"", + # strings w/ only single-byte chars + b"1", + b"123", + b'\x1a', + b'\x1a' * 10, + b'\x7f', + b'\x7f' * 10, + # strings w/ properly formed UTF8 continuation sequences + b'a\xc2\xa0\xc3\xbe\xc3\xbe', + b'abcdefghjusdfaoiu\xc2\xa0\xc3\xbe\xc3\xbedsfioauweoiruer', + ]: + source.decode("utf-8") # sanity check - should always be valid UTF8 + + end = len(source) + for idx in range(end + 16): + prefix = "source=%r index=%r: " % (source, idx) + + result = utf8_truncate(source, idx) + + # result should always be valid utf-8 + result.decode("utf-8") + + # result should never be larger than source + self.assertLessEqual(len(result), end, msg=prefix) + + # result should always be in range(idx, idx+4) + self.assertGreaterEqual(len(result), min(idx, end), msg=prefix) + self.assertLess(len(result), min(idx + 4, end + 1), msg=prefix) + + # should be strict prefix of source + self.assertEqual(result, source[:len(result)], msg=prefix) + + # + # malformed utf8 -- + # strings w/ only initial chars (should cut just like single-byte chars) + # + for source in [ + b'\xca', + b'\xca' * 10, + # also test null bytes (not valid utf8, but this func should treat them like ascii) + b'\x00', + b'\x00' * 10, + ]: + end = len(source) + for idx in range(end + 16): + prefix = "source=%r index=%r: " % (source, idx) + result = utf8_truncate(source, idx) + self.assertEqual(result, source[:idx], msg=prefix) + + # + # malformed utf8 -- + # strings w/ only continuation chars (should cut at index+3) + # + for source in [ + b'\xaa', + b'\xaa' * 10, + ]: + end = len(source) + for idx in range(end + 16): + prefix = "source=%r index=%r: " % (source, idx) + result = utf8_truncate(source, idx) + self.assertEqual(result, source[:idx+3], msg=prefix) + + # + # string w/ some invalid utf8 -- + # * \xaa byte is too many continuation byte after \xff start byte + # * \xab byte doesn't have preceding start byte + # XXX: could also test continuation bytes w/o start byte, WITHIN the string. + # but think this covers edges well enough... + # + source = b'MN\xff\xa0\xa1\xa2\xaaOP\xab' + + self.assertEqual(utf8_truncate(source, 0), b'') # index="M", stops there + + self.assertEqual(utf8_truncate(source, 1), b'M') # index="N", stops there + + self.assertEqual(utf8_truncate(source, 2), b'MN') # index="\xff", stops there + + self.assertEqual(utf8_truncate(source, 3), + b'MN\xff\xa0\xa1\xa2') # index="\xa0", runs out after index+3="\xa2" + + self.assertEqual(utf8_truncate(source, 4), + b'MN\xff\xa0\xa1\xa2\xaa') # index="\xa1", runs out after index+3="\xaa" + + self.assertEqual(utf8_truncate(source, 5), + b'MN\xff\xa0\xa1\xa2\xaa') # index="\xa2", stops before "O" + + self.assertEqual(utf8_truncate(source, 6), + b'MN\xff\xa0\xa1\xa2\xaa') # index="\xaa", stops before "O" + + self.assertEqual(utf8_truncate(source, 7), + b'MN\xff\xa0\xa1\xa2\xaa') # index="O", stops there + + self.assertEqual(utf8_truncate(source, 8), + b'MN\xff\xa0\xa1\xa2\xaaO') # index="P", stops there + + self.assertEqual(utf8_truncate(source, 9), + b'MN\xff\xa0\xa1\xa2\xaaOP\xab') # index="\xab", runs out at end + + self.assertEqual(utf8_truncate(source, 10), + b'MN\xff\xa0\xa1\xa2\xaaOP\xab') # index=end + + self.assertEqual(utf8_truncate(source, 11), + b'MN\xff\xa0\xa1\xa2\xaaOP\xab') # index=end+1 + + #============================================================================= # byte/unicode helpers #============================================================================= diff --git a/passlib/tests/utils.py b/passlib/tests/utils.py index 1ba7449..6ab89f0 100644 --- a/passlib/tests/utils.py +++ b/passlib/tests/utils.py @@ -356,6 +356,8 @@ class TestCase(_TestCase): def setUp(self): super(TestCase, self).setUp() self.setUpWarnings() + # have uh.debug_only_repr() return real values for duration of test + self.patchAttr(exc, "ENABLE_DEBUG_ONLY_REPR", True) def setUpWarnings(self): """helper to init warning filters before subclass setUp()""" @@ -364,11 +366,15 @@ class TestCase(_TestCase): ctx.__enter__() self.addCleanup(ctx.__exit__) + # ignore security warnings, tests may deliberately cause these + warnings.filterwarnings("ignore", category=exc.PasslibSecurityWarning) + # ignore warnings about PasswordHash features deprecated in 1.7 # TODO: should be cleaned in 2.0, when support will be dropped. # should be kept until then, so we test the legacy paths. warnings.filterwarnings("ignore", r"the method .*\.(encrypt|genconfig|genhash)\(\) is deprecated") warnings.filterwarnings("ignore", r"the 'vary_rounds' option is deprecated") + warnings.filterwarnings("ignore", r"Support for `(py-bcrypt|bcryptor)` is deprecated") #--------------------------------------------------------------- # tweak message formatting so longMessage mode is only enabled @@ -662,6 +668,18 @@ class TestCase(_TestCase): wraps(orig)(value) setattr(obj, attr, value) + def getLogger(self): + """ + return logger named after current test. + """ + cls = type(self) + # NOTE: conditional on qualname for PY2 compat + path = cls.__module__ + "." + getattr(cls, "__qualname__", cls.__name__) + name = self._testMethodName + if name: + path = path + "." + name + return logging.getLogger(path) + #=================================================================== # eoc #=================================================================== @@ -669,8 +687,22 @@ class TestCase(_TestCase): #============================================================================= # other unittest helpers #============================================================================= + RESERVED_BACKEND_NAMES = ["any", "default"] + +def doesnt_require_backend(func): + """ + decorator for HandlerCase.create_backend_case() -- + used to decorate methods that should be run even if backend isn't present + (by default, full test suite is skipped when backend is missing) + + NOTE: tests decorated with this should not rely on handler have expected (or any!) backend. + """ + func._doesnt_require_backend = True + return func + + class HandlerCase(TestCase): """base class for testing password hash handlers (esp passlib.utils.handlers subclasses) @@ -913,7 +945,8 @@ class HandlerCase(TestCase): # and other backend helpers #--------------------------------------------------------------- - BACKEND_NOT_AVAILABLE = "backend not available" + #: default message used by _get_skip_backend_reason() + _BACKEND_NOT_AVAILABLE = "backend not available" @classmethod def _get_skip_backend_reason(cls, backend): @@ -926,7 +959,7 @@ class HandlerCase(TestCase): return "only default backend is being tested" if handler.has_backend(backend): return None - return cls.BACKEND_NOT_AVAILABLE + return cls._BACKEND_NOT_AVAILABLE @classmethod def create_backend_case(cls, backend): @@ -943,28 +976,51 @@ class HandlerCase(TestCase): dict( descriptionPrefix="%s (%s backend)" % (name, backend), backend=backend, + _skip_backend_reason=cls._get_skip_backend_reason(backend), __module__=cls.__module__, ) ) - skip_reason = cls._get_skip_backend_reason(backend) - if skip_reason: - subcls = skip(skip_reason)(subcls) return subcls + #: flag for setUp() indicating this class is disabled due to backend issue; + #: this is only set for dynamic subclasses generated by create_backend_case() + _skip_backend_reason = None + + def _test_requires_backend(self): + """ + check if current test method decorated with doesnt_require_backend() helper + """ + meth = getattr(self, self._testMethodName, None) + return not getattr(meth, "_doesnt_require_backend", False) + #=================================================================== # setup #=================================================================== def setUp(self): + + # check if test is disabled due to missing backend; + # and that it wasn't exempted via @doesnt_require_backend() decorator + test_requires_backend = self._test_requires_backend() + if test_requires_backend and self._skip_backend_reason: + raise self.skipTest(self._skip_backend_reason) + super(HandlerCase, self).setUp() # if needed, select specific backend for duration of test + # NOTE: skipping this if create_backend_case() signalled we're skipping backend + # (can only get here for @doesnt_require_backend decorated methods) handler = self.handler backend = self.backend if backend: if not hasattr(handler, "set_backend"): raise RuntimeError("handler doesn't support multiple backends") - self.addCleanup(handler.set_backend, handler.get_backend()) - handler.set_backend(backend) + try: + self.addCleanup(handler.set_backend, handler.get_backend()) + handler.set_backend(backend) + except uh.exc.MissingBackendError: + if test_requires_backend: + raise + # else test is decorated with @doesnt_require_backend, let it through. # patch some RNG references so they're reproducible. from passlib.utils import handlers @@ -2687,7 +2743,9 @@ class HandlerCase(TestCase): failed[0] += 1 raise def launch(n): - name = "Fuzz-Thread-%d" % (n,) + cls = type(self) + name = "Fuzz-Thread-%d ('%s:%s.%s')" % (n, cls.__module__, cls.__name__, + self._testMethodName) thread = threading.Thread(target=wrapper, name=name) thread.setDaemon(True) thread.start() @@ -2843,7 +2901,6 @@ class HandlerCase(TestCase): def gendict(map): out = {} for key, meth in map.items(): - func = getattr(self, meth) value = getattr(self, meth)() if value is not None: out[key] = value @@ -3073,12 +3130,6 @@ class OsCryptMixin(HandlerCase): # list of (platform_regex, True|False|None) entries. platform_crypt_support = [] - #: flag indicating backend provides a fallback when safe_crypt() can't handle password - has_os_crypt_fallback = True - - #: alternate handler to use when searching for backend to fake safe_crypt() support. - alt_safe_crypt_handler = None - #=================================================================== # instance attrs #=================================================================== @@ -3096,6 +3147,7 @@ class OsCryptMixin(HandlerCase): def setUp(self): assert self.backend == "os_crypt" if not self.handler.has_backend("os_crypt"): + # XXX: currently, any tests that use this are skipped entirely! (see issue 120) self._patch_safe_crypt() super(OsCryptMixin, self).setUp() @@ -3106,9 +3158,7 @@ class OsCryptMixin(HandlerCase): backend will be None if none availabe. """ # find handler that generates safe_crypt() compatible hash - handler = cls.alt_safe_crypt_handler - if not handler: - handler = unwrap_handler(cls.handler) + handler = unwrap_handler(cls.handler) # hack to prevent recursion issue when .has_backend() is called handler.get_backend() @@ -3117,6 +3167,14 @@ class OsCryptMixin(HandlerCase): alt_backend = get_alt_backend(handler, "os_crypt") return handler, alt_backend + @property + def has_os_crypt_fallback(self): + """ + test if there's a fallback handler to test against if os_crypt can't support + a specified secret (may be explicitly set to False for some subclasses) + """ + return self._get_safe_crypt_handler_backend()[0] is not None + def _patch_safe_crypt(self): """if crypt() doesn't support current hash alg, this patches safe_crypt() so that it transparently uses another one of the handler's @@ -3151,7 +3209,7 @@ class OsCryptMixin(HandlerCase): reason = super(OsCryptMixin, cls)._get_skip_backend_reason(backend) from passlib.utils import has_crypt - if reason == cls.BACKEND_NOT_AVAILABLE and has_crypt: + if reason == cls._BACKEND_NOT_AVAILABLE and has_crypt: if TEST_MODE("full") and cls._get_safe_crypt_handler_backend()[1]: # in this case, _patch_safe_crypt() will monkeypatch os_crypt # to use another backend, just so we can test os_crypt fully. @@ -3191,7 +3249,7 @@ class OsCryptMixin(HandlerCase): def test_80_faulty_crypt(self): """test with faulty crypt()""" hash = self.get_sample_hash()[1] - exc_types = (AssertionError,) + exc_types = (exc.InternalBackendError,) mock_crypt = self._use_mock_crypt() def test(value): @@ -3221,40 +3279,60 @@ class OsCryptMixin(HandlerCase): self.assertTrue(self.do_verify("stub", h1)) else: # handler should give up - from passlib.exc import MissingBackendError + from passlib.exc import InternalBackendError as err_type hash = self.get_sample_hash()[1] - self.assertRaises(MissingBackendError, self.do_encrypt, 'stub') - self.assertRaises(MissingBackendError, self.do_genhash, 'stub', hash) - self.assertRaises(MissingBackendError, self.do_verify, 'stub', hash) + self.assertRaises(err_type, self.do_encrypt, 'stub') + self.assertRaises(err_type, self.do_genhash, 'stub', hash) + self.assertRaises(err_type, self.do_verify, 'stub', hash) + @doesnt_require_backend def test_82_crypt_support(self): - """test platform-specific crypt() support detection""" - # NOTE: this is mainly just a sanity check to ensure the runtime - # detection is functioning correctly on some known platforms, - # so that I can feel more confident it'll work right on unknown ones. + """ + test platform-specific crypt() support detection + + NOTE: this is mainly just a sanity check to ensure the runtime + detection is functioning correctly on some known platforms, + so that we can feel more confident it'll work right on unknown ones. + """ + + # skip wrapper handlers, won't ever have crypt support if hasattr(self.handler, "orig_prefix"): raise self.skipTest("not applicable to wrappers") + + # look for first entry that matches current system + # XXX: append "/" + platform.release() to string? + # XXX: probably should rework to support rows being dicts w/ "minver" / "maxver" keys, + # instead of hack where we add major # as part of platform regex. + using_backend = not self.using_patched_crypt + name = self.handler.name platform = sys.platform - for pattern, state in self.platform_crypt_support: + for pattern, expected in self.platform_crypt_support: if re.match(pattern, platform): break else: - raise self.skipTest("no data for %r platform" % platform) - if state is None: - # e.g. platform='freebsd8' ... sha256_crypt not added until 8.3 - raise self.skipTest("varied support on %r platform" % platform) - elif state != self.using_patched_crypt: - return - elif state: - self.fail("expected %r platform would have native support " - "for %r" % (platform, self.handler.name)) + raise self.skipTest("no data for %r platform (current host support = %r)" % + (platform, using_backend)) + + # rules can use "state=None" to signal varied support; + # e.g. platform='freebsd8' ... sha256_crypt not added until 8.3 + if expected is None: + raise self.skipTest("varied support on %r platform (current host support = %r)" % + (platform, using_backend)) + + # compare expectation vs reality + if expected == using_backend: + pass + elif expected: + self.fail("expected %r platform would have native support for %r" % + (platform, name)) else: - self.fail("did not expect %r platform would have native support " - "for %r" % (platform, self.handler.name)) + self.fail("did not expect %r platform would have native support for %r" % + (platform, name)) #=================================================================== - # fuzzy verified support -- add new verified that uses os crypt() + # fuzzy verified support -- add additional verifier that uses os crypt() #=================================================================== + def fuzz_verifier_crypt(self): """test results against OS crypt()""" @@ -3266,14 +3344,17 @@ class OsCryptMixin(HandlerCase): # create a wrapper for fuzzy verified to use from crypt import crypt + from passlib.utils import _safe_crypt_lock encoding = self.FuzzHashGenerator.password_encoding def check_crypt(secret, hash): """stdlib-crypt""" if not self.crypt_supports_variant(hash): return "skip" + # XXX: any reason not to use safe_crypt() here? or just want to test against bare metal? secret = to_native_str(secret, encoding) - return crypt(secret, hash) == hash + with _safe_crypt_lock: + return crypt(secret, hash) == hash return check_crypt diff --git a/passlib/totp.py b/passlib/totp.py index a5027b4..14573e3 100644 --- a/passlib/totp.py +++ b/passlib/totp.py @@ -1152,7 +1152,7 @@ class TOTP(object): Serialized TOTP key. Can be anything accepted by :meth:`TOTP.from_source`. - :param \*\*kwds: + :param \\*\\*kwds: All additional keywords passed to :meth:`TOTP.match`. :return: diff --git a/passlib/utils/__init__.py b/passlib/utils/__init__.py index 3f85389..0c950ba 100644 --- a/passlib/utils/__init__.py +++ b/passlib/utils/__init__.py @@ -36,6 +36,11 @@ else: import time if stringprep: import unicodedata +try: + import threading +except ImportError: + # module optional before py37 + threading = None import timeit import types from warnings import warn @@ -55,12 +60,12 @@ from passlib.utils.decor import ( classproperty, hybrid_method, ) -from passlib.exc import ExpectedStringError +from passlib.exc import ExpectedStringError, ExpectedTypeError from passlib.utils.compat import (add_doc, join_bytes, join_byte_values, join_byte_elems, irange, imap, PY3, join_unicode, unicode, byte_elem_value, nextgetter, - unicode_or_bytes_types, - get_method_function, suppress_cause) + unicode_or_str, unicode_or_bytes_types, + get_method_function, suppress_cause, PYPY) # local __all__ = [ # constants @@ -141,7 +146,7 @@ class SequenceMixin(object): subclass just needs to provide :meth:`_as_tuple()`. """ def _as_tuple(self): - raise NotImplemented("implement in subclass") + raise NotImplementedError("implement in subclass") def __repr__(self): return repr(self._as_tuple()) @@ -571,13 +576,20 @@ def xor_bytes(left, right): return int_to_bytes(bytes_to_int(left) ^ bytes_to_int(right), len(left)) def repeat_string(source, size): - """repeat or truncate <source> string, so it has length <size>""" - cur = len(source) - if size > cur: - mult = (size+cur-1)//cur - return (source*mult)[:size] - else: - return source[:size] + """ + repeat or truncate <source> string, so it has length <size> + """ + mult = 1 + (size - 1) // len(source) + return (source * mult)[:size] + + +def utf8_repeat_string(source, size): + """ + variant of repeat_string() which truncates to nearest UTF8 boundary. + """ + mult = 1 + (size - 1) // len(source) + return utf8_truncate(source * mult, size) + _BNULL = b"\x00" _UNULL = u"\x00" @@ -592,6 +604,74 @@ def right_pad_string(source, size, pad=None): else: return source[:size] + +def utf8_truncate(source, index): + """ + helper to truncate UTF8 byte string to nearest character boundary ON OR AFTER <index>. + returned prefix will always have length of at least <index>, and will stop on the + first byte that's not a UTF8 continuation byte (128 - 191 inclusive). + since utf8 should never take more than 4 bytes to encode known unicode values, + we can stop after ``index+3`` is reached. + + :param bytes source: + :param int index: + :rtype: bytes + """ + # general approach: + # + # * UTF8 bytes will have high two bits (0xC0) as one of: + # 00 -- ascii char + # 01 -- ascii char + # 10 -- continuation of multibyte char + # 11 -- start of multibyte char. + # thus we can cut on anything where high bits aren't "10" (0x80; continuation byte) + # + # * UTF8 characters SHOULD always be 1 to 4 bytes, though they may be unbounded. + # so we just keep going until first non-continuation byte is encountered, or end of str. + # this should work predictably even for malformed/non UTF8 inputs. + + if not isinstance(source, bytes): + raise ExpectedTypeError(source, bytes, "source") + + # validate index + end = len(source) + if index < 0: + index = max(0, index + end) + if index >= end: + return source + + # can stop search after 4 bytes, won't ever have longer utf8 sequence. + end = min(index + 3, end) + + # loop until we find non-continuation byte + while index < end: + if byte_elem_value(source[index]) & 0xC0 != 0x80: + # found single-char byte, or start-char byte. + break + # else: found continuation byte. + index += 1 + else: + assert index == end + + # truncate at final index + result = source[:index] + + def sanity_check(): + # try to decode source + try: + text = source.decode("utf-8") + except UnicodeDecodeError: + # if source isn't valid utf8, byte level match is enough + return True + + # validate that result was cut on character boundary + assert text.startswith(result.decode("utf-8")) + return True + + assert sanity_check() + + return result + #============================================================================= # encoding helpers #============================================================================= @@ -753,17 +833,48 @@ def as_bool(value, none=None, param="boolean"): # host OS helpers #============================================================================= +def is_safe_crypt_input(value): + """ + UT helper -- + test if value is safe to pass to crypt.crypt(); + under PY3, can't pass non-UTF8 bytes to crypt.crypt. + """ + if crypt_accepts_bytes or not isinstance(value, bytes): + return True + try: + value.decode("utf-8") + return True + except UnicodeDecodeError: + 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 def safe_crypt(secret, hash): return None 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. @@ -772,27 +883,71 @@ else: _invalid_prefixes = u"*:!" if PY3: + + # * 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: # 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 isinstance(secret, bytes): - # Python 3's crypt() only accepts unicode, which is then + if crypt_accepts_bytes: + # PyPy3 -- all bytes accepted, but unicode encoded to ASCII, + # so handling that ourselves. + if isinstance(secret, unicode): + secret = secret.encode("utf-8") + if _BNULL in secret: + raise ValueError("null character in secret") + if isinstance(hash, unicode): + 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. - orig = secret - try: - secret = secret.decode("utf-8") - except UnicodeDecodeError: - return None - 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") - result = _crypt(secret, hash) + 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") + 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: return None return result else: + + #: see feature-detection in PY3 fork above + crypt_accepts_bytes = True + + # Python 2 crypt handler def safe_crypt(secret, hash): if isinstance(secret, unicode): secret = secret.encode("utf-8") @@ -800,7 +955,8 @@ else: raise ValueError("null character in secret") if isinstance(hash, unicode): hash = hash.encode("ascii") - result = _crypt(secret, hash) + with _safe_crypt_lock: + result = _crypt(secret, hash) if not result: return None result = result.decode("ascii") @@ -844,7 +1000,13 @@ def test_crypt(secret, hash): :arg hash: known hash of password to use as reference :returns: True or False """ - assert secret and hash + # safe_crypt() always returns unicode, which means that for py3, + # 'hash' can't be bytes, or "== hash" will never be True. + # under py2 unicode & str(bytes) will compare fine; + # so just enforcing "unicode_or_str" limitation + assert isinstance(hash, unicode_or_str), \ + "hash must be unicode_or_str, got %s" % type(hash) + assert hash, "hash must be non-empty" return safe_crypt(secret, hash) == hash timer = timeit.default_timer diff --git a/passlib/utils/compat/__init__.py b/passlib/utils/compat/__init__.py index 2cc1e5b..1cd4c8d 100644 --- a/passlib/utils/compat/__init__.py +++ b/passlib/utils/compat/__init__.py @@ -79,6 +79,9 @@ __all__ = [ # collections 'OrderedDict', + # context helpers + 'nullcontext', + # introspection 'get_method_function', 'add_doc', ] @@ -289,15 +292,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 #============================================================================= @@ -326,6 +334,28 @@ else: _lazy_attrs['OrderedDict'] = 'collections.OrderedDict' #============================================================================= +# context managers +#============================================================================= + +try: + # new in py37 + from contextlib import nullcontext +except ImportError: + + class nullcontext(object): + """ + Context manager that does no additional processing. + """ + def __init__(self, enter_result=None): + self.enter_result = enter_result + + def __enter__(self): + return self.enter_result + + def __exit__(self, *exc_info): + pass + +#============================================================================= # lazy overlay module #============================================================================= from types import ModuleType diff --git a/passlib/utils/handlers.py b/passlib/utils/handlers.py index e53a99f..748d27d 100644 --- a/passlib/utils/handlers.py +++ b/passlib/utils/handlers.py @@ -346,7 +346,6 @@ def mask_value(value, show=4, pct=0.125, char=u"*"): show = min(show, int(size * pct)) return value[:show] + char * (size - show) - #============================================================================= # parameter helpers #============================================================================= @@ -697,7 +696,7 @@ class GenericHandler(MinimalHandler): r""" return parsed instance from hash/configuration string - :param \*\*context: + :param \\*\\*context: context keywords to pass to constructor (if applicable). :raises ValueError: if hash is incorrectly formatted @@ -42,7 +42,7 @@ opts = dict( author_email="elic@assurancetechnologies.com", license="BSD", - url="https://bitbucket.org/ecollins/passlib", + url="https://passlib.readthedocs.io", # NOTE: 'download_url' set below extras_require={ @@ -60,7 +60,7 @@ opts = dict( "build_docs": [ "sphinx>=1.6", "sphinxcontrib-fulltoc>=1.2.0", - "cloud_sptheme>=1.10.0", + "cloud_sptheme>=1.10.1", ], }, @@ -80,7 +80,7 @@ providing full-strength password hashing for multi-user applications. * See the `documentation <https://passlib.readthedocs.io>`_ for details, installation instructions, and examples. -* See the `homepage <https://bitbucket.org/ecollins/passlib>`_ +* See the `homepage <https://foss.heptapod.net/python-libs/passlib/wikis/home>`_ for the latest news and more information. * See the `changelog <https://passlib.readthedocs.io/en/stable/history>`_ @@ -113,6 +113,7 @@ Programming Language :: Python :: 3.5 Programming Language :: Python :: 3.6 Programming Language :: Python :: 3.7 Programming Language :: Python :: 3.8 +Programming Language :: Python :: 3.9 Programming Language :: Python :: Implementation :: CPython Programming Language :: Python :: Implementation :: Jython Programming Language :: Python :: Implementation :: PyPy @@ -47,7 +47,7 @@ envlist = # TODO: would like to 'default-pyston' but doesnt quite work # TODO: also add default-jython27 # NOTE: removed 2.6 & 3.3 as of 2019-11, tox+pip no longer work for these versions. - default-py{27,34,35,36,37,38,py,py3}, + default-py{27,34,35,36,37,38,39,py,py3}, # pbkdf2 backend testing # NOTE: 'hashlib' takes priority under py34+ @@ -83,7 +83,6 @@ envlist = django-{wdeps,nodeps}-py{2,3}, # other tests - gae-py27 docs #=========================================================================== @@ -102,6 +101,7 @@ basepython = py36: python3.6 py37: python3.7 py38: python3.8 + py39: python3.9 pypy: pypy pypy3: pypy3 @@ -120,9 +120,12 @@ setenv = bcrypthash-builtin: PASSLIB_BUILTIN_BCRYPT = enabled bcrypthash-disabled: PASSLIB_TEST_MODE = quick + # option that depends on rednose (see below) + !py33-!py34: HIDE_SKIPS = --hide-skips + # nose option fragments with_coverage: TEST_COVER_OPTS = --with-xunit --with-coverage --cover-xml --cover-package passlib - TEST_OPTS = --hide-skips --randomize {env:TEST_COVER_OPTS:} + TEST_OPTS = {env:HIDE_SKIPS:} --randomize {env:TEST_COVER_OPTS:} changedir = {envdir} commands = @@ -143,7 +146,7 @@ commands = deps = # common nose - rednose + !py33-!py34: rednose coverage randomize unittest2 @@ -151,7 +154,7 @@ deps = # totp helper tests # NOTE: cryptography requires python-dev, libffi-dev, libssl-dev # XXX: 2016-6-20: having issue w/ cryptography under pypy, disabling it for now - default-py{2,26,27,3,33,34,35,36}: cryptography + default-!pypy{,3}: cryptography # pbkdf2 backend tests # NOTE: fastpbkdf2 requires python-dev, libffi-dev, libssl-dev @@ -190,29 +193,6 @@ deps = django{,1x,18}: mock #=========================================================================== -# Google App Engine integration -# -# NOTE: for this to work, the GAE SDK should be installed in -# /usr/local/google_appengine, or set nosegae's --gae-lib-root -# -# NOTE: not run by default -#=========================================================================== -[testenv:gae-py27] -basepython = python2.7 -deps = - nose - rednose - nosegae - unittest2 -changedir = {envdir}/lib/python2.7/site-packages -commands = - # setup custom app.yaml so GAE can run - python -m passlib.tests.tox_support setup_gae . python27 - - # run tests - nosetests --with-gae {posargs:passlib/tests} - -#=========================================================================== # build documentation #=========================================================================== [testenv:docs] |