diff options
author | Eli Collins <elic@assurancetechnologies.com> | 2012-04-17 23:14:51 -0400 |
---|---|---|
committer | Eli Collins <elic@assurancetechnologies.com> | 2012-04-17 23:14:51 -0400 |
commit | 64ab6fc89b497efa9169f11d55251e417c4db0ba (patch) | |
tree | b3f6f5dc27b87a6bc90cb3686fa98239ee8ff053 | |
parent | 8eb4c4d3b58eec6802c698ddbf357b2fd243a68c (diff) | |
parent | cd029846fdc0c3d7ffc7f53caad4579e7e0e8725 (diff) | |
download | passlib-ironpython-support-dev.tar.gz |
Merge from defaultironpython-support-dev
55 files changed, 7070 insertions, 3325 deletions
@@ -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 @@ -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 Binary files differnew file mode 100644 index 0000000..c58ce0e --- /dev/null +++ b/passlib/tests/sample1c.cfg 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 @@ -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"] }, @@ -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 +#=========================================================================== |