summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorEli Collins <elic@assurancetechnologies.com>2020-10-06 12:14:04 -0400
committerEli Collins <elic@assurancetechnologies.com>2020-10-06 12:14:04 -0400
commit0a25f664189515086d12aabacc0275b6b2ba209a (patch)
tree5e67f582d500d3f6fb7e57d8f6a58e78d3d99155
parent15f0486c44428351ba45e57e541e3e5c04019e01 (diff)
parentbf7afc81eff400c835fc43505ff6e2f91bf73fa9 (diff)
downloadpasslib-0a25f664189515086d12aabacc0275b6b2ba209a.tar.gz
Merge from stable
-rw-r--r--LICENSE2
-rw-r--r--README16
-rw-r--r--docs/conf.py6
-rw-r--r--docs/dev-requirements.txt2
-rw-r--r--docs/history/1.7.rst113
-rw-r--r--docs/history/index.rst10
-rw-r--r--docs/index.rst29
-rw-r--r--docs/install.rst16
-rw-r--r--docs/lib/passlib.exc.rst4
-rw-r--r--docs/lib/passlib.hash.argon2.rst9
-rw-r--r--docs/lib/passlib.hash.bcrypt.rst4
-rw-r--r--docs/lib/passlib.hash.bcrypt_sha256.rst39
-rw-r--r--docs/lib/passlib.hash.django_std.rst2
-rw-r--r--docs/lib/passlib.hash.ldap_std.rst5
-rw-r--r--docs/lib/passlib.hash.rst8
-rw-r--r--docs/lib/passlib.hash.sha256_crypt.rst6
-rw-r--r--docs/requirements.txt2
-rw-r--r--passlib/apache.py4
-rw-r--r--passlib/apps.py14
-rw-r--r--passlib/context.py14
-rw-r--r--passlib/crypto/digest.py221
-rw-r--r--passlib/exc.py96
-rw-r--r--passlib/ext/django/utils.py5
-rw-r--r--passlib/handlers/bcrypt.py369
-rw-r--r--passlib/handlers/des_crypt.py16
-rw-r--r--passlib/handlers/digests.py38
-rw-r--r--passlib/handlers/ldap_digests.py91
-rw-r--r--passlib/handlers/md5_crypt.py8
-rw-r--r--passlib/handlers/sha1_crypt.py8
-rw-r--r--passlib/handlers/sha2_crypt.py63
-rw-r--r--passlib/hash.py2
-rw-r--r--passlib/ifc.py4
-rw-r--r--passlib/pwd.py4
-rw-r--r--passlib/registry.py7
-rw-r--r--passlib/tests/test_crypto_digest.py55
-rw-r--r--passlib/tests/test_crypto_scrypt.py2
-rw-r--r--passlib/tests/test_ext_django.py47
-rw-r--r--passlib/tests/test_handlers.py156
-rw-r--r--passlib/tests/test_handlers_bcrypt.py180
-rw-r--r--passlib/tests/test_handlers_django.py15
-rw-r--r--passlib/tests/test_registry.py7
-rw-r--r--passlib/tests/test_totp.py2
-rw-r--r--passlib/tests/test_utils.py208
-rw-r--r--passlib/tests/utils.py165
-rw-r--r--passlib/totp.py2
-rw-r--r--passlib/utils/__init__.py216
-rw-r--r--passlib/utils/compat/__init__.py34
-rw-r--r--passlib/utils/handlers.py3
-rw-r--r--setup.py7
-rw-r--r--tox.ini36
50 files changed, 1954 insertions, 418 deletions
diff --git a/LICENSE b/LICENSE
index 00170b5..48c0f72 100644
--- a/LICENSE
+++ b/LICENSE
@@ -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
diff --git a/README b/README
index c00296d..abd701f 100644
--- a/README
+++ b/README
@@ -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
diff --git a/setup.py b/setup.py
index f715240..5310d4f 100644
--- a/setup.py
+++ b/setup.py
@@ -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
diff --git a/tox.ini b/tox.ini
index 8a99d6b..80d4981 100644
--- a/tox.ini
+++ b/tox.ini
@@ -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]