summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorEli Collins <elic@assurancetechnologies.com>2012-04-17 23:14:51 -0400
committerEli Collins <elic@assurancetechnologies.com>2012-04-17 23:14:51 -0400
commit64ab6fc89b497efa9169f11d55251e417c4db0ba (patch)
treeb3f6f5dc27b87a6bc90cb3686fa98239ee8ff053
parent8eb4c4d3b58eec6802c698ddbf357b2fd243a68c (diff)
parentcd029846fdc0c3d7ffc7f53caad4579e7e0e8725 (diff)
downloadpasslib-ironpython-support-dev.tar.gz
Merge from defaultironpython-support-dev
-rw-r--r--.hgignore2
-rw-r--r--CHANGES61
-rw-r--r--admin/benchmarks.py257
-rw-r--r--admin/gae-test-app.yaml9
-rw-r--r--docs/lib/passlib.apache.rst33
-rw-r--r--docs/lib/passlib.context-interface.rst17
-rw-r--r--docs/lib/passlib.context-options.rst103
-rw-r--r--docs/lib/passlib.context-usage.rst80
-rw-r--r--docs/lib/passlib.ext.django.rst2
-rw-r--r--docs/lib/passlib.hash.bsdi_crypt.rst27
-rw-r--r--docs/lib/passlib.hosts.rst4
-rw-r--r--docs/lib/passlib.utils.des.rst2
-rw-r--r--docs/lib/passlib.utils.rst2
-rw-r--r--docs/modular_crypt_format.rst2
-rw-r--r--passlib/apache.py1142
-rw-r--r--passlib/apps.py2
-rw-r--r--passlib/context.py2554
-rw-r--r--passlib/exc.py26
-rw-r--r--passlib/ext/django/models.py4
-rw-r--r--passlib/handlers/bcrypt.py13
-rw-r--r--passlib/handlers/cisco.py2
-rw-r--r--passlib/handlers/des_crypt.py245
-rw-r--r--passlib/handlers/digests.py62
-rw-r--r--passlib/handlers/fshp.py8
-rw-r--r--passlib/handlers/ldap_digests.py2
-rw-r--r--passlib/handlers/md5_crypt.py7
-rw-r--r--passlib/handlers/misc.py2
-rw-r--r--passlib/handlers/pbkdf2.py21
-rw-r--r--passlib/handlers/phpass.py2
-rw-r--r--passlib/handlers/scram.py10
-rw-r--r--passlib/handlers/sha2_crypt.py3
-rw-r--r--passlib/hosts.py23
-rw-r--r--passlib/registry.py12
-rw-r--r--passlib/tests/sample1.cfg9
-rw-r--r--passlib/tests/sample1b.cfg9
-rw-r--r--passlib/tests/sample1c.cfgbin0 -> 490 bytes
-rw-r--r--passlib/tests/test_apache.py347
-rw-r--r--passlib/tests/test_apps.py8
-rw-r--r--passlib/tests/test_context.py1737
-rw-r--r--passlib/tests/test_context_deprecated.py1165
-rw-r--r--passlib/tests/test_ext_django.py144
-rw-r--r--passlib/tests/test_handlers.py272
-rw-r--r--passlib/tests/test_hosts.py2
-rw-r--r--passlib/tests/test_registry.py2
-rw-r--r--passlib/tests/test_utils.py514
-rw-r--r--passlib/tests/test_utils_crypto.py550
-rw-r--r--passlib/tests/tox_support.py37
-rw-r--r--passlib/tests/utils.py197
-rw-r--r--passlib/utils/__init__.py183
-rw-r--r--passlib/utils/compat.py10
-rw-r--r--passlib/utils/des.py353
-rw-r--r--passlib/utils/handlers.py24
-rw-r--r--passlib/utils/md4.py6
-rw-r--r--setup.py3
-rw-r--r--tox.ini82
55 files changed, 7070 insertions, 3325 deletions
diff --git a/.hgignore b/.hgignore
index 7e3cc34..157710f 100644
--- a/.hgignore
+++ b/.hgignore
@@ -1,4 +1,3 @@
-glob:docs/_build
glob:*.pyc
glob:*.egg-info
glob:.coverage
@@ -8,3 +7,4 @@ glob:dist
glob:*$py.class
glob:MANIFEST
glob:.tox
+glob:app.yaml
diff --git a/CHANGES b/CHANGES
index 866d845..4e6f7a2 100644
--- a/CHANGES
+++ b/CHANGES
@@ -53,6 +53,9 @@ Release History
* The :doc:`ldap salted digests </lib/passlib.hash.ldap_std>`
now support salts from 4-16 bytes [issue 30].
+ * :class:`bsdi_crypt` now issues a warning if an even number of rounds
+ is requested by the application, due to a known weakness in DES.
+
* All hashes will now throw :exc:`~passlib.exc.PasswordSizeError`
if the provided password is larger than 4096 characters.
@@ -66,32 +69,45 @@ Release History
.. currentmodule:: passlib.context
* :class:`~CryptContext` now supports a :ref:`passprep <passprep>` option,
- which runs all passwords through SASLPrep (:rfc:`4013`)
+ which can be used to run all passwords through SASLPrep (:rfc:`4013`),
in order to normalize their unicode representation before hashing
[issue 24].
- * Internals of :class:`CryptPolicy` have been
- re-written drastically. Should now be stricter (and more informative)
- about invalid values, and common :class:`CryptContext`
- operations should all have much shorter code-paths.
+ * The :class:`!CryptContext` option
+ :ref:`min_verify_time <min-verify-time>` has been deprecated,
+ will be ignored in release 1.7, and will be removed in release 1.8.
+
+ * The internals of :class:`!CryptContext` have been rewritten
+ drastically. It's methods should now be stricter and more informative
+ about invalid values; and common :class:`!CryptContext` operations
+ should be faster, and have shorter internal code paths.
- * Config parsing now done with :class:`SafeConfigParser`.
- :meth:`CryptPolicy.from_path` and :meth:`CryptPolicy.from_string`
- previously used :class:`!ConfigParser` interpolation.
- Release 1.5 switched to :class:`SafeConfigParser`,
+ * The :attr:`!CryptContext.policy` attr, and the supporting
+ :class:`!CryptPolicy` class, have been deprecated in their entirety.
+
+ They will not be removed until Passlib 1.8, to give applications
+ which used these features time to migrate. Applications which did
+ not use either of these features explicitly should be unaffected by
+ this change.
+
+ The functionality of :class:`!CryptPolicy` has been merged
+ into the :class:`CryptContext` class, in order to simplify
+ the exposed interface. Information on migrating can be found
+ in the :class:`CryptPolicy` documentation, as well as in
+ the :exc:`DeprecationWarning` messages issued when a :class:`!CryptPolicy`
+ is invoked.
+
+ * :meth:`CryptContext.from_path` and :meth:`CryptContext.from_string`
+ (and the legacy :class:`CryptPolicy` object) now use stdlib's
+ :class:`!SafeConfigParser`.
+
+ Previous releases used the original :class:`!ConfigParser` interpolation.
+ Passlib 1.5 switched to :class:`SafeConfigParser`,
but kept support for the old format as a (deprecated) fallback.
This fallback has been removed in 1.6; any
- legacy config files may need to escape raw ``%`` characters
+ legacy config files may need to double any raw ``%`` characters
in order to load successfully.
- * The main CryptContext methods (e.g. :meth:`~CryptContext.encrypt`,
- and :meth:`~CryptContext.verify`) will now consistently raise
- a :exc:`TypeError` when called with ``hash=None`` or another
- non-string type, to match the :doc:`password-hash-api`.
- Under previous releases, they might return ``False``,
- raise :exc:`ValueError`, or raise :exc:`TypeError`,
- depending on the specific method and context settings.
-
Utils
.. currentmodule:: passlib.utils.handlers
@@ -134,12 +150,13 @@ Release History
* deprecated some unused functions in :mod:`!passlib.utils`,
they will be removed in release 1.7.
- * The :class:`!CryptContext` option
- :ref:`min_verify_time <min-verify-time>` has been deprecated,
- will be ignored in release 1.7, and will be removed in release 1.8.
-
Other
+ * The api for the :mod:`passlib.apache` module has been updated
+ to add more flexibility, and to fix some ambiguous method
+ and keyword names. The old names are still supported, but deprecated,
+ and will be removed in Passlib 1.8.
+
* Handle platform-specific error strings returned by :func:`!crypt.crypt`.
* Passlib is now source-compatible with Python 2.5+ and Python 3,
diff --git a/admin/benchmarks.py b/admin/benchmarks.py
index 5cba45e..4b73ef2 100644
--- a/admin/benchmarks.py
+++ b/admin/benchmarks.py
@@ -7,15 +7,15 @@ parsing was being sped up. it could definitely be improved.
# init script env
#=============================================================================
import os, sys
-root_dir = os.path.join(os.path.dirname(__file__), os.path.pardir)
-sys.path.insert(0, root_dir)
+root = os.path.join(os.path.dirname(__file__), os.path.pardir)
+sys.path.insert(0, root)
#=============================================================================
# imports
#=============================================================================
# core
import logging; log = logging.getLogger(__name__)
-from timeit import Timer
+import os
import warnings
# site
# pkg
@@ -26,18 +26,99 @@ except ImportError:
import passlib.utils.handlers as uh
from passlib.utils.compat import u, print_, unicode
# local
-__all__ = [
-]
+
+#=============================================================================
+# benchmarking support
+#=============================================================================
+class benchmark:
+ "class to hold various benchmarking helpers"
+
+ @classmethod
+ def constructor(cls, **defaults):
+ """mark callable as something which should be benchmarked.
+ callable should return a function will be timed.
+ """
+ def marker(func):
+ if func.__doc__:
+ name = func.__doc__.splitlines()[0]
+ else:
+ name = func.__name__
+ func._benchmark_task = ("ctor", name, defaults)
+ return func
+ return marker
+
+ @classmethod
+ def run(cls, source, **defaults):
+ """run benchmark for all tasks in source, yielding result records"""
+ for obj in source.values():
+ for record in cls._run_object(obj, defaults):
+ yield record
+
+ @classmethod
+ def _run_object(cls, obj, defaults):
+ args = getattr(obj, "_benchmark_task", None)
+ if not args:
+ return
+ mode, name, options = args
+ kwds = defaults.copy()
+ kwds.update(options)
+ if mode == "ctor":
+ itr = obj()
+ if not hasattr(itr, "next"):
+ itr = [itr]
+ for func in itr:
+ # TODO: per function name & options
+ secs, precision = cls.measure(func, None, **kwds)
+ yield name, secs, precision
+ else:
+ raise ValueError("invalid mode: %r" % (mode,))
+
+ @staticmethod
+ def measure(func, setup=None, maxtime=1, bestof=3):
+ """timeit() wrapper which tries to get as accurate a measurement as
+ possible w/in maxtime seconds.
+
+ :returns:
+ ``(avg_seconds_per_call, log10_number_of_repetitions)``
+ """
+ from timeit import Timer
+ from math import log
+ timer = Timer(func, setup=setup or '')
+ number = 1
+ while True:
+ delta = min(timer.repeat(bestof, number))
+ maxtime -= delta*bestof
+ if maxtime < 0:
+ return delta/number, int(log(number, 10))
+ number *= 10
+
+ @staticmethod
+ def pptime(secs, precision=3):
+ """helper to pretty-print fractional seconds values"""
+ usec = int(secs * 1e6)
+ if usec < 1000:
+ return "%.*g usec" % (precision, usec)
+ msec = usec / 1000
+ if msec < 1000:
+ return "%.*g msec" % (precision, msec)
+ sec = msec / 1000
+ return "%.*g sec" % (precision, sec)
#=============================================================================
# utils
#=============================================================================
+sample_config_1p = os.path.join(root, "passlib", "tests", "sample_config_1s.cfg")
-class BlankHandler(uh.HasRounds, uh.HasSalt, uh.GenericHandler):
+from passlib.context import CryptContext
+if hasattr(CryptContext, "from_path"):
+ CryptPolicy = None
+else:
+ from passlib.context import CryptPolicy
- setting_kwds = ("rounds", "salt", "salt_size")
+class BlankHandler(uh.HasRounds, uh.HasSalt, uh.GenericHandler):
name = "blank"
ident = u("$b$")
+ setting_kwds = ("rounds", "salt", "salt_size")
checksum_size = 1
min_salt_size = max_salt_size = 1
@@ -62,99 +143,109 @@ class AnotherHandler(BlankHandler):
name = "another"
ident = u("$a$")
+SECRET = u("toomanysecrets")
+OTHER = u("setecastronomy")
+
#=============================================================================
-# crypt context tests
+# CryptContext benchmarks
#=============================================================================
-def setup_policy():
- import os
- from passlib.context import CryptPolicy
- test_path = os.path.join(root_dir, "passlib", "tests", "sample_config_1s.cfg")
-
- def test_policy_creation():
- with open(test_path, "rb") as fh:
- policy1 = CryptPolicy.from_string(fh.read())
- yield test_policy_creation
-
- default = CryptPolicy.from_path(test_path)
- def test_policy_composition():
- policy2 = default.replace(
- schemes = [ "sha512_crypt", "sha256_crypt", "md5_crypt",
- "des_crypt", "unix_fallback" ],
- deprecated = [ "des_crypt" ],
- )
- yield test_policy_composition
-
-secret = u("secret")
-other = u("other")
-
-def setup_context():
- from passlib.context import CryptContext
-
- def test_context_init():
- return CryptContext(
+@benchmark.constructor()
+def test_context_from_path():
+ "test speed of CryptContext.from_path()"
+ path = sample_config_1p
+ if CryptPolicy:
+ def helper():
+ CryptPolicy.from_path(path)
+ else:
+ def helper():
+ CryptContext.from_path(path)
+ return helper
+
+@benchmark.constructor()
+def test_context_update():
+ "test speed of CryptContext.update()"
+ kwds = dict(
+ schemes = [ "sha512_crypt", "sha256_crypt", "md5_crypt",
+ "des_crypt", "unix_fallback" ],
+ deprecated = [ "des_crypt" ],
+ sha512_crypt__min_rounds=4000,
+ )
+ if CryptPolicy:
+ policy=CryptPolicy.from_path(sample_config_1p)
+ def helper():
+ policy.replace(**kwds)
+ else:
+ ctx = CryptContext.from_path(sample_config_1p)
+ def helper():
+ ctx.copy(**kwds)
+ return helper
+
+@benchmark.constructor()
+def test_context_init():
+ "test speed of CryptContext() constructor"
+ kwds = dict(
schemes=[BlankHandler, AnotherHandler],
default="another",
blank__min_rounds=1500,
blank__max_rounds=2500,
another__vary_rounds=100,
- )
- yield test_context_init
+ )
+ def helper():
+ CryptContext(**kwds)
+ return helper
- ctx = test_context_init()
- def test_context_calls():
- hash = ctx.encrypt(secret, rounds=2001)
- ctx.verify(secret, hash)
- ctx.verify_and_update(secret, hash)
- ctx.verify_and_update(other, hash)
- yield test_context_calls
+@benchmark.constructor()
+def test_context_calls():
+ "test speed of CryptContext password methods"
+ ctx = CryptContext(
+ schemes=[BlankHandler, AnotherHandler],
+ default="another",
+ blank__min_rounds=1500,
+ blank__max_rounds=2500,
+ another__vary_rounds=100,
+ )
+ def helper():
+ hash = ctx.encrypt(SECRET, rounds=2001)
+ ctx.verify(SECRET, hash)
+ ctx.verify_and_update(SECRET, hash)
+ ctx.verify_and_update(OTHER, hash)
+ return helper
-def setup_handlers():
+#=============================================================================
+# handler benchmarks
+#=============================================================================
+@benchmark.constructor()
+def test_md5_crypt_builtin():
+ "test test md5_crypt builtin backend"
from passlib.hash import md5_crypt
md5_crypt.set_backend("builtin")
- def test_md5_crypt():
- hash = md5_crypt.encrypt(secret)
- md5_crypt.verify(secret, hash)
- md5_crypt.verify(other, hash)
- yield test_md5_crypt
+ def helper():
+ hash = md5_crypt.encrypt(SECRET)
+ md5_crypt.verify(SECRET, hash)
+ md5_crypt.verify(OTHER, hash)
+ yield helper
+
+@benchmark.constructor()
+def test_ldap_salted_md5():
+ "test ldap_salted_md5"
+ from passlib.hash import ldap_salted_md5 as handler
+ def helper():
+ hash = handler.encrypt(SECRET, salt='....')
+ handler.verify(SECRET, hash)
+ handler.verify(OTHER, hash)
+ yield helper
#=============================================================================
# main
#=============================================================================
-def pptime(secs):
- precision = 3
- usec = int(secs * 1e6)
- if usec < 1000:
- return "%.*g usec" % (precision, usec)
- msec = usec / 1000
- if msec < 1000:
- return "%.*g msec" % (precision, msec)
- sec = msec / 1000
- return "%.*g sec" % (precision, sec)
-
def main(*args):
- names = args
source = globals()
- for key in sorted(source):
- if not key.startswith("setup_"):
- continue
- sname = key[6:]
- setup = source[key]
- for test in setup():
- name = test.__name__
- if name.startswith("test_"):
- name = name[5:]
- if names and name not in names:
- continue
- timer = Timer(test)
- number = 1
- while True:
- t = timer.timeit(number)
- if t > .2:
- break
- number *= 10
- repeat = 3
- best = min(timer.repeat(repeat, number)) / number
- print_("%30s %s" % (name, pptime(best)))
+ if args:
+ orig = source
+ source = dict((k,orig[k]) for k in orig if k in args)
+ helper = benchmark.run(source, maxtime=2, bestof=3)
+ for name, secs, precision in helper:
+ print_("%-50s %9s (%d)" % (name, benchmark.pptime(secs), precision))
if __name__ == "__main__":
import sys
diff --git a/admin/gae-test-app.yaml b/admin/gae-test-app.yaml
deleted file mode 100644
index c846c23..0000000
--- a/admin/gae-test-app.yaml
+++ /dev/null
@@ -1,9 +0,0 @@
-application: fake-app
-version: 2
-runtime: python
-api_version: 1
-
-handlers:
-- url: /.*
- script: dummy.py
-
diff --git a/docs/lib/passlib.apache.rst b/docs/lib/passlib.apache.rst
index 731baed..8649e62 100644
--- a/docs/lib/passlib.apache.rst
+++ b/docs/lib/passlib.apache.rst
@@ -8,6 +8,13 @@
This module provides utilities for reading and writing Apache's
htpasswd and htdigest files; though the use of two helper classes.
+.. versionchanged:: 1.6
+ The api for this module was updated to be more flexible,
+ and to have (hopefully) less confusing method names.
+ The old method and keyword names are supported but deprecated, and
+ will be removed in Passlib 1.8.
+ No more backwards-incompatible changes are currently planned.
+
.. index:: apache; htpasswd
Htpasswd Files
@@ -17,34 +24,34 @@ A quick summary of it's usage::
>>> from passlib.apache import HtpasswdFile
- >>> #when creating a new file, set to autoload=False, add entries, and save.
- >>> ht = HtpasswdFile("test.htpasswd", autoload=False)
- >>> ht.update("someuser", "really secret password")
+ >>> # when creating a new file, set to new=True, add entries, and save.
+ >>> ht = HtpasswdFile("test.htpasswd", new=True)
+ >>> ht.set_password("someuser", "really secret password")
>>> ht.save()
- >>> #loading an existing file to update a password
+ >>> # loading an existing file to update a password
>>> ht = HtpasswdFile("test.htpasswd")
- >>> ht.update("someuser", "new secret password")
+ >>> ht.set_password("someuser", "new secret password")
>>> ht.save()
- >>> #examining file, verifying user's password
+ >>> # examining file, verifying user's password
>>> ht = HtpasswdFile("test.htpasswd")
>>> ht.users()
[ "someuser" ]
- >>> ht.verify("someuser", "wrong password")
+ >>> ht.check_password("someuser", "wrong password")
False
- >>> ht.verify("someuser", "new secret password")
+ >>> ht.check_password("someuser", "new secret password")
True
- >>> #making in-memory changes and exporting to string
+ >>> # making in-memory changes and exporting to string
>>> ht = HtpasswdFile()
- >>> ht.update("someuser", "mypass")
- >>> ht.update("someuser", "anotherpass")
+ >>> ht.set_password("someuser", "mypass")
+ >>> ht.set_password("someuser", "anotherpass")
>>> print ht.to_string()
someuser:$apr1$T4f7D9ly$EobZDROnHblCNPCtrgh5i/
anotheruser:$apr1$vBdPWvh1$GrhfbyGvN/7HalW5cS9XB1
-.. autoclass:: HtpasswdFile(path, default=None, autoload=True)
+.. autoclass:: HtpasswdFile(path=None, new=False, autosave=False, ...)
.. index:: apache; htdigest
@@ -53,7 +60,7 @@ Htdigest Files
The :class:`!HtdigestFile` class allows management of htdigest files
in a similar fashion to :class:`HtpasswdFile`.
-.. autoclass:: HtdigestFile(path, autoload=True)
+.. autoclass:: HtdigestFile(path, default_realm=None, new=False, autosave=False, ...)
.. rubric:: Footnotes
diff --git a/docs/lib/passlib.context-interface.rst b/docs/lib/passlib.context-interface.rst
index 8331992..a760d06 100644
--- a/docs/lib/passlib.context-interface.rst
+++ b/docs/lib/passlib.context-interface.rst
@@ -8,8 +8,7 @@
.. currentmodule:: passlib.context
-This details all the constructors and methods provided by :class:`!CryptContext`
-and :class:`!CryptPolicy`.
+This details all the constructors and methods provided by :class:`!CryptContext`.
.. seealso::
@@ -19,12 +18,14 @@ and :class:`!CryptPolicy`.
The Context Object
==================
-.. autoclass:: CryptContext(schemes=None, policy=<default policy>, \*\*kwds)
-
-The Policy Object
-=================
-.. autoclass:: CryptPolicy(\*\*kwds)
+.. autoclass:: CryptContext(schemes=None, \*\*kwds)
Other Helpers
=============
-.. autoclass:: LazyCryptContext([schemes=None,] **kwds [, create_policy=None])
+.. autoclass:: LazyCryptContext([schemes=None,] \*\*kwds [, onload=None])
+
+.. rst-class:: html-toggle
+
+(deprecated) The CryptPolicy Class
+==================================
+.. autoclass:: CryptPolicy
diff --git a/docs/lib/passlib.context-options.rst b/docs/lib/passlib.context-options.rst
index 4f0bcbe..ece9033 100644
--- a/docs/lib/passlib.context-options.rst
+++ b/docs/lib/passlib.context-options.rst
@@ -9,9 +9,13 @@
.. currentmodule:: passlib.context
The :class:`CryptContext` accepts a number of keyword options.
-These are divides into the "context options", which affect
-the context instance directly, and the "hash options",
-which affect the context treats a particular type of hash:
+These can be provided to any of the CryptContext constructor methods,
+as well as the :meth:`CryptContext.update` method, or any configuration
+string or INI file passed to :meth:`CryptContext.load`.
+
+The options are divided into two categories: "context options", which directly
+affect the :class:`!CryptContext` object itself; and "hash options", which
+affect the behavior of a particular password hashing scheme.
.. seealso::
@@ -21,8 +25,7 @@ which affect the context treats a particular type of hash:
Context Options
===============
-The following keyword options are accepted by both the :class:`CryptContext`
-and :class:`CryptPolicy` constructors, and directly affect the behavior
+The following keyword options directly affect the behavior
of the :class:`!CryptContext` instance itself:
``schemes``
@@ -41,15 +44,14 @@ of the :class:`!CryptContext` instance itself:
``deprecated``
List of handler names which should be considered deprecated by the CryptContext.
- This should be a subset of the names of the handlers listed in schemes.
- This is optional, if not specified, no handlers will be considered deprecated.
+ This should be a subset of the names of the handlers listed in *schemes*.
+ This is optional, and if not specified, no handlers will be considered deprecated.
- For use in INI files, this may also be specified as a single comma-separated string
+ For INI files, this may also be specified as a single comma-separated string
of handler names.
- This is primarily used by :meth:`CryptContext.hash_needs_update` and
- :meth:`CryptPolicy.handler_is_deprecated`. If the application does not use
- these methods, this option can be ignored.
+ This is primarily used by :meth:`CryptContext.hash_needs_update`.
+ If the application does not use this method, this option can be ignored.
Example: ``deprecated=["des_crypt"]``.
@@ -77,25 +79,22 @@ of the :class:`!CryptContext` instance itself:
For symmetry with the format of the hash option keywords (below),
all of the above context option keywords may also be specified
- using the format :samp:`context__{option}` (note double underscores),
- or :samp:`context.{option}` within INI files.
+ using the format :samp:`context__{option}` (note double underscores).
.. note::
To override context options for a particular :ref:`user category <user-categories>`,
- use the format :samp:`{category}__context__{option}`,
- or :samp:`{category}.context.{option}` within an INI file.
+ use the format :samp:`{category}__context__{option}`.
Hash Options
============
-The following keyword options are accepted by both the :class:`CryptContext`
-and :class:`CryptPolicy` constructors, and affect how a :class:`!CryptContext` instance
-treats hashes belonging to a particular hash scheme, as identified by the hash's handler name.
+The following keyword option affect how a :class:`!CryptContext` instance
+treats hashes belonging to a particular hash scheme,
+as identified by the scheme's name.
All hash option keywords should be specified using the format :samp:`{hash}__{option}`
(note double underscores); where :samp:`{hash}` is the name of the hash's handler,
and :samp:`{option}` is the name of the specific options being set.
-Within INI files, this may be specified using the alternate format :samp:`{hash}.{option}`.
:samp:`{hash}__default_rounds`
@@ -111,11 +110,9 @@ Within INI files, this may be specified using the alternate format :samp:`{hash}
to have a rounds value random chosen from the range :samp:`{default_rounds} +/- {vary_rounds}`.
This may be specified as an integer value, or as a string containing an integer
- with a percent suffix (eg: ``"10%"``). if specified as a percent,
+ with a percent suffix (eg: ``"10%"``). If specified as a percent,
the amount varied will be calculated as a percentage of the :samp:`{default_rounds}` value.
- The default Passlib policy sets this to ``"10%"``.
-
.. note::
If this is specified as a percentage, and the hash algorithm
@@ -172,6 +169,11 @@ Within INI files, this may be specified using the alternate format :samp:`{hash}
It is recommended to set this for all hashes via ``all__passprep``,
instead of settings it per algorithm.
+ .. note::
+
+ Due to a missing :mod:`!stringprep` module, this feature
+ is not available on Jython.
+
:samp:`{hash}__{setting}`
Any other option values, which match the name of a parameter listed
@@ -217,48 +219,61 @@ of the category string it wants to use, and add an additional separator to the k
the need to use a different hash for a particular category
can instead be acheived by overridden the ``default`` context option.
-Sample Policy File
+Sample Config File
==================
-A sample policy file:
+A sample config file:
.. code-block:: ini
[passlib]
- #configure what schemes the context supports (note the "context." prefix is implied for these keys)
+ # configure what schemes the context supports
+ # (note that the "context__" prefix is implied for these keys)
schemes = md5_crypt, sha512_crypt, bcrypt
deprecated = md5_crypt
default = sha512_crypt
- #set some common options for all schemes
- all.vary_rounds = 10%%
- ; NOTE the '%' above has to be escaped due to configparser interpolation
+ # set some common options for all schemes
+ # (this particular setting causes the rounds value to be varied
+ # +/- 10% for each encrypt call)
+ all__vary_rounds = 0.1
- #setup some hash-specific defaults
- sha512_crypt.min_rounds = 40000
- bcrypt.min_rounds = 10
+ # setup some hash-specific defaults
+ sha512_crypt__min_rounds = 40000
+ bcrypt__min_rounds = 10
- #create a "admin" category, which uses bcrypt by default, and has stronger hashes
- admin.context.default = bcrypt
- admin.sha512_crypt.min_rounds = 100000
- admin.bcrypt.min_rounds = 13
+ # create an "admin" category which uses bcrypt by default,
+ # and has stronger default cost
+ admin__context__default = bcrypt
+ admin__sha512_crypt__min_rounds = 100000
+ admin__bcrypt__min_rounds = 13
-And the equivalent as a set of python keyword options::
+This can be turned into a :class:`!CryptContext` via :meth:`CryptContext.from_path`,
+or loaded into an existing object via :meth:`CryptContext.load`.
+
+And the equivalent of the above, as a set of Python keyword options::
dict(
- #configure what schemes the context supports (note the "context." prefix is implied for these keys)
+ # configure what schemes the context supports
+ # (note the "context__" prefix is implied for these keys)
schemes = ["md5_crypt", "sha512_crypt", "bcrypt" ],
deprecated = ["md5_crypt"],
default = "sha512_crypt",
- #set some common options for all schemes
- all__vary_rounds = "10%",
+ # set some common options for all schemes
+ # (this particular setting causes the rounds value to be varied
+ # +/- 10% for each encrypt call)
+ all__vary_rounds = 0.1,
- #setup some hash-specific defaults
+ # setup some hash-specific defaults
sha512_crypt__min_rounds = 40000,
bcrypt__min_rounds = 10,
- #create a "admin" category, which uses bcrypt by default, and has stronger hashes
- admin__context__default = bcrypt
- admin__sha512_crypt__min_rounds = 100000
- admin__bcrypt__min_rounds = 13
+ # create a "admin" category which uses bcrypt by default,
+ # and has stronger default cost
+ admin__context__default = bcrypt,
+ admin__sha512_crypt__min_rounds = 100000,
+ admin__bcrypt__min_rounds = 13,
)
+
+This can be turned into a :class:`CryptContext` via the class constructor,
+or loaded into an existing object via :meth:`CryptContext.load`.
diff --git a/docs/lib/passlib.context-usage.rst b/docs/lib/passlib.context-usage.rst
index 9832203..4d897d4 100644
--- a/docs/lib/passlib.context-usage.rst
+++ b/docs/lib/passlib.context-usage.rst
@@ -77,28 +77,39 @@ copy; using the :meth:`CryptContext.replace` method to create
a mutated copy of the original object::
>>> from passlib.apps import ldap_context
- >>> pwd_context = ldap_context.replace(default="ldap_md5_crypt")
+ >>> pwd_context = ldap_context.copy(default="ldap_md5_crypt")
>>> pwd_context.encrypt("somepass")
'{CRYPT}$1$Cw7t4sbP$dwRgCMc67mOwwus9m33z71'
Examining a CryptContext Instance
=================================
All configuration options for a :class:`!CryptContext` instance
-are stored in a :class:`!CryptPolicy` instance accessible through
-the :attr:`CryptContext.policy` attribute::
+are accessible through various methods of the object:
>>> from passlib.context import CryptContext
>>> myctx = CryptContext([ "md5_crypt", "des_crypt" ], deprecated="des_crypt")
- >>> #get a list of schemes recognized in this context:
- >>> myctx.policy.schemes()
+ >>> # get a list of schemes recognized in this context:
+ >>> myctx.schemes()
[ 'md5-crypt', 'bcrypt' ]
- >>> #get the default handler class :
- >>> myctx.policy.get_handler()
+ >>> # get the default handler object:
+ >>> myctx.handler("default")
<class 'passlib.handlers.md5_crypt.md5_crypt'>
-See the :class:`CryptPolicy` class for more details on it's interface.
+ >>> # the results of a CryptContext object can be serialized as a dict,
+ >>> # suitable for passing to CryptContext's class constructor.
+ >>> myctx.to_dict()
+ {'schemes': ['md5_crypt, 'des_crypt'], 'deprecated': 'des_crypt'}
+
+ >>> # or serialized to an INI-style string, suitable for passing to
+ >>> # CryptContext's from_string() method.
+ >>> print myctx.to_string()
+ [passlib]
+ schemes = md5_crypt, des_crypt
+ deprecated = des_crypt
+
+See the :class:`CryptContext` reference for more details on it's interface.
Full Integration Example
========================
@@ -123,31 +134,32 @@ applications with advanced policy requirements may want to create a hash policy
[passlib]
- ;setup the context to support pbkdf2_sha1, along with legacy md5_crypt hashes:
+ ; setup the context to support pbkdf2_sha1, along with legacy md5_crypt hashes:
schemes = pbkdf2_sha1, md5_crypt
- ;flag md5_crypt as deprecated
- ; (existing md5_crypt hashes will be flagged as needs-updating)
+ ; flag md5_crypt as deprecated
+ ; (existing md5_crypt hashes will be flagged as needs-updating)
deprecated = md5_crypt
- ;set boundaries for pbkdf2 rounds parameter
- ; (pbkdf2 hashes outside this range will be flagged as needs-updating)
- pbkdf2_sha1.min_rounds = 10000
- pbkdf2_sha1.max_rounds = 50000
-
- ;set the default rounds to use when encrypting new passwords.
- ;the 'vary' field will cause each new hash to randomly vary
- ;from the default by the specified %.
- pbkdf2_sha1.default_rounds = 20000
- pbkdf2_sha1.vary_rounds = 10%%
- ; NOTE the '%' above has to be doubled due to configparser interpolation
-
- ;applications can choose to treat certain user accounts differently,
- ;by assigning different types of account to a 'user category',
- ;and setting special policy options for that category.
- ;this create a category named 'admin', which will have a larger default rounds value.
- admin.pbkdf2_sha1.min_rounds = 40000
- admin.pbkdf2_sha1.default_rounds = 50000
+ ; set boundaries for pbkdf2 rounds parameter
+ ; (pbkdf2 hashes outside this range will be flagged as needs-updating)
+ pbkdf2_sha1__min_rounds = 10000
+ pbkdf2_sha1__max_rounds = 50000
+
+ ; set the default rounds to use when encrypting new passwords.
+ ; the 'vary' field will cause each new hash to randomly vary
+ ; from the default by the specified % of the default (in this case,
+ ; 20000 +/- 10% or 2000).
+ pbkdf2_sha1__default_rounds = 20000
+ pbkdf2_sha1__vary_rounds = 0.1
+
+ ; applications can choose to treat certain user accounts differently,
+ ; by assigning different types of account to a 'user category',
+ ; and setting special policy options for that category.
+ ; this create a category named 'admin', which will have a larger default
+ ; rounds value.
+ admin__pbkdf2_sha1__min_rounds = 40000
+ admin__pbkdf2_sha1__default_rounds = 50000
Initializing the CryptContext
-----------------------------
@@ -172,7 +184,6 @@ the configuration once the application starts:
#
from myapp.model.security import user_pwd_context
- from passlib.context import CryptPolicy
def myapp_startup():
@@ -180,10 +191,13 @@ the configuration once the application starts:
# ... other code ...
#
- # vars:
- # policy_path - path to policy file defined in previous step
#
- user_pwd_context.policy = CryptPolicy.from_path(policy_path)
+ # load configuration from some application-specified path.
+ # the load() method also supports loading from a string,
+ # or from dictionary, and other options.
+ #
+ ##user_pwd_context.load(policy_config_string)
+ user_pwd_context.load_path(policy_config_path)
#
#if you want to reconfigure the context without restarting the application,
diff --git a/docs/lib/passlib.ext.django.rst b/docs/lib/passlib.ext.django.rst
index 225642e..d797e23 100644
--- a/docs/lib/passlib.ext.django.rst
+++ b/docs/lib/passlib.ext.django.rst
@@ -73,7 +73,7 @@ you may set the following options in django ``settings.py``:
* ``"disabled"``, in which case this app will do nothing when Django is loaded.
* A multiline configuration string suitable for passing to
- :meth:`passlib.context.CryptPolicy.from_string`.
+ :meth:`passlib.context.CryptContext.from_string`.
It is *strongly* recommended to use a configuration which will support
the existing Django hashes
(see :data:`~passlib.ext.django.utils.STOCK_CTX`).
diff --git a/docs/lib/passlib.hash.bsdi_crypt.rst b/docs/lib/passlib.hash.bsdi_crypt.rst
index 99e7231..8a1b9af 100644
--- a/docs/lib/passlib.hash.bsdi_crypt.rst
+++ b/docs/lib/passlib.hash.bsdi_crypt.rst
@@ -18,19 +18,19 @@ This class can be used directly as follows::
>>> from passlib.hash import bsdi_crypt as bc
>>> bc.encrypt("password") #generate new salt, encrypt password
- '_cD..Bf/46u7tr9IAJ6M'
+ '_7C/.Bf/4gZk10RYRs4Y'
- >>> bc.encrypt("password", rounds=10000) #same, but with explict number of rounds
- '_EQ0.amG/Pp5b0hIpggo'
+ >>> bc.encrypt("password", rounds=10001) #same, but with explict number of rounds
+ '_FQ0.amG/zwCMip7DnBk'
- >>> bc.identify('_cD..Bf/46u7tr9IAJ6M') #check if hash is recognized
+ >>> bc.identify('_7C/.Bf/4gZk10RYRs4Y') #check if hash is recognized
True
>>> bc.identify('$1$3azHgidD$SrJPt7B.9rekpmwJwtON31') #check if some other hash is recognized
False
- >>> bc.verify("password", '_cD..Bf/46u7tr9IAJ6M') #verify correct password
+ >>> bc.verify("password", '_7C/.Bf/4gZk10RYRs4Y') #verify correct password
True
- >>> bc.verify("secret", '_cD..Bf/46u7tr9IAJ6M') #verify incorrect password
+ >>> bc.verify("secret", '_7C/.Bf/4gZk10RYRs4Y') #verify incorrect password
False
Interface
@@ -110,11 +110,16 @@ BSDi Crypt should not be considered sufficiently secure, for a number of reasons
* The fact that it only uses the lower 7 bits of each byte of the password
restricts the keyspace which needs to be searched.
-.. note::
+* Additionally, even *rounds* values are slightly weaker still,
+ as they may reveal the hash used one of the weak DES keys [#weak]_.
+ This information could theoretically allow an attacker to perform a
+ brute-force attack on a reduced keyspace and against only 1-2 rounds of DES.
+ (This issue is mitagated by the fact that few passwords are both valid *and*
+ result in a weak key).
- This algorithm is none-the-less stronger than des-crypt itself,
- since it supports variable rounds, a larger salt size,
- and uses all bytes of the password.
+This algorithm is none-the-less stronger than :class:`!des_crypt` itself,
+since it supports variable rounds, a larger salt size,
+and uses all the bytes of the password.
Deviations
==========
@@ -138,3 +143,5 @@ This implementation of bsdi-crypt differs from others in one way:
.. [#] Another source describing algorithm -
`<http://ftp.lava.net/cgi-bin/bsdi-man?proto=1.1&query=crypt&msection=3&apropos=0>`_
+
+.. [#weak] DES weak keys - `<https://en.wikipedia.org/wiki/Weak_key#Weak_keys_in_DES>`_
diff --git a/docs/lib/passlib.hosts.rst b/docs/lib/passlib.hosts.rst
index 5ca13db..85514e8 100644
--- a/docs/lib/passlib.hosts.rst
+++ b/docs/lib/passlib.hosts.rst
@@ -52,7 +52,7 @@ for the following Unix variants:
All of the above contexts include the :class:`~passlib.hash.unix_disabled` handler
as a final fallback. This special handler treats all strings as invalid passwords,
particularly the common strings ``!`` and ``*`` which are used to indicate
- that an account has been disabled [#shadow]_.
+ that an account has been disabled [#shadow]_.
A quick usage example, using the :data:`!linux_context` instance::
@@ -81,7 +81,7 @@ Current Host OS
The main differences between this object and :func:`!crypt`:
* this object provides introspection about *which* schemes
- are available on a given system (via ``host_context.policy.schemes()``).
+ are available on a given system (via ``host_context.schemes()``).
* it defaults to the strongest algorithm available,
automatically configured to an appropriate strength
for encrypting new passwords.
diff --git a/docs/lib/passlib.utils.des.rst b/docs/lib/passlib.utils.des.rst
index 674f934..ea09506 100644
--- a/docs/lib/passlib.utils.des.rst
+++ b/docs/lib/passlib.utils.des.rst
@@ -18,4 +18,4 @@ since they are designed primarily for use in password hash algorithms
.. autofunction:: expand_des_key
.. autofunction:: des_encrypt_block
-.. autofunction:: mdes_encrypt_int_block
+.. autofunction:: des_encrypt_int_block
diff --git a/docs/lib/passlib.utils.rst b/docs/lib/passlib.utils.rst
index afd27de..060d420 100644
--- a/docs/lib/passlib.utils.rst
+++ b/docs/lib/passlib.utils.rst
@@ -3,7 +3,7 @@
=============================================
.. module:: passlib.utils
- :synopsis: helper functions for implementing password hashes
+ :synopsis: internal helpers for implementing password hashes
This module contains a number of utility functions used by passlib
to implement the builtin handlers, and other code within passlib.
diff --git a/docs/modular_crypt_format.rst b/docs/modular_crypt_format.rst
index 10cd225..d80bae4 100644
--- a/docs/modular_crypt_format.rst
+++ b/docs/modular_crypt_format.rst
@@ -131,7 +131,7 @@ and indicates which operating systems [#gae]_ offer native support.
Scheme Prefix Linux FreeBSD NetBSD OpenBSD Solaris
==================================== ==================== =========== =========== =========== =========== =======
:class:`~passlib.hash.des_crypt` n/a y y y y y
-:class:`~passlib.hash.bsdi_crypt` ``_`` y y
+:class:`~passlib.hash.bsdi_crypt` ``_`` y y y
:class:`~passlib.hash.md5_crypt` ``$1$`` y y y y y
:class:`~passlib.hash.sun_md5_crypt` ``$md5$``, ``$md5,`` y
:class:`~passlib.hash.bcrypt` ``$2$``, ``$2a$`` y y y y
diff --git a/passlib/apache.py b/passlib/apache.py
index 05f4b68..70ac78d 100644
--- a/passlib/apache.py
+++ b/passlib/apache.py
@@ -11,390 +11,722 @@ import sys
#site
#libs
from passlib.context import CryptContext
-from passlib.utils import consteq, render_bytes
-from passlib.utils.compat import b, bytes, join_bytes, lmap, str_to_bascii, u, unicode
+from passlib.exc import ExpectedStringError
+from passlib.hash import htdigest
+from passlib.utils import consteq, render_bytes, to_bytes, deprecated_method
+from passlib.utils.compat import b, bytes, join_bytes, str_to_bascii, u, \
+ unicode, BytesIO, iteritems, imap, PY3
#pkg
#local
__all__ = [
+ 'HtpasswdFile',
+ 'HtdigestFile',
]
-BCOLON = b(":")
+#=========================================================
+# constants & support
+#=========================================================
+_UNSET = object()
+
+_BCOLON = b(":")
+
+# byte values that aren't allowed in fields.
+_INVALID_FIELD_CHARS = b(":\n\r\t\x00")
+
+# helpers to detect non-ascii codecs
+_ASCII_TEST_BYTES = b("\x00\n aA:#!\x7f")
+_ASCII_TEST_UNICODE = _ASCII_TEST_BYTES.decode("ascii")
+
+def is_ascii_codec(codec):
+ "test if codec is 7-bit ascii safe (e.g. latin-1, utf-8; but not utf-16)"
+ return _ASCII_TEST_UNICODE.encode(codec) == _ASCII_TEST_BYTES
#=========================================================
-#common helpers
+# backport of OrderedDict for PY2.5
#=========================================================
-DEFAULT_ENCODING = "utf-8" if sys.version_info >= (3,0) else None
+try:
+ from collections import OrderedDict
+except ImportError:
+ # Python 2.5
+ class OrderedDict(dict):
+ """hacked OrderedDict replacement.
+
+ NOTE: this doesn't provide a full OrderedDict implementation,
+ just the minimum needed by the Htpasswd internals.
+ """
+ def __init__(self):
+ self._keys = []
+
+ def __iter__(self):
+ return iter(self._keys)
+ def __setitem__(self, key, value):
+ if key not in self:
+ self._keys.append(key)
+ super(OrderedDict, self).__setitem__(key, value)
+
+ def __delitem__(self, key):
+ super(OrderedDict, self).__delitem__(key)
+ self._keys.remove(key)
+
+ def iteritems(self):
+ return ((key, self[key]) for key in self)
+
+ # these aren't used or implemented, so disabling them for safety.
+ update = pop = popitem = clear = keys = iterkeys = None
+
+#=========================================================
+#common helpers
+#=========================================================
class _CommonFile(object):
- "helper for HtpasswdFile / HtdigestFile"
-
- #XXX: would like to add 'path' keyword to load() / save(),
- # but that makes .mtime somewhat meaningless.
- # to simplify things, should probably deprecate mtime & force=False
- # options.
- #XXX: would also like to make _load_string available via public interface,
- # such as via 'content' keyword in load() method.
- # in short, need to clean up the htpasswd api a little bit in 1.6.
- # keeping _load_string private for now, cause just using it for UTing.
-
- #NOTE: 'path' is a property instead of attr,
- # so that .mtime is wiped whenever path is changed.
- _path = None
- def _get_path(self):
- return self._path
- def _set_path(self, path):
- if path != self._path:
- self.mtime = 0
- self._path = path
- path = property(_get_path, _set_path)
+ """common framework for HtpasswdFile & HtdigestFile"""
+ #=======================================================================
+ # instance attrs
+ #=======================================================================
+
+ # charset encoding used by file (defaults to utf-8)
+ encoding = None
+
+ # whether users() and other public methods should return unicode or bytes?
+ # (defaults to False under PY2, True under PY3)
+ return_unicode = None
+
+ # if bound to local file, these will be set.
+ _path = None # local file path
+ _mtime = None # mtime when last loaded, or 0
+
+ # if true, automatically save to local file after changes are made.
+ autosave = False
+
+ # ordered dict mapping key -> value for all records in database.
+ # (e.g. user => hash for Htpasswd)
+ _records = None
+
+ #=======================================================================
+ # alt constuctors
+ #=======================================================================
+ @classmethod
+ def from_string(cls, data, **kwds):
+ """create new object from raw string.
+
+ :arg data:
+ unicode or bytes string to load
+
+ :param \*\*kwds:
+ all other keywords are the same as in the class constructor
+ """
+ if 'path' in kwds:
+ raise TypeError("'path' not accepted by from_string()")
+ self = cls(**kwds)
+ self.load_string(data)
+ return self
@classmethod
- def _from_string(cls, content, **kwds):
- #NOTE: not public yet, just using it for unit tests.
+ def from_path(cls, path, **kwds):
+ """create new object from file, without binding object to file.
+
+ :arg path:
+ local filepath to load from
+
+ :param \*\*kwds:
+ all other keywords are the same as in the class constructor
+ """
self = cls(**kwds)
- self._load_string(content)
+ self.load(path)
return self
- def __init__(self, path=None, autoload=True,
- encoding=DEFAULT_ENCODING,
+ #=======================================================================
+ # init
+ #=======================================================================
+ def __init__(self, path=None, new=False, autoload=True, autosave=False,
+ encoding="utf-8", return_unicode=PY3,
):
- if encoding and u(":\n").encode(encoding) != b(":\n"):
- #rest of file assumes ascii bytes, and uses ":" as separator.
+ # set encoding
+ if not encoding:
+ warn("``encoding=None`` is deprecated as of Passlib 1.6, "
+ "and will cause a ValueError in Passlib 1.8, "
+ "use ``return_unicode=False`` instead.",
+ DeprecationWarning, stacklevel=2)
+ encoding = "utf-8"
+ return_unicode = False
+ elif not is_ascii_codec(encoding):
+ # htpasswd/htdigest files assumes 1-byte chars, and use ":" separator,
+ # so only ascii-compatible encodings are allowed.
raise ValueError("encoding must be 7-bit ascii compatible")
self.encoding = encoding
- self.path = path
- ##if autoload == "exists":
- ## autoload = bool(path and os.path.exists(path))
- if autoload and path:
+
+ # set other attrs
+ self.return_unicode = return_unicode
+ self.autosave = autosave
+ self._path = path
+ self._mtime = 0
+
+ # init db
+ if not autoload:
+ warn("``autoload=False`` is deprecated as of Passlib 1.6, "
+ "and will be removed in Passlib 1.8, use ``new=True`` instead",
+ DeprecationWarning, stacklevel=2)
+ new = True
+ if path and not new:
self.load()
- ##elif raw:
- ## self._load_lines(raw.split("\n"))
else:
- self._entry_order = []
- self._entry_map = {}
-
- def _load_string(self, content):
- """UT helper for loading from string
-
- to be improved/made public in later release.
-
+ self._records = OrderedDict()
+
+ def __repr__(self):
+ tail = ''
+ if self.autosave:
+ tail += ' autosave=True'
+ if self._path:
+ tail += ' path=%r' % self._path
+ if self.encoding != "utf-8":
+ tail += ' encoding=%r' % self.encoding
+ return "<%s 0x%0x%s>" % (self.__class__.__name__, id(self), tail)
+
+ # NOTE: ``path`` is a property so that ``_mtime`` is wiped when it's set.
+ def _get_path(self):
+ return self._path
+ def _set_path(self, value):
+ if value != self._path:
+ self._mtime = 0
+ self._path = value
+ path = property(_get_path, _set_path)
- :param content:
- if specified, should be a bytes object.
- passwords will be loaded directly from this string,
- and any files will be ignored.
- """
- if isinstance(content, unicode):
- content = content.encode(self.encoding or 'utf-8')
- self.mtime = 0
- #XXX: replace this with iterator?
- lines = content.splitlines()
- self._load_lines(lines)
+ @property
+ def mtime(self):
+ "modify time when last loaded (if bound to a local file)"
+ return self._mtime
+
+ #=======================================================================
+ # loading
+ #=======================================================================
+ def load_if_changed(self):
+ """Reload from ``self.path`` only if file has changed since last load"""
+ if not self._path:
+ raise RuntimeError("%r is not bound to a local file" % self)
+ if self._mtime and self._mtime == os.path.getmtime(self._path):
+ return False
+ self.load()
return True
- def load(self, force=True):
- """load entries from file
+ def load(self, path=None, force=True):
+ """Load state from local file.
+ If no path is specified, attempts to load from ``self.path``.
- :param force:
- if ``True`` (the default), always loads state from file.
- if ``False``, only loads state if file has been modified since last load.
+ :arg path: local file to load from
- :raises IOError: if file not found
+ :param force:
+ if ``force=False``, only load from ``self.path`` if file
+ has changed since last load.
- :returns: ``False`` if ``force=False`` and no load performed; otherwise ``True``.
+ .. deprecated:: 1.6
+ This keyword will be removed in Passlib 1.8;
+ Applications should use :meth:`load_if_changed` instead.
"""
- path = self.path
- if not path:
- raise RuntimeError("no load path specified")
- if not force and self.mtime and self.mtime == os.path.getmtime(path):
- return False
- with open(path, "rb") as fh:
- self.mtime = os.path.getmtime(path)
- self._load_lines(fh)
+ if path is not None:
+ with open(path, "rb") as fh:
+ self._mtime = 0
+ self._load_lines(fh)
+ elif not force:
+ warn("%(name)s.load(force=False) is deprecated as of Passlib 1.6,"
+ "and will be removed in Passlib 1.8; "
+ "use %(name)s.load_if_changed() instead." %
+ self.__class__.__name__,
+ DeprecationWarning, stacklevel=2)
+ return self.load_if_changed()
+ elif self._path:
+ with open(self._path, "rb") as fh:
+ self._mtime = os.path.getmtime(self._path)
+ self._load_lines(fh)
+ else:
+ raise RuntimeError("%s().path is not set, an explicit path is required" %
+ self.__class__.__name__)
return True
+ def load_string(self, data):
+ "Load state from unicode or bytes string, replacing current state"
+ data = to_bytes(data, self.encoding, "data")
+ self._mtime = 0
+ self._load_lines(BytesIO(data))
+
def _load_lines(self, lines):
- pl = self._parse_line
- entry_order = self._entry_order = []
- entry_map = self._entry_map = {}
- for line in lines:
- #XXX: found mention that "#" comment lines may be supported by htpasswd,
- # should verify this.
- key, value = pl(line)
- if key in entry_map:
- #XXX: should we use data from first entry, or last entry?
- # going w/ first entry for now.
- continue
- entry_order.append(key)
- entry_map[key] = value
-
- #subclass: _parse_line(line) -> (key, hash)
+ "load from sequence of lists"
+ # XXX: found reference that "#" comment lines may be supported by
+ # htpasswd, should verify this, and figure out how to handle them.
+ # if true, this would also affect what can be stored in user field.
+ # XXX: if multiple entries for a key, should we use the first one
+ # or the last one? going w/ first entry for now.
+ # XXX: how should this behave if parsing fails? currently
+ # it will contain everything that was loaded up to error.
+ # could clear / restore old state instead.
+ parse = self._parse_record
+ records = self._records = OrderedDict()
+ for idx, line in enumerate(lines):
+ key, value = parse(line, idx+1)
+ if key not in records:
+ records[key] = value
+
+ def _parse_record(cls, record, lineno):
+ "parse line of file into (key, value) pair"
+ raise NotImplementedError("should be implemented in subclass")
+
+ #=======================================================================
+ # saving
+ #=======================================================================
+ def _autosave(self):
+ "subclass helper to call save() after any changes"
+ if self.autosave and self._path:
+ self.save()
+
+ def save(self, path=None):
+ """Save current state to file.
+ If no path is specified, attempts to save to ``self.path``.
+ """
+ if path is not None:
+ with open(path, "wb") as fh:
+ fh.writelines(self._iter_lines())
+ elif self._path:
+ self.save(self._path)
+ self._mtime = os.path.getmtime(self._path)
+ else:
+ raise RuntimeError("%s().path is not set, cannot autosave" %
+ self.__class__.__name__)
+
+ def to_string(self):
+ "Export current state as a string of bytes"
+ return join_bytes(self._iter_lines())
def _iter_lines(self):
"iterator yielding lines of database"
- rl = self._render_line
- entry_order = self._entry_order
- entry_map = self._entry_map
- assert len(entry_order) == len(entry_map), "internal error in entry list"
- return (rl(key, entry_map[key]) for key in entry_order)
-
- def save(self):
- "save entries to file"
- if not self.path:
- raise RuntimeError("no save path specified")
- with open(self.path, "wb") as fh:
- fh.writelines(self._iter_lines())
- self.mtime = os.path.getmtime(self.path)
+ return (self._render_record(key,value) for key,value in iteritems(self._records))
- def to_string(self):
- "export whole database as a byte string"
- return join_bytes(self._iter_lines())
+ def _render_record(cls, key, value):
+ "given key/value pair, encode as line of file"
+ raise NotImplementedError("should be implemented in subclass")
- #subclass: _render_line(entry) -> line
+ #=======================================================================
+ # field encoding
+ #=======================================================================
+ def _encode_user(self, user):
+ "user-specific wrapper for _encode_field()"
+ return self._encode_field(user, "user")
- def _update_key(self, key, value):
- entry_map = self._entry_map
- if key in entry_map:
- entry_map[key] = value
- return True
- else:
- self._entry_order.append(key)
- entry_map[key] = value
- return False
+ def _encode_realm(self, realm):
+ "realm-specific wrapper for _encode_field()"
+ return self._encode_field(realm, "realm")
- def _delete_key(self, key):
- entry_map = self._entry_map
- if key in entry_map:
- del entry_map[key]
- self._entry_order.remove(key)
- return True
- else:
- return False
+ def _encode_field(self, value, errname="field"):
+ """convert field to internal representation.
- invalid_chars = b(":\n\r\t\x00")
-
- def _norm_user(self, user):
- "encode user to bytes, validate against format requirements"
- return self._norm_ident(user, errname="user")
-
- def _norm_realm(self, realm):
- "encode realm to bytes, validate against format requirements"
- return self._norm_ident(realm, errname="realm")
-
- def _norm_ident(self, ident, errname="user/realm"):
- ident = self._encode_ident(ident, errname)
- if len(ident) > 255:
- raise ValueError("%s must be at most 255 characters: %r" % (errname, ident))
- if any(c in self.invalid_chars for c in ident):
- raise ValueError("%s contains invalid characters: %r" % (errname, ident,))
- return ident
-
- def _encode_ident(self, ident, errname="user/realm"):
- "ensure identifier is bytes encoded using specified encoding, or rejected"
- encoding = self.encoding
- if encoding:
- if isinstance(ident, unicode):
- return ident.encode(encoding)
- raise TypeError("%s must be unicode, not %s" %
- (errname, type(ident)))
- else:
- if isinstance(ident, bytes):
- return ident
- raise TypeError("%s must be bytes, not %s" %
- (errname, type(ident)))
-
- def _decode_ident(self, ident, errname="user/realm"):
- "decode an identifier (if encoding is specified, else return encoded bytes)"
- assert isinstance(ident, bytes)
- encoding = self.encoding
- if encoding:
- return ident.decode(encoding)
+ internal representation is always bytes. byte strings are left as-is,
+ unicode strings encoding using file's default encoding (or ``utf-8``
+ if no encoding has been specified).
+
+ :raises UnicodeEncodeError:
+ if unicode value cannot be encoded using default encoding.
+
+ :raises ValueError:
+ if resulting byte string contains a forbidden character,
+ or is too long (>255 bytes).
+
+ :returns:
+ encoded identifer as bytes
+ """
+ if isinstance(value, unicode):
+ value = value.encode(self.encoding)
+ elif not isinstance(value, bytes):
+ raise ExpectedStringError(value, errname)
+ if len(value) > 255:
+ raise ValueError("%s must be at most 255 characters: %r" %
+ (errname, value))
+ if any(c in _INVALID_FIELD_CHARS for c in value):
+ raise ValueError("%s contains invalid characters: %r" %
+ (errname, value,))
+ return value
+
+ def _decode_field(self, value):
+ """decode field from internal representation to format
+ returns by users() method, etc.
+
+ :raises UnicodeDecodeError:
+ if unicode value cannot be decoded using default encoding.
+ (usually indicates wrong encoding set for file).
+
+ :returns:
+ field as unicode or bytes, as appropriate.
+ """
+ assert isinstance(value, bytes), "expected value to be bytes"
+ if self.return_unicode:
+ return value.decode(self.encoding)
else:
- return ident
+ return value
- #FIXME: htpasswd doc sez passwords limited to 255 chars under Windows & MPE,
- # longer ones are truncated. may be side-effect of those platforms
- # supporting plaintext. we don't currently check for this.
+ # FIXME: htpasswd doc says passwords limited to 255 chars under Windows & MPE,
+ # and that longer ones are truncated. this may be side-effect of those
+ # platforms supporting the 'plaintext' scheme. these classes don't currently
+ # check for this.
+
+ #=======================================================================
+ # eoc
+ #=======================================================================
#=========================================================
#htpasswd editing
#=========================================================
-#FIXME: apr_md5_crypt technically the default only for windows, netware and tpf.
-#TODO: find out if htpasswd's "crypt" mode is crypt *call* or just des_crypt implementation.
+
+# FIXME: apr_md5_crypt technically the default only for windows, netware and tpf.
+# TODO: find out if htpasswd's "crypt" mode is crypt *call* or just des_crypt implementation.
htpasswd_context = CryptContext([
- "apr_md5_crypt", #man page notes supported everywhere, default on Windows, Netware, TPF
- "des_crypt", #man page notes server does NOT support this on Windows, Netware, TPF
- "ldap_sha1", #man page notes only for transitioning <-> ldap
+ "apr_md5_crypt", # man page notes supported everywhere, default on Windows, Netware, TPF
+ "des_crypt", # man page notes server does NOT support this on Windows, Netware, TPF
+ "ldap_sha1", # man page notes only for transitioning <-> ldap
"plaintext" # man page notes server ONLY supports this on Windows, Netware, TPF
])
class HtpasswdFile(_CommonFile):
"""class for reading & writing Htpasswd files.
- :arg path: path to htpasswd file to load from / save to (required)
+ The class constructor accepts the following arguments:
- :param default:
- optionally specify default scheme to use when encoding new passwords.
+ :type path: filepath
+ :param path:
- Must be one of ``None``, ``"apr_md5_crypt"``, ``"des_crypt"``, ``"ldap_sha1"``, ``"plaintext"``.
+ Specifies path to htpasswd file, use to implicitly load from and save to.
- If no value is specified, this class currently uses ``apr_md5_crypt`` when creating new passwords.
+ This class has two modes of operation:
- :param autoload:
- if ``True`` (the default), :meth:`load` will be automatically called
- by constructor.
+ 1. It can be "bound" to a local file by passing a ``path`` to the class
+ constructor. In this case it will load the contents of the file when
+ created, and the :meth:`load` and :meth:`save` methods will automatically
+ load from and save to that file if they are called without arguments.
- Set to ``False`` to disable automatic loading (primarily used when
- creating new htdigest file).
+ 2. Alternately, it can exist as an independant object, in which case
+ :meth:`load` and :meth:`save` will require an explicit path to be
+ provided whenever they are called. As well, ``autosave`` behavior
+ will not be available.
- :param encoding:
- optionally specify encoding used for usernames.
+ This feature is new in Passlib 1.6, and is the default if no
+ ``path`` value is provided to the constructor.
+
+ This is exposed as a readonly instance attribute.
+
+ :type new: bool
+ :param new:
+
+ Normally, if *path* is specified, :class:`HtpasswdFile` will
+ immediately load the contents of the file. However, when creating
+ a new htpasswd file, applications can set ``new=True`` so that
+ the existing file (if any) will not be loaded.
+
+ .. versionchanged:: 1.6
+ This feature was previously enabled by setting ``autoload=False``.
+ That alias has been deprecated, and will be removed in Passlib 1.8
- if set to ``None``,
- user names must be specified as bytes,
- and will be returned as bytes.
+ :type autosave: bool
+ :param autosave:
+
+ Normally, any changes made to an :class:`HtpasswdFile` instance
+ will not be saved until :meth:`save` is explicitly called. However,
+ if ``autosave=True`` is specified, any changes made will be
+ saved to disk immediately (assuming *path* has been set).
+
+ This is exposed as a writeable instance attribute.
+
+ :type encoding: str
+ :param encoding:
- if set to an encoding,
- user names must be specified as unicode,
- and will be returned as unicode.
- when stored, then will use the specified encoding.
+ Optionally specify character encoding used to read/write file
+ and hash passwords. Defaults to ``utf-8``, though ``latin-1``
+ is the only other commonly encountered encoding.
- for backwards compatibility with passlib 1.4,
- this defaults to ``None`` under Python 2,
- and ``utf-8`` under Python 3.
+ This is exposed as a readonly instance attribute.
- .. note::
+ :type default_scheme: str
+ :param default_scheme:
+ Optionally specify default scheme to use when encoding new passwords.
+ Must be one of ``"apr_md5_crypt"``, ``"des_crypt"``, ``"ldap_sha1"``,
+ ``"plaintext"``. It defaults to ``"apr_md5_crypt"``.
- this is not the encoding for the entire file,
- just for the usernames within the file.
- this must be an encoding which is compatible
- with 7-bit ascii (which is used by rest of file).
+ .. versionchanged:: 1.6
+ This keyword was previously named ``default``. That alias
+ has been deprecated, and will be removed in Passlib 1.8.
+ :type context: :class:`~passlib.context.CryptContext`
:param context:
- :class:`~passlib.context.CryptContext` instance used to handle
- hashes in this file.
+ :class:`!CryptContext` instance used to encrypt
+ and verify the hashes found in the htpasswd file.
+ The default value is a pre-built context which supports all
+ of the hashes officially allowed in an htpasswd file.
+
+ This is exposed as a readonly instance attribute.
.. warning::
- this should usually be left at the default,
- though it can be overridden to implement non-standard hashes
- within the htpasswd file.
+ This option is useful to add support for non-standard hash
+ formats to an htpasswd file. However, the resulting file
+ will probably not be usuable by another application,
+ particularly Apache itself.
Loading & Saving
================
.. automethod:: load
+ .. automethod:: load_if_changed
+ .. automethod:: load_string
.. automethod:: save
.. automethod:: to_string
Inspection
================
.. automethod:: users
- .. automethod:: verify
+ .. automethod:: check_password
+ .. automethod:: get_hash
Modification
================
- .. automethod:: update
+ .. automethod:: set_password
.. automethod:: delete
- .. note::
+ Alternate Constructors
+ ======================
+ .. automethod:: from_string
- All of the methods in this class enforce some data validation
- on the ``user`` parameter:
- they will raise a :exc:`ValueError` if the string
- contains one of the forbidden characters ``:\\r\\n\\t\\x00``,
+ Errors
+ ======
+ :raises ValueError:
+ All of the methods in this class will raise a :exc:`ValueError` if
+ any user name contains a forbidden character (one of ``:\\r\\n\\t\\x00``),
or is longer than 255 characters.
"""
- def __init__(self, path=None, default=None, context=htpasswd_context, **kwds):
+ #=========================================================
+ # instance attrs
+ #=========================================================
+
+ # NOTE: _records map stores <user> for the key, and <hash> for the value,
+ # both in bytes which use self.encoding
+
+ #=========================================================
+ # init & serialization
+ #=========================================================
+ def __init__(self, path=None, default_scheme=None, context=htpasswd_context,
+ **kwds):
+ if 'default' in kwds:
+ warn("``default`` is deprecated as of Passlib 1.6, "
+ "and will be removed in Passlib 1.8, it has been renamed "
+ "to ``default_scheem``.",
+ DeprecationWarning, stacklevel=2)
+ default_scheme = kwds.pop("default")
+ if default_scheme:
+ context = context.copy(default=default_scheme)
self.context = context
- if default:
- self.context = self.context.replace(default=default)
super(HtpasswdFile, self).__init__(path, **kwds)
- def _parse_line(self, line):
- #should be user, hash
- return line.rstrip().split(BCOLON)
+ def _parse_record(self, record, lineno):
+ # NOTE: should return (user, hash) tuple
+ result = record.rstrip().split(_BCOLON)
+ if len(result) != 2:
+ raise ValueError("malformed htpasswd file (error reading line %d)"
+ % lineno)
+ return result
- def _render_line(self, user, hash):
+ def _render_record(self, user, hash):
return render_bytes("%s:%s\n", user, hash)
+ #=========================================================
+ # public methods
+ #=========================================================
+
def users(self):
- "return list of all users in file"
- return lmap(self._decode_ident, self._entry_order)
+ "Return list of all users in database"
+ return [self._decode_field(user) for user in self._records]
- def update(self, user, password):
- """update password for user; adds user if needed.
+ ##def has_user(self, user):
+ ## "check whether entry is present for user"
+ ## return self._encode_user(user) in self._records
+
+ ##def rename(self, old, new):
+ ## """rename user account"""
+ ## old = self._encode_user(old)
+ ## new = self._encode_user(new)
+ ## hash = self._records.pop(old)
+ ## self._records[new] = hash
+ ## self._autosave()
+
+ def set_password(self, user, password):
+ """Set password for user; adds user if needed.
- :returns: ``True`` if existing user was updated, ``False`` if user added.
+ :returns:
+ * ``True`` if existing user was updated.
+ * ``False`` if user account was added.
+
+ .. versionchanged:: 1.6
+ This method was previously called ``update``, it was renamed
+ to prevent ambiguity with the dictionary method.
+ The old alias is deprecated, and will be removed in Passlib 1.8.
"""
- user = self._norm_user(user)
+ user = self._encode_user(user)
hash = self.context.encrypt(password)
- return self._update_key(user, hash)
+ if PY3:
+ hash = hash.encode(self.encoding)
+ existing = (user in self._records)
+ self._records[user] = hash
+ self._autosave()
+ return existing
+
+ @deprecated_method(deprecated="1.6", removed="1.8",
+ replacement="set_password")
+ def update(self, user, password):
+ "set password for user"
+ return self.set_password(user, password)
+
+ def get_hash(self, user):
+ """Return hash stored for user, or ``None`` if user not found.
+ .. versionchanged:: 1.6
+ This method was previously named ``find``, it was renamed
+ for clarity. The old name is deprecated, and will be removed
+ in Passlib 1.8.
+ """
+ try:
+ return self._records[self._encode_user(user)]
+ except KeyError:
+ return None
+
+ @deprecated_method(deprecated="1.6", removed="1.8",
+ replacement="get_hash")
+ def find(self, user):
+ "return hash for user"
+ return self.get_hash(user)
+
+ # XXX: rename to something more explicit, like delete_user()?
def delete(self, user):
- """delete user's entry.
+ """Delete user's entry.
- :returns: ``True`` if user deleted, ``False`` if user not found.
+ :returns:
+ * ``True`` if user deleted.
+ * ``False`` if user not found.
"""
- user = self._norm_user(user)
- return self._delete_key(user)
+ try:
+ del self._records[self._encode_user(user)]
+ except KeyError:
+ return False
+ self._autosave()
+ return True
- def verify(self, user, password):
- """verify password for specified user.
+ def check_password(self, user, password):
+ """Verify password for specified user.
:returns:
- * ``None`` if user not found
- * ``False`` if password does not match
- * ``True`` if password matches.
+ * ``None`` if user not found.
+ * ``False`` if user found, but password does not match.
+ * ``True`` if user found and password matches.
+
+ .. versionchanged:: 1.6
+ This method was previously called ``verify``, it was renamed
+ to prevent ambiguity with the :class:`!CryptContext` method.
+ The old alias is deprecated, and will be removed in Passlib 1.8.
"""
- user = self._norm_user(user)
- hash = self._entry_map.get(user)
+ user = self._encode_user(user)
+ hash = self._records.get(user)
if hash is None:
return None
- else:
- return self.context.verify(password, hash)
- #TODO: support migration from deprecated hashes
+ if isinstance(password, unicode):
+ # NOTE: encoding password to match file, making the assumption
+ # that server will use same encoding to hash the password.
+ password = password.encode(self.encoding)
+ ok, new_hash = self.context.verify_and_update(password, hash)
+ if ok and new_hash is not None:
+ # rehash user's password if old hash was deprecated
+ self._records[user] = new_hash
+ self._autosave()
+ return ok
+
+ @deprecated_method(deprecated="1.6", removed="1.8",
+ replacement="check_password")
+ def verify(self, user, password):
+ "verify password for user"
+ return self.check_password(user, password)
+
+ #=========================================================
+ # eoc
+ #=========================================================
#=========================================================
#htdigest editing
#=========================================================
class HtdigestFile(_CommonFile):
- """class for reading & writing Htdigest files
+ """class for reading & writing Htdigest files.
- :arg path: path to htpasswd file to load from / save to (required)
+ The class constructor accepts the following arguments:
- :param autoload:
- if ``True`` (the default), :meth:`load` will be automatically called
- by constructor.
+ :type path: filepath
+ :param path:
- Set to ``False`` to disable automatic loading (primarily used when
- creating new htdigest file).
+ Specifies path to htdigest file, use to implicitly load from and save to.
- :param encoding:
- optionally specify encoding used for usernames / realms.
+ This class has two modes of operation:
+
+ 1. It can be "bound" to a local file by passing a ``path`` to the class
+ constructor. In this case it will load the contents of the file when
+ created, and the :meth:`load` and :meth:`save` methods will automatically
+ load from and save to that file if they are called without arguments.
+
+ 2. Alternately, it can exist as an independant object, in which case
+ :meth:`load` and :meth:`save` will require an explicit path to be
+ provided whenever they are called. As well, ``autosave`` behavior
+ will not be available.
+
+ This feature is new in Passlib 1.6, and is the default if no
+ ``path`` value is provided to the constructor.
+
+ This is exposed as a readonly instance attribute.
+
+ :type default_realm: str
+ :param default_realm:
- if set to ``None``,
- user names & realms must be specified as bytes,
- and will be returned as bytes.
+ If ``default_realm`` is set, all the :class:`HtdigestFile`
+ methods that require a realm will use this value if one is not
+ provided explicitly. If unset, they will raise an error stating
+ that an explicit realm is required.
- if set to an encoding,
- user names & realms must be specified as unicode,
- and will be returned as unicode.
- when stored, then will use the specified encoding.
+ This is exposed as a writeable instance attribute.
- for backwards compatibility with passlib 1.4,
- this defaults to ``None`` under Python 2,
- and ``utf-8`` under Python 3.
+ .. versionadded:: 1.6
- .. note::
+ :type new: bool
+ :param new:
- this is not the encoding for the entire file,
- just for the usernames & realms within the file.
- this must be an encoding which is compatible
- with 7-bit ascii (which is used by rest of file).
+ Normally, if *path* is specified, :class:`HtdigestFile` will
+ immediately load the contents of the file. However, when creating
+ a new htpasswd file, applications can set ``new=True`` so that
+ the existing file (if any) will not be loaded.
+
+ .. versionchanged:: 1.6
+ This feature was previously enabled by setting ``autoload=False``.
+ That alias has been deprecated, and will be removed in Passlib 1.8
+
+ :type autosave: bool
+ :param autosave:
+
+ Normally, any changes made to an :class:`HtdigestFile` instance
+ will not be saved until :meth:`save` is explicitly called. However,
+ if ``autosave=True`` is specified, any changes made will be
+ saved to disk immediately (assuming *path* has been set).
+
+ This is exposed as a writeable instance attribute.
+
+ :type encoding: str
+ :param encoding:
+
+ Optionally specify character encoding used to read/write file
+ and hash passwords. Defaults to ``utf-8``, though ``latin-1``
+ is the only other commonly encountered encoding.
+
+ This is exposed as a readonly instance attribute.
Loading & Saving
================
.. automethod:: load
+ .. automethod:: load_if_changed
+ .. automethod:: load_string
.. automethod:: save
.. automethod:: to_string
@@ -402,130 +734,242 @@ class HtdigestFile(_CommonFile):
==========
.. automethod:: realms
.. automethod:: users
- .. automethod:: find
- .. automethod:: verify
+ .. automethod:: check_password(user[, realm], password)
+ .. automethod:: get_hash
Modification
============
- .. automethod:: update
+ .. automethod:: set_password(user[, realm], password)
.. automethod:: delete
.. automethod:: delete_realm
- .. note::
+ Alternate Constructors
+ ======================
+ .. automethod:: from_string
- All of the methods in this class enforce some data validation
- on the ``user`` and ``realm`` parameters:
- they will raise a :exc:`ValueError` if either string
- contains one of the forbidden characters ``:\\r\\n\\t\\x00``,
+ Errors
+ ======
+ :raises ValueError:
+ All of the methods in this class will raise a :exc:`ValueError` if
+ any user name or realm contains a forbidden character (one of ``:\\r\\n\\t\\x00``),
or is longer than 255 characters.
-
"""
- #XXX: don't want password encoding to change if user account encoding does.
- # but also *can't* use unicode itself. setting this to utf-8 for now,
- # until it causes problems - in which case stopgap of setting this attr
- # per-instance can be used.
- password_encoding = "utf-8"
+ #=========================================================
+ # instance attrs
+ #=========================================================
+
+ # NOTE: _records map stores (<user>,<realm>) for the key,
+ # and <hash> as the value, all as <self.encoding> bytes.
+
+ # NOTE: unlike htpasswd, this class doesn't use a CryptContext,
+ # as only one hash format is supported: htdigest.
+
+ # optionally specify default realm that will be used if none
+ # is provided to a method call. otherwise realm is always required.
+ default_realm = None
+
+ #=========================================================
+ # init & serialization
+ #=========================================================
+ def __init__(self, path=None, default_realm=None, **kwds):
+ self.default_realm = default_realm
+ super(HtdigestFile, self).__init__(path, **kwds)
+
+ def _parse_record(self, record, lineno):
+ result = record.rstrip().split(_BCOLON)
+ if len(result) != 3:
+ raise ValueError("malformed htdigest file (error reading line %d)"
+ % lineno)
+ user, realm, hash = result
+ return (user, realm), hash
- #XXX: provide rename() & rename_realm() ?
+ def _render_record(self, key, hash):
+ user, realm = key
+ return render_bytes("%s:%s:%s\n", user, realm, hash)
- def _parse_line(self, line):
- user, realm, hash = line.rstrip().split(BCOLON)
- return (user, realm), hash
+ def _encode_realm(self, realm):
+ # override default _encode_realm to fill in default realm field
+ if realm is None:
+ realm = self.default_realm
+ if realm is None:
+ raise TypeError("you must specify a realm explicitly, "
+ "or set the default_realm attribute")
+ return self._encode_field(realm, "realm")
- def _render_line(self, key, hash):
- return render_bytes("%s:%s:%s\n", key[0], key[1], hash)
-
- #TODO: would frontend to calc_digest be useful?
- ##def encrypt(self, password, user, realm):
- ## user = self._norm_user(user)
- ## realm = self._norm_realm(realm)
- ## hash = self._calc_digest(user, realm, password)
- ## if self.encoding:
- ## #decode hash if in unicode mode
- ## hash = hash.decode("ascii")
- ## return hash
-
- def _calc_digest(self, user, realm, password):
- "helper to calculate digest"
- if isinstance(password, unicode):
- password = password.encode(self.password_encoding)
- #NOTE: encode('ascii') is noop under py2, required under py3
- return str_to_bascii(md5(render_bytes("%s:%s:%s", user, realm, password)).hexdigest())
+ #=========================================================
+ # public methods
+ #=========================================================
def realms(self):
- "return all realms listed in file"
- return lmap(self._decode_ident,
- set(key[1] for key in self._entry_order))
+ """Return list of all realms in database"""
+ realms = set(key[1] for key in self._records)
+ return [self._decode_field(realm) for realm in realms]
- def users(self, realm):
- "return list of all users within specified realm"
- realm = self._norm_realm(realm)
- return lmap(self._decode_ident,
- (key[0] for key in self._entry_order if key[1] == realm))
+ def users(self, realm=None):
+ """Return list of all users in specified realm.
+
+ * uses ``self.default_realm`` if no realm explicitly provided.
+ * returns empty list if realm not found.
+ """
+ realm = self._encode_realm(realm)
+ return [self._decode_field(key[0]) for key in self._records
+ if key[1] == realm]
+
+ ##def has_user(self, user, realm=None):
+ ## "check if user+realm combination exists"
+ ## user = self._encode_user(user)
+ ## realm = self._encode_realm(realm)
+ ## return (user,realm) in self._records
+
+ ##def rename_realm(self, old, new):
+ ## """rename all accounts in realm"""
+ ## old = self._encode_realm(old)
+ ## new = self._encode_realm(new)
+ ## keys = [key for key in self._records if key[1] == old]
+ ## for key in keys:
+ ## hash = self._records.pop(key)
+ ## self._records[key[0],new] = hash
+ ## self._autosave()
+ ## return len(keys)
+
+ ##def rename(self, old, new, realm=None):
+ ## """rename user account"""
+ ## old = self._encode_user(old)
+ ## new = self._encode_user(new)
+ ## realm = self._encode_realm(realm)
+ ## hash = self._records.pop((old,realm))
+ ## self._records[new,realm] = hash
+ ## self._autosave()
+
+ def set_password(self, user, realm=None, password=_UNSET):
+ """Set password for user; adds user & realm if needed.
+
+ If ``self.default_realm`` has been set, this may be called
+ with the syntax ``set_password(user, password)``,
+ otherwise it must be called with all three arguments:
+ ``set_password(user, realm, password)``.
+ :returns:
+ * ``True`` if existing user was updated
+ * ``False`` if user account added.
+ """
+ if password is _UNSET:
+ # called w/ two args - (user, password), use default realm
+ realm, password = None, realm
+ user = self._encode_user(user)
+ realm = self._encode_realm(realm)
+ key = (user, realm)
+ existing = (key in self._records)
+ hash = htdigest.encrypt(password, user, realm, encoding=self.encoding)
+ if PY3:
+ hash = hash.encode(self.encoding)
+ self._records[key] = hash
+ self._autosave()
+ return existing
+
+ @deprecated_method(deprecated="1.6", removed="1.8",
+ replacement="set_password")
def update(self, user, realm, password):
- """update password for user under specified realm; adding user if needed
+ "set password for user"
+ return self.set_password(user, realm, password)
+
+ # XXX: rename to something more explicit, like get_hash()?
+ def get_hash(self, user, realm=None):
+ """Return :class:`~passlib.hash.htdigest` hash stored for user.
+
+ * uses ``self.default_realm`` if no realm explicitly provided.
+ * returns ``None`` if user or realm not found.
- :returns: ``True`` if existing user was updated, ``False`` if user added.
+ .. versionchanged:: 1.6
+ This method was previously named ``find``, it was renamed
+ for clarity. The old name is deprecated, and will be removed
+ in Passlib 1.8.
"""
- user = self._norm_user(user)
- realm = self._norm_realm(realm)
- key = (user,realm)
- hash = self._calc_digest(user, realm, password)
- return self._update_key(key, hash)
+ key = (self._encode_user(user), self._encode_realm(realm))
+ hash = self._records.get(key)
+ if hash is None:
+ return None
+ if PY3:
+ hash = hash.decode(self.encoding)
+ return hash
+
+ @deprecated_method(deprecated="1.6", removed="1.8",
+ replacement="get_hash")
+ def find(self, user, realm):
+ "return hash for user"
+ return self.get_hash(user, realm)
- def delete(self, user, realm):
- """delete user's entry for specified realm.
+ # XXX: rename to something more explicit, like delete_user()?
+ def delete(self, user, realm=None):
+ """Delete user's entry for specified realm.
- :returns: ``True`` if user deleted, ``False`` if user not found in realm.
+ if realm is not specified, uses ``self.default_realm``.
+
+ :returns:
+ * ``True`` if user deleted,
+ * ``False`` if user not found in realm.
"""
- user = self._norm_user(user)
- realm = self._norm_realm(realm)
- return self._delete_key((user,realm))
+ key = (self._encode_user(user), self._encode_realm(realm))
+ try:
+ del self._records[key]
+ except KeyError:
+ return False
+ self._autosave()
+ return True
def delete_realm(self, realm):
- """delete all users for specified realm
+ """Delete all users for specified realm.
- :returns: number of users deleted
+ if realm is not specified, uses ``self.default_realm``.
+
+ :returns: number of users deleted (0 if realm not found)
"""
- realm = self._norm_realm(realm)
- keys = [
- key for key in self._entry_map
- if key[1] == realm
- ]
+ realm = self._encode_realm(realm)
+ records = self._records
+ keys = [key for key in records if key[1] == realm]
for key in keys:
- self._delete_key(key)
+ del records[key]
+ self._autosave()
return len(keys)
- def find(self, user, realm):
- """return digest hash for specified user+realm; returns ``None`` if not found
+ def check_password(self, user, realm=None, password=_UNSET):
+ """Verify password for specified user + realm.
- :returns: htdigest hash or None
- :rtype: bytes or None
- """
- user = self._norm_user(user)
- realm = self._norm_realm(realm)
- hash = self._entry_map.get((user,realm))
- if hash is not None and self.encoding:
- #decode hash if in unicode mode
- hash = hash.decode("ascii")
- return hash
-
- def verify(self, user, realm, password):
- """verify password for specified user + realm.
+ If ``self.default_realm`` has been set, this may be called
+ with the syntax ``check_password(user, password)``,
+ otherwise it must be called with all three arguments:
+ ``check_password(user, realm, password)``.
:returns:
- * ``None`` if user not found
- * ``False`` if password does not match
- * ``True`` if password matches.
+ * ``None`` if user or realm not found.
+ * ``False`` if user found, but password does not match.
+ * ``True`` if user found and password matches.
+
+ .. versionchanged:: 1.6
+ This method was previously called ``verify``, it was renamed
+ to prevent ambiguity with the :class:`!CryptContext` method.
+ The old alias is deprecated, and will be removed in Passlib 1.8.
"""
- user = self._norm_user(user)
- realm = self._norm_realm(realm)
- hash = self._entry_map.get((user,realm))
+ if password is _UNSET:
+ # called w/ two args - (user, password), use default realm
+ realm, password = None, realm
+ user = self._encode_user(user)
+ realm = self._encode_realm(realm)
+ hash = self._records.get((user,realm))
if hash is None:
return None
- result = self._calc_digest(user, realm, password)
- return consteq(result, hash)
+ return htdigest.verify(password, hash, user, realm,
+ encoding=self.encoding)
+
+ @deprecated_method(deprecated="1.6", removed="1.8",
+ replacement="check_password")
+ def verify(self, user, realm, password):
+ "verify password for user"
+ return self.check_password(user, realm, password)
+
+ #=========================================================
+ # eoc
+ #=========================================================
#=========================================================
# eof
diff --git a/passlib/apps.py b/passlib/apps.py
index 55dbea5..017de7e 100644
--- a/passlib/apps.py
+++ b/passlib/apps.py
@@ -112,7 +112,7 @@ def _create_phpass_policy(**kwds):
phpass_context = LazyCryptContext(
schemes=["bcrypt", "phpass", "bsdi_crypt"],
- create_policy=_create_phpass_policy,
+ onload=_create_phpass_policy,
)
phpbb3_context = LazyCryptContext(["phpass"], phpass__ident="H")
diff --git a/passlib/context.py b/passlib/context.py
index 2eb1a4f..5f84202 100644
--- a/passlib/context.py
+++ b/passlib/context.py
@@ -16,27 +16,57 @@ from time import sleep
from warnings import warn
#site
#libs
-from passlib.exc import PasslibConfigWarning, ExpectedStringError
+from passlib.exc import PasslibConfigWarning, ExpectedStringError, ExpectedTypeError
from passlib.registry import get_crypt_handler, _validate_handler_name
from passlib.utils import is_crypt_handler, rng, saslprep, tick, to_bytes, \
to_unicode
-from passlib.utils.compat import bytes, is_mapping, iteritems, num_types, \
+from passlib.utils.compat import bytes, iteritems, num_types, \
PY3, PY_MIN_32, unicode, SafeConfigParser, \
NativeStringIO, BytesIO, base_string_types
#pkg
#local
__all__ = [
- 'CryptPolicy',
'CryptContext',
+ 'LazyCryptContext',
+ 'CryptPolicy',
]
#=========================================================
-#crypt policy
+# support
#=========================================================
-# NOTE: doing this for security purposes, why would you ever want a fixed salt?
-#: hash settings which aren't allowed to be set via policy
-_forbidden_hash_options = frozenset([ "salt" ])
+# private object to detect unset params
+_UNSET = object()
+
+def _coerce_vary_rounds(value):
+ "parse vary_rounds string to percent as [0,1) float, or integer"
+ if value.endswith("%"):
+ # XXX: deprecate this in favor of raw float?
+ return float(value.rstrip("%"))*.01
+ try:
+ return int(value)
+ except ValueError:
+ return float(value)
+
+# set of options which aren't allowed to be set via policy
+_forbidden_scheme_options = set(["salt"])
+ # 'salt' - not allowed since a fixed salt would defeat the purpose.
+
+# dict containing funcs used to coerce strings to correct type
+# for scheme option keys.
+_coerce_scheme_options = dict(
+ min_rounds=int,
+ max_rounds=int,
+ default_rounds=int,
+ vary_rounds=_coerce_vary_rounds,
+ salt_size=int,
+)
+
+# dict mapping passprep policy name -> implementation
+_passprep_funcs = dict(
+ saslprep=saslprep,
+ raw=lambda s: s,
+)
def _splitcomma(source):
"split comma-separated string into list of strings"
@@ -47,18 +77,37 @@ def _splitcomma(source):
return []
return [ elem.strip() for elem in source.split(",") ]
-#--------------------------------------------------------
-#policy class proper
-#--------------------------------------------------------
+def _is_handler_registered(handler):
+ """detect if handler is registered or a custom handler"""
+ return get_crypt_handler(handler.name, None) is handler
+
+#=========================================================
+# crypt policy
+#=========================================================
+_preamble = ("The CryptPolicy class has been deprecated as of "
+ "Passlib 1.6, and will be removed in Passlib 1.8. ")
+
class CryptPolicy(object):
- """stores configuration options for a CryptContext object.
+ """This class has been deprecated, and will be removed in Passlib 1.8.
+
+ This class previously stored the configuration options for the
+ CryptContext class. In the interest of interface simplification,
+ all of this class' functionality has been rolled into the CryptContext
+ class itself.
- The CryptPolicy class constructor accepts a dictionary
- of keywords, which can include all the options
- listed in the :ref:`list of crypt context options <cryptcontext-options>`.
+ The documentation for this class is now focused on documenting how to
+ migrate to the new api. Additionally, where possible, the deprecation
+ warnings issued by the CryptPolicy methods will list the replacement call
+ that should be used.
Constructors
============
+ CryptPolicy objects can be constructed directly using any of
+ the keywords accepted by :class:`CryptContext`. Direct uses of the
+ :class:`!CryptPolicy` constructor should either pass the keywords
+ directly into the CryptContext constructor, or to :meth:`CryptContext.update`
+ if the policy object was being used to update an existing context object.
+
In addition to passing in keywords directly,
CryptPolicy objects can be constructed by the following methods:
@@ -70,6 +119,9 @@ class CryptPolicy(object):
Introspection
=============
+ All of the informational methods provided by this class have been deprecated
+ by identical or similar methods in the :class:`CryptContext` class:
+
.. automethod:: has_schemes
.. automethod:: schemes
.. automethod:: iter_handlers
@@ -86,660 +138,468 @@ class CryptPolicy(object):
.. automethod:: to_string
.. note::
- Instances of CryptPolicy should be treated as immutable.
+ CryptPolicy are immutable.
Use the :meth:`replace` method to mutate existing instances.
- """
+ .. deprecated:: 1.6
+ """
#=========================================================
#class methods
#=========================================================
-
- # NOTE: CryptPolicy always uses native strings for keys.
- # thus the from_path/from_string methods always treat files as utf-8
- # by default, leave the keys alone under py2, but decode to unicode
- # under py3.
-
@classmethod
def from_path(cls, path, section="passlib", encoding="utf-8"):
- """create new policy from specified section of an ini file.
+ """create a CryptPolicy instance from a local file.
- :arg path: path to ini file
- :param section: option name of section to read from.
- :arg encoding: optional encoding (defaults to utf-8)
+ .. deprecated:: 1.6
- :raises EnvironmentError: if the file cannot be read
+ Creating a new CryptContext from a file, which was previously done via
+ ``CryptContext(policy=CryptPolicy.from_path(path))``, can now be
+ done via ``CryptContext.from_path(path)``.
+ See :meth:`CryptContext.from_path` for details.
- :returns: new CryptPolicy instance.
+ Updating an existing CryptContext from a file, which was previously done
+ ``context.policy = CryptPolicy.from_path(path)``, can now be
+ done via ``context.load_path(path)``.
+ See :meth:`CryptContext.load_path` for details.
"""
- if PY3:
- # for python 3, need to provide a unicode stream,
- # so policy object's keys will be native str type (unicode).
- with open(path, "rt", encoding=encoding) as stream:
- return cls._from_stream(stream, section, path)
- elif encoding in ["utf-8", "ascii"]:
- # for python 2, need to provide utf-8 stream,
- # so policy object's keys will be native str type (utf-8 bytes)
- with open(path, "rb") as stream:
- return cls._from_stream(stream, section, path)
- else:
- # for python 2, need to transcode to utf-8 stream,
- # so policy object's keys will be native str type (utf-8 bytes)
- with open(path, "rb") as fh:
- stream = BytesIO(fh.read().decode(encoding).encode("utf-8"))
- return cls._from_stream(stream, section, path)
+ warn(_preamble +
+ "Instead of ``CryptPolicy.from_path(path)``, "
+ "use ``CryptContext.from_path(path)`` "
+ " or ``context.load_path(path)`` for an existing CryptContext.",
+ DeprecationWarning, stacklevel=2)
+ return cls(_internal_context=CryptContext.from_path(path, section,
+ encoding))
@classmethod
def from_string(cls, source, section="passlib", encoding="utf-8"):
- """create new policy from specified section of an ini-formatted string.
+ """create a CryptPolicy instance from a string.
- :arg source: bytes/unicode string containing ini-formatted content.
- :param section: option name of section to read from.
- :arg encoding: optional encoding if source is bytes (defaults to utf-8)
+ .. deprecated:: 1.6
- :returns: new CryptPolicy instance.
- """
- if PY3:
- source = to_unicode(source, encoding, errname="source")
- else:
- source = to_bytes(source, "utf-8", source_encoding=encoding,
- errname="source")
- return cls._from_stream(NativeStringIO(source), section, "<???>")
+ Creating a new CryptContext from a string, which was previously done via
+ ``CryptContext(policy=CryptPolicy.from_string(data))``, can now be
+ done via ``CryptContext.from_string(data)``.
+ See :meth:`CryptContext.from_string` for details.
- @classmethod
- def _from_stream(cls, stream, section, filename=None):
- "helper for from_string / from_path"
- p = SafeConfigParser()
- if PY_MIN_32:
- # python 3.2 deprecated readfp in favor of read_file
- p.read_file(stream, filename or "<???>")
- else:
- p.readfp(stream, filename or "<???>")
- return cls(dict(p.items(section)))
+ Updating an existing CryptContext from a string, which was previously done
+ ``context.policy = CryptPolicy.from_string(data)``, can now be
+ done via ``context.load(data)``.
+ See :meth:`CryptContext.load` for details.
+ """
+ warn(_preamble +
+ "Instead of ``CryptPolicy.from_string(source)``, "
+ "use ``CryptContext.from_string(source)`` or "
+ "``context.load(source)`` for an existing CryptContext.",
+ DeprecationWarning, stacklevel=2)
+ return cls(_internal_context=CryptContext.from_string(source, section,
+ encoding))
@classmethod
- def from_source(cls, source):
- """create new policy from input.
-
- :arg source:
- source may be a dict, CryptPolicy instance, filepath, or raw string.
-
- the exact type will be autodetected, and the appropriate constructor called.
-
- :raises TypeError: if source cannot be identified.
-
- :returns: new CryptPolicy instance.
+ def from_source(cls, source, _warn=True):
+ """create a CryptPolicy instance from some source.
+
+ this method autodetects the source type, and invokes
+ the appropriate constructor automatically. it attempts
+ to detect whether the source is a configuration string, a filepath,
+ a dictionary, or an existing CryptPolicy instance.
+
+ .. deprecated:: 1.6
+
+ Create a new CryptContext, which could previously be done via
+ ``CryptContext(policy=CryptPolicy.from_source(source))``, should
+ now be done using an explicit method: the :class:`CryptContext`
+ constructor itself, :meth:`CryptContext.from_path`,
+ or :meth:`CryptContext.from_string`.
+
+ Updating an existing CryptContext, which could previously be done via
+ ``context.policy = CryptPolicy.from_source(source)``, should
+ now be done using an explicit method: :meth:`CryptContext.update`,
+ or :meth:`CryptContext.load`.
"""
+ if _warn:
+ warn(_preamble +
+ "Instead of ``CryptPolicy.from_source()``, "
+ "use ``CryptContext.from_string(path)`` "
+ " or ``CryptContext.from_path(source)``, as appropriate.",
+ DeprecationWarning, stacklevel=2)
if isinstance(source, CryptPolicy):
- # NOTE: can just return source unchanged,
- # since we're treating CryptPolicy objects as read-only
return source
-
elif isinstance(source, dict):
- return cls(source)
-
- elif isinstance(source, (bytes,unicode)):
- # FIXME: this autodetection makes me uncomfortable...
- # it assumes none of these chars should be in filepaths,
- # but should be in config string, in order to distinguish them.
- if any(c in source for c in "\n\r\t") or \
- not source.strip(" \t./\;:"):
- return cls.from_string(source)
-
- # other strings should be filepath
- else:
- return cls.from_path(source)
+ return cls(_internal_context=CryptContext(**source))
+ elif not isinstance(source, (bytes,unicode)):
+ raise TypeError("source must be CryptPolicy, dict, config string, "
+ "or file path: %r" % (type(source),))
+ elif any(c in source for c in "\n\r\t") or not source.strip(" \t./\;:"):
+ return cls(_internal_context=CryptContext.from_string(source))
else:
- raise TypeError("source must be CryptPolicy, dict, config string, or file path: %r" % (type(source),))
+ return cls(_internal_context=CryptContext.from_path(source))
@classmethod
- def from_sources(cls, sources):
- """create new policy from list of existing policy objects.
+ def from_sources(cls, sources, _warn=True):
+ """create a CryptPolicy instance by merging multiple sources.
- this method takes multiple sources and composites them on top
- of eachother, returning a single resulting CryptPolicy instance.
- this allows default policies to be specified, and then overridden
- on a per-context basis.
+ each source is interpreted as by :meth:`from_source`,
+ and the results are merged together.
- :arg sources: list of sources to build policy from, elements may be any type accepted by :meth:`from_source`.
+ .. deprecated:: 1.6
- :returns: new CryptPolicy instance
+ Instead of using this method to merge multiple policies together,
+ a :class:`CryptContext` instance should be created, and then
+ the multiple sources merged together via :meth:`CryptContext.load`.
"""
- # check for no sources - should we return blank policy in that case?
+ if _warn:
+ warn(_preamble +
+ "Instead of ``CryptPolicy.from_sources()``, "
+ "use the various CryptContext constructors "
+ " followed by ``context.update()``.",
+ DeprecationWarning, stacklevel=2)
if len(sources) == 0:
- # XXX: er, would returning an empty policy be the right thing here?
raise ValueError("no sources specified")
-
- # check if only one source
if len(sources) == 1:
- return cls.from_source(sources[0])
-
- # else create policy from first source, update options, and rebuild.
- result = _UncompiledCryptPolicy()
- target = result._kwds
+ return cls.from_source(sources[0], _warn=False)
+ kwds = {}
for source in sources:
- policy = _UncompiledCryptPolicy.from_source(source)
- target.update(policy._kwds)
-
- #build new policy
- result._force_compile()
- return result
+ kwds.update(cls.from_source(source, _warn=False)._context.to_dict(resolve=True))
+ return cls(_internal_context=CryptContext(**kwds))
def replace(self, *args, **kwds):
- """return copy of policy, with specified options replaced by new values.
+ """create a new CryptPolicy, optionally updating parts of the
+ existing configuration.
- this is essentially a convience record around :meth:`from_sources`,
- except that it always inserts the current policy
- as the first element in the list;
- this allows easily making minor changes from an existing policy object.
+ .. deprecated:: 1.6
- :param \*args: optional list of sources as accepted by :meth:`from_sources`.
- :param \*\*kwds: optional specific options to override in the new policy.
-
- :returns: new CryptPolicy instance
+ Callers of this method should :meth:`CryptContext.update` or
+ :meth:`CryptContext.copy` instead.
"""
+ if self._stub_policy:
+ warn(_preamble +
+ "Instead of ``context.policy.replace()``, "
+ "use ``context.update()`` or ``context.copy()``.",
+ DeprecationWarning, stacklevel=2)
+ else:
+ warn(_preamble +
+ "Instead of ``CryptPolicy().replace()``, "
+ "create a CryptContext instance and "
+ "use ``context.update()`` or ``context.copy()``.",
+ DeprecationWarning, stacklevel=2)
sources = [ self ]
if args:
sources.extend(args)
if kwds:
sources.append(kwds)
- return CryptPolicy.from_sources(sources)
+ return CryptPolicy.from_sources(sources, _warn=False)
#=========================================================
#instance attrs
#=========================================================
- #: dict of (category,scheme,key) -> value, representing the original
- # raw keywords passed into constructor. the rest of the policy's data
- # structures are derived from this attribute via _compile()
- _kwds = None
- #: list of user categories in sorted order; first entry is always `None`
- _categories = None
-
- #: list of all schemes specified by `context.schemes`
- _schemes = None
-
- #: list of all handlers specified by `context.schemes`
- _handlers = None
+ # internal CryptContext we're wrapping to handle everything
+ # until this class is removed.
+ _context = None
- #: double-nested dict mapping key -> category -> normalized value.
- _context_options = None
-
- #: triply-nested dict mapping scheme -> category -> key -> normalized value.
- _scheme_options = None
+ # flag indicating this is wrapper generated by the CryptContext.policy
+ # attribute, rather than one created independantly by the application.
+ _stub_policy = False
#=========================================================
# init
#=========================================================
def __init__(self, *args, **kwds):
- if args:
- if len(args) != 1:
- raise TypeError("only one positional argument accepted")
- if kwds:
- raise TypeError("cannot specify positional arg and kwds")
- kwds = args[0]
- # XXX: type check, and accept strings for from_source ?
- parse = self._parse_option_key
- self._kwds = dict((parse(key), value) for key, value in
- iteritems(kwds))
- self._compile()
-
- @staticmethod
- def _parse_option_key(ckey):
- "helper to expand policy keys into ``(category, name, option)`` tuple"
- ##if isinstance(ckey, tuple):
- ## assert len(ckey) == 3, "keys must have 3 parts: %r" % (ckey,)
- ## return ckey
- parts = ckey.split("." if "." in ckey else "__")
- count = len(parts)
- if count == 1:
- return None, None, parts[0]
- elif count == 2:
- scheme, key = parts
- if scheme == "context":
- scheme = None
- return None, scheme, key
- elif count == 3:
- cat, scheme, key = parts
- if cat == "default":
- cat = None
- if scheme == "context":
- scheme = None
- return cat, scheme, key
+ context = kwds.pop("_internal_context", None)
+ if context:
+ assert isinstance(context, CryptContext)
+ self._context = context
+ self._stub_policy = kwds.pop("_stub_policy", False)
+ assert not (args or kwds), "unexpected args: %r %r" % (args,kwds)
else:
- raise TypeError("keys must have less than 3 separators: %r" %
- (ckey,))
+ if args:
+ if len(args) != 1:
+ raise TypeError("only one positional argument accepted")
+ if kwds:
+ raise TypeError("cannot specify positional arg and kwds")
+ kwds = args[0]
+ warn(_preamble +
+ "Instead of constructing a CryptPolicy instance, "
+ "create a CryptContext directly, or use ``context.update()`` "
+ "and ``context.load()`` to reconfigure existing CryptContext "
+ "instances.",
+ DeprecationWarning, stacklevel=2)
+ self._context = CryptContext(**kwds)
#=========================================================
- # compile internal data structures
+ # public interface for examining options
#=========================================================
- def _compile(self):
- "compile internal caches from :attr:`_kwds`"
- source = self._kwds
-
- # build list of handlers & schemes
- handlers = self._handlers = []
- schemes = self._schemes = []
- data = source.get((None,None,"schemes"))
- if isinstance(data, str):
- data = _splitcomma(data)
- if data:
- for elem in data:
- #resolve & validate handler
- if hasattr(elem, "name"):
- handler = elem
- scheme = handler.name
- _validate_handler_name(scheme)
- elif isinstance(elem, str):
- handler = get_crypt_handler(elem)
- scheme = handler.name
- else:
- raise TypeError("scheme must be name or crypt handler, "
- "not %r" % type(elem))
-
- #check scheme hasn't been re-used
- if scheme in schemes:
- raise KeyError("multiple handlers with same name: %r" %
- (scheme,))
-
- #add to handler list
- handlers.append(handler)
- schemes.append(scheme)
-
- # run through all other values in source, normalize them, and store in
- # scheme/context option dictionaries.
- scheme_options = self._scheme_options = {}
- context_options = self._context_options = {}
- norm_scheme_option = self._normalize_scheme_option
- norm_context_option = self._normalize_context_option
- cats = set()
- add_cat = cats.add
- for (cat, scheme, key), value in iteritems(source):
- add_cat(cat)
- if scheme:
- value = norm_scheme_option(key, value)
- if scheme in scheme_options:
- config = scheme_options[scheme]
- if cat in config:
- config[cat][key] = value
- else:
- config[cat] = {key: value}
- else:
- scheme_options[scheme] = {cat: {key: value}}
- elif key == "schemes":
- if cat:
- raise KeyError("'schemes' context option is not allowed "
- "per category")
- continue
- else:
- value = norm_context_option(key, value)
- if key in context_options:
- context_options[key][cat] = value
- else:
- context_options[key] = {cat: value}
-
- # store list of categories
- cats.discard(None)
- self._categories = [None] + sorted(cats)
+ def has_schemes(self):
+ """return True if policy defines *any* schemes for use.
- @staticmethod
- def _normalize_scheme_option(key, value):
- # some hash options can't be specified in the policy, e.g. 'salt'
- if key in _forbidden_hash_options:
- raise KeyError("Passlib does not permit %r handler option "
- "to be set via a policy object" % (key,))
-
- # for hash options, try to coerce everything to an int,
- # since most things are (e.g. the `*_rounds` options).
- elif isinstance(value, str):
- try:
- value = int(value)
- except ValueError:
- pass
- return value
-
- def _normalize_context_option(self, key, value):
- "validate & normalize option value"
- if key == "default":
- if hasattr(value, "name"):
- value = value.name
- schemes = self._schemes
- if schemes and value not in schemes:
- raise KeyError("default scheme not found in policy")
-
- elif key == "deprecated":
- if isinstance(value, str):
- value = _splitcomma(value)
- schemes = self._schemes
- if schemes:
- # if schemes are defined, do quick validation first.
- for scheme in value:
- if scheme not in schemes:
- raise KeyError("deprecated scheme not found "
- "in policy: %r" % (scheme,))
-
- elif key == "min_verify_time":
- warn("'min_verify_time' is deprecated as of Passlib 1.6, will be "
- "ignored in 1.7, and removed in 1.8.", DeprecationWarning)
- value = float(value)
- if value < 0:
- raise ValueError("'min_verify_time' must be >= 0")
+ .. deprecated:: 1.6
+ applications should use ``bool(context.schemes())`` instead.
+ see :meth:`CryptContext.schemes`.
+ """
+ if self._stub_policy:
+ warn(_preamble +
+ "Instead of ``context.policy.has_schemes()``, "
+ "use ``bool(context.schemes())``.",
+ DeprecationWarning, stacklevel=2)
else:
- raise KeyError("unknown context keyword: %r" % (key,))
-
- return value
+ warn(_preamble +
+ "Instead of ``CryptPolicy().has_schemes()``, "
+ "create a CryptContext instance and "
+ "use ``bool(context.schemes())``.",
+ DeprecationWarning, stacklevel=2)
+ return bool(self._context.schemes())
- #=========================================================
- # private helpers for reading options
- #=========================================================
- def _get_option(self, scheme, category, key, default=None):
- "get specific option value, without inheritance"
- try:
- if scheme:
- return self._scheme_options[scheme][category][key]
- else:
- return self._context_options[key][category]
- except KeyError:
- return default
+ def iter_handlers(self):
+ """return iterator over handlers defined in policy.
- def _get_handler_options(self, scheme, category):
- "return composite dict of handler options for given scheme + category"
- scheme_options = self._scheme_options
- has_cat_options = False
+ .. deprecated:: 1.6
- # start with options common to all schemes
- common_kwds = scheme_options.get("all")
- if common_kwds is None:
- kwds = {}
+ applications should use ``context.schemes(resolve=True))`` instead.
+ see :meth:`CryptContext.schemes`.
+ """
+ if self._stub_policy:
+ warn(_preamble +
+ "Instead of ``context.policy.iter_handlers()``, "
+ "use ``context.schemes(resolve=True)``.",
+ DeprecationWarning, stacklevel=2)
else:
- # start with global options
- tmp = common_kwds.get(None)
- kwds = tmp.copy() if tmp is not None else {}
-
- # add category options
- if category:
- tmp = common_kwds.get(category)
- if tmp is not None:
- kwds.update(tmp)
- has_cat_options = True
-
- # add scheme-specific options
- scheme_kwds = scheme_options.get(scheme)
- if scheme_kwds is not None:
- # add global options
- tmp = scheme_kwds.get(None)
- if tmp is not None:
- kwds.update(tmp)
+ warn(_preamble +
+ "Instead of ``CryptPolicy().iter_handlers()``, "
+ "create a CryptContext instance and "
+ "use ``context.schemes(resolve=True)``.",
+ DeprecationWarning, stacklevel=2)
+ return self._context.schemes(resolve=True)
- # add category options
- if category:
- tmp = scheme_kwds.get(category)
- if tmp is not None:
- kwds.update(tmp)
- has_cat_options = True
+ def schemes(self, resolve=False):
+ """return list of schemes defined in policy.
- # add context options
- context_options = self._context_options
- if context_options is not None:
- # add deprecated flag
- dep_map = context_options.get("deprecated")
- if dep_map:
- deplist = dep_map.get(None)
- dep = (deplist is not None and scheme in deplist)
- if category:
- deplist = dep_map.get(category)
- if deplist is not None:
- value = (scheme in deplist)
- if value != dep:
- dep = value
- has_cat_options = True
- if dep:
- kwds['deprecated'] = True
-
- # add min_verify_time flag
- mvt_map = context_options.get("min_verify_time")
- if mvt_map:
- mvt = mvt_map.get(None)
- if category:
- value = mvt_map.get(category)
- if value is not None and value != mvt:
- mvt = value
- has_cat_options = True
- if mvt:
- kwds['min_verify_time'] = mvt
+ .. deprecated:: 1.6
- return kwds, has_cat_options
+ applications should use :meth:`CryptContext.schemes` instead.
+ """
+ if self._stub_policy:
+ warn(_preamble +
+ "Instead of ``context.policy.schemes()``, "
+ "use ``context.schemes()``.",
+ DeprecationWarning, stacklevel=2)
+ else:
+ warn(_preamble +
+ "Instead of ``CryptPolicy().schemes()``, "
+ "create a CryptContext instance and "
+ "use ``context.schemes()``.",
+ DeprecationWarning, stacklevel=2)
+ return list(self._context.schemes(resolve=resolve))
- #=========================================================
- # public interface for examining options
- #=========================================================
- def has_schemes(self):
- "check if policy supports *any* schemes; returns True/False"
- return len(self._handlers) > 0
+ def get_handler(self, name=None, category=None, required=False):
+ """return handler as specified by name, or default handler.
- def iter_handlers(self):
- "iterate through handlers for all schemes in policy"
- return iter(self._handlers)
+ .. deprecated:: 1.6
- def schemes(self, resolve=False):
- "return list of supported schemes; if resolve=True, returns list of handlers instead"
- if resolve:
- return list(self._handlers)
+ applications should use :meth:`CryptContext.handler` instead,
+ though note that the ``required`` keyword has been removed,
+ and the new method will always act as if ``required=True``.
+ """
+ if self._stub_policy:
+ warn(_preamble +
+ "Instead of ``context.policy.get_handler()``, "
+ "use ``context.handler()``.",
+ DeprecationWarning, stacklevel=2)
else:
- return list(self._schemes)
-
- def get_handler(self, name=None, category=None, required=False):
- """given the name of a scheme, return handler which manages it.
+ warn(_preamble +
+ "Instead of ``CryptPolicy().get_handler()``, "
+ "create a CryptContext instance and "
+ "use ``context.handler()``.",
+ DeprecationWarning, stacklevel=2)
+ if not name: # CryptContext.handler() uses different default value
+ name = "default"
+ # CryptContext.handler() doesn't support required=False,
+ # so wrapping it in try/except
+ try:
+ return self._context.handler(name, category)
+ except KeyError:
+ if required:
+ raise
+ else:
+ return None
- :arg name: name of scheme, or ``None``
- :param category: optional user category
- :param required: if ``True``, raises KeyError if name not found, instead of returning ``None``.
+ def get_min_verify_time(self, category=None):
+ """get min_verify_time setting for policy.
- if name is not specified, attempts to return default handler.
- if returning default, and category is specified, returns category-specific default if set.
+ .. deprecated:: 1.6
- :returns: handler attached to specified name or None
+ min_verify_time will be removed entirely in passlib 1.8
"""
- if name is None:
- name = self._get_option(None, category, "default")
- if not name and category:
- name = self._get_option(None, None, "default")
- if not name and self._handlers:
- return self._handlers[0]
- if not name:
- if required:
- raise KeyError("no crypt algorithms found in policy")
- else:
- return None
- for handler in self._handlers:
- if handler.name == name:
- return handler
- if required:
- raise KeyError("crypt algorithm not found in policy: %r" % (name,))
- else:
- return None
+ warn("get_min_verify_time() and min_verify_time option is deprecated, "
+ "and will be removed in Passlib 1.8", DeprecationWarning,
+ stacklevel=2)
+ mvtmap = self._context._mvtmap
+ if category:
+ try:
+ return mvtmap[category]
+ except KeyError:
+ pass
+ return mvtmap.get(None) or 0
def get_options(self, name, category=None):
- """return dict of options for specified scheme
+ """return dictionary of options specific to a given handler.
- :arg name: name of scheme, or handler instance itself
- :param category: optional user category whose options should be returned
+ .. deprecated:: 1.6
- :returns: dict of options for CryptContext internals which are relevant to this name/category combination.
+ this method has no direct replacement in the 1.6 api, as there
+ is not a clearly defined use-case. however, examining the output of
+ :meth:`CryptContext.to_dict` should serve as the closest alternative.
"""
- # XXX: deprecate / enhance this function ?
+ # XXX: might make a public replacement, but need more study of the use cases.
+ if self._stub_policy:
+ warn(_preamble +
+ "``context.policy.get_options()`` will no longer be available.",
+ DeprecationWarning, stacklevel=2)
+ else:
+ warn(_preamble +
+ "``CryptPolicy().get_options()`` will no longer be available.",
+ DeprecationWarning, stacklevel=2)
if hasattr(name, "name"):
name = name.name
- return self._get_handler_options(name, category)[0]
+ return self._context._get_record_options(name, category)[0]
def handler_is_deprecated(self, name, category=None):
- "check if scheme is marked as deprecated according to this policy; returns True/False"
- # XXX: deprecate this function ?
+ """check if handler has been deprecated by policy.
+
+ .. deprecated:: 1.6
+
+ this method has no direct replacement in the 1.6 api, as there
+ is not a clearly defined use-case. however, examining the output of
+ :meth:`CryptContext.to_dict` should serve as the closest alternative.
+ """
+ # XXX: might make a public replacement, but need more study of the use cases.
+ if self._stub_policy:
+ warn(_preamble +
+ "``context.policy.handler_is_deprecated()`` will no longer be available.",
+ DeprecationWarning, stacklevel=2)
+ else:
+ warn(_preamble +
+ "``CryptPolicy().handler_is_deprecated()`` will no longer be available.",
+ DeprecationWarning, stacklevel=2)
if hasattr(name, "name"):
name = name.name
- kwds = self._get_handler_options(name, category)[0]
- return bool(kwds.get("deprecated"))
-
- def get_min_verify_time(self, category=None):
- warn("get_min_verify_time is deprecated, and will be removed in "
- "Passlib 1.8", DeprecationWarning)
- kwds = self._get_handler_options("all", category)[0]
- return kwds.get("min_verify_time") or 0
+ return self._context._is_deprecated_scheme(name, category)
#=========================================================
# serialization
#=========================================================
- ##def __iter__(self):
- ## return self.iter_config(resolve=True)
-
def iter_config(self, ini=False, resolve=False):
- """iterate through key/value pairs of policy configuration
-
- :param ini:
- If ``True``, returns data formatted for insertion
- into INI file. Keys use ``.`` separator instead of ``__``;
- lists of handlers are returned as comma-separated strings.
+ """iterate over key/value pairs representing the policy object.
- :param resolve:
- If ``True``, returns handler objects instead of handler
- names where appropriate. Ignored if ``ini=True``.
+ .. deprecated:: 1.6
- :returns:
- iterator which yields (key,value) pairs.
+ applications should use :meth:`CryptContext.to_dict` instead.
"""
- #
- #prepare formatting functions
- #
- sep = "." if ini else "__"
-
- def format_key(cat, name, key):
- if cat:
- return sep.join([cat, name or "context", key])
- if name:
- return sep.join([name, key])
- return key
-
- def encode_list(hl):
- if ini:
- return ", ".join(hl)
- else:
- return list(hl)
-
- #
- #run through contents of internal configuration
- #
+ if self._stub_policy:
+ warn(_preamble +
+ "Instead of ``context.policy.iter_config()``, "
+ "use ``context.to_dict().items()``.",
+ DeprecationWarning, stacklevel=2)
+ else:
+ warn(_preamble +
+ "Instead of ``CryptPolicy().iter_config()``, "
+ "create a CryptContext instance and "
+ "use ``context.to_dict().items()``.",
+ DeprecationWarning, stacklevel=2)
+ # hacked code that renders keys & values in manner that approximates
+ # old behavior. context.to_dict() is much cleaner.
+ context = self._context
+ if ini:
+ def render_key(key):
+ return context._render_config_key(key).replace("__", ".")
+ def render_value(value):
+ if isinstance(value, (list,tuple)):
+ value = ", ".join(value)
+ return value
+ resolve = False
+ else:
+ render_key = context._render_config_key
+ render_value = lambda value: value
+ return (
+ (render_key(key), render_value(value))
+ for key, value in context._iter_config(resolve)
+ )
- # write list of handlers at start
- if (None,None,"schemes") in self._kwds:
- if resolve and not ini:
- value = self._handlers
- else:
- value = self._schemes
- yield format_key(None, None, "schemes"), encode_list(value)
+ def to_dict(self, resolve=False):
+ """export policy object as dictionary of options.
- # then per-category elements
- scheme_items = sorted(iteritems(self._scheme_options))
- get_option = self._get_option
- for cat in self._categories:
+ .. deprecated:: 1.6
- # write deprecated list (if any)
- value = get_option(None, cat, "deprecated")
- if value is not None:
- yield format_key(cat, None, "deprecated"), encode_list(value)
+ applications should use :meth:`CryptContext.to_dict` instead.
+ """
+ if self._stub_policy:
+ warn(_preamble +
+ "Instead of ``context.policy.to_dict()``, "
+ "use ``context.to_dict()``.",
+ DeprecationWarning, stacklevel=2)
+ else:
+ warn(_preamble +
+ "Instead of ``CryptPolicy().to_dict()``, "
+ "create a CryptContext instance and "
+ "use ``context.to_dict()``.",
+ DeprecationWarning, stacklevel=2)
+ return self._context.to_dict(resolve)
- # write default declaration (if any)
- value = get_option(None, cat, "default")
- if value is not None:
- yield format_key(cat, None, "default"), value
+ def to_file(self, stream, section="passlib"):
+ """export policy to file.
- # write mvt (if any)
- value = get_option(None, cat, "min_verify_time")
- if value is not None:
- yield format_key(cat, None, "min_verify_time"), value
+ .. deprecated:: 1.6
- # write configs for all schemes
- for scheme, config in scheme_items:
- if cat in config:
- kwds = config[cat]
- for key in sorted(kwds):
- yield format_key(cat, scheme, key), kwds[key]
+ applications should use :meth:`CryptContext.to_string` instead,
+ and then write the output to a file as desired.
+ """
+ if self._stub_policy:
+ warn(_preamble +
+ "Instead of ``context.policy.to_file(stream)``, "
+ "use ``stream.write(context.to_string())``.",
+ DeprecationWarning, stacklevel=2)
+ else:
+ warn(_preamble +
+ "Instead of ``CryptPolicy().to_file(stream)``, "
+ "create a CryptContext instance and "
+ "use ``stream.write(context.to_string())``.",
+ DeprecationWarning, stacklevel=2)
+ out = self._context.to_string(section=section)
+ if PY2:
+ out = out.encode("utf-8")
+ stream.write(out)
- def to_dict(self, resolve=False):
- "return policy as dictionary of keywords"
- return dict(self.iter_config(resolve=resolve))
-
- def _escape_ini_pair(self, k, v):
- if isinstance(v, str):
- v = v.replace("%", "%%") #escape any percent signs.
- elif isinstance(v, num_types):
- v = str(v)
- return k,v
-
- def _write_to_parser(self, parser, section):
- "helper for to_string / to_file"
- parser.add_section(section)
- for k,v in self.iter_config(ini=True):
- k,v = self._escape_ini_pair(k,v)
- parser.set(section, k,v)
+ def to_string(self, section="passlib", encoding=None):
+ """export policy to file.
- #XXX: rename as "to_stream" or "write_to_stream" ?
- def to_file(self, stream, section="passlib"):
- "serialize to INI format and write to specified stream"
- p = SafeConfigParser()
- self._write_to_parser(p, section)
- p.write(stream)
+ .. deprecated:: 1.6
- def to_string(self, section="passlib", encoding=None):
- "render to INI string; inverse of from_string() constructor"
- buf = NativeStringIO()
- self.to_file(buf, section)
- out = buf.getvalue()
- if not PY3:
- out = out.decode("utf-8")
- if encoding:
- return out.encode(encoding)
+ applications should use :meth:`CryptContext.to_string` instead.
+ """
+ if self._stub_policy:
+ warn(_preamble +
+ "Instead of ``context.policy.to_string()``, "
+ "use ``context.to_string()``.",
+ DeprecationWarning, stacklevel=2)
else:
- return out
-
- ##def to_path(self, path, section="passlib", update=False):
- ## "write to INI file"
- ## p = ConfigParser()
- ## if update and os.path.exists(path):
- ## if not p.read([path]):
- ## raise EnvironmentError("failed to read existing file")
- ## p.remove_section(section)
- ## self._write_to_parser(p, section)
- ## fh = file(path, "w")
- ## p.write(fh)
- ## fh.close()
+ warn(_preamble +
+ "Instead of ``CryptPolicy().to_string()``, "
+ "create a CryptContext instance and "
+ "use ``context.to_string()``.",
+ DeprecationWarning, stacklevel=2)
+ out = self._context.to_string(section=section)
+ if encoding:
+ out = out.encode(encoding)
+ return out
#=========================================================
- #eoc
+ # eoc
#=========================================================
-class _UncompiledCryptPolicy(CryptPolicy):
- """helper class which parses options but doesn't compile them,
- used by CryptPolicy.from_sources() to efficiently merge policy objects.
- """
-
- def _compile(self):
- "convert to actual policy"
- pass
-
- def _force_compile(self):
- "convert to real policy and compile"
- self.__class__ = CryptPolicy
- self._compile()
-
#=========================================================
-# helpers for CryptContext
+# _CryptRecord helper class
#=========================================================
-_passprep_funcs = dict(
- saslprep=saslprep,
- raw=lambda s: s,
-)
-
class _CryptRecord(object):
"""wraps a handler and automatically applies various options.
@@ -757,23 +617,26 @@ class _CryptRecord(object):
# informational attrs
handler = None # handler instance this is wrapping
category = None # user category this applies to
+ deprecated = False # set if handler itself has been deprecated in config
- # rounds management
- _has_rounds = False # if handler has variable cost parameter
- _has_rounds_bounds = False # if min_rounds / max_rounds set
+ # rounds management - filled in by _init_rounds_options()
+ _has_rounds_options = False # if _has_rounds_bounds OR _generate_rounds is set
+ _has_rounds_bounds = False # if either min_rounds or max_rounds set
_min_rounds = None #: minimum rounds allowed by policy, or None
_max_rounds = None #: maximum rounds allowed by policy, or None
+ _generate_rounds = None # rounds generation function, or None
# encrypt()/genconfig() attrs
- _settings = None # subset of options to be used as encrypt() defaults.
+ settings = None # options to be passed directly to encrypt()
# verify() attrs
_min_verify_time = None
# hash_needs_update() attrs
- _has_rounds_introspection = False
+ _is_deprecated_by_handler = None # optional callable used by bcrypt/scram
+ _has_rounds_introspection = False # if rounds can be extract from hash
- # cloned from handler
+ # cloned directly from handler, not affected by config options.
identify = None
genhash = None
@@ -784,142 +647,173 @@ class _CryptRecord(object):
min_rounds=None, max_rounds=None, default_rounds=None,
vary_rounds=None, min_verify_time=None, passprep=None,
**settings):
+ # store basic bits
self.handler = handler
self.category = category
- self._compile_rounds(min_rounds, max_rounds, default_rounds,
- vary_rounds, 'rounds' in settings)
- self._compile_encrypt(settings)
- self._compile_verify(min_verify_time)
- self._compile_deprecation(deprecated)
+ self.deprecated = deprecated
+ self.settings = settings
+
+ # validate & normalize rounds options
+ self._init_rounds_options(min_rounds, max_rounds, default_rounds,
+ vary_rounds)
- # these aren't modified by the record, so just copy them directly
+ # init wrappers for handler methods we modify args to
+ self._init_encrypt_and_genconfig()
+ self._init_verify(min_verify_time)
+ self._init_hash_needs_update()
+
+ # these aren't wrapped by _CryptRecord, copy them directly from handler.
self.identify = handler.identify
self.genhash = handler.genhash
- # let stringprep code wrap genhash/encrypt if needed
- self._compile_passprep(passprep)
+ # let stringprep code wrap genhash/encrypt/verify if needed
+ self._init_passprep(passprep)
+ #================================================================
+ # virtual attrs
+ #================================================================
@property
def scheme(self):
return self.handler.name
@property
- def _ident(self):
+ def _errprefix(self):
"string used to identify record in error messages"
handler = self.handler
category = self.category
if category:
- return "%s %s policy" % (handler.name, category)
+ return "%s %s config" % (handler.name, category)
else:
- return "%s policy" % (handler.name,)
+ return "%s config" % (handler.name,)
+
+ def __repr__(self):
+ return "<_CryptRecord 0x%x for %s>" % (id(self), self._errprefix)
#================================================================
# rounds generation & limits - used by encrypt & deprecation code
#================================================================
- def _compile_rounds(self, mn, mx, df, vr, fixed):
+ def _init_rounds_options(self, mn, mx, df, vr):
"parse options and compile efficient generate_rounds function"
+ #----------------------------------------------------
+ # extract hard limits from handler itself
+ #----------------------------------------------------
handler = self.handler
if 'rounds' not in handler.setting_kwds:
+ # doesn't even support rounds keyword.
return
hmn = getattr(handler, "min_rounds", None)
hmx = getattr(handler, "max_rounds", None)
- def hcheck(value, name):
- "issue warnings if value outside of handler limits"
+ def check_against_handler(value, name):
+ "issue warning if value outside handler limits"
if hmn is not None and value < hmn:
warn("%s: %s value is below handler minimum %d: %d" %
- (self._ident, name, hmn, value), PasslibConfigWarning)
+ (self._errprefix, name, hmn, value), PasslibConfigWarning)
if hmx is not None and value > hmx:
warn("%s: %s value is above handler maximum %d: %d" %
- (self._ident, name, hmx, value), PasslibConfigWarning)
-
- def clip(value):
- "clip value to policy & handler limits"
- if mn is not None and value < mn:
- value = mn
- if hmn is not None and value < hmn:
- value = hmn
- if mx is not None and value > mx:
- value = mx
- if hmx is not None and value > hmx:
- value = hmx
- return value
+ (self._errprefix, name, hmx, value), PasslibConfigWarning)
#----------------------------------------------------
- # validate inputs
+ # set policy limits
#----------------------------------------------------
if mn is not None:
if mn < 0:
- raise ValueError("%s: min_rounds must be >= 0" % self._ident)
- hcheck(mn, "min_rounds")
+ raise ValueError("%s: min_rounds must be >= 0" % self._errprefix)
+ check_against_handler(mn, "min_rounds")
+ self._min_rounds = mn
+ self._has_rounds_bounds = True
if mx is not None:
if mn is not None and mx < mn:
raise ValueError("%s: max_rounds must be "
- ">= min_rounds" % self._ident)
+ ">= min_rounds" % self._errprefix)
elif mx < 0:
- raise ValueError("%s: max_rounds must be >= 0" % self._ident)
- hcheck(mx, "max_rounds")
-
- if vr is not None:
- if isinstance(vr, str):
- assert vr.endswith("%")
- vr = float(vr.rstrip("%"))
- if vr < 0:
- raise ValueError("%s: vary_rounds must be >= '0%%'" %
- self._ident)
- elif vr > 100:
- raise ValueError("%s: vary_rounds must be <= '100%%'" %
- self._ident)
- vr_is_pct = True
- else:
- assert isinstance(vr, int)
- if vr < 0:
- raise ValueError("%s: vary_rounds must be >= 0" %
- self._ident)
- vr_is_pct = False
-
- if df is None:
- # fallback to handler's default if available
- if vr or mx or mn:
- df = getattr(handler, "default_rounds", None) or mx or mn
- else:
+ raise ValueError("%s: max_rounds must be >= 0" % self._errprefix)
+ check_against_handler(mx, "max_rounds")
+ self._max_rounds = mx
+ self._has_rounds_bounds = True
+
+ #----------------------------------------------------
+ # validate default_rounds
+ #----------------------------------------------------
+ if df is not None:
if mn is not None and df < mn:
raise ValueError("%s: default_rounds must be "
- ">= min_rounds" % self._ident)
+ ">= min_rounds" % self._errprefix)
if mx is not None and df > mx:
raise ValueError("%s: default_rounds must be "
- "<= max_rounds" % self._ident)
- hcheck(df, "default_rounds")
+ "<= max_rounds" % self._errprefix)
+ check_against_handler(df, "default_rounds")
+ elif vr or mx or mn:
+ # need an explicit default to work with
+ df = getattr(handler, "default_rounds", None) or mx or mn
+ assert df is not None, "couldn't find fallback default_rounds"
+ else:
+ # no need for rounds generation
+ self._has_rounds_options = self._has_rounds_bounds
+ return
- #----------------------------------------------------
- # set policy limits
- #----------------------------------------------------
- self._has_rounds_bounds = (mn is not None) or (mx is not None)
- self._min_rounds = mn
- self._max_rounds = mx
+ # clip default to handler & policy limits *before* vary rounds
+ # is calculated, so that proportion vr values are scaled against
+ # the effective default.
+ def clip(value):
+ "clip value to intersection of policy + handler limits"
+ if mn is not None and value < mn:
+ value = mn
+ if hmn is not None and value < hmn:
+ value = hmn
+ if mx is not None and value > mx:
+ value = mx
+ if hmx is not None and value > hmx:
+ value = hmx
+ return value
+ df = clip(df)
#----------------------------------------------------
- # setup rounds generation function
+ # validate vary_rounds,
+ # coerce df/vr to linear scale,
+ # and setup scale_value() to undo coercion
#----------------------------------------------------
- if df is None or fixed:
- self._generate_rounds = None
- self._has_rounds = self._has_rounds_bounds
- elif vr:
- scale_value = lambda v,uf: v
- if vr_is_pct:
- scale = getattr(handler, "rounds_cost", "linear")
- assert scale in ["log2", "linear"]
- if scale == "log2":
+ # NOTE: vr=0 same as if vr not set
+ if vr:
+ if vr < 0:
+ raise ValueError("%s: vary_rounds must be >= 0" %
+ self._errprefix)
+ def scale_value(value, upper):
+ return value
+ if isinstance(vr, float):
+ # vr is value from 0..1 expressing fraction of default rounds.
+ if vr > 1:
+ # XXX: deprecate 1.0 ?
+ raise ValueError("%s: vary_rounds must be < 1.0" %
+ self._errprefix)
+ # calculate absolute vr value based on df & rounds_cost
+ cost_scale = getattr(handler, "rounds_cost", "linear")
+ assert cost_scale in ["log2", "linear"]
+ if cost_scale == "log2":
+ # convert df & vr to linear scale for limit calc,
+ # but define scale_value() to convert back to log2.
df = 1<<df
- def scale_value(v, uf):
- if v <= 0:
+ def scale_value(value, upper):
+ if value <= 0:
return 0
- elif uf:
- return int(logb(v,2))
+ elif upper:
+ return int(logb(value,2))
else:
- return int(ceil(logb(v,2)))
- vr = int(df*vr/100)
+ return int(ceil(logb(value,2)))
+ vr = int(df*vr)
+ elif not isinstance(vr, int):
+ raise TypeError("vary_rounds must be int or float")
+ # else: vr is explicit number of rounds to vary df by.
+
+ #----------------------------------------------------
+ # set up rounds generation function.
+ #----------------------------------------------------
+ if not vr:
+ # fixed rounds value
+ self._generate_rounds = lambda : df
+ else:
+ # randomly generate rounds in range df +/- vr
lower = clip(scale_value(df-vr,False))
upper = clip(scale_value(df+vr,True))
if lower == upper:
@@ -927,77 +821,93 @@ class _CryptRecord(object):
else:
assert lower < upper
self._generate_rounds = lambda: rng.randint(lower, upper)
- self._has_rounds = True
- else:
- df = clip(df)
- self._generate_rounds = lambda: df
- self._has_rounds = True
- # filled in by _compile_rounds_settings()
- _generate_rounds = None
+ # hack for bsdi_crypt - want to avoid even-valued rounds
+ # NOTE: this technically might generate a rounds value 1 larger
+ # than the requested upper bound - but better to err on side of safety.
+ if getattr(handler, "_avoid_even_rounds", False):
+ gen = self._generate_rounds
+ self._generate_rounds = lambda : gen()|1
+
+ self._has_rounds_options = True
#================================================================
# encrypt() / genconfig()
#================================================================
- def _compile_encrypt(self, settings):
+ def _init_encrypt_and_genconfig(self):
+ "initialize genconfig/encrypt wrapper methods"
+ settings = self.settings
handler = self.handler
- skeys = handler.setting_kwds
+
+ # check no invalid settings are being set
+ keys = handler.setting_kwds
for key in settings:
- if key not in skeys:
+ if key not in keys:
raise KeyError("keyword not supported by %s handler: %r" %
(handler.name, key))
- self._settings = settings
- if not (settings or self._has_rounds):
- # bypass prepare settings entirely.
+ # if _prepare_settings() has nothing to do, bypass our wrappers
+ # with reference to original methods.
+ if not (settings or self._has_rounds_options):
self.genconfig = handler.genconfig
self.encrypt = handler.encrypt
def genconfig(self, **kwds):
+ "wrapper for handler.genconfig() which adds custom settings/rounds"
self._prepare_settings(kwds)
return self.handler.genconfig(**kwds)
def encrypt(self, secret, **kwds):
+ "wrapper for handler.encrypt() which adds custom settings/rounds"
self._prepare_settings(kwds)
return self.handler.encrypt(secret, **kwds)
def _prepare_settings(self, kwds):
- "normalize settings for handler according to context configuration"
+ "add default values to settings for encrypt & genconfig"
#load in default values for any settings
- settings = self._settings
- for k in settings:
- if k not in kwds:
- kwds[k] = settings[k]
+ if kwds:
+ for k,v in iteritems(self.settings):
+ if k not in kwds:
+ kwds[k] = v
+ else:
+ # faster, and the common case
+ kwds.update(self.settings)
- #handle rounds
- if self._has_rounds:
+ # handle rounds
+ if self._has_rounds_options:
rounds = kwds.get("rounds")
if rounds is None:
+ # fill in default rounds value
gen = self._generate_rounds
if gen:
kwds['rounds'] = gen()
elif self._has_rounds_bounds:
+ # check bounds for application-provided rounds value.
# XXX: should this raise an error instead of warning ?
# NOTE: stackdepth=4 is so that error matches
# where ctx.encrypt() was called by application code.
mn = self._min_rounds
if mn is not None and rounds < mn:
warn("%s requires rounds >= %d, increasing value from %d" %
- (self._ident, mn, rounds), PasslibConfigWarning, 4)
+ (self._errprefix, mn, rounds), PasslibConfigWarning, 4)
rounds = mn
mx = self._max_rounds
if mx and rounds > mx:
warn("%s requires rounds <= %d, decreasing value from %d" %
- (self._ident, mx, rounds), PasslibConfigWarning, 4)
+ (self._errprefix, mx, rounds), PasslibConfigWarning, 4)
rounds = mx
kwds['rounds'] = rounds
#================================================================
# verify()
#================================================================
- def _compile_verify(self, mvt):
+ # TODO: once min_verify_time is removed, this will just be a clone
+ # of handler.verify()
+
+ def _init_verify(self, mvt):
+ "initialize verify() wrapper - implements min_verify_time"
if mvt:
- assert mvt > 0, "CryptPolicy should catch this"
+ assert isinstance(mvt, (int,float)) and mvt > 0, "CryptPolicy should catch this"
self._min_verify_time = mvt
else:
# no mvt wrapper needed, so just use handler.verify directly
@@ -1006,18 +916,17 @@ class _CryptRecord(object):
def verify(self, secret, hash, **context):
"verify helper - adds min_verify_time delay"
mvt = self._min_verify_time
- assert mvt > 0
+ assert mvt > 0, "wrapper should have been replaced for mvt=0"
start = tick()
- ok = self.handler.verify(secret, hash, **context)
- if ok:
+ if self.handler.verify(secret, hash, **context):
return True
end = tick()
delta = mvt + start - end
if delta > 0:
sleep(delta)
elif delta < 0:
- #warn app they exceeded bounds (this might reveal
- #relative costs of different hashes if under migration)
+ # warn app they exceeded bounds (this might reveal
+ # relative costs of different hashes if under migration)
warn("CryptContext: verify exceeded min_verify_time: "
"scheme=%r min_verify_time=%r elapsed=%r" %
(self.scheme, mvt, end-start), PasslibConfigWarning)
@@ -1026,8 +935,10 @@ class _CryptRecord(object):
#================================================================
# hash_needs_update()
#================================================================
- def _compile_deprecation(self, deprecated):
- if deprecated:
+ def _init_hash_needs_update(self):
+ """initialize state for hash_needs_update()"""
+ # if handler has been deprecated, replace wrapper and skip other checks
+ if self.deprecated:
self.hash_needs_update = lambda hash: True
return
@@ -1038,25 +949,28 @@ class _CryptRecord(object):
#
# NOTE: this interface is still private, because it was hacked in
# for the sake of bcrypt & scram, and is subject to change.
- #
handler = self.handler
const = getattr(handler, "_deprecation_detector", None)
if const:
- self._hash_needs_update = const(**self._settings)
+ self._is_deprecated_by_handler = const(**self.settings)
# XXX: what about a "min_salt_size" deprecator?
- # check if there are rounds, rounds limits, and if we can
- # parse the rounds from the handler. if that's the case...
+ # set flag if we can extract rounds from hash, allowing
+ # hash_needs_update() to check for rounds that are outside of
+ # the configured range.
if self._has_rounds_bounds and hasattr(handler, "from_string"):
self._has_rounds_introspection = True
def hash_needs_update(self, hash):
- # NOTE: this is replaced by _compile_deprecation() if self.deprecated
+ # init replaces this method entirely for this case.
+ ### check if handler has been deprecated
+ ##if self.deprecated:
+ ## return True
# check handler's detector if it provided one.
- hnu = self._hash_needs_update
- if hnu and hnu(hash):
+ check = self._is_deprecated_by_handler
+ if check and check(hash):
return True
# if we can parse rounds parameter, check if it's w/in bounds.
@@ -1065,11 +979,12 @@ class _CryptRecord(object):
try:
rounds = hash_obj.rounds
except AttributeError:
- # XXX: hash_obj should generally have rounds attr
- # should a warning be raised here?
+ # XXX: hash_obj should generally have rounds attr,
+ # so should a warning be raised here?
pass
else:
- if rounds < self._min_rounds:
+ mn = self._min_rounds
+ if mn is not None and rounds < mn:
return True
mx = self._max_rounds
if mx and rounds > mx:
@@ -1077,13 +992,10 @@ class _CryptRecord(object):
return False
- # filled in by init from handler._hash_needs_update
- _hash_needs_update = None
-
#================================================================
# password stringprep
#================================================================
- def _compile_passprep(self, value):
+ def _init_passprep(self, value):
# NOTE: all of this code assumes secret uses utf-8 encoding if bytes.
if not value:
return
@@ -1134,185 +1046,1134 @@ class _CryptRecord(object):
#================================================================
#=========================================================
-# context classes
+# main CryptContext class
#=========================================================
class CryptContext(object):
"""Helper for encrypting passwords using different algorithms.
- :param \*\*kwds:
-
- ``schemes`` and all other keywords are passed to the CryptPolicy constructor,
- or to :meth:`CryptPolicy.replace`, if a policy has also been specified.
-
- :param policy:
- Optionally you can pass in an existing CryptPolicy instance,
- which allows loading the policy from a configuration file,
- combining multiple policies together, and other features.
-
- The options from this policy will be used as defaults,
- which will be overridden by any keywords passed in explicitly.
-
- .. automethod:: replace
+ Instances of this class allow applications to choose a specific
+ set of hash algorithms which they wish to support, set limits and defaults
+ for the rounds and salt sizes those algorithms should use, flag
+ which algorithms should be deprecated, and automatically handle
+ migrating users to stronger hashes when they log in.
- Configuration
- =============
- .. attribute:: policy
+ This class can be created one of three ways: directly through it's
+ constructor via keywords, loaded from a configuration string,
+ or loaded from a file. Configuration strings / files can be created by hand;
+ or automatically created by serializing an existing CryptContext, using
+ :meth:``to_string``.
- This exposes the :class:`CryptPolicy` instance
- which contains the configuration used by this context object.
+ :param \*\*kwds:
- This attribute may be written to (replacing it with another CryptPolicy instance),
- in order to reconfigure a CryptContext while an application is running.
- However, this should only be done for context instances created by the application,
- and NOT for context instances provided by PassLib.
+ CryptContext instances accept a wide number of keywords as possible
+ options. Common keywords include ``schemes`` and ``default``.
+ See :doc:`/lib/passlib.context-options` for a full list.
Main Interface
==============
- .. automethod:: identify
+ Most applications will only need to make use two methods in a CryptContext
+ instance:
+
.. automethod:: encrypt
.. automethod:: verify
- Migration Helpers
- =================
+ Applications which want to detect and re-encrypt deprecated
+ hashes will want to use one of the following methods:
+
.. automethod:: hash_needs_update
.. automethod:: verify_and_update
+
+ Additionally, the main interface offers a few helper methods,
+ useful for certain border cases:
+
+ .. automethod:: identify
+ .. automethod:: genhash
+ .. automethod:: genconfig
+
+ Alternate Constructors
+ ======================
+ In addition to the main class constructor, which accepts a configuration
+ as a set of keywords, there are the following alternate constructors:
+
+ .. automethod:: from_string
+ .. automethod:: from_path
+ .. automethod:: copy
+
+ Updating the Configuration
+ ==========================
+ CryptContext objects can have their configuration replaced or updated
+ on the fly, and from a variety of sources (keywords, strings, files).
+ This is done through two methods:
+
+ .. automethod:: update(\*\*kwds)
+ .. automethod:: load
+ .. automethod:: load_path
+
+ Examining the Configuration
+ ===========================
+ The CryptContext object also supports some basic inspection of it's
+ current configuration:
+
+ .. automethod:: schemes
+ .. automethod:: default_scheme
+ .. automethod:: handler
+
+ More detailed inspection can be done through the serialization methods:
+
+ .. automethod:: to_dict
+ .. automethod:: to_string
"""
+ # FIXME: altering the configuration of this object isn't threadsafe,
+ # but is generally only done during application init, so not a major
+ # issue (just yet).
+
#===================================================================
#instance attrs
#===================================================================
- _policy = None # policy object governing context - access via :attr:`policy`
- _records = None # map of (category,scheme) -> _CryptRecord instance
- _record_lists = None # map of category -> records for category, in order
+
+ # tuple of handlers (from 'schemes' keyword)
+ _handlers = None
+
+ # tuple of scheme names (in same order as handlers)
+ _schemes = None
+
+ # tuple of extra category names (in alpha order, omits ``None``)
+ _categories = None
+
+ # triple-nested-dict which maps scheme -> category -> option -> value
+ _scheme_options = None
+
+ # dict mapping category -> default scheme
+ _default_schemes = None
+
+ # dict mapping category -> set of deprecated schemes
+ _deprecated_schemes = None
+
+ # dict mapping category -> min_verify_time
+ _mvtmap = None
+
+ # dict mapping (scheme,category) -> _CryptRecord instance.
+ # initial values populated by load(), but extra keys
+ # such as scheme=None for default record are populated on demand
+ # by _get_record()
+ _records = None
+
+ # dict mapping category -> list of _CryptRecord instances for that category,
+ # in order of schemes(). populated on demand by _get_record_list()
+ _record_lists = None
#===================================================================
- #init
+ # secondary constructors
#===================================================================
- def __init__(self, schemes=None, policy=None, **kwds):
- # XXX: add a name for the contexts, to help out repr?
- # XXX: add ability to make policy readonly for certain instances,
- # eg the builtin passlib ones?
- if schemes:
- kwds['schemes'] = schemes
- if not policy:
- policy = CryptPolicy(**kwds)
- elif kwds:
- policy = policy.replace(**kwds)
- self.policy = policy
+ @classmethod
+ def from_string(cls, source, section="passlib", encoding="utf-8"):
+ """create new CryptContext instance from an INI-formatted string.
- def __repr__(self):
- # XXX: *could* have proper repr(), but would have to render policy
- # object options, and it'd be *really* long
- names = [ handler.name for handler in self.policy._handlers ]
- return "<CryptContext %0xd schemes=%r>" % (id(self), names)
+ :arg source:
+ bytes/unicode string containing INI-formatted content.
- #XXX: make an update() method that just updates policy?
+ :param section:
+ option name of section to read from, defaults to ``"passlib"``.
- def replace(self, **kwds):
- """return mutated CryptContext instance
+ :arg encoding:
+ optional encoding used when source is bytes, defaults to ``"utf-8"``.
+
+ :returns:
+ new CryptContext instance.
+
+ usage example::
+
+ >>> from passlib.context import CryptContext
+ >>> context = CryptContext.from_string('''
+ ... [passlib]
+ ... schemes = sha256_crypt, des_crypt
+ ... sha256_crypt__default_rounds = 30000
+ ... ''')
+
+ .. versionadded:: 1.6
+ """
+ if not isinstance(source, base_string_types):
+ raise ExpectedTypeError(source, "unicode or bytes", "source")
+ self = cls(_autoload=False)
+ self.load(source, section=section, encoding=encoding)
+ return self
+
+ @classmethod
+ def from_path(cls, path, section="passlib", encoding="utf-8"):
+ """create new CryptContext instance from an INI-formatted file.
- this function operates much like :meth:`datetime.replace()` - it returns
- a new CryptContext instance whose configuration is exactly the
- same as the original, with the exception of any keywords
- specificed taking precedence over the original settings.
+ this functions exactly the same as :meth:`from_string`,
+ except that it loads from a local file.
- this is identical to the operation ``CryptContext(policy=self.policy.replace(**kwds))``,
- see :meth:`CryptPolicy.replace` for more details.
+ :arg source:
+ path to local file containing INI-formatted config.
+
+ :param section:
+ option name of section to read from, defaults to ``"passlib"``.
+
+ :arg encoding:
+ encoding used to load file, defaults to ``"utf-8"``.
+
+ :returns:
+ new CryptContext instance.
+
+ .. versionadded:: 1.6
"""
- return CryptContext(policy=self.policy.replace(**kwds))
+ self = cls(_autoload=False)
+ self.load_path(path, section=section, encoding=encoding)
+ return self
+
+ def copy(self, **kwds):
+ """return copy of existing CryptContext instance.
+
+ this function returns a new CryptContext instance whose configuration
+ is exactly the same as the original, with the exception that any keywords
+ passed in will take precedence over the original settings.
+
+ usage example::
+
+ >>> # given an existing context, e.g...
+ >>> from passlib.apps import custom_app_context
- #XXX: make an update() method that just updates policy?
- ##def update(self, **kwds):
- ## self.policy = self.policy.replace(**kwds)
+ >>> # copy can be used to make a clone
+ >>> my_context = custom_app_context.copy(default="sha512_crypt")
+
+ >>> # and the original will be unaffected by the change
+ >>> my_context.default_scheme()
+ "sha512_crypt"
+ >>> custom_app_context.default_scheme()
+ "sha256_crypt"
+
+ .. seealso:: :meth:`update`
+
+ .. versionchanged:: 1.6
+ This method was previously named ``replace``, that name
+ is still supported, but deprecated, and will be removed
+ in Passlib 1.8.
+ """
+ other = CryptContext(**self.to_dict(resolve=True))
+ if kwds:
+ other.load(kwds, update=True)
+ return other
+
+ def replace(self, **kwds):
+ "deprecated alias of :meth:`copy`"
+ warn("CryptContext().replace() has been deprecated in Passlib 1.6, "
+ "and will be removed in Passlib 1.8, "
+ "it has been renamed to CryptContext().copy()",
+ DeprecationWarning, stacklevel=2)
+ return self.copy(**kwds)
#===================================================================
- # policy management
+ #init
#===================================================================
+ def __init__(self, schemes=None,
+ # keyword only...
+ policy=_UNSET, # <-- deprecated
+ _autoload=True, **kwds):
+ # XXX: add ability to make flag certain contexts as immutable,
+ # e.g. the builtin passlib ones?
+ # XXX: add a name or import path for the contexts, to help out repr?
+ if schemes is not None:
+ kwds['schemes'] = schemes
+ if policy is not _UNSET:
+ warn("The CryptContext ``policy`` keyword has been deprecated as of Passlib 1.6, "
+ "and will be removed in Passlib 1.8; please use "
+ "``CryptContext.from_string()` or "
+ "``CryptContext.from_path()`` instead.",
+ DeprecationWarning)
+ if policy is None:
+ self.load(kwds)
+ elif isinstance(policy, CryptPolicy):
+ self.load(policy._context)
+ self.update(kwds)
+ else:
+ raise TypeError("policy must be a CryptPolicy instance")
+ elif _autoload:
+ self.load(kwds)
+ else:
+ assert not kwds, "_autoload=False and kwds are mutually exclusive"
+
+ def __str__(self):
+ if PY3:
+ return self.to_string()
+ else:
+ return self.to_string().encode("utf-8")
+
+ def __repr__(self):
+ return "<CryptContext 0x%0x>" % id(self)
+ # XXX: not sure if this would be helpful, or confusing...
+ ### let repr output string required to recreate the CryptContext
+ ##data = self.to_string(compact=True)
+ ##if not PY3:
+ ## data = data.encode("utf-8")
+ ##return "CryptContext.from_string(%r)" % (data,)
+
+ #===================================================================
+ # deprecated policy object
+ #===================================================================
def _get_policy(self):
- return self._policy
-
- def _set_policy(self, value):
- if not isinstance(value, CryptPolicy):
- raise TypeError("value must be a CryptPolicy instance")
- if value is not self._policy:
- self._policy = value
- self._compile()
-
- policy = property(_get_policy, _set_policy)
-
- #------------------------------------------------------------------
- # compile policy information into _CryptRecord instances
- #------------------------------------------------------------------
- def _compile(self):
- "update context object internals based on new policy instance"
- policy = self._policy
+ # The CryptPolicy class has been deprecated, so to support any
+ # legacy accesses, we create a stub policy object so .policy attr
+ # will continue to work.
+ #
+ # the code waits until app accesses a specific policy object attribute
+ # before issuing deprecation warning, so developer gets method-specific
+ # suggestion for how to upgrade.
+
+ # NOTE: making a copy of the context so the policy acts like a snapshot,
+ # to retain the pre-1.6 behavior.
+ return CryptPolicy(_internal_context=self.copy(), _stub_policy=True)
+
+ def _set_policy(self, policy):
+ warn("The CryptPolicy class and the ``context.policy`` attribute have "
+ "been deprecated as of Passlib 1.6, and will be removed in "
+ "Passlib 1.8; please use the ``context.load()`` and "
+ "``context.update()`` methods instead.",
+ DeprecationWarning, stacklevel=2)
+ if isinstance(policy, CryptPolicy):
+ self.load(policy._context)
+ else:
+ raise TypeError("expected CryptPolicy instance")
+
+ policy = property(_get_policy, _set_policy,
+ doc="[deprecated] returns CryptPolicy instance "
+ "tied to this CryptContext")
+
+ #===================================================================
+ # loading / updating configuration
+ #===================================================================
+ @staticmethod
+ def _parse_ini_stream(stream, section, filename):
+ "helper read INI from stream, extract passlib section as dict"
+ # NOTE: this expects a unicode stream under py3,
+ # and a utf-8 bytes stream under py2,
+ # allowing the resulting dict to always use native strings.
+ p = SafeConfigParser()
+ if PY_MIN_32:
+ # python 3.2 deprecated readfp in favor of read_file
+ p.read_file(stream, filename)
+ else:
+ p.readfp(stream, filename)
+ return dict(p.items(section))
+
+ def load_path(self, path, update=False, section="passlib", encoding="utf-8"):
+ """load new configuration into CryptContext from a local file.
+
+ This function is a wrapper for :meth:`load`, which
+ loads a configuration string from the local file *path*,
+ instead of an in-memory source. It's behavior and options
+ are otherwise identical to :meth:`!load` when provided with
+ an INI-formatted string.
+
+ .. versionadded:: 1.6
+ """
+ def helper(stream):
+ kwds = self._parse_ini_stream(stream, section, path)
+ return self.load(kwds, update=update)
+ if PY3:
+ # decode to unicode, which load() expected under py3
+ with open(path, "rt", encoding=encoding) as stream:
+ return helper(stream)
+ elif encoding in ["utf-8", "ascii"]:
+ # keep as utf-8 bytes, which load() expects under py2
+ with open(path, "rb") as stream:
+ return helper(stream)
+ else:
+ # transcode to utf-8 bytes
+ with open(path, "rb") as fh:
+ tmp = fh.read().decode(encoding).encode("utf-8")
+ return helper(BytesIO(tmp))
+
+ def load(self, source, update=False, section="passlib", encoding="utf-8"):
+ """load new configuration into CryptContext, replacing existing config.
+
+ :arg source:
+ source of new configuration.
+
+ *source* can be one of a few of different types:
+
+ :class:`!dict` or another mapping
+
+ the key/value pairs will be interpreted the same
+ keywords for the :class:`CryptContext` class constructor.
+
+ :class:`!unicode` or :class:`!bytes` string
+
+ this will be interpreted as an INI-formatted file,
+ and appropriate key/value pairs will be loaded from
+ the specified *section*.
+
+ :type update: bool
+ :param update:
+ By default, :meth:`load` will replace the existing configuration
+ entirely. If ``update=True``, it will preserve any existing
+ configuration options that are not overridden by the new source,
+ much like the :meth:`update` method.
+
+ :type section: str
+ :param section:
+ When parsing an INI-formatted string, :meth:`load` will look for
+ a section named ``"passlib"``. This option allows an alternate
+ section name to be used. Ignored when loading from a dictionary.
+
+ :type encoding: str
+ :param encoding:
+ Encoding to use when decode bytes from string.
+ Defaults to ``"utf-8"``. Ignoring when loading from a dictionary.
+
+ :raises TypeError:
+ * If the source cannot be identified.
+ * If an unknown / malformed keyword is encountered.
+
+ :raises ValueError:
+ If an invalid keyword value is encountered.
+
+ If an error occurs during a :meth:`!load` call, the :class`!CryptContext`
+ instance will be restored to the configuration it was in before
+ the :meth:`!load` call was made, it should never be left in an
+ inconsistent state due to a load failure.
+
+ .. versionadded:: 1.6
+ """
+ #-----------------------------------------------------------
+ # autodetect source type, convert to dict
+ #-----------------------------------------------------------
+ parse_keys = True
+ if isinstance(source, base_string_types):
+ if PY3:
+ source = to_unicode(source, encoding, errname="source")
+ else:
+ source = to_bytes(source, "utf-8", source_encoding=encoding,
+ errname="source")
+ source = self._parse_ini_stream(NativeStringIO(source), section,
+ "<string>")
+ elif isinstance(source, CryptContext):
+ # do this a little more efficiently since we can extract
+ # the keys as tuples directly from the other instance.
+ source = dict(source._iter_config(resolve=True))
+ parse_keys = False
+ elif not hasattr(source, "items"):
+ # assume it's not a mapping.
+ raise ExpectedTypeError(source, "string or dict", "source")
+
+ # XXX: add support for other iterable types, e.g. sequence of pairs?
+
+ #-----------------------------------------------------------
+ # parse dict keys into (category, scheme, option) format,
+ # and merge with existing configuration if needed.
+ #-----------------------------------------------------------
+ if parse_keys:
+ parse = self._parse_config_key
+ source = dict((parse(key), value) for key, value in iteritems(source))
+ if update and self._handlers is not None:
+ if not source:
+ return
+ tmp = source
+ source = dict(self._iter_config(resolve=True))
+ source.update(tmp)
+
+ #-----------------------------------------------------------
+ # clear internal config, replace with content of source.
+ #-----------------------------------------------------------
+ # NOTE: if this fails, 'self' will be an unpredicatable state,
+ # since config parsing can fail at a number of places.
+ # the follow code fixes this by backing up the state, and restoring
+ # it if any errors occur. this is somewhat... hacked...
+ # but it works for now, and performance is not an issue in the
+ # error case. but because of that, care should be taken
+ # that _load() never modifies existing attrs, and instead replaces
+ # them entirely.
+ state = self.__dict__.copy()
+ try:
+ self._load(source)
+ except:
+ self.__dict__.clear()
+ self.__dict__.update(state)
+ raise
+
+ def _load(self, source):
+ """load source keys into internal configuration.
+
+ note that if this throws error, object's config will be left
+ in inconsistent state, load() takes care of backing up / restoring
+ original config.
+ """
+ #-----------------------------------------------------------
+ # build & validate list of handlers
+ #-----------------------------------------------------------
+ handlers = []
+ schemes = []
+ data = source.get((None,None,"schemes"))
+ if isinstance(data, str):
+ data = _splitcomma(data)
+ for elem in data or ():
+ # resolve elem -> handler & scheme
+ if hasattr(elem, "name"):
+ handler = elem
+ scheme = handler.name
+ _validate_handler_name(scheme)
+ elif isinstance(elem, str):
+ handler = get_crypt_handler(elem)
+ scheme = handler.name
+ else:
+ raise TypeError("scheme must be name or CryptHandler, "
+ "not %r" % type(elem))
+
+ #check scheme name already in use
+ if scheme in schemes:
+ raise KeyError("multiple handlers with same name: %r" %
+ (scheme,))
+
+ #add to handler list
+ handlers.append(handler)
+ schemes.append(scheme)
+
+ self._handlers = handlers = tuple(handlers)
+ self._schemes = schemes = tuple(schemes)
+
+ #-----------------------------------------------------------
+ # initialize internal storage, write all scheme-specific options
+ # to _scheme_options, validate & store all global CryptContext
+ # options in the appropriate private attrs.
+ #-----------------------------------------------------------
+ scheme_options = self._scheme_options = {}
+ self._default_schemes = {}
+ self._deprecated_schemes = {}
+ self._mvtmap = {}
+ categories = set()
+ add_cat = categories.add
+ for (cat, scheme, key), value in iteritems(source):
+ add_cat(cat)
+ # store scheme-specific options for later,
+ # and let _CryptRecord() handle validation in next section.
+ if scheme:
+ # check for invalid options
+ if key == "rounds":
+ # for now, translating this to 'default_rounds' to be helpful.
+ # need to pick one of the two as official,
+ # and deprecate the other one.
+ key = "default_rounds"
+ elif key in _forbidden_scheme_options:
+ raise KeyError("%r option not allowed in CryptContext "
+ "configuration" % (key,))
+ # coerce strings for certain fields (e.g. min_rounds -> int)
+ if isinstance(value, str):
+ func = _coerce_scheme_options.get(key)
+ if func:
+ value = func(value)
+ # store value in scheme_options
+ if scheme in scheme_options:
+ config = scheme_options[scheme]
+ if cat in config:
+ config[cat][key] = value
+ else:
+ config[cat] = {key: value}
+ else:
+ scheme_options[scheme] = {cat: {key: value}}
+ # otherwise it's a CryptContext option of some type.
+ # perform validation here, and store internally.
+ elif key == "default":
+ if hasattr(value, "name"):
+ value = value.name
+ elif not isinstance(value, str):
+ raise ExpectedTypeError(value, "str", "default")
+ if schemes and value not in schemes:
+ raise KeyError("default scheme not found in policy")
+ self._default_schemes[cat] = value
+ elif key == "deprecated":
+ if isinstance(value, str):
+ value = _splitcomma(value)
+ elif not isinstance(value, (list,tuple)):
+ raise ExpectedTypeError(value, "str or seq", "deprecated")
+ if schemes:
+ for scheme in value:
+ if not isinstance(scheme, str):
+ raise ExpectedTypeError(value, "str", "deprecated element")
+ if scheme not in schemes:
+ raise KeyError("deprecated scheme not found "
+ "in policy: %r" % (scheme,))
+ # TODO: make sure there's at least one non-deprecated scheme.
+ # TODO: make sure default scheme hasn't been deprecated.
+ self._deprecated_schemes[cat] = value
+ elif key == "min_verify_time":
+ warn("'min_verify_time' is deprecated as of Passlib 1.6, will be "
+ "ignored in 1.7, and removed in 1.8.", DeprecationWarning)
+ value = float(value)
+ if value < 0:
+ raise ValueError("'min_verify_time' must be >= 0")
+ self._mvtmap[cat] = value
+ elif key == "schemes":
+ if cat:
+ raise KeyError("'schemes' context option is not allowed "
+ "per category")
+ #else: cat=None already handled above
+ else:
+ raise KeyError("unknown CryptContext keyword: %r" % (key,))
+ categories.discard(None)
+ self._categories = categories = tuple(sorted(categories))
+
+ #-----------------------------------------------------------
+ # compile table of _CryptRecord instances, one for every
+ # (scheme,category) combination.
+ #-----------------------------------------------------------
+ # NOTE: could do all of this on-demand in _get_record(),
+ # but _CryptRecord() handles final validation of settings,
+ # and we want to alert the user to errors now instead of later.
records = self._records = {}
self._record_lists = {}
- handlers = policy._handlers
- if not handlers:
+ get_options = self._get_record_options
+ for handler in handlers:
+ scheme = handler.name
+ kwds, _ = get_options(scheme, None)
+ records[scheme, None] = _CryptRecord(handler, **kwds)
+ for cat in categories:
+ kwds, has_cat_options = get_options(scheme, cat)
+ if has_cat_options:
+ records[scheme, cat] = _CryptRecord(handler, **kwds)
+ # NOTE: if handler has no category-specific opts, _get_record()
+ # will automatically use the default category's record.
+ # NOTE: default records for specific category stored under the
+ # key (None,category); these are populated on-demand by _get_record().
+
+ @staticmethod
+ def _parse_config_key(ckey):
+ """helper used to parse ``cat__scheme__option`` keys into a tuple"""
+ # split string into 1-3 parts
+ assert isinstance(ckey, str)
+ parts = ckey.split("." if "." in ckey else "__")
+ count = len(parts)
+ if count == 1:
+ cat, scheme, key = None, None, parts[0]
+ elif count == 2:
+ cat = None
+ scheme, key = parts
+ elif count == 3:
+ cat, scheme, key = parts
+ else:
+ raise TypeError("keys must have less than 3 separators: %r" %
+ (ckey,))
+ # validate & normalize the parts
+ if cat == "default":
+ cat = None
+ elif not cat and cat is not None:
+ raise TypeError("empty category: %r" % ckey)
+ if scheme == "context":
+ scheme = None
+ elif not scheme and scheme is not None:
+ raise TypeError("empty scheme: %r" % ckey)
+ if not key:
+ raise TypeError("empty option: %r" % ckey)
+ return cat, scheme, key
+
+ def update(self, *args, **kwds):
+ """helper for quickly changing configuration.
+
+ this acts much like the dictionary :meth:`!update` method;
+ it accepts any keyword accepted by the :class:`CryptContext`
+ constructor, and updates the context's configuration,
+ replacing the original value(s).
+
+ .. versionadded:: 1.6
+
+ .. seealso:: :meth:`copy`
+ """
+ if args:
+ if len(args) > 1:
+ raise TypeError("expected at most one positional argument")
+ if kwds:
+ raise TypeError("positional arg and keywords mutually exclusive")
+ self.load(args[0], update=True)
+ elif kwds:
+ self.load(kwds, update=True)
+
+ # XXX: make this public?
+ def _simplify(self):
+ "helper to remove redundant/unused options"
+ # don't do anything if no schemes are defined
+ if not self._schemes:
return
- get_option = policy._get_option
- get_handler_options = policy._get_handler_options
- schemes = policy._schemes
- default_scheme = get_option(None, None, "default") or schemes[0]
- for cat in policy._categories:
- # build record for all schemes, re-using record from default
- # category if there aren't any category-specific options.
- for handler in handlers:
- scheme = handler.name
- kwds, has_cat_options = get_handler_options(scheme, cat)
- if cat and not has_cat_options:
- records[scheme, cat] = records[scheme, None]
+
+ def strip_items(target, filter):
+ keys = [key for key,value in iteritems(target)
+ if filter(key,value)]
+ for key in keys:
+ del target[key]
+
+ # remove redundant default.
+ defaults = self._default_schemes
+ if defaults.get(None) == self._schemes[0]:
+ del defaults[None]
+
+ # remove options for unused schemes.
+ scheme_options = self._scheme_options
+ schemes = self._schemes + ("all",)
+ strip_items(scheme_options, lambda k,v: k not in schemes)
+
+ # remove rendundant cat defaults.
+ cur = self.default_scheme()
+ strip_items(defaults, lambda k,v: k and v==cur)
+
+ # remove redundant category deprecations.
+ deprecated = self._deprecated_schemes
+ cur = self._deprecated_schemes.get(None)
+ strip_items(deprecated, lambda k,v: k and v==cur)
+
+ # remove redundant category options.
+ for scheme, config in iteritems(scheme_options):
+ if None in config:
+ cur = config[None]
+ strip_items(config, lambda k,v: k and v==cur)
+
+ # XXX: anything else?
+
+ #===================================================================
+ # reading configuration
+ #===================================================================
+ def _get_record_options(self, scheme, category):
+ """return composite dict of options for given scheme + category.
+
+ this is currently a private method, though some variant
+ of it's output may eventually be made public.
+
+ given a scheme & category, it returns two things:
+ a set of all the keyword options to pass to the _CryptRecord constructor,
+ and a bool flag indicating whether any of these options
+ were specific to the named category. if this flag is false,
+ the options are identical to the options for the default category.
+
+ the options dict includes all the scheme-specific settings,
+ as well as optional *deprecated* and *min_verify_time* keywords.
+ """
+ scheme_options = self._scheme_options
+ has_cat_options = False
+
+ # start with options common to all schemes
+ common_kwds = scheme_options.get("all")
+ if common_kwds is None:
+ kwds = {}
+ else:
+ # start with global options
+ tmp = common_kwds.get(None)
+ kwds = tmp.copy() if tmp is not None else {}
+
+ # add category options
+ if category:
+ tmp = common_kwds.get(category)
+ if tmp is not None:
+ kwds.update(tmp)
+ has_cat_options = True
+
+ # add scheme-specific options
+ scheme_kwds = scheme_options.get(scheme)
+ if scheme_kwds:
+ # add global options
+ tmp = scheme_kwds.get(None)
+ if tmp is not None:
+ kwds.update(tmp)
+
+ # add category options
+ if category:
+ tmp = scheme_kwds.get(category)
+ if tmp is not None:
+ kwds.update(tmp)
+ has_cat_options = True
+
+ # add deprecated flag
+ dep_map = self._deprecated_schemes
+ if dep_map:
+ deplist = dep_map.get(None)
+ dep = (deplist is not None and scheme in deplist)
+ if category:
+ deplist = dep_map.get(category)
+ if deplist is not None:
+ value = (scheme in deplist)
+ if value != dep:
+ dep = value
+ has_cat_options = True
+ if dep:
+ kwds['deprecated'] = True
+
+ # add min_verify_time setting
+ mvt_map = self._mvtmap
+ if mvt_map:
+ mvt = mvt_map.get(None)
+ if category:
+ value = mvt_map.get(category)
+ if value is not None and value != mvt:
+ mvt = value
+ has_cat_options = True
+ if mvt:
+ kwds['min_verify_time'] = mvt
+
+ return kwds, has_cat_options
+
+ def schemes(self, resolve=False):
+ """return schemes loaded into this CryptContext instance.
+
+ :returns:
+ returns list of schemes as a tuple.
+ if ``resolve=True``, returns the actual handler objects instead
+ of the names (but in the same order).
+
+ .. versionadded:: 1.6
+ This was previously available as ``CryptContext().policy.schemes()``
+ """
+ return self._handlers if resolve else self._schemes
+
+ # XXX: need to decide if exposing this would be useful to applications
+ # in any way that isn't already served by to_dict()
+ ##def deprecated_schemes(self, category=None, resolve=False):
+ ## """return tuple of deprecated schemes"""
+ ## depmap = self._deprecated_schemes
+ ## if category and category in depmap:
+ ## deplist = depmap[category]
+ ## elif None in depmap:
+ ## deplist = depmap[None]
+ ## else:
+ ## return self.schemes(resolve)
+ ## if resolve:
+ ## return tuple(handler for handler in self._handlers
+ ## if handler.name not in deplist)
+ ## else:
+ ## return tuple(scheme for scheme in self._schemes
+ ## if scheme not in deplist)
+
+ def _is_deprecated_scheme(self, scheme, category=None):
+ "helper used by unittests to check if scheme is deprecated"
+ return self._get_record(scheme, category).deprecated
+# kwds, _ = self._get_record_options(scheme, category)
+# return bool(kwds.get("deprecated"))
+
+ def default_scheme(self, category=None, resolve=False):
+ """return name of scheme that :meth:`encrypt` will use by default.
+
+ .. versionadded:: 1.6
+ """
+ if resolve:
+ scheme = self.default_scheme(category)
+ for handler in self._handlers:
+ if handler.name == scheme:
+ return handler
+ raise AssertionError("failed to find matching handler")
+ defaults = self._default_schemes
+ if defaults:
+ try:
+ return defaults[category]
+ except KeyError:
+ pass
+ if category:
+ try:
+ return defaults[None]
+ except KeyError:
+ pass
+ try:
+ return self._schemes[0]
+ except IndexError:
+ raise KeyError("no crypt algorithms loaded in this "
+ "CryptContext instance")
+
+ # XXX: need to decide if exposing this would be useful in any way
+ ##def categories(self):
+ ## """return user-categories with algorithm-specific options in this CryptContext.
+ ##
+ ## this will always return a tuple.
+ ## if no categories besides the default category have been configured,
+ ## the tuple will be empty.
+ ## """
+ ## return self._categories
+
+ # XXX: need to decide if exposing this would be useful to applications
+ # in any meaningful way that isn't already served by to_dict()
+ ##def options(self, scheme, category=None):
+ ## kwds, percat = self._config.get_options(scheme, category)
+ ## kwds.pop("min_verify_time", None)
+ ## return kwds
+
+ def handler(self, scheme="default", category=None):
+ """helper to resolve name of scheme -> handler object.
+
+ the scheme may optionally be set to ``"default"``,
+ in which case the handler attached to the default scheme will be
+ returned. if ``category`` is specified, the default for that
+ category will be returned.
+
+ this will raise a :exc:`KeyError` if no scheme of that name has been
+ loaded into this CryptContext.
+
+ .. versionadded:: 1.6
+ This was previously available as ``CryptContext().policy.get_handler()``
+ """
+ if scheme == "default":
+ return self.default_scheme(category, True)
+ for handler in self._handlers:
+ if handler.name == scheme:
+ return handler
+ if self._handlers:
+ raise KeyError("crypt algorithm not found in this "
+ "CryptContext instance: %r" % (scheme,))
+ else:
+ raise KeyError("no crypt algorithms loaded in this "
+ "CryptContext instance")
+
+ def _has_unregistered(self):
+ "check if any handlers in this context aren't in the global registry"
+ return not all(_is_handler_registered(handler)
+ for handler in self._handlers)
+
+ #===================================================================
+ # exporting config
+ #===================================================================
+ def _iter_config(self, resolve=False):
+ """regenerate original config.
+
+ this is an iterator which yields ``(cat,scheme,option),value`` items,
+ in the order they generally appear inside an INI file.
+ if interpreted as a dictionary, it should match the original
+ keywords passed to the CryptContext (aside from any canonization).
+
+ it's mainly used as the internal backend for most of the public
+ serialization methods.
+ """
+ # grab various bits of data
+ defaults = self._default_schemes
+ deprecated = self._deprecated_schemes
+ mvt = self._mvtmap
+ scheme_options = self._scheme_options
+ schemes = sorted(scheme_options)
+
+ # write loaded schemes (may differ from 'schemes' local var)
+ value = self._schemes
+ if value:
+ if resolve:
+ value = self._handlers
+ yield (None, None, "schemes"), list(value)
+
+ # then run through config for each user category
+ for cat in (None,) + self._categories:
+
+ # write default scheme (if set)
+ value = defaults.get(cat)
+ if value is not None:
+ yield (cat, None, "default"), value
+
+ # write deprecated-schemes list (if set)
+ value = deprecated.get(cat)
+ if value is not None:
+ yield (cat, None, "deprecated"), list(value)
+
+ # write mvt (if set)
+ value = mvt.get(cat)
+ if value is not None:
+ yield (cat, None, "min_verify_time"), value
+
+ # write per-scheme options for all schemes.
+ for scheme in schemes:
+ try:
+ kwds = scheme_options[scheme][cat]
+ except KeyError:
+ pass
else:
- records[scheme, cat] = _CryptRecord(handler, cat, **kwds)
- # clone default scheme's record to None so we can resolve default
- if cat:
- scheme = get_option(None, cat, "default") or default_scheme
+ for key in sorted(kwds):
+ yield (cat, scheme, key), kwds[key]
+
+ @staticmethod
+ def _render_config_key(key, compact=False):
+ "convert 3-part config key to single string"
+ cat, scheme, option = key
+ if cat:
+ fmt = "%s.%s.%s" if compact else "%s__%s__%s"
+ return fmt % (cat, scheme or "context", option)
+ elif scheme:
+ fmt = "%s.%s" if compact else "%s__%s"
+ return fmt % (scheme, option)
+ else:
+ return option
+
+ @staticmethod
+ def _render_ini_value(key, value):
+ "render value to string suitable for INI file"
+ # convert lists to comma separated lists
+ # (mainly 'schemes' & 'deprecated')
+ if isinstance(value, (list,tuple)):
+ value = ", ".join(value)
+
+ # convert numbers to strings
+ elif isinstance(value, num_types):
+ if isinstance(value, float) and key[2] == "vary_rounds":
+ value = ("%.2f" % value).rstrip("0") if value else "0"
else:
- scheme = default_scheme
- records[None, cat] = records[scheme, cat]
+ value = str(value)
+
+ assert isinstance(value, str), \
+ "expected string for key: %r %r" % (key, value)
+
+ #escape any percent signs.
+ return value.replace("%", "%%")
+
+ def to_dict(self, resolve=False):
+ """return as config dictionary;
+
+ if ``resolve=True``, the ``schemes`` key will contain
+ a list of handler objects rather than just their names.
+
+ the output of this should be acceptable as input
+ to the CryptContext constructor.
+
+ usage example::
+
+ >>> # you can dump the configuration of any crypt context...
+ >>> from passlib.apps import ldap_nocrypt_context
+ >>> ldap_nocrypt_context.to_dict()
+ {'schemes': ['ldap_salted_sha1',
+ 'ldap_salted_md5',
+ 'ldap_sha1',
+ 'ldap_md5',
+ 'ldap_plaintext']}
+
+ .. versionadded:: 1.6
+ This was previously available as ``CryptContext().policy.to_dict()``
+ """
+ # XXX: should resolve default to conditional behavior
+ # based on presence of unregistered handlers?
+ render_key = self._render_config_key
+ return dict((render_key(key), value)
+ for key, value in self._iter_config(resolve))
+
+ def _write_to_parser(self, parser, section, compact=False):
+ "helper to write to ConfigParser instance"
+ render_key = self._render_config_key
+ render_value = self._render_ini_value
+ parser.add_section(section)
+ for k,v in self._iter_config():
+ v = render_value(k, v)
+ k = render_key(k, compact)
+ parser.set(section, k, v)
+
+ def to_string(self, section="passlib", compact=False):
+ """serialize to INI format and return as unicode string.
+
+ :param section:
+ name of INI section to output, defaults to ``"passlib"``.
+
+ :param compact:
+ if ``True``, this will attempt to return as short a string
+ as possible, rather than a readable one.
+
+ :returns:
+ CryptContext configuration, serialized to a INI unicode string.
+
+ usage example::
+
+ >>> # you can dump the configuration of any crypt context...
+ >>> from passlib.apps import ldap_nocrypt_context
+ >>> print ldap_nocrypt_context.to_string()
+ [passlib]
+ schemes = ldap_salted_sha1, ldap_salted_md5, ldap_sha1, ldap_md5, ldap_plaintext
+
+ passing the output of this method into :meth:`load`,
+ :meth:`from_string` or :meth:`from_file` should recreate
+ the exact state of the :class:`CryptContext` instance.
+
+ .. versionadded:: 1.6
+ This was previously available as ``CryptContext().policy.to_string()``
+ """
+ parser = SafeConfigParser()
+ self._write_to_parser(parser, section, compact)
+ buf = NativeStringIO()
+ parser.write(buf)
+ out = buf.getvalue()
+ if compact:
+ out = out.replace(", ", ",").rstrip() + "\n"
+ out = re.sub(r"(?m)^([\w.]+)\s+=\s*", r"\1=", out)
+ else:
+ names = [ handler.name for handler in self._handlers
+ if not _is_handler_registered(handler) ]
+ if names:
+ names = ", ".join(repr(name) for name in names)
+ out += "# NOTE: the %s handler(s) are not registered with Passlib,\n" % names
+ out += "# so this string may not correctly reproduce the current configuration.\n\n"
+ if not PY3:
+ out = out.decode("utf-8")
+ return out
- def _get_record(self, scheme, category=None, required=True):
- "private helper used by CryptContext"
+ # XXX: is this useful enough to enable?
+ ##def write_to_path(self, path, section="passlib", update=False):
+ ## "write to INI file"
+ ## parser = ConfigParser()
+ ## if update and os.path.exists(path):
+ ## if not parser.read([path]):
+ ## raise EnvironmentError("failed to read existing file")
+ ## parser.remove_section(section)
+ ## self._write_to_parser(parser, section)
+ ## fh = file(path, "w")
+ ## parser.write(fh)
+ ## fh.close()
+
+ #===================================================================
+ # _CryptRecord cache
+ #===================================================================
+
+ # NOTE: the CryptContext object takes the current configuration,
+ # and creates a _CryptRecord containing the settings for each
+ # (scheme,category) combination. This is used by encrypt() etc
+ # to do a quick lookup of the appropriate record,
+ # and hand off the real work to the record's methods,
+ # which are optimized for the specific set of options.
+
+ def _get_record(self, scheme, category=None):
+ "return record for specific scheme & category (cached)"
+ # quick lookup in cache
try:
return self._records[scheme, category]
except KeyError:
pass
+
+ # if scheme=None, use category's default scheme,
+ # and cache for next time.
+ if not scheme:
+ default = self.default_scheme(category)
+ assert default
+ record = self._records[None, category] = self._get_record(default,
+ category)
+ return record
+
+ # if category has no record for scheme, use default category's record,
+ # and cache for next time.
if category:
- # category not referenced in policy file.
- # so populate cache from default category.
- cache = self._records
try:
- record = cache[scheme, None]
+ cache = self._records
+ record = cache[scheme, category] = cache[scheme, None]
+ return record
except KeyError:
pass
- else:
- cache[scheme, category] = record
- return record
- if not required:
- return None
- elif scheme:
+
+ # scheme not found in configuration.
+ if scheme:
raise KeyError("crypt algorithm not found in policy: %r" %
(scheme,))
else:
- assert not self._policy._handlers
+ assert not self._schemes, "somehow lost default scheme!"
raise KeyError("no crypt algorithms supported")
def _get_record_list(self, category=None):
- "return list of records for category"
+ "return list of records for category (cached)"
try:
return self._record_lists[category]
except KeyError:
- # XXX: could optimize for categories not in policy.
- get = self._get_record
value = self._record_lists[category] = [
- get(scheme, category)
- for scheme in self._policy._schemes
- ]
+ self._get_record(scheme, category)
+ for scheme in self._schemes
+ ]
return value
def _identify_record(self, hash, category, required=True):
"""internal helper to identify appropriate _CryptRecord for hash"""
+ # FIXME: if multiple hashes could match (e.g. lmhash vs nthash)
+ # this will only return first match. might want to do something
+ # about this in future, but for now only hashes with unique identifiers
+ # will work properly in a CryptContext.
if not isinstance(hash, base_string_types):
raise ExpectedStringError(hash, "hash")
records = self._get_record_list(category)
@@ -1327,14 +2188,14 @@ class CryptContext(object):
raise ValueError("hash could not be identified")
#===================================================================
- #password hash api proxy methods
+ # password hash api
#===================================================================
# NOTE: all the following methods do is look up the appropriate
# _CryptRecord for a given (scheme,category) combination,
- # and then let the record object take care of the rest,
- # since it will have optimized itself for the particular
- # settings used within the policy by that (scheme,category).
+ # and then let the record object take care of the rest.
+ # Each record object stores the options used
+ # by the specific (scheme,category) combination it manages.
# XXX: would a better name be needs_update/is_deprecated?
def hash_needs_update(self, hash, scheme=None, category=None):
@@ -1346,7 +2207,7 @@ class CryptContext(object):
number of rounds, and other properties against the current policy;
and returns True if the hash is using a deprecated scheme,
or is otherwise outside of the bounds specified by the policy.
- if so, the password should be re-encrypted using ``ctx.encrypt(passwd)``.
+ if so, the password should be re-encrypted using :meth:`encrypt`.
:arg hash: existing hash string
:param scheme: optionally identify specific scheme to check against.
@@ -1382,7 +2243,7 @@ class CryptContext(object):
(using the default if none other is specified).
See the :ref:`password-hash-api` for details.
"""
- #XXX: could insert normalization to preferred unicode encoding here
+ # XXX: could insert normalization to preferred unicode encoding here
return self._get_record(scheme, category).genhash(secret, config,
**context)
@@ -1429,8 +2290,24 @@ class CryptContext(object):
:returns:
The secret as encoded by the specified algorithm and options.
+
+ usage example::
+
+ >>> # given an existing CryptContext...
+ >>> from passlib.apps import custom_app_context
+
+ >>> # calling encrypt will generate a new salt, and hash
+ >>> # the password using the context's default scheme
+ >>> custom_app_context.encrypt("fooey")
+ '$5$rounds=39987$WWelpiPfgTdTrpSr$tosg/YcCKCzePQnB6eseVh8YdSKRkCJ6uQ/QpXoEUm.'
+
+ >>> # optionally you can specify a particular scheme and options
+ >>> # (though if these options are to be hardcoded, you should
+ >>> # just create your own CryptContext instance).
+ >>> custom_app_context.encrypt("fooey", "sha512_crypt", rounds=5000)
+ '$6$D3OcBBac5qGdvIbT$mQvIwPS8bWx2DnSrZrFK3e1Rie1vv8hlixCoBfDSJ..Bg1E0PJVzKzAkdt/cBm9CdADrCxv6tOPjqqn8AxpF01'
"""
- #XXX: could insert normalization to preferred unicode encoding here
+ # XXX: could insert normalization to preferred unicode encoding here
return self._get_record(scheme, category).encrypt(secret, **kwds)
def verify(self, secret, hash, scheme=None, category=None, **context):
@@ -1464,14 +2341,14 @@ class CryptContext(object):
record = self._get_record(scheme, category)
else:
record = self._identify_record(hash, category)
- # XXX: strip context kwds if scheme doesn't use them?
+ # XXX: have record strip context kwds if scheme doesn't use them?
# XXX: could insert normalization to preferred unicode encoding here
return record.verify(secret, hash, **context)
def verify_and_update(self, secret, hash, scheme=None, category=None, **kwds):
"""verify secret and check if hash needs upgrading, in a single call.
- This is a convience method for a common situation in most applications:
+ This is a convenience method for a common situation in most applications:
When a user logs in, they must :meth:`verify` if the password matches;
if successful, check if the hash algorithm
has been deprecated (:meth:`hash_needs_update`); and if so,
@@ -1511,11 +2388,12 @@ class CryptContext(object):
record = self._get_record(scheme, category)
else:
record = self._identify_record(hash, category)
- # XXX: strip context kwds if scheme doesn't use them?
- # XXX: could insert normalization to preferred unicode encoding here
+ # XXX: have record strip context kwds if scheme doesn't use them?
+ # XXX: could insert normalization to preferred unicode encoding here.
if not record.verify(secret, hash, **kwds):
return False, None
elif record.hash_needs_update(hash):
+ # NOTE: we re-encrypt with default scheme, not current one.
return True, self.encrypt(secret, None, category, **kwds)
else:
return True, None
@@ -1536,20 +2414,28 @@ class LazyCryptContext(CryptContext):
the first positional argument can be a list of schemes, or omitted,
just like CryptContext.
- :param create_policy:
+ :param onload:
if a callable is passed in via this keyword,
it will be invoked at lazy-load time
with the following signature:
- ``create_policy(**kwds) -> CryptPolicy``;
+ ``onload(**kwds) -> kwds``;
where ``kwds`` is all the additional kwds passed to LazyCryptContext.
- It should return a CryptPolicy instance, which will then be used
- by the CryptContext.
+ It should perform any additional deferred initialization,
+ and return the final dict of options to be passed to CryptContext.
+
+ .. versionadded:: 1.6
+
+ :param create_policy:
+
+ .. deprecated:: 1.6
+ This option will be removed in Passlib 1.8.
+ Applications should use *onload* instead.
:param kwds:
- All additional keywords are passed to CryptPolicy;
- or to the create_policy function if provided.
+ All additional keywords are passed to CryptContext;
+ or to the *onload* function (if provided).
This is mainly used internally by modules such as :mod:`passlib.apps`,
which define a large number of contexts, but only a few of them will be needed
@@ -1565,7 +2451,7 @@ class LazyCryptContext(CryptContext):
# previously it just called _lazy_init() when ``.policy`` was
# first accessed. now that is done whenever any of the public
# attributes are accessed, and the class itself is changed
- # to a regular CryptContext, to remove the overhead one it's unneeded.
+ # to a regular CryptContext, to remove the overhead once it's unneeded.
def __init__(self, schemes=None, **kwds):
if schemes is not None:
@@ -1575,16 +2461,26 @@ class LazyCryptContext(CryptContext):
def _lazy_init(self):
kwds = self._lazy_kwds
if 'create_policy' in kwds:
+ warn("The CryptPolicy class, and LazyCryptContext's "
+ "``create_policy`` keyword have been deprecated as of "
+ "Passlib 1.6, and will be removed in Passlib 1.8; "
+ "please use the ``onload`` keyword instead.",
+ DeprecationWarning)
create_policy = kwds.pop("create_policy")
- policy = create_policy(**kwds)
- kwds = dict(policy=CryptPolicy.from_source(policy))
- super(LazyCryptContext, self).__init__(**kwds)
+ result = create_policy(**kwds)
+ policy = CryptPolicy.from_source(result, _warn=False)
+ kwds = policy._context.to_dict()
+ elif 'onload' in kwds:
+ onload = kwds.pop("onload")
+ kwds = onload(**kwds)
del self._lazy_kwds
+ super(LazyCryptContext, self).__init__(**kwds)
self.__class__ = CryptContext
def __getattribute__(self, attr):
- if not attr.startswith("_"):
- self._lazy_init()
+ if (not attr.startswith("_") or attr.startswith("__")) and \
+ self._lazy_kwds is not None:
+ self._lazy_init()
return object.__getattribute__(self, attr)
#=========================================================
diff --git a/passlib/exc.py b/passlib/exc.py
index 1e78123..5f7d1dd 100644
--- a/passlib/exc.py
+++ b/passlib/exc.py
@@ -48,10 +48,10 @@ class PasslibConfigWarning(PasslibWarning):
This occurs primarily in one of two cases:
- * the policy contains rounds limits which exceed the hard limits
+ * the CryptContext contains rounds limits which exceed the hard limits
imposed by the underlying algorithm.
* an explicit rounds value was provided which exceeds the limits
- imposed by the policy.
+ imposed by the CryptContext.
In both of these cases, the code will perform correctly & securely;
but the warning is issued as a sign the configuration may need updating.
@@ -102,17 +102,25 @@ def _get_name(handler):
#----------------------------------------------------------------
# encrypt/verify parameter errors
#----------------------------------------------------------------
-def ExpectedStringError(value, param):
- "error message when param was supposed to be unicode or bytes"
- # NOTE: value is never displayed, since it may sometimes be a password.
+def type_name(value):
+ "return pretty-printed string containing name of value's type"
cls = value.__class__
if cls.__module__ and cls.__module__ not in ["__builtin__", "builtins"]:
- name = "%s.%s" % (cls.__module__, cls.__name__)
+ return "%s.%s" % (cls.__module__, cls.__name__)
elif value is None:
- name = 'None'
+ return 'None'
else:
- name = cls.__name__
- return TypeError("%s must be unicode or bytes, not %s" % (param, name))
+ return cls.__name__
+
+def ExpectedTypeError(value, expected, param):
+ "error message when param was supposed to be one type, but found another"
+ # NOTE: value is never displayed, since it may sometimes be a password.
+ name = type_name(value)
+ return TypeError("%s must be %s, not %s" % (param, expected, name))
+
+def ExpectedStringError(value, param):
+ "error message when param was supposed to be unicode or bytes"
+ return ExpectedTypeError(value, "unicode or bytes", param)
def MissingDigestError(handler=None):
"raised when verify() method gets passed config string instead of hash"
diff --git a/passlib/ext/django/models.py b/passlib/ext/django/models.py
index d76cc9c..0bf9b99 100644
--- a/passlib/ext/django/models.py
+++ b/passlib/ext/django/models.py
@@ -13,7 +13,7 @@ see the Passlib documentation for details on how to use this app
#site
from django.conf import settings
#pkg
-from passlib.context import CryptContext, CryptPolicy
+from passlib.context import CryptContext
from passlib.utils import is_crypt_context
from passlib.utils.compat import bytes, unicode, base_string_types
from passlib.ext.django.utils import DEFAULT_CTX, get_category, \
@@ -35,7 +35,7 @@ def patch():
if ctx == "passlib-default":
ctx = DEFAULT_CTX
if isinstance(ctx, base_string_types):
- ctx = CryptContext(policy=CryptPolicy.from_string(ctx))
+ ctx = CryptContext.from_string(ctx)
if not is_crypt_context(ctx):
raise TypeError("django settings.PASSLIB_CONTEXT must be CryptContext "
"instance or configuration string: %r" % (ctx,))
diff --git a/passlib/handlers/bcrypt.py b/passlib/handlers/bcrypt.py
index f07a194..890c656 100644
--- a/passlib/handlers/bcrypt.py
+++ b/passlib/handlers/bcrypt.py
@@ -186,8 +186,7 @@ class bcrypt(uh.HasManyIdents, uh.HasRounds, uh.HasSalt, uh.HasManyBackends, uh.
def _norm_salt(self, salt, **kwds):
salt = super(bcrypt, self)._norm_salt(salt, **kwds)
- if not salt:
- return None
+ assert salt is not None, "HasSalt didn't generate new salt!"
changed, salt = bcrypt64.check_repair_unused(salt)
if changed:
warn(
@@ -250,10 +249,14 @@ class bcrypt(uh.HasManyIdents, uh.HasRounds, uh.HasSalt, uh.HasManyBackends, uh.
assert hash.startswith(config) and len(hash) == len(config)+31
return hash[-31:]
else:
- #NOTE: not checking other backends since this is lowest priority one,
- # so they probably aren't available either.
+ # NOTE: it's unlikely any other backend will be available,
+ # but checking before we bail, just in case.
+ for name in self.backends:
+ if name != "os_crypt" and self.has_backend(name):
+ func = getattr(self, "_calc_checksum_" + name)
+ return func(secret)
raise uh.exc.MissingBackendError(
- "encoded password can't be handled by os_crypt, "
+ "password can't be handled by os_crypt, "
"recommend installing py-bcrypt.",
)
diff --git a/passlib/handlers/cisco.py b/passlib/handlers/cisco.py
index 184134e..c61a105 100644
--- a/passlib/handlers/cisco.py
+++ b/passlib/handlers/cisco.py
@@ -149,7 +149,7 @@ class cisco_type7(uh.GenericHandler):
else:
raise TypeError("no salt specified")
if not isinstance(salt, int):
- raise TypeError("salt must be an integer")
+ raise uh.exc.ExpectedTypeError(salt, "integer", "salt")
if salt < 0 or salt > self.max_salt_value:
msg = "salt/offset must be in 0..52 range"
if self.relaxed:
diff --git a/passlib/handlers/des_crypt.py b/passlib/handlers/des_crypt.py
index 56102c0..4a19532 100644
--- a/passlib/handlers/des_crypt.py
+++ b/passlib/handlers/des_crypt.py
@@ -1,54 +1,4 @@
-"""passlib.handlers.des_crypt - traditional unix (DES) crypt and variants
-
-.. note::
-
- for des-crypt, passlib restricts salt characters to just the hash64 charset,
- and salt string size to >= 2 chars; since implementations of des-crypt
- vary in how they handle other characters / sizes...
-
- linux
-
- linux crypt() accepts salt characters outside the hash64 charset,
- and maps them using the following formula (determined by examining crypt's output):
- chr 0..64: v = (c-(1-19)) & 63 = (c+18) & 63
- chr 65..96: v = (c-(65-12)) & 63 = (c+11) & 63
- chr 97..127: v = (c-(97-38)) & 63 = (c+5) & 63
- chr 128..255: same as c-128
-
- invalid salt chars are mirrored back in the resulting hash.
-
- if the salt is too small, it uses a NUL char for the remaining
- character (which is treated the same as the char ``G``)
- when decoding the 12 bit salt. however, it outputs
- a hash string containing the single salt char twice,
- resulting in a corrupted hash.
-
- netbsd
-
- netbsd crypt() uses a 128-byte lookup table,
- which is only initialized for the hash64 values.
- the remaining values < 128 are implicitly zeroed,
- and values > 128 access past the array bounds
- (but seem to return 0).
-
- if the salt string is too small, it reads
- the NULL char (and continues past the end for bsdi crypt,
- though the buffer is usually large enough and NULLed).
- salt strings are output as provided,
- except for any NULs, which are converted to ``.``.
-
- openbsd, freebsd
-
- openbsd crypt() strictly defines the hash64 values as normal,
- and all other char values as 0. salt chars are reported as provided.
-
- if the salt or rounds string is too small,
- it'll read past the end, resulting in unpredictable
- values, though it'll terminate it's encoding
- of the output at the first null.
- this will generally result in a corrupted hash.
-"""
-
+"""passlib.handlers.des_crypt - traditional unix (DES) crypt and variants"""
#=========================================================
#imports
#=========================================================
@@ -60,7 +10,7 @@ from warnings import warn
#libs
from passlib.utils import classproperty, h64, h64big, safe_crypt, test_crypt, to_unicode
from passlib.utils.compat import b, bytes, byte_elem_value, u, uascii_to_str, unicode
-from passlib.utils.des import mdes_encrypt_int_block
+from passlib.utils.des import des_encrypt_int_block
import passlib.utils.handlers as uh
#pkg
#local
@@ -72,70 +22,86 @@ __all__ = [
]
#=========================================================
-#pure-python backend
+# pure-python backend for des_crypt family
#=========================================================
def _crypt_secret_to_key(secret):
- "crypt helper which converts lower 7 bits of first 8 chars of secret -> 56-bit des key, padded to 64 bits"
- return sum(
- (byte_elem_value(c) & 0x7f) << (57-8*i)
- for i, c in enumerate(secret[:8])
- )
-
-def raw_crypt(secret, salt):
- "pure-python fallback if stdlib support not present"
+ """convert secret to 64-bit DES key.
+
+ this only uses the first 8 bytes of the secret,
+ and discards the high 8th bit of each byte at that.
+ a null parity bit is inserted after every 7th bit of the output.
+ """
+ # NOTE: this would set the parity bits correctly,
+ # but des_encrypt_int_block() would just ignore them...
+ ##return sum(expand_7bit(byte_elem_value(c) & 0x7f) << (56-i*8)
+ ## for i, c in enumerate(secret[:8]))
+ return sum((byte_elem_value(c) & 0x7f) << (57-i*8)
+ for i, c in enumerate(secret[:8]))
+
+def _raw_des_crypt(secret, salt):
+ "pure-python backed for des_crypt"
assert len(salt) == 2
- #NOTE: technically could accept non-standard salts & single char salt,
- #but no official spec.
+ # NOTE: some OSes will accept non-HASH64 characters in the salt,
+ # but what value they assign these characters varies wildy,
+ # so just rejecting them outright.
+ # NOTE: the same goes for single-character salts...
+ # some OSes duplicate the char, some insert a '.' char,
+ # and openbsd does something which creates an invalid hash.
try:
salt_value = h64.decode_int12(salt)
except ValueError: #pragma: no cover - always caught by class
raise ValueError("invalid chars in salt")
- #FIXME: ^ this will throws error if bad salt chars are used
- # whereas linux crypt does something (inexplicable) with it
- #convert first 8 bytes of secret string into an integer
+ # forbidding NULL char because underlying crypt() rejects them too.
+ if b('\x00') in secret:
+ raise ValueError("null char in secret")
+
+ # convert first 8 bytes of secret string into an integer
key_value = _crypt_secret_to_key(secret)
- #run data through des using input of 0
- result = mdes_encrypt_int_block(key_value, 0, salt_value, 25)
+ # run data through des using input of 0
+ result = des_encrypt_int_block(key_value, 0, salt_value, 25)
- #run h64 encode on result
+ # run h64 encode on result
return h64big.encode_int64(result)
-def raw_ext_crypt(secret, rounds, salt):
- "ext_crypt() helper which returns checksum only"
+def _bsdi_secret_to_key(secret):
+ "covert secret to DES key used by bsdi_crypt"
+ key_value = _crypt_secret_to_key(secret)
+ idx = 8
+ end = len(secret)
+ while idx < end:
+ next = idx+8
+ tmp_value = _crypt_secret_to_key(secret[idx:next])
+ key_value = des_encrypt_int_block(key_value, key_value) ^ tmp_value
+ idx = next
+ return key_value
+
+def _raw_bsdi_crypt(secret, rounds, salt):
+ "pure-python backend for bsdi_crypt"
- #decode salt
+ # decode salt
try:
salt_value = h64.decode_int24(salt)
except ValueError: #pragma: no cover - always caught by class
raise ValueError("invalid salt")
- #validate secret
- if b('\x00') in secret: #pragma: no cover - always caught by class
- #builtin linux crypt doesn't like this, so we don't either
- #XXX: would make more sense to raise ValueError, but want to be compatible w/ stdlib crypt
+ # forbidding NULL char because underlying crypt() rejects them too.
+ if b('\x00') in secret:
raise ValueError("secret must be string without null bytes")
- #convert secret string into an integer
- key_value = _crypt_secret_to_key(secret)
- idx = 8
- end = len(secret)
- while idx < end:
- next = idx+8
- key_value = mdes_encrypt_int_block(key_value, key_value) ^ \
- _crypt_secret_to_key(secret[idx:next])
- idx = next
+ # convert secret string into an integer
+ key_value = _bsdi_secret_to_key(secret)
- #run data through des using input of 0
- result = mdes_encrypt_int_block(key_value, 0, salt_value, rounds)
+ # run data through des using input of 0
+ result = des_encrypt_int_block(key_value, 0, salt_value, rounds)
- #run h64 encode on result
+ # run h64 encode on result
return h64big.encode_int64(result)
#=========================================================
-#handler
+# handlers
#=========================================================
class des_crypt(uh.HasManyBackends, uh.HasSalt, uh.GenericHandler):
"""This class implements the des-crypt password hash, and follows the :ref:`password-hash-api`.
@@ -156,21 +122,21 @@ class des_crypt(uh.HasManyBackends, uh.HasSalt, uh.GenericHandler):
You can see which backend is in use by calling the :meth:`get_backend()` method.
"""
-
#=========================================================
- #class attrs
+ # class attrs
#=========================================================
#--GenericHandler--
name = "des_crypt"
setting_kwds = ("salt",)
checksum_chars = uh.HASH64_CHARS
+ checksum_size = 11
#--HasSalt--
min_salt_size = max_salt_size = 2
salt_chars = uh.HASH64_CHARS
#=========================================================
- #formatting
+ # formatting
#=========================================================
#FORMAT: 2 chars of H64-encoded salt + 11 chars of H64-encoded checksum
@@ -191,7 +157,7 @@ class des_crypt(uh.HasManyBackends, uh.HasSalt, uh.GenericHandler):
return uascii_to_str(hash)
#=========================================================
- #backend
+ # backend
#=========================================================
backends = ("os_crypt", "builtin")
@@ -205,11 +171,7 @@ class des_crypt(uh.HasManyBackends, uh.HasSalt, uh.GenericHandler):
# gotta do something - no official policy since des-crypt predates unicode
if isinstance(secret, unicode):
secret = secret.encode("utf-8")
- # forbidding nul chars because linux crypt (and most C implementations)
- # won't accept it either.
- if b('\x00') in secret:
- raise ValueError("null char in secret")
- return raw_crypt(secret, self.salt.encode("ascii")).decode("ascii")
+ return _raw_des_crypt(secret, self.salt.encode("ascii")).decode("ascii")
def _calc_checksum_os_crypt(self, secret):
# NOTE: safe_crypt encodes unicode secret -> utf8
@@ -222,19 +184,9 @@ class des_crypt(uh.HasManyBackends, uh.HasSalt, uh.GenericHandler):
return self._calc_checksum_builtin(secret)
#=========================================================
- #eoc
+ # eoc
#=========================================================
-#=========================================================
-#handler
-#=========================================================
-
-#FIXME: phpass code notes that even rounds values should be avoided for BSDI-Crypt,
-# so as not to reveal weak des keys. given the random salt, this shouldn't be
-# a very likely issue anyways, but should do something about default rounds generation anyways.
-# http://wiki.call-cc.org/eggref/4/crypt sez even rounds of DES may reveal weak keys.
-# list of semi-weak keys - http://dolphinburger.com/cgi-bin/bsdi-man?proto=1.1&query=bdes&msection=1&apropos=0
-
class bsdi_crypt(uh.HasManyBackends, uh.HasRounds, uh.HasSalt, uh.GenericHandler):
"""This class implements the BSDi-Crypt password hash, and follows the :ref:`password-hash-api`.
@@ -259,7 +211,7 @@ class bsdi_crypt(uh.HasManyBackends, uh.HasRounds, uh.HasSalt, uh.GenericHandler
You can see which backend is in use by calling the :meth:`get_backend()` method.
"""
#=========================================================
- #class attrs
+ # class attrs
#=========================================================
#--GenericHandler--
name = "bsdi_crypt"
@@ -281,7 +233,7 @@ class bsdi_crypt(uh.HasManyBackends, uh.HasRounds, uh.HasSalt, uh.GenericHandler
# but that seems to be an OS policy, not a algorithm limitation.
#=========================================================
- #internal helpers
+ # parsing
#=========================================================
_hash_regex = re.compile(u(r"""
^
@@ -310,7 +262,36 @@ class bsdi_crypt(uh.HasManyBackends, uh.HasRounds, uh.HasSalt, uh.GenericHandler
return uascii_to_str(hash)
#=========================================================
- #backend
+ # validation
+ #=========================================================
+
+ # flag so CryptContext won't generate even rounds.
+ _avoid_even_rounds = True
+
+ def _norm_rounds(self, rounds):
+ rounds = super(bsdi_crypt, self)._norm_rounds(rounds)
+ # issue warning if app provided an even rounds value
+ if self.use_defaults and not rounds & 1:
+ warn("bsdi_crypt rounds should be odd, "
+ "as even rounds may reveal weak DES keys",
+ uh.exc.PasslibSecurityWarning)
+ return rounds
+
+ @classmethod
+ def _deprecation_detector(cls, **settings):
+ return cls._hash_needs_update
+
+ @classmethod
+ def _hash_needs_update(cls, hash):
+ # mark bsdi_crypt hashes as deprecated if they have even rounds.
+ assert cls.identify(hash)
+ if isinstance(hash, unicode):
+ hash = hash.encode("ascii")
+ rounds = h64.decode_int24(hash[1:5])
+ return not rounds & 1
+
+ #=========================================================
+ # backends
#=========================================================
backends = ("os_crypt", "builtin")
@@ -323,7 +304,7 @@ class bsdi_crypt(uh.HasManyBackends, uh.HasRounds, uh.HasSalt, uh.GenericHandler
def _calc_checksum_builtin(self, secret):
if isinstance(secret, unicode):
secret = secret.encode("utf-8")
- return raw_ext_crypt(secret, self.rounds, self.salt.encode("ascii")).decode("ascii")
+ return _raw_bsdi_crypt(secret, self.rounds, self.salt.encode("ascii")).decode("ascii")
def _calc_checksum_os_crypt(self, secret):
config = self.to_string()
@@ -335,12 +316,9 @@ class bsdi_crypt(uh.HasManyBackends, uh.HasRounds, uh.HasSalt, uh.GenericHandler
return self._calc_checksum_builtin(secret)
#=========================================================
- #eoc
+ # eoc
#=========================================================
-#=========================================================
-#
-#=========================================================
class bigcrypt(uh.HasSalt, uh.GenericHandler):
"""This class implements the BigCrypt password hash, and follows the :ref:`password-hash-api`.
@@ -354,7 +332,7 @@ class bigcrypt(uh.HasSalt, uh.GenericHandler):
If specified, it must be 22 characters, drawn from the regexp range ``[./0-9A-Za-z]``.
"""
#=========================================================
- #class attrs
+ # class attrs
#=========================================================
#--GenericHandler--
name = "bigcrypt"
@@ -367,7 +345,7 @@ class bigcrypt(uh.HasSalt, uh.GenericHandler):
salt_chars = uh.HASH64_CHARS
#=========================================================
- #internal helpers
+ # internal helpers
#=========================================================
_hash_regex = re.compile(u(r"""
^
@@ -395,29 +373,24 @@ class bigcrypt(uh.HasSalt, uh.GenericHandler):
return value
#=========================================================
- #backend
+ # backend
#=========================================================
- #TODO: check if os_crypt supports ext-des-crypt.
-
def _calc_checksum(self, secret):
if isinstance(secret, unicode):
secret = secret.encode("utf-8")
- chk = raw_crypt(secret, self.salt.encode("ascii"))
+ chk = _raw_des_crypt(secret, self.salt.encode("ascii"))
idx = 8
end = len(secret)
while idx < end:
next = idx + 8
- chk += raw_crypt(secret[idx:next], chk[-11:-9])
+ chk += _raw_des_crypt(secret[idx:next], chk[-11:-9])
idx = next
return chk.decode("ascii")
#=========================================================
- #eoc
+ # eoc
#=========================================================
-#=========================================================
-#
-#=========================================================
class crypt16(uh.HasSalt, uh.GenericHandler):
"""This class implements the crypt16 password hash, and follows the :ref:`password-hash-api`.
@@ -431,7 +404,7 @@ class crypt16(uh.HasSalt, uh.GenericHandler):
If specified, it must be 2 characters, drawn from the regexp range ``[./0-9A-Za-z]``.
"""
#=========================================================
- #class attrs
+ # class attrs
#=========================================================
#--GenericHandler--
name = "crypt16"
@@ -444,7 +417,7 @@ class crypt16(uh.HasSalt, uh.GenericHandler):
salt_chars = uh.HASH64_CHARS
#=========================================================
- #internal helpers
+ # internal helpers
#=========================================================
_hash_regex = re.compile(u(r"""
^
@@ -466,10 +439,8 @@ class crypt16(uh.HasSalt, uh.GenericHandler):
return uascii_to_str(hash)
#=========================================================
- #backend
+ # backend
#=========================================================
- #TODO: check if os_crypt supports ext-des-crypt.
-
def _calc_checksum(self, secret):
if isinstance(secret, unicode):
secret = secret.encode("utf-8")
@@ -484,22 +455,22 @@ class crypt16(uh.HasSalt, uh.GenericHandler):
key1 = _crypt_secret_to_key(secret)
#run data through des using input of 0
- result1 = mdes_encrypt_int_block(key1, 0, salt_value, 20)
+ result1 = des_encrypt_int_block(key1, 0, salt_value, 20)
#convert next 8 bytes of secret string into integer (key=0 if secret < 8 chars)
key2 = _crypt_secret_to_key(secret[8:16])
#run data through des using input of 0
- result2 = mdes_encrypt_int_block(key2, 0, salt_value, 5)
+ result2 = des_encrypt_int_block(key2, 0, salt_value, 5)
#done
chk = h64big.encode_int64(result1) + h64big.encode_int64(result2)
return chk.decode("ascii")
#=========================================================
- #eoc
+ # eoc
#=========================================================
#=========================================================
-#eof
+# eof
#=========================================================
diff --git a/passlib/handlers/digests.py b/passlib/handlers/digests.py
index ec08056..22c1c6a 100644
--- a/passlib/handlers/digests.py
+++ b/passlib/handlers/digests.py
@@ -9,7 +9,7 @@ import logging; log = logging.getLogger(__name__)
from warnings import warn
#site
#libs
-from passlib.utils import to_native_str
+from passlib.utils import to_native_str, to_bytes, render_bytes, consteq
from passlib.utils.compat import bascii_to_str, bytes, unicode, str_to_uascii
import passlib.utils.handlers as uh
from passlib.utils.md4 import md4
@@ -76,5 +76,65 @@ hex_sha256 = create_hex_hash(hashlib.sha256, "sha256")
hex_sha512 = create_hex_hash(hashlib.sha512, "sha512")
#=========================================================
+# htdigest
+#=========================================================
+class htdigest(object):
+ """htdigest hash function.
+
+ .. todo::
+ document this hash
+ """
+ name = "htdigest"
+ setting_kwds = ()
+ context_kwds = ("user", "realm")
+
+ @classmethod
+ def encrypt(cls, secret, user, realm, encoding="utf-8"):
+ # NOTE: deliberately written so that raw bytes are passed through
+ # unchanged, encoding only used to handle unicode values.
+ uh.validate_secret(secret)
+ if isinstance(secret, unicode):
+ secret = secret.encode(encoding)
+ user = to_bytes(user, encoding, "user")
+ realm = to_bytes(realm, encoding, "realm")
+ data = render_bytes("%s:%s:%s", user, realm, secret)
+ return hashlib.md5(data).hexdigest()
+
+ @classmethod
+ def _norm_hash(cls, hash):
+ "normalize hash to native string, and validate it"
+ hash = to_native_str(hash, errname="hash")
+ if len(hash) != 32:
+ raise uh.exc.MalformedHashError(cls, "wrong size")
+ for char in hash:
+ if char not in uh.LC_HEX_CHARS:
+ raise uh.exc.MalformedHashError(cls, "invalid chars in hash")
+ return hash
+
+ @classmethod
+ def verify(cls, secret, hash, user, realm, encoding="utf-8"):
+ hash = cls._norm_hash(hash)
+ other = cls.encrypt(secret, user, realm, encoding)
+ return consteq(hash, other)
+
+ @classmethod
+ def identify(cls, hash):
+ try:
+ cls._norm_hash(hash)
+ except ValueError:
+ return False
+ return True
+
+ @classmethod
+ def genconfig(cls):
+ return None
+
+ @classmethod
+ def genhash(cls, secret, config, user, realm, encoding="utf-8"):
+ if config is not None:
+ cls._norm_hash(config)
+ return cls.encrypt(secret, user, realm, encoding)
+
+#=========================================================
#eof
#=========================================================
diff --git a/passlib/handlers/fshp.py b/passlib/handlers/fshp.py
index 3404bd8..28be83c 100644
--- a/passlib/handlers/fshp.py
+++ b/passlib/handlers/fshp.py
@@ -68,7 +68,9 @@ class fshp(uh.HasRounds, uh.HasRawSalt, uh.HasRawChecksum, uh.GenericHandler):
max_salt_size = None
#--HasRounds--
- default_rounds = 16384 #current passlib default, FSHP uses 4096
+ # FIXME: should probably use different default rounds
+ # based on the variant. setting for default variant (sha256) for now.
+ default_rounds = 50000 #current passlib default, FSHP uses 4096
min_rounds = 1 #set by FSHP
max_rounds = 4294967295 # 32-bit integer limit - not set by FSHP
rounds_cost = "linear"
@@ -116,7 +118,7 @@ class fshp(uh.HasRounds, uh.HasRawSalt, uh.HasRawChecksum, uh.GenericHandler):
if not isinstance(variant, int):
raise TypeError("fshp variant must be int or known alias")
if variant not in self._variant_info:
- raise TypeError("unknown fshp variant")
+ raise ValueError("invalid fshp variant")
return variant
@property
@@ -152,7 +154,7 @@ class fshp(uh.HasRounds, uh.HasRawSalt, uh.HasRawChecksum, uh.GenericHandler):
rounds = int(rounds)
try:
data = b64decode(data.encode("ascii"))
- except ValueError:
+ except TypeError:
raise uh.exc.MalformedHashError(cls)
salt = data[:salt_size]
chk = data[salt_size:]
diff --git a/passlib/handlers/ldap_digests.py b/passlib/handlers/ldap_digests.py
index 19089ee..f51e482 100644
--- a/passlib/handlers/ldap_digests.py
+++ b/passlib/handlers/ldap_digests.py
@@ -223,7 +223,7 @@ _init_ldap_crypt_handlers()
## global _lcn_host
## if _lcn_host is None:
## from passlib.hosts import host_context
-## schemes = host_context.policy.schemes()
+## schemes = host_context.schemes()
## _lcn_host = [
## "ldap_" + name
## for name in unix_crypt_names
diff --git a/passlib/handlers/md5_crypt.py b/passlib/handlers/md5_crypt.py
index 9441bbc..c963155 100644
--- a/passlib/handlers/md5_crypt.py
+++ b/passlib/handlers/md5_crypt.py
@@ -68,14 +68,13 @@ def _raw_md5_crypt(pwd, salt, use_apr=False):
#=====================================================================
#validate secret
+ # XXX: not sure what official unicode policy is, using this as default
if isinstance(pwd, unicode):
- # XXX: not sure what official unicode policy is, using this as default
pwd = pwd.encode("utf-8")
- elif not isinstance(pwd, bytes):
- raise TypeError("password must be bytes or unicode")
+ assert isinstance(pwd, bytes), "pwd not unicode or bytes"
pwd_len = len(pwd)
- #validate salt
+ #validate salt - should have been taken care of by caller
assert isinstance(salt, unicode), "salt not unicode"
salt = salt.encode("ascii")
assert len(salt) < 9, "salt too large"
diff --git a/passlib/handlers/misc.py b/passlib/handlers/misc.py
index cb812ff..7121707 100644
--- a/passlib/handlers/misc.py
+++ b/passlib/handlers/misc.py
@@ -141,6 +141,8 @@ class unix_disabled(object):
# NOTE: config/hash will generally be "!" or "*",
# but we want to preserve it in case it has some other content,
# such as ``"!" + original hash``, which glibc uses.
+ # XXX: should this detect mcf header, or other things re:
+ # local system policy?
return to_native_str(config, errname="config")
else:
return to_native_str(marker or cls.marker, errname="marker")
diff --git a/passlib/handlers/pbkdf2.py b/passlib/handlers/pbkdf2.py
index 662bdcd..9980518 100644
--- a/passlib/handlers/pbkdf2.py
+++ b/passlib/handlers/pbkdf2.py
@@ -44,7 +44,7 @@ class Pbkdf2DigestHandler(uh.HasRounds, uh.HasRawSalt, uh.HasRawChecksum, uh.Gen
max_salt_size = 1024
#--HasRounds--
- default_rounds = 6400
+ default_rounds = None # set by subclass
min_rounds = 1
max_rounds = 2**32-1
rounds_cost = "linear"
@@ -84,7 +84,7 @@ class Pbkdf2DigestHandler(uh.HasRounds, uh.HasRawSalt, uh.HasRawChecksum, uh.Gen
secret = secret.encode("utf-8")
return pbkdf2(secret, self.salt, self.rounds, self.checksum_size, self._prf)
-def create_pbkdf2_hash(hash_name, digest_size, ident=None):
+def create_pbkdf2_hash(hash_name, digest_size, rounds=6400, ident=None):
"create new Pbkdf2DigestHandler subclass for a specific hash"
name = 'pbkdf2_' + hash_name
if ident is None:
@@ -95,6 +95,7 @@ def create_pbkdf2_hash(hash_name, digest_size, ident=None):
name=name,
ident=ident,
_prf = prf,
+ default_rounds=rounds,
checksum_size=digest_size,
encoded_checksum_size=(digest_size*4+2)//3,
__doc__="""This class implements a generic ``PBKDF2-%(prf)s``-based password hash, and follows the :ref:`password-hash-api`.
@@ -115,15 +116,15 @@ def create_pbkdf2_hash(hash_name, digest_size, ident=None):
:param rounds:
Optional number of rounds to use.
Defaults to %(dr)d, but must be within ``range(1,1<<32)``.
- """ % dict(prf=prf.upper(), dsc=base.default_salt_size, dr=base.default_rounds)
+ """ % dict(prf=prf.upper(), dsc=base.default_salt_size, dr=rounds)
))
#---------------------------------------------------------
#derived handlers
#---------------------------------------------------------
-pbkdf2_sha1 = create_pbkdf2_hash("sha1", 20, ident=u("$pbkdf2$"))
-pbkdf2_sha256 = create_pbkdf2_hash("sha256", 32)
-pbkdf2_sha512 = create_pbkdf2_hash("sha512", 64)
+pbkdf2_sha1 = create_pbkdf2_hash("sha1", 20, 32000, ident=u("$pbkdf2$"))
+pbkdf2_sha256 = create_pbkdf2_hash("sha256", 32, 4000)
+pbkdf2_sha512 = create_pbkdf2_hash("sha512", 64, 3200)
ldap_pbkdf2_sha1 = uh.PrefixWrapper("ldap_pbkdf2_sha1", pbkdf2_sha1, "{PBKDF2}", "$pbkdf2$")
ldap_pbkdf2_sha256 = uh.PrefixWrapper("ldap_pbkdf2_sha256", pbkdf2_sha256, "{PBKDF2-SHA256}", "$pbkdf2-sha256$")
@@ -173,8 +174,8 @@ class cta_pbkdf2_sha1(uh.HasRounds, uh.HasRawSalt, uh.HasRawChecksum, uh.Generic
min_salt_size = 0
max_salt_size = 1024
- #--HasROunds--
- default_rounds = 10000
+ #--HasRounds--
+ default_rounds = 20000
min_rounds = 1
max_rounds = 2**32-1
rounds_cost = "linear"
@@ -260,8 +261,8 @@ class dlitz_pbkdf2_sha1(uh.HasRounds, uh.HasSalt, uh.GenericHandler):
max_salt_size = 1024
salt_chars = uh.HASH64_CHARS
- #--HasROunds--
- default_rounds = 10000
+ #--HasRounds--
+ default_rounds = 20000
min_rounds = 1
max_rounds = 2**32-1
rounds_cost = "linear"
diff --git a/passlib/handlers/phpass.py b/passlib/handlers/phpass.py
index c093255..00a4e33 100644
--- a/passlib/handlers/phpass.py
+++ b/passlib/handlers/phpass.py
@@ -64,7 +64,7 @@ class phpass(uh.HasManyIdents, uh.HasRounds, uh.HasSalt, uh.GenericHandler):
salt_chars = uh.HASH64_CHARS
#--HasRounds--
- default_rounds = 9
+ default_rounds = 16
min_rounds = 7
max_rounds = 30
rounds_cost = "log2"
diff --git a/passlib/handlers/scram.py b/passlib/handlers/scram.py
index e7919a2..036d7c2 100644
--- a/passlib/handlers/scram.py
+++ b/passlib/handlers/scram.py
@@ -285,7 +285,7 @@ class scram(uh.HasRounds, uh.HasRawSalt, uh.HasRawChecksum, uh.GenericHandler):
raise ValueError("SCRAM limits algorithm names to "
"9 characters: %r" % (alg,))
if not isinstance(digest, bytes):
- raise TypeError("digests must be raw bytes")
+ raise uh.exc.ExpectedTypeError(digest, "raw bytes", "digests")
# TODO: verify digest size (if digest is known)
if 'sha-1' not in checksum:
# NOTE: required because of SCRAM spec.
@@ -374,9 +374,8 @@ class scram(uh.HasRounds, uh.HasRawSalt, uh.HasRawChecksum, uh.GenericHandler):
else:
failed = True
if correct and failed:
- warning("scram hash verified inconsistently, may be corrupted",
- PasslibHashWarning)
- return False
+ raise ValueError("scram hash verified inconsistently, "
+ "may be corrupted")
else:
return correct
else:
@@ -385,7 +384,8 @@ class scram(uh.HasRounds, uh.HasRawSalt, uh.HasRawChecksum, uh.GenericHandler):
if alg in chkmap:
other = self._calc_checksum(secret, alg)
return consteq(other, chkmap[alg])
- # there should *always* be at least sha-1.
+ # there should always be sha-1 at the very least,
+ # or something went wrong inside _norm_algs()
raise AssertionError("sha-1 digest not found!")
#=========================================================
diff --git a/passlib/handlers/sha2_crypt.py b/passlib/handlers/sha2_crypt.py
index e344912..29a18de 100644
--- a/passlib/handlers/sha2_crypt.py
+++ b/passlib/handlers/sha2_crypt.py
@@ -253,7 +253,6 @@ class _SHA2_Common(uh.HasManyBackends, uh.HasRounds, uh.HasSalt,
max_salt_size = 16
salt_chars = uh.HASH64_CHARS
- default_rounds = 40000 # current passlib default
min_rounds = 1000 # bounds set by spec
max_rounds = 999999999 # bounds set by spec
rounds_cost = "linear"
@@ -393,6 +392,7 @@ class sha256_crypt(_SHA2_Common):
name = "sha256_crypt"
ident = u("$5$")
checksum_size = 43
+ default_rounds = 80000 # current passlib default
#=========================================================
# backends
@@ -448,6 +448,7 @@ class sha512_crypt(_SHA2_Common):
ident = u("$6$")
checksum_size = 86
_cdb_use_512 = True
+ default_rounds = 60000 # current passlib default
#=========================================================
# backend
diff --git a/passlib/hosts.py b/passlib/hosts.py
index 5d3abc6..dc3ce83 100644
--- a/passlib/hosts.py
+++ b/passlib/hosts.py
@@ -20,10 +20,10 @@ __all__ = [
]
#=========================================================
-#linux support
+# linux support
#=========================================================
-#known platform names - linux2
+# known platform names - linux2
linux_context = linux2_context = LazyCryptContext(
schemes = [ "sha512_crypt", "sha256_crypt", "md5_crypt",
@@ -32,7 +32,7 @@ linux_context = linux2_context = LazyCryptContext(
)
#=========================================================
-#bsd support
+# bsd support
#=========================================================
#known platform names -
@@ -59,13 +59,16 @@ openbsd_context = LazyCryptContext(["bcrypt", "md5_crypt", "bsdi_crypt",
netbsd_context = LazyCryptContext(["bcrypt", "sha1_crypt", "md5_crypt",
"bsdi_crypt", "des_crypt", "unix_disabled"])
+# XXX: include darwin in this list? it's got a BSD crypt variant,
+# but that's not what it uses for user passwords.
+
#=========================================================
#current host
#=========================================================
if has_crypt:
- #NOTE: this is basically mimicing the output of os crypt(),
- #except that it uses passlib's (usually stronger) defaults settings,
- #and can be introspected and used much more flexibly.
+ # NOTE: this is basically mimicing the output of os crypt(),
+ # except that it uses passlib's (usually stronger) defaults settings,
+ # and can be introspected and used much more flexibly.
def _iter_os_crypt_schemes():
"helper which iterates over supported os_crypt schemes"
@@ -76,11 +79,11 @@ if has_crypt:
found = True
yield name
if found:
- #only offer fallback if there's another scheme in front,
- #as this can't actually hash any passwords
+ # only offer disabled handler if there's another scheme in front,
+ # as this can't actually hash any passwords
yield "unix_disabled"
- else:
- #no idea what OS this could happen on, but just in case...
+ else: # pragma: no cover
+ # no idea what OS this could happen on...
warn("crypt.crypt() function is present, but doesn't support any "
"formats known to passlib!", PasslibRuntimeWarning)
diff --git a/passlib/registry.py b/passlib/registry.py
index 662c9d1..627ebe9 100644
--- a/passlib/registry.py
+++ b/passlib/registry.py
@@ -105,6 +105,7 @@ _handler_locations = {
"hex_sha1": ("passlib.handlers.digests", "hex_sha1"),
"hex_sha256": ("passlib.handlers.digests", "hex_sha256"),
"hex_sha512": ("passlib.handlers.digests", "hex_sha512"),
+ "htdigest": ("passlib.handlers.digests", "htdigest"),
"ldap_plaintext": ("passlib.handlers.ldap_digests","ldap_plaintext"),
"ldap_md5": ("passlib.handlers.ldap_digests","ldap_md5"),
"ldap_sha1": ("passlib.handlers.ldap_digests","ldap_sha1"),
@@ -155,7 +156,7 @@ _handler_locations = {
_name_re = re.compile("^[a-z][_a-z0-9]{2,}$")
#: names which aren't allowed for various reasons (mainly keyword conflicts in CryptContext)
-_forbidden_names = frozenset(["policy", "context", "all", "default", "none"])
+_forbidden_names = frozenset(["onload", "policy", "context", "all", "default", "none"])
#==========================================================
#registry frontend functions
@@ -288,10 +289,11 @@ def get_crypt_handler(name, default=_NOTSET):
"""
global _handlers, _handler_locations
- #check if handler loaded
- handler = _handlers.get(name)
- if handler:
- return handler
+ #check if handler is already loaded
+ try:
+ return _handlers[name]
+ except KeyError:
+ pass
#normalize name (and if changed, check dict again)
assert isinstance(name, str), "name must be str instance"
diff --git a/passlib/tests/sample1.cfg b/passlib/tests/sample1.cfg
new file mode 100644
index 0000000..c90ba83
--- /dev/null
+++ b/passlib/tests/sample1.cfg
@@ -0,0 +1,9 @@
+[passlib]
+schemes = des_crypt, md5_crypt, bsdi_crypt, sha512_crypt
+default = md5_crypt
+all__vary_rounds = 0.1
+bsdi_crypt__default_rounds = 25000
+bsdi_crypt__max_rounds = 30000
+sha512_crypt__max_rounds = 50000
+sha512_crypt__min_rounds = 40000
+
diff --git a/passlib/tests/sample1b.cfg b/passlib/tests/sample1b.cfg
new file mode 100644
index 0000000..1cb4fd1
--- /dev/null
+++ b/passlib/tests/sample1b.cfg
@@ -0,0 +1,9 @@
+[passlib]
+schemes = des_crypt, md5_crypt, bsdi_crypt, sha512_crypt
+default = md5_crypt
+all__vary_rounds = 0.1
+bsdi_crypt__default_rounds = 25000
+bsdi_crypt__max_rounds = 30000
+sha512_crypt__max_rounds = 50000
+sha512_crypt__min_rounds = 40000
+
diff --git a/passlib/tests/sample1c.cfg b/passlib/tests/sample1c.cfg
new file mode 100644
index 0000000..c58ce0e
--- /dev/null
+++ b/passlib/tests/sample1c.cfg
Binary files differ
diff --git a/passlib/tests/test_apache.py b/passlib/tests/test_apache.py
index d3b4ab8..f05c05b 100644
--- a/passlib/tests/test_apache.py
+++ b/passlib/tests/test_apache.py
@@ -32,10 +32,23 @@ class HtpasswdFileTest(TestCase):
"test HtpasswdFile class"
descriptionPrefix = "HtpasswdFile"
- sample_01 = b('user2:2CHkkwa2AtqGs\nuser3:{SHA}3ipNV1GrBtxPmHFC21fCbVCSXIo=\nuser4:pass4\nuser1:$apr1$t4tc7jTh$GPIWVUo8sQKJlUdV8V5vu0\n')
+ # sample with 4 users
+ sample_01 = b('user2:2CHkkwa2AtqGs\n'
+ 'user3:{SHA}3ipNV1GrBtxPmHFC21fCbVCSXIo=\n'
+ 'user4:pass4\n'
+ 'user1:$apr1$t4tc7jTh$GPIWVUo8sQKJlUdV8V5vu0\n')
+
+ # sample 1 with user 1, 2 deleted; 4 changed
sample_02 = b('user3:{SHA}3ipNV1GrBtxPmHFC21fCbVCSXIo=\nuser4:pass4\n')
- sample_03 = b('user2:pass2x\nuser3:{SHA}3ipNV1GrBtxPmHFC21fCbVCSXIo=\nuser4:pass4\nuser1:$apr1$t4tc7jTh$GPIWVUo8sQKJlUdV8V5vu0\nuser5:pass5\n')
+ # sample 1 with user2 updated, user 1 first entry removed, and user 5 added
+ sample_03 = b('user2:pass2x\n'
+ 'user3:{SHA}3ipNV1GrBtxPmHFC21fCbVCSXIo=\n'
+ 'user4:pass4\n'
+ 'user1:$apr1$t4tc7jTh$GPIWVUo8sQKJlUdV8V5vu0\n'
+ 'user5:pass5\n')
+
+ # standalone sample with 8-bit username
sample_04_utf8 = b('user\xc3\xa6:2CHkkwa2AtqGs\n')
sample_04_latin1 = b('user\xe6:2CHkkwa2AtqGs\n')
@@ -46,60 +59,93 @@ class HtpasswdFileTest(TestCase):
if gae_env:
return self.skipTest("GAE doesn't offer read/write filesystem access")
- #check with existing file
+ # check with existing file
path = mktemp()
set_file(path, self.sample_01)
ht = apache.HtpasswdFile(path)
self.assertEqual(ht.to_string(), self.sample_01)
- #check autoload=False
- ht = apache.HtpasswdFile(path, autoload=False)
+ # check without autoload
+ ht = apache.HtpasswdFile(path, new=True)
self.assertEqual(ht.to_string(), b(""))
- #check missing file
+ # check missing file
os.remove(path)
self.assertRaises(IOError, apache.HtpasswdFile, path)
- #NOTE: "default" option checked via update() test, among others
+ #NOTE: "default_scheme" option checked via set_password() test, among others
def test_01_delete(self):
"test delete()"
- ht = apache.HtpasswdFile._from_string(self.sample_01)
- self.assertTrue(ht.delete("user1"))
+ ht = apache.HtpasswdFile.from_string(self.sample_01)
+ self.assertTrue(ht.delete("user1")) # should delete both entries
self.assertTrue(ht.delete("user2"))
- self.assertTrue(not ht.delete("user5"))
+ self.assertFalse(ht.delete("user5")) # user not present
self.assertEqual(ht.to_string(), self.sample_02)
+ # invalid user
self.assertRaises(ValueError, ht.delete, "user:")
- def test_02_update(self):
- "test update()"
- ht = apache.HtpasswdFile._from_string(
- self.sample_01, default="plaintext")
- self.assertTrue(ht.update("user2", "pass2x"))
- self.assertTrue(not ht.update("user5", "pass5"))
+ def test_01_delete_autosave(self):
+ if gae_env:
+ return self.skipTest("GAE doesn't offer read/write filesystem access")
+ path = mktemp()
+ sample = b('user1:pass1\nuser2:pass2\n')
+ set_file(path, sample)
+
+ ht = apache.HtpasswdFile(path)
+ ht.delete("user1")
+ self.assertEqual(get_file(path), sample)
+
+ ht = apache.HtpasswdFile(path, autosave=True)
+ ht.delete("user1")
+ self.assertEqual(get_file(path), b("user2:pass2\n"))
+
+ def test_02_set_password(self):
+ "test set_password()"
+ ht = apache.HtpasswdFile.from_string(
+ self.sample_01, default_scheme="plaintext")
+ self.assertTrue(ht.set_password("user2", "pass2x"))
+ self.assertFalse(ht.set_password("user5", "pass5"))
self.assertEqual(ht.to_string(), self.sample_03)
- self.assertRaises(ValueError, ht.update, "user:", "pass")
+ # invalid user
+ self.assertRaises(ValueError, ht.set_password, "user:", "pass")
+
+ def test_02_set_password_autosave(self):
+ if gae_env:
+ return self.skipTest("GAE doesn't offer read/write filesystem access")
+ path = mktemp()
+ sample = b('user1:pass1\n')
+ set_file(path, sample)
+
+ ht = apache.HtpasswdFile(path)
+ ht.set_password("user1", "pass2")
+ self.assertEqual(get_file(path), sample)
+
+ ht = apache.HtpasswdFile(path, default_scheme="plaintext", autosave=True)
+ ht.set_password("user1", "pass2")
+ self.assertEqual(get_file(path), b("user1:pass2\n"))
def test_03_users(self):
"test users()"
- ht = apache.HtpasswdFile._from_string(self.sample_01)
- ht.update("user5", "pass5")
+ ht = apache.HtpasswdFile.from_string(self.sample_01)
+ ht.set_password("user5", "pass5")
ht.delete("user3")
- ht.update("user3", "pass3")
- self.assertEqual(ht.users(), ["user2", "user4", "user1", "user5", "user3"])
-
- def test_04_verify(self):
- "test verify()"
- ht = apache.HtpasswdFile._from_string(self.sample_01)
- self.assertTrue(ht.verify("user5","pass5") is None)
+ ht.set_password("user3", "pass3")
+ self.assertEqual(ht.users(), ["user2", "user4", "user1", "user5",
+ "user3"])
+
+ def test_04_check_password(self):
+ "test check_password()"
+ ht = apache.HtpasswdFile.from_string(self.sample_01)
+ self.assertTrue(ht.check_password("user5","pass5") is None)
for i in irange(1,5):
i = str(i)
- self.assertTrue(ht.verify("user"+i, "pass"+i))
- self.assertTrue(ht.verify("user"+i, "pass5") is False)
+ self.assertTrue(ht.check_password("user"+i, "pass"+i))
+ self.assertTrue(ht.check_password("user"+i, "pass5") is False)
- self.assertRaises(ValueError, ht.verify, "user:", "pass")
+ self.assertRaises(ValueError, ht.check_password, "user:", "pass")
def test_05_load(self):
"test load()"
@@ -110,33 +156,36 @@ class HtpasswdFileTest(TestCase):
path = mktemp()
set_file(path, "")
backdate_file_mtime(path, 5)
- ha = apache.HtpasswdFile(path, default="plaintext")
+ ha = apache.HtpasswdFile(path, default_scheme="plaintext")
self.assertEqual(ha.to_string(), b(""))
- #make changes, check force=False does nothing
- ha.update("user1", "pass1")
- ha.load(force=False)
+ #make changes, check load_if_changed() does nothing
+ ha.set_password("user1", "pass1")
+ ha.load_if_changed()
self.assertEqual(ha.to_string(), b("user1:pass1\n"))
#change file
set_file(path, self.sample_01)
- ha.load(force=False)
+ ha.load_if_changed()
self.assertEqual(ha.to_string(), self.sample_01)
- #make changes, check force=True overwrites them
- ha.update("user5", "pass5")
+ #make changes, check load() overwrites them
+ ha.set_password("user5", "pass5")
ha.load()
self.assertEqual(ha.to_string(), self.sample_01)
#test load w/ no path
hb = apache.HtpasswdFile()
self.assertRaises(RuntimeError, hb.load)
- self.assertRaises(RuntimeError, hb.load, force=False)
+ self.assertRaises(RuntimeError, hb.load_if_changed)
- #test load w/ dups
+ #test load w/ dups and explicit path
set_file(path, self.sample_dup)
- hc = apache.HtpasswdFile(path)
- self.assertTrue(hc.verify('user1','pass1'))
+ hc = apache.HtpasswdFile()
+ hc.load(path)
+ self.assertTrue(hc.check_password('user1','pass1'))
+
+ # NOTE: load_string() tested via from_string(), which is used all over this file
def test_06_save(self):
"test save()"
@@ -155,44 +204,37 @@ class HtpasswdFileTest(TestCase):
self.assertEqual(get_file(path), self.sample_02)
#test save w/ no path
- hb = apache.HtpasswdFile()
- hb.update("user1", "pass1")
+ hb = apache.HtpasswdFile(default_scheme="plaintext")
+ hb.set_password("user1", "pass1")
self.assertRaises(RuntimeError, hb.save)
+ # test save w/ explicit path
+ hb.save(path)
+ self.assertEqual(get_file(path), b("user1:pass1\n"))
+
def test_07_encodings(self):
- "test encoding parameter behavior"
- #test bad encodings cause failure in constructor
+ "test 'encoding' kwd"
+ # test bad encodings cause failure in constructor
self.assertRaises(ValueError, apache.HtpasswdFile, encoding="utf-16")
- #check users() returns native string by default
- ht = apache.HtpasswdFile._from_string(self.sample_01)
- self.assertIsInstance(ht.users()[0], str)
-
- #check returns unicode if encoding explicitly set
- ht = apache.HtpasswdFile._from_string(self.sample_01, encoding="utf-8")
- self.assertIsInstance(ht.users()[0], unicode)
-
- #check returns bytes if encoding explicitly disabled
- ht = apache.HtpasswdFile._from_string(self.sample_01, encoding=None)
- self.assertIsInstance(ht.users()[0], bytes)
-
- #check sample utf-8
- ht = apache.HtpasswdFile._from_string(self.sample_04_utf8, encoding="utf-8")
+ # check sample utf-8
+ ht = apache.HtpasswdFile.from_string(self.sample_04_utf8, encoding="utf-8",
+ return_unicode=True)
self.assertEqual(ht.users(), [ u("user\u00e6") ])
- #check sample latin-1
- ht = apache.HtpasswdFile._from_string(self.sample_04_latin1,
- encoding="latin-1")
+ # check sample latin-1
+ ht = apache.HtpasswdFile.from_string(self.sample_04_latin1,
+ encoding="latin-1", return_unicode=True)
self.assertEqual(ht.users(), [ u("user\u00e6") ])
def test_08_to_string(self):
"test to_string"
- #check with known sample
- ht = apache.HtpasswdFile._from_string(self.sample_01)
+ # check with known sample
+ ht = apache.HtpasswdFile.from_string(self.sample_01)
self.assertEqual(ht.to_string(), self.sample_01)
- #test blank
+ # test blank
ht = apache.HtpasswdFile()
self.assertEqual(ht.to_string(), b(""))
@@ -207,10 +249,24 @@ class HtdigestFileTest(TestCase):
"test HtdigestFile class"
descriptionPrefix = "HtdigestFile"
- sample_01 = b('user2:realm:549d2a5f4659ab39a80dac99e159ab19\nuser3:realm:a500bb8c02f6a9170ae46af10c898744\nuser4:realm:ab7b5d5f28ccc7666315f508c7358519\nuser1:realm:2a6cf53e7d8f8cf39d946dc880b14128\n')
- sample_02 = b('user3:realm:a500bb8c02f6a9170ae46af10c898744\nuser4:realm:ab7b5d5f28ccc7666315f508c7358519\n')
- sample_03 = b('user2:realm:5ba6d8328943c23c64b50f8b29566059\nuser3:realm:a500bb8c02f6a9170ae46af10c898744\nuser4:realm:ab7b5d5f28ccc7666315f508c7358519\nuser1:realm:2a6cf53e7d8f8cf39d946dc880b14128\nuser5:realm:03c55fdc6bf71552356ad401bdb9af19\n')
+ # sample with 4 users
+ sample_01 = b('user2:realm:549d2a5f4659ab39a80dac99e159ab19\n'
+ 'user3:realm:a500bb8c02f6a9170ae46af10c898744\n'
+ 'user4:realm:ab7b5d5f28ccc7666315f508c7358519\n'
+ 'user1:realm:2a6cf53e7d8f8cf39d946dc880b14128\n')
+
+ # sample 1 with user 1, 2 deleted; 4 changed
+ sample_02 = b('user3:realm:a500bb8c02f6a9170ae46af10c898744\n'
+ 'user4:realm:ab7b5d5f28ccc7666315f508c7358519\n')
+ # sample 1 with user2 updated, user 1 first entry removed, and user 5 added
+ sample_03 = b('user2:realm:5ba6d8328943c23c64b50f8b29566059\n'
+ 'user3:realm:a500bb8c02f6a9170ae46af10c898744\n'
+ 'user4:realm:ab7b5d5f28ccc7666315f508c7358519\n'
+ 'user1:realm:2a6cf53e7d8f8cf39d946dc880b14128\n'
+ 'user5:realm:03c55fdc6bf71552356ad401bdb9af19\n')
+
+ # standalone sample with 8-bit username & realm
sample_04_utf8 = b('user\xc3\xa6:realm\xc3\xa6:549d2a5f4659ab39a80dac99e159ab19\n')
sample_04_latin1 = b('user\xe6:realm\xe6:549d2a5f4659ab39a80dac99e159ab19\n')
@@ -219,61 +275,101 @@ class HtdigestFileTest(TestCase):
if gae_env:
return self.skipTest("GAE doesn't offer read/write filesystem access")
- #check with existing file
+ # check with existing file
path = mktemp()
set_file(path, self.sample_01)
ht = apache.HtdigestFile(path)
self.assertEqual(ht.to_string(), self.sample_01)
- #check autoload=False
- ht = apache.HtdigestFile(path, autoload=False)
+ # check without autoload
+ ht = apache.HtdigestFile(path, new=True)
self.assertEqual(ht.to_string(), b(""))
- #check missing file
+ # check missing file
os.remove(path)
self.assertRaises(IOError, apache.HtdigestFile, path)
+ # NOTE: default_realm option checked via other tests.
+
def test_01_delete(self):
"test delete()"
- ht = apache.HtdigestFile._from_string(self.sample_01)
+ ht = apache.HtdigestFile.from_string(self.sample_01)
self.assertTrue(ht.delete("user1", "realm"))
self.assertTrue(ht.delete("user2", "realm"))
- self.assertTrue(not ht.delete("user5", "realm"))
+ self.assertFalse(ht.delete("user5", "realm"))
+ self.assertFalse(ht.delete("user3", "realm5"))
self.assertEqual(ht.to_string(), self.sample_02)
+ # invalid user
self.assertRaises(ValueError, ht.delete, "user:", "realm")
- def test_02_update(self):
+ # invalid realm
+ self.assertRaises(ValueError, ht.delete, "user", "realm:")
+
+ def test_01_delete_autosave(self):
+ if gae_env:
+ return self.skipTest("GAE doesn't offer read/write filesystem access")
+ path = mktemp()
+ set_file(path, self.sample_01)
+
+ ht = apache.HtdigestFile(path)
+ self.assertTrue(ht.delete("user1", "realm"))
+ self.assertFalse(ht.delete("user3", "realm5"))
+ self.assertFalse(ht.delete("user5", "realm"))
+ self.assertEqual(get_file(path), self.sample_01)
+
+ ht.autosave = True
+ self.assertTrue(ht.delete("user2", "realm"))
+ self.assertEqual(get_file(path), self.sample_02)
+
+ def test_02_set_password(self):
"test update()"
- ht = apache.HtdigestFile._from_string(self.sample_01)
- self.assertTrue(ht.update("user2", "realm", "pass2x"))
- self.assertTrue(not ht.update("user5", "realm", "pass5"))
+ ht = apache.HtdigestFile.from_string(self.sample_01)
+ self.assertTrue(ht.set_password("user2", "realm", "pass2x"))
+ self.assertFalse(ht.set_password("user5", "realm", "pass5"))
self.assertEqual(ht.to_string(), self.sample_03)
- self.assertRaises(ValueError, ht.update, "user:", "realm", "pass")
- self.assertRaises(ValueError, ht.update, "u"*256, "realm", "pass")
+ # default realm
+ self.assertRaises(TypeError, ht.set_password, "user2", "pass3")
+ ht.default_realm = "realm2"
+ ht.set_password("user2", "pass3")
+ ht.check_password("user2", "realm2", "pass3")
- self.assertRaises(ValueError, ht.update, "user", "realm:", "pass")
- self.assertRaises(ValueError, ht.update, "user", "r"*256, "pass")
+ # invalid user
+ self.assertRaises(ValueError, ht.set_password, "user:", "realm", "pass")
+ self.assertRaises(ValueError, ht.set_password, "u"*256, "realm", "pass")
+
+ # invalid realm
+ self.assertRaises(ValueError, ht.set_password, "user", "realm:", "pass")
+ self.assertRaises(ValueError, ht.set_password, "user", "r"*256, "pass")
+
+ # TODO: test set_password autosave
def test_03_users(self):
"test users()"
- ht = apache.HtdigestFile._from_string(self.sample_01)
- ht.update("user5", "realm", "pass5")
+ ht = apache.HtdigestFile.from_string(self.sample_01)
+ ht.set_password("user5", "realm", "pass5")
ht.delete("user3", "realm")
- ht.update("user3", "realm", "pass3")
+ ht.set_password("user3", "realm", "pass3")
self.assertEqual(ht.users("realm"), ["user2", "user4", "user1", "user5", "user3"])
- def test_04_verify(self):
- "test verify()"
- ht = apache.HtdigestFile._from_string(self.sample_01)
- self.assertTrue(ht.verify("user5", "realm","pass5") is None)
+ def test_04_check_password(self):
+ "test check_password()"
+ ht = apache.HtdigestFile.from_string(self.sample_01)
+ self.assertIs(ht.check_password("user5", "realm","pass5"), None)
for i in irange(1,5):
i = str(i)
- self.assertTrue(ht.verify("user"+i, "realm", "pass"+i))
- self.assertTrue(ht.verify("user"+i, "realm", "pass5") is False)
+ self.assertTrue(ht.check_password("user"+i, "realm", "pass"+i))
+ self.assertIs(ht.check_password("user"+i, "realm", "pass5"), False)
+
+ # default realm
+ self.assertRaises(TypeError, ht.check_password, "user5", "pass5")
+ ht.default_realm = "realm"
+ self.assertTrue(ht.check_password("user1", "pass1"))
+ self.assertIs(ht.check_password("user5", "pass5"), None)
- self.assertRaises(ValueError, ht.verify, "user:", "realm", "pass")
+ # invalid user
+ self.assertRaises(ValueError, ht.check_password, "user:", "realm", "pass")
def test_05_load(self):
"test load()"
@@ -287,25 +383,30 @@ class HtdigestFileTest(TestCase):
ha = apache.HtdigestFile(path)
self.assertEqual(ha.to_string(), b(""))
- #make changes, check force=False does nothing
- ha.update("user1", "realm", "pass1")
- ha.load(force=False)
+ #make changes, check load_if_changed() does nothing
+ ha.set_password("user1", "realm", "pass1")
+ ha.load_if_changed()
self.assertEqual(ha.to_string(), b('user1:realm:2a6cf53e7d8f8cf39d946dc880b14128\n'))
#change file
set_file(path, self.sample_01)
- ha.load(force=False)
+ ha.load_if_changed()
self.assertEqual(ha.to_string(), self.sample_01)
#make changes, check force=True overwrites them
- ha.update("user5", "realm", "pass5")
+ ha.set_password("user5", "realm", "pass5")
ha.load()
self.assertEqual(ha.to_string(), self.sample_01)
#test load w/ no path
hb = apache.HtdigestFile()
self.assertRaises(RuntimeError, hb.load)
- self.assertRaises(RuntimeError, hb.load, force=False)
+ self.assertRaises(RuntimeError, hb.load_if_changed)
+
+ # test load w/ explicit path
+ hc = apache.HtdigestFile()
+ hc.load(path)
+ self.assertEqual(hc.to_string(), self.sample_01)
def test_06_save(self):
"test save()"
@@ -325,12 +426,16 @@ class HtdigestFileTest(TestCase):
#test save w/ no path
hb = apache.HtdigestFile()
- hb.update("user1", "realm", "pass1")
+ hb.set_password("user1", "realm", "pass1")
self.assertRaises(RuntimeError, hb.save)
+ # test save w/ explicit path
+ hb.save(path)
+ self.assertEqual(get_file(path), hb.to_string())
+
def test_07_realms(self):
"test realms() & delete_realm()"
- ht = apache.HtdigestFile._from_string(self.sample_01)
+ ht = apache.HtdigestFile.from_string(self.sample_01)
self.assertEqual(ht.delete_realm("x"), 0)
self.assertEqual(ht.realms(), ['realm'])
@@ -339,52 +444,36 @@ class HtdigestFileTest(TestCase):
self.assertEqual(ht.realms(), [])
self.assertEqual(ht.to_string(), b(""))
- def test_08_find(self):
- "test find()"
- ht = apache.HtdigestFile._from_string(self.sample_01)
- self.assertEqual(ht.find("user3", "realm"), "a500bb8c02f6a9170ae46af10c898744")
- self.assertEqual(ht.find("user4", "realm"), "ab7b5d5f28ccc7666315f508c7358519")
- self.assertEqual(ht.find("user5", "realm"), None)
+ def test_08_get_hash(self):
+ "test get_hash()"
+ ht = apache.HtdigestFile.from_string(self.sample_01)
+ self.assertEqual(ht.get_hash("user3", "realm"), "a500bb8c02f6a9170ae46af10c898744")
+ self.assertEqual(ht.get_hash("user4", "realm"), "ab7b5d5f28ccc7666315f508c7358519")
+ self.assertEqual(ht.get_hash("user5", "realm"), None)
def test_09_encodings(self):
"test encoding parameter"
- #test bad encodings cause failure in constructor
+ # test bad encodings cause failure in constructor
self.assertRaises(ValueError, apache.HtdigestFile, encoding="utf-16")
- #check users() returns native string by default
- ht = apache.HtdigestFile._from_string(self.sample_01)
- self.assertIsInstance(ht.realms()[0], str)
- self.assertIsInstance(ht.users("realm")[0], str)
-
- #check returns unicode if encoding explicitly set
- ht = apache.HtdigestFile._from_string(self.sample_01, encoding="utf-8")
- self.assertIsInstance(ht.realms()[0], unicode)
- self.assertIsInstance(ht.users(u("realm"))[0], unicode)
-
- #check returns bytes if encoding explicitly disabled
- ht = apache.HtdigestFile._from_string(self.sample_01, encoding=None)
- self.assertIsInstance(ht.realms()[0], bytes)
- self.assertIsInstance(ht.users(b("realm"))[0], bytes)
-
- #check sample utf-8
- ht = apache.HtdigestFile._from_string(self.sample_04_utf8, encoding="utf-8")
+ # check sample utf-8
+ ht = apache.HtdigestFile.from_string(self.sample_04_utf8, encoding="utf-8", return_unicode=True)
self.assertEqual(ht.realms(), [ u("realm\u00e6") ])
self.assertEqual(ht.users(u("realm\u00e6")), [ u("user\u00e6") ])
- #check sample latin-1
- ht = apache.HtdigestFile._from_string(self.sample_04_latin1, encoding="latin-1")
+ # check sample latin-1
+ ht = apache.HtdigestFile.from_string(self.sample_04_latin1, encoding="latin-1", return_unicode=True)
self.assertEqual(ht.realms(), [ u("realm\u00e6") ])
self.assertEqual(ht.users(u("realm\u00e6")), [ u("user\u00e6") ])
-
def test_10_to_string(self):
"test to_string()"
- #check sample
- ht = apache.HtdigestFile._from_string(self.sample_01)
+ # check sample
+ ht = apache.HtdigestFile.from_string(self.sample_01)
self.assertEqual(ht.to_string(), self.sample_01)
- #check blank
+ # check blank
ht = apache.HtdigestFile()
self.assertEqual(ht.to_string(), b(""))
diff --git a/passlib/tests/test_apps.py b/passlib/tests/test_apps.py
index d48654d..1758c38 100644
--- a/passlib/tests/test_apps.py
+++ b/passlib/tests/test_apps.py
@@ -25,7 +25,7 @@ class AppsTest(TestCase):
def test_custom_app_context(self):
ctx = apps.custom_app_context
- self.assertEqual(ctx.policy.schemes(), ["sha512_crypt", "sha256_crypt"])
+ self.assertEqual(ctx.schemes(), ("sha512_crypt", "sha256_crypt"))
for hash in [
('$6$rounds=41128$VoQLvDjkaZ6L6BIE$4pt.1Ll1XdDYduEwEYPCMOBiR6W6'
'znsyUEoNlcVXpv2gKKIbQolgmTGe6uEEVJ7azUxuc8Tf7zV9SD2z7Ij751'),
@@ -93,10 +93,12 @@ class AppsTest(TestCase):
h1 = '$2a$10$Ljj0Kgu7Ddob9xWoqzn0ae.uNfxPRofowWdksk.6jCUHKTGYLD.QG'
if hashmod.bcrypt.has_backend():
self.assertTrue(ctx.verify("test", h1))
- self.assertEqual(ctx.policy.get_handler().name, "bcrypt")
+ self.assertEqual(ctx.default_scheme(), "bcrypt")
+ self.assertEqual(ctx.handler().name, "bcrypt")
else:
self.assertEqual(ctx.identify(h1), "bcrypt")
- self.assertEqual(ctx.policy.get_handler().name, "phpass")
+ self.assertEqual(ctx.default_scheme(), "phpass")
+ self.assertEqual(ctx.handler().name, "phpass")
def test_phpbb3_context(self):
ctx = apps.phpbb3_context
diff --git a/passlib/tests/test_context.py b/passlib/tests/test_context.py
index d1e4511..d039ac6 100644
--- a/passlib/tests/test_context.py
+++ b/passlib/tests/test_context.py
@@ -1,9 +1,14 @@
-"""tests for passlib.pwhash -- (c) Assurance Technologies 2003-2009"""
+"""tests for passlib.context"""
#=========================================================
#imports
#=========================================================
from __future__ import with_statement
+from passlib.utils.compat import PY3
#core
+if PY3:
+ from configparser import NoSectionError
+else:
+ from ConfigParser import NoSectionError
import hashlib
from logging import getLogger
import os
@@ -17,7 +22,7 @@ except ImportError:
resource_filename = None
#pkg
from passlib import hash
-from passlib.context import CryptContext, CryptPolicy, LazyCryptContext
+from passlib.context import CryptContext, LazyCryptContext
from passlib.exc import PasslibConfigWarning
from passlib.utils import tick, to_bytes, to_unicode
from passlib.utils.compat import irange, u
@@ -25,90 +30,105 @@ import passlib.utils.handlers as uh
from passlib.tests.utils import TestCase, mktemp, catch_warnings, \
gae_env, set_file
from passlib.registry import register_crypt_handler_path, has_crypt_handler, \
- _unload_handler_name as unload_handler_name
+ _unload_handler_name as unload_handler_name, get_crypt_handler
#module
log = getLogger(__name__)
+#=========================================================
+# support
+#=========================================================
+here = os.path.abspath(os.path.dirname(__file__))
+
+def merge_dicts(first, *args, **kwds):
+ target = first.copy()
+ for arg in args:
+ target.update(arg)
+ if kwds:
+ target.update(kwds)
+ return target
#=========================================================
#
#=========================================================
-class CryptPolicyTest(TestCase):
- "test CryptPolicy object"
-
- #TODO: need to test user categories w/in all this
+class CryptContextTest(TestCase):
+ descriptionPrefix = "CryptContext"
- descriptionPrefix = "CryptPolicy"
+ # TODO: these unittests could really use a good cleanup
+ # and reorganizing, to ensure they're getting everything.
#=========================================================
- #sample crypt policies used for testing
+ # sample configurations used in tests
#=========================================================
#-----------------------------------------------------
- #sample 1 - average config file
+ # sample 1 - typical configuration
#-----------------------------------------------------
- #NOTE: copy of this is stored in file passlib/tests/sample_config_1s.cfg
- sample_config_1s = """\
-[passlib]
-schemes = des_crypt, md5_crypt, bsdi_crypt, sha512_crypt
-default = md5_crypt
-all.vary_rounds = 10%%
-bsdi_crypt.max_rounds = 30000
-bsdi_crypt.default_rounds = 25000
-sha512_crypt.max_rounds = 50000
-sha512_crypt.min_rounds = 40000
-"""
- sample_config_1s_path = os.path.abspath(os.path.join(
- os.path.dirname(__file__), "sample_config_1s.cfg"))
- if not os.path.exists(sample_config_1s_path) and resource_filename:
- #in case we're zipped up in an egg.
- sample_config_1s_path = resource_filename("passlib.tests",
- "sample_config_1s.cfg")
-
- #make sure sample_config_1s uses \n linesep - tests rely on this
- assert sample_config_1s.startswith("[passlib]\nschemes")
-
- sample_config_1pd = dict(
- schemes = [ "des_crypt", "md5_crypt", "bsdi_crypt", "sha512_crypt"],
+ sample_1_schemes = ["des_crypt", "md5_crypt", "bsdi_crypt", "sha512_crypt"]
+ sample_1_handlers = [get_crypt_handler(name) for name in sample_1_schemes]
+
+ sample_1_dict = dict(
+ schemes = sample_1_schemes,
default = "md5_crypt",
- all__vary_rounds = "10%",
+ all__vary_rounds = 0.1,
bsdi_crypt__max_rounds = 30000,
bsdi_crypt__default_rounds = 25000,
sha512_crypt__max_rounds = 50000,
sha512_crypt__min_rounds = 40000,
)
- sample_config_1pid = {
- "schemes": "des_crypt, md5_crypt, bsdi_crypt, sha512_crypt",
- "default": "md5_crypt",
- "all.vary_rounds": "10%",
- "bsdi_crypt.max_rounds": 30000,
- "bsdi_crypt.default_rounds": 25000,
- "sha512_crypt.max_rounds": 50000,
- "sha512_crypt.min_rounds": 40000,
- }
-
- sample_config_1prd = dict(
- schemes = [ hash.des_crypt, hash.md5_crypt, hash.bsdi_crypt, hash.sha512_crypt],
- default = "md5_crypt", # NOTE: passlib <= 1.5 was handler obj.
- all__vary_rounds = "10%",
- bsdi_crypt__max_rounds = 30000,
- bsdi_crypt__default_rounds = 25000,
- sha512_crypt__max_rounds = 50000,
- sha512_crypt__min_rounds = 40000,
- )
+ sample_1_resolved_dict = merge_dicts(sample_1_dict,
+ schemes = sample_1_handlers)
+
+ sample_1_unnormalized = u("""\
+[passlib]
+schemes = des_crypt, md5_crypt, bsdi_crypt, sha512_crypt
+default = md5_crypt
+; this is using %...
+all__vary_rounds = 10%%
+; this is using 'rounds' instead of 'default_rounds'
+bsdi_crypt__rounds = 25000
+bsdi_crypt__max_rounds = 30000
+sha512_crypt__max_rounds = 50000
+sha512_crypt__min_rounds = 40000
+""")
+
+ sample_1_unicode = u("""\
+[passlib]
+schemes = des_crypt, md5_crypt, bsdi_crypt, sha512_crypt
+default = md5_crypt
+all__vary_rounds = 0.1
+bsdi_crypt__default_rounds = 25000
+bsdi_crypt__max_rounds = 30000
+sha512_crypt__max_rounds = 50000
+sha512_crypt__min_rounds = 40000
+
+""")
#-----------------------------------------------------
- #sample 2 - partial policy & result of overlay on sample 1
+ # sample 1 external files
#-----------------------------------------------------
- sample_config_2s = """\
-[passlib]
-bsdi_crypt.min_rounds = 29000
-bsdi_crypt.max_rounds = 35000
-bsdi_crypt.default_rounds = 31000
-sha512_crypt.min_rounds = 45000
-"""
- sample_config_2pd = dict(
+ # sample 1 string with '\n' linesep
+ sample_1_path = os.path.join(here, "sample1.cfg")
+
+ # sample 1 with '\r\n' linesep
+ sample_1b_unicode = sample_1_unicode.replace(u("\n"), u("\r\n"))
+ sample_1b_path = os.path.join(here, "sample1b.cfg")
+
+ # sample 1 using UTF-16 and alt section
+ sample_1c_bytes = sample_1_unicode.replace(u("[passlib]"),
+ u("[mypolicy]")).encode("utf-16")
+ sample_1c_path = os.path.join(here, "sample1c.cfg")
+
+ # enable to regenerate sample files
+ if False:
+ set_file(sample_1_path, sample_1_unicode)
+ set_file(sample_1b_path, sample_1b_unicode)
+ set_file(sample_1c_path, sample_1c_bytes)
+
+ #-----------------------------------------------------
+ # sample 2 & 12 - options patch
+ #-----------------------------------------------------
+ sample_2_dict = dict(
#using this to test full replacement of existing options
bsdi_crypt__min_rounds = 29000,
bsdi_crypt__max_rounds = 35000,
@@ -117,511 +137,891 @@ sha512_crypt.min_rounds = 45000
sha512_crypt__min_rounds=45000,
)
- sample_config_12pd = dict(
- schemes = [ "des_crypt", "md5_crypt", "bsdi_crypt", "sha512_crypt"],
- default = "md5_crypt",
- all__vary_rounds = "10%",
- bsdi_crypt__min_rounds = 29000,
- bsdi_crypt__max_rounds = 35000,
- bsdi_crypt__default_rounds = 31000,
- sha512_crypt__max_rounds = 50000,
- sha512_crypt__min_rounds=45000,
- )
+ sample_2_unicode = """\
+[passlib]
+bsdi_crypt__min_rounds = 29000
+bsdi_crypt__max_rounds = 35000
+bsdi_crypt__default_rounds = 31000
+sha512_crypt__min_rounds = 45000
+"""
+
+ # sample 2 overlayed on top of sample 1
+ sample_12_dict = merge_dicts(sample_1_dict, sample_2_dict)
#-----------------------------------------------------
- #sample 3 - just changing default
+ # sample 3 & 123 - just changing default from sample 1
#-----------------------------------------------------
- sample_config_3pd = dict(
+ sample_3_dict = dict(
default="sha512_crypt",
)
- sample_config_123pd = dict(
- schemes = [ "des_crypt", "md5_crypt", "bsdi_crypt", "sha512_crypt"],
- default = "sha512_crypt",
- all__vary_rounds = "10%",
- bsdi_crypt__min_rounds = 29000,
- bsdi_crypt__max_rounds = 35000,
- bsdi_crypt__default_rounds = 31000,
- sha512_crypt__max_rounds = 50000,
- sha512_crypt__min_rounds=45000,
- )
+ # sample 3 overlayed on 2 overlayed on 1
+ sample_123_dict = merge_dicts(sample_12_dict, sample_3_dict)
#-----------------------------------------------------
- #sample 4 - category specific
+ # sample 4 - used by api tests
#-----------------------------------------------------
- sample_config_4s = """
-[passlib]
-schemes = sha512_crypt
-all.vary_rounds = 10%%
-default.sha512_crypt.max_rounds = 20000
-admin.all.vary_rounds = 5%%
-admin.sha512_crypt.max_rounds = 40000
-"""
+ sample_4_dict = dict(
+ schemes = [ "des_crypt", "md5_crypt", "phpass", "bsdi_crypt",
+ "sha256_crypt"],
+ deprecated = [ "des_crypt", ],
+ default = "sha256_crypt",
+ bsdi_crypt__max_rounds = 30,
+ bsdi_crypt__default_rounds = 25,
+ bsdi_crypt__vary_rounds = 0,
+ sha256_crypt__max_rounds = 3000,
+ sha256_crypt__min_rounds = 2000,
+ sha256_crypt__default_rounds = 3000,
+ phpass__ident = "H",
+ phpass__default_rounds = 7,
+ )
- sample_config_4pd = dict(
- schemes = [ "sha512_crypt" ],
- all__vary_rounds = "10%",
- sha512_crypt__max_rounds = 20000,
- admin__all__vary_rounds = "5%",
- admin__sha512_crypt__max_rounds = 40000,
- )
+ #=========================================================
+ # constructors
+ #=========================================================
+ def test_01_constructor(self):
+ "test class constructor"
- #-----------------------------------------------------
- #sample 5 - to_string & deprecation testing
- #-----------------------------------------------------
- sample_config_5s = sample_config_1s + """\
-deprecated = des_crypt
-admin__context__deprecated = des_crypt, bsdi_crypt
-"""
+ # test blank constructor works correctly
+ ctx = CryptContext()
+ self.assertEqual(ctx.to_dict(), {})
- sample_config_5pd = sample_config_1pd.copy()
- sample_config_5pd.update(
- deprecated = [ "des_crypt" ],
- admin__context__deprecated = [ "des_crypt", "bsdi_crypt" ],
- )
+ # test sample 1 with scheme=names
+ ctx = CryptContext(**self.sample_1_dict)
+ self.assertEqual(ctx.to_dict(), self.sample_1_dict)
+
+ # test sample 1 with scheme=handlers
+ ctx = CryptContext(**self.sample_1_resolved_dict)
+ self.assertEqual(ctx.to_dict(), self.sample_1_dict)
+
+ # test sample 2: options w/o schemes
+ ctx = CryptContext(**self.sample_2_dict)
+ self.assertEqual(ctx.to_dict(), self.sample_2_dict)
- sample_config_5pid = sample_config_1pid.copy()
- sample_config_5pid.update({
- "deprecated": "des_crypt",
- "admin.context.deprecated": "des_crypt, bsdi_crypt",
- })
+ # test sample 3: default only
+ ctx = CryptContext(**self.sample_3_dict)
+ self.assertEqual(ctx.to_dict(), self.sample_3_dict)
- sample_config_5prd = sample_config_1prd.copy()
- sample_config_5prd.update({
- # XXX: should deprecated return the actual handlers in this case?
- # would have to modify how policy stores info, for one.
- "deprecated": ["des_crypt"],
- "admin__context__deprecated": ["des_crypt", "bsdi_crypt"],
- })
+ def test_02_from_string(self):
+ "test from_string() constructor"
+ # test sample 1 unicode
+ ctx = CryptContext.from_string(self.sample_1_unicode)
+ self.assertEqual(ctx.to_dict(), self.sample_1_dict)
+
+ # test sample 1 with unnormalized inputs
+ ctx = CryptContext.from_string(self.sample_1_unnormalized)
+ self.assertEqual(ctx.to_dict(), self.sample_1_dict)
+
+ # test sample 1 utf-8
+ ctx = CryptContext.from_string(self.sample_1_unicode.encode("utf-8"))
+ self.assertEqual(ctx.to_dict(), self.sample_1_dict)
+
+ # test sample 1 w/ '\r\n' linesep
+ ctx = CryptContext.from_string(self.sample_1b_unicode)
+ self.assertEqual(ctx.to_dict(), self.sample_1_dict)
+
+ # test sample 1 using UTF-16 and alt section
+ ctx = CryptContext.from_string(self.sample_1c_bytes, section="mypolicy",
+ encoding="utf-16")
+ self.assertEqual(ctx.to_dict(), self.sample_1_dict)
+
+ # test wrong type
+ self.assertRaises(TypeError, CryptContext.from_string, None)
+
+ # test missing section
+ self.assertRaises(NoSectionError, CryptContext.from_string,
+ self.sample_1_unicode, section="fakesection")
+
+ def test_03_from_path(self):
+ "test from_path() constructor"
+ # make sure sample files exist
+ if not os.path.exists(self.sample_1_path):
+ raise RuntimeError("can't find data file: %r" % self.sample_1_path)
+
+ # test sample 1
+ ctx = CryptContext.from_path(self.sample_1_path)
+ self.assertEqual(ctx.to_dict(), self.sample_1_dict)
+
+ # test sample 1 w/ '\r\n' linesep
+ ctx = CryptContext.from_path(self.sample_1b_path)
+ self.assertEqual(ctx.to_dict(), self.sample_1_dict)
+
+ # test sample 1 encoding using UTF-16 and alt section
+ ctx = CryptContext.from_path(self.sample_1c_path, section="mypolicy",
+ encoding="utf-16")
+ self.assertEqual(ctx.to_dict(), self.sample_1_dict)
+
+ # test missing file
+ self.assertRaises(EnvironmentError, CryptContext.from_path,
+ os.path.join(here, "sample1xxx.cfg"))
+
+ # test missing section
+ self.assertRaises(NoSectionError, CryptContext.from_path,
+ self.sample_1_path, section="fakesection")
+
+ def test_04_copy(self):
+ "test copy() method"
+ cc1 = CryptContext(**self.sample_1_dict)
+
+ # overlay sample 2 onto copy
+ cc2 = cc1.copy(**self.sample_2_dict)
+ self.assertEqual(cc1.to_dict(), self.sample_1_dict)
+ self.assertEqual(cc2.to_dict(), self.sample_12_dict)
+
+ # check that repeating overlay makes no change
+ cc2b = cc2.copy(**self.sample_2_dict)
+ self.assertEqual(cc1.to_dict(), self.sample_1_dict)
+ self.assertEqual(cc2b.to_dict(), self.sample_12_dict)
+
+ # overlay sample 3 on copy
+ cc3 = cc2.copy(**self.sample_3_dict)
+ self.assertEqual(cc3.to_dict(), self.sample_123_dict)
+
+ # test empty copy creates separate copy
+ cc4 = cc1.copy()
+ self.assertIsNot(cc4, cc1)
+ self.assertEqual(cc1.to_dict(), self.sample_1_dict)
+ self.assertEqual(cc4.to_dict(), self.sample_1_dict)
+
+ # ... and that modifying copy doesn't affect original
+ cc4.update(**self.sample_2_dict)
+ self.assertEqual(cc1.to_dict(), self.sample_1_dict)
+ self.assertEqual(cc4.to_dict(), self.sample_12_dict)
#=========================================================
- #constructors
+ # modifiers
#=========================================================
- def test_00_constructor(self):
- "test CryptPolicy() constructor"
- policy = CryptPolicy(**self.sample_config_1pd)
- self.assertEqual(policy.to_dict(), self.sample_config_1pd)
-
- #check key with too many separators is rejected
- self.assertRaises(TypeError, CryptPolicy,
- schemes = [ "des_crypt", "md5_crypt", "bsdi_crypt", "sha512_crypt"],
- bad__key__bsdi_crypt__max_rounds = 30000,
+ def test_10_load(self):
+ "test load() / load_path() method"
+ # NOTE: load() is the workhorse that handles all policy parsing,
+ # compilation, and validation. most of it's features are tested
+ # elsewhere, since all the constructors and modifiers are just
+ # wrappers for it.
+
+ # source_type 'auto'
+ ctx = CryptContext()
+
+ # detect dict
+ ctx.load(self.sample_1_dict)
+ self.assertEqual(ctx.to_dict(), self.sample_1_dict)
+
+ # detect unicode string
+ ctx.load(self.sample_1_unicode)
+ self.assertEqual(ctx.to_dict(), self.sample_1_dict)
+
+ # detect bytes string
+ ctx.load(self.sample_1_unicode.encode("utf-8"))
+ self.assertEqual(ctx.to_dict(), self.sample_1_dict)
+
+ # anything else - TypeError
+ self.assertRaises(TypeError, ctx.load, None)
+
+ # NOTE: load_path() tested by from_path()
+ # NOTE: additional string tests done by from_string()
+
+ # update flag - tested by update() method tests
+ # encoding keyword - tested by from_string() & from_path()
+ # section keyword - tested by from_string() & from_path()
+
+ # multiple loads should clear the state
+ ctx = CryptContext()
+ ctx.load(self.sample_1_dict)
+ ctx.load(self.sample_2_dict)
+ self.assertEqual(ctx.to_dict(), self.sample_2_dict)
+
+ def test_11_load_rollback(self):
+ "test load() errors restore old state"
+ # create initial context
+ cc = CryptContext(["des_crypt", "sha256_crypt"],
+ sha256_crypt__default_rounds=5000,
+ all__vary_rounds=0.1,
)
+ result = cc.to_string()
- #check nameless handler rejected
- class nameless(uh.StaticHandler):
- name = None
- self.assertRaises(ValueError, CryptPolicy, schemes=[nameless])
+ # do an update operation that should fail during parsing
+ # XXX: not sure what the right error type is here.
+ self.assertRaises(TypeError, cc.update, too__many__key__parts=True)
+ self.assertEqual(cc.to_string(), result)
- # check scheme must be name or crypt handler
- self.assertRaises(TypeError, CryptPolicy, schemes=[uh.StaticHandler])
+ # do an update operation that should fail during extraction
+ # FIXME: this isn't failing even in broken case, need to figure out
+ # way to ensure some keys come after this one.
+ self.assertRaises(KeyError, cc.update, fake_context_option=True)
+ self.assertEqual(cc.to_string(), result)
- #check name conflicts are rejected
- class dummy_1(uh.StaticHandler):
- name = 'dummy_1'
- self.assertRaises(KeyError, CryptPolicy, schemes=[dummy_1, dummy_1])
+ # do an update operation that should fail during compilation
+ self.assertRaises(ValueError, cc.update, sha256_crypt__min_rounds=10000)
+ self.assertEqual(cc.to_string(), result)
- #with unknown deprecated value
- self.assertRaises(KeyError, CryptPolicy,
- schemes=['des_crypt'],
- deprecated=['md5_crypt'])
+ def test_12_update(self):
+ "test update() method"
- #with unknown default value
- self.assertRaises(KeyError, CryptPolicy,
- schemes=['des_crypt'],
- default='md5_crypt')
+ # empty overlay
+ ctx = CryptContext(**self.sample_1_dict)
+ ctx.update()
+ self.assertEqual(ctx.to_dict(), self.sample_1_dict)
- def test_01_from_path_simple(self):
- "test CryptPolicy.from_path() constructor"
- #NOTE: this is separate so it can also run under GAE
+ # test basic overlay
+ ctx = CryptContext(**self.sample_1_dict)
+ ctx.update(**self.sample_2_dict)
+ self.assertEqual(ctx.to_dict(), self.sample_12_dict)
- #test preset stored in existing file
- path = self.sample_config_1s_path
- policy = CryptPolicy.from_path(path)
- self.assertEqual(policy.to_dict(), self.sample_config_1pd)
+ # ... and again
+ ctx.update(**self.sample_3_dict)
+ self.assertEqual(ctx.to_dict(), self.sample_123_dict)
- #test if path missing
- self.assertRaises(EnvironmentError, CryptPolicy.from_path, path + 'xxx')
+ # overlay w/ dict arg
+ ctx = CryptContext(**self.sample_1_dict)
+ ctx.update(self.sample_2_dict)
+ self.assertEqual(ctx.to_dict(), self.sample_12_dict)
- def test_01_from_path(self):
- "test CryptPolicy.from_path() constructor with encodings"
- if gae_env:
- return self.skipTest("GAE doesn't offer read/write filesystem access")
+ # overlay w/ string
+ ctx = CryptContext(**self.sample_1_dict)
+ ctx.update(self.sample_2_unicode)
+ self.assertEqual(ctx.to_dict(), self.sample_12_dict)
- path = mktemp()
+ # too many args
+ self.assertRaises(TypeError, ctx.update, {}, {})
+ self.assertRaises(TypeError, ctx.update, {}, schemes=['des_crypt'])
- #test "\n" linesep
- set_file(path, self.sample_config_1s)
- policy = CryptPolicy.from_path(path)
- self.assertEqual(policy.to_dict(), self.sample_config_1pd)
+ # wrong arg type
+ self.assertRaises(TypeError, ctx.update, None)
- #test "\r\n" linesep
- set_file(path, self.sample_config_1s.replace("\n","\r\n"))
- policy = CryptPolicy.from_path(path)
- self.assertEqual(policy.to_dict(), self.sample_config_1pd)
+ #=========================================================
+ # option parsing
+ #=========================================================
+ def test_20_options(self):
+ "test basic option parsing"
+ def parse(**kwds):
+ return CryptContext(**kwds).to_dict()
- #test with custom encoding
- uc2 = to_bytes(self.sample_config_1s, "utf-16", source_encoding="utf-8")
- set_file(path, uc2)
- policy = CryptPolicy.from_path(path, encoding="utf-16")
- self.assertEqual(policy.to_dict(), self.sample_config_1pd)
+ #
+ # common option parsing tests
+ #
- def test_02_from_string(self):
- "test CryptPolicy.from_string() constructor"
- #test "\n" linesep
- policy = CryptPolicy.from_string(self.sample_config_1s)
- self.assertEqual(policy.to_dict(), self.sample_config_1pd)
-
- #test "\r\n" linesep
- policy = CryptPolicy.from_string(
- self.sample_config_1s.replace("\n","\r\n"))
- self.assertEqual(policy.to_dict(), self.sample_config_1pd)
-
- #test with unicode
- data = to_unicode(self.sample_config_1s)
- policy = CryptPolicy.from_string(data)
- self.assertEqual(policy.to_dict(), self.sample_config_1pd)
-
- #test with non-ascii-compatible encoding
- uc2 = to_bytes(self.sample_config_1s, "utf-16", source_encoding="utf-8")
- policy = CryptPolicy.from_string(uc2, encoding="utf-16")
- self.assertEqual(policy.to_dict(), self.sample_config_1pd)
-
- #test category specific options
- policy = CryptPolicy.from_string(self.sample_config_4s)
- self.assertEqual(policy.to_dict(), self.sample_config_4pd)
-
- def test_03_from_source(self):
- "test CryptPolicy.from_source() constructor"
- #pass it a path
- policy = CryptPolicy.from_source(self.sample_config_1s_path)
- self.assertEqual(policy.to_dict(), self.sample_config_1pd)
-
- #pass it a string
- policy = CryptPolicy.from_source(self.sample_config_1s)
- self.assertEqual(policy.to_dict(), self.sample_config_1pd)
-
- #pass it a dict (NOTE: make a copy to detect in-place modifications)
- policy = CryptPolicy.from_source(self.sample_config_1pd.copy())
- self.assertEqual(policy.to_dict(), self.sample_config_1pd)
-
- #pass it existing policy
- p2 = CryptPolicy.from_source(policy)
- self.assertIs(policy, p2)
-
- #pass it something wrong
- self.assertRaises(TypeError, CryptPolicy.from_source, 1)
- self.assertRaises(TypeError, CryptPolicy.from_source, [])
-
- def test_04_from_sources(self):
- "test CryptPolicy.from_sources() constructor"
-
- #pass it empty list
- self.assertRaises(ValueError, CryptPolicy.from_sources, [])
-
- #pass it one-element list
- policy = CryptPolicy.from_sources([self.sample_config_1s])
- self.assertEqual(policy.to_dict(), self.sample_config_1pd)
-
- #pass multiple sources
- policy = CryptPolicy.from_sources(
- [
- self.sample_config_1s_path,
- self.sample_config_2s,
- self.sample_config_3pd,
- ])
- self.assertEqual(policy.to_dict(), self.sample_config_123pd)
-
- def test_05_replace(self):
- "test CryptPolicy.replace() constructor"
-
- p1 = CryptPolicy(**self.sample_config_1pd)
-
- #check overlaying sample 2
- p2 = p1.replace(**self.sample_config_2pd)
- self.assertEqual(p2.to_dict(), self.sample_config_12pd)
-
- #check repeating overlay makes no change
- p2b = p2.replace(**self.sample_config_2pd)
- self.assertEqual(p2b.to_dict(), self.sample_config_12pd)
-
- #check overlaying sample 3
- p3 = p2.replace(self.sample_config_3pd)
- self.assertEqual(p3.to_dict(), self.sample_config_123pd)
-
- def test_06_forbidden(self):
- "test CryptPolicy() forbidden kwds"
-
- #salt not allowed to be set
- self.assertRaises(KeyError, CryptPolicy,
- schemes=["des_crypt"],
- des_crypt__salt="xx",
- )
- self.assertRaises(KeyError, CryptPolicy,
- schemes=["des_crypt"],
- all__salt="xx",
- )
+ # test key with blank separators is rejected
+ self.assertRaises(TypeError, CryptContext, __=0.1)
+ self.assertRaises(TypeError, CryptContext, __default='x')
+ self.assertRaises(TypeError, CryptContext, default____default='x')
+ self.assertRaises(TypeError, CryptContext, __default____default='x')
- #schemes not allowed for category
- self.assertRaises(KeyError, CryptPolicy,
- schemes=["des_crypt"],
- user__context__schemes=["md5_crypt"],
- )
+ # test key with too many separators is rejected
+ self.assertRaises(TypeError, CryptContext,
+ category__scheme__option__invalid = 30000)
- #=========================================================
- #reading
- #=========================================================
- def test_10_has_schemes(self):
- "test has_schemes() method"
+ #
+ # context option -specific tests
+ #
- p1 = CryptPolicy(**self.sample_config_1pd)
- self.assertTrue(p1.has_schemes())
+ # test context option key parsing
+ result = dict(default="md5_crypt")
+ self.assertEqual(parse(default="md5_crypt"), result)
+ self.assertEqual(parse(context__default="md5_crypt"), result)
+ self.assertEqual(parse(default__context__default="md5_crypt"), result)
+ self.assertEqual(parse(**{"context.default":"md5_crypt"}), result)
+ self.assertEqual(parse(**{"default.context.default":"md5_crypt"}), result)
- p3 = CryptPolicy(**self.sample_config_3pd)
- self.assertTrue(not p3.has_schemes())
+ # test context option key parsing w/ category
+ result = dict(admin__context__default="md5_crypt")
+ self.assertEqual(parse(admin__context__default="md5_crypt"), result)
+ self.assertEqual(parse(**{"admin.context.default":"md5_crypt"}), result)
- def test_11_iter_handlers(self):
- "test iter_handlers() method"
+ #
+ # hash option -specific tests
+ #
- p1 = CryptPolicy(**self.sample_config_1pd)
- s = self.sample_config_1prd['schemes']
- self.assertEqual(list(p1.iter_handlers()), s)
+ # test hash option key parsing
+ result = dict(all__vary_rounds=0.1)
+ self.assertEqual(parse(all__vary_rounds=0.1), result)
+ self.assertEqual(parse(default__all__vary_rounds=0.1), result)
+ self.assertEqual(parse(**{"all.vary_rounds":0.1}), result)
+ self.assertEqual(parse(**{"default.all.vary_rounds":0.1}), result)
- p3 = CryptPolicy(**self.sample_config_3pd)
- self.assertEqual(list(p3.iter_handlers()), [])
+ # test hash option key parsing w/ category
+ result = dict(admin__all__vary_rounds=0.1)
+ self.assertEqual(parse(admin__all__vary_rounds=0.1), result)
+ self.assertEqual(parse(**{"admin.all.vary_rounds":0.1}), result)
- def test_12_get_handler(self):
- "test get_handler() method"
+ # settings not allowed if not in hash.settings_kwds
+ ctx = CryptContext(["phpass", "md5_crypt"], phpass__ident="P")
+ self.assertRaises(KeyError, ctx.copy, md5_crypt__ident="P")
- p1 = CryptPolicy(**self.sample_config_1pd)
+ # hash options 'salt' and 'rounds' not allowed
+ self.assertRaises(KeyError, CryptContext, schemes=["des_crypt"],
+ des_crypt__salt="xx")
+ self.assertRaises(KeyError, CryptContext, schemes=["des_crypt"],
+ all__salt="xx")
- #check by name
- self.assertIs(p1.get_handler("bsdi_crypt"), hash.bsdi_crypt)
+ def test_21_schemes(self):
+ "test 'schemes' context option parsing"
- #check by missing name
- self.assertIs(p1.get_handler("sha256_crypt"), None)
- self.assertRaises(KeyError, p1.get_handler, "sha256_crypt", required=True)
+ # schemes can be empty
+ cc = CryptContext(schemes=None)
+ self.assertEqual(cc.schemes(), ())
- #check default
- self.assertIs(p1.get_handler(), hash.md5_crypt)
+ # schemes can be list of names
+ cc = CryptContext(schemes=["des_crypt", "md5_crypt"])
+ self.assertEqual(cc.schemes(), ("des_crypt", "md5_crypt"))
- def test_13_get_options(self):
- "test get_options() method"
+ # schemes can be comma-sep string
+ cc = CryptContext(schemes=" des_crypt, md5_crypt, ")
+ self.assertEqual(cc.schemes(), ("des_crypt", "md5_crypt"))
- p12 = CryptPolicy(**self.sample_config_12pd)
+ # schemes can be list of handlers
+ cc = CryptContext(schemes=[hash.des_crypt, hash.md5_crypt])
+ self.assertEqual(cc.schemes(), ("des_crypt", "md5_crypt"))
- self.assertEqual(p12.get_options("bsdi_crypt"),dict(
- vary_rounds = "10%",
- min_rounds = 29000,
- max_rounds = 35000,
- default_rounds = 31000,
- ))
+ # scheme must be name or handler
+ self.assertRaises(TypeError, CryptContext, schemes=[uh.StaticHandler])
- self.assertEqual(p12.get_options("sha512_crypt"),dict(
- vary_rounds = "10%",
- min_rounds = 45000,
- max_rounds = 50000,
- ))
+ # handlers must have a name
+ class nameless(uh.StaticHandler):
+ name = None
+ self.assertRaises(ValueError, CryptContext, schemes=[nameless])
+
+ # names must be unique
+ class dummy_1(uh.StaticHandler):
+ name = 'dummy_1'
+ self.assertRaises(KeyError, CryptContext, schemes=[dummy_1, dummy_1])
+
+ # schemes not allowed per-category
+ self.assertRaises(KeyError, CryptContext,
+ admin__context__schemes=["md5_crypt"])
+
+ def test_22_deprecated(self):
+ "test 'deprecated' context option parsing"
+ def getdep(ctx, category=None):
+ return [name for name in ctx.schemes()
+ if ctx._is_deprecated_scheme(name, category)]
+
+ # no schemes - all deprecated values allowed
+ cc = CryptContext(deprecated=["md5_crypt"])
+ cc.update(schemes=["md5_crypt", "des_crypt"])
+ self.assertEqual(getdep(cc),["md5_crypt"])
+
+ # deprecated values allowed if subset of schemes
+ cc = CryptContext(deprecated=["md5_crypt"], schemes=["md5_crypt", "des_crypt"])
+ self.assertEqual(getdep(cc), ["md5_crypt"])
+
+ # can be handler
+ # XXX: allow handlers in deprecated list? not for now.
+ self.assertRaises(TypeError, CryptContext, deprecated=[hash.md5_crypt],
+ schemes=["md5_crypt", "des_crypt"])
+## cc = CryptContext(deprecated=[hash.md5_crypt], schemes=["md5_crypt", "des_crypt"])
+## self.assertEqual(getdep(cc), ["md5_crypt"])
+
+ # comma sep list
+ cc = CryptContext(deprecated="md5_crypt,des_crypt", schemes=["md5_crypt", "des_crypt"])
+ self.assertEqual(getdep(cc), ["md5_crypt", "des_crypt"])
+
+ # values outside of schemes not allowed
+ self.assertRaises(KeyError, CryptContext, schemes=['des_crypt'],
+ deprecated=['md5_crypt'])
+
+ # wrong type
+ self.assertRaises(TypeError, CryptContext, deprecated=123)
+
+ # deprecated per-category
+ cc = CryptContext(deprecated=["md5_crypt"],
+ schemes=["md5_crypt", "des_crypt"],
+ admin__context__deprecated=["des_crypt"],
+ )
+ self.assertEqual(getdep(cc), ["md5_crypt"])
+ self.assertEqual(getdep(cc, "user"), ["md5_crypt"])
+ self.assertEqual(getdep(cc, "admin"), ["des_crypt"])
+
+ def test_23_default(self):
+ "test 'default' context option parsing"
+
+ # anything allowed if no schemes
+ self.assertEqual(CryptContext(default="md5_crypt").to_dict(),
+ dict(default="md5_crypt"))
+
+ # default allowed if in scheme list
+ ctx = CryptContext(default="md5_crypt", schemes=["des_crypt", "md5_crypt"])
+ self.assertEqual(ctx.default_scheme(), "md5_crypt")
- p4 = CryptPolicy.from_string(self.sample_config_4s)
- self.assertEqual(p4.get_options("sha512_crypt"), dict(
- vary_rounds="10%",
+ # default can be handler
+ # XXX: sure we want to allow this ? maybe deprecate in future.
+ ctx = CryptContext(default=hash.md5_crypt, schemes=["des_crypt", "md5_crypt"])
+ self.assertEqual(ctx.default_scheme(), "md5_crypt")
+
+ # error if not in scheme list
+ self.assertRaises(KeyError, CryptContext, schemes=['des_crypt'],
+ default='md5_crypt')
+
+ # wrong type
+ self.assertRaises(TypeError, CryptContext, default=1)
+
+ # per-category
+ ctx = CryptContext(default="des_crypt",
+ schemes=["des_crypt", "md5_crypt"],
+ admin__context__default="md5_crypt")
+ self.assertEqual(ctx.default_scheme(), "des_crypt")
+ self.assertEqual(ctx.default_scheme("user"), "des_crypt")
+ self.assertEqual(ctx.default_scheme("admin"), "md5_crypt")
+
+ def test_24_vary_rounds(self):
+ "test 'vary_rounds' hash option parsing"
+ def parse(v):
+ return CryptContext(all__vary_rounds=v).to_dict()['all__vary_rounds']
+
+ # floats should be preserved
+ self.assertEqual(parse(0.1), 0.1)
+ self.assertEqual(parse('0.1'), 0.1)
+
+ # 'xx%' should be converted to float
+ self.assertEqual(parse('10%'), 0.1)
+
+ # ints should be preserved
+ self.assertEqual(parse(1000), 1000)
+ self.assertEqual(parse('1000'), 1000)
+
+ #=========================================================
+ # inspection & serialization
+ #=========================================================
+ def test_30_schemes(self):
+ "test schemes() method"
+ # NOTE: also checked under test_21
+
+ # test empty
+ ctx = CryptContext()
+ self.assertEqual(ctx.schemes(), ())
+ self.assertEqual(ctx.schemes(resolve=True), ())
+
+ # test sample 1
+ ctx = CryptContext(**self.sample_1_dict)
+ self.assertEqual(ctx.schemes(), tuple(self.sample_1_schemes))
+ self.assertEqual(ctx.schemes(resolve=True), tuple(self.sample_1_handlers))
+
+ # test sample 2
+ ctx = CryptContext(**self.sample_2_dict)
+ self.assertEqual(ctx.schemes(), ())
+
+ def test_31_default_scheme(self):
+ "test default_scheme() method"
+ # NOTE: also checked under test_23
+
+ # test empty
+ ctx = CryptContext()
+ self.assertRaises(KeyError, ctx.default_scheme)
+
+ # test sample 1
+ ctx = CryptContext(**self.sample_1_dict)
+ self.assertEqual(ctx.default_scheme(), "md5_crypt")
+ self.assertEqual(ctx.default_scheme(resolve=True), hash.md5_crypt)
+
+ # test sample 2
+ ctx = CryptContext(**self.sample_2_dict)
+ self.assertRaises(KeyError, ctx.default_scheme)
+
+ # test defaults to first in scheme
+ ctx = CryptContext(schemes=self.sample_1_schemes)
+ self.assertEqual(ctx.default_scheme(), "des_crypt")
+
+ # categories tested under test_23
+
+ def test_32_handler(self):
+ "test handler() method"
+
+ # default for empty
+ ctx = CryptContext()
+ self.assertRaises(KeyError, ctx.handler)
+
+ # default for sample 1
+ ctx = CryptContext(**self.sample_1_dict)
+ self.assertEqual(ctx.handler(), hash.md5_crypt)
+
+ # by name
+ self.assertEqual(ctx.handler("des_crypt"), hash.des_crypt)
+
+ # name not in schemes
+ self.assertRaises(KeyError, ctx.handler, "mysql323")
+
+ # TODO: per-category
+
+ def test_33_options(self):
+ "test internal _get_record_options() method"
+ def options(ctx, scheme, category=None):
+ return ctx._get_record_options(scheme, category)[0]
+
+ # this checks that (3 schemes, 3 categories) inherit options correctly.
+ # the 'user' category is not present in the options.
+ cc4 = CryptContext(
+ schemes = [ "sha512_crypt", "des_crypt", "bsdi_crypt"],
+ deprecated = ["sha512_crypt", "des_crypt"],
+ all__vary_rounds = 0.1,
+ bsdi_crypt__vary_rounds=0.2,
+ sha512_crypt__max_rounds = 20000,
+ admin__context__deprecated = [ "des_crypt", "bsdi_crypt" ],
+ admin__all__vary_rounds = 0.05,
+ admin__bsdi_crypt__vary_rounds=0.3,
+ admin__sha512_crypt__max_rounds = 40000,
+ )
+ self.assertEqual(cc4._categories, ("admin",))
+
+ #
+ # sha512_crypt
+ #
+ self.assertEqual(options(cc4, "sha512_crypt"), dict(
+ deprecated=True,
+ vary_rounds=0.1, # inherited from all__
max_rounds=20000,
))
- self.assertEqual(p4.get_options("sha512_crypt", "user"), dict(
- vary_rounds="10%",
+ self.assertEqual(options(cc4, "sha512_crypt", "user"), dict(
+ deprecated=True, # unconfigured category inherits from default
+ vary_rounds=0.1,
max_rounds=20000,
))
- self.assertEqual(p4.get_options("sha512_crypt", "admin"), dict(
- vary_rounds="5%",
- max_rounds=40000,
+ self.assertEqual(options(cc4, "sha512_crypt", "admin"), dict(
+ # NOT deprecated - context option overridden per-category
+ vary_rounds=0.05, # global overridden per-cateogry
+ max_rounds=40000, # overridden per-category
))
- def test_14_handler_is_deprecated(self):
- "test handler_is_deprecated() method"
- pa = CryptPolicy(**self.sample_config_1pd)
- pb = CryptPolicy(**self.sample_config_5pd)
-
- self.assertFalse(pa.handler_is_deprecated("des_crypt"))
- self.assertFalse(pa.handler_is_deprecated(hash.bsdi_crypt))
- self.assertFalse(pa.handler_is_deprecated("sha512_crypt"))
-
- self.assertTrue(pb.handler_is_deprecated("des_crypt"))
- self.assertFalse(pb.handler_is_deprecated(hash.bsdi_crypt))
- self.assertFalse(pb.handler_is_deprecated("sha512_crypt"))
-
- #check categories as well
- self.assertTrue(pb.handler_is_deprecated("des_crypt", "user"))
- self.assertFalse(pb.handler_is_deprecated("bsdi_crypt", "user"))
- self.assertTrue(pb.handler_is_deprecated("des_crypt", "admin"))
- self.assertTrue(pb.handler_is_deprecated("bsdi_crypt", "admin"))
-
- # check deprecation is overridden per category
- pc = CryptPolicy(
- schemes=["md5_crypt", "des_crypt"],
- deprecated=["md5_crypt"],
- user__context__deprecated=["des_crypt"],
- )
- self.assertTrue(pc.handler_is_deprecated("md5_crypt"))
- self.assertFalse(pc.handler_is_deprecated("des_crypt"))
- self.assertFalse(pc.handler_is_deprecated("md5_crypt", "user"))
- self.assertTrue(pc.handler_is_deprecated("des_crypt", "user"))
+ #
+ # des_crypt
+ #
+ self.assertEqual(options(cc4, "des_crypt"), dict(
+ deprecated=True,
+ vary_rounds=0.1,
+ ))
- def test_15_min_verify_time(self):
- "test get_min_verify_time() method"
- # silence deprecation warnings for min verify time
- warnings.filterwarnings("ignore", category=DeprecationWarning)
+ self.assertEqual(options(cc4, "des_crypt", "user"), dict(
+ deprecated=True, # unconfigured category inherits from default
+ vary_rounds=0.1,
+ ))
- pa = CryptPolicy()
- self.assertEqual(pa.get_min_verify_time(), 0)
- self.assertEqual(pa.get_min_verify_time('admin'), 0)
+ self.assertEqual(options(cc4, "des_crypt", "admin"), dict(
+ deprecated=True, # unchanged though overidden
+ vary_rounds=0.05, # global overridden per-cateogry
+ ))
- pb = pa.replace(min_verify_time=.1)
- self.assertEqual(pb.get_min_verify_time(), .1)
- self.assertEqual(pb.get_min_verify_time('admin'), .1)
+ #
+ # bsdi_crypt
+ #
+ self.assertEqual(options(cc4, "bsdi_crypt"), dict(
+ vary_rounds=0.2, # overridden from all__vary_rounds
+ ))
- pc = pa.replace(admin__context__min_verify_time=.2)
- self.assertEqual(pc.get_min_verify_time(), 0)
- self.assertEqual(pc.get_min_verify_time('admin'), .2)
+ self.assertEqual(options(cc4, "bsdi_crypt", "user"), dict(
+ vary_rounds=0.2, # unconfigured category inherits from default
+ ))
- pd = pb.replace(admin__context__min_verify_time=.2)
- self.assertEqual(pd.get_min_verify_time(), .1)
- self.assertEqual(pd.get_min_verify_time('admin'), .2)
+ self.assertEqual(options(cc4, "bsdi_crypt", "admin"), dict(
+ vary_rounds=0.3,
+ deprecated=True, # deprecation set per-category
+ ))
- #=========================================================
- #serialization
- #=========================================================
- def test_20_iter_config(self):
- "test iter_config() method"
- p5 = CryptPolicy(**self.sample_config_5pd)
- self.assertEqual(dict(p5.iter_config()), self.sample_config_5pd)
- self.assertEqual(dict(p5.iter_config(resolve=True)), self.sample_config_5prd)
- self.assertEqual(dict(p5.iter_config(ini=True)), self.sample_config_5pid)
-
- def test_21_to_dict(self):
+ def test_34_to_dict(self):
"test to_dict() method"
- p5 = CryptPolicy(**self.sample_config_5pd)
- self.assertEqual(p5.to_dict(), self.sample_config_5pd)
- self.assertEqual(p5.to_dict(resolve=True), self.sample_config_5prd)
+ # NOTE: this is tested all throughout this test case.
+ ctx = CryptContext(**self.sample_1_dict)
+ self.assertEqual(ctx.to_dict(), self.sample_1_dict)
+ self.assertEqual(ctx.to_dict(resolve=True), self.sample_1_resolved_dict)
- def test_22_to_string(self):
+ def test_35_to_string(self):
"test to_string() method"
- pa = CryptPolicy(**self.sample_config_5pd)
- s = pa.to_string() #NOTE: can't compare string directly, ordering etc may not match
- pb = CryptPolicy.from_string(s)
- self.assertEqual(pb.to_dict(), self.sample_config_5pd)
- #=========================================================
- #
- #=========================================================
+ # create ctx and serialize
+ ctx = CryptContext(**self.sample_1_dict)
+ dump = ctx.to_string()
-#=========================================================
-#CryptContext
-#=========================================================
-class CryptContextTest(TestCase):
- "test CryptContext class"
- descriptionPrefix = "CryptContext"
+ # check ctx->string returns canonical format.
+ # NOTE: ConfigParser for PY26 and earlier didn't use OrderedDict,
+ # so to_string() won't get order correct.
+ # so we skip this test.
+ import sys
+ if sys.version_info >= (2,7):
+ self.assertEqual(dump, self.sample_1_unicode)
+
+ # check ctx->string->ctx->dict returns original
+ ctx2 = CryptContext.from_string(dump)
+ self.assertEqual(ctx2.to_dict(), self.sample_1_dict)
+
+ # TODO: test other features, like the unmanaged handler warning.
+ # TODO: test compact mode, section
#=========================================================
- #constructor
+ # password hash api
#=========================================================
- def test_00_constructor(self):
- "test constructor"
- #create crypt context using handlers
- cc = CryptContext([hash.md5_crypt, hash.bsdi_crypt, hash.des_crypt])
- c,b,a = cc.policy.iter_handlers()
- self.assertIs(a, hash.des_crypt)
- self.assertIs(b, hash.bsdi_crypt)
- self.assertIs(c, hash.md5_crypt)
-
- #create context using names
- cc = CryptContext(["md5_crypt", "bsdi_crypt", "des_crypt"])
- c,b,a = cc.policy.iter_handlers()
- self.assertIs(a, hash.des_crypt)
- self.assertIs(b, hash.bsdi_crypt)
- self.assertIs(c, hash.md5_crypt)
-
- #TODO: test policy & other options
-
- def test_01_replace(self):
- "test replace()"
-
- cc = CryptContext(["md5_crypt", "bsdi_crypt", "des_crypt"])
- self.assertIs(cc.policy.get_handler(), hash.md5_crypt)
-
- cc2 = cc.replace()
- self.assertIsNot(cc2, cc)
- self.assertIs(cc2.policy, cc.policy)
-
- cc3 = cc.replace(default="bsdi_crypt")
- self.assertIsNot(cc3, cc)
- self.assertIsNot(cc3.policy, cc.policy)
- self.assertIs(cc3.policy.get_handler(), hash.bsdi_crypt)
-
- def test_02_no_handlers(self):
- "test no handlers"
-
- #check constructor...
- cc = CryptContext()
- self.assertRaises(KeyError, cc.identify, 'hash', required=True)
- self.assertRaises(KeyError, cc.encrypt, 'secret')
- self.assertRaises(KeyError, cc.verify, 'secret', 'hash')
+ nonstring_vectors = [
+ (None, {}),
+ (None, {"scheme": "des_crypt"}),
+ (1, {}),
+ ((), {}),
+ ]
+
+ def test_40_basic(self):
+ "test basic encrypt/identify/verify functionality"
+ handlers = [hash.md5_crypt, hash.des_crypt, hash.bsdi_crypt]
+ cc = CryptContext(handlers)
+
+ #run through handlers
+ for crypt in handlers:
+ h = cc.encrypt("test", scheme=crypt.name)
+ self.assertEqual(cc.identify(h), crypt.name)
+ self.assertEqual(cc.identify(h, resolve=True), crypt)
+ self.assertTrue(cc.verify('test', h))
+ self.assertTrue(not cc.verify('notest', h))
- #check updating policy after the fact...
- cc = CryptContext(['md5_crypt'])
- p = CryptPolicy(schemes=[])
- cc.policy = p
+ #test default
+ h = cc.encrypt("test")
+ self.assertEqual(cc.identify(h), "md5_crypt")
- self.assertRaises(KeyError, cc.identify, 'hash', required=True)
- self.assertRaises(KeyError, cc.encrypt, 'secret')
- self.assertRaises(KeyError, cc.verify, 'secret', 'hash')
+ #test genhash
+ h = cc.genhash('secret', cc.genconfig())
+ self.assertEqual(cc.identify(h), 'md5_crypt')
- #=========================================================
- #policy adaptation
- #=========================================================
- sample_policy_1 = dict(
- schemes = [ "des_crypt", "md5_crypt", "phpass", "bsdi_crypt",
- "sha256_crypt"],
- deprecated = [ "des_crypt", ],
- default = "sha256_crypt",
- bsdi_crypt__max_rounds = 30,
- bsdi_crypt__default_rounds = 25,
- bsdi_crypt__vary_rounds = 0,
- sha256_crypt__max_rounds = 3000,
- sha256_crypt__min_rounds = 2000,
- sha256_crypt__default_rounds = 3000,
- phpass__ident = "H",
- phpass__default_rounds = 7,
- )
+ h = cc.genhash('secret', cc.genconfig(), scheme='md5_crypt')
+ self.assertEqual(cc.identify(h), 'md5_crypt')
- def test_10_01_genconfig_settings(self):
- "test genconfig() settings"
- cc = CryptContext(policy=None,
- schemes=["md5_crypt", "phpass"],
+ self.assertRaises(ValueError, cc.genhash, 'secret', cc.genconfig(), scheme="des_crypt")
+
+ def test_41_genconfig(self):
+ "test genconfig() method"
+ cc = CryptContext(schemes=["md5_crypt", "phpass"],
phpass__ident="H",
phpass__default_rounds=7,
)
- # hash specific settings
+ # uses default scheme
self.assertTrue(cc.genconfig().startswith("$1$"))
- self.assertEqual(
- cc.genconfig(scheme="phpass", salt='.'*8),
- '$H$5........',
- )
+
+ # override scheme
+ self.assertTrue(cc.genconfig(scheme="phpass").startswith("$H$5"))
+
+ # override scheme & custom settings
self.assertEqual(
cc.genconfig(scheme="phpass", salt='.'*8, rounds=8, ident='P'),
'$P$6........',
)
- # unsupported hash settings should be rejected
- self.assertRaises(KeyError, cc.replace, md5_crypt__ident="P")
+ #--------------------------------------------------------------
+ # border cases
+ #--------------------------------------------------------------
+
+ # throws error without schemes
+ self.assertRaises(KeyError, CryptContext().genconfig)
+
+ def test_42_genhash(self):
+ "test genhash() method"
+
+ #--------------------------------------------------------------
+ # border cases
+ #--------------------------------------------------------------
+
+ # rejects non-string secrets
+ cc = CryptContext(["des_crypt"])
+ hash = cc.encrypt('stub')
+ for secret, kwds in self.nonstring_vectors:
+ self.assertRaises(TypeError, cc.genhash, secret, hash, **kwds)
+
+ # rejects non-string hashes
+ cc = CryptContext(["des_crypt"])
+ for hash, kwds in self.nonstring_vectors:
+ self.assertRaises(TypeError, cc.genhash, 'secret', hash, **kwds)
+
+ # .. but should accept None if default scheme lacks config string
+ cc = CryptContext(["mysql323"])
+ self.assertIsInstance(cc.genhash("stub", None), str)
+
+ # throws error without schemes
+ self.assertRaises(KeyError, CryptContext().genhash, 'secret', 'hash')
+
+ def test_43_encrypt(self):
+ "test encrypt() method"
+ cc = CryptContext(**self.sample_4_dict)
+
+ # hash specific settings
+ self.assertEqual(
+ cc.encrypt("password", scheme="phpass", salt='.'*8),
+ '$H$5........De04R5Egz0aq8Tf.1eVhY/',
+ )
+ self.assertEqual(
+ cc.encrypt("password", scheme="phpass", salt='.'*8, ident="P"),
+ '$P$5........De04R5Egz0aq8Tf.1eVhY/',
+ )
+
+ # NOTE: more thorough job of rounds limits done below.
- def test_10_02_genconfig_rounds_limits(self):
- "test genconfig() policy rounds limits"
- cc = CryptContext(policy=None,
- schemes=["sha256_crypt"],
+ # min rounds
+ with catch_warnings(record=True) as wlog:
+ self.assertEqual(
+ cc.encrypt("password", rounds=1999, salt="nacl"),
+ '$5$rounds=2000$nacl$9/lTZ5nrfPuz8vphznnmHuDGFuvjSNvOEDsGmGfsS97',
+ )
+ self.consumeWarningList(wlog, PasslibConfigWarning)
+
+ self.assertEqual(
+ cc.encrypt("password", rounds=2001, salt="nacl"),
+ '$5$rounds=2001$nacl$8PdeoPL4aXQnJ0woHhqgIw/efyfCKC2WHneOpnvF.31'
+ )
+ self.consumeWarningList(wlog)
+
+ # NOTE: max rounds, etc tested in genconfig()
+
+ # make default > max throws error if attempted
+ self.assertRaises(ValueError, cc.copy,
+ sha256_crypt__default_rounds=4000)
+
+ #--------------------------------------------------------------
+ # border cases
+ #--------------------------------------------------------------
+
+ # rejects non-string secrets
+ cc = CryptContext(["des_crypt"])
+ for secret, kwds in self.nonstring_vectors:
+ self.assertRaises(TypeError, cc.encrypt, secret, **kwds)
+
+ # throws error without schemes
+ self.assertRaises(KeyError, CryptContext().encrypt, 'secret')
+
+ def test_44_identify(self):
+ "test identify() border cases"
+ handlers = ["md5_crypt", "des_crypt", "bsdi_crypt"]
+ cc = CryptContext(handlers)
+
+ #check unknown hash
+ self.assertEqual(cc.identify('$9$232323123$1287319827'), None)
+ self.assertRaises(ValueError, cc.identify, '$9$232323123$1287319827', required=True)
+
+ #--------------------------------------------------------------
+ # border cases
+ #--------------------------------------------------------------
+
+ # rejects non-string hashes
+ cc = CryptContext(["des_crypt"])
+ for hash, kwds in self.nonstring_vectors:
+ self.assertRaises(TypeError, cc.identify, hash, **kwds)
+
+ # throws error without schemes
+ cc = CryptContext()
+ self.assertIs(cc.identify('hash'), None)
+ self.assertRaises(KeyError, cc.identify, 'hash', required=True)
+
+ def test_45_verify(self):
+ "test verify() scheme kwd"
+ handlers = ["md5_crypt", "des_crypt", "bsdi_crypt"]
+ cc = CryptContext(handlers)
+
+ h = hash.md5_crypt.encrypt("test")
+
+ #check base verify
+ self.assertTrue(cc.verify("test", h))
+ self.assertTrue(not cc.verify("notest", h))
+
+ #check verify using right alg
+ self.assertTrue(cc.verify('test', h, scheme='md5_crypt'))
+ self.assertTrue(not cc.verify('notest', h, scheme='md5_crypt'))
+
+ #check verify using wrong alg
+ self.assertRaises(ValueError, cc.verify, 'test', h, scheme='bsdi_crypt')
+
+ #--------------------------------------------------------------
+ # border cases
+ #--------------------------------------------------------------
+
+ # rejects non-string secrets
+ cc = CryptContext(["des_crypt"])
+ h = cc.encrypt('stub')
+ for secret, kwds in self.nonstring_vectors:
+ self.assertRaises(TypeError, cc.verify, secret, h, **kwds)
+
+ # rejects non-string hashes
+ cc = CryptContext(["des_crypt"])
+ for h, kwds in self.nonstring_vectors:
+ self.assertRaises(TypeError, cc.verify, 'secret', h, **kwds)
+
+ # throws error without schemes
+ self.assertRaises(KeyError, CryptContext().verify, 'secret', 'hash')
+
+ def test_46_hash_needs_update(self):
+ "test hash_needs_update() method"
+ cc = CryptContext(**self.sample_4_dict)
+
+ #check deprecated scheme
+ self.assertTrue(cc.hash_needs_update('9XXD4trGYeGJA'))
+ self.assertFalse(cc.hash_needs_update('$1$J8HC2RCr$HcmM.7NxB2weSvlw2FgzU0'))
+
+ #check min rounds
+ self.assertTrue(cc.hash_needs_update('$5$rounds=1999$jD81UCoo.zI.UETs$Y7qSTQ6mTiU9qZB4fRr43wRgQq4V.5AAf7F97Pzxey/'))
+ self.assertFalse(cc.hash_needs_update('$5$rounds=2000$228SSRje04cnNCaQ$YGV4RYu.5sNiBvorQDlO0WWQjyJVGKBcJXz3OtyQ2u8'))
+
+ #check max rounds
+ self.assertFalse(cc.hash_needs_update('$5$rounds=3000$fS9iazEwTKi7QPW4$VasgBC8FqlOvD7x2HhABaMXCTh9jwHclPA9j5YQdns.'))
+ self.assertTrue(cc.hash_needs_update('$5$rounds=3001$QlFHHifXvpFX4PLs$/0ekt7lSs/lOikSerQ0M/1porEHxYq7W/2hdFpxA3fA'))
+
+ #--------------------------------------------------------------
+ # border cases
+ #--------------------------------------------------------------
+
+ # rejects non-string hashes
+ cc = CryptContext(["des_crypt"])
+ for hash, kwds in self.nonstring_vectors:
+ self.assertRaises(TypeError, cc.hash_needs_update, hash, **kwds)
+
+ # throws error without schemes
+ self.assertRaises(KeyError, CryptContext().hash_needs_update, 'hash')
+
+ def test_47_verify_and_update(self):
+ "test verify_and_update()"
+ cc = CryptContext(**self.sample_4_dict)
+
+ #create some hashes
+ h1 = cc.encrypt("password", scheme="des_crypt")
+ h2 = cc.encrypt("password", scheme="sha256_crypt")
+
+ #check bad password, deprecated hash
+ ok, new_hash = cc.verify_and_update("wrongpass", h1)
+ self.assertFalse(ok)
+ self.assertIs(new_hash, None)
+
+ #check bad password, good hash
+ ok, new_hash = cc.verify_and_update("wrongpass", h2)
+ self.assertFalse(ok)
+ self.assertIs(new_hash, None)
+
+ #check right password, deprecated hash
+ ok, new_hash = cc.verify_and_update("password", h1)
+ self.assertTrue(ok)
+ self.assertTrue(cc.identify(new_hash), "sha256_crypt")
+
+ #check right password, good hash
+ ok, new_hash = cc.verify_and_update("password", h2)
+ self.assertTrue(ok)
+ self.assertIs(new_hash, None)
+
+ #--------------------------------------------------------------
+ # border cases
+ #--------------------------------------------------------------
+
+ # rejects non-string secrets
+ cc = CryptContext(["des_crypt"])
+ hash = cc.encrypt('stub')
+ for secret, kwds in self.nonstring_vectors:
+ self.assertRaises(TypeError, cc.verify_and_update, secret, hash, **kwds)
+
+ # rejects non-string hashes
+ cc = CryptContext(["des_crypt"])
+ for hash, kwds in self.nonstring_vectors:
+ self.assertRaises(TypeError, cc.verify_and_update, 'secret', hash, **kwds)
+
+ # throws error without schemes
+ self.assertRaises(KeyError, CryptContext().verify_and_update, 'secret', 'hash')
+
+ #=========================================================
+ # rounds options
+ #=========================================================
+ # NOTE: the follow tests check how _CryptRecord handles
+ # the min/max/default/vary_rounds options, via the output of
+ # genconfig(). it's assumed encrypt() takes the same codepath.
+
+ def test_50_rounds_limits(self):
+ "test rounds limits"
+ cc = CryptContext(schemes=["sha256_crypt"],
all__min_rounds=2000,
all__max_rounds=3000,
all__default_rounds=2500,
@@ -631,7 +1031,7 @@ class CryptContextTest(TestCase):
with catch_warnings(record=True) as wlog:
# set below handler min
- c2 = cc.replace(all__min_rounds=500, all__max_rounds=None,
+ c2 = cc.copy(all__min_rounds=500, all__max_rounds=None,
all__default_rounds=500)
self.consumeWarningList(wlog, [PasslibConfigWarning]*2)
self.assertEqual(c2.genconfig(salt="nacl"), "$5$rounds=1000$nacl$")
@@ -661,7 +1061,7 @@ class CryptContextTest(TestCase):
# max rounds
with catch_warnings(record=True) as wlog:
# set above handler max
- c2 = cc.replace(all__max_rounds=int(1e9)+500, all__min_rounds=None,
+ c2 = cc.copy(all__max_rounds=int(1e9)+500, all__min_rounds=None,
all__default_rounds=int(1e9)+500)
self.consumeWarningList(wlog, [PasslibConfigWarning]*2)
self.assertEqual(c2.genconfig(salt="nacl"),
@@ -693,225 +1093,119 @@ class CryptContextTest(TestCase):
self.assertEqual(cc.genconfig(salt="nacl"), '$5$rounds=2500$nacl$')
# fallback default rounds - use handler's
- c2 = cc.replace(all__default_rounds=None, all__max_rounds=50000)
- self.assertEqual(c2.genconfig(salt="nacl"), '$5$rounds=40000$nacl$')
+ df = hash.sha256_crypt.default_rounds
+ c2 = cc.copy(all__default_rounds=None, all__max_rounds=df<<1)
+ self.assertEqual(c2.genconfig(salt="nacl"),
+ '$5$rounds=%d$nacl$' % df)
# fallback default rounds - use handler's, but clipped to max rounds
- c2 = cc.replace(all__default_rounds=None, all__max_rounds=3000)
+ c2 = cc.copy(all__default_rounds=None, all__max_rounds=3000)
self.assertEqual(c2.genconfig(salt="nacl"), '$5$rounds=3000$nacl$')
# TODO: test default falls back to mx / mn if handler has no default.
#default rounds - out of bounds
- self.assertRaises(ValueError, cc.replace, all__default_rounds=1999)
- cc.policy.replace(all__default_rounds=2000)
- cc.policy.replace(all__default_rounds=3000)
- self.assertRaises(ValueError, cc.replace, all__default_rounds=3001)
+ self.assertRaises(ValueError, cc.copy, all__default_rounds=1999)
+ cc.copy(all__default_rounds=2000)
+ cc.copy(all__default_rounds=3000)
+ self.assertRaises(ValueError, cc.copy, all__default_rounds=3001)
# invalid min/max bounds
- c2 = CryptContext(policy=None, schemes=["sha256_crypt"])
- self.assertRaises(ValueError, c2.replace, all__min_rounds=-1)
- self.assertRaises(ValueError, c2.replace, all__max_rounds=-1)
- self.assertRaises(ValueError, c2.replace, all__min_rounds=2000,
+ c2 = CryptContext(schemes=["sha256_crypt"])
+ self.assertRaises(ValueError, c2.copy, all__min_rounds=-1)
+ self.assertRaises(ValueError, c2.copy, all__max_rounds=-1)
+ self.assertRaises(ValueError, c2.copy, all__min_rounds=2000,
all__max_rounds=1999)
- def test_10_03_genconfig_linear_vary_rounds(self):
- "test genconfig() linear vary rounds"
- cc = CryptContext(policy=None,
- schemes=["sha256_crypt"],
+ def test_51_linear_vary_rounds(self):
+ "test linear vary rounds"
+ cc = CryptContext(schemes=["sha256_crypt"],
all__min_rounds=1995,
all__max_rounds=2005,
all__default_rounds=2000,
)
# test negative
- self.assertRaises(ValueError, cc.replace, all__vary_rounds=-1)
- self.assertRaises(ValueError, cc.replace, all__vary_rounds="-1%")
- self.assertRaises(ValueError, cc.replace, all__vary_rounds="101%")
+ self.assertRaises(ValueError, cc.copy, all__vary_rounds=-1)
+ self.assertRaises(ValueError, cc.copy, all__vary_rounds="-1%")
+ self.assertRaises(ValueError, cc.copy, all__vary_rounds="101%")
# test static
- c2 = cc.replace(all__vary_rounds=0)
+ c2 = cc.copy(all__vary_rounds=0)
self.assert_rounds_range(c2, "sha256_crypt", 2000, 2000)
- c2 = cc.replace(all__vary_rounds="0%")
+ c2 = cc.copy(all__vary_rounds="0%")
self.assert_rounds_range(c2, "sha256_crypt", 2000, 2000)
# test absolute
- c2 = cc.replace(all__vary_rounds=1)
+ c2 = cc.copy(all__vary_rounds=1)
self.assert_rounds_range(c2, "sha256_crypt", 1999, 2001)
- c2 = cc.replace(all__vary_rounds=100)
+ c2 = cc.copy(all__vary_rounds=100)
self.assert_rounds_range(c2, "sha256_crypt", 1995, 2005)
# test relative
- c2 = cc.replace(all__vary_rounds="0.1%")
+ c2 = cc.copy(all__vary_rounds="0.1%")
self.assert_rounds_range(c2, "sha256_crypt", 1998, 2002)
- c2 = cc.replace(all__vary_rounds="100%")
+ c2 = cc.copy(all__vary_rounds="100%")
self.assert_rounds_range(c2, "sha256_crypt", 1995, 2005)
- def test_10_03_genconfig_log2_vary_rounds(self):
- "test genconfig() log2 vary rounds"
- cc = CryptContext(policy=None,
- schemes=["bcrypt"],
+ def test_52_log2_vary_rounds(self):
+ "test log2 vary rounds"
+ cc = CryptContext(schemes=["bcrypt"],
all__min_rounds=15,
all__max_rounds=25,
all__default_rounds=20,
)
# test negative
- self.assertRaises(ValueError, cc.replace, all__vary_rounds=-1)
- self.assertRaises(ValueError, cc.replace, all__vary_rounds="-1%")
- self.assertRaises(ValueError, cc.replace, all__vary_rounds="101%")
+ self.assertRaises(ValueError, cc.copy, all__vary_rounds=-1)
+ self.assertRaises(ValueError, cc.copy, all__vary_rounds="-1%")
+ self.assertRaises(ValueError, cc.copy, all__vary_rounds="101%")
# test static
- c2 = cc.replace(all__vary_rounds=0)
+ c2 = cc.copy(all__vary_rounds=0)
self.assert_rounds_range(c2, "bcrypt", 20, 20)
- c2 = cc.replace(all__vary_rounds="0%")
+ c2 = cc.copy(all__vary_rounds="0%")
self.assert_rounds_range(c2, "bcrypt", 20, 20)
# test absolute
- c2 = cc.replace(all__vary_rounds=1)
+ c2 = cc.copy(all__vary_rounds=1)
self.assert_rounds_range(c2, "bcrypt", 19, 21)
- c2 = cc.replace(all__vary_rounds=100)
+ c2 = cc.copy(all__vary_rounds=100)
self.assert_rounds_range(c2, "bcrypt", 15, 25)
# test relative - should shift over at 50% mark
- c2 = cc.replace(all__vary_rounds="1%")
+ c2 = cc.copy(all__vary_rounds="1%")
self.assert_rounds_range(c2, "bcrypt", 20, 20)
- c2 = cc.replace(all__vary_rounds="49%")
+ c2 = cc.copy(all__vary_rounds="49%")
self.assert_rounds_range(c2, "bcrypt", 20, 20)
- c2 = cc.replace(all__vary_rounds="50%")
+ c2 = cc.copy(all__vary_rounds="50%")
self.assert_rounds_range(c2, "bcrypt", 19, 20)
- c2 = cc.replace(all__vary_rounds="100%")
+ c2 = cc.copy(all__vary_rounds="100%")
self.assert_rounds_range(c2, "bcrypt", 15, 21)
def assert_rounds_range(self, context, scheme, lower, upper):
"helper to check vary_rounds covers specified range"
# NOTE: this runs enough times the min and max *should* be hit,
# though there's a faint chance it will randomly fail.
- handler = context.policy.get_handler(scheme)
+ handler = context.handler(scheme)
salt = handler.default_salt_chars[0:1] * handler.max_salt_size
seen = set()
for i in irange(300):
h = context.genconfig(scheme, salt=salt)
r = handler.from_string(h).rounds
seen.add(r)
- self.assertEqual(min(seen), lower, "vary_rounds lower bound:")
- self.assertEqual(max(seen), upper, "vary_rounds upper bound:")
-
- def test_11_encrypt_settings(self):
- "test encrypt() honors policy settings"
- cc = CryptContext(**self.sample_policy_1)
-
- # hash specific settings
- self.assertEqual(
- cc.encrypt("password", scheme="phpass", salt='.'*8),
- '$H$5........De04R5Egz0aq8Tf.1eVhY/',
- )
- self.assertEqual(
- cc.encrypt("password", scheme="phpass", salt='.'*8, ident="P"),
- '$P$5........De04R5Egz0aq8Tf.1eVhY/',
- )
-
- # NOTE: more thorough job of rounds limits done in genconfig() test,
- # which is much cheaper, and shares the same codebase.
-
- # min rounds
- with catch_warnings(record=True) as wlog:
- self.assertEqual(
- cc.encrypt("password", rounds=1999, salt="nacl"),
- '$5$rounds=2000$nacl$9/lTZ5nrfPuz8vphznnmHuDGFuvjSNvOEDsGmGfsS97',
- )
- self.consumeWarningList(wlog, PasslibConfigWarning)
-
- self.assertEqual(
- cc.encrypt("password", rounds=2001, salt="nacl"),
- '$5$rounds=2001$nacl$8PdeoPL4aXQnJ0woHhqgIw/efyfCKC2WHneOpnvF.31'
- )
- self.consumeWarningList(wlog)
-
- # max rounds, etc tested in genconfig()
-
- # make default > max throws error if attempted
- self.assertRaises(ValueError, cc.replace,
- sha256_crypt__default_rounds=4000)
-
- def test_12_hash_needs_update(self):
- "test hash_needs_update() method"
- cc = CryptContext(**self.sample_policy_1)
-
- #check deprecated scheme
- self.assertTrue(cc.hash_needs_update('9XXD4trGYeGJA'))
- self.assertFalse(cc.hash_needs_update('$1$J8HC2RCr$HcmM.7NxB2weSvlw2FgzU0'))
-
- #check min rounds
- self.assertTrue(cc.hash_needs_update('$5$rounds=1999$jD81UCoo.zI.UETs$Y7qSTQ6mTiU9qZB4fRr43wRgQq4V.5AAf7F97Pzxey/'))
- self.assertFalse(cc.hash_needs_update('$5$rounds=2000$228SSRje04cnNCaQ$YGV4RYu.5sNiBvorQDlO0WWQjyJVGKBcJXz3OtyQ2u8'))
-
- #check max rounds
- self.assertFalse(cc.hash_needs_update('$5$rounds=3000$fS9iazEwTKi7QPW4$VasgBC8FqlOvD7x2HhABaMXCTh9jwHclPA9j5YQdns.'))
- self.assertTrue(cc.hash_needs_update('$5$rounds=3001$QlFHHifXvpFX4PLs$/0ekt7lSs/lOikSerQ0M/1porEHxYq7W/2hdFpxA3fA'))
+ self.assertEqual(min(seen), lower, "vary_rounds had wrong lower limit:")
+ self.assertEqual(max(seen), upper, "vary_rounds had wrong upper limit:")
#=========================================================
- #identify
+ # feature tests
#=========================================================
- def test_20_basic(self):
- "test basic encrypt/identify/verify functionality"
- handlers = [hash.md5_crypt, hash.des_crypt, hash.bsdi_crypt]
- cc = CryptContext(handlers, policy=None)
-
- #run through handlers
- for crypt in handlers:
- h = cc.encrypt("test", scheme=crypt.name)
- self.assertEqual(cc.identify(h), crypt.name)
- self.assertEqual(cc.identify(h, resolve=True), crypt)
- self.assertTrue(cc.verify('test', h))
- self.assertTrue(not cc.verify('notest', h))
-
- #test default
- h = cc.encrypt("test")
- self.assertEqual(cc.identify(h), "md5_crypt")
-
- #test genhash
- h = cc.genhash('secret', cc.genconfig())
- self.assertEqual(cc.identify(h), 'md5_crypt')
-
- h = cc.genhash('secret', cc.genconfig(), scheme='md5_crypt')
- self.assertEqual(cc.identify(h), 'md5_crypt')
-
- self.assertRaises(ValueError, cc.genhash, 'secret', cc.genconfig(), scheme="des_crypt")
-
- def test_21_identify(self):
- "test identify() border cases"
- handlers = ["md5_crypt", "des_crypt", "bsdi_crypt"]
- cc = CryptContext(handlers, policy=None)
-
- #check unknown hash
- self.assertEqual(cc.identify('$9$232323123$1287319827'), None)
- self.assertRaises(ValueError, cc.identify, '$9$232323123$1287319827', required=True)
-
- def test_22_verify(self):
- "test verify() scheme kwd"
- handlers = ["md5_crypt", "des_crypt", "bsdi_crypt"]
- cc = CryptContext(handlers, policy=None)
-
- h = hash.md5_crypt.encrypt("test")
-
- #check base verify
- self.assertTrue(cc.verify("test", h))
- self.assertTrue(not cc.verify("notest", h))
-
- #check verify using right alg
- self.assertTrue(cc.verify('test', h, scheme='md5_crypt'))
- self.assertTrue(not cc.verify('notest', h, scheme='md5_crypt'))
-
- #check verify using wrong alg
- self.assertRaises(ValueError, cc.verify, 'test', h, scheme='bsdi_crypt')
-
- def test_24_min_verify_time(self):
+ def test_60_min_verify_time(self):
"test verify() honors min_verify_time"
#NOTE: this whole test assumes time.sleep() and tick()
# have better than 100ms accuracy - set via delta.
@@ -967,111 +1261,10 @@ class CryptContextTest(TestCase):
self.assertAlmostEqual(elapsed, max_delay, delta=delta)
self.consumeWarningList(wlog, ".*verify exceeded min_verify_time")
- def test_25_verify_and_update(self):
- "test verify_and_update()"
- cc = CryptContext(**self.sample_policy_1)
-
- #create some hashes
- h1 = cc.encrypt("password", scheme="des_crypt")
- h2 = cc.encrypt("password", scheme="sha256_crypt")
-
- #check bad password, deprecated hash
- ok, new_hash = cc.verify_and_update("wrongpass", h1)
- self.assertFalse(ok)
- self.assertIs(new_hash, None)
-
- #check bad password, good hash
- ok, new_hash = cc.verify_and_update("wrongpass", h2)
- self.assertFalse(ok)
- self.assertIs(new_hash, None)
-
- #check right password, deprecated hash
- ok, new_hash = cc.verify_and_update("password", h1)
- self.assertTrue(ok)
- self.assertTrue(cc.identify(new_hash), "sha256_crypt")
-
- #check right password, good hash
- ok, new_hash = cc.verify_and_update("password", h2)
- self.assertTrue(ok)
- self.assertIs(new_hash, None)
-
- #=========================================================
- # border cases
- #=========================================================
- def test_30_nonstring_hash(self):
- "test non-string hash values cause error"
- #
- # test hash=None or some other non-string causes TypeError
- # and that explicit-scheme code path behaves the same.
- #
- cc = CryptContext(["des_crypt"])
- for hash, kwds in [
- (None, {}),
- (None, {"scheme": "des_crypt"}),
- (1, {}),
- ((), {}),
- ]:
-
- self.assertRaises(TypeError, cc.identify, hash, **kwds)
- self.assertRaises(TypeError, cc.genhash, 'stub', hash, **kwds)
- self.assertRaises(TypeError, cc.verify, 'stub', hash, **kwds)
- self.assertRaises(TypeError, cc.verify_and_update, 'stub', hash, **kwds)
- self.assertRaises(TypeError, cc.hash_needs_update, hash, **kwds)
-
- #
- # but genhash *should* accept None if default scheme lacks config string.
- #
- cc2 = CryptContext(["mysql323"])
- self.assertRaises(TypeError, cc2.identify, None)
- self.assertIsInstance(cc2.genhash("stub", None), str)
- self.assertRaises(TypeError, cc2.verify, 'stub', None)
- self.assertRaises(TypeError, cc2.verify_and_update, 'stub', None)
- self.assertRaises(TypeError, cc2.hash_needs_update, None)
-
-
- def test_31_nonstring_secret(self):
- "test non-string password values cause error"
- cc = CryptContext(["des_crypt"])
- hash = cc.encrypt("stub")
- #
- # test secret=None, or some other non-string causes TypeError
- #
- for secret, kwds in [
- (None, {}),
- (None, {"scheme": "des_crypt"}),
- (1, {}),
- ((), {}),
- ]:
- self.assertRaises(TypeError, cc.encrypt, secret, **kwds)
- self.assertRaises(TypeError, cc.genhash, secret, hash, **kwds)
- self.assertRaises(TypeError, cc.verify, secret, hash, **kwds)
- self.assertRaises(TypeError, cc.verify_and_update, secret, hash, **kwds)
-
- #=========================================================
- # other
- #=========================================================
- def test_90_bcrypt_normhash(self):
- "teset verify_and_update / hash_needs_update corrects bcrypt padding"
- # see issue 25.
- bcrypt = hash.bcrypt
-
- PASS1 = "loppux"
- BAD1 = "$2a$12$oaQbBqq8JnSM1NHRPQGXORm4GCUMqp7meTnkft4zgSnrbhoKdDV0C"
- GOOD1 = "$2a$12$oaQbBqq8JnSM1NHRPQGXOOm4GCUMqp7meTnkft4zgSnrbhoKdDV0C"
- ctx = CryptContext(["bcrypt"])
-
- with catch_warnings(record=True) as wlog:
- self.assertTrue(ctx.hash_needs_update(BAD1))
- self.assertFalse(ctx.hash_needs_update(GOOD1))
-
- if bcrypt.has_backend():
- self.assertEqual(ctx.verify_and_update(PASS1,GOOD1), (True,None))
- self.assertEqual(ctx.verify_and_update("x",BAD1), (False,None))
- res = ctx.verify_and_update(PASS1, BAD1)
- self.assertTrue(res[0] and res[1] and res[1] != BAD1)
-
- def test_91_passprep(self):
+ def test_61_passprep(self):
"test passprep option"
+ self.require_stringprep()
+
# saslprep should normalize pu -> pn
pu = u("a\u0300") # unnormalized unicode
pn = u("\u00E0") # normalized unicode
@@ -1126,8 +1319,45 @@ class CryptContextTest(TestCase):
self.assertFalse(ctx.verify(pu, ctx.encrypt(pn, scheme="md5_crypt")))
self.assertTrue(ctx.verify(pu, ctx.encrypt(pn, scheme="sha256_crypt")))
+ def test_62_bcrypt_update(self):
+ "test verify_and_update / hash_needs_update corrects bcrypt padding"
+ # see issue 25.
+ bcrypt = hash.bcrypt
+
+ PASS1 = "loppux"
+ BAD1 = "$2a$12$oaQbBqq8JnSM1NHRPQGXORm4GCUMqp7meTnkft4zgSnrbhoKdDV0C"
+ GOOD1 = "$2a$12$oaQbBqq8JnSM1NHRPQGXOOm4GCUMqp7meTnkft4zgSnrbhoKdDV0C"
+ ctx = CryptContext(["bcrypt"])
+
+ with catch_warnings(record=True) as wlog:
+ self.assertTrue(ctx.hash_needs_update(BAD1))
+ self.assertFalse(ctx.hash_needs_update(GOOD1))
+
+ if bcrypt.has_backend():
+ self.assertEqual(ctx.verify_and_update(PASS1,GOOD1), (True,None))
+ self.assertEqual(ctx.verify_and_update("x",BAD1), (False,None))
+ ok, new_hash = ctx.verify_and_update(PASS1, BAD1)
+ self.assertTrue(ok)
+ self.assertTrue(new_hash and new_hash != BAD1)
+
+ def test_63_bsdi_crypt_update(self):
+ "test verify_and_update / hash_needs_update correct bsdi even rounds"
+ even_hash = '_Y/../cG0zkJa6LY6k4c'
+ odd_hash = '_Z/..TgFg0/ptQtpAgws'
+ secret = 'test'
+ ctx = CryptContext(['bsdi_crypt'])
+
+ self.assertTrue(ctx.hash_needs_update(even_hash))
+ self.assertFalse(ctx.hash_needs_update(odd_hash))
+
+ self.assertEqual(ctx.verify_and_update(secret, odd_hash), (True,None))
+ self.assertEqual(ctx.verify_and_update("x", even_hash), (False,None))
+ ok, new_hash = ctx.verify_and_update(secret, even_hash)
+ self.assertTrue(ok)
+ self.assertTrue(new_hash and new_hash != even_hash)
+
#=========================================================
- #eoc
+ # eoc
#=========================================================
#=========================================================
@@ -1153,44 +1383,25 @@ class LazyCryptContextTest(TestCase):
self.assertFalse(has_crypt_handler("dummy_2", True))
- self.assertTrue(cc.policy.handler_is_deprecated("des_crypt"))
- self.assertEqual(cc.policy.schemes(), ["dummy_2", "des_crypt"])
+ self.assertEqual(cc.schemes(), ("dummy_2", "des_crypt"))
+ self.assertTrue(cc._is_deprecated_scheme("des_crypt"))
self.assertTrue(has_crypt_handler("dummy_2", True))
def test_callable_constructor(self):
- "test create_policy() hook, returning CryptPolicy"
- self.assertFalse(has_crypt_handler("dummy_2"))
- register_crypt_handler_path("dummy_2", "passlib.tests.test_context")
-
- def create_policy(flag=False):
- self.assertTrue(flag)
- return CryptPolicy(schemes=iter(["dummy_2", "des_crypt"]), deprecated=["des_crypt"])
-
- cc = LazyCryptContext(create_policy=create_policy, flag=True)
-
- self.assertFalse(has_crypt_handler("dummy_2", True))
-
- self.assertTrue(cc.policy.handler_is_deprecated("des_crypt"))
- self.assertEqual(cc.policy.schemes(), ["dummy_2", "des_crypt"])
-
- self.assertTrue(has_crypt_handler("dummy_2", True))
-
- def test_callable_constructor2(self):
- "test create_policy() hook, returning dict"
self.assertFalse(has_crypt_handler("dummy_2"))
register_crypt_handler_path("dummy_2", "passlib.tests.test_context")
- def create_policy(flag=False):
+ def onload(flag=False):
self.assertTrue(flag)
return dict(schemes=iter(["dummy_2", "des_crypt"]), deprecated=["des_crypt"])
- cc = LazyCryptContext(create_policy=create_policy, flag=True)
+ cc = LazyCryptContext(onload=onload, flag=True)
self.assertFalse(has_crypt_handler("dummy_2", True))
- self.assertTrue(cc.policy.handler_is_deprecated("des_crypt"))
- self.assertEqual(cc.policy.schemes(), ["dummy_2", "des_crypt"])
+ self.assertEqual(cc.schemes(), ("dummy_2", "des_crypt"))
+ self.assertTrue(cc._is_deprecated_scheme("des_crypt"))
self.assertTrue(has_crypt_handler("dummy_2", True))
diff --git a/passlib/tests/test_context_deprecated.py b/passlib/tests/test_context_deprecated.py
new file mode 100644
index 0000000..f6d33d8
--- /dev/null
+++ b/passlib/tests/test_context_deprecated.py
@@ -0,0 +1,1165 @@
+"""tests for passlib.context
+
+this file is a clone of the 1.5 test_context.py,
+containing the tests using the legacy CryptPolicy api.
+it's being preserved here to ensure the old api doesn't break
+(until Passlib 1.8, when this and the legacy api will be removed).
+"""
+#=========================================================
+#imports
+#=========================================================
+from __future__ import with_statement
+#core
+import hashlib
+from logging import getLogger
+import os
+import time
+import warnings
+import sys
+#site
+try:
+ from pkg_resources import resource_filename
+except ImportError:
+ resource_filename = None
+#pkg
+from passlib import hash
+from passlib.context import CryptContext, CryptPolicy, LazyCryptContext
+from passlib.exc import PasslibConfigWarning
+from passlib.utils import tick, to_bytes, to_unicode
+from passlib.utils.compat import irange, u
+import passlib.utils.handlers as uh
+from passlib.tests.utils import TestCase, mktemp, catch_warnings, \
+ gae_env, set_file
+from passlib.registry import register_crypt_handler_path, has_crypt_handler, \
+ _unload_handler_name as unload_handler_name
+#module
+log = getLogger(__name__)
+
+#=========================================================
+#
+#=========================================================
+class CryptPolicyTest(TestCase):
+ "test CryptPolicy object"
+
+ #TODO: need to test user categories w/in all this
+
+ descriptionPrefix = "CryptPolicy"
+
+ #=========================================================
+ #sample crypt policies used for testing
+ #=========================================================
+
+ #-----------------------------------------------------
+ #sample 1 - average config file
+ #-----------------------------------------------------
+ #NOTE: copy of this is stored in file passlib/tests/sample_config_1s.cfg
+ sample_config_1s = """\
+[passlib]
+schemes = des_crypt, md5_crypt, bsdi_crypt, sha512_crypt
+default = md5_crypt
+all.vary_rounds = 10%%
+bsdi_crypt.max_rounds = 30000
+bsdi_crypt.default_rounds = 25000
+sha512_crypt.max_rounds = 50000
+sha512_crypt.min_rounds = 40000
+"""
+ sample_config_1s_path = os.path.abspath(os.path.join(
+ os.path.dirname(__file__), "sample_config_1s.cfg"))
+ if not os.path.exists(sample_config_1s_path) and resource_filename:
+ #in case we're zipped up in an egg.
+ sample_config_1s_path = resource_filename("passlib.tests",
+ "sample_config_1s.cfg")
+
+ #make sure sample_config_1s uses \n linesep - tests rely on this
+ assert sample_config_1s.startswith("[passlib]\nschemes")
+
+ sample_config_1pd = dict(
+ schemes = [ "des_crypt", "md5_crypt", "bsdi_crypt", "sha512_crypt"],
+ default = "md5_crypt",
+ # NOTE: not maintaining backwards compat for rendering to "10%"
+ all__vary_rounds = 0.1,
+ bsdi_crypt__max_rounds = 30000,
+ bsdi_crypt__default_rounds = 25000,
+ sha512_crypt__max_rounds = 50000,
+ sha512_crypt__min_rounds = 40000,
+ )
+
+ sample_config_1pid = {
+ "schemes": "des_crypt, md5_crypt, bsdi_crypt, sha512_crypt",
+ "default": "md5_crypt",
+ # NOTE: not maintaining backwards compat for rendering to "10%"
+ "all.vary_rounds": 0.1,
+ "bsdi_crypt.max_rounds": 30000,
+ "bsdi_crypt.default_rounds": 25000,
+ "sha512_crypt.max_rounds": 50000,
+ "sha512_crypt.min_rounds": 40000,
+ }
+
+ sample_config_1prd = dict(
+ schemes = [ hash.des_crypt, hash.md5_crypt, hash.bsdi_crypt, hash.sha512_crypt],
+ default = "md5_crypt", # NOTE: passlib <= 1.5 was handler obj.
+ # NOTE: not maintaining backwards compat for rendering to "10%"
+ all__vary_rounds = 0.1,
+ bsdi_crypt__max_rounds = 30000,
+ bsdi_crypt__default_rounds = 25000,
+ sha512_crypt__max_rounds = 50000,
+ sha512_crypt__min_rounds = 40000,
+ )
+
+ #-----------------------------------------------------
+ #sample 2 - partial policy & result of overlay on sample 1
+ #-----------------------------------------------------
+ sample_config_2s = """\
+[passlib]
+bsdi_crypt.min_rounds = 29000
+bsdi_crypt.max_rounds = 35000
+bsdi_crypt.default_rounds = 31000
+sha512_crypt.min_rounds = 45000
+"""
+
+ sample_config_2pd = dict(
+ #using this to test full replacement of existing options
+ bsdi_crypt__min_rounds = 29000,
+ bsdi_crypt__max_rounds = 35000,
+ bsdi_crypt__default_rounds = 31000,
+ #using this to test partial replacement of existing options
+ sha512_crypt__min_rounds=45000,
+ )
+
+ sample_config_12pd = dict(
+ schemes = [ "des_crypt", "md5_crypt", "bsdi_crypt", "sha512_crypt"],
+ default = "md5_crypt",
+ # NOTE: not maintaining backwards compat for rendering to "10%"
+ all__vary_rounds = 0.1,
+ bsdi_crypt__min_rounds = 29000,
+ bsdi_crypt__max_rounds = 35000,
+ bsdi_crypt__default_rounds = 31000,
+ sha512_crypt__max_rounds = 50000,
+ sha512_crypt__min_rounds=45000,
+ )
+
+ #-----------------------------------------------------
+ #sample 3 - just changing default
+ #-----------------------------------------------------
+ sample_config_3pd = dict(
+ default="sha512_crypt",
+ )
+
+ sample_config_123pd = dict(
+ schemes = [ "des_crypt", "md5_crypt", "bsdi_crypt", "sha512_crypt"],
+ default = "sha512_crypt",
+ # NOTE: not maintaining backwards compat for rendering to "10%"
+ all__vary_rounds = 0.1,
+ bsdi_crypt__min_rounds = 29000,
+ bsdi_crypt__max_rounds = 35000,
+ bsdi_crypt__default_rounds = 31000,
+ sha512_crypt__max_rounds = 50000,
+ sha512_crypt__min_rounds=45000,
+ )
+
+ #-----------------------------------------------------
+ #sample 4 - category specific
+ #-----------------------------------------------------
+ sample_config_4s = """
+[passlib]
+schemes = sha512_crypt
+all.vary_rounds = 10%%
+default.sha512_crypt.max_rounds = 20000
+admin.all.vary_rounds = 5%%
+admin.sha512_crypt.max_rounds = 40000
+"""
+
+ sample_config_4pd = dict(
+ schemes = [ "sha512_crypt" ],
+ # NOTE: not maintaining backwards compat for rendering to "10%"
+ all__vary_rounds = 0.1,
+ sha512_crypt__max_rounds = 20000,
+ # NOTE: not maintaining backwards compat for rendering to "5%"
+ admin__all__vary_rounds = 0.05,
+ admin__sha512_crypt__max_rounds = 40000,
+ )
+
+ #-----------------------------------------------------
+ #sample 5 - to_string & deprecation testing
+ #-----------------------------------------------------
+ sample_config_5s = sample_config_1s + """\
+deprecated = des_crypt
+admin__context__deprecated = des_crypt, bsdi_crypt
+"""
+
+ sample_config_5pd = sample_config_1pd.copy()
+ sample_config_5pd.update(
+ deprecated = [ "des_crypt" ],
+ admin__context__deprecated = [ "des_crypt", "bsdi_crypt" ],
+ )
+
+ sample_config_5pid = sample_config_1pid.copy()
+ sample_config_5pid.update({
+ "deprecated": "des_crypt",
+ "admin.context.deprecated": "des_crypt, bsdi_crypt",
+ })
+
+ sample_config_5prd = sample_config_1prd.copy()
+ sample_config_5prd.update({
+ # XXX: should deprecated return the actual handlers in this case?
+ # would have to modify how policy stores info, for one.
+ "deprecated": ["des_crypt"],
+ "admin__context__deprecated": ["des_crypt", "bsdi_crypt"],
+ })
+
+ #=========================================================
+ #constructors
+ #=========================================================
+ def setUp(self):
+ TestCase.setUp(self)
+ warnings.filterwarnings("ignore",
+ r"The CryptPolicy class has been deprecated")
+
+ def test_00_constructor(self):
+ "test CryptPolicy() constructor"
+ policy = CryptPolicy(**self.sample_config_1pd)
+ self.assertEqual(policy.to_dict(), self.sample_config_1pd)
+
+ #check key with too many separators is rejected
+ self.assertRaises(TypeError, CryptPolicy,
+ schemes = [ "des_crypt", "md5_crypt", "bsdi_crypt", "sha512_crypt"],
+ bad__key__bsdi_crypt__max_rounds = 30000,
+ )
+
+ #check nameless handler rejected
+ class nameless(uh.StaticHandler):
+ name = None
+ self.assertRaises(ValueError, CryptPolicy, schemes=[nameless])
+
+ # check scheme must be name or crypt handler
+ self.assertRaises(TypeError, CryptPolicy, schemes=[uh.StaticHandler])
+
+ #check name conflicts are rejected
+ class dummy_1(uh.StaticHandler):
+ name = 'dummy_1'
+ self.assertRaises(KeyError, CryptPolicy, schemes=[dummy_1, dummy_1])
+
+ #with unknown deprecated value
+ self.assertRaises(KeyError, CryptPolicy,
+ schemes=['des_crypt'],
+ deprecated=['md5_crypt'])
+
+ #with unknown default value
+ self.assertRaises(KeyError, CryptPolicy,
+ schemes=['des_crypt'],
+ default='md5_crypt')
+
+ def test_01_from_path_simple(self):
+ "test CryptPolicy.from_path() constructor"
+ #NOTE: this is separate so it can also run under GAE
+
+ #test preset stored in existing file
+ path = self.sample_config_1s_path
+ policy = CryptPolicy.from_path(path)
+ self.assertEqual(policy.to_dict(), self.sample_config_1pd)
+
+ #test if path missing
+ self.assertRaises(EnvironmentError, CryptPolicy.from_path, path + 'xxx')
+
+ def test_01_from_path(self):
+ "test CryptPolicy.from_path() constructor with encodings"
+ if gae_env:
+ return self.skipTest("GAE doesn't offer read/write filesystem access")
+
+ path = mktemp()
+
+ #test "\n" linesep
+ set_file(path, self.sample_config_1s)
+ policy = CryptPolicy.from_path(path)
+ self.assertEqual(policy.to_dict(), self.sample_config_1pd)
+
+ #test "\r\n" linesep
+ set_file(path, self.sample_config_1s.replace("\n","\r\n"))
+ policy = CryptPolicy.from_path(path)
+ self.assertEqual(policy.to_dict(), self.sample_config_1pd)
+
+ #test with custom encoding
+ uc2 = to_bytes(self.sample_config_1s, "utf-16", source_encoding="utf-8")
+ set_file(path, uc2)
+ policy = CryptPolicy.from_path(path, encoding="utf-16")
+ self.assertEqual(policy.to_dict(), self.sample_config_1pd)
+
+ def test_02_from_string(self):
+ "test CryptPolicy.from_string() constructor"
+ #test "\n" linesep
+ policy = CryptPolicy.from_string(self.sample_config_1s)
+ self.assertEqual(policy.to_dict(), self.sample_config_1pd)
+
+ #test "\r\n" linesep
+ policy = CryptPolicy.from_string(
+ self.sample_config_1s.replace("\n","\r\n"))
+ self.assertEqual(policy.to_dict(), self.sample_config_1pd)
+
+ #test with unicode
+ data = to_unicode(self.sample_config_1s)
+ policy = CryptPolicy.from_string(data)
+ self.assertEqual(policy.to_dict(), self.sample_config_1pd)
+
+ #test with non-ascii-compatible encoding
+ uc2 = to_bytes(self.sample_config_1s, "utf-16", source_encoding="utf-8")
+ policy = CryptPolicy.from_string(uc2, encoding="utf-16")
+ self.assertEqual(policy.to_dict(), self.sample_config_1pd)
+
+ #test category specific options
+ policy = CryptPolicy.from_string(self.sample_config_4s)
+ self.assertEqual(policy.to_dict(), self.sample_config_4pd)
+
+ def test_03_from_source(self):
+ "test CryptPolicy.from_source() constructor"
+ #pass it a path
+ policy = CryptPolicy.from_source(self.sample_config_1s_path)
+ self.assertEqual(policy.to_dict(), self.sample_config_1pd)
+
+ #pass it a string
+ policy = CryptPolicy.from_source(self.sample_config_1s)
+ self.assertEqual(policy.to_dict(), self.sample_config_1pd)
+
+ #pass it a dict (NOTE: make a copy to detect in-place modifications)
+ policy = CryptPolicy.from_source(self.sample_config_1pd.copy())
+ self.assertEqual(policy.to_dict(), self.sample_config_1pd)
+
+ #pass it existing policy
+ p2 = CryptPolicy.from_source(policy)
+ self.assertIs(policy, p2)
+
+ #pass it something wrong
+ self.assertRaises(TypeError, CryptPolicy.from_source, 1)
+ self.assertRaises(TypeError, CryptPolicy.from_source, [])
+
+ def test_04_from_sources(self):
+ "test CryptPolicy.from_sources() constructor"
+
+ #pass it empty list
+ self.assertRaises(ValueError, CryptPolicy.from_sources, [])
+
+ #pass it one-element list
+ policy = CryptPolicy.from_sources([self.sample_config_1s])
+ self.assertEqual(policy.to_dict(), self.sample_config_1pd)
+
+ #pass multiple sources
+ policy = CryptPolicy.from_sources(
+ [
+ self.sample_config_1s_path,
+ self.sample_config_2s,
+ self.sample_config_3pd,
+ ])
+ self.assertEqual(policy.to_dict(), self.sample_config_123pd)
+
+ def test_05_replace(self):
+ "test CryptPolicy.replace() constructor"
+
+ p1 = CryptPolicy(**self.sample_config_1pd)
+
+ #check overlaying sample 2
+ p2 = p1.replace(**self.sample_config_2pd)
+ self.assertEqual(p2.to_dict(), self.sample_config_12pd)
+
+ #check repeating overlay makes no change
+ p2b = p2.replace(**self.sample_config_2pd)
+ self.assertEqual(p2b.to_dict(), self.sample_config_12pd)
+
+ #check overlaying sample 3
+ p3 = p2.replace(self.sample_config_3pd)
+ self.assertEqual(p3.to_dict(), self.sample_config_123pd)
+
+ def test_06_forbidden(self):
+ "test CryptPolicy() forbidden kwds"
+
+ #salt not allowed to be set
+ self.assertRaises(KeyError, CryptPolicy,
+ schemes=["des_crypt"],
+ des_crypt__salt="xx",
+ )
+ self.assertRaises(KeyError, CryptPolicy,
+ schemes=["des_crypt"],
+ all__salt="xx",
+ )
+
+ #schemes not allowed for category
+ self.assertRaises(KeyError, CryptPolicy,
+ schemes=["des_crypt"],
+ user__context__schemes=["md5_crypt"],
+ )
+
+ #=========================================================
+ #reading
+ #=========================================================
+ def test_10_has_schemes(self):
+ "test has_schemes() method"
+
+ p1 = CryptPolicy(**self.sample_config_1pd)
+ self.assertTrue(p1.has_schemes())
+
+ p3 = CryptPolicy(**self.sample_config_3pd)
+ self.assertTrue(not p3.has_schemes())
+
+ def test_11_iter_handlers(self):
+ "test iter_handlers() method"
+
+ p1 = CryptPolicy(**self.sample_config_1pd)
+ s = self.sample_config_1prd['schemes']
+ self.assertEqual(list(p1.iter_handlers()), s)
+
+ p3 = CryptPolicy(**self.sample_config_3pd)
+ self.assertEqual(list(p3.iter_handlers()), [])
+
+ def test_12_get_handler(self):
+ "test get_handler() method"
+
+ p1 = CryptPolicy(**self.sample_config_1pd)
+
+ #check by name
+ self.assertIs(p1.get_handler("bsdi_crypt"), hash.bsdi_crypt)
+
+ #check by missing name
+ self.assertIs(p1.get_handler("sha256_crypt"), None)
+ self.assertRaises(KeyError, p1.get_handler, "sha256_crypt", required=True)
+
+ #check default
+ self.assertIs(p1.get_handler(), hash.md5_crypt)
+
+ def test_13_get_options(self):
+ "test get_options() method"
+
+ p12 = CryptPolicy(**self.sample_config_12pd)
+
+ self.assertEqual(p12.get_options("bsdi_crypt"),dict(
+ # NOTE: not maintaining backwards compat for rendering to "10%"
+ vary_rounds = 0.1,
+ min_rounds = 29000,
+ max_rounds = 35000,
+ default_rounds = 31000,
+ ))
+
+ self.assertEqual(p12.get_options("sha512_crypt"),dict(
+ # NOTE: not maintaining backwards compat for rendering to "10%"
+ vary_rounds = 0.1,
+ min_rounds = 45000,
+ max_rounds = 50000,
+ ))
+
+ p4 = CryptPolicy.from_string(self.sample_config_4s)
+ self.assertEqual(p4.get_options("sha512_crypt"), dict(
+ # NOTE: not maintaining backwards compat for rendering to "10%"
+ vary_rounds=0.1,
+ max_rounds=20000,
+ ))
+
+ self.assertEqual(p4.get_options("sha512_crypt", "user"), dict(
+ # NOTE: not maintaining backwards compat for rendering to "10%"
+ vary_rounds=0.1,
+ max_rounds=20000,
+ ))
+
+ self.assertEqual(p4.get_options("sha512_crypt", "admin"), dict(
+ # NOTE: not maintaining backwards compat for rendering to "5%"
+ vary_rounds=0.05,
+ max_rounds=40000,
+ ))
+
+ def test_14_handler_is_deprecated(self):
+ "test handler_is_deprecated() method"
+ pa = CryptPolicy(**self.sample_config_1pd)
+ pb = CryptPolicy(**self.sample_config_5pd)
+
+ self.assertFalse(pa.handler_is_deprecated("des_crypt"))
+ self.assertFalse(pa.handler_is_deprecated(hash.bsdi_crypt))
+ self.assertFalse(pa.handler_is_deprecated("sha512_crypt"))
+
+ self.assertTrue(pb.handler_is_deprecated("des_crypt"))
+ self.assertFalse(pb.handler_is_deprecated(hash.bsdi_crypt))
+ self.assertFalse(pb.handler_is_deprecated("sha512_crypt"))
+
+ #check categories as well
+ self.assertTrue(pb.handler_is_deprecated("des_crypt", "user"))
+ self.assertFalse(pb.handler_is_deprecated("bsdi_crypt", "user"))
+ self.assertTrue(pb.handler_is_deprecated("des_crypt", "admin"))
+ self.assertTrue(pb.handler_is_deprecated("bsdi_crypt", "admin"))
+
+ # check deprecation is overridden per category
+ pc = CryptPolicy(
+ schemes=["md5_crypt", "des_crypt"],
+ deprecated=["md5_crypt"],
+ user__context__deprecated=["des_crypt"],
+ )
+ self.assertTrue(pc.handler_is_deprecated("md5_crypt"))
+ self.assertFalse(pc.handler_is_deprecated("des_crypt"))
+ self.assertFalse(pc.handler_is_deprecated("md5_crypt", "user"))
+ self.assertTrue(pc.handler_is_deprecated("des_crypt", "user"))
+
+ def test_15_min_verify_time(self):
+ "test get_min_verify_time() method"
+ # silence deprecation warnings for min verify time
+ warnings.filterwarnings("ignore", category=DeprecationWarning)
+
+ pa = CryptPolicy()
+ self.assertEqual(pa.get_min_verify_time(), 0)
+ self.assertEqual(pa.get_min_verify_time('admin'), 0)
+
+ pb = pa.replace(min_verify_time=.1)
+ self.assertEqual(pb.get_min_verify_time(), .1)
+ self.assertEqual(pb.get_min_verify_time('admin'), .1)
+
+ pc = pa.replace(admin__context__min_verify_time=.2)
+ self.assertEqual(pc.get_min_verify_time(), 0)
+ self.assertEqual(pc.get_min_verify_time('admin'), .2)
+
+ pd = pb.replace(admin__context__min_verify_time=.2)
+ self.assertEqual(pd.get_min_verify_time(), .1)
+ self.assertEqual(pd.get_min_verify_time('admin'), .2)
+
+ #=========================================================
+ #serialization
+ #=========================================================
+ def test_20_iter_config(self):
+ "test iter_config() method"
+ p5 = CryptPolicy(**self.sample_config_5pd)
+ self.assertEqual(dict(p5.iter_config()), self.sample_config_5pd)
+ self.assertEqual(dict(p5.iter_config(resolve=True)), self.sample_config_5prd)
+ self.assertEqual(dict(p5.iter_config(ini=True)), self.sample_config_5pid)
+
+ def test_21_to_dict(self):
+ "test to_dict() method"
+ p5 = CryptPolicy(**self.sample_config_5pd)
+ self.assertEqual(p5.to_dict(), self.sample_config_5pd)
+ self.assertEqual(p5.to_dict(resolve=True), self.sample_config_5prd)
+
+ def test_22_to_string(self):
+ "test to_string() method"
+ pa = CryptPolicy(**self.sample_config_5pd)
+ s = pa.to_string() #NOTE: can't compare string directly, ordering etc may not match
+ pb = CryptPolicy.from_string(s)
+ self.assertEqual(pb.to_dict(), self.sample_config_5pd)
+
+ #=========================================================
+ #
+ #=========================================================
+
+#=========================================================
+#CryptContext
+#=========================================================
+class CryptContextTest(TestCase):
+ "test CryptContext class"
+ descriptionPrefix = "CryptContext"
+
+ def setUp(self):
+ TestCase.setUp(self)
+ warnings.filterwarnings("ignore",
+ r"CryptContext\(\)\.replace\(\) has been deprecated.*")
+ warnings.filterwarnings("ignore",
+ r"The CryptContext ``policy`` keyword has been deprecated.*")
+ warnings.filterwarnings("ignore", ".*(CryptPolicy|context\.policy).*(has|have) been deprecated.*")
+
+ #=========================================================
+ #constructor
+ #=========================================================
+ def test_00_constructor(self):
+ "test constructor"
+ #create crypt context using handlers
+ cc = CryptContext([hash.md5_crypt, hash.bsdi_crypt, hash.des_crypt])
+ c,b,a = cc.policy.iter_handlers()
+ self.assertIs(a, hash.des_crypt)
+ self.assertIs(b, hash.bsdi_crypt)
+ self.assertIs(c, hash.md5_crypt)
+
+ #create context using names
+ cc = CryptContext(["md5_crypt", "bsdi_crypt", "des_crypt"])
+ c,b,a = cc.policy.iter_handlers()
+ self.assertIs(a, hash.des_crypt)
+ self.assertIs(b, hash.bsdi_crypt)
+ self.assertIs(c, hash.md5_crypt)
+
+ #TODO: test policy & other options
+
+ def test_01_replace(self):
+ "test replace()"
+
+ cc = CryptContext(["md5_crypt", "bsdi_crypt", "des_crypt"])
+ self.assertIs(cc.policy.get_handler(), hash.md5_crypt)
+
+ cc2 = cc.replace()
+ self.assertIsNot(cc2, cc)
+ # NOTE: was not able to maintain backward compatibility with this...
+ ##self.assertIs(cc2.policy, cc.policy)
+
+ cc3 = cc.replace(default="bsdi_crypt")
+ self.assertIsNot(cc3, cc)
+ # NOTE: was not able to maintain backward compatibility with this...
+ ##self.assertIs(cc3.policy, cc.policy)
+ self.assertIs(cc3.policy.get_handler(), hash.bsdi_crypt)
+
+ def test_02_no_handlers(self):
+ "test no handlers"
+
+ #check constructor...
+ cc = CryptContext()
+ self.assertRaises(KeyError, cc.identify, 'hash', required=True)
+ self.assertRaises(KeyError, cc.encrypt, 'secret')
+ self.assertRaises(KeyError, cc.verify, 'secret', 'hash')
+
+ #check updating policy after the fact...
+ cc = CryptContext(['md5_crypt'])
+ p = CryptPolicy(schemes=[])
+ cc.policy = p
+
+ self.assertRaises(KeyError, cc.identify, 'hash', required=True)
+ self.assertRaises(KeyError, cc.encrypt, 'secret')
+ self.assertRaises(KeyError, cc.verify, 'secret', 'hash')
+
+ #=========================================================
+ #policy adaptation
+ #=========================================================
+ sample_policy_1 = dict(
+ schemes = [ "des_crypt", "md5_crypt", "phpass", "bsdi_crypt",
+ "sha256_crypt"],
+ deprecated = [ "des_crypt", ],
+ default = "sha256_crypt",
+ bsdi_crypt__max_rounds = 30,
+ bsdi_crypt__default_rounds = 25,
+ bsdi_crypt__vary_rounds = 0,
+ sha256_crypt__max_rounds = 3000,
+ sha256_crypt__min_rounds = 2000,
+ sha256_crypt__default_rounds = 3000,
+ phpass__ident = "H",
+ phpass__default_rounds = 7,
+ )
+
+ def test_10_01_genconfig_settings(self):
+ "test genconfig() settings"
+ cc = CryptContext(policy=None,
+ schemes=["md5_crypt", "phpass"],
+ phpass__ident="H",
+ phpass__default_rounds=7,
+ )
+
+ # hash specific settings
+ self.assertTrue(cc.genconfig().startswith("$1$"))
+ self.assertEqual(
+ cc.genconfig(scheme="phpass", salt='.'*8),
+ '$H$5........',
+ )
+ self.assertEqual(
+ cc.genconfig(scheme="phpass", salt='.'*8, rounds=8, ident='P'),
+ '$P$6........',
+ )
+
+ # unsupported hash settings should be rejected
+ self.assertRaises(KeyError, cc.replace, md5_crypt__ident="P")
+
+ def test_10_02_genconfig_rounds_limits(self):
+ "test genconfig() policy rounds limits"
+ cc = CryptContext(policy=None,
+ schemes=["sha256_crypt"],
+ all__min_rounds=2000,
+ all__max_rounds=3000,
+ all__default_rounds=2500,
+ )
+
+ # min rounds
+ with catch_warnings(record=True) as wlog:
+
+ # set below handler min
+ c2 = cc.replace(all__min_rounds=500, all__max_rounds=None,
+ all__default_rounds=500)
+ self.consumeWarningList(wlog, [PasslibConfigWarning]*2)
+ self.assertEqual(c2.genconfig(salt="nacl"), "$5$rounds=1000$nacl$")
+ self.consumeWarningList(wlog)
+
+ # below
+ self.assertEqual(
+ cc.genconfig(rounds=1999, salt="nacl"),
+ '$5$rounds=2000$nacl$',
+ )
+ self.consumeWarningList(wlog, PasslibConfigWarning)
+
+ # equal
+ self.assertEqual(
+ cc.genconfig(rounds=2000, salt="nacl"),
+ '$5$rounds=2000$nacl$',
+ )
+ self.consumeWarningList(wlog)
+
+ # above
+ self.assertEqual(
+ cc.genconfig(rounds=2001, salt="nacl"),
+ '$5$rounds=2001$nacl$'
+ )
+ self.consumeWarningList(wlog)
+
+ # max rounds
+ with catch_warnings(record=True) as wlog:
+ # set above handler max
+ c2 = cc.replace(all__max_rounds=int(1e9)+500, all__min_rounds=None,
+ all__default_rounds=int(1e9)+500)
+ self.consumeWarningList(wlog, [PasslibConfigWarning]*2)
+ self.assertEqual(c2.genconfig(salt="nacl"),
+ "$5$rounds=999999999$nacl$")
+ self.consumeWarningList(wlog)
+
+ # above
+ self.assertEqual(
+ cc.genconfig(rounds=3001, salt="nacl"),
+ '$5$rounds=3000$nacl$'
+ )
+ self.consumeWarningList(wlog, PasslibConfigWarning)
+
+ # equal
+ self.assertEqual(
+ cc.genconfig(rounds=3000, salt="nacl"),
+ '$5$rounds=3000$nacl$'
+ )
+ self.consumeWarningList(wlog)
+
+ # below
+ self.assertEqual(
+ cc.genconfig(rounds=2999, salt="nacl"),
+ '$5$rounds=2999$nacl$',
+ )
+ self.consumeWarningList(wlog)
+
+ # explicit default rounds
+ self.assertEqual(cc.genconfig(salt="nacl"), '$5$rounds=2500$nacl$')
+
+ # fallback default rounds - use handler's default
+ df = hash.sha256_crypt.default_rounds
+ c2 = cc.copy(all__default_rounds=None, all__max_rounds=df<<1)
+ self.assertEqual(c2.genconfig(salt="nacl"),
+ '$5$rounds=%d$nacl$' % df)
+
+ # fallback default rounds - use handler's, but clipped to max rounds
+ c2 = cc.replace(all__default_rounds=None, all__max_rounds=3000)
+ self.assertEqual(c2.genconfig(salt="nacl"), '$5$rounds=3000$nacl$')
+
+ # TODO: test default falls back to mx / mn if handler has no default.
+
+ #default rounds - out of bounds
+ self.assertRaises(ValueError, cc.replace, all__default_rounds=1999)
+ cc.policy.replace(all__default_rounds=2000)
+ cc.policy.replace(all__default_rounds=3000)
+ self.assertRaises(ValueError, cc.replace, all__default_rounds=3001)
+
+ # invalid min/max bounds
+ c2 = CryptContext(policy=None, schemes=["sha256_crypt"])
+ self.assertRaises(ValueError, c2.replace, all__min_rounds=-1)
+ self.assertRaises(ValueError, c2.replace, all__max_rounds=-1)
+ self.assertRaises(ValueError, c2.replace, all__min_rounds=2000,
+ all__max_rounds=1999)
+
+ def test_10_03_genconfig_linear_vary_rounds(self):
+ "test genconfig() linear vary rounds"
+ cc = CryptContext(policy=None,
+ schemes=["sha256_crypt"],
+ all__min_rounds=1995,
+ all__max_rounds=2005,
+ all__default_rounds=2000,
+ )
+
+ # test negative
+ self.assertRaises(ValueError, cc.replace, all__vary_rounds=-1)
+ self.assertRaises(ValueError, cc.replace, all__vary_rounds="-1%")
+ self.assertRaises(ValueError, cc.replace, all__vary_rounds="101%")
+
+ # test static
+ c2 = cc.replace(all__vary_rounds=0)
+ self.assert_rounds_range(c2, "sha256_crypt", 2000, 2000)
+
+ c2 = cc.replace(all__vary_rounds="0%")
+ self.assert_rounds_range(c2, "sha256_crypt", 2000, 2000)
+
+ # test absolute
+ c2 = cc.replace(all__vary_rounds=1)
+ self.assert_rounds_range(c2, "sha256_crypt", 1999, 2001)
+ c2 = cc.replace(all__vary_rounds=100)
+ self.assert_rounds_range(c2, "sha256_crypt", 1995, 2005)
+
+ # test relative
+ c2 = cc.replace(all__vary_rounds="0.1%")
+ self.assert_rounds_range(c2, "sha256_crypt", 1998, 2002)
+ c2 = cc.replace(all__vary_rounds="100%")
+ self.assert_rounds_range(c2, "sha256_crypt", 1995, 2005)
+
+ def test_10_03_genconfig_log2_vary_rounds(self):
+ "test genconfig() log2 vary rounds"
+ cc = CryptContext(policy=None,
+ schemes=["bcrypt"],
+ all__min_rounds=15,
+ all__max_rounds=25,
+ all__default_rounds=20,
+ )
+
+ # test negative
+ self.assertRaises(ValueError, cc.replace, all__vary_rounds=-1)
+ self.assertRaises(ValueError, cc.replace, all__vary_rounds="-1%")
+ self.assertRaises(ValueError, cc.replace, all__vary_rounds="101%")
+
+ # test static
+ c2 = cc.replace(all__vary_rounds=0)
+ self.assert_rounds_range(c2, "bcrypt", 20, 20)
+
+ c2 = cc.replace(all__vary_rounds="0%")
+ self.assert_rounds_range(c2, "bcrypt", 20, 20)
+
+ # test absolute
+ c2 = cc.replace(all__vary_rounds=1)
+ self.assert_rounds_range(c2, "bcrypt", 19, 21)
+ c2 = cc.replace(all__vary_rounds=100)
+ self.assert_rounds_range(c2, "bcrypt", 15, 25)
+
+ # test relative - should shift over at 50% mark
+ c2 = cc.replace(all__vary_rounds="1%")
+ self.assert_rounds_range(c2, "bcrypt", 20, 20)
+
+ c2 = cc.replace(all__vary_rounds="49%")
+ self.assert_rounds_range(c2, "bcrypt", 20, 20)
+
+ c2 = cc.replace(all__vary_rounds="50%")
+ self.assert_rounds_range(c2, "bcrypt", 19, 20)
+
+ c2 = cc.replace(all__vary_rounds="100%")
+ self.assert_rounds_range(c2, "bcrypt", 15, 21)
+
+ def assert_rounds_range(self, context, scheme, lower, upper):
+ "helper to check vary_rounds covers specified range"
+ # NOTE: this runs enough times the min and max *should* be hit,
+ # though there's a faint chance it will randomly fail.
+ handler = context.policy.get_handler(scheme)
+ salt = handler.default_salt_chars[0:1] * handler.max_salt_size
+ seen = set()
+ for i in irange(300):
+ h = context.genconfig(scheme, salt=salt)
+ r = handler.from_string(h).rounds
+ seen.add(r)
+ self.assertEqual(min(seen), lower, "vary_rounds had wrong lower limit:")
+ self.assertEqual(max(seen), upper, "vary_rounds had wrong upper limit:")
+
+ def test_11_encrypt_settings(self):
+ "test encrypt() honors policy settings"
+ cc = CryptContext(**self.sample_policy_1)
+
+ # hash specific settings
+ self.assertEqual(
+ cc.encrypt("password", scheme="phpass", salt='.'*8),
+ '$H$5........De04R5Egz0aq8Tf.1eVhY/',
+ )
+ self.assertEqual(
+ cc.encrypt("password", scheme="phpass", salt='.'*8, ident="P"),
+ '$P$5........De04R5Egz0aq8Tf.1eVhY/',
+ )
+
+ # NOTE: more thorough job of rounds limits done in genconfig() test,
+ # which is much cheaper, and shares the same codebase.
+
+ # min rounds
+ with catch_warnings(record=True) as wlog:
+ self.assertEqual(
+ cc.encrypt("password", rounds=1999, salt="nacl"),
+ '$5$rounds=2000$nacl$9/lTZ5nrfPuz8vphznnmHuDGFuvjSNvOEDsGmGfsS97',
+ )
+ self.consumeWarningList(wlog, PasslibConfigWarning)
+
+ self.assertEqual(
+ cc.encrypt("password", rounds=2001, salt="nacl"),
+ '$5$rounds=2001$nacl$8PdeoPL4aXQnJ0woHhqgIw/efyfCKC2WHneOpnvF.31'
+ )
+ self.consumeWarningList(wlog)
+
+ # max rounds, etc tested in genconfig()
+
+ # make default > max throws error if attempted
+ self.assertRaises(ValueError, cc.replace,
+ sha256_crypt__default_rounds=4000)
+
+ def test_12_hash_needs_update(self):
+ "test hash_needs_update() method"
+ cc = CryptContext(**self.sample_policy_1)
+
+ #check deprecated scheme
+ self.assertTrue(cc.hash_needs_update('9XXD4trGYeGJA'))
+ self.assertFalse(cc.hash_needs_update('$1$J8HC2RCr$HcmM.7NxB2weSvlw2FgzU0'))
+
+ #check min rounds
+ self.assertTrue(cc.hash_needs_update('$5$rounds=1999$jD81UCoo.zI.UETs$Y7qSTQ6mTiU9qZB4fRr43wRgQq4V.5AAf7F97Pzxey/'))
+ self.assertFalse(cc.hash_needs_update('$5$rounds=2000$228SSRje04cnNCaQ$YGV4RYu.5sNiBvorQDlO0WWQjyJVGKBcJXz3OtyQ2u8'))
+
+ #check max rounds
+ self.assertFalse(cc.hash_needs_update('$5$rounds=3000$fS9iazEwTKi7QPW4$VasgBC8FqlOvD7x2HhABaMXCTh9jwHclPA9j5YQdns.'))
+ self.assertTrue(cc.hash_needs_update('$5$rounds=3001$QlFHHifXvpFX4PLs$/0ekt7lSs/lOikSerQ0M/1porEHxYq7W/2hdFpxA3fA'))
+
+ #=========================================================
+ #identify
+ #=========================================================
+ def test_20_basic(self):
+ "test basic encrypt/identify/verify functionality"
+ handlers = [hash.md5_crypt, hash.des_crypt, hash.bsdi_crypt]
+ cc = CryptContext(handlers, policy=None)
+
+ #run through handlers
+ for crypt in handlers:
+ h = cc.encrypt("test", scheme=crypt.name)
+ self.assertEqual(cc.identify(h), crypt.name)
+ self.assertEqual(cc.identify(h, resolve=True), crypt)
+ self.assertTrue(cc.verify('test', h))
+ self.assertTrue(not cc.verify('notest', h))
+
+ #test default
+ h = cc.encrypt("test")
+ self.assertEqual(cc.identify(h), "md5_crypt")
+
+ #test genhash
+ h = cc.genhash('secret', cc.genconfig())
+ self.assertEqual(cc.identify(h), 'md5_crypt')
+
+ h = cc.genhash('secret', cc.genconfig(), scheme='md5_crypt')
+ self.assertEqual(cc.identify(h), 'md5_crypt')
+
+ self.assertRaises(ValueError, cc.genhash, 'secret', cc.genconfig(), scheme="des_crypt")
+
+ def test_21_identify(self):
+ "test identify() border cases"
+ handlers = ["md5_crypt", "des_crypt", "bsdi_crypt"]
+ cc = CryptContext(handlers, policy=None)
+
+ #check unknown hash
+ self.assertEqual(cc.identify('$9$232323123$1287319827'), None)
+ self.assertRaises(ValueError, cc.identify, '$9$232323123$1287319827', required=True)
+
+ def test_22_verify(self):
+ "test verify() scheme kwd"
+ handlers = ["md5_crypt", "des_crypt", "bsdi_crypt"]
+ cc = CryptContext(handlers, policy=None)
+
+ h = hash.md5_crypt.encrypt("test")
+
+ #check base verify
+ self.assertTrue(cc.verify("test", h))
+ self.assertTrue(not cc.verify("notest", h))
+
+ #check verify using right alg
+ self.assertTrue(cc.verify('test', h, scheme='md5_crypt'))
+ self.assertTrue(not cc.verify('notest', h, scheme='md5_crypt'))
+
+ #check verify using wrong alg
+ self.assertRaises(ValueError, cc.verify, 'test', h, scheme='bsdi_crypt')
+
+ def test_24_min_verify_time(self):
+ "test verify() honors min_verify_time"
+ #NOTE: this whole test assumes time.sleep() and tick()
+ # have better than 100ms accuracy - set via delta.
+ delta = .05
+ min_delay = 2*delta
+ min_verify_time = 5*delta
+ max_delay = 8*delta
+
+ class TimedHash(uh.StaticHandler):
+ "psuedo hash that takes specified amount of time"
+ name = "timed_hash"
+ delay = 0
+
+ @classmethod
+ def identify(cls, hash):
+ return True
+
+ def _calc_checksum(self, secret):
+ time.sleep(self.delay)
+ return to_unicode(secret + 'x')
+
+ # silence deprecation warnings for min verify time
+ with catch_warnings(record=True) as wlog:
+ cc = CryptContext([TimedHash], min_verify_time=min_verify_time)
+ self.consumeWarningList(wlog, DeprecationWarning)
+
+ def timecall(func, *args, **kwds):
+ start = tick()
+ result = func(*args, **kwds)
+ end = tick()
+ return end-start, result
+
+ #verify genhash delay works
+ TimedHash.delay = min_delay
+ elapsed, result = timecall(TimedHash.genhash, 'stub', None)
+ self.assertEqual(result, 'stubx')
+ self.assertAlmostEqual(elapsed, min_delay, delta=delta)
+
+ #ensure min verify time is honored
+ elapsed, result = timecall(cc.verify, "stub", "stubx")
+ self.assertTrue(result)
+ self.assertAlmostEqual(elapsed, min_delay, delta=delta)
+
+ elapsed, result = timecall(cc.verify, "blob", "stubx")
+ self.assertFalse(result)
+ self.assertAlmostEqual(elapsed, min_verify_time, delta=delta)
+
+ #ensure taking longer emits a warning.
+ TimedHash.delay = max_delay
+ with catch_warnings(record=True) as wlog:
+ elapsed, result = timecall(cc.verify, "blob", "stubx")
+ self.assertFalse(result)
+ self.assertAlmostEqual(elapsed, max_delay, delta=delta)
+ self.consumeWarningList(wlog, ".*verify exceeded min_verify_time")
+
+ def test_25_verify_and_update(self):
+ "test verify_and_update()"
+ cc = CryptContext(**self.sample_policy_1)
+
+ #create some hashes
+ h1 = cc.encrypt("password", scheme="des_crypt")
+ h2 = cc.encrypt("password", scheme="sha256_crypt")
+
+ #check bad password, deprecated hash
+ ok, new_hash = cc.verify_and_update("wrongpass", h1)
+ self.assertFalse(ok)
+ self.assertIs(new_hash, None)
+
+ #check bad password, good hash
+ ok, new_hash = cc.verify_and_update("wrongpass", h2)
+ self.assertFalse(ok)
+ self.assertIs(new_hash, None)
+
+ #check right password, deprecated hash
+ ok, new_hash = cc.verify_and_update("password", h1)
+ self.assertTrue(ok)
+ self.assertTrue(cc.identify(new_hash), "sha256_crypt")
+
+ #check right password, good hash
+ ok, new_hash = cc.verify_and_update("password", h2)
+ self.assertTrue(ok)
+ self.assertIs(new_hash, None)
+
+ #=========================================================
+ # border cases
+ #=========================================================
+ def test_30_nonstring_hash(self):
+ "test non-string hash values cause error"
+ #
+ # test hash=None or some other non-string causes TypeError
+ # and that explicit-scheme code path behaves the same.
+ #
+ cc = CryptContext(["des_crypt"])
+ for hash, kwds in [
+ (None, {}),
+ (None, {"scheme": "des_crypt"}),
+ (1, {}),
+ ((), {}),
+ ]:
+
+ self.assertRaises(TypeError, cc.identify, hash, **kwds)
+ self.assertRaises(TypeError, cc.genhash, 'stub', hash, **kwds)
+ self.assertRaises(TypeError, cc.verify, 'stub', hash, **kwds)
+ self.assertRaises(TypeError, cc.verify_and_update, 'stub', hash, **kwds)
+ self.assertRaises(TypeError, cc.hash_needs_update, hash, **kwds)
+
+ #
+ # but genhash *should* accept None if default scheme lacks config string.
+ #
+ cc2 = CryptContext(["mysql323"])
+ self.assertRaises(TypeError, cc2.identify, None)
+ self.assertIsInstance(cc2.genhash("stub", None), str)
+ self.assertRaises(TypeError, cc2.verify, 'stub', None)
+ self.assertRaises(TypeError, cc2.verify_and_update, 'stub', None)
+ self.assertRaises(TypeError, cc2.hash_needs_update, None)
+
+
+ def test_31_nonstring_secret(self):
+ "test non-string password values cause error"
+ cc = CryptContext(["des_crypt"])
+ hash = cc.encrypt("stub")
+ #
+ # test secret=None, or some other non-string causes TypeError
+ #
+ for secret, kwds in [
+ (None, {}),
+ (None, {"scheme": "des_crypt"}),
+ (1, {}),
+ ((), {}),
+ ]:
+ self.assertRaises(TypeError, cc.encrypt, secret, **kwds)
+ self.assertRaises(TypeError, cc.genhash, secret, hash, **kwds)
+ self.assertRaises(TypeError, cc.verify, secret, hash, **kwds)
+ self.assertRaises(TypeError, cc.verify_and_update, secret, hash, **kwds)
+
+ #=========================================================
+ # other
+ #=========================================================
+ def test_90_bcrypt_normhash(self):
+ "teset verify_and_update / hash_needs_update corrects bcrypt padding"
+ # see issue 25.
+ bcrypt = hash.bcrypt
+
+ PASS1 = "loppux"
+ BAD1 = "$2a$12$oaQbBqq8JnSM1NHRPQGXORm4GCUMqp7meTnkft4zgSnrbhoKdDV0C"
+ GOOD1 = "$2a$12$oaQbBqq8JnSM1NHRPQGXOOm4GCUMqp7meTnkft4zgSnrbhoKdDV0C"
+ ctx = CryptContext(["bcrypt"])
+
+ with catch_warnings(record=True) as wlog:
+ self.assertTrue(ctx.hash_needs_update(BAD1))
+ self.assertFalse(ctx.hash_needs_update(GOOD1))
+
+ if bcrypt.has_backend():
+ self.assertEqual(ctx.verify_and_update(PASS1,GOOD1), (True,None))
+ self.assertEqual(ctx.verify_and_update("x",BAD1), (False,None))
+ res = ctx.verify_and_update(PASS1, BAD1)
+ self.assertTrue(res[0] and res[1] and res[1] != BAD1)
+
+ #=========================================================
+ #eoc
+ #=========================================================
+
+#=========================================================
+#LazyCryptContext
+#=========================================================
+class dummy_2(uh.StaticHandler):
+ name = "dummy_2"
+
+class LazyCryptContextTest(TestCase):
+ descriptionPrefix = "LazyCryptContext"
+
+ def setUp(self):
+ # make sure this isn't registered before OR after
+ unload_handler_name("dummy_2")
+ self.addCleanup(unload_handler_name, "dummy_2")
+
+ # silence some warnings
+ warnings.filterwarnings("ignore",
+ r"CryptContext\(\)\.replace\(\) has been deprecated")
+ warnings.filterwarnings("ignore", ".*(CryptPolicy|context\.policy).*(has|have) been deprecated.*")
+
+ def test_kwd_constructor(self):
+ "test plain kwds"
+ self.assertFalse(has_crypt_handler("dummy_2"))
+ register_crypt_handler_path("dummy_2", "passlib.tests.test_context")
+
+ cc = LazyCryptContext(iter(["dummy_2", "des_crypt"]), deprecated=["des_crypt"])
+
+ self.assertFalse(has_crypt_handler("dummy_2", True))
+
+ self.assertTrue(cc.policy.handler_is_deprecated("des_crypt"))
+ self.assertEqual(cc.policy.schemes(), ["dummy_2", "des_crypt"])
+
+ self.assertTrue(has_crypt_handler("dummy_2", True))
+
+ def test_callable_constructor(self):
+ "test create_policy() hook, returning CryptPolicy"
+ self.assertFalse(has_crypt_handler("dummy_2"))
+ register_crypt_handler_path("dummy_2", "passlib.tests.test_context")
+
+ def create_policy(flag=False):
+ self.assertTrue(flag)
+ return CryptPolicy(schemes=iter(["dummy_2", "des_crypt"]), deprecated=["des_crypt"])
+
+ cc = LazyCryptContext(create_policy=create_policy, flag=True)
+
+ self.assertFalse(has_crypt_handler("dummy_2", True))
+
+ self.assertTrue(cc.policy.handler_is_deprecated("des_crypt"))
+ self.assertEqual(cc.policy.schemes(), ["dummy_2", "des_crypt"])
+
+ self.assertTrue(has_crypt_handler("dummy_2", True))
+
+#=========================================================
+#EOF
+#=========================================================
diff --git a/passlib/tests/test_ext_django.py b/passlib/tests/test_ext_django.py
index 890c87b..0a8764f 100644
--- a/passlib/tests/test_ext_django.py
+++ b/passlib/tests/test_ext_django.py
@@ -9,7 +9,7 @@ import sys
import warnings
#site
#pkg
-from passlib.context import CryptContext, CryptPolicy
+from passlib.context import CryptContext
from passlib.apps import django_context
from passlib.ext.django import utils
from passlib.hash import sha256_crypt
@@ -118,9 +118,9 @@ sample1_sha1 = 'sha1$b215d$9ee0a66f84ef1ad99096355e788135f7e949bd41'
# context for testing category funcs
category_context = CryptContext(
schemes = [ "sha256_crypt" ],
- sha256_crypt__rounds = 1000,
- staff__sha256_crypt__rounds = 2000,
- superuser__sha256_crypt__rounds = 3000,
+ sha256_crypt__default_rounds = 1000,
+ staff__sha256_crypt__default_rounds = 2000,
+ superuser__sha256_crypt__default_rounds = 3000,
)
def get_cc_rounds(**kwds):
@@ -258,7 +258,6 @@ class PatchTest(TestCase):
def test_01_patch_bad_types(self):
"test set_django_password_context bad inputs"
set = utils.set_django_password_context
- self.assertRaises(TypeError, set, CryptPolicy())
self.assertRaises(TypeError, set, "")
def test_02_models_check_password(self):
@@ -430,85 +429,70 @@ class PluginTest(TestCase):
descriptionPrefix = "passlib.ext.django plugin"
def setUp(self):
- #remove django patch
+ super(PluginTest, self).setUp()
+
+ # remove django patch now, and at end
utils.set_django_password_context(None)
+ self.addCleanup(utils.set_django_password_context, None)
- #ensure django settings are empty
+ # ensure django settings are empty
update_settings(
PASSLIB_CONTEXT=_NOTSET,
PASSLIB_GET_CATEGORY=_NOTSET,
)
- #unload module so it's re-run
+ # unload module so it's re-run when imported
sys.modules.pop("passlib.ext.django.models", None)
- def tearDown(self):
- #remove django patch
- utils.set_django_password_context(None)
+ def check_hashes(self, tests, default_scheme, deprecated=[], load=True):
+ """run through django api to verify patch is configured & functioning"""
+ # load extension if it hasn't been already.
+ if load:
+ import passlib.ext.django.models
- def check_hashes(self, tests, new_hash=None, deprecated=None):
- u = FakeUser()
- deprecated = None
+ # create fake user object
+ user = FakeUser()
- # check new hash construction
- if new_hash:
- u.set_password("placeholder")
- handler = get_crypt_handler(new_hash)
- self.assertTrue(handler.identify(u.password))
+ # check new hashes constructed using default scheme
+ user.set_password("stub")
+ handler = get_crypt_handler(default_scheme)
+ self.assertTrue(handler.identify(user.password),
+ "handler failed to identify hash: %r %r" %
+ (default_scheme, user.password))
# run against hashes from tests...
for test in tests:
for secret, hash in test.iter_known_hashes():
# check against valid password
- u.password = hash
+ user.password = hash
if has_django0 and isinstance(secret, unicode):
secret = secret.encode("utf-8")
- self.assertTrue(u.check_password(secret))
- if new_hash and deprecated and test.handler.name in deprecated:
+ self.assertTrue(user.check_password(secret))
+ if deprecated and test.handler.name in deprecated:
self.assertFalse(handler.identify(hash))
- self.assertTrue(handler.identify(u.password))
+ self.assertTrue(handler.identify(user.password))
# check against invalid password
- u.password = hash
- self.assertFalse(u.check_password('x'+secret))
- if new_hash and deprecated and test.handler.name in deprecated:
+ user.password = hash
+ self.assertFalse(user.check_password('x'+secret))
+ if deprecated and test.handler.name in deprecated:
self.assertFalse(handler.identify(hash))
- self.assertEqual(u.password, hash)
+ self.assertEqual(user.password, hash)
# check disabled handling
if has_django1:
- u.set_password(None)
+ user.set_password(None)
handler = get_crypt_handler("django_disabled")
- self.assertTrue(handler.identify(u.password))
- self.assertFalse(u.check_password('placeholder'))
+ self.assertTrue(handler.identify(user.password))
+ self.assertFalse(user.check_password('placeholder'))
- def test_00_actual_django(self):
- "test actual Django behavior has not changed"
- #NOTE: if this test fails,
- # probably means newer version of Django,
- # and passlib's policies should be updated.
+ def check_django_stock(self, load=True):
self.check_hashes(django_hash_tests,
"django_salted_sha1",
- ["hex_md5"])
-
- def test_01_explicit_unset(self, value=None):
- "test PASSLIB_CONTEXT = None"
- update_settings(
- PASSLIB_CONTEXT=value,
- )
- import passlib.ext.django.models
- self.check_hashes(django_hash_tests,
- "django_salted_sha1",
- ["hex_md5"])
-
- def test_02_stock_ctx(self):
- "test PASSLIB_CONTEXT = utils.STOCK_CTX"
- self.test_01_explicit_unset(value=utils.STOCK_CTX)
+ ["hex_md5"], load=load)
- def test_03_implicit_default_ctx(self):
- "test PASSLIB_CONTEXT unset"
- import passlib.ext.django.models
+ def check_passlib_stock(self):
self.check_hashes(default_hash_tests,
"sha512_crypt",
["hex_md5", "django_salted_sha1",
@@ -516,24 +500,46 @@ class PluginTest(TestCase):
"django_des_crypt",
])
- def test_04_explicit_default_ctx(self):
+ def test_10_django(self):
+ "test actual Django behavior has not changed"
+ #NOTE: if this test fails,
+ # probably means newer version of Django,
+ # and passlib's policies should be updated.
+ self.check_django_stock(load=False)
+
+ def test_11_none(self):
+ "test PASSLIB_CONTEXT=None"
+ update_settings(PASSLIB_CONTEXT=None)
+ self.check_django_stock(load=False)
+
+ def test_12_string(self):
+ "test PASSLIB_CONTEXT=string"
+ update_settings(PASSLIB_CONTEXT=utils.STOCK_CTX)
+ self.check_django_stock(load=False)
+
+ def test_13_unset(self):
+ "test unset PASSLIB_CONTEXT uses default"
+ self.check_passlib_stock()
+
+ def test_14_default(self):
"test PASSLIB_CONTEXT = utils.DEFAULT_CTX"
- update_settings(
- PASSLIB_CONTEXT=utils.DEFAULT_CTX,
- )
- self.test_03_implicit_default_ctx()
+ update_settings(PASSLIB_CONTEXT=utils.DEFAULT_CTX)
+ self.check_passlib_stock()
- def test_05_default_ctx_alias(self):
+ def test_15_default_alias(self):
"test PASSLIB_CONTEXT = 'passlib-default'"
- update_settings(
- PASSLIB_CONTEXT="passlib-default",
- )
- self.test_03_implicit_default_ctx()
+ update_settings(PASSLIB_CONTEXT="passlib-default")
+ self.check_passlib_stock()
+
+ def test_16_invalid(self):
+ "test PASSLIB_CONTEXT = invalid type"
+ update_settings(PASSLIB_CONTEXT=123)
+ self.assertRaises(TypeError, __import__, 'passlib.ext.django.models')
- def test_06_categories(self):
+ def test_20_categories(self):
"test PASSLIB_GET_CATEGORY unset"
update_settings(
- PASSLIB_CONTEXT=category_context.policy.to_string(),
+ PASSLIB_CONTEXT=category_context.to_string(),
)
import passlib.ext.django.models
@@ -541,12 +547,12 @@ class PluginTest(TestCase):
self.assertEqual(get_cc_rounds(is_staff=True), 2000)
self.assertEqual(get_cc_rounds(is_superuser=True), 3000)
- def test_07_categories_explicit(self):
+ def test_21_categories_explicit(self):
"test PASSLIB_GET_CATEGORY = function"
def get_category(user):
return user.first_name or None
update_settings(
- PASSLIB_CONTEXT = category_context.policy.to_string(),
+ PASSLIB_CONTEXT = category_context.to_string(),
PASSLIB_GET_CATEGORY = get_category,
)
import passlib.ext.django.models
@@ -556,10 +562,10 @@ class PluginTest(TestCase):
self.assertEqual(get_cc_rounds(first_name='staff'), 2000)
self.assertEqual(get_cc_rounds(first_name='superuser'), 3000)
- def test_08_categories_disabled(self):
+ def test_22_categories_disabled(self):
"test PASSLIB_GET_CATEGORY = None"
update_settings(
- PASSLIB_CONTEXT = category_context.policy.to_string(),
+ PASSLIB_CONTEXT = category_context.to_string(),
PASSLIB_GET_CATEGORY = None,
)
import passlib.ext.django.models
diff --git a/passlib/tests/test_handlers.py b/passlib/tests/test_handlers.py
index f00e1cf..61cdc8f 100644
--- a/passlib/tests/test_handlers.py
+++ b/passlib/tests/test_handlers.py
@@ -201,49 +201,55 @@ class _bcrypt_test(HandlerCase):
#===============================================================
# fuzz testing
#===============================================================
- def get_fuzz_verifiers(self):
- verifiers = super(_bcrypt_test, self).get_fuzz_verifiers()
-
- # test other backends against py-bcrypt if available
+ def os_supports_ident(self, hash):
+ "check if OS crypt is expected to support given ident"
+ if hash is None:
+ return True
+ # most OSes won't support 2x/2y
+ # XXX: definitely not the BSDs, but what about the linux variants?
+ if hash.startswith("$2x$") or hash.startswith("$2y$"):
+ return False
+ return True
+
+ def fuzz_verifier_pybcrypt(self):
+ # test against py-bcrypt if available
from passlib.utils import to_native_str
try:
from bcrypt import hashpw
except ImportError:
- pass
- else:
- def check_pybcrypt(secret, hash):
- "pybcrypt"
- secret = to_native_str(secret, self.fuzz_password_encoding)
- if hash.startswith("$2y$"):
- hash = "$2a$" + hash[4:]
- try:
- return hashpw(secret, hash) == hash
- except ValueError:
- raise ValueError("py-bcrypt rejected hash: %r" % (hash,))
- verifiers.append(check_pybcrypt)
-
- # test other backends against bcryptor if available
+ return
+ def check_pybcrypt(secret, hash):
+ "pybcrypt"
+ secret = to_native_str(secret, self.fuzz_password_encoding)
+ if hash.startswith("$2y$"):
+ hash = "$2a$" + hash[4:]
+ try:
+ return hashpw(secret, hash) == hash
+ except ValueError:
+ raise ValueError("py-bcrypt rejected hash: %r" % (hash,))
+ return check_pybcrypt
+
+ def fuzz_verifier_bcryptor(self):
+ # test against bcryptor if available
+ from passlib.utils import to_native_str
try:
from bcryptor.engine import Engine
except ImportError:
- pass
- else:
- def check_bcryptor(secret, hash):
- "bcryptor"
- secret = to_native_str(secret, self.fuzz_password_encoding)
- if hash.startswith("$2y$"):
- hash = "$2a$" + hash[4:]
- elif hash.startswith("$2$"):
- # bcryptor doesn't support $2$ hashes; but we can fake it
- # using the $2a$ algorithm, by repeating the password until
- # it's 72 chars in length.
- hash = "$2a$" + hash[3:]
- if secret:
- secret = repeat_string(secret, 72)
- return Engine(False).hash_key(secret, hash) == hash
- verifiers.append(check_bcryptor)
-
- return verifiers
+ return
+ def check_bcryptor(secret, hash):
+ "bcryptor"
+ secret = to_native_str(secret, self.fuzz_password_encoding)
+ if hash.startswith("$2y$"):
+ hash = "$2a$" + hash[4:]
+ elif hash.startswith("$2$"):
+ # bcryptor doesn't support $2$ hashes; but we can fake it
+ # using the $2a$ algorithm, by repeating the password until
+ # it's 72 chars in length.
+ hash = "$2a$" + hash[3:]
+ if secret:
+ secret = repeat_string(secret, 72)
+ return Engine(False).hash_key(secret, hash) == hash
+ return check_bcryptor
def get_fuzz_rounds(self):
# decrease default rounds for fuzz testing to speed up volume.
@@ -254,6 +260,8 @@ class _bcrypt_test(HandlerCase):
if ident == u("$2x$"):
# just recognized, not currently supported.
return None
+ if self.backend == "os_crypt" and not self.using_patched_crypt and not self.os_supports_ident(ident):
+ return None
return ident
#===============================================================
@@ -421,13 +429,17 @@ class _bsdi_crypt_test(HandlerCase):
platform_crypt_support = dict(
freebsd=True,
- openbsd=False,
+ openbsd=True,
netbsd=True,
linux=False,
solaris=False,
# darwin ?
)
+ def setUp(self):
+ super(_bsdi_crypt_test, self).setUp()
+ warnings.filterwarnings("ignore", "bsdi_crypt rounds should be odd.*")
+
os_crypt_bsdi_crypt_test = create_backend_case(_bsdi_crypt_test, "os_crypt")
builtin_bsdi_crypt_test = create_backend_case(_bsdi_crypt_test, "builtin")
@@ -497,6 +509,7 @@ class cisco_pix_test(UserHandlerMixin, HandlerCase):
class cisco_type7_test(HandlerCase):
handler = hash.cisco_type7
salt_bits = 4
+ salt_type = int
known_correct_hashes = [
#
@@ -539,6 +552,41 @@ class cisco_type7_test(HandlerCase):
(UPASS_TABLE, '0958EDC8A9F495F6F8A5FD'),
]
+ known_unidentified_hashes = [
+ # salt with hex value
+ "0A480E051A33490E",
+
+ # salt value > 52. this may in fact be valid, but we reject it for now
+ # (see docs for more).
+ '99400E4812',
+ ]
+
+ def test_90_decode(self):
+ "test cisco_type7.decode()"
+ from passlib.utils import to_unicode, to_bytes
+
+ handler = self.handler
+ for secret, hash in self.known_correct_hashes:
+ usecret = to_unicode(secret)
+ bsecret = to_bytes(secret)
+ self.assertEqual(handler.decode(hash), usecret)
+ self.assertEqual(handler.decode(hash, None), bsecret)
+
+ self.assertRaises(UnicodeDecodeError, handler.decode,
+ '0958EDC8A9F495F6F8A5FD', 'ascii')
+
+ def test_91_salt(self):
+ "test salt value border cases"
+ handler = self.handler
+ self.assertRaises(TypeError, handler, salt=None)
+ handler(salt=None, use_defaults=True)
+ self.assertRaises(TypeError, handler, salt='abc')
+ self.assertRaises(ValueError, handler, salt=-10)
+ with catch_warnings(record=True) as wlog:
+ h = handler(salt=100, relaxed=True)
+ self.consumeWarningList(wlog, ["salt/offset must be.*"])
+ self.assertEqual(h.salt, 52)
+
#=========================================================
# crypt16
#=========================================================
@@ -603,6 +651,10 @@ class _des_crypt_test(HandlerCase):
# bad char in otherwise correctly formatted hash
#\/
'!gAwTx2l6NADI',
+
+ # wrong size
+ 'OgAwTx2l6NAD',
+ 'OgAwTx2l6NADIj',
]
platform_crypt_support = dict(
@@ -627,18 +679,15 @@ class _DjangoHelper(object):
# NOTE: not testing against Django < 1.0 since it doesn't support
# most of these hash formats.
- def get_fuzz_verifiers(self):
- verifiers = super(_DjangoHelper, self).get_fuzz_verifiers()
-
+ def fuzz_verifier_django(self):
from passlib.tests.test_ext_django import has_django1
- if has_django1:
- from django.contrib.auth.models import check_password
- def verify_django(secret, hash):
- "django check_password()"
- return check_password(secret, hash)
- verifiers.append(verify_django)
-
- return verifiers
+ if not has_django1:
+ return None
+ from django.contrib.auth.models import check_password
+ def verify_django(secret, hash):
+ "django check_password()"
+ return check_password(secret, hash)
+ return verify_django
def test_90_django_reference(self):
"run known correct hashes through Django's check_password()"
@@ -794,6 +843,9 @@ class fshp_test(HandlerCase):
]
known_malformed_hashes = [
+ # bad base64 padding
+ '{FSHP0|0|1}qUqP5cyxm6YcTAhz05Hph5gvu9M',
+
# wrong salt size
'{FSHP0|1|1}qUqP5cyxm6YcTAhz05Hph5gvu9M=',
@@ -801,6 +853,32 @@ class fshp_test(HandlerCase):
'{FSHP0|0|A}qUqP5cyxm6YcTAhz05Hph5gvu9M=',
]
+ def test_90_variant(self):
+ "test variant keyword"
+ handler = self.handler
+ kwds = dict(salt=b('a'), rounds=1)
+
+ # accepts ints
+ handler(variant=1, **kwds)
+
+ # accepts bytes or unicode
+ handler(variant=u('1'), **kwds)
+ handler(variant=b('1'), **kwds)
+
+ # aliases
+ handler(variant=u('sha256'), **kwds)
+ handler(variant=b('sha256'), **kwds)
+
+ # rejects None
+ self.assertRaises(TypeError, handler, variant=None, **kwds)
+
+ # rejects other types
+ self.assertRaises(TypeError, handler, variant=complex(1,1), **kwds)
+
+ # invalid variant
+ self.assertRaises(ValueError, handler, variant='9', **kwds)
+ self.assertRaises(ValueError, handler, variant=9, **kwds)
+
#=========================================================
#hex digests
#=========================================================
@@ -844,6 +922,37 @@ class hex_sha512_test(HandlerCase):
]
#=========================================================
+# htdigest hash
+#=========================================================
+class htdigest_test(UserHandlerMixin, HandlerCase):
+ handler = hash.htdigest
+
+ known_correct_hashes = [
+ # secret, user, realm
+
+ # from RFC 2617
+ (("Circle Of Life", "Mufasa", "testrealm@host.com"),
+ '939e7578ed9e3c518a452acee763bce9'),
+
+ # custom
+ ((UPASS_TABLE, UPASS_USD, UPASS_WAV),
+ '4dabed2727d583178777fab468dd1f17'),
+ ]
+
+ def test_80_user(self):
+ raise self.skipTest("test case doesn't support 'realm' keyword")
+
+ def _insert_user(self, kwds, secret):
+ "insert username into kwds"
+ if isinstance(secret, tuple):
+ secret, user, realm = secret
+ else:
+ user, realm = "user", "realm"
+ kwds.setdefault("user", user)
+ kwds.setdefault("realm", realm)
+ return secret
+
+#=========================================================
#ldap hashes
#=========================================================
class ldap_md5_test(HandlerCase):
@@ -1080,6 +1189,9 @@ class _md5_crypt_test(HandlerCase):
known_malformed_hashes = [
# bad char in otherwise correct hash \/
'$1$dOHYPKoP$tnxS1T8Q6VVn3kpV8cN6o!',
+
+ # too many fields
+ '$1$dOHYPKoP$tnxS1T8Q6VVn3kpV8cN6o.$',
]
platform_crypt_support = dict(
@@ -1789,6 +1901,13 @@ class scram_test(HandlerCase):
# bad char in digest ---\/
'$scram$4096$QSXCR.Q6sek8bf92$sha-1=HZbuOlKbWl.eR8AfIposuKbhX3-',
+ # missing sections
+ '$scram$4096$QSXCR.Q6sek8bf92',
+ '$scram$4096$QSXCR.Q6sek8bf92$',
+
+ # too many sections
+ '$scram$4096$QSXCR.Q6sek8bf92$sha-1=HZbuOlKbWl.eR8AfIposuKbhX30$',
+
# missing separator
'$scram$4096$QSXCR.Q6sek8bf92$sha-1=HZbuOlKbWl.eR8AfIposuKbhX30'
'sha-256=qXUXrlcvnaxxWG00DdRgVioR2gnUpuX5r.3EZ1rdhVY',
@@ -1800,11 +1919,17 @@ class scram_test(HandlerCase):
# missing sha-1 alg
'$scram$4096$QSXCR.Q6sek8bf92$sha-256=HZbuOlKbWl.eR8AfIposuKbhX30',
+ # non-iana name
+ '$scram$4096$QSXCR.Q6sek8bf92$sha1=HZbuOlKbWl.eR8AfIposuKbhX30',
]
- # silence norm_hash_name() warning
def setUp(self):
super(scram_test, self).setUp()
+
+ # some platforms lack stringprep (e.g. Jython, IronPython)
+ self.require_stringprep()
+
+ # silence norm_hash_name() warning
warnings.filterwarnings("ignore", r"norm_hash_name\(\): unknown hash")
def test_90_algs(self):
@@ -1858,6 +1983,10 @@ class scram_test(HandlerCase):
'sha-1=HZbuOlKbWl.eR8AfIposuKbhX30'), ["sha-1"])
self.assertEqual(eda('$scram$4096$QSXCR.Q6sek8bf92$'
+ 'sha-1=HZbuOlKbWl.eR8AfIposuKbhX30', format="hashlib"),
+ ["sha1"])
+
+ self.assertEqual(eda('$scram$4096$QSXCR.Q6sek8bf92$'
'sha-1=HZbuOlKbWl.eR8AfIposuKbhX30,'
'sha-256=qXUXrlcvnaxxWG00DdRgVioR2gnUpuX5r.3EZ1rdhVY,'
'sha-512=lzgniLFcvglRLS0gt.C4gy.NurS3OIOVRAU1zZOV4P.qFiVFO2/'
@@ -1887,6 +2016,9 @@ class scram_test(HandlerCase):
# check rounds
self.assertRaises(ValueError, hash, "IX", s1, 0, 'sha-1')
+ # bad types
+ self.assertRaises(TypeError, hash, "IX", u('\x01'), 1000, 'md5')
+
def test_94_saslprep(self):
"test encrypt/verify use saslprep"
# NOTE: this just does a light test that saslprep() is being
@@ -1917,14 +2049,16 @@ class scram_test(HandlerCase):
self.assertEqual(handler.extract_digest_algs(h), ["md5", "sha-1"])
self.assertFalse(c1.hash_needs_update(h))
- c2 = c1.replace(scram__algs="sha1")
+ c2 = c1.copy(scram__algs="sha1")
self.assertFalse(c2.hash_needs_update(h))
- c2 = c1.replace(scram__algs="sha1,sha256")
+ c2 = c1.copy(scram__algs="sha1,sha256")
self.assertTrue(c2.hash_needs_update(h))
def test_96_full_verify(self):
"test verify(full=True) flag"
+ def vpart(s, h):
+ return self.handler.verify(s, h)
def vfull(s, h):
return self.handler.verify(s, h, full=True)
@@ -1953,12 +2087,16 @@ class scram_test(HandlerCase):
'edGQSu/kD1LwdX0SNV/KsPdHSwEl5qRTuZQ')
self.assertRaises(ValueError, vfull, 'pencil', h)
- # catch digests belonging to diff passwords.
+ # catch hash containing digests belonging to diff passwords.
+ # proper behavior for quick-verify (the default) is undefined,
+ # but full-verify should throw error.
h = ('$scram$4096$QSXCR.Q6sek8bf92$'
- 'sha-1=HZbuOlKbWl.eR8AfIposuKbhX30,'
- 'sha-256=R7RJDWIbeKRTFwhE9oxh04kab0CllrQ3kCcpZUcligc' # 'tape'
- 'sha-512=lzgniLFcvglRLS0gt.C4gy.NurS3OIOVRAU1zZOV4P.qFiVFO2/'
+ 'sha-1=HZbuOlKbWl.eR8AfIposuKbhX30,' # 'pencil'
+ 'sha-256=R7RJDWIbeKRTFwhE9oxh04kab0CllrQ3kCcpZUcligc,' # 'tape'
+ 'sha-512=lzgniLFcvglRLS0gt.C4gy.NurS3OIOVRAU1zZOV4P.qFiVFO2/' # 'pencil'
'edGQSu/kD1LwdX0SNV/KsPdHSwEl5qRTuZQ')
+ self.assertTrue(vpart('tape', h))
+ self.assertFalse(vpart('pencil', h))
self.assertRaises(ValueError, vfull, 'pencil', h)
self.assertRaises(ValueError, vfull, 'tape', h)
@@ -1983,6 +2121,12 @@ class _sha1_crypt_test(HandlerCase):
# zero padded rounds
'$sha1$01773$uV7PTeux$I9oHnvwPZHMO0Nq6/WgyGV/tDJIH',
+
+ # too many fields
+ '$sha1$21773$uV7PTeux$I9oHnvwPZHMO0Nq6/WgyGV/tDJIH$',
+
+ # empty rounds field
+ '$sha1$$uV7PTeux$I9oHnvwPZHMO0Nq6/WgyGV/tDJIH$',
]
platform_crypt_support = dict(
@@ -2294,6 +2438,14 @@ class sun_md5_crypt_test(HandlerCase):
]
known_malformed_hashes = [
+ # unexpected end of hash
+ "$md5,rounds=5000",
+
+ # bad rounds
+ "$md5,rounds=500A$xxxx",
+ "$md5,rounds=0500$xxxx",
+ "$md5,rounds=0$xxxx",
+
# bad char in otherwise correct hash
"$md5$RPgL!6IJ$WTvAlUJ7MqH5xak2FMEwS/",
@@ -2347,6 +2499,16 @@ class unix_disabled_test(HandlerCase):
# TODO: test custom marker support
# TODO: test default marker selection
+ def test_90_preserves_existing(self):
+ "test preserves existing disabled hash"
+ handler = self.handler
+
+ # use marker if no hash
+ self.assertEqual(handler.genhash("stub", None), handler.marker)
+
+ # use hash if provided and valid
+ self.assertEqual(handler.genhash("stub", "!asd"), "!asd")
+
class unix_fallback_test(HandlerCase):
handler = hash.unix_fallback
accepts_all_hashes = True
diff --git a/passlib/tests/test_hosts.py b/passlib/tests/test_hosts.py
index de744a8..a64fb30 100644
--- a/passlib/tests/test_hosts.py
+++ b/passlib/tests/test_hosts.py
@@ -72,7 +72,7 @@ class HostsTest(TestCase):
# validate schemes is non-empty,
# and contains unix_disabled + at least one real scheme
- schemes = ctx.policy.schemes()
+ schemes = list(ctx.schemes())
self.assertTrue(schemes, "appears to be unix system, but no known schemes supported by crypt")
self.assertTrue('unix_disabled' in schemes)
schemes.remove("unix_disabled")
diff --git a/passlib/tests/test_registry.py b/passlib/tests/test_registry.py
index 1990919..3f18271 100644
--- a/passlib/tests/test_registry.py
+++ b/passlib/tests/test_registry.py
@@ -126,6 +126,8 @@ class RegistryTest(TestCase):
self.assertRaises(ValueError, register_crypt_handler, type('x', (uh.StaticHandler,), dict(name=None)))
self.assertRaises(ValueError, register_crypt_handler, type('x', (uh.StaticHandler,), dict(name="AB_CD")))
self.assertRaises(ValueError, register_crypt_handler, type('x', (uh.StaticHandler,), dict(name="ab-cd")))
+ self.assertRaises(ValueError, register_crypt_handler, type('x', (uh.StaticHandler,), dict(name="ab__cd")))
+ self.assertRaises(ValueError, register_crypt_handler, type('x', (uh.StaticHandler,), dict(name="default")))
class dummy_1(uh.StaticHandler):
name = "dummy_1"
diff --git a/passlib/tests/test_utils.py b/passlib/tests/test_utils.py
index 678ae06..d317629 100644
--- a/passlib/tests/test_utils.py
+++ b/passlib/tests/test_utils.py
@@ -26,6 +26,60 @@ class MiscTest(TestCase):
#NOTE: could test xor_bytes(), but it's exercised well enough by pbkdf2 test
+ def test_classproperty(self):
+ from passlib.utils import classproperty
+
+ class test(object):
+ xvar = 1
+ @classproperty
+ def xprop(cls):
+ return cls.xvar
+
+ self.assertEqual(test.xprop, 1)
+ prop = test.__dict__['xprop']
+ self.assertIs(prop.im_func, prop.__func__)
+
+ def test_deprecated_function(self):
+ from passlib.utils import deprecated_function
+ # NOTE: not comprehensive, just tests the basic behavior
+
+ @deprecated_function(deprecated="1.6", removed="1.8")
+ def test_func(*args):
+ "test docstring"
+ return args
+
+ self.assertTrue(".. deprecated::" in test_func.__doc__)
+
+ with catch_warnings(record=True) as wlog:
+ self.assertEqual(test_func(1,2), (1,2))
+ self.consumeWarningList(wlog,[
+ dict(category=DeprecationWarning,
+ message="the function passlib.tests.test_utils.test_func() "
+ "is deprecated as of Passlib 1.6, and will be "
+ "removed in Passlib 1.8."
+ ),
+ ])
+
+ def test_memoized_property(self):
+ from passlib.utils import memoized_property
+
+ class dummy(object):
+ counter = 0
+
+ @memoized_property
+ def value(self):
+ value = self.counter
+ self.counter = value+1
+ return value
+
+ d = dummy()
+ self.assertEqual(d.value, 0)
+ self.assertEqual(d.value, 0)
+ self.assertEqual(d.counter, 1)
+
+ prop = dummy.value
+ self.assertIs(prop.im_func, prop.__func__)
+
def test_getrandbytes(self):
"test getrandbytes()"
from passlib.utils import getrandbytes, rng
@@ -241,13 +295,11 @@ class MiscTest(TestCase):
def test_saslprep(self):
"test saslprep() unicode normalizer"
- from passlib.utils import saslprep as sp, _ipy_missing_stringprep
-
+ self.require_stringprep()
+ from passlib.utils.compat import IRONPYTHON
if IRONPYTHON:
- self.assertTrue(_ipy_missing_stringprep,
- "alert passlib author that IPY has stringprep support!")
- self.assertRaises(NotImplementedError, sp, u('abc'))
- raise self.skipTest("stringprep missing under IPY")
+ warn("IronPython now has stringprep support!")
+ from passlib.utils import saslprep as sp
# invalid types
self.assertRaises(TypeError, sp, None)
@@ -290,6 +342,8 @@ class MiscTest(TestCase):
# unassigned code points (as of unicode 3.2)
self.assertRaises(ValueError, sp, u("\u0900"))
self.assertRaises(ValueError, sp, u("\uFFF8"))
+ # tagging characters
+ self.assertRaises(ValueError, sp, u("\U000e0001"))
# verify bidi behavior
# if starts with R/AL -- must end with R/AL
@@ -450,86 +504,32 @@ class CodecTest(TestCase):
self.assertFalse(is_same_codec("ascii", "utf-8"))
#=========================================================
-#test des module
+# base64engine
#=========================================================
-import passlib.utils.des as des
-
-class DesTest(TestCase):
-
- #test vectors taken from http://www.skepticfiles.org/faq/testdes.htm
-
- #data is list of (key, plaintext, ciphertext), all as 64 bit hex string
- test_des_vectors = [
- (line[4:20], line[21:37], line[38:54])
- for line in
-b(""" 0000000000000000 0000000000000000 8CA64DE9C1B123A7
- FFFFFFFFFFFFFFFF FFFFFFFFFFFFFFFF 7359B2163E4EDC58
- 3000000000000000 1000000000000001 958E6E627A05557B
- 1111111111111111 1111111111111111 F40379AB9E0EC533
- 0123456789ABCDEF 1111111111111111 17668DFC7292532D
- 1111111111111111 0123456789ABCDEF 8A5AE1F81AB8F2DD
- 0000000000000000 0000000000000000 8CA64DE9C1B123A7
- FEDCBA9876543210 0123456789ABCDEF ED39D950FA74BCC4
- 7CA110454A1A6E57 01A1D6D039776742 690F5B0D9A26939B
- 0131D9619DC1376E 5CD54CA83DEF57DA 7A389D10354BD271
- 07A1133E4A0B2686 0248D43806F67172 868EBB51CAB4599A
- 3849674C2602319E 51454B582DDF440A 7178876E01F19B2A
- 04B915BA43FEB5B6 42FD443059577FA2 AF37FB421F8C4095
- 0113B970FD34F2CE 059B5E0851CF143A 86A560F10EC6D85B
- 0170F175468FB5E6 0756D8E0774761D2 0CD3DA020021DC09
- 43297FAD38E373FE 762514B829BF486A EA676B2CB7DB2B7A
- 07A7137045DA2A16 3BDD119049372802 DFD64A815CAF1A0F
- 04689104C2FD3B2F 26955F6835AF609A 5C513C9C4886C088
- 37D06BB516CB7546 164D5E404F275232 0A2AEEAE3FF4AB77
- 1F08260D1AC2465E 6B056E18759F5CCA EF1BF03E5DFA575A
- 584023641ABA6176 004BD6EF09176062 88BF0DB6D70DEE56
- 025816164629B007 480D39006EE762F2 A1F9915541020B56
- 49793EBC79B3258F 437540C8698F3CFA 6FBF1CAFCFFD0556
- 4FB05E1515AB73A7 072D43A077075292 2F22E49BAB7CA1AC
- 49E95D6D4CA229BF 02FE55778117F12A 5A6B612CC26CCE4A
- 018310DC409B26D6 1D9D5C5018F728C2 5F4C038ED12B2E41
- 1C587F1C13924FEF 305532286D6F295A 63FAC0D034D9F793
- 0101010101010101 0123456789ABCDEF 617B3A0CE8F07100
- 1F1F1F1F0E0E0E0E 0123456789ABCDEF DB958605F8C8C606
- E0FEE0FEF1FEF1FE 0123456789ABCDEF EDBFD1C66C29CCC7
- 0000000000000000 FFFFFFFFFFFFFFFF 355550B2150E2451
- FFFFFFFFFFFFFFFF 0000000000000000 CAAAAF4DEAF1DBAE
- 0123456789ABCDEF 0000000000000000 D5D44FF720683D0D
- FEDCBA9876543210 FFFFFFFFFFFFFFFF 2A2BB008DF97C2F2
- """).split(b("\n")) if line.strip()
- ]
+class Base64EngineTest(TestCase):
+ "test standalone parts of Base64Engine"
+ # NOTE: most Base64Engine testing done via _Base64Test subclasses below.
- def test_des_encrypt_block(self):
- for k,p,c in self.test_des_vectors:
- k = unhexlify(k)
- p = unhexlify(p)
- c = unhexlify(c)
- result = des.des_encrypt_block(k,p)
- self.assertEqual(result, c, "key=%r p=%r:" % (k,p))
-
- #test 7 byte key
- #FIXME: use a better key
- k,p,c = b('00000000000000'), b('FFFFFFFFFFFFFFFF'), b('355550B2150E2451')
- k = unhexlify(k)
- p = unhexlify(p)
- c = unhexlify(c)
- result = des.des_encrypt_block(k,p)
- self.assertEqual(result, c, "key=%r p=%r:" % (k,p))
-
- def test_mdes_encrypt_int_block(self):
- for k,p,c in self.test_des_vectors:
- k = int(k,16)
- p = int(p,16)
- c = int(c,16)
- result = des.mdes_encrypt_int_block(k,p, salt=0, rounds=1)
- self.assertEqual(result, c, "key=%r p=%r:" % (k,p))
-
- #TODO: test other des methods (eg: mdes_encrypt_int_block w/ salt & rounds)
- # though des-crypt builtin backend test should thump it well enough
+ def test_constructor(self):
+ from passlib.utils import Base64Engine, AB64_CHARS
+
+ # bad charmap type
+ self.assertRaises(TypeError, Base64Engine, 1)
+
+ # bad charmap size
+ self.assertRaises(ValueError, Base64Engine, AB64_CHARS[:-1])
+
+ # dup charmap letter
+ self.assertRaises(ValueError, Base64Engine, AB64_CHARS[:-1] + "A")
+
+ def test_ab64(self):
+ from passlib.utils import ab64_decode
+ # TODO: make ab64_decode (and a b64 variant) *much* stricter about
+ # padding chars, etc.
+
+ # 1 mod 4 not valid
+ self.assertRaises(ValueError, ab64_decode, "abcde")
-#=========================================================
-# base64engine
-#=========================================================
class _Base64Test(TestCase):
"common tests for all Base64Engine instances"
#=========================================================
@@ -730,6 +730,8 @@ class _Base64Test(TestCase):
out = engine.decode_bytes(tmp)
self.assertEqual(out, result)
+ self.assertRaises(TypeError, engine.encode_transposed_bytes, u("a"), [])
+
def test_decode_transposed_bytes(self):
"test decode_transposed_bytes()"
engine = self.engine
@@ -895,351 +897,5 @@ class H64Big_Test(_Base64Test):
]
#=========================================================
-#test md4
-#=========================================================
-class _MD4_Test(TestCase):
- #test vectors from http://www.faqs.org/rfcs/rfc1320.html - A.5
-
- hash = None
-
- vectors = [
- # input -> hex digest
- (b(""), "31d6cfe0d16ae931b73c59d7e0c089c0"),
- (b("a"), "bde52cb31de33e46245e05fbdbd6fb24"),
- (b("abc"), "a448017aaf21d8525fc10ae87aa6729d"),
- (b("message digest"), "d9130a8164549fe818874806e1c7014b"),
- (b("abcdefghijklmnopqrstuvwxyz"), "d79e1c308aa5bbcdeea8ed63df412da9"),
- (b("ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"), "043f8582f241db351ce627e153e7f0e4"),
- (b("12345678901234567890123456789012345678901234567890123456789012345678901234567890"), "e33b4ddc9c38f2199c3e7b164fcc0536"),
- ]
-
- def test_md4_update(self):
- "test md4 update"
- md4 = self.hash
- h = md4(b(''))
- self.assertEqual(h.hexdigest(), "31d6cfe0d16ae931b73c59d7e0c089c0")
-
- #NOTE: under py2, hashlib methods try to encode to ascii,
- # though shouldn't rely on that.
- if PY3:
- self.assertRaises(TypeError, h.update, u('x'))
-
- h.update(b('a'))
- self.assertEqual(h.hexdigest(), "bde52cb31de33e46245e05fbdbd6fb24")
-
- h.update(b('bcdefghijklmnopqrstuvwxyz'))
- self.assertEqual(h.hexdigest(), "d79e1c308aa5bbcdeea8ed63df412da9")
-
- def test_md4_hexdigest(self):
- "test md4 hexdigest()"
- md4 = self.hash
- for input, hex in self.vectors:
- out = md4(input).hexdigest()
- self.assertEqual(out, hex)
-
- def test_md4_digest(self):
- "test md4 digest()"
- md4 = self.hash
- for input, hex in self.vectors:
- out = bascii_to_str(hexlify(md4(input).digest()))
- self.assertEqual(out, hex)
-
- def test_md4_copy(self):
- "test md4 copy()"
- md4 = self.hash
- h = md4(b('abc'))
-
- h2 = h.copy()
- h2.update(b('def'))
- self.assertEqual(h2.hexdigest(), '804e7f1c2586e50b49ac65db5b645131')
-
- h.update(b('ghi'))
- self.assertEqual(h.hexdigest(), 'c5225580bfe176f6deeee33dee98732c')
-
-#
-#now do a bunch of things to test multiple possible backends.
-#
-import passlib.utils.md4 as md4_mod
-
-has_ssl_md4 = (md4_mod.md4 is not md4_mod._builtin_md4)
-
-if has_ssl_md4:
- class MD4_SSL_Test(_MD4_Test):
- descriptionPrefix = "MD4 (SSL version)"
- hash = staticmethod(md4_mod.md4)
-
-if not has_ssl_md4 or enable_option("cover"):
- class MD4_Builtin_Test(_MD4_Test):
- descriptionPrefix = "MD4 (builtin version)"
- hash = md4_mod._builtin_md4
-
-#=========================================================
-#test passlib.utils.pbkdf2
-#=========================================================
-import hashlib
-import hmac
-from passlib.utils import pbkdf2
-
-#TODO: should we bother testing hmac_sha1() function? it's verified via sha1_crypt testing.
-class CryptoTest(TestCase):
- "test various crypto functions"
-
- ndn_formats = ["hashlib", "iana"]
- ndn_values = [
- # (iana name, hashlib name, ... other unnormalized names)
- ("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"),
- ("test128", "test-128", "TEST128"),
- ("test2", "test2", "TEST-2"),
- ("test3128", "test3-128", "TEST-3-128"),
- ]
-
- def test_norm_hash_name(self):
- "test norm_hash_name()"
- from itertools import chain
- from passlib.utils.pbkdf2 import norm_hash_name, _nhn_hash_names
-
- # test formats
- for format in self.ndn_formats:
- norm_hash_name("md4", format)
- self.assertRaises(ValueError, norm_hash_name, "md4", None)
- self.assertRaises(ValueError, norm_hash_name, "md4", "fake")
-
- # test types
- self.assertEqual(norm_hash_name(u("MD4")), "md4")
- self.assertEqual(norm_hash_name(b("MD4")), "md4")
- self.assertRaises(TypeError, norm_hash_name, None)
-
- # test selected results
- with catch_warnings():
- warnings.filterwarnings("ignore", '.*unknown hash')
- for row in chain(_nhn_hash_names, self.ndn_values):
- for idx, format in enumerate(self.ndn_formats):
- correct = row[idx]
- for value in row:
- result = norm_hash_name(value, format)
- self.assertEqual(result, correct,
- "name=%r, format=%r:" % (value,
- format))
-
-class KdfTest(TestCase):
- "test kdf helpers"
-
- def test_pbkdf1(self):
- "test pbkdf1"
- for secret, salt, rounds, klen, hash, correct in [
- #http://www.di-mgt.com.au/cryptoKDFs.html
- (b('password'), hb('78578E5A5D63CB06'), 1000, 16, 'sha1',
- hb('dc19847e05c64d2faf10ebfb4a3d2a20')),
- ]:
- result = pbkdf2.pbkdf1(secret, salt, rounds, klen, hash)
- self.assertEqual(result, correct)
-
- #test rounds < 1
- #test klen < 0
- #test klen > block size
- #test invalid hash
-
-#NOTE: this is not run directly, but via two subclasses (below)
-class _Pbkdf2BackendTest(TestCase):
- "test builtin unix crypt backend"
- enable_m2crypto = False
-
- def setUp(self):
- #disable m2crypto support so we'll always use software backend
- if not self.enable_m2crypto:
- self._orig_EVP = pbkdf2._EVP
- pbkdf2._EVP = None
- else:
- #set flag so tests can check for m2crypto presence quickly
- self.enable_m2crypto = bool(pbkdf2._EVP)
- pbkdf2._clear_prf_cache()
-
- def tearDown(self):
- if not self.enable_m2crypto:
- pbkdf2._EVP = self._orig_EVP
- pbkdf2._clear_prf_cache()
-
- #TODO: test get_prf() behavior in various situations - though overall behavior tested via pbkdf2
-
- def test_rfc3962(self):
- "rfc3962 test vectors"
- self.assertFunctionResults(pbkdf2.pbkdf2, [
- # result, secret, salt, rounds, keylen, digest="sha1"
-
- #test case 1 / 128 bit
- (
- hb("cdedb5281bb2f801565a1122b2563515"),
- b("password"), b("ATHENA.MIT.EDUraeburn"), 1, 16
- ),
-
- #test case 2 / 128 bit
- (
- hb("01dbee7f4a9e243e988b62c73cda935d"),
- b("password"), b("ATHENA.MIT.EDUraeburn"), 2, 16
- ),
-
- #test case 2 / 256 bit
- (
- hb("01dbee7f4a9e243e988b62c73cda935da05378b93244ec8f48a99e61ad799d86"),
- b("password"), b("ATHENA.MIT.EDUraeburn"), 2, 32
- ),
-
- #test case 3 / 256 bit
- (
- hb("5c08eb61fdf71e4e4ec3cf6ba1f5512ba7e52ddbc5e5142f708a31e2e62b1e13"),
- b("password"), b("ATHENA.MIT.EDUraeburn"), 1200, 32
- ),
-
- #test case 4 / 256 bit
- (
- hb("d1daa78615f287e6a1c8b120d7062a493f98d203e6be49a6adf4fa574b6e64ee"),
- b("password"), b('\x12\x34\x56\x78\x78\x56\x34\x12'), 5, 32
- ),
-
- #test case 5 / 256 bit
- (
- hb("139c30c0966bc32ba55fdbf212530ac9c5ec59f1a452f5cc9ad940fea0598ed1"),
- b("X"*64), b("pass phrase equals block size"), 1200, 32
- ),
-
- #test case 6 / 256 bit
- (
- hb("9ccad6d468770cd51b10e6a68721be611a8b4d282601db3b36be9246915ec82a"),
- b("X"*65), b("pass phrase exceeds block size"), 1200, 32
- ),
- ])
-
- def test_rfc6070(self):
- "rfc6070 test vectors"
- self.assertFunctionResults(pbkdf2.pbkdf2, [
-
- (
- hb("0c60c80f961f0e71f3a9b524af6012062fe037a6"),
- b("password"), b("salt"), 1, 20,
- ),
-
- (
- hb("ea6c014dc72d6f8ccd1ed92ace1d41f0d8de8957"),
- b("password"), b("salt"), 2, 20,
- ),
-
- (
- hb("4b007901b765489abead49d926f721d065a429c1"),
- b("password"), b("salt"), 4096, 20,
- ),
-
- #just runs too long - could enable if ALL option is set
- ##(
- ##
- ## unhexlify("eefe3d61cd4da4e4e9945b3d6ba2158c2634e984"),
- ## "password", "salt", 16777216, 20,
- ##),
-
- (
- hb("3d2eec4fe41c849b80c8d83662c0e44a8b291a964cf2f07038"),
- b("passwordPASSWORDpassword"),
- b("saltSALTsaltSALTsaltSALTsaltSALTsalt"),
- 4096, 25,
- ),
-
- (
- hb("56fa6aa75548099dcc37d7f03425e0c3"),
- b("pass\00word"), b("sa\00lt"), 4096, 16,
- ),
- ])
-
- def test_invalid_values(self):
-
- #invalid rounds
- self.assertRaises(ValueError, pbkdf2.pbkdf2, b('password'), b('salt'), -1, 16)
- self.assertRaises(ValueError, pbkdf2.pbkdf2, b('password'), b('salt'), 0, 16)
- self.assertRaises(TypeError, pbkdf2.pbkdf2, b('password'), b('salt'), 'x', 16)
-
- #invalid keylen
- self.assertRaises(ValueError, pbkdf2.pbkdf2, b('password'), b('salt'),
- 1, 20*(2**32-1)+1)
-
- #invalid salt type
- self.assertRaises(TypeError, pbkdf2.pbkdf2, b('password'), 5, 1, 10)
-
- #invalid secret type
- self.assertRaises(TypeError, pbkdf2.pbkdf2, 5, b('salt'), 1, 10)
-
- #invalid hash
- self.assertRaises(ValueError, pbkdf2.pbkdf2, b('password'), b('salt'), 1, 16, 'hmac-foo')
- self.assertRaises(ValueError, pbkdf2.pbkdf2, b('password'), b('salt'), 1, 16, 'foo')
- self.assertRaises(TypeError, pbkdf2.pbkdf2, b('password'), b('salt'), 1, 16, 5)
-
- def test_default_keylen(self):
- "test keylen==-1"
- self.assertEqual(len(pbkdf2.pbkdf2(b('password'), b('salt'), 1, -1,
- prf='hmac-sha1')), 20)
-
- self.assertEqual(len(pbkdf2.pbkdf2(b('password'), b('salt'), 1, -1,
- prf='hmac-sha256')), 32)
-
- def test_hmac_sha1(self):
- "test independant hmac_sha1() method"
- self.assertEqual(
- pbkdf2.hmac_sha1(b("secret"), b("salt")),
- b('\xfc\xd4\x0c;]\r\x97\xc6\xf1S\x8d\x93\xb9\xeb\xc6\x00\x04.\x8b\xfe')
- )
-
- def test_sha1_string(self):
- "test various prf values"
- self.assertEqual(
- pbkdf2.pbkdf2(b("secret"), b("salt"), 10, 16, "hmac-sha1"),
- b('\xe2H\xfbk\x136QF\xf8\xacc\x07\xcc"(\x12')
- )
-
- def test_sha512_string(self):
- "test alternate digest string (sha512)"
- self.assertFunctionResults(pbkdf2.pbkdf2, [
- # result, secret, salt, rounds, keylen, digest="sha1"
-
- #case taken from example in http://grub.enbug.org/Authentication
- (
- hb("887CFF169EA8335235D8004242AA7D6187A41E3187DF0CE14E256D85ED97A97357AAA8FF0A3871AB9EEFF458392F462F495487387F685B7472FC6C29E293F0A0"),
- b("hello"),
- hb("9290F727ED06C38BA4549EF7DE25CF5642659211B7FC076F2D28FEFD71784BB8D8F6FB244A8CC5C06240631B97008565A120764C0EE9C2CB0073994D79080136"),
- 10000, 64, "hmac-sha512"
- ),
- ])
-
- def test_sha512_function(self):
- "test custom digest function"
- def prf(key, msg):
- return hmac.new(key, msg, hashlib.sha512).digest()
-
- self.assertFunctionResults(pbkdf2.pbkdf2, [
- # result, secret, salt, rounds, keylen, digest="sha1"
-
- #case taken from example in http://grub.enbug.org/Authentication
- (
- hb("887CFF169EA8335235D8004242AA7D6187A41E3187DF0CE14E256D85ED97A97357AAA8FF0A3871AB9EEFF458392F462F495487387F685B7472FC6C29E293F0A0"),
- b("hello"),
- hb("9290F727ED06C38BA4549EF7DE25CF5642659211B7FC076F2D28FEFD71784BB8D8F6FB244A8CC5C06240631B97008565A120764C0EE9C2CB0073994D79080136"),
- 10000, 64, prf,
- ),
- ])
-
-has_m2crypto = (pbkdf2._EVP is not None)
-
-if has_m2crypto:
- class Pbkdf2_M2Crypto_Test(_Pbkdf2BackendTest):
- descriptionPrefix = "pbkdf2 (m2crypto backend)"
- enable_m2crypto = True
-
-if not has_m2crypto or enable_option("cover"):
- class Pbkdf2_Builtin_Test(_Pbkdf2BackendTest):
- descriptionPrefix = "pbkdf2 (builtin backend)"
- enable_m2crypto = False
-
-#=========================================================
#EOF
#=========================================================
diff --git a/passlib/tests/test_utils_crypto.py b/passlib/tests/test_utils_crypto.py
new file mode 100644
index 0000000..94c20e8
--- /dev/null
+++ b/passlib/tests/test_utils_crypto.py
@@ -0,0 +1,550 @@
+"""tests for passlib.utils.(des|pbkdf2|md4)"""
+#=========================================================
+#imports
+#=========================================================
+from __future__ import with_statement
+#core
+from binascii import hexlify, unhexlify
+import sys
+import random
+import warnings
+#site
+#pkg
+#module
+from passlib.utils.compat import b, bytes, bascii_to_str, irange, PY2, PY3, u, \
+ unicode, join_bytes
+from passlib.tests.utils import TestCase, Params as ak, enable_option, catch_warnings
+
+#=========================================================
+# support
+#=========================================================
+def hb(source):
+ return unhexlify(b(source))
+
+#=========================================================
+#test des module
+#=========================================================
+class DesTest(TestCase):
+
+ # test vectors taken from http://www.skepticfiles.org/faq/testdes.htm
+ des_test_vectors = [
+ # key, plaintext, ciphertext
+ (0x0000000000000000, 0x0000000000000000, 0x8CA64DE9C1B123A7),
+ (0xFFFFFFFFFFFFFFFF, 0xFFFFFFFFFFFFFFFF, 0x7359B2163E4EDC58),
+ (0x3000000000000000, 0x1000000000000001, 0x958E6E627A05557B),
+ (0x1111111111111111, 0x1111111111111111, 0xF40379AB9E0EC533),
+ (0x0123456789ABCDEF, 0x1111111111111111, 0x17668DFC7292532D),
+ (0x1111111111111111, 0x0123456789ABCDEF, 0x8A5AE1F81AB8F2DD),
+ (0x0000000000000000, 0x0000000000000000, 0x8CA64DE9C1B123A7),
+ (0xFEDCBA9876543210, 0x0123456789ABCDEF, 0xED39D950FA74BCC4),
+ (0x7CA110454A1A6E57, 0x01A1D6D039776742, 0x690F5B0D9A26939B),
+ (0x0131D9619DC1376E, 0x5CD54CA83DEF57DA, 0x7A389D10354BD271),
+ (0x07A1133E4A0B2686, 0x0248D43806F67172, 0x868EBB51CAB4599A),
+ (0x3849674C2602319E, 0x51454B582DDF440A, 0x7178876E01F19B2A),
+ (0x04B915BA43FEB5B6, 0x42FD443059577FA2, 0xAF37FB421F8C4095),
+ (0x0113B970FD34F2CE, 0x059B5E0851CF143A, 0x86A560F10EC6D85B),
+ (0x0170F175468FB5E6, 0x0756D8E0774761D2, 0x0CD3DA020021DC09),
+ (0x43297FAD38E373FE, 0x762514B829BF486A, 0xEA676B2CB7DB2B7A),
+ (0x07A7137045DA2A16, 0x3BDD119049372802, 0xDFD64A815CAF1A0F),
+ (0x04689104C2FD3B2F, 0x26955F6835AF609A, 0x5C513C9C4886C088),
+ (0x37D06BB516CB7546, 0x164D5E404F275232, 0x0A2AEEAE3FF4AB77),
+ (0x1F08260D1AC2465E, 0x6B056E18759F5CCA, 0xEF1BF03E5DFA575A),
+ (0x584023641ABA6176, 0x004BD6EF09176062, 0x88BF0DB6D70DEE56),
+ (0x025816164629B007, 0x480D39006EE762F2, 0xA1F9915541020B56),
+ (0x49793EBC79B3258F, 0x437540C8698F3CFA, 0x6FBF1CAFCFFD0556),
+ (0x4FB05E1515AB73A7, 0x072D43A077075292, 0x2F22E49BAB7CA1AC),
+ (0x49E95D6D4CA229BF, 0x02FE55778117F12A, 0x5A6B612CC26CCE4A),
+ (0x018310DC409B26D6, 0x1D9D5C5018F728C2, 0x5F4C038ED12B2E41),
+ (0x1C587F1C13924FEF, 0x305532286D6F295A, 0x63FAC0D034D9F793),
+ (0x0101010101010101, 0x0123456789ABCDEF, 0x617B3A0CE8F07100),
+ (0x1F1F1F1F0E0E0E0E, 0x0123456789ABCDEF, 0xDB958605F8C8C606),
+ (0xE0FEE0FEF1FEF1FE, 0x0123456789ABCDEF, 0xEDBFD1C66C29CCC7),
+ (0x0000000000000000, 0xFFFFFFFFFFFFFFFF, 0x355550B2150E2451),
+ (0xFFFFFFFFFFFFFFFF, 0x0000000000000000, 0xCAAAAF4DEAF1DBAE),
+ (0x0123456789ABCDEF, 0x0000000000000000, 0xD5D44FF720683D0D),
+ (0xFEDCBA9876543210, 0xFFFFFFFFFFFFFFFF, 0x2A2BB008DF97C2F2),
+ ]
+
+ def test_01_expand(self):
+ "test expand_des_key()"
+ from passlib.utils.des import expand_des_key, shrink_des_key, \
+ _KDATA_MASK, INT_56_MASK
+
+ # make sure test vectors are preserved (sans parity bits)
+ # uses ints, bytes are tested under #02
+ for key1, _, _ in self.des_test_vectors:
+ key2 = shrink_des_key(key1)
+ key3 = expand_des_key(key2)
+ # NOTE: this assumes expand_des_key() sets parity bits to 0
+ self.assertEqual(key3, key1 & _KDATA_MASK)
+
+ # type checks
+ self.assertRaises(TypeError, expand_des_key, 1.0)
+
+ # too large
+ self.assertRaises(ValueError, expand_des_key, INT_56_MASK+1)
+ self.assertRaises(ValueError, expand_des_key, b("\x00")*8)
+
+ # too small
+ self.assertRaises(ValueError, expand_des_key, -1)
+ self.assertRaises(ValueError, expand_des_key, b("\x00")*6)
+
+ def test_02_shrink(self):
+ "test shrink_des_key()"
+ from passlib.utils.des import expand_des_key, shrink_des_key, \
+ INT_64_MASK
+ from passlib.utils import random, getrandbytes
+
+ # make sure reverse works for some random keys
+ # uses bytes, ints are tested under #01
+ for i in range(20):
+ key1 = getrandbytes(random, 7)
+ key2 = expand_des_key(key1)
+ key3 = shrink_des_key(key2)
+ self.assertEqual(key3, key1)
+
+ # type checks
+ self.assertRaises(TypeError, shrink_des_key, 1.0)
+
+ # too large
+ self.assertRaises(ValueError, shrink_des_key, INT_64_MASK+1)
+ self.assertRaises(ValueError, shrink_des_key, b("\x00")*9)
+
+ # too small
+ self.assertRaises(ValueError, shrink_des_key, -1)
+ self.assertRaises(ValueError, shrink_des_key, b("\x00")*7)
+
+ def _random_parity(self, key):
+ "randomize parity bits"
+ from passlib.utils.des import _KDATA_MASK, _KPARITY_MASK, INT_64_MASK
+ from passlib.utils import rng
+ return (key & _KDATA_MASK) | (rng.randint(0,INT_64_MASK) & _KPARITY_MASK)
+
+ def test_03_encrypt_bytes(self):
+ "test des_encrypt_block()"
+ from passlib.utils.des import (des_encrypt_block, shrink_des_key,
+ _pack64, _unpack64)
+
+ # run through test vectors
+ for key, plaintext, correct in self.des_test_vectors:
+ # convert to bytes
+ key = _pack64(key)
+ plaintext = _pack64(plaintext)
+ correct = _pack64(correct)
+
+ # test 64-bit key
+ result = des_encrypt_block(key, plaintext)
+ self.assertEqual(result, correct, "key=%r plaintext=%r:" %
+ (key, plaintext))
+
+ # test 56-bit version
+ key2 = shrink_des_key(key)
+ result = des_encrypt_block(key2, plaintext)
+ self.assertEqual(result, correct, "key=%r shrink(key)=%r plaintext=%r:" %
+ (key, key2, plaintext))
+
+ # test with random parity bits
+ for _ in range(20):
+ key3 = _pack64(self._random_parity(_unpack64(key)))
+ result = des_encrypt_block(key3, plaintext)
+ self.assertEqual(result, correct, "key=%r rndparity(key)=%r plaintext=%r:" %
+ (key, key3, plaintext))
+
+ # check invalid keys
+ stub = b('\x00') * 8
+ self.assertRaises(TypeError, des_encrypt_block, 0, stub)
+ self.assertRaises(ValueError, des_encrypt_block, b('\x00')*6, stub)
+
+ # check invalid input
+ self.assertRaises(TypeError, des_encrypt_block, stub, 0)
+ self.assertRaises(ValueError, des_encrypt_block, stub, b('\x00')*7)
+
+ # check invalid salts
+ self.assertRaises(ValueError, des_encrypt_block, stub, stub, salt=-1)
+ self.assertRaises(ValueError, des_encrypt_block, stub, stub, salt=1<<24)
+
+ # check invalid rounds
+ self.assertRaises(ValueError, des_encrypt_block, stub, stub, 0, rounds=0)
+
+ def test_04_encrypt_ints(self):
+ "test des_encrypt_int_block()"
+ from passlib.utils.des import (des_encrypt_int_block, shrink_des_key)
+
+ # run through test vectors
+ for key, plaintext, correct in self.des_test_vectors:
+ # test 64-bit key
+ result = des_encrypt_int_block(key, plaintext)
+ self.assertEqual(result, correct, "key=%r plaintext=%r:" %
+ (key, plaintext))
+
+ # test with random parity bits
+ for _ in range(20):
+ key3 = self._random_parity(key)
+ result = des_encrypt_int_block(key3, plaintext)
+ self.assertEqual(result, correct, "key=%r rndparity(key)=%r plaintext=%r:" %
+ (key, key3, plaintext))
+
+ # check invalid keys
+ self.assertRaises(TypeError, des_encrypt_int_block, b('\x00'), 0)
+ self.assertRaises(ValueError, des_encrypt_int_block, -1, 0)
+
+ # check invalid input
+ self.assertRaises(TypeError, des_encrypt_int_block, 0, b('\x00'))
+ self.assertRaises(ValueError, des_encrypt_int_block, 0, -1)
+
+ # check invalid salts
+ self.assertRaises(ValueError, des_encrypt_int_block, 0, 0, salt=-1)
+ self.assertRaises(ValueError, des_encrypt_int_block, 0, 0, salt=1<<24)
+
+ # check invalid rounds
+ self.assertRaises(ValueError, des_encrypt_int_block, 0, 0, 0, rounds=0)
+
+#=========================================================
+#test md4
+#=========================================================
+class _MD4_Test(TestCase):
+ #test vectors from http://www.faqs.org/rfcs/rfc1320.html - A.5
+
+ hash = None
+
+ vectors = [
+ # input -> hex digest
+ (b(""), "31d6cfe0d16ae931b73c59d7e0c089c0"),
+ (b("a"), "bde52cb31de33e46245e05fbdbd6fb24"),
+ (b("abc"), "a448017aaf21d8525fc10ae87aa6729d"),
+ (b("message digest"), "d9130a8164549fe818874806e1c7014b"),
+ (b("abcdefghijklmnopqrstuvwxyz"), "d79e1c308aa5bbcdeea8ed63df412da9"),
+ (b("ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"), "043f8582f241db351ce627e153e7f0e4"),
+ (b("12345678901234567890123456789012345678901234567890123456789012345678901234567890"), "e33b4ddc9c38f2199c3e7b164fcc0536"),
+ ]
+
+ def test_md4_update(self):
+ "test md4 update"
+ md4 = self.hash
+ h = md4(b(''))
+ self.assertEqual(h.hexdigest(), "31d6cfe0d16ae931b73c59d7e0c089c0")
+
+ #NOTE: under py2, hashlib methods try to encode to ascii,
+ # though shouldn't rely on that.
+ if PY3:
+ self.assertRaises(TypeError, h.update, u('x'))
+
+ h.update(b('a'))
+ self.assertEqual(h.hexdigest(), "bde52cb31de33e46245e05fbdbd6fb24")
+
+ h.update(b('bcdefghijklmnopqrstuvwxyz'))
+ self.assertEqual(h.hexdigest(), "d79e1c308aa5bbcdeea8ed63df412da9")
+
+ def test_md4_hexdigest(self):
+ "test md4 hexdigest()"
+ md4 = self.hash
+ for input, hex in self.vectors:
+ out = md4(input).hexdigest()
+ self.assertEqual(out, hex)
+
+ def test_md4_digest(self):
+ "test md4 digest()"
+ md4 = self.hash
+ for input, hex in self.vectors:
+ out = bascii_to_str(hexlify(md4(input).digest()))
+ self.assertEqual(out, hex)
+
+ def test_md4_copy(self):
+ "test md4 copy()"
+ md4 = self.hash
+ h = md4(b('abc'))
+
+ h2 = h.copy()
+ h2.update(b('def'))
+ self.assertEqual(h2.hexdigest(), '804e7f1c2586e50b49ac65db5b645131')
+
+ h.update(b('ghi'))
+ self.assertEqual(h.hexdigest(), 'c5225580bfe176f6deeee33dee98732c')
+
+#
+#now do a bunch of things to test multiple possible backends.
+#
+import passlib.utils.md4 as md4_mod
+
+has_ssl_md4 = (md4_mod.md4 is not md4_mod._builtin_md4)
+
+if has_ssl_md4:
+ class MD4_SSL_Test(_MD4_Test):
+ descriptionPrefix = "MD4 (SSL version)"
+ hash = staticmethod(md4_mod.md4)
+
+if not has_ssl_md4 or enable_option("cover"):
+ class MD4_Builtin_Test(_MD4_Test):
+ descriptionPrefix = "MD4 (builtin version)"
+ hash = md4_mod._builtin_md4
+
+#=========================================================
+#test passlib.utils.pbkdf2
+#=========================================================
+import hashlib
+import hmac
+from passlib.utils import pbkdf2
+
+#TODO: should we bother testing hmac_sha1() function? it's verified via sha1_crypt testing.
+class CryptoTest(TestCase):
+ "test various crypto functions"
+
+ ndn_formats = ["hashlib", "iana"]
+ ndn_values = [
+ # (iana name, hashlib name, ... other unnormalized names)
+ ("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"),
+ ("test128", "test-128", "TEST128"),
+ ("test2", "test2", "TEST-2"),
+ ("test3128", "test3-128", "TEST-3-128"),
+ ]
+
+ def test_norm_hash_name(self):
+ "test norm_hash_name()"
+ from itertools import chain
+ from passlib.utils.pbkdf2 import norm_hash_name, _nhn_hash_names
+
+ # test formats
+ for format in self.ndn_formats:
+ norm_hash_name("md4", format)
+ self.assertRaises(ValueError, norm_hash_name, "md4", None)
+ self.assertRaises(ValueError, norm_hash_name, "md4", "fake")
+
+ # test types
+ self.assertEqual(norm_hash_name(u("MD4")), "md4")
+ self.assertEqual(norm_hash_name(b("MD4")), "md4")
+ self.assertRaises(TypeError, norm_hash_name, None)
+
+ # test selected results
+ with catch_warnings():
+ warnings.filterwarnings("ignore", '.*unknown hash')
+ for row in chain(_nhn_hash_names, self.ndn_values):
+ for idx, format in enumerate(self.ndn_formats):
+ correct = row[idx]
+ for value in row:
+ result = norm_hash_name(value, format)
+ self.assertEqual(result, correct,
+ "name=%r, format=%r:" % (value,
+ format))
+
+class KdfTest(TestCase):
+ "test kdf helpers"
+
+ def test_pbkdf1(self):
+ "test pbkdf1"
+ for secret, salt, rounds, klen, hash, correct in [
+ #http://www.di-mgt.com.au/cryptoKDFs.html
+ (b('password'), hb('78578E5A5D63CB06'), 1000, 16, 'sha1',
+ hb('dc19847e05c64d2faf10ebfb4a3d2a20')),
+ ]:
+ result = pbkdf2.pbkdf1(secret, salt, rounds, klen, hash)
+ self.assertEqual(result, correct)
+
+ #test rounds < 1
+ #test klen < 0
+ #test klen > block size
+ #test invalid hash
+
+#NOTE: this is not run directly, but via two subclasses (below)
+class _Pbkdf2BackendTest(TestCase):
+ "test builtin unix crypt backend"
+ enable_m2crypto = False
+
+ def setUp(self):
+ #disable m2crypto support so we'll always use software backend
+ if not self.enable_m2crypto:
+ self._orig_EVP = pbkdf2._EVP
+ pbkdf2._EVP = None
+ else:
+ #set flag so tests can check for m2crypto presence quickly
+ self.enable_m2crypto = bool(pbkdf2._EVP)
+ pbkdf2._clear_prf_cache()
+
+ def tearDown(self):
+ if not self.enable_m2crypto:
+ pbkdf2._EVP = self._orig_EVP
+ pbkdf2._clear_prf_cache()
+
+ #TODO: test get_prf() behavior in various situations - though overall behavior tested via pbkdf2
+
+ def test_rfc3962(self):
+ "rfc3962 test vectors"
+ self.assertFunctionResults(pbkdf2.pbkdf2, [
+ # result, secret, salt, rounds, keylen, digest="sha1"
+
+ #test case 1 / 128 bit
+ (
+ hb("cdedb5281bb2f801565a1122b2563515"),
+ b("password"), b("ATHENA.MIT.EDUraeburn"), 1, 16
+ ),
+
+ #test case 2 / 128 bit
+ (
+ hb("01dbee7f4a9e243e988b62c73cda935d"),
+ b("password"), b("ATHENA.MIT.EDUraeburn"), 2, 16
+ ),
+
+ #test case 2 / 256 bit
+ (
+ hb("01dbee7f4a9e243e988b62c73cda935da05378b93244ec8f48a99e61ad799d86"),
+ b("password"), b("ATHENA.MIT.EDUraeburn"), 2, 32
+ ),
+
+ #test case 3 / 256 bit
+ (
+ hb("5c08eb61fdf71e4e4ec3cf6ba1f5512ba7e52ddbc5e5142f708a31e2e62b1e13"),
+ b("password"), b("ATHENA.MIT.EDUraeburn"), 1200, 32
+ ),
+
+ #test case 4 / 256 bit
+ (
+ hb("d1daa78615f287e6a1c8b120d7062a493f98d203e6be49a6adf4fa574b6e64ee"),
+ b("password"), b('\x12\x34\x56\x78\x78\x56\x34\x12'), 5, 32
+ ),
+
+ #test case 5 / 256 bit
+ (
+ hb("139c30c0966bc32ba55fdbf212530ac9c5ec59f1a452f5cc9ad940fea0598ed1"),
+ b("X"*64), b("pass phrase equals block size"), 1200, 32
+ ),
+
+ #test case 6 / 256 bit
+ (
+ hb("9ccad6d468770cd51b10e6a68721be611a8b4d282601db3b36be9246915ec82a"),
+ b("X"*65), b("pass phrase exceeds block size"), 1200, 32
+ ),
+ ])
+
+ def test_rfc6070(self):
+ "rfc6070 test vectors"
+ self.assertFunctionResults(pbkdf2.pbkdf2, [
+
+ (
+ hb("0c60c80f961f0e71f3a9b524af6012062fe037a6"),
+ b("password"), b("salt"), 1, 20,
+ ),
+
+ (
+ hb("ea6c014dc72d6f8ccd1ed92ace1d41f0d8de8957"),
+ b("password"), b("salt"), 2, 20,
+ ),
+
+ (
+ hb("4b007901b765489abead49d926f721d065a429c1"),
+ b("password"), b("salt"), 4096, 20,
+ ),
+
+ #just runs too long - could enable if ALL option is set
+ ##(
+ ##
+ ## unhexlify("eefe3d61cd4da4e4e9945b3d6ba2158c2634e984"),
+ ## "password", "salt", 16777216, 20,
+ ##),
+
+ (
+ hb("3d2eec4fe41c849b80c8d83662c0e44a8b291a964cf2f07038"),
+ b("passwordPASSWORDpassword"),
+ b("saltSALTsaltSALTsaltSALTsaltSALTsalt"),
+ 4096, 25,
+ ),
+
+ (
+ hb("56fa6aa75548099dcc37d7f03425e0c3"),
+ b("pass\00word"), b("sa\00lt"), 4096, 16,
+ ),
+ ])
+
+ def test_invalid_values(self):
+
+ #invalid rounds
+ self.assertRaises(ValueError, pbkdf2.pbkdf2, b('password'), b('salt'), -1, 16)
+ self.assertRaises(ValueError, pbkdf2.pbkdf2, b('password'), b('salt'), 0, 16)
+ self.assertRaises(TypeError, pbkdf2.pbkdf2, b('password'), b('salt'), 'x', 16)
+
+ #invalid keylen
+ self.assertRaises(ValueError, pbkdf2.pbkdf2, b('password'), b('salt'),
+ 1, 20*(2**32-1)+1)
+
+ #invalid salt type
+ self.assertRaises(TypeError, pbkdf2.pbkdf2, b('password'), 5, 1, 10)
+
+ #invalid secret type
+ self.assertRaises(TypeError, pbkdf2.pbkdf2, 5, b('salt'), 1, 10)
+
+ #invalid hash
+ self.assertRaises(ValueError, pbkdf2.pbkdf2, b('password'), b('salt'), 1, 16, 'hmac-foo')
+ self.assertRaises(ValueError, pbkdf2.pbkdf2, b('password'), b('salt'), 1, 16, 'foo')
+ self.assertRaises(TypeError, pbkdf2.pbkdf2, b('password'), b('salt'), 1, 16, 5)
+
+ def test_default_keylen(self):
+ "test keylen==-1"
+ self.assertEqual(len(pbkdf2.pbkdf2(b('password'), b('salt'), 1, -1,
+ prf='hmac-sha1')), 20)
+
+ self.assertEqual(len(pbkdf2.pbkdf2(b('password'), b('salt'), 1, -1,
+ prf='hmac-sha256')), 32)
+
+ def test_hmac_sha1(self):
+ "test independant hmac_sha1() method"
+ self.assertEqual(
+ pbkdf2.hmac_sha1(b("secret"), b("salt")),
+ b('\xfc\xd4\x0c;]\r\x97\xc6\xf1S\x8d\x93\xb9\xeb\xc6\x00\x04.\x8b\xfe')
+ )
+
+ def test_sha1_string(self):
+ "test various prf values"
+ self.assertEqual(
+ pbkdf2.pbkdf2(b("secret"), b("salt"), 10, 16, "hmac-sha1"),
+ b('\xe2H\xfbk\x136QF\xf8\xacc\x07\xcc"(\x12')
+ )
+
+ def test_sha512_string(self):
+ "test alternate digest string (sha512)"
+ self.assertFunctionResults(pbkdf2.pbkdf2, [
+ # result, secret, salt, rounds, keylen, digest="sha1"
+
+ #case taken from example in http://grub.enbug.org/Authentication
+ (
+ hb("887CFF169EA8335235D8004242AA7D6187A41E3187DF0CE14E256D85ED97A97357AAA8FF0A3871AB9EEFF458392F462F495487387F685B7472FC6C29E293F0A0"),
+ b("hello"),
+ hb("9290F727ED06C38BA4549EF7DE25CF5642659211B7FC076F2D28FEFD71784BB8D8F6FB244A8CC5C06240631B97008565A120764C0EE9C2CB0073994D79080136"),
+ 10000, 64, "hmac-sha512"
+ ),
+ ])
+
+ def test_sha512_function(self):
+ "test custom digest function"
+ def prf(key, msg):
+ return hmac.new(key, msg, hashlib.sha512).digest()
+
+ self.assertFunctionResults(pbkdf2.pbkdf2, [
+ # result, secret, salt, rounds, keylen, digest="sha1"
+
+ #case taken from example in http://grub.enbug.org/Authentication
+ (
+ hb("887CFF169EA8335235D8004242AA7D6187A41E3187DF0CE14E256D85ED97A97357AAA8FF0A3871AB9EEFF458392F462F495487387F685B7472FC6C29E293F0A0"),
+ b("hello"),
+ hb("9290F727ED06C38BA4549EF7DE25CF5642659211B7FC076F2D28FEFD71784BB8D8F6FB244A8CC5C06240631B97008565A120764C0EE9C2CB0073994D79080136"),
+ 10000, 64, prf,
+ ),
+ ])
+
+has_m2crypto = (pbkdf2._EVP is not None)
+
+if has_m2crypto:
+ class Pbkdf2_M2Crypto_Test(_Pbkdf2BackendTest):
+ descriptionPrefix = "pbkdf2 (m2crypto backend)"
+ enable_m2crypto = True
+
+if not has_m2crypto or enable_option("cover"):
+ class Pbkdf2_Builtin_Test(_Pbkdf2BackendTest):
+ descriptionPrefix = "pbkdf2 (builtin backend)"
+ enable_m2crypto = False
+
+#=========================================================
+#EOF
+#=========================================================
diff --git a/passlib/tests/tox_support.py b/passlib/tests/tox_support.py
new file mode 100644
index 0000000..7da0546
--- /dev/null
+++ b/passlib/tests/tox_support.py
@@ -0,0 +1,37 @@
+"""passlib.tests.tox_support - helper script for tox tests"""
+#=============================================================================
+# imports
+#=============================================================================
+# core
+import os
+import logging; log = logging.getLogger(__name__)
+# site
+# pkg
+# local
+__all__ = [
+]
+
+#=============================================================================
+# main
+#=============================================================================
+def main(path, runtime):
+ "write fake GAE ``app.yaml`` to current directory so nosegae will work"
+ from passlib.tests.utils import set_file
+ set_file(os.path.join(path, "app.yaml"), """\
+application: fake-app
+version: 2
+runtime: %s
+api_version: 1
+
+handlers:
+- url: /.*
+ script: dummy.py
+""" % runtime)
+
+if __name__ == "__main__":
+ import sys
+ sys.exit(main(*sys.argv[1:]) or 0)
+
+#=============================================================================
+# eof
+#=============================================================================
diff --git a/passlib/tests/utils.py b/passlib/tests/utils.py
index 713824e..5cbb427 100644
--- a/passlib/tests/utils.py
+++ b/passlib/tests/utils.py
@@ -42,7 +42,7 @@ from passlib.utils import has_rounds_info, has_salt_info, rounds_cost_values, \
classproperty, rng, getrandstr, is_ascii_safe, to_native_str, \
repeat_string
from passlib.utils.compat import b, bytes, iteritems, irange, callable, \
- base_string_types, exc_err, u, unicode
+ base_string_types, exc_err, u, unicode, PY2
import passlib.utils.handlers as uh
#local
__all__ = [
@@ -134,6 +134,18 @@ def get_file(path):
with open(path, "rb") as fh:
return fh.read()
+def tonn(source):
+ "convert native string to non-native string"
+ if not isinstance(source, str):
+ return source
+ elif PY3:
+ return source.encode("utf-8")
+ else:
+ try:
+ return source.decode("utf-8")
+ except UnicodeDecodeError:
+ return source.decode("latin-1")
+
#=========================================================
#custom test base
#=========================================================
@@ -493,6 +505,13 @@ class TestCase(unittest.TestCase):
msg = "error for case %r:" % (elem.render(1),)
self.assertEqual(result, correct, msg)
+ def require_stringprep(self):
+ "helper to skip test if stringprep is missing"
+ from passlib.utils import stringprep
+ if not stringprep:
+ from passlib.utils import _stringprep_missing_reason
+ raise self.skipTest("not available - stringprep module is " +
+ _stringprep_missing_reason)
#============================================================
#eoc
#============================================================
@@ -741,7 +760,7 @@ class HandlerCase(TestCase):
# XXX: any more checks needed?
- def test_02_config(self):
+ def test_02_config_workflow(self):
"""test basic config-string workflow
this tests that genconfig() returns the expected types,
@@ -785,7 +804,7 @@ class HandlerCase(TestCase):
else:
self.assertRaises(TypeError, self.do_identify, config)
- def test_03_hash(self):
+ def test_03_hash_workflow(self):
"""test basic hash-string workflow.
this tests that encrypt()'s hashes are accepted
@@ -835,8 +854,36 @@ class HandlerCase(TestCase):
#
self.assertTrue(self.do_identify(result))
+ def test_04_hash_types(self):
+ "test hashes can be unicode or bytes"
+ # this runs through workflow similar to 03, but wraps
+ # everything using tonn() so we test unicode under py2,
+ # and bytes under py3.
+
+ # encrypt using non-native secret
+ result = self.do_encrypt(tonn('stub'))
+ self.check_returned_native_str(result, "encrypt")
+
+ # verify using non-native hash
+ self.check_verify('stub', tonn(result))
+
+ # verify using non-native hash AND secret
+ self.check_verify(tonn('stub'), tonn(result))
- def test_04_backends(self):
+ # genhash using non-native hash
+ other = self.do_genhash('stub', tonn(result))
+ self.check_returned_native_str(other, "genhash")
+ self.assertEqual(other, result)
+
+ # genhash using non-native hash AND secret
+ other = self.do_genhash(tonn('stub'), tonn(result))
+ self.check_returned_native_str(other, "genhash")
+ self.assertEqual(other, result)
+
+ # identify using non-native hash
+ self.assertTrue(self.do_identify(tonn(result)))
+
+ def test_05_backends(self):
"test multi-backend support"
handler = self.handler
if not hasattr(handler, "set_backend"):
@@ -1065,6 +1112,34 @@ class HandlerCase(TestCase):
self.assertRaises(ValueError, self.do_genconfig, salt=c*chunk,
__msg__="invalid salt char %r:" % (c,))
+ @property
+ def salt_type(self):
+ "hack to determine salt keyword's datatype"
+ # NOTE: cisco_type7 uses 'int'
+ if getattr(self.handler, "_salt_is_bytes", False):
+ return bytes
+ else:
+ return unicode
+
+ def test_15_salt_type(self):
+ "test non-string salt values"
+ self.require_salt()
+ salt_type = self.salt_type
+
+ # should always throw error for random class.
+ class fake(object):
+ pass
+ self.assertRaises(TypeError, self.do_encrypt, 'stub', salt=fake())
+
+ # unicode should be accepted only if salt_type is unicode.
+ if salt_type is not unicode:
+ self.assertRaises(TypeError, self.do_encrypt, 'stub', salt=u('x'))
+
+ # bytes should be accepted only if salt_type is bytes,
+ # OR if salt type is unicode and running PY2 - to allow native strings.
+ if not (salt_type is bytes or (PY2 and salt_type is unicode)):
+ self.assertRaises(TypeError, self.do_encrypt, 'stub', salt=b('x'))
+
#==============================================================
# rounds
#==============================================================
@@ -1561,12 +1636,15 @@ class HandlerCase(TestCase):
# run through all verifiers we found.
for verify in verifiers:
name = vname(verify)
-
- if not verify(secret, hash, **ctx):
+ result = verify(secret, hash, **ctx)
+ if result == "skip": # let verifiers signal lack of support
+ continue
+ assert result is True or result is False
+ if not result:
raise self.failureException("failed to verify against %s: "
"secret=%r config=%r hash=%r" %
(name, secret, kwds, hash))
- # occasionally check that some other secret WON'T verify
+ # occasionally check that some other secrets WON'T verify
# against this hash.
if rng.random() < .1 and verify(other, hash, **ctx):
raise self.failureException("was able to verify wrong "
@@ -1588,13 +1666,16 @@ class HandlerCase(TestCase):
handler = self.handler
verifiers = []
- # test against self
- def check_default(secret, hash, **ctx):
- "self"
- return self.do_verify(secret, hash, **ctx)
- verifiers.append(check_default)
+ # call all methods starting with prefix in order to create
+ # any verifiers.
+ prefix = "fuzz_verifier_"
+ for name in dir(self):
+ if name.startswith(prefix):
+ func = getattr(self, name)()
+ if func is not None:
+ verifiers.append(func)
- # test against any other available backends
+ # create verifiers for any other available backends
if hasattr(handler, "backends") and enable_option("all-backends"):
def maker(backend):
def func(secret, hash):
@@ -1604,23 +1685,41 @@ class HandlerCase(TestCase):
func.__doc__ = backend + "-backend"
return func
cur = handler.get_backend()
- check_default.__doc__ = cur + "-backend"
for backend in handler.backends:
if backend != cur and handler.has_backend(backend):
verifiers.append(maker(backend))
+ return verifiers
+
+ def fuzz_verifier_default(self):
+ # test against self
+ def check_default(secret, hash, **ctx):
+ return self.do_verify(secret, hash, **ctx)
+ if self.backend:
+ check_default.__doc__ = self.backend + "-backend"
+ else:
+ check_default.__doc__ = "self"
+ return check_default
+
+ def os_supports_ident(self, ident):
+ "skip verifier_crypt when OS doesn't support ident"
+ return True
+
+ def fuzz_verifier_crypt(self):
# test againt OS crypt()
# NOTE: skipping this if using_patched_crypt since _has_crypt_support()
# will return false positive in that case.
- if not self.using_patched_crypt and _has_crypt_support(handler):
- from crypt import crypt
- def check_crypt(secret, hash):
- "stdlib-crypt"
- secret = to_native_str(secret, self.fuzz_password_encoding)
- return crypt(secret, hash) == hash
- verifiers.append(check_crypt)
-
- return verifiers
+ handler = self.handler
+ if self.using_patched_crypt or not _has_crypt_support(handler):
+ return None
+ from crypt import crypt
+ def check_crypt(secret, hash):
+ "stdlib-crypt"
+ if not self.os_supports_ident(hash):
+ return "skip"
+ secret = to_native_str(secret, self.fuzz_password_encoding)
+ return crypt(secret, hash) == hash
+ return check_crypt
def get_fuzz_password(self):
"generate random passwords (for fuzz testing)"
@@ -1672,11 +1771,8 @@ class HandlerCase(TestCase):
return rng.choice(handler.ident_values)
#=========================================================
- # test 8x - mixin tests
- # test 9x - handler-specific tests
- #=========================================================
-
- #=========================================================
+ # test 8x - mixin tests
+ # test 9x - handler-specific tests
# eoc
#=========================================================
@@ -1699,9 +1795,6 @@ class OsCryptMixin(HandlerCase):
# encodeds as os.platform prefixes.
platform_crypt_support = dict()
- # TODO: test that os_crypt support is detected correct on the expected
- # platofrms.
-
#=========================================================
# instance attrs
#=========================================================
@@ -1746,20 +1839,27 @@ class OsCryptMixin(HandlerCase):
#=========================================================
# custom tests
#=========================================================
- def test_80_faulty_crypt(self):
- "test with faulty crypt()"
- # patch safe_crypt to return mock value.
+ def _use_mock_crypt(self):
+ "patch safe_crypt() so it returns mock value"
import passlib.utils as mod
- self.addCleanup(setattr, mod, "_crypt", mod._crypt)
+ if not self.using_patched_crypt:
+ self.addCleanup(setattr, mod, "_crypt", mod._crypt)
crypt_value = [None]
mod._crypt = lambda secret, config: crypt_value[0]
+ def setter(value):
+ crypt_value[0] = value
+ return setter
- # prepare framework
+ def test_80_faulty_crypt(self):
+ "test with faulty crypt()"
hash = self.get_sample_hash()[1]
exc_types = (AssertionError,)
+ setter = self._use_mock_crypt()
def test(value):
- crypt_value[0] = value
+ # set safe_crypt() to return specified value, and
+ # make sure assertion error is raised by handler.
+ setter(value)
self.assertRaises(exc_types, self.do_genhash, "stub", hash)
self.assertRaises(exc_types, self.do_encrypt, "stub")
self.assertRaises(exc_types, self.do_verify, "stub", hash)
@@ -1768,8 +1868,27 @@ class OsCryptMixin(HandlerCase):
test(hash[:-1]) # detect too short
test(hash + 'x') # detect too long
- def test_81_crypt_support(self):
- "test crypt support detection"
+ def test_81_crypt_fallback(self):
+ "test per-call crypt() fallback"
+ # set safe_crypt to return None
+ setter = self._use_mock_crypt()
+ setter(None)
+ if _find_alternate_backend(self.handler, "os_crypt"):
+ # handler should have a fallback to use
+ h1 = self.do_encrypt("stub")
+ h2 = self.do_genhash("stub", h1)
+ self.assertEqual(h2, h1)
+ self.assertTrue(self.do_verify("stub", h1))
+ else:
+ # handler should give up
+ from passlib.exc import MissingBackendError
+ 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)
+
+ def test_82_crypt_support(self):
+ "test platform-specific crypt() support detection"
platform = sys.platform
for name, flag in self.platform_crypt_support.items():
if not platform.startswith(name):
diff --git a/passlib/utils/__init__.py b/passlib/utils/__init__.py
index 8eecec3..4ba4be8 100644
--- a/passlib/utils/__init__.py
+++ b/passlib/utils/__init__.py
@@ -2,6 +2,7 @@
#=============================================================================
#imports
#=============================================================================
+from passlib.utils.compat import PYPY, JYTHON, IRONPYTHON
#core
from base64 import b64encode, b64decode
from codecs import lookup as _lookup_codec
@@ -11,17 +12,24 @@ import math
import os
import sys
import random
-from passlib.utils.compat import PYPY, IRONPYTHON, JYTHON
-_ipy_missing_stringprep = False
-if IRONPYTHON:
+if JYTHON or IRONPYTHON:
+ # Jython 2.5.2 lacks stringprep module -
+ # see http://bugs.jython.org/issue1758320
+ #
+ # IronPython also lacks stringprep module
try:
import stringprep
except ImportError:
- _ipy_missing_stringprep = True
+ stringprep = None
+ if JYTHON:
+ _stringprep_missing_reason = "not present under Jython"
+ else:
+ _stringprep_missing_reason = "not present under IronPython"
else:
import stringprep
import time
-import unicodedata
+if stringprep:
+ import unicodedata
from warnings import warn
#site
#pkg
@@ -87,10 +95,6 @@ __all__ = [
# constants
#=================================================================================
-# Python VM identification
-PYPY = hasattr(sys, "pypy_version_info")
-JYTHON = sys.platform.startswith('java')
-
# bitsize of system architecture (32 or 64)
sys_bits = int(math.log(sys.maxsize if PY3 else sys.maxint, 2) + 1.5)
@@ -134,30 +138,46 @@ class classproperty(object):
"py3 compatible alias"
return self.im_func
-def deprecated_function(msg=None, deprecated=None, removed=None, updoc=True):
+def deprecated_function(msg=None, deprecated=None, removed=None, updoc=True,
+ replacement=None, _is_method=False):
"""decorator to deprecate a function.
:arg msg: optional msg, default chosen if omitted
:kwd deprecated: release where function was first deprecated
:kwd removed: release where function will be removed
+ :kwd replacement: name/instructions for replacement function.
:kwd updoc: add notice to docstring (default ``True``)
"""
if msg is None:
- msg = "the function %(mod)s.%(name)s() is deprecated"
+ if _is_method:
+ msg = "the method %(mod)s.%(klass)s.%(name)s() is deprecated"
+ else:
+ msg = "the function %(mod)s.%(name)s() is deprecated"
if deprecated:
msg += " as of Passlib %(deprecated)s"
if removed:
msg += ", and will be removed in Passlib %(removed)s"
+ if replacement:
+ msg += ", use %s instead" % replacement
msg += "."
def build(func):
- final = msg % dict(
+ kwds = dict(
mod=func.__module__,
name=func.__name__,
deprecated=deprecated,
removed=removed,
- )
+ )
+ if _is_method:
+ state = [None]
+ else:
+ state = [msg % kwds]
def wrapper(*args, **kwds):
- warn(final, DeprecationWarning, stacklevel=2)
+ text = state[0]
+ if text is None:
+ klass = args[0].__class__
+ kwds.update(klass=klass.__name__, mod=klass.__module__)
+ text = state[0] = msg % kwds
+ warn(text, DeprecationWarning, stacklevel=2)
return func(*args, **kwds)
update_wrapper(wrapper, func)
if updoc and (deprecated or removed) and wrapper.__doc__:
@@ -170,50 +190,63 @@ def deprecated_function(msg=None, deprecated=None, removed=None, updoc=True):
return wrapper
return build
-def relocated_function(target, msg=None, name=None, deprecated=None, mod=None,
- removed=None, updoc=True):
- """constructor to create alias for relocated function.
+def deprecated_method(msg=None, deprecated=None, removed=None, updoc=True,
+ replacement=None):
+ """decorator to deprecate a method.
- :arg target: import path to target
:arg msg: optional msg, default chosen if omitted
:kwd deprecated: release where function was first deprecated
:kwd removed: release where function will be removed
+ :kwd replacement: name/instructions for replacement method.
:kwd updoc: add notice to docstring (default ``True``)
"""
- target_mod, target_name = target.rsplit(".",1)
- if mod is None:
- import inspect
- mod = inspect.currentframe(1).f_globals["__name__"]
- if not name:
- name = target_name
- if msg is None:
- msg = ("the function %(mod)s.%(name)s() has been moved to "
- "%(target_mod)s.%(target_name)s(), the old location is deprecated")
- if deprecated:
- msg += " as of Passlib %(deprecated)s"
- if removed:
- msg += ", and will be removed in Passlib %(removed)s"
- msg += "."
- msg %= dict(
- mod=mod,
- name=name,
- target_mod=target_mod,
- target_name=target_name,
- deprecated=deprecated,
- removed=removed,
- )
- state = [None]
- def wrapper(*args, **kwds):
- warn(msg, DeprecationWarning, stacklevel=2)
- func = state[0]
- if func is None:
- module = __import__(target_mod, fromlist=[target_name], level=0)
- func = state[0] = getattr(module, target_name)
- return func(*args, **kwds)
- wrapper.__module__ = mod
- wrapper.__name__ = name
- wrapper.__doc__ = msg
- return wrapper
+ return deprecated_function(msg, deprecated, removed, updoc, replacement,
+ _is_method=True)
+
+##def relocated_function(target, msg=None, name=None, deprecated=None, mod=None,
+## removed=None, updoc=True):
+## """constructor to create alias for relocated function.
+##
+## :arg target: import path to target
+## :arg msg: optional msg, default chosen if omitted
+## :kwd deprecated: release where function was first deprecated
+## :kwd removed: release where function will be removed
+## :kwd updoc: add notice to docstring (default ``True``)
+## """
+## target_mod, target_name = target.rsplit(".",1)
+## if mod is None:
+## import inspect
+## mod = inspect.currentframe(1).f_globals["__name__"]
+## if not name:
+## name = target_name
+## if msg is None:
+## msg = ("the function %(mod)s.%(name)s() has been moved to "
+## "%(target_mod)s.%(target_name)s(), the old location is deprecated")
+## if deprecated:
+## msg += " as of Passlib %(deprecated)s"
+## if removed:
+## msg += ", and will be removed in Passlib %(removed)s"
+## msg += "."
+## msg %= dict(
+## mod=mod,
+## name=name,
+## target_mod=target_mod,
+## target_name=target_name,
+## deprecated=deprecated,
+## removed=removed,
+## )
+## state = [None]
+## def wrapper(*args, **kwds):
+## warn(msg, DeprecationWarning, stacklevel=2)
+## func = state[0]
+## if func is None:
+## module = __import__(target_mod, fromlist=[target_name], level=0)
+## func = state[0] = getattr(module, target_name)
+## return func(*args, **kwds)
+## wrapper.__module__ = mod
+## wrapper.__name__ = name
+## wrapper.__doc__ = msg
+## return wrapper
class memoized_property(object):
"""decorator which invokes method once, then replaces attr with result"""
@@ -317,7 +350,7 @@ def consteq(left, right):
return result == 0
@deprecated_function(deprecated="1.6", removed="1.8")
-def splitcomma(source, sep=","):
+def splitcomma(source, sep=","): # pragma: no cover
"""split comma-separated string into list of elements,
stripping whitespace and discarding empty elements.
"""
@@ -350,6 +383,11 @@ def saslprep(source, errname="value"):
:returns:
normalized unicode string
+
+ .. note::
+
+ Due to a missing :mod:`!stringprep` module, this feature
+ is not available on Jython.
"""
# saslprep - http://tools.ietf.org/html/rfc4013
# stringprep - http://tools.ietf.org/html/rfc3454
@@ -439,12 +477,12 @@ def saslprep(source, errname="value"):
return data
-# implement stub for ironpython
-if _ipy_missing_stringprep:
+# replace saslprep() with stub when stringprep is missing
+if stringprep is None:
def saslprep(source, errname="value"):
- "ironpython stub for saslprep()"
- raise NotImplementedError("saslprep() requires the stdlib 'stringprep' "
- "module, which is not available under IronPython")
+ "stub for saslprep()"
+ raise NotImplementedError("saslprep() support requires the 'stringprep' "
+ "module, which is " + _stringprep_missing_reason)
#=============================================================================
# bytes helpers
@@ -480,7 +518,7 @@ def render_bytes(source, *args):
# NOTE: deprecating bytes<->int in favor of just using struct module.
@deprecated_function(deprecated="1.6", removed="1.8")
-def bytes_to_int(value):
+def bytes_to_int(value): # pragma: no cover
"decode string of bytes as single big-endian integer"
from passlib.utils.compat import byte_elem_value
out = 0
@@ -489,7 +527,7 @@ def bytes_to_int(value):
return out
@deprecated_function(deprecated="1.6", removed="1.8")
-def int_to_bytes(value, count):
+def int_to_bytes(value, count): # pragma: no cover
"encodes integer into single big-endian byte string"
assert value < (1<<(8*count)), "value too large for %d bytes: %d" % (count, value)
return join_byte_values(
@@ -592,15 +630,15 @@ def to_unicode(source, source_encoding="utf-8", errname="value"):
* returns unicode strings unchanged.
* returns bytes strings decoded using *source_encoding*
"""
+ assert source_encoding
if isinstance(source, unicode):
return source
elif isinstance(source, bytes):
- assert source_encoding
return source.decode(source_encoding)
else:
raise ExpectedStringError(source, errname)
-if PY3 or IRONPYTHON:
+if PY3:
def to_native_str(source, encoding="utf-8", errname="value"):
if isinstance(source, bytes):
return source.decode(encoding)
@@ -639,7 +677,7 @@ add_doc(to_native_str,
""")
@deprecated_function(deprecated="1.6", removed="1.7")
-def to_hash_str(source, encoding="ascii"):
+def to_hash_str(source, encoding="ascii"): # pragma: no cover
"deprecated, use to_native_str() instead"
return to_native_str(source, encoding, errname="hash")
@@ -713,7 +751,7 @@ class Base64Engine(object):
if isinstance(charmap, unicode):
charmap = charmap.encode("latin-1")
elif not isinstance(charmap, bytes):
- raise TypeError("charmap must be unicode/bytes string")
+ raise ExpectedStringError(charmap, "charmap")
if len(charmap) != 64:
raise ValueError("charmap must be 64 characters in length")
if len(set(charmap)) != 64:
@@ -1160,8 +1198,7 @@ class Base64Engine(object):
:returns:
a string of length ``int(ceil(bits/6.0))``.
"""
- if value < 0:
- raise ValueError("value cannot be negative")
+ assert value >= 0, "caller did not sanitize input"
pad = -bits % 6
bits += pad
if self.big:
@@ -1313,22 +1350,14 @@ else:
if isinstance(secret, bytes):
# Python 3's crypt() only accepts unicode, which is then
# encoding using utf-8 before passing to the C-level crypt().
- # so we have to decode the secret, but also check that it
- # re-encodes to the original sequence of bytes... otherwise
- # the call to crypt() will digest the wrong value.
+ # so we have to decode the secret.
orig = secret
try:
secret = secret.decode("utf-8")
except UnicodeDecodeError:
return None
- if secret.encode("utf-8") != orig:
- # just in case original encoding wouldn't be reproduced
- # during call to os_crypt. not sure if/how this could
- # happen, but being paranoid.
- from passlib.exc import PasslibRuntimeWarning
- warn("utf-8 password didn't re-encode correctly!",
- PasslibRuntimeWarning)
- 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):
diff --git a/passlib/utils/compat.py b/passlib/utils/compat.py
index 3d7b012..5b413cd 100644
--- a/passlib/utils/compat.py
+++ b/passlib/utils/compat.py
@@ -85,7 +85,7 @@ __all__ = [
'print_',
# type detection
- 'is_mapping',
+## 'is_mapping',
'callable',
'int_types',
'num_types',
@@ -289,9 +289,9 @@ else:
#=============================================================================
# typing
#=============================================================================
-def is_mapping(obj):
- # non-exhaustive check, enough to distinguish from lists, etc
- return hasattr(obj, "items")
+##def is_mapping(obj):
+## # non-exhaustive check, enough to distinguish from lists, etc
+## return hasattr(obj, "items")
if (3,0) <= sys.version_info < (3,2):
# callable isn't dead, it's just resting
@@ -454,7 +454,7 @@ class _LazyOverlayModule(ModuleType):
attrs.update(self.__dict__)
attrs.update(self.__attrmap)
proxy = self.__proxy
- if proxy:
+ if proxy is not None:
attrs.update(dir(proxy))
return list(attrs)
diff --git a/passlib/utils/des.py b/passlib/utils/des.py
index 4172a2e..25f1d0d 100644
--- a/passlib/utils/des.py
+++ b/passlib/utils/des.py
@@ -1,4 +1,5 @@
-"""
+"""passlib.utils.des -- DES block encryption routines
+
History
=======
These routines (which have since been drastically modified for python)
@@ -32,8 +33,7 @@ The copyright & license for that source is as follows::
@version $Id: UnixCrypt2.txt,v 1.1.1.1 2005/09/13 22:20:13 christos Exp $
@author Greg Wilkins (gregw)
-netbsd des-crypt implementation,
-which has some nice notes on how this all works -
+The netbsd des-crypt implementation has some nice notes on how this all works -
http://fxr.googlebit.com/source/lib/libcrypt/crypt.c?v=NETBSD-CURRENT
"""
@@ -45,7 +45,10 @@ which has some nice notes on how this all works -
# core
import struct
# pkg
-from passlib.utils.compat import bytes, join_byte_values, byte_elem_value, irange, irange
+from passlib import exc
+from passlib.utils.compat import bytes, join_byte_values, byte_elem_value, \
+ b, irange, irange, int_types
+from passlib.utils import deprecated_function
# local
__all__ = [
"expand_des_key",
@@ -54,30 +57,29 @@ __all__ = [
]
#=========================================================
-#precalculated iteration ranges & constants
+# constants
#=========================================================
-R8 = irange(8)
-RR8 = irange(7, -1, -1)
-RR4 = irange(3, -1, -1)
-RR12_1 = irange(11, 1, -1)
-RR9_1 = irange(9,-1,-1)
-RR6_S2 = irange(6, -1, -2)
-RR14_S2 = irange(14, -1, -2)
-R16_S2 = irange(0, 16, 2)
+# masks/upper limits for various integer sizes
+INT_24_MASK = 0xffffff
+INT_56_MASK = 0xffffffffffffff
+INT_64_MASK = 0xffffffffffffffff
-INT_24_MAX = 0xffffff
-INT_64_MAX = 0xffffffff
-INT_64_MAX = 0xffffffffffffffff
+# mask to clear parity bits from 64-bit key
+_KDATA_MASK = 0xfefefefefefefefe
+_KPARITY_MASK = 0x0101010101010101
-uint64_struct = struct.Struct(">Q")
+# mask used to setup key schedule
+_KS_MASK = 0xfcfcfcfcffffffff
#=========================================================
-# static tables for des
+# static DES tables
#=========================================================
-PCXROT = IE3264 = SPE = CF6464 = None #placeholders filled in by load_tables
-def load_tables():
+# placeholders filled in by _load_tables()
+PCXROT = IE3264 = SPE = CF6464 = None
+
+def _load_tables():
"delay loading tables until they are actually needed"
global PCXROT, IE3264, SPE, CF6464
@@ -558,10 +560,14 @@ def load_tables():
0x0000000004040000, 0x0000000004040004, 0x0000000004040400, 0x0000000004040404, ),
)
#=========================================================
- #eof load_data
+ # eof _load_tables()
#=========================================================
-def permute(c, p):
+#=========================================================
+# support
+#=========================================================
+
+def _permute(c, p):
"""Returns the permutation of the given 32-bit or 64-bit code with
the specified permutation table."""
#NOTE: only difference between 32 & 64 bit permutations
@@ -573,104 +579,213 @@ def permute(c, p):
return out
#=========================================================
-#des frontend
+# packing & unpacking
+#=========================================================
+_uint64_struct = struct.Struct(">Q")
+
+_BNULL = b('\x00')
+
+def _pack64(value):
+ return _uint64_struct.pack(value)
+
+def _unpack64(value):
+ return _uint64_struct.unpack(value)[0]
+
+def _pack56(value):
+ return _uint64_struct.pack(value)[1:]
+
+def _unpack56(value):
+ return _uint64_struct.unpack(_BNULL+value)[0]
+
+#=========================================================
+# 56->64 key manipulation
#=========================================================
+
+##def expand_7bit(value):
+## "expand 7-bit integer => 7-bits + 1 odd-parity bit"
+## # parity calc adapted from 32-bit even parity alg found at
+## # http://graphics.stanford.edu/~seander/bithacks.html#ParityParallel
+## assert 0 <= value < 0x80, "value out of range"
+## return (value<<1) | (0x9669 >> ((value ^ (value >> 4)) & 0xf)) & 1
+
+_EXPAND_ITER = irange(49,-7,-7)
+
def expand_des_key(key):
- "convert 7 byte des key to 8 byte des key (by adding parity bit every 7 bits)"
- if not isinstance(key, bytes):
- raise TypeError("key must be bytes, not %s" % (type(key),))
+ "convert DES from 7 bytes to 8 bytes (by inserting empty parity bits)"
+ if isinstance(key, bytes):
+ if len(key) != 7:
+ raise ValueError("key must be 7 bytes in size")
+ elif isinstance(key, int_types):
+ if key < 0 or key > INT_56_MASK:
+ raise ValueError("key must be 56-bit non-negative integer")
+ return _unpack64(expand_des_key(_pack56(key)))
+ else:
+ raise exc.ExpectedTypeError(key, "bytes or int", "key")
+ key = _unpack56(key)
+ # NOTE: this function would insert correctly-valued parity bits in each key,
+ # but the parity bit would just be ignored in des_encrypt_block(),
+ # so not bothering to use it.
+ ##return join_byte_values(expand_7bit((key >> shift) & 0x7f)
+ # for shift in _EXPAND_ITER)
+ return join_byte_values(((key>>shift) & 0x7f)<<1 for shift in _EXPAND_ITER)
+
+def shrink_des_key(key):
+ "convert DES key from 8 bytes to 7 bytes (by discarding the parity bits)"
+ if isinstance(key, bytes):
+ if len(key) != 8:
+ raise ValueError("key must be 8 bytes in size")
+ return _pack56(shrink_des_key(_unpack64(key)))
+ elif isinstance(key, int_types):
+ if key < 0 or key > INT_64_MASK:
+ raise ValueError("key must be 64-bit non-negative integer")
+ else:
+ raise exc.ExpectedTypeError(key, "bytes or int", "key")
+ key >>= 1
+ result = 0
+ offset = 0
+ while offset < 56:
+ result |= (key & 0x7f)<<offset
+ key >>= 8
+ offset += 7
+ assert not (result & ~INT_64_MASK)
+ return result
- #NOTE: could probably do this much more cleverly and efficiently,
- # but no need really given it's use.
+#=========================================================
+# des encryption
+#=========================================================
+def des_encrypt_block(key, input, salt=0, rounds=1):
+ """encrypt single block of data using DES, operates on 8-byte strings.
- #NOTE: the parity bits are generally ignored, including by des_encrypt_block below
- assert len(key) == 7
+ :arg key:
+ DES key as 7 byte string, or 8 byte string with parity bits
+ (parity bit values are ignored).
- def iter_bits(source):
- for c in source:
- v = byte_elem_value(c)
- for i in irange(7,-1,-1):
- yield (v>>i) & 1
+ :arg input:
+ plaintext block to encrypt, as 8 byte string.
- out = 0
- p = 1
- for i, b in enumerate(iter_bits(key)):
- out = (out<<1) + b
- p ^= b
- if i % 7 == 6:
- out = (out<<1) + p
- p = 1
-
- return join_byte_values(
- ((out>>s) & 0xFF)
- for s in irange(8*7,-8,-8)
- )
+ :arg salt:
+ optional 24-bit integer used to mutate the base DES algorithm in a
+ manner specific to :class:`~passlib.hash.des_crypt` and it's variants:
+
+ for each bit ``i`` which is set in the salt value,
+ bits ``i`` and ``i+24`` are swapped in the DES E-box output.
+ the default (``salt=0``) provides the normal DES behavior.
-def des_encrypt_block(key, input):
- """do traditional encryption of a single DES block
+ :arg rounds:
+ optional number of rounds of to apply the DES key schedule.
+ the default (``rounds=1``) provides the normal DES behavior,
+ but :class:`~passlib.hash.des_crypt` and it's variants use
+ alternate rounds values.
- :arg key: 8 byte des key
- :arg input: 8 byte plaintext
- :returns: 8 byte ciphertext
+ :raises TypeError: if any of the provided args are of the wrong type.
+ :raises ValueError:
+ if any of the input blocks are the wrong size,
+ or the salt/rounds values are out of range.
- all values must be :class:`bytes`
+ :returns:
+ resulting 8-byte ciphertext block.
"""
- if not isinstance(key, bytes):
- raise TypeError("key must be bytes, not %s" % (type(key),))
- if len(key) == 7:
- key = expand_des_key(key)
- if not isinstance(input, bytes):
- raise TypeError("input must be bytes, not %s" % (type(input),))
- input = uint64_struct.unpack(input)[0]
- key = uint64_struct.unpack(key)[0]
- out = mdes_encrypt_int_block(key, input, 0, 1)
- return uint64_struct.pack(out)
-
-def mdes_encrypt_int_block(key, input, salt=0, rounds=1):
- """do modified multi-round DES encryption of single DES block.
-
- the function implements the salted, variable-round version
- of DES used by :class:`~passlib.hash.des_crypt` and related variants.
- it also can perform regular DES encryption
- by using ``salt=0, rounds=1`` (the default values).
-
- :arg key: 8 byte des key as integer
- :arg input: 8 byte plaintext block as integer
- :arg salt: integer 24 bit salt, used to mutate output (defaults to 0)
- :arg rounds: number of rounds of DES encryption to apply (defaults to 1)
-
- The salt is used to to mutate the normal DES encrypt operation
- by swapping bits ``i`` and ``i+24`` in the DES E-Box output
- if and only if bit ``i`` is set in the salt value. Thus,
- if the salt is set to ``0``, normal DES encryption is performed.
+ # validate & unpack key
+ if isinstance(key, bytes):
+ if len(key) == 7:
+ key = expand_des_key(key)
+ elif len(key) != 8:
+ raise ValueError("key must be 7 or 8 bytes")
+ key = _unpack64(key)
+ else:
+ raise exc.ExpectedTypeError(key, "bytes", "key")
+
+ # validate & unpack input
+ if isinstance(input, bytes):
+ if len(input) != 8:
+ raise ValueError("input block must be 8 bytes")
+ input = _unpack64(input)
+ else:
+ raise exc.ExpectedTypeError(input, "bytes", "input")
+
+ # hand things off to other func
+ result = des_encrypt_int_block(key, input, salt, rounds)
+
+ # repack result
+ return _pack64(result)
+
+def des_encrypt_int_block(key, input, salt=0, rounds=1):
+ """encrypt single block of data using DES, operates on 64-bit integers.
+
+ this function is essentially the same as :func:`des_encrypt_block`,
+ except that it operates on integers, and will NOT automatically
+ expand 56-bit keys if provided (since there's no way to detect them).
+
+ :arg key:
+ DES key as 64-bit integer (the parity bits are ignored).
+
+ :arg input:
+ input block as 64-bit integer
+
+ :arg salt:
+ optional 24-bit integer used to mutate the base DES algorithm.
+ defaults to ``0`` (no mutation applied).
+
+ :arg rounds:
+ optional number of rounds of to apply the DES key schedule.
+ defaults to ``1``.
+
+ :raises TypeError: if any of the provided args are of the wrong type.
+ :raises ValueError:
+ if any of the input blocks are the wrong size,
+ or the salt/rounds values are out of range.
:returns:
- resulting block as 8 byte integer
+ resulting ciphertext as 64-bit integer.
"""
+ #-------------------------------------------------------------------
+ # input validation
+ #-------------------------------------------------------------------
+
+ # validate salt, rounds
+ if rounds < 1:
+ raise ValueError("rounds must be positive integer")
+ if salt < 0 or salt > INT_24_MASK:
+ raise ValueError("salt must be 24-bit non-negative integer")
+
+ # validate & unpack key
+ if not isinstance(key, int_types):
+ raise exc.ExpectedTypeError(key, "int", "key")
+ elif key < 0 or key > INT_64_MASK:
+ raise ValueError("key must be 64-bit non-negative integer")
+
+ # validate & unpack input
+ if not isinstance(input, int_types):
+ raise exc.ExpectedTypeError(input, "int", "input")
+ elif input < 0 or input > INT_64_MASK:
+ raise ValueError("input must be 64-bit non-negative integer")
+
+ #-------------------------------------------------------------------
+ # DES setup
+ #-------------------------------------------------------------------
+ # load tables if not already done
global SPE, PCXROT, IE3264, CF6464
+ if PCXROT is None:
+ _load_tables()
- #bounds check
- assert 0 <= input <= INT_64_MAX, "input value out of range"
- assert 0 <= salt <= INT_24_MAX, "salt value out of range"
- assert rounds >= 0, "rounds out of range"
- assert 0 <= key <= INT_64_MAX, "key value out of range"
+ # load SPE into local vars to speed things up and remove an array access call
+ SPE0, SPE1, SPE2, SPE3, SPE4, SPE5, SPE6, SPE7 = SPE
- #load tables if not already done
- if PCXROT is None:
- load_tables()
+ # NOTE: parity bits are ignored completely
+ # (UTs do fuzz testing to ensure this)
- #convert key int -> key schedule
- #NOTE: generation was modified to output two elements at a time,
- #to optimize for per-round algorithm below.
- mask = ~0x0303030300000000
- def _gen(K):
+ # generate key schedule
+ # NOTE: generation was modified to output two elements at a time,
+ # so that per-round loop could do two passes at once.
+ def _iter_key_schedule(ks_odd):
+ "given 64-bit key, iterates over the 8 (even,odd) key schedule pairs"
for p_even, p_odd in PCXROT:
- K1 = permute(K, p_even)
- K = permute(K1, p_odd)
- yield K1 & mask, K & mask
- ks_list = list(_gen(key))
+ ks_even = _permute(ks_odd, p_even)
+ ks_odd = _permute(ks_even, p_odd)
+ yield ks_even & _KS_MASK, ks_odd & _KS_MASK
+ ks_list = list(_iter_key_schedule(key))
- #expand 24 bit salt -> 32 bit
+ # expand 24 bit salt -> 32 bit per des_crypt & bsdi_crypt
salt = (
((salt & 0x00003f) << 26) |
((salt & 0x000fc0) << 12) |
@@ -678,26 +793,25 @@ def mdes_encrypt_int_block(key, input, salt=0, rounds=1):
((salt & 0xfc0000) >> 16)
)
- #init L & R
+ # init L & R
if input == 0:
L = R = 0
else:
L = ((input >> 31) & 0xaaaaaaaa) | (input & 0x55555555)
- L = permute(L, IE3264)
+ L = _permute(L, IE3264)
R = ((input >> 32) & 0xaaaaaaaa) | ((input >> 1) & 0x55555555)
- R = permute(R, IE3264)
-
- #load SPE into local vars to speed things up and remove an array access call
- SPE0, SPE1, SPE2, SPE3, SPE4, SPE5, SPE6, SPE7 = SPE
+ R = _permute(R, IE3264)
- #run specified number of passed
+ #-------------------------------------------------------------------
+ # main DES loop - run for specified number of rounds
+ #-------------------------------------------------------------------
while rounds:
rounds -= 1
- #run over each part of the schedule, 2 parts at a time
+ # run over each part of the schedule, 2 parts at a time
for ks_even, ks_odd in ks_list:
- k = ((R>>32) ^ R) & salt #use the salt to alter specific bits
+ k = ((R>>32) ^ R) & salt # use the salt to flip specific bits
B = (k<<32) ^ k ^ R ^ ks_even
L ^= (SPE0[(B>>58)&0x3f] ^ SPE1[(B>>50)&0x3f] ^
@@ -705,7 +819,7 @@ def mdes_encrypt_int_block(key, input, salt=0, rounds=1):
SPE4[(B>>26)&0x3f] ^ SPE5[(B>>18)&0x3f] ^
SPE6[(B>>10)&0x3f] ^ SPE7[(B>>2)&0x3f])
- k = ((L>>32) ^ L) & salt #use the salt to alter specific bits
+ k = ((L>>32) ^ L) & salt # use the salt to flip specific bits
B = (k<<32) ^ k ^ L ^ ks_odd
R ^= (SPE0[(B>>58)&0x3f] ^ SPE1[(B>>50)&0x3f] ^
@@ -716,6 +830,9 @@ def mdes_encrypt_int_block(key, input, salt=0, rounds=1):
# swap L and R
L, R = R, L
+ #-------------------------------------------------------------------
+ # return final result
+ #-------------------------------------------------------------------
C = (
((L>>3) & 0x0f0f0f0f00000000)
|
@@ -725,10 +842,16 @@ def mdes_encrypt_int_block(key, input, salt=0, rounds=1):
|
((R<<1) & 0x00000000f0f0f0f0)
)
-
- C = permute(C, CF6464)
-
- return C
+ return _permute(C, CF6464)
+
+def mdes_encrypt_int_block(key, input, salt=0, rounds=1): # pragma: no cover
+ warn("mdes_encrypt_int_block() has been deprecated as of Passlib 1.6,"
+ "and will be removed in Passlib 1.8, use des_encrypt_int_block instead.")
+ if isinstance(key, bytes):
+ if len(key) == 7:
+ key = expand_des_key(key)
+ key = _unpack64(key)
+ return des_encrypt_int_block(key, input, salt, rounds)
#=========================================================
#eof
diff --git a/passlib/utils/handlers.py b/passlib/utils/handlers.py
index fbf7b69..eb2b7d3 100644
--- a/passlib/utils/handlers.py
+++ b/passlib/utils/handlers.py
@@ -19,11 +19,11 @@ from passlib.exc import MissingBackendError, PasslibConfigWarning, \
from passlib.registry import get_crypt_handler
from passlib.utils import classproperty, consteq, getrandstr, getrandbytes,\
BASE64_CHARS, HASH64_CHARS, rng, to_native_str, \
- is_crypt_handler, deprecated_function, to_unicode, \
+ is_crypt_handler, to_unicode, \
MAX_PASSWORD_SIZE
from passlib.utils.compat import b, join_byte_values, bytes, irange, u, \
uascii_to_str, join_unicode, unicode, str_to_uascii, \
- join_unicode, base_string_types
+ join_unicode, base_string_types, PY2, int_types
# local
__all__ = [
# helpers for implementing MCF handlers
@@ -173,7 +173,7 @@ def parse_mc3(hash, prefix, sep=_UDOLLAR, rounds_base=10,
elif rounds:
rounds = int(rounds, rounds_base)
elif default_rounds is None:
- raise exc.MalformedHashError(handler, "missing rounds field")
+ raise exc.MalformedHashError(handler, "empty rounds field")
else:
rounds = default_rounds
@@ -411,15 +411,15 @@ class GenericHandler(object):
# NOTE: no clear route to reasonbly convert unicode -> raw bytes,
# so relaxed does nothing here
if not isinstance(checksum, bytes):
- raise TypeError("checksum must be byte string")
+ raise exc.ExpectedTypeError(checksum, "bytes", "checksum")
elif not isinstance(checksum, unicode):
- if self.relaxed:
+ if isinstance(checksum, bytes) and self.relaxed:
warn("checksum should be unicode, not bytes",
PasslibHashWarning)
checksum = checksum.decode("ascii")
else:
- raise TypeError("checksum must be unicode string")
+ raise exc.ExpectedTypeError(checksum, "unicode", "checksum")
# handle stub
if checksum == self._stub_checksum:
@@ -960,14 +960,14 @@ class HasSalt(GenericHandler):
# check type
if self._salt_is_bytes:
if not isinstance(salt, bytes):
- raise TypeError("salt must be specified as bytes")
+ raise exc.ExpectedTypeError(salt, "bytes", "salt")
else:
if not isinstance(salt, unicode):
- # XXX: should we disallow bytes here?
- if isinstance(salt, bytes):
+ # NOTE: allowing bytes under py2 so salt can be native str.
+ if isinstance(salt, bytes) and (PY2 or self.relaxed):
salt = salt.decode("ascii")
else:
- raise TypeError("salt must be specified as unicode")
+ raise exc.ExpectedTypeError(salt, "unicode", "salt")
# check charset
sc = self.salt_chars
@@ -1138,8 +1138,8 @@ class HasRounds(GenericHandler):
% (self.name,))
# check type
- if not isinstance(rounds, int):
- raise TypeError("rounds must be an integer")
+ if not isinstance(rounds, int_types):
+ raise exc.ExpectedTypeError(rounds, "integer", "rounds")
# check bounds
mn = self.min_rounds
diff --git a/passlib/utils/md4.py b/passlib/utils/md4.py
index cd3d012..4389a09 100644
--- a/passlib/utils/md4.py
+++ b/passlib/utils/md4.py
@@ -242,14 +242,14 @@ def _has_native_md4():
try:
h = hashlib.new("md4")
except ValueError:
- #not supported
+ # not supported - ssl probably missing (e.g. ironpython)
return False
result = h.hexdigest()
if result == '31d6cfe0d16ae931b73c59d7e0c089c0':
return True
if PYPY and result == '':
- #as of 1.5, pypy md4 just returns null!
- #since this is expected, don't bother w/ warning.
+ # as of pypy 1.5-1.7, this returns empty string!
+ # since this is expected, don't bother w/ warning.
return False
#anything else should alert user
from passlib.exc import PasslibRuntimeWarning
diff --git a/setup.py b/setup.py
index d5814c1..a1598a0 100644
--- a/setup.py
+++ b/setup.py
@@ -134,7 +134,7 @@ else:
#=========================================================
#run setup
#=========================================================
-# XXX: could omit 'passlib.setup' from eggs, but not sdist
+# XXX: could omit 'passlib._setup' from eggs, but not sdist
setup(
#package info
packages = [
@@ -144,6 +144,7 @@ setup(
"passlib.handlers",
"passlib.tests",
"passlib.utils",
+ "passlib.utils._blowfish",
"passlib._setup",
],
package_data = { "passlib.tests": ["*.cfg"] },
diff --git a/tox.ini b/tox.ini
index 1490ea0..ea8e2bb 100644
--- a/tox.ini
+++ b/tox.ini
@@ -1,52 +1,92 @@
[tox]
-envlist = py27,py32,py25,py26,py31,pypy15,pypy16,jython,gae
+envlist = py27,py32,py25,py26,py31,pypy15,pypy16,pypy17,jython,gae25,gae27
+#===========================================================================
+# stock CPython VMs
+#===========================================================================
[testenv]
-setenv =
+setenv =
PASSLIB_TESTS = all
+ PASSLIB_TESTS_FUZZ_TIME = 20
changedir = {envdir}
-commands =
+commands =
nosetests passlib.tests
-
deps =
nose
unittest2
[testenv:py27]
-deps =
+deps =
nose
unittest2
py-bcrypt
bcryptor
[testenv:py31]
-deps =
- nose
- unittest2py3k
+deps =
+ nose
+ unittest2py3k
[testenv:py32]
-deps =
- nose
- unittest2py3k
+deps =
+ nose
+ unittest2py3k
+#===========================================================================
+# PyPy VM - all target Python 2.7
+#===========================================================================
[testenv:pypy15]
basepython = pypy1.5
[testenv:pypy16]
basepython = pypy1.6
-[testenv:gae]
-# NOTE: annoyingly, have to use --without-sandbox
-# or else nose / nosegae / GAE / virtualenv don't play nice.
-# need to figure out what's the matter, and submit a patch.
-# might just have to write a python script that sets everything
-# up and runs nose manually
+[testenv:pypy17]
+basepython = pypy1.7
+setenv =
+ PASSLIB_TESTS = all
+ PASSLIB_TESTS_FUZZ_TIME = 20
+ PASSLIB_BUILTIN_BCRYPT = enable # only place this isn't punitively slow
+
+#===========================================================================
+# Jython - no special directives, currently same as py25
+#===========================================================================
+
+#===========================================================================
+# Google App Engine
+#===========================================================================
+[testenv:gae25]
basepython = python2.5
-deps =
+deps =
nose
- nosegae
+ # FIXME: getting all kinds of errors when using nosegae 0.2.0 :(
+ nosegae==0.1.9
unittest2
changedir = {envdir}/lib/python2.5/site-packages
commands =
- cp {toxinidir}/admin/gae-test-app.yaml app.yaml
- nosetests --with-gae --without-sandbox passlib/tests
+ # setup custom app.yaml so GAE can run
+ python -m passlib.tests.tox_support . python
+
+ # have to run without sandbox for now, something in nose+GAE+virtualenv
+ # won't play nice with eachother.
+ nosetests --with-gae --without-sandbox passlib/tests
+
+[testenv:gae27]
+basepython = python2.7
+deps =
+ nose
+ # FIXME: getting all kinds of errors when using nosegae 0.2.0 :(
+ nosegae==0.1.9
+ unittest2
+changedir = {envdir}/lib/python2.7/site-packages
+commands =
+ # setup custom app.yaml so GAE can run
+ python -m passlib.tests.tox_support . python27
+
+ # have to run without sandbox for now, something in nose/GAE/virtualenv
+ # won't play nice with eachother.
+ nosetests --with-gae --without-sandbox passlib/tests
+
+#===========================================================================
+# EOF
+#===========================================================================